Android黄油工程详解与实践

1,784 阅读5分钟

背景

Android4.1之前,UI不流畅的问题非常的明显,Google决定在后续的每个版本都进行一个重要问题的优化,4.1的重要优化就是提高流畅度,整个项目称之为黄油计划,整个过程对渲染体系几乎是进行了重构。

这里先简单的介绍一下黄油计划的几个核心设计:

  1. 通过将Vsync引入到局部系统中去实现稳定的帧率

  2. 增加三缓冲(Triple Buffer)进一步保证帧率的稳定

  3. 编舞者(Choreographer)配合Vsync实现事件输入、动画、绘制等任务。

  4. 同步屏障 保障UI渲染的优先级。

为什么要学习这古老的设计?

黄油工程大概是在2012年诞生的,都2024年了,为什么还要学习黄油工程? 学了黄油工程有什么用?

优雅的设计总是相同的,可以复用在其他场景,后面我会分享一个参考黄油工程的实际案例。

技术解读

这里会从VSync局部系统的引入、三缓冲、编舞者、消息屏障四个方面逐步解读。

局部系统加入 VSync

image.png

未加入VSync之前

image.png

(图中display上的数字显示的是具体的哪一帧,比如第一个周期显示的是帧1,第2个周期因为帧2还没生成,最终还是现实的帧1,这时候就发生了丢帧,这一帧我们就称之为jank帧)

从图中可以看出,由于CPU没有时钟的控制,有时候在一个VSync内,可能会渲染2帧,有时候一帧也不会渲染,导致的结果就是在5个时钟周期内生产了5帧,最终只渲染了3帧(1/3/4这几帧)。

GPU/CPU 加入 Vsync 同步

控制每一帧的CPU、GPU时间跟 上一张图一致,看看最终的效果。

image.png 从图中可以看出,同样的CPU跟GPU的使用率,5个时钟周期产生了5帧,渲染了4帧。

如果GPU跟CPU的执行时长超过一个时钟周期的话(比如刷新率是60帧,每个周期是16ms,每一帧CPU+GPU的执行为18ms),使用VSYNC+双缓冲问题就严重了。

image.png 从图中可以看出,几乎会丢失一半的帧,如果刷新率是60帧的话,最终只有30帧。如果发生了这种情况,最终效果可能还不如黄油工程之前,于是,官方又引入了三缓冲。

加入三缓冲

image.png

从图中可以清晰的看出,只有第一帧渲染失败,后续的每一帧都渲染成功

编舞者(Choreographer)

编舞者是什么?

编舞者的作用是承上启下,用来接收系统层VSync信号,接收到信号后,通知处理。

这里有三个核心的类去完成一个VSync周期的渲染。

作用
FrameDisplayEventReceiver接收VSync信号,发送异步消息,调用 Choreographer 的doFrame方法。
Choreographer监听修改UI的各种任务,收到FrameDisplayEventReceiver的回调后,逐个回调回去,比如调用ViewRootImp的doTransval()。
ViewRootImp这是View树最顶层的一个ViewParent,但它只是实现了ViewParent的接口,但实际上并不是一个View,可以将其理解为一个桥梁,控制view的绘制流程以及与其他进程通信。

Choreographer 最核心的代码是持有一个CallbackQueue数组,数组大小一般是5。

  1. 第1个是 CALLBACK_INPUT

  2. 第2个是 CALLBACK_ANIMATION

  3. 第3个是 CALLBACK_INSETS_ANIMATION

  4. 第4个是 CALLBACK_TRAVERSAL

  5. 第5个是 CALLBACK_COMMIT

public final class Choreographer {
...
    private final CallbackQueue[] mCallbackQueues;
    
    public static final int CALLBACK_COMMIT = 4;
    private static final int CALLBACK_LAST = CALLBACK_COMMIT;
    public Choreographer() {
        mCallbackQueues = new CallbackQueue[CALLBACK_LAST + 1];
    }
...
}

编舞者的工作时序图

这里以一个button设置文字为text1,点击事件设置文字为text2为例。(这里省略了一些细节,比如布局如果是自适应,修改文字会触发requestLayout)

image.png

从图中第二个VSync周期可以看出,input事件的回调会影响后续的回调。这里的回调顺序优先级分别为,Input事件,Animation,InsetsAnimation,Traversal、Commit。

同步屏障

同步屏障是什么?

同步屏障是一个特殊的Message,它的Target为null,使用方式跟普通Message一样。

目前Message有三种:

消息类型说明
同步消息普通的消息,平常我们调用 handler.post() 方法生成的就是同步消息。
异步消息普通消息调用 msg.setAsynchronous(true); 就会变成异步消息。在没有同步屏障的情况下,跟同步消息一样。
同步屏障Target 为null,不能自己创建,只要是通过handler post的方法,最终都会默认设置一个target。如果要创建同步屏障,必须调用MessageQueue 的 postSyncBarrier,这是一个private方法,业务层想要使用的话,可以通过反射调用。

当这个Message加入到队列之后,后续的同步消息都不会执行,相当于是给同步消息亮起了红灯,给异步消息开启了绿色通道。

目前只有在 ViewRootImp scheduleTraversals 的时候会去调用同步屏障,在doTraversal 的时候 remove 掉同步屏障。

class ViewRootImp{
    ...
    void scheduleTraversals() {
        if (!mTraversalScheduled) {
            mTraversalScheduled = true;
            mTraversalBarrier = mHandler.getLooper().getQueue().postSyncBarrier();
            mChoreographer.postCallback(
                    Choreographer.CALLBACK_TRAVERSAL, mTraversalRunnable, null);
            notifyRendererOfFramePending();
            pokeDrawLockIfNeeded();
        }
    }
    ...
}

class MessageQueue {
    private int postSyncBarrier(long when) {
        // Enqueue a new sync barrier token.
        // We don't need to wake the queue because the purpose of a barrier is to stall it.
        synchronized (this) {
            final int token = mNextBarrierToken++;
            final Message msg = Message.obtain();
            msg.markInUse();
            msg.when = when;
            msg.arg1 = token;
    
            Message prev = null;
            Message p = mMessages;
            if (when != 0) {
                while (p != null && p.when <= when) {
                    prev = p;
                    p = p.next;
                }
            }
            if (prev != null) { // invariant: p == prev.next
                msg.next = p;
                prev.next = msg;
            } else {
                msg.next = p;
                mMessages = msg;
            }
            return token;
        }
    }
    
    public void removeSyncBarrier(int token) {
        // Remove a sync barrier token from the queue.
        // If the queue is no longer stalled by a barrier then wake it.
        synchronized (this) {
            Message prev = null;
            Message p = mMessages;
            while (p != null && (p.target != null || p.arg1 != token)) {
                prev = p;
                p = p.next;
            }
            if (p == null) {
                throw new IllegalStateException("The specified message queue synchronization "
                        + " barrier token has not been posted or has already been removed.");
            }
            final boolean needWake;
            if (prev != null) {
                prev.next = p.next;
                needWake = false;
            } else {
                mMessages = p.next;
                needWake = mMessages == null || mMessages.target != null;
            }
            p.recycleUnchecked();
    
            // If the loop is quitting then it is already awake.
            // We can assume mPtr != 0 when mQuitting is false.
            if (needWake && !mQuitting) {
                nativeWake(mPtr);
            }
        }
    }
}

直播相关实践

多缓冲采集编码推流

这里的技术方案参考了 Vsync 三缓冲的设计理念,帧率取得了较大提升。

原有架构

image.png

多缓冲架构架构

image.png

通用性卡顿解决方案

具体原理:通过插入同步屏障提高Vsync的响应时间。