调试简介

使用过软件的任何人都很有可能遇到过 bug。bug 是指软件中会导致意外行为(如应用崩溃或功能无法按预期运行)的错误。所有开发者(无论经验如何)都会在编写代码时引入 bug,而对于 Android 开发者来说,最重要的技能之一就是识别并修复这些 bug。整个应用版本专门用来修复 bug 的情况并不少见。例如,我们来看看下面 Google 地图的版本详情:

9d5ec1958683e173.png

修复 bug 的过程称为调试。著名的计算机科学家 Brian Kernighan 曾经说过:“最有效的调试工具仍然是仔细的思考,再加上明智放置的输出语句。”虽然可能确实如此,但更复杂的调试工具肯定可以帮助您更快速且更轻松地找到 bug。尽管调试(就像编程一样)是一项随着时间逐渐积累的技能,但熟悉 Android Studio 内置的调试工具越早越好。在本课中,您将了解 Android Studio 的集成式调试程序,学习如何在堆栈轨迹中找到有意义的信息,以及如何使用断点单步调试代码。

前提条件

  • 您知道如何在 Android Studio 中浏览项目。

学习内容

  • 如何将调试程序连接到正在运行的应用。
  • 如何在堆栈轨迹中找到有意义的信息。
  • 使用断点暂停正在运行的应用,并且一次检查一行代码。

所需条件

  • 一台安装了 Android Studio 的计算机。

我们不是调试一个复杂的大型应用,而是从一个空白项目着手,并故意引入一些有 bug 的代码,以帮助演示 Android Studio 中的调试工具。

首先创建一个新的 Android Studio 项目,如下所示。

  1. Select a Project Template 屏幕上,选择 Blank Activity

a949156bcfbf8a56.png

  1. 将应用命名为 Debugging,确保将语言设置为 Kotlin,其余所有内容都保持不变。

9863157e10628a87.png

  1. 您将看到一个新的 Android Studio 项目,其中显示了一个名为 MainActivity.kt 的文件。

e3ab4a557c50b9b0.png

创建 bug

在空白项目中没有很多的调试工作要做。我们来添加一些会导致此应用崩溃的代码。

我们在数学课上学过,不能将一个数字除以 0,还记得吗?让我们看看尝试在代码中除以 0 时会发生什么情况。

  1. 打开 MainActivity.kt 并添加以下函数。这段代码首先定义了两个数字,然后使用 repeat 输出分子除以分母 5 次所得的结果。每次运行 repeat 代码块中的代码时,denominator 的值都会减 1。在第五次也是最后一次迭代时,应用将尝试除以 0。
fun division() {
    val numerator = 60
    var denominator = 4
    repeat(5) {
        println(numerator / denominator)
        denominator--
    }
}
  1. onCreate() 中调用 division() 函数。新的 onCreate() 函数应如下所示。
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)
    division()
}
  1. 运行您的应用。由于 onCreate() 将在第一个函数出现后立即运行,因此预计应用会在启动后立即崩溃。当应用崩溃时,系统会提供有用的信息来帮助您找到错误。
  2. 打开 Android Studio 底部的 Logcat 标签页(如果这是首次打开该标签页,可能需要一些时间来加载)。

e4d025b0363eaa63.png

  1. Logcat 窗口显示了大量的输出,因此您可能需要稍微滚动一下才能找到其他内容。为了确保系统仅显示您的应用的输出,请将左上角的下拉菜单设置为您的模拟器(或物理设备)的名称和您的应用的进程 (com.example.debugging)。

5c008135b1804091.png

  1. 搜索错误消息(在 Windows 上,按 Ctrl+F 组合键;在 Mac 上,按 Command+F 组合键),方法是在搜索框中输入“RuntimeException”,然后按 Enter 键。

9468226e5f4d5729.png

堆栈轨迹剖析

用于描述异常的文本块称为“堆栈轨迹”。堆栈轨迹会显示在发生异常之前调用的所有函数,从最近调用的函数开始。完整的输出如下所示。

