Kotlin 協程五 —— 在Android 中使用 Kotlin 協程

Kotlin 協程系列文章導航:
Kotlin 協程一 —— 協程 Coroutine
Kotlin 協程二 —— 通道 Channel
Kotlin 協程三 —— 數據流 Flow
Kotlin 協程四 —— Flow 和 Channel 的應用
Kotlin 協程五 —— 在Android 中使用 Kotlin 協程

一、Android MVVM 結構

Android 官方提供的架構圖

二、添加依賴

如需在 Android 項目中使用協程,請將以下依賴項添加到應用的 build.gradle 文件中:

dependencies {
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.9")
}

三、在後臺線程中執行

3.1 協程解決了什麼問題

在安卓中,協程很好的解決了兩個問題:

  1. 耗時任務,運行時間過長阻塞主線程
  2. 主線程安全,允許你在主線程中調用任意 suspend(掛起) 函數

獲取網頁,和 API 進行交互,都涉及到了網絡請求。同樣的,從數據庫讀取數據,從硬盤中加載圖片,都涉及到了文件讀取。這些就是我們所說的耗時任務。
爲了避免在主線程中進行網絡請求,一種通用的模式是使用 CallBack(回調),它可以在將來的某一時間段回調進入你的代碼。

class ViewModel: ViewModel() {
   fun getDataAndShow() {
       getApi() { result ->
           show(result)
       }
    }
}

儘管 getApi() 方法是在主線程調用的,但它會在另一個線程中進行網絡請求。一旦網絡請求的結果可用了,回調就會在主線程中被調用。這是處理耗時任務的一種好方式。
用協程來處理耗時任務可以簡化代碼。以上面的 fetchDocs() 方法爲例,我們使用協程來重寫之前的回調邏輯。

// Dispatchers.Main
suspend fun getDataAndShow() {
    // Dispatchers.IO
    val result = getApi()
    // Dispatchers.Main
    show(result)
}
// look at this in the next section
suspend fun getApi() = withContext(Dispatchers.IO){/*...*/}

協程中講到,執行到 withContext 會掛起,執行完後會恢復。

  • suspend —— 掛起當前協程的執行,保存所有局部變量
  • resume —— 從被掛起協程掛起的地方繼續執行

協程的掛起和恢復共同工作來替代回調。使得我們用同步的方式來寫異步的代碼。

3.2 保證主線程安全

使用 suspend 並不意味着告訴 Kotlin 一定要在後臺線程運行函數。爲了讓一個函數不會使主線程變慢,我們可以告訴 Kotlin 協程使用 Default 或者 IO 調度器。

  • Room 在你使用 掛起函數 、RxJava 、LiveData 時自動提供主線程安全。
  • Retrofit 和 Volley 等網絡框架一般自己管理線程調度,當你使用 Kotlin 協程的時候不需要再顯式保證主線程安全。

通過協程,可以細粒度的控制線程調度,因爲 withContext 讓你可以控制任意一行代碼運行在什麼線程上,而不用引入回調來獲取結果。可將其應用在很小的函數中,例如數據庫操作和網絡請求。所以,比較好的做法是,使用 withContext 確保每個函數在任意調度器上執行都是安全的,包括 Main,這樣調用者在調用函數時就不需要考慮應該運行在什麼線程上。

編寫良好的掛起函數被任意線程調用都應該是安全的。

3.3 withContext 的性能

如果一個函數將對數據庫進行10次調用,那麼您可以告訴 Kotlin 在外部的 withContext 中調用一次切換。儘管數據庫會重複調用 withContext ,但是他它將在同一個調度器下,尋找最快路徑。此外,Dispatchers.Default 和 Dispatchers.IO 之間的協程切換已經過優化,以儘可能避免線程切換。

四、結構化併發

4.1 追蹤協程

使用代碼手動追蹤一千個協程的確是很困難的。你可以嘗試去追蹤它們,並且手動保證它們最後會完成或者取消,但是這樣的代碼冗餘,而且容易出錯。如果你的代碼不夠完美,你將失去對一個協程的追蹤,我把它稱之爲任務泄露。

泄露的協程會浪費內存,CPU,磁盤,甚至發送一個不需要的網絡請求。

