iOS App 程式開發

Massive View Controller 重構: Swift Extension 整理術

Swift Extension 是用來延伸既有型別的東西。透過 Extension,當我們想為某個型別加功能的時候,就可以不用把新的功能寫在該型別的主體裡面。此文教大家善加運用 Extension,從而大幅簡化 Massive View Controller。
Massive View Controller 重構: Swift Extension 整理術
Massive View Controller 重構: Swift Extension 整理術
In: iOS App 程式開發, Swift 程式語言

Extension 是 Swift 裡用來延伸既有型別的東西。透過 Extension,當我們想為某個型別加功能的時候,就可以不用把新的功能寫在該型別的主體裡面。比如說,如果我們想為 Cat 增加一個 purr() 方法的時候,可以這樣寫:

class Cat {

    func meow() {
        print("Meow!")
    }

}

extension Cat {

    func purr() {
        print("咕嚕嚕嚕嚕...")
    }

}

let myCat = Cat()
myCat.purr() // 咕嚕嚕嚕嚕...

這種能力讓我們可以為一些碰不到原始碼的型別,加上我們自己給的功能。這也就是所謂的回顧式建模 (Retrospective Modeling)── 在不更改原本型別的前提下,去為這個型別增加功能。

雖然這感覺並不是甚麼厲害的設計模式,但其實善加運用的話,就可以大幅簡化 Massive View Controller。以下,我們就一起來看看它有甚麼樣的用法。

本篇所說的「型別」是指 Class、Structure 與 Enumeration 這三種實際型別。關於 Protocol Extension,可以參考 ShihTing Huang 的這一篇文章。)

模組化

Extension 並不只是拿來加功能而已,它也可以用來打散你自己寫的型別。比如說,我們可以把所有的工廠方法都丟到同一個 Extension 裡面:

class MyViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()

        let label = makeLabel()
        view.addSubview(label)
    }

    // ...

}

extension MyViewController {

    func makeLabel() -> UILabel {
        // ...
    }

    // 其它工廠方法...

}

這樣做的好處,是在閱讀這個型別的程式碼時,我們可以更容易去定位某一個方法。不要小看單純視覺上的分區,當你找 bug 找到焦頭爛額的時候,你會感謝當初自己有把程式碼好好整理過。

你可能會想,這跟用 // MARK: 來做段落有甚麼差別呢?// MARK: 還讓你可以為段落命名勒!其實,這兩種東西並不會互相排斥,我們完全可以將它們搭配使用,像這樣:

// ...

// MARK: - 工廠方法
extension MyViewController {

    // 所有的工廠方法實作...

}

如此一來,我們就可以在 Xcode 的 Jump bar 下拉式選單裡,知道這個 Extension 是做甚麼的:

swift-extension-1

同時,Extension 確實也提供了一些 // MARK: 不具有的功能。

程式碼折疊

在最新版的 Xcode 裡,所有被大括號(「{」與「}」)括起來的程式碼都可以被折疊。Extension 即是用大括號把一些程式碼包起來,所以我們可以把整個 Extension 都折疊起來,像這樣:

swift-extension-2

要怎麼折疊呢?除了彼得潘所提到的方法── 使用 Code folding ribbon 之外,也可以:

  1. 將鍵盤輸入游標移動到該 Extension 所處的任何一行。
  2. 按下鍵盤快捷鍵:Command-Option-Left Arrow

如果要展開的話,改按 Command-Option-Right Arrow就可以了。

程式碼折疊對於想專心處理其它部份的程式碼時非常有用,畢竟眼不見為淨。然而除了折疊之外,想隱藏程式碼還有另一種更基進的做法。

移到新檔案

沒錯,把整個 Extension 移到別的檔案去,你就不會再在這裡看到它了。比如說,我們可以新增一個叫做 MyViewController+FactoryMethods.swift 的檔案,並把相關的 Extension 整個剪貼過去:

// MyViewController+FactoryMethods.swift

extension MyViewController {

    // 所有的工廠方法實作...

}

這在原本的 View Controller 行數太多時尤其有用;但除此之外,新檔案也意味著幾件事。

首先是創造了新的隱私權範圍。不管是 private 還是 fileprivate ,現在 (Swift 4.2) 都是以檔案作為最大範圍的。也就是說,我們得以在原本的 MyViewController.swift 與新的 MyViewController+FactoryMethods.swift 裡,各自定義只能在檔案內部讀取的方法,以此增強個別的模組化,並降低複雜度。

再來是開啟了在不同 Target(編譯目標)之間、不同組合的可能性。由於 Swift 編譯器是以檔案為基本單位的,所以我們可以選擇在某些 Target 裡不要包含某些檔案。比如說,我們可能只有在主程式裡才需要編輯功能,那就可以把所有的編輯功能都透過 Extension 放到一個新檔案裡,並只在主程式 Target 裡包含該檔案。

遵守 Protocol

Extension 可以加的功能並不只是方法與計算型屬性而已,它還可以加上對 Protocol 的遵守。比如說,我們可以使 MyViewController 的 Extension 去遵守 UITableViewDataSource

