Flutter 基础体系 · 第 17/80 篇。示例基于当前稳定 Flutter 与 Dart 3 语言能力;Android、iOS、桌面和 Web 差异会明确说明。

Flutter 性能优化:帧流水线、重建、栅格、内存和 DevTools

Flutter 性能优化的目标不是让某个函数“更快”,而是让一次用户可见的帧在规定时间内完成,并在长期运行、不同屏幕刷新率、不同渲染后端和不同内存压力下保持可预测。

一帧通常经历以下路径:

flowchart LR
    V[vsync 到达] --> B[Build 构建 Widget/Element]
    B --> L[Layout 布局 RenderObject]
    L --> P[Paint 绘制 DisplayList/Layer]
    P --> C[Compositing 合成]
    C --> R[Raster 栅格化到 GPU/Surface]
    R --> S[屏幕显示]

这里有两个必须区分的概念:

  • UI 线程通常负责 Dart 代码、build、layout、paint 和帧调度;
  • Raster 线程通常负责将绘制指令栅格化并提交到 GPU 或平台 Surface。

这是常见实现路径,而不是应用可以依赖的绝对线程契约。具体线程模型会受平台、渲染后端和引擎实现影响。优化时应以 DevTools 的实际时间线为证据,而不是假设某个操作一定发生在某个线程。


一、帧时间预算:为什么“能运行”不等于“流畅”

屏幕刷新率为 ff Hz 时,理想帧间隔为:

Tframe=1fT_{\text{frame}} = \frac{1}{f}

例如:

刷新率 理想帧间隔
60 Hz 16.67 ms
90 Hz 11.11 ms
120 Hz 8.33 ms

这只是时间预算,不是 Flutter 保证应用每帧都能使用的时间。操作系统、输入处理、平台消息、GPU 竞争和设备温度都会占用资源。

一次帧渲染可以近似表示为:

Tframe=max(TUI,TRaster)T_{\text{frame}} = \max(T_{\text{UI}}, T_{\text{Raster}})

因为 UI 线程和 Raster 线程在很多阶段可以并行。如果某帧中:

  • UI 线程耗时 20 ms,Raster 线程耗时 4 ms;
  • UI 线程耗时 4 ms,Raster 线程耗时 20 ms;

在 60 Hz 下都可能错过帧截止时间。

但这不是说 UI 和 Raster 永远完全独立。Raster 往往需要等待 UI 线程提交新的绘制指令;UI 线程也可能因为上一帧的管线阻塞而受到影响。因此更准确的判断是:

  1. UI 线程是否按时生成了这一帧的绘制数据;
  2. Raster 线程是否按时完成了这一帧的栅格化;
  3. 两者之间是否存在等待、排队或背压。

不要把所有耗时相加后与 16.67 ms 比较,也不要只看 Dart 函数耗时。 应在时间线上观察帧的实际阶段和阻塞关系。

1. “丢帧”究竟是什么

如果下一次 vsync 到来时,上一帧尚未完成,系统只能:

  • 延迟显示上一帧;
  • 重复显示旧帧;
  • 或等待更晚的帧。

用户看到的是动画跳动、滚动不连续、点击反馈延迟。高刷新率设备尤其敏感:120 Hz 下 8.33 ms 的预算大约只有 60 Hz 的一半。

因此性能验证至少应覆盖:

  • 60 Hz 中端 Android 设备;
  • 高刷新率设备;
  • iOS 真机;
  • 桌面窗口缩放和大屏;
  • Web 的目标浏览器。

不要只在开发机 Debug 模式下判断流畅度。Debug 模式包含额外断言和诊断开销,Release 模式的编译和渲染行为更接近生产。


二、Flutter 帧流水线:Build、Layout、Paint 和 Raster 分别做什么

1. Build:把状态描述成 Widget 树

Widget 是不可变配置。build 的结果是新的 Widget 描述,Flutter 再通过 Element 树将描述与已有对象匹配。

例如:

class CounterText extends StatelessWidget {
  const CounterText({super.key, required this.value});

  final int value;

  @override
  Widget build(BuildContext context) {
    return Text('count: $value');
  }
}

