前言:
本文通过阅读各种文章和源码总结出来的,如有不对,还望指出
目录
正文
基础概念
视觉暂留
逐行扫描
帧
CPU/GPU/Surface:
帧率、刷新率、画面撕裂
画面撕裂
Android屏幕刷新机制的演变
单缓存(Android4.0之前)
双缓存
VSync(垂直同步)
三缓存
源码解析
正文
扯这个机制之前,先了解几个基础概念
基础概念
视觉暂留
物体在快速运动时, 当人眼所看到的影像消失后,人眼仍能继续保留其影像0.1-0.4秒左右的图像,这种现象被称为视觉暂留现象。是人眼具有的一种性质。人眼观看物体时,成像于视网膜上,并由视神经输入人脑,感觉到物体的像。但当物体移去时,视神经对物体的印象不会立即消失,而要延续0.1 -0.4秒的时间,人眼的这种性质被称为“眼睛的视觉暂留”。
逐行扫描
显示器并不是一次性将画面显示到屏幕上,而是从左到右边,从上到下逐行扫描,顺序显示整屏的一个个像素点。
帧
在视频领域中,帧表示一副画面,就比如
在纸上看电影:闭关37天,爆肝手绘700张画,完成一段星爷的搞笑喜剧
嗯,就这样的,一张纸表示一个画面,表示一帧,通过快速翻动,利用视觉暂留的原理实现了电影或者动画的呈现
CPU/GPU/Surface:
- Surface,这里只是简单说明,后续会更新个文章专门讲这个,通俗的讲,这个就是Android的view跟其子类都是绘制在上面的,可以说没有这个Surface的渲染,就没有android那些花里胡哨的布局界面
- CPU:
中央处理器
,主要用于计算数据,在Android中主要用于三大绘制流程中Surface的计算过程,起着生产者的作用 - GPU,
图像处理器
,主要用于游戏画面的渲染,在Android中主要用于将CPU计算好的Surface数据合成后放到buffer中,让显示器进行读取,起着消费者的作用。
帧率、刷新率、画面撕裂
- 帧率,也叫帧数,俗称fps,就我们游戏中的fps就是这玩意,指的是GPU1秒内渲染多少画面到buffer中,单位是fps,比如60 fps就是指的是1秒内可以渲染60帧画面到buffer中
- 刷新率,指的是屏幕在1秒内从buffer中读取数据的次数,单位是HZ,常见屏幕刷新率为60Hz,和帧率不一样刷新率是个固定值,更硬件参数有关
画面撕裂
简单点说就是显示器把两帧或两帧以上的数据同时显示在同一个画面的现象,比如这样
画面撕裂的原因:我们知道屏幕刷新率是固定的,假设为60HZ,正常情况下当我们的GPU的帧率也是60fps的时候,GPU绘制完一帧,屏幕刷新一帧,这样是不会出问题的,但是随着GPU显卡性能的提升,GPU的帧率超过60fps后,就会出现画面撕裂的情况,
就比如,GPU的帧率是120fps,每秒钟可以处理120张画面到buffer中,然后就会出现的问题就是每1/120秒就会有一张画面进入buffer中,下一个1/120秒,下一站画面就会把上面一张给取代,然后屏幕的刷新率是60Hz,就可以理解为它一秒内只能扫描到60张画面进行显示。这样就会出现画面撕裂了,因为屏幕提取画面是从上到下一行一行(逐行扫描)把画面显示出来的,本来要1/60秒才能显示完,结果显示一半的时候1/120秒,下一张画面就塞进来了,这时候屏幕肯定会照样会从buffer中拿到画面进行显示的,这样就会造成一半上一张的,一半是下一张的情况
所以其本质是帧率和屏幕刷新率的不一致导致的撕裂。
Android屏幕刷新机制的演变
单缓存(Android4.0之前)
那可能大家要说了,等屏幕一帧刷新完成后,再将新的一帧存到buffer中不就可以了,那你要知道,早期的4.0之前设备是只有一个buffer,且其并没有buffer同步的概念,屏幕读取buffer中的数据时,GPU是不知道的,屏幕读取的同时,GPU也在写入,导致buffer被覆盖,出现同一画面使用的是不同帧的数据。用图来表示,就是这样的:
那既然是因为使用同一个Buffer引起的画面撕裂,使用两个buffer不就可以了?
谷歌当时也觉得可行,然后说干就干,于是开启了黄油计划
双缓存
针对上面的问题关键:图像绘制和屏幕读取这一帧数据使用的是一块Buffer
可以想到的一种解决方案是:不让它们使用同一块Buffer
,用两块让它们各自为战不就好了,这么想的思路确实是对的。分析下这个具体过程:
当图像绘制和屏幕显示有各自的Buffer
后,GPU
将绘制完的一帧图像写入到后缓存(Back Buffer),显示器显示的时候只会去扫描前缓存的数据(Frame Buffer)
,在显示器未扫描完一帧前,前缓存区内数据不改变,屏幕就只会显示一帧的数据,避免了撕裂。
但这样做的最关键一步是,什么时候去交换两块Buffer
的数据?
等 Back buffer准备完成一帧数据以后就进行?肯定是不行的,因为此时屏幕还没有完整显示上一帧内容,这样弄肯定是会出问题的。看来只能是等到屏幕处理完一帧数据后,才可以执行这一操作了。
当扫描完一个屏幕后,设备需要重新回到第一行以进入下一次的循环,此时有一段时间空隙,称为VerticalBlanking Interval(VBI)。那,这个时间点就是我们进行缓冲区交换的最佳时间。因为此时屏幕没有在刷新,也就避免了交换过程中出现 屏幕撕裂的状况。大致流程如下:
VSync(垂直同步)
VSync(垂直同步)是VerticalSynchronization的简写,它利用VBI时期出现的vertical sync pulse(垂直同步脉冲)来保证双缓冲在最佳时间点才进行交换。另外,交换是指各自的内存地址,可以认为该操作是瞬间完成。
所以说V-sync这个概念并不是Google首创的,它在早年的PC机领域就已经出现了。
不过,需要注意的是:开启垂直同步后,就算GPU准备好了Back Buffer
的数据,但屏幕没有逐行扫描完前缓冲区的,就不允许发生帧传递
。GPU就空载着,等待显示器扫描完毕后的VBlank阶段
。
意思就是说,开启VSync后,GPU的帧率被强制锁定为跟屏幕刷新率一样,这就解释了在玩游戏的时候,如果开启了垂直功能,游戏中显示的帧率一直处于一个帧率之下,这个时候显示帧率值就是屏幕刷新率。
这样就能解决问题了嘛?我们来通过一张具体的流程图来看看
Jank
在下面的图中,你将会经常看到Jank
一词语,它术语翻译,叫做卡顿。卡顿很容易理解了,比如我们在打游戏时,经常会遇到同一帧画面在那显示很久没有变化,这就是所谓的Jank
场景1
先看下最原始的,只有双缓冲,没有VSync
影响下,它会发生什么:
图中Display
为显示屏, VSync
仅仅指双缓冲的交换。
(1)Display
显示第0帧,此时 CPU/GPU
渲染第1帧画面,并且在 Display
显示下一帧前完成。
(2)Display
正常渲染第一帧
(3)出于某种原因,如 CPU
资源被占用,系统没有及时处理第2帧数据,当 Display
显示下一帧时,由于数据没处理完,所以依然显示第1帧,即发生“Jank” ,
上图出现的情况就是第2帧没有在显示前及时处理,导致屏幕多显示第一帧一次,导致后面的帧都延时了。根本原因是因为第2帧的数据没能在VBlank
时(即本次完成到下次扫描开始前的时间间隙)完成。
上图可以看到的是由于CPU资源被抢,导致第2帧的数据处理时机太晚,假设在双缓存交换完成后,CPU资源可以立刻为处理第二帧所用,就可以处理完成该帧的数据(当前前提是该帧的处理数据不超过刷新一帧的时间),也就避免了Jank的出现。
场景2
在双缓冲下,有了VSync
会怎么样呢?
如图,当且仅当收到VSync
通知(比如16ms触发一次),CPU
和GPU
立刻开始计算然后把数据写入Buffer
。VSync
同步信号的出现让绘制速度和屏幕刷新速度保持一致,使CPU
和GPU
充分利用了这16.6 ms的时间,减少了jank。
场景3
但是如果界面比较复杂,CPU/GPU处理时间真的超过16.6ms的话,就会发生:
图中可以看出当第1个 VSync
到来时GPU
还在处理数据,这时缓冲区在处理数据B,被占用了,此时的VBlank阶段
就无法进行缓冲区交换,屏幕依然显示前缓冲区的数据A,发生了jank
。当下一个信号到来时,此时 GPU 已经处理完了,那么就可以交换缓冲区,此时屏幕就会显示交互后缓冲区的数据B了。
由于硬件性能限制,我们无法改变 CPU/GPU 渲染的时间,所以第一次的Jank是无法避免的,但是在第二次信号来的时候,由于GPU占用了后缓冲区,没能实现缓冲区交换,导致屏幕依然显示上一帧A。由于此时,后缓冲区被占用了,就算此时CPU是空闲的也不能处理下一帧数据。增大了后期Jank的概率,比如图中第二个Jank的出现。
出现该问题本质的原因是,两个缓冲区各自被GPU/CPU、屏幕显示所占用。导致下一帧的数据不能被处理。
三缓存
找到问题的本质了,那很容易想到,再加一个Buffer
(这里叫它中Buffer
)参与,让添加的这个中Buffer
和后Buffer
交换,这样既不会影响到显示器读取前Buffer
,又可以在后Buffer
缓冲区不能处理时,让中Buffer
来处理。像下图这样:
当第一个信号到来时,前缓冲区在显示A、后缓冲区在处理B,它们都被占用。此时 CPU 就可以使用中缓冲区,来处理下一帧数据C。这样的话,C数据可以提前处理完成,之前第二次发生的Jank
就不存在了,有效的降低了Jank出现的几率。
到这里,可以看出,不管是双缓冲和三缓冲,都会有卡顿、延时问题,只是三缓冲下,减少了卡顿的次数。
那又有人要说了,那就再多开几个不就可以了,是的,buffer越多jank越少,但是你得考虑性价比: 3 buffer已经可以最大限度的避免jank的发生了,再多的buffer起到的作用就微乎其微,反而因为buffer的数量太多,浪费更多内存,得不偿失。
源码解析
我们都知道,View在绘制的时候,最终都要调用ViewRootImpl
的scheduleTraversals
方法(==这个后面会写篇文章扯扯它怎么到这来的),会往MessageQueue
插入同步屏障消息,绘制完成后会移除同步屏障消息。同步屏障消息不懂的看看handler解析(3)-同步消息、异步消息、同步屏障_handler同步消息和异步消息_沙滩捡贝壳的小孩的博客-CSDN博客
@UnsupportedAppUsage
void scheduleTraversals() {
if (!mTraversalScheduled) {
mTraversalScheduled = true;
//插入同步屏障消息
mTraversalBarrier = mHandler.getLooper().getQueue().postSyncBarrier();
mChoreographer.postCallback(
Choreographer.CALLBACK_TRAVERSAL, mTraversalRunnable, null);
if (!mUnbufferedInputDispatch) {
scheduleConsumeBatchedInput();
}
notifyRendererOfFramePending();
pokeDrawLockIfNeeded();
}
}
void unscheduleTraversals() {
if (mTraversalScheduled) {
mTraversalScheduled = false;
//移除同步屏障消息
mHandler.getLooper().getQueue().removeSyncBarrier(mTraversalBarrier);
mChoreographer.removeCallbacks(
Choreographer.CALLBACK_TRAVERSAL, mTraversalRunnable, null);
}
}
为了保证View的绘制过程不被主线程其它任务影响,View在绘制之前会先往MessageQueue插入同步屏障消息,然后再注册Vsync信号监听,Choreographer$FrameDisplayEventReceiver
就是用来接收vsync信号回调的
Choreographer$FrameDisplayEventReceiver
private final class FrameDisplayEventReceiver extends DisplayEventReceiver
implements Runnable {
...
@Override
public void onVsync(long timestampNanos, long physicalDisplayId, int frame) {
...
//
mTimestampNanos = timestampNanos;
mFrame = frame;
Message msg = Message.obtain(mHandler, this);
//1、发送异步消息
msg.setAsynchronous(true);
mHandler.sendMessageAtTime(msg, timestampNanos / TimeUtils.NANOS_PER_MS);
}
@Override
public void run() {
// 2、doFrame优先执行
doFrame(mTimestampNanos, mFrame);
}
}
收到Vsync信号回调,注释1会往主线程MessageQueue
post一个异步消息,保证注释2的doFrame
优先执行。
doFrame
才是View真正开始绘制的地方,会调用ViewRootImpl
的doTraversal
、performTraversals
,
而performTraversals
里面会调用我们熟悉的View的onMeasure
、onLayout
、onDraw
。
参考文章:
通俗易懂的Android屏幕刷新机制 - IM Geek开发者社区-移动开发者社区-开源社区-IM Geek官网
“一文读懂“系列:Android屏幕刷新机制_android 刷新 原理_程序员一东的博客-CSDN博客
视觉暂留_百度百科