精确定位页面滑动帧率瓶颈及优化参考

一、背景

在 App 使用过程中,页面流畅性是仅次于 Crash 的影响用户体验的指标。在苹果新推出的 iPhone 13 Pro 和 Max 上支持了 ProMotion,最大刷新率达到 120Hz,这使得用户对页面流畅性导致的刷新率变化更为敏感。本文总结了雪球 iOS 客户端在社区业务中 feed 流页面和正文页流畅性优化方面的工作,主要包括识别/测试卡顿工具使用和卡顿优化实践两方面内容。

二、工具

定义卡顿

非高刷 iPhone 的刷新率为 60Hz,也就是 VSync 信号的频率,这要求一帧内容需要在 16.67ms 之内完成渲染。如果下一帧 B 的渲染时间超过了 16.67ms,即在 VSync 信号到来之后才完成渲染,那么当前帧 A 会滞留在屏幕上,帧 B 需要再等待一次 VSync 信号才能渲染给用户。苹果将帧错过预期 VSync 信号称之为卡顿( Hitches )[1]

当用户在页面上操作时,比如上下滑动页面或者页面跳转时,主要焦点集中在手势的交互上,卡顿表现为用户可感知的“抖动”。良好的交互体验是提供流畅的响应速度,反之用户将会感知到明显的卡顿,卡顿会影响用户体验,甚至让用户失去对 App 的兴趣。

识别卡顿

Instrument Animation Hitches

Instrument 中的 Animation Hitches 模板可以检查卡顿,下图中 Hitches 一栏展示了发生的卡顿,点击其中一个卡顿,下面会显示该卡顿类型,下图中卡顿6的 Hitch Type 为 Expensive Commits,说明是 commit 阶段的耗时过长导致了该卡顿。

在左上角筛选框中输入当前项目名字,并圈选中造成卡顿的 commit,左下角切换为 Profile,通过 Time Profiler 工具查看该 commit 中耗时的调用。

Animation Hitches 工具可以检测到卡顿,并结合 Time Profiler 可以分析造成卡顿的调用耗时。Time Profiler 的原理是采集运行线程的调用栈,然后以统计学的方式汇总,所以 Time Profiler 展示的并不是实际代码执行时间,只是栈在采样统计中出现的时间,如下图所示。所以 Time Profiler 只适用于粗粒度的分析。

火焰图

如果需要精细化分析造成卡顿的耗时调用,os_signpost 是一个可靠的选择,但是手动插入大量 os_signpost 代码统计函数耗时效率比较低。hook objc_msgSend 能够统计消息发送中的函数耗时,而在分析 CPU 耗时调用中,火焰图是非常有效的工具,我们将两者结合起来作为函数耗时细粒度分析的工具。

Trace Event Format [2] 定义了一种火焰图数据格式,结合 hook objc_msgSend 方案,在方法调用开始和结束的地方打点,即可生成火焰图展示数据 [3]。下图是一段测试代码的火焰图,函数内部的子函数调用表现为垂直方向向下的“火焰”,函数调用栈越深,则向下延伸越深。在火焰图中,较平的底层"火焰"表示该函数可能存在性能问题。在测试代码中,-testFunction1_1_1_1_1 和 -testFunction1_1_1_2 函数内部线程睡眠了一段时间,在示意图中表现为两个较平的底部,也就是说优先优化这两个函数获得的收益最大。此外,控制火焰图的函数调用深度限制和最低函数耗时限制两个变量可以控制统计细化程度。

Hitch ratio

减少函数消耗调用并不能直接转化为页面流畅性指标,需要一个客观的指标来评价优化工作效果,WWDC20 [1] 定义了 Hitch time 和 Hitch ratio,Hitch time 是帧延迟显示的时间(以 ms 为单位),Hitch ratio 是页面滑动或其他动画过程中每秒内 Hitch time 的比率(以 ms/s 为单位)。苹果采用 Hitch ratio 来量化页面卡顿,并给出了 Hitch ratio 的建议数值,认为 Hitch ratio 低于 5ms/s 时用户体验比较好。

XCTest 框架中的 UI 测试可以搜集 Hitch ratio,我们测试了 feed 流页面和正文页优化前后的 Hitch ratio 来评判优化效果。