调用 build 会创建一个新的 Text Widget 对象,但这不等价于“屏幕一定重绘”。框架会根据运行时类型、Key 和父子关系,尝试复用已有 Element 和 RenderObject。

2. Element:决定哪些状态和对象可以复用

Widget、Element、RenderObject 的关系可以简化为:

  • Widget:不可变配置;
  • Element:Widget 在树中的运行时位置,持有生命周期和状态;
  • RenderObject:负责布局、绘制和命中测试。

当父节点重新 build 时,Flutter 会比较新旧 Widget。对同一位置,如果类型和 Key 匹配,通常可以更新原有 Element,而不必销毁并重建整个子树。

例如:

Column(
  children: [
    const Text('固定标题'),
    Text('value: $value'),
  ],
)

value 改变时,Column 及其返回路径上的节点可能重新执行 build,但第一个 const Text 仍可复用。const 的价值主要是减少不必要的对象创建和帮助框架识别稳定配置,而不是一个“禁止重建”的开关。

3. Layout:计算 RenderObject 的尺寸和位置

布局通常遵循“约束向下传递,尺寸向上返回,位置由父节点决定”的模型:

  1. 父 RenderObject 向子节点传递 BoxConstraints
  2. 子节点在约束范围内选择尺寸;
  3. 父节点根据子节点尺寸决定位置;
  4. 必要时继续向上影响祖先。

典型约束可以表示为:

wminwwmax,hminhhmaxw_{\min} \leq w \leq w_{\max}, \qquad h_{\min} \leq h \leq h_{\max}

当某个 RenderObject 的尺寸、父约束或布局相关属性变化时,可能触发布局。布局结果变化后,通常还需要重新绘制;但仅仅重新 build 不必然导致 layout。

一个常见反例是:

Widget build(BuildContext context) {
  return Container(
    width: MediaQuery.sizeOf(context).width,
    child: expensiveChild,
  );
}

如果只需要屏幕宽度,却让一个范围很大的组件直接依赖整个 MediaQuery,屏幕尺寸、文字缩放、无障碍设置等变化都可能使它重新 build。可以使用更窄的依赖:

final width = MediaQuery.sizeOf(context).width;

更重要的是,不要为了“减少 build”而违反布局模型,例如在需要约束的地方随意使用无限尺寸。错误布局会表现为异常、反复布局或大面积无效工作,而不是性能优化。

4. Paint:把布局结果转换成绘制指令

Paint 阶段通常将 RenderObject 的绘制操作记录成 DisplayList 或等价的绘制数据。常见操作包括:

  • 绘制文字、路径、图片;
  • 应用裁剪;
  • 应用变换;
  • 创建透明度、模糊、阴影等效果;
  • 创建或更新合成层。

Paint 较慢不一定是 Dart 代码慢,也可能是绘制指令复杂、层结构不利于合成,或者后续 Raster 代价很高。

5. Raster:把绘制指令变成像素

Raster 阶段需要将绘制指令栅格化。它关注的是:

  • 需要处理多少像素;
  • 绘制是否涉及复杂路径;
  • 是否存在离屏缓冲;
  • 是否需要纹理上传;
  • Shader 是否首次编译;
  • 层是否能有效复用。

一个只覆盖 100×100 像素的绘制和一个覆盖整个 4K 窗口的绘制,即使 Dart 代码相同,Raster 成本也可能完全不同。


三、重建:什么会重新 build,什么不会

1. setState 的范围

setState 标记的是调用它所在的 State 对应 Element:

class CounterPage extends StatefulWidget {
  const CounterPage({super.key});

  @override
  State<CounterPage> createState() => _CounterPageState();
}

class _CounterPageState extends State<CounterPage> {
  int count = 0;

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Text('count: $count'),
        ExpensivePanel(),
        ElevatedButton(
          onPressed: () => setState(() => count++),
          child: const Text('increment'),
        ),
      ],
    );
  }
}

这里按钮点击会使 _CounterPageState.build 再次执行。ExpensivePanel 是否也执行 build,取决于它如何出现在新树中以及框架是否能够复用相应子树;不能简单概括为“整个页面都会重建”或“子组件一定不会重建”。