extension MyViewController: UITableViewDataSource {

    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        // ...
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        // ...
    }

}

使用 Extension 來遵守 Protocol 可以說是模組化的最佳實踐,因為這使得我們能馬上就明白哪些方法與屬性是屬於哪個 Protocol 的。這也可以提醒自己把同個 Protocol 的方法寫在一起,使我們不用去煩惱哪個方法要寫在哪一行的同時,也可以寫出有條不紊的程式碼。

另外前面也有提到,我們可以在不同 Target 之間採取不同的檔案組合,以控制某型別功能的差異;但我們並沒有說要怎麼判斷該型別是否有某種功能。雖然我們可以用 Swift 編譯器的 Active Compilation Conditions 編譯符、與 #if/#else/#endif 判斷式,去決定所在的 Target 有沒有包含某個 Extension;但用 Protocol 來判斷的話,就不用去碰編譯設定了。

比如說,假設我們想要把 MyViewController 的編輯功能限縮在主程式之內的話,那我們可以這樣寫:

// MyViewController.swift
// 包含於所有 Target。

class MyViewController: UIViewController {

    @IBOutlet weak var imageView: UIImageView!

    @IBAction func deleteButtonWasPressed() {

        // 判斷 self 是不是 Editable。
        if let editableSelf = self as? Editable {
            editableSelf.setImage(nil)
        }
    }

}
// Editable.swift
// 包含於所有 Target。

protocol Editable {
    func setImage(_ image: UIImage?)
}
// MyViewController+Editing.swift
// 僅包含於主程式 Target。

extension MyViewController: Editable {

    func setImage(_ image: UIImage?) {

        imageView.image = image
    }

}

如此一來,在使用者按下刪除、並觸發 deleteButtonWasPressed() 的時候,Swift 的 Runtime 就會去檢查 MyViewController 是不是 Editable,進而執行編輯指令 setImage(_:)。而由於只有在主程式裡 MyViewController 才遵守 Editable,所以在其它的 Target 裡 setImage(_:) 是不會被執行的。

將程式碼歸類到適合的型別裡

在寫 View Controller 的時候,我們經常會用到各種工廠方法與輔助方法 (Helper Method)。它們可以使程式碼更整齊,也能幫助我們理解某一段程式碼的意義。

比如說,我們要寫一個負責彈出刪除警告視窗的輔助方法。而一如前文提到,我們可以把它放到一個 Extension 裡面去,以方便管理。

class MyViewController: UIViewController {

    // 負責刪除內容。
    func deleteContent() {
        // ...
    }

    // 處理刪除按鍵被按下的事件。
    @IBAction func deleteButtonItemWasPressed() {

        // 呼叫輔助方法。
        presentDeleteConfirmationAlert()
    }

}

// MARK: 輔助方法
extension MyViewController {

    // 顯示刪除確認警告的輔助方法。
    func presentDeleteConfirmationAlert() {

        // 創造 alertController。
        let alertController = UIAlertController(title: nil, message: "確定要刪除嗎?", preferredStyle: .alert)

        let deleteAction = UIAlertAction(title: "刪除", style: .destructive) { [weak self] _ in

            // 呼叫 self 的 deleteContent()。
            self?.deleteContent()
        }
        alertController.addAction(deleteAction)

        let cancelAction = UIAlertAction(title: "取消", style: .cancel, handler: nil)
        alertController.addAction(cancelAction)

        // 呈現 alertController。
        present(alertController, animated: true, completion: nil)
    }

}

透過把樣板程式碼 (boilerplate code) 用一個輔助方法包起來,並收納到 Extension 裡,我們使型別主體的程式碼更容易閱讀了。然而,這還不是最乾淨的方式,因為雖然這個輔助方法已經在 Extension 裡了,但它仍然跟型別主體互相依賴。這個時候,我們就可以想想有沒有更乾淨的寫法。

首先,讓我們來想想這個輔助方法到底做了甚麼事?在這裡,它主要就做了兩件事:創造一個 UIAlertController,以及去呈現它。但其實呈現所需的程式碼也就只有一行而已,所以這個方法最主要的職責,其實就是創造 UIAlertController 的實體。

沒錯,聽起來很像工廠方法。不過,Swift 其實提供了另一種工具來滿足這個需求:Convenience Initializer。也就是說,與其把它改寫成這樣:

extension MyViewController {

    func makeDeleteConfirmationAlert(deletionHandler: @escaping () -> Void) -> UIAlertController {
        // ...
    }

}

不如試試寫成這樣:

extension UIAlertController {

    convenience init(deletionHandler: @escaping () -> Void) {
        // ...
    }

}

為甚麼會有 deletionHandler 這個參數呢?因為原來的寫法裡,刪除的動作會直接去呼叫 self?.deleteContent(),造成對 MyViewController 的依賴。用閉包來取代掉這一行的話,就可以解除依賴了。整個程式碼如下:

class MyViewController: UIViewController {

    // 負責刪除內容。
    func deleteContent() {
        // ...
    }

