保持页面滑动流畅性的一次工程实践


最近在做自己的独立App的时候又出现到了UItableView的滑动流畅性问题,于是这一次想彻底的解决这一问题,并总结解决方法,待以后工作开发中可以快速复用,提高效率。
界面滑动流畅一般是指系统会在一秒钟之内刷新60次界面,也就是60FPS(Frames Per Second),即每16.7ms就需要刷新一次,如果每次刷新时间超过16.7ms,就会出现界面掉帧和卡顿,就会让用户感觉到滑动不流畅,会带来非常不好的体验。追求性能、提升用户体验是我们开发者永远期待并执行的一件事。

先来看看现状吧,剖析完现状才能对症下药。

项目现状


可以看到FPS有出现了20、30多的情况,这已经到了无法接受的的地步了。所以开始优化实践吧。期望值能稳定达到55~60,也就是使滑动达到或接近满帧。

了解原理

在做性能优化之前,去了解原理是必不可少的一个过程,熟悉并理解原理之后,我们才能做出更加成熟、稳定的优化方案。

图像显示原理

还记得我们物理学中的电子枪、电子在磁场中的运动等知识,也就是这些基础的物理知识成就了现在的高科技电子产品。
显示器中有两个同步信号用来控制电子枪。水平同步信号(HSync)和垂直同步信号(VSync)。其中水平同步信号会控制电子枪进行扫描换行,也就是同步好一帧画面的绘制工作,垂直同步信号则是用来控制帧与帧之间的同步,避免出现上一帧还没有绘制完就开始了下一帧的绘制工作。
通常来讲,一帧画面的绘制,需要CPU和GPU来共同完成,其中CPU将会计算好需要显示的内容提交给GPU,而GPU会进行渲染工作,渲染完成后会提交到帧缓冲区中,随后视频控制器会根据VSync信号读取帧缓冲区中的数据,经过数模转换传递给显示器进行显示。

UI卡顿原因

开篇的时候也提到了一点点原因,不过在这里较详细的提及一下。
在绘制一帧画面的时候的时间轴节点是以VSync信号来进行的。当Vsync信号到来之时,系统会发出通知,告诉App主线程可以开始进行工作了,这时候CPU开始进行计算需要显示的内容,比如视图的创建、计算布局、图片解码、文本绘制等任务,随后将计算好的结果通过总线提交给GPU,有GPU进行变换、合成、渲染,随后GPU将渲染的结果提交到帧缓冲区,等待视频控制器读取显示。由于垂直同步的机制,如何CPU和GPU在一个VSync信号的时间内没有完成上述工作并没有提交到帧缓冲区。那么这一帧就会被丢弃,等待下一个VSync信号到来在读取,那么屏幕上面的内容将会保持不变,这就是卡顿的原因。

寻找切入点

从原理背后我们可以发现,我可以从CPU和GPU做的工作进行入手进行优化,那么先来看看CPU和GPU具体做了哪些工作。

CPU干了些什么

对象的创建与初始化调整

在面向对象的编程世界里,视图显示的时候缺了对象怎么能行。CPU第一步工作就是需要创建好这次视图显示所需要的对象了。而创建对象需要分配内存、调整属性、有些还会涉及到一些文件读取初始化操作,这里就会消耗CPU资源。在创建好对象之后的初始化调整时也是非常消耗CPU资源的,我们熟知的CALayer:它内部并没有属性,当调用属性方法的时候,它内部通过OC强大的运行时机制,调用resolveInstanceMethod为对象临时添加一个方法,并能把把属性保存起来。

视图布局(AutoLayout)

我们知道AutoLayout在iOS12之前如果在进行自动布局的时候没有把握好,消耗的CPU资源与时间会层指数级增长(这点笔者有幸看过一篇博文,iOS中的Layout是利用的Cassowary算法进行线性规划,算法在进行计算完后会以一组线性方程组呈现出来,求解这组线性方程组就依赖其复杂度,在iOS12之前的某些情况下没有利用好改算法,使得线性方程组的复杂度非常高,就出现了指数级增长[笔者看完博文后的思考,如有错误欢迎留言指出~])。视图布局不管是利用AutoLayout进行布局还是原始的frame布局,最终转化出来都是以UIView的frame、bounds等属性的调整上。

