はてなブログAndroidアプリでのJetpack Navigation 3移行

 こんにちは。 アプリケーションエンジニアの id:yutailang0119 です。

 この記事は、はてなブログAndroidアプリの開発体験について解説するRecomposing Hatena Blog for Android連載の第四弾です。

 2025年から進めている「はてなブログAndroidアプリリニューアル」では、Jetpack Composeを全面的に採用しました。 その過程で2025年11月にStable化されたJetpack Navigation 3(以下Nav3)への移行を進め、本記事ではその実装を通じて得た所感と知見を共有します。

 対象読者は、Jetpack Composeで書かれたAndroidアプリの開発者、特にナビゲーションまわりの設計に課題感を持っている方を想定しています。

背景:Nav3がStable化したタイミング

 Androidアプリのリニューアル(v3.0.0)リリース時点ではNavigation Compose(Navigation 2、以下Nav2)を採用していました。 そこからすぐのNav3移行に至る流れは、おおよそ次のようなものです。

 v3.0.0のリリースとNav3のStable化がほぼ同じタイミングだったため、リリース直前の差し替えはリスクが大きいと判断し、まずはNav2のままv3.0.0をリリース。 その後で順次Nav3へ置き換えていく方針を取りました。

 Nav2での課題は大きく2つでした。

①状態管理の二重性

 Nav2を使ったComposeのナビゲーションでは、画面の表示状態を2つの異なる場所で管理する必要がありました。

  • UI側の状態: NavigationSuiteScaffoldがもつタブ選択状態
  • ナビゲーション側の状態: NavHostControllerがもつBackStack

 両者は本来連動すべきものですが、それぞれが独立して状態を持つため、currentDestinationと選択中のタブを手動で同期するロジックが必要です。 タブ切り替えと画面遷移が混ざる場面で、意図しない状態の不整合が起きやすい構造になっていました。

②アダプティブレイアウトとの相性

 NavHostは単一の最上位画面しか表示できないため、Foldableや大画面端末でlist-detailのような分割レイアウトを採用しようとすると、分割表示部分が全体のナビゲーションから外れた実装になりがちでした。 リスト側と詳細側でそれぞれ別の状態管理を持つ必要があり、画面遷移とレイアウト変化の組み合わせを破綻なく扱うのが難しいという課題がありました。

 Googleが公開しているAnnouncing Jetpack Navigation 3 for Composeandroid/nav3-recipesを読むと、Nav3は次の3つの考え方で設計されているように見えます。

"You own the back stack"

 開発者がバックスタックを完全に制御。 バックスタックはSnapshotStateList<NavKey>のような単なるState付きリストとして表現され、追加・削除・挿入はリスト操作そのものです。 rememberNavBackStack()などのヘルパーが用意されていますが、本質的には自分でリストを管理しているのと同じです。

"Get out of your way"

 Nav2にあった暗黙的な挙動(自動的なpopUpTo、画面の状態保存など)は最小限。 便利な反面、暗黙の挙動に依存して動いていた箇所は、Nav3では明示的な実装が必要です。

"Pick your building blocks"

 Nav3は「決まったナビゲーションの形」を提供しない設計。 タブ切り替え、マルチスタック、DeepLink、アダプティブレイアウトなどは、すべて部品を組み合わせて実装します。 android/nav3-recipesに典型例のサンプルがまとまっており、まずはこれを参考に自分のアプリに合わせて組み立てるとよいでしょう。

 大まかに整理すると次の構成です。

コンポーネント 役割
NavDisplay 画面表示のコンテナ。NavEntryの登録、トランジションの設定を行う
NavKey 各画面に対応するキー。@Serializableなデータクラスで定義する
NavEntry 画面1つに対応するエントリ。SceneStrategy選択時の配置やトランジション上書きを記述できる
NavBackStack バックスタックの実体。rememberNavBackStack()で取得
SceneStrategy 画面の見せ方を決める戦略。SinglePaneSceneStrategy / ListDetailSceneStrategyなどが提供される

 ポイントはSceneStrategyで、これを差し替えるだけで「通常表示」と「分割表示」を切り替えられます。 Nav2では別実装になりがちだったアダプティブレイアウトと通常表示が、同じバックスタック・同じNavEntryの上で表現できるのが大きな差分です。

はてなブログアプリでの実装

移行のスコープ

 v3.0.0リリースの裏で、2025年10〜12月にかけて一部画面から段階的にNav3化を進めていきました。 まずは独立したNavGraphBuilder遷移グラフ内で検証し、そこからSceneStrategyの準備、前画面への結果返却のNav3対応と広げていきました。

 「1つのPRで一気に切り替える」のではなく、画面単位でNav3化の準備を進めていき、最後にNav2のコードを取り除く、という方法を取りました。

タブ管理(マルチスタック)の実装

 Nav2時代の最大の課題だった「タブとBackStackの状態分離」は、Nav3ではマルチスタック構成として明示的に管理することで解決しました。 android/nav3-recipesのMultipleStacksを参考に、タブごとにNavBackStackを持ち、タブ切り替え時に表示するNavBackStackを入れ替えるという構成です。

val tabState = remember { TabBackStacks() }
val activeBackStack = tabState.backStackOf(currentTab)

NavDisplay(
    backStack = activeBackStack,
    sceneStrategy = sceneStrategy,
    entryProvider = entryProvider,
)

 BlogAppNavSuiteの中でタブ選択と「どのスタックをNavDisplayに渡すか」を管理することで、Nav2で必要だった手動の同期処理が不要になりました。

 NavSuite自体をNav3に合わせる対応や、rememberEntryDecorators()を介した共通のDecorator適用も、この段階で整理しています。

