前兩篇文章主要是介紹瞭如何使用協程來簡化代碼,在 Android 上保證主線程安全,避免任務泄漏。以此爲背景,咱們認爲使用協程是在處理後臺任務和簡化 Android 回調代碼的絕佳方案。html
目前爲止,咱們主要集中在介紹協程是什麼,以及如何管理它們,本文咱們將介紹如何使用協程來完成一些實際任務。協程同函數同樣,是在編程語言特性中的一個經常使用特性,您可使用它來實現任何能夠經過函數和對象能實現的功能。可是,在實際編程中,始終存在兩種類型的任務很是適合使用協程來解決:android
協程對於處理這些任務是一個絕佳的解決方案。在這篇文章中,咱們將會深刻介紹一次性請求,並探索如何在 Android 中使用協程實現它們。git
一次性請求會調用一次就請求一次,獲取到結果後就結束執行。這個模式同調用常規函數很像 —— 調用一次,執行,而後返回。正由於同函數調用類似,因此相對於流式請求它更容易理解。github
一次性請求會調用一次就請求一次,獲取到結果後就結束執行。數據庫
舉例來講,您能夠把它類比爲瀏覽器加載頁面。當您點擊了這篇文章的連接後,瀏覽器向服務器發送了網絡請求,而後進行頁面加載。一旦頁面數據傳輸到瀏覽器後,瀏覽器就有了全部須要的數據,而後中止同後端服務的對話。若是服務器後來又修改了這篇文章的內容,新的更改是不會顯示在瀏覽器中的,除非您主動刷新了瀏覽器頁面。編程
儘管這樣的方式缺乏了流式請求那樣的實時推送特性,可是它仍是很是有用的。在 Android 的應用中您能夠用這種方式解決不少問題,好比對數據的查詢、存儲或更新,它還很適用於處理列表排序問題。後端
咱們經過一個展現有序列表的例子來探索一下如何構建一次性請求。爲了讓例子更具體一些,咱們來構建一個用於商店員工使用的庫存應用,使用它可以根據上次進貨的時間來查找相應商品,並可以以升序和降序的方式排列。由於這個倉庫中存儲的商品不少,因此對它們進行排序要花費將近 1 秒鐘,所以咱們須要使用協程來避免阻塞主線程。設計模式
在應用中,全部的數據都會存儲到 Room 數據庫中。因爲不涉及到網絡請求,所以咱們不須要進行網絡請求,從而專一於一次性請求這樣的編程模式。因爲無需進行網絡請求,這個例子會很簡單,儘管如此它仍然展現了該使用怎樣的模式來實現一次性請求。瀏覽器
爲了使用協程來實現此需求,您須要在協程中引入 ViewModel、Repository 和 Dao。讓咱們逐個進行介紹,看看如何把它們同協程整合在一塊兒。安全
class ProductsViewModel(val productsRepository: ProductsRepository): ViewModel() {
private val _sortedProducts = MutableLiveData<List<ProductListing>>()
val sortedProducts: LiveData<List<ProductListing>> = _sortedProducts
/**
* 當用戶點擊相應排序按鈕後,UI 進行調用
*/
fun onSortAscending() = sortPricesBy(ascending = true)
fun onSortDescending() = sortPricesBy(ascending = false)
private fun sortPricesBy(ascending: Boolean) {
viewModelScope.launch {
// suspend 和 resume 使得這個數據庫請求是主線程安全的,因此 ViewModel 不須要關心線程安全問題
_sortedProducts.value =
productsRepository.loadSortedProducts(ascending)
}
}
}
複製代碼
ProductsViewModel 負責從 UI 層接受事件,而後向 repository 請求更新的數據。它使用 LiveData 來存儲當前排序的列表數據,以供 UI 進行展現。當出現某個新事件時,sortProductsBy 會啓動一個新的協程對列表進行排序,當排序完成後更新 LiveData。在這種架構下,一般都是使用 ViewModel 啓動協程,由於這樣作的話能夠在 onCleared 中取消所啓動的協程。當用戶離開此界面後,這些任務就不必繼續進行了。
若是您以前沒有用過 LiveData,您能夠看看這篇由 @CeruleanOtter 寫的文章,它介紹了 LiveData 是如何爲 UI 保存數據的 —— ViewModels: A Simple Example。
@CeruleanOtter :
ViewModels: A Simple Example:
這是在 Android 上使用協程的通用模式。因爲 Android framework 不會主動調用掛起函數,因此您須要配合使用協程來響應 UI 事件。最簡單的方法就是來一個事件就啓動一個新的協程,最適合處理這種狀況的地方就是 ViewModel 了。
在 ViewModel 中啓動協程是很通用的模式。
ViewModel 實際上使用了 ProductsRepository 來獲取數據,示例代碼以下:
class ProductsRepository(val productsDao: ProductsDao) {
/**
這是一個普通的掛起函數,也就是說調用方必須在一個協程中。repository 並不負責啓動或者中止協程,由於它並不負責對協程生命週期的掌控。
這可能會在 Dispatchers.Main 中調用,一樣它也是主線程安全的,由於 Room 會爲咱們保證主線程安全。
*/
suspend fun loadSortedProducts(ascending: Boolean): List<ProductListing> {
return if (ascending) {
productsDao.loadProductsByDateStockedAscending()
} else {
productsDao.loadProductsByDateStockedDescending()
}
}
}
複製代碼
ProductsRepository 提供了一個合理的同商品數據進行交互的接口,此應用中,全部內容都存儲在本地 Room 數據庫中,它爲 @Dao 提供了針對不一樣排序具備不一樣功能的兩個接口。
repository 是 Android 架構組件中的一個可選部分,若是您在應用中已經集成了它或者其餘的類似功能的模塊,那麼它應該更偏向於使用掛起函數。由於 repository 並無生命週期,它僅僅是一個對象,因此它不能處理資源的清理工做,因此默認狀況下,repository 中啓動的全部協程都有可能出現泄漏。
使用掛起函數除了避免泄漏以外,在不一樣的上下文中也能夠重複使用 repository,任何知道如何建立協程的均可以調用 loadSortedProducts,例如 WorkManager 所調度管理的後臺任務就能夠直接調用它。
repository 應該使用掛起函數來保證主線程安全。
注意: 當用戶離開界面後,有些在後臺中處理數據保存的操做可能還要繼續工做,這種狀況下脫離了應用生命週期來運行是沒有意義的,因此大部分狀況下 viewModelScope 都是一個好的選擇。
再來看看 ProductsDao,示例代碼以下:
@Dao
interface ProductsDao {
// 由於這個方法被標記爲了 suspend,Room 將會在保證主線程安全的前提下使用本身的調度器來運行這個查詢
@Query("select * from ProductListing ORDER BY dateStocked ASC")
suspend fun loadProductsByDateStockedAscending(): List<ProductListing>
// 由於這個方法被標記爲了 suspend,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
@FMuntenescu
Room 🔗 Coroutines
不過要注意的是,調用它的協程將會在主線程上執行。因此,若是您要對執行結果作一些比較耗時的操做,好比對列表內容進行轉換,您要確保這個操做不會阻塞主線程。
注意: Room 使用了本身的調度器在後臺線程上進行查詢操做。您不該該再使用 withContext(Dispatchers.IO) 來調用 Room 的 suspend 查詢,這隻會讓您的代碼變複雜,也會拖慢查詢速度。
Room 的掛起函數是主線程安全的,並運行於自定義的調度器中。
這是在 Android 架構組件中使用協程進行一次性請求的完整模式,咱們將協程添加到了 ViewModel、Repository 和 Room 中,每一層都有着不一樣的責任分工。
ViewModel 負責啓動協程,並保證用戶離開了相應界面時它們就會被取消。它自己並不會作一些耗時的操做,而是依賴別的層級來作。一旦有告終果,就使用 LiveData 將數據發送到 UI 層。由於 ViewModel 並不作一些耗時操做,因此它是在主線程啓動協程的,以便可以更快地響應用戶事件。
Repository提供了掛起函數用來訪問數據,它一般不會啓動一些生命週期比較長的協程,由於它們一旦啓動了便沒法取消。不管什麼時候 Repository 想要作一些耗時操做,好比對列表內容進行轉換,都應該使用 withContext 來提供主線程安全的接口。
數據層 (網絡或數據庫) 老是會提供掛起函數,使用 Kotlin 協程的時候要保證這些掛起函數是主線程安全的,Room 和 Retrofit 都遵循了這一點。
在一次性請求中,數據層只提供掛起函數,調用方若是想要獲取最新的值,只能再次進行調用,這就像瀏覽器中的刷新按鈕同樣。
花點時間讓您瞭解一次性請求的模式是值得,它在 Android 協程中是比較通用的模式,您會一直用到它。
在通過測試後,您部署到了生產環境,運行了幾周都感受良好,直到您收到了一個很奇怪的 bug 報告:
標題: 🐞 — 排序錯誤!
錯誤報告: 當我很是快速地點擊排序按鈕時,排序的結果偶爾是錯的,這還不是每次都能復現的🙃。
您研究了一下,不由問本身哪裏出錯了?這個邏輯很簡單:
您以爲這個 bug 不存在準備關閉它,由於解決方案很簡單,"不要那麼快地點擊按鈕",可是您仍是很擔憂,以爲仍是哪一個地方出了問題。因而在代碼中加入一些日誌,並跑了一堆測試用例後,您終於知道問題出在什麼地方了!
看起來應用內展現的排序結果並非真正的 "排序結果",而是上一次完成排序的結果。當用戶快速點擊按鈕時,就會同時觸發多個排序操做,這些操做可能以任意順序結束。
當啓動一個新的協程來響應 UI 事件時,要去考慮一下用戶若在上一個任務未完成以前又開始了新的任務,會有什麼樣的後果。
這實際上是一個併發致使的問題,它和是否使用了協程其實沒有什麼關係。若是您使用回調、Rx 或者是 ExecutorService,仍是可能會遇到這樣的 bug。
有很是多方案可以解決這個問題,既能夠在 ViewModel 中解決,又能夠在 Repository 中解決。咱們來看看怎麼才能讓一次性請求按照咱們所指望的順序返回結果。
核心問題出在咱們作了兩次排序,要修復的話咱們能夠只讓它排序一次。最簡單的解決方法就是禁用按鈕,不讓它發出新的事件就能夠了。
這看起來很簡單,並且確實是個好辦法。實現起來的代碼也很簡單,還容易測試,只要它能在 UI 中體現出來這個按鈕的狀態,就徹底能夠解決問題。
要禁用按鈕,只須要告訴 UI 在 sortPricesBy 中是否有正在處理的排序請求,示例代碼以下:
// 方案 0: 當有任何排序正在執行時,禁用排序按鈕
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
}
/**
當用戶點擊排序按鈕時,調用
*/
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 中的 _sortButtonsEnabled 在排序時禁用按鈕
好了,這看起來還行,只須要在調用 repository 時在 sortPricesBy 內部禁用按鈕就行了。
大部分狀況下,這都是最佳解決方案,可是若是咱們想在保持按鈕可用的前提下解決 bug 呢?這樣的話有一點困難,在本文剩餘的部分看看該怎麼作。
注意: 這段代碼展現了從主線程啓動的巨大優點,點擊以後按鈕馬上變得不可點了。但若是您換用了其餘的調度程序,當出現某個手速很快的用戶在運行速度較慢的手機上操做時,仍是可能出現發送屢次點擊事件的狀況。
下面幾個章節咱們探討一些比較高級的話題,若是您纔剛剛接觸協程,能夠不去理解這一部分,使用禁用按鈕這一方案就是解決大部分相似問題的最佳方案。
在剩餘部分咱們將探索在不由用按鈕的前提下,確保一次性請求可以正常運行。咱們能夠經過控制什麼時候讓協程運行 (或者不運行) 來避免剛剛出現的併發問題。
有三個基本的模式可讓咱們確保在同一時間只會有一次請求進行:
當介紹完這三個方案後,您可能會發現它們的實現都挺複雜的。爲了專一於設計模式而不是實現細節,我建立了一個 gist 來提供這三個模式的實現做爲可重用抽象 。
方案 1: 取消以前的任務
在排序這種狀況下,獲取新的事件後就意味着能夠取消上一個排序任務了。畢竟用戶經過這樣的行爲已經代表了他們不想要上次的排序結果了,繼續進行上一次排序操做沒什麼意義了。
要取消上一個請求,咱們首先要以某種方式追蹤它。在 gist 中的 cancelPreviousThenRun 函數就作到了這個。
來看看如何使用它修復這個 bug:
// 方案 1: 取消以前的任務
// 對於排序和過濾的狀況,新請求進來,取消上一個,這樣的方案是很適合的。
class ProductsRepository(val productsDao: ProductsDao, val productsApi: ProductsService) {
var controlledRunner = ControlledRunner<List<ProductListing>>()
suspend fun loadSortedProducts(ascending: Boolean): List<ProductListing> {
// 在開啓新的排序以前,先取消上一個排序任務
return controlledRunner.cancelPreviousThenRun {
if (ascending) {
productsDao.loadProductsByDateStockedAscending()
} else {
productsDao.loadProductsByDateStockedDescending()
}
}
}
}
複製代碼
使用 cancelPreviousThenRun 來確保同一時間只有一個排序任務在進行
看一下 gist 中 cancelPreviousThenRun 中的代碼實現,您能夠學習到如何追蹤正在工做的任務。
// see the complete implementation at
// 在 https://gist.github.com/objcode/7ab4e7b1df8acd88696cb0ccecad16f7 中查看完整實現
suspend fun cancelPreviousThenRun(block: suspend () -> T): T {
// 若是這是一個 activeTask,取消它,由於它的結果已經不須要了
activeTask?.cancelAndJoin()
// ...
複製代碼
簡而言之,它會經過成員變量 activeTask 來保持對當前排序的追蹤。不管什麼時候開始一個新的排序,都當即對當前 activeTask 中的全部任務執行 cancelAndJoin 操做。這樣會在開啓一次新的排序以前就會把正在進行中的排序任務給取消掉。
使用相似於 ControlledRunner 這樣的抽象實現來對邏輯進行封裝是比較好的方法,比直接混雜併發與應用邏輯要好不少。
選擇使用抽象來封裝代碼邏輯,避免混雜併發和應用邏輯代碼。
注意: 這個模式不適合在全局單例中使用,由於不相關的調用方是不該該相互取消。
方案 2: 讓下一個任務排隊等待
這裏有一個對併發問題老是有效的解決方案。
讓任務去排隊等待依次執行,這樣同一時間就只會有一個任務會被處理。就像在商場裏進行排隊,請求將會按照它們排隊的順序來依次處理。
對於這種特定的排序問題,其實選擇方案 1 比使用本方案要更好一些,但仍是值得介紹一下這個方法,由於它老是可以有效的解決併發問題。
// 方案 2: 使用互斥鎖
// 注意: 這個方法對於排序或者是過濾來講並非一個很好的解決方案,可是它對於解決網絡請求引發的併發問題很是適合。
class ProductsRepository(val productsDao: ProductsDao, val productsApi: ProductsService) {
val singleRunner = SingleRunner()
suspend fun loadSortedProducts(ascending: Boolean): List<ProductListing> {
// 開始新的任務以前,等待以前的排序任務完成
return singleRunner.afterPrevious {
if (ascending) {
productsDao.loadProductsByDateStockedAscending()
} else {
productsDao.loadProductsByDateStockedDescending()
}
}
}
}
複製代碼
不管什麼時候進行一次新的排序, 都使用一個 SingleRunner 實例來確保同時只會有一個排序任務在進行。
它使用了 Mutex,能夠把它理解爲一張單程票 (或是鎖),協程在必需要獲取鎖才能進入代碼塊。若是一個協程在運行時,另外一個協程嘗試進入該代碼塊就必須掛起本身,直到全部的持有 Mutex 的協程完成任務,並釋放 Mutex 後才能進入。
Mutex 保證同時只會有一個協程運行,而且會按照啓動的順序依次結束。
方案 3: 複用前一個任務
第三種能夠考慮的方案是複用前一個任務,也就是說新的請求能夠重複使用以前存在的任務,好比前一個任務已經完成了一半進來了一個新的請求,那麼這個請求直接重用這個已經完成了一半的任務,就省事不少。
但其實這種方法對於排序來講並無多大意義,可是若是是一個網絡數據請求的話,就很適用了。
對於咱們的庫存應用來講,用戶須要一種方式來從服務器獲取最新的商品庫存數據。咱們提供了一個刷新按鈕這樣的簡單操做來讓用戶點擊一次就能夠發起一次新的網絡請求。
當請求正在進行時,禁用按鈕就能夠簡單地解決問題。可是若是咱們不想這樣,或者說不能這樣,咱們就能夠選擇這種方法複用已經存在的請求。
查看下面的來自 gist 的使用了 joinPreviousOrRun 的示例代碼:
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 相反,它會直接使用以前的請求而放棄新的請求,而 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 來查詢商品數據的請求。您可使用 map 來創建 id 到 Deferred 的映射關係,而後使用相同的邏輯來追蹤同一個產品以前的請求數據。
直接複用以前的任務能夠有效避免重複的網絡請求。
在這篇文章中,咱們探討了如何使用 Kotlin 協程來實現一次性請求。咱們實現瞭如何在 ViewModel 中啓動協程,而後在 Repository 和 Room Dao 中提供公開的 suspend function,這樣造成了一個完整的編程範式。
對於大部分任務來講,在 Android 上使用 Kotlin 協程按照上面這些方法就已經足夠了。這些方法就像上面所說的排序同樣能夠應用在不少場景中,您也可使用這些方法來解決查詢、保存、更新網絡數據等問題。
而後咱們探討了一下可能出現 bug 的地方,並給出瞭解決方案。最簡單 (每每也是最好的) 的方案就是從 UI 上直接更改,排序運行時直接禁用按鈕。
最後,咱們探討了一些高級併發模式,並介紹瞭如何在 Kotlin 協程中實現它們。雖然這些代碼有點複雜,可是爲一些高級協程方面的話題作了很好的介紹。
在下一篇文章中,咱們將會研究一下流式請求,並探索如何使用 liveData 構造器,感興趣的讀者請繼續關注咱們的更新。
點擊這裏查看 Android 官方中文文檔 —— 利用 Kotlin 協程提高應用性能