本文首發於 Ficow Shen's Blog,原文地址: Combine 框架,從0到1 —— 3.使用 Subscriber 控制釋出速度。
內容概覽
- 前言
- 在釋出者生產元素時消耗它們
- 使用自定義的訂閱者施加背壓(back pressure)
- 使用背壓操作符管理無限需求(Unlimited Demand)
- 總結
前言
對於大多數響應式程式設計場景而言,訂閱者不需要對釋出過程進行過多的控制。當釋出者釋出元素時,訂閱者只需要無條件地接收即可。但是,如果釋出者釋出的速度過快,而訂閱者接收的速度又太慢
,我們該怎麼解決這個問題呢?Combine
已經為我們制定了穩健的解決方案!現在,讓我們來了解如何施加背壓(back pressure,也可以叫反壓)以精確控制釋出者何時生成元素
。
在 Combine
中,釋出者生成元素,而訂閱者對其接收的元素進行操作。不過,釋出者會在訂閱者連線和獲取元素時才傳送元素。訂閱者通過 Subscribers.Demand
型別來表明自己可以接收多少個元素,以此來控制釋出者傳送元素的速率。
訂閱者可以通過兩種方式來表明需求(Demand
):
- 呼叫
Subscription
例項(由釋出者在訂閱者進行第一次訂閱時提供)的request(_:)
方法; - 在釋出者呼叫訂閱者的
receive(_:)
方法來傳送元素時,返回一個新的Subscribers.Demand
例項;
Demand
是可以累加的。如果訂閱者已經請求了兩個元素,然後請求 Subscribers.Demand(.max(3))
,則現在釋出者不滿足的需求是五個元素。如果釋出者隨後傳送元素,則未滿足的需求將減少到四個。
釋出元素是減少未滿足需求的數量的唯一方法,訂閱者不能請求負需求。
很多應用會使用 sink(receiveValue:)
和 assign(to:on:)
來建立便捷的訂閱者型別,分別為:Subscribers.Sink
和 Subscribers.Assign
。這兩種訂閱者在第一次連線到釋出者時,會傳送一個 unlimited
的 Demand
,這時候訂閱者會一直不停地接收發布者發來的內容。
在釋出者生產元素時消耗它們
當釋出者的需求很高或不受限制時,它傳送元素的速度可能比訂閱者處理元素的速度快很多。這種情況可能導致元素丟失,或者在元素等待被快取時迅速增加記憶體的壓力。
如果您使用便捷的訂閱者,則會發生這種情況,因為它們的需求(Demand
) 是無限數量 (unlimited
) 的元素。確保您提供給 sink(receiveValue:)
的閉包和 assign(to:on:)
的副作用(執行效果)遵循以下特徵:
- 不會阻塞釋出者;
- 不會因為快取元素而消耗過多的記憶體;
- 不會不知所措並且不能處理元素;
慶幸的是,許多常用的釋出者(例如與使用者介面元素相關聯的釋出者)都會以可控的速度進行釋出。其他常見的釋出者僅僅生成一個元素,例如:URL
載入系統的 URLSession.DataTaskPublisher
。配合這些釋出者,使用 sink(receiveValue:)
和 assign(to:on:)
訂閱者是絕對安全的。
使用自定義的訂閱者施加背壓(back pressure)
想要控制釋出者向訂閱者傳送元素的速率,可以建立訂閱者協議的自定義實現。使用你的自定義實現來指定你的訂閱者可以適應的需求。當訂閱者接收元素時,它可以通過返回新的需求值給 receive(_:)
方法,或通過在訂閱上呼叫 request(_:)
來請求更多內容。無論使用哪種方法,你自定義的訂閱者都可以在任何給定時間微調發布者可以傳送的元素數量。
通過發訊號來表明訂閱者已準備好接收元素來控制流量的概念稱為
背壓
。
每個釋出者都跟蹤其當前未滿足的需求,也就是:訂閱者已請求多少個元素。甚至,像 Foundation
框架中的 Timer.TimerPublisher
這樣的自動化資源,也只會在有未滿足的需求時才產生元素。
下面的示例程式碼說明了這個行為:
// 釋出者: 使用一個定時器來每秒傳送一個日期物件
let timerPub = Timer.publish(every: 1, on: .main, in: .default)
.autoconnect()
// 訂閱者: 在訂閱以後,等待5秒,然後請求最多3個值
class MySubscriber: Subscriber {
typealias Input = Date
typealias Failure = Never
var subscription: Subscription?
func receive(subscription: Subscription) {
print("published received")
self.subscription = subscription
DispatchQueue.main.asyncAfter(deadline: .now() + 5) {
subscription.request(.max(3))
}
}
func receive(_ input: Date) -> Subscribers.Demand {
print("\(input) \(Date())")
return Subscribers.Demand.none
}
func receive(completion: Subscribers.Completion<Never>) {
print ("--done--")
}
}
// 訂閱 timerPub
let mySub = MySubscriber()
print ("Subscribing at \(Date())")
timerPub.subscribe(mySub)
訂閱者的 receive(subscription:)
實現在請求釋出者的任何元素之前執行了五秒鐘的延遲。在此期間,釋出者存在並具有有效的訂閱者,但需求為零,因此不會產生任何元素。它僅在延遲到期且訂閱者給它一個非零需求 subscription.request(.max(3))
之後才開始釋出元素,如以下輸出所示:
Subscribing at 2019-12-09 18:57:06 +0000
published received
2019-12-09 18:57:11 +0000 2019-12-09 18:57:11 +0000
2019-12-09 18:57:12 +0000 2019-12-09 18:57:12 +0000
2019-12-09 18:57:13 +0000 2019-12-09 18:57:13 +0000
這個示例只請求了三個元素,在五秒鐘的延遲到期後發出需求。最後,釋出者在第三個元素之後不再傳送其他元素,但是也不會通過傳送完成(.finished
) 的值來完成釋出,因為釋出者只是在等待更多需求。為了繼續接收元素,訂閱者可以儲存訂閱並定期請求更多元素。它還可以在 receive(_:)
方法中返回新需求的值。
使用背壓操作符管理無限需求(Unlimited Demand)
即使沒有自定義的訂閱者,你也可以通過一些操作符來實施背壓:
buffer(size:prefetch:whenFull:)
,保留來自上游釋出者的固定數量的專案。緩衝滿了之後,緩衝區會丟棄元素或丟擲錯誤;debounce(for:scheduler:options:)
,只在上游釋出者在指定的時間間隔內停止釋出時才釋出;throttle(for:scheduler:latest:)
,以給定的最大速率生成元素。如果在一個間隔內接收到多個元素,則僅傳送最新的或最早的元素;collect(_:)
和collect(_:options:)
聚集元素,直到它們超過給定的數量或時間間隔,然後向訂閱者傳送元素陣列。如果訂閱者可以同時處理多個元素,這個操作符將是很好的選擇。
由於這些操作符可以控制訂閱者接收的元素數量,因此可以放心地連線無限需求的訂閱者,例如:sink(receiveValue:)
和 assign(to:on:)
。
總結
通過實施背壓,我們可以靈活地調控釋出過程。背壓操作符可以幫助我們應對大多數場景,這些操作符可以大幅提升我們的開發效率。
比如這種常見的場景:當搜尋輸入框的內容發生變動時,應用需要去查詢使用者輸入內容對應的結果,但是這個查詢操作的頻率需要有一定的控制。如果使用者按住一個鍵不放開,輸入框的內容就會一直變化,此時就會觸發多次查詢操作。這時候,我們可以從容地使用背壓操作符解決這種問題。
如果你需要處理的場景非常複雜,通過自定義訂閱者來實施精確的背壓
將會是一個更好的選擇。
本文內容來源: Processing Published Elements with Subscribers,轉載請註明出處。