本文将从原理出发,解释卡顿发生的原理,然后会讲解项目中行之有效的几个优化点,最后会展望一下接下来将要尝试的方向。下面进入正题。
屏幕显示的原理
屏幕显示原理
我们知道,远古时代的CRT显示器的显示原理是用电子枪扫描荧光屏来发光。如上图所示,电子枪按照从左到右,然后从上到下的顺序扫描。当电子枪换到新的一行准备进行扫描时,也就是上图A4、B4、C4、D4的位置,显示器会发出一个水平同步信号;而当一帧画面绘制完成后,电子枪回复到原位准备画下一帧前,也就是上图D4的位置,显示器会发出一个垂直同步信号。垂直同步信号的作用一方面是通知显示器回到A1位置,另外一方面,也通知显卡,准备输出下一帧画面。现在已经是液晶显示器的时代了,不再使用电子枪扫描了,但是原理还是类似的,水平同步信号和垂直同步信号还是一样被使用的。
计算机工作原理
计算机系统的工作原理如上图所示:首先是CPU的工作,包括创建视图分配内存、计算布局、图片解码以及文本绘制等;接下来轮到GPU工作了,GPU负责视图变换、合成和渲染等;GPU渲染完提交到帧缓冲区中,等收到垂直同步信号后将帧缓冲区的内容显示到屏幕上。
屏幕撕裂(Screen tearing)
上述的简单的屏幕显示原理其实会产生这样一个问题:假设我们的显卡速度很快,每秒生产的帧数肯定要超过显示器刷新率。那么在实际数据处理过程中,缓冲区的数据,在被输出之前,就被显卡不断的刷新重写。但是缓冲区并不是“先清空再写入数据”,这太没有效率,而是采用“新数据覆盖老数据”的方式。
假设这样一种情况,缓冲区已经有一副完整的帧画面(A帧),然后显卡生成了下一帧画面(B帧),新一帧的数据开始写入缓冲区,写到一半的时候,垂直同步信号来 了,于是缓冲区的数据被输出到显示器。但问题是,这时缓冲区的数据,是由一半A帧和一半B帧数据合成的。因此最终显示器上显示出来的画面就不是一副完整的 画面,这就是“画面撕裂”现象出现的原因(如下图)。
屏幕撕裂
那怎么才能解决画面撕裂呢?简单来说只要让帧缓冲区里的数据始终保持一副完整的画面就可以了。从技术角度出发,其实就是利用刚刚提到的垂直同步信号。
具体说起来就是,当显卡生成了一副完整画面并写入了帧缓冲区之后,暂停!然后开始等待垂直同步信号,当得到垂直同步信号后,再继续渲染下一帧写入缓冲区。这样就可以保证在缓冲区的数据始终是一副完整的画面,不会出现前后帧混合的问题。
卡顿产生原因
但是呢,垂直同步带来了一个新的问题—掉帧。所谓的掉帧,跟垂直同步有一定关系,因为垂直同步机制决定了如果在一个时钟周期内CPU或者GPU没有完成各自的任务的话,就会将帧缓冲区里的内容直接丢弃!掉帧并不能完全怪罪于垂直同步机制,更重要的原因是我们作为开发者没有进行足够的优化,将过重的任务派发到了CPU或者GPU上,下图(from:iOS 保持界面流畅的技巧)是掉帧的图示,表明CPU或者GPU任意一个没能在时钟周期内完成自己的任务的话都会导致卡顿掉帧。
卡顿图示
行之有效的优化点
提前布局
提前布局可以说是最重要的优化点了。其实在从服务端拿到 JSON 数据的时候,关于视图的布局就已经确定了,包括每个控件的frame、cell的高度以及文本排版结果等等,在这个时候完全可以在后台线程计算并封装为对应的布局对象XXXTableViewCellLayout,每个cellLayout的内存占用并不是很多,所以直接全部缓存到内存中。当列表滚动到某个cell的时候,直接拿到对应的cellLayout配置这个cell的对应属性即可。当然,该有的计算是免不了的,只是提前算好并缓存,免去了在滚动的时候计算和重复的计算。通过这一个优化,将本来的fps50的列表优化到了55、56左右,可以说从肉眼上已经看不出有卡顿掉帧了。
cellLayout示例图
上图是项目中某个cellLayout的部分代码,可以看到里面存的就是所有控件的frame和文本的排版结果而已,里面没有任何的黑科技,只是将本来在滚动中才做的事情提前了而已。
按页加载缓存
现状分析:90%的APP有tableview,90%的tableview里有上拉刷新和下拉加载。以我司的项目ZAKER中的热点新闻界面为例,简单流程大概是这样的:①应用启动的时候会将磁盘中所有的新闻一次性读取出来显示到屏幕上; ②在每次下拉刷新和上拉加载的时候会将内存中所有新闻缓存到磁盘中,也即全量读写。这意味着大部分的新闻数据会反复写入到磁盘中,这样的写入是冗余的,因为前面的这些新闻数据并没有发生改变。
改进方案:所以优化的方法就是将这些列表数组进行分割,分割成一页一页,每次写入的数据量很小,而且避免了冗余写入的问题。现在的流程变为:①启动时只读取第一批新闻显示在屏幕中;②下拉刷新和上拉加载的时候只把当前服务器返回的一批新闻写入缓存中;③在上拉加载的时候会先查看磁盘中是否有未读的缓存,若有则读取缓存,否则才从服务器下载一批新的文章。
直观图示:
按页缓存
可以看到,优化之前整个新闻列表以及其他配置都在一个文件里,刷新10几次之后文件大小达到2MB,并且随着不断刷新而越来越大;优化之后,其他的配置还是在刚刚的文件中,但是不断增长的新闻数组被分割成一页一页的文件,每一页里面有10多条的新闻数据,同时有一个configure文件保存这些页的信息以及页的顺序。根据测试人员的反馈,进行按页加载缓存优化能减少5%~8%的CPU占用,使用的内存也有一定的下降。还是有很明显的优化效果的。
后台线程处理图片
圆形头像、图片裁圆角等处理可以说是非常常见的需求了,包括从iOS11的系统各处都能看到,整体的页面控件都变得更加圆润了。但是,对图片处理必然是消耗资源的,实现过图片圆角效果的应该都知道,最简单的就是 layer.cornerRadius+layer.masksToBounds 的方式,但是这种做法在tableview中往往会使滚动变得卡顿,因为这种实现方式会触发离屏渲染,屏幕外缓冲区跟当前屏幕缓冲区上下文切换是很耗性能的,所以离屏渲染往往会造成卡顿(参考:iOS 离屏渲染的研究)。
那要怎么处理图片呢?可以使用Core Graphics,Core Graphic通常是线程安全的,所以可以进行异步绘制,显示的时候再放回主线程。我们在项目中实现了一个后台处理图片的框架,核心代码如下:
NSBlockOperation *transformOperation = [[NSBlockOperation alloc] init]; [transformOperation addExecutionBlock:^{ // 此处处理图片 ... dispatch_async(dispatch_get_main_queue(), ^{ // 主线程设置图片 [self setImage:transformedImage forState:UIControlStateNormal]; } }); }];
更加高效的控件
还可以直接从开源库中选用更加高效的控件替换项目中性能没那么好的控件。项目中将之前的TTTAttributedLabel、M80AttributedLabel全部替换为YYLabel,开启YYLabel的displaysAsynchronously、ignoreCommonProperties属性可以异步绘制文本以及忽略不需要的属性。更加追求性能的话,可以结合第1点的提前布局机制,在提前布局的阶段生成好YYLabel渲染时用到的textLayout,显示的时候直接赋值textLayout就可以了。
其他
还有一些比较微小的优化,对性能可以说没有多大的影响,但是可以在开发阶段稍加留意,养成良好的习惯。
尽量减少视图层级,合并多余的视图。同样以 ZAKER 为例,用户显示时的蓝V标签、达人标签以及楼主图片等几个视图,之前是用不同的view来展示的,优化过程将这几个view合并为一个view,一个view管理这些相似的事物,也可以减少某些相同逻辑的代码。
减少频繁的addSubview、removeSubview,remove之后视图的实例对象会被释放,再add的时候会再次调用初始化函数。替代方案的话,可以用hidden属性隐藏不显示的视图。
异步绘制
从一开始接触iOS的我们就反复被告知,UIKit的东西是绝对不能在后台线程调用的,一定得在主线程调用,所以主线程也被叫做UI线程。在后台线程调用UIKit的东西有一定几率导致崩溃,或者出现视图不显示、显示错乱等等问题。但是呢,根据刚刚所说的,Core Graphics的那一套东西是线程安全的,所以可以通过Core Graphics在后台将视图渲染到一张图片上,显示的时候在主线程将这张图片设置到相应位置上。Facebook著名的AsyncDisplayKit的核心实现应该也是基于这个原理,接下来的优化可以尝试这个方案。
Metal
根据Apple官方说法,Metal框架被设计用来实现两个目标: 3D 图形渲染和并行计算。这两者有很多共同点。它们都在数量庞大的数据上并行运行特殊的代码,并可以在GPU上执行。目前正在研究学习阶段,看项目中是否能利用Metal进行一定的优化。
APM?
Application Performance Management(APM):应用程序性能管理, 通过对应用的可靠性、稳定性等方面的监控,进而达到可以快速修复问题、提高用户体验的目的。目前比较有代表性的 APM 产品有:听云、阿里百川、腾讯 bugly等,现在也在考虑自己研发一套APM系统,先从比较简单的指标入手,先对卡顿和崩溃这两个指标着手,做的顺利的话再逐步扩展别的指标的检测管理。