Runloop在iOS中是一个很重要的组成部分,关于任何单线程的UI模型都必须运用EvenLoop才能够接连处理不同的事情,而RunLoop便是EvenLoop模型在iOS中的完成。在前面的几篇文章中,我现已介绍了Runloop的底层原理等,这篇文章主要是从实践开发的角度,讨论一下实践上在哪些场景下,咱们能够去运用RunLoop。

线程保活

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

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,具体代码如下:

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中正好有:

**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中,经过一种取巧的办法来解决或许的功用问题。

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来做音讯的转发,效率更高。

// 抽象的超类,用来充当其它目标的一个替身
// 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的办法来监测卡顿的。

一起为了让这些操作是同步的,这儿运用了信号量。

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 状况,而没有进入下一状况(即休眠),也发生了卡顿。

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的目的。

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来来监测卡顿的计划的原理是相同的:

- (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的运用远不止这些,假如有更多更好的运用,希望大家能够留言沟通。

  • 我正在参与技能社区创作者签约计划招募活动,点击链接报名投稿。