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。如下图所示:

提升用户体验的关键:UI监控方案

CFRunLoop 内部包含了若干个 Mode,Mode 的类型是 CFRunLoopModeRef,CFRunLoop 每次只能发动一个 Mode 运转,假如需求切换 Mode,只能先退出当时的 Mode,再挑选新的 Mode 进入。iOS 现在预设了两种 Mode 类型,分别是 kCFRunLoopDefaultMode 和 UITrackingRunLoopMode。一个 Mode 里面又包含了若干个 Mode Item,分别是 Source0、Source1、Timer 和 Observers。Mode 与 Mode 之间的 Mode Item 是阻隔的,互不影响的。如下图所示:

提升用户体验的关键:UI监控方案

Mode 中的 Source 类型是 CFRunLoopSourceRef,首要有两类,分别是 Source0 和 Source1,这要来源于触摸事情。Timer 的类型是 CFRunLoopTimerRef,首要来源于体系的定时使命,如 NSTimer。Observer 的类型是 CFRunLoopObserverRef,首要是用来监听 CFRunLoop 中的状况改变、现在 CFRunLoop 的状况如下图所示:

提升用户体验的关键:UI监控方案

接下来,经过 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 进程,能够总结为如下所示一张图:

提升用户体验的关键:UI监控方案

用 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 的用户体验。同时,卡顿监控也能够提供详细的卡顿信息和日志,协助开发人员更好地定位问题并加以处理。