伍:RunLoop的实际使用

语言: CN / TW / HK

Runloop在iOS中是一个很重要的组成部分,对于任何单线程的UI模型都必须使用EvenLoop才可以连续处理不同的事件,而RunLoop就是EvenLoop模型在iOS中的实现。在前面的几篇文章中,我已经介绍了Runloop的底层原理等,这篇文章主要是从实际开发的角度,探讨一下实际上在哪些场景下,我们可以去使用RunLoop。

线程保活

在实际开发中,我们通常会遇到常驻线程的创建,比如说发送心跳包,这就可以在一个常驻线程来发送心跳包,而不干扰主线程的行为,再比如音频处理,这也可以在一个常驻线程中来处理。以前在Objective-C中使用的AFNetworking 1.0就使用了RunLoop来进行线程的保活。

```swift var thread: Thread!

func createLiveThread() { thread = Thread.init(block: { let port = NSMachPort.init() RunLoop.current.add(port, forMode: .default) RunLoop.current.run() }) thread.start() } ```

值得注意的是RunLoop的mode中至少需要一个port/timer/observer,否则RunLoop只会执行一次就退出了。

停止Runloop

离开RunLoop一共有两种方法:其一是给RunLoop配置一个超时的时间,其二是主动通知RunLoop离开。Apple在文档中是推荐第一种方式的,如果能直接定量的管理,这种方式当然是最好的。

设置超时时间

然而实际中我们无法准确的去设置超时的时刻,比如在线程保活的例子中,我们需要保证线程的RunLoop一直保持运行中,所以结束的时间是一个变量,而不是常量,要达到这个目标我们可以结合一下RunLoop提供的API,在开始的时候,设置RunLoop超时时间为无限,但是在结束时,设置RunLoop超时时间为当前,这样变相通过控制timeout的时间停止了RunLoop,具体代码如下:

```swift var thread: Thread? var isStopped: Bool = false

func createLiveThread() { thread = Thread.init(block: { [weak self] in guard let self = self else { return } let port = NSMachPort.init() RunLoop.current.add(port, forMode: .default)

            while !self.isStopped {
            RunLoop.current.run(mode: .default, before: Date.distantFuture)
    }
    })
    thread?.start()

}

func stop() { self.perform(#selector(self.stopThread), on: thread!, with: nil, waitUntilDone: false) }

@objc func stopThread() { self.isStopped = true RunLoop.current.run(mode: .default, before: Date.init()) self.thread = nil } ```

直接停止

CoreFoundation提供了API:CFRunLoopStop() 但是这个方法只会停止当前这次循环的RunLoop,并不会完全停止RunLoop。那么有没有其它的策略呢?我们知道RunLoop的Mode中必须要至少有一个port/timer/observer才会工作,否则就会退出,而CF提供的API中正好有:

```objectivec **public func CFRunLoopRemoveSource(_ rl: CFRunLoop!, _ source: CFRunLoopSource!, _ mode: CFRunLoopMode!)

public func CFRunLoopRemoveObserver(_ rl: CFRunLoop!, _ observer: CFRunLoopObserver!, _ mode: CFRunLoopMode!)

public func CFRunLoopRemoveTimer(_ rl: CFRunLoop!, _ timer: CFRunLoopTimer!, _ mode: CFRunLoopMode!)** ```

所以很自然的联想到如果移除source/timer/observer, 那么这个方案可不可以停止RunLoop呢?

答案是否定的,这一点在Apple的官方文档中有比较详细的描述:

Although removing a run loop’s input sources and timers may also cause the run loop to exit, this is not a reliable way to stop a run loop. Some system routines add input sources to a run loop to handle needed events. Because your code might not be aware of these input sources, it would be unable to remove them, which would prevent the run loop from exiting.

简而言之,就是你无法保证你移除的就是全部的source/timer/observer,因为系统可能会添加一些必要的source来处理事件,而这些source你是无法确保移除的。

延迟加载图片

这是一个很常见的使用方式,因为我们在滑动scrollView/tableView/collectionView的过程,总会给cell设置图片,但是直接给cell的imageView设置图片的过程中,会涉及到图片的解码操作,这个就会占用CPU的计算资源,可能导致主线程发生卡顿,所以这里可以将这个操作,不放在trackingMode,而是放在defaultMode中,通过一种取巧的方式来解决可能的性能问题。

```swift func setupImageView() { self.performSelector(onMainThread: #selector(self.setupImage), with: nil, waitUntilDone: false, modes: [RunLoop.Mode.default.rawValue]) }

@objc func setupImage() { imageView.setImage() } ```

