View Controller 可以說是 iOS 開發裡的核心物件。這不只是因為它佔據了 MVC (Model-View-Controller) 中的中心位置 Controller,還因為 UIKit 團隊有意推動場景導向的設計。UIKit 本身是以當時的 AppKit 為基礎,針對行動系統所重新設計出來的一個框架。在原本的 AppKit 裡面,MVC 基本上是以視窗為單位的,以 NSWindowController
為最主要的 Controller。但是在 UIKit 裡面,視窗的重要性被大大降低,取而代之的是一個個「場景」。場景的位階低於視窗,類似於 View,但也不是普通的 View,因為它在一般的 View 階層之上還多了類似於視窗的呈現方式、以及從場景到場景之間的轉場。然而在程式碼中,並沒有針對場景而在 UIWindow
與 UIView
之間另外創造一個 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 的職責。
依賴與依賴注入
什麼是依賴注入?這個詞聽起來很艱深,而它的概念也不如網路上的人說得那麼簡單。那些人說,依賴注入其實就是把某個東西所需的資訊從外部設定給它而已:
I love to use¹ dependency² injection³
¹ pass
² values
³ to functions— patrick thomson (@importantshock) 2019年1月17日
這是錯的。依賴注入所注入的是依賴,而不是值或物件。所以,單純把某個屬性從自己建立,改成交給別的物件去指派,並不能算是依賴注入。
但是,什麼是依賴呢?
所謂的「依賴」,指的是有用到某型別的意思。拿以下的這個例子來說:
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
的,所以這還不能稱之為依賴注入。
當我們說「依賴注入」的時候,目的是避免對特定型別的依賴。也就是說,不管未來我們怎麼修改 Dog
,Master
的程式碼都不需要跟著改。要達到這個目的,我們必須要把 Dog
這個型別從 Master
當中整個拿掉。
class Master {
var pet: Any
init(pet: Any) {
self.pet = pet
}
func pokePet() {
}
}
但是拿掉 Dog
之後,Master
的 pet
就會變成沒有型別,也沒辦法呼叫它的任何方法了。這時,我們可以另外寫一個抽象型別來給 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
),並在生成該類型的物件時,再把實際上提供服務的型別(Dog
、Cat
等有遵守 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
型別。萬一 ModelManager
的 requestJSONData(forIdentifier:handler:)
介面有什麼更動,或甚至我們想要換成另一個類型的物件來提供圖片,那我們就勢必得重寫 reloadData()
了。這裡,我們就可以改用依賴注入,將 ImageViewController
與 ModelManager
的耦合解開。
在 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 與轉場對象的依賴解耦。敬請期待!