iOS界面渲染与优化

Posted by LML on March 19, 2020

前言

本文主要介绍下吗几点知识

  • iOS 界面渲染流程
  • iOS 中的渲染框架
  • 离屏渲染那些事
    • 什么是离屏渲染?
    • 哪些操作导致离屏渲染?如何优化
  • 为什么UI必须在主线程进行

iOS 界面渲染流程

Runloop 与绘图循环

Main Runloop 负责处理 app 存活期间的大部分事件,如用户交互等,它一直处于不断处理事件和休眠的循环之中,以确保能尽快的将用户事件传递给 GPU 进行渲染,使用户行为能够得到响应,画面之所以能够得到不断刷新也是因为Main Runloop在驱动着。

当在操作 UI 时,比如改变了 Frame、更新了 UIView/CALayer 的层次时,或者手动调用了 UIView/CALayer 的 setNeedsLayout/setNeedsDisplay方法后,这个 UIView/CALayer 就被标记为待处理,并被提交到一个全局的容器去。

苹果注册了一个 Observer 监听 BeforeWaiting(即将进入休眠) 和 Exit (即将退出Loop) 事件,回调去执行函数: _ZN2CA11Transaction17observer_callbackEP19__CFRunLoopObservermPv()。这个函数里会遍历所有待处理的 UIView/CAlayer 以执行实际的绘制和调整,并更新 UI 界面。即触发界面渲染流程。

也就是说每一个 view 的变化的修改并不是立刻变化,相反的会在当前runloop的结束的时候统一进行重绘,这样设计的目的是为了能够在一个runloop里面处理好所有需要变化的view,包括resize、hide、reposition等等,所有view的改变都能在同一时间生效,这样能够更高效的处理绘制,这个机制被称为绘图循环(View Drawing Cycle)。

若程序中存在一个耗时非常长的计算,就会导致 runloop 不能进入休眠状态,进而导致页面长久不更新,给用户的感觉就睡页面卡住了,app卡住了。

界面渲染流程

渲染流程

简单来说,流程如下:

  • CPU计算显示内容→总线→ GPU
  • GPU渲染内容→帧缓冲区(FrameBuffer)
  • 视频控制器以一定周期从帧缓冲区取数据→显示器
    • 显示过程:电子枪从左往右,从上往下扫描,扫描完成即出现一帧画面,之后归位到左上角,开始下一帧的扫描。为了同步视频控制器与显示器显示过程,系统使用硬件时钟产生一系列的定时信号:
      • 换行开始新一行的扫描时,触发水平同步信号(horizontal synchronization),简称HSync
      • 归位开始下一帧扫描时,触发垂直同步信号(vertical synchronization),简称 VSync
    • 显示器通常以固定频率进行刷新,此频率即VSync信号的产生频率,也叫场频。对于iOS设备,VSync信号的间隔是16.7ms,也就是1秒60帧。

要在屏幕上显示图像,需要CPU和GPU一起协作,CPU计算好显示的内容提交到GPU,GPU渲染完成后将结果放到帧缓存区,随后视频控制器会定时逐行读取帧缓冲区的数据,经过可能的数模转换传递给显示器显示。

屏幕成像与卡顿

屏幕撕裂和卡顿

在这种单一缓存的模式下,最理想的情况就是一个流畅的流水线:每次电子束从头开始新的一帧的扫描时CPU+GPU 对于该帧的渲染流程已经结束,渲染好的位图已经放入了帧缓冲器中。但这种完美的情况是非常脆弱的,很容易产生屏幕撕裂.

CPU+GPU 的渲染流程是一个非常耗时的过程。如果在电子束开始扫描新的一帧时,位图还没有渲染好,而是在扫描到屏幕中间时才渲染完成,被放入帧缓冲器中 —- 那么已扫描的部分就是上一帧的画面,而未扫描的部分则会显示新的一帧图像,这就造成屏幕撕裂。

若加以限制,比如 GPU 收到 VSync 信号才能将bitmap写入缓冲区,实现读的时候就不能写了,写的时候就不能读了,这样就存在GPU和视频控制器相互等待的问题 所以效率低了,会出现卡顿

双缓冲机制

由于单缓冲区效率较低,一般会引入双缓冲区(F1和F2),GPU渲染一帧后放入F1,让视频控制器读取,下一帧渲染后放入F2,并将视频控制器的指针指向F2,从而实现交替写入和读取,提高效率。

