在 Android 上使用協程(三) :Real Work

秉心說TM發表於2019-06-05

這裡是關於在 Android 上使用協程的一系列文章。本篇文章將著重於介紹使用協程來解決實際問題。

該系列其他文章:

在 Android 上使用協程(一):Getting The Background

在 Android 上使用協程(二):Getting started

使用協程解決現實問題

系列前兩篇文章著重於介紹協程如何簡化程式碼,在 Android 上提供主執行緒安全,避免洩露任務。以此為背景,對於在 Android 中處理後臺任務和簡化回撥程式碼,這都是一個很好的解決方案。

到目前為止,我們瞭解了什麼是協程以及如何管理它們。在這篇文章中,我們將看一下如何使用協程來完成真實的任務。協程是一種通用的程式語言特性,和函式同一級別,所以你可以使用協程來實現任何物件或函式可以完成的工作。然而,對下面這兩種真實程式碼中經常出現的任務來說,協程是一個很好的解決方案。

  1. 一次性請求 : 呼叫一次執行一次,它們總是在結果準備好之後才結束執行。
  2. 流式請求 : 觀察變化並反饋給呼叫者,它們直到第一個結果返回才會結束執行。

協程很好的解決了上面這些任務。這篇文章中,我們會深入一次性請求,探討在 Android 上如何實現它。

一次性請求

一次性請求每呼叫一次就會執行一次,結果一旦準備好就會結束執行。這和普通函式呼叫是一樣的模式 —— 呼叫,做一些工作,返回。由於其和函式呼叫的相似性,它比流式請求更容易理解。

一次性請求每次呼叫時執行,結果一旦準備好就會停止執行。

舉個一次性請求的例子,想象一下你的瀏覽器是如何載入網頁的。當你點選連結時,向伺服器傳送了一個網路請求來載入網頁。一旦資料傳輸到了你的瀏覽器,它就停止與後端的互動了,此時它已經擁有了需要的所有資料。如果伺服器修改了資料,新的修改不會在瀏覽器展示,你必須重新整理頁面。

所以,即使一次性請求缺少流式請求的實時推送功能,但它仍然很強大。在 Android 上,你可以使用一次性請求做很多事情,例如查詢,儲存或者更新資料。對於列表排序來說,它也是一種好方案。

問題:展示有序列表

讓我們通過展示有序列表來探索一次性請求。為了使例子更加具體,我們編寫一個產品庫存應用給商店的員工使用。它被用於根據最後一次進貨的時間來查詢貨物。貨物既可以升序排列,也可以降序排列。這兒的貨物太多了以至於排序花費了幾乎一秒,讓我們使用協程來避免阻塞主執行緒。

這個 App 中的所有產品都儲存在資料庫 Room 中。這是一個很好的例子,因為我們不需要進行網路請求,這樣我們就可以專注於設計模式。由於無需網路請求使得這個例子很簡單,儘管這樣,但是它仍然展示了實現一次性請求所使用的模式。

為了使用協程實現這個請求,你需要把協程引入 ViewModelRepositoryDao。讓我們逐個看看它們是如何與協程結合在一起的。

class ProductsViewModel(val productsRepository: ProductsRepository) : ViewModel() {
    private val _sortedProducts = MultableLiveData<List<ProductListing>>()
    val sortedProducts: LiveData<List<ProductListing>> = _sortedProducts
    
    /**
     * Called by the UI when the user clicks the appropriate sort button
     */
    fun onSortAscending() = sortPricesBy(ascending = true)
    fun onSortDescending() = sortPricesBy(ascending = false)
    
    private fun sortPricesBy(ascending: Boolean) {
        viewModelScope.launch {
            // suspend and resume make this database request main-safe
            // so our ViewModel doesn't need to worry about threading
            _sortedProducts.value = 
                productsRepository.loadSortedProducts(ascending)
        }
    }
}
複製程式碼

