Kotlin coroutine best practices

Article directory

    • Reprinted from official documentation
    • 1. Inject the scheduler
    • 2. Suspended functions should be able to be safely called from the main thread
    • Three ViewModel should create coroutines
    • 4. Don’t expose mutable types
    • 5. The data layer and business layer should expose suspend functions and data flows
      • Create coroutines in the business layer and data layer
    • 6. Inject TestDispatcher in the test
    • 7. Avoid using GlobalScope
    • 8. Set the coroutine to be cancelable
    • 9. Pay attention to abnormalities

Reprinted from official documentation

https://developer.android.google.cn/kotlin/coroutines/coroutines-best-practices?hl=zh-cn

One injection scheduler

Do not hardcode Dispatchers when creating new coroutines or calling withContext.

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

2 Suspended functions should be safe to call from the main thread

Suspending functions should be main thread safe, which means, you can safely call suspending functions from the main thread. If a class performs a long-running blocking operation in a coroutine, that class is responsible for moving the execution off the main thread using withContext. This applies to all classes in the application, regardless of which part of the architecture they belong to.

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

Three ViewModels should create coroutines

ViewModel classes should prefer creating coroutines instead of exposing suspending functions to execute business logic

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

The view should not directly trigger any coroutines to perform business logic, but should delegate this work to the ViewModel

4 Do not expose variable types

It’s better to expose immutable types to other classes. This way, all changes to mutable types are concentrated in one class, making it easier to debug if problems arise.

// 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 The data layer and business layer should expose suspend functions and data flows

Classes in the data and business layers typically expose functions to perform one-time calls or to receive notifications of data changes over time. Classes in these layers should expose suspend functions for one-time calls and expose data streams to receive notifications about data changes.

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

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

Create coroutines in the business layer and data layer

The work to be done in these coroutines is only relevant while the user is viewing the current screen, and should follow the caller’s lifecycle. In most cases, the caller is a ViewModel and the call will be canceled when the user leaves the screen and the ViewModel is cleared. In this case coroutineScope or supervisorScope should be used.

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())
        }
    }
}

If the work being done is relevant as long as the app is open, and the work is not restricted to a specific screen, then the work should outlive the caller’s lifetime. For this case, you should use an external CoroutineScope (as described in the post “Coroutines and patterns for work that shouldn’t be canceled”)

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
    }
}

Six Inject TestDispatcher in the test

  • StandardTestDispatcher: Uses a scheduler to queue coroutines that have been started on it and execute them when the test thread is not busy. You can suspend the test thread using methods such as advanceUntilIdle to allow other queued coroutines to run.

  • UnconfinedTestDispatcher: Run the new coroutine immediately in blocking mode. Doing this often makes it easier to write tests, but gives you less control over how the coroutine executes during testing.

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()
    }
}

7 Avoid using GlobalScope

By using GlobalScope, you are hard-coding the CoroutineScope used by the class, which creates some problems:

  • Raise the hardcoded value. If you hardcode GlobalScope, you may also hardcode Dispatchers.

  • This makes testing very difficult because your code is executing in an uncontrolled scope and you have no control over its execution.

  • There is no way to set a common CoroutineContext to execute on all coroutines built into the scope itself.

Consider injecting a CoroutineScope for work that needs to live longer than the current scope

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

8 Set the coroutine to be cancelable

Coroutine cancellation is a cooperative operation, that is, after the coroutine’s Job is canceled, the corresponding coroutine will not be canceled before suspending or checking whether there is a cancellation operation. If you perform blocking operations in a coroutine, make sure the coroutine is cancelable.

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

All suspending functions in kotlinx.coroutines (such as withContext and delay) are cancellable. If your coroutine calls these functions, you don’t need to do anything else.

9 Pay attention to abnormalities

Unhandled exceptions thrown in coroutines may cause the application to crash. If an exception may occur, catch the appropriate exception in the body of any coroutine created using viewModelScope or 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
            }
        }
    }
}