【吐血】一次线上空指针崩溃的排查记录

ebe85887fcfda9b625e18d1f018e5641.jpeg

/   今日科技快讯   /

近日消息,许多德国公司正面临劳动力市场供需紧张的困境,急需具备关键软件工程技能的员工,为此他们正将美国硅谷裁员潮视为招聘顶尖人才的绝佳机会。

美国西海岸始终是雄心勃勃的软件工程师渴望前往的主要目的地,他们希望在那里找到薪酬更高的技术工作。然而,科技行业的大规模裁员创造了德国公司的招聘机会。汽车制造商大众旗下软件子公司Cariad的首席人事官表示:“他们裁员,我们招聘。我们在美国、欧洲和中国都有数百个空缺职位需要填补。”

/   作者简介   /

本篇文章转自coder_pig的博客,文章主要分享了一次线上空指针崩溃的排查过程,相信会对大家有所帮助!

原文地址:

https://juejin.cn/post/6976566366211948581

/   前言   /

41f69acaa37bfe7d6935e167f4a44dca.png

直接说引起NPE的根本原因(有被自己菜到):

rx订阅没有取消,回调时Fragment已经被回收,引用view调更新方法,自然NPE。

就这?是的,就是这么简单的基础错误,我排查了一天,淦,rx订阅要取消这可是常识:

  • 要么引入生命周期管理;

  • 要么定义CompositeSubscription,在Activity、Fragment销毁时clear()

  • 要么单独取消订阅,RxJava2用unsubscribe(),RxJava1用dispose()

我以为的开发常识,却被老项目打脸,接着我来捋一捋事情的前因后果,望大家吸取教训,排查BUG时少走弯路,错误原因已经给出了,对排查过程不感兴趣的可以直接略过了~

31b7bb29124324d72b03bd80924665e5.png

/   阴差阳错解了另一个BUG   /

昨日早上十一点半+,打开疼讯视频,准备边看剧边干饭,结果组长钉钉甩来一个:

58dcab9b04d93a981b5b78751b54161f.png

打开一看,我擦,刚上线的版本,爆了两千多次这个错误:

b61ae2b40d84e78d48f0e0fe4ce266f9.png

打开详细日志一看:

d408757918bc1cb94b2d877e8550f83e.png

NPE,空指针异常,调用刷新组件的关闭刷新方法报空,排查下日志的其他部分,用 自己的写的脚本 去下混淆,看下是否得到更多有帮助的信息。

没有什么卵用,不过奇怪的是,这个BUG的报错数还在不断增加,却没有用户反馈这个问题。

集成测试测三轮没发现,我们自己自测也不复现不了。

只能看进行更多的日志排查,一条日志引起我的注意:

UnknownHostException: Unable to resolve host "xxx.xxx.com": No address associated with hostname

然后报NPE,指向异常处理里的finishRefresh():

284d6972ae638e219926849dd3ab0659.png

em?难不成错误处理的代码有问题?测试人员人手不够,漏掉 网络异常边界 测试也很正常,而且可能都不懂怎么 模拟网络异常和弱网情况。

用 Charles 抓包模拟一波,定位到列表接口,下断点,请求后,直接把请求给 Abort 掉(丢弃):

5f89dff65d00acb0dc826efc9dda3a3c.png

果然应用崩溃了,心中窃喜,这么快就定位到了问题了?看下错误日志:

c4179ec1e3d7cdeeb7e1d1fb0dfb23ac.png

擦,怎么不是NPE,而是数组越界,看了下代码,原来是setEmptyView()后没有notifyDataSetChanged()一下。

好家伙,没解决BUG,却解了另外一个BUG,这...算是因祸得福吗?

/   真的是KAE的锅吗?   /

NPE的问题还没解决,但是干饭要紧,下午睡醒再说,控件为null,隐隐觉得可能是kotlin-android-extensions (后面都简称KAE)的锅,因为以前也遇到过根据id获取View实例为空的情况,不过那是因为id重复,于是干饭前在群里问了下小伙伴:

