iOS App 程式開發

Massive View Controller 重構:透過依賴注入 (Dependency Injection) 減輕職責

Massive View Controller 重構:透過依賴注入 (Dependency Injection) 減輕職責
Massive View Controller 重構:透過依賴注入 (Dependency Injection) 減輕職責
In: iOS App 程式開發, Swift 程式語言, UIKit

View Controller 可以說是 iOS 開發裡的核心物件。這不只是因為它佔據了 MVC (Model-View-Controller) 中的中心位置 Controller,還因為 UIKit 團隊有意推動場景導向的設計。UIKit 本身是以當時的 AppKit 為基礎,針對行動系統所重新設計出來的一個框架。在原本的 AppKit 裡面,MVC 基本上是以視窗為單位的,以 NSWindowController 為最主要的 Controller。但是在 UIKit 裡面,視窗的重要性被大大降低,取而代之的是一個個「場景」。場景的位階低於視窗,類似於 View,但也不是普通的 View,因為它在一般的 View 階層之上還多了類似於視窗的呈現方式、以及從場景到場景之間的轉場。然而在程式碼中,並沒有針對場景而在 UIWindowUIView 之間另外創造一個 UIScene 的類型,而是把場景控制整個交給 UIViewController 來處理。除此之外,還把 NSWindow 大幅簡化、拿掉 NSWindowController,把它們原本的責任都交給 UIViewController。也就是說,所有屬於 Controller 的重大責任,外加場景的處理,幾乎都在 UIViewController 的肩上了。

這樣的情況在 Storyboard 出現以後更是雪上加霜。原本在沒有 Storyboard 的時候,還需要透過 AppDelegate 去創造視窗的根 VC,使它負擔了一部份 Controller 的職責。但用了 Storyboard 之後,AppDelegate 的使用不再是必須,所有的程式碼都可以寫在 UIViewController 的子類型裡面,等於是獨尊 View Controller。

在這樣的情形之下,我們很容易把一個場景中絕大多數的程式碼都丟給代表該場景的 View Controller 來處理,使 View Controller 變得肥大、複雜,而難以維護和測試。因此,iOS 的 MVC 模式,就被諷刺其實是 Massive View Controller 的縮寫才對。

近年來許多不同的架構模式,像是 MVVM、VIPER、MVP 等,都是針對 Massive View Controller 的問題而出現的。但是,這並不代表 iOS MVC 就必然會導向 View Controller 的腫大。事實上,我們仍然可以透過一些古典的物件導向程式設計 (Object-Oriented Programming, OOP) 原則來重構 Massive View Controller,這方法不需要開發者去學習一套新的軟體架構;相反,這可能會使你重新認識 MVC 與 OOP。今天要介紹的,就是如何透過依賴注入 (Dependency Injection) 來減輕一個 View Controller 的職責。

依賴與依賴注入

什麼是依賴注入?這個詞聽起來很艱深,而它的概念也不如網路上的人說得那麼簡單。那些人說,依賴注入其實就是把某個東西所需的資訊從外部設定給它而已:

這是錯的。依賴注入所注入的是依賴,而不是值或物件。所以,單純把某個屬性從自己建立,改成交給別的物件去指派,並不能算是依賴注入。

但是,什麼是依賴呢?

所謂的「依賴」,指的是有用到某型別的意思。拿以下的這個例子來說:

class Dog {

    func bark() {
        print("Bark!")
    }

}

class Master {

    var pet = Dog()

    func pokePet() {
        pet.bark()
    }

}

由於有用到 Dog,所以 Master 是依賴於 Dog 的。用 UML(統一塑模語言)表示的話是這個樣子:

依賴關係所造成的壞處是,如果 A 型別依賴於 B 型別的話,那當 B 型別有所修改的時候,A 型別很可能也要跟著改,像這樣:

class Dog {

    // 從 bark() 改名而來。
    func woof() {
        print("Woof!")
    }

}

class Master {

    var pet = Dog()

    func pokePet() {

        // 必須跟著改。
        pet.woof()
    }

}

這樣的依賴關係並不會因為我們把 pet 交給別人設定而解除。請看這個例子:

class Master {

    var pet: Dog

    init(pet: Dog) {
        self.pet = pet
    }

    func pokePet() {

        // 仍然在使用 Dog 的介面。
        pet.woof()
    }

}

雖然我們是把 Master 所需要的 pet 用建構式指派給它,但 Master 仍然是依賴於 Dog 的,所以這還不能稱之為依賴注入。

