[譯] 在 Android 使用協程(part III) - 在實際工做中使用

這是一篇關於在 Android 上使用協程的系列文章之一。經過實現一次請求來解釋使用協程中的實際問題是這篇文章的重點。android

本系列的其餘文章:

[譯] 在 Android 使用協程(part I) - 協程的背景知識git

[譯] 在 Android 使用協程(part II) - 入門指南github

使用協程解決實際問題

本系列的第 1 部分和第 2 部分重點介紹瞭如何使用協程來簡化代碼、在 Android 上提供主線程安全調用以及避免協程泄露。有了這個背景,協程看起來是一個既能夠用於後臺處理,又能夠簡化 Callback 的很好解決方案。web

到目前爲止,咱們主要關注的是「什麼是協程」以及「如何管理它們」。在這篇文章中,咱們將看看如何使用它們來完成一些真正的任務。協程是一種通用的編程語言特性,與函數處於同一級別,所以,你可使用它們來實現任何使用函數和對象實現的功能。然而,有兩種類型的任務老是出如今實際代碼中,協程是一種很好的解決方案:數據庫

  1. 一次性請求 它們老是在獲得響應時認爲請求完成了,因此每次調用時都會從新運行的請求
  2. 流請求 它們不會在獲得第一個響應時就認爲請求完成了,還會繼續觀察改變並將其報告給調用者

協程是這兩個任務的一個很好的解決方案。在這篇文章中,咱們將深刻研究一次性請求,並探索如何在 Android 上用協程實現它們。編程

一次性請求

每次調用一個一次性請求都會執行一次,並在響應時完成。此模式與常規函數調用相同——被調用,執行一些操做,而後返回。因爲與函數調用的類似性,它們每每比流請求更容易理解。後端

每次調用一個一次性請求時都會執行一次。一旦獲得響應,就中止執行。瀏覽器

對於一次性請求的示例,請考慮瀏覽器如何加載此頁面。當你點擊到這篇文章的連接時,瀏覽器向服務器發送了一個網絡請求來加載頁面。一旦頁面被傳輸到你的瀏覽器,它就中止與後端通訊——它已經獲取到須要的全部數據。若是服務器修改了這篇文章,除非你刷新頁面不然新的修改將不會顯示在瀏覽器中。緩存

所以,雖然它們缺少流請求的實時推送功能,但一次性請求仍舊很是強大。在 Android 應用中,有不少事情能夠經過一次性請求來解決,好比獲取、存儲或更新數據。對於排序列表之類的事情,它也是一種很好的模式。安全

問題:顯示已排序的列表

讓咱們經過查看如何顯示排序列表來研究一次性請求。爲了讓示例更加具體,咱們構建了一個「存貨清單」的應用,供商店員工使用。它將用於根據產品最後一次進貨的時間查找產品——他們但願可以對列表進行升序和降序排序。由於有不少產品,排序產品可能須要一秒鐘,因此咱們將使用協程來避免阻塞主線程!

在這個應用中,全部的產品都存儲在一個 Room 數據庫中。這是一個很好的用例,由於它不須要涉及網絡請求,因此咱們能夠關注模式。儘管這個示例比較簡單,由於它不使用網絡,可是它展現了實現一次性請求所需的模式。

要使用協程實現這個請求,你將把協程引入到 ViewModel、Repository 和 Dao。讓咱們逐個瀏覽一下,看看如何將它們與協程集成在一塊兒。

class ProductsViewModel(val productsRepository: ProductsRepository): ViewModel() {
   val _sortedProducts = MutableLiveData<List<ProductListing>>()
   val sortedProducts: LiveData<List<ProductListing>> = _sortedProducts

   /** * 當用戶點擊 sort 按鈕時調用,由 UI 層調用 */
   fun onSortAscending() = sortPricesBy(ascending = true)
   fun onSortDescending() = sortPricesBy(ascending = false)

   private fun sortPricesBy(ascending: Boolean) {
       viewModelScope.launch {
           // 掛起和恢復使這個數據庫請求確保主線程安全
           // 因此咱們的 ViewModel 不須要擔憂線程
           _sortedProducts.value =
                   productsRepository.loadSortedProducts(ascending)
       }
   }
}
複製代碼

ProductsViewModel 負責從 UI 層接收事件,而後向存儲庫請求更新後的數據。它使用 LiveData 保存當前已排序的列表,以便讓 UI 顯示。當 sortProductsBy 接收一個新事件時,啓動一個新的協程來對列表進行排序,並在響應時更新 LiveData。ViewModel 一般是這個體系結構中啓動大多數協程的正確位置,由於它能夠在 onCleared中取消協程。若是用戶離開界面,它們一般再也不須要工做。

