用脚步丈量人生宽度

Flutter 从零开始(五):列表与滚动性能,从 ListView.builder 到 Sliver

我做的第一个带长列表的页面,是本地通讯录的展示,两百多条联系人,一帧都不掉。等到接上服务端接口、数据涨到三千条,手指按住屏幕往上拖,列表就开始一顿一顿。那时候我的第一反应是「Flutter 列表性能不过如此」,后来才知道问题基本都不在渲染管线上,而在我自己怎么写 builder。

这一篇记录我从「换成 builder 就好了」到「知道为什么是这里慢」的过程,顺便把吸顶标题这块我一开始完全摸不着头脑的 Sliver 模型讲清楚。

我的第一个误解:掉帧是「画不出来」

Flutter 每帧要过三段:build(把 Widget 变成 Element 和 RenderObject,算出该画什么)、layout(确定每个 RenderObject 的尺寸和位置)、paint(生成绘制指令)。前两段在 UI 线程,paint 指令最终交给 Raster 线程合成上屏。

我先入为主以为滑动慢是 GPU 画不过来,实际打开 DevTools 才发现,我那三千条的列表慢在 UI 线程——每滑一行就有几十个 widget 子树被重新构建。原因在我一开始写的是这种形态:

// 我最初的写法:children 是「已经存在的 widget 列表」
ListView(
  children: contacts
      .map((c) => ContactTile(contact: c)) // 构造 ListView 那一刻,3000 个 ContactTile 对象全部建出来
      .toList(),
)

children 这个参数要求在构造列表的同时就把每一项的 widget 交出来,所以三千条数据就是三千次子树展开,对应的 Element 和 RenderObject 也一次性全造出来,跟可见范围没有关系。屏幕只能放十五行,剩下两千多行既占内存又要参与布局测量。

换成 builder 之后,我交给框架的不再是一份成品清单,而是一个「按编号给我第 index 项」的回调:

ListView.builder(
  itemCount: contacts.length, // 只告诉框架总长度,不创建任何 item
  // 框架只会在「可见区 + 缓存区」覆盖到某个 index 时才调用这里
  itemBuilder: (context, index) => ContactTile(contact: contacts[index]),
)

这个形态上的差别对我来说是致命的:列表的长度不再等于构建成本。滑动过程中,进入缓存区的行才 build,滑出去的行会被丢弃,成本从「正比于数据量」变成「正比于屏幕行数」。

itemExtent 与 cacheExtent:各自省掉的是哪一段

有了 builder 之后我还是想再压一点耗时,于是去查了 itemExtent。它的收益来自 layout 阶段:默认情况下框架要在某个滚动偏移处确定该显示哪些行,得先按累计高度把偏移换算成 index,而定高之外的每行都要实测;给了 itemExtent,偏移和 index 之间就退化成一次除法,内容高度也不用逐行累加,重复测量被省掉了。

ListView.builder(
  itemCount: contacts.length,
  // 我的行高是固定的 68,写死之后框架不再逐行测量
  itemExtent: 68,
  // 缓存区:超出屏幕上下各 250 逻辑像素的行会提前 build 好待命
  cacheExtent: 250,
  itemBuilder: (context, index) => ContactTile(contact: contacts[index]),
)

cacheExtent 解决的是另一个问题。它决定「滑出去多远之前行还活着」。我踩过的是它的反面:一开始把它设成 0,快速拖动时会先看到一片空白再弹出内容——因为行是在进入可见区那一帧才开始构建,构建耗时直接暴露在帧里。设大一点能提前把 build 摊到空闲帧上,代价是离屏的 Element 和 RenderObject 更多、内存更高。

itemExtent 的适用场景相对窄一些:内容得是真定高。我在行里加了备注字段、有些行会撑到两行高之后,这个参数就只能去掉,否则超出的部分会被裁掉。要不要用它,取决于设计上能不能保证行高恒定。

滑出去的那一行,它的 State 去哪了

这是我困惑最久的一次行为:列表项里有个选中态,我勾中第 40 行,往下拖到底,再拖回来,勾没了;更糟的是勾还常常跑到别的行上。

去查了框架的实现后我理清了这条链:ListView.builder 内部是 SliverList,它维护的子项和可见范围绑定。一行滑出「可见区 + cacheExtent」之外,它对应的 Element 会被移出树,State 对象也随之销毁;滑回来时走的是 initState,重新从数据源读初值。所以选中态丢失不是 bug,是这套复用机制的默认语义——State 的生命周期跟着可见范围走,不跟着数据走。

