再看關於 Storyboard 的一些爭論

從 iOS 5 的時代 Apple 推出 Storyboard (以下簡稱 SB) 後,關於使用這種方式構建 UI 的爭論就在 Cocoa 開發者社區裏一直髮生着。我在 2013 年寫過一篇關於代碼手寫 UI,xib 和 SB 之間的取捨的文章。在四五年後的今天,SB 得到了多次進化,大家也積攢了很多關於使用 SB 進行開發的經驗,我們不妨再回頭看看當初的憂慮,並結合 SB 開發的現狀,來提取一些現階段被認爲比較好的實踐。

這篇文章緣起爲對使用 SB 的方式一文 (及其英文原文) 的迴應,我對其中部分意見有一些不同的看法。不過正如原文作者在最後一段所說,你應該選擇最適合自己的使用方式。所以我的意見或者說所謂的「好的實踐」,也只是從我自己的觀點出發所得到的結論。本文將首先對原文提出的幾個論點逐個分析,然後介紹一些我自己在日常使用 SB 時的經驗和方式。

反正關於 Storyboard 或者 Interface Builder 已經吵了那麼多年了,也不在乎多這麼一篇-。-

原文分析

Storyboard 衝突風險和加載

原文中有一個非常激進的觀點,那就是:

每個 SB 裏只放一個 UIViewController

我無法贊同這個觀點。如果在 iOS 3 或者 4 時代有 xib 使用經驗的開發者會知道,這基本就是將 SB 倒退到 xib 的用法。原文中提到這麼做的原因主要有三點:

  • 減少兩個開發者同時開發一個 View Controller 時的 git 衝突
  • 加速 storyboard 加載,因爲只需要加載一個 UIViewController
  • 只用 initial view controller 就可以從 SB 中加載想要的 View Controller

在 Xcode 7 引入了 SB reference 以後,「SB 容易衝突」已經徹底變成假命題了。通過合理地劃分功能模塊和每個開發者負責的部分,我們可以完全避免 SB 的修改衝突。最近兩三年以來我們在實際項目中完全沒有出現過 SB 衝突的情況。

另外,即使 SB 劃分出現問題,影響也是可控的。在單個的 SB 文件中,每個 View Controller 有各自的範圍域,因此即使存在不同開發者同時着手一個 SB 文件的情況,只要他們不同時修改同一個 View Controller 的內容,也並不會在 View Controller 上產生衝突。在 SB 文件中確實存在一些共用的部分,比如 IB 的版本,系統的版本等,但它們並不影響實質的 UI,而且可以通過統一開發成員的環境來避免衝突。因此,一個 SB 中多個 VC 和一個 SB 中一個 VC,其實所帶來的衝突風險幾乎是一樣的。

關於 SB 的加載,可以看出原作者可能並沒有搞清 UI 加載的整個流程,不求甚解地認爲 SB 文件中 View Controller 越多加載時間越長,但事實並非如此。細心的同學 (或者項目中有很多 SB 文件的同學) 會發現,在編譯的時候 Xcode 有一個 Compiling Storyboard files 的過程:

compiling-sb

編譯過程中,項目裏用到的 SB 文件也會被編譯,並以 storyboardc 爲擴展名保存在最終的 app 包內。這個文件和 .bundle 或者 .framework 類似,實際上是一個文件夾,裏面存儲了一個描述該編譯後的 SB 信息的 Info.plist 文件,以及一系列 .nib 文件。原來的 SB 中的每個對象 (或者說,一般就是每個 View Controller) 將會被編譯爲一個單獨的 .nib,而 .nib 中包含了編碼後的對應的對象層級。在加載一個 SB,並從中讀取單個 View Controller 時,首先系統會找到編譯後的 .storyboardc 文件,從 Info.plist 中獲取所需的 View Controller 類型和 nib 的關係,來完成 UIStoryboard 的初始化。接下來讀取對應的某個 nib,並使用 UINibDecoder 進行解碼,將 nib 二進制還原爲實際的對象,最後調用該對象的 initWithCoder: 完成各個屬性的解碼。在完成這些工作後,awakeFromNib 被調用,來通知開發者從 nib 的加載已經完畢。

