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 方法中会通过 Surface#lockCanvas 方法创建一个 Canvas(在英文中是“画布”的意思) 对象,然后进入 View#draw 流程,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; // 1...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; // 2try {canvas = mSurface.lockCanvas(dirty); // 3canvas.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 {... }
}
一个 Canvas 对象从 ViewRootImpl 传给 View,View 的各个方法(draw、dispatchDraw 和 drawChild)都只接收 Canvas 对象,每个 View 都要把其想要展示的内容传递到 Canvas 对象中。
// /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 中有一个 Bitmap 类型的对象,这个 Bitmap 才是真正的画布。
// /frameworks/base/graphics/java/android/graphics/Canvas.java
public class Canvas extends BaseCanvas {private Bitmap mBitmap;
}
那么,Surface 是什么呢?以下是 Surface 的部分源码:
/*** Handle onto a raw buffer that is being managed by the screen * 由屏幕管理的原始缓冲区*/
public class Surface implements Parcelable {private final Canvas mCanvas = new CompatibleCanvas();public Canvas lockCanvas(Rect inOutDirty)throws Surface.OutOfResourcesException, IllegalArgumentException {synchronized (mLock) {checkNotReleasedLocked();if (mLockedObject != 0) {throw new IllegalArgumentException("Surface was already locked");}mLockedObject = nativeLockCanvas(mNativeObject, mCanvas, inOutDirty);return mCanvas;}}
}
从注释上可以知道,Surface 是一块原始缓冲区。 在 Android 中,所有的 View 都由窗口管理,而每个窗口都会关联一个 Surface。在屏幕上绘制内容之前,都需要先获得 Surface,然后用 2D/3D 引擎(Skia/OpenGL)在这个缓冲区上绘制内容。 绘制完成之后,会通知 SurfaceFlinger 将绘制内容(Frame Buffer)渲染到屏幕上去。关于 SurfaceFlinger,之后会做详细解释。
屏幕渲染分为软件渲染和硬件渲染,Canvas 对象的来源也有两个:
- 一是走软件渲染时,在 ViewRootImpl 中创建,从源码上看,ViewRootImpl 本身就会创建一个 Surface 对象,然后用 Surface 获取出一个 Canvas 对象,再传递给 View,由 View 进行具体的绘制;
- 二是走硬件加速,会由 hwui 创建 Canvas 对象;
因此,draw 的触发逻辑也有两条:
- 没有硬件加速时,走的是 ViewRootImpl#performTraversals —> performDraw —> draw —> drawSoftware —> View#draw;
- 启动硬件加速时,走的是 ViewRootImpl#performTraversals —> performDraw —> draw —> ThreadedRenderer.java#draw
2 软件绘制和硬件绘制
Android 4.0 开始引入硬件加速机制,之前走的都是软件渲染。如果有一些 API 是不支持硬件加速的,需要进行手动关闭。
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 选择的是 Skia(2D) 和 OpenGL(3D) 来绘制图形,图形库可以直接控制 GPU 产生图形数据(Canvas.draw —> native —>Skia/OpenGL —> GPU)。
软件绘制使用的是 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:是一种跨平台的 2D/3D 图形绘制规范接口,OpenGL ES 是针对嵌入式设备的,对手机做了优化;
- Vulkan:Vulkan 是用来替换 OpenGL 的,它同时支持 2D 和 3D 绘制,也更加轻量级;
3 Android 黄油计划(Project Butter)
虽然引入了硬件加速机制,加快了渲染的时间,但是对于 GUI(Graphical User Interface 图形用户界面)的流畅度、响应度,特别是动画这一块的流畅程度和其他平台(如 Apple)差距仍然是很大的。一个重要的原因就在于,GUI 整体的渲染缺少协同。 最大的问题在于动画,动画要求连续不断的重绘,如果仅靠客户端来触发,帧率不够,由此造成的流畅度也不好。
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 帧的画面,也就是 1000/60 ≈ 16ms 生成一帧画面;
- 12 fps:由于人类眼睛的特殊生理结构,如果所看画面帧率高于 10~12 fps 的时候,就会认为是连贯的;
- 24fps:有声电影的拍摄及播放帧率均为 24fps,对一般人来说是可以接受的;
- 30fps:早起的高动态电子游戏,帧率小于 30fps 时就会显得不连贯,这是因为没有动态模糊使流畅度降低;
- 60fps:在与手机交互过程中,如果触摸和反馈在 60fps 以下是可以被人感觉出来的,会感到画面卡顿和迟滞现象;
- 屏幕刷新频率(Refresh Rate):单位是赫兹(Hz),表示屏幕在一秒内刷新画面的次数,刷新频率取决于硬件的固定参数,该值对于特定的设备来说是一个常量。如 60Hz、144 Hz 表示每秒刷新 60 次或 144 次。
对于一个特定的设备来说,帧率和屏幕刷新速率没有必然的关系。但是两者需要协同工作,才能正确的获取图像数据并进行绘制。比如 Android 手机的刷新频率是 60Hz,那么一帧数据需要在 16ms 内完成。
屏幕并不是一次性的显示画面的,而是从左到右(行刷新,水平刷新,Horizontal Scanning)、从上到下(屏幕刷新,垂直刷新,Vertiacl Scanning)逐行扫描显示,不过这一过程快到人眼无法察觉。以 60Hz 的刷新频率的屏幕为例,即 1000/60 ≈ 16ms,16ms 刷新一次。
如果上一帧的扫描没有结束,屏幕又开始扫描下一帧,就会出现扫描撕裂的情况:
因此,GPU 厂商开发出了一种防止屏幕撕裂的技术方案 —— Vertical Synchronization,即 VSync,垂直同步信号或时钟中断。VSync 是一个硬件信号,它和显示器的刷新频率相对应,每当屏幕完成一次垂直刷新,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();}
}final class TraversalRunnable implements Runnable {@Overridepublic void run() {doTraversal();}
}
final TraversalRunnable mTraversalRunnable = new TraversalRunnable();
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 三缓存
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)。因此,双缓存的缺陷在于,当 CPU/GPU 绘制一帧的时间超过 16ms 时,就会产生 Jank。
如下图所示,A、B 和 C 都是 Buffer。蓝色代表 CPU 生成的帧数据,绿色代表 GPU 执行生成帧数据,黄色代表生成帧完成:
CPU/GPU 处理数据的时间过长,超过了一帧绘制的时间,在第二个时间段内,由于 GPU 还在处理 B 帧数据,无法进行数据交换,导致 A 帧被重复绘制。而 B 帧数据在绘制完成后又缺乏 VSync 信号,只能等待下一次的 VSync 信号的来临。因此,在这一过程中,有一段时间是被浪费的。
3.3.3 三缓存(Triple Buffer)
于是有了三缓存:
工作原理同双缓冲类似,只是多了一个 Back Buffer。三缓冲机制有效的利用了等待 VSync 信号的时间,可以帮助我们减少 jank。
如果有第三个 Buffer 能让 CPU/GPU 在这个时候继续工作,那就完全可以避免第二个 Jank 产生了。
需要注意的是,第三个缓存并不是总存在的,只有当需要的时候才会创建。 之所以这样,是因此三缓存会显著增加用户输入到显示的延迟时间。如上图,帧 C 是在第 2 个刷新周期产生的,却是在第 4 个周期显示的。
4 Android 渲染的整体架构
以下是 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 从图像缓冲区将数据取出,通过硬件合成器 Hardware Composer 进行加工及合成 layer,最终交给 HAL 展示;
- HAL:硬件抽象层,把图形数据展示到设备屏幕;
整个图像渲染系统就是采用了生产者-消费者模式,屏幕渲染的核心,是对图像数据的生产和消费。 生产和消费的对象是 BufferQueue 中的 Buffer。
前面我们已经说过,Surface 是一块原始缓冲区,每个窗口都会管理一个 Surface,屏幕在绘制内容之前,先要获得 Surface,然后在再用 2D/3D 引擎(Skia/OpenGL)在这个缓冲区上进行绘制(Surface —> Canvas)。
SurfaceFlinger 是图像数据的消费者,它的作用主要是接收 Graphic Buffer,然后交给 HWComposer 合成,合成完的数据,最终交给了 Frame Buffer(帧缓冲区)。
软件渲染
再没有硬件加速之前主要是通过 Skia 这种软件方式渲染 UI,如下所示:
整个渲染流程看上去比较简单,但是正如前面所说,CPU 对于图形处理器并不是那么高效,这个过程完全没有利用 GPU 的高性能。
硬件渲染
Android 3.0,支持硬件加速,需要手动打开,Android 4.0 就默认开启硬件加速了,开启硬件加速流程如下:
硬件加速绘制最核心就是通过 GPU 完成 Graphic Buffer 的内容绘制。
RenderThread 线程
经过 Android 4.1 的 Projcet Butter 黄油计划之后,Android 的渲染性有了很大的改善。不过你有没有注意到这样一个问题,虽然利用了 GPU 的图形高性能运算,但是从计算到通过 GPU 绘制到 Frame Buffer,整个计算和绘制都在 UI 主线程中完成。UI 线程任务过于繁重。如果整个渲染过程比较耗时,可能造成无法响应用户的操作,进而出现卡顿的情况。GPU 对图形的渲染能力更胜一筹,如果使用 GPU 并在不同的线程绘制渲染图形,那么整个流程会更加顺畅。
在 Android 5.0 之通过引进 RenderThread(渲染线程),我们就可将 UI 渲染工作从 Main Thread 释放出来,交由 RenderThread 来处理,从而也使得 Main Thread 可以更专注高效地处理用户输入,这样使得在提高 UI 绘制效率的同时,也使得 UI 具有更高的响应。