不管處理何種問題,我們都必須考慮是出於何種場景,就像是
使用者在 1 秒內點了 10 次。由於 launch 的天性是「射後不理 (fire-and-forget)」,你的 App 也會忠實地啟動 10 個獨立的 Coroutine,發起 10 次網路請求。
這樣看起來問題是 不想要同時跑十次任務,那為何不想要?如果不想要是為啥?是為了解決啥情境遇到的問題
要解決這個問題,我們有幾個層次的策略:
- UI 層 在 launch 開始時立刻設定 button.isEnabled = false,並在 finally 區塊中將它設回 true。這是最直觀的防禦,直接阻止了使用者發起新任務的可能。
- Job 層 launch 會返回一個 Job 物件,你可以把它想像成這個任務的「遙控器」。遙控器可以控制任務。
- Flow層 ,如果是壹連串改變的流程,那肯定有可以修改的部分
底下我們從場景來分析怎麼改?
「搜尋」功能 — 我只關心最新的結果
在搜尋這種場景,使用者在短時間內快速輸入 “A”、”AP”、”APP”、”APPL”、”APPLE”,我們其實只關心最後 “APPLE” 的搜尋結果。前面幾次的請求,不僅浪費資源,如果舊的結果比新的結果晚回來,還會覆蓋掉正確的搜尋結果。
策略:如果舊任務還在跑,就取消它,立刻開始新任務。
Kotlin Job
// 在你的 ViewModel 或 Presenter 中
private var searchJob: Job? = null
fun onSearchQueryChanged(query: String) {
// 1. 如果上一個搜尋任務 (searchJob) 還在執行中,就取消它!
searchJob?.cancel()
// 2. 啟動一個全新的 Coroutine 來處理這次的搜尋請求
searchJob = lifecycleScope.launch {
try {
// 模擬網路延遲
delay(1000)
val result = apiService.search(query)
_uiState.value = result // 更新 UI
} catch (e: CancellationException) {
// 當 Job 被 cancel 時會拋出這個異常,這是正常的流程,可以不用處理
Log.i("SearchJob", "Job for query '$query' was cancelled.")
} catch (e: Exception) {
// 處理其他真實的錯誤
_uiState.value = "Error: ${e.message}"
}
}
}
Kotlin Flow
等他停止輸入一小段時間後。debounce
// 在你的 ViewModel 中
viewModelScope.launch {
// 假設 clicksFlow 是從你的 UI 按鈕點擊事件轉換而來的 Flow
clicksFlow
.debounce(300L) // 只有當 300 毫秒內沒有新的點擊時,才讓最新的事件通過
.onEach { query ->
// 在這裡,query 就是通過了 debounce 考驗的最終搜尋關鍵字
_uiState.value = "正在搜尋: $query"
// 觸發網路請求
val result = apiService.search(query)
_uiState.value = result
}
.catch { throwable ->
// 優雅地處理上游或 onEach 中發生的任何錯誤
_uiState.value = "Error: ${throwable.message}"
}
.launchIn(viewModelScope) // 在 viewModelScope 中啟動這個 Flow 的收集
}
只關心最新輸入的結果 (transformLatest)
viewModelScope.launch {
clicksFlow
// mapLatest 是 transformLatest 的簡化版
.mapLatest { query ->
// 當新的 query 進來時,前一個尚未完成的 apiService.search 會被自動取消
apiService.search(query)
}
.catch { ... }
.collect { result ->
// 這裡收到的永遠是最新 query 對應的結果
_uiState.value = result
}
}
「付款」按鈕 — 關鍵操作不容打斷
對於像「確認付款」、「送出訂單」這種一次性的關鍵操作,我們絕不希望它被中途取消。而且,在第一次點擊的任務還沒完成前(例如,還在跟銀行伺服器溝通),我們應該忽略所有後續的點擊,避免重複下單。
策略:如果任務正在執行中,就忽略所有新的點擊請求。
Kotlin Job
// 在你的 ViewModel 或 Presenter 中
private var paymentJob: Job? = null
fun onPayButtonClicked() {
// 1. 檢查 paymentJob 是否存在,並且是否還在活躍狀態 (isActive)
if (paymentJob?.isActive == true) {
// 2. 如果是,表示上次的付款流程還沒跑完,直接 return,不做任何事。
Log.d("PaymentJob", "Payment is already in progress. Ignoring new click.")
return
}
// 3. 如果沒有正在進行的任務,就啟動一個新的 Coroutine
paymentJob = lifecycleScope.launch {
try {
_uiState.value = "處理中..." // 顯示處理中狀態
button.isEnabled = false // 同時搭配 UI 防守
// 模擬一個無法被取消的關鍵網路請求
val result = withContext(NonCancellable) {
apiService.processPayment()
}
_uiState.value = "付款成功!" // 更新 UI
} catch (e: Exception) {
_uiState.value = "付款失敗: ${e.message}"
} finally {
button.isEnabled = true // 無論如何,最後都要恢復按鈕狀態
}
}
}
Kotlin Flow
// 在 ViewModel 中
// 1. 一個「忙碌信號燈」,用來標記是否正在處理付款
private val isProcessing = MutableStateFlow(false)
// 2. 一個「點擊觸發器」,UI 層只需要呼叫它來發送點擊事件
// 使用 SharedFlow,因為我們可能有多個地方需要觸發或監聽
private val paymentTrigger = MutableSharedFlow<Unit>()
// 3. 在 ViewModel 初始化時,設定好監聽和處理邏輯
init {
viewModelScope.launch {
paymentTrigger.collect {
// 每次點擊事件觸發時,先檢查「信號燈」
if (isProcessing.value) {
// 如果是紅燈(正忙),直接 return,忽略這次觸發
return@collect
}
// 如果是綠燈(空閒),立刻切換為紅燈,並開始工作
isProcessing.value = true
try {
_uiState.value = "處理中..."
val result = apiService.processPayment()
_uiState.value = "付款成功!"
} catch (e: Exception) {
_uiState.value = "付款失敗: ${e.message}"
} finally {
// 無論成功或失敗,工作結束後,都要把信號燈切回綠燈
isProcessing.value = false
}
}
}
}
// 4. UI 層只需要呼叫這個簡單的函式
fun onPayButtonClicked() {
viewModelScope.launch {
paymentTrigger.emit(Unit)
}
}
—