Android 崩溃优化

崩溃分类

Android 的两种崩溃:

  • Java 崩溃:在 Java 代码中,出现了未捕获异常,导致程序异常退出。
  • Native 崩溃:一般是因为在 Native 代码中访问非法地址,也可能是地址对齐出现了问题,或者发生了程序主动 abort(异常终止一个进程),这些都会产生相应的 signal 信号,导致程序异常退出。

可通过 CrashHandler 来监视应用的 crash 信息,当程序 crash 时会调用其 uncaughtException()。


Native 崩溃

建议先阅读《Android 平台 Native 代码的崩溃捕获机制及实现》来了解一些 Native 崩溃的基本知识。
Native 崩溃的捕获流程:

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

Native 崩溃捕获的难点:
上述三个流程中,最核心的在于怎样保证客户端在各种极端情况下依然可以生成崩溃日志。(因为在崩溃时,程序会处于一个不安全的状态,如果处理不当,非常容易发生二次崩溃。)

生成崩溃日志时的一些比较棘手的情况:

  • 文件句柄泄露,导致创建日志文件失败:(一个句柄,就是一个名字, 以便记住正处理的名字, 并隐藏某些缓存等的复杂性。)
    应对方式:需要提前申请文件句柄 fd 预留,防止出现这种情况。(在内核中,每一个进程都有一个私有的”打开文件表”,这个表是一个指针数组,每一个元素都指向一个内核的打开文件对象。而fd,就是这个表的下标。)
  • 因为栈溢出了,导致日志生成失败。
    应对方式:为防止栈溢出导致进程没有空间创建调用栈执行处理函数,通常会使用常见的 signalstack(使用单独的信号堆栈) 。在一些特殊情况下,可能还需要直接替换当前栈,所以这里也需要在堆中预留部分空间。
  • 整个堆的内存都耗尽了,导致日志生成失败。
    应对方式:这个时候无法安全地分配内存,也不敢使用 stl 或者 libc 的函数,因为它们内部实现会分配堆内存。这个时候如果继续分配内存,会导致出现堆破坏或者二次崩溃的情况。( libc 和 stl 是一个应用于 C 语言编程的函数库)
  • 堆破坏或者二次崩溃导致日志生成失败。
    应对方式:Breakpad 会从原进程 fork 出子进程去收集崩溃现场,此外涉及 Java 相关的,一般也会用子进程去操作。这样即使出现二次崩溃,只是这部分的信息丢失,父进程后面还可以继续获取其它的信息。在一些特殊情况下,还可能需要从子进程 fork 出孙进程。
    一个进程调用fork()函数后,系统先给新的进程分配资源,例如存储数据和代码的空间。然后把原来的进程的所有值都复制到新的新进程中,只有少数值与原来的进程的值不同。相当于克隆了一个自己。

崩溃率

如何客观地衡量崩溃:
崩溃就是程序出现异常,而崩溃率是衡量一个应用质量高低的基本指标。

1
2
3
4
5
/**
* UV 是指通过互联网访问、浏览这个网页的人。访问网站的一台电脑客户端为一个访客。00:00-24:00内相同的客户端只被计算一次。
* PV 即页面浏览量或点击量,用户每一次对网站中的每个网页访问均被记录一个 PV。用户对同一页面的多次访问,访问量累计,用以衡量网站用户访问的网页数量。
*/
UV 崩溃率 = 发生崩溃的 UV / 登录 UV

还可以去看应用 PV 崩溃率、启动崩溃率、重复崩溃率这些指标,计算方法都大同小异。
关于启动崩溃率,还涉及到一种《安全模式》的技术来保障客户端的启动流程。


稳定性

如何客观地衡量稳定性:
处理了崩溃,有时还要处理 ANR 的问题:

  • 使用 FileObserver 监听 /data/anr/traces.txt 的变化。
    但很多高版本的 ROM,已经没有读取这个文件的权限了。海外可以使用 Google Play服务。国内微信利用《Hardcoder》框架向厂商获取了更大权限。
  • 监控消息队列的运行时间。
    这个方案无法准确的判断是否真正出现了 ANR 异常,也无法得到完整的 ANR 日志。或许,更应该放到卡顿的性能范畴。

