注册
iOS

iOS 渲染过程

背景



app如何快速显示首屏?

滑动列表时候如何做到流畅?

当我们说界面卡了我们在说什么?

......



应用运行的卡顿率是一个十分重要的指标,相比慢、发热、占用内存高来讲,卡顿是用户第一时间能感知的东西,三步两卡的应用基本逃不出被卸载的命运,要想优化卡顿就要搞清楚画面卡住不动的原因,这就需要对整个渲染过程有一定了解,本文会从图层说起,来聊聊整个渲染过程以及优化点,在写这篇文章之前笔者努力在想,对于完全没有做过图形处理相关工作的工程师来说,理解这个过程是有一定难度的,那么要怎么写才可以脉络清晰又浅显易懂呢,想来想去还是从日常开发中的界面UI开始分析吧,毕竟可直接感知


从一个简单的界面开始

- (void)viewDidLoad {
[super viewDidLoad];
self.view.backgroundColor = UIColor.blueColor;
// 蒙板视图
UIView *maskView = [[UIView alloc] initWithFrame:self.view.bounds];
maskView.backgroundColor = [UIColor redColor];
maskView.alpha = 0.3;
[self.view addSubview:maskView];
// 初始化屏幕大小的矩形路径
UIBezierPath *bpath = [UIBezierPath bezierPathWithRoundedRect:CGRectMake(0, 0, [[UIScreen mainScreen] bounds].size.width, [[UIScreen mainScreen] bounds].size.height) cornerRadius:8.0];
// 中间添加一个圆形的路径
[bpath appendPath:[UIBezierPath bezierPathWithArcCenter:maskView.center radius:100 startAngle:0 endAngle:2*M_PI clockwise:NO]];
//创建一个layer设置其CGPath为我们创建的路径并且赋值给蒙板视图的layer
CAShapeLayer *shapeLayer = [CAShapeLayer layer];
shapeLayer.path = bpath.CGPath;
maskView.layer.mask = shapeLayer;
}

效果如下:我们设置self.view的背景色为蓝色,然后添加了一层中间镂空的红色透明度0.3的蒙板,经过叠加、裁剪、混合(后面会详述)

数一数得到图中的效果,我们一共用了几个元素



  • 一个蓝色背景UIView
  • 一个蒙板视图UIView
  • 一个用贝塞尔曲线修改过的CAShapeLayer

我们自己创建的元素有三个,背景视图上面添加蒙板视图,蒙板视图的图层的蒙板层mask设置为自定义的镂空CAShapeLayer层,可见开发中既有视图又有图层,会不会觉得有些冗余呢


视图和图层为何拆分开来又聚合在一起呢


这是自图形界面发明出来就广泛应用的设计,拆开之后,layer负责界面显示,view负责事件分发,聚合一起是因为操作起来更方便,不能说开发者设置完事件层次之后,再设置一遍图层层次吧



iOS中用UIView和CALayer来描述两者,所有视图相关的类都继承自UIView,所有图层相关的类都继承自CALayer,view是layer的代理并且苹果建议我们不要修改这种关系,为了对开发者更友好,view中暴露了一些界面设置相关的属性,所有view显示相关的设置最终都会映射到与之绑定的layer上,这样的api设计有意隐藏了CALayer的部分功能,对开发者来讲不用关心那么多显示相关的细节



因此当我们研究渲染过程的时候,只需要关注CALayer即可


渲染数据流


纵观我们的app界面,图层上面加图层,图层又有子图层,整个结构是以CALayer或其子类对象为节点连接而成的树型结构,我们通常称之为图层树,苹果称其为模型树,layer默认是个矩形,我们可以通过path属性修改其形状,可以修改为圆形、三角形等任何规则不规则的形状,那么从图层树到屏幕上显示的界面都需要哪些步骤呢,如果你去查资料你可能会翻到图层树->呈现树->渲染树,那么他们都是什么东西呢,数据是如何流转的,CPU、GPU和渲染引擎都扮演了怎样的角色呢,我们来拆解下渲染数据流的处理过程



用CALayer构建图层树

