Android高手课---崩溃捕获

1.Android中的崩溃有两种,Java崩溃和Native崩溃。Java崩溃就是在Java代码中,出现了未捕获异常,导致程序异常退出;Native崩溃一般都是因为在Native代码中访问非法地址,也可能是地址对齐出现问题,或者发生了程序主动abort,这些都会产生相应的signal信号,导致程序异常退出。

2.崩溃就是程序出现异常,而一个产品的崩溃率,跟我们如何捕获、处理这些异常有比较大的关系。Java崩溃的捕获比较简单,但Native的捕获有一定难度。

3.Native crash一直是crash里的大头,具有上下文不全、出错信息模糊、难以捕捉等特点,比java crash更难修复。一个合格的异常捕获组件要能达到以下目的:

  1. 支持在crash时进行更多扩展操作,如:打印logcat和应用日志、上传crash次数、对不同的crash做不同的恢复措施。
  2. 可以针对业务不断改进和适应。

4.现有方案:

  • Google的Breakpad,优点,权威、平台大;缺点,代码体量较大。
  • 利用LogCat日志,优点,利用安卓系统实现;缺点,需要在crash时启动新进程过滤logcat日志,不可靠。
  • coffeecatch,实现简洁,改动容易;缺点,存在兼容性问题。

5.捕获Native crash:

  • 注册信号处理函数,通过信号处理函数捕获到Native crash。
  • 设置额外栈空间。SIGSEGV很有可能是栈溢出引起的,如果在默认的栈上运行很可能会破坏程序运行的现场,无法获得正确的上下文。而且当栈满了(太多次递归,栈上太多对象),系统会在同一个已经满了的栈上调用SIGSEGV的信号处理函数,又再一次引起同样的信号。我们应该开辟一块新的空间作为运行信号处理函数的栈。可以使用sigalstack在任意线程注册一个可选的栈,保留一下在紧急情况下使用的空间。(系统会在危险情况下把栈指针指向这个地方,使得可以在一个新的栈上运行信号处理函数)
  • 兼容其他signal处理。某些信号可能在之前已经被安装过信号处理函数,而sigaction一个信号量只能注册一个函数,这意味着我们的处理函数会覆盖其他人的处理信号。

6.一个完整的Native崩溃从捕获到解析要经历的流程:

  • 编译端。编译C/C++代码时,需要将带符号信息的文件保留下来。
  • 客户端。捕获到崩溃时候,将收集到尽可能多的有用信息写入日志文件,然后选择合适的时机上传到服务器。
  • 服务器。读取客户端上报的日志文件,寻找合适的符号文件,生成可读的C/C++调用栈。

7.程序在崩溃时,处于一个不安全的状态,如果处理不当,非常容易发生二次崩溃,怎样才能保证客户端在各种极端情况下依然可以生成崩溃日志。

8.生成崩溃日志的时候会有哪些比较棘手的情况呢?

  1. 文件句柄泄漏,导致创建日志文件失败,怎么办?应对方式:我么需要提前申请文件句柄fd预留,防止出现这种情况。
  2. 因为栈溢出了,导致日志生成失败,怎么办?应对方式:为了防止栈溢出导致进程没有空间创建调用栈执行处理函数,我么通常会使用常见的sigalstack。在一些特殊情况下,我们可能还需要直接替换当前栈,所以这里也需要在堆中预留部分空间。
  3. 整个堆的内存都耗尽了,导致日志生成失败,怎么办?应对方式:这个时候我们无法安全地分配内存,也不敢使用stl或者libc函数,因为他们内部事件会分配堆内存。这个时候如果继续分配内存,会导致出现堆破坏或者二次崩溃的情况。
  4. 堆破坏或二次崩溃导致日志生成失败,怎么办?应对方式:Breakpad会从原进程fork出子进程去收集崩溃现场,此外涉及Java相关的,一般也会用子进程去操作,这样即使出现二次崩溃,只是这部分的信息丢失,我们的父进程后面还可以继续获取其他信息。

9.想要彻底清楚Native崩溃捕获,需要我们对虚拟机运行、汇编这些内功有一定的造诣。做一个高可用的崩溃SDK并不容易,它需要经过多年技术积累,要考虑的细节也比较多,每一个失败路径或者二次崩溃场景都要有应对措施或备用方案。

10.启动崩溃对用户带来的伤害最大,应用无法启动往往通过热修复也无法拯救。闪屏广告、运营活动、各种资源下发、配置下发,过程复杂,所以极容易出现问题,所以这种偏运营的应用都有使用一种叫做安全模式的技术来保障客户端的启动流程,在监控到客户端启动失败后,会给用户自救的机会。

