圆角卡顿刨根问底

整篇文章其实说来说去,最后其实只是把卡顿的这个事情用最通俗最简单最没技术含量的方案实行了。但那么多方案,为什么选择一个方案,每个方案都有优势,同样也有弊端,不同的case,不同的场景,不可能一个方案万金油适用,这个过程需要我们刨根问底,去了解简单的解决方案背后的为什么?

前言

tableView or collectionView的Cell使用中如果大量出现了view.layer.cornerRadius + ClipToBoundsormasksToBounds的设置,会造成滚动不流畅,滚动起来十分的卡顿。

这一点相信很多iOS developer都不陌生,相关的搜索圆角卡顿圆角性能优化,都能看到很多文章,思路大致如下

  • 这样切圆角会造成GPU的离屏渲染
  • 离屏渲染会消耗太多GPU资源,但是CPU却没有太多的占用
  • GPU消耗太大拖慢了单帧绘制
  • 解决办法,采用CPU预先绘制bitmap,交给GPU直接渲染

最广泛的办法就是预先用CPU,构建圆角路径贝塞尔曲线UIBezierPath,用原来的图片填充进圆角路径,获得天然的自带圆角透明的bitmap数据UIImage,从而直接交给GPU进行普通渲染

有不少的blog,以及大量的demo,都验证了这一点,但看完后不禁有几个疑问,于是产生了今天的刨根问底

  • 什么才是卡顿的根本原因?
  • 离屏渲染是什么?
  • GPU消耗交给CPU,CPU难道就没消耗了么?

每一种解决方案,都是在一定得特定情景下而产生的最优解,针对CPU预绘bitmap的方案,在相当多的使用场景下,是正确的没有任何问题的。

但是这里要讲一个真实的case,如果这个方案都无法解决问题,依然还是卡,那么该怎么办?

到底卡顿根本原因是啥?

无法彻底缓解卡顿,该怎么办?

真实Case

我们的App有一个用UICollectionView制作了书架的功能,可以放置图书,多本图书叠放在一起自动生成文件夹,如图

icon

可以看到图中每一本书都是一个圆角矩形,而一个文件夹,如果图书超过4本则是4+1个圆角矩形,普通的用户使用习惯,可能不会有太多的文件夹(也说不准哟!)但是如果文件夹数量超过2个屏幕,每一个文件夹都是4本以上的图书,那么在2各屏幕内来回滚动,产生了很恐怖的结果。。。

我们的app,在ip6上,同屏幕最多可以有9个Item,每个Item如果都是4个以上图书的文件夹就是5个圆角矩形,换句话说,一个屏幕内最多有45个圆角矩形。在这样的极限情况下,iPhone6上会卡的只有15帧左右!60帧才是满帧啊亲。。。

更何况我们的app,在iPhone 6 plus上是4*4个item,于是一个屏幕内最多有80个圆角矩形。

我们还支持iPad universal

细思极恐

真实Case环境说明

  • 测试中的图书阴影为贴图,不是GPU渲染,排除这部分干扰
  • 测试中的图书均为已下载完成,排除后台下载线程导致的干扰
  • 测试的书架上半部分大概有20本左右的图书,足够在2个屏幕的范围内来回滚动测试纯图书1圆角的帧率
  • 测试的书架下半部分大概有20个4本以上图书的文件夹,足够在2个屏幕的范围内来回滚动测试纯文件夹5圆角的帧率

无优化处理情况

首先在没有任何优化代码的情况下,都是最直接的

1
2
bookProfileImageView.layer.cornerRadius = 3.0f;
bookProfileImageView.clipsToBounds = YES;

性能检测

让我们看一组Instrument里面core Animation的数据结果

图书范围内滚动帧率

icon

我们可以看出,在全是图书的情况下,仅仅9个圆角矩形,并不会影响帧率,至少能保持在55帧左右,滚动流畅度接近100% 并且Cpu的占用率并不高

文件夹范围内滚动帧率

icon

可怕地事情来了,在全是文件夹的情况下,已经达到了单屏45个圆角矩形,帧率已经降到了平均15,这是一种什么感觉,满帧率60,现在只达到了滚动流畅的25%,简直惨不忍睹

大家再看下Cpu占用率,还是不高

看一下Cpu的消耗情况

我们仔细看看Cpu都消耗在哪?

icon

可以看到,Cpu的消耗在曲线图上并没有陡然增高,消耗也都是一些基本的UICollectionView的处理

性能分析

可以看到,在极限纯文件夹区域滚动的时候,就只有那4个字可以形容惨不忍睹

这样的产生原因,其实在一开始就提到了,因为圆角遮罩,在Gpu运算的时候会发生离屏渲染

离屏渲染

将离屏渲染作为关键词去搜索一下你会查到很多的信息,比如iOS离屏渲染的研究

当使用圆角,阴影,遮罩的时候,图层属性的混合体被指定为在未预合成之前不能直接在屏幕中绘制,所以就需要屏幕外渲染被唤起。
屏幕外渲染并不意味着软件绘制,但是它意味着图层必须在被显示之前在一个屏幕外上下文中被渲染(不论Cpu还是Gpu)。
所以当使用离屏渲染的时候会很容易造成性能消耗,因为在OPENGL里离屏渲染会单独在内存中创建一个屏幕外缓冲区并进行渲染,而屏幕外缓冲区跟当前屏幕缓冲区上下文切换是很耗性能的。

离屏渲染可以是广义的理解为,在屏幕外的时候就要进行渲染,无论是Cpu还是Gpu

在我们的当前的Case里,因为过度的使用了Gpu去处理圆角遮罩,因此Gpu发生了大量的离屏渲染,大幅度拖慢了速度,导致帧率如此悲惨

但因为是Gpu那边的资源被过度消耗,Cpu这边显然处于比较清闲的状态,因此,这三张图片里面,Cpu占用率一直不高,并且没有明显的某个异常函数严重消耗Cpu资源

Cpu绘制bitmap优化处理情况

那么,我们就对他进行一定的优化

这段代码是引用来的,因为前一阵子很多人讨论这个话题,已经有了很多优秀的方案,比如 iOS高效添加圆角实战讲解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
-(void)kt_addCorner:(CGFloat)radius
{
if (self.image) {
self.image = [self.image imageAddCornerWithRadius:radius andSize:self.bounds.size];
}
return;
}
- (UIImage*)imageAddCornerWithRadius:(CGFloat)radius andSize:(CGSize)size{
CGRect rect = CGRectMake(0, 0, size.width, size.height);
UIGraphicsBeginImageContextWithOptions(size, NO, [UIScreen mainScreen].scale);
CGContextRef ctx = UIGraphicsGetCurrentContext();
UIBezierPath * path = [UIBezierPath bezierPathWithRoundedRect:rect byRoundingCorners:UIRectCornerAllCorners cornerRadii:CGSizeMake(radius, radius)];
CGContextAddPath(ctx,path.CGPath);
CGContextClip(ctx);
[self drawInRect:rect];
CGContextDrawPath(ctx, kCGPathFillStroke);
UIImage * newImage = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
return newImage;
}

可以看到,思路就是绘制一个圆角的路径,然后填充生成一个天生自带圆角的bitmap

然后注掉了代码中所有的cornerRadius 换上了

1
[bookProfileImageView kt_addCorner]

上问题到离屏渲染可以是广义的理解为,在屏幕外的时候就要进行渲染,无论是Cpu还是Gpu
所以思路就是,既然Gpu目前负荷会比较大,而Cpu则相当空闲,那我们何不让Cpu提前处理一下bitmap数据呢,这也算是另一种Cpu离屏渲染

让我们看看效果

性能检测

让我们看一组Instrument里面core Animation的数据结果

图书范围内滚动帧率

icon

什么鬼!图书范围的性能居然下降了!!
对比前面的截图可以明显看出来,图书范围的滚动帧率从平均55左右下降到平均50,虽然非常的细微,但很明显,在这个优化改动下,图书范围滚动性能反而下降了!

仔细看一下Cpu占用率,通过前面的图进行对比,还是能看出来有增高,或许不明显,我们继续看

文件夹范围内滚动帧率

icon

大家注意看,同样的代码下,文件夹区域的滚动性能却有大幅度提升,从刚才的15帧左右提升到了35帧,提升效果超过了100%

但是我们仔细看,Cpu占用率这次能明显看出提升了很多很多!

看一下Cpu的消耗情况

我们仔细看看Cpu都消耗在哪?

icon

看到了吧,此时此刻,Cpu被大量消耗在了imageAddCornerWithRadius:andSize:这个我们专门为优化而写的函数里,很明显的说明,在Cpu的层面,此处已经在重点消耗Cpu资源了

性能分析

可以看到,我们专门针对Gpu离屏渲染做的专门的优化,似乎?并没有那么有效!是不是?

  • 首先,9圆角矩形的量级下,性能反而下降,虽然下降并不明显
  • 其次,即便是在45圆角矩形的量级下,性能还真提升不少,但是依然停留在35帧作用,直观感觉,还是卡!

到底为什么会这样呢?

简单的来说就一句话

Gpu运算会有消耗,转移给Cpu去运算一定也会产生消耗

  • 在图书区域的对比中可以看到,原本的9个圆角的数量级,在Gpu可以承受的运算范围内,此时此刻并没有很大的Gpu压力,所以还算流畅55帧,
  • 但是我们的优化方向,再减轻Gpu压力,加大Cpu负荷(虽然也没多大),因此性能反而略微下降到50帧
  • 在文件夹区域可以看到,45个圆角的数量级,Gpu已经完全不可承受了,压力其大无比,帧率悲剧到15帧
  • 经过我们的优化,Gpu的压力被大幅度减少,Cpu的压力随之上升,此消彼长,但最终的结果是整体帧率提升到了35帧

为什么会是这样,还是得从离屏渲染下手

离屏渲染

