大约一年前,Resso 接入了 Combine,运用呼应式编程简化了代码逻辑,也积累了许多实践经验。本文会从呼应式编程的基本思想并逐步深化介绍 Combine 的概念与最佳实践, 希望能协助更多的同学顺畅上手并实践呼应式编程,少踩坑。

等等,Resso 是什么?Resso 来历于 Resonate(共鸣),是字节跳动推出的一个交际音乐流媒体平台,专为下一代音乐发烧友规划,使他们能够经过对音乐的酷爱来表达和与他人建立联络。

书回正文,所谓的呼应式编程究竟是什么呢?

了解 Combine 的同学能够直接跳到实践主张部分。

呼应式编程

维基百科对呼应式编程的界说是:

在核算中,呼应式编程是一种面向数据流和改变传达的声明式编程范式。

尽管界说中每个字都知道,但连起来却十分隐晦。咱们能够把界说中的内容分开来了解,逐一击破。首要,让咱们来看下声明式编程。

声明式编程

声明式和指令式编程是常见的编程范式。在指令式编程中,开发者经过组合运算、循环、条件等句子让核算机履行程序。声明式与指令式正相反,假如说指令式像是告知核算机 How to do,而声明式则是告知核算机 What to do。其实大家都接触过声明式编程,但在编码时并不会意识到。各类 DSL 和函数式编程都归于声明式编程的范畴。

举个比如,假设咱们想要获取一个整形数组里的一切奇数。依照指令式的逻辑,咱们需求把进程拆解为一步一步的句子:

  1. 遍历数组中的一切元素。
  2. 判断是否为奇数。
  3. 假如是的话,参加到成果中。继续遍历。
varresults=[Int]()
fornuminvalues{
    ifnum%2!=0{
        results.append(num)
    }
}

假如按声明式编程来,咱们的主意或许是“过滤出一切奇数”,对应的代码就十分直观:

varresults=values.filter{$0%2!=0}

可见上述两种编程办法有着明显的区别:

  • 指令式编程:描述进程(How),核算机直接履行并得成果。
  • 声明式编程:描述成果(What),让核算机为咱们安排出详细进程,终究得到被描述的成果。

“面向数据流和改变传达”

用说人话的办法解说,面向数据流和改变传达是呼应未来产生的事情流。

从响应式编程到 Combine 实践

  1. 事情发布: 某个操作发布了事情A,事情A能够带着一个可选的数据B
  2. 操作变形: 事情A与数据B经过一个或多个的操作产生了改变,终究得到事情A'与数据B'
  3. 订阅运用: 在消费端,有一个或多个订阅者来消费处理后的A'B',并进一步驱动程序其他部分 (如 UI )

在这个流程中,无数的事情组成了事情流,订阅者不断承受到新的事情并作出呼应。

至此,咱们对呼应式编程的界说有了开端的了解,即以声明的办法呼应未来产生的事情流。 在实际编码中,许多优异的三方库对这套机制进一步抽象,为开发者供给了功用各异的接口。在 iOS 开发中,有三种主流的呼应式“门户“。

呼应式门户

  • ReactiveX:RxSwift
  • Reactive Streams:Combine
  • Reactive*:ReactiveCocoa / ReactiveSwift /ReactiveObjc

这三个门户别离是 ReactiveX、Reactive Streams 和 Reactive* ReactiveX 接下来会详细介绍。Reactive Stream 旨在界说一套非阻塞式异步事情流处理标准,Combine 挑选了它作为完结的标准。以 ReactiveCocoa 为代表的 Reactive*在 Objective-C 年代曾十分盛行,但随着 Swift 兴起,更多开发者挑选了 RxSwift 或 Combine,导致 Reactive* 整体热度下降不少。

ReactiveX (Reactive Extension)

ReactiveX 开端是微软在 .NET 上完结的一个呼应式的拓宽。它的接口命名并不直观,如 Observable (可观测的) 和 Observer(观测者)。ReactiveX 的优势在于创新地融入了许多函数式编程的概念,使得整个事情流的变形十分灵敏。这个易用且强壮的概念敏捷被各个言语的开发者喜爱,因此 ReactiveX 在许多言语都有对应版别的完结(如 RxJS,RxJava,RxSwift),都十分盛行。Resso 的 Android 团队就在重度运用 RxJava。

