如何設計一個 iOS 控制元件? iOS 控制元件完全解析

發表於2015-12-03

程式碼的等級:可編譯、可執行、可測試、可讀、可維護、可複用

前言

一個控制元件從外在特徵來說,主要是封裝這幾點:

  • 互動方式
  • 顯示樣式
  • 資料使用

對外在特徵的封裝,能讓我們在多種環境下達到 PM 對產品的要求,並且提到程式碼複用率,使維護工作保持在一個相對較小的範圍內;而一個好的控制元件除了有對外一致的體驗之外,還有其內在特徵:

  • 靈活性
  • 低耦合
  • 易擴充
  • 易維護

通常特徵之間需要做一些取捨,比如靈活性與耦合度,有時候介面越多越能適應各種環境,但是介面越少對外產生的依賴就越少,維護起來也更容易。通常一些前期看起來還不錯的程式碼,往往也會隨著時間加深慢慢“成長”,功能的增加也會帶來新的介面,很不自覺地就加深了耦合度,在開發中時不時地進行一些重構工作很有必要。總之,儘量減少介面的數量,但有足夠的定製空間,可以在一開始把介面全部隱藏起來,再根據實際需要慢慢放開。

自定義控制元件在 iOS 專案裡很常見,通常頁面之間入口很多,而且使用場景極有可能大不相同,比如一個 UIView 既可以以程式碼初始化,也可以以 xib 的形式初始化,而我們是需要保證這兩種操作都能產生同樣的行為。本文將會討論到以下幾點:

  • 選擇正確的初始化方式
  • 調整佈局的時機
  • 正確的處理 touches 方法
  • drawRectCALayer 與動畫
  • UIControl 與 UIButton
  • 更友好的支援 xib
  • 不規則圖形和事件觸發範圍(事件鏈的簡單介紹以及處理)
  • 合理使用 KVO

如果這些問題你一看就懂的話就不用繼續往下看了。

設計方針


選擇正確的初始化方式

UIView 的首要問題就是既能從程式碼中初始化,也能從 xib 中初始化,兩者有何不同? UIView 是支援 NSCoding 協議的,當在 xib 或 storyboard 裡存在一個 UIView 的時候,其實是將 UIView 序列化到檔案裡(xib 和 storyboard 都是以 XML 格式來儲存的),載入的時候反序列化出來,所以:

  • 當從程式碼例項化 UIView 的時候,initWithFrame 會執行;
  • 當從檔案載入 UIView 的時候,initWithCoder 會執行。

從程式碼中載入

雖然 initWithFrame 是 UIView 的Designated Initializer,理論上來講你繼承自 UIView 的任何子類,該方法最終都會被呼叫,但是有一些類在初始化的時候沒有遵守這個約定,如 UIImageViewinitWithImageUITableViewCellinitWithStyle:reuseIdentifier: 的構造器等,所以我們在寫自定義控制元件的時候,最好只假設父檢視的 Designated Initializer 被呼叫。

如果控制元件在初始化或者在使用之前必須有一些引數要設定,那我們可以寫自己的 Designated Initializer 構造器,如:

在實現中一定要呼叫父類的 Designated Initializer,而且如果你有多個自定義的 Designated Initializer,最終都應該指向一個全能的初始化構造器:

並且你要考慮到,因為你的控制元件是繼承自 UIView 或 UIControl 的,那麼使用者完全可以不使用你提供的構造器,而直接呼叫基類的構造器,所以最好重寫父類的 Designated Initializer,使它呼叫你提供的 Designated Initializer ,比如父類是個 UIView:

這樣當使用者從程式碼裡初始化你的控制元件的時候,就總是逃脫不了你需要執行的初始化程式碼了,哪怕使用者直接呼叫 init 方法,最終還是會回到父類的 Designated Initializer 上。

從 xib 或 storyboard 中載入

當控制元件從 xib 或 storyboard 中載入的時候,情況就變得複雜了,首先我們知道有 initWithCoder 方法,該方法會在物件被反序列化的時候呼叫,比如從檔案載入一個 UIView 的時候:

