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) { /* ... */ }
}

这种依赖项注入模式使测试更容易,因为您可以在单元测试和 Instrumentation 测试中用 测试调度器 替换这些调度器,以使您的测试更具确定性。

挂起函数应可以安全地从主线程调用

挂起函数应该是主线程安全的,这意味着它们可以安全地从主线程调用。如果某个类在协程中执行长时间运行的阻塞操作,则它负责使用 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 对象可以进行单元测试,而不是使用需要测试视图的 Instrumentation 测试。

除此之外,如果在 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,如Coroutines & Patterns for work that shouldn’t be cancelled 博客文章 中所述。

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 管理。

在测试中注入 TestDispatchers

在测试中,应将 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
    }
}

Coroutines & Patterns for work that shouldn’t be cancelled 博客文章 中了解有关 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 协程和流的其他资源页面。