很多 Flutter 性能文章会直接给结论:少 setState、加 const、用 RepaintBoundary。结论没有错,但如果不知道它们分别作用在帧管线的哪一段,就很容易把优化做成玄学。
先看一帧从触发到上屏的大致路径:

1. setState 不是立刻重建,而是标记脏节点
setState 做的核心事情不是马上执行 build,而是把当前 Element 标记为 dirty,等下一帧统一进入 buildScope。所以性能问题通常不在“调用 setState 这个动作”,而在 dirty 子树太大。
class FeedPageState extends State<FeedPage> {
int selectedIndex = 0;
void select(int index) {
setState(() => selectedIndex = index);
}
@override
Widget build(BuildContext context) {
return Column(
children: [
Header(selectedIndex: selectedIndex),
Expanded(child: HeavyFeedList(selectedIndex: selectedIndex)),
],
);
}
}
上面这段代码的问题不是 setState,而是状态放得太高。每次切换 tab,都让 HeavyFeedList 进入重建路径。更稳的做法是把状态下沉到真正消费它的区域,或者用 Selector、ValueListenableBuilder、BlocSelector 缩小订阅范围。
2. Build、Layout、Paint 是三种不同成本
Build 负责产生新的 Widget 配置;Layout 计算尺寸和位置;Paint 生成绘制指令。很多卡顿不是 build 慢,而是 layout 传播太深,或者 paint 区域太大。
判断问题位置时,我通常先看三个信号:
- 打开 Performance Overlay,看 UI 线程和 Raster 线程哪边高。
- 用 DevTools 的 Rebuild Stats 看重建是否异常扩散。
- 打开
debugRepaintRainbowEnabled观察是否有大面积重复重绘。
如果 UI 线程高,多半是 build/layout 工作重;如果 Raster 线程高,多半是图片、阴影、裁剪、透明度、复杂路径或过大的重绘区域。
3. RepaintBoundary 不是越多越好
RepaintBoundary 的价值是把一个复杂但相对稳定的绘制区域切成独立 Layer。比如图表、地图、头像墙、富文本预览等区域,父组件滚动或状态刷新时,不应该反复把它们一起重画。
RepaintBoundary(
child: CustomPaint(
painter: TrendChartPainter(points),
size: const Size(double.infinity, 180),
),
)
但它也会增加 Layer 管理成本。边界应该加在“重绘成本高、变化频率低”的地方,而不是随手给每个 Widget 包一层。
4. 一个更健康的页面结构

这个结构里有几个关键点:
- 静态头部尽量
const,让 Widget 实例稳定。 - 长列表使用
SliverList/ListView.builder懒构建,不一次性生成所有子节点。 - Cell 内部只订阅自己需要的字段,避免整页状态变化触发所有 Cell build。
- 复杂绘制区域用
RepaintBoundary隔离,但只在测量后添加。 - 图片提前控制尺寸、缓存策略和解码尺寸,别让 Raster 线程背锅。
5. 优化要落到可测量指标
我更推荐用以下顺序定位问题:
void main() {
debugProfileBuildsEnabled = true;
runApp(const App());
}
先确认重建范围,再看 layout 和 paint。不要一上来就改架构,也不要看到卡顿就盲目加缓存。Flutter 的性能优化,本质上是在每一帧 16.67ms 的预算里,减少不必要的 dirty Element、约束传播、绘制指令和栅格化工作。
真正稳定的优化思路不是“用了某个高级组件”,而是能说清楚:这次修改减少了哪一段管线的成本,以及为什么它不会把成本转移到另一段。