调试简介

1. 准备工作

使用过软件的人都很有可能遇到过 bug。bug 是指软件中会导致意外行为(如应用崩溃或功能无法按预期运行)的错误。无论是新手,还是经验丰富的开发者,在编写代码时都无法避免 bug。发现并修复 bug 是 Android 开发者需要掌握的一项重要技能。仅为了修复 bug 而发布一个新应用版本的情况并不少见。下面 Google 地图的版本详情就是个典型的例子:

9d5ec1958683e173.png

修复 bug 的过程称为调试。著名的计算机科学家 Brian Kernighan 曾经说过:“最有效的调试工具仍然是仔细的思考,再加上明智放置的输出语句。”虽然您可能已通过之前的 Codelab 熟悉 Kotlin 的 println() 语句,但专业的 Android 开发者会利用日志记录功能更好地整理其程序的输出。在此 Codelab 中,您将学习如何在 Android Studio 中使用日志记录功能,以及如何将其用作调试工具。您还将学习如何读取错误消息日志(称为堆栈轨迹),以及如何找出和解决 bug。最后,您将了解如何自行研究 bug,并了解如何通过针对运行中的应用截取屏幕截图或生成 GIF 来捕获 Android 模拟器的输出。

前提条件

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

学习内容

学完此 Codelab 后,您将能够

  • 使用 android.util.Logger 写入日志。
  • 了解何时使用不同的日志级别。
  • 将日志用作简单而强大的调试工具。
  • 如何在堆栈轨迹中找到有意义的信息。
  • 搜索错误消息以解决应用的崩溃问题。
  • 从 Android 模拟器中捕获屏幕截图和动画 GIF。

所需条件

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

2. 创建一个新项目

我们在此不使用复杂的大型应用,而是从空白项目着手,来展示日志语句及其在调试方面的作用。

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

  1. New Project 界面上,选择 Empty Activity

72a0bbf2012bcb7d.png

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

60a1619c07fae8f5.png

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

e3ab4a557c50b9b0.png

3. 日志记录和调试输出

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

若要着手在空白项目中使用日志记录功能,请执行以下步骤。

  1. MainActivity.kt 中的类声明前面,添加一个名为 TAG 的常量,并将其值设置为类的名称 MainActivity
private const val TAG = "MainActivity"
  1. MainActivity 类添加一个名为 logging() 的新函数,如下所示。
fun logging() {
    Log.v(TAG, "Hello, world!")
}
  1. onCreate() 中调用 logging()。新的 onCreate() 方法应如下所示。
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)
    logging()
}
  1. 运行该应用以查看实际日志。日志会显示在屏幕底部的 Logcat 窗口中。由于 Logcat 还会显示设备(或模拟器)上其他进程的输出,您可以从下拉菜单中选择您的应用 (com.example.debugging),以过滤掉与该应用无关的所有日志。

199c65d11ee52b5c.png

在输出窗口中,您应该会看到“Hello, world!”输出。如果需要,您可以通过在 Logcat 窗口顶部的搜索框中输入“hello”,搜索所有日志。

92f258013bc15d12.png

日志级别

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

日志级别

用例

示例

ERROR

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

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

WARN

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

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 日志提供了一些有用的信息,如操作成功完成。

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

DEBUG

DEBUG 日志包含在调查问题时可能有用的信息。这些日志不会出现在发布 build 中,例如您会在 Google Play 商店中发布的 build。

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

顾名思义,VERBOSE 是最不具体的日志级别。如果要将调试日志与详细日志对比,会有点主观。不过一般来说,在实现某项功能后可以移除详细日志,而调试日志可能对调试仍然有用。发布 build 中同样也不会包含这些日志。

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.")

请注意,对于何时使用每种类型的日志级别没有一成不变的规则,特别是何时使用 DEBUGVERBOSE。软件开发团队可以制定自己的准则来规定何时使用每个日志级别,又或许决定根本不使用某些日志级别,如 VERBOSE。请务必注意,这两个日志级别不会出现在发布 build 中,因此使用日志进行调试不会影响已发布应用的性能;不过 println() 语句会保留在发布 build 中,确实会对性能产生不利影响。

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

  1. MainActivity.kt 中,将 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. 运行应用并观察 Logcat 中的输出。如有必要,请过滤输出以仅显示 com.example.debugging 进程的日志。此外,您也可以过滤输出以仅显示带有“MainActivity”标记的日志。为此,请从 Logcat 窗口右上角的下拉菜单中选择 Edit Filter Configuration

383ec6d746bb72b1.png

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

e7ccfbb26795b3fc.png

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

4061ca006b1d278c.png

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

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

c4aa479a8dd9d4ca.png

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

ee3be7cfaa0d8bd1.png