2782b6605a48720651fc6f13fd811142.png

大家都很热情地劝我不要用KAE,坑多,官方推荐ViewBinding,手写findViewById稳等,这些我都知道...

但是哪能说话就换,项目里那么地方用到了,而且换一个方式,并没有真的解决问题,起码得搞清楚问题发生原因吧...

午睡完,继续排查,直接源码看下KAE是怎么让你免去findViewById的,随手写个测试项目,写个TestActivity,里面引用下某个控件,依次点击Tools → Show Kotlin ByteCode → Decompile:

48f634bde98d9f1625f967de3fc8ff2d.png

也是调 Activity.findViewById() 查找控件,没毛病,试试Fragment的情况:

50f637d03108eef977c91ffb3470ec07.png

和Activity实现方式如出一辙,唯二的区别是:

  1. 调用Fragment的getView()方法的布局(onCreateView返回的View);

  2. 重写onDestroyView()方法,清空map中的实例;

你可能有疑问,为啥要在onDestroyView()里清空Map,而不是onDestory()里?

答:考虑到Fragment复用的场景,具体如下:

replace() Fragment后会执行onDestoryView(),而不是执行onDestory()完全销毁,目的是在销毁视图的同时,保留View状态和Fragment成员状态,下次加载时可以直接走onCreateView(),更快加载以达到复用的目的。

关于View状态的保存机制,笔者也不是特别了解,大概瞄下TextView的源码(onSaveInstanceState和onRestoreInstanceState),看到了实现Parcelable重写了一些方法,猜测是序列化。而序列化和反序列化前后,对象实例是不相等的,此时Map里还保留这之前的键值对(id → 实例),此时根据id拿到的View实例肯定是不对的,所以这里做了清空操作。

所以这里也没毛病,所以不是KAE的锅,虽然这里不涉及,但也把Adapter情况如何findViewById也过一下~

KAE不支持直接在adapter里直接用,需要在build.gradle添加下述实验性配置:

// 主要是为了启用LayoutContainer   
androidExtensions {
    experimental = true
}

而调用方式其实分两种,第一种是这样:

04fdbfaf58001d88e03dfb52a73f5e79.png

看下字节码转Java:

2219828fcefbc1841402facaade2a34c.png

直接findViewById的,看看另一种,让ViewHolder实现LayoutContainer的方式:

1e1b3ff08c812e8c6889249137a8eecb.png

看字节码转Java:

a04cab43b1ab8de99dd295d874c1cf1a.png

原理同样是创建一个hashMap来保存引用,通过ViewHolder传进的View进行绑定,真要在adapter里用KAE,建议使用第二种。

在《Kotlin Android Extensions遭废弃,官方推荐使用ViewBinding》一问中说到KAE的问题:

e5adf6044ee3c6a20b6601d8c454a90f.png

除此之外空间换时间,用一个额外的HashMap来存储View实例,更重要的是这部分内容对大部分使用者而言是黑盒,有时会踩上一些莫名其妙的“坑”。

官方提了一嘴使用ViewBinding替代KAE,其实就是启用ViewBinding功能后,AS自动为每个布局文件生成一个对应的Binding类,在里面完成View绑定(包含判空),文件输出目录:/build/generated/data_binding_base_class_source_out。

/   灵光乍现的瞬间   /

排查完不是KAE的锅,那到底是什么原因导致的控件为空呢?排查进展一下子陷入了僵局,只能从用户行为入手了,友盟上不知道为何看到不到用户的行为日志。好在有自家全埋点,打开 Kibana,过滤错误类型日志,找到错误日志,获取deviceid,然后查询用户行为。

b7e3648220ef949fb8d1d7806809bccc.png

通过分析多个用户报错的情况,我发现了一个规律:

都是一次首页的Loading,然后崩溃,而距离用户上次打开APP的时间一般都会很久。

脑子里突然蹦出一个想法,该不会是因为APP被回收,重新打开Activity重建的问题把,因为APP中有一个跟很多APP一样的鸡贼操作,虽然提示了“再按一次退出程序”,但其实是调用moveTaskToBack()退到后台而已。