三、优化实践

通过上述工具分析,雪球社区 feed 流页面和正文页的卡顿主要集中在富文本的解析和绘制阶段,以及随着页面样式复杂性增加而积累的约束 remakeConstraints,下面主要针对这几项进行优化。

富文本优化

雪球的社区业务主要是围绕着富文本处理展开的,当富文本较为复杂时,解析和绘制均耗费大量时间,是导致页面卡顿的最主要因素,下面主要从解析和绘制两方面介绍富文本方面的优化。

富文本解析

上图为原有富文本解析流程,对特殊 <a> 标签处插入 <img> 标签,以及去除 HTML 标签括号等流程,需要多次遍历富文本。而当富文本中包含大量 <a> 标签或 <img> 标签时,占用了大量主线程时间,造成了严重的卡顿。

一次性遍历解析

我们使用 DTCoreText 对现有富文本解析流程进行优化。DTCoreText 是开源的 iOS 富文本组件,可以一次性将 HTML+CSS 富文本转化为 NSAttributedString。DTCoreText 数据解析的流程如上图所示:

  1. 富文本 HTMLString 字符串传递入 DTAttributedStringBuilder,DTAttributedStringBuilder 接收 DTHTMLParser 的回调生成 DOM 树,在 DTHTMLParser 的回调中可以增加处理特殊 <a> 标签的流程。
  2. 生成的 DOM 树种每个节点都是自定义的 DTHTMLElement,通过 DTCSSStylesheet 解析每个元素对应的样式,这时每个 DTHTMLElement 已经包含了节点的内容和样式,最后从 DTHTMLElement生成 NSAttributedString。

DTCoreText 在解析富文本时,把解析过程暴露给使用者,通过回调函数告诉调用者当前解析到什么元素,让使用者决定怎么处理。所以 DTCoreText 是边解析边处理,只需要遍历一次富文本,因此我们可以高效地完成在特殊 <a> 标签插入 <img> 标签等需求。

异步解析

DTAttributedStringBuilder 创建了 3 个队列:解析 html 的 _dataParsingQueue,生成 DOM 树的 _treeBuildingQueue,以及组装 NSAttributedString 的 _stringAssemblyQueue,将解析过程分派到 3 个队列,通过 dispatch_group_wait 阻塞等待所有任务完成后返回结果。所以解析过程是在非主线程上完成的,可以异步解析富文本进一步减少主线程上的时间消耗。

富文本绘制

自定义文本异步绘制

为了满足业务需求,feed 流页面使用 CoreText 来实现富文本绘制。当富文本内容比较复杂,尤其是包含表情较多时,在主线程绘制时会造成严重卡顿,因此采用异步绘制来进行优化。

iOS 中 UIView 负责处理事件传递,而绘制是通过 CALayer 来完成的,CALayer 通过“-(void)display”方法进行绘制。异步绘制就是通过继承 CALayer 并重写“-(void)display”方法,在内部将绘制任务放在非主线程来实现。YYAsyncLayer [4] 是一个实现了异步绘制的 CALayer,当它需要显示内容时,它会向 delegate,也就是 UIView 请求一个异步绘制的任务。

我们使用 YYAsyncLayer 对 feed 流富文本进行异步绘制,SNBTextLabel 为富文本显示组件,实现了YYAsyncLayer 定义的协议 YYTextAsyncLayerDelegate,通过“-(YYTextAsyncLayerDisplayTask *)newAsyncDisplayTask”创建异步绘制任务,在异步绘制任务内部调用了 SNBTextData 封装的原富文本绘制流程。这种异步绘制改造,对原绘制流程侵入性较小,减少了测试回归点并保证上线质量。

YYLabel 异步绘制优化

