2021-12-06一次tableView的完整优化记录
本文提纲:
1、什么是离屏渲染?
2、检测app的性能工具,可不可以查看到竞品的各项性能;
3、优化过程。
什么是离屏渲染?
要想深刻的了解离屏渲染,我们要从屏幕显示图片的原理说起。
首先从过去的CRT(Cathode Ray Tube)显示原理说起。
CRT电子枪按照上面的方式,从上到下一行一行的扫描,扫描完成后,显示器就呈现出一帧的画面,这一帧完成之后,电子枪会回到初始位置准备下一次的扫描。
为了把显示器的显示过程和系统的视频控制器进行同步,显示器(或者其他硬件)会用硬件时钟产生一系列的定时信号。当电子枪换到新的一行准备扫描时,显示器会发出一个水平同步信号(horizonal synchronization),简称HSync;而当一帧绘制完毕后,电子枪复原到初始位置,准备画下一帧前,显示器会发出一个垂直同步信号(vertical synchroization),简称VSync。显示器通常以固定的频率刷新,这个刷新率就是VSync信号产生的频率。液晶显示屏的原理也是这样。
ios_screen_display.png通常来说,计算机系统中的CPU、GPU、显示器是以上面这种方式协同工作的。CPU会计算好显示的内容,然后提交给GPU,GPU渲染完成后将结果放到帧缓冲区(FrameBuffer),随后视频控制器会按照VSync信号逐行读取FrameBuffer中的数据,经过一系列的数模转换传递给显示器显示。
那么在VSync信号来临之前,如果上一个VSnyc信号的把渲染完毕的图片还没有显示完,或者说在上一个VSnyc信号结束之前,帧缓冲区里没有新的内容CPU或者GPU还在处理图像的内容都没有内容提交,那么就没有办法读取到下一帧,我们看到的画面就会停留在上一帧渲染完成的屏幕,也就是造成了卡顿
。
最简单的情况,帧缓冲区只有一个的时候,那么读取和刷新会有很大的效率问题。现在iOS的设备大多都是双缓冲区,安卓是三缓冲区。
离屏渲染
在iOS中,图像显示的方式依赖于OpenGL,在OpenGL中,GPU有两种渲染方式:
On-Screen Rendering
、Off-Screen Rendering
;也就是当前屏幕渲染,和离屏渲染。
- On-Screen Rendering:当前屏幕渲染,在当前用于显示的屏幕缓冲区进行渲染操作。
- Off-Screen Rendering:离屏渲染,在当前屏幕缓冲区以外新开辟一个缓冲区进行渲染操作。
那我们常说要避免离屏渲染,是因为离屏渲染会造成大量的系统开销,比较耗性能:
1、离屏渲染需要创建新的缓冲区;
2、离屏渲染的整个过程要多次切换上下文环境,先是从当前屏幕切换到离屏,离屏结束后,将离屏的缓冲区渲染到屏幕上,又需要从离屏切换到当前屏幕;
既然离屏渲染造成如此大的系统开销,那它有什么作用嘛?
- 一些特殊效果需求运用额外的 Offscreen Buffer 来保存的中心情况,所以不得不运用离屏渲染去处理复杂的图像。
- 处于效率的意图,在offscreen buffer中的数据也可以被复用。
触发离屏渲染的逻辑
图层的叠加遵循“画家算法”,这种算法会按图层绘制,先去绘制较远的场景,然后绘制较近的部分遮盖较远的部分。在一般的layer绘制中,上层的sublayer会覆盖基层的sublayer,基层绘制完毕的layer就会清除掉。全部的layer绘制完毕后,整个绘制过程就结束了。如果我们不设置剪裁和圆角,那么整个绘制过程如下:
image.png
但是当我们设置了cornerRadius以及 masksToBounds 进行圆角 + 裁剪时,masksToBounds会应用到全部的sublayer上。这也就是说,全部的sublayer都会被剪裁,为了节省效率,全部的sublayer在绘制完毕以后,要进行一次性的剪裁。所以也就是说明,在sublayer在第一次被绘制完毕以后,sublayer不能直接被清除掉,而是会被保存在Offscreen buffer 中等待下一轮的圆角+剪裁,这也就导致了离屏渲染
。过程如下:
总结:在一次画家算法完毕之后,如果还需要额外的裁剪处理就会产生离屏渲染
如果是只有单图层,那么在绘制的过程中会进行直接裁剪,此时不会发生离屏渲染,因为不需要额外的空间去保存图层了只有这一层。
检测app的性能工具
- 卡顿的检测
关于界面是否卡顿我们通常用到的参数是FPS
,正常情况下是每秒刷新60帧,也就是说在16.67ms内处理完一次绘制任务,当帧率小于60时,说明界面开始卡顿,当低于45的时候卡顿会比较明显。
我们可以使用xcode自带的工具instrument
的Animation Hitches
来检测卡顿。
instrument:Instruments是一个强大而灵活的性能分析和测试工具,它是Xcode工具集的一部分。它旨在帮助您分析iOS、watchOS、tvOS和macOS应用程序、流程和设备,以便更好地了解和优化它们的行为和性能。
我在iPhone8(iOS 13.1.2)上进行的测试,结果如上,发现了大量的卡顿,因为除了大部分的16.67ms是正常的,还有一部分的33.34m和50.0ms甚至还有大于1s的地方,后面会说下优化。
-
离屏渲染的检测
首先我在模拟器上进行debug,开启了Color-offScreen Rendered,所有产生离屏渲染的部分会被标记为黄色:
image.png
然后开启了Color Blended Layers,这个选项基于渲染程度对屏幕中的混合区域进行绿到红的高亮,性能越差的区域就越红(也就是多个半透明图层的叠加)。由于重绘的原因,混合对 GPU 性能会有影响,同时也是滑动或者动画帧率下降的罪魁祸首之一。
image.png检测竞品的性能数据
了解了自己的app之后,然后就想看看能不能看看竞品的app的性能数据,于是就上网搜索各种方法。
功夫不负有心人!让我找到了啊,找到了一款工具PerfDog可以测试手机上安装的任意一款的性能,虽然我不太明白这个原理到底是什么,等我了解了再进行整理和记录。
我们是做小说阅读的,所以测了下7猫的性能,然后测了下我们自己的又,确实差了不少😓,革命尚未完成,少年仍需努力!
七猫.png 对比图.png虽然说有一些参数还比较陌生看不太懂,但是这个平台上的文档都有解释,后续查阅一下着重解释一个,既然发现了差距,接下来就是要思考怎么缩小,自己哪里做的出问题了,开始优化!
优化过程
我们通过👆🏻上面的测试可以了解到我们现在存在的问题:
1、图片部分的离屏渲染严重,圆角的优化很有必要。
2、部分透明的layer图层混合增加了绘制的难度,需要设置背景颜色。
3、通过instrument可以了解到一部分耗时的操作是在:在滑动时部分的autolayout自动布局的
计算上,我们用的框架是Masonry,可以考虑用frame替代,或者滑动时不再自动布局。
因为我们的cell的行高是在拿到数据之后在一个管理cell的model中已经计算好了,所以显示的时候没有再去计算了,相当于行高已经缓存好了,直接读就可以,这个地方不用再优化。
我们先从以上这三个方向进行优化,然后再进行测试看看效果,比对下性能。
- 圆角的优化
原来使用的切圆角的框架是XKCornerRadius,它是通过创建CAShapeLayer添加到layer上,从目前的离屏渲染检测上来看,这种圆角会引发离屏,所以打算换一种方式来进行。于是就上github上搜一下看看有没有好用的库。这部分我的思路是,先去找有没有已经实现的好用的第三个的框架,如果没有,或者框架过于庞大可以自己去通过UIBezierPath的方式,或者依赖于UI去切一个带圆角的蒙层方式自己处理,通过性能比对下两个方式那个更好。
于是找到了一个ZYCornerRadius,无离屏渲染,然后内容只有一个UIImageView的分类,非常奈斯~哈哈哈哈,是通过UIBezierPath和UIGraphicsGetImageFromCurrentImageContext重绘image的方式处理的,使用完以后效果如下:
书封的部分应用了这个圆角的处理方法,确实没有了离屏渲染。
-
优化部分透明的layer图层混合
给红色的地方设置一个背景颜色,设置前效果如下:
image.png
然后我就设置了下图片的背景色,发现并不管用,所以他这里造成的图层混合不是因为UIImageView没有背景颜色,具体原因我继续上网搜索资料。然后发现sd_webImage会有影响,于是我屏蔽掉这句代码:
-(void)setMenuModel:(YueYouBookStoreTabMenu *)menuModel{
_menuModel = menuModel;
// [_iconImageView sd_setImageWithURL:[NSURL URLWithString:menuModel.imageUrl]];
_iconLabel.text = menuModel.name;
}
然后效果图:
image.png
所以是下载完的图片出现了问题,因为我原来这个里面有占位图,这个时候它不产生混合区域。于是我们着重在下载完的图片之后看看能不能做什么处理。通过查阅资料我们了解到之所以会产生红色区域是因为:
-
由于网络图片不会有@2x和@3x之分,通过SDWebImage库下载的图片不加以处理就直接显示,会有一些常见的问题,如像素不对齐。
-
App中经常使用圆角图片,一般采用裁剪图片的方式;但是这些图片源来自服务器(本地圆角图片让UI直接提供就可以了),我们需要在SDWebImage基础上增加对网络圆角图片的处理。
那么像素不对齐会引发什么问题呢?
-
像素不对齐是指物理像素(pixel)不对齐;出现像素不对齐,会导致GPU在渲染时,对没对齐的边缘,进行插值计算,造成性能损耗了。
-
当图片的size和显示图片View的size不同 或 图片的scale和屏幕的scale不同,就会发生像素不对齐的问题。要想像素对齐,必须保证image.size和显示图片view.size相等 且 image.scale和 [UIScreen mainScreen].scale相等。
-
当UIView(及其子类)的frame像素不对齐显示洋红色;当图片的像素大小与控件的大小不一致,显示黄色。
所以其实我们要解决的是,当图片下载完毕后我们要调整一下图片的大小,来避免这种不对齐的情况。在下载图片完成的回调里对图片的大小进行下处理。
我们先把label都设置上背景色,建了一个label的基类,统一处理一下背景颜色。然后最后再去统一处理图片,设置完背景色效果图如下:
image.png
那么最后剩下的红色区域的都是图片的问题了,我们来处理这个图片的问题。我通过image的缩放处理发现,并没有解决这个问题,我在SDWebImage图片的回调中处理如下:
- (void)yy_setImageWithImageUrlStr:(NSString *)urlStr yyCompleted:(nonnull yy_completed)yycompleted{
[self sd_setImageWithURL:[NSURL URLWithString:urlStr] placeholderImage:[UIImage imageNamed:@"coverPlaceholder"] completed:^(UIImage * _Nullable image, NSError * _Nullable error, SDImageCacheType cacheType, NSURL * _Nullable imageURL) {
[image cornerImageWithSize:self.bounds.size fillColor:[UIColor whiteColor] cornerRadius:2.0f completion:^(UIImage * _Nonnull resultImage) {
yycompleted(image,imageURL);
}];
}];
}
cornerImageWithSize
是处理图片的缩放的问题的,但是在Color Blended Layers开启后,并没有明显的优化效果(看来SDWebImage已经优化了这个部分的问题,搜到的资料说有这个问题的都比较早期):
于是又查找问题,我们的这个书封的图片构成是由三个部分组成的:
- 主图部分:显示图片的imageView
- 阴影部分:书封底部的阴影图片
- 还有一个1像素通过drawRect绘制的内边框部分
现在可以排除问题不在imageView的显示上,然后来看阴影和内边框部分,我通过代码注释掉内边框,或者直接给内边框的view一个背景颜色,他原来是透明的view,大概猜想问题就出现在这个透明。于是我就设置了个背景白色:
image.png
然后书封部分的红色就消失了:
image.png
或者我直接不添加这层border,如下:
image.png
书封下面那个小的红色的是阴影的部分,应该也是图片的透明导致的,这个部分到时候管UI同学要一个不带透明度的图就好了,于是接下来最后处理的问题就是这个border的绘制,还是用UIBezierPath绘制一下,不使用view的drawRect实现了。
image.png
OK这个问题解决,我在上边画了一个宽5个像素的大黑边,为了效果明显一点,可以看到这个部分不再显示红色了,剩下的都是本地图片的处理了✌🏻!
image.png
- 优化cell在滑动时autoLayout的自动布局
这个部分就根据instrument的调试,去一点一点修改了部分UI的数值的设置问题,从xcode自带的debug view hierarchy
先来查看一部分UI有问题的部分:
image.png
上面图片标记红色的部分是UI的设置有问题的部分,根据debug view hierarchy
的提示可以先去优化这个部分的ambiguous的问题。其实xcode会帮你标记出来是哪个view有问题,要仔细去看,比如下图,我标记出的UI控件是已经加了蓝色描边的,但是要仔细一点,不然就可能找不到,xcode帮忙标记出来就非常方便了:
然后我去查看这个view的实现代码,果然有问题,不小心写成了这个鬼样子!(Σ(⊙▽⊙"a):
[_rankImageView mas_makeConstraints:^(MASConstraintMaker *make) {
make.left.mas_equalTo(self.bookShadowImageView.mas_right).offset(kMatchW(12));
make.top.mas_equalTo(self.bookShadowImageView).offset(kMatchW(5));
make.width.mas_equalTo(kMatchW(14));
make.width.mas_equalTo(kMatchW(16));
}];
后面就一点一点跟着提示去处理就可以了。然后继续处理cell赋值时检查下有没有更新layout的部分,这个部分也比较耗时,把滑动时更新的layout处理掉后,会有明显的优化效果,但是还是不符合预期,还需要进一步的优化。
-
层级结构自查
我们的主界面是通过tableView嵌套collectionView进行实现的,我们参考了其余APP的层级实现,觉着这个部分也可以接着优化,上网搜了一些资料,这里有两个思路: -
一层collectionView去实现,外层不嵌套tableView(参考番茄小说)
-
tableView+cell去实现 内部不嵌套collectionView了 (参考qq阅读)
我用自己的手机IPhone11(iOS 15.0)分别下载看了下效果,发现番茄的还是有一点卡顿(肉眼可见的那种🐶),qq的比较流畅,因为原来的部分也是基于外层是table实现的,所以table在外层其实比较好改也。
虽然我们的书城的结构和番茄小说的很像, 包括榜单的部分结构都相似,那个部分需要collectionViewcell内部再加collectionView还有一个scrollView,感觉这样嵌套导致了卡顿,尽管我的离屏渲染已经做过优化工作了,我猜测可能是因为嵌套代理的回调导致耗时,由于工时的原因,后面搞清楚了再来记录。
而且用collectionView还有一个问题就是不同section的layout布局管理,这个地方需要一定的设计,不然数据源和布局就会很凌乱,不好维护,collectionView要比tableView复杂,我们目前的需求cell的样式从产品上定义大概有十几种左右,定义的枚举就十九种,所以用collecionView直接去根据section去布局代码会很凌乱。
cell模块枚举类型定义示例.png
出于上述,工时,以及效果,还有修改难度等因素来考虑,打算用tableView+cell不再嵌套多层的方案来继续修改一下。
-
自查出来的复用问题
image.png
因为有一些不同的section的样式用的是一个cell只是根据type去区分下细微的UI,所以在复用的ID的时候拼接了section,然而从服用队列中取的时候并没有拼接section!导致部分的cell一直在创建!
这个问题是在代码自查,在局部cell的创建打了个断点,发现滑动的过程中创建代码一直执行,然后找到了问题的根源。
通过以上这些优化,终于卡顿消失了,性能也提升了不少,最后放上结果对比效果,还是很明显的!
结果比对
-
通过性能测试工具PrefDog可以看到性能明显的提升
结果比对.png -
instruments测试可以看到大部分都是16.67ms了
image.png -
录制的实际界面感受
附地址:https://v.youku.com/v_show/id_XNTgyNTc0MDA4OA==.html
总结
非常开心能有这个机会来做一次这样的优化,通过文字记录,一是希望后来者可以通过这片文章再遇到相似的问题,有更多的解决思路和灵感。二是让自己印象更加深刻,并且通过这样的一次优化,间接的了解和学习到很多新的知识,记录一次让自己印象更加深刻,也方便自己以后有类似的问题再去查阅相关资料。
最后附了对于解决这次问题比较有帮助的一些参考资料(感谢这些大佬)。