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

文章轉載:https://onevcat.com/2016/11/pop-cocoa-1/

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

本文是筆者在 MDCC 16 (移動開發(fā)者大會) 上 iOS 專場中的主題演講的文字整理笋妥。您可以在這里找到演講使用的 Keynote拷窜,部分示例代碼可以在 MDCC 2016 的官方 repo中找到。因為全部內容比較長,所以分成了上下兩個部分,本文 (上) 主要介紹了一些理論方面的內容,包括面向對象編程存在的問題,面向協(xié)議的基本概念和決策模型等,下半部分主要展示了一些筆者日常使用面向協(xié)議思想和 Cocoa 開發(fā)結合的示例代碼绑谣,并對其進行了一些解說。

引子

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

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

Protocol

Swift 標準庫中有 50 多個復雜不一的協(xié)議括改,幾乎所有的實際類型都是滿足若干協(xié)議的。protocol 是 Swift 語言的底座,語言的其他部分正是在這個底座上組織和建立起來的嘱能。這和我們熟知的面向對象的構建方式很不一樣吝梅。

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

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

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

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

面向對象

在深入 Swift 協(xié)議的概念之前纱扭,我想先重新讓大家回顧一下面向對象。相信我們不論在教科書或者是博客等各種地方對這個名詞都十分熟悉了儡遮。那么有一個很有意思乳蛾,但是其實并不是每個程序員都想過的問題,面向對象的核心思想究竟是什么鄙币?

我們先來看一段面向對象的代碼:

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(這里應該使用虛類肃叶,但是 Swift 中沒有這個概念,所以先請無視這里的return 2)十嘿,以及動物的eat和run方法因惭,并為它們提供了實現(xiàn)。子類的Tiger根據自身情況重寫了leg(4 條腿)和eat(吃肉)绩衷,而對于run筛欢,父類的實現(xiàn)已經滿足需求,因此不必重寫唇聘。

我們看到Tiger和Animal共享了一部分代碼,這部分代碼被封裝到了父類中柱搜,而除了Tiger的其他的子類也能夠使用Animal的這些代碼迟郎。這其實就是 OOP 的核心思想 - 使用封裝和繼承,將一系列相關的內容放到一起聪蘸。我們的前輩們?yōu)榱四軌驅φ鎸嵤澜绲膶ο筮M行建模宪肖,發(fā)展出了面向對象編程的概念,但是這套理念有一些缺陷健爬。雖然我們努力用這套抽象和繼承的方法進行建模控乾,但是實際的事物往往是一系列特質的組合,而不單單是以一脈相承并逐漸擴展的方式構建的娜遵。所以最近大家越來越發(fā)現(xiàn)面向對象很多時候其實不能很好地對事物進行抽象蜕衡,我們可能需要尋找另一種更好的方式。

面向對象編程的困境

橫切關注點

我們再來看一個例子设拟。這次讓我們遠離動物世界慨仿,回到 Cocoa久脯,假設我們有一個ViewController,它繼承自UIViewController镰吆,我們向其中添加一個myMethod:

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

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

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

這時,我們迎來了 OOP 的第一個大困境万皿,那就是我們很難在不同繼承關系的類里共用代碼摧找。這里的問題用“行話”來說叫做“橫切關注點” (Cross-Cutting Concerns)。我們的關注點myMethod位于兩條繼承鏈 (UIViewController->ViewCotroller和UIViewController->UITableViewController->AnotherViewController) 的橫切面上牢硅。面向對象是一種不錯的抽象方式蹬耘,但是肯定不是最好的方式。它無法描述兩個不同事物具有某個相同特性這一點唤衫。在這里婆赠,特性的組合要比繼承更貼切事物的本質。

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

Copy & Paste

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

引入 BaseViewController

在一個繼承自UIViewController的BaseViewController上添加需要共享的代碼,或者干脆在UIViewController上添加 extension抓于∽鲈粒看起來這是一個稍微靠譜的做法,但是如果不斷這么做捉撮,會讓所謂的Base很快變成垃圾堆怕品。職責不明確,任何東西都能扔進Base巾遭,你完全不知道哪些類走了Base肉康,而這個“超級類”對代碼的影響也會不可預估。