为何挑选 Combine

Combine 是 Apple 在 2019 年推出的一个类似 RxSwift 的异步事情处理结构。

经过对事情处理的操作进行组合 (combine) ,来对异步事情进行自界说处理 (这也正是 Combine 结构的姓名的由来)。Combine 供给了一组声明式的 Swift API,来处理随时刻改变的值。这些值能够代表用户界面的事情,网络的呼应,方案好的事情,或许许多其他类型的异步数据。

Resso iOS 团队也曾时间短尝试过 RxSwift,但在细心考察 Combine 后,发现 Combine 无论是在功用、调试快捷程度上都优于 RxSwift,此外还有内置结构和 SwiftUI 官配的特别优势,受其多方面优势的招引,咱们全面切换到了 Combine。

Combine 的优势

相较于 RxSwift,Combine 有许多优势:

  • Apple 出品
    • 内置在体系中,对 App 包体积无影响
  • 功用更好
  • Debug 更快捷
  • SwiftUI 官配

功用优势

Combine 的各项操作相较 RxSwift 有 30% 多的功用提高。

从响应式编程到 Combine 实践

Reference: Combine vs. RxSwift Performance Benchmark Test Suite

Debug 优势

由于 Combine 是一方库,在 Xcode 中敞开了Show stack frames without debug symbols and between libraries选项后,无效的堆栈能够大幅的减少,提高了 Debug 功率。

//在GlobalQueue中承受并答应出数组中的值
[1,2,3,4].publisher
.receive(on:DispatchQueue.global())
.sink{valuein
print(value)
}

从响应式编程到 Combine 实践

Combine 接口

上文说到,Combine 的接口是根据 Reactive Streams Spec 完结的,Reactive Streams 中现已界说好了PublisherSubscriberSubscription等概念,Apple 在其上有一些微调。

详细到接口层面,Combine API 与 RxSwift API 比较类似,更精简,了解 RxSwift 的开发者能无缝快速上手 Combine。Combine 中缺漏的接口能够经过其他已有接口组成替代,少部分操作符也有开源的第三方完结,对出产环境的运用不会产生影响。

从响应式编程到 Combine 实践

OpenCombine

细心的读者或许有发现Debug 优势的图中呈现了一个 OpenCombine。Combine 万般好,但有一个致命的缺陷:它要求的最低体系版别是 iOS 13,许多要保护兼容多个体系版别的 App 并不能运用。好在开源社区给力,完结了一份仅要求 iOS 9.0 的 Combine 开源完结:OpenCombine。经内部测试,OpenCombine 的功用与 Combine 持平。OpenCombine 运用上与 Combine 差距很小,未来假如 App 的最低版别升级至 iOS 13 之后,从 OpenCombine 迁移到 Combine 的成本也很低,基本只要简略的文本替换工作。公司内 Resso、剪映、醒图、Lark 都有运用 OpenCombine。

Combine 根底概念

上文说到,Combine 的概念根据 Reactive Streams。呼应式编程中的三个关键概念,事情发布/操作变形/订阅运用,别离对应到 Combine 中的Publisher,OperatorSubscriber

在简化的模型中,首要有一个Publisher,经过Operater改换后被Subscriber消费。而在实际编码中,Operator的来历或许是复数个PublisherOperator也或许会被多个Publisher订阅,通常会形成一个十分复杂的图。

从响应式编程到 Combine 实践

Publisher

Publisher<Output,Failure:Error>

Publisher事情产生的源头。事情是 Combine 中十分重要的概念,能够分红两类,一类带着了值(Value),另外一类标志了完毕(Completion)。完毕的能够是正常完结(Finished)或失利(Failure)。

Events:
- Value:Output
-Completion
-Finished
-Failure(Error)

从响应式编程到 Combine 实践

通常状况下, 一个Publisher能够生成N个事情后完毕。需求留意的是,一个Publisher一旦宣布了Completion(能够是正常完结或失利),整个订阅将完毕,之后就不能宣布任何事情了。

