Flutter 列表性能优化

文章来源声明: 原文作者:陆断枫; 来源站点:掘金; 原文链接:https://juejin.cn/post/7690358995769212938; 本文基于上述来源整理/加工,觅优补充点评,仅供技术学习交流。版权归原作者所有。
觅优短评

适合中高级 Flutter 开发者:把过时经验换成 3.47 下的可执行做法,图文长列表与万条数据场景收益最直接。

列表卡顿这事,写 Flutter 的基本都遇到过。ListView 滚两下掉帧、图文列表进屏就卡、长列表内存滚着滚着就涨上去了。从 1.x 时代到现在,这类文章写了不少,但里面很多经验已经不好使了:
  • 有的代码在新的 SDK 下编译都过不了(就比如"继承 RenderSliverList 重写渲染"那套);
  • 有的原理本来就讲歪了(比如"加了 const 就能防重绘"这个说法);
  • 有的参数官方已经废弃改名(cacheExtent,现在改叫 scrollCacheExtent)。

本文按 Flutter 3.47把列表性能优化重新捋一遍。

先说一个前提,3.47 开始 Material / Cupertino 从 SDK 里拆出来成了 material_ui / cupertino_ui 两个独立包(1.0 已上架,想迁移的话 dart fix --apply --code=migrate_design_widgets 能自动改 import),不过核心 SDK 里带的 flutter/material.dart 还能正常用,真正要淘汰要等到看下一个版本。所以下面的例子就还是基于 material.dart。另外 Impeller 现在移动端、桌面端都默认了,测出来的滚动表现跟 Skia 会不一样,后面说性能的时候就按这个背景来理解。

一、先别急着优化,想清楚卡在哪

动手前得先有个预算的概念:

  • 60Hz 的屏一帧 16.67ms,可现在的手机基本都是 120Hz 高刷,预算直接砍到 8.33ms 左右。中端机也普遍默认开高刷了,所以网上那些"稳定 60fps 就行"的说法已经过时了。
  • 而且一帧里不光是 Dart 在做 widget 构建和布局,光栅化(Impeller/Raster 线程)也占时间,超了就掉帧。

列表卡顿的来源,其实就四类:

  1. 建得多——ListView(children: [...]) 把上千项一次性构造出来,起手就卡;
  2. 算得慢——item 嵌套五六层、又不给固定高度,每滚进来一个都得重新测量布局;
  3. 画得勤——item 里有持续动画或倒计时,每跳一帧整个可见区跟着重绘;
  4. 占得多——图片不加缓存上限、几千条数据一次全灌进来。

排查主要靠 DevTools 的 Performance 面板。不过有两件事注意:第一,一定要用 flutter run --profile 跑 Profile 模式,debug 模式那数据基本没参考价值(JIT 加一堆断言,开销大得离谱,新手最容易在这上面反复踩);第二,模拟器的帧率别当真,最终以真机为准。看到掉帧以后,点开单帧看耗时是花在 build、layout 还是 raster,再回上面四类去定位。

二、先把三个最常见的坑填了

换个写法:别再用 ListView(children:)

ListView(children: [...]) 会把子项一次性全建出来,这种属于不用多解释的低级错误。改成 ListView.builder 按需构建,是第一步,但很多人就停在这步了——其实还有一件顺手就能做的事:给固定高度。

高度都一样的话,直接给个 itemExtent,滚动时就不用逐个测量每个子项到底多高,这一步省得最多。

