協程的去抖動、節流、重試選項

2024年2月6日 12点热度 0人点赞

介紹

Kotlin 語言長期以來一直是 Android 應用程序開發的事實上的標準,這也就不足為奇了——空類型安全性、高階函數和許多其他方便的機制幫助它贏得了開發人員的歡迎和喜愛。協程就是一種大大簡化了異步代碼工作的機制。

在本文中,通過向遠程服務器發出請求的示例,將展示如何使用協程來調整異步操作隨著時間的推移執行的順序(去抖、限制、重試)。

Kotlin 中的協程

協程的概念

協程是輕量級執行線程,允許在同步功能內創建異步代碼。它們提供了一種在 Kotlin 中組織競爭力的新機制,並允許開發人員簡化異步編程,避免 Java 線程和回調 API 的問題。

協程可用於在單獨的線程中執行任務,而不會阻塞主 (UI) 線程,從而使應用程序在出現錯誤時更具響應能力和容錯能力。

使用協程的好處

  • 簡化異步代碼:協程允許以更方便且易於閱讀的形式編寫異步代碼,而不需要復雜的構造(例如 Callback 或 Promise)。
  • 減少資源消耗:協程不會根據任務需要創建盡可能多的線程,從而減少了 CPU、內存和操作系統的負載。
  • 避免阻塞主 UI 線程:協程可以與主線程並行運行而不會阻塞主線程,從而使應用程序響應更快,用戶界面響應更靈敏、更流暢。
  • 網絡和磁盤 I/O 操作:協程允許在不阻塞線程的情況下執行阻塞操作,例如網絡請求和磁盤 I/O 操作,從而減少完成操作的延遲。

創建和運行協程

要在 Kotlin 中使用協程,需要在 Gradle 文件中添加依賴:

dependencies {
  implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:$oroutinesVersion'
  implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion'
}

要運行協程,需要創建一個掛起函數:

suspend fun fetchData() {
   // Asynchronous code, such as network queries and databases, can be used here
}

掛起函數隻能從協程或另一個掛起函數調用,因此特殊的協程構建器(launch 或 async)用於從同步執行切換到異步執行。這必須在特殊的 CoroutineScope 中完成,它控制在其中運行的協程的可見性和狀態。大多數新版本的 Jetpack 庫(Lifecycle、ViewModel 等)都添加了對相應 CoroutineScope 的支持:

class MainViewModel: ViewModel() {
  fun startFetchData() {
    viewModelScope.launch {
      fetchData()
    }
  }
}

使用協程處理網絡請求

網絡請求是 Android 應用程序中需要異步代碼的主要任務之一。當應用程序發出網絡請求時,它會等待服務器的響應,此時應用程序不應阻塞。否則,應用程序將掛起直到響應到達,這可能會導致響應能力和用戶體驗不佳。

在 Android 開發的不同時期,使用了不同的方法(Handler、Thread、AsymcTask、回調)來解決這個問題。然而,許多這些方法會導致代碼難以閱讀和理解。

Kotlin 協程通過為異步網絡 I/O 操作創建輕量級執行線程來避免這些問題;該代碼看起來就像普通的同步代碼。

請求執行機制

通常需要靈活配置操作執行的頻率。要將這些方法與 Kotlin 協程一起使用,可以使用標準 Kotlin 庫“kotlinx.coroutines.flow”中的函數。接下來,看一下使用 debounce、throttle 和 retryWhen 函數的示例。

debounce 去抖動

對函數進行去抖動意味著所有調用都將被忽略,直到它們停止一段時間。隻有這樣該函數才會被調用。例如,如果我們將計時器設置為 2 秒,並且該函數以 1 秒的間隔調用 10 次,則實際調用將在最後一次(第十次)調用該函數後僅 2 秒發生。

kotlinx.coroutines.flow 中的 debounce 方法允許您在將數據釋放到流中之前設置延遲,以避免對使用者(通常是用戶界面)的頻繁更新。

該方法的本質是在接收到最後一個流值後會等待一定的時間間隔。如果在此期間收到新值,則間隔將重新開始。如果在等待間隔期間沒有收到新值,則最後一個值將輸出到最終線程。

例如,如果使用一個線程在每次用戶輸入字符時更新電話查找,那麼使用 debounce 方法,電話將不會隨著每次更改而不斷出現和消失。相反,在用戶完成鍵入後,更新查找會延遲一定時間。

假設我們的任務是處理用戶在搜索字段中的輸入,但隻有在用戶完成輸入數據後才需要將數據發送到服務器。在這種情況下,可以使用 debounce 方法,僅在用戶輸入完成後才處理用戶的輸入。

import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
fun main() = runBlocking<Unit> {
 val searchQuery = MutableStateFlow("")
 // set a delay of 500 ms before each retrieved value
 val result = searchQuery.debounce(500)
 // subscribing to receive values from the result
 result.collect { 
   // send a request to the server only if the result of the search string is not empty
   if (it.isNotBlank()) {
     println("Request to server: $it")
   } else {
     println("The search string is empty")
   }
 }
 // simulate user input in the search field
 launch {
 delay(0)
 searchQuery.value = "a"
 delay(100)
 searchQuery.value = "ap"
 delay(100)
 searchQuery.value = "app"
 delay(1000)
 searchQuery.value = "apple"
 delay(1000)
 searchQuery.value = ""
 }
}