11.会导致应用异常退出的情形有:

  • 主动自杀。Process.killProgress()、exit()等。
  • 崩溃。出现了Java或Native崩溃。
  • 系统重启;系统出现异常、断电、用户主动重启等,我们可以通过比较应用开机运行时间是否比之前记录的值更小。
  • 被系统杀死。被low memory killer杀掉,从系统的任务管理器中划掉等。
  • ANR。

12.我们可以在应用启动的时候设定一个标志,在主动自杀或崩溃后更新标志,这样下次启动时通过检测这个标志就能确认运行期间是否发生过异常退出。

13.崩溃现场应该采集哪些信息?

   1.崩溃信息  :  从崩溃的基本信息,我们可以对崩溃有初步的判断。      

  • 进程名、线程名。崩溃的进程是前台进程还是后台进程,崩溃是不是发生在UI线程。
  • 崩溃堆栈和类型。特备是需要看崩溃堆栈的栈顶,看具体崩溃在系统的代码,还是我们自己的代码里面。

   2.系统信息: 系统信息有时候会带一些关键线索,对我们解决问题帮助很大。

  • Logcat 。这里包括应用、系统的运行日志。由于系统权限问题,获取到的logcat可能只包含于当前app相关。其中系统的event logcat会记录app运行的一些基本情况,记录在文件/system/etc/event-log-tags中。
  • 机型、系统、厂商、CPU、ABI、Linux版本等。我们会采集多达几十个维度,这对寻找共性问题有帮助。
  • 设备状态:是否root、是否是模拟器。一些问题是由Xposed或多开软件造成,对这部分问题我们要区别对待。

   3.内存信息:OOM、ANR、虚拟内存耗尽等,很多崩溃都跟内存有直接关系。

  • 系统剩余内存。关于系统内存状态,可以直接读取文件/proc/meminfo。当系统可用内存很小(低于MemTota的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等问题都是虚拟内存不足导致的。(一般来说,对于32位进程,如果是32位的CPU,虚拟内存达到3GB就可能会引起内存申请失败的问题。如果是64位的CPU,虚拟内存一般在3~4GB之间。当然如果我们支持64位进程,虚拟内存就不会成为问题。)

   4.资源信息 :有时候我们发现应用堆内存和设备内存都非常充足,还是会出现内存分配失败的情况,这跟资源泄漏可能有比较大的关系。

  • 文件句柄fd。文件句柄的限制可以通过/proc/self/limits获得,一般单个进程允许打开的最大文件句柄个数为1024。但是如果文件句柄超过800个就比较危险,需要将所有的fd以及对应的文件名输出到日志中,进一步排查是否出现了有文件或者线程的泄漏。
  • 线程数。一个线程可能就占2M的虚拟内存,过多的线程会对虚拟内存和文件句柄带来压力。经验来看,如果线程数超过400个就比较危险。需要将所有的线程id以及对应的线程名输出到日志中,进一步排查是否出现了线程相关的问题。
  • JNI。使用JNI时,如果不注意很容易出现引用失效、引用爆表等一些崩溃。我们可以通过DumpReferenceTables统计JNI的引用表,进一步分析是否出现了JNI泄漏等问题。

   5.应用信息:除了系统,我们的应用更懂自己,可以留下许多相关的信息。

  • 崩溃场景。崩溃发生在哪个Activity或Fragment,发生在哪个业务中。
  • 关键操作路径。不同于开发过程详细的打点日志,我们可以记录关键的用户操作路径,这对我们复现崩溃有比较大的帮助。
  • 其他自定义信息。如运行时间、是否加载了补丁、是否是全新安装或升级等。

   6.其他信息:除了通用的信息外,针对特定的一些崩溃,我们可能还需要获取类似磁盘空间、电量、网络等特定信息。所以说一个好的崩溃捕获工具,会根据场景为我们采集足够多的信息,让我们有足够多的线索去分析和定位问题。当然数据的采集需要注意用户隐私,做到足够强度的加密和脱敏。

14.崩溃分析三部曲:

   第一步:确定重点:关键是在于日志中找到重要信息,对问题有一个大致判断。一般来说,建议关注以下几点:

  • 确认严重程度。解决崩溃要看性价比,我们优先解决Top崩溃或者对业务有重大影响,例如启动、支付过程的崩溃。
  • 崩溃基本信息。确定崩溃的类型以及异常描述,对崩溃有大致的判断。大部分的简单崩溃经过这一步已经可以得到结论。Java崩溃,比如OutOfMemoryError是资源不足,这个时候需要进一步查看日志中的“内存信息”和“资源信息”;Native崩溃,需要观察signal、code、fault addr等内容,以及崩溃时Java的堆栈。关于signal,可以查看崩溃信息介绍。比较常见的是有SIGSEGV和SIGABRT,前者一般是由空指针、非法指针造成,后者主要是因为ANR和调用abort()退出所导致;ANR,我的经验是,先看看主线程的堆栈,是否是因为锁等待导致,接着看看ANR日志中iowait、CPU、GC、system server等信息,进一步确定I/O问题,或是CPU竞争问题,还是由于大量GC导致卡死。
  • Logcat。logcat中一般会存在一些有价值的线索,日志级别是Warning、Error的需要特别注意。从Logcat中我们可以看到当时系统的一些行为跟手机的状态,例如出现ANR时,会有"am_anr";APP被杀死时,会有"am_kill"。不同系统、厂商输出的日志有所差别,当从一条崩溃日志中无法看出问题的原因,或者得不到有用信息时,不要放弃,建议查看相同崩溃点下的更多崩溃日志。
  • 各个资源情况。结合崩溃的基本信息,我们接着看是不是跟内存信息有关,是不是跟资源信息有关,比如是物理内存不足、虚拟内存不足,还是文件句柄fd泄漏了。

     无论是资源文件还是Logcat,内存与线程相关的信息都需要特别注意,很多崩溃都是由于它们使用不当造成的。

第二步:查找共性:如果使用了上面的方法还是不能有效定位,我们可以尝试查找这类崩溃有没有共性,找到了共性,也就可以进一步找到差异,离解决问题也就更近一步。机型、系统、ROM、厂商、ABI,这些采集到的系统信息都可以作为维度聚合,共性问题例如是不是因为安装了Xposed,是不是只出现在x86的手机,是不是只有三星这款机型,是不是只在5.0系统上。应用信息也可以作为维度来聚合,比如正在打开的链接、正在播放的视频、国家、地区等。找到了共性,可以对下一步复现问题有更明确的指引。

第三步:尝试复现:如果我们大概知道了崩溃原因,为了进一步确认更多信息,就需要尝试复现崩溃;如果我们对崩溃完全没有头绪,也希望通过用户操作路径来尝试复现,然后再去分析崩溃原因。在稳定的复现路径上面,我们可以 采用增加日志或者使用Debugger、GDB等各种各样的手段或工具做进一步分析。

      奇葩问题,比如某个厂商改了底层实现、新的Android系统实现有所更改,都需要去Google、翻源码,有时候还需要去抠厂商的ROM或手动刷ROM。这些痛苦经历告诉我们,很多疑难问题需要我们耐得住寂寞,反复猜测、反复发灰度、反复验证。

15.疑难问题:系统崩溃。系统崩溃常常令我们感到非常无助,它可能是某个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)

    为什么8.0系统不会有这个问题呢?在查看了Android 8.0的源码后我们发下有以下修改:

    
    try {
      mWM.addView(mView, mParams);
      trySendAccessibilityEvent();
    } catch (WindowManager.BadTokenException e) {
      /* ignore */
    }

    考虑再三,我们决定参考Android 8.0的做法,直接catch住这个异常。这里的关键点在于寻找Hook点,这个案例算是相对简单的,Toast里面有一个变量叫mTN,它的类型为handler,我们只需要代理它就可以实现捕获。

