Day 19:從回傳一個值到回傳一串值,Flow 是什麼 停在一個問題上,拿到一串進度數值,如果需要過濾掉不需要的中間值,或者轉換成另一種格式才呈現給使用者,難道每個值都得自己在 collect 的 lambda 裡手動判斷跟改寫嗎?今天要正面回答這個問題。
Day 19 建立的 downloadProgress 範例,只示範了最原始的收集方式,flow 區塊送出什麼數值,collect 就原封不動拿到什麼數值。一旦需求變成要過濾中間值,或是把 10、30、70 這種原始比例轉換成適合呈現的格式,如果全部塞進 collect 的 lambda 裡處理,程式碼很快就會混雜一堆條件判斷與轉換邏輯,可讀性直線下降。多來源圖片下載與聚合工具這個情境尤其明顯,畫面上同時掛著好幾個來源的進度條,每個來源各自持續回報數值,收集端如果還要在同一個 lambda 裡判斷「這個值要不要顯示」「這個值要轉換成什麼格式」,程式碼很快就會糾纏成一團,維護起來也容易漏改某個分支。
Flow 提供一整套操作符,可以在值被收集之前,先對這些值做轉換、篩選等處理。把這些操作符串接起來,就能組合成一套資料處理管線,讓收集端拿到的已經是加工過、可以直接使用的值,收集端只需要專心處理「拿到值之後要做什麼」,不需要再插手「這個值該怎麼被加工」。今天只聚焦其中最常用的三個:map、filter、collect,這三個操作符已經足夠處理大多數的日常情境,其餘操作符留待之後有需要時再個別認識。
map 操作符會對 Flow 發送的每一個值,套用同一個轉換規則,產生一個新的值,組合出一個新的 Flow。原本的 Flow 本身不受影響,map 回傳的是一個全新的 Flow,內部包了一層轉換邏輯,這一點跟 Kotlin 集合上的 map 語意類似,一個 List<Int> 呼叫 map 之後會得到一個全新的 List<String>,原本的清單同樣不會被改動。差別在於,集合的 map 是把整份清單一次轉換完畢,Flow 的 map 則是等值真正被發送出來的那一刻,才對那個值套用轉換規則,這正是建立在 Day 19 定案的冷流與依序發送特性之上。
回到多來源圖片下載與聚合工具,downloadProgress 依序發送的是像 10、30、70 這樣的原始比例數值,這種數字對收集端來說,還得自己額外拼接文字才能呈現。如果要把這些數值轉換成「百分之多少」這種更適合呈現給使用者的字串格式,用 map 就能做到:
fun downloadProgressText(): Flow<String> =
downloadProgress().map { percent -> "目前進度:$percent%" }
downloadProgress() 回傳的 Flow<Int>,經過 map 之後,變成了 Flow<String>。轉換規則寫在 map 的 lambda 裡,跟收集端的邏輯完全分開,收集端不需要再關心「原始數值長什麼樣子」這件事,只需要把 downloadProgressText() 收集下來,拿到的就已經是可以直接顯示在畫面上的字串。如果日後轉換規則要改,例如改成同時顯示來源名稱與百分比,也只需要調整 map 內部的這一行邏輯,收集端的程式碼完全不需要跟著更動。
filter 操作符會依照一個判斷條件,只讓符合條件的值繼續往下傳遞。不符合條件的值會被直接捨棄,不會出現在後續收集到的結果裡,這個判斷條件是一個回傳布林值的 lambda,回傳 true 的值才會被留下。
多來源圖片下載與聚合工具中,如果進度數值更新得非常頻繁,畫面呈現其實只需要看到明顯的進度變化,不需要每一個微小的數值波動都觸發一次畫面更新。試想同時有五個來源在下載,每個來源都各自每隔幾十毫秒就回報一次進度,如果每個數值都直接拿去更新畫面,使用者看到的會是進度條不斷閃爍跳動,反而比穩定的階段式更新更難閱讀。這時候可以用 filter 過濾掉變化幅度太小、不值得更新畫面的中間值,只保留有意義的進度節點:
fun significantProgress(): Flow<Int> =
downloadProgress().filter { percent -> percent % 10 == 0 }
這個範例只保留能被 10 整除的進度值,模擬「只在整數關卡才通知畫面更新」的情境。實際的判斷條件會依照畫面更新的需求調整,例如換成「與上一次通知的數值相差超過某個門檻」這種更貼近真實情境的邏輯,這裡只是示範 filter 的基本用法。跟 map 一樣,filter 回傳的也是一個全新的 Flow,原本的 downloadProgress() 不會因為套用了 filter 而改變自身的行為,任何其他收集端如果直接收集原本的 Flow,依然會拿到完整未經篩選的數值序列。
map 與 filter 各自看起來都不複雜,真正的威力在於它們可以串接在一起使用:
suspend fun showFilteredProgress() {
downloadProgress()
.filter { percent -> percent % 10 == 0 }
.map { percent -> "目前進度:$percent%" }
.collect { text -> println(text) }
}
這段程式碼讀起來就像是把資料處理的每個步驟依序列出來,先過濾掉不需要的中間值,再把留下來的值轉換成呈現用的格式,最後才收集下來印出。先做什麼、再做什麼,一目瞭然。這正是操作符組合帶來的可讀性優勢。
對照如果不用操作符,全部邏輯都寫在 collect 的 lambda 裡會是什麼樣子:
suspend fun showFilteredProgressWithoutOperators() {
downloadProgress().collect { percent ->
if (percent % 10 == 0) {
val text = "目前進度:$percent%"
println(text)
}
}
}
兩段程式碼做的事情完全一樣,但後者把「要不要更新畫面」的判斷跟「怎麼格式化文字」的邏輯全部糾纏在同一層縮排裡,日後如果還要再加一道處理步驟,例如再加一層轉換或再加一個篩選條件,這個 lambda 只會越疊越深、越改越難讀。前者則是每加一道處理步驟,就在管線上多串一個操作符,彼此互不干擾,各自只負責一件事。
這裡也可以借用一個比喻幫助理解,操作符串接起來的樣子,有點像工廠生產線,原料依序通過幾個加工站,每一站只負責一個簡單的加工步驟,最後在出貨口才被真正收下。不過比喻終究只是輔助,真正要抓住的機制是,filter 與 map 串接之後得到的依然是一個 Flow,這整條管線在沒有被收集之前,同樣不會真正開始執行。這正是 Day 19:從回傳一個值到回傳一串值,Flow 是什麼 定案的冷流特性,串接操作符並不會讓這個特性失效,操作符本身不會主動觸發任何值的發送,管線只是把「等一下要怎麼加工」這件事先描述好,執行的時機依然交給收集動作決定。換句話說,showFilteredProgress 這個函式本身在被呼叫的那一刻,管線裡的 filter 與 map 都還沒有真正處理過任何一個值,一切都要等到 collect 出手才會啟動。
前面示範的每個操作符範例,最後都呼叫了 collect,這不是巧合。collect 是整條操作符管線真正的終點,呼叫 collect 才會觸發整個 Flow 從頭開始執行,依序經過前面串接的每一個操作符處理,最終抵達 collect 這裡被實際取用。
以 showFilteredProgress 這個範例來說,呼叫 collect 之後,downloadProgress 內部的 emit 才會依序執行,每個發送出來的值會先經過 filter 判斷是否保留,留下來的值再經過 map 轉換成字串,最後才交到 collect 的 lambda 裡印出。整個流程是依照串接順序,一個值一個值走完整條管線,不是先把所有值都跑完 filter、再整批送進 map。以 downloadProgress 依序發送 10、30、70、100 為例,收集端會先拿到「目前進度:10%」,接著才是「目前進度:30%」,依此類推,每個值都是獨立走完整條管線之後才輪到下一個值進場,這也呼應了 Day 19 定案的依序發送特性,並沒有因為中間插入了操作符就被打亂。
collect 之所以能扮演這個終點角色,是因為它是一個 suspend function,呼叫它的協程會一路暫停等待,直到整個 Flow 發送完畢,這也是為什麼前面的範例函式都得標上 suspend。如果只是組合了一串 map、filter,卻沒有在最後呼叫 collect 或其他類似的收集動作,這整條管線不會有任何值真正被處理,因為管線本身只是描述,沒有收集動作,就沒有執行的時機,這一點跟單獨一個 flow { } 區塊在沒被收集之前不會執行的道理完全一致,只是現在描述的對象從單一 Flow 變成了一整條串接起來的管線。
今天示範的 map、filter、collect,讓開發者可以用宣告式的方式描述資料處理流程,先過濾、再轉換、最後收集,不需要在收集的地方手動撰寫一堆條件判斷與轉換邏輯。串接起來的管線讀起來清楚,運作起來也依然遵守冷流特性,沒有被收集就不會執行。
今天示範的這些操作符,處理的都還是 Day 19:從回傳一個值到回傳一串值,Flow 是什麼 建立的那種 Flow,每次收集都是從頭開始,發送一次就結束的資料流。這種資料流適合的情境,跟接下來會遇到的情境不完全相同,冷流的這個特性在某些場景下會遇到侷限,這會是下一篇的主題。