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

この依存関係挿入パターンにより、単体テストとインストルメンテーション テストのディスパッチャをテスト ディスパッチャに置き換えることでテストの確定性を高めることができるため、テストが容易になります。

suspend 関数はメインスレッドから安全に呼び出せるようにする

suspend 関数は、メインセーフである(つまり、メインスレッドから安全に呼び出せる)必要があります。コルーチンで長時間ブロック操作を実行しているクラスは、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)
    }
}

このパターンを使用すると、suspend 関数を呼び出すクラスが、どのタイプの処理にどの Dispatcher を使用すべきか考慮する必要がないため、アプリのスケーラビリティが向上します。それを考慮する役割は、処理を行うクラスが担います。

ViewModel でコルーチンを作成する

ViewModel クラスは、suspend 関数を公開するのではなく、コルーチンを作成してビジネス ロジックを実行する必要があります。ViewModel の suspend 関数は、データ ストリームを使用してステータスを公開する代わりに、1 つの値のみを出力する必要がある場合に役立ちます。

// 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 のスコープよりも長く存続させる必要がある場合は、「ビジネスレイヤとデータレイヤでのコルーチンの作成」のセクションをご覧ください。

変更可能な型を公開しない

変更不能な型は他のクラスに公開することをおすすめします。これにより、変更可能な型に対するすべての変更が 1 つのクラスに集中するため、問題が発生した場合のデバッグが容易になります。

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

    /* ... */
}

データレイヤとビジネスレイヤによって suspend 関数と Flow を公開する

通常、データレイヤとビジネスレイヤのクラスは、ワンショット呼び出しを実行する関数や、時間の経過に伴うデータの変更について通知を受け取る関数を公開します。これらのレイヤのクラスは、ワンショット呼び出しのための suspend 関数データ変更について通知する 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 ライブラリで利用可能な実装は次の 2 つです。

  • StandardTestDispatcher: スケジューラで開始されたコルーチンをキューに入れ、テストスレッドがビジー状態ではないときにそれらのコルーチンを実行します。advanceUntilIdle などのメソッドを使用してキュー内の他のコルーチンを実行できるようテストスレッドを一時停止できます。

  • UnconfinedTestDispatcher: ブロック形式で、新しいコルーチンを積極的に実行します。一般にテストは作成しやすくなりますが、テストの際にコルーチンを実行する方法を制御しにくくなります。

詳しくは、各ディスパッチャの実装のドキュメントを参照してください。

コルーチンをテストするには、runTest コルーチン ビルダーを使用します。runTestTestCoroutineScheduler を使用してテストの遅延をスキップし、仮想時間を制御できるようにします。また、このスケジューラを使用して、必要に応じて追加のテスト ディスパッチャを作成することもできます。

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 はすべて同じスケジューラを共有する必要があります。これで、1 つのテストスレッドですべてのコルーチン コードを実行し、テストを確定的にすることができます。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 がキャンセルされても、キャンセルを確認または停止するまでコルーチンはキャンセルされません。コルーチンでブロック操作を実行する場合、コルーチンがキャンセル可能であることを確認してください。

たとえば、ディスクから複数のファイルを読み込む場合は、各ファイルの読み込みを開始する前に、コルーチンがキャンセルされたかどうかを確認してください。キャンセルを確認する 1 つの方法は、ensureActive 関数を呼び出すことです。

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

kotlinx.coroutines からの suspend 関数(withContextdelay など)はすべてキャンセルできます。それらがコルーチンによって呼び出されている場合、追加の作業は必要ありません。

コルーチンでのキャンセルの詳細については、ブログ記事「コルーチンでのキャンセル」をご覧ください。

例外に注意する

処理されていない例外がコルーチン内でスローされた場合、アプリがクラッシュする可能性があります。例外が発生する可能性が高い場合は、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 のコルーチンと Flow に関する参考情報」のページをご覧ください。