虽然这样似乎有点过于重视 println() 语句,但随着您构建更大的应用,会有更多的 Logcat 输出,使用不同的日志级别可让您挑选出最有用且最相关的信息。由于调试日志和详细日志不会影响发布 build 的性能,因此使用日志被视为最佳实践,在 Android 开发中优于 println()。您还可以根据不同的日志级别过滤日志。选择正确的日志级别不仅能使您的开发团队中可能不像您那样熟悉代码的其他人受益,而且还能使识别和解决 bug 变得更容易。

4. 包含错误消息的日志

引入 bug

在空白项目中没有很多的调试工作要做。作为 Android 开发者,您遇到的许多 bug 都与应用崩溃有关。毫无疑问,应用崩溃是一种不好的用户体验。我们来添加一些会导致此应用崩溃的代码。

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

  1. 将以下函数添加到 MainActivity.ktlogging() 函数的上方。这段代码首先定义了两个数字,然后使用 repeat 记录分子除以分母 5 次所得的结果。每次运行 repeat 代码块中的代码时,分母的值都会减 1。在第五次,也是最后一次迭代时,应用将尝试除以 0。
fun division() {
    val numerator = 60
    var denominator = 4
    repeat(5) {
        Log.v(TAG, "${numerator / denominator}")
        denominator--
    }
}
  1. onCreate() 中的 logging() 调用之后,添加对 division() 函数的调用。
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)
    logging()
    division()
}
  1. 再次运行应用,请注意,应用会崩溃。如果您向下滚动到 MainActivity.kt 类中的日志,将会看到之前定义的 logging() 函数的日志、division() 函数的详细日志,还有说明应用崩溃原因的红色错误日志。

12d87f287661a66.png

堆栈轨迹剖析

用于描述崩溃的错误日志(也称为异常)叫作堆栈轨迹。堆栈轨迹会显示在发生异常之前调用的所有函数,从最近调用的函数开始。完整的输出如下所示。

Process: com.example.debugging, PID: 14581
    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:21)
        at com.example.debugging.MainActivity.onCreate(MainActivity.kt:14)
        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.division(MainActivity.kt:21)

如果您向下滚动到“Caused by”(原因)行,会发现系统再次指出发生了“除以 0”的错误。这一次,系统还为您显示了发生错误的确切函数 (division()),以及确切的行号 (21)。Logcat 窗口中的文件名和行号以超链接形式呈现。输出还会显示出错函数 division() 的名称以及调用该函数的函数 onCreate()

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

为什么使用“堆栈轨迹”?

“堆栈轨迹”用来描述错误中的文本输出似乎是个奇怪的术语。为了更好地了解其工作机制,您需要进一步了解函数堆栈。

当一个函数调用另一个函数时,设备在第二个函数完成执行之前不会运行第一个函数中的任何代码。当第二个函数执行完毕后,第一个函数会从上次停止的位置继续运行。如果第二个函数还调用其他函数,也会如此。第二个函数在第三个函数(及其调用的任何其他函数)完成执行之前不会恢复执行,第一个函数在第二个函数完成执行之前也不会恢复执行。这类似于现实世界中堆叠的物品,例如一摞盘子或一摞纸牌。如果您想取个盘子,就需要先取最上面的那个。如果不先拿掉上面的所有盘子,就不可能拿到下面的盘子。

下面的代码很好地说明了函数堆栈。

val TAG = ...

fun first() {
    second()
    Log.v(TAG, "1")
}

fun second() {
    third()
    Log.v(TAG, "2")
    fourth()
}

fun third() {
    Log.v(TAG, "3")
}

fun fourth() {
    Log.v(TAG, "4")
}

如果您调用 first(),系统将按以下顺序记录数字。

3
2
4
1

这是为什么?当系统调用第一个函数时,这个函数会立即调用 second(),因此系统无法立即记录数字 1。相应的函数堆栈如下所示。

second()
first()

然后,第二个函数调用 third(),因此系统将其添加到函数堆栈中。

third()
second()
first()

随后第三个函数会输出数字 3。第三个函数执行完毕后,系统就会将其从函数堆栈中移除。

second()
first()

然后 second() 函数会记录数字 2,随后调用 fourth()。到目前为止,已先后记录数字 32,现在函数堆栈如下所示。

fourth()
second()
first()

fourth() 函数会输出数字 4,然后系统将其从函数堆栈中移除(弹出)。然后 second() 函数完成执行,系统将其从函数堆栈中弹出。由于 second() 及其调用的所有函数均已完成执行,随后设备会执行 first() 中的剩余代码,该代码会输出数字 1

因此,数字的记录顺序如下:4231

如果您花时间浏览一下代码,并在头脑中形象地思考函数堆栈,就能确切看到该执行哪个代码以及按什么顺序执行。这本身就是一种强大的调试技术,可用于调试一些 bug,比如上文中除零计算的示例。通过浏览代码,您还可以清楚地了解将日志语句放在什么位置能够帮助调试更复杂的问题。

