洋仔的博客 洋仔的博客
首页
  • 个人心法总结

    • 价值心法
  • 技术文档
  • GitHub技巧
  • Nodejs
  • 博客搭建
  • iOS基础知识
  • 前端
  • 学习
  • 面试
  • 心情杂货
  • 实用技巧
  • 投资体系
  • 毛选
  • 友情链接
关于
收藏
  • 分类
  • 标签
  • 归档
GitHub (opens new window)

洋仔

奋斗的小青年
首页
  • 个人心法总结

    • 价值心法
  • 技术文档
  • GitHub技巧
  • Nodejs
  • 博客搭建
  • iOS基础知识
  • 前端
  • 学习
  • 面试
  • 心情杂货
  • 实用技巧
  • 投资体系
  • 毛选
  • 友情链接
关于
收藏
  • 分类
  • 标签
  • 归档
GitHub (opens new window)
  • 技术文档

  • GitHub技巧

  • Nodejs

  • 博客搭建

  • iOS基础知识

    • iOS底层相关

    • Runloop系列

    • Runtime系列

    • 内存管理系列

    • Block系列

    • 线程系列

    • KVC跟KVO系列以及通知中心

    • UI系列

    • 离屏渲染系列

      • 离屏渲染
        • 为什么要理解离屏渲染
        • 显示器显示原理
        • 屏幕成像过程
        • 屏幕撕裂的原因
        • 苹果官方的解决方案
          • 掉帧
        • iOS的渲染
          • iOS中渲染框架总结
        • iOS离屏渲染 &&在屏渲染
          • 正常渲染流程
          • 离屏渲染流程
          • 离屏渲染的另一个原因:光栅化
          • 圆角中离屏渲染的触发时机
          • 圆角设置不生效问题!
        • 如何减少离屏渲染
        • iOS中图像显示原理 == 图像加载过程
        • 图片的解压缩
        • 离屏渲染为何性能消耗高
        • 卡顿发生的原因
          • 监控卡顿的指标: FPS(帧率)
        • 问题汇总
          • 对视图的性能优化有一些了解,你可不可以先说下 图像显示相关的原理
          • 既然了解图像显示的原理,那你知道IOS视图卡顿掉帧的原因吗?
          • 那你知道如何监控界面的卡顿吗
          • 如何优化掉帧卡顿
      • 图片相关的问题
    • 组件化系列跟架构

    • OC跟webview交互系列

    • 持久化系列

    • APP编译系列

    • APP性能优化系列

    • cocoapods系列

    • swift系列

    • Git系列

    • 网络相关

    • 三方库系列

    • 系统原理

    • 总结系列

    • 算法系列

    • 数据结构系列

  • 前端

  • 技术
  • iOS基础知识
  • 离屏渲染系列
洋仔
2023-08-11
目录

离屏渲染

# 为什么要理解离屏渲染

注意

离屏渲染(Offscreen rendering)对iOS开发者来说不是一个陌生的东西,项目中或多或少都会存在离屏渲染,也是面试中经常考察的知识点。一般来说,大多数人都能知道设置圆角、mask、阴影等会触发离屏渲染,但我们深入的探究一下,大家能够很清楚的知道下面几个问题吗?

离屏渲染是在哪一步发生的吗?

离屏渲染产生的原因是什么呢?

设置圆角一定会触发离屏渲染吗?

离屏渲染既然会影响性能我们为什么还要使用呢?

优化方案又有那些?

# 显示器显示原理

以过去的CRT显示原理为例: 介绍屏幕图像显示的原理,需要先从 CRT 显示器原理说起,如下图所示 CRT原理图

  • CRT 的电子枪从上到下逐行扫描,扫描完成后显示器就呈现一帧画面。 然后电子枪回到初始位置进行下一次扫描。

  • 为了同步显示器的显示过程和系统的视频控制器,显示器会用硬件时钟产生一系列的定时信号。当电子枪换行进行扫描时,显示器会发出一个水平同步信号(horizonal synchronization),简称 HSync;

  • 而当一帧画面绘制完成后,电子枪回复到原位,准备画下一帧前,显示器会发出一个垂直同步信号(vertical synchronization),简称 VSync。

  • 显示器通常以固定频率进行刷新,这个刷新率就是 VSync 信号产生的频率 又称帧率。

  • 虽然现在的显示器基本都是液晶显示屏了,但其原理基本一致。

# 屏幕成像过程

Screenshot-2023-08-13-at-8

  • 将需要显示的图像,经由GPU渲染
  • 将渲染后的结果,存储到帧缓存区,帧缓存区中存储的格式是位图
  • 由视屏控制器从帧缓存区中读取位图,交由显示器,从左上角逐行扫描进行显示

# 屏幕撕裂的原因

  • 在屏幕显示图形图像的过程中,是不断从帧缓存区获取一帧一帧数据进行显示的,
  • 然后在渲染的过程中,帧缓存区中仍是旧的数据,屏幕拿到旧的数据去进行显示,
  • 在旧的数据没有读取完时 ,新的一帧数据处理好了,放入了缓存区,这时就会导致屏幕另一部分的显示是获取的线数据,从而导致屏幕上呈现图片不匹配,人物、景象等错位显示的情况。

Screenshot-2023-08-13-at-8

垂直同步:是指给帧缓冲加锁,当电子光束扫描的过程中,只有扫描完成了才会读取下一帧的数据,而不是只读取一部分