Process: com.example.debugging, PID: 23296
    java.lang.RuntimeException: Unable to start activity ComponentInfo{com.example.debugging/com.example.debugging.MainActivity}: java.lang.ArithmeticException: divide by zero
        at android.app.ActivityThread.performLaunchActivity(ActivityThread.java:3449)
        at android.app.ActivityThread.handleLaunchActivity(ActivityThread.java:3601)
        at android.app.servertransaction.LaunchActivityItem.execute(LaunchActivityItem.java:85)
        at android.app.servertransaction.TransactionExecutor.executeCallbacks(TransactionExecutor.java:135)
        at android.app.servertransaction.TransactionExecutor.execute(TransactionExecutor.java:95)
        at android.app.ActivityThread$H.handleMessage(ActivityThread.java:2066)
        at android.os.Handler.dispatchMessage(Handler.java:106)
        at android.os.Looper.loop(Looper.java:223)
        at android.app.ActivityThread.main(ActivityThread.java:7656)
        at java.lang.reflect.Method.invoke(Native Method)
        at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:592)
        at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:947)
     Caused by: java.lang.ArithmeticException: divide by zero
        at com.example.debugging.MainActivity.division(MainActivity.kt:17)
        at com.example.debugging.MainActivity.onCreate(MainActivity.kt:10)
        at android.app.Activity.performCreate(Activity.java:8000)
        at android.app.Activity.performCreate(Activity.java:7984)
        at android.app.Instrumentation.callActivityOnCreate(Instrumentation.java:1309)
        at android.app.ActivityThread.performLaunchActivity(ActivityThread.java:3422)
        at android.app.ActivityThread.handleLaunchActivity(ActivityThread.java:3601)
        at android.app.servertransaction.LaunchActivityItem.execute(LaunchActivityItem.java:85)
        at android.app.servertransaction.TransactionExecutor.executeCallbacks(TransactionExecutor.java:135)
        at android.app.servertransaction.TransactionExecutor.execute(TransactionExecutor.java:95)
        at android.app.ActivityThread$H.handleMessage(ActivityThread.java:2066)
        at android.os.Handler.dispatchMessage(Handler.java:106)
        at android.os.Looper.loop(Looper.java:223)
        at android.app.ActivityThread.main(ActivityThread.java:7656)
        at java.lang.reflect.Method.invoke(Native Method)
        at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:592)
        at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:947)

其中包含大量的文本!幸好,您通常只需要几段文本,通过缩小范围来找到确切错误。让我们从顶部开始讲起。

  1. java.lang.RuntimeException:
java.lang.RuntimeException: Unable to start activity
ComponentInfo{com.example.debugging/com.example.debugging.MainActivity}: java.lang.ArithmeticException: divide by zero

第一行指出应用无法启动 activity,这就是应用崩溃的原因。下一行又多提供了一点信息。具体来说,activity 无法启动的原因是发生了 ArithmeticException。更具体地说,ArithmeticException 的类型为“divide by zero”(除以 0)。

  1. Caused by:
Caused by: java.lang.ArithmeticException: divide by zero
        at com.example.debugging.MainActivity.onCreate(MainActivity.kt:17)

如果您向下滚动到“Caused by”(原因)行,会发现系统再次指出发生了“除以 0”的错误。这一次,系统还为您显示了发生错误的确切函数 (division()),以及确切的行号 (17)。

这一切都不应该让人感到意外,因为这个 bug 是我们故意引入的。不过,如果您需要确定未知错误的原因,那么知道异常的确切类型、函数名称和行号会提供极为有用的信息。

在前面的示例中,您已经看到了堆栈轨迹如何提供有关错误的具体信息,如发生错误的函数和代码行。在处理真正的 bug 时,这可能不足以确定修复方案。例如,即使您知道 bug 是在代码中的什么位置引入的(第 17 行),也知道确切的错误是什么(除以 0),但您可能不知道为什么代码将一个数字除以 0。在这种情况下,您可以在 division() 函数中出现除法之前添加 println() 语句,以输出当前用作分母的 denominator 的值。

