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

文章轉(zhuǎn)載:https://onevcat.com/2016/11/pop-cocoa-1/

(作者非常棒,建議大家點進去關(guān)注下作者)

本文是筆者在 MDCC 16 (移動開發(fā)者大會) 上 iOS 專場中的主題演講的文字整理。您可以在這里找到演講使用的 Keynote,部分示例代碼可以在 MDCC 2016 的官方 repo中找到。因為全部內(nèi)容比較長,所以分成了上下兩個部分,本文 (上) 主要介紹了一些理論方面的內(nèi)容,包括面向?qū)ο缶幊檀嬖诘膯栴},面向協(xié)議的基本概念和決策模型等,下半部分主要展示了一些筆者日常使用面向協(xié)議思想和 Cocoa 開發(fā)結(jié)合的示例代碼,并對其進行了一些解說。

引子

面向協(xié)議編程 (Protocol Oriented Programming,以下簡稱 POP) 是 Apple 在 2015 年 WWDC 上提出的 Swift 的一種編程范式。相比與傳統(tǒng)的面向?qū)ο缶幊?(OOP),POP 顯得更加靈活。結(jié)合 Swift 的值語義特性和 Swift 標準庫的實現(xiàn),這一年來大家發(fā)現(xiàn)了很多 POP 的應(yīng)用場景。本次演講希望能在介紹 POP 思想的基礎(chǔ)上,引入一些日常開發(fā)中可以使用 POP 的場景,讓與會來賓能夠開始在日常工作中嘗試 POP,并改善代碼設(shè)計。

起?初識 - 什么是 Swift 協(xié)議

Protocol

Swift 標準庫中有 50 多個復(fù)雜不一的協(xié)議,幾乎所有的實際類型都是滿足若干協(xié)議的。protocol 是 Swift 語言的底座,語言的其他部分正是在這個底座上組織和建立起來的。這和我們熟知的面向?qū)ο蟮臉?gòu)建方式很不一樣。

一個最簡單但是有實際用處的 Swift 協(xié)議定義如下:

protocolGreetable{varname:String{get}funcgreet()}

這幾行代碼定義了一個名為Greetable的協(xié)議,其中有一個name屬性的定義,以及一個greet方法的定義。

所謂協(xié)議,就是一組屬性和/或方法的定義,而如果某個具體類型想要遵守一個協(xié)議,那它需要實現(xiàn)這個協(xié)議所定義的所有這些內(nèi)容。協(xié)議實際上做的事情不過是“關(guān)于實現(xiàn)的約定”。

面向?qū)ο?/p>

在深入 Swift 協(xié)議的概念之前,我想先重新讓大家回顧一下面向?qū)ο?。相信我們不論在教科書或者是博客等各種地方對這個名詞都十分熟悉了。那么有一個很有意思,但是其實并不是每個程序員都想過的問題,面向?qū)ο蟮暮诵乃枷刖烤故鞘裁矗?/p>

我們先來看一段面向?qū)ο蟮拇a:

classAnimal{varleg:Int{return2}funceat(){print("eat food.")}funcrun(){print("run with\(leg)legs")}}classTiger:Animal{overridevarleg:Int{return4}overridefunceat(){print("eat meat.")}}lettiger=Tiger()tiger.eat()// "eat meat"tiger.run()// "run with 4 legs"

父類Animal定義了動物的leg(這里應(yīng)該使用虛類,但是 Swift 中沒有這個概念,所以先請無視這里的return 2),以及動物的eat和run方法,并為它們提供了實現(xiàn)。子類的Tiger根據(jù)自身情況重寫了leg(4 條腿)和eat(吃肉),而對于run,父類的實現(xiàn)已經(jīng)滿足需求,因此不必重寫。

我們看到Tiger和Animal共享了一部分代碼,這部分代碼被封裝到了父類中,而除了Tiger的其他的子類也能夠使用Animal的這些代碼。這其實就是 OOP 的核心思想 - 使用封裝和繼承,將一系列相關(guān)的內(nèi)容放到一起。我們的前輩們?yōu)榱四軌驅(qū)φ鎸嵤澜绲膶ο筮M行建模,發(fā)展出了面向?qū)ο缶幊痰母拍?,但是這套理念有一些缺陷。雖然我們努力用這套抽象和繼承的方法進行建模,但是實際的事物往往是一系列特質(zhì)的組合,而不單單是以一脈相承并逐漸擴展的方式構(gòu)建的。所以最近大家越來越發(fā)現(xiàn)面向?qū)ο蠛芏鄷r候其實不能很好地對事物進行抽象,我們可能需要尋找另一種更好的方式。

面向?qū)ο缶幊痰睦Ь?/p>

橫切關(guān)注點

我們再來看一個例子。這次讓我們遠離動物世界,回到 Cocoa,假設(shè)我們有一個ViewController,它繼承自UIViewController,我們向其中添加一個myMethod:

classViewCotroller:UIViewController{// 繼承// view, isFirstResponder()...// 新加funcmyMethod(){}}

如果這時候我們又有一個繼承自UITableViewController的AnotherViewController,我們也想向其中添加同樣的myMethod:

classAnotherViewController:UITableViewController{// 繼承// tableView, isFirstResponder()...// 新加funcmyMethod(){}}

這時,我們迎來了 OOP 的第一個大困境,那就是我們很難在不同繼承關(guān)系的類里共用代碼。這里的問題用“行話”來說叫做“橫切關(guān)注點” (Cross-Cutting Concerns)。我們的關(guān)注點myMethod位于兩條繼承鏈 (UIViewController->ViewCotroller和UIViewController->UITableViewController->AnotherViewController) 的橫切面上。面向?qū)ο笫且环N不錯的抽象方式,但是肯定不是最好的方式。它無法描述兩個不同事物具有某個相同特性這一點。在這里,特性的組合要比繼承更貼切事物的本質(zhì)。

想要解決這個問題,我們有幾個方案:

Copy & Paste

這是一個比較糟糕的解決方案,但是演講現(xiàn)場還是有不少朋友選擇了這個方案,特別是在工期很緊,無暇優(yōu)化的情況下。這誠然可以理解,但是這也是壞代碼的開頭。我們應(yīng)該盡量避免這種做法。

引入 BaseViewController

在一個繼承自UIViewController的BaseViewController上添加需要共享的代碼,或者干脆在UIViewController上添加 extension??雌饋磉@是一個稍微靠譜的做法,但是如果不斷這么做,會讓所謂的Base很快變成垃圾堆。職責(zé)不明確,任何東西都能扔進Base,你完全不知道哪些類走了Base,而這個“超級類”對代碼的影響也會不可預(yù)估。

依賴注入

通過外界傳入一個帶有myMethod的對象,用新的類型來提供這個功能。這是一個稍好的方式,但是引入額外的依賴關(guān)系,可能也是我們不太愿意看到的。

多繼承

當然,Swift 是不支持多繼承的。不過如果有多繼承的話,我們確實可以從多個父類進行繼承,并將myMethod添加到合適的地方。有一些語言選擇了支持多繼承 (比如 C++),但是它會帶來 OOP 中另一個著名的問題:菱形缺陷。

菱形缺陷

上面的例子中,如果我們有多繼承,那么ViewController和AnotherViewController的關(guān)系可能會是這樣的:

在上面這種拓撲結(jié)構(gòu)中,我們只需要在ViewController中實現(xiàn)myMethod,在AnotherViewController中也就可以繼承并使用它了??雌饋砗芡昝溃覀儽苊饬酥貜?fù)。但是多繼承有一個無法回避的問題,就是兩個父類都實現(xiàn)了同樣的方法時,子類該怎么辦?我們很難確定應(yīng)該繼承哪一個父類的方法。因為多繼承的拓撲結(jié)構(gòu)是一個菱形,所以這個問題又被叫做菱形缺陷 (Diamond Problem)。像是 C++ 這樣的語言選擇粗暴地將菱形缺陷的問題交給程序員處理,這無疑非常復(fù)雜,并且增加了人為錯誤的可能性。而絕大多數(shù)現(xiàn)代語言對多繼承這個特性選擇避而遠之。

動態(tài)派發(fā)安全性

Objective-C 恰如其名,是一門典型的 OOP 語言,同時它繼承了 Small Talk 的消息發(fā)送機制。這套機制十分靈活,是 OC 的基礎(chǔ)思想,但是有時候相對危險??紤]下面的代碼:

ViewController*v1=...[v1myMethod];AnotherViewController*v2=...[v2myMethod];NSArray*array=@[v1,v2];for(idobjinarray){[objmyMethod];}

我們?nèi)绻赩iewController和AnotherViewController中都實現(xiàn)了myMethod的話,這段代碼是沒有問題的。myMethod將會被動態(tài)發(fā)送給array中的v1和v2。但是,要是我們有一個沒有實現(xiàn)myMethod的類型,會如何呢?

NSObject*v3=[NSObjectnew]// v3 沒有實現(xiàn) `myMethod`NSArray*array=@[v1,v2,v3];for(idobjinarray){[objmyMethod];}// Runtime error:

// unrecognized selector sent to instance blabla

編譯依然可以通過,但是顯然,程序?qū)⒃谶\行時崩潰。Objective-C 是不安全的,編譯器默認你知道某個方法確實有實現(xiàn),這是消息發(fā)送的靈活性所必須付出的代價。而在 app 開發(fā)看來,用可能的崩潰來換取靈活性,顯然這個代價太大了。雖然這不是 OOP 范式的問題,但它確實在 Objective-C 時代給我們帶來了切膚之痛。