5. 利用日志找出并修复 bug

在上一部分中,您仔细看过了堆栈轨迹,尤其是下面这行。

Caused by: java.lang.ArithmeticException: divide by zero
        at com.example.debugging.MainActivity.division(MainActivity.kt:21)

在这里,您可以看出第 21 行发生的崩溃问题与除零计算有关。所以,在执行该代码之前的某个环节中分母为 0。虽然您可以尝试自行逐步执行代码,这非常适合这样的小型示例,不过您也可以使用日志语句,通过输出分母值,避免除零计算的发生,从而节省时间。

  1. Log.v() 语句之前,添加一个用于记录分母的 Log.d() 调用。由于此日志专门用于调试,因此使用 Log.d() 便于您过滤掉详细日志。
Log.d(TAG, "$denominator")
  1. 再次运行应用。虽然应用仍会崩溃,但应该会多次记录分母。您可以通过使用过滤器配置,仅显示带有 "MainActivity" 标记的日志。

d6ae5224469d3fd4.png

  1. 您可以看到输出了多个值。看起来这个循环执行了几次,然后在第五次迭代时,分母为 0,发生了崩溃。这是合理的,因为分母是 4,在 5 次迭代中,分母每次递减 1。若要修复此 bug,您可以将循环中的迭代次数由 5 更改为 4。如果您重新运行该应用,它应该不会再崩溃。
fun division() {
    val numerator = 60
    var denominator = 4
    repeat(4) {
        Log.v(TAG, "${numerator / denominator}")
        denominator--
    }
}

6. 调试示例:访问不存在的值

默认情况下,您用于创建项目的 Blank Activity 模板会添加一个 activity,其中一个 TextView 位于屏幕中央。您之前已经学过,您可以从代码中引用视图,方法是在布局编辑器中设置一个 ID,并使用 findViewById() 访问视图。在 activity 类中调用 onCreate() 时,必须先调用 setContentView() 以加载布局文件(例如 activity_main.xml)。如果您在调用 setContentView() 之前尝试调用 findViewById(),应用会崩溃,因为该视图不存在。让我们尝试访问视图来帮助演示另一个 bug。

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

c94be640d0e03e1d.png

  1. 返回 onCreate() 中的 ActivityMain.kt,在对 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. 再次运行应用,您会发现,它再次在启动后立即崩溃。您可能需要移除上一个示例中所述的过滤条件,才能看到不带 "MainActivity" 标记的日志。840ddd002e92ee46.png

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

Process: com.example.debugging, PID: 14896
    java.lang.RuntimeException: Unable to start activity ComponentInfo{com.example.debugging/com.example.debugging.MainActivity}: java.lang.NullPointerException: 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.NullPointerException: findViewById(R.id.hello_world) must not be null
        at com.example.debugging.MainActivity.onCreate(MainActivity.kt:14)
        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.RuntimeException: Unable to start activity ComponentInfo{com.example.debugging/com.example.debugging.MainActivity}: java.lang.NullPointerException: findViewById(R.id.hello_world) must not be null

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

Caused by: java.lang.NullPointerException: findViewById(R.id.hello_world) must not be null

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

由于可能有很多答案相似但不完全相同的问题,因此在自行寻找答案时,请谨记以下提示。

  1. 回复的新旧程度如何?几年前的回复可能不再适用,或者可能使用了过时的语言或框架版本。
  2. 答案是使用 Java 还是 Kotlin 编写的?您的问题是否特定于某个语言?或者与某个具体框架相关?
  3. 标记有“accepted”或收到顶的次数较多的答案可能质量较高,但请注意,其他答案也可能会提供有价值的信息。

1636a21ff125a74c.png

数字表示顶(或踩)的次数,绿色对勾标记表示答案已被采纳。

如果您找不到现有问题,可以随时提出新问题。当您在 StackOverflow(或任何网站上)上提问时,请谨记这些准则

动手搜索错误

a60ba40e5247455e.png

如果您仔细阅读一些答案,会发现该错误可能有多种原因。不过,鉴于您是有意地在 setContentView() 之前调用 findViewById(),关于此问题的一些答案似乎非常有价值。举例来说,获得顶的数量排名第二的答案是:

“这可能是因为您先调用了 findViewById,然后再调用 setContentView?如果是这种情况,请尝试在调用 setContentView 之后再调用 findViewById

