面向协议编程与 Cocoa 的邂逅

文章转载:https://onevcat.com/2016/11/pop-cocoa-1/

(作者非常棒,建议大家点进去关注下作者)

本文是笔者在 MDCC 16 (移动开发者大会) 上 iOS 专场中的主题演讲的文字整理。您可以在这里找到演讲使用的 Keynote,部分示例代码可以在 MDCC 2016 的官方 repo中找到。因为全部内容比较长,所以分成了上下两个部分,本文 (上) 主要介绍了一些理论方面的内容,包括面向对象编程存在的问题,面向协议的基本概念和决策模型等,下半部分主要展示了一些笔者日常使用面向协议思想和 Cocoa 开发结合的示例代码,并对其进行了一些解说。

引子

面向协议编程 (Protocol Oriented Programming,以下简称 POP) 是 Apple 在 2015 年 WWDC 上提出的 Swift 的一种编程范式。相比与传统的面向对象编程 (OOP),POP 显得更加灵活。结合 Swift 的值语义特性和 Swift 标准库的实现,这一年来大家发现了很多 POP 的应用场景。本次演讲希望能在介绍 POP 思想的基础上,引入一些日常开发中可以使用 POP 的场景,让与会来宾能够开始在日常工作中尝试 POP,并改善代码设计。

起・初识 - 什么是 Swift 协议

Protocol

Swift 标准库中有 50 多个复杂不一的协议,几乎所有的实际类型都是满足若干协议的。protocol 是 Swift 语言的底座,语言的其他部分正是在这个底座上组织和建立起来的。这和我们熟知的面向对象的构建方式很不一样。

一个最简单但是有实际用处的 Swift 协议定义如下:

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

这几行代码定义了一个名为Greetable的协议,其中有一个name属性的定义,以及一个greet方法的定义。

所谓协议,就是一组属性和/或方法的定义,而如果某个具体类型想要遵守一个协议,那它需要实现这个协议所定义的所有这些内容。协议实际上做的事情不过是“关于实现的约定”。

面向对象

在深入 Swift 协议的概念之前,我想先重新让大家回顾一下面向对象。相信我们不论在教科书或者是博客等各种地方对这个名词都十分熟悉了。那么有一个很有意思,但是其实并不是每个程序员都想过的问题,面向对象的核心思想究竟是什么?

我们先来看一段面向对象的代码:

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方法,并为它们提供了实现。子类的Tiger根据自身情况重写了leg(4 条腿)和eat(吃肉),而对于run,父类的实现已经满足需求,因此不必重写。

我们看到Tiger和Animal共享了一部分代码,这部分代码被封装到了父类中,而除了Tiger的其他的子类也能够使用Animal的这些代码。这其实就是 OOP 的核心思想 - 使用封装和继承,将一系列相关的内容放到一起。我们的前辈们为了能够对真实世界的对象进行建模,发展出了面向对象编程的概念,但是这套理念有一些缺陷。虽然我们努力用这套抽象和继承的方法进行建模,但是实际的事物往往是一系列特质的组合,而不单单是以一脉相承并逐渐扩展的方式构建的。所以最近大家越来越发现面向对象很多时候其实不能很好地对事物进行抽象,我们可能需要寻找另一种更好的方式。

面向对象编程的困境

横切关注点

我们再来看一个例子。这次让我们远离动物世界,回到 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

这是一个比较糟糕的解决方案,但是演讲现场还是有不少朋友选择了这个方案,特别是在工期很紧,无暇优化的情况下。这诚然可以理解,但是这也是坏代码的开头。我们应该尽量避免这种做法。

引入 BaseViewController

在一个继承自UIViewController的BaseViewController上添加需要共享的代码,或者干脆在UIViewController上添加 extension。看起来这是一个稍微靠谱的做法,但是如果不断这么做,会让所谓的Base很快变成垃圾堆。职责不明确,任何东西都能扔进Base,你完全不知道哪些类走了Base,而这个“超级类”对代码的影响也会不可预估。

依赖注入

通过外界传入一个带有myMethod的对象,用新的类型来提供这个功能。这是一个稍好的方式,但是引入额外的依赖关系,可能也是我们不太愿意看到的。

多继承

当然,Swift 是不支持多继承的。不过如果有多继承的话,我们确实可以从多个父类进行继承,并将myMethod添加到合适的地方。有一些语言选择了支持多继承 (比如 C++),但是它会带来 OOP 中另一个著名的问题:菱形缺陷。

