《iOS 三问》 -- iOS UI 显示的原理及优化策略 (下) -- iOS UI 优化场景

1 本章小结

2 离屏渲染

离屏渲染 这个概念与 CALayer 在之前章节中我们已经一起学习过了,但那时说得不太够细,现在我们来细细看下这个问题。首先,我们补充下当初学习 CALayer 时漏掉的点,关于 CALayer 的显示结构:

ios-ui-advanced-layer-compose

这里我们看到,CALayer 也不是简简单单一张图画,也是由几个部件合成的。默认的空 Layer 的三个视觉元素是这样的:contents 为空,背景颜色为空 (透明色),前景框宽度为 0 的前景框,也就是说这个视图从视觉上是都看不到的。

通常来说,在原理篇我们学习显示的硬件流水与软件流水时已经知道了,一般情况下 layer 的这些东西直接传给 GPU 渲染进 Framebuffer 中显示就可以了,这也就是所谓的 在屏渲染 (On-Screen Rendering)。但是并不是所有事情都这么简单的。

思考一下,当我们使用 CG 方法绘图时,Core Graphics 绘制 API 是在 CPU 上执行的,也就是我们调用 CG 方法绘图是由 CPU 渲染出 bitmap,再赋给 layer 经 GPU 显示,这里的渲染结果不是直接入 Framebuffer,故被称为 离屏渲染 (Off-Screen Rendering)。所以我们说尽量少地使用 CG 方法绘图,因为这会加大 CPU 的工作量。

相较于 CPU 的离屏渲染,GPU 的离屏渲染更为隐蔽。当对图层使用圆角,阴影,遮罩等效果的时候,图层的这些属性导致需要对 layer 的 contents 进行再加工。这时,图层就不是简单地直接由 GPU 渲染至 FB 了,而是要先创建一个缓冲区,将渲染上下文从屏幕切换到这个缓冲区并进行离屏渲染,渲染结束后,再将上下文切回屏幕,这时再利用离屏渲染的内容结合输出显示。我们从这个过程可以看到,这就产生了 上下文切换开销 创建缓冲区的开销 。所以我们说,离屏渲染会带来性能问题,如果一屏显示中太多离屏渲染,就会带来卡顿感。

那么除了圆角,还有哪些操作会触发离屏渲染呢?

2.1 哪些操作会触发离屏渲染

官方公开的的资料里关于离屏渲染的信息最早是在 2011 年的 WWDC, 在多个 session 里都提到了尽量避免会触发离屏渲染的效果,包括:mask, shadow, group opacity, edge antialiasing。

  • shouldRasterize(光栅化)
  • masks(遮罩)
  • shadows(阴影)
  • edge antialiasing(抗锯齿)
  • group opacity(不透明)
  • 复杂形状设置圆角等
  • 渐变
  • Text(UILabel, CATextLayer, Core Text, etc)…

遇到离屏渲染我们怎么处理呢?

2.2 Color Offscreen-Rendered Yellow

首先我们要确定 UI 遇上了离屏渲染问题。使用 Debug 工具中的 Color Offscreen-Rendered Yellow 可以检测离屏渲染(我们在中篇 UI 优化工具 介绍过),这个选项会把那些离屏渲染的图层显示为黄色。黄色越多,性能越差。当你的 UI 界面非常黄的时候,就说明你要对其进行优化了。

可以通过看我的 iOSOneDemo 中有一个示例如下 UI 优化 - 优化工具 这个列表,当使用 Color Offscreen-Rendered Yellow 工具查看时,列表部分比较黄,因为这里用到有阴影,产生了离屏渲染。

2.3 优化离屏渲染

当我们发现 App 产生离屏渲染后,可以采取什么措施来优化呢? 首先我们应该要清楚是什么操作导致了离屏渲染 ,比如你是用圆角还是阴影。然后我们可以结合工具确定下,比如把 UI 的圆角去掉,看是否 FPS 上去了界面流畅了。 确定问题之后,我们要思考下是否要以采取措施绕过去 ,比如圆角效果,是不是可以直接让设计输出本身就是圆角的图片,或者使用一张四周圆角的 mast 图片 (或自己画一张) 盖在上面。 确定方案后再结合工具尝试下,看问题是否解决 。下面给出常邮的几种优化方案。