執行 unarchiveObjectWithData 的時候, initWithCoder 會被呼叫,那麼你有可能會在這個方法裡做一些初始化工作,比如恢復到儲存之前的狀態,當然前提是需要在 encodeWithCoder 中預先儲存下來。

不過我們很少會自己直接把一個 View 儲存到檔案中,一般是在 xib 或 storyboard 中寫一個 View,然後讓系統來完成反序列化的工作,此時在 initWithCoder 呼叫之後,awakeFromNib 方法也會被執行,既然在 awakeFromNib 方法裡也能做初始化操作,那我們如何抉擇?

一般來說要儘量在 initWithCoder 中做初始化操作,畢竟這是最合理的地方,只要你的控制元件支援序列化,那麼它就能在任何被反序列化的時候執行初始化操作,這裡適合做全域性資料、狀態的初始化工作,也適合手動新增子檢視。

awakeFromNib 相較於 initWithCoder 的優勢是:當 awakeFromNib 執行的時候,各種 IBOutlet 也都連線好了;而 initWithCoder 呼叫的時候,雖然子檢視已經被新增到檢視層級中,但是還沒有引用。如果你是基於 xib 或 storyboard 建立的控制元件,那麼你可能需要對 IBOutlet 連線的子控制元件進行初始化工作,這種情況下,你只能在 awakeFromNib 裡進行處理。同時 xib 或 storyboard 對靈活性是有打折的,因為它們建立的程式碼無法被繼承,所以當你選擇用 xib 或 storyboard 來實現一個控制元件的時候,你已經不需要對靈活性有很高的要求了,唯一要做的是要保證使用者一定是通過 xib 建立的此控制元件,否則可能是一個空的檢視,可以在 initWithFrame 裡放置一個 斷言 或者異常來通知控制元件的使用者。

最後還要注意檢視層級的問題,比如你要給 View 放置一個背景,你可能會在 initWithCoderawakeFromNib 中這樣寫:

你的本意是在控制元件的最下面放置一個背景,卻有可能將這個背景覆蓋到控制元件的最上方,原因是使用者可能會在 xib 裡寫入這個控制元件,然後往它上面新增一些子檢視,這樣一來,使用者新增的這些子檢視會在你新增背景之前先進入檢視層級,你的背景被新增後就擋住了使用者的子檢視。如果你想支援使用者的這種操作,可以把 addSubview 替換成 insertSubview:atIndex:

同時支援從程式碼和檔案中載入

如果你要同時支援 initWithFrameinitWithCoder ,那麼你可以提供一個 commonInit 方法來做統一的初始化:

awakeFromNib 方法裡就不要再去呼叫 commonInit 了。


調整佈局的時機

當一個控制元件被初始化以及開始使用之後,它的 frame 仍然可能發生變化,我們也需要接受這些變化,因為你提供的是 UIView 的介面,UIView 有很多種初始化方式:initWithFrameinitWithCoderinit 和類方法 new,使用者完全可以在初始化之後再設定 frame 屬性,而且使用者就算使用 initWithFrame 來初始化也避免不了 frame 的改變,比如在橫豎屏切換的時候。為了確保當它的 Size 發生變化後其子檢視也能同步更新,我們不能一開始就把佈局寫死(使用約束除外)。

基於 frame

如果你是直接基於 frame 來佈局的,你應該確保在初始化的時候只新增檢視,而不去設定它們的frame,把設定子檢視 frame 的過程全部放到 layoutSubviews 方法裡:

這麼做就能保證 label 總是出現在正確的位置上。

使用 layoutSubviews 方法有幾點需要注意:

  1. 不要依賴前一次的計算結果,應該總是根據當前最新值來計算
  2. 由於 layoutSubviews 方法是在自身的 bounds 發生改變的時候呼叫, 因此 UIScrollView 會在滾動時不停地呼叫,當你只關心 Size 有沒有變化的時候,可以把前一次的 Size 儲存起來,通過與最新的 Size 比較來判斷是否需要更新,在大多數情況下都能改善效能

