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

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

曾几什么时候跟项目大佬有过这样的对话git

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

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

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

蜗牛:.....api

行吧,那就本身撸一个。缓存

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

由于想装逼。性能优化

开个玩笑,其实是由于公司项目处于隐私合规考虑,没有使用国内平台而使用了Fabric,但它又没有提供卡顿监控这部分功能,否则你觉得蜗牛闲的蛋疼→_→。bash

卡顿场景

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

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

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

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

方案选择

较早以前蜗牛就本身写过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),顺手点个赞,不甚感激!
相关文章
相关标签/搜索