阅读本文需了解 Navigation 基本工作原理,且有一定的工程实践。历史文章指路 juejin.cn/post/698535…
背景
随着 APP 首页的复杂度不断提升,原本基于 TabLayout + ViewPager2 的切换方案,在 Android Navigation 大的技术架构下渲染问题变的愈发突出,线上统计数据反映,从各类二级页面返回首页时会出现明显的卡顿问题,性能差的设备中卡顿时长超 800ms,严重影响用户体验。
问题分析
通过 perfetto 抓取返回首页过程的 trace 文件,可以看到低性能设备上一帧卡顿 800ms+,其中绝大部分为视图的 inflate 和 layout。
造成问题的直接原因是 Navigation 框架到导航机制,页面的跳转会将视图全部销毁,页面的返回会通过 saveInstance 机制还原,首页 Fragment 的 onRestoreInstance 执行结束之前无法与用户交互。
由于 ViewPager2 的特殊机制,默认状态下会创建选中 Tab 左右两边(如有)的 Fragment,即使是不可见的 Fragment 也会走到 onStart 生命周期,同时当缓存 Fragment 超过 3 个时,还会把最早创建的 Fragment 销毁,且不保留状态。当前车鱼首页有 5 个Tab,为保证业务逻辑能正常开展,被迫将 ViewPager2 的 offscreenPageLimit 调大。
这导致首次进入首页时,所有 Tab Fragment 的已经偷偷被创建。同时,由于 Navigation 组件的导航机制,当出现页面跳转时会采用 replace 机制将 Fragment 替换,被替换的 Fragment 会走到 onDestroyView 生命周期,也就是试图会被全部销毁。与此同时,当用户再次通过导航返回首页时,这些试图又会被偷偷创建,首页 Tab 多,每个 Tab 下元素多,层级嵌套深、布局复杂,一次性创建和布局导致问题积累爆发。
修复方案选型
自定义 FragmentNavigator 从源头改造
既然问题的源头是视图销毁导致的,那可以将强行将 Fragment 的 replace 机制改为 add,自定义一个以 add 方式导航的 navigator,从实现来说并不是不可以,但由于一次 add,后续的导航操作必须同时都为 add,这将逐步退化为FragmentManager 自行控制导航的时代,问题一箩筐。
事实上,官方也并不支持这么做。
一是这会内存上涨,业务越是复杂问题越明显。
二是业务处理难度大,比如 add 情况下,背后 Fragment 是否会调用 onStop() 生命周期还依赖新添加的 Fragment 透明度,业务上难以统一处理,导致部分资源没有合适的时机释放,需要业务层谨慎处理。
重新拆分业务模块改为 Activity 实现
这是对 Navigation 框架的整体拆分,需要将首页拆分成独立的 Activity,同时由于剩余页面需要定义 startDestination,因此需要额外为这些页面包装一个分发类,设计相当蹩脚,所以大概率要将所有的业务都拆成独立的 Activity,如此一来 navigation 框架形同虚设。
好处是确实可以从根本上解决视图销毁和创建的问题,但整体还是逆时代发展的演进,参考 compose。
内存数据也必然带来较大的劣化。
仿 BottomNavigation 重构首页导航结构
受 Navigation 支持多返回栈的启发,翻看了关于 BottomNavigationView 和 Navigation 配合使用的最佳实践,有了新的启发。
通过底部导航和 Navigation 配合使用可以支持多返回栈的状态恢复。如果把车鱼的首页顶部 Tab 当成这里的底部导航,应该可以做到一致的效果。
核心的改变是把原本离屏缓存的视图,换成导航的返回栈实现。
由于 Navigation 架构,返回栈只记录视图状态数据,视图本身会销毁,因此视图重建的成本方面都是最低的。
具体实现步骤如下:
- 定义首页的多 Tab 的嵌套导航图。
- 首页 ViewPager2 实现替换为 NavHostFragment 实现,并指向该导航图。
- 处理 Tab 选中的导航逻辑。
private fun switchTab(@IdRes id: Int, arguments: Bundle?) {
//copy from NavigationUI.onNavDestinationSelected
val builder = NavOptions.Builder().setLaunchSingleTop(true).setRestoreState(true)
val navController = binding.fcvHomeRoot.findNavController()
//核心是这里切换tab时弹栈且保留状态
builder.setPopUpTo(
navController.graph. findStartDestination ().id,
inclusive = false ,
saveState = true
)
val options = builder.build()
try {
navController.navigate(id, arguments, options)
} catch (e: IllegalArgumentException) {
e.printStackTrace()
}
}
实测效果
首页响应速度
对比修改前后,返回首页的耗时统计,选取相对复杂的发现和视频 Tab。
| 场景 | ViewPager2 | 导航图 |
|---|---|---|
| 发现 Tab | 665ms | 189ms(70%↓) |
| 视频 Tab | 766ms | 345ms(55%↓) |
改动影响范围
获取 NavController /返回栈节点
首页各个 Tab 下获取 NavController、返回栈节点 API 时注意,原本返回的主导航的 controller ,现在会返回嵌套层级的相关节点,使用时需明确范围,避免崩溃。
首页各 Tab Fragment 生命周期变化
| 方案/场景 | 选中Tab | 未选中 Tab | 切换 Tab 离场 | 切换 Tab 入场 |
|---|---|---|---|---|
| ViewPager2 方案 | Fragment 创建并执行到 onResume 生命周期 | Fragment 创建并执行到 onStart 生命周期 | Fragment 执行到 onStop 生命周期 | Fragment 执行到 onResume 生命周期 |
| 导航图方案 | Fragment 创建并执行到 onResume 生命周期 | Fragment 未创建不执行任何生命周期 | ⭐️Fragment 弹栈,完全销毁,即执行到 onDestroy 生命周期 | Fragment 创建并执行到 onResume 生命周期 |
⭐️ 位置还跟导航图的起始目的地有关,若离场的 Tab 是起始目的地,则生命周期只会走到 onDestroyView,否则会执行到 onDestroy 生命周期,完全销毁。
由于导航方案 View 创建会延迟到 Tab 选中时,所以首次切换 Tab 会有加载过程。