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

Flutter 卡顿诊断:UI/Raster 线程、Shader、图片和 Timeline

Flutter 中的“卡顿”通常不是一个单一问题。一次滑动掉帧,可能由 Dart 代码阻塞 UI isolate 引起,也可能由光栅化、图片上传、GPU 管线创建或浏览器合成阶段变慢引起。要准确诊断,首先必须区分几个容易混用的概念:

  • UI 线程:执行 Dart 代码,通常包括构建、布局、绘制记录以及应用逻辑。
  • Raster 线程:把 Flutter 产生的绘制指令转换为实际的图形内容,并提交给 GPU。
  • Shader:GPU 执行的图形程序,负责渐变、阴影、图片采样、混合等像素计算。
  • 图片:不仅包括网络加载,还包括解码、尺寸选择、纹理上传、缓存和绘制。
  • Timeline:记录这些阶段及其耗时的时间线数据,DevTools Performance 页面正是基于它进行分析。

这些术语描述的是同一帧在不同阶段的处理过程,而不是五类互不相关的优化点。


先建立正确的帧模型

Flutter 使用垂直同步(vsync)驱动帧。显示器或系统合成器在一个刷新周期内提供一个可提交画面的时间窗口。常见刷新率下的时间预算为:

Tbudget=1000R msT_{\text{budget}}=\frac{1000}{R}\text{ ms}

其中:

  • RR 是刷新率,单位为 Hz;
  • TbudgetT_{\text{budget}} 是一帧的时间预算。

因此:

  • 60 Hz:约 16.67 ms16.67\text{ ms}
  • 90 Hz:约 11.11 ms11.11\text{ ms}
  • 120 Hz:约 8.33 ms8.33\text{ ms}

这不是 Flutter 的硬性规范,而是显示刷新周期带来的时间约束。实际系统还可能保留一部分时间给平台合成、输入处理和其他任务,所以应用不应把预算全部当作可用计算时间。

一帧至少包含以下两个关键阶段:

sequenceDiagram
    participant V as Vsync
    participant U as UI isolate
    participant R as Raster thread
    participant G as GPU/Compositor
    V->>U: 请求生成下一帧
    U->>U: build / layout / paint
    U->>R: 提交绘制指令
    R->>R: 光栅化、图片处理、图形管线准备
    R->>G: 提交 GPU 工作
    G-->>V: 在截止时间前显示

这里的 paint 不是把每个像素直接画到屏幕上。Flutter 的绘制代码通常先生成一组绘制指令,或者称为绘制记录;Raster 线程随后消费这些指令,进行裁剪、变换、纹理处理和 GPU 提交。

UI 阶段

UI 阶段主要运行在 Flutter 的 UI isolate 上,常见工作包括:

  1. 执行事件回调和状态管理逻辑;
  2. 调用 build 生成或更新 Element、Widget 和 RenderObject 结构;
  3. 执行布局;
  4. 执行绘制记录;
  5. 处理部分图片、文本和插件相关逻辑。

“UI 线程”是 Flutter 性能分析中的概念名称。Dart 代码运行在 isolate 中,通常主 isolate 由引擎安排到一个名为 UI thread 的线程执行。不要把它理解成“所有 UI 工作都必须由一个固定操作系统线程完成”的 API 保证;具体线程命名和调度属于引擎与平台实现。

Raster 阶段

Raster 线程接收 UI 阶段提交的绘制信息,负责把它们变成可显示的图形结果。典型工作包括:

  • 执行路径、矩形、文本和图片的光栅化;
  • 应用裁剪、变换、透明度和混合;
  • 创建或查找图形资源;
  • 将解码后的图片转成 GPU 可采样的纹理;
  • 准备并提交 GPU 命令。

因此,build() 很快并不代表帧一定流畅。一个 CustomPainter 可能只花很少的 Dart 时间,却生成非常复杂的路径,最终让 Raster 阶段超时。


UI 超时和 Raster 超时不是同一种故障

设某一帧的 UI 阶段耗时为 UU,Raster 阶段耗时为 RsR_s,预算为 BB

最简单的判断方式是:

U>BUI 阶段无法按时产出U>B \Rightarrow \text{UI 阶段无法按时产出}

Rs>BRaster 阶段无法按时完成R_s>B \Rightarrow \text{Raster 阶段无法按时完成}

但是,不能简单使用:

U+Rs>BU+R_s>B

来判断每一帧是否必然掉帧。UI 和 Raster 在流水线中可以部分重叠。UI 生成第 n+1n+1 帧时,Raster 可能仍在处理第 nn 帧。

更准确地说,连续帧的生产和消费受到流水线依赖约束:

  • UI 必须先完成某一帧的绘制记录,Raster 才能处理该帧;
  • Raster 处理过慢会积压已生成的帧,最终对 UI 形成反压;
  • UI 处理过慢会让 Raster 没有新帧可消费;
  • 其中任一阶段超过显示截止时间,都可能造成用户看到旧帧。

一个完整算例

假设设备刷新率为 60 Hz:

B=16.67 msB=16.67\text{ ms}

某帧测得:

  • UI:U=12 msU=12\text{ ms}
  • Raster:Rs=18 msR_s=18\text{ ms}

虽然 U+Rs=30 msU+R_s=30\text{ ms},但真正的直接问题是 Raster 超过了单帧预算。UI 阶段本身仍有机会及时生成绘制记录,但 Raster 无法在截止时间前完成,因此用户会看到这一帧延迟显示或直接错过刷新周期。

再看另一种情况:

  • UI:U=22 msU=22\text{ ms}
  • Raster:Rs=5 msR_s=5\text{ ms}

Raster 很快,但 UI 没有及时生成新帧,Raster 最终只能重复显示上一帧。此时增加 GPU 性能不会解决问题,应该查找 Dart 回调、build、layout 或 paint 阶段的阻塞。

为什么“总耗时”容易误导

Timeline 中可能同时看到 UI 和 Raster 的事件。如果直接把所有事件相加,往往会重复计算并行时间。诊断时应分别观察:

  • UI 轨道是否超过当前刷新率的时间预算;
  • Raster 轨道是否出现长任务;
  • 长任务是否与图片上传、Shader、复杂绘制或平台纹理有关;
  • 同一时间是否还有 GC、插件调用或系统线程竞争。

FrameTiming:先用数据确认是哪一阶段慢

Flutter 提供 SchedulerBinding.addTimingsCallback 接收引擎报告的帧耗时。下面是一个可运行的最小示例:

import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';

void main() {
  WidgetsFlutterBinding.ensureInitialized();

  SchedulerBinding.instance.addTimingsCallback((List<FrameTiming> timings) {
    for (final timing in timings) {
      final buildMs = timing.buildDuration.inMicroseconds / 1000.0;
      final rasterMs = timing.rasterDuration.inMicroseconds / 1000.0;
      final totalMs = timing.totalSpan.inMicroseconds / 1000.0;

      debugPrint(
        'build=${buildMs.toStringAsFixed(2)} ms, '
        'raster=${rasterMs.toStringAsFixed(2)} ms, '
        'total=${totalMs.toStringAsFixed(2)} ms',
      );
    }
  });

  runApp(const MaterialApp(home: DemoPage()));
}

class DemoPage extends StatelessWidget {
  const DemoPage({super.key});

  @override
  Widget build(BuildContext context) {
    return const Scaffold(
      body: Center(child: Text('Frame timing demo')),
    );
  }
}

这些字段表示什么

  • buildDuration:引擎测得的 UI 构建相关阶段耗时;
  • rasterDuration:Raster 阶段耗时;
  • totalSpan:从该帧开始到结束的时间跨度,可能包含阶段间等待和调度影响。

这些值来自引擎帧时序,不等同于某一个 Dart 函数的 CPU 时间。一个 buildDuration 较小的帧仍可能在平台线程、GPU 或合成阶段等待。

如何使用这个示例

  1. 使用真实设备运行,而不是只看热重载后的 Debug 状态。
  2. 在滚动、打开页面、显示图片、切换动画等真实场景下采样。
  3. 观察一批连续帧,而不是只看单个最大值。
  4. 先区分 buildDuration 高还是 rasterDuration 高,再决定是否深入 Dart CPU、绘制、Shader 或图片。