Apple 为官方根底库中的许多常用类供给了 Combine 拓宽 Publisher,如 Timer, NotificationCenter, Array, URLSession, KVO 等。运用这些拓宽咱们能够快速组合出一个 Publisher,如:

//`cancellable`是用于撤销订阅的token,下文会详细介绍
cancellable=URLSession.shared
//生成一个https://example.com恳求的Publisher
.dataTaskPublisher(for:URL(string:"https://example.com")!)
//将恳求成果中的Data转换为字符串,并疏忽掉空成果,下面会详细介绍compactMap
.compactMap{
String(data:$0.data,encoding:.utf8)
}
//在主线程承受后续的事情(上面的compactMap产生在URLSession的线程中)
.receive(on:RunLoop.main)
//对终究的成果(恳求成果对应的字符串)进行消费
.sink{_in
//
}receiveValue:{resultStringin
self.textView.text=resultString
}

此外,还有一些特别的Publisher也十分有用:

  • Future:只会产生一个事情,要么成功要么失利,适用于大部分简略回调场景
  • Just:对值的简略封装,如Just(1)
  • @Published:下文会详细介绍 在大部分状况下,运用这些特别的Publisher以及下文介绍的Subject能够灵敏组合出满意需求的事情源。极少的状况下,需求完结自界说的 Publisher ,能够看这篇文章。

Subscriber

Subscriber<Input,Failure:Error>

Subsriber作为事情的订阅端,它的界说与Publisher对应,Publisher中的Output对应SubscriberInput。常用的SubscriberSinkAssign

Sink直接对事情流进行订阅运用,能够对Valuecompletion别离进行处理。

Sink 这个单词在初度看到会令人十分隐晦。这个术语可来历于网络流中的汇点(Sink),咱们也能够了解为 The stream goes down the sink。

//从数组生成一个Publisher
cancellable=[1,2,3,4,5].publisher
.sink{completionin
//处理事情流完毕
}receiveValue:{valuein
//打印会每个值,会依次打印出1,2,3,4,5
print(value)
}

Assign是一个特化版的Sink,支撑经过KeyPath直接进行赋值。

lettextLabel=UILabel()
cancellable=[1,2,3].publisher
//将数字转换为字符串,并疏忽掉nil,下面会详细介绍这个Operator
.compactMap{String($0)}
.assign(to:.text,on:textLabel)

需求留意的是,假如用assignself进行赋值,或许会形成隐式的循环引用,这种状况需求改用sinkweak self手动进行赋值。

Cancellable & AnyCancellable

细心的读者或许发现了上面呈现了一个cancellable。每一个订阅都会生成一个AnyCancellable目标,用于控制订阅的生命周期。经过这个目标,咱们能够撤销订阅。当这个目标被开释时,订阅也会被撤销。

//撤销订阅
cancellable.cancel()

需求留意的是,每一个订阅咱们都需求持有这个cancellable,否则整个订阅会当即被撤销并完毕掉。

Subscription

PublisherSubscriber之间是经过Subscription建立衔接。了解整个订阅进程对后续深化运用 Combine 十分有协助。

从响应式编程到 Combine 实践
图片来自《SwiftUI 和 Combine 编程》

Combine 的订阅进程其实是一个拉取模型。

  1. Subscriber主张一个订阅,告知Publisher我需求一个订阅。
  2. Publisher回来一个订阅实体(Subscription)。
  3. Subscriber经过这个Subscription去恳求固定数量(Demand)的数据。
  4. Publisher根据Demand回来事情。单次的Demand发布完结后,假如Subscriber继续恳求事情,Publisher会继续发布。
  5. 继续发布流程。
  6. Subscriber恳求的事情悉数发布完结后,Publisher会发送一个Completion

Subject

Subject<Output,Failure:Error>

Subject是一类特别的Publisher,咱们能够经过办法调用(如send())手动向事情流中注入新的事情。

privateletisPlayingPodcastSubject=CurrentValueSubject<Bool,Never>(false)
//向isPlayingPodcastPublisher注入一个新的事情,它的值是true
isPlayingPodcastSubject.send(true)

Combine 供给了两个常用的SubjectPassthroughSubjectCurrentValueSubject

  • PassthroughSubject:透传事情,不会持有最新的Output
  • CurrentValueSubject:除了传递事情之外,会持有最新的Output