看到此答案后,您随即可以在代码中验证,对 findViewById() 的调用在 setContentView() 之前,确实过早,应在 setContentView() 之后调用。

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

  1. 将对 findViewById() 的调用以及用于设置 helloTextView 的文本的代码行移至对 setContentView() 的调用下方。新的 onCreate() 方法应如下所示。您还可以添加如下所示的日志,验证 bug 是否已修复。
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)
    Log.d(TAG, "this is where the app crashed before")
    val helloTextView: TextView = findViewById(R.id.hello_world)
    Log.d(TAG, "this should be logged if the bug is fixed")
    helloTextView.text = "Hello, debugging!"
    logging()
    division()
}
  1. 重新运行应用。请注意,该应用不再崩溃,文本会按预期更新。

9ff26c7deaa4a7cc.png

截屏

现在,您可能已经在本课程中看到了来自 Android 模拟器的许多屏幕截图。截屏虽然相对简单直接,但对于分享信息来说是非常有用的方法,例如用于与其他团队成员分享重现 bug 的步骤。在 Android 模拟器中,您可以按右侧工具栏中的相机图标进行截屏。

455336f50c5c3c7f.png

您也可以使用键盘快捷键“Command+S”进行截图。屏幕截图会自动保存到“桌面”文件夹中。

录制正在运行的应用

虽然屏幕截图可以传递很多信息,但有时分享应用运行过程的录制内容对于帮助他人重现导致 bug 的问题会很有帮助。Android 模拟器提供了一些内置工具,可帮助您轻松捕获运行中应用的 GIF(动画图片)。

  1. 在右侧的模拟器工具中,点击“More”558dbea4f70514a8.png 按钮(最后一个选项),以显示更多模拟器调试选项。此时会弹出一个窗口,提供用于为测试模拟实体设备功能的更多工具。

46b1743301a2d12.png

  1. 在左侧菜单中,点击 Record and Playback,您即会看到一个屏幕,其中有开始录制的按钮。

dd8b5019702ead03.png

  1. 目前,除了静态 TextView 外,您的项目没有任何有趣的内容可录制。我们来修改代码,使其每隔几秒就更新一次标签,以显示除法计算的结果。在 MainActivity 中的 division() 方法内,在对 Log() 的调用之前添加对 Thread.sleep(3000) 的调用。该方法现在应如下所示(请注意,循环应仅重复 4 次,以避免发生崩溃)。
fun division() {
   val numerator = 60
   var denominator = 4
   repeat(4) {
       Thread.sleep(3000)
       Log.v(TAG, "${numerator / denominator}")
       denominator--
   }
}
  1. activity_main.xml 中,将 TextViewid 设置为 division_textview

db3c1ef675872faf.png

  1. 回到 MainActivity.kt 中,将对 Log.v() 的调用替换为对 findViewById()setText() 的以下调用,将文本设置为商数。
findViewById<TextView>(R.id.division_textview).setText("${numerator / denominator}")
  1. 由于您现在正于应用界面中呈现除法运算的结果,因此需要注意一些有关界面更新运行情况的细节。首先,您需要创建一个可以运行 repeat 循环的新线程。否则,Thread.sleep(3000) 将阻塞主线程,并且在 onCreate() 完成之前(包括 division()repeat 循环)此应用视图不会呈现。
fun division() {
   val numerator = 60
   var denominator = 4

   thread(start = true) {
      repeat(4) {
         Thread.sleep(3000)
         findViewById<TextView>(R.id.division_textview).setText("${numerator / denominator}")
         denominator--
      }
   }
}
  1. 如果您现在尝试运行此应用,会看到 FATAL EXCEPTION。出现此异常的原因是,只有创建了视图的线程才能对其进行更改。幸运的是,您可以使用 runOnUiThread() 引用界面线程。请更改 division() 以更新界面线程中的 TextView
private fun division() {
   val numerator = 60
   var denominator = 4
   thread(start = true) {
      repeat(4) {
         Thread.sleep(3000)
         runOnUiThread {
            findViewById<TextView>(R.id.division_textview).setText("${numerator / denominator}")
            denominator--
         }
      }
   }
}
  1. 运行您的应用,然后立即切换到模拟器。当应用启动时,点击“Extended Controls”窗口中的 Start Recording 按钮。您应该会看到商数每 3 秒更新一次。商数更新几次后,请点击 Stop Recording

55121bab5b5afaa6.png

  1. 默认情况下,系统会以 .webm 格式保存该输出。您可以使用下拉菜单,选择以 GIF 文件格式导出输出。

850713aa27145908.png

7. 恭喜

恭喜!在本衔接课程中,您学习了:

  • 调试是指对代码中的 bug 进行问题排查的过程。
  • 利用日志,您可以输出采用不同日志级别和标记的文本。
  • 堆栈轨迹提供了有关异常的信息,如导致异常的确切函数以及发生异常的行号。
  • 在调试时,其他人也可能遇到了相同或类似的问题,您可以通过 StackOverflow 等网站研究相应 bug。
  • 您可以使用 Android 模拟器轻松导出屏幕截图和动画 GIF。

了解详情