简介
卡顿的定义有多种,按照卡住的程度排列的话,有这么几种,从ANR到Hitch,本文主要描述Hitch,Render Loop以及如何发现和修复Hitch。
Hitch
hitch代表任何时候一帧比预期的晚出现在屏幕上,hitch指标主要用在Instruments和MerticKit
如图Frame4比预期的晚出现在了屏幕上
场景
APP中发生画面变化的情况主要有以下3种场景
- Srcoll
- Animation
- Transition
Render Loop
渲染环是一个持续的过程,从用户触摸事件到提供信息给操作系统来分析和展示最终结果
这个工作与设备容量高度相关,因为这个过程在设备的刷新率下发生
屏幕按照帧率刷新的时候,每一帧都会由硬件触发一个垂直信号,在这个垂直信号发出之前每一帧需要显示的内容需要准备好,才能提供流畅的用户体验
这个过程又这可以分成3个步骤
- APP处理步骤
- GPU渲染步骤
- 屏幕显示步骤
Double Buffering
Trible Buffering
这3个步骤对应如下5个阶段
-
APP
- Event Phase
- Commit Phase
-
Render Server
- Render Prepare
- Render Execute
-
Display
Event Phase
处理事件,修改状态
事件包括
- Events
- Touch
- Networking
- Keyboard
- Timers
事件中设置BackgroudColor,设置Bounds等,在设置好之后,setNeedsLayout会进行状态的修改和传递
Commit Phase
提交阶段布局和绘制视图布局和状态
Render Prepare Phase
处理layers图层,效果,和用于执行的动画
Render Execute
使用GPU把图层和效果绘制出来
并行处理关系
Hitch分类
Commit Hitch
APP提交和处理事件的时间过长
延迟1帧,即16.67ms
延迟2帧,即33.34ms
Render Hitch
渲染无法及时完成
延迟1帧,即16.67ms
Hitch Time Measurement
整体hitch的时间
- 不同的过程,设备刷新率和很多正常的帧
- 不好比较
Hitch Time Ratio
整体hitch的时间跟整体总计时间的比例
-
为不同的设备统一了总共hitch的时间
以这30帧为例,因为hitch time是0,所以0ms/0.5s = 0 ms/s
如果其中hitch time的时间是100.02ms,那么计算下来就是200.04ms/s
性能指标
- 严重是大于等于10ms/s
- 警告是5ms/s与10ms/s之间
- 很好是小于5ms/s
修复Hitch
Commit Phase
Commit Transaction
应用的视图结构从等待事件的状态转换到接收事件的状态
从接收事件的状态到提交的状态
Layout
每个需要layout的View的layoutSubviews()
方法都会被调用
Layout needed的条件
- 移动位置(frame,bounds,transform)
- 添加或者移除视图
- 显式调用
setNeedsLayout()
Display
每个需要更新内容的View的draw(rect:)
方法都会被调用
Display needed的条件
- 添加的view覆盖了
draw(rect:)
- 显示调用
setNeedsDisplay()
Prepare
- 如果有必要的图片解码
- 如果有必要的图片转换
Commit
提交会做
- 递归打包视图树
- 发送到渲染服务者
Find hitches with Instruments
在Instruments的工具中可以找到Animation Hitches工具
左侧列出了Events,Commits,Renders,GPU,Frame Lifetime和Display信息
正常的情况
Acceptable Latency
Hitch Duration
在下边的数据追踪详情中可以看到Acceptable Latency数据,Hitch Duration数据和Hitch Type类型数据
可以选择和筛选到对应Commit Phase,通过查看Thread中的调用栈来找到导致Hitch发生的原因
实例中是在复用函数事件prepareForReuse()
中遍历调用了addSubview()
和removeFromSuperview()
的方法导致Hitch
Recommendations
保持视图轻量级
- 使用CALayer的属性好于自定义
draw(rect:)
代码 - 没必要的情况下,不要覆写
draw(rect:)
- 复用视图避免增加和移除的操作
- 可以使用
hidden
属性
减少大开销或者重复的布局
- 尽量使用setNeedsLayout(), 不用layoutIfNeeded()
- 尽量使用简明的约束
- 递归布局的开销很大
Render Phase
Render Phase
Render Server
Render Phase分为两个细分阶段
- Render Prepare
- Render Execute
Render Prepare
把视图层级和效果拆解成一步一步的简单操作
Render Execute
一步一步进行纹理叠加
遇到Shadow的时候,需要开辟新的渲染空间,离屏渲染产生了
另一块空间,离屏空间渲染完成后,再拷贝回原渲染空间中,进行纹理叠加
离屏渲染
任何时候当GPU必须开辟另外一个区域渲染视图,然后再复制回来
几个主要原因
- Shadowing
- Masking
- Rounded Rectangles
- Visual Effects
Shadowing
Masking
Rounded Rectangles
Visual Effects
Find hitches with Instruments
可以观察到GPU Phase超时
通过Xcode中Debug Navigator查看视图层级
Layer的描述中标记了离屏渲染的情况
Editor中Show Optimization Opportunities
Xcode中的Issue Navigator会显示出视图对应的问题
Recommendations
尽量使用提供的API
- 设置
shadowPath
属性定义阴影形状 - 使用
cornerRadius
和cornerCurve
来画圆角
优化mask遮掩层
- 尽量使用maskToBounds而不是自定义mask
- 如果内容不超过边界不要开启maskToBounds
结合XCTest
iOS14开始,在开发,Beta和生产阶段都可以使用工具套件来追踪hitch,本节结合XCTest从开发阶段排查hitch问题
- XCTest framework提供了在单元测试和UI测试中直接收集hitch和animation数据
- MetricKit和Xcode Organizer可以从用户侧收集性能数据
Xcode11开始,推荐使用XCTMetrics
XCTApplicationLaunchMetric
XCTCPUMetric
XCTClockMetric
XCTMemoryMetric
XCTOSSignpostMetric
XCTStorageMetric
Xcode11中,我们可以使用XCTOSSignpostMetric
来衡量os_signpost的间隔
Xcode12中,我们使用animation os_signpost
间隔,不仅可以获得时长信息,还可以获得3个hitch相关信息,帧率和帧数
需要收集这些指标,有3种方式在代码中触发一个os_signpost
间隔,分成非动画间隔和动画间隔2种类型
Xcode11中需要使用begin和end来检测非动画间隔,在Xcode12中只需要换成使用animationBegin接口即可
os_signpost(.animationBegin, log: logHandle, name: "performAnimationInterval")
os_signpost(.end, log: logHandle, name: "performAnimationInterval")
除了自定义间隔,你也可以使用关于navigation转场和滑动的预定义的UIKit的检测间隔
extension XCTOSSignpostMetric {
open class var navigationTransitionMetric: XCTMetric { get }
open class var customNavigationTransitionMetric: XCTMetric { get }
open class var scrollDecelerationMetric: XCTMetric { get }
open class var scrollDraggingMetric: XCTMetric { get }
}
如示例中,点击cell后进入collectionView并滑动,打算检测scrollDeceleration子指标。测量代码块,默认会检测5次来收集性能数据。
// Measure scrolling animation performance using a Performance XCTest
func testScrollingAnimationPerformance() throws {
app.launch()
app.staticTexts["Meal Planner"].tap()
let foodCollection = app.collectionViews.firstMatch
measure(metrics: [XCTOSSignpostMetric.scrollDecelerationMetric]) {
foodCollection.swipeUp(velocity: .fast)
}
}
为了避免上滑5次显示5次不同内容,可以每次在运行前重置应用的状态,我们可以使用XCTMeasureOptions
来让我们的检测代码块知道我们会手动停止检测收集,把参数传递到测量代码块中
func testScrollingAnimationPerformance() throws {
app.launch()
app.staticTexts["Meal Planner"].tap()
let foodCollection = app.collectionViews.firstMatch
let measureOptions = XCTMeasureOptions()
measureOptions.invocationOptions = [.manuallyStop]
measure(metrics: [XCTOSSignpostMetric.scrollDecelerationMetric],
options: measureOptions) {
foodCollection.swipeUp(velocity: .fast)
stopMeasuring()
foodCollection.swipeDown(velocity: .fast)
}
}
但是在运行测量代码之前,我们需要在test scheme中修改一些配置消除对性能检测的影响
- 选择release build configuration
- 关闭the debugger
- 关闭automatic screenshot
- 关闭code coverage
- 关闭所有diagnostic options
跑完测试可以得到以下数据,选择到Hitch Ratio Rate指标数据
5次测量数据
平均值1.2ms/s
可以把这个数据设置为基准数据,用于跟后续的性能数据做对比
查看Number of hitches,确认此时无hitches
添加了图片加载等代码后,再查看Number of hitches,发现有hitches产生
问题出现在scaleAspectFit方法的调用中,主线程在负责渲染UI剩余部分的时候,该方法中又在重新绘制图片
可以通过设置Core Animation提供的setContentMode
来处理图片的重绘,使用现有的图片像素,减少主线程工作量
再启动XCTest发现问题解除
引用
Explore UI animation hitches and the render loop
Find and fix hitches in the commit phase
Demystify and eliminate hitches in the render phase