IOS设计模式之三(适配器模式,观察者模式)
由 @krq_tiger(http://weibo.com/xmuzyq)翻译,如果你发现有什么错误,请与我联系谢谢。
适配器(Adapter)模式
适配器可以让一些接口不兼容的类一起工作。它包装一个对象然后暴漏一个标准的交互接口。
如果你熟悉适配器设计模式,苹果通过一个稍微不同的方式来实现它-苹果使用了协议的方式来实现。你可能已经熟悉UITableViewDelegate, UIScrollViewDelegate, NSCoding 和 NSCopying协议。举个例子,使用NSCopying协议,任何类都可以提供一个标准的copy方法。
如何使用适配器模式
前面提到的水平滚动视图如下图所示:
为了开始实现它,在工程导航视图中右键点击View组,选择New File...使用iOS\Cocoa Touch\Objective-C class 模板创建一个类。命名这个新类为HorizontalScroller,并且设置它是UIView的子类。
打开HorizontalScroller.h文件,在@end 行后面插入如下代码:
- @protocolHorizontalScrollerDelegate <NSObject>
- // methods declaration goes in here
- @end
上面的代码定义了一个名为HorizontalScrollerDelegate的协议,它采用Objective-C 类继承父类的方式继承自NSObject协议。去遵循NSObject协议或者遵循一个本身实现了NSObject协议的类 是一条最佳实践,这使得你可以给HorizontalScroller的委托发送NSObject定义的消息。你不久会意识到为什么这样做是重要的。
在@protocol和@end之间,你定义了委托必须实现以及可选的方法。所以增加下面的方法:
- @required
- // ask the delegate how many views he wants to present inside the horizontal scroller
- - (NSInteger)numberOfViewsForHorizontalScroller:(HorizontalScroller*)scroller;
- // ask the delegate to return the view that should appear at <index>
- - (UIView*)horizontalScroller:(HorizontalScroller*)scroller viewAtIndex:(int)index;
- // inform the delegate what the view at <index> has been clicked
- - (void)horizontalScroller:(HorizontalScroller*)scroller clickedViewAtIndex:(int)index;
- @optional
- // ask the delegate for the index of the initial view to display. this method is optional
- // and defaults to 0 if it's not implemented by the delegate
- - (NSInteger)initialViewIndexForHorizontalScroller:(HorizontalScroller*)scroller;
这里你既有必需的方法也有可选方法。必需的方法要求委托必须实现它,因为它提供一些必需的数据。在这里,必需的是视图的数量,指定索引位置的视图,以及用户点击视图后的行为,可选的方法是初始化视图;如果它没有实现,那么HorizontalScroller将缺省用第一个索引的视图。
下一步,你需要在HorizontalScroller类中引用新建的委托。但是委托的定义是在类的定义之后的,所以在类中它是不可见的,怎么办呢?
解决方案就是前置声明委托协议以便编译器(和Xcode)知道协议的存在。如何做?你只需要在@interface行前面增加下面的代码即可:
@protocolHorizontalScrollerDelegate;
继续在HorizontalScroller.h文件中,在@interface 和@end之间增加如下的语句:
- @property (weak) id<HorizontalScrollerDelegate> delegate;
- - (void)reload;
这里你声明属性为weak.这样做是为了防止循环引用。如果一个类强引用它的委托,它的委托也强引用那个类,那么你的app将会出现内存泄露,因为任何一个类都不能释放调分配给另一个类的内存。
id意味着delegate属性可以用任何遵从HorizontalScrollerDelegate的类赋值,这样可以保障一定的类型安全。
reload方法在UITableView的reloadData方法之后被调用,它重新加载所有的数据去构建水平滚动视图。
用如下的代码取代HorizontalScroller.m的内容:
- #import "HorizontalScroller.h"
- // 1
- #define VIEW_PADDING 10
- #define VIEW_DIMENSIONS 100
- #define VIEWS_OFFSET 100
- // 2
- @interfaceHorizontalScroller () <UIScrollViewDelegate>
- @end
- // 3
- @implementationHorizontalScroller
- {
- UIScrollView *scroller;
- }
- @end
让我们来对上面每个注释块的内容进行一一分析:
1. 定义了一系列的常量以方便在设计的时候修改视图的布局。水平滚动视图中的每个子视图都将是100*100,10点的边框的矩形.
2. HorizontalScroller遵循UIScrollViewDelegate协议。因为HorizontalScroller使用UIScrollerView去滚动专辑封面,所以它需要用户停止滚动类似的事件
3.创建了UIScrollerView的实例。
下一步你需要实现初始化器。增加下面的代码:
- - (id)initWithFrame:(CGRect)frame
- {
- self = [super initWithFrame:frame];
- if (self)
- {
- scroller = [[UIScrollView alloc] initWithFrame:CGRectMake(0, 0, frame.size.width, frame.size.height)];
- scroller.delegate = self;
- [self addSubview:scroller];
- UITapGestureRecognizer *tapRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(scrollerTapped:)];
- [scroller addGestureRecognizer:tapRecognizer];
- }
- return self;
- }
滚动视图完全充满了HorizontalScroller。UITapGestureRecognizer检测滚动视图的触摸事件,它将检测专辑封面是否被点击了。如果专辑封面被点击了,它会通知HorizontalScroller的委托。
现在,增加下面的代码:
- - (void)scrollerTapped:(UITapGestureRecognizer*)gesture
- {
- CGPoint location = [gesture locationInView:gesture.view];
- // we can't use an enumerator here, because we don't want to enumerate over ALL of the UIScrollView subviews.
- // we want to enumerate only the subviews that we added
- for (int index=0; index<[self.delegate numberOfViewsForHorizontalScroller:self]; index++)
- {
- UIView *view = scroller.subviews[index];
- if (CGRectContainsPoint(view.frame, location))
- {
- [self.delegate horizontalScroller:self clickedViewAtIndex:index];
- [scroller setContentOffset:CGPointMake(view.frame.origin.x - self.frame.size.width/2 + view.frame.size.width/2, 0) animated:YES];
- break;
- }
- }
- }
Gesture对象被当做参数传递,让你通过locationInView:导出点击的位置。
接下来,你调用了numberOfViewsForHorizontalScroller:委托方法,HorizontalScroller实例除了知道它可以安全的发送这个消息给委托之外,它不知道其它关于委托的信息,因为委托必须遵循HorizontalScrollerDelegate协议。
对于滚动视图中的每个子视图,通过CGRectContainsPoint方法发现被点击的视图。当你已经找到了被点击的视图,给委托发送horizontalScroller:clickedViewAtIndex:消息。在退出循环之前,将被点击的视图放置到滚动视图的中间。
现在增加下面的代码去重新加载滚动视图:
- - (void)reload
- {
- // 1 - nothing to load if there's no delegate
- if (self.delegate == nil) return;
- // 2 - remove all subviews
- [scroller.subviews enumerateObjectsUsingBlock:^(id obj, NSUInteger idx, BOOL *stop) {
- [obj removeFromSuperview];
- }];
- // 3 - xValue is the starting point of the views inside the scroller
- CGFloat xValue = VIEWS_OFFSET;
- for (int i=0; i<[self.delegate numberOfViewsForHorizontalScroller:self]; i++)
- {
- // 4 - add a view at the right position
- xValue += VIEW_PADDING;
- UIView *view = [self.delegate horizontalScroller:self viewAtIndex:i];
- view.frame = CGRectMake(xValue, VIEW_PADDING, VIEW_DIMENSIONS, VIEW_DIMENSIONS);
- [scroller addSubview:view];
- xValue += VIEW_DIMENSIONS+VIEW_PADDING;
- }
- // 5
- [scroller setContentSize:CGSizeMake(xValue+VIEWS_OFFSET, self.frame.size.height)];
- // 6 - if an initial view is defined, center the scroller on it
- if ([self.delegate respondsToSelector:@selector(initialViewIndexForHorizontalScroller:)])
- {
- int initialView = [self.delegate initialViewIndexForHorizontalScroller:self];
- [scroller setContentOffset:CGPointMake(initialView*(VIEW_DIMENSIONS+(2*VIEW_PADDING)), 0) animated:YES];
- }
- }
我们来一步步的分析代码中有注释的地方:
1. 如果没有委托,那么不需要做任何事情,仅仅返回即可。
2. 移除之前添加到滚动视图的子视图
3. 所有的视图的位置从给定的偏移量开始。当前的偏移量是100,它可以通过改变文件头部的#DEFINE来很容易的调整。
4. HorizontalScroller每次从委托请求视图对象,并且根据预先设置的边框来水平的放置这些视图。
5. 一旦所有视图都设置好了以后,设置UIScrollerView的内容偏移(contentOffset)以便用户可以滚动的查看所有的专辑封面。
6. HorizontalScroller检测是否委托实现了initialViewIndexForHorizontalScroller:方法,这个检测是需要的,因为这个方法是可选的。如果委托没有实现这个方法,0就是缺省值。最后设置滚动视图为协议规定的初始化视图的中间。
当数据已经发生改变的时候,你要执行reload方法。当增加HorizontalScroller到另外一个视图的时候,你也需要调用reload方法。增加下面的代码来实现后面一种场景:
- - (void)didMoveToSuperview
- {
- [self reload];
- }
didMoveToSuperview方法会在视图被增加到另外一个视图作为子视图的时候调用,这正式重新加载滚动视图的最佳时机。
最后我们需要确保所有你正在浏览的专辑数据总是在滚动视图的中间。为了这样做,当用户的手指拖动滚动视图的时候,你将需要做一些计算。
再一次在HorizontalScroller.m中增加如下方法:
- - (void)centerCurrentView
- {
- int xFinal = scroller.contentOffset.x + (VIEWS_OFFSET/2) + VIEW_PADDING;
- int viewIndex = xFinal / (VIEW_DIMENSIONS+(2*VIEW_PADDING));
- xFinal = viewIndex * (VIEW_DIMENSIONS+(2*VIEW_PADDING));
- [scroller setContentOffset:CGPointMake(xFinal,0) animated:YES];
- [self.delegate horizontalScroller:self clickedViewAtIndex:viewIndex];
- }
为了计算当前视图到中间的距离,上面的代码考虑了滚动视图当前的偏移量,视图的尺寸以及边框。最后一行代码是重要的,一当子视图被置中,你将需要将这种变化通知委托。
为了检测用户在滚动视图中的滚动,你必需增加如下的UIScrollerViewDelegate方法:
- - (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate
- {
- if (!decelerate)
- {
- [self centerCurrentView];
- }
- }
- - (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView
- {
- [self centerCurrentView];
- }
scrollViewDidEndDragging:willDecelerate:方法在用户完成拖动的时候通知委托。如果视图还没有完全的停止,那么decelerate参数为true.当滚动完全停止的时候,系统将会调用scrollViewDidEndDecelerating.在两种情况下,我们都需要调用我们新增的方法去置中当前的视图,因为当前的视图在用户拖动以后可能已经发生了变化。
你的HorizontalScroller现在已经可以使用了。浏览你刚刚写的代码,没有涉及到任何与Album或AlbumView类的信息。这个相对的棒,因为这意味着这个新的滚动视图是完全的独立和可复用的。
构建的工程确保每个资源可以正确编译。
现在HorizontalScroller完整了,是时候去在app使用它了。打开ViewController.m 增加下面的导入语句:
- #import "HorizontalScroller.h"
- #import "AlbumView.h"
增加HorizontalScrollerDelegate协议为ViewController遵循的协议:
- @interfaceViewController ()<UITableViewDataSource, UITableViewDelegate, HorizontalScrollerDelegate>
在类的扩展中增加下面的实例变量:
HorizontalScroller *scroller;
现在你可以实现委托方法;你可能会感到惊讶,因为只需要几行代码就可以实现大量的功能啦。
在ViewController.m中增加下面的代码:
- #pragma mark - HorizontalScrollerDelegate methods
- - (void)horizontalScroller:(HorizontalScroller *)scroller clickedViewAtIndex:(int)index
- {
- currentAlbumIndex = index;
- [self showDataForAlbumAtIndex:index];
- }
它设置保存当前专辑数据的变量,然后调用showDataForAlbumAtIndex:方法显示专辑数据。
注意:在#pragma mark 指令后面写方法代码是一种通用的实践。c 编译器会忽略调这些行,但是如果你通过Xcode的弹出框的时候,你将看到这些指令会帮你把代码组织成有独立和粗体标题的组。这可以帮你使得你的代码更方便在Xcode中导航。
接下来,增加下面的代码:
- - (NSInteger)numberOfViewsForHorizontalScroller:(HorizontalScroller*)scroller
- {
- return allAlbums.count;
- }
正如你意识到的,这个是返回滚动视图所有子视图数量的协议方法。因为滚动视图要显示所有专辑的封面,这个数量就是专辑记录的数量。
现在,增加下面的代码:
- - (UIView*)horizontalScroller:(HorizontalScroller*)scroller viewAtIndex:(int)index
- {
- Album *album = allAlbums[index];
- return [[AlbumView alloc] initWithFrame:CGRectMake(0, 0, 100, 100) albumCover:album.coverUrl];
- }
这里你创建了一个新的AlbumView,并且将它传递给HorizontalScroller。
够了,仅仅三个简短的方法就可以显示一个漂亮的水平滚动视图。
是的,你任然需要创建滚动视图,并且把它增加到你的主视图中,但是在这样做之前,你增加下面的方法先:
- - (void)reloadScroller
- {
- allAlbums = [[LibraryAPI sharedInstance] getAlbums];
- if (currentAlbumIndex < 0) currentAlbumIndex = 0;
- else if (currentAlbumIndex >= allAlbums.count) currentAlbumIndex = allAlbums.count-1;
- [scroller reload];
- [self showDataForAlbumAtIndex:currentAlbumIndex];
- }
这个方法通过LibraryAPI加载专辑数据,然后根据当前视图的索引设置当前显示的视图。如果当前的视图索引小于0,意味着当前没有选定任何视图,此时可以选择第一个专辑来显示,否则下面一个专辑将会显示。
现在在viewDidLoad的[self showDataForAlbumAtIndex:0]之前增加下面的代码来初始化滚动视图:
- scroller = [[HorizontalScroller alloc] initWithFrame:CGRectMake(0, 0, self.view.frame.size.width, 120)];
- scroller.backgroundColor = [UIColor colorWithRed:0.24f green:0.35f blue:0.49f alpha:1];
- scroller.delegate = self;
- [self.view addSubview:scroller];
- [self reloadScroller];
上面的代码简单的创建了一个HorizontalScroller类的实例,设置它的背景色,委托,增加它到主视图,然后加载所有子视图去显示专辑数据。
注意:如果一个协议变得特别冗长,包含太多的方法。你应该考虑将它氛围更家细粒度的协议。UITableViewDelegate 和 UITableViewDataSource是一个好的例子。因为它们都是UITableView的协议。试着设计你的协议以便每个协议都关注特定的功能。
构建并运行你的on过程,查看一下你帅气十足的水平滚动视图吧:
对了,等等。水平滚动视图没问题,但是为什么没有显示封面呢?
是的,那就对了-你还没有实现下载封面的代码。为了实现这个功能,你需要去新增一个下载图片的方法。因为所有对服务的访问都通过LibraryAPI,那我们就可以在LibraryAPI中实现新的方法。然而我们首先需要虑一些事情:
1. AlbumView不应该直接和LibraryAPI交互。你不想混淆显示逻辑和网络交互逻辑。
2. 同样的原因,LibraryAPI也不应该知道AlbumView。
3. 一旦封面已经下载,LibraryAPI需要通知AlbumView,因为AlbumView显示专辑封面。
听上去是不是挺糊涂的?不要灰心。你将学习如何使用观察者模式来实现它。
观察者(Observer)模式
在观察者模式中,一个对象任何状态的变更都会通知另外的对改变感兴趣的对象。这些对象之间不需要知道彼此的存在,这其实是一种松耦合的设计。当某个属性变化的时候,我们通常使用这个模式去通知其它对象。
此模式的通用实现中,观察者注册自己感兴趣的其它对象的状态变更事件。当状态发生变化的时候,所有的观察者都会得到通知。苹果的推送通知(Push Notification)就是一个此模式的例子。
如果你要遵从MVC模式的概念,你需要让模型对象和视图对象在不相互直接引用的情况下通信。这正是观察者模式的用武之地。
Cocoa通过通知(Notifications)和Key-Value Observing(KVO)来实现观察者模式。
通知(Notifications)
不要和远程推送以及本地通知所混淆,通知是一种基于订阅-发布模式的模型,它让发布者可以给订阅者发送消息,并且发布者不需要对订阅者有任何的了解。
通知在苹果官方被大量的使用。举例来说,当键盘弹出或者隐藏的时候,系统会独立发送UIKeyboardWillShowNotification/UIKeyboardWillHideNotification通知。当你的应用进入后台运行的时候,系统会发送一个UIApplicationDidEnterBackgroundNotification通知。
注意:打开UIApplication.h,在文件的末尾,你将看到一个由系统发出的超过20个通知组成的列表。
如何使用通知(Notifications)
打开AlbumView.m,在initWithFrame:albumCover::方法的[self addSubview:indicator];语句之后加入如下代码:
- [[NSNotificationCenterdefaultCenter] postNotificationName:@"BLDownloadImageNotification"
- object:self
- userInfo:@{@"imageView":coverImage, @"coverUrl":albumCover}];
这行代码通过NSNotificationCenter单例发送了一个通知。这个通知包含了UIImageView和需要下载的封面URL,这些是你下载任务所需要的所有信息。
在LibraryAPI.m文件init方法的isOnline=NO之后,增加如下的代码:
- [[NSNotificationCenterdefaultCenter] addObserver:self selector:@selector(downloadImage:) name:@"BLDownloadImageNotification" object:nil];
这个是观察者模式中两部分的另外一部分:观察者。每次AlbumView发送一个BLDownloadImageNotification通知,因为LibraryAPI已经注册为同样的通知的观察者,那么系统就会通知LibraryAPI,LibraryAPI又会调用downloadImage:来响应。
然而在你实现downloadImage:方法之前,你必须在你的对象销毁的时候,退订所有之前订阅的通知。如果你不能正确的退订的话,一个通知发送给一个已经销毁的对象会导致你的app崩溃。
在Library.m中增加下面的代码:
- - (void)dealloc
- {
- [[NSNotificationCenterdefaultCenter] removeObserver:self];
- }
当对象被销毁的时候,它将移除所有监听通知的观察者。
还有一件事情需要去做,将已经下载的封面图片本地存储起来是个不错的主意,这样可以避免每次都重新下载相同的封面。
打开PersistencyManager.h文件,增加下面两个方法原型:
- - (void)saveImage:(UIImage*)image filename:(NSString*)filename;
- - (UIImage*)getImage:(NSString*)filename;
在PersistencyManager.m文件中,增加方法的实现:
- - (void)saveImage:(UIImage*)image filename:(NSString*)filename
- {
- filename = [NSHomeDirectory() stringByAppendingFormat:@"/Documents/%@", filename];
- NSData *data = UIImagePNGRepresentation(image);
- [data writeToFile:filename atomically:YES];
- }
- - (UIImage*)getImage:(NSString*)filename
- {
- filename = [NSHomeDirectory() stringByAppendingFormat:@"/Documents/%@", filename];
- NSData *data = [NSDatadataWithContentsOfFile:filename];
- return [UIImage imageWithData:data];
- }
上面的代码相当直接。下载的图片会被保存在文档(Documents)目录,如果在文档目录不存在指定的文件,getImage:方法将返回nil.
现在在LibraryAPI.m中增加下面的方法:
- - (void)downloadImage:(NSNotification*)notification
- {
- // 1
- UIImageView *imageView = notification.userInfo[@"imageView"];
- NSString *coverUrl = notification.userInfo[@"coverUrl"];
- // 2
- imageView.image = [persistencyManager getImage:[coverUrl lastPathComponent]];
- if (imageView.image == nil)
- {
- // 3
- dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
- UIImage *image = [httpClient downloadImage:coverUrl];
- // 4
- dispatch_sync(dispatch_get_main_queue(), ^{
- imageView.image = image;
- [persistencyManager saveImage:image filename:[coverUrl lastPathComponent]];
- });
- });
- }
- }
下面是以上代码分段描述:
1. downloadImage方法是通过通知被执行的,所以通知对象会当作参数传递。UIImageView和图片URL都会从通知中获取。
2. 如果图片已经被下载过了,直接从PersistencyManager方法获取。
3. 如果图片还没有被下载,通过HTTPClient去获取它。
4. 当图片下载的时候,将它显示在UIImageView中,同时使用PersistencyManager保存到本地。
再一次,你使用了门面(Facade)模式隐藏了下载图片的复杂性。通知的发送者不需要关心图片是来自网络还是来自本地文件系统。
构建并运行你的应用,看看那些在滚动视图中的漂亮封面吧:
停止你的应用再一次运行它,你会注意到不会存在加载图片的延迟,因为它们都已经被保存到了本地。甚至你可以断开网络,你的应用也可以完美地运行。然而这里有点奇怪,图片上的提示转盘一直在转动,出了什么问题呢?
当开始下载图片的时候,你启动了提示图片正在加载的旋转提示器,但是你还没有实现图片下载完成后停止它的逻辑。你应该在每次图片下载完成的时候发送一个通知,但是这里你使用KVO这种观察者模式。
Key-Value Observing(KVO)模式
如何使用KVO
正如上面所说的,KVO机制让对象可以感知到属性的变化。在本例中,你可以使用KVO去观察UIImageView的image属性的变化。
打开AlbumView.m文件,在initWithFrame:albumCover:方法[self addSubview:indicator]这一行后,增加下面的代码:
- [coverImage addObserver:self forKeyPath:@"image" options:0 context:nil];
这里它增加了它自己(当前的类)作为image属性的观察者。
当完成的时候,你同样需要注销相应的观察者。仍然在AlbumView.m中增加下面的代码:
- - (void)dealloc
- {
- [coverImage removeObserver:self forKeyPath:@"image"];
- }
最后增加下面的方法:
- - (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context
- {
- if ([keyPath isEqualToString:@"image"])
- {
- [indicator stopAnimating];
- }
- }
你必须在每个观察者类中实现这个方法。系统会在被观察的属性发送变化的时候通知观察者。在上面的代码中,当image属性变化的时候,你停止了封面上面的旋转提示器。这样以来,当图片加载完后,旋转提示器将会停止。
构建并运行的你的工程。旋转提示器应该会消失:
注意:你要总是记得去移除已经销毁的观察者,否则当给不存在的观察者发送消息的时候,你的应用可能会崩溃。
如果你玩一回你的应用后终止它,你会发现你的应用状态没有被保存,你上次查看的专辑不是下次启动时候的缺省专辑。
为了修正这个问题,你可以使用列表中的下个模式:备忘录(Memento)模式.