純Swift專案-Xib | StoryBoard 多人協作技巧

小歪發表於2019-01-22

不同於國外,StoryBoard從面世到如今飽受國內開發者的質疑,質疑的理由很多,什麼不利於多人協作啊,隱藏了UI細節啊,出問題不容易測試,降低執行效率啊等等。此文就是針對這些問題的舉例和剖析。

StoryBoardXib 有什麼區別?

StoryBoardXib 都是用來分離UI樣式程式碼,改善檢視程式碼重用率,增加所見即所得,降低檢視測試繁複度的檢視系列化工具,

  1. 其中Xib以檢視View為主,
  2. StoryBoard 以控制器Controller及其之間的關係,以及和檢視View的關係為主。

實際使用例子參見《純Swift專案-Xib | StoryBoard 裝置適配技巧》或其他StoryBoard文章

StoryBoardXib 不利於多人協作,git合併程式碼容易衝突,且難以處理?

這個是詆譭StoryBoard最多的理由,也是看上去最充分的理由。最顯著的就是下圖這種失敗的例子。

Storyboard不利圖片

在一個Storyboard中,大量的Controller控制器和Segue連線彰顯著錯綜複雜的UI關係,使人望而生畏或者難以維護。

但這並不應該是Storyboard的鍋,僅僅是使用者對工具的濫用!

沒錯,就是濫用,無論是Storyboard也好,純程式碼也罷,它們的本質都是工具,工具本身沒有正義或邪惡,影響工具的是使用者。哪怕是用純程式碼開發,如果沒有命名規範,肆意的巢狀if,不遵守MVC或者MVVM等開發模式,不區分開發環境與生產環境,這樣寫出來的程式碼又何談可維護性,和多人協作呢?

那麼反過來說,如何使用Storyboard才不算濫用?

避免濫用,最好的方法就是定製規範,就好像程式碼中的諸多規範一樣。每個團隊可能有自己不同的喜好,我在此拋磚引玉,列出我們團隊使用Storyboard的規範,供大家參考。

每個模組獨立Storyboard 每個Storyboard只應該有一個主VC和同頁的子VC,主VC不應存在2個以上
純Swift專案-Xib | StoryBoard 多人協作技巧
純Swift專案-Xib | StoryBoard 多人協作技巧
  1. 一個專案中,Storyboard不該是孤立存在的,應該像MVP模式那樣,每個頁面都有獨立的Storyboard,每個Storyboard只應該有一個主VC和同頁的子VC,主VC不應存在2個以上。(絕大多數情況下,一個Storyboard上只應該有一個VC
  2. 頁面間的Segue連線應該使用Stroyboard Reference SceneUITabBarController的子頁因為複雜度應該當成主VC處置
  3. 檢視的初始樣式應儘量在Storyboard上屬性皮膚中設定,非極特殊情況,佈局也應在Storyboard上使用各種約束配合完成。這樣有利於檢視樣式和檢視程式碼分離,有利於檢視程式碼重用性和相容性提高。
  4. 對於邏輯複雜的VC,應新增Object物件,並繫結相應的類來分離邏輯程式碼。
  5. 對於圓角,背景色,陰影等CALayer的樣式,應該使用擴充套件或子類化例項的形式,使用@IBInspectable屬性關鍵字,在Storyboard屬性皮膚中設定初始樣式。
  6. 對於自定義檢視,應使用@IBDesignable關鍵字保障在在Storyboard上所見即所得!

使用以上原則,只要任務分工合理,基本上不存在多人同時修改同一個Storyboard的情況,就算配合失誤偶然發生,精簡的Storyboard其程式碼量也不大,藉助檔案比較工具很容易就能處理git衝突。

說到底,臃腫的Storyboard和臃腫的ViewController一樣,都是難以維護且容易git衝突的。唯一的解決方案就是有節制的使用工具。

StoryBoardXib 隱藏了UI細節,且容易導致ViewController臃腫?

與其說StoryBoardXib 隱藏了UI細節,倒不如說蘋果是希望通過他們來引導開發者正確的使用 檢視控制器 ,他們建立檢視例項的時候都是通過

    required init?(coder aDecoder: NSCoder) {
    
    }
複製程式碼

構造方法建立檢視例項。所有初始樣式都是在屬性皮膚中設定的值,通過

    func setValue(_ value: Any?, forUndefinedKey key: String) {
        ......
    }
複製程式碼

來賦值給檢視對應的屬性。

至於說導致ViewController臃腫,更是荒謬,StoryBoard提供了多種方案來分離程式碼,只不過很多人不知道而已。

拿美團的主頁UI舉例

純Swift專案-Xib | StoryBoard 多人協作技巧

這樣的首頁較為複雜,正常佈局的話需要多個CollectionView和一個UITableView

純Swift專案-Xib | StoryBoard 多人協作技巧

如果這些檢視的Delegate都由ViewController來實現,自然顯得臃腫且混亂。

一般手寫派會分出3個ChildViewController來解決臃腫問題,難道Storyboard就做不到麼?

純Swift專案-Xib | StoryBoard 多人協作技巧

答案是否定的,很早的版本,蘋果就給出了上圖中的解決方案。一個佔位的容器檢視指向子控制器的Embed Segue

純Swift專案-Xib | StoryBoard 多人協作技巧

按住Control鍵連線到想要包含的子控制器,佔位檢視的例項==子控制器的view(子控制器根檢視)

純Swift專案-Xib | StoryBoard 多人協作技巧

選擇Embed連線方式後,子控制器 的尺寸變化成跟佔位檢視一樣的尺寸

純Swift專案-Xib | StoryBoard 多人協作技巧

這樣我們可以將功能圖示的CollectionView的程式碼放到這第一個子控制器上,CollectionViewDelegateCollectionViewDataSource等程式碼也由子控制器實現

同理,優惠專區可以再新增一個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.parentnil,這時不能拿到主ViewController例項。如果需要在初始化的時候拿到主ViewController的例項,則應該在主ViewController``viewDidLoad方法中,呼叫ChildViewController的特定方法,把 self 當引數傳過去。


  • 除此之外還可以使用Object物件

純Swift專案-Xib | StoryBoard 多人協作技巧

將它新增到控制器之上。

純Swift專案-Xib | StoryBoard 多人協作技巧

它的本質是一個繼承自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,繫結上面的類

純Swift專案-Xib | StoryBoard 多人協作技巧
右鍵這個Object,在彈出的選單中連線

純Swift專案-Xib | StoryBoard 多人協作技巧

右鍵CollectionView 設定 DelegateDataSource 等的連線

純Swift專案-Xib | StoryBoard 多人協作技巧

在主ViewController中如需呼叫這個模組的方法或者傳參

class HomeController: UIViewController {
    
    @IBOutlet weak var featuresController:FeaturesController!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        featuresController.datas = [....]
        featuresController.collectionView.reloadData()
    }
    
}
複製程式碼

