美文网首页
iOS 渲染

iOS 渲染

作者: Trigger_o | 来源:发表于2022-07-07 15:54 被阅读0次

整理一些老生常谈的问题.

一.显示流程

1.绘制
当然是逻辑上的绘制,此时各种框架的代码开始工作,iOS并不完全由GPU负责渲染,比如当需要Core Graphics参与时,CPU会做更多的事,这一点很重要;比如当重写drawRect方法时使用了很多Core Graphics的api,此时就由CPU来绘制bitmap,并且还需要内存来存储bitmap,当然只是绘制一张图并不会消耗什么,但是如果频繁的绘制就不一样了.
绘制包含很多动作,比如UIKit中UIView的布局,Core Animation中CALayer的显示和动画,还有上面说的core Graphics绘制,以及OpenGL,Metal的绘制,这部分可能是CPU的工作也可能直接由GPU处理.

框架

2.画面渲染
从渲染流程的角度,不同的框架控制不同的步骤,除了OpenGL或者Metal更多设计GPU的工作,其他框架基本都是在编程CPU的工作.编程以外的事,就是GPU渲染管线,计算过程,缓存策略等等了.
cpu和gpu处理完成之后,得到一张bitmap,也就是像素点阵,接下来视频控制器将bitmap显示到屏幕上.
但是由于计算需要时间不一,视频控制器不能去等bitmap生成出来,因此需要一个缓存来存储已经生成好的bitmap,也就是帧缓冲Framebuffer,

渲染过程

3.扫描显示
当cpu和GPU的工作完成时,视频控制器就应该将bitmap转换为模拟信号,给显示元件发送指令了.
iOS屏幕的显示仍然类似CRT电子扫描显示,从左上角开始,从左到右以此控制显示元件,一行显示完成后开始第二行,这样从上到下的刷新屏幕.

GPU随时可能会进行渲染,视频控制器随时可能会给显示元件发送指令,那么自然就会有这种情况,当扫描显示进行到一半的时候收到了新的指令,如果画面变化不大,那就感觉不出来什么区别,如果画面变化很大,就产生了画面撕裂现象.

4.垂直同步和二级缓冲
iOS采用使用垂直同步信号 Vsync 与双缓冲机制 Double Buffering来解决这个问题.

当元件开始读取帧数据的时候,加一个锁,在当前帧发送完毕之前,不发送下一帧的显示指令;当完成一行的显示时,发送一个水平同步信号(horizonal synchronization),当完成一帧的显示时,发送一个垂直同步信号(vertical synchronization).当视频控制器收到垂直信号时,再从帧缓冲中取出需要显示的下一张bitmap,进行显示.

5.卡顿
解决画面撕裂的方案其实就一个字,等.但是这会引起别的问题.
视图控制器有预定的发送频率,也就是预定帧率,在支持高刷(ProMotion displays)的iPhone13之前,是60fps,之后是120fps.如果是60Fps,那就是16.7毫秒发送一次bitmap,假如cpu和gpu的复载很大,收到Vsync的时候framebuffer还没有新的一帧,就发送旧的,画面就没有变化,对应到屏幕刷新,就是卡顿现象.
二级缓冲就是GPU 会预先渲染一帧放入一个缓冲区中,用于视频控制器的读取。当下一帧渲染完毕后,GPU 会直接把视频控制器的指针指向第二个缓冲器。

二.离屏渲染

1.Core Animation的工作流程

image.png

这张有名的图是Core Animation的工作流程,可以看到既参与Application的部分,又要负责Rander server的部分.

Commit Transaction阶段指的是对布局进行计算,构建和绘制,以及图片解码等等工作;

首先是构建视图(layout),包括初始化各种UIView对象,addsubveiw, 计算frame布局和autolayout等;在这一阶段,影响性能的就是图层数量,布局关系,减少视图层级,避免不必要的初始化可以提升性能.
其次是绘制(display),构建视图时,用于交付 draw calls的bitmap还没有生成,但是如果在drawrect中使用core Graphics的api绘制图形,CPU就会直接开始生成bitmap,并且还要申请内存来存储.此时就需要注意不能频繁的绘制.

Rander server阶段, Core Animation对图层解码,得到每个layer的bitmap;
当完成解码后,等待下一次runloop,core Animation调用OpenGL或者Metal的接口,开始draw calls阶段;
OpenGL或者Metal拿到具体的任务,此时,工作已经到了GPU这边,开始执行渲染;
当渲染完成,下一次runloop循环开始,就是显示元件的工作了.

