上一篇讲了 Android 14/15 的适配,回到性能监控这个常青主题。卡顿是用户最直观的"烂体验",但怎么量化它?Choreographer 就是系统给的官方答案——理解它就能做精确的卡顿监控。
帧率 vs 流畅度
很多 App 显示"30/60 FPS"作为流畅度指标,但 FPS 高不代表不卡。关键指标是:
掉帧(Frame Skipping):屏幕 16.6ms 应该刷新一帧,实际花了 33ms,这帧掉了一次。
流畅度本质问题:每帧必须在 vsync 信号到来前准备好。Android 的 vsync 由 SurfaceFlinger 触发:
VSync 信号 (16.6ms @60Hz)
↓
Choreographer.doFrame() (主线程)
↓
ViewRootImpl.performTraversals() (测量/布局/绘制)
↓
RenderThread (硬件加速)
↓
SurfaceFlinger (合成上屏)
任何一环慢,整个流程就掉帧。
Choreographer 是什么
Choreographer 是 Android 的"动画调度员"。每次 vsync 信号到来,它会触发一个 callback 在主线程执行:
Choreographer.getInstance().postFrameCallback(object : Choreographer.FrameCallback {
override fun doFrame(frameTimeNanos: Long) {
// 这一帧已经到来,主线程被通知
// 这里是测量/布局/绘制的起点
}
})
每个 doFrame 就是一次 vsync 主线程回调。
用 Choreographer 检测卡顿
原理:在 doFrame 进入时记录时间,在 doFrame 即将退出时记录时间差。差值大于一帧(16.6ms)就是掉帧:
object FrameMonitor {
private const val FRAME_INTERVAL_NS = 16_666_666L // 60Hz
private const val ONE_SECOND_NS = 1_000_000_000L
private val choreographer = Choreographer.getInstance()
private var lastFrameTime = 0L
private var frameCount = 0
private var droppedFrames = 0
private var lastSecondStart = 0L
fun start() {
lastSecondStart = System.nanoTime()
choreographer.postFrameCallback(frameCallback)
}
private val frameCallback = object : Choreographer.FrameCallback {
override fun doFrame(frameTimeNanos: Long) {
val now = System.nanoTime()
if (lastFrameTime != 0L) {
val expected = FRAME_INTERVAL_NS
val actual = frameTimeNanos - lastFrameTime
if (actual > expected * 2) {
val drop = (actual / expected).toInt() - 1
droppedFrames += drop
}
}
lastFrameTime = frameTimeNanos
frameCount++
// 每秒汇总一次
val secondDuration = now - lastSecondStart
if (secondDuration >= ONE_SECOND_NS) {
val fps = frameCount.toDouble() / (secondDuration / ONE_SECOND_NS.toDouble())
Log.i("FPS", "FPS: ${"%.1f".format(fps)}, Dropped: $droppedFrames")
frameCount = 0
droppedFrames = 0
lastSecondStart = now
}
// 继续监听下一帧
choreographer.postFrameCallback(this)
}
}
}
放在 Application.onCreate() 里启动。日志输出:
I FPS: FPS: 59.5, Dropped: 0
I FPS: FPS: 52.3, Dropped: 7
更精确的实现:Lianjie SDK 的做法
业内成熟监控(腾讯 Matrix、字节跳动 Lynx、阿里 ArgusAPM)的卡顿检测思路:
每种都有局限。生产环境通常组合使用:
1. Looper Printer 监控
object LooperMonitor {
fun install() {
// 在主线程 Looper 设置 printer
Looper.getMainLooper().setMessageLogging { msg ->
val startTime = SystemClock.uptimeMillis()
// 调度前一个 message
currentStart = startTime
currentMsg = msg
}
}
}
但 setMessageLogging 在 Android 14+ 已经被限制(looper 改用了 TokenBucket 控制)。
2. 异步 stack sample
主线程卡顿时,定时从工作线程读 /proc/self/task/{tid}/stack,拿到主线程 stack:
class StackSampler(
private val sampleIntervalMs: Long = 200
) {
private var executor: ScheduledExecutorService? = null
fun start() {
executor = Executors.newSingleThreadScheduledExecutor()
executor!!.scheduleAtFixedRate({
if (isMainThreadBusy()) {
val stack = getMainThreadStack()
Logger.log("main_busy", "stack: $stack")
}
}, 0, sampleIntervalMs, TimeUnit.MILLISECONDS)
}
private fun isMainThreadBusy(): Boolean {
// 上一次记录的 frame 时间差判断
}
private fun getMainThreadStack(): String {
val stack = Looper.getMainLooper().thread.stackTrace
val sb = StringBuilder()
stack.forEach { sb.appendLine("\tat $it") }
return sb.toString()
}
}
Android 7+ 直接读 stack 在新设备上有限制,需要用 Thread.getAllStackTraces() 替代。
3. 完整监控 SDK 示例
// 卡顿检测 v1
class BlockDetector(
private val thresholdMs: Long = 500
) {
private val mainHandler = Handler(Looper.getMainLooper())
private val backgroundHandler = Handler(HandlerThread("BlockDetect").apply { start() }.looper)
private var lastPingToken = 0
fun install() {
mainHandler.post(loop)
}
private val loop = object : Runnable {
override fun run() {
val token = ++lastPingToken
backgroundHandler.postDelayed({
if (token == lastPingToken) {
// 上一帧没回 ping,主线程卡了
val stack = getMainThreadStack()
BlockReport.report(stack, thresholdMs)
}
}, thresholdMs)
mainHandler.postDelayed(this, thresholdMs / 2)
}
}
}
这个思路:用 ping-pong 模型。子线程等 threshold 时间内没收到主线程返回的"还能跑",就认定为卡顿。
真实世界的卡顿监控
真实的 APM 卡顿检测需要解决:
false positive:GC、IO 阻塞、正常动画帧不算卡顿
false negative:系统调度延迟、SLAB 分配
采样率:不能所有用户都采样
聚合上报:不能每帧上报
通常做法:
// 1. 在 Application 里启动
class MyApp : Application() {
override fun onCreate() {
super.onCreate()
BlockMonitor.init(this) {
// 配置
thresholds = longArrayOf(100, 200, 500, 1000)
sampleRate = 0.1 // 10% 用户采样
stackDepth = 30
}
}
}
// 2. 上报到 APM
BlockMonitor.onBlock { event ->
APMApi.uploadBlockEvent(
type = event.type,
cost = event.costMs,
stack = event.stack,
context = getActivityContext()
)
}
Choreographer 还能做什么
1. 给动画选择最佳时间点
Choreographer.getInstance().postFrameCallback {
// 下一帧开始时,做动画启动
propertyAnimator.start()
}
避免动画在 vsync 中段启动,导致一帧丢两次。
2. 帧率统计(不是简化的 FPS)
// 滑动列表时的精确掉帧统计
class RecyclerViewFPSMonitor {
fun attach(rv: RecyclerView) {
rv.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(rv: RecyclerView, dx: Int, dy: Int) {
// 在 scroll 期间统计帧率
}
})
}
}
3. JankStats(Android 11+)
JankStats.createAndTrack(window) { frame ->
// frame.isJank = true 时记录
if (frame.isJank) {
Logger.log("UI jank: ${frame.frameDurationUiNanos / 1_000_000} ms")
}
}
这是 Android 11+ 官方的卡顿检测 API。涵盖 measure、layout、draw、swap、dispatch 五个阶段的最大耗时。值得用。
实际排查案例
案例:用户反馈「首页滑动卡顿」。
收集:
• APM 数据显示 32% 用户首页 jank rate > 5%
• 卡顿时长集中在 100-300ms
检查:
•
BlockMonitor抓到主线程栈:90% 的卡顿都在RecyclerView.onBindViewHolder里• onBindViewHolder 里调用了
Glide.load(url).into(view)但没缓存优化:
• 改
imageView用固定 size,避免 Glide 重采样• 把 IO 移到
Dispatchers.IO• 加 ViewPool 复用
结果:
• 滑动卡顿降到 5% 以下
小结
Choreographer 是 Android 卡顿检测的官方机制。结合 JankStats、getAllStackTraces()、Looper 监控,能做出生产级的 APM。卡顿问题不仅是技术问题,是体验问题——能卡顿 200ms 用户就能感知。投入 1-2 天搭一套卡顿检测,长远受益。
下一篇聊聊今日头条的屏幕适配方案,它解决了 Android 碎片化最痛的点。