三大困境

我們可以總結(jié)一下 OOP 面臨的這幾個問題。

動態(tài)派發(fā)安全性

橫切關(guān)注點

菱形缺陷

首先,在 OC 中動態(tài)派發(fā)讓我們承擔(dān)了在運行時才發(fā)現(xiàn)錯誤的風(fēng)險,這很有可能是發(fā)生在上線產(chǎn)品中的錯誤。其次,橫切關(guān)注點讓我們難以對對象進行完美的建模,代碼的重用也會更加糟糕。

承?相知 - 協(xié)議擴展和面向協(xié)議編程

使用協(xié)議解決 OOP 困境

協(xié)議并不是什么新東西,也不是 Swift 的發(fā)明。在 Java 和 C# 里,它叫做Interface。而 Swift 中的 protocol 將這個概念繼承了下來,并發(fā)揚光大。讓我們回到一開始定義的那個簡單協(xié)議,并嘗試著實現(xiàn)這個協(xié)議:

protocolGreetable{varname:String{get}funcgreet()}

structPerson:Greetable{letname:Stringfuncgreet(){print("你好\(name)")}}Person(name:"Wei Wang").greet()

實現(xiàn)很簡單,Person結(jié)構(gòu)體通過實現(xiàn)name和greet來滿足Greetable。在調(diào)用時,我們就可以使用Greetable中定義的方法了。

動態(tài)派發(fā)安全性

除了Person,其他類型也可以實現(xiàn)Greetable,比如Cat:

structCat:Greetable{letname:Stringfuncgreet(){print("meow~\(name)")}}

現(xiàn)在,我們就可以將協(xié)議作為標準類型,來對方法調(diào)用進行動態(tài)派發(fā)了:

letarray:[Greetable]=[Person(name:"Wei Wang"),Cat(name:"onevcat")]forobjinarray{obj.greet()}// 你好 Wei Wang// meow~ onevcat

對于沒有實現(xiàn) Greetbale 的類型,編譯器將返回錯誤,因此不存在消息誤發(fā)送的情況:

structBug:Greetable{letname:String}// Compiler Error:// 'Bug' does not conform to protocol 'Greetable'// protocol requires function 'greet()'

這樣一來,動態(tài)派發(fā)安全性的問題迎刃而解。如果你保持在 Swift 的世界里,那這個你的所有代碼都是安全的。

? 動態(tài)派發(fā)安全性

橫切關(guān)注點

菱形缺陷

橫切關(guān)注點

使用協(xié)議和協(xié)議擴展,我們可以很好地共享代碼?;氐缴弦还?jié)的myMethod方法,我們來看看如何使用協(xié)議來搞定它。首先,我們可以定義一個含有myMethod的協(xié)議:

protocolP{funcmyMethod()}

注意這個協(xié)議沒有提供任何的實現(xiàn)。我們依然需要在實際類型遵守這個協(xié)議的時候為它提供具體的實現(xiàn):

// class ViewController: UIViewControllerextensionViewController:P{funcmyMethod(){doWork()}}// class AnotherViewController: UITableViewControllerextensionAnotherViewController:P{funcmyMethod(){doWork()}}

你可能不禁要問,這和 Copy & Paste 的解決方式有何不同?沒錯,答案就是 – 沒有不同。不過稍安勿躁,我們還有其他科技可以解決這個問題,那就是協(xié)議擴展。協(xié)議本身并不是很強大,只是靜態(tài)類型語言的編譯器保證,在很多靜態(tài)語言中也有類似的概念。那到底是什么讓 Swift 成為了一門協(xié)議優(yōu)先的語言?真正使協(xié)議發(fā)生質(zhì)變,并讓大家如此關(guān)注的原因,其實是在 WWDC 2015 和 Swift 2 發(fā)布時,Apple 為協(xié)議引入了一個新特性,協(xié)議擴展,它為 Swift 語言帶來了一次革命性的變化。

所謂協(xié)議擴展,就是我們可以為一個協(xié)議提供默認的實現(xiàn)。對于P,可以在extension P中為myMethod添加一個實現(xiàn):

protocolP{funcmyMethod()}extensionP{funcmyMethod(){doWork()}}

有了這個協(xié)議擴展后,我們只需要簡單地聲明ViewController和AnotherViewController遵守P,就可以直接使用myMethod的實現(xiàn)了:

extensionViewController:P{}extensionAnotherViewController:P{}viewController.myMethod()anotherViewController.myMethod()

不僅如此,除了已經(jīng)定義過的方法,我們甚至可以在擴展中添加協(xié)議里沒有定義過的方法。在這些額外的方法中,我們可以依賴協(xié)議定義過的方法進行操作。我們之后會看到更多的例子??偨Y(jié)下來:

協(xié)議定義

提供實現(xiàn)的入口

