Kotlin 如何預防 Launch 連發,搜尋任務

基於 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)
    }
}

操作符的微架構分工:

  1. debounce(300L):防抖靈魂。每次收到新字串就重置計時器,只有使用者停手 300ms 後才放行,徹底杜絕快速打字時的請求轟炸。
  2. distinctUntilChanged():用 equals() 比對前後值,字串完全相同就不往下送。
  3. flatMapLatest:底層包裝了自動取消機制。只要上游放行了新值,它會立刻 cancel 掉上一個還在跑的搜尋子協程,完美解決時序倒掛問題!

踩坑警示:別拿 collectLatest 去做扣款或寫資料庫!

很多人學會了 Flow 的操作符之後,覺得 collectLatest 太神了,就寫出這種危險程式碼:

// 致命大坑:絕對不要在 collectLatest 裡面做關鍵資料寫入或扣款!
paymentClickFlow.collectLatest {
    // 當使用者在 500ms 內連點兩次:
    // 第一次點擊發起的網路請求,在即將回傳的前一刻被 CANCEL 掉了!
    // 結果:伺服器端已經扣款成功,但 Client 端因為協程被取消而丟失回應,直接引爆帳務現場事故!
    paymentApi.charge()
}

[!CAUTION]
鐵律:

  • 唯讀、可被中斷的操作(如搜尋聯想、篩選預覽):無條件使用 debounce + flatMapLatest / collectLatest,新來舊亡。
  • 不可中斷的關鍵事務(如付款、提交訂單、門禁開門):必須使用 UI 禁用 + compareAndSet 原子搶佔 + Job isActive 排他阻斷,本地資料庫寫入才用 withContext(NonCancellable) 保護!

三種防抖方案對比矩陣

評估維度 UI 物理防禦 Job 遙控器 Flow 響應式管道
主要防禦對象 使用者暴力連點按鈕 單一任務的生命週期控制 高頻連續資料流、搜尋輸入框
典型場景 訂單提交、付款、開門指令 頁面跳轉、單次非同步按鈕 即時搜尋建議、滑桿滑動、感測器高頻事件
實作難度 最簡單(綁定 enabled) 中等(維護 var job: Job?) 需熟悉 Flow 操作符與背壓機制
多執行緒安全性 需注意狀態更新的主執行緒派發 差(多執行緒併發存取 job 變數需同步) 極高(Channel 內部自帶執行緒安全)
時序倒掛防禦 否(無法取消已發送的請求) 支援(手動呼叫 cancel()) 完美原生支援(flatMapLatest 自動取消舊請求)
維護性 狀態零散在 UI ViewModel 內殘留可變狀態 宣告式單向資料流(UDF),最好寫單元測試

工位大實話與收工

在 Android 開發裡,不要想著用同一招打天下:

  1. 只要牽涉到錢、單向事務、實體硬體控制:用 UI 禁用 + CAS 原子搶佔 + Job isActive 排他阻斷,寧可不理後續點擊,也絕不中斷執行中的交易。
  2. 只要是即時打字、動態搜尋、過濾清單:一律採用 Flow 管道(debounce(300L) + flatMapLatest),讓操作符幫你處理防抖與過期請求的自動取消。

感謝觀看,收工。

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *