Open1

[Kotlin] マルチスレッド環境下のフラグメント操作を安全に行う方法

nmakinmaki

Android開発でエラー問題になりやすいフラグメントですが、通信やオーディオ再生の完了を通知するためにフラグメントを設定したいケースはよくあると思います。その場合、親画面が Pause / Stop / Destroy の状態にあるときにフラグメントをcommitすると、IllegalStateExceptionが発生します。一度発生さえしてしまえば、どこでエラーになったのかはわかりやすい一方で、開発や検証では発覚しにくい側面もあります。

そもそも発覚されにくいためか、対策も人によってバラバラで、定番と言えるほど簡潔な案はなさそうに思えました。そこで自分が調べて実装した中で、公式のライブラリが組み込んでほしいなというものをご紹介します(公式で登場したら、そちらを使う方が良いです)。

ご紹介する方法もシンプルな実装とはいえないため、カスタマイズして使うことを前提に背景的な知識や方針を理解してからの導入を推奨します。以下、順番に説明します。

メインスレッドでフラグメントをcommitすれば良い、というわけではない

Activityにおける鉄則の一つで、画面操作を行う場合はメインスレッドで・・・というものがあります。
この鉄則は、Activityの配下であるはずのフラグメントには適用されないようです。

        CoroutineScope(Dispatchers.Default).launch { // 通信スレッド
            val mainHandler = Handler(Looper.getMainLooper()) // メインスレッド
            RetrofitClient.someRequest(
                fun () { // 通信スレッドのコールバック呼び出し
                    mainHandler.post {
                        showSomeDialog() // 注意!メインに渡しても安全とは言えない
                    }
                }
            )
        }

ただし、この考え方には、問題を解決するヒントも含まれています。
supportFragmentManagerを対象とした時点でコンテキストは確定可能なため、MainLooperでコンテキスト状態を自動判定して、必要なタイミングまでキュー実行を保留にすることはできると考えられます。公式ライブラリのHandlerでは実装されていないというだけで、それに近い処理は自前で実装可能です。

親画面の状態によって実行制御を行うカスタムハンドラを作成する

Activityの状態を保持可能なハンドラを用意します。通常のhandleMessageは渡されたメッセージをすぐに処理するところですが、オーバーライドしたhandleMessageはpause後の状態である場合にメッセージをバッファリングします。その後のライフサイクルで、resumeのとき一気に新しいメッセージとして再投入します。

class CustomHandler(l: Looper) : Handler(l) {
    companion object {
        const val MSG_WHAT = ('E'.code shl 16) + ('T'.code shl 8) + 'C'.code
    }
    private val messageQueueBuffer = Vector<Message>()
    private var paused = false
    fun resume() {
        paused = false
        while (messageQueueBuffer.size > 0) {
            val msg = messageQueueBuffer.elementAt(0)
            messageQueueBuffer.removeElementAt(0)
            sendMessage(msg)
        }
    }
    fun pause() { paused = true }
    override fun handleMessage(msg: Message) {
        if (paused) {
            val msgCopy = Message()
            msgCopy.copyFrom(msg)
            messageQueueBuffer.add(msgCopy)
        } else {
            processMessage(msg)
        }
    }
    private fun processMessage(msg: Message?) {
        when (msg?.what) {
            MSG_WHAT -> {
                if (msg.obj is Function0<*>) {
                    val code = msg.obj as Function0<*>
                    code()
                }
            }
        }
    }
}

親画面側で、Resume/Pauseをカスタムハンドラに状態登録する

Activityのライフサイクルイベントでカスタムハンドラを制御します。必ずメインスレッドで実行するようにしてください。

class SomeActivity {
    ...
    private var mCustomHandler = CustomHandler(Looper.getMainLooper())
    override fun onResume() {
        super.onResume()
        mCustomHandler.resume()
    }
    override fun onPause() {
        super.onPause()
        mCustomHandler.pause()
    }
    ...
}

親画面がフラグメントである場合も、Resume/Pauseをカスタムハンドラに登録する

Activity -> FragmentA -> FragmentB という構成で、FragmentAにFragmentBを追加してcommitしたい場合も、同じようにFragmentAのonResume/onPauseをオーバーライドすることで扱えます。

別スレッド操作のコールバックでカスタムハンドラを使用する

通常のハンドラと同じ要領でメッセージを送ります。オブジェクトには関数を設定することで、ある程度柔軟なイベント構築が可能になります。

        CoroutineScope(Dispatchers.Default).launch { // 通信スレッド
            RetrofitClient.someRequest(
                fun () { // 通信スレッドのコールバック呼び出し
                    mCustomHandler.sendMessage(
                        mCustomHandler.obtainMessage(CustomHandler.MSG_WHAT, fun() {
                            showSomeDialog()
                        })
                    )
                }
            )
        }

まとめ

マルチスレッドとフラグメントという、よくある組み合わせの中でも見落としがちになるエラーについてピックアップしました。スレッド種別やライフサイクル上の制約によって遅延実行しなければならないケースではハンドラを使いますが、公式のハンドラはライフサイクル上の制約を加味しないようです。カスタムハンドラを作成し、NGタイミングでの実行を避ける実装が執筆時点(2023/8/22)でベストになりそうだということでご紹介しました。