Kotlin-安卓编程-全-

Kotlin 安卓编程(全)

原文:zh.annas-archive.org/md5/5516731c6537b7140e922b2c519b8673

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

JetBrains 创建 Kotlin 的原因有两个:一是没有语言可以填补使用(传统)Java 库进行 Android 开发中的所有空白,二是新语言可以让 Android 开发引领潮流,而不仅仅是跟随。

Kotlin 1.0 在 2015 年 2 月正式宣布。Kotlin 简洁、安全、务实,并专注于与 Java 代码的互操作性。它可以在目前使用 Java 的任何地方使用:用于服务器端开发、Android 应用程序、桌面或便携客户端、IoT 设备编程等等。Kotlin 在 Android 开发者中迅速流行起来,Google 决定将 Kotlin 作为官方 Android 开发语言,导致了人们对该语言的兴趣急剧增长。根据 Android Developers 网站,超过 60% 的专业 Android 开发者目前使用 Kotlin。

Android 的学习曲线相当陡峭:诚然,学习难度大,掌握更难。对于许多人来说,成为 Android 开发者的一部分是随着时间暴露于 Android 操作系统与应用程序之间的意外交互。本书旨在通过深入分析 Android 中的这些问题,向读者展示这种类型的暴露情况。我们不仅将讨论 Kotlin 和 Java,还将讨论在使用 Android 时可能出现的并发问题,以及 Kotlin 如何解决这些问题。

我们有时会将 Kotlin 与 Java 进行比较,因为我们认为这样做可以提供更好的见解(特别是因为大多数读者预计具有 Java 背景)。我们可以通过可工作的示例演示如何弥合差距,以及大多数 Kotlin 操作的基本概念与 Java 等效操作更相似的方式。这些任务将按主题组织,以提供软件工程师对该大量信息的结构化分解,并展示如何使应用程序更加强壮和可维护。

此外,熟悉 Java 的用户,包括 Android 开发者,在我们以 Java 和 Kotlin 分别展示每个常见任务时,将发现他们的学习曲线大大平缓。在适当的时候,我们将讨论一个或两者的差异和 pitfalls,但我们希望提供一些易于消化的示例,让任务“一切顺利”,并使读者能够消化和适应现代范式,并立即本能地意识到更新代码的重要性。

虽然 Kotlin 与 Java 完全互操作,但其他 Java 应用程序开发(服务器端编程、桌面客户端、中间件等)尚未像安卓那样普及。这在很大程度上是因为安卓的维护者(谷歌)强烈“鼓励”用户进行转变。用户定期迁移到 Kotlin,但更多的仍然因为任务关键性工作而回到 Java。我们希望这本书能成为安卓开发者在承诺 Kotlin 所代表的优势和简单性时所需的生命线。

谁应该读这本书

超过六百万名安卓工程师。我们相信几乎每一位安卓工程师都能从这本书中受益。虽然只有少数人精通 Kotlin,但他们可能也会从我们提供的信息中学到一些东西。但实际上,我们的目标是那些尚未过渡到 Kotlin 的绝大多数人。这本书也适合那些尝试过一点 Kotlin,但在 Kotlin 的熟悉程度上还没有达到在以 Java 为中心的安卓开发中所积累的水平:

场景 1

读者精通 Java,听说过这种新的 Kotlin 语言,并想尝试一下。因此,他们阅读了一些在线教程,开始使用它,效果很好。不久他们意识到这不仅仅是一种新的语法。习语不同(例如函数式编程、协程),现在也可能有一整套新的开发方式。但是他们缺乏指导、结构。对他们来说,这本书是一个完美的选择。

场景 2

读者是一个由几位 Java 开发者组成的小团队的一部分。他们讨论是否应该在项目中开始包含 Kotlin。即使 Kotlin 被说成与 Java 百分之百互操作,一些同事认为引入另一种语言会增加项目的复杂性。其他人则认为这可能会限制能够参与项目的同事数量,因为需要掌握两种语言。如果读者能证明收益大于成本,他们可以利用这本书说服他们的同事。

场景 3

一位经验丰富的安卓开发者可能已经试过 Kotlin 或者写过其中的一个功能,但在需要完成任务时仍然依赖于 Java。当我们意识到现在我们正在推广的这本书会让我们的生活变得更加轻松时,我们处于这种情况。这也是我们周围最常见的状态——许多安卓开发者接触过 Kotlin,许多人觉得自己在必要时能写出来,但他们可能不知道,或者简单地不认同,数据类、不可变属性和结构化并发的重要性。我们认为这本书会把一个好奇者变成一名坚定的倡导者。

为什么写这本书

有很多书籍展示了 Android 的工作原理,Kotlin 的运行方式,或者并发的工作方式。Kotlin 因其易于采用和更清晰的语法在 Android 开发中变得异常流行,但 Kotlin 为 Android 提供的远不止此:它为解决 Android 中的并发问题提供了新的解决方法。我们编写本书的目的是深入探讨这些主题的独特和特定交集。Android 和 Kotlin 都在快速变化,无论是单独还是共同。试图跟上所有变化可能会很困难。

我们视这本书为历史上的一个有价值的检查点:展示了 Android 的起源,现在的位置以及随着 Kotlin 语言成熟它将如何继续发展。

阅读本书

有时我们会将代码片段作为屏幕截图包含,而不是常规的 atlas 代码格式。这在使用协程和流时特别有用,因为悬停点可以清晰地识别。我们还从 IDE 中获取类型提示。

第一章,“Kotlin 基础” 和 第二章,“Kotlin 集合框架” 讨论了在 Kotlin 中实现的 Android 中的主要显著转变。虽然这些章节中的信息足以为您提供对 Kotlin 的良好基础,但更多的章节将深入探讨更复杂/高级的功能。熟悉 Java 或类似语法结构的用户会发现翻译令人惊讶地自然。

第三章,“Android 基础” 和 第四章,“Android 中的并发” 将为您提供关于 Android 系统在内存和线程方面的基础知识。与任何其他操作系统一样,并发是难以实现的。

第五章,“线程安全” 到 第十一章,“使用 Android 性能分析工具考虑性能问题” 探讨围绕内存和线程的常见问题,同时指出 Android 框架随时间如何演变以赋予开发者更多控制权。同时,这些章节展示了 Kotlin 的扩展和语言特性如何帮助开发者更快地编写更好的应用程序。

第十二章,“通过性能优化减少资源消耗” 探索使用强大的 Android 开发者工具来检查性能和与内存相关的分析,以便能够看到你以前从未真正了解过的东西。本书将为工程师提供专业开发和策划的本地 Android 开发中看到的最常见任务的实现。许多任务将包括一个真实世界的问题,接着是 Java 和 Kotlin 的相应解决方案。当需要进一步解释时,解决方案将遵循简洁和自然语言为重点的快速比较和对比模型。

本书使用的约定

本书使用以下排版约定:

斜体

表示新术语、网址、电子邮件地址、文件名和文件扩展名。

等宽字体

用于程序列表,也可在段落内引用程序元素,如变量或函数名称、数据库、数据类型、环境变量、语句和关键字。

等宽粗体

显示用户应按字面输入的命令或其他文本。

等宽斜体

显示应由用户提供值或由上下文确定值的文本。

提示

此元素表示提示或建议。

注意

此元素表示一般注释。

警告

此元素表示警告或注意。

使用代码示例

补充材料(代码示例、练习等)可在https://github.com/ProgrammingAndroidWithKotlin下载。

如果您有技术问题或使用代码示例时遇到问题,请发送电子邮件至bookquestions@oreilly.com

本书旨在帮助您完成工作。一般而言,如果本书提供示例代码,则您可以在自己的程序和文档中使用它。除非您重现了代码的大部分内容,否则无需联系我们以获得许可。例如,编写使用本书多个代码片段的程序不需要许可。销售或分发 O’Reilly 书籍中的示例代码需要许可。引用本书并引用示例代码回答问题不需要许可。将本书大量示例代码整合到产品文档中需要许可。

我们欢迎,但通常不要求署名。署名通常包括标题、作者、出版商和 ISBN。例如:“使用 Kotlin 编程 Android,作者 Pierre-Olivier Laurence、Amanda Hinchman-Dominguez、G. Blake Meike 和 Mike Dunn(O’Reilly)。版权所有 2022 年 Pierre-Olivier Laurence 和 Amanda Hinchman-Dominguez,978-1-492-06300-1。”

如果您认为您对代码示例的使用超出了公平使用范围或上述许可,请随时通过permissions@oreilly.com联系我们。

O’Reilly 在线学习

注意

40 多年来,O’Reilly Media提供技术和业务培训、知识和见解,帮助公司取得成功。

我们独特的专家和创新者网络通过书籍、文章和我们的在线学习平台分享他们的知识和专业知识。O’Reilly 的在线学习平台为您提供按需访问的实时培训课程、深度学习路径、交互式编码环境以及来自 O’Reilly 和其他 200 多家出版商的大量文本和视频。有关更多信息,请访问http://oreilly.com

如何联系我们

请将有关本书的评论和问题发送至出版社:

  • O’Reilly Media, Inc.

  • 1005 Gravenstein Highway North

  • Sebastopol, CA 95472

  • 800-998-9938(美国或加拿大)

  • 707-829-0515(国际或本地)

  • 707-829-0104(传真)

我们为本书制作了一个网页,列出勘误、示例和任何额外信息。您可以访问https://oreil.ly/pak查看。

发送电子邮件至bookquestions@oreilly.com提出对本书的评论或技术问题。

关于我们的书籍和课程的新闻和信息,请访问http://oreilly.com

在 Facebook 上找到我们:http://facebook.com/oreilly

在 Twitter 上关注我们:http://twitter.com/oreillymedia

在 YouTube 上观看我们:http://youtube.com/oreillymedia

致谢

本书得到了我们的技术审阅者 Adnan Sozuan 和 Andrew Gibel 的大力支持和改进。我们感谢 O’Reilly 的人们帮助我们聚集在一起,并给予我们完成这本书所需的所有支持,特别是 Jeff Bleiel 和 Zan McQuade。

我们感谢 Roman Elizarov 和 Jake Wharton 抽出时间与我们讨论 Kotlin 中并发演变的方向和 Android 低级光学的问题。

我们感谢我们的朋友、家人和同事的支持。我们感谢 Kotlin 社区以及那些抽出时间阅读早期草稿并提供反馈的个人。

最后,我们将这本书献给 Mike Dunn:合著者、同事、朋友和父亲。我们深切怀念他,希望这本书是他为之自豪的东西。

第一章:Kotlin 基础

Kotlin 是由俄罗斯圣彼得堡的 JetBrains 团队创建的。JetBrains 以 IntelliJ Idea IDE 而闻名,后者是 Android Studio 的基础。现在 Kotlin 已经广泛应用于多种操作系统环境中。自从 Google 宣布在 Android 上支持 Kotlin 已经将近五年了。根据 Android Developers Blog,截至 2021 年,Google Play 商店中超过 120 万款应用程序使用 Kotlin,其中包括前一千名应用程序中的 80%。

如果你拿起这本书,我们假设你已经是一名 Android 开发者,并且对 Java 很熟悉。

Kotlin 被设计与 Java 互操作。甚至它的名字,取自圣彼得堡附近的一个岛屿,是对 Java 的一个机智影射,Java 是印度尼西亚的一个岛屿。尽管 Kotlin 支持其他平台(iOS、WebAssembly、Kotlin/JS 等),Kotlin 被广泛使用的关键是其对 Java 虚拟机(JVM)的支持。由于 Kotlin 可以编译为 Java 字节码,它可以在任何支持 JVM 运行的地方运行。

本章的大部分讨论将 Kotlin 与 Java 进行比较。然而,重要的是要理解 Kotlin 不仅仅是加了一些新功能的 Java。Kotlin 是一种全新的不同的语言,与 Java 的联系几乎与它与 Scala、Swift 和 C# 的联系一样紧密。它有自己的风格和习惯用法。虽然可以以 Java 的思维写 Kotlin,但以 Kotlin 的习惯用法思考将展示语言的全部威力。

我们意识到可能有一些 Android 开发者长期以来一直使用 Kotlin,从未写过任何 Java。如果你是这样的人,你可能可以略过本章以及其对 Kotlin 语言的回顾。然而,即使你对该语言相当熟悉,这也可能是一个提醒你某些细节的好机会。

本章并不意味着要对 Kotlin 进行全面介绍,所以如果你对 Kotlin 完全陌生,我们推荐优秀的 Kotlin 实战。^(1) 相反,本章是对 Kotlin 基础的回顾:类型系统、变量、函数和类。即使你不是 Kotlin 语言专家,它也应为你理解本书的其余内容提供足够的基础。

和所有静态类型语言一样,Kotlin 的类型系统是 Kotlin 用来描述自身的元语言。因为这是讨论 Kotlin 的一个重要方面,我们将从回顾它开始。

Kotlin 类型系统

像 Java 一样,Kotlin 是一种静态类型语言。Kotlin 编译器了解程序操作的每个实体的类型。它可以推断^(2) 这些实体,并使用这些推断识别代码与之相悖时将会发生的错误。类型检查允许编译器捕捉和标记整个大类编程错误。本节重点介绍 Kotlin 类型系统的一些最有趣的特性,包括 Unit 类型、函数类型、空安全和泛型。

原始类型

Java 和 Kotlin 类型系统之间最明显的区别是,Kotlin 没有 原始类型 的概念。

Java 有 intfloatboolean 等类型。这些类型的特殊之处在于它们不继承 Java 的基本类型 Object。例如,语句 int n = null; 在 Java 中是非法的。List<int> integers; 也是如此。为了减轻这种不一致性,每个 Java 原始类型都有一个 装箱类型 等价物。例如,Integerint 的类比;Booleanboolean 的类比,依此类推。原始类型和装箱类型之间的区别几乎已经消失,因为自 Java 5 以来,Java 编译器自动在原始类型和装箱类型之间转换。现在可以合法地说 Integer i = 1

Kotlin 的类型系统中没有原始类型,它的单一基础类型Any类似于 Java 的Object,是整个 Kotlin 类型层次结构的根。

注意

Kotlin 对简单类型的内部表示与其类型系统无关。Kotlin 编译器具有足够的信息来以与任何其他语言一样的效率表示 32 位整数。因此,写 val i: Int = 1 可能会使用原始类型或装箱类型,这取决于在代码中如何使用变量 i。尽可能地,Kotlin 编译器会使用原始类型。

空安全

Java 和 Kotlin 的第二个主要区别在于 可空性 是 Kotlin 类型系统的一部分。可空类型通过其名称末尾的问号来区分其非可空模拟;例如,StringString?PersonPerson?。Kotlin 编译器允许将 null 赋给可空类型:var name: String? = null。但它不允许 var name: String = null(因为 String 不是可空类型)。

Any 是 Kotlin 类型系统的根,就像 Java 中的 Object。然而,有一个显著的区别:Any 是所有非空类的基类,而 Any? 是所有可空类的基类。这是 空安全 的基础。换句话说,可以将 Kotlin 的类型系统视为两个相同的类型树:所有非空类型都是 Any 的子类型,所有可空类型都是 Any? 的子类型。

变量必须初始化。变量没有默认值。例如,以下代码会生成编译错误:

val name: String // error! Nonnullable types must be initialized!

正如前面所述,Kotlin 编译器使用类型信息进行推断。通常编译器可以从它已有的信息中推断出标识符的类型。这个过程称为类型推断。当编译器可以推断出类型时,开发人员无需指定它。例如,赋值var name = "Jerry"是完全合法的,尽管并未指定变量name的类型。编译器可以推断变量name必须是String,因为它被赋予了值"Jerry"(这是一个String)。

推断类型有时会让人感到意外。这段代码将生成编译器错误:

var name = "Jerry"
name = null

编译器为变量name推断了类型String,而不是类型String?。因为String不是可空类型,试图将null赋给它是非法的。

需要注意的是,可空类型与其非空对应类型并不相同。理所当然地,可空类型表现为相关非空类型的超类型。例如,下面的代码没有任何问题地编译,因为StringString?

val name = Jerry
fun showNameLength(name: String?) { // Function accepts a nullable parameter
     // ...
}

showNameLength(name)

另一方面,以下代码根本无法编译,因为String? 不是 String

val name: String? = null
fun showNameLength(name: String) { // This function only accepts non-nulls
    println(name.length)
}

showNameLength(name)               // error! Won't compile because "name"
                                   // can be null

仅仅改变参数的类型并不能完全解决问题:

val name: String? = null
fun showNameLength(name: String?) { // This function now accepts nulls
    println(name.length)            // error!
}

showNameLength(name)                // Compiles

这段代码会因为错误Only safe (?.) or non-null asserted (!!.) calls are allowed on a nullable receiver of type String?而失败。

Kotlin 要求对可空变量进行安全处理,以避免生成空指针异常。为了使代码编译通过,必须正确处理namenull的情况:

val name: String? = null
fun showNameLength(name: String?) {
    println(if (name == null) 0 else name.length)
    // we will use an even nicer syntax shortly
}

Kotlin 有特殊的操作符,?.?:,可以简化与可空实体的工作:

val name: String? = null
fun showNameLength(name: String?) {
    println(name?.length ?: 0)
}

在前面的示例中,当name不为null时,name?.length的值与name.length的值相同。然而,当namenull时,name?.length的值为null。该表达式不会抛出空指针异常。因此,在前面的示例中,第一个操作符,安全操作符?.,在语法上等同于:

if (name == null) null else name.length

第二个操作符,Elvis 操作符 ?:,如果左侧表达式非空,则返回左侧表达式的值;否则返回右侧表达式的值。请注意,仅当左侧表达式为 null 时才会评估右侧表达式。

相当于:

if (name?.length == null) 0 else name.length

单元类型

在 Kotlin 中,每个东西都有一个值。一旦你理解了这一点,就不难想象即使一个方法没有明确返回任何内容,它也有一个默认值。那个默认值被命名为UnitUnit恰好是一个对象的名称,如果它们没有任何其他值,就会使用这个值。Unit对象的类型方便地被命名为Unit

Unit的整个概念对于习惯于表达式有值和语句无值之间区别的 Java 开发人员可能显得奇怪。

Java 的条件语句是区分语句表达式的一个很好的例子,因为它同时具有两者!在 Java 中,你可以说:

if (maybe) doThis() else doThat();

然而,你不能说:

int n = if (maybe) doThis() else doThat();

语句,比如if语句,不返回任何值。你不能将if语句的值赋给一个变量,因为if语句不返回任何东西。对于循环语句、case 语句等也是如此。

然而,Java 的if语句有一个类似物,称为三元表达式。由于它是一个表达式,它返回一个值,并且可以被赋值。这在 Java 中是合法的(只要doThisdoThat都返回整数):

int n = (maybe) ? doThis() : doThat();

在 Kotlin 中,没有必要有两个条件语句,因为if是一个表达式并返回一个值。例如,这是完全合法的:

val n = if (maybe) doThis() else doThat()

在 Java 中,返回类型为void的方法类似于一个语句。实际上,这有点不准确,因为void不是一种类型。它是 Java 语言中的保留字,表示该方法不返回任何值。当 Java 引入泛型时,引入了类型Void来填补这个空白(有意思!)。然而,“空白”的两种表现形式,关键字和类型,却令人困惑且不一致:返回类型为Void的函数必须明确返回null

Kotlin 更加一致:所有函数都返回一个值并具有一个类型。如果函数的代码没有明确返回一个值,那么函数的值为Unit

函数类型

Kotlin 的类型系统支持函数类型。例如,下面的代码定义了一个变量func,其值是一个函数,即 lambda 表达式{ x -> x.pow(2.0) }

val func: (Double) -> Double = { x -> x.pow(2.0) }

由于func是一个接受一个Double类型参数并返回Double类型的函数,它的类型是(Double) -> Double

在上一个例子中,我们显式指定了func的类型。然而,Kotlin 编译器可以从赋给它的值中推断出func变量的类型的许多信息。它知道返回类型,因为它知道pow的类型。然而,它没有足够的信息来猜测参数x的类型。如果我们提供了它,我们可以省略变量的类型说明符:

val func = { x: Double -> x.pow(2.0)}
注意

Java 的类型系统不能描述一个函数类型——没有办法在类的上下文之外谈论函数。在 Java 中,要做类似前面例子的事情,我们可以使用函数类型Function,像这样:

Function<Double, Double> func
    = x -> Math.pow(x, 2.0);

func.apply(256.0);

变量func被赋予了一个类型为Function的匿名实例,其方法apply是给定的 lambda 表达式。

由于函数类型的存在,函数可以接受其他函数作为参数或将它们作为值返回。我们称这些为高阶函数。考虑一个 Kotlin 类型的模板:(A, B) -> C。它描述了一个接受两个参数的函数,类型分别为 AB(无论这些类型是什么),并返回类型为 C 的值。因为 Kotlin 的类型语言可以描述函数,ABC 都可以是函数本身。

如果听起来有些抽象,那是因为确实如此。让我们更具体地说明。对于模板中的 A,让我们替换为 (Double, Double) -> Int。这是一个接受两个 Double 并返回一个 Int 的函数。对于 B,让我们简单地替换为一个 Double。到目前为止,我们有 ((Double, Double) -> Int, Double) -> C

最后,假设我们的新功能类型返回一个 (Double) -> Int,一个接受一个 Double 参数并返回一个 Int 的函数。以下代码显示了我们假想函数的完整签名:

fun getCurve(
    surface: (Double, Double) -> Int,
    x: Double
): (Double) -> Int {
    return { y -> surface(x, y) }
}

我们刚刚描述了一个接受两个参数的函数类型。第一个是一个接受两个 Double 参数的函数 (surface),返回一个 Int。第二个是一个 Double (x)。我们的 getCurve 函数返回一个函数,该函数接受一个 Double 参数 (y),并返回一个 Int

将函数作为参数传递给其他函数是函数式语言的重要支柱。使用高阶函数,可以减少代码冗余,而不必像在 Java 中那样创建新的类(子类化 RunnableFunction 接口)。明智地使用高阶函数可以提高代码的可读性。

泛型

像 Java 一样,Kotlin 的类型系统支持类型变量。例如:

fun <T> simplePair(x: T, y: T) = Pair(x, y)

此函数创建一个 Kotlin Pair 对象,其中两个元素必须是相同类型。根据这个定义,simplePair("Hello", "Goodbye")simplePair(4, 5) 都是合法的,但 simplePair("Hello", 5) 不合法。

simplePair 定义中标记为 T 的通用类型是一个类型变量:它可以取 Kotlin 类型的值(在此示例中为 StringInt)。使用类型变量的函数(或类)称为泛型

变量和函数

现在我们有 Kotlin 的类型语言支持,我们可以开始讨论 Kotlin 本身的语法。

在 Java 中,顶级的语法实体是类。所有变量和方法都是某个类的成员,而类是同名文件中的主要元素。

Kotlin 没有这种限制。如果你愿意的话,可以将整个程序放在一个文件中(请不要这样做)。你还可以在任何类之外定义变量和函数。

变量

声明变量有两种方法:使用关键字 valvar。关键字是必需的,是行中的第一件事,并引入了声明:

val ronDeeLay = "the night time"

关键字 val 创建的变量是只读的:它不能被重新赋值。但要小心!你可能会认为 val 就像使用 final 关键字声明的 Java 变量。虽然相似,但并不完全相同!尽管不能被重新赋值,val 变量确实可以改变值!在 Kotlin 中,val 变量更像是 Java 类的字段,具有 getter 但没有 setter,如下面的代码所示:

val surprising: Double
    get() = Math.random()

每次访问 surprising,它都会返回不同的随机值。这是一个没有 后备字段 的属性的示例。我们将在本章后面讨论属性。另一方面,如果我们写了 val rand = Random(),那么 rand 的值不会改变,更像是 Java 中的 final 变量。

第二个关键字 var 创建的是一个熟悉的可变变量:就像一个小盒子,可以装入最后放入的东西。

在接下来的部分,我们将继续讨论 Kotlin 作为函数式语言的一个特性:lambda

Lambda

Kotlin 支持函数字面值:lambda。在 Kotlin 中,lambda 总是用花括号括起来。在花括号内,参数列表位于箭头 -> 的左侧,执行 lambda 的值是箭头右侧的表达式,如下面的代码所示:

{ x: Int, y: Int -> x * y }

按照惯例,返回的值是 lambda 体中最后一个表达式的值。例如,下面代码中显示的函数的类型是 (Int, Int) -> String

{ x: Int, y: Int -> x * y; "down on the corner" }

Kotlin 有一个非常有趣的特性,允许实际上扩展语言。当函数的最后一个参数是另一个函数(该函数是高阶的)时,可以将作为参数传递的 lambda 表达式移出通常界定实际参数列表的括号,如下面的代码所示:

// The last argument, "callback", is a function
fun apiCall(param: Int, callback: () -> Unit)

这个函数通常会像这样使用:

apiCall(1, { println("I'm called back!")})

但由于我们提到的语言特性,它也可以像这样使用:

apiCall(1) {
   println("I'm called back!")
}

这样更好,不是吗?多亏了这个特性,您的代码可以更易读。这个特性的更高级用法是 DSL。^(3)

扩展函数

当您需要向现有类添加一个新方法,并且该类来自您不拥有源代码的依赖项时,您会怎么做?

在 Java 中,如果类不是 final,您可以对其进行子类化。有时这并不理想,因为这会增加项目的复杂性。如果类是 final,则可以在您自己的某个实用程序类内定义静态方法,如下面的代码所示:

class FileUtils {
    public static String getWordAtIndex(File file, int index) {
        /* Implementation hidden for brevity */
    }
}

在前面的示例中,我们定义了一个函数来获取文本文件中给定索引处的单词。在使用时,您可以写成 String word = getWordAtIndex(file, 3),假设您导入了 FileUtils.getWordAtIndex 的静态方法。这很好,我们在 Java 中已经做了这些多年,它很有效。

在 Kotlin 中,还有一件事情可以做。您可以定义一个新的方法在一个类上,即使它不是该类的真正成员函数。因此,您并没有真正扩展类,但在使用时感觉像是向类添加了一个方法。这是如何可能的呢?通过定义一个扩展函数,如下所示:

// declared inside FileUtils.kt
fun File.getWordAtIndex(index: Int): String {
    val context = this.readText()  // 'this' corresponds to the file
    return context.split(' ').getOrElse(index) { "" }
}

在扩展函数的声明内部,this指的是接收类型的实例(这里是File)。您只能访问公共和内部属性和方法,因此privateprotected字段是不可访问的,很快您就会理解为什么。

在使用时,您可以写成 val word = file.getWordAtIndex(3)。正如您所见,我们在File实例上调用getWordAtIndex()函数,就像File类有getWordAtIndex()成员函数一样。这使得使用位置更加表达和可读。我们不必为新的实用程序类命名:我们可以直接在源文件的根部声明扩展函数。

注意

让我们来看看getWordAtIndex的反编译版本:

public class FileUtilsKt {
    public static String getWordAtIndex(
            File file, int index
    ) {
        /* Implementation hidden for brevity */
    }
}

编译时,我们的扩展函数生成的字节码相当于一个以File作为其第一个参数的静态方法。包围类FileUtilsKt的名称与源文件的名称(FileUtils.kt)相同,带有“kt”后缀。

这解释了为什么我们不能在扩展函数中访问私有字段:我们只是添加了一个以接收类型为参数的静态方法。

还有更多!对于类属性,您可以声明扩展属性。思想完全相同——您并没有真正扩展类,但可以使用点符号使新属性可访问,如下面的代码所示:

// The Rectangle class has width and height properties
val Rectangle.area: Double
    get() = width * height

请注意,这次我们使用了val(而不是fun)来声明扩展属性。您可以这样使用它:val area = rectangle.area

扩展函数和扩展属性允许您扩展类的功能,使用漂亮的点表示法,同时仍然保持关注点的分离。您不会因特定需求而在现有类中添加特定的代码。

Kotlin 中的类,起初看起来很像 Java 中的类:class关键字,后面跟随定义类的块。不过 Kotlin 的一大杀手级特性是构造函数的语法以及在其中声明属性的能力。下面的代码显示了一个简单的Point类的定义以及几个用法:

class Point(val x: Int, var y: Int? = 3)

fun demo() {
    val pt1 = Point(4)
    assertEquals(3, pt1.y)
    pt1.y = 7
    val pt2 = Point(7, 7)
    assertEquals(pt2.y, pt1.y)
}

类初始化

请注意,在前面的代码中,Point的构造函数嵌入在类声明中。它被称为主构造函数Point的主构造函数声明了两个类属性,xy,都是整数。第一个x是只读的。第二个y是可变的和可空的,并且具有默认值 3。

注意varval关键字非常重要!声明class Point(x: Int, y: Int)与之前的声明非常不同,因为它不声明任何成员属性。没有这些关键字,标识符xy仅仅是构造函数的参数。例如,以下代码将会生成一个错误:

class Point(x: Int, y: Int?)

fun demo() {
    val pt = Point(4)
    pt.y = 7 // error!  Variable expected
}

在这个示例中,Point类只有一个构造函数,即在其声明中定义的构造函数。然而,在 Kotlin 中,类不仅限于这个单一的构造函数。您还可以定义辅助构造函数和初始化块,如下所示的Segment类的定义:

class Segment(val start: Point, val end: Point) {
    val length: Double = sqrt(
            (end.x - start.x).toDouble().pow(2.0)
                    + (end.y - start.y).toDouble().pow(2.0))

    init {
        println("Point starting at $start with length $length")
    }

    constructor(x1: Int, y1: Int, x2: Int, y2: Int) :
            this(Point(x1, y1), Point(x2, y2)) {
        println("Secondary constructor")
    }
}

这个示例中还有一些其他值得注意的事情。首先,请注意辅助构造函数必须在其声明中委托给主构造函数,即: this(...)。构造函数可能有一个代码块,但必须显式地首先委托给主构造函数。

或许更有趣的是在前述声明中代码的执行顺序。假设创建一个新的Segment,使用辅助构造函数,打印语句将以何种顺序出现?

好吧!让我们试一试看:

>>> val s = Segment(1, 2, 3, 4)

Point starting at Point(x=1, y=2) with length 2.8284271247461903
Secondary constructor

这非常有趣。init块在与辅助构造函数关联的代码块之前运行!另一方面,属性lengthstart已经用它们的构造函数提供的值初始化。这意味着主构造函数必须在init块之前运行。

实际上,Kotlin 保证了这个顺序:主构造函数(如果有的话)首先运行。在它完成之后,init块按声明顺序(从上到下)运行。如果使用辅助构造函数创建新实例,与该构造函数关联的代码块将是最后执行的内容。

属性

Kotlin 变量,在构造函数中使用valvar声明,或者在类的顶层声明,实际上定义了一个属性。在 Kotlin 中,属性类似于 Java 字段及其 getter(如果属性是只读的,用val定义),或者它的 getter 和 setter(如果用var定义)的组合。

Kotlin 支持自定义属性的访问器和修改器,并有特殊的语法来实现,如Rectangle类的定义所示:

class Rectangle(val l: Int, val w: Int) {
    val area: Int
        get() = l * w
}

属性area合成的:它从长度和宽度的值计算得出。因为对area进行赋值是没有意义的,所以它是一个val,只读的,并且没有set()方法。

使用标准的“点”符号访问属性的值:

val rect = Rectangle(3, 4)
assertEquals(12, rect.area)

为了进一步探索自定义属性的 getter 和 setter,考虑一个具有经常使用的哈希码(可能实例保存在Map中)且计算代价很高的类。作为设计决策,您决定缓存哈希码,并在类属性值更改时设置它。第一次尝试可能看起来像这样:

// This code doesn't work (we'll see why)
class ExpensiveToHash(_summary: String) {

    var summary: String = _summary
        set(value) {
            summary = value    // unbounded recursion!!
            hashCode = computeHash()
        }

    //  other declarations here...
    var hashCode: Long = computeHash()

    private fun computeHash(): Long = ...
}

前面的代码将因为无限递归而失败:对 summary 的赋值是对 summary.set() 的调用!试图在其自身的 setter 内设置属性的值是行不通的。Kotlin 使用特殊标识符 field 来解决这个问题。以下显示了修正后的代码版本:

class ExpensiveToHash(_summary: String) {

    var summary: String = _summary
        set(value) {
            field = value
            hashCode = computeHash()
        }

    //  other declarations here...
    var hashCode: Long = computeHash()

    private fun computeHash(): Long = ...
}

标识符 field 仅在自定义 getter 和 setter 内具有特殊意义,其中它指的是包含属性状态的后备字段

还要注意,前面的代码演示了使用构造函数提供的值初始化具有自定义 getter/setter 的属性的习惯用法。在构造函数参数列表中定义属性是非常方便的简写形式。然而,如果几个构造函数中的属性定义具有自定义 getter 和 setter,可能会使构造函数变得难以阅读。

当必须从构造函数初始化具有自定义 getter 和 setter 的属性时,该属性与其自定义 getter 和 setter 一起在类的主体中定义。属性使用构造函数中的一个参数进行初始化(在本例中为 _summary)。这再次说明了构造函数参数列表中 valvar 关键字的重要性。参数 _summary 只是一个参数,并不是类属性,因为它在声明时没有任何关键字。

延迟初始化属性

有时,在声明变量的地方无法获得其值。对于 Android 开发者来说,一个明显的例子是在 ActivityFragment 中使用的 UI 小部件。直到 onCreateonCreateView 方法运行时,变量才能被初始化,以便在整个活动中引用该小部件。例如,在本例中的 button

class MyFragment: Fragment() {
    private var button: Button? = null // will provide actual value later
}

变量必须进行初始化。一个标准的技术,因为我们还不知道值,是将变量设为可空,并用 null 进行初始化。

在这种情况下,您应该首先问自己的问题是,是否真的有必要在此时此地定义这个变量。button 引用真的会在多个方法中使用吗,还是只在一个或两个特定位置使用?如果是后者,您可以完全消除类全局变量。

然而,使用可空类型的问题在于,每当在代码中使用 button 时,您都需要检查其是否为 null。例如:button?.setOnClickListener { .. }。如果像这样使用几个变量,您将会看到很多令人讨厌的问号!如果您习惯于 Java 和它的简单点表示法,这看起来可能特别凌乱。

您可能会问,为什么 Kotlin 不允许我使用非空类型来声明 button,当您确信在任何东西尝试访问它之前将对其进行初始化?难道没有一种方法可以放宽编译器对此 button 的初始化规则吗?

是可能的。您可以使用 lateinit 修饰符来实现这一点,如下面的代码所示:

class MyFragment: Fragment() {
    private lateinit var button: Button // will initialize later
}

因为变量声明为 lateinit,Kotlin 允许您声明变量而不为其分配值。该变量必须是可变的,即 var,因为根据定义,您将稍后为其分配一个值。很好——问题解决了,对吧?

我们这些作者,在开始使用 Kotlin 时确实考虑到了这一点。现在,我们倾向于仅在绝对必要时使用 lateinit,并使用可空值替代。为什么呢?

当您使用 lateinit 时,您在告诉编译器:“我现在没有值可以给你。但我保证以后会给你一个值。” 如果 Kotlin 编译器能说话,它会回答:“好的!你说你知道自己在做什么。如果出了问题,那就是你的责任。” 使用 lateinit 修饰符时,您会为该变量禁用 Kotlin 的空安全性。如果您忘记初始化变量或在初始化之前调用某些方法,则会收到 UninitializedPropertyAccessException,这与在 Java 中收到 NullPointerException 类似。

每次 我们在代码中使用 lateinit,最终都会受到影响。我们的代码可能在所有我们预见的情况下都可以工作。我们确信没有遗漏任何东西… 但我们错了。

当您声明一个变量为 lateinit 时,您正在做编译器无法证明的假设。当您或其他开发人员在之后重构代码时,您精心设计的代码可能会被破坏。测试可能会捕捉到错误。或者不会。^(4) 根据我们的经验,使用 lateinit 总是导致运行时崩溃。我们是如何解决这个问题的?通过使用可空类型。

当您使用可空类型而不是 lateinit 时,Kotlin 编译器会强制您在代码中检查可空性,确切地在可能为 null 的地方。在代码中添加几个问号绝对值得以获得更健壮的代码。

懒加载属性

在软件工程中,推迟创建和初始化对象直到实际需要时是一种常见模式。这种模式被称为 懒加载初始化,在 Android 上尤其常见,因为在应用启动期间分配大量对象会导致启动时间变长。Example 1-1 是 Java 中懒加载初始化的典型案例。

Example 1-1. Java 懒加载初始化
class Lightweight {
    private Heavyweight heavy;

    public Heavyweight getHeavy() {
        if (heavy == null) {
            heavy = new Heavyweight();
        }
        return heavy;
    }
}

heavy 字段只有在首次调用 lightweight.getHeavy() 时,才会使用类 Heavyweight 的新实例进行初始化(假设这个实例创建代价高昂)。后续调用 getHeavy() 将返回缓存的实例。

在 Kotlin 中,懒加载初始化是语言的一部分。通过使用 by lazy 指令并提供一个初始化块,剩下的懒加载实例化是隐式的,如 Example 1-2 所示。

Example 1-2. Kotlin 懒加载初始化
class Lightweight {
    val heavy by lazy { // Initialization block
        Heavyweight()
    }
}

我们将在下一节中更详细地解释这种语法。

注意

注意,在示例 1-1 中的代码不是线程安全的。同时调用LightweightgetHeavy()方法的多个线程可能会得到不同的Heavyweight实例。

默认情况下,示例 1-2 中的代码是线程安全的。对Lightweight::getHeavy()的调用将被同步,以确保每次只有一个线程在初始化块中。

可以使用LazyThreadSafetyMode来对延迟初始化块的并发访问进行精细化控制。

Kotlin 的延迟值在运行时不会被初始化。第一次引用属性heavy时,初始化块将被执行。

委托

Lazy 属性是 Kotlin 更一般的委托特性的一个例子。声明使用关键字by定义一个委托,该委托负责获取和设置属性的值。在 Java 中,可以通过例如将其参数传递给委托对象的方法调用来实现类似的功能。

因为 Kotlin 的延迟初始化特性是惯用 Kotlin 强大功能的一个极好例子,让我们花点时间来解析它。

在示例 1-2 中声明的第一部分是val heavy。我们知道,这是一个只读变量heavy的声明。接下来是关键字by,引入了一个委托。关键字by表示声明中的下一个标识符是一个表达式,该表达式将求值为负责heavy值的对象。

声明中的下一个事物是标识符lazy。Kotlin 期望一个表达式。原来lazy只是一个函数!它是一个接受单个 lambda 参数并返回对象的函数。它返回的对象是一个Lazy<T>,其中T是 lambda 返回的类型。

Lazy<T>的实现非常简单:第一次调用时运行 lambda 并缓存其值。随后的调用将返回缓存的值。

延迟委托只是属性委托的众多变体之一。使用关键字by,你也可以定义可观察属性(参见Kotlin 委托属性的文档)。在 Android 代码中,延迟委托是最常用的属性委托。

伴生对象

或许你想知道 Kotlin 是如何处理静态变量的。别担心;Kotlin 使用伴生对象。伴生对象是始终与 Kotlin 类相关联的单例对象。虽然不是必需的,但大多数情况下伴生对象的定义放在相关类的底部,如下所示:

class TimeExtensions {
    //  other code

    companion object {
        const val TAG = "TIME_EXTENSIONS"
    }
}

Companion objects 可以有名称,扩展类,并继承接口。在这个例子中,TimeExtension的伴生对象被命名为StdTimeExtension,并继承接口Formatter

interface Formatter {
    val yearMonthDate: String
}

class TimeExtensions {
    //  other code

    companion object StdTimeExtension : Formatter {
        const val TAG = "TIME_EXTENSIONS"
        override val yearMonthDate = "yyyy-MM-d"
    }
}

当从包含它的类外部引用伴生对象的成员时,必须用包含类的名称限定引用:

val timeExtensionsTag = TimeExtensions.StdTimeExtension.TAG

当 Kotlin 加载相关类时,伴生对象会被初始化。

数据类

在 Java 中有一类非常常见的类,它们甚至有一个名字:POJOs,或者叫做普通的旧 Java 对象。其思想是它们是结构化数据的简单表示。它们是数据成员(字段)的集合,其中大多数有 getter 和 setter,还有少量其他方法:equalshashCodetoString。这种类如此常见,以至于 Kotlin 将其作为语言的一部分。它们被称为数据类

我们可以通过将其定义为数据类来改进Point类的定义:

data class Point(var x: Int, var y: Int? = 3)

使用data修饰符声明的这个类与原始的不使用修饰符声明的类有什么区别?让我们进行一个简单的实验,首先使用原始定义的Point(不带data修饰符):

class Point(var x: Int, var y: Int? = 3)

fun main() {
    val p1 = Point(1)
    val p2 = Point(1)
    println("Points are equals: ${p1 == p2}")
}

这个小程序的输出将是"Points are equals: false"。这个也许意料之外的结果的原因是,Kotlin 将p1 == p2编译成了p1.equals(p2)。由于我们第一次定义的Point类没有覆盖equals方法,这就转变成了对Point基类Any中的equals方法的调用。Anyequals实现仅在对象与自身比较时返回true

如果我们尝试使用新定义的Point作为数据类来做同样的事情,程序将会打印出"Points are equals: true"。新定义的行为符合预期,因为数据类自动包含了equalshashCodetoString方法的覆盖。每个自动生成的方法都依赖于类的所有属性。

例如,Point的数据类版本包含了等效于这个的equals方法:

override fun equals(o: Any?): Boolean {
    // If it's not a Point, return false
    // Note that null is not a Point
    if (o !is Point) return false

    // If it's a Point, x and y should be the same
    return x == o.x && y == o.y
}

除了提供equalshashCode的默认实现外,数据类还提供了copy方法。以下是其使用示例:

data class Point(var x: Int, var y: Int? = 3)
val p = Point(1)          // x = 1, y = 3
val copy = p.copy(y = 2)  // x = 1, y = 2

Kotlin 的数据类非常方便,用于频繁使用的习语。

在接下来的部分中,我们将研究另一种特殊类型的类:枚举类

枚举类

还记得开发者曾经被建议说枚举在安卓上太昂贵了吗?幸运的是,现在没有人再建议那样做:尽情使用枚举类吧!

Kotlin 的枚举类与 Java 的枚举非常相似。它们创建一个不能被子类化的类,并且有一组固定的实例。与 Java 一样,枚举不能子类化其他类型,但可以实现接口,可以有构造函数、属性和方法。以下是一些简单的示例:

enum class GymActivity {
    BARRE, PILATES, YOGA, FLOOR, SPIN, WEIGHTS
}

enum class LENGTH(val value: Int) {
    TEN(10), TWENTY(20), THIRTY(30), SIXTY(60);
}

枚举与 Kotlin 的when表达式非常搭配。例如:

fun requiresEquipment(activity: GymActivity) = when (activity) {
    GymActivity.BARRE -> true
    GymActivity.PILATES -> true
    GymActivity.YOGA -> false
    GymActivity.FLOOR -> false
    GymActivity.SPIN -> true
    GymActivity.WEIGHTS -> true
}

当使用when表达式用于变量赋值,或者作为函数的表达式体时,必须是穷尽的。一个穷尽的when表达式是指覆盖了其参数的每一个可能值(在本例中为activity)。确保when表达式是穷尽的一种标准方法是包含一个else子句。else子句匹配参数的任何未显式列出的值。

在前面的例子中,为了是穷尽的,when表达式必须适应函数参数activity的每一个可能值。该参数是类型为GymActivity的枚举实例之一。因为枚举具有已知的一组实例,Kotlin 可以确定所有可能的值都被显式列出,并允许省略else子句。

像这样省略else子句有一个非常好的优势:如果我们向GymActivity枚举添加一个新值,我们的代码将突然无法编译。Kotlin 编译器检测到when表达式不再是穷尽的。几乎肯定,当你向枚举添加新的情况时,你希望知道所有需要适应新值的代码位置。不包含else情况的穷尽when表达式正是这样做的。

注意

如果一个when语句不需要返回值(例如,when语句的值不是函数的返回值的值),会发生什么?

如果when语句未用作表达式,Kotlin 编译器不会强制其为穷尽的。但是,你会收到一个 lint 警告(在 Android Studio 中为黄色标志),告诉你建议对枚举的when表达式进行穷尽检查。

还有一个技巧可以强制 Kotlin 将任何when语句解释为表达式(因此必须是穷尽的)。在示例 1-3中定义的扩展函数强制when语句返回一个值,正如我们在示例 1-4中看到的。因为它必须有一个值,Kotlin 将坚持它是穷尽的。

示例 1-3. 强制when表达式是穷尽的
val <T> T.exhaustive: T
    get() = this
示例 1-4. 检查穷尽的when
when (activity) {
    GymActivity.BARRE -> true
    GymActivity.PILATES -> true
}.exhaustive // error!  when expression is not exhaustive.

枚举是创建具有指定静态实例集的类的一种方法。Kotlin 提供了这种能力的一个有趣的泛化,即封闭类

封闭类

考虑以下代码。它定义了一个单一类型Result,具有精确的两个子类型。Success包含一个值;Failure包含一个Exception

interface Result
data class Success(val data: List<Int>) : Result
data class Failure(val error: Throwable?) : Result

注意,用enum无法实现这一点。枚举的所有值必须是相同类型的实例。然而,在这里,有两个不同类型作为Result的子类型。

我们可以创建两种类型中的任何一种的新实例:

fun getResult(): Result = try {
    Success(getDataOrExplode())
} catch (e: Exception) {
    Failure(e)
}

同样,when表达式是管理Result的一种方便方式:

fun processResult(result: Result): List<Int> = when (result) {
    is Success -> result.data
    is Failure -> listOf()
    else -> throw IllegalArgumentException("unknown result type")
}

我们不得不再次添加else分支,因为 Kotlin 编译器不知道SuccessFailure是唯一的Result子类。在程序的某个地方,你可能会创建Result的另一个子类并添加另一个可能的情况。因此,编译器需要else分支。

封闭类对类型起到与枚举对实例的作用。它们允许你向编译器宣告某种基类型(此处为Result)有一组固定的已知子类型(在本例中为SuccessFailure)。要做出这样的声明,请在声明中使用关键字sealed,如下代码所示:

sealed class Result
data class Success(val data: List<Int>) : Result()
data class Failure(val error: Throwable?) : Result()

因为Resultsealed的,Kotlin 编译器知道SuccessFailure是唯一可能的子类。因此,我们可以再次从when表达式中移除else分支:

fun processResult(result: Result): List<Int> = when (result) {
    is Success -> result.data
    is Failure -> listOf()
}

可见性修饰符

在 Java 和 Kotlin 中,可见性修饰符决定了变量、类或方法的作用域。在 Java 中,有三种可见性修饰符:

private

引用仅对其定义所在的类及其外部类(如果定义在内部类中)可见。

protected

引用仅对其定义所在的类及其子类可见。此外,它们还可从同一包中的类中访问。

public

引用在任何地方可见。

Kotlin 也有这三种可见性修饰符。但是,存在一些细微的差异。在 Java 中,你只能在类成员声明中使用它们,而在 Kotlin 中,你可以在类成员顶层声明中使用它们:

private

声明的可见性取决于其定义位置:

  • 声明为private的类成员仅在定义它的中可见。

  • 顶层的private声明仅在定义它的文件中可见。

protected

受保护的声明仅在定义它们的类及其子类中可见。

public

引用与 Java 中一样,在任何地方可见。

除了这三种不同的可见性修饰符,Java 还有第四种package-private,使引用仅在同一包中的类中可见。当声明没有可见性修饰符时,即为包私有可见性,这是 Java 中的默认可见性。

Kotlin 没有这样的概念。^(5) 这可能会让人感到惊讶,因为 Java 开发者经常依赖于包私有可见性来隐藏同一模块内其他包的实现细节。在 Kotlin 中,包不用于可见性作用域——它们只是命名空间。因此,Kotlin 中的默认可见性与 Java 不同——它是public

Kotlin 不支持包私有可见性对我们如何设计和组织代码产生了相当大的影响。为了确保声明(类、方法、顶层字段等)的完全封装,可以在同一个文件中将所有这些声明设为private

有时将几个密切相关的类分割到不同的文件中是可以接受的。然而,这些类不能访问同一包中的兄弟类,除非它们是 publicinternal。什么是 internal?它是 Kotlin 支持的第四种可见性修饰符,在包含 模块 内的任何地方都可见。^(6) 从模块的角度来看,internalpublic 是相同的。然而,在将此模块用作库(例如,它是其他模块的依赖项)时,internal 就显得很有意义。事实上,internal 声明对导入您库的模块是不可见的。因此,internal 对于隐藏对外界的声明是很有用的。

注意

internal 修饰符并不是用于模块内部的可见性范围,这是 Java 中包私有的作用。在 Kotlin 中这是不可能的。可以使用 private 修饰符稍微严格限制可见性。

摘要

表 1-1 强调了 Java 和 Kotlin 之间的一些关键差异。

表 1-1. Java 和 Kotlin 特性的差异

功能 Java Kotlin
文件内容 单个文件包含单个顶级类。 单个文件可以包含任意数量的类、变量或函数。
变量 使用 final 使变量不可变;变量默认可变。定义在类级别。 使用 val 使变量只读,或使用 var 进行读/写操作。定义在类级别,或可以独立存在于类外。
类型推断 需要数据类型。Date date = new Date(); 数据类型可以推断,例如 val date = Date(),或显式定义,例如 val date: Date = Date()
包装和拆箱类型 在 Java 中,像 int 这样的原始数据类型推荐用于更昂贵的操作,因为它们比 Integer 等包装类型更便宜。然而,在 Java 的包装类中,有许多有用的方法。 Kotlin 没有原始类型。一切都是对象。当编译为 JVM 时,生成的字节码会自动进行拆箱操作,如果可能的话。
访问修饰符 公共和受保护的类、函数和变量可以被扩展和重写。 作为一种函数式语言,Kotlin 在尽可能的情况下鼓励不可变性。类和函数默认为 final。
多模块项目中的访问修饰符 默认访问是包私有的。 Kotlin 没有包私有,而是默认公共访问。新的 internal 访问提供同一模块中的可见性。
函数 所有函数都是方法。 Kotlin 具有函数类型。函数数据类型看起来像 (param: String) -> Boolean
可空性 任何非基本类型对象可以为 null。 只有显式可空引用,类型后缀为 ?,可以设置为 null:val date: Date? = new Date()
静态与常量 static 关键字将变量附加到类定义,而不是实例。 没有 static 关键字。使用私有 constcompanion 对象。

恭喜,您刚刚完成了一章涵盖 Kotlin 的基本内容。在我们开始讨论如何将 Kotlin 应用于 Android 之前,我们需要讨论 Kotlin 的内置库:集合和数据转换。理解 Kotlin 中数据转换的基本功能将为理解 Kotlin 作为一种函数式语言所需的基础打下必要的基础。

^(1) Dmitry Jemerov 和 Svetlana Isakova。Kotlin in Action。Manning,2017 年。

^(2) Kotlin 官方称之为类型推断,它使用编译器的部分阶段(前端组件)在您在 IDE 中编写代码时进行类型检查。这是 IntelliJ 的一个插件!有趣的事实:IntelliJ 和 Kotlin 的整体都是由编译器插件构建的。

^(3) DSL 意味着领域特定语言。Kotlin 中构建的 DSL 示例包括 Kotlin Gradle DSL。

^(4) 您可以使用 this::button.isInitialized 检查 latenit button 属性是否已初始化。依赖开发人员在所有正确的位置添加此检查并不能解决潜在问题。

^(5) 至少在 Kotlin 1.5.20 版中。截至我们撰写这些文字时,Jetbrains 正在考虑向语言添加包私有可见性修饰符。

^(6) 模块是一组一起编译的 Kotlin 文件。

第二章:Kotlin 集合框架

在前一章中,我们提供了 Kotlin 语言语法的概述。与任何语言一样,语法是基础,但真正重要的是实际工作时它只是个基础。要完成工作,单靠语法是不够的。为此,你需要一些易于组装成有用代码的表达式和习惯用语,并且其他开发者也能容易理解和修改它们。

几乎每种现代语言的一个重要方面就是它的 集合框架:即对象的分组方式,以及操作它们的函数库。

在引入时,Java 的集合框架是当时的最先进技术。如今,超过 20 年后,新语言提供的基本数据结构并没有太大改变。我们熟悉的所有容器(来自 Java 框架,甚至是最早版本的 C++ stdlib)仍然存在:IterableCollectionListSetMap(使用它们的 Java 名称)。然而,面对广泛接受的函数式编程风格,现代语言的集合框架如 Swift 和 Scala 通常提供了一组通用的高阶函数来操作集合:filtermapflatmapzip 等等。事实上,你会在 Kotlin 标准库的集合框架中找到这些函数。

在本章中,我们首先会介绍集合本身以及 Kotlin 语言赋予的一些有趣扩展。之后,我们会深入探讨一些在集合上运行的强大高阶函数。

集合基础知识

Kotlin 的集合框架将 Java 集合框架的数据结构作为一个子集嵌入其中。它用一些新特性包装了基本的 Java 类,并添加了对它们进行的功能性转换。

让我们从对数据结构本身的扩展快速深入到这个集合库。

Java 互操作性

由于与 Java 的无缝互操作性是 Kotlin 语言的一个核心目标,Kotlin 的集合数据类型基于其 Java 对应物。图表 2-1 说明了它们的关系。

Kotlin 集合

图表 2-1. Kotlin 集合类型层次结构及其与 Java 的关系。

Kotlin 将其集合类型作为 Java 类型的子类型,以保留 Java 集合框架的所有功能。大多数情况下,Kotlin 是在扩展 Java 框架,而非改变它。它只是添加了一些新的功能性方法。

有一个显著的例外:可变性。

可变性

或许,一种将可变性嵌入语法的语言也会将可变性嵌入到其集合系统中,这是合乎逻辑的。

Kotlin 在其集合框架中定义了两个不同的类型层次结构,一个用于可变集合,一个用于不可变集合。这可以在例子 2-1 中看到。

例 2-1. 可变和不可变列表
val mutableList = mutableListOf(1, 2, 4, 5)
val immutableList = listOf(1, 2, 4, 5)
mutableList.add(4)    // compiles

// doesn't compile: ImmutableList has no `add` method.
immutableList.add(2)
注意

可变不可变的相反。可变对象可以更改,而不可变对象则不能。在尝试优化代码时,这种区别非常关键。由于不可变对象无法更改,可以在多个线程之间安全共享它们。然而,如果要共享可变对象,则必须显式地保证线程安全性。线程安全性需要锁定或复制,这可能很昂贵。

不幸的是,Kotlin 无法保证其不可变集合的不可变性。不可变集合简单地没有修改器函数(addremoveput等)。特别是当将 Kotlin 集合传递给 Java 代码时——在那里,Kotlin 的不可变性约束不受类型系统强制——无法保证集合的内容不会发生变化。

注意,集合的可变性与集合包含的对象的可变性无关。作为一个非常简单的例子,考虑以下代码:

val deeplist = listOf(mutableListOf(1, 2), mutableListOf(3, 4))

// Does not compile: "Unresolved reference: add"
deeplist.add(listOf(3))

deeplist[1][1] = 5      // works
deeplist[1].add(6)      // works

变量deeplist是一个List<MutableList<Int>>。它始终是两个列表的列表。然而,deeplist包含的列表的内容可以增长、缩小和更改。

Kotlin 的创建者正在积极研究所有不可变事物。原型kotlinx.collections.immutable库旨在成为一组真正的不可变集合。要在您自己的 Android/Kotlin 项目中使用它们,请将以下依赖项添加到您的build.gradle文件中:

implementation \
'org.jetbrains.kotlinx:kotlinx-collections-immutable:$IC_VERSION'

虽然Kotlinx 不可变集合库使用先进的算法并对其进行优化,以使其比其他 JVM 不可变集合实现快得多,但这些真正的不可变集合仍然比其可变模拟慢一个数量级。目前还没有解决办法。然而,许多现代开发者愿意为不可变性带来的安全性牺牲一些性能,特别是在并发环境下。^(1)

过载运算符

Kotlin 支持有纪律的能力来重载特定中缀运算符的含义,特别是+-。Kotlin 的集合框架充分利用了这种能力。为了演示,让我们看一个将List<Int>转换为List<Double>的函数的天真实现:

fun naiveConversion(intList: List<Int>): List<Double> {
    var ints = intList
    var doubles = listOf<Double>()
    while (!ints.isEmpty()) {
        val item = ints[0]
        ints = ints - item
        doubles = doubles + item.toDouble()
    }
    return doubles
}

不要这样做。这个例子唯一高效的地方是演示两个中缀运算符+-的使用。前者向列表添加元素,而后者从中删除元素。

+- 运算符左侧的操作数可以定义该运算符的行为。当容器出现在 +- 的左侧时,定义了这两个运算符的两种实现:一种是右操作数是另一个容器时的实现,另一种是右操作数不是容器时的实现。

将非容器对象添加到容器中会创建一个新的容器,其中包含左操作数(容器)的所有元素以及添加的新元素(右操作数)。将两个容器相加会创建一个新的容器,其中包含两者的所有元素。

类似地,从容器中减去一个对象会创建一个新的容器,其中除了左操作数的第一次出现外,其他所有左操作数的元素都包含在内。从另一个容器中减去一个容器会创建一个新的容器,其中包含左操作数的元素,并移除右操作数中所有元素的所有出现。

注意

+- 运算符在基础容器有序时保留顺序。例如:

(listOf(1, 2) + 3)
    .equals(listOf(1, 2, 3))    // true
(listOf(1, 2) + listOf(3, 4))
    .equals(listOf(1, 2, 3, 4)) // true

创建容器

Kotlin 没有一种方式可以表示容器字面值。例如,没有一种语法方式可以创建包含数字 8、9 和 54 的 List,也没有一种方式可以创建包含字符串 "Dudley" 和 "Mather" 的 Set。相反,有方便的方法可以创建几乎同样优雅的容器。示例 2-1 中的代码展示了创建列表的两个简单示例。还有用于创建可变和不可变列表、集合和映射的 ...Of 方法。

创建文字映射需要了解一个巧妙的技巧。mapOf 函数以 Pairs 列表作为其参数。每对提供一个键(对的第一个值)和一个值(对的第二个值)。回想一下,Kotlin 支持一组扩展的中缀运算符。其中之一是 to,它创建一个新的 Pair,其左操作数作为第一个元素,右操作数作为第二个元素。结合这两个特性,您可以方便地构建这样的 Map

val map = mapOf(1 to 2, 4 to 5)

容器的内容类型使用类似于 Java 的泛型语法表示。例如,前面代码中变量 map 的类型是 Map<Int, Int>,这是一个将 Int 键映射到其 Int 值的容器。

Kotlin 编译器在推断使用其工厂方法创建的容器内容类型时非常聪明。显然,在此示例中:

val map = mutableMapOf("Earth" to 3, "Venus" to 4)

map 的类型是 MutableMap<String, Int>。但是这又如何?

val list = listOf(1L, 3.14)

Kotlin 将选择类型层次树中最接近的类型作为容器元素的所有元素的祖先(此类型称为上界类型)。在这种情况下,它将选择 Number,即 LongDouble 的最近祖先。变量 list 具有推断类型 List<Number>

我们可以添加 String,如下所示:

val list = mutablelistOf(1L, 3.14, "e")

所有元素的祖先唯一类型是 LongDoubleString 的根,即 Kotlin 类型层次结构的 Any。变量 list 的类型是 MutableList<Any>

再次强调,不过,请从 第一章 回忆,类型 Any 与类型 Any? 并不相同。以下内容将不会编译(假设前面示例的定义):

list.add(null)  // Error: Null cannot be a value of a non-null type Any

为了允许列表包含null,我们必须明确指定其类型:

val list: MutableList<Any?> = mutablelistOf(1L, 3.14, "e")

我们现在可以创建集合了。那么,我们应该如何处理它们呢?

函数式编程

我们对它们进行操作!我们将在这里讨论的几乎所有操作都基于函数式编程的范式。为了理解它们的背景和动机,让我们回顾一下这种范式。

面向对象编程(OOP)和 函数式编程(FP)都是软件设计的范式。软件架构师们在函数式编程刚刚发明后很快就理解了它的潜力。早期的函数式程序往往速度较慢,但直到最近,函数式风格才能够挑战更为实用的命令式模型以获取更好的性能。随着程序变得更加复杂和难以理解,随着并发变得不可避免,以及编译器优化的改善,函数式编程正在从一种可爱的学术玩具变成每个开发人员都应该掌握的有用工具。

函数式编程鼓励不可变性。与代码中的函数不同,数学函数不会改变事物。它们不会“返回”任何东西。它们只有一个值。就像“4”和“2 + 2”是同一个数字的名称一样,给定参数评估的给定函数只是其值的名称(也许是冗长的名称!)。因为数学函数不会改变,所以它们不受时间的影响。在并发环境中工作时,这非常有用。

尽管不同,FP 和 OOP 范式可以共存。Java 显然是作为面向对象语言设计的,而完全可互操作的 Kotlin 几乎可以逐字复制 Java 算法。正如我们在前一章中所宣称的,然而,Kotlin 的真正力量在于其可扩展的函数式编程能力。从事“用 Kotlin 编写 Java”并非罕见。随着他们变得更加舒适,他们倾向于更具代表性的 Kotlin,其中很大一部分涉及应用 FP 的能力。

函数式与过程式:一个简单的例子

以下代码展示了处理集合的过程式方式:

fun forAll() {
    for (x in collection) { doSomething(x) }
}

在示例中,for 循环遍历列表。它从 collection 中选择一个元素,并将其分配给变量 x。然后调用元素的 doSomething 方法。它会为列表中的每个元素执行此操作。

集合的唯一约束是必须有一种方法能确保每个元素只被获取一次。这种能力正是由类型 Iterable<T> 封装的。

功能性编程范式肯定更简单:没有额外的变量和特殊的语法。只需一个单一的方法调用:

fun forAll() = collection.forEach(::doSomething)

forEach 方法将一个函数作为其参数。在本例中,该参数 doSomething 是一个接受 collection 中包含的类型的单个参数的函数。换句话说,如果 collection 是一个 String 列表,doSomething 必须是 doSomething(s: String)。如果 collectionSet<Freeptootsie>,那么 doSomething 必须是 doSomething(ft: Freeptootsie)forEach 方法调用其参数 (doSomething),并将集合中的每个元素作为其参数。

这似乎是一个微不足道的区别。它并不是。forEach 方法在关注点分离方面要好得多。

Iterable<T> 是有状态的,有序的,并且时间相关的。任何曾经遇到过 ConcurrentModificationException 的人都知道,迭代器的状态可能与其正在迭代的集合的状态不匹配。尽管 Kotlin 的 forEach 操作符并非完全免疫于 ConcurrentModificationException,但这些异常发生在实际并发的代码中。

更重要的是,集合用于将传递的函数应用于其每个元素的机制完全是集合自身的事务。特别是,关于函数在集合元素上评估的顺序没有固有的约定。

例如,一个集合可以将其元素分成组。它可以将每个这些组分配给一个单独的处理器,然后重新组合结果。在处理器内核数量迅速增加的时代,这种方法特别有趣。Iterator<T> 协议无法支持这种并行执行。

功能性安卓

安卓在功能性编程方面有一个古怪的历史。因为其虚拟机与 Java 没有关系,所以 Java 语言的改进并不一定对安卓开发者可用。Java 中一些最重要的变化,包括 lambda 表达式和方法引用,出现在 Java 8 后,在一段时间内并未得到安卓的支持。

尽管 Java 可以编译这些新特性,并且 DEX(安卓的字节码)甚至可以表示它们(尽管可能不是高效地),但是安卓工具链不能将这些特性的表示形式——编译后的 Java 字节码——转换为可以在安卓系统上运行的 DEX 代码。

填补这一空白的第一次尝试是一个名为 RetroLambda 的包。随后出现了其他附加库解决方案,有时带有令人困惑的规则(例如,使用 Android Gradle 插件 [AGP] 3.0+,如果想使用 Java Streams API,至少必须定位到 Android API 24)。

现在,使用 Kotlin 在 Android 上已经没有这些限制了。AGP 的最新版本将支持功能编程,即使在较旧版本的 Android 上也是如此。您现在可以在任何支持的平台上使用完整的 Kotlin 集合包。

Kotlin 转换函数

在本节中,您将看到 Kotlin 如何为集合带来函数式能力,以提供优雅且安全的方式来操作它们。就像在前一章中我们没有访问 Kotlin 的所有语法一样,在本章中我们也不会尝试访问 Kotlin 的所有库函数。并不需要记住它们所有。然而,要想用 Kotlin 来写出符合习惯且高效的代码,掌握几个关键的转换并理解它们的工作方式是必要的。

布尔函数

一组方便的集合函数返回一个 Boolean 来指示集合是否具有或不具有给定的属性。例如,函数 any() 在集合包含至少一个元素时将返回 true。如果与谓词一起使用,如 any { predicate(it) }any 将在集合中任何元素的谓词评估为 true 时返回 true

val nums = listOf(10, 20, 100, 5)
val isAny = nums.any()                 // true
val isAnyOdd = nums.any { it % 1 > 0 } // true
val isAnyBig = nums.any { it > 1000}   // false
注意

当 lambda 只接受单个参数且 Kotlin 编译器可以使用类型推断来确定它(通常情况下可以),您可以省略参数声明并使用隐式参数 it。在 any 方法的谓词定义中,上述示例两次使用了这个快捷方式。

另一个布尔函数 all { predicate } 仅在列表中的每个元素都与谓词匹配时返回 true

val nums = listOf(10, 20, 100, 5)
val isAny = nums.all { it % 1 > 0 } // false

any 的相反函数是 none。没有谓词时,none() 仅在集合中没有元素时返回 true。有谓词时,none { predicate } 仅在谓词对集合中的任何元素都不成立时返回 true。例如:

val nums = listOf(10, 20, 100, 5)
val isAny = nums.none()              // false
val isAny4 = nums.none { it == 4 }   // true

过滤函数

基本的 filter 函数将返回一个包含原始集合中与给定谓词匹配的元素的新集合。例如,在这个示例中,变量 numbers 将包含一个只有单个值 100 的列表:

val nums = listOf(10, 20, 100, 5)
val numbers = nums.filter { it > 20 }

filterNot 函数则是相反的。它返回不匹配谓词的元素。例如,在这个示例中,变量 numbers 将包含三个元素 10、20 和 5:即 nums 中不大于 20 的元素:

val nums = listOf(10, 20, 100, 5)
val numbers = nums.filterNot { it > 20 }

filterNot 的一个极其方便的特例是函数 filterNotNull。它从集合中删除所有的 null

val nums = listOf(null, 20, null, 5)
val numbers = nums.filterNotNull() // { 20, 5 }

在这个示例中,变量 numbers 将是一个包含两个元素 20 和 5 的列表。

Map

map 函数将其参数应用于集合中的每个元素,并返回结果值的集合。请注意,它不会改变所应用的集合;它返回一个新的结果集合。

这里是 map 函数在 Array 类型上的定义:

inline fun <T, R> Array<out T>.map(transform: (T) -> R): List<R>

让我们来理解这一点。

从左边开始,map 是一个内联函数。现在,“fun”部分应该很清楚了。但是“inline”呢。

关键字 inline 告诉 Kotlin 编译器,在每次调用该方法时直接将函数的字节码复制到二进制文件中,而不是生成一个传输到单个编译版本的转移。当调用函数所需的指令数量占总运行所需指令数量的大部分时,使用 inline 函数作为空间换时间的权衡是有意义的。有时候,它还可以消除一些 lambda 表达式需要的额外对象分配的开销。

接下来是 <T, R>,这是函数定义中使用的两个自由类型变量。我们稍后会回到它们。

接下来是接收者的描述,Array<out T>。这个 map 函数是 Array 类型的扩展函数:它是一个作用在类型为 T 的数组上的函数(或者 T 的超类,比如 Any)。

接下来是 map 的参数。该参数是一个名为 transform 的函数。Transform 是一个函数 transform: (T) -> R:它以 T 类型的参数作为其参数,并返回 R 类型的结果。哦!这很有趣!函数将应用于数组中的元素,这些元素的类型是 T

最后,map 的返回值是 List<R>,一个元素类型为 R 的列表。如果将 transform 应用于数组元素(一个 T),则会得到 R

一切都很顺利。在数组上调用 map 函数,该函数可以应用于数组的元素,将返回一个包含应用函数结果的新 List

这里有一个示例,返回的是员工记录开始日期的列表,这些开始日期存储为字符串:

data class Hire(
    val name: String,
    val position: String,
    val startDate: String
)

fun List<Hire>.getStartDates(): List<Date> {
    val formatter
        = SimpleDateFormat("yyyy-MM-d", Locale.getDefault())
    return map {
        try {
            formatter.parse(it.startDate)
        } catch (e: Exception) {
            Log.d(
                "getStartDates",
                "Unable to format first date. $e")
            Date()
        }
    }
}

或许你正在想:“如果转换函数不返回值会发生什么?” 啊!但 Kotlin 函数总是有一个值!

例如:

val doubles: List<Double?> = listOf(1.0, 2.0, 3.0, null, 5.0)
val squares: List<Double?> = doubles.map { it?.pow(2) }

在这个例子中,变量 squares 将是列表 [1.0, 4.0, 9.0, null, 25.0]。由于转换函数中的条件运算符 ?.,如果参数不为空,则函数的值为其参数的平方。然而,如果参数为空,则函数的值为 null

Kotlin 库中 map 函数有几种变体。其中一种是 mapNotNull,解决了这样的情况:

val doubles: List<Double?> = listOf(1.0, 2.0, 3.0, null, 5.0)
val squares: List<Double?> = doubles.mapNotNull { it?.pow(2) }

此示例中变量 squares 的值是 [1.0, 4.0, 9.0, 25.0]。

map 的另一个变体是 mapIndexedmapIndexed 也接受一个函数作为其参数。不过,不同于 mapmapIndexed 的函数参数将集合的元素作为其第二个参数(而不是其第一个唯一参数,如 map 的参数所做)。mapIndexed 的函数参数以 Int 作为其第一个参数。这个 Int 是序数,表示集合中元素的位置:第一个元素为 0,第二个为 1,依此类推。

对于大多数类似集合的对象,都有映射函数。甚至对于 Map(虽然它们不是 Collection 的子类型),也有类似的函数:函数 Map::mapKeysMap::mapValues

flatMap

使 flatMap 函数难以理解的是,它可能看起来抽象而且并不特别有用。但事实证明,尽管它抽象,它非常有用。

让我们用一个类比来开始。假设你决定联系你旧高中辩论队的成员。你不知道如何再联系他们了。但你记得,你有所有四年的年鉴,每年鉴上都有辩论队的照片。

你决定将联系成员的过程分为两步。首先,你将检查团队每张照片,并尝试识别出每个人。你将制作一个你识别到的人的名单。然后,你将这四个列表合并成一个辩论队所有成员的单一列表。

这就是 flatmapping!它与容器有关。让我们概括一下。

假设你有某种容器的容器。它是 CON<T>。在年鉴示例中,CON<T> 是四张照片,一个 Set<Photo>。接下来你有一个函数,将 T -> KON<R> 映射。也就是说,它接受 CON 的一个元素并将其转换为新类型的容器 KON,其元素类型为 R。在例子中,这是你识别每张照片中的每个人,并生成一个人名列表。KON 是一张名单,R 是一个人的名字。

flatMap 函数在示例中的结果是名字的汇总列表。

CON<T> 的 flatmap 是这个函数:

fun <T, R> CON<T>.flatMap(transform: (T) -> KON<R>): KON<R>

注意,为了比较,flatMap 如何与 map 不同。对于容器 CON,使用相同的转换函数,map 函数的签名如下:

fun <T, R> CON<T>.map(transform: (T) -> KON<R>): CON<KON<R>>

flatMap 函数将一个容器“展平”。

当我们谈论这个主题时,让我们来看一个非常常见的使用 flatMap 的例子:

val list: List<List<Int>> = listOf(listOf(1, 2, 3, 4), listOf(5, 6))
val flatList: List<Int> = list.flatMap { it }

变量 flatList 将具有值 [1, 2, 3, 4, 5, 6]。

这个例子可能会让人困惑。不同于前面的例子,它将一组照片转换为名单,并将这些名单汇总在一起,在这个常见的例子中,两种容器类型 CONKON 是相同的:它们都是 List<Int>。这可能会让你难以理解实际发生了什么。

为了证明它确实有效,让我们通过将这个有些令人困惑的例子中的数量绑定到函数描述中的类型来进行练习。该函数应用于List<List<Int>>,因此T必须是List<Int>。转换函数是恒等函数。换句话说,它是(List<Int>) -> List<Int>:它返回其参数。这意味着KON<R>也必须是List<Int>,而R必须是Int。然后,flatMap函数将返回一个KON<R>,即List<Int>

它有效。

分组

除了过滤外,Kotlin 标准库还提供了另一组小型转换扩展函数,用于对集合的元素进行分组。例如,groupBy函数的签名如下:

inline fun <T, K> Array<out T>
    .groupBy(keySelector: (T) -> K): Map<K, List<T>>

通常情况下,你可以通过查看类型信息直觉地理解这个函数的行为。groupBy是一个函数,它接受一个Array(在本例中是Array:其他容器类型有对应的函数)。对于每个元素,它应用keySelector方法。那个方法以某种方式用类型K的值标记这个元素。groupBy方法的返回值是一个映射,将每个标签映射到keySelector为其分配的元素列表。

一个例子将有所帮助:

val numbers = listOf(1, 20, 18, 37, 2)
val groupedNumbers = numbers.groupBy {
    when {
        it < 20 -> "less than 20"
        else -> "greater than or equal to 20"
    }
}

变量groupedNumbers现在包含一个Map<String, List<Int>>。该映射有两个键,“小于 20”和“大于或等于 20”。第一个键的值是列表[1, 18, 2]。第二个键的值是[20, 37]

从分组函数生成的映射将保留原始集合中元素的顺序,即输出映射键的值列表。

迭代器与序列

假设你要给你的书桌涂漆。你决定如果它是一种漂亮的棕色而不是那种普通的浅棕色,它看起来会更好。你去油漆店,发现大约有 57 种颜色可能正合你心意。

接下来你做什么?你买每一种颜色的样本回家吗?几乎可以肯定不是!相反,你买两三种看起来有希望的颜色并尝试它们。如果它们不是你心中所想,你就回到商店再买三种。你不是买所有候选颜色的样本并对它们进行迭代,而是创建一个过程,让你能够获取下一个候选颜色,考虑到你已经尝试过的颜色。

序列与迭代器的区别类似。迭代器是从现有集合中精确获取每个元素的一种方式。集合已经存在。迭代器只需要对其进行排序。

另一方面,序列并不一定由集合支持。序列由 生成器 支持。生成器是一个函数,将提供序列中的下一个项。在这个例子中,如果你需要更多的油漆样本,你有办法得到它们:你回到商店买更多。你不必买下它们并迭代所有。你只需购买几个,因为你知道如何获得更多。你可以在找到正确的颜色之前停下来,带来好运的话,这可能发生在你支付所有可能颜色样本之前。

在 Kotlin 中,你可能会这样表达对桌子油漆的搜索:

val deskColor = generateSequence("burnt umber") {
    buyAnotherPaintSample(it)
}.first { looksGreat(it) }

println("Start painting with ${deskColor}!")

这个算法很有效。平均而言,使用它的桌子油漆工只会购买 28 个油漆样本,而不是 57 个。

因为序列是惰性的——只在需要时生成下一个元素——所以它们在优化操作中非常有用,甚至在固定内容的集合上也是如此。例如,假设你有一个 URL 列表,想知道哪个是指向包含猫图片页面的链接。你可以这样做:

val catPage = listOf(
    "http://ragdollies.com",
    "http://dogs.com",
    "http://moredogs.com")
    .map { fetchPage(it) }
    .first { hasCat(it) }

那个算法将下载所有页面。如果你用序列做同样的事情:

val catPage = sequenceOf(
    "http://ragdollies.com",
    "http://dogs.com",
    "http://moredogs.com")
    .map { fetchPage(it) }
    .first { hasCat(it) }

只会下载第一页。顺序将提供第一个网址,map 函数将获取它,并且first 函数会被满足。不会下载其他页面。

但要小心!不要请求无限集合的所有元素!例如,这段代码最终会产生 OutOfMemory 错误:

val nums = generateSequence(1) { it + 1 }
    .map { it * 7 }                 // that's fine
    .filter { it mod 10000 = 0 }    // still ok
    .asList()                       // FAIL!

一个例子

让我们通过一个例子来具体化这一切。

我们刚刚接触了 Kotlin 标准库提供的几个方便的函数,用于操作集合。使用这些函数,你可以创建复杂逻辑的健壮实现。为了说明这一点,我们将以飞机发动机工厂中使用的真实应用为例。

问题

Bandalorium 公司制造飞机发动机。每个发动机部件由其序列号唯一标识。每个零件都要经过严格的质量控制过程,记录其几个关键属性的数值测量。

引擎部件的属性是任何可测量的特征。例如,一根管子的外径可能是一个属性。某些电线的电阻可能是另一个。第三个可能是零件反射特定颜色光的能力。唯一的要求是测量属性必须产生单一的数值。

Bandalorium 想要跟踪的其中一件事是其生产过程的精度。它需要追踪其生产的零件的测量值以及它们是否随时间变化。

挑战在于:

给定某个时间段(比如三个月)内生产的零件属性测量列表,创建类似于 图 2-2 中所示的 CSV(逗号分隔值)报告。如图所示,报告应按测量时间排序。

pawk 0202

图 2-2. CSV 输出示例。

如果我们可以提出一个建议——现在是将这本书放一边一会儿,考虑一下你如何解决这个问题的好时机。也许只需勾勒出足够高级的代码来确信你能够解决它。

实现

在 Kotlin 中,我们可能像这样表示一个属性:

data class Attr(val name: String, val tolerance: Tolerance)

enum class Tolerance {
    CRITICAL,
    IMPORTANT,
    REGULAR
}

名称是属性的唯一标识符。属性的公差表示属性对最终产品质量的重要性:关键、重要或普通。

每个属性可能还有许多其他相关信息。毫无疑问,还有一个测量单位(厘米、焦耳等)、其可接受值的描述,以及可能用于测量的程序。在此示例中,我们将忽略这些特性。

特定引擎零件的属性测量包括以下内容:

  • 零件的序列号

  • 时间戳显示测量时间

  • 测量值

例如,一个测量可以在 Kotlin 中建模如下:

data class Point(
    val serial: String,
    val date: LocalDateTime,
    val value: Double)

最后,我们需要一种方法将测量与其所测量的属性关联起来。我们像这样建模这种关系:

data class TimeSeries(val points: List<Point>, val attr: Attr)

TimeSeries 将一系列测量与它们所测量的 Attr 相关联。

首先,我们构建 CSV 文件的头部:第一行的列标题(参见 示例 2-2)。前两列名为 dateserial。数据集中的其他列名是属性的不同名称。

示例 2-2. 制作标题
fun createCsv(timeSeries: List<TimeSeries>): String {
    val distinctAttrs = timeSeries
        .distinctBy { it.attr } ![1](https://github.com/OpenDocCN/ibooker-mobi-zh/raw/master/docs/prog-andr-kt/img/1.png)
        .map { it.attr }        ![2](https://github.com/OpenDocCN/ibooker-mobi-zh/raw/master/docs/prog-andr-kt/img/2.png)
        .sortedBy { it.name }   ![3](https://github.com/OpenDocCN/ibooker-mobi-zh/raw/master/docs/prog-andr-kt/img/3.png)

    val csvHeader = "date;serial;" +
        distinctAttrs.joinToString(";") { it.name } +
        "\n"

    /* Code removed for brevity */
}

1

使用 distinctBy 函数获取具有不同 attr 属性值的 TimeSeries 实例列表。

2

我们有了上一步的不同 TimeSeries 的列表,现在只需要 attr,因此我们使用 map 函数。

3

最后,我们使用 sortedBy 进行字母排序。虽然这不是必需的,但为什么不呢?

现在我们有了不同特性的列表,使用 joinToString 函数进行头部格式化非常简单。该函数通过指定字符串分隔符将列表转换为字符串。如果需要,甚至可以指定前缀和/或后缀。

注意

在收集转换函数的返回类型中找到类型是很有用的。例如,在 示例 2-2 中,如果激活了类型提示,你将只得到整个链条的推断类型(变量 distinctAttrs 的类型)。这是一个很好的 IntelliJ/Android Studio 功能,可以帮助你!

  1. 在源代码中点击 distinctCharacs

  2. 按 Ctrl + Shift + P 键。会看到一个下拉窗口出现。

    pawk 0203

  3. 选择你想要的步骤,类型会在你眼前出现!

    pawk 0204

构建标题后,我们构建 CSV 文件的内容。这是最技术性和最有趣的部分。

我们试图重现的 CSV 文件的其余部分按日期对数据进行排序。对于每个给定的日期,它会提供一个零件的序列号,然后是该零件在每个感兴趣属性上的测量值。这需要一些思考,因为在我们创建的模型中,这些东西并不直接相关。一个TimeSeries仅包含单个属性的数据,而我们需要多个属性的数据。

在这种情况下的常见方法是将输入数据合并和展平为更方便的数据结构,如示例 2-3 所示。

示例 2-3. 合并和展平数据
fun createCsv(timeSeries: List<TimeSeries>): String {
    /* Code removed for brevity */

    data class PointWithAttr(val point: Point, val attr: Attr)

    // First merge and flatten so we can work with a list of PointWithAttr
    val pointsWithAttrs = timeSeries.flatMap { ts ->
        ts.points.map { point -> PointWithAttr(point, ts.attr) }

   /* Code removed for brevity */
}

在此步骤中,我们将每个Point与其对应的Attr关联到一个单独的PointAndAttr对象中。这类似于在 SQL 中连接两个表。

flatMap函数将转换TimeSeries对象列表。在内部,flatMap应用的函数使用map函数,series.points.map { ... },为每个TimeSeries中的点创建一个PointAndAttr列表。如果我们使用map而不是flatMap,我们将产生一个List<List<PointAndAttr>>。不过,请记住,flatMap会展平容器的顶层,因此这里的结果是一个List<PointAndAttr>

现在我们已经将属性信息“传播”到每个Point中,创建 CSV 文件就变得非常简单了。

我们将按日期将pointWithAttrs列表分组,以创建一个Map<LocalDate, List<PointWithAttr>>。这个映射将包含每个日期的pointWithAttrs列表。由于示例似乎有二次排序(按零件序列号),我们将在先前分组的Map中的每个列表按序列号分组。剩下的就是字符串格式化,如示例 2-4 所示。

示例 2-4. 创建数据行
fun createCsv(timeSeries: List<TimeSeries>): String {
    /* Code removed for brevity */

    val rows = importantPointsWithAttrs.groupBy { it.point.date }  ![1](https://github.com/OpenDocCN/ibooker-mobi-zh/raw/master/docs/prog-andr-kt/img/1.png)
    .toSortedMap()                                     ![2](https://github.com/OpenDocCN/ibooker-mobi-zh/raw/master/docs/prog-andr-kt/img/2.png)
    .map { (date, ptsWithAttrs1) ->
        ptsWithAttrs1
            .groupBy { it.point.serial }             ![3](https://github.com/OpenDocCN/ibooker-mobi-zh/raw/master/docs/prog-andr-kt/img/3.png)
            .map { (serial, ptsWithAttrs2) ->
                listOf(                                        ![4](https://github.com/OpenDocCN/ibooker-mobi-zh/raw/master/docs/prog-andr-kt/img/4.png)
                    date.format(DateTimeFormatter.ISO_LOCAL_DATE),
                    serial
                ) + distinctAttrs.map { attr ->
                    val value = ptsWithAttrs2.firstOrNull { it.attr == attr }
                    value?.point?.value?.toString() ?: ""
                }
            }.joinToString(separator = "") {        ![5](https://github.com/OpenDocCN/ibooker-mobi-zh/raw/master/docs/prog-andr-kt/img/5.png)
                it.joinToString(separator = ";", postfix = "\n")
            }
    }.joinToString(separator = "")

    return csvHeader + rows                               ![6](https://github.com/OpenDocCN/ibooker-mobi-zh/raw/master/docs/prog-andr-kt/img/6.png)
}

1

按日期分组,使用groupBy函数。

2

对映射(按日期)进行排序。这不是强制性的,但排序的 CSV 更易于阅读。

3

按序列号分组。

4

构建每行的值列表。

5

使用joinToString函数格式化每一行并组装所有这些行。

6

最后,将标题和行作为单个String返回。

现在,假设您收到额外的请求,只报告CRITICALIMPORTANT的属性。您只需使用filter函数,如示例 2-5 所示。

示例 2-5. 过滤关键和重要样本
fun createCsv(timeSeries: List<TimeSeries>): String {
    /* Code removed for brevity */

    val pointsWithAttrs2 = timeSeries.filter {
        it.attr.tolerance == Tolerance.CRITICAL
                || it.attr.tolerance == Tolerance.IMPORTANT
    }.map { series ->
        series.points.map { point ->
            PointWithAttr(point, series.attr)
        }
    }.flatten()

    /* Code removed for brevity */

    return csvHeader + rows
}

就这样!

要测试那段代码,我们可以使用预定义的输入并检查输出是否符合你的期望。我们这里不会展示完整的单元测试集合,只是展示了 CSV 输出的示例,如示例 2-6 所示。

示例 2-6. 展示应用程序
fun main() {
    val dates = listOf<LocalDateTime>(
        LocalDateTime.parse("2020-07-27T15:15:00"),
        LocalDateTime.parse("2020-07-27T15:25:00"),
        LocalDateTime.parse("2020-07-27T15:35:00"),
        LocalDateTime.parse("2020-07-27T15:45:00")
    )
    val seriesExample = listOf(
        TimeSeries(
            points = listOf(
                Point("HC11", dates[3], 15.1),
                Point("HC12", dates[2], 15.05),
                Point("HC13", dates[1], 15.11),
                Point("HC14", dates[0], 15.08)
            ),
            attr = Attr("AngleOfAttack", Tolerance.CRITICAL)
        ),
        TimeSeries(
            points = listOf(
                Point("HC11", dates[3], 0.68),
                Point("HC12", dates[2], 0.7),
                Point("HC13", dates[1], 0.69),
                Point("HC14", dates[0], 0.71)
            ),
            attr = Attr("ChordLength", Tolerance.IMPORTANT)
        ),
        TimeSeries(
            points = listOf(
                Point("HC11", dates[3], 0x2196F3.toDouble()),
                Point("HC14", dates[0], 0x795548.toDouble())
            ),
            attr = Attr("PaintColor", Tolerance.REGULAR)
        )
    )
    val csv = createCsv(seriesExample)
    println(csv)
}

如果你将csv字符串作为一个带有“.csv”扩展名的文件的内容,你可以使用你喜爱的电子表格工具打开它。图 2-3 展示了我们使用 FreeOffice 得到的内容。

pawk 0202

图 2-3. 最终输出。

使用函数式编程转换数据,就像这个例子中一样,特别强大。为什么?通过结合 Kotlin 的空安全性和标准库的函数,你可以生成几乎没有或没有副作用的代码。添加任何你可以想象的PointWithAttr列表。如果有一个Point实例有一个null值,代码甚至不会编译。每当转换的结果返回一个可能为空的结果时,语言都会强制你考虑到这种情况。在这里,我们在第 4 步中使用了firstOrNull函数。

当你的代码在第一次尝试时编译并确切地执行你的期望时,总是让人兴奋。利用 Kotlin 的空安全性和函数式编程,这种情况经常发生。

摘要

作为一种函数式语言,Kotlin 采用了诸如映射、合并和其他函数式转换的伟大思想。它甚至允许你利用高阶函数和 Lambda 创建自己的数据转换:

  • Kotlin 集合包括整个 Java 集合 API。此外,该库还提供了所有常见的函数式转换,如映射、过滤、分组等。

  • Kotlin 支持内联函数以实现更高性能的数据转换。

  • Kotlin 集合库支持序列,这是一种通过意图而不是扩展定义的处理集合的方式。当获取下一个元素非常昂贵,甚至在大小不受限制的集合上时,序列是合适的选择。

如果你曾经使用过像 Ruby、Scala 或 Python 这样的语言,也许这些内容对你来说并不陌生。应该是的!Kotlin 的设计基于驱动这些语言开发的许多相同原则。

以更加函数式的方式编写你的 Android 代码就像使用 Kotlin 标准库提供的数据转换操作一样简单。现在你已经熟悉了 Kotlin 语法和 Kotlin 中函数式编程的精神,接下来的章节将专注于 Android 操作系统和其他编程基础知识。Android 开发在 2017 年转向 Kotlin 作为官方语言,因此 Kotlin 在近年来已经对 Android 的发展产生了重大影响,未来也将继续如此。

^(1) Roman Elizarov;关于 Kotlin Collections Immutable Library 的电子邮件访谈。2020 年 10 月 8 日。

第三章:Android 基础知识

本书的前两章是对 Kotlin 语言的快速回顾。本章将审视我们将使用 Kotlin 的环境:Android。

Android 是一个像 Windows 和 MacOS 一样的操作系统。但与这两个系统不同的是,Android 是基于 Linux 的操作系统,就像 Ubuntu 和 Red Hat 一样。不过,与 Ubuntu 和 Red Hat 不同的是,Android 已经针对移动设备进行了非常大量的优化,特别是针对使用电池供电的移动设备。

这些优化中最重要的一项是什么意味着成为一个应用程序。特别是正如我们将看到的那样,Android 应用程序与传统的桌面应用程序相比,与 Web 应用程序有更多的共同点。

但我们稍后会详细讨论这个问题。首先,让我们更详细地看一下 Android 环境。我们将操作系统视为一个堆栈——一种层层叠加的结构。

Android 堆栈

图 3-1 展示了看待 Android 的一种方式:作为一堆组件的堆栈。堆栈中的每一层都有特定的任务并提供特定的服务;每一层都利用了其下层的功能。

从底部向上走,层次是:

  • 硬件

  • 内核

  • 系统服务

  • Android 运行时环境

  • 应用程序

pawk 0301

图 3-1. Android 堆栈。

硬件

当然,在 Android 堆栈下面是硬件:一块温暖的硅片。虽然硬件不是 Android 堆栈的一部分,但重要的是要认识到 Android 设计的硬件对系统施加了一些相当严格的限制。其中最重要的限制是电力。大多数常见的操作系统假定有无限的电源供应,而 Android 系统不能这样假定。

内核

Android 操作系统依赖于 Linux 内核。内核负责提供开发人员所期望的基本服务:文件系统、线程和进程、网络访问、与硬件设备的接口等。Linux 是自由开源的,因此是硬件和设备制造商的热门选择。

由于它基于 Linux,Android 与常见的 Linux 发行版(如 Debian、Centos 等)有些相似。然而,在内核以上的层次,相似性减弱了。尽管大多数常见的 Linux 发行版都严重依赖 GNU 家族的系统软件(并且应该适当地称为 GNU/Linux),但 Android 的系统软件却有很大不同。一般来说,不可能直接在 Android 系统上运行常见的 Linux 应用程序。

系统服务

系统服务层非常庞大且复杂。它包括各种实用程序,从作为内核的一部分运行的代码(驱动程序或内核模块),到长时间运行的管理各种日常任务的应用程序(守护程序),再到实现标准功能如加密和媒体呈现的库。

这一层包括几个 Android 特有的系统服务。其中包括 Binder,Android 的基本进程间通信系统;ART,作为 Android 的 Java 虚拟机的类比已取代 Dalvik;以及 Zygote,Android 的应用程序容器。

Android 运行时环境

系统服务上面的一层是 Android 运行时环境 的实现。Android 运行时环境是你通过 import 语句包含在应用程序中的库集合:android.viewandroid.os 等等。它们是底层提供的服务,供应用程序使用。这些库的有趣之处在于它们通常是用两种语言实现的:Java 和 C 或 C++。

您的应用程序导入的实现部分可能是用 Java 编写的。然而,Java 代码使用 Java Native Interface (JNI) 调用本地代码,通常是用 C 或 C++ 编写的。实际上是本地代码与系统服务进行交互。

应用程序

最后,在堆栈顶部是 Android 应用程序。在 Android 世界中,应用程序实际上是堆栈的一部分。它们由各个可寻址的组件组成,其他应用程序可以“调用”这些组件。拨号器、相机和联系人程序都是其他应用程序使用的 Android 应用程序示例。

这是 Android 应用程序执行的环境。让我们重新审视应用程序本身的解剖。

Android 应用环境

Android 应用程序是从源语言(Java 或 Kotlin)翻译成可传输的中间语言 DEX 的程序。当应用程序运行时,DEX 代码安装在设备上,并由 ART 虚拟机解释。

几乎每个开发者都熟悉标准的应用程序环境。操作系统创建一个“进程”——一种虚拟计算机,似乎完全属于该应用程序。系统在进程中运行应用程序代码,它看起来拥有自己的内存、处理器等等,完全独立于可能在同一设备上运行的其他应用程序。应用程序运行,直到它自己决定停止。

Android 并不是按照应用程序的概念工作的。例如,Android 应用程序没有类似于 Java 的 public static void main 方法,用于启动典型的 Java 应用程序。相反,Android 应用程序是组件的库。Zygote 运行时管理进程、生命周期等等。只有在需要时,它才调用应用程序的组件。这使得 Android 应用程序非常类似于 Web 应用程序:它们是部署到容器中的组件的集合。

生命周期的另一端,即终止应用程序,可能更加有趣。在其他操作系统上,突然停止应用程序(kill -9或“强制退出”)很少发生,只有在应用程序行为不当时才会发生。在 Android 上,这是终止应用程序的最常见方式。几乎每个正在运行的应用程序最终都会突然终止。

与大多数 Web 应用程序框架一样,组件实现为模板基类的子类。组件子类覆盖了框架调用的方法,以提供特定于应用程序的行为。通常情况下,当这些模板方法之一被调用时,超类有重要的工作要做。在这些情况下,子类中覆盖的方法必须调用其覆盖的超类方法。

Android 支持四种类型的组件:

  • 活动

  • 服务

  • 广播接收器

  • 内容提供者

就像在 Web 应用程序中一样,这些组件的实现必须在清单中注册:一个 XML 文件。Android 的清单文件通常称为AndroidManifest.xml。Android 容器在加载应用程序时解析此文件。应用程序组件(而不是某个总体的应用程序)是 Android 应用程序的基本单元。它们是可单独寻址的,并且可以单独发布以供其他应用程序使用。

那么,应用程序如何定位 Android 组件的目标?通过一个Intent

意图和意图过滤器

在 Android 中,组件是通过Intent启动的。Intent是一个小包,命名了它所针对的组件。它有一些额外的空间,可以指示接收组件执行的特定操作以及请求的一些参数。可以将意图视为函数调用:类的名称、类内的特定函数名称以及调用的参数。系统将意图传递给目标组件。由组件来执行请求的服务。

值得注意的是,与其组件导向的架构一致,Android 实际上没有任何启动应用程序的方式。相反,客户端启动一个组件,可能是用户刚在启动器页面上点击其图标的应用程序的主Activity。如果拥有该活动的应用程序尚未运行,则会作为副作用启动它。

一个意图可以显式地命名其目标,如此处所示:

context.startActivity(
  Intent(context, MembersListActivity::class.java)))

此代码向Activity MembersListActivity发出一个Intent。请注意,这里的调用startActivity必须与被启动的组件的类型相匹配:在这种情况下是一个Activity。还有其他类似的方法用于向其他类型的组件发送意图(例如startService用于Service等)。

此代码行触发的Intent称为显式意图,因为它指定了一个特定的、唯一的类,在一个唯一的应用程序中(由稍后讨论的Context标识),该Intent将被传递到该类。

由于它们标识了一个独特、具体的目标,显式意图比隐式意图更快速且更安全。出于安全原因,Android 系统的某些地方要求使用显式意图。即使不是必需的,尽可能也应优先选择显式意图。

在应用程序内部,可以始终使用显式意图访问组件。对于公开可见的来自其他应用程序的组件,也可以始终显式访问。那么,为什么还要使用隐式意图呢?因为隐式意图允许动态解析请求。

想象一下,您多年来在手机上使用的电子邮件应用程序允许使用外部编辑器编辑消息。我们现在可以猜测它是通过触发类似以下内容的意图来实现的:

val intent = Intent(Intent.ACTION_EDIT))
intent.setDataAndType(textToEditUri, textMimeType);
startActivityForResult(intent, reqId);

此意图中指定的目标并非显式。Intent既未指定Context,也未指定上下文内的组件的完全限定名。此意图是隐式的,Android 允许任何组件注册以处理它。

组件通过IntentFilter注册隐式意图。事实上,您刚刚安装的“出色代码编辑器”在其清单中包含如下IntentFilter,完全匹配了前述代码中显示的意图:

<manifest ...>
  <application
    android:label="@string/awesome_code_editor">
    ...>
    <activity
      android:name=".EditorActivity"
      android:label="@string/editor">
      <intent-filter>
        <action
          android:name="android.intent.action.EDIT" />
        <category
          android:name="android.intent.category.TEXT" />
      </intent-filter>
    </activity>
  </application>
</manifest>

如您所见,意图过滤器与电子邮件应用程序触发的意图匹配。

当 Android 安装出色的代码编辑器应用程序时,它解析应用程序清单并注意到EditorActivity声称能够处理类别为android.intent.category.TEXTEDIT操作(在Android 开发者文档中有更多信息)。它会记住这一事实。

下次您的电子邮件程序请求编辑器时,Android 将在提供的编辑器列表中包含出色的代码编辑器供您使用。您只需安装另一个应用程序,就简单地升级了您的电子邮件程序。这可真是太棒了!

注意

Android 在最近的几个版本中逐渐增加了对隐式意图使用的限制。尽管它们非常强大,但由于可以被任意安装的应用拦截,隐式意图并不安全。从 v30 版本开始,Android 强加了对其使用的严格新限制。特别是,现在不可能在清单中注册许多隐式意图。

上下文

因为 Android 组件只是在更大容器中运行的子系统,它们需要一种方式来引用容器,以便从中请求服务。从组件内部来看,容器显示为ContextContext有几种不同的类型:组件和应用程序。让我们分别看看它们。

组件上下文

我们已经看到了像这样的调用:

context.startActivity(
  Intent(context, MembersListActivity::class.java)))

此调用两次使用了Context。首先,启动Activity是组件从框架请求的一个功能,即Context。在这种情况下,它调用了Context方法startActivity。接下来,为了使意图明确,组件必须确定包含其想要启动的组件的唯一包。Intent的构造函数使用传递的context作为其第一个参数来获取context所属应用程序的唯一名称:此调用启动了属于此应用程序的Activity

Context是一个抽象类,提供对各种资源的访问,包括:

  • 启动其他组件

  • 访问系统服务

  • 访问SharedPreferences、资源和文件

Android 的两个组件ActivityService本身就是Context。除了是Context之外,它们还是 Android 容器期望管理的组件。这可能导致问题,所有这些问题都是在示例 3-1 中显示的代码变体。

示例 3-1. 千万别这样做!
class MainActivity : AppCompatActivity() {
  companion object {
    var context: Context? = null;
  }

  override fun onCreate() {
    if (context == null) {
      context = this  // NO!
    }
  }
  // ...
}

我们的开发人员已经决定,在他们的应用程序中随时说出像MainActivity.context.startActivity(...)这样的事情会非常方便。为了做到这一点,他们在全局变量中存储了对Activity的引用,在应用程序的整个生命周期中都可以访问它。会出什么问题呢?

有两件事可能会出错,一件是糟糕的,另一件是可怕的。糟糕的是当 Android 框架知道不再需要Activity并希望释放其内存以进行垃圾回收时,但由于伴随对象中的引用,它无法这样做。这个伴随对象中的引用将阻止Activity的释放,整个应用程序的生命周期内都是如此。Activity已经泄漏了。Activity是大对象,泄露其内存绝非小事。

第二件(更糟糕)可能出错的事情是对缓存的Activity调用方法可能会导致灾难性失败。很快我们将解释,一旦框架决定不再使用Activity,它就会丢弃它。它已经完成了并且将永远不会再次使用它。因此,对象可能会处于不一致的状态。调用其上的方法可能导致难以诊断和重现的失败。

虽然这段代码中的问题很容易看出来,但还有一些更微妙的变体。以下代码可能存在类似的问题:

override fun onCreate(savedInstanceState: Bundle?) {
  super.onCreate(savedInstanceState)
  // ...
  NetController.refresh(this::update)
}

更难看出的是,回调this::update是对Activity中包含此onCreate方法的this方法的引用。一旦onCreate完成,NetController会保留对此Activity的引用,不遵守其生命周期,并可能引起前面描述的任何问题。

应用程序上下文

还有另一种上下文类型。 当 Android 启动应用程序时,通常会创建Application类的单例实例。 该实例是一个Context,虽然它有一个生命周期,但该生命周期基本上与应用程序的生命周期一致。 由于它的寿命很长,因此在其他长期存在的位置中保存对它的引用是非常安全的。 这段代码与之前显示的危险代码类似,因为它存储引用的contextApplicationContext

class SafeApp : Application() {
  companion object {
    var context: Context? = null;
  }

  override fun onCreate() {
    if (context == null) {
      context = this
    }
  }
  // ...
}

请务必记住,为了 Android 系统使用自定义的Application子类而不是其默认值,必须像这样在清单中注册SafeApp类:

<manifest ...>
  <application
    android:name=".SafeApp"
    ...>
    ...
  </application>
</manifest>

现在,当框架创建ApplicationContext时,它将是SafeApp的一个实例,而不是它本来会使用的Application的实例。

还有另一种获取ApplicationContext的方法。 在任何上下文中调用方法Context.getApplicationContext(),包括ApplicationContext本身,在任何情况下都将返回长期存在的应用程序上下文。 但是,这里有个坏消息:ApplicationContext并非万能药。 ApplicationContext不是Activity。 它的Context方法的实现与Activity的不同。 例如,可能最令人讨厌的是,您无法从ApplicationContext启动ActivityApplicationContext上有一个startActivity方法,但在除了非常有限的情况下,它只会生成错误消息。

Android 应用程序组件:构建模块

最后,我们可以把焦点放在组件本身上,这是应用程序的本质。

Android 应用程序组件的生命周期由 Android 框架管理,根据其需要创建和销毁它们。 请注意,这绝对包括实例化! 应用程序代码绝对不应创建组件的新实例。

请记住,有四种类型的组件:

  • 活动

  • 服务

  • 广播接收器

  • 内容提供者

还要记住,以下描述仅仅是简要概述,可能会引起注意潜在陷阱或有趣的特性。 Android 开发者文档是全面、完整且权威的。

活动及其友好

Activity组件管理应用程序 UI 的单个页面。 它是 Android 中的 Web 应用程序 servlet 的类比。 它使用 Android 丰富的“小部件”库来绘制单个交互页面。 小部件(按钮、文本框等)是基本的 UI 元素,它们将屏幕表示与提供小部件行为的输入组合在一起。 我们将很快详细讨论它们。

如前所述,理解Activity不是一个应用程序非常重要!Activity是短暂存在的,仅在它管理的页面可见时才保证存在。当页面变得不可见时,无论是因为应用程序显示了不同的页面,还是因为用户例如接听电话,都不能保证 Android 会保留Activity实例或其代表的任何状态。

图 3-2 显示了控制Activity生命周期的状态机。这些方法显示为状态转换,成对出现,是Activity可能处于的四种状态的书封:destroyedcreatedstartedrunning。方法严格按顺序调用。例如,在调用onStart之后,Android 仅会执行两种可能的调用之一:onResume,以进入下一个状态,或onStop,以返回到上一个状态。

pawk 0302

图 3-2. Activity生命周期。

第一对书封是onCreateonDestroy。在它们之间,Activity被称为created。当 Android 实例化一个新的Activity时,几乎立即调用其onCreate方法。在此之前,Activity处于不一致状态,并且其大多数功能将不起作用。特别要注意的是,大部分Activity的功能不便地在其构造函数中是不可用的。

onCreate方法是执行Activity只需一次初始化的理想位置。这几乎总是包括设置视图层次结构(通常通过膨胀 XML 布局),安装视图控制器或呈现器,并连接文本和触摸监听器。

Activitys,同样,在调用它们的onDestroy方法后不应再使用。再次强调,Activity处于不一致状态,并且 Android 框架将不再使用它。(例如,不会调用onCreate来重新启动它。)但要注意:onDestroy方法未必是执行重要的最终处理的最佳位置!Android 仅在尽力的基础上调用onDestroy。完全可能在所有ActivityonDestroy方法完成之前,应用程序就会被终止。

Activity可以通过调用其finish()方法从其自身程序中销毁。

下一对方法是onStartonStop。前者onStart仅在Activity处于已创建状态时才会调用。它将Activity移动到其待命状态,称为started。处于 started 状态的Activity可能部分可见,位于对话框或其他未完全填充屏幕的应用后面。在 started 状态下,Activity应该已完全绘制,但不应期望用户输入。良好编写的Activity在处于 started 状态时不会运行动画或其他占用资源的任务。

onStop 方法仅在已启动的 Activity 上调用。它将其返回到已创建状态。

最后一对方法是 onResumeonPause。它们之间,Activity 的页面处于设备上的焦点,并接收用户输入。它被称为 running。同样,这些方法只会在已启动或运行状态的 Activity 上调用。

onCreate 一起,onResumeonPauseActivity 生命周期中最重要的部分。它们是页面开始运行、数据更新、动画等使 UI 反应灵敏的关键部分。

提示

尊重这些方法的配对是一个好习惯:在一对中的开始方法中启动某些操作,在相同一对的结束方法中停止它们。例如,在 onResume 中尝试启动网络轮询,在 onStop 中停止,这样做可能导致难以调试的错误。

Fragments

Fragment 只是 Android 版本 3(蜂巢,2011 年)中稳定组件功能的一个事后补充。它们可能会感觉有些“附加”。它们被引入是为了使得可以跨屏幕共享 UI 实现,这些屏幕的形状和大小差异如此之大,以至于影响了导航,尤其是手机和平板电脑。

Fragment 不是 Context。虽然它们在大部分生命周期中持有对底层 Activity 的引用,但 Fragment 并未在清单中注册。它们是在应用程序代码中实例化的,不能使用 Intent 启动。它们也非常复杂。比较 Fragment 的状态图表(参见 图 3-3)与 Activity 的区别!

详细讨论如何(或者说是否)使用 Fragment 是本书讨论的范围之外。简单来说,可以将 Fragment 想象成网页中的 iframe:几乎是嵌入在 Activity 中的 Activity。它们是完整的、逻辑上的 UI 单元,可以以不同的方式组合成页面。

如图所示,Fragment 的生命周期与 Activity 类似(尽管更复杂)。然而,Fragment 只有在附加到 Activity 时才有用。这就是 Fragment 生命周期更复杂的主要原因:它的状态可能会受到所附加的 Activity 状态变化的影响。

正如 Activity 在其 onCreate 方法调用之前以不一致的状态可编程访问一样,Fragment 在其附加到 Activity 之前也可以通过编程访问。在 onAttachonCreateView 方法调用之前,必须非常小心地使用 Fragment

Back Store store Activities FILO.

图 3-3. Fragment 生命周期。

返回栈

Android 支持一种称为卡片堆栈导航的导航范式。导航到新页面将该页面堆叠在前一个页面之上。当用户按下返回按钮时,当前页面从堆栈中弹出,以显示之前占据屏幕的页面。这种范式对大多数人类用户来说相当直观:推送新卡片到顶部;弹出卡片以返回到之前的页面。

在 Figure 3-4 中,当前的Activity是一个名为 SecondActivity 的活动。按下返回按钮将导致名为 MainActivity 的Activity占据屏幕。

注意,与 Web 浏览器不同,Android 不支持前进导航。一旦用户按下返回按钮,就没有简单的导航设备让他们返回到弹出的页面。Android 利用这一点推断,如果需要资源,可以销毁 SecondActivity(在这种情况下)。

pawk 0304

Figure 3-4. 返回栈以后进先出(LIFO)顺序存储Activity的页面。

Fragment也可以作为片段事务的一部分放入返回栈中,如 Figure 3-5 所示。

pawk 0305

Figure 3-5. 一个Fragment事务,在返回栈中,将在包含它的Activity被弹出之前被还原。

将一个 Fragment 添加到返回栈中在与标记结合时可能特别有用,如下面的代码所示:

// Add the new tab fragment
supportFragmentManager.beginTransaction()
    .replace(
        R.id.fragment_container,
        SomeFragment.newInstance())
    .addToBackStack(FRAGMENT_TAG)
    .commit()

此代码创建SomeFragment的一个新实例,并将其添加到返回栈中,并用标识符FRAGMENT_TAG(一个字符串常量)标记。如下面的代码所示,您可以使用supportFragmentManager弹出所有返回栈的内容,直到标记:

manager.popBackStack(
    FRAGMENT_TAG,
    FragmentManager.POP_BACK_STACK_INCLUSIVE)

当返回栈为空时,按下返回按钮将用户返回到启动器。

服务

一个Service是一个几乎完全没有 UI 的 Android 组件,几乎与Activity一样。这听起来有点奇怪,因为Activity的唯一存在理由是它管理 UI!

Android 设计时硬件与现在通常不同得多。第一部 Android 手机,HTC Dream,于 2008 年 9 月宣布发布。它的物理内存非常少(192 MB),根本不支持虚拟内存。它最多只能同时运行几个应用程序。Android 的设计者需要一种方式来知道应用程序何时不执行有用工作,以便可以回收其内存用于其他用途。

很容易判断一个Activity何时不执行有用工作。它只有一个任务:管理一个可见页面。如果应用程序仅由Activity组成,则很容易判断何时不再需要其中一个并且可以终止它。当应用程序的所有Activity都不可见时,应用程序不执行任何有用工作并且可以被回收。就是这么简单。

当应用需要执行长时间运行的任务,而这些任务与任何 UI 都没有关联时就会出现问题:比如监控位置、在网络上同步数据集等等。尽管 Android 明确偏向于“如果用户看不到它,为什么要做它?”的态度,但它也不情愿地承认长时间运行的任务的存在,并发明了 Service 来处理它们。

虽然 Service 仍然有它们的用处,但是它们被设计用来做的大部分工作,早期版本的 Android 上因其硬件限制而现在可以通过其他技术完成。Android 的 WorkManager 是管理重复任务的绝佳方式。还有其他更简单、更可维护的方法在后台运行任务,比如在工作线程上运行。一个简单的单例类可能已经足够了。

Service 组件仍然存在,并且仍然扮演着重要角色。

实际上有两种不同类型的 Serviceboundstarted。尽管 Service 基类混淆地是两者的模板,但这两种类型完全正交。一个单一的 Service 可以是两者之一或两者兼而有之。

两种类型的 Service 都有 onCreateonDestroy 方法,它们的行为与 Activity 中的完全相同。由于 Service 没有 UI,它不需要 Activity 的其他模板方法。

尽管 Service 还有其他的模板方法。一个具体的 Service 实现哪些方法取决于它是 started 还是 bound。

Started Services

Started Service 是通过发送 Intent 启动的。虽然可以创建一个返回值的 started service,但这样做可能会显得不够优雅复杂,并且可能表明设计可以改进。大部分情况下,started services 是一次性的:比如“把这个放到数据库里”或者“发送到网络上去”。

要启动一个服务,发送一个意图。意图必须指明服务的名称,可能需要明确传递当前上下文和服务类。如果服务提供多个功能,则意图还可以指示打算调用的功能。它可能还会提供适合调用的参数。

服务将意图作为 Android 框架调用的参数接收,传递给 Service.onStart 方法。请注意,这不是在“后台”完成的!onStart 方法在主/UI 线程上运行。onStart 方法解析 Intent 内容,并适当地处理其中的请求。

一个行为良好的启动 Service 在完成工作后会调用 Service.stopSelf()。这个调用类似于 Activity.finish():它告诉框架 Service 实例不再执行有用的工作,可以被回收。现代版本的 Android 实际上很少关注服务是否已经停止自己。Service 可能会被暂停甚至终止,使用的标准不那么自愿(参见 Android 开发者文档)。

绑定服务

绑定 Service 是 Android 的 IPC 机制。绑定服务提供了一个客户端和服务器之间的通信通道,是进程不可知的:两端可能属于同一个应用程序,也可能不是。绑定服务,至少是它们提供的通信通道,是 Android 的核心所在。它们是应用程序将任务发送到系统服务的机制。

一个绑定的服务本身实际上并没有做太多事情。它只是 Binder 的工厂,一个半双工 IPC 通道。虽然本书不详细描述 Binder IPC 通道及其使用,但其结构对于使用任何其他常见 IPC 机制的用户来说都很熟悉。图 3-6 展示了这个系统。

通常,服务提供一个 代理,看起来像一个简单的函数调用。代理 打包 请求服务的标识符(本质上是函数名)及其参数,将它们转换为可以通过连接传输的数据:通常是整数和字符串等非常简单的数据类型的聚合体。打包的数据通过 Binder 内核模块传输到绑定服务提供的 存根,这是连接目标。

pawk 0306

图 3-6. Binder IPC。

存根 解组 数据,将其转换回到对服务实现的函数调用。请注意,代理函数和服务实现函数具有相同的签名:它们实现了相同的接口(如 图 3-6 所示的 IService)。

Android 在实现系统服务时 广泛 使用了这种机制。实际上是远程进程调用的函数是 Android 的一个基本部分。

ServiceConnection 类的一个实例表示到绑定服务的连接。以下代码演示了它的使用:

abstract class BoundService<T : Service> : ServiceConnection {
    abstract class LocalBinder<out T : Service> : Binder() {
        abstract val service: T?
    }

    private var service: T? = null

    protected abstract val intent: Intent?

    fun bind(ctxt: Context) {
        ctxt.bindService(intent, this, Context.BIND_AUTO_CREATE)
    }

    fun unbind(ctxt: Context) {
        service = null
        ctxt.unbindService(this)
    }

    override fun onServiceConnected(name: ComponentName, binder: IBinder) {
        service = (binder as? LocalBinder<T>)?.service
        Log.d("BS", "bound: ${service}")
    }

    override fun onServiceDisconnected(name: ComponentName) {
        service = null
    }
}

BoundService 的子类提供了将要绑定的服务的类型,以及一个定位到它的 Intent

客户端通过bind调用发起连接。作为响应,框架会启动到远程绑定服务对象的连接。远程框架调用绑定服务的onBind方法,并带有意图。绑定服务创建并返回一个IBinder的实现,同时也是客户端请求的接口的实现。请注意,这通常是对绑定服务本身的引用。换句话说,Service通常不仅是工厂,还是实现。

服务端使用绑定服务提供的实现来创建远程侧存根。然后通知客户端,表明准备就绪。客户端框架创建代理,然后最终调用ServiceConnectiononServiceConnected方法。客户端现在持有与远程服务的活动连接。利润!

正如从onServiceDisconnected方法的存在可以猜到的那样,客户端随时可能会丢失与绑定服务的连接。尽管通知通常是即时的,但客户端在收到断开连接通知之前调用服务的调用可能会失败。

与启动服务类似,绑定服务代码不会在后台运行。除非明确要求,否则绑定服务代码将在应用程序的主线程上运行。这可能会令人困惑,因为绑定服务可能会在不同应用程序的主线程上运行。

如果服务实现中的代码必须在后台线程上运行,则服务实现负责安排。尽管绑定服务的客户端调用是异步的,但不能控制服务本身运行的线程。

与其他所有组件一样,服务必须在应用程序清单中注册:

<manifest xmlns:android="http://schemas.android.com/apk/res/android">
  <application...>
    <service android:name=".PollService"/>
  </application>
</manifest>

内容提供者

ContentProvider是应用程序中保存数据的类似 REST 的接口。因为它是一个 API,而不仅仅是对数据的直接访问,所以ContentProvider可以对其发布的内容及发布对象行使非常精细的控制。外部应用程序通过 Binder IPC 接口访问ContentProvider,通过该接口ContentProvider可以获取关于查询过程、其所持有的权限以及请求的访问类型的信息。

早期的 Android 应用程序通常通过将数据放入可公开访问的文件中来共享数据。即便如此,Android 也鼓励使用ContentProvider代替。在安全性方面,更近期的 Android 版本已经使直接共享文件变得困难,从而使ContentProvider变得更加相关。

注意

虽然ContentProvider提供了对存储数据的访问,但您必须拥有某种数据存储以读取和写入数据。Android Jetpack 提供了 Room 持久化库作为选择。正如其官方文档所述,Room 提供了“一个抽象层,允许更强大的访问,并充分利用 SQLite 的全部功能。”

想了解如何使用 Room 在本地数据库中保存数据的更多信息,请查看Android 开发者文档

一个特别有趣的ContentProvider的能力是可以将一个打开的文件传递给另一个程序。请求的程序不需要使用文件路径直接访问文件。ContentProvider可以以任何方式构造它传递的文件。通过传递一个打开的文件,ContentProvider移出了自身的循环。它直接给请求的程序访问数据的权限。在客户端和数据之间,既没有ContentProvider也没有其他 IPC 机制。客户端就像打开了文件一样简单地读取文件。

应用程序像往常一样通过在应用程序清单中声明来发布ContentProvider

<application...>
  <provider
   android:name="com.oreilly.kotlin.example.MemberProvider"
   android:authorities="com.oreilly.kotlin.example.members"
   android:readPermission="com.oreilly.kotlin.example.members.READ"/>
 </application>

这个 XML 元素表示应用程序包含名为com.oreilly.kotlin.example.MemberProvider的类,它必须是android.content.ContentProvider的子类。该元素声明MemberProvider是 URL content://com.oreilly.kotlin​​.exam⁠⁠ple.members 的数据请求的权限。最后,声明要求请求的应用程序必须持有权限“com.oreilly.kotlin.example.members.READ”,以获取任何访问权限,即使如此,它们也仅能获取读取权限。

ContentProvider确实具有 REST 接口应有的 API:

query()

从特定表中获取数据。

insert()

在内容提供者中插入新行并返回内容 URI。

update()

这更新现有行的字段并返回更新的行数。

delete()

这删除现有行并返回删除的行数。

getType()

这返回给定 Content URI 的 MIME 数据类型。

MemberProviderContentProvider可能只实现这些方法中的第一个,因为它是只读的。

广播接收器

最初BroadcastReceiver的概念是作为一种数据总线。监听器可以订阅以获取感兴趣的事件通知。然而,随着系统的发展,BroadcastReceiver已被证明过于昂贵和容易遭受安全问题,无法广泛使用。它们仍然主要是系统用来向应用程序发出重要事件信号的工具。

BroadcastReceiver最常见的用途也许是作为启动应用程序的一种方式,即使没有用户的请求。

Intent android.intent.action.BOOT_COMPLETED 是在 Android 系统稳定后,系统重新启动后由 Android 系统广播的。应用程序可以注册以接收此广播,例如:

<receiver android:name=".StartupReceiver">
    <intent-filter>
        <action android:name="android.intent.action.BOOT_COMPLETED"/>
    </intent-filter>
</receiver>

如果一个应用这样做,它的StartupReceiver将会启动,以接收操作系统重新启动时的BOOT_COMPLETED Intent广播。正如前面所述,启动StartupReceiver的副作用是启动包含接收器的应用程序。

应用程序利用这种方式创建守护程序,即始终运行的应用程序。尽管这种方法不成熟且易碎(即使在早期的 Android 中,行为也随版本变化而变化),但这个技巧足够好,以至于许多应用程序都在使用它。即使 Android 26 版本在后台进程管理方面引入了一些非常激进的变化(BroadcastReceiver不能在其清单中为隐式广播注册;必须使用Context.registerReceiver动态注册它们),开发者仍在继续寻找使用它的方法。

注意

Android 26 的隐式 Intent 规则有例外。接收短信、更改语言环境、检测 USB 设备和其他一些 Intent 是允许的,应用程序可以在其清单中注册它们。ACTION_USB_ACCESSORY_ATTACHEDACTION_CONNECTION_STATE_CHANGED和我们亲爱的旧朋友ACTION_BOOT_COMPLETED都属于允许的 Intent 之列。有关更多信息,请查看Android 开发者文档

ActivityServiceContentProviderBroadcastReceiver是构成 Android 应用程序的四个基本构件。随着 Android 的发展和改进,它引入了新的抽象化,使这些基本机制变得模糊。现代 Android 应用程序可能只直接使用其中一两个构件,许多开发者将永远不会编写ContentProviderBroadcastReceiver

这里的基本教训是值得重复的,即 Android 应用程序不是传统意义上的“应用程序”。它更像是一个 Web 应用程序:一组在请求时为框架提供服务的组件。

Android 应用架构

到目前为止,在本章中我们已经讨论了 Android 系统架构。虽然理解该架构对于任何严肃的 Android 开发者来说都是必不可少的,但这并不足以理解如何编写健壮、无 bug 的 Android 程序。作为证据,只需看看多年来 Android 存在的许多尝试和放弃的工具和抽象化。然而,时间和经验磨练了 Android 的游戏规则,并使得编写一个稳健、易于维护的应用程序的路径变得更加容易。

MVC:基础

对于具有用户界面的应用程序的原始模式称为模型-视图-控制器(MVC)。该模式引入的创新是保证视图——即屏幕上呈现的内容——始终保持一致。它通过坚持数据流的单向循环来实现这一点。

一切都始于用户。他们在屏幕上看到某些东西(视图:我告诉过你这是一个循环!),作为对所看到的反应,采取一些行动。他们触摸屏幕,输入文字,说话,或者其他。他们做一些改变应用程序状态的事情。

他们的输入由控制器处理。控制器有两个责任。首先,它命令用户的输入。对于任何给定的用户事件,比如点击“停止”按钮,所有其他用户事件要么在该点击之前发生,要么在之后发生。从不会同时处理两个事件。

注意

控制器是单线程的暗示是原始 MVC 模式中最重要的一个方面之一。先前的多线程策略(包括 Java 的抽象窗口工具包[AWT])通常会产生死锁的噩梦,因为来自用户和发送给用户的消息会试图以不同的顺序获取相同的锁。

控制器的第二个责任是将用户输入转化为对模型的操作。

模型是应用程序的业务逻辑。它可能将某种持久数据存储与可能的网络连接结合起来,同时还有从控制器接收和解释输入的规则。在理想的 MVC 架构中,它是唯一保存应用程序当前状态的组件。

模型再次理想地只允许向视图发送一条消息:“事情已经改变了”。当视图收到这样的消息时,它完成了自己的工作。它从模型请求应用程序状态,解释它,并在屏幕上呈现它。它呈现的始终是模型的一致快照。在这一点上,用户可以看到新状态并采取新的响应动作。这个循环继续。

尽管当 MVC 模式被引入时相当革命性,但仍有改进的空间。

小部件

正如我们之前在Activity组件的上下文中提到的,小部件是一个将视图组件和控制器组件结合在一起的单一类。在前面讨论了 MVC 模式及其强调分离这两者之后,发现像ButtonTextBoxRadioButton这样明显将两者结合起来的类可能会感到奇怪。

小部件不会破坏 MVC 架构。在每个小部件中仍然有明确的视图和控制器代码。小部件的控制器部分从不直接与视图交谈,视图也不会接收来自控制器的事件。这些部分是独立的;它们只是捆绑在一个方便的容器中。

将这两个功能结合起来似乎显而易见。如果一个按钮的图像可以放置在屏幕的任何地方,而不响应点击,那有什么用呢?对于 UI 组件的渲染器以及处理其输入的机制,成为同一组件的一部分就是有意义的。

本地模型

随着 Web、浏览器的出现以及整个 MVC 循环所需的长时间延迟,开发人员开始意识到需要将屏幕状态作为单独的 UI-Local Model。随着时间的推移,开发人员根据设计模式的其他特性,给这个组件起了几个名字。为了避免混淆,在本章的其余部分,我们将称其为Local Model

使用 Local Model 引出了一个新的模式,可以说是一个两层 MVC——甚至被称为“八字模式”。当用户执行操作时,Controller 更新 Local Model 而不是 Model,因为 Model 更新可能需要进行网络连接。Local Model 并不是业务逻辑。它尽可能简单地表示 View 的状态:哪些按钮是开启的,哪些是关闭的,哪个框中有什么文本,以及图表中的颜色和长度。

Local Model 在响应操作时有两个作用。首先,它通知 View 事物已经改变,以便 View 可以从新的 Local Model 状态重新渲染屏幕。此外,类似于简单 MVC 的 Controller,Local Model 通过代码将状态变化转发给 Model。作为回应,Model 最终会通知——这次是 Local Model——已经有更新,Local Model 应该同步自己。这可能导致第二个请求,要求 View 更新自己。

Android 模式

在 Android 中,不论采用何种模式,Activity 对象——或者可能是其近亲 Fragment——扮演 View 的角色。这基本上是由于 Activity 对象的结构:它拥有屏幕并且可以访问组成视图的部件。然而,随着时间的推移,正如适用于基于 MVC 的 UI,Activity 对象变得越来越简单。在现代 Android 应用程序中,一个 Activity 可能只会做很少的事情:填充视图、将用户的入站事件委托给 Local Model,并观察感兴趣的 Local Model 状态,以响应更新重新绘制自身。

Model–View–Intent

Android 社区采用的最古老版本的 MVC 之一被称为 Model–View–Intent。该模式通过使用 Intent 和它们的载荷,将 Activity 与 Model 解耦。虽然这种结构能够产生出色的组件隔离,但可能会相当慢,而且构建 Intents 的代码相当庞大。尽管它仍然成功使用,但较新的模式已大部分取代了它。

Model–View–Presenter

所有基于 MVC 的模式的目标都是减少三个组件之间的耦合,并使信息流单向传递。然而,在一个简单的实现中,视图和本地模型各自持有对另一个的引用。也许视图从某种工厂获取本地模型的实例,然后注册它。尽管微妙——并且不管信息流的明显方向如何——持有对特定类型对象的引用都是耦合的表现。

在过去几年中,有几次对 MVC 模式进行了改进,试图减少耦合。虽然这些改进通常会导致更好的代码,但它们之间的区别,以及用来标识它们的名称,并不总是清晰明了。

最早的一种改进方法是,用接口代替视图和本地模型相互引用。该模式通常称为 Model–View–Presenter(MVP)。在这种模式的实现中,本地模型持有的不是视图Activity的引用,而仅仅是某个接口的实现。这个接口描述了本地模型可以从其对等体期望的最小操作集。它基本上并不知道视图是一个视图:它只看到用于更新信息的操作。

视图代理用户输入事件给其 Presenter。正如前面所述,Presenter 响应事件,根据需要更新本地模型和模型状态。然后通知视图需要重新绘制自己。因为 Presenter 准确知道发生了哪些变化,它可以请求视图仅更新受影响的部分,而不是强制整个屏幕重绘。

这种架构的最重要特性是,Presenter(本架构称为本地模型)可以进行单元测试。测试只需模拟视图提供给 Presenter 的接口,就可以完全隔离它与视图的联系。极其简洁的视图和可测试的 Presenter 可以极大增强应用的健壮性。

但我们可能会比这做得更好。本地模型可能根本不持有对视图的引用!

Model–View–ViewModel

谷歌通过引入 Jetpack,支持了一种称为 Model–View–ViewModel(MVVM)的架构。因为它在现代 Android 框架内部得到支持,所以它是现代 Android 应用程序中最常见和最讨论的模式。

在 MVVM 模式中,通常使用ActivityFragment充当视图。视图代码尽可能简单,通常完全包含在ActivityFragment子类中。也许一些复杂的视图需要单独的类来进行图像渲染或RecyclerView。尽管如此,这些视图也会由ActivityFragment直接实例化和安装。

ViewModel 负责将更新视图和后端模型的命令连接在一起。这种模式的新特性是使用单一接口Observable传输本地模型状态的更改到视图。

MVVM 模式中,ViewModel 代表可视化数据作为一组Observable,而不是 MVP 模式中使用的多个 Presenter 接口。视图只需将自己注册为这些可观察对象的观察者,并对其中数据变化的通知做出反应。

Jetpack 库将这些Observable称为LiveDataLiveData对象是一个可观察的数据持有者类,具有单个泛型接口,用于通知订阅者数据底层的更改。

与 MVP 类似,MVVM 让模拟和单元测试变得容易。MVVM 引入的重要新特性是生命周期感知。

注意的读者会发现,在之前描述的 MVP 模式版本中,确实完全做了我们在 示例 3-1 中警告的事情:它在一个长期存在的对象中存储了对Activity的引用,而Activity是一个具有由 Android 控制的生命周期的对象!应用程序必须自行确保引用不会超出目标对象的生命周期。

Jetpack 支持的 MVVM 模式实现大大减少了这个问题。在其实现中,对视图的唯一引用是对LiveData可观察对象的订阅。LiveData对象在它们的生命周期结束时自动识别FragmentActivity的观察者,并注销它们。

使用 JetPack 实现的 MVVM 模式构建的应用程序可以非常优雅。对于各种应用程序,视图将包含一个简单的声明性方法来绘制屏幕。它将将这个方法注册为观察者以观察 ViewModel 的可观察对象。ViewModel 将用户输入转换为对后端模型的调用,并根据模型通知更新其可观察对象。就是这么简单。

摘要

祝贺你,在这一小节里,你成功地涵盖了大量信息!

记住,这些材料中的大部分都是基础知识。重要的不是你掌握这里呈现的所有信息。事实上,你可能永远都不会接触到像ContentProviderBroadcastReceiver这样的东西。只使用对你有实际意义的内容,并在它们变得有用时逐步掌握它们。

这里有一些要记住的关键点:

  • Android 应用程序不是传统意义上的“应用程序”。它更像是一个 Web 应用程序:一组组件,它们在请求时为框架提供服务。

  • Android 操作系统是一个非常专业化的 Linux 发行版。每个应用程序都被视为一个单独的“用户”,并拥有自己的私有文件存储空间。

  • Android 有四种组件。它们是:Activity(活动),Service(服务),ContentProvider(内容提供者)和BroadcastReceiver(广播接收器)。ActivityServiceContentProvider必须在 Android 清单文件中注册,并可能需要权限:

    • Activity是 Android 应用程序的用户界面。它们在onCreate时开始其生命周期,在onResume后可以与用户进行交互,并且可能随时被中断(onPause)。

    • Fragment是具有独立生命周期的复杂实体。它们可用于在 UI 页面中组织独立的 UI 容器。

    • Service可以是启动服务和/或绑定服务。从 API 26 开始,服务的后台使用引入了限制,因此通常规则是,如果用户以任何方式与任务进行交互,则应将服务转换为前台服务。

    • 除非BroadcastReceiver使用了系统明确允许的隐式意图和操作,否则可能需要从应用程序代码动态注册广播接收器。

  • 谨慎使用Activity Context。活动具有不受您应用程序控制的生命周期。对Activity的引用必须遵守活动的实际生命周期。

  • Android 中的一般软件架构,如 MVI、MVP 和 MVVM,旨在使FragmentActivity保持简洁,并鼓励更好地分离关注点和测试,同时具备“生命周期感知”。

现在我们已经复习了基本规则并探索了领域,我们正式开始在 Kotlin 中实现结构化协程的旅程。在接下来的章节中,我们将开始将此基础应用于研究 Android 中的内存和线程。了解 Android 的组织细节将揭示即将解决的问题。

第四章:并发编程在 Android 中

本章不专门关注 Kotlin。相反,它将介绍围绕并发编程的一些问题,这些问题是本书其余部分讨论的内容。它还将介绍一些已经提供给 Android 开发者的用于管理并发任务的工具。

并发编程有点像黑魔法:是由自称为巫师的人做的事情,初学者可能会触碰到危险。当然,编写正确的并发程序可能非常具有挑战性。这尤其是因为并发程序中的错误并不总是立即显现出来。几乎不可能测试并发错误,即使已知其存在,要复现它们也可能极为困难。

关于担心并发编程风险的开发人员,有三件事是需要记住的:

  • 几乎你每天做的一切,除了 编程,都是并发的。在并发环境中你可以很好地相处。奇怪的是编程,它是按顺序进行的。

  • 如果你正试图理解并发编程带来的问题,你走在了正确的道路上。即使对并发性的理解不完整,也比复制示例代码并祈祷要好。

  • 并发编程就是 Android 的工作方式。除了最简单的 Android 应用程序外,其他任何东西都需要并发执行。最好开始并弄清楚它到底是什么!

在具体进入细节之前,让我们先定义一些术语。

第一个术语是进程。进程是应用程序可以使用的内存,以及一个或多个执行线程。内存空间属于进程——没有其他进程可以影响它。^(1) 一个应用程序通常作为一个单独的进程运行。

当然,这引出了第二个术语:线程。线程是一系列指令,按顺序逐一执行。

这也引出了在某种程度上驱动本书其余部分的术语:线程安全。如果一组指令在多个线程执行时,线程执行指令的任何可能顺序都不能产生不能在每个线程完全执行代码时以某种顺序逐一执行的情况下获得的结果。这有点难以解析,但它只是说,无论多个线程同时执行代码还是按顺序逐一执行,代码都产生相同的结果。这意味着运行程序会产生可预测的结果。

如何使程序具备线程安全性?关于这个问题有很多不同的想法。我们想提出一个清晰、相对易于理解并且总是正确的方法。只需要遵循一个相对清晰、相对简单的规则。我们将在接下来的几页中阐述这个规则。不过首先,让我们更详细地讨论一下什么是线程安全性。

线程安全性

我们已经说过,线程安全的代码不能在同时由多个线程执行时产生不能通过一次性执行线程产生的结果。然而,这个定义在实践中并不十分有用:没有人会测试所有可能的执行顺序。

也许我们可以通过查看代码可能线程不安全的一些常见方法来解决问题。

线程安全失败可以分为几类。最重要的两类是 原子性可见性

原子性

几乎所有开发人员都了解原子性的问题。此代码不是线程安全的:

fun unsafe() { globalVar++ }

多个线程执行此代码可能会互相干扰。执行此代码的每个线程可能会读取相同的 globalVar 值,比如 3。每个线程可能会将该值递增为 4,然后每个线程可能会更新 globalVar 为值 4。即使有 724 个线程执行了此代码,当所有线程执行完毕时,globalVar 的值可能仍然为 4。

没有可能让这 724 个线程依次执行该代码,并使 globalVar 最终值为 4。因为并发执行的结果可能与串行执行生成的任何可能结果都不同,根据我们的定义,此代码不是线程安全的。

要使代码线程安全,我们需要使变量 globalVar 的读取、递增和写入操作原子化。原子操作是指不能被另一个线程中断的操作。如果读取、递增和写入操作是原子的,那么没有两个线程能看到相同的 globalVar 值,程序保证表现如预期。

原子性很容易理解。

可见性

我们的第二类线程安全错误,可见性,更难以理解。此代码也不是线程安全的:

var shouldStop = false

fun runner() {
    while (!shouldStop) { doSomething() }
}

fun stopper() { shouldStop = true }

运行函数 runner 的线程可能永远不会停止,即使另一个线程运行 stopper。运行 runner 的线程可能永远不会注意到 shouldStop 的值已更改为 true

这背后的原因是优化。硬件(使用寄存器、多层缓存等)和编译器(使用提升、重排序等)都在尽最大努力使您的代码运行速度快。为此,硬件实际执行的指令可能与 Kotlin 源代码看起来完全不同。特别是,尽管您认为 shouldStop 是一个单一变量,但硬件可能对其至少有两种表示:一个在寄存器中,一个在主存储器中。

你绝对希望这样!你不希望你的代码中的循环依赖于对主存储器的访问,而不是使用缓存和寄存器。快速内存优化了您的代码,因为其访问时间比主存储器快几个数量级。

然而,为了使示例代码正常工作,你必须告诉编译器,它不能将shouldStop的值保留在本地内存(寄存器或缓存)中。如果按照建议,在不同种类的硬件内存中有shouldStop的多个表示,编译器必须确保快速本地表示的shouldStop值被推送到对所有线程可见的内存中。这称为发布该值。

@Synchronized 就是这么做的方式。同步告诉编译器,必须确保在同步块内执行的代码的任何副作用对所有其他线程可见,然后执行线程离开该块。

因此,同步不仅仅是关于硬件,或者关于必须保护和不需要保护的棘手和复杂的标准。同步是开发人员与编译器之间的一种契约。如果你不同步代码,编译器可以根据串行执行来进行任何安全证明的优化。如果某处有其他代码在不同的线程上运行,并使编译器的证明无效,你必须同步代码。

所以,这里有一个规则。如果你想编写线程安全的代码,你只需遵循这一简短而明确的规则。来自 Java 并行编程的圣经Java Concurrency in Practice的释义:^(2) 每当多个线程访问给定状态变量,并且其中一个可能会写入它时,它们都必须使用同步来协调对它的访问。

顺便说一句,那个引用并没有区分同步的读访问和写访问。除非可以保证没有人会改变共享状态,否则所有的访问,无论是读还是写,都必须同步。

Android 线程模型

如第三章所述,MVC 架构的一个含义是单线程 UI(视图和控制器)。尽管多线程 UI 看起来非常诱人(视图一个线程,控制器一个线程……),但在 20 世纪 70 年代就放弃了尝试构建它们,因为很明显,它们最终陷入了死锁的混乱中。

自从 MVC 的普遍采纳以来,标准 UI 设计是由单线程队列服务的(在 Android 中是主线程UI 线程)。如图 4-1 所示,事件——无论是用户发起的(点击、轻触、输入等)还是模型发起的(动画、请求重新绘制/更新屏幕等)——都会被单一 UI 线程按顺序排队并最终处理。

pawk 0401

图 4-1. UI 线程。

这正是安卓 UI 的工作方式。一个应用程序的主线程(应用程序进程的原始线程)成为其 UI 线程。作为初始化的一部分,该线程进入一个紧密的循环。在应用程序的其余生命周期中,它逐个从标准 UI 队列中移除任务并执行它们。因为 UI 方法始终在单线程上运行,UI 组件并不试图保持线程安全。

听起来很不错,对吧?单线程 UI 且无需担心线程安全问题。但有个问题。为了理解这个问题,我们需要摘下开发者的帽子,稍微讨论一下安卓设备终端用户的体验。特别是,我们需要深入了解视频显示的一些细节。

注意

当每个线程都持有另一个需要的资源时,线程被称为死锁:它们都无法向前进展。例如,一个线程可能持有显示值的小部件并需要显示该值的容器。同时,另一个线程可能持有容器并需要该小部件。如果所有线程始终按相同的顺序获取资源,则可以避免死锁。

丢帧

通过长期的电影和电视观看经验,我们知道,即使实际上不是连续运动,人脑也可以被欺骗为感知到连续的运动。快速显示一系列静止图像,一个接一个地显示,观察者会感觉是平滑的、不间断的运动。图像显示的速率称为帧率。它以每秒帧数(fps)来衡量。

电影的标准帧率是 24 fps,在整个好莱坞黄金时代都很有效。老式电视使用的帧率为 30 fps。你可以想象,比较快的帧率比慢的帧率更能欺骗大脑。即使你不能确切感知到差异,但如果你在高帧率视频和低帧率视频之间进行比较,你很可能会注意到不同。快速的那个会感觉“更流畅”。

许多安卓设备使用 60 fps 的帧率。这意味着屏幕每 16 毫秒(ms)重绘一次。这意味着 UI 线程,即处理 UI 任务的单个线程,必须在每 16 毫秒准备好新图像,以便在屏幕重绘时绘制。如果生成图像所需时间超过这个时间,并且在屏幕重绘时新图像还没有准备好,我们称帧已丢失。

屏幕再次重绘并显示新帧之前会再等待 16 ms。如果帧率从 60 fps 降至 30 fps,那就接近人脑能感知的阈值了。只需少数几个丢失的帧就会让用户界面产生所谓的“卡顿”感觉。

考虑在图 4-2 中显示的任务队列,在安卓的标准渲染速率为 60 fps。

pawk 0402

图 4-2。排队等待 UI 线程的任务。

处理用户的字符输入是第一个任务,执行时间为 8 毫秒。接下来的任务是更新视图,是动画的一部分。为了看起来平滑,动画需要每秒至少更新 24 次。然而,处理用户点击的第三个任务需要 22 毫秒。图示中的最后一个任务是动画的下一帧。图 4-3 展示了 UI 线程的视角。

pawk 0403

图 4-3. 一帧丢失的情况。

第一个任务在 8 毫秒内完成。动画在 4 毫秒内向显示缓冲区绘制了一帧。然后 UI 线程开始处理点击。点击处理几毫秒后,硬件进行了重绘,此时动画帧已经显示在屏幕上。

不幸的是,16 毫秒后,处理点击的任务仍未完成。排在其后的动画下一帧的任务也未被处理。当进行重绘时,显示缓冲区的内容与上一次重绘时完全相同。动画帧已经丢失。

注意

计算机显示通常使用一个或多个显示缓冲区来管理。显示缓冲区 是内存中的一个区域,用户代码在其中“绘制”将显示在屏幕上的内容。偶尔在刷新间隔(大约 60 帧每秒时约为 16 毫秒)时,用户代码会短暂地被锁定在缓冲区之外。系统使用缓冲区的内容来渲染屏幕,然后将其释放回用户代码以进行进一步更新。

几毫秒后,当点击处理任务完成时,动画任务有机会更新显示缓冲区。尽管显示缓冲区现在包含动画的下一帧,屏幕将不会在几毫秒内进行重绘。动画的帧率已经降低到每秒 30 帧,接近可见的闪烁频率。

一些新设备,如谷歌的 Pixel 4,能够以更高的帧率刷新屏幕。例如,帧率提高到两倍(120 帧每秒),即使 UI 线程连续错过两帧,它仍然只需等待额外的 8 毫秒进行下一次重绘。在这种情况下,两次渲染之间的间隔仅约为 24 毫秒;远远优于在 60 帧每秒时掉帧造成的 32 毫秒成本。

虽然提高帧率可能有所帮助,但安卓开发者必须保持警惕,确保应用程序尽量减少掉帧现象。如果应用程序正在进行一项昂贵的计算,并且该计算花费的时间超过预期,它将错过重绘的时间段,导致掉帧,应用程序会感觉卡顿。

这种情况正是为什么在安卓应用程序中必须处理并发的原因。简而言之,UI 是单线程的,UI 线程绝不能被占用超过几毫秒。

对于非平凡应用程序,唯一可能的解决方案是将耗时工作(如数据库存储和检索、网络交互和长时间运算)传递给其他线程。

内存泄漏

我们已经处理了并发引入的一个复杂性:线程安全。Android 的组件化架构引入了第二个同样危险的复杂性:内存泄漏

当对象不再有用时无法释放(垃圾回收),就会发生内存泄漏。在最坏的情况下,内存泄漏可能导致OutOfMemoryError和应用程序崩溃。即使情况没有变得那么糟,内存不足也可能强制更频繁地进行垃圾回收,进而导致“卡顿”。

如第三章所讨论的,Android 应用程序特别容易发生内存泄漏,因为一些最常用组件的生命周期(如ActivityFragmentService等)不受应用程序控制。这些组件的实例很容易变成无用的负担。

在多线程环境中尤为如此。考虑将任务卸载到工作线程,如下所示:

override fun onViewCreated(
    view: View,
    savedInstanceState: Bundle?
) {
    // DO NOT DO THIS!
    myButton.setOnClickListener {
        Thread {
            val status = doTimeConsumingThing()
            view.findViewById<TextView>(R.id.textview_second)
                .setText(status)
        }
            .start()
    }
}

将耗时工作从 UI 线程移开的想法是很崇高的。不幸的是,前述代码存在几个缺陷。你能发现它们吗?

首先,如本章前述,Android UI 组件不是线程安全的,不能在 UI 线程之外访问或修改。在此代码中,从非 UI 线程调用setText是不正确的。许多 Android UI 组件会检测到此类不安全的使用,并在发生时抛出异常。

解决此问题的一种方法是使用 Android 工具包中的安全线程分派方法将结果返回到 UI 线程,如下所示。请注意,此代码仍然存在缺陷!

override fun onViewCreated(
    view: View,
    savedInstanceState: Bundle?
) {
    // DO NOT DO THIS EITHER!
    myButton.setOnClickListener {
        Thread {
            val status = doTimeConsumingThing()
            view.post {
                view.findViewById<TextView>(R.id.textview_second)
                    .setText(status)
            }
        }
            .start()
    }
}

这解决了第一个问题(UI 方法setText现在从主线程调用),但代码仍然不正确。尽管语言的曲折性使问题难以看清楚,但问题是在ClickListener中新创建的线程隐含地引用了一个由 Android 管理的对象。由于doTimeConsumingThingActivity(或Fragment)的方法,因此在点击监听器中新创建的线程隐式引用了该Activity,如图 4-4 所示。

pawk 0404

图 4-4. 泄露的活动。

如果将调用 doTimeConsumingThing 写成 this.doTimeConsumingThing,可能会更加明显。但是仔细想想,显然无法在没有持有该对象引用的情况下(在这种情况下是 Activity 的实例),调用方法 doTimeConsumingThing。现在 Activity 实例在工作线程上运行的 Runnable 持有其引用时,该实例将无法进行垃圾回收。如果线程运行时间较长,Activity 的内存就会泄露。

解决这个问题比上一个问题困难得多。一种方法假定保证仅短时间(少于一秒)持有隐式引用的任务可能不会导致问题。Android 操作系统本身偶尔会创建这种短暂的任务。

ViewModelLiveData 确保你的 UI 总是展示最新的数据,并且安全地进行更新。结合 Jetpack 的 viewModelScope 和协程(即将介绍的两个内容),这些工具使得更容易控制取消不再相关的后台任务,并确保内存完整性和线程安全。如果没有这些库,我们必须自行正确处理所有这些问题。

注意

谨慎设计,使用 Jetpack 的生命周期感知、可观察的 LiveData 容器,如第三章所述,可以帮助消除内存泄漏,并消除使用已完成生命周期的 Android 组件的风险。

管理线程的工具

实际上,我们刚刚讨论的代码中存在第三个缺陷;一个深层次的设计缺陷。

线程是非常昂贵的对象。它们占用空间大,影响垃圾回收,并且在它们之间切换上下文远非免费。创建和销毁线程,如示例中的代码所做的那样,是非常浪费和不明智的,可能会影响应用程序的性能。

增加更多线程并不能使应用程序完成更多的工作:CPU 的处理能力是有限的。那些没有执行的线程只是代表尚未完成的工作,是一种昂贵的表示方式。

例如考虑一下,如果用户反复点击之前例子中的 myButton,会发生什么。即使每个生成的线程执行的操作都很快且线程安全,创建和销毁这些线程将使应用程序速度变得非常慢。

应用程序的最佳实践是线程策略:一种基于实际有用线程数量的应用程序全局策略,用于控制任何给定时间运行的线程数量。一个智能的应用程序维护一个或多个线程池,每个线程池都有特定目的,并且每个线程池都由一个队列控制。客户端代码有工作需要完成时,将任务加入到线程池中由池线程执行,并在必要时恢复任务结果。

接下来的两个部分介绍 Android 开发人员可用的两种线程原语,Looper/HandlerExecutor

Looper/Handler

Looper/Handler是一组协作类的框架:一个Looper,一个MessageQueue及其上排队的Message,以及一个或多个Handler

Looper其实就是一个 Java Thread,通过从其run方法调用Looper.prepare()Looper.start()方法进行初始化,代码如下:

var looper = Thread {
    Looper.prepare()
    Looper.loop()
}
looper.start()

第二种方法Looper.loop()会使线程进入一个紧密循环,它会检查其MessageQueue中的任务,逐个移除并执行它们。如果没有要执行的任务,线程将休眠,直到有新任务入队为止。

注意

如果你觉得这听起来有些眼熟,你是对的。Android 的 UI 线程就是从应用程序进程的主线程中创建的Looper

Handler是在Looper队列上排队任务并进行处理的机制。你可以像这样创建一个Handler

var handler = new Handler(someLooper);

主线程的Looper可以通过方法Looper.getMainLooper随时访问。因此,创建一个将任务发布到 UI 线程的Handler就变得如此简单:

var handler = new Handler(Looper.getMainLooper);

实际上,这正是前面示例中展示的post()方法的工作原理。

Handler非常有趣,因为它们处理Looper队列的两端。为了理解其工作原理,让我们跟随一个单一任务通过Looper/Handler框架的过程。

有几种用于将任务加入队列的Handler方法。以下是其中的两种:

  • post(task: Runnable)

  • send(task: Message)

这两种方法定义了两种稍微不同的任务入队方式:发送消息和发布Runnable。实际上,Handler总是将Message加入队列。但为了方便起见,post...()方法组会将Runnable附加到Message上,以进行特殊处理。

在本例中,我们使用Handler.post(task: Runnable)方法将任务加入队列。Handler从池中获取一个Message对象,将Runnable附加到其中,并将Message添加到LooperMessageQueue的末尾。

我们的任务现在等待执行。当它达到队列的首部时,Looper会接收到它,并且有趣的是,它会将任务交回给正是将其入队的那个Handler。因此,入队任务和执行任务的Handler实例始终是同一个。

这可能看起来有点令人困惑,直到你意识到提交任务的Handler代码可以在任何应用程序线程上运行。然而,处理任务的Handler代码始终在Looper上运行,如图 4-5 所示。

pawk 0405

图 4-5. Looper/Handler

Looper通过调用Handler方法来处理任务,首先检查Message是否包含Runnable。如果是(因为我们使用了post...()方法之一,所以任务包含Runnable),Handler会执行Runnable

如果我们使用了 send...() 方法之一,Handler 将会将 Message 传递给自己的可重写方法 Handler.handleMessage(msg: Message)Handler 的子类将在该方法中使用 Message 属性 what 来决定它应执行的特定任务,以及属性 arg1arg2obj 作为任务参数。

MessageQueue 实际上是一个排序队列。每个 Message 包含其可能被执行的最早时间之一作为其属性之一。在前面两种方法中,postsend 简单地使用当前时间(消息将会“现在”立即被处理)。

另外两种方法允许任务在将来某个时间被排队运行:

  • postDelayed(runnable, delayMillis)

  • sendMessageDelayed(message, delayMillis)

使用这些方法创建的任务将被排序到 MessageQueue 中,在指定的时间执行。

注意

正如注意到的,一个 Looper 只能尽力在请求的时间运行任务。虽然它不会在规定时间之前运行延迟的任务,但如果另一个任务占用了线程,该任务可能会运行晚一些。

Looper/Handler 是一种非常多才多艺和高效的工具。Android 系统广泛使用它们,尤其是 send...() 调用,这些调用不会进行任何内存分配。

注意,一个 Looper 可以向自身提交任务。执行任务并在给定间隔后重新安排自己(使用 ...Delayed() 方法之一)是 Android 创建动画的一种方式。

还要注意,因为 Looper 是单线程的,只在特定 Looper 上运行的任务无需线程安全。当任务仅在单个线程上运行时,无论是异步运行的任务还是同步运行的任务,都不需要同步或排序。正如前面提到的,整个 Android UI 框架仅依赖于这一假设,它运行在 UI Looper 上。

Executors 和 ExecutorServices

Java 5 引入了 ExecutorExecutorService,作为新并发框架的一部分。新框架提供了几个更高级的并发抽象,允许开发人员摆脱线程、锁和同步的许多细节。

正如其名,Executor 是一个执行提交给它的任务的实用工具。它的合同是单一方法 execute(Runnable)

Java 提供了接口的多个实现,每个都具有不同的执行策略和目的。其中最简单的可以使用方法 Executors.newSingleThreadExecutor 获得。

单线程执行器与前面部分讨论的 Looper/Handler 非常相似:它是一个单线程前面的无界队列。新任务被排入队列,然后按顺序从服务队列的单个线程上执行和删除。

Looper/Handler 和单线程 Executor 各有各的优点。例如,Looper/Handler 经过了大量优化,避免了对象分配。另一方面,单线程 Executor 会在其线程因失败任务而中止时替换线程。

单线程 Executor 的一种泛化是 FixedThreadPoolExecutor:它不是单个线程,而是其无界队列由固定数量的线程服务。与单线程 Executor 类似,FixedThreadPoolExecutor 将替换线程当任务终止它们时。然而,FixedThreadPoolExecutor 不保证任务顺序,并且会同时执行任务,硬件允许的情况下。

单线程调度的 Executor 是 Java 中的 Looper/Handler 的等效物。它类似于单线程的 Executor,但像 Looper/Handler 一样,它的队列按执行时间排序。任务按时间顺序执行,而不是提交顺序。当然,与 Looper/Handler 类似,长时间运行的任务可能会阻止后续任务按时执行。

如果这些标准执行工具都不符合你的需求,你可以创建一个定制的 ThreadPoolExecutor 实例,指定诸如其队列的大小和顺序、线程池中的线程数及其创建方式,以及当池的队列满时会发生什么。

还有一种 Executor 类型值得特别关注——ForkJoinPool。Fork-join 池存在的原因是观察到有时单个问题可以分解为多个子问题,并可以并发执行。

这种问题的一个常见示例是将两个大小相同的数组相加。同步解决方案是迭代,i = 0 .. n - 1,在每个 i 处计算 s[i] = a1[i] + a2[i]

有一个聪明的优化方法,但如果任务分成几部分的话就可能实现,这时任务可以细分为 n` 子任务,每个子任务计算 s[i] = a1[i] + a2[i]

注意,一个执行服务创建的预计要自己处理的子任务可以将子任务排入线程本地队列。由于本地队列主要由单个线程使用,几乎不会争用队列锁。大部分时间队列属于线程本身,它独自放入和取出东西。这可以是一种相当的优化。

考虑一组这些线程,每个线程都有自己的快速本地队列。假设其中一个线程完成了所有工作,准备使自己空闲,同时另一个线程池线程有一个包含 200 个子任务的队列要执行。空闲线程偷走了这些工作。它获取了忙线程队列的锁,拿走了一半的子任务,把它们放入自己的队列,并开始处理它们。

当并发任务衍生出其自身的子任务时,工作窃取技巧最为有用。正如我们将看到的,Kotlin 协程恰好就是这样的任务。

作业管理工具

就像生产汽车时可能存在规模经济一样,有一些重要的优化需要系统的大规模视图。考虑手机上的无线电使用情况。

当应用程序需要与远程服务交互时,手机通常处于节电模式,必须启动其无线电,连接到附近的基站,协商连接,然后传输其消息。由于连接协商是开销,手机会保持连接一段时间。假设一旦进行了一次网络交互,很可能会有其他的网络交互随之而来。然而,如果有一分钟左右没有使用网络,手机就会返回其静态的、节电的状态。

鉴于这种行为,想象一下当多个应用程序在不同时间发送请求时会发生什么情况。当第一个应用程序发送其请求时,手机会启动其无线电,协商连接,传输应用程序的消息,等待一段时间,然后进入休眠状态。然而,就在手机进入休眠状态时,下一个应用程序尝试使用网络。手机必须重新启动,重新协商连接等。如果有多个应用程序这样做,手机的无线电几乎始终处于全功率状态。同时,它也花费大量时间重新协商刚刚断开的网络连接。

没有单个应用程序能够解决这类问题。需要系统级别的电池和网络使用视图来协调多个应用程序(每个应用程序有其自身的需求)并优化电池寿命。

Android 8.0(API 26+)引入了对应用程序资源消耗的限制。这些限制包括以下内容:

  • 当应用程序具有可见活动或运行前台服务时,应用程序才处于前台状态。绑定和启动的Service不再阻止应用程序被杀死。

  • 应用程序不能使用其清单来注册隐式广播。同时,发送广播也有限制。

这些限制可能会使应用程序难以执行“后台”任务:与远程同步、记录位置等。在大多数情况下,可以使用JobScheduler或 Jetpack 的WorkManager来减轻这些限制。

每当需要安排中等到大型任务超过几分钟时,最佳做法是使用这些工具之一。任务的大小很重要:每隔几毫秒刷新动画或者几秒钟安排另一个位置检查可能适合使用线程级别的调度器。但是,每隔 10 分钟从上游刷新数据库则明显应该使用JobScheduler来完成。

JobScheduler

JobScheduler是 Android 用于安排未来任务(可能是重复任务)的工具。它非常灵活,并且除了优化电池寿命外,还提供了系统状态的详细信息,应用程序以前需要通过启发式推断。

事实上,JobScheduler作业实际上是一个绑定服务。应用程序在其清单中声明一个特殊服务,使其对 Android 系统可见。然后使用JobInfo为该服务安排任务。

当满足JobInfo的条件时,Android 会绑定任务服务,就像我们在“绑定服务”中描述的那样。任务一旦绑定,Android 就会指示服务运行并传递任何相关参数。

创建JobScheduler任务的第一步是在应用程序清单中注册它。如下所示:

<service
    android:name=".RecurringTask"
    android:permission="android.permission.BIND_JOB_SERVICE"/>

在这个声明中,重要的是权限。除非服务声明了确切的android.permission.BIND_JOB_SERVICE权限,否则JobScheduler将无法找到它。

请注意,任务服务对其他应用程序不可见。这不是问题。JobScheduler是 Android 系统的一部分,可以查看普通应用程序无法看到的内容。

设置JobScheduler任务的下一步是在方法schedulePeriodically中调度它,如下所示:

const val TASK_ID = 8954
const val SYNC_INTERVAL = 30L
const val PARAM_TASK_TYPE = "task"
const val SAMPLE_TASK = 22158

class RecurringTask() : JobService() {
    companion object {
        fun schedulePeriodically(context: Context) {
            val extras = PersistableBundle()
            extras.putInt(PARAM_TASK_TYPE, SAMPLE_TASK)

            (context.getSystemService(Context.JOB_SCHEDULER_SERVICE)
                as JobScheduler)
                .schedule(
                    JobInfo.Builder(
                        TASK_ID,
                        ComponentName(
                            context,
                            RecurringTask::class.java
                        )
                    )
                        .setPeriodic(SYNC_INTERVAL)
                        .setRequiresStorageNotLow(true)
                        .setRequiresCharging(true)
                        .setExtras(extras)
                        .build()
                )
        }
    }

    override fun onStartJob(params: JobParameters?): Boolean {
        // do stuff
        return true;
    }

    override fun onStopJob(params: JobParameters?): Boolean {
        // stop doing stuff
        return true;
    }
}

这个特定任务将每SYNC_INTERVAL秒运行一次,但仅在设备上有足够空间且当前连接到外部电源时才会运行。这些只是用于调度任务的众多属性中的两个。调度的粒度和灵活性可能是JobScheduler最吸引人的特性。

请注意,JobInfo类似于我们在第三章中为Intent确定目标的方式来识别要运行的任务类。

系统将根据JobInfo中设置的条件,在任务符合运行条件时调用任务的onStartJob方法。这也是JobScheduler存在的原因。由于它了解所有预定任务的调度和要求,因此可以全局优化调度,以最小化对电池的影响,特别是在电池寿命方面。

小心!onStartJob方法在主(UI)线程上运行。如果预定的任务很可能需要超过几毫秒,必须使用前面描述的某种技术在后台线程上安排它。

如果onStartJob返回true,系统将允许应用程序运行,直到它调用jobFinished或者JobInfo中描述的条件不再满足为止。例如,如果运行上一个示例中的RecurringTask的手机从其电源中断开连接,则系统将立即调用正在运行任务的onStopJob()方法通知它停止运行。

JobScheduler 任务接收到 onStopJob() 调用时,必须停止。文档建议任务有一点时间整理并干净地终止。不幸的是,它对“一点时间”的确切时间非常模糊。然而,它在警告上非常严厉,指出“在收到此消息后,您的应用程序行为由您完全负责;如果忽略此消息,您的应用程序可能开始运行不正常。”

如果 onStopJob() 返回 false,即使其 JobInfo 中的条件得到满足,任务也不会再次被调度:该作业已被取消。重复任务应始终返回 true

WorkManager

WorkManager 是一个 Android Jetpack 库,它包装了 JobScheduler。它允许单个代码库充分利用支持 JobScheduler 的现代 Android 版本,并在不支持的旧版 Android 上运行。

虽然 WorkManager 提供的服务及其 API 与包装它的 JobScheduler 提供的类似,但它们距离实现细节更远,抽象更为简洁。

JobScheduler 中,通过 onStopJob 方法的 Boolean 返回值编码了周期重复任务和一次性任务的区别,而 WorkManager 则显式地表明了这一点;有两种类型的任务:OneTimeWorkRequestPeriodicWorkRequest

排队工作请求始终返回一个标记,即 WorkRequest,可用于在不再需要时取消任务。

WorkManager 还支持构建复杂的任务链:“并行运行这个和那个,并在两者都完成后运行另一个”。这些任务链甚至可能让您想起我们在 第二章 中用于转换集合的链条。

WorkManager 是确保必要任务运行的最流畅、最简洁的方式(即使您的应用程序在设备屏幕上不可见),并以优化电池使用的方式执行这些任务。

总结

在本章中,我们介绍了 Android 的线程模型,以及一些概念和工具,帮助您有效地使用它。总结一下:

  • 线程安全的程序是一种行为良好的程序,无论并发线程如何执行它,其行为都可以在串行执行时再现。

  • 在 Android 线程模型中,UI 线程负责以下任务:

    • 绘制视图

    • 处理由用户与 UI 的交互产生的事件

  • Android 程序使用多个线程,以确保 UI 线程可以在不丢帧的情况下重新绘制屏幕。

  • Java 和 Android 提供了几种语言级别的线程原语:

    • Looper/Handler 是由单一专用线程处理的任务队列。

    • ExecutorExecutionService 是 Java 构造,用于实现应用程序范围的线程管理策略。

  • Android 提供了架构组件 JobSchedulerWorkManager 来高效地调度任务。

接下来的章节将涉及 Android 和并发中更复杂的主题。在这些章节中,我们将探讨 Kotlin 如何使管理并发进程更加清晰、更容易,减少错误的发生。

^(1) 进程可以共享一些内存(如使用 Binder),但它们以非常受控制的方式进行。

^(2) Goetz 等人,2006 年。Java Concurrency in Practice. 波士顿:Addison-Wesley.

第五章:线程安全性

随着 Java 5 中java.util.concurrent包的引入,线程成为提高复杂应用程序性能的常用工具。在图形(或头部)应用程序中,通过减少处理信息以呈现视图(用户可以在屏幕上看到和交互的编程组件)的主线程负载,它们提高了响应能力。当在具有主线程或 UI 线程概念的程序中创建线程时,它被称为后台线程。这些后台线程经常接收和处理用户交互事件,如手势和文本输入;或其他形式的数据检索,如从服务器读取;或本地存储,如数据库或文件系统。在服务器端,使用线程的后端应用程序通过利用现代 CPU 的多个核心获得更好的吞吐量。

然而,使用线程也有其自身的风险,正如您将在本章中看到的那样。线程安全性可以被看作是一组技术和良好实践,以规避这些风险。这些技术包括同步互斥锁阻塞非阻塞。像线程封闭等更高级的概念也非常重要。

本章的目标是介绍一些重要的线程安全概念,这些概念将在接下来的章节中使用。然而,我们不会广泛涵盖线程安全性。例如,我们不会解释对象发布或提供 Java 内存模型的详细信息。这些都是我们鼓励您在理解本章中解释的概念之后学习的高级主题。

一个线程问题的示例

要理解什么是线程安全性,我们将选择一个简单的线程安全问题的例子。当程序同时运行多个线程时,每个线程都有可能与其他正在运行的线程同时执行操作。但这并不一定会发生。当发生时,您需要防止一个线程访问正在被另一个线程修改的对象,因为它可能会读取对象的不一致状态。同样适用于同时的修改。确保只有一个线程可以同时访问一段代码块称为互斥。例如,考虑以下内容:

class A {
    var aList: MutableList<Int> = ArrayList()
    private set

    fun add() {
        val last = aList.last()  // equivalent of aList[aList.size - 1]
        aList.add(last + 1)
    }

    init {
        aList.add(1)
    }
}

add()方法获取列表的最后一个元素,将其加 1,并将结果追加到列表中。如果两个线程同时尝试执行add(),预期的行为将会是什么?

当第一个线程引用最后一个元素时,另一个线程可能已经有时间执行整个aList.add(last + 1)行。^(1) 在这种情况下,第一个线程读取最后一个元素为 2,并将 3 追加到列表中。结果列表将是[1, 2, 3]。还有另一种可能的情况。如果第二个线程没有时间追加新值,那么两个线程将读取最后一个元素的相同值。假设其余执行没有问题,我们得到结果[1, 2, 2]。还可能发生一种更危险的情况:如果两个线程正好同时尝试将新元素追加到列表中,则会抛出ArrayIndexOutOfBoundsException

根据线程的交错情况,结果可能会有所不同。不能保证我们能得到任何结果。这些都是不线程安全的类或函数的症状,当从多个线程访问时可能不会正确运行。

那么,我们该如何修复这个潜在的错误行为呢?我们有三个选择:

  1. 不要在线程之间共享状态。

  2. 在线程之间共享不可变状态。

  3. 修改我们的实现,以便多个线程可以使用我们的类并获得可预测的结果。

处理某种线程安全的多种策略,每种策略都有其优势和注意事项,因此开发人员必须能够评估他们的选择,并选择最适合线程问题需求的选项。

第一个选项相对明显。当线程可以处理完全独立的数据集时,没有访问相同内存地址的风险。

第二个选项是使用不可变对象和集合。不可变性是设计健壮系统的一种非常有效的方式。如果一个线程无法改变对象,则根本没有风险从另一个线程读取不一致的状态。在我们的例子中,我们可以使列表不可变,但是线程将无法向其追加元素。这并不意味着这个原则不能在这里应用。事实上,它可以——但我们稍后会回到这一章。我们必须提到使用不可变性可能存在的潜在缺点。从本质上讲,由于对象复制,它需要更多的内存。例如,每当一个线程需要处理另一个线程的状态时,都会执行状态对象的复制。如果重复进行并且速度很快,不可变性可能会增加内存占用——这可能是一个问题(特别是在 Android 上)。

第三种选项可以描述为:“执行add方法的任何线程都会在其他线程的后续add访问之前发生。”换句话说,add访问是串行进行的,没有交错。如果你的实现强制执行了上述声明,那么就不会有线程安全问题——该类被称为线程安全的。在并发世界中,前述声明被称为不变量

不变量

要正确地使一个类或一组类成为线程安全的,我们必须定义不变量。不变量是一个始终为真的断言。无论线程如何调度,不变量都不应被违反。在我们的例子中,可以这样表达(从线程的角度来看):

当我执行add方法时,我获取列表的最后一个元素,并且当我将其追加到列表中时,我确保插入的元素比前一个元素大 1。

从数学上讲,我们可以写成:

l i s t [ n ] = l i s t [ n - 1 ] + 1

从一开始我们就看到我们的类不是线程安全的。现在我们可以这样说,因为在多线程环境中执行时,不变量有时会被违反,或者我们的程序会崩溃。

那么,我们可以做些什么来强制执行我们的不变量呢?实际上,这是一个复杂的问题,但我们将涵盖一些最常见的技术:

  • 互斥锁

  • 线程安全集合

互斥锁

互斥锁允许您防止对状态的并发访问,这个状态可以是一个代码块或者只是一个对象。这种互斥也称为synchronization。称为mutexlockObject保证了当它被一个线程拿走时,没有其他线程可以进入由此锁守护的区域。当一个线程试图获取另一个线程持有的锁时,它会被阻塞——直到锁被释放为止,它无法继续执行。这种机制相对容易使用,这也是开发人员在面对这种情况时经常采取的应对措施。不幸的是,这也像是打开了潘多拉魔盒,会引发死锁、竞态条件等问题。由于不正确的同步可能引发的问题太多,超出了本书的范围。然而,稍后在本书中我们将讨论其中一些,比如通信顺序进程中的死锁。

线程安全集合

线程安全集合是可以在多个线程访问时保持其状态一致的集合。Collections.synchronizedList是使List线程安全的一个有用方法。它返回一个List,该List包装对作为参数传递的List的访问,并使用内部锁调节并发访问。

乍一看,它看起来很有趣。因此,你可能会被诱惑使用它:

class A {
    var list =
        Collections.synchronizedList<Int>(object : ArrayList<Int?>() {
            init {
                add(1)
            }
        })

    fun add() {
        val last = list.last()
        list.add(last + 1)
    }
}

作为记录,这是 Java 中的等价物:

class A {
    List<Integer> list = Collections.synchronizedList(
        new ArrayList<Integer>() {{
           add(1);
        }}
    );

    void add() {
        Integer last = list.get(list.size() - 1);
        list.add(last + 1);
    }
}

这两种实现都存在问题。你能找出来吗?

注意

我们也可以将列表声明为:

var list: List<Int> = CopyOnWriteArrayList(listOf(1))

在 Java 中,它的等价物是:

List<Integer> list = new CopyOnWriteArrayList<>(Arrays.asList(1));

CopyOnWriteArrayListArrayList 的一个线程安全实现,在这个实现中,所有的改变操作如 addset 都通过创建底层数组的新副本来实现。线程 A 可以安全地遍历列表。如果与此同时,线程 B 向列表添加一个元素,则会创建一个新的副本,只对线程 B 可见。这本身并不使得这个类线程安全——因为 addset 是由锁保护的。这种数据结构在我们更频繁地迭代它而不是修改它时非常有用,因为复制整个底层数组可能代价太高。注意,还有一个 CopyOnWriteArraySet,它只是 Set 的实现而不是 List 的实现。

虽然我们确实解决了并发访问问题,但我们的类仍然不符合我们的不变量。在测试环境中,我们创建了两个线程并启动它们。每个线程在我们类的同一个实例上执行一次 add() 方法。第一次运行测试后,当这两个线程完成它们的工作时,结果的列表是 [1, 2, 3]。有趣的是,我们多次运行相同的测试,有时结果是 [1, 2, 2]。这是由于前面展示的确切原因导致的:当一个线程执行 add() 内的第一行时,另一个线程可以在第一个线程继续执行其余部分之前执行整个 add() 方法。看到同步问题有多么隐蔽:看起来很好,但我们的程序却是有问题的。即使在一个微不足道的示例上,我们也很容易搞错。

一个正确的解决方案是:

class A {
    val list: MutableList<Int> = mutableListOf(1)

    @Synchronized
    fun add() {
        val last = list.last()
        list.add(last + 1)
    }
}

可能看到 Java 等价的会有所帮助:

public class A {
    private List<Integer> list = new ArrayList<Integer>() {{
        add(1);
    }};

    synchronized void add() {
        Integer last = list.get(list.size() - 1);
        list.add(last + 1);
    }
}

正如你所看到的,我们实际上并不需要同步列表。相反,add() 方法应该被同步。现在当一个线程首次执行 add() 方法时,另一个线程尝试执行 add() 时会被阻塞,直到第一个线程离开 add() 方法。没有两个线程同时执行 add()。这样不变量就被尊重了。

这个示例展示了一个类在内部使用线程安全的集合,但本身并不是线程安全的情况。当一个类或者代码是线程安全的时候,它的不变量从未被违反。这些不变量以及如何根据创建者的要求使用类,定义了一个明确的策略,应该在 javadoc 中清晰地表达出来。

注意

这是 Java 内置的强制互斥机制。一个同步块由一个锁和一段代码块组成。在 Java 中,每个 Object 都可以用作锁。一个同步方法是一个其锁是类实例的同步块。当一个线程进入同步块时,它会获取锁。当线程离开块时,它释放锁。

还要注意,add 方法可以声明为使用 synchronized 语句的形式:

void add() {
    synchronized(this) {
        val last = list.last()
        list.add(last + 1)
    }
}

一个线程无法进入已经被另一个线程获取了的同步块。因此,当一个线程进入同步方法时,它会阻止其他线程执行任何同步方法或由此(也称为内部锁)保护的代码块。

线程限制

确保线程安全的另一种方法是确保只有一个线程拥有状态。如果状态对其他线程不可见,则根本不会出现并发问题的风险。例如,一个类的公共变量(其使用意图为线程限制到主线程)可能是错误的潜在来源,因为开发人员(不知道此线程策略)可能在另一个线程中使用变量。

线程限制的直接好处是简单性。例如,如果我们遵循每个View类型类应仅从主线程使用的约定,则可以避免在代码各处同步。但这是有代价的。客户端代码的正确性现在取决于使用我们代码的开发人员。在 Android 中,正如我们在上一章中看到的,应仅从 UI 线程操作视图。这是一种线程限制的形式——只要不违反规则,就不应出现涉及 UI 相关对象的并发访问问题。

另一个值得注意的线程限制形式是ThreadLocalThreadLocal实例可以看作是某个对象的提供者。该提供者确保对象的给定实例在每个线程中是唯一的。换句话说,每个线程都拥有自己的值的实例。使用示例如下:

private val myConnection =
        object : ThreadLocal<Connection>() {
            override fun initialValue(): Connection? {
                return DriverManager.getConnection(connectionStr)
            }
        }

常与不线程安全的 JDBC 连接一起使用,ThreadLocal确保每个线程将使用自己的 JDBC 连接。

线程争用

线程之间的同步很困难,因为可能会出现许多问题。我们刚刚看到了潜在的线程安全问题。还有一个可能影响性能的危险是线程争用,我们鼓励所有程序员都熟悉这一点。考虑以下例子:

class WorkerPool {
    private val workLock = Any() // In Java, we would have used `new Object()`

    fun work() {
        synchronized(workLock) {
            try {
                Thread.sleep(1000) // simulate CPU-intensive task
            } catch (e: Exception) {
                e.printStackTrace()
            }
        }
    }

    // other methods which may use the intrinsic lock
}

因此,我们有一个WorkerPool,它以这样的方式控制由工作线程完成的工作,以至于一次只能有一个工作线程在work方法内部执行真正的工作。当实际工作涉及使用非线程安全对象并且开发人员决定使用这种锁定策略时,您可能会遇到这种情况。专门为work方法创建了一个专用锁,而不是在this上同步,因为现在工作线程可以调用其他方法而无需互斥。这也是为什么锁以相关方法命名的原因。

如果启动了多个工作线程并调用此work方法,则它们将竞争相同的锁。最终,根据线程的交替执行,一个工作线程因为另一个工作线程已经获取了锁而被阻塞。如果等待锁的时间远远小于其余执行时间,这不是问题。如果情况不是这样,则存在线程争用。线程大部分时间都在等待其他线程。然后操作系统可能会主动暂停一些线程,以便处于等待状态的其他线程可以恢复执行,这使得情况变得更糟,因为线程之间的上下文切换并不是免费的。当频繁发生时,可能会导致性能影响。

作为开发者,您应该始终避免线程争用,因为它可能会迅速降低吞吐量,并且会影响超出受影响线程的后果,因为上下文切换的速率可能会增加,这本身就会影响整体性能。

避免这种情况的最有效方法之一是避免阻塞调用,这将在下一节中解释。

阻塞调用与非阻塞调用

到目前为止,我们知道当一个线程试图获取另一个线程持有的锁时,该线程可能会被阻塞。导致线程被阻塞的函数称为阻塞调用。即使锁可能会立即被获取,调用可能会潜在地阻塞也使其成为一个阻塞调用的案例。但这只是一个特例。实际上有另外两种方式来阻塞线程。第一种是运行 CPU 密集型计算,这也被称为CPU 绑定任务。第二种是等待硬件响应。例如,当网络请求导致调用线程等待远程服务器的响应时,我们称之为 IO 绑定任务。^(2)

使调用快速返回的所有其他操作都被视为非阻塞

当您准备进行阻塞调用时,应避免从主线程(也称为 UI 线程,在 Android 上)进行。^(3) 这是因为此线程运行处理触摸事件和所有与 UI 相关的任务如动画的事件循环。如果主线程重复被阻塞并超过几毫秒的持续时间,将会影响响应性,这是导致 Android“应用程序无响应”(ANR)错误的原因。

非阻塞调用是响应式应用程序的一个构建模块。现在,您需要识别利用这种技术的模式。工作队列是其中之一,在本书中我们将遇到各种形式的工作队列。

注意

在大多数情况下,同步异步 这两个术语分别被用作 阻塞非阻塞 的同义词。虽然它们在概念上是相近的概念,但例如,异步与非阻塞的使用取决于上下文。异步调用通常涉及回调的概念,而这对于非阻塞调用并非必要。

工作队列

线程间通信,尤其是从一个线程向另一个线程提交工作,在 Android 中被广泛使用。这是 生产者-消费者 设计模式的一种实现。在线程中应用这一模式时,生产者是生成需要由消费者线程进一步处理的数据的线程。与直接通过共享可变状态让生产者与消费者直接交互不同,在它们之间使用队列来入队由生产者生成的工作。这种方式将生产者与消费者解耦,但这不是它的唯一好处,我们将会看到更多。通常,Queue 以 FIFO(先进先出)的方式运作。^(4)

从语义上讲,将 Queue 想象成一个电影院观众的排队。当第一位观众到达时,他们排在队列的最前面。每位额外的观众都排在最后。当开放门户并允许观众进入时,队列中的第一人先入场,然后是下一个,依此类推,直到整个 Queue 清空。

生产者将对象放在队列的头部,消费者从队列的尾部弹出对象。put 方法可能是一个阻塞调用,但如果可以证明大部分时间它实际上不会阻塞(即使阻塞时也是短暂的),那么我们就有了一种非常有效的方式,可以以非阻塞的方式(从生产者的角度来看)将工作从生产者转移到消费者,如图 5-1 所示。

在实践中,入队的对象通常是由后台线程提交的 Runnable 实例,并由主线程处理。此外,这不限于一个生产者和一个消费者。多个生产者可以并发地向队列提交工作,多个消费者可以从队列中取出工作。这意味着队列必须是线程安全的。^(5)

pawk 0501

图 5-1. 生产者-消费者。
注意

不要混淆 QueueStack,后者使用 LIFO(后进先出)而不是 FIFO。

从语义上来说,让我们把 Stack 想象成一叠煎饼。当厨房做更多煎饼时,它们会放在栈的顶部。当食客吃煎饼时,他们也是从栈的顶部拿走的。

回压

现在想象一下,我们的生产者比消费者快得多。工作对象会在队列中积累起来。如果队列恰好是无界的,我们会冒着耗尽内存资源和潜在不可恢复异常的风险:应用程序可能会崩溃。这种不仅对用户体验不佳,而且在这种未处理的错误中,你几乎可以肯定会丢失存在的任何状态信息。除非你非常注意并且对此情况做出反应,否则可能会出现突然终止,而没有机会执行通常会执行的任何清理。在 Android 中,当 Bitmap 实例不再被使用时,可以使用 recycle 方法将每个底层内存分配标记为不可达并且有资格进行垃圾收集。在一个混乱的系统退出中,你可能没有机会这样做,并且可能会有泄漏数据的风险。

在这种情况下,使用有界队列是一个明智的选择。但是当队列已满并且生产者尝试put一个对象时应该发生什么?

我们将在使用协程时再回到这个问题,但现在我们只讨论线程,答案是:它应该阻塞生产者线程,直到消费者从队列中至少取出一个对象。虽然这种阻塞应该作为设计的一部分,并预见到可能导致用户到达程序中这一点的任何情况或逻辑分支。虽然阻塞线程似乎有害,但阻塞的生产者允许消费者赶上并释放足够的空间到队列中,以便释放生产者。

这种机制被称为回压——无法跟上传入数据的数据消费者减慢数据生产者的能力。这是设计强大系统的非常有效的方法。示例 5-1展示了回压的实现方式。

示例 5-1. 回压示例
fun main() {
    val workQueue = LinkedBlockingQueue<Int>(5)  // queue of size 5

    val producer = thread {
        while (true) {
            /* Inserts one element at the tail of the queue,
 * waiting if necessary for space to become available. */
            workQueue.put(1)
            println("Producer added a new element to the queue")
        }
    }

    val consumer = thread {
        while (true) {
            // We have a slow consumer - it sleeps at each iteration
            Thread.sleep(1000)
            workQueue.take()
            println("Consumer took an element out of the queue")
        }
    }
}

自从 Java 7 以来,专门用于此目的的队列家族是BlockingQueue——它是一个接口,实现从单端队列LinkedBlockingQueue到双端队列LinkedBlockingDequeue(还存在其他实现)。示例 5-1的输出如下:

Producer added a new element to the queue
Producer added a new element to the queue
Producer added a new element to the queue
Producer added a new element to the queue
Producer added a new element to the queue
Consumer took an element out of the queue
Producer added a new element to the queue
Consumer took an element out of the queue
Producer added a new element to the queue
...

您可以看到,生产者很快用五个元素填充了队列。然后,在第六次尝试添加新元素时,由于队列已满,它被阻塞。一秒钟后,消费者从队列中取出一个元素,释放了生产者,现在可以添加一个新元素。此时队列已满。生产者尝试添加新元素,但再次被阻塞。再过一秒,消费者再次取出一个元素——依此类推。

需要注意的是,向BlockingQueue插入元素并不一定会阻塞。如果使用put方法,则在队列满时会阻塞。由于put可能会阻塞,我们说这是一个阻塞调用。然而,还有另一种方法可以添加新元素:offer,它尝试立即添加新元素并返回一个布尔值——操作是否成功。由于offer方法不会阻塞底层线程,并且仅在队列满时返回 false,我们说offer是非阻塞的。

如果我们在示例 5-1 中使用了offer而不是put,那么生产者将永远不会被阻塞,并且输出将会被填充Producer added a new element to the queue。根本不会有任何背压 - 切记不要这样做!

offer方法在可以承受丢失工作或者阻塞生产者线程不合适的情况下非常有用。当从队列中取出对象时,使用takepoll同样适用,它们分别是阻塞和非阻塞的。

相反地,如果消费者比生产者更快,那么队列最终会变为空。在BlockingQueue的情况下,使用消费者端的take方法将会阻塞,直到生产者在队列中添加新元素。因此,在这种情况下,消费者会放慢速度以匹配生产者的速率。

总结

  • 当类或代码被称为线程安全时,它的不变性从不会被违反。因此,线程安全总是指一个应该在类的 javadoc 中明确定义的策略。

  • 一个类可以在内部使用线程安全的数据结构,但自身并不是线程安全的。

  • 尽量避免或减少线程争用。线程争用通常是糟糕的锁定策略的后果。降低这种风险的有效方法是尽可能使用非阻塞调用。

  • 工作队列是一个模式,你经常会在 Android 和其他平台(如后端服务)中遇到。它简化了生产者(如 UI 线程)将任务移交给消费者(后台线程)的方式。消费者在能够时处理任务。任务完成后,消费者可以使用另一个工作队列将其工作结果发送回原始生产者。

  • 有界的BlockingQueue在其已满时会阻塞put操作。因此,生产者速度过快会最终被阻塞,这为消费者赶上提供了机会。这是背压的一种实现,它有一个主要的缺点:生产者线程可能会被阻塞。有没有可能在不阻塞生产者线程的情况下实现背压?是的——我们将在第九章中看到这一点。

^(1) 实际上,线程的交错可以发生在字节码的行之间,而不仅仅是在普通 Java 行之间。

^(2) IO 操作并不一定是阻塞的。非阻塞 IO 存在,尽管它要复杂得多才能理解。Android 链接足够友好,当您在主线程上执行 HTTP 请求时会提醒您,但其他 IO 任务——如读取文件或查询数据库——则不会。如果在极其深思熟虑和小心的监督下进行,这甚至可能是一种故意且被接受的做法;虽然可能,但这应该是标准的罕见例外。

^(3) 即使对于工作线程,执行像处理 800 万像素图片这样的长时间任务,那些阻塞调用可能会阻塞 UI 正在等待的任务包。

^(4) 虽然并非所有工作队列都使用这种数据结构排列。一些更复杂的例子,如 Android 的 MessageQueue

^(5) 即使只有一个生产者和一个消费者,队列也必须是线程安全的。

第六章:使用回调处理并发

在 Kotlin 中处理并发的惯用方式是使用协程。然而,Java 中一直以来都是使用线程和回调。那么,我们为什么需要协程呢?

为了回答这个问题,我们将重新审视 Android 上典型的 Kotlin 实现,并讨论使用线程的缺点。了解传统方法的弱点是理解协程设计动机的关键。

在 Android 应用中,不应该在 UI 线程上执行长时间运行的任务,正如你在前一章中所见。如果阻塞主线程——UI 线程——你的应用可能无法获得绘制屏幕或适当更新的资源。实际上,如果尝试在 UI 线程上执行明显的 IO 调用(例如建立 HTTP 连接),lint 会抱怨。

当主线程在大多数设备上的 16ms 帧时间内完成所有任务时,Android 应用程序可以平稳运行。这是相当短的时间,所有阻塞调用,如网络请求(阻塞 IO),都应该在后台线程上执行。^(1)

当你将任务委托给另一个线程时,通常会调用一个启动异步任务的函数。在某些情况下,这是“发射并忘记”,但通常你正在等待结果——并且需要对其进行操作。这是通过提供一个在作业完成时将被调用的函数来完成的。这个函数称为回调。回调通常接受参数,因此后台线程通常使用作业结果调用回调。在完成时调用任意或注入函数进行计算被称为回调模式

使用回调是相当高效的,尽管它也有一些限制和缺点。为了说明这一点,我们将在 Kotlin 中实现一个简单而现实的例子。协程解决了所有与回调相关的问题,但在直接使用协程之前,理解它们旨在解决的问题非常重要。

付费功能示例

假设你正在开发一个 Android 应用的付费功能。用户注册后,你会检查该用户已经购买的商品列表,并据此进行操作。为了获取购买列表,让我们使用一个名为BillingClient的对象。请注意,我们讨论的不是 Android 框架提供的实际BillingClient,即com.android.billingclient.api.BillingClient。我们使用的是我们自己简化版本的基本概念,如下面的代码所示:

interface BillingClient {
    fun interface BillingCallback {
        fun onInitDone(provider: PurchasesProvider?)
    }

    /* Implementations should be nonblocking */
    fun init(callback: BillingCallback)
}

典型的任务流程如下:

  1. 初始化连接到BillingClient。等待它准备就绪——你的回调会提供一个PurchasesProvider,或者在出错时提供 null。目前,我们不处理错误。

  2. 使用返回的PurchasesProvider来异步获取用户的购买列表。您的程序将等待响应,其中包含购买列表和可能的其他元数据。

  3. 对这些新信息做出反应;您可以显示一个购买列表的 UI,以提供更多细节,或请求状态、取消订单中的项目等等。

以后的参考,我们将称之为前面的流程为我们的逻辑

如您所见,这只是一个接口,有一个方法,接受BillingCallback作为输入。BillingCallbackBillingClient接口内声明,因为此回调仅在BillingClient内部使用。当接口声明在类或接口内部时,它告诉您类和接口之间的关系:作者打算类不依赖于其他实体来提供接口。这避免了类和接口之间兼容性破坏的风险。这两者是耦合的,如果您发布一个BillingClient,您也会发布一个BillingCallback。请注意,我们正在使用 Kotlin 1.4 的新fun interface,而不是传统的interface。这将允许在提供实现时使用简洁的语法。还有,init方法的文档说明实现应该是非阻塞的。如果您没有阅读前一章节,这意味着无论哪个线程调用此方法,它都不会被阻塞等待方法返回。

同样,我们的PurchasesProvider如下代码所示:

interface PurchasesProvider {
    fun interface PurchaseFetchCallback {
        fun onPurchaseFetchDone(purchases: List<String>)
    }

    fun fetchPurchases(user: String, callback: PurchaseFetchCallback)
}

现在,让我们假设我们提供了这些抽象及其实现。尽管真实的应用程序会使用框架提供的类,但这个示例的重点是业务逻辑,而不是BillingClientPurchasesProvider的实现。

作为 Android 开发者,希望您熟悉 Android Jetpack 的ViewModel的核心概念,但如果不是这样,也不用担心,因为ViewModel的操作细节不是本讨论的重点。即使没有ViewModel,您可能已经有了某个版本的 MVC、MVP 或 MVVM,它们基本上都遵循相同的模式。视图负责展示工作,模型负责逻辑工作,控制器或视图模型是连接它们的粘合剂,充当允许两者通信的网络。重要的是视图模型内部的逻辑实现。其他都是上下文或框架代码,但仍然非常重要。图 6-1 显示了目标架构。

pawk 0601

图 6-1. MVVM 架构。

现在假设您已经按照单活动架构组织了应用程序。视图应该是一个显示当前用户购买的片段。在设计中应考虑片段的生命周期。任何时候,设备都可能旋转,并重新创建片段。用户可以返回,并且如果未被销毁,片段可以被放入后退堆栈中。

这就是LiveData,一个感知生命周期的组件,发挥作用的地方。每次创建片段时,它请求一个PurchaseViewModel的实例。稍后我们将详细解释它的工作原理。

创建应用程序

在本节中,我们将向您展示在 Android 应用程序内的典型实现。如果您已经熟悉此内容,可以直接跳转到下一节,其中我们讨论逻辑的实现。

视图模型

所以业务逻辑是在ViewModel内部实现的(参见示例 6-1)。视图模型需要通过某个其他组件进行构造注入,稍后您将看到。BillingClientViewModel的一个依赖项,而PurchaseProviderBillingClient的一个依赖项。

与此ViewModel交互的视图触发getUserPurchases方法(我们尚未实现)以获取purchasesLiveData属性的 getter。您可能已经注意到,purchasesLiveData属性的类型是LiveData,而私有后备属性_purchasesMutableLiveData。这是因为ViewModel应该是唯一更改LiveData值的组件。因此,向此ViewModel的客户端公开的类型仅为LiveData,如示例 6-1 所示。

示例 6-1. PurchasesViewModel
class PurchasesViewModel internal constructor(
    private val billingClient: BillingClient,
    private val user: String
) : ViewModel() {
    private var _purchases = MutableLiveData<UserPurchases>()

    private fun getUserPurchases(user: String) {
        // TODO: implement
    }

    val purchasesLiveData: LiveData<UserPurchases>
        get() {
            getUserPurchases(user)
            return _purchases
        }

    interface BillingClient { /* removed for brevity*/ }

    interface PurchasesProvider { /* removed for brevity*/ }
}

我们差不多完成了,现在我们缺少的只是视图。

视图

在我们的架构中,视图是一个Fragment。如下面的代码所示,视图依赖于视图模型。这展示了我们如何从视图内部使用视图模型:

class PurchasesFragment : Fragment() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        /* Create a ViewModel the first time this Fragment is created.
         * Re-created Fragment receives the same ViewModel instance after
         * device rotation. */
        val factory: ViewModelProvider.Factory = PurchaseViewModelFactory() ![1](https://github.com/OpenDocCN/ibooker-mobi-zh/raw/master/docs/prog-andr-kt/img/1.png)
        val model by viewModels<PurchasesViewModel> { factory }             ![2](https://github.com/OpenDocCN/ibooker-mobi-zh/raw/master/docs/prog-andr-kt/img/2.png)
        model.purchasesLiveData.observe(this) { (_, purchases) ->           ![3](https://github.com/OpenDocCN/ibooker-mobi-zh/raw/master/docs/prog-andr-kt/img/3.png)
            // update UI
            println(purchases)
        }
    }
}

每次创建片段时,它都订阅UserPurchases的更新,遵循以下三个步骤:

1

ViewModel 创建一个工厂(请记住,ViewModel 有依赖项,Fragment 并不负责提供它们)。严格来说,这个工厂不应该在片段内创建,因为工厂现在与你的片段紧密耦合——PurchasesFragment 总是使用 PurchaseViewModelFactory。在测试环境中,你应该独立测试视图,这可能会成为一个问题。因此,应该通过依赖注入框架或手动注入将该工厂注入到 Fragment 内。为了简单起见,我们决定在此片段内创建它。可以参考 示例 6-2 中的 ViewModel 工厂。

2

viewModels 函数获取 PurchasesViewModel 实例。这是获取 ViewModel 实例的推荐方式。

3

最后,从 ViewModel 中获取一个 LiveData 实例,并通过同名方法(“observe”)由一个 Observable 实例进行观察。在这个例子中,观察者只是一个打印购买列表到控制台的 lambda 函数。在生产应用程序中,通常会触发更新与片段中的所有相关视图。

ViewModel 还有自己的生命周期,这取决于 ViewModel 是绑定到片段还是活动。在这个例子中,它绑定到了一个片段。你可以通过使用 by viewModels<..> 来确认。如果我们使用了 by activityViewModels<..>,则该视图模型将绑定到活动上。

当绑定到片段时,ViewModel 在设备旋转时会保留,但在不再使用时会被销毁(例如,与其绑定的所有片段都被销毁时,除了设备旋转)。如果 ViewModel 绑定到活动上,它将在设备旋转时保留,但在销毁活动的其他情况下会被销毁。

警告

由于 ViewModel 通过配置更改而保留,并在销毁并重新创建包含的活动时,它不应引用视图、Lifecycle 实例或任何可能持有活动上下文引用的类实例。但是,它可以引用 Application 上下文。

如果你查看BillingClient的实际代码,你会发现创建BillingClient.Builder需要提供一个上下文。它可以是活动上下文,因为在内部构建器调用context.getApplicationContext()时,这是BillingClient保留的唯一上下文引用。ApplicationContext在整个应用生命周期内保持不变。因此,在应用程序的某个地方引用ApplicationContext不会导致内存泄漏。这就是在ViewModel内部引用BillingClient是安全的原因。

如 示例 6-2 所示,ViewModel的依赖关系是在PurchaseViewModelFactory内创建的。

示例 6-2. PurchaseViewModelFactory
class PurchaseViewModelFactory : ViewModelProvider.Factory {
    private val provider: PurchasesProvider = PurchasesProviderImpl()
    private val billingClient: BillingClient = BillingClientImpl(provider)
    private val user = "user" // Get in from registration service

    override fun <T : ViewModel?> create(modelClass: Class<T>): T {
        if (modelClass.isAssignableFrom(PurchasesViewModel::class.java)) {
            return PurchasesViewModel(billingClient, user) as T
        }
        throw IllegalArgumentException("Unknown ViewModel class")
    }
}

BillingClientImpl是之前展示的BillingClient接口的实际实现(参见 示例 6-3 和 示例 6-4)。

示例 6-3. BillingClientImpl
class BillingClientImpl(private val purchasesProvider: PurchasesProvider) : BillingClient {
    private val executor =
        Executors.newSingleThreadExecutor()

    override fun init(callback: BillingCallback) {
        /* perform asynchronous work here */
        executor.submit {
            try {
                Thread.sleep(1000)
                callback.onInitDone(purchasesProvider)
            } catch (e: InterruptedException) {
                e.printStackTrace()
            }
        }
    }
}
示例 6-4. PurchasesProviderImpl
class PurchasesProviderImpl : PurchasesProvider {
    private val executor =
        Executors.newSingleThreadExecutor()

    override fun fetchPurchases(
        user: String,
        callback: PurchaseFetchCallback
    ) {
        /* perform asynchronous work */
        executor.submit {
            try {
                // Simulate blocking IO
                Thread.sleep(1000)
                callback.onPurchaseFetchDone(
                    listOf("Purchase1", "Purchase2")
                )
            } catch (e: InterruptedException) {
                e.printStackTrace()
            }
        }
    }
}

为了符合我们所建立的应用设计,initfetchPurchases方法应该是非阻塞的。这可以通过后台线程实现。出于效率考虑(参见即将到来的部分),你可能不想每次连接到BillingClient时都创建一个新线程。而是可以使用线程池,可以通过java.util.concurrent.Executors的工厂方法直接创建,或者通过ThreadPoolExecutor实例创建许多常见的配置。使用Executors.newSingleThreadExecutor(),你可以拥有一个单独的专用线程,可以在每次异步调用时重复使用。你可能认为PurchasesProviderImplBillingClientImpl应该共享同一个线程池。这取决于你——尽管出于简洁起见,我们在这里没有这样做。对于生产应用程序,可能会有多个ThreadPoolExecutor来服务应用程序的不同部分。

如果你查看这些实现中如何使用回调函数,你会发现它们会在Thread.sleep()之后立即调用(这模拟了阻塞 IO 调用)。除非显式地发布到主线程(通常通过Handler类的实例或通过LiveData实例的postValue方法),否则回调函数将在后台线程的上下文中被调用。这一点至关重要,了解如何在线程上下文之间进行通信非常重要,这将在下一节中介绍。

警告

注意提供的回调函数在哪个线程上运行,这取决于实现方式。有时回调函数在调用线程上异步执行,而有时会同步在后台线程执行。

实现逻辑

现在所有必要的组件都已经就位,可以实现逻辑。步骤如 示例 6-5 所示。

示例 6-5. 逻辑
private fun getUserPurchases(user: String) {
   billingClient.init { provider ->                   ![1](https://github.com/OpenDocCN/ibooker-mobi-zh/raw/master/docs/prog-andr-kt/img/1.png)
       // this is called from a background thread
       provider?.fetchPurchases(user) { purchases ->  ![2](https://github.com/OpenDocCN/ibooker-mobi-zh/raw/master/docs/prog-andr-kt/img/2.png)
           _purchases.postValue(UserPurchases(user, purchases))
       }
   }
}

1

调用billingClient.init并提供一个回调,该回调将在客户端初始化过程完成时调用。如果客户端提供了非空的PurchasesProvider实例,则继续下一步。

2

此时,您已经准备好使用PurchasesProvider实例。调用fetchPurchases,将当前用户作为第一个参数提供,并提供应该在提供程序完成其工作后执行的回调。仔细查看回调内容:

_purchases.postValue(UserPurchases(user, purchases))

MutableLiveData实例上,您可以使用setValuepostValue方法。两者之间的区别在于,只有在从主线程调用时才允许使用setValue。当情况不是这样时,使用postValue将新值添加到MutableLiveData将在主线程的下一帧处理的队列中。这是工作队列模式的一种实现(见“工作队列”),也是将新值安全地分配给MutableLiveData的线程安全方式。

讨论

就是这样。它有效果——或者至少满足了规格要求。我们邀请您稍微退后一步,看看整体情况。getUserPurchases 的结构是什么?它由一个函数调用构成,该调用提供另一个函数,该函数本身又调用一个函数,而该函数提供另一个函数…… 就像俄罗斯套娃一样。这已经有点难以跟踪了,添加异常处理可能很快就会变成“嵌套地狱”(见图 6-2)。为了保持我们示例逻辑的简单和易于跟踪,我们省略了一些 API 调用失败的边界情况;例如,网络问题或授权错误使得某些后台 IO 工作变得脆弱且容易失败,生产代码应该能够处理这些情况。

pawk 0602

图 6-2. 回调使用。

在第一个回调函数的代码中包含了对BillingClient的响应处理(回调 2)。如果您决定内联所有这些代码,就像我们在示例 6-5 中所做的那样,您会有几个级别的缩进,随着要解决的问题变得更加复杂,缩进水平会迅速增加。另一方面,如果您决定将第一个回调函数封装到自己的函数中,您确实会减少getUserPurchases的缩进级别及其表面复杂性。与此同时,您会增加完全理解业务逻辑所需的方向数量。

使用回调函数的第一个缺点是它迅速变得复杂,如果不小心和思维设计不周,可能难以维护。有些人认为即使采取了谨慎的预防措施,这条路也很危险。作为开发者,我们努力创建一个我们和同事能够处理的系统。

注意

使用 CompletableFuture 或类似的库(如 RxJava),可以这样重写 getUserPurchases

private void getUserPurchases(String user) {
    billingClient.initAsync()
    .thenCompose { provider ->
        fetchPurchasesAsync(provider, user)
    }
    .thenAccept { purchases ->
        this.purchases.postValue(...)
    }
}

这样做更清晰,没有嵌套缩进,甚至可以正确处理异常。然而,可以看到它依赖于thenComposethenAccept这两个组合器,它们操作CompletableFuture<T>。虽然我们的简单示例只使用了两个组合器,但存在许多组合器,每个组合器都有特定的目的。有些人可能认为另一种不熟悉的模式和 API 的学习曲线是这种模式的一个弱点。

结构化并发

现在想象一下,某些 API 调用在计算上是相当昂贵的。例如,您的应用程序用户导航到触发其中一些 API 调用的视图,但由于内容并非即时加载,他们失去耐心并点击返回,并在应用程序的另一部分开始新的操作系列。在这种情况下,您不希望昂贵的 API 调用继续运行,因为它们可能对后端服务器或应用程序本身造成不必要的负载。此外,当回调触发时应该更新的 UI 不存在会发生什么?NullPointerException 可能是最好的情况,内存泄漏则是最坏的情况。相反,让我们取消在视图模型内初始化的过程。您该如何做到这一点?您必须监听片段生命周期终止事件的特定生命周期事件:onStoponPauseonDestroy。在这种特定情况下,您可能希望在 onStop 中执行此操作,这会在资源被回收之前触发。onPause 在应用程序转到后台以便接听电话或在应用程序之间切换时触发,而 onDestroy 比我们需要的稍晚一些。当 onStop 事件触发时,您应该通知视图模型应停止任何后台处理。这需要一种线程安全的方法来中断线程。在回调中检查一个易失性的 isCancelled 布尔值,以决定它们是否应该继续进行。所以这是可行的,但又繁琐又脆弱。

如果自动取消会怎样?想象一下,后台处理与视图模型的生命周期绑定。视图模型被销毁的瞬间,所有后台处理都会被取消。这并不是童话——它甚至有一个名字:结构化并发

内存泄漏

自动取消悬空的后台线程还有另一个好处:减少内存泄漏的风险。回调可能会持有一个对具有生命周期或是另一个组件子组件的引用。如果这个组件有资格进行垃圾回收,而在某个运行中的线程中仍然存在对该组件的引用,则内存无法被回收,就会造成内存泄漏。像前面示例中使用 LiveData 一样即使不取消后台任务也是安全的。然而,总体而言,让任务无端运行永远都不是好事。

取消并不是唯一可能出错的事情。使用线程作为异步计算的基元(我们将其称为线程模型)还有其他一些陷阱,我们将在下一节中讨论它们。

线程模型的限制

在 Android 应用程序中,进程和任务总是在竞争内存。对于只有一个主线程或 UI 线程的情况,聪明的 Android 开发者必须找到有效操作和处理线程的方法。

当使用单个线程时,异步任务被转移到该线程时会串行执行——一个接着一个。如果其中一个任务执行时间过长,剩余的工作无法处理,如图 6-3 所示。

Blocking Work

图 6-3. 任务在线程内串行执行。

在可能需要长时间执行的后台任务中,您需要超过一个后台线程。ThreadPoolExecutor原语允许您启动多个线程,并将工作块投入其中以执行,如图 6-4 所示。

ThreadPoolExecutor

图 6-4. 一个ThreadPoolExecutor负责处理所有繁重的工作,包括启动线程、在这些线程之间负载均衡工作,甚至结束这些线程。

然而,并不是一直拥有更多的线程就是好事。以下是一些注意事项:

  • CPU 只能同时执行一定数量的线程。

  • 就内存而言,线程本身是昂贵的——每个线程至少消耗 64 Kb 的 RAM。

  • 当 CPU 核心从一个线程切换到另一个线程时,会发生线程上下文切换。^(4) 这些切换不是免费的。当您有几个线程时不是问题,但是如果您不断添加更多线程,线程上下文切换的影响可能会显著。您可能会发现,实际上比使用较少线程时更慢。

总结

  • 您可以使用回调函数实现异步逻辑。您还可能希望查看一些其他相关的框架 API,如HandlerHandlerThread。使用回调可能会导致复杂的嵌套函数调用,或者逻辑流在多个类中分割并且难以跟踪。如果这变得成问题,一种解决方法是依赖CompletableFuture或类似的 API;第三方框架 RxJava 具有这种功能,但需要学习另一组可能快速耦合到业务逻辑中并改变您编写应用程序代码方式的 API。

  • 大多数情况下,异步逻辑涉及检索和操作数据,然后将其渲染为屏幕上的视图实例。出于这个目的,Android Jetpack 的ViewModel提供了生命周期感知组件,帮助您编写更有组织性和可维护性的代码。

  • 当组件达到其生命周期的末端时,很可能应该取消一些相关的后台任务;否则,它们将消耗内存并增加内存泄漏或甚至应用崩溃的风险。结构化并发是解决这个问题的理想方案,我们将在下一章讨论它。

  • 使用线程作为并发原语有其局限性。您需要确保不要创建过多的线程,因为它们会消耗大量内存,并且由于过多的线程上下文切换可能会影响性能。

协程的目的是解决线程模型的限制。接下来的四章——重点介绍协程、结构化并发、通道和流——是本书的“高峰”,突显了 Kotlin 为 Android 开发者在控制异步计算方面提供的真正优势。

^(1) 使用java.nio.channels.SocketChannel进行非阻塞 IO 可以在 UI 线程上执行而不会阻塞它。然而,大多数情况下在处理 IO 时,您将使用像java.io.InputStream这样的阻塞 API。

^(2) 单个活动和多个片段。

^(3) 开发到接口而不是实际实现,可以提高代码的可测试性和可移植性。在测试环境中,您可以用自定义模拟的依赖项替换实际的实现。所谓可移植性,假设您有一个名为AnalyticsManager的接口,提供一些方法,您将实现这些方法以通知您的分析服务。考虑到一个功能强大的具有仪表板和大量数据可视化与授权的分析 SaaS 本身就已经是一个重大挑战,大多数应用开发者会借助第三方库来处理他们的流程中的这一部分。例如,如果您从一个提供者切换到另一个提供者,只要您组织您的交互以匹配AnalyticsManager接口,您的客户端代码就不会受到影响、变更或潜在地引入新的错误;更新的只是AnalyticsManager实现的业务逻辑。

^(4) 线程切换涉及保存和加载 CPU 寄存器和内存映射。

第七章:协程概念

在前一章中,您了解了线程模型的缺陷。作为线程模型的替代方案,Kotlin 语言引入了一个名为kotlinx.coroutines的库,旨在解决先前提到的限制。协程使开发人员能够以低成本编写顺序、异步的代码。协程的设计包括挂起函数结构化并发以及其他特定考虑因素,如协程上下文协程作用域。这些主题彼此紧密相关。我们将逐步和易于理解地介绍每一个考虑因素。

什么是协程?

官方的 Kotlin 文档将协程描述为“轻量级线程”,旨在利用现有且众所周知的范式。你可以将协程理解为可以分派到非阻塞线程的代码块

协程确实是轻量级的,但重要的是要注意协程本身并不是线程。事实上,许多协程可以在单个线程上运行,尽管每个协程都有自己的生命周期。在本节中,您将看到它们实际上只是状态机,每个状态对应于某个线程最终执行的代码块。

注意

你可能会惊讶地发现,协程的概念可以追溯到上世纪 60 年代早期的 Cobol 编译器创建时,它使用了在汇编语言中挂起和启动函数的想法。协程还可以在 Go、Perl 和 Python 等语言中找到。

协程库提供了一些管理这些线程的功能。但是,如果需要,您可以配置协程构建器来自行管理线程。

您的第一个协程

在本节中,我们将介绍来自kotlinx.coroutines包的许多新术语和概念。为了使学习顺畅,我们选择从简单的协程使用开始,并逐步解释其工作原理。

下面的示例以及本章中的其他示例,使用了kotlinx.coroutines包中声明的语义。

fun main() = runBlocking {
    val job: Job = launch {
        var i = 0
        while (true) {
            println("$i I'm working")
            i++
            delay(10)
        }
    }

    delay(30)
    job.cancel()
}

runBlocking方法运行一个新的协程,并阻塞当前线程,直到协程的工作完成。这个协程构建器通常用于主函数和测试中,因为它可以作为常规阻塞代码的桥梁。

在代码块内部,我们使用launch函数创建一个协程。由于它创建了一个协程,它是一个协程构建器 ——稍后你会看到其他协程构建器的存在。launch方法返回一个Job的引用,代表了启动的协程的生命周期。

在协程内部,有一个无限执行的while循环。在job协程下方,您可能会注意到稍后会取消job。为了展示这意味着什么,我们可以运行我们的程序,输出如下:

0 I'm working
1 I'm working
2 I'm working

看起来协程像钟表一样运行。同时,代码继续在主线程中执行,在由 delay 调用给出的 30 毫秒窗口内打印了三行,如 图 7-1 所示。

第一个协程

图 7-1. 第一个协程。

delay 函数在使用上看起来非常像 Thread.sleep。主要区别在于 delay非阻塞 的,而 Thread.sleep(...)阻塞 的。为了说明我们的意思,让我们再次检查我们的代码,但用 Thread.sleep 替换协程中的 delay 调用:

fun main() = runBlocking {
    val job: Job = launch {
        while (true) {
            println("I'm working")
            Thread.sleep(10L)
        }
    }

    delay(30)
    job.cancel()
}

观察当我们再次运行代码时会发生什么。我们得到以下输出:

I'm working
I'm working
I'm working
I'm working
I'm working
I'm working
I'm working
I'm working
I'm working
I'm working
I'm working
.....

现在输出看起来是无限的。当协程执行时,Thread.sleep(10L) 调用会阻塞主线程,直到由 launch 启动的协程完成。由于由 launch 启动的协程使主线程休眠或打印,协程永远不会完成,因此执行永远不会离开协程,如 图 7-2 所示。

永远运行的程序

图 7-2. 永远运行的程序。

记住以下几点很重要:

  • launch 协程构建器是“发射并忘记”工作—换句话说,没有结果可返回。

  • 一旦调用,它立即返回一个 Job 实例,并启动一个新的协程。Job 代表协程本身,就像其生命周期的句柄一样。可以通过在其 Job 实例上调用 cancel 方法来取消协程。

  • launch 启动的协程将不会返回结果,而是返回对后台作业的引用。

反之,如果需要从异步计算中获取结果,则应使用 async 协程构建器。

async 协程构建器

async 协程构建器可以与 Java 的 Future/Promise 模型进行比较,以支持异步编程:

class WorkingClass() {
    public CompletableFuture<SomeOtherResult> doBothAsync() {
        somethingAsync().thenAcceptBoth(somethingElseAsync()) {
            one, two ->
            // combine results of both calls here
        };
    }
}

与进行阻塞调用以获取数据不同,异步函数立即返回一个结果的包装器。根据使用的库不同,这个包装器称为 FutureCompletableFuturePromise 等。这个包装器就像一个句柄,您可以从中检查结果是否可用。如果需要,您可以使用 Future.get() 方法阻塞线程,直到结果可用。

就像 Future 一样,async 协程构建器 返回一个围绕结果的包装器;而这个包装器的类型是 Deferred<T>(泛型类型是结果的类型),如下面的代码所示:

fun main() = runBlocking {
    val slow: Deferred<Int> = async {
        var result = 0
        delay(1000)   // simulate some slow background work
        for (i in 1..10) {
            result += i
        }
        println("Call complete for slow: $result")
        result
    }

    val quick: Deferred<Int> = async {
        delay(100)   // simulate some quick background work
        println("Call complete for quick: 5")
        5
    }

    val result: Int = quick.await() + slow.await()
    println(result)
}

数据类型 quickslow 是作为 Deferred<Int> 实现的未来结果,也称为带有结果的 Job。通过在每个 Deferred<Int> 实例上调用 await 方法,程序等待每个协程的结果。

这一次,我们使用 async 协程构建器启动了两个协程。代码本身可以让我们猜到可能会发生什么,但无论如何,让我们运行它看看以下输出:

Call complete for quick: 5
Call complete for slow: 55
60

前面的程序通过 1,000 毫秒延迟了慢的 async 作业,而快的 async 作业只延迟了 100 毫秒 — result 等待两者完成后才输出结果。

记住以下几点是很重要的:

  • async 协程构建器旨在并行分解工作 — 也就是说,您显式指定了某些任务将同时运行。

  • 一旦调用,async 立即返回一个 Deferred 实例。Deferred 是一个特殊的 Job,带有一些额外的方法如 await。它是一个带有返回值的 Job

  • FuturePromise 非常类似,您需要在 Deferred 实例上调用 await 方法以获取返回值。^(2)

您可能已经注意到,与协程构建器 launchasync 一起提供的示例被 runBlocking 调用包装。我们之前提到 runBlocking 运行一个新的协程,并阻塞当前线程,直到协程工作完成。要更好地理解 runBlocking 的角色,我们必须首先提前预览结构化并发,这是一个将在下一章节详细探讨的概念。

有关结构化并发的快速介绍

协程不仅仅是另一种启动后台任务的花哨方式。协程库围绕结构化并发范式构建。在继续探索协程之前,您应该理解它是什么,以及协程库旨在解决的问题。

让开发变得更容易是一个值得追求的目标。在结构化并发的情况下,这几乎是对更普遍问题的一种愉快副作用的响应。考虑每个开发者都熟悉的最简单的构造:一个函数。

函数在执行上是可预测的,因为它们是从上到下执行的。如果我们忽略函数内部可能抛出异常的可能性,^(3) 我们知道在函数返回值之前,执行顺序是串行的:每个语句在下一个语句之前执行。如果在函数内部创建并启动了另一个线程呢?这是完全合法的,但现在您有两个执行流,如 图 7-3 所示。

两个流

图 7-3. 两个流。

调用此函数不仅会产生一个结果;它还会产生一个并行执行的流。这可能会因以下原因而成为问题:

异常不会传播

如果线程内部抛出异常并且未处理,那么 JVM 将调用线程的 UncaughtExceptionHandler,这是一个简单的接口:

interface UncaughtExceptionHandler {
    fun uncaughtException(t: Thread, e: Throwable)
}

您可以使用Thread.setUncaughtExceptionHandler方法为您的线程实例提供处理程序。默认情况下,当您创建线程时,它没有特定的UncaughtExceptionHandler。当异常未被捕获时,并且您没有设置特定的异常处理程序时,将调用默认处理程序。

在 Android 框架中,需要注意,默认的UncaughtExceptionHandler将导致您的应用崩溃并终止应用的本机进程。Android 设计者之所以做出这样的选择,是因为对于 Android 应用来说,快速失败通常更好,因为系统不应代表开发者做出关于未处理异常的决定。堆栈跟踪对于真正的问题是相关的——虽然从中恢复可能会产生不一致的行为和问题,因为根本原因可能在调用堆栈中较早的位置。

在我们的示例中,没有东西可以通知我们的函数如果后台线程发生了错误。有时这很好,因为错误可以直接从后台线程处理,但您可能有更复杂的逻辑,需要调用代码监控问题以不同和特定的方式做出反应。

提示

在默认处理程序被调用之前涉及一个机制。每个线程可以属于一个ThreadGroup,可以处理异常。每个线程组还可以有一个父线程组。在 Android 框架中,有两个静态创建的组:“system”和“main”的子组。“main”组总是将异常处理委托给“system”组的父组,如果不为空,则委托给Thread.getDefaultUncaughtExceptionHandler()。否则,“system”组将异常名称和堆栈跟踪打印到System.err

执行流程难以控制

由于线程可以从任何地方创建和启动,想象一下,您的后台线程实例化并启动了三个新线程来委托一些工作,或者在父线程上下文中对计算的反应进行任务,如图 7-4 所示。

多个流

图 7-4. 多个流。

如何确保函数只有在所有后台处理完成后才返回?这可能出错:你需要确保等待所有子线程完成它们的工作。^(4) 当使用基于Future的实现(例如CompletableFuture)时,即使省略了Future.get的调用,也可能导致执行流程过早终止。

后来,当后台线程及其所有子线程仍在运行时,所有这些工作可能需要被取消(用户退出 UI,抛出错误等)。在这种情况下,没有自动机制来取消整个任务层次结构。

当处理线程时,很容易忘记一个后台任务。结构化并发不过是一个旨在解决这个问题的概念

在接下来的部分中,我们将详细解释这一概念,并说明它与协程的关系。

结构化并发中的父子关系

到目前为止,我们已经讨论了线程,这些线程在之前的示例中用箭头表示。让我们想象一个更高层次的抽象,其中某个父实体可以创建多个子级,如图 7-5 所示。

Parent-Child

图 7-5. 父子关系。

这些子级可以与彼此以及父级并发运行。如果父级失败或被取消,则所有子级也将被取消。^(5) 这就是结构化并发的第一条规则:

  • 取消始终向下传播。
Tip

一个子级的失败如何影响同级别的其他子级,这是父级的参数化。

就像父实体可能会失败或被取消一样,任何子级都可能会发生这种情况。在其中一个子级取消的情况下,参照第一条规则,我们知道父级不会被取消(取消向下传播,而不是向上)。在失败的情况下,接下来会发生什么取决于你试图解决的问题。一个子级的失败应该或不应该导致其他子级的取消,如图 7-6 所示。这两种可能性描述了父子失败关系,并且是父级的参数化。

Cancellation policy

图 7-6. 取消策略。
Tip

父级始终等待所有子级完成。

可以添加其他关于异常传播的规则,但这些规则会依赖于具体的实现,现在是时候介绍一些具体的例子了。

结构化并发在 Kotlin 协程中使用CoroutineScopeCoroutineContext。在之前的示例中,CoroutineScopeCoroutineContext都扮演了父级的角色,而协程则扮演了子级的角色。

在接下来的部分中,我们将更详细地介绍CoroutineScopeCoroutineContext

CoroutineScope 和 CoroutineContext

我们即将深入讨论kotlinx.coroutine库的细节。在即将到来的部分中会有a lot的新概念。虽然这些概念对于掌握协程非常重要,但你现在并不需要完全理解所有内容,只要开始使用协程并提高生产力。接下来和下一章节会有许多示例,这些示例将帮助你更好地理解协程的工作方式。因此,你可能会发现,在练习一段时间后再回顾这部分会更容易理解。

现在你对结构化并发有了一个概念,让我们再次回顾整个runBlocking的事情。为什么不直接调用launchasync而不是在runBlocking调用之外呢?

下面的代码将无法编译:

fun main() {
   launch {
       println("I'm working")       // will not compile
   }
}

编译器报告:“未解析的引用:launch”。这是因为协程构建器是CoroutineScope的扩展函数。

CoroutineScope控制协程的生命周期,位于一个定义良好的作用域或生命周期内。它是一个在结构化并发中扮演父级角色的对象——它的目的是管理和监控你在其内创建的协程。也许你会惊讶地发现,在前面的示例中,使用async协程构建器时,已经提供了一个CoroutineScope来启动新的协程。那个CoroutineScope是由runBlocking块提供的。怎么做的?这是runBlocking的简化签名:

fun <T> runBlocking(
    // function arguments removed for brevity
    block: suspend CoroutineScope.() -> T): T { // impl
}

最后一个参数是一个带有类型为CoroutineScope的接收者的函数。因此,当你为块参数提供一个函数时,你可以使用CoroutineScope来调用CoroutineScope的扩展函数。正如你在图 7-7 中所看到的,Android Studio 能够捕捉到 Kotlin 中的隐式类型引用,因此如果你启用了“类型提示”,你可以看到类型参数。

Android Studio 中的类型提示

图 7-7. Android Studio 中的类型提示。

除了提供CoroutineScope外,runBlocking的目的是什么?runBlocking会阻塞当前线程直到其完成。它可以从常规阻塞代码中调用,作为到包含挂起函数的代码的桥梁(我们将在本章后面讨论挂起函数)。

要能够创建协程,我们必须将我们的代码桥接到我们代码中的“常规”函数main。但是,以下示例无法编译,因为我们试图从常规代码中启动协程:

fun main() = launch {
    println("I'm a coroutine")
}

这是因为launch协程构建器实际上是CoroutineScope扩展函数

fun CoroutineScope.launch(
    context: CoroutineContext = EmptyCoroutineContext,
    // other params removed for brevity,
    block: suspend CoroutineScope.() -> Unit
): Job { /* implementation */ }

由于常规代码不提供CoroutineScope实例,因此你不能直接从那里调用协程构建器。

那么什么是CoroutineContext?要回答这个问题,你需要了解CoroutineScope的细节。

如果你查看源代码,CoroutineScope是一个接口:

interface CoroutineScope {
    val coroutineContext: CoroutineContext
}

换句话说,CoroutineScopeCoroutineContext的容器。

CoroutineScope的目的是通过应用结构化并发来封装并发任务(协程和其他作用域)。作用域和协程形成了一个树状架构,其根部是一个作用域,如图 7-8 所示。

树状结构

图 7-8. 树状关系(协程表示为矩形)。

CoroutineContext,我们将其称为上下文以供将来参考,是一个更广泛的概念。它是上下文元素的不可变联合集合。为了将来的参考,我们将使用术语“元素”来指代上下文元素

这就是理论。在实践中,您将最常使用特殊的上下文元素来控制协程将在哪个线程或线程池上执行。例如,想象一下,您必须在launch内部运行 CPU 密集型计算,同时不阻塞主线程。这正是协程库非常方便的地方,因为大多数常见用途的线程池都可以直接使用。对于 CPU 密集型任务,您不必定义自己的线程池。您只需像这样使用特殊的Dispatchers.Default上下文元素:

fun main() = runBlocking<Unit> {
    launch(Dispatchers.Default) {
        println("I'm executing in ${Thread.currentThread().name}")
    }
}

输出现在是:

I'm executing in DefaultDispatcher-worker-2 @coroutine#2

Dispatchers.Main是一个上下文元素。稍后您将看到,可以使用操作符将不同的上下文元素组合在一起,以进一步调整协程的行为。

正如其名称所示,Dispatcher的目的是在特定线程或线程池上调度协程。默认情况下,有四个Dispatcher可供使用——MainDefaultIOUnconfined

Dispatchers.Main

这使用主线程或 UI 线程,具体取决于您使用的平台。

Dispatchers.Default

这是用于 CPU 密集型任务,并默认由包含四个线程的线程池支持。

Dispatchers.IO

这是用于 IO 密集型任务,并默认由包含 64 个线程的线程池支持。

Dispatchers.Unconfined

这不是您在学习协程时应该使用或甚至需要的东西。它主要用于协程库的内部。

通过仅更改调度器,您可以控制协程将在哪个线程或线程池上执行。上下文元素Dispatcher.DefaultCoroutineDispatcher的一个子类,但还存在其他上下文元素。

通过提供调度器上下文,您可以轻松指定逻辑流的执行位置。因此,将上下文提供给协程构建器是开发者的责任。

在协程框架术语中,协程始终在一个上下文中运行。这个上下文由协程范围提供,与您提供的上下文不同。为了避免混淆,我们将称协程的上下文为协程上下文,将您提供给协程构建器的上下文称为提供的上下文

差异微妙——还记得Job对象吗?Job实例是协程生命周期的一个句柄,它也是协程上下文的一部分。每个协程都有一个代表它的Job实例,而这个Job是协程上下文的一部分。

现在是时候揭示这些上下文是如何创建的了。看看示例 7-1,它与先前的示例略有不同。

示例 7-1. 调度器示例
fun main() = runBlocking<Unit>(Dispatchers.Main) {
    launch(Dispatchers.Default) {
        val threadName = Thread.currentThread().name
        println("I'm executing in $threadName")
    }
}

此代码块创建了两个具有各自 Job 实例的协程:runBlocking 启动第一个协程,另一个由 launch 启动。

runBlocking 创建的协程有其自己的上下文。由于这是在作用域内启动的根协程,我们将此上下文称为作用域上下文。作用域上下文包含协程上下文,如 图 7-9 所示。

上下文

图 7-9. 上下文。

您已经看到 launchCoroutineScope 的扩展函数(它保存一个上下文),并且它可以接收一个上下文作为其第一个参数。因此,在此函数中我们有两个上下文可供使用,如 示例 7-1 所示:一个来自接收类型(作用域上下文),另一个来自上下文参数(提供的上下文)。

在调用我们提供的函数之前,launch 在其实现中做了什么?它合并了两个上下文,使上下文参数中的元素优先于作用域中的其他元素。从此合并操作中我们获得了父上下文。此时,协程的 Job 尚未创建。

最后,作为父上下文中 Job 的子级创建了一个新的 Job 实例。然后将此新 Job 添加到父上下文中,以替换父上下文的 Job 实例,以获取协程上下文。

这些关系和交互在 图 7-10 中表示,其中一个上下文由包含其他上下文元素的矩形表示。

上下文

图 7-10. 上下文 的表示。

图 7-10 表示包含 Job 实例和分发器 Dispatchers.Main 的上下文。考虑到这种表示,图 7-11 展示了如何表示 示例 7-1 的上下文。

上下文细节

图 7-11. 上下文细节。

您在提供给 launch 方法的上下文中提供的所有内容优先于作用域上下文。这导致了一个父上下文,该上下文继承了作用域上下文中未在提供的上下文中提供的元素(在本例中为一个 Job)。然后,创建一个新的 Job 实例(位于右上角的点上),作为父 Job 的子 Job,该父 Job 也是作用域上下文中的 Job。生成的协程上下文由父上下文中的元素组成,除了 Job(它是父上下文中 Job 的子 Job)。

协程上下文是我们提供给 launch 的 lambda 将在其中执行的上下文。

结构化并发性成为可能,因为协程上下文中的 Job 是父上下文中 Job 的子级。如果出于任何原因取消了作用域,则每个启动的子协程都将自动取消。^(6)

更重要的是,协程上下文继承作用域上下文中的上下文元素,并且这些元素不会被作为参数提供给launch时的上下文覆盖;在这方面,async方法的行为是完全一致的。

挂起函数

我们已经讨论了如何使用协程构建器launchasync启动协程,并简要介绍了阻塞和非阻塞的含义。在其核心,Kotlin 协程提供了一些不同的东西,这将真正展示协程的强大:挂起函数

想象一下,你串行调用了两个任务。第一个任务在第二个任务可以继续执行之前完成了。

上下文

当任务 A 执行时,底层线程无法继续执行其他任务—此时任务 A 被称为阻塞调用

然而,任务 A 在等待长时间运行的作业(例如 HTTP 请求)时花费了相当多的时间,最终阻塞了底层线程,使得等待的任务 B 变得无用。

因此,任务 B 等待任务 A 完成。节俭的开发者可能会认为这种情况浪费了线程资源,因为线程在任务 A 等待其网络调用结果时可以(而且应该)继续执行另一个任务。

使用挂起函数,我们可以将任务分解为可以suspend的块。在我们的例子中,当任务 A 执行其远程调用时,可以暂停它,从而使底层线程可以继续执行另一个任务(或者只执行其中的一部分)。当任务 A 获取其远程调用的结果时,可以在稍后恢复执行,如图 7-12 所示。

上下文

图 7-12. 所节省的时间表现在最后。

正如你所看到的,这两个任务的完成时间比之前的情况更早。这种任务的片段交错使得底层线程始终忙于执行任务。因此,挂起机制需要更少的线程来产生相同的总吞吐量,这在每个线程都有自己的堆栈并且每个堆栈至少占用 64 Kb 内存时尤为重要。通常,一个线程占用 1 MB 的 RAM。

使用挂起机制,我们可以更节省地使用相同的资源。

在幕后挂起函数

到目前为止,我们已经介绍了一个新概念:任务可以suspend。任务可以在不阻塞底层线程的情况下“暂停”其执行。虽然这听起来对你来说可能像是魔术,但重要的是要理解,这一切归结为底层结构,我们将在本节中解释。

一个任务,或者更准确地说,一个协程,如果使用了至少一个挂起函数,就可以暂停。挂起函数很容易识别,因为它声明时带有suspend修饰符。

当 Kotlin 编译器遇到挂起函数时,它会将其编译为带有额外类型为Continuation<T>的参数的常规函数,这只是一个接口,如示例 7-2 所示。

示例 7-2. 接口 Continuation<T>
public interface Continuation<in T> {
    /**
     * The context of the coroutine that corresponds to this continuation.
     */
    public val context: CoroutineContext

    /**
     * Resumes the execution of the corresponding coroutine passing a successful
     * or failed [result] as the return value of the last suspension point.
     */
    public fun resumeWith(result: Result<T>)
}

假设你定义这个挂起函数如下:

suspend fun backgroundWork(): Int {
    // some background work on another thread, which returns an Int
}

在编译时,这个函数会被转换成一个普通函数(没有 suspend 修饰符),并增加一个额外的 Continuation 参数:

fun backgroundWork(callback: Continuation<Int>): Int {
    // some background work on another thread, which returns an Int
}
注意

挂起函数被编译成接受额外的 Continuation 对象参数的普通函数。这是继续传递样式(CPS)的实现,一种控制流通过 Continuation 对象传递的编程风格。

这个 Continuation 对象包含了在 backgroundWork 函数体中应该执行的所有代码。

Kotlin 编译器为这个 Continuation 对象实际上生成了什么?

出于效率考虑,Kotlin 编译器生成了一个状态机。^(7) 状态机的实现就是尽可能少地分配对象,因为协程非常轻量级,可能会同时运行成千上万个。

在这个状态机内部,每个状态对应于挂起函数主体中的一个挂起点。让我们看一个例子。想象在一个 Android 项目中,我们使用 presenter 层执行一些涉及 IO 和图形处理的长时间运行进程,在 viewModelScope 启动的自管理协程中,以下代码块有两个挂起点:^(8)

suspend fun renderImage() {
    val path: String = getPath()
    val image = fetchImage(path)    // first suspension point (fetchImage is a suspending function)
    val clipped = clipImage(image)  // second suspension point (clipImage is a suspending function)
    postProcess(clipped)
}

/** Here is an example of usage of the [renderImage] suspending function */
fun onStart() {
    viewModelScope.launch(Dispatchers.IO) {
        renderImage()
    }
}

编译器生成了一个实现 Continuation 接口的匿名类。为了让你了解实际生成的内容,我们提供了 renderImage 挂起函数生成的伪代码。该类有一个 state 字段,保存状态机的当前状态。它还有每个在状态之间共享的变量字段:

object : Continuation<Unit>  {
   // state
   private var state = 0

   // fields
   private var path: String? = null
   private var image: Image? = null

   fun resumeWith(result: Any) {
      when (state) {
         0 -> {
            path = getPath()
            state = 1
            // Pass this state machine as Continuation.
            val firstResult = fetchImage(path, this)
            if (firstResult == COROUTINE_SUSPENDED) return
            // If we didn't get COROUTINE_SUSPENDED, we received an
            // actual Image instance, execution shall proceed to
            // the next state.
            resumeWith(firstResult)
         }
         1 -> {
            image = result as Image
            state = 2
            val secondResult = clipImage(image, this)
            if (secondResult == COROUTINE_SUSPENDED) return
               resumeWith(secondResult)
            }
         2 -> {
            val clipped = result as Image
            postProcess(clipped)
         }
         else -> throw IllegalStateException()
      }
   }
}

此状态机被初始化为 state = 0。因此,当使用 launch 启动的协程调用 renderImage 挂起函数时,执行会“跳转”到第一个情况 (0)。我们获取一个路径,设置下一个状态为 1,然后调用 fetchImage,这是 renderImage 主体中的第一个挂起函数。

在这个阶段,有两种可能的情况:

  1. fetchImage 需要一些时间来返回一个 Image 实例,并立即返回 COROUTINE_SUSPENDED 值。通过返回这个特定的值,fetchImage 实际上在说:“我需要更多时间来返回一个实际的值,所以给我你的状态机对象,当我有结果时我会使用它。” 当 fetchImage 最终有了 Image 实例时,它调用 stateMachine.resumeWith(image)。此时 state 等于 1,执行会“跳转”到 when 语句的第二个情况。

  2. fetchImage 立即返回一个 Image 实例。在这种情况下,执行会继续下一个状态(通过 resumeWith(image))。

其余的执行遵循相同的模式,直到最后一个状态的代码调用 postProcess 函数。

注意

此解释并非生成的字节码中确切状态机的状态,而是其代表逻辑的伪代码,以传达主要思想。对于日常使用而言,了解 Kotlin 字节码中实际生成的有限状态机的实现细节不如理解其背后发生的事情重要。

在概念上,当您调用挂起函数时,将会创建一个回调(Continuation)以及生成的结构,以便挂起函数返回后仅调用挂起函数之后的其余代码。减少样板代码的时间,您可以专注于业务逻辑和高级概念。

到目前为止,我们分析了 Kotlin 编译器如何在幕后重新构造我们的代码,这样我们就不必自己编写回调函数。当然,你不必完全了解有限状态机代码生成的细节就能使用挂起函数。但是,理解这个概念很重要!为此,练习是最好的方式!

使用协程和挂起函数:实际示例

假设在 Android 应用程序中,您希望使用 id 加载用户的个人资料。在导航到个人资料时,根据 id 获取用户数据的方法命名为 fetchAndLoadProfile 是有意义的。

您可以使用协程来实现这一点,使用您在上一节中学到的内容。目前,假设在您的应用程序中的某个地方(通常是 MVC 架构中的控制器或 MVVM 中的 ViewModel),您有一个带有 Dispatchers.Main 调度程序的 CoroutineScope。在这种情况下,我们称此作用域在主线程上调度协程,这与默认行为相同。在接下来的章节中,我们将为您提供有关协程范围的详细解释和示例,以及如何在需要时访问和创建它们。

作用域默认为主线程的事实并不会有任何限制,因为您可以在此作用域内使用任何您想要的 CoroutineDispatcher 创建协程。fetchAndLoadProfile 的此实现说明了这一点:

fun fetchAndLoadProfile(id: String) {
    scope.launch {                                          ![1](https://github.com/OpenDocCN/ibooker-mobi-zh/raw/master/docs/prog-andr-kt/img/1.png)
        val profileDeferred = async(Dispatchers.Default) {  ![2](https://github.com/OpenDocCN/ibooker-mobi-zh/raw/master/docs/prog-andr-kt/img/2.png)
            fetchProfile(id)
        }
        val profile = profileDeferred.await()               ![3](https://github.com/OpenDocCN/ibooker-mobi-zh/raw/master/docs/prog-andr-kt/img/3.png)
        loadProfile(profile)                                ![4](https://github.com/OpenDocCN/ibooker-mobi-zh/raw/master/docs/prog-andr-kt/img/4.png)
    }
}

这是通过四个步骤完成的:

1

launch 开始。您希望 fetchAndLoadProfile 立即返回,以便可以在主线程上顺序进行。由于作用域默认为主线程,没有额外上下文的 launch 继承作用域的上下文,因此在主线程上运行。

2

使用asyncDispatchers.Default,你调用fetchProfile,这是一个阻塞调用。作为提醒,使用Dispatchers.Default会导致fetchProfile在线程池中执行。你立即得到一个Deferred<Profile>,命名为profileDeferred。此时,后台工作正在线程池的一个线程上进行。fetchProfile的签名如下:fun fetchProfile(id: String): Profile { // impl }。这是一个可能在远程服务器上执行数据库查询的阻塞调用。

3

你不能立即使用profileDeferred来加载配置文件,你需要等待后台查询的结果。你可以使用profileDeferred.await()来实现这一点,它将生成并返回一个Profile实例。

4

最后,你可以使用获取到的配置文件调用loadProfile。由于外部启动继承了其上下文从父范围,loadProfile在主线程上调用。我们假设这是期望的,因为大多数与 UI 相关的操作必须在主线程上完成。

每当你调用fetchAndLoadProfile时,后台处理会在 UI 线程之外完成以检索配置文件。一旦配置文件可用,UI 就会更新。你可以从任何线程调用fetchAndLoadProfile,但这不会改变最终在 UI 线程上调用loadProfile的事实。

还不错,但我们可以做得更好。

注意,这段代码从上到下阅读,没有间接性或回调。你可以认为“profileDeferred”命名和await调用感觉笨重。当你获取配置文件、等待它,然后加载它时,这一点可能会更加明显。这就是挂起函数发挥作用的地方。

挂起函数是协程框架的核心。

提示

在概念上,挂起函数是一种可能不会立即返回的函数。如果它不会立即返回,它会挂起调用这个挂起函数的协程,同时进行内部计算。这个内部计算不应该阻塞调用线程。稍后,当内部计算完成时,协程将会恢复执行。

挂起函数只能从协程内部或另一个挂起函数中调用。

“挂起协程”意味着协程执行被暂停。这里有一个例子:

suspend fun backgroundWork(): Int {
    // some background work on another thread, which returns an Int
}

首先,挂起函数不是普通函数;它有自己的suspend关键字。它可以有返回类型,但请注意,在这种情况下,它不会返回Deferred<Int>,而只是裸露的Int

其次,它只能从协程或另一个挂起函数中调用。

回到我们之前的例子:使用async块进行配置文件的获取和等待。从概念上讲,这正是挂起函数的目的。我们将借用与阻塞fetchProfile函数相同的名称,并像这样重新编写它:

suspend fun fetchProfile(id: String): Profile {
    // for now, we’re not showing the implementation
}

与原始 async 块的两个主要区别是 suspend 修饰符和返回类型。

这使你能够简化 fetchAndLoadProfile

fun fetchAndLoadProfile(id: String) {
    scope.launch {
        val profile = fetchProfile(id)   // suspends
        loadProfile(profile)
    }
}

现在 fetchProfile 是一个悬挂函数,通过 launch 启动的协程在调用 fetchProfile 时会暂停。暂停意味着协程的执行被停止,下一行代码不会执行。它将保持暂停状态,直到检索到配置文件,此时通过 launch 启动的协程会恢复执行。然后执行下一行代码 (loadProfile)。

注意这段代码看起来像过程式代码。想象一下,你如何实现每个步骤都需要前一个步骤结果的复杂异步逻辑。你将像这样按顺序调用悬挂函数,采用经典的过程式风格。易于理解的代码更易于维护。这是悬挂函数最直接有用的方面之一。

作为奖励,IntelliJ IDEA 和 Android Studio 帮助你一目了然地识别悬挂调用。在图 7-13 中,你可以看到边缘处的符号表示悬挂调用。

suspend call

图 7-13. 悬挂调用。

当你在边缘处看到这个符号时,你知道协程可以在这一行暂停执行。

不要误解 suspend 修饰符

看起来很厉害,但是将 suspend 修饰符添加到普通函数并不会神奇地将其转变为非阻塞函数。还有更多细节。以下是一个使用悬挂函数 fetchProfile 的示例:

suspend fun fetchProfile(id: String) = withContext(Dispatchers.Default) {
   // same implementation as the original fetchProfile, which returns a Profile instance
}

fetchProfile(...) 使用协程框架中的 withContext 函数,接受 CoroutineContext 作为参数。在这种情况下,我们提供 Dispatchers.Default 作为上下文。几乎每次使用 withContext,你只需要提供一个 Dispatcher

将执行 withContext 主体的线程由提供的 Dispatcher 决定。例如,使用 Dispatchers.Default,它将是专门用于 CPU 密集型任务的线程池中的一个线程。在使用 Dispatchers.Main 的情况下,它将是主线程。

fetchProfile 为何会挂起?这是 withContext 和协程框架的实现细节。

要记住的最重要的概念很简单:协程调用悬挂函数 可能 会暂停其执行。在协程术语中,我们说它达到了一个暂停点。

为什么我们说它 可能 会挂起?想象一下,在 fetchProfile 的实现中,你检查是否在缓存中有关联的配置文件数据。如果在缓存中有数据,你可以立即返回它。那么外部协程的执行就不需要暂停。^(9)

创建悬挂函数有几种方法。使用 withContext 只是其中之一,尽管可能是最常见的方法之一。

概要

  • 协程总是从 CoroutineScope 启动。在结构化并发术语中,CoroutineScope 是父级,而协程本身是该作用域的子级。一个 CoroutineScope 可以是现有 CoroutineScope 的子级。请参阅下一章如何获取 CoroutineScope 或创建一个。

  • CoroutineScope 可以看作是根协程。事实上,任何具有 Job 的东西在技术上都可以被视为协程。唯一的区别在于预期的使用方式。作用域意味着其包含其子协程。正如本章开头所见,作用域的取消会导致所有子协程的取消。

  • launch 是一个协程构建器,返回一个 Job 实例。它用于“启动并忘记”。

  • async 是一个协程构建器,可以返回值,非常类似于 PromiseFuture。它返回一个 Deferred<T> 实例,这是一个专门的 Job

  • Job 是协程生命周期的句柄。

  • 新创建的协程的上下文,通过 launchasync 开始的协程上下文,从作用域上下文和作为参数传递的上下文中继承(传递的上下文优先)。每个协程的作业 (Job) 都是新创建的一个上下文元素。例如:

    launch(Dispatchers.Main) {
       async {
          // inherits the context of the parent, so is dispatched on
          // the main thread
       }
    }
    
  • 挂起函数指的是可能不会立即返回的函数。使用 withContext 和适当的 Dispatcher,任何阻塞函数都可以转换为非阻塞的挂起函数。

  • 一个协程通常由多次调用挂起函数构成。每次调用挂起函数时,都会达到一个挂起点。协程的执行会在每个挂起点停止,直到恢复。^(10)

本章最后的一点补充:作用域上下文 是新概念,只是协程机制的一部分。其他如 异常处理协作取消 的主题将在下一章中讨论。

^(1) 在这种情况下,job.cancel() 对由 launch 启动的协程没有影响。我们将在下一章讨论这个问题(协程必须配合取消以便被取消)。

^(2) 这会挂起调用的协程,直到获取值,或者如果以 async 开始的协程被取消或因异常而失败,则抛出异常。本章后面将详细介绍。

^(3) 我们假设异常已经处理,不会干扰执行流程。

^(4) join() 方法会使调用线程进入等待状态,直到原始线程终止为止。

^(5) 实体的失败对应于实体无法从中恢复的任何异常事件。这通常使用未处理的或抛出的异常来实现。

^(6) 你可能注意到,没有什么阻止你将一个Job实例传递到“提供的上下文”中。那么会发生什么呢?按照解释的逻辑,这个Job实例将成为协程上下文中的Job的父级(例如,新创建的协程)。因此,作用域不再是协程的父级;父子关系被打破。这就是为什么强烈不建议这样做的原因,除非在下一章节将要解释的特定场景中。

^(7) 实际上,当一个暂停函数仅作为尾调用调用单个暂停函数时,不需要状态机。

^(8) viewModelScope来自ViewModel的 AndroidX 实现。viewModelScope的作用域限定为ViewModel的生命周期。关于这个将在下一章详细讲解。

^(9) 我们将在第八章中向你展示如何做到这一点。

^(10) 当暂停函数导致协程暂停的原因函数退出时,协程机制会恢复协程的执行。

第八章:使用协程进行结构化并发

在前一章中,我们介绍了一种新的异步编程范式——协程。在使用协程时,了解如何适当使用挂起函数非常重要;我们将在本章介绍这个话题。由于大多数程序都需要处理异常处理和取消操作,我们还将涵盖这些主题——您会看到,在这方面,协程有自己的一套您应该了解的规则。

本章的第一部分涵盖了挂起函数的惯用用法。我们将以徒步旅行应用为例,比较基于线程和基于挂起函数和协程的两种实现方式。您将看到这种比较如何突显出协程在某些情况下的强大。

对于大多数移动应用程序而言,徒步旅行示例需要一个取消机制。我们将详细介绍关于使用协程进行取消的所有知识。为了准备应对各种情况,我们将介绍并行分解监督。使用这些概念,您将能够在需要时实现复杂的并发逻辑。

最后,本章结束时将解释协程的异常处理。

挂起函数

想象一下,您正在开发一个帮助用户规划、计划、跟踪、绘制和分享关于徒步旅行的信息的应用程序。用户应能够导航到他们已经完成或正在进行的任何徒步旅行。在开始某次徒步旅行之前,一些基本的统计信息是有用的,比如:

  • 总距离

  • 上次徒步旅行的时间和距离长度

  • 他们选择的路径上的当前天气

  • 最喜爱的徒步旅行

这样的应用程序需要客户端与服务器之间各种关于气象数据和用户信息的交互。我们可能会选择如何存储这样一个应用程序的数据?

我们可以选择在本地存储这些数据以供以后使用,或者在远程服务器上存储(这称为持久化策略)。长时间运行的任务,特别是网络或 IO 任务,可以通过后台作业来实现,比如从数据库中读取、从本地文件中读取或从远程服务器查询。从主机设备读取数据的速度始终快于从网络读取相同数据。

因此,检索到的数据可能以不同的速率返回,这取决于查询的性质。我们的工作逻辑必须具有弹性和足够的灵活性,以支持并应对这种情况,而且要足够强大,能够处理我们无法控制甚至意识到的情况。

设定场景

您需要开发一个功能,允许用户检索他们最喜欢的徒步旅行及每次徒步旅行的当前天气。

我们已经提供了本章开头描述的应用程序的一些库代码。以下是已为您提供的一组类和函数:

data class Hike(
   val name: String,
   val miles: Float,
   val ascentInFeet: Int)

class Weather // Implementation removed for brevity

data class HikeData(val hike: Hike, val weather: Weather?)

Weather 不是 Kotlin 数据类,因为我们需要为 HikeData 的天气属性提供一个类型名称(如果我们将 Weather 声明为没有提供属性的数据类,代码将无法编译)。

在此示例中,Hike 仅仅是:

  1. 一个名称

  2. 总英里数

  3. 海拔总上升(英尺)

HikeDataHike 对象与一个可为空的 Weather 实例配对(如果由于某些原因无法获取天气数据)。

我们还提供了获取给定用户 ID 的远足列表以及远足的天气数据的方法:

fun fetchHikesForUser(userId: String): List<Hike> {
    // implementation removed for brevity
}

fun fetchWeather(hike: Hike): Weather {
    // implementation removed for brevity
}

这两个函数可能是长时间运行的操作,比如查询数据库或 API。为了在获取远足列表或当前天气时避免阻塞 UI 线程,我们将利用挂起函数。

我们认为了解如何使用挂起函数的最佳方式是比较以下内容:

  • 使用线程和 Handler 的“传统”方法

  • 使用协程的挂起函数实现

首先,我们将向您展示传统方法在某些情况下存在限制,并且不容易克服这些限制。然后,我们将向您展示如何使用挂起函数和协程改变我们实现异步逻辑的方式,并且如何解决我们在传统方法中遇到的所有问题。

让我们从基于线程的实现开始。

使用 java.util.concurrent.ExecutorService 的传统方法

fetchHikesForUserfetchWeather 函数应该从后台线程调用。在 Android 中,可以通过多种方式来实现。当然,Java 有传统的 Thread 库和 Executors 框架。Android 标准库有(现在已经过时的)AsyncTaskHandlerThread,以及 ThreadPoolExecutor 类。

在所有可能性中,我们希望选择表现力、可读性和控制性最好的实现方式。因此,我们决定利用 Executors 框架。

ViewModel 中,假设您使用 Executors 类的工厂方法之一来获取返回用于使用传统基于线程的模型执行异步工作的 ThreadPoolExecutor

在接下来的内容中,我们选择了 工作窃取 池。与带有阻塞队列的简单线程池相比,工作窃取池可以减少争用,同时保持目标数量的活动线程。其背后的想法是维护足够多的工作队列,以便超负荷的工作线程可能会将其任务“窃取”给负载较轻的另一个工作线程:

class HikesViewModel : ViewModel() {
    private val ioThreadPool: ExecutorService =
        Executors.newWorkStealingPool(10)

    fun fetchHikesAsync(userId: String) {
        ioThreadPool.submit {
            val hikes = fetchHikesForUser(userId)
            onHikesFetched(hikes)
        }
    }

    private fun onHikesFetched(hikes: List<Hike>) {
        // Continue with the rest of the view-model logic
        // Beware, this code is executed from a background thread
    }
}

在执行 IO 操作时,即使在 Android 设备上,拥有 10 个线程也是合理的。在使用 Executors.newWorkStealingPool 的情况下,实际线程数量会根据负载动态增长和收缩。但需要注意的是,工作窃取池不保证提交的任务执行顺序。

注意

我们也可以利用 Android 原生的 ThreadPoolExecutor 类。具体来说,我们可以这样创建我们的线程池:

private val ioThreadPool: ExecutorService =
    ThreadPoolExecutor(
        4,   // Initial pool size
        10,  // Maximum pool size
        1L,
        TimeUnit.SECONDS,
        LinkedBlockingQueue()
    )

然后使用方式完全相同。即使最初创建的工作窃取池存在微妙差异,重要的是注意如何向线程池提交任务。

仅仅为 fetchHikesForUser 使用线程池可能会有些过头 —— 特别是如果你不会并发地为不同用户调用 fetchHikesForUser。考虑使用 ExecutorService 的其余实现来处理更复杂的并发工作,如下面的代码所示:

class HikesViewModel : ViewModel() {
    // other attributes
    private val hikeDataList = mutableListOf<HikeData>()
    private val hikeLiveData = MutableLiveData<List<HikeData>>()

    fun fetchHikesAsync(userId: String) { // content hidden } 
    private fun onHikesFetched(hikes: List<Hike>) {
        hikes.forEach { hike  ->
            ioThreadPool.submit {
                val weather = fetchWeather(hike)         ![1](https://github.com/OpenDocCN/ibooker-mobi-zh/raw/master/docs/prog-andr-kt/img/1.png)
                val hikeData = HikeData(hike, weather)   ![2](https://github.com/OpenDocCN/ibooker-mobi-zh/raw/master/docs/prog-andr-kt/img/2.png)
                hikeDataList.add(hikeData)               ![3](https://github.com/OpenDocCN/ibooker-mobi-zh/raw/master/docs/prog-andr-kt/img/3.png)
                hikeLiveData.postValue(hikeDataList)     ![4](https://github.com/OpenDocCN/ibooker-mobi-zh/raw/master/docs/prog-andr-kt/img/4.png)
            }
        }
    }
}

对于每个 Hike,都会提交一个新任务。这个新任务:

1

获取天气信息

2

HikeWeather 对象存储在 HikeData 容器内。

3

HikeData 实例添加到内部列表

4

通知视图 HikeData 列表已更改,将传递该列表数据的新更新状态。

我们故意在前述代码中留下了一个常见的错误。你能发现它吗?尽管目前的运行正常,但想象一下,如果我们添加一个公共方法来添加新的徒步旅行:

fun addHike(hike: Hike) {
    hikeDataList.add(HikeData(hike, null))
    // then fetch Weather and notify view using hikeLiveData
}

onHikesFetched 方法的第 3 步中,我们从 ioThreadPool 的一个后台线程向 hikeDataList 添加了一个新元素。这样一个无害的方法可能会出什么问题?

当后台线程修改 hikeDataList 时,你可能尝试从主线程调用 addHike

没有强制规定从哪个线程调用公共的 addHike。在 Kotlin 的 JVM 中,可变列表由 ArrayList 支持。然而,ArrayList 不是 线程安全 的。事实上,这并不是我们唯一的错误。hikeDataList 没有正确发布 —— 在第 4 步中,后台线程可能看不到 hikeDataList 的更新值。在 Java 内存模型中,这里没有 happens before^(2) 强制执行 —— 即使主线程之前将一个新元素放入列表中。

因此,在 onHikesFetched 链中的迭代器在意识到集合已被“神奇地”修改时会抛出 ConcurrentModificationException。在这种情况下,从后台线程填充 hikeDataList 是不安全的(参见 Figure 8-1)。

AddHike ConcurrentModificationException

图 8-1. addHike 方法会向已在后台线程中修改的 hikeDataList 添加新内容。

落入这种模式,即使在安全时,也增加了习惯占优势的可能性,并且在同一天或同一周或同一月内,此错误在不安全的情况下重复发生。考虑到其他具有对同一代码库编辑访问权限的团队成员,你会发现我们很快失去了控制。

在多个线程同时尝试访问相同资源时,线程安全性很重要,而且很难做到正确。这就是为什么默认使用主线程^(3)被认为是一个良好的实践。

那么你该如何做呢?能否让后台线程告诉主线程“无论何时都要将此元素添加到此列表,然后用更新后的HikeData列表通知视图”?为此,可以使用方便的HandlerThreadHandler类。

关于 HandlerThread 的提醒

一个HandlerThread是一个带有“消息循环”的线程。它是生产者-消费者设计模式的一种实现,其中HandlerThread是消费者。一个Handler位于实际消息队列和可以发送新消息的其他线程之间。在内部,消费消息队列的循环是使用Looper类(也称为“looper”)创建的。当调用其quitquickSafely方法时,HandlerThread会完成。根据 Android 的文档,quit方法会导致处理程序线程的 looper 终止,而不处理消息队列中的任何其他消息。quitSafely方法会导致处理程序线程的 looper 在处理完所有已准备好交付的剩余消息后立即终止。

非常注意记得停止HandlerThread。例如,想象一下,你在活动的生命周期内启动了一个HandlerThread(比如在片段的onCreate方法中)。如果你旋转设备,活动会被销毁然后重新创建。然后会创建并启动一个新的HandlerThread实例,而旧的仍在运行,导致严重的内存泄漏(见图 8-2)!

HandlerThread

图 8-2. 一个HandlerThread消费来自MessageQueue的任务。

在 Android 上,主线程是一个HandlerThread。因为创建一个Handler来向主线程发送消息是非常常见的,所以Looper类上存在一个静态方法来获取与Handler关联的主线程的Looper实例的引用。使用Handler,你可以将一个Runnable发布到与Handler关联的Looper实例所附加的线程上执行。Java 签名是:

public final boolean post(@NonNull Runnable r) { ... }

由于Runnable只有一个抽象方法run,在 Kotlin 中可以使用 lambda 语法来美化代码,如下所示:

// Direct translation in Kotlin (though not idiomatic)
handler.post(object: Runnable {
      override fun run() {
         // content of run
      }
   }
)

// ..which can be nicely simplified into:
handler.post {
    // content of `run` method
}

在实际应用中,你可以像这样创建它:

val handler: Handler = Handler(Looper.getMainLooper())

然后,你可以在前面示例中利用循环处理程序,如下代码所示:

class HikesViewModel : ViewModel() {
    private val ioThreadPool: ExecutorService = Executors.newWorkStealingPool(10)
    private val hikeDataList = mutableListOf<HikeData>()
    private val hikeLiveData = MutableLiveData<List<HikeData>>()
    private val handler: Handler = Handler(Looper.getMainLooper())

    private fun onHikesFetched(hikes: List<Hike>) {
        hikes.forEach { hike  ->
            ioThreadPool.submit {
                val weather = fetchWeather(hike)
                val hikeData = HikeData(hike, weather)

                // Here we post a Runnable
                handler.post {
                    hikeDataList.add(hikeData)           ![1](https://github.com/OpenDocCN/ibooker-mobi-zh/raw/master/docs/prog-andr-kt/img/1.png)
                    hikeLiveData.value = hikeDataList    ![2](https://github.com/OpenDocCN/ibooker-mobi-zh/raw/master/docs/prog-andr-kt/img/2.png)
                }
            }
        }
    }

    // other methods removed for brevity }

这一次,我们将一个Runnable发布到主线程中,其中:

1

新的HideData实例添加到hikeDataList

2

hikeDataList作为更新后的值赋予hikeLiveData。请注意,我们可以在此处使用高度可读且直观的赋值运算符:hikeLiveData.value = ..,这比hikeLiveData.postValue(..)更好。这是因为Runnable将从主线程执行——postValue仅在从后台线程更新LiveData的值时有用。

这样做,所有hikeDataList的访问器线程限定为主线程(见图 8-3),消除所有可能的并发危害。

线程限定

图 8-3. 主线程只能访问hikeDataList

这就是“传统”方法的全部内容。其他库如RxJava/RxKotlinArrow也可以用于执行基本相同的操作。逻辑由几个步骤组成。您开始第一个步骤,给它一个回调,其中包含在后台作业完成时运行的指令集。每个步骤都通过回调内部的代码连接到下一个步骤。我们在第六章中讨论过它,并希望我们已经阐明了一些潜在的问题,并为您提供了避免这些问题的工具。

有趣的是,回调复杂性在这个示例中似乎不是问题——一切都是用两个方法完成的,一个Handler和一个ExecutorService。然而,在以下情况下出现了一个隐匿的情况:

用户导航到一系列远足,然后在ViewModel上调用fetchHikesAsync。用户刚刚在新设备上安装了应用程序,因此历史记录不在缓存中,因此应用程序必须访问远程 API 从某个远程服务获取新鲜数据。

假设无线网络速度较慢,但不会导致 IO 超时错误。视图不断显示列表正在更新,用户可能会误以为实际上存在被抑制的错误,并重试获取(可能可以使用某些刷新 UI,如SwipeRefreshLayout、显式刷新按钮,甚至只是使用导航重新进入 UI 并假设将隐式调用获取操作)。

不幸的是,我们的实现没有预料到这一点。当调用fetchHikesAsync时,启动了一个工作流程,无法停止。想象最坏的情况,每当用户返回并重新进入远足列表视图时,都会启动一个新的工作流程。这显然是设计不良。

取消机制可能是一种可能的解决方案。我们可以通过确保每次新调用fetchHikesAsync时取消任何先前的正在进行或挂起的调用来实现取消机制。或者,当先前的调用仍在运行时,您可以丢弃新的fetchHikesAsync调用。在这种情况下实施这一点需要深思熟虑和审慎。

取消机制并不像我们在其他流程中发现的那样随手可得,因为你必须确保每一个后台线程都有效地停止它们的执行。

正如你从前一章节中所知,协程和挂起函数在这里以及类似的情况下非常合适。我们选择这个徒步应用的例子是因为我们有一个很好的机会使用挂起函数。

使用挂起函数和协程

作为提醒,我们现在将实现完全相同的逻辑;但这一次我们将使用挂起函数和协程。

当函数可能不会立即返回时,你声明一个挂起函数。因此,任何阻塞函数都有资格被重写为挂起函数。

fetchHikesForUser函数是一个很好的例子,因为它会阻塞调用线程,直到返回一个Hike实例列表。因此,它可以表示为一个挂起函数,如下所示:

suspend fun hikesForUser(userId: String): List<Hike> {
    return withContext(Dispatchers.IO) {
        fetchHikesForUser(userId)
    }
}

我们不得不为挂起函数选择另一个名称。在这个例子中,按照约定,阻塞调用都以“fetch”作为前缀。

类似地,如示例 8-1 所示,你可以声明相当于fetchWeather的内容。

示例 8-1. fetchWeather作为挂起函数
suspend fun weatherForHike(hike: Hike): Weather {
    return withContext(Dispatchers.IO) {
        fetchWeather(hike)
    }
}

这些挂起函数是它们阻塞对应函数的包装器。当从协程内部调用时,由withContext函数提供的Dispatcher确定阻塞调用在哪个线程池上执行。在这里,Dispatchers.IO非常适合,并且与之前看到的工作窃取池非常相似。

注意

一旦你将阻塞调用包装在类似挂起weatherForHike函数中的挂起块中,你现在可以在协程内使用这些挂起函数了——正如你马上将看到的那样。

实际上,有一个关于挂起函数的约定,可以让每个人的生活更简单:挂起函数永远不会阻塞调用线程。对于weatherForHike来说,这确实如此,因为无论哪个线程从协程内部调用weatherForHikewithContext(Dispatchers.IO)语句都会导致执行跳转到另一个线程。^(4)

我们通过回调模式所做的一切现在可以放在一个单独的公共update方法中,这样看起来就像是过程化的代码。这得益于挂起函数,正如在示例 8-2 中所示。

示例 8-2. 在视图模型中使用挂起函数
class HikesViewModel : ViewModel() {
    private val hikeDataList = mutableListOf<HikeData>()
    private val hikeLiveData = MutableLiveData<List<HikeData>>()

    fun update() {
        viewModelScope.launch {                                 ![1](https://github.com/OpenDocCN/ibooker-mobi-zh/raw/master/docs/prog-andr-kt/img/1.png)
            /* Step 1: get the list of hikes */
            val hikes = hikesForUser("userId")                  ![2](https://github.com/OpenDocCN/ibooker-mobi-zh/raw/master/docs/prog-andr-kt/img/2.png)

            /* Step 2: for each hike, get the weather, wrap into a
             * container, update hikeDataList, then notify view
             * listeners by updating the corresponding LiveData */
            hikes.forEach { hike ->                             ![3](https://github.com/OpenDocCN/ibooker-mobi-zh/raw/master/docs/prog-andr-kt/img/3.png)
                launch {
                    val weather = weatherForHike(hike)          ![4](https://github.com/OpenDocCN/ibooker-mobi-zh/raw/master/docs/prog-andr-kt/img/4.png)
                    val hikeData = HikeData(hike, weather)
                    hikeDataList.add(hikeData)
                    hikeLiveData.value = hikeDataList
                }
            }
        }
    }
}

我们将逐步提供示例 8-2 的细节:

1

当调用update时,它会立即启动一个协程,使用launch协程构建器。正如你所知,协程不会突然间启动。正如我们在第七章中所见,协程必须始终在CoroutineScope内启动。这里我们使用的是viewModelScope

这个范围是从哪里来的?Google 的 Android Jetpack 团队知道使用 Kotlin 和协程需要一个CoroutineScope。为了简化您的生活,他们维护了Android KTX,这是在 Android 平台和其他 API 上的一组 Kotlin 扩展。其目标是使用 Kotlin 惯用语法,同时与 Android 框架良好集成。他们利用了扩展函数、lambda、参数默认值和协程。Android KTX 由多个库组成。在这个示例中,我们使用了lifecycle-viewmodel-ktx。要在您的应用程序中使用它,请将以下内容添加到build.gradle中的依赖项列表中(如果有更新的版本,请使用更新版本):implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0"

2

val hikes = hikesForUser("userId") 是第一个暂停点。由launch启动的协程将停止,直到hikesForUser返回。

3

您已经获取了您的Hike实例列表。现在您可以并发地为每个实例获取天气数据。我们可以使用循环,并使用launch为每次徒步旅行启动一个新协程。

4

val weather = weatherForHike(hike) 是另一个暂停点。for循环中启动的每个协程将达到这个暂停点。

让我们仔细看看以下代码中为每个Hike实例启动的协程:

launch {
    val weather = weatherForHike(hike)
    val hikeData = HikeData(hike, weather)
    hikeDataList.add(hikeData)
    hikeLiveData.value = hikeDataList
}

由于父作用域(viewModelScope)默认为主线程,launch块内的每一行都在主线程上执行,除了挂起函数weatherForHike的内容,它使用Dispatchers.IO(见 Example 8-1)。weather的赋值是在主线程上完成的。因此,hikeDataList的使用局限于主线程——没有线程安全问题。至于hikeLiveData,您可以使用其value的 setter(因为我们知道我们是从主线程调用此方法),而不是postValue

警告

使用协程范围时,您应始终意识到它如何管理您的协程,特别是要知道范围使用的Dispatcher。下面的代码展示了它在库源代码中的声明方式:

val ViewModel.viewModelScope: CoroutineScope
  get() {
    val scope: CoroutineScope? = this.getTag(JOB_KEY)
    if (scope != null) {
       return scope
    }
    return setTagIfAbsent(
       JOB_KEY,
       CloseableCoroutineScope(
          SupervisorJob() +  Dispatchers.Main.immediate))
  }

正如你在这个例子中看到的那样,viewModelScope 被声明为 ViewModel 类的扩展属性。即使 ViewModel 类根本没有 CoroutineScope 的概念,以这种方式声明它也能使我们示例中的语法生效。然后,会查询内部存储来检查是否已经创建了范围。如果没有,将使用 CloseableCoroutineScope(..) 创建一个新的范围。^(5) 例如,不要关注 SupervisorJob ——我们稍后在讨论取消时会解释它的作用。这里特别相关的是 Dispatchers.Main.immediate,这是 Dispatcher.Main 的一种变体,它在从主线程启动协程时会立即执行。因此,此范围默认为主线程。这是您从这里继续前进需要了解的关键信息。

挂起函数与传统线程的总结

多亏了挂起函数,异步逻辑可以像过程式代码一样编写。由于 Kotlin 编译器在幕后生成了所有必要的回调和样板代码,因此使用取消机制编写的代码可以更加简洁。^(6) 例如,使用 Dispatchers.Main 的协程范围不需要 Handler 或其他通信原语来在后台线程与主线程之间传递数据,这在纯多线程环境(无协程)中仍然是必需的。实际上,通过协程我们成功解决了线程模型中遇到的所有问题,包括取消机制。

使用协程和挂起函数的代码也可以更易读,因为可以减少隐式或间接指令(例如嵌套调用或 SAM 实例,如 第六章 中所述)。此外,IntelliJ 和 Android Studio 使用特殊图标使这些挂起调用在边栏中突出显示。

在本节中,我们仅仅触及了取消操作的表面。接下来的部分将全面介绍使用协程进行取消操作所需的所有知识。

取消操作

处理任务取消是 Android 应用的关键部分。当用户首次导航到显示徒步旅行列表及其统计和天气的视图时,从视图模型启动了大量协程。如果用户因某些原因决定离开视图,则视图模型启动的任务可能无用。当然,除非用户稍后重新导航到视图,但假设这一点是危险的。为了避免浪费资源,在此情况下的一个良好做法是取消所有与不再需要的视图相关的进行中的任务。这是您可能会自行实施的取消的一个很好的例子,作为应用程序设计的一部分。还有另一种类型的取消:在发生不良情况时发生的取消。因此,在这里我们将区分这两种类型:

设计的取消

例如,用户在自定义或任意 UI 中点击“取消”按钮后取消的任务。

失败取消

例如,由异常引起的取消,无论是有意(抛出)还是意外(未处理)。

记住这两种类型的取消,因为您会发现协程框架对它们有不同的处理方式。

协程生命周期

要理解取消工作的方式,您需要了解协程的生命周期,如 图 8-4 所示。

生命周期

图 8-4. 协程生命周期。

当协程例如使用 launch {..} 函数创建时,没有额外的上下文或参数,它会在 Active 状态下创建。这意味着当调用 launch 时,它会立即开始。这也称为 eagerly 启动。在某些情况下,您可能希望 lazily 启动协程,这意味着它在手动启动之前不会执行任何操作。要做到这一点,launchasync 都可以接受名为“start”的命名参数,类型为 CoroutineStart。默认值是 CoroutineStart.DEFAULT(eager start),但您可以使用 CoroutineStart.LAZY,如以下代码所示:

val job = scope.launch(start = CoroutineStart.LAZY) { ... }
// some work
job.start()

不要忘记调用 job.start()!因为当协程懒惰启动时,需要显式启动它。^(7) 默认情况下,您不必这样做,因为协程是在 Active 状态下创建的。

当协程完成其工作后,它会保持在 Completing 状态,直到其所有子协程都达到 Completed 状态(参见 第七章)。然后它才会达到 Completed 状态。像往常一样,让我们打开源代码并查看以下内容:

viewModelScope.launch {
    launch {
        fetchData()   // might take some time
    }
    launch {
        fetchOtherData()
    }
}

这个 viewModelScope.launch 几乎立即完成其工作:它只启动了两个子协程,并且自身没有其他操作。它很快进入 Completing 状态,并且只有在子协程完成时才进入 Completed 状态。

协程取消

当处于ActiveCompleting状态时,如果抛出异常或逻辑调用cancel(),协程将转换为Cancelling状态。如果需要,这时可以执行必要的清理工作。协程将保持在Cancelling状态,直到清理工作完成。然后协程才会转换到Cancelled状态。

作业保持状态

所有这些生命周期状态都由协程的Job持有。Job没有名为“state”的属性(其值范围从“NEW”到“COMPLETED”)。相反,状态由三个布尔标志(isActiveisCancelledisCompleted)表示。每个状态由这些标志的组合表示,如你在 Table 8-1 中所见。

表 8-1. Job 状态

State isActive isCompleted isCancelled
New (optional initial state) false false false
Active (default initial state) true false false
Completing (transient state) true false false
Cancelling (transient state) false false true
Cancelled (final state) false true true
Completed (final state) false true false

正如你所见,仅凭这些布尔值无法区分Completing状态和Active状态。不过,在大多数情况下,你真正关心的是特定标志的值,而不是状态本身。例如,如果你检查isActive,实际上是同时检查ActiveCompleting状态。在下一节中会详细讨论这个问题。

取消协程

让我们看看下面的例子,其中有一个协程简单地每秒在控制台上打印"job: I'm working.."两次。父协程在取消此协程之前稍等一下:

val startTime = System.currentTimeMillis()
val job = launch(Dispatchers.Default) {
    var nextPrintTime = startTime
    while (true) {
        if (System.currentTimeMillis() >= nextPrintTime) {
            println("job: I'm working..")
            nextPrintTime += 500
        }
    }
}
delay(1200)
println("main: I'm going to cancel this job")
job.cancel()
println("main: Done")

你可以看到由launch返回的Job实例有一个cancel()方法。顾名思义,它取消正在运行的协程。顺便说一句,由async协程构建器返回的Deferred实例也有这个cancel()方法,因为Deferred实例是一个特殊的Job

回到我们的例子:你可能期望这段小代码打印“job: I'm working..”三次。实际上输出结果是:

job: I'm working..
job: I'm working..
job: I'm working..
main: I'm going to cancel this job
main: Done
job: I'm working..
job: I'm working..

因此,尽管父协程取消了,子协程仍在运行。这是因为子协程不配合取消。有几种方法可以改变这种情况。第一种方法是定期检查协程的取消状态,使用isActive,如下面的代码所示:

val job = launch(Dispatchers.Default) {
    var nextPrintTime = startTime
    while (isActive) {
        if (System.currentTimeMillis() >= nextPrintTime) {
            println("job: I'm working..")
            nextPrintTime += 500
        }
    }
}

你可以这样调用isActive,因为它是CoroutineScope的扩展属性,如下面的代码所示:

/**
 * Returns true when the current Job is still active (has not
 * completed and was not cancelled yet).
 */
val CoroutineScope.isActive: Boolean (source)

现在代码与取消是协作的,结果如下:

job: I'm working..
job: I'm working..
job: I'm working..
main: I'm going to cancel this job
main: Done

使用isActive只是简单地读取一个布尔值。确定是否应该停止作业,以及该逻辑的设置和执行,是你的责任。

可以使用 ensureActive 代替 isActiveisActiveensureActive 的区别在于,后者如果作业不再活动,立即抛出 CancellationException

因此,ensureActive 可以替换以下代码:

if (!isActive) {
    throw CancellationException()
}

类似于 Thread.yield(),还有第三种可能性:yield(),它是一个挂起函数。除了检查作业的取消状态外,底层线程被释放并可以用于其他协程。当使用 Dispatchers.Default(或类似的)在协程中执行 CPU 密集型计算时,这是非常有用的。通过在战略位置放置 yield(),您可以避免耗尽线程池。换句话说,如果这些资源可以更好地为另一个进程提供服务,您可能不希望协程过于自私,保持一个核心忙碌,负责特定的上下文责任一段时间。

当取消发生在您的代码内部时,中断协程的这些方式是完美的。如果您将某些工作委托给第三方库,例如 HTTP 客户端,该怎么办呢?

取消委托给第三方库的任务

OkHttp 是 Android 上广泛部署的 HTTP 客户端。如果您对这个库不熟悉,以下是从官方文档摘录的片段,用于执行同步 GET 请求:

fun run() {
    val request = Request.Builder()
        .url("https://publicobject.com/helloworld.txt")
        .build()

    client.newCall(request).execute().use { response ->
      if (!response.isSuccessful)
          throw IOException("Unexpected code $response")

      for ((name, value) in response.headers) {
        println("$name: $value")
      }

      println(response.body?.string())
    }
}

这个例子非常简单。client.newCall(request) 返回一个 Call 的实例。您可以在代码继续进行的同时排队一个 Callback 的实例。这是可取消的吗?是的。可以使用 call.cancel() 手动取消 Call

在使用协程时,前面的例子是您可能在协程内部编写的代码。如果在进行 HTTP 请求的协程取消时自动完成取消将是理想的。否则,以下展示了您必须编写的代码:

if (!isActive) {
    call.cancel()
    return
}

显而易见的警告是,它会污染您的代码,更不用说您可能会忘记添加此检查或者将其放置在错误的位置。必须有更好的解决方案。

幸运的是,协程框架提供了专门设计的函数,可以将期望回调的函数转换为挂起函数。它们有多种变体,包括 suspendCancellableCoroutine。后者旨在创建一个与取消协作的挂起函数。

下面的代码展示了如何创建一个挂起函数作为Call的扩展函数,它是可取消的,并且挂起直到您获得您的 HTTP 请求的响应或发生异常:

suspend fun Call.await() = suspendCancellableCoroutine<ResponseBody?> {
    continuation ->

    continuation.invokeOnCancellation {
        cancel()
    }

    enqueue(object : Callback {
        override fun onResponse(call: Call, response: Response) {
            continuation.resume(response.body)
        }

        override fun onFailure(call: Call, e: IOException) {
            continuation.resumeWithException(e)
        }
    })
}

如果您从未见过这样的代码,对其复杂性感到畏惧是很自然的。但是好消息是,这个函数是完全通用的 - 您只需要编写一次即可。如果您愿意,可以将它放在项目的“util”包中,或者在您的并行包中;或者只需记住基本知识,在执行类似的转换时使用其某个版本即可。

在展示这种实用方法的好处之前,我们需要给您一个详细的解释。

在第七章中,我们解释了 Kotlin 编译器为每个挂起函数生成一个Continuation实例。suspendCancellableCoroutine函数允许您使用这个Continuation实例。它接受一个带有CancellableContinuation作为接收器的 lambda 表达式,如下所示:

public suspend inline fun <T> suspendCancellableCoroutine(
    crossinline block: (CancellableContinuation<T>) -> Unit
): T

CancellableContinuation是一个可取消的Continuation。我们可以注册一个回调函数,在取消时调用它,使用invokeOnCancellation { .. }。在这种情况下,我们只需取消Call。由于我们在Call的扩展函数内部,我们添加以下代码:

continuation.invokeOnCancellation {
    cancel()   // Call.cancel()
}

在指定了挂起函数取消时应发生的事情之后,我们通过调用Call.enqueue()执行实际的 HTTP 请求,提供一个Callback实例。当对应的Continuation使用resumeresumeWithException恢复时,挂起函数就会“恢复”或“停止挂起”。

当您获取到 HTTP 请求的结果时,Callback实例上的onResponseonFailure将被调用。如果调用了onResponse,这是“快乐路径”。您收到了响应,现在可以使用您选择的结果恢复继续。如图 8-5 图所示,我们选择了 HTTP 响应的主体。与此同时,在“悲伤路径”上,将调用onFailure,并且OkHttp API会给您一个IOException的实例。

快乐路径/悲伤路径

图 8-5. (1)首先,设备将向服务器发送一个 HTTP 请求。(2)返回的响应类型将决定接下来会发生什么。(3)如果请求成功,则调用onResponse。否则,将执行onFailure

使用resumeWithException恢复该异常的继续是非常重要的,这是一个例外情况。这样,协程框架就能够知道这个挂起函数的失败,并将此事件传播到协程层次结构的所有位置。

现在到了最精彩的部分:展示如何在协程中使用它,如下所示:

fun main() = runBlocking {
    val job = launch {                                        ![1](https://github.com/OpenDocCN/ibooker-mobi-zh/raw/master/docs/prog-andr-kt/img/1.png)
        val response = performHttpRequest()                   ![2](https://github.com/OpenDocCN/ibooker-mobi-zh/raw/master/docs/prog-andr-kt/img/2.png)
        println("Got response ${response?.string()}")
    }
    delay(200)                                                ![3](https://github.com/OpenDocCN/ibooker-mobi-zh/raw/master/docs/prog-andr-kt/img/3.png)
    job.cancelAndJoin()                                       ![4](https://github.com/OpenDocCN/ibooker-mobi-zh/raw/master/docs/prog-andr-kt/img/4.png)
    println("Done")
}

val okHttpClient = OkHttpClient()
val request = Request.Builder().url(
    "http://publicobject.com/helloworld.txt"
).build()

suspend fun performHttpRequest(): ResponseBody? {
     return withContext(Dispatchers.IO) {
         val call = okHttpClient.newCall(request)
         call.await()
     }
}

1

我们首先使用launch启动一个协程。

2

在由launch返回的协程内部,我们调用一个挂起函数performHttpRequest,它使用Dispatchers.IO。这个挂起函数创建一个新的Call实例,然后在其上调用我们的挂起await()。在这一点上,执行了一个 HTTP 请求。

3

与此同时,当Dispatchers.IO的某个线程完成步骤 2 时,我们的主线程继续执行主方法,并立即遇到delay(200)。主线程上运行的协程会挂起 200 毫秒。

4

过了 200 毫秒后,我们调用job.cancelAndJoin(),这是一个方便的方法,相当于job.cancel(),然后job.join()。因此,如果 HTTP 请求花费的时间超过 200 毫秒,由launch启动的协程仍处于Active状态。挂起的performHttpRequest还没有返回。调用job.cancel()取消了协程。由于结构化并发,协程知道它所有的子协程。取消操作会向下传播到整个层次结构。performHttpRequestContinuation被取消,HTTP 请求也被取消。如果 HTTP 请求花费时间少于 200 毫秒,job.cancelAndJoin()没有效果。

无论 HTTP 请求在协程层次结构的多深处执行,如果使用我们预定义的Call.await(),则取消Call时会触发。

与取消协作的协程

您刚刚看到了使协程可取消的各种技术。实际上,协程框架有一个约定:行为良好的可取消协程在取消时会抛出CancellationException。为什么呢?让我们看看下面的代码中的这个挂起函数:

suspend fun wasteCpu() = withContext(Dispatchers.Default) {
    var nextPrintTime = System.currentTimeMillis()
    while (isActive) {
        if (System.currentTimeMillis() >= nextPrintTime) {
            println("job: I'm working..")
            nextPrintTime += 500
        }
    }
}

多亏了isActive检查,它确实是可取消的。想象一下,当函数被取消时,您需要进行一些清理工作。当isActive == false时,您知道函数被取消了,因此您可以在最后添加一个清理块,如下所示:

suspend fun wasteCpu() = withContext(Dispatchers.Default) {
    var nextPrintTime = System.currentTimeMillis()
    while (isActive) {
        if (System.currentTimeMillis() >= nextPrintTime) {
            println("job: I'm working..")
            nextPrintTime += 500
        }
    }

    // cleanup
    if (!isActive) { .. }
}

有时,您需要在取消的函数之外进行清理逻辑;例如,当此函数来自外部依赖时。因此,您需要找到一种方法来通知调用堆栈该函数已取消。异常非常适合此目的。这就是为什么协程框架遵循抛出CancellationException的约定。实际上,kotlinx.coroutines包中的所有挂起函数都是可取消的,并且在取消时抛出CancellationExceptionwithContext就是其中之一,因此您可以在调用堆栈中更高层次地响应wasteCpu的取消,如下面的代码所示:

fun main() = runBlocking {
    val job = launch {
        try {
            wasteCpu()
        } catch (e: CancellationException) {
            // handle cancellation
        }
    }
    delay(200)
    job.cancelAndJoin()
    println("Done")
}

如果运行此代码,您会发现捕获到了CancellationException。即使我们从wasteCpu()内部从未显式地抛出过CancellationExceptionwithContext也为我们抛出了它。

注意

仅在取消情况下引发CancellationException时,协程框架才能区分简单的取消和协程失败。在后一种情况下,将引发一个不是CancellationException子类型的异常。

如果您希望调查协程取消,您可以命名您的协程,并通过添加 VM 选项-Dkotlinx.coroutines.debug在 IDE 中启用协程调试。要命名协程,只需添加CoroutineName上下文元素,如下所示:val job = launch(CoroutineName("wasteCpu")) {..}。这样,当捕获CancellationException时,堆栈跟踪会更加明确,并以以下行开始:

kotlinx.coroutines.JobCancellationException: StandaloneCoroutine was cancelled; job="wasteCpu#2":StandaloneCoroutine{Cancelling}@53bd815b

在上一个例子中,如果您将wasteCpu()替换为我们之前用suspendCancellableCoroutine创建的挂起函数performHttpRequest(),您还会发现捕获到CancellationException。因此,使用suspendCancellableCoroutine创建的挂起函数在取消时也会抛出CancellationException

delay 可取消

还记得delay()吗?其签名如下所示:

public suspend fun delay(timeMillis: Long) {
    if (timeMillis <= 0) return // don't delay
    return suspendCancellableCoroutine sc@ { .. }
}

再次是suspendCancellableCoroutine!这意味着无论您在哪里使用delay,您都为协程或挂起函数提供了取消的机会。基于此,我们可以按照以下方式重新编写wasteCpu()

private suspend fun wasteCpu() = withContext(Dispatchers.Default) {
    var nextPrintTime = System.currentTimeMillis()
    while (true) {       ![1](https://github.com/OpenDocCN/ibooker-mobi-zh/raw/master/docs/prog-andr-kt/img/1.png)
        delay(10)        ![2](https://github.com/OpenDocCN/ibooker-mobi-zh/raw/master/docs/prog-andr-kt/img/2.png)
        if (System.currentTimeMillis() >= nextPrintTime) {
            println("job: I'm working..")
            nextPrintTime += 500
        }
    }
}

请注意:

1

我们去掉了isActive检查。

2

然后我们增加了一个简单的delay,睡眠时间足够短(使行为类似于以前的实现)。

这个新版本的wasCpu结果与原版一样可取消,并在取消时抛出CancellationException。这是因为这个挂起函数大部分时间都在delay函数中度过。

提示

总结本节,您应该努力使您的挂起函数可取消。一个挂起函数可能由多个挂起函数组成。它们都应该是可取消的。例如,如果您需要执行大量计算密集型计算,那么您应该在合适的位置使用yield()ensureActive()。例如:

suspend fun compute() = withContext(Dispatchers.Default) {
     blockingCall()  // a regular blocking call, hopefully not blocking too long
     yield()  // give the opportunity to cancel
     anotherBlockingCall()   // because why not
}

处理取消

在前一节中,您已经学会了通过 try/catch 语句对取消作出反应。但是,想象一下,在处理取消的代码内部,您需要调用其他挂起函数。您可能会想要实现以下代码中显示的策略:

launch {
    try {
        suspendCall()
    } catch (e: CancellationException) {
       // handle cancellation
       anotherSuspendCall()
   }
}

遗憾的是,前面的代码无法编译。为什么?因为取消的协程不允许挂起。这是协程框架的另一个规则。解决方案是使用withContext(NonCancellable),如下所示:

launch {
    try {
        suspendCall()
    } catch (e: CancellationException) {
       // handle cancellation
       withContext(NonCancellable) {
            anotherSuspendCall()
       }
   }
}

NonCancellable是专门为withContext设计,以确保提供的代码块不会被取消。^(8)

取消的原因

正如我们之前看到的,有两种取消方式:按设计按失败。最初,我们说当抛出异常时遇到失败。这有点言过其实。你刚刚看到,当自愿取消一个协程时,会抛出 CancellationException。这实际上是区分这两种取消方式的标志。

当取消一个协程 Job.cancel(按设计),该协程终止而不影响其父协程。如果父协程还有其他子协程,它们也不受此取消影响。以下代码说明了这一点:

fun main() = runBlocking {
    val job = launch {
        val child1 = launch {
            delay(Long.MAX_VALUE)
        }
        val child2 = launch {
            child1.join()
            println("Child 1 is cancelled")

            delay(100)
            println("Child 2 is still alive!")
        }

        println("Cancelling child 1..")
        child1.cancel()
        child2.join()
        println("Parent is not cancelled")
    }
    job.join()
}

此程序的输出为:

Cancelling child 1..
Child 1 is cancelled
Child 2 is still alive!
Parent is not cancelled

child1 永久延迟,而 child2 等待 child1 继续。父协程迅速取消 child1,我们可以看到 child1 确实被取消了,因为 child2 继续执行。最后,“Parent is not cancelled” 的输出证明父协程未受此取消影响(顺便说一句,child2 也没有受影响)。

另一方面,在失败的情况下(如果抛出了与 CancellationException 不同的异常),默认行为是父协程用该异常取消。如果父协程还有其他子协程,它们也会被取消。让我们试着说明这一点。剧透警告——不要照着以下内容做:

fun main() = runBlocking {
    val scope = CoroutineScope(coroutineContext + Job())    ![1](https://github.com/OpenDocCN/ibooker-mobi-zh/raw/master/docs/prog-andr-kt/img/1.png)

    val job = scope.launch {                                ![2](https://github.com/OpenDocCN/ibooker-mobi-zh/raw/master/docs/prog-andr-kt/img/2.png)
        launch {
            try {
                delay(Long.MAX_VALUE)                       ![3](https://github.com/OpenDocCN/ibooker-mobi-zh/raw/master/docs/prog-andr-kt/img/3.png)
            } finally {
                println("Child 1 was cancelled")
            }
        }

        launch {
            delay(1000)                                     ![4](https://github.com/OpenDocCN/ibooker-mobi-zh/raw/master/docs/prog-andr-kt/img/4.png)
            throw IOException()
        }
    }
    job.join()                                              ![5](https://github.com/OpenDocCN/ibooker-mobi-zh/raw/master/docs/prog-andr-kt/img/5.png)
}

我们尝试创建这样一种情况:一个子协程在一段时间后失败,我们希望检查它是否导致父协程失败。然后,我们需要确认,假设这是我们传递的取消策略,该父协程的所有其他子协程也应该被取消。

乍一看,这段代码看起来没问题:

1

我们正在创建父作用域。

2

我们在这个作用域内部启动一个新的协程。

3

第一个子协程无限等待。如果取消此子协程,它应该打印“Child 1 was cancelled”,因为从 delay(Long.MAX_VALUE) 中会抛出 CancellationException

4

另一个子协程在延迟 1 秒后抛出 IOException

5

等待在步骤 2 中启动的协程。如果不这样做,runBlocking 的执行将终止,程序停止。

运行此程序,你确实会看到“Child 1 was cancelled”,尽管程序立即因未捕获的 IOException 而崩溃。即使你用 try/catch 块包围 job.join(),仍会导致崩溃。

我们在这里缺少的是异常的发生地点。它是从launch内部抛出的,该异常通过协程层次结构向上传播,直到达到父作用域。这种行为无法被覆盖。一旦scope捕获到异常,它将取消自身和所有子作用域,然后将异常传播到其父作用域,即runBlocking的作用域。

重要的是要意识到,试图捕获异常并不会改变runBlocking的根协程因该异常而被取消的事实。

在某些情况下,您可能认为这是可以接受的场景:任何未处理的异常都会导致程序崩溃。但在其他情况下,您可能希望防止作用域的失败传播到主协程。为此,您需要注册一个CoroutineExceptionHandler(CEH):

fun main() = runBlocking {
    val ceh = CoroutineExceptionHandler { _, exception ->
        println("Caught original $exception")
    }
    val scope = CoroutineScope(coroutineContext + ceh + Job())

    val job = scope.launch {
         // same as in the previous code sample
    }
}

CoroutineExceptionHandler在概念上与Thread.UncaughtExceptionHandler非常相似——只是它用于协程。它是一个Context元素,应添加到作用域或协程的上下文中。作用域应该创建自己的Job实例,因为 CEH 仅在安装在协程层次结构的顶部时才会生效。在前面的示例中,我们将 CEH 添加到了作用域的上下文中。我们也可以将其添加到第一个launch的上下文中,如下所示:

fun main() = runBlocking {
    val ceh = CoroutineExceptionHandler { _, exception ->
        println("Caught original $exception")
    }

    // The CEH can also be part of the scope
    val scope = CoroutineScope(coroutineContext + Job())

    val job = scope.launch(ceh) {
        // same as in the previous code sample
    }
}

运行此示例时使用异常处理程序,程序的输出如下:

Child 1 was cancelled
Caught original java.io.IOException

程序不再崩溃。在 CEH 实现内部,您可以重试先前失败的操作。

此示例演示了默认情况下,协程的失败会导致其父作用域及其所有其他子作用域被取消。如果此行为不符合您的应用程序设计,该怎么办?有时,协程的失败是可以接受的,并且不需要取消同一作用域内启动的所有其他协程。在协程框架中,这称为监督

监督

考虑加载片段布局的真实示例。每个子View可能需要一些后台处理才能完全构建。假设您使用的作用域默认为主线程,并且使用子协程进行后台任务处理,其中一个任务的失败不应导致父作用域的失败。否则,整个片段对用户将变得无响应。

要实现这种取消策略,您可以使用SupervisorJob,它是一个Job,其子作业的失败或取消不会影响其他子作业,也不会影响作用域本身。当构建CoroutineScope时,通常将SupervisorJob用作Job的替代品。然后称该作用域为“监督作用域”。这样的作用域仅向下传播取消,如以下代码所示:

fun main() = runBlocking {
    val ceh = CoroutineExceptionHandler { _, e -> println("Handled $e") }
    val supervisor = SupervisorJob()
    val scope = CoroutineScope(coroutineContext + ceh + supervisor)
    with(scope) {
        val firstChild = launch {
            println("First child is failing")
            throw AssertionError("First child is cancelled")
        }

        val secondChild = launch {
            firstChild.join()

            delay(10) // playing nice with hypothetical cancellation
            println("First child is cancelled: ${firstChild.isCancelled}, but second one is still active")
        }

        // wait until the second child completes
        secondChild.join()
    }
}

此示例的输出是:

First child is failing
Handled java.lang.AssertionError: First child is cancelled
First child is cancelled: true, but second one is still active

注意,在范围的上下文中我们已经安装了一个 CEH。为什么呢?第一个子任务抛出了一个未被捕获的异常。即使监督范围不受子任务失败的影响,它仍会传播未处理的异常——正如你所知,这可能导致程序崩溃。这正是 CEH 的目的:处理未捕获的异常。有趣的是,CEH 也可以安装在第一个launch的上下文中,结果相同,如下所示:

fun main() = runBlocking {
    val ceh = CoroutineExceptionHandler { _, e -> println("Handled $e") }
    val supervisor = SupervisorJob()
    val scope = CoroutineScope(coroutineContext + supervisor)
    with(scope) {
        val firstChild = launch(ceh) {
            println("First child is failing")
            throw AssertionError("First child is cancelled")
        }

        val secondChild = launch {
            firstChild.join()

            delay(10)
            println("First child is cancelled: ${firstChild.isCancelled}, but second one is still active")
        }

        // wait until the second child completes
        secondChild.join()
    }
}

CEH 应安装在协程层次结构的顶部,这是处理未捕获异常的理想位置。

在这个例子中,CEH 安装在协程范围的直接子任务上。你可以在嵌套协程上安装它,就像下面这样:

val firstChild = launch {
    println("First child is failing")
    launch(ceh) {
       throw AssertionError("First child is cancelled")
    }
}

在这种情况下,CEH 没有被考虑在内,程序可能会崩溃。

supervisorScope 构建器

coroutineScope构建器类似,它继承当前上下文并创建一个新的JobsupervisorScope创建一个SupervisorJob。与coroutineScope一样,它等待所有子任务完成。与coroutineScope的一个关键区别是它只向下传播取消,并且仅当它自己失败时才取消所有子任务。与coroutineScope的另一个区别是异常的处理方式。我们将在下一节深入探讨这一点。

并行分解

想象一下,一个挂起函数在返回其结果之前必须并行运行多个任务。例如,在本章开头的我们的徒步应用中的挂起函数weatherForHike。获取天气可能涉及多个 API,具体取决于数据的性质。风力数据和温度可能分别从不同的数据源获取。

假设你有挂起函数fetchWindfetchTemperatures,你可以实现weatherForHike如下:

private suspend fun weatherForHike(hike: Hike): Weather =
        withContext(Dispatchers.IO) {
   val deferredWind = async { fetchWind(hike) }
   val deferredTemp = async { fetchTemperatures(hike) }
   val wind = deferredWind.await()
   val temperatures = deferredTemp.await()
   Weather(wind, temperatures) // assuming Weather can be built that way
}

在这个例子中也可以使用async,因为withContext提供了一个CoroutineScope——它的最后一个参数是一个挂起 lambda,以CoroutineScope作为接收者。如果没有withContext,这个示例就无法编译,因为没有提供任何作用域给async使用。

withContext在需要在挂起函数内部更改调度器时特别有用。如果不需要更改调度器呢?那么,挂起weatherForHike很可能已经从已经调度到 IO 调度器的协程中调用。那么,使用withContext(Dispatchers.IO)将是多余的。在这种情况下,你可以使用coroutineScope替代或与withContext结合使用。它是一个CoroutineScope构建器,你可以像下面这样使用:

private suspend fun weatherForHike(hike: Hike): Weather = coroutineScope {
    // Wind and temperature fetch are performed concurrently
    val deferredWind = async(Dispatchers.IO) {
        fetchWind(hike)
    }
    val deferredTemp = async(Dispatchers.IO) {
        fetchTemperatures(hike)
    }
   val wind = deferredWind.await()
   val temperatures = deferredTemp.await()
   Weather(wind, temperatures) // assuming Weather can be built that way
}

在这里,coroutineScope替换了withContext。那么,coroutineScope做什么呢?首先,看一下它的签名:

public suspend fun <R> coroutineScope(block: suspend CoroutineScope.() -> R): R

根据官方文档,此函数创建一个CoroutineScope并使用此范围调用指定的suspend块。提供的范围从外部范围继承其coroutineContext,但覆盖了上下文的Job

这个函数被设计用于并行分解工作。当此作用域内的任何子协程失败时,此作用域失败,并且所有其余的子协程都会被取消(如果需要不同的行为,请使用supervisorScope)。此函数在给定的代码块及其所有子协程完成后返回。

自动取消

应用于我们的示例,如果fetchWind失败,则由coroutineScope提供的作用域失败,并且随后取消fetchTemperatures。如果fetchTemperatures涉及分配大型对象,您可以看到取消的好处。

coroutineScope 在需要同时执行多个任务时表现出色。

异常处理

异常处理是应用程序设计的重要部分。有时,您会在异常被引发后立即捕获它们,而其他时候则会让它们向上层级冒泡,直到专用组件处理它。在这种程度上,语言结构try/catch可能是您到目前为止使用的内容。然而,在协程框架中,有一个小陷阱(有点双关语)。我们本章可以从中开始,但我们首先需要向您介绍监督CoroutineExceptionHandler

未处理与暴露的异常

当涉及异常传播时,未捕获的异常可以被协程机制处理为以下之一:

未处理给客户端代码

未处理的异常只能由CoroutineExceptionHandler处理。

暴露给客户端代码

暴露的异常是客户端代码可以使用try/catch处理的异常。

在此事项中,我们可以根据协程构建器如何处理未捕获的异常将协程构建器区分为两类:

  • 未处理的(launch 是其中之一)

  • 暴露的(async 是其中之一)

首先,请注意我们谈论的是未捕获的异常。如果在协程构建器处理之前捕获异常,一切都正常——您捕获了它,因此协程机制并不知晓它。以下是使用launchtry/catch的示例:

scope.launch {
    try {
        regularFunctionWhichCanThrowException()
    } catch (e: Exception) {
        // handle exception
    }
}

如果regularFunctionWhichCanThrowException正如其名称所示,是一个不直接或间接涉及其他协程构建器的常规函数,则此示例会按预期工作(在这种情况下,可能会应用特殊规则,我们稍后将在本章中详细介绍)。

相同的思路适用于async构建器,如下所示:

fun main() = runBlocking {

    val itemCntDeferred = async {
        try {
            getItemCount()
        } catch (e: Exception) {
            // Something went wrong. Suppose you don't care and consider it should return 0.
            0
        }
    }

    val count = itemCntDeferred.await()
    println("Item count: $count")
}

fun getItemCount(): Int {
    throw Exception()
    1
}

此程序的输出,正如您可以轻易猜到的那样:

Item count: 0

或者,您可以使用runCatching而不是try/catch。如果考虑到快乐路径是在没有抛出异常时:

scope.launch {
     val result = runCatching {
           regularFunctionWhichCanThrowException()
     }

     if (result.isSuccess) {
         // no exception was thrown
     } else {
         // exception was thrown
     }
}

在底层,runCatching其实就是一个try/catch,返回一个Result对象,提供一些便捷方法如getOrNull()exceptionOrNull(),如下所示:

/**
 * Calls the specified function [block] with `this` value as its receiver
 * and returns its encapsulated result if invocation was successful,
 * catching and encapsulating any thrown exception as a failure.
 */
public inline fun <T, R> T.runCatching(block: T.() -> R): Result<R> {
    return try {
        Result.success(block())
    } catch (e: Throwable) {
        Result.failure(e)
    }
}

一些扩展函数被定义在Result上,并且可以直接使用,比如getOrDefault,如果Result.isSuccesstrue,则返回Result实例的封装值,否则返回提供的默认值。

暴露的异常

正如我们之前所述,你可以使用内置语言支持来捕获暴露的异常:try/catch。以下代码展示了我们在自己的作用域内创建了两个并发任务task1task2的示例,在supervisorScope中启动。task2立即失败:

fun main() = runBlocking {

    val scope = CoroutineScope(Job())

    val job = scope.launch {
        supervisorScope {
            val task1 = launch {
                // simulate a background task
                delay(1000)
                println("Done background task")
            }

            val task2 = async {
                // try to fetch some count, but it fails
                throw Exception()
                1
            }

            try {
                task2.await()
            } catch (e: Exception) {
                println("Caught exception $e")
            }
            task1.join()
        }
    }

    job.join()
    println("Program ends")
}

该程序的输出是:

Caught exception java.lang.Exception
Done background task
Program ends

此示例演示了在supervisorScope内部,async await 调用中暴露了未捕获的异常。如果你不用try/catch块包围await调用,那么supervisorScope的作用域会失败并取消task1,然后其父级暴露导致其失败的异常。因此,即使在使用supervisorScope时,作用域内的未处理异常也会导致整个协程层次结构的取消,并且异常会向上传播。通过在此示例中处理异常的方式,任务 2 失败而任务 1 不受影响。

有趣的是,如果你不调用task2.await(),程序会执行,就好像从未抛出异常一样——task2会静默失败。

现在我们将使用完全相同的示例,但是使用coroutineScope而不是supervisorScope

fun main() = runBlocking {

    val scope = CoroutineScope(Job())

    val job = scope.launch {
        coroutineScope {
            val task1 = launch {
                delay(1000)
                println("Done background task")
            }

            val task2 = async {
                throw Exception()
                1
            }

            try {
                task2.await()
            } catch (e: Exception) {
                println("Caught exception $e")
            }
            task1.join()
        }
    }

    job.join()
    println("Program ends")
}

该程序的输出是:

Caught exception java.lang.Exception

然后程序在安卓上由于java.lang.Exception而崩溃——我们很快会解释这个问题。

从中你可以学到,在coroutineScope内部,async 暴露了未捕获的异常,但同时也通知其父级。如果你不调用task2.await(),程序仍会崩溃,因为coroutineScope失败并暴露了导致其失败的异常。然后,scope.launch将这个异常视为未处理的异常。

未处理的异常

协程框架以特定方式处理作用域内的未处理异常:尝试使用 CEH(CoroutineExceptionHandler),如果协程上下文有的话。如果没有,就委托给全局处理程序。此处理程序调用一组可自定义的 CEH,调用未处理异常的标准机制:Thread.uncaughtExceptionHandler。在安卓上,默认情况下,先前提到的处理程序集仅由一个单独的 CEH 组成,它打印未处理异常的堆栈跟踪。但是,可以注册自定义处理程序,它将在打印堆栈跟踪的处理程序之外调用。因此,请记住,如果不处理异常,将调用Thread.uncaughtExceptionHandler

在 Android 上,默认的UncaughtExceptionHandler会使您的应用程序崩溃,而在 JVM 上^(9),默认的处理程序会将堆栈跟踪打印到控制台。因此,如果您不是在 Android 上执行此程序,而是在 JVM 上执行,则输出为:^(10)

Caught exception java.lang.Exception
(stacktrace of java.lang.Exception)
Program ends

回到 Android。你怎么处理这个异常?由于coroutineScope暴露异常,你可以将coroutineScope包装在try/catch语句中。另外,如果你没有正确处理它,前面的coroutineScopescope.launch,会将这个异常视为未处理的。那么你最后处理这个异常的机会就是注册一个 CEH。至少有两个原因会这样做:第一,停止异常的传播并避免程序崩溃;第二,通知您的崩溃分析并重新抛出异常——可能导致应用程序崩溃。无论如何,我们都不主张悄悄地捕获异常。如果您确实想要使用 CEH,有几件事情您应该知道。只有在以下情况下注册的 CEH 才会起作用:

  • launch是根协程构建器时(而不是async)^(11)

  • 一个范围

  • supervisorScope的直接子项

在我们的示例中,CEH 应该注册在 scope.launch 上,或者直接在范围上。下面的代码显示了这个在根协程上的示例:

fun main() = runBlocking {

    val ceh = CoroutineExceptionHandler { _, t ->
        println("CEH handle $t")
    }

    val scope = CoroutineScope(Job())

    val job = scope.launch(ceh) {
        coroutineScope {
            val task1 = launch {
                delay(1000)
                println("Done background task")
            }

            val task2 = async {
                throw Exception()
                1
            }

            task1.join()
        }
    }

    job.join()
    println("Program ends")
}

此程序的输出是:

Caught exception java.lang.Exception
CEH handle java.lang.Exception
Program ends

这里是同样的例子,这次在范围上注册了 CEH:

fun main() = runBlocking {

    val ceh = CoroutineExceptionHandler { _, t ->
        println("CEH handle $t")
    }

    val scope = CoroutineScope(Job() + ceh)

    val job = scope.launch {
       // same as previous example
    }
}

最后,我们演示了在supervisorScope的直接子项上使用 CEH 的方法:

fun main() = runBlocking {

    val ceh = CoroutineExceptionHandler { _, t ->
        println("CEH handle $t")
    }

    val scope = CoroutineScope(Job())

    val job = scope.launch {
        supervisorScope {
            val task1 = launch {
                // simulate a background task
                delay(1000)
                println("Done background task")
            }

            val task2 = launch(ceh) {
                // try to fetch some count, but it fails
                throw Exception()
            }

            task1.join()
            task2.join()
        }
    }

    job.join()
    println("Program ends")
}

注意 CEH 注册的协程构建器是launch。如果是async,它不会被考虑,因为它暴露未捕获的异常,可以使用try/catch处理。

摘要

  • 当函数可能不会立即返回时,将其实现为挂起函数是一个很好的选择。但是,suspend修饰符并不会自动将阻塞调用转换为非阻塞调用。使用withContext以及适当的Dispatcher,和/或调用其他挂起函数。

  • 一个协程可以通过Job.cancel()来被主动取消,用于launch,或者通过Deferred.cancel()用于async。如果你需要在清理代码中调用一些挂起函数,请确保将清理逻辑包装在withContext(NonCancellable) { .. }块中。被取消的协程会保持取消状态直到清理完成。清理完成后,上述协程进入取消状态。

  • 一个协程总是在其子协程完成之前等待自身完成。所以取消一个协程也会取消其所有子协程。

  • 您的协程应与取消协作。来自kotlinx.coroutines包的所有挂起函数都是可取消的。这特别包括withContext。如果您正在实现自己的挂起函数,请确保它是可取消的,方法是在适当的步骤中检查isActive或调用ensureActive()yield()

  • 协程作用域有两种类别:使用Job的作用域和使用SupervisorJob(也称为监督作用域)的作用域。它们在取消操作和异常处理方式上有所不同。如果子协程的失败也应该取消其他子协程,请使用普通作用域。否则,请使用监督作用域。

  • launchasync在处理未捕获的异常时有所不同。async 公开 异常,可以通过在await调用周围包装try/catch来捕获。另一方面,launch将未捕获的异常视为未处理的,可以使用 CEH 来处理它们。

  • CEH(未处理异常处理器)是可选的。只有在真正需要处理未处理异常时才应使用它。未处理的异常通常应导致应用程序崩溃。或者,从某些异常中恢复可能会使应用程序处于不确定状态。然而,如果决定使用 CEH,则应将其安装在协程层次结构的顶部——通常是在最顶层的作用域中。它也可以安装在supervisorScope的直接子级上。

  • 如果协程因未捕获的异常而失败,则它将与其所有子协程一起取消,并且异常将向上传播。

总结思考

您学会了如何编写自己的挂起函数,以及如何在协程中使用它们。您的协程存在于作用域内。为了实现所需的取消策略,您知道如何在coroutineScopesupervisorScope之间进行选择。您创建的作用域是更高层次作用域的子作用域。在 Android 中,这些“根”作用域由库提供——您不需要自己创建。一个很好的例子是任何ViewModel实例中都可以使用的viewModelScope

协程非常适合一次性或重复性任务。然而,我们经常需要处理异步数据流。ChannelFlow就是为此设计的,并将在接下来的两章中进行讲解。

^(1) 在执行 CPU 绑定任务时,工作者绑定到 CPU 核心。

^(2) 参见Java 并发实战(Addison-Wesley),Brian Goetz 等人,16.2.2。

^(3) 我们在第五章中提到过这一点。在这种情况下,这意味着我们从主线程向hikeDataList添加了一个新元素。

^(4) 除非Dispatchers.IO遭遇线程饥饿,这种情况非常罕见。

^(5) 它只是常规CoroutineScope的一个子类,其在close()方法中调用coroutineContext.cancel()

^(6) 注意,挂起函数方法的材料相对较短(三页半,相比传统方法的七页)——可能是因为挂起函数是一种更简单(也更易于解释)的解决方案。

^(7) 当懒启动时,协程处于 New 状态。只有在调用 job.start() 后,协程才会进入 Active 状态。调用 job.join() 也会启动协程。

^(8) NonCancellable 实际上是 Job 的特殊实现,始终处于 Active 状态。因此,在此上下文中使用 ensureActive() 的挂起函数永远不会被取消。

^(9) JVM 指的是桌面应用程序或服务器端。

^(10) 因为未处理的异常使得 scope 失败,而不是 runBlocking 中的作用域,所以打印了“程序结束”。

^(11) 根协程构建器是作用域的直接子级。在前面的例子中,在val job = scope.launch {..}这一行,launch 是一个根协程构建器。

第九章:通道

在前一章中,您学习了如何创建协程、取消它们并处理异常。因此,您知道如果任务 B 需要任务 A 的结果,可以将它们实现为两个依次调用的挂起函数。但如果任务 A 产生一系列的值怎么办?async和挂起函数并不适用于这种情况。这就是Channels^(1)的用途——让协程进行通信。在本章中,您将详细了解什么是通道以及如何使用它们。

仅使用通道和协程,我们可以使用通信顺序进程(CSP)设计复杂的异步逻辑。什么是 CSP?Kotlin 受到几种现有编程语言的启发,如 Java、C#、JavaScript、Scala 和 Groovy。值得注意的是,Go(语言)通过其“goroutines”与协程共同启发了它们。

在计算机科学中,CSP 是一种并发编程语言,由 Tony Hoare 于 1978 年首次描述。自那以后,它已经不断发展,现在 CSP 这个术语主要用来描述一种编程风格。如果您熟悉 Actor 模型,CSP 与之非常相似——尽管存在一些差异。如果您从未听说过 CSP,不要担心——我们将通过实际例子简要解释它的理念。目前,您可以将 CSP 视为一种编程风格。

与往常一样,我们将从一点理论开始,然后实施一个真实的问题。最后,我们将讨论使用协程的 CSP 的好处和权衡。

通道概述

回到我们的介绍性示例,想象一下一个任务异步地生成了三个Item实例的列表(生产者),而另一个任务则对每个项目进行操作(消费者)。由于生产者不会立即返回,您可以像以下这样实现getItems挂起函数:

suspend fun getItems(): List<Item> {
     val items = mutableListOf<Item>()
     items.add(makeItem())
     items.add(makeItem())
     items.add(makeItem())
     return items
}

suspend fun makeItem(): Item {
    delay(10) // simulate some asynchronism
    return Item()
}

至于消费者,它消费每一个项目,您可以简单地像这样实现:

fun consumeItems(items: List<Item>) {
     for (item in items) println("Do something with $item")
}

综合起来:

fun main() = runBlocking {
     val items = getItems()
     consumeItems(items)
}

如您所预期,“对..做一些事情”会打印三次。然而,在这种情况下,我们最感兴趣的是执行顺序。让我们更仔细地看一看实际发生了什么,如图 9-1 所示。

在图 9-1 中,只有在所有项目都被生产后,才会开始消费项目。生产项目可能需要一些时间,并且在某些情况下等待它们全部生产完毕是不可接受的。相反,我们可以异步地对每个生成的项目进行操作,如图 9-2 所示。

执行架构

图 9-1. 全部一起处理。

依次处理

图 9-2. 依次处理。

为了实现这一点,我们不能像之前那样将getItems实现为挂起函数。协程应该充当Item实例的生产者,并将它们发送到主协程。这是一个典型的生产者-消费者问题。

在 第五章 中,我们解释了如何使用 BlockingQueue 来实现 工作队列 —— 或者,在这种情况下,是一个数据队列。提醒一下,BlockingQueue 有阻塞方法 puttake,分别用于向队列中插入和从队列中取出对象。当队列被用作两个线程之间唯一的通信方式(一个生产者和一个消费者)时,它提供了一个巨大的好处,即避免了共享的可变状态。此外,如果队列是有界的(有大小限制),如果消费者太慢,一个过快的生产者最终将在 put 调用中被阻塞。这被称为背压:被阻塞的生产者给了消费者追赶的机会,从而释放了生产者。

如果使用 BlockingQueue 作为协程之间的通信原语并不是一个好主意,因为协程不应该涉及阻塞调用。相反,协程可以暂停。Channel 可以被看作是这样的:一个带有暂停函数 sendreceive 的队列,就像 图 9-3 所示。Channel 还有非暂停的对应方法:trySendtryReceive。这两种方法也是非阻塞的。trySend 尝试立即将元素添加到通道,并返回结果的包装类 ChannelResult<T>。该包装类也指示操作的成功或失败。tryReceive 尝试立即从通道中检索元素,并返回一个 ChannelResult<T> 实例。

一个通道可以发送和接收

图 9-3. 通道。

像队列一样,Channel 有多种不同的类型。我们将用基本示例来介绍每种 Channel 变体。

会合通道

“Rendezvous” 是法语词汇,意思是“约会”或“一次约会” —— 这取决于上下文(我们这里不是指 CoroutineContext)。会合通道完全没有缓冲区。只有当 sendreceive 调用在时间上会合(rendezvous)时,元素才从发送方传输到接收方,因此 send 会暂停,直到另一个协程调用 receive,而 receive 会暂停,直到另一个协程调用 send

另一种说法是,会合通道涉及生产者(调用 send 的协程)和消费者(调用 receive 的协程)之间的来回通信。在没有中间的 receive 的情况下,不能连续进行两次 send

默认情况下,当使用 Channel<T>() 创建通道时,会得到一个会合通道。

我们可以使用会合通道来正确实现我们之前的示例:

fun main() = runBlocking {
    val channel = Channel<Item>()
    launch {                        ![1](https://github.com/OpenDocCN/ibooker-mobi-zh/raw/master/docs/prog-andr-kt/img/1.png)
        channel.send(Item(1))       ![3](https://github.com/OpenDocCN/ibooker-mobi-zh/raw/master/docs/prog-andr-kt/img/3.png)
        channel.send(Item(2))       ![4](https://github.com/OpenDocCN/ibooker-mobi-zh/raw/master/docs/prog-andr-kt/img/4.png)
        println("Done sending")
    }

    println(channel.receive())      ![2](https://github.com/OpenDocCN/ibooker-mobi-zh/raw/master/docs/prog-andr-kt/img/2.png)
    println(channel.receive())      ![5](https://github.com/OpenDocCN/ibooker-mobi-zh/raw/master/docs/prog-andr-kt/img/5.png)

    println("Done!")
}

data class Item(val number: Int)

该程序的输出是:

Item(number=1)
Item(number=2)
Done!
Done sending

在这个例子中,主协程使用launch启动了一个子协程,在1处到达,然后到达2并暂停,直到某个协程在通道中发送了一个Item实例。稍后,子协程在3处发送第一个项目,然后在4处到达并暂停,直到某个协程准备好接收项目。随后,主协程(在2处暂停)被恢复,并从通道接收第一个项目并打印出来。然后主协程到达5,并立即接收第二个项目,因为子协程已经在send调用中暂停。紧接着,子协程继续执行(打印“Done sending”)。

遍历通道

可以使用常规的for循环迭代Channel。请注意,由于通道不是常规集合^(2),因此无法使用 Kotlin 标准库中的forEach或类似的其他函数。在这里,通道迭代是一种特定的语言级特性,只能使用for循环语法完成:

for (x in channel) {
   // do something with x every time some coroutine sends an element in
   // the channel
}

隐式地,每次迭代中x等于channel.receive()。因此,协程在通道上迭代可能会无限进行,除非它包含条件逻辑以中断循环。幸运的是,有一个标准机制来中断循环:关闭通道。这里是一个例子:

fun main() = runBlocking {
    val channel = Channel<Item>()
    launch {
        channel.send(Item(1))
        channel.send(Item(2))
        println("Done sending")
        channel.close()
    }

    for (x in channel) {
        println(x)
    }
    println("Done!")
}

这个程序有类似的输出,但有一个小的区别:

Item(number=1)
Item(number=2)
Done sending
Done!

这一次,“Done sending”出现在“Done!”之前,这是因为主协程只在channel关闭时离开通道迭代。而这发生在子协程完成发送所有元素时。

内部地,关闭一个通道会向通道发送一个特殊的令牌,表示不会再发送任何其他元素。由于通道中的项目是串行消耗的(一个接一个地),在关闭特殊令牌之前发送到会合通道的所有项目都保证会被发送到接收方。

警告

注意—尝试从已关闭的通道调用receive会抛出ClosedReceiveChannelException异常。然而,尝试在这样的通道上进行迭代不会抛出任何异常:

fun main() = runBlocking {
    val channel = Channel<Int>()
    channel.close()

    for (x in channel) {
        println(x)
    }
    println("Done!")
}

输出是:Done!

其他类型的通道

在前面的例子中,Channel似乎是使用类构造函数创建的。如果查看源代码,可以看到实际上是使用以大写字母开头的公共函数命名,以给出使用类构造函数的假象:

public fun <E> Channel(capacity: Int = RENDEZVOUS): Channel<E> =
    when (capacity) {
        RENDEZVOUS -> RendezvousChannel()
        UNLIMITED -> LinkedListChannel()
        CONFLATED -> ConflatedChannel()
        BUFFERED -> ArrayChannel(CHANNEL_DEFAULT_CAPACITY)
        else -> ArrayChannel(capacity)
    }

您可以看到此Channel函数具有一个capacity参数,默认为RENDEZVOUS。记录中,如果您进入RENDEZVOUS声明,您会看到它等于 0。每个capacity值都对应一个相应的通道实现。有四种不同的通道类型:会合、无限合并缓冲。不要过多关注具体实现(如RendezvousChannel()),因为这些类是内部的,未来可能会更改。另一方面,值RENDEZVOUSUNLIMITEDCONFLATEDBUFFERED是公共 API 的一部分。

我们将在接下来的章节中介绍每一种通道类型。

无限通道

无限通道有一个仅受可用内存限制的缓冲区。向此通道发送数据的发送者永不挂起,而接收者仅在通道为空时挂起。通过无限通道交换数据的协程无需在时间上满足。

此时,您可能会认为当发送者和接收者在不同线程上执行时,这样的通道应该存在并发修改问题。毕竟,协程是在线程上分发的,因此通道很可能会被不同线程使用。让我们自己检查Channel的健壮性!在以下示例中,我们从在Dispatchers.Default上分派的协程中发送Int到同一个通道,同时另一个协程从主线程读取同一通道,如果Channel不是线程安全的,我们会注意到:

fun main() = runBlocking {
    val channel = Channel<Int>(UNLIMITED)
    val childJob = launch(Dispatchers.Default) {
        println("Child executing from ${Thread.currentThread().name}")
        var i = 0
        while (isActive) {
            channel.send(i++)
        }
        println("Child is done sending")
    }

    println("Parent executing from ${Thread.currentThread().name}")
    for (x in channel) {
        println(x)

        if (x == 1000_000) {
            childJob.cancel()
            break
        }
    }

    println("Done!")
}

此程序的输出是:

Parent executing from main
Child executing from DefaultDispatcher-worker-2
0
1
..
1000000
Done!
Child is done sending

您可以随意运行此示例,并始终无并发问题。这是因为Channel内部使用无锁算法。^(3)

注意

Channel是线程安全的。多个线程可以以线程安全的方式同时调用sendreceive方法。

合并通道

此通道具有大小为 1 的缓冲区,并且仅保留最后发送的元素。要创建合并通道,您可以调用Channel<T>(Channel.CONFLATED)。例如:

fun main() = runBlocking {
    val channel = Channel<String>(Channel.CONFLATED)

    val job = launch {
        channel.send("one")
        channel.send("two")
    }

    job.join()
    val elem = channel.receive()
    println("Last value was: $elem")
}

此程序的输出是:

Last value was: two

第一个发送的元素是“one”。当发送two时,它替换了通道中的“one”。我们使用job.join()等待协程发送元素完成,然后从通道中读取值two

缓冲通道

缓冲通道是具有固定容量的Channel,容量为大于 0 的整数。向此通道发送数据的发送者仅在缓冲区满时挂起,而从此通道接收数据的接收者仅在缓冲区空时挂起。要创建一个具有大小为 2 的Int缓冲区的缓冲通道,您可以调用Channel<Int>(2)。以下是一个使用示例:

fun main() = runBlocking<Unit> {
    val channel = Channel<Int>(2)

    launch {
        for (i in 0..4) {
            println("Send $i")
            channel.send(i)
        }
    }

    launch {
        for (i in channel) {
            println("Received $i")
        }
    }
}

此程序的输出是:

Send 0
Send 1
Send 2
Received 0
Received 1
Received 2
Send 3
Send 4
Received 3
Received 4

在这个例子中,我们定义了一个容量为 2 的Channel。一个协程尝试发送五个整数,而另一个协程从通道中消费元素。发送者协程成功一次性发送 0 和 1,然后尝试发送 3。对于值 3,println("Send $i")被执行,但发送者协程在send调用中被挂起。消费者协程也是同样的道理:连续接收两个元素,并在挂起之前额外打印。

通道生产者

到目前为止,你已经看到Channel既可以用于发送也可以用于接收元素。有时候,你可能希望更明确地指定通道应该用于发送还是接收。当你正在实现一个只能被其他协程读取的Channel时,你可以使用produce构建器:

fun CoroutineScope.produceValues(): ReceiveChannel<String> = produce {
    send("one")
    send("two")
}

正如你所看到的,produce返回一个ReceiveChannel—它只有与接收操作相关的方法(receive是其中之一)。ReceiveChannel的实例不能用于发送元素。

提示

另外,我们已将produceValues()定义为CoroutineScope的扩展函数。调用produceValues将启动一个将元素发送到通道的新协程。在 Kotlin 中有一个约定:每个启动协程的函数都应该被定义为CoroutineScope的扩展函数。如果遵循这个约定,你可以很容易地区分出哪些函数在你的代码中启动了新的协程而不是挂起函数。

使用produceValues的主要代码可能是:

fun main() = runBlocking {
    val receiveChannel = produceValues()

    for (e in receiveChannel) {
        println(e)
    }
}

相反,SendChannel只有与发送操作相关的方法。实际上,从源代码来看,Channel是一个接口,继承自ReceiveChannelSendChannel

public interface Channel<E> : SendChannel<E>, ReceiveChannel<E> {
    // code removed for brevity
}

这是如何使用SendChannel的:

fun CoroutineScope.collectImages(imagesOutput: SendChannel<Image>) {
    launch(Dispatchers.IO) {
        val image = readImage()
        imagesOutput.send(image)
    }
}

通信顺序进程

理论够了,让我们开始看看通道如何用于实现一个真实问题。想象一下,你的 Android 应用程序必须在画布上显示“形状”。根据用户的输入,你的应用程序必须显示任意数量的形状。我们故意使用通用术语—一个形状可以是地图上的兴趣点,游戏中的物品,任何可能需要一些后台工作的东西,比如 API 调用、文件读取、数据库查询等。在我们的例子中,主线程,已经处理用户输入,将模拟请求渲染新形状。你已经可以预见到这是一个生产者-消费者问题:主线程发出请求,而一些后台任务处理这些请求并将结果返回给主线程。

我们的实现应该:

  • 线程安全

  • 降低设备内存超负荷风险

  • 没有线程争用(我们不会使用锁)

模型和架构

一个Shape由一个Location和一些有用的ShapeData组成:

data class Shape(val location: Location, val data: ShapeData)
data class Location(val x: Int, val y: Int)
class ShapeData

给定一个 Location,我们需要获取相应的 ShapeData 来构建一个 Shape。因此,在这个例子中,Location 是输入,而 Shape 是输出。为简洁起见,我们将使用 “位置” 表示 Location, “形状” 表示 Shape

在我们的实现中,我们将区分两个主要组件:

视图模型

这段代码包含大部分与形状相关的应用逻辑。当用户与 UI 交互时,视图会将位置列表传递给视图模型。

shapeCollector

这负责根据位置列表获取形状。

图 9-4 描述了视图模型和形状收集器之间的双向关系。

高级架构

图 9-4. 高级架构。

ShapeCollector 遵循一个简单的流程:

               fetchData
Location ---------------------> ShapeData

作为额外的先决条件,我们的 ShapeCollector 应该维护一个内部的“注册表”,用于正在处理的位置。在接收到要处理的位置时,ShapeCollector 不应尝试下载已经正在处理的位置。

第一个实现

我们可以从这个第一个天真的 ShapeCollector 实现开始,虽然它远非完整,但你会有个概念:

class ShapeCollector {
    private val locationsBeingProcessed = mutableListOf<Location>()

    fun processLocation(location: Location) {
        if (locationsBeingProcessed.add(location)) {
             // fetch data, then send back a Shape instance to
             // the view-model
        }
    }
}

如果我们使用线程编程,会有多个线程共享一个 ShapeCollector 实例,并并发执行 processLocation。然而,这种方法会导致共享可变状态。在前面的代码片段中,locationsBeingProcessed 就是一个例子。

正如你在 第五章 中学到的,使用锁容易出错。使用协程,我们无需共享可变状态。如何做到的呢?通过协程和通道,我们可以通过通信来共享而不是共享来通信

关键思想是将可变状态封装在协程内部。对于正在处理的 Location 列表,可以通过以下方式实现:

launch {
    val locationsBeingProcessed = mutableListOf<Location>()     ![1](https://github.com/OpenDocCN/ibooker-mobi-zh/raw/master/docs/prog-andr-kt/img/1.png)

    for (location in locations) {                               ![2](https://github.com/OpenDocCN/ibooker-mobi-zh/raw/master/docs/prog-andr-kt/img/2.png)
        // same code from previous figure
    }
}

1

在前面的例子中,只有使用 launch 启动的协程可以访问可变状态,即 locationsBeingProcessed

2

不过,我们现在遇到了一个问题。我们如何提供 location 呢?我们必须以某种方式将这个可迭代对象传递给协程。所以我们会使用一个 Channel,并将其作为我们即将声明的函数的输入。由于我们在函数内启动一个协程,我们将此函数声明为 CoroutineScope 的扩展函数:

private fun CoroutineScope.collectShapes(
     locations: ReceiveChannel<Location>
) = launch {
     // code removed for brevity
}

由于这个协程将从视图模型接收 Location,我们将 Channel 声明为 ReceiveChannel。顺便说一句,在前面的部分中,你已经看到 Channel 可以像列表一样迭代。所以现在,我们可以为从通道接收到的每个 Location 实例获取相应的 ShapeData。由于你希望并行进行此操作,你可能会写出类似于以下的代码:

private fun CoroutineScope.collectShapes(
     locations: ReceiveChannel<Location>
) = launch {
     val locationsBeingProcessed = mutableListOf<Location>()

     for (loc in locations) {
         if (!locationsBeingProcessed.contains(loc) {
              launch(Dispatchers.IO) {
                   // fetch the corresponding `ShapeData`
              }
         }
    }
}

注意,在这段代码中有一个陷阱。你看,对于每个接收到的位置,我们都会启动一个新的协程。如果 locations 通道提供了大量的项目,这段代码可能会启动大量的协程。因此,这种情况也称为 无限并发。当我们介绍协程时,我们说它们很轻量级。这是真的,但它们所做的工作可能会消耗大量资源。在这种情况下,launch(Dispatchers.IO) 本身的开销微不足道,而获取 ShapeData 可能需要对带有有限带宽的服务器进行 REST API 调用。

因此,我们必须找到一种方法来限制并发 —— 我们不希望启动无限数量的协程。面对这种线程的情况,一个常见的做法是使用与工作队列耦合的线程池(见第五章)。我们将创建一个 协程池,命名为 工作者池,而不是线程池。来自这个工作者池的每个协程将为给定位置执行实际的 ShapeData 获取。为了与这个工作者池通信,collectShapes 应该使用一个额外的通道,通过它可以将位置发送到工作者池,如图 9-5 所示。

并发限制

图 9-5. 并发限制。
警告

当你使用 Channel 时,要小心不要有无限并发。想象一下,你必须实例化很多 Bitmap 实例。存储像素数据的底层内存缓冲区在内存中占用了非常可观的空间。在处理大量图像时,每次需要创建图像时分配一个新的 Bitmap 实例会给系统带来显著的压力(需要在 RAM 中分配内存,同时垃圾收集器清理所有不再引用的先前创建的实例)。这个问题的一个经典解决方案是 Bitmap 池化,这只是 对象池 更一般模式的一个特例。你可以从池中选择一个 Bitmap 实例(并在可能时重用底层缓冲区)而不是创建一个新的 Bitmap 实例。

这就是修改 collectShapes 以接受额外通道参数的方法:

private fun CoroutineScope.collectShapes(
     locations: ReceiveChannel<Location>,
     locationsToProcess: SendChannel<Location>,
) = launch {
     val locationsBeingProcessed = mutableListOf<Location>()

     for (loc in locations) {
         if (!locationsBeingProcessed.contains(loc) {
              launch(Dispatchers.IO) {
                   locationsToProcess.send(loc)
              }
         }
    }
}

注意现在 collectShapes 如何将位置发送到 locationsToProcess 通道,只有当该位置不在当前正在处理的位置列表中时。

至于工作实现,它只是从我们刚刚创建的通道中读取 —— 除了从工作者的角度来看,它是一个ReceiveChannel。使用相同的模式:

private fun CoroutineScope.worker(
        locationsToProcess: ReceiveChannel<Location>,
) = launch(Dispatchers.IO) {
        for (loc in locationsToProcess) {
             // fetch the ShapeData, see later
        }
}

现在,我们不关注如何获取ShapeData。这里最重要的概念是for循环。由于在locationsToProcess通道上进行迭代,每个单独的worker协程将收到自己的位置,而不会干扰其他协程。无论我们启动多少个 worker,从collectShapes发送到locationsToProcess通道的位置只会被一个 worker 接收。当我们连接所有这些东西时,每个 worker 将使用相同的通道实例创建。在面向消息的软件中,这种模式,即将消息传递到多个目的地,称为fan-out

回顾一下for循环内缺失的实现,我们将要做的事情是:

  1. 获取ShapeData(从现在开始我们简称为“数据”)。

  2. 从位置和数据创建一个Shape

  3. 将形状发送到某个通道,我们应用程序中的其他组件将使用这个通道从ShapeCollector获取形状。显然,我们还没有创建这样的通道。

  4. 通知collectShapes协程,给定位置已经处理完毕,通过将其发送回发送者。同样,这样的通道必须先创建。

注意,这并不是唯一可能的实现方式。你可以想象其他方法,并根据自己的需求进行调整。毕竟,本章的目的就是为你提供示例和灵感,以便你进行下一步的开发。

回到我们的主题,示例 9-1 展示了worker协程的最终实现。

示例 9-1. 工作协程
private fun CoroutineScope.worker(
    locationsToProcess: ReceiveChannel<Location>,
    locationsProcessed: SendChannel<Location>,
    shapesOutput: SendChannel<Shape>
) = launch(Dispatchers.IO) {
    for (loc in locationsToProcess) {
        try {
            val data = getShapeData(loc)
            val shape = Shape(loc, data)
            shapesOutput.send(shape)
        } finally {
            locationsProcessed.send(loc)
        }
    }
}

就像之前调整collectShapes以接受一个通道作为参数一样,这次我们要添加两个通道:locationsProcessedshapesOutput

for循环内,我们首先获取一个位置的ShapeData实例。为了这个简单示例的目的,示例 9-2 展示了我们的实现。

示例 9-2. 获取形状数据
private suspend fun getShapeData(
    location: Location
): ShapeData = withContext(Dispatchers.IO) {
        /* Simulate some remote API delay */
        delay(10)
        ShapeData()
}

由于getShapeData方法可能不会立即返回,我们将其实现为suspend函数。假设下游代码涉及远程 API,我们使用Dispatchers.IO

collectShapes协程必须再次适应,因为它必须接受另一个通道——工作者完成处理后返回的位置。你已经开始适应它了——从collectShapes的角度来看,这将是一个ReceiveChannel。现在,collectShapes接受两个ReceiveChannel和一个SendChannel

让我们试试看:

private fun CoroutineScope.collectShapes(
     locations: ReceiveChannel<Location>,
     locationsToProcess: SendChannel<Location>,
     locationsProcessed: ReceiveChannel<Location>
): Job = launch {
     ...
     for (loc in locations) {
          // same implementation, hidden for brevity
     }
     // but.. how do we iterate over locationsProcessed?
}

现在我们有了问题。如何同时从多个ReceiveChannel接收元素?如果我们在locations通道迭代的下方添加另一个for循环,它将无法按预期工作,因为第一个迭代仅在locations通道关闭时结束。

为此,可以使用select表达式。

Select 表达式

select 表达式等待多个暂停函数的结果同时返回,这些函数在此 select 调用的主体中使用 子句 指定。调用者在一个子句被 选中失败 后才会被暂停。

在我们的情况下,它的工作方式如下:

select<Unit> {
    locations.onReceive { loc ->
        // do action 1
    }
    locationsProcessed.onReceive { loc ->
        // do action 2
    }
}

如果 select 表达式能说话,它会说:“每当 locations 通道接收到一个元素时,我将执行动作 1. 或者,如果 locationsProcessed 通道接收到某个东西,我将执行动作 2. 我无法同时执行这两个操作。顺便说一句,我会返回 Unit。”

“我无法同时执行这两个操作”是很重要的。你可能会想知道如果操作 1 需要半个小时,或者更糟的是,永远无法完成,会发生什么。我们将在 “CSP 中的死锁” 中描述类似的情况。然而,随后的实现保证在每个操作中 永远 不会长时间阻塞。

因为 select 是一个表达式,它会返回一个结果。结果类型由我们为 select 的每个 case 提供的 lambda 的返回类型推断出来,这与 when 表达式非常相似。在这个特定的示例中,我们不需要任何结果,所以返回类型是 Unit。由于 selectlocationslocationsProcessed 通道接收到元素后返回,它不像之前的 for 循环那样遍历通道。因此,我们必须将其包装在 while(true) 内。完整的 collectShapes 实现在 示例 9-3 中显示。

示例 9-3. 收集形状
private fun CoroutineScope.collectShapes(
    locations: ReceiveChannel<Location>,
    locationsToProcess: SendChannel<Location>,
    locationsProcessed: ReceiveChannel<Location>
) = launch(Dispatchers.Default) {

    val locationsBeingProcessed = mutableListOf<Location>()

    while (true) {
        select<Unit> {
            locationsProcessed.onReceive {                     ![1](https://github.com/OpenDocCN/ibooker-mobi-zh/raw/master/docs/prog-andr-kt/img/1.png)
                locationsBeingProcessed.remove(it)
            }
            locations.onReceive {                              ![2](https://github.com/OpenDocCN/ibooker-mobi-zh/raw/master/docs/prog-andr-kt/img/2.png)
                if (!locationsBeingProcessed.any { loc ->
                    loc == it }) {
                    /* Add it to the list of locations being processed */
                    locationsBeingProcessed.add(it)

                    /* Now download the shape at location */
                    locationsToProcess.send(it)
                }
            }
        }
    }
}

1

locationsProcessed 通道接收到一个位置时,我们知道这个位置已经被一个 worker 处理过了。现在应该将其从正在处理的位置列表中移除。

2

locations 通道接收到一个位置时,我们必须首先检查我们是否已经在处理相同的位置。如果没有,我们将该位置添加到 locationsBeingProcessed 列表中,然后将其发送到 locationsToProcess 通道。

将所有内容整合在一起

ShapeCollector 的最终架构已经成形,如 图 9-6 所示。

最终架构

图 9-6. 最终架构。

请记住,我们用来实现 collectShapesworker 方法的所有通道都必须在某个地方创建。为了尊重封装性,一个好的地方是在 start 方法中,如 示例 9-4 中所示。

示例 9-4. 形状收集器
class ShapeCollector(private val workerCount: Int) {
    fun CoroutineScope.start(
        locations: ReceiveChannel<Location>,
        shapesOutput: SendChannel<Shape>
    ) {
        val locationsToProcess = Channel<Location>()
        val locationsProcessed = Channel<Location>(capacity = 1)

        repeat(workerCount) {
             worker(locationsToProcess, locationsProcessed, shapesOutput)
        }
        collectShapes(locations, locationsToProcess, locationsProcessed)
    }

    private fun CoroutineScope.collectShapes // already implemented

    private fun CoroutineScope.worker        // already implemented

    private suspend fun getShapeData         // already implemented
}

这个start方法负责启动整个形状收集机制。在ShapeCollector内部专门使用的两个通道被创建:locationsToProcesslocationsProcessed。我们在这里没有显式地创建ReceiveChannelSendChannel实例。我们创建它们作为Channel实例,因为它们将进一步被用作ReceiveChannelSendChannel。然后通过调用worker方法workerCount次来创建并启动工作者池。这是通过使用标准库中的repeat函数实现的。

最后,我们调用collectShapes一次。总的来说,在这个start方法中我们启动了workerCount + 1个协程。

你可能已经注意到locationsProcessed被创建时容量为 1。这是有意为之,并且是一个重要的细节。我们将在下一节解释原因。

Fan-Out 和 Fan-In

你刚刚看到了多个协程从同一个通道接收数据的例子。事实上,所有worker协程都从同一个locationsToProcess通道接收数据。发送到locationsToProcess通道的Location实例将被一个工作者处理,没有任何并发问题的风险。这种协程之间的特定交互被称为fan-out,如图 9-7 所示。从collectShapes函数启动的协程的角度来看,位置被分发到工作者池中。

Fan-Out and Fan-In

图 9-7。Fan-out 和 fan-in。

通过启动几个协程来实现 Fan-out,它们都迭代同一个ReceiveChannel实例(参见示例 9-1 中的worker实现)。如果其中一个工作者失败,其他工作者将继续从通道接收数据,使系统在一定程度上具有弹性。

相反地,当几个协程向同一个SendChannel实例发送元素时,我们称之为fan-in。再次,你有一个很好的例子,因为所有工作者都向shapesOutput发送Shape实例。

性能测试

好了!是时候测试我们的ShapeCollector的性能了。以下代码片段有一个main函数,调用了consumeShapessendLocations函数。这些函数分别启动一个协程,从ShapeCollector中消费Shape实例并发送Location实例。总的来说,这段代码接近于你在真实的视图模型中编写的代码,如示例 9-5 所示。

示例 9-5。形状收集器
fun main() = runBlocking<Unit> {
    val shapes = Channel<Shape>()                ![1](https://github.com/OpenDocCN/ibooker-mobi-zh/raw/master/docs/prog-andr-kt/img/1.png)
    val locations = Channel<Location>()

    with(ShapeCollector(4)) {                    ![2](https://github.com/OpenDocCN/ibooker-mobi-zh/raw/master/docs/prog-andr-kt/img/2.png)
        start(locations, shapes)
        consumeShapes(shapes)
    }

    sendLocations(locations)
}

var count = 0

fun CoroutineScope.consumeShapes(
    shapesInput: ReceiveChannel<Shape>
) = launch {
    for (shape in shapesInput) {
        // increment a counter of shapes
        count++                                  ![3](https://github.com/OpenDocCN/ibooker-mobi-zh/raw/master/docs/prog-andr-kt/img/3.png)
    }
}

fun CoroutineScope.sendLocations(
    locationsOutput: SendChannel<Location>
) = launch {
    withTimeoutOrNull(3000) {                    ![4](https://github.com/OpenDocCN/ibooker-mobi-zh/raw/master/docs/prog-andr-kt/img/4.png)
        while (true) {
            /* Simulate fetching some shape location */
            val location = Location(Random.nextInt(), Random.nextInt())
            locationsOutput.send(location)
        }
    }
    println("Received $count shapes")
}

1

我们根据ShapeCollector的需求设置了通道——参见图 9-4。

2

我们使用四个工作者创建了一个ShapeCollector

3

consumeShapes函数只是增加一个计数器。这个计数器是全局声明的,这是可以的,因为由consumeShapes启动的协程是唯一修改count的。

4

sendLocations 函数中,我们设置了三秒的超时时间。withTimeoutOrNull 是一个挂起函数,它会挂起直到提供的时间结束。因此,以 sendLocations 开始的协程仅在三秒后打印接收到的计数。

如果你回想一下在 Example 9-2 中的 getShapeData 实现,我们增加了 delay(10) 来模拟一个 10 毫秒长的挂起调用。在三秒内运行四个工作者,理论上我们应该接收到 3,000 / 10 × 4 = 1,200 个形状,如果我们的实现没有额外开销的话。在我们的测试机器上,我们得到了 1,170 个形状—这是 98% 的效率。

在更多工作者(64)和每个工作者中使用 delay(5) 的情况下,我们在 10 秒内得到了 122,518 个形状(理想数目为 128,000)—这是 96% 的效率。

总体而言,ShapeCollector 的吞吐量相当不错,即使有一个 sendLocations 函数不间断地发送 Location 实例而没有任何暂停。

回压

如果我们的工作者速度太慢会发生什么?如果远程 HTTP 调用花费了时间来响应,或者后端服务器不堪重负——我们无法确定。为了模拟这种情况,我们可以大幅增加 getShapeData 内部的延迟(见 Example 9-2)。使用 delay(500),我们在三秒内只获得了 20 个形状,使用了四个工作者。吞吐量减少了,但这并不是最有趣的部分。和生产者-消费者问题一样,当消费者变慢时问题可能会出现——因为生产者可能会积累数据,系统最终可能会耗尽内存。你可以在生产者协程内添加 println() 日志并重新运行程序。

fun CoroutineScope.sendLocations(locationsOutput: SendChannel<Location>) = launch {
    withTimeoutOrNull(3000) {
        while (true) {
            /* Simulate fetching some shape location */
            val location = Location(Random.nextInt(), Random.nextInt())
            println("Sending a new location")
            locationsOutput.send(location)      // suspending call
        }
    }
    println("Received $count shapes")
}

现在,“发送一个新位置”仅在控制台中打印了大约 25 次。

因此,生产者正在被减速。如何减速?

因为 locationsOutput.send(location) 是一个挂起调用。当工作器速度较慢时,ShapeCollector 类的 collectShapes 函数(见 Example 9-3)在 locationsToProcess.send(it) 这一行很快就会挂起。确实,locationsToProcess 是一个会合通道。因此,当以 collectShapes 开始的协程达到该行时,它会被挂起,直到工作者准备好从 locationsToProcess 接收位置信息。当前述的协程被挂起时,它无法再从 locations 通道接收信息—这对应于前面例子中的 locationsOutput。这就是以 sendLocation 开始的协程被挂起的原因。当工作者最终完成他们的工作时,collectShapes 可以恢复执行,生产者协程也会随之恢复。

与 Actor 模型的相似之处

在 CSP 中,您创建协程来封装可变状态。它们不通过共享状态来通信,而是通过通信(使用Channel)来共享。使用collectShapes函数启动的协程(参见示例 9-3)使用三个通道与其他协程进行通信——一个SendChannel和两个ReceiveChannel,如图 9-8 所示。

在 CSP 术语中,collectShapes及其三个通道被称为进程。进程是一个计算实体,通过异步消息传递(通道)与其他角色进行通信。它一次只能做一件事情——读取、写入通道或处理。

在 Actor 模型中,actor非常相似。一个显著的区别是,一个 actor 只有一个通道——称为邮箱。如果一个 actor 需要响应迅速且不阻塞,它必须将长时间运行的处理委托给子 actor。这种相似性是 CSP 有时被称为 Actor 模型实现的原因。

进程

图 9-8. CSP 中的进程。

进程内执行是顺序的。

我们刚刚看到进程由单个协程和通道组成。协程的本质是在某个线程上执行。因此,除非此协程启动其他子协程(并发或并行运行),否则该协程的所有行都会顺序执行。这包括从通道接收、发送对象到其他通道和突变某些私有状态。因此,本章实现的 actor 可以从一个通道接收或向另一个通道发送,但不能同时进行。在负载下,这种类型的 actor 可以高效,因为它不涉及阻塞调用,只涉及挂起函数。当一个协程被挂起时,整体效率不一定受影响,因为执行挂起协程的线程可以执行其他有事情要做的协程。这样,线程可以充分利用,永远不会争夺某个锁。

总结

使用 CSP 风格的这种机制几乎没有内部开销。由于使用了Channel和协程,我们的实现是无锁的。因此,不存在线程竞争——ShapeCollector不太可能影响应用程序的其他线程。类似地,我们在ShapeCollector中使用的Dispatchers也有可能在应用程序的其他功能中使用。通过利用无锁实现,协程在从通道接收时被挂起不会阻止底层线程执行其他协程。换句话说,我们可以在相同的资源上做更多事情。

此外,这种架构提供了内置的反压功能。如果某些ShapeData实例突然获取需要更多时间,那么ShapeLocation实例的生产者将放慢速度,以防止位置积累,从而降低内存耗尽的风险。这种反压功能是免费提供的——您并没有显式编写此功能的代码。

本章节中提供的示例足够通用,可以直接采用并根据您的需求进行调整。如果您需要显著偏离我们的示例,那么我们有责任给您更深入的解释。例如,为什么在示例 9-4 中我们将locationsProcessed通道的容量设置为 1?答案确实并不简单。如果我们创建了一个常规的会合通道,我们的ShapeCollector将会遭受死锁,这将引导我们到下一节。

CSP 中的死锁

当涉及线程时,死锁最常见。当线程 A 持有锁 1 并试图获取锁 2 时,而线程 B 持有锁 2 并试图获取锁 1 时,就会发生死锁。这两个线程互相无限等待,都无法继续执行。当死锁发生在应用程序的关键组件中时,可能会产生灾难性后果。避免这种情况的有效方法是确保在任何情况下都不会发生死锁。即使条件极不可能被满足,你也要相信墨菲定律总会发生作用。

然而,在 CSP 架构中也可能发生死锁。我们可以做一个小实验来说明这一点。在示例 9-4 中,不要将locationsProcessed通道的容量设置为 1,而是使用一个没有缓冲区的通道(即会合通道),并在示例 9-5 中运行性能测试样本。控制台打印的结果是:

Received 4 shapes

记录中,我们本应该收到 20 个形状。那么,究竟发生了什么?

注意

警告:以下解释详细说明每一个必要的细节,篇幅相当长。我们鼓励您仔细阅读直至最后。这是测试您对通道理解的终极挑战。

您也可以完全跳过它,直接转到“TL;DR”。

让我们更仔细地查看我们的ShapeCollector类的内部,并像一个实时调试器一样跟随每一步。想象一下,你刚刚启动了示例 9-5 中的性能测试样本,并且第一个Location实例被发送到locations通道。该位置通过其select表达式经过collectShapes方法。在那一刻,locationsProcessed没有提供任何内容,因此select表达式通过第二种情况:locations.onReceive{..}。如果你看一下这第二种情况内部的操作,你会发现一个位置被发送到locationsToProcess通道——这是每个工作者的接收通道。因此,由collectShapes方法启动的协程(我们称之为collectShapes协程)在locationsToProcess.send(it)调用时被暂停,直到一个工作者与locationsToProcess交汇通道握手。这发生得相当快,因为此时所有工作者都处于空闲状态。

当一个工作者收到第一个Location实例时,collectShapes协程被恢复,并且能够接收其他位置。在我们的工作者实现中,我们添加了一些延迟来模拟后台处理,你可以考虑工作者相对于其他协程来说速度较慢——这些协程包括collectShapes协程和在测试样本中通过sendLocations方法启动的生产者协程(我们将其称为sendLocations协程)。因此,在处理第一个位置的工作者仍在忙于处理时,collectShapes协程接收到另一个位置。类似地,第二个工作者快速处理第二个位置,而第三个位置被collectShapes协程接收,依此类推。

执行继续直到所有四个工作者都忙碌,与此同时第五个位置被collectShapes协程接收。按照之前的逻辑,collectShapes协程被暂停,直到一个工作者准备接收Location实例。不幸的是,所有工作者都在忙碌中。因此,collectShapes协程无法再接收新的位置。由于collectShapessendLocations协程通过一个交汇通道进行通信,sendLocations协程也被暂停,直到collectShapes准备接收更多位置。

直到一个工作线程可以接收第五个位置时,时间已经过去了。最终,一个工作线程(可能是第一个工作线程)完成了它的Location实例的处理。然后它将结果发送到shapesOutput通道,并尝试将处理后的位置发送回collectShapes协程,使用locationsProcessed通道。请记住,这是我们通知collectShapes协程位置已处理的机制。然而,collectShapes协程在locationsToProcess.send(it)调用处被挂起。所以collectShapes无法从locationsProcessed通道接收。这种情况没有问题:这是一个死锁,^(4) 如图 9-9 所示。

最终,工作线程处理的前四个位置都被处理了,并且四个Shape实例被发送到shapesOutput通道。每个工作线程的延迟仅为 10 毫秒,因此在三秒的超时之前,所有工作线程都有时间完成。因此结果是:

Received 4 shapes

CSP 中的死锁

图 9-9. CSP 中的死锁。

如果locationsProcessed通道至少有 1 的容量,第一个可用的工作线程将能够发送其Location实例,然后从locationsToProcess通道接收,释放collectShapes协程。随后,在collectShapes协程的select表达式中,总是先检查locationsToProcess通道,然后再检查locations通道。这确保了当collectShapes协程最终在locationsToProcess.send(it)调用处挂起时,locationsProcessed通道的缓冲区保证为空,因此工作线程可以发送位置而不被挂起。如果你感兴趣,尝试反转locationsProcessed.onReceive {..}locations.onReceive {..}这两种情况,并且locationsProcessed通道的容量为 1。结果将是:“收到 5 个形状。”

TL;DR

locationsProcessed通道的容量为 1 不仅非常重要,在collectShapes协程的select表达式中通道的读取顺序也很重要。^(5) 从中应该记住什么呢?CSP 中可能会出现死锁。更重要的是,了解死锁的原因是一个很好的练习,可以测试你对通道工作原理的理解。

如果我们回顾一下ShapeCollector的结构,我们可以将其表示为一个循环图,如图 9-10 所示。

循环图

图 9-10. 循环图。

这种新的表示法强调了结构的一个重要属性:它是cyclic的。Location实例在collectShapes协程和工作线程之间来回传送。

CSP 中的循环实际上是死锁的原因。没有循环,就不可能发生死锁。然而,有时候,你别无选择,只能使用这些循环。在这种情况下,我们给出了关键的思路来推理 CSP,所以你可以自行找到解决方案。

通道的限制

到目前为止,我们一直没有讨论通道的限制,现在我们将描述其中一些限制。使用本章的概念,通常会像在 Example 9-6 中展示的那样创建一个Int值的流。

示例 9-6. 生成数字
fun CoroutineScope.numbers(): ReceiveChannel<Int> = produce {
    send(1)
    send(2)
    // send other numbers
}

在接收端,你可以像这样消费这些数字:

fun main() = runBlocking {
    val channel = numbers()
    for (x in channel) {
        println(x)
    }
}

相当直接。现在,如果你需要为这些数字应用转换怎么办?想象一下,你的转换函数是:

suspend fun transform(n: Int) = withContext(Dispatchers.Default) {
    delay(10) // simulate some heavy CPU computations
    n + 1
}

你可以像这样修改numbers函数:

fun CoroutineScope.numbers(): ReceiveChannel<Int> = produce {
    send(transform(1))
    send(transform(2))
}

它可以工作,但不够优雅。一个更好的解决方案应该是这样的:

fun main() = runBlocking {
    /* Warning - this doesn't compile */
    val channel = numbers().map {
        transform(it)
    }
    for (x in channel) {
        println(x)
    }
}

实际上,截至 Kotlin 1.4 版本,此代码不能编译。在通道的早期,我们有“通道操作符”如 map。然而,这些操作符在 Kotlin 1.3 中已被弃用,并在 Kotlin 1.4 中移除了。

为什么?通道是协程之间的通信原语。它们专门设计用于分发值,以便每个值只被一个接收器接收。不能使用通道向多个接收器广播值。协程的设计者专门为异步数据流创建了Flow,我们可以在其上使用转换操作符;我们将在下一章看到如何使用。

因此,通道并不是实现数据转换流水线的便捷解决方案。

通道是热的

让我们来看看produce通道构建器的源代码。有两行代码很有趣,如下所示:

public fun <E> CoroutineScope.produce(                           ![1](https://github.com/OpenDocCN/ibooker-mobi-zh/raw/master/docs/prog-andr-kt/img/1.png)
    context: CoroutineContext = EmptyCoroutineContext,
    capacity: Int = 0,
    @BuilderInference block: suspend ProducerScope<E>.() -> Unit
): ReceiveChannel<E> {
    val channel = Channel<E>(capacity)
    val newContext = newCoroutineContext(context)
    val coroutine = ProducerCoroutine(newContext, channel)
    coroutine.start(CoroutineStart.DEFAULT, coroutine, block)    ![2](https://github.com/OpenDocCN/ibooker-mobi-zh/raw/master/docs/prog-andr-kt/img/2.png)
    return coroutine
}

1

produceCoroutineScope 上的扩展函数。还记得惯例吗?它表示此函数启动了一个新的协程。

2

我们可以通过coroutine.start()调用来确认。不要过多关注协程是如何启动的——这是一个内部实现。

因此,当你调用produce通道构建器时,会启动一个新的协程,立即开始生成元素并将它们发送到返回的通道,即使没有协程在消耗这些元素。

这就是为什么通道被称为 热的:一个协程正在积极运行以生成或消耗数据。如果你了解 RxJava,这与热可观察对象的概念相同:它们独立于各个订阅而发出值。考虑这个简单的流:

fun CoroutineScope.numbers(): ReceiveChannel<Int> = produce {
    use(openConnectionToDatabase()) {
        send(1)
        send(2)
    }
}

此外,请想象没有其他协程正在消费此流。由于此函数返回的是一种会合通道,启动的协程将在第一次send时挂起。因此,您可能会说:“好吧,我们没问题——在我们向此流提供消费者之前,没有后台处理。”这是正确的,但如果您忘记消费流,则数据库连接将保持打开——请注意,我们使用了标准库中的use函数,这相当于 Java 中的try-resources语句。尽管它可能现在不会有害,但如果此逻辑是重试循环的一部分,那么将会导致大量资源泄漏。

总之,通道是协程间通信的原语。它们在类似 CSP 的架构中非常有效。然而,我们没有像mapfilter这样的便捷操作符来转换它们。我们也不能将值广播到多个接收者。此外,它们的热特性在某些情况下可能会导致内存泄漏。

为了解决这些通道的限制,已经创建了流。我们将在下一章中介绍流。

总结

  • 通道是提供协程之间传输值流的通信原语。

  • 虽然通道在概念上接近于 Java 的BlockingQueue,但根本区别在于通道的sendreceive方法是挂起函数,而不是阻塞调用。

  • 使用通道和协程,您可以通过通信共享来共享数据,而不是传统的共享数据通信。其目标是避免共享可变状态和线程安全问题。

  • 可以使用 CSP 风格实现复杂逻辑,利用背压。这样做可能会带来出色的性能,因为挂起函数的非阻塞特性将线程竞争降到最低。

  • 请注意,CSP 中可能会发生死锁,如果您的架构存在循环(一个协程将对象发送到另一个协程,同时也从同一个协程接收对象)。您可以通过调整select表达式处理每个情况的顺序,或者调整某些通道的容量来解决这些死锁问题。

  • 应该将通道视为低级原语。CSP 中的死锁就是对通道误用的一个例子。下一章将介绍——在协程之间交换数据流的更高级原语。这并不意味着您不应该使用通道——仍然有一些情况下通道是必需的(本章中的ShapeCollector就是一个例子)。然而,您会发现在许多情况下,流是更好的选择。无论如何,了解通道是非常重要的,因为(您将看到)在某些情况下,流在幕后也使用通道。

^(1) 在本章的其余部分中,我们有时将Channel简称为通道。

^(2) 具体而言,Channel不实现Iterable

^(3) 如果你想学习这种算法的工作原理,我们建议你阅读《Java 并发实战》中的第 15.4 节,“非阻塞算法”,作者是 Brian Goetz 等人。还有一个有趣的 YouTube 视频,Kotlin 协程的无锁算法(第一部分),由 Kotlin 协程的首席设计师 Roman Elizarov 发布。

^(4) 虽然这里没有涉及锁或互斥体,但情况与涉及线程的死锁非常相似。这就是为什么我们使用相同的术语的原因。

^(5) 实际上,我们的实现中,对于locationsProcessed的容量为 1,并非唯一可以避免死锁的实现。至少有一种解决方案可以使用locationsProcessed作为会合通道。我们将其留给读者作为练习。

第十章:流

到目前为止,我们已经涵盖了协程、挂起函数以及如何使用 Channel 处理流。我们从前一章节中了解到,使用 Channel 意味着启动协程来发送和/或接收来自这些 Channel 的数据。上述的协程就是 实体,有时很难调试,或者如果它们没有在应该取消时被取消,可能会泄漏资源。

FlowChannel 一样,都用于处理异步数据流,但在更高层次的抽象和更好的库工具化。在概念上,Flow 类似于 Sequence,但是 Flow 的每一步都可以是异步的。将流集成到结构化并发中也很容易,以避免资源泄漏。

然而,Flows^(1) 并不意味着要取代 Channels。 Channels 是流的构建块。在某些架构中,比如 CSP(参见 第九章),Channels 仍然是合适的。尽管如此,你会看到在异步数据处理中,Flows 更适合大多数需求。

在本章中,我们将介绍冷流和热流。你将看到 流在你希望确保不泄漏任何资源时可能是一个更好的选择。另一方面, 流提供了不同的用途,比如在应用程序中需要“发布-订阅”关系时。例如,你可以使用热流实现事件总线。

理解流的最佳方式是看看它们在现实生活应用中的使用方式。因此,本章还将通过一系列典型用例来讲解。

流介绍

让我们使用 Flow 重新实现 示例 9-6:

fun numbers(): Flow<Int> = flow {
    emit(1)
    emit(2)
    // emit other values
}

有几个重要方面需要注意:

  1. 不是返回 Channel 实例,而是返回 Flow 实例。

  2. 在流中,我们使用 emit 挂起函数而不是 send

  3. numbers 函数返回一个 Flow 实例,并不是一个挂起函数。调用 numbers 函数本身并不会启动任何东西 — 它只是立即返回一个 Flow 实例。

总结一下,在 flow 块中定义了值的发射。当调用时,numbers 函数会快速返回一个 Flow 实例,而不会在后台运行任何东西。

在消费端:

fun main() = runBlocking {
    val flow = numbers()      ![1](https://github.com/OpenDocCN/ibooker-mobi-zh/raw/master/docs/prog-andr-kt/img/1.png)
    flow.collect {            ![2](https://github.com/OpenDocCN/ibooker-mobi-zh/raw/master/docs/prog-andr-kt/img/2.png)
        println(it)
    }
}

1

我们通过 numbers 函数获得了一个 Flow 实例。

2

一旦我们获得了一个流,不像循环遍历它(就像我们使用通道那样),我们使用 collect 函数,这在流的术语中被称为 终端操作符。我们将会在 “Operators” 中详细讨论 flows operators 和终端操作符。现在,我们可以总结 collect 终端操作符的目的:它消费了流;例如,迭代流并在流的每个元素上执行给定的 lambda 函数。

就这样——你已经看到了流的基本用法。正如我们之前提到的,现在我们将看一个更为现实的例子,这样你就能看到Flow的真正用途。

更为现实的例子

想象一下,你需要从远程数据库获取令牌,^(2)然后为每个令牌查询附加数据。你只需要偶尔这样做,所以决定不保持与数据库的活动连接(这可能很昂贵)。因此,只有在获取数据时才创建连接,并在完成后关闭连接。

你的实现应该首先建立到数据库的连接。然后使用挂起函数getToken获取一个令牌。这个getToken函数向数据库发出请求并返回一个令牌。然后异步获取与该令牌相关联的可选数据。在我们的例子中,通过调用挂起函数getData来完成这一点,该函数以令牌作为参数。一旦获取到getData的结果,你就将令牌和结果包装在一个TokenData类实例中,定义如下:

data class TokenData(val token: String, val opt: String? = null)

总之,你需要生成一系列的TokenData对象。这个流程首先需要建立数据库连接,然后执行异步查询以检索令牌并获取相关数据。你可以自行决定需要多少个令牌。在处理完所有令牌后,断开连接并释放底层数据库连接资源。图 10-1 展示了如何实现这样的流程。

实现检索令牌数据流程

图 10-1. 数据流。

你可以在[Github 上找到相应的源代码]。

注意

在本章中,有时我们使用图像而不是代码块,因为我们的 IDE 截图显示了悬停点(在边缘)和类型提示,这对于理解非常有帮助。

这个实现的几个方面特别重要需要注意:

  • 创建到数据库的连接并在完成时关闭对于使用该流的客户端代码完全透明。客户端代码只看到TokenData的流。

  • 流内的所有操作都是顺序执行的。例如,一旦我们获取了第一个令牌(比如说,“token1”),流就会调用getData("token1")并暂停,直到获取结果(比如说,“data1”)。然后流会发布第一个TokenData("token1," "data1")。只有在此之后才会继续执行“token2”等。

  • 调用getDataFlow函数本身不会做任何事情。它只是返回一个流。流内的代码只有在协程收集流时才会执行,就像在例子 10-1 中所示。

    例子 10-1. 收集一个流
    fun main() = runBlocking<Unit> {
        val flow = getDataFlow(3) // Nothing runs at initialization
    
        // A coroutine collects the flow
        launch {
            flow.collect { data ->
                println(data)
            }
        }
    }
    
  • 如果收集流的协程被取消或到达流的末尾,onCompletion块内的代码将执行。这保证了我们正确释放对数据库的连接。

正如我们之前提到的,collect 是一个终端操作符,消耗流的所有元素。在这个例子中,collect 在流的每个收集元素上调用一个函数(例如,println(data) 被调用三次)。我们将在“冷流用法示例”中覆盖其他终端操作符。

注意

到目前为止,您已经看到了不运行任何代码直到协程收集它们的流示例。在流术语中,它们是冷流。

操作符

如果您需要在流上执行类似于集合的转换操作,协程库提供了诸如 mapfilterdebouncebufferonCompletion 等函数。这些函数被称为流操作符中间操作符,因为它们在流上操作并返回另一个流。不要将常规操作符与终端操作符混淆,稍后您会看到它们的区别。

以下是 map 操作符的一个示例用法:

fun main() = runBlocking<Unit> {
    val numbers: Flow<Int> = // implementation hidden for brevity

    val newFlow: Flow<String> = numbers().map {
        transform(it)
    }
}

suspend fun transform(i :Int): String = withContext(Dispatchers.Default) {
    delay(10) // simulate real work
    "${i + 1}"
}

这里有趣的地方在于 mapFlow<Int> 转换为 Flow<String>。结果流的类型由传递给操作符的 lambda 的返回类型确定。

注意

map 流操作符在概念上与集合的 map 扩展函数非常接近。不过,有一个显著的区别:传递给 map 流操作符的 lambda 可以是一个挂起函数。

在下一节中,我们将覆盖大多数常见操作符的一系列使用案例。

终端操作符

终端操作符可以很容易地与其他常规操作符区分开,因为它是一个启动流收集的挂起函数。您之前已经看到了 collect

其他终端操作符可用,如 toListcollectLatestfirst 等。以下是这些终端操作符的简要描述:

  • toList 收集给定的流,并返回一个包含所有收集元素的 List

  • collectLatest 使用提供的操作收集给定的流。与 collect 的区别在于,当原始流发出新值时,前一个值的操作块将被取消。

  • first 返回流发出的第一个元素,然后取消流的收集。如果流为空,则抛出 NoSuchElementException。还有一个变体,firstOrNull,如果流为空则返回 null

冷流用法示例

事实证明,选择一个例子来展示所有可能操作符的使用并不是最佳方式。相反,我们将提供不同的用例,以说明几个流操作符的使用。

使用案例#1:与基于回调的 API 进行接口交互

假设您正在开发一个聊天应用程序。您的用户可以互发消息。消息包含日期、消息作者的引用和纯文本内容。

这里是一条 Message

data class Message(
    val user: String,
    val date: LocalDateTime,
    val content: String
)

毫不奇怪,我们将消息流表示为Message实例的流。每当用户将消息发布到应用程序时,流将传输该消息。暂时假设你可以调用getMessageFlow函数,该函数返回Flow<Message>的实例。使用 Kotlin Flows 库,你可以创建自己的自定义流。然而,首先探索流 API 如何在常见用例中使用是最有意义的:

fun getMessageFlow(): Flow<Message> {
    // we'll implement it later
}

现在假设你想要在后台线程上执行翻译所有来自特定用户的消息。此外,你希望能够实时翻译这些消息到不同的语言。

要实现这一点,你首先通过调用getMessageFlow()来获取消息流。然后你对原始流应用操作符,如下所示:

fun getMessagesFromUser(user: String, language: String): Flow<Message> {
    return getMessageFlow()
        .filter { it.user == user }           ![1](https://github.com/OpenDocCN/ibooker-mobi-zh/raw/master/docs/prog-andr-kt/img/1.png)
        .map { it.translate(language) }       ![2](https://github.com/OpenDocCN/ibooker-mobi-zh/raw/master/docs/prog-andr-kt/img/2.png)
        .flowOn(Dispatchers.Default)          ![3](https://github.com/OpenDocCN/ibooker-mobi-zh/raw/master/docs/prog-andr-kt/img/3.png)
}

1

第一个操作符filter在原始流上操作,并返回由传递的user参数产生的另一个消息流。

2

第二个操作符map操作filter返回的流,并返回翻译后的消息流。从filter操作符的角度来看,原始流(由getMessageFlow()返回)是上游流,而下游流filter之后的所有操作符表示。中间操作符都有它们自己的相对上游和下游流,如图 10-2 所示。

3

最后,flowOn操作符会改变它所操作的流的上下文。它会改变上游流的协程上下文,但不会影响下游流。因此,步骤 1 和步骤 2 都将使用调度器Dispatchers.Default执行。

换句话说,上游流的操作符(即filtermap)现在被封装起来:它们的执行上下文始终为Dispatchers.Default。不管结果流将在哪个上下文中被收集,前述的操作符都将使用Dispatchers.Default执行。

这是流非常重要的一个特性,称为上下文保留。想象一下,你在应用程序的 UI 线程上收集流,通常你会使用ViewModelviewModelScope来做到这一点。如果流的操作符的执行上下文泄漏到下游,并影响最终收集流的线程,这将是令人尴尬的。幸运的是,这种情况永远不会发生。例如,如果你在 UI 线程上收集流,所有值都是由一个使用Dispatchers.Main的协程发出的。所有必要的上下文切换都会自动为你管理。

pawk 1002

图 10-2. 上游和下游流。

在内部,flowOn在检测到上下文即将改变时启动一个新的协程。这个新的协程通过一个内部管理的通道与流的其余部分交互。

注意

在流术语中,像map这样的中间操作符作用于上游流,并返回另一个流。从map操作符的角度来看,返回的流就是下游流。

map操作符接受一个挂起函数作为转换块。因此,如果你只想使用Dispatchers.Default执行消息翻译(而不是消息过滤),你可以移除flowOn操作符,并像这样声明translate函数:

private suspend fun Message.translate(
    language: String
): Message  = withContext(Dispatchers.Default) {
    // this is a dummy implementation
    copy(content = "translated content")
}

看看将数据转换的部分轻松转移到其他线程,同时仍然对数据流的整体有一个大图景是多么容易?

正如你所见,Flow API 允许以声明方式表达数据转换。当你调用getMessagesFromUser("Amanda," "en-us")时,并不会实际运行任何内容。所有这些转换涉及中间操作符,当流被收集时将被触发。

在消费方面,如果你需要对每个接收到的消息采取操作,你可以像这样使用collect函数:

fun main() = runBlocking {
    getMessagesFromUser("Amanda", "en-us").collect {
        println("Received message from ${it.user}: ${it.content}")
    }
}

现在我们已经展示了如何转换流并消耗它,我们可以为流本身提供一个实现:getMessageFlow函数。这个函数的签名是返回Message的流。在这种特定情况下,我们可以合理地假设消息机制实际上是在其自己的线程中运行的服务。我们将这个服务命名为MessageFactory

与大多数类似的服务一样,消息工厂具有发布/订阅机制——我们可以注册或注销观察者以接收新消息,如下所示:

abstract class MessageFactory : Thread() {
    /* The internal list of observers must be thread-safe */
    private val observers = Collections.synchronizedList(
        mutableListOf<MessageObserver>())
    private var isActive = true

    override fun run() = runBlocking {
        while(isActive) {
            val message = fetchMessage()
            for (observer in observers) {
                observer.onMessage(message)
            }
            delay(1000)
        }
    }

    abstract fun fetchMessage(): Message

    fun registerObserver(observer: MessageObserver) {
        observers.add(observer)
    }

    fun unregisterObserver(observer: MessageObserver) {
        observers.removeAll { it == observer }
    }

    fun cancel() {
        isActive = false
        observers.forEach {
            it.onCancelled()
        }
        observers.clear()
    }

    interface MessageObserver {
        fun onMessage(msg: Message)
        fun onCancelled()
        fun onError(cause: Throwable)
    }
}

此实现每秒轮询新消息并通知观察者。现在的问题是:如何将诸如MessageFactory这样的热实体转换为流?MessageFactory也被称为基于回调的,因为它保存对MessageObserver实例的引用,并在检索到新消息时调用这些实例的方法。要将流世界与“回调”世界连接起来,你可以使用callbackFlow流构建器。示例 10-2 展示了如何使用它。

示例 10-2. 从基于回调的 API 创建流
fun getMessageFlow(factory: MessageFactory) = callbackFlow<Message> {
    val observer = object : MessageFactory.MessageObserver {
        override fun onMessage(msg: Message) {
            trySend(msg)
        }

        override fun onCancelled() {
            channel.close()
        }

        override fun onError(cause: Throwable) {
            cancel(CancellationException("Message factory error", cause))
        }
    }

    factory.registerObserver(observer)
    awaitClose {
        factory.unregisterObserver(observer)
    }
}

callbackFlow构建器创建了一个冷流,直到你调用终端操作符之前都不会执行任何操作。让我们来详细分析一下。首先,它是一个带有给定类型的流的参数化函数。它总是分为三个步骤完成:

callbackFlow {
    /*
 1\. Instantiate the "callback." In this case, it's an observer.
 2\. Register that callback using the available api.
 3\. Listen for close event using `awaitClose`, and provide a
 relevant action to take in this case. Most probably,
 you'll have to unregister the callback.
 */
}

值得一看的是callbackFlow的签名:

public inline fun <T> callbackFlow(
    @BuilderInference noinline block: suspend ProducerScope<T>.() -> Unit
): Flow<T>

不要被这些印象深刻。一个关键的信息是,callbackFlow接受一个带有ProducerScope接收者的挂起函数作为参数。这意味着在callbackFlow后面的大括号块内部,你有一个ProducerScope实例作为隐式的this

这是ProducerScope的签名:

public interface ProducerScope<in E> : CoroutineScope, SendChannel<E>

因此,ProducerScope是一个SendChannel。这就是你应该记住的:callbackFlow为你提供了一个SendChannel实例,你可以在你的实现中使用它。你将从回调中获取的对象实例发送到这个通道。这就是我们在 Example 10-2 的第一步中所做的。

使用案例#2:并发转换值流

有时你必须在集合或对象流上应用转换,以获取一个新的转换对象集合。当这些转换应该异步进行时,情况就开始变得有些复杂了。但使用流,一切变得简单!

假设你有一个Location实例的列表。每个位置可以使用transform函数解析为一个Content实例:

suspend fun transform(loc: Location): Content = withContext(Dispatchers.IO) {
    // Actual implementation doesn't matter
}

因此,你会接收到Location实例,并且必须使用transform函数即时转换它们。然而,处理一个Location实例可能需要相当长的时间。因此,你不希望一个位置的处理延迟到下一个位置的转换。换句话说,转换应该并行进行,如 Figure 10-3 所示。

合并流

图 10-3. 合并流。

在前面的示意图中,我们限制了并发数为四;换句话说,在任何给定时间点最多可以同时转换四个位置。

Figure 10-4 展示了如何使用流来实现此行为。

pawk 1004

图 10-4. 实现合并流。

你可以在 GitHub 上找到对应的源代码

要理解这里的情况,你应该意识到locations.map{..}返回了一个流的流(例如,类型为Flow<Flow<Content>>)。实际上,在map{..}操作符内部,当上游流(即locationsFlow)发射一个位置时,就会创建一个新的流。每个创建的流都是Flow<Content>类型,并且单独执行位置转换。

最后一句,flattenMerge,将所有这些创建的流合并到一个新的结果流Flow<Content>中(我们将其分配给contentFlow)。此外,flattenMerge具有“并发”参数。确实,在每次接收位置时并发创建和收集流可能是不合适的。通过并发级别为 4,我们确保在给定时间点最多只有四个流将被收集。这在 CPU 密集型任务中非常方便,当你知道你的 CPU 不能并行转换超过四个位置时(假设 CPU 有四个核心)。换句话说,flattenMerge的并发级别指的是在给定时间点最多可以并行进行多少操作/转换。

多亏了流的挂起性质,你可以免费获得回压。只有在机器可用于处理时,才会从locationsFlow中收集新的位置。可以使用线程池和阻塞队列实现类似的机制,而不使用流或协程。然而,这将需要更多的代码行。

注意

战至撰写本文时,flattenMerge操作符在源代码中标记为@FlowPreview,这意味着此声明处于预览状态,并且可以通过尽力迁移以不向后不兼容的方式进行更改。

我们希望在完成写作时,流合并 API 将得到稳定。否则,类似的操作符可能会取代flattenMerge

发生错误时会发生什么?

如果其中一个transform函数抛出异常,整个流将被取消,并且异常将向下游传播。虽然这是一个很好的默认行为,但你可能希望在流内部处理一些异常。

我们将展示如何在“错误处理”中做到这一点。

最后的思考

  • 你是否意识到,我们仅用五行代码就创建了一个工作池,同时转换传入的对象流?

  • 您可以确保流机制是线程安全的。不再为了找到适当的同步策略而头疼,以便将对象引用从线程池传递到收集线程。

  • 您可以轻松调整并发级别,这在本例中意味着最大并行转换数。

使用情况#3:创建自定义操作符

即使有很多流操作符可以直接使用,有时候你必须自己制作。幸运的是,流是可组合的,实现自定义的响应式逻辑并不那么困难。

例如,到我们撰写这些文字的时候,还没有 Flows 操作符等同于Project Reactor 的 bufferTimeout

那么,bufferTimeout应该做什么?想象一下,你有一个元素的上游流,但你希望通过批处理以及在固定的最大速率下处理这些元素。bufferTimeout返回的流应该缓冲元素,并在以下情况之一时发出一个元素列表(批处理):

  • 缓冲区已满。

  • 预定义的最大时间已过(超时)。

在进行实现之前,让我们先讨论关键思想。bufferTimeout返回的流应内部消耗上游流并缓冲元素。当缓冲区满或超时已过时,流应发出缓冲区的内容(一个列表)。你可以想象内部我们会启动一个协程来接收两种类型的事件:

  • “刚刚从上游流接收到一个元素。我们是应该将其添加到缓冲区还是发送整个缓冲区?”

  • “超时!立即发送缓冲区内容。”

在第九章(CSP 部分)中,我们已经讨论过类似的情况。select表达式非常适合处理来自多个通道的多个事件。

现在我们将实现我们的bufferTimeout流操作符:

pawk 10in01

您可以在 GitHub 中找到相应的源代码

这里是解释:

  • 首先,操作符的签名告诉了我们很多信息。它声明为Flow<T>的扩展函数,因此您可以像这样使用它:upstreamFlow.bufferTimeout(10, 100)。至于返回类型,它是Flow<List<T>>。请记住,您希望按批处理处理元素,因此bufferTimeout返回的流应将元素作为List<T>返回。

  • 第 17 行:我们正在使用一个flow{}构建器。作为提醒,这个构建器为您提供了一个FlowCollector的实例,并且代码块是一个以FlowCollector作为接收器类型的扩展函数。换句话说,您可以在代码块内部调用emit

  • 第 21 行:我们正在使用coroutineScope{},因为我们将启动新的协程,这只能在CoroutineScope内部完成。

  • 第 22 行:从我们的协程视角来看,接收到的元素应该来自于一个ReceiveChannel。因此,我们需要启动另一个内部协程来消费上游的流,并通过一个通道发送它们。这正是produceIn流操作符的目的。

  • 第 23 行:我们需要生成“超时”事件。已经有一个专门用于此目的的库函数:ticker。它创建一个通道,在给定的初始延迟后产生第一个项目,并且在它们之间以给定的延迟产生后续项目。正如文档中指定的那样,ticker会急切地启动一个新的协程,我们需要完全负责取消它。

  • 第 34 行:我们正在使用whileSelect,它实际上只是在select表达式中循环的语法糖,当子句返回true时。在whileSelect{}块内部,您可以看到仅在缓冲区不满时才向其添加元素,并在缓冲区已满时发出整个缓冲区。

  • 第 46 行:当上游流收集完成时,使用produceIn启动的协程仍然会尝试从该流中读取,并且会引发ClosedReceiveChannelException。因此,我们捕获该异常,然后知道我们应该发出缓冲区的内容。

  • 第 48 和 49 行:通道是活跃实体,当它们不再需要使用时应该被取消。对于ticker也应该取消。

使用情况:

图 10-5 显示了如何使用bufferTimeout的示例。

 usage

图 10-5. bufferTimeout使用示例。

您可以在 GitHub 中找到相应的源代码

输出结果:

139 ms: [1, 2, 3, 4]
172 ms: [5, 6, 7, 8]
223 ms: [9, 10, 11, 12, 13]
272 ms: [14, 15, 16, 17]
322 ms: [18, 19, 20, 21, 22]
...
1022 ms: [86, 87, 88, 89, 90]
1072 ms: [91, 92, 93, 94, 95]
1117 ms: [96, 97, 98, 99, 100]

正如您所见,上游流正在发出从 1 到 100 的数字,每次发射之间延迟 10 毫秒。我们设置了 50 毫秒的超时,并且每个发射的列表最多可以包含五个数字。

错误处理

错误处理在响应式编程中至关重要。如果您熟悉 RxJava,您可能使用subscribe方法的onError回调来处理异常:

// RxJava sample
someObservable().subscribe(
    { value -> /* Do something useful */ },
    { error -> println("Error: $error") }
)

使用流,您可以使用一系列技术处理错误,涉及:

  • 经典的try/catch块。

  • catch操作符——我们将在讨论try/catch块后立即介绍这个新操作符。

try/catch 块

如果我们定义一个仅由三个Int组成的虚拟上游流,并且在collect{}块内部故意抛出异常,我们可以通过将整个链条包装在try/catch块中来捕获异常:

pawk 10in02

您可以在 GitHub 上找到相应的source code in GitHub

输出是:

Received 1
Received 2
Caught java.lang.RuntimeException

需要注意的是,try/catch也适用于从上游流内部引发异常的情况。例如,如果我们将上游流的定义更改为以下内容,我们将得到完全相同的结果:

pawk 10in03

您可以在 GitHub 上找到相应的source code in GitHub

然而,如果您尝试在流本身内部拦截异常,则可能会得到意外的结果。这里是一个例子:

// Warning: DON'T DO THIS, this flow swallows downstream exceptions
val upstream: Flow<Int> = flow {
    for (i in 1..3) {
        try {
            emit(i)
        } catch (e: Throwable) {
            println("Intercept downstream exception $e")
        }
    }
}

fun main() = runBlocking {
    try {
        upstream.collect { value ->
            println("Received $value")
            check(value <= 2) {
                "Collected $value while we expect values below 2"
            }
        }
    } catch (e: Throwable) {
        println("Caught $e")
    }
}

在这个例子中,我们使用flow构建器来定义upstream,并且我们在emit调用周围包裹了一个try/catch语句。即使看起来没有用,因为emit不会抛出异常,但在非平凡的发射逻辑中这也是有意义的。在消费站点,在main函数中,我们收集该流,并检查我们是否获得了严格大于 2 的值。否则,catch块应打印Caught java.lang.IllegalStateException Collected x while we expect values below 2

我们期望以下输出:

Received 1
Received 2
Caught java.lang.IllegalStateException: Collected 3 while we expect values below 2

然而,实际上我们得到的是:

Received 1
Received 2
Received 3
Intercept downstream exception java.lang.IllegalStateException: Collected 3 while we expect values below 2

尽管main函数的try/catch语句没有捕获check(value <= 2) {..}引发的异常,但flowtry/catch语句却捕获了它。

警告

流构建器内部的try/catch语句可能会捕获下游异常——包括在收集流期间引发的异常。

关注分离非常重要

流的实现不应对收集该流的代码产生副作用。同样,收集流的代码不应了解上游流的实现细节。流应始终对异常透明:它应传播来自收集器的异常。换句话说,流永远不应吞噬下游的异常。

在整本书中,我们将使用异常透明来指代一种对异常透明的流。

异常透明性违规

前一个示例是例外透明性违规的示例。试图从try/catch块内部发出值是另一种违规。以下是一个示例(再次强调,不要这样做!):

val violatesExceptionTransparency: Flow<Int> = flow {
    for (i in 1..3) {
        try {
            emit(i)
        } catch (e: Throwable) {
            emit(-1)
        }
    }
}

fun main() = runBlocking {
    try {
        violatesExceptionTransparency.collect { value ->
            check(value <= 2) { "Collected $value" }
        }
    } catch (e: Throwable) {
        println("Caught $e")
    }
}

输出如下:

Caught java.lang.IllegalStateException: Flow exception transparency is
violated:
Previous 'emit' call has thrown exception java.lang.IllegalStateException: Collected 3, but then emission attempt of value '-1' has been detected.
Emissions from 'catch' blocks are prohibited in order to avoid unspecified behaviour, 'Flow.catch' operator can be used instead.
For a more detailed explanation, please refer to Flow documentation.

try/catch块应该用于包围收集器,以处理收集器本身引发的异常,或者(虽然不是理想的)处理流引发的异常。

要处理流内的异常,应使用catch运算符。

catch 运算符

catch运算符允许以声明式风格捕获异常,如图 10-6 所示。它捕获所有上游异常。这里的所有异常包括Throwable。由于它只捕获上游异常,catch运算符不具有try/catch块的异常问题。

pawk 10in04

图 10-6. 声明式风格。

您可以在 GitHub 上找到相应的源代码

输出如下:

Received 1
Received 2
Caught java.lang.RuntimeException

如果流传递大于 2 的值,则流会引发RuntimeException。紧接着,在catch运算符中,我们在控制台打印。然而,收集器永远不会收到值 3。因此,catch运算符会自动取消流。

异常透明性

在这个运算符内部,你只能捕获上游异常。所谓的上游是相对于catch运算符而言。为了说明我们的意思,我们将选择一个示例,在该示例中,收集器在流内部抛出异常之前抛出异常。收集器应该能够捕获引发的异常(异常不应该被流捕获):

pawk 10in05

您可以在 GitHub 上找到相应的源代码

在此示例中,如果收集器收集到大于 2 的值,它会抛出RuntimeException。收集逻辑包装在try/catch语句中,因为我们不希望程序崩溃并记录异常。如果值为负,流内部会引发NumberformatExceptioncatch运算符充当保护措施(记录异常并取消流)。

输出如下:

Received 0
Collector stopped collecting the flow

注意,流没有拦截收集器内部引发的异常,因为异常已在try/catch子句中捕获。流从未引发NumberformatException,因为收集器过早取消了收集。

另一个示例

在“用例 #2: 同时转换值流”中,我们暂时不讨论错误处理。假设transform函数可能引发异常,其中包括NumberFormatException。您可以使用catch运算符有选择地处理NumberFormatException

fun main() = runBlocking {
    // Defining the Flow of Content - nothing is executing yet
    val contentFlow = locationsFlow.map { loc ->
        flow {
            emit(transform(loc))
        }.catch { cause: Throwable ->
            if (cause is NumberFormatException) {   ![1](https://github.com/OpenDocCN/ibooker-mobi-zh/raw/master/docs/prog-andr-kt/img/1.png)
                println("Handling $cause")
            } else {
                throw cause                         ![2](https://github.com/OpenDocCN/ibooker-mobi-zh/raw/master/docs/prog-andr-kt/img/2.png)
            }
        }
    }.flattenMerge(4)

    // We now collect the entire flow using the toList terminal operator
    val contents = contentFlow.toList()
}

1

catch操作符捕获Throwable时,我们需要检查错误的类型。如果错误是NumberFormatException,那么我们在if语句内处理它。您可以在那里添加其他检查来处理不同类型的错误。

2

否则,您无法知道错误的类型。在大多数情况下,最好不要吞噬错误并重新抛出。

您可以在catch内部使用emit

有时,在捕获流内部的异常时发出特定值是有意义的:

pawk 10in06

您可以在 GitHub 上找到相应的源代码

输出是:

Received 1
Received 3
Received 0

catch内部发出值对于具体化异常尤其有用。

具体化您的异常

异常的具体化^(5)是捕获异常并发出代表这些异常的特殊值或对象的过程。其目标是避免从流的内部抛出异常,因为代码执行会转向收集该流的任何地方。无论收集代码是否处理流抛出的异常,都不重要。如果流抛出异常,收集代码需要意识到这些异常并捕获它们,以避免未定义的行为。因此,流对收集代码有副作用,这违反了异常透明原则。

注意

收集代码不应该了解流的实现细节。例如,如果流是Flow<Number>,您应该只期望获得Number值(或其子类型),而不是异常。

让我们举另一个例子。假设您正在获取图像,给定它们的 URL。您有一系列传入的 URL:

// We don't use realistic URLs, for brevity
val urlFlow = flowOf("url-1", "url-2", "url-retry")

您已经可以使用此功能:

suspend fun fetchImage(url: String): Image {
    // Simulate some remote call
    delay(10)

    // Simulate an exception thrown by the server or API
    if (url.contains("retry")) {
        throw IOException("Server returned HTTP response code 503")
    }

    return Image(url)
}

data class Image(val url: String)

这个fetchImage函数可能会抛出IOException。为了使用urlFlowfetchImage函数来构建“图像流”,您应该具体化IOException。关于fetchImage函数,它要么成功返回一个Image实例,要么抛出异常。您可以通过Result类型来表示这些结果,其中包括SuccessError子类:^(6)

sealed class Result
data class Success(val image: Image) : Result()
data class Error(val url: String) : Result()

在成功的情况下,我们包装实际的结果——Image实例。在失败的情况下,我们认为将失败的图像检索的 URL 包装起来是合适的。但是,您可以自由地包装所有可能对收集代码有用的数据,例如异常本身。

现在,您可以通过创建一个返回Result实例的fetchResult函数来封装fetchImage的使用:

suspend fun fetchResult(url: String): Result {
    println("Fetching $url..")
    return try {
        val image = fetchImage(url)
        Success(image)
    } catch (e: IOException) {
        Error(url)
    }
}

最后,您可以实现一个resultFlow并安全地收集它:

fun main() = runBlocking {
    val urlFlow = flowOf("url-1", "url-2", "url-retry")

    val resultFlow = urlFlow
        .map { url -> fetchResult(url) }

    val results = resultFlow.toList()
    println("Results: $results")
}

输出是:

Fetching url-1..
Fetching url-2..
Fetching url-retry..
Results: [Success(image=Image(url=url-1)), Success(image=Image(url=url-2)), Error(url=url-retry)]

一个额外的奖励

假设您希望在发生错误时自动重试获取图像。您可以实现一个自定义流操作符,该操作符在predicate返回true时重试action

fun <T, R : Any> Flow<T>.mapWithRetry(
    action: suspend (T) -> R,
    predicate: suspend (R, attempt: Int) -> Boolean
) = map { data ->
    var attempt = 0L
    var shallRetry: Boolean
    var lastValue: R? = null
    do {
        val tr = action(data)
        shallRetry = predicate(tr, ++attempt)
        if (!shallRetry) lastValue = tr
    } while (shallRetry)
    return@map lastValue
}

如果您想重试,在返回错误之前最多可以尝试三次,您可以像这样使用此操作符:

fun main() = runBlocking {
    val urlFlow = flowOf("url-1", "url-2", "url-retry")

    val resultFlowWithRetry = urlFlow
        .mapWithRetry(
            { url -> fetchResult(url) },
            { value, attempt -> value is Error && attempt < 3L }
        )

    val results = resultFlowWithRetry.toList()
    println("Results: $results")
}

输出是:

Fetching url-1..
Fetching url-2..
Fetching url-retry..
Fetching url-retry..
Fetching url-retry..
Results: [Success(image=Image(url=url-1)), Success(image=Image(url=url-2)), Error(url=url-retry)]

使用 SharedFlow 的热流

以前的流实现是cold的:直到开始收集流为止,什么都不会运行。这是可能的,因为对于每个发出的值,只有一个收集器会获取该值。因此,在收集器准备好收集值之前,无需运行任何内容。

但是,如果您需要在多个收集器之间共享发出的值怎么办?例如,假设您的应用中完成了文件下载等事件。您可能希望直接通知各种组件,例如一些视图模型、存储库或甚至一些视图。您的文件下载器可能不需要知道应用程序的其他部分的存在。良好的关注点分离从类的松耦合开始,并且事件总线是在这种情况下有助于的一种架构模式。

原理很简单:下载器通过将事件(一个类的实例,可选地保存一些状态)传递给事件总线来发出事件,随后所有订阅者都会接收到该事件。SharedFlow 可以像这样运行,如 图 10-7 所示。

SharedFlow

图 10-7. SharedFlow

SharedFlow 将事件广播给所有订阅者。实际上,SharedFlow 确实是一个可以在许多情况下使用的工具箱,而不仅仅是实现事件总线。在提供使用示例之前,我们将展示如何创建一个 SharedFlow 以及如何调整它。

创建一个 SharedFlow

在最简单的用法中,您可以无参数调用 MutableSharedFlow()。正如其名称所示,您可以通过向其发送值来改变其状态。创建 SharedFlow 的常见模式是创建一个私有的可变版本和一个使用 asSharedFlow() 创建的公共不可变版本,如下所示:

private val _sharedFlow = MutableSharedFlow<Data>()
val sharedFlow: SharedFlow<Data> = _sharedFlow.asSharedFlow()

当确保订阅者只能读取流时,这种模式非常有用(例如,不能发送值)。您可能会感到惊讶,MutableSharedFlow 不是一个类。它实际上是一个接受参数的函数,我们稍后将在本章中详细介绍。现在,我们只展示了 MutableSharedFlow 的默认无参数版本。

注册一个订阅者

当订阅者开始收集 SharedFlow 时,最好使用公共不可变版本注册:

scope.launch {
   sharedFlow.collect { data ->
      println(data)
   }
}

订阅者只能存在于一个作用域中,因为 collect 终端操作符是一个挂起函数。这对于结构化并发非常有利:如果取消了作用域,订阅者也会被取消。

向 SharedFlow 发送值

MutableSharedFlow 公开两种方法来发射值——emittryEmit

emit

在某些条件下会暂停(稍后讨论)。

tryEmit

这永远不会暂停。它会立即尝试发射该值。

为什么有两种发射值的方法?这是因为,默认情况下,当 MutableSharedFlow 使用 emit 发射值时,它会暂停,直到 所有 订阅者开始处理该值。我们将在下一节中举例说明 emit 的使用。

然而,有时这并不是您想要的。您会发现某些情况下需要从非挂起代码发射值(见 “使用 SharedFlow 作为事件总线”)。因此,这里有 tryEmit,它尝试立即发射一个值,并在成功时返回 true,否则返回 false。我们将在接下来的部分详细介绍 emittryEmit 的微妙之处。

使用 SharedFlow 流式传输数据

假设您正在开发一款新闻应用。您的应用的一个特性是从 API 或本地数据库获取新闻并显示这些新闻(或新闻源)。理想情况下,您应该依赖于本地数据库,以尽可能避免使用 API。在此示例中,我们将 API 作为唯一的新闻来源,尽管您可以轻松扩展我们的示例以添加本地持久化。

架构

在我们的架构中,视图模型依赖于存储库获取新闻源。当视图模型接收到新闻时,它会通知视图。存储库负责定期查询远程 API,并为视图模型提供获取新闻源的方式(见 图 10-8)。

应用架构

图 10-8. 应用架构。

实施

为了简化,以下 News 数据类代表新闻:

data class News(val content: String)

存储库通过 NewsDao 访问 API。在我们的示例中,数据访问对象(DAO)是手动构造注入的。在实际应用中,建议使用依赖注入(DI)框架,如 Hilt 或 Dagger:

interface NewsDao {
    suspend fun fetchNewsFromApi(): List<News>
}

现在我们已经有足够的材料来实现存储库了:

class NewsRepository(private val dao: NewsDao) {
    private val _newsFeed = MutableSharedFlow<News>()    ![1](https://github.com/OpenDocCN/ibooker-mobi-zh/raw/master/docs/prog-andr-kt/img/1.png)
    val newsFeed = _newsFeed.asSharedFlow()              ![2](https://github.com/OpenDocCN/ibooker-mobi-zh/raw/master/docs/prog-andr-kt/img/2.png)

    private val scope = CoroutineScope(Job() + Dispatchers.IO)

    init {
        scope.launch {                                   ![3](https://github.com/OpenDocCN/ibooker-mobi-zh/raw/master/docs/prog-andr-kt/img/3.png)
            while (true) {
                val news = dao.fetchNewsFromApi()
                news.forEach { _newsFeed.emit(it) }      ![4](https://github.com/OpenDocCN/ibooker-mobi-zh/raw/master/docs/prog-andr-kt/img/4.png)

                delay(3000)
            }
        }
    }

    fun stop() = scope.cancel()
}

1

我们创建了私有的可变共享流。它仅在存储库内部使用。

2

我们创建了共享流的公共不可变版本。

3

一旦创建存储库实例,我们就开始从 API 获取新闻。

4

每次我们获取 News 实例列表时,我们都会使用我们的 MutableSharedFlow 发射这些值。

唯一剩下的就是实现一个视图模型,它将订阅存储库的共享流:

class NewsViewsModel(private val repository: NewsRepository) : ViewModel() {
    private val newsList = mutableListOf<News>()

    private val _newsLiveData = MutableLiveData<List<News>>(newsList)
    val newsLiveData: LiveData<List<News>> = _newsLiveData

    init {
        viewModelScope.launch {
            repository.newsFeed.collect {
                println("NewsViewsModel receives $it")
                newsList.add(it)
                _newsLiveData.value = newsList
            }
        }
    }
}

通过调用 repository.newsFeed.collect { .. },视图模型订阅了共享流。每当存储库向共享流发射一个 News 实例时,视图模型将接收到新闻并将其添加到其 LiveData 以更新视图。

注意流集合发生在使用 viewModelScope.launch 启动的协程内部。这意味着如果视图模型达到生命周期的末尾,流集合将自动取消,这是一件好事。

提示

在我们的示例中,我们手动构造注入一个对象(在本例中是仓库)。依赖注入框架肯定会帮助避免样板代码。由于本章节的重点不是演示依赖注入框架,我们选择手动将仓库注入到视图模型中。

我们实现的测试

为了测试前面的代码,我们需要模拟NewsDao。我们的 DAO 将只发送两个虚拟的News实例并增加一个计数器:

val dao = object : NewsDao {
    private var index = 0

    override suspend fun fetchNewsFromApi(): List<News> {
        delay(100)  // simulate network delay
        return listOf(
            News("news content ${++index}"),
            News("news content ${++index}")
        )
    }
}

当我们使用上述 DAO 运行我们的代码时,在控制台中可以看到以下内容:

NewsViewsModel receives News(content=news content 1)
NewsViewsModel receives News(content=news content 2)
NewsViewsModel receives News(content=news content 3)
...

这里没有什么令人惊讶的:我们的视图模型简单地接收存储库发送的新闻。当不只有一个而是多个视图模型订阅共享流时,情况变得有趣。我们已经创建了另一个视图模型,它也在控制台中记录。我们在程序启动后的 250 毫秒创建了另一个视图模型。这是我们得到的输出:

NewsViewsModel receives News(content=news content 1)
NewsViewsModel receives News(content=news content 2)
NewsViewsModel receives News(content=news content 3)
AnotherViewModel receives News(content=news content 3)
NewsViewsModel receives News(content=news content 4)
AnotherViewModel receives News(content=news content 4)
NewsViewsModel receives News(content=news content 5)
AnotherViewModel receives News(content=news content 5)
NewsViewsModel receives News(content=news content 6)
AnotherViewModel receives News(content=news content 6)
...

您可以看到另一个视图模型错过了前两个新闻条目。这是因为在共享流发出前两个新闻条目时,第一个视图模型是唯一的订阅者。第二个视图模型稍后加入,只接收后续新闻。

重放值

如果您需要第二个视图模型获取先前的新闻,会发生什么?共享流可以选择性缓存值,以便新订阅者接收最后n个缓存的值。在我们的情况下,如果我们希望共享流重放最后两条新闻条目,我们只需更新存储库中的一行:

private val _newsFeed = MutableSharedFlow<News>(replay = 2)

改变后,两个视图模型都会收到所有新闻。在其他常见情况下,回放数据实际上是有用的。想象一下用户离开显示新闻列表的片段。如果与片段绑定的生命周期相关联的视图模型也可能被销毁(如果您选择将视图模型绑定到活动上,则不会发生这种情况)。稍后,用户返回到新闻片段。那么会发生什么?视图模型会重新创建,并立即获取最后两条新闻条目,同时等待新的新闻。因此,仅重放两条新闻条目可能是不够的。因此,您可能希望将重放次数增加到,比如,15。

让我们回顾一下。一个SharedFlow可以选择性地为新订阅者重放值。通过MutableSharedFlow函数的replay参数,可以配置重放的值数量。

暂停还是不暂停?

还有关于这个重放特性的最后一点需要注意。一个replay > 0的共享流内部使用类似于Channel的缓存。例如,如果您创建一个带有replay = 3的共享流,则前三个emit调用不会暂停。在这种情况下,emittryEmit的作用完全相同:它们向缓存中添加一个新值,如图 10-9 所示。

重放缓存未满

图 10-9. 重放缓存未满。

当你向共享流提交第四个值时,取决于你使用的是emit还是tryEmit,如图 10-10 所示。默认情况下,当重放缓存满时,emit会暂停,直到所有订阅者开始处理缓存中的最旧值。至于tryEmit,由于无法将值添加到缓存中,它返回false。如果你没有自己跟踪第四个值,这个值将会丢失。

重放缓存已满

图 10-10. 重放缓存已满。

当重放缓存满时,可以更改该行为。你还可以选择丢弃缓存中最旧的值或正在添加到缓存中的值。在这两种情况下,emit不会暂停,并且tryEmit返回 true。因此,在缓冲区溢出时有三种可能的行为:暂停、丢弃最旧的和丢弃最新的。

在创建共享流时,可以使用onBufferOverflow参数应用所需的行为,如下所示:

MutableSharedFlow(replay = 3, onBufferOverflow = BufferOverflow.DROP_OLDEST)

BufferOverflow是一个包含三个可能值的枚举SUSPENDDROP_OLDESTDROP_LATEST。如果未为onBufferOverflow指定值,则SUSPEND是默认策略。

缓冲值

除了能够重放值外,共享流还可以缓冲值而不重放它们,允许慢速订阅者落后于其他更快的订阅者。缓冲区的大小是可自定义的,如下所示:

MutableSharedFlow(extraBufferCapacity = 2)

默认情况下,extraBufferCapacity等于零。当你设置一个严格正值时,只要缓冲区中还有空间,emit就不会暂停,除非你明确更改缓冲区溢出策略。

你可能会想知道extraBufferCapacity在哪些情况下会有用。例如,创建一个带有extraBufferCapacity = 1onBufferOverflow = BufferOverflow.DROP_OLDEST的共享流,可以确保tryEmit始终成功地将值插入到共享流中。有时候,从非挂起代码中向共享流插入值确实非常方便。一个很好的示例是在使用共享流作为事件总线时。

将 SharedFlow 用作事件总线

当所有以下条件满足时,你需要一个事件总线:

  • 你需要向一个或多个订阅者广播事件。

  • 事件应仅处理一次

  • 如果在发出事件时某个组件未注册为订阅者,则该组件将丢失事件。

请注意与LiveData的区别,它会在内存中保留上次发出的值,并在每次片段重新创建时重放它。对于事件总线,片段只会收到一次事件。例如,如果重新创建片段(用户旋转设备),事件不会再次被处理。

当你想要像 ToastSnackbar 一样显示消息时,事件总线特别有用。只显示一次消息是有意义的。为了实现这一点,存储库可以将共享流公开如下代码所示。为了使公开的流对视图模型或者片段可访问,你可以使用诸如 Hilt 或 Dagger 的 DI 框架:

class MessageRepository {
    private val _messageFlow = MutableSharedFlow<String>(
        extraBufferCapacity = 1,
        onBufferOverflow = BufferOverflow.DROP_OLDEST
    )
    val messageEventBus = _messageFlow.asSharedFlow()

    private fun someTask() {
        // Notify subscribers to display a message
        _messageFlow.tryEmit("This is important")
    }
}

我们将 extraBufferCapacity 设置为 1,将 onBufferOverflow 设置为 DROP_OLDEST,以确保 _messageFlow.tryEmit 总是能够成功发出。为什么我们关心 tryEmit?在我们的示例中,我们从一个非挂起函数使用 _messageFlow。因此,我们无法在 someTask 中使用 emit

如果你在协程内部使用 _messageFlow,你可以使用 emit。行为会完全相同,因为由于缓冲区的存在以及缓冲区溢出策略,emit 不会挂起。

事件总线适合分派一次性事件,一些组件可能会错过这些事件,如果它们尚未准备好接收这些事件。例如,假设当用户尚未导航到显示录音的片段时,你会触发“录制停止”事件。结果是事件丢失。但是,你的应用程序可以设计为在任何时候片段恢复时更新片段的状态。因此,仅当片段处于恢复状态时才接收“录制停止”,这应该触发状态更新。这只是一个示例,说明在某些情况下丢失事件是完全可以接受的,并且是应用程序设计的一部分。

然而,有时这并不是你想要实现的。例如,一个可以执行下载的服务。如果服务触发了“下载完成”事件,你不希望你的 UI 错过这个事件。当用户导航到显示下载状态的视图时,视图应该呈现下载的更新状态

你会面临需要共享状态的情况。这种情况非常常见,因此专门为此创建了一种类型的共享流:StateFlow

StateFlow:一种特殊的 SharedFlow

当共享状态时,状态流:

  • 仅共享一个值:当前状态

  • 重播状态。确实,即使订阅者在后来订阅,他们也应该获得最后一个状态。

  • 发出初始值——就像 LiveData 有初始值一样。

  • 仅在状态更改时发出新值。

正如你之前学到的,可以使用共享流来实现这种行为:

val shared = MutableSharedFlow(
    replay = 1,
    onBufferOverflow = BufferOverflow.DROP_OLDEST
)
shared.tryEmit(initialValue) // emit the initial value
val state = shared.distinctUntilChanged() // get StateFlow-like behavior

StateFlow^(7) 是前述代码的缩写。实际上,你只需要写:

val state = MutableStateFlow(initialValue)

StateFlow 使用示例

假设你有一个下载服务,它可以发出三种可能的下载状态:下载已开始、正在下载和下载完成,如 图 10-11 所示。

下载状态

图 10-11. 下载状态。

从 Android 服务公开流可以通过几种方式完成。如果需要高解耦,例如用于可测试性目的,可以通过 DI 注入的“repository”对象公开流。然后将存储库注入所有需要订阅的组件中。或者服务可以在伴生对象中静态地公开流。这将在所有使用流的组件之间引入紧密耦合。然而,在小型应用程序或演示目的中,这可能是可以接受的,例如以下示例:

class DownloadService : Service() {
    companion object {
        private val _downloadState =
            MutableStateFlow<ServiceStatus>(Stopped)
        val downloadState = _downloadState.asStateFlow()
    }
    // Rest of the code hidden for brevity
}

sealed class ServiceStatus
object Started : ServiceStatus()
data class Downloading(val progress: Int) : ServiceStatus()
object Stopped : ServiceStatus()

在内部,服务可以使用例如_downloadState.tryEmit(Stopped)来更新其状态。当声明在伴生对象内部时,状态流可以轻松地从视图模型访问,并使用asLiveData()作为LiveData公开:

class DownloadViewModel : ViewModel() {
    val downloadServiceStatus = DownloadService.downloadState.asLiveData()
}

随后,视图可以订阅LiveData

class DownloadFragment : Fragment() {
    private val viewModel: DownloadViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        viewModel.downloadServiceStatus.observe(this) {   ![1](https://github.com/OpenDocCN/ibooker-mobi-zh/raw/master/docs/prog-andr-kt/img/1.png)
            it?.also {
                onDownloadServiceStatus(it)
            }
        }
    }

    private fun onDownloadServiceStatus(
        status: ServiceStatus
    ): Nothing = when (status) {                          ![2](https://github.com/OpenDocCN/ibooker-mobi-zh/raw/master/docs/prog-andr-kt/img/2.png)
        Started -> TODO("Show download is about to start")
        Stopped -> TODO("Show download stopped")
        is Downloading -> TODO("Show progress")
    }
}

1

我们订阅LiveData。如果收到非空值,则调用onDownloadServiceStatus方法。

2

我们特意使用when作为表达式,以便 Kotlin 编译器确保考虑了所有可能的ServiceStatus类型。

您可能想知道为什么我们使用状态流,并且为什么我们首先没有使用LiveData——这消除了在视图模型中使用asLiveData()的需要。

原因很简单。LiveData是特定于 Android 的。它是一个生命周期感知的组件,仅在 Android 视图中使用时才有意义。您可能设计您的应用程序考虑到 Kotlin 多平台代码。当针对 Android 和 iOS 时,只有多平台代码可以作为公共代码共享。协程库是多平台的。LiveData不是。

然而,即使不考虑 Kotlin 多平台,流 API 也更有意义,因为它提供了所有流操作符的更大灵活性。

概述

  • 流 API 允许异步数据流转换。许多操作符已经内置并覆盖了大多数用例。

  • 由于流操作符的可组合特性,如果需要的话,你可以相当容易地设计自己的流操作。

  • 流的某些部分可以转移到后台线程或线程池,并保持对数据转换的高级别视图。

  • 共享流向所有订阅者广播值。您可以启用缓冲和/或回放值。共享流真的是一个工具箱。您可以将它们用作一次性事件的事件总线,或者用于组件之间更复杂的交互。

  • 当组件共享其状态时,适合使用一种特殊类型的共享流:状态流。它会为新订阅者重播最后的状态,并且仅在状态更改时通知订阅者。

^(1) 我们将在本章的其余部分将Flow称为flows

^(2) 令牌通常是客户端应用程序存储在内存中的加密注册数据,以便进一步的数据库访问不需要显式身份验证。

^(3) 与冷实体相对应,热实体会自行运行,直到显式停止。

^(4) 使用 coroutineScope{} 开始的协程。

^(5) Materialize 来自同名的 Rx 操作符。查看 Rx 文档 获取更多见解。

^(6) 这些子类是一种代数数据类型。

^(7) 实际上,StateFlow 在内部 就是 SharedFlow

第十一章:Android 性能分析工具的性能考虑

在 Android 中使用熟练的并发技术可以提升应用程序的性能。这也是为什么我们把 Kotlin 并发技术作为本书的主要关注点。为了解决性能瓶颈问题,首先你必须能够发现它们。不用担心:本章将介绍常用的 Android 工具,用于检测性能潜在问题。

在野外,Android 面临现实挑战,这些挑战影响性能和电池寿命。例如,并非所有人都有无限的移动数据计划或可靠的连接。现实情况是,Android 应用必须争夺有限的资源。性能应该是任何 Android 应用的重要考虑因素。Android 开发不仅仅是创建一个应用程序。有效的开发还确保平滑和无缝的用户体验。即使你对 Android 开发有深入的理解,你的应用程序可能会出现一些问题,比如:

  • 性能下降

  • 启动缓慢/对用户交互的响应缓慢

  • 电池耗尽

  • 资源浪费,内存拥堵

  • 不会强制崩溃或生成异常的 UI 错误,但仍然会影响用户体验

应用程序中这些突然、奇怪的行为列表远非详尽无遗。就像前面的章节展示的那样,当有多个交互的 Android 组件需要跟踪时,管理多线程可能变得复杂。即使你对多线程有很好的理解,直到使用性能分析工具分析应用程序性能时,才能真正了解应用程序的运行情况。为了回答这类模糊的问题,有几种有用的工具可以用来分析 Android 的各个方面。其中四个可以直接在 Android Studio 中获取并使用,如图 11-1 所示。

Android Studio 分析器

图 11-1. Android Studio 分析器和 LeakCanary 对于识别性能瓶颈非常有用。

本章中,我们将研究 Android Studio 的性能分析工具 Android Profiler 和一个流行的开源库 LeakCanary。我们通过对真实应用程序进行性能分析,以找出潜在的性能瓶颈。还记得前几章讨论过的徒步应用程序吗?惊喜!它的灵感来自 TrekMe。TrekMe 是一个 Android 山地徒步应用,用户可以下载互动式地形徒步路线以备离线使用。TrekMe 最初是一个 Java 项目,但其代码现在 80%以上是 Kotlin。以下是用户可以享受到的 TrekMe 的一些重要功能:

  • 下载地形地图以供离线使用。

  • 即使在没有网络的情况下,也能获取设备的实时位置,同时尽量节省电池寿命。

  • 在最需要时,详细跟踪徒步旅行而不耗尽设备的电池。

  • 可以在无需互联网连接的情况下访问其他有用的信息(仅用于创建地图)。

我们鼓励您探索 TrekMe,这样您就可以跟随本章的内容。您可以从 GitHub 获取源代码。克隆项目后,使用 Android Studio 打开它。最后,在您打算在上运行 TrekMe 的 Android Virtual Device (AVD) Manager 中运行一个模拟器实例。

性能考虑至关重要。在任何应用程序中发现性能滞后并不罕见,但必须谨慎对待这样的“钓鱼远征”。决定最相关的工具和优化方式,以及这些优化相对于其创建成本的利益的权衡,由开发者决定。对应用程序进行性能分析,使您能够客观地调查应用程序的性能。为了举例说明您可能会遇到的惊喜,我们将使用 Android Profiler 来查看 TrekMe。

Android Profiler

Android Profiler 分析应用程序的会话,生成 CPU 使用率、内存使用率以及网络和能耗分析的实时反馈。图 11-2 显示了 Android Studio 中 TrekMe 应用程序运行时显示在控制台底部的情况。

Android Profiler

图 11-2. 一个性能分析会话记录了性能分析数据。活动会话连接到模拟器中运行的应用程序(未显示)。

Android 分析可以通过三种方式实例化:

  1. 如果您的应用程序没有运行,请点击右上角的 Profile app 图标一次实例化应用程序和分析器。此操作会构建和编译应用程序的新运行实例。然后,Android Studio 将打开一个新会话,实时显示您的数据流。

  2. 如果您的应用程序已经在运行,请单击 + 图标并选择正在运行的模拟器。

  3. 您还可以通过点击 + 图标导入先前保存的性能分析会话。从那里,您可以加载先前保存的 .hprof 文件。

您可以在每个会话中记录和存储数据。在 图 11-3 中,我们展示了使用 Android Profiler 可以记录的不同类型数据的保存性能分析会话的屏幕截图。

pawk 1103

图 11-3. 保存堆转储或不同类型的 CPU 跟踪。

方法跟踪堆转储 都可以作为运行会话中的单独条目进行保存。方法跟踪显示可以在 CPU 分析中记录的方法和函数的堆栈跟踪。同时,堆转储指的是从 垃圾收集 中收集的数据,允许我们分析哪些对象占用了不必要的内存空间。

Android Profiler 每次记录一个应用程序会话。但是,您可以保存多个录音并在它们之间进行比较数据。明亮的点表示活动会话的录制。在 图 11-3 中,有三个记录的会话。最后一个记录的会话保存了一个堆转储,这是指在快照时 JVM 中存储内存的日志。我们将在 “内存分析器” 中详细讨论这一点。第一个记录的会话保存了不同类型的 CPU 记录。这将在 “CPU 分析器” 中讨论。

注意

Android Studio 仅在其实例的生命周期内缓存会话。如果重新启动 Android Studio,则不会保存记录的会话。

下面的章节详细展示了 Android Profiler 如何在运行时评估设备资源。我们将使用四个分析器:网络分析器CPU 分析器能量分析器内存分析器。所有这些分析器在应用运行时记录数据流,可以在它们各自的特殊视图中进行更详细的访问。

设计上,TrekMe 鼓励用户在家轻松地下载详细的地形地图到他们的设备上。在 TrekMe 中,创建新的地形地图是这个过程中消耗最多资源的功能。即使在移动覆盖不可靠的情况下,这些地图也可以在用户徒步旅行时进行渲染。TrekMe 的地图创建功能允许您选择像 Instituto Geografico Nacional(IGN)或 U.S. Geological Survey(USGS)等官方地图生成器,如 图 11-4 所示。然后,TrekMe 将逐个加载所选服务的地图瓦片。

pawk 1104

图 11-4. TrekMe 允许您从不同的服务创建和下载地图。

在本章的其余部分中,我们将使用 Android Profiler 分析 TrekMe 在通过 IGN 创建地图时的加载时间,并确保其性能最佳。

  • 我们的网络调用速度快吗?

  • 我们获取的数据是否以最高效的格式返回?

  • 应用程序的哪些部分最耗 CPU?

  • 哪些 Android 操作消耗了最多的电池?

  • 在堆中,哪些对象占用了最多的内存?

  • 什么消耗最多的内存?

在接下来的部分中,我们将使用网络分析器回答前两个问题。我们将在后续部分探讨其余问题。

网络分析器

当进行网络调用时,Android 设备中的无线电会打开,以便进行网络通信。然后,这个无线电会保持开启一段时间,以确保没有额外的请求需要监听。在某些手机上,每两分钟使用一次网络会使设备始终保持全电状态。对于 Android 资源来说,过多的网络调用可能代价高昂,因此分析和优化应用中的网络使用非常重要。

网络分析器 生成由 HttpURLConnectionOkHttp 库使用的连接详细信息。它可以提供诸如网络请求/响应时间、头部、cookie、数据格式、调用堆栈等信息。当您记录会话时,网络分析器会生成交互式视觉数据,同时您可以继续与应用程序进行交互。

当我们使用 IGN 创建地图时,TrekMe 会逐个在屏幕上渲染地图的方块瓦片。但有时,瓦片渲染似乎需要很长时间。图 11-5 显示了分析器捕获的传入/传出网络请求,并显示了在 TrekMe 上通过 IGN 创建地图时可用的连接:

您可以突出显示时间线上的选定范围,以进一步深入了解这些连接,这将扩展网络分析器工作区的新视图,允许您访问连接视图线程视图标签以进一步分析这些网络调用。

pawk 1105

图 11-5. 网络分析器时间线记录了 IGN 西班牙地图在 TrekMe 上的创建过程。在聊天窗口的左上角,标签 MainActivity 下的长线代表活动的 Activity 会话,而在标签 MainActivity 上方的短而厚的线,左侧带有一个点,代表用户触摸事件。

使用连接视图和线程视图查看网络调用

连接视图显示了发送/接收的数据。您可以在时间线的高亮部分看到这一点,详见图 11-6。也许最显著的是连接视图可以按大小、状态和时间对资源文件进行排序。点击每个部分的标题将会组织所需过滤器的排序。时间线部分将请求/响应条分成两种颜色。较浅的部分表示请求的持续时间,而较暗的部分表示响应的持续时间。

连接视图

图 11-6. 连接视图显示了单个网络调用的列表。

连接视图看起来与线程视图中的时间线类似,但它们并不完全相同。线程视图显示了在指定启动线程内进行的网络调用,可以显示并行时间内运行的多个网络调用。在图 11-7 中显示的屏幕截图是前一图像的补充,使用相同的数据集。

pawk 1107

图 11-7. 线程视图显示了每个线程内进行的网络调用列表。

实时查看工作线程如何分工可以帮助揭示改进的空间。TrekMe 的池化线程负责根据需要自动分解下载所有这些图片的工作。

两个图像显示大约 23 秒的网络调用时间,响应时间显示出相似的趋势。与请求相比,响应似乎占用了完成整个网络调用所需时间的不成比例部分。这可能有几个原因:例如,如果设备试图从一个遥远的国家获取数据,则服务器连接可能较弱。也许后端的查询调用存在效率低下的情况。不管原因是什么,我们可以说我们的网络调用可能不是最快的。然而,快速的请求时间与缓慢的响应时间的存在表明了设备无法控制的外部因素。

现在我们转向第二个问题:我们是否在使用最有效的数据格式?让我们在连接视图选项卡中查看连接类型,如图 11-6 中所示。如果您的图像不需要透明度,请避免使用 PNG 文件,因为该文件格式不如 JPEG 或 WebP 文件压缩得好。在我们的情况下,网络调用返回的是 JPEG 格式的有效负载。我们希望文件能够提供一致且良好的图像质量,以使用户能够根据需要放大这些图像的细节。使用 JPEG 文件还比使用 PNG 文件占用更少的内存。

通过选择任何项,我们可以获得关于每个网络调用及其有效负载的更详细信息:这将在网络分析工具的右侧打开一个新视图,显示概述、响应、请求和调用堆栈选项卡。在下一节中,我们将能够深入了解单个网络调用的具体情况,并找出代码中进行网络调用的位置。

网络调用,扩展:概述 | 响应 | 请求 | 调用堆栈

Android 开发人员习惯于与其他平台合作,以实现功能的平等性及更多功能。假设一个网络调用开始返回错误类型的信息以进行网络请求。API 团队需要您在客户端获取的网络请求和响应的具体信息。您如何将他们所需的请求参数和内容头部发送过去以便他们进行调查?

网络分析工具使我们能够在连接视图或线程视图的右侧面板上检查网络响应和请求,如图 11-8 所示。

概述标签详细说明了请求和响应中捕获的显著亮点:

请求

路径和可能的查询参数

状态

返回的响应中的 HTTP 状态码

方法

使用的方法类型

内容类型

资源的媒体类型

大小

返回响应中返回的资源大小

pawk 1108

图 11-8. 网络分析工具允许您检查响应和请求信息。

请求响应 选项卡显示了头部、参数、正文数据等的详细信息。在 图 11-9 中,我们展示了与前一图像相同的网络调用,但选择了响应选项卡。

正如您在网络响应中看到的,TrekMe 使用基本的 HTTP API。其他类型的 API 数据格式返回 HTML、JSON 和其他资源。在适用的情况下,请求和响应选项卡提供了正文数据的格式化或原始表示。在我们的情况下,资源媒体返回 JPEG 图像。

pawk 1109

图 11-9. 网络分析器捕捉网络调用以渲染地图。

最后,调用堆栈 选项卡显示了执行网络连接所做的相关调用的堆栈跟踪,如 图 11-10 所示。未淡化的调用代表来自您自己代码的方法调用。您可以右键点击所示的调用以轻松跳转到源代码。

网络分析器不仅对分析有用。正如您所见,您能够快速处理大量信息。从缓存重复调用到确认 API 合同,网络分析器是值得放在工具箱中的工具。

pawk 1110

图 11-10. 调用堆栈选项卡。

当涉及到渲染时间缓慢时,网络性能不是唯一的罪魁祸首。创建全新的地形图任务本身就非常繁重,但从网络角度来看,无需进一步采取措施来改善加载时间或数据格式。然而,仅凭慢响应时间本身是不足以解释慢加载时间的。在 TrekMe 接收网络数据后,必须处理数据以渲染用户界面。因此,我们应该检查在网络调用后绘制地图时潜在的效率低下。CPU 分析器 能够提供这方面的洞察力。在接下来的章节中,我们将使用 CPU 分析器来检查渲染 IGN 西班牙地图时的处理消耗情况。

CPU 分析器

虽然网络分析器能够提供有关网络调用的信息,但无法全面了解时间消耗的情况。对于网络调用,我们有一个调用堆栈,但不知道某些方法实际运行了多长时间。这就是 CPU 分析器发挥作用的地方。CPU 分析器通过分析函数执行消耗的时间和跟踪调用所在的线程,帮助识别资源消耗过度的问题。这为什么重要?如果 TrekMe 消耗过多处理资源,应用程序将变慢,影响用户体验。使用的 CPU 越多,电池消耗就越快。

CPU 分析器允许您通过线程检查 CPU 记录和实时数据,如 图 11-11 所示。

在接下来的章节中,我们将详细介绍 CPU 时间线、线程活动时间线和分析面板。因为 TrekMe 似乎花了很多时间将工作转移到后台线程,我们将选择一个线程来进行更详细的分析。

CPU Profiler 简介

图 11-11. CPU Profiler 显示了执行方法的调用堆栈和记录的时间。

CPU 时间线

CPU 时间线将区域性的调用堆栈组织到线程面板中的记录线程中。图中的 图 11-12 显示了 CPU 使用率的波峰,其中的数字是可用 CPU 的百分比。如果您已经进行了跟踪记录,您应该能够突出显示 CPU 时间线以查看更多信息。

CPU 时间线

图 11-12. CPU 时间线。

Android Studio 允许您在 CPU 时间线的记录样本上拖动和点击,以显示调用图。点击记录将带您到一个单独的跟踪 CPU 记录屏幕(详细内容请参阅 记录跟踪)。为了创建我们在下一节中探索的更细粒度的调用图,突出显示记录的 CPU 跟踪的较小部分非常有帮助。

线程活动时间线

线程活动时间线伴随 CPU 时间线显示应用程序中的每个运行线程。如果某个部分已被跟踪记录,您应该能够选择一个线程以查看在所选时间范围内捕获的调用堆栈。在 图 11-13 中,已创建并在应用程序中使用了 31 个线程。这些线程是由您的代码、Android 操作系统或第三方库创建的。

线程活动时间线

图 11-13. 线程活动时间线。

最浅色的块表示正在运行或活动的线程。在主线程上没有太多可见的内容,但请记住,这张图捕捉了下载地图图像的网络请求的 CPU 跟踪。在这种情况下,我们期望后台线程执行必要的工作来下载网络数据。似乎我们的主线程等待其中一个 DefaultDispatcher 线程的时间达到了一半。双击一个单独的线程会展开调用堆栈。

在线程活动时间线下方是调用图(参见 图 11-14)。

调用图

图 11-14. 调用图显示了捕获方法的自顶向下表示。

调用图显示了 CPU 使用时间段的调用堆栈。顶部方框表示封装的父方法,而下面的方法是被调用的子方法。父方法等待子方法执行完成,因此这是查看 TrekMe 方法中可能长时间执行的地方,比如 TileStreamProviderHttp 方法的良好位置。

如果您正在阅读印刷书籍,请注意条形图是按颜色编码的。Android OS 方法为橙色,您编写的方法为绿色,第三方库为蓝色。在这个协程中,执行时间最长的是TileStreamProviderHttp.getTileStream(...)。这是预期的,因为每个瓦片都会进行单独的网络请求。

分析面板

分析面板呈现了一个分层选项卡视图。面板顶部突出显示了活动线程的集合。在选项卡菜单下方是一个搜索栏,位于堆栈跟踪信息的上方。您可以使用搜索栏来过滤与特定调用相关的跟踪数据。在其下方是一组选项卡,用于在三个视图中渲染来自方法跟踪的视觉数据:自顶向下自底向上火焰图

自顶向下呈现了从图表顶部到底部的方法跟踪图形表示。任何在方法内部进行的调用都会呈现为原始方法下面的子方法。在 图 11-15 中显示的方法getTileStream在 TrekMe 中等待一系列的网络连接调用和从数据流中读取。

自顶向下视图显示了 CPU 时间如何分解成三种方式:

自身

方法执行时间本身

子方法

执行被调用方法所需的时间

总计

自身和子项的组合时间

pawk 1115

图 11-15. 自顶向下视图。

getTileStream的情况下,大部分时间都花在网络调用上:特别是连接请求和从网络接收数据的getInputStream。对于 IGN 西班牙服务器,在其他国家和一天中不同时间访问时,这些时间可能会有所不同。由于客户端正在消耗服务器数据,TrekMe 无法控制服务器的性能。

与自顶向下相反,自底向上(在 图 11-16 中显示)显示了调用堆栈的叶子元素的反向表示。这种视图显示了大量方法,有助于识别消耗最多 CPU 时间的方法。

最后一个选项卡提供了火焰图视图。火焰图提供了一个从底部向上聚合的操作视觉图。它提供了一个反转的调用图,以更好地查看哪些函数/方法消耗了更多的 CPU 时间。

自底向上

图 11-16. 自底向上视图。

总结一下,CPU 分析可以呈现三种不同的视图,取决于您希望进行的深度分析类型:

  • 自顶向下图形表示显示了每个方法调用的 CPU 时间以及其被调用方法的时间。

  • 自底向上反转了自顶向下的表示,并且最有用于对消耗最多或最少时间的方法进行排序。

  • 火焰图水平反转并聚合调用堆栈,以显示首先消耗最多 CPU 时间的同级其他被调方法。

不仅有三种不同的数据呈现方式,还有不同种类的调用堆栈可以记录。在接下来的章节中,我们将介绍 CPU Profiler 中不同种类的方法追踪。当您开始理解 CPU Profiler 试图捕获的信息类型时,我们将转向 CPU Profiler 中的方法追踪并记录 TrekMe 创建新地图的片段。

方法追踪

CPU Profiler 允许您记录跟踪以分析并呈现其状态、持续时间、类型等。跟踪涉及在短时间内记录设备活动。方法跟踪直到两次单击录制按钮才会发生:第一次开始录制,第二次结束录制。如图 11-17 所示,有四种样本和跟踪配置。

pawk 1117

图 11-17. Android 开发者可以为样本和跟踪配置配置。

样本 Java 方法捕获应用程序调用堆栈,或称为调用图表(也见前几节)。调用图表在线程活动时间线下呈现,显示特定时间哪些线程处于活动状态。这些跟踪将个人会话存储到右侧窗格中,以便与其他保存的会话进行比较。

通过选择样本 Java 方法配置,你可以通过将鼠标悬停在特定方法上来检查 TrekMe 的调用堆栈,如图 11-18 所示。

样本 Java 方法

图 11-18. 样本 Java 方法。
警告

不要让你的录音时间过长。一旦录音达到其大小限制,即使当前会话继续录制,跟踪也会停止收集数据。

与样本 Java 方法不同,跟踪 Java 方法串联了一系列记录的方法调用的时间戳。如果您愿意,可以监视样本 C/C+函数以获得应用程序与 Android 操作系统交互的洞察。为 Android API 26 及更高版本提供了本机线程的样本跟踪录制。

在日常谈话中,“方法”和“函数”通常在谈论方法追踪分析时可以互换使用。此时,您可能想知道为什么 Java 方法和 C/C++函数在 CPU 分析中区别足以重要。

在 CPU 录制配置中,Android Profiler 使用“方法”来指代基于 Java 的代码,而“函数”则引用线程。两者之间的区别在于方法执行顺序通过调用堆栈保留,而线程则由 Android 操作系统自身创建和调度。

最后,在图 11-17 中显示的配置中有系统调用跟踪。系统跟踪是一种为 Android 开发者提供的强大 CPU 录制配置。它返回有关帧渲染数据的图形信息。

跟踪系统调用记录了关于CPU 核心的分析,以查看如何在整个系统中进行调度。对于检测跨 CPU 核心的 CPU 瓶颈,此配置变得更加有意义。这些瓶颈可能在 RenderThread 噎住的地方显得特别突出,尤其是在红色帧的情况下。与其他配置不同,跟踪系统调用显示了线程状态及其当前运行的 CPU 核心,如 图 11-19 所示。

系统跟踪的一个关键功能是访问RenderThread。在渲染 UI 时,RenderThread 可显示性能瓶颈可能发生的位置。在 图 11-19 的情况下,我们可以看到大部分空闲时间发生在实际绘制图块本身周围。

Android 系统尝试根据屏幕的刷新率(在 8 ms 和 16 ms 之间)重新绘制屏幕。工作包耗时超过帧率可能会导致丢帧,在 Frames 下的红色插槽中指示。当某些任务在屏幕自我重新绘制之前未返回时,帧会丢失。在此系统跟踪记录中,看起来确实有一些丢帧,这些丢帧由 Frame 部分内标记方框中的数字指示。

TrekMe 将每帧保存为 JPEG 文件,并将图像加载到位图中进行解码。然而,在 图 11-19 中,我们看到在 RenderThread 中,DrawFrame 的长度与绘制速率间隔不完全匹配。在稍后的一些空闲时间内,某些长时间运行的 decodeBitmap 方法与池化线程有关。

系统跟踪

图 11-19. 系统跟踪显示了标记为 Frames 内的时间标签,显示了丢帧。

从这里开始,有一些可能被考虑用于更快绘制的选项;即,缓存图像的网络响应,甚至预取。对于需要几兆字节数据的用户来说,预取是一个很好的选择,尤其是在设备至少有 3G 网络访问的情况下。这样做的问题在于,在知道必须渲染之前,可能不是渲染这些位图的最佳选择。另一个选择可能是将数据编码成更压缩的格式,以便更容易解码。无论做出什么决定,都是开发者来评估实施某些优化的权衡和努力。

注意

预取的概念是指预测未来请求可能带来的数据类型,并在有活动无线电连接时预先抓取该数据。每个无线电请求在唤醒无线电并保持无线电唤醒状态期间都有额外开销,因此 Android 开发人员可以利用这一点,在无线电已经唤醒时进行额外的调用。

记录样本方法跟踪

现在您对录制配置的提供更加熟悉,我们转向 TrekMe 上的样本方法跟踪。CPU 记录与 CPU 分析器时间轴分离。首先,在屏幕顶部单击“录制”按钮,以分析与 TrekMe 交互时的 CPU 活动。

结束录制后,会生成一个带有样本或跟踪调用执行时间的选项卡式右侧窗格。您还可以一次性高亮显示多个线程进行分析。普通的 Android 开发者可能不会一直使用所有这些选项卡,但了解您可以使用哪些工具是非常重要的。

在 TrekMe 中,有一组预定义的可迭代瓷砖需要下载。多个协程同时读取可迭代对象,并在每个瓷砖的网络请求成功后解码位图。这些协程被发送到诸如 Dispatchers.IO 这样的调度器,并且在结果发送回 UI 线程后进行渲染。UI 线程永远不会被阻塞,等待位图解码或网络请求。

在 图 11-20 中,缩小的 CPU 时间轴乍一看似乎只是对前一屏幕视图的引用。然而,您可以通过范围选择器与这些数据进行交互,通过高亮显示时间段进一步深入分析,如 图 11-21 所示。

分析面板

图 11-20. CPU 分析器可将记录的跟踪结果分离。

范围选择器

图 11-21. 范围选择器有助于管理高亮显示范围的部分。

在 图 11-22 中,我们看到一个运行时间较长的方法 getTileStream。在时间轴下方,左侧面板允许您通过拖放功能组织线程交互。能够将线程分组也意味着您可以高亮显示一组堆栈跟踪。通过双击两次线程,您可以展开记录跟踪的线程,显示一个下拉式可视化的调用堆栈。

选择某个项目还会打开右侧的附加面板。这就是分析面板,它允许您更详细地检查堆栈跟踪和执行时间。跟踪 CPU 使用情况非常重要,但也许您希望能够分析应用程序与 Android 硬件组件的交互方式。在接下来的部分中,我们将探讨 Android Studio 的能量分析器

调用堆栈

图 11-22. 您可以通过搜索功能查找特定方法。

在 Android 设备上过多的网络调用也会耗电。设备无线电通信保持唤醒状态的时间越长,CPU 消耗和电池消耗就越多。根据这一逻辑,可以合理推测网络占用了大部分能量消耗。通过使用能量分析器,我们可以确认这一点。

能量分析器

能量分析器最适用于确定高能耗情况。当应用程序发起网络请求时,应用程序会启动移动无线电硬件组件。随着安卓设备与网络通信,CPU 消耗加快,导致电池更快地耗尽。

TrekMe 对位图进行预缩放,以确保用户缩放时内存和能量使用保持一致。当用户创建和下载地图时,默认情况下以最高分辨率的细节下载地图的详细信息。事件面板在下载大块数据时显示更高水平的消耗。

拖动和点击可以选择时间轴的一段范围,以显示 Android OS 事件的详细信息。在 图 11-23 中,我们可以看到能源图的弹出渲染,显示了能源图的分解。弹出图例的前半部分包含 CPU、网络和位置等类别,这些类别与堆叠图中提供的每个类别相关联。尽管在执行网络调用请求大数据块并将其绘制到屏幕上这一相对重要的任务中,CPU 和网络使用量轻微增加仍然是一个良好的迹象。

能量分析器系统事件面板

图 11-23. 系统事件面板。

弹出图例的后半部分描述了从设备捕获的系统事件类型。能量分析器用于捕获设备上某些类型的系统事件及其能耗:

  • 闹钟任务 是设计用于在指定时间唤醒设备的系统事件。作为最佳实践,Android 现在建议尽可能使用 WorkManagerJobScheduler,特别是用于后台任务。

  • 位置 请求使用 Android GPS 传感器,可能会消耗大量电池。确保正确评估准确性和频率是一个良好的实践。

虽然 图 11-23 仅显示一个位置请求,但还有其他类型的系统事件,它们具有各自独特的状态。例如,请求事件可能具有 Active 状态,如 图 11-23 所示,RequestedRequest Removed。同样,如果能量分析器捕获到 Wake Lock 类型的系统事件,则时间轴将显示唤醒锁事件期间的状态,例如 AcquiredHeldReleased 等。

选择特定的系统事件将在能源分析器的右窗格中打开,以查看更多详细信息。从这里,您可以直接跳转到该位置请求的源代码。在 TrekMe 中,GoogleLocationProvider是一个每秒轮询用户位置的类。这并不一定是一个问题 - 轮询旨在使设备能够持续更新您的位置。这证明了这种性能分析工具的效力:您可以获得精确的信息,而无需查看源代码。请求是逐个进行的,当新的图像块已下载时,会删除现有请求以进行新的请求。

与位置轮询相比,我们可以预期当用户放大渲染地图时,能源消耗会减少。不会发出请求来下载大块数据。我们确实期望在跟踪用户位置时会消耗一些能量,这也使用了GoogleLocationProvider

在 Figure 11-24 中,我们可以看到在堆叠覆盖图上方的圆点所示的过多和快速触摸事件。由于 TrekMe 已经下载了所需的所有信息,此时不会进行网络调用。然而,我们确实注意到 CPU 使用率再次急剧上升。为了避免过载系统,限制触摸事件以避免产生重复的缩放绘图函数是一个良好的实践。

TrekMe 能源分析器

图 11-24. TrekMe 打开并放大现有地图。

到目前为止,我们通过查看处理能力来评估性能。但是检查电池/CPU 使用情况并不总是能够诊断出性能问题。有时,慢速行为可能归因于内存阻塞。在接下来的部分中,我们探讨 CPU 和内存之间的关系,并在 TrekMe 的 GPX 录制功能上使用内存分析器。

内存分析器

在 TrekMe 中,您可以在拉出抽屉中导航到GPX Record。GPX 代表GPS 交换格式,是一组用于 GPS 格式化的数据,用于软件应用中的 XML 模式。徒步者可以在控制下点击播放图标。然后应用程序会跟踪并记录徒步者及其设备的移动,这些可以保存为 GPX 文件,稍后可以作为线绘制显示以指示所走路径。Figure 11-25 展示了 TrekMe 的 GPX 录制功能。

pawk 1126

图 11-25. TrekMe 的 GPX 录制功能使用GpxRecordingService来跟踪用户在徒步旅行中的 GPS 坐标。

我们知道,在系统中使用位置可能会对 CPU 处理造成负担。但有时,减速可能归因于内存问题。 CPU 处理使用 RAM 作为工作空间的容量,因此当 RAM 装满时,Android 系统必须执行堆转储。当内存使用受到严重限制时,同时执行许多任务的能力变得有限。执行较少的应用程序操作所需的时间越长,Android 的速度就越慢。 RAM 在所有应用程序之间共享:如果太多应用程序消耗太多内存,可能会减慢设备的性能,甚至导致OutOfMemoryException崩溃。

内存分析器允许您查看应用程序运行所分配的内存中消耗了多少内存。使用内存分析器,您可以在运行会话中手动触发堆转储,以生成分析结果,以确定堆中保存了哪些对象以及有多少个对象。

如图 11-26 所示,内存分析器提供了强大的功能:

  • 触发垃圾回收

  • 捕获 Java 堆转储

  • 分配跟踪

  • Android 应用程序中可用的片段和活动的交互式时间轴

  • 用户输入事件

  • 将内存计数划分为不同类别

内存分析器

图 11-26. 分配跟踪提供了一个完整的斜体文本配置,可以捕获内存中的所有对象分配,而采样配置则定期记录对象。
注意

像在 CPU 分析器中记录样本和跟踪一样,捕获 Java 堆转储结果会保存在 Android Profiler 的会话面板中,以便在 Android Studio 实例的生命周期内进行比较。

过多地触发垃圾回收(GC)可能会影响性能:例如,执行大量的 GC 可能会减慢设备的速度,这取决于内存中分代对象分配的频率和大小。至少,Android 开发人员应该尝试对每个应用程序运行内存分析,以确保没有任何东西被保留在堆中超出其用途,也就是所谓的“内存泄漏”。检测内存泄漏可能是救命的,尤其是对于依赖更长电池寿命的 Android 用户。您即将看到的是开发人员在处理服务时经常犯的一种常见内存管理错误的变体:意外保持服务运行。

TrekMe 使用前台服务来获取用户徒步旅行的统计数据,这是跟踪用户位置的自然选择。服务与其他 Android 组件一样,在应用程序的 UI 线程中运行。然而,持久服务往往会耗尽电池和系统资源。因此,限制前台服务的使用以不影响整体设备性能,并在必要时尽快终止它们是很重要的。

我们可以对 Memory Profiler 运行几次 GPX 录制并触发堆转储,以查看在堆中保留的对象消耗最多的内存,如图 11-27 所示。

pawk 1128

图 11-27。您可以使用 CTRL + F 功能搜索“GpxRecordingService”以缩小结果范围。

堆转储显示了一个类列表,可以按堆分配本机大小浅大小保留大小组织。浅大小是指使用的总 Java 内存。本机大小是指在本机内存中使用的总内存。保留大小由浅大小和保留大小(以字节为单位)组成。

在记录的堆转储中,您可以按app heapimage heapzygote heap组织您的分配记录。Zygote heap 指的是为 zygote 进程分配的内存,其中可能包括常用的框架代码和资源。Image heap 存储来自操作系统本身的内存分配,并包含对包含我们应用程序的映像中使用的类的引用,用于系统引导。对于我们的用例,我们更关注 app heap,这是应用程序分配内存的主要堆。

在内存分析器中,触发堆转储将显示在 GC 后仍保留在内存中的对象列表。这个列表可以为您提供:

  • Instance View窗格中显示的所选对象的每个对象实例,可以选择在代码中“跳转到源代码”

  • 通过右键单击References中的对象并选择Go to Instance,可以检查实例数据的能力

请记住,当缓存保留对不再需要的对象的引用时,会发生内存泄漏。在图 11-28 中,我们搜索“Location”以在相同的堆转储中定位我们的服务并查看总内存分配。LocationService似乎有多个分配,而实际上应该一次只有一个运行。

pawk 1129

图 11-28。似乎有可疑数量的LocationService实例仍保留在内存中。

看起来每次我们按下 Record 按钮时,在 TrekMe 中都会实例化一个新的LocationService,然后即使服务已经结束,该实例仍保留在内存中。您可以启动和停止服务,但如果您在后台线程中保留对该服务的引用,即使它已经结束,实例在 GC 后仍会保留在堆中。

让我们在 TrekMe 中运行几次录制以确认我们怀疑的行为。我们可以右键单击其中一个实例以“跳转到源代码”查看。在RecordingViewModel.kt中,我们看到以下代码:

fun startRecording() {
    val intent = Intent(app, LocationServices::class.java)
    app.startService(intent)
}

我们希望检查这些服务是否确实在启动新服务之前停止。已启动的服务会尽可能长时间保持活动状态:直到服务外部进行 stopService 调用或服务内部进行 stopSelf 调用。这使得持久服务的使用变得昂贵,因为 Android 认为运行中的服务始终处于使用中状态,这意味着服务在 RAM 中使用的内存将永远不会释放。

当 GPX 记录停止时,LocationService 会传播一系列事件,ping GPS 位置,然后将其记录并保存为一组数据。当刚写入 GPX 文件时,该服务订阅主线程以发送状态。由于 LocationService 扩展了 Android 的 Service,我们可以调用 Service::stopSelf 来停止该服务:

@Subscribe(threadMode = ThreadMode.MAIN)
fun onGpxFileWriteEvent(
   event: GpxFileWriteEvent
) {
    mStarted = false
    sendStatus()
    stopSelf()    // <--- fix will stop the service and release the reference at GC
}

我们可以使用内存分析器并检查堆转储,以确保我们只保留对一个服务的引用在内存中。事实上,由于 GPX 记录是通过 LocationService 进行的,因此在用户停止录制时停止服务是有意义的。这样,服务可以在 GC 时从内存中释放:否则,堆将继续保持 LocationService 实例直到其生命周期结束。

内存分析器可以帮助您通过筛查堆转储来检测可能的内存泄漏。您还可以通过在内存分析器中的堆转储配置中勾选 Activities/Fragments Leaks 复选框来过滤堆转储。寻找内存泄漏可能是一个手动的过程,即便如此,自己寻找内存泄漏仍然是捕捉它们的一种方式。幸运的是,我们有 LeakCanary,这是一个流行的内存泄漏检测库,可以在调试模式下附加到您的应用程序中,并悄悄地监视内存泄漏的发生。

使用 LeakCanary 检测内存泄漏

LeakCanary 可以在运行时自动检测可能难以手动检测到的显式和隐式内存泄漏。这是一个巨大的好处,因为内存分析器需要手动触发堆转储并检查保留的内存。当崩溃分析无法检测到来自 OutOfMemoryException 的崩溃时,LeakCanary 作为一种可行的替代方案,在运行时检测到的问题,并在发现内存泄漏方面提供更好的覆盖率。

内存泄漏通常来自于与对象生命周期相关的错误。LeakCanary 能够检测到各种错误,例如:

  • 在不销毁现有版本的情况下创建新的 Fragment 实例

  • 将 Android 的 ActivityContext 引用 隐式显式 注入非 Android 组件

  • 注册监听器、广播接收器或 RxJava 订阅者后忘记在父生命周期结束时释放监听器/订阅者

对于此示例,我们已在 TrekMe 中安装了 LeakCanary。在开发过程中,LeakCanary 可以自然地使用,直到保留了潜在泄漏的堆转储。您可以通过将以下依赖项添加到 Gradle 来安装 LeakCanary:

debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.*'

一旦安装到您的应用程序中,LeakCanary 会在ActivityFragment被销毁时自动检测泄漏,清除ViewModel等。它通过检测通过某些ObjectWatcher传递的保留对象来执行此操作。然后,LeakCanary 转储堆,分析堆,并对这些泄漏进行分类,以便轻松消费。安装 LeakCanary 后,您可以像平常一样使用应用程序。如果 LeakCanary 检测到堆转储中保留的实例,则会向系统托盘发送通知。

对于 TrekMe 而言,在图 11-29 中,LeakCanary 似乎检测到了MapImportFragment的 RecyclerView 实例中的内存泄漏。

pawk 1130

图 11-29. LeakCanary 显示了 RecyclerView 在其堆栈跟踪中的泄漏。

错误消息告诉我们,一个 RecyclerView 实例“泄漏”了。LeakCanary 指出此视图实例保持对包装活动的Context实例的引用。某些东西阻止了RecyclerView实例被垃圾回收——可能是一个对RecyclerView实例的隐式或显式引用超出了活动的组件。

我们还不确定我们在处理什么,因此我们首先查看MapImportFragment.kt类,其中提到了图 11-29 中的 RecyclerView。追溯到从布局文件引用的 UI 元素recyclerViewMapImport,我们注意到了一些奇怪的地方:

class MapImportFragment: Fragment() {

    private val viewModel: MapImportViewModel by viewModels()

    /* removed for brevity */

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        /* removed for brevity */
        recyclerViewMapImport.addOnItemTouchListener(
            RecyclerItemClickListener(
                this.context,                            ![1](https://github.com/OpenDocCN/ibooker-mobi-zh/raw/master/docs/prog-andr-kt/img/1.png)
                recyclerViewMapImport,
                object: RecyclerItemClickListener.onItemClickListener {
                    override fun onItemClick(view: View, position: Int) {
                        binding.fab.activate()
                        single.fab(position)
                    }
            })
        )
    }

    /* removed for brevity */

    private fun FloatingActionButton.activate() {
        /* removed for brevity */
        fab.setOnClickListener {
            itemSelected?.let { item ->
                val inputStream = context.contentResolver.
                    openInputStream(item.url)
                inputStream?.let {
                    viewModel.unarchiveAsync(it, item)   ![2](https://github.com/OpenDocCN/ibooker-mobi-zh/raw/master/docs/prog-andr-kt/img/2.png)
                }
            }
        }
    }
}

1

MapImportFragment中,我们为RecyclerView中的每个ViewHolder附加了自定义的点击监听器。

2

然后使用Context获取ContentResolver并创建InputStream以作为MapImportViewModel::unarchiveAsync的参数。

当用户点击 RecyclerView 中的特定项时,将调用 Kotlin 扩展函数FloatingActionButton::activate。请记住,内存泄漏的常见原因是我们意外地将ActivityContext注入非 Android 组件中。

如果仔细观察FloatingActionButton::activate的实现,可以看到我们创建了对封闭类的隐式引用,即MapImportFragment实例。

隐式引用是如何创建的?我们向按钮添加了点击监听器。监听器持有对父Context的引用(通过片段的getContext()方法返回)。为了能够从监听器内部访问Context,Kotlin 编译器创建了对封闭类的隐式引用。

在跟随到MapImportViewModel方法的代码时,我们看到InputStream被传递下来以调用ViewModel中的另一个私有方法:

class MapImportViewModel @ViewModelInject constructor(
    private val settings: Settings
) : ViewModel() {
    /* removed for brevity */

    fun unarchiveAsync(inputStream: InputStream, item: ItemData) {
        viewModelScope.launch {
            val rootFolder = settings.getAppDir() ?: return@launch
            val outputFolder = File(rootFolder, "imported")
            /* removed for brevity */
        }
    }
}

一个ViewModel对象有自己的生命周期,并且旨在超过其所绑定的视图的生命周期,直到Fragment被分离。与使用InputStream作为参数不同,最好使用应用程序context,它在整个应用程序的生命周期内都可用,并且可以通过MapImportViewModel的构造函数参数注入来注入。然后我们可以在MapImportViewModel::unarchiveAsync中创建InputStream

class MapImportViewModel @ViewModelInject constructor(
    private val settings: Settings,
    private val app: Application
): ViewModel() {
    /* removed for brevity */

    fun unarchiveAsync(item: ItemData) {
        viewModelScope.launch {
            val inputStream = app.contentResolve.
                openInputStream(item.uri) ?: return@launch
            val rootFolder = settings.getAppDir() ?: return@launch
            val outputFolder = File(rootFolder, "imported")
            /* removed for brevity */
        }
    }
}

当然,如果现有应用程序存在许多内存泄漏,打开 LeakCanary 可能会对开发造成干扰。在这种情况下,诱惑可能是关闭 LeakCanary 以防止当前工作的干扰。如果选择在应用程序上使用 LeakCanary,最好只在您和您的团队有能力“面对现实”时才这样做。

摘要

毫无疑问,Android 基准测试和性能分析工具非常强大。为了确保您的应用程序充分利用分析功能,最好选择一两个适当的工具。很容易迷失在优化的世界中,但重要的是要记住,最大的收益来自于付出最少努力和产生最大影响的优化。同样,重要的是要考虑当前的优先事项和团队工作量。

将 Android 优化视为营养师,鼓励逐步、习惯性的变化,而不是“暴饮暴食”。Android 性能分析旨在向您展示幕后发生的情况,但重要的是要记住,在一个时间和人力资源可能有限的世界中,普通的 Android 开发人员必须优先考虑哪些问题必须解决。

希望您感到更有能力处理可能出现的任何潜在错误,并且本章节给您信心开始探索一些这些工具在您自己的应用程序中的运行情况,看看事情在幕后是如何运作的:

  • Android 性能分析器是分析应用程序性能的强大方式,从网络和 CPU 到内存和能量分析。Android Studio 会缓存记录的会话以及堆转储和方法跟踪,以便您可以将它们与其他保存的会话进行比较。

  • 网络分析器可以帮助解决特定于 API 调试的 Android 问题。它可以提供对客户端设备和数据来源服务器都有用的信息,并且可以帮助我们确保网络调用中的数据格式化最佳。

  • CPU 分析器可以揭示大部分时间用于执行方法的地方,并且特别适用于找出性能瓶颈。您可以记录不同类型的 CPU 跟踪,以便能够深入研究特定线程和调用堆栈。

  • 能量分析器查看应用程序中的 CPU 进程、网络调用或 GPS 位置是否可能耗尽设备的电池。

  • Memory Profiler 查看分配在堆上的内存量。这可以帮助深入了解代码中可能需要在内存方面改进的地方。

  • LeakCanary 是由 Square 创建的流行开源库。使用 LeakCanary 可以帮助检测在运行时难以发现的内存泄漏问题。

^(1) @ViewModelInject 注解是 Hilt 框架特有的,这是一个依赖注入框架。然而,构造函数参数注入也可以通过手动 DI 或者像 Dagger 和 Koin 这样的 DI 框架实现。

第十二章:通过性能优化减少资源消耗

在前一章中,您已经熟悉了使用流行的 Android 分析工具来检查“引擎盖下”发生的情况的方法。这一最后一章突出了一系列性能优化考虑因素。没有适用于所有情况的通用方法,因此了解潜在的性能陷阱(及其解决方案)是有帮助的。然而,性能问题有时可能是多个复合问题的结果,单独看起来可能并不重要。

性能考虑因素允许您检查可能影响应用程序扩展能力的问题。如果可以在代码库中使用这些策略中的任何一个作为“低成本成果”,那么追求最大收益是非常值得的。本章的每个部分并不适合您工作的每个项目,但在编写任何 Android 应用程序时,它们仍然是有用的考虑因素。这些主题涵盖从视图系统性能优化到网络数据格式、缓存等内容。

我们知道 View 系统将被 Jetpack Compose 取代:但是,即使有了 Jetpack,View 系统在未来几年也不会消失。本章的前半部分专注于每个项目都能从中受益的视图主题:Android View 系统的潜在优化。如果不小心设置视图层次结构,它可能会对性能产生重大影响。因此,我们看看两种简单的优化视图性能的方法:使用ConstraintLayout减少视图层次结构的复杂性,并为动画/自定义背景创建可绘制资源。

使用 ConstraintLayout 实现更平坦的视图层次结构

作为一般规则,您希望在 Android 中保持视图层次结构尽可能平坦。深度嵌套的层次结构会影响性能,无论是在视图首次膨胀时还是用户与屏幕进行交互时都是如此。当视图层次结构深度嵌套时,发送指令到包含所有元素的根ViewGroup并遍历以对特定视图进行更改可能需要更长时间。

除了在 第十一章 中提到的分析工具外,Android Studio 还提供了布局检查器,它可以在运行时分析您的应用程序并在屏幕上创建视图元素的 3D 渲染。您可以通过单击 Android Studio 底部角标打开布局检查器,如 图 12-1 所示。

布局检查器

图 12-1. 布局检查器允许您旋转运行 API 29+ 的设备的 3D 渲染。

当子组件被绘制时,它们会叠加在父View上。布局检查器提供了一个组件树窗格,位于左侧,以便您可以深入了解元素并检查它们的属性。为了更好地理解用户与 Android UI 小部件交互时发生的情况,图 12-2 展示了与组件树中提供的完全相同的布局层次结构的鸟瞰图。

即使对于相对简单的布局,视图层次结构也可以迅速变得复杂起来。管理许多嵌套布局可能会带来额外的成本,例如更难管理触摸事件、更慢的 GPU 渲染以及难以保证在不同尺寸屏幕上视图的相同间距/大小。

布局检查器

图 12-2. 运行活动的元素完全展开。

除了您的应用程序可能需要的视觉更改之外,Android 操作系统还可能会独立影响视图属性。由您或操作系统调用的视图属性更改可能会触发视图层次结构的重新布局。是否发生这种情况取决于视图如何实现(由您自己或外部依赖实现),布局组件触发尺寸调整的频率以及它们在视图层次结构中的位置。

我们不仅必须担心层次结构的复杂性,还必须注意避免某些类型的视图,这些视图可能会使应用程序付出两倍于发送指令给 Android 操作系统所需遍历次数的代价。在 Android 中,一些较旧的布局类型在启用相对定位时容易出现“双重征税”:

RelativeLayout

这总是至少两次遍历其子元素:一次用于每个位置和大小的布局计算,一次用于最终定位。

LinearLayout

在水平方向设置其方向或在垂直方向上设置android:setMeasureWithLargestChildEnabled="true",这两种情况都会为每个子元素进行两次遍历。

GridLayout

如果布局使用权重分配或将android:layout_gravity设置为任何有效值,可能会导致双重遍历。

当这些情况之一位于树的更接近根部时,双重征税的成本可能会变得更加严重,甚至可能导致指数遍历。视图层次结构越深,处理输入事件和相应地更新视图所需的时间就越长。

作为一个良好的实践,最好降低视图重新布局对应用程序响应性的负面影响。为了保持更平坦和更健壮的层次结构,Android 倡导使用ConstraintLayoutConstraintLayout帮助为复杂布局创建响应式 UI。

使用ConstraintLayout时需要记住几个规则:

  • 每个视图必须至少有一个水平和一个垂直约束。

  • 视图的起始/结束只能链接到其他视图的起始/结束。

  • 视图的顶部/底部只能链接到其他视图的顶部/底部。

Android Studio 的设计预览显示父视图如何将视图绑定到屏幕的指定端点,如图 12-3 所示。

pawk 1203

图 12-3。在这个特定的ConstraintLayout中,微调按钮将所有父边约束到屏幕中心。左上角的文本元素只约束到父视图的顶部和左侧。

当突出显示时,锯齿状的线条出现在视图上,指示约束一侧到视图的位置,而波浪线则表示两个视图互相约束。

本书不涵盖ConstraintLayout的其他有用功能,如屏障、指南线、组和创建约束等。了解ConstraintLayout的最佳方法是在设计面板中的分割视图中自行尝试这些元素,如图 12-4 所示。

分割视图

图 12-4。设计面板的分割视图显示了布局文件的半代码和半设计。

使用ConstraintLayout,特别是当ViewGroup元素可能被深度嵌套或效率低下时,是解决任何 Android 应用程序运行时潜在性能瓶颈的简单方法。在下一节中,我们将把重点从视图本身的性能优化转移到视图动画。

使用可绘制对象减少程序绘制

任何 Android 项目的另一个潜在性能问题是运行时的程序绘制。偶尔,Android 开发人员会遇到布局文件中某些视图元素无法访问特定属性的情况。假设您想要在视图上渲染仅在顶部两个角具有圆角的视图。一种方法是通过 Kotlin 扩展函数以编程方式绘制:

fun View.roundCorners(resources: Resources, outline: OutLine?) {
    val adjusted = TypedValue.applyDimension(
        TypedValue.COMPLEX_UNIT_SP,
        25,
        resources?.displayMetrics
    )
    val newHeight =
        view.height.plus(cornerRadiusAdjusted).toInt()
    this.run { outline?.setRoundRect(0, 0, width, newHeight, adjusted)}
}

这是正常且有效的;然而,如果程序绘制过多,可能会导致渲染线程卡住,随后阻塞 UI 线程无法在运行时完成后续事件处理。此外,如果特定视图需要调整大小以满足约束,则以编程方式更改视图的成本会更高。在运行时调整视图元素的大小意味着您无法使用LayoutInflater来调整元素如何适应原始更改后视图的新尺寸。

您可以通过使用存储在资源资产的/drawables文件夹中的可绘制对象来避免可能发生的开销。以下代码显示了一个Drawable XML 文件如何实现在视图元素的顶部两个角实现相同目标的圆角化:

<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
    android:shape = "rectangle">
    <corners android:topLeftRadius="25dp" android:topRightRadius="25dp"/>
    <stroke android:width="1dp" android:color="#FFF"/>
    <solid android:color="#FFF"/>
</shape>

然后,您可以将文件名作为Drawable类型添加到视图布局文件中的背景属性中Drawable文件的名称:

android:background="@drawable/rounded_top_corners_background"

在前一节中,我们简要介绍了用户交互如何向 Android 操作系统发送指令的初始阶段。为了理解动画是如何发生的,我们现在将更深入地了解 Android 渲染 UI 的完整过程。让我们考虑 TrekMe 中的用户按下“创建地图”按钮的情况。

在本节剩余部分中,我们将介绍操作系统如何处理屏幕上的用户事件以及如何能够从软件到硬件执行绘制指令的全部过程。我们解释了 Android 操作系统在绘制中执行的所有阶段,直到动画发生在 Sync 阶段为止,如 图 12-5 所示。

Android UI 渲染方式

图 12-5. 动画发生在遍历执行后的 Sync 阶段。

VSync 代表屏幕上帧绘制之间给定的时间。在应用程序中,当用户触摸屏幕上的视图元素时,输入处理 就会发生。在 输入 阶段,Android 操作系统通过复制一组指令来跟踪脏状态,调用 invalidate 来使树上所有父视图元素节点无效。无效并不会重新绘制视图本身,而是在稍后指示系统哪些标记的视图必须稍后重新绘制。这是通过将复制的信息向上传播到视图层次结构中,以便在稍后的阶段在返回时执行的。图 12-6 显示了当用户输入后发生无效时的情况,当有人触摸按钮时:遍历节点,然后将一组 DisplayList 指令复制到每个父视图上。尽管箭头指向下方的元素,表示子元素,但遍历和复制 getDisplayList() 实际上是向上到根部再返回向下。

显示列表

图 12-6. DisplayList 对象是一组紧凑的指令,用于指示在画布上需要重新绘制哪些视图。这些指令在无效期间被复制到根层次结构的每个父视图元素中,然后在遍历期间执行。

然后,Android UI 系统安排下一个阶段,称为 遍历,其中包含自己的一组渲染阶段:

测量

这计算 MeasureSpecs 并将其传递给子元素进行测量。它递归地执行此操作,一直到叶节点。

布局

这设置了子布局的视图位置和大小。

绘制

这使用一组 DisplayList 指令渲染视图。

在接下来的阶段,“Sync”中,Android 操作系统在 CPU 和 GPU 之间同步DisplayList信息。当 CPU 在 Android 中与 GPU 交流时,JNI 在 UI 线程内的 Java 本地层接受其一套指令,并从 RenderThread 向 GPU 发送一个合成副本,以及其他一些信息。RenderThread 负责动画并从 UI 线程卸载工作(而不是必须将工作发送到 GPU)。从那里,CPU 和 GPU 彼此通信以确定应执行的指令,并在屏幕上合并视觉上渲染。最后,我们到达“Execute”阶段,在此阶段,操作系统以优化的方式执行DisplayList操作(例如一次性绘制相似的操作)。“Drawn Out: How Android Renders”是一个关于 Android 渲染系统层面更详细信息的优秀讨论。^(1)

截至 Android Oreo,如圆形揭示、涟漪和矢量可绘制动画等动画仅存在于RenderThread中,这意味着这些类型的动画对 UI 线程是非阻塞的。您可以使用自定义可绘制对象创建这些动画。考虑这样一种情况,我们希望在用户按下某种ViewGroup时,在视图背景中动画显示阴影涟漪。您可以组合一组可绘制对象来实现这一点,从RippleDrawable类型的Drawable开始创建涟漪动画本身:

<?xml version="1.0" encoding="utf-8"?>
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
        android:color="@color/primary">
    <item android:id="@android:id/mask">
        <shape android:shape="rectangle">
            <solid android:color="@color/ripple_mask" />
        </shape>
    </item>
</ripple>

RippleDrawable,其在 XML 中的等效物是ripple,需要一个颜色属性用于涟漪效果。要将此动画应用于背景,我们可以使用另一个可绘制文件:

<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
        android:shape="rectangle">
    <solid android:color="@color/background_pressed" />
</shape>

我们可以使用 DrawableStates,这是一组可以在Drawable上指定的框架提供的状态。在这种情况下,我们在选择器上使用 DrawableStates 来确定动画以及动画是否在按下时发生。最后,我们创建一个用于渲染不同状态的Drawable。每个状态由一个子可绘制对象表示。在这种情况下,我们仅在视图被按下时应用涟漪可绘制动画:

<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android"
    android:enterFadeDuration="@android:integer/config_shortAnimTime"
    android:exitFadeDuration="@android:integer/config_shortAnimTime">
    <item
        android:state_pressed="true" android:state_enabled="true"
        android:drawable="@drawable/background_pressed_ripple"/>
    <item
        android:state_pressed="false"
        android:drawable="@android:color/transparent"/>
</selector>
注意

正如本章开头提到的,围绕 Jetpack Compose 构建的视图系统与 Android 中的视图系统完全不同,具有其自己的 UI 管理、图形、运行时/编译时行为等一套。如果 Jetpack Compose 是通过编程方式进行绘制,那是否意味着使用 Jetpack Compose 来绘制不高效?尽管 XML 当前比 Compose 本身的渲染速度更快,但正在进行优化以缩小渲染时间差距。然而,您应牢记 Compose 所具有的主要优势是能够快速更新或重新组合可组合视图,比当前的 Android 视图框架效率更高。

我们已经讨论完视图性能优化,接下来我们将继续讨论围绕 Android 应用程序各个部分的更多性能优化提示。

在网络调用中最小化资产负载

在 Android 中,使用最小的负载是很重要的,以避免加载变慢、耗电、以及使用过多的数据。在前一章中,我们开始研究网络负载数据格式。图像和序列化数据格式通常是导致最多膨胀的罪魁祸首,因此检查负载的数据格式是很重要的。

如果在 Android 项目中处理的图像不需要透明度,最好使用 JPG/JPEG 格式,因为这种格式本质上不支持透明度,并且比 PNG 格式压缩更好。当需要为缩略图放大位图时,可能更合理地以更低分辨率呈现图像。

在工业界,JSON 通常被用作网络传输中的数据负载。不幸的是,JSON 和 XML 数据负载对于压缩来说很糟糕,因为数据格式包括空格、引号、换行符、顶点等等。像协议缓冲区这样的二进制序列化格式,是 Android 中的一种可访问的数据格式,可能是一种更便宜的替代方案。您可以定义数据结构,Protobuf 能够将数据压缩得比 XML 和 JSON 数据小得多。查看Google Developers了解更多关于协议缓冲区的信息。

位图池和缓存

TrekMe 使用位图池来避免分配过多的Bitmap对象。位图池在可能的情况下重用现有实例。这个“现有实例”是从哪里来的呢?当一个Bitmap不再可见时,不要让它可供垃圾回收(只是不保留对它的引用),您可以将不再使用的Bitmap放入“位图池”中。这样的池只是一个容器,用于存放以后使用的可用位图。例如,TrekMe 使用一个简单的内存双端队列作为位图池。要将图像加载到现有位图中,您必须指定要使用的位图实例。您可以使用BitMapFactory.OptionsinBitmap参数^(2)来实现这一点:

// we get an instance of bitmap from the pool
 BitmapFactory.Options().inBitmap = pool.get()

值得注意的是,像 Glide 这样的图片加载库可以帮助你避免处理位图的复杂性。使用这些库可以在应用程序中免费实现位图缓存。在网络调用较慢的情况下,获取新的 Bitmap 实例可能会很昂贵。这时,从位图缓存中获取可以节省大量时间和资源。如果用户重新访问一个屏幕,则该屏幕几乎可以立即加载,而无需再次进行网络请求。我们可以区分两种缓存:内存文件系统 缓存。内存缓存提供最快的对象检索速度,但会消耗更多内存。文件系统缓存通常较慢,但内存占用较低。一些应用程序依赖于内存中的 LRU 缓存,^(3) 而其他应用则使用基于文件系统的缓存或两种方法的混合。

例如,如果你的应用程序执行 HTTP 请求,可以使用 OkHttp 提供一个漂亮的 API 来使用文件系统缓存。OkHttp(也是流行库 Retrofit 的传递依赖项)是 Android 网络中广泛使用的流行客户端库。添加缓存相对较容易:

val cacheSize = 10 * 1024 * 1024
val cache = Cache(rootDir, cacheSize)

val client = OkHttpClient.Builder()
                .cache(cache)
                .build()

使用 OkHttp 客户端构建时,可以轻松创建具有自定义拦截器的配置,以更好地适应应用程序的使用场景。例如,拦截器可以强制缓存在指定间隔内刷新。在设备资源有限的环境中,缓存是一个很好的工具。因此,Android 开发者应该利用缓存来跟踪计算的结果。

提示

一个不错的支持 内存文件系统 缓存的开源库是 Dropbox Store

减少不必要的工作

为了节约应用程序的资源消耗,应避免编写不必要的代码。即使是资深开发人员通常也会犯这类错误,导致不必要的工作和内存分配。例如,在 Android 中,自定义视图需要特别注意。让我们考虑一个带有圆形形状的自定义视图。对于自定义视图的实现,你可以子类化任何类型的 View 并重写 onDraw 方法。这里是 CircleView 的一个可能实现:

// Warning: this is an example of what NOT to do!
class CircleView @JvmOverloads constructor(
    context: Context,
) : View(context) {

    override fun onDraw(canvas: Canvas) {
       super.onDraw(canvas)
       canvas.save()
       // Never initialize object allocation here!
       val paint: Paint = Paint().apply {
           color = Color.parseColor("#55448AFF")
           isAntiAlias = true
       }
       canvas.drawCircle(100f, 100f, 50f, paint)
       canvas.restore()
   }
}

每次需要重新绘制视图时都会调用 onDraw 方法。这可能会频繁发生,特别是如果视图被动画化或移动。因此,你绝不应在 onDraw 中实例化新对象。这样的错误会导致不必要地分配大量对象,增加垃圾收集器的压力。在前面的例子中,每次渲染层绘制 CircleView 时都会创建一个新的 Paint 实例。你绝对不能这样做。

另外,最好将 Paint 对象实例化为类属性一次:

class CircleView @JvmOverloads constructor(
    context: Context,
) : View(context) {

    private var paint: Paint = Paint().apply {
        color = Color.parseColor("#55448AFF")
        isAntiAlias = true
    }
        set(value) {
            field = value
            invalidate()
        }

    override fun onDraw(canvas: Canvas) {
       super.onDraw(canvas)
       canvas.save()
       canvas.drawCircle(100f, 100f, 50f, paint)
       canvas.restore()
   }
}

现在 paint 对象仅分配一次。对于这个现有类的目的,有时会将 paint 值设置为不同的颜色。但是,如果赋值不是动态的,您可以通过惰性评估 paint 值来进一步处理。

在可能的情况下,您希望保持注入的平衡和依赖关系的轻量化。对于仓库、服务和其他单例依赖项(内存中的单一对象,如 object),使用 lazy 委托是有意义的,这样就有一个单例实例,而不是在堆中存在多个相同对象的副本。

考虑我们之前在 “使用 LeakCanary 检测内存泄漏” 中检查过的代码:

class MapImportViewModel @ViewModelInject constructor(
    private val settings: Settings,
    private val app: Application
): ViewModel() {
    /* removed for brevity */

    fun unarchiveAsync(item: ItemData) {
        viewModelScope.launch {
            val inputStream = app.contentResolve.
                openInputStream(item.uri) ?: return@launch
            val rootFolder = settings.getAppDir() ?: return@launch
            val outputFolder = File(rootFolder, "imported")
            /* removed for brevity */
        }
    }
}

在这个类中,使用 Hilt 注入了 settings 依赖项—您可以通过 @ViewModelInject 看出来。在编写此示例时,我们使用的是 Hilt 2.30.1-alpha,并且只能将活动范围内可用的依赖项注入到 ViewModel 中。换句话说,只要活动没有重新创建,新创建的 MapImportViewModel 就始终注入到同一个 Settings 实例中。因此,总体而言:像 Hilt 这样的依赖注入框架可以帮助您管理依赖项的生命周期。在 TrekMe 中,Settings 被作用域在应用程序中。因此,Settings 在技术上是一个单例。

注意

Hilt 是一个依赖注入(DI)框架,为你的应用程序提供了一种标准的 DI 使用方式。该框架还具有自动管理生命周期的好处,并且有适用于 Jetpack 组件(如 ViewModels 和 WorkManager)的扩展。

避免不必要的工作扩展到 Android 开发的每个范围。在绘制要渲染到 UI 上的对象时,重复利用已经绘制的像素是有意义的。同样,由于我们知道在 Android 中进行网络调用会耗费电池,因此检查调用次数和频率是很好的。也许您的应用程序中有一个购物车。为了让用户跨平台访问其购物车,更新远程服务器可能是一个不错的商业决策。另一方面,也值得探索在本地存储中更新用户的购物车(定期进行网络更新)。当然,这些商业决策超出了本书的范围,但技术上的考虑总是有助于制定更加 thoughtful 的功能。

使用静态函数

当方法或属性不与任何类实例绑定时(例如不改变对象状态),有时使用 静态函数/属性 是有意义的。我们将展示使用静态函数比使用继承更合适的不同场景。

Kotlin 极大地简化了静态函数的使用。在类声明中的 companion object 包含了静态常量、属性和函数,可以在项目的任何地方引用。例如,Android 服务可以暴露一个静态属性 isStarted,该属性只能由服务本身修改,如示例 12-1 所示。

示例 12-1. GpxRecordingService.isStarted
class GpxRecordingService {

    /* Removed for brevity */

    companion object {
        var isStarted: Boolean = false
            private set(value) {
                EventBus.getDefault().post(GpxRecordServiceStatus(value))
                field = value
            }
    }
}

在示例 12-1 中,GpxRecordingService 可以在内部更改 isStarted 的值。在此过程中,通过事件总线发送事件,通知所有注册的组件。此外,GpxRecordingService 的状态可以从应用的任何地方以只读的 GpxRecordingService.isStarted 属性访问。但请记住避免意外地将 ActivityFragmentViewContext 保存到静态成员中,否则可能导致严重的内存泄漏!

使用 R8 和 ProGuard 进行缩小和混淆

常见做法是对生产中的发布构建进行 缩小,以便删除未使用的代码和资源。通过缩小代码,您可以更安全地向 Google PlayStore 提交更小的 APK。缩小 通过删除未使用的方法来减小您的代码。缩小代码还为您提供了 混淆 的功能作为额外的安全功能。混淆会乱化类/字段/方法的名称,并删除调试属性,以防止反向工程。

对于 Android 用户,R8 现在是 Android Gradle 插件 5.4.1+ 提供的默认缩小工具。ProGuard 是 R8 的严格和更强大的前身,主要专注于优化 Gson 等中重度反射的内容。相比之下,更新的缩小工具 R8 不支持此功能。然而,R8 在实现更小的压缩和优化方面取得了成功。

可以通过 proguardFile 进行配置(您将在本节末尾看到一个示例)。R8 读取提供给 proguardFile 的规则,并相应地执行缩小和混淆。然后可以将 proguardFile 分配给特定的风格和构建类型在 build.gradle 中:

buildTypes {
    release {
        minifyEnabled true
        shrinkResources true
        proguardFile getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
    }
}

缩小 APK 以上传到 PlayStore 是一种常见做法。然而,重要的是要警惕,避免无意中缩小/混淆可能在运行时需要由第三方库使用的代码。Kotlin 使用 Java 类中的元数据来支持 Kotlin 构造。然而,当 R8 缩小 Kotlin 类时,它无法保持与 Kotlin 元数据的状态同步。在最好的情况下,缩小/混淆这样的类可能会导致异常行为;在最坏的情况下,可能会导致难以解释的崩溃。

为了展示 ProGuard 在意外混淆太多应用程序代码的场景,我们观察到流行的开源库 Retrofit 出现了一些奇怪的行为。也许你的应用在调试模式下运行正常,但在发布模式下,一个网络调用莫名其妙地返回了NullPointerException。不幸的是,即使在 Retrofit 的@SerializedName注解属性/字段的情况下,Kotlin Gson 模型也会变空,这要归功于 Kotlin 反射。因此,你必须在你的 ProGuard 文件中添加一条规则,以防止 Kotlin 模型类被混淆。通常情况下,你可能会不得不通过直接将它们添加到你的proguardFile中来包含你的模型类。以下是向proguardFile添加模型域类的示例,以确保发布版本不会意外地混淆上述类:

# Retrofit 2.X
-dontwarn retrofit2.**
-keep class retrofit2.** { *; }
# Kotlin source code whitelisted here
-keep class com.some.kotlin.network.model.** { *; }
-keepattributes Signature
-keepattributes Exceptions
-keepclasseswithmembers class * {
    @retrofit2.http.* <methods>;
}

一个很好的建议是:始终测试发布版本!

摘要

本章覆盖了以下重要的性能优化技巧:

  • 在 Android 视图框架中,深度嵌套的视图层次结构比较扁平的层次结构绘制和遍历时间更长。考虑使用ConstraintLayout,在这里你可以扁平化嵌套视图。

  • 在 Android 视图框架中,最好将程序绘制和动画移至可绘制资源,以便在运行时将工作卸载到 RenderThread 上。

  • 对于网络数据负载,使用 JSON 和 XML 格式进行压缩效果很差。使用协议缓冲区可以实现更小的数据压缩。

  • 尽可能避免不必要的工作:确保你不会为了常量更新而进行不必要的网络调用,并尝试回收绘制对象。

  • 通过诚实地查看你编写的代码,可以实现性能和内存的优化。你是否无意中在循环中创建对象,而这些对象可以在循环外创建一次?哪些昂贵的操作可以减少为较不密集的操作?

  • 你可以使用 ProGuard 文件尽可能地减小应用程序的大小,并添加自定义规则来收缩、混淆和优化你的应用程序。

面对现实吧:Android 可能是一个难以跟进的挑战。逐步接受信息是可以的,这种策略保证了能够长期记住的学习机会。无论你的旅程处于什么阶段,除了这本书以外,Kotlin 和 Android 的最佳资源之一是开源社区。无论是 Android 还是 Kotlin,它们都是不断发展的社区,你可以从中获取最新和最相关的信息。为了保持自己的知识更新,你可以寻找像 Twitter、SlackKEEP 这样的额外资源。你可能会发现,随着时间的推移,在 Android 中经常出现的流行和经典问题,你也可以回到这本书中查阅。希望你喜欢这本书。

^(1) Chet Haase 和 Romain Guy。“绘制过程:Android 的渲染方式。” Google I/O ’18,2017 年。

^(2) 提供的Bitmap实例必须是可变位图。

^(3) LRU 代表最近最少使用。由于无法无限缓存对象,缓存始终与一种逐出策略相关联,以维护缓存的目标或可接受的大小。在 LRU 缓存中,“最旧”的对象首先被逐出。

posted @ 2025-11-15 13:07  绝不原创的飞龙  阅读(0)  评论(0)    收藏  举报