依賴反轉原則

蘇醒宇
Dcard Tech Blog
Published in
12 min readJun 15, 2023

前言

上次 Dcard 線上分享會跟大家分享了隨著我們團隊人數的成長,我們引進了 Tuist 這個工具讓我們擺脫了 Xcode project 🙌,大幅的減少了合併分支時的衝突。

另外使用 Tuist 還有一個好處是可以只做出要修改的部分的專案,在建立這個專案時 Tuist 會幫我們引用相關的 module 和 target,讓這個專案是可以成功被編譯的。因此,若把各個功能做成獨立的 module ,就可以大幅減少需要引用的 code 的量,加速小部分功能的編譯速度👍🏻。

但隨著開發團隊變多,功能變的複雜,各個 module 間 import 的衝突也漸漸出現。例如,在看板頁面中點擊要顯示文章,在文章中點擊看板名稱也要顯示看板頁面。

為了要可以讓「看板頁面」和「文章頁面」拆分為獨立的兩個 module,就要用到我們今天的主角 — 依賴反轉原則(Dependency Inversion Principle)啦🎉!

依賴反轉原則

根據定義(取自維基):
1. 高層次的模組不應該依賴於低層次的模組,兩者都應該依賴於抽象介面。
2. 抽象介面不應該依賴於具體實現。而具體實現則應該依賴於抽象介面。

簡單來說呢,我們可以根據自身 module 中的實作製作一層抽象層的介面,將這些介面放在一個獨立的 target 中,外部要使用時就只需要 import 介面層即可。以我們的例子來說,forum module 會引用 forum interface 來實作我們定義的介面,也會引用 post interface 來取得文章頁面的型別來使用。

下面我們以程式碼來實踐上面的想法👨🏻‍💻:

// Post Interface

struct Post {...}

protocol PostViewControllerType: UIViewController {
init(post: Post)
}


// Forum Interface

struct Forum {...}

protocol ForumViewControllerType: UIViewController {
init(forum: Forum)
}


// Forum Module

import ForumInterface
import PostInterface

func ForumViewController: UIViewController, ForumViewControllerType {

init(forum: Forum) {...}

func present(post: Post) {

// 🔴 ERROR: Type 'any PostViewControllerType' cannot be instantiated
let viewController = PostViewControllerType.init(post)

navigationController.pushViewController(
viewController, animated: true
)
}
}

不過我們很快就遇到問題了,如同上面的註解所示,我們引用了 PostInterface 後雖可以知道「文章頁面」抽象後的 protocol 是 PostViewControllerType,但我們還是無法取得真正的實作來創建「文章頁面」😢。因此,我們還需要「依賴反轉原則」的另一個好朋友「依賴注入(Dependency Injection)」來幫我們做到這件事😀。

依賴注入

根據定義(取自維基):
「依賴」是指接收方所需的物件。「注入」是指將「依賴」傳遞給接收方的過程。

簡單來說呢,我們需要有一個人可以知道 PostViewControllerType 在 app 中應該使用的實作到底是誰,當我們想要使用 PostViewControllerType 時,可以給我們一個對應的型別,在這邊我們就先把「這個人」叫做 Dependency 吧。依賴和注入的關係會像下圖這樣:

從上面我們可以看到,透過 Dependency 提供實作時,ForumViewController 依然只需要知道 PostViewControllerType, 並不會增加依賴關係。接下來我們有兩個方案可以選,一個是直接提供實體,另一個是提供型別:

/// Dependency Module

class Dependency {
func resolve<T>(type: T.Type) -> T // 提供實體
func resolve<T>(type: T.Type) -> T.Type // 提供型別(這樣使用有問題後面會提到)
}

提供實體的作法相對簡單,大概念如下:

class Dependency {

static let shared = Dependency()

typealias TypeAlias = String
typealias Provider<T> = () -> T

private var relations: [TypeAlias: Any] = [:]

func inject<T>(type: T.Type, provider: @escaping Provider<T>){
let alias = String(reflecting: type)
relations[alias] = provider
}

func resolve<T>(type: T.Type) throws -> T {
let alias = String(reflecting: type)
if let provider = relations[alias] as? Provider<T> {
return provider()
} else {
// Throw error
}
}
}

// Main app target
import DependencyModule
import PostInterface
import PostModule

