1. 为什么要盯加载过程,而不是只看首帧数字
冷启动优化做过三回以上的人,基本都会经历这么个阶段:一开始看 IDE 自带的 Profiler,觉得每个方法都不慢,但整体就是慢;后来学会打日志,发现入口到首帧确实走了几百毫秒,但中间哪一段最贵,日志上看不出来;直到某天把 perfetto 的时间线真正打开,才第一次把 App 加载过程“看见”了。
这篇文章想聊的就是这套东西——用 Perfetto 同时观察 Flutter 和 Android 的加载过程,把系统、框架、渲染三层放到同一条时间轴上看,定位首帧前的每一毫秒花在哪、为什么花在那、能不能省掉。
它能解决的痛点很直接:你只知道启动慢,但不知道是卡在进程启动、Application 初始化、Flutter 引擎加载、widget 构建还是 GPU 合成;你试过各种优化手段,但没法量化收益,改完也不知道是不是心理作用。Perfetto 本身不治任何病,但它能帮你找到该下刀的地方。
1.1 首帧时长只是结果,不是原因
一个 App 从点击图标到看到第一帧,中间其实是一整条流水线:Zygote 孵化进程、加载 APK、创建 Application、Activity 走生命周期、View 树 measure/layout/draw、Flutter 侧还要再叠一层引擎初始化和 Dart 代码执行。
问题在于,很多人只看am start -W打出来的 TotalTime,或者只看首屏渲染回调里的时间戳,然后拿这个数字去对上“做了几个优化”,数字降了就认为是优化生效。但启动过程的瓶颈经常是叠加的,A 段慢 100ms、B 段慢 80ms,它们之间还会互相影响,只盯着最终数字根本分不清哪一段是主要矛盾。
举个例子:某次我在一个项目里查启动问题,首帧看 Flutter 的 build 和 layout 都不慢,但从点击到进入 Flutter 页面总时间却有将近 700ms。最后用跟踪工具一看,大头在系统侧:Application 初始化阶段被一个平台插件的静态注册拖住了 main 线程,Flutter 引擎反而只站了 200ms。这种问题不看时间线是绝对猜不出来的。
1.2 Perfetto 这类工具到底解决什么问题
Perfetto 的前身是 systrace,但它不是一个简单的“升级版”,它是把内核调试、系统服务埋点、图形栈事件、App 内部 trace 全部统一进一条同步时间线的框架。最关键的一点是:它是系统级的,不是 App 内的采样器。
用常见的代码插桩性能工具,你只能看到自己埋点的区域,进程外的东西全是黑盒。Perfetto 则能同时看到线程调度、CPU 频率变化、Binder 调用、SurfaceFlinger 合成、GPU 状态、内存只读这些底层信息。换句话说,它是把“整个手机”变成一个被观察的对象,而不是只盯着你的 App。
而且 Perfetto 的采集开销比很多工具低得多,trace 文件压缩后也能在浏览器里快速可视化。配合 trace processor 做 SQL 查询,还能对大量数据做聚合分析,这在系统问题上尤其有用。
注意:Perfetto 解决的是“看到了什么”,不会直接告诉你“该怎么办”。真正有价值的是你在时间线上把系统现象和业务代码关联起来的那一步。
2. 观察前的准备:设备、环境和抓取命令
拿到 Perfetto 就直接开抓,大概率会抓出一堆看着头晕的表,然后什么都定位不了。我现在的固定流程是先把环境约束住,再决定用什么模式抓、抓多长时间、开哪些类别。
2.1 设备与基础环境的要求
建议使用原生系统或者改动较少的 Android 9 以上设备,越接近原生的调度特性越容易看懂。厂商系统往往有高频调度、各种后台清理,这类策略会显著影响 trace 的可解释性,特别是 CPU 频率和线程优先级部分。
抓取前做三件事:
- 关掉忽明忽暗的动态壁纸,锁屏只保留普通壁纸,减少渲染噪声。
- 关闭试验性的省电模式、极限省电之类的策略,否则 CPU 频率被压住,所有结论都会偏。
- 保证同一个工程用同一个测试入口,比如 adb deep-link 直接拉起测试页面,不要每次手动点击首页,减少点击位置不同带来的噪声。
模拟器也能测,但模拟器的调度模型和真机差异很大,线程饥饿、帧节奏跟真机的表现完全不是一回事。我一般只在没有真机做初步判断时才用模拟器,最终结论一定以真机为准。
2.2 一次完整的冷启动抓取命令
设备上如果有内置的 perfetto 可执行文件,可以直接在 adb shell 里跑命令抓取。以下是一个针对冷启动比较实用的组合:
# 设备端开启 12 秒采集,覆盖完整冷启动窗口 adb shell perfetto -o /data/misc/perfetto-traces/startup_trace.perfetto-trace \ -t 12s \ sched freq gfx view wm am binder_driver power hal采集开始后等待 1 秒,让 trace 稳定,然后强制停止目标 App,再立即冷启动:
adb shell am force-stop com.example.app adb shell am start -W -n com.example.app/.MainActivity采集结束后用adb pull把文件拉到本地,再拖进 Perfetto UI 可视化。
如果你的设备上没有内置 perfetto,也可以下载预编译的 perfetto 二进制推到/data/local/tmp/perfetto下执行,或者使用 SDK 自带的录制脚本生成 trace。
2.3 Flutter 侧的专属开关
纯 Android 角度抓到的 trace 只能看到 Flutter 进程里哪些线程在跑、跑了多久、有没有 Binder 等待,但看不到 Dart 侧的 widget build、layout、paint 这些阶段。想看清 Flutter 内部的工作,建议使用 profile 模式加启动 trace:
flutter run --profile --trace-startup这样会在启动阶段把 Flutter 框架内部的 timeline 事件导出来,里面能看到Flutter.Build、Flutter.Layout、Flutter.Paint、Flutter.Raster这类标签。不同版本命名稍有差异,但含义基本一致。
之所以强调 profile 模式,是因为 debug 模式下 Dart 是 JIT,类加载、内联、类型判断、甚至基础操作都会慢不少,Flutter 引擎的启动路径和 release 也完全不同,测出来的数字完全没有参考性。profile 模式接近 release 的 AOT 行为,同时保留了一定的调试能力,是最适合做启动分析的模式。
注意:用
--trace-startup只会抓启动窗口内的事件,如果你在runApp()执行前花了很多时间准备数据,这段从引擎启动到 runApp 之间的窗口就会被完整完整记录下来,但如果你启动后很快就手动关掉了命令,后续帧的事件就丢失了,建议把 trace 维持到首帧呈现之后再加一两秒。
3. Android 侧加载时间线怎么读
Perfetto 打开后界面信息很多,新手最容易犯的错是到处看。更好的方式是从系统侧的几个关键事件入手,先把整条启动链路串出来。
3.1 冷启动的三大阶段
Android 冷启动在 trace 里天然分成三段,每段的重点不一样:
| 阶段 | 主要工作 | 时间线上重点看什么 |
|---|---|---|
| 进程孵化 | Zygote fork、加载 class、建立主线程 | 进程出现、bindApplication、主线程调度 |
| Application 与 Activity 初始化 | Application 构造、attachBaseContext、onCreate、Activity 生命周期 | handleLaunchActivity、performResume、Binder 时间片 |
| 首帧绘制与合成 | View 树构建、measure/layout/draw、VSYNC、SurfaceFlinger 合成 | ChoreographerdoFrame、performTraversals、合成事件 |
这三段之间是有等待关系的,前一段卡住,后一段的天数全部顺延。所以读 trace 的第一步不是找 App 自己的代码,而是先在这三段之间分别画一条竖线,看哪一段占用的墙钟时间最长。
3.2 从 trace 里找“谁占了大头”
Android App 进程的main线程在 trace 上通常是一段连续的运行块,中间可能出现许多等待块。等待块颜色的含义大致是:绿色代表运行中,蓝色代表可运行但没拿到 CPU(调度延迟),橙色代表不可中断的睡眠,很常见的是等 Binder、等 I/O。
具体分析时,我习惯先用 Perfetto 的搜索功能按进程过滤,把com.example.app进程单独看,然后把 main 线程的时间块砸开。如果发现某个 block 特别长,点开看它是什么事件,再沿着它关联的线程往下钻。
比如 main 线程里一大段橙色等待,通常能找到对应的 Binder transaction。这时候看旁边binder_driver相关 track,能看到事务名、发送方和接收方,这对定位“Application.onCreate 里调用系统服务卡住了”这类问题非常直观。
3.3 VSYNC 与首帧节奏
不要忽略 vsync。即使你不在乎帧率,启动首帧也要等至少一个 vsync 周期才能上屏。Perfetto 里 SurfaceFlinger 相关的 track 会把 vsync 信号和合成周期显示出来。
如果am start -W返回的总时间还好,但从 Activity onCreate 到首帧合成之间有明显空闲,那多半不是业务代码慢,而是帧节奏没对齐,系统在等下个 vsync 周期点。这种情况在 trace 上看起来就是:主线程早点跑完了工作,但光栅化和合成发生在较晚的时间点。理解了这一段,再回头看 TotalTime 就不会被“明明代码跑得快却还是慢”迷惑。
4. Flutter 侧加载时间线怎么读
Flutter 叠加在 Android 之上,它的加载过程更容易理解,但需要接受一个前提:Flutter 进程里有不止一个线程在干活,而且它的线程模型和传统 Android 不太一样。
4.1 Flutter 进程里的线程配置
Perfetto 里展开 Flutter 进程的线程列表,通常会看见这些名字:
| 线程 | 职责 |
|---|---|
| main | Android 平台线程,处理 Activity 生命周期、插件、platform channel |
| 1.ui | Dart root isolate 所在线程,处理 widget 构建、布局、绘制指令生成 |
| raster | 光栅化线程,执行 GPU 上传和最终渲染指令 |
| io | Flutter 引擎的 IO 工作,比如图片解码 |
| worker / Dart worker | 引擎内部任务和 Dart 后台 GC |
很多人第一次看会问:为什么构建 Flutter 页面不占用 Android main 线程?因为 Flutter 自己有一套线程分工。Debug profile 模式下,其实 UI 线程和平台线程都很忙,但它们的忙碌内容完全不同。读 trace 时必须分清楚,你在1.ui上看到的代码是 Flutter 框架层,在main上看到的是 Android 平台层。
4.2 Flutter 首帧的里程碑
Flutter 冷启动在 trace 和启动 timeline 里可以拆成几个里程碑:
- 引擎进程加载:加载
libflutter.so、libapp.so,代码段和资源映射。 - Dart VM 初始化:创建 Dart VM、root isolate。
runApp()执行:触发第一次 build、layout、paint 指令生成。- 首帧异步光栅化:raster 线程执行真正绘制。
- SurfaceFlinger 合成上屏。
看起来简单,但实际 trace 里每个里程碑内部还能继续拆。runApp()本身不慢,慢的是它之前 Dart isolate 的初始化、RunApp 之后的第一次帧生成的调度等待,还有 shader 编译这类隐藏耗时。
在 timeline 事件里,如果能看到 Build 阶段比预期长,优先查 widget 数和树深度;如果 Build 很快但Flutter.Raster很长,问题通常在 shader 编译或 GPU 上传,而不是 widget 代码;如果1.ui和raster都不长,只是它们之间的发起间隔大,那就是调度和同步开销,比如等 vsync、等 GPU fence。
4.3 两种视角怎么合并
单独看 Flutter timeline,能定位到 Flutter 框架内部的瓶颈;单独看 Perfetto,能定位到系统和平台层的瓶颈。合在一起看,才能回答“首帧慢到底是谁的锅”。
比如一种常见情况:Perfetto 里 Android main 线程几乎没怎么忙,从进程创建到首帧的时长却很长,而 Flutter timeline 显示 build 和 raster 也很快。这时候问题往往出在 Flutter 引擎初始化和 Activity 生命周期之间的等待,比如插件注册、PlatformChannel 建立,或者 App 自己写在了 main 线程上的阻塞初始化。
另一种常见情况正好相反:Perfetto 里 Flutter1.ui线程长时间的 CPU 使用非常密集,而 Android main 线程很闲。那问题重点就在 Dart 侧的 widget 构建和布局表达上,应该从 Flutter 层面优化。
5. 实操:一次完整的定位流程
说了理论,走一遍实际操作。以下是我最近一次排查模拟项目 X 冷启动慢的具体流程,按这个顺序走完基本不会漏掉大头。
5.1 固定实验参数
我先写死测试环境:同一台手机、同一屏亮度、飞行模式、关后台应用、关闭窗口动画。然后确认测试入口能通过 adb deep-link 直达测试页,避免点击手势干扰。
手机进入稳定状态后,执行:
adb shell am force-stop com.example.app sleep 3强制停掉 App 后等几秒,是为了让系统把上一个进程的残留状态清理干净,也让 CPU 频率回落到稳定点。然后开启 perfetto 采集,1 秒后再冷启动。
5.2 找到时间线锚点
把 Perfetto trace 和 Flutter timeline 对齐是合并分析的关键步骤。两个时间线的参考时钟不完全一致,但可以找系统事件做锚点。
最简单的方式是用am start -W输出的时间,或者在 Perfetto UI 里定位am_proc_start这个事件点,再在 Flutter timeline 里找到DartInitialize或FirstFrame这类标记,以进程创建的那一排时间作为对齐点。实际操作中,Perfetto 已经把 App 自己导出的 Flutter timeline 包含进来了,所以通常不额外手工对齐,更多是验证时间线是否吻合。
5.3 案例分析:数字从哪来、怎么看
假设 trace 里我们盯出这样一段时间分布:
| 段 | 耗时 |
|---|---|
| 点击到进程 spawn | 50ms |
| spawn 到 Application.onCreate 完成 | 120ms |
| Flutter 引擎初始化 + Dart isolate 创建 | 260ms |
| runApp 到首帧 build/layout/paint | 80ms |
| raster 到 SurfaceFlinger 合成 | 60ms |
总耗时 570ms。这时不能直接说“Flutter 引擎初始化最该优化”,要先看这个 260ms 的内部组成。点开对应的事件,如果发现 260ms 里大部分是 Dart isolate 创建时同步等待一个插件通道的 binder 调用,说明瓶颈其实在插件侧,不在 Flutter 本身。
再看Application.onCreate的 120ms,如果 time 主要是等待某个文件读取,那就是业务初始化问题,比如首屏要读取大配置。
所以分析的套路是:一级一级往下钻,从“哪段长”到“那段里是什么事件”,再到“事件里等待了什么”,最后才是“怎么改”。
6. 常见问题与排查技巧实录
用 Perfetto 观察 Flutter 启动这事,我在实际执行里踩过不少坑,很多坑其实不是工具问题,而是使用姿势不对。整理成速查表,方便你直接对照。
| 现象 | 常见原因 | 解决方式 |
|---|---|---|
trace 里看不到 Flutter 的1.ui、raster线程 | 用了 debug build,或采集开始太晚 | 使用 profile 模式 +--trace-startup,启动前 1 秒就开始采集 |
am start -W的 TotalTime 和 Perfetto 里首帧时刻对不上 | 统计口径不同,TotalTime 以 Activity 管理标准为准 | 统一以 Perfetto 里的进程创建到首帧合成为准,不追求两个数字完全一致 |
| Binder 等待片段很多,但看不出原因 | 没有按线程过滤,噪音太大 | 先过滤到目标进程,再用 Flow 箭头追踪 Binder 事务方向 |
| Flutter 的 Build 很快但 Raster 很慢 | shader 编译缓存未命中、GPU 上传慢 | 提前 warm shader cache、检查是否有大图片在首帧解码 |
1.ui线程 CPU 高,但找不到具体代码 | 没导出 Dart timeline 事件 | 开启--trace-startup,在 Flutter timeline 里看 Build/Layout/Paint 三个阶段 |
| trace 文件过大,加载卡顿 | 采集类别太多、时间太长 | 精简类别,-t控制在 12 秒内;优先保留 gfx、view、wm、am、sched、binder_driver |
| 抓到的频率全部锁在低档,结论失真 | 设备背板发热降频或省电策略干预 | 降低屏幕亮度、飞行模式、充电状态下测,必要时把频率策略固定到性能档 |
除了速查表,还有几个实操细节:
每次抓 trace 前,我习惯在代码里固定一个实验入口,通过 adb 命令直达测试页,这样能排除点击手势和首页动画带来的假信号。分析时不要只看首帧出现前的窗口,也要看首帧后的 1 秒内有没有掉帧,因为有些初始化工作会把任务拆到首帧后,影响后续几帧的稳定性。
在 Perfetto UI 里做 SQL 查询是个进阶玩法。比如你想知道某个进程在启动阶段总共处于 Runnable 状态多少毫秒,可以直接用 trace processor 做聚合,这对对比优化前后的效果非常有用。不过新手不适合一上来就写 SQL,先把 UI 上的事件关系看懂,SQL 只是辅助。
翻了几次车之后我最大的体会是:Perfetto 这种工具,难的不是学会用,是养成“先看系统再下结论”的习惯。以前拿到慢启动问题,第一反应就是去猜业务代码里哪里耗时长;现在我基本都会先花十分钟抓一条干净的 trace,把所有时间线摆出来,再决定优化方向。这样做有一个额外的好处:优化完再抓一次 trace,前后对比,收益量化得很清楚,再也不会出现“改了但不敢确定是不是有效”的情况。