我们编写的所有UI代码,最终都会以一个个的CALayer对象的形式被添加到渲染数据流中,在此过程中会做如下处理




  • 视图懒加载

    这是常规设计,通常所有界面元素都会用懒加载的方式去处理,即只有视图需要显示的时候才去加载它,以最大化优化内存,此过程同时会进行图片解压缩(当设置资源路径到UIImage或者UIImageVIew的时候)

  • 布局计算

    加载完视图之后,会进行addSubview、addSublayer操作,这是在设置图层之间的关系,所有图层会以superlayer、sublayer指针连接起来成为一个树型结构,一个节点只能有一个superlayer,可以有无限个sublayer,每个节点承载着与父子节点的位置关系以及渲染属性,当发生布局改变的时候,若是单纯的修改某个节点的渲染属性,则开销较小,若是修改层级,则整个图层树都需要重新计算修正


  • Core Graphics绘制

    如果实现了-drawRect:或者-drawLayer:inContext:方法,系统会以当前layer为画布创建一个寄宿图来单独绘制字符串或者图片,若是图片,同样会进行图片解压缩操作


以上过程图层树已生成,就是以CALayer对象为节点的树型结构



Core Animation构建呈现树

图层树仅仅是一个数据结构,而GPU只负责计算处理图形图像数据,因此在发送数据到渲染服务之前还需要把图层树图形图像化,在此过程中会做如下处理




  • CALayer生成图形

    遍历图层树,取出每个layer节点,根据渲染属性生成图形,通常都是矩形(可以是任意形状就像文章开篇那个界面一样)

  • 图片生成位图

    无论是直接通过UIImage或者UIImageView加载的图片还是通过drawRect或者drawLayer:inContext绘制的图片都会在此过程中生成位图(第一步仅仅是解码生成非压缩二进制流)


以上过程呈现树已生成,图层树、呈现树构建过程都是由CPU负责计算



渲染服务

呈现树已经是图形图像组织而成的树型结构,Core Animation通过IPC进程通信将其发送到渲染服务进程




  • 生成纹理

    如上的图形图像都是非格式化的数据,在计算机图形学里面通常称为Buffer,而GPU能处理的是格式化纹理数据Texture,在此过程中Core Animation会将呈现树Buffer数据通过渲染引擎转化为纹理数据Texture,自此渲染树已生成,这一步骤仍然由CPU计算

  • 顶点数据:包括顶点坐标、纹理坐标、顶点法线和顶点颜色等属性,顶点数据构成的图元信息(点、线、三角形等)需要参数代入绘制指令

  • 顶点着色器:将输入的局部坐标变换到世界坐标、观察坐标和裁剪坐标

  • 图元装配:将输入的顶点组装成指定的图元,这个阶段会进行裁剪和背面剔除相关优化

  • 几何着色器:将输入的图元扩展成多边形,将物体坐标变换为窗口坐标

  • 光栅化:将多边形转化为离散屏幕像素点并得到片元信息

  • 片元着色器:通过片元信息为像素点着色,这个阶段会进行光照计算、阴影处理等特效处理

  • 测试混合阶段:依次进行裁切测试、Alpha测试、模板测试和深度测试

  • 帧缓存:最终生成的图像存储在帧缓存,然后放入渲染缓冲区
  • 显示到屏幕


以上过程都由渲染引擎处理,除了生成纹理是CPU负责,其他都全权由GPU负责,以上就是界面渲染的全部过程,那我们自定义的画布呢,比如常见的播放器业务、地图业务都是怎么最终显示到屏幕的呢


自定义画布


我这篇文章有详述其组织渲染过程:https://www.jianshu.com/p/4613e0bcd31f,但是渲染到帧缓存之后,显示到屏幕过程是怎样的呢

iOS中是不支持直接渲染到屏幕的,我们的自定义画布,需要配合Core Animation来完成最终的显示,诸如播放器、地图等业务得到的渲染缓冲renderBuffer,需要通过layer关联到Core Animation层,待到生成渲染树的时候替换原来的内容,此过程只是一个指针替换,即将渲染树对应层的指针指向renderBuffer,然后由渲染引擎通过GPU最终将其呈现到屏幕


图层截图黑屏问题