一个可靠的结构优化是把不变子树作为参数传入:

class CounterPage extends StatefulWidget {
  const CounterPage({super.key, required this.panel});

  final Widget panel;

  @override
  State<CounterPage> createState() => _CounterPageState();
}

class _CounterPageState extends State<CounterPage> {
  int count = 0;

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Text('count: $count'),
        widget.panel,
        ElevatedButton(
          onPressed: () => setState(() => count++),
          child: const Text('increment'),
        ),
      ],
    );
  }
}

调用方可以传入一个稳定的 Widget 实例:

CounterPage(
  panel: const ExpensivePanel(),
)

这样做的依据是:父组件每次 build 时复用同一个子 Widget 配置,框架无需因为父状态变化而重新描述该子树。它不是普遍适用的“缓存 Widget”规则;如果子树依赖变化的参数,就不能为了避免 build 而强行复用旧实例。

2. const 的正确边界

const 可以在编译期构造不可变对象:

const Padding(
  padding: EdgeInsets.all(16),
  child: Text('static'),
)

它能减少运行时对象创建,特别适合静态图标、间距、标签和固定布局。但以下情况不能靠 const 解决:

  • 子树依赖动态数据;
  • 大量复杂绘制导致 Raster 慢;
  • 图片解码和上传占用时间;
  • build 中执行同步 I/O 或复杂计算;
  • 频繁布局导致 layout 慢。

“所有 Widget 都加 const 就不会重建”是错误理解。重建是 Element 更新过程,const 只是使配置对象更稳定、更容易复用。

3. InheritedWidget、Provider 和选择性依赖

依赖 InheritedWidget 的组件,在其依赖值变化时可能收到通知。例如 MediaQuery.of(context)、主题和本地化都属于常见依赖。

状态管理库通常在此基础上提供选择性订阅。原则是:组件只订阅它实际需要的字段。

反例:

final model = context.watch<CartModel>();
return Text('${model.total}');

如果 CartModel 的任何字段变化都会通知该组件,那么即使 total 没变,也可能重新 build。

更窄的选择器应表达为“只监听 total”,具体 API 取决于所用状态管理库。这里不能把某个库的选择器写成 Flutter 内置 API。性能判断也应通过 DevTools 的 Widget rebuild 信息验证,而不是根据代码形式猜测。

4. Key:身份匹配,不是性能魔法

Key 用于在同级子节点中识别身份,尤其是列表插入、删除、排序时:

ListView(
  children: items.map((item) {
    return ListTile(
      key: ValueKey(item.id),
      title: Text(item.name),
    );
  }).toList(),
)

没有 Key 时,如果列表前端插入一个元素,框架可能按位置匹配旧 Element,导致有状态子项错配或产生更多更新。ValueKey 适合稳定业务 ID;UniqueKey 每次都不同,会主动破坏复用,不应为了“保证刷新”而滥用。

Key 不能减少已经发生的业务数据变化,也不能替代列表虚拟化。它解决的是身份和状态迁移问题。


四、列表和滚动:从子树规模控制成本

ListView(children: [...]) 会一次性创建所有子项。对于固定且很短的列表,这很简单;对于长列表,应优先使用 builder:

class MessageList extends StatelessWidget {
  const MessageList({super.key, required this.messages});

  final List<String> messages;

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      itemCount: messages.length,
      itemBuilder: (context, index) {
        return ListTile(
          key: ValueKey(messages[index]),
          title: Text(messages[index]),
        );
      },
    );
  }
}

ListView.builder 的核心不是“build 永远只执行一次”,而是只为当前需要的可视区域和缓存区域创建子项。滚动时,离开区域的子项可能被销毁,重新进入时可能再次创建,因此子项必须正确处理生命周期和状态持久化。

列表优化需要分别观察:

  1. 创建成本:每个子项 build 是否包含 JSON 解析、日期格式化、大量对象创建;
  2. 布局成本:是否嵌套了无法确定边界的滚动容器;
  3. 绘制成本:是否每项都使用阴影、模糊、复杂 CustomPainter;
  4. 图片成本:是否加载了远大于显示尺寸的图片;
  5. 状态成本:是否因 Key 错误导致状态错位或子树反复初始化。

