崩溃

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

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

An app crash on an Android device

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

应用无需在前台运行即可崩溃。任何应用组件,即使是像在后台运行的广播接收器或内容提供器等组件,都可能导致应用崩溃。这些崩溃常常让用户感到困惑,因为他们并未主动与您的应用交互。

如果您的应用遇到崩溃,您可以使用本页中的指南来诊断和修复问题。

检测问题

您可能并非总能知道用户在使用您的应用时是否遇到崩溃。如果您已发布应用,则可以使用 Android vitals 查看应用的崩溃率。

Android vitals

Android vitals 可帮助您监控和改进应用的崩溃率。Android vitals 衡量以下几种崩溃率:

  • 崩溃率:遇到任何类型崩溃的每日活跃用户百分比。
  • 用户感知崩溃率:在活跃使用应用(用户感知崩溃)时,至少遇到一次崩溃的每日活跃用户百分比。如果应用显示任何 Activity 或执行任何前台服务,则视为处于活跃使用状态。

  • 多次崩溃率:至少遇到两次崩溃的每日活跃用户百分比。

每日活跃用户是指在某一天在某一台设备上使用您的应用(可能涉及多个会话)的唯一用户。如果用户在某一天在多台设备上使用您的应用,则每台设备都会计入该天的活跃用户数。如果多个用户在某一天使用同一台设备,则计为一名活跃用户。

用户感知崩溃率是一项核心重要指标,这意味着它会影响您的应用在 Google Play 上的可发现性。它很重要,因为它所计数的崩溃总是发生在用户与应用交互时,从而造成最大程度的干扰。

Play 在此指标上定义了两个不良行为阈值

  • 总体不良行为阈值:在所有设备型号中,至少有 1.09% 的每日活跃用户经历用户感知崩溃。
  • 每设备不良行为阈值:对于单个设备型号,至少有 8% 的每日活跃用户经历用户感知崩溃。

如果您的应用超过总体不良行为阈值,则在所有设备上的可发现性可能会降低。如果您的应用在某些设备上超过了每设备不良行为阈值,则在这些设备上的可发现性可能会降低,并且您的商店详情页上可能会显示警告。

当您的应用出现过度崩溃时,Android vitals 可以通过 Play 管理中心向您发出提醒。

有关 Google Play 如何收集 Android vitals 数据的信息,请参阅 Play 管理中心文档。

诊断崩溃

确定您的应用报告崩溃后,下一步就是诊断它们。解决崩溃可能很困难。但是,如果您能找出崩溃的根本原因,很可能就能找到解决方案。

有许多情况可能导致您的应用崩溃。有些原因很明显,例如检查空值或空字符串,但有些则更微妙,例如向 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 管理器设置,您可以在其中控制设备上的内存量。

Memory setting on AVD manager

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

网络异常

由于用户经常进出移动或 Wi-Fi 网络覆盖范围,因此在应用程序中,网络异常通常不应被视为错误,而应视为意外发生的正常操作条件。

如果您需要重现网络异常(例如 UnknownHostException),请在您的应用程序尝试使用网络时尝试开启飞行模式。

另一个选项是通过选择网络速度模拟和/或网络延迟来降低模拟器中的网络质量。您可以使用 AVD 管理器上的速度延迟设置,或者您可以使用 -netdelay-netspeed 标志启动模拟器,如以下命令行示例所示:

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

此示例将所有网络请求的延迟设置为 20 秒,上传和下载速度为 14.4 Kbps。有关模拟器命令行选项的更多信息,请参阅从命令行启动模拟器

使用 logcat 读取

一旦您能够重现崩溃的步骤,就可以使用 logcat 等工具获取更多信息。

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

防止空指针异常导致的崩溃

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

因为您不希望对每个方法的所有参数都进行空检查,所以您可以依赖 IDE 或对象的类型来指示可空性。

Java 编程语言

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

编译时警告

使用 @Nullable@NonNull 注解您方法参数和返回值,以接收来自 IDE 的编译时警告。这些警告会提示您预期一个可空对象:

Null pointer exception warning

这些空检查适用于您知道可能为空的对象。@NonNull 对象上的异常表明您的代码中存在需要解决的错误。

编译时错误

因为可空性应该有意义,您可以将其嵌入到您使用的类型中,以便在编译时检查空值。如果您知道对象可以为空并且应该处理可空性,则可以将其包装在像 Optional 这样的对象中。您应该始终优先选择能表达可空性的类型。

Kotlin

在 Kotlin 中,可空性是类型系统的一部分。例如,变量需要从一开始就声明为可空或不可空。可空类型用 ? 标记。

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

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

不可空变量不能被赋值为 null,而可空变量在使用为非 null 之前需要检查其可空性。

如果您不想显式检查 null,可以使用 ?. 安全调用运算符:

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

作为最佳实践,请确保处理可空对象的空值情况,否则您的应用可能会进入意外状态。如果您的应用不再因 NullPointerException 而崩溃,您将不知道这些错误是否存在。

以下是一些检查空值的方法:

  • if 检查

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

    由于智能转换和空检查,Kotlin 编译器知道字符串值是非空的,因此它允许您直接使用该引用,而无需安全调用运算符。

  • ?: Elvis 运算符

    此运算符允许您声明“如果对象非空,则返回对象;否则,返回其他内容”。

    val length = string?.length ?: 0
    

您仍然可能在 Kotlin 中遇到 NullPointerException。以下是最常见的情况:

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

平台类型

平台类型是来自 Java 的对象声明。这些类型经过特殊处理;空检查不像在 Java 中那样强制执行,因此非空保证与 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 的引用具有正确可空状态的最佳方法是在 Java 代码中使用可空性注解(例如,@Nullable)。Kotlin 编译器会将这些引用表示为实际的可空或不可空类型,而不是平台类型。

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

由于 Kotlin 的类型系统,我们看到应用程序的 NullPointerException 崩溃大大减少。例如,Google Home 应用在将新功能开发迁移到 Kotlin 的一年中,因空指针异常导致的崩溃减少了 30%。