很多人都知道设置了layer的圆角属性cornerRadius
并裁减clipsToBounds/layer.masksToBounds = YES
之后会触发离屏渲染,所以在类似tableView这种在整个界面上呈现出很多圆角视图就会造成卡顿,所以不建议在一个页面上用这种方式大量设置圆角,但是所有的圆角都会触发离屏渲染吗?我们来看看下面的例子。
-
先通过Xcode模拟器打开离屏渲染效果.
-
我们来看下下面代码跑起来的效果
///都用圆角
//imageView没有设置背景色,不会离屏
UIImageView *imgView = [[UIImageView alloc] initWithFrame:CGRectMake(100, 30, 100, 100)];
imgView.layer.cornerRadius = 20;
imgView.layer.masksToBounds = YES;
imgView.image = [UIImage imageNamed:@"测试.png"];
[self.view addSubview:imgView];
//imageView设置背景色,用clipsToBounds/masksToBounds会离屏
UIImageView *imgView2 = [[UIImageView alloc] initWithFrame:CGRectMake(100, 150, 100, 100)];
imgView2.layer.cornerRadius = 20;
// imgView2.clipsToBounds = YES;
imgView2.layer.masksToBounds = YES;
imgView2.backgroundColor = [UIColor whiteColor];
imgView2.image = [UIImage imageNamed:@"测试.png"];
[self.view addSubview:imgView2];
//imageView设置边框,会离屏
UIImageView *imgView3 = [[UIImageView alloc] initWithFrame:CGRectMake(100, 270, 100, 100)];
imgView3.layer.cornerRadius = 20;
imgView3.layer.masksToBounds = YES;
imgView3.image = [UIImage imageNamed:@"测试.png"];
imgView3.layer.borderWidth = 2;
imgView3.layer.borderColor = [UIColor redColor].CGColor;
[self.view addSubview:imgView3];
//按钮只设置背景色,不会离屏
UIButton *btn = [UIButton buttonWithType:UIButtonTypeCustom];
btn.frame = CGRectMake(100, 400, 100, 100);
btn.layer.cornerRadius = 20;
btn.layer.masksToBounds = YES;
btn.backgroundColor = [UIColor redColor];
[self.view addSubview:btn];
//按钮设置背景色+背景图,会离屏
UIButton *btn1 = [UIButton buttonWithType:UIButtonTypeCustom];
btn1.frame = CGRectMake(100, 520, 100, 100);
[btn1 setImage:[UIImage imageNamed:@"测试.png"] forState:UIControlStateNormal];
btn1.layer.cornerRadius = 20;
btn1.layer.masksToBounds = YES;
btn1.backgroundColor = [UIColor redColor];
[self.view addSubview:btn1];
- 可以看到黄色的区域发生了离屏渲染,分别是第2、3、5个视图,而第1、4个正常。说明了离屏渲染的触发是有条件的。
我们先来了解下屏幕的渲染流程,下面分别是正常渲染流程和离屏渲染流程
假设我们的APP有60FPS的,那么屏幕的一分钟就能显示60帧,那么在正常渲染流程里,会先把图像数据放在帧缓冲区(Frame Buffer)里面,然后屏幕刷新的时候视频控制器不断从帧缓冲区里取数据。
而离屏渲染的时候,会先把CPU处理好的数据放在帧缓冲区之外另外开辟的离屏缓冲区(OffScreen Buffer)里面,等把要一起显示的数据都叠加到离屏缓冲区里之后,再交由帧缓冲区按正常渲染流程进行。
举两个例子:
- 当我们使用遮罩效果
layer.mask
的时候,也会触发离屏渲染,它的渲染过程中,GPU首先渲染好遮罩层layer,这时候并不能交给帧缓冲区给屏幕显示,需要等到整个图像遮罩效果处理后才能交给帧缓冲区。这时候它就把遮罩层放在离屏缓冲区,然后再渲染好另外一个图层,之后也放到离屏缓冲区里,两个图层合并之后再交给帧缓冲区,最后显示到屏幕上。而这一次mask发生了两次离屏渲染和一次主屏渲染,相当于普通视图的3倍,若再加上下文环境切换,一次mask就是普通渲染的30倍以上耗时操作。
我做了个测试来直观感受使用遮罩的性能损耗。
当使用layer.cornerRadius
+layer.masksToBounds
处理圆角视图,在滚动视图里快速滚动时,可以看到FPS变化在56左右
而如果改为使用
UIBezierPath* path = [UIBezierPath bezierPathWithRoundedRect:self.bounds byRoundingCorners:byRoundingCorners cornerRadii:cornerRadius];
CAShapeLayer* shape = [CAShapeLayer layer];
shape.path = path.CGPath;
self.layer.mask = shape;
可以看到FPS已经低于50了。所以尽量不要过多的使用layer.mask
去处理视图。
- 当我们使用毛玻璃效果
UIVisualEffectView
,渲染流程是先拿到渲染内容(Render Content)、捕获内容(capture Content)、垂直模糊(Vertical Blur)、水平模糊(Horizontal Blur),分别把这4步的图层放到离屏缓冲区里,然后拿出来合成(Compositing Pass)到最终的模糊效果图。
所以离屏渲染的原理是:APP进行额外的渲染和合并操作,在离屏缓冲区(OffScreen Buffer)组合,之后交给帧缓冲区(Frame Buffer),最后显示到屏幕上。
这样(触发离屏渲染)带来的影响是什么呢?
1.需要开辟额外的存储空间;
2.从OffScreen Buffer转存到Frame Buffer,也是需要时间的;
3.OffScreen Buffer空间是有限制的,大小相当于屏幕像素点的2.5倍。
这就解释了为什么离屏渲染这么耗时。原因主要在创建缓冲区和上下文切换。创建新的缓冲区代价都不算大,付出最大代价的是上下文切换。
上下文切换:首先我要保存当前屏幕渲染环境,然后切换到一个新的绘制环境,申请绘制资源,初始化环境,然后开始一个绘制,绘制完毕后销毁这个绘制环境,例如需要切换到On-Screen Rendering或者再开始一个新的离屏渲染重复之前的操作。
所以开启了大量的离屏渲染就会容易掉帧,造成性能问题。
既然离屏渲染有这些性能问题,那为什么还要用呢?
- 当我们需要一些特殊的效果,这种效果不能一次性渲染完成,需要使用离屏缓冲区来保存中间状态,就不得不使用离屏渲染,这种情况是系统自动触发,比如经常使用的圆角、阴影、高斯模糊、遮罩(mask),抗锯齿(edge antialiasing)等。
- 可以提升渲染的效率,当一个效果需要多次显示在屏幕上时,可以提前渲染,保存到离屏缓冲区,可以达到复用的目的。这种情况是需要我们手动触发的。
主动使用离屏渲染的原因:光栅化
上面说到光栅化(shouldRasterize = YES
)可以复用,提高渲染效率。
使用
view.layer.shouldRasterize = YES;
view.layer.rasterizationScale = view.layer.contentsScale;
开启光栅化。
但是光栅化不是可以随便乱开的,使用建议:
- 如果layer不能被复用,则没有必要开启光栅化;
- 如果layer不是静态,需要被频繁修改,比如处于动画中,那么开启光栅化反而影响了效率;
- 离屏渲染缓存内容有时间限制,缓存的内容如果100ms内没有被使用,那么它就会丢弃,无法进行复用;
- 离屏渲染的缓存空间有限,超过2.5倍屏幕像素大小的话也会失效,无法进行复用了;
总结来说,如果可以避免触发离屏渲染,尽量避免,否则如果视图可以复用,并且是的静态内容,也就是内部结构和内容不发生变化的视图,才考虑开启光栅化,但是也要考虑光栅缓存的利用率,如果整屏视图中利用到光栅缓存的视图很少,那反而更耗费性能。(可以通过CoreAnimation InStruments工具查看并通过Xcode打开「Color Hits Green and Misses Red」观察离屏渲染对缓存的使用,绿色代表了用到了光栅缓存,红色则是需要重新渲染的部分)
回到一开始的问题,设置圆角而触发离屏渲染是有条件的。
先来看看一个视图的渲染层级。
再来看看苹果官方文档上关于设置圆角的说法。
可以看到,苹果告诉我们,设置了cornerRadius只会设置backgroundColor和border的圆角,而内容图层contents,并不会设置圆角。除非你同时设置了layer.masksToBounds(对应view.clipsToBounds),才会也对contents设置圆角。
离屏渲染的逻辑
这张图演示的是著名的油画算法。指的是先绘制远的部分,再绘制近的部分。我们图层的渲染也是同理,系统会先绘制最底层的图层,再往上一层层的绘制,最后形成了图层树,所以苹果建议开发者在建立UI的时候,图层树不要太复杂,层级不要太多,不然也是会有性能影响。
- 正常渲染:如果是不触发离屏渲染的正常渲染,苹果在绘制完最底层的图层,从帧缓冲区显示到屏幕上之后,就丢弃了,并不会保存起来,再画第二层,也是如此,显示完了就丢弃,从而节省了空间。
- 离屏渲染:如果对一个多图层图像进行圆角处理,就需要对所有图层进行圆角(包括内容contents),如果按照正常渲染,一层用完就丢弃,这样就达不到显示的效果。这时候就需要开辟一个离屏缓冲区去保存这些图层,等到所有图层都做了圆角处理,就把它们从离屏缓冲区里取出来进行合并显示。
这就解释了为什么有些圆角会触发离屏渲染,纠其根本就是用到了离屏缓冲区。
我总结了处理圆角的几种可用方式.
- 1.最简单的就是让UI同事切一个带圆角的图。
- 如果内容contents没有内容,只有背景色,就不用使用masksToBounds/ClipsToBounds了。直接设置cornerRadius就好了。
- 3.设置imageView的圆角并裁减。比如我们要设置带图片按钮的圆角,可以这样设置。
//按钮设置背景色+背景图,会离屏
UIButton *btn1 = [UIButton buttonWithType:UIButtonTypeCustom];
btn1.frame = CGRectMake(100, 100, 100, 100);
[btn1 setImage:[UIImage imageNamed:@"测试.png"] forState:UIControlStateNormal];
btn1.layer.cornerRadius = 20;
btn1.layer.masksToBounds = YES;
[self.view addSubview:btn1];
//改为
UIButton *btn2 = [UIButton buttonWithType:UIButtonTypeCustom];
btn2.frame = CGRectMake(100, 220, 100, 100);
[btn2 setImage:[UIImage imageNamed:@"测试.png"] forState:UIControlStateNormal];
btn2.imageView.layer.cornerRadius = 20;
btn2.imageView.layer.masksToBounds = YES;
[self.view addSubview:btn2];
这样就不会离屏渲染了。
4.CATextLayer, Core Text设置了文本内容也会触发离屏渲染,而label设置圆角不当的时候也会触发离屏渲染
//文字圆角
UILabel *lab = [[UILabel alloc] initWithFrame:CGRectMake(220, 30, 50, 50)];
lab.text = @"文字";
//设置layer的背景色会离屏渲染
lab.layer.backgroundColor = [UIColor redColor].CGColor;
//设置label背景色不会离屏渲染
// lab.backgroundColor = [UIColor redColor];
lab.layer.cornerRadius = 20;
//当设置layer背景色时,圆角不用裁剪也能生效。
lab.layer.masksToBounds = YES;
//在masksToBounds基础上设置了边框就会导致离屏
// lab.layer.borderWidth = 1;
[self.view addSubview:lab];
//添加子视图后只要有裁剪,不论是在设置哪里的背景色都会离屏。
// UIView *viewinLab = [[UIView alloc] initWithFrame:CGRectMake(30, 0, 20, 20)];
// viewinLab.backgroundColor = [UIColor blueColor];
// [lab addSubview:viewinLab];
- 会触发离屏渲染的情况:cornerRadius+masksToBounds,并且设置了layer.backgroundColor,或者设置了边框或者添加子视图。
- 不会触发离屏渲染:cornerRadius+masksToBounds+label.backgroundColor,或者cornerRadius+layer.backgroundColor(加上masksToBounds会离屏渲染)。
总结:
会触发离屏渲染的根本原因就是对多层进行裁剪,用到了离屏缓冲区。单纯cornerRadius+layer.backgroundColor设置圆角只会对contents圆角,在此之上加了masksToBounds,则会对contents和contents下面背景色层裁剪,所以是多层裁剪。而改为label.backgroundColor,masksToBounds则只会对contents层裁剪圆角,不存在layer的背景色层,所以不会触发离屏渲染。
所以建议用cornerRadius+layer.backgroundColor方式对label设置圆角,或者cornerRadius+label.backgroundColor+ masksToBounds设置圆角。
- 使用贝塞尔曲线绘制
//写一个UIImage类别方法
- (UIImage *)roundedCornerImageWithCornerRaidus:(CGFloat)cornerRaidus{
CGFloat w = self.size.width;
CGFloat h = self.size.height;
CGFloat scale = [UIScreen mainScreen].scale;
if (cornerRaidus < 0) {
cornerRaidus = 0;
}else if (cornerRaidus > MIN(w, h)) {
cornerRaidus = MIN(w, h)/2.0;
}
CGRect imageFrame = CGRectMake(0.f, 0.f, w, h);
UIGraphicsBeginImageContextWithOptions(self.size, NO, scale);
[[UIBezierPath bezierPathWithRoundedRect:imageFrame cornerRadius:cornerRaidus] addClip];
[self drawInRect:imageFrame];
UIImage * image = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
return image;
}
使用的时候可以这样调用,同步或者异步绘制,建议放在后台绘制,这样CPU使用率低很多,帧率提高很多。
UIButton *btn1 = [UIButton buttonWithType:UIButtonTypeCustom];
btn1.frame = CGRectMake(100, 520, 100, 100);
[self.view addSubview:btn1];
// 同步的做法 [btn1 setImage:[[UIImage imageNamed:@"测试.png"] roundedCornerImageWithCornerRaidus:200] forState:UIControlStateNormal];
//异步绘制
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
UIImage *image = [[UIImage imageNamed:@"测试.png"] roundedCornerImageWithCornerRaidus:100];
dispatch_async(dispatch_get_main_queue(), ^{
[btn1 setImage:image forState:UIControlStateNormal];
});
});
- 使用YY_Image的处理方式
这个方法里还可以接收边框设置,需要的时候可以一步到位。也可以自己提取里面的圆角处理方式使用。
- (UIImage *)yy_imageByRoundCornerRadius:(CGFloat)radius
corners:(UIRectCorner)corners
borderWidth:(CGFloat)borderWidth
borderColor:(UIColor *)borderColor
borderLineJoin:(CGLineJoin)borderLineJoin {
if (corners != UIRectCornerAllCorners) {
UIRectCorner tmp = 0;
if (corners & UIRectCornerTopLeft) tmp |= UIRectCornerBottomLeft;
if (corners & UIRectCornerTopRight) tmp |= UIRectCornerBottomRight;
if (corners & UIRectCornerBottomLeft) tmp |= UIRectCornerTopLeft;
if (corners & UIRectCornerBottomRight) tmp |= UIRectCornerTopRight;
corners = tmp;
}
UIGraphicsBeginImageContextWithOptions(self.size, NO, self.scale);
CGContextRef context = UIGraphicsGetCurrentContext();
CGRect rect = CGRectMake(0, 0, self.size.width, self.size.height);
CGContextScaleCTM(context, 1, -1);
CGContextTranslateCTM(context, 0, -rect.size.height);
CGFloat minSize = MIN(self.size.width, self.size.height);
if (borderWidth < minSize / 2) {
UIBezierPath *path = [UIBezierPath bezierPathWithRoundedRect:CGRectInset(rect, borderWidth, borderWidth) byRoundingCorners:corners cornerRadii:CGSizeMake(radius, borderWidth)];
[path closePath];
CGContextSaveGState(context);
[path addClip];
CGContextDrawImage(context, rect, self.CGImage);
CGContextRestoreGState(context);
}
if (borderColor && borderWidth < minSize / 2 && borderWidth > 0) {
CGFloat strokeInset = (floor(borderWidth * self.scale) + 0.5) / self.scale;
CGRect strokeRect = CGRectInset(rect, strokeInset, strokeInset);
CGFloat strokeRadius = radius > self.scale / 2 ? radius - self.scale / 2 : 0;
UIBezierPath *path = [UIBezierPath bezierPathWithRoundedRect:strokeRect byRoundingCorners:corners cornerRadii:CGSizeMake(strokeRadius, borderWidth)];
[path closePath];
path.lineWidth = borderWidth;
path.lineJoinStyle = borderLineJoin;
[borderColor setStroke];
[path stroke];
}
UIImage *image = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
return image;
}
最后,我们来看下常见的触发离屏渲染的情景。
1.使用layer.mask,iOS8以上可以使用maskView属性。mask没办法避免离屏渲染,可以通过使用混合图层模拟mask效果,比如在要添加圆角的视图上再叠加一个部分透明的视图,只对圆角部分进行遮挡,来达到mask的效果。
2.抗锯齿,可以设置 allowsEdgeAntialiasing = NO
(默认就是NO),这就是为什么我们在游戏里如果FPS太低的情况会建议关闭抗锯齿的原因。(但是我自己在测试时开启了抗锯齿没看到离屏渲染的黄色特征,可能已经被苹果优化了。)
3.使用layer.masksToBounds
(view.clipsToBounds
)+layer.cornerRadius > 0
去裁减。
4.设置了组透明度(allowsGroupOpacity
)开启,并且当视图透明度(layer.opacity
)小于1时,有子视图或者背景图的情况会导致离屏渲染。这个属性为YES会导致视图里包含的所有其他子视图的透明度也跟随父视图的透明度,子视图的透明度上限为父视图的透明度,iOS7之后苹果默认帮我们开启了这个属性,可以通过allowsGroupOpacity = NO
关闭,自己根据需要去设置单个图层透明度。
然而在 TableView 这样的视图里设置 cell 或 cell.contentView 的alpha属性小于1并不能检测离屏渲染的黄色特征,性能上也没有明显差别。经过摸索发现:只有设置 tableView 的alpha小于1时才会触发离屏渲染,对性能无明显影响;设置 cell 的alpha属性并不会对整体的透明度产生影响,只有设置 cell.contentView 才有效。(参考文章离屏渲染优化详解:实例示范+性能测试)
5.添加了阴影layer.shadow
。在原来阴影写法上设置添加阴影路径的方法解决离屏渲染layer.shadowPath = [UIBezierPath bezierPathWithRect:view.bounds].CGPath;
。
6.采用了光栅化的layer。
网友评论