本文是筆者在 MDCC 16 (移動開發者大會) 上 iOS 專場中的主題演講的文字整理。您可以在這裡找到演講使用的 Keynote,部分示例程式碼可以在 MDCC 2016 的官方 repo 中找到。
在上半部分主要介紹了一些理論方面的內容,包括物件導向程式設計存在的問題,面向協議的基本概念和決策模型等。本文 (下) 主要展示了一些筆者日常使用面向協議思想和 Cocoa 開發結合的示例程式碼,並對其進行了一些解說。
轉・熱戀 – 在日常開發中使用協議
WWDC 2015 在 POP 方面有一個非常優秀的主題演講:#408 Protocol-Oriented Programming in Swift。Apple 的工程師通過舉了畫圖表和排序兩個例子,來闡釋 POP 的思想。我們可以使用 POP 來解耦,通過組合的方式讓程式碼有更好的重用性。不過在 #408 中,涉及的內容偏向理論,而我們每天的 app 開發更多的面臨的還是和 Cocoa 框架打交道。在看過 #408 以後,我們就一直在思考,如何把 POP 的思想運用到日常的開發中?
我們在這個部分會舉一個實際的例子,來看看 POP 是如何幫助我們寫出更好的程式碼的。
基於 Protocol 的網路請求
網路請求層是實踐 POP 的一個理想場所。我們在接下的例子中將從零開始,用最簡單的面向協議的方式先構建一個不那麼完美的網路請求和模型層,它可能包含一些不合理的設計和耦合,但是卻是初步最容易得到的結果。然後我們將逐步捋清各部分的所屬,並用分離職責的方式來進行重構。最後我們會為這個網路請求層進行測試。通過這個例子,我希望能夠設計出包括型別安全,解耦合,易於測試和良好的擴充套件性等諸多優秀特性在內的 POP 程式碼。
Talk is cheap, show me the code.
初步實現
首先,我們想要做的事情是從一個 API 請求一個 JSON,然後將它轉換為 Swift 中可用的例項。作為例子的 API 非常簡單,你可以直接訪問 https://api.onevcat.com/users/onevcat 來檢視返回:
1 2 |
{"name":"onevcat","message":"Welcome to MDCC 16!"} |
我們可以新建一個專案,並新增 User.swift
來作為模型:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
// User.swift import Foundation struct User { let name: String let message: String init?(data: Data) { guard let obj = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] else { return nil } guard let name = obj?["name"] as? String else { return nil } guard let message = obj?["message"] as? String else { return nil } self.name = name self.message = message } } |
User.init(data:)
將輸入的資料 (從網路請求 API 獲取) 解析為 JSON 物件,然後從中取出 name
和 message
,並構建代表 API 返回的 User
例項,非常簡單。
現在讓我們來看看有趣的部分,也就是如何使用 POP 的方式從 URL 請求資料,並生成對應的 User
。首先,我們可以建立一個 protocol 來代表請求。對於一個請求,我們需要知道它的請求路徑,HTTP 方法,所需要的引數等資訊。一開始這個協議可能是這樣的:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
enum HTTPMethod: String { case GET case POST } protocol Request { var host: String { get } var path: String { get } var method: HTTPMethod { get } var parameter: [String: Any] { get } } |
將 host
和 path
拼接起來可以得到我們需要請求的 API 地址。為了簡化,HTTPMethod
現在只包含了 GET
和 POST
兩種請求方式,而在我們的例子中,我們只會使用到 GET
請求。
現在,可以新建一個 UserRequest
來實現 Request
協議:
1 2 3 4 5 6 7 8 9 10 11 |
struct UserRequest: Request { let name: String let host: = "https://api.onevcat.com" var path: String { return "/users/\(name)" } let method: HTTPMethod = .GET let parameter: [String: Any] = [:] } |
UserRequest
中有一個未定義初始值的 name
屬性,其他的屬性都是為了滿足協議所定義的。因為請求的引數使用者名稱 name
會通過 URL 進行傳遞,所以 parameter
是一個空字典就足夠了。有了協議定義和一個滿足定義的具體請求,現在我們需要傳送請求。為了任意請求都可以通過同樣的方法傳送,我們將傳送的方法定義在 Request
協議擴充套件上:
1 2 3 4 5 6 |
extension Request { func send(handler: @escaping (User?) -> Void) { // ... send 的實現 } } |
在 send(handler:)
的引數中,我們定義了可逃逸的 (User?) -> Void
,在請求完成後,我們呼叫這個 handler
方法來通知呼叫者請求是否完成,如果一切正常,則將一個 User
例項傳回,否則傳回 nil
。
我們想要這個 send
方法對於所有的 Request
都通用,所以顯然回撥的引數型別不能是 User
。通過在 Request
協議中新增一個關聯型別,我們可以將回撥引數進行抽象。在 Request
最後新增:
1 2 3 4 5 |
protocol Request { ... associatedtype Response } |
然後在 UserRequest
中,我們也相應地新增型別定義,以滿足協議:
1 2 3 4 5 |
struct UserRequest: Request { ... typealias Response = User } |
現在,我們來重新實現 send
方法,現在,我們可以用 Response
代替具體的 User
,讓 send
一般化。我們這裡使用 URLSession
來傳送請求:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
extension Request { func send(handler: @escaping (Response?) -> Void) { let url = URL(string: host.appending(path))! var request = URLRequest(url: url) request.httpMethod = method.rawValue // 在示例中我們不需要 `httpBody`,實踐中可能需要將 parameter 轉為 data // request.httpBody = ... let task = URLSession.shared.dataTask(with: request) { data, res, error in // 處理結果 print(data) } task.resume() } } |
通過拼接 host
和 path
,可以得到 API 的 entry point。根據這個 URL 建立請求,進行配置,生成 data task 並將請求傳送。剩下的工作就是將回撥中的 data
轉換為合適的物件型別,並呼叫 handler
通知外部呼叫者了。對於 User
我們知道可以使用 User.init(data:)
,但是對於一般的 Response
,我們還不知道要如何將資料轉為模型。我們可以在 Request
裡再定義一個 parse(data:)
方法,來要求滿足該協議的具體型別提供合適的實現。這樣一來,提供轉換方法的任務就被“下放”到了 UserRequest
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
protocol Request { ... associatedtype Response func parse(data: Data) -> Response? } struct UserRequest: Request { ... typealias Response = User func parse(data: Data) -> User? { return User(data: data) } } |
有了將 data
轉換為 Response
的方法後,我們就可以對請求的結果進行處理了:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
extension Request { func send(handler: @escaping (Response?) -> Void) { let url = URL(string: host.appending(path))! var request = URLRequest(url: url) request.httpMethod = method.rawValue // 在示例中我們不需要 `httpBody`,實踐中可能需要將 parameter 轉為 data // request.httpBody = ... let task = URLSession.shared.dataTask(with: request) { data, _, error in if let data = data, let res = parse(data: data) { DispatchQueue.main.async { handler(res) } } else { DispatchQueue.main.async { handler(nil) } } } task.resume() } } |
現在,我們來試試看請求一下這個 API:
1 2 3 4 5 6 7 8 9 |
let request = UserRequest(name: "onevcat") request.send { user in if let user = user { print("\(user.message) from \(user.name)") } } // Welcome to MDCC 16! from onevcat |
重構,關注點分離
雖然能夠實現需求,但是上面的實現可以說非常糟糕。讓我們看看現在 Request
的定義和擴充套件:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
protocol Request { var host: String { get } var path: String { get } var method: HTTPMethod { get } var parameter: [String: Any] { get } associatedtype Response func parse(data: Data) -> Response? } extension Request { func send(handler: @escaping (Response?) -> Void) { ... } } |
這裡最大的問題在於,Request
管理了太多的東西。一個 Request
應該做的事情應該僅僅是定義請求入口和期望的響應型別,而現在 Request
不光定義了 host
的值,還對如何解析資料瞭如指掌。最後 send
方法被綁死在了 URLSession
的實現上,而且是作為 Request
的一部分存在。這是很不合理的,因為這意味著我們無法在不更改請求的情況下更新傳送請求的方式,它們被耦合在了一起。這樣的結構讓測試變得異常困難,我們可能需要通過 stub 和 mock 的方式對請求攔截,然後返回構造的資料,這會用到 NSURLProtocol
的內容,或者是引入一些第三方的測試框架,大大增加了專案的複雜度。在 Objective-C 時期這可能是一個可選項,但是在 Swift 的新時代,我們有好得多的方法來處理這件事情。
讓我們開始著手重構剛才的程式碼,併為它們加上測試吧。首先我們將 send(handler:)
從 Request
分離出來。我們需要一個單獨的型別來負責傳送請求。這裡基於 POP 的開發方式,我們從定義一個可以傳送請求的協議開始:
1 2 3 4 5 6 |
protocol Client { func send(_ r: Request, handler: @escaping (Request.Response?) -> Void) } // 編譯錯誤 |
從上面的宣告從語義上來說是挺明確的,但是因為 Request
是含有關聯型別的協議,所以它並不能作為獨立的型別來使用,我們只能夠將它作為型別約束,來限制輸入引數 request
。正確的宣告方式應當是:
1 2 3 4 5 6 |
protocol Client { func send(_ r: T, handler: @escaping (T.Response?) -> Void) var host: String { get } } |
除了使用 這個泛型方式以外,我們還將
host
從 Request
移動到了 Client
裡,這是更適合它的地方。現在,我們可以把含有 send
的 Request
協議擴充套件刪除,重新建立一個型別來滿足 Client
了。和之前一樣,它將使用 URLSession
來傳送請求:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
struct URLSessionClient: Client { let host = "https://api.onevcat.com" func send(_ r: T, handler: @escaping (T.Response?) -> Void) { let url = URL(string: host.appending(r.path))! var request = URLRequest(url: url) request.httpMethod = r.method.rawValue let task = URLSession.shared.dataTask(with: request) { data, _, error in if let data = data, let res = parse(data: data) { DispatchQueue.main.async { handler(res) } } else { DispatchQueue.main.async { handler(nil) } } } task.resume() } } |
現在傳送請求的部分和請求本身分離開了,而且我們使用協議的方式定義了 Client
。除了 URLSessionClient
以外,我們還可以使用任意的型別來滿足這個協議,併傳送請求。這樣網路層的具體實現和請求本身就不再相關了,我們之後在測試的時候會進一步看到這麼做所帶來的好處。
現在這個的實現裡還有一個問題,那就是 Request
的 parse
方法。請求不應該也不需要知道如何解析得到的資料,這項工作應該交給 Response
來做。而現在我們沒有對 Response
進行任何限定。接下來我們將新增一個協議,滿足這個協議的型別將知道如何將一個 data
轉換為實際的型別:
1 2 3 4 |
protocol Decodable { static func parse(data: Data) -> Self? } |
Decodable
定義了一個靜態的 parse
方法,現在我們需要在 Request
的 Response
關聯型別中為它加上這個限制,這樣我們可以保證所有的 Response
都可以對資料進行解析,原來 Request
中的 parse
宣告也就可以移除了:
1 2 3 4 5 6 7 8 9 10 11 |
// 最終的 Request 協議 protocol Request { var path: String { get } var method: HTTPMethod { get } var parameter: [String: Any] { get } // associatedtype Response // func parse(data: Data) -> Response? associatedtype Response: Decodable } |
最後要做的就是讓 User
滿足 Decodable
,並且修改上面 URLSessionClient
的解析部分的程式碼,讓它使用 Response
中的 parse
方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
extension User: Decodable { static func parse(data: Data) -> User? { return User(data: data) } } struct URLSessionClient: Client { func send(_ r: T, handler: @escaping (T.Response?) -> Void) { ... // if let data = data, let res = parse(data: data) { if let data = data, let res = T.Response.parse(data: data) { ... } } } |
最後,將 UserRequest
中不再需要的 host
和 parse
等清理一下,一個型別安全,解耦合的面向協議的網路層就呈現在我們眼前了。想要呼叫 UserRequest
時,我們可以這樣寫:
1 2 3 4 5 6 |
URLSessionClient().send(UserRequest(name: "onevcat")) { user in if let user = user { print("\(user.message) from \(user.name)") } } |
當然,你也可以為 URLSessionClient
新增一個單例來減少請求時的建立開銷,或者為請求新增 Promise 的呼叫方式等等。在 POP 的組織下,這些改動都很自然,也不會牽扯到請求的其他部分。你可以用和 UserRequest
型別相似的方式,為網路層新增其他的 API 請求,只需要定義請求所必要的內容,而不用擔心會觸及網路方面的具體實現。
網路層測試
將 Client
宣告為協議給我們帶來了額外的好處,那就是我們不在侷限於使用某種特定的技術 (比如這裡的 URLSession
) 來實現網路請求。利用 POP,你只是定義了一個傳送請求的協議,你可以很容易地使用像是 AFNetworking 或者 Alamofire 這樣的成熟的第三方框架來構建具體的資料並處理請求的底層實現。我們甚至可以提供一組“虛假”的對請求的響應,用來進行測試。這和傳統的 stub & mock 的方式在概念上是接近的,但是實現起來要簡單得多,也明確得多。我們現在來看一看具體應該怎麼做。
我們先準備一個文字檔案,將它新增到專案的測試 target 中,作為網路請求返回的內容:
1 2 3 |
// 檔名:users:onevcat {"name":"Wei Wang", "message": "hello"} |
接下來,可以建立一個新的型別,讓它滿足 Client
協議。但是與 URLSessionClient
不同,這個新型別的 send
方法並不會實際去建立請求,併傳送給伺服器。我們在測試時需要驗證的是一個請求發出後如果伺服器按照文件正確響應,那麼我們應該也可以得到正確的模型例項。所以這個新的 Client
需要做的事情就是從本地檔案中載入定義好的結果,然後驗證模型例項是否正確:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
struct LocalFileClient: Client { func send(_ r: T, handler: @escaping (T.Response?) -> Void) { switch r.path { case "/users/onevcat": guard let fileURL = Bundle(for: ProtocolNetworkTests.self).url(forResource: "users:onevcat", withExtension: "") else { fatalError() } guard let data = try? Data(contentsOf: fileURL) else { fatalError() } handler(T.Response.parse(data: data)) default: fatalError("Unknown path") } } // 為了滿足 `Client` 的要求,實際我們不會傳送請求 let host = "" } |
LocalFileClient
做的事情很簡單,它先檢查輸入請求的 path
屬性,如果是 /users/onevcat
(也就是我們需要測試的請求),那麼就從測試的 bundle 中讀取預先定義的檔案,將其作為返回結果進行 parse
,然後呼叫 handler
。如果我們需要增加其他請求的測試,可以新增新的 case
項。另外,載入本地檔案資源的部分應該使用更通用的寫法,不過因為我們這裡只是示例,就不過多糾結了。
在 LocalFileClient
的幫助下,現在可以很容易地對 UserRequest
進行測試了:
1 2 3 4 5 6 7 8 9 |
func testUserRequest() { let client = LocalFileClient() client.send(UserRequest(name: "onevcat")) { user in XCTAssertNotNil(user) XCTAssertEqual(user!.name, "Wei Wang") } } |
通過這種方法,我們沒有依賴任何第三方測試庫,也沒有使用 url 代理或者執行時訊息轉發等等這些複雜的技術,就可以進行請求測試了。保持簡單的程式碼和邏輯,對於專案維護和發展是至關重要的。
可擴充套件性
因為高度解耦,這種基於 POP 的實現為程式碼的擴充套件提供了相對寬鬆的可能性。我們剛才已經說過,你不必自行去實現一個完整的 Client
,而可以依賴於現有的網路請求框架,實現請求傳送的方法即可。也就是說,你也可以很容易地將某個正在使用的請求方式替換為另外的方式,而不會影響到請求的定義和使用。類似地,在 Response
的處理上,現在我們定義了 Decodable
,用自己手寫的方式在解析模型。我們完全也可以使用任意的第三方 JSON 解析庫,來幫助我們迅速構建模型型別,這僅僅只需要實現一個將 Data
轉換為對應模型型別的方法即可。
如果你對 POP 方式的網路請求和模型解析感興趣的話,不妨可以看看 APIKit 這個框架,我們在示例中所展示的方法,正是這個框架的核心思想。
合・陪伴 – 使用協議幫助改善程式碼設計
通過面向協議的程式設計,我們可以從傳統的繼承上解放出來,用一種更靈活的方式,搭積木一樣對程式進行組裝。每個協議專注於自己的功能,特別得益於協議擴充套件,我們可以減少類和繼承帶來的共享狀態的風險,讓程式碼更加清晰。
高度的協議化有助於解耦、測試以及擴充套件,而結合泛型來使用協議,更可以讓我們免於動態呼叫和型別轉換的苦惱,保證了程式碼的安全性。
提問環節
主題演講後有幾位朋友提了一些很有意義的問題,在這裡我也稍作整理。有可能問題和回答與當時的情形會有小的出入,僅供參考。
我剛才在看 demo 的時候發現,你都是直接先寫 protocol
,而不是 struct
或者 class
。是不是我們在實踐 POP 的時候都應該直接先定義協議?
我直接寫
protocol
是因為我已經對我要做什麼有充分的瞭解,並且希望演講不要超時。但是實際開發的時候你可能會無法一開始就寫出合適的協議定義。建議可以像我在 demo 中做的那樣,先“粗略”地進行定義,然後通過不斷重構來得到一個最終的版本。當然,你也可以先用紙筆勾勒一個輪廓,然後再去定義和實現協議。當然了,也沒人規定一定需要先定義協議,你完全也可以從普通型別開始寫起,然後等發現共通點或者遇到我們之前提到的困境時,再回頭看看是不是面向協議更加合適,這需要一定的 POP 經驗。
既然 POP 有這麼多好處,那我們是不是不再需要物件導向,可以全面轉向面向協議了?
答案可能讓你失望。在我們的日常專案中,每天打交道的 Cocoa 其實還是一個帶有濃厚 OOP 色彩的框架。也就是說,可能一段時期內我們不可能拋棄 OOP。不過 POP 其實可以和 OOP “和諧共處”,我們也已經看到了不少使用 POP 改善程式碼設計的例子。另外需要補充的是,POP 其實也並不是銀彈,它有不好的一面。最大的問題是協議會增加程式碼的抽象層級 (這點上和類繼承是一樣的),特別是當你的協議又繼承了其他協議的時候,這個問題尤為嚴重。在經過若干層的繼承後,滿足末端的協議會變得困難,你也難以確定某個方法究竟滿足的是哪個協議的要求。這會讓程式碼迅速變得複雜。如果一個協議並沒有能描述很多共通點,或者說能讓人很快理解的話,可能使用基本的型別還會更簡單一些。
謝謝你的演講,想問一下你們在專案中使用 POP 的情況
我們在專案裡用了很多 POP 的概念。上面 demo 裡的網路請求的例子就是從實際專案中抽出來的,我們覺得這樣的請求寫起來非常輕鬆,因為程式碼很簡單,新人進來交接也十分愜意。除了模型層之外,我們在 view 和 view controller 層也用了一些 POP 的程式碼,比如從 nib 建立 view 的
NibCreatable
,支援分頁請求 tableview controller 的NextPageLoadable
,空列表時顯示頁面的EmptyPage
等等。因為時間有限,不可能展開一一說明,所以這裡我只挑選了一個具有代表性,又不是很複雜的網路的例子。其實每個協議都讓我們的程式碼,特別是 View Controller 變短,而且使測試變為可能。可以說,我們的專案從 POP 受益良多,而且我們應該會繼續使用下去。
推薦資料
幾個我認為在 POP 實踐中值得一看的資料,願意再進行深入瞭解的朋友不妨一看。
- Protocol-Oriented Programming in Swift – WWDC 15 #408
- Protocols with Associated Types – @alexisgallagher
- Protocol Oriented Programming in the Real World – @_matthewpalmer
- Practical Protocol-Oriented-Programming – @natashatherobot