2
5

BroadcastReceiverでの非同期処理にはgoAsyncを使おう

Last updated at Posted at 2024-08-10

Jetpack Glanceを調べていたら、非常に優れたコードを発見したので紹介します。
goAsyncという拡張関数です。なぜこれがinternalなんでしょう?知見の詰まった素晴らしいコードです。

CoroutineBroadcastReceiver.kt
internal fun BroadcastReceiver.goAsync(
    coroutineContext: CoroutineContext = Dispatchers.Default,
    block: suspend CoroutineScope.() -> Unit,
) {
    val coroutineScope = CoroutineScope(SupervisorJob() + coroutineContext)
    val pendingResult = goAsync()

    coroutineScope.launch {
        try {
            try {
                block()
            } catch (e: CancellationException) {
                throw e
            } catch (t: Throwable) {
                Log.e(GlanceAppWidgetTag, "BroadcastReceiver execution failed", t)
            } finally {
                // Nothing can be in the `finally` block after this, as this throws a
                // `CancellationException`
                coroutineScope.cancel()
            }
        } finally {
            // This must be the last call, as the process may be killed after calling this.
            try {
                pendingResult.finish()
            } catch (e: IllegalStateException) {
                // On some OEM devices, this may throw an error about "Broadcast already finished".
                // See b/257513022.
                Log.e(GlanceAppWidgetTag, "Error thrown when trying to finish broadcast", e)
            }
        }
    }
}

Jetpack Glanceでは以下のように使われています。

GlanceAppWidgetReceiver.kt
    @CallSuper
    override fun onUpdate(
        context: Context,
        appWidgetManager: AppWidgetManager,
        appWidgetIds: IntArray
    ) {
        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
            Log.w(
                TAG,
                "Using Glance in devices with API<23 is untested and might behave unexpectedly."
            )
        }
        goAsync(coroutineContext) {
            updateManager(context)
            appWidgetIds.map { async { glanceAppWidget.update(context, it) } }.awaitAll()
        }
    }

これは何

BroadcastReceiverでIntentを受け取った後、メインスレッド以外で処理を実行したり、suspend関数を実行したい場合がよくあります。このようなシチュエーションでは、以下のようにonReceiveで非同期処理を起動し、そのままonReceiveメソッドを終了させてしまっていませんか?私は以前、普通にそうしてしまっていました。

class SomeReceiver: BroadcastReceiver() {
    private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
    override fun onReceive(context: Context, intent: Intent) {
        scope.launch { 
            suspendFoo()
        }
    }
}

BroadcastReceiverのライフサイクルは、原則onReceiveメソッドの実行中だけが保証されています。onRecieveで非同期処理を起動し、onReceiveが終了してしまうと、非同期処理が残っていても、OSは既に終了したものと見なしてしまう可能性があります。
ほとんどの場合、起動される非同期処理は1秒とかからず終了するでしょうから、このような実装でも問題となることはあまりないとは思いますが、良い実装とは言えません。

BroadcastReceiverで非同期処理を行う仕組みは、API 11(Android 3.0)から用意されています。

このメソッドを実行すると、PendingResultが返却され、PendingResult.finish()が呼び出されるまでの間、BroadcastReceiverの実行を保留させることができます。


以上を踏まえて、CoroutineBroadcastReceiver.ktの実装を見てみましょう。

CoroutineBroadcastReceiver.kt
internal fun BroadcastReceiver.goAsync(
    coroutineContext: CoroutineContext = Dispatchers.Default,
    block: suspend CoroutineScope.() -> Unit,
) {
    val coroutineScope = CoroutineScope(SupervisorJob() + coroutineContext)
    val pendingResult = goAsync()

    coroutineScope.launch {
        try {
            try {
                block()
            } catch (e: CancellationException) {
                throw e
            } catch (t: Throwable) {
                Log.e(GlanceAppWidgetTag, "BroadcastReceiver execution failed", t)
            } finally {
                // Nothing can be in the `finally` block after this, as this throws a
                // `CancellationException`
                coroutineScope.cancel()
            }
        } finally {
            // This must be the last call, as the process may be killed after calling this.
            try {
                pendingResult.finish()
            } catch (e: IllegalStateException) {
                // On some OEM devices, this may throw an error about "Broadcast already finished".
                // See b/257513022.
                Log.e(GlanceAppWidgetTag, "Error thrown when trying to finish broadcast", e)
            }
        }
    }
}

CoroutineScopeはメソッド内で作成され、実行が完了するとcancelされています。
BroadcastReceiverのライフサイクルが1メソッドの実行中に限られるので、scopeもその範囲内に閉じています。

処理の実行はtryブロックが二重になっています。
内側のブロックでは、CancellationExceptionは外部に投げ、それ以外のThrowableは握りつぶしています。(ここが汎用メソッドになっていない理由かもしれません。)そして、finallyでcoroutineScopeをcancelしています。CancellationExceptionが投げられるので外側のfinallyにまとめられないとコメントされています。
外側のfinallyでpendingResult.finish()を実行します。このとき、一部デバイスでIllegalStateExceptionが投げられるため、それをcatchしています。これも貴重な知見ですね。

注意点

2回目のBroadcastReceiver.goAsync()はnullが返るようになります。
BroadcastReceiver.goAsync()を実行すると、BroadcastReceiver自体の結果を受けとる機能をPendingResultに委譲してしまいます。2回目はすでに委譲が行われているため、新たにPendingResultを返すことはできないわけですね。
非同期実行を行う場合は、処理を一つにまとめて、すべて完了してからPendingResult.finish()を呼び出す必要があります。複雑な処理を実行する場合は、Workerなど他の仕組みを使うべきですね。

ちょっと困ってしまうのが、Jetpack GlanceのonUpdateなどで、Glanceのsuspend関数をつかってロギングなど軽い処理を行いたい場合です。goAsync()GlanceAppWidgetReceiver側で使われてしまっているので、PendingResultを受け取れません。別のBroadcastReceiverに処理を渡すか、Workerを起動するかが必要ですが、本当に小さな処理なのに、と悩ましいところです。

2
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
5