我们在代码中写的View、Image等组件,最终是如何一步步渲染到屏幕上的呢?触摸、动画等是如何实现的?我们可以利用这些知识做哪些优化呢?
本文先从屏幕物理层原理出发,一步步介绍渲染流程,然后介绍iOS的UIKit框架设计,最后介绍如何利用这些知识做优化
先看第一步,屏幕是如何非常细腻的展示图片的
屏幕上的数据是如何一步步渲染出来的?
屏幕的显示原理
我们知道,所有的颜色都可以通过三原色红蓝绿来展示出来,在屏幕上也是一样,屏幕设备上的每个像素点,如果我们把它放大,那么可以看到3个滤光片组成的,简单理解是这样子的
屏幕类型主要是LCD和OLED,他们的发光原理不同,但是抽象起来就是通过RGBA四个参数的调整来做最后的显示。
PS: 如果对两种屏幕的原理感兴趣的话,可以看下这个视频
[video(video-9IgXCWLT-1714361636297)(type-undefined)(url-undefined)(image-https://img-blog.csdnimg.cn/editor-video.png)(【硬核科普】全网最简洁易懂的OLED与LCD屏幕工作原理与优劣科普
)]
知道了屏幕的显示原理之后,我们就会想到一个问题,那屏幕的RGBA四个参数是从哪里来的呢? 答案是GPU
GPU
在计算机结构的设计中,CPU主要负责逻辑运算,而GPU就负责图片渲染的运算。 GPU从硬件上支持T&L(Transform and Lighting,多边形转换与光源处理),相比通用计算的CPU来讲,其有两个优势:
- 处理图片计算的能力要强的多,因为硬件支持T&L
- 并发能力比CPU也要大
详细对比如下:
GPU的渲染流程大致如下:
针对屏幕的硬件能力,GPU会根据其FPS来组织数据,比如常见的FPS是60,那么GPU每1/60秒内就要提供一次渲染数据供绘制,如果没能及时提供数据,那么就会出现该帧没有重绘,给用户的感觉就是出现了卡顿
从上图可以看到,最终输入GPU的其实还是一堆数据,那么这堆数据是谁给GPU的呢?
GPU的数据从哪里来 - OpenGL和Metal
在不同的系统上会有不同的答案,针对iOS的UIKit,答案就是OpenGL和Metal
OpenGL是苹果最初的采用的渲染框架,其具有跨平台、性能好,包大小可控等优点,但是随着图形技术的发展,其他也暴露出很多问题:
- 现代 GPU 的渲染管线已经发生变化。
- 不支持多线程操作。
- 不支持异步处理。
因此苹果重新设计了Metal框架,其优势如下:
- 更高效的 GPU 交互,更低的 CPU 负荷。
- 支持多线程操作,以及线程间资源共享能力。
- 支持资源和同步的控制。
PS: 具体的两者间细节对比,可以参考: https://juejin.cn/post/6844903619339223048
OpenGL的数据从哪里来
在UIKit中,OpenGL的数据主要从以下三个库获得:
- Core Graphics : 基于Quartz的绘图引擎,用于运行时绘制图像
- Core Image : 处理图像
- Core Animation : 核心动画和图层渲染能力
Metal 的数据从哪里来
目前来看来看UIKit的内容都不走Metal,而是通过OpenGL来实现,直接使用Metal的主要是Unity等框架直接使用,比如RenderTexture
综上所述,我们就可以画出这张图:
那么问题来了,我们并没有直接调用Core Graphics, Core Animation这些框架,而是使用UIImageView, UIView这些类,UIKit框架是如何调用Core框架来渲染的呢?
UIKit 的实现
上面的内容是从底层往上层讲的,这次我们反过来,看看UIView是一步步渲染的,首先介绍基础概念: UIView和CALayer
UIView和CALayer
UIView和CALayer体验了职责分离的设计思想,其中UIView负责触摸事件的处理,而CALayer负责渲染层。之所以这样设计,是因为Mac等系统没有使用UIKit,而是使用了AppKit,但是底层渲染都使用了CoreAnimation
在UIView创建的同时,系统会创建一个layer绑定到UIView的属性上,被称为backing layer。 当我们修改UIView的圆角、边框等属性时,其实是UIView封装了Layer的修改方法,最终的渲染实现还是在Layer上。
而响应链等触摸响应的能力,则是由UIView来实现(当然,底层还是依赖Layer的-containsPoint:和-hitTest:等方法)
CALayer的绘制
读取图片或者纹理
CALayer有个属性 contents ,用来承载最终在界面上绘制的内容
/* An object providing the contents of the layer, typically a CGImageRef
- or an IOSurfaceRef, but may be something else. (For example, NSImage
- objects are supported on Mac OS X 10.6 and later.) Default value is nil.
- Animatable. */
@property(nullable, strong) id contents;
看介绍可以看到,其在iOS系统上来源就是CGImageRef和IOSurfaceRef,我们来看下这俩分别是什么
- CGImageRef : A bitmap image or image mask. 用来承载读取的图片
- IOSurface是用于存储FBO、RBO等渲染数据的底层数据结构,是跨进程的,通常在CoreGraphics、OpenGLES、Metal之间传递纹理数据
这里简单可以理解为,content内容就是CALayer从现成的图片或者纹理来读取内容,然后渲染。
手动绘制
如果不想直接读取图片呢?我们也可以手动来绘制
通常,我们会继承UIView类,然后实现-drawRect:方法来实现自定义的绘制。 当然,就像上面说的UIView和CALayer的关系,虽然看起来是UIView实现了自定义的绘制,但是其实际工作都是CALayer来完成的
CALayer持有一个delegate,CALayerDelegate,其指向关联的UIView,如果UIView实现了drawRect方法,那么会执行drawRect方法来绘制,生成最终的图片到backing store, 关系如下:
当然,一般都不太推荐使用Core Graphics来自定义绘制,因为CoreAnimation已经提供了很多好用的类,比如CAShapeLayer,CATextLayer等来使用,性能会提升很多
CALayer在实际绘制的过程中,也是通过drawLayer等方法完成,后续可以再研究下细节。
看到CA这个前缀,我们就可以确定CALayer的底层渲染框架是Core Animation,下面就介绍下Core Animation,其功能远超其名称,这里强推一本书《iOS Core Animation: Advanced Techniques中文译本》https://zsisme.gitbooks.io/ios-/content/index.html
Core Animation 当我们聊动画的时候,我们在聊些什么?
当我们说动画的时候,我们通常的意思是,会动的图画,一帧帧的图片快速切换,就让人眼产生了会动的感觉,现在的视频都是这个原理。比如我们通常使用动画来做一些好看的交互效果,图片的切换、弹窗的弹出等等。 而Core Animation的动画做的事情超出了这个意思上的界限。
隐式动画
Core Animation基于一个假设,屏幕上任何变化的东西都可以做动画,不仅仅是位置、大小、角度。 也包括颜色等属性, 我理解只要是能通过数学公式曲线变化的,都可以想办法做动画。
颜色特别好理解,比如有颜色A,其可以数学理解为rgba四个属性,那么其颜色变成B就是rgba变化,在数学上就可以理解为 A(ra,ga,ba,aa) -> B(rb,gb,bb,ba) 的转换
在修改CALayer的属性时,Core Animation都会去做隐式动画的转换,让整个变换的过程更加流畅而不显生硬。比如我们想修改CALayer的颜色时,我们可以观察下:
aView.frame = CGRectMake(100, 100, 100, 100)
aView.backgroundColor = .red
view.addSubview(aView)
let layer = CALayer()
layer.frame = CGRectMake(25, 25, 50, 50)
layer.backgroundColor = UIColor.blue.cgColor
aView.layer.addSublayer(layer)
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
let red = arc4random() % 255
let green = arc4random() % 255
let blue = arc4random() % 255
layer.backgroundColor = UIColor(red: CGFloat(red)/255.0, green: CGFloat(green)/255.0, blue: CGFloat(blue)/255.0, alpha: 1).cgColor
}
其变化比较快,但是感觉是平滑过渡的,但是隐式动画的默认动画时间是0.25s,所以肉眼观察比较难,我们可以修改下动画时间:
aView.frame = CGRectMake(100, 100, 100, 100)
aView.backgroundColor = .red
view.addSubview(aView)
let layer = CALayer()
layer.frame = CGRectMake(25, 25, 50, 50)
layer.backgroundColor = UIColor.blue.cgColor
aView.layer.addSublayer(layer)
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
CATransaction.begin()
CATransaction.setAnimationDuration(5.0)
let red = arc4random() % 255
let green = arc4random() % 255
let blue = arc4random() % 255
layer.backgroundColor = UIColor(red: CGFloat(red)/255.0, green: CGFloat(green)/255.0, blue: CGFloat(blue)/255.0, alpha: 1).cgColor
CATransaction.commit()
}
这样就会很明显的看到,颜色是在一点点平滑过渡的。
事务
在这里我们用了一个类CATransaction,中文翻译为: 事务。 CALayer每个可隐式动画属性的转换都是通过事务来变化的,事务提交之后,其才会从原始值变更为新值。
在一个run loop周期内, CoreAnimation会提交事务,然后runloop会循环收集这些事务,然后执行。
PS: 在UIView中,隐式动画是被禁用的,可以看https://juejin.cn/post/6984716038445203469详细了解
呈现树
刚才提到,修改属性的时候,属性并不是立刻变化的,而是通过隐形动画来变化。但是当我们在动画未完成的时候读取属性时,会发现其属性也变化了, 即系统记录了当前变化的最终目标。那么系统是如何知道变化过程中的值呢?
和图层树对应,系统会创建一个呈现树来记录当前变化中的图层状态。
目前为止我们一共知道了3种树:
- 视图树(UIView)
- 图层树(CALayer)
- 呈现树(变化中的实时渲染属性)
还有一种树,渲染树,我们会在后面讲到
绘制流水线
上面提到,最终的绘制都是通过CALayer来完成,那么Core Animation是如何一步步将CALayer绘制出来呢?
- 当App需要变更UI的时候,即视图树发生变化,这时候对应的图层树也会跟随变化,CoreAnimation会提交事务来渲染最终的效果
- 这时候RenderServer会把数据反序列化为渲染树的内容,然后提交给GPU来渲染
- GPU收到数据,进行它最擅长的图形数据计算,然后把数据给屏幕
- 最终,屏幕完成了RGBA在每个像素点上的变更
因为屏幕的渲染是持续的,所以上面只是一个帧的工作,而系统是通过流水线的形式,保证每一帧都能有最终的屏幕刷新,如图:
性能优化
基于上面学到的知识,我们该如何写代码,提升性能呢?
减少CPU的使用
上面提到,GPU擅长计算图形,而CPU擅长通用计算,所以我们在写代码的时候进行多利用GPU,而减少CPU的抢占。 那么有哪些任务会触发CPU呢?
- 布局计算,自动布局尤其明显,最好是能提前计算好,比如tableview的cell高度,自适应高度的话卡顿还是比较明显的
- 减少Core Graphics的计算,比如自己实现-drawRect:方法
- 解压特别大的图片,尤其是超过屏幕大小的图片
- 减少IO操作
减少图层的混合,减少图层数量
GPU在计算的时候,需要计算所有图层累计后的展示,那么减少图层就可以减少混合的工作。 另外GPU也会放弃所有被完全遮挡的图层,所以如果可以的话,非必要不使用带透明度的图层
离屏渲染
当图层属性的混合体被指定为在未预合成之前不能直接在屏幕中绘制时,屏幕外渲染就被唤起了。屏幕外渲染并不意味着软件绘制,但是它意味着图层必须在被显示之前在一个屏幕外上下文中被渲染(不论CPU还是GPU)。图层的以下属性将会触发屏幕外绘制:
- 圆角(当和maskToBounds一起使用时)
- 图层蒙板
- 阴影
- 光栅化
我们可以使用CAShapeLayer,contentsCenter或者shadowPath来获得同样的表现而且较少地影响到性能。
适当使用光栅化技术
上面我们提到光栅化会执行离屏渲染,那为什么这里又提到可以优化性能呢? 别急,我们先看下什么是光栅化。
shouldRasterize = YES在其他属性触发离屏渲染的同时,会将光栅化后的内容缓存起来,如果对应的layer及其sublayers没有发生改变,在下一帧的时候可以直接复用。
如果我们有个cell,它的阴影是固定的,那么就可以考虑光栅化,将其缓存下来,避免重复的阴影绘制。 具体的数据对比可以看: iOS性能优化 ------ 一个简单的Layer Rasterize(光栅化)例子
参考文档
- https://juejin.cn/post/6994075190514679838
- wwdc 2011 session 121 understanding uikit rendering
- IOS进阶-图层与渲染
- iOS界面渲染与优化
- https://zhuanlan.zhihu.com/p/587949539
- https://www.jianshu.com/p/ab28b7745e30
- https://juejin.cn/post/6844904106419552269
- https://juejin.cn/post/6844903619339223048