界面卡顿是哪些问题导致的?
死锁:主线程拿到锁A,需要获得锁B,而同时某个子线程拿了锁B,需要锁A,这样互相等待就造成死锁了。
抢锁:主线程需要访问DB,而此时某个子线程往DB插入大量数据。通常抢锁的体验是偶尔卡一阵子,过会就恢复了。
主线程大量IO:主线程为了方便直接写入大量数据,会导致界面卡顿。
主线程大量计算:算法不合理,导致主线程某个函数占用大量CPU。
大量的UI绘制:复杂的UI、图文混排等,带来大量的UI绘制。
针对这些原因,我们如何定位具体问题呢?
死锁一般会伴随crash,可以通过crash report来分析。
抢锁不好办,将锁等待时间打出来用处不大,我们还需要知道是谁占了锁。
大量IO可以在函数开始结束打点,讲占用时间打到日志中。
大量计算同理可以将耗时打到日志中。
大量UI绘制一般是必现,还好办;如果是偶现的话,想加日志点都没地方,因为是慢在系统函数里面。
卡顿困难点
1.不易重现。可能是特定用户的手机上才有问题,由于种种原因这个手机不能拿来调试;也有可能是特定的时机才会出问题,过后就不能重现了(例如线程抢锁)。
2.操作路径长,日志无法准确打点
对于这些界面卡顿反馈,通常我们拿用户日志作用不大,增加日志点也用处不大。只能不断重试希望能够重现出来,或者埋头代码逻辑中试图能找的蛛丝马迹。
如果可以将当时的线程捕捉下来,那么上述难题都迎刃而解了。主线程在什么函数哪一行卡住,在等什么锁,而这个锁有事被哪个子线程的哪个函数占用,有了堆栈,我们都可以知道。自然也能知道是慢在UI绘制,还是慢在我们的代码。
所以,思路就是起一个子线程,监控主线程的活动情况,如果发现有卡顿,就将堆栈dump下来。
流程图描述如下:
1. 判断标准
怎么判断主线程是不是发生了卡顿?一般来说,用户感受得到的卡顿大概有三个特征:
1.1 FPS 降低
1.2 CPU 占用率很高
1.3 主线程 Runloop 执行了很久
看起来 FPS 能够兼容后面两个特征,但是在实际操作过程中发现 FPS 不好衡量,抖动比较大。而对于抢锁或大量 IO 的情况,光有 CPU 是不行的。所以我们实际上用到的是下面两个准则:
1.1 CPU 占用超过了100%
1.2 主线程 Runloop 执行了超过2秒
2. 检测策略
为了降低检测带来的性能损耗,我们仔细设计了检测线程的策略:
内存 dump:每1秒检查一次,如果检查到主线程卡顿,就将所有线程的函数调用堆栈 dump 到内存中。
文件 dump:如果内存 dump 的堆栈跟上次捕捉到的不一样,则 dump 到文件中;否则按照斐波那契数列将检查时间递增(1,1,2,3,5,8…)直到没有遇到卡顿或卡顿堆栈不一样。这样能够避免同一个卡顿写入多个文件的情况,也能避免检测线程围着同一个卡顿空转的情况。
3. 分类方法
直接用 crash report 的分类方法是不行的,这个很好理解:最终卡在 lock 函数的卡顿,外面可能是很多不同的业务,例如可能是读取消息,可能是读取联系人,等等。卡顿监控需要仔细定义自己的分类规则。可以是从调用堆栈的最外层开始归类,或者是取中间一部分归类,或者是取最里面一部分归类。各有优缺点:
最外层归类:能够将同一入口的卡顿归类起来。缺点是层数不好定,可能外面十来层都是系统调用,也有可能第一层就是微信的函数了。
中间层归类:能够根据事先划分好的“特征值”来归类。缺点是“特征值”不好定,如果要做到自动学习生成的话,对后台分析系统要求太高了。
最内层归类:能够将同一原因的卡顿归类起来。缺点是同一分类可能包含不同的业务。
综合考虑并一一尝试之后,我们采用了最内层归类的优化版,亦即进行二级归类。
第一级按照最内倒数2层归类,这样能够将同一原因的卡顿集中起来;
第二级分类是从第一级点击进来,然后从最内层倒数4层进行归类,这样能够将同一原因的不同业务分散归类起来。
2.基于线程
最理想的方案是让UI线程“主动汇报”当前耗时的任务,听起来简单做起来不轻松。
我们可以假设这样一套机制:每隔16ms让UI线程来报道一次,如果16ms之后UI线程没来报道,那就一定是在执行某个耗时的任务。这种抽象的描述翻译成代码,可以用如下表述:
我们启动一个worker线程,worker线程每隔一小段时间(delta)ping以下主线程(发送一个NSNotification),如果主线程此时有空,必然能接收到这个通知,并pong以下(发送另一个NSNotification),如果worker线程超过delta时间没有收到pong的回复,那么可以推测UI线程必然在处理其他任务了,此时我们执行第二步操作,暂停UI线程,并打印出当前UI线程的函数调用栈。