但是在双缓冲区下,如果GPU在视频控制器读取完一帧前就放入下一帧,势必导致帧错乱,用户层面则会看到画面撕裂,因此需要有一个读取完成的信号,GPU等待这个信号后,才进行新的一帧的更新。 这个信号就是 VSync。

双缓冲的机制下,流程如下:

  • 有两个缓冲区 back 和 front
  • 视频控制器一直按照指定频率从front buffer 读取bitmap,读取完毕后,发出VSync信号
  • 收到Vsync 信号,检测 back buffer 是否准备好,
    • 若准备好,则交换 back front 指针,
    • 若没有准备好,不交换指针
  • GPU 收到信号后才会将bitmap写入 back buffer
  • 上述步骤一直重复执行

掉帧和卡顿

手机使用卡顿的直接原因,就是掉帧。前文也说过,屏幕刷新频率必须要足够高才能流畅。对于 iPhone 手机来说,屏幕最大的刷新频率是 60 FPS,一般只要保证 50 FPS 就已经是较好的体验了。但是如果掉帧过多,导致刷新频率过低,就会造成不流畅的使用体验。

那么双缓冲机制下掉帧是如何产生的呢?

当视频控制器从 front buffer 读取完毕发出 Vsync 信号后,发现back buffer还没有准备好,不能切换 back front 导致读取旧的buffer,页面没刷线,即掉帧。根本原因是CPU+GPU运算渲染的时间过长。

当back终于准备好后,会继续切换back front,其实页面没有丢,只不过延迟展示了,一个页面展示了多次。

那有么有这种情况呢?CPU+GPU计算非常快,还没有收到VSync信号,bitmap已经准备好了,这时候怎么办?什么效果?

CPU一定会等到收到VSync 信号后才会将bitmap写入 back buffer,之前应该是在等待,因为执行到这里,app即将进入休眠,等待也没关系,但是若马上被触发不要休眠了怎么办?这里还没有想明白

iOS 中的渲染框架

在硬件基础之上,iOS 中有 Core Graphics、Core Animation、Core Image、OpenGL 等多种软件框架来绘制内容,在 CPU 与 GPU 之间进行了更高层地封装。

  • GPU Driver
    • GPU Driver 是直接和 GPU 交流的代码块,直接与 GPU 连接。
  • OpenGL:
    • 是一个提供了 2D 和 3D 图形渲染的 API,它能和 GPU 密切的配合,最高效地利用 GPU 的能力,实现硬件加速渲染。OpenGL的高效实现(利用了图形加速硬件)一般由显示设备厂商提供,而且非常依赖于该厂商提供的硬件。OpenGL 之上扩展出很多东西,如 Core Graphics 等最终都依赖于 OpenGL,有些情况下为了更高的效率,比如游戏程序,甚至会直接调用 OpenGL 的接口。
  • Core Graphics
    • Core Graphics 是一个强大的二维图像绘制引擎,是 iOS 的核心图形库,常用的比如 CGRect 就定义在这个框架下。
    • draw 中绘图调用的方法就是基于 core Graohics的
  • Core Animation
    • 在 iOS 上,几乎所有的东西都是通过 Core Animation 绘制出来,它的自由度更高,使用范围也更广。
    • Core Animation 是 AppKit 和 UIKit 完美的底层支持,同时也被整合进入 Cocoa 和 Cocoa Touch 的工作流之中,它是 app 界面渲染和构建的最基础架构。Core Animation 的职责就是尽可能快地组合屏幕上不同的可视内容,这个内容是被分解成独立的 layer(iOS 中具体而言就是 CALayer),并且被存储为树状层级结构。这个树也形成了 UIKit 以及在 iOS 应用程序当中你所能在屏幕上看见的一切的基础。
    • 会调用 core Graphics
  • Core Image
    • Core Image 是一个高性能的图像处理分析的框架,它拥有一系列现成的图像滤镜,能对已存在的图像进行高效的处理。

CALayer

用户能看到的屏幕上的内容都由 CALayer 进行管理。那么 CALayer 究竟是如何进行管理的呢?另外在 iOS 开发过程中,最大量使用的视图控件实际上是 UIView 而不是 CALayer,那么他们两者的关系到底如何呢?

  • UIView 继承自 UIResponder,负责响应用户的操作,不具有显示的功能,多层次的view 组成图层树,用于传递用户操作,响应链
  • CALayer 负责展示,但是UIview 持有了CAlyer,多层CALyer 组成图层树,视图树会维护图层树的变化
  • UIview 对CALyer 的部分功能进行了封装

