图形性能剖析工具链 · 第 十 篇

FrameTimeline:一帧到底有没有掉

2026-09-2516,651 字25 张配图本文由@溯光 出品,转载请注明出处

CSDN 版文章链接:待发布 知乎专栏版文章链接:待发布

题图:Perfetto 官方给出的 FrameTimeline 轨道实景,每个至少上过一屏一帧的应用在 UI 上会多出 expected 与 actual 两条轨道
题图每个"至少上过一屏一帧"的应用都会在 UI 上多出 expected 与 actual 两条轨道——这篇要讲的就是这两条轨道上的每一段分别代表什么、怎么判定它有没有超期@ Perfetto 官方文档(Google,Apache-2.0)

点题、总览与边界:掉帧不是感觉,是一个时间差

先说清一件事:本篇只回答一个问题——这一帧,到底掉没掉。这话听着像句废话,因为"卡"的感受我们都有;可要把它写成能复现、能追责的数字,问题就不平凡了:一帧的预算多少、晚多少才算晚、"晚"是应用侧还是合成侧、晚的那一帧在数据里能不能被看见——这四问串起来,才是 FrameTimeline(帧时间线)的全部内容。

本篇的边界也画在这一句话上:不涉及 trace 文件的内部结构(那是本系列的 ep07),不涉及数据源怎么采集与注册(那是 ep08),不复述 SQL 语法与调优(那是 ep09)。本篇是《图形性能剖析工具链》的第 10 篇,只讲帧这一题域的判据与归属:什么算掉帧、掉了多少、怪谁。

这条链路是这样接上的:ep07 讲"文件里有什么",ep08 讲"这些内容谁写进去的",ep09 讲"表该怎么问才既快又对";到本篇,数据与问法都已就位,剩下的问题只有一个:这一帧有没有按时上屏;如果没有,怪谁。

官方对卡顿(jank)的定义,把这个问题钉得更精确:当一个帧实际上屏的时间与调度器预测的上屏时间不一致时,这一帧就是卡顿帧。请注意两处细节:它谈的是"上屏"不是"画完",谈的是"与预测不一致"而不是"超过 16.7 毫秒"。这两个细节决定了本篇后面所有判据的形状。

下面我们先把地图铺好。

0.1 三种"看起来没问题"的错法

先来看看三种最容易发生的错法。 共同特征是:SQL 不报错,结果也不离谱,于是没有人会怀疑它。

第一种是把颜色当成判据。UI 上红色就是卡顿、绿色就是好帧,于是有人直接在截图里数红块。可颜色是给人看的,官方一共给了五种(3.5 节有对照表)。其中"浅绿色"与"黄色",用"数红块"永远数不出来——而它们恰恰最该看:一个是"帧没掉但手感变差",另一个是"帧掉了但锅不在你的代码里"。

第二种是用一个指标替代全部指标。android_frame_stats 里有个 was_jank 列,名字取得太好,于是很多人拿它数掉帧。实测很刺眼:在 android_startup_real.perfetto_trace 上,真正超期的帧(overrun > 0)有 236 帧,was_jank 只报 13 帧;webview_jank.pb 上是 149 对 0;wattson_jank_cuj.pb 上是 42 对 1。同一份数据,换个列名,答案少一个数量级。

第三种是把两路数据当成一路。webview_jank.pb 里有 1031 条 actual 帧、851 条 expected 帧,但你查 Choreographer#doFrame 是 0 条、查 DrawFrame 是 0 条、查 android_frames 是 0 行。这不是语法错,而是FrameTimeline 与 atrace 应用侧切片本来就是两路独立采集的数据:缺了后者,前者照样完整。不知道这件事的人,会把"表空"当成自己的查询问题。

把这三件事并成一句题眼:掉帧不是感觉,是一个可判定、可归因、也会被数错的时间差。 九章顺着它排开:零章立边界,一、二章建坐标系,三章给判据,四章给归因,五章讲标准库算了什么,六章讲怎么问,七章讲哪里取不到、哪里取错,八章收口。

0.2 全篇知识地图

下面这张表是后面九章的顺序与产出。它不是并列的知识点,而是一条坡道——顺序不能颠倒。

章节 在回答什么 你会拿到什么
零、点题与边界 这一篇讲什么? 知识地图 + 版本门槛 + 术语双语表 + 边界声明
一、一帧的生命周期 一帧由哪几段构成? vsync → doFrame → DrawFrame → SF 合成 → 上屏 的分段 + token
二、两条时间线 "应该"与"实际"各指什么? expected / actual 的定义差、取 max 的终点、Flow events
三、判据 晚了多少才算掉? overrun 公式与正负、四档严重度、present_type 取值面
四、归因 掉在谁身上? 8 条目 × 判据 × 责任方矩阵、分流树、文档与实现的不一致
五、标准库替你算 官方已写好什么? 四模块分工、android_frames 列结构、SDK 28 分水岭
六、把一帧查出来 该问哪些问题、答案在哪列? 三段可直接跑的查询 + 列选择速查
七、边界与陷阱 哪里会取不到、哪里会取错? 陷阱矩阵:was_jank 少报、两路数据、内置视图为空
八、收口清单与结语 下次遇到同类问题怎么办? 判据 / 归因 / 查询 三列矩阵 + 下一站方向

这九章里,第一、二章最要紧。 后面的判据都建立在"一帧由哪几段构成"这个坐标系上——overrun 是两个时间点相减,present_type 描述终点落在哪。坐标系画错,后面每一步都在错地基上算。

0.3 这道题有版本门槛:FrameTimeline 的演进脉络

先说年份口径:下表年份以该能力进入 AOSP 主线或官方文档体系的可考时间为准,取"面向开发者的公开时间",不是内部提交时间。

