APM - iOS 卡顿原理及监控方案
APM - iOS 卡顿优化方案
上一篇文章介绍了卡顿产生的本质原因以及监控卡顿的方案,本篇将主要介绍一下常见的导致卡顿的场景,以及对 CPU 和 GPU 的资源消耗的场景。
CPU 资源的消耗
CPU 一般负责视图创建、销毁,布局计算、文本绘制、图片的解码等,在这些过程中如果编码不够规范或者业务场景比较复杂,都会导致卡顿的产生。
1.文本相关
文本计算
当某个页面包含大量文本且需要根据内容长度按需展示,此时文本的宽高计算会占用很大一部分资源,通常需要将其放到后台线程执行计算任务,避免阻塞主线程。
文本渲染
当遇到一些复杂的文本展示(如 markdown),由于内容的样式比较丰富,一般可以通过富文本 NSAttributeString 来完成渲染。但如果对性能要求极高,可以自定义文本控件,基于 TextKit 或者底层的 CoreText 对文本异步绘制。
2.图片相关
图片解码
通过 UIImage 创建图片时,图片数据并不会立刻解码,只有将图片设置到 UIImageView 后,在提交给 GPU 前,CGImage 中的数据才会得到解码,这是在主线程执行的操作。如果想减少主线程的任务,可在后台线程现将图片绘制到 CGBitmapContext 中,然后从 Bitmap 直接创建图片。
图片绘制
图片的绘制,指通过 CoreGraphics 框架中相关的方法在画布中进行绘制,然后从画布创建图片并显示的过程。这些 api 是线程安全的,图片的绘制完全可以放到后台线程去执行,即异步绘制,大概过程如下:
swift
- (void)display {
dispatch_async(backgroundQueue, ^{
CGContextRef ctx = CGBitmapContextCreate(...);
// draw in context...
CGImageRef img = CGBitmapContextCreateImage(ctx);
CFRelease(ctx);
dispatch_async(mainQueue, ^{
layer.contents = img;//回主线程显示
});
});
}
其他
针对图片绘制还有一些其他的优化技巧,如:
- 图片与 imageView 的size,尽量保持一致,避免拉伸
- 列表有大量图片时候,可当滚动停止时候在刷新,或后台预加载
- 针对大图绘制时,很可能会造成长卡或卡死,可通过下采样的方式解决
- ...
3.布局相关
- AutoLayout
AutoLayout 自动布局也是官方比较推荐的布局方式,多数情况下也能够帮助我们提高开发效率,但是。随着视图数量的增长,AutoLayout 对 CPU 的消耗会呈指数级增长,因此针对复杂页面布局时候,可以选择直接计算 frame 的方式进行布局,并且 frame 的计算也可以放在后台线程执行。
4.线程相关
主线程执行耗时任务
主线程通常执行一些 UI 相关的任务,若主线程发生卡顿会直接影响页面的流畅度。 有时由于编码不够规范或开发经验不足,很容易将一些耗时操作直接放在了主线程执行,比如 IO读写、网络请求、json 解析等。这些耗时操作完全可以放在子线程去执行,来减轻主线程的压力。
等锁释放
主线程需要获得锁 A,但是当前某个子线程持有这个锁 A,导致主线程不得不等待子线程完成任务。
死锁
- 会 crash
主线程执行的任务是通过串行队列管理的(主队列),如直接向主队列中添加同步任务,会直接导致crash。
swift
func viewDidLoad() {
super.viewDidLoad()
//在主线程内再向"主队列" 添加同步任务,会直接 crash
DispatchQueue.main.sync {
print("3")
}
}
-
- 不会 crash
当发生阻塞的不是主队列时,此时的死锁会将程序卡死但不会崩溃,如下源码:
- 不会 crash
swift
override func viewDidLoad() {
super.viewDidLoad()
print("1")
let que = DispatchQueue.init(label: "thread")
que.async {
print("2")
DispatchQueue.main.sync {
print("3")
que.sync {
print("4")
}
}
print("5")
}
print("6")
}
上述代码会打印:1、6、2、3,然后由于 que 队列发生了循环等待,造成死锁,但不会 crash。
GPU 资源的消耗
相对于CPU 来说,GPU 干的事情比较单一,接收 CPU 计算的结果(纹理等),应用变换、混合并渲染,最终输出到屏幕上显示。但是,如果视图的渲染比较复杂,肯定也消耗更多 GPU 的资源。因此在优化流畅度的过程,减轻 GPU 的压力也是不可获取的一环。
1.纹理的渲染
所有的 Bitmap(位图),包括文本、图片、栅格化的内容,最终都会由内存提交到显存,绑定为 GPU 的纹理。如短时间需要显示大量图片时,CPU 占用率很低,GPU 的占用会非常高,此时仍然会掉帧。针对这种情况可考虑,避免短时间显示大量图片,或者降低图片的采样率等手段。
2.视图的混合
当多个视图(或者说 CALayer)重叠在一起时,GPU 会把他们混合在一起。视图层级越多,混合过程消耗的 GPU 资源就越多,解决方案:1) 因此可适当减少视图数量和层级数; 2)也可以通过绘制的方式将多个视图绘制成一张图片再显示。
3.离屏渲染
GPU 共有 2 中渲染方式: On-Screen Rendering:当前屏幕渲染,在当前屏幕缓冲区进行渲染操作; Off-Screen Rendering:离屏渲染,在当前屏幕缓冲区之外在开辟一个缓冲区进行渲染操作;
为什么要离屏渲染?
在渲染过程中,Render Server 会遵循"画家算法"(由远及近) ,将每一层 layer 按照深度排序,然后由深到浅依次输出到缓冲区,后一层覆盖前一层,从而得到最终显示的结果。
比如,当给某个 UI 场景需要添加圆角时,通常是设置在父视图的 layer 上,最后连其子视图都会应用圆角效果。
按照上述的渲染过程,父视图的 layer 先被渲染,而子视图还在排队等待渲染,无法对每一个 layer 添加圆角效果。因此,Render Server 会开辟一个新的缓冲区,先将所有的 layer 绘制在一起,再整体渲染圆角,最后将渲染结果放到 Frame Buffer 中等待上屏。
消耗性能的原因?
- 创建新的缓冲区耗时
- 离屏渲染过程中,多次上下文环境切换会非常耗时。先是从当前屏幕(On-Screen)切换到离屏(Off-Screen),等到离屏渲染结束以后,将离屏缓冲区的渲染结果显示到屏幕上有需要将上下文环境从离屏切换到当前屏幕。
哪些操作会触发离屏渲染?
- 圆角裁剪: layer.maskToBounds 和 view.clipToBounds 为 true
- 图层遮罩:layer.mask
- 图层阴影:layer.shadow
- 图层不透明:layer.allowsGroupOpacity = true 和 layer.opacity < 1.0
- 文本的渲染:包括 UILabel、CATextLayer、CoreText 等
- 重写 drawRect 方法,方法中(使用CGContext)的绘制都会导致离屏渲染,即使空方法。
有性能问题为什么还要使用离屏渲染?
- 可用来实现一些特殊效果,有些效果是不能直接被呈现在屏幕上的,需要使用离屏缓冲区来保存中间状态,这种情况下的离屏渲染是系统自动触发的;例如圆角、阴影、高斯模糊等。
- 还可提高渲染效率,如一个效果在多个地方重复展现,可以提前渲染,保存在离屏缓冲区,来达到复用的目的。
优化方案
1.预排版
预排版,即在设置 UI 控件之前就计算好每个控件的布局(frame)。通常可以在获取到展示的数据之后,在子线程计算好每个控件的布局,缓存在一个布局对象中,从而避免主线程发生卡顿。
提前计算好布局、减少 Autolayout 等技术的使用,对复杂列表视图来说,提升列表的流畅度是很有效的一个优化方案。
2.预渲染
我们知道当对 layer 设置一些圆角、阴影、遮罩等效果,会触发的离屏渲染,此时对 GPU 的消耗是非常高的。比如当需要图片圆角效果时,避免简单直接的设置layer 的属性来实现圆角效果,我们可以在子线程将图片先渲染为圆角图片并缓存起来,最后在回到主线程设置给 ImageView。
3.异步绘制
iOS UIKit 中的控件是基于 Core Animation 封装的高级控件,这些控件的使用都必须是在主线程调用,需要CPU 来进行绘制, 当统一时刻有较多的组件需要绘制时,必然给 CPU 带来压力,很容易掉帧。
如上图,底层的渲染操作交给了 OpenGL ES 和 Core Graphics 完成的,我们看下 CoreGraphics 框架,是支持在子线程执行的,我们可以通过CoreGraphics 的 api 在子线程进行一步绘制,最后将绘制结果呈现在一张图片(位图:CGImage),最后回主线程赋值 CALayer.contents 。
业内比较成熟的异步绘制的框架如: AsyncDisplayKit、YYAsyncLayer 等,使用一步绘制的方式开发 UI 无疑会增加开发的复杂度,但若能够取得不错的性能收益也是值得投入的。
防劣化
1.代码质量
提高程序的性能,减少卡顿的发生,提高开发阶段的代码质量;需要建立 Code Review 机制,以及需要建立自动化代码流水线检查机制,尽量在代码合入之前将常见的能引起卡顿的代码拦截。
2.监控平台
建立性能监控看板,每个版本灰度期间及时观察卡顿上报的情况,同时可建立报警机制,劣化严重触发某个阈值后,自动通知相关 owner,并及时治理。
3.火焰图分析
可将性能 SDK 采集的数据生成火焰图,可通过火焰图来分析 CPU 的占用情况,快速定位出频繁执行的函数或者执行耗时的函数,能够直观的了解程序的性能问题。
总结
在开发过程中,产生卡顿的场景是非常多的,优化的手段可认为有两大类:
- 提前做(预加载、预排版、预渲染等)
- 异步做(子线程)
写出流畅的代码,不仅需要开发经验的积累,更需要一些体系化、工具化的开发范式来统一的代码规范。
参考