CALayer 与 UIView 的关系

Views are the fundamental building blocks of your app’s user interface, and the UIView class defines the behaviors that are common to all views. A view object renders content within its bounds rectangle and handles any interactions with that content. UIView - Apple

Layers are often used to provide the backing store for views but can also be used without a view to display content. A layer’s main job is to manage the visual content that you provide…

If the layer object was created by a view, the view typically assigns itself as the layer’s delegate automatically, and you should not change that relationship. UILayer - Apple

  • CALayer 的主要职责是管理内部的可视内容
    • 当我们创建一个 UIView 的时候,UIView 会自动创建一个 CALayer
  • UIView 是 app 中的基本组成结构,定义了一些统一的规范。它会负责内容的渲染以及,处理交互事件。
    • 布局与子 view 的管理
    • 点击事件处理
    • 绘制与动画
  • CALayer 是 UIView 的属性之一,负责渲染和动画,提供可视内容的呈现。
  • UIView 提供了对 CALayer 部分功能的封装,同时也另外负责了交互事件的处理。
  • 相同的层级结构:我们对 UIView 的层级结构非常熟悉,由于每个 UIView 都对应 CALayer 负责页面的绘制,所以 CALayer 也具有相应的层级结构。
  • 部分效果的设置:因为 UIView 只对 CALayer 的部分功能进行了封装,而另一部分如圆角、阴影、边框等特效都需要通过调用 layer 属性来设置。
  • 是否响应点击事件:CALayer 不负责点击事件,所以不响应点击事件,而 UIView 会响应。
  • 不同继承关系:CALayer 继承自 NSObject,UIView 由于要负责交互事件,所以继承自 UIResponder。

那为什么要将 CALayer 独立出来,直接使用 UIView 统一管理不行吗?为什么不用一个统一的对象来处理所有事情呢?

这样设计的主要原因就是为了职责分离,拆分功能,方便代码的复用。通过 Core Animation 框架来负责可视内容的呈现,这样在 iOS 和 OS X 上都可以使用 Core Animation 进行渲染。与此同时,两个系统还可以根据交互规则的不同来进一步封装统一的控件,比如 iOS 有 UIKit 和 UIView,OS X 则是AppKit 和 NSView。
即 Layer 抽出来可以服用,上层 iOS OS X用不同的

Core Animation 渲染全内容

整个流水线一共有下面几个步骤:

  • Handle Events
    • 这个过程中会先处理点击事件,这个过程中有可能会需要改变页面的布局和界面层次。
  • Commit Transaction
    • 此时 app 会通过 CPU 处理显示内容的前置计算,比如布局计算、图片解码等任务,接下来会进行详细的讲解。之后将计算好的图层进行打包发给 Render Server。
  • Decode:
    • 打包好的图层被传输到 Render Server 之后,首先会进行解码。注意完成解码之后需要等待下一个 RunLoop 才会执行下一步 Draw Calls
  • Draw Calls:
    • 解码完成后,Core Animation 会调用下层渲染框架(比如 OpenGL 或者 Metal)的方法进行绘制,进而调用到 GPU。
  • Render:
    • 这一阶段主要由 GPU 进行渲染。
  • Display:
    • 显示阶段,需要等 render 结束的下一个 RunLoop 触发显示。

Commit Transaction 发生了什么