阶段 时间 关键变化 留下来的痕迹
帧指标前史 Android 9(SDK 28)及之前 只有 Choreographer#doFrame / DrawFrame 切片,帧要靠时间窗自己拼 今天 timeline_maxsdk28 的老路径
平台侧帧指标 Android 7.0(API 24,FrameMetrics) 应用侧可自己拿到一段帧的耗时分解 android_cpu_time_per_frame 的按帧聚合思路(源码标注 Availability: from N (API 24))
FrameTimeline 落进 SF Android 12(S) 官方明说 需要 Android 12(S)或更高;它是 SurfaceFlinger 内部模块,检测 jank 并报告来源 原生表 expected_frame_timeline_slice / actual_frame_timeline_slice
标准库把它包起来 2024 年(android/frames/*.sql 版权头) 四个模块把"拼帧 / 算超期 / 归类责任方"全部封装 今天你在 SQL 里用的 android_frames / android_frame_stats
文档与实现不再同步 近年 官方页给 8 个条目,jank_type.sql 的 GLOB 判据已多出三个取值,且真数据里已出现 本篇第四章的核心论据

年份口径与出处(本轮逐行复核,不留未证年份):三处此前没取到一手依据的时间,本轮这样落定——① Android 7.0 与 FrameMetrics:官方 android.view.FrameMetrics 参考页对每个常量逐条标注 Added in API level 24(即 Android 7.0),且 per_frame_metrics.sql 对 android_cpu_time_per_frame 明写 Availability: from N (API 24)——官方页与源码一致,故写作 Android 7.0(API 24),来源 developer.android.google.cn/reference/android/view/FrameMetrics(镜像站,.com 直链本机不可达)。② "前史"一行:原写"Android 6.0 到 9"无一手可考,改用有源码依据的下界——标准库给老路径的模块名是 android/frames/timeline_maxsdk28.sql,而 timeline.sql 的分叉判据是 iif((SELECT count(1) FROM actual_frame_timeline_slice) > 0, "after_28", "maxsdk28"),即没有 FrameTimeline 数据时走 maxsdk28,故写作 Android 9(SDK 28)及之前。③ "标准库把它包起来"一行:原写"2020 年以后"同样无一手依据,改用该目录源码的版权头(Copyright 2024 The Android Open Source Project,见 android/frames/timeline.sql / per_frame_metrics.sql / jank_type.sql 四文件的文件头),来源 raw.githubusercontent.com/google/perfetto/main/.../android/frames/。"FrameTimeline 落进 SF"一行(Android 12/S)有官方一手页原文(Perfetto frametimeline 页:NOTE: FrameTimeline requires Android 12(S) or higher),末行有本轮实测支撑。

最后一行值得单独说一句:文档与实现不一致,而实现才准。 官方页写明 jank 类型定义出自 JankInfo.h,列了 8 个条目;而本机读到的源码里,判断"这算不算应用侧 jank"用的是 GLOB 模式,其中 App Resynced Jitter、SurfaceFlinger Scheduling、Dropped Frame 都不在那 8 条清单里。更关键的是,在 android_startup_real.perfetto_trace 上真实取到了 SurfaceFlinger Scheduling 3 次、Dropped Frame 2 次——这不是理论上的额外值,是真数据里就会出现的取值。

0.4 术语双语对照(先备着)

先来看看这套术语的双语对照。 后面几章会密集出现官方术语原名:

中文 English 一句话
期望时间线 expected FrameTimeline 调度器给出的"这一帧该在什么时间窗内完成"
实际时间线 actual FrameTimeline 应用这一帧实际花掉的时间窗
上屏时刻 present time 帧真正出现在屏幕上的时刻
卡顿类型 jank type 这一帧被判为卡顿后,官方给出的原因归类
垂直同步 vsync 显示刷新与绘制对齐的心跳信号
合成器 SurfaceFlinger Android 里把各层合成为一屏的系统进程
编舞者 Choreographer 应用侧接收 vsync、驱动一次绘制的调度入口
超期 overrun 实际完成时刻减去期望完成时刻的差值
帧标识 frame token 贯穿应用、渲染线程与合成器的同帧对齐标识
预测类型 prediction type 调度器这次预测是否仍然有效

一句话记住方法:先看这一帧属于哪两条轨道,再问它超了多少,最后问超在了谁身上。

0.5 边界声明与量化锚点

边界在本篇开篇说一次,第八章会再确认一次:

  • 不涉及 trace 文件的内部结构。 帧数据以哪种 packet、哪些字段写下来,是 ep07 的全部内容。
  • 不涉及数据来源与采集配置。 哪个源、怎么配、谁写进 ring buffer,是 ep08 的全部内容。
  • 不复述 SQL 语法与调优。 CREATE PERFETTO TABLE / INDEX 的判据、slices.* 家族、SPAN_JOIN、2 × S × N 内存模型、命令行四模式,ep09 已讲透,本篇只讲"帧该问哪些问题、答案在哪一列"。
  • 不涉及下一篇的题域。 ep11 即第 十一 篇《实战一:一条链路揪 GPU 瓶颈》,本篇不做标题级预告。
  • 只讲帧这一题域的判据与归属,不讲这套判据怎么被上层业务指标消费(那是 cujs / startup 的事)。

本篇要拿下的量化锚点有六个,后文逐一对上:60 Hz 下一帧预算 16.7 ms(官方语境常以 16 ms 作红线);官方示例里应用侧 expected 的 dur 是 20.5 ms、SF 侧是 10.5 ms——期望窗口不是恒定值;官方 jank 条目 8 个(None + 7 类);严酷度四档阈值 20 / 50 / 200 ms;在 wattson_jank_cuj.pb 实测 overrun 区间 −13.87 ms 到 +2.08 ms;本机 trace_processor_shell 版本 v58.2-add693d8b。最后两个是"这确实是真跑出来的"的证据。

最后提一句题图:它截的就是官方文档里那个"每个上过屏的应用都会多出两条轨道"的画面[^ext1]。后面每章讲的,都是这两条轨道上的事。


一、一帧的生命周期:把"卡"切成可测量的段

下面我们开始拆第一层。 这一章不讲判据,只做一件事:把"一帧"切成能分别计时、分别追责的几段。后面的"晚了",都得先知道"晚在哪一段"。

1.1 心跳从哪里来:vsync 与应用的接收点

屏幕按固定节奏刷新,垂直同步(vsync)就是这条节奏线。显示端每个刷新周期发一次 vsync,应用侧收到后准备下一帧,合成器在下一个刷新点把准备好的内容推上屏。60 Hz 屏上这条线每 16.7 ms 走一次,90 Hz 与 120 Hz 更短——这就是"目标帧率要随刷新率调整"的由来,也是用固定 16.7 ms 去衡量 120 Hz 设备必然出错的原因。

应用侧接住心跳的入口是编舞者(Choreographer),它收到 vsync 后回调应用,这条回调用切片记下来就是 Choreographer#doFrame;较新的实现里还有一条等价入口 AChoreographer_vsyncCallback——两者在帧的语义上是同一段,只是调用形态不同。这一段的开始时刻很关键:官方对"实际时间线"起点的定义,正是"Choreographer#doFrame 或 AChoreographer_vsyncCallback 开始运行的时刻"。

1.2 第二段:渲染线程里的 DrawFrame

言归正传,接着看渲染线程(RenderThread)。真正向 GPU 提交绘制指令的是它,切片名是 DrawFrame。这里有个易漏的官方提示:一个 vsync 可能对应多个 DrawFrame——比如同时绘制状态栏与通知栏。这一点后面会在真数据上看到(wattson_jank_cuj.pb 上有 32 帧 draw_frame_count = 2)。

DrawFrame 这一段包含 GPU 的工作。官方对"实际时间线"的定义特意强调:应用"实际完成该帧"的时间是含 GPU work 的。所以 DrawFrame 很长时,可能不是 CPU 在忙,是 GPU 还没交活。

1.3 第三段:合成与上屏

帧画完要被交给合成器(SurfaceFlinger)合成,再推给显示硬件。官方对 SF 侧轨道的描述是:SF 的 slice 代表从 SurfaceFlinger 主线程开始,到屏幕上完成一次更新的区间,同时涵盖 Composer 与 DisplayHAL。这一段最容易出"看起来与应用无关、实际影响上屏"的问题——颜色码里的黄色(帧卡了但应用不是主因)指的就是这类。

把三段合起来,就是本章的坐标系:

帧生命周期的三个可测区间:应用侧 doFrame(vsync 心跳之后回调被调度运行)、渲染线程 DrawFrame(含 GPU work)、合成侧 SurfaceFlinger 合成到上屏,每段都对应 trace 里可查的切片名
图 10-1一帧的三个可测区间:应用侧 doFrame / AChoreographer_vsyncCallback → 渲染线程 DrawFrame(含 GPU work)→ 合成侧 SurfaceFlinger 合成与上屏;跨进程的接缝只有一处@ 本系列绘制

这张图要留意的是"分界线不是进程边界"。 三个区间里跨进程的接缝只有一处:应用侧 doFrame 与渲染线程 DrawFrame 都在应用进程内,应用把帧交出去(post)之后,工作才转到 SurfaceFlinger 进程,合成与上屏都在那一侧。所以跨进程归因必须把合成侧那一轨拉进来对照——这也是 actual_frame_timeline_slice 里会有 layer_name 与 upid 的原因。

1.4 两条轨靠什么对齐:同一个 token

应用侧那么多帧、合成侧也那么多帧,凭什么知道哪个应用帧对应哪个合成帧?答案是帧标识(frame token)。官方机制是:期望/实际时间线里每一段的切片名就是从 Choreographer 拿到的那个 token;同一个 token 还会被加到应用侧的 doFrame 与渲染线程的切片上;而在合成器那一侧,你会在 onMessageReceived 上看到同名 token。

官方实景:SurfaceFlinger 侧的同名 token 出现在 onMessageReceived 上——合成器与应用靠同一个 token 把同一帧对齐
图 10-2SurfaceFlinger 侧的同名 token 出现在 onMessageReceived 上——合成器与应用侧靠同一个 token 把同一帧对齐@ Perfetto 官方文档(Google,Apache-2.0)

上面这张官方实景图(原图长边 621 px,按原理小图处理)来自 FrameTimeline 数据源文档页[^ext2]。没有 token,两轨就只能靠时间戳硬凑;有了 token,同帧的关联是一个等值条件。

1.5 用一条查询把分段读出来

概念讲完,落到一次真实查询上。下面这段 SQL 把某个进程的三段切片并排拉出来,它是本章的落地动作,不是判据:

-------------------------------------【帧的三段切片:先看清原料】-----------------------------------
-- 描述:按切片名把一帧的三段素材捞出来,确认这份 trace 里到底有哪些帧原料
-- 依据:perfetto.dev/docs/data-sources/frametimeline 与 perfetto.dev/docs/analysis/sql-tables
--------------------------------------------------------------------------------------------------
SELECT
  s.ts,
  time_to_ms(s.dur) AS dur_ms,                       --【1】统一换算成毫秒,避免手写零的个数
  s.name,
  t.name AS thread_name                              --【2】带上线程名,一眼看出是主线程还是渲染线程
FROM slice AS s
JOIN thread_track AS tt ON s.track_id = tt.id
JOIN thread AS t USING (utid)
WHERE s.name GLOB 'Choreographer#doFrame*'            --【3】应用侧的第一段
   OR s.name GLOB 'DrawFrame*'                        --【4】渲染线程的一段,注意可能一帧多条
   OR s.name GLOB 'AChoreographer_vsyncCallback*'
ORDER BY s.ts
LIMIT 50;

下面对【1】【3】【4】特别说明。【1】是 ep09 反复强调的纪律:时间单位是纳秒,手写换算迟早多写或少写一个零。【3】与【4】是本章最容易误判的地方——这两个模式串匹配的条数不能直接当帧数用。本机实测 wattson_jank_cuj.pb:Choreographer#doFrame* 有 466 条、DrawFrame* 有 252 条,而真正的帧数是 android_frames 的 220 行。条数比帧数多,因为"一个 vsync 可能有多个 DrawFrame",也因为同名模式会捞到变体切片。想要"帧"这个粒度,就得交给标准库去拼(第五章)。

这一章收在这里。 第一章走完,手里该有三件事:一帧有三个可测区间(应用侧、渲染线程、合成侧)、跨进程的接缝只有一处、靠 token 对齐、切片条数不等于帧数。

本章对应的源码:帧归属与 token 解析在 android/frames/timeline.sql,其中 _get_frame_table_with_id() 用 STR_SPLIT(name, ' ', 1) 从切片名里抠出帧标识,并显式过滤掉 frame_id != 0 与 frame_id != -1 两种无效值——这是"为什么有些帧你永远查不到"的第一个伏笔。


二、两条时间线:一条写"应该",一条写"实际"

好了,坐标系有了,我们来看系统为每一帧准备的两把尺子。 FrameTimeline 不满足于记录"这一帧花了多久",而是同时记录两个时间窗:调度器认为它该在什么窗口里完成,以及它实际在什么窗口里完成。两把尺子一减,就有了判据。

2.1 官方定义:两轨的起点与语义

期望时间线(expected FrameTimeline):每一段代表给应用渲染这一帧的时间;它的开始时刻是 Choreographer 回调被调度运行的时间。也就是说,expected 的起点不是"应用开始干活",是"系统打算让应用开始干活"——它由调度器给出,是一份计划。

实际时间线(actual FrameTimeline):每一段代表应用实际完成这一帧(含 GPU work)并交给合成器的时间;它的开始时刻是 Choreographer#doFrame 或 AChoreographer_vsyncCallback 开始运行的时间。

两条定义摆在一起,差别就出来了:两轨的起点口径不同。一旦系统忙、线程没被及时唤醒,这两个时刻就会分开——分开多少本身就是一条信息(第五章会看到标准库为此造的 android_app_vsync_delay_per_frame 视图)。

expected 与 actual 的定义对照:左右两栏分别是调度器给出的期望窗口与应用实际完成窗口,逐条对照两轨起点口径,并给出 actual 终点取 GPU 与 post 较大者的规则
图 10-3expected 与 actual 的定义对照:上轨起点是"被调度",下轨起点是"真开始",两轨终点相减即得超期量@ 本系列绘制

2.2 expected 不是一个固定的 16.7 毫秒

先看看 expected 的长度。不少人第一次听说 FrameTimeline,会默认"expected 就是 16.7 毫秒"——这个默认是错的,而且错得很自然:16.7 ms 是"屏幕刷新间隔",不是"系统分配给某个应用完成一帧的时间窗"。

官方示例给了两个真实取值:应用侧 expected 的 dur 是 20500000 ns,也就是 20.5 ms;同一时刻合成器侧的是 10500000 ns,也就是 10.5 ms(示例时间戳从 60230453475 起算,单位纳秒)。两个都不是 16.7 ms:应用侧比一个刷新周期长,合成侧比一个刷新周期短。

后果很直接:判据不能写成"dur > 16.7 ms 就是掉帧"。 正确的判据不是比长度,是比两个窗口谁更靠后——第三章的 overrun 就是这么定义的。调度器之所以能给应用一个 20.5 ms 的窗口,是因为它在按当前刷新率、当前负载与预测的呈现时刻动态分配;这也是 prediction type 单列成一栏的原因。

2.3 actual 的终点:为什么要取 max

接下来是本章最容易算错的一处:actual 的终点不是 DrawFrame 的结束时刻。

官方的写法是:actual 的 end time = max(gpu time, post time),其中 post time 是应用把帧 post 给 SurfaceFlinger 的时刻。为什么取大者?因为帧真正"完成"的标志是两件事都做完:GPU 画完了,应用也把这一帧交出去了。

两种典型反例,各记一笔:

  • CPU 早交出去了,GPU 还在跑:post time 小、gpu time 大,取 max 后 actual 被拉长——只看 DrawFrame 耗时,会得出"这帧很快"的错误结论。
  • GPU 早画完了,应用迟迟不 post:gpu time 小、post time 大,对应的是"UI 线程状态更新没及时推给渲染线程",也就是颜色码里应用侧那个蓝色(Dropped Frame)描述的场景。

一句话记住这一节:actual 的终点是帧"具备被合成条件"的时刻。

2.4 token 在两轨上的落点

原生两张表各有一对 token 列:

表 应用侧标识 合成侧标识
expected_frame_timeline_slice surface_frame_token display_frame_token
actual_frame_timeline_slice surface_frame_token display_frame_token

也就是说,同一个 token 会同时出现在多处:期望轨与实际轨的同一行上;在应用侧 doFrame 与渲染线程的切片名里;在合成器那一侧,它出现在 onMessageReceived 上。把这四处置于同一张图上,就能"顺着一个 token 横穿三段"。

token 对齐链:同一个 token 的四处落点——两轨切片名、应用侧 doFrame、渲染线程 DrawFrame 与合成器 onMessageReceived,说明为什么同一帧可以被跨进程关联
图 10-4token 对齐链:同一个 token 同时挂在应用侧 doFrame、渲染线程 DrawFrame 与合成器 onMessageReceived 上,这是跨进程归因的等值条件@ 本系列绘制

2.5 跨进程那一跳:Flow events 是怎么画出来的

下面我们开始看跨进程那一跳。UI 上怎么知道应用侧这一段对应合成侧那一段?答案是 Flow events(流事件),官方给了两条方向相反的机制:选中应用侧的 actual slice,UI 会画出连线指向对应的合成器切片;选中合成侧的 DisplayFrame,UI 会画出箭头指向被一起合成的所有帧(可跨进程)。

官方实景:选中应用侧的 actual slice 后,UI 画出连线指向对应的合成器时间线切片——跨进程归因的可视化机制
图 10-5选中应用侧的 actual slice 后,UI 会画出连线指向对应的合成器时间线切片——这就是跨进程归因在界面上的样子@ Perfetto 官方文档(Google,Apache-2.0)

这张官方实景图(原图 1237×714)[^ext3] 证明了一句常被当成"玄学"的话:"这一帧到底怪谁"在 UI 上是有可视证据的。箭头从应用侧出发落到合成侧;红色落在应用那侧,锅在应用;落在合成侧,锅在系统。

2.6 一次把两轨并排捞出来

落到查询上:下面这段 SQL 把应用侧两轨并排放在一张结果表里,回答"两轨窗口分别多长、错开多少",不是"掉没掉":

-------------------------------------【两轨并排:看窗口长度与错位量】---------------------------------
-- 描述:应用侧的期望窗口 vs 实际窗口逐帧对照,错位量即 app_vsync_delay 的来源
-- 依据:perfetto.dev/docs/data-sources/frametimeline 的官方示例(两表 LEFT JOIN process 取进程名)
----------------------------------------------------------------------------------------------------
SELECT
  exp.ts                                   AS exp_start,
  time_to_ms(exp.dur)                      AS exp_ms,          --【1】期望窗口长度(官方示例里是 20.5 ms 量级)
  act.ts                                   AS act_start,
  time_to_ms(act.dur)                      AS act_ms,          --【2】实际窗口长度
  time_to_ms(act.ts - exp.ts)              AS start_delay_ms,  --【3】两轨起点错位量
  p.name                                   AS process_name
FROM expected_frame_timeline_slice AS exp
JOIN actual_frame_timeline_slice  AS act
  ON exp.surface_frame_token = act.surface_frame_token            --【4】按应用侧 token 对齐,而不是按时间戳
LEFT JOIN process AS p USING (upid)
ORDER BY exp.ts
LIMIT 20;

下面对【3】【4】特别说明。【3】是 2.1 节"起点口径不同"的可观测形态:两者相减就是被耽搁的时间;标准库有一个视图专干这件事,但它的注释与实现并不一致,这个矛盾留到 5.4 节说。【4】是重点:对齐要用 token,不要用时间戳。用时间戳去凑("起点差 1 ms 内就算同一帧")不忙时看着能用,一忙就张冠李戴;token 是等值条件,不存在"凑"。

这一章收在这里。 四个结论:expected 起点是"被调度"、actual 起点是"真开始";expected 的长度不是恒定的 16.7 ms;actual 的终点要取 GPU 与 post 的较大者;两轨靠 token 对齐,跨进程那一跳靠 Flow events 显示。

本章对应的官方依据:两轨语义与 Flow events 见 FrameTimeline 数据源页,两表列定义见 SQL 表参考。


三、判据:超了多少才算掉

下面我们进入本篇的核心。 这一章回答三个问题:掉了多少(overrun)、掉了算多严重(四档阈值)、这一帧的终点落在哪一档(present_type 与它的同伴列)。

3.1 overrun:一个减法,两种符号

判据只有一行减法,定义在 android_frames_overrun 视图里:

overrun = (actual.ts + actual.dur) − (expected.ts + expected.dur)

其中 actual.ts + actual.dur 是 actual 轨的终点,expected.ts + expected.dur 是 expected 轨的终点。两条终点一减,符号即结论。源码注释很直白:这个值为负表示没有错过截止时间;只有当 overrun 为正时,这一帧才被认为是卡顿帧。

overrun 的公式与符号判定:以零点为界分成两侧,左侧终点之差小于等于零为未超期,右侧大于零才判为卡顿,图下给出公式并附本机实跑的帧数
图 10-6overrun 的公式与符号判定:只有终点之差为正才算卡顿,负值代表按时完成@ 本系列绘制

这张图要挡住一个常见的误读:把"负的 overrun"当异常值。 它不是异常,是"提前完成"。在 wattson_jank_cuj.pb 上的完整分布是:215 帧有 overrun 值,其中 > 0 的 42 帧、≤ 0 的 173 帧;最小值 −13,873,287 ns(−13.87 ms),最大值 +2,078,702 ns(+2.08 ms)。负到 −13.87 ms 说明相当一部分帧是提前完成的——若用"绝对值大于某阈值"筛异常,这 173 帧会被全部误判。

说白了,判据就是两个终点相减。

3.2 四档严重度:别把阈值写进自己的 SQL

判据不止"掉没掉"一档。另三档阈值都写在 android_frame_stats 里:

列 判据(源码原文) 含义
was_jank overrun > 0 错过了截止时间
was_slow_frame cpu_time > 20 ms 帧的 CPU 时间超过 20 毫秒
was_big_jank cpu_time > 50 ms 超过 50 毫秒
was_huge_jank cpu_time > 200 ms 超过 200 毫秒

这张表有两个陷阱。 一是量纲:第一档判的是 overrun(与截止时间比),后三档判的是 cpu_time(CPU 耗时)——两把不同的尺子,不能混着读成"四档超期"。二是阈值别手写:官方源码用的是函数式毫秒换算,照抄"200"或"200000000"这样的裸数字,单位口径一变就是静默错误。顺带把原生表上那列 jank_severity_type 也盘了一遍(本篇不用它,但要知道它跟上面四档不是一回事):本机在 android_startup_real.perfetto_trace 的 2028 条 actual 帧上取到 4 个取值——None 1921、Partial 92、Full 11、Unknown 4;它判的是"这一帧该不该算严重 jank"的平台侧档位,与 android_frame_stats 里那四个基于 overrun / cpu_time 的布尔列不是同一套口径,别混用。

cpu_time 本身还有一层估算:算不出精确 CPU 时间时,标准库退回一个估值,规则是 ui_time + 5 ms(UI 时间加 5 毫秒),用在一个叫 _estimated_cpu_time_per_frame 的内部表上。意义在于:was_slow_frame 这类判据在回退帧上不是严格测量值,是估值。 写结论时说清这一点,比报一个漂亮数字更重要。

3.3 present_type 与它的两个同伴列

overrun 回答"超了多少",present_type 回答"这一帧落在哪一档"。官方详情面板里这一栏的常见取值是三个:Early Present(早于预测呈现)、On-time Present(按时呈现)、Late Present(晚于预测呈现)。它与另两列配合使用:

  • on_time_finish:应用是否按时完成了这一帧的工作——它判的是"应用的工作完成时刻",与 present_type 判的"上屏时刻"是两件事;
  • prediction_type:调度器这次的预测是否仍然有效,取值是 Valid Prediction(预测有效) 与 Expired Prediction(预测已过期)。

这一栏的组合才是归因的入口。 在 wattson_jank_cuj.pb 的 217 条 actual 帧上取到:present_type 是 On-time Present 216 条 + Late Present 1 条;on_time_finish 是 1 出现 216 次、0 出现 1 次;prediction_type 全部是 Valid Prediction。也就是说这份 trace 的预测没有漂移,唯一那条晚呈现的帧,应用侧也确实没按时完成——两列的信息是对上的。

但取值面并不干净。官方页只给了 Early / On-time / Late 三态,而在最大样本 android_startup_real.perfetto_trace(2028 条 actual 帧)上把 present_type 分组统计,得到 4 个取值:On-time Present 1921、Late Present 103、Unspecified Present 2、Dropped Frame 2——后两个都不在官方三态里(Early Present 在这三份 trace 上一条都没出现,说明"官方列了"与"真数据会有"是两件事)。交叉表看得更清楚:Late Present 那 103 条对应的是 Buffer Stuffing / App Deadline Missed / SurfaceFlinger Scheduling;Unspecified Present 的两条恰好都带 Expired Prediction;而 Dropped Frame 那两条的 jank_type 与 jank_tag 也同为 Dropped Frame。这就是本篇反复出现的同一种现象:文档列的是"常见值",不是"完备枚举"。 用等值比较去圈这几个取值,会漏掉未列出的形态。

present_type 三列取值对照:第一列列出官方给出的三个呈现档位,并补上真数据里额外取到的 Unspecified Present 与 Dropped Frame 两个取值,第二列是应用是否按时完成,第三列是预测是否仍有效
图 10-7present_type 的取值面与两个同伴列:官方三态 + 真数据多出的两个取值、应用是否按时完成、预测是否仍有效,三者组合才是归因入口@ 本系列绘制

3.4 官方详情面板:这一章的字段全在上面

官方给了一张详情面板截图,上面就是本章这几列在界面上的样子:Present Type(呈现类型)、On time finish(是否按时完成)、Jank Type(卡顿类型)、Prediction type(预测类型)、GPU Composition(是否 GPU 合成)、Layer Name(层名)。

官方实景:选中一条 actual slice 后的详情面板,逐项列出呈现类型、是否按时完成、卡顿类型、预测类型、GPU 合成与层名
图 10-8选中一条 actual slice 后的详情面板:呈现类型、是否按时完成、卡顿类型、预测类型、GPU 合成与层名——本篇第三章与第四章讲的列,全在这一屏上@ Perfetto 官方文档(Google,Apache-2.0)

这张详情面板(原图 1173×604,按原理小图处理)[^ext4] 值得与 3.1 节的公式对着看。 面板上有的字段,SQL 里都有对应列,这就是"UI 与命令行问的是同一份数据"的证据(actual_frame_timeline_slice 共 25 列,与判据相关的有 present_type、on_time_finish、gpu_composition、jank_type、prediction_type、jank_tag 等)。

3.5 颜色码:给眼睛看的版本

颜色 含义
绿色 好帧,无 jank
浅绿色 高延迟状态:帧率顺滑,但呈现偏晚,输入延迟变大
红色 卡顿,且该 slice 所属进程是这次卡顿的主因
黄色 帧卡了,但应用不是主因(合成侧造成)——只用于应用侧
蓝色 丢帧:合成侧指"主动跳过该帧、优先更新的帧";应用侧指"UI 线程的状态更新没及时推给渲染线程"

最该记住的是"红与黄的分工":颜色本身就是责任方。 红与黄的区别不在"卡得多严重",在"怪谁";浅绿与绿色同理,区别不在"顺不顺",在"手感好不好"。颜色是给人扫一眼的,判据是给机器算的——对应关系成立,但推导方向不能反。

3.6 把判据跑出来

下面这段 SQL 一次把三件事都算出来,而且不自己写阈值:

-------------------------------------【判据一次到位:超期、严重度、呈现档位】-----------------------
-- 描述:用标准库的视图取判据,不在自己的 SQL 里重现阈值与公式
-- 依据:stdlib/android/frames/per_frame_metrics.sql(raw 全文)与 stdlib/android/frames/jank_type.sql
----------------------------------------------------------------------------------------------------
INCLUDE PERFETTO MODULE android.frames.timeline;          --【1】先引入帧归属,后面所有帧表都建立其上
INCLUDE PERFETTO MODULE android.frames.per_frame_metrics; --【2】再引入判据视图

SELECT
  count()                                            AS frames,
  sum(iif(overrun > 0, 1, 0))                        AS overrun_positive,  --【3】这才是"掉了多少帧"
  min(overrun)                                       AS min_overrun,
  max(overrun)                                       AS max_overrun,
  sum(CASE WHEN was_slow_frame = 1 THEN 1 ELSE 0 END) AS slow_frames
FROM android_frame_stats;                             --【4】四档判据都在这个视图里

下面对【3】【4】特别说明。【3】是本篇最想让人记住的一行:数掉帧要数 overrun > 0,不要数 was_jank。 两者语义上是一件事,但真数据上答案差一个数量级(0.1 节给过三组:236→13、149→0、42→1)。成因不是"哪列更权威",而是能不能算出来——was_jank 所在视图要做帧级 CPU 时间聚合,依赖应用侧切片;切片缺失时,那一帧即使超期也进不了统计(第七章拆开)。【4】是"别重写标准库"的再次体现:阈值、回退估值、聚合口径全在视图里,你只用列名。

这一章收在这里。 overrun 是两轨终点之差,只有为正才算卡顿;严重度另有四档、判的是 CPU 时间;呈现档位看 present_type,配合是否按时完成与预测是否有效;颜色是判据的界面版本。

本章对应的源码:公式、四档阈值与 CPU 时间回退估值都在 android/frames/per_frame_metrics.sql。


四、归因:这一帧掉在了谁身上

好了,判据有了,接下来是它真正的用武之地。 官方把 jank 检测做进 SurfaceFlinger,目的不只是"标出卡顿",而是报告 jank 的来源。这一章把"来源"拆成三件事:官方列了哪些条目、每条的判据是什么、以及文档与实现不一致时该信谁。

4.1 官方条目清单:8 个条目,不是 8 类 jank

这里第一个坑是计数口径:8 个条目里有 1 个是 None(无 jank),剩下 7 个才是真正的 jank 类型。把它们并列成"8 类 jank"是常见口误,这个口误会变成实际错误——None 不该被算进"卡顿帧分类统计"的分母。

条目 一句话判据(官方口径)
None 无 jank
App Deadline Missed 应用自己跑超时:起点是 Choreographer 被唤醒,终点是 GPU 与 post 的较大者
Buffer Stuffing 应用在前一帧尚未呈现时继续送新帧,导致队列积压、至少晚一个 vsync
SurfaceFlinger CPU Deadline Missed 合成器主线程超时:设备合成时含整段合成时间;GPU 合成时含写 draw call 与交 fence
SurfaceFlinger GPU Deadline Missed CPU 时间没超,但 GPU 没按时完成,于是被推到下一个 vsync
Display HAL 合成器按时交给了硬件抽象层,但没能在该 vsync 上屏
Prediction Error 调度器预测漂移(例如预测 20 毫秒、实际 23 毫秒);调度器会自我纠正,孤立漂移几乎感知不到
Unknown 未知原因,例如"超时了但帧仍提前呈现",官方说这种概率极低

最反直觉的一行是 Buffer Stuffing。 官方对它的描述里有一句关键话:它更像一种状态,不是一次 jank。原因是队列积压会让帧继续顺畅地出来(帧率看着没问题),代价是输入延迟变大——手指点下去,屏幕晚一拍才有反应。这就是颜色码里"浅绿色"对应的状态:帧率顺滑、手感变差。只看帧率的看板上,这种劣化完全隐形。

4.2 归因矩阵:条目 × 判据 × 责任方

把 8 个条目按"谁造成的"重排,就是本篇的中心图。判据列取自官方页与 JankInfo.h 的口径,责任方列取自 jank_type.sql 里三个分类函数的实现:

jank 归因矩阵:三列分别是条目、一句话判据与责任方,前 8 行是官方条目,带星标的三个取值只在源码的 GLOB 判据里出现
图 10-9jank 归因矩阵:8 个官方条目 × 判据 × 责任方;带 ★ 星标的三个取值不在官方清单里,但确实出现在源码判据与真数据中@ 本系列绘制

这张矩阵要按住一个观念:判据与责任方是两列,不是一列。 不少人把"卡得多严重"与"怪谁"混成一个判断,看到红帧就认定是应用的问题。读法应该是:先由判据确定"哪一类",再由分类函数确定"记在谁头上"。

4.3 责任方分流:从字符串到归属

先看看三个分类函数。官方 stdlib 文档页给了它们各自的一句话:android_is_sf_jank_type 判断这次卡顿是否由合成器造成;android_is_app_jank_type 判断是否由应用造成;android_is_missed_frame_type 判断该算在合成器、应用,还是 Dropped Frame。

三个函数都不建表,也不依赖具体数据——它们纯粹是字符串分类器。实跑确认了这一点:引入 android.frames.jank_type 后,用 sqlite_master 枚举不到任何新表或新视图,函数调用本身却可用。

责任方分流:从 jank type 字符串出发,分到应用侧、合成器 CPU、合成器 GPU、显示硬件抽象层、预测漂移、未知六支,另有一条单列 Buffer Stuffing 属于状态而非 jank
图 10-10责任方分流树:一个 jank type 字符串如何被分到六类责任方;Buffer Stuffing 单列一支,因为它是状态而不是一次 jank@ 本系列绘制

这棵树上两处容易走反。 一是 Display HAL 与 SurfaceFlinger CPU / GPU 的区别:前者是"合成器按时交出去了,硬件没按时上屏",后者是"合成器自己就没按时"——同样表现为上屏晚,责任方一个在系统硬件链路,一个在合成器进程。二是预测漂移:官方明确说调度器会自我纠正、孤立漂移几乎感知不到,所以单列一类,不当成"应用或系统的锅"。看到 Prediction Error 就去找应用代码,是白费力气。

4.4 文档与实现不一致:这一节是本篇最硬的地方

下面我们进入本篇最值得单独拎出来的一节。 官方页给的条目是 8 个,而读到的 jank_type.sql 里,三个分类函数用的是 GLOB 模式匹配,模式里多出了三个官方清单没有的取值。

分类函数 源码里的 GLOB 判据 官方 8 条目清单里有没有
android_is_sf_jank_type *SurfaceFlinger CPU Deadline Missed*、*SurfaceFlinger GPU Deadline Missed*、*SurfaceFlinger Scheduling*、*Prediction Error*、*Display HAL* 前两个有,SurfaceFlinger Scheduling 没有
android_is_app_jank_type *App Deadline Missed*、*App Resynced Jitter* 前者有,App Resynced Jitter 没有
android_is_missed_frame_type 前两个函数的结果,或 *Dropped Frame* Dropped Frame 没有

关键不是"文档漏了三个词条",而是这三个词条在真数据里真的会来。 在 android_startup_real.perfetto_trace 的 2028 条 actual 帧上统计 jank_type 真值:

jank_type 真值 条数 在官方 8 条目清单里
None 1921 在
Buffer Stuffing 89 在
App Deadline Missed 12 在
SurfaceFlinger Scheduling 3 不在
Dropped Frame 2 不在
App Deadline Missed, Buffer Stuffing 1 两值各自在,但这是逗号拼接的多值形态

这张表一口气钉住三件事。 第一,SurfaceFlinger Scheduling 与 Dropped Frame 各出现 3 次与 2 次——实现侧多出的取值不是理论可能,是实拍;这也解释了那三个 GLOB 的来历:实现比文档更新,而实现才准。 第二,同一个 jank_type 可以是逗号拼接的多值,于是等值比较会漏——WHERE jank_type = 'App Deadline Missed' 会漏掉那 1 条多值帧,而 GLOB '*App Deadline Missed*' 才能命中。第三,权威枚举源 JankInfo.h 本轮不可达,本篇只给"截至官方页列为 8 个条目"这一口径,并把真数据反例并排列出——这不是含糊,是把不确定性写在明处。

4.5 函数怎么用:入参是显示形,不是驼峰形

还有一个纯操作层面的坑:这三个函数的入参必须是带空格的显示形态。实跑:android_is_app_jank_type('App Deadline Missed') 返回 1,android_is_sf_jank_type('SurfaceFlinger CPU Deadline Missed') 返回 1;把合成器类判给应用侧返回 0——分类是互斥的,没有"两边都算"的情况。

三函数的真值本轮一次性跑全(android_startup_real.perfetto_trace,v58.2):android_is_app_jank_type('App Deadline Missed')=1、android_is_app_jank_type('SurfaceFlinger CPU Deadline Missed')=0、android_is_sf_jank_type('SurfaceFlinger CPU Deadline Missed')=1、android_is_sf_jank_type('App Deadline Missed')=0、android_is_missed_frame_type('App Deadline Missed')=1、android_is_missed_frame_type('Dropped Frame')=1、android_is_missed_frame_type('Buffer Stuffing')=0、android_is_missed_frame_type('None')=0、android_is_app_jank_type('App Resynced Jitter')=1、android_is_sf_jank_type('SurfaceFlinger Scheduling')=1。其中 android_is_missed_frame_type('App Deadline Missed')=1 一项,是为消除两份记录的自相矛盾而专门复跑的(研究笔记与素材清单曾分别记 0 与 1);实跑定论为 1,且这个结果在实现上是必然的——is_missed 的定义就是 is_sf($x) OR is_app($x) OR $x GLOB '*Dropped Frame*',App Deadline Missed 会先被 is_app 命中。后两项(App Resynced Jitter、SurfaceFlinger Scheduling)则再次坐实 4.4 节:官方清单外的取值不只存在于 GLOB 模式里,函数对它们就是返回真。

-------------------------------------【责任方分类:入参必须带空格】-----------------------------------
-- 描述:三个分类函数是纯字符串分类器,不建表、不依赖数据;入参用显示形
-- 依据:stdlib/android/frames/jank_type.sql(raw 全文,本轮已读到)
----------------------------------------------------------------------------------------------------
INCLUDE PERFETTO MODULE android.frames.jank_type;                    --【1】只说 3 个函数,没有表

SELECT
  android_is_app_jank_type('App Deadline Missed')                  AS a1, --【2】应用侧类 → 1
  android_is_app_jank_type('SurfaceFlinger CPU Deadline Missed')   AS a2, --【3】合成器类不该判给应用 → 0
  android_is_sf_jank_type('SurfaceFlinger CPU Deadline Missed')    AS a3, --【4】合成器类 → 1
  android_is_missed_frame_type('Dropped Frame')                    AS a4, --【5】官方清单外,但算 missed frame → 1
  android_is_missed_frame_type('Buffer Stuffing')                  AS a5, --【6】状态而非 jank,不算 missed → 0
  android_is_missed_frame_type('None')                             AS a6  --【7】无 jank,不算 missed → 0

下面对【2】【5】特别说明。【2】的重点是形态:'App Deadline Missed' 带空格才对,写成 'AppDeadlineMissed' 不匹配——因为源码用的是 GLOB 模式 *App Deadline Missed*,模式里的空格是字面量。这个坑很隐蔽:你会先看到"函数返回 0"而怀疑逻辑,其实只是字符串没写对;而 jank type 往往是从列里读出来的,一旦 SQL 中间做过字符串加工(去空格、转驼峰),分类就静默失效。【5】再一次印证 4.4 节:Dropped Frame 不在官方清单里,却在 android_is_missed_frame_type 的判据里,真数据里出现 2 次——要数"丢帧",就得把它算进去。

4.6 合成器侧的跨进程箭头

官方给了一张实景图:选中 DisplayFrame 之后,UI 会画出箭头指向被一起合成的所有帧——可以跨进程、可以来自不同应用。

官方实景:选中 DisplayFrame 后画出箭头指向被一起合成的所有帧,箭头可跨进程——合成侧归因的入口
图 10-11选中 DisplayFrame 后,UI 画出箭头指向被一起合成的所有帧(可跨进程)——这就是"合成侧责任落到具体帧"的入口@ Perfetto 官方文档(Google,Apache-2.0)

这张图(原图 1414×528,长边达标)[^ext5] 补上了分流树的另一半。 树告诉你"锅在合成侧",它告诉你"合成侧那一帧同时拖累了哪些应用帧"——一屏合成迟到,往往是多个应用帧一起受影响。这也是颜色码把"黄色"单列的原因:帧卡了但应用不是主因。

这一章收在这里。 四条:官方清单 8 个条目(含一个 None);判据与责任方是分开的两列,桥是 jank type 字符串;Buffer Stuffing 是状态不是 jank,伤的是输入延迟而不是帧率;文档与实现不一致时以实现为准。

本章对应的官方依据:条目清单与判据见 FrameTimeline 数据源页;三个分类函数见 标准库文档索引。


五、标准库替你算:四个模块与一道分水岭

下面我们来看看这套判据背后的工程量。 帧要拼、token 要解析、多 layer 要归并、SDK 版本要分叉——这些活都由 android.frames.* 包了。本章回答:它们分了几个模块、各自管什么、为什么你不该自己从 slice 表里手抠帧号。

5.1 四个模块的分工(本机实跑枚举)

帧题域不是"一个 timeline.sql",而是一个目录、四个功能模块加一个构建文件:

模块 本机实跑新增对象数 管什么
android.frames.timeline 7 个 帧归属:把三段切片拼成"帧",并给出 android_frames 等表
android.frames.per_frame_metrics 9 个 判据:overrun、ui_time、cpu_time、四档严重度
android.frames.jank_type 3 个(且全是函数,无表) 责任方分类
android.frames.timeline_maxsdk28 供回退路径使用 没有 FrameTimeline 数据时,按老办法拼帧

最值得记的是第二列。 第一行引入的 7 个对象里,4 个是能直接用的表(android_frames、android_frames_layers、android_frames_choreographer_do_frame、android_frames_draw_frame),2 个是内部表与内部函数,1 个是函数式入口(android_first_frame_after)。第二行的 9 个对象里有 4 个内部表。第三行最干净:引入 android.frames.jank_type 后,sqlite_master 里查不到任何新表或新视图——它只提供函数,与官方文档页"该模块只有 3 个函数、没有表"一致。这是"文档与实现一致"的正例,可与 4.4 节的反例并置。

标准库四模块分工与依赖:timeline 在下方负责帧归属,per_frame_metrics 在其上算判据,jank_type 只提供三个分类函数,timeline_maxsdk28 作为没有 FrameTimeline 数据时的回退路径,并标出各模块引入的对象数
图 10-12android.frames.* 四模块的分工与依赖:帧归属 → 判据 → 责任方分类,外加一条 SDK 28 之前的回退路径@ 本系列绘制

5.2 android_frames 的 14 列:帧的"全息档案"

下面我们进入 14 列。本机实跑的 android_frames 是 14 列:frame_id、ts、dur、do_frame_id、draw_frame_id、actual_frame_timeline_id、expected_frame_timeline_id、render_thread_utid、ui_thread_utid、actual_frame_timeline_count、expected_frame_timeline_count、draw_frame_count、upid、process_name。

其中四个 count 类字段是"体检指标":

  • expected_frame_timeline_count 恒为 1。源码注释的原因:"一个 frame_id 的 expected 永远只有一条"。在 wattson_jank_cuj.pb 上验证:220 行全部等于 1。如果哪天它不等于 1,那不是数据有惊喜,是口径搞错了。
  • actual_frame_timeline_count 会取到 0、1、2:分布是 0 出现 103 行、1 出现 115 行、2 出现 2 行。"一帧"并不保证"一定有一条 actual",也不保证"只有一条"。
  • draw_frame_count 会取到 2:分布是 1 出现 188 行、2 出现 32 行,印证官方注释"一个 vsync 可能有多个 DrawFrame"。
  • 同一 frame_id 会落在多个 layer 上:android_frames 220 行 vs android_frames_layers 256 行,差 36 行,而 frame_id 去重计数仍是 220——多出来的全是同一帧的不同 layer。所以"一帧一行"这个直觉是个假象。

5.3 SDK 28 分水岭:两套口径在同一个表名下

这是本篇最关键的一处机制。 标准库的 all_frames 不是一张表,是两条路的并集:一条用 FrameTimeline 数据(Android 12/S 之后),一条用老办法拼帧(SDK 28 及之前)。选哪条由标准库自己判断:

WHERE sdk = iif((SELECT count(1) FROM actual_frame_timeline_slice) > 0, "after_28", "maxsdk28")

意思是:标准库先看"这份 trace 里有没有 FrameTimeline 数据",没有就自动回退到老路径。 善意在于写 SELECT * FROM android_frames 时不必先判断 Android 版本;代价是,同一个表名下面,两条路的 frame_id 口径不一样。

说白了,SDK 分水岭的价值是"让你不必自己判断版本",代价是口径被藏了起来。老路径的 frame_id 是现造的:源码注释写明它用 row_number() OVER () 从 0 开始编号;而 FrameTimeline 路径的 frame_id 来自系统 token,是真实标识符(真数据上是五位以上的大数)。把它们当成同一个东西,会犯两个错:一是"明明看到 frame_id = 5,怎么在另一份 trace 上对不上";二是"把 frame_id 当时间序"——老路径上它恰好递增,FrameTimeline 路径上不是。

SDK 28 分水岭:有帧数据就走 FrameTimeline、没有则回退到 maxsdk28 按时间窗拼帧,两路在同一个表名 all_frames 下合并,老路径的 frame_id 由行号现造
图 10-13SDK 28 分水岭:两套口径在同一个表名下合并,frame_id 的来源不同是最大的坑@ 本系列绘制

5.4 两处官方口径的自相矛盾(别选一个当真理)

读源码时顺手记下两处"官方没说圆"的地方。这两处本轮都核过一遍:一处能在真数据上判成定论并给出取值区间,另一处判定了"注释与实现确实不一致"(正文一律按实现口径算):

  • android_app_vsync_delay_per_frame 的注释与实现不一致(本轮已读到源码全文,判成定论)。 该表定义在 per_frame_metrics.sql,注释两句自相矛盾:第一句写 "The time between the VSYNC-app signal and the start of Choreographer work"(信号→开工),第二句写 "time difference between the actual frame start … and start of the Choreographer#doFrame slice";而实现体是 SELECT frame_id, act.ts - exp.ts AS app_vsync_delay FROM distinct_frames AS f JOIN slice AS exp ON (f.expected_frame_timeline_id = exp.id) JOIN slice AS act ON (f.actual_frame_timeline_id = act.id)——即 actual 轨起点 − expected 轨起点,口径是"被调度到开工",不是注释第一句的"信号到开工"。真数据(wattson_jank_cuj.pb)上该表 117 行,取值区间 0.368 ms ~ 10.527 ms、中位 0.584 ms(v58.2 实跑);它只有 117 行(不是 220)也是内连接的结果,与 7.2 节同源。
  • jank_type.sql 的入参来源(本轮已在真数据上交叉验证,由"存疑"转"结论")。 注释说入参是"来自 args.display_value、key 为 Jank type 的值",而 actual_frame_timeline_slice 本身就有 jank_type 列。本轮直接把两者比出来:以 SELECT count() AS rows_n, sum(CASE WHEN jank_type = extract_arg(arg_set_id,'Jank type') THEN 1 ELSE 0 END) AS equal_rows, sum(CASE WHEN jank_type IS NOT extract_arg(arg_set_id,'Jank type') THEN 1 ELSE 0 END) AS diff_rows FROM actual_frame_timeline_slice 在三份 trace 上实跑,三份均为 equal_rows = rows_n、diff_rows = 0(android_startup_real.perfetto_trace 2028/2028、wattson_jank_cuj.pb 217/217、webview_jank.pb 1031/1031,且 arg_set_id 无空值)。结论:jank_type 列与 args 侧 key=Jank type 是同值关系——注释里那句 args.display_value 说的就是同一份值。这是两处"官方口径自相矛盾"里第一处被实测判定的。
app_vsync_delay 的实测区间:实现口径是 actual 轨起点减 expected 轨起点,本机在 wattson_jank_cuj.pb 上取到最小值 0.368 毫秒、中位数 0.584 毫秒、最大值 10.527 毫秒,三档之间差了近三十倍
图 10-14android_app_vsync_delay_per_frame 的实测区间:最小值 0.368 ms / 中位数 0.584 ms / 最大值 10.527 ms;实现体是 act.ts − exp.ts(被调度 → 开工),注释第一句的"信号 → 开工"量不到这段@ 本机自跑,trace_processor_shell v58.2-add693d8b

5.5 亲手验证"标准库到底建了什么"

下面这段 SQL 不查帧,而是查标准库自己——排查"表名对不上"的第一招:

-------------------------------------【枚举模块引入的对象】------------------------------------------
-- 描述:先 INCLUDE 再枚举,确认模块到底建了哪些表/视图/函数
-- 依据:trace_processor_shell v58.2 实跑(sqlite_master 枚举)
----------------------------------------------------------------------------------------------------
INCLUDE PERFETTO MODULE android.frames.timeline;           --【1】先引入帧归属模块

SELECT type, name                                          --【2】type 区分 table / view / function
FROM sqlite_master
WHERE name LIKE 'android_frames%'
   OR name LIKE '\_frames%' ESCAPE '\'                     --【3】内部表以下划线开头,要单独捞
ORDER BY type, name;

下面对【1】【3】特别说明。【1】的顺序不能反:per_frame_metrics 依赖 timeline。【3】的内幕是:标准库把不打算给你直接用的东西加了下划线前缀(看到的有 _frames_maxsdk_28、_get_frame_table_with_id、_cpu_time_per_frame_fallback 等)。它们可以查,但不是接口——版本升级时名字与列都可能变,写进长期脚本就是定时炸弹。

这一章收在这里。 三件事:帧题域是四模块协作(7 / 9 / 3 个对象);android_frames 的四个 count 列各有反直觉取值;all_frames 是两条路的并集,老路径的 frame_id 是现造行号。

本章对应的官方源码:帧归属与 SDK 分叉在 android/frames/timeline.sql,老路径在 android/frames/timeline_maxsdk28.sql。


六、把一帧查出来:三段查询与列选择

下面我们开始装零件。 前面五章给"是什么"与"为什么",这一章给"怎么问"。三段查询,每段一个问题,跑在真 trace 上,数字全部来自实跑输出。

6.1 查询路径:四层结构,一层一个问题

先看看查询路径,因为"表名对不上"是这类分析里最高频的卡点:

查询路径与列选择:自下而上四层——原生两张 timeline 表、android_frames 帧归属、android_frame_stats 判据、三个责任方分类函数,一层回答一个问题
图 10-15查询路径与"问什么列"的决策图:原生两表 → 帧归属 → 判据视图 → 责任方分类函数,四层各答一个问题@ 本系列绘制

读法是"自下而上、按需停靠"。 只想知道"这份 trace 有没有帧数据",看最底层两张原生表的行数就够;想知道"哪一帧超期",上到 android_frame_stats;想回答"怪谁",才用分类函数。别一上来就从顶层开始——顶层视图要做 CPU 时间聚合,在没有应用侧切片的 trace 上会返回空结果(第七章给实证),你会误以为"这份 trace 没有掉帧"。

6.2 第一问:这一帧到底掉没掉

第一段查询只做一件事:把"超期"数出来,并且同时给出分布——只报"掉了 42 帧"没信息量,你还得知道这批帧超得多离谱:

-------------------------------------【第一问:掉了多少、超了多少】-----------------------------------
-- 描述:先引入帧归属,再引入判据;用 overrun 而不是 was_jank 数掉帧
-- 实测:wattson_jank_cuj.pb 上 215 帧有 overrun 值,其中 >0 的 42 帧、≤0 的 173 帧
----------------------------------------------------------------------------------------------------
INCLUDE PERFETTO MODULE android.frames.timeline;
INCLUDE PERFETTO MODULE android.frames.per_frame_metrics;

SELECT
  count()                                             AS frames,                   --【1】能算出 overrun 的帧数
  sum(iif(overrun > 0, 1, 0))                         AS janky_frames,             --【2】这才是掉帧数
  round(min(overrun) / 1e6, 2)                        AS min_overrun_ms,           --【3】最提前的那一帧
  round(max(overrun) / 1e6, 2)                        AS max_overrun_ms,           --【4】最超期的那一帧
  round(avg(overrun) / 1e6, 2)                        AS avg_overrun_ms
FROM android_frames_overrun;                          --【5】只含 frame_id 与 overrun 两列的最小视图

下面对【2】【5】特别说明。【2】数的是 overrun > 0,不是 was_jank = 1。【5】解释了为什么这一行要写在 android_frames_overrun 上——它只有两列,不依赖 CPU 时间聚合,因此对"缺应用侧切片"的 trace 更宽容。

自跑实测:wattson_jank_cuj.pb 上 overrun 的正负分布与极值——215 帧中 42 帧为正、173 帧为负,最小值 −13.87 毫秒、最大值 +2.08 毫秒,右侧列出超期最严重的一帧
图 10-16本机实跑:wattson_jank_cuj.pb 上 overrun 的正负分布(42 正 / 173 负)与极值(−13.87 ms 到 +2.08 ms)@ 本机自跑,trace_processor_shell v58.2-add693d8b

这张图是第七章的伏笔。 记住这 42 帧:同一个 trace、同一帧集合,改用 was_jank 去数,答案会变成 1 帧。差的 41 帧不是"争议帧",是"根本没被算进去"的帧 [^src1]。这一点下一章用三份 trace 的对照再证一次,并给出成因链。

6.3 第二问:怪谁(以及为什么不能用等值比较)

写这一段时的第一个动作不是 SELECT,是想清楚能不能用 =。先看看 jank_type 长什么样:

-------------------------------------【第二问:怪谁——统计 jank_type 的分布】---------------------------
-- 描述:jank_type 是逗号拼接的多值,所以分布统计要用"包含"而不是"等于"
-- 实测:android_startup_real.perfetto_trace 的 2028 条 actual 帧上,取值分布见下
----------------------------------------------------------------------------------------------------
SELECT
  jank_type,
  count() AS frames
FROM actual_frame_timeline_slice                    --【1】判据列在原生表上就有,不必先 INCLUDE
GROUP BY jank_type
ORDER BY frames DESC;

-- 反例:下面这种写法会漏掉多值形态(真数据里确有 1 条 "App Deadline Missed, Buffer Stuffing")
SELECT count() AS missed
FROM actual_frame_timeline_slice
WHERE jank_type = 'App Deadline Missed';            --【2】等值比较:漏掉逗号拼接的帧

-- 正确写法:用 GLOB(与标准库 jank_type.sql 的判据口径一致)
SELECT count() AS missed
FROM actual_frame_timeline_slice
WHERE jank_type GLOB '*App Deadline Missed*';        --【3】子串命中:多值形态也能被捞到

下面对【1】【2】特别说明。【1】是个好消息:jank_type、present_type、on_time_finish、prediction_type 这些列直接长在原生表上(actual_frame_timeline_slice 共 25 列),不引入任何模块就能查——那些视图是"聚合与派生",不是"取数的唯一入口"。【2】是本篇第三次强调:jank_type 可以是逗号拼接的多值。真数据里那 1 条多值帧在两值各自都在清单内,却会让等值统计漏掉一帧;帧数少时不显眼,但口径错了就是错了——这种错误不会报警。

自跑实测:jank_type 的多值形态与真值分布——android_startup_real 的 2028 条帧上 None 1921、Buffer Stuffing 89、App Deadline Missed 12,另有官方清单外的 SurfaceFlinger Scheduling 3 与 Dropped Frame 2,以及 1 条逗号拼接的多值
图 10-17本机实跑:jank_type 是多值逗号拼接,且实现侧多出的取值真会出现——等值比较会漏,GLOB 才命中@ 本机自跑,trace_processor_shell v58.2-add693d8b

图上还有一处:None 有 1921 条,并不意味着"只有 107 帧有问题"。 它是 2028 条 actual 帧的完整分布,None 就是"呈现无异常"这个正常值。要做占比归一化,第二步才是把 None 从分母摘掉;但第一步永远是先把"多值"与"清单外取值"处理掉,否则分母本身就是错的 [^src4]。

6.4 第三问:慢在哪一段

先看看第三问。android_frame_stats 给了三个现成的分解列:overrun(超期量)、ui_time(应用侧 UI 时间)、cpu_time(帧的 CPU 时间)。差别正是分段的意义:

你看到的现象 该问的列 说明
超期了,但 CPU 时间很小 overrun 大、cpu_time 小 通常是等待造成的(等 GPU、等合成、被调度推迟)
CPU 时间很大 cpu_time 大 CPU 侧真忙,看后三档严重度
CPU 时间很大但 UI 时间小 ui_time 小于 cpu_time 时间花在主线程之外(渲染线程或输入等待)
两轨起点错开很多 android_app_vsync_delay_per_frame 被调度与真开始之间的耽搁(注意 5.4 节的不一致)

这张表把一个"卡"字拆成了四种互斥的问法。 跳过这一步,就只剩"帧掉了"三个字;而这一步几乎不花额外成本——三个列都在同一个视图里。

6.5 列选择速查(把本章压成一页)

问题 停靠层 关键列
这份 trace 有没有帧数据 原生两表 expected_frame_timeline_slice 的 count()
这一帧掉没掉 android_frames_overrun overrun(> 0 才算)
掉了算什么严重度 android_frame_stats was_jank / was_slow_frame / was_big_jank / was_huge_jank
呈现档位如何 原生表 present_type / on_time_finish / prediction_type
怪谁 分类函数 android_is_app_jank_type / android_is_sf_jank_type / android_is_missed_frame_type
一帧落在几个 layer 上 android_frames_layers layer_id / layer_name

这一章收在这里。 三段查询加一张速查表。下一章要讲的,恰好是这三段查询最容易在哪里给出"看起来正常、其实错误"的答案。

本章对应的官方依据:视图与列定义见 标准库文档索引 与 SQL 表参考;自跑方式见 Trace Processor 命令行参考。


七、边界与陷阱:取不到,还是取到了但不是你想的

下面我们进入本篇最该记的一章。 前面的判据都对,但各自有一个"会静默失效"的条件。这一章把陷阱分两类:取不到(返回 0 行,至少能发现)与取到了但不是你想的(返回了数,但数不对)。后一类才危险。

7.1 陷阱矩阵

陷阱分类:左侧三条属于取不到、返回空结果一眼能发现,右侧四条属于取到了但不是你想的,逐条附上真数据证据
图 10-18陷阱矩阵:七条静默失效的路径与各自的真数据证据;"取到了但不是你想的"这一类用醒目色标出@ 本系列绘制

分类维度值得记住:按"是否报错"分,不按"是否严重"分。 frame_slice 返回 0 行是"取不到",一眼能发现;而 was_jank 少报一个数量级是"取到了但不是你想的",它和正确答案长得一模一样。在性能分析里,后者的代价高得多——它会一路演变成"优化结论",而没人会怀疑一个数据。

7.2 陷阱一:was_jank 会少报一个数量级

三份真 trace 上,overrun > 0 的帧数与 was_jank 报出的帧数对照如下:

trace overrun > 0 的帧数 was_jank 报出的帧数 差异
android_startup_real.perfetto_trace 236 13 少报 223 帧
webview_jank.pb 149 0 全部漏掉
wattson_jank_cuj.pb 42 1 少报 41 帧

第二行最刺眼:webview_jank.pb 上 149 帧实际超期,was_jank 一帧都没报出来。 根因不在"哪个列更准",在能不能算出来——这一条已由源码坐实,不再是从可观测反推:android_frame_stats 的定义体是 FROM android_frames_overrun JOIN android_frames_ui_time USING (frame_id) JOIN _estimated_cpu_time_per_frame USING (frame_id),三段全是裸 JOIN(等值内连接),一处 LEFT JOIN 都没有;而 android_frames_ui_time 取自 android_frames_choreographer_do_frame(依赖 Choreographer#doFrame 切片)、android_frames_overrun 又要求该 frame_id 同时落在 actual 与 expected 两张 timeline 表里(它本身是 _frame_id_count_in_actual_timeline 与 _frame_id_count_in_expected_timeline 的内连接,per_frame_metrics.sql 里就是这么写的)。于是任何一腿缺失,这一帧就从结果集里整行消失——webview_jank.pb 上 doFrame 切片为 0,android_frames_ui_time 为空,内连接一交,android_frame_stats 直接是 0 行,was_jank 自然报 0;android_startup_real.perfetto_trace 上 android_frame_stats 只剩 792 行(android_frames_overrun 是 1395 行),也是同一机制在削行。

三段裸内连接的削行效果:android_frames_overrun 的 1395 行依次内连接 UI 时间与 CPU 时间估算两腿,任一腿缺失该帧就整行消失,最后只剩 792 行
图 10-19android_frame_stats 的三段裸内连接:android_frames_overrun 1395 行经两道等值内连接后只剩 792 行,一处 LEFT JOIN 都没有——这就是 was_jank 少报的机制@ 本机自跑,trace_processor_shell v58.2-add693d8b
自跑实测:三份 trace 上 overrun 正帧数与 was_jank 帧数的对照——236 对 13、149 对 0、42 对 1,柱状对比直观显示少报一个数量级
图 10-20本机实跑:只用 was_jank 数掉帧会少报一个数量级(236→13、149→0、42→1),根因是该视图依赖应用侧切片做 CPU 时间聚合@ 本机自跑,trace_processor_shell v58.2-add693d8b

正确用法不是"永远别用 was_jank",是"知道它在什么条件下会漏"。 当 trace 同时含 FrameTimeline 与 atrace 应用侧切片时,差距会缩小但仍不等(42 对 1)——只要问题是"掉了多少帧",就用 overrun;was_jank 只在想同时看 CPU 时间档位时才需要 [^src2]。

7.3 陷阱二:有 FrameTimeline ≠ 有帧原料切片

第二类陷阱是"表里有数、另一张表里没数",最容易让人怀疑自己的语法:

查询对象 webview_jank.pb wattson_jank_cuj.pb
actual_frame_timeline_slice 1031 217
expected_frame_timeline_slice 851 215
Choreographer#doFrame* 切片 0 466
DrawFrame* 切片 0 252
android_frames 0 220
android_frame_stats 0 117

左右对比:FrameTimeline 数据与 atrace 应用侧切片是两路独立采集。 左边那份有 1031 条帧的完整期望/实际窗口,却一条 doFrame 切片都没有;右边两份齐全。缺了应用侧切片,所有需要"把帧拆到线程"的视图都会空掉,但"帧本身超没超"仍然算得出来(它只依赖两轨窗口)。

自跑实测:同一份 webview_jank.pb 上帧表有 1031 条而 doFrame 与 DrawFrame 均为 0,右侧 wattson_jank_cuj.pb 同口径对比——两路数据独立,缺一路不影响另一路
图 10-21本机实跑:webview_jank.pb 有 1031 条 actual 帧却 doFrame = 0 / DrawFrame = 0——FrameTimeline 与 atrace 是两路独立数据@ 本机自跑,trace_processor_shell v58.2-add693d8b

实战解法只有一个动作:跑判据之前先确认"我要问的那一路数据在不在"。 只判断"超没超",两表有数就够;一旦要问"超在哪段代码",就必须先确认应用侧切片存在——否则你会花半小时调 SQL,最后发现 trace 里根本没有那路数据 [^src3]。

7.4 陷阱三:内置视图为空,以及一个不存在的表名

下面我们看看第三个陷阱:你要查的那个"表",可能根本不由这份数据填充,或者根本不存在。

  • 内置视图 frame_slice 恒为 0 行,而它到底由谁填充——本轮读到了建视图 SQL。 直接查 SELECT sql FROM sqlite_master WHERE name = 'frame_slice',它的定义是 CREATE VIEW frame_slice AS SELECT s.id, s.ts, s.dur, s.track_id, s.category, s.name, s.depth, s.parent_id, s.arg_set_id, extract_arg(s.arg_set_id,'layer_name') AS layer_name, extract_arg(s.arg_set_id,'frame_number') AS frame_number, extract_arg(s.arg_set_id,'queue_to_acquire_time') AS queue_to_acquire_time, extract_arg(s.arg_set_id,'acquire_to_latch_time') AS acquire_to_latch_time, extract_arg(s.arg_set_id,'latch_to_present_time') AS latch_to_present_time FROM slice AS s JOIN track AS t ON s.track_id = t.id WHERE t.type = 'graphics_frame_event'。关键是末行的过滤条件:它只取 track.type = 'graphics_frame_event' 的切片——那是 atrace FrameEvents 那一路的轨道类型,与 FrameTimeline 的帧不在同一种轨道上:实测 actual_frame_timeline_slice 的 2028 行全部落在 type = 'android_actual_frame_timeline' 的轨道上。在六份 trace 上实测(actual 分别 2028 / 217 / 1031 / 280 / 113 / 16 行),frame_slice 全部是 0 行,且六份 trace 里 graphics_frame_event 轨道数都是 0——所以"恒为空"不是 bug,是这条视图的数据源在 FrameTimeline trace 里本来就不存在:拿它当"帧表"用,等于拿错了数据通路。
  • 表名 frame_timeline_slice 不存在。 这个拼法很自然(把 expected / actual 合成一个通用名),但实测:对它做列查询或计数查询都直接报错 no such table: frame_timeline_slice;官方标准库文档页全文检索该名也零命中。真实存在的是两张原生表 expected_frame_timeline_slice 与 actual_frame_timeline_slice,外加那个恒为空的内置视图 frame_slice。 旧资料里写着 frame_timeline_slice 的,一律不要照抄。

7.5 边界清单:还有这四条要记住

边界 说明 正确姿势
没有 FrameTimeline 数据 标准库自动回退到 SDK 28 之前的老路径拼帧,frame_id 口径随之改变 先按 6.1 的最底层确认"有没有 actual 数据",再决定用哪套口径
SurfaceView 不支持 官方明确写了 FrameTimeline 目前不支持 SurfaceViews 这类画面(相机预览、视频播放、游戏画面)不在本判据覆盖范围内
frame_id = -1 与 resynced 切片被剔除 标准库显式过滤 frame_id != 0 AND frame_id != -1;形如"resynced to 1234 in 20.0ms"的切片也会被剔除 你查到的帧数少于切片条数,这是设计如此,不是丢数据
一个 vsync 多个 DrawFrame 真数据上 draw_frame_count = 2 有 32 行 用 android_frames 的 draw_frame_count 判断,别拿 DrawFrame 切片条数当帧数

第二行还有一处细节:在 wattson_jank_cuj.pb 上看到的 *FT#Missed*Callback* 切片共 4 条,全部是 FT#MissedHWUICallback#289719、#289734、#289794、#289854 这一类形态,合成器侧一条都没有。标准库解析这张内部表时,用 max(name GLOB '*SF*') 与 max(name GLOB '*HWUI*') 分别产出"合成器侧漏回调"与"应用框架侧漏回调"两个标记——"vsync 回调是谁漏的"就藏在这两个标记里:这次漏在应用框架侧。

漏回调标识位的归属判定:切片名里匹配 *FT#Missed*Callback* 的有 4 条,标准库在合成器侧标记上取到 0 条、在应用框架侧标记上取到 4 条
图 10-22漏回调的归属判定:*FT#Missed*Callback* 匹配 4 条,合成器侧标记 0 条、应用框架侧标记 4 条——这次漏回调的是应用框架侧@ 本机自跑,trace_processor_shell v58.2-add693d8b

7.6 一段自检查询:先确认你的数据能不能回答你的问题

把本章收成一段动作:在跑任何判据之前先跑一段"体检",确认四件事。

-------------------------------------【开查之前先体检:四问一屏】-------------------------------------
-- 描述:跑判据之前先确认数据通路,避免把"缺数据"读成"没掉帧"
-- 实测:webview_jank.pb 上第 2/3/4 问会给出 0,wattson_jank_cuj.pb 上四问都有数
----------------------------------------------------------------------------------------------------
SELECT 'actual 帧'      AS item, count() FROM actual_frame_timeline_slice   --【1】帧本身有没有
UNION ALL
SELECT 'doFrame 切片',   count() FROM slice WHERE name GLOB 'Choreographer#doFrame*'  --【2】帧原料有没有
UNION ALL
SELECT 'frame_slice',    count() FROM frame_slice                          --【3】内置视图恒为 0,别用它
UNION ALL
SELECT 'frames 行数',    count() FROM android_frames;                       --【4】拼出来的帧数(需先 INCLUDE)

下面对【1】【3】特别说明。【1】是所有讨论的前提:这行是 0,说明这份 trace 不含 FrameTimeline 数据,后面一切判据都不适用(标准库也会自动走老路径)。【3】是一个"反直觉的固定值":在六份真 trace 上跑这一问,结果全是 0,但你不会收到任何警告——它就是一个合法的空结果。把它放进体检脚本,是为了一旦换了 trace 结果全空,第一时间排除这个固定不变的空源。

这一章收在这里。 三句话:was_jank 会少报一个数量级,数掉帧用 overrun > 0;FrameTimeline 与 atrace 是两路数据;frame_slice 恒为空、frame_timeline_slice 不存在。

本章对应的官方依据:无效 frame_id 的过滤规则与 *FT#Missed*Callback* 的解析见 android/frames/timeline.sql。


八、收口清单与结语

好了,最后一章我们做三件事: 把全篇压成一张可截图的表、把边界再钉一遍、把"下一站"指个方向。

8.1 收口矩阵:判据 / 归因 / 查询

这张表的三列对应三个问题:先问判据(这一帧掉没掉)、再问归因(掉在谁身上)、最后问查询(用哪一列去问)。顺序不能倒:先有判据,归因才有意义;先有归因,查询才知道该捞哪一列。

收口三列矩阵:左列是判据(超期、四档严重度、呈现档位),中列是归因(八条目、三分类函数、Buffer Stuffing 状态),右列是查询(四层路径与关键列名)
图 10-23收口三列矩阵:判据 → 归因 → 查询,每一行都能对回前文某一章@ 本系列绘制

再做一次归类,三列只有四类手段:改判据(用 overrun > 0 不用 was_jank)、改对齐键(用 token 不用时间戳)、改比较方式(用 GLOB 不用等值)、先体检再下结论(确认数据通路再跑判据)。先修判据,再修对齐,最后才谈工程化:判据错了,后面三步都是在错误结论上加速。

8.2 下一站方向:从"帧掉没掉"到"帧里的时间去哪"

先看看下一站。本篇回答了"这一帧掉没掉、怪谁",但下一个问题立刻就来:如果这一帧真的掉了,它的时间到底花在哪了? 这就是已知的下一站方向:把一帧之内的 CPU 时间拆到线程与切片上。

官方实景:把 CPU 时间按线程与调用切片归集,用来回答一帧之内的时间去哪了
图 10-24把 CPU 时间归集到线程与切片上——这是"帧之后"的下一步:从"掉没掉"走到"时间去哪了"@ Perfetto 官方文档(Google,Apache-2.0)

这张图(原图 2556×1294)[^ext6] 恰好接在本篇的缺口上。 本篇的 cpu_time 是"一帧的 CPU 时间之和",它只说"这一帧花了 37 毫秒",但不告诉你这 37 毫秒花在哪段代码上——后者要把帧的时间窗与线程上的切片做区间关联,属于另一个题域。本篇只指方向,不展开:ep11 即第 十一 篇《实战一:一条链路揪 GPU 瓶颈》,此处不做标题级预告。

8.3 边界再确认表

0.5 节开篇时给过一次边界,走完全篇之后再确认一次:

本篇讲了 本篇没讲(→ 对应篇目)
帧的三段分段与 token 对齐 帧数据的 packet 与字段结构 → 属 ep07
两条时间线的定义差与取 max 的终点 数据源注册、采集配置与丢包成因 → 属 ep08
overrun 判据、四档严重度、呈现档位 SQL 语法入门、CREATE PERFETTO TABLE / INDEX 的判据 → 属 ep09
8 个 jank 条目、责任方分流、文档与实现的不一致 slices.* 家族、SPAN_JOIN 与区间连接、2 × S × N 内存模型 → 属 ep09
四模块分工与 SDK 28 分水岭 帧内 CPU 时间归集(拆到线程与切片)→ 属 第 十一 篇《实战一:一条链路揪 GPU 瓶颈》
三段可执行查询与开查前的体检 上层业务指标(cujs / startup)如何消费帧数据 → 只指出依赖关系

第五行最该留意。 它是本篇唯一的"向前开口":帧的时间窗算清楚了,窗内的时间归属没拆。除此之外,本篇的题域是一个闭环。

8.4 资源清单与参考资料来源

  • FrameTimeline 数据源页(jank 定义、两轨语义、8 条目、颜色码、示例 SQL)—— https://perfetto.dev/docs/data-sources/frametimeline
  • 标准库文档索引(四模块的视图、函数与依赖图)—— https://perfetto.dev/docs/analysis/stdlib-docs
  • SQL 表参考(两张 timeline 表的列定义)—— https://perfetto.dev/docs/analysis/sql-tables
  • PerfettoSQL 入门(时间单位、INCLUDE 语义、最佳实践)—— https://perfetto.dev/docs/analysis/perfetto-sql-getting-started
  • PerfettoSQL 语法(CREATE PERFETTO … 与模块引入)—— https://perfetto.dev/docs/analysis/perfetto-sql-syntax
  • Trace Processor(会话与嵌入,自跑脚本的载体)—— https://perfetto.dev/docs/analysis/trace-processor
  • 批处理 Trace Processor(多 trace 并行跑同类查询)—— https://perfetto.dev/docs/analysis/batch-trace-processor
  • Trace Processor 命令行参考(query / server / summarize / export)—— https://perfetto.dev/docs/reference/trace-processor-cli
  • 命令行分析入门(导出与配方)—— https://perfetto.dev/docs/getting-started/command-line-analysis
  • Android trace 分析入门(图形流水线的官方上手路径)—— https://perfetto.dev/docs/getting-started/android-trace-analysis
  • 时钟同步概念(跨时钟域对齐帧与 CPU 时的依据)—— https://perfetto.dev/docs/concepts/clock-sync
  • 采集侧相邻数据源:atrace —— https://perfetto.dev/docs/data-sources/atrace
  • 采集侧相邻数据源:GPU 计数器 —— https://perfetto.dev/docs/data-sources/gpu
  • TrackEvent 插桩(应用侧切片从哪来)—— https://perfetto.dev/docs/instrumentation/track-events
  • Perfetto UI 使用说明(轨道、选中与 Flow events 的操作)—— https://perfetto.dev/docs/visualization/perfetto-ui
  • Trace Processor 架构设计(列式存储与视图实现位置)—— https://perfetto.dev/docs/design-docs/trace-processor-architecture
  • Debug Tracks(把查询结果画回时间轴)—— https://perfetto.dev/docs/analysis/debug-tracks
  • trace packet proto 参考(FrameTimelineEvent,回指 ep07)—— https://perfetto.dev/docs/reference/trace-packet-proto
  • 测试约定(策略与回归基线)—— https://perfetto.dev/docs/contributing/testing
  • 源码:android/frames/ 模块目录 —— https://github.com/google/perfetto/tree/main/src/trace_processor/perfetto_sql/stdlib/android/frames
  • 源码:frames/timeline.sql(帧归属、token 过滤、SDK 28 分叉)—— https://github.com/google/perfetto/blob/main/src/trace_processor/perfetto_sql/stdlib/android/frames/timeline.sql
  • 源码:frames/per_frame_metrics.sql(公式、四档阈值、回退估值)—— https://github.com/google/perfetto/blob/main/src/trace_processor/perfetto_sql/stdlib/android/frames/per_frame_metrics.sql
  • 源码:frames/jank_type.sql(三个分类函数与 GLOB 判据)—— https://github.com/google/perfetto/blob/main/src/trace_processor/perfetto_sql/stdlib/android/frames/jank_type.sql
  • 源码:frames/timeline_maxsdk28.sql(老拼帧路径)—— https://github.com/google/perfetto/blob/main/src/trace_processor/perfetto_sql/stdlib/android/frames/timeline_maxsdk28.sql
  • 工具:trace_processor 下载入口 —— https://get.perfetto.dev/trace_processor
  • 数据:官方 test data 桶(本轮 6 份含 FrameTimeline 的 trace 来源)—— https://storage.googleapis.com/perfetto/test_data/
  • 数据:官方 Android 示例 trace(阶段 1 的反例:帧数为 0)—— https://storage.googleapis.com/perfetto-misc/example_android_trace_30s_1
  • Android 平台侧 jank 检测(镜像站)—— https://developer.android.google.cn/studio/profile/jank-detection
  • Android 平台侧性能度量(16.7 ms 与刷新率的来历,镜像站)—— https://developer.android.google.cn/topic/performance/measuring-performance
  • Android FrameMetrics 参考页(Added in API level 24,即 Android 7.0 与平台侧帧指标的时间归属,镜像站)—— https://developer.android.google.cn/reference/android/view/FrameMetrics
  • Android Compose 性能 codelab(复述五色码,镜像站)—— https://developer.android.google.cn/codelabs/jetpack-compose-performance
  • 社区:AOSP 课程《Frame Drops & Jank Analysis》(二手)—— https://aospinsider.com/courses/aosp-course-4/20-frame-drops-jank-analysis/
  • 社区:用 FrameTimeline 诊断 Android jank(二手)—— https://mvpfactory.io/blog/diagnosing-android-jank-with-frametimeline-api-surfaceflinger-deadlines-hwui

参考资料来源:判据、公式、阈值与取值面以 Perfetto 官方现行文档与 google/perfetto 源码为一手依据;所有帧级真值均为本机自跑产物,跑法为 trace_processor_shell(v58.2-add693d8b)对官方 test data 中 6 份 trace 执行查询,命令与输出可复现;配图为官方文档配图(Google,Apache-2.0)与本系列自绘;社区材料仅作旁证。官方 analysis 分区 2026 年重构后的现行路径已逐条核对,本篇不引用已失效路径。

下篇预告:第 十一 篇《实战一:一条链路揪 GPU 瓶颈》已上线;"下一站方向"是把一帧内 CPU 时间拆到线程与切片。

开篇快捷键:数掉帧用 overrun > 0,别用 was_jank;对齐用 token,别用时间戳;jank type 用 GLOB,别用等值;下判据之前先跑一次四问体检。

以上。 希望这一路"从看着卡到算得清"的梳理,能让你下次遇到"用户说卡"时不再靠试错,而是一路问下去:这份 trace 里有没有帧、这一帧超没超、超的这段该记在谁头上。

本文由@溯光 出品,让"一帧到底有没有掉"变成一个可复现的判断。

With Best Wishes.


[^ext1]: 题图「轨道实景」取自 Perfetto 官方文档(GitHub google/perfetto 的 docs/images/frametimeline/timeline_tracks.png,配套 https://perfetto.dev/docs/data-sources/frametimeline),作者:Google,Apache-2.0 许可;实测尺寸 1595×157。

[^ext2]: 图「SF 侧同名 token」取自 Perfetto 官方文档(GitHub google/perfetto 的 docs/images/frametimeline/sf-vsyncid.png,配套 https://perfetto.dev/docs/data-sources/frametimeline),作者:Google,Apache-2.0 许可;实测尺寸 621×353。

[^ext3]: 图「Flow events 连线」取自 Perfetto 官方文档(GitHub google/perfetto 的 docs/images/frametimeline/select-app-slice.png,配套 https://perfetto.dev/docs/data-sources/frametimeline),作者:Google,Apache-2.0 许可;实测尺寸 1237×714。

[^ext4]: 图「actual slice 详情面板」取自 Perfetto 官方文档(GitHub google/perfetto 的 docs/images/frametimeline/selection.png,配套 https://perfetto.dev/docs/data-sources/frametimeline),作者:Google,Apache-2.0 许可;实测尺寸 1173×604。

[^ext5]: 图「DisplayFrame 的跨进程箭头」取自 Perfetto 官方文档(GitHub google/perfetto 的 docs/images/frametimeline/select-sf-slice-1.png,配套 https://perfetto.dev/docs/data-sources/frametimeline),作者:Google,Apache-2.0 许可;实测尺寸 1414×528。

[^ext6]: 图「把 CPU 时间归集到线程与切片」取自 Perfetto 官方文档(GitHub google/perfetto 的 docs/images/android-trace-analysis-cpu-utilization-slice.png,配套 https://perfetto.dev/docs/getting-started/android-trace-analysis),作者:Google,Apache-2.0 许可;实测尺寸 2556×1294。

[^src1]: 自跑图「overrun 正负分布与极值」由本机 trace_processor_shell(v58.2-add693d8b)对官方 test data wattson_jank_cuj.pb 执行查询生成;215 帧中 overrun > 0 42 帧、≤ 0 173 帧,min −13,873,287 ns、max +2,078,702 ns。

[^src2]: 自跑图「was_jank 少报对照」由本机对 android_startup_real.perfetto_trace(236→13)、webview_jank.pb(149→0)、wattson_jank_cuj.pb(42→1)三份 trace 同口径查询生成。

[^src3]: 自跑图「两路独立数据对照」由本机对 webview_jank.pb(actual 1031 / expected 851 / doFrame 0 / DrawFrame 0 / android_frames 0)与 wattson_jank_cuj.pb(217 / 215 / 466 / 252 / 220)执行查询生成。

[^src4]: 自跑图「jank_type 多值形态与真值分布」由本机对 android_startup_real.perfetto_trace 的 2028 条 actual 帧分组统计生成,并对照 jank_type.sql 的 GLOB 判据。