@Published

关于刚接触 Combine 的同学来说,最困扰的问题莫过于难以找到能够直接运用的事情源。Combine 供给了一个 Property Wrapper@Pubilshed能够快速封装一个变量得到一个Publisher

//声明变量
classAlarm{
@Published
publicvarcountDown=0
}
letalarm=Alarm()
//订阅改变
letcancellable=alarm.$countDown//Published<Int>.Publisher
.sink{print($0)}
//修正countDown,上面sink的闭包会触发
alarm.countDown+=1

上面比较有趣的是$countDown访问到的一个Publisher,这其实是一个语法糖,$访问到其实是countDownprojectedValue,正是对应的Publisher

@propertyWrapperpublicstructPublished<Value>{
//...
///Thepropertyforwhichthisinstanceexposesapublisher
///
///The``Published/projectedValue`isthepropertyaccessedwiththe`$`operator
publicvarprojectedValue:Published<Value>.Publisher{mutatinggetset}
}

@Published十分合适在模块内对事情进行封装,类型擦除后供给外部进行订阅消费。

实际实践中,关于已有的代码逻辑,运用@Published能够在不改动其他代码快速让特点得到 Publisher 的才能。而新编写的代码,假如不会产生过错且需求运用到当时的 Value,@Published也是很好的挑选,除此之外则需求按需考虑运用PassthroughSubjectCurrentValueSubject

Operator

实际编码中,Publisher带着的数据类型或许并不满意咱们的需求,这时需求运用Operator对数据进行改换。Combine 自带了十分丰富的 Operator,接下来会针对其间常用的几个进行介绍。

map, filter, reduce

了解函数式编程的同学对这几个 Operator 应该十分了解。它们的作用与在数组上的作用十分类似,只不过这次是在异步的事情流中。

例如,关于map来说,他会对每个事情中的值进行改换:

从响应式编程到 Combine 实践


[1,2,3].publisher
.map{$0*10}
.sink{valuein
//将会答应出10,20,30
print(value)
}

filter也类似,会对每个事情用闭包里的条件进行过滤。reduce则会对每个事情的值进行核算,终究将核算成果传递给下流。

compactMap

关于 Value 是Optional的事情流,能够运用compactMap得到一个 Value 为非空类型的 Publisher。

//Publiser<Int?,Never>->Publisher<Int,Never>
cancellable=[1,nil,2,3].publisher
.compactMap{$0}
.map{$0*10}
.sink{print($0)}

flatMap

flatMap是一个特别的操作符,它将每一个的事情转换为一个事情流并合并在一起。举例来说,当用户在查找框输入文本时,咱们能够订阅文本的改变,并针对每一个文本生成对应的查找恳求 Publisher,并将一切 Publisher 的事情汇聚在一起进行消费。

从响应式编程到 Combine 实践

其他常见的 Operator 还有zip,combineLatest等。

实践主张

类型擦除

Combine 中的Publisher在经过各种Operator改换之后会得到一个多层泛型嵌套类型:

URLSession.shared.dataTaskPublisher(for:URL(string:"https://resso.com")!)
.map{$0.data}
.decode(type:String.self,decoder:JSONDecoder())
//这个publisher的类型是Publishers.Decode<Publishers.Map<URLSession.DataTaskPublisher,JSONDecoder.Input>,String,JSONDecoder>

假如在Publisher创立变形完结后当即订阅消费,这并不会带来任何问题。但一旦咱们需求把这个Publisher供给给外部运用时,复杂的类型会露出过多内部完结细节,一起也会让函数/变量的界说十分臃肿。Combine 供给了一个特别的操作符erasedToAnyPublisher,让咱们能够擦除去详细类型:

//生成一个类型擦除后的恳求。函数的回来值更简练
funcrequestRessoAPI()->AnyPublisher<String,Error>{
letrequest=URLSession.shared.dataTaskPublisher(for:URL(string:"https://resso.com")!)
.map{$0.data}
.decode(type:String.self,decoder:JSONDecoder())
//Publishers.Decode<Publishers.Map<URLSession.DataTaskPublisher,JSONDecoder.Input>,String,JSONDecoder>
//to
//AnyPublisher<String,Error>
returnrequest.eraseToAnyPublisher()
}
//在模块外,不必关怀`requestRessoAPI()`回来的详细类型,直接进行消费
cancellable=requestRessoAPI().sink{_in
}receiveValue:{
print($0)
}