如果你理解這個過程,就可以看出,從只有單個 View Controller 的 SB 中加載這個 VC,與從多個 View Controller 中加載一個的情況,在速度上並不會有什麼區別。硬要說的話,如果使用太多 SB 文件,反而會在初始化 UIStoryboard 時需要讀取更多的 Info.plist,反而造成性能下降 (相對地我們可以使用 View Controller 的 storyboard 屬性來獲取當前 VC 所屬的 UIStoryboard,從而避免多次初始化同一個 Storyboard,不過這點性能損失其實無關緊要)。

關於第三點,原作者使用了一段代碼來展示如何通過類似這樣的方法來創建類型安全的對象:

let feed = FeedViewController.instance()
// `feed` is of type `FeedViewController`

這麼做有幾個前提,首先它需要按照 View Controller 類型名字來創建 SB 文件,其次還需要爲 UIViewController 添加按照類型名字尋找 SB 文件的輔助方法。這並不是一個很明顯的優點,它肯定會引入 NSStringFromClass 這種動態的東西,而且其實我們有很多更好的方式來創建類型安全的 View Controller。我會在第二部分介紹一些相關的內容。

Segue 的使用

原文中第二個主要觀點是:

不要使用 Segue

Segue 的基本作用是串聯不同的 View Controller,完成各 VC 的遷移或者組織。在第一個觀點 (一個 SB 文件只含有一個 VC) 的前提下,不使用 Segue 是自然而然的推論,因爲同一個 SB 中沒有多個 VC 的關係需要組織,segue 的作用被大大降低。但是作者使用了一個不是很好的例子想要強行說明使用 segue 以及 prepare(for:sender:) 的壞處。下面是原文中的一段示例代碼:

class UsersViewController: UIViewController, UITableViewDelegate {

  private enum SegueIdentifier {
    static let showUserDetails = "showUserDetails"
  }

  var usernames: [String] = ["Marin"]

  func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
    usernameToSend = usernames[indexPath.row]
    performSegue(withIdentifier: SegueIdentifier.showUserDetails, sender: nil)
  }


  private var usernameToSend: String?

  override func prepare(for segue: UIStoryboardSegue, sender: Any?) {

    switch segue.identifier {
      case SegueIdentifier.showUserDetails?:

        guard let usernameToSend = usernameToSend else {
          assertionFailure("No username provided!")
          return
        }

        let destination = segue.destination as! UserDetailViewController
        destination.username = usernameToSend

      default:
        break
    }     
  }
}

簡單說,這段代碼做的就是在用戶點擊 table view 中某個 cell 的時候,將點擊的內容保存到 View Controller 的一個成員變量 usernameToSend 中,然後調用 performSegue(withIdentifier:sender:)。接下來,在 prepare(for:sender:) 中獲取保存的這個成員變量,並且設置給目標 View Controller。對於 table view 來說,這是一個不太必要的做法。我們完全可以直接將 cell 通過 segue 連接到目標 View Controller 上,然後在 prepare(for:sender:) 中使用 table view 的 indexPathForSelectedRow 獲取需要的數據,並對目標 View Controller 進行設置。可能原作者不太清楚 UITableView 有這麼一個 API,所以用了不太好的例子。

那麼 segue 有問題嗎?我的回答是有,但是問題不大。實際開發中確實存在不少類似原作者說到的情形,需要將數據在 prepare(for:sender:) 中傳遞給目標 View Controller,不過這種情況的數據很多時候已經存在於當前 View Controller 中 (比如需要傳遞文本框中輸入的文字,或者當前 VC 的 model 的某個屬性等)。相比於變量的問題,segue 帶來的更大的挑戰在於 View Controller 之間遷移的管理。現在我們可以通過代碼進行轉場 (pushViewController(:animated) 或者 present(:animated:completion)),也可以使用 SB 裏點擊控件的 segue,甚至還可以從代碼中調用 performSegue,在不同的地方進行管理讓代碼變得複雜和難以理解,所以我們可能需要考慮如何以集中的方式進行管理。objc.io 的 Swift Talk 的第五期視頻 - Connecting View Controllers (而且是免費的) 對這個問題進行了一些探討,並給出了一種集中管理 View Controller 之間遷移的方式。其中使用回調的方法可以借鑑,但是我個人對整個思路運用在實際項目裏存有疑慮,大家也不妨作爲參考瞭解。