双缓冲区:采用两个帧缓冲区用途GPU处理结果的存储,当屏幕显示其中一个缓存区内容时,另一个缓冲区继续等待下一个缓冲结果,两个缓冲区依次进行交替

# 苹果官方的解决方案

苹果官方针对屏幕撕裂现象,目前一直采用的是 垂直同步+双缓存,该方案是强制要求同步,且是以掉帧为代价的。

以下是垂直同步+双缓存的一个图解过程,如有描述错误的地方,欢迎留言指出

好图

垂直同步:是指给帧缓冲加锁,当电子光束扫描的过程中,只有扫描完成了才会读取下一帧的数据,而不是只读取一部分

双缓冲区:采用两个帧缓冲区用途GPU处理结果的存储,当屏幕显示其中一个缓存区内容时,另一个缓冲区继续等待下一个缓冲结果,两个缓冲区依次进行交替

# 掉帧

采用苹果的双缓冲区方案后,又会出现新的问题,掉帧。 什么是掉帧?简单来说就是 屏幕重复显示同一帧数据的情况就是掉帧

如图所示:当前屏幕显示的是A,在收到垂直信号后,CPU和GPU处理的B还没有准备好,此时,屏幕显示的仍然是A

掉帧

针对掉帧情况,我们可以在苹果方案的基础上进行优化,即采用三缓存区,意味着,在屏幕显示时,后面还准备了3个数据用于显示。

# iOS的渲染

在iOS中渲染的整体流程如下所示 Screenshot-2023-08-13-at-8

  • App通过调用CoreGraphics、CoreAnimation、CoreImage等框架的接口触发图形渲染操作
  • CoreGraphics、CoreAnimation、CoreImage等框架将渲染交由OpenGL ES,由OpenGL ES来驱动GPU做渲染,最后显示到屏幕上
  • 由于OpenGL ES 是跨平台的,所以在他的实现中,是不能有任何窗口相关的代码,而是让各自的平台为OpenGL ES提供载体。在ios中,如果需要使用OpenGL ES,就是通过CoreAnimation提供窗口,让App可以去调用。

# iOS中渲染框架总结

渲染总结

# iOS离屏渲染 &&在屏渲染

屏幕上最终显示的数据有两种加载流程

正常渲染加载流程

离屏渲染加载流程

Screenshot-2023-08-13-at-8

从图上看,他们之间的区别就是离屏渲染比正常渲染多了一个离屏缓冲区,这个缓冲区的作用是什么呢?下面来仔细说说 首先,说说正常渲染流程

# 正常渲染流程

如果是不触发离屏渲染的正常渲染,苹果在绘制完最底层的图层,从帧缓冲区显示到屏幕上之后,就丢弃了,并不会保存起来,再画第二层,也是如此,显示完了就丢弃,从而节省了空间。 8488628-57584ba10c3fbf7a

这张图演示的是著名的油画算法。指的是先绘制远的部分,再绘制近的部分。我们图层的渲染也是同理,系统会先绘制最底层的图层,再往上一层层的绘制,最后形成了图层树,苹果建议开发者在建立UI的时候,图层树不要太复杂,层级不要太多,不然也是会有性能影响。 如果是不触发离屏渲染的正常渲染,苹果在绘制完最底层的图层,从帧缓冲区显示到屏幕上之后,就丢弃了,并不会保存起来,再画第二层,也是如此,显示完了就丢弃,从而节省了空间。

Screenshot-2023-08-13-at-9

# 离屏渲染流程

如果对一个多图层图像进行圆角处理,就需要对所有图层进行圆角(包括内容contents),如果按照正常渲染,一层用完就丢弃,这样就达不到显示的效果。这时候就需要开辟一个离屏缓冲区去保存这些图层,等到所有图层都做了圆角处理,就把它们从离屏缓冲区里取出来进行合并显示。

离屏渲染

说白了,离屏缓存区就是一个临时的缓冲区,用来存放在后续操作使用,但目前并不使用的数据。

离屏渲染再给我们带来方便的同时,也带来了严重的性能问题。由于离屏渲染中的离屏缓冲区,是额外开辟的一个存储空间,当它将数据转存到Frame Buffer时,也是需要耗费时间的,所以在转存的过程中,仍有掉帧的可能。 离屏缓冲区的空间并不是无限大的, 它是有上限的,最大只能是屏幕的2.5倍 那为什么我们明知有性能问题时,还是要使用离屏渲染呢?

可以处理一些特殊的效果,这种效果并不能一次就完成,需要使用离屏缓冲区来保存中间状态,不得不使用离屏渲染,这种情况下的离屏渲染是系统自动触发的,例如经常使用的圆角、阴影、高斯模糊、光栅化等 可以提升渲染的效率,如果一个效果是多次实现的,可以提前渲染,保存到离屏缓冲区,以达到复用的目的。这种情况是需要开发者手动触发的。

# 离屏渲染的另一个原因:光栅化

提示

When the value of this property is YES, the layer is rendered as a bitmap in its local coordinate space and then composited to the destination with any other content.

当我们开启光栅化时,会将layer渲染成位图保存在缓存中,这样在下次使用时,就可以直接复用,提高效率。 针对光栅化的使用,有以下几个建议:

  • 如果layer不是静态,需要被频繁修改,比如处于动画中、size修改、tableView、collectionView视图中,那么开启光栅化反而影响了效率;

  • 离屏渲染缓存内容有时间限制,缓存的内容如果100ms内没有被使用,那么它就会丢弃,无法进行复用;

  • 离屏渲染的缓存空间有限,大小相当于屏幕像素点的2.5倍,超过的话也会失效,无法进行复用了;