在生产应用中不应无条件输出每帧日志。高频 debugPrint 本身会干扰时序,也会污染日志。更合适的做法是只在开发或诊断构建中聚合统计,例如记录超过某个阈值的帧数量。


Timeline 是什么,以及它为什么能定位卡顿

Timeline 是一组带时间戳的事件记录。事件通常包含:

  • 名称;
  • 开始时间和结束时间;
  • 所属线程或轨道;
  • 可选参数;
  • 同步或异步关系。

Flutter 引擎会自动产生帧、UI、Raster、图片和图形相关事件。Dart 代码也可以手动加入事件。

在 Dart 代码中标记关键区段

import 'dart:developer' as developer;

List<int> buildIndex(int count) {
  return developer.Timeline.timeSync<List<int>>(
    'buildIndex',
    () {
      final result = <int>[];

      for (var i = 0; i < count; i++) {
        result.add(i * i);
      }

      return result;
    },
    arguments: <String, Object>{
      'count': count,
    },
  );
}

这个方法会在 Timeline 中创建一个同步区间:

  1. 进入 buildIndex 时记录开始时间;
  2. 执行闭包;
  3. 闭包返回或抛出异常时记录结束时间;
  4. count 作为事件参数保存。

它不会把计算移到后台,也不会自动提升性能。它的作用是提供因果证据:如果某个长帧中同时出现了 buildIndex 事件,就能确认该区段占用了 UI 时间。

异常仍会正常向上传播,timeSync 不会吞掉异常。因此它适合包围已有逻辑,而不是用来代替错误处理。

不要用 Timeline 事件包围每个小函数

如果为大量纳秒级或微秒级函数都创建事件,事件本身会增加开销,也会让 Performance 页面难以阅读。标记应放在具有业务意义的区段,例如:

  • 一次列表数据转换;
  • 一次大 JSON 解析;
  • 一次图片尺寸计算;
  • 一次复杂动画状态更新。

Timeline 的边界

Timeline 只能记录被接入的事件。它不能自动解释任意原生库内部的耗时,也不能把 GPU 真正执行的每一条指令都还原成 Dart 调用栈。

因此,Timeline 中看到一个 Raster 长事件,只能说明 Raster 阶段在这段时间没有完成;还需要结合事件名称、绘制结构、图片和 Shader 信息判断具体原因。


DevTools Performance 页面:按因果顺序阅读

典型诊断流程如下:

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

然后打开 DevTools 的 Performance 页面,在复现卡顿的操作前开始录制,完成操作后停止录制。

为什么要使用 Profile 模式

Debug 模式包含断言、调试检查、热重载支持和其他开发辅助逻辑,执行路径和发布包不同。Debug 中看到的时间不能直接作为生产性能结论。

Profile 模式通常保留性能采样和 Timeline 能力,同时更接近发布构建。不同平台支持的调试能力存在差异;如果需要做最终验收,还应在接近生产的 Release 构建和目标设备上复测。

读取 Performance 时间线的顺序

建议按以下顺序观察:

  1. 先看帧是否超过预算
    结合设备刷新率判断,而不是固定使用 16 ms。120 Hz 设备的预算只有约 8.33 ms。

  2. 再看 UI 轨道
    如果长事件出现在 UI isolate,继续查看 CPU Profile、Dart Timeline 和 Widget 构建关系。

  3. 再看 Raster 轨道
    如果 UI 正常而 Raster 很长,重点检查复杂绘制、图片、Shader、裁剪、阴影和纹理上传。

  4. 定位事件边界
    通过事件名称、调用栈和相邻事件判断是一次性初始化,还是每帧重复执行。

  5. 比较冷启动与热状态
    第一次进入页面慢,可能是图片解码、Shader 或资源创建;持续滚动每帧慢,则更可能是重复布局、重复绘制或过多纹理工作。

常见观察与正确推断

