Upgrade to Pro — share decks privately, control downloads, hide ads and more …

TimeTreeのNavigation 3移行記

Avatar for Dai Miyamoto Dai Miyamoto
October 08, 2025
210

TimeTreeのNavigation 3移行記

DroidKaigi 2025 後夜祭

Avatar for Dai Miyamoto

Dai Miyamoto

October 08, 2025

Transcript

  1. 発表者紹介 宮本 ⼤ Miyamoto Dai • 2025年2⽉ 株式会社TimeTree 中途⼊社 •

    公開カレンダーチーム Androidエンジニア • 社内ニックネーム: Danny • DroidKaigi 2022ではCamera Xについて登壇 @dairoid7774 Dai1678
  2. 発表の概要 • DroidKaigi 2025 セッション 「Navigation 2 を 3 に移⾏する(予定)ためにやったこと」

    のTimeTree版 • 違いは対象アーキテクチャ [DroidKaigi セッション] Navigation Compose ↓ Navigation 3 (移⾏予定) [今回の発表] Multi Activity ↓ Navigation 3 (導⼊中)
  3. Navigation 3 おさらい • Jetpack Composeに最適化されたナビゲーションライブラリ • 2025年5⽉ Google I/Oで発表

    • オープンで拡張性の⾼い設計 従来のナビゲーション バックスタックを フレームワーク / ライブラリが管理 Navigation 3 バックスタックを 開発者が管理‧制御
  4. バックスタック管理‧制御 // 画⾯を表すKeyを定義 data object ProductList data class ProductDetail(val id:

    String) @Composable fun MyApp() { // バックスタックはListで管理 val backStack = remember { mutableStateListOf<Any>(ProductList) } // バックスタックにKeyを追加すると、ライブラリが状態を更新してくれる backStack.add(ProductDetail(id = "ABC")) // バックスタックからKeyを削除すると画⾯を破棄できる backStack.removeLastOrNull() }
  5. NavDisplay と Single Activity • バックスタックを監視してコンテンツをレンダリング • 全ての画⾯のコンテナとして機能 • Single

    Activity構成が必要 NavDisplayとは Activity A Activity B Activity C Activity NavDisplay - 〇〇Screen - ✕✕Screen
  6. モジュール構成 features-xxx - XXXScreen components-navigation - NavEntryProviderのinterface定義 - NavKeyの定義 app

    NavEntryProviderの実装クラス features-root NavDisplayを持つ新しい ルーティングActivity NavEntryProvider.kt interface NavEntryProvider<T : NavKey> : () -> (T) -> NavEntry<T> interface RootNavEntryProvider : NavEntryProvider<RootNavKey> RootNavKey.kt sealed class RootNavKey : NavKey { @Serializable data object Calendar : RootNavKey() @Serializable data object Settings : RootNavKey() }
  7. モジュール構成 features-calendar - CalendarScreen components-navigation NavEntryProviderのinterface定義 app NavEntryProviderの実装クラス features-root NavDisplayを持つ新しい

    ルーティングActivity NavEntryProviderImpl.kt class RootNavEntryProviderImpl @Inject constructor() : RootNavEntryProvider { override fun invoke(): (RootNavKey) -> NavEntry<RootNavKey> = entryProvider { entry<RootNavKey.Calendar> { CalendarScreen() } entry<RootNavKey.Settings> { SettingsScreen() } } }
  8. モジュール構成 features-xxx - XXXScreen components-navigation NavEntryProviderのinterface定義 app NavEntryProviderの実装クラス features-root NavDisplayを持つ新しい

    ルーティングActivity RootNavDisplay.kt @Composable fun RootNavDisplay( rootNavEntryProvider: RootNavEntryProvider, ) { val rootBackStack = rememberNavBackStack(RootNavKey.Calendar) CompositionLocalProvider( LocalRootBackStack provides rootBackStack, ) { NavDisplay( backStack = rootBackStack.backStack, entryProvider = rootNavEntryProvider(), entryDecorators = listOf( rememberSceneSetupNavEntryDecorator(), rememberSavedStateNavEntryDecorator(), rememberViewModelStoreNavEntryDecorator(), ), ) } }
  9. 段階的移⾏ app NavEntryProviderの実装クラス features-root NavDisplayを持つ新しい ルーティングActivity features-legacy-home これまでログイン後に遷移していた ホーム画⾯を持つActivity features-app-navigation

    - Feature Flag値によって新旧どちらのActivityを起動 するか判断し、遷移先ActivityのIntentを返す AppStarter.kt @Singleton class AppStarter @Inject constructor( private val context: Context, ) { val defaultIntent: Intent get() = if (/** リニューアル後の場合 */) { Intent(context, RootActivity::class.java) } else { Intent(context, LegacyHomeActivity::class.java) } val restoreToFrontIntent: Intent get() = defaultIntent.apply { addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) } }
  10. Navigation 3導⼊中で⾏った⼯夫 主な課題 ① ディープリンク‧PUSH通知からの起動 バックスタックの構築が必要 • 従来: TaskStackBuilder •

    Nav3: ⾃前でListを管理 ③ Activitiyベースの遷移との共存 Composable内にActivityに依存する処理があると移⾏しづらい • Activityに依存する処理をComposableから削除する
  11. ⼯夫① ディープリンク‧PUSH通知からの起動 Activity起動後にバックスタックの構築を明⽰的に⾏う RememberOnNewIntent.kt @Composable fun rememberNewIntent(): Intent? { val

    activity = LocalActivity.current as ComponentActivity? ?: return null var intent by remember { mutableStateOf<Intent?>(null) } DisposableEffect(activity) { val onNewIntentListener = Consumer { newIntent: Intent -> intent = newIntent } activity.addOnNewIntentListener(onNewIntentListener) onDispose { activity.removeOnNewIntentListener(onNewIntentListener) } } return intent }
  12. ⼯夫① ディープリンク‧PUSH通知からの起動 Activity起動後にバックスタックの構築を明⽰的に⾏う BuildBackStackHandler.kt @Composable fun BuildBackStackHandler() { val launchedAction:

    LaunchedAction? = rememberNewIntent()?.getParcelableExtra(“build_back_stack”, LaunchedAction::class.java) val rootBackStack = LocalRootBackStack.current LaunchedEffect(launchedAction) { when (launchedAction) { is LaunchedAction.CalendarEvent -> { rootBackStack.add(RootNavKey.CalendarEvent) } } } }
  13. ⼯夫② Activitiyベースの遷移との共存 Composable内でActivityに依存している CalendarEventScreen.kt @Composable fun CalendarEventScreen() { Scaffold( topBar

    = { TopAppBar( title = { … } navigationIcon = { IconButton(onClick = { activity?.finish() }) { … } ) } ) { … } }
  14. ⼯夫② Activitiyベースの遷移との共存 Activityに依存する処理はホスティングする CalendarEventScreen.kt @Composable fun CalendarEventScreen( onBack: () ->

    Unit, ) { Scaffold( topBar = { TopAppBar( title = { … } navigationIcon = { IconButton(onClick = onBack) { … } ) } ) { … } } CalendarEventActivity.kt @AndroidEntryPoint class CalendarEventActivity : ComponentActivity { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { TimeTreeTheme { CalendarEventScreen(onBack = { finish() }) } } } }
  15. ⼯夫② Activitiyベースの遷移との共存 Navigation 3に依存する処理もホスティングする NavEntryProviderImpl.kt class RootNavEntryProviderImpl @Inject constructor() :

    RootNavEntryProvider { override fun invoke(): (RootNavKey) -> NavEntry<RootNavKey> = entryProvider { entry<RootNavKey.CalendarEvent> { val backstack = LocalRootBackStack.current CalendarEventScreen( onBack = { backstack.removeLastOrNull() } ) } } }
  16. ⼯夫② Activitiyベースの遷移との共存 Activityでフィールドインジェクションしたものを使⽤ CalendarEventScreen.kt @Composable fun CalendarEventScreen( eventLogger: EventLogger, )

    { … } CalendarEventActivity.kt @AndroidEntryPoint class CalendarEventActivity : ComponentActivity { @field:Inject internal latent var eventLogger: EventLogger override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { TimeTreeTheme { CalendarEventScreen(eventLogger = eventLogger) } } } }
  17. ⼯夫② Activitiyベースの遷移との共存 Composable内でインジェクトする CalendarEventScreen.kt @Composable fun CalendarEventScreen() { val activity

    = LocalActivity.current val entryPoint = EntryPointAccessors.fromActivity( activity = requreNotActivity(activity) entryPoint = CalendarEventScreenEntryPoint::class.java, ) entryPoint.eventLogger().logEvent() } CalendarEventScreenEntryPoint.kt @EntryPoint @InstallIn(ActivityComponent::class) interface CalendarEventScreenEntryPoint { fun eventLogger(): EventLogger }
  18. 今後の展望 現在: 部分的なNavigation 3 導⼊ NavDisplay Activity + 既存Activity郡のハイブリッド構成 中期⽬標:

    全画⾯のCompose化 既存画⾯を段階的にCompose化してNavDisplay管理下に移⾏ 🚧道のりは⻑い... Composeに移⾏できていない画⾯が多数 最終⽬標: 完全なNavigation 3構成 全画⾯がNavDisplayを持つ⼀つのActivityの元で管理される ✨2ペインレイアウトなど⼤画⾯デバイス最適化の実現
  19. Appendix • Android Developers ナビゲーション 3 https://developer.android.com/guide/navigation/navigat ion-3?hl=ja • Navigation

    2 を 3 に移⾏する(予定) ためにやったこと https://speakerdeck.com/yokomii/navigation-2-wo-3-niyi -xing-suru-yu-ding-tameniyatutakoto • nav3-recipes https://github.com/android/nav3-recipes エンジニア採⽤中! →採⽤ページはコチラ 懇親会でもぜひお話しましょう!