深入浅出Apple响应式框架Combine

2020 年 11 月 12 日

深入浅出Apple响应式框架Combine

一、引言


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。最终输出如下:


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 数组中,这样能保证订阅者不会被释放。最终输出结果如下:


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 会输出如下:


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,最终终端输出如下:


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(),他们没有太大的区别,但是能给我们提供更好的输入以及提供手动设置断点。



输出如下:


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.swiftPublishers+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,代码如下:


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 还是很有必要的。


作者介绍


青花瓷的平方,携程技术专家,主要从事无线开发,负责携程支付 iOS 相关开发工作。


本文转载自公众号携程技术(ID:ctriptech)。


原文链接


深入浅出Apple响应式框架Combine


2020 年 11 月 12 日 14:00700

评论

发布
暂无评论
发现更多内容

重新强调完成的定义

Bob Jiang

Scrum 完成的定义 DoD definition of done

linux文件系统-inode学习整理

戈坞昂

Linux inode

在Gitlab-ce的Docker中使用自定义端口

天飞

Docker gitlab

JAVA主流锁

颇风

Java 多线程

Kafka系列第7篇:你必须要知道集群内部工作原理的一些事!

z小赵

大数据 kafka 实时计算

npm下载electron缓慢的问题

玏佾

npm Electron

东哥和刘亦菲的故事

张利东

R

设计模式前传——为什么要学设计模式

海星

Java 面试 设计模式

Web3极客日报#127

谢锐 | Frozen

区块链 开源 技术社区 Rebase Web3 Daily

Spring Security 中的授权操作原来这么简单

江南一点雨

Java spring Spring Boot spring security

识别代码中的坏味道(三)

Page

敏捷开发 面向对象 重构 代码质量 代码坏味道

单核小鸡上的Minikube实践(一)

摩登土狗

Docker Linux DevOps k8s minikube

项目提升服务过程与总结稿

Geek_bc0aff

Web3极客日报 #128

谢锐 | Frozen

区块链 开源 技术社区 Rebase Web3 Daily

MacOS 下使用VSCode进行GoLang Test报错

北纬32°

golang macos vscode Unit Test debug

中小型城市商业银行数字化转型实践(三)数据中台建设思路和路径

泡菜小仙

数据中台 数字化转型 数据架构

给苹果提醒APP配个助手

BabyKing

提醒助手 TODO 奇妙清单 Reminders Helper

ZooKeeper,到底如何选主?

奈学教育

产品周刊 | 第 15 期(20200517)

Herbert

产品 设计 产品经理 产品设计

换脸新潮流:BIGO风靡全球的人脸风格迁移技术

DT极客

中小型城市商业银行数字化转型实践(一)整体技术架构转型(双态IT)

泡菜小仙

数字化转型 架构设计 技术架构

我的读书笔记-樊登读书法

lmymirror

学习 读书笔记 方法论 读书方式

如何更自信的写作

七镜花园-董一凡

写作

Vue+SpringBoot+SpreadJS 实现的在线文档

Geek_Willie

Spring Boot Vue SpreadJS

中小型城市商业银行数字化转型实践(二)集成关系ESB APIGateway ServiceMesh

泡菜小仙

架构设计 集成架构 ESB

谈谈控制感(7):底线思维与控制感

史方远

职场 心理 成长

Redis缓存三大问题

Bruce Duan

redis 缓存穿透 缓存击穿 缓存雪崩

游戏夜读 | Two Sum问题的八个解

game1night

DDD 实践手册(番外篇: 事件风暴-概念)

Joshua

领域驱动设计 DDD 事件风暴 事件驱动 Event Storming

NIO看破也说破(四)—— Java的NIO

小眼睛聊技术

Java 学习 开源 架构 后端

Deno 入门手册:附大量 TypeScript 代码实例

寇云

node.js typescript

深入浅出Apple响应式框架Combine-InfoQ