若是你還不常用 LiveData,請查看 @CeruleanOtter發佈的這篇很棒的文章,它介紹瞭如何爲 UI 存儲數據.

一個簡單的 ViewModel 示例 (ViewModels : A Simple Example)

這是 Android 上協程的通常模式。因爲 Android 框架不能調用掛起函數,所以你須要配合一個協程來響應 UI 事件。最簡單的辦法是事件發生時啓動一個新的協程,而在 ViewModel 作這件事比較合適。

在 ViewModel 中啓動協程做爲通常模式。

ViewModel 使用 ProductsRepository 來實際獲取數據。來看它是這樣作的:

class ProductsRepository(val productsDao: ProductsDao) {

  /** * 這是一個"常規"掛起函數,這意味着調用者必須處於一個協程中。存儲層不負責啓動或 * 中止協程,由於它沒有一個合適的生命週期來取消沒必要要的工做。 * 這能夠是從 Dispatchers.Main 調用的,並且是主線程安全的,由於 Room 將爲咱們負責 * 主線程安全。 */
   suspend fun loadSortedProducts(ascending: Boolean): List<ProductListing> {
       return if (ascending) {
           productsDao.loadProductsByDateStockedAscending()
       } else {
           productsDao.loadProductsByDateStockedDescending()
       }
   }
}
複製代碼

ProductsRepository 爲產品交互提供了一個合理的接口。在這個應用程序中,因爲全部內容都在本地的 Room 數據庫中,因此他只是爲 @Dao 提供了一個很好的接口,對於不一樣的排序,@Dao 有兩個不一樣的函數。

存儲層是 Android 架構體系中一個可選部分——但若是你的應用有它或相似的層,它應該更願意暴露常規掛起函數。由於存儲層沒有一個自然的生命週期——它只是一個對象——它沒有辦法清理工做。所以,在默認狀況下,在存儲庫中啓動的任何協程都會泄露。

除了避免泄露以外,經過暴露常規掛起函數,還能夠很容易地在不一樣的上下文中複用存儲庫。任何知道如何創造協程的東西均可以調用 loadSortedProducts 。例如,Workmanager 調度的後臺 Job 能夠直接調用它。

存儲庫應該暴露出主線程安全的常規掛起函數。

注意:一些後臺保存操做可能會但願用戶離開界面後繼續執行——在沒有生命週期的狀況下運行這些保存是有意義的。在大多數其餘狀況下,viewModelScope 是一個合理的選擇。

繼續看 ProductsDao,它看起來是這樣的:

@Dao
interface ProductsDao {
   // 由於這是掛起的,Room 將使用它本身的調度器以主線程安全的方式運行這個查詢
   @Query("select * from ProductListing ORDER BY dateStocked ASC")
   suspend fun loadProductsByDateStockedAscending(): List<ProductListing>

   // 由於這是掛起的,Room 將使用它本身的調度器以主線程安全的方式運行這個查詢
   @Query("select * from ProductListing ORDER BY dateStocked DESC")
   suspend fun loadProductsByDateStockedDescending(): List<ProductListing>
}  
複製代碼

ProductsDao 是一個 Room @Dao ,它公開了兩個掛起函數。因爲函數被suspend 標記,Room 確保它們是主線程安全的。這意味着你能夠直接從 Dispatchers.Main 來調用它們。

若是你尚未在 Room 中看到協程,請查看 @FMuntenescu的這篇很棒的文章

Room 🔗 Coroutines

不過有一點注意,調用它的協程將位於主線程上。所以,若是你對結果作了一些花銷大的操做(好比將它們轉換爲一個新列表),你應該確保它沒有阻塞主線程。

注意:Room 使用本身的調度器在後臺線程上運行查詢。你的代碼不該該用 withContext(Dispatchers.IO) 來調用 Room 的掛起查詢函數。這會使代碼變得複雜,使查詢運行的更慢。

Room 的掛起函數是主線程安全的,並在自定義調度器上運行。

一次性請求模式

這是在 Android 架構組件中使用協程發出一次性請求的完整模式。咱們將協程添加到 ViewModel、Repository 和 Room 中,每一層都有不一樣的職責。

  1. ViewModel 在主線程上啓動一個協程——當它獲得響應時,它就完成了。

  2. Repository 暴露常規掛起函數,並確保它們是主線程安全的。

  3. 數據庫和網絡暴露常規掛起函數,並確保它們是主線程安全的。