不要把 shrinkWrap: true 当作通用修复方案。它通常要求滚动视图测量更多甚至全部子项,长列表中可能显著增加布局成本。嵌套滚动应有明确的约束和滚动协调方案,而不是通过 shrinkWrap 消除异常就结束诊断。


五、同步计算、异步计算和 Isolate

Dart 的异步 Future 不会自动把 CPU 密集型工作移出 UI isolate:

final result = await heavyCalculation();

如果 heavyCalculation() 在返回 Future 前已经同步执行了大量计算,UI 线程仍会被阻塞。

可以使用 isolate:

import 'dart:convert';
import 'package:flutter/foundation.dart';

List<String> parseNames(String source) {
  final decoded = jsonDecode(source) as List<dynamic>;
  return decoded
      .cast<Map<String, dynamic>>()
      .map((item) => item['name'] as String)
      .toList();
}

Future<List<String>> loadNames(String source) {
  return compute(parseNames, source);
}

前提是:

  • 顶层函数或静态函数可被 isolate 调用;
  • 参数和返回值必须可跨 isolate 传输;
  • 不能直接捕获 UI 状态、BuildContext 或不可发送对象;
  • 序列化和 isolate 启动本身也有成本。

在移动端,compute 常用于较重的 JSON 解析、压缩或纯计算。对很小的数据使用它可能比直接计算更慢。Web 的 isolate 能力和并行模型受浏览器、编译目标和部署方式限制,不能不加验证地假设拥有与 Android/iOS 相同的并行效果。

网络等待、文件等待和数据库等待属于 I/O,不应通过 isolate 解决;应使用异步 API,并确保回调恢复后只做足够轻量的 UI 更新。


六、栅格性能:为什么 GPU 也会成为瓶颈

“栅格”是将绘制指令转换为像素的过程。它不是“把 Widget 画出来”这么简单,而是同时受面积、绘制操作、层结构、纹理和着色器影响。

1. 大面积绘制比小面积绘制更贵

以下代码逻辑简单,但会绘制一个覆盖整个区域的渐变:

DecoratedBox(
  decoration: const BoxDecoration(
    gradient: LinearGradient(
      colors: [Color(0xff202020), Color(0xff606060)],
    ),
  ),
  child: child,
)

如果该区域是一个全屏、持续动画的背景,Raster 需要在每帧处理大量像素。优化方向不是盲目减少 Widget 数量,而是:

  • 缩小实际绘制区域;
  • 避免不必要的全屏重绘;
  • 将不会变化的内容缓存或移出动画重绘区域;
  • 用 DevTools 的 Raster 时间和重绘图层确认是否确实是瓶颈。

2. saveLayer 和离屏缓冲

某些效果需要先把内容绘制到离屏缓冲,再进行合成,例如:

  • 某些混合模式;
  • 背景模糊;
  • 复杂裁剪;
  • 需要对一组内容统一应用透明度的场景。

离屏缓冲通常会增加内存带宽和纹理处理成本。OpacityClipPath、阴影和滤镜并不必然昂贵,但它们可能导致额外层或离屏绘制,实际代价取决于区域大小和设备。

例如:

Opacity(
  opacity: 0.5,
  child: largeWidget,
)

如果只是单个颜色或图片透明度,可以考虑直接把透明度放入颜色或绘制参数;如果是大块动态子树,需用时间线验证是否产生了高昂的合成成本。不能简单声称“Opacity 一定慢”。

3. RepaintBoundary:隔离变化,不是越多越好

RepaintBoundary 将子树划分为独立的重绘边界:

RepaintBoundary(
  child: ExpensiveStaticChart(data: data),
)

当外部兄弟区域变化时,边界内内容可能复用已有绘制结果;当边界内部变化时,仍然需要重绘该边界。

它适合:

  • 内容绘制昂贵;
  • 自身变化频率低;
  • 经常被周围动画或滚动影响;
  • 边界缓存收益大于额外层和纹理成本。