经过类型擦除,终究露出给外部的是一个简略的AnyPublisher<String, Error>

Debugging

呼应式编程写起来十分的行云流水,但 Debug 起来就相对没有那么愉快了。对此,Combine 也供给了几个 Operator 协助开发者 Debug。

Debug Operator

printhandleEvents

print能够打印出整个订阅进程从开端到完毕的 Subscription 改变与一切值,例如:

cancellable=[1,2,3].publisher
.receive(on:DispatchQueue.global())
//运用`ArrayPublisher`作为一切打印内容的前缀
.print("ArrayPublisher")
.sink{_in}

能够得到:

ArrayPublisher:receivesubscription:(ReceiveOn)
ArrayPublisher:requestunlimited
ArrayPublisher:receivecancel
ArrayPublisher:receivevalue:(1)
ArrayPublisher:receivevalue:(2)
ArrayPublisher:receivevalue:(3)
ArrayPublisher:receivefinished

在一些状况下,咱们只对一切改变中的部分事情感兴趣,这时候能够用handleEvents对部分事情进行打印。类似的还有breakpoint,能够在事情产生时触发断点。

画图法

到了万策尽的地步,用图像理清思路也是很好的办法。关于单个 Operator,能够在 RxMarble 找到对应 Operator 承认了解是否正确。关于复杂的订阅,能够画图承认事情流的传递是否契合预期。

letgreetings=PassthroughSubject<String,Never>()
letnames=PassthroughSubject<String,Never>()
letyears=PassthroughSubject<Int,Never>()
//CombineLatest会选用两个事情流中最新的值生成新的事情流
letgreetingNames=Publishers.CombineLatest(greetings,names)
.map{"($1)($0)"}
letwholeSentence=Publishers.CombineLatest(greetingNames,years)
.map{")($0),($1)"}
.sink{print($0)}
greetings.send("Hello")
names.send("Combine")
years.send(2022)

从响应式编程到 Combine 实践

常见过错

当即开端的 Just 和 Future

关于大部分的Publisher来说,它们在订阅后才会开端出产事情,但也有一些破例。JustFuture在初始化完结后会当即履行闭包出产事情,这或许会让一些耗时长的操作在不契合预期的时机提早开端,也或许会让第一个订阅错过一些太早开端的事情。

funcmakeMyPublisher()->AnyPublisher<Int,Never>{
Just(calculateTimeConsumingResult())
.eraseToAnyPublisher()
}

一个可行的解法是在这类Publisher外封装一层Defferred,让它在接收到订阅之后再开端履行内部的闭包。

funcmakeMyFuture2()->AnyPublisher<Int,Never>{
Deferred{
returnJust(calculateTimeConsumingResult())
}.eraseToAnyPublisher()
}

产生过错导致 Subscription 意外完毕

funcrequestingAPI()->AnyPublisher<String,Error>{
returnURLSession.shared
.dataTaskPublisher(for:URL(string:"https://resso.com")!)
.map{$0.data}
.decode(type:String.self,decoder:JSONDecoder())
.eraseToAnyPublisher()
}
cancellable=NotificationCenter.default
.publisher(for:UserCenter.userStateChanged)
.flatMap({_in
returnrequestingAPI()
})
.sink{completionin
}receiveValue:{valuein
textLabel.text=value
}

上面的代码中将用户状态的通知转化成了一个网络恳求,并将恳求成果更新到一个 Label 上。需求留意的是,一旦某次网络恳求产生过错,整个订阅会被完毕掉,后续新的通知并不会被转化为恳求。

cancellable=NotificationCenter.default
.publisher(for:UserCenter.userStateChanged)
.flatMap{valuein
returnrequestingAPI().materialize()
}
.sink{textin
titleLabel.text=text
}

处理这个问题的办法有许多,上面运用materialize将事情从Publisher<Output, MyError>转换为Publisher<Event<Output, MyError>, Never>然后避免了过错产生。