爲了避免泄露協程,Kotlin 引入了 structured concurrency(結構化併發)。結構化並集合了語言特性和最佳實踐,遵循這個原則將幫助你追蹤協程中的所有任務。

在 Android 中,我們使用結構化併發可以做三件事:

  1. 取消不再需要的任務
  2. 追蹤所有正在進行的任務
  3. 協程失敗時的錯誤信號

4.2 通過作用域取消任務

在 Kotlin 中,協程必須運行在 CoroutineScope 中。CoroutineScope 會追蹤你的協程,即使協程已經被掛起。爲了保證所有的協程都被追蹤到,Kotlin 不允許你在沒有 CoroutineScope 的情況下開啓新的協程。你可以把 CoroutineScope 想象成具有特殊能力的輕量級的 ExecutorServicce。它賦予你創建新協程的能力,這些協程都具備我們在上篇文章中討論過的掛起和恢復的能力。CoroutineScope 會追蹤所有的協程,並且它也可以取消所有由他開啓的協程。這很適合 Android 開發者,當用戶離開當前頁面後,可以保證清理掉所有已經開啓的東西。

CoroutineScope 會追蹤所有的協程,並且它也可以取消所有由他開啓的協程。

值得注意的是,已經取消的作用域,不能再啓動新協程,如果只是想取消作用域內的某個協程,需要使用協程的 Job 對其進行取消。

4.2.1 啓動新協程

啓動協程有兩種方法,且有不同的用法:

  1. 使用 launch 協程構建器啓動一個新的協程,這個協程是沒返回值的
  2. 使用 async 協程構建器啓動一個新的協程,它允許你返回一個結果,通過掛起函數 await 來獲取。

在大多數情況下,如何從一個普通函數啓動協程的答案都是使用 launch,因爲普通函數是不能調用 await 的。準確的說,是普通函數不能調用掛起函數。

fun foo(scope: CoroutineScope) {
    scope.launch {
        test()  // 允許
    }

    val task = scope.async { }
    task.await() // 不允許
    
    test() // 不允許
}

launch 連接了普通函數中的代碼和協程的世界。在 launch 內部,你可以調用掛起函數。因爲 launch 啓動了一個協程。

這很好理解,掛起函數只能直接或者間接地在協程中被調用。

Launch 是把普通函數帶進協程世界的橋樑。

提示:launch 和 async 很大的一個區別是異常處理。async 期望你通過調用 await 來獲取結果(或異常),所以它默認不會拋出異常。這就意味着使用 async 啓動新的協程,它會悄悄的把異常丟棄。

假設我們編寫了一個 suspend 方法,出現了異常

val unrelatedScope = MainScope()

// example of a lost error
suspend fun lostError() {
    // async without structured concurrency
    unrelatedScope.async {
        throw InAsyncNoOneCanHearYou("except")
    }
}

注意,上面的代碼中聲明瞭一個未經關聯的協程作用域,並且未通過結構化併發啓動新協程。
上面代碼中的錯誤會丟失,因爲 async 認爲你會調用 await,這時候會重新拋出異常。但是如果你沒有調用 await,這個錯誤將永遠被保存,靜靜的等待被發現。

如果我們使用結構化併發寫上面的代碼,異常將會正確的拋給調用者。

suspend fun foundError() {
    coroutineScope {
        async {
            throw StructuredConcurrencyWill("throw")
        }
    }
}

由於 coroutineScope 會等待所有子協程執行完成,所以當子協程失敗時它也會知道。當 coroutineScope 啓動的協程拋出了異常,coroutineScope 會將異常扔給調用者。如果使用 coroutineScope 代替 supervisorScope,當異常拋出時,會立刻停止所有的子協程。

結構化併發保證當一個協程發生錯誤,它的調用者或者作用域可以發現。

4.2.2 在 ViewModel 中啓動

如果一個 CoroutineScope 追蹤在其中啓動的所有協程,launch 會新建一個協程,那麼你應該在何處調用 launch 並將其置於協程作用域中呢?還有,你應該在什麼時候取消在作用域中啓動的所有協程呢?
在 Android 中,通常將 CoroutineScope 和用戶界面相關聯起來。這將幫助你避免協程泄露,並且使得用戶不再需要的 Activity 或者 Fragment 不再做額外的工作。當用戶離開當前頁面,與頁面相關聯的 CoroutineScope 將取消所有工作。

