写点什么

「Android 渲染」图像是怎样显示到屏幕上的?

用户头像
李小四
关注
发布于: 2021 年 01 月 26 日
「Android渲染」图像是怎样显示到屏幕上的?

我们每天花很多时间盯着手机屏幕,不知道你有没有好奇过:


手机屏幕上的这些东西是怎么显示出来的?


这时候来了一位 Android 程序员(当然也可以是 iOS 或者是前端程序员)说: 这里显示的其实是一个 View 树,我们看到的都是大大小小的 View。


。。。听起来很有道理,我们也经常指着屏幕说这个 View 怎么怎么样,可问题又来了:


屏幕认识 View 吗?

我们把一个 View 发给屏幕,它就显示出来了?


程序员老兄又来了: 屏幕当然不能识别 View,它作为一个硬件,只能根据收到的数据改变每个像素单元的数据,这样整体来看,用户就发现屏幕上的内容变化了。至于 View 的内容是如何一步一步转化成屏幕可是识别的数据的,简单讲可以分成三步:

  1. 准备材料

  2. 画出来

  3. 显示到屏幕


。。。听起来很有道理,可问题又来了:


这也太简单了吧,能详细一点吗?


那可就说来话长了。。。


1. 准备材料


对于measure layoutdraw,Android 工程师(大都)非常熟悉,我们常常在执行了onDraw()方法后,一个让人自豪的自定义 View 就显示出来了。在实际的 Android 绘制流程中,第一步就是通过measure layoutdraw这些步骤准备了下面的材料:


  • 画什么

  • 画的参数


画什么


在 Android 的绘制中,我们使用 Canvas API 进行来告诉表示画的内容,如drawCircle() drawColor() drawText() drawBitmap() 等,也是这些内容最终呈现在屏幕上。


画的参数


  • 画的坐标


坐标系: Android 图像坐标系以左上角为 0 点,x 轴左负右正,y 轴上负下正,z 轴内负外正;


Viewlayout基准点是父容器的左上角,View 的draw内容基准点是View的左上角。


根节点父容器是当前WindowDecorView,它的布局信息由WindowManger来管理。


到此,当前应用所有 View 放在哪个位置就确定了。


  • 画的层级(重叠时的覆盖关系)


View 之间并不是井水不犯河水,经常出现重叠的情况,重叠时该怎样覆盖和显示正确的 View 大体遵循以下规则:


- 指定z-order情况下,数值最大的显示在最上层,剩下的降序显示。

- 在没有指定z-order的情况下,子 View 覆盖父容器,相同父容器 View 后添加的显示在最上层。


  • 特定参数


不同的方法需要的参数不同,比如drawCircle()会有圆心和半径,drawText()需要对应的 text 资源,drawBitmap()需要对应的 Bitmap 资源等等。


在当前应用中,View 树中所有元素的材料最终会封装到DisplayList对象中(后期版本有用RenderNodeDisplayList又做了一层封装,实现了更好的性能),然后发送出去,这样第一阶段就完


当然就有一个重要的问题:


这个阶段怎么处理 Bitmap 呢?


会将 Bitmap 复制到下一个阶段(准确地讲就是复制到 GPU 的内存中)。