ProductsViewModel 負責接收使用者層事件,然後請求 repository 更新資料。它使用 LiveData 儲存要在 UI 中進行展示的當前有序列表。當接收到一個新的事件,sortPricesBy 方法會開啟一個新的協程來排序集合,當結果可用時更新 LiveData。由於 ViewModel 可以在 onCleared 回撥中取消協程,所以它是這個架構中啟動協程的好位置。當使用者離開介面的時候,就無需再繼續未完成的任務了。

如果你不是很瞭解 LiveData,這裡有一篇介紹 LiveData 如何為 UI 層儲存資料的好文章,作者是 CeruleanOtter

ViewModels: A Simple Example

這是在 Android 上使用協程的通用模式。由於 Android Framework 無法呼叫 suspend 函式,你需要配合一個協程來響應 UI 事件。最簡單的方法就是當事件發生時啟動一個新的協程,最適合的地方就是 ViewModel 了。

在 ViewModel 中啟動協程是一個通用的設計模式。

ViewModel 實際上通過 ProductsRepository 來獲取資料。讓我們來看一下程式碼:

class ProductsRepository(val productsDao: ProductsDao) {
    /**
     * This is a "regular" suspending function, which means the caller must
     * be in a coroutine. The repository is not responsible for starting or
     * stoppong coroutines since it doesn't have a natural lifecycle to cancel
     * unnecssary work.
     *
     * This *may* be called from Dispatchers.Main abd is main-safe because
     * Room will take care of main-safety for us.
     */
    suspend fun loadSortedProducts(ascending: Boolean): List<ProductListing> {
        return if (ascending) {
            productsDao.loadProductsByDateStockedAscending()
        } else {
            productsDao.loadProductsByDateStockedDescending()

        }
    }
}
複製程式碼

ProductsRepository 為商品資料的互動提供了合理的介面。在這個 App 中,由於所有資料都是儲存在 Room 資料庫中,它提供了具有兩個針對不同排序的方法的 Dao

Repository 是 Android Architecture Components 架構的可選部分。如果你在 app 中使用了 repository 或者相似作用的層級,它更偏向於使用掛起函式。由於 repository 沒有生命週期,它僅僅只是一個物件,所有它沒有辦法做資源清理工作。在 repository 中啟動的協程將有可能洩露。

使用掛起函式,除了避免洩露以外,在不同上下文中也可以重複使用 repository 。任何知道如何建立協程的都可以呼叫 loadSortedProducts,例如 WorkManager 庫啟動了後臺任務。

Repository 應該使用掛起函式來保證主執行緒安全。

注意: 當使用者離開介面時,一些後臺執行的儲存操作可能想繼續執行,這種情況下,脫離生命週期執行是有意義的。在大多數情況下,viewModelScope 都是一個好選擇。

再來看看 ProductsDao

@Dao
interface ProductsDao {
    // Because this is marked suspend, Room will use it's own dispatcher
    // to run this query in a main-safe way,
    @Query("select * from ProductListing ORDER BY dataStocked ASC")
    suspend fun loadProductsByDateStockedAsceding(): List<ProductListing>
    
    // Because this is marked suspend, Room will use it's own dispatcher
    // to run this query in a main-safe way,
    @Query("select * from ProductListing ORDER BY dataStocked DESC")
    suspend fun loadProductsByDateStockedDesceding(): List<ProductListing>
}
複製程式碼

ProductsDao 是一個 Room Dao,它對外提供了兩個掛起函式。由於函式由 suspend 修飾,Room 會確保它們主執行緒安全。這就意味著你可以直接在 Dispatchers.Main 中呼叫它們。

如果你沒有在 Room 中使用過協程,閱讀一下 FMuntenescu 的這篇文章:

Room && Coroutines

不過需要注意這一點,呼叫它的協程將執行在主執行緒。所以如果你要對結果進行一些昂貴的操作,例如轉換成集合,你要確保不會阻塞主執行緒。