結構化併發保證當協程作用域取消,其中的所有協程都會取消。

當通過 Android Architecture Components 集成協程時,一般都是在 ViewModel 中啓動協程。這裏是許多重要任務開始工作的地方,並且你不必擔心旋轉屏幕會殺死協程。
通過 viewModelScope 啓動協程,當 viewModelScope 被清除(即 onCleared() 被調用)時,它會自動取消由它啓動的所有協程。

當你需要協程和 ViewModel 的生命週期保持一致時,使用 viewModelScope 來從普通函數切換到協程。那麼,由於 viewModelScope 會自動取消協程,可以確保任何工作,即使是死循環,都能在不再需要執行的時候將其取消。

4.3 使用結構化併發

使用未關聯的 CoroutineScope(注意是大寫字母 C),或者使用全局作用域 GlobalScope ,會導致非結構化併發。只有在少數情況下,你需要協程的生命週期長於調用者的作用域時,才考慮使用非結構化併發。通常情況下,你都應該使用結構化併發來追蹤協程,處理異常,擁有良好的取消機制。

結構化併發幫助我們解決的三個問題:

  1. 取消不再需要的任務
  2. 追蹤所有正在進行的任務
  3. 協程失敗時的錯誤信號

結構化併發給予我們如下保證:

  1. 當作用域取消,其中的協程也會取消
  2. 當掛起函數返回,其中的所有任務都已完成
  3. 當協程發生錯誤,其調用者會得到通知

這些加在一起,使得我們的代碼更加安全,簡潔,並且幫助我們避免任務泄露。

五、 Android中使用協程的一些最佳做法

5.1 注入調度器

在創建新協程或調用 withContext 時,請勿對 Dispatchers 進行硬編碼。

// DO inject Dispatchers
class NewsRepository(
    private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default
) {
    suspend fun loadNews() = withContext(defaultDispatcher) { /* ... */ }
}

// DO NOT hardcode Dispatchers
class NewsRepository {
    // DO NOT use Dispatchers.Default directly, inject it instead
    suspend fun loadNews() = withContext(Dispatchers.Default) { /* ... */ }
}

這種依賴項注入模式可以降低測試難度,因爲您可以使用 TestCoroutineDispatcher 替換單元測試和插樁測試中的這些調度程序,以提高測試的確定性。

5.2 掛起函數應該保證線程安全

即 3.2 所說的內容,掛起函數應該保證對任意線程安全,不應該由掛起函數的調用方來切換線程。

class NewsRepository(private val ioDispatcher: CoroutineDispatcher) {

    // As this operation is manually retrieving the news from the server
    // using a blocking HttpURLConnection, it needs to move the execution
    // to an IO dispatcher to make it main-safe
    suspend fun fetchLatestNews(): List<Article> {
        withContext(ioDispatcher) { /* ... implementation ... */ }
    }
}

// This use case fetches the latest news and the associated author.
class GetLatestNewsWithAuthorsUseCase(
    private val newsRepository: NewsRepository,
    private val authorsRepository: AuthorsRepository
) {
    // This method doesn't need to worry about moving the execution of the
    // coroutine to a different thread as newsRepository is main-safe.
    // The work done in the coroutine is lightweight as it only creates
    // a list and add elements to it
    suspend operator fun invoke(): List<ArticleWithAuthor> {
        val news = newsRepository.fetchLatestNews()

        val response: List<ArticleWithAuthor> = mutableEmptyList()
        for (article in news) {
            val author = authorsRepository.getAuthor(article.author)
            response.add(ArticleWithAuthor(article, author))
        }
        return Result.Success(response)
    }
}

此模式可以提高應用的可伸縮性,因爲調用掛起函數的類無需擔心使用哪個 Dispatcher 來處理哪種類型的工作。該責任將由執行相關工作的類承擔。

5.3 ViewModel 應創建協程

ViewModel 類應首選創建協程,而不是公開掛起函數來執行業務邏輯。如果只需要發出一個值,而不是使用數據流公開狀態,ViewModel 中的掛起函數就會非常有用。

