面试题 Android 如何实现自定义View 固定帧率绘制

公主: 别写代码了, 我们一起去看电影吧

曾经遇到的面试题, 如何实现自定义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的requestLayoutinvalidate申请重新绘制. 实际上这两个函数最终都会调用到ViewRootImplscheduleTraversals这一函数向Choreographer注册绘制的Callback

代码解释

scss 复制代码
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时间

kotlin 复制代码
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
            )
        }
    }

}
arduino 复制代码
//第一帧绘制
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信号到来的时间. 该接口常被用于帧率/掉帧的检测

csharp 复制代码
public interface FrameCallback {
    public void doFrame(long frameTimeNanos);
}

在自定义View中, 我们可以通过监听Vsync信号到来的时间以及当前绘制的时间还有屏幕刷新率推算出我们期望下一次绘制所对应的Vsync信号时间的间隔, 然后发送延时消息触发View绘制

kotlin 复制代码
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()
    }
}
yaml 复制代码
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左右完成了绘制

代码仓库

CustomFpsDraw

相关推荐
Android小码家5 分钟前
llama.cpp+Android应用定制
android·llama
龚礼鹏14 分钟前
Android应用程序 c/c++ 崩溃排查流程二——AddressSanitizer工具使用
android·c语言·c++
Android-Flutter17 分钟前
android compose DropdownMenu 菜单项列表 使用
android
蝎子莱莱爱打怪1 小时前
我的2025年年终总结
java·后端·面试
我的写法有点潮1 小时前
JS中对象是怎么运算的呢
前端·javascript·面试
镜花水月linyi1 小时前
Cookie、Session、JWT 的区别?
后端·面试
青莲8431 小时前
Java内存模型(JMM)与JVM内存区域完整详解
android·前端·面试
林栩link1 小时前
【车载Android】「场景引擎」设计思路分享
android
青莲8431 小时前
Java内存回收机制(GC)完整详解
java·前端·面试
CCPC不拿奖不改名1 小时前
python基础:python语言中的函数与模块+面试习题
开发语言·python·面试·职场和发展·蓝桥杯