崩溃

每当未处理的异常或信号导致意外退出时,Android 应用就会崩溃。使用 Java 或 Kotlin 编写的应用会在抛出未处理的异常(由 Throwable 类表示)时崩溃。使用机器码或 C++ 语言编写的应用会在执行过程中遇到未处理的信号(如 SIGSEGV)时崩溃。

当应用崩溃时,Android 会终止应用的进程并显示一个对话框,告知用户应用已停止,如图 1 所示。

Android 设备上的应用崩溃

图 1. Android 设备上的应用崩溃

应用并不是只有在前台运行时才会崩溃。任何应用组件,甚至是广播接收器或 content provider 这类在后台运行的组件,都有可能导致应用崩溃。这些崩溃常常让用户感到困惑,因为他们并没有在使用应用。

如果您的应用发生了崩溃,您可以使用本页中的指南来诊断和解决问题。

检测问题

您并不一定知道用户在使用您的应用时遇到了崩溃。如果您已发布了您的应用,则可以使用 Android Vitals 来查看应用的崩溃率。

Android Vitals

Android Vitals 可帮助您监控和降低应用的崩溃率。Android Vitals 会衡量多种崩溃率:

  • 崩溃率:在日活跃用户数中,遇到任何类型崩溃的用户所占的百分比。
  • 用户感知的崩溃率:在日活跃用户中,在主动使用应用期间遇到过至少 1 次崩溃(用户感知的崩溃)的用户所占的百分比。如果应用显示任何 activity 或执行任何前台服务,即被视为处于活跃使用状态。

  • 多次崩溃率:在日活跃用户数中,遇到过至少 2 次崩溃的用户所占的百分比。

“日活跃用户数”是指在一天内(可能在多次会话中)使用您的应用的唯一身份用户数。如果一位用户在一天中通过多台设备使用您的应用,则系统会针对每台设备统计这一天的活跃用户数。如果多位用户在一天中使用同一台设备,系统会将其统计为一位活跃用户。

“用户感知的崩溃率”是 Android Vitals 核心指标,也就是说,它会影响您的应用在 Google Play 上的曝光度。此指标很重要,因为在用户与应用互动时,它统计的崩溃问题总是会发生,造成最严重的中断。

Play 针对此指标定义了两个不良行为阈值

  • 整体不良行为阈值:在所有设备型号上,至少有 1.09% 的每日活跃用户遇到用户感知的崩溃。
  • 单一设备不良行为阈值在单个设备型号上,至少有 8% 的日活跃用户遇到用户感知的崩溃。

如果您的应用超过了整体不良行为阈值,则在所有设备上的曝光度可能会降低。如果您的应用在某些设备上超出了单一设备不良行为阈值,则在这些设备上的曝光度可能会降低,并且您的商品详情中可能会显示警告。

当您的应用崩溃次数太多时,Android Vitals 可以通过 Play 管理中心向您发出提醒。

如需了解 Google Play 如何收集 Android Vitals 数据,请参阅 Play 管理中心文档。

诊断崩溃

确定应用报告崩溃后,下一步就是诊断崩溃。解决崩溃问题可能很困难。不过,如果您能确定崩溃的根本原因,就很有可能会找到解决方法。

有很多情况会导致应用崩溃。有些原因很明显,例如检查 null 值或空字符串,而其他一些原因则不太容易察觉,例如向 API 传递了无效的参数,或者是复杂的多线程互动。

Android 上的崩溃会生成堆栈轨迹,其中会列出程序在崩溃之前调用过的一系列嵌套函数。您可以在 Android Vitals 中查看崩溃堆栈轨迹。

如何读取堆栈轨迹

解决崩溃的第一步是确定它发生的位置。如果您使用的是 Play 管理中心或 logcat 工具的输出,则可使用报告详细信息中提供的堆栈轨迹。如果您没有堆栈轨迹可用,则应通过手动测试应用或联系受影响的用户在本地再现崩溃,并且要在使用 logcat 的情况下再现。

以下轨迹显示了使用 Java 编程语言编写的应用的崩溃示例:

--------- beginning of crash
AndroidRuntime: FATAL EXCEPTION: main
Process: com.android.developer.crashsample, PID: 3686
java.lang.NullPointerException: crash sample
at com.android.developer.crashsample.MainActivity$1.onClick(MainActivity.java:27)
at android.view.View.performClick(View.java:6134)
at android.view.View$PerformClick.run(View.java:23965)
at android.os.Handler.handleCallback(Handler.java:751)
at android.os.Handler.dispatchMessage(Handler.java:95)
at android.os.Looper.loop(Looper.java:156)
at android.app.ActivityThread.main(ActivityThread.java:6440)
at java.lang.reflect.Method.invoke(Native Method)
at com.android.internal.os.Zygote$MethodAndArgsCaller.run(Zygote.java:240)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:746)
--------- beginning of system