注意:Room 使用自己的排程器在後臺執行緒進行查詢操作。你不應該再使用 withContext(Dispatchers.IO) 來呼叫 Room 的 suspend 查詢,這隻會讓你的程式碼執行的更慢。

Room 中的掛起函式是主執行緒安全的,它執行在自定義的排程器中。

一次性請求模式

這就是在 Android Architecture Components 中使用協程進行一次性請求的完整模式。我們將協程新增到 ViewModelRepositoryRoom 中,每一層都有不同的責任。

  1. ViewModel 在主執行緒啟動協程,一旦有了結果就結束。
  2. Repository 提供掛起函式並保證它們主執行緒安全。
  3. 資料庫和網路層提供掛起函式並保證它們主執行緒安全。

ViewModel 負責啟動協程,保證使用者離開介面時取消協程。它本身不做昂貴的操作,而是依賴其他層來做。一旦有了結果,就使用 LiveData 傳送給 UI 介面。也正因為 ViewModel 不做昂貴的操作,所以它在主執行緒啟動協程。通過在主執行緒啟動,當結果可用它可以更快的響應使用者事件(例如記憶體快取)。

Repository 提供掛起函式來訪問資料。它通常不會啟動長生命週期的協程,因為它沒有辦法取消它們。無論何時 Repository 需要做昂貴的操作(集合轉換等),它都需要使用 withContext 來提供主執行緒安全的介面。

資料層(網路或者資料庫)總是提供掛起函式。使用 Kotlin 協程的時候需要保證這些掛起函式是主執行緒安全的,Room 和 Retrofit 都遵循了這一原則。

在一次性請求中,資料層只提供掛起函式。如果想要獲取新值,就必須再呼叫一次。這就像瀏覽器中的重新整理按鈕。

花點時間讓你明白一次性請求的模式是值得的。這在 Android 協程中是通用的模式,你也會一直使用它。

第一個 Bug Report

在測試過該解決方案之後,你將其用到生產環境,幾周內都執行良好,直到你收到了一個非常奇怪的錯誤報告:

Subject: ? — 排序錯誤!

Report: 當我非常非常非常非常快速點選排序按鈕時,排序偶爾是錯誤的。這並不是每次都會發生。

你看了看,撓撓頭,哪裡可能發生錯誤了呢?這個邏輯看起來相當簡單:

  1. 開始使用者請求的排序
  2. 在 Room 排程器中開始排序
  3. 展示排序結果

你正準備關閉這個 bug,關閉理由是 “不予處理 —— 不要快速點選按鈕”,但是你又擔心的確是哪裡出了什麼問題。在新增了日誌以及編寫測試用例來測試一次性發起許多排序請求,你最終找到了原因。

最後獲得的結果實際上並不是 “排序的結果”,而是 “完成最後一次排序時” 的結果。當使用者狂點按鈕時,同時發起了多次排序,可能以任意順序結束。(譯者注:可以想象成 Java 中的多執行緒併發)

當啟動一個新協程來響應使用者事件時,要考慮到使用者在該協程未結束之前又啟動一個協程會發生什麼。

這是一個併發導致的 bug,實際上它和協程並沒有什麼關係。當我們以同樣的方式使用回撥,Rx,甚至 ExecutorService,都可能會有這樣的 bug。讓我們探索一下下面這些方案是如何保證一次性請求按使用者所期望的順序執行的。

最佳方案:禁用按鈕

核心問題就是我們如何進行兩次排序。我們可以讓它僅僅只進行一次排序!最簡單的方法就是禁用排序按鈕,停止傳送新事件。

這似乎是一個很簡單的方案,但它的確是個好主意。程式碼實現也很簡單,易於測試。

要禁用按鈕,可以通知 UI sortPricesBy 中正在進行一次排序請求,如下所示:

// Solution 0: Disable the sort buttons when any sort is running

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

   /**
    * Called by the UI when the user clicks the appropriate sort button
    */
   fun onSortAscending() = sortPricesBy(ascending = true)
   fun onSortDescending() = sortPricesBy(ascending = false)

   private fun sortPricesBy(ascending: Boolean) {
       viewModelScope.launch {
           // disable the sort buttons whenever a sort is running
           _sortButtonsEnabled.value = false
           try {
               _sortedProducts.value =
                       productsRepository.loadSortedProducts(ascending)
           } finally {
               // re-enable the sort buttons after the sort is complete
               _sortButtonsEnabled.value = true
           }
       }
   }
}
複製程式碼

這看起來還不賴。只需在呼叫 repository 時在 sortPricesBy 內部禁用按鈕。

大多數情況下,這都是解決問題的好方案。但是我們想在按鈕可用的情況下來解決這個 bug 呢?這有一點點困難,我們將在本文剩餘部分來看幾種方式。

Important :This code shows a major advantage of starting on main — the buttons disable instantly in response to a click. If you switched dispatchers, a fast-fingered user on a slow phone could send more than one click!

併發模式

下面幾節將探討一些高階話題。如果你才剛剛開始使用協程,你不必完全理解。簡單的禁用按鈕就是你遇到的大部分問題的良好解決方案。

在本文的剩餘部分,我們將討論在不禁用按鈕的前提下,如何去保證一次性請求正常執行。我們可以通過控制協程何時執行(或者不執行)來避免意外的併發情況。

下面有三種模式,你可以在一次性請求中使用它們來確保同一時間只進行一次請求。

  1. 在啟動更多協程之前先取消上一個。
  2. 將下一個任務放入等待佇列,直到前一個請求執行完成在開始另一個。
  3. 如果已經有一個請求在執行,那麼就返回該請求,而不是啟動另一個請求。

想一下這些解決方案,你會發現它們的實現相對都比較複雜。為了專注於設計模式而不是實現細節,我建立了 gist 來提供這三種模式的實現作為可用抽象。(這裡可以大概瀏覽一下 gist 中的程式碼實現)

方案一 : 取消前一個任務

在排序的情況下,從使用者那獲取了一個新的事件,就意味著你可以取消上一個請求了。畢竟,使用者已經不想知道上一個任務的結果了,繼續下去還有什麼意義呢?

為了取消上一個請求,我們首先要以某種方式追蹤它。gist 中的 cancelPreviousThenRun 函式就是這麼做的。

讓我們看看它是如何被用來修復 bug 的:

// Solution #1: Cancel previous work

// This is a great solution for tasks like sorting and filtering that
// can be cancelled if a new request comes in.

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 中的 實現,你可以瞭解到它是如何追蹤正在工作的任務的。