它不适合被当作“禁止重绘”开关。边界本身会带来额外合成和内存成本;如果每个列表项都加边界,可能导致层数量过多,反而使 Raster 和内存变差。

4. CustomPainter 的重绘判定

class ProgressPainter extends CustomPainter {
  const ProgressPainter(this.progress);

  final double progress;

  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint()..color = const Color(0xff42a5f5);
    canvas.drawArc(
      Offset.zero & size,
      -1.57,
      progress * 6.28,
      false,
      paint,
    );
  }

  @override
  bool shouldRepaint(covariant ProgressPainter oldDelegate) {
    return oldDelegate.progress != progress;
  }
}

shouldRepaint 返回 true 时,Painter 对应区域需要重新绘制。判断应基于真正影响绘制的字段。如果 Painter 还依赖颜色、线宽或路径,却只比较 progress,更新后会出现视觉错误。

如果动画每帧改变 progress,返回 false 会导致画面不更新;返回 true 则是正确行为。性能优化应减少每帧创建的大对象和绘制面积,而不是错误地阻止必要重绘。

5. 动画中的分工

Implicit Animation 适合少量状态变化,由框架管理控制器;显式 AnimationController 适合精确控制、反向、暂停和多个动画联动;Hero 会在路由过渡期间构建飞行中的视觉对象;CustomPainter 适合直接控制绘制。

它们的共同性能边界是:动画 tick 触发的更新如果使大范围子树 build、layout、paint 或 raster,就会按帧重复支付成本。

例如:

class FadeBox extends StatefulWidget {
  const FadeBox({super.key});

  @override
  State<FadeBox> createState() => _FadeBoxState();
}

class _FadeBoxState extends State<FadeBox>
    with SingleTickerProviderStateMixin {
  late final AnimationController controller;
  late final Animation<double> opacity;

  @override
  void initState() {
    super.initState();
    controller = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 300),
    );
    opacity = CurvedAnimation(
      parent: controller,
      curve: Curves.easeOut,
    );
    controller.forward();
  }

  @override
  void dispose() {
    controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return FadeTransition(
      opacity: opacity,
      child: const RepaintBoundary(
        child: LargeStaticPanel(),
      ),
    );
  }
}

这里 AnimationController 的生命周期与 State 绑定,必须在 dispose 中释放。FadeTransition 比在每个 tick 中手动 setState 重建整个页面更容易把变化限制在动画相关路径中,但最终是否更快仍取决于子树和合成结果。


七、图片、纹理和内存:内存问题不只发生在 Dart 堆

Flutter 应用至少需要关注三类内存:

  1. Dart Heap:Widget、List、JSON、闭包、业务对象;
  2. Native/External Memory:引擎对象、字体、插件资源、解码后的图片等;
  3. GPU Memory:纹理、离屏缓冲和合成相关资源。

1. 压缩文件大小不等于解码后占用小

一张 JPEG 文件可能只有 500 KB,但解码为 RGBA 后,粗略内存为:

MW×H×4M \approx W \times H \times 4

其中 WWHH 是像素宽高,4 表示每像素约 4 字节。

例如 4000×3000 的图片:

4000×3000×4=48,000,000 bytes45.8 MiB4000 \times 3000 \times 4 = 48{,}000{,}000\text{ bytes} \approx 45.8\text{ MiB}

这还没有计算缓存、纹理副本和其他中间资源。如果它只显示为 400×300,直接解码原图就是明显浪费。

可以指定目标解码尺寸:

Image.network(
  imageUrl,
  cacheWidth: 800,
  cacheHeight: 600,
  fit: BoxFit.cover,
)

cacheWidthcacheHeight 是解码缓存尺寸提示,具体是否精确采用以及最终内存布局由平台和图片编解码器决定。它们不是 CSS 缩放,也不是把服务器下载文件变小;网络传输仍应通过服务端缩略图、合适格式和 CDN 尺寸策略优化。

2. 图片缓存有收益也有代价