现在大多数设备使用了 GPU 硬件加速,而 GPU 在渲染来自 Bitmap 的数据时只能读取 GPU 内存中的数据, 所以需要赋值 Bitmap 到 GPU 内存,这个阶段对应的名称叫Sync&upload。另外,硬件加速并不支持所有 Canvas API,如果自定义 View 使用了不支持硬件加速的 Canvas API(参考[Android 硬件加速文档](https://developer.android.com/guide/topics/graphics/hardware-accel#unsupported)),为了避免出错就需要对 View 进行软件绘制,其处理方式就是生成一个 Bitmap,然后复制到 GPU 进行处理。


这时可能会有问题:如果 Bitmap 很多或者单个 Bitmap 尺寸很大,这个过程可能会时间比较久,那有什么办法吗?


当然有(做作。。。)


  • 预上传: [Bitmap.prepareToDraw()(from Android 7.0 - Nougat)](https://developer.android.com/reference/android/graphics/Bitmap#prepareToDraw())


  • 使用Hardware-Only Bitmap(from Android 8.0 - Oreo)


从 Android 8.0 开始,支持了Hardware-Only Bitmap类型,这种类型的 Bitmap 的数据只存放在 GPU 内存中,这样在Sync&upload阶段就不需要 upload 这个 Bitmap 了。使用很简单,只需要将Options.inPreferredConfig赋值为Bitmap.Config.HARDWARE即可。


这种方式能实现特定场景的极致性能,提供便利的同时,这种 Bitmap 的某些操作是受限的(毕竟数据存储只存储在 GPU 内存中),可以查看Glide的总结(为啥不是 google?。。。)


关于 Bitmap 这里再多说一句:

Bitmap 的内存管理一直是 Android 程序员很关心的问题,毕竟它是个很占内存的大胖子,在 Android3.0~Android7.0,Bitmap 内存放在 Java 堆中,而 android 系统中每个进程的 Java 堆是有严格限制的,处理不好这些 Bitmap 内存,容易导致频繁 GC,甚至触发 Java 堆的OutOfMemoryError。从 Android8.0 开始,bitmap 的像素数据放入了 native 内存,于是 Java Heap 的内存问题暂时缓解了。


Tip:


第一步的所有操作都在应用进程的UI Thread中执行。
复制代码




2. 画出来


现在材料已经备好,我们要真正地画东西了。


谁来画


接下来就要把东西画出来了,画出来的过程就是把前面的材料转化成一个堆像素数据的过程,也叫栅格化,那这个活儿谁来干呢?


候选人只有两个:


  • CPU: 软件绘制,使用Skia方案实现,绘制慢。

  • GPU: 硬件加速绘制,使用OpenGL ESVulkan方案实现,绘制快很多。


大部分情况下,都是 GPU 来干这个活儿,因为 GPU 真的特别快!!!


怎么画


所谓的“画”,对于计算机来讲就是处理图像,其实就是根据需要(就是 DisplayList 中的命令)对数据做一些特定类型的数学运算,最后输出结果的过程。我们看到的每一帧精美界面,(几乎)都是 GPU 吭哧吭哧"算"出来的,这个就有疑问了:


既然是运算,CPU 也能算啊,为什么 GPU 更快呢?


我们简单地聊聊 CPU 与 GPU 的区别:

CPU 的核心数通常是几个,单个核心的主频高,功能强大,擅长串行处理复杂的流程;

GPU (Graphics Processing Unit) 有成百上千个核心,单个核心主频低,功能有限,擅长(利用超多核心)大量并行简单运算;正如它的名字一样,GPU 就是为图像绘制这个场景量身定做的硬件(所以使用 GPU 也叫硬件加速),后来也被用到挖矿和神经网络中。

图片肯定没有视频直观,我们从感性的角度感受一下 GPU 到底有多快,我想下面的视频看过就不会忘掉,你会被 GPU 折服:

<iframe src="//player.bilibili.com/player.html?aid=12558564&bvid=BV15x411q7a7&cid=20663978&page=1" autoPlay="false" frameborder="0" framespacing="0" allowFullScreen="true" scrolling="no"></iframe>


看这个视频,我们对于“加速”应该有了更深刻的印象,这里不再进一步分析 CPU 和 GPU 更微观的差别(因为不懂),我想已经讲明白为什们 GPU 更快了。


另外,在 GPU 开始绘制之前,系统也做了一些优化(对 DisplayList 中的命令进行预处理),让整个绘制流程更加高效:


  • 增量更新:两帧图像之间只是个别 View 改变了,那么只绘制更新的 View 即可,实现方案是DisplayListDamaged Area


  • 指令重排序(reordering)、指令合并(merging)、批处理(batching)


在硬件绘制之前,第一步中输出的信息会转化成 OpenGL ES中对应的绘制命令( gl commands ),这些命令原本是按照 View 树的层级关系来递归输出的。可这些命令中有很多(看起来)相同的操作,比如我们在绘制一个列表时,同样属性的文字(标题,内容,昵称等)要绘制十几次,这时候如果把绘制命令重新排序、进行一定的合并和批处理,性能会提升很多。如下图:


- 未经优化, 按顺序绘制

- 优化后,一次绘制出所有的文字


第二步的具体过程还是很复杂的,比如涉及到 Alpha 绘制,相关的优化会失效,详情查看文章《为什么alpha渲染性能低?》


画在哪里


至于画在哪里,我们现在理解为一个缓冲(Buffer)中就可以了,具体的机制放在第三步讲。


到此,我们已经画(绘制)完了图像内容,把这个内容发送出去,第二步的任务就完成了。


Tip:


在Android L 之前,第二步的操作在应用进程的UI Thread中执行;在Android L 之后, 第二步的操作在应用进程的RenderThread中执行。
复制代码




3. 显示到屏幕


我们知道,除了我们的应用界面,手机屏幕上同时显示着其他内容,比如 SystemUI(状态栏、导航栏)或者另外的悬浮窗等,这些内容都需要显示到屏幕上。所以要先把这些界面的内容合成,然后再显示到屏幕


在讲合成图像之前,我们有必要知道这些界面图像(Buffer)是怎么传递的:


BufferQueue


Android 图形架构中,使用生产者消费者模型来处理图像数据,其中的图像缓冲队列叫BufferQueue, 队列中的元素叫Graphic Buffer,队列有生产者也有消费者;每个应用通常会对应一个Surface,一个Surface对应着一个缓冲队列,每个队列中Graphic Buffer的数量不超过 3 个, 上面两步后绘制的图像数据最终会放入一个Graphic Buffer,应用自身就是队列的生产者(BufferQueue在 Android 图形处理中有广泛的应用,当前只讨论界面绘制的场景)。


每个Graphic Buffer本身体积很大,在从生产者到消费者的传递过程中不会进行复制的操作,都是用匿名共享内存的方式,通过句柄来跨进程传递。


我们可以通过以下命令来查看手机当前用到的Graphic Buffer情况:


adb shell dumpsys SurfaceFlinger
复制代码



这个命令会输出很多内容,尾部会有当前正在使用的GraphicBuffer信息,从上图中我们看到,当前正在使用的微信共有 3 个Graphic Buffer,所有的 Buffer 共占用接近 90MB 的内存,这些内存在应用不再显示后就马上回收。


关于上面的命令,你可能会好奇这个SurfaceFlinger是什么东西啊?


SurfaceFlinger


上文提到过每个应用(一般)对应一个Surface,从字面意思看,SurfaceFlinger就是把应用的Surface投射到目的地。


实际上,SurfaceFlinger就是界面(Buffer)合成的负责人,在应用界面绘制的场景,SurfaceFlinger充当了BufferQueue的消费者。绘制好的Graphic Buffer会进入(queue)队列,SurfaceFlinger会在合适的时机(这个时机下文讨论),从队列中取出(acquire)Buffer 数据进行处理。


我们知道,除了我们的应用界面,手机屏幕上同时显示着其他内容,比如 SystemUI(状态栏、导航栏)或者另外的悬浮窗等,这些部分的都有各自的 Surface,当然也会往对应的BufferQueue中生产Graphic Buffer


如下图所示,SurfaceFlinger获取到所有 Surface 的最新 Buffer 之后,会配合 HWComposer 进行处理合成,最终把这些 Buffer 的数据合成到一个FrameBuffer中,而 FrameBuffer 的数据会在另一个合适的时机(同样下文讨论)迅速地显示到屏幕上,这时用户才观察到屏幕上的变化。



关于上图中的HWComposer,它是 Android HAL 接口中的一部分,它定义了上层需要的能力,让由硬件提供商来实现,因为不同的屏幕硬件差别很大,让硬件提供商驱动自己的屏幕,上层软件无需关心屏幕硬件的兼容问题。


事实上,如果你观察足够仔细的话,可能对上图还有疑问:


SurfaceFlinger部分,

为什么有的 Buffer 是直接发到HWComposer合成,

而有的 Buffer 需要通过 GPU 合并成一个新的 Buffer 才能合成。


同学你观察很仔细(...),事实上,这是SurfaceFlinger合成过程中重要的细节,对于不同Surface的 Buffer, 合成的方法有两种:


  • 把 Buffer 发到HWComposer,直接写到FrameBuffer的对位置

  • 由于某些操作HWComposer不能支持直接写(但是 GPU 知道),部分 Buffer 的内容需要通过写到一个临时的 Buffer 中(HWComposer知道这个临时的 Buffer 该怎么写),最终把这临时的 Buffer 写到FrameBuffer的对应位置。


显然第一种方法是最高效的,但为了保证正确性,Android 系统结合了两种方法。具体实现上,SurfaceFlinger会询问(prepare)HWComposer是否支持直接合成,之后按照结果做对应处理。


有的朋友憋不住了:


你上面说合成的触发、FrameBuffer显示到屏幕上都需要合适的时机,

到底是什么时机?


Good question! (太做作了。。。)


为了保证最好的渲染性能,上面各个步骤之间并不是串行阻塞运行的关系,所以有一个机制来调度每一步的触发时机,不过在此之前,我们先讲介绍一个更基础的概念:


屏幕刷新率


刷新率是屏幕的硬件指标,单位是 Hz(赫兹),意思是屏幕每秒可以刷新的次数。


这里稍微展开一下,我们之所以在屏幕上看到东西在"动"(看视频或者滑动列表),其原理是屏幕在快速地播放不同的帧,相邻帧的图像只有很小的位移,加上大脑的残留效应,我们感官上就觉得这个东西在连续地动;有时候我们觉得手机界面卡顿,原因是两个帧之前的时间太长了,大脑残留内容消失了。


想要达到(看起来)流畅的效果,就要确保帧率足够大,一般电影(视频)的帧率不小于 24 帧,手机屏幕上不小于 40 帧,人眼就不易察觉卡顿了。


在 2021 年的今天,Android 旗舰手机通常配置了 90Hz~120Hz 的高刷新率屏幕,iPhone 与其他 Android 中低端手机会配置 60Hz 的屏幕。用 60Hz 来计算,1000 ➗ 60 ≈ 16.7ms。这就是要求每一帧数据在 16ms 之内绘制完成的原因。


当然,人眼的分辨能力还是远超 60Hz,如果用一段时间 90Hz 或者 120Hz 的设备,再回到 60Hz,就会觉得不爽。人眼也能适应越来越高的分辨率,现在大部分手机屏幕的分辨率超过了 400ppi,有些上了 2k 屏已经超过了 500ppi,再回到乔帮主在 iPhone4 定义的视网膜屏幕(指在距离屏幕 10inch 的距离,超过 300ppi 的分辨率是人眼难以分辨的,iPhone4/5/6/7/8 的分辨率都是 326ppi),会察觉到明显的颗粒感。


回到问题,既然屏幕这个硬件每隔一段时间(如 60Hz 屏幕是 16ms)就刷新一次,最佳的方案就是屏幕刷新时开始新一轮的绘制流程,让一次绘制的流程尽可能占满整个刷新周期,这样掉帧的可能性最小。基于这样的思考,在 Android4.1(JellyBean)引入 VSYNC(Vertical Synchronization - 垂直同步信号)


收到系统发出的 VSYNC 信号后,有三件事会同时执行(并行)


  • (第一步和第二步)应用开始绘制 Graphic Buffer

  • (第三步)SurfaceFlinger 开始合成 FrameBuffer

  • 屏幕刷新: 显示 FrameBuffer 中的数据


下图描述了没有掉帧时的 VSYNC 执行流程,现在我们可以直接回答问题了: 合适的时机就是 VSYNC 信号



从上图可以看出,在一次 VSYNC 信号发出后,屏幕立即显示 2 个 VSYNC 周期(60Hz 屏幕上就是 32ms)之前开始绘制的图像,这当然是延迟,不过这个延迟非常稳定,只要前面的绘制不掉链子,界面也是如丝般顺滑。当然,Android 还是推出一种机制让延迟可以缩小到 1 个 VSYNC 周期,详情可参考VSYNC-offset


实际上,系统只会在需要的时候才发出 VSYNC 信号,这个开关由 SurfaceFlinger 来管理。应用也只是在需要的时候才接收 VSYNC 信号,什么时候需要呢?也就是应用界面有变化,需要更新了,具体的流程可以参考View.requestLayout()View.invalidate()Choreographer(编舞者)的调用过程。这个过程会注册一次 VSYNC 信号,下一次 VSYNC 信号发出后应用就能收到了,然后开始新的绘制工作;想要再次接收 VSYNC 信号就需要重新注册,可见,应用界面没有改变的时候是不会进行刷新的。


我们可以看到,无论是 VSYNC 开关,还是应用对 VSYNC 信号的单次注册逻辑,都是秉承着按需分配的原则,这样的设计能够带来 Android 操作系统更好的性能和更低的功耗。


Tip:


第3步的操作执行在系统进程中
复制代码



终于。。。说完了


我们简单回顾一下,

  1. 准备材料

  2. 画出来

  3. 显示到屏幕


更形象一点就是:


<iframe src="https://v.qq.com/txp/iframe/player.html?vid=v3223am30cm" autoPlay="false" frameborder="0" framespacing="0" allowFullScreen="true" scrolling="no"></iframe>



Android 渲染的演进


之所以有这一节,是因为随着 Android 版本的更替,渲染方案也发生了很多变化。为了简化表达,我们前文都以当前最新的方案来讲解,事实上,部分流程的实现方式在不同版本可能会有较大的变化,甚至在之前版本没有实现方案,这里我尽可能详细地列出 Android 版本更迭过程中与渲染相关的更新(包括监控工具)。

Android 3.0 (Honeycomb)


  • 硬件加速

  • `DisplayList

Android 4.0 (Ice Cream Sandwich)


  • 默认开启硬件加速

Android 4.1 (Jelly Bean)



- VSYNC


- Triple-Buffering(3 缓冲)


Android 应用的 BufferQueue 中的 Graphic Buffer 数量经历了 1 个到 2 个再到 3 个的变化。一次次地提升了性能。

单 Buffer 时代,Buffer 没有锁机制,也没有 VSYNC 来协调绘制的节奏,可能 Buffer 绘制到一半屏幕刷新了,结果就出现屏幕上下两部分界面错位的问题(如下图)。为了解决这个问题,于是锁机制与双 Buffer 就来了。


双 Buffer 时代,锁机制的加入就带来了对锁的争夺,加上没有 VSYNC 机制,界面上下错位的问题倒是没有了,但界面卡顿依然严重。

ProjectButter 项目带来了 VSYNC, 与 三 Buffer,这是因为 CPU(第一步)、GPU(第二步)、与 SurfaceFlinger(第三步)都会抢占 Buffer,如果上一次的 GPU 渲染(第二步)比较耗时,此时下一次的 VSYNC 信号来了,那么系统会分配第三个 Buffer 给 CPU。(有能力三 Buffer,但非默认)


  • Systrace: 功能强大的采集工具

Android 4.2 (Jelly Bean)

Android 5.0 (Lollipop)

  • RenderThread

Android5.0 之后,绘制阶段移到了单独线程 RenderThread(渲染线程)中,进一步提升渲染性能。

  • RenderNode

Android 7.0 (Nougat)

  • 支持 Vulkan

Android 8.0 (Oreo)

  • Bitmap 内存转移至 native 内存

  • Hardware-Only Bitmap


如果你居然能读到这里,那我猜你对下面的参考文章也会感兴趣:

Reference


https://source.android.com/devices/graphics


https://hencoder.com/tag/hui-zhi/


https://www.youtube.com/watch?v=wIy8g8yNhNk&feature=emb_logo


https://www.youtube.com/watch?v=v9S5EO7CLjo


https://www.youtube.com/watch?v=zdQRIYOST64&t=177s


https://www.youtube.com/watch?v=we6poP0kw6E&index=64&list=PLWz5rJ2EKKc9CBxr3BVjPTPoDPLdPIFCE


https://developer.android.com/topic/performance/rendering


https://developer.android.com/guide/topics/graphics/hardware-accel


https://developer.android.com/topic/performance/rendering/profile-gpu#su


https://mp.weixin.qq.com/s/0OOSmrzSkjG3cSOFxWYWuQ


Android Developer Backstage - Android Rendering


Android Developer Backstage - Graphics Performance


https://elinux.org/images/2/2b/Androidgraphicspath--chis_simmonds.pdf


发布于: 2021 年 01 月 26 日阅读数: 446
用户头像

李小四

关注

还未添加个人签名 2018.05.01 加入

还未添加个人简介

评论 (1 条评论)

发布
用户头像
很赞
2021 年 02 月 10 日 21:46
回复
没有更多了
「Android渲染」图像是怎样显示到屏幕上的?