辕门射戟——基本的滚动视图优化

286 阅读5分钟

在实现动物类型与动物信息的双层列表嵌套时,直接使用 ListView.builder 会导致高度计算困难。即使手动设定高度,还需要处理滚动冲突。因此,我们选择只在外层使用 ListView.builder 渲染,内部则使用 List 进行自定义 Item 生成。然而,这种方案导致渲染效率显著降低。

交互需求

用户操作 → 第一级展示动物类型 → 展开/收起动物类型 → 查看对应类型的动物信息

如下图层级:

image.png

原有代码实现

Widget build(BuildContext context) {
  // 动物分类列表 
  var AnimalClassifyList = getAnimalClassifyMethod(); 
   ListView.builder(
     itemCount: animalClassifyList.length,
     itemBuilder: (index, context) {
        String classifyName = animalClassifyList[index].name;
        bool isShow = getIsShow(deviceType);

        return Column(
          children: [
          Text(classifyName),
          if (isShow) AnimalListWidget(classifyName)
         ],
      );
    }),
  }

Widget AnimalListWidget() {
//单个动物信息
  List animalList = getAnimalListMethod(classifyName);
  return Column(
    children: animalList.map((animal) {
      return Text(animal.name);
    }).toList(),
  );
}

getAnimalClassifyMethod 与 getAnimalListMethod 均为内存操作,不涉及 I/O 处理

性能问题

在使用过程中,以下性能问题较为突出:

  • 帧耗时过高:通过 Flutter Performance 工具观察,单帧渲染耗时超过 50ms,远高于 16ms 的目标。
  • 频繁掉帧:展开/收起二级列表时,Jank 现象频发,滚动体验显著卡顿。
  • 资源浪费ListView.builderColumn 的嵌套使得屏幕外内容仍被渲染,消耗大量 CPU 和 GPU 资源。

优化代码

核心思路

  • 惰性加载:仅渲染屏幕内可见内容。
  • 减少不必要的布局重建:优化构建逻辑,避免重复渲染。
  • 精细化滚动控制:使用 CustomScrollViewSliverList 实现复杂滚动视图。

no bb,show code

通过 CustomScrollViewSliverChildBuilderDelegate 实现动态加载

针对渲染频繁,采用 SliverList 替代 ListView.builder 进行惰性加载,进一步进行 SliverChildBuilderDelegate 声明只渲染当前屏幕内可见的 Sliver 项,将整个滚动视图的计算和渲染过程进行了细粒度的控制,减少不必要的渲染起到节约 cpu 资源的作用

@override
Widget build(BuildContext context) {
return CustomScrollView(
 slivers: [
   // 渲染外部动物分类的 SliverList
   SliverList(
     delegate: SliverChildBuilderDelegate(
       (context, index) {
         String classifyName = animalClassifyList[index].name;
         bool isShow = getIsShow(deviceType);

         return Column(
           children: [
             Text(classifyName),
             if (isShow) AnimalListWidget(deviceType),
           ];
         );
       },
       childCount: animalClassifyList.length,
     ),
   ),
 ],
);
}

优化后实测

  1. Frame Build Time 降低了约 20ms:

    • 优化了布局和构建逻辑(比如从嵌套 ListView.builder 改为 CustomScrollView)。
    • CPU 在每帧中的工作量减少,框架可以更快完成 UI 构建。
  2. Jank Count 减少了 2/3:

    • 构建和栅格化的总时间更接近或低于 16ms,掉帧频率降低,体验更加流畅。

优化后视觉上没有明显交互卡顿,整体思路:1、减少了深层布局的计算工作,2、将渲染逻辑集中在可见区域,避免不必要的绘制,结合 1、2 降低 CPU 和 GPU 的协同瓶颈,达到了资源利用效率和用户体验的改进

还可以做哪些

如果确认最内层动物信息类别不存在实时变化,可以用 const 构造函数修饰 item 小组件,确保无状态的 AnimalListWidget 不被频繁重建。对于依赖状态的组件,可以结合 ValueKeyGlobalKey

return Column( 
   key: ValueKey(classifyName), // 添加 Key 以避免不必要的重建 
   children: animalList.map((animal) { 
        return Text(animal.name); 
    }).toList(), 
  );
  

换个场景

如果确信列表中组件固定不变,且包含有不同的组件渲染,或是说期望在切换视窗时候保留当前列表处组件渲染避免多次重复构建可以混入AutomaticKeepAliveClientMixin 解决;

class _MyWidgetState extends State<MyWidget> 
    with AutomaticKeepAliveClientMixin {  // 混入 Mixin
  
  @override
  bool get wantKeepAlive => true;  // 返回 true 启用状态保留[1,2](@ref)

  @override
  Widget build(BuildContext context) {
    super.build(context);  // 必须调用父类方法[1,2](@ref)
    return YourChildWidget();
  }
}
  1. 状态保持
    通过 wantKeepAlive: true 标记需要保持状态的子项,避免其被销毁。例如在 AnimalListWidget 中,若内部包含展开的动物详情或用户交互状态(如勾选框),此 Mixin 可保留这些状态

  2. 参数优化方向

    • addRepaintBoundaries:设置为 true(默认)时,为每个子项添加重绘边界,结合 RepaintBoundary 减少重绘范围
    • addAutomaticKeepAlives:设置为 true(默认)时,与 AutomaticKeepAliveClientMixin 配合实现状态保留。
  3. 适用场景
    需要保持子项状态的复杂列表(如嵌套可折叠列表、表单输入),避免重复构建带来的性能损耗。

注意: AutomaticKeepAliveClientMixin 使用前提是仅在需要保持状态的子项中考虑混入使用,避免保留过多界面占用内存。

一些相关点

ListView 与 CustomScrollView 基本差异

特性ListViewCustomScrollView
结构简单封装的垂直或水平列表通过 Sliver 系列组件构建,支持复杂布局
布局控制只能展示简单的列表项,布局和结构相对固定灵活,可以组合不同类型的 Sliver,支持复杂布局(如吸顶、分组滚动等)
性能对于大数据量的列表,ListView.builder 有惰性加载,但仍然可能因布局复杂导致性能问题CustomScrollView 使用 Sliver,精细控制渲染,只渲染当前可见区域的子项,内存占用更少,性能更优
适用场景用于简单的列表展示用于复杂的滚动视图,包含多个可滚动区域、分组或者动态变化的内容

SliverChildBuilderDelegate 与 AutomaticKeepAliveClientMixin

优化维度SliverChildBuilderDelegate  AutomaticKeepAliveClientMixin
内存占用动态回收不可见项,内存占用较低保留部分子项状态,内存略增但可控
重建频率滚动时频繁重建子项标记的子项避免重复重建
用户体验滚动流畅,但状态丢失保留交互状态(如展开/折叠),体验更连贯
适用场景静态列表、简单内容动态列表、复杂交互场景

Jank 的衡量标准为什么是 16ms 一帧?

  • 屏幕的刷新率通常是 60Hz(现代屏幕可能会支持更高刷新率,如 120Hz,但 60Hz 是常见标准)。

  • 在 60Hz 下,每次刷新需要的时间是 。

    1/60 ≈ 16.67ms

  • 要保持流畅,Flutter 应用必须在 16ms 内完成一帧的所有工作,包括:

    • UI 布局(Layout)
    • 绘制(Paint)
    • GPU 渲染(Compositing)

如果帧时间超过 16ms,就会掉帧,导致动画或滚动看起来不流畅。

image.png

Flutter 性能分析 | Flutter 中文文档 - Flutter 中文开发者网站 - Flutter