卡顿监测

目前来说,一共有三种卡顿监测的方案,然而基本上每一种卡顿监测的方案都和RunLoop是有关联的。

CADisplayLink(FPS)

YYFPSLabel 采用的就是这个方案,FPS(Frames Per Second)代表每秒渲染的帧数,一般来说,如果App的FPS保持50~60之间,用户的体验就是比较流畅的,但是Apple自从iPhone支持120HZ的高刷之后,它发明了一种ProMotion的动态屏幕刷新率的技术,这种方式基本就不能使用了,但是这里依旧提供已作参考。

这里值得注意的技术细节是使用了NSObject来做方法的转发,在OC中可以使用NSProxy来做消息的转发,效率更高。

```swift // 抽象的超类,用来充当其它对象的一个替身 // Timer/CADisplayLink可以使用NSProxy做消息转发,可以避免循环引用 // swift中我们是没发使用NSInvocation的,所以我们直接使用NSobject来做消息转发 class WeakProxy: NSObject { private weak var target: NSObjectProtocol?

init(target: NSObjectProtocol) {
    self.target = target
    super.init()
}

override func responds(to aSelector: Selector!) -> Bool {
    return (target?.responds(to: aSelector) ?? false) || super.responds(to: aSelector)
}

override func forwardingTarget(for aSelector: Selector!) -> Any? {
    return target
}

}

class FPSLabel: UILabel { var link: CADisplayLink! var count: Int = 0 var lastTime: TimeInterval = 0.0

fileprivate let defaultSize = CGSize.init(width: 80, height: 20)

override init(frame: CGRect) {
    super.init(frame: frame)

    if frame.size.width == 0 || frame.size.height == 0 {
        self.frame.size = defaultSize
    }

    layer.cornerRadius = 5.0
    clipsToBounds = true
    textAlignment = .center
    isUserInteractionEnabled = false
    backgroundColor = UIColor.white.withAlphaComponent(0.7)

    link = CADisplayLink.init(target: WeakProxy.init(target: self), selector: #selector(FPSLabel.tick(link:)))
    link.add(to: RunLoop.main, forMode: .common)
}

required init?(coder: NSCoder) {
    fatalError("init(coder:) has not been implemented")
}

deinit {
    link.invalidate()
}

@objc func tick(link: CADisplayLink) {
    guard lastTime != 0 else {
        lastTime = link.timestamp
        return
    }

    count += 1

    let timeDuration = link.timestamp - lastTime

    // 1、设置刷新的时间: 这里是设置为1秒(即每秒刷新)
    guard timeDuration >= 1.0 else { return }

    // 2、计算当前的FPS
    let fps = Double(count)/timeDuration
    count = 0
    lastTime = link.timestamp

    // 3、开始设置FPS了
    let progress = fps/60.0
    let color = UIColor(hue: CGFloat(0.27 * (progress - 0.2)), saturation: 1, brightness: 0.9, alpha: 1)
    self.text = "\(Int(round(fps))) FPS"
    self.textColor = color
}

} ```

子线程Ping

这种方法是创建了一个子线程,通过GCD给主线程添加异步任务:修改是否超时的参数,然后让子线程休眠一段时间,如果休眠的时间结束之后,超时参数未修改,那说明给主线程的任务并没有执行,那么这就说明主线程的上一个任务还没有做完,那就说明卡顿了,这种方式其实和RunLoop没有太多的关联,它不依赖RunLoop的状态。在ANREye中是采用子线程Ping的方式来监测卡顿的。

同时为了让这些操作是同步的,这里使用了信号量。

```swift class PingMonitor { static let timeoutInterval: TimeInterval = 0.2 static let queueIdentifier: String = "com.queue.PingMonitor"

private var queue: DispatchQueue = DispatchQueue.init(label: queueIdentifier)
private var isMonitor: Bool = false
private var semphore: DispatchSemaphore = DispatchSemaphore.init(value: 0)

func startMonitor() {
    guard isMonitor == false else { return }

    isMonitor = true

    queue.async {
        while self.isMonitor {

            var timeout = true

            DispatchQueue.main.async {
                timeout = false
                self.semphore.signal()
            }

            Thread.sleep(forTimeInterval:PingMonitor.timeoutInterval)

            // 说明等了timeoutInterval之后,主线程依然没有执行派发的任务,这里就认为它是处于卡顿的
            if timeout == true {
                //TODO: 这里需要取出崩溃方法栈中的符号来判断为什么出现了卡顿
                // 可以使用微软的框架:PLCrashReporter
            }

            self.semphore.wait()
        }
    }
}

} ```