2.offscreen buffer
前面说到GPU的frame buffer缓存将要显示的bitmap,但是有些情况下,core Animation交付的bitmap并不是最终版本,GPU需要暂存一些中间状态的bitmap,当这些中间bitmap都到位之后,再生出最终的bitmap放到frame buffer,而offscreen buffer就负责管理这些中间状态的bitmap.也就是常说的离屏渲染.
因此offscreen是GPU的工作部分产生的,所以CPU负责的部分,虽然也可能申请内存去存储中间状态,比如core Graphics绘制就是典型的开辟一个cgcontext来画,除此之外还有文字绘制,图片解码视频软解等,但是这些不属于离屏渲染,重写drawrect并不会被Color offscreen rendered标记黄色.

3.画家算法
OpenGL/Metal输出的bitmap会像画油画一样一层层覆盖,bitmap作为位图,像素点阵,包含的信息很有限,一个位置的像素信息,被后来的信息覆盖,前面的就丢失了,当修改完一整张bitmap后,再想回头修改之前的bitmap是做不到的.
对于一个layer,如果它能够确定自己的内容,一步到位,就可以直接到frame buffer中去叠加, 如果不能一步到位,比如说cornerRadius+clipsToBounds,就得把自己和子layer都先叠加画好好,然后再裁剪,再然后才能去frame buffer,在画子layer之前,自己就会先在offscreen buffer中渲染.

4.离屏渲染的场景
那么什么时候会产生离屏渲染呢,模拟器打开Color offscreen rendered观察一下.

  • 圆角

这是一个UIImageView,它的clipsToBounds为true,layer.cornerRadius是10.0,backgroundcolor是clear,
此时并没有产生离屏渲染
使用layer的contents也是一样的

        let l = CALayer.init()
        l.frame = .init(x: 0, y: 0, width: 20, height: 20)
        l.masksToBounds = true
        l.contents = UIImage.init(named:"avatar")?.cgImage
        l.contentsGravity = .resizeAspectFill
        l.cornerRadius = 5
//        l.backgroundColor = UIColor.blue.cgColor
        contentView.layer.addSublayer(l)

image.png

但是如果backgroundcolor不是clear,比如设置成black,就会产生离屏渲染


image.png

此外border也会影响离屏渲染,即便背景是透明,设置了border和圆角和clipstobound,也会产生离屏渲染

avatar.backgroundColor = .clear
avatar.layer.borderWidth = 1
avatar.layer.borderColor = UIColor.black.cgColor
image.png

主要是因为layer的三层结构


layer的结构

这是一个UIView,同样设置了clipsToBounds=true,layer.cornerRadius=10.0,backgroundcolor是灰色,它没有离屏渲染,
当给他添加一个subView的时候,就产生了离屏渲染,此时如果把backgroundcolor设置为clear,又没有离屏渲染了.


image.png
image.png

此时如果改成超出父视图的这种布局,会发现离屏渲染又回来了;
这是由core Animation的算法决定的,子层没有超过父层的部分,那么就可以按照画家算法正常的去画.


超出了父视图
image.png

因此在clipsToBounds圆角方面,视图层级,视图布局,背景色,contents,都是会影响离屏渲染的因素.

  • 阴影
    给一个UIView添加阴影,结果整个都是黄色的.
singleV.layer.shadowColor = UIColor.black.cgColor
        singleV.layer.shadowOffset = .init(width: 2, height: 2)
        singleV.layer.shadowRadius = 0.8
        singleV.layer.shadowOpacity = 1.0
image.png

甚至当设置clipsToBounds= true时,关闭圆角,此时阴影被裁剪了,依然是离屏渲染


阴影被裁了

而clearcolor的情况下阴影不会生效,也不会有离屏渲染,不过如果有子视图,阴影是会对子视图生效的,此时就有离屏渲染


clearcolor
子视图
  • mask
    这个都不用看效果了,mask的本质就是层的合并,只要layer本身不是透明的,用了肯定离屏渲染.
let be = UIBezierPath.init()
        be.move(to: .init(x: 10, y: 10))
        be.addLine(to: .init(x: 50, y: 10))
        be.addLine(to: .init(x: 50, y: 50))
        be.addLine(to: .init(x: 10, y: 50))
        be.close()
        let layer = CAShapeLayer.init()
        layer.path = be.cgPath
        layer.backgroundColor = UIColor.red.cgColor
        singleV.layer.mask = layer
image.png
let l = CALayer.init()
        l.backgroundColor = UIColor.black.cgColor
        l.opacity = 0.5
        l.frame = .init(x: 0, y: 0, width: 80, height: 80)
        avatar.layer.mask = l
image.png
  • 光栅化
    shouldRasterize默认是false, 设置为true时会缓存该layer的bitmap在offscreen buffer,因此属于离屏渲染
  • 组透明度
    layer.allowsGroupOpacity默认是true,开启和关闭时的渲染效果不同,并且开启时如果存在子layer,会引起离屏渲染.
    它指的是不单独对层进行透明度处理,等层和子层渲染完了之后,再应用透明度.
    另外透明度是是渲染比较靠后的一步,光栅化缓存的bitmap也不包含透明度信息.


    true
    false
    image.png
  • 文本
    CATextLayer和带有contents的普通layer性质相同