菱形缺陷

上面的例子中,如果我们有多继承,那么ViewController和AnotherViewController的关系可能会是这样的:

在上面这种拓扑结构中,我们只需要在ViewController中实现myMethod,在AnotherViewController中也就可以继承并使用它了。看起来很完美,我们避免了重复。但是多继承有一个无法回避的问题,就是两个父类都实现了同样的方法时,子类该怎么办?我们很难确定应该继承哪一个父类的方法。因为多继承的拓扑结构是一个菱形,所以这个问题又被叫做菱形缺陷 (Diamond Problem)。像是 C++ 这样的语言选择粗暴地将菱形缺陷的问题交给程序员处理,这无疑非常复杂,并且增加了人为错误的可能性。而绝大多数现代语言对多继承这个特性选择避而远之。

动态派发安全性

Objective-C 恰如其名,是一门典型的 OOP 语言,同时它继承了 Small Talk 的消息发送机制。这套机制十分灵活,是 OC 的基础思想,但是有时候相对危险。考虑下面的代码:

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

我们如果在ViewController和AnotherViewController中都实现了myMethod的话,这段代码是没有问题的。myMethod将会被动态发送给array中的v1和v2。但是,要是我们有一个没有实现myMethod的类型,会如何呢?

NSObject*v3=[NSObjectnew]// v3 没有实现 `myMethod`NSArray*array=@[v1,v2,v3];for(idobjinarray){[objmyMethod];}// Runtime error:

// unrecognized selector sent to instance blabla

编译依然可以通过,但是显然,程序将在运行时崩溃。Objective-C 是不安全的,编译器默认你知道某个方法确实有实现,这是消息发送的灵活性所必须付出的代价。而在 app 开发看来,用可能的崩溃来换取灵活性,显然这个代价太大了。虽然这不是 OOP 范式的问题,但它确实在 Objective-C 时代给我们带来了切肤之痛。

三大困境

我们可以总结一下 OOP 面临的这几个问题。

动态派发安全性

横切关注点

菱形缺陷

首先,在 OC 中动态派发让我们承担了在运行时才发现错误的风险,这很有可能是发生在上线产品中的错误。其次,横切关注点让我们难以对对象进行完美的建模,代码的重用也会更加糟糕。

承・相知 - 协议扩展和面向协议编程

使用协议解决 OOP 困境

协议并不是什么新东西,也不是 Swift 的发明。在 Java 和 C# 里,它叫做Interface。而 Swift 中的 protocol 将这个概念继承了下来,并发扬光大。让我们回到一开始定义的那个简单协议,并尝试着实现这个协议:

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

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

实现很简单,Person结构体通过实现name和greet来满足Greetable。在调用时,我们就可以使用Greetable中定义的方法了。

动态派发安全性

除了Person,其他类型也可以实现Greetable,比如Cat:

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

现在,我们就可以将协议作为标准类型,来对方法调用进行动态派发了:

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

对于没有实现 Greetbale 的类型,编译器将返回错误,因此不存在消息误发送的情况:

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

这样一来,动态派发安全性的问题迎刃而解。如果你保持在 Swift 的世界里,那这个你的所有代码都是安全的。

✅ 动态派发安全性

横切关注点

菱形缺陷

横切关注点

使用协议和协议扩展,我们可以很好地共享代码。回到上一节的myMethod方法,我们来看看如何使用协议来搞定它。首先,我们可以定义一个含有myMethod的协议:

protocolP{funcmyMethod()}

注意这个协议没有提供任何的实现。我们依然需要在实际类型遵守这个协议的时候为它提供具体的实现:

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

你可能不禁要问,这和 Copy & Paste 的解决方式有何不同?没错,答案就是 – 没有不同。不过稍安勿躁,我们还有其他科技可以解决这个问题,那就是协议扩展。协议本身并不是很强大,只是静态类型语言的编译器保证,在很多静态语言中也有类似的概念。那到底是什么让 Swift 成为了一门协议优先的语言?真正使协议发生质变,并让大家如此关注的原因,其实是在 WWDC 2015 和 Swift 2 发布时,Apple 为协议引入了一个新特性,协议扩展,它为 Swift 语言带来了一次革命性的变化。

所谓协议扩展,就是我们可以为一个协议提供默认的实现。对于P,可以在extension P中为myMethod添加一个实现:

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