# 圆角中离屏渲染的触发时机

在讲圆角之前,首先说明下CALayer的构成,如图所示,它是由backgroundColor、contents、borderWidth&borderColor构成的。跟我们即将解释的圆角触发离屏渲染息息相关。

Screenshot-2023-08-13-at-9

# 圆角设置不生效问题!

在平常写代码时,比如UIButton设置圆角,当设置好按钮的image、cornerRadius、borderWidth、borderColor等属性后,运行发现并没有实现我们想要的效果

  let btn0 = UIButton(type: .custom)
  btn0.frame = CGRect(x: 100, y: 60, width: 100, height: 100)
  //设置圆角
  btn0.layer.cornerRadius = 50
  //设置border宽度和颜色
  btn0.layer.borderWidth = 2
  btn0.layer.borderColor = UIColor.red.cgColor
  self.view.addSubview(btn0)
  //设置背景图片
  btn0.setImage(UIImage(named: "mouse"), for: .normal)
1
2
3
4
5
6
7
8
9
10

此时的效果就是这样的,可以发现,我们设置的按钮图片还是方方正正的

Screenshot-2023-08-13-at-9

针对上面的这个问题,我相信99%的人都能信手拈来,知道必须要设置masksToBounds为 true,才会得到我们想要的效果。解决的方法很简单,但原理是大部人都没有去仔细研究的。

下面是苹果官方文档针对圆角设置的一些说明

苹果官网

官方文档告诉我们,设置cornerRadius只会对CALayer中的backgroundColor 和 boder设置圆角,不会设置contents的圆角,如果contents需要设置圆角,需要同时将maskToBounds / clipsToBounds设置为true。

所以我们可以理解为圆角不生效的根本原因是没有对contents设置圆角,而按钮设置的image是放在contents里面的,所以看到的界面上的就是image没有进行圆角裁剪。

下面我们通过几段代码来说明 圆角设置中什么时候会离屏渲染触发 首先,需要打开模拟器的离屏渲染颜色标记

Screenshot-2023-08-13-at-9

# 1、按钮 仅设置背景颜色+border

  let btn01 = UIButton(type: .custom)
  btn01.frame = CGRect(x: 100, y: 200, width: 100, height: 100)
  //设置圆角
  btn01.layer.cornerRadius = 50
  //设置border宽度和颜色
  btn01.layer.borderWidth = 4
  btn01.layer.borderColor = UIColor.red.cgColor
  self.view.addSubview(btn01)
  //设置背景颜色
  btn01.backgroundColor = UIColor.green
1
2
3
4
5
6
7
8
9
10

在这种情况下,无论是使用默认的maskToBounds / clipsToBounds(false),还是将其修改为true,都不会触发离屏渲染,究其根本原因是 contents中没有需要圆角处理的layer。

Screenshot-2023-08-13-at-9

# 按钮设置背景图片+boder

let btn0 = UIButton(type: .custom)
btn0.frame = CGRect(x: 100, y: 60, width: 100, height: 100)
//设置圆角
btn0.layer.cornerRadius = 50
//设置border宽度和颜色
btn0.layer.borderWidth = 2
btn0.layer.borderColor = UIColor.red.cgColor
self.view.addSubview(btn0)
//设置背景图片
btn0.setImage(UIImage(named: "mouse"), for: .normal)
1
2
3
4
5
6
7
8
9
10

使用默认的maskToBounds / clipsToBounds(false) 这种情况就是最开始我们讲到的圆角设置不生效的情况,就不再多做说明了

maskToBounds / clipsToBounds 修改为true Screenshot-2023-08-13-at-9

从屏幕的显示上可以看出,此时触发了离屏渲染,是因为圆角的设置是需要对所有layer都进行裁剪的,而maskToBounds裁剪是应用到所有layer上的。如果从正常渲染的角度来说,一个个layer是用完即扔的。而现在我们的圆角设置需要3个layer叠加合并的,所以将先处理好的layer保存在离屏缓冲区,等到最后一个layer处理完,合并进行圆角+裁剪,所以才会触发离屏渲染

总结

当只设置backgroundColor、border,而contents中没有子视图时,无论maskToBounds / clipsToBounds是true还是false,都不会触发离屏渲染 当contents中有子视图时,此时设置 cornerRadius+maskToBounds / clipsToBounds,就会触发离屏渲染

# 如何减少离屏渲染

RoundedCorner(圆角) 在仅指定cornerRadius时不会触发离屏渲染,仅适用于特殊情况:contents为 nil 或者contents不会遮挡背景色时的圆角。 最简单的就是让UI同事切一个带圆角的图。

Shawdow 可以通过指定路径来取消离屏渲染。

Mask 无法取消离屏渲染,可以利用混合图层来进行优化。

如果内容contents没有内容,只有背景色,就不用使用masksToBounds/ClipsToBounds了。直接设置cornerRadius就好了。

//按钮设置背景图,会离屏
UIButton *btn1 = [UIButton buttonWithType:UIButtonTypeCustom];
btn1.frame = CGRectMake(100, 100, 100, 100);
[btn1 setImage:[UIImage imageNamed:@"测试.png"] forState:UIControlStateNormal];
btn1.layer.cornerRadius = 20;
btn1.layer.masksToBounds = YES;
[self.view addSubview:btn1];