有哪些应用退出的情形:

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

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

还有一个指标来衡量应用的稳定性:

1
2
3
4
/**
* 异常率
*/
UV 异常率 = 发生异常退出或崩溃的 UV / 登录 UV

崩溃现场

崩溃信息

  • 进程名,线程名。崩溃的进程是前台进程还是后台进程,崩溃是不是发生在 UI 线程。
  • 崩溃堆栈和类型。崩溃是属于 Java 崩溃、 Native 崩溃,还是 ANR,对于不同类型的崩溃要关注的点也不太一样。特别需要看崩溃堆栈的栈顶,看具体崩溃在系统的代码,还是自己的代码里。

系统信息

  • Logcat。这里包括应用,系统的运行日志。由于系统权限问题,获取到的 Logcat 可能只包含与当前 App 相关的。其中系统的 event logcat 会记录 App 运行的一些基本情况,记录在文件 /system/etc/event-log-tags 中。
    1
    2
    3
    4
    5
    6
    7
    8
    system logcat:
    10-25 17:13:47.788 21430 21430 D dalvikvm: Trying to load lib ...
    event logcat:
    10-25 17:13:47.788 21430 21430 I am_on_resume_called: 生命周期
    10-25 17:13:47.788 21430 21430 I am_low_memory: 系统内存不足
    10-25 17:13:47.788 21430 21430 I am_destroy_activity: 销毁 Activty
    10-25 17:13:47.888 21430 21430 I am_anr: ANR 以及原因
    10-25 17:13:47.888 21430 21430 I am_kill: APP 被杀以及原因
  • 机型、系统、厂商、CPU、ABI(应用程序二进制接口)、Linux 版本等。
  • 设备状态。是否 root 、是否是模拟器。一些问题是由 Xposed框架 或多开软件造成,对这部分问题要区别对待。

内存信息

  • 系统剩余内存。关于系统内存状态,可以直接读取文件 /proc/meminfo。当系统可用内存很小(低于 MemTotal 的 10%,MemTotal 对应当前系统中可以使用的物理内存)时,OOM、大量 GC、系统频繁自杀拉起等问题都非常容易出现。
  • 应用使用内存。包括 Java 内存、RSS(实际使用物理内存,包含共享库占用的全部内存)、PSS(实际使用的物理内存,比例分配共享库占用的内存,按照进程数等比例划分),可以得出应用本身内存的占用大小和分布。PSS 和 RSS 通过 /proc/self/smap 计算,可以进一步得到例如 apk、dex、so 等更加详细的分类统计。
  • 虚拟内存。虚拟内存可以通过 /proc/self/status 得到,通过 /proc/self/maps 文件可以得到具体的分布情况。有时一般不太重视虚拟内存,但是很多类似 OOM、tgkill 等问题都是虚拟内存不足导致的。
    1
    2
    3
    4
    5
    Name:     com.sample.name   // 进程名
    FDSize: 800 // 当前进程申请的文件句柄个数
    VmPeak: 3004628 kB // 当前进程的虚拟内存峰值大小
    VmSize: 2997032 kB // 当前进程的虚拟内存大小
    Threads: 600 // 当前进程包含的线程个数

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

  • 文件句柄 fd。文件句柄的限制可以通过 /proc/self/limits 获得,一般单个进程允许打开的最大文件句柄个数为 1024 。但是在超过 800 个时就比较危险,需要将所有的 fd 以及对应的文件名输出到日志中,进一步排查是否出现了有文件或者线程泄露。
    1
    2
    3
    4
    5
    6
    opened files count 812:
    0 -> /dev/null
    1 -> /dev/log/main4
    2 -> /dev/binder
    3 -> /data/data/com.crash.sample/files/test.config
    ...
  • 线程数
    当前线程数大小可以通过上面的 status 文件得到,一个线程可能就占 2MB 的虚拟内存,过多的线程会对虚拟内存和文件句柄带来压力。当线程数超过 400 个就比较危险。需要将所有的线程 id 以及对应的线程名输出到日志中,进一步排查是否出现了线程相关的问题。
    1
    2
    3
    4
    5
    threads count 412:               
    1820 com.sample.crashsdk
    1844 ReferenceQueueD
    1869 FinalizerDaemon
    ...
  • JNI。使用 JNI 时,如果不注意很容易出现引用失败、引用爆表等一些崩溃。可通过 DumpReferenceTables 统计 JNI 的引用表,进一步分析是否出现了 JNI 泄露等问题。