遵循協(xié)議的類型需要對其進行實現(xiàn)

協(xié)議擴展

為入口提供默認實現(xiàn)

根據(jù)入口提供額外實現(xiàn)

這樣一來,橫切點關(guān)注的問題也簡單安全地得到了解決。

? 動態(tài)派發(fā)安全性

? 橫切關(guān)注點

菱形缺陷

菱形缺陷

最后我們看看多繼承。多繼承中存在的一個重要問題是菱形缺陷,也就是子類無法確定使用哪個父類的方法。在協(xié)議的對應(yīng)方面,這個問題雖然依然存在,但卻是可以唯一安全地確定的。我們來看一個多個協(xié)議中出現(xiàn)同名元素的例子:

protocolNameable{varname:String{get}}protocolIdentifiable{varname:String{get}varid:Int{get}}

如果有一個類型,需要同時實現(xiàn)兩個協(xié)議的話,它必須提供一個name屬性,來同時滿足兩個協(xié)議的要求:

structPerson:Nameable,Identifiable{letname:Stringletid:Int}// `name` 屬性同時滿足 Nameable 和 Identifiable 的 name

這里比較有意思,又有點讓人困惑的是,如果我們?yōu)槠渲械哪硞€協(xié)議進行了擴展,在其中提供了默認的name實現(xiàn),會如何??紤]下面的代碼:

extensionNameable{varname:String{return"default name"}}structPerson:Nameable,Identifiable{// let name: Stringletid:Int}// Identifiable 也將使用 Nameable extension 中的 name

這樣的編譯是可以通過的,雖然Person中沒有定義name,但是通過Nameable的name(因為它是靜態(tài)派發(fā)的),Person依然可以遵守Identifiable。不過,當Nameable和Identifiable都有name的協(xié)議擴展的話,就無法編譯了:

extensionNameable{varname:String{return"default name"}}extensionIdentifiable{varname:String{return"another default name"}}structPerson:Nameable,Identifiable{// let name: Stringletid:Int}// 無法編譯,name 屬性沖突

這種情況下,Person無法確定要使用哪個協(xié)議擴展中name的定義。在同時實現(xiàn)兩個含有同名元素的協(xié)議,并且它們都提供了默認擴展時,我們需要在具體的類型中明確地提供實現(xiàn)。這里我們將Person中的name進行實現(xiàn)就可以了:

extensionNameable{varname:String{return"default name"}}extensionIdentifiable{varname:String{return"another default name"}}structPerson:Nameable,Identifiable{letname:Stringletid:Int}Person(name:"onevcat",id:123).name// onevcat

這里的行為看起來和菱形問題很像,但是有一些本質(zhì)不同。首先,這個問題出現(xiàn)的前提條件是同名元素以及同時提供了實現(xiàn),而協(xié)議擴展對于協(xié)議本身來說并不是必須的。其次,我們在具體類型中提供的實現(xiàn)一定是安全和確定的。當然,菱形缺陷沒有被完全解決,Swift 還不能很好地處理多個協(xié)議的沖突,這是 Swift 現(xiàn)在的不足。

? 動態(tài)派發(fā)安全性

? 橫切關(guān)注點

?菱形缺陷

本文是筆者在 MDCC 16 (移動開發(fā)者大會) 上 iOS 專場中的主題演講的文字整理。您可以在這里找到演講使用的 Keynote,部分示例代碼可以在 MDCC 2016 的官方 repo中找到。

上半部分主要介紹了一些理論方面的內(nèi)容,包括面向?qū)ο缶幊檀嬖诘膯栴},面向協(xié)議的基本概念和決策模型等。本文 (下) 主要展示了一些筆者日常使用面向協(xié)議思想和 Cocoa 開發(fā)結(jié)合的示例代碼,并對其進行了一些解說。

轉(zhuǎn)?熱戀 - 在日常開發(fā)中使用協(xié)議

WWDC 2015 在 POP 方面有一個非常優(yōu)秀的主題演講:#408 Protocol-Oriented Programming in Swift。Apple 的工程師通過舉了畫圖表和排序兩個例子,來闡釋 POP 的思想。我們可以使用 POP 來解耦,通過組合的方式讓代碼有更好的重用性。不過在 #408 中,涉及的內(nèi)容偏向理論,而我們每天的 app 開發(fā)更多的面臨的還是和 Cocoa 框架打交道。在看過 #408 以后,我們就一直在思考,如何把 POP 的思想運用到日常的開發(fā)中?

我們在這個部分會舉一個實際的例子,來看看 POP 是如何幫助我們寫出更好的代碼的。

基于 Protocol 的網(wǎng)絡(luò)請求