勾跑到别的行上,则是另一层:框架要决定「新一次的第 i 项」能不能沿用「上一次的某个 Element」,比对的是 widget 的 runtimeType 和 key。没有 key 时,同类型的子项按位置顺序配对。于是我中途往列表头部插一条数据,后面所有项的位置整体后移一位,而 Element 还在按位置复用——第三行的 State 就留给了新的第三项。

给每一项绑上稳定的业务身份之后,配对就改由 key 决定:

ListView.builder(
  itemCount: contacts.length,
  itemBuilder: (context, index) => ContactTile(
    contact: contacts[index],
    // key 挂在这一层返回的根 widget 上,ValueKey 里是数据库 ID 而不是 index
    key: ValueKey(contacts[index].id),
  ),
)

同一份数据的位置变化不再影响配对,State 跟着「这个人」走。

配套的一个报错也在这里出现过。我在某个列表项的 initState 里发起过异步查询,回来后调 setState,快速滑动时控制台甩出:

Looking up a deactivated widget's ancestor is unsafe.

以及更早一次的 setState() called after dispose()。成因是同一件事:异步回调落地时,那一行可能早已滑出缓存区、Element 已经被移出树,State 也 dispose 了,此时再标记它脏、再去找它的祖先,框架自然不让。我的做法是在回调第一行判 if (!mounted) return;,把「这一项还在不在屏上」当作前置条件。

Key 我实际用到的三种,以及加错的代价

key 类型我加它的理由代价与失效场景
ValueKey(id)数据有稳定业务 ID,重排、删项后要保住 State 和滚动中的动画ID 一旦变化就被当成换了一项,状态照样丢
ObjectKey(item)没有 ID,但对象引用在整个生命周期里不变数据每次刷新都是新的不可变副本时,key 全部变化,等于每项都重建
UniqueKey()确实希望这一项每次 rebuild 都从零开始(我遇到过的场景很少)每次父级构建都是新 key,旧 Element 直接作废;用在列表上会看到闪烁和状态全丢

我的判断是:静态、只读、不会重排的列表,加 key 没有收益,只多付一次哈希比较;只有在「顺序会变、项内有状态或有动画」时它才起作用。

还有一类我一开始搞混的问题——key 应该加在哪。它要求在同一层级的兄弟之间唯一,所以我加在 itemBuilder 返回的那个根 widget 上。第一次试图给列表内部的图标加 ValueKey 时,控制台报过:

Duplicate keys found.

因为那时重复的 key 落在了同一层兄弟节点里,框架无法区分两个候选配对对象。

图片才是这类列表真正的大头

我的三千条列表里每行有头像,换成 ListView.builder、加了 key,滑动时依然会掉帧,而且内存曲线一路上行。查下来才想明白:行 widget 的构建只是 CPU 的一段,图片解码是另一笔更大的开销。

关键在于解码尺寸。网络头像原图常常是 800×800,而我在行里显示 44 逻辑像素。默认解码按原图尺寸来,一张 RGBA 位图就是 800×800×4 ≈ 2.5 MB,两千行即便只保留几十张在缓存里也是几百 MB。指定解码尺寸后,框架解码时直接按比例缩到目标像素:

// 行高 68 逻辑像素,头像显示区 44 逻辑像素
final int decodeSize = (44 * MediaQuery.devicePixelRatioOf(context)).round();

Image.network(
  contact.avatarUrl,
  // 解码就按屏幕像素来,位图内存占用从原图尺寸降到这里算出的尺寸
  cacheWidth: decodeSize,
  cacheHeight: decodeSize,
  filterQuality: FilterQuality.medium,
)

filterQuality 那一行是我后来加的:低倍缩小时 low 会出现锯齿,high 又给每帧多付一次滤波成本,中档在我这种尺寸上比较平衡。

我改用 CachedNetworkImage 之后写法是这样,尺寸参数换了名字:

CachedNetworkImage(
  imageUrl: contact.avatarUrl,
  memCacheWidth: decodeSize, // 进内存缓存时的宽度上限
  memCacheHeight: decodeSize,
  placeholder: (context, url) => const AvatarSkeleton(),
  errorWidget: (context, url, error) => const AvatarFallback(),
  fit: BoxFit.cover,
)

这里有个原理是我后来才理清的:Flutter 的 ImageCache 默认上限是 1000 张、总量 100 MB(按位图字节算)。超出就按最少使用淘汰,被淘汰的图重新出现时要再解码一次——所以我看到的「滑回去时白一下」通常不是网络请求,而是解码重来。指定解码尺寸相当于同时把「计数上限」变成了「字节上限」里更划算的一方。

