Wu: Practical use of RunLoop

Runloop is a very important part in iOS. For any single-threaded UI model, EvenLoop must be used to process different events continuously, and RunLoop is the implementation of the EvenLoop model in iOS. In the previous articles, I have introduced the underlying principles of Runloop, etc. This article mainly discusses the scenarios in which we can use RunLoop from the perspective of actual development.

thread keep alive

In actual development, we usually encounter the creation of resident threads, such as sending heartbeat packets, which can send heartbeat packets in a resident thread without interfering with the behavior of the main thread, such as audio processing, which also Can be processed in a resident thread. AFNetworking 1.0, previously used in Objective-C, uses RunLoop to keep threads alive.

var thread: Thread!

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

It is worth noting that at least one port/timer/observer is required in the mode of RunLoop, otherwise RunLoop will only execute once and then exit.

stop runloop

There are two ways to leave RunLoop: one is to configure a timeout period for RunLoop, and the other is to actively notify RunLoop to leave. Apple recommends the first method in the document. If it can be directly and quantitatively managed, this method is of course the best.

set timeout

However, in practice, we cannot accurately set the timeout time. For example, in the example of thread keep alive, we need to ensure that the thread's RunLoop keeps running, so the end time is a variable, not a constant. To achieve this goal, we You can combine the API provided by RunLoop. At the beginning, set the RunLoop timeout to infinite, but at the end, set the RunLoop timeout to current, so that the RunLoop is stopped by controlling the timeout time in disguise. The specific code is as follows:

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
}
复制代码

just stop

CoreFoundation provides an API: CFRunLoopStop()but this method will only stop the RunLoop of the current loop, and will not stop the RunLoop completely. So are there other strategies? We know that there must be at least one port/timer/observer in the Mode of RunLoop to work, otherwise it will exit, and the API provided by CF happens to have:

**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的运用远不止这些,如果有更多更好的使用,希望大家可以留言交流。

Guess you like

Origin juejin.im/post/7119404712272592933