iOS屏幕绘制
屏幕是如何绘制的?这其中涉及到了很多的流程和不同的系统框架,这里就来梳理一边在屏幕绘制的过程中都发生了什么,希望可以帮助大家在性能调优的时候选择合适的方法和API。这里讲解的内容主要是针对iOS平台,同样也适用于OS X平台。
图形堆栈
不管前面发生了什么,最终在屏幕显示的所有东西都是通过像素展示出来的。每个像素由三个颜色单元组成,RGB红绿蓝,每个颜色单元通过不同的强度亮起来,共同形成了这一个像素的颜色。例如在iPhone 5手机上由1136 x 640 = 727040个像素,那么就一共有2181120个颜色单元,在一个15寸的配置了视网膜屏幕MacBook Pro上有1550万个颜色单元。整个图形堆栈的任务就是通力协作确保每一个颜色单元按照正确的强度亮起来。当屏幕滚动的时候,所有这些颜色单元都要以每秒60次的频率刷新自己的强度。
软件架构
软件架构.png在屏幕上方是GPU,图像处理单元,专门设计用来并行处理图像计算的,从而保证了屏幕像素的快速刷新,包括不同纹理的叠加显示,因为他是专门用来处理图像的,因此它在处理图像方面要比CPU更快,耗电更少。
GPU的上层是GPU驱动,直观上理解就是直接操作GPU的一段代码,用来屏蔽不同GPU的特性,从而向上层提供更加统一的接口,这里的上层通常是指OpenGL,现在应该已经被Metal所取代了。
OpenGL是一套绘制2D和3D图形的API接口,因为GPU的差异性非常大,OpenGL直接与GPU通信来最大化利用GPU的特性同时加速硬件渲染。虽然OpenGL看起来非常底层,但是在1992年刚出现的时候,是第一个标准化的与GPU通信的方式,结束了针对不同GPU需要重写和优化代码的局面。
再向上就比较杂了,在iOS应用上,基本上是逃不开使用Core Aanimation的, 在OS X上,则常常跳过Core Animation而直接使用Core Graphics, 对于游戏或者某些特殊的应用,可能会直接使用OpenGL。对于其他的一些系统库,如AVFoundation、Core Image等则会同时用到上面提到这些技术。
需要注意的是,GPU与CPU通过某些总线相连,像是OpenGL、Core Animation和Core Graphics会协调数据在CPU和GPU之间的传输。有一些操作在CPU内完成,然后将数据上传到GPU,然后GPU在通过一些计算最终将图片以像素的方式显示在屏幕上。
硬件架构
硬件架构.png对于GPU来讲,它的瓶颈在于需要在每一帧将所有的位图(纹理texture)合成一张完整的像素表显示在屏幕上,每个位图都会占用一部分VRAM(显存),而显存的容量又是有限制的,GPU在位图组合上是非常高效的,但是有些合成会非常耗时,因此GPU在16.7ms也就是1/60s内能做的事情其实是非常有限的。
另外一个瓶颈在与将数据从内存上传至显存中,当数据量较大的时候,这个过程会非常耗时。
另外,也有很多过程是由CPU在计算的,比如我们会告诉CPU从沙盒中家在一个PNG图片并解压缩,所有这些过程都由CPU在计算,当它需要被显示在屏幕上的时候,这些数据会被上传给GPU。对于平常的文字显示来讲,CPU需要通过Core Text与Core Graphics的通力合作来生成一张显示的文字的唯独,然后交给GPU显示,当屏幕滚动的时候,这张位图是可以被重用的,此时CPU仅仅需要告诉GPU这张位图新的位置在哪里就可以了。CPU不想要重新生成位图,位图也不需要重新被上传至GPU。
合成
合成的意思是将多张位图组合在一起形成一张最终展示在屏幕上的图像的过程。
我们假设一种最简单的情况,需要展示的是一个矩形的区域,那么这张位图就是一个由RGBA值组成的矩阵,这其实就是Core Aanimation中CALayer。
每个layer都是一张位图,假设每个layer都完整地叠加张另一个layer上面,那么最终显示在屏幕的时候,GPU需要计算出所有layer叠加后,矩阵中的每一个值。这就是合成。
如果只有一个位图,且位图的每一个点正好对应屏幕上的每一个像素,那么位图的像素就是最终需要显示在屏幕的像素。如果这张位图上面还有一张位图,那么GPU就需要进行合成动作,合成有三种模式。假设这两张位图的像素一一对应,且使用正常的模式合成,那么最终像素的结果是:
R = S + D * (1 - Sa)
最终的没一点的像素是上面的像素加上下面像素与1减去上面像素透明值的乘机。这里假设每一个自己的像素点都已经与自己的透明值进行了乘机,如(RGB为 240, 99, 24且透明度为0.33的像素表示为ARGB84, 80, 33, 8)。
如果我们假设第一张位图(1 0 0)的alpha是1,那么最终的结果就是R = S。
这里我们可以看到,图形中的每个点计算过去计算量非常大的。上面仅仅是针对两张位图的计算,而真实场景中通常是有非常多的图层相互叠加而成,即使GPU是专门设计用来进行这种计算的,这仍然是一项非常繁重的工作。
透明vs不透明
如果上面的像素是不透明的话,那么GPU会省去很多的工作,直接使用上面的像素显示就可以了,但是GPU无法知道是不是上面的位图中所有的像素都是不透明的,所以CALayer中有一个opaque的属性,如果设置为YES,那么GPU将会忽略掉下面的所有图层,直接使用当前的图层进行显示。通过Instruments工具中的blended layers选项可以查看到那些图层是不透明的,需要GPU进行合成动作。
如果你确定你的图层是不透明的,那么请将它的opaque属性设置为YES。如果你家在的图片没有alpha通道,那么在通过UIImageView显示图片的时候系统会自动地将opaque属性设置为YES。但是一个没有alpha通道的图片和一个有alpha通道但是完全不透明的图片是有区别的,因为Core Animation需要一个一个像素判断过去那些像素是不透明的。在Finder中可以通过Get Info属性查看图片的alpha通道信息。
像素对齐和错位
到目前为止,我们讨论的情况都是位图像素与屏幕像素正好对齐的情况。这种情况的计算比较简单,直接将图层中像素与对应的屏幕上的点的像素做合并就可以了。
当图层的像素与屏幕上的像素正好是一一对应的时候,我们称之为像素对齐,但是有两种情况却无法满足一一对应的要求。第一种情况是缩放,当图像被缩放的时候,图像中的像素无法与屏幕上的像素一一对应;另一种情况是图像起点不在像素的边界上。
这两种情况下,GPU都需要做额外的运算。需要将原图上的多个像素进行运算才能确定用来进行合成动作的像素值。
在Intrument和模拟器中可以查看到那些图层发生了像素错位。
蒙版
一个图层可以关联一个蒙版,一个蒙版就是一个具有alpha值的位图,该图层会先与蒙版组合,然后再与下面的图层进行组合。当在设置一个图层的圆角的时候,其实就是在为图层添加一个蒙版,我们还可以通过设置一个自定形状的蒙版,比如字母A,那么就会只用字母形状的部分被显示出来。
离屏渲染
离屏渲染可以被Core Animation自动触发,也可以被应用强制触发。离屏渲染将图层树的一部分放到新的缓存中(也就是说不是直接显示在屏幕上的)的进行组合,然后再将buffer渲染到屏幕上。
当组合非常耗时的时候,你可能会想要强制进行离屏渲染,这是一种缓存像素组合结果的方法。当图层树非常复杂的时候,可以通过离屏渲染来缓存这些图层组合的结果,然后再将缓存绘制到屏幕上。
如果应用组合了非常多的图层,同时需要将他们一起做一个动画,那么GPU通常会在每一帧的时候重新绘制这些图层到屏幕上,如果使用离屏渲染,那么GPU会首先将这些图层组合的结果缓存起来,然后再利用缓存绘制屏幕,这样当图层移动的时候,GPU可以重复利用缓存的位图,从而减少工作量。但是需要注意的是,这种情况必须保证图层没有变化,如果图册内容发生了变化,那么GPU就必须重新创建位图缓存。可以通过属性shouldRasterize设置为YES,来触发离屏渲染。
这是一种取舍,因为它也有可能使得渲染变得很慢。创建额外的离屏缓存对于GPU来讲是一种额外的工作负担,如果创建出来的缓存后面都用不到,那么无疑这是一种浪费,如果缓存经常会被用到,那么对GPU来讲会是一种减负。需要通过衡量GPU的使用情况来判断是否需要离屏渲染。
离屏渲染可可能是某些属性设置的负面效果,如果直接或间接设置了图层的蒙版,那么Core Animation将会强制使用离屏渲染来应用蒙版,这对GPU来讲是一种负担。通常GPU的工作负荷只够直接将图层绘制到屏幕的帧缓存中。
Instruments中的Core Aanimation工具有一个Color Offscreen-Rendered Yellow的工具(模拟器的调试选项中也有),可以将离屏渲染的部分用黄色标识出来。勾选上Color Hits Green和Miises Red选项,绿色表示离屏缓存被重用,红色表示离屏缓存被重建。
通常来讲应该尽量避免离屏缓存,因为操作耗时。直接将结果组合到屏幕的帧缓存上要更快。首先创建一个离屏缓存,将图层绘制到离屏缓存上,然后再绘制到帧缓存上,这一系列的动作涉及到上下文的来回切换,会非常耗时。
所以当调试的时候看到黄色标识的时候需要警惕。但也要根据实际情况来判断,如果Core Animation可以重用缓存的话也是可以提升性能的,但重用的前提是缓存的图层是没有变化的。
还需要注意的是栅格化图层(也就是需要先离屏渲染的图层)的缓存空间是有限的,苹果暗示大概是屏幕帧缓存的两倍。
通常设置圆角、设置蒙版图层添加阴影等都会触发离屏渲染,需要尽量避免。针对需要圆角的图层,可以创建一个圆角的图像作为图层的内容来避免离屏渲染,而针对一个矩形的蒙版,则可以通过使用contentsRect来实现矩形蒙版的效果。
如果你仍然执意要把shouldRasterize属性数值为YES,记住同时还需要将rasterizationScale设置为contentsScale。
OS X
如果使用的是OS X平台,那么你会发现大部分的调试工具都在Quartz Debug应用中。Quartz Debug工具在Graphics Tools应用中,是一个需要单独下载的开发工具。
Core Animation和OpenGL ES
Core Animation主要负责动画,但是这里我们跳过动画,专注于绘制。Core Animation可以很高效地进行绘制,这也是为什么使用Core Animation动画可以轻松满足每秒60帧的动画。
Core Animation的核心是OpenGL ES,它封装了OpenGL ES的复杂性。当我们在讨论合成的时候经常使用到图层和位图两个术语,这两个是不同的概念,但却非常相近。
Core Animation图层可以有子图层,是一个图层树的结构,Core Animation的主要工作是计算出图层需要显示的内容,而OpenGL ES的主要工作是将这些图层进行组合并展示到屏幕上。
例如,当设置图层的内容为某个CGImageRef的时候Core Animation创建一个OpenGL纹理,然后将图片的位图上传到对应的纹理中。如果重写了drawInContext方法,那么Core Animation会分配一个纹理,同时调用你的方法将生成对应的位图数据。图层的属性以及CALayer的子类会影响OpenGL如何进行绘制,很多OpenGL ES的底层行为都被封装到了CALayer的概念中。
Core Animation一边通过Core Graphics在CPU内计算出对应的位图,另一边连接着OpenGL ES,因为Core Animation是整个绘制流程中的核心管道,因为它的使用方式将直接影响到绘制的性能。
CPU绑定vxGPU版定
在一个完整的显示流程中,GPU与CPU是一起工作的,各自处理各自的任务,各自也都有着各自有限的资源。
为了保证每秒60帧的刷新速率,我们需要保证CPU和GPU都不能超负荷工作,或者极端一点,我们需要将尽可能多的工作交给GPU,将CPU释放出来来处理程序的逻辑,而不是忙于显示。另外,GPU在处理显示相关的逻辑要比CPU更快,耗电更少,负荷也更低。
因为显示需要CPU与GPU共同完成,因此我们需要搞清楚到底是哪个在影响我们的绘制性能。如果是GPU影响了性能,那么称之为GPU绑定,如果是CPU,则是CPU绑定。对应的解决方法当然是给GPU减负或给CPU减负。
我们可以通过OpenGL ES Driver工具来查看,点击小i按钮,然后配置,确保选中了Device Utilization选项,就可以查看app运行的时候GPU的负荷,如果接近100%,则表示GPU的工作量超负荷了。
而CPU绑定则比较常见,剋有通过Timer Profile工具来查看耗时在哪里。
Core Graphics Quartz 2D
Quartz 2D相比于它所在的框架Core Graphics,并不是那么被人们所熟悉。Quartz 2D功能非常强大,以PDF的创建、绘制和打印为例,其流程与在屏幕上绘制位图基本上一样,都是基于Quartz 2D的。
我们主要看下Quartz 2D的2D绘制功能。其中涉及到了基于路径的绘制、抗锯齿渲染、透明图层、分辨率与设备独立性,涉及到的各种API非常庞大,而且大部分都是基于C的接口。
好在UIKit和AppKit封装了部分的Quartz 2D接口,使其更加方便使用。这些接口可以实现类似于Photoshop和Illustrator的效果。苹果提到过预安装的股票app就是使用了Quartz 2D,其中的曲线绘制就是使用了Quartz 2D编码。
当app在绘制位图的时候,不管怎样总会用到Quartz 2D,这其中一部分位图的计算是由CPU通过Quartz 2D来完成的。虽然Quartz 2D可以完成更多丰富的功能,但是这里我们仅聚焦于使用Quartz 2D来生成一段内存中的RGBA数据。
比如,我们想要绘制一个八边形,使用UIKit:
UIBezierPath *path = [UIBezierPath bezierPath];
[path moveToPoint:CGPointMake(16.72, 7.22)];
[path addLineToPoint:CGPointMake(3.29, 20.83)];
[path addLineToPoint:CGPointMake(0.4, 18.05)];
[path addLineToPoint:CGPointMake(18.8, -0.47)];
[path addLineToPoint:CGPointMake(37.21, 18.05)];
[path addLineToPoint:CGPointMake(34.31, 20.83)];
[path addLineToPoint:CGPointMake(20.88, 7.22)];
[path addLineToPoint:CGPointMake(20.88, 42.18)];
[path addLineToPoint:CGPointMake(16.72, 42.18)];
[path addLineToPoint:CGPointMake(16.72, 7.22)];
[path closePath];
path.lineWidth = 1;
[[UIColor redColor] setStroke];
[path stroke];
使用Core Graphics代码如下:
CGContextBeginPath(ctx);
CGContextMoveToPoint(ctx, 16.72, 7.22);
CGContextAddLineToPoint(ctx, 3.29, 20.83);
CGContextAddLineToPoint(ctx, 0.4, 18.05);
CGContextAddLineToPoint(ctx, 18.8, -0.47);
CGContextAddLineToPoint(ctx, 37.21, 18.05);
CGContextAddLineToPoint(ctx, 34.31, 20.83);
CGContextAddLineToPoint(ctx, 20.88, 7.22);
CGContextAddLineToPoint(ctx, 20.88, 42.18);
CGContextAddLineToPoint(ctx, 16.72, 42.18);
CGContextAddLineToPoint(ctx, 16.72, 7.22);
CGContextClosePath(ctx);
CGContextSetLineWidth(ctx, 1);
CGContextSetStrokeColorWithColor(ctx, [UIColor redColor].CGColor);
CGContextStrokePath(ctx);
问题是这些东西绘制在哪里?这也就是CGContext存在的目的,这个上下文就是我们需要绘制的地方。如果我们重写了CALayer的drawInContext方法,其中有个上下文的参数,向那个上下文写入也就是向那个图层的缓存中绘制数据。但是我们也可以通过CGBitmapContextCreate方法来创建自己的上下文,然后向其中绘制数据。
在UIKit的代码中不需要使用上下文,因为UIKit和AppKit中上下文是自动维护的,UIKit维护了一个上下文堆栈,UIKit方法总是向顶层的上下文中绘制数据,可以通过UIGraphicsGetCurrentContext方法获取到最顶层的上下文,还可以通过UIGraphicsPushContext和UIGraphicsPopContext向堆栈中添加和删除上下文。
在UIKit中可以使用UIGraphicsBeginImageContextWithOptions和UIGraphicsEndImageContext来创建一个位图的上下文,类似于CGBitmapContextCreate方法,UIKit与Core Grahpics混写也是可以的:
UIGraphicsBeginImageContextWithOptions(CGSizeMake(45, 45), YES, 2);
CGContextRef ctx = UIGraphicsGetCurrentContext();
CGContextBeginPath(ctx);
CGContextMoveToPoint(ctx, 16.72, 7.22);
CGContextAddLineToPoint(ctx, 3.29, 20.83);
...
CGContextStrokePath(ctx);
UIGraphicsEndImageContext();
或者
CGContextRef ctx = CGBitmapContextCreate(NULL, 90, 90, 8, 90 * 4, space, bitmapInfo);
CGContextScaleCTM(ctx, 0.5, 0.5);
UIGraphicsPushContext(ctx);
UIBezierPath *path = [UIBezierPath bezierPath];
[path moveToPoint:CGPointMake(16.72, 7.22)];
[path addLineToPoint:CGPointMake(3.29, 20.83)];
...
[path stroke];
UIGraphicsPopContext(ctx);
CGContextRelease(ctx);
Core Graphics功能异常强大,以至于苹果将其描述为可以做到无与伦比的保真输出。它与Adobie Illustrator和Adobe Photoshop的工作模型非常相近,而且Core Graphics中的绝大部分概念也是出自其中。毕竟它起源于NeXTSTEP,也是使用Display PostScript,与Adobe相同。
像素
屏幕上的像素通常使用三个颜色单位来表示RGB,每一张位图都可以使用RGB数据来表示,那么这些数据在内存中是如何表示的呢?目前有很多种不同的表示方法。
首先我们以一张位图为例,通常每个像素我们使用RGB配合上透明度alpha,四个单位来表示。
默认像素布局
在iOS和OS X上一种常见的格式是32 bits-per-pixel(bpp,每个像素32位),8 bits-per-component(bpc,每个单位8位),透明值预算。在内存中如下:
A R G B A R G B A R G B
| pixel 0 | pixel 1 | pixel 2
0 1 2 3 4 5 6 7 8 9 10 11 ...
这种格式通常称为ARGB,开头的第一个为透明度,后面的RGB的值都是与透明度相乘过的结果。
另一种常见的也是32bpp,8bpc,透明度开头但无值的格式如下:
x R G B x R G B x R G B
| pixel 0 | pixel 1 | pixel 2
0 1 2 3 4 5 6 7 8 9 10 11 ...
这种格式也被成为xRGB,没有透明度的值,但是内存依然预留了位置,这样做除了增加了25%内存开销有什么好处呢?最大的好处就是更容易被现代的CPU进行计算,如果所有的像素都是32位的宽度,那么所有的运算数据都是对齐的,不用进行大量的位移运算,尤其是在与有透明度的格式进行混合的时候。
在处理RGB数据的时候,Core Graphics也支持将透明度值放到最后,如RGBA和RGBx。
Esoteric Layouts
大多数情况下,我们处理位图数据的时候都是通过Core Graphics和Quartz 2D,它支持有限的数据格式,但是RGB还有其他的数据格式,如16 bpp 5bpc无透明度的格式。这种格式大概是之前格式所占用内存的一半。但是因为每个颜色单位只有5位,因此图像(尤其是平滑梯度)会产生条带的效果。
另外也有64bpp 16bpc 和 128bpp 32 bpc(同时支持和不支持透明度)的格式,因为每个颜色单位使用的位数更大,因此具有更大的保真度,但是同时也占用了更多的内存和计算资源。
为了解决这个问题,Core Graphics还支持了一些会读和CMKY格式,也是同样包含了支持和不支持透明度的格式。
平面数据
大部分的处理框架,包括Core Graphics使用的像素数据是混合在一起的。但是有些情况下也会遇到所谓的平面组件或者组件平面的东西。它的意思是每个颜色单元共同占据一块内存区域,例如RGB来讲,有三个独立的内存区域,一块包含了所有的R值,一块包含所有的G值和一块包含所有的B值。通常在一些视频处理框架中在某些情况下会使用平面数据。
YCbCr
YCbCr是处理视频数据的时候常用的格式。它也包含了三个单元(Y表示光的浓度,Cb和Cr表示蓝色和红色浓度偏移量)用来表示颜色。简单来讲,它更接近于人眼对于颜色的识别。相对于Cb和Cr,人眼对于亮度更加luma Y更加敏感,因此Cb和Cr可以被更加激进地压缩,而不影响总体的图像质量。
出于同样的原因,JPEG有时候会把RGB数据转换为YCbCr,对每个颜色单位单独进行压缩,在对YCbCr进行压缩的时候,Cb和Cr可以被压缩得更小。
图片格式
通常在iOS和 OS X平台上最常用的是JPEG和PNG格式的图片,让我们重点看下这两种格式。
JPEG
JPEG家喻户晓,通常用来作为相机图片的格式。很多人认为JPEG格式仅仅是一种图片的存储格式,将所有的RGB数据按照上面的某种方式存储在磁盘上,实际上不是这个样子。
将JPEG数据转换成可以直接显示的RGB数据是非常复杂的。对于每个颜色平面,JPEG压缩使用基于离散余弦算法将空间数据转换位频域数据。然后再基于霍夫曼编码的改进,对数据进行量化、排序和打包。压缩的时候将RGB转换为YCbCr数据。解压的时候是上述算法的逆向过程。
这就是为什么创建一个JPEG的UIImage显示在屏幕上的时候,会有一个延迟,因为CPU需要进行数据的解压。如果在tableview中使用了JPEG格式的图片,那么在滚动的时候就很容易发生卡顿。
那么为什么还要使用JPEG格式呢?因为JPEG格式压缩照片的效果非常好。iPhone 5拍摄的原始照片有24MB,默认的压缩设置可以将图片压缩到2到2M。JPEG压缩效果好主要是因为他是有损压缩,通过将人眼不敏感的信息都丢掉,可以远远超过普通压缩算法的效果。但这也仅仅是针对照片,因为JPEG依赖于图片中对于人眼不敏感的信息。如果从网页中截屏一段文字,JPEG就无法很好地进行压缩,压缩过程会变慢,同时还可以明显感觉到JPEG修改了截屏的原始图片。
PNG
PNG读作ping。相对于JPEG,他是一种无损的压缩格式。将图片保存成PNG,然后再打开它,数据会和之前一摸一样。因此PNG对图片的压缩无法向JPEG一样优秀,但是对于app中常用的美术图片,如按钮、图标等,非常实用。而且解码PNG数据要比解码JPEG轻松得多。
但是现世界中也存在着多种PNG格式,但是简单来讲,PNG也同时支持有和没有透明通道的RGB像素压缩。这也是PNG适合app艺术图片的一种原因。
选择格式
在app中一般仅在JPEG和PNG中选择需要的格式。读取和写入这两种格式的编码和解码器是经过高度优化的,且支持并行。而且还可以享受这苹果升级带来的长期持续地免费优化。如果使用其他格式的图片,有可能会影响到app的性能,而且还可能会有安全漏洞,因此在图像解码的过程是黑客们很喜欢注入代码的地方。
需要注意的是,Xcode针对PNG格式的优化与其他常见的优化引擎有很大的不同。当Xcode在对PNG文件优化的时候,这些PNG文件从严格意义上来讲已经不再是PNG图片了,但是对于iOS来讲却可以更高效地解压这些图片。Xcode通过这种方式,来使得这些图片在解压的时候可以使用更高效的算法,但是这种算法并不适用于一般的PNG格式的图片。就像我们之前提到过的,针对像素数据的处理,有多种表示RGB数据的方法,如果这种格式不是iOS图像系统所需要的,就需要对每个像素数据进行便宜操作,而能省略这一步将会大大提升效率。
还需要注意的一点是,如果可以,尽量使用可伸缩图片作为app的素材,这样文件可以尽可能地小,文件需要只需要加载和解码更少的数据量。
UIKit和像素
UIKit中的每个视图都有自己的CALayer,因此每个图层通常都对应一个自己的位图缓存,类似于一张图片,这个缓存就是最终要被显示在屏幕上的数据。
-drawRect:
如果你的视图重写了-drawRect:方法,那么当调用setNeedsDisplay方法的时候,UIKit会调用图层setNeedsDisplay方法,将图层打上标记,标识该图层需要被重绘。这个时候还有没做任何实际的动作,因此一口气连续调用setNeedsDisplay方法并不会有效率问题。
然后当绘制系统准备好以后,会调用图层的display方法,这个时候图层会建立自己的位图缓存,并创建一个Core Graphics上下文CGContextRef与该位图缓存的内存相关联,使用该上下文绘制的时候,内容会被写进该内存区域。
当使用UIKit的绘制方法,如在drawRect方法内调用UIRectFill或[UIBezierPath fill] 的时候,他们将会使用该上下文。因为UIKit会将图层的上下文压到自己的图层堆栈内,将其设置为当前的上下文。使用UIGraphicsGetCurrent将会返回该堆栈顶部的上下文,因此使用UIGraphicsGetCurrent绘图的时候,会绘制到对应图层的位图缓存内。因此当你想要直接使用Core Graphics方法的时候,就可以使用UIGraphicsGetCurrent来获取当前图层的上下文,然后传递到Core Graphics方法内作为上下文参数。
然后图层的位图缓存会被不断地绘制到屏幕上,直到某个时候再次被调用了setNeedsDisplay方法来更新该位图缓存。
没有使用drawRect的时候
当使用UIImageView的时候,视图仍然会有一个对应的CALayer,但是该图层并没有分配一个对应的位图缓存,而是使用CGImageRef作为他的内容,然后绘制引擎会将图像的数据绘制到帧缓存内进行显示。
这个过程中并没有绘制的动作发生,仅仅是将UIImageView的图像以位图的数据的形式发送给Core Animation,再由它将其发送给绘制引擎。
使用drawRect: 或不使用 -drawRect:
虽然说了等于没说,但还是要说“最快地绘图就是不去绘图”
大多数情况下,我们不需要组合自己自定义的视图或者组合图层,因为UIKit已经对他们做过了优化。
一个自定义绘图的例子是苹果的WWDC 2012’s session 506: Optimizing 2D Graphics and Animation Performance,里面有一个手指画画的demo。
另外一个需要自己绘制的例子是iOS的预装股票app,k线图是通过Core Grahpics绘制的。需要注意的是,虽然你需要自己手动绘制一些东西,但并不意味着一定要通过drawRect方法,有些情况下可以通过UIGraphicsBeginImageContextWithOptions或者CGBitmapContextCreate,从中返回一个图片,设置到图层的内容当中。进行测试比较,哪个更快。
实色
如果我们看下面的代码
- (void)drawRect:(CGRect)rect
{
[[UIColor redColor] setFill];
UIRectFill([self bounds]);
}
之所以这段代码不好,是因为我们让Core Animation创建了一个图层的缓存,然后让Core Graphics向里面填充了实色,然后将其上传到GPU中。
我们完全可以绕开drawRect方法来省去上面的这些创建、绘制和上传动作,直接通过设置视图图层的backgroundColor。如果视图的默认图层是一个CAGradientLayer图层,同样的方法可以绕开创建位图缓存的过程来实现渐变。
可拉伸图片
类似的,可以通过使用可拉伸图片来尽情图像系统的压力。比如说我们需要一个300 x 50的图片作为按钮素材,600 x 100 = 60k像素,然后需要60k x 4 = 240kB的内存数据需要被上传到GPU,这些数据是需要占用显存的。如果可以使用可拉伸图片的话,比如说使用一个54 x 12的素材,那么只需要2.6k像素和10kB的内存,会更快。
Core Animation可以通过CALayer的contentsCenter属性来拉伸图片,但是大多数情况下我们还是更常用[UIImage resizableImageWithCapInsets:resizingMode:]。
另外,在按钮最终被第一次显示在屏幕上之前,相对于从文件系统中读取60k像素的PNG图片并解压,图片越小,这个过程也会更快。因此总体来说使用更小的可拉伸图片在各个流程都会更加高效。
并行绘制
objc.io的最后一个iusse就是并行。我们都知道UIKit的线程模型非常简单,只能够在主线程中使用UIKit中的类。那么对于绘制呢?
如果重写了drawRect方法,并需要绘制一些较为复杂的内容,那么这可能会比较耗时。为了让动画更加顺滑,我们很倾向于在次线程中做很多工作。多线程非常复杂,但是通过一些方法,我们还是可以很轻松地实现多线程绘制。
我们只能在主线程中向CALayer的位图缓存中绘制数据,这通常不是很高效,但是我们可以在次线程中向另外一个上下文绘制内容。
我们之前讲过,Core Graphics方法都会有一个上下文参数,表示向哪里绘制内容。UIKit有一个当前上下文表示数据绘制到哪里。这个当前上下文针对每一个线程是不同的。
为了实现并线绘制,我们可以采用下面的方法。在另外一个队列中创建一个图片,然后切换到主线程中将该图片设置为UIImageView的图片。在WWDC 2012 session 211中有对其进行讨论:
- (UIImage *)renderInImageOfSize:(CGSize)size;
{
UIGraphicsBeginImageContextWithOptions(size, NO, 0);
// do drawing here
UIImage *result = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
return result;
}
这个方法通过UIGraphicsBeginImageContextWithOptions创建了一个指定尺寸的CGContextRef位图。同时将新创建的上下文作为UIKit的当前上下文。此时就可以像在drawRect中那样绘制需要的内容。然后通过UIGraphicsGetImageFromCurrentImageContext将位图数据以图片的形式返回,最后销毁该上下文。
非常重要的一点是,在上面的方法中所有调用的绘制方法必须是线程安全的,也就是说你所调用的属性必须是线程安全的。因为你是在另外一个队列中调用这个方法,如果这个方法在你的视图类中,那么就很危险了。另外一个方法是创建一个单独的绘制类,设置后所有属性后,然后仅仅触发绘制图片的方法。
所有的UIKit绘制API都可以在其他的队列中调用,但是要保证所有UIGraphicsBeginImageContextWithOptions和UIGraphicsEndIamgeContext中的所有代码都在同一个操作中。
可以通过如下的方式来触发绘制:
UIImageView *view; // assume we have this
NSOperationQueue *renderQueue; // assume we have this
CGSize size = view.bounds.size;
[renderQueue addOperationWithBlock:^(){
UIImage *image = [renderer renderInImageOfSize:size];
[[NSOperationQueue mainQueue] addOperationWithBlock:^(){
view.image = image;
}];
}];
注意这里的view.image = image只能在主线程中调用。
通常,多线程变成会增加复杂性,我们还需要考虑将取消掉某些后台绘制,设置并线队列中允许执行的最大绘制数量。
为了便于便于,最简单的方法是使用NSOperation的子类来实现renderInImageOfSize方法。
最后,还需要指出的是在UITableViewCell中使用异步绘制也需要小心。当图像绘制好的时候,cell可能已经被重用来显示其他的数据了。
CALayer的七七八八
现在我们已经直到CALayer关联着GPU最终显示的位图,视图有一个位图缓存,存放着需要被手动绘制到界面上的位图数据。
通常使用CALayer的时候,需要将其内容设置为图片,这样做是为了让Core Animation使用图片的位图数据来显示在界面上,如果是JPEG或PNG图片,Core Animation会进行图片的解压并将数据上传到GPU。
虽然还有一些其他类型的图层。但是如果使用的是一般的CALayer,那么不要设置content,而是设置背景色,Core Animaiton就不需要上传任何数据到GPU,就可以让GPU在没有任何像素数据的情况下进行绘制。对于渐变图层也是一样,CPU可以不需要任何工作,GPU也不需要上传任何像素数据就完成绘制
手动绘制的图层
如果一个CALayer子类重写了drawInContext或者它的代理方法drawLayer:inContext:,Core Animation会为其分配一个位图缓存来存放该方法绘制的位图数据,这个方法内部的代码需要在CPU上执行,然后将结果上传到GPU。
形状和文字图层
对于图形和文字图层,情况会有些不同。这种图层Core Animation都会为其分配位图缓存存放生成的位图数据。Core Animation会在位图缓存中绘制图形或者文字,这与我们重写drawInContext方法然后在其内部绘制图形或者文字的情况非常相同。效率也基本相同。
当需要更新图形或者文字图层的时候,图层的位图缓存会被更新,Core Animation会重新绘制该缓存内的内容,比如在对形状图层的大小进行一个动画效果,Core Animation不得不在动画的每一帧中重新绘制该形状。
异步绘制
CALayer有一个drawsAsynchronously的属性,看起来好像是解决所有问题的银弹,但是,它可能提升性能,也可能是性能的噩梦。
当设置了drawsAsynchronously为YES的时候,drawRect或者drawInContext方法仍然会在主线程中调用,但是所有对Core Graphics的调用什么都不会做(所有UIKit的图形API,也都最终调用的是Core Graphics),这些绘制命令会延迟然后切到后台异步执行。
也就是说所有的绘图命令只是被记录下来,然后稍后在后台队列执行,为了实现这种方式,系统需要做大量的工作,但是很多工作被从主线程中移除掉了,最好进行测试和比较再确定最终方案。
通常对于非常复杂的绘制方法可以提升性能,但是对于比较简单的绘制未必起作用。