首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >读者点单·06|你的 App 为什么「掉帧」?从 VSync 信号到屏幕亮像素的 16ms 全链路拆解

读者点单·06|你的 App 为什么「掉帧」?从 VSync 信号到屏幕亮像素的 16ms 全链路拆解

作者头像
陆业聪
发布2026-06-29 14:43:01
发布2026-06-29 14:43:01
290
举报

📚 读者点单·端午投票系列 · 第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。我们看关键源码:

代码语言:javascript
复制
// 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 就是这么做的。

代码语言:javascript
复制
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,每帧都能拿到帧时间,计算实际帧间隔:

代码语言:javascript
复制
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。它能直接拿到每帧各阶段的耗时,不需要自己算:

代码语言:javascript
复制
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 两次,严重浪费帧预算。

正确做法:

代码语言:javascript
复制
// ✗ 错误: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 抓取方法

代码语言:javascript
复制
# 抓取 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
EOF

5.2 读懂 FrameTimeline

打开 ui.perfetto.dev 后,展开你的 App 进程,找到 Expected TimelineActual 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 查询,批量分析十分方便:

代码语言:javascript
复制
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 次

减少流量和存储

用户行为关联

只在用户活跃操作时采集

避免后台抗锟帧

核心代码示例——连续丢帧检测器:

代码语言:javascript
复制
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篇:系列复盘

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2026-06-28,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 陆业聪 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档