Gunosy Tech Blog

Gunosy Tech Blogは株式会社Gunosyのエンジニアが知見を共有する技術ブログです。

Android OnBackInvokedDispatcher の導入

こんにちは。Android アプリ開発担当の nagayama(@nagayan_dev)です。

今回私が担当している「auサービスToday」で OnBackInvokedDispatcher を導入したため、その内容をお伝えします。


背景

Android でのバックキーイベントは Activity#onBackPressed() があり、私を含め Android アプリ開発者は長く慣れ親しんだ API であると思います。しかし、 API レベル 33 で deprecated になり、多くの開発者に衝撃を与えました。 developer.android.com

OnBackInvokedDispatcher について

Activity#onBackPressed() の代わりに onBackInvokedDispatcher の使用が推奨されています。

developer.android.com

onBackPressed() とは違い、バックキーイベントを受け取るには registerOnBackInvokedCallback() メソッドで OnBackInvokedCallback のバックキーイベントを登録させる必要があります。

しかし、単純にフィールド変数等に定義してしまうと以下のエラーが出てしまいます。

java.lang.NoClassDefFoundError: Failed resolution of: Landroid/window/OnBackInvokedCallback;

OnBackInvokedCallback は API レベル 33 以上でないと参照ができず、それ未満の端末では実行することができませんでした。

そこでもう一つのバックキーイベントである onBackPressedDispatcher を用いて、API レベル 33 以上と未満とで処理を分けられるようにしたいと考えました。

developer.android.com

onBackPressedDispatcher は lifecycle に紐づいて使用するバックキーイベントで、例えば Fragment 単位でバックキーイベントのコールバックを受け取ることができます。「auサービスToday」はまだ Fragment を用いて画面表示を行なっているため、onBackPressedDispatcher のみの対応でも問題はありません。しかし現在フル Compose 化と Navigation 導入を検討しており、 Fragment を使用しなくなることを考えると onBackInvokedDispatcher を主軸とした設計の方が良いと判断しました。

実装

下記が今回のバックキーイベントをハンドリングしてくれる基本クラスの BackPressHandler です。

class BackPressHandler(
        private val activity: FragmentActivity?,
        private val onBackPressed: () -> Unit
) {
    private val launcher: BackPressedLauncher by lazy {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
            BackPressedLauncherTiramisuImpl(activity, ::onBackPressed)
        } else {
            BackPressedLauncherImpl(activity, ::onBackPressed)
        }
    }

    fun register() {
        launcher.register(activity)
    }

    fun unregister() {
        launcher.unregister()
    }

    abstract class BackPressedLauncher {
        abstract fun register(lifecycleOwner: LifecycleOwner)
        abstract fun unregister()
    }

    /**
     * OS13以上の場合は onBackInvokedDispatcher を用いたバックキーハンドリングをする必要がある
     *
     */
    @RequiresApi(Build.VERSION_CODES.TIRAMISU)
    private class BackPressedLauncherTiramisu(
            private val activity: ComponentActivity?,
            onBackPressed: () -> Unit
    ) : BackPressedLauncher() {
        private val backInvokedCallback = OnBackInvokedCallback {
            onBackPressed.invoke()
        }

        override fun register(lifecycleOwner: LifecycleOwner) {
            activity?.onBackInvokedDispatcher?.registerOnBackInvokedCallback(OnBackInvokedDispatcher.PRIORITY_OVERLAY, backInvokedCallback)
        }

        override fun unregister() {
            activity?.onBackInvokedDispatcher?.unregisterOnBackInvokedCallback(backInvokedCallback)
        }
    }

    /**
     * OS13未満の場合のバックキーハンドリング
     * ライブラリで onBackInvokedDispatcher を参照できないため、クラスを分ける
     */
    private class BackPressedLauncherImpl(
            private val activity: ComponentActivity?,
            private val onBackPressed: () -> Unit
    ) : BackPressedLauncher() {
        private val onBackPressedCallback = object : OnBackPressedCallback(true) {
            override fun handleOnBackPressed() {
                onBackPressed.invoke()
            }
        }

        override fun register(lifecycleOwner: LifecycleOwner) {
            activity?.onBackPressedDispatcher?.addCallback(lifecycleOwner, onBackPressedCallback)
        }

        override fun unregister() {
            onBackPressedCallback.remove()
        }
    }
}

BackPressedLauncher の interface を用意し、それを継承した内部クラスとして API レベル 33 以上の BackPressedLauncherTiramisuImpl とそれ未満の BackPressedLauncherImpl を作成します。それぞれの内部クラス内で OnBackInvokedCallbackOnBackPressedCallback を作成し、登録・解除ができるようにします。内部クラスに内包されているため、 API レベル 33 未満でも OnBackInvokedCallback が参照できないエラーが発生されなくなります。

こちらを使用する実装としては、例えば Activity では下記のようになります。

    val backPressHandler = BackPressHandler(this) {
        // do back event
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        backPressHandler.register()
        ~省略~
    }
    
    override fun onDestroy() {
        backPressHandler.unregister()
        ~省略~
    }

使用する側は API レベルを意識せずにバックキーイベントを受けとることができるようになりました。

今回は省略しましたが「複数の Fragment を重複して表示をしている場合」や「バックキー無効の場合」は、今回の実装では不十分であるため、通知をしている onBackPressed 等で対応が必要となります。

まとめ

OnBackInvokedDispatcher の導入についてお伝えしました。API レベル 33 未満での考慮が必要であり、またバックキーイベントはアプリ開発の中でも根幹部分の一つでもあるため、しっかりとした理解が必要です。長く開発を共に過ごした onBackPressed API との別れは名残惜しいですが、新たな開発ステージへステップアップしていきましょう。