作为技术人员,我们不应该盲目追求崩溃率这一个数字,应该以用户体验为先,如果强行去掩盖一些问题往往更加适得其反。我们不应该随意使用 try catch 去隐藏真正的问题,要从源头入手,了解崩溃的本质原因,保证后面的运行流程。在解决崩溃的过程,也要做到由点到面,不能只针对这个崩溃去解决,而应该要考虑这一类崩溃怎么解决和预防。(附github项目demo参考项目)
一、Android 的两种崩溃
我们都知道,Android 崩溃分为 Java 崩溃和 Native崩溃。
简单来说,Java 崩溃就是在 Java 代码中,出现了未捕获异常,导致程序异常退出。那 Native 崩溃一般都是因为Native代码中访问非法地址或者是地址对齐出现问题,再或者是发生程序主动abort,这些都会产生对应的signal信号,导致程序异常退出。所以,“崩溃”就是程序出现异常,而一个产品的崩溃率,跟我们如何捕获、处理这些异常有比较大的关系。Java 崩溃的捕获比较简单,但是很多同学对于如何捕获Native 崩溃还是一知半解,下面我就重点介绍 Native 崩溃的捕获流程和难点。
1、Native崩溃捕获流程(Native 崩溃机制)
(1)编译端。编译 C/C++ 代码时,需要将带符号信息的文件保留下来。
(2)客户端。捕获到崩溃时候,将收集到尽可能多的有用信息写入日志文件,然后选择合适的时机上传到服务器。
(3)服务端,读取客户端上报的日志文件,寻找适合的符号文件,生成可读的C/C++调用栈。
2、Native崩溃捕获的难点
Chromium 的Breakpad是目前 Native崩溃捕获中最成熟的方案,所以核心就是保证客户端在各种极端情况下依然可以生成崩溃日志,因为崩溃时,程序处于不安全状态,很可能发生二次崩溃,那么生成崩溃日志主要一些棘手情况:
情况一:文件句柄泄露,导致创建文件日志失败怎么办?应对方式:我们需要提前申请文件句柄fd预留,防止出现这种情况。
情况二:因为栈溢出了导致日志生成失败怎么办?应对方式:为了防止栈溢出导致进程没有空间创建调用栈执行处理函数,我们通常会使用常见的 signalstack。在一些特殊情况,我们可能还需要直接替换当前栈,所以这里也需要在堆中预留部分空间。
情况三:整个堆的内存都耗尽了导致日志生成失败,怎么办?应对方式:这个时候我们无法安全地分配内存,也不敢使用 stl 或者 libc 的函数,因为它们内部实现会分配堆内存。这个时候如果继续分配内存,会导致出现堆破坏或者二次崩溃的情况。Breakpad 做的比较彻底,重新封装了Linux Syscall Support,来避免直接调用libc。
情况四:堆破坏或二次崩溃导致日志生成失败,怎么办?应对方式:Breakpad 会从原进程 fork 出子进程去收集崩溃现场,此外涉及与 Java 相关的,一般也会用子进程去操作。这样即使出现二次崩溃,只是这部分的信息丢失,我们的父进程后面还可以继续获取其他的信息。在一些特殊的情况,我们还可能需要从子进程 fork 出孙进程。
当然 Breakpad 也存在着一些问题,例如生成的 minidump 文件是二进制格式的,包含了太多不重要的信息,导致文件很容易达到几 MB。但是 minidump 也不是毫无用处,它有一些比较高级的特性,比如使用 gdb 调试、可以看到传入参数等。Chromium 未来计划使用 Crashpad 全面替代 Breakpad,但目前来说还是 “too early to mobile”。
我们有时候想遵循 Android 的文本格式,并且添加更多我们认为重要的信息,这个时候就要去改造 Breakpad 的实现。比较常见的例如增加 Logcat 信息、Java 调用栈信息以及崩溃时的其他一些有用信息,在下一节我们会有更加详细的介绍。 如果想彻底弄清楚 Native 崩溃捕获,需要我们对虚拟机运行、汇编这些内功有一定造诣。做一个高可用的崩溃收集 SDK 真的不是那么容易,它需要经过多年的技术积累,要考虑的细节也非常多,每一个失败路径或者二次崩溃场景都要有应对措施或备用方案。
3、选择合适的崩溃服务
对于很多中小型公司来说,我并不建议自己去实现一套如此复杂的系统,可以选择一些第三方的服务。目前各种平台也是百花齐放,包括腾讯的Bugly、阿里的啄木鸟平台、网易云捕、Google 的 Firebase 等等。
二、发现应用中的 ANR 异常(Application Not Responding,程序无响应)问题
通常两种方法去发现应用中ANR
1、使用 FileObserver 监听 /data/anr/traces.txt的变化。非常不幸的是,很多高版本的 ROM,已经没有读取这个文件的权限了。这个时候你可能只能思考其他路径,海外可以使用 Google Play 服务,而国内微信利用Hardcoder框架(HC 框架是一套独立于安卓系统实现的通信框架,它让 App 和厂商 ROM 能够实时“对话”了,目标就是充分调度系统资源来提升 App 的运行速度和画质,切实提高大家的手机使用体验)向厂商获取了更大的权限。
2、 监控消息队列的运行时间。这个方案无法准确地判断是否真正出现了 ANR 异常,也无法得到完整的 ANR 日志。在我看来,更应该放到卡顿的性能范畴。
在讨论什么是异常退出之前,我们先看看都有哪些应用退出的情形:
主动自杀、
Process.killProcess()或者exit() 等
崩溃。出现了 Java 或 Native 崩溃。
系统重启;系统出现异常、断电、用户主动重启等,我们可以通过比较应用开机运行时间是否比之前记录的值更小。
被系统杀死。被 low memory killer 杀掉、从系统的任务管理器中划掉等。
ANR。
我们可以在应用启动的时候设定一个标志,在主动自杀或崩溃后更新标志,这样下次启动时通过检测这个标志就能确认运行期间是否发生过异常退出,对应上面五种情形除了主动自杀和崩溃(崩溃会单独统计)希望监控剩下三种异常退出,理论上对于异常捕获机制可百分之百覆盖。
三、采集崩溃来源
1、从崩溃的基本信息。
比如进程名或线程名:崩溃的进程是前台进程还是后台进程,崩溃是不是发生在 UI 线程。
崩溃堆栈和类型:崩溃是属于 Java 崩溃、Native 崩溃,还是 ANR,对于不同类型的崩溃我们关注的点也不太一样,特别需要看崩溃堆栈的栈顶,看具体崩溃在系统的代码,还是我们自己的代码里面。
2、系统信息。
Logcat。这里包括应用、系统的运行日志。由于系统权限问题,获取到的 Logcat 可能只包含与当前 App 相关的。其中系统的 event logcat 会记录 App 运行的一些基本情况,记录在文件/system/etc/event-log-tags 中。
机型、系统、厂商、CPU、ABI、Linux 版本等。我们会采集多达几十个维度,这对后面讲到寻找共性问题会很有帮助。
设备状态:是否 root、是否是模拟器。一些问题是由 Xposed 或多开软件造成,对这部分问题我们要区别对待。
3、内存信息:OOM、ANR、虚拟内存耗尽等,很多崩溃都跟内存有直接关系。如果我们把用户的手机内存分为“2GB 以下”和“2GB 以上“两个桶,会发现“2GB 以下”用户的崩溃率是“2GB 以上”用户的几倍。
系统剩余内存:关于系统内存状态,可以直接读取文件 /proc/meminfo,当系统可用内存很小(低于 MemTotal 的 10%)时,OOM、大量 GC、系统频繁自杀拉起等问题都非常容易出现。
应用使用内存:包括 Java 内存、RSS(Resident Set Size)、PSS(Proportional Set Size),我们可以得出应用本身内存的占用大小和分布。PSS 和 RSS 通过 /proc/self/smap 计算,可以进一步得到例如 apk、dex、so 等更加详细的分类统计。
虚拟内存:虚拟内存可以通过 /proc/self/status 得到,通过 /proc/self/maps 文件可以得到具体的分布情况。有时候我们一般不太重视虚拟内存,但是很多类似 OOM、tgkill 等问题都是虚拟内存不足导致的。
4、资源信息:有的时候我们会发现应用堆内存和设备内存都非常充足,还是会出现内存分配失败的情况,这跟资源泄漏可能有比较大的关系。
文件句柄fd:文件句柄的限制可以通过 /proc/self/limits获得,一般单个进程允许打开的最大文件句柄个数为 1024。但是如果文件句柄超过 800 个就比较危险,需要将所有的 fd 以及对应的文件名输出到日志中,进一步排查是否出现了有文件或者线程的泄漏。
线程数:当前线程数大小可以通过上面的 status 文件得到,一个线程可能就占 2MB 的虚拟内存,过多的线程会对虚拟内存和文件句柄带来压力。根据我的经验来说,如果线程数超过 400 个就比较危险。需要将所有的线程 id 以及对应的线程名输出到日志中,进一步排查是否出现了线程相关的问题。
JNI:使用 JNI 时,如果不注意很容易出现引用失效、引用爆表等一些崩溃,我们可以通过 DumpReferenceTables 统计JNI的引用表,进一步分析是否出现了JNI泄露等问题。
5、应用信息:除了系统,其实我们的应用更懂自己,可以留下很多相关的信息。
崩溃场景:崩溃发生在哪个 Activity 或 Fragment,发生在哪个业务中。
关键操作路径:不同于开发过程详细的打点日志,我们可以记录关键的用户操作路径,这对我们复现崩溃会有比较大的帮助。
其他自定义信息:不同的应用关心的重点可能不太一样,比如网易云音乐会关注当前播放的音乐,QQ 浏览器会关注当前打开的网址或视频。此外例如运行时间、是否加载了补丁、是否是全新安装或升级等信息也非常重要。
除了上面这些通用的信息外,针对特定的一些崩溃,我们可能还需要获取类似磁盘空间、电量、网络使用等特定信息。所以说一个好的崩溃获取工具,会根据场景为我们采集足够多的信息,让我们有更多的线索去分析和定位问题,当然数据的采集需要注意用户隐私,做到足够强度的加密和脱敏。
四、崩溃分析
第一步:确定重点。
(1)确认严重程度:解决崩溃也要看性价比,我们优先解决 Top 崩溃或者对业务有重大影响,例如启动、支付过程的崩溃。
(2)崩溃基本信息:确定崩溃的类型以及异常描述,对崩溃有大致判断,一般来说大部分崩溃经过这一步已经可以得到结论。比如JAVA崩溃,类型比较明显,像NullPointerException等。或者Native崩溃,需要观察signal、code、fault addr等内容,以及崩溃时 Java 的堆栈。关于各 signal 含义的介绍,比较常见的是有 SIGSEGV 和 SIGABRT,前者一般是由于空指针、非法指针造成,后者主要因为 ANR 和调用abort()退出所导致。ANR我的经验是先看看主线程 堆栈,是否是因为锁等待导致,接着看ANR日志中iowait、CPU、GC、system server 等信息,进一步确定是I/O问题亦或是CPU竞争问题,还是大量的GC导致卡死。
(3)Logcat:Logcat 一般会存在一些有价值的线索,日志级别是 Warning、Error 的需要特别注意。从 Logcat 中我们可以看到当时系统的一些行为跟手机的状态,例如出现 ANR 时,会有“am_anr”;App 被杀时,会有“am_kill”。不同的系统、厂商输出的日志有所差别,当从一条崩溃日志中无法看出问题的原因,或者得不到有用信息时,不要放弃,建议查看相同崩溃点下的更多崩溃日志。
(4)各个资源情况:结合崩溃的基本信息,我们接着看看是不是跟 “内存信息” 有关,是不是跟“资源信息”有关。比如是物理内存不足、虚拟内存不足,还是文件句柄 fd 泄漏了。
第二步:寻找共性
如果使用了上面的方法还是不能有效定位问题,我们可以尝试查找这类崩溃有没有什么共性。找到了共性,也就可以进一步找到差异,离解决问题也就更进一步。 机型、系统、ROM、厂商、ABI,这些采集到的系统信息都可以作为维度聚合,共性问题例如是不是因为安装了 Xposed,是不是只出现在 x86 的手机,是不是只有三星这款机型,是不是只在 Android 5.0 的系统上。应用信息也可以作为维度来聚合,比如正在打开的链接、正在播放的视频、国家、地区等。 找到了共性,可以对你下一步复现问题有更明确的指引。
第三步:尝试复现
如果我们已经大概知道了崩溃的原因,为了进一步确认更多信息,就需要尝试复现崩溃。如果我们对崩溃完全没有头绪,也希望通过用户操作路径来尝试重现,然后再去分析崩溃原因。 “只要能本地复现,我就能解”,相信这是很多开发跟测试说过的话。有这样的底气主要是因为在稳定的复现路径上面,我们可以采用增加日志或使用 Debugger、GDB 等各种各样的手段或工具做进一步分析。 回想当时在开发 Tinker 的时候,我们遇到了各种各样的奇葩问题。比如某个厂商改了底层实现、新的 Android 系统实现有所更改,都需要去 Google、翻源码,有时候还需要去抠厂商的 ROM 或手动刷 ROM。这个痛苦的经历告诉我,很多疑难问题需要我们耐得住寂寞,反复猜测、反复发灰度、反复验证。
疑难问题:系统崩溃 系统崩溃常常令我们感到非常无助,它可能是某个 Android 版本的 bug,也可能是某个厂商修改 ROM 导致。这种情况下的崩溃堆栈可能完全没有我们自己的代码,很难直接定位问题。针对这种疑难问题,我来谈谈我的解决思路。
1. 查找可能的原因。通过上面的共性归类,我们先看看是某个系统版本的问题,还是某个厂商特定 ROM 的问题。虽然崩溃日志可能没有我们自己的代码,但通过操作路径和日志,我们可以找到一些怀疑的点。
2. 尝试规避。查看可疑的代码调用,是否使用了不恰当的 API,是否可以更换其他的实现方式规避。
3. Hook 解决。这里分为 Java Hook 和 Native Hook。以我最近解决的一个系统崩溃为例,我们发现线上出现一个 Toast 相关的系统崩溃,它只出现在 Android 7.0 的系统中,看起来是在 Toast 显示的时候窗口的 token 已经无效了。这有可能出现在 Toast 需要显示时,窗口已经销毁了。 android.view.WindowManager$BadTokenException: at android.view.ViewRootImpl.setView(ViewRootImpl.java) at android.view.WindowManagerGlobal.addView(WindowManagerGlobal.java) at android.view.WindowManagerImpl.addView(WindowManagerImpl.java4) at android.widget.Toast$TN.handleShow(Toast.java)
为什么 Android 8.0 的系统不会有这个问题?在查看 Android 8.0 的源码后我们发现有以下修改: try { mWM.addView(mView, mParams); trySendAccessibilityEvent(); } catch (WindowManager.BadTokenException e) { /* ignore */ }
考虑再三,我们决定参考 Android 8.0 的做法,直接 catch 住这个异常。这里的关键在于寻找 Hook 点,这个案例算是相对比较简单的。Toast 里面有一个变量叫 mTN,它的类型为 handler,我们只需要代理它就可以实现捕获。 如果你做到了我上面说的这些,95% 以上的崩溃都能解决或者规避,大部分的系统崩溃也是如此。当然总有一些疑难问题需要依赖到用户的真实环境,我们希望具备类似动态跟踪和调试的能力。专栏后面还会讲到 xlog 日志、远程诊断、动态分析等高级手段,可以帮助我们进一步调试线上疑难问题,敬请期待。 崩溃攻防是一个长期的过程,我们希望尽可能地提前预防崩溃的发生,将它消灭在萌芽阶段。这可能涉及我们应用的整个流程,包括人员的培训、编译检查、静态扫描工作,还有规范的测试、灰度、发布流程等。 而崩溃优化也不是孤立的,它跟我们后面讲到的内存、卡顿、I/O 等内容都有关。可能等你学完整个课程后,再回头来看会有不同的理解。
最后(附github项目demo参考项目、天猫APP启动保护实践)