基於 Auto Layout 約束

如果你是基於 Auto Layout 約束來進行佈局,那麼可以在 commonInit 呼叫的時候就把約束新增上去,不要重寫 layoutSubviews 方法,因為這種情況下它的預設實現就是根據約束來計算 frame。最重要的一點,把 translatesAutoresizingMaskIntoConstraints 屬性設為 NO,以免產生 NSAutoresizingMaskLayoutConstraint 約束,如果你使用 Masonry 框架的話,則不用擔心這個問題,mas_makeConstraints 方法會首先設定這個屬性為 NO:

支援 sizeToFit

如果你的控制元件對尺寸有嚴格的限定,比如有一個統一的寬高比或者是固定尺寸,那麼最好能實現系統給出的約定成俗的介面。

sizeToFit 用在基於 frame 佈局的情況下,由你的控制元件去實現 sizeThatFits: 方法:

然後在外部呼叫該控制元件的 sizeToFit 方法,這個方法內部會自動呼叫 sizeThatFits 並更新自身的 Size:

在 ViewController 裡調整檢視佈局

當執行 viewDidLoad 方法時,不要依賴 self.view 的 Size。很多人會這樣寫:

這樣是不對的,哪怕看上去沒問題也只是碰巧沒問題而已。當 viewDidLoad 方法被呼叫的時候,self.view 才剛剛被初始化,此時它的容器還沒有對它的 frame 進行設定,如果 view 是從 xib 載入的,那麼它的 Size 就是 xib 中設定的值;如果它是從程式碼載入的,那麼它的 Size 和螢幕大小有關係,除了 Size 以外,Origin 也不會準確。整個過程看起來像這樣:

當訪問 ViewController 的 view 的時候,ViewController 會先執行 loadViewIfRequired 方法,如果 view 還沒有載入,則呼叫 loadView,然後是 viewDidLoad 這個鉤子方法,最後是返回 view,容器拿到 view 後,根據自身的屬性(如 edgesForExtendedLayout、判斷是否存在 tabBar、判斷 navigationBar 是否透明等)新增約束或者設定 frame。

你至少應該設定 autoresizingMask 屬性:

或者在 viewDidLayoutSubviews 裡處理:

如果是基於 Auto Layout 來佈局,則在 viewDidLoad 裡新增約束即可。


正確的處理 touches 方法

如果你需要重寫 touches 方法,那麼應該完整的重寫這四個方法:

當你的檢視在這四個方法執行的時候,如果已經對事件進行了處理,就不要再呼叫 super 的 touches 方法,super 的 touches 方法預設實現是在響應鏈裡繼續轉發事件(UIView 的預設實現)。如果你的基類是 UIScrollView 或者 UIButton 這些已經重寫了事件處理的類,那麼當你不想處理事件的時候可以呼叫 self.nextRespondertouches 方法來轉發事件,其他的情況就呼叫 supertouches 方法來轉發,比如 UIScrollView 可以這樣來轉發 觸控 事件:

這麼實現以後,當你僅僅只是“碰”一個 UIScrollView 的時候,該事件就有可能被 nextResponder 處理。

如果你沒有實現自己的事件處理,也沒有呼叫 nextRespondersuper,那麼響應鏈就會斷掉。另外,儘量用手勢識別器去處理自定義事件,它的好處是你不需要關心響應鏈,邏輯處理起來也更加清晰,事實上,UIScrollView 也是通過手勢識別器實現的:

 


drawRect、CALayer 與動畫

drawRect 方法很適合做自定義的控制元件,當你需要更新 UI 的時候,只要用 setNeedsDisplay 標記一下就行了,這麼做又簡單又方便;控制元件也常常用於封裝動畫,但是動畫卻有可能被移除掉。

