1 Android 渲染流程
一般情况下,一个布局写好以后,使用 Activity#setContentView 调用该布局,这个 View tree 就创建好了。Activity#setContentView 其实是通过 LayoutInflate 来把布局文件转化为 View tree 的(反射)。需要注意的是,LayoutInflate 虽然可以帮助创建 View tree,但到这里也仅是以单纯的对象数据存在,这个时候是无法正确的获取 View 的 GUI(Graphical User Interface 图形用户界面)的相关属性的,如大小、位置和渲染状态。
View tree 生成的最后一步就是把根节点送到 ViewRootImpl#setView 中,之后就会进入渲染流程,入口方法是 ViewRootImpl#requestLayout,之后是 ViewRootImpl#scheduleTraversals,最后调用的是 ViewRootImpl#performTraversals,View tree 的渲染流程全都在这里,也就是常说的 measure、layout、draw。View体系与自定义View(三)—— View的绘制流程
以下为 View 的绘制流程/视图添加到 Window 的过程:
总结:文本数据(xml)—> 实例数据(java) —> 图像数据 bitmap,bitmap 才是屏幕(硬件)所需的数据。
在 ViewRootImpl#drawSoftware 方法中创建一个 Canvas 对象,然后进入 View#draw 流程,Canvas 才是实际制作图像的工具,比如如何画点,如何画线,如何画文字、图片等等。
Canvas 对象是从哪里来的呢?
// /frameworks/base/core/java/android/view/ViewRootImpl.java
public final Surface mSurface = new Surface();
private boolean draw(boolean fullRedrawNeeded, boolean forceDraw) {
Surface surface = mSurface;
...
if (!drawSoftware(surface, mAttachInfo, xOffset, yOffset,
scalingRequired, dirty, surfaceInsets)) {
return false;
}
}
private boolean drawSoftware(Surface surface, AttachInfo attachInfo, int xoff,
int yoff, boolean scalingRequired,
Rect dirty, Rect surfaceInsets) {
// Draw with software renderer.
final Canvas canvas;
try {
canvas = mSurface.lockCanvas(dirty);
canvas.setDensity(mDensity);
} catch (Surface.OutOfResourcesException e) {
handleOutOfResourcesException(e);
return false;
} catch (IllegalArgumentException e) {
mLayoutRequested = true; // ask wm for a new surface next time.
return false;
}
try {
...
mView.draw(canvas);
...
}finally {
...
}
}
// /frameworks/base/core/java/android/view/View.java
public void draw(@NonNull Canvas canvas) {
...
}
protected void dispatchDraw(@NonNull Canvas canvas) { }
// /frameworks/base/core/java/android/view/ViewGroup.java
protected boolean drawChild(@NonNull Canvas canvas, View child, long drawingTime) {
return child.draw(canvas, this, drawingTime);
}
一个 Canvas 对象从 ViewRootImpl 传给 View,View 的各个方法(draw、dispatchDraw 和 drawChild)都只接收 Canvas 对象,每个 View 都要把其想要展示的内容传递到 Canvas 对象中。
Canvas 对象的来源有两个:
- 一是走软件渲染时,在 ViewRootImpl 中创建,从源码上看,ViewRootImpl 本身就会创建一个 Surface 对象,然后用 Surface 获取出一个 Canvas 对象,再传递给 View;
- 二是走硬件加速,会由 hwui 创建 Canvas 对象;
因此,draw 的触发逻辑也有两条:
- 没有硬件加速时,走的是 ViewRootImpl#performTraversals —> performDraw —> draw —> drawSoftware —> View#draw;
- 启动硬件加速时,走的是 ViewRootImpl#performTraversals —> performDraw —> draw —> ThreadedRenderer.java#draw
2 软件绘制和硬件绘制
硬件加速绘制是从 Android 4.0 开始支持的,在此之前走的都是软件渲染,也就是从 ViewRoot(Android 4.0 之前是 ViewRoot,之后才是 ViewRootImpl)中持有的 Surface 直接创建 Canvas,然后交给 View 去做具体的绘制。
Skia(二维)、OpenGL(三维)是专门用来制作图形的(Bitmap),可以直接对 GPU 进行调用处理。底层的图像库很多,Android 选择的是这两个。
软件绘制使用的是 Skia 库,是一款能在低端设备,如手机上呈现高质量的 2D 跨平台图形框架,Chrome、Flutter 内部使用的都是 Skia 库。需要注意的是,软件绘制使用的是 Skia 库,但这并不代表 Skia 库不支持硬件加速,从 Android 8 开始,我们可以使用 Skia 进行硬件加速,Android 9 开始默认使用Skia 进行硬件加速。
在处理 3D 场景时,通常使用 OpenGL ES。在 Android 7.0 中添加了对 Vulkan 的支持。Vulkan 的设计目标是取代 OpenGL,Vulkan 是个相当低级别的 API,并且提供了并行的任务处理。除了较低的 CPU 的使用率,VulKan 还能够更好的在多个 CPU 内核之间分配工作。在功耗、多核优化提升会图调用上有非常明显的优势。
Skia、OpenGL、Vulkan 的区别:
- Skia:是 2D 图形渲染库。如果想完成 3D 效果需要 OpenGL、Vulkan、Metal 进行支持。Android 8 开始 Skia 支持硬件加速,Chrome、Flutter 都是用它来完成绘制的;
- OpenGL:是一种跨平台的 3D 图形绘制规范接口,OpenGL ES 是针对嵌入式设备的,对手机做了优化;
- Vulkan:Vulkan 是用来替换 OpenGL 的,它同时支持 2D 和 3D 绘制,也更加轻量级;
除了屏幕,UI 渲染海要依赖两个核心的硬件,CPU 和 GPU:
- CPU(Center Processing Unit 中央处理器),是计算机系统的运算和控制核心,是信息处理、程序运行的最终执行单元;
- GPU(Graphics Processing Unit 图形处理器),是一种专门用于图像运算的处理器,在加计算机系统中通常被称为“显卡”的核心部位就是 GPU;
在没有 GPU 的时代,UI 的绘制任务都是由 CPU 完成的,也就是说,CPU 除了负责逻辑运算、内存管理还要负责 UI 绘制,这就导致 CPU 的任务繁重,性能也会受到影响。
CPU 和 GPU 在结构设计上完全不同,如下所示:
- 黄色部分:Control 控制器,用于协调控制整个 CPU 的运行,包括读取指令、控制其他模块的运行等;
- 绿色部分:ALU(Arithmetic Logic Unit)是算数逻辑单元,用于进行数学、逻辑运算;
- 橙色部分:Cache 和 DRAM 分别为高速缓存和 RAM,用于存储信息;
从上图可以看出,CPU 的控制器较为复杂,而 ALU 数量较少,因此 CPU 更擅长各种复杂的逻辑运算,而不擅长数学尤其是浮点运算。而 GPU 的设计正是为了实现大量的数学运算。GPU 的控制器比较简单,但包含大量的 ALU,GPU 中的 ALU 使用了并行设计,且具有较多的浮点运算单元,可以帮助我们加快 Rasterization(栅格化)操作。
栅格化将 UI 组件拆分到显示器上的不同像素上进行显示。UI 组件在绘制到屏幕之前都要经过 Rasterization(栅格化)操作,是绘制 Button、Shape、Path、String、Bitmap 等显示组件最基础的操作。这是一个非常耗时的操作,GPU 的引入就是为了加快栅格化。
因此,硬件绘制的思想就是 CPU 将 XML 数据转换成实例对象,然后将 CPU 不擅长的图形计算交由 GPU 去处理,由 GPU 完成绘制任务,以便实现更好的性能(CPU 和 GPU 是制图者)。
Android 4.0 开始引入硬件加速机制,但是还有一些 API 是不支持硬件加速的,需要进行手动关闭。
3 Android 黄油计划(Project Butter)
虽然引入了硬件加速机制,加快了渲染的时间,但是对于 GUI(Graphical User Interface 图形用户界面)的流畅度、响应度,特别是动画这一块的流畅程度和其他平台(如 Apple)差距仍然是很大的。一个重要的原因就在于,GUI 整体的渲染缺少协同。最大的问题在于动画,动画要求连续不断的重绘,如果仅靠客户端来触发,帧率不够,由此造成的流畅度也不好。
于是在 Android 4.1 中引入了 Choreographer 以及 Vsync 机制来解决这个问题。
Google 在 2012 年的 I/O 大会上宣布了 Project Butter 黄油计划,并且在 Android 4.1 中正式开启这个机制。Project Butter 主要包含三个组成部分:
- VSync
- Choreographer
- TripBuffer
其中,VSync(Vertical Synchronization) 是理解 Project Butter 的核心。
3.1 VSync
帧率 vs 屏幕刷新频率:
- 帧率(Frame Rate):单位 fps,即 GPU 在一秒内生成的帧数(图片),帧率越高越好。例如电影界采用 24 帧的速度就可以画面非常流畅了,而 Android 系统则采用更高的 60fps,即每秒生成 60 帧的画面;
- 屏幕刷新频率(Refresh Rate):单位是赫兹(Hz),表示屏幕在一秒内刷新画面的次数,刷新频率取决于硬件的固定参数,该值对于特定的设备来说是一个常量。如 60Hz、144 Hz 表示每秒刷新 60 次或 144 次。
对于一个特定的设备来说,帧率和屏幕刷新速率没有必然的关系。但是两者需要协同工作,才能正确的获取图像数据并进行绘制。
屏幕并不是一次性的显示画面的,而是从左到右(行刷新,水平刷新,Horizontal Scanning)、从上到下(屏幕刷新,垂直刷新,Vertiacl Scanning)逐行扫描显示,不过这一过程快到人眼无法察觉。以 60Hz 的刷新频率的屏幕为例,即 1000/60 ≈ 16ms,16ms 刷新一次。
如果上一帧的扫描没有结束,屏幕又开始扫描下一帧,就会出现扫描撕裂的情况:
因此,GPU 厂商开发出了一种防止屏幕撕裂的技术方案 —— Vertical Synchronization,即 VSync,垂直同步信号或时钟中断。VSync 是一个硬件信号,它和显示器的刷新频率相对应,通常是 60Hz,每当屏幕完成一次垂直刷新,VSync 信号就会被发出,作为显示器和图形引擎之间时间同步的标准,其本质意义在于保证界面的流畅性和稳定性。
3.2 Choreographer
Choreographer(编舞者)根据 VSync 信号来对 CPU/GPU 进行绘制指导,协调整个渲染过程,对于输入事件响应、动画和渲染在时间上进行把控,以保证流畅的用户体验。
Choreographer 在 ViewRootImpl 中的使用:
// /frameworks/base/core/java/android/view/ViewRootImpl.java
final Choreographer mChoreographer;
void scheduleTraversals() {
if (!mTraversalScheduled) {
mTraversalScheduled = true;
mTraversalBarrier = mHandler.getLooper().getQueue().postSyncBarrier();
mChoreographer.postCallback(
Choreographer.CALLBACK_TRAVERSAL, mTraversalRunnable, null);
notifyRendererOfFramePending();
pokeDrawLockIfNeeded();
}
}
Choreographer 的作用:
- 布局请求:当视图需要进行布局操作时,Choreographer 发出布局请求并协调布局操作的执行。它确保将布局请求与其他动画和绘制操作同步,避免冲突和界面不一致;
- 绘制同步:Choreographer 负责将绘制操作与显示器的刷新同步。它通过监听系统的 VSync 信号,去定绘制操作的时机,避免图形撕裂和卡顿现象;
- 输入事件处理:Choreographer 管理和分发用户输入事件,确保它们在正确的时间点被处理,并与动画和渲染操作同步。这有助于提供更流畅和响应敏捷的用户交互体验;
- 动画调度:Choreographer 调度和管理应用程序中的动画效果,确保动画按照预定的帧率和时间表进行播放,并平滑地过渡到下一个动画阶段;
Choreographer 使用了以下几种机制来实现流畅的界面渲染:
- VSync(垂直同步信号):Choreographer 监听系统发出的 VSync 信号。每当收到 VSync 信号时,Choreographer 就知道屏幕即将进行一次刷新。这样,它可以根据 VSync 信号的时间点来安排渲染和动画操作的触发和执行;
- 时间戳(Timestamping):Choreographer 在收到 VSync 信号时,会获取一个时间戳,以记录每次 VSync 信号的时间点。这个时间戳可以用于计算渲染和动画的操作时间和持续时间,从而在合适的时机进行调度和执行;
- 界面刷新(Frame Refresh):Choreographer 使用 VSync 信号和时间戳来决定界面的刷新时机。它根据预定的逻辑和优先级,调度动画、布局和绘制操作,以确保它们在下一次 VSync 信号到来之前完成。这样可以避免界面的撕裂或卡顿现象,提供流畅的用户体验;
其实这个 Choreogarpher 这个类本身并不会很复杂,简单来说它就是负责定时回调,主要方法有 postFrameCallback 和 removeFrameCallback,FrameCallback 是个比较简单的接口:
// /frameworks/base/core/java/android/view/Choreographer.java
public interface FrameCallback {
public void doFrame(long frameTimeNanos);
}
3.3 TripBuffer 三缓存
以下是 Android 渲染的整体架构:
Android 渲染的整体架构可以分为以下几部分:
- 图像生产者(image stream producers):主要有 MediaPlayer、CameraPreview、NDK(Skia)、OpenGL ES。其中,MediaPlayer 和 Camera Preview 是通过直接读取图像源来生成图像数据。NDK(Skia)、OpenGL ES 是通过自身的绘制能力产生的图像数据。
- 图像缓冲区(BufferQueue):一般是三缓冲区。NDK(Skia)、OpenGL ES、Vulkan 将绘制的数据存放在图像缓冲区;
- 图像消费者(image stream consumers): SurfaceFlinger 从图像缓冲区将数据取出,进行加工及合成,最终交给 HAL 展示;
- HAL:硬件抽象层,把图形数据展示到设备屏幕;
整个图像渲染系统就是采用了生产者-消费者模式,屏幕渲染的核心,是对图像数据的生产和消费。生产和消费的对象是 BufferQueue 中的 Buffer。
无论开发者使用哪种 API,一切内容都会渲染到 Surface(Canvas —> Surface) 上。Surface 表示缓冲队列中的生产方,缓冲队列通常被 SurfaceFlinger 消耗。如 draw 方法会把绘制指令通过 Canvas 传递给 framework 层的 RenderThread 线程。RenderThread 线程通过 surface.dequeue 的到缓冲区 graphic buffer,然后在上面通过 OpenGL 来完成真正的渲染命令,把缓冲区交还给 BufferQueue 队列中。
3.3.1 单缓存
在没有引入 Vsync 的时候,屏幕显示图像的工作流程是这样的:
如上图所示,CPU/GPU 将需要绘制的数据存放在图像缓冲区中,屏幕从图像缓冲区中获取数据,然后刷新显示,这是典型的生产者-消费者模型。
理想的情况是帧率(GPU)和刷新频率(屏幕)相等,每绘制一帧,屏幕就显示一帧。而实际情况是,二者之间没有必然的联系,如果没有锁来控制同步,很容易出现问题。如当帧率大于刷新频率时,屏幕还没有刷新到 n-1 帧的时候,GPU 已经生成第 n 帧了,屏幕刷新的时候绘制的就是第 n 帧数据,这个时候屏幕上半部分显示的是第 n 帧数据,屏幕的下半部分显示的是第 n-1 帧之前的数据,这样显示的图像就会出现明显的偏差,也就是“tearing”,如下所示:
如果刷新频率大于帧率的时候,屏幕就会拿不到下一帧数据,导致黑屏。
3.3.2 双缓存(Double Buffer)
这里的双缓存和计算机组成原理中的“二级缓存”不是一回事。
为了解决单缓存的 tearing 问题,双缓存和 VSync 应运而生。双缓存的模型如下所示:
两个缓存分别为 Back Buffer 和 Frame Buffer。GPU 向 Back Buffer 中写数据,屏幕从 Frame Buffer 中读数据。VSync 信号负责调用 Back Buffer 到 Frame Buffer 的复制操作,可以认为该复制操作在瞬间完成。
在双缓冲模式下,工作流程是这样的:在某个时间点,一个屏幕刷新周期完成,进入短暂的刷新空白期。此时,VSync 信号产生,先完成复制操作,然后通知 CPU/GPU 绘制下一帧图像。复制操作完成后屏幕开始下一个刷新周期,即将刚复制到 Frame Buffer 的数据显示到屏幕上。
在双缓冲模型下,只有当 VSync 信号产生时,CPU/GPU 才会开始绘制。这样,当帧率大于刷新频率时,帧率就会被迫跟刷新频率保持同步,从而避免“tearing”现象。
需要注意的是,当 VSync 信号发出时,如果 CPU/GPU 正在生产帧数据,此时不会发生复制操作。当屏幕进入下一个刷新周期时,就会从 Frame Buffer 中取出“老”数据,而非正在产生的帧数据,即两个刷新周期显示的是同一帧数据,这就是“掉帧”现象(Dropped Frame,Skipped Frame,Jank)。
3.3.3 三缓存(Triple Buffer)
双缓存的缺陷在于,当 CPU/GPU 绘制一帧的时间超过 16ms 时,就会产生 Jank。
如下图所示,A、B 和 C 都是 Buffer。蓝色代表 CPU 生成的 Display List,绿色代表 GPU 执行 Display List 中的命令生成帧,黄色代表生成帧完成:
如果有第三个 Buffer 能让 CPU/GPU 在这个时候继续工作,那就完全可以避免第二个 Jank 产生了。
于是有了三缓存:
工作原理同双缓冲类似,只是多了一个 Back Buffer。
需要注意的是,第三个缓存并不是总存在的,只有当需要的时候才会创建。之所以这样,是因此三缓存会显著增加用户输入到显示的延迟时间。如上图,帧 C 是在第 2 个刷新周期产生的,却是在第 4 个周期显示的。
SurfaceFlinger 是图像数据的消费者。在应用程序请求创建 Surface 的时候,SurfaceFlinger 会创建一个 Layer。Layer 是 SurfaceFlinger 操作合成的基本单元。所以,一个 Surface 对应一个 Layer。当应用程序把绘制好的 Graphic Buffer 数据放入 BufferQueue 后,接下来的工作就是 SurfaceFlinger 来完成了。
SurfaceFlinger 的作用主要是接收 Graphic Buffer,然后交给 HWComposer 或者 OpenGL 做合成,合成完成后,SurfaceFlinger 会把最终的数据提交给 FrameBuffer。