ViewModel 負責啓動協程,並確保在用戶離開界面時它們被取消。它不作大開銷的工做——而是依靠其餘層來完成繁重的工做。一旦有了響應,它就使用 LiveData 將其發送到 UI。因爲 ViewModel 不作大開銷的工做,因此它在主線程上啓動協程。由於運行在主線程上,若是響應當即可用(例如從內存緩存中獲取),它能夠更快的響應用戶事件。

存儲庫經過暴露掛起函數來外界訪問數據。它一般不會本身啓動一個長生命週期的協程,由於沒有任何辦法取消它們。每當存儲庫必須作一些開銷大的事情,好比轉換列表時,它應該使用 withContext 來暴露一個主線程安全的接口。

數據層(網絡或數據庫) 老是暴露常規掛起函數。使用 Kotlin 協程時,這些掛起函數是主線程安全的,這一點很重要,Room 和 Retrofit 都遵循這種模式。

在一次性請求中,數據層只暴露掛起函數。若是調用者想要一個新的數據,則必須再調用它們。這就像 web 瀏覽器上的刷新按鈕同樣。

值的花點時間來確保你理解這些一次性請求的模式。這是 Android 上協程的正常模式,你會一直使用它。

咱們的第一個 Bug 報告!

在測試了該解決方案以後,你將其投入生產,而且在接下來的幾周內一切都很順利,直到你獲得一個很是奇怪的 Bug 報告:

主題:🐞——錯誤的排序順序!

報告:當我很是很是很是快地點擊排序按鈕時,有時排序是錯誤的。這個問題偶爾纔會發生🙃。

你看了看,撓撓頭。有什麼地方可能出錯呢?流程看起來至關簡單:

  1. 用戶啓動排序請求
  2. 在 Room 調度器中執行排序
  3. 顯示排序的結果

你很想用 "不會修復-不要按按鈕那麼快"來關閉這個 Bug,可是你擔憂可能有哪裏不對。在添加打印 Log 並編寫了一個測試來同時調用多個排序以後——你終於找到了答案!

最終顯示的結果實際上不是"排序的結果",而是"最後一個完成的排序"的結果。"當用戶重複點擊按鈕時,它們會同時啓動多個排序任務,而且可能獲得任意排序的結果"。

在響應 UI 事件啓動一個新的協程時,請考慮若是用戶在這個事件完成以前啓動了另外一個協程會發生什麼。

這是一個併發性 Bug,它實際上與協程沒有關係。若是咱們以一樣的方式使用回調、Rx,甚至是 ExecutorService 也會有一樣的 Bug。

在 ViewModel 和存儲庫中,有不少辦法能夠修復這個問題。讓咱們研究一些模式,以確保按用戶指望的順序完成一個一次性請求。

最佳解決方案:禁用按鈕

問題的根本緣由是咱們同時執行了兩次排序。咱們能夠經過讓它同時只作一次排序來解決這個問題!最簡單的辦法是在合適的時候禁用排序按鈕。

這彷佛是一個簡單的解決方案,但它確實是一個好主意。實現這一點的代碼很簡單,而且易於測試,只要它的 UI 不是無厘頭的,就徹底能夠解決問題!

要禁用按鈕,那麼就告訴 UI 排序請求正在 sortPricesBy 中進行,以下所示:

// 0 號解決方案:在運行任何排序時禁用排序按鈕

class ProductsViewModel(val productsRepository: ProductsRepository): ViewModel() {
   val _sortedProducts = MutableLiveData<List<ProductListing>>()
   val sortedProducts: LiveData<List<ProductListing>> = _sortedProducts
  
   val _sortButtonsEnabled = MutableLiveData<Boolean>()
   val sortButtonsEnabled: LiveData<Boolean> = _sortButtonsEnabled
  
   init {
       _sortButtonsEnabled.value = true
   }

   /** * 在用戶點擊合適的排序按鈕時,由 UI 層調用 */
   fun onSortAscending() = sortPricesBy(ascending = true)
   fun onSortDescending() = sortPricesBy(ascending = false)

   private fun sortPricesBy(ascending: Boolean) {
       viewModelScope.launch {
           // 在排序進行時禁用按鈕
           _sortButtonsEnabled.value = false
           try {
               _sortedProducts.value =
                       productsRepository.loadSortedProducts(ascending)
           } finally {
               // 在排序完成時恢復按鈕
               _sortButtonsEnabled.value = true
           }
       }
   }
}
複製代碼

