天罗地网? iOS卡顿监控实战(附源码)

最近这几天终于闲了一点,终于有空写写之前就定下来的这篇博文,虽然周末是有时间啦,但是我懒 :)(理直气壮)

曾几何时跟项目大佬有过这样的对话

大佬:最近有用户反馈用起来卡卡的,不太流畅,有找到原因吗?
我:没找到,用户的操作路径太泛,没有复现。
大佬:那你想想办法如何监控线上卡顿吧。
我:.....
复制代码

行吧,那就自己撸一个。

这时候可能有小机灵举手问了:国内主流集成平台如友盟、听云、Bugly等均有卡顿监控,为啥还要自己开发?

因为想装逼。

开个玩笑,实际上是因为公司项目处于隐私合规考虑,没有使用国内平台而使用了 Fabric ,但它又没有提供卡顿监控这部分功能,只能自己动手丰衣足食了。

卡顿场景

基于我们的项目来看,用户在使用上会感觉到卡顿的场景,主要分为两种:

  • 用户在操作之后无法进行下一步,卡死在当前页面,过一会才恢复。(主线程阻塞)
  • 查词候选、云输入等出现慢,但用户仍可继续操作。(网络原因,子线程阻塞,如文件读写,低效计算,数据转换等)

很明显第一种情况最为致命,卡顿监测工具首先能够监测主线程阻塞,并且能够及时打印主线程上的方法栈,上传到统计平台便于开发者修复。

那么接下来就有两个问题需要考虑,如何监测线程阻塞和如何收集方法栈并上传。

方案选择

较早之前我就自己写过 runloop 版的实现,其实这也算是卡顿监控的标准答案,微信很早就是通过 runloop 来实现的,可能有些同学还看过那篇博文,后来微信推广到了整个Bugly平台。

这次有时间去做这个事情,当然不会拿着以前的代码再修修补补,经过一两天的海选,最终有四种实现方式在等待我的亮灯:

fps ping runloop hook msgSend
卡顿反馈 高,但在table滑动、转场动画等情况也会有下滑,会收集较多无用记录 高,能有效收集主线程卡顿,且可以控制卡顿阈值 中高,监控状态切换耗时,但timer、dispatchMain、source1事件可能反馈不到位 极高,可能会采集到大量系统方法消耗
采集精度 低,需cpu空闲时才能回调,栈信息采集不够及时 高,卡顿时能准确获取到栈信息 中高 极高,只要是方法耗时,均会拦截
性能损耗 中低,闲置时会频繁唤醒runloop处理 中,需要一个常驻子线程 低,仅监控runloop状态 高,任意方法均会hook,处理量太大
开发成本 低,使用CADisplayLink实现 低,常驻子线程ping主线程,及时释放临时变量 中低,实现代码相对较多 中高,依赖runtime,需使用OC编写

fps 的方案可能有些同学比较熟悉,因为这是一个监测页面流畅度的比较常见的手段(有兴趣的同学可以谷歌,烂大街了没必要再写),但是精度实在是比较低,并且不支持子线程,不符合我们的要求。

runloop 的方式也不错,但是最关键的一点,我们日常的多线程开发,使用最多的是 GCDOperationQueue ,这两者都是自己维护的线程池,我们没法插手,想要监控子线程,还得使用 Thread 来开发多线程,我选择狗带。

如果有同学对 runloop 的方案感兴趣,可以移步 iOS开发小记-RunLoop篇 ,在实际应用中有相关介绍及核心代码。

hook msgSend 的方案是我唯一没有实践的,其实 runtime 实现上问题倒是不大,但是一想到所有方法都被hook,然后前后添加耗时打印,程序一运行起来无数日志满屏飞就头大,并且该方案肉眼可见的性能损耗,只能放弃。

ping 的方案卡顿反馈、采集精度都有不错的表现,监控效果强,且性能损耗和开发成本较低,轻松支持全线程,为它疯狂打call。

代码实现

ping 的实现说白了就是线程同步,提供一个额外的worker线程去定期在目标线程里修改全局状态位,如果目标线程此时有空,必然能对标记位进行修改,如果worker线程超时发现标记位没变,那么可以推测目标线程必然仍在处理其他任务,此时上报所有线程的堆栈。

  • 核心代码