在此示例中,搜索字段由 MutableStateFlow 表示。去抖方法在檢索每個值之前設置 0.5 秒的延遲。在最後一個線程中,僅當搜索字符串不為空時才會向服務器發出請求。

執行此代碼的結果將打印到控制臺:

Request to server: app
Request to server: apple
The search string is empty

這意味著僅在用戶在搜索字段中輸入完數據後才向服務器發送請求。

Throttling 節流

函數限制是指在指定時間段內調用該函數不超過一次(例如每 10 秒一次)。換句話說,如果某個函數最近已經被調用過,trattting 會阻止該函數被調用。 Trattting 還確保該函數定期執行。

throttle 方法與 debounce 類似,因為它也用於控制線程內發送項目的頻率。這些方法之間的區別在於它們如何處理接收到的值。與防抖不同的是,防抖會忽略除最後一個值之外的所有值,而節流閥會記住最後一個值,並在設置的時間間隔到期時重置計時器。如果沒有出現新值,則將最後存儲的值發送到輸出流。

因此,debounce 會忽略除最後一個值之外的所有值,並僅在經過一定時間後發送最後一個值,而不會收到新值。 Throttle 會記住最後一個值,並在每次一定時間間隔後發送它,無論該時間段內收到的值數量如何。

兩種方法都適用於不同的場景,方法的選擇取決於所需的數據處理邏輯。

Retry 重試

使用 Kotlin 協程,如果上次操作失敗,可以在一段時間後輕松重試操作。為此,可以使用 retryWhen 函數,該函數允許確定應重復操作的頻率和次數。

retryWhen 函數應與標準 Kotlin 庫“kotlinx.coroutines”中的 catch 語句結合使用。 catch 語句用於捕獲操作期間可能發生的任何異常。

但是,retryWhen 方法是作為 Flow 的擴展來實現的。要允許操作獨立於流程執行一次,請考慮其自己的實現:

suspend fun loadResource(url: String): Resource {
 // loadResource by url
}
suspend fun getResourceWithRetry(url: String, retries: Int, intervalMillis: Long): Resource {
 return try {
   loadResource(url)
 } catch (e: Exception) {
   if (retries > 0) {
     delay(intervalMillis) // a delay for a certain period of time
     getResourceWithRetry(url, retries - 1, intervalMillis) // repeat the operation after a certain period of time
   } else {
     throw e // throw an exception if retries are expired
   }
 }
}
// example of use
CoroutineScope(Dispatchers.IO).launch {
 val resource = getResourceWithRetry("http://example.com/resource", 3, 1000)
 // use of the loaded resource
}

這裡我們定義了一個 getResourceWithRetry 函數,它調用 loadResource 操作來加載給定 URL 處的資源。如果操作不成功,將使用延遲函數遞歸調用函數。

嘗試重復操作的次數由retries參數決定,重試之間的時間間隔由intervalMillis參數決定。

為了處理異常,在 loadResource 函數調用周圍使用 catch 語句。如果操作失敗,會再次調用 getResourceWithRetry 函數,重試次數減少 1,並延遲 IntervalMillis 參數定義的時間間隔。

這樣,如果上次失敗,可以使用 retryWhen 函數和 catch 運算符輕松地在一段時間後重新運行該操作。

所提出的算法有兩個缺點:它執行一個固定操作(訪問網絡中的特定地址)並且以恒定的間隔執行。第一個問題可以通過使用模板方法來解決,該方法允許用必要的操作來代替可能失敗的代碼調用。第二個問題可以通過稍微復雜地計算嘗試之間的間隔來解決(這尤其重要,例如,在訪問遠程服務器時 - 如果許多客戶端以相同的間隔重復嘗試,則存在增加服務器上的負載達到臨界水平)。

因此,我們有一個更靈活的選擇:

suspend fun <T> getResourceWithRetry(
 retries: Int = 5, // 5 retries
 initialDelay: Long = 100L, // 0.1 second
 maxDelay: Long = 128000L, // 128 seconds
 factor: Double = 2.0,
 block: suspend () -> T): T
{
 return try {
   loadResource(url)
 } catch (e: Exception) {
   if (retries > 0) {
     val currentDelay = (currentDelay * factor).toLong().coerceAtMost(maxDelay)
     delay(currentDelay) // delay for a certain period of time
     getResourceWithRetry(retries - 1, currentDelay, maxDelay, factor, block) // repeat the operation after some time
   } else {
     throw e // throw an exception if retries are over
   }
}

結論

總之, Kotlin 協程操作是用於控制事件的計時和排序的強大工具,可幫助您解決與應用程序中的異步和數據流管理相關的各種問題。使用這些操作可以顯著提高代碼的質量和效率,改善用戶體驗,並減少與異步相關的錯誤。

但是,在使用這些操作時,您應該記住它們有自己的特殊性,並且需要謹慎使用。例如,值得考慮對嘗試次數和嘗試之間的時間間隔進行限制,以免導致無休止的重試循環、產生過載,或者相反,跳過操作(值)。