<span>import</span> '<span>package</span>:flutter/material.dart';
​
void main() => runApp(const <span>MyApp</span>());
​
<span><span>class</span> <span>MyApp</span> <span>extends</span> <span>StatelessWidget</span> </span>{
  const <span>MyApp</span>({<span>super</span>.key});
​
  <span>@override</span>
  <span>Widget</span> build(<span>BuildContext</span> context) {
    <span>return</span> <span>MaterialApp</span>(
      home: <span>Scaffold</span>(
        appBar: <span>AppBar</span>(title: const <span>Text</span>('<span>ListView</span>.builder 懒加载')),
        <span>// 反例:一次性构建 1000 个,初始化就卡</span>
        <span>// body: ListView(</span>
        <span>//   children: List.generate(1000, (i) => ItemTile(index: i)),</span>
        <span>// ),</span>
        body: <span>ListView</span>.builder(
          itemCount: <span>1000</span>,
          <span>// 高度固定直接给,省掉逐项测量</span>
          itemExtent: <span>52</span>,
          itemBuilder: (context, index) => <span>ItemTile</span>(index: index),
        ),
      ),
    );
  }
}
​
<span><span>class</span> <span>ItemTile</span> <span>extends</span> <span>StatelessWidget</span> </span>{
  <span>final</span> int index;
  const <span>ItemTile</span>({<span>super</span>.key, required <span>this</span>.index});
​
  <span>@override</span>
  <span>Widget</span> build(<span>BuildContext</span> context) {
    <span>return</span> <span>Padding</span>(
      padding: const <span>EdgeInsets</span>.symmetric(horizontal: <span>16</span>, vertical: <span>8</span>),
      child: <span>Align</span>(
        alignment: <span>Alignment</span>.centerLeft,
        child: <span>Text</span>('<span>Item</span> ${index + <span>1</span>}', style: const <span>TextStyle</span>(fontSize: <span>16</span>)),
      ),
    );
  }
}

子项高度不一样的话,itemExtent 就用不了,可以看两个替代:

  • prototypeItem:传一个样板 widget,让所有子项按它的高度来。适合那些"结构一样、内容长短不同,但高度被约束死"的 item;
  • itemExtentBuilder(3.35 引入,内部走 SliverVariedExtentList):干脆按 index 直接返回每个具体高度,测量都省了。签名是 (int index, SliverLayoutDimensions dimensions) => double?:
<span>ListView</span><span>.builder</span>(
  <span>itemCount</span>: items.length,
  <span>// 图文卡片 104,纯文字行 52,按内容给</span>
  <span>itemExtentBuilder</span>: (index, _) => items[index].hasCover ? <span>104</span> : <span>52</span>,
  <span>itemBuilder</span>: (context, index) => <span>FeedTile</span>(<span>item</span>: items[index]),
)

这里有个反直觉的坑得提醒:itemExtent 给的值必须跟 item 实际布局出来的一致,不然会被强行拉伸或裁掉。别说 itemExtent: 52 配一个实际占 80 高的 item,那俩直接打架,最新版本还专门加了 itemExtent 和约束的校验断言,报错会提示。

item 这回别套了

一个"图标 + 两行字"的 entry,有人能整出 Container 套 Container 套 Column 的四五层结构来。每一层嵌套都是一次多余的 layout 传递,滚上一百个 item,光这就有几百次多余计算。

能不手搓就不手搓,现成的组合件直接用:

<span><span>class</span> <span>FeedTile</span> <span>extends</span> <span>StatelessWidget</span> </span>{
  <span>final</span> <span>String</span> title;
  <span>final</span> <span>String</span> subtitle;
  const <span>FeedTile</span>({<span>super</span>.key, required <span>this</span>.title, required <span>this</span>.subtitle});
​
  <span>@override</span>
  <span>Widget</span> build(<span>BuildContext</span> context) {
    <span>return</span> <span>ListTile</span>(
      leading: const <span>Icon</span>(<span>Icons</span>.article_outlined, color: <span>Colors</span>.blue),
      title: <span>Text</span>(title, maxLines: <span>1</span>, overflow: <span>TextOverflow</span>.ellipsis),
      subtitle: <span>Text</span>(subtitle, maxLines: <span>1</span>, overflow: <span>TextOverflow</span>.ellipsis),
    );
  }
}

顺手给几个减负的小习惯:只要背景色就用 ColoredBox,别随手 Container(Container 会带上 padding/margin/decoration 那一堆合并判断);留白用 SizedBox 或者 Padding 的 EdgeInsets,别为了点间距又套一层;ListTile、Card 这些现成组合件内部布局已经优化过,别自己再造轮子。

顺便再解释一个流传挺广的误区:卡顿的源头并不是"SizedBox 比 Container 轻"这么细,真正花销的是层数带来的布局传递,少一层才是实打实省,纠结用哪个组件反而本末倒置。

聊两句 const,它不是你想的那样

不是所有的:"子组件加了 const 构造函数,父组件重绘时子组件就不重绘了。"这话是错的。const 构造函数只是必要前提,真正起作用的是 const 字面量。

