ローソンデジタルイノベーション テックブログ

ローソンデジタルイノベーション(LDI)の技術ブログです

【Android】GlobalScopeの置き換え対応

はじめに

Androidエンジニアの篠本(ささもと)です。

今回、delicate(取り扱い注意)のGlobalScopeをwithContext(NonCancellable)に置き換えまして、そのご紹介をさせていただきます。

なお、この記事は以下の環境を前提としています。

  • Kotlin 1.9.24
  • Kotlinx Coroutines 1.8.1
  • Android APIレベル 34

GlobalScopeとは?

GlobalScopeとは、Kotlin Coroutinesを使用して非同期処理を実行できるCoroutineScopeの一つです。

kotlinlang.org

Kotlin Coroutinesを使用して非同期処理を実行する場合、CoroutineScopeを生成してそのCoroutineScopeの中で非同期処理を実行します。このメリットとして、非同期処理の呼び出し元はCoroutineScopeを介して非同期処理のキャンセルや完了待ちなどを行えます。

AndroidにてCoroutineScopeを使用する場合、lifecycleScopeviewModelScopeを使用することが多いです。これらはAndroid SDKのクラスがすでに用意しているものなので、自前で生成や後処理などを行う必要がなくそのまま使用することが可能です。

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // Jobのjoin()にて完了待ち、cancel()にてキャンセルを行うことができる。
        val job = lifecycleScope.launch {
            // 内部でI/O制御を行っているが、CoroutineScope内部で呼んでいるため、
            // 呼び出し元の処理がブロックされることはない。
            UserInfoRemoteDataSource().fetchUserInfo()
        }
    }
}

しかし、lifecycleScopeviewModelScopeはAndroid SDKのクラスを継承したクラスでしか使用できないため、それ以外のクラスでは使用できません。

class UserInfoModel {

    suspend fun fetch() {
        // コンパイルエラー。
        // このクラスはAndroid SDKのクラスを継承していないため、lifecycleScopeを使用することはできない。
        lifecycleScope.launch {
            UserInfoRemoteDataSource().fetchUserInfo()
        }
    }
}

GlobalScopeにはそのような制限はなくどこでも使用可能です。

class UserInfoModel {

    suspend fun fetch() {
        // コンパイルOK。
        GlobalScope.launch {
            UserInfoRemoteDataSource().fetchUserInfo()
        }
    }
}

GlobalScopeがdelicate扱いに

Kotlinx Coroutines 1.5からGlobalScopeの使用はdelicate(取り扱いに注意が必要)になりました。

blog.jetbrains.com

delicateの理由として、その使用の手軽さ故にGlobalScopeはハードコーディングされて使用されることが多く、GlobalScopeにて実行される非同期処理が呼び出し元で制御されないまま実行されるリスクがあります。

具体的な事例としては、テストコードが期待通りに実行されない可能性が考えられます。

テストコードでは、テスト対象のメソッドの処理がすべて実行完了した後にその実行結果を検証します。そのテスト対象のメソッド内部にてGlobalScopeによる非同期処理が行われている場合、その非同期処理がすべて実行完了する前に検証が行われてしまい、誤ってNG判定される可能性があります。

class UserInfoModel {

    // テスト対象のメソッド
    suspend fun fetch() {
        GlobalScope.launch {
            UserInfoRemoteDataSource().fetchUserInfo()
        }
    }
}

@Test
fun ユーザー情報取得_fetchUserInfo()が期待通りに動作すること() = runTest {
    // テスト実行
    UserInfoModel().fetch()
    
    // テスト検証
    // ここでfetchUserInfo()の実行結果を検証するが、
    // まだfetchUserInfo()の実行が完了していない可能性があり、誤ってテストNGと判断される可能性がある。
}

弊社アプリでも何箇所かGlobalScopeが使用されている箇所がありました。幸いに、GlobalScopeの使用による不具合はありませんでした。

しかし、たとえば今後の開発において、既存のGlobalScopeの使用箇所を参考にした結果による不具合発生の可能性も考えられます。

