Administrator
发布于 2026-08-25 / 24 阅读
0
0

系列文章《Android 工程师的 100 篇进阶笔记》第 59 篇:Choreographer 与卡顿监控:从掉帧到 FPS 监测

上一篇讲了 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)的卡顿检测思路:

实现

检测原理

触发条件

Choreographer 帧时间

检查两帧间隔

> 5 帧

主线程 Looper Printer

设置 Printer 监测 message 处理

message 处理时间 > 阈值

Stack Sample

定时 dump 主线程堆栈

主线程阻塞 > 阈值

每种都有局限。生产环境通常组合使用:

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 卡顿检测需要解决:

  1. false positive:GC、IO 阻塞、正常动画帧不算卡顿

  2. false negative:系统调度延迟、SLAB 分配

  3. 采样率:不能所有用户都采样

  4. 聚合上报:不能每帧上报

通常做法:

// 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 五个阶段的最大耗时。值得用。

实际排查案例

案例:用户反馈「首页滑动卡顿」。

  1. 收集:

    • APM 数据显示 32% 用户首页 jank rate > 5%

    • 卡顿时长集中在 100-300ms

  2. 检查:

    • BlockMonitor 抓到主线程栈:90% 的卡顿都在 RecyclerView.onBindViewHolder 里

    • onBindViewHolder 里调用了 Glide.load(url).into(view) 但没缓存

  3. 优化:

    • 改 imageView 用固定 size,避免 Glide 重采样

    • 把 IO 移到 Dispatchers.IO

    • 加 ViewPool 复用

  4. 结果:

    • 滑动卡顿降到 5% 以下

小结

Choreographer 是 Android 卡顿检测的官方机制。结合 JankStats、getAllStackTraces()、Looper 监控,能做出生产级的 APM。卡顿问题不仅是技术问题,是体验问题——能卡顿 200ms 用户就能感知。投入 1-2 天搭一套卡顿检测,长远受益。

下一篇聊聊今日头条的屏幕适配方案,它解决了 Android 碎片化最痛的点。


评论