需要注意的地方:

  1. drawRect 裡儘量用 CGContext 繪製 UI。如果你用 addSubview 插入了其他的檢視,那麼當系統在每次進入繪製的時候,會先把當前的上下文清除掉(此處不考慮 clearsContextBeforeDrawing 的影響),然後你也要清除掉已有的 subviews,以免重複新增檢視;使用者可能會往你的控制元件上新增他自己的子檢視,然後在某個情況下清除所有的子檢視(我就喜歡這麼做):
  2. CALayer 代替 UIViewCALayer 節省記憶體,而且更適合去做一個“圖層”,因為它不會接收事件、也不會成為響應鏈中的一員,但是它能夠響應父檢視(或 layer)的尺寸變化,這種特性很適合做單純的資料展示:
  3. 如果有可能的話使用 setNeedsDisplayInRect 代替 setNeedsDisplay 以優化效能,但是遇到效能問題的時候應該先檢查自己的繪圖演算法和繪圖時機,我個人其實從來沒有使用過 setNeedsDisplayInRect
  4. 當你想做一個無限迴圈播放的動畫的時候,可能會建立幾個封裝了動畫的 CALayer,然後把它們新增到檢視層級上,就像我在 iOS 實現脈衝雷達以及動態增減元素 By Swift 中這麼做的:
     

    效果還不錯,實現又簡單,但是當你按下 Home 鍵並再次返回到 app 的時候,原本好看的動畫就變成了一灘死水:

    這是因為在按下 Home 鍵的時候,所有的動畫被移除了,具體的,每個 layer 都呼叫了 removeAllAnimations 方法。

    如果你想重新播放動畫,可以監聽 UIApplicationDidBecomeActiveNotification 通知,就像我在 上述部落格 中做的那樣。

  5. UIImageViewdrawRect 永遠不會被呼叫:

    Special Considerations

    The UIImageView class is optimized to draw its images to the display. UIImageView will not call drawRect: in a subclass. If your subclass needs custom drawing code, it is recommended you use UIView as the base class.

  6. UIViewdrawRect 也不一定會呼叫,我在 12 年的部落格:定製UINavigationBar 中曾經提到過 UIKit 框架的實現機制:

    眾所周知一個檢視如何顯示是取決於它的 drawRect 方法,因為調這個方法之前 UIKit 也不知道如何顯示它,但其實 drawRect 方法的目的也是畫圖(顯示內容),而且我們如果以其他的方式給出了內容(圖)的話, drawRect 方法就不會被呼叫了。

    注:實際上 UIView 是 CALayer 的delegate,如果 CALayer 沒有內容的話,會回撥給 UIView 的 displayLayer: 或者 drawLayer:inContext: 方法,UIView 在其中呼叫 drawRect ,draw 完後的圖會快取起來,除非使用 setNeedsDisplay 或是一些必要情況,否則都是使用快取的圖。

    UIViewCALayer 都是模型物件,如果我們以這種方式給出內容的話,drawRect 也就不會被呼叫了:

    我猜測是在 CALayersetContents 方法裡有個標記,無論傳入的物件是什麼都會將該標記開啟,但是呼叫 setNeedsDisplay 的時候會將該標記去除。


UIControl 與 UIButton

如果要做一個可互動的控制元件,那麼把 UIControl 作為基類就是首選,這個完美的基類支援各種狀態:

  • enabled
  • selected
  • highlighted
  • tracking
  • ……

還支援多狀態下的觀察者模式:

這個基類可以很方便地為檢視新增各種點選狀態,最常見的用法就是將 UIViewControllerview 改成 UIControl,然後就能快速實現 resignFirstResponder

UIButton 自帶圖文介面,支援更強大的狀態切換,titleEdgeInsetsimageEdgeInsets 也比較好用,配合兩個基類的屬性更好,先設定對齊規則,再設定 insets:

UIControlUIButton 都能很好的支援 xib,可以設定各種狀態下的顯示和 Selector,但是對 UIButton 來說這些並不夠,因為 NormalHighlightedNormal | Highlighted 是三種不同的狀態,如果你需要實現根據當前狀態顯示不同高亮的圖片,可以參考我下面的程式碼:
這裡寫圖片描述這裡寫圖片描述

