UI卡顿
随着 App 的不断发展壮大,UI 卡顿成为了一个常见的问题。当我们在使用 App 时,频繁出现卡顿现象或长时间不响应,这会对用户的使用体验造成极大的影响。因此,解决 UI 卡顿问题成为我们业务上需要重点关注的问题。
造成卡顿现象的原因有很多,其中最常见的原因是在主线程上执行耗时的任务。一些常见的原因包括:
- 复杂 UI、图文混排的绘制量过大;
- 在主线程上进行网络同步请求;
- 在主线程上进行大量的 IO 操作;
- 运算量过大导致 CPU 持续高占用、死锁和主子线程抢锁等;
针对这些原因,我们需要采取相应的措施来解决问题,以提高 App 的用户体验。
在提到 UI 监控时,通常情况下,如果一个 App 卡死超过 20 秒,就会触发系统保护机制而发生崩溃。虽然在用户设备中可以找到操作系统生成的卡死崩溃日志,但 App 层面是没有权限获取这些日志的。
因此,网上常见的解决方案之一是通过监视 FPS(每秒帧数)来检测卡顿。FPS 代表一次垂直同步时间内屏幕刷新的次数,一般 24 帧可以满足需求。随着 iPhone 目前大部分机型屏幕刷新频率从每秒 60 帧提升到每秒 120 帧,视觉效果更加流畅。
然而,通过监视 FPS 来检测卡顿虽然可以发现问题,但很难定位到具体原因。而苹果官方虽然提供了很多 UI 监控工具却无法继承到线上。 因此,我们需要使用细致且能集成上线的监控方案来解决这个问题。
既然卡顿问题通常是发生在主线程上,我们可以直接观察主线程的操作情况。在 App 启动时,会为主线程启动一个 NSRunLoop,使得主线程可以一直保持运行状态,并在有消息时及时处理,没有消息时则处于休眠状态。在运行过程中,NSRunLoop 会发生各种状态变化,我们可以通过监听这些状态变化的超时时间来判断是否发生卡顿。
接下来,我们先分析下 NSRunLoop 的运行原理。
NSRunLoop 的原理
NSRunLoop 是 iOS 基于 CFRunLoop 封装的高级类,CFRunLoop 是 Core Foundation 框架的 API。CFRunLoop 和线程是一一对应的,CFRunLoop 必定会伴随一条线程,但线程却不一定需要 CFRunLoop。iOS 系统内部维护了一个静态全局字典 CFRunLoopRefs,用来维护线程和线程对应的 CFRunLoop 实例,其中 key 值是线程,value 值是 CFRunLoop。如下图所示:
CFRunLoop 内部包含了若干个 Mode,Mode 的类型是 CFRunLoopModeRef,CFRunLoop 每次只能启动一个 Mode 运行,如果需要切换 Mode,只能先退出当前的 Mode,再选择新的 Mode 进入。iOS 目前预设了两种 Mode 类型,分别是 kCFRunLoopDefaultMode 和 UITrackingRunLoopMode。一个 Mode 里面又包含了若干个 Mode Item,分别是 Source0、Source1、Timer 和 Observers。Mode 与 Mode 之间的 Mode Item 是隔离的,互不影响的。如下图所示:
Mode 中的 Source 类型是 CFRunLoopSourceRef,主要有两类,分别是 Source0 和 Source1,这要来源于触摸事件。Timer 的类型是 CFRunLoopTimerRef,主要来源于系统的定时任务,如 NSTimer。Observer 的类型是 CFRunLoopObserverRef,主要是用来监听 CFRunLoop 中的状态变化、目前 CFRunLoop 的状态如下图所示:
接下来,通过 RunLoop 的源码,来探索 CFRunLoop 的运行过程。
第一步,通知 Observers,即将进入 RunLoop:
// 通知 Observer,即将进入 RunLoop
if (currentMode->_observerMask & kCFRunLoopEntry ) __CFRunLoopDoObservers(rl, currentMode, kCFRunLoopEntry);
第二步,通过 do while 来保活线程,同时通知 Observers 状态变化:即将出发 Timer 回调、Source0 回调、执行加入的 Block:
// 通知 Observers:RunLoop 即将触发 Timer 回调
if (rlm->_observerMask & kCFRunLoopBeforeTimers) { __CFRunLoopDoObservers(rl, rlm, kCFRunLoopBeforeTimers); }
// 通知 Observers:RunLoop 即将出发 Source0(非 port)回调
if (rlm->_observerMask & kCFRunLoopBeforeSources) { __CFRunLoopDoObservers(rl, rlm, kCFRunLoopBeforeSources); }
// 执行被加入的 Block
__CFRunLoopDoBlocks(rl, rlm);
如果有 Source 1 是 ready 状态,则跳到 handle_msg 对事件进行处理:
// 如果有 Source1(基于 port)处于 ready 状态,直接处理这个 Source1 然后跳转去处理消息
if (__CFRunLoopServiceMachPort(dispatchPort, &msg, sizeof(msg_buffer), &livePort, 0, &voucherState, NULL, rl, rlm)) {
goto handle_msg;
}
第三步,通知 Observers,RunLoop 即将进入休眠:
// 通知 Observers:RunLoop 的线程即将进入休眠
if (!poll && (rlm->_observerMask & kCFRunLoopBeforeWaiting))
__CFRunLoopDoObservers(rl, rlm, kCFRunLoopBeforeWaiting);
第四步,进入休眠后,会等待 mach_port 的消息,以再次唤醒,只有下面四个事件出现才会被唤醒:
- 一个基于 port 的Source 的事件。
- 一个 Timer 到时间了
- RunLoop 自身的超时时间到了
- 被其他什么调用者手动唤醒
等待唤醒代码如下:
do {
__CFRunLoopServiceMachPort(waitSet, &msg, sizeof(msg_buffer), &livePort, poll ? 0 : TIMEOUT_INFINITY, &voucherState, &voucherCopy, rl, rlm);
if (modeQueuePort != MACH_PORT_NULL && livePort == modeQueuePort) {
break;
}
if (currentMode->_timerFired) { break; }
} while (1)
第五步:通知 Observers,RunLoop 被唤醒,代码如下:
// 通知 Observers:RunLoop 的线程刚被唤醒
if (!poll && (rlm->_observerMask & kCFRunLoopAfterWaiting))
__CFRunLoopDoObservers(rl, rlm, kCFRunLoopAfterWait
第六步:RunLoop 唤醒后就要开始处理消息:
- 如果是 Timer 时间到的话,就触发 Timer 回调
- 如果是 dispatch 的话,就执行 block
- 如果是 Source1 事件的话,就处理事件
代码如下:
// 如果是 Timer 到时,触发 Timer 回调
if (!__CFRunLoopDoTimers(rl, rlm, mach_absolute_time())) {
// Re-arm the next timer __CFArmNextTimerInMode(rlm, rl);
}
// 如果有 dispatch 到 main_queue 的 block,执行 block
else if (livePort == dispatchPort) {
__CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__(msg); }
// 如果一个 Source1 (基于port) 发出事件了,处理这个事件 else { CFRunLoopSourceRef
rls = __CFRunLoopModeFindSourceForMachPort(rl, rlm, livePort);
sourceHandledThisLoop = __CFRunLoopDoSource1(rl, rlm, rls, msg, msg->msgh_size, &reply) || sourceHandledThisLoop;
if (NULL != reply) {
(void)mach_msg(reply, MACH_SEND_MSG, reply->msgh_size, 0, MACH_PORT_NULL, 0, MACH_PORT_NULL);
CFAllocatorDeallocate(kCFAllocatorSystemDefault, reply);
}
} // 执行加入到 Loop 的 block __CFRunLoopDoBlocks(rl, rlm);
第七步,根据当前的 RunLoop 状态来判断是否需要继续下一个 loop,当被外部强制停止或 loop 超时,就不需要继续下一个 loop了,代码如下:
if (sourceHandledThisLoop && stopAfterHandle) {
// 通过参数说明处理完就返回 retVal = kCFRunLoopRunHandledSource;
} else if (timeout_context->termTSR < mach_absolute_time()) {
// 超出入参的超时时间,则标记超时 retVal = kCFRunLoopRunTimedOut;
} else if (__CFRunLoopIsStopped(rl)) {
// 被外部强制暂停了
__CFRunLoopUnsetStopped(rl);
retVal = kCFRunLoopRunStopped;
} else if (__CFRunLoopModeIsEmpty(rl, rlm, previousMode)) {
// mode 里面没有 source/timer/observer retVal = kCFRunLoopRunFinished;
}
// 如果没有超时,mode 没空,loop 没有停止,则继续 loop
整个 RunLoop 过程,可以总结为如下所示一张图:
用 RunLoop 的状态来监控卡顿
通过对 RunLoop 的源码分析,可以发现在进入 kCFRunLoopBeforeSources 或 kCFRunLoopAfterWaiting 这两个状态后,会执行 block 或者对消息做处理,如果 RunLoop 在这两个状态停留过久,可以认为线程在执行耗时任务。如果这个线程是主线程,表现出来就是卡顿。所以,我们可以通过关注这两个阶段,来实现 UI 卡顿监控的目的。
第一步,创建一个 CFRunLoopObserverContext 观察者,同时将其添加到主线程 RunLoop 的 common 模式下观察,代码如下:
private var runLoopObserver: CFRunLoopObserver?
let semaphore: DispatchSemaphore
private var runLoopActivity:
CFRunLoopActivity runLoopObserver = CFRunLoopObserverCreateWithHandler(kCFAllocatorDefault, CFRunLoopActivity.allActivities.rawValue, true, 0, {[weak self] (observer, activity) -> Void in
guard let `self` = self else { return }
self.runLoopActivity = activity self.semaphore.signal()
})
CFRunLoopAddObserver(CFRunLoopGetMain(), runLoopObserver!, .commonModes)
第二步,创建一条子线程,专门用来监控主线程的 RunLoop 状态,当信号量等待超时后,通过判断 RunLoop 此时的状态,如果是 kCFRunLoopBeforeSources 或 kCFRunLoopAfterWaiting,则可以判断发生了卡顿。我们可以 dump 出堆栈信息,分析哪一步的方法执行过长:
monitorQueue.async { [weak self] in
guard let `self` = self else { return }
while true {
let semaphoreWait = self.semaphore.wait(timeout: .now()+20)
if semaphoreWait == .timedOut {
if self.runLoopObserver == nil { return }
if self.runLoopActivity == .beforeSources || self.runLoopActivity == .afterWaiting {
self.dumpQueue.async { // 打印 dump 信息 }
}
}
}
子线程监控发现卡顿后,还需要记录当前出现卡顿堆栈信息,具体可以考虑使用 PLCrashReporter 来获取堆栈信息,代码如下:
private let crashRport: PLCrashReporter = PLCrashReporter(configuration: PLCrashReporterConfig(signalHandlerType: .BSD, symbolicationStrategy: PLCrashReporterSymbolicationStrategy(rawValue: 0)))
let data = self.crashRport.generateLiveReport()
do {
let lagReport = try PLCrashReport(data: data)
let lagReportString = PLCrashReportTextFormatter.stringValue(for: lagReport, with: .init(0))
} catch { }
小结
今天主要分享了卡顿监控的方案。首先,我们了解了在日常编码中可能会造成 UI 卡顿的原因。然后,通过了解 RunLoop 的原理,我们可以更好地理解如何进行 RunLoop 卡顿监控。
RunLoop 是 iOS 中非常重要的一个机制,它负责处理输入源并调度任务,是保持主线程存活的关键。在卡顿监控中,我们可以通过监听 RunLoop 的状态变化,来判断主线程是否出现卡顿。例如,我们可以在 RunLoop 的空闲时间内执行一些监控任务,当 RunLoop 超时时,说明主线程在执行某些耗时操作而无法响应用户的操作,即发生卡顿现象。
卡顿监控可以帮助我们及时发现主线程中的耗时操作,从而及时采取措施来解决卡顿问题,提高 App 的用户体验。同时,卡顿监控也可以提供详细的卡顿信息和日志,帮助开发人员更好地定位问题并加以解决。