在 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 协程和数据流的其他资源页面。