堆栈轨迹会显示对调试崩溃至关重要的两条信息:

  • 抛出的异常的类型。
  • 抛出异常的代码部分。

抛出的异常的类型通常能够非常明确地提示出了什么问题。查看是发生了 IOExceptionOutOfMemoryError,还是其他问题,并找到有关异常类的文档。

抛出异常的类、方法、文件和源文件的行号显示在堆栈轨迹的第二行。对于调用的每个函数,会有另一行显示前面的调用点(称为堆栈帧)。通过遍历堆栈并检查代码,您可能会发现传递错误值的位置。如果您的代码没有出现在堆栈轨迹中,则表示您可能在某个位置将无效参数传递给了异步操作。您通常可以通过以下方式确定发生了什么问题:检查堆栈轨迹的每一行,查找所使用的任何 API 类,确认传递的参数是否正确,以及是否从允许的位置调用了参数。

对于使用 C 和 C++ 代码的应用,其堆栈轨迹的工作方式大致相同。

*** *** *** *** *** *** *** *** *** *** *** *** *** *** *** ***
Build fingerprint: 'google/foo/bar:10/123.456/78910:user/release-keys'
ABI: 'arm64'
Timestamp: 2020-02-16 11:16:31+0100
pid: 8288, tid: 8288, name: com.example.testapp  >>> com.example.testapp <<<
uid: 1010332
signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x0
Cause: null pointer dereference
    x0  0000007da81396c0  x1  0000007fc91522d4  x2  0000000000000001  x3  000000000000206e
    x4  0000007da8087000  x5  0000007fc9152310  x6  0000007d209c6c68  x7  0000007da8087000
    x8  0000000000000000  x9  0000007cba01b660  x10 0000000000430000  x11 0000007d80000000
    x12 0000000000000060  x13 0000000023fafc10  x14 0000000000000006  x15 ffffffffffffffff
    x16 0000007cba01b618  x17 0000007da44c88c0  x18 0000007da943c000  x19 0000007da8087000
    x20 0000000000000000  x21 0000007da8087000  x22 0000007fc9152540  x23 0000007d17982d6b
    x24 0000000000000004  x25 0000007da823c020  x26 0000007da80870b0  x27 0000000000000001
    x28 0000007fc91522d0  x29 0000007fc91522a0
    sp  0000007fc9152290  lr  0000007d22d4e354  pc  0000007cba01b640

backtrace:
  #00  pc 0000000000042f89  /data/app/com.example.testapp/lib/arm64/libexample.so (com::example::Crasher::crash() const)
  #01  pc 0000000000000640  /data/app/com.example.testapp/lib/arm64/libexample.so (com::example::runCrashThread())
  #02  pc 0000000000065a3b  /system/lib/libc.so (__pthread_start(void*))
  #03  pc 000000000001e4fd  /system/lib/libc.so (__start_thread)

如果您在原生堆栈轨迹中未看到类和函数级信息,可能需要生成原生调试符号文件,并将其上传到 Google Play 管理中心。如需了解详情,请参阅对崩溃堆栈轨迹进行去混淆处理。如需了解有关原生代码崩溃的一般信息,请参阅诊断原生代码崩溃问题

有关再现崩溃的提示

再现问题可能无法简单地通过启动模拟器或将设备连接到计算机来实现。开发环境往往具有更多的资源,比如带宽、内存和存储。通过异常类型确定哪项资源不足,或找到 Android 版本、设备类型或应用版本之间的关联。

内存错误

如果发生了 OutOfMemoryError,您可以创建一个内存容量较低的模拟器用来测试。图 2 显示了 AVD 管理器设置,您可以通过这些设置控制设备的内存容量。

AVD 管理器上的内存设置

图 2. AVD 管理器上的内存设置

网络异常

由于用户经常进出移动或 Wi-Fi 网络覆盖范围,因此在应用中,网络异常通常不应被视为错误,而应被视为意外出现的正常运行状况。

如果您需要再现网络异常(比如 UnknownHostException),那么可以在您的应用尝试使用网络时试着打开飞行模式。

此外,也可以通过选择网速模拟和/或网络延迟,降低模拟器中的网络质量。您可以使用 AVD 管理器上的 SpeedLatency 设置,也可以启动带有 -netdelay-netspeed 标志的模拟器,如以下命令行示例中所示:

emulator -avd [your-avd-image] -netdelay 20000 -netspeed gsm

本示例为所有网络请求设置了 20 秒的延迟,并且将上传和下载速度设置为 14.4 Kbps。如需详细了解模拟器的命令行选项,请参阅从命令行启动模拟器

使用 logcat 读取

在您找到再现崩溃的方法后,可以使用 logcat 等工具获取更多信息。