应用信息

  • 崩溃场景:崩溃发生在哪个 Activity 或 Fragment,发生在哪个业务里。
  • 关键操作路径:不同于开发过程中详细的打点日志,可以记录关键的用户操作路径,这对复现崩溃会有比较大的帮助。
  • 其它自定义信息:不同的应用关心的重点可能不太一样。此外例如运行时间、是否加载了补丁、是否是全新安装或升级等信息也非常重要。
  • 除了上面这些通用信息外,针对特定的一些崩溃,可能还需要获取类似磁盘空间、电量、网络使用等特定信息。

崩溃分析

确定重点

  • 确定严重程度:优先解决 Top 崩溃或对业务有重大影响的。
  • 崩溃基本信息:确定崩溃的类型和异常描述,对崩溃有大致的判断。
  • Java 崩溃。Java 崩溃类型比较明显。
  • Native 崩溃。需要观察 signal(《崩溃信号介绍》,比较常见的有 SIGSEGV 和 SIGABRT,前者一般由于空指针、非法指针造成,后者主要因为 ANR 和调用 abort()退出所导致。)、code、fault addr等内容,以及崩溃时 Java 的堆栈。
  • ANR。先看主线程堆栈,是否因为锁等待导致。接着看 ANR 日志中 iowait 、CPU、GC、system server 等信息,进一步确定是 I/O 问题,或是 CPU 竞争问题,还是由于大量 GC 导致卡死。
  • Logcat:当从一条崩溃日志中无法看出问题的原因,或者得不到有用信息时,建议查看相同崩溃点下的更多崩溃日志。
  • 各个资源情况:结合崩溃的基本信息,查看是否跟内存信息或是资源信息有关。比如物理内存不足、虚拟内存不足,还是文件句柄 fd 泄露。
  • 无论是资源文件还是 Logcat,内存与线程相关的信息都需要特别注意,很多崩溃都是由于它们使用不当造成的。

查找共性
机型、系统、ROM、厂商、ABI,这些采集到的系统信息都可以作为维度聚合。共性问题例如是否只出现在某款机型,或是某个版本。应用信息也可以作为维度来聚合,比如正在打开的链接、正在播放的视频、国家、地区等。

尝试复现
在稳定的复现路径上面,可以采用增加日志或使用 Debugger、GDB 等各种各样的手段或工具做进一步分析。

疑难问题:系统崩溃

  • 查找可能原因:通过共性归类,查看是某个系统版本的问题,还是某个厂商特定 ROM 的问题。虽然崩溃日志可能没有自己的代码,但通过操作路径和日志,可以找到一些怀疑的点。
  • 尝试规避:查看可疑的代码调用,是否使用了不恰当的 API,是否可以更换其他的实现方式规避。
  • Hook 解决:可分为 Java Hook 和 Native Hook。

获得 Logcat 和 Java 堆栈的方法
获取 Logcat:
logcat 日志流程:应用层–>liblog.so–>logd,底层使用 ring buffer 来存储数据。

  • 通过 logcat 命令获取。
    优点:非常简单,兼容性好。
    缺点:整个链路比较长,可控性差,失败率高,特别是堆破坏或者堆内存不足时,基本会失败。
  • hook liblog.so 实现。通过 hook liblog.so 中 __android_log_buf_write 方法,将内容重定向到自己的 buffer 中。
    优点:简单,兼容性相对还好。
    缺点:要一直打开。
  • 自定义获取代码。通过移植底层获取 logcat 的实现,通过 socket 直接跟 logd 交互。
    优点:比较灵活,预先分配好资源,成功率也比较高。
    缺点:实现非常复杂

获取 Java 堆栈:
native 崩溃时,通过 unwind 只能拿到 Native 堆栈。我们希望可以拿到当时各个线程的 Java 堆栈

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

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


链接

参考资料:
极客时间-Android开发高手课