有了这个协议扩展后,我们只需要简单地声明ViewController和AnotherViewController遵守P,就可以直接使用myMethod的实现了:

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

不仅如此,除了已经定义过的方法,我们甚至可以在扩展中添加协议里没有定义过的方法。在这些额外的方法中,我们可以依赖协议定义过的方法进行操作。我们之后会看到更多的例子。总结下来:

协议定义

提供实现的入口

遵循协议的类型需要对其进行实现

协议扩展

为入口提供默认实现

根据入口提供额外实现

这样一来,横切点关注的问题也简单安全地得到了解决。

✅ 动态派发安全性

✅ 横切关注点

菱形缺陷

菱形缺陷

最后我们看看多继承。多继承中存在的一个重要问题是菱形缺陷,也就是子类无法确定使用哪个父类的方法。在协议的对应方面,这个问题虽然依然存在,但却是可以唯一安全地确定的。我们来看一个多个协议中出现同名元素的例子:

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

如果有一个类型,需要同时实现两个协议的话,它必须提供一个name属性,来同时满足两个协议的要求:

structPerson:Nameable,Identifiable{letname:Stringletid:Int}// `name` 属性同时满足 Nameable 和 Identifiable 的 name

这里比较有意思,又有点让人困惑的是,如果我们为其中的某个协议进行了扩展,在其中提供了默认的name实现,会如何。考虑下面的代码:

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

这样的编译是可以通过的,虽然Person中没有定义name,但是通过Nameable的name(因为它是静态派发的),Person依然可以遵守Identifiable。不过,当Nameable和Identifiable都有name的协议扩展的话,就无法编译了:

extensionNameable{varname:String{return"default name"}}extensionIdentifiable{varname:String{return"another default name"}}structPerson:Nameable,Identifiable{// let name: Stringletid:Int}// 无法编译,name 属性冲突

这种情况下,Person无法确定要使用哪个协议扩展中name的定义。在同时实现两个含有同名元素的协议,并且它们都提供了默认扩展时,我们需要在具体的类型中明确地提供实现。这里我们将Person中的name进行实现就可以了:

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

这里的行为看起来和菱形问题很像,但是有一些本质不同。首先,这个问题出现的前提条件是同名元素以及同时提供了实现,而协议扩展对于协议本身来说并不是必须的。其次,我们在具体类型中提供的实现一定是安全和确定的。当然,菱形缺陷没有被完全解决,Swift 还不能很好地处理多个协议的冲突,这是 Swift 现在的不足。

✅ 动态派发安全性

✅ 横切关注点

❓菱形缺陷

本文是笔者在 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.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实例,非常简单。

现在让我们来看看有趣的部分,也就是如何使用 POP 的方式从 URL 请求数据,并生成对应的User。首先,我们可以创建一个 protocol 来代表请求。对于一个请求,我们需要知道它的请求路径,HTTP 方法,所需要的参数等信息。一开始这个协议可能是这样的:

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

将host和path拼接起来可以得到我们需要请求的 API 地址。为了简化,HTTPMethod现在只包含了GET和POST两种请求方式,而在我们的例子中,我们只会使用到GET请求。

现在,可以新建一个UserRequest来实现Request协议:

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

UserRequest中有一个未定义初始值的name属性,其他的属性都是为了满足协议所定义的。因为请求的参数用户名name会通过 URL 进行传递,所以parameter是一个空字典就足够了。有了协议定义和一个满足定义的具体请求,现在我们需要发送请求。为了任意请求都可以通过同样的方法发送,我们将发送的方法定义在Request协议扩展上:

extensionRequest{funcsend(handler:@escaping(User?)->Void){// ... send 的实现}}

在send(handler:)的参数中,我们定义了可逃逸的(User?) -> Void,在请求完成后,我们调用这个handler方法来通知调用者请求是否完成,如果一切正常,则将一个User实例传回,否则传回nil。

我们想要这个send方法对于所有的Request都通用,所以显然回调的参数类型不能是User。通过在Request协议中添加一个关联类型,我们可以将回调参数进行抽象。在Request最后添加:

protocolRequest{...associatedtypeResponse}

然后在UserRequest中,我们也相应地添加类型定义,以满足协议:

structUserRequest:Request{...typealiasResponse=User}

现在,我们来重新实现send方法,现在,我们可以用Response代替具体的User,让send一般化。我们这里使用URLSession来发送请求:

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 创建请求,进行配置,生成 data task 并将请求发送。剩下的工作就是将回调中的data转换为合适的对象类型,并调用handler通知外部调用者了。对于User我们知道可以使用User.init(data:),但是对于一般的Response,我们还不知道要如何将数据转为模型。我们可以在Request里再定义一个parse(data:)方法,来要求满足该协议的具体类型提供合适的实现。这样一来,提供转换方法的任务就被“下放”到了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()}}

现在,我们来试试看请求一下这个 API:

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

重构,关注点分离

虽然能够实现需求,但是上面的实现可以说非常糟糕。让我们看看现在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应该做的事情应该仅仅是定义请求入口和期望的响应类型,而现在Request不光定义了host的值,还对如何解析数据了如指掌。最后send方法被绑死在了URLSession的实现上,而且是作为Request的一部分存在。这是很不合理的,因为这意味着我们无法在不更改请求的情况下更新发送请求的方式,它们被耦合在了一起。这样的结构让测试变得异常困难,我们可能需要通过 stub 和 mock 的方式对请求拦截,然后返回构造的数据,这会用到NSURLProtocol的内容,或者是引入一些第三方的测试框架,大大增加了项目的复杂度。在 Objective-C 时期这可能是一个可选项,但是在 Swift 的新时代,我们有好得多的方法来处理这件事情。

让我们开始着手重构刚才的代码,并为它们加上测试吧。首先我们将send(handler:)从Request分离出来。我们需要一个单独的类型来负责发送请求。这里基于 POP 的开发方式,我们从定义一个可以发送请求的协议开始:

protocolClient{funcsend(_r:Request,handler:@escaping(Request.Response?)->Void)}// 编译错误

从上面的声明从语义上来说是挺明确的,但是因为Request是含有关联类型的协议,所以它并不能作为独立的类型来使用,我们只能够将它作为类型约束,来限制输入参数request。正确的声明方式应当是:

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

除了使用这个泛型方式以外,我们还将host从Request移动到了Client里,这是更适合它的地方。现在,我们可以把含有send的Request协议扩展删除,重新创建一个类型来满足Client了。和之前一样,它将使用URLSession来发送请求:

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()}}