观察 更可能的方向 不能直接推出的结论
UI 轨道单帧超过预算 Dart、build、layout、paint、同步插件调用 不能直接说是 build()
Raster 轨道单帧超过预算 绘制复杂度、图片、Shader、GPU 资源 不能直接说是 GPU 硬件不足
首帧特别慢,后续正常 资源初始化、图片解码、Shader 或管线创建 不能直接说所有启动慢都由 Shader 引起
滚动图片列表时 Raster 抖动 解码、纹理上传、图片尺寸过大、缓存抖动 不能只通过增加图片缓存解决
UI 和 Raster 都短但仍掉帧 平台线程、浏览器合成、系统负载、输入或外部原因 不能断言 Flutter 代码无问题

UI 线程卡顿:最常见的 Dart 侧路径

在帧回调中执行同步计算

下面的代码会同步阻塞 UI isolate:

class BadPage extends StatelessWidget {
  const BadPage({super.key});

  @override
  Widget build(BuildContext context) {
    var value = 0;

    for (var i = 0; i < 20 * 1000 * 1000; i++) {
      value ^= i;
    }

    return Text('$value');
  }
}

如果 build() 因状态变化频繁执行,这段循环就会在每次重建时重复执行。问题不是“循环很慢”这么简单,而是它与帧生成处于同一个同步执行上下文中:

  1. 状态变化触发下一帧;
  2. Flutter 执行 build()
  3. build() 进入长循环;
  4. UI isolate 无法及时完成该帧;
  5. Raster 没有新的绘制记录,或只能继续显示旧帧。

应先判断计算是否可以:

  • 在事件发生前预计算;
  • 缓存结果;
  • 拆分为增量任务;
  • 放入独立 isolate。

但把工作放进 isolate 也不是无成本的。大量对象跨 isolate 传递会产生序列化和复制开销;无法直接访问 UI 状态或大多数 Flutter UI 对象。适合 isolate 的通常是纯 Dart 数据处理,例如 JSON 解析、排序和图像元数据计算,而不是 Widget 构建。

build 频繁不等于一定卡顿

build() 调用次数本身不是性能指标。一个轻量 Text 的重建可能非常快;一个包含大列表、复杂计算和大量对象分配的重建则可能很慢。

正确问题是:

  • 哪些 Widget 在长帧中重建;
  • 重建是否触发了大范围布局;
  • 是否在 build() 中做了同步 I/O、排序、JSON 解析或大循环;
  • 是否每帧创建了大量短命对象。

使用 const、拆分 Widget、缩小状态作用域可以减少无效工作,但它们不是对所有卡顿的通用修复。若长事件出现在 Raster,单纯减少 Widget 重建不会解决根因。


Raster 卡顿:绘制记录已经生成,但画面仍然来不及完成

Raster 卡顿常见于以下场景:

  • 大量复杂路径;
  • 多层透明叠加;
  • 大面积模糊或阴影;
  • 过多裁剪和变换;
  • 超大图片纹理;
  • Shader 或图形管线首次创建;
  • 频繁创建和上传 GPU 资源。

复杂绘制的反例

class ExpensivePainter extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint()
      ..color = Colors.blue
      ..style = PaintingStyle.stroke
      ..strokeWidth = 2;

    final path = Path();

    for (var i = 0; i < 10000; i++) {
      path.lineTo(
        i.toDouble() % size.width,
        (i * 37 % size.height).toDouble(),
      );
    }

    canvas.drawPath(path, paint);
  }

  @override
  bool shouldRepaint(covariant ExpensivePainter oldDelegate) {
    return true;
  }
}

这里有两个独立问题:

  1. paint 每次都生成一个包含大量线段的路径;
  2. shouldRepaint 永远返回 true,即使输入没有变化,也要求重绘。

如果动画确实改变了所有线段,返回 true 可能是必要的;如果数据不变,就应让 shouldRepaint 反映实际输入变化。需要注意,避免重绘只能减少 UI 产生绘制记录的工作,也不等于已经生成的绘制本身很便宜。

图层缓存不是免费优化

RepaintBoundary 可以把子树隔离为独立的重绘区域。一个静态复杂子树旁边有一个频繁变化的动画时,边界可能避免静态内容反复重绘。

但它也可能增加:

  • 离屏纹理或图层内存;
  • 图层合成;
  • 纹理上传和采样;
  • Raster 的合成工作。

