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

12,105 阅读10分钟

XXPerformanceMonitor是一个Swift版轻量卡顿监控工具,支持主线程和子线程,一句代码即可轻松集成,开源在蜗牛的Github,可以结合代码来阅读本文。

前言

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

大佬:最近有用户反馈用起来卡卡的,不太流畅,有找到原因吗?

蜗牛:没找到,用户的操作路径太泛,没有复现。

大佬:那你想想办法如何监控线上卡顿吧。

蜗牛:......

行吧,那就自己撸一个。

这时候可能有小机灵举手问了:国内主流集成平台如友盟、听云、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的方案卡顿反馈、采集精度都有不错的表现,监控效果强,且性能损耗和开发成本较低,轻松支持全线程,完全符合我的要求。

代码实现

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线程中,由于此时目标进程还在执行,想要更精确的结果,最好的方式就是暂停目标线程,保证此时捕获的堆栈是准确的,这里可以通过pthread_kill来实现,大致代码如下:

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

// 捕获闭包中暂停主线程
func handleThread(reason: String, domain: XXPerformanceMonitorDomain) {
    pthread_kill(threadID, 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:XXPerformanceMonitor中子线程监控仅支持OperationQueue,如果说不支持Thread是由于较少使用,那为什么GCD也不支持?

排查分析

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

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

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

常见的骚操作

这里简单举几个栗子🌰,有兴趣的同学欢迎留言补充:

图片读取

项目里为了控制内存,在读取图片时,往往使用UIImage(contentsOfFile: ),资源量一上来耗时往往超乎你的想象,针对该问题,有以下建议:

  • 需要频繁使用,但是又想不用后释放(例如二级面板,退出后应该清理使用内存),可以自行缓存,然后在deinit时手动释放
  • 较多图片处理时,考虑是否有需要放在主线程操作?

文件IO

一般来说,文件io不建议在主线程操作,一是不支持文件的并发处理(多线程读单线程写),二是不方便管理,三是相对耗时。除非场景需要且无法通过其他方式实现,否则不要放在主线程。

另外主线程中同步等待文件IO也是个比较骚的操作,尽量避免。

文本计算

可能有两种情况:

  • 一次性循环计算大量文本
  • 列表滑动时重复计算

文本计算单个操作耗时不算多,出现此类问题一般是使用问题,建议遇到该问题时,考虑如下两点:

  • 能否在子线程提前计算?
  • 计算结果能否复用?

由外部决定执行线程

本身存在线程安全的类,在实现之初内部就应该管理好线程安全,而不是随意让外部调用者决定在哪条线程来执行,非常容易造成难以发现的崩溃。

线程同步

一般开发时有两种骚操作:

  • 在写多线程的时候,无论是否有必要都放入同一个串行或单线程队列进行管理,导致部分耗时很少但需要同步的操作,需要等待队列里其他任务处理完
  • 修复多线程导致的崩溃,将有问题的地方直接撸进统一子线程处理,没有关注需要子线程同步回调的操作是否有在主线程中调用

建议在修改时考虑:

  • 该操作是否有必要进行多线程管理?是否有线程风险?
  • 能否使用闭包来代替线程同步?
  • 对已存在操作增加线程同步时,是否之前已在主线程调用?

如果我们在开发中使用到了包含线程同步的方法,考虑下是否有主线程卡顿风险:

  • 如果后续代码无需放在主线程,全部撸进后台
  • 如果需要放在主线程,可以现在后台调用存在线程同步的方法,再回到主线程做后续操作
  • 如果不仅要放在主线程,并且需要拿到结果return,考虑是否需要每次都调用?结果能否缓存?

上线效果

上线后通过数据收集和分析,发现了不少之前不易排查的卡顿点,并且通过两三个版本的迭代优化,将主线程上卡顿率由 1.1% 下降至 0.6% ,减少 45% ,整体效果还是令人满意的。

原创不易,文章有任何错误,欢迎批(feng)评(kuang)指(diao)教(wo),顺手点个赞👍,不甚感激!