原理很简单:用 const 字面量建出来的 widget,Dart 会做规范化(canonicalize),每次 build 拿到的都是同一个实例。Element 做 diff 的时候发现新旧 widget 是 identical,直接跳过整棵子树——不 rebuild、不 relayout、也不 repaint。但如果你只是把构造函数写成 const,调用处没写 const,照样每次 new 一个,一点便宜占不到。

直观验证:

<span><span>class</span> <span>ConstDemo</span> <span>extends</span> <span>StatefulWidget</span> </span>{
  const <span>ConstDemo</span>({<span>super</span>.key});
  <span>@override</span>
  <span>State</span><<span>ConstDemo</span>> createState() => _ConstDemoState();
}
​
<span><span>class</span> <span>_ConstDemoState</span> <span>extends</span> <span>State<ConstDemo></span> </span>{
  int _count = <span>0</span>;
​
  <span>@override</span>
  <span>Widget</span> build(<span>BuildContext</span> context) {
    <span>return</span> <span>Scaffold</span>(
      appBar: <span>AppBar</span>(
        title: <span>Text</span>('父组件 rebuild 次数:$_count'),
        actions: [
          <span>IconButton</span>(
            icon: const <span>Icon</span>(<span>Icons</span>.refresh),
            onPressed: () => setState(() => _count++),
          ),
        ],
      ),
      body: <span>Column</span>(
        children: const [
          <span>// const 字面量:父组件 setState 时这两项会跳过 rebuild</span>
          _StaticLeaf(),
          _StaticLeaf(),
        ],
      ),
    );
  }
}
​
<span><span>class</span> <span>_StaticLeaf</span> <span>extends</span> <span>StatelessWidget</span> </span>{
  const _StaticLeaf();
​
  <span>@override</span>
  <span>Widget</span> build(<span>BuildContext</span> context) {
    debugPrint('<span>StaticLeaf</span> build'); <span>// 整个生命周期只该打印一次</span>
    <span>return</span> const <span>Padding</span>(
      padding: <span>EdgeInsets</span>.all(<span>16</span>),
      child: <span>Text</span>('静态子树'),
    );
  }
}

刷新按钮点多少次,控制台都不会多打一条 log。把 children: const [...] 里的 const 去掉再试,每 setState 一次它就打一次。

但这对列表来说意义没想的那么大。ListView.builder 的 itemBuilder 里,ItemTile(index: index) 这种带着动态参数的调用本来就不是 const 表达式,item 滚进屏幕时该重新 build 就得 build——这是正常开销,const 救不了,也不用救。const 真正用得上的是那些死的静态子树:列表里的 icon、分割线、装饰性文字、空态页,放进 const,父级怎么 rebuild 都跟它们没关系。附带的好处是这些规范化实例不产生垃圾对象,几千个 item 滚一天也少些 GC 压力。

至于是不是每个 const 都值得手工去补?没必要,让 IDE 的自动 const 插入(quick fix)处理就行,别把"代码里到处是 const"当成成果。

三、图文、分页、动画这三件具体的事

图文列表:升级到 4.x,再管管图片缓存

图文列表是最容易掉帧的场景之一, cached_network_image ——现在已经 4.x 了(要求 Flutter ≥ 3.44):

<span>dependencies:</span>
  <span>cached_network_image:</span> <span>^4.0.2</span>