// see the complete implementation at
// https://gist.github.com/objcode/7ab4e7b1df8acd88696cb0ccecad16f7
suspend fun cancelPreviousThenRun(block: suspend () -> T): T {
   // If there is an activeTask, cancel it because it's result is no longer needed
   activeTask?.cancelAndJoin()
   
   // ...
複製程式碼

簡而言之,它總是追蹤成員變數 activeTask 中的當前排序。無論何時開始一次新的排序,都會立即 cancelAndJoin activeTask 中的所有內容。這會造成的影響就是,在開啟一次新的排序之前會取消所有正在進行的排序。

使用類似 ControlledRunner<T> 的抽象實現來封裝邏輯是個好方法,而不是將併發性和程式邏輯混雜在一起。

重要:這個模式不適合在全域性單例中使用,因為不相關的呼叫者不應該互相取消。

方案二 :將下一個任務入隊

這裡有一個對於併發 bug 總是有效的解決方案。

只需要將請求排隊,這樣同時只會進行一個請求。就像商店中排隊一樣,請求將按它們排隊的順序依次執行。

對於這種特定的排隊問題,取消可能比排隊更好。但值得一提的是它總是可以保證正常工作。

// Solution #2: Add a Mutex

// Note: This is not optimal for the specific use case of sorting
// or filtering but is a good pattern for network saves.

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(互斥鎖) 是一個單程票,或者說鎖,協程必須獲取鎖才能進入程式碼塊。如果一個協程在執行時另一個協程嘗試進入,它將掛起自己直到所有等待的協程都完成。

Mutex 保證同時只有一個協程執行,並且它們將按啟動的順序結束。

方案三 :加入前一個任務

第三種解決方案是加入前一個任務。如果新請求可以重複使用已經存在的,已經完成了一半的相同的任務,這會是一個好主意。

這種模式對於排序功能來說並沒有太大意義,但是對於網路請求來說是很適用的。

對於我們的產品庫存應用,使用者需要一種方式來從伺服器獲取最新的產品庫存資料。我們提供了一個重新整理按鈕,使用者可以點選來發起一次新的網路請求。

就和排序按鈕一樣,當請求正在進行的時候,禁用按鈕就可以解決問題。但是如果我們不想這樣,或者不能這樣,我們可以選擇加入已經存在的請求。

檢視 gist 中使用 joinPreviousOrRun 的程式碼,看看它是如何工作的:

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

   suspend fun fetchProductsFromBackend(): List<ProductListing> {
       // if there's already a request running, return the result from the 
       // existing request. If not, start a new request by running the block.
       return controlledRunner.joinPreviousOrRun {
           val result = productsApi.getProducts()
           productsDao.insertAll(result)
           result
       }
   }
}
複製程式碼

這與 cancelPreviousAndRun 的行為相反。cancelPreviousAndRun 會通過取消直接放棄前一個請求,而 joinPreviousOrRun 將會放棄新請求。如果已經存在正在執行的請求,它將會等待執行結果並返回,而不是發起一次新的請求。只有在沒有正在執行的請求時才會執行程式碼塊。

在下面的程式碼中你可以看到 joinPreviousOrRun 中的任務是如何工作的。它僅僅只是當 activeTask 中存在任務的時候,直接返回前一個請求的結果。

// see the complete implementation at
// https://gist.github.com/objcode/7ab4e7b1df8acd88696cb0ccecad16f7#file-concurrencyhelpers-kt-L124

suspend fun joinPreviousOrRun(block: suspend () -> T): T {
    // if there is an activeTask, return it's result and don't run the block
    activeTask?.let {
        return it.await()
    }
    // ...
複製程式碼

這個模式很適合通過 id 查詢產品的請求。你可以使用 map 來儲存 idDeferred 的對映關係,然後使用相同的 join 邏輯來追蹤同一個產品的之前的請求。

加入前面的任務可以有效避免重複的網路請求。

## What's next ?

在這篇文章中,我們探討了如何使用 Kotlin 協程來實現一次性請求。首先我們通過在 ViewModel 中啟動協程,通過 Repository 和 Room Dao 提供公開的掛起函式來實現了一個完整的設計模式。

對於大多數任務,為了在 Android 上使用 Kotlin 協程,這就是全部你所需要做的。這個模式可以應用在許多場景,就像上面說過的排序。你也可以使用它查詢,儲存,更新網路資料。

然後我們看了一個可能出現的 bug 及其解決方案。最簡單的(經常也是最好的)方案就是從 UI 上修改,當一個排序正在執行時直接禁用排序按鈕。

最後,我們研究了一些高階併發模式,以及如何在 Kotlin 協程中實現。程式碼 有點複雜,但為一些高階協程方面的話題提供了很好的介紹。

下一篇中,讓我們進入流式請求,以及如何使用 liveData 構建器 !

文章首發微信公眾號: 秉心說 , 專注 Java 、 Android 原創知識分享,LeetCode 題解。

更多原創文章,掃碼關注我吧!

在 Android 上使用協程(三) :Real Work

相關文章