前言
2021年初在公司内部分享的文章,现在分享给大家。
WWDC的新视频终于更新了instruments的Animation Hitches工具的使用方式,本篇文档为你介绍iOS的渲染流程以及新工具的使用。首先,由于使用新工具需要掌握渲染流程知识,所以我们必须先熟练掌握它。接着,我会通过使用新工具来找出西瓜视频中问题的方式来给大家介绍如何使用它。最后,我补充了一些我在实际使用过程中的经验,希望能帮助到大家。
渲染流程(Render loop)
简介
在我们的app中,每一帧的渲染分为三步。第一步,我们操作app会触发UI变更。第二步,我们操作的变更会被打包提交到独立的Render server(渲染服务)进程进行渲染,这个阶段UI才被真正渲染。第三步,将这一帧显示出来。这三步中的每一步都需要在一个VSync内完成,否则就会出现丢帧。
这种三段式渲染被叫做双缓冲,可以看到第三个VSync显示在屏幕上的这一帧已经进行了2个VSync的处理。
有时为了避免卡顿,系统会切换到三缓冲模式,让Render server有了2个VSync进行处理。如果你用instruments测试一下,有时候你就会发现系统切换到了三缓冲模式。
在下图中,从左到右是渲染的具体阶段。第一个阶段是处理用户事件(Event),在这个阶段会通过用户事件来决定是否触发UI变更。第二个是提交阶段(Commit),我们的app会更新UI,并且通知Render server开始渲染。第三个阶段(Render prepare),Render server处理提交的数据,并且为GPU渲染做好准备。第四个阶段(Render execute),GPU会将UI绘制出来在第五个阶段(Display)展示。
因此,如果我们要保证列表的流畅性,那么这三个阶段都很重要,我们要保障三个阶段的处理耗时在1个VSync之间。虽然Render server是独立于app之外的进程,但是app提交的commit也会对它的性能起到重大的影响。
三段式的设计还有一个好处,那就是可以并行处理。举个例子,当Render server在渲染前一帧的时候,app已经开始处理新的一帧的数据了,图示大概如下,相同颜色代表处理的是同一帧。
例子
下面我们来一个例子让大家更直观的理解渲染流程。
User Events
当app中的view进行了一些设置,比如说改了frame,那么Core Animation就会同时调用setNeedsLayout表示需要进行布局。系统会合并这些需要布局的请求,并在commit阶段按顺序执行。
Commit
如果需要进行布局的话,在commit阶段就会开始。首先,系统会挑选需要进行布局的view,然后从父view到子view依次布局。布局是比较常见的性能瓶颈,我们大概只有几ms的时间来进行这项操作。 Label、ImageView和重写了drawRect方法的view需要更新的话必须调用setNeedsDisplay。跟布局一样,系统会合并这些更新请求在完成所有布局后执行这些更新操作。通过Core Animation的一番操作,app中的view就变成了图片。 当所有的布局以及图层都绘制好之后,整个图层树会被打包发送到Render server进行渲染。
Render Prepare
在渲染的准备阶段,Render server会遍历整个图层树,然后准备一条pipeline(队列)提供给GPU。绘制的顺序是从父到子,先添加的同级到后添加的同级。
Render execute
到这一步时,GPU就把pipeline中的图层绘制成真正可以展示在屏幕上的图像。在下一个VSync中,图像就可以显示了。
Display
图像显示。
卡顿类型
在渲染流程中,有两种发生卡顿的类型。第一种是在app中出现的提交卡顿,苹果称之为Commit hitch。第二种是发生在Render server种的卡顿,苹果称之为Render hitch。
Commit hitch
提交卡顿就是app花费过长的时间来处理或提交事件。举个例子,在下面这张图里,commit应该在VSync2中处理完,但它延迟到VSync3才提交,这样原本Render server就不得不到等到VSync4再处理,这样就发生了一帧的卡顿。
Render hitch
渲染卡顿的逻辑与提交卡顿一样。
找到并解决Commit hitch
Commit的四个小阶段
首先我们确定一下提交阶段系统的4个小阶段,分别是布局阶段、显示阶段、准备阶段和提交阶段。
Layout
在布局阶段,系统会为每一个需要布局的view调用layoutSubviews。当你的view更改位置(frame、bounds和transform)、添加或移除view和给view调用setNeedsLayout就会触发这个阶段。
Display
在显示阶段,每一个需要显示显示的view都会被调用drawRect方法。重写view的drawRect方法和给view调用setNeedsDisplay就可以触发这个阶段。文本绘制和一些其他的内容绘制就在这个阶段。
Prepare
在准备阶段,还没有解码的图片会在这个阶段进行解码。如果图片比较大的话,这一步就比较耗时了。各种图片库所做的后台线程强制解码就是为了减少这个阶段的耗时。如果一个图片的颜色格式GPU不能直接使用的话,这一步也会进行处理。网上有博客说明Apple的GPU只解析32bit的颜色格式(RGBA),Xcode开启Color copied image就能发现这个问题,我倒是没见过这个问题。另外,动画操作也会在这个阶段处理。
Commit
最后,图层树会被递归的打包,然后送到Render server。需要注意的是,如果你的view层级很深,这里会花费更长的时间。
找到卡顿
我们可以使用instruments的新模板——Animation Hitches,来帮我们找到卡顿问题。注意,需要你的手机需要升级到iOS 14。
下方是我们西瓜视频推荐频道的一次分析,我们可以看到,这次分析发现了不少卡顿。Hitches子轨道的内容从上到下分别是User Events(触发卡顿的用户操作,并不会记录所有操作)、Commits、Renders、GPU和Frame Lifetimes。它们中的前4个分别代表之前讲解渲染流程5个阶段的前4个,而Frame Lifetimes这表示这一帧到底持续了多久。
现在我们来看hitch 13,这是一个提交阶段的卡顿。我们选中它并用一下Time Profiler看一下到底是什么卡着了。如下图,通过调用栈我们可以发现这里创建了一些View,并且对它们进行了更新。
由于保密原因,我不在这里贴上代码。在上述调用栈的Cell中,有两种View在复用时都有可能创建新的view并移除旧的view,这就是hitch 13卡顿的元凶。
一些建议
-
让view更轻量
- 尽量不要重写drawRect方法。
- 尽量复用view而不是反复添加和移除。
- 如果一定要移除一个view,尽可能的使用hidden属性。
-
避免重复布局
- 更新布局时只使用setNeedsLayout而不接着调用layoutIfNeeded,直接调用会在当前的渲染循流程进行布局,这样会造成卡顿。一般情况下都可以等到下一个渲染流程进行布局更新。
找到并解决Render hitch
Render的两个阶段都有可能导致丢帧。下图为GPU绘制一个图像的过程,由后往前绘制每一个图层。
在GPU绘制阴影前,必须要知道阴影的形状。因此它必须将圆形和矩形绘制在全新的纹理中,然后让它们变黑并且加上蒙层,然后再拷贝回原先的纹理,这就出现了离屏渲染。
在iOS中,阴影、mask、圆角和visual effect会导致离屏渲染。
找到卡顿
下图是我们小视频频道的一次分析,点击Render Phase 13,我们可以看到红框中选中的Render Count这列,根据苹果的介绍,这是需要创建离屏渲染通道的数量,也是我们在优化GPU性能的过程中需要减少的数量。这里仅介绍离屏渲染的优化工具,实际上GPU的性能优化并不局限于离屏渲染,还有图层混合、像素不对齐等等问题。
修复离屏渲染的问题并不需要使用instruments进行分析,我们使用Xcode就可以了。我们使用Xcode的View Hierarchy并打开Editor->Show Layers看到如下图。可以看到左侧导航栏有一些优化建议,右侧工具栏红框选中的部分是该图层的离屏渲染通道数量。
你只要打开Editor->Show Optimization Opportunities就可以看到优化建议,虽然不一定有用,但是能让你比较快速的找到离屏渲染的控件。
在这个case中,离屏渲染的代码如下:
- (UILabel *)titleLabel {
if (!_titleLabel) {
_titleLabel = [[UILabel alloc] init];
_titleLabel.numberOfLines = 3;
_titleLabel.font = [UIFont boldSystemFontOfSize:17];
_titleLabel.textColor = [UIColor whiteColor];
_titleLabel.layer.shadowColor = [UIColor greyColor].CGColor;
_titleLabel.layer.shadowRadius = 2.0;
_titleLabel.layer.shadowOpacity = 1;
_titleLabel.layer.shadowOffset = CGSizeZero;
}
return _titleLabel;
}
Xcode建议你给label的阴影使用shadowPath,这个效果并不符合预期。我们应该用NSShadow和富文本来解决这里的离屏渲染。
一些建议
-
阴影
- 可以使用shadowPath,文字的阴影可以用NSShadow和富文本代替。
-
Mask
- 圆角可以用cornerRadius而不是layer.mask。
使用Animation hitch的一些经验
由于苹果对于新工具的介绍不是非常的全面,下面给大家介绍我使用这个工具的时候总结的一些经验,希望能够帮助到大家。 一般来说我们使用这个模板时都会比较在意Hitch Type是什么,因为根据不同的type,我们优化的方式也不一样。另外,我们在优化过程中需要重点关注Pre-Commit latency和Expensive Commit这两个type的问题。
Pre-Commit (s) latency
它长这样,出现这个问题的原因是因为commit阶段被延迟了,你需要像下图这样的方式选定接着去看TimeProfiler的调用栈。一般来说是这个问题出现的原因是view初始化的时候比较重,我们很容易就能在刚进入的列表中发现这个问题。
Expensive Commit (s)
它长这样,出现这个问题的原因是commit阶段执行时间过长。主要有两块原因:
- 上文所述的commit四个小阶段耗时过长。
- 此时主线程正好有耗时操作,比如说数据库读写、播放器的一些操作等。
这些原因还是看TimeProfiler就能轻易发现。
Expensive Rendering
一般业务开发中比少出现的问题,看名字就可以明白,render的流程太耗时了。想要优化这个问题不需要使用instruments,按照GPU优化的手段就行。
Commit to Render latency
从名字上来看说明commit阶段打包的数据发送到Render server延迟了。我分析了很多case的CPU调度,要么是连续hitch导致的延迟,要么是一堆不知道做什么的进程不知道在干什么,我觉得优化时可忽略。
Delay Frame Swap
hitch连续出现导致帧的交换出现了延时,据我观察是因为其他hitch原因导致它的出现,优化时可忽略。
遇到的bug
如果你的分析中遇到了下面这张图的情况,相信我,这一定是个bug,重启手机试一下。这里我理解为instruments没有获取到具体的Hitch Type所以使用了Delay Frame Swap作为缺省值。
总结
我个人觉得在开发过程中,需要时刻注意避免出现Commit hitch,也就是要考虑CPU性能。这是我们最容易出现问题的地方,而且一旦有问题就会出现比较糟糕的表现。我们一方面需要拥有合理的框架来保证流畅性的下限,另一方面需要了解流畅性性能知识来争取更好的性能。 GPU上会出现的一些性能问题并不用保持警惕,该用还是得用,过早优化是罪恶之源,等待业务稳定后再集中治理。