上文提到的离屏渲染 有这样一句话

所以当使用离屏渲染的时候会很容易造成性能消耗,因为在OPENGL里离屏渲染会单独在内存中创建一个屏幕外缓冲区并进行渲染,而屏幕外缓冲区跟当前屏幕缓冲区上下文切换是很耗性能的。

另外一篇文章有这样一句话
A Performance-minded take on iOS design

You’d think the GPU would always be faster than the CPU at this sort of thing, but there are some tricky considerations here. It’s expensive for the GPU to switch contexts from on-screen to off-screen drawing (it must flush its pipelines and barrier), so for simple drawing operations, the setup cost may be greater than the total cost of doing the drawing in CPU via e.g.

我们可以理解为Gpu在处理浮点运算,处理矩阵运算的时候,一定会比Cpu快得,毕竟他天生就是拿来做图形处理的,所以在离屏渲染的数量比较少的时候,我们把运算交给Cpu,反而是略微增加了耗时与卡顿

就像引文里说的,离屏渲染真正的消耗,在于不同缓冲区的来回切换,一旦圆角的数量增多,计算量加大,这种切换会更加频繁,所以当数量庞大的时候,Gpu最终所有的操作就会更加耗时

我们面临的特殊问题

要说明的是,我们这次的case和网上的其他例子比是有不同的

  • 网上的一些demo都是针对UILabel UIBotton一些相对简单的UI,来进行的Cpu圆角绘制
  • 我们的情况是,我们是针对一张张图书封面bookcover,一个个真实的丰富多彩的png图,来进行Cpu圆角绘制,这样更加的耗时
  • 最终的结果就是,即便我们采用了Cpu离屏渲染,但是帧率依然只有35帧左右,还卡!怎么办!

其他的优化处理办法

我们的核心目的是,消除卡顿,感受感受丝般顺滑,但是现有的一些手段,虽然有效果,但还远远达不到目标怎么办?

有人说了,不要切圆角了,直接让UE出一张圆角切图,把所有的运算都省了

那我们来试试

去掉所有圆角代码

1
2
//bookProfileImageView.layer.cornerRadius = 3.0f;
//bookProfileImageView.clipsToBounds = YES;

换上这样的一张图,中间透明四个角有背景色

icon

让我们测试下帧率

图书范围内滚动帧率

icon

文件夹范围内滚动帧率

icon

无论是图书,还是文件夹都已经达到了55帧左右的帧率,接近满帧

丝般顺滑

刨根问底深入思考

这样就满足了么?显然是不可以的,因为如果一旦圆角item背后有背景图,有纹理,那这种贴图的方式根本不能实现了。难道就这么让app卡着凑合用么

显然不可以

深入思考卡顿的原理

解决问题应该从源头入手,所以我们相应地要思考,卡顿是怎么来的?

这块就要从ibireme大神的 iOS 保持界面流畅的技巧 这篇博客来深入学习

图为原博客中的图

icon

iOS设备都是采用双缓冲区+垂直同步开启的方式来进行图形渲染,什么意思呢?

  • Cpu运算处理结束后将要渲染的任务提交给Gpu
  • Gpu运算渲染完成后讲最终图形放入缓冲区
  • Gpu触发离屏渲染,会有多缓冲区来回切换管理等复杂耗时操作
  • 视频控制器在固定的频率内,从缓冲区取渲染结果,展现到显示器上

icon

这幅图更加直观

  • 每一个VSync的点,都是垂直同步作用下,控制器去取渲染结果,准备展现的时间点
  • 当Vsync的点到来时,Cpu蓝色+Gpu红色都运算结束,那么就没有发生掉帧,没有发生卡顿,很顺畅的绘制了下去
  • 当Vsync的点到来时,运算没有结束,那么说明这一帧还没有渲染完毕,因此无法顺畅绘制,产生了掉帧,也就是卡顿
  • 红色的Gpu持续时间过长,会导致Vsync点到来时运算没有结束导致卡顿,这是我们Gpu离屏渲染15帧的情况
  • 蓝色的Cpu持续时间长,也会导致Vsync点到来时运算没有结束导致卡顿,这就是我们Cpu离屏渲染35帧的情况

当图形的总运算量在那里摆着,就是很大,就是很卡怎么办呢?

AsyncDisplay

异步绘制

简单地说,就是已经采用了Cpu离屏渲染,还是会因为主线程计算耗时很长而卡顿UI,那我们就把Cpu计算bitmap这个过程放到线程里去。

运算量大怎么办?

  • 优化运算,合并图层,在需求范围内替换贴图
  • 开个后台线程慢慢算,算好了再回到主线程绘制

但因为我们面对的是频繁复用的UICollectionView或者UITableView,所以要有很完善的线程管理机制,再辅助以cache机制

采用图片的方法已经解决了当下app的卡顿问题,但是后续对AsyncDisplay的支持,等有空了再整理一篇吧。。

其实 facebook开源的 AsyncDisplayKit 就是实现了这些,功能很强大,我还没用熟,感觉有点重