這樣還不錯。只須要在調用了存儲庫的sortPricesBy ,排序開始時就會禁用排序按鈕。

在大多數狀況下,這是解決這個問題的正確方法。可是若是咱們想讓按鈕保持啓用狀態並修復 Bug 呢?這有點難,咱們將在接下來的部分探索一些不一樣的選項。

重要提示:這段代碼顯示了在主線程上啓動協程的一個主要優點——按鈕在點擊時馬上禁用。若是你切換調度器,在低端手機上進行快速操做的用戶能夠發送不止一個點擊事件!

併發模式

接下來幾節將探索高級主題——若是你剛剛開始使用協程,那麼你不須要馬上理解它們。簡單地禁用按鈕時你將遇到的大多數問題的最佳解決方案。

在這篇文章的其他部分,咱們將探討如何在按鈕可用時,但又確保一次性請求的執行順序不會讓用戶感到意外的狀況下使用協程。咱們能夠經過控制協程什麼時候運行(或不運行)來避免意外的併發狀況。

對於一次性請求,可使用三種基本模式來確保每次只運行一個請求。

  1. 在開始更多的工做前,先取消以前的工做。
  2. 將下一個工做排隊,等待前面的請求完成後繼續再啓動另外一個。
  3. 若是已經有一個請求在運行,那麼讓以前的工做執行完畢而後返回以前的工做結果,而不啓動後來的一個請求。

當你查看這些解決方案時,你會注意到它們的實現有些複雜。爲了關注如何使用這些模式而不是實現細節,我 建立了一個gist,將全部的三個模式的實現都做爲可複用的抽象。

1 號解決方案:取消以前的工做

在排序時,從用戶得到一個新事件一般意味着能夠取消最後一個排序。畢竟,若是用戶已經告訴你他們不想要前面那個結果,那麼繼續下去又有什麼意義呢?

要取消以前的請求,咱們須要以某種方式跟蹤它。函數cancelPreviousThenRungist中就是這樣作的。

讓咱們來看看如何用它來修復 Bug:

// 1 號解決方案:取消以前的工做

// 對於排序和過濾這樣的任務,這是一個很好的解決方案,若是有新的請求進來,能夠取消這些任務

class ProductsRepository(val productsDao: ProductsDao, val productsApi: ProductsService) {
   var controlledRunner = ControlledRunner<List<ProductListing>>()

   suspend fun loadSortedProducts(ascending: Boolean): List<ProductListing> {
       // cancel the previous sorts before starting a new one
       return controlledRunner.cancelPreviousThenRun {
           if (ascending) {
               productsDao.loadProductsByDateStockedAscending()
           } else {
               productsDao.loadProductsByDateStockedDescending()
           }
       }
   }
}
複製代碼

查看 gist 中的 cancelPreviousThenRun 的示例實現是瞭解如何跟蹤正在進行工做的好辦法。