5390e4c679d56af9fb40ef8edb75af9d.png

模拟APP被回收就简单了,AS跑下程序,来到出问题的页面,APP退到后台,直接在Logcat把程序干掉,接着重新打开程序,静待片刻,果然,崩溃了,看下日志信息:

d19ff39f8452447e38a49c7f6676a3b0.png

好家伙,果然复现了,因为Activity重建导致的刷新控件为空,噼里啪啦跟组长解释一波崩溃的原因,然后应急处理方式就是调用前先判空,保证不崩溃先。本来快到下班的点了(6点),正常情况应该是吃点东西摸鱼等下班了,不过没搞清楚引发这个问题的具体原因,回家也是念念不忘,索性加班排查下吧。

/   加班加点排查   /

涉及Activity重建,那估计也跟Fragment生命周期,Fragment多层嵌套之类的脱不了干系,在BaseActivity和BaseFragment中把生命周期相关的回调都加上日志。

ba50be72c33eb03d08a571cc52e13b3d.png

页面的话三层嵌套:CustomerFragment → ThirdAgentListFragment → CustomerChildNewFragment。接着模拟崩溃,看日志输出结果分析:

5a767da71875f5cc4b14ea74969c8c0d.png

不难看出Activity重建的时候把Fragment都恢复了,但是很快又销毁掉了,正常来说恢复Fragment的流程:

onCreateView() → onViewCreated() → onActivityCreated() → 各种初始化操作

这里却直接马上走onDestoryView()也走了onDestory(),发生这个原因其实是replace,看回代码:

bebbd28787d0e88a93bbdc31ffecd513.png

调用FragmentManager的replace()方法,而正常两个Fragment走的生命周期(未调用addToBackStack):

  • 被替换Fragment:onPause() → onStop() → onDestroyView() → onDestroy() → onDetach()

  • 替换Fragment:onAttach() → onCreate() → onCreateView() → onViewCreated() → onActivityCreated() → onStart() → onResume()

所以,这里的实际逻辑是这样:

恢复的方式创建了Fragment → 创建新的Fragment → 替换掉Fragment → 恢复创建的Fragment被干掉

然后,我在Fragment的onActivityCreated()中又发起了一个请求,那就存在一种情况:请求发出去了,响应还没回来,Fragment就被替换干掉了,这个时候去调已经销毁的Fragment里的View实例,妥妥滴空指针啊!

一种看似取巧的解决方式:savedInstanceState(Bundle) 方法中判断参数是否为空,不为空就不加载请求:

d20d7eb794e4621a96ac4607eb2105ef.png

当然,治本的方法肯定是从网络请求入手,当Activity或Fragment销毁时,需要把rx的订阅都取消掉,方法就是开头说的几种。

项目都四五年了,竟然一直没爆这个BUG,大概的原因是:

单Activity、多Fragment玩法,没有频繁的replace() Fragment的场景,而且大部分请求都有不可取消的Loading。

排查了一天,原来就是这样一个简单的BUG,前人挖坑,后人填坑,真是一口老血...

037537f698b01b067a13851d39c80d36.png

不过在排查过程中也收获不少:

  • 了解KAE不用findViewById的原理,以后可以放心使用了;

  • ViewBinding有个大概了解;

  • 对Fragment生命周期的验证(平时都是死记);

  • 了解了一下Activity具体重建机制;

就说这么多,解BUG之路道阻且跻,希望本文对你日常的Debug定位错误有所帮助,谢谢~

推荐阅读:

我的新书,《第一行代码 第3版》已出版!

由浅入深,详解 ViewModel 的那些事

Kotlin Flow响应式编程,StateFlow和SharedFlow

欢迎关注我的公众号

学习技术或投稿

62e29e0ec009f9d072de55da4e1ba1dc.png

ec86f0159588b5e88e6d2697e99bca7a.jpeg

长按上图,识别图中二维码即可关注

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值