# iOS 界面渲染流程 > 下面几个问题你熟悉吗? > > - 为什么调用 `[UIView serNeedsDisplay]` 并没有立刻发生当前视图的绘制工作? ## 视图显示原理 为什么调用 `[UIView setNeedsDisplay]` 并没有立刻发生当前视图的绘制工作? UIView 绘制流程。 当调用 UIView `[UIView setNeedsDisplay]` 方法时,系统会立刻调用其 Layer 的同名方法 `[view.layer setNeedsDisplay]` 方法,之后相当于给当前 Layer 打上一个脏标记,之后会在当前 RunLoop 快要结束的时候才会调用 Layer 的 `[CALayer display]` 方法。然后进入当前 UIView 真正的绘制流程中。 其次,会判断 CALayer 的代理,有没有实现 `displayLayer:` 方法 - 如果没有实现,则进入系统的绘制流程:比如:创建绘制上下文、调用 `drawInContext:`、生成内容并赋值给 `contents` - 如果实现了,则可能是异步绘制或者自定义渲染的实现。是**代理自定义绘制的入口**。代理可以在这个方法里直接设置`layer.contents`(比如异步绘制生成 UIImage 后赋值给`contents`),完全接管 layer 的内容渲染 Demo1: 自定义 View,不实现 `displayInContext` 方法 ```objective-c #import @interface CustomDrawView : UIView @end @implementation CustomDrawView // 重写drawRect: —— 系统绘制流程的上层入口 - (void)drawRect:(CGRect)rect { // 1. 系统自动创建绘制上下文,这里可以直接获取 CGContextRef context = UIGraphicsGetCurrentContext(); // 2. 绘制操作(对应系统流程的「调用drawInContext:」阶段) // 设置填充色为红色 CGContextSetFillColorWithColor(context, [UIColor redColor].CGColor); // 绘制一个矩形 CGContextFillRect(context, CGRectMake(20, 20, 100, 100)); NSLog(@"执行drawRect: → 底层对应系统调用CALayer的drawInContext:"); } // 关键:不实现 displayLayer: 代理方法 // - (void)displayLayer:(CALayer *)layer {} // 注释掉,模拟「未实现」场景 @end ``` 在 ViewController 中使用 ```objective-c #import "ViewController.h" #import "CustomDrawView.h" @interface ViewController () @end @implementation ViewController - (void)viewDidLoad { [super viewDidLoad]; self.view.backgroundColor = [UIColor whiteColor]; // 1. 创建自定义View并添加到界面 CustomDrawView *drawView = [[CustomDrawView alloc] initWithFrame:CGRectMake(50, 100, 150, 150)]; drawView.backgroundColor = [UIColor lightGrayColor]; // 浅灰色背景,方便区分绘制区域 [self.view addSubview:drawView]; // 2. 触发绘制(打脏标记,RunLoop阶段系统会执行layer.display) [drawView setNeedsDisplay]; } @end ``` 结果:屏幕上会显示「浅灰色背景 + 红色矩形」;控制台打印: `执行drawRect: → 底层对应系统调用CALayer的drawInContext:`; 分析: - `[drawView setNeedsDisplay]` → 内部调用 `layer.setNeedsDisplay`,给 layer 打 “脏标记” - 当前 RunLoop 的 CATransaction 阶段,系统调用 `[layer display]` - layer 检查代理(CustomDrawView)有没有实现代理方法 → 未实现`-(void)displayLayer:(CALayer *)layer`; - 系统**自动创建绘制上下文** → 调用 `[layer drawInContext:]`(UIView 的`drawRect:`是对这个方法的封装,所以`drawRect:`被执行) - 系统将绘制结果生成位图 → 赋值给 `layer.contents` - 最终 layer 把`contents`内容渲染到屏幕 Demo2: 自定义 Layer,实现 `displatLayer:` 代理方法的 Layer ```objective-c #import @interface CustomLayer : CALayer @end @implementation CustomLayer // 重写CALayer的drawInContext: —— 系统绘制流程的核心方法 - (void)drawInContext:(CGContextRef)ctx { // 系统创建的上下文会传入这个方法 NSLog(@"系统调用drawInContext: → 进入核心绘制阶段"); // 绘制操作:画一个蓝色圆形 CGContextSetFillColorWithColor(ctx, [UIColor blueColor].CGColor); CGContextFillEllipseInRect(ctx, CGRectMake(20, 20, 100, 100)); } @end ``` 在 VC 中使用 ```objective-c #import "ViewController.h" #import "CustomLayer.h" @interface ViewController () @end @implementation ViewController - (void)viewDidLoad { [super viewDidLoad]; self.view.backgroundColor = [UIColor whiteColor]; // 1. 创建自定义Layer CustomLayer *customLayer = [CustomLayer layer]; customLayer.frame = CGRectMake(50, 250, 150, 150); customLayer.backgroundColor = [UIColor lightGrayColor].CGColor; [self.view.layer addSublayer:customLayer]; // 2. 触发绘制(打脏标记) [customLayer setNeedsDisplay]; } @end ``` 结果:屏幕上显示「浅灰色背景 + 蓝色圆形」;并且输出:`系统调用drawInContext: → 进入核心绘制阶段` 分析: - 调用 CALayer 的 setNeedsDisplay 方法,内部会调用 display 方法 - 系统会将其 CALayer 打上 dirty 标记 - RunLoop 会在一次 loop 的末尾,提交 CATranscation。然后去绘制 layer 的 displayLayer 方法 - 判断没有实现 displayLayer 方法,然后自动创建渲染上下文。 - 然后调用 `drawInContext:(CGContextRef)ctx` ,方法的 ctx 参数就是系统自动创建的上下文对象 - 该方法内创建的渲染内容,最后会合成一张 bitmap,最后交给 layer.contents 属性 - 屏幕渲染 contents 内容 下面来个 Demo 展示下简单的异步绘制一个 String。 接下来看看系统的绘制实现流程: 如何实现异步绘制? `[layer.delegate displayPlayer:]` - 代理负责生成对应的 bitmap - 设置该 bitmap 作为 layer.contents 属性的值 ## 渲染机制 ![](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 都有一个 CALayer,layer 属性都有 contents,contents 其实是一块缓存,叫做 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到Surface(Metal或者OpenGL ES Texture)上 -> 6. Surface(Metal或者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 是有损压缩,可以指定0~100%压缩比。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中使用的是大端模式,为了兼容,我们使用kCGBitmapByteOrder32Host,32位字节顺序,该宏在不同的平台上面会自动组装换成不同的模式。 /* #ifdef __BIG_ENDIAN__ # define kCGBitmapByteOrder16Host kCGBitmapByteOrder16Big # define kCGBitmapByteOrder32Host kCGBitmapByteOrder32Big #else //Little endian. # define kCGBitmapByteOrder16Host kCGBitmapByteOrder16Little # define kCGBitmapByteOrder32Host kCGBitmapByteOrder32Little #endif */ CGBitmapInfo bitmapInfo = kCGBitmapByteOrder32Host; //根据是否含有alpha通道,如果有则使用kCGImageAlphaPremultipliedFirst,ARGB否则使用kCGImageAlphaNoneSkipFirst,RGB 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); }); }); } ```