Combine 官方并没有完结 materialize ,CombineExt 供给了开源的完结。

Combine In Resso

Resso 在许多场景运用到了 Combine,其间最经典的比如莫过于音效功用中多个特点的获取逻辑。音效需求运用专辑封面,专辑主题色以及歌曲对应的特效装备来驱动音效播放。这三个特点别离需求运用三个网络恳求来获取,假如运用 iOS 中经典的闭包回调来编写这部分逻辑,那嵌套三个闭包,堕入回调地狱,更别提其间的过错分支很有或许遗失。

funcstartEffectNormal(){
//1.获取歌曲封面
WebImageManager.shared.requestImage(trackCoverURL){resultin
switchresult{
case.success(letimage):
//2.获取特效装备
fetchVisualEffectConfig(for:trackID){resultin
switchresult{
case.success(letpath):
//3.获取封面主题色
fetchAlbumColor(trackID:trackID){resultin
switchresult{
case.success(letalbumColor):
self.startEffect(coverImage:coverImage,effectConfig:effectConfig,coverColor:coverColor)
case.failure:
//处理获取封面颜色过错
break
}
}
case.failure(leterror):
//处理获取特效装备过错
break
}
}
case.failure(leterror):
//处理下载图片过错
break
}
}
}

运用 Combine,咱们能够把三个恳求封装成单独的Publisher,再经过combineLatest将三个成果合并在一起进行运用:

funcstartEffect(){
//获取歌曲封面的Publisher
cancellable=fetchTrackCoverImagePublisher(for:trackCoverURL)
//并与获取特效装备的Publisher和获取专辑主题色的Publisher中的最新成果组成新的Publisher
.combineLatest(fetchVisualEffectPathPublisher(for:trackID),fetchAlbumColorPublisher(trackID:trackID))
//对终究的成果进行运用
.sink{completionin
ifcase.failure(leterror)=completion{
//对过错进行处理
}
}receiveValue:{(coverImage,effectConfig,coverColor)in
self.startEffect(coverImage:coverImage,effectConfig:effectConfig,coverColor:coverColor)
}
}

这样的完结办法带来了许多好处:

  1. 代码结构更紧凑,可读性更好
  2. 过错处理更集中,不易遗失
  3. 可保护性更好,后续假如需求新的恳求,只需继续 combine 新的 Publisher 即可

此外,Resso 也对自己的网络库完结了 Combine 拓宽,方便更多的同学开端运用 Combine:

funcfetchSomeResource()->RestfulClient<SomeResponse>.DataTaskPublisher{
letrequest=SomeRequest()
returnRestfulClient<SomeResponse>(request:request)
.dataTaskPublisher
}

总结

一言以蔽之,呼应式编程的中心在于用声明的办法呼应未来产生的事情流。 在日常的开发中,合理地运用呼应式编程能够大幅简化代码逻辑,但在不适宜的场景(乃至是一切场景)滥用则会让同事 。常见的多重嵌套回调、自界说的通知都是十分合适切入运用的场景。

Combine 是呼应式编程的一种详细完结,体系原生内置与优异的完结让它相较于其他呼应式结构有着诸多的优势,学习并掌握 Combine 是实践呼应式编程的绝佳途径,对日常开发也有诸多毗益。

参加咱们

咱们是字节跳动世界音乐团队,担任 Resso 等多块世界音乐业务!团队致力于运用更先进的言语(Swift & Kotlin)与更先进的技能, 带给用户流通的播放体会,极致的交互呼应。在这里能够吸收到点播,直播,码率等多个维度的技能养分,了解怎么经过预加载、码率自适应、动态水位等手法继续优化中心目标,一起推进稳定性管理、客户端容灾、包大小、UI 流通度、低端机等专项,继续打磨根底体会,保障 App 稳定运转。

假如你也是音乐发烧友,假如你对全球音乐行业开展/音乐人生态/音乐产品演化/音乐创作感兴趣,假如你的日子里除了 0 和 1,更有对“哆来咪发嗦拉西”的酷爱,音乐团队,等你火速上车:jobs.bytedance.com/referral/pc…

参考

  • www.vadimbulavin.com/debugging-w…
  • objccn.io/products/sw…