araki tech

for developers including me

GlobalScopeとCoroutineScopeについて整理しよう【Kotlin Coroutines】

GlobalScopeとCoroutineScopeについて整理しよう【Kotlin Coroutines】

はじめに

Kotlinを使ってAndroid開発をしていると、Coroutinesを使った非同期処理を実装することはよくあるシチュエーションだと思います。

Android でのコルーチンに関するベスト プラクティス | Android developersによると、GlobalScopeのハードコードはダメとの記載があります。

その代わりに、CoroutineScope()というメソッドを使用して置き換える方もいらっしゃるかもしれません。

使い方を誤ると、GlobalScopeを使用しているのと何も変わらない、という結果に陥る可能性があります。

これについて整理していきます。

ベストプラクティスをまとめると

GlobalScopeのハードコードはやめましょう

GlobalScopeは言い換えればApplicationScopeであり、アプリの寿命で実行されることを指します。

ViewModelが持っているviewModelScopeやActivityが持っているlifecycleScopeは、スコープの所有者が死ぬとそこで実行されていた非同期処理も死にます

なので、意図せず非同期処理が生き残り続けてリソースを食う、という事態は無くなります

これが大事で、要はCancellable (キャンセル可能) かどうかが大事です。

もし、

GlobalScope.launch {
    // ... 
}

のようにハードコードされたGlobalScopeで処理を実行すると、キャンセルも不可能な上、その処理の行く末を追うのが難しくなります

さらに、テストもしづらくなります。

例えば、上記のような実装を含むメソッドをテストしようと思うと、GlobalScopeで実行された処理を待つために、delay() を挟むなどといったスマートでは無い実装が出てきてしまいます。

じゃあどうするか

最低でもCancellableにしましょう。

val job = GlobalScope.launch {
    // ...
}

こうすることで、

job.cancel()

で非同期処理を打ちきることができ、管理しやすくなります。

さらに推奨するのが、コンストラクタでの挿入です

class JobOwner(
    private val applicationScope: CoroutineScope = GlobalScope
) {
    fun doSomething() {
        applicationScope.launch {  /* ... */ }
    }
}

こうするとCancellableなだけでなくテストでテスト用のCoroutineScope (TestCoroutineScope)を挿入できます。

// JobOwnerTest.kt
// ...

@Test
fun `doSometnig - simple test`() {
    val testDispatcher = TestCoroutineDispatcher()
    val testScope = TestCoroutineScope(testDispatcher)

    val jobOwner = JobOwner(testScope)
    
    // ...
}

これで、テスト用のスコープでスレッドをブロッキングして実行してくれるので、delay()も必要ありません。

DispatcherのコンストラクタInjectも推奨

CoroutineScopeだけでなく、Dispatcherもコンストラクタで挿入するのも推奨します。

これは主にテスト用で、テスト用のTestCoroutinrDispatcherを内部で扱えるので、制御が楽になるためです。

class JobOwner(
    private val applicationScope: CoroutineScope = GlobalScope,
    private val mainDispatcher: CoroutineDispatcher = Dispatchers.Main
) {
    fun doSomething() {
        applicationScope.launch(mainDispatcher) { /* ... */ }
    }
}
// JobOwnerTest.kt
// ...

@Test
fun `doSometnig - simple test`() {
    val testDispatcher = TestCoroutineDispatcher()
    val testScope = TestCoroutineScope(testDispatcher)

    val jobOwner = JobOwner(testScope, testDispatcher)

    // ...
}

ただし、コンストラクタ引数が多くなってしまうので、CoroutineScopeの挿入よりは優先度は低いかなと、個人的には思います。

suspend関数はどのスレッドで呼ばれても大丈夫な状態にする

suspend関数は呼び出し側で、スレッドの切り替えを意識させないようにするのが望ましいです。

具体的にはsuspend関数では、withContextを用いて安全に適したスレッドで非同期処理を行われるようにした方が良いです。

class MyRepository {
    suspend fun fetchSomething() = withContext(Dispatchers.IO) {
        // ...
    }
}

class MyViewModel: ViewModel() {
    private val repo = MyRepository()
    
    fun doSomething() {
        viewModelScope.launch(Dispatchers.Main) { 
            // do something
            val result = repo.fetchSomething()  // 呼び出し元では切り替えの必要なし
            // do something
        }
    }
}

独自のCoroutineScopeを作る場合

ApplicationScopeではなく、呼び出し元のCoroutineScopeのContextに依存する子CoroutieScopeを作成する場合は以下のように、独自に定義することが可能です。

以下はviewModelScopeに依存する例です。

class MainViewModel: ViewModel() {
    val jobOwner = JobOwner(parentJob = viewModelScope.coroutineContext.job)
}
class JobOwner(private val parentJob) {
    private val scope = CoroutineScope(parentJob + Dispatchers.Main)
}

こうすることで、親Job (parentJob) がキルされればこのCoroutineScopeもキルされます。

間違ったCoroutineScopeの例

class JobOwner() {
    private val scope = CoroutineScope(Dispatchers.Main)
}

この使い方は間違いです。

CoroutineDispatcherCoroutineContextですので上記実装でもエラーになることはもちろんありませんんが、何もCoroutineContextを引き継いでいないので、実質ApplicationScope (≒GlobalScope) と同等になります。(GlobalScopeはDispatcherはDefaultがデフォルトなので少し違いますが)

ただCancellableなのでGlobalScopeのハードコードよりはマシと言えばマシです。

参考リンク

  1. Android でのコルーチンに関するベスト プラクティス | Android developers
  2. Kotlin コルーチンでアプリのパフォーマンスを改善する | Android developers