Compose 单 Activity 架构实现指南
目录
- [为什么选择单 Activity](#为什么选择单 Activity)
- 整体架构蓝图
- [第一步:只声明一个 Activity](#第一步:只声明一个 Activity)
- [第二步:Activity 基类与组合入口](#第二步:Activity 基类与组合入口)
- 第三步:用类型安全路由描述所有页面
- 第四步:按模块拆分导航图
- 第五步:参数传递
- 第六步:页面间结果回传
- 第七步:统一动画系统
- [第八步:底部导航与 Scaffold](#第八步:底部导航与 Scaffold)
- 常见坑与检查清单
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()
}
4.2 根 Composable:Scaffold + NavHost
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 命名与分类规范
- 路由名即页面语义(用类名表达,不再手写字符串路径)。
- 所有路由集中在单一文件,按
//region 认证 / 首页 / 设置 ...注释分组,方便检索、避免重复。 - 带参路由用
data class构造参数表达(等价于旧版{paramName}路径,但类型安全,无需手写navArgument)。 - 底部 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配置正确