<span>import</span> <span>'package:cached_network_image/cached_network_image.dart'</span>;
<span>import</span> <span>'package:flutter/material.dart'</span>;
​
<span>void</span> <span>main</span>(<span></span>) {
  <span>// 全局内存图片缓存上限。默认 1000 张 / 100MB,图文列表建议主动收紧</span>
  <span>PaintingBinding</span>.<span>instance</span>.<span>imageCache</span>.<span>maximumSize</span> = <span>300</span>;
  <span>PaintingBinding</span>.<span>instance</span>.<span>imageCache</span>.<span>maximumSizeBytes</span> = <span>64</span> << <span>20</span>; <span>// 64MB</span>
  <span>runApp</span>(<span>const</span> <span>MyApp</span>());
}
​
<span>class</span> <span>ImageTile</span> <span>extends</span> <span>StatelessWidget</span> {
  final <span>String</span> coverUrl;
  final <span>String</span> title;
  <span>const</span> <span>ImageTile</span>({<span>super</span>.<span>key</span>, required <span>this</span>.<span>coverUrl</span>, required <span>this</span>.<span>title</span>});
​
  <span>@override</span>
  <span>Widget</span> <span>build</span>(<span>BuildContext context</span>) {
    <span>return</span> <span>ListTile</span>(
      <span>leading</span>: <span>ClipRRect</span>(
        <span>borderRadius</span>: <span>BorderRadius</span>.<span>circular</span>(<span>8</span>),
        <span>child</span>: <span>CachedNetworkImage</span>(
          <span>width</span>: <span>72</span>,
          <span>height</span>: <span>72</span>,
          <span>fit</span>: <span>BoxFit</span>.<span>cover</span>,
          <span>imageUrl</span>: coverUrl,
          <span>// 占位和失败态必须给,不然滚动时一片空白比卡顿还难看</span>
          <span>placeholder</span>: <span>(<span>context, url</span>) =></span> <span>const</span> <span>ColoredBox</span>(<span>color</span>: <span>Color</span>(<span>0xFFEEEEEE</span>)),
          <span>errorWidget</span>: <span>(<span>context, url, error</span>) =></span> <span>const</span> <span>Icon</span>(<span>Icons</span>.<span>broken_image_outlined</span>),
          <span>// 命中磁盘缓存时做一个 200ms 淡入,视觉上抹平解码延迟</span>
          <span>fadeInDuration</span>: <span>const</span> <span>Duration</span>(<span>milliseconds</span>: <span>200</span>),
        ),
      ),
      <span>title</span>: <span>Text</span>(title, <span>maxLines</span>: <span>2</span>, <span>overflow</span>: <span>TextOverflow</span>.<span>ellipsis</span>),
    );
  }
}

有两个容易被漏掉的点。

第一,图片内存暴涨的根子往往在内存缓存。ImageCache 默认大概能放 100MB / 1000 张,注意这是解码后的位图,一张 2K 原图解码出来就是十几 MB,没几张就顶满了,之后就是不停踢旧图、重新解码,又是掉帧又是费流量。上面代码收紧上限是一方面,更管用的是服务端配合裁剪尺寸——列表里 72dp 的缩略图,别让接口返回 2K 原图。这一条是我在实际项目里觉得收益最大的,比换加载库管用。

第二,要是列表特别长、图特别多,你对缓存查找那一下的性能较真,社区有个 cached_network_image_ce 分叉,底层把 sqflite 换成了 hive,缓存命中查询快一个量级,API 完全兼容,可以关注。当然原版 4.x 日常用足够。

分页 + 预加载:别再一次性灌千条数据

数据量一大,靠渲染层硬扛是扛不住的,得从数据源头分页。老文那套"ScrollController 监听 + 到阈值就预加载"的思路没过时,就是写法能更现代点:

<span>// Dart 3 records 当轻量 DTO,比 Map<String, String> 强类型也好读</span>
<span>typedef</span> FeedItem = ({<span>String</span> title, <span>String</span> cover});
​
<span><span>class</span> <span>FeedPage</span> <span>extends</span> <span>StatefulWidget</span> </span>{
  <span>const</span> FeedPage({<span>super</span>.key});
  <span>@override</span>
  State<FeedPage> createState() => _FeedPageState();
}
​
<span><span>class</span> <span>_FeedPageState</span> <span>extends</span> <span>State</span><<span>FeedPage</span>> </span>{
  <span>static</span> <span>const</span> _pageSize = <span>20</span>;
​
  <span>final</span> _items = <FeedItem>[];
  <span>final</span> _controller = ScrollController();
  <span>int</span> _page = <span>1</span>;
  <span>bool</span> _loading = <span>false</span>;
  <span>bool</span> _hasMore = <span>true</span>;
​
  <span>@override</span>
  <span>void</span> initState() {
    <span>super</span>.initState();
    _loadMore();
    _controller.addListener(() {
      <span>// extentAfter 是剩余可滚动距离,比 maxScrollExtent - pixels 直观</span>
      <span>if</span> (_controller.position.extentAfter < <span>300</span>) _loadMore();
    });
  }
​
  Future<<span>void</span>> _loadMore() <span>async</span> {
    <span>if</span> (_loading || !_hasMore) <span>return</span>;
    _loading = <span>true</span>;
    <span>try</span> {
      <span>final</span> batch = <span>await</span> _fetchPage(_page);
      <span>if</span> (!mounted) <span>return</span>;
      setState(() {
        _items.addAll(batch);
        _page++;
        _hasMore = batch.length == _pageSize;
      });
    } <span>catch</span> (e) {
      <span>// 失败得留重试的口子,别让 _loading 复位后静默空转</span>
      debugPrint(<span>'分页加载失败: <span>$e</span>'</span>);
    } <span>finally</span> {
      _loading = <span>false</span>;
    }
  }
​
  <span>@override</span>
  <span>void</span> dispose() {
    _controller.dispose();
    <span>super</span>.dispose();
  }
​
  <span>// 模拟接口,真实项目换你的网络层</span>
  Future<<span>List</span><FeedItem>> _fetchPage(<span>int</span> page) <span>async</span> {
    <span>await</span> Future<<span>void</span>>.delayed(<span>const</span> <span>Duration</span>(milliseconds: <span>600</span>));
    <span>return</span> <span>List</span>.generate(
      page > <span>5</span> ? <span>0</span> : _pageSize,
      (i) => (title: <span>'Feed <span>${(page - <span>1</span>) * _pageSize + i + <span>1</span>}</span>'</span>, cover: <span>''</span>),
    );
  }
​
  <span>@override</span>
  Widget build(BuildContext context) {
    <span>return</span> Scaffold(
      appBar: AppBar(title: <span>const</span> Text(<span>'分页加载'</span>)),
      body: ListView.builder(
        controller: _controller,
        itemCount: _items.length + <span>1</span>, <span>// 尾部留一个 footer 位</span>
        itemBuilder: (context, index) {
          <span>if</span> (index == _items.length) {
            <span>return</span> Padding(
              padding: <span>const</span> EdgeInsets.symmetric(vertical: <span>16</span>),
              child: Center(
                child: _hasMore
                    ? <span>const</span> CircularProgressIndicator()
                    : <span>const</span> Text(<span>'没有更多了'</span>),
              ),
            );
          }
          <span>return</span> ListTile(title: Text(_items[index].title));
        },
      ),
    );
  }
}

几个具体的点:extentAfter < 300 这个提前量跟着你具体情况调,网络慢就提前到 500–800,别让用户滚到底才看到 footer 转圈;catch 里一定要给失败状况兜底,否则网络抖一下,_loading 复位了数据却没进来,用户就干看着转圈;不想持有 ScrollController 的话,NotificationListener<ScrollNotification> 是等价写法,还能顺便拿到滚动方向做"上拉才加载"。

RepaintBoundary:隔离的是动画,不是点击

给"点击变色"的 item 套 RepaintBoundary,说实话收益很小。先纠正个基础认知:ListView.builder 默认 addRepaintBoundaries: true,也就是说每个 item 外层本来就带了一个 RepaintBoundary。所以那种"一个 item 状态变化带动整个列表重绘"的情形,正常配置下根本不会发生——你套不套,边界都在那儿。

RepaintBoundary 真正该手动加的场景只有一个:item 内部有一小块区域在持续高频重绘(倒计时、呼吸点、旋转的 icon、进度条),你不想让这一帧帧的动画带动整个 item 跟着重绘。把动画那小块单独框起来,重绘就锁死在这个边界里:

<span><span>class</span> <span>LiveTile</span> <span>extends</span> <span>StatefulWidget</span> </span>{
  <span>final</span> <span>String</span> title;
  const <span>LiveTile</span>({<span>super</span>.key, required <span>this</span>.title});
  <span>@override</span>
  <span>State</span><<span>LiveTile</span>> createState() => _LiveTileState();
}
​
<span><span>class</span> <span>_LiveTileState</span> <span>extends</span> <span>State<LiveTile></span></span>
    <span>with</span> <span>SingleTickerProviderStateMixin</span> {
  late <span>final</span> <span>AnimationController</span> _controller = <span>AnimationController</span>(
    vsync: <span>this</span>,
    duration: const <span>Duration</span>(milliseconds: <span>1200</span>),
  )..repeat(reverse: <span>true</span>);
​
  <span>@override</span>
  void dispose() {
    _controller.dispose();
    <span>super</span>.dispose();
  }
​
  <span>@override</span>
  <span>Widget</span> build(<span>BuildContext</span> context) {
    <span>return</span> <span>ListTile</span>(
      <span>// 只框住动画那块,每帧的 repaint 都困在这一小块</span>
      leading: <span>RepaintBoundary</span>(
        child: <span>FadeTransition</span>(
          opacity: _controller,
          child: const <span>DecoratedBox</span>(
            decoration: <span>BoxDecoration</span>(
              color: <span>Colors</span>.green,
              shape: <span>BoxShape</span>.circle,
            ),
            child: <span>SizedBox</span>(width: <span>10</span>, height: <span>10</span>),
          ),
        ),
      ),
      <span>// 右边静态内容完全不受动画影响</span>
      title: <span>Text</span>(widget.title),
      subtitle: const <span>Text</span>('直播中 · 静态区域不参与重绘'),
    );
  }
}

想验证的话很简单:把 RepaintBoundary 去掉,用 DevTools 的 repaint 彩虹图看滚动时的重绘范围,整个 item 都会跟着动画闪,加上之后就只剩那个 10×10 的小绿点在闪。

但别滥用。每个边界都要占一块 layer 内存,给每个 icon 都框一层反而亏。原则就那么一条:只隔离高频变化的区域,静态布局交给默认边界就行。

网格列表:SliverGrid 不是"更快",是"能混排"

有个传得很广的错误说法——"SliverGrid 比 GridView 快"。真相是 GridView.builder 内部就是个 SliverGrid,单独用根本没差别。你该上 CustomScrollView + SliverGrid 的唯一理由是:顶部 Banner、中间网格、底下列表这种混排结构,sliver 全家桶能在同一个滚动视图里统一复用,而不是各滚各的嵌套滚动。

<span><span>class</span> <span>MixedFeedPage</span> <span>extends</span> <span>StatelessWidget</span> </span>{
  <span>const</span> <span>MixedFeedPage</span>({super.key});
​
  @override
  Widget <span>build</span>(BuildContext context) {
    <span>return</span> <span>Scaffold</span>(
     <span> appBar</span>: <span>AppBar</span>(<span>title</span>: <span>const</span> <span>Text</span>(<span>'Banner + 网格混排'</span>)),
     <span> body</span>: <span>CustomScrollView</span>(
       <span> slivers</span>: [
          // 头部 Banner 跟着一起滚、一起复用
          <span>const</span> <span>SliverToBoxAdapter</span>(<span>child</span>: <span>_Banner</span>()),
          <span>SliverPadding</span>(
           <span> padding</span>: <span>const</span> EdgeInsets.<span>all</span>(<span>12</span>),
           <span> sliver</span>: SliverGrid.<span>builder</span>(
             <span> gridDelegate</span>: <span>const</span> <span>SliverGridDelegateWithFixedCrossAxisCount</span>(
               <span> crossAxisCount</span>: <span>2</span>,
               <span> crossAxisSpacing</span>: <span>12</span>,
               <span> mainAxisSpacing</span>: <span>12</span>,
                // 宽/高比,<span>1.0</span> 是正方形;比例写反会把 item 裁掉,这坑常见
               <span> childAspectRatio</span>: <span>1.2</span>,
              ),
             <span> itemCount</span>: <span>200</span>,
             <span> itemBuilder</span>: (context, index) => <span>GridTile</span>(<span>index</span>: index),
            ),
          ),
        ],
      ),
    );
  }
}
​
<span><span>class</span> <span>_Banner</span> <span>extends</span> <span>StatelessWidget</span> </span>{
  <span>const</span> <span>_Banner</span>();
  @override
  Widget <span>build</span>(BuildContext context) {
    <span>return</span> <span>Container</span>(
     <span> height</span>: <span>160</span>,
     <span> margin</span>: <span>const</span> EdgeInsets.<span>all</span>(<span>12</span>),
     <span> decoration</span>: <span>BoxDecoration</span>(
       <span> color</span>: Colors.blue.shade50,
       <span> borderRadius</span>: BorderRadius.<span>circular</span>(<span>12</span>),
      ),
     <span> alignment</span>: Alignment.center,
     <span> child</span>: <span>const</span> <span>Text</span>(<span>'Banner 区域'</span>),
    );
  }
}
​
<span><span>class</span> <span>GridTile</span> <span>extends</span> <span>StatelessWidget</span> </span>{
  <span>final</span> <span>int</span> index;
  <span>const</span> <span>GridTile</span>({super.key, required this.index});
​
  @override
  Widget <span>build</span>(BuildContext context) {
    <span>return</span> <span>ColoredBox</span>(
     <span> color</span>: Colors.grey.shade100,
     <span> child</span>: <span>Center</span>(<span>child</span>: <span>Text</span>(<span>'Grid ${index + 1}'</span>)),
    );
  }
}