因此,不能把 RepaintBoundary 当作“越多越好”。应该用 Timeline 和实际帧数据验证:加入边界后,UI 重绘范围和 Raster 时间是否真的改善。


Shader:为什么第一次出现特效时会卡

Shader 是运行在 GPU 上的程序,用于计算顶点位置或像素颜色。Flutter 中常见的 Shader 来源包括:

  • 线性渐变和径向渐变;
  • 图片采样;
  • 颜色混合;
  • 模糊、阴影和遮罩;
  • 自定义 fragment shader;
  • 某些复杂裁剪和图形效果。

Shader 卡顿的基本路径

首次使用某种图形效果时,可能发生以下过程:

  1. Flutter 创建或请求相应的图形程序;
  2. 图形后端将 Shader 转换为目标 GPU 或图形 API 能执行的形式;
  3. 驱动创建管线状态;
  4. Raster 或相关图形线程提交第一次绘制;
  5. GPU 执行该程序。

如果第 2、3 步发生在首次显示的关键帧附近,用户可能看到“第一次滑到这里卡一下,之后正常”。

这类卡顿通常不是 Dart 的 build() 慢。即使 UI 轨道很短,Raster 或图形相关轨道仍可能出现长事件。

Skia、Impeller 和平台差异

Flutter 的渲染后端会随 Flutter 版本、目标平台、系统版本和配置变化。常见后端包括 Skia 和 Impeller,但不能根据某个旧版本的默认行为推断当前所有设备。

Impeller 的设计目标之一是减少运行时 Shader 编译造成的不确定性,并将更多工作前移或采用更可控的管线准备方式。但这不意味着:

  • 所有图形管线都在应用启动时完成;
  • 所有 GPU 资源创建都没有运行时成本;
  • 使用复杂 Shader 就必然没有首帧抖动;
  • Skia 和 Impeller 的 Timeline 事件名称完全相同。

实际诊断应以目标 Flutter 版本和目标设备的运行结果为准。使用 flutter run --verbose 可以辅助确认引擎启动信息,但详细事件仍应在 Profile 录制中观察。

Shader 卡顿的验证方法

不要看到渐变就直接归因于 Shader。可以做隔离实验:

  1. 保持页面结构不变,暂时移除模糊、阴影、渐变或自定义 Shader;
  2. 在相同操作、相同设备和相同数据下重新录制;
  3. 比较第一次出现特效时的 Raster 和总帧耗时;
  4. 再重复操作,区分“冷路径”与“热路径”。

如果只有第一次明显变慢,资源或管线初始化的可能性较高;如果每一帧都慢,则应进一步检查 Shader 复杂度、覆盖面积、透明层数量和 GPU 填充压力。

Shader 预热的边界

某些平台和图形后端支持通过预热或提前访问资源减少首次卡顿,但这类能力依赖版本和渲染后端,不应把旧版 Android OpenGL 场景下的 SkSL 缓存流程当成所有平台的通用方案。

预热也有取舍:

  • 启动时间增加;
  • 内存和包体可能增加;
  • 预热场景不完整时,无法覆盖真实效果;
  • 设备 GPU 型号不同,结果可能不同;
  • 把所有 Shader 都提前准备可能浪费资源。

生产决策应基于真实首屏、首个动效和首个滚动视口的 Profile 数据,而不是机械地收集或预热全部 Shader。


图片:网络完成不代表图片已经可以流畅绘制

一张图片至少经历以下阶段:

flowchart LR
    A[ImageProvider 请求] --> B[网络或文件读取]
    B --> C[压缩格式解码]
    C --> D[尺寸与颜色信息]
    D --> E[ImageCache]
    E --> F[GPU 纹理上传]
    F --> G[Raster 绘制]

每个阶段的瓶颈不同:

  • 网络慢:首屏等待,但不一定造成滚动掉帧;
  • 解码慢:可能阻塞资源处理或造成加载抖动;
  • 尺寸过大:占用更多 CPU、内存和 GPU 纹理空间;
  • 上传慢:首次显示或快速滚动时 Raster 可能变慢;
  • 缓存不足:图片不断被淘汰和重新解码;
  • 缓存过大:内存压力和回收频率增加。

