本文將討論一些自定義視圖、控件的訣竅和技巧。我們先概述一下 UIKit 向我們提供的控件,并介紹一些渲染技巧。隨后我們會深入到視圖和其所有者之間的通信策略,并簡略探討輔助功能,本地化和測試。
如果你觀察一下 UIView 的子類,可以發(fā)現(xiàn) 3 個基類: reponders
(響應者),views
(視圖)和 controls
(控件)。我們快速重溫一下它們之間發(fā)生了什么。
UIResponder
是 UIView
的父類。responder
能夠處理觸摸、手勢、遠程控制等事件。之所以它是一個單獨的類而沒有合并到 UIView
中,是因為 UIResponder
有更多的子類,最明顯的就是 UIApplication
和 UIViewController
。通過重寫 UIResponder
的方法,可以決定一個類是否可以成為第一響應者 (first responder),例如當前輸入焦點元素。
當 touches (觸摸) 或 motion (指一系列運動傳感器) 等交互行為發(fā)生時,它們被發(fā)送給第一響應者 (通常是一個視圖)。如果第一響應者沒有處理,則該行為沿著響應鏈到達視圖控制器,如果行為仍然沒有被處理,則繼續(xù)傳遞給應用。如果想監(jiān)測晃動手勢,可以根據(jù)需要在這3層中的任意位置處理。
UIResponder
還允許自定義輸入方法,從 inputAccessoryView
向鍵盤添加輔助視圖到使用 inputView
提供一個完全自定義的鍵盤。
UIView
子類處理所有跟內(nèi)容繪制有關的事情以及觸摸時間。只要寫過 "Hello, World" 應用的人都知道視圖,但我們重申一些技巧點:
一個普遍錯誤的概念:視圖的區(qū)域是由它的 frame 定義的。實際上 frame 是一個派生屬性,是由 center
和 bounds
合成而來。不使用 Auto Layout 時,大多數(shù)人使用 frame 來改變視圖的位置和大小。小心些,官方文檔特別詳細說明了一個注意事項:
如果 transform 屬性不是 identity transform 的話,那么這個屬性的值是未定義的,因此應該將其忽略
另一個允許向視圖添加交互的方法是使用手勢識別。注意它們對 responders 并不起作用,而只對視圖及其子類奏效。
UIControl
建立在視圖上,增加了更多的交互支持。最重要的是,它增加了 target / action 模式??匆幌戮唧w的子類,我們可以看一下按鈕,日期選擇器 (Date pickers),文本框等等。創(chuàng)建交互控件時,你通常想要子類化一個 UIControl
。一些常見的像 bar buttons (雖然也支持 target / action) 和 text view (這里需要你使用代理來獲得通知) 的類其實并不是 UIControl
。
現(xiàn)在,我們轉(zhuǎn)向可見部分:自定義渲染。正如 Daniel 在他的文章中提到的,你可能想避免在 CPU 上做渲染而將其丟給 GPU。這里有一條經(jīng)驗:盡量避免 drawRect:
,使用現(xiàn)有的視圖構建自定義視圖。
通常最快速的渲染方法是使用圖片視圖。例如,假設你想畫一個帶有邊框的圓形頭像,像下面圖片中這樣:
http://wiki.jikexueyuan.com/project/objc/images/3-11.png" alt="" />
為了實現(xiàn)這個,我們用以下的代碼創(chuàng)建了一個圖片視圖的子類:
// called from initializer
- (void)setupView
{
self.clipsToBounds = YES;
self.layer.cornerRadius = self.bounds.size.width / 2;
self.layer.borderWidth = 3;
self.layer.borderColor = [UIColor darkGrayColor].CGColor;
}
我鼓勵各位讀者深入了解 CALayer
及其屬性,因為你用它能實現(xiàn)的大多數(shù)事情會比用 Core Graphics 自己畫要快。然而一如既往,監(jiān)測自己的代碼的性能是十分重要的。
把可拉伸的圖片和圖片視圖一起使用也可以極大的提高效率。在 Taming UIButton 這個帖子中,Reda Lemeden 探索了幾種不同的繪圖方法。在文章結尾處有一個很有價值的來自 UIKit 團隊的工程師 Andy Matuschak 的回復,解釋了可拉伸圖片是這些技術中最快的。原因是可拉伸圖片在 CPU 和 GPU 之間的數(shù)據(jù)轉(zhuǎn)移量最小,并且這些圖片的繪制是經(jīng)過高度優(yōu)化的。
處理圖片時,你也可以讓 GPU 為你工作來代替使用 Core Graphics。使用 Core Image,你不必用 CPU 做任何的工作就可以在圖片上建立復雜的效果。你可以直接在 OpenGL 上下文上直接渲染,所有的工作都在 GPU 上完成。
如果決定了采用自定義繪制,有幾種不同的選項可供選擇。如果可能的話,看看是否可以生成一張圖片并在內(nèi)存和磁盤上緩存起來。如果內(nèi)容是動態(tài)的,也許你可以使用 Core Animation,如果還是行不通,使用 Core Graphics。如果你真的想要接近底層,使用 GLKit 和原生 OpenGL 也不是那么難,但是需要做很多工作。
如果你真的選擇了重寫 drawRect:
,確保檢查內(nèi)容模式。默認的模式是將內(nèi)容縮放以填充視圖的范圍,這在當視圖的 frame 改變時并不會重新繪制。
正如之前所說的,自定義控件的時候,你幾乎一定會擴展一個 UIControl 的子類。在你的子類里,可以使用 target action 機制觸發(fā)事件,如下面的例子:
[self sendActionsForControlEvents:UIControlEventValueChanged];
為了響應觸摸,你可能更傾向于使用手勢識別。然而如果想要更接近底層,仍然可以重寫 touchesBegan
, touchesMoved
和 touchesEnded
方法來訪問原始的觸摸行為。但雖說如此,創(chuàng)建一個手勢識別的子類來把手勢處理相關的邏輯從你的視圖或者視圖控制器中分離出來,在很多情況下都是一種更合適的方式。
創(chuàng)建自定義控件時所面對的一個普遍的設計問題是向擁有它們的類中回傳返回值。比如,假設你創(chuàng)建了一個繪制交互餅狀圖的自定義控件,想知道用戶何時選擇了其中一個部分。你可以用很多種不同的方法來解決這個問題,比如通過 target action 模式,代理,block 或者 KVO,甚至通知。
經(jīng)典學院派的,通常也是最方便的做法是使用 target-action。在用戶選擇后你可以在自定義的視圖中做類似這樣的事情:
[self sendActionsForControlEvents:UIControlEventValueChanged];
如果有一個視圖控制器在管理這個視圖,需要這樣做:
- (void)setupPieChart
{
[self.pieChart addTarget:self
action:@selector(updateSelection:)
forControlEvents:UIControlEventValueChanged];
}
- (void)updateSelection:(id)sender
{
NSLog(@"%@", self.pieChart.selectedSector);
}
這么做的好處是在自定義視圖子類中需要做的事情很少,并且自動獲得多目標支持。
如果你需要更多的控制從視圖發(fā)送到視圖控制器的消息,通常使用代理模式。在我們的餅狀圖中,代碼看起來大概是這樣:
[self.delegate pieChart:self didSelectSector:self.selectedSector];
在視圖控制器中,你要寫如下代碼:
@interface MyViewController <PieChartDelegate>
...
- (void)setupPieChart
{
self.pieChart.delegate = self;
}
- (void)pieChart:(PieChart*)pieChart didSelectSector:(PieChartSector*)sector
{
// 處理區(qū)塊
}
當你想要做更多復雜的工作而不僅僅是通知所有者值發(fā)生了變化時,這么做顯然更合適。不過雖然大多數(shù)開發(fā)人員可以非??焖俚膶崿F(xiàn)自定義代理,但這種方式仍然有一些缺點:你必須檢查代理是否實現(xiàn)了你想要調(diào)用的方法 (使用 respondsToSelector:
),最重要的,通常你只有一個代理 (或者需要創(chuàng)建一個代理數(shù)組)。也就是說,一旦視圖所有者和視圖之間的通信變得稍微復雜,我們幾乎總是會采取這種模式。
另一個選擇是使用 block。再一次用餅狀圖舉例,代碼看起來大概是這樣:
@interface PieChart : UIControl
@property (nonatomic,copy) void(^selectionHandler)(PieChartSection* selectedSection);
@end
在選取行為的代碼中,你只需要執(zhí)行它。在此之前檢查一下block是否被賦值非常重要,因為執(zhí)行一個未被賦值的 block 會使程序崩潰。
if (self.selectionHandler != NULL) {
self.selectionHandler(self.selectedSection);
}
這種方法的好處是可以把相關的代碼整合在視圖控制器中:
- (void)setupPieChart
{
self.pieChart.selectionHandler = ^(PieChartSection* section) {
// 處理區(qū)塊
}
}
就像代理,每個動作通常只有一個 block。另一個重要的限制是不要形成引用循環(huán)。如果你的視圖控制器持有餅狀圖的強引用,餅狀圖持有 block,block 又持有視圖控制器,就形成了一個引用循環(huán)。只要在 block 中引用 self 就會造成這個錯誤。所以通常代碼會寫成這個樣子:
__weak id weakSelf = self;
self.pieChart.selectionHandler = ^(PieChartSection* section) {
MyViewController* strongSelf = weakSelf;
[strongSelf handleSectionChange:section];
}
一旦 block 中的代碼要失去控制 (比如 block 中要處理的事情太多,導致 block 中的代碼過多),你還應該將它們抽離成獨立的方法,這種情況的話可能用代理會更好一些。
如果喜歡 KVO,你也可以用它來觀察。這有一點神奇而且沒那么直接,但當應用中已經(jīng)使用,它是很好的解耦設計模式。在餅狀圖類中,編寫代碼:
self.selectedSegment = theNewSelectedSegment;
當使用合成屬性,KVO 會攔截到該變化并發(fā)出通知。在視圖控制器中,編寫類似的代碼:
- (void)setupPieChart
{
[self.pieChart addObserver:self forKeyPath:@"selectedSegment" options:0 context:NULL];
}
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context
{
if(object == self.pieChart && [keyPath isEqualToString:@"selectedSegment"]) {
// 處理改變
}
}
根據(jù)你的需要,在 viewWillDisappear:
或 dealloc
中,還需要移除觀察者。對同一個對象設置多個觀察者很容易造成混亂。有一些技術可以解決這個問題,比如 ReactiveCocoa 或者更輕量級的 THObserversAndBinders
。
作為最后一個選擇,如果你想要一個非常松散的耦合,可以使用通知來使其他對象得知變化。對于餅狀圖來說你幾乎肯定不想這樣,不過為了講解的完整,這里介紹如何去做。在餅狀圖的的頭文件中:
extern NSString* const SelectedSegmentChangedNotification;
在實現(xiàn)文件中:
NSString* const SelectedSegmentChangedNotification = @"selectedSegmentChangedNotification";
...
- (void)notifyAboutChanges
{
[[NSNotificationCenter defaultCenter] postNotificationName:SelectedSegmentChangedNotification object:self];
}
現(xiàn)在訂閱通知,在視圖控制器中:
- (void)setupPieChart
{
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(segmentChanged:)
name:SelectedSegmentChangedNotification
object:self.pieChart];
}
...
- (void)segmentChanged:(NSNotification*)note
{
}
當添加了觀察者,你可以不將餅狀圖作為參數(shù) object
,而是傳遞 nil
,以接收所有餅狀圖對象發(fā)出的通知。就像 KVO 通知,你也需要在恰當?shù)牡胤酵擞嗊@些通知。
這項技術的好處是完全的解耦。另一方面,你失去了類型安全,因為在回調(diào)中你得到的是一個通知對象,而不像代理,編譯器無法檢查通知發(fā)送者和接受者之間的類型是否匹配。
蘋果官方提供的標準 iOS 控件均有輔助功能。這也是推薦用標準控件創(chuàng)建自定義控件的另一個原因。
這或許可以作為一整期的主題,但是如果你想編寫自定義視圖,Accessibility Programming Guide 說明了如何創(chuàng)建輔助控制器。最為值得注意的是,如果有一個視圖中有多個需要輔助功能的元素,但它們并不是該視圖的子視圖,你可以讓視圖實現(xiàn) UIAccessibilityContainer
協(xié)議。對于每一個元素,返回一個描述它的 UIAccessibilityElement
對象。
創(chuàng)建自定義視圖時,本地化也同樣重要。像輔助功能一樣,這個可以作為一整期的話題。本地化自定義視圖的最直接工作就是字符串內(nèi)容。如果使用 NSString
,你不必擔心編碼問題。如果在自定義視圖中展示日期或數(shù)字,使用日期和數(shù)字格式化類來展示它們。使用 NSLocalizedString
本地化字符串。
另一個本地化過程中很有用的工具是 Auto Layout。例如,有在英文中很短的詞在德語中可能會很長。如果根據(jù)英文單詞的長度對視圖的尺寸做硬編碼,那么當翻譯成德文的時候幾乎一定會遇上麻煩。通過使用 Auto Layout,讓標簽控件自動調(diào)整為內(nèi)容的尺寸,并向依賴元素添加一些其他的限制以確保重新設置尺寸,使這項工作變得非常簡單。蘋果為此提供了一個很好的 介紹。另外,對于類似希伯來語這種順序從右到左的語言,如果你使用了 leading 和 trailing 屬性,整個視圖會自動按照從右到左的順序展示,而不是硬編碼的從左至右。
最后,讓我們考慮測試視圖的問題。對于單元測試,你可以使用 Xcode 自帶的工具或者其它第三方框架。另外,可以使用 UIAutomation 或者其它基于它的工具。為此,你的視圖完全支持輔助功能是必要的。UIAutomation 并未充分得到利用的一個功能是截圖;你可以用它自動對比視圖和設計以確保兩者每一個像素都分毫不差。(插一個無關的小提示:你還可以使用它來為應用上架 App Store 自動生成截圖,這在你有多個多國語言的應用時會特別有用)。