網(wǎng)絡(luò)請求層是實踐 POP 的一個理想場所。我們在接下的例子中將從零開始,用最簡單的面向協(xié)議的方式先構(gòu)建一個不那么完美的網(wǎng)絡(luò)請求和模型層,它可能包含一些不合理的設(shè)計和耦合,但是卻是初步最容易得到的結(jié)果。然后我們將逐步捋清各部分的所屬,并用分離職責(zé)的方式來進行重構(gòu)。最后我們會為這個網(wǎng)絡(luò)請求層進行測試。通過這個例子,我希望能夠設(shè)計出包括類型安全,解耦合,易于測試和良好的擴展性等諸多優(yōu)秀特性在內(nèi)的 POP 代碼。

Talk is cheap, show me the code.

初步實現(xiàn)

首先,我們想要做的事情是從一個 API 請求一個 JSON,然后將它轉(zhuǎn)換為 Swift 中可用的實例。作為例子的 API 非常簡單,你可以直接訪問https://api.onevcat.com/users/onevcat來查看返回:

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

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

// User.swiftimportFoundationstructUser{letname:Stringletmessage:Stringinit?(data:Data){guardletobj=try?JSONSerialization.jsonObject(with:data,options:[])as?[String:Any]else{returnnil}guardletname=obj?["name"]as?Stringelse{returnnil}guardletmessage=obj?["message"]as?Stringelse{returnnil}self.name=nameself.message=message}}

User.init(data:)將輸入的數(shù)據(jù) (從網(wǎng)絡(luò)請求 API 獲取) 解析為 JSON 對象,然后從中取出name和message,并構(gòu)建代表 API 返回的User實例,非常簡單。

現(xiàn)在讓我們來看看有趣的部分,也就是如何使用 POP 的方式從 URL 請求數(shù)據(jù),并生成對應(yīng)的User。首先,我們可以創(chuàng)建一個 protocol 來代表請求。對于一個請求,我們需要知道它的請求路徑,HTTP 方法,所需要的參數(shù)等信息。一開始這個協(xié)議可能是這樣的:

enumHTTPMethod:String{caseGETcasePOST}protocolRequest{varhost:String{get}varpath:String{get}varmethod:HTTPMethod{get}varparameter:[String:Any]{get}}

將host和path拼接起來可以得到我們需要請求的 API 地址。為了簡化,HTTPMethod現(xiàn)在只包含了GET和POST兩種請求方式,而在我們的例子中,我們只會使用到GET請求。

現(xiàn)在,可以新建一個UserRequest來實現(xiàn)Request協(xié)議:

structUserRequest:Request{letname:Stringlethost="https://api.onevcat.com"varpath:String{return"/users/\(name)"}letmethod:HTTPMethod=.GETletparameter:[String:Any]=[:]}

UserRequest中有一個未定義初始值的name屬性,其他的屬性都是為了滿足協(xié)議所定義的。因為請求的參數(shù)用戶名name會通過 URL 進行傳遞,所以parameter是一個空字典就足夠了。有了協(xié)議定義和一個滿足定義的具體請求,現(xiàn)在我們需要發(fā)送請求。為了任意請求都可以通過同樣的方法發(fā)送,我們將發(fā)送的方法定義在Request協(xié)議擴展上:

extensionRequest{funcsend(handler:@escaping(User?)->Void){// ... send 的實現(xiàn)}}

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

我們想要這個send方法對于所有的Request都通用,所以顯然回調(diào)的參數(shù)類型不能是User。通過在Request協(xié)議中添加一個關(guān)聯(lián)類型,我們可以將回調(diào)參數(shù)進行抽象。在Request最后添加:

protocolRequest{...associatedtypeResponse}

然后在UserRequest中,我們也相應(yīng)地添加類型定義,以滿足協(xié)議:

structUserRequest:Request{...typealiasResponse=User}

現(xiàn)在,我們來重新實現(xiàn)send方法,現(xiàn)在,我們可以用Response代替具體的User,讓send一般化。我們這里使用URLSession來發(fā)送請求:

extensionRequest{funcsend(handler:@escaping(Response?)->Void){leturl=URL(string:host.appending(path))!varrequest=URLRequest(url:url)request.httpMethod=method.rawValue// 在示例中我們不需要 `httpBody`,實踐中可能需要將 parameter 轉(zhuǎn)為 data// request.httpBody = ...lettask=URLSession.shared.dataTask(with:request){data,res,errorin// 處理結(jié)果print(data)}task.resume()}}

通過拼接host和path,可以得到 API 的 entry point。根據(jù)這個 URL 創(chuàng)建請求,進行配置,生成 data task 并將請求發(fā)送。剩下的工作就是將回調(diào)中的data轉(zhuǎn)換為合適的對象類型,并調(diào)用handler通知外部調(diào)用者了。對于User我們知道可以使用User.init(data:),但是對于一般的Response,我們還不知道要如何將數(shù)據(jù)轉(zhuǎn)為模型。我們可以在Request里再定義一個parse(data:)方法,來要求滿足該協(xié)議的具體類型提供合適的實現(xiàn)。這樣一來,提供轉(zhuǎn)換方法的任務(wù)就被“下放”到了UserRequest:

protocolRequest{...associatedtypeResponsefuncparse(data:Data)->Response?}structUserRequest:Request{...typealiasResponse=Userfuncparse(data:Data)->User?{returnUser(data:data)}}

有了將data轉(zhuǎn)換為Response的方法后,我們就可以對請求的結(jié)果進行處理了:

extensionRequest{funcsend(handler:@escaping(Response?)->Void){leturl=URL(string:host.appending(path))!varrequest=URLRequest(url:url)request.httpMethod=method.rawValue// 在示例中我們不需要 `httpBody`,實踐中可能需要將 parameter 轉(zhuǎn)為 data// request.httpBody = ...lettask=URLSession.shared.dataTask(with:request){data,_,errorinifletdata=data,letres=parse(data:data){DispatchQueue.main.async{handler(res)}}else{DispatchQueue.main.async{handler(nil)}}}task.resume()}}

現(xiàn)在,我們來試試看請求一下這個 API:

letrequest=UserRequest(name:"onevcat")request.send{userinifletuser=user{print("\(user.message)from\(user.name)")}}// Welcome to MDCC 16! from onevcat

重構(gòu),關(guān)注點分離

雖然能夠?qū)崿F(xiàn)需求,但是上面的實現(xiàn)可以說非常糟糕。讓我們看看現(xiàn)在Request的定義和擴展:

protocolRequest{varhost:String{get}varpath:String{get}varmethod:HTTPMethod{get}varparameter:[String:Any]{get}associatedtypeResponsefuncparse(data:Data)->Response?}extensionRequest{funcsend(handler:@escaping(Response?)->Void){...}}

這里最大的問題在于,Request管理了太多的東西。一個Request應(yīng)該做的事情應(yīng)該僅僅是定義請求入口和期望的響應(yīng)類型,而現(xiàn)在Request不光定義了host的值,還對如何解析數(shù)據(jù)了如指掌。最后send方法被綁死在了URLSession的實現(xiàn)上,而且是作為Request的一部分存在。這是很不合理的,因為這意味著我們無法在不更改請求的情況下更新發(fā)送請求的方式,它們被耦合在了一起。這樣的結(jié)構(gòu)讓測試變得異常困難,我們可能需要通過 stub 和 mock 的方式對請求攔截,然后返回構(gòu)造的數(shù)據(jù),這會用到NSURLProtocol的內(nèi)容,或者是引入一些第三方的測試框架,大大增加了項目的復(fù)雜度。在 Objective-C 時期這可能是一個可選項,但是在 Swift 的新時代,我們有好得多的方法來處理這件事情。

讓我們開始著手重構(gòu)剛才的代碼,并為它們加上測試吧。首先我們將send(handler:)從Request分離出來。我們需要一個單獨的類型來負責(zé)發(fā)送請求。這里基于 POP 的開發(fā)方式,我們從定義一個可以發(fā)送請求的協(xié)議開始:

protocolClient{funcsend(_r:Request,handler:@escaping(Request.Response?)->Void)}// 編譯錯誤

從上面的聲明從語義上來說是挺明確的,但是因為Request是含有關(guān)聯(lián)類型的協(xié)議,所以它并不能作為獨立的類型來使用,我們只能夠?qū)⑺鳛轭愋图s束,來限制輸入?yún)?shù)request。正確的聲明方式應(yīng)當是:

protocolClient{funcsend(_r:T,handler:@escaping(T.Response?)->Void)varhost:String{get}}

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

structURLSessionClient:Client{lethost="https://api.onevcat.com"funcsend(_r:T,handler:@escaping(T.Response?)->Void){leturl=URL(string:host.appending(r.path))!varrequest=URLRequest(url:url)request.httpMethod=r.method.rawValuelettask=URLSession.shared.dataTask(with:request){data,_,errorinifletdata=data,letres=r.parse(data:data){DispatchQueue.main.async{handler(res)}}else{DispatchQueue.main.async{handler(nil)}}}task.resume()}}

現(xiàn)在發(fā)送請求的部分和請求本身分離開了,而且我們使用協(xié)議的方式定義了Client。除了URLSessionClient以外,我們還可以使用任意的類型來滿足這個協(xié)議,并發(fā)送請求。這樣網(wǎng)絡(luò)層的具體實現(xiàn)和請求本身就不再相關(guān)了,我們之后在測試的時候會進一步看到這么做所帶來的好處。

現(xiàn)在這個的實現(xiàn)里還有一個問題,那就是Request的parse方法。請求不應(yīng)該也不需要知道如何解析得到的數(shù)據(jù),這項工作應(yīng)該交給Response來做。而現(xiàn)在我們沒有對Response進行任何限定。接下來我們將新增一個協(xié)議,滿足這個協(xié)議的類型將知道如何將一個data轉(zhuǎn)換為實際的類型:

protocolDecodable{staticfuncparse(data:Data)->Self?}

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

// 最終的 Request 協(xié)議protocolRequest{varpath:String{get}varmethod:HTTPMethod{get}varparameter:[String:Any]{get}// associatedtype Response// func parse(data: Data) -> Response?associatedtypeResponse:Decodable}

最后要做的就是讓User滿足Decodable,并且修改上面URLSessionClient的解析部分的代碼,讓它使用Response中的parse方法:

extensionUser:Decodable{staticfuncparse(data:Data)->User?{returnUser(data:data)}}structURLSessionClient:Client{funcsend(_r:T,handler:@escaping(T.Response?)->Void){...// if let data = data, let res = parse(data: data) {ifletdata=data,letres=T.Response.parse(data:data){...}}}

最后,將UserRequest中不再需要的host和parse等清理一下,一個類型安全,解耦合的面向協(xié)議的網(wǎng)絡(luò)層就呈現(xiàn)在我們眼前了。想要調(diào)用UserRequest時,我們可以這樣寫:

URLSessionClient().send(UserRequest(name:"onevcat")){userinifletuser=user{print("\(user.message)from\(user.name)")}}

當然,你也可以為URLSessionClient添加一個單例來減少請求時的創(chuàng)建開銷,或者為請求添加 Promise 的調(diào)用方式等等。在 POP 的組織下,這些改動都很自然,也不會牽扯到請求的其他部分。你可以用和UserRequest類型相似的方式,為網(wǎng)絡(luò)層添加其他的 API 請求,只需要定義請求所必要的內(nèi)容,而不用擔(dān)心會觸及網(wǎng)絡(luò)方面的具體實現(xiàn)。

網(wǎng)絡(luò)層測試

將Client聲明為協(xié)議給我們帶來了額外的好處,那就是我們不在局限于使用某種特定的技術(shù) (比如這里的URLSession) 來實現(xiàn)網(wǎng)絡(luò)請求。利用 POP,你只是定義了一個發(fā)送請求的協(xié)議,你可以很容易地使用像是 AFNetworking 或者 Alamofire 這樣的成熟的第三方框架來構(gòu)建具體的數(shù)據(jù)并處理請求的底層實現(xiàn)。我們甚至可以提供一組“虛假”的對請求的響應(yīng),用來進行測試。這和傳統(tǒng)的 stub & mock 的方式在概念上是接近的,但是實現(xiàn)起來要簡單得多,也明確得多。我們現(xiàn)在來看一看具體應(yīng)該怎么做。

我們先準備一個文本文件,將它添加到項目的測試 target 中,作為網(wǎng)絡(luò)請求返回的內(nèi)容:

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

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

structLocalFileClient:Client{funcsend(_r:T,handler:@escaping(T.Response?)->Void){switchr.path{case"/users/onevcat":guardletfileURL=Bundle(for:ProtocolNetworkTests.self).url(forResource:"users:onevcat",withExtension:"")else{fatalError()}guardletdata=try?Data(contentsOf:fileURL)else{fatalError()}handler(T.Response.parse(data:data))default:fatalError("Unknown path")}}// 為了滿足 `Client` 的要求,實際我們不會發(fā)送請求lethost=""}

LocalFileClient做的事情很簡單,它先檢查輸入請求的path屬性,如果是/users/onevcat(也就是我們需要測試的請求),那么就從測試的 bundle 中讀取預(yù)先定義的文件,將其作為返回結(jié)果進行parse,然后調(diào)用handler。如果我們需要增加其他請求的測試,可以添加新的case項。另外,加載本地文件資源的部分應(yīng)該使用更通用的寫法,不過因為我們這里只是示例,就不過多糾結(jié)了。

在LocalFileClient的幫助下,現(xiàn)在可以很容易地對UserRequest進行測試了:

functestUserRequest(){letclient=LocalFileClient()client.send(UserRequest(name:"onevcat")){userinXCTAssertNotNil(user)XCTAssertEqual(user!.name,"Wei Wang")}}

通過這種方法,我們沒有依賴任何第三方測試庫,也沒有使用 url 代理或者運行時消息轉(zhuǎn)發(fā)等等這些復(fù)雜的技術(shù),就可以進行請求測試了。保持簡單的代碼和邏輯,對于項目維護和發(fā)展是至關(guān)重要的。

可擴展性

因為高度解耦,這種基于 POP 的實現(xiàn)為代碼的擴展提供了相對寬松的可能性。我們剛才已經(jīng)說過,你不必自行去實現(xiàn)一個完整的Client,而可以依賴于現(xiàn)有的網(wǎng)絡(luò)請求框架,實現(xiàn)請求發(fā)送的方法即可。也就是說,你也可以很容易地將某個正在使用的請求方式替換為另外的方式,而不會影響到請求的定義和使用。類似地,在Response的處理上,現(xiàn)在我們定義了Decodable,用自己手寫的方式在解析模型。我們完全也可以使用任意的第三方 JSON 解析庫,來幫助我們迅速構(gòu)建模型類型,這僅僅只需要實現(xiàn)一個將Data轉(zhuǎn)換為對應(yīng)模型類型的方法即可。

如果你對 POP 方式的網(wǎng)絡(luò)請求和模型解析感興趣的話,不妨可以看看APIKit這個框架,我們在示例中所展示的方法,正是這個框架的核心思想。

合?陪伴 - 使用協(xié)議幫助改善代碼設(shè)計

通過面向協(xié)議的編程,我們可以從傳統(tǒng)的繼承上解放出來,用一種更靈活的方式,搭積木一樣對程序進行組裝。每個協(xié)議專注于自己的功能,特別得益于協(xié)議擴展,我們可以減少類和繼承帶來的共享狀態(tài)的風(fēng)險,讓代碼更加清晰。

高度的協(xié)議化有助于解耦、測試以及擴展,而結(jié)合泛型來使用協(xié)議,更可以讓我們免于動態(tài)調(diào)用和類型轉(zhuǎn)換的苦惱,保證了代碼的安全性。

提問環(huán)節(jié)

主題演講后有幾位朋友提了一些很有意義的問題,在這里我也稍作整理。有可能問題和回答與當時的情形會有小的出入,僅供參考。

我剛才在看 demo 的時候發(fā)現(xiàn),你都是直接先寫protocol,而不是struct或者class。是不是我們在實踐 POP 的時候都應(yīng)該直接先定義協(xié)議?

我直接寫protocol是因為我已經(jīng)對我要做什么有充分的了解,并且希望演講不要超時。但是實際開發(fā)的時候你可能會無法一開始就寫出合適的協(xié)議定義。建議可以像我在 demo 中做的那樣,先“粗略”地進行定義,然后通過不斷重構(gòu)來得到一個最終的版本。當然,你也可以先用紙筆勾勒一個輪廓,然后再去定義和實現(xiàn)協(xié)議。當然了,也沒人規(guī)定一定需要先定義協(xié)議,你完全也可以從普通類型開始寫起,然后等發(fā)現(xiàn)共通點或者遇到我們之前提到的困境時,再回頭看看是不是面向協(xié)議更加合適,這需要一定的 POP 經(jīng)驗。

既然 POP 有這么多好處,那我們是不是不再需要面向?qū)ο?,可以全面轉(zhuǎn)向面向協(xié)議了?

答案可能讓你失望。在我們的日常項目中,每天打交道的 Cocoa 其實還是一個帶有濃厚 OOP 色彩的框架。也就是說,可能一段時期內(nèi)我們不可能拋棄 OOP。不過 POP 其實可以和 OOP “和諧共處”,我們也已經(jīng)看到了不少使用 POP 改善代碼設(shè)計的例子。另外需要補充的是,POP 其實也并不是銀彈,它有不好的一面。最大的問題是協(xié)議會增加代碼的抽象層級 (這點上和類繼承是一樣的),特別是當你的協(xié)議又繼承了其他協(xié)議的時候,這個問題尤為嚴重。在經(jīng)過若干層的繼承后,滿足末端的協(xié)議會變得困難,你也難以確定某個方法究竟?jié)M足的是哪個協(xié)議的要求。這會讓代碼迅速變得復(fù)雜。如果一個協(xié)議并沒有能描述很多共通點,或者說能讓人很快理解的話,可能使用基本的類型還會更簡單一些。

謝謝你的演講,想問一下你們在項目中使用 POP 的情況

我們在項目里用了很多 POP 的概念。上面 demo 里的網(wǎng)絡(luò)請求的例子就是從實際項目中抽出來的,我們覺得這樣的請求寫起來非常輕松,因為代碼很簡單,新人進來交接也十分愜意。除了模型層之外,我們在 view 和 view controller 層也用了一些 POP 的代碼,比如從 nib 創(chuàng)建 view 的NibCreatable,支持分頁請求 tableview controller 的NextPageLoadable,空列表時顯示頁面的EmptyPage等等。因為時間有限,不可能展開一一說明,所以這里我只挑選了一個具有代表性,又不是很復(fù)雜的網(wǎng)絡(luò)的例子。其實每個協(xié)議都讓我們的代碼,特別是 View Controller 變短,而且使測試變?yōu)榭赡?。可以說,我們的項目從 POP 受益良多,而且我們應(yīng)該會繼續(xù)使用下去。

推薦資料

幾個我認為在 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

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容