Android Navigation 返回卡顿问题优化

1,338 阅读5分钟

阅读本文需了解 Navigation 基本工作原理,且有一定的工程实践。历史文章指路 juejin.cn/post/698535…

背景

随着 APP 首页的复杂度不断提升,原本基于 TabLayout + ViewPager2 的切换方案,在 Android Navigation 大的技术架构下渲染问题变的愈发突出,线上统计数据反映,从各类二级页面返回首页时会出现明显的卡顿问题,性能差的设备中卡顿时长超 800ms,严重影响用户体验。

问题分析

通过 perfetto 抓取返回首页过程的 trace 文件,可以看到低性能设备上一帧卡顿 800ms+,其中绝大部分为视图的 inflate 和 layout。

造成问题的直接原因是 Navigation 框架到导航机制,页面的跳转会将视图全部销毁,页面的返回会通过 saveInstance 机制还原,首页 Fragment 的 onRestoreInstance 执行结束之前无法与用户交互。

前.png

由于 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 配合使用的最佳实践,有了新的启发。

bottom_navigation.gif

通过底部导航和 Navigation 配合使用可以支持多返回栈的状态恢复。如果把车鱼的首页顶部 Tab 当成这里的底部导航,应该可以做到一致的效果。

核心的改变是把原本离屏缓存的视图,换成导航的返回栈实现。

由于 Navigation 架构,返回栈只记录视图状态数据,视图本身会销毁,因此视图重建的成本方面都是最低的。

具体实现步骤如下:

  1. 定义首页的多 Tab 的嵌套导航图。
  2. 首页 ViewPager2 实现替换为 NavHostFragment 实现,并指向该导航图。
  3. 处理 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导航图
发现 Tab665ms189ms(70%↓)
视频 Tab766ms345ms(55%↓)

后1.png

改动影响范围

获取 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 会有加载过程。