图片缓存减少重复解码和网络等待,但缓存越大,内存占用越高。生产环境应根据页面类型、图片尺寸和设备内存验证缓存策略,不要仅凭“缓存命中率高”判断优化成功。

大图列表中的典型失败路径是:

  1. 列表快速滚动;
  2. 每项加载高分辨率原图;
  3. 多张图片同时解码并上传 GPU;
  4. 内存峰值升高;
  5. 系统回收或杀死进程;
  6. 回到页面时又重复加载,形成抖动。

解决路径需要同时控制服务端尺寸、客户端解码尺寸、预加载范围和页面生命周期。

3. 生命周期泄漏

需要释放的对象包括但不限于:

@override
void dispose() {
  _controller.dispose();
  _focusNode.dispose();
  _textController.dispose();
  _subscription.cancel();
  super.dispose();
}

常见泄漏并非 Dart 永久保留普通局部变量,而是长生命周期对象持有 State:

  • Timer 持有回调;
  • Stream subscription 未取消;
  • AnimationController 未 dispose;
  • 插件回调注册后未注销;
  • 闭包捕获了大型对象或页面上下文;
  • isolate、原生资源、文件句柄未关闭。

异步回调还可能在 State 已销毁后返回:

Future<void> load() async {
  final result = await repository.fetch();
  if (!mounted) return;
  setState(() {
    data = result;
  });
}

mounted 检查主要防止已销毁 State 上调用 setState;它不能解决任务本身继续占用资源的问题。更完整的实现应在 dispose 中取消可取消的请求或订阅。


八、DevTools:用证据定位是 Build、Raster 还是内存

性能优化必须先建立可重复场景,再收集证据。建议使用 profile 或 release 构建验证,而不是只使用 Debug。

启动常见方式:

flutter run --profile

指定设备:

flutter devices
flutter run --profile -d <device-id>

flutter devices 输出可用设备和标识;flutter run --profile 会以 profile 模式运行,保留性能分析能力,同时比 Debug 更接近生产。不同平台对 profile 支持和行为存在差异,Web 还受浏览器开发者工具和渲染后端影响。

1. Performance 页面和时间线

DevTools 的 Performance 页面用于查看帧时间线、Dart 事件、GPU/Raster 相关活动和时间段分析。

诊断步骤应是:

  1. 启动 profile 应用;
  2. 打开目标页面;
  3. 执行固定动作,例如连续滚动、打开弹窗或播放 Hero 动画;
  4. 记录慢帧发生的时间;
  5. 在时间线上展开该帧;
  6. 判断 UI 侧是 build、layout、paint 还是同步 Dart 计算;
  7. 判断 Raster 侧是否有大面积绘制、离屏层、纹理上传或 Shader 问题;
  8. 修改一项因素后重复同一动作。

如果 UI 侧明显超时而 Raster 正常,应优先查:

  • build 中的同步计算;
  • 大量 Widget 创建;
  • 状态通知范围过大;
  • JSON 解析、排序、格式化;
  • 频繁 layout。

如果 Raster 侧明显超时而 UI 正常,应优先查:

  • 大面积重绘;
  • 复杂路径;
  • 图片纹理;
  • blur、mask、saveLayer;
  • 层结构和阴影;
  • Shader 首次编译。

2. Performance Overlay

可以在代码中临时打开性能叠加层:

import 'package:flutter/material.dart';

void main() {
  runApp(
    const MaterialApp(
      showPerformanceOverlay: true,
      home: HomePage(),
    ),
  );
}

叠加层展示 UI 线程和 Raster 线程的帧性能曲线。它适合在真机上快速观察滚动和动画,但不能代替时间线:曲线能说明哪一侧超时,却不一定告诉你具体是哪一个 Widget 或绘制操作导致问题。

只应在诊断时启用,不能作为生产 UI 的一部分。

3. Widget Inspector 和重建信息

DevTools Inspector 可查看 Widget 树、RenderObject、布局边界和重建相关信息。诊断重建时,可以:

  • 选中怀疑的 Widget;
  • 观察其父子关系;
  • 打开重建统计或相关调试信息;
  • 触发一次状态变化;
  • 比较哪些区域被重新 build 或 repaint。