    // 處理刪除按鍵被按下的事件。
    @IBAction func deleteButtonItemWasPressed() {

        let deleteConfirmationAlert = UIAlertController(deletionHandler: { [weak self] in
            self?.deleteContent()
        })
        present(deleteConfirmationAlert, animated: true, completion: nil)
    }

}

extension UIAlertController {

    convenience init(deletionHandler: @escaping () -> Void) {
        self.init(title: nil, message: "確定要刪除嗎?", preferredStyle: .alert)

        let deleteAction = UIAlertAction(title: "刪除", style: .destructive) { _ in

            // 呼叫傳入的 deletionHandler。
            deletionHandler()
        }
        addAction(deleteAction)

        let cancelAction = UIAlertAction(title: "取消", style: .cancel, handler: nil)
        addAction(cancelAction)
    }

}

現在,型別與型別之間的分工更清楚了!MyViewController 負責在適當時機要 UIAlertController 創造一個刪除警告出來,然後再呈現它;而 UIAlertController 則負責創造這個刪除警告。這就符合了「高聚合,低耦合 (High Cohesion, Low Coupling)」原則,或者簡單說,就是各司其職

另一個潛在的好處,是我們現在也可以在別的地方使用這個 Convenience Initializer 了。由於它沒有對官方框架以外的型別有任何依賴,所以它是可以在任何有 import UIKit 的檔案裡使用。

不過,程式碼的整理並不需要以重用性為目標。我們甚至可以把這個 Convenience Initializer 標記為 fileprivate,以使它只能在同一個檔案裡被使用。我們光是讓程式碼易於理解就已經達到目標了,不必要為了重用性而犧牲易讀性。

還有很棒的一點是:這樣寫可以省下很多字符。如果比較一下輔助方法版本的 presentDeleteConfirmationAlert() 與 Convenience Initializer 版本的 init(deletionHandler:),你會發現後者完全沒有出現 alertController 這個辨識符!因為前者的 alertController 在後者其實就是 self,所以可以整個省略掉。

事實上,這也可以拿來判斷說,把程式碼放到哪個型別的 Extension 會比較好。在一個輔助方法中,如果某個辨識符出現的頻率越高,那它就越可能是這個輔助方法中的主角,我們就可以試著把整個輔助方法移到該辨識符型別的 Extension 裡。

擴展通用型別

Extension 可以只針對滿足了某條件的通用型別 (Generic Type) 增加功能,這適合應用在內建的許多集合型別上,因為集合型別大多都是通用型別。比如說,在使用 UITableViewUICollectionView 的時候,我們常常會需要用 IndexPath 去取值,像是這樣:

struct Section {

    var title: String

    var texts: [String]

}

class MyViewController: UIViewController {

    var sections: [Section] = []

    // ...

}

extension MyViewController: UITableViewDataSource {

    // ...

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "cellIdentifier", for: indexPath)

        // 用 indexPath 向 self.sections 取值。
        let text = self.sections[indexPath.section].texts[indexPath.row]

        cell.textLabel?.text = text

        return cell
    }

}

可以看到,取個值就要寫一串長長的程式碼,用了兩次下標(Subscript)語法。但既然兩次下標的輸入來源都是同一個 indexPath,我們有沒有辦法簡化這整個陳述呢?

有的,Extension 就可以做到了。

// 設定為只有在 Array<Section> 的時候才加功能。
extension Array where Element == Section {

    subscript(indexPath: IndexPath) -> String {
        get {

            // 因為確認 Element 是 Section,所以 self 的型別已經是 [Section] 了。
            return self[indexPath.section].texts[indexPath.row]
        }
        // 必須加上 mutating 才能更動 self,因為 Array 是一個 Structure。
        mutating set {
            self[indexPath.section].texts[indexPath.row] = newValue
        }
    }

}

用 Extension 加入上述的下標之後,我們就可以把原本的方法改成這樣:

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell = tableView.dequeueReusableCell(withIdentifier: "cellIdentifier", for: indexPath)

    cell.textLabel?.text = self.sections[indexPath]

    return cell
}

是不是簡化許多了呢?

結論

軟體重構這件事,其實就是對程式碼做整體的整理。除了大規模的架構更動之外,小規模的分段、分檔案等都是能增加程式碼可讀性的手段,而 Extension 就很適合拿來應用在小規模的重構。有的時候,你甚至會發現有些問題其實不用去寫一個新的型別來處理,只要一個 Extension 就可以解決了。讀完這篇,也希望你得到一些活用 Extension 的靈感!

作者
Hsu Li-Heng
iOS 開發者、寫作者、filmmaker。現正負責開發 Storyboards by narrativesaw 此一故事板文件 app 中。深深認同 Swift 對於程式碼易讀性的重視。個人網站:lihenghsu.com。電郵:[email protected]
評論
很好! 你已成功註冊。
歡迎回來! 你已成功登入。
你已成功訂閱 AppCoda 中文版 電子報。
你的連結已失效。
成功! 請檢查你的電子郵件以獲取用於登入的連結。
好! 你的付費資料已更新。
你的付費方式並未更新。