图片尺寸应接近实际显示尺寸

假设一个头像在布局中显示为 120120 个逻辑像素,设备像素比为 33,目标解码宽度约为:

Wdecode=120×3=360 pxW_{\text{decode}}=120\times3=360\text{ px}

如果直接解码一张 40004000 像素宽的原图,再缩小到 360 像素显示,就会浪费解码内存和纹理容量。

一个简单示例:

class Avatar extends StatelessWidget {
  const Avatar({
    required this.url,
    super.key,
  });

  final String url;

  @override
  Widget build(BuildContext context) {
    final dpr = MediaQuery.devicePixelRatioOf(context);
    final targetWidth = (120 * dpr).round();

    return ClipOval(
      child: Image.network(
        url,
        width: 120,
        height: 120,
        fit: BoxFit.cover,
        cacheWidth: targetWidth,
        cacheHeight: targetWidth,
        errorBuilder: (context, error, stackTrace) {
          return const ColoredBox(
            color: Colors.grey,
            child: Icon(Icons.person),
          );
        },
      ),
    );
  }
}

这里:

  • widthheight 是布局中的逻辑像素;
  • cacheWidthcacheHeight 是目标解码尺寸,单位是物理像素;
  • errorBuilder 处理加载或解码失败,避免异常图片让页面没有可用替代内容。

cacheWidth 不保证网络服务器只传输这个尺寸的文件。它主要影响 Flutter 侧的解码目标和缓存键。若要减少网络流量,还需要服务端缩略图、CDN 参数或图片处理服务。

图片缓存不是“缓存越大越好”

Flutter 的图片缓存会保存 ImageProvider 对应的图片结果,但缓存有容量和淘汰行为。大量不同 URL、不同尺寸或不同参数的图片,可能导致:

  • 缓存命中率低;
  • 解码重复发生;
  • 内存持续增长;
  • 系统内存压力触发回收;
  • 大图在显示时重新上传纹理。

还要注意,同一张源图片以不同 cacheWidth 请求,可能形成不同的缓存结果。尺寸策略不稳定会降低缓存复用率。

图片列表的典型失败路径

快速滚动瀑布流时,如果每个单元格都加载大图:

  1. 新单元格进入视口;
  2. 新图片开始读取和解码;
  3. 多张图片在短时间内完成;
  4. Raster 需要把多个大位图上传为纹理;
  5. 缓存达到上限,旧图片被淘汰;
  6. 用户反向滚动时又重新解码和上传。

此时只增加 cacheWidth 可能不够,还要检查:

  • 服务端返回尺寸是否合理;
  • 列表是否复用了稳定的 key;
  • 是否在离屏区域提前创建过多图片;
  • 图片缓存是否发生频繁淘汰;
  • Raster 长事件是否与纹理上传同时出现。

Timeline 中如何区分图片问题和 Shader 问题

两者都可能表现为“第一次显示时卡顿”,但证据不同。

更像图片问题的信号

  • 长事件与图片解码、缓存未命中或纹理上传相邻;
  • 卡顿跟图片尺寸、数量和滚动速度相关;
  • 替换为纯色占位后问题消失;
  • 使用较小的 cacheWidth 后 Raster 峰值下降;
  • 网络慢时主要是内容晚到,而不是每帧掉帧。

更像 Shader 或图形管线问题的信号

  • 图片已在缓存中,仍在第一次使用特效时卡顿;
  • 移除渐变、模糊、自定义 fragment shader 后问题消失;
  • 第一次进入页面慢,随后相同路径明显变快;
  • UI 时间稳定,Raster 或图形相关事件出现一次性长峰值。

这不是绝对分类。图片本身也可能触发颜色转换、采样和纹理管线;Shader 也可能与图片采样结合。最终仍应通过“只改变一个变量”的对照实验确认因果。


用 Timeline 标记图片前处理,而不是伪造引擎事件

如果应用有自定义图片处理或数据转换,可以标记 Dart 侧工作:

import 'dart:developer' as developer;
import 'dart:typed_data';