//改为对button上的imageView裁剪圆角 这样就不会离屏渲染了
UIButton *btn2 = [UIButton buttonWithType:UIButtonTypeCustom];
btn2.frame = CGRectMake(100, 220, 100, 100);
[btn2 setImage:[UIImage imageNamed:@"测试.png"] forState:UIControlStateNormal];
btn2.imageView.layer.cornerRadius = 20;
btn2.imageView.layer.masksToBounds = YES;
[self.view addSubview:btn2];
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

# iOS中图像显示原理 == 图像加载过程

iOS从磁盘加载一张图片,使用UIImageView显示在屏幕上,加载流程如下:

我们使用 +imageWithContentsOfFile:(使用Image I/O创建CGImageRef内存映射数据)方法从磁盘中加载一张图片。此时,图片尚未解码。在这个过程中先从磁盘拷贝数据到内核缓冲区,再从内核缓冲区复制数据到用户空间。

生成UIImageView,把图像数据赋值给UIImageView,如果图像数据未解码(PNG/JPG),解码为位图数据。(什么是位图?别着急,往下看) 隐式CATransaction 捕获到UIImageView layer树的变化。

在主线程的下一个 runloop 到来时,Core Animation 提交了这个隐式的 transaction ,这个过程可能会对图片进行 copy 操作,而受图片是否字节对齐等因素的影响,这个 copy 操作可能会涉及以下部分或全部步骤:

  • 分配内存缓冲区用于管理文件 IO 和解压缩操作;

  • 将文件数据从磁盘读到内存中;

  • 将压缩的图片数据解码成未压缩的位图形式,这是一个非常耗时的 CPU 操作;

  • 然后 Core Animation 中CALayer使用未压缩的位图数据渲染 UIImageView 的图层。

  • 最后CPU计算好图片的Frame,对图片解压之后.就会交给GPU来做图片渲染。

渲染流程

  • GPU获取获取图片的坐标

  • 将坐标交给顶点着色器(顶点计算)

  • 将图片光栅化(获取图片对应屏幕上的像素点)

  • 片元着色器计算(计算每个像素点的最终显示的颜色值)

  • 从帧缓存区中渲染到屏幕上

在最简单的情况下,帧缓冲区只有一个,这时帧缓冲区的读取和刷新都都会有比较大的效率问题。为了解决效率问题,显示系统通常会引入两个缓冲区,即双缓冲机制。在这种情况下,GPU 会预先渲染好一帧放入一个缓冲区内,让视频控制器读取,当下一帧渲染好后,GPU 会直接把视频控制器的指针指向第二个缓冲器。如此一来效率会有很大的提升。

双缓冲虽然能解决效率问题,但会引入一个新的问题。当视频控制器还未读取完成时,即屏幕内容刚显示一半时,GPU 将新的一帧内容提交到帧缓冲区并把两个缓冲区进行交换后,视频控制器就会把新的一帧数据的下半段显示到屏幕上,造成画面撕裂现象。

为了解决这个问题,GPU 通常有一个机制叫做垂直同步(简写也是 V-Sync),当开启垂直同步后,GPU 会等待显示器的 VSync 信号发出后,才进行新的一帧渲染和缓冲区更新。这样能解决画面撕裂现象,也增加了画面流畅度,但需要消费更多的计算资源,也会带来部分延迟。

从上面我们也可以看到通常计算机在显示是CPU与GPU协同合作完成一次渲染.接下来我们了解一下CPU/GPU等在这样一次渲染过程中,具体的分工是什么?

  • CPU: 计算视图frame,图片解码,需要绘制纹理图片通过数据总线交给GPU
  • GPU: 纹理混合,顶点变换与计算,像素点的填充计算,渲染到帧缓冲区
  • 时钟信号:垂直同步信号V-Sync / 水平同步信号H-Sync
  • iOS设备双缓冲机制:显示系统通常会引入两个帧缓冲区,双缓冲机制(从相关资料可以知道,iOS 设备会始终使用双缓存,并开启垂直同步。而安卓- 设备直到 4.1 版本,Google 才开始引入这种机制,目前安卓系统是三缓存+垂直同步) 图片显示到屏幕上是CPU与GPU的协作完成

对应用来说,图片是最占用手机内存的资源,将一张图片从磁盘中加载出来,并最终显示到屏幕上,中间其实经过了一系列复杂的处理过程。

# 图片的解压缩

我们上面提到了图片的解压缩是一个非常耗时的 CPU 操作,并且它默认是在主线程中执行的。那么当需要加载的图片比较多时,就会对我们应用的响应性造成严重的影响,尤其是在快速滑动的列表上,这个问题会表现得更加突出。接下来就让我们来看下图片的解压缩过程。

图片的本质就是由许多的像素点构成的,而前面所说的位图实际上就是一个装着这些像素点的数组。而我们平时开发经常用的PNG或者JPG图片,都是一种压缩的位图图形格式。只不过 PNG图片是无损压缩,并且支持 alpha 通道。而 JPEG 图片则是有损压缩,可以指定 0-100% 的压缩比。苹果提供了以下这两个函数用来生成 PNG 和 JPEG 图片,想必大家也不陌生:

// return image as PNG. May return nil if image has no CGImageRef or invalid bitmap format
UIKIT_EXTERN NSData * __nullable UIImagePNGRepresentation(UIImage * __nonnull image);

