Android 協同程式的最佳做法

本資訊頁面提供了幾個最佳做法,讓您在使用協同程式時,透過使應用程式更具擴充性和測試能力,而帶來積極的影響。

插入調派程式

在建立新的協同程式或呼叫 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) { /* ... */ }
}

這種依附元件插入模式讓測試更容易,因為您可以將單元和檢測設備測試中調派程式替換為「測試調派程式」,使測試更有確定性。

暫停函式應能安全地從主執行緒呼叫

暫停函式應對主執行緒沒有威脅,意味著可安全地從主執行緒呼叫。如果類別在協同程式中持續執行封鎖作業,則該類別會透過 withContext 將執行作業移出主執行緒。無論該類別屬於哪個架構,這種情況都適用於應用程式中的所有類別。

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。執行該工作的類別要發揮此作用。

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 的範圍,請查看「在業務和資料層建立協同程式」一節。

不要公開可變動的類型

最好對其他類別公開不可變動的類型。如此一來,對可變類型的所有變更都會集中在一個類別中,方便您在發生問題時偵錯。

// 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)

    /* ... */
}

資料與業務層應公開暫停函式和資料流

資料和業務層中的類別通常會公開某些函式。這些函式用於執行一次性呼叫,或接收資料隨時間變更的通知。在這些層中,類別應公開一次性呼叫的暫停函式發出資料變更通知的資料流

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

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

最佳做法是讓呼叫端 (通常是簡報層) 控制這些層中的執行作業以及該工作的生命週期,並視需要取消。

在業務和資料層中建立協同程式

如果資料層或業務層中的類別,因不同原因而需要建立協同程式,可採取各種不同做法。

如果只有在使用者查看當前畫面時,要在這些協同程式中完成的工作才存在關聯性,則應遵循呼叫端的生命週期。在大部分情況下,呼叫端是 ViewModel,當使用者離開畫面且 ViewModel 遭清除時,呼叫將會取消。在這種情況下,應使用 coroutineScopesupervisorScope

class GetAllBooksAndAuthorsUseCase(
    private val booksRepository: BooksRepository,
    private val authorsRepository: AuthorsRepository,
) {
    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 { booksRepository.getAllBooks() }
            val authors = async { authorsRepository.getAllAuthors() }
            BookAndAuthors(books.await(), authors.await())
        }
    }
}

如果要執行的作業在應用程式開啟時即存在關聯性,且這項作業未聯結至特定螢幕,則這項工作應該在呼叫端的生命週期後繼續留存。如同網誌文章「不應取消的工作的協同程式和模式」所述,在這種情況下,應使用外部 CoroutineScope

class ArticlesRepository(
    private val articlesDataSource: ArticlesDataSource,
    private val externalScope: CoroutineScope,
) {
    // 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 { articlesDataSource.bookmarkArticle(article) }
            .join() // Wait for the coroutine to complete
    }
}

externalScope 應由在當前螢幕消失後依然留存的類別所建立和管理,也可由 Application 類別或範圍受導覽圖限制的 ViewModel 管理。

在測試中插入 TestDispatcher

您應在測試中將 TestDispatcher 執行個體插入類別。kotlinx-coroutines-test 程式庫 提供兩種實作方式:

  • StandardTestDispatcher:使在其上以排程器啟動的協同程式排入佇列,並在測試執行緒不忙碌時執行這些協同程式。您可以暫停測試執行緒,並使用 advanceUntilIdle 等方法,讓其他已排入佇列的協同程式執行。

  • UnconfinedTestDispatcher:以阻礙性的方式激烈執行新的協同程式。這通常可使編寫測試的過程更輕鬆,但會削弱您對協同程式在測試期間的執行方式的控制權。

詳情請參閱每次調派程式實作的說明文件。

如要測試協同程式,請使用 runTest 協同程式建構工具。runTest 使用 TestCoroutineScheduler 來略過測試中的延遲情形,可讓您控制虛擬時間。您也可以視需要使用這個排程器,來建立其他測試調派程式。

class ArticlesRepositoryTest {

    @Test
    fun testBookmarkArticle() = runTest {
        // Pass the testScheduler provided by runTest's coroutine scope to
        // the test dispatcher
        val testDispatcher = UnconfinedTestDispatcher(testScheduler)

        val articlesDataSource = FakeArticlesDataSource()
        val repository = ArticlesRepository(
            articlesDataSource,
            testDispatcher
        )
        val article = Article()
        repository.bookmarkArticle(article)
        assertThat(articlesDataSource.isBookmarked(article)).isTrue()
    }
}

所有 TestDispatchers 都必須共用相同的排程器。這樣就能在單一測試執行緒上執行所有協同程式的程式碼,讓測試具有確定性。runTest 會等待位於相同排程器上或做為測試協同程式中子項的所有協同程式完成,然後再傳回。

避免使用 GlobalScope

操作方式與「插入調派程式」最佳做法類似。如果使用 GlobalScope,就是在對類別所使用的 CoroutineScope 進行硬式編碼。這樣做會帶來一些弊端:

  • 提高以硬式編碼方式寫入的值。如果對 GlobalScope 進行硬式編碼,可能也是在對 Dispatchers 進行硬式編碼。

  • 由於程式碼是在不受控管的範圍內執行,因此測試非常困難,且您將無法控制其執行作業。

  • 您無法對範圍內建構的所有協同程式執行同一個 CoroutineContext

請考慮為需要在當前範圍外繼續留存的工作插入 CoroutineScope。請參閱「在業務和資料層中建立協同程式」一節,來進一步瞭解這個主題。

// DO inject an external scope instead of using GlobalScope.
// GlobalScope can be used indirectly. Here as a default parameter makes sense.
class ArticlesRepository(
    private val articlesDataSource: ArticlesDataSource,
    private val externalScope: CoroutineScope = GlobalScope,
    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
    }
}

// DO NOT use GlobalScope directly
class ArticlesRepository(
    private val articlesDataSource: ArticlesDataSource,
) {
    // 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 with GlobalScope
    suspend fun bookmarkArticle(article: Article) {
        GlobalScope.launch {
            articlesDataSource.bookmarkArticle(article)
        }
            .join() // Wait for the coroutine to complete
    }
}

如要進一步瞭解 GlobalScope 及其替代方案,請參閱網誌文章「不應取消的工作的協同程式和模式」

使協同程式可取消

協同程式中的取消作業屬於合作式操作,表示在協同程式的 Job 遭取消後,協同程式在遭暫停或確認已取消之前,不會取消作業。如果執行協同程式中的封鎖作業,請確認協同程式「可取消」

例如,如果您從磁碟讀取多個檔案,請在開始讀取每個檔案之前,檢查是否已取消協同程式。檢查取消作業的其中一種方法是呼叫 ensureActive 函式。

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

kotlinx.coroutines 的所有暫停函式 (例如 withContextdelay) 都可以取消。如果協同程式呼叫這些函式,您不需要執行其他工作。

如要進一步瞭解協同程式內的取消作業,請參閱網誌文章「協同程式內的取消作業」

留意例外狀況

協同程式擲回的未處理例外狀況可能會導致應用程式當機。如果很有可能發生例外狀況,請在以 viewModelScopelifecycleScope 建立的任何協同程式主體中擷取這些例外狀況。

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 (exception: IOException) {
                // Notify view login attempt failed
            }
        }
    }
}

詳情請參閱網誌文章《協同程式中的例外狀況》或 Kotlin 說明文件中《協同程式例外狀況處理》。

進一步瞭解協同程式

如需更多協同程式資源,請參閱「Kotlin 協同程式和資料流的其他資源」資訊頁面。