APM - iOS 卡顿监控 Hitch

4,784 阅读7分钟

简介

卡顿的定义有多种,按照卡住的程度排列的话,有这么几种,从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属性定义阴影形状
  • 使用cornerRadiuscornerCurve来画圆角

优化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

Eliminate animation hitches with XCTest

Apple Document XCTMetric