现在发送请求的部分和请求本身分离开了,而且我们使用协议的方式定义了Client。除了URLSessionClient以外,我们还可以使用任意的类型来满足这个协议,并发送请求。这样网络层的具体实现和请求本身就不再相关了,我们之后在测试的时候会进一步看到这么做所带来的好处。

现在这个的实现里还有一个问题,那就是Request的parse方法。请求不应该也不需要知道如何解析得到的数据,这项工作应该交给Response来做。而现在我们没有对Response进行任何限定。接下来我们将新增一个协议,满足这个协议的类型将知道如何将一个data转换为实际的类型:

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

Decodable定义了一个静态的parse方法,现在我们需要在Request的Response关联类型中为它加上这个限制,这样我们可以保证所有的Response都可以对数据进行解析,原来Request中的parse声明也就可以移除了:

// 最终的 Request 协议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等清理一下,一个类型安全,解耦合的面向协议的网络层就呈现在我们眼前了。想要调用UserRequest时,我们可以这样写:

URLSessionClient().send(UserRequest(name:"onevcat")){userinifletuser=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需要做的事情就是从本地文件中加载定义好的结果,然后验证模型实例是否正确:

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` 的要求,实际我们不会发送请求lethost=""}

LocalFileClient做的事情很简单,它先检查输入请求的path属性,如果是/users/onevcat(也就是我们需要测试的请求),那么就从测试的 bundle 中读取预先定义的文件,将其作为返回结果进行parse,然后调用handler。如果我们需要增加其他请求的测试,可以添加新的case项。另外,加载本地文件资源的部分应该使用更通用的写法,不过因为我们这里只是示例,就不过多纠结了。

在LocalFileClient的帮助下,现在可以很容易地对UserRequest进行测试了:

functestUserRequest(){letclient=LocalFileClient()client.send(UserRequest(name:"onevcat")){userinXCTAssertNotNil(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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,126评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,254评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,445评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,185评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,178评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,970评论 1 284
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,276评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,927评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,400评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,883评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,997评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,646评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,213评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,204评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,423评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,423评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,722评论 2 345

推荐阅读更多精彩内容