这个方法在正常情况下会每隔一段时间让主线程执行GCD派发的任务,会造成部分资源的浪费,而且它是一种主动的去Ping主线程,并不能很及时的发现卡顿问题,所以这种方法会有一些缺点。

实时监控

而我们知道,主线程中任务都是通过RunLoop来管理执行的,所以我们可以通过监听RunLoop的状态来知道是否会出现卡顿的情况,一般来说,我们会监测两种状态:第一种是kCFRunLoopAfterWaiting 的状态,第二种是kCFRunLoopBeforeSource的状态。为什么是两种状态呢?

首先看第一种状态kCFRunLoopAfterWaiting ,它会在RunLoop被唤醒之后回调这种状态,然后根据被唤醒的端口来处理不同的任务,如果处理任务的过程中耗时过长,那么下一次检查的时候,它依然是这个状态,这个时候就可以说明它卡在了这个状态了,然后可以通过一些策略来提取出方法栈,来判断卡顿的代码。同理,第二种状态也是一样的,说明一直处于kCFRunLoopBeforeSource 状态,而没有进入下一状态(即休眠),也发生了卡顿。

```swift class RunLoopMonitor { private init() {}

static let shared: RunLoopMonitor = RunLoopMonitor.init()

var timeoutCount = 0

var runloopObserver: CFRunLoopObserver?
var runLoopActivity: CFRunLoopActivity?
var dispatchSemaphore: DispatchSemaphore?

// 原理:进入睡眠前方法的执行时间过长导致无法进入睡眠,或者线程唤醒之后,一直没进入下一步
func beginMonitor() {
    let uptr = Unmanaged.passRetained(self).toOpaque()
    let vptr = UnsafeMutableRawPointer(uptr)
    var context = CFRunLoopObserverContext.init(version: 0, info: vptr, retain: nil, release: nil, copyDescription: nil)

    runloopObserver = CFRunLoopObserverCreate(kCFAllocatorDefault,
                                              CFRunLoopActivity.allActivities.rawValue,
                                              true,
                                              0,
                                              observerCallBack(),
                                              &context)
    CFRunLoopAddObserver(CFRunLoopGetMain(), runloopObserver, .commonModes)

    // 初始化的信号量为0
    dispatchSemaphore = DispatchSemaphore.init(value: 0)

    DispatchQueue.global().async {
        while true {
            // 方案一:可以通过设置单次超时时间来判断 比如250毫秒
                            // 方案二:可以通过设置连续多次超时就是卡顿 戴铭在GCDFetchFeed中认为连续三次超时80秒就是卡顿
            let st = self.dispatchSemaphore?.wait(timeout: DispatchTime.now() + .milliseconds(80))

            if st == .timedOut {
                guard self.runloopObserver != nil else {
                    self.dispatchSemaphore = nil
                    self.runLoopActivity = nil
                                            self.timeoutCount = 0
                    return
                }

                if self.runLoopActivity == .afterWaiting || self.runLoopActivity == .beforeSources {
                                            self.timeoutCount += 1

                    if self.timeoutCount < 3 { continue }

                    DispatchQueue.global().async {
                        let config = PLCrashReporterConfig.init(signalHandlerType: .BSD, symbolicationStrategy: .all)
                        guard let crashReporter = PLCrashReporter.init(configuration: config) else { return }
                        let data = crashReporter.generateLiveReport()

                        do {
                            let reporter = try PLCrashReport.init(data: data)

                            let report = PLCrashReportTextFormatter.stringValue(for: reporter, with: PLCrashReportTextFormatiOS) ?? ""

                            NSLog("------------卡顿时方法栈:\n \(report)\n")
                        } catch _ {
                            NSLog("解析crash data错误")
                        }
                    }
                }
            }
        }
    }
}

func end() {
    guard let _ = runloopObserver else { return }

    CFRunLoopRemoveObserver(CFRunLoopGetMain(), runloopObserver, .commonModes)
    runloopObserver = nil
}

private func observerCallBack() -> CFRunLoopObserverCallBack {
    return { (observer, activity, context) in
        let weakself = Unmanaged<RunLoopMonitor>.fromOpaque(context!).takeUnretainedValue()

        weakself.runLoopActivity = activity
        weakself.dispatchSemaphore?.signal()
    }
}

} ```

Crash防护