logcat 输出将显示您输出的其他日志消息,以及系统输出的日志消息。请不要忘记关闭您已添加的任何额外的 Log 语句,因为在应用运行时输出它们会浪费 CPU 和电池电量。

防止由 null 指针异常导致的崩溃

当您尝试访问为 null 的对象(通常是通过调用其方法或访问其成员)时,就会发生 null 指针异常(由运行时错误类型 NullPointerException 标识)。null 指针异常是 Google Play 上应用崩溃的最大原因。null 的用途是表示对象缺失 - 例如,尚未创建或分配对象。为了避免 null 指针异常,您需要确保您正在处理的对象引用为非 null 引用,然后再调用其方法或尝试访问其成员。如果对象引用为 null,应妥善处理这种情况(例如,在对对象引用执行任何操作之前退出方法,并将相关信息写入调试日志)。

因为您不希望对调用的每个方法的每个参数都进行 null 检查,所以可以依赖于 IDE 或对象类型来表示是否可为 null。

Java 编程语言

下面几部分适用于 Java 编程语言。

编译时警告

使用 @Nullable@NonNull 标注方法的参数并返回值,以从 IDE 接收编译时警告。这些警告会提示您应该有可为 null 的对象:

null 指针异常警告

这些 null 检查针对您知道可能为 null 的对象。@NonNull 对象的异常指示代码中需要解决的错误。

编译时错误

由于是否可为 null 应该有意义,因此您可以将其嵌入您使用的类型中,以便在编译时检查是否为 null。如果您知道某个对象可以为 null 且应该处理这种是否可为 null 的情况,您可以将其封装在一个对象中,如 Optional。您应始终优先选用表达是否可为 null 的类型。

Kotlin

在 Kotlin 中,是否可为 null 是类型系统的一部分。例如,需要从一开始就将某个变量声明为可为 null 或不可为 null。可为 null 的类型标有 ?

// non-null
var s: String = "Hello"

// null
var s: String? = "Hello"

对于不可为 null 的变量,不能为其赋予 null 值;对于可为 null 的变量,需要先检查是否可为 null,然后再将其用作非 null 变量。

如果您不希望明确检查是否为 null,则可以使用 ?. 安全调用运算符:

val length: Int? = string?.length  // length is a nullable int
                                   // if string is null, then length is null

最佳做法是,确保处理可为 null 的对象的 null 情况,否则您的应用可能会进入非预期状态。如果您的应用不再因 NullPointerException 而崩溃,那么您就不会知道这些错误的存在。

下面是检查是否为 null 的一些方法:

  • if 检查

    val length = if(string != null) string.length else 0
    

    由于智能类型转换和 null 检查,Kotlin 编译器知道字符串值为非 null 值,因此它可让您直接使用引用,而无需使用安全调用运算符。

  • ?: Elvis 运算符

    此运算符可让您声明“如果对象为非 null 对象,则返回对象;否则,返回其他内容”。

    val length = string?.length ?: 0
    

您仍然会在 Kotlin 中收到 NullPointerException。下面是最常见的情况:

  • 当您明确抛出 NullPointerException 时。
  • 当您使用 null 断言 !! 运算符时。此运算符可将任何值转换为非 null 类型,如果值为 null,则会抛出 NullPointerException
  • 当访问平台类型的 null 引用时。

平台类型

平台类型是来自 Java 的对象声明。这些类型受到特殊对待;不强制执行 null 检查,因此非 null 保证与 Java 中相同。当您访问平台类型引用时,Kotlin 不会创建编译时错误,但这些引用可能会导致运行时错误。请参阅 Kotlin 文档中的以下示例:

val list = ArrayList<String>() // non-null (constructor result) list.add("Item")
val size = list.size // non-null (primitive int) val item = list[0] // platform
type inferred (ordinary Java object) item.substring(1) // allowed, may throw an
                                                       // exception if item == null

将平台值赋予 Kotlin 变量时,Kotlin 依赖于类型推断,或者您也可以定义期望的类型。如需确保来自 Java 的引用具有正确的是否可为 null 状态,最佳方法是在 Java 代码中使用是否可为 null 注解(例如 @Nullable)。Kotlin 编译器会将这些引用表示为实际可为 null 或不可为 null 类型,而不是表示为平台类型。

Java Jetpack API 已根据需要带有 @Nullable@NonNull 注解,并且 Android 11 SDK 中也采用了类似的方法。来自此 SDK 的类型在 Kotlin 中使用时,将表示为正确的可为 null 或不可为 null 类型。

由于使用了 Kotlin 的类型系统,我们发现,由 NullPointerException 导致的应用崩溃次数大大减少。例如,在 Google Home 应用将新功能开发迁移到 Kotlin 的一年当中,我们发现,由 null 指针异常导致它崩溃的次数减少了 30%。