Day 24:背壓是什麼,生產太快時該怎麼踩煞車 結尾提到,本階段用來處理協程資料流的核心工具已經全部到齊,這幾樣工具彼此之間的關係,值得回頭整理一次。
今天要做的,就是把這些工具放進同一個情境裡走一次。
從 Day 19:從回傳一個值到回傳一串值,Flow 是什麼 到 Day 24:背壓是什麼,生產太快時該怎麼踩煞車,陸續定案了 Flow、操作符、StateFlow 與 SharedFlow、Channel、Flow 與 Channel 的底層關係、背壓,表面上是六個不同的主題,六天分別學了六件事。但與其說是六個各自獨立的主題,不如說它們其實一直圍繞著 Day 18:小結:協程的執行環境與例外治理工具箱總覽 結尾拋出的同一個問題:執行一次拿到一個結果,只是任務可能出現的其中一種形式,真正還沒被回答的是,當一個任務需要隨著時間持續、依序回報多個值時,該怎麼優雅處理。
那個問題在 Day 18 結尾只是被立起來,具體怎麼設計、怎麼使用留到下一篇才展開。走到今天回頭看,這個命題已經在整個階段四裡被完整回答過一輪了。
今天不會出現任何新的英文名詞,也不會定案任何新規則。唯一的任務,是把這六天累積下來的內容放進同一個情境裡完整走一次,讓讀者看清楚這些機制彼此如何搭配,並且跟 Day 18 已經整合過的階段三內容銜接起來。
進入整合情境之前,先用最精簡的方式把六個角色的定位排一次隊,不重新展開任何一項的完整定義,只確認分工。讀者若對細節生疏,回頭參照對應天數即可,今天不重複教學。
Flow 負責把一段會隨時間持續產生多個值的任務,包裝成一個可以依序發送、可以被收集、可以被取消的資料流型別,回答「任務要怎麼持續回報多個值」,這是 Day 19:從回傳一個值到回傳一串值,Flow 是什麼 定案的錨點。
操作符負責用宣告式的方式,把 Flow 發送出來的值組合成一套資料處理管線,回答「拿到這些值之後怎麼加工」,這是 Day 20:Flow 的操作符,map、filter、collect 這些常用招式 定案的錨點。
StateFlow 與 SharedFlow 負責在冷流不夠用的情境提供熱流能力,StateFlow 回答「現在的狀態是什麼」,SharedFlow 回答「剛剛發生了什麼事,要廣播給多個訂閱者」,這是 Day 21:StateFlow 與 SharedFlow,冷流不夠用的時候 定案的錨點。
Channel 負責在協程之間建立點對點的資料傳遞管道,回答「一個協程怎麼把資料一筆一筆接力遞交給另一個協程」,這是 Day 22:Channel,協程之間怎麼傳話 定案的錨點。
Flow 與 Channel 的底層關係負責說明兩者並非互不相干,某些建立 Flow 的方式底層正是透過類似 Channel 的機制運作,回答「Flow 依序處理值的能力是從哪裡繼承來的」,這是 Day 23:Flow 底層其實是 Channel,兩者的關係是什麼 定案的內容。
背壓負責在生產速度超過消費速度時,提供從最基本的暫停等待,到更積極的緩衝、只保留最新、節流等因應策略,回答「生產太快的時候該怎麼踩煞車」,這是 Day 24:背壓是什麼,生產太快時該怎麼踩煞車 定案的錨點。
六個角色排完隊,接下來把它們放進同一個情境裡看看實際怎麼搭配。
延續一路使用的多來源圖片下載與聚合工具,今天把情境再加厚一層,同時容納六件事:多個來源平行下載,各自把下載進度包裝成 Flow 持續回報;畫面訂閱一個 StateFlow,隨時顯示目前整體進度;某來源下載失敗時,透過 SharedFlow 廣播失敗事件給畫面上多個元件;下載完成的圖片資料,透過 Channel 接力遞交給另一個負責後續處理的協程;過程中進度更新頻率極高,套用只保留最新的背壓策略避免畫面處理跟不上。
任務啟動時,第一個上場的是 Flow。三個來源各自的下載函式,不再只是等到完成才回傳一次結果,而是依 Day 19 的設計,包裝成一個持續發送目前百分比的 Flow:
fun downloadProgress(url: String): Flow<Int> = flow {
// 依序 emit 目前下載到的百分比
}
每個來源的 Flow 一旦被收集,就會依序送出進度數值,而且這個收集動作發生在某個協程裡,跟 Day 19 定案的結構化並發框架完全一致,收集所在的協程被取消,這個 Flow 也會跟著停止。
第二個上場的是 StateFlow。畫面需要隨時顯示「目前整體下載進度」,不管使用者是一開始就在看,還是中途才切換過來,都要立刻看到當下正確的數值。這正是 Day 21 定案的心智模型,一份隨時可查、跟訂閱時機無關的最新狀態:
private val _overallProgress = MutableStateFlow(0)
val overallProgress: StateFlow<Int> = _overallProgress
每個來源的下載進度 Flow 被收集之後,透過操作符加工成整體進度,再更新到 _overallProgress 上。這裡操作符扮演的正是 Day 20 定案的角色,map 把原始百分比轉換成適合呈現的格式,filter 或後面會提到的背壓策略過濾掉不需要逐一處理的中間值,收集端拿到的已經是加工過的值,不需要在收集邏輯裡混雜一堆判斷。
如果某個來源逾時失敗,這個事件屬於「剛剛發生的一件事」,稱不上是「目前的狀態」,需要廣播給進度條與錯誤提示這兩個畫面元件同時知道。第三個上場的角色是 SharedFlow:
private val _errorEvents = MutableSharedFlow<String>()
val errorEvents: SharedFlow<String> = _errorEvents
這正是 Day 21 對照過的差異,StateFlow 持有一份任何時間點訂閱都拿得到的最新值,SharedFlow 廣播一次跟時機綁定的事件,錯過就是錯過了。整體進度用 StateFlow,失敗通知用 SharedFlow,兩者在同一個工具裡各自負責不同性質的資料,沒有互相取代的關係。
下載完成的圖片資料,接下來要交給另一個專門負責壓縮或儲存的協程處理。這批資料算不上狀態,也算不上要廣播給多方的事件,比較貼切的說法是一筆一筆接力遞交的動作,第四個角色 Channel 該上場了:
val imageChannel = Channel<ByteArray>()
launch {
val imageData = downloadImage(url)
imageChannel.send(imageData)
}
launch {
for (imageData in imageChannel) {
processImage(imageData)
}
}
這正是 Day 22 定案的點對點遞交模式,負責下載的協程專心把圖片送進 Channel,負責處理的協程專心從 Channel 取出來處理,兩者不需要互相呼叫,也不需要互相等待。而 Channel 的發送與接收動作,之所以能在容量不足或資料未到時自然暫停而不阻塞執行緒,正是 Day 23 釐清的那層底層關係在背後撐著,某些建立 Flow 的方式,其實就是靠類似 Channel 這樣的機制完成暫停發送與依序接收。
最後一塊拼圖是背壓。五、六個來源同時以極高頻率回報進度,如果每一筆都得完整跑過收集邏輯才能繼續,畫面處理速度根本跟不上。這裡不需要每一筆進度都被處理到,使用者在意的只是最終看到的數字夠不夠新,中間跳過幾筆完全無傷大雅。這正是 Day 24 定案的只保留最新策略,套用在餵給 StateFlow 的那條進度管線上:
downloadProgress(url)
.conflate()
.collect { percent -> _overallProgress.value = percent }
至於圖片資料本身透過 Channel 遞交的那一段,性質完全不同,每一筆下載結果都攸關正確性,一張圖片下載成功還是失敗,不應該被任何背壓策略捨棄,所以這段不套用只保留最新這類會捨棄資料的策略,維持逐筆送進 Channel、逐筆處理的樣子。同一個工具裡,進度數值可以容忍取捨,圖片結果不能,這正呼應了 Day 24 談過的判斷方向,選哪一種策略取決於資料本身的性質,不是整個系統套用同一套規則。
走完這整段流程,回頭看看 Day 18 已經整合過的階段三內容。這些下載子協程,依然活在某個 Coroutine Scope 劃出的明確邊界裡,跟父範圍之間依然是清楚的父子收斂關係;哪個協程跑在 Dispatchers.IO,失敗要不要向上波及其他來源,沒被處理的例外最終去了哪裡,這些階段三建立的判斷依然完整適用,並沒有因為換了資料流的寫法就失效。差別在於,現在這套執行環境與例外治理的關係之上,又疊加了一層新的能力:如何持續產生、傳遞、控制多個值。三個階段的整合內容,是同一套心智模型的不同層次,階段二回答協程怎麼被組織起來,階段三回答協程實際在什麼環境下執行、失敗時怎麼被治理,階段四回答協程如何持續產生一連串的值,並且優雅地在多個角色之間傳遞與調節,而不是各自獨立的三份清單。
把六天的內容走完一遍情境之後,可以收斂成一組設計協程資料流時隨手可以自問的檢核方向。
這個任務的本質是一次性的資料處理流程,還是需要持續被查詢的狀態,還是需要廣播給多個訂閱者的事件,還是協程之間點對點的接力傳遞。前者對應冷流搭配操作符組成的管線,狀態查詢對應 StateFlow,事件廣播對應 SharedFlow,點對點接力對應 Channel。如果生產與消費的速度可能不對等,這批資料能不能容忍中間值被捨棄,還是每一筆都攸關正確性,能容忍就考慮只保留最新或節流,不能容忍就傾向暫停等待或緩衝,寧可讓生產端偶爾等一下,也不要漏掉該處理的資料。
這份工具箱的重點不在於死記一份規則清單,而在於每次設計涉及協程資料流的程式碼時,可以回頭套用的思考順序。選用哪一項工具,取決於資料本身的性質與情境需求,跟哪一個工具比較新、比較潮沒有關係。多來源圖片下載與聚合工具這個情境走了一整個階段,最終呈現的樣子,比起把六樣工具全部塞進同一段程式碼裡炫技,更重要的是依照每一份資料各自的性質,挑出對應的工具去處理它。
回頭檢視系列至今驗證程式碼是否正確的方式,其實一直停留在同一個層次:把程式碼跑起來,看終端機印出的訊息像不像預期,或者看畫面上的進度條、錯誤提示反應得對不對。這種驗證方式相對主觀,也很難重複做出一模一樣的結果。
協程程式碼牽涉暫停、取消、時間差、多個協程交錯執行,今天整合的這套資料流工具箱又疊加了發送順序、訂閱時機、背壓策略這幾層變數。某個來源的失敗事件有沒有準時被 SharedFlow 廣播出去,Channel 裡的資料有沒有依照正確順序被接收端取走,只保留最新的策略在極端情況下會不會不小心漏掉一筆本來該被處理的關鍵資料,這些問題光靠人眼閱讀程式碼,或是跑一次程式看看輸出結果像不像預期,很難有十足把握說每一種情境都正確無誤。尤其是那些需要真實等待很久才會發生的情境,例如逾時多久才觸發、延遲多久才重試,如果驗證方式還是「跑一次、盯著畫面看」,光是想確認一個逾時邏輯有沒有寫對,可能就得真的等上好幾秒甚至好幾分鐘。
這個問題目前為止完全沒有被系列正面處理過。如果想確定一段協程程式碼在各種時間差、取消時機下的行為都符合預期,而且不想每次驗證都得真的等上老半天,該怎麼做?目前為止學過的所有工具,Flow 也好、Channel 也好、背壓策略也好,都沒有一個是為了系統化驗證協程行為而設計的,它們解決的是協程程式碼該怎麼寫,還沒有回答協程程式碼寫得對不對該怎麼被確認。
這正是接下來要進入的新命題,協程行為該如何被系統化驗證,而不是只靠肉眼觀察與主觀判斷。下一篇會正式帶系列走進這個方向,開始建立為協程撰寫測試的方法。