一般开发当中能影响到的就是 Handle Events 和 Commit Transaction 这两个阶段,这也是开发者接触最多的部分。Handle Events 就是处理触摸事件,而 Commit Transaction 这部分中主要进行的是:Layout、Display、Prepare、Commit 等四个具体的操作。

  • Layout:构建视图,这个阶段主要处理视图的构建和布局,具体步骤包括:
    • 调用重载的 layoutSubviews 方法
    • 创建视图,并通过 addSubview 方法添加子视图
    • 计算视图布局,即所有的 Layout Constraint
  • Display:绘制视图,交给 Core Graphics 进行视图的绘制,注意不是真正的显示
    • 根据上一阶段 Layout 的结果创建得到图元信息(对图像进行一系列的操作或者改变,最终将新的图像信息传给下一阶段。这部分信息被叫做图元(primitives))。
    • 如果重写了 drawRect: 方法,那么会调用重载的 drawRect: 方法,在 drawRect: 方法中手动绘制得到 bitmap 数据,从而自定义视图的绘制
    • Display 阶段只会得到图元 primitives 信息,而位图 bitmap 是在 GPU 中根据图元信息绘制得到的。但是如果重写了 drawRect: 方法,这个方法会直接调用 Core Graphics 绘制方法得到 bitmap 数据,同时系统会额外申请一块内存,用于暂存绘制好的 bitmap。重写了 drawRect: 方法,导致绘制过程从 GPU 转移到了 CPU,这就导致了一定的效率损失。与此同时,这个过程会额外使用 CPU 和内存,因此需要高效绘制,否则容易造成 CPU 卡顿或者内存爆炸。(这点存在疑问,view等也有layer bitmap 不会爆炸?)
  • Prepare:Core Animation 额外的工作
    • 图片解码和转换
  • Commit:打包并发送
    • 图层打包并发送到 Render Server。
    • 依赖图层树递归执行的,所以如果图层树过于复杂,commit 的开销就会很大。这也是我们希望减少视图层级,从而降低图层树复杂度的原因。

后续就是GPU的工作了,将像素信息进行处理得到 bitmap,之后存入 Render Buffer(缓冲器)

离屏渲染

什么是离屏渲染

正常情况下:App 通过 CPU 和 GPU 的合作,不停地将内容渲染完成放入 Framebuffer 帧缓冲器中,而显示屏幕不断地从 Framebuffer 中获取内容,显示实时的内容。

离屏渲染:先额外创建离屏渲染缓冲区 Offscreen Buffer,将提前渲染好的内容放入其中,等到合适的时机再将 Offscreen Buffer 中的内容进一步叠加、渲染,完成后将结果切换到 Framebuffer 中。

如果要在显示屏上显示内容,我们至少需要一块与屏幕像素数据量一样大的frame buffer,作为像素数据存储区域,而这也是GPU存储渲染结果的地方。如果有时因为面临一些限制,无法把渲染结果直接写入frame buffer,而是先暂存在另外的内存区域,之后再写入frame buffer,那么这个过程被称之为离屏渲染。

CPU 离屏渲染

UIView 的 CALyer 属性有个content 属性,用于存储 bitmap,一般这个属性为 nil,如果出现下面两种,则不为nil:

  • 重写了 drawRect 方法
    • drawRect方法中 CoreGraphics 进行绘画操作得到的是bitmap,存储在layer的content属性中
  • 对view.layer.content进行了赋值
    • [[UIImageView alloc] initWithImage:[UIImage imageNamed:@”test”]] 其实隐藏的对layer的content进行了赋值
      • CPU会负责解码,解码得到的bitmao存储在layer的content属性中

这两种行为均是无法直接绘制到由GPU掌管的frame buffer,只能暂时先放在另一块内存之中,说起来都属于“离屏渲染”。

其实通过CPU渲染就是俗称的“软件渲染”,而真正的离屏渲染发生在GPU。

GPU离屏渲染

有些没有预合成之前不能直接在屏幕中绘制,不得不开辟一块独立于frame buffer的空白内存,先把容器以及其所有子layer依次画好,再把结果画到frame buffer中。这就是GPU的离屏渲染

为什么需要离屏渲染

  • 一些特殊效果需要使用额外的 Offscreen Buffer 来保存渲染的中间状态,所以不得不使用离屏渲染。
  • 出于效率目的,可以将内容提前渲染保存在 Offscreen Buffer 中,达到复用的目的。 对于第一种情况,也就是不得不使用离屏渲染的情况,一般都是系统自动触发的,比如阴影、圆角等等。

为什么离屏渲染代价高

离屏渲染是指图层在被显示之前,GPU在当前屏幕缓冲区以外新开辟一个缓冲区进行渲染操作。离屏渲染耗时是发生在离屏这个动作上面,而不是渲染。为什么离屏这么耗时?原因主要有创建缓冲区和上下文切换。创建新的缓冲区代价都不算大,付出最大代价的是上下文切换。

【上下文切换】 不管是在GPU渲染过程中,还是一直所熟悉的进程切换,上下文切换在哪里都是一个相当耗时的操作。首先我要保存当前屏幕渲染环境,然后切换到一个新的绘制环境,申请绘制资源,初始化环境,然后开始一个绘制,绘制完毕后销毁这个绘制环境,如需要切换到On-Screen Rendering或者再开始一个新的离屏渲染重复之前的操作。

