×

iOS离屏渲染优化

96
路飞_Luck
2017.09.02 16:26* 字数 2899

本文参考seedante的iOS离屏渲染优化

离屏渲染(Offscreen Render)

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

本文以「Color Offscreen-Renderd Yellow」为触发离屏渲染的标准,除非还有这个标准无法检测出来的引发离屏渲染的行为。那么 Core Graphics API 是不会触发离屏渲染的,比如重写drawRect:,而除了以上四种效果会触发离屏渲染,使用系统提供的圆角效果也会触发离屏渲染,比如这样:

view.layer.cornerRadius = 5
view.layer.masksToBounds = true
开始之前,先铺垫一点基础的东西。
UIView 和 CALayer 的关系

The Relationship Between Layers and Views的解释很细致但是太啰嗦,简单来说,UIView 是对 CALayer 的一个封装。

Graphics and Animations.png

CALayer 负责显示内容contents,UIView 为其提供内容,以及负责处理触摸等事件,参与响应链。CALayer 的结构如下.出自 Layers Have Their Own Background and Border

CALayer构成.jpg

CALayer 有三个视觉元素,中间的contents属性是这样声明的:var contents: AnyObject?,实际上它必须是一个CGImage才能显示。

当使用let view = UIView(frame: CGRectMake(0, 0, 200, 200))生成一个视图对象并添加到屏幕上时,从 CALayer 的结构可以知道,这个视图的 layer 的三个视觉元素是这样的:contents为空,背景颜色为空(透明色),前景框宽度为0的前景框,这个视图从视觉上看什么都看不到。CALayer 文档第一句话就是:「The CALayer class manages image-based content and allows you to perform animations on that content.」UIView 的显示内容很大程度上就是一张图片(CGImage)。

UIImageView

既然直接对 CALayer 的contents属性赋值一个CGImage便能显示图片,所以 UIImageView 就顺利成章地诞生了。实际上 UIImage 就是对 CGImage(或者 CIImage) 的一个轻量封装。记得我刚接触 iOS 时,搞不懂这两者的区别,有人这样对我说过,没想到出处是这里:

1464163152150338.jpg

UIKit 和 Core Graphics 框架的联系很紧密,UIKit 里带CG前缀属性的类基本上是对应 Core Graphics 框架里的对象的封装,UIKit 里的绘制功能也是 Core Graphics 绘制 API 的封装。Drawing with Quartz and UIKit列举了这些对应关系。界面的内容主要是图像和文字,文字是怎么显示的?也是使用 Core Graphics 框架绘制出来的。

接下来,正式开始本文的话题。

RoundedCorner

设置圆角:

view.layer.cornerRadius = 5
方案1 重绘

重绘的方式有多种,都是殊途同归。实际中重绘圆角的优化方案需要考虑的是,将图像重新绘制为为圆角图像相当于多了一份拷贝,要不要缓存?A.第一次重绘后将这些圆角图像缓存在磁盘里,第二次加载直接使用缓存的圆角图像;B.直接保存在内存里,在内存比较吃紧时显然不是个好选择;C.不缓存,和系统圆角一样,每次都重绘,浪费电量。

方案2

如果不需要对外部来源的图片做圆角,由设计师直接画成圆角图片是最方便的;

方案3 混合图层

在要添加圆角的视图上再叠加一个部分透明的视图,只对圆角部分进行遮挡。VVebo微博客户端就是这样做的,遮挡的部分背景最好与周围背景相同。多一个图层会增加合成的工作量,但这点工作量与离屏渲染相比微不足道,性能上无论各方面都和无效果持平。下面左侧的图像是 VVebo 里用来制造圆形头像的 mask 图像,实际中有这种需求的基本是制造圆形头像,普通的圆角遮罩需要左二这种,左三是通用型。如果叠加的视图都一样,可以只加载一次遮罩图片以减少内存占用。

如何在文本视图类上实现圆角

文本视图主要是这三类:UILabel, UITextField, UITextView。其中 UITextField 类自带圆角风格的外型,UILabel 和 UITextView 要想显示圆角需要表现出与周围不同的背景色才行。想要在 UILabel 和 UITextView 上实现低成本的圆角(不触发离屏渲染),需要保证 layer 的contents呈现透明的背景色,文本视图类的 layer 的contents默认是透明的(字符就在这个透明的环境里绘制、显示),此时只需要设置 layer 的backgroundColor,再加上cornerRadius就可以搞定了。不过 UILabel 上设置backgroundColor的行为被更改了,不再是设定 layer 的背景色而是为contents设置背景色,UITextView 则没有改变这一点,所以在 UILabel 上实现圆角要这么做:

//不要这么做:label.backgroundColor = aColor 以及不要在 IB 里为 label 设置背景色
label.layer.backgroundColor = aColor
label.layer.cornerRadius = 5
Shadow

阴影直接合成在视图的下面,视图结构里并没有多出一个视图。在没有指定阴影路径时,阴影是沿着视图的非透明部分扩展的,而且 CALayer 的三个视觉元素至少有一个存在时才会有阴影。

使用阴影必须保证 layer 的masksToBounds = false,因此阴影与系统圆角不兼容。但是注意,只是在视觉上看不到,对性能的影响依然。通常这样实现一个阴影:

