列表与滚动:builder 的回收,与卡顿排查
这一章你几乎白赚。ListView.builder 就是 RecyclerView/LazyColumn——只构建可见项加一圈缓存,滚出去的回收掉。你的经验直接用。这一章要做的只有三件事:用引擎让你看清回收到底省了多少、点破那个「一进页面卡半秒」的经典写法、以及讲清列表项的状态该放哪才不会「滚回来就没了」。
亲手跑:视口回收
一个 1000 项的列表。橙色是真的被 build 出来的,蓝色是屏幕上看得见的。拖那个滑块滚动,看两种写法的差别。
切到 ListView.builder:不管你滚到哪,当场只有十几个项活着——视口里那几个,加上上下各一圈缓存区。滚出去的被 dispose、滚进来的即时 build。1000 项,省下 98% 以上的构建。
切到 ListView(children: [...]):1000 个项一进页面就全部被构建出来,哪怕你只看得见十个。这就是下面这个经典事故的原理。
ListView(children: List.generate(1000, ...)) 和 ListView.builder(itemCount: 1000, ...) 看起来只差一个写法,性能天差地别:
前者:进页面的那一帧,要同步构建 1000 个 Widget(可能还各带一张图、一次布局)——这一帧轻松超过 16ms(第 18 章),用户看到明显的卡顿,甚至白屏半秒。
后者:只构建视口内那十几个,进页面瞬间完成。
铁律:数据量不确定或可能长,永远用 .builder。ListView(children:) 只留给「就三五个、写死的」短列表。
builder 家族:按形态选
| 要什么 | 用什么 |
|---|---|
| 纵向列表 | ListView.builder |
| 项之间要分隔线 | ListView.separated(多一个 separatorBuilder) |
| 网格 | GridView.builder |
| 横向滚动 | ListView.builder(scrollDirection: Axis.horizontal) |
| 下拉刷新 | 外面包 RefreshIndicator |
| 混合多种内容的滚动 | CustomScrollView + slivers(第 13 章) |
列表项的状态放哪:一个必须想清的问题
回收带来一个第 8 章埋过的后果:滚出视口的项,它的 Element 和 State 被销毁了。所以——
假设每个待办项内部用 StatefulWidget 存「勾选没勾选」。你勾了第 1 项,滚下去(它被回收)、再滚回来(它被重建、initState 重跑)——勾选状态没了,因为承载它的 State 已经被 dispose 了。
解法:把列表项的状态往上提(第 14、17 章)——存在页面的 Notifier / Repository 里,列表项做成无状态的、纯根据数据渲染。这样它被回收再重建也无所谓,状态在上面好好待着。
这和 RecyclerView 里「别把状态存在 ViewHolder 上、要存在 adapter 的数据里」是一模一样的教训——你早就懂,只是换了个框架再遇到一次。
顺带:这也是第 8 章 key 的用武之地。如果列表项确实需要各自的状态(带动画、内嵌输入框)且列表会重排,就给每项 ValueKey(item.id),让状态跟着正确的项走。
cacheExtent:为什么不是「刚好可见」才建
你可能注意到引擎里「build 出来的」总比「看得见的」多一圈——那圈就是 cacheExtent:视口上下额外预建的一段(默认 250 像素)。它让你滚动时下一批项已经建好了,不会边滚边现建导致卡顿。
它是速度和内存的权衡:调大更顺滑但更耗内存,调小省内存但滚快了可能露白。默认值几乎总是对的,别乱改——只有在特定场景(比如项特别重、想更激进预加载)才微调。
列表性能:真出问题时的排查清单
用了 .builder 还卡,多半是每个项的构建太重。按这个顺序查:
| 症状 / 检查 | 动作 |
|---|---|
| 项里有图片 | 用 cached_network_image;给 Image 指定 cacheWidth/cacheHeight 让它解码成显示尺寸(别加载原图再缩) |
| 项里能加 const 的没加 | 第 4 章:静态部分 const 掉 |
| 项很复杂、还带独立动画/重绘 | 用 RepaintBoundary 包住,把它的重绘和别的项隔离(见下) |
| 滚动时明显掉帧 | DevTools Performance 录一段,看是 Build 慢还是 Raster 慢 |
| 项高度固定 | 给 itemExtent,框架能跳过测量、滚动更快 |
第 6 章说过,Paint 比 Build 贵。如果列表里某个项在频繁重绘(一个动画、一个进度条),默认它可能连累整个列表所在图层一起重绘。用 RepaintBoundary 包住它,它就有了自己的图层,重绘被锁在这个项内部,不外溢。DevTools 的「Highlight Repaints」能让你直接看到哪块在闪——闪的地方就是该包 boundary 的地方。别到处滥包(每个 boundary 有自己的内存开销),只包真正高频重绘的。
顺带:Impeller 让滚动更顺了
第 1 章提过一句,这里落实。你可能听过「Flutter 列表首次快速滚动会卡一下」——那是旧的 Skia 渲染器需要在运行时编译着色器,第一次遇到某个特效就现编,造成一次性抖动。
Impeller 把着色器编译提前到了构建期,彻底消除了这类抖动。而到 Flutter 3.44,Android 10+ 上已经只有 Impeller(Skia 回退被移除)。所以:如果你按几年前的印象担心「Flutter 滚动会抖」,那个问题在你用的版本上已经不存在了——你不用再做当年那些「预热着色器」的偏方。
下拉刷新与上拉加载更多
这是列表页的两个标配交互。下拉刷新最简单——包一层 RefreshIndicator:
RefreshIndicator( onRefresh: () => ref.refresh(courseListProvider.future), // 返回 Future,转圈到它完成 child: ListView.builder(...), )
上拉加载更多(配合第 22 章的分页)要监听滚动,快到底时触发下一页:
final _controller = ScrollController();
@override
void initState() {
super.initState();
_controller.addListener(() {
// 距底部还有 200 像素时,预加载下一页
if (_controller.position.pixels >= _controller.position.maxScrollExtent - 200) {
ref.read(noticesProvider.notifier).loadMore();
}
});
}
@override
void dispose() { _controller.dispose(); super.dispose(); } // 第 9 章的对称律
// build 里把 controller 交给列表
ListView.builder(controller: _controller, ...)
ScrollController 是你监听和控制滚动的把手(拿当前位置、跳到某处、监听到底),对应 Compose 的 LazyListState / rememberLazyListState。别忘了 dispose。
空状态、加载态、错误态:列表页的三张脸
新手常只写「有数据」这一种情况,结果列表为空时是一片惨白、加载时是白屏、出错时是红屏。一个像样的列表页要处理四种状态,配第 19 章的 AsyncValue 正好:
switch (ref.watch(coursesProvider)) {
AsyncData(:final value) when value.isEmpty => const EmptyView(text: '还没有课程'),
AsyncData(:final value) => ListView.builder(itemCount: value.length, ...),
AsyncError(:final error) => ErrorView(error: error, onRetry: () => ref.refresh(coursesProvider)),
_ => const Center(child: CircularProgressIndicator()),
}
注意第一条那个 when value.isEmpty——「加载完了但列表是空的」和「还在加载」是两回事,用户体验差别很大。学校 App 里「本周没有课」「暂无通知」这类空状态很常见,别让它显示成一片空白让人以为坏了。
| Compose / Android | Flutter |
|---|---|
LazyColumn { items(...) } | ListView.builder |
LazyVerticalGrid | GridView.builder |
items(list, key = { it.id }) | itemBuilder + ValueKey(item.id) |
| 状态存 ViewModel 不存 item | 状态提升到 Notifier,item 无状态 |
Coil 加载图 + 内存缓存 | cached_network_image |
| RecyclerView 的 ViewHolder 复用 | Element 回收 + cacheExtent 预建 |
这是全书迁移成本最低的一章——「只建可见项、状态别放在项里、图片要缓存和降采样」这三条你在 Android 上已经刻进肌肉了,换个 API 名照做即可。唯一的新东西是那条「用 .builder 别用 children:」的写法陷阱,记住它就行。
这一章的一句话
ListView.builder 就是你熟的 RecyclerView / LazyColumn——只建可见项加一圈 cacheExtent;「一进页面卡半秒」是误用了全量构建的 ListView(children:);而列表项的状态必须往上提到 Notifier(项做成无状态),否则滚回来就没了——这些教训你在 Android 上早就学过,直接搬。
只剩最后一章了。页面、状态、异步、数据、列表——App 的骨肉都齐了。下一章把它交付出去:主题怎么统一、要调原生功能(相机、通知)时的平台通道、怎么写测试、以及那张跨越两个平台(现在你多了 iOS 那半边)的构建与上架清单。