作者简介
青花瓷的平方,携程技术专家,主要从事无线开发,负责携程支付iOS相关开发工作。
一、引言
Combine.framework 是Apple在2019 WWDC 上基于Swift推出的函数响应框架(Functional Reactive Programming),支持Apple全平台的操作系统(iOS13 ,macOS 10.15 等)。函数式响应框架无论在哪个平台早已流行泛滥,开源的Rx更是实现了各种语言的响应式编程框架。Apple在这个时候推出响应式框架,无疑是对自己护城河的进一步巩固。事实上SwiftUI的数据驱动就是依赖Combine。
本文将深入浅出地介绍Combine的基本概念和原理,然后通过具体demo详细阐述其在实际编码中的应用。
二、什么是Combine
Combine is Swift declarative framework for processing values over time
Combine 为处理随着时间变化的值的一种声明式框架。Combine 作用是将异步事件通过组合事件处理操作符进行自定义处理。关注如何处理变化的值,正是响应式编程的核心。
Combine可以概述为一种声明式的函数响应式编程,简洁用下图表示:
通过此图,我们可以总结Combine是什么:
Combine = Publishers Operators Subscribers
2.1 Publishers
Publisher sends sequences of values over time to one or more Subscribers.
发布者(Publisher)随着时间变化发送一系列的值给一个或者多个订阅者(Subscriber)。
一个发布者可以发布一个value,Value的类型为Output,有两种状态:成功/失败。成功会发送Value,失败就会产生Failure,Failure的类型为Error。当然如果一个发布者永远不失败,那么失败就是Never类型。
Combine内置的Publisher有Just, Future, Deferred, Empty, Fail, Record, Published以及PassthroughSubject和CurrentValueSubject。Published实际上是用propertyWrapper封装的Publisher,它可以将任意一个变量封装成一个Publisher,并通过projectedValue(影子变量)轻松实现MVVM,本文将在后续介绍。
2.2 Subscribers
Subscriber receives values from a publisher.
订阅者(Subscriber)接收发布者发送的Value。订阅者遵循的协议如下:
订阅者接受一个Input类型的Value以及接收到事件失败的类型Failure。protocol中的三个receive方法描述了订阅三种不同的生命周期,本文会在后续2.5介绍。
Publisher发布者协议中有两个通用类型参数Output和Failure。而Subscriber订阅者接受一个Input类型的Value或者接收到事件已经发送成功或者失败。既然订阅者和发布者都有了,接下来的关键是如何连接他们,连接他们的是Subscribtion(订阅),我们将在2.5中详细介绍。
使用sink方法和assign方法将在Combine内部自动创建subcribtion连接发布者和订阅者。Publisher发布者协议中有两个通用类型参数Output和Failure,而Subscriber订阅者接收发布者产生的Output和Failure,因为发布者和订阅者是互相协作的,所以一个匹配的发布者和订阅者会有Output==Input和Failure == Failure,如果不匹配,编译器会自动报错提示我们。
Combine内置了两种Subscribers,分别是Subscribers.Sink和Subscriber.Assign。简单举例说明:
注释1中我们创建了List,并使用内置的Publishers.Sequence<[Int], Never>创建了Publisher,其中Int是输入参数Output,明显是数组中的单个元素,并且指定了失败类型为Never。然后我们创了subscriber,指定input为Int,Failure为Never。然后通过subscriber方法连接他们,subcriber方法会在内部创建subcription连接Publisher和Subscriber。最终输出如下:
代码语言:javascript复制receiveValue:1 receiveValue:2 receiveValue:3 receiveValue:4 receiveValue:5 receiveValue:6 finished
得益于Swift的Extension,我们可以将上述创建的subscriber包裹到Publisher的Extension中,所以就有了注释2的简化版。进一步,我们可以拓展序列的Extension,将publisher封装到Sequence的扩展中,所以才有最终简化版方法注释3。
2.3 Subject
Subject主题是一种特殊的发布者,它可以自己主动传送Value到Combine事件流中,接口如下:
Combine内置了两种Subject,分别是PassthrougSubject和CurrentValueSubject,他们的含义都很明确。这里我们通过举例来说明PassthrougSubject:
上述代码中我们创建subject对象,指定Output为String,Failure为ExampleError。然后通过sink产生订阅者连接,sink方法返回的是Anycancellabel对象,它表示一个发布者和订阅者的链接可取消,通过store方法将其保存在外部变量setList数组中,这样能保证订阅者不会被释放。最终输出结果如下:
代码语言:javascript复制Subscriber received value: Hello! Subscriber received value: Hello Again! failure(CombineTest2.ExampleError.somethingWentWrong)
从输出中可以看到一旦一个事件流完成(completion)或者遇到Error后,后续再发送其他的值,由于此时事件流已经结束,所以输出结果中不会再有后续的send的Value。
2.4 Operators
响应式编程的核心其实是Publishers各种转换,为什么要有操作符?因为任何一个事件流中,往往最初的对象和我们最后产生的对象并不一致,这时候就要通过Operator来转换输入的对象。Combine中的Operator是将一个Publisher作为输入对象,通过operator产生另一个Publisher。
Combine中的各种operators是定义在Publisher的各种Extension中。在各自的扩展中实现了内置的classes或者structures。举例来说,map(:)操作符返回的对象是Publishers.Map对象。Apple目前内置了50多种Operators,尽管这样,它仍然比Rxswift少,这里有一份Combine和RxSwift的操作符对比RxSwift to Combine Cheatsheet。我们列举几种简单的如下图:
我们通过URLSession内置的dataPublisher发送网络请求解析来说明用法,目的是为了说明Combine中的异步API以及在异步API中如何使用Operator。代码示例如下图:
1)我们定义了常见的网络请求的错误类型;
2)UserResponse返回的是服务端的json数据Model;
3)判断URL是否有误,如果异常,返回PassthroughSubject生成的订阅者,发送unsupportUrl的Failure告知外部事件流结束;
4)tryMap的Input类型是dataPublisher返回的元组(data: Data, response: URLResponse),我们判断http的statusCode是否异常,如果异常直接thorw错误,否则将元组的第一个元素data返回,所以对应的Output为Data,Failure为CustomAPIError;
5)通过decode操作符将data转换为UserResponse,decode的失败Failure类型为Error;
6)处理tryMap和decode产生的Error,将其全部转换为CustomAPIError;
7)最后通过earseToAnyPublisher将内部产生的Publisher类型擦除,因为外部关心的是Publisher携带的UserResponse和CustomAPIError;
8)最终调用sink方法可以轻松的接送服务返回的数据。
2.5 生命周期
在2.2中我们已经说明了连接Publisher/Subscriber实际是通过一个中介对象Subscription。完成的流程如下图:
- 1-3,当一个订阅者Subscriber创建和绑定到发布者Publisher,订阅者Publisher将创建一个Subscription对象,并将subscriber的引用发给Subscription,这是时序图中的步骤1-3。
- 4,这时候订阅者Subscriber和发布者Publisher已经通过subscription绑定了,订阅者Subscriber就可以通过request(_ demand:)方法获取它想要多少个Value,demand参数实际上是一个Int包裹值,类型包括:
Subscribers.Demand.none Subscribers.Demand.unlimited Subscribers.Demand.max(Int)
当我们通过Publisher的sink方法创建subscription时候,实际request的参数是.unlimited,因为它想接收发布者Publisher发送的所有values。大多情况这是我们想要的,但是某些情况如果想要限制订阅者的请求次数,那么就可以通过定义具体的max携带的Int值,比如:
上图中我们自定义了IntSubscriber,在receive(subscription:)方法中最多请求接收2次Value,所以在console会输出如下:
代码语言:javascript复制Received subscription Received input:0 Received input:1
最终我们只接收到2次Input,由此可见Demand决定了订阅者和Subscription的生命周期。
- 5,收到订阅者Subscriber的请求后,subscription通过发布者Publisher发送Value给订阅者Subscriber;
- 6-7,subscription根据Demand的值来提交value,通过调用receive(_:)方法发送value,直到到达发送次数Demand的最大值;
- 8,订阅者接收subscription发送的value,作为响应,它将返回一个新的Demand,注意到demand会根据先前已发送的demand进行相加,所以demand会保持至少不会减少;
- 一旦新的Demand被subscription接收到,subscription又会根据demand重新来提交value,因此整个6-8过程是循环过程,一直到接收到completion或者Failure整个事件流才完全结束,这就是时序图中的9-10。
2.6 Debugging
响应式编程的最大痛点就是出现bug难以排查问题,但Apple设计的API通常简洁好用且方便调试。Apple提供了print()和HandleEvents()两种API来控制输出,方便开发者调试。
通过举例来说明:
我们将数组[1,2,3,4]的publisher过滤数组中的奇数,然后通过map将转换成平方,在此通过map转换成String,最终终端输出如下:
代码语言:javascript复制receive subscription: ([1, 2, 3, 4]) //1 request unlimited //2 receive value: (1) //3 request max: (1) (synchronous) //4 receive value: (2) //5 receiveValue=EventNumber=4 //6 receive value: (3) request max: (1) (synchronous) receive value: (4) receiveValue=EventNumber=16 receive finished completion=finished
注释1是我们通过数组最终转换的Publisher.Map通过receive方法连接订阅者AnySubscriber,然后创建subscription连接他们,紧接着subscriber通过request(:)方法获取需要知道请求多少个value,这里是无限次。
然后subscription提交value,subscriber通过receive(:)方法接收value:1,并返回响应Demand.max(1)。这里涉及到Filter的实现问题,由于1不是偶数,因为不满足我们的条件,在Combine的Publishers.Filter中会在receive(_:)方法中将不满足过滤条件的value返回max(1),从而保证事件流下一次执行。注释5接收到value:2,满足fliter然后进行map转换提交value,一直到事件complete完成,整个事件流才结束。这里佐证了我们在2.5时序图描述是正确的。
上述print()函数也可以替换成HandleEvents(),他们没有太大的区别,但是能给我们提供更好的输入以及提供手动设置断点。
输出如下:
代码语言:javascript复制receiveRequest:unlimited receiveSubscription:[1, 2, 3, 4] receiveOutput:1 receiveRequest:max(1) receiveOutput:2 receiveOutput:3 receiveRequest:max(1) receiveOutput:4 receiveCompletion:finished
此外还有breakpointOnError()和breakpoint(),本文限于长度不在累述,详情可参考Apple官方文档。
三、实战
3.1 自定义Publishers和Subscribers
iOS13系统内置了诸如KVO,Notification,URLSession,Timer的Publisher,所以大部分场景开发者不需要自定义的Publisher,但某些场景也有特定需求。UIKit本身自带了很多UI控件的事件,但iOS系统本身确没有给出内置的publisher,为此我们需要自定义UIControl的Publisher。
3.1.1 自定义Subscription
在创建Plublisher之前,我们先创建Subscrption,因为Subscription是连接发布者和订阅者的中介者,没有它Combine事件流无法驱动。
我们定义了UIControlSubscription:
1)构造器带有三个参数:分别是subscriber,control,以及Control的事件类型。我们保存subcriber,是因为在接下来的点击事件中,要让subcriber接收Value。因为点击事件不会有失败类型,所以限制Failure类型为Never;
2)实现cancel方法,以便于Combine能正确的释放资源;
3)注意到Subscription在初始化时候回调用receive(:)方法,系统内置的Subscriber.Sink在receive方法中会调用request(.unlimited),这里不再request填充任何代码,是因为我们只想当点击事件发生就立即处理,无论当前的请求次数是多少。一旦用户出发了点击事件,就会执行eventHandler方法,订阅者subcriber就会立即接收我们UIControl。
3.1.2 自定义Publisher
当UIControl的事件发生时,需要将UIControl本身传递出去。我们定义UIControlPublisher代码如下:
1)UIControlPublisher的Output传递为UIControl本身,Failure为Never。
2)在构造器中,除了传递UIControl,还将事件UIControl.Event传递进去,因为我们想要处理不同的UI事件。
3)receive方法是将订阅者连接到发布者上,我们内部创建在3.1.1中定义的UIControlSubscription,然后调用subscriber的receive方法向Publisher请求接收Control。
3.1.3 使用UIControlPublisher
我们在UIControlPublisher中使用了泛型,这样在拓展UIControl子控件时候就可以无需转换,方便地直接使用。拓展常见的UI事件的Publisher如下:
1)使用Extension集成了publisher的通用方法,这样它的所有子控件都可以快速使用该方法。
2)除了通用的publisher,我们还使用Extension扩展了UITextFiled输入框监听文字可变的Publisher,以及UISwitch开关状态的Publisher。
3.1.4 补充说明
我们自定义了UIControlPublisher,限于篇幅不会再定义其他诸如异步事件处理的Publisher。虽然Combine本身是闭源的,但Combine在Foundation层的代码确是开源的,有兴趣的读者可以参考Swift源码中Publishers URLSession.swift和Publishers NotificationCenter.swift进一步了解Publisher和Subscriber是如何协同工作的原理。
3.2 实战
实现一个简单登录注册的UI,如下所示:
界面很简单,就是用户名,密码,确认密码三个输入框以及同意隐私协议开关按钮和注册按钮。来给定一个简单的验证规则:
1)当用户输入登录名称大于等于6位;
2)密码和确认密码相等并且至少为6位;
3)用户同意隐私协议;
同时满足上述三个条件时注册按钮才点击可用,我们使用Combine来实现注册校验逻辑。
3.2.1 ViewModel
用Combine来实现MVVM,首先显示RegisterViewModel,如下:
1)注意到我们使用了Property Wrapper的Published来生成一个Publisher,Published包装任意一个变量成为Publisher,并且可以使用$符号表示其自身实际的Publisher。分别定义用户名username,密码password,二次确认密码confirmPwd,以及同意协议accept的Publisher。
2)定义validToRegisterPublisher为注册按钮可点击的Publisher为只读属性,内部使用CombineLatest操作符来生成新的Publisher,CombineLatest会依赖发布者产生的最新value值,然后通过map转换我们要求的验证规则是否合法,返回Bool类型,true表示注册按钮可点击。最后在使用eraseToAnyPublisher()来擦除产生的发布者类型,因为使用者最终只关心发布者携带的value值的类型。
3.2.2 Bind ViewModel
下面是在VC中具体bind ViewModel的示例:
1)我们实现了bindView()方法,该方法将UI控件通过在3.1中封装的自定义UIControlPublisher实现UI控件的事件绑定,并且将Publisher产生的值绑定到ViewModel中对应的Publisher中。
2)调用系统内置的assign方法将validToRegisterPublisher产生的value绑定到按钮的isEnabled属性上。
最终我们用Combine实现了MVVM模式的注册业务。
四、性能表现
RxSwift已在开源社区广泛应用,Apple本身推出的Combine的性能表现如何呢?我们使用Will Combine kill RxSwift?的测试代码来比较Combine和RxSwift,代码如下:
代码语言:javascript复制class CombineVSRxSwiftTests: XCTestCase {
private let input = stride(from: 0, to: 1_000_000, by: 1)
override class var defaultPerformanceMetrics: [XCTPerformanceMetric] {
return [ XCTPerformanceMetric("com.apple.XCTPerformanceMetric_TransientHeapAllocationsKilobytes"),
.wallClockTime
]
}
func testCombine() {
self.measure {
_ = Publishers.Sequence(sequence: input)
.map { $0 * 2 }
.filter { $0.isMultiple(of: 2) }
.flatMap { Just($0) }
.count()
.sink(receiveValue: {
print($0)
})
}
}
func testRxSwift() {
self.measure {
_ = Observable.from(input)
.map { $0 * 2 }
.filter { $0.isMultiple(of: 2) }
.flatMap { Observable.just($0) }
.toArray()
.map { $0.count }
.subscribe(onSuccess: { print($0) })
}
}
}
在2019 iMac 16G上测试出Time和Allocation的情况如下:
可以看到Combine的性能惊人,比RxSwift好的不止一个等级。当然Combine本身是闭源的,我们猜测Apple工程师可能使用大量的C/C 代码来优化性能,而RxSwift则是纯Swift实现,性能表现则不是那么特别优异。
五、总结
本文系统的介绍了Combine的Publisher,Subscriber,以及Operator的工作原理,并在了解工作原理的基础上自定义了UIControlPublisher,然后结合实际案例介绍了如何使用Combine实现MVVM模式,最后我们比较了Combine和目前流行的RxSwift,显而易见Combine在性能上有巨大的优势。
Combine只支持iOS13,或许让部分开发者觉得实际离我们还很远,但截止目前,根据Apple在2020WWDC前公布的数据,iOS13设备占有率已达92%以上,相信随着iOS14的到来,iOS13占有率会更高,提前了解和掌握Combine还是很有必要的。