let imageViewLayer = avatorView.layer
imageViewLayer.shadowColor = UIColor.blackColor().CGColor
imageViewLayer.shadowOpacity = 1.0 //此参数默认为0,即阴影不显示
imageViewLayer.shadowRadius = 2.0 //给阴影加上圆角,对性能无明显影响
imageViewLayer.shadowOffset = CGSize(width: 5, height: 5)
//设定路径:与视图的边界相同
let path = UIBezierPath(rect: cell.imageView.bounds)
imageViewLayer.shadowPath = path.CGPath//路径默认为 nil

在 OffscreenRenderDemo 里,仅开启阴影(没有指定路径,同屏数量10个以上)在滚动时帧率会大幅下降,检测到离屏渲染的黄色特征;指定一个与边界相同的简单路径后离屏渲染特征消失,帧率恢复正常。

除了指定路径,实现良好性能阴影的方法还有:用圆角优化里混合图层的方法模拟阴影的效果:放一个同样效果的视图在要添加阴影程度的视图的下方;使用 Core Graphics 绘制阴影,不过除非万不得已没人想碰 Core Graphics API。从实现成本来讲,都不如指定路径方便。

Mask

Mask 效果与混合图层的效果非常相似,只是使用同一个遮罩图像时,mask 与混合图层的效果是相反的,在 Demo 里使用反向内容的遮罩来实现圆角。实现 mask 效果使用 CALayer 的layer属性,在 iOS 8 以上可以使用 UIView 的maskView属性。

if #available(iOS 8.0, *) {
    avatorView.maskView = UIImageView(image: maskImage)
} else {
    let maskLayer = CALayer()
    maskLayer.frame = avatorView.bounds
    maskLayer.contents = maskImage?.CGImage
    avatorView.layer.mask = maskLayer
}

如果所有 maskImage 相同的话,使用一个 maskImage 就够了,不然每次生成一个新的 UIImage 也会是一个性能隐患点。注意:可以使用同一个 maskImage,但不能使用同一个 maskView,不然同时只会有一个 mask 效果。

Mask 效果无法取消离屏渲染,使用混合图层的方法来模拟 mask 效果,性能各方面都是和无效果持平。

使用 mask 来实现圆角时也可以不用图片,而使用 CAShapeLayer 来指定混合的路径。

let roundedRectPath = UIBezierPath(roundedRect: avatorView.bounds, byRoundingCorners: .AllCorners, cornerRadii: CGSize(width: 10, height: 10))
let shapeLayer = CAShapeLayer()
shapeLayer.path = roundedRectPath.CGPath
avatorView.layer.mask = shapeLayer

同样的 mask 效果使用 CAShapeLayer 时相比直接使用 maskImage 在帧率上稍低,CPU 利用率无明显变化,但是 GPU 利用率也低一些。

GroupOpacity

首先来看看 GroupOpacity 是什么效果:

1464163639631478.png

GroupOpacity 是指 CALayer 的allowsGroupOpacity属性,UIView 的alpha属性等同于 CALayer opacity属性。开启 GroupOpacity 后,子 layer 在视觉上的透明度的上限是其父 layer 的opacity。

从 iOS 7 以后默认全局开启了这个功能,这样做是为了让子视图与其容器视图保持同样的透明度。

GroupOpacity 开启离屏渲染的条件是:layer.opacity != 1.0并且有子 layer 或者背景图。

这个触发条件并不需要subLayer.opacity != 1.0,非常容易满足。然而在 TableView 这样的视图里设置 cell 或 cell.contentView 的alpha属性小于1并不能检测离屏渲染的黄色特征,性能上也没有明显差别。经过摸索发现:只有设置 tableView 的alpha小于1时才会触发离屏渲染,对性能无明显影响;设置 cell 的alpha属性并不会对整体的透明度产生影响,只有设置 cell.contentView 才有效。

EdgeAntialiasing

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

终极优化方案

除了 GroupOpacity 和 EdgeAntialiasing,其他效果触发的离屏渲染都会对性能产生严重影响,离屏渲染真的是一无是处吗?不,离屏渲染本来是个优化设计。如何物尽其用?答案是:Rasterization。

cell.layer.shouldRasterize = true
cell.layer.rasterizationScale = cell.layer.contentsScale

shouldRasterize = false时,离屏渲染的黄色特征仅限于上述自动触发离屏渲染的效果的部分,shouldRasterize = true后该部分和开启了该属性的 layer 整体(在这里就是 cell 整体)都有黄色特征,所以开启 Rasterization 是手动启动了离屏渲染。

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

总结
(1) RoundedCorner 在仅指定cornerRadius时不会触发离屏渲染,仅适用于特殊情况:contents为 nil 或者contents不会遮挡背景色圆角;
(2) Shawdow 可以通过指定路径来取消离屏渲染;
(3) Mask 无法取消离屏渲染;

以上效果在同等数量的规模下,对性能的影响等级:Shadow > RoundedCorner > Mask > GroupOpacity(迷之效果)。

任何时候优先考虑避免触发离屏渲染,无法避免时优化方案有两种:

(1) Rasterization:适用于静态内容的视图,也就是内部结构和内容不发生变化的视图,对上面的所有效果而言,在实现成本以及性能上最均衡的。即使是动态变化的视图,开启 Rasterization 后能够有效降低 GPU 的负荷,不过在动态视图里是否启用还是看 Instruments 的数据。

(2) 规避离屏渲染,用其他手法来模拟效果,混合图层是个性能最好、耗能最少的通用优化方案,尤其对于 rounded corer 和 mask。

OC 进阶
Web note ad 1