適配器(Adapter)模式
適配器可以讓一些接口不兼容的類一起工作。它包裝一個對象然後暴漏一個標准的交互接口。
如果你熟悉適配器設計模式,蘋果通過一個稍微不同的方式來實現它-蘋果使用了協議的方式來實現。你可能已經熟悉UITableViewDelegate, UIScrollViewDelegate, NSCoding 和 NSCopying協議。舉個例子,使用NSCopying協議,任何類都可以提供一個標准的copy方法。
如何使用適配器模式
前面提到的水平滾動視圖如下圖所示:
為了開始實現它,在工程導航視圖中右鍵點擊View組,選擇New File...使用iOS\Cocoa Touch\Objective-C class 模板創建一個類。命名這個新類為HorizontalScroller,並且設置它是UIView的子類。
打開HorizontalScroller.h文件,在@end 行後面插入如下代碼:
Objective-c代碼
@protocolHorizontalScrollerDelegate <NSObject>
// methods declaration goes in here
@end
上面的代碼定義了一個名為HorizontalScrollerDelegate的協議,它采用Objective-C 類繼承父類的方式繼承自NSObject協議。去遵循NSObject協議或者遵循一個本身實現了NSObject協議的類 是一條最佳實踐,這使得你可以給HorizontalScroller的委托發送NSObject定義的消息。你不久會意識到為什麼這樣做是重要的。
在@protocol和@end之間,你定義了委托必須實現以及可選的方法。所以增加下面的方法:
Objective-c代碼
@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之間增加如下的語句:
Objective-c代碼
@property (weak) id<HorizontalScrollerDelegate> delegate;
- (void)reload;
這裡你聲明屬性為weak.這樣做是為了防止循環引用。如果一個類強引用它的委托,它的委托也強引用那個類,那麼你的app將會出現內存洩露,因為任何一個類都不能釋放調分配給另一個類的內存。
id意味著delegate屬性可以用任何遵從HorizontalScrollerDelegate的類賦值,這樣可以保障一定的類型安全。
reload方法在UITableView的reloadData方法之後被調用,它重新加載所有的數據去構建水平滾動視圖。
用如下的代碼取代HorizontalScroller.m的內容:
Objective-c代碼
#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的實例。
下一步你需要實現初始化器。增加下面的代碼:
Objective-c代碼
- (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的委托。
現在,增加下面的代碼:
Objective-c代碼
- (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:消息。在退出循環之前,將被點擊的視圖放置到滾動視圖的中間。
現在增加下面的代碼去重新加載滾動視圖:
Objective-c代碼
- (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方法。增加下面的代碼來實現後面一種場景:
Objective-c代碼
- (void)didMoveToSuperview
{
[self reload];
}
didMoveToSuperview方法會在視圖被增加到另外一個視圖作為子視圖的時候調用,這正式重新加載滾動視圖的最佳時機。
最後我們需要確保所有你正在浏覽的專輯數據總是在滾動視圖的中間。為了這樣做,當用戶的手指拖動滾動視圖的時候,你將需要做一些計算。
再一次在HorizontalScroller.m中增加如下方法:
Objective-c代碼
- (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方法:
Objective-c代碼
- (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 增加下面的導入語句:
Objective-c代碼
#import "HorizontalScroller.h"
#import "AlbumView.h"
增加HorizontalScrollerDelegate協議為ViewController遵循的協議:
Objective-c代碼
@interfaceViewController ()<UITableViewDataSource, UITableViewDelegate, HorizontalScrollerDelegate>
在類的擴展中增加下面的實例變量:
HorizontalScroller *scroller;
現在你可以實現委托方法;你可能會感到驚訝,因為只需要幾行代碼就可以實現大量的功能啦。
在ViewController.m中增加下面的代碼:
Objective-c代碼
#pragma mark - HorizontalScrollerDelegate methods
- (void)horizontalScroller:(HorizontalScroller *)scroller clickedViewAtIndex:(int)index
{
currentAlbumIndex = index;
[self showDataForAlbumAtIndex:index];
}
它設置保存當前專輯數據的變量,然後調用showDataForAlbumAtIndex:方法顯示專輯數據。
注意:在#pragma mark 指令後面寫方法代碼是一種通用的實踐。c 編譯器會忽略調這些行,但是如果你通過Xcode的彈出框的時候,你將看到這些指令會幫你把代碼組織成有獨立和粗體標題的組。這可以幫你使得你的代碼更方便在Xcode中導航。
接下來,增加下面的代碼:
Objective-c代碼
- (NSInteger)numberOfViewsForHorizontalScroller:(HorizontalScroller*)scroller
{
return allAlbums.count;
}
正如你意識到的,這個是返回滾動視圖所有子視圖數量的協議方法。因為滾動視圖要顯示所有專輯的封面,這個數量就是專輯記錄的數量。
現在,增加下面的代碼:
Objective-c代碼
- (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。
夠了,僅僅三個簡短的方法就可以顯示一個漂亮的水平滾動視圖。
是的,你任然需要創建滾動視圖,並且把它增加到你的主視圖中,但是在這樣做之前,你增加下面的方法先:
Objective-c代碼
- (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]之前增加下面的代碼來初始化滾動視圖:
Objective-c代碼
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];語句之後加入如下代碼:
Objective-c代碼
[[NSNotificationCenterdefaultCenter] postNotificationName:@"BLDownloadImageNotification"
object:self
userInfo:@{@"imageView":coverImage, @"coverUrl":albumCover}];
這行代碼通過NSNotificationCenter單例發送了一個通知。這個通知包含了UIImageView和需要下載的封面URL,這些是你下載任務所需要的所有信息。
在LibraryAPI.m文件init方法的isOnline=NO之後,增加如下的代碼:
Objective-c代碼
[[NSNotificationCenterdefaultCenter] addObserver:self selector:@selector(downloadImage:) name:@"BLDownloadImageNotification" object:nil];
這個是觀察者模式中兩部分的另外一部分:觀察者。每次AlbumView發送一個BLDownloadImageNotification通知,因為LibraryAPI已經注冊為同樣的通知的觀察者,那麼系統就會通知LibraryAPI,LibraryAPI又會調用downloadImage:來響應。
然而在你實現downloadImage:方法之前,你必須在你的對象銷毀的時候,退訂所有之前訂閱的通知。如果你不能正確的退訂的話,一個通知發送給一個已經銷毀的對象會導致你的app崩潰。
在Library.m中增加下面的代碼:
Objective-c代碼
- (void)dealloc
{
[[NSNotificationCenterdefaultCenter] removeObserver:self];
}
當對象被銷毀的時候,它將移除所有監聽通知的觀察者。
還有一件事情需要去做,將已經下載的封面圖片本地存儲起來是個不錯的主意,這樣可以避免每次都重新下載相同的封面。
打開PersistencyManager.h文件,增加下面兩個方法原型:
Objective-c代碼
- (void)saveImage:(UIImage*)image filename:(NSString*)filename;
- (UIImage*)getImage:(NSString*)filename;
在PersistencyManager.m文件中,增加方法的實現:
Objective-c代碼
- (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中增加下面的方法:
Objective-c代碼
- (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保存到本地。