2.3.1 后台重绘

引用文章【3】中 ibireme 的这篇文章说了一个图像后台绘制的方法:

图像后台绘制
1
2
3
4
5
6
7
8
9
10
11
- (void)display {
dispatch_async(backgroundQueue, ^{
CGContextRef ctx = CGBitmapContextCreate(...);
// draw in context...
CGImageRef img = CGBitmapContextCreateImage(ctx);
CFRelease(ctx);
dispatch_async(mainQueue, ^{
layer.contents = img;
});
});
}

我们可以通过后台绘制而不使用会导致离屏渲染的属性这种方法来绕开离屏渲染。但是这种方法本身也会产生开销,怎样把绘制的结果缓存起来下次利用可以一定程度上减少这种开销。具体使用时需要结合实际,最观察优化后的结果 。

比如给 UIImageView 扩展个成生圆角的方法:

UIImageView 后台绘制圆角
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
- (void)imageWithCorner:(CGSize)size fillColor:(UIColor *)fillColor completion:(void (^)(UIImage *image))completion
{
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{

UIGraphicsBeginImageContextWithOptions(size, YES, 0);
CGRect rect = CGRectMake(0, 0, size.width, size.height);

// 绘制圆角
[fillColor setFill];
UIRectFill(rect);
UIBezierPath *path = [UIBezierPath bezierPathWithOvalInRect:rect];
[path addClip];

// 绘制图像
[self drawInRect:rect];

// 输出图像
UIImage *result = UIGraphicsGetImageFromCurrentImageContext();

// 回调
dispatch_async(dispatch_get_main_queue(), ^{
if (completion) {
completion(result);
}
});
});
}

上面的方法不建议直接拿来用,只是提供了一个思路。因为:

  1. 上面的代码没有做缓存,并非最优 (缓存的话要自行做好 2 级缓存);
  2. 在图片很多的列表或 collectionView 中,这样做虽然减轻了 GPU 负担,但是加重了 CPU 负担!

2.3.2 混合图层

也就是针对属性效果使用一张透明的视图遮罩在上面。多一个图层会增加合成的工作量,但这点工作量与离屏渲染相比微不足道。比如,你要实现圆角效果,就使用一张中间空洞四周圆角的图片盖在原图上面。你要实现阴影,就使用四周阴影中间空洞的图片盖在上面。

2.3.3 优化 shadow

layer 的阴影属性有个坑,我们之前介绍 CALayer 时在讲到阴影时说他有个牛逼的功能,就是会根据你图片的轮廓产生阴影 (见我的 iOSOneDemo 中 CALayer 属性部分)!但是这个牛逼功能也带来了一个坑,就是离屏渲染!

所以,如果不想要离屏渲染,指定一个与边界相同的简单路径就可以了。

1
cell.layer.shadowPath = [UIBezierPath bezierPathWithRect:cell.bounds]

2.3.4 EdgeAntialiasing

根据 seedante 在引用 1 中里说的,开启 edge antialiasing(旋转视图并且设置 layer.allowsEdgeAntialiasing = true) 在 iOS 8 和 iOS 9 上并不会触发离屏渲染,对性能也没有什么影响,也许到现在这个功能已经被优化了。

2.3.5 shouldRasterize

shouldRasterize = YES 在其它属性触发离屏渲染的同时,会将光栅化后的内容缓存起来, 如果对应的 layer 或者 sublayers 没有发生改变,在 下一帧的时候可以直接复用 , 从而减少渲染的频率。

开启 Rasterization 后,GPU 只合成一次内容,然后复用合成的结果;合成的内容超过 100ms 没有使用会从缓存里移除,在更新内容时还会产生更多的离屏渲染。对于内容不发生变化的视图,原本拖后腿的离屏渲染就成为了助力;如果视图内容是动态变化的,使用这个方案有可能让性能变得更糟。

使用 shouldRasterize 优化离屏渲染
1
2
cell.layer.shouldRasterize = YES;
cell.layer.rasterizationScale = [UIScreen mainScreen].scale;

3 引用

坚持原创技术分享,您的支持将鼓励我继续创作!