iOS 离屏渲染分析/优化

作者: 顶级蜗牛 | 来源:发表于2021-08-12 18:23 被阅读0次

    开始前的提问:
    1.离屏渲染是什么?
    2.离屏渲染在哪一步进行的?
    3.离屏渲染的影响在哪?
    4.设置圆角一定会触发离屏渲染吗?
    5.如何优化离屏渲染?

    深入理解了上面几个问题足以回答面试官的问题。

    iOS中图像渲染流程

    UIKit其实就是CoreGraphicsCoreAnimation的高度集成。
    我们通过UIKit实现可视化的控件布局其实就是CoreGraphics绘制图层,但是它显示的部分和它的动画其实是CoreAnimation来完成。

    CoreGraphics它用来处理在运行前创建的图像。比如说在工程中导入的图片/资源文件。它可以对现成的文件进行高效的处理,既可以在CPU也可以在GPU上执行

    CoreAnimation用来做核心动画,图形图像显示。
    CoreImage做一些滤镜处理操作

    图像渲染框架.png

    在Application阶段,用CPU处理。CPU创建视图;计算视图的frame;进行图片解码、绘制纹理等等。再交由我们的GPU。
    再由顶点着色器去确定图形在我们硬件上的具体显示位置。
    在由片元着色器去确定计算每一个像素点的颜色值。
    再进行光算化,它会找到对应像素点的范围,把每一个像素点的颜色显示上去,然后再放入我们的帧缓存区里frameBuffer。
    最后由显示系统将帧缓存区里的数据显示出来。

    图像渲染流程..png 渲染路线.png

    视频控制器是怎么把帧缓存区的数据显示出来的?
    通过逐行扫描的方式确定一帧画面的显示。再回到初始的点逐行扫描确定下一帧的图像。

    我们的显示器通常都是固定的形式刷新的。像我们苹果手机刷新频率每秒60Hz。我们做屏幕优化的时候都是以fps作为指标。那么它的值越接近60说明屏幕流畅度越好

    image.png

    当VSync垂直同步信号的时候,GPU它还没有把这一帧的数据放入frameBuffer,我们这一个画面帧就会被丢失。等待下一个垂直同步信号过来的时候,再来显示我们前面的内容。

    丢帧原因.png

    离屏渲染是什么?

    如果要在显示屏上显示内容,我们至少需要一块与屏幕像素数据量一样大的frameBuffer(帧缓存区)作为像素数据存储区域,然后由显示器把帧缓存区的数据显示到屏幕上。
    如果有时因为面临一些限制,比如说阴影/遮罩等等,GPU无法吧渲染结果直接写入frameBuffer,而是先暂时把中间的一个临时状态存入另外新开辟的内存区域,之后再写入frameBuffer,这个过程被称之为离屏渲染

    离屏渲染产生.png

    当视图层级结构比较复杂的时候,就需要开辟临时内存区域。

    离屏渲染会有什么影响呢?

    GPU计算出的复杂的渲染图层,它会开辟一个临时内存区域;
    离屏渲染的整个过程,需要多次切换上下文环境:先是从当前屏幕(On-Screen)切换到离屏(Off-Screen),等到离屏渲染结束以后,将离屏缓冲区的渲染结果显示到屏幕上又需要将上下文环境从离屏切换到当前屏幕。而上下文环境的切换是要付出很大代价的。
    由于垂直同步的机制,如果在一个 HSync 时间内,CPU 或者 GPU 没有完成内容提交,则那一帧就会被丢弃,等待下一次机会再显示,而这时显示屏会保留之前的内容不变。这就是界面卡顿的原因。

    既然离屏渲染这么耗性能,为什么有这套机制呢?

    有些效果被认为不能直接呈现于屏幕,而需要在别的地方做额外的处理预合成。图层属性的混合体没有预合成之前不能直接在屏幕中绘制,所以就需要屏幕外渲染。屏幕外渲染并不意味着软件绘制,但是它意味着图层必须在被显示之前在一个屏幕外上下文中被渲染(不论CPU还是GPU)。

    CALayer产生GPU离屏渲染的操作与相应的优化手段

    UIViewCALayer关系:
    UIView继承自UIResponder,可以处理系统传递过来的事件,如:UIApplication、UIViewController、UIView,以及所有从UIView派生出来的UIKit类。每个UIView内部都有一个CALayer提供内容的绘制和显示,并且作为内部RootLayer的代理视图。

    CALayer继承自NSObject类,负责显示UIView提供的内容contents。CALayer有三个视觉元素:背景色backgroundColor、内容contents、边缘borderWidth&borderColor构成,其中,内容的本质是一个CGImage。

    CALayer结构.png

    以下离屏渲染操作,按对性能影响等级从高到低进行排序: shadows(阴影)圆角mask(遮罩)allowsGroupOpacity(组不透明)edge antialiasing(抗锯齿)

    离屏渲染分析

    如何查看渲染出来的UI是否经过离屏渲染过程呢?
    打开模拟器工具栏 -> Debug -> Color Off-screen Rendered
    图层变化成黄色,说明是经过离屏渲染。

    模拟器.png

    代码部分很简单,新建一个工程写一个tableView,在tableView上面添加一个ImageView和一个UILabel即可。接下来逐个分析出现离屏渲染的情况。

    Instruments使用

    利用Core Animation来分析应用的性能问题

    Instruments.png

    Color Blended Layers
    这个选项基于渲染程度对屏幕中的混合区域进行绿到红的高亮(也就是多个半透明图层的叠加)。由于重绘的原因,混合对GPU性能会有影响,同时也是滑动或者动画帧率下降的罪魁祸首之一

    Color Hits Green and Misses Red
    当设置shouldRasterizep属性为YES的时候,耗时的图层绘制会被缓存,然后当做一个简单的扁平图片呈现。当缓存再生的时候这个选项就用红色对栅格化图层进行了高亮。如果缓存频繁再生的话,就意味着栅格化可能会有负面的性能影响了

    Color Offscreen-Rendered Yellow
    开启后会把那些需要离屏渲染的图层高亮成黄色,这就意味着黄色图层可能存在性能问题

    当然Debug还有其它的选项,来分析不同的性能问题,如有需求,请参考其它资料。

    光栅 - 触发离屏渲染
        private func wj_shouldRasterize() {
            // 缓存机制 -- bitmap -- cpu直接从缓存取数据 -- gpu渲染 -- 可提供性能 -- 缓存在100ms内  慎用!!!
            // 面试尽量别提光栅化。若当视图内容是动态变化(如后台下载图片完毕后切换到主线程设置)时,使用此方案反而为增加系统负荷。
            imageV.layer.shouldRasterize = true
            imageV.layer.rasterizationScale = imageV.layer.contentsScale
        }
    
    image.png
    遮罩Mask - 触发离屏渲染
        private func wj_mask() {
            // mask是添加在imageV.layer的上层
            let layer = CALayer()
            layer.frame = CGRect(x: 0, y: 0, width: imageV.bounds.size.width, height: imageV.bounds.size.height)
            layer.backgroundColor = UIColor.red.cgColor
            imageV.layer.mask = layer
        }
    
    阴影 - 触发离屏渲染
        private func wj_shadows() {
            // shadow是在imageV.layer的下层
            imageV.layer.shadowColor = UIColor.red.cgColor
            imageV.layer.shadowOpacity = 0.1
            imageV.layer.shadowRadius = 5
            imageV.layer.shadowOffset = CGSize(width: 10, height: 10)
        }
    
    阴影优化 - 不会离屏渲染
        private func wj_shadowsOptimize() {
            imageV.layer.shadowColor = UIColor.red.cgColor
            imageV.layer.shadowOpacity = 0.1
            imageV.layer.shadowRadius = 5
            // CoreAnimation - 阴影的几何形状
            imageV.layer.shadowPath = UIBezierPath(rect: CGRect(x: 0, y: 0, width: imageV.bounds.size.width+10, height: imageV.bounds.size.height+10)).cgPath // 10是阴影偏移量
        }
    
    抗锯齿 - 触发离屏渲染
        private func wj_edgeAntialiasing() {
            let angel = CGFloat.pi/20.0
            imageV.layer.transform = CATransform3DRotate(imageV.layer.transform, angel, 0, 0, 1);
            imageV.clipsToBounds = true
            imageV.layer.allowsEdgeAntialiasing = true
        }
    
    视图组不透明 - 触发离屏渲染
    // 视图组不透明 alpha 只要有子视图并设置父视图的alpha<1就会离屏渲染
        private func wj_allowsGroupOpacity() {
            // 没有子视图view是不会离屏渲染
            let view = UIView(frame: CGRect(x: 10, y: 10, width: 20, height: 20))
            view.backgroundColor = .green
            imageV.addSubview(view)
            
            imageV.alpha = 0.5
            imageV.layer.allowsGroupOpacity = true // 设置view与imageV透明度一样
        }
    
    圆角

    圆角就一定会触发离屏渲染吗?
    先告诉你答案是否定的!重点看分析:

    圆角案例一
        // 不会触发离屏渲染
        private func wj_radius() {
            imageV.layer.cornerRadius = 20
            imageV.clipsToBounds = true
        }
    
    案例一不会触发.png
    圆角案例二
        // 四个角就会离屏渲染
        // 设置borderWidth、borderColor只要Color不为clear,四个角就会离屏渲染
        private func wj_radius() {
            imageV.layer.borderWidth = 1
            imageV.layer.borderColor = UIColor.red.cgColor
            imageV.layer.cornerRadius = 20
            imageV.clipsToBounds = true
        }
    
    圆角案例二触发.png
    圆角案例三
        // 添加子视图 - 四个角就会离屏渲染
        private func wj_radius() {
            let view = UIView(frame: CGRect(x: 30, y: 30, width: 30, height: 30))
            view.backgroundColor = .green
            imageV.addSubview(view)
            imageV.layer.cornerRadius = 20
            imageV.clipsToBounds = true
        }
    
    圆角案例三触发.png
    圆角案例四:重点分析
    image.png
        private func wj_radius() {
             // 加了背景颜色会触发离屏渲染, 其实设置layer的backgroundColor
            imageV.backgroundColor = .red
            imageV.layer.cornerRadius = 20
            imageV.clipsToBounds = true
    

    新增一行代码给imageV添加背景色,它就会触发离屏渲染。
    因为imageV.backgroundColor其实设置的是layerbackgroundColor

    此时再添加一行代码,将image设置为nil,它就不会触发离屏渲染了:

        private func wj_radius() {
             // 加了背景颜色会触发离屏渲染, 其实设置layer的backgroundColor
            imageV.backgroundColor = .red
             // 实际是把contents层内容设置为nil
            imageV.image = nil
            imageV.layer.cornerRadius = 20
            imageV.clipsToBounds = true
        }
    
    image.png

    这是因为 imageV.image = nil 实际上是把imageVcontents层内容设置为nil

    此时再添加一行代码设置layer的背景色

        private func wj_radius() {
             // 加了背景颜色会触发离屏渲染, 其实设置layer的backgroundColor
            imageV.backgroundColor = .red
             // 实际是把contents层内容设置为nil
            imageV.image = nil
            // 实际是contents层的背景设置为蓝色
            imageV.layer.backgroundColor = UIColor.blue.cgColor
            imageV.layer.cornerRadius = 20
            imageV.clipsToBounds = true
        }
    
    image.png

    这就验证了上面图层的结构了,contents在layer的上层。

    特殊的Label

    上面尝试仅仅给imageV添加背景色做圆角处理,它会触发离屏渲染,而对于label它是不会触发离屏渲染的:

        private func wj_radius() {
             // 加了背景颜色会触发离屏渲染, 其实设置layer的backgroundColor
            imageV.backgroundColor = .red
            imageV.layer.cornerRadius = 20
            imageV.clipsToBounds = true
            
            // 它设置的是contents这个backgroundColor
            label.backgroundColor = .red
            label.layer.cornerRadius = 15
            label.clipsToBounds = true
        }
    
    image.png

    其实label.backgroundColor = .red其实设置的是contents层的背景色,这与imageV不一样了,如何看出呢?我们设置label.layer的背景色看看输出:

        private func wj_radius() {
             // 加了背景颜色会触发离屏渲染, 其实设置layer的backgroundColor
            imageV.backgroundColor = .red
            imageV.layer.cornerRadius = 20
            imageV.clipsToBounds = true
            
            // 它设置的是contents这个backgroundColor
            label.backgroundColor = .red
            // 它设置的是layer的backgroundColor
            label.layer.backgroundColor = UIColor.blue.cgColor
            label.layer.cornerRadius = 15
            label.clipsToBounds = true
        }
    
    image.png

    可以看出label的背景色依旧是红色,并且这个时候label是通过离屏渲染过程的。(另外label设置边框颜色和宽度也会离屏渲染 自行调试)

    圆角离屏渲染总结:设置圆角要触发离屏渲染条件:去操作contents

    圆角优化 - 贝塞尔曲线
        private func wj_radiusBezier() {
            imageV.backgroundColor = .red        
            UIGraphicsBeginImageContextWithOptions(imageV.bounds.size, false, 0.0)
            UIBezierPath(roundedRect: imageV.bounds, cornerRadius: imageV.bounds.size.height/2).addClip()
            imageV.draw(imageV.bounds)
            imageV.image = UIGraphicsGetImageFromCurrentImageContext()
            UIGraphicsEndImageContext()
        }
    

    然而这种优化方案不是最优异的解决办法。根据实际情况,可以让设计师去切一个圆形的遮罩图,当然这种方式并不能在所有场景都适用。大家灵活运用。

    圆形的遮罩图.png

    本文重在对离屏渲染分析。demo链接
    喜欢的铁铁给个star支持一下,感谢收看。

    相关文章

      网友评论

        本文标题:iOS 离屏渲染分析/优化

        本文链接:https://www.haomeiwen.com/subject/jbylbltx.html