卡顿监控
https://mp.weixin.qq.com/s/3dubi2GVW_rVFZZztCpsKg
主线程卡顿监控
方案一、Looper Printer监控每次 dispatchMessage 的执行耗时:
DoKit & BlockCanary & Matrix
滴滴的哆啦A梦的卡顿检测其实就是blockCanary,和Matrix 的EvilMethodTracer和AnrTracer (当然后来Matrix还增加了native的Signal信号监听)使用的 方案也就是Looper设置Printer监听卡顿
都是根据handler原理,通过给Looper.loop() 中设置printer(无论是通过反射替换Looper的mLogging还是通过setMessageLogging设置printer),监控超过 设定阈值(matrix700ms) 的主线程消息(超过5s报为ANR),printer 中判断start和end,来获取主线程dispatch该message的开始和结束时间,并判定该时间超过阈值为主线程卡慢发生,并 打印当时堆栈 + 方法耗时(matrix/dokit)
1 | Looper.loop() { |
Matrix:无论是通过反射替换Looper的mLogging还是通过setMessageLogging设置printer,我们只需要替换主线程Looper的printer对象,通过计算执行dispatchMessage方法之后和之前打印字符串的时间的差值,就可以拿到到dispatchMessage方法执行的时间。而大部分的主线程的操作最终都会执行到这个dispatchMessage方法中。
Looper.loop()设置Printer监控方案存在问题及解决方案:
简述:Looper.loop()中设置printer的方法监控耗时还会遗漏这几个场景:
首先大致代码:
1 | Looper.loop() { |
如上所示就可以看到设置printer监控方案在这里有监控不到的情况,也就是queue.next阻塞的情况,分两种:
1、主线程空闲时idleHandler处理的情况。 —— 可以通过反射MessageQueue中的mIdleHandlers(ArrayList),替代成自定义的ArrayList类,在重写的add方法中获得所有的idleHandelr
2、Touch事件的话可以通过PLT hook,hook native framework中的事件机制。
还有另一种是barrier消息泄露的情况,这种情况很少见。
Q:
如果排除主线程空闲的情况,究竟会是什么原因会卡在MessageQueue的next方法中呢?下图是next方法简化过后的源码,*frameworks/base/core/java/android/os/MessageQueue.java:next()*
1 | for (;;) { |
因为有些情况的卡顿,这种方案从原理上就无法监控到。看到上面的queue.next(),这里给了注释:might block,直接跟你说这里是可能会卡住的,这时候再计算dispatchMessage方法的耗时显然就没有意义了。有的同学可能会想,那我改成计算相邻两次dispatchMessage执行之前打印字符串的时间差值不就好了?这样就可以把next方法的耗时也计算在内。
1、主线程空闲也就是queue.next()阻塞的时候,同时也是应用的Touch事件。不幸的是,主线程空闲时,也会阻塞在MessageQueue的next方法中,我们很难区分究竟是发生了卡顿还是主线程空闲,除了主线程空闲时就是阻塞在nativePollOnce之外,非常重要的是,应用的Touch事件也是在这里被处理的。这就意味着,View的TouchEvent中的卡顿这种方案是无法监控的。(微信中有大量的自定义View,这些View中充满了各种各样很多的onTouch回调,卡在这里面的情况非常普遍,这种情况的卡顿监控不到是很难接受的)
2、IdleHandler的queueIdle()回调方法。这个方法会在主线程空闲的时候被调用。然而实际上,很多开发同学都先入为主的认为这个时候反正主线程空闲,做一些耗时操作也没所谓。其实主线程MessageQueue的queueIdle默认当然也是执行在主线程中,所以这里的耗时操作其实是很容易引起卡顿和ANR的。(例如微信之前就使用IdleHandler在进入微信的主界面后,做一些读写文件的IO操作,就造成了一些卡顿和ANR问题)
3、SyncBarrier泄露。还有一类相对少见的问题是SyncBarrier(同步屏障)的泄漏同样无法被监控到
当我们每次通过invalidate来刷新UI时,最终都会调用到ViewRootImpl中的scheduleTraversals方法,会向主线程的Looper中post一个SyncBarrier,其目的是为了在刷新UI时,主线程的同步消息都被跳过,此时渲染UI的异步消息就可以得到优先处理。但是我们注意到这个方法是线程不安全的,如果在非主线程中调用到了这里,就有可能会同时post多个SyncBarrier,但只能remove掉最后一个,从而有一个SyncBarrier就永远无法被remove,就导致了主线程Looper无法处理同步消息(Message默认就是同步消息),导致卡死
A:
A.1. 监控IdleHandler卡顿
首先从简单的下手,对于IdleHandler的queueIdle回调方法的监控。我们惊喜的发现MessageQueue中的mIdleHandlers是可以被反射的,这个变量保存了所有将要执行的IdleHandler,我们只需要把ArrayList类型的mIdleHandlers,通过反射,替换为MyArrayList,在我们自定义的MyArrayList中重写add方法,再将我们自定义的MyIdleHandler添加到MyArrayList中,就完成了“偷天换日”。从此之后MessageQueue每次执行queueIdle回调方法,都会执行到我们的MyIdleHandler中的的queueIdle方法,就可以在这里监控queueIdle的执行时间了。
1 | private static void detectIdleHandler() { |
A.2. 监控TouchEvent卡顿
那么TouchEvent我们有什么办法监控吗?首先想到的可能是反射View的mListenerInfo,然后进一步替换其中的mTouchListenr,但是这需要我们枚举所有需要被监控的View,全部反射替换一遍,这完全是憨憨行为。那有没有更加根本,全局性的方法呢?
熟悉input系统的同学应该知道,Touch事件最终是通过server端的InputDispatcher线程传递给Client端的UI线程的,并且使用的是一对Socket进行通讯的。我们可以通过PLT Hook,去Hook这对Socket的send和recv方法来监控Touch事件啊!我们先捋一下一次Touch事件的处理过程:
我们通过PLT Hook,成功hook到libinput.so中的recvfrom和sendto方法,使用我们自己的方法进行替换。当调用到了recvfrom时,说明我们的应用接收到了Touch事件,当调用到了sendto时,说明这个Touch事件已经被成功消费掉了,当两者的时间相差过大时即说明产生了一次Touch事件的卡顿。这种方案经过验证是可行的!
A.3. 监控SyncBarrier泄漏
最后,SyncBarrier泄漏的问题,有什么好办法能监控到吗?目前我们的方案是不断轮询主线程Looper的MessageQueue的mMessage(也就是主线程当前正在处理的Message)。而SyncBarrier本身也是一种特殊的Message,其特殊在它的target是null。如果我们通过反射mMessage,发现当前的Message的target为null,并且通过这个Message的when发现其已经存在很久了,这个时候我们合理怀疑产生了SyncBarrier的泄漏(但还不能完全确定,因为如果当时因为其他原因导致主线程卡死,也可能会导致这种现象),然后再发送一个同步消息和一个异步消息,如果异步消息被处理了,但是同步消息一直无法被处理,这时候就说明产生了SyncBarrier的泄漏。如果激进一些,这个时候我们甚至可以反射调用*MessageQueue*的*removeSyncBarrier*方法,手动把这个SyncBarrier移除掉,从而从错误状态中恢复。
坏消息是,这种方案只能监控到问题的产生,也可以直接解决问题,但是无法溯源问题究竟是哪个View导致的。其实我们也尝试过,通过插桩或者Java hook的方法,监控invalidate方法是否在非主线程中进行,但是考虑到风险以及对性能影响都比较大,没有在线上使用。所幸,通过监控发现,这个问题对我们来说,发生的概率并不高。如果发现某个场景下该问题确实较为严重,可以考虑使用插桩或者Java hook在测试环境下debug该问题。
方案二、依赖 Choreographer 模块,监控相邻两次 Vsync 事件通知的时间差
利用系统 Choreographer 模块,向该模块注册一个 FrameCallback 监听对象,同时通过另外一条线程循环记录主线程堆栈信息,并在每次 Vsync 事件 doFrame 通知回来时,循环注册该监听对象,间接统计两次 Vsync 事件的时间间隔,当超出阈值时,取出记录的堆栈进行分析上报。
1 | Choreographer.getInstance().postFrameCallback(new Choreographer.FrameCallback() { |
卡顿发生时堆栈的收集
BlocakCanary:在执行前利用另外一条线程,通过 Thread#getStackTrace 接口,以轮询的方式获取主线程执行堆栈信息并记录起来,同时统计每次 dispatchMessage 方法执行耗时,当超出阈值时,将该次获取的堆栈进行分析上报,从而来捕捉卡顿信息,否则丢弃此次记录的堆栈信息。
Matrix:根据编译期插桩记录函数耗时,在卡顿发生时获取之前一段时间的函数进行归堆,性能更佳,
不仅在编译期时对特殊无需插桩函数排除(方法字节码中是否只包含PUT/READ FIELD等简单指令、默认或匿名构造函数)、插桩函数用ID映射
还在运行期时用long[]数组记录函数id和函数函数,极大程度的减少占用内存、另一个线程每5ms更新时间减少调用System.nanoTime的耗时
Dokit:也是插桩,但优化不细。
动画优化
简述:TODO
对于同样机器环境上的应用来说,抛去受CPU、屏幕和系统GUI系统的固有时间消耗外,要实现流畅的动画的核心也就是减少视图Draw的时间。
这里有几点经验可以跟大家分享一下:
尽量不要在刷新时做耗时操作,必须准备数据,创建图片,图片变换等,数据和图片都应该在之间就加载到内存中,图片变换用canvas的变换来实现。
同一个界面中多个动画重叠出现时,尽量将动画的刷新过程统一进行刷新,避免频繁的invalidate,尤其是多个动画有时序上的关系时更应该统一。
尽量使用带有参数的invalidate来刷新,这样可以减少很多运算量。
合理的环境下使用surfaceview来操作,比如播放视频等,这种刷新耗时比较大的情况。
开启硬件加速,硬件加速由于采用了显示列表的概念,所以刷新过程也有很大的优化,但是会增加额外的8M内存占用。
Animation流畅度
动画线程中,少做动画外的事情(比如拖动的时候同时做了图片加载,或进度转圈),或用子线程去做这一件事;多个View做动画,变成一个View做多个动画,从而减少View Tree递归调用;
消失的或不在屏幕中的bg,view不绘制,减小绘制面积(bg绘制前用clipRect控制),减小缓存尺寸;不要用requestLayout实现动画,用矩阵变换代替,少用clipPath剪切图片;
不要设置listview的selector;
动画时间控制在400ms以内;
利用好硬件加速;
动画用nineoldandroid或者在实现的时候尽量把动画的绘制都放到一个消息循环里面;
Layout加载速度
简化动画布局(包括view层级和数量),不用的布局可以用viewstub包住在用的时候inflate;
提前将布局inflate传入,记得处理static引用;