// return image as JPEG. May return nil if image has no CGImageRef or invalid bitmap format. compression is 0(most)..1(least)                           
UIKIT_EXTERN NSData * __nullable UIImageJPEGRepresentation(UIImage * __nonnull image, CGFloat compressionQuality);
1
2
3
4
5

# 离屏渲染为何性能消耗高

  1. 创建新缓冲区

要想进行离屏渲染,首先要创建一个新的缓冲区。

  1. 上下文切换

离屏渲染的整个过程,需要多次切换上下文环境:先是从当前屏幕(On-Screen)切换到离屏(Off-Screen);等到离屏渲染结束以后,将离屏缓冲区的渲染结果显示到屏幕上有需要将上下文环境从离屏切换到当前屏幕。而上下文环境的切换是要付出很大代价的。

那哪些情况会Offscreen Render呢?

提示

  1. drawRect
  2. layer.shouldRasterize = true;
  3. 有mask或者是阴影(layer.masksToBounds, layer.shadow*);
    • 3.1 shouldRasterize(光栅化)
    • 3.2 masks(遮罩)
    • 3.3 shadows(阴影)
    • 3.4 edge antialiasing(抗锯齿)
    • 3.5 group opacity(不透明)
  4. Text(UILabel, CATextLayer, Core Text, etc)...

注:layer.cornerRadius,layer.borderWidth,layer.borderColor并不会Offscreen Render,因为这些不需要加入Mask。

需要注意的是,如果shouldRasterize被设置成YES,在触发离屏绘制的同时,会将光栅化后的内容缓存起来,如果对应的layer及其sublayers没有发生改变,在下一帧的时候可以直接复用。这将在很大程度上提升渲染性能。

而其它属性如果是开启的,就不会有缓存,离屏绘制会在每一帧都发生。

另一种特殊的“离屏渲染”方式:CPU渲染

如果我们重写了drawRect方法,并且使用任何Core Graphics的技术进行了绘制操作,就涉及到了CPU渲染。整个渲染过程由CPU在App内同步地

完成,渲染得到的bitmap最后再交由GPU用于显示。

当前屏幕渲染、离屏渲染、CPU渲染的选择

1.尽量使用当前屏幕渲染

鉴于离屏渲染、CPU渲染可能带来的性能问题,一般情况下,我们要尽量使用当前屏幕渲染。

2.离屏渲染 VS CPU渲染

由于GPU的浮点运算能力比CPU强,CPU渲染的效率可能不如离屏渲染;但如果仅仅是实现一个简单的效果,直接使用CPU渲染的效率又可能比离屏渲染好,毕竟离屏渲染要涉及到缓冲区创建和上下文切换等耗时操作。

# 卡顿发生的原因

卡顿: 所谓卡顿,就是App在主线程阻塞,页面交互无法响,用户体验差的现象。 如果一个App出现了长时间的卡顿,那么极有可能流失大量用户;所以卡顿对App的负面影响巨大,是我们必须要面对并解决的问题;

卡顿的发生通常有以下几个原因:

  • UI过于复杂,图文混排的绘制量过大;

  • 在主线程上进行同步的网络请求;

  • 在主线程上进行大量的IO操作;

  • 函数运算量过大,持续占用较高的CPU;

  • 死锁和主子线程抢锁;

# 监控卡顿的指标: FPS(帧率)

提示

FPS是一秒钟显示的帧数,也就是一秒内画面变化的数量。如果按照我们经常看的动画片来说,那么动画片的FPS就是24,是达不到满帧的60的。也就是对于动画片来说,24帧虽然不足60帧,也没有60帧来的流畅,但是对于我们来说已经是连贯的了,所以并不是24帧就会卡顿,少于60帧更不能算是卡顿;

# 问题汇总

# 对视图的性能优化有一些了解,你可不可以先说下 图像显示相关的原理

iOS系统中 CPU、GPU、显示器是以下面图中方式协同工作的。CPU和GPU是通过总线链接起来的,CPU 计算好显示内容提交到 GPU,GPU 渲染完成后将渲染结果放入帧缓冲区,视频控制器会按照 VSync 信号逐行读取帧缓冲区的数据,经过数模转换传递给显示器显示。 下图就是图像显示的流程:

Screenshot-2023-08-19-at-20

关于CPU和GPU的分工又有以下内容:

CPU负责:

  • 对象创建和销毁
  • 对象属性调整
  • 布局计算、文本的计算
  • 排版、图片的格式转换和解码
  • 图像的绘制

GPU负责:

  • 纹理的渲染
  • 视图的混合
  • 图形的生成

# 既然了解图像显示的原理,那你知道IOS视图卡顿掉帧的原因吗?

Screenshot-2023-08-19-at-20

标准情况下,页面滑动流畅是60FPs ,就是每一秒有60帧的画面刷新,每16.7ms(1/60秒)有一帧数据。上图两个VSync 之间的时间就是16.7ms。 如果CPU 和 GPU 加起来的处理时间超过了 16.7ms,就会造成掉帧甚至卡顿。当FPs 帧数低于30时,人的肉眼就能感觉到画面明显的卡顿。

# 那你知道如何监控界面的卡顿吗

既然知道了造成卡顿的原因,监控卡顿的思路就有了。

思路一监控FPS:一般来说,我们约定60FPS即为流畅。那么反过来,如果App在运行期间出现了掉帧,即可认为出现了卡顿。