为什么会产生离屏渲染

  • 在VSync(垂直脉冲)信号作用下,视频控制器每隔16.67ms就会去帧缓冲区(当前屏幕缓冲区)读取渲染后的数据;但是有些效果被认为不能直接呈现于屏幕前,而需要在别的地方做额外的处理,进行预合成。
    • 比如图层属性的混合体再没有预合成之前不能直接在屏幕中绘制,所以就需要屏幕外渲染
  • 有些视图渲染后的纹理需要被多次复用,但屏幕内的渲染缓冲区是实时更新的,所以需要通过开辟屏幕外的渲染缓冲区,将视图的内容渲染成纹理并缓存,然后再需要的时候在调入屏幕缓冲区,可以避免多次渲染的开销。

如何检测离屏渲染

模拟器在工作栏上面的Debug -> Color Off-Screen Rendered 真机在工作栏上面的Debug -> View Debugging -> Rendering -> Color Off-Screen Rendered Yellow

怎么优化

哪些行为会产生离屏渲染

  • cornerRadius+clipsToBounds

  • shadow
    • shadow,其原因在于,虽然layer本身是一块矩形区域,但是阴影默认是作用在其中”非透明区域“的,而且需要显示在所有layer内容的下方,因此根据画家算法必须被渲染在先。但矛盾在于此时阴影的本体(layer和其子layer)都还没有被组合到一起,怎么可能在第一步就画出只有完成最后一步之后才能知道的形状呢?这样一来又只能另外申请一块内存,把本体内容都先画好,再根据渲染结果的形状,添加阴影到frame buffer,最后把内容画上去(这只是我的猜测,实际情况可能更复杂)。不过如果我们能够预先告诉CoreAnimation(通过shadowPath属性)阴影的几何形状,那么阴影当然可以先被独立渲染出来,不需要依赖layer本体,也就不再需要离屏渲染了
  • group opacity
  • mask
  • UIBlurEffect

怎么优化

  • cornerRadius+clipsToBounds
   UIButton *btn = [[UIButton alloc]initWithFrame:CGRectMake(130, 330, 100, 100)];  
    [btn setBackgroundColor:[UIColor colorWithRed:(226.0 / 255.0) green:(113.0 / 255.0) blue:(19.0 / 255.0) alpha:1]];  
    [backgroundImageView addSubview:btn];  
    //绘制曲线路径  
    UIBezierPath *maskPath = [UIBezierPath bezierPathWithRoundedRect:btn.bounds   byRoundingCorners:UIRectCornerAllCorners cornerRadii:btn.bounds.size];  
    CAShapeLayer *maskLayer = [[CAShapeLayer alloc]init];  
    //设置大小  
    maskLayer.frame = btn.bounds;  
    //设置图形样子  
    maskLayer.path = maskPath.CGPath;  
    btn.layer.mask = maskLayer;  
   
  • shadow
  • group opacity
  • mask
  • UIBlurEffect

于界面流畅如果想要深层探索可以看 YYKit作者 写的文章iOS 保持界面流畅的技巧 。该文章从屏幕显示图像的原理,到改进的方案都有详细介绍。

为什么必须在主线程操作UI

  • UIKit并不是一个线程安全的类,UI操作涉及到渲染访问各种View对象的属性,如果异步操作下会存在读写问题,而为其加锁则会耗费大量资源并拖慢运行速度。
  • 与渲染原理相关
    • 需要渲染的变动提交到容器->一次Runloop完结 -> Core Animation提交渲染树CA::render::commit 如果多线程,多个runloop,那么渲染频率加快,影响性能
    • 而在渲染方面由于图像的渲染需要以60帧的刷新率在屏幕上同时更新,在非主线程异步化的情况下无法确定这个处理过程能够实现同步更新。即导致用户的操作与响应可能不同步
  • 因为整个程序的起点UIApplication是在主线程进行初始化,所有的用户事件都是在主线程上进行传递(如点击、拖动),所以view只能在主线程上才能对事件进行响应。异步线程容易造成用户操作和响应不同步

参考

  • https://juejin.im/post/5c406d97e51d4552475fe178
  • https://www.jianshu.com/p/57e2ec17585b
  • https://zhuanlan.zhihu.com/p/72653360
  • https://www.jianshu.com/p/30f93a9f9540
  • http://chuquan.me/2018/09/25/ios-graphics-render-principle/
  • https://juejin.im/post/6844903763011076110