每行都新建一个 Image.network(...) 这件事我一开始以为是浪费,后来才理清:provider 对象确实每次 rebuild 都新建,但它的缓存 key 由 URL 和解码尺寸一起决定,key 相同就命中同一份缓存,所以真正决定成本的是这个 key 稳不稳定。我有一版列表和详情页对同一个头像传了不同的 cacheWidth,同一张图就被解码缓存了两次——把尺寸参数收进一个共用常量之后才合上。

Sliver:把「列表」拆开看

上面这些行为——按需构建、缓存区、子项配对——其实都在同一套抽象上,就是 Sliver。ListView、GridView 内部就是一个 CustomScrollView 包一个 SliverList/SliverGrid,参数也是转手传进去的。我真正需要它,是当我在一个页面里要「头部大图 + 搜索框 + 分组列表 + 吸顶字母标题」的时候:把这些套进一个 Column 再各自塞 ListView,得到的只是 BoxConstraints forces an infinite height. 这类报错——Column 给子项的高度约束是无界的,而滚动列表必须知道自己能占多少空间才能建视口。

Sliver 模型的思路是反过来:每个 sliver 只声明「我在滚动轴上多长、可见的那部分怎么摆」,由外层的 Viewport 统一持有滚动偏移并依次调度。所以多个来源不同的 sliver 能共享同一个滚动位置,页面级滚动只有一条。

我实际用到的几种:

sliver我拿它解决什么
SliverList定长/不定长的纵向序列,ListView 里的核心
SliverGrid九宫格、瀑布近似;crossAxisCount、childAspectRatio 在这里给
SliverAppBar会折叠、展开、渐隐的头部,expandedHeight 配 FlexibleSpaceBar
SliverPersistentHeader吸顶标题、悬浮筛选栏;需要自己写 delegate,控制收缩前后的两个高度
SliverToBoxAdapter把单个普通 widget(一整块说明文案、一个分隔条)塞进滚动流里

完整例子:吸顶分组通讯录

下面这一段是我最后落地的形态,能直接跑:一个可折叠头部,按字母分组,每组的标题在滚到自己组末尾之前一直吸在顶上。

class ContactGroup {
  const ContactGroup({required this.letter, required this.contacts});
  final String letter;
  final List<Contact> contacts;
}

class ContactsPage extends StatelessWidget {
  const ContactsPage({super.key, required this.groups});

  final List<ContactGroup> groups;

  @override
  Widget build(BuildContext context) {
    return CustomScrollView(
      // 只有一个 scrollOffset,头部折叠、分组标题吸顶都从它推出来
      slivers: [
        const SliverAppBar(
          pinned: true,
          expandedHeight: 170,
          title: Text('通讯录'),
          flexibleSpace: FlexibleSpaceBar(
            background: ColoredBox(color: Color(0xFF2E7D32)),
          ),
        ),
        for (final group in groups) ...[
          SliverPersistentHeader(
            // pinned 让标题停在视口上沿,直到下一组标题把它顶下去
            pinned: true,
            delegate: GroupHeaderDelegate(
              letter: group.letter,
              count: group.contacts.length,
            ),
          ),
          SliverList(
            delegate: SliverChildBuilderDelegate(
              (context, index) => ContactTile(contact: group.contacts[index]),
              childCount: group.contacts.length,
              // 数据重排后按 ID 找回旧的 Element,避免整组 State 被推倒重建
              findChildIndexCallback: (key) {
                if (key is ValueKey<String>) {
                  final i = group.contacts.indexWhere((c) => c.id == key.value);
                  return i < 0 ? null : i;
                }
                return null;
              },
            ),
          ),
        ],
      ],
    );
  }
}

class ContactTile extends StatelessWidget {
  const ContactTile({super.key, required this.contact});
  final Contact contact;

  @override
  Widget build(BuildContext context) {
    return ListTile(
      // key 由外层 delegate 挂,这里只保证项本身可复用
      title: Text(contact.name),
      subtitle: Text(contact.phone),
      trailing: Text(contact.letter),
    );
  }
}

class GroupHeaderDelegate extends SliverPersistentHeaderDelegate {
  const GroupHeaderDelegate({required this.letter, required this.count});

  final String letter;
  final int count;

  static const double _expandedHeight = 44;
  static const double _pinnedHeight = 32;

  @override
  double get minExtent => _pinnedHeight;

  // 收缩后的高度如果大于展开高度,框架的断言会直接把整页打挂
  @override
  double get maxExtent => _expandedHeight;