除了管理轉場外,segue 還能夠提供方便的 Container View 的 embed 關係,也可以在使用像是 UIPageViewController 這樣的多個 VC 關係的時候,用來提供一些初始化時運行的代碼,又或者是用 unwind 來方便地實現 dismiss。這些「附加」的功能都讓我們少寫很多代碼,開發效率得到提升,不去嘗試使用的話可以說是相當可惜。

要愛,不要拒絕 GUI

原文作者的最後一個主要觀點是:

所有的屬性都在代碼中設置

作者在原文一開始就提到,人都是視覺動物,使用 SB 的一大目標就是直觀地理解界面。通過 SB 畫布我們可以迅速獲得要進行開發的 View Controller 的信息,這比閱讀代碼要快得多。但是,如果所有屬性都在代碼中進行設置的話,這一優勢還剩多少呢?

作者提議在 SB 中對添加的 View 或者 ViewController 保留所有默認設置 (甚至是 view 的背景顏色,或者 label 文字等),然後使用代碼對它們進行設置。在這一點上,原文作者的顧慮是對於 UI 元素樣式的更改。作者希望通過使用一些常量來保存像是字體,顏色等,並在代碼中將它們分別賦值給 UI 元素,這樣能做到設計改變時只在一處進行更改就可以對應。

這種做法帶來的缺點相當明顯,那就是爲了設置這些屬性,你需要很多的 IBOutlet,以及很多額外的工作量。我的建議是,對於那些不會隨着程序狀態改變的內容,最好儘量使用 SB 直接進行設置。比如一個 label 上的文字,除非這些文字確實需要改變 (比如顯示的是用戶名,或者當前評論數之類),否則完全沒有必要添加 @IBOutlet,直接設置 text 會簡單得多。其他像是 UIScrollView 的 Cancellable Content Touches 等屬性,如果不需要在程序中根據程序狀態進行改變,也最好直接在 IB 裏設置。作者在原文裏提到,“通過掃描代碼來尋找 view 的屬性要比在 storyboard 中尋找一個勾號來的容易”,關於這一點,我認爲其實兩者並沒有什麼不同。舉例來說,通過 IB 將 UIScrollView 的 Cancellable Content Touches 設置爲 false,在對應的 SB 文件中的 scroll view 裏會加上 canCancelContentTouches="NO" 這樣的屬性。通過全局搜索的方式找到這個屬性也是輕而易舉的。甚至你可以直接修改 SB 的源碼達到目的,而根本不需要打開 Xcode 或者 IB。基於查找的可能性,批量的替換和更新與使用代碼來設置也並無異。並不存在說在代碼裏更容易被找到這種情況。

不過要注意的是,SB 中的屬性在 Xcode 的查找結果中是被過濾,不會出現的,所以可能需要使用其他的文本編輯器來全局查找。

關於像是字體或者顏色這樣的 view 樣式,作者的顧慮可以理解。IB 現在缺乏良好的做樣式的方法,這也是大家詬病已久的問題。在 Font 選擇中存在 style 的選項,讓我們可以從 Body,Headline 之類的項目中進行選擇,看起來很好:

但是這僅僅只是爲了支持 Dynamic Type,設置這些值和調用 UIFontpreferredFont(forTextStyle:) 獲取特定字體是一樣的。我們並不能自行定義這些字體樣式,也不能進行添加。顏色也一樣,Xcode 並沒有提供一個類似可以在 IB 裏使用的項目顏色版或者顏色變量的概念。

