关于 flutter-performance
flutter-performance 是面向 Flutter 开发的性能优化技能,强调在真实低端设备的 profile 模式下测量。它提供减少重建、降低绘制成本和避免 UI 线程阻塞的实践规则与诊断流程。
主要功能
- 01缩小状态变化触发的重建范围
- 02长列表采用惰性构建
- 03将重计算移出 build()
- 04按显示尺寸解码图片
- 05使用 DevTools 分析 UI 与栅格线程
- 06规范资源释放与绘制优化
适用场景
- 01排查低端设备上的掉帧
- 02减少大型列表的构建开销
- 03诊断动画或图像引发的渲染负担
- 04在 profile 模式比较优化前后表现
安装
复制下面的命令,在终端中运行。
$
npx skills add https:/ / github.com/ zakariaf/ flutter-skills --skill flutter-performance安装后在 Claude Code 中输入/flutter-performance确认已加载。
详细介绍
Flutter 性能
在入门级设备(低端 Android/较旧的 iPhone)上保持稳定的 60 fps(每帧 <=16 ms;120 Hz 时 <=8 ms)。性能需要在 profile 模式下测量,而不是靠断言得出。减少重建、降低绘制开销,并确保 UI 线程和光栅线程都不被占用,才能获得良好的性能。
不容妥协的规则
- 所有合法的内容都使用
const。 重建时会跳过const子树——框架会根据对象标识直接短路。将prefer_const_constructors保持为错误级别;本可以使用 const 却没有使用的非 const 字面量属于 lint 错误,而非建议。 - 将重建范围缩小到发生变化的最小 widget。 当状态对象只有一个字段变化时,绝不要对整个状态对象调用
ref.watch(provider)——应使用provider.select((s) => s.field)。HUD 计数器的刷新绝不能导致耗时的同级 widget 重建。缩小重建范围是降低构建线程开销最有效的手段。 - 长列表一律延迟构建。 对内容可变或无上限的列表,使用
ListView.builder/GridView.builder/sliver。ListView(children: items.map(...).toList())会预先构建所有屏幕外的行——除非内容很少且固定,否则不要使用。 - 不要在
build()中执行耗时操作。 不要进行jsonDecode、排序、正则匹配、DateTime运算、文件/网络操作或大量内存分配——build()可能每帧都会运行。在 Notifier/ViewModel 中只计算一次,并将结果缓存到不可变状态中。 - 将繁重的 CPU 工作移出 UI isolate。 使用
compute()或启动的Isolate解析大型数据、处理图像或进行复杂计算。阻塞 UI 线程必然导致卡顿;光栅线程无法弥补。 - 按照显示区域的大小解码图像。 使用
cacheWidth/cacheHeight或ResizeImage,设置正确的资源分辨率,并对首屏图片调用precacheImage。将 4000 px 的源图解码到 100 px 的显示框中会导致内存激增,让低端手机内存溢出。 - 能满足需求时,优先使用开销最低的 widget。 优先用
ColoredBox/DecoratedBox替代Container,用SizedBox实现间距,在高频路径中用FadeTransition/AnimatedOpacity替代Opacitywidget。避免在滚动列表中使用ClipPath/saveLayer——它们会强制光栅线程创建离屏缓冲区。 - 在开销较大且独立重绘的子树周围使用
RepaintBoundary(例如动画、图表、实时指示器),这样它的重绘就不会导致相邻内容重新光栅化。不要到处添加边界——每个边界都是会占用内存的合成器层。 - 通过
child:传入耗时的子级。AnimatedBuilder/ValueListenableBuilder/StreamBuilder只会重建builder;将不变的子树通过child:传入,使其只构建一次,而不是每帧都构建。 - 及时释放资源,避免泄漏。
AnimationController、手势识别器、流订阅、计时器、TextEditingController、缓存等,都应在dispose()(widget)或ref.onDispose(provider)中释放。会话作用域的状态应设置为autoDispose,避免在离开屏幕后继续存活。 - 使用 DevTools,在真实的入门级设备上以 PROFILE 模式测量。 Debug 模式下的耗时没有参考意义(JIT、断言)。除了 UI 线程,还要关注光栅线程——
saveLayer/模糊/裁剪的开销会体现在线程上,而非 Dart 中。绝不要发布一个仅凭断言得出、未经测量的帧耗时数据。
缩小重建范围
监听发生变化的单个字段,而不是整个状态对象:
// Rebuilds only when the count changes, not on every OrderState mutation.
class OrderBadge extends ConsumerWidget {
const OrderBadge({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final count = ref.watch(orderProvider.select((s) => s.items.length));
return Text('$count');
}
}
.select 会在每次发布时运行选择器,但只有选定值的 == 发生变化时才会触发重建,因此其输入必须是具有正确相等性判断的不可变值。关于 watch/read/listen 的区别,以及 family + autoDispose,请参阅 state-management-riverpod。
将耗时的子级移出动画循环
// The card is built once; only the transform recomputes each tick.
AnimatedBuilder(
animation: _controller,
child: const ProductCard(),
builder: (_, child) =>
Transform.scale(scale: _controller.value, child: child),
);
延迟构建列表并调整图像尺寸
// Lazy — only visible rows are built.
ListView.builder(
itemCount: items.length,
itemBuilder: (context, i) => ItemTile(item: items[i]),
);
// Decode to the slot, not the source resolution.
Image.asset('assets/hero.png', cacheWidth: 240);
将繁重工作移出 UI isolate
// parseItems is a top-level or static function (isolate entry-point rules).
final items = await compute(parseItems, jsonString);
自定义绘制
对于 CustomPainter,应确保 paint() 零分配,shouldRepaint 只对不可变场景值进行一次比较,并使用 repaint: Listenable(即 AnimationController),而不是在 ticker 中调用 setState。相关做法详见 custom-canvas-and-gestures;这里需要牢记的一条性能关键规则:绝不要在 paint() 中分配 Paint/Path/Gradient——将它们保留为 painter 字段,并只修改开销低的属性。
性能分析工作流
- 在实体设备上运行
flutter run --profile。 - 打开 DevTools -> Performance;重现卡顿时录制。
- 对每个超出预算的帧,检查耗时发生在 Build、Layout 还是 Raster。
- Build 开销大 -> 使用
.select缩小重建范围,添加const,将工作移出build()。 - Raster 开销大 -> 减少
Opacity/裁剪/saveLayer,添加RepaintBoundary,简化着色器。
- Build 开销大 -> 使用
- 使用“Track widget rebuilds”查找重建过于频繁的 Widget。
- 修复一个瓶颈后重新测量。绝不要在没有前后测量数据的情况下优化。
反模式
- 在 debug 模式下判断性能——JIT 和断言会让计时结果失真。始终使用 profile 模式和真实设备。
- 没有性能分析数据就过早优化——只会增加复杂性,无法解决问题;先测量。
- 在树的高层调用
setState(),因微小变化重建整个屏幕——缩小状态范围。 - 叶节点中使用
ref.watch(provider)监听整个对象,而实际只修改了一个字段——使用.select。 - 长列表或无界内容使用
ListView(children: [...].toList())——会构建所有屏幕外的行。 - 在
build()中进行jsonDecode/排序/DateTime运算/正则处理——这些操作可能每帧都运行;在 ViewModel 中预先计算。 - 在滚动列表中使用
OpacityWidget 实现淡入淡出——会强制执行saveLayer;改用FadeTransition或通过颜色实现淡化。 - 将完整分辨率图像解码后用于小型 Widget——会导致内存激增,并使低端手机 OOM。
- 在 UI isolate 上解析大型负载或执行繁重循环——会冻结帧;使用
compute。 - 到处添加
RepaintBoundary——会造成图层内存膨胀;仅在会独立重绘的子树周围有针对性地使用。 - 使用
AnimatedBuilder/StreamBuilder重建开销大的子项,而不是通过child:传入该子项。 - 每次按键都触发重建却不做 debounce,或使用无界缓存/监听器导致泄漏。
- 在
paint()中分配Paint/Path——会产生逐帧垃圾;将它们保留为字段。
完成标准
- 在所有允许的位置应用
const;prefer_const_constructors检查通过。 - 使用
.select限定重建范围;一个 Widget 中的 tick 绝不会导致开销大的同级 Widget 重建。 - 长列表或可变长度列表采用惰性构建(
.builder/slivers)。 -
build()中没有 I/O、解析或繁重计算;大型任务通过compute/Isolate执行。 - 图像尺寸与显示尺寸匹配(
cacheWidth/ResizeImage);预缓存首屏内容。 - 选用开销最低且合适的 Widget/效果;有针对性地使用
RepaintBoundary;通过child:传入开销大的子项。 - 任何
CustomPainter在paint()中都不分配对象;shouldRepaint只进行一次值比较。 - 已释放控制器、识别器、订阅和计时器;已对作用域内的会话状态调用
autoDispose;无泄漏。 - 通过 DevTools 在 profile 模式下的实体设备上完成测量——UI 和 raster 线程都在预算内;没有超过 16 ms 的帧;性能结论有录制数据支撑。
相关技能
state-management-riverpod— watch/read/listen 的划分、.select、family+autoDispose,以及确保.select正确工作的不可变状态。widget-composition— 使用小型constWidget 类而非_buildX方法、遵循 dispose 规则,以及选择开销最低的 Widget。custom-canvas-and-gestures— 零分配的paint()、只进行一次值比较的shouldRepaint、repaint:Listenable。testing-strategy— 通过注入时钟实现纯核心逻辑,从而能够在 UI isolate 之外测试繁重计算。
参考资料
评论
支持 Markdown