你是否曾被舊版 API 程式碼困住:刪不掉,直接用也很痛苦?
把老舊網路模組接到新畫面時,這是任何人都會遇到的障礙。
先說結論,最乾淨的解法是用協定(interface)包裝舊版 API,並在兩者之間放入適配器。
適配器模式是在兩個不相容的介面之間插入「轉換器」。
如果你容易把它和同樣包裝物件的 Facade、Proxy、Decorator 搞混,可以在 4 種包裝模式比較 先從目的區分它們。
今天就依照我親自走過的流程,整理如何在 Swift 中套用這個模式。
本文可以學到的三件事
先為忙碌的讀者整理重點。
- 先用協定定義新程式碼需要的形式
- 建立符合該協定、負責轉換舊版 API 的適配器型別
- 讓畫面與檢視模型只相依於協定,而不是舊版 API
只要遵守這三點,之後整個替換 API 時,需要修改的範圍就會大幅縮小。
改成這種結構後,我覺得撰寫測試程式碼方便多了。
為什麼需要 Swift 適配器模式?
舊版 API 通常不是我們想要的樣子。
它可能以回呼為基礎、參數混亂,或回傳型別模糊。
如果新程式碼被迫配合老舊 API,等它消失的那天,整個程式碼庫都會受到影響。
所以我們會在中間放入適配器。
假設舊模組長得像下面這樣。
// 難以下手的舊版程式碼 API (以回呼為基礎)
class LegacyUserAPI {
func fetch(id: Int,
done: @escaping (NSDictionary?) -> Void) {
// 老舊的網路呼叫...
}
}
如果把 NSDictionary 原封不動帶到畫面層,之後更換這個 API 時,連畫面程式碼都得全部拆掉重寫。
一看就知道不想直接接上的樣子。
如何用協定包裝舊版 API(3 個步驟)
實際的包裝流程比想像中簡單。
步驟 1:用協定定義想要的形式。
先寫下新程式碼希望能以「這種方式呼叫」的形式。
// 新程式碼需要的簡潔介面
protocol UserRepository {
func user(id: Int) async throws -> User
}
我把回呼改成 async/await,並將 NSDictionary 改成 User 型別。
步驟 2:讓適配器將舊版 API 調整為符合這個協定。
所有複雜的轉換都封裝在適配器內。
// 將舊版 API 轉換成新協定的適配器
struct LegacyUserAdapter: UserRepository {
let legacy = LegacyUserAPI()
func user(id: Int) async throws -> User {
try await withCheckedThrowingContinuation { cont in
legacy.fetch(id: id) { dict in
cont.resume(returning: User(dict))
}
}
}
}
在這裡,將回呼轉成 async 的 withCheckedThrowingContinuation 扮演核心角色。
步驟 3:讓畫面只相依於協定。
檢視模型不需要知道 LegacyUserAPI,只要知道 UserRepository 就可以了。
如此一來,舊版程式碼只會在適配器這一個地方出現。
使用適配器後有什麼不同?(與直接呼叫比較)
我用表格比較了直接呼叫與適配器方式。
| 項目 | 直接呼叫舊版 API | 用適配器包裝 |
|---|---|---|
| 替換 API 時的修改範圍 | 整個畫面 | 適配器一處 |
| 單元測試 | 困難 | 用 mock 輕鬆完成 |
| 新程式碼可讀性 | 低 | 高 |
| 初期工作量 | 少 | 稍微增加 |
初期工作量確實會稍微增加。
但我完全不覺得這筆成本浪費。
測試時只要放入一個實作 UserRepository 的假物件,就能在沒有網路的情況下驗證畫面邏輯。
即使實際 API 尚未完成,也能先開始開發。
常見問題(Q&A)
Q. 適配器應該做成 struct 還是 class?
如果沒有狀態,使用 struct 就足夠了。
如果必須持續持有舊版物件,或需要共享參考,請使用 class。
Q. 適配器模式和 Facade 模式有什麼不同?
適配器的目的是「讓介面相容」。
Facade 的目的是「將多個複雜元件以一個簡單介面呈現」,因此方向略有不同。
Q. 協定名稱該怎麼命名?
不要沿用舊版名稱,請從新程式碼的角度,依照所需角色命名。
例如使用 UserRepository,而不是 LegacyUserAPI。
不要勉強刪除舊版程式碼,先用協定與適配器將它安靜地隔離起來。
只要把舊程式碼關在一個地方,接下來的重構就會輕鬆許多。也試著先包裝專案中最混亂的一個 API 吧。

