基於 blog 的主旨「Something Record」,這次來記錄一下非同步並行時天天遇到的靈魂拷問。
剛從 Java 切換到 Kotlin 的那陣子,我看著協程的語法糖爽感十足,隨手就在 ViewModel 裡到處寫 viewModelScope.launch。因為協程 launch 天生就是**「射後不理 (fire-and-forget)」**,既不阻塞呼叫端,語法又比手寫執行緒乾淨十倍,寫起來行雲流水。

直到線上產品開跑,手速極快的使用者在 1 秒內連按了 10 次付款按鈕,或者在搜尋框無情鬼畜打字,App 忠實地平行啟動 10 個獨立協程,連續向後端轟炸 10 次網路請求。
算一筆最殘酷的現場資源赤字:
- OkHttp 連線池塞車:OkHttp 預設
maxRequestsPerHost = 5。10 次連發瞬間把連線池灌爆,後續請求被迫在連線佇列排隊乾等。 - 伺服器端 QPS 翻倍:只要幾十個使用者同時連點,瞬間就是數百次突發併發,直接把後台伺服器打出 502 Bad Gateway。
- 記憶體與 GC 抖動:1 秒內建立 10 套 Request、Response、Okio Buffer 物件,在 ART 虛擬機器裡噴出大量臨時記憶體垃圾,頻繁觸發 GC 導致 UI 幀率暴跌。
但在動手寫防禦前,必須先看清業務現場的兩種極端情境——因為用錯防禦手段,比連發更致命。
業務現場的兩大極端情境:新來舊亡 vs. 關鍵排他
並行問題之所以麻煩,是因為不同業務對「連發」的期望完全相反:
情境 1:搜尋輸入框 —— 前面的請求全是廢話,我只關心最新結果
使用者在短時間內快速輸入「A」、「AP」、「APP」、「APPLE」,我們其實只關心最後「APPLE」的搜尋結果。
前面幾次的請求不僅浪費頻寬和伺服器算力,更致命的是時序倒掛(Race Condition / Stale Overwrite):如果舊的「APP」請求因為網路波動,比新的「APPLE」更晚回傳,後回來的舊資料就會把正確的最新結果硬生生蓋掉!
核心策略:新來舊亡。只要新輸入一進來,舊任務還在跑就立刻強制取消。
情境 2:確認付款 / 送出訂單 —— 關鍵事務絕不容打斷,任務在跑就別理我
像「確認付款」、「送出訂單」、「門禁開門」這種操作,我們絕不希望它被中途取消,更不允許重複執行。在第一次點擊的任務還沒完成前(還在跟銀行伺服器通訊),所有後續的點擊通通都該被直接忽略,避免重複下單。
核心策略:排他阻斷。如果前一個任務正在跑,忽略後面所有的點擊。
防禦方案一:UI 物理防禦(按下去直接鎖死按鈕)
最直觀的做法是在 launch 開始前立刻把按鈕禁用,跑完再解鎖。
很多工程師寫出這樣的程式碼,自以為很安全:
// 典型錯誤範例:存在 TOCTOU 併發穿透漏洞
fun submitOrder() {
if (_isSubmitting.value) return // 1. 主執行緒讀取
viewModelScope.launch {
_isSubmitting.value = true // 2. 協程被排程器派發前,空窗期依然為 false!
try {
orderRepository.checkout()
} finally {
_isSubmitting.value = false
}
}
}
這段程式碼有教科書級別的 Time-of-Check to Time-of-Use (TOCTOU) 競爭漏洞!
launch 預設派發給協程排程器,在協程真正執行第一行前存在排程空窗期。若手速極快的使用者連點,第二個點擊會在協程啟動前再次通過 if 檢查,導致發起多個付款協程!
正確做法:CAS 原子搶佔鎖 + enabled 綁定
必須在呼叫 launch 前同步搶佔旗標,消滅排程空窗期:
class PaymentViewModel : ViewModel() {
// 初始狀態為 false (未送出)
private val _isSubmitting = MutableStateFlow(false)
val isSubmitting: StateFlow<Boolean> = _isSubmitting.asStateFlow()
fun submitOrder() {
// 第一道防線:透過 CAS (Compare-And-Set) 原子操作立即搶佔旗標
// 如果目前不是 false,代表已有任務正在進行,直接返回
if (!_isSubmitting.compareAndSet(expect = false, update = true)) return
viewModelScope.launch {
try {
// 執行關鍵付款
orderRepository.checkout()
} catch (e: Exception) {
// 錯誤處理
} finally {
// 不管成功、失敗還是被取消,最後一定要安全釋放旗標!
_isSubmitting.value = false
}
}
}
}
// Compose 端的宣告式綁定
@Composable
fun SubmitButton(viewModel: PaymentViewModel) {
val isSubmitting by viewModel.isSubmitting.collectAsState()
Button(
onClick = { viewModel.submitOrder() },
enabled = !isSubmitting // 物理停用點擊
) {
if (isSubmitting) {
CircularProgressIndicator(modifier = Modifier.size(16.dp))
} else {
Text("確認付款")
}
}
}
- 優點:直觀,使用者介面看得見按鈕變灰或轉圈圈。
- 缺點:只能防按鈕點擊。在搜尋框快速打字時,你不可能每敲一個字就把輸入框鎖死 300 毫秒(使用者會以為手機觸控當機),因此搜尋情境不適用。
防禦方案二:Job 遙控器(手動控制協程生命週期)
呼叫 launch 會回傳一個 Job 物件。可以把這個 Job 當成任務的「遙控器」,用來主動 cancel 或檢查活躍狀態。
搜尋情境:新來舊亡,立刻取消前一個任務
class SearchViewModel(
private val searchApi: SearchApi
) : ViewModel() {
private var searchJob: Job? = null
private val _searchResult = MutableStateFlow<List<String>>(emptyList())
val searchResult: StateFlow<List<String>> = _searchResult.asStateFlow()
fun onQueryChanged(query: String) {
// 1. 如果上一個搜尋任務還在跑,立刻按取消鍵!
searchJob?.cancel()
if (query.isBlank()) {
_searchResult.value = emptyList()
return
}
// 2. 開一個新的協程,並把遙控器存下來
searchJob = viewModelScope.launch {
try {
// 等 300ms,如果使用者繼續打字,上面就會觸發 cancel() 中斷這裡
delay(300L)
val results = searchApi.search(query)
_searchResult.value = results
} catch (e: CancellationException) {
// 協程被取消會拋出此取消例外 (CancellationException),屬正常流程
} catch (e: Exception) {
// 處理真實業務錯誤
}
}
}
}
微架構細節:協程取消是「協同式(Cooperative)」的!
很多人以為
job.cancel()一按下去,程式碼就會像被拔掉插頭一樣瞬間停住。根本不是這回事!cancel()只是把 Job 狀態設為Cancelling。如果協程正在跑一段純 CPU 密集運算的迴圈(例如在算矩陣、影像解碼或特徵點比對),它完全感知不到取消!
必須要有掛起點(如delay()、網路 I/O),或者在迴圈中主動加上ensureActive()或yield(),協程才能在被 cancel 時及時拋出CancellationException退場。
付款情境:任務在跑,後續點擊通通忽略
class CheckoutViewModel : ViewModel() {
private var checkoutJob: Job? = null
fun onPayClicked(idempotencyKey: String) {
// 檢查前一個任務是否還在活躍狀態 (isActive)
if (checkoutJob?.isActive == true) {
// 上一次付款還沒跑完,直接 return,忽視這次點擊
return
}
checkoutJob = viewModelScope.launch {
try {
// 1. 網路請求保持正常可取消,由後端 idempotency key 保證不重複扣款
val response = paymentGateway.processTransaction(idempotencyKey)
// 2. 只有本地不可逆的訂單狀態寫入,才用 NonCancellable 避免狀態撕裂
withContext(NonCancellable) {
orderRepository.markOrderAsPaid(response.transactionId)
}
} catch (e: CancellationException) {
// 協程被正常取消,依賴下次進入畫面或背景輪詢對帳
throw e
} finally {
// 任務結束,Job 自然變成非活躍狀態,下次點擊就能再進來
}
}
}
}
微架構陷阱:
NonCancellable是雙刃劍千萬不要盲目把整段長達 30 秒的網路請求全塞進
withContext(NonCancellable)裡!
如果使用者按了上一頁退出畫面,這個連線會死死咬住網路卡直到逾時,造成記憶體洩漏與頻寬浪費。
真正標準的工業級做法:網路請求保持正常可取消,但搭配後端冪等性 Token(Idempotency Key);只有在「本地資料庫扣款狀態更新 / 交易寫入」的最關鍵幾行,才需要用NonCancellable保護原子性。
防禦方案三:Flow 管道(現代響應式架構)
在現代 Android 開發中,最優雅的做法是把事件當成水流(Flow),直接用內建操作符做宣告式控制。
終極搜尋防抖管道:debounce + distinctUntilChanged + flatMapLatest
class ModernSearchViewModel(
private val searchApi: SearchApi
) : ViewModel() {
// 1. 事件入口水管
private val queryChannel = MutableSharedFlow<String>(
replay = 0,
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
// 2. 宣告響應式處理管線
val searchUiState: StateFlow<SearchUiState> = queryChannel
// 核心防抖:只有當 300 毫秒內沒有新事件流進來,才放行最新字串
.debounce(300L)
// 核心過濾:打了 "A" 又刪除改回 "A",內容沒變就不重新發請求
.distinctUntilChanged()
// 核心併發控制:新 query 進來時,自動把上一個尚未完成的搜尋請求取消
.flatMapLatest { query ->
flow {
if (query.isBlank()) {
emit(SearchUiState.Idle)
} else {
emit(SearchUiState.Loading)
val result = searchApi.search(query)
emit(SearchUiState.Success(result))
}
}.catch { e ->
emit(SearchUiState.Error(e.message ?: "Unknown Error"))
}
}
// 轉為生命週期感知的 StateFlow
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000L),
initialValue = SearchUiState.Idle
)
// 3. UI 只需要無腦呼叫這個函式丟事件
fun onQueryInput(query: String) {
queryChannel.tryEmit(query)
}
}
操作符的微架構分工:
debounce(300L):防抖靈魂。每次收到新字串就重置計時器,只有使用者停手 300ms 後才放行,徹底杜絕快速打字時的請求轟炸。distinctUntilChanged():用equals()比對前後值,字串完全相同就不往下送。flatMapLatest:底層包裝了自動取消機制。只要上游放行了新值,它會立刻 cancel 掉上一個還在跑的搜尋子協程,完美解決時序倒掛問題!
踩坑警示:別拿 collectLatest 去做扣款或寫資料庫!
很多人學會了 Flow 的操作符之後,覺得 collectLatest 太神了,就寫出這種危險程式碼:
// 致命大坑:絕對不要在 collectLatest 裡面做關鍵資料寫入或扣款!
paymentClickFlow.collectLatest {
// 當使用者在 500ms 內連點兩次:
// 第一次點擊發起的網路請求,在即將回傳的前一刻被 CANCEL 掉了!
// 結果:伺服器端已經扣款成功,但 Client 端因為協程被取消而丟失回應,直接引爆帳務現場事故!
paymentApi.charge()
}
[!CAUTION]
鐵律:
- 唯讀、可被中斷的操作(如搜尋聯想、篩選預覽):無條件使用
debounce+flatMapLatest/collectLatest,新來舊亡。- 不可中斷的關鍵事務(如付款、提交訂單、門禁開門):必須使用 UI 禁用 +
compareAndSet原子搶佔 + JobisActive排他阻斷,本地資料庫寫入才用withContext(NonCancellable)保護!
三種防抖方案對比矩陣
| 評估維度 | UI 物理防禦 | Job 遙控器 | Flow 響應式管道 |
|---|---|---|---|
| 主要防禦對象 | 使用者暴力連點按鈕 | 單一任務的生命週期控制 | 高頻連續資料流、搜尋輸入框 |
| 典型場景 | 訂單提交、付款、開門指令 | 頁面跳轉、單次非同步按鈕 | 即時搜尋建議、滑桿滑動、感測器高頻事件 |
| 實作難度 | 最簡單(綁定 enabled) |
中等(維護 var job: Job?) |
需熟悉 Flow 操作符與背壓機制 |
| 多執行緒安全性 | 需注意狀態更新的主執行緒派發 | 差(多執行緒併發存取 job 變數需同步) |
極高(Channel 內部自帶執行緒安全) |
| 時序倒掛防禦 | 否(無法取消已發送的請求) | 支援(手動呼叫 cancel()) |
完美原生支援(flatMapLatest 自動取消舊請求) |
| 維護性 | 狀態零散在 UI | ViewModel 內殘留可變狀態 | 宣告式單向資料流(UDF),最好寫單元測試 |
工位大實話與收工
在 Android 開發裡,不要想著用同一招打天下:
- 只要牽涉到錢、單向事務、實體硬體控制:用 UI 禁用 + CAS 原子搶佔 + Job
isActive排他阻斷,寧可不理後續點擊,也絕不中斷執行中的交易。 - 只要是即時打字、動態搜尋、過濾清單:一律採用 Flow 管道(
debounce(300L)+flatMapLatest),讓操作符幫你處理防抖與過期請求的自動取消。
感謝觀看,收工。

發佈留言