  @override
  Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {
    // shrinkOffset 从 0 变到 maxExtent-minExtent,我用它把标题从大字号收到小字号
    final t = (shrinkOffset / (_expandedHeight - _pinnedHeight)).clamp(0.0, 1.0);
    return SizedBox.expand(
      child: ColoredBox(
        color: Color.lerp(const Color(0xFFE8F5E9), const Color(0xFFFFFFFF), t)!,
        child: Center(
          child: Padding(
            padding: const EdgeInsets.symmetric(horizontal: 16),
            child: Row(
              children: [
                Text(letter,
                    style: TextStyle(
                        fontSize: 18 - 4 * t, fontWeight: FontWeight.w600)),
                const SizedBox(width: 8),
                Text('$count 人', style: TextStyle(fontSize: 12 + 1 * t)),
              ],
            ),
          ),
        ),
      ),
    );
  }

  // 返回 false 时框架沿用上一次的结果,标题内容不变就不必重新 build
  @override
  bool shouldRebuild(covariant GroupHeaderDelegate oldDelegate) =>
      oldDelegate.letter != letter || oldDelegate.count != count;
}

关于 findChildIndexCallback,我一开始以为有了 ValueKey 就够了,实测重排时还是能看到动画被打断。原因在实现层面:RenderSliverList 更新子项时,要在旧的子项序列里按 key 找到对应位置,默认策略是从头线性扫;扫不到就把旧 Element 整体移出、按新顺序重新构建。这个回调把「按 key 找位置」这件事交还给我,我用业务 ID 做映射,找得到就能原地搬。数据规模大、重排频繁时这个差别才明显;列表只有十几行、也不会重排的话,这行参数没有存在感。

学习时要特别注意的几处

滚动到底部抛 RangeError (index): Invalid value: Not in inclusive range。 症状是快速滑到列表末尾时 builder 里 contacts[index] 越界。原因是 itemCount 和 builder 里读的那份数据来自两处,异步刷新时 itemCount 已经变成新长度、builder 用的还是旧数组(或反过来)。判断方法是看异常栈里 index 落在哪个边界,再确认这两个值是不是同一个不可变对象的两个引用。我后来把「长度」和「数据」合并成一个 state 字段,一次赋值换掉整份。

吸顶标题报 Failed assertion: 'maxExtent >= minExtent'。 症状是整个 CustomScrollView 直接白屏,控制台甩出这条断言。原因是 SliverPersistentHeaderDelegate 里两个高度写反了——我把 minExtent 当成「最小可见高度」随手写了个更大的值。判断方法很直接:打印这两个 getter,确认收缩后的高度不大于展开高度。

列表里嵌列表时 BoxConstraints forces an infinite height. 症状是我把 Column 里第二个 Expanded 去掉后想省点层级,直接把 ListView 放进 Column。原因是 Column 在纵向上给子项的是无界约束,滚动组件拿到无界高度就无从决定视口。判断方式:看报错发生在 performLayout,而约束来源写的是 Column/Row 的某一项。我的处理是外层用 Expanded 收住高度,或者干脆把整页改成 CustomScrollView,把那块内容做成 sliver。

行内 ClipRRect + 阴影让 Raster 线程变长。 症状是 DevTools 里 UI 线程那一条很短、下面 Raster 那条很长,滑起来却明显卡。原因是圆角裁剪与 BoxShadow 走的是 saveLayer,离屏合成成本按面积算。判断方法是把圆角和阴影临时去掉再录一帧,看 Raster 时长是否回落。我最后的取舍是头像用预置圆角的图片资源,行阴影只在选中时给。

我是怎么定位到具体那一段的

排查工具里我用得最多的是 DevTools 的 Performance 面板。它把每一帧拆成 UI 线程和 Raster 线程两条,帧条超过预算线(60 Hz 下 16.67 ms,120 Hz 下 8.33 ms)会被标出来。我盯着三段耗时看:build 长说明子树构建太重(多半是 builder 里做了多余的计算,或者缓存区太小);layout 长往往是定高信息没给、内容尺寸要实测;paint 长和 Raster 长则指向绘制本身,overpaint、裁剪、模糊这类。

配合这一行,我能在时间线上看到逐个 widget 的 build 记录,定位到具体是哪一层的子树被重复构建:

// 只用于排查,打开后 build 记录会把时间线挤满,所以我没有留在发布版本里
debugProfileBuildsEnabled = true;

我在这上面翻过一次方向性错误:一开始以为掉帧是「行组件太多」,把行做得很简,几乎没变化;真正的原因在我给头像传的是原图尺寸,解码在 UI 线程上占掉了大半帧。把耗时拆到 build/layout/paint 三段来看,比凭直觉删组件有用得多——哪一段长,就顺着那一段的职责去找,而这三段各自的边界,正好对应前面 itemExtent、key、图片解码尺寸分别落在的位置。

评论

暂未收到

写下评论

支持 Markdown 语法