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