正文页的评论区使用了 YYLabel,YYLabel 提供了 displaysAsynchronously 属性来控制是否开启异步绘制。但是当 YYLabel 开启异步绘制之后,评论区在加载下一页 reloadData 存在闪动 [5],这是由于 YYLabel 的 clearContentsBeforeAsynchronouslyDisplay 属性默认为 YES,在 YYLabel 重写的修改属性函数内,如果 displaysAsynchronously 和 clearContentsBeforeAsynchronouslyDisplay 同时为YES,则会先清理掉原有的内容。所以 reloadData 时即使 cell 中 YYLabel 的文字内容不变,还是会先清理掉已有的 layer.contents,然后再异步绘制出新的 layer.contents,由于是异步的原因,中间会有一段时间 YYLabel 内容是清空的,导致表现为闪动。

// YYLabel.m

- (void)setTextColor:(UIColor *)textColor {
  if (!textColor) {
    textColor = [UIColor blackColor];
  }

  if (_textColor == textColor || [_textColor isEqual:textColor]) return;
  _textColor = textColor;
  _innerText.yy_color = textColor;
  if (_innerText.length && !_ignoreCommonProperties) {
    if (_displaysAsynchronously && _clearContentsBeforeAsynchronouslyDisplay) {
      [self _clearContents];  // 清理掉原有内容
    }
    [self _setLayoutNeedUpdate];
  }
}
复制代码

所以在当前使用场景下,将 YYLabel 的 displaysAsynchronously 设置为 YES 时,同时将clearContentsBeforeAsynchronouslyDisplay 设置为 NO,避免出现闪动。

约束布局优化

减少 remakeConstraints 次数

雪球 feed 流页面的 cell 承载着很多业务,存在着大量的 remakeConstraints 代码,是除了富文本绘制和解析之外最耗时的函数调用。改成 Frame 布局可以消除掉这部分耗时,但是涉及到大量测试回归点,风险比较大。从另一个角度出发,在大多数情况下,feed 流 cell 显示的 UI 组件是一样的,并不需要每次设置数据时对各个子视图进行 remakeConstraints。

所以如下面代码所示,对视图组件 viewX,每次设置数据时检查已绑定的历史数据 _model 和新数据model 的区别,判断数据是否发生了需要更新 viewX 约束的变化,从而减少 remkaeConstriants 的次数。

- (void)setModel:(Model *)model
{
    BOOL needReLayoutViewX = [self measureRelayoutViewNecessary:model];
    if (needReLayoutViewX) {
        [self.viewX mas_remakeConstraints:^(MASConstraintMaker *make) {
            // 设置约束
        }];
    }
    _model = model;
}

- (BOOL)measureRelayoutViewNecessary:(Model *)model
{
    if (model && self.model && 'UI组件显示变更不满足') {
        return NO;
    }
    return YES;
}
复制代码

其他优化

减少视图创建和移除的次数

  • 频繁地创建和移除视图也比较耗时。例如 feed 流 cell 中的9宫格图片部分,避免显示图片时每次都创建新的 UIImageView,而应该复用已创建的 UIImageView,当显示图片不够 9 张时只需要隐藏多余的 UIImageView。
  • 避免直接触发 UI 组件懒加载调用,只有当满足显示条件时才触发懒加载,否则可以使用实例变量来代替。

四、实验结果和总结

通过 XCTest 框架测试了 feed 流页面和正文页在模拟极端复杂数据下的 Hitch ratio。经过多个版本的优化,在 iPhoneXs 上,feed 流页面的 Hitch ratio 由 16ms/s 降低到了 3.5ms/s;在 iPhone6s 上,正文页的 Hitch ratio 由优化前的 60ms/s 降低到了 5.5ms/s。

在雪球 iOS 社区页面的流畅性优化实践中,通过 Instrument Animation Hitches 和火焰图可以定位commit 阶段耗时较多的函数调用,并针对几个头部耗时函数调用进行了优化,并通过 Hitch ratio 指标量化了优化效果,在低端手机上实际使用体验也得到了很大的提升。本文涉及到的优化点主要是 commit 阶段的耗时优化,关于渲染阶段的优化可以更多地参考苹果的技术分享 [6]。

五、引用

[1] Session 10077 - Eliminate animation hitches with XCTest

[2] Trace Event Format

[3] www.speedscope.app

[4] iOS 保持界面流畅的技巧

[5] github.com/ibireme/yyk…

[6] WWDC Demystify and eliminate hitches in the render phase

猜你喜欢

转载自juejin.im/post/7077812846217658381