Uint8List prepareBytes(Uint8List source) {
  return developer.Timeline.timeSync<Uint8List>(
    'prepareImageBytes',
    () {
      // 示例:这里放实际的纯 Dart 数据处理。
      // 不应在这里假设 Flutter 已经完成 GPU 上传。
      return Uint8List.fromList(source);
    },
    arguments: <String, Object>{
      'inputBytes': source.length,
    },
  );
}

这个事件只能说明 prepareImageBytes 这段 Dart 代码的耗时,不能证明:

  • 图片已经解码完成;
  • 图片已经进入 ImageCache
  • 图片已经上传到 GPU;
  • Raster 已经完成绘制。

如果要确认后面三个阶段,应同时观察图片加载行为和 Raster Timeline,而不是把这个自定义事件当成完整图片生命周期。


常见误解与反例

误解一:UI 时间没超 16 ms,就不会掉帧

错误。设备可能是 90 Hz 或 120 Hz,预算分别约为 11.11 ms 和 8.33 ms。并且 Raster、GPU、平台合成也有自己的限制。

误解二:Raster 慢就是 Widget 太多

不一定。Widget 数量主要更直接影响构建、布局和绘制记录生成。Raster 还可能被一张超大图片、一个复杂路径、全屏模糊或大量透明混合拖慢。

误解三:const 能解决所有卡顿

const 可以减少部分 Widget 实例创建和重建,但不能修复:

  • GPU Shader 编译;
  • 大图片纹理上传;
  • 复杂 CustomPainter 的光栅化;
  • 平台线程阻塞;
  • 浏览器合成瓶颈。

误解四:增加 RepaintBoundary 一定有效

它可能减少无关重绘,也可能增加离屏图层和纹理合成。必须比较加入前后的 UI、Raster 和内存数据。

误解五:图片已经下载到本地,就不会造成卡顿

下载完成只代表字节可用。解码和 GPU 使用仍可能尚未完成,而且不同尺寸的解码结果可能分别缓存。

误解六:Timeline 里的 Dart 事件就是整个功能耗时

Timeline 事件只覆盖标记区间。异步网络请求、原生插件、GPU 执行和浏览器合成可能发生在其他轨道,甚至不显示为一个与 Dart 函数一一对应的区间。


平台差异:不要把移动端经验直接套到 Web

Android 和 iOS

移动端通常可以用 UI、Raster、平台和 GPU 相关轨道分析 Flutter 引擎流水线。具体渲染后端和默认配置取决于 Flutter 版本、系统版本、设备能力及应用配置。

Android 设备型号差异尤其明显:

  • GPU 驱动实现不同;
  • 刷新率可能动态变化;
  • 纹理内存和系统内存压力不同;
  • 某些图形管线首次创建代价不同。

iOS 也不能假设所有设备的 GPU 行为相同。模拟器的 CPU、GPU 和图形驱动环境与真机不同,不能用模拟器结果替代真机性能结论。

桌面端

Windows、macOS 和 Linux 的窗口合成、GPU 驱动、缩放比例和显示刷新率差异较大。桌面设备通常拥有更强的硬件,但高分辨率窗口、多个显示器和高刷新率会增加像素填充和合成压力。

桌面端的性能问题可能只有在窗口放大、外接高分辨率显示器或启用复杂透明效果时出现。

Web

Flutter Web 的代码运行在浏览器事件循环中,渲染结果还要经过浏览器的 Canvas、WebGL/WebGPU、合成器和页面布局系统。不同 Web 渲染器、浏览器和版本会改变具体执行路径。

因此,在 Web 上:

  • 不应假设一定存在与移动端完全相同的独立 Flutter Raster 线程;
  • Flutter DevTools 中的 UI/Raster 指标不能机械地与 Android 或 iOS 数值比较;
  • 浏览器 Performance 面板常常需要与 Flutter DevTools 联合使用;
  • 页面其他 DOM、浏览器扩展、标签页竞争和合成层也可能造成卡顿。

Web 上还要特别检查主线程长任务。即使 Flutter 侧 Dart 代码看起来正常,浏览器主线程上的样式计算、布局、Canvas 提交或页面合成也可能延迟呈现。


一套可复现的诊断流程