純Swift專案-Xib | StoryBoard 多人協作技巧

完成連線,同理,如果一個頁面需要多個子模組,可以在Storyboard上拖入多個Object,並繫結不同的模組控制類,相對於佔位的Container ViewChildViewController方法,Object方法在傳參或互相呼叫方面,更加簡便。缺點是沒有ChildViewController的生命週期方法,如需使用viewWillAppear等,需要在主ViewControllerviewWillAppear中,呼叫Object的自定義方法。

通過上面的2種方法不難看出,並非是Storyboard造成ViewController程式碼臃腫,而是因為設計不當導致,就算你不用Storyboard,把所有功能都寫在一個ViewController裡一樣臃腫。這都是使用者決定的,並非Storyboard的責任!

StoryBoardXib 出了問題不容易測試?

這個問題其實問的很模糊,我也是諮詢了很多人才知道,他們所謂的問題不容易測試,是指如下兩種情況:

  1. 修改或刪除 @IBOutlet 的變數名時,對應的Storyboard上未做處理,導致執行時崩潰,崩潰內容看不懂!
  2. 繫結的類名改變時,對應的Storyboard上未做處理,導致執行時崩潰,崩潰內容看不懂!

其實只要知道,蘋果是如何把Storyboardxml解析成檢視,崩潰的錯誤內容也就容易看懂了 之前提到過,檢視構造使用的是下面這個方法

    required init?(coder aDecoder: NSCoder) {
    
    }
複製程式碼

如果繫結的類名改變輸出錯誤:

  1. Unknown class _TtC11ProjectName14HomeController in Interface Builder file. // Swift
  2. Unknown class HomeController in Interface Builder file. // Objective C

通過上面的錯誤提示Interface Builder file就是指通過Storyboard或者Xib構建檢視或者控制器,但找不到名為HomeController的控制器,看到這裡就應該明白,我們某個Storyboard上繫結了名為HomeController的控制器,但程式碼中找不到,可能是改名或者刪除了。這時可以全域性搜素一下

純Swift專案-Xib | StoryBoard 多人協作技巧

在搜出來的結果中可以看到,是在Main.storyboard上繫結了HomeControllerTest.swift檔案中定義了該類,但是因為改名所以無法找到。