關於 view 樣式,最常見也是最簡單的解決方案大概有兩種。

第一種是使用自定義的子類,來統一設置字體或者顏色這些屬性。比如說你的項目裏可以會有 HeaderLabel,或者 BodyLable 這樣的 UILabel 的子類,然後在子類裏相應的方法中設置字體。這種方式來得比較直接,你可以通過更改 IB 裏的 label 類型來適用字體。但是缺點在於當項目變大以後,可能 label 的類型會變得很多。另外,對於非全局性的修改,比如只針對某一個特定 label 調整的時候會比較麻煩,很可能你會想只針對個例做個別調整,而不是專門爲這種情況建立新的子類,而這個決定往往會讓你之前爲了統一樣式所做的努力付之一炬。

另外一種方式是爲目標 view 的類型添加像是 style 屬性,然後使用 runtime attribute 來設置。簡單的想法大概是這樣的,比如針對字體:

extension UIFont {

    enum Style: String {
        case p = "p"
        case h1 = "h1"
        case defalt = ""
    }

    static func font(forStyle string: String?) -> UIFont {
        guard let fontStyle = Style(rawValue: string ?? "") else {
            fatalError("Unrecognized font style.")
        }

        switch fontStyle {
        case .p: return .systemFont(ofSize: 14)
        case .h1: return .boldSystemFont(ofSize: 22)
        case .defalt: return .systemFont(ofSize: 17)
        }
    }
}

這段代碼爲 UIFont 添加了一個靜態方法,通過輸入的字符串獲取不同樣式的字體。

然後,我們爲需要字體樣式支持的類型添加設置 style 的擴展,比如對 UILabel

extension UILabel {
    var style: String {
        get { fatalError("Getting the label style is not permitted.") }
        set { font = UIFont.font(forStyle: newValue) }
    }
}

在使用的時候,我們在 IB 裏想要適用樣式的 UILabel 添加 runtime attribute 就可以了:

不過不論哪種做法,缺點都是我們無法在 IB 中直觀地看到 label 的變化。當然,可以通過爲自定義的 UILabel 子類實現 @IBDesignable 來克服這個缺點,不過這也需要額外的工作量。還是希望 Xcode 和 IB 能夠進步,原生支持類似的樣式組織方式吧。不過就因此放棄簡單明瞭的 UI 構建方式,未免有些過於武斷。

基本上我對原文的每個觀點已經提出了我的想法,不過正如原文作者最後說的那樣,你應該選擇你自己的使用風格,並決定要如何使用 Storyboard。

It’s not all or nothing.

原文作者就只將 IB 和 Storyboard 作爲一個設置 view 層次和添加 layout 約束的工具,這確實是 SB 的強項所在,但是我認爲它的功能要遠比這強大的多。正確地理解 SB 的設計思想和哲學,正確地在可控範圍內使用 SB,對於發掘這個工具的潛力,對於進一步提高開發效率,都會帶來好處。

本文下一部分將會簡單介紹幾個使用 SB 的實踐。

實踐經驗

以類型安全的方式使用 Storyboard

原文作者提到使用單個 VC 的 Storyboard 可以以類型安全的方式進行創建。其實這並不是必要條件,甚至我們通過別的方式可以做得更好。在 Cocoa 框架中,爲了靈活性,確實有很多基於字符串的 API,這導致了一定程度的不安全。Apple 自己爲了 API 的通用性和兼容性,不太可能對現有的類型不安全的 API 進行大幅修改,不過通過一些合適的封裝,我們依然可以讓 API 更加安全。不管是我個人的項目還是公司的項目,其實都在使用像是 R.swift 這樣的工具。這個項目通過掃描你的各種基於字符串命名的資源 (比如圖片名,View Controller 和 segue 的 identifier 等),創建一個使用類型來獲取資源的方式。相比與原作者的類型安全的手法,這顯然是一種更成熟和完善的方式。

比如原來我們可以要用這樣的代碼來從 SB 裏獲取 View Controller:

let myImage = UIImage(names: "myImage")
let viewController = UIStoryboard(name: "Main", bundle: nil).instantiateViewController(withIdentifier: "myViewController") as! MyViewController

在 R.swift 的幫助下,我們將可以使用下面的代碼:

let myImage = R.image.myImage()
// myImage: UIImage?

let viewController = R.storyboard.main.myViewController()
// viewController: MyViewController?

這種做法在保證類型安全的同時,還可以在編譯時就確認相應資源的存在。要是你修改了 SB 中 View Controller 的 identifier,但是沒有修改相應代碼的話,你會得到一個編譯錯誤。

R.swift 除了可以針對圖片和 View Controller 外,也可以用在本地化字符串、Segue、nib 文件或者 cell 等一系列含有字符串 identifier 的地方。通過在項目中引入 R.swift 進行管理,我們在開發中避免了很多可能的資源使用上的危險和 bug,也在自動補全的幫助下節省了無數時間,而像是使用 Storyboard 並從中創建 View Controller 這樣的工作也變得完全不值一提了。

利用 @IBInspectable 減少代碼設置

通過 IB 設置 view 的屬性有一個侷限,那就是有一些屬性沒有暴露在 IB 的設置面板中,或者是設置的時候有可能要“轉個彎”。雖然在 IB 面板中已經包含了八九成經常使用的屬性,但是難免會有「漏網之魚」。我們在工程實踐中最常遇到的情形有兩種:爲一個顯示文字的 view 設置本地化字符串,以及爲一個 image view 設置圓角。

這兩個課題我們都使用在對應的 view 中添加 @IBInspectable 的 extension 方法來解決。比如對於本地化字符串的問題,我們會有類似這樣的 extension:

extension UILabel {
    @IBInspectable var localizedKey: String? {
        set {
            guard let newValue = newValue else { return }
            text = NSLocalizedString(newValue, comment: "")
        }
        get { return text }
    }
}

extension UIButton {
    @IBInspectable var localizedKey: String? {
        set {
            guard let newValue = newValue else { return }
            setTitle(NSLocalizedString(newValue, comment: ""), for: .normal)
        }
        get { return titleLabel?.text }
    }
}

extension UITextField {
    @IBInspectable var localizedKey: String? {
        set {
            guard let newValue = newValue else { return }
            placeholder = NSLocalizedString(newValue, comment: "")
        }
        get { return placeholder }
    }
}

這樣,在 IB 中我們就可以利用對應類型的 Localized Key 來直接設置本地化字符串了:

設置圓角也類似,爲 UIImageView (或者甚至是 UIView) 引入這樣的擴展,並直接在 IB 中進行設置,可以避免很多模板代碼:

@IBInspectable var cornerRadius: CGFloat {
   get {
       return layer.cornerRadius
   }

   set {
       layer.cornerRadius = newValue
       layer.masksToBounds = newValue > 0
   }
}

@IBInspectable 實際上和上面提到的 UILabel 的 style 方法一樣,它們都使用了 runtime attribute。顯然,你也可以把 UILabel style 寫成一個 @IBInspectable,來方便在 IB 中直接設置樣式。

@IBOutlet 的 didSet

雖然這個小技巧並不會對 IB 或者 SB 的使用帶來實質性的改善,但是我覺得還是值得一提。如果我們由於某種原因,確實需要在代碼中設置一些 view 的屬性,在連接 @IBOutlet 後,不少開發者會選擇在 viewDidLoad 中進行設置。其實個人認爲一個更合適的地方是在該 @IBoutletdidSet 中進行。@IBoutlet 所修飾的也是一個屬性,這個關鍵詞所做的僅只是將屬性暴露給 IB,所以它的各種屬性觀察方法 (willSetdidSet 等) 也會被正常調用。比如,下面我們實際項目中的一段代碼:

@IBOutlet var myTextField: UITextField! {
    didSet {
        // Workaround for https://openradar.appspot.com/28751703
        myTextField.layer.borderWidth = 1.0
        myTextField.layer.borderColor = UIColor.lineGreen.cgColor
    }
}