// 查看完整的實如今 https://gist.github.com/objcode/7ab4e7b1df8acd88696cb0ccecad16f7
suspend fun cancelPreviousThenRun(block: suspend () -> T): T {
   // 若是有 activeTask,取消它,由於不在須要它的結果
   activeTask?.cancelAndJoin()
   
   // ...
複製代碼

簡而言之,它老是跟蹤成員變量 activeTask 中當前活動的排序。每當排序開始時,它將當即調用activeTaskcancelAndJoin。這樣作的效果是,在開始一個新的排序以前,取消任何正在進行的排序。

使用相似 ControlledRunner<T> 的抽象來封裝這樣的邏輯是一個好主意,而不是將特別的併發性與應用程序邏輯混合在一塊兒。

考慮構建抽象,以免將特別的併發模式與應用程序代碼搞混。

重要說明:此模式不適合在全局單例中使用,由於不相關的調用方不該該相互取消。

2 號解決方案:排隊進行

有一種解決併發 bug 的辦法老是有效的。只要把請求排隊,一次只能發生一件事!就像商店中的隊列同樣,請求將按啓動的順序依次執行。

對於這個特殊的排序問題,取消可能比排隊更好,可是排隊進行仍是值得討論,由於它也是有用的。

// 2 號解決方案:添加互斥鎖

// 注意:這對於排序或過濾的特定用例不是最優的,可是對於網絡保存是一種很好的模式
class ProductsRepository(val productsDao: ProductsDao, val productsApi: ProductsService) {
   val singleRunner = SingleRunner()

   suspend fun loadSortedProducts(ascending: Boolean): List<ProductListing> {
       // wait for the previous sort to complete before starting a new one
       return singleRunner.afterPrevious {
           if (ascending) {
               productsDao.loadProductsByDateStockedAscending()
           } else {
               productsDao.loadProductsByDateStockedDescending()
           }
       }
   }
}
複製代碼

每當出現新的排序時,它都會使用SingleRunner 實例來確保一次只運行一個排序。它使用 Mutex,這是一個單一的票據(或鎖),協程必須得到它才能進入代碼塊。若是在一個協程正在運行時嘗試啓動另外一個協程,它將掛起本身,直到全部等待的協程都完成了。

Mutex 容許你確保一次只運行一個協程——而且它們會按啓動的順序結束。

3 號解決方案:返回前面的工做結果

第三個解決方案是使用前面的已經進行工做。若是新請求將從新啓動已經完成一半的相同工做,這是一個好主意。

這種模式對於 sort 函數沒有太大意義,可是對於加載網絡數據來講,它是一種合適的選擇。

對於咱們的產品目錄應用,用戶須要一種辦法從服務器來獲取一個新的產品目錄。做爲一個簡單的 UI 咱們將爲它提供一個刷新按鈕,它們能夠按這個按鈕啓動一個新的網絡請求。

與排序按鈕同樣,只要在請求運行時禁用按鈕,就能夠徹底解決這個問題。可是若是咱們不這樣作,或者不能這樣作,咱們能夠加入現有的請求。

讓咱們來看看一些使用 joinPreviousOrRun 的 gist 代碼,看看它是如何工做的。

class ProductsRepository(val productsDao: ProductsDao, val productsApi: ProductsService) {
   var controlledRunner = ControlledRunner<List<ProductListing>>()

   suspend fun fetchProductsFromBackend(): List<ProductListing> {
       // 若是已經有一個請求,則返回現有的請求的結果。若是沒有則經過容許該塊來啓動一個新請求。
       return controlledRunner.joinPreviousOrRun {
           val result = productsApi.getProducts()
           productsDao.insertAll(result)
           result
       }
   }
}
複製代碼

這將反轉 cancelPreviousAndRun 的行爲。它將丟棄新請求並避免運行它,而不是經過取消它來丟棄之前的請求。若是已經有一個請求在運行,它將等待當前"正在執行"的請求的結果,並返回該結果,而不是運行一個新請求。傳入的代碼塊只有在沒有任何正在運行的請求時纔會被執行。

你能夠在 joinPreviousOrRun 開始時看到它是如何工做的——若是 activeTask 已經存在,那麼它只返回以前這個的結果:

// 查看完整的代碼 https://gist.github.com/objcode/7ab4e7b1df8acd88696cb0ccecad16f7#file-concurrencyhelpers-kt-L124

suspend fun joinPreviousOrRun(block: suspend () -> T): T {
    // 若是有 activeTask ,返回它的結果,不要運行新的代碼塊
    activeTask?.let {
        return it.await()
    }
    // ...
複製代碼

這種模式適用於像按 id 獲取產品這樣的請求。你能夠維護一個id 對應 Deferred 的 Map,而後使用相同的結合邏輯跟蹤相同產品的先前的請求。

結合以前的工做是避免重複網絡請求的一個很好的解決方案。

What’s next?

在本文中,咱們探討了如何使用 Kotlin 協程實現一次性請求。首先,咱們實現了一個完整的模式,展現瞭如何在 ViewModel 中啓動協程,而後從存儲庫和 Room Dao 中暴露常規掛起函數。

對於大多數任務,爲了在 Android 上使用 Kotlin 協程,這就是你須要作的所有工做。這種模式能夠應用於許多常見的任務,好比咱們這裏展現的排序列表。你還可使用它來獲取、保存或更新網絡上的數據。

而後咱們研究了一個可能出現的小錯誤和可能會用到的解決方案。修復這個問題最簡單(一般也是最好)的辦法是在 UI 中——只要在排序過程當中禁用排序按鈕。

最後,咱們研究了一些高級併發模式以及如何在 Kotlin 協程中實現它們。這方面的代碼有點複雜,但它確實爲一些高級協程主題提供了很好的介紹。

在下一篇文章中,咱們將研究流請求,並探索如何使用 LiveData 構建器!

相關文章
相關標籤/搜索