文本的计算与内容渲染

大多数情况下我们需要对某一个Cell里面的Text内容进行高度、宽度计算,而与计算有关的事情肯定是耗CPU的,所以这一点也会影响到我们的滑动流畅性。再来看看文本内容的渲染,熟知的屏幕上能看到的所有内容都是由一个个位图显示合成而来,我们的文本控件在底层都是通过CoreText排版、绘制成bitmap显示。可想而知排版和绘制都会使CPU的消耗。

图片解码与图像绘制

当我们调用UIImage(named:”xxxx”)的时候,图片的数据并不会立刻解码,这里可以参考我写的SDWebImage解读心得。在图像显示的时候可以说万物皆图像了,这里指的就是UIView里面的drawRect方法了。这个绘制方法如果是在主线程做的话也是会大量消耗CPU资源

GPU干了些什么

在我们学习计算机组成原理的时候老师可能会讲到GPU最擅长的事情就是与图像有关了,在计算机视觉的世界中GPU当然也是非常重要的。它做的事情相对于CPU来讲,比较专注于一件事:将CPU提交过来的结果,进行渲染、合成最终提交给屏幕帧缓冲区。

GPU在提交位图到帧缓冲区之前将会做:纹理渲染、视图合成、图形生成等工作。先来看看纹理渲染,纹理渲染是指所有的Bitmap都要由内存提交到显存,在这一提交过程中都是有GPU负责,避免不了消耗GPU资源。然我们在一个Cell里面加载大量图片的时候,CPU占有率很低,而GPU非常高,也会出现卡顿掉帧的情况。
视图的合成,当我们把多个View叠加到一起的时候回发生什么呢?当然是消耗GPU资源了,在多个View混合合成的时候需要尤其是当顶层View的alpha值不为1的时候,这个时候就会进行像素叠加计算,如果视图混合过多,那么像素的叠加计算时间就会边长,从而影响性能。
图形的生成是指CALayer的一些属性可能会发生离屏渲染,而离屏渲染通常发生在GPU中,会新开劈一个屏幕缓冲区(非当前屏幕缓冲区)这样需要消耗资源和时间。

提出解决方案

1、减轻CPU压力

如何减轻CPU压力,那么根据项目现状,我想计算时间方面入手。

提前计算

提前计算好Cell所有需要的数据高度,在进行刷新的时候保证在O(1)的时间内取出。

异步计算

图片显示的时候我们可以将其异步到子线程中进行解码加载,不阻塞主线程的正常工作。

减少计算

在使用Autolayout的时候,我们尽量不要依赖于同级视图进行布局计算,这样可以保证局部同级视频的优先级是统一的,从而不需要延长布局时间。

2、减轻GPU压力

减轻GPU压力主要可以从 图像合成的时候规避不必要的离屏渲染和规避不必要的alpha值的使用。

落地

提前计算O(1)时间内取出高度

func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
    let data = viewModel.momentDatas[indexPath.section].comments[indexPath.row]

    return data.height
}
func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
    let data = viewModel.momentDatas[section]
    if data.contentHeight >= 250.fitW && data.conntentIsFold {
        return data.height + 250.fit - data.contentHeight
    }
    return data.height
}

异步解码:
swift中的Kinshfier与OC中间的SDWebImage已经帮我们做了。可以去了解弄清其中的原理。

减少计算:在updateCell的时候利用一个 sumHeight 表示距离Cell顶部的距离,为参数进行布局。

// 如此类布局
        contentLabel.snp.remakeConstraints { (make) in
           make.top.equalTo(self).offset(sumHeight)
            make.height.equalTo(contentHeight)
            make.left.equalTo(self).offset(20.fitW)
            make.width.equalTo(screenWidth - 40.fitW)
        }

在GPU这块,由于笔者的项目没有出现需要加入aplha值,所以没有进行调优。

优化后

能稳定达到55帧率,不影响用户体验。

未来可以继续研究

1、重写UIView的drawRect方法 进行异步绘制
2、可以利用贝塞尔曲线进行圆角的控制。
3、利用runloop空闲时间进行预缓存。


文章作者: Cone
版权声明: 本博客所有文章除特別声明外,均采用 CC BY-NC-ND 4.0 许可协议。转载请注明来源 Cone !
  目录