這麼做可以讓設置 view 的代碼和 view 本身相對集中,也可以使 viewDidLoad 更加乾淨。

繼承和重用的問題

誇了 Storyboard 這麼多,當然不是說它沒有缺點。事實不僅如此,SB 還有很多很多可以改善的地方,其中,使用 SB 來實現繼承和重用是最困難的地方。

Storyboard 不允許放置單獨的 view,所以如果想要通過 IB 來實現 view 的重用的話,我們需要回退到 xib 文件。即使如此,想要在 SB 的 View Controller 中初始化一個通過 xib 加載的 view 也並不是一件很容易的事情。一般對於這種需求,我們會選擇在 init(coder:) 中加載目標 nib 然後將它作爲 subview 添加到目標 view 中。整個過程需要開發者對 nib 加載 view 和 View Controller 的過程有比較清楚的瞭解,但不幸的是 Apple 把這個過程藏得有些深,所以絕大多數開發者並不關心、也不是很清楚這個過程,就認爲這是不可能的。

對於 view 的繼承的話更困難一些。依然是由於二進制 nib 將通過解碼的方式進行還原,所以在設置父類的屬性時需要特別注意。另外,子類的 UI 是否應該通過創建新的 xib 進行構建,還是應該通過代碼將父類的 UI 加到子類上,也會是艱難的選擇。相比起來,使用代碼進行 view 的繼承和重用就要容易得多,方法也明確得多。

不光是單獨的 view,SB 中 View Controller 的繼承和重用也面臨着同樣的問題。View Controller 的重用相對簡單,通過 storyboard 初始化對應的 View Controller,或者通過 segue 就可以了。繼承則更麻煩,不過好在相比起 view 的繼承,View Controller 的繼承關係並不會特別複雜,在 UIKit 中對於 UIViewController 的繼承最常用的基本也就 UITableViewControllerUICollectionViewController,而作爲最終展示給用戶的 view 的管理代碼來說,也很少有需要繼承一個已經高度專用,並使用 IB 構建的 View Controller。如果你在項目中出現這種繼承的需求,首先對繼承的必要性進行考慮會是不錯的選擇。如果可以通過不同的配置重用已有的 View Controller,那麼說明「繼承」可能只是一個僞需求。

不管如何,不能否認,因爲構建 UI 的方式是對 xml 文件的編碼和解碼,由此帶來了繼承和重用的困難,這是 IB 或者說 SB 的最大的短板。

總結

本文旨在介紹一些我自己對 Storyboard 的看法,和我日常開發中的使用方式。並不是說什麼「你應該這樣使用」或者「最佳實踐就應當如此這般」。你可以選擇使用純代碼構建 UI,但同時 Apple 也爲我們提供了更快捷的 IB 和 Storyboard 的方式。在我這麼幾年的使用經驗來看,SB 的設計並沒有這麼不堪,而相比於以前使用代碼或者 xib 的方式,現在的開發方式確實讓效率得到了提高。開發者根據自己的需求和理解對工具進行選擇,每個人的選擇和使用的方式都是值得尊重。只要願意擁抱變化,勇於嘗試新的事物,並從中找到合適自己的東西,那麼使用什麼樣的方式本身其實便沒有那麼重要了。

最後,願你的技術歷久彌新,願你的生活光芒萬丈。

作者簡介:王巍(@onevcat),江湖人稱“喵神”,iOS 和 Unity3D 開發者,旅居日本,目前供職於 LINE,著有《Swifter - 100 個 Swift 必備 tips》,同時也是翻譯項目 ObjC 中國的組織者和管理者,維護 VVDocumenter-Xcode 及 Kingfisher 等開源項目。
責編: 唐小引,技術之路,共同進步,歡迎技術投稿、給文章糾錯,請發送郵件至[email protected]
版權聲明: 本文爲 CSDN 原創文章,未經允許,請勿轉載。

發佈了216 篇原創文章 · 獲贊 114 · 訪問量 26萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章