至于选哪个 delegate,这条值得留:每行固定个数用 SliverGridDelegateWithFixedCrossAxisCount;想让 item 宽度自适应、行数随屏宽变就用 SliverGridDelegateWithMaxCrossAxisExtent——平板横屏的时候这个是真救命的。

四、万条数据那点事,别走弯路

别再寻思继承 RenderSliverList 了

以前继承 SliverList / RenderSliverList 去重写渲染逻辑。里面 context as SliverChildManager 这种强转,在新版 SDK 下编译直接报错(不相关类型强转),childManager 那套内部 API 也早就变了。就算你把编译糊弄过去了,"手动重新 layout 可见子项"的思路本身也是错的——sliver 的懒加载协议本来就不会给不可见子项布局,你再重写一遍属于往坑里跳。这条路真不用走。

十万条纯数据,想稳住 60fps,配置就这么多:

<span>ListView</span><span>.builder</span>(
  <span>itemCount</span>: <span>100000</span>,
  <span>// 1. 固定高度,测量开销归零,这对长列表是头号功臣</span>
  <span>itemExtent</span>: <span>48</span>,
  <span>// 2. 3.41 起 cacheExtent 废弃,改用 scrollCacheExtent 控制预渲染区</span>
  <span>//    长列表调大能少出现滚进来的"白块"</span>
  <span>scrollCacheExtent</span>: ScrollCacheExtent.<span>viewport</span>(<span>1</span>),
  <span>// 3. 全是无状态 item 就不需要保活,省掉每个 item 的 keepalive 开销</span>
  <span>addAutomaticKeepAlives</span>: false,
  <span>// 4. 保持默认 true,item 级重绘边界仍然有用</span>
  <span>addRepaintBoundaries</span>: true,
  <span>itemBuilder</span>: (context, index) => <span>Align</span>(
    <span>alignment</span>: Alignment.centerLeft,
    <span>child</span>: <span>Padding</span>(
      <span>padding</span>: const EdgeInsets.<span>symmetric</span>(<span>horizontal</span>: <span>16</span>),
      <span>child</span>: <span>Text</span>(<span>'Item $index'</span>, <span>style</span>: const <span>TextStyle</span>(<span>fontSize</span>: <span>16</span>)),
    ),
  ),
)

关键还是 itemExtent。高度不固定时,sliver 每次滚动都要对入屏的 item 逐个测量;给了定值,整条布局走固定管线,十万条跟一千条在渲染层没啥区别。

但注意,渲染层扛得住不代表内存扛得住。十万条字符串对象全塞进 List 里照样几十 MB,所以正确组合永远是"分页拿数据 + 固定高度渲染"。scrollCacheExtent 这个参数是 3.41 之后出的(旧的 cacheExtent 已标 @Deprecated),ScrollCacheExtent.viewport(1) 意思就是预渲染一整屏,图多、item 重的列表你想保守点就改成 pixels(300),省内存。

AutomaticKeepAliveClientMixin:它是拿内存换状态

以前逻辑是给"前 10 项和后 10 项"选择性保活,是为了省内存。可保活(keepalive)这机制的本质是多占内存,换取 item 滚出屏幕后状态不丢——你用它是为了保状态,不是为了省内存。真要省内存,方向恰恰相反,是关掉它。

什么时候值得保活?item 是 StatefulWidget,滚回来时你不想让它从头再来:视频/音频列表的播放进度、已加载的封面、用户展开的折叠状态、表单草稿。