思路二监控RunLoop:监控每一帧的时长是否超时。

思路程三Ping主线程:Ping主线程的核心思想是向主线程发送一个信号,一定时间内收到了主线程的回复,即表示当前主线程流畅运行。没有收到主线程的回复,即表示当前主线程在做耗时运算,发生了卡顿。

# 方案一:监控FPS

一般来说,我们约定60FPS即为流畅。那么反过来,如果App在运行期间出现了掉帧,即可认为出现了卡顿。

监控FPS的方案几乎都是基于CADisplayLink实现的。简单介绍一下CADisplayLink:CADisplayLink是一个和屏幕刷新率保持一致的定时器,一但 CADisplayLink 以特定的模式注册到runloop之后,每当屏幕需要刷新的时候,runloop就会调用CADisplayLink绑定的target上的selector。

可以通过向RunLoop中添加CADisplayLink,根据其回调来计算出当前画面的帧数。

点击查看
#import "FPSMonitor.h"
#import <UIKit/UIKit.h>

@interface FPSMonitor ()
@property (nonatomic, strong) CADisplayLink* link;
@property (nonatomic, assign) NSInteger count;
@property (nonatomic, assign) NSTimeInterval lastTime;
@end

@implementation FPSMonitor

- (void)beginMonitor {
    _link = [CADisplayLink displayLinkWithTarget:self selector:@selector(fpsInfoCaculate:)];
    [_link addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes];

}

- (void)fpsInfoCaculate:(CADisplayLink *)sender {
    if (_lastTime == 0) {
        _lastTime = sender.timestamp;
        return;
    }
    _count++;
    double deltaTime = sender.timestamp - _lastTime;
    if (deltaTime >= 1) {
        NSInteger FPS = _count / deltaTime;
        _lastTime = sender.timestamp;
        _count = 0;
        NSLog(@"FPS: %li", (NSInteger)ceill(FPS + 0.5));
    }
}

@end
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33

FPS的好处就是直观,小手一划后FPS下降了,说明页面的某处有性能问题。坏处就是只知道这是页面的某处,不能准确定位到具体的堆栈。

# 方案二:监控RunLoop

首先知道RunLoop的六个状态

提示

typedef CF_OPTIONS(CFOptionFlags, CFRunLoopActivity) {
    kCFRunLoopEntry         = (1UL << 0), // 即将进入Loop
    kCFRunLoopBeforeTimers  = (1UL << 1), // 即将处理 Timer
    kCFRunLoopBeforeSources = (1UL << 2), // 即将处理 Source
    kCFRunLoopBeforeWaiting = (1UL << 5), // 即将进入休眠
    kCFRunLoopAfterWaiting  = (1UL << 6), // 刚从休眠中唤醒
    kCFRunLoopExit          = (1UL << 7), // 即将退出Loop
kCFRunLoopAllActivities // loop所有状态改变

};
1
2
3
4
5
6
7
8
9
10

要想监听RunLoop,你就首先需要创建一个 CFRunLoopObserverContext 观察者,代码如下:

提示