fun division() {
    val numerator = 60
    var denominator = 4
    repeat(5) {
        println(denominator)
        println(numerator / denominator)
        denominator--
    }
}

当然,如果问题变得更加复杂,您就必须不断添加 println() 语句并重新运行应用,直到您找到有用的信息为止。随着代码变得越来越复杂,这可能很难跟踪。

在这里,所谓的“断点”就派上用场了!虽然 Brian Kernighan 关于适当放置的输出语句起着重要作用的看法可能是正确的,但断点发挥着类似的作用,只不过它更像是在正在运行的应用上按一下暂停按钮。您几乎可以在任何一行代码中设置断点。当到达某个断点时,所有执行都会停止,您可以检查变量的值,甚至可以通过一次只执行一行代码来单步调试代码。为了使用断点,您需要使用一个称为“调试程序”的工具来运行应用。

连接调试程序

在后台,Android Studio 使用一个称为 Android 调试桥(简称 ADB)的工具。这是一个集成到 Android Studio 中的命令行工具,可以为正在运行的应用提供调试功能,如断点。用于调试的工具通常称为“调试程序”。

为了将调试程序用于(连接到)应用,您不能像以前那样简单地通过依次点击 Run > Run 来运行应用,而应通过依次点击 Run > Debug ‘app' 来运行应用。

21d706a854ebe710.png

为项目添加断点

执行以下步骤来看看断点的实际运用。

  1. 通过点击您希望在其位置暂停的行号旁边的空白处来添加一个断点。该行号旁边会出现一个点,并且该行会突出显示。

6b6c2cd97bdc08ba.png

  1. 依次点击 Run > Debug ‘app' 或使用工具栏中的 f6a141c7f2a4e444.png 图标,在连接了调试程序的情况下运行应用。应用启动时,您应该会看到如下所示的屏幕。

3bd9cbe69d5a0d0e.png

应用启动后,您会看到断点被激活时突出显示。

a4860e59534f216a.png

在您之前查看 Logcat 窗口的屏幕底部,系统打开了一个新的 Debug 标签页。

ce37d2791db7302.png

左侧是函数列表(与堆栈轨迹中显示的列表相同),右侧是一个窗格,您可以在其中检查各个变量的值。顶部还有一些按钮,可帮助您在程序处于暂停状态时进行浏览。您最常用的一个按钮是 Step Over,它用于执行突出显示的一行代码。

a6c07c89e81abdc5.png

执行以下步骤来调试代码。

  1. 到达断点后,第 14 行(声明 numerator 变量)现在突出显示,但尚未执行。使用 Step Over 1d02d8134802ee64.png 按钮执行第 14 行。现在,第 15 行将突出显示。

58f4bb135d5b756e.png

  1. 在第 17 行设置一个断点。这是出现除法的位置,也是堆栈轨迹报告异常的那一行。

88d7d810a29965aa.png

  1. 使用 Debug 窗口左侧的 Resume Program 8119afebc5492126.png 按钮转到下一个断点,并执行 division() 函数的其余部分。

433d1c2a610b7945.png

  1. 请注意,在第 17 行停止了执行,未执行该行。

1f6aedcf2a48c492.png

  1. 每个变量(numeratordenominator)的值都显示在其声明旁边。请注意,您也可以在“Debug”窗口的 Variables 标签页中看到这些变量的值。

ebac20924bafbea5.png

  1. 再按“Debug”窗口左侧的 Resume Program 按钮 4 次,每次都暂停以观察 numeratordenominator 的值。在最后一次迭代时,numerator 应该为 60denominator 应该为 0。但不能将 60 除以 0!

246dd310b7fb54fe.png