应避免只看 build 次数。一个 Widget build 次数较多,但每次极轻,可能不是问题;一个 Widget 只 build 一次,却触发巨大 Raster 或图片解码,也可能成为瓶颈。

4. Memory 页面和分配追踪

内存问题要区分:

  • 页面进入后是否持续增长;
  • 离开页面后是否回落;
  • 大量对象属于哪种类型;
  • 是否存在监听器、闭包或缓存持有路径;
  • 图片和外部内存是否增长。

可执行的验证流程是:

  1. 记录进入页面前快照;
  2. 重复进入、退出页面若干次;
  3. 强制执行可用的 GC 或等待回收;
  4. 再记录快照;
  5. 比较 State、Controller、List、图片对象数量;
  6. 查看对象引用路径;
  7. 修复 dispose、订阅取消或缓存边界;
  8. 重复测试确认增长趋势消失。

一次快照变大不等于泄漏。GC 可能尚未运行,缓存也可能按设计保留对象。泄漏的证据通常是“重复相同生命周期后,无法回到相近基线,并且引用路径仍然存在”。

5. CPU Profiler

CPU Profiler 用于发现 Dart 代码中消耗 CPU 的函数,例如:

  • 大型列表排序;
  • 图片元数据处理;
  • 日期和正则表达式密集计算;
  • build 中的复杂转换;
  • 日志序列化;
  • 同步 JSON 解码。

采样分析会有统计误差,短函数可能不容易被捕获。应通过重复场景和较长采样窗口提高信号,不能因某个函数出现在顶部就立即断定它是根因,还要检查调用频率和是否处于每帧路径。


九、常见误判与失败表现

1. “减少 Widget 数量就一定更快”

Widget 是轻量配置对象,真正昂贵的可能是:

  • layout 规模;
  • paint 指令;
  • Raster 像素量;
  • 图片解码;
  • 文本排版;
  • 合成层。

一个结构清晰但 Widget 较多的页面,可能比一个 Widget 数量少、使用大面积模糊和复杂路径的页面更快。

2. “加 RepaintBoundary 就能解决卡顿”

如果瓶颈在 UI isolate 的 JSON 解析,RepaintBoundary 无效;如果边界包住了每帧变化的巨大区域,也无效;如果边界数量过多,还会增加层管理和内存成本。

3. “Future 会自动开线程”

Future 只描述异步结果,不保证计算移出当前 isolate。CPU 密集任务需要 isolate 或其他平台并行机制;网络请求则应使用真正异步的 I/O API。

4. “看起来是动画卡,其实是首次 Shader 编译”

某些复杂视觉效果第一次出现时可能触发 Shader 编译或资源准备,表现为首帧或首次交互卡顿。应分别测试冷启动、首次进入页面和重复进入页面。Shader 预热能力、后端行为和平台支持具有版本与平台差异,不能假设某个预热策略在所有设备都等价有效。

5. “模拟器流畅,所以真机没问题”

模拟器的 CPU、GPU、分辨率和驱动路径与真实设备不同。Android 低端设备、iOS 真机、高刷新率设备和浏览器环境都应单独验证。


十、平台差异

Android

Android 设备型号、GPU、刷新率和系统调度差异很大。低端设备常见问题是 UI 线程计算、图片解码和 Raster 同时竞争。应重点测试真实 APK 的 profile/release 行为,以及冷启动、后台恢复和内存压力。

iOS

iOS 的系统调度、纹理和合成路径与 Android 不同。不能用 Android 上的帧时间直接推断 iOS。刘海、安全区域、动态文字大小和后台恢复也可能改变布局与缓存行为。

桌面

桌面窗口通常更大,绘制面积增加;窗口缩放会频繁触发布局和重绘。高分辨率屏幕下,单帧像素量显著增加。桌面还可能有鼠标悬停、窗口动画和多窗口等移动端少见的事件路径。

Web

Web 的 Flutter 应用运行在浏览器调度和渲染后端之上。CanvasKit、WebAssembly 相关后端以及浏览器版本会影响文本、图片、合成和内存表现;浏览器的标签页降频、后台暂停、网络缓存和开发者工具也会影响测量。