播放器、地图类业务图层截图的时候会黑屏,原因是图层截图截取的是呈现树,此时自定义画布得到的renderBuffer还没有替换layer原来的内容,解决这个问题需要在截图的时候,将renderBuffer的内容在layer的上下文上单独绘制


当我们说界面卡了我们在说什么



屏幕会以60帧每秒的频率刷新,就是16.7毫秒一帧,系统渲染进程是不会卡的,除非发生了系统错误或者硬件错误,通常渲染服务进程会一直以16.7毫秒一帧不停的刷新,这个过程由VSync信号驱动,VSync信号由硬件时钟生成,每秒钟发出60次,渲染服务进程接收到VSync信号后,会通过IPC通知到活跃的App进程,进程内的所有线程的Runloop在启动后会注册对应的CFRunLoopSource,通过mach_port接收传过来的VSync信号,Runloop随之执行一次以驱动整个app的运行



页面卡顿,现象是当我们滑动列表或者点击一个按钮之后页面没有响应,本质原因是主线程卡了,因为整个UI界面的构建、计算、合成纹理过程都是在主线程进行的,主线程16.7毫秒之内没有执行完当前任务,该任务可能是:



  • 非UI业务逻辑耗时过多
  • 图层树构建组织过程耗时过多
  • 呈现树生成过程耗时过多

总之是渲染树没有更新导致看上去还是上一帧的画面,这就是卡顿


卡顿优化


优化卡顿无非就是减少上面几个步骤的耗时,使Runloop能在16.7毫秒之内执行完一次





  • 非UI业务逻辑耗时

    尽可能的将其放在非主线程执行,完成之后异步刷新UI





  • 图层树构建组织过程耗时

    1、布局计算的耗时与图层的个数、图层之间的层级关系和位置关系呈线性关系,即图层越多越耗时,图层关系越复杂越耗时,因此尽量用更少的图层个数和简单的图层关系来布局就是优化方向,而且尽量不要动态修改图层的层级关系,否则整个图层树都需要重新计算修正

    2、尽量不要重写-drawRect:或者-drawLayer:inContext:方法,因为Core Animation不得不生成一张layer等大小的寄宿图用于绘制,不仅占用额外的内存而且绘制过程是CPU计算的

    3、图片解码尽量在需要展示之前进行,SDWebImage做的就很好,不仅优化了图片文件IO而且图片解码也是在非主线程执行,完成之后异步刷新UI





  • 呈现树生成过程耗时

    这里要纠正个问题,离屏渲染是常规操作,经过优化的播放器、地图等业务都是用的离屏渲染,发生在主线程的离屏渲染才有性能问题,可以用CPU渲染也可以用GPU渲染,离屏渲染也是同理

    1、CALayer生成图形,离屏渲染发生在这个阶段,对于特定图层的圆角、图层遮罩、阴影或者是图层光栅化都会使Core Animation不得不进行当前图层的离屏绘制,不过在界面设计的时候,大多数设计师都钟爱于以上效果,使用起来也没有太大影响,只是会造成多余的计算、耗时和内存占用,如果不是列表型的界面,可以尽情的使用,对于列表型界面,我们可以禁用shouldRasterize(就是光栅化),这将会让图层离屏渲染一次后把结果保存起来,后面刷新会直接用缓存的结果

    2、图片生成位图,显然图片越多、越大计算量越大,就越耗时,因此如果能用CALayer实现的效果,尽量不要让设计师出图,不仅占用存储空间、占用内存而且加载耗时


总结


本文从一个简单的界面开始详细阐述了iOS渲染过程以及卡顿优化点,有些内容官方文档写的很清楚,甚至还有demo,大家在学习的时候首先应该关注的就是官方文档,下面给出两个官方参考链接:


https://developer.apple.com/documentation/quartzcore/calayer?language=objc


https://developer.apple.com/library/archive/documentation/3DDrawing/Conceptual/OpenGLES_ProgrammingGuide/WorkingwithEAGLContexts/WorkingwithEAGLContexts.html#//apple_ref/doc/uid/TP40008793-CH103-SW7



链接:https://www.jianshu.com/p/0ced61fd1f21

0 个评论

要回复文章请先登录注册