private final class WorkerThread: Thread {
    // 监控间隔
    private let threshold: CGFloat
    // 捕获闭包
    private let catchHandler: () -> Void
    // 信号量控制,避免重复上报
    private let semaphore = DispatchSemaphore(value: 0)

    // 递归锁保证全局变量多线程安全
    private let lockObj = NSObject()
    private var _isResponse = true
    private var isResponse: Bool {
        get {
            objc_sync_enter(lockObj)
            let result = _isResponse
            objc_sync_exit(lockObj)
            return result
        }

        set {
            objc_sync_enter(lockObj)
            _isResponse = newValue
            objc_sync_exit(lockObj)
        }
    }

    init(_ t: CGFloat, _ handler: @escaping () -> Void) {
        threshold = t
        catchHandler = handler
        super.init()
    }

    override func main() {
        // 生命不息,监控不止
        while !isCancelled { 
            // 及时释放临时变量
            autoreleasepool { 
                // 全局标记位,实际上使用局部变量也可以,只要注意OC语法下在block中修改需要对局部变量声明__weak
                isResponse = false 
                // 主线程同步标志位,同时释放信号量
                DispatchQueue.main.async {
                    self.isResponse = true
                    self.semaphore.signal()
                }
                
                // 暂停指定间隔,检验此时标志位是否修改,没有修改则说明线程卡顿,需要上报
                Thread.sleep(forTimeInterval: TimeInterval(threshold))
                if !isResponse {
                    catchHandler()
                }
                
                // 避免重复上报,一次卡顿仅上报一次(这里与微信runloop方案有比较大的区别,微信是会按照斐波拉契数列重复上报)
                _ = semaphore.wait(timeout: DispatchTime.distantFuture)
            }
        }
    }
}
复制代码
  • 堆栈获取

关于堆栈获取的实现可以移步 戴铭-深入剖析 iOS 性能优化 ,在这里就不再赘述。同学们也可以找找相关的开源库或者按需要改改铭神的demo,应该比较容易,如果有需要,我自己撸的swift版整理下也可以开源出来。

  • 堆栈上报

既然捕获到了卡顿,在捕获闭包里,我们需要获取到全线程的堆栈信息并且上报

func handleThread(reason: String, domain: XXPerformanceMonitorDomain) {
    // 1.获取堆栈
    // 2.上报
}
复制代码

实际上我们捕获回调时仍然在worker线程中(问题1),由于此时目标进程还在执行,想要更精确的结果,最好的方式就是暂停主线程(问题2),保证此时捕获的堆栈是准确的,这里可以通过 pthread_kill 来实现,大致代码如下:

// 注册signal handle
signal(CALLSTACK_SIG, thread_singal_handler)

// 捕获闭包中暂停主线程
func handleThread(reason: String, domain: XXPerformanceMonitorDomain) {
    pthread_kill(mainThreadID, CALLSTACK_SIG)
}

// 在signal handle中获取堆栈信息
func thread_singal_handler(sig: int) {
    // 捕获当前堆栈
}

复制代码

但是这里当时遇到一个非常棘手的问题,由于我们上报数据使用的是Fabric的 Non-fatals 上报,在 thread_singal_handler 中调用相关API上传堆栈地址数据时,总是收集到异常崩溃,由于 thread_singal_handler 中需要确保safe的调用,而翻阅官方文档发现相关API在主线程可能是不安全的,配合使用可能会导致偶现死锁崩溃。

最终无奈放弃了 pthread_kill 的方案而回调后直接进行上报,实际上由于堆栈地址获取耗时并不明显,直接上报造成的误差实际上还是可以接受的。

问题1:为什么不能在目标线程中回调?

问题2:除了主线程,可以暂停子线程么?如果可以,是否有意义?

如何分析数据

首先明确监测到卡顿的落点堆栈, 并不一定代表最后一个调用栈单个耗时就超过了阈值,它只是表示在整个方法执行中,执行到最后一个调用栈时已经超过了阈值 ,所以我们需要根据堆栈信息的上下文来分析和判断可能存在的卡顿点,不要只盯着最后一个调用栈分析。

它跟崩溃的强定位不同,更多的只是定位到可能存在的地方,用于辅助RD去分析。如下图

虽然最终定位的 子方法c ,但实际 子方法b 才是真正造成卡顿的原因。

我来评几句
登录后评论

已发表评论数()

相关站点

+订阅
热门文章