卷 VI · 交付CH 23深度 23/24

列表与滚动:builder 的回收,与卡顿排查

直觉过境 「LazyColumn 那套『只建可见项』的经验有用。」 直接放行

这一章你几乎白赚。ListView.builder 就是 RecyclerViewLazyColumn——只构建可见项加一圈缓存,滚出去的回收掉。你的经验直接用。这一章要做的只有三件事:用引擎让你看清回收到底省了多少、点破那个「一进页面卡半秒」的经典写法、以及讲清列表项的状态该放哪才不会「滚回来就没了」。

视口回收builder vs childrencacheExtent卡顿排查

亲手跑:视口回收

一个 1000 项的列表。橙色是真的被 build 出来的,蓝色是屏幕上看得见的。拖那个滑块滚动,看两种写法的差别。

切到 ListView.builder:不管你滚到哪,当场只有十几个项活着——视口里那几个,加上上下各一圈缓存区。滚出去的被 dispose、滚进来的即时 build。1000 项,省下 98% 以上的构建。

切到 ListView(children: [...])1000 个项一进页面就全部被构建出来,哪怕你只看得见十个。这就是下面这个经典事故的原理。

◆ 「一进页面卡半秒」的元凶

ListView(children: List.generate(1000, ...))ListView.builder(itemCount: 1000, ...) 看起来只差一个写法,性能天差地别:

前者:进页面的那一帧,要同步构建 1000 个 Widget(可能还各带一张图、一次布局)——这一帧轻松超过 16ms(第 18 章),用户看到明显的卡顿,甚至白屏半秒。

后者:只构建视口内那十几个,进页面瞬间完成。

铁律:数据量不确定或可能长,永远用 .builderListView(children:) 只留给「就三五个、写死的」短列表。

builder 家族:按形态选

要什么用什么
纵向列表ListView.builder
项之间要分隔线ListView.separated(多一个 separatorBuilder
网格GridView.builder
横向滚动ListView.builder(scrollDirection: Axis.horizontal)
下拉刷新外面包 RefreshIndicator
混合多种内容的滚动CustomScrollView + slivers(第 13 章)

列表项的状态放哪:一个必须想清的问题

回收带来一个第 8 章埋过的后果:滚出视口的项,它的 Element 和 State 被销毁了。所以——

⚠ 列表项的状态别存在项自己的 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,框架能跳过测量、滚动更快
✎ RepaintBoundary:把重绘关进笼子

第 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 对照 · 你的列表经验几乎全部有效
Compose / AndroidFlutter
LazyColumn { items(...) }ListView.builder
LazyVerticalGridGridView.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 那半边)的构建与上架清单。