📰 每日要闻
• 亚洲股市普涨,AI 热情盖过伊朗协议不确定性,韩国 KOSPI 创历史新高(Investing.com 中文,2026-06-01)
• 鲍威尔获肯尼迪勇气奖,卸任美联储主席后首次公开发声暗批白宫越界(Investing.com 中文,2026-05-31)
• Anthropic 估值超过 OpenAI,AI 大模型公司估值格局再度生变(少数派派早报,2026-06-01)
• 苹果代工厂开造人形机器人,一场押注未来的产能大迁移(36 氪硬氪观察,2026-05-31)
• AMD 新策略:让用户继续用老款硬件,强调 AI 算力下沉的价值(The Verge,2026-05-30)
最近几年,"单 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 CheckoutGraph
navigation<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 性能优化与稳定性治理,包括预热、复用池、内存治理与崩溃防护。
如果觉得有帮助,欢迎点赞、转发、关注,下次更新不迷路。
夜雨聆风