本页介绍了在使用协程时对提升应用可伸缩性和可测试性有积极影响的几项最佳实践。
注入调度器
在创建新协程或调用 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)
/* ... */
}
数据和业务层应暴露挂起函数和流
数据和业务层中的类通常会暴露函数来执行一次性调用或在数据随时间变化时得到通知。这些层中的类应为一次性调用暴露挂起函数,并为数据变化通知暴露 Flow。
// Classes in the data and business layer expose
// either suspend functions or Flows
class ExampleRepository {
suspend fun makeNetworkRequest() { /* ... */ }
fun getExamples(): Flow<Example> { /* ... */ }
}
这种最佳实践使得调用者(通常是表示层)能够控制这些层中发生的工作的执行和生命周期,并在需要时取消。
在业务和数据层创建协程
对于数据或业务层中因各种原因需要创建协程的类,有不同的选项。
如果在这些协程中要完成的工作仅在用户当前屏幕上存在时才相关,则它应该遵循调用者的生命周期。在大多数情况下,调用者将是 ViewModel,当用户离开屏幕并且 ViewModel 被清除时,调用将被取消。在这种情况下,应使用 coroutineScope
或 supervisorScope
。
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
管理。
在测试中注入 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
}
}
在协程和不应取消的工作模式博客文章中了解更多关于 GlobalScope
及其替代方案的信息。
使您的协程可取消
协程中的取消是协作式的,这意味着当协程的 Job
被取消时,协程不会立即取消,直到它挂起或检查取消。如果您在协程中执行阻塞操作,请确保协程是可取消的。
例如,如果您正在从磁盘读取多个文件,在开始读取每个文件之前,请检查协程是否已取消。检查取消的一种方法是调用 ensureActive
函数。
someScope.launch {
for(file in files) {
ensureActive() // Check for cancellation
readFile(file)
}
}
所有来自 kotlinx.coroutines
的挂起函数,例如 withContext
和 delay
都是可取消的。如果您的协程调用了它们,您就不需要进行任何额外的工作。
有关协程中取消的更多信息,请查看协程中的取消博客文章。
注意异常
协程中抛出的未处理异常可能会导致您的应用崩溃。如果可能发生异常,请在使用 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 (exception: IOException) {
// Notify view login attempt failed
}
}
}
}
有关更多信息,请查看博客文章协程中的异常,或 Kotlin 文档中的协程异常处理。
了解更多协程信息
有关更多协程资源,请参阅Kotlin 协程和流的其他资源页面。