第一步:固定测试条件

记录以下信息:

  • Flutter 和 Dart 版本;
  • 构建模式;
  • 平台、系统版本和设备型号;
  • 屏幕刷新率;
  • 渲染后端;
  • 测试数据规模;
  • 是否为首次进入页面。

如果不固定这些变量,同一个修复可能只是换了设备或缓存状态,无法证明有效。

第二步:使用 Profile 复现

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

在 DevTools Performance 页面中录制完整操作,包括:

  • 页面进入前的准备;
  • 触发卡顿的操作;
  • 卡顿结束后的恢复阶段。

不要只录制卡顿中间的一秒,因为首次资源创建通常发生在操作开始处。

第三步:按轨道分类

  • UI 高:查看同步计算、Widget 构建、布局、绘制记录和插件调用;
  • Raster 高:查看绘制复杂度、图片纹理、Shader、裁剪、阴影和图层;
  • 两者不高但用户仍感觉卡:检查平台线程、浏览器主线程、GPU/合成器和系统负载。

第四步:做单变量实验

例如:

  • 将网络图片替换为固定色块;
  • 将模糊替换为普通背景;
  • 将复杂 CustomPainter 替换为矩形;
  • 将图片解码尺寸降低;
  • 暂时关闭某个动画;
  • 将数据规模缩小一半。

一次只改变一个因素。若同时改变图片、布局和动画,就无法知道是哪一项产生了效果。

第五步:验证修复没有转移问题

例如,把大图改成小图后 Raster 变快,但如果缓存命中率下降,反向滚动可能更慢;加入 RepaintBoundary 后当前动画变快,但内存占用和首次纹理创建可能增加。

修复应至少在以下场景复测:

  • 冷启动;
  • 首次进入页面;
  • 首次显示图片或特效;
  • 持续滚动;
  • 快速反向滚动;
  • 长时间停留后的重复操作;
  • 低端设备和高刷新率设备。

如何把一次卡顿还原成故障路径

一个可靠的分析应能写出类似下面的因果链:

用户快速滚动图片列表
→ 新图片以原始 4000 px 宽度解码
→ 多张位图在短时间内完成
→ Raster 发生连续纹理上传
→ Raster 超过 8.33 ms 的 120 Hz 预算
→ UI 仍然较快,但画面出现掉帧

或者:

用户第一次打开带模糊的页面
→ UI 构建在预算内完成
→ 首次图形管线创建发生在 Raster 路径
→ Raster 单帧出现长事件
→ 第二次进入同一路径时资源已存在,卡顿减弱

也可能是:

每次滑动都触发列表状态更新
→ 父级 Widget 重建整个列表
→ 同步排序和对象创建占用 UI isolate
→ UI 无法及时提交新帧
→ Raster 时间正常但画面停顿

如果只能说“这个页面很复杂”“可能是 GPU 慢”,说明还没有完成诊断。Timeline 的价值就在于把用户动作、代码区段、引擎阶段和最终帧结果连起来。


生产取舍:优化目标不是让所有帧都相同

性能优化的目标是让关键交互在目标设备上满足可接受的帧时限,而不是追求所有情况下都低于某个固定数字。

需要明确区分三种结论:

  1. 规范或架构保证
    例如显示刷新率决定理论帧预算;UI 生成绘制信息后,Raster 才能消费该帧。

  2. 常见实现
    Flutter 通常使用独立的 UI 和 Raster 执行路径,但具体线程、渲染后端和平台合成方式随目标平台和版本变化。

  3. 经验建议
    预热 Shader、增加图层边界、扩大图片缓存、使用 isolate 都必须通过目标设备的测量验证,不能当作无条件规则。

最终诊断应回答四个问题:

  • 哪个阶段超过了预算;
  • 具体事件占用了时间;
  • 该事件为什么只在当前场景发生;
  • 修复后是否在冷路径、热路径和目标设备上都得到验证。

当 UI、Raster、Shader、图片和 Timeline 被放回同一条帧流水线中,Flutter 卡顿就不再是“凭感觉猜原因”,而可以被拆成可观测、可复现、可验证的工程问题。


系列导航与关联阅读

官方资料

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