依賴注入

通過外界傳入一個帶有myMethod的對象灼舍,用新的類型來提供這個功能吼和。這是一個稍好的方式,但是引入額外的依賴關系骑素,可能也是我們不太愿意看到的炫乓。

多繼承

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

菱形缺陷

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

在上面這種拓撲結構中船老,我們只需要在ViewController中實現(xiàn)myMethod,在AnotherViewController中也就可以繼承并使用它了圃酵×希看起來很完美,我們避免了重復郭赐。但是多繼承有一個無法回避的問題薪韩,就是兩個父類都實現(xiàn)了同樣的方法時,子類該怎么辦捌锭?我們很難確定應該繼承哪一個父類的方法俘陷。因為多繼承的拓撲結構是一個菱形,所以這個問題又被叫做菱形缺陷 (Diamond Problem)观谦。像是 C++ 這樣的語言選擇粗暴地將菱形缺陷的問題交給程序員處理拉盾,這無疑非常復雜,并且增加了人為錯誤的可能性豁状。而絕大多數現(xiàn)代語言對多繼承這個特性選擇避而遠之捉偏。

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

Objective-C 恰如其名,是一門典型的 OOP 語言泻红,同時它繼承了 Small Talk 的消息發(fā)送機制夭禽。這套機制十分靈活,是 OC 的基礎思想谊路,但是有時候相對危險驻粟。考慮下面的代碼:

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

我們如果在ViewController和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

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

三大困境

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

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

橫切關注點

菱形缺陷

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