當我們說「依賴注入」的時候,目的是避免對特定型別的依賴。也就是說,不管未來我們怎麼修改 DogMaster 的程式碼都不需要跟著改。要達到這個目的,我們必須要把 Dog 這個型別從 Master 當中整個拿掉。

class Master {

    var pet: Any

    init(pet: Any) {
        self.pet = pet
    }

    func pokePet() {

    }

}

但是拿掉 Dog 之後,Masterpet 就會變成沒有型別,也沒辦法呼叫它的任何方法了。這時,我們可以另外寫一個抽象型別來給 pet 用。在 Swift 中,它可以是類型,也可以是協定:

protocol Pet {
    func wasPoked()
}

// 或者:

class Pet {

    func wasPoked() {
        // 等待被覆寫、實作。
    }

}

接下來,我們就可以把 Master 改成如下:

class Master {

    var pet: Pet

    init(pet: Pet) {
        self.pet = pet
    }

    func pokePet() {
        pet.wasPoked()
    }

}

只要 Dog 遵守 Pet 這個協定(或者繼承它),不管其它的實作怎麼改,我們都不用再跟著去修改 Master 的實作。

extension Dog: Pet {

    func wasPoked() {
        woof()
    }

}

let master = Master(pet: Dog())

master.pokePet() // Woof!

除此之外,我們還可以指派其它的寵物給 Master,只要它也遵守 Pet

class Cat {

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

}

extension Cat: Pet {

    func wasPoked() {
        meow()
    }

}

master.pet = Cat()

master.pokePet() // Meow!

這樣子把某個類型會用到的服務抽象化(從原本的 Dog 變成 Pet),並在生成該類型的物件時,再把實際上提供服務的型別(DogCat 等有遵守 Pet 的型別 )注入給它,就是依賴注入。畫成 UML 圖的話,就是這樣:

(由於軟體限制,空心箭頭在此以實心箭頭取代,代表了「實作」的意思)

可以看到,改用依賴注入之後,Master 不再知道它用的是 Dog 還是 Cat,它互動的對象變成抽象的介面 Pet。而由於 Pet 只是把 Master 需要對它的 pet 做的事情集合起來而已,所以可以有效地防止 Master 去調用 Pet 介面背後的物件中其它的方法。

依賴注入一詞的發明人之一 Martin Fowler,也是這樣解釋依賴注入的[^1]:


圖片從 Martin Fowler 個人網站擷取,版權由 Martin Fowler 所有

從圖中我們可以看到,上面中間的 MovieFinder 其實就是一個抽象介面,是左邊 MovieLister 唯一的依賴。MovieLister 不必知道是誰在 MovieFinder 介面的後面,更不用去管要怎麼取得一個 MovieFinder 的實體 ── 這些事情交給右下角的 Assembler 去煩惱就好。MovieLister 唯一需要關心的,就只是它要怎麼樣使用 MovieFinder 而已。

也就是說,依賴注入根本就跟是不是把值從外面注入無關。Swift 的泛型 (Generics) 就是最好的例子。讓我們看看我們可以怎麼用泛型來改造 Master

protocol Pet {
    init()
    func wasPoked()
}

class Master<T> where T: Pet {

    var pet = T()

    func pokePet() {
        pet.wasPoked()
    }

}

class Cat {

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

    required init() { }

}

extension Cat: Pet {

    func wasPoked() {
        meow()
    }

}

// 注入依賴。
let master = Master<Cat>()

master.pokePet() // Meow!

在這個例子中,我們雖然把 pet 的建構交給 Master 自己來處理,但仍然透過指定泛型的實際型別做到了依賴注入。

透過依賴注入,我們使某型別與其它的實際型別脫鉤,達成了型別之間的低耦合 (low coupling),並使各個型別更專注於它所負責的問題領域 (problem domain)。那麼,就 View Controller 來說,我們可以怎麼用依賴注入來重構它呢?

View Controller 與依賴注入

先假設我們需要做一個顯示單張圖片的場景,而且它具備了分享圖片的能力。這個 app 是透過以下的 ModelManager 來讀取遠端資料的:

class ModelManager {

    // 單例。
    static let shared = ModelManager()

    func requestJSONData(forIdentifier identifier: String, handler: (Data?) -> Void) {
        // 實作...
    }

}

而我們寫了一個 View Controller 來代表這個顯示圖片的場景:

class ImageViewController: UIViewController {

    @IBOutlet private weak var imageView: UIImageView!

    var identifier: String = "" {
        didSet {
            if isViewLoaded {
                reloadData()
            }
        }
    }

