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

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

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 になります。この場合、coroutineScope または supervisorScope を使用します。

class GetAllBooksAndAuthorsUseCase(
    private val booksRepository: BooksRepository,
    private val authorsRepository: AuthorsRepository,
    private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default
) {
    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(defaultDispatcher) {
                booksRepository.getAllBooks()
            }
            val authors = async(defaultDispatcher) {
                authorsRepository.getAllAuthors()
            }
            BookAndAuthors(books.await(), authors.await())
        }
    }
}

行われる処理が、アプリが開かれている限り関係し、特定の画面にバインドされていない場合、その処理は呼び出し元のライフサイクルよりも長く存続する必要があります。このシナリオでは、ブログ記事「キャンセルすべきでない処理のコルーチンとパターン」で説明されているように、外部の CoroutineScope を使用する必要があります。

class ArticlesRepository(
    private val articlesDataSource: ArticlesDataSource,
    private val externalScope: CoroutineScope,
    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
    }
}

externalScope は、現在の画面よりも長く存続するクラスによって作成、管理される必要があります。これは、Application クラス、またはナビゲーション グラフにスコープされる ViewModel によって、管理できます。

テストに TestCoroutineDispatcher を挿入する

テストのクラスに TestCoroutineDispatcher のインスタンスを挿入する必要があります。TestCoroutineDispatcher はタスクをすぐに実行し、テストでのコルーチンの実行のタイミングを制御できるようにします。

テスト本体で TestCoroutineDispatcherrunBlockingTest を使用して、そのディスパッチャを使用するすべてのコルーチンが完了するまで待機します。

class ArticlesRepositoryTest {

    private val testDispatcher = TestCoroutineDispatcher()

    @Test
    fun testBookmarkArticle() {
        // Execute all coroutines that use this Dispatcher immediately
        testDispatcher.runBlockingTest {
            val articlesDataSource = FakeArticlesDataSource()
            val repository = ArticlesRepository(
                articlesDataSource,
                // Make the CoroutineScope use the same dispatcher
                // that we use for runBlockingTest
                CoroutineScope(testDispatcher),
                testDispatcher
            )
            val article = Article()
            repository.bookmarkArticle(article)
            assertThat(articlesDataSource.isBookmarked(article)).isTrue()
        }
        // make sure nothing else is scheduled to be executed
        testDispatcher.cleanupTestCoroutines()
    }
}

テスト対象のクラスによって作成されたすべてのコルーチンが同じ TestCoroutineDispatcher を使用し、テスト本体は runBlockingTest を使用してその実行を待機するので、テストは確定的なものになり、競合状態が生じることはありません。

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 からの 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 (error: Throwable) {
                // Notify view login attempt failed
            }
        }
    }
}

CoroutineExceptionHandler の使用法とその他のシナリオについては、ブログ記事「コルーチンの例外」をご覧ください。

コルーチンの詳細

コルーチンに関するその他の参考情報については、「Kotlin のコルーチンと Flow に関する参考情報」のページをご覧ください。