现在,您不仅知道了导致 bug 的确切代码行,而且还知道了确切的原因。在第 5 次迭代时,denominator 的值为 0。如需修复此错误,您可以添加一个 if 语句,规定只有在 denominator 不等于 0. 时才会进行除法运算。

fun division() {
    val numerator = 60
    var denominator = 4
    repeat(5) {
        if (denominator != 0) {
            println(numerator / denominator)
        }
        denominator--
    }
}

您也可以将重复代码的次数从 5 更改为 4

fun division() {
    val numerator = 60
    var denominator = 4
    repeat(5) {
        println(numerator / denominator)
        denominator--
    }
}

这两种方法都可以让您运行代码而不会导致运行时异常。试试看吧!

在前面的课程中,您使用过 Kotlin 的 println() 语句来生成文本输出。在 Android 应用中,记录输出的最佳做法是使用日志。有几个函数用于记录输出,它们采用 Log.e()Log.d() 的形式,并且接受两个参数:第一个称为“标记”,它是一个字符串,用于标识日志消息的来源(如记录文本的类的名称);第二个是实际的日志消息。

之所以存在不同的日志函数(以不同的字母命名),是因为它们对应于不同的日志级别。根据您希望输出哪种类型的信息,您可以使用不同的日志级别来帮助您在 Logcat 输出中进行过滤。您经常使用的主要日志级别有 5 个。

日志级别

用例

示例

ERROR

ERROR 日志报告出现的一些严重问题,如应用崩溃的原因。

Log.e(TAG, "The cake was left in the oven for too long and burned.").

WARN

WARN 日志的严重程度低于错误,但仍然会报告一些应该修复的问题,以避免更严重的错误。例如,如果您调用一个已废弃的函数(这意味着,不建议使用该函数,取而代之的是一种更新的替代方案),就会生成此级别的日志。

Log.w(TAG, "This oven does not heat evenly. You may want to turn the cake around halfway through to promote even browning.")

INFO

INFO 日志提供了一些有用的信息,如操作成功完成。这是 println() 语句的日志级别。

Log.i(TAG, "The cake is ready to be served.").println("The cake has cooled.")

DEBUG

DEBUG 日志包含在调查问题时可能有用的信息。

Log.d(TAG, "Cake was removed from the oven after 55 minutes. Recipe calls for the cake to be removed after 50 - 60 minutes.")

VERBOSE

顾名思义,详细日志被视为一种调试日志,调试日志与详细日志形成一种对比,这有点主观。但一般来说,在实现某项功能后可以移除详细日志,而调试日志可能对调试仍然有用。

Log.v(TAG, "Put the mixing bowl on the counter.")Log.v(TAG, "Grabbed the eggs from the refrigerator.")Log.v(TAG, "Plugged in the stand mixer.")

请注意,对于何时使用每种类型的日志级别没有一成不变的规则,特别是何时使用 INFO、DEBUG 和 VERBOSE。软件开发团队可以制定自己的准则来规定何时使用每个日志级别,又或许决定根本不使用某些日志级别,如 VERBOSE

让我们看看这些不同的日志级别在 Logcat 中是什么样子的。

  1. MainActivity.kt 中的类声明前面,添加一个名为 TAG 的常量,并将其值设置为类的名称 MainActivity
private const val TAG = "MainActivity"
  1. MainActivity 类添加一个名为 logging() 的新函数,如下所示。
fun logging() {
    Log.e(TAG, "ERROR: a serious error like an app crash")
    Log.w(TAG, "WARN: warns about the potential for serious errors")
    Log.i(TAG, "INFO: reporting technical information, such as an operation succeeding")
    Log.d(TAG, "DEBUG: reporting technical information useful for debugging")
    Log.v(TAG, "VERBOSE: more verbose than DEBUG logs")
}
  1. onCreate() 中对 division() 的调用(在前面的示例中)替换为对 logging() 的调用。新的 onCreate() 方法应如下所示。
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)
    logging()
}
  1. 运行应用并观察 Logcat 中的输出。如有必要,请过滤输出以仅显示 com.example.debugging 进程的日志。此外,您也可以过滤输出以仅显示带有“MainActivity”标记的日志。为此,请从 Logcat 窗口右上角的下拉菜单中选择“Edit Filter Configuration”。