這樣的問題不用Storyboard就可以避免麼?答案是否定的,因為重構程式碼的時候,改了一處忽略它處的例子比比皆是。哪怕純程式碼也是一樣,因此,如果需要修改類名或者變數名,應該善用Xcode的重構功能,而不是簡單的直接修改。

純Swift專案-Xib | StoryBoard 多人協作技巧

純Swift專案-Xib | StoryBoard 多人協作技巧

這樣修改類名或者變數名是,Storyboard或者Xib上繫結或連線的內容也會同步改變。就不會出錯了。

同理,@IBOutlet 連線的屬性通過下面的方法給檢視賦值

    func setValue(_ value: Any?, forUndefinedKey key: String) {
        ......
    }
複製程式碼

如果變數名改變的時候,會出現如下錯誤:

  1. *** 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屬性,通過全域性搜尋可以發現,程式碼中改了名字,

純Swift專案-Xib | StoryBoard 多人協作技巧

解決的方法同樣是刪掉對應的連線或者修改變數名時使用重構

由此可見,所謂的不容易測試,完全是因為重構不謹慎且對構造過程不理解,否則還是很容易定位問題且修改的。而且重構程式碼時利用Xcode重構功能的話,連問題都不會出現

StoryBoardXib 降低執行效率?

這個問題看起來好像是那麼回事,StoryBoardXib本質上是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萬次,結果輸出

  1. Storyboard建立30000次用時 8.648092089919373
  2. 純程式碼建立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在這種情況下確實比純程式碼建立更快。

  1. Storyboard建立30000次用時 8.513293381780386
  2. 純程式碼建立30000次用時 27.19225306995213
  3. Storyboard建立30000次用時 25.9916725079529

這個結果是如何出現的,不妨大膽猜測一下,可能是由於蘋果在物件多次建立的情況下,Storyboard可能存在快取復刻機制,來提升效率,而純程式碼並沒有這樣的優化。為了驗證猜測,我們逐漸降低數量級。

  1. Storyboard建立3000次用時 0.20833597797900438
  2. 純程式碼建立3000次用時 0.2654381438624114
  3. Storyboard建立3000次用時 0.34943647705949843
  1. Storyboard建立300次用時 0.010981905972585082
  2. 純程式碼建立300次用時 0.005475352052599192
  3. Storyboard建立300次用時 0.014193600043654442
  1. Storyboard建立30次用時 0.0016030301339924335
  2. 純程式碼建立30次用時 0.00031192018650472164
  3. Storyboard建立30次用時 0.001034758985042572
  1. Storyboard建立10次用時 0.0009886820334941149
  2. 純程式碼建立10次用時 0.0001325791236013174
  3. Storyboard建立10次用時 0.0014422889798879623

上述結果果然驗證了我們的猜測,隨著次數的減少,Storyboard建立的速度逐漸低於存程式碼建立,但單次耗時仍然低於萬分之一秒,這種效率是不會讓使用者有任何感知的,何況重複建立比純程式碼還有優勢,因此,這一條也不算StoryBoardXib的缺點

StoryBoardXib 拖動和設定約束佈局很難精確?不易修改?

我想,這種言論可能是因為不太熟悉Interface Builder的功能和操作造成的,僅僅實驗了幾次不得其門而入就放棄了。

實際上約束佈局是一個很強大的功能,可以解決絕大多數(98%)佈局適配問題,98%這個數並不是隨便給出的,很多人覺得達不到這個比例是因為對約束理解較少,還是按照以前的autolayoutMask的方式使用約束,因此很多佈局問題還在用程式碼計算,可實際上約束功能十分強大,目前無法通過約束直接解決,必須程式碼輔助的問題微乎其微。

但與之相對的是約束的概念較多,依賴人腦思考很容易產生遺漏,這樣在執行的時候就會各種報錯或顯示異常,因此用純程式碼寫約束,反覆執行除錯檢視樣式尺寸十分常見,而且有些頁面較深,測試起來十分麻煩。

而使用StoryBoardXib就不同了,缺少約束或者約束衝突直接就有錯誤提示,適配不同裝置可以直接在Interface Builder上切換測試,效率不知高了多少倍,準確性也高了很多

如果需要詳細瞭解在StoryBoardXib上使用約束的技巧,可以參考文章《純Swift專案-Xib | StoryBoard 裝置適配技巧》及 《純Swift專案-Xib | StoryBoard 約束使用技巧》或其他相關文章。

總結,StoryBoardXib雖然不是毫無缺點,但優勢遠大於付出,值得學習研究!

相關文章