- (void)registerObserver {

    CFRunLoopObserverContext context = {0,(__bridge void*)self,NULL,NULL};
    //创建Run loop observer对象
    //第一个参数用于分配observer对象的内存
    //第二个参数用以设置observer所要关注的事件,详见回调函数myRunLoopObserver中注释
    //第三个参数用于标识该observer是在第一次进入run loop时执行还是每次进入run loop处理时均执行
    //第四个参数用于设置该observer的优先级
    //第五个参数用于设置该observer的回调函数
    //第六个参数用于设置该observer的运行环境
    CFRunLoopObserverRef observer = CFRunLoopObserverCreate(kCFAllocatorDefault,
                                                            kCFRunLoopAllActivities,
                                                            YES,
                                                            0,
                                                            &runLoopObserverCallBack,
                                                            &context);
    CFRunLoopAddObserver(CFRunLoopGetMain(), observer, kCFRunLoopCommonModes);

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

实时获取变化的回调的方法:

//每当runloop状态变化的触发这个回调方法
static void runLoopObserverCallBack(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info) {
    MyClass *object = (__bridge MyClass*)info;
    object->activity = activity;
}
1
2
3
4
5

其中UI主要集中在

_CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION(source0)和

CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE1_PERFORM_FUNCTION(source1)之前。

获取kCFRunLoopBeforeSources到kCFRunLoopBeforeWaiting再到kCFRunLoopAfterWaiting的状态就可以知道是否有卡顿的情况。

be9a2b9fd4f7bc85364b92d07a393ed5

根据这张图可以看出:RunLoop在BeforeSources和AfterWaiting后会进行任务的处理。可以在此时阻塞监控线程并设置超时时间,若超时后RunLoop的状态仍为RunLoop在BeforeSources或AfterWaiting,表明此时RunLoop仍然在处理任务,主线程发生了卡顿。

点击查看
- (void)beginMonitor {
    self.dispatchSemaphore = dispatch_semaphore_create(0);
    // 第一个监控,监控是否处于 运行状态
    CFRunLoopObserverContext context = {0, (__bridge void *) self, NULL, NULL, NULL};
    self.runLoopBeginObserver = CFRunLoopObserverCreate(kCFAllocatorDefault,
                                                        kCFRunLoopAllActivities,
                                                        YES,
                                                        LONG_MIN,
                                                        &myRunLoopBeginCallback,
                                                        &context);
    //  第二个监控,监控是否处于 睡眠状态
    self.runLoopEndObserver = CFRunLoopObserverCreate(kCFAllocatorDefault,
                                                      kCFRunLoopAllActivities,
                                                      YES,
                                                      LONG_MAX,
                                                      &myRunLoopEndCallback,
                                                      &context);
    CFRunLoopAddObserver(CFRunLoopGetMain(), self.runLoopBeginObserver, kCFRunLoopCommonModes);
    CFRunLoopAddObserver(CFRunLoopGetMain(), self.runLoopEndObserver, kCFRunLoopCommonModes);

    // 创建子线程监控
    dispatch_async(dispatch_get_global_queue(0, 0), ^{
        //子线程开启一个持续的loop用来进行监控
        while (YES) {
            long semaphoreWait = dispatch_semaphore_wait(self.dispatchSemaphore, dispatch_time(DISPATCH_TIME_NOW, 17 * NSEC_PER_MSEC));
            if (semaphoreWait != 0) {
                if (!self.runLoopBeginObserver || !self.runLoopEndObserver) {
                    self.timeoutCount = 0;
                    self.dispatchSemaphore = 0;
                    self.runLoopBeginActivity = 0;
                    self.runLoopEndActivity = 0;
                    return;
                }
                // 两个runloop的状态,BeforeSources和AfterWaiting这两个状态区间时间能够检测到是否卡顿
                if ((self.runLoopBeginActivity == kCFRunLoopBeforeSources || self.runLoopBeginActivity == kCFRunLoopAfterWaiting) ||
                    (self.runLoopEndActivity == kCFRunLoopBeforeSources || self.runLoopEndActivity == kCFRunLoopAfterWaiting)) {
                    // 出现三次出结果
                    if (++self.timeoutCount < 2) {
                        continue;
                    }
                    NSLog(@"调试:监测到卡顿");
                } // end activity
            }// end semaphore wait
            self.timeoutCount = 0;
        }// end while
    });
}

// 第一个监控,监控是否处于 运行状态
void myRunLoopBeginCallback(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info) {
    RunLoopMonitor2* lagMonitor = (__bridge RunLoopMonitor2 *)info;
    lagMonitor.runLoopBeginActivity = activity;
    dispatch_semaphore_t semaphore = lagMonitor.dispatchSemaphore;
    dispatch_semaphore_signal(semaphore);
}

//  第二个监控,监控是否处于 睡眠状态
void myRunLoopEndCallback(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info) {
    RunLoopMonitor2* lagMonitor = (__bridge RunLoopMonitor2 *)info;
    lagMonitor.runLoopEndActivity = activity;
    dispatch_semaphore_t semaphore = lagMonitor.dispatchSemaphore;
    dispatch_semaphore_signal(semaphore);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63

UIApplicationMain函数内部会启动主线程的RunLoop,使得iOS程序持续运行。

iOS系统中有两套API来使用RunLoop,NSRunLoop(CFRunLoopRef的封装)和CFRunLoopRef。Foundation框架是不开源的,可以通过开源的CoreFoundation来分析RunLoop内部实现。

# 方案三:Ping主线程

Ping主线程的核心思想是向主线程发送一个信号,一定时间内收到了主线程的回复,即表示当前主线程流畅运行。没有收到主线程的回复,即表示当前主线程在做耗时运算,发生了卡顿。

目前昆虫线上使用的就是这套方案。

点击查看
self.semaphore = dispatch_semaphore_create(0);
- (void)main {
    //判断是否需要上报
    __weak typeof(self) weakSelf = self;
    void (^ verifyReport)(void) = ^() {
        __strong typeof(weakSelf) strongSelf = weakSelf;
        if (strongSelf.reportInfo.length > 0) {
            if (strongSelf.handler) {
                double responseTimeValue = floor([[NSDate date] timeIntervalSince1970] * 1000);
                double duration = responseTimeValue - strongSelf.startTimeValue;
                if (DEBUG) {
                    NSLog(@"卡了%f,堆栈为--%@", duration, strongSelf.reportInfo);
                }
                strongSelf.handler(@{
                    @"title": [InsectUtil dateFormatNow].length > 0 ? [InsectUtil dateFormatNow] : @"",
                    @"duration": [NSString stringWithFormat:@"%.2f",duration],
                    @"content": strongSelf.reportInfo
                                   });
            }
            strongSelf.reportInfo = @"";
        }
    };

    while (!self.cancelled) {
        if (_isApplicationInActive) {
            self.mainThreadBlock = YES;
            self.reportInfo = @"";
            self.startTimeValue = floor([[NSDate date] timeIntervalSince1970] * 1000);
            dispatch_async(dispatch_get_main_queue(), ^{
                self.mainThreadBlock = NO;
                dispatch_semaphore_signal(self.semaphore);
            });
            [NSThread sleepForTimeInterval:(self.threshold/1000)];
            if (self.isMainThreadBlock) {
                self.reportInfo = [InsectBacktraceLogger insect_backtraceOfMainThread];
            }
            dispatch_semaphore_wait(self.semaphore, DISPATCH_TIME_FOREVER);
            //卡顿超时情况;
            verifyReport();
        } else {
            [NSThread sleepForTimeInterval:(self.threshold/1000)];
        }
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44

三种方案总结:

方案 优点 缺点 实现复杂性
FPS 直观 无法准确定位卡顿堆栈 简单
RunLoop Observer 能定位卡顿堆栈 不能记录卡顿时间,定义卡顿的阈值不好控制 复杂
Ping Main Thread 能定位卡顿堆栈,能记录卡顿时间 一直ping主线程,费电 中等

# 如何优化掉帧卡顿

图像显示的工作是由CPU和GPU协同完成的, 那么优化的方向和思路就是尽量减少他们的处理时长。

对CPU处理的优化:

  • 简单总结:

对象的创建,释放,属性调整。这里尤其要提一下属性调整,CALayer的属性调整的时候是会创建隐式动画的,是比较损耗性能的。

视图和文本的布局计算,AutoLayout的布局计算都是在主线程上的,所以占用CPU时间也很多 。

文本渲染,诸如UILabel和UITextview都是在主线程渲染的

图片的解码,这里要提到的是,通常UIImage只有在交给GPU之前的一瞬间,CPU才会对其解码。

————————————————

  • 详细说明:
  1. 尽量用轻量级的对象代替重量级的对象,可以对性能有所优化,例如 不需要相应触摸事件的控件,用CALayer代替UIView

  2. 尽量减少对UIView和CALayer的属性修改

CALayer内部并没有属性,当调用属性方法时,其内部是通过运行时resolveInstanceMethod为对象临时添加一个方法,并将对应属性值保存在内部的一个Dictionary中,同时还会通知delegate、创建动画等,非常耗时

UIView相关的显示属性,例如frame、bounds、transform等,实际上都是从CALayer映射来的,对其进行调整时,消耗的资源比一般属性要大

  1. 当有大量对象释放时,也是非常耗时的,尽量挪到后台线程去释放

  2. 尽量提前计算视图布局,即预排版,例如cell的行高

  3. Autolayout在简单页面情况下们可以很好的提升开发效率,但是对于复杂视图而言,会产生严重的性能问题,随着视图数量的增长,Autolayout带来的CPU消耗是呈指数上升的。所以尽量使用代码布局。如果不想手动调整frame等,也可以借助三方库,例如Masonry(OC)、SnapKit(Swift)、ComponentKit、AsyncDisplayKit等

  4. 文本处理的优化:当一个界面有大量文本时,其行高的计算、绘制也是非常耗时的

1)如果对文本没有特殊要求,可以使用UILabel内部的实现方式,且需要放到子线程中进行,避免阻塞主线程

计算文本宽高:[NSAttributedString boundingRectWithSize:options:context:]

文本绘制:[NSAttributedString drawWithRect:options:context:]

2)自定义文本控件,利用TextKit 或最底层的 CoreText 对文本异步绘制。并且CoreText 对象创建好后,能直接获取文本的宽高等信息,避免了多次计算(调整和绘制都需要计算一次)。CoreText直接使用了CoreGraphics占用内存小,效率高

  1. 图片处理(解码 + 绘制)

1)当使用UIImage 或 CGImageSource 的方法创建图片时,图片的数据不会立即解码,而是在设置时解码(即图片设置到UIImageView/CALayer.contents中,然后在CALayer提交至GPU渲染前,CGImage中的数据才进行解码)。这一步是无可避免的,且是发生在主线程中的。想要绕开这个机制,常见的做法是在子线程中先将图片绘制到CGBitmapContext,然后从Bitmap 直接创建图片,例如SDWebImage三方框架中对图片编解码的处理。这就是Image的预解码

当使用CG开头的方法绘制图像到画布中,然后从画布中创建图片时,可以将图像的绘制在子线程中进行

  1. 图片优化

1)尽量使用PNG图片,不使用JPGE图片