let l = CATextLayer.init()
        l.frame = .init(x: 320, y: 10, width: 100, height: 40)
        l.foregroundColor = UIColor.white.cgColor
        l.contentsScale = UIScreen.main.scale
        let font = UIFont.systemFont(ofSize: 14)
        l.font = CGFont.init(font.fontName as CFString)
        l.fontSize = font.pointSize
        l.string = "text layer"
        l.backgroundColor = UIColor.black.cgColor
        l.masksToBounds = true
        l.cornerRadius = 10
        contentView.layer.addSublayer(l)
CATextLayer

UILabel具有其特殊性,不管是普通的绘制文字,clipsToBounds,背景颜色,圆角,都不会产生离屏渲染;
UILabel的layer,类型是_UILabelLayer,并非CATextLayer. _UILabelLayer继承自CALayer.

label.textColor = .white
        label.backgroundColor = .black
        label.clipsToBounds = true
        label.layer.cornerRadius = 10
UILabel

三:关于性能优化

1.CPU和GPU都要关照
通常大部分的渲染工作由GPU来完成,从框架角度来说基本是由Core Animation来做,Core Animation实现硬件加速,也就是把渲染工作转换成适合GPU处理的形式.但是也有一些工作Core Animation做不到的,他们属于core Graphics的工作范围,比如绘制文字,图形以及ImageIO的图片解码等等,这些必须由CPU完成,然后再把数据传给GPU.

CPU的有些渲染其实也可以叫做CPU专属的"离屏渲染",毕竟表现还是很像的,就是更多的消耗性能和占用内存
比如要避免频繁的core Graphics绘制,大量的解码图片会引起内存暴涨等这些情况要及时释放内存,core Graphics和core image的api都有对应的release方法,要合理使用.
前面说到CPU还需要负责视图的构建和布局计算,因此频繁的调整图层结构,频繁的重置布局都是需要注意的地方.

对于GPU方面,要用color offscreen rendered来观察,而不是靠经验和猜测.

2.处理圆角
处理圆角要考虑前面的那些情况,如果子视图能单独处理,就单独处理,分别对层进行切圆角,同时背景能clear的就clear;
另外可以考虑用脑洞来实现圆角效果,比如用图片遮挡.

3.处理阴影
使用shadowPath可以避免离屏渲染

        singleV.layer.shadowColor = UIColor.black.cgColor
        singleV.layer.shadowOffset = .init(width: 3, height: 3)
        singleV.layer.shadowRadius = 0.8
        singleV.layer.shadowOpacity = 1.0
   //singleV.layer.shadowPath = UIBezierPath.init(rect: singleV.bounds).cgPath
使用shadowpath 没有使用shadowpath

那么为什么呢:
首先阴影是添加在其他层的效果,A的阴影显然不会在A自己身上,会在下面的BCD...上,从画家算法思考,B画了一半,画A,然后在B上画A的阴影,这才算完.
shadowPath会预先定义好阴影的位置,在core Animation的阶段就可以确定阴影画在哪,现在当画B的时候,直接把阴影画上,即使A的bitmap还没有被发送到GPU,因为在生成B的bitmap时core Animation已经预先计算好了阴影.

4.使用光栅化的情况
光栅化是计算机图形学的必要流程,是把采样结果隐射到bitmap上,所以这里的shouldRasterize并不是这个意思;
shouldRasterize指的是会把层以及子层整个生成好的bitmap缓存在offscreen buffer,之后如果能够重用,就可以直接发送到frame buffer.

当界面上的内容在频繁的快速的变化时,比如滑动一个列表,对于60fps的机型,理论上渲染流程每秒要走60次;
但是一秒内一个单元格可能从最底下到最上面,它的内容并没有发生变化;
这种情况下使用shouldRasterize就可以提升性能,当然前提是单元格本身就存在不可避免的离屏渲染,或者存在复杂繁多的图层结构,因为shouldRasterize本身就会产生离屏渲染,简单的布局使用了它之后得不偿失.

除此之外,CPU的部分还要考虑多线程问题,UI的刷新只会在主队列进行,主队列只有一个线程就是主线程Thread 0,耗时的操作放在主队列会影响UI的刷新.
这是apple的解决方案,单线程虽然效率低,但是线程安全;
不过实际上UIKit和core Graphics的绘制也是可以在主线程之外进行的,相关的库也有一些比如Facebook的Texture

相关文章

网友评论

      本文标题:iOS 渲染

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