Crash防护是一个很有意思的点,处于应用层的APP,在执行了某些不被操作系统允许的操作之后会触发操作系统抛出异常信号,但是因为没有处理这些异常从而被系操作系统杀掉的线程,比如常见的闪退。这里不对Crash做详细的描述,我会在下一个模块来描述iOS中的异常。要明确的是,有些场景下,是希望可以捕获到系统抛出的异常,然后将App从错误中恢复,重新启动,而不是被杀死。而对应在代码中,我们需要去手动的重启主线程,已达到继续运行App的目的。

```swift let runloop = CFRunLoopGetCurrent() guard let allModes = CFRunLoopCopyAllModes(runloop) as? [CFRunLoopMode] else { return }

while true { for mode in allModes { CFRunLoopRunInMode(mode, 0.001, false) } } ```

CFRunLoopRunInMode(mode, 0.001, false) 因为无法确定RunLoop到底是怎样启动的,所以采用了这种方式来启动RunLoop的每一个Mode,也算是一种替代方案了。因为CFRunLoopRunInMode 在运行的时候本身就是一个循环并不会退出,所以while循环不会一直执行,只是在mode退出之后,while循环遍历需要执行的mode,直到继续在一个mode中常驻。

这里只是重启RunLoop,其实在Crash防护里最重要的还是要监测到何时发送崩溃,捕获系统的exception信息,以及singal信息等等,捕获到之后再对当前线程的方法栈进行分析,定位为crash的成因。

Matrix框架

接下来我们具体看一下RunLoop在Matrix框架中的运用。Matrix是腾讯开源的一款用于性能监测的框架,在这个框架中有一款插件WCFPSMonitorPlugin这是一款FPS监控工具,当用户滑动界面时,记录主线程的调用栈。它的源码中和我们上述提到的通过CADisplayLink来来监测卡顿的方案的原理是一样的:

```objectivec - (void)startDisplayLink:(NSString *)scene { FPSInfo(@"startDisplayLink");

m_displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(onFrameCallback:)];
[m_displayLink addToRunLoop:[NSRunLoop currentRunLoop] forMode:NSRunLoopCommonModes];

    ...

}

  • (void)onFrameCallback:(id)sender { // 当前时间: 单位为秒 double nowTime = CFAbsoluteTimeGetCurrent(); // 将单位转化为毫秒 double diff = (nowTime - m_lastTime) * 1000;
    // 1、如果时间间隔超过最大的帧间隔:那么此次屏幕刷新方法超时
    

    if (diff > self.pluginConfig.maxFrameInterval) { m_currRecorder.dumpTimeTotal += diff; m_dropTime += self.pluginConfig.maxFrameInterval * pow(diff / self.pluginConfig.maxFrameInterval, self.pluginConfig.powFactor);

    // 总超时时间超过阈值:展示超时信息
    if (m_currRecorder.dumpTimeTotal > self.pluginConfig.dumpInterval * self.pluginConfig.dumpMaxCount) {
        FPSInfo(@"diff %lf exceed, begin: %lf, end: %lf, scene: %@, you can see more detail in record id: %d",
                m_currRecorder.dumpTimeTotal,
                m_currRecorder.dumpTimeBegin,
                m_currRecorder.dumpTimeBegin + m_currRecorder.dumpTimeTotal / 1000.0,
                m_scene,
                m_currRecorder.recordID);
                    ...... 
    }
    // 2、如果时间间隔没有最大的帧间隔:那么此次屏幕刷新方法不超时
    

    } else { // 总超时时间超过阈值:展示超时信息 if (m_currRecorder.dumpTimeTotal > self.pluginConfig.maxDumpTimestamp) { FPSInfo(@"diff %lf exceed, begin: %lf, end: %lf, scene: %@, you can see more detail in record id: %d", m_currRecorder.dumpTimeTotal, m_currRecorder.dumpTimeBegin, m_currRecorder.dumpTimeBegin + m_currRecorder.dumpTimeTotal / 1000.0, m_scene, m_currRecorder.recordID); .... // 总超时时间不超过阈值:将时间归0 重新计数 } else { m_currRecorder.dumpTimeTotal = 0; m_currRecorder.dumpTimeBegin = nowTime + 0.0001; } } m_lastTime = nowTime; }

```

它通过次数以及两次之间允许的时间间隔作为阈值,超过阈值就记录,没超过阈值就归0重新计数。当然这个框架也不仅仅是作为一个简单的卡顿监测来使用的,还有很多性能监测的功能以供平时开发的时候来使用:包括对崩溃时方法栈的分析等等。

总结

本篇文章我从线程保活开始介绍了RunLoop在实际开发中的使用,然后主要是介绍了卡顿监测和Crash防护中的高阶使用,当然,RunLoop的运用远不止这些,如果有更多更好的使用,希望大家可以留言交流。