5fa189e6b18a966a.png

  1. 然后,针对 Log Tag 输入“MainActivity”,并为过滤器创建一个名称,如下所示。

6dbba17eb5df15eb.png

  1. 现在,您应该只看到带有“MainActivity”标记的日志消息。

4061ca006b1d278c.png

请注意,类名称前面有一个字母(例如 W/MainActivity),该字母对应于日志级别。此外,WARN 日志显示为蓝色,而 ERROR 日志显示为红色,就像前面示例中的致命错误一样。

  1. 就像您可以按进程过滤调试输出一样,您也可以按日志级别过滤输出。默认情况下,此项设置为 Verbose,这样将显示 VERBOSE 日志以及更高的日志级别。从下拉菜单中选择“Warn”,您会看到,现在仅显示 WARNERROR 级别的日志。

c4aa479a8dd9d4ca.png

  1. 同样,将下拉菜单中的选项更改为 Assert,您会观察到,未显示任何日志。这样会过滤掉 ERROR 级别及以下的所有日志。

169a0bc232f77734.png

虽然这样似乎有点把 println() 语句太当回事了,但随着您构建更大的应用,会有更多的 Logcat 输出,使用不同的日志级别可让您挑选出最有用且最相关的信息。使用日志被认为是一种最佳做法,在 Android 开发中优先于 println()。选择正确的日志级别不仅能使您的开发团队中可能不像您那样熟悉代码的其他人受益,而且还能使识别和解决 bug 变得更容易。

默认情况下,您用于创建项目的 Blank Activity 模板会添加一个 activity,其中一个 TextView 位于屏幕中央。您之前已经学过,您可以从代码中引用视图,方法是在布局编辑器中设置一个 ID,并使用 findViewByID() 访问视图。让我们尝试访问视图来帮助演示另一个 bug。

  1. 打开 activity_main.xml,选择 Hello, world! TextView,并将 ID 设置为 hello_world

8a5dede436e2718e.png

  1. 然后,返回 ActivityMain.kt 中的 onCreate(),在对 setContentView() 的调用之前添加代码以获取 TextView 并将其文本更改为 "Hello, debugging!"
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    val helloTextView: TextView = findViewById(R.id.hello_world)
    helloTextView.text = "Hello, debugging!"
    setContentView(R.layout.activity_main)
    division()
}
  1. 再次运行应用,您会观察到,它再次在启动后立即崩溃。按 com.example.debugging 过滤日志。

cdb335255d798a0a.png

该异常应该是显示在 Logcat 中的最后一项内容(如果不是,您可以搜索 RuntimeException)。输出应如下所示。

