作者 | ?何星
大約一年前,Resso 接入了 Combine,利用響應式編程簡化了代碼邏輯,也積累了很多實踐經驗。本文會從響應式編程的基本思想并逐步深入介紹 Combine 的概念與最佳實踐, 希望能幫助更多的同學順利上手并實踐響應式編程,少踩坑。
等等,Resso 是什么?Resso 來源于 Resonate(共鳴),是字節跳動推出的一個社交音樂流媒體平臺,專為下一代音樂發燒友設計,使他們能夠通過對音樂的熱愛來表達和與他人建立聯系。
書回正文,所謂的響應式編程到底是什么呢?
熟悉 Combine 的同學可以直接跳到實踐建議部分。?
響應式編程
維基百科對響應式編程的定義是:
- 在計算中,響應式編程是一種面向數據流和變化傳播的聲明式編程范式。
雖然定義中每個字都認識,但連起來卻十分費解。我們可以把定義中的內容分開來理解,逐個擊破。首先,讓我們來看下聲明式編程。
聲明式編程
聲明式和指令式編程是常見的編程范式。在指令式編程中,開發者通過組合運算、循環、條件等語句讓計算機執行程序。聲明式與指令式正相反,如果說指令式像是告訴計算機 How to do,而聲明式則是告訴計算機 What to do。其實大家都接觸過聲明式編程,但在編碼時并不會意識到。各類 DSL 和函數式編程都屬于聲明式編程的范疇。
舉個例子,假設我們想要獲取一個整形數組里的所有奇數。按照指令式的邏輯,我們需要把過程拆解為一步一步的語句:
- 遍歷數組中的所有元素。
- 判斷是否為奇數。
- 如果是的話,加入到結果中。繼續遍歷。
var results = [Int]()
for num in values {
if num %2 != 0 {
results.append(num)
}
}
如果按聲明式編程來,我們的想法可能是“過濾出所有奇數”,對應的代碼就十分直觀:
var results = values.filter { $0 % 2 != 0 }
可見上述兩種編程方式有著明顯的區別:
- 指令式編程:描述過程(How),計算機直接執行并得結果。
- 聲明式編程:描述結果(What),讓計算機為我們組織出具體過程,最后得到被描述的結果。
“面向數據流和變化傳播”
用說人話的方式解釋,面向數據流和變化傳播是響應未來發生的事件流。
- 事件發布:某個操作發布了事件A?,事件A? 可以攜帶一個可選的數據B 。
- 操作變形:事件A? 與數據B? 經過一個或多個的操作發生了變化,最終得到事件A'? 與數據B'。
- 訂閱使用:在消費端,有一個或多個訂閱者來消費處理后的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% 多的性能提升。
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 { value in
print(value)
}
Combine 接口
上文提到,Combine 的接口是基于 Reactive Streams Spec 實現的,Reactive Streams 中已經定義好了 Publisher?, Subscriber,Subscription 等概念,Apple 在其上有一些微調。
具體到接口層面,Combine API 與 RxSwift API 比較類似,更精簡,熟悉 RxSwift 的開發者能無縫快速上手 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?, Operator? 與 Subscriber。
在簡化的模型中,首先有一個 Publisher?,經過 Operater? 變換后被 Subscriber?消費。而在實際編碼中, Operator? 的來源可能是復數個 Publisher,Operator? 也可能會被多個 Publisher 訂閱,通常會形成一個非常復雜的圖。
Publisher
Publisher<Output, Failure: Error>
Publisher? 是事件產生的源頭。事件是 Combine 中非常重要的概念,可以分成兩類,一類攜帶了值(Value?),另外一類標志了結束(Completion?)。結束的可以是正常完成(Finished?)或失敗(Failure)。
Events:
- Value:Output
- Completion
- Finished
- Failure(Error)
通常情況下, 一個 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: { resultString in
self.textView.text = resultString
}
此外,還有一些特殊的 Publisher 也十分有用:
- Future:只會產生一個事件,要么成功要么失敗,適用于大部分簡單回調場景
- Just?:對值的簡單封裝,如Just(1)
- @Published?:下文會詳細介紹 在大部分情況下,使用這些特殊的Publisher? 以及下文介紹的Subject 可以靈活組合出滿足需要的事件源。極少的情況下,需要實現自定義的 Publisher ,可以看這篇文章。
Subscriber
Subscriber<Input, Failure: Error>
Subsriber? 作為事件的訂閱端,它的定義與 Publisher? 對應,Publisher? 中的 Output?對應Subscriber? 的 Input?。常用的 Subscriber? 有 Sink? 和 Assign。
Sink? 直接對事件流進行訂閱使用,可以對 Value? 和 completion 分別進行處理。
Sink 這個單詞在初次看到會令人非常費解。這個術語可來源于網絡流中的匯點(Sink),我們也可以理解為 The stream goes down the sink。
// 從數組生成一個 Publisher
cancellable = [1, 2, 3, 4, 5].publisher
.sink { completion in
// 處理事件流結束
} receiveValue: { value in
// 打印會每個值,會依次打印出 1, 2, 3, 4, 5
print(value)
}
Assign? 是一個特化版的 Sink? ,支持通過 KeyPath 直接進行賦值。
let textLabel = UILabel()
cancellable = [1, 2, 3].publisher
// 將 數字 轉換為 字符串,并忽略掉 nil ,下面會詳細介紹這個 Operator
.compactMap { String($0) }
.assign(to: \.text, on: textLabel)
需要留意的是,如果用 assign? 對 self? 進行賦值,可能會形成隱式的循環引用,這種情況需要改用 sink? 與 weak self 手動進行賦值。
Cancellable & AnyCancellable
細心的讀者可能發現了上面出現了一個 cancellable?。每一個訂閱都會生成一個 AnyCancellable 對象,用于控制訂閱的生命周期。通過這個對象,我們可以取消訂閱。當這個對象被釋放時,訂閱也會被取消。
// 取消訂閱
cancellable.cancel()
需要注意的是,每一個訂閱我們都需要持有這個 cancellable,否則整個訂閱會立即被取消并結束掉。
Subscription
Publisher? 和 Subscriber? 之間是通過 Subscription 建立連接。理解整個訂閱過程對后續深入使用 Combine 非常有幫助。
圖片來自《SwiftUI 和 Combine 編程》
Combine 的訂閱過程其實是一個拉取模型。
- Subscriber? 發起一個訂閱,告訴Publisher 我需要一個訂閱。
- Publisher? 返回一個訂閱實體(Subscription)。
- Subscriber? 通過這個Subscription? 去請求固定數量(Demand)的數據。
- Publisher? 根據Demand? 返回事件。單次的Demand? 發布完成后,如果Subscriber?繼續請求事件,Publisher 會繼續發布。
- 繼續發布流程。
- 當Subscriber? 請求的事件全部發布完成后,Publisher? 會發送一個Completion。
Subject
Subject<Output, Failure: Error>
Subject? 是一類特殊的 Publisher?,我們可以通過方法調用(如 send())手動向事件流中注入新的事件。
private let isPlayingPodcastSubject = CurrentValueSubject<Bool, Never>(false)
// 向 isPlayingPodcastPublisher 注入一個新的事件,它的值是 true
isPlayingPodcastSubject.send(true)
Combine 提供了兩個常用的 Subject:PassthroughSubject? 與 CurrentValueSubject。
- PassthroughSubject?:透傳事件,不會持有最新的Output
- CurrentValueSubject?:除了傳遞事件之外,會持有最新的Output
@Published
對于剛接觸 Combine 的同學來說,最困擾的問題莫過于難以找到可以直接使用的事件源。Combine 提供了一個 Property Wrapper @Pubilshed? 可以快速封裝一個變量得到一個 Publisher。
// 聲明變量
class Alarm {
@Published
public var countDown = 0
}
let alarm = Alarm()
// 訂閱變化
let cancellable = alarm.$countDown // Published<Int>.Publisher
.sink { print($0) }
// 修改 countDown,上面 sink 的閉包會觸發
alarm.countDown += 1
上面比較有趣的是 $countDown? 訪問到的一個 Publisher?,這其實是一個語法糖,$? 訪問到其實是 countDown? 的 projectedValue?,正是對應的 Publisher。
@propertyWrapper public struct Published<Value> {
// ...
/// The property for which this instance exposes a publisher
///
/// The ``Published/projectedValue` is the property accessed with the `$` operator
public var projectedValue: Published<Value>.Publisher { mutating get set }
}
@Published 非常適合在模塊內對事件進行封裝,類型擦除后提供外部進行訂閱消費。
實際實踐中,對于已有的代碼邏輯,使用 @Published? 可以在不改動其他代碼快速讓屬性得到 Publisher 的能力。而新編寫的代碼,如果不會發生錯誤且需要使用到當前的 Value,@Published? 也是很好的選擇,除此之外則需要按需考慮使用 PassthroughSubject? 或 CurrentValueSubject。
Operator
現實編碼中,Publisher? 攜帶的數據類型可能并不滿足我們的需求,這時需要使用 Operator 對數據進行變換。Combine 自帶了非常豐富的 Operator,接下來會針對其中常用的幾個進行介紹。
map, filter, reduce
熟悉函數式編程的同學對這幾個 Operator 應該非常熟悉。它們的作用與在數組上的效果非常相似,只不過這次是在異步的事件流中。
例如,對于 map 來說,他會對每個事件中的值進行變換:
[1, 2, 3].publisher
.map { $0 * 10 }
.sink { value in
// 將會答應出 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 的事件匯聚在一起進行消費。
其他常見的 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,讓我們可以擦除掉具體類型:
// 生成一個類型擦除后的請求。函數的返回值更簡潔
func requestRessoAPI() -> AnyPublisher<String, Error> {
let request = 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>
return request.eraseToAnyPublisher()
}
// 在模塊外,不用關心 `requestRessoAPI()` 返回的具體類型,直接進行消費
cancellable = requestRessoAPI().sink { _ in
} receiveValue: {
print($0)
}
通過類型擦除,最終暴露給外部的是一個簡單的 AnyPublisher<String, Error>。
Debugging
響應式編程寫起來非常的行云流水,但 Debug 起來就相對沒有那么愉快了。對此,Combine 也提供了幾個 Operator 幫助開發者 Debug。
Debug Operator
print 和 handleEvents
print 可以打印出整個訂閱過程從開始到結束的 Subscription 變化與所有值,例如:
cancellable = [1, 2, 3].publisher
.receive(on: DispatchQueue.global())
// 使用 `Array Publisher` 作為所有打印內容的前綴
.print ( "Array Publisher")
.sink { _ in }
可以得到:
Array Publisher: receive subscription: (ReceiveOn)
Array Publisher: request unlimited
Array Publisher: receive cancel
Array Publisher: receive value: (1)
Array Publisher: receive value: (2)
Array Publisher: receive value: (3)
Array Publisher: receive finished
在一些情況下,我們只對所有變化中的部分事件感興趣,這時候可以用 handleEvents? 對部分事件進行打印。類似的還有 breakpoint,可以在事件發生時觸發斷點。
畫圖法
到了萬策盡的地步,用圖像理清思路也是很好的方法。對于單個 Operator,可以在 RxMarble 找到對應 Operator 確認理解是否正確。對于復雜的訂閱,可以畫圖確認事件流的傳遞是否符合預期。
let greetings = PassthroughSubject<String, Never>()
let names = PassthroughSubject<String, Never>()
let years = PassthroughSubject<Int, Never>()
// CombineLatest 會選用兩個事件流中最新的值生成新的事件流
let greetingNames = Publishers.CombineLatest(greetings, names)
.map {"\($1) \($0)" }
let wholeSentence = Publishers.CombineLatest(greetingNames, years)
.map { ")($0), \($1)" }
.sink { print($0) }
greetings.send("Hello")
names.send("Combine")
years.send(2022)
常見錯誤
立即開始的 Just 和 Future
對于大部分的 Publisher?來說,它們在訂閱后才會開始生產事件,但也有一些例外。Just? 和 Future 在初始化完成后會立即執行閉包生產事件,這可能會讓一些耗時長的操作在不符合預期的時機提前開始,也可能會讓第一個訂閱錯過一些太早開始的事件。
func makeMyPublisher () -> AnyPublisher<Int, Never> {
Just(calculateTimeConsumingResult())
.eraseToAnyPublisher()
}
一個可行的解法是在這類 Publisher? 外封裝一層 Defferred,讓它在接收到訂閱之后再開始執行內部的閉包。
func makeMyFuture2( ) -> AnyPublisher<Int, Never> {
Deferred {
return Just(calculateTimeConsumingResult())
}.eraseToAnyPublisher()
}
發生錯誤導致 Subscription 意外結束
func requestingAPI() -> AnyPublisher<String, Error> {
return URLSession.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
return requestingAPI()
})
.sink { completion in
} receiveValue: { value in
textLabel.text = value
}
上面的代碼中將用戶狀態的通知轉化成了一個網絡請求,并將請求結果更新到一個 Label 上。需要留意的是,一旦某次網絡請求發生錯誤,整個訂閱會被結束掉,后續新的通知并不會被轉化為請求。
cancellable = NotificationCenter.default
.publisher(for: UserCenter.userStateChanged)
.flatMap { value in
return requestingAPI().materialize()
}
.sink { text in
titleLabel.text = text
}
解決這個問題的方式有很多,上面使用 materialize? 將事件從 Publisher<Output, MyError>? 轉換為 Publisher<Event<Output, MyError>, Never> 從而避免了錯誤發生。
Combine 官方并沒有實現 materialize ,CombineExt 提供了開源的實現。
Combine In Resso
Resso 在很多場景使用到了 Combine,其中最經典的例子莫過于音效功能中多個屬性的獲取邏輯。音效需要使用專輯封面,專輯主題色以及歌曲對應的特效配置來驅動音效播放。這三個屬性分別需要使用三個網絡請求來獲取,如果使用 iOS 中經典的閉包回調來編寫這部分邏輯,那嵌套三個閉包,陷入回調地獄,更別提其中的錯誤分支很有可能遺漏。
func startEffectNormal() {
// 1. 獲取歌曲封面
WebImageManager.shared.requestImage(trackCoverURL) { result in
switch result {
case .success(let image):
// 2. 獲取特效配置
fetchVisualEffectConfig(for: trackID) { result in
switch result {
case .success(let path):
// 3. 獲取封面主題色
fetchAlbumColor(trackID: trackID) { result in
switch result {
case .success(let albumColor):
self.startEffect(coverImage: coverImage, effectConfig: effectConfig, coverColor: coverColor)
case .failure:
// 處理獲取封面顏色錯誤
break
}
}
case .failure(let error):
// 處理獲取特效配置錯誤
break
}
}
case .failure(let error):
// 處理下載圖片錯誤
break
}
}
}
使用 Combine,我們可以把三個請求封裝成單獨的 Publisher?,再通過 combineLatest 將三個結果合并在一起進行使用:
func startEffect() {
// 獲取歌曲封面的 Publisher
cancellable = fetchTrackCoverImagePublisher(for: trackCoverURL)
// 并與 獲取特效配置的 Publisher 和 獲取專輯主題色的 Publisher 中的最新結果組成新的 Publisher
.combineLatest(fetchVisualEffectPathPublisher(for: trackID), fetchAlbumColorPublisher(trackID: trackID))
// 對最終的結果進行使用
.sink { completion in
if case .failure(let error) = completion {
// 對錯誤進行處理
}
} receiveValue: { (coverImage, effectConfig, coverColor) in
self.startEffect(coverImage: coverImage, effectConfig: effectConfig, coverColor: coverColor)
}
}
這樣的實現方式帶來了很多好處:
- 代碼結構更緊湊,可讀性更好
- 錯誤處理更集中,不易遺漏
- 可維護性更好,后續如果需要新的請求,只需繼續 combine 新的 Publisher 即可
此外,Resso 也對自己的網絡庫實現了 Combine 拓展,方便更多的同學開始使用 Combine:
func fetchSomeResource() -> RestfulClient<SomeResponse>.DataTaskPublisher{
let request = SomeRequest()
return RestfulClient<SomeResponse>(request: request)
.dataTaskPublisher
}
總結
一言以蔽之,響應式編程的核心在于用聲明的方式響應未來發生的事件流。在日常的開發中,合理地使用響應式編程可以大幅簡化代碼邏輯,但在不適宜的場景(甚至是所有場景)濫用則會讓同事 ??。常見的多重嵌套回調、自定義的通知都是非常適合切入使用的場景。
Combine 是響應式編程的一種具體實現,系統原生內置與優秀的實現讓它相較于其他響應式框架有著諸多的優勢,學習并掌握 Combine 是實踐響應式編程的絕佳途徑,對日常開發也有諸多毗益。