Web 还需要区分:

  • Flutter/Dart 代码耗时;
  • 浏览器主线程任务;
  • Canvas/WebGL/WebGPU 或对应后端的绘制;
  • 浏览器合成和页面布局。

不能把移动端的 UI/Raster 线程模型原样套到 Web。Web 性能测试必须固定浏览器、窗口尺寸、缩放比例和渲染后端,并在生产构建中验证。


十一、把性能纳入错误处理和可观测性

性能异常经常不是单纯的“慢”,而是数据异常、资源失败或生命周期错误造成的连锁结果。例如:

  1. 图片 URL 错误导致重复重试;
  2. 重试回调不断触发状态更新;
  3. 页面离开后订阅仍然存在;
  4. 内存和 UI 工作持续增长;
  5. 最终出现丢帧或进程被杀。

因此性能日志应与错误日志关联,但必须控制隐私和体积。可以记录:

  • 页面或业务场景标识;
  • 操作类型;
  • 帧耗时区间;
  • 图片尺寸和解码尺寸;
  • 资源加载失败类型;
  • 是否处于冷启动或恢复流程;
  • 设备、平台和应用版本。

不要记录完整用户文本、Token、URL 中的敏感参数或原始业务数据。性能埋点本身也不能在每帧执行昂贵序列化。

对于异常路径,应保证:

try {
  final data = await repository.fetch();
  if (!mounted) return;
  setState(() => value = data);
} catch (error, stackTrace) {
  logger.error(
    'load failed',
    error: error,
    stackTrace: stackTrace,
  );
  if (!mounted) return;
  setState(() => failed = true);
}

这里的关键不是把错误吞掉,而是:

  • 记录错误和堆栈;
  • 避免失败后无限重试;
  • 页面销毁后不再更新状态;
  • 向用户提供有限、可恢复的失败状态;
  • 不让错误处理本身制造更多帧工作。

Zone、全局错误捕获、Crash 上报和性能数据应保持关联,但全局捕获不能代替局部资源生命周期管理,也不能捕获所有原生层或进程级崩溃。


十二、一个可执行的性能验证闭环

对一个具体页面,可以按以下顺序建立证据链:

  1. 定义场景:例如冷启动后打开商品列表,连续滚动 10 秒,再打开详情页;
  2. 固定环境:设备型号、刷新率、系统版本、网络条件、构建模式;
  3. 测 UI:确认是否存在 build、layout、同步计算长任务;
  4. 测 Raster:确认是否存在大面积重绘、离屏层、复杂路径或纹理上传;
  5. 测内存:重复进入退出页面,观察是否回到相近基线;
  6. 修改单个变量:例如将原图替换为目标尺寸图片;
  7. 重复同一场景:避免把不同测试条件下的数据直接比较;
  8. 检查失败路径:断网、快速返回、后台恢复和低内存;
  9. 在目标平台复测:至少覆盖实际交付的主要平台;
  10. 保存基线:记录关键页面在代表设备上的帧、内存和启动指标。

优化是否成立,应满足因果验证:

改动目标阶段耗时下降或峰值降低用户场景中的丢帧/崩溃减少\text{改动} \rightarrow \text{目标阶段耗时下降或峰值降低} \rightarrow \text{用户场景中的丢帧/崩溃减少}

如果只是 Widget 数量减少,但时间线、帧率和内存没有改善,就不能称为有效优化。相反,如果代码结构变化很小,却显著减少了 Raster 面积或图片解码内存,那就是更有价值的改动。

Flutter 性能的核心始终是同一件事:让状态变化只影响必要的 Widget、必要的布局和绘制区域,并让每帧所需的像素、计算和资源都处于目标设备能够按时完成的范围内。DevTools 的作用不是提供一个“性能分数”,而是把这条因果链具体化:哪一帧、哪个线程、哪个阶段、哪类对象和哪种资源造成了实际成本。


系列导航与关联阅读

官方资料

本文依据 Flutter 与 Dart 官方文档重新梳理;正文与示例由 WR BLOG 编写。