2)通过子线程预解码,主线程渲染,即通过Bitmap创建图片,在子线程赋值image

3)优化图片大小,尽量避免动态缩放

4)尽量将多张图合为一张进行显示

  1. 尽量避免使用透明view,因为使用透明view,会导致在GPU中计算像素时,会将透明view下层图层的像素也计算进来,即颜色混合处理

  2. 按需加载,例如在TableView中滑动时不加载图片,使用默认占位图,而是在滑动停止时加载

  3. 少使用addView 给cell动态添加view

对GPU处理的优化

相对于CPU而言,GPU主要是接收CPU提交的纹理+顶点,经过一系列transform,最终混合并渲染,输出到屏幕上。

  1. 尽量减少在短时间内大量图片的显示,尽可能将多张图片合为一张显示,主要是因为当有大量图片进行显示时,无论是CPU的计算还是GPU的渲染,都是非常耗时的,很可能出现掉帧的情况

  2. 尽量避免图片的尺寸超过4096×4096,因为当图片超过这个尺寸时,会先由CPU进行预处理,然后再提交给GPU处理,导致额外CPU资源消耗

  3. 尽量减少视图数量和层次,主要是因为视图过多且重叠时,GPU会将其混合,混合的过程也是非常耗时的

  4. 尽量避免离屏渲染

  5. 异步渲染,例如可以将cell中的所有控件、视图合成一张图片进行显示。

编辑 (opens new window)
上次更新: 2024/10/23, 23:26:17
UI系列
图片相关的问题

← UI系列 图片相关的问题→

最近更新
01
数组
10-25
02
数组双指针系列之对撞指针
10-25
03
数组双指针系列之快慢指针
10-25
更多文章>
Theme by Vdoing | Copyright © 2019-2024 Evan Xu | MIT License
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式