    override func viewDidLoad() {
        super.viewDidLoad()

        reloadData()
    }

    func reloadData() {
        ModelManager.shared.requestJSONData(forIdentifier: identifier) { jsonData in
            guard let jsonData = jsonData else { return }
            guard let jsonObject = try! JSONSerialization.jsonObject(with: jsonData, options: []) as? [String : Any] else { return }
            guard let imageString = jsonObject["image_base64"] as? String else { return }
            guard let imageData = Data(base64Encoded: imageString) else { return }
            self.imageView.image = UIImage(data: imageData)
        }
    }

}

這個 ImageViewController 會接收一個 identifier,並用之去向 ModelManager 請求它所需的圖片資料。用 UML 來表示的話,就是這樣:

我們可以發現,ImageViewController 依賴的是一個實際的 ModelManager 型別。萬一 ModelManagerrequestJSONData(forIdentifier:handler:) 介面有什麼更動,或甚至我們想要換成另一個類型的物件來提供圖片,那我們就勢必得重寫 reloadData() 了。這裡,我們就可以改用依賴注入,將 ImageViewControllerModelManager 的耦合解開。

在 View Controller 中,對 Model 資料提供者的抽象介面通常就叫做 Data Source:

protocol ImageViewControllerDataSource: AnyObject {
    func imageViewController(_ imageVC: ImageViewController, requestImageWithHandler handler: @escaping (UIImage?) -> Void) 
}

class ImageViewController: UIViewController {

    weak var dataSource: ImageViewControllerDataSource?

    // 其它實作...

}

有了這個介面,我們就可以把 reloadData() 改成這樣:

    func reloadData() {
        dataSource?.imageViewController(self, requestImageWithHandler: { image in
            self.imageView.image = image
        })
    }

是不是簡潔很多呢?

因為我們向 Data Source 要求的,是一個直接可以用的 UIImage 物件,所以所有的轉換與邏輯就都交給 Data Source 去煩惱了:

extension ModelManager: ImageViewControllerDataSource {

    func imageViewController(_ imageVC: ImageViewController, requestImageWithHandler handler: @escaping (UIImage?) -> Void) {

        requestJSONData(forIdentifier: imageVC.identifier) { jsonData in
            guard let jsonData = jsonData,
                let jsonObject = try! JSONSerialization.jsonObject(with: jsonData, options: []) as? [String : Any],
                let imageString = jsonObject["image_base64"] as? String,
                let imageData = Data(base64Encoded: imageString) else {
                    handler(nil)
                    return
            }
            let image = UIImage(data: imageData)

            handler(image)
        }
    }

}

要使用 ImageViewController 的時候,只要把 ModelManager 指派給它的 dataSource 就可以了:

let imageVC = ImageViewController()

imageVC.dataSource = ModelManager.shared

想要把它的 Data Source 整個都換掉也沒問題:

class ImageDocument: UIDocument {

    var images: [String : UIImage] = [:]

    // 其它實作...

}

extension ImageDocument: ImageViewControllerDataSource {

    func imageViewController(_ imageVC: ImageViewController, requestImageWithHandler handler: @escaping (UIImage?) -> Void) {
        open { success in
            guard success else {
                handler(nil)
                return
            }
            handler(self.images[imageVC.identifier])
        }
    }

}

let document = ImageDocument(fileURL: URL(fileURLWithPath: "/path/to/my/document"))

imageVC.dataSource = document

畫成 UML 圖的話是這樣:

這使 ImageViewController 的可測試性大大提高,因為現在我們可以輕易地做出一個假的 Data Source 給它用。同時,這也使它的程式碼更容易閱讀,因為我們可以把每個動作的意圖清楚地寫在 Data Source 的方法內。最棒的是,我們透過直接要求它真正需要的資料 (UIImage),把所有其它的 Model 資料處理都轉交給 Data Source 的實作者去做,重新達成 MVC 的分責。

這所有的好處,只要寫一個 Data Source 協定與一個 dataSource 屬性就可以達到,這就是依賴注入的力量!

結論

在這篇文章中,我介紹了 OOP 中依賴與依賴注入的概念,以及以依賴注入去重構 View Controller 的技巧。透過依賴注入,我們將 View Controller 與程式的 Model Manager 解耦,改成透過 Data Source 模式來與 Model Manager 溝通。然而,View Controller 的依賴並不是只有 Model Manager 而已。下一篇,我會來探討如何將 View Controller 與轉場對象的依賴解耦。敬請期待!

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