不同於國外,StoryBoard
從面世到如今飽受國內開發者的質疑,質疑的理由很多,什麼不利於多人協作啊,隱藏了UI細節啊,出問題不容易測試,降低執行效率啊等等。此文就是針對這些問題的舉例和剖析。
StoryBoard
和 Xib
有什麼區別?
StoryBoard
和 Xib
都是用來分離UI樣式程式碼,改善檢視程式碼重用率,增加所見即所得,降低檢視測試繁複度的檢視系列化工具,
- 其中
Xib
以檢視View
為主,StoryBoard
以控制器Controller
及其之間的關係,以及和檢視View
的關係為主。
實際使用例子參見《純Swift專案-Xib | StoryBoard 裝置適配技巧》或其他StoryBoard
文章
StoryBoard
和 Xib
不利於多人協作,git
合併程式碼容易衝突,且難以處理?
這個是詆譭StoryBoard
最多的理由,也是看上去
最充分的理由。最顯著的就是下圖這種失敗的例子。
在一個Storyboard
中,大量的Controller
控制器和Segue
連線彰顯著錯綜複雜的UI關係,使人望而生畏或者難以維護。
但這並不應該是Storyboard
的鍋,僅僅是使用者對工具的濫用!
沒錯,就是濫用
,無論是Storyboard
也好,純程式碼也罷,它們的本質都是工具,工具本身沒有正義或邪惡,影響工具的是使用者。哪怕是用純程式碼開發,如果沒有命名規範,肆意的巢狀if
,不遵守MVC或者MVVM等開發模式,不區分開發環境與生產環境,這樣寫出來的程式碼又何談可維護性,和多人協作呢?
那麼反過來說,如何使用Storyboard
才不算濫用?
避免濫用,最好的方法就是定製規範,就好像程式碼中的諸多規範一樣。每個團隊可能有自己不同的喜好,我在此拋磚引玉,列出我們團隊使用Storyboard
的規範,供大家參考。
每個模組獨立Storyboard | 每個Storyboard只應該有一個主VC和同頁的子VC,主VC不應存在2個以上 |
---|---|
- 一個專案中,Storyboard不該是孤立存在的,應該像
MVP
模式那樣,每個頁面都有獨立的Storyboard,每個Storyboard只應該有一個主VC和同頁的子VC,主VC不應存在2個以上。(絕大多數情況下,一個Storyboard上只應該有一個VC)- 頁面間的
Segue
連線應該使用Stroyboard Reference Scene
,UITabBarController
的子頁因為複雜度應該當成主VC處置- 檢視的初始樣式應儘量在Storyboard上屬性皮膚中設定,非極特殊情況,佈局也應在Storyboard上使用各種約束配合完成。這樣有利於檢視樣式和檢視程式碼分離,有利於檢視程式碼重用性和相容性提高。
- 對於邏輯複雜的VC,應新增Object物件,並繫結相應的類來分離邏輯程式碼。
- 對於圓角,背景色,陰影等
CALayer
的樣式,應該使用擴充套件或子類化例項的形式,使用@IBInspectable
屬性關鍵字,在Storyboard屬性皮膚中設定初始樣式。- 對於自定義檢視,應使用
@IBDesignable
關鍵字保障在在Storyboard上所見即所得!
使用以上原則,只要任務分工合理,基本上不存在多人同時修改同一個Storyboard
的情況,就算配合失誤偶然發生,精簡的Storyboard其程式碼量也不大,藉助檔案比較工具很容易就能處理git衝突。
說到底,臃腫的
Storyboard
和臃腫的ViewController
一樣,都是難以維護且容易git衝突的。唯一的解決方案就是有節制的使用工具。
StoryBoard
和 Xib
隱藏了UI細節,且容易導致ViewController
臃腫?
與其說StoryBoard
和 Xib
隱藏了UI細節,倒不如說蘋果是希望通過他們來引導開發者正確的使用 檢視 和 控制器 ,他們建立檢視例項的時候都是通過
required init?(coder aDecoder: NSCoder) {
}
複製程式碼
構造方法建立檢視例項。所有初始樣式都是在屬性皮膚中設定的值,通過
func setValue(_ value: Any?, forUndefinedKey key: String) {
......
}
複製程式碼
來賦值給檢視對應的屬性。
至於說導致
ViewController
臃腫,更是荒謬,StoryBoard
提供了多種方案來分離程式碼,只不過很多人不知道而已。
拿美團的主頁UI舉例
這樣的首頁較為複雜,正常佈局的話需要多個CollectionView
和一個UITableView
如果這些檢視的Delegate
都由ViewController
來實現,自然顯得臃腫且混亂。
一般手寫派會分出3個ChildViewController
來解決臃腫問題,難道Storyboard
就做不到麼?
答案是否定的,很早的版本,蘋果就給出了上圖中的解決方案。一個佔位的容器檢視指向子控制器的Embed Segue
按住Control
鍵連線到想要包含的子控制器,佔位檢視的例項==子控制器的view
(子控制器根檢視)
選擇Embed
連線方式後,子控制器 的尺寸變化成跟佔位檢視一樣的尺寸
這樣我們可以將功能圖示的CollectionView
的程式碼放到這第一個子控制器上,CollectionViewDelegate
、CollectionViewDataSource
等程式碼也由子控制器實現
同理,優惠專區可以再新增一個Container View
,指向第二個子控制器。
通過 Container View
建立的ChildViewController
如何與主ViewController
傳參或互相呼叫?
ChildViewController
可以通過 self.parent(Swift)|| self.parentViewController(OC)來拿到主ViewController
的例項。 主ViewController
可以通過 self.chilren(Swift) || self.childViewControllers(OC)來拿到ChildViewController
的例項,它是一個陣列,順序等同於佔位檢視再檢視層次中的順序。
值得一提的是,通過此種方式建立的ChildViewController
,其構造方法晚於主ViewController
,但生命週期中的viewDidLoad
則早於主ViewController
,
因此在ChildViewController
中的viewDidLoad
方法中,self.parent 是nil
,這時不能拿到主ViewController
例項。如果需要在初始化的時候拿到主ViewController
的例項,則應該在主ViewController``viewDidLoad
方法中,呼叫ChildViewController
的特定方法,把 self 當引數傳過去。
- 除此之外還可以使用Object物件
將它新增到控制器之上。
它的本質是一個繼承自NSObject的子類,我們完全可以把它當成一個小功能模組的控制器。
class FeaturesController: NSObject, UICollectionViewDataSource, UICollectionViewDelegate {
@IBOutlet weak var collectionView:UICollectionView!
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
<#code#>
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
<#code#>
}
}
複製程式碼
在Storyboard
上選中這個Object
,繫結上面的類
Object
,在彈出的選單中連線
右鍵CollectionView
設定 Delegate 和 DataSource 等的連線
在主ViewController
中如需呼叫這個模組的方法或者傳參
class HomeController: UIViewController {
@IBOutlet weak var featuresController:FeaturesController!
override func viewDidLoad() {
super.viewDidLoad()
featuresController.datas = [....]
featuresController.collectionView.reloadData()
}
}
複製程式碼
完成連線,同理,如果一個頁面需要多個子模組,可以在Storyboard
上拖入多個Object
,並繫結不同的模組控制類,相對於佔位的Container View
和ChildViewController
方法,Object
方法在傳參或互相呼叫方面,更加簡便。缺點是沒有ChildViewController
的生命週期方法,如需使用viewWillAppear
等,需要在主ViewController
的viewWillAppear
中,呼叫Object
的自定義方法。
通過上面的2種方法不難看出,並非是Storyboard
造成ViewController
程式碼臃腫,而是因為設計不當導致,就算你不用Storyboard
,把所有功能都寫在一個ViewController
裡一樣臃腫。這都是使用者決定的,並非Storyboard
的責任!
StoryBoard
和 Xib
出了問題不容易測試?
這個問題其實問的很模糊,我也是諮詢了很多人才知道,他們所謂的問題不容易測試,是指如下兩種情況:
- 修改或刪除 @IBOutlet 的變數名時,對應的
Storyboard
上未做處理,導致執行時崩潰,崩潰內容看不懂!- 繫結的類名改變時,對應的
Storyboard
上未做處理,導致執行時崩潰,崩潰內容看不懂!
其實只要知道,蘋果是如何把Storyboard
的xml
解析成檢視,崩潰的錯誤內容也就容易看懂了
之前提到過,檢視構造使用的是下面這個方法
required init?(coder aDecoder: NSCoder) {
}
複製程式碼
如果繫結的類名改變輸出錯誤:
- Unknown class _TtC11ProjectName14HomeController in Interface Builder file. // Swift
- Unknown class HomeController in Interface Builder file. // Objective C
通過上面的錯誤提示Interface Builder file
就是指通過Storyboard
或者Xib
構建檢視或者控制器,但找不到名為HomeController
的控制器,看到這裡就應該明白,我們某個Storyboard
上繫結了名為HomeController
的控制器,但程式碼中找不到,可能是改名或者刪除了。這時可以全域性搜素一下
在搜出來的結果中可以看到,是在Main.storyboard
上繫結了HomeController
,Test.swift
檔案中定義了該類,但是因為改名所以無法找到。
這樣的問題不用Storyboard
就可以避免麼?答案是否定的,因為重構程式碼的時候,改了一處忽略它處的例子比比皆是。哪怕純程式碼也是一樣,因此,如果需要修改類名或者變數名,應該善用Xcode
的重構功能,而不是簡單的直接修改。
這樣修改類名或者變數名是,Storyboard
或者Xib
上繫結或連線的內容也會同步改變。就不會出錯了。
同理,@IBOutlet 連線的屬性通過下面的方法給檢視賦值
func setValue(_ value: Any?, forUndefinedKey key: String) {
......
}
複製程式碼
如果變數名改變的時候,會出現如下錯誤:
- *** Terminating app due to uncaught exception 'NSUnknownKeyException', reason: '[<HomeController 0x7fbd0ce20c40> setValue:forUndefinedKey:]: this class is not key value coding-compliant for the key featuresController.'
這個方法找不到對應的屬性時,就會丟擲異常, 這裡就是指找不到featuresController
屬性,通過全域性搜尋可以發現,程式碼中改了名字,
解決的方法同樣是刪掉對應的連線或者修改變數名時使用重構
由此可見,所謂的不容易測試,完全是因為重構不謹慎且對構造過程不理解,否則還是很容易定位問題且修改的。而且重構程式碼時利用Xcode重構功能
的話,連問題都不會出現
StoryBoard
和 Xib
降低執行效率?
這個問題看起來好像是那麼回事,StoryBoard
和 Xib
本質上是XML
,要解析成檢視就需要反序列化,必然沒有直接程式碼建立速度高,但這只是感覺上,實際上有多少影響呢?我們來測試一下:
var controllers:[ViewController] = []
let count = 30000
controllers.reserveCapacity(count)
guard let sb = storyboard else { return }
var beginTime = CACurrentMediaTime()
for _ in 0..<count {
let vc = sb.instantiateViewController(withIdentifier: "ViewController") as! ViewController
controllers.append(vc)
}
print("Storyboard建立\(count)次用時", CACurrentMediaTime() - beginTime)
controllers.removeAll(keepingCapacity: true)
beginTime = CACurrentMediaTime()
for _ in 0..<count {
let vc = ViewController()
controllers.append(vc)
}
print("純程式碼建立\(count)次用時", CACurrentMediaTime() - beginTime)
複製程式碼
第一次使用了3萬次,結果輸出
- Storyboard建立30000次用時 8.648092089919373
- 純程式碼建立30000次用時 27.226440161000937
我們看到了什麼?從Storyboard
建立竟然比純程式碼更快?簡直不敢相信自己的眼睛,而且差距這麼大一定是有什麼神奇的事情發生,為了驗證我的想法,我又將Storyboard
建立複製了一次
var controllers:[ViewController] = []
let count = 30000
controllers.reserveCapacity(count)
guard let sb = storyboard else { return }
var beginTime = CACurrentMediaTime()
for _ in 0..<count {
let vc = sb.instantiateViewController(withIdentifier: "ViewController") as! ViewController
controllers.append(vc)
}
print("Storyboard建立\(count)次用時", CACurrentMediaTime() - beginTime)
controllers = []
controllers.reserveCapacity(count)
beginTime = CACurrentMediaTime()
for _ in 0..<count {
let vc = ViewController()
controllers.append(vc)
}
print("純程式碼建立\(count)次用時", CACurrentMediaTime() - beginTime)
controllers = []
controllers.reserveCapacity(count)
beginTime = CACurrentMediaTime()
for _ in 0..<count {
let vc = sb.instantiateViewController(withIdentifier: "ViewController") as! ViewController
controllers.append(vc)
}
print("Storyboard建立\(count)次用時", CACurrentMediaTime() - beginTime)
複製程式碼
輸出結果如下,而且多次執行結果相近,可能是因為隨著記憶體使用率提高,電腦效能在降低,影響了結論,但不管怎麼說,大量測試空的ViewController
在這種情況下確實比純程式碼建立更快。
- Storyboard建立30000次用時 8.513293381780386
- 純程式碼建立30000次用時 27.19225306995213
- Storyboard建立30000次用時 25.9916725079529
這個結果是如何出現的,不妨大膽猜測一下,可能是由於蘋果在物件多次建立的情況下,Storyboard
可能存在快取復刻機制,來提升效率,而純程式碼並沒有這樣的優化。為了驗證猜測,我們逐漸降低數量級。
- Storyboard建立3000次用時 0.20833597797900438
- 純程式碼建立3000次用時 0.2654381438624114
- Storyboard建立3000次用時 0.34943647705949843
- Storyboard建立300次用時 0.010981905972585082
- 純程式碼建立300次用時 0.005475352052599192
- Storyboard建立300次用時 0.014193600043654442
- Storyboard建立30次用時 0.0016030301339924335
- 純程式碼建立30次用時 0.00031192018650472164
- Storyboard建立30次用時 0.001034758985042572
- Storyboard建立10次用時 0.0009886820334941149
- 純程式碼建立10次用時 0.0001325791236013174
- Storyboard建立10次用時 0.0014422889798879623
上述結果果然驗證了我們的猜測,隨著次數的減少,Storyboard
建立的速度逐漸低於存程式碼建立,但單次耗時仍然低於萬分之一秒,這種效率是不會讓使用者有任何感知的,何況重複建立比純程式碼還有優勢,因此,這一條也不算StoryBoard
和 Xib
的缺點
在 StoryBoard
和 Xib
拖動和設定約束佈局很難精確?不易修改?
我想,這種言論可能是因為不太熟悉Interface Builder
的功能和操作造成的,僅僅實驗了幾次不得其門而入就放棄了。
實際上約束佈局是一個很強大的功能,可以解決絕大多數(98%)佈局適配問題,98%
這個數並不是隨便給出的,很多人覺得達不到這個比例是因為對約束理解較少,還是按照以前的autolayoutMask的方式使用約束,因此很多佈局問題還在用程式碼計算,可實際上約束功能十分強大,目前無法通過約束直接解決,必須程式碼輔助的問題微乎其微。
但與之相對的是約束的概念較多,依賴人腦思考很容易產生遺漏,這樣在執行的時候就會各種報錯或顯示異常,因此用純程式碼寫約束,反覆執行除錯檢視樣式尺寸十分常見,而且有些頁面較深,測試起來十分麻煩。
而使用StoryBoard
或 Xib
就不同了,缺少約束或者約束衝突直接就有錯誤提示,適配不同裝置可以直接在Interface Builder
上切換測試,效率不知高了多少倍,準確性也高了很多
如果需要詳細瞭解在
StoryBoard
或Xib
上使用約束的技巧,可以參考文章《純Swift專案-Xib | StoryBoard 裝置適配技巧》及 《純Swift專案-Xib | StoryBoard 約束使用技巧》或其他相關文章。