// DO create coroutines in the ViewModel
class LatestNewsViewModel(
    private val getLatestNewsWithAuthors: GetLatestNewsWithAuthorsUseCase
) : ViewModel() {

    private val _uiState = MutableStateFlow<LatestNewsUiState>(LatestNewsUiState.Loading)
    val uiState: StateFlow<LatestNewsUiState> = _uiState

    fun loadNews() {
        viewModelScope.launch {
            val latestNewsWithAuthors = getLatestNewsWithAuthors()
            _uiState.value = LatestNewsUiState.Success(latestNewsWithAuthors)
        }
    }
}

// Prefer observable state rather than suspend functions from the ViewModel
class LatestNewsViewModel(
    private val getLatestNewsWithAuthors: GetLatestNewsWithAuthorsUseCase
) : ViewModel() {
    // DO NOT do this. News would probably need to be refreshed as well.
    // Instead of exposing a single value with a suspend function, news should
    // be exposed using a stream of data as in the code snippet above.
    suspend fun loadNews() = getLatestNewsWithAuthors()
}

視圖不應直接觸發任何協程來執行業務邏輯,而應將這項工作委託給 ViewModel。這樣一來,業務邏輯就會變得更易於測試,因爲可以對 ViewModel 對象進行單元測試,而不必使用測試視圖所必需的插樁測試。

此外,如果工作是在 viewModelScope 中啓動,您的協程將在配置更改後自動保留。如果您改用 lifecycleScope 創建協程,則必須手動進行處理該操作。如果協程的存在時間需要比 ViewModel 的作用域更長,請查看“在業務和數據層中創建協程”部分。

直白點理解就是業務邏輯,應該在 ViewModel 中啓動協程處理,而不是在 View 中。

注意:視圖應對與界面相關的邏輯啓動協程。例如,從互聯網提取映像或設置字符串格式。

5.4 不要公開可變類型

最好向其他類公開不可變類型。這樣一來,對可變類型的所有更改都會集中在一個類中,便於在出現問題時進行調試。

// DO expose immutable types
class LatestNewsViewModel : ViewModel() {

    private val _uiState = MutableStateFlow(LatestNewsUiState.Loading)
    val uiState: StateFlow<LatestNewsUiState> = _uiState

    /* ... */
}

class LatestNewsViewModel : ViewModel() {

    // DO NOT expose mutable types
    val uiState = MutableStateFlow(LatestNewsUiState.Loading)

    /* ... */
}

5.5 數據層和業務層應公開掛起函數和數據流

數據層和業務層中的類通常會公開函數以執行一次性調用,或接收數據隨時間變化的通知。這些層中的類應該針對一次性調用公開掛起函數,並公開數據流以接收關於數據更改的通知。

// Classes in the data and business layer expose
// either suspend functions or Flows
class ExampleRepository {
    suspend fun makeNetworkRequest() { /* ... */ }

    fun getExamples(): Flow<Example> { /* ... */ }
}

採用該最佳做法後,調用方(通常是演示層)能夠控制這些層中發生的工作的執行和生命週期,並在需要時取消相應工作。

在業務層和數據層中創建協程
對於數據層或業務層中因不同原因而需要創建協程的類,它們可以選擇不同的選項。

如果僅當用戶查看當前屏幕時,要在這些協程中完成的工作才具有相關性,則應遵循調用方的生命週期。在大多數情況下,調用方將是 ViewModel。在這種情況下,應使用 coroutineScope 或 supervisorScope。

class GetAllBooksAndAuthorsUseCase(
    private val booksRepository: BooksRepository,
    private val authorsRepository: AuthorsRepository,
    private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default
) {
    suspend fun getBookAndAuthors(): BookAndAuthors {
        // In parallel, fetch books and authors and return when both requests
        // complete and the data is ready
        return coroutineScope {
            val books = async(defaultDispatcher) {
                booksRepository.getAllBooks()
            }
            val authors = async(defaultDispatcher) {
                authorsRepository.getAllAuthors()
            }
            BookAndAuthors(books.await(), authors.await())
        }
    }
}

如果只要應用處於打開狀態,要完成的工作就具有相關性,並且此工作不限於特定屏幕,那麼此工作的存在時間應該比調用方的生命週期更長。對於這種情況,您應使用外部 CoroutineScope。

