曾经遇到的面试题, 如何实现自定义View 1s内固定帧率的绘制.
当时对Android理解不深, 考虑的不全面, 直接回答了在onDraw结束时通过postDelay发送一个(1000 / 帧数)ms的延时消息触发invalidate
进行下一次绘制. 但实际上这样做存在明显的问题 实际上1s绘制的帧数是不符合期望帧数的. 个人觉得主要还是考察对Android渲染机制的理解以及熟悉程度
Android渲染机制
先简单介绍下Android的渲染机制
绘制入口
在Android中, 当系统Vsync信号到来之后Choreographer
会执行doFrame
函数将Choreographer
内注册的各种类型的Callback一一执行. 这其中包含了Choreographer.CALLBACK_TRAVERSAL
这一类型的Callback. 在Callback的实现中, 将会调用ViewRootImpl.doTraversal()
然后开始Android绘制的三大流程即 measure
, layout
, draw
. 不考虑高刷屏幕的话, Vsync信号会每间隔16.6ms到来一次. 基于此, 应用得以完成每秒60帧的绘制
创建绘制任务
当View需要重新绘制时, 会调用到View的requestLayout
或invalidate
申请重新绘制. 实际上这两个函数最终都会调用到ViewRootImpl
的scheduleTraversals
这一函数向Choreographer
注册绘制的Callback
代码解释
ViewRootImpl
void invalidate() {
mDirty.set(0, 0, mWidth, mHeight);
if (!mWillDrawSoon) {
// 计划绘制
scheduleTraversals();
}
}
void scheduleTraversals() {
if (!mTraversalScheduled) {
mTraversalScheduled = true;
//插入同步屏障
mTraversalBarrier = mHandler.getLooper().getQueue().postSyncBarrier();
// 向mChoreographer中注册Callback
mChoreographer.postCallback(
Choreographer.CALLBACK_TRAVERSAL, mTraversalRunnable, null);
notifyRendererOfFramePending();
pokeDrawLockIfNeeded();
}
}
//向mChoreographer注册的Callback类
final class TraversalRunnable implements Runnable {
@Override
public void run() {
doTraversal();
}
}
void doTraversal() {
if (mTraversalScheduled) {
mTraversalScheduled = false;
//移除同步屏障
mHandler.getLooper().getQueue().removeSyncBarrier(mTraversalBarrier);
if (mProfile) {
Debug.startMethodTracing("ViewAncestor");
}
//绘制三大流程入口
performTraversals();
if (mProfile) {
Debug.stopMethodTracing();
mProfile = false;
}
}
}
Choreographer
void doFrame(long frameTimeNanos, int frame,
DisplayEventReceiver.VsyncEventData vsyncEventData) {
// 省略大部分代码
//执行Input类型Callback
doCallbacks(Choreographer.CALLBACK_INPUT, frameData, frameIntervalNanos);
//执行Input类型Callback
doCallbacks(Choreographer.CALLBACK_ANIMATION, frameData, frameIntervalNanos);
//执行动画类型Callback
doCallbacks(Choreographer.CALLBACK_INSETS_ANIMATION, frameData,
frameIntervalNanos);
//执行绘制类型Callback
doCallbacks(Choreographer.CALLBACK_TRAVERSAL, frameData, frameIntervalNanos);
//执行Commit类型Callback
doCallbacks(Choreographer.CALLBACK_COMMIT, frameData, frameIntervalNanos);
}
如何实现固定帧率的绘制(60帧为例)
为什么postDelay帧间隔存在问题
假设Vsync信号在第0ms时到达, 而我们的onDraw函数执行完时已经达到了第X ms(0 < X < 16 不考虑掉帧的情况). 此时如果按照上面所讲的方式发送一个16ms的延时Message. 那么invalidate
被触发的时机是在第二次Vsync执行doFrame之后了, 也就是说下一次绘制实际上是在第三个Vsync信号到来执行doFrame的时候. 由于invalidate
调用时机不正确实际上绘制的帧数与预期是完全不符的
从以下日志中可以看出绘制60帧实际上花了大约1800ms 远大于实际期望的1s时间
class CustomView1 : View {
companion object {
private const val TAG = "CustomView1"
private const val DELAY = 16L
}
private var mSum = 0
private val mRunnable = Runnable {
invalidate()
}
override fun onDraw(canvas: Canvas?) {
super.onDraw(canvas)
Log.d(TAG, "onDraw")
mSum++
if (mSum < 60) {
postDelayed(
mRunnable,
DELAY
)
}
}
}
//第一帧绘制
2023-11-13 23:47:27.185 29343-29343 CustomView1 com.example.fps.test D onDraw
//第60帧绘制
2023-11-13 23:47:28.996 29343-29343 CustomView1 com.example.fps.test D onDraw
如何实现1s内固定帧率的绘制
如果想要在1s内均匀的绘制完固定的帧率, 我们需要控制好invalidate
的调用时机. 那么我们就需要了解下一次需要绘制的Vsync到来的时间, 在Vsync信号到来之前就调用invalidate
实际上对于非高刷屏幕, 我们可以直接在onDraw结束时就调用invalidate
这样1s内60帧View的onDraw都将被执行. 但是对于高刷屏幕或者60以外的帧数的话, 就需要做一些额外处理了.
Andorid在Choreographer
中提供了接口可以用来监听Vsync信号到来的时间. 该接口常被用于帧率/掉帧的检测
public interface FrameCallback {
public void doFrame(long frameTimeNanos);
}
在自定义View中, 我们可以通过监听Vsync信号到来的时间以及当前绘制的时间还有屏幕刷新率推算出我们期望下一次绘制所对应的Vsync信号时间的间隔, 然后发送延时消息触发View绘制
private val mRunnable = Runnable {
Log.d(TAG, "run invalidate")
invalidate() // 触发绘制
Choreographer.getInstance().postFrameCallback(this) //继续监听
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
val expectDrawTime = mLastVsyncTime + DRAW_INTERVAL //期望绘制的时间
var targetVsyncTime = mLastVsyncTime + mDoFrameInterval
while (targetVsyncTime + mDoFrameInterval <= expectDrawTime) { //得出对应的Vsync时间
targetVsyncTime += mDoFrameInterval
}
val curTime = SystemClock.uptimeMillis()
var delayTime = targetVsyncTime - curTime
if (delayTime > mDoFrameInterval) {
delayTime -= mDoFrameInterval / 2 // 不能将delay时间设置为刚好Vsync时间 不然会错过
Log.d(TAG, "postDelayed targetVsyncTime:$targetVsyncTime curTime:$curTime delayTime:$delayTime")
postDelayed(
mRunnable,
delayTime
)
} else { // 下一次Vsync时间马上到来直接触发
Log.d(TAG, "direct invalidate")
mRunnable.run()
}
}
30帧(第一帧与最后一帧时间)
2023-11-16 21:42:59.323 17976-17976 CustomView2 com.example.fps.test D onDraw
2023-11-16 21:43:00.290 17976-17976 CustomView2 com.example.fps.test D onDraw
60帧(第一帧与最后一帧时间)
2023-11-16 21:40:54.886 17390-17390 CustomView2 com.example.fps.test D onDraw
2023-11-16 21:40:55.878 17390-17390 CustomView2 com.example.fps.test D onDraw
120帧(第一帧与最后一帧时间)
2023-11-16 21:41:41.243 17650-17650 CustomView2 com.example.fps.test D onDraw
2023-11-16 21:41:42.225 17650-17650 CustomView2 com.example.fps.test D onDraw
从以上日志可以看出, 基本在1s左右完成了绘制
为了帮助大家能够能顺利的面试,我这边知识梳理了一些核心的知识点,也准备了不少的电子书和面试笔记等学习文档,这些笔记将各个知识点进行了完美的总结(包含了很多内容:Android 基础、Java 基础、Android 源码相关分析、常见的一些原理性问题等等)。
- Android 知识点汇总:
https://qr18.cn/CyxarU
- 面试题笔记:
https://qr18.cn/CgxrRy