或者使用初始化設定:

總之儘量使用原生類的介面,或者模仿原生類的介面。

大多數情況下根據你所需要的特性來選擇現有的基類就夠了,或者用 UIView + 手勢識別器 的組合也是一個好方案,儘量不要用 touches 方法(userInteractionEnabled 屬性對 touches手勢識別器的作用一樣),這是我在 DKCarouselView 中內建的一個可點選的 ImageView,也可以繼承 UIButton,不過 UIButton 更側重於狀態,ImageView 側重於圖片本身:


更友好的支援 xib

你的控制元件現在應該可以正確的從檔案、程式碼中初始化了,但是從 xib 中初始化以後可能還需要通過程式碼來進行一些設定,你或許覺得像上面那樣設定 Button 的狀態很噁心而且不夠直觀,但是也沒辦法,這是由於 xib 雖然對原生控制元件,如 UIViewUIImageViewUIScrollView 等支援較好(想設定圓角、邊框等屬性也沒辦法,只能通過 layer 來設定),但是對自定義控制元件卻沒有什麼辦法,當你拖一個 UIView 到 xib 中,然後把它的 Class 改成你自己的子類後,xib 如同一個瞎子一樣,不會有任何變化。————好在這些都成了過去。

Xcode 6 引入了兩個新的巨集:IBInspectableIBDesignable

IBInspectable

該巨集會讓 xib 識別屬性,它支援這些資料型別:布林、字串、數字(NSNumber)、 CGPoint、CGSize、CGRect、UIColor 、 NSRange 和 UIImage。

比如我們要讓自定義的 Button 能在 xib 中設定 UIControlStateSelected | UIControlStateHighlighted 狀態的圖片,就可以這麼做:

只需要在屬性上加個 IBInspectable 巨集即可,然後 xib 中就能顯示這個自定義的屬性:
這裡寫圖片描述

xib 會把屬性名以大駝峰樣式顯示,如果有多個屬性,xib 也會自動按屬性名的第一個單詞分組顯示,如:
這裡寫圖片描述

通過使用 IBInspectable 巨集,你可以把原本只能通過程式碼來設定的屬性,也放到 xib 裡來,程式碼就顯得更加簡潔了。

IBDesignable

xib 配合 IBInspectable 巨集雖然可以讓屬性設定變得簡單化,但是隻有在執行期間你才能看到控制元件的真正效果,而使用 IBDesignable 可以讓 Interface Builder 實時渲染控制元件,這一切只需要在類名加上 IBDesignable 巨集即可:

這樣一來,當你在 xib 中調整屬性的時候,畫布也會實時更新。

關於對 IBInspectable / IBDesignable 的詳細介紹可以看這裡:http://nshipster.cn/ibinspectable-ibdesignable/

這是 Twitter 上其他開發者做出的效果:
這裡寫圖片描述
這裡寫圖片描述

相信通過使用 IBInspectable / IBDesignable ,會讓控制元件使用起來更加方便、也更加有趣。


不規則圖形和事件觸發範圍

不規則圖形在 iOS 上並不多見,想來設計師也怕麻煩。不過 iOS 上的控制元件說到底都是各式各樣的矩形,就算你修改 cornerRadius,讓它看起來像這樣:
這裡寫圖片描述

也只是看起來像這樣罷了,它的實際事件觸發範圍還是一個矩形。

問題描述

想象一個複雜的可互動的控制元件,它並不是單獨工作的,可能需要和另一個控制元件互動,而且它們的事件觸發範圍可能會重疊,像這個選擇聯絡人的列表:

這裡寫圖片描述

在設計的時候讓上面二級選單在最大的範圍內可以被點選,下面的一級選單也能在自己的範圍內很好的工作,正常情況下它們的觸發範圍是這樣的:

這裡寫圖片描述

我們想要的是這樣的:

這裡寫圖片描述

想要實現這樣的效果需要對事件分發有一定的瞭解。首先我們來想想,當觸控螢幕的時候發生了什麼?