16.如果做到了上面说的这些,95%以上的崩溃都能解决或者规避,大部分的系统崩溃也是如此。当然我们也希望具备类似动态跟踪、远程诊断等手段,帮助我们进一步调试线上疑难问题。

17.补充一下获得logcat和Jave堆栈的方法:
一. 获取logcat
logcat日志流程是这样的,应用层 --> liblog.so --> logd,底层使用ring buffer来存储数据。
获取的方式有以下三种:
   1. 通过logcat命令获取。
   优点:非常简单,兼容性好。
   缺点:整个链路比较长,可控性差,失败率高,特别是堆破坏或者堆内存不足时,基本会失败。
   2. hook liblog.so实现。通过hook liblog.so 中__android_log_buf_write 方法,将内容重定向到自己的buffer中。
   优点:简单,兼容性相对还好。
   缺点:要一直打开。
   3. 自定义获取代码。通过移植底层获取logcat的实现,通过socket直接跟logd交互。
   优点:比较灵活,预先分配好资源,成功率也比较高。
   缺点:实现非常复杂

二. 获取Java 堆栈
   native崩溃时,通过unwind只能拿到Native堆栈。我们希望可以拿到当时各个线程的Java堆栈
   1. Thread.getAllStackTraces()。
    优点:简单,兼容性好。
    缺点:
        a. 成功率不高,依靠系统接口在极端情况也会失败。
        b. 7.0之后这个接口是没有主线程堆栈。
        c. 使用Java层的接口需要暂停线程
   2. hook libart.so。通过hook ThreadList和Thread的函数,获得跟ANR一样的堆栈。为了稳定性,我们会在fork子进程执行。
   优点:信息很全,基本跟ANR的日志一样,有native线程状态,锁信息等等。
   缺点:黑科技的兼容性问题,失败时可以用Thread.getAllStackTraces()兜底

获取Java堆栈的方法还可以用在卡顿时,因为使用fork进程,所以可以做到完全不卡主进程。

 

  • 0
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值