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

TimeTreeのNavigation 3移行記

Avatar for Dai Miyamoto Dai Miyamoto
October 08, 2025
97

TimeTreeのNavigation 3移行記

DroidKaigi 2025 後夜祭

Avatar for Dai Miyamoto

Dai Miyamoto

October 08, 2025
Tweet

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 エンジニア採⽤中! →採⽤ページはコチラ 懇親会でもぜひお話しましょう!