你好,歡迎來到IOS教程網

 Ios教程網 >> IOS編程開發 >> IOS開發基礎 >> 面向協議編程與 Cocoa 的邂逅 (下)

面向協議編程與 Cocoa 的邂逅 (下)

編輯:IOS開發基礎

本文是筆者在 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 來查看返回:

{"name":"onevcat","message":"Welcome to MDCC 16!"}

我們可以新建一個項目,並添加 User.swift 來作為模型:

// 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 方法,所需要的參數等信息。一開始這個協議可能是這樣的:

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 協議:

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 協議擴展上:

extension Request {
    func send(handler: @escaping (User?) -> Void) {
        // ... send 的實現
    }
}

在 send(handler:) 的參數中,我們定義了可逃逸的 (User?) -> Void,在請求完成後,我們調用這個 handler 方法來通知調用者請求是否完成,如果一切正常,則將一個 User 實例傳回,否則傳回 nil。

我們想要這個 send 方法對於所有的 Request 都通用,所以顯然回調的參數類型不能是 User。通過在 Request 協議中添加一個關聯類型,我們可以將回調參數進行抽象。在 Request 最後添加:

protocol Request {
    ...
    associatedtype Response
}

然後在 UserRequest 中,我們也相應地添加類型定義,以滿足協議:

struct UserRequest: Request {
    ...
    typealias Response = User
}

現在,我們來重新實現 send 方法,現在,我們可以用 Response 代替具體的 User,讓 send 一般化。我們這裡使用 URLSession 來發送請求:

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:

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 的方法後,我們就可以對請求的結果進行處理了:

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:

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 的定義和擴展:

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 的開發方式,我們從定義一個可以發送請求的協議開始:

protocol Client {
    func send(_ r: Request, handler: @escaping (Request.Response?) -> Void)
}
// 編譯錯誤

從上面的聲明從語義上來說是挺明確的,但是因為 Request 是含有關聯類型的協議,所以它並不能作為獨立的類型來使用,我們只能夠將它作為類型約束,來限制輸入參數 request。正確的聲明方式應當是:

protocol Client {
    func send(_ r: T, handler: @escaping (T.Response?) -> Void)
    var host: String { get }
}

除了使用這個泛型方式以外,我們還將 host 從 Request 移動到了 Client 裡,這是更適合它的地方。現在,我們可以把含有 send 的 Request 協議擴展刪除,重新創建一個類型來滿足 Client 了。和之前一樣,它將使用 URLSession 來發送請求:

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 轉換為實際的類型:

protocol Decodable {
    static func parse(data: Data) -> Self?
}

Decodable 定義了一個靜態的 parse 方法,現在我們需要在 Request 的 Response 關聯類型中為它加上這個限制,這樣我們可以保證所有的 Response 都可以對數據進行解析,原來 Request 中的 parse 聲明也就可以移除了:

// 最終的 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 方法:

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 時,我們可以這樣寫:

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 中,作為網絡請求返回的內容:

// 文件名:users:onevcat
{"name":"Wei Wang", "message": "hello"}

接下來,可以創建一個新的類型,讓它滿足 Client 協議。但是與 URLSessionClient 不同,這個新類型的 send 方法並不會實際去創建請求,並發送給服務器。我們在測試時需要驗證的是一個請求發出後如果服務器按照文檔正確響應,那麼我們應該也可以得到正確的模型實例。所以這個新的 Client 需要做的事情就是從本地文件中加載定義好的結果,然後驗證模型實例是否正確:

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 進行測試了:

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


  1. 上一頁:
  2. 下一頁:
蘋果刷機越獄教程| IOS教程問題解答| IOS技巧綜合| IOS7技巧| IOS8教程
Copyright © Ios教程網 All Rights Reserved