<span><span>class</span> <span>VideoTile</span> <span>extends</span> <span>StatefulWidget</span> </span>{
  <span>final</span> <span>String</span> title;
  const <span>VideoTile</span>({<span>super</span>.key, required <span>this</span>.title});
  <span>@override</span>
  <span>State</span><<span>VideoTile</span>> createState() => _VideoTileState();
}
​
<span><span>class</span> <span>_VideoTileState</span> <span>extends</span> <span>State<VideoTile></span></span>
    <span>with</span> <span>AutomaticKeepAliveClientMixin</span> {
  <span>// 滚出屏幕也保持活着:播放进度、已解码的第一帧都别丢</span>
  <span>@override</span>
  bool get wantKeepAlive => <span>true</span>;
​
  <span>@override</span>
  <span>Widget</span> build(<span>BuildContext</span> context) {
    <span>super</span>.build(context); <span>// 混入的硬性要求,忘了调它保活根本不生效</span>
    <span>return</span> <span>ListTile</span>(
      leading: const <span>Icon</span>(<span>Icons</span>.play_circle_outline, size: <span>40</span>),
      title: <span>Text</span>(widget.title),
      subtitle: const <span>Text</span>('播放到 <span>03</span>:<span>12</span> · 状态已保活'),
    );
  }
​
  <span>@override</span>
  void dispose() {
    <span>// 列表整体销毁时才走到这,停播放器、取消图片流都丢在这</span>
    debugPrint('<span>VideoTile</span> ${widget.title} disposed');
    <span>super</span>.dispose();
  }
}

有两个连带的事得说清楚。ListView.builder 默认 addAutomaticKeepAlives: true,意味着每个带 State 的 item 都默认挂了保活机制,wantKeepAlive 返回 true 才真保,false 就跟普通 item 一样销毁。保活的 item 会攒在缓存区,视频、大图这种一多内存就翘——这时候就在列表级别把 addAutomaticKeepAlives 关掉,或者用 scrollCacheExtent 把缓存区压小,只让真正需要保状态的少数 item 自己开 wantKeepAlive。

再一个,dispose 里的清理(停动画、cancel 图片流、释放播放器)是内存兜底的最后一环。AutomaticKeepAlive 只保证"活着的时候不销毁你",保证不了"销毁的时候你已经把自己收拾干净"。所以这种脏活别依赖框架,自己在 dispose 里干。

五、踩坑速查

这几条是我觉得最值得记住的,按重要性排:

  • 别用 ListView(children:) 铺大数据,换 builder,再尽量给固定高度(itemExtent / itemExtentBuilder)。测量开销才是大头。
  • "const 防重绘"是个误会,只有 const 字面量才 canonicalize;带参数的 itemBuilder 该 build 就 build,const 管的是死静态子树。
  • 图片列表内存涨,一条对的方向:cached_network_image 4.x + 收紧 ImageCache 上限 + 服务端按尺寸裁剪,别让列表返回原图。
  • cacheExtent 已经废了,3.41 起用 scrollCacheExtent: ScrollCacheExtent.viewport(n)。
  • item 里有动画卡,别包整个列表,每个 item 本来就有默认重绘边界,手动边界只框动画那块。
  • 万条数据卡,不用搞底层重写(那套在新版连编译都过不了),itemExtent + 分页 + 关掉 keepalive 够了。
  • 保活是拿内存换状态,想省内存正好相反,能关就关。
  • shrinkWrap: true 慎用——它会让内部列表失去懒加载,先把所有子项量一遍再渲染。嵌套滚动老老实实用 NestedScrollView 或者平铺 sliver。
  • 性能验证别在 debug 模式糊弄,--profile + 真机,模拟器数据不算。

收尾

列表优化抠到底还是那三件事:少建、少算、少画。做法上按顺序来就行。

先把基础的三样做了——builder 懒加载、固定高度、布局精简,大部分列表到这一步就不卡了。真有图文、分页、动画的具体场景再逐个对症下药。万条数据这种,别一上来就寻思底层重写,原生配置拉满加个分页,已经是现在这个生态里的标准做法。

每改完一步,用 Profile 模式跑一遍 DevTools Performance 验证下收效,别凭感觉。另外有个省事的思路:Flutter 大版本升级本身就会带来一批性能变化(比如 Impeller 推开了之后滚动体验明显不一样),所以项目每次升完 SDK,顺手做一轮列表回归。很多你以为是"玄学卡顿"的问题,换个新版大版本就自己好了。