IOS設計模式之三(適配器模式,觀察者模式)
適配器(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機制讓對象可以感知到屬性的變化。在本例中,你可以使用KVO去觀察UIImageView的image屬性的變化。
打開AlbumView.m文件,在initWithFrame:albumCover:方法[self addSubview:indicator]這一行後,增加下面的程式碼:Apple’s KVO Programming Guide.
如何使用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)模式.
繼續觀看:
繼續觀看:
沒有留言:
張貼留言