适用场景:信息流/商品列表/评论列表等"上万条数据"的长列表页面。
目标:用一个可运行 Demo 对比"未优化 Baseline"与"系统化优化 Optimized",并给出可复现的性能验证方法(Profile / DevTools)。
本文基于项目目录github链接
核心实现包含两个页面:
- BaselineListPage:故意写差,用于放大卡顿差异
- OptimizedListPage:落实懒构建、分页加载、局部刷新、RepaintBoundary、图片缓存/按尺寸加载、滚动中暂停图片解码/加载、防抖节流、帧耗时聚合统计
1. 为什么"上万条列表"会卡:Flutter 的瓶颈在哪里?
长列表滑动卡顿,本质不外乎 UI 线程(Build/Layout)或 Raster 线程(Paint/Rasterize/GPU)超预算:
- UI 线程超预算:频繁 build、layout 复杂、一次性创建大量对象/Widget、整页 setState 导致重建扩散
- Raster 线程超预算:图片解码/缩放、阴影/圆角裁剪/透明度、过多重绘(repaint 传播)
关键经验:
- 列表不是"能显示 10000 条",而是"滚动过程中每帧只做必要工作",把每帧的 build/layout/paint 控制在预算内。
2. Demo 工程结构(分层是优化的前提)
在 lib/ 下我按"页面/数据/组件/工具"分层,便于把"性能策略"落到具体代码位置:
pages/baseline_list_page.dartoptimized_list_page.dart
data/item_model.dart(轻量实体)item_repository.dart(分页模拟 + Store)
widgets/list_item_baseline.dartlist_item_optimized.dart
utils/scroll_activity_notifier.dart(滚动中/停止的 notifier + idle 防抖)
这样做的好处:页面只负责组合与监听;分页与状态由 store 管理;item 组件内做局部刷新与重绘隔离。
3. Baseline:故意写差的点(让你看到"什么会卡")
对应文件:lib/pages/baseline_list_page.dart + lib/widgets/list_item_baseline.dart
Baseline 的典型反面教材包括:
3.1 整页 setState + 频繁变化
Baseline 页面用定时器触发 setState,造成不必要的 rebuild 扩散,滚动时更容易引发 jank。
3.2 item 树更"重"
item 内用多层 Container/Opacity/ClipRRect 等,结构更复杂,layout/paint 成本上升。
3.3 图片不做尺寸控制
直接 Image.network 拉较大尺寸图(示例里用较大的 URL),不传 memCacheWidth/Height,会带来:
- 更高的解码成本
- 更高的内存占用与抖动(GC 更频繁)
- 滚动中网络解码/绘制峰值更明显
Baseline 的意义不是"写出最差代码",而是把真实业务里常见的坑集中展示出来,方便你用 DevTools 对照观察。
4. Optimized:优化策略如何在代码里落地?
对应文件:
- 页面:
lib/pages/optimized_list_page.dart - item:
lib/widgets/list_item_optimized.dart - 滚动状态:
lib/utils/scroll_activity_notifier.dart - 分页与状态:
lib/data/item_repository.dart
下面按"视图/图片/数据/滚动/监控"拆解。
4.1 视图层:强制懒构建 + 结构可组合(Sliver)
为什么用 Sliver?
因为 CustomScrollView + Sliver* 能把吸顶头部、列表主体、footer/loading 统一在同一个滚动管线里,避免嵌套滚动与 shrinkWrap 的额外测量成本,同时保持 builder 懒构建。
在 optimized_list_page.dart:
CustomScrollViewSliverAppBar(pinned: true):原生吸顶SliverToBoxAdapter:说明块插入滚动流SliverList(delegate: SliverChildBuilderDelegate(...)):只构建可视区
这确保了"逻辑上支持 10000+"但绝不会一次性 build 10000 个 widget。
4.2 缩小刷新范围:局部状态更新,不整页 setState
在 item_repository.dart 的 ItemStore 里为每个 item 建一个 ValueNotifier<bool>(like 状态):
likeNotifierFor(id)返回对应 notifier- item 内用
ValueListenableBuilder只刷新按钮区域
在 list_item_optimized.dart:
_Actions只监听likedListenable,点爱心只重建这个小组件,不会整页 setState。
这点在长列表里非常关键:避免一次交互触发所有可见 item rebuild。
4.3 重绘隔离:为什么每个 item 要 RepaintBoundary?
在 list_item_optimized.dart 的 item 根节点包 RepaintBoundary,目标是:
- 将 item 变成独立 repaint 边界,减少 repaint 传播
- 当 item 内部局部变化(like、图片占位→真图)时,尽量限制重绘在当前 item 内部
- 降低滚动时 Raster 压力波动
同时在 optimized_list_page.dart 的 SliverChildBuilderDelegate 里把 addRepaintBoundaries: false 关掉,避免系统自动再套一层边界造成 layer "套娃",改为手动在 item 根上统一控制。
4.4 图片优化:缓存 + 固定尺寸加载 + 滚动中暂停真图
这部分直接决定 Raster 线程稳不稳。
在 list_item_optimized.dart 的 _Thumb:
- 缓存 :使用
CachedNetworkImage(内存 + 磁盘缓存) - 固定尺寸 URL :
picsum.photos/seed/{id}/80/80模拟服务端按尺寸返回小图(不假设 WebP) - memCacheWidth/Height :按
devicePixelRatio计算,避免把大图解码进内存 - 滚动中暂停真图 :监听
isScrollingListenable- 滚动中:显示轻量占位(不触发网络图解码/绘制)
- 停止后(idle):切换回
CachedNetworkImage真图
- idle 防抖 :在
scroll_activity_notifier.dart里对"滚动结束→idle"做 debounce,避免刚停下马上触发大量重建/加载
这套组合的效果通常很直观:快速甩动时 UI/GPU 更稳定;停止时图片再逐步加载/命中缓存。
4.5 数据层:分页懒加载 + 数据隔离(Store/Repository)
在 item_repository.dart:
ItemRepository.fetchPage(page, pageSize):模拟接口分页(延迟)ItemStore:持有 items、isLoading、hasMore、nextPage;外部通过AnimatedBuilder(animation: _store)监听变更
在 optimized_list_page.dart:
ScrollController复用- 监听滚动位置
extentAfter,接近底部触发loadNextPage - 触底加载做 debounce:避免滚动回弹/抖动时重复触发
这样做能保证:
- 数据量可上万,但内存里只逐步增长
- UI 刷新仅发生在 store 状态变化时,且仍保持 Sliver builder 的懒构建
4.6 监控与验证:别靠"感觉",要看指标
Optimized 页内置了两类验证手段:
- 肉眼:PerformanceOverlay
main.dart用全局ValueNotifier控制MaterialApp.showPerformanceOverlay- 页面右上角开关随时对比
- 数据:FrameTiming 聚合统计(避免刷屏)
optimized_list_page.dart的_FrameJankAggregator- 通过
SchedulerBinding.instance.addTimingsCallback拿到FrameTiming - 每 3 秒聚合输出:平均 build/raster 耗时 + jank 比例
这能让你在真机 profile 下用一组数字对比不同策略(比如开/关滚动中暂停真图、不同 pageSize 等)。
5. 如何用 Profile + DevTools 复现实验(iOS 注意事项)
5.1 Profile 模式启动命令
bash
cd flutter_demo
flutter pub get
flutter run --profile -d <真机deviceId>
重要提示:
- iOS 模拟器不支持 Flutter Profile(你之前遇到的报错就是这个原因)
- 请用真机(USB 连接 + 信任设备 + 开发者模式)
5.2 DevTools Performance 面板怎么对比
操作建议:
- 打开 DevTools → Performance
- Record
- 在 Baseline 页快速甩动 5~10 秒并停下
- 在 Optimized 页重复同样操作
- 对照观察:
- UI/Raster 帧是否频繁超过预算(16.7ms @60Hz;高刷更严格)
- Raster 是否出现明显尖峰(图片解码、阴影、裁剪常见)
- 停止滚动瞬间:Optimized 会恢复真图加载,但应更可控
5.3 Impeller/Skia 怎么表述与验证
不建议写死"关闭 Skia"。更合理的表述是:
- 在支持的平台/Flutter 版本上尽量启用/验证 Impeller
- 用 Profile + Performance 面板观察 raster 的稳定性差异
可尝试(若当前版本支持):
bash
flutter run --profile --enable-impeller -d <deviceId>
是否启用成功以启动日志/引擎信息为准(不同版本输出不同)。
6. 总结:这套优化为什么有效?
你可以把 Optimized 的策略理解为一句话:
滚动过程中只做"必须做的最少工作":只构建可视区、把状态更新限制在局部、把重绘限制在 item、把图片解码/加载从滚动关键路径移走、把触底与 idle 行为做节流防抖、再用指标验证收益。
7. 进一步可落地优化(8 条以内)
- item 高度固定时,用
SliverFixedExtentList降低 layout 成本 - 停止滚动后图片"分批恢复"(每帧恢复少量),降低瞬时解码峰值
- 对复杂计算(JSON/富文本/格式化)用
compute/Isolate,避免占 UI 线程 - 减少阴影与大 blur 半径,优先用分隔线/纯色块替代
- 避免
Intrinsic*布局与不必要的Opacity/Clip,能 Decoration 就别裁剪 - 对列表交互状态做"会话级聚合埋点"(滚动开始/结束输出一次),更贴近线上
- 控制图片缓存策略与尺寸,避免内存抖动导致 GC 卡顿
- 适配高刷设备预算(90/120Hz),把"掉帧阈值"动态化用于统计
8. 对比截图