entryProviderの整備

 NavDisplayにはNavKeyごとのNavEntryを返す関数(entryProvider)を渡しますが、画面数が増えてくると単一の関数に詰め込むと肥大化します。 そこでEntryProviderScope<NavKey>を拡張し、機能モジュールごとにentryを追加できる形に整理しました。

EntryProviderScope<NavKey>.subscribingBlogEntries() {
    entry<SubscribingBlogListKey> { ... }
    entry<SubscribingBlogDetailKey> { ... }
}

 各機能モジュールが自身のNavKeyとNavEntryを提供する形になり、画面追加時の:app側への変更が最小限で済みます。

DeepLink対応

 Nav3 1.0はDeepLinkを「公式機能」としては提供していないため、recipesのサンプル実装を参考に実装しました。 処理の流れは次のとおりです。

IntentのUriパース
   ↓
Uri → DeepLinkKey(NavKeyのサブセット)への変換
   ↓
該当タブを選択し、対応するNavBackStackにpush
   ↓
NavDisplayが対応するNavEntryを表示

 各画面のDeepLink URLテンプレートは、Reflectionを使ってNavKeyから自動生成する仕組みです。 これにより、対応するURLパターンを追加する際はNavKey側にメタ情報を持たせるだけでよく、ルーティング表のような中央集権的な定義を持たずに済みます。

 対応している各種DeepLinkはNav2時代から段階的に追加してきたもので、Nav3への移行に合わせて起動フローを書き直しています。

 マルチスタック構成とDeepLinkを組み合わせると、「DeepLinkからの遷移時はタブ選択も自動的に同期される」のが自然に表現できるのも、Nav3の素直さが効いた部分でした。

 Nav3 1.2(2026年8月現在 1.2.0-alpha07)ではDeepLinkRequestUriDeepLinkMatcherなどが追加され、Nav3自身が提供するDeepLink実装が進んでいるので、確認をお勧めします。

既存実装からの移行ステップ

 総じて、次の順序で無理なく進められました。

  1. 影響範囲の小さい画面(設定、購読リスト)をNav3で再実装する
  2. NavSuiteやマルチスタックなど、複数画面にまたがる部分をNav3化する
  3. 残りの画面を順次置き換える
  4. Nav2のNavGraphBuilderを完全に削除する
  5. NavKeyの命名、entryProviderの構造など、Nav3前提のリファクタを行う

移行時点(Nav3 1.0)での制限と対応

 移行を進めていた時点では、次の3点が公式にサポートされておらず、自前で組み立てる必要がありました。

課題 当時の状況 対応
マルチスタック 直接サポートなし nav3-recipesを参考に自前実装
DeepLink 直接サポートなし Uri → NavKeyの変換パイプラインを自前実装
アダプティブレイアウト中の画面状態 画面側に「分割中かどうか」が伝わりにくい SceneStrategyの実装から判定する補助層を用意

 特に最後の項目は、1-paneでは戻るボタンを出したいが2-paneでは出したくない、といったUI判定で問題になります。 現状はNavEntryのmetadataを参照して状態を取り回す形で解決していますが、compose-material3-adaptive 1.3.0-alpha07該当する対応が入りました。 そして本日2026年8月12日に1.3.0がStableになり、ListDetailSceneStrategySupportingPaneSceneStrategyを提供するadaptive-navigation3もあわせてリリースされています。 このあたりは自前の補助層を捨てて、公式APIを採用できそうです。

移行のコストと効果

コスト

 私が取り組んだ期間はおおよそ1ヶ月でした。

  • 調査・実装:3週間(マルチスタック・DeepLinkの自前対応を含む)
  • 既存のNav2とNav3を一時的に並存させながら、画面ごとに切り替えるためのレビューコスト

 ただし、現在ではandroid/skillsを使うことで、AIのアシストを受けながら効率的に移行ができそうです。 developer.android.com

効果

  • タブ状態とBackStackの二重管理が解消され、状態モデルが単純になった
  • アダプティブレイアウトへ向けた基盤として、SceneStrategyという拡張点が手に入った
  • 画面追加時の:app側の変更が最小化され、機能モジュールが自立しやすくなった

今後の展開

 Nav3への移行を含む変更はv3.3.0としてリリースしています。

 エントリー一覧 ↔ エントリー詳細のアダプティブレイアウト(list-detail)はすでに対応済みなので、今後は他の画面にもSceneStrategyを活用した分割表示を広げていきたいと考えています。

まとめ

 Jetpack Navigation 3は、Nav2が暗黙的に提供していた挙動を意識的に剥がし、「バックスタックの所有権を開発者に戻す」設計になっています。 その結果、状態管理は素直になる一方、マルチスタックやDeepLink、アダプティブレイアウトといった応用的な要件は自前で組み立てる前提になります。

 対応してみた結果、「Nav2で困っていないなら、すぐにクリティカルになることはない」というのが正直なところです。 一方で、状態の二重管理やアダプティブレイアウトに課題を感じている場合は、Nav3が提供する設計思想は十分検討に値します。

 はてなブログアプリでは、リスクを抑えるために段階的に移行を進めました。これからNav3を検討する方の参考になれば嬉しいです。

 今後とも、はてなブログAndroidアプリをどうぞご利用ください。 play.google.com

Recomposing Hatena Blog for Androidはこの後も続きます。