离屏渲染我们在iOS面试中也是很高频的面试题,下面我们就去揭开离屏渲染这层神秘的面纱。
我们先看一段代码
- (void)viewDidLoad {
[super viewDidLoad];
// Do any additional setup after loading the view, typically from a nib.
//1.按钮存在背景图片 -- 触发了离屏渲染
UIButton *btn1 = [UIButton buttonWithType:UIButtonTypeCustom];
btn1.frame = CGRectMake(100, 30, 100, 100);
btn1.layer.cornerRadius = 50;
[self.view addSubview:btn1];
[btn1 setImage:[UIImage imageNamed:@"btn.png"] forState:UIControlStateNormal];
btn1.clipsToBounds = YES;
//2.按钮不存在背景图片 -- 未触发离屏渲染
UIButton *btn2 = [UIButton buttonWithType:UIButtonTypeCustom];
btn2.frame = CGRectMake(100, 180, 100, 100);
btn2.layer.cornerRadius = 50;
btn2.backgroundColor = [UIColor blueColor];
[self.view addSubview:btn2];
btn2.clipsToBounds = YES;
//3.UIImageView 设置了图片+背景色; -- 触发了离屏渲染
UIImageView *img1 = [[UIImageView alloc]init];
img1.frame = CGRectMake(100, 320, 100, 100);
img1.backgroundColor = [UIColor blueColor];
[self.view addSubview:img1];
img1.layer.cornerRadius = 50;
img1.layer.masksToBounds = YES;
img1.image = [UIImage imageNamed:@"btn.png"];
//4.UIImageView 只设置了图片,无背景色; -- 未触发离屏渲染
UIImageView *img2 = [[UIImageView alloc]init];
img2.frame = CGRectMake(100, 480, 100, 100);
[self.view addSubview:img2];
img2.layer.cornerRadius = 50;
img2.layer.masksToBounds = YES;
img2.image = [UIImage imageNamed:@"btn.png"];
}
运行结果
通过上面的简单的代码我们可以看到第一个button和第三个UIImageView 都触发了离屏渲染。第一个button和第二个button都设置了cornerRadius、clipsToBounds,第一个button设置了背景图片就触发了离屏渲染,第二个button没有触发;两个imageView也都设置了cornerRadius、masksToBounds但是只有设置了背景颜色的imageView触发了离屏渲染,这到底是是为什么呢?下面我们从离屏渲染的工作原理去解释这个问题。
我们先来看一下正常的渲染流程和离屏渲染流程
从图上看,他们之间的区别就是离屏渲染比正常渲染多了一个离屏缓冲区,这个缓冲区的作用是什么呢?下面来仔细说说
正常渲染流程
APP中的数据经过CPU计算和GPU渲染后,将结果存放在帧缓冲区,利用视频控制器从帧缓冲区中取出,并显示到屏幕上。
- 在GPU的渲染流程中,显示到屏幕上的图像是遵循大画家算法按照由远及近的顺序,依次将结果存储到帧缓冲区
- 视频控制器从帧缓冲区中读取一帧数据,将其显示到屏幕上后,会立即丢弃这帧数据,不会做任何保留,这样做的目的是可以节省空间,且在屏幕上是各自显示各自的,互相不影响。
image.png
离屏渲染流程
当App需要进行额外的渲染和合并时,例如按钮(UIButton)设置圆角和设置背景图片,按钮(UIButton)有更多个图层,这是渲染按钮(UIButton)时是需要对按钮(UIButton)这个控件的所有图层进行都进行圆角和裁剪,然后再把每个处理后的图层进行合并,合并完成后放到帧缓存区,然后视频控制器从帧缓存区取出数据显示到屏幕上。这种情况正常的渲染流程是无法对多个图层进行圆角裁剪设置的,因为它是渲染完一帧数据放到帧缓存区显示完成这帧数据就会丢弃,这就需要在弄出一个缓存区存放这些对图层进行特殊处理的数据,这就是离屏缓存区,把这些对每个图层特殊处理的数据存放到离屏缓存区,将这些图层进行叠加合并处理后再放到帧缓存区,最后显示到屏幕上。
简单的说,离屏缓存区就是一个临时的缓冲区,用来存放在后续操作使用,但目前并不使用的数据。离屏渲染再给我们带来方便的同时,也带来了严重的性能问题。由于离屏渲染中的离屏缓冲区,是额外开辟的一个存储空间,当它将数据转存到Frame Buffer时,也是需要耗费时间的,所以在转存的过程中,仍有掉帧的可能。离屏缓冲区的空间并不是无限大的, 它是又上限的,最大只能是屏幕像素的2.5倍。
那为什么我们明知有性能问题时,还是要使用离屏渲染呢?
- 可以处理一些特殊的效果,这种效果并不能一次就完成,需要使用离屏缓冲区来保存中间状态,不得不使用离屏渲染,这种情况下的离屏渲染是系统自动触发的,例如经常使用的圆角、阴影、高斯模糊、光栅化等
- 可以提升渲染的效率,如果一个效果是多次实现的,可以提前渲染,保存到离屏缓冲区,以达到复用的目的。这种情况是需要开发者手动触发的。
了解了离屏渲染的原理那么还有哪些操作会出发离屏渲染呢?
光栅化
When the value of this property is YES, the layer is rendered as a bitmap in its local coordinate space and then composited to the destination with any other content.
这是苹果的一段解释:当我们开启光栅化时,会将layer渲染成位图保存在缓存中,这样在下次使用时,就可以直接复用,提高效率。
shouldRasterize 使用建议:
- 如果layer不能被复用,则没有必要开启光栅化
- 如果layer不是静态,需要被频繁修改(例如动画过程中),此时开启光栅化反而影响效率
- 离屏渲染缓存内容有时间限制,如果100ms内没有被使用,那么就会丢弃,无法进行复用
- 离屏渲染的缓存空间有限,超过2.5倍屏幕像素大小的话也会失效,无法实现复用
圆角出发离屏渲染
并不是所有的圆角设置都会出发离屏渲染
我们先看一下CALayer的构成,如图所示,它是由backgroundColor、contents、borderWidth&borderColor构成的。跟我们即将解释的圆角触发离屏渲染息息相关。
//未触发离屏渲染
UIButton *btn2 = [UIButton buttonWithType:UIButtonTypeCustom];
btn2.frame = CGRectMake(100, 180, 100, 100);
btn2.layer.cornerRadius = 50;
btn2.backgroundColor = [UIColor blueColor];
[self.view addSubview:btn2];
btn2.clipsToBounds = YES;
上面的代码我们经常写,会感到很纳闷为啥不触发离屏渲染呢?我们看一下苹果的相关文档说明
官方文档告诉我们,设置cornerRadius只会对CALayer中的backgroundColor 和 boder设置圆角,不会设置contents的圆角,如果contents需要设置圆角,需要同时将maskToBounds / clipsToBounds设置为true。那么我们看到上面代码里也设置了btn2.clipsToBounds = YES,为什么仍然没有触发呢,我们通过上面layer 的结构中可以看到layer 是由backgroundColor、contents和borderWidthborderColor 三部分组成,上面的代码里只是设置了backgroundColor,contents是没有内容的,即使btn2.clipsToBounds = YES也不会触发离屏渲染,因为只有backgroundColor一层需要圆角处理,不需要放到离屏缓存区里,直接在帧缓存区就可以处理了。
代码改为如下:
//触发了离屏渲染
UIButton *btn1 = [UIButton buttonWithType:UIButtonTypeCustom];
btn1.frame = CGRectMake(100, 30, 100, 100);
btn1.layer.cornerRadius = 50;
[self.view addSubview:btn1];
[btn1 setImage:[UIImage imageNamed:@"btn.png"] forState:UIControlStateNormal];
btn1.clipsToBounds = YES;
image 是放在contents里的,contents里有内容了并且 btn1.clipsToBounds = YES; 要对contents和backgroundColor 2层做圆角处理,只在帧缓存区无法完成,需要离屏缓存区存储每一层圆角处理的状态,然后渲染合并后存到帧缓存区进行显示。
离屏渲染无疑会对App性能造成影响,我们在开发时尽量少用能避免则避免,下面有几种设置圆角避免离屏渲染的方法
-
让美工切一个圆角图片直接设置成背景图片
image.png -
贝塞尔曲线去画圆角
image.png
image.png - 来自YYImage的处理方案
- (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];
}