什么是卡顿?
App 在使用过程中出现了一段时间的阻塞,其表现为在用户触摸屏幕后,需要等待一段时间 App 才有响应,在这段时间内用户都无法进行其它操作,屏幕上的内容也没有任何的更新,正如上述示例所示。
为什么要治理卡顿?
- 用户会不耐烦主动退出应用或超出系统阈值而发生崩溃;而这种体验对用户的伤害其实比普通的崩溃更加严重
- 持续的卡顿可能导致用户退出并转去使用其他应用
- 甚至可能会卸载应用并在 App 商店留下不好的评论,直接关系到用户留存率、DAU 和 DNU 等各项产品数据。
因此卡顿目前已成为 iOS 最重要的性能指标之一,治理并减少 App 的卡顿率变得尤其关键。
卡顿分析
正常情况下,iOS 默认显示频率是 60Hz,所以 GPU 渲染只要达到 60fps 就不会产生卡顿;以 60fps 为例,vSync 会每 16ms(1/60) 触发一次渲染,假如在 16ms 内没有准备好下一帧数据就会使画面停留在上一帧,就造成了掉帧或卡顿现象
由于 UIKit 是非线程安全的,因此一切与 UI 相关的操作都必须放在主线程执行,系统会每 16ms 将 UI 的变化计算重新绘制,渲染至屏幕上。如果 UI 刷新的间隔能小于 16ms,那么用户是不会感到卡顿的。但是如果在主线程进行了一些耗时操作,阻碍了 UI 的刷新,那么就会产生卡顿,甚至是卡死。
主线程对任务的处理是基于 Runloop 机制,如下图所示。Runloop 支持并提供给外部注册 6 个时机的事件回调,分别是:
- RunloopEntry
- RunloopBeforeTimers
- RunloopBeforeSources
- RunloopBeforeWaiting
- RunloopAfterWaiting
- RunloopExit
其流转关系如下图所示。Runloop 在没有任务需要处理的时候就会进入休眠状态,直至有信号将其唤醒,它又会去处理新的任务。
在日常开发中,UIEvent事件、Timer事件以及dispatch主线程块任务都是在 Runloop 循环机制的驱动下完成的。一旦我们在主线程中的任一个环节进行了一个耗时的操作,或者因为锁使用不当造成了主线程因等待而阻塞,那么主线程就会因为无法执行Core Animation回调而造成界面无法刷新。而用户的交互又依赖于UIEvent事件的传递和响应,该流程也必须在主线程中完成。所以主线程的阻塞会导致应用 UI 和交互双双阻塞,这也是导致卡顿的根本原因。
实际在日常开发中通常造成卡顿的原因主要是以下几种:
- 主线程执行耗时的任务(CPU 密集型任务),比如调用UIGraphicsGetCurrentContext等接口在 CPU 上进行绘制计算;
- 主线程等待繁忙的子线程或低优先级的后台线程任务而导致阻塞,比如在主线程使用queue.sync同步派发任务或使用semaphore.wait()将异步调用转化为同步调用等;
- 主线程等待系统资源,比如使用Data(contentsOf:)进行 IO 读取等;
如何排查卡顿?
在 iOS 16 和 Xcode 14 以前,Apple 提供了 Instruments、MetricKit 以及 Xcode Organizer 等工具供开发者在不同开发阶段进行 App 性能的统计分析,但是针对卡顿的排查分析十分有限。值得高兴的是今年 Apple 在 iOS 16 和 Xcode 14 上更新了一些帮助开发者在不同开发阶段进行排查和分析卡顿的工具。它们分别是:
- Thread Performance Checker
- Hang detection in Instruments
- On-Device Hang Detection
- Xcode Reports Organizer
Thread Performance Checker
首先是开发阶段,当我们在使用 Xcode 进行真机调试时,可以在 Edit Scheme -> Run -> Diagnostics 选项卡中开启 Thread Performance Checker。(Xcode 14 默认开启)
开启 Thread Performance Checker 后,Xcode 如果检测到 App 在运行时,有例如线程优先级反转和非 UI 工作在主线程运行等问题时就会在 Xcode 问题导航栏中提示该卡顿风险警告。这可以帮助我们在开发初期就能发现并去解决隐含的卡顿风险问题。
Instruments
Xcode 14 的 Timer Profiler 工具分析 App 重现的卡顿问题时,可以惊喜地发现新的 Timer Profiler 在检测到 App 有卡顿问题时就会在轨道时间线上展示红色的 Hang 标记,该标记的长度代表了卡顿的时间间隔。然后,我们可以通过点击三次 Hang 标记过滤出该卡顿时间间隔区间内的所有事件并展开详细的线程轨道视图,以方便查看其它线程的繁忙情况。如下图所示,可以看到主线程在这段时间内属于空闲状态,而有一个 worker 子线程在这段时间内却属于繁忙状态,可见应该是主线程在等待该子线程完成任务。这与上述 Thread Performance Checker 中展示的卡顿风险警告遥相呼应,最后我们可以展开 Timer Profiler 下方的调用堆栈分析当时子线程的堆栈信息,结合实际上下文并最终解决主线程阻塞问题。
值得一提的是上述的 Instruments 中卡顿检测与标记在 Timer Profiler 和 CPU Profiler 工具中同样都是默认可用的,另外也可以在其它 Trace 模版中添加 Hang tracing 跟其他工具结合进行测试,不过需要注意的是单独的 Hang tracing 只能检测到运行期间是否发生了卡顿以及卡顿时长,并没有实际的堆栈信息,所以在实际利用 Instruments 排查卡顿时还是建议优先使用 Timer Profiler 进行分析。
On-Device Detection
前面讨论了可利用 Thread Performance Checker 和 Instruments 中的卡顿检测工具来帮助我们发现并定位问题,其实可以发现这两个工具都是线下的定位手段。虽然我们可能在开发阶段已经做足了相对完整的测试,并取得了较好的测试覆盖率,但是在后续的 Beta 测试阶段和线上发布阶段中也有可能会出现自己没考虑到的卡顿问题的路径。这时候用户设备都是无法连接到 Xcode 进行线下调试的,所以就非常依赖线上的工具进行定位问题。谈到线上工具,首先值得高兴的是今年 Apple 在 iOS 16 的开发者设置中引入了 Hang Detection(卡顿检测)功能,为 App 运行时提供实时的卡顿检测通知并诊断的能力,不过这只适用于由开发证书签名的以及通过 TestFlight 分发的应用,换言之就是该功能只能统计通过 Xcode 安装的 Debug 包和通过 TestFlight 安装的 Release 包,而通过 AppStore 安装的应用或企业包则不能被统计
功能具体打开方式: Settings -> Developer -> Hang Detection,并切换 Enable Hang Detection 开关状态到开启状态。开启后可以看到以下三部分:
Hang Threshold:可设置卡断检测的阈值,目前只有 250ms、500ms、1000ms 和 2000ms 四个可选;
Monitored Apps:展示可监控的 App 列表;(注意:只展示由开发证书签名的和通过 TestFlight 安装的应用,企业证书签名无法适用)
Avalable Hang Logs:展示了收到卡顿警告通知时诊断所产生的卡顿日志列表,这个后续我们排查具体问题时会用到;
MetricKit
然后说回到线上工具,值得一提的是 MetricKit 框架,它是 Apple 在 iOS 13 发布的用于收集和诊断性能的工具,其中就包括卡顿指标。不过它始终只是一个框架,线上使用时还需要人为接入它并上报相关诊断数据才能进行系统性地分析,不过其实苹果也考虑到了这点,也一并在 Xcode Organizer 中加入相应的指标分析能力
Xcode Organizer
最后当 App 发布到正式环境以后,后续我们就可以通过 Xcode Organizer 来分析线上版本 App 的性能指标。Xcode 14 以前 Organizer 只提供了卡顿率这种经过系统性分析后的数据指标,并没有提供诸如包含堆栈信息的卡顿报告来帮助排查定位,功能上相对鸡肋。不过在 Xcode 14 上 Organizer 终于支持了 Hang Reports,它能收集并上报线上用户在遇到卡顿时系统所产生的诊断报告数据(前提是用户同意了与 App 开发者共享应用分析)。如下图,Xcode 14 Organizer 的 Reports 分类中新增加了 Hang Reports 栏目。左起第二栏展示问题的聚合列表,问题按用户影响程度进行排序;第三栏展示了具体问题的堆栈信息,可帮助开发者分析定位卡顿原因;第四栏展示了具体问题的汇总统计信息,比如发生卡顿的数量,操作系统和设备分布比例等。
例如上图所示,我们观察到 Hangs Reports 的问题列表中最顶部的问题占了该版本卡顿问题的 21%,问题相当严重。我们可以尝试解决该问题,选中该问题并展开查看具体的堆栈信息,最终可以推断出该问题是因为在主线程同步读取磁盘文件而引起阻塞。这里补充说明下上述堆栈信息是经过符号化后的结果,具体只要用户在 App 上传到 App Store 时一并上传符号信息,报告中的堆栈信息就能自动符号化了。
如何解决卡顿?
在分析和定位到了具体的卡顿原因之后,我们就可以着手解决问题了。面对大多日常开发中的常见问题我们可以有以下通用解决方案:
- 将 CPU 密集型工作迁移到子线程队列处理,降低主线程繁忙的概率;
- 避免主线程等待子线程的场景,尽量使用异步子线程处理任务,完成后通知回调到主线程的方式;
- 特别需要注意的是在主线程上访问一些原子变量或使用锁(例如
pthread_rwlock_t
)访问数据时,可能会与其它子线程同时竞争锁,当其它子线程先获取到锁时,主线程就会因为等待而阻塞,所以尽量避免在主线程频繁的访问原子变量和锁,即使不可避免的要在主线程上使用锁,推荐优先使用能够提升线程优先级的锁(例如pthread_mutex
和os_unfair_lock
),能够避免发生优先级反转问题。 - 避免在主线程同步读取磁盘或网络数据,可通过在后台线程代替处理,待 IO 操作完成后再回调主线程;
参考资料:
1、 apple session 10082
2、 iOS 稳定性问题治理:卡死崩溃监控原理及最佳实践
3、利用 Xcode 和设备上的检测工具排查卡顿