
📰 每日要闻
• 亚洲股市普涨,AI 热情盖过伊朗协议不确定性,韩国 KOSPI 创历史新高
• 鲍威尔获肯尼迪勇气奖,卸任美联储主席后首次公开发声暗批白宫越界
• Anthropic 估值超过 OpenAI,AI 大模型公司估值格局再度生变(少数派派早报)
• 苹果代工厂开造人形机器人,一场押注未来的产能大迁移(36 氪硬氪观察)
• AMD 新策略:让用户继续用老款硬件,强调 AI 算力下沉的价值(The Verge)
最近几年,"单 Activity 架构"在 Android 圈子里几乎成了一个默认共识。官方文档推它,Compose 体系把它推到主舞台,团队一开新项目,第一句话经常就是"咱们这次直接单 Activity 吧"。
但真把它落到一个有六七十个模块、上百个页面、要支持外部深链路、要支持冷启动直达任意页面的大型 App 里,你会发现单 Activity 远不是"把 Activity 换成 Composable"这么简单。它会暴露出一连串小项目里根本不会遇到的问题——ViewModel 范围怎么定、进程被杀后 BackStack 怎么还原、不同模块之间怎么互相导航、外部 deep link 进来到底落在哪个 NavGraph 上、Predictive Back 手势如何与多页面共享元素动画兼容。
这篇文章不讲"什么是单 Activity 架构"——官方文档和入门教程已经写烂了。我想聊的是,把它放进一个真实大型项目时,有哪些坑你必须先想清楚,以及 2026 年这个时间点,Navigation Compose(含 Type-Safe Navigation)的能力边界究竟在哪。
一、为什么大家都开始抛弃多 Activity
先把动机捋清楚。多 Activity 架构不是"过时",而是它在解决一些不存在的问题,同时把另一些问题搞得很复杂。
多 Activity 时代每开一个页面就 startActivity,看起来逻辑清晰,但代价很重:
• 启动开销:Activity 是系统级组件,要走 ActivityManagerService、Instrumentation、生命周期回调一整套流程,冷启动比 Fragment / Composable 慢一个数量级
• 共享数据麻烦:跨 Activity 传对象只能 Parcelable / Serializable / 进程内单例,类型安全弱、容易内存泄漏
• 转场动画受限:早期共享元素动画各种坑,Material Motion 在 Activity 间几乎不可用
• BackStack 难管:TaskAffinity、launchMode、FLAG_ACTIVITY_* 组合起来语义复杂,QA 永远能找出新 case
• 状态恢复成本高:每个 Activity 都要单独处理 onSaveInstanceState,跨 Activity 的状态一致性需要额外协调
单 Activity 架构本质上是把"页面"这个概念从系统组件下沉为应用层概念。整个 App 只有一个 Activity 作为窗口宿主,所有 UI 都在 Composable 或 Fragment 中渲染,导航由应用自己控制。这样系统层只需要管理一个 Activity 的生命周期,应用层完全掌握自己的页面栈。
但这个"完全掌握"是双刃剑——以前系统帮你处理的事,现在你得自己处理,包括状态恢复、进程死亡重建、外部 intent、深链路。
二、Navigation Compose 现在到哪一步了
2026 年这个时间点,Navigation Compose 已经走到了 2.8+ 的版本,最大的变化是 Type-Safe Navigation 全面成熟,开发体验比早期那种 string route 强了一大截。
核心 API 长这样:
@Serializable
data class HomeRoute(val tab: String = "feed")@Serializable
data class DetailRoute(val itemId: Long, val from: String? = null)@Composable
fun AppNav() {
val nav = rememberNavController()
NavHost(navController = nav, startDestination = HomeRoute()) {
composable<HomeRoute> { entry ->
val args = entry.toRoute<HomeRoute>()
HomeScreen(tab = args.tab, onItemClick = { id ->
nav.navigate(DetailRoute(itemId = id, from = "home"))
})
}
composable<DetailRoute> { entry ->
val args = entry.toRoute<DetailRoute>()
DetailScreen(itemId = args.itemId, from = args.from)
}
}
}对比早期那种 "detail/{id}?from={from}" 字符串路由,Type-Safe 的好处是:
• 编译期就能发现参数缺失或类型错配
• IDE 能补全、能跳转、能重构
• 复杂参数(嵌套对象、可空字段)不再需要自己手撸 NavType
• 默认值、序列化规则统一由 kotlinx.serialization 接管
但 Type-Safe 不解决业务上的难题。它只是把"传参"这件事做漂亮了,真正麻烦的几个问题——多模块、深链路、进程死亡——还是要架构层自己想办法。
三、第一个真问题:多模块怎么互相导航
大型项目通常按业务拆模块——feature_home、feature_detail、feature_profile、feature_pay…… 这些模块在 Gradle 层是平级的,互相不依赖。这时候问题来了:feature_home 要跳到 feature_detail 的某个页面,怎么跳?
如果你直接在 feature_home 里 import DetailRoute,那 feature_home 就强耦合了 feature_detail,模块拆分等于白拆。
常见的解法有三种思路:
• Route 抽到独立模块:建一个 navigation_api 模块,里面只放 Route data class,所有 feature 模块都依赖它。这是最简单的,但当 Route 数量多了,这个模块会变得很臃肿,而且任何 Route 改动都会触发全量编译
• 每个 feature 提供自己的 NavGraph 扩展函数:feature_detail 暴露一个 NavGraphBuilder.detailGraph(),但 Route 还是要外露给调用方。问题没本质变化
• 用接口 + 依赖注入解耦:feature_home 通过一个 DetailNavigator 接口跳转,由 app 模块负责把 NavController 注入进来
第三种是目前比较推崇的做法,配合 Hilt / Koin 注入很自然:
// :navigation-api 模块
interface DetailNavigator {
fun openDetail(itemId: Long, from: String? = null)
}// :feature-home 里
class HomeViewModel @Inject constructor(
private val detailNav: DetailNavigator
) : ViewModel() {
fun onItemClicked(id: Long) {
detailNav.openDetail(id, from = "home")
}
}// :app 模块里把 NavController 与 Navigator 绑定
@Composable
fun AppRoot() {
val nav = rememberNavController()
val detailNav = remember(nav) {
object : DetailNavigator {
override fun openDetail(itemId: Long, from: String?) {
nav.navigate(DetailRoute(itemId, from))
}
}
}
CompositionLocalProvider(LocalDetailNavigator provides detailNav) {
AppNav(nav)
}
}这种方式的代价是:你要为每个跨模块跳转写接口,模块多了接口数量也多。但好处是 feature 模块对"导航"这件事完全不感知,未来要把页面换成 WebView、换成动态化模块、换成 Compose Multiplatform,调用方不用改一行代码。
选哪种取决于团队规模和模块数量。10 个以下模块直接 Route 抽公共模块就行;20 个以上模块,接口 + DI 的代价才回得了本。
四、第二个真问题:进程被杀,BackStack 怎么还原
这是单 Activity 架构最容易被低估的问题。多 Activity 时代,Activity 栈是系统管的,进程被杀重建后系统会帮你按之前的 task 重新拉起 Activity。单 Activity 之后,整个页面栈都在 NavController 内部,进程一死,全没了。
Navigation Compose 默认会保存 BackStack 到 SavedStateHandle,看起来很美好——直到你试着杀进程再回来。常见的问题有:
• ViewModel 状态丢了:BackStack 还原了,但 ViewModel 是新建的,里面的网络数据、用户输入全清零
• Composable 状态丢了:rememberSaveable 能救一部分,但只针对可序列化的状态。复杂业务状态(比如一个本地图片选择器选了哪些文件)不重写不行
• 跳板页问题:用户在登录页停留时进程死掉,重启后落回登录页,但用户原本要去的页面不见了
这几件事的核心是:你必须明确区分哪些状态属于"页面瞬时",哪些属于"会话持续",哪些属于"持久化"。
实操上的几条经验:
• 网络请求结果的缓存放 Repository / DataStore,不要只放在 ViewModel 里。这样进程重建后能从持久层快速恢复
• 表单输入用 SavedStateHandle 显式保存,不要依赖 rememberSaveable。SavedStateHandle 由 ViewModel 持有,跟着 NavBackStackEntry 走,恢复粒度更可控
• "原本要去哪"这种意图状态要序列化保存。比如"未登录用户点击收藏,登录后回到收藏页",应该把目标 Route 存进 SavedStateHandle,登录成功后恢复
• 测试方法很简单:开发者选项打开"不保留活动",再加一个 ADB 命令 adb shell am kill 包名,每个关键页面都试一遍
五、第三个真问题:深链路与外部 Intent
单 Activity 架构下,所有外部入口都集中到 MainActivity 一个点,听起来很优雅,实际写起来一堆细节。
典型的外部入口包括:浏览器深链路(http/https)、自定义 scheme(myapp://)、推送通知点击、Widget 点击、AppShortcut、其他 App 通过 Intent 跳过来。每个入口都要正确路由到对应页面,并且要考虑当前 BackStack 状态。
几个具体场景:
• App 已经在前台,深链路从外部进来:MainActivity 收到 onNewIntent,需要把 BackStack 切换到目标页面,但要不要清除中间页?这是产品决定,不是技术决定。常见做法是从 Intent 里读取一个标志位,决定是 push 还是 replace
• App 完全没启动,冷启动直接进深链路:MainActivity 在 onCreate 时拿到 Intent,但此时 NavController 还没初始化。要么用 LaunchedEffect 等 NavController 准备好再 navigate,要么把 deep link 解析下沉到 Compose 层用 LocalActivityResultRegistryOwner 之类的方式监听
• 用户从深链路落到详情页,按返回键应该回到哪:这是产品体验题。常见策略是构造"虚拟 BackStack"——比如详情页背后补一个 Home,让用户按返回不会直接退出 App
Navigation Compose 提供了 deepLink DSL,但它只能解决"URL 解析成 Route"这一小段,上面三个场景都需要架构层补逻辑。
composable<DetailRoute>(
deepLinks = listOf(
navDeepLink<DetailRoute>(
basePath = "https://app.example.com/detail"
)
)
) { entry ->
val args = entry.toRoute<DetailRoute>()
DetailScreen(args.itemId)
}// MainActivity
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
deepLinkChannel.tryEmit(intent)
}@Composable
fun AppNav(nav: NavHostController) {
LaunchedEffect(Unit) {
deepLinkChannel.collect { intent ->
if (shouldClearBackStack(intent)) {
nav.popBackStack(HomeRoute(), inclusive = false)
}
nav.handleDeepLink(intent)
}
}
}关键是:把 Intent 当成事件流而不是 Activity 状态。MainActivity 只负责把 Intent 转发给 Compose 层,由 Compose 层根据当前 BackStack 决定怎么响应。这样测试起来也容易。
六、第四个真问题:ViewModel 范围怎么定
多 Activity 时代 ViewModel 范围比较直观——绑 Activity 或者绑 Fragment。单 Activity 之后选项变多了:
• 绑 Activity:所有页面共享,跟全局单例几乎没区别
• 绑 NavBackStackEntry:跟着具体页面走,离开页面就销毁
• 绑某个 Nested NavGraph:在一组相关页面间共享,比如下单流程里的几个步骤
• 绑全局 Hilt SingletonComponent:跨页面长期存在
大部分场景用 NavBackStackEntry 范围就够了,但下单流程、表单分步、引导流这种"几个页面共享一份草稿状态"的场景,需要 Nested NavGraph 范围。
@Serializable
object CheckoutGraphnavigation<CheckoutGraph>(startDestination = AddressRoute) {
composable<AddressRoute> { entry ->
val parentEntry = remember(entry) {
navController.getBackStackEntry(CheckoutGraph)
}
val checkoutVm: CheckoutViewModel = hiltViewModel(parentEntry)
AddressScreen(checkoutVm)
}
composable<PaymentRoute> { entry ->
val parentEntry = remember(entry) {
navController.getBackStackEntry(CheckoutGraph)
}
val checkoutVm: CheckoutViewModel = hiltViewModel(parentEntry)
PaymentScreen(checkoutVm)
}
}这里有个容易踩的坑:remember(entry) 不能省。如果直接 val parentEntry = navController.getBackStackEntry(CheckoutGraph),每次重组都会去查一次,性能下降不说,还容易拿到不一致的 Entry。
七、第五个真问题:转场动画与 Predictive Back
Compose 的 AnimatedContent + sharedElement 让转场动画终于变成了一行代码的事,但如果你要做出"看起来像系统级"的体验,还是要花心思。Android 14+ 引入的 Predictive Back(预测式返回手势)让这件事更复杂——用户拖到一半时,你的页面要正确"预览"返回目标。
Navigation Compose 在 2.8+ 已经原生支持 Predictive Back,但你需要做几件事:
• 关闭老的 OnBackPressedCallback 自定义拦截,统一用 NavController 的能力
• 给所有 composable 设置合理的 enterTransition / exitTransition / popExitTransition
• Shared Element 在跨 NavBackStackEntry 之间用 SharedTransitionLayout,注意 key 不要重复
• 测试时一定要在真机上拖动手势查看效果,模拟器上 Predictive Back 行为不一致
转场动画有一个反直觉的事:动画时长越短,用户感知的"流畅"越强。很多团队喜欢用 400ms 的缓动,看起来"高级",但实际用起来会觉得 App 很慢。建议主线导航 200ms 左右,模态弹层 150ms,长按或确认类操作可以稍长。
八、单 Activity 不是银弹:什么时候要保留多 Activity
说了这么多优点,要给单 Activity 浇点冷水。下面这几种场景,多 Activity 反而是更合理的选择:
• 独立的"轻量入口":比如分享面板、扫一扫、悬浮窗后落地的小页面,这些页面通常需要独立的 task affinity,进出要干净,多 Activity 更合适
• 跨进程的页面:比如把视频播放放到独立进程隔离崩溃,单 Activity 做不到
• 需要不同主题/启动模式的入口:比如透明 Activity、Dialog 主题、singleTask 等
• 大型 App 的子产品:比如某个收银台、某个 H5 容器,团队边界清晰、复用性差,单独一个 Activity 容器反而方便
合理的姿势是"主航道单 Activity,特殊场景多 Activity",不要为了"纯粹"把所有东西都塞进一个 Activity。
九、迁移路径建议
如果你手上是一个老项目要迁单 Activity,不要尝试一次到位。常见的渐进路径:
• 第一步:把所有 startActivity 调用收敛到一个 Navigator 接口,原实现仍然是 startActivity。这一步不改架构,只是把"导航"这件事从分散的代码里收上来
• 第二步:选一个相对独立的业务流程(比如个人中心),改造成单 Activity 内的 Navigation Compose,验证模板
• 第三步:逐步把高频流程迁过来,每迁一个流程同步把 deep link、推送、shortcut 跑通
• 第四步:剩下的边角页面(设置、关于、调试入口)保留多 Activity,没必要硬迁
整个过程可能跨 3-6 个版本,期间会出现"两套架构并存"的混乱期。这是正常的,关键是 Navigator 接口在两套架构上都能工作,业务代码不用改。
写在最后
单 Activity 架构火了几年,从早期 Fragment + Navigation 到今天的 Navigation Compose + Type-Safe Routes,工具链比当年好太多了。但工具链好不等于架构问题就消失了——多模块解耦、状态恢复、深链路、ViewModel 范围、转场动画、Predictive Back,这些事情每一件都需要架构层显式想清楚。
这些问题没有标准答案,每个团队的选择会随着 App 规模、团队习惯、产品形态变化。但如果你正在评估要不要走单 Activity,至少先把上面这几个问题在自家场景里推一遍,再决定是否动手。
下一篇将继续 WebView 深度探索系列——讲 WebView 性能优化与稳定性治理,包括预热、复用池、内存治理与崩溃防护。
如果觉得有帮助,欢迎点赞、转发、关注,下次更新不迷路。