本文作者:鲁可——腾讯SNG专项测试组 测试工程师
背景
承上经典随机Crash之一:线程安全
问题的模型
好几次灰度top1、top2 Crash发生场景:在很平常、频繁的使用页面,打开一个界面,马上返回,piaji,挂了,估计用户心中有千万只草泥马在奔腾,手机QQ究竟怎么呢?
找到开发童鞋,还是熟悉的对话:
-
请教:这个Crash能复现吗?开发答:场景就在这,就是复现不了啊
-
这里有个空指针,那我就加个判空
我只好去看下开发童鞋的代码,发现都有一个共性,跟handler postDelayed
有关系,这里抽取出Crash代码梗概
Post一个匿名Runnable
,延迟500ms
跟开发童鞋反复再三确认,mGLVideoView
置空的地方只有一处,就在onDestroy()
中
开发童鞋一般为了解决内存泄露问题,会在onDestroy
中将变量置空,以让系统回收,这么做也理所当然。跟用户反馈的情况也吻合,打开界面,立马返回,会Crash。
为了搞清这个问题的根源,需要对Android消息机制有一定了解,大家可以搜索下相关文章。
不按套路出牌,碉堡了的用户是这样的,如图所示
弱爆了的我是这样的,如图所示
那接下来的事情就好办了,寻找腾讯手速最快的人,要在500ms之内打开界面,返回,要是他都复现不了,那就真的复现不了,虽然是开个玩笑,但这确实已经不是个概率性问题了,在我们手速不够快的情况下,这类型Crash确实是复现不了,但很显然这不是解决问题的正确姿势。
解决问题的思路
事后手段:
-
加判空
-
这里给大家推荐这篇文章:
Android handler.removeCallbacksAndMessages(null)的妙用
http://www.snowdream.tech/2016/02/18/handler-removeCallbacksAndMessages/
好处有:非静态匿名内部类Runnable
持有外部类会导致内存泄露,remove
掉以较少内存泄露;消除这类空指针Crash的隐患;减少主线程消息队列的任务,还能提升点性能
然而这些都不能做到事前发现,今天我们就一起来探讨下一些事前的手段,并解密一个我申请的有利于发现同类问题的专利。
请教了做静态检查的同学,在没有任何上下文环境的情况下直接使用一个变量,这种空指针检查很难搞,我们主要从动态角度上分析。
1、 在activity onDestroy
之后handler.post
监控Activity onDestroy
、handler post
操作,强制在onDestroy
之后再post
,就能100%复现这个Crash了
那首先需要寻找Activity与handler之间的联系,监控onDestroy
,可以用hook或者类似LeakCanary的方式,注册ActivityLifecycleCallbacks
来监听,但难点在怎么把handler post
跟Activity onDestroy
建立起联系,从开发者的角度来说,这两个模块没有联系,Activity完全不用handler也是可以的,在Activity的生命周期方法中,没有哪个需要带上handler,Activity中会不会默认隐藏着handler了?
抱着这样的疑问,我去看了下Activity的源码(以Android5.0为准)
果真Activity中会有一个mHandler
看了下这个mHandler
在什么地方会被用到
只有在runOnUiThread
中会被用到,但开发者自己绑定MainLooper
的handler
跟这个mHandler
没有关系。
这种方法需要对Activity Handler
两大核心模块找到一种关联,并做一种高精度的手术,限于本人能力有限,一时陷入了困境。
2、 控制消息的时机
既然没法找到Activity Handler
的关联,就只好从消息机制本身着手。
刚开始我们想到的方法,把这种消息从消息队列里取出来,等待时机,然后再重新插入消息队列
那第一步就需要把这种消息取出来,我们先来看看源码是怎么做的
在loop()
中会通过next()
获取一个消息,如果能获取到,则通过dispatchMessage()
分发消息,接下来我们看看next()是怎么获取消息的
在next
获取了当前系统时间,若到了消息执行时间,则返回消息
这里一定会有疑问,msg.when
是怎么设置的?消息是如何插入队列的?
next()
从消息队列获取一个消息,无法精准到具体的消息,其实我们还可以参考removeMessages
的实现,通过反射来取出消息,如果remove
的时机过晚,也会导致这个消息已经被消费了,如果remove
错了,导致丢消息,篓子就捅大了。总之,我们必须搞清楚消息入队列的过程。
发送消息主要有sendXXX,postXXX两大类方法,由于Runnable也会被封装成Message
其实这里面也会有个坑:Callback类型Message的what是0,大家有兴趣也可以学习下
看过post (runnable)
、sendMessage
过程后,我画了一个postXXX、sendXXX调用关系图
根据上面的图,可以看出sendMessageDelayed
和sendMessageAtTime
是非常重要的两个环节,我们来看下这两个方法究竟做了啥
在sendMessageDelayed
中会用系统开机总时间+dalayMillis
,所以传入sendMessageAtTime
的值是相对于系统启动的绝对值
再来看queue.enqueueMessage
的过程
when
赋值给了msg.when
,这下能解释next()
中msg.when
是如何得来的问题,到这里,您应该清楚了,原来插入消息队列的顺序是根据msg.when
大小来插入的。
前面说到when
传入的是一个绝对值,那上面为啥有when==0
的判断,那什么时候when
会为0呢?当把一个消息强制插入到队列首的位置,会传入0
如果我们要延迟那个消息的处理时机,只需改动这个绝对值就可以了,我们决定通过hook sendMessageDelayed
,将延迟时间delayMillis
改长,如果您看到这里,是不是觉得方案其实很简单?确实是的,如果我一上来就告诉您这么做,那这个问题就很简单了,其实中间也是踩了一些坑,然而知道为什么要这么做,似乎更重要,也更有趣。
到此,您已经清楚Android是如何插入消息的了,您要是愿意,完全可以把全部消息hook住了,随意改uptimeMillis
,那您已经掌握了玩弄消息顺序于股掌之中的技术。
问题的解决方案
最终综合安全性、稳定性等方面的考虑,我们采用了将delayMillis
时间改长的方案
-
考虑到主线程做了很多事情,比如需处理绘制UI等一些系统消息,而开发者一般把延时操作都放在了
Runnable
里,这里我们只延迟Runnable
经过封装的消息,并根据调用堆栈做了过滤 -
考虑到这种Crash容易发生在post短时间内,如果开发者本来设置的延迟时间就比较大,如果再加大延迟,会让消息得不到及时处理,所以我们对需要加大延迟的时间做了阈值判断
最终实现的流程图如下图所示:
因此,这个专利水到渠成:一种延迟消息分发模拟Crash的方法
最终要达到的效果下图所示:
众里寻他千百度,蓦然回首,那人却在,灯火阑珊处。延迟一个小时,我完全可以出去吃个饭、遛个弯,再回来复现这个Crash了。
问:跟当前主线程卡顿监控方案是否有冲突?
答:主线程卡顿监控主要是计算
dispatchMessage
,Dispatching
、Finished
之间的耗时,我们对dispatchMessage
没做任何手脚,只是延迟了消息的处理时机。
问:会不会造成卡顿?
答:UI上的不流畅主要是掉帧,每个消息具体耗时多少,还是取决于消息本身在做什么,我们跟开发者自己把
delayMillis
改长并没什么区别。
效果
延迟消息分发SDK已加入NewMonkey随身版挑战者模式中,能做到无场景延迟Runnable
类型消息的分发,功能上线短短1天内,就发现了Android QQ 4个Crash,都得到了开发同学的迅速fix。
由于本人能力、精力有限,对Android消息机制远未啃透,若有纰漏,欢迎斧正,对其他平台的消息机制更是一窍不通,若对您有所启发,深感荣幸。
道高一尺魔高一丈,在降Crash率上,依旧任重而道远。
更多精彩内容欢迎关注腾讯 Bugly的微信公众账号:
腾讯 Bugly是一款专为移动开发者打造的质量监控工具,帮助开发者快速,便捷的定位线上应用崩溃的情况以及解决方案。智能合并功能帮助开发同学把每天上报的数千条 Crash 根据根因合并分类,每日日报会列出影响用户数最多的崩溃,精准定位功能帮助开发同学定位到出问题的代码行,实时上报可以在发布后快速的了解应用的质量情况,适配最新的 iOS, Android 官方操作系统,鹅厂的工程师都在使用,快来加入我们吧!