Compose 单 Activity 架构实现指南

Compose 单 Activity 架构实现指南


目录

  1. [为什么选择单 Activity](#为什么选择单 Activity)
  2. 整体架构蓝图
  3. [第一步:只声明一个 Activity](#第一步:只声明一个 Activity)
  4. [第二步:Activity 基类与组合入口](#第二步:Activity 基类与组合入口)
  5. 第三步:用类型安全路由描述所有页面
  6. 第四步:按模块拆分导航图
  7. 第五步:参数传递
  8. 第六步:页面间结果回传
  9. 第七步:统一动画系统
  10. [第八步:底部导航与 Scaffold](#第八步:底部导航与 Scaffold)
  11. 常见坑与检查清单

1. 为什么选择单 Activity

维度 多 Activity 单 Activity + Compose
页面过渡 依赖窗口动画,共享元素难做 同窗口合成,过渡天然连贯
生命周期 频繁 onCreate 重建 仅一个宿主,开销小
状态管理 跨 Activity 传递繁琐 导航状态、全局 UI 集中在一棵树
与 Compose 契合度 需要 ComponentActivity 承载 本身就是「树 + 状态」模型

结论 :现代 Compose 应用优先采用单 Activity,把所有「页面」做成 Composable,由 NavHost 按当前路由渲染。


2. 整体架构蓝图

复制代码
                 ┌────────────────────────────────────────┐
    LAUNCHER ──▶ │             RootActivity                │
                 │  ┌────────────────────────────────────┐ │
                 │  │ Scaffold (bottomBar / topBar / ...) │ │
                 │  │  ┌─────────────┐  ┌──────────────┐ │ │
                 │  │  │   NavHost   │  │  BottomBar   │ │ │
                 │  │  └─────────────┘  └──────────────┘ │ │
                 │  └────────────────────────────────────┘ │
                 └────────────────────────────────────────┘
                          │ 挂载 N 个 NavGraph
        ┌──────────┬──────────┬──────────┬──────────┬──────────┐
     authNav   homeNav   featureANav featureBNav  commonNav   ...
  • 一个 Activity 只负责「承载组合树 + 提供 NavHost」。
  • 每个业务模块 一个 NavGraphBuilder 扩展函数,集中声明该模块的页面与跳转。
  • 每个页面 是一个 @Composable Screen(content, callbacks),通过回调把事件「向上」交回 ViewModel。

3. 第一步:只声明一个 Activity

在 AndroidManifest.xml 中只注册一个 Activity,并作为启动项:

xml 复制代码
<activity
    android:name=".ui.RootActivity"
    android:exported="true">
    <intent-filter>
        <action android:name="android.intent.action.MAIN" />
        <category android:name="android.intent.category.LAUNCHER" />
    </intent-filter>
</activity>

要点:

  • 不要为每个页面新建 Activity;新页面 = 新路由 + 新 Composable。
  • android:theme 可保留一个轻量主题,仅作为启动窗口背景兜底;真正的视觉主题由 Compose 在运行时提供。

4. 第二步:Activity 基类与组合入口

4.1 基类职责

抽象一个 BaseComposeActivity,统一处理与「页面内容无关」的宿主层逻辑:

  • 沉浸式边到边 :调用 enableEdgeToEdge();并在 onConfigurationChanged 中重设,使切换深浅色主题无需重建 Activity。
  • 设置组合内容 :在 setContent { } 内包裹应用主题(如 AppTheme),并提供全局 CompositionLocal(例如全局对话框状态)。
  • 抽象内容钩子 :暴露 ProviderContent(),让子类填入根 Composable。
kotlin 复制代码
abstract class BaseComposeActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            AppTheme {
                CompositionLocalProvider(LocalDialog provides dialogState) {
                    ProviderContent()
                    GlobalDialogHost()   // 全局对话框宿主,挂在内容之后
                }
            }
        }
    }
    @Composable
    abstract fun ProviderContent()
}

RootActivity 仅做一件事------搭 Scaffold 并在此挂载 NavHost:

kotlin 复制代码
@AndroidEntryPoint   // 需要 Hilt 注入时
class RootActivity : BaseComposeActivity() {
    @Composable
    override fun ProviderContent() {
        val navController = rememberNavController()
        val currentRoute by navController.currentBackStackEntryAsState()

        Scaffold(
            containerColor = Color.Transparent,   // 透出窗口背景图
            bottomBar = { /* 见第 10 节 */ }
        ) { padding ->
            NavHost(
                navController = navController,
                startDestination = Routes.Launch,
                modifier = Modifier.padding(padding)
            ) {
                authNavGraph(navController)
                homeNavGraph(navController)
                featureANavGraph(navController)
                commonNavGraph(navController)
            }
        }
    }
}

要点:

  • startDestination 直接传路由类型对象(类型安全),不是字符串。
  • navController 用 rememberNavController() 在组合树创建,向下传给各 NavGraph 扩展。
  • Scaffold 的 padding 必须施加到 NavHost,否则内容被底部栏遮挡。

5. 第三步:用类型安全路由描述所有页面

5.1 用密封类表达路由

集中定义一个 sealed class Routes,并用 @Serializable 标注,从而获得编译期类型检查 与自动参数序列化:

  • 无参页面 → object
  • 带参页面 → data class(字段为可序列化类型)
kotlin 复制代码
@Serializable
sealed class Routes {
    @Serializable object Launch : Routes()
    @Serializable object Home : Routes()
    @Serializable data class Detail(val id: Long) : Routes()
    @Serializable data class Web(val title: String, val url: String) : Routes()
}

5.2 命名与分类规范

  1. 路由名即页面语义(用类名表达,不再手写字符串路径)。
  2. 所有路由集中在单一文件,按 //region 认证 / 首页 / 设置 ... 注释分组,方便检索、避免重复。
  3. 带参路由用 data class 构造参数表达(等价于旧版 {paramName} 路径,但类型安全,无需手写 navArgument)。
  4. 底部 Tab 等需要「显隐判断」的路由,可在 companion 中维护集合与判定函数。

进阶:判断当前是否在某路由,优先用 destination.hasRoute<Home>() 而非解析 route 字符串类名,更稳健。


6. 第四步:按模块拆分导航图

把导航声明从 RootActivity 中抽离,按业务模块拆成多个 NavGraphBuilder 扩展函数,参数为统一的 navController:

kotlin 复制代码
fun NavGraphBuilder.homeNavGraph(navController: NavHostController) {
    animatedComposable(route = Routes.Home::class) {
        val vm: HomeViewModel = hiltViewModel()
        val uiState by vm.uiState.collectAsStateWithLifecycle()
        HomeScreen(
            uiState = uiState,
            onItemClick = { id -> navController.navigate(Routes.Detail(id)) }
        )
    }
}

好处:

  • RootActivity 只负责「挂载」,不堆积所有 composable { }。
  • 每个模块的页面与其 Screen 就近组织,改动局部即可。
  • 预留 typeMap / deepLinks 参数,便于后续支持自定义 NavType 与深链。

7. 第五步:参数传递

7.1 正向传参(类型安全)

kotlin 复制代码
// 跳转:直接传对象
navController.navigate(Routes.Detail(id = 12))
navController.navigate(Routes.Web(title = "帮助", url = "https://..."))

// 目标页读取
composable(route = Routes.Web::class) { backStackEntry ->
    val web: Routes.Web = backStackEntry.toRoute()
    WebScreen(title = web.title, url = web.url)
}
  • 支持 Int / Long / String / Boolean / 枚举 等 kotlinx.serialization 可序列化类型。
  • 复杂类型(自定义对象、列表)通过 typeMap: Map<KType, NavType<*>> 扩展 composable 即可。
  • URL 等字符串由 Navigation 内部自动做 URL 安全编码,无需手动 URLEncoder。

8. 第六步:页面间结果回传

详情页 / 选择器回传结果给上一页,推荐用 savedStateHandle 方式(在 navResult / resultRecipient 正式 API 之前即为成熟做法,也兼容旧版本):

8.1 来源页写入

kotlin 复制代码
navController.previousBackStackEntry?.savedStateHandle?.set(KEY_RESULT, value)
navController.popBackStack()

8.2 目标页监听(响应式)

kotlin 复制代码
val savedStateHandle = navController.currentBackStackEntryAsState().value?.savedStateHandle
LaunchedEffect(savedStateHandle) {
    savedStateHandle?.getStateFlow<ResultType?>(KEY_RESULT, null)
        ?.collect { result ->
            result?.let {
                // 使用结果
                savedStateHandle.remove<ResultType>(KEY_RESULT)  // 消费后清除
            }
        }
}

要点:

  • 用 getStateFlow().collect 而非一次性 get,避免错过时机。
  • 消费后 remove,防止配置变更 / 重入重复触发。

现代备选(Navigation 2.8+):用 navController.getResult<T>(key) / saveStateHandle.setResult(key, value),或在 composable 注册 resultKey 配合 resultRecipient,语义更清晰。


9. 第七步:统一动画系统

9.1 工厂 + 枚举,避免重复

把动画抽成工厂函数,并用枚举分档,每个 composable 只选一个风格:

kotlin 复制代码
enum class AnimationStyle { SLIDE, FADE, SCALE, SLIDE_UP_DOWN, NONE }

object NavAnimations {
    fun slideInFromRight() = slideInHorizontally { w -> w } + fadeIn(tween(300))
    fun slideOutToLeft()  = slideOutHorizontally { w -> -w } + fadeOut(tween(300))
    // ...左进右出、淡入淡出、缩放、底部弹入等
}

9.2 统一封装入口

用一个 animatedComposable 把 AnimationStyle 映射为 enter/exit/popEnter/popExit 四元组,再调用标准 composable:

kotlin 复制代码
fun NavGraphBuilder.animatedComposable(
    route: KClass<out Routes>,
    animationStyle: AnimationStyle = AnimationStyle.SLIDE,
    content: @Composable AnimatedVisibilityScope.(NavBackStackEntry) -> Unit
) {
    val t = when (animationStyle) {
        AnimationStyle.SLIDE -> Quad(
            enter = { NavAnimations.slideInFromRight() },
            exit  = { NavAnimations.slideOutToLeft() },
            popEnter = { NavAnimations.slideInFromLeft() },
            popExit   = { NavAnimations.slideOutToRight() }
        )
        AnimationStyle.FADE -> Quad( /* fade 四元组 */ )
        // SCALE / SLIDE_UP_DOWN / NONE 类似
    }
    composable(route = route,
        enterTransition = t.enter, exitTransition = t.exit,
        popEnterTransition = t.popEnter, popExitTransition = t.popExit,
        content = content)
}

约定:

  • 详情页默认 SLIDE(右进左出)。
  • 底部 Tab 主页用 FADE,避免切换突兀。
  • 弹窗 / 抽屉式页用 SLIDE_UP_DOWN。
  • 统一时长(如 300ms)+ 缓动(tween),保持全局手感一致。
  • 用 + 组合多个 transition(如 slideIn + fadeIn)。

10. 第八步:底部导航与 Scaffold

10.1 底部栏显隐

用 AnimatedVisibility 包裹 NavigationBar,根据「当前路由是否属于底部 Tab」决定可见性,进出用短促垂直滑动:

kotlin 复制代码
bottomBar = {
    AnimatedVisibility(
        visible = shouldShowBottomBar(currentRoute),
        enter = slideInVertically(initialOffsetY = { it }, animationSpec = tween(100)),
        exit  = slideOutVertically(targetOffsetY = { it }, animationSpec = tween(100))
    ) { BottomNavigationBar(navController, currentRoute) }
}

10.2 Tab 切换的栈管理(黄金配置)

kotlin 复制代码
navController.navigate(route) {
    popUpTo(navController.graph.startDestinationId) { saveState = true }
    launchSingleTop = true
    restoreState = true
}

要点:

  • popUpTo 回到导航图起点,避免返回键一步步回退。
  • launchSingleTop 防重复实例。
  • saveState + restoreState 是保留 Tab 页面状态(滚动、输入)的关键。
  • 若希望「任意 Tab 返回即退出 App」,可把 popUpTo 目标设为根(0);按产品需求选择语义。

10.3 图标与文案

  • 图标:根据 selected 选 xxx_selected / xxx_unselected 两张图。
  • 文案:放 stringResource,便于多语言。
  • 颜色:取自自定义 ColorScheme,含选中 / 未选中图标、文字、indicator 半透明主题色。

11. 常见坑与检查清单

  • ❌ 多个 NavHost / navController 不一致 :Scaffold 与 NavHost 必须用同一个 navController,否则底部栏与内容不同步。
  • ❌ 用 route 字符串解析做匹配 :改用 destination.hasRoute<Home>() 更稳健。
  • ❌ ViewModel 作用域错配 :hiltViewModel() 默认作用域为当前目的地;跨页面共享需 activityViewModel() 或提升到父图。
  • ❌ 忘记 padding :NavHost 必须 Modifier.padding(paddingValues)。
  • ❌ 后台仍在收集 :用 collectAsStateWithLifecycle()(lifecycle-runtime-compose)而非裸 collectAsState。
  • ⚠️ 结果回传未 remove :savedStateHandle 消费后务必 remove,防重复触发。
  • ⚠️ 动画时长不统一:集中到工厂 / 枚举,避免每个页面各写 300/250/400。

落地检查表:

  • Manifest 仅一个 LAUNCHER Activity
  • 有 BaseComposeActivity(edge-to-edge + 主题 + 全局 Dialog)
  • 用 sealed class + @Serializable 集中定义路由
  • 导航按模块拆 NavGraphBuilder 扩展并挂载到 NavHost
  • 参数走类型安全 navigate(Route(arg)) / toRoute()
  • 结果回传用 savedStateHandle 或现代 resultRecipient
  • 动画统一封装为 animatedComposable + 风格枚举
  • 底部栏 popUpTo + launchSingleTop + restoreState 配置正确

相关推荐
FlightYe1 小时前
音视频修炼之容器与协议(二):HLS和DASH流媒体
android·linux·ffmpeg·音视频·android runtime·dash
allanGold1 小时前
Android Studio 无线调试
android·ide·android studio
德迅云安全-上官2 小时前
服务器遭攻击如何快速响应?从技术原理到接入防御架构的实战指南
运维·服务器·架构
super_Omni2 小时前
PDF/DOCX 解析为纯文本结构化字段关键词打分排序截断推给 HR
架构·开源软件
Kapaseker2 小时前
AI 会干掉 React Native 吗?
android·kotlin
2501_915921432 小时前
iOS 实时日志怎么看?KeyMob 的日志过滤、崩溃报告与符号化
android·ios·小程序·https·uni-app·iphone·webview
千里马学框架2 小时前
Service服务绑定对进程adj影响及常见绑定错误
android·智能手机·性能优化·程序员·framework·性能·安卓失业
刘老高2 小时前
微服务架构实战:ShardingSphere Proxy + Canal 分库分表与 ES 同步全链路
elasticsearch·微服务·架构
誰能久伴不乏5 小时前
桌面客户端的运行日志系统:SQLite 分块 + 独立写线程 + 文本降级
数据库·c++·qt·架构·sqlite