また、AndroidにてCoroutineScopeを使用する場合にはAndroid SDKにて定義されているCoroutineScopeを使用することが推奨されているため、社内のコーディングルールではGlobalScopeの使用をアンチパターンとしています。たとえば、ActivityやFragmentにて定義されているlifecycleScopeや、ViewModelにて定義されているviewModelScopeの使用が推奨されています。

以上により、既存のGlobalScopeの使用を別の方法に置き換えることとしました。

対応方法

検討の結果、GlobalScopeをwithContext(NonCancellable)へ置き換えることにしました。なぜなら弊社アプリの場合、非同期処理を呼び出し元によりキャンセルされたくない場合にGlobalScopeを使用していたためです。

kotlinlang.org

kotlinlang.org

先の説明にて、CoroutineScopeを使用するメリットとして非同期処理を呼び出し元からキャンセルすることができることをあげましたが、キャンセルされたくない非同期処理を実行する場合、これはデメリットになります。

そして弊社アプリの場合、キャンセルされたくない非同期処理の実行にGlobalScopeを使用していました。

GlobalScopeは呼び出し元のCoroutineScopeとは独立しているために、呼び出し元のCoroutineScopeがキャンセルされてもGlobalScopeの中で実行される非同期処理はキャンセルされない特徴があります。

弊社アプリでは、この特徴を利用してキャンセルされたくない非同期処理をGlobalScopeにて実行していました。

// メイン画面のクラス
class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // 画面が閉じられるなどするとlifecycleScopeの中の非同期処理がキャンセルされる。
        lifecycleScope.launch {
            NonCancellableModel().doNonCancellableProcess()
        }
    }
}

// キャンセルされたくない処理が実装されているクラス
class NonCancellableModel {

    suspend fun doNonCancellableProcess() {
        // GlobalScopeは呼び出し元のlifecycleScopeとは独立しているため、
        // たとえば画面が閉じられてもGlobalScopeの中の処理はキャンセルされない。
        GlobalScope.launch {
            // ここでキャンセルされたくない処理を実行する。
        }
    }
}

しかし、GlobalScopeは呼び出し元のCoroutineScopeとは独立しているために、たとえば呼び出し元においてGlobalScopeの中の非同期処理の実行完了まで処理を待つことができない特徴もあり、これが将来的に不具合になる可能性があります。

今回やりたいことは非同期処理をキャンセル不可で呼び出したいだけであり、呼び出し元からCoroutineScopeを独立させたいわけではありません。

そこで、今回はGlobalScopeをwithContext(NonCancellable)へ置き換えることにしました。

withContextはCoroutineScopeのCoroutineContextを変更する機能を持ちます。CoroutineContextは非同期処理の状態を持つものですが、withContext(NonCancellable)とすることで、withContextのブロック内の処理のみキャンセル不可にすることができます。

{
    // キャンセル可
    withContext(NonCancellable) {
        // キャンセル不可
    }
    // キャンセル可
}

これにより、呼び出し元のCoroutineScopeを継承しながら、キャンセル可否を不可に変更することができます。

// メイン画面のクラス
class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        lifecycleScope.launch {
            NonCancellableModel().doNonCancellableProcess()
        }
    }
}

// キャンセルされたくない処理が実装されているクラス
class NonCancellableModel {

    suspend fun doNonCancellableProcess() {
        // 呼び出し元のCoroutineContextを継承しながら、非同期処理をキャンセル不可に変更する。
        withContext(NonCancellable) {
            // ここでキャンセルされたくない処理を実行する。
        }
    }
}

最後に

非同期処理は学ぶべきことが多く、理解が浅いまま実装すると誤った実装をしてしまうことがあります。今回の対応によりKotlin Coroutinesによる非同期処理に対する理解が深まり、よい経験になりました。

今後もローソンデジタルイノベーションでは技術ブログを更新していきますので、是非「読者になる」で応援していただけますと幸いです。