
📚 读者点单·端午投票系列 · 第6/10篇
✅ 第1篇:Android 性能治理的「全景图」:从机型分级到指标体系
✅ 第2篇:Android 启动优化实战:Trace 抓取→冷启动全流程拆解
✅ 第3篇:Compose 与传统 View 混用的 12 个真实坑
✅ 第4篇:Android 内存治理实战:从 PSS 到 LeakCanary
✅ 第5篇:Token 节省专题:把 AI 编程账单砍 60%
📖 第6篇:Android 帧率治理(本篇)
⏳ 第7篇:ANR 治理实战
⏳ 第8篇:AI × Android 端侧落地
⏳ 第9篇:Android 包体积治理
⏳ 第10篇:系列复盘
同样是 RecyclerView 滑动,为什么你的列表「卡成 PPT」而竞品丝般顺滑?答案藏在 16ms 流水线里每一个被阻塞的环节中。本篇从 VSync 底层机制出发,串联 Choreographer → 主线程 → RenderThread → SurfaceFlinger 的完整渲染链路,结合 Perfetto FrameTimeline 实战,给出可落地的卡顿监控和优化方案。
📰 今日要闻
• OpenAI 发布首款自研 AI 芯片 — 正式进军芯片领域,意在减少对英伟达的依赖,芯片针对推理场景优化,预计 2027 年量产部署。
• Anthropic 成全球价值最高独角兽 — 最新一轮融资后估值超过 SpaceX,DeepSeek 同期跻身全球独角兽企业前 15 名,AI 赛道头部集中度加速。
• 苹果宣布上调 iPad 及 Mac 价格 — 受关税及供应链成本影响,部分机型涨幅 5%-8%;供应商领益智造完成 106 亿港元 IPO 即将登陆港股。
• 美联储首选通胀指标超预期 — 5 月核心 PCE 同比达 3.4%,为 2023 年 10 月以来最高,降息预期再度推迟;软银股价下挫,报道称 OpenAI 或将 IPO 推迟至明年。
上周有个读者私信我:「我的列表页在中端机上滑动已经很流畅了,为什么换到线上低端机就变成了 PPT?」我当时的第一反应是——你确定看的是帧率数据,不是主观感觉吗?
这是个很典型的问题。很多同学踩卡顿的坑,不是因为不知道“60fps = 流畅”这个结论,而是不清楚一帧从“应该开始绘制”到“真正亮像素”之间,到底经过了哪些环节、每个环节能“堵”多久。
今天这篇,我们就把“一帧的一生”拆开看。从 VSync 信号发出的那一刻开始,一路跟到 SurfaceFlinger 把 buffer 提交给显示屏,看清楚“丢帧”究竟丢在了哪里。然后给一套可落地的监控和优化方案。
1. 一帧的一生:VSync → Display 的 16ms 流水线
Android 的渲染流水线,本质上是一个被 VSync 信号驱动的三级流水线。我画个简图你就懂了:
VSync 信号触发
↓
Choreographer.doFrame()
↓
INPUT → 触摸事件分发
ANIMATION → 属性动画计算
TRAVERSAL → measure / layout / draw
↓
RenderThread
SYNC → DisplayList 同步到 GPU
DRAW → GPU 实际渲染
↓
SurfaceFlinger 合成
↓
DRM/KMS → Display 亮像素
关键点在于:每一级都有自己的时间窗口。在 60Hz 屏幕上,整个流水线的 deadline 是 16.6ms。如果主线程的 INPUT + ANIMATION + TRAVERSAL 就吃掉了 12ms,留给 RenderThread 只有 4ms —— 要是 GPU 负载再高点,这帧就没了。
VSync 的本质:屏幕的“心跳”
VSync(Vertical Sync)是显示屏硬件发出的中断信号,告诉系统“我开始扫描下一帧了”。Android 4.1(Project Butter)的核心改动就是把整个 UI 渲染流水线“锁”到 VSync 上。在此之前,应用想什么时候绘制就什么时候绘制,导致大量撒裂(tearing)和不规则丢帧。
Android 上有两路 VSync:
• VSYNC-app:触发 App 端的 Choreographer 开始绘制
• VSYNC-sf:触发 SurfaceFlinger 开始合成各 Layer
两者有一个 phase offset(相位差),通常 VSYNC-sf 比 VSYNC-app 晚几毫秒,这样 App 绘制完的 buffer 刚好赶上 SurfaceFlinger 的下一次合成。如果你用 Perfetto 看过 trace,会发现这两个信号在时间线上有规律地错开。
2. Choreographer 源码解析:帧节拍器的几个关键时刻
Choreographer 是 Android 渲染的“指挥家”。每次 VSync 信号到达时,它会按顺序执行四类回调:INPUT → ANIMATION → INSETS_ANIMATION → TRAVERSAL。我们看关键源码:
// Choreographer.java 简化版
void doFrame(
long frameTimeNanos,
int frame
) {
// 1. 计算丢帧数
long jitterNanos =
frameTimeNanos
- mLastFrameTimeNanos;
if (jitterNanos >=
mFrameIntervalNanos) {
long skipped =
jitterNanos
/ mFrameIntervalNanos;
// 丢帧日志
Log.i(TAG,
"Skipped "
+ skipped
+ " frames!");
}
// 2. 依次执行回调
doCallbacks(
Choreographer
.CALLBACK_INPUT,
frameTimeNanos);
doCallbacks(
Choreographer
.CALLBACK_ANIMATION,
frameTimeNanos);
doCallbacks(
Choreographer
.CALLBACK_TRAVERSAL,
frameTimeNanos);
}看到这里你就明白了:如果你在 CALLBACK_INPUT 阶段做了一个耗时操作(比如在 onTouchEvent 里读数据库),后面的 ANIMATION 和 TRAVERSAL 就被迫延后,导致整帧超时。
实战教训:我曾经在一个日历应用里,在 onTouchEvent 中同步查询“当天是否有日程”来决定高亮样式。在有 200+ 条日程的月份,每次滑动都能感觉到明显卡顿。只需要把查询结果缓存到 HashMap,卡顿就完全消失。
FrameInfo 时间戳:帧级的“尸检报告”
Android 从 7.0 开始,在 FrameInfo 中记录了每一帧的关键时间戳:
时间戳字段 | 含义 |
|---|---|
INTENDED_VSYNC | 预期的 VSync 时间 |
VSYNC | 实际收到 VSync 的时间 |
HANDLE_INPUT_START | 输入事件处理开始 |
ANIMATION_START | 动画回调开始 |
PERFORM_TRAVERSALS_START | View 三件套开始 |
DRAW_START | Draw 阶段开始 |
SYNC_START | 同步到 RenderThread |
ISSUE_DRAW_COMMANDS_START | GPU 绘制指令发出 |
通过计算相邻时间戳的差值,你能精确定位“是 Input 处理慢,还是 Traversal 慢,还是 GPU 渲染慢”。这比只看一个总体帧时间要有价值得多。
3. 卡顿监控三板斧:从线下到线上的完整方案
知道了帧的生命周期,接下来就是怎么“发现卡顿”的问题。我在实际项目中用过三种方案,各有优劣:
3.1 Looper Printer 方案
原理很简单:Looper 在分发每个 Message 前后都会调用 Printer.println(),我们在前后打个时间戳,超过阈值就抓栈。这是最经典的方案,BlockCanary 就是这么做的。
class JankPrinter :
Printer {private var startMs = 0Loverride fun println(
x: String
) {
if (x.startsWith(
">>>>>"
)) {
startMs = SystemClock
.uptimeMillis()
} else {
val cost =
SystemClock
.uptimeMillis()
- startMs
if (cost > 100) {
// 抓栈上报
dumpStack(cost)
}
}
}
}// 注册
Looper.getMainLooper()
.setMessageLogging(
JankPrinter()
)❗ 注意事项:Looper Printer 方案本身有字符串拼接开销。在高帧率场景(120Hz)下,每秒触发 240 次 println,对性能有一定影响。线上用时建议设置采样率,比如每 10 次 dispatch 才开启一轮检测。
3.2 Choreographer FrameCallback 方案
第二种方案更“专业”——直接用 Choreographer 的 FrameCallback,每帧都能拿到帧时间,计算实际帧间隔:
class FpsMonitor :
Choreographer.FrameCallback {private var lastNanos = 0L
private var dropCount = 0override fun doFrame(
frameTimeNanos: Long
) {
if (lastNanos > 0) {
val interval =
(frameTimeNanos
- lastNanos)
/ 1_000_000
if (interval > 17) {
dropCount +=
(interval / 16)
.toInt() - 1
}
}
lastNanos = frameTimeNanos
Choreographer
.getInstance()
.postFrameCallback(
this
)
}
}这个方案的优势是能精确计算丢帧数,而且开销极低。缺点是它只能告诉你“丢了几帧”,不能告诉你“为什么丢”。实际项目中通常和抖化的抓栈方案配合用。
3.3 FrameMetrics API(API 24+)
如果你的 minSdk 已经到 24,强烈推荐用 FrameMetrics API。它能直接拿到每帧各阶段的耗时,不需要自己算:
window.addOnFrameMetrics
AvailableListener(
{ _, metrics, _ ->
val total = metrics
.getMetric(
FrameMetrics
.TOTAL_DURATION
)
val layout = metrics
.getMetric(
FrameMetrics
.LAYOUT_MEASURE_DURATION
)
val draw = metrics
.getMetric(
FrameMetrics
.DRAW_DURATION
)
if (total >
16_000_000) {
// 上报各阶段耗时
reportJank(
total, layout, draw
)
}
},
Handler(
handlerThread.looper
)
)三种方案对比:
方案 | 精度 | 开销 | 适用 |
|---|---|---|---|
Looper Printer | Message 级 | 中 | 线下调试 |
FrameCallback | 帧级 | 极低 | 线上帧率统计 |
FrameMetrics | 阶段级 | 低 | 线上归因 |
4. RecyclerView 滑动卡顿 Top10:你至少踩过 3 个
说实话,我见过的线上卡顿 Case,80% 都跟 RecyclerView 有关。把我这些年踩过和见过的坑排个序:
TOP 1 onBindViewHolder 中做 IO — 读 DB、解析大 JSON、调网络接口
TOP 2 布局嵌套太深 — 3 层 LinearLayout 嵌套 + RelativeLayout,measure 指数爆炸
TOP 3 图片没有指定固定尺寸 — Glide 每次都要重新 requestLayout
TOP 4 ViewHolder 类型太多 — 超过 20 种,缓存池形同虚设、命中率极低
TOP 5 notifyDataSetChanged 滥用 — 每次全量刷新,DiffUtil 解决 90% 场景
TOP 6 setHasFixedSize(false) — 每次 item 变化都触发 RV 自身 requestLayout
TOP 7 动画在主线程执行 — item 动画用 ValueAnimator 而不是硬件加速的 ViewPropertyAnimator
TOP 8 Prefetch 被意外禁用 — 自定义 LayoutManager 没调 setItemPrefetchEnabled
TOP 9 ItemDecoration.onDraw 中创建对象 — 每帧都 new Paint/Path,GC 压力极大
TOP 10 嵌套滑动冲突 — RV 内嵌 VP2/NestedScrollView,事件拦截导致用户感知“卡”
重点展开一下 Top 1——onBindViewHolder 中做 IO。最常见的反模式是“懒加载”时在 bind 中触发数据请求,然后收到数据后 notifyItemChanged。看起来很聊,实际上快速滑动时每个新形成的 ViewHolder 都要 bind 两次,严重浪费帧预算。
正确做法:
// ✗ 错误:bind 中触发请求
override fun onBindViewHolder(
holder: VH, pos: Int
) {
val item = list[pos]
if (item.detail == null) {
// “懒加载”触发网络
loadDetail(item.id)
}
}// ✓ 正确:预加载 + 缓存
override fun onBindViewHolder(
holder: VH, pos: Int
) {
val item = list[pos]
// 直接用缓存数据渲染
holder.bind(item)
}// 在 ViewModel 中预加载
fun prefetch(
visibleRange: IntRange
) {
val preloadRange =
visibleRange.expand(5)
preloadRange
.filter {
cache[it] == null
}
.forEach {
loadAsync(it)
}
}5. Perfetto FrameTimeline 实战:一张 Trace 定位卡顿归因
前面讲的监控方案能告诉你“发生了卡顿”,但要定位根因,还得靠 Perfetto 的 FrameTimeline。这是 Google 在 Android 12 引入的能力,让你可以看到每一帧在 App 端和 SurfaceFlinger 端的 Expected vs Actual 时间线对比。
5.1 抓取方法
# 抓取 5 秒 trace
adb shell perfetto \
-c - --txt \
-o /data/misc/perfetto-traces\
/frame.perfetto-trace << EOFbuffers: {
size_kb: 65536
fill_policy: RING_BUFFER
}
data_sources: {
config {
name: "android.surfaceflinger\
.frametimeline"
}
}
data_sources: {
config {
name: "linux.ftrace"
ftrace_config {
atrace_categories:
"view"
atrace_categories:
"gfx"
atrace_categories:
"input"
}
}
}
duration_ms: 5000
EOF5.2 读懂 FrameTimeline
打开 ui.perfetto.dev 后,展开你的 App 进程,找到 Expected Timeline 和 Actual Timeline 两行。关键规则:
一帧的 FrameTimeline 判定规则
↓
Actual 超过 Expected 了吗?
↓
✅ 未超过 → 绿色帧,正常
❌ 超过 → 红色帧,点击看归因
↓
Jank 归因分类
↓
APP_DEADLINE_MISSED → App 端处理超时
SF_DEADLINE_MISSED → SurfaceFlinger 合成超时
DISPLAY_HAL → 硬件层问题
我的经验是:线上 95% 以上的卡顿都是 APP_DEADLINE_MISSED,也就是 App 自己的问题。真正由 SurfaceFlinger 引起的很少,除非你在做多窗口/画中画场景。
5.3 用 SQL 批量分析卡顿帧
Perfetto 支持 SQL 查询,批量分析十分方便:
SELECT
af.name AS app_name,
ef.dur / 1000000
AS expected_ms,
af.dur / 1000000
AS actual_ms,
af.jank_type
FROM
actual_frame_timeline_slice af
JOIN
expected_frame_timeline_slice ef
ON af.display_frame_token
= ef.display_frame_token
WHERE
af.jank_type != 'None'
AND af.name LIKE
'%com.your.app%'
ORDER BY
actual_ms DESC
LIMIT 20;6. 线上卡顿告警:阈值设计与降噪策略
监控只是第一步,线上告警才是真正能“防患于未然”的。但卡顿告警有个很大的难题:噪声太多。用户手机可能正在后台杀进程、可能 CPU 被其他 App 抢占、可能系统在做 GC。如果不降噪,每天几千条告警直接让人麻痹。
我们团队踩过很多坑后总结的一套策略:
维度 | 策略 | 效果 |
|---|---|---|
机型分级 | 低端机阈值放宽到 50ms | 减少 60% 噪声 |
连续性 | 单帧不报,连续 3 帧以上才触发 | 过滤偶发尖刺 |
场景白名单 | App 启动/页面切换不报 | 避免“正常卡顿”干扰 |
聚合上报 | 1 分钟窗口内聚合,只上报最严重的 1 次 | 减少流量和存储 |
用户行为关联 | 只在用户活跃操作时采集 | 避免后台抗锟帧 |
核心代码示例——连续丢帧检测器:
class ContinuousJankDetector(
private val
threshold: Int = 3,
private val
frameMs: Long = 16L
) {
private var count = 0
private var scene = ""fun onFrame(
costMs: Long
) {
if (costMs > frameMs) {
count++
if (count >=
threshold) {
report(
scene, count,
costMs
)
}
} else {
count = 0
}
}fun setScene(
s: String
) { scene = s }
}Android 16 新进展:ADPF 2.0 与动态帧率
饱尔提一下 Android 16(今年 Q3 正式发布)带来的 ADPF 2.0(Android Dynamic Performance Framework)。核心变化是系统会根据 App 的实际渲染负载动态调整帧率目标,而不是固定的 60/90/120Hz。对应用开发者来说,最直接的影响是:
• 帧预算不再固定:以前你可以用 16ms 作为 hardcode 阈值,现在必须用 FrameMetrics 动态获取当前 deadline
• Hint API 更实用:可以主动告诉系统“下一帧很重”,系统会拉高 CPU/GPU 频率
• 游戏场景受益最大:可以根据场景复杂度主动降帧率省电,而不是被动丢帧
写在最后
卡顿治理这件事,我觉得核心就三步:能发现 → 能归因 → 能预防。FrameCallback 让你发现,FrameTimeline 让你归因,线上告警让你预防。把这三层能力建好,帧率问题基本不会成为你的短板。
下一篇我们聊 ANR 治理。说实话,ANR 比卡顿更难搞——卡顿至少用户还能用,ANR 直接弹窗让用户关 App。而且 ANR 的 Trace 解读比 FrameTimeline 要难得多,我们到时候从如何读 trace 文件开始讲起。敜请期待。
📚 系列导航
读者点单·端午投票系列 · 共 10 篇
✅ 第1篇:性能治理「全景图」
✅ 第2篇:启动优化实战
✅ 第3篇:Compose 与 View 混用
✅ 第4篇:内存治理实战
✅ 第5篇:Token 节省专题
📖 第6篇:帧率治理(本篇)
⏳ 第7篇:ANR 治理实战
⏳ 第8篇:AI × Android 端侧落地
⏳ 第9篇:包体积治理
⏳ 第10篇:系列复盘