Files
knowledge-kit/Chapter1 - iOS/1.105.md
2024-07-17 23:30:50 +08:00

399 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# iOS 界面渲染流程
> 下面几个问题你熟悉吗?
>
> - 为什么调用 `[UIView serNeedsDisplay]` 并没有立刻发生当前视图的绘制工作?
## 视图显示原理
为什么调用 `[UIView setNeedsDisplay]` 并没有立刻发生当前视图的绘制工作?
UIView 绘制流程。
<img src="https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/UIViewRefreshProcess.png" style="zoom:60%" />
当调用 UIView `[UIView setNeedsDisplay]` 方法时,系统会立刻调用其 Layer 的同名方法 `[view.layer setNeedsDisplay]` 方法,之后相当于给当前 Layer 打上一个脏标记,之后会在当前 RunLoop 快要结束的时候才会调用 Layer 的 `[CALayer display]` 方法。然后进入当前 UIView 真正的绘制流程中。
其次,会判断 CALayer 的代理,有没有实现 `displayLayer:` 方法,如果没有实现,则进入系统的绘制流程中;如果实现了,则可能是异步绘制或者自定义渲染的实现。
Tips`[UIView setNeedsDisplay]` 之后并不会立马调用 `[view.layer setNeedsDisplay]` 方法。要么手动触发 `[view.layer setNeedsDisplay]` 要么,调用一下 `-(void)drawRect:(CGRect)rect` 方法(即使是空实现也没关系)。
下面来个 Demo 展示下简单的异步绘制一个 String。
<img src="https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/AsyncUILabelRender.png" style="zoom:30%" />
接下来看看系统的绘制实现流程:
<img src="https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/UIViewSystemRenderProcess.png" style="zoom:60%" />
如何实现异步绘制?
`[layer.delegate displayPlayer:]`
- 代理负责生成对应的 bitmap
- 设置该 bitmap 作为 layer.contents 属性的值
<img src='https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/AsyncRenderProcessAPI.png' style="zoom:30%" />
## 渲染机制
![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/RenderStructure.png)
iOS 渲染框架可以分为4层顶层是 UIKit包括图形界面的高级 API 和常用的各种 UI 控件。UIKit 下层是 Core Animation不要被名字误解了它不光是处理动画相关也在做图形渲染相关的事情(比如 UIView 的 CALayer 就处于 Core Animation 中)。Core Animation 之下就是由 OpenGL ES 和 CoreGraphics 组成的图形渲染层OpenGL ES 主要操作 GPU 进行图形渲染CoreGraphics 主要操作 CPU 进行图形渲染。上面3层都属于渲染图形软件层再下层就是图形显示硬件层。
iOS 图形界面的显示是一个复杂的流程,一部分数据通过 Core Graphics、Core Image 有 CPU 预处理,最终通过 OpenGL ES 将数据传输给 GPU最终显示到屏幕上。
![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/RenderPipeline.png)
- Core Animation 提交会话(事务),包括自己和子树(view hierarchy) 的布局状态
- Render Server 解析所提交的子树状态,生成绘制指令
- GPU 执行绘制指令
- 显示器显示渲染后的数据
## Core Animation
![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/APM-CoreAnimationPipeline.png)
可以看到 Core Animation pipeline 由4部分组成Application 层的 Core Animation 部分、Render Server 中的 Core Animation 部分、GPU 渲染、显示器显示。
### Application 层 Core Animation 部分
![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/CoreAnimationCommit.png)
- 布局(Layout)`layoutSubviews``addSubview`,这里通常是 CPU、IO 繁忙
- 显示(Display):调用 view 重写的 `drawRect` 方法,或者绘制字符串。这里主要是 CPU 繁忙、消费较多内存。每个 UIView 都有 CALayer同时图层又一个像素存储控件存储视图调用 `setNeedsDisplay` 仅会设置图层为 dirty。当渲染系统准备就绪调用视图的 `display` 方法,同时装配像素存储空间,建立一个 Core Graphics 上下文(CGContextRef),将上下文 push 进上下文堆栈,绘图程序进入对应的内存存储空间。
- 准备(Prepare)图片解码、图片格式转换。GPU 不支持某些图片格式,尽量使用 GPU 能支持的图片格式
- 提交(Commit):打包 layers 并发送给 Render Server递归提交子树的 layers。如果子树层级较多(复杂),则对性能造成影响
### Render Server 中 Core Animation 部分
Render Server 是一个独立的渲染进程,当收到来自 Application 的 (IPC) 事务时,首先解析 layer 层级关系,然后 Decode。最后执行 Draw Calls(执行对应的 OpenGL ES 命令)
### GPU 渲染
- OpenGL ES 的 command buffer 进行定点变换,三角形拼接、光栅话变为 parameter buffer
- parameter buffer 进行像素变化testing、blending 生成 frame buffe
### 显示器显示
视频控制器从 frame buffer 中读取数据显示在显示屏上。
## UIView 绘制流程
![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/UIRenderPipeline.png)
- 每个 UIView 都有一个 CALayerlayer 属性都有 contentscontents 其实是一块缓存,叫做 backing store
- 当 UIView 被绘制时CPU 执行 drawRect 方法,通过 context 将数据写入 backing store 中(位图 bitmap)
- 当 backing store 写完后,通过 Render Server 交给 GPU 去渲染,最后显示到屏幕上
![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/UIViewRenderPipeline.png)
- 调用 `[UIView setNeedsDisplay]` 方法时,并没有立即执行绘制工作,而是马上调用 `[view.layer setNeedsDisplay]` 方法,给当前 layer 打上标记
- 在当前 RunLoop 快要结束的时候调用 layer 的 display 方法,来进入到当前视图真正的绘制流程
- 在 layer 的 display 方法内部,系统会判断 layer 的 layer.delegate 是否实现了 `displayLayer` 方法
- 如果没有,则执行系统的绘制流程
- 如果实现了,则会进入异步绘制流程
- 最后把绘制完的 backing store 提交给 GPU
### 系统绘制流程
![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/iOSRenderProcess.png)
- 首先 CALayer 内部会创建一个 CGContextRef在 drwaRect 方法中,可以通过上下文堆栈取出 context拿到当前视图渲染上下文也就是 backing store
- 然后 layer 会判断是否存在代理,若没有,则调用 CALayer 的 drawInContext
- 如果存在代理,则调用代理方法。然后做当前视图的绘制工作,然后调用 view 的 drawRect 方法
- 最后由 CALayer 上传对应的 backing store(可以理解为位图)提交给 GPU。
### 异步绘制流程
![](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/iOSAsyncRender.png)
- 如果 layer 有代理对象,且代理对象实现了代理方法,则可以进入异步绘制流程
- 异步绘制流程中主要生成对应的 bitmap。目的是最后一步需要将 bitmap 设置为 layer.contents 的值
- 左侧是主队列,右侧是全局并发队列
- 调用了setNeedsDiaplay 方法后,在当前 Runloop 将要结束的时候,会有系统调用视图所对应 layer 的 display 方法
- 通过在子线程中去做位图的绘制,此时主线程可以去做些其他的工作。在子线程中:主要通过 CGBitmapContextCreate 方法,来创建一个位图的上下文、通过CoreGraphic API绘制 UI、通过 CGBitmapContextCreatImage 方法,根据所绘制的上下文,生成一张 CGImage 图片
- 然后再回到主队列中,提交这个位图,设置给 CALayer 的 contents 属性
## 图片加载库都做了什么事
众所周知iOS应用的渲染模式是完全基于Core Animation和CALayer的macOS上可选另说。因此当一个UIImageView需要把图片呈现到设备的屏幕上时候其实它的Pipeline是这样的
1. 一次Runloop完结 ->
2. Core Animation提交渲染树CA::render::commit ->
3. 遍历所有Layer的contents ->
4. UIImageView的contents是CGImage ->
5. 拷贝CGImage的Bitmap Buffer到SurfaceMetal或者OpenGL ES Texture上 ->
6. SurfaceMetal或者OpenGL ES渲染到硬件管线上
这个流程看起来没有什么问题但是注意Core Animation库自身虽然支持异步线程渲染在macOS上可以手动开启但是UIKit的这套内建的pipeline全部都是发生在主线程的。
因此当一个CGImage是采取了惰性解码通过Image/IO生成出来的那么将会在主线程触发先前提到的惰性解码callback实际上Core Animation的调用触发了一个`CGDataProviderRetainBytePtr`这时候Image/IO的具体解码器会根据先前的图像元信息去分配内存创建Bitmap Buffer这一步骤也发生在主线程。
这个流程带来的问题在于主线程过多的频繁操作会造成渲染帧率的下降。实验可以看出通过原生这一套流程对于一个1000*1000的PNG图片第一次滚动帧率大概会降低5-6帧iPhone 5S上当年有人的测试。后续帧率不受影响因为是惰性解码解码完成后的Bitmap Buffer会复用。
所以,最早不知是哪个团队的人(可能是[FastImageCache](https://github.com/path/FastImageCache)不确定发现并提出了另一种方案通过预先调用获取Bitmap强制Image/IO产生的CGImage解码这样到最终渲染的时候主线程就不会触发任何额外操作带来明显的帧率提升。后面的一系列图片库都互相效仿来解决这个问题。
具体到解决方案上目前主流的方式是通过CGContext开一个额外的画布然后通过`CGContextDrawImage`来画一遍原始的空壳CGImage由于在`CGContextDrawImage`的执行中,会触发到`CGDataProviderRetainBytePtr`因此这时候Image/IO就会立即解码并分配Bitmap内存。得到的产物用来真正产出一个CGImage-based的UIImage交由UIImageView渲染。
## ForceDecode的优缺点
上面解释了ForceDecode具体解决的问题当然这个方案肯定存在一定的问题不然苹果研发团队早已经改变了这套Pipeline流程了
优点:可以提升,图像第一次渲染到屏幕上时候的性能和滚动帧率
缺点提前解码会立即分配Bitmap Buffer的内存增加了内存压力。举例子对于一张大图2048*2048像素32位色来说就会立即分配16MB(2048 * 2048 * 4 Bytes)的内存。
由此可见这是一个拿空间换时间的策略。但是实际上iOS设备早期的内存都是非常有限的UIKit整套渲染机制很多地方采取的都是时间换空间因此最终苹果没有使用这套Pipeline而是依赖于高性能的硬件解码器+其他优化来保证内存开销稳定。当然作为图片库和开发者这就属于仁者见仁的策略了。如大量小图渲染的时候开启Force Decode能明显提升帧率同时内存开销也比较稳定。
## iOS 图片解压缩到渲染过程
- 假设我们使用 `+imageWithContentsOfFile:` 方法从磁盘中加载一张图片,这个时候的图片并没有解压缩
- 然后将生成的 `UIImage` 赋值给 `UIImageView`
- 接着一个隐式的 `CATransaction` 捕获到了 `UIImageView` 图层树的变化
- 在主线程的下一个 `runloop` 到来时,`Core Animation` 提交了这个隐式的 `transaction` ,这个过程可能会对图片进行 `copy` 操作,而受图片是否字节对齐等因素的影响,这个 `copy` 操作可能会涉及以下部分或全部步骤
- 分配内存缓冲区用于管理文件 IO 和解压缩操作
- 将文件数据从磁盘读到内存中
- 将压缩的图片数据解码成未压缩的位图形式,这是一个非常耗时的 CPU 操作
- 最后 `Core Animation``CALayer` 使用未压缩的位图数据渲染 `UIImageView` 的图层
- CPU 计算好图片的 Frame,对图片解压之后.就会交给 GPU 来做图片渲染
- 渲染流程
- GPU 获取图片的坐标
- 将坐标交给顶点着色器(顶点计算)
- 将图片光栅化(获取图片对应屏幕上的像素点)
- 片元着色器计算(计算每个像素点的最终显示颜色值)
- 从帧缓存区中渲染到屏幕上
我们提到了图片的解压缩是一个非常耗时的 CPU 操作,并且它默认是在主线程中执行的。那么当需要加载的图片比较多时,就会对我们应用的响应性造成严重的影响,尤其是在快速滑动的列表上,这个问题会表现得更加突出
## 为什么要解压缩图片
既然图片的解压缩很耗费 CPU 时间,那么为什么还要对图片进行解压缩?是否可以不解压缩直接显示图片?不能
其实位图,就是一个像素数组,数组中的每个像素就代表图片中的一个点。平时遇到的 png、jpeg 就是位图。
```objective-c
UIImage *image = [UIImage imageNamed:@"text.png"];
CFDataRef rawData = CGDataProviderCopyData(CGImageGetDataProvider(image.CGImage));
```
rawData 就是图片原始数据。
jpg、png 都是一种压缩格式,只不过 png 是无损压缩,支持 alpha 通道。而 jpeg 是有损压缩可以指定0100%压缩比。iOS 提供2个函数来生成 png、jpeg 图片。
```objective-c
// return image as PNG. May return nil if image has no CGImageRef or invalid bitmap format
UIKIT_EXTERN NSData * __nullable UIImagePNGRepresentation(UIImage * __nonnull image);
// return image as JPEG. May return nil if image has no CGImageRef or invalid bitmap format. compression is 0(most)..1(least)
UIKIT_EXTERN NSData * __nullable UIImageJPEGRepresentation(UIImage * __nonnull image, CGFloat compressionQuality);
```
所以,在磁盘的图片渲染到屏幕之前,必须先得到图片的原始像素数据,才可以执行后续的操作。所以必须先解压缩。
## 图片解压缩原理
既然图片的解压缩不可避免,而我们也不想让它在主线程执行,影响 App 性能,那么是否有比较好的解决方案呢?
我们前面已经提到了,当未解压缩的图片将要渲染到屏幕时,系统会在主线程对图片进行解压缩,而如果图片已经解压缩了,系统就不会再对图片进行解压缩。因此,也就有了业内的解决方案,在**子线程提前对图片进行强制解压缩**。
而强制解压缩的原理就是**对图片进行重新绘制,得到一张新的解压缩后的位图**。其中,用到的最核心的函数是 `CGBitmapContextCreate`
```objective-c
CG_EXTERN CGContextRef __nullable CGBitmapContextCreate(void * __nullable data,
size_t width, size_t height, size_t bitsPerComponent, size_t bytesPerRow,
CGColorSpaceRef cg_nullable space, uint32_t bitmapInfo)
CG_AVAILABLE_STARTING(__MAC_10_0, __IPHONE_2_0);
```
参数说明:
- data如果不为 `NULL` ,那么它应该指向一块大小至少为 `bytesPerRow * height` 字节的内存;如果 为 `NULL`,那么系统就会为我们自动分配和释放所需的内存,所以一般指定 `NULL` 即可;
- width 和 height :位图的宽度和高度,分别赋值为图片的像素宽度和像素高度即可;
- bitsPerComponent像素的每个颜色分量使用的 bit 数,在 RGB 颜色空间下指定 8 即可;
- bytesPerRow :位图的每一行使用的字节数,大小至少为 `width * bytes per pixel` 字节。当我们指定 0/NULL 时,系统不仅会为我们自动计算,而且还会进行 `cache line alignment` 的优化
- space :就是我们前面提到的颜色空间,一般使用 RGB 即可;
- bitmapInfo :位图的布局信息.`kCGImageAlphaPremultipliedFirst`
参考 YYImage/SDWebImage 都有图片解压缩的实现
```objective-c
CGImageRef YYCGImageCreateDecodedCopy(CGImageRef imageRef, BOOL decodeForDisplay) {
...
if (decodeForDisplay) { // decode with redraw (may lose some precision)
CGImageAlphaInfo alphaInfo = CGImageGetAlphaInfo(imageRef) & kCGBitmapAlphaInfoMask;
BOOL hasAlpha = NO;
if (alphaInfo == kCGImageAlphaPremultipliedLast ||
alphaInfo == kCGImageAlphaPremultipliedFirst ||
alphaInfo == kCGImageAlphaLast ||
alphaInfo == kCGImageAlphaFirst) {
hasAlpha = YES;
}
// BGRA8888 (premultiplied) or BGRX8888
// same as UIGraphicsBeginImageContext() and -[UIView drawRect:]
CGBitmapInfo bitmapInfo = kCGBitmapByteOrder32Host;
bitmapInfo |= hasAlpha ? kCGImageAlphaPremultipliedFirst : kCGImageAlphaNoneSkipFirst;
CGContextRef context = CGBitmapContextCreate(NULL, width, height, 8, 0, YYCGColorSpaceGetDeviceRGB(), bitmapInfo);
if (!context) return NULL;
CGContextDrawImage(context, CGRectMake(0, 0, width, height), imageRef); // decode
CGImageRef newImage = CGBitmapContextCreateImage(context);
CFRelease(context);
return newImage;
} else {
...
}
}
```
自己也可以实现
```objective-c
- (void)setImage {
SP_BEGIN_LOG(custome, gl_log, imageSet);
[self decodeImage:[UIImage imageNamed:@"peacock"] completion:^(UIImage *image) {
self.imageView.image = image;
SP_END_LOG(imageSet);
}];
}
- (void)decodeImage:(UIImage *)image completion:(void(^)(UIImage *image))completionHandler {
if (!image) return;
//在子线程执行解码操作
dispatch_async(dispatch_get_global_queue(0, 0), ^{
CGImageRef imageRef = image.CGImage;
//获取像素宽和像素高
size_t width = CGImageGetWidth(imageRef);
size_t height = CGImageGetHeight(imageRef);
if (width == 0 || height == 0) return ;
CGImageAlphaInfo alphaInfo = CGImageGetAlphaInfo(imageRef) & kCGBitmapAlphaInfoMask;
BOOL hasAlpha = NO;
//判断颜色是否含有alpha通道
if (alphaInfo == kCGImageAlphaPremultipliedLast ||
alphaInfo == kCGImageAlphaPremultipliedFirst ||
alphaInfo == kCGImageAlphaLast ||
alphaInfo == kCGImageAlphaFirst) {
hasAlpha = YES;
}
//在iOS中使用的是小端模式在mac中使用的是大端模式为了兼容我们使用kCGBitmapByteOrder32Host32位字节顺序该宏在不同的平台上面会自动组装换成不同的模式。
/*
#ifdef __BIG_ENDIAN__
# define kCGBitmapByteOrder16Host kCGBitmapByteOrder16Big
# define kCGBitmapByteOrder32Host kCGBitmapByteOrder32Big
#else //Little endian.
# define kCGBitmapByteOrder16Host kCGBitmapByteOrder16Little
# define kCGBitmapByteOrder32Host kCGBitmapByteOrder32Little
#endif
*/
CGBitmapInfo bitmapInfo = kCGBitmapByteOrder32Host;
//根据是否含有alpha通道如果有则使用kCGImageAlphaPremultipliedFirstARGB否则使用kCGImageAlphaNoneSkipFirstRGB
bitmapInfo |= hasAlpha ? kCGImageAlphaPremultipliedFirst : kCGImageAlphaNoneSkipFirst;
//创建一个位图上下文
CGContextRef context = CGBitmapContextCreate(NULL, width, height, 8, 0, CGColorSpaceCreateDeviceRGB(), bitmapInfo);
if (!context) return;
//将原始图片绘制到上下文当中
CGContextDrawImage(context, CGRectMake(0, 0, width, height), imageRef);
//创建一张新的解压后的位图
CGImageRef newImage = CGBitmapContextCreateImage(context);
CFRelease(context);
UIImage *originImage =[UIImage imageWithCGImage:newImage scale:[UIScreen mainScreen].scale orientation:image.imageOrientation];
//回到主线程回调
dispatch_async(dispatch_get_main_queue(), ^{
!completionHandler ?: completionHandler(originImage);
});
});
}
```