class ArticlesRepository(
    private val articlesDataSource: ArticlesDataSource,
    private val externalScope: CoroutineScope,
    private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default
) {
    // As we want to complete bookmarking the article even if the user moves
    // away from the screen, the work is done creating a new coroutine
    // from an external scope
    suspend fun bookmarkArticle(article: Article) {
        externalScope.launch(defaultDispatcher) {
            articlesDataSource.bookmarkArticle(article)
        }
            .join() // Wait for the coroutine to complete
    }
}

externalScope 應由存在時間比當前屏幕更長的類進行創建和管理,並且可由 Application 類或作用域限定爲導航圖的 ViewModel 進行管理。

5.6 在測試中注入 TestCoroutineDispatcher

應在測試內的類中注入 TestCoroutineDispatcher 的實例。TestCoroutineDispatcher 會立即執行任務,並讓您能夠控制協程在測試中執行的時機。

在測試主體中使用 TestCoroutineDispatcher 的 runBlockingTest,以等待所有使用相應調度程序的協程完成。

class ArticlesRepositoryTest {

    private val testDispatcher = TestCoroutineDispatcher()

    @Test
    fun testBookmarkArticle() {
        // Execute all coroutines that use this Dispatcher immediately
        testDispatcher.runBlockingTest {
            val articlesDataSource = FakeArticlesDataSource()
            val repository = ArticlesRepository(
                articlesDataSource,
                // Make the CoroutineScope use the same dispatcher
                // that we use for runBlockingTest
                CoroutineScope(testDispatcher),
                testDispatcher
            )
            val article = Article()
            repository.bookmarkArticle(article)
            assertThat(articlesDataSource.isBookmarked(article)).isTrue()
        }
        // make sure nothing else is scheduled to be executed
        testDispatcher.cleanupTestCoroutines()
    }
}

由於被測類創建的所有協程都使用同一 TestCoroutineDispatcher,並且測試主體會使用 runBlockingTest 等待協程執行,因此您的測試將變得具有確定性,並且不會受到競態條件的影響。

注意:如果被測代碼中未使用其他 Dispatchers,則會出現以上情況。因此,我們不建議在類中對 Dispatchers 進行硬編碼。如果需要注入多個 Dispatchers,您可以傳遞 TestCoroutineDispatcher 的同一實例。

5.7 避免使用 GlobalScope

這類似於“注入調度程序”最佳做法。通過使用 GlobalScope,您將對類使用的 CoroutineScope 進行硬編碼,而這會帶來一些問題:

  • 提高硬編碼值。如果您對 GlobalScope 進行硬編碼,則可能同時對 Dispatchers 進行硬編碼。
  • 這會讓測試變得非常困難,因爲您的代碼是在非受控的作用域內執行的,您將無法控制其執行。
  • 您無法設置一個通用的 CoroutineContext 來對內置於作用域本身的所有協程執行。

而您可以考慮針對存在時間需要比當前作用域更長的工作注入一個 CoroutineScope。如需詳細瞭解此主題,請參閱“在業務層和數據層中創建協程”部分。

5.8 將協程設爲可取消

協程取消屬於協作操作,也就是說,在協程的 Job 被取消後,相應協程在掛起或檢查是否存在取消操作之前不會被取消。如果您在協程中執行阻塞操作,請確保相應協程是可取消的。
例如,如果您要從磁盤讀取多個文件,請先檢查協程是否已取消,然後再開始讀取每個文件。若要檢查是否存在取消操作,有一種方法是調用 ensureActive 函數。

someScope.launch {
    for(file in files) {
        ensureActive() // Check for cancellation
        readFile(file)
    }
}

kotlinx.coroutines 中的所有掛起函數(例如 withContext 和 delay)都是可取消的。如果您的協程調用這些函數,您無需執行任何其他操作。

5.8 留意異常

不當處理協程中拋出的異常可能導致您的應用崩潰。如果可能會發生異常,請在使用 viewModelScope 或 lifecycleScope 創建的任何協程內容中捕獲相應異常。

class LoginViewModel(
    private val loginRepository: LoginRepository
) : ViewModel() {

    fun login(username: String, token: String) {
        viewModelScope.launch {
            try {
                loginRepository.login(username, token)
                // Notify view user logged in successfully
            } catch (error: Throwable) {
                // Notify view login attempt failed
            }
        }
    }
}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章