當觸控螢幕的時候發生了什麼?

當螢幕接收到一個 touch 的時候,iOS 需要找到一個合適的物件來處理事件( touch 或者手勢),要尋找這個物件,需要用到這個方法:

該方法會首先在 applicationkeyWindow 上呼叫(UIWindow 也是 UIView 的子類),並且該方法的返回值將被用來處理事件。如果這個 view(無論是 window 還是普通的 UIView) 的 userInteractionEnabled 屬性被設定為 NO,則它的 hitTest: 永遠返回 nil,這意味著它和它的子檢視沒有機會去接收和處理事件。如果 userInteractionEnabled 屬性為 YES,則會先判斷產生觸控的 point 是否發生在自己的 bounds 內,如果沒有也將返回 nil;如果 point 在自己的範圍內,則會為自己的每個子檢視呼叫 hitTest: 方法,只要有一個子檢視通過這個方法返回一個 UIView 物件,那麼整個方法就一層一層地往上返回;如果沒有子檢視返回 UIView 物件,則父檢視將會把自己返回。

所以,在事件分發中,有這麼幾個關鍵點:

  1. 如果父檢視不能響應事件(userInteractionEnabled 為 NO),則其子檢視也將無法響應事件。
  2. 如果子檢視的 frame 有一半在外面,就像這樣:
    這裡寫圖片描述 

    則在外面的部分是無法響應事件的,因為它超出了父檢視的範圍。

  3. 整個事件鏈只會返回一個 Hit-Test View 來處理事件。
  4. 子檢視的順序會影響到 Hit-Test View 的選擇:最先通過 hitTest: 方法返回的 UIView 才會被返回,假如有兩個子檢視平級,並且它們的 frame 一樣,但是誰是後新增的誰就優先返回。

瞭解了事件分發的這些特點後,還需要知道最後一件事:UIView 如何判斷產生事件的 point 是否在自己的範圍內? 答案是通過 pointInside 方法,這個方法的預設實現類似於這樣:

所以,當我們想改變一個 View 的事件觸發範圍的時候,重寫 pointInside 方法就可以了。

回到問題

針對這種檢視一定要處理它們的事件觸發範圍,也就是 pointInside 方法,一般來說,我們先判斷 point 是不是在自己的範圍內(通過呼叫 super 來判斷),然後再判斷該 point 符不符合我們的處理要求:

這個例子我用 Swift 來寫

如果你要實現非矩形的控制元件,那麼請在開發時處理好這類問題。

這裡附上一個很容易測試的小 Demo:


合理使用 KVO

某些檢視的介面比較寶貴,被你用掉後外部的使用者就無法使用了,比如 UITextFielddelegate,好在 UITextField 還提供了通知和 UITextInput 方法可以使用;像 UIScrollView 或者基於 UIScrollView 的控制元件,你既不能設定它的 delegate,又沒有其他的替代方法可以使用,對於像以下這種需要根據某些屬性實時更新的控制元件來說,KVO 真是極好的:

這是一個動態高度 Header 的例子(DKStickyHeaderView):

這個是一個固定在 Bottom 的例子(DKStickyFooterView):

兩者都是基於 UIScrollView、基於 KVO ,不依賴外部引數:

對容器類的 ViewController 來說也一樣有用。在 iOS8 之前沒有 UIContentContainer 這個正式協議,如果你要實現一個很長的、非列表、可滾動的 ViewController,那麼你可能會將其中的功能分散到幾個 ChildViewController 裡,然後把它們組合起來,這樣一來,這些 ChildViewController 既能被單獨作為一個 ViewController 展示,也可以被組合到一起。作為組合到一起的前提,就是需要一個至少有以下兩個方法的協議:

  1. 提供一個統一的輸入源,大多是一個 Model 或者像 userId 這樣的
  2. 能夠返回你所需要的高度,比如設定 preferredContentSize 屬性

ChildViewController 動態地設定 contentSize,容器監聽 contentSize 的變化動態地設定約束或者 frame。


歡迎補充和討論

相關文章