承?相知 - 協(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結構體通過實現(xiàn)name和greet來滿足Greetable。在調用時玩郊,我們就可以使用Greetable中定義的方法了肢执。

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

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

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

現(xiàn)在预茄,我們就可以將協(xié)議作為標準類型,來對方法調用進行動態(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ā)安全性

橫切關注點

菱形缺陷

橫切關注點

使用協(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ā)生質變,并讓大家如此關注的原因鲜戒,其實是在 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()

不僅如此咳焚,除了已經定義過的方法,我們甚至可以在擴展中添加協(xié)議里沒有定義過的方法庞溜。在這些額外的方法中革半,我們可以依賴協(xié)議定義過的方法進行操作。我們之后會看到更多的例子流码∮止伲總結下來:

協(xié)議定義

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

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

協(xié)議擴展

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

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

這樣一來,橫切點關注的問題也簡單安全地得到了解決漫试。

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

? 橫切關注點

菱形缺陷

菱形缺陷

最后我們看看多繼承六敬。多繼承中存在的一個重要問題是菱形缺陷,也就是子類無法確定使用哪個父類的方法商虐。在協(xié)議的對應方面觉阅,這個問題雖然依然存在,但卻是可以唯一安全地確定的秘车。我們來看一個多個協(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

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

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

? 橫切關注點

?菱形缺陷

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

上半部分主要介紹了一些理論方面的內容徐紧,包括面向對象編程存在的問題静檬,面向協(xié)議的基本概念和決策模型等炭懊。本文 (下) 主要展示了一些筆者日常使用面向協(xié)議思想和 Cocoa 開發(fā)結合的示例代碼,并對其進行了一些解說拂檩。

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

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

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

基于 Protocol 的網絡請求

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

Talk is cheap, show me the code.

初步實現(xiàn)

首先,我們想要做的事情是從一個 API 請求一個 JSON抢埋,然后將它轉換為 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:)將輸入的數據 (從網絡請求 API 獲取) 解析為 JSON 對象穷吮,然后從中取出name和message,并構建代表 API 返回的User實例饥努,非常簡單捡鱼。

現(xiàn)在讓我們來看看有趣的部分,也就是如何使用 POP 的方式從 URL 請求數據酷愧,并生成對應的User驾诈。首先缠诅,我們可以創(chuàng)建一個 protocol 來代表請求。對于一個請求乍迄,我們需要知道它的請求路徑滴铅,HTTP 方法,所需要的參數等信息就乓。一開始這個協(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é)議所定義的擅笔。因為請求的參數用戶名name會通過 URL 進行傳遞志衣,所以parameter是一個空字典就足夠了。有了協(xié)議定義和一個滿足定義的具體請求猛们,現(xiàn)在我們需要發(fā)送請求念脯。為了任意請求都可以通過同樣的方法發(fā)送,我們將發(fā)送的方法定義在Request協(xié)議擴展上:

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

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

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

protocolRequest{...associatedtypeResponse}

然后在UserRequest中,我們也相應地添加類型定義兆蕉,以滿足協(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 轉為 data// request.httpBody = ...lettask=URLSession.shared.dataTask(with:request){data,res,errorin// 處理結果print(data)}task.resume()}}

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

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

有了將data轉換為Response的方法后缺谴,我們就可以對請求的結果進行處理了:

extensionRequest{funcsend(handler:@escaping(Response?)->Void){leturl=URL(string:host.appending(path))!varrequest=URLRequest(url:url)request.httpMethod=method.rawValue// 在示例中我們不需要 `httpBody`,實踐中可能需要將 parameter 轉為 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

重構湿蛔,關注點分離

雖然能夠實現(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應該做的事情應該僅僅是定義請求入口和期望的響應類型财喳,而現(xiàn)在Request不光定義了host的值苫纤,還對如何解析數據了如指掌。最后send方法被綁死在了URLSession的實現(xiàn)上纲缓,而且是作為Request的一部分存在卷拘。這是很不合理的,因為這意味著我們無法在不更改請求的情況下更新發(fā)送請求的方式祝高,它們被耦合在了一起栗弟。這樣的結構讓測試變得異常困難辨图,我們可能需要通過 stub 和 mock 的方式對請求攔截妙啃,然后返回構造的數據,這會用到NSURLProtocol的內容猛铅,或者是引入一些第三方的測試框架陆蟆,大大增加了項目的復雜度雷厂。在 Objective-C 時期這可能是一個可選項,但是在 Swift 的新時代叠殷,我們有好得多的方法來處理這件事情改鲫。

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

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

從上面的聲明從語義上來說是挺明確的缕题,但是因為Request是含有關聯(lián)類型的協(xié)議截歉,所以它并不能作為獨立的類型來使用,我們只能夠將它作為類型約束烟零,來限制輸入參數request瘪松。正確的聲明方式應當是:

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

除了使用這個泛型方式以外,我們還將host從Request移動到了Client里锨阿,這是更適合它的地方×构洌現(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ā)送請求。這樣網絡層的具體實現(xiàn)和請求本身就不再相關了口渔,我們之后在測試的時候會進一步看到這么做所帶來的好處样屠。

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

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

Decodable定義了一個靜態(tài)的parse方法,現(xiàn)在我們需要在Request的Response關聯(lián)類型中為它加上這個限制礁扮,這樣我們可以保證所有的Response都可以對數據進行解析知举,原來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é)議的網絡層就呈現(xiàn)在我們眼前了锰提。想要調用UserRequest時,我們可以這樣寫:

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

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

網絡層測試

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

我們先準備一個文本文件,將它添加到項目的測試 target 中为障,作為網絡請求返回的內容:

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

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

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 中讀取預先定義的文件,將其作為返回結果進行parse伴逸,然后調用handler缠沈。如果我們需要增加其他請求的測試,可以添加新的case項。另外洲愤,加載本地文件資源的部分應該使用更通用的寫法颓芭,不過因為我們這里只是示例,就不過多糾結了柬赐。

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

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

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

可擴展性

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

如果你對 POP 方式的網絡請求和模型解析感興趣的話皱碘,不妨可以看看APIKit這個框架,我們在示例中所展示的方法隐孽,正是這個框架的核心思想癌椿。

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

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

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

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

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

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

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

既然 POP 有這么多好處驮审,那我們是不是不再需要面向對象,可以全面轉向面向協(xié)議了吉执?

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

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

我們在項目里用了很多 POP 的概念逝慧。上面 demo 里的網絡請求的例子就是從實際項目中抽出來的昔脯,我們覺得這樣的請求寫起來非常輕松,因為代碼很簡單笛臣,新人進來交接也十分愜意云稚。除了模型層之外,我們在 view 和 view controller 層也用了一些 POP 的代碼沈堡,比如從 nib 創(chuàng)建 view 的NibCreatable静陈,支持分頁請求 tableview controller 的NextPageLoadable,空列表時顯示頁面的EmptyPage等等诞丽。因為時間有限鲸拥,不可能展開一一說明,所以這里我只挑選了一個具有代表性率拒,又不是很復雜的網絡的例子崩泡。其實每個協(xié)議都讓我們的代碼禁荒,特別是 View Controller 變短猬膨,而且使測試變?yōu)榭赡堋呛伴?梢哉f勃痴,我們的項目從 POP 受益良多,而且我們應該會繼續(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

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
  • 序言:七十年代末姐军,一起剝皮案震驚了整個濱河市铁材,隨后出現(xiàn)的幾起案子尖淘,更是在濱河造成了極大的恐慌,老刑警劉巖著觉,帶你破解...
    沈念sama閱讀 206,378評論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件村生,死亡現(xiàn)場離奇詭異,居然都是意外死亡饼丘,警方通過查閱死者的電腦和手機趁桃,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,356評論 2 382
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來肄鸽,“玉大人卫病,你說我怎么就攤上這事〉渑牵” “怎么了蟀苛?”我有些...
    開封第一講書人閱讀 152,702評論 0 342
  • 文/不壞的土叔 我叫張陵,是天一觀的道長烂斋。 經常有香客問我屹逛,道長,這世上最難降的妖魔是什么汛骂? 我笑而不...
    開封第一講書人閱讀 55,259評論 1 279
  • 正文 為了忘掉前任罕模,我火速辦了婚禮,結果婚禮上帘瞭,老公的妹妹穿的比我還像新娘淑掌。我一直安慰自己,他們只是感情好蝶念,可當我...
    茶點故事閱讀 64,263評論 5 371
  • 文/花漫 我一把揭開白布抛腕。 她就那樣靜靜地躺著,像睡著了一般媒殉。 火紅的嫁衣襯著肌膚如雪担敌。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 49,036評論 1 285
  • 那天廷蓉,我揣著相機與錄音全封,去河邊找鬼。 笑死桃犬,一個胖子當著我的面吹牛刹悴,可吹牛的內容都是我干的。 我是一名探鬼主播攒暇,決...
    沈念sama閱讀 38,349評論 3 400
  • 文/蒼蘭香墨 我猛地睜開眼土匀,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了形用?” 一聲冷哼從身側響起就轧,我...
    開封第一講書人閱讀 36,979評論 0 259
  • 序言:老撾萬榮一對情侶失蹤证杭,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后妒御,有當地人在樹林里發(fā)現(xiàn)了一具尸體躯砰,經...
    沈念sama閱讀 43,469評論 1 300
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 35,938評論 2 323
  • 正文 我和宋清朗相戀三年携丁,在試婚紗的時候發(fā)現(xiàn)自己被綠了琢歇。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 38,059評論 1 333
  • 序言:一個原本活蹦亂跳的男人離奇死亡梦鉴,死狀恐怖李茫,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情肥橙,我是刑警寧澤魄宏,帶...
    沈念sama閱讀 33,703評論 4 323
  • 正文 年R本政府宣布,位于F島的核電站存筏,受9級特大地震影響宠互,放射性物質發(fā)生泄漏。R本人自食惡果不足惜椭坚,卻給世界環(huán)境...
    茶點故事閱讀 39,257評論 3 307
  • 文/蒙蒙 一予跌、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧善茎,春花似錦券册、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,262評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至耕赘,卻和暖如春骄蝇,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背操骡。 一陣腳步聲響...
    開封第一講書人閱讀 31,485評論 1 262
  • 我被黑心中介騙來泰國打工九火, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人当娱。 一個月前我還...
    沈念sama閱讀 45,501評論 2 354
  • 正文 我出身青樓吃既,卻偏偏與公主長得像考榨,于是被迫代替她去往敵國和親跨细。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 42,792評論 2 345

推薦閱讀更多精彩內容