Dependency.shared.inject(type: PostViewControllerType.self) {
PostViewController()
} // #1 注入實作

// Forum Module
import DependencyModule
import PostInterface

let postViewController = try Dependency.shared.resolve(type: PostViewControllerType.self)
present(postViewController, animated: true)

從上面的實作我們可以看出一些不足的部分:

第一個問題是 #1 註冊實作的部分,如果每新增一個型別都要自己到 main target 中註冊實作,難免會有人忘記來加🙈?

第二個問題是「直接提供實體」的方式其實有些缺點,是無法直接取得 class property 和 class function。例如我們會將一些常數訂為 class property,若要取得這些參數變得要先做出實體,然後再用 type(of: …) 來取得 class property。一來浪費效能,二來這會變成要使用依賴注入的時候,實作必須跟著修改,讓人有種薛足適履的感覺😅。

既然如此,我們馬上來改成「提供型別」的方式吧:

class Dependency {
func resolve<T>(type: T.Type) throws -> T.Type {...}
}

let postViewControllerType= try Dependency.shared.resolve(type: PostViewControllerType.self)

// 🔴 Type 'any PostViewControllerType' cannot be instantiated
// postViewControllerType is PostViewControllerType.Protocol
postViewControllerType.init()

但我們馬上就翻車啦,不過想想也是🤔,我要傳入一個 T.Type 要拿回一個 T.Type,我傳入 PostViewControllerType.self 當然也是拿回 PostViewControllerType 呀,那不就回到前面我們無法從 protocol 建立實體的問題🫠🫠🫠。

那既然如此,我們就把提供實作的這個工作(「提供實體」做法的 Dependency 的工作)也抽象出來,這樣就不會要從給定的 protocol 直接回傳實作型別啦🤩,整個流程大概會像下面這樣:

原本的 ForumViewController 會跟 Dependency 拿可以提供 PostInterface 中的實作的人 — Provider,再跟 Provider 拿 PostViewControllerType 的實作。

由於 PostInterface provider 已經是實體了,其本身儲存的也是實作的型別。而 ForumViewController 也只需要知道 Provider 是有實作 PostInterface 的人,因此也不會增加依賴關係。

下面我們就把它實作出來吧:

// Dependency Module

class Dependency {
static let shared = Dependency()

typealias TypeAlias = String
typealias Provider<T> = () -> T

private var relations: [TypeAlias: Any] = [:]

func inject<T>(interface: T.Type, provider: @escaping Provider<T>){
let alias = String(reflecting: interface)
relations[alias] = provider
}

func resolve<T>(interface: T.Type) throws -> T {
let alias = String(reflecting: interface)
if let provider = relations[alias] as? Provider<T> {
return provider()
} else {
// Throw error
}
}
}


// Post Interface

protocol PostInterface {
var postViewController: PostViewControllerType.Type { get }
}

protocol PostViewControllerType {

static var version: Int { get }
init(post: Post)
}


// Post Module

struct PostInterfaceProvider: PostInterface {
var postViewController: PostViewControllerType.Type {
PostViewController.self
}
}

class PostViewController: PostViewControllerType {
...
}


// Main app target

import DependencyModule
import PostInterface
import PostModule

Dependency.shared.inject(type: PostInterface.self) {
PostInterfaceProvider()
} // 注入 interface provider

// Forum Module
import DependencyModule
import PostInterface

let postViewControllerType = try Dependency.shared
.resolve(interface: PostInterface.self)
.postViewController // PostViewController.self

let postViewController = postViewControllerType.init(post: post)✅
present(postViewController, animated: true)

print(postViewControllerType.version)✅

這樣的實作方式也可以部分的解決原本的第一個問題,因爲只有在創建新的 module 時會需要在 mian app target 中註冊 interface provider,新增 module 的機會相對新增型別的幾會少很多。而當有人在 interface 中新增參數時,compiler 也會自動檢查 provider 是否有實作對應的參數👍🏻。

套用了依賴反轉的架構後,我們的 app 中的 import 關係就可以變成下面這樣啦:

上面使用的實作方式功能上是 OK 了,但在合作開發上還是有些可以改善的地方,例如任何的型別都可以向 Dependency 註冊和提取,但應該是要 interface 類型的 protocol 才可以,這樣才能減少其他開發者誤用的機會,不過這我們就之後有機會再跟大家分享囉,掰掰。

--

--