com.example.debugging E/AndroidRuntime: FATAL EXCEPTION: main
    Process: com.example.debugging, PID: 5516
    java.lang.RuntimeException: Unable to start activity ComponentInfo{com.example.debugging/com.example.debugging.MainActivity}: java.lang.IllegalStateException: findViewById(R.id.hello_world) must not be null
        at android.app.ActivityThread.performLaunchActivity(ActivityThread.java:3449)
        at android.app.ActivityThread.handleLaunchActivity(ActivityThread.java:3601)
        at android.app.servertransaction.LaunchActivityItem.execute(LaunchActivityItem.java:85)
        at android.app.servertransaction.TransactionExecutor.executeCallbacks(TransactionExecutor.java:135)
        at android.app.servertransaction.TransactionExecutor.execute(TransactionExecutor.java:95)
        at android.app.ActivityThread$H.handleMessage(ActivityThread.java:2066)
        at android.os.Handler.dispatchMessage(Handler.java:106)
        at android.os.Looper.loop(Looper.java:223)
        at android.app.ActivityThread.main(ActivityThread.java:7656)
        at java.lang.reflect.Method.invoke(Native Method)
        at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:592)
        at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:947)
     Caused by: java.lang.IllegalStateException: findViewById(R.id.hello_world) must not be null
        at com.example.debugging.MainActivity.onCreate(MainActivity.kt:10)
        at android.app.Activity.performCreate(Activity.java:8000)
        at android.app.Activity.performCreate(Activity.java:7984)
        at android.app.Instrumentation.callActivityOnCreate(Instrumentation.java:1309)
        at android.app.ActivityThread.performLaunchActivity(ActivityThread.java:3422)
        at android.app.ActivityThread.handleLaunchActivity(ActivityThread.java:3601)
        at android.app.servertransaction.LaunchActivityItem.execute(LaunchActivityItem.java:85)
        at android.app.servertransaction.TransactionExecutor.executeCallbacks(TransactionExecutor.java:135)
        at android.app.servertransaction.TransactionExecutor.execute(TransactionExecutor.java:95)
        at android.app.ActivityThread$H.handleMessage(ActivityThread.java:2066)
        at android.os.Handler.dispatchMessage(Handler.java:106)
        at android.os.Looper.loop(Looper.java:223)
        at android.app.ActivityThread.main(ActivityThread.java:7656)
        at java.lang.reflect.Method.invoke(Native Method)
        at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:592)
        at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:947)

像之前一样,顶部显示“Unable to start activity”。这讲得通,因为应用在 MainActivity 启动之前就崩溃了。下一行又多告诉了我们一点有关错误的信息。

java.lang.IllegalStateException: findViewById(R.id.hello_world) must not be null

在堆栈轨迹中再向下,您还会看到下面这一行,它显示了确切的函数调用和行号。

Caused by: java.lang.IllegalStateException: findViewById(R.id.hello_world) must not be null
        at com.example.debugging.MainActivity.onCreate(MainActivity.kt:10)

此错误到底是什么意思?“null”值到底是什么呢?虽然这是一个人为设计的示例,您可能已经知道为什么应用会崩溃,但您会不可避免地遇到以前没有见过的错误消息。当发生这种情况时,您很可能不是第一个看到该错误的人,即使是最有经验的开发者也经常使用 Google 搜索错误消息,看看其他人是如何解决该问题的。查找此错误会产生几条来自 StackOverflow 的结果,StackOverflow 是一个网站,开发者可以在该网站上提出问题并给出关于有 bug 的代码或更一般的编程主题的解答。

efa074b344d1704c.png

如果您仔细阅读一些解答,您会注意到,该错误可能有多个不同的原因。不过,如果您将我们的示例与第 1 单元中的代码进行比较,您会注意到,我们故意在对 setContentView() 进行调用之前尝试访问一个视图。在视图存在之前尝试对其进行访问导致了运行时异常。

通过更新代码来修复该错误。

  1. 将对 findViewById() 的调用以及用于设置 helloTextView 的文本的代码行移至对 setContentView() 的调用下方。新的 onCreate() 方法应如下所示。
override fun onCreate(savedInstanceState: Bundle?) {
   super.onCreate(savedInstanceState)
   setContentView(R.layout.activity_main)
   val helloTextView: TextView = findViewById(R.id.hello_world)
   helloTextView.text = "Hello, debugging!"
   division()
}
  1. 然后,重新运行应用,您会观察到,代码不再崩溃,并且文本按预期进行了更新。

e52adf1c7bf6f792.png

总结:

  • 调试是指对代码中的 bug 进行问题排查的过程。
  • 堆栈轨迹提供了有关异常的信息,如导致异常的确切函数以及发生异常的行号。
  • 您可以设置断点来暂停应用的执行。
  • 执行暂停后,您可以“单步”只执行一行代码。

了解详情