3、帧率监控原理:Android帧率计算方式、Choreographer、FrameMetrics

说到帧率监控,很多人第一反应就是——看FPS呗。没错,FPS(Frames Per Second)是最直观的指标。但你真的搞清楚Android系统是怎么算出这个数字的吗?或者,我们到底应该监控哪些数据才真正有用?

我个人习惯把这些东西拆成三块:帧率怎么算、谁在驱动每一帧、怎么拿到每一帧的细节。这三块加起来,才够做一个靠谱的帧率监控。

帧率计算方式:不是简单的「1秒÷帧数」

先纠正一个常见误区。很多人以为帧率就是统计1秒内显示了多少帧,然后算平均值。举个反例:如果一个动画前0.5秒卡成狗,只渲了10帧,后0.5秒飞起,渲了50帧。平均FPS是60,但用户体感就是卡。所以单纯的平均FPS没有意义。

真正监控帧率,我建议用两种方式:

  1. 逐帧耗时法:记录每一帧从开始到结束的耗时,耗时超过16.67ms(60fps)就算丢帧。适合做卡顿率统计。
  2. 滑动时间窗口法:比如统计最近1秒或0.5秒内的帧数,算出瞬时FPS。这样能捕捉到瞬时的掉帧。
方法特点适用场景
逐帧耗时法记录每帧实际渲染耗时,精度高分析卡顿原因、帧生成时间分布
滑动时间窗口法计算单位时间内的帧数,平滑但滞后实时显示FPS、性能仪表盘

核心指标:真正该关注的是掉帧率(超过16.67ms的帧占比)和Jank(连续两帧都掉帧的严重卡顿)。光看FPS数字容易被骗。

Choreographer:每一帧的“发令枪”

那每一帧到底是谁喊开始的?答案是Choreographer。它是Android渲染系统的调度核心,负责在VSync信号到来时,通知各个监听者开始工作。

我举个例子。你写一个Compose界面,点击按钮后状态变了,Compose会触发重组。实际上重组并不是立刻发生的——你必须等到下一个VSync信号。Choreographer在收到VSync后,会依次执行:

  • 输入事件处理:触摸、按键等
  • 动画回调:处理Animator、插值器等
  • 遍历/测量/布局:View系统的onMeasure、onLayout,或者Compose的Composition
  • 绘制:最终把UI绘制到Surface上

这些步骤全都在一帧内完成。如果任何一个阶段超时,就会导致丢帧。所以Choreographer的每一帧回调其实就是一个天然的计时点。我曾经在调试一个列表滑动卡顿问题时,就是通过hook Choreographer的帧回调,定位到某个onDraw里做了图片解码。

FrameMetrics:系统给我们的“黑匣子”

如果Choreographer是发令枪,那FrameMetrics就是每一帧的详细日志。从Android 7.0开始,Google提供了FrameMetrics这套API,用来获取每一帧在各个阶段的耗时。

这个API非常细:你可以拿到一帧从开始到结束的总耗时,还能拆成下面几个阶段:

帧阶段含义典型耗时范围
UNKNOWN未知阶段,一般忽略—
INPUT_HANDLING处理输入事件的时间0.1~2ms
ANIMATION执行动画回调的时间0.5~5ms
LAYOUT_MEASURE布局测量时间(View系统)1~10ms
DRAWView/Composable的绘制时间1~15ms
SYNC同步到SurfaceFlinger的时间0.1~1ms
COMMAND_ISSUE提交GPU命令的时间0.5~3ms
SWAP_BUFFERS交换缓冲区(等待VSync)有时会很长

实战技巧:我个人最常用的是FrameMetrics.INTENDED_VSYNC_TIMESTAMP和FrameMetrics.DEADLINE,前者是预期的VSync时间,后者是该帧必须完成的时间。通过对比实际的完成时间,能快速判断是渲染超时还是被调度延迟了。

使用FrameMetrics的方式也很简单:通过Window.OnFrameMetricsAvailableListener来注册一个监听器。系统在每一帧完成后,会回调给你一个FrameMetrics对象,里面包含了上面那些阶段的耗时。

window.addOnFrameMetricsAvailableListener(
    { window, frameMetrics, dropCountSoFar ->
        val totalDuration = frameMetrics[FrameMetrics.TOTAL_DURATION]
        val drawDuration = frameMetrics[FrameMetrics.DRAW_DURATION]
        // 这里可以记录、上报、或者绘制到UI上
    },
    Handler(Looper.getMainLooper())
)

注意:FrameMetrics只在Android 7.0以上的设备上可用。如果你的插件需要兼容低版本,需要自己通过Choreographer的FrameCallback来计算耗时。另外,注册回调一定要绑定到主线程的Handler上,不然拿不到数据。

三者如何配合?一张图看懂

讲到这里,我们把这三者的关系画出来:

帧率监控原理:三个核心模块的关系 App UI状态变化 触发重组/重绘 等待VSync Choreographer VSync信号到达,按顺序调度 输入事件 & 动画回调 测量 / 布局 / 绘制 交换缓冲区(Swap) FrameMetrics 收集 onFrameMetricsAvailable 回调 开发者获取帧数据 计算FPS、掉帧率、各阶段耗时 箭头方向:数据流 + 调度流

实战中怎么用这些原理?

上面说了那么多理论,最终还是要落地到代码里。我自己在做Compose UI Test的帧率监控插件时,遵循了这么几步:

  1. 获取Window对象:在测试中可以通过composeTestRule.activity拿到Activity,再通过window属性获得。
  2. 注册FrameMetrics监听:在测试的before中注册,在after中取消注册,避免影响后续测试。
  3. 数据清洗:Android系统在App刚启动或切换页面时会有一两帧异常值(比如耗时特别长的initial帧),需要过滤掉。
  4. 计算关键指标:比如总耗时超过16.67ms算一次掉帧,连续掉帧算一次Jank,最后生成卡顿率报告。
  5. 输出到报告:最好把每帧的详细耗时(绘制、布局、输入等)也记录下来,方便定位瓶颈。

避坑经验:我曾经遇到过同一个操作在不同手机上帧数据差异巨大的情况。后来发现是部分设备开启了硬件层叠层(Hardware Layer)导致Choreographer的调度次数增加。遇到这种问题,别只看总FPS,一定要看每帧的SWAP_BUFFERS耗时,如果这个数值特别大,说明GPU缓冲区交换被堵塞了。

另外,关于Choreographer的帧回调,我自己在低版本设备上用过Choreographer.FrameCallback来计算帧耗时。原理很简单:记录回调的时间戳,下一次回调减去上一次就是这一帧的耗时。不过这个办法有个缺点——Choreographer回调本身是在主线程的,如果你在主线程做耗时操作,它就会延迟触发,导致你算出来的帧耗时偏大。所以后来我弃用了这个方案,全部迁移到FrameMetrics上。

好了,本章的核心就这些。记住三点:帧率不能只看平均值,Choreographer负责触发每一帧的工作,FrameMetrics给了你每一帧的详细画像。搞懂这三样,写一个帧率监控插件就不难了。