开发者博客 – IT技术 尽在开发者博客

开发者博客 – 科技是第一生产力


  • 首页

  • 归档

  • 搜索

数据结构与算法

发表于 2022-09-16

⭐️ 本文已收录到 AndroidFamily,技术和职场问题,请关注公众号 [彭旭锐] 和 [BaguTree Pro] 知识星球提问。

学习数据结构与算法的关键在于掌握问题背后的算法思维框架,你的思考越抽象,它能覆盖的问题域就越广,理解难度也更复杂。在这个专栏里,小彭将基于 Java / Kotlin 语言,为你分享常见的数据结构与算法问题,及其解题框架思路。

本文是数据结构与算法系列的第 14 篇文章,完整文章目录请移步到文章末尾~

前言

大家好,我是小彭。

今天分享到的是一种相对冷门的数据结构 —— 并查集。虽然冷门,但是它背后体现的算法思想却非常精妙,在处理特定问题上能做到出奇制胜。那么,并查集是用来解决什么问题的呢?


学习路线图:


  1. 认识并查集

除了并查集之外,不相交集合(Disjoint Sets)、合并-查找集合(Merge-find Set)、联合-查询数据结构(Union-find Data Structure)、联合-查询算法(Union-find algorithm),均表示相同的数据结构或思想。

1.1 并查集用于解决什么问题?

并查集是一种用来高效地判断 “动态连通性 ” 的数据结构: 即给定一个无向图,要求判断某两个元素之间是否存在相连的路径(连通),这就是连通问题,也叫 “朋友圈” 问题。听起来有点懵,你先别着急哈,咱来一点一点地把这个知识体系建立起来。

先举个例子,给定一系列航班信息,问是否存在 “北京” 到 “广州” 的路径,这就是连通性问题。而如果是问 “北京” 到 “广州” 的最短路径,这就是路径问题。并查集是专注于解决连通性问题的数据结构,而不关心元素之间的路径与距离,所以最短路径等问题就超出了并查集的能够处理的范围,不是它考虑的问题。

连通问题与路径问题示意图

另一个关键点是,并查集也非常适合处理动态数据的连通性问题。 因为在完成旧数据的处理后,旧数据的连通关系是记录在并查集中的。即使后续动态增加新的数据,也不需要重新检索整个数据集,只需要将新数据提供的信息补充到并查集中,这带有空间换时间的思想。

动态连通问题

理解了并查集的应用场景后,下面讨论并查集是如何解决连通性的问题。

1.2 并查集的逻辑结构

既然要解决连通性问题,那么在并查集的逻辑结构里,就必须用某种方式体现出两个元素或者一堆元素之间的连接关系。那它是怎么体现的呢 —— 代表元法。

并查集使用 “代表元法” 来表示元素之间的连接关系:将相互连通的元素组成一个子集,并从中选取一个元素作为代表元。而判断两个元素之间是否连通,就是判断它们的代表元是否相同,代表元相同则说明处于相同子集,代表元不同则说明处于不同的子集。

例如,我们将航班信息构建为并查集的数据结构后,就有 “重庆” 和 “北京” 两个子集。此时,问是否存在 “北京” 到 “广州” 的路径,就是看 “北京” 和 “广州” 的代表元是否相同。可见它们的代表元是相同的,因此它们是连通的。

并查集的逻辑结构和物理结构

理解了并查集的逻辑结构后,下面讨论如何用代码实现并查集。

1.3 并查集的物理结构

并查集的物理结构可以是数组,亦可以是链表,只要能够体现节点之间连接关系即可。

  • 链表实现: 为每个元素创建一个链表节点,每个节点持有指向父节点的指针,通过指针的的指向关系来构建集合的连接关系,而根节点(代表元)的父节点指针指向节点本身;
  • 数组实现: 创建与元素个数相同大小的数组,每个数组下标与每个元素一一对应,数组的值表示父节点的下标位置,而根节点(代表元)所处位置的值就是数组下标,表示指向本身。

数组实现相对于链表实现更加常见,另外,在数组的基础上还衍生出散列表的实现,关键看元素个数是否固定。例如:

  • 在 LeetCode · 990. 等式方程的可满足性 这道题中,节点是已知的 26 个字母,此时使用数组即可;
  • 在 LeetCode · 684. 冗余连接 这道题中,节点个数是未知的,此时使用散列表更合适。

提示: 我们这里将父节点指向节点本身定义为根节点,也有题解将父节点指向 null 或者 -1 的节点定义为根节点。两种方法都可以,只要能够区分出普通节点和根节点。但是指向节点本身的写法更简洁,不需要担心 Union(x, x) 出现死循环。

以下为基于数组和基于散列表的代码模板:

基于数组的并查集

1
2
3
4
5
6
kotlin复制代码// 数组实现适合元素个数固定的场景
class UnionFind(n: Int) {
// 创建一个长度为 n 的数组,每个位置上的值初始化数组下标,表示初始化时有 n 个子集
private val parent = IntArray(n) { it }
...
}

基于散列表的并查集

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
kotlin复制代码// 散列表实现适合元素个数不固定的场景
class UnionFind() {
// 创建一个空散列表,
private val parent = HashMap<Int, Int>()

// 查询操作
fun find(x: Int): Int {
// 1. parent[x] 为 null 表示首次查询,先加入散列表中并指向自身
if (null == parent[x]) {
parent[x] = x
return x
}
// 下文说明查询操作细节...
}
}

  1. 并查集的基本概念

2.1 合并操作与查询操作

“并查集,并查集”,顾名思义并查集就是由 “并” 和 “查” 这两个最基本的操作组成的:

  • Find 查询操作: 沿着只用链条找到根节点(代表元)。如果两个元素的根节点相同,则说明两个元素是否属于同一个子集,否则属于不同自己;
  • Union 合并操作: 将两个元素的根节点合并,也表示将两个子集合并为一个子集。

例如,以下是一个基于数组的并查集实现,其中使用 Find(x) 查询元素的根节点使用 Union(x, y) 合并两个元素的根节点:

基于数组的并查集

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
kotlin复制代码class UnionFind(n: Int) {

// 创建一个长度为 n 的数组,每个位置上的值初始化数组下标,表示初始化时有 n 个子集
val parent = IntArray(n) { it }

// 查询操作(遍历写法)
fun find(x: Int): Int {
var key = x
while (key != parent[key]) {
key = parent[key]
}
return key
}

// 合并操作
fun union(x: Int, y: Int) {
// 1. 分别找出两个元素的根节点
val rootX = find(x)
val rootY = find(y)
// 2. 任意选择其中一个根节点成为另一个根节点的子树
parent[rootY] = rootX
}

// 判断连通性
fun isConnected(x: Int, y: Int): Boolean {
// 判断根节点是否相同
return find(x) == find(y)
}

// 查询操作(递归写法)
fun find(x: Int): Int {
var key = x
if (key != parent[key]) {
return find(parent[key])
}
return key
}
}

合并与查询示意图

2.2 连通分量

并查集的连通分量,表示的是整个并查集中独立的子集个数,也就是森林中树的个数。要计算并查集的连通分量,其实就是在合并操作中维护连通分量的计数,在合并子集后将计数减一。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
kotlin复制代码class UnionFind(n: Int) {

private val parent = IntArray(n) { it }

// 连通分量计数,初始值为元素个数 n
var count = n

// 合并操作
fun union(x: Int, y: Int) {
val rootX = find(x)
val rootY = find(y)
if(rootX == rootY){
// 未发生合并,则不需要减一
return
}
// 合并后,连通分量减一
parent[rootY] = rootX
count --
}
...
}

连通分量示意图


  1. 典型例题 · 等式方程的可满足性

理解以上概念后,就已经具备解决连通问题的必要知识了。我们看一道 LeetCode 上的典型例题: LeetCode · 990.

LeetCode 例题

我们可以把每个变量看作看作一个节点,而等式表示两个节点相连,不等式则表示两个节点不相连。那么,我们可以分 2 步:

  • 1、先遍历所有等式,将等式中的两个变量合并到同一个子集中,最终构造一个并查集;
  • 2、再遍历所有不等式,判断不等式中的两个变量是否处于同一个子集。是则说明有冲突,即等式方程不成立。

—— 图片引用自 LeetCode 官方题解

题解示例如下:

题解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
kotlin复制代码// 未优化版本
class Solution {
fun equationsPossible(equations: Array<String>): Boolean {
// 26 个小写字母的并查集
val unionFind = UnionFind(26)

// 合并所有等式
for (equation in equations.filter { it[1] == '=' }) {
unionFind.union(equation.first(), equation.second())
}
// 检查不等式是否与连通性冲突
for (equation in equations.filter { it[1] == '!' }) {
if (unionFind.isConnected(equation.first(), equation.second())) {
return false
}
}
return true
}

private fun String.first(): Int {
return this[0].toInt() - 97
}

private fun String.second(): Int {
return this[3].toInt() - 97
}

private class UnionFind() {
// 代码略
}
}

  1. 并查集的优化

前面说到并查集逻辑上是一种基于森林的数据结构。既然与树有关,我们自然能想到它的复杂度就与树的高度有关。在极端条件下(按照特殊的合并顺序),有可能出现树的高度恰好等于元素个数 n 的情况,此时,单次 Find 查询操作的时间复杂度就退化到 O(n)O(n)O(n)。

那有没有优化的办法呢?

4.1 父节点重要吗?

在介绍具体的优化方法前,我先提出来一个问题:在已经选定集合的代表元后,一个元素的父节点是谁还重要吗?答案是不重要。

因为无论父节点是谁,最终都是去找根节点的。至于中间是经过哪些节点到达根节点的,这个并不重要。举个例子,以下 3 个并查集是完全等价的,但明显第 3 个并查集中树的高度更低,查询的时间复杂度更好。

父节点并不重要

理解了这个点之后,再理解并查集的优化策略就容易了。在并查集里,有 2 种防止链表化的优化策略 —— 路径压缩 & 按秩合并。

4.2 路径压缩(Path Compression)

路径压缩指在查询的过程中,逐渐调整父节点的指向,使其指向更高层的节点,使得很多深层的阶段逐渐放到更靠近根节点的位置。 根据调整的激进程度又分为 2 种:

  • 隔代压缩: 调整父节点的指向,使其指向父节点的父节点;
  • 完全压缩: 调整父节点的指向,使其直接指向根节点。

路径压缩示意图

路径压缩示例程序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
kotlin复制代码// 遍历写法
fun find(x: Int): Int {
var key = x
while (key != parent[key]) {
parent[key] = parent[parent[key]]
key = parent[key]
}
return key
}

// 递归写法
fun find(x: Int): Int {
var key = x
if (key != parent[key]) {
parent[key] = find(parent[key])
return parent[key]
}
return key
}

4.3 按秩合并(Union by Rank)

在 第 2.1 节 提到合并操作时,我们采取的合并操作是相对随意的。我们在合并时会任意选择其中一个根节点成为另一个根节点的子树,这就有可能让一棵较大子树成为较小子树的子树,使得树的高度增加。

而按秩合并就是要打破这种随意性,在合并的过程中让较小的子树成为较大子树的子树,避免合并以后树的高度增加。 为了表示树的高度,需要维护使用 rank 数组,记录根节点对应的高度。

按秩合并示意图

按秩合并示例程序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
kotlin复制代码private class UnionFind(n: Int) {
// 父节点
private val parent = IntArray(n) { it }

// 节点的高度
private val rank = IntArray(n) { 1 }

// 连通分量
var count = n
private set

// 查询(路径压缩)
fun find(x: Int): Int {
var key = x
while (key != parent[key]) {
parent[key] = parent[parent[key]]
key = parent[key]
}
return key
}

// 合并(按秩合并)
fun union(key1: Int, key2: Int) {
val root1 = find(key1)
val root2 = find(key2)

if (root1 == root2) {
return
}
if (rank[root1] > rank[root2]) {
// root1 的高度更大,让 root2 成为子树,树的高度不变
parent[root2] = root1
} else if (rank[root2] > rank[root1]) {
// root2 的高度更大,让 root1 成为子树,树的高度不变
parent[root1] = root2
} else {
// 高度相同,谁当子树都一样
parent[root1] = root2
// root2 的高度加一
rank[root2]++
// 或
// parent[root2] = root1
// rank[root1] ++
}
count--
}
}

4.4 优化后的时间复杂度分析

在同时使用路径压缩和按秩合并两种优化策略时,单次合并操作或查询操作的时间复杂度几乎是常量,整体的时间复杂度几乎是线性的。

以对 N 个元素进行 N - 1 次合并和 M 次查询的操作序列为例,单次操作的时间复杂度是 O(a(N))O(a(N))O(a(N)),而整体的时间复杂度是 O(M⋅a(N))O(M·a(N))O(M⋅a(N))。其中 a(x)a(x)a(x) 是逆阿克曼函数,是一个增长非常非常慢的函数,只有使用那些非常大的 “天文数字” 作为变量 xxx,否则 a(x)a(x)a(x) 的取值都不会超过 4,基本上可以当作常数。

然而,逆阿克曼函数毕竟不是常数,因此我们不能说并查集的时间复杂度是线性的,但也几乎是线性的。关于并查集时间复杂度的论证过程,具体可以看参考资料中的两本算法书籍,我是看不懂的。


  1. 典型例题 · 岛屿数量(二维)

前面我们讲的是一维的连通性问题,那么在二维世界里的连通性问题,并查集还依旧好用吗?我们看 LeetCode 上的另一道典型例题: LeetCode · 200.

LeetCode 例题

这个问题直接上 DFS 广度搜索自然是可以的:遍历二维数组,每找到 1 后使用 DFS 遍历将所有相连的 1 消除为 0,直到整块相连的岛屿都消除掉,记录岛屿数 +1。最后,输出岛屿数。

用并查集的来解的话,关键技巧就是建立长度为 M * N 的并查集:遍历二维数组,每找到 1 后,将它与右边和下边的 1 合并起来,最终输出并查集中连通分量的个数,就是岛屿树。

并查集解法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
kotlin复制代码class Solution {
fun numIslands(grid: Array<CharArray>): Int {

// 位置
fun position(row: Int, column: Int) = row * grid[0].size + column

// 并查集
val unionFind = UnionFind(grid)

// 偏移量数组(向右和向下)
val directions = arrayOf(intArrayOf(0, 1), intArrayOf(1, 0))

// 边界检查
fun checkBound(row: Int, column: Int): Boolean {
return (row in grid.indices) and (column in grid[0].indices)
}

for (row in grid.indices) {
for (column in grid[0].indices) {
if ('1' == grid[row][column]) {
// 消费(避免后续的遍历中重复搜索)
grid[row][column] = '0'
for (direction in directions) {
val newRow = row + direction[0]
val newColumn = column + direction[1]
if (checkBound(newRow, newColumn) && '1' == grid[newRow][newColumn]) {
unionFind.union(position(newRow, newColumn), position(row, column))
}
}
}
}
}
return unionFind.count
}

private class UnionFind(grid: Array<CharArray>) {

// 父节点
private val parent = IntArray(grid.size * grid[0].size) { it }

// 节点高度
private val rank = IntArray(grid.size * grid[0].size) { 1 }

// 连通分量(取格子 1 的总数)
var count = grid.let {
var countOf1 = 0
for (row in grid.indices) {
for (column in grid[0].indices) {
if ('1' == grid[row][column]) countOf1++
}
}
countOf1
}
private set

// 合并(按秩合并)
fun union(key1: Int, key2: Int) {
val root1 = find(key1)
val root2 = find(key2)
if (root1 == root2) {
// 未发生合并,则不需要减一
return
}
if (rank[root1] > rank[root2]) {
parent[root2] = root1
} else if (rank[root2] > rank[root1]) {
parent[root1] = root2
} else {
parent[root1] = root2
rank[root2]++
}
// 合并后,连通分量减一
count--
}

// 查询(使用路径压缩)
fun find(x: Int): Int {
var key = x
while (key != parent[key]) {
parent[key] = parent[parent[key]]
key = parent[key]
}
return key
}
}
}

  1. 总结

到这里,并查集的内容就讲完了。文章开头也提到了,并查集并不算面试中的高频题目,但是它的设计思想确实非常妙。不知道你有没有这种经历,在看到一种非常美妙的解题 / 设计思想后,会不自觉地拍案叫绝,直呼内行,并查集就是这种。

更多同类型题目:

并查集 题解
990. 等式方程的可满足性 【题解】
200. 岛屿数量 【题解】
547. 省份数量 【题解】
684. 冗余连接 【题解】
685. 冗余连接 II
1319. 连通网络的操作次数 【题解】
399. 除法求值
952. 按公因数计算最大组件大小
130. 被围绕的区域
128. 最长连续序列
721. 账户合并
765. 情侣牵手

版权声明

本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!

参考资料

  • 数据结构与算法分析 · Java 语言描述(第 8 章 · 不相互交集类)—— [美] Mark Allen Weiss 著
  • 算法导论(第 21 章 · 用于不相交集合的数据结构)—— [美] Thomas H. Cormen 等 著
  • 专题 · 并查集 —— LeetCode 出品
  • 题目 · 等式方程的可满足性 —— LeetCode 出品

推荐阅读

数据结构与算法系列完整目录如下(2023/07/11 更新):

  • #1 链表问题总结
  • #2 链表相交 & 成环问题总结
  • #3 计算器与逆波兰表达式总结
  • #4 高楼丢鸡蛋问题总结
  • #5 为什么你学不会递归?谈谈我的经验
  • #6 回溯算法解题框架总结
  • #7 下次面试遇到二分查找,别再写错了
  • #8 什么是二叉树?
  • #9 什么是二叉堆 & Top K 问题
  • #10 使用前缀和数组解决 “区间和查询” 问题
  • #11 面试遇到线段树,已经这么卷了吗?
  • #12 使用单调队列解决 “滑动窗口最大值” 问题
  • #13 使用单调栈解决 “下一个更大元素” 问题
  • #14 使用并查集解决 “朋友圈” 问题
  • #15 如何实现一个优秀的 HashTable 散列表
  • #16 简答一波 HashMap 常见面试题
  • #17 二叉树高频题型汇总
  • #18 下跳棋,极富想象力的同向双指针模拟

Java & Android 集合框架系列文章: 跳转阅读

LeetCode 上分之旅系列文章:跳转阅读

⭐️ 永远相信美好的事情即将发生,欢迎加入小彭的 Android 交流社群~

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

kotlin协程:挂起与恢复原理(逆向剖析)

发表于 2022-09-15

12222.webp

前言:只有在那崎岖的小路上不畏艰险奋勇攀登的人,才有希望达到光辉的顶点。 ——马克思

前言

经过前面两篇协程的学习,我相信大家对协程的使用已经非常熟悉了。本着知其然更要知其之所以然的心态,很想知道它里面是怎么可以让异步代码同步化的?协程它是如何实现线程的调度的?协程的挂起和恢复本质是什么?今天在这里一一为大家解答。

整个Kotlin 协程学习分为三部曲,本文是第三篇:

Kotlin 协程实战进阶(一、筑基篇)

Kotlin 协程实战进阶(二、进阶篇)

Kotlin 协程实战进阶(三、原理篇)

(本文需要前面两篇文章协程的知识点作为基础)

本文大纲

Kotlin协程原理.png

协程最核心的就是挂起与恢复,但是这两个名称在一定程度上面迷惑了我们,因为这两个名词并不能够让我们在源码上面和它的实现原理有清晰的认知。

协程的挂起本质上是方法的挂起,而方法的挂起本质上是 return,协程的恢复本质上方法的恢复,而恢复的本质是 callback 回调。

但是我们在Kotlin协程源码里面看不到 return 和 callback 回调的,其实这些都是kotlin编译器帮我们做了,单单看kotlin的源码是看不出所以然的,需要反编译成Java文件,才能看到本质之处。

通过AS的工具栏中 Tools->kotlin->show kotlin ByteCode,得到的是java字节码,需要再点击Decompile按钮反编译成java源码:

image.png

一、协程主要结构

1.suspend fun

再来复习一下挂起函数:

  • suspend 是 Kotlin 协程最核心的关键字;
  • 使用suspend关键字修饰的函数叫作挂起函数,挂起函数只能在协程体内或者在其他挂起函数内调用;
  • 被关键字 suspend 修饰的方法在编译阶段,编译器会修改方法的签名,包括返回值,修饰符,入参,方法体实现。
1
2
kotlin复制代码@GET("users/{login}")
suspend fun getUserSuspend(@Path("login") login: String): User

将上面的挂起函数反编译:

1
2
3
java复制代码@GET("users/{login}")
@Nullable
Object getUserSuspend(@Path("login") @NotNull String var1, @NotNull Continuation var2);
  1. 反编译后你会发现多了一个Continuation参数(它就是 callback),也就是说调用挂起函数的时候需要传递一个Continuation,只是传递这个参数是由编译器悄悄传,而不是我们传递的。这就是挂起函数为什么只能在协程或者其他挂起函数中执行,因为只有挂起函数或者协程中才有Continuation。
  2. 但是编译器怎么判断哪些方法需要 callback 呢?就是通过 suspend 关键字来区分的。suspend 修饰的方法会在编译期间被Kotlin编译器做特殊处理,编译器会认为一旦一个方法增加 suspend 关键字,有可能会导致协程暂停往下执行,所以此时会给方法传递要给 Continuation。等方法执行完成后,通过 Continuation 回调回去,从而让协程恢复,继续往下执行。
  3. 它还把返回值 User改成了 Object。

2.Continuation

Continuation 是 Kotlin 协程中非常重要的一个概念,它表示一个挂起点之后的延续操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
kotlin复制代码//Continuation接口表示挂起点之后的延续,该挂起点返回类型为“T”的值。
public interface Continuation<in T> {
//对应这个Continuation的协程上下文
public val context: CoroutineContext

//恢复相应协程的执行,传递一个成功或失败的结果作为最后一个挂起点的返回值。
public fun resumeWith(result: Result<T>)
}

//将[value]作为最后一个挂起点的返回值,恢复相应协程的执行。
fun <T> Continuation<T>.resume(value: T): Unit =
resumeWith(Result.success(value))

//恢复相应协程的执行,以便在最后一个挂起点之后重新抛出[异常]。
fun <T> Continuation<T>.resumeWithException(exception: Throwable): Unit =
resumeWith(Result.failure(exception))

Continuation 有一个 resumeWith 函数可以接收 Result 类型的参数。在结果成功获取时,调用resumeWith(Result.success(value))或者调用拓展函数resume(value);出现异常时,调用resumeWith(Result.failure(exception))或者调用拓展函数resumeWithException(exception),这就是 Continuation 的恢复调用。

Continuation类似于网络请求回调Callback,也是一个请求成功或失败的回调:

1
2
3
4
5
6
7
java复制代码public interface Callback {
//请求失败回调
void onFailure(Call call, IOException e);

//请求成功回调
void onResponse(Call call, Response response) throws IOException;
}

3.SuspendLambda

suspend{}其实就是协程的本体,它是协程真正执行的逻辑,会创建一个SuspendLambda类,它是Continuation的实现类。

QQ图片20210724173917.png

二、协程的创建流程

1.协程的创建

标准库给我们提供的创建协程最原始的api:

1
2
3
4
5
6
7
8
kotlin复制代码public fun <T> (suspend () -> T).createCoroutine(
completion: Continuation<T>
): Continuation<Unit>

public fun <R, T> (suspend R.() -> T).createCoroutine(
receiver: R,
completion: Continuation<T>
): Continuation<Unit>

协程创建后会有两个 Contiunation,需要分清楚:

  • completion:     表示协程本体,协程执行完需要一个 Continuation 实例在恢复时调用;
  • Contiunation<Unit>: 它是创建出来的协程的载体,(suspend () -> T) 函数会被传给该实例作为协程的实际执行体。

这两个Contiunation是不同的东西。传进来的 completion 实际上是协程的本体,协程执行完需要一个 Contiunation 回调执行的,所以它叫 completion;还有一个返回的 Contiunation ,它就是协程创建出来的载体,当它里面所有的resume都执行完成之后就会调用上面的 completion的resumeWith()方法恢复协程。

2.协程的作用域

在Androidx的Activity中模拟创建网络请求,通过这个例子来深挖协程的原理:

1
2
3
4
5
6
7
8
9
10
11
12
kotlin复制代码lifecycleScope.launch(Dispatchers.IO) {
val result = requestUserInfo()
tvName.text = result
}

/**
* 模拟请求,2秒后返回数据
*/
suspend fun requestUserInfo(): String {
delay(2000)
return "result form userInfo"
}

跟进lifecycleScope源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
kotlin复制代码val LifecycleOwner.lifecycleScope: LifecycleCoroutineScope
get() = lifecycle.coroutineScope

val Lifecycle.coroutineScope: LifecycleCoroutineScope
get() {
while (true) {
······
//关联声明周期的作用域实现类
val newScope = LifecycleCoroutineScopeImpl(
this,
SupervisorJob() + Dispatchers.Main.immediate
)
//注册生命周期
newScope.register()
return newScope
}
}

作用域也是其实就是为协程定义的作用范围,为了确保所有的协程都会被追踪,Kotlin 不允许在没有使用CoroutineScope的情况下启动新的协程。
lifecycleScope通过lifecycle,SupervisorJob(),Dispatchers.Main创建一个LifecycleCoroutineScopeImpl,它是一个关联宿主生命周期的作用域。CoroutineScope绑定到这个LifecycleOwner的Lifecycle 。当宿主被销毁时,这个作用域也被取消。

3.协程的启动

进入launch():

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
kotlin复制代码public fun CoroutineScope.launch(
context: CoroutineContext = EmptyCoroutineContext,
start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> Unit
): Job {
//创建新的上下文
val newContext = newCoroutineContext(context)
val coroutine = if (start.isLazy)
//延迟执行的协程
LazyStandaloneCoroutine(newContext, block) else
//独立的协程
StandaloneCoroutine(newContext, active = true)
//启动协程
coroutine.start(start, coroutine, block)
//返回coroutine,coroutine中实现了job接口
return coroutine
}

参数 block: suspend CoroutineSope.() -> Unit 表示协程代码,实际上就是闭包代码块。
这里面做了三件事:

  1. 根据context参数创建一个新的协程上下文CoroutineContext;
  2. 创建Coroutine,如果启动模式为Lazy则创建LazyStandaloneCoroutine,否则创建StandaloneCoroutine;
  3. coroutine.start()启动协程。

newCoroutineContext

1
2
3
4
5
6
7
kotlin复制代码public actual fun CoroutineScope.newCoroutineContext(context: CoroutineContext): CoroutineContext {
//将作用域的上下文与传入的参数合并为新的上下文
val combined = coroutineContext + context
val debug = if (DEBUG) combined + CoroutineId(COROUTINE_ID.incrementAndGet()) else combined
return if (combined !== Dispatchers.Default && combined[ContinuationInterceptor] == null)
debug + Dispatchers.Default else debug
}

为新协程创建上下文。通过 + 将作用域的上下文 coroutineContext 与传入的上下文 context 合并为新的上下文。它在没有指定其他调度器或 ContinuationInterceptor 时则默认使用 Dispatchers.Default。

StandaloneCoroutine

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
kotlin复制代码private open class StandaloneCoroutine(
parentContext: CoroutineContext,
active: Boolean
) : AbstractCoroutine<Unit>(parentContext, active) {
override fun handleJobException(exception: Throwable): Boolean {
//处理异常
handleCoroutineException(context, exception)
return true
}
}

public abstract class AbstractCoroutine<in T>(
protected val parentContext: CoroutineContext,
active: Boolean = true
) : JobSupport(active), Job, Continuation<T>, CoroutineScope {
······
//启动协程
public fun <R> start(start: CoroutineStart, receiver: R, block: suspend R.() -> T) {
initParentJob()
//根据[start]参数启动协程
start(block, receiver, this)
}
}

如果不指定启动模式,则默认使用 CoroutineStart.DEFAULT,创建一个独立协程 StandaloneCoroutine,而
StandaloneCoroutine 继承了 AbstractCoroutine 类,并重写了父类的 handleJobException() 方法。AbstractCoroutine 用于在协程构建器中实现协程的抽象基类,
实现了 Continuation 、 Job 和 CoroutineScope 等接口。所以 AbstractCoroutine 本身也是一个 Continuation。

Coroutine继承关系.png
coroutine.start()

1
kotlin复制代码start(block, receiver, this)

从上面的源码中,协程启动 coroutine.start() 方法是 AbstractCoroutine 类中实现的,这里涉及到运算符重载,而后该方法实际上会调用 CoroutineStart#invoke() 方法 ,并把代码块和接收者、completion等参数传到 CoroutineStart 中。

1
2
3
4
5
6
7
8
9
10
kotlin复制代码public enum class CoroutineStart {
//···
fun <R, T> invoke(block: suspend R.() -> T, receiver: R, completion: Continuation<T>): Unit =
when (this) {
DEFAULT -> block.startCoroutineCancellable(receiver, completion)
ATOMIC -> block.startCoroutine(receiver, completion)
UNDISPATCHED -> block.startCoroutineUndispatched(receiver, completion)
LAZY -> Unit
}
}

使用此协程策略将相应的块 [block] 作为协程启动。这里的 [block] 就是协程里面执行的代码块。

  • block:    协程里面执行的代码块;
  • receiver:   接收者;
  • completion:  协程的本体,协程执行完需要一个 Continuation 实例在恢复时调用。

在上面 AbstractCoroutine 我们看到 completion 传递的是 this ,也就是 AbstractCoroutine 自己,也就是 Coroutine 协程本身。所以这个 completion 就是协程本体。(这是Continuation三层包装的第一层包装)

接着进入 startCoroutineCancellable(),可以以可取消的方式启动协程,以便在等待调度时取消协程:

1
2
3
4
5
6
7
8
9
kotlin复制代码internal fun <R, T> (suspend (R) -> T).startCoroutineCancellable(receiver: R, completion: Continuation<T>) =
runSafely(completion) {
// 1.创建一个没有被拦截的 Continuation
createCoroutineUnintercepted(receiver, completion)
// 2.添加拦截器
.intercepted()
// 3.执行协程,也是调用continuation.resumeWith(result)
.resumeCancellableWith(Result.success(Unit))
}

这里主要做了三件事:

  1. 创建一个新的 Continuation。
  2. 给 Continuation 加上 ContinuationInterceptor 拦截器,也是线程调度的关键。
  3. resumeCancellableWith最终调用 continuation.resumeWith(result) 执行协程。

4.创建Continuation<Unit>

createCoroutineUnintercepted() 每次调用此函数时,都会创建一个新的可暂停计算实例。
通过返回的 Continuation<Unit> 实例上调用 resumeWith(Unit) 开始执行创建的协程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
kotlin复制代码#IntrinsicsJvm.kt

public actual fun <R, T> (suspend R.() -> T).createCoroutineUnintercepted(
receiver: R,
completion: Continuation<T>
): Continuation<Unit> {//返回Continuation<Unit>,它就是协程的载体
val probeCompletion = probeCoroutineCreated(completion)
return if (this is BaseContinuationImpl)
//如果调用者是 `BaseContinuationImpl` 或者其子类
create(receiver, probeCompletion)
else {
createCoroutineFromSuspendFunction(probeCompletion) {
(this as Function2<R, Continuation<T>, Any?>).invoke(receiver, it)
}
}
}

重点:创建并返回一个未拦截的Continuation,它就是协程的载体。(这是Continuation三层包装的第二层包装)(suspend () -> T) 函数会被传给该实例作为协程的实际执行体。这个 Continuation 封装了协程的代码运行逻辑和恢复接口,下面会讲到。

因为this就是(suspend () -> T),SuspendLambda 又是 BaseContinuationImpl 的实现类,则执行 create() 方法创建协程载体:

1
2
3
4
5
6
7
8
9
10
11
kotlin复制代码abstract class BaseContinuationImpl {
//···
public open fun create(completion: Continuation<*>): Continuation<Unit> {
throw UnsupportedOperationException("create(Continuation) has not been overridden")
}

public open fun create(value: Any?, completion: Continuation<*>): Continuation<Unit> {
throw UnsupportedOperationException("create(Any?;Continuation) has not been overridden")
}
//···
}

create() 是 BaseContinuationImpl 类中的一个公开方法。那么是谁实现了这个方法呢? 看看 SuspendLambda 与 BaseContinuationImpl 与 Continuation 之间的关系讲解。

5.SuspendLambda及其父类

上面提到 suspend{} 就是 (suspend R.() -> T) ,它是协程真正需要执行的逻辑,传入的lambda表达式被编译成了继承 SuspendLambda 的子类,SuspendLambda 是 Continuation 的实现类。

1
2
3
4
5
6
7
8
9
10
11
12
kotlin复制代码internal abstract class SuspendLambda(
public override val arity: Int,
completion: Continuation<Any?>?
) : ContinuationImpl(completion), FunctionBase<Any?>, SuspendFunction {
constructor(arity: Int) : this(arity, null)

public override fun toString(): String =
if (completion == null)
Reflection.renderLambdaToString(this) //这是 lambda
else
super.toString() //这是 continuation
}

而SuspendLambda 继承自 ContinuationImpl:

1
2
3
4
5
6
7
8
9
10
11
12
13
kotlin复制代码internal abstract class ContinuationImpl(
completion: Continuation<Any?>?,
) : BaseContinuationImpl(completion) {

private var intercepted: Continuation<Any?>? = null

public fun intercepted(): Continuation<Any?> =
intercepted
?: (context[ContinuationInterceptor]?.interceptContinuation(this) ?: this)
.also { intercepted = it }

protected override fun releaseIntercepted() { ··· }
}

ContinuationImpl又继承自BaseContinuationImpl,SuspendLambda 的 resume() 方法的具体实现为 BaseContinuationImpl 的 resumeWith() 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
kotlin复制代码internal abstract class BaseContinuationImpl(
//每个BaseContinuationImpl实例都会引用一个完成Continuation,用来在当前状态机流转结束时恢复这个Continuation
public val completion: Continuation<Any?>?
) : Continuation<Any?>, CoroutineStackFrame, Serializable {

//resumeWith() 中通过循环由里到外恢复Continuation
public final override fun resumeWith(result: Result<Any?>) {

var current = this
var param = result
while (true) {
probeCoroutineResumed(current)
with(current) {
val completion = completion!! //completion为null则会抛出异常
val outcome: Result<Any?> =
try {
// 1.调用 invokeSuspend 方法执行,执行协程的真正运算逻辑
val outcome = invokeSuspend(param)
// 2.如果已经挂起则提前结束
if (outcome === COROUTINE_SUSPENDED) return
Result.success(outcome)
} catch (exception: Throwable) {
Result.failure(exception)
}
//当invokeSuspend方法没有返回COROUTINE_SUSPENDED,那么当前状态机流转结束,即当前suspend方法执行完毕,释放拦截
releaseIntercepted()
if (completion is BaseContinuationImpl) {
//3.如果 completion 是 BaseContinuationImpl,内部还有suspend方法,则会进入循环递归
current = completion
param = outcome
} else {
//4.否则是最顶层的completion,则会调用resumeWith恢复上一层并且return
// 这里实际调用的是其父类 AbstractCoroutine 的 resumeWith 方法
completion.resumeWith(outcome)
return
}
}
}
}
//·····
}

这里主要做了四件事:

  1. 调用 invokeSuspend 方法,执行协程的真正运算逻辑,并返回一个结果;
  2. 如果 outcome 是 COROUTINE_SUSPENDED ,代码块里面执行了挂起方法,则继续挂起;
  3. 如果 completion 是 BaseContinuationImpl,内部还有suspend方法,则会进入循环递归,继续执行挂起;
  4. 如果 completion 不是 BaseContinuationImpl,则实际调用父类 AbstractCoroutine 的 resumeWith 方法。

接下来再来看 AbstractCoroutine 的 resumeWith 实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
kotlin复制代码public abstract class AbstractCoroutine<in T>(
protected val parentContext: CoroutineContext,
active: Boolean = true
) : JobSupport(active), Job, Continuation<T>, CoroutineScope {

//以指定的结果完成执行协程
public final override fun resumeWith(result: Result<T>) {
// 1. 获取当前协程的技术状态
val state = makeCompletingOnce(result.toState())
// 2. 如果当前还在等待完成,说明还有子协程没有结束
if (state === COMPLETING_WAITING_CHILDREN) return
// 3. 执行结束恢复的方法,默认为空
afterResume(state)
}
//···
}

其中一类 completion 是 BaseContinuationImpl,每个实例就代表一个suspend方法状态机。resumeWith()封装了协程的运算逻辑,用以协程的启动和恢复;而另一类 completion 是 AbstractCoroutine,主要是负责维护协程的状态和管理,它的resumeWith则是完成协程,恢复调用者协程。

其继承关系为: SuspendLambda -> ContinuationImpl -> BaseContinuationImpl -> Continuation。

suspendLambda继承关系.png
因此 create() 方法创建的 Continuation 是一个 SuspendLambda 对象。
回到上面的 createCoroutineUnintercepted() 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
kotlin复制代码//IntrinsicsJvm.kt
public actual fun <R, T> (suspend R.() -> T).createCoroutineUnintercepted(
receiver: R,
completion: Continuation<T>
): Continuation<Unit> {//返回Continuation<Unit>,它就是协程的载体
val probeCompletion = probeCoroutineCreated(completion)
return if (this is BaseContinuationImpl)
//如果调用者是 `BaseContinuationImpl` 或者其子类
create(receiver, probeCompletion)
else {
createCoroutineFromSuspendFunction(probeCompletion) {
(this as Function2<R, Continuation<T>, Any?>).invoke(receiver, it)
}
}
}

其实这段代码是在JVM平台中找到的,在 IntrinsicsJvm.kt 类中,但是在 Android 源码中是这样子的:

1
2
3
4
kotlin复制代码fun <R, T> (suspend R.() -> T).createCoroutineUnintercepted(
receiver: R,
completion: Continuation<T>
): Continuation<kotlin.Unit> { /* compiled code */ }

compiled code 就已经提示里面的代码是编译后的代码。

前面也提到了,在协程源码里面是看不到完整的协程原理的,有一部分代码是kotlin编译器处理的,所以在研究协程的运行流程时,单单看kotlin的源码是看不出本质的,需要反编译成Java文件,看看反编译之后的代码被修改成什么样子了。

6.Function 的创建

将协程模拟网络请求的代码反编译:

1
2
3
4
5
6
kotlin复制代码fun getData() {
lifecycleScope.launch {
val result = requestUserInfo()
tvName.text = result
}
}

反编译后的代码如下(代码有删减),你会发现发生了巨大的变化,而这些工作都是kotlin编译器帮我们完成的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
java复制代码public final void getData() {
BuildersKt.launch$default((CoroutineScope)LifecycleOwnerKt.getLifecycleScope(this), (CoroutineContext)null, (CoroutineStart)null, (Function2)(new Function2((Continuation)null) {
int label; // 初始值为0

@Nullable
public final Object invokeSuspend(@NotNull Object $result) {
//···
Object var10000 = requestUserInfo(this); //执行挂起函数
//···
String result = (String)var10000;
CoroutinesActivity.this.getTvName().setText((CharSequence)result);
return Unit.INSTANCE;
}

@NotNull
public final Continuation create(@Nullable Object value, @NotNull Continuation completion) {
Intrinsics.checkParameterIsNotNull(completion, "completion");
Function2 var3 = new <anonymous constructor>(completion);
return var3;
}

public final Object invoke(Object var1, Object var2) {
return ((<undefinedtype>)this.create(var1, (Continuation)var2)).invokeSuspend(Unit.INSTANCE);
}
}), 3, (Object)null);
}

lifecycleScope.launch {} 在反编译后增加了 CoroutineScope,CoroutineContext,CoroutineStart,Function2,3,Object等参数,这些都是kotlin编译器帮我们做了。这里就是最顶层的completion处理协程挂起与恢复的地方。 这里一旦恢复了,那么说明整个协程恢复了。

这里创建了一个Function2,里面有三个重要的方法:

  • invokeSuspend(result):  里面执行luanch{}里面的代码,所以它执行了协程真正的运行逻辑;
  • create(value, completion):通过传递的completion参数创建一个 Function2 并返回,实际是一个Continuation;
  • invoke(var1, var2):    复写了Funtion.invoke() 方法,通过传递过来的参数链式调用create().invokeSuspend()。
  1. 从上面知道 (suspend R.() -> T) 是 BaseContinuationImpl 的实现类,所以会走 onCreate() 方法创建 Continuation, 通过 completion 参数创建一个新的 Function2,作为Continuation返回,这就是创建出来的协程载体;
  2. 然后调用 resumeWith() 启动协程,那么就会执行BaseContinuationImpl 的 resumeWith() 方法,此时就会执行 invokeSuspend() 方法,执行协程真正的运行逻辑。

协程的创建流程如下:

协程的创建流程图.png

三、 协程的挂起与恢复

协程工作的核心就是它内部的状态机,invokeSuspend() 函数。 requestUserInfo() 方法是一个挂起函数,这里通过反编译它来阐述协程状态机的原理,逆向剖析协程的挂起和恢复。

1.方法的挂起

1
2
3
4
5
kotlin复制代码//延时2000毫秒,返回一个String结果
suspend fun requestUserInfo(): String {
delay(2000)
return "result form userInfo"
}

反编译后的代码如下(代码有删减),同样发现发生了巨大的变化,而这些工作都是kotlin编译器帮我们完成的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
java复制代码//1.函数返回值由String变成Object,入参也增加了Continuation参数
public final Object requestUserInfo(@NotNull Continuation completion) {
//2.通过completion创建一个ContinuationImpl,并且复写了invokeSuspend()
Object continuation = new ContinuationImpl(completion) {
Object result;
int label; //初始值为0

@Nullable
public final Object invokeSuspend(@NotNull Object $result) {
this.result = $result;
this.label |= Integer.MIN_VALUE;
return requestUserInfo(this);//又调用了requestUserInfo()方法
}
};

Object $result = (continuation).result;
Object var4 = IntrinsicsKt.getCOROUTINE_SUSPENDED();
//状态机
//3.方法被恢复的时候又会走到这里,第一次进入case 0分支,label的值从0变为1,第二次进入就会走case 1分支
switch(continuation.label) {
case 0:
ResultKt.throwOnFailure($result);
continuation.label = 1;
//4.delay()方法被suspend修饰,传入一个continuation回调,返回一个object结果。这个结果要么是`COROUTINE_SUSPENDED`,否则就是真实结果。
Object delay = DelayKt.delay(2000L, continuation)
if (delay == var4) {//如果是COROUTINE_SUSPENDED则直接return,就不会往下执行了,requestUserInfo()被暂停了。
return var4;
}
break;
case 1:
ResultKt.throwOnFailure($result);
break;
default:
throw new IllegalStateException("call to 'resume' before 'invoke' with coroutine");
}
return "result form userInfo";
}

上面主要步骤为:

  1. 函数返回值由 String 变成 Object,函数没有入参的编译后也增加了 Continuation 参数。原本需要我们做的 callback,现在编译器帮我们完成了。
  2. 根据 completion 创建了一个 ContinuationImpl ,复写了 invokeSuspend() 方法,在这个方法里面它又调用了 requestUserInfo() 方法,这里又调用了一次自己(是不是很神奇),并且把 continuation 传递进去。
  3. 在 switch 语句中,label 的默认初始值为 0,第一次会进入 case 0 分支,delay() 是一个挂起函数,传入上面的 continuation 参数,会有一个 Object 类型的返回值。这个结果要么是COROUTINE_SUSPENDED,否则就是真实结果。
  4. DelayKt.delay(2000, continuation)的返回结果如果是 COROUTINE_SUSPENDED, 则直接 return ,那么方法执行就被结束了,方法就被挂起了。

这就是挂起的真正原理。所以函数即便被 suspend 修饰了,但是也未必会挂起。需要里面的代码编译后有返回值为 COROUTINE_SUSPENDED 这样的标记位才可以,所以程序执行到 case 0 的时候就 return 了。那就意味着方法被暂停了,那么协程也被暂停了。所以说协成的挂起实际上是方法的挂起,方法的挂起本质是 return。

2.COROUTINE_SUSPENDED

1
2
3
4
5
kotlin复制代码Object var4 = IntrinsicsKt.getCOROUTINE_SUSPENDED();
//执行已暂停,并且不会立即返回任何结果
public val COROUTINE_SUSPENDED: Any get() = CoroutineSingletons.COROUTINE_SUSPENDED

internal enum class CoroutineSingletons { COROUTINE_SUSPENDED, UNDECIDED, RESUMED }

在 var4 = IntrinsicsKt.getCOROUTINE_SUSPENDED()中, COROUTINE_SUSPENDED 就是一个枚举常量,表示协程已经挂起,并且不会立即返回任何结果。那么 DelayKt.delay() 返回值是 COROUTINE_SUSPENDED 就被 return 了。

跟进 DelayKT 看看 COROUTINE_SUSPENDED 是如何被获取的:
image.png

找到 DelayKT 类(注意:不是Delay.kt哈,别搞错了),Decomplie to java反编译成java源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
java复制代码public final class DelayKt {
//增加了Object返回值,并且追加了一个Continuation参数,
@Nullable
public static final Object delay(long timeMillis, @NotNull Continuation $completion) {
if (timeMillis <= 0L) {
return Unit.INSTANCE;
} else {
//···
getDelay(cont.getContext()).scheduleResumeAfterDelay(timeMillis, cont);
Object var10000 = cancellable$iv.getResult();
//···
return var10000;
}
}
}

可以看到 DelayKt.delay() 增加了 Object 返回值,并且追加了一个 completion 参数,这个返回值是 var10000 ,它是在 cancellable$iv.getResult() 得到的:

1
2
3
4
5
6
7
8
kotlin复制代码@PublishedApi
internal fun getResult(): Any? {
setupCancellation()
//尝试挂起,如果返回TRUE则返回COROUTINE_SUSPENDED
if (trySuspend()) return COROUTINE_SUSPENDED
//···
return getSuccessfulResult(state)
}

trySuspend() 尝试把方法挂起,如果返回 true 则返回 COROUTINE_SUSPENDED:

1
2
3
4
5
6
7
8
9
10
11
12
13
kotlin复制代码private val _decision = atomic(UNDECIDED)

private fun trySuspend(): Boolean {
//循环遍历里面的值,而_decision初始值为UNDECIDED,那么第一次肯定返回true
_decision.loop { decision ->
when (decision) {
//返回true,并且把当前状态更改为SUSPENDED,代表它以经被挂起了
UNDECIDED -> if (this._decision.compareAndSet(UNDECIDED, SUSPENDED)) return true
RESUMED -> return false
else -> error("Already suspended")
}
}
}

trySuspend() 里面循环遍历了 _decision 的值, _decision 初始值为 UNDECIDED,那么第一次会进入UNDECIDED -> if (this._decision.compareAndSet(UNDECIDED, SUSPENDED)) return true分支,这里就返回true了,并且把当前状态更改为 SUSPENDED,代表着它已经被挂起了。

1
2
3
4
5
6
7
8
9
10
11
kotlin复制代码//方法的状态只有在调用tryResume时才会把状态更改为RESUMED
private fun tryResume(): Boolean {
_decision.loop { decision ->
when (decision) {
//返回true,并且把状态改为RESUMED
UNDECIDED -> if (this._decision.compareAndSet(UNDECIDED, RESUMED)) return true
SUSPENDED -> return false
else -> error("Already resumed")
}
}
}

这个方法的状态会在它恢复的时候调用 tryResume() 把状态更改为 RESUMED。这就是决策状态机:

image.png

那么 trySuspend() 返回 true 则 getResult() 返回了 COROUTINE_SUSPENDED 枚举常量,那么 DelayKt.delay() 就返回了 COROUTINE_SUSPENDED,所以下面的判断条件就会满足,会直接return。delay() 方法是一个真真正正的挂起函数,能够导致协程被暂停。

所以 requestUserInfo() 方法在 delay(2000) 被暂停了,在协程中调用,那么协程也就暂停了,后面的结果 result form userInfo 也没有被返回。所以这就是被 suspend 修饰的函数不一定能导致协程被挂起,还需要里面的实现经过编译之后有返回值并且为 COROUTINE_SUSPENDED 才可以。

3.方法的恢复

继续回到 requestUserInfo() 分析恢复的原理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
java复制代码@Nullable
public final Object requestUserInfo(@NotNull Continuation completion) {
//···
Object continuation = new ContinuationImpl(completion) {
Object result;
int label; //初始值为0

@Nullable
public final Object invokeSuspend(@NotNull Object $result) {
this.result = $result;
this.label |= Integer.MIN_VALUE;
return requestUserInfo(this);//又调用了requestUserInfo()方法
}
};

Object $result = (continuation).result;
Object var4 = IntrinsicsKt.getCOROUTINE_SUSPENDED();
//方法被恢复的时候又会走到这里,第一次进入case 0,第二次 ontinuation.label = 1,所以能继续执行下去返回结果
switch(continuation.label) {
case 0:
ResultKt.throwOnFailure($result);
continuation.label = 1;
//delay()方法被suspend修饰,传入一个continuation回调,返回一个object结果。这个结果要么是`COROUTINE_SUSPENDED`,否则就是真实结果。
Object delay = DelayKt.delay(2000L, continuation)
if (delay == var4) {//如果是COROUTINE_SUSPENDED则直接return,就不会往下执行了。
return var4;
}
break;
case 1:
ResultKt.throwOnFailure($result);
break;
default:
throw new IllegalStateException("call to 'resume' before 'invoke' with coroutine");
}
return "result form userInfo";
}
  1. 因为 delay() 是 io 操作,在2000毫米后就会通过传递给它的 continuation 回调回来。
  2. 回调到 ContinuationImpl 这个类里面的 resumeWith() 方法,会再次调用 invokeSuspend() 方法,进而再次调用 requestUserInfo() 方法。
  3. 它又会进入switch语句,由于第一次在 case 0 时把 label = 1 赋值为1,所以这次会进入 case 1 分支,并且返回了结果result form userInfo。
  4. 并且 requestUserInfo() 的返回值作为 invokeSuspend() 的返回值返回。重新被执行的时候就代表着方法被恢复了。

那么 invokeSuspend() 方法是怎么被触发回调的呢?它拿到返回值有什么用呢?

上面提到 ContinuationImpl 继承自 BaseContinuationImpl,而它又实现了 continuation 接口并且复写了 resumeWith() 方法,里面就调用了 val outcome = invokeSuspend(param) 方法。(源码有删减)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
kotlin复制代码internal abstract class BaseContinuationImpl(
public val completion: Continuation<Any?>?
) : Continuation<Any?>, CoroutineStackFrame, Serializable {
//这个实现是最终的,用于展开 resumeWith 递归。
public final override fun resumeWith(result: Result<Any?>) {
var current = this
var param = result
while (true) {
with(current) {
val completion = completion!!
val outcome: Result<Any?> =
try {
// 1.调用 invokeSuspend()方法执行,执行协程的真正运算逻辑,拿到返回值
val outcome = invokeSuspend(param)
// 2.如果返回的还是COROUTINE_SUSPENDED则提前结束
if (outcome == COROUTINE_SUSPENDED) return
Result.success(outcome)
} catch (exception: Throwable) {
Result.failure(exception)
}
if (completion is BaseContinuationImpl) {
//3.如果 completion 是 BaseContinuationImpl,内部还有suspend方法,则会进入循环递归,继续执行和恢复
current = completion
param = outcome
} else {
//4.否则是最顶层的completion,则会调用resumeWith恢复上一层并且return
// 这里实际调用的是其父类 AbstractCoroutine 的 resumeWith 方法
completion.resumeWith(outcome)
return
}
}
}
}

实际上任何一个挂起函数它在恢复的时候都会调到 BaseContinuationImpl 的 resumeWith() 方法里面。

  1. 一但 invokeSuspend() 方法被执行,那么 requestUserInfo() 又会再次被调用, invokeSuspend() 就会拿到 requestUserInfo() 的返回值,在 ContinuationImpl 里面根据 val outcome = invokeSuspend() 的返回值来判断我们的 requestUserInfo() 方法恢复了之后的操作。
  2. 如果 outcome 是 COROUTINE_SUSPENDED 常量,说明你即使被恢复了,执行了一下, if (outcome == COROUTINE_SUSPENDED) return 但是立马又被挂起了,所以又 return 了。
  3. 如果本次恢复 outcome 是一个正常的结果,就会走到 completion.resumeWith(outcome),当前被挂起的方法已经被执行完了,实际调用的是其父类 AbstractCoroutine 的 resumeWith 方法,那么协程就恢复了。

我们知道 requestUserInfo() 肯定是会被协程调用的(从上面反编译代码知道会传递一个Continuation completion参数),requestUserInfo() 方法恢复完了就会让协程completion.resumeWith()去恢复,所以说协程的恢复本质上是方法的恢复。

这是在android studio当中通过反编译kotlin源码来分析协程挂起与恢复的流程。流程图如下:

协程挂起与恢复.png

4.在协程中运行的挂起与恢复

那么 requestUserInfo() 方法在协程里面执行的整个挂起和恢复流程是怎么样的呢?

1
2
3
4
5
6
7
8
9
10
11
12
kotlin复制代码fun getData() {
lifecycleScope.launch {
val result = requestUserInfo()
tvName.text = result
}
}

//模拟请求,2秒后返回数据
suspend fun requestUserInfo(): String {
delay(2000)
return "result form userInfo"
}

反编译代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
java复制代码public final void getData() {
BuildersKt.launch$default((CoroutineScope)LifecycleOwnerKt.getLifecycleScope(this), (CoroutineContext)null, (CoroutineStart)null, (Function2)(new Function2((Continuation)null) {
int label; // 初始值为0

@Nullable
public final Object invokeSuspend(@NotNull Object $result) {
Object var3 = IntrinsicsKt.getCOROUTINE_SUSPENDED(); //挂起状态
Object var10000;
//状态机
switch(this.label) {
case 0:
ResultKt.throwOnFailure($result);
this.label = 1; //修改label
var10000 = requestUserInfo(this); //执行挂起函数
if (var10000 == var3) {
return var3; //如果var10000是COROUTINE_SUSPENDED则直接挂起协程
}
break;
case 1:
ResultKt.throwOnFailure($result);
var10000 = $result; //返回实际结果
break;
//···
}

String result = (String)var10000;
CoroutinesActivity.this.getTvName().setText((CharSequence)result);
return Unit.INSTANCE;
}

@NotNull
public final Continuation create(@Nullable Object value, @NotNull Continuation completion) {
Intrinsics.checkParameterIsNotNull(completion, "completion");
Function2 var3 = new <anonymous constructor>(completion);
return var3;
}

public final Object invoke(Object var1, Object var2) {
return ((<undefinedtype>)this.create(var1, (Continuation)var2)).invokeSuspend(Unit.INSTANCE);
}
}), 3, (Object)null);
}

@Nullable
public final Object requestUserInfo(@NotNull Continuation completion) {
//···
Object continuation = new ContinuationImpl(completion) {
Object result;
int label; //初始值为0

@Nullable
public final Object invokeSuspend(@NotNull Object $result) {
this.result = $result;
this.label |= Integer.MIN_VALUE;
return requestUserInfo(this); //又调用了requestUserInfo()方法
}
};

Object $result = (continuation).result;
Object var4 = IntrinsicsKt.getCOROUTINE_SUSPENDED();
//状态机
//方法被恢复的时候又会走到这里,第一次进入case 0,第二次 ontinuation.label = 1,所以就打印了日志输出
switch(label) {
case 0:
ResultKt.throwOnFailure($result);
continuation.label = 1;
//delay()方法被suspend修饰,传入一个continuation回调,返回一个object结果。这个结果要么是`COROUTINE_SUSPENDED`,否则就是真实结果。
Object delay = DelayKt.delay(2000L, continuation)
if (delay == var4) {//如果是COROUTINE_SUSPENDED则直接return,就不会往下执行了,requestUserInfo()被暂停了。
return var4;
}
break;
case 1:
ResultKt.throwOnFailure($result);
break;
default:
throw new IllegalStateException("call to 'resume' before 'invoke' with coroutine");
}
return "result form userInfo";
}
  1. 可以看到协程里面反编译后的代码和 requestUserInfo() 方法反编译后的代码类似,Function2 里面也复写了invokeSuspend() 方法,状态机也类似,
  2. 在 case 0 处判断 requestUserInfo() 返回值是否为COROUTINE_SUSPENDED, 如果是则挂起协程。我们在上面分析知道, requestUserInfo() 第一次返回的值是COROUTINE_SUSPENDED,所以 requestUserInfo() 被挂起了,协程也被挂起了。所以说协程的挂起实际上是方法的挂起。
  3. 协程恢复的原理也和 requestUserInfo() 恢复的原理大致相同。在调用 requestUserInfo(this) 的时候把 Continuation 传递了进去。
  4. 那么 requestUserInfo() 函数2000毫秒后在恢复时将结果通过 invokeSuspend() 回调给上一层 completion 的 resumeWith() 里面,那么协程的 invokeSuspend(result) 就是被回调。
  5. 通过状态机流转执行之前挂起逻辑之后的代码。此时 lable = 1 进入 case 1 赋值给 var10000,然后执行剩下的代码。 所以 requestUserInfo() 方法恢复后,调用它的协程也跟着恢复了,所以说协程的恢复本质上是方法的恢复。

协程的挂起与恢复流程简图.png

四、协程的调度

1.协程拦截

协程的线程调度是通过拦截器实现的,回到前面的 startCoroutineCancellable:

1
2
3
4
5
6
7
8
9
10
11
kotlin复制代码internal fun <R, T> (suspend (R) -> T).startCoroutineCancellable(
receiver: R,
completion: Continuation<T>) =
runSafely(completion) {
// 创建一个没有被拦截的 Coroutine
createCoroutineUnintercepted(receiver, completion)
// 添加拦截器
.intercepted()
// 执行协程
.resumeCancellableWith(Result.success(Unit))
}

看看 intercepted() 的具体实现:

1
2
3
kotlin复制代码//使用[ContinuationInterceptor]拦截Continuation
public actual fun <T> Continuation<T>.intercepted(): Continuation<T> =
(this as? ContinuationImpl)?.intercepted() ?: this

拦截器在每次(恢复)执行协程体的时候都会拦截协程本体SuspendLambda。interceptContinuation()方法中拦截了一个Continuation<T>并且再返回一个Continuation<T>,拦截到Continuation后就可以做一些事情,比如线程切换等。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
kotlin复制代码internal abstract class ContinuationImpl(
completion: Continuation<Any?>?,
private val _context: CoroutineContext?
) : BaseContinuationImpl(completion) {

@Transient
private var intercepted: Continuation<Any?>? = null // 拦截到的Continuation

public fun intercepted(): Continuation<Any?> =
intercepted
?: (context[ContinuationInterceptor]?.interceptContinuation(this) ?: this)
.also { intercepted = it }
//······
}

而 interceptContinuation() 方法的实现是在 CoroutineDispatcher 中,它是所有协程调度程序实现扩展的基类:

1
2
3
4
5
6
7
8
9
10
11
12
kotlin复制代码public abstract class CoroutineDispatcher :
AbstractCoroutineContextElement(ContinuationInterceptor), ContinuationInterceptor {
//是否需要调度
public open fun isDispatchNeeded(context: CoroutineContext): Boolean = true

//将可运行块的执行分派到给定上下文中的另一个线程上
public abstract fun dispatch(context: CoroutineContext, block: Runnable)

//通过DispatchedContinuation返回包装原始continuation的 continuation
public final override fun <T> interceptContinuation(continuation: Continuation<T>): Continuation<T> =
DispatchedContinuation(this, continuation)
}

注意:[block] 是一个 Runnable 类型。

如果传递了协程调度器,那么协程中的闭包代码块就决定了所运行的线程环境,CoroutineDispatcher 有三个重要的方法:

  • isDispatchNeeded():协程的启动需不需要分发到别的线程上面去。
  • dispatch():      将可运行块的执行分派到给定上下文中的另一个线程上,由子类去实现具体的调度。
  • interceptContinuation:拦截协程本体,包装成一个 DispatchedContinuation。

拦截协程本体,包装成一个 DispatchedContinuation,它在执行任务的时候会通过 needDispatch() 来判断本次协程启动需不需要分发到别的线程上面,如果返回了true,那么就会调用子类的 dispatch(runnable) 方法,来完成协程的本次启动工作,如果返回false,就会由 CoroutineDispatcher 在当前线程立刻执行。

2.协程分发

在截获的 Continuation 上调用resume(Unit) 保证协程和完成的执行都发生在由 ContinuationInterceptor 建立的调用上下文中。而拦截后的 continuation 被 DispatchedContinuation包装了一层:(这是Continuation三层包装的第三层包装)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
kotlin复制代码internal class DispatchedContinuation<in T>(
@JvmField val dispatcher: CoroutineDispatcher,
@JvmField val continuation: Continuation<T>
) : DispatchedTask<T>(MODE_UNINITIALIZED), CoroutineStackFrame, Continuation<T> by continuation {
//·····
override val delegate: Continuation<T>
get() = this

inline fun resumeCancellable(value: T) {
// 是否需要线程调度
if (dispatcher.isDispatchNeeded(context)) {
// 将协程的运算分发到另一个线程
dispatcher.dispatch(context, this)
} else {
// 如果不需要调度,立即恢复在在当前线程执行协程运算
withCoroutineContext(this.context, countOrElement) {
continuation.resumeWith(result)
}
}
}

override fun resumeWith(result: Result<T>) {
// 是否需要线程调度
if (dispatcher.isDispatchNeeded(context)) {
// 将协程的运算分发到另一个线程
dispatcher.dispatch(context, this)
} else {
// 如果不需要调度,立即恢复在当前线程执行协程运算
continuation.resumeWith(result)
}
}
//·····
}

DispatchedContinuation拦截了协程的启动和恢复,分别是resumeCancellable(Unit)和重写的resumeWith(Result)。

当需要分发时,就调用 dispatcher 的 dispatch(context, this) 方法,this是一个 DispatchedTask:

1
2
3
4
5
6
7
8
9
10
11
12
13
kotlin复制代码internal abstract class DispatchedTask<in T>(
@JvmField public var resumeMode: Int
) : SchedulerTask() {

public final override fun run() {
//·····
withContinuationContext(continuation, delegate.countOrElement) {
//恢复协程执行 最终调用resumeWith
continuation.resume(getSuccessfulResult(state))
}
//·····
}
}

DispatchedTask 实际上是一个Runnable。

  1. 当需要线程调度时,则在调度后会调用 DispatchedContinuation.continuation.resumeWith() 来启动协程,其中 continuation 是 SuspendLambda 实例;
  2. 当不需要线程调度时,则直接调用 continuation.resumeWith() 来直接启动协程。

也就是说对创建的 Continuation 的 resumeWith() 增加拦截操作,拦截协程的运行操作:

协程的分发机制.png
分别分析一下四种调度模式的具体实现:

3.Dispatchers.Unconfined

1
2
3
4
5
6
7
8
9
10
kotlin复制代码public actual val Unconfined: CoroutineDispatcher = kotlinx.coroutines.Unconfined

internal object Unconfined : CoroutineDispatcher() {
//返回false, 不拦截协程
override fun isDispatchNeeded(context: CoroutineContext): Boolean = false

override fun dispatch(context: CoroutineContext, block: Runnable) {
//····
}
}

Dispatchers.Unconfined:对应的是Unconfined,它里面的isDispatchNeeded()返回的是false,不限于任何特定线程的协程调度程序。那么它的父类ContinuationInterceptor就不会把本次任务的调度交给子类来执行,而是由父类在当前线程立刻执行。

4.Dispatchers.Main

Dispatchers.Main 继承自 MainCoroutineDispatcher 通过 MainDispatcherLoader.dispatcher 实现调度器:

1
2
3
4
kotlin复制代码public actual object Dispatchers {
@JvmStatic
public actual val Main: MainCoroutineDispatcher get() = MainDispatcherLoader.dispatcher
}

MainDispatcherLoader 通过工厂模式创建 MainCoroutineDispatcher:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
kotlin复制代码internal object MainDispatcherLoader {
@JvmField
val dispatcher: MainCoroutineDispatcher = loadMainDispatcher()

private fun loadMainDispatcher(): MainCoroutineDispatcher {
//···
val factories = FastServiceLoader.loadMainDispatcherFactory()
//···
return factories.maxByOrNull { it.loadPriority }?.tryCreateDispatcher(factories)
}
}

public fun tryCreateDispatcher(factories: List<MainDispatcherFactory>): MainCoroutineDispatcher =
try {
createDispatcher(factories)
} catch (cause: Throwable) {
//如果出现异常则创建一个MissingDispatcher
createMissingDispatcher(cause, hintOnError())
}

MainDispatcherFactory 是一个接口,通过实现类来创建Dispatcher:

1
2
3
4
5
6
7
8
9
10
11
kotlin复制代码public interface MainDispatcherFactory {
//子类实现
public fun createDispatcher(allFactories: List<MainDispatcherFactory>): MainCoroutineDispatcher
}

internal class AndroidDispatcherFactory : MainDispatcherFactory {
//由AndroidDispatcherFactory创建HandlerContext,可以看到它是一个主线程调度器
override fun createDispatcher(allFactories: List<MainDispatcherFactory>) =
HandlerContext(Looper.getMainLooper().asHandler(async = true), "Main")
//···
}

我们看到了AndroidDispatcherFactory, Looper.getMainLooper(),Main 等关键字,毫无疑问这就是主线程调度器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
kotlin复制代码internal class HandlerContext private constructor(
private val handler: Handler,
private val name: String?,
private val invokeImmediately: Boolean
) : HandlerDispatcher(), Delay {
//···
override val immediate: HandlerContext = _immediate ?:
HandlerContext(handler, name, true).also { _immediate = it }

//invokeImmediately默认为false, Looper.myLooper() != handler.looper判断当前线程looper
override fun isDispatchNeeded(context: CoroutineContext): Boolean {
return !invokeImmediately || Looper.myLooper() != handler.looper
}

override fun dispatch(context: CoroutineContext, block: Runnable) {
handler.post(block)
}
//···
}

它们三者的继承关系:
HandlerContext->HandlerDispatcher->MainCoroutineDispatcher()->CoroutineDispatcher。

它里面的isDispatchNeeded()返回的是true,当协程启动的时候则由HandlerContext来分发,而它里面的分发工作是通过 handler.post(runnable) 分发给主线程来完成的。在恢复的时候也是通过Dispatchers.Main这个调度器来恢复。当完成任务之后就会通过HandlerDispatcher把协程中的代码再次切换到主线程。

5.Dispatchers.IO

1
2
3
4
kotlin复制代码public actual object Dispatchers {
@JvmStatic
public val IO: CoroutineDispatcher = DefaultScheduler.IO
}

DefaultScheduler协程调度器的默认调度器,是一个线程调度器,执行阻塞任务,此调度程序与Dispatcher.Default调度程序共享线程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
kotlin复制代码internal object DefaultScheduler : ExperimentalCoroutineDispatcher() {
val IO: CoroutineDispatcher = LimitingDispatcher(
this,
systemProp(IO_PARALLELISM_PROPERTY_NAME, 64.coerceAtLeast(AVAILABLE_PROCESSORS)),
"Dispatchers.IO",
TASK_PROBABLY_BLOCKING
)
//···
}

private class LimitingDispatcher(
private val dispatcher: ExperimentalCoroutineDispatcher,
//···
) : ExecutorCoroutineDispatcher(), TaskContext, Executor {

override val executor: Executor
get() = this

override fun execute(command: Runnable) = dispatch(command, false)

override fun dispatch(context: CoroutineContext, block: Runnable) = dispatch(block, false)

private fun dispatch(block: Runnable, tailDispatch: Boolean) {
var taskToSchedule = block
while (true) {
//没有超过限制,立即分发任务
if (inFlight <= parallelism) {
dispatcher.dispatchWithContext(taskToSchedule, this, tailDispatch)
return
}
//任务超过限制,则加入等待队列
queue.add(taskToSchedule)
//···
}
}
}

dispatcher.dispatchWithContext()立即分发任务由ExperimentalCoroutineDispatcher实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
kotlin复制代码public open class ExperimentalCoroutineDispatcher(
private val corePoolSize: Int,
private val maxPoolSize: Int,
//···
) : ExecutorCoroutineDispatcher() {

override val executor: Executor
get() = coroutineScheduler

private var coroutineScheduler = createScheduler()

//分发
override fun dispatch(context: CoroutineContext, block: Runnable): Unit = coroutineScheduler.dispatch(block)

//···
internal fun dispatchWithContext(block: Runnable, context: TaskContext, tailDispatch: Boolean) {
coroutineScheduler.dispatch(block, context, tailDispatch)
}

//创建调度器
private fun createScheduler() = CoroutineScheduler(corePoolSize, maxPoolSize, idleWorkerKeepAliveNs, schedulerName)
//···
}

ExperimentalCoroutineDispatcher将任务分发到coroutineScheduler.dispatch() 实现。
CoroutineScheduler 就是一个线程池Executor。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
kotlin复制代码//协程调度器的主要目标是在工作线程上分配调度的协程,包括 CPU 密集型任务和阻塞任务。
internal class CoroutineScheduler(
val corePoolSize: Int,
val maxPoolSize: Int,
//···
) : Executor, Closeable {

override fun execute(command: Runnable) = dispatch(command)

//调度可运行块的执行,并提示调度程序该块是否可以执行阻塞操作(IO、系统调用、锁定原语等)
fun dispatch(block: Runnable, taskContext: TaskContext = NonBlockingContext, tailDispatch: Boolean = false) {

//1.构建Task,Task实现了Runnable接口
val task = createTask(block, taskContext)
//2.取当前线程转为Worker对象,Worker是一个继承自Thread的类,循环执行任务
val currentWorker = currentWorker()
//3.添加任务到本地队列
val notAdded = currentWorker.submitToLocalQueue(task, tailDispatch)
if (notAdded != null) {
//4.notAdded不为null,则再将notAdded(Task)添加到全局队列中
if (!addToGlobalQueue(notAdded)) {
throw RejectedExecutionException("$schedulerName was terminated")
}
}

if (task.mode == TASK_NON_BLOCKING) {
if (skipUnpark) return
//5.创建Worker并开始执行该线程
signalCpuWork()
} else {
// Increment blocking tasks anyway
signalBlockingWork(skipUnpark = skipUnpark)
}
}
}

上面代码主要做了以下几件事:

  1. 首先是通过Runnable构建了一个Task,这个Task其实也是实现了Runnable接口;
  2. 将当前线程取出来转换成Worker,这个Worker是继承自Thread的一个类;
  3. 将task提交到本地队列中;
  4. 如果task提交到本地队列的过程中没有成功,那么会添加到全局队列中;
  5. 创建Worker线程,并开始执行任务。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
kotlin复制代码class Worker private constructor() : Thread() {
//woeker队列
val localQueue: WorkQueue = WorkQueue()

override fun run() = runWorker()

private fun runWorker() {
var rescanned = false
while (!isTerminated && state != WorkerState.TERMINATED) {
val task = findTask(mayHaveLocalTasks)
//找到任务,执行任务,重复循环
if (task != null) {
executeTask(task)
continue
} else {
mayHaveLocalTasks = false
}
}
}

private fun executeTask(task: Task) {
val taskMode = task.mode
idleReset(taskMode)
beforeTask(taskMode)
runSafely(task)
afterTask(taskMode)
}
}

//执行任务
fun runSafely(task: Task) {
try {
task.run()
}
//···
}

run方法直接调用的runWorker(),在里面是一个while循环,不断从队列中取Task来执行,调用task.run()。

  1. 从本地队列或者全局队列中取出Task。
  2. 执行这个task,最终其实就是调用这个Runnable的run方法。

也就是说,在Worker这个线程中,执行了这个Runnable的run方法。还记得这个Runnable是谁么?它就是上面我们看过的DispatchedTask,这里的run方法执行的就是协程任务,那这块具体的run方法的实现逻辑,我们应该到DispatchedTask中去找。

1
2
3
4
5
6
7
8
9
10
11
12
13
kotlin复制代码internal abstract class DispatchedTask<in T>(
@JvmField public var resumeMode: Int
) : SchedulerTask() {

public final override fun run() {
//·····
withContinuationContext(continuation, delegate.countOrElement) {
//恢复协程执行,最终调用resumeWith
continuation.resume(getSuccessfulResult(state))
}
//·····
}
}

run方法执行continuation.resume恢复协程执行。最后通过executor.execute()启动线程池。

1
2
3
4
5
6
7
8
9
10
kotlin复制代码internal abstract class ExecutorCoroutineDispatcherBase : ExecutorCoroutineDispatcher(), Delay {
override fun dispatch(context: CoroutineContext, block: Runnable) {
try {
executor.execute(wrapTask(block))
} catch (e: RejectedExecutionException) {
unTrackTask()
DefaultExecutor.enqueue(block)
}
}
}

6.Dispatchers.Default

如果不指定调度器,则会默认 DefaultScheduler,它实际和Dispatchers.IO是同一个线程调度器,这个是线程调度器:

1
2
3
4
5
6
kotlin复制代码public actual object Dispatchers {
public actual val Default: CoroutineDispatcher = createDefaultDispatcher()
}

internal actual fun createDefaultDispatcher(): CoroutineDispatcher =
if (useCoroutinesScheduler) DefaultScheduler else CommonPool

如果指定了调度器则使用 CommonPool,表示共享线程的公共池作为计算密集型任务的协程调度程序。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
kotlin复制代码internal object CommonPool : ExecutorCoroutineDispatcher() {

override val executor: Executor
get() = pool ?: getOrCreatePoolSync()

//执行任务
override fun dispatch(context: CoroutineContext, block: Runnable) {
(pool ?: getOrCreatePoolSync()).execute(wrapTask(block))
}
//创建一个固定大小的线程池
private fun createPlainPool(): ExecutorService {
val threadId = AtomicInteger()
return Executors.newFixedThreadPool(parallelism) {
Thread(it, "CommonPool-worker-${threadId.incrementAndGet()}").apply { isDaemon = true }
}
}

CommonPool中也是创建了一个固定大小的线程池,dispatch()通过execute()执行协程任务。

协程线程调度.png

总结如下:

类型 调度器实现类 说明
Dispatchers.Main HandlerContext 它里面的isDispatchNeeded()返回的是true,当协程启动的时候则由HandlerDispatcher来分发,而它里面的分发工作是通过 handler.post(runnable) 来完成的。
Dispatchers.IO DefaultScheduler 它是线程调度器,它里面的isDispatchNeeded()返回的是true,而它调度任务的时候是通过 executors.execute(runnable) 来执行runnable任务。也就是把协程中的代码块运行到IO线程。
Dispatchers.Default DefaultScheduler,CommonPool 如果不指定调度器,则会默认DefaultScheduler,它实际和Dispatchers.IO是同一个线程调度器;如果指定调度器,则是CommonPool共享线程池。isDispatchNeeded()都是true,通过 executors.execute(runnable) 来执行runnable任务。
Dispatchers.Unconfined Unconfined 它里面的isDispatchNeeded()返回的是false,那么它的父类ContinuationInterceptor就不会把本次任务的调度交给子类来执行,而是由父类在当前线程立刻执行。

五、总结

1.协程的三层包装

通过一步步的分析,慢慢发现协程其实有三层包装:

  • 常用的launch和async返回的Job、Deferred,里面封装了协程状态,提供了取消协程接口,而它们的实例都是继承自AbstractCoroutine,它是协程的第一层包装。
  • 第二层包装是编译器生成的 SuspendLambda 的子类,封装了协程的真正运算逻辑,继承自BaseContinuationImpl,包含了第一层包装,其中completion就是协程的第一层包装。
  • 第三层包装是协程的线程调度时的DispatchedContinuation,封装了线程调度逻辑,包含了协程的第二层包装。

三层包装都实现了Continuation接口,通过代理模式将协程的各层包装组合在一起,每层负责不同的功能。

image.png

2.协程的挂起与恢复原理

  1. 在研究协程原理时需要反编译成Java文件,才能看到本质之处。因为有一部分代码是kotlin编译器生成的,在协程源码里是看不出来的。
  2. 每个挂起点对应于一个case分支(状态机),每调用一次 label 加1;label 的默认初始值为 0,第一次会进入 case 0 分支,挂起函数在返回 COROUTINE_SUSPENDED 时直接 return ,那么方法执行就被结束了,方法就被挂起了。
  3. 协程体内的代码都是通过 continuation.resumeWith() 调用;获取到真实结果后,回调到 ContinuationImpl 这个类里面的 resumeWith() 方法,会再次调用 invokeSuspend(result) 方法,进入状态机case分支,返回真实结果,方法恢复后,接着恢复协程。
  4. 所以说,协程的挂起本质上是方法的挂起,而方法的挂起本质上是 return,协程的恢复本质上方法的恢复,而恢复的本质是 callback 回调。

3.协程的调度原理

  • 拦截器在每次(恢复)执行协程体的时候都会拦截协程本体SuspendLambda,然后会通过协程分发器的 interceptContinuation() 方法拦截了一个Continuation<T>并且再返回一个Continuation<T>。
  • 把拦截的代码块封装为任务 DispatchedContinuation ,会通过 CoroutineDispatcher 的 needDispatch() 来判断需不需要分发,由子类的 dispatch(runnable) 方法来实现协程的本次调度工作。

4.协程面试常见问题

  • 面试官:什么是协程?

协程是一种解决方案,是一种解决嵌套,并发,弱化线程概念的方案。能让多个任务之间更好协作,能够以同步的方式完成异步工作,将异步代码像同步代码一样直观。

  • 面试官:协程与线程有什么区别?

协程就像轻量级的线程,协程是依赖于线程,一个线程中可以创建多个协程。协程挂起时不会阻塞线程。线程进程都是同步机制,而协程则是异步。

  • 面试官:协程的调度原理

根据创建协程指定调度器HandlerContext,DefaultScheduler,UnconfinedDispatcher来执行任务,以解决协程中的代码运行在那个线程上。HandlerContext通过handler.post(runnable)分发到主线程,DefaultScheduler本质是通过excutor.excute(runnable)分发到IO线程。

  • 面试官:协程是线程框架吗?

协程的本质是编译时return+callback,只不过在调度任务时提供了能够运行在IO线程的调度器和主线程的调度器。把协程称为线程框架不够准确。

  • 面试官:什么时候使用协程?

多任务并发流程控制场景,流程控制比较简单,不会涉及线程阻塞和唤醒,性能比Java并发控制手段高。

点关注,不迷路


好了各位,以上就是这篇文章的全部内容了,很感谢您阅读这篇文章。我是suming,感谢支持和认可,您的点赞就是我创作的最大动力。山水有相逢,我们下篇文章见!

本人水平有限,文章难免会有错误,请批评指正,不胜感激 !

Kotlin协程学习三部曲:

  • 《Kotlin 协程实战进阶(一、筑基篇)》
  • 《Kotlin 协程实战进阶(二、进阶篇)》
  • 《Kotlin 协程实战进阶(三、原理篇)》

Kotlin协程+Jetpack实战项目

参考链接:

  • Kotlin官网
  • 《深入理解Kotlin协程》
  • 慕课网之《新版Kotlin从入门到精通》
  • 慕课网之《大白话剖析Kotlin协程机制》
  • Kotlin Coroutines(协程) 完全解析(二),深入理解协程的挂起、恢复与调度》

希望我们能成为朋友,在 Github、掘金 上一起分享知识,一起共勉!Keep Moving!

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

掘金1000粉!使用Threejs实现一个创意纪念页面 🏆

发表于 2022-09-14

“我报名参加金石计划1期挑战——瓜分10万奖池,这是我的第1篇文章,点击查看活动详情”

声明:本文涉及图文和模型素材仅用于个人学习、研究和欣赏,请勿二次修改、非法传播、转载、出版、商用、及进行其他获利行为。

背景

不知不觉,掘金关注者人数已经超过 1000 人,因此特地做了这个页面纪念一下,感谢大家关注 🙇‍。后续也将继续努力,持续输出一些有价值的文章。本文内容涉及的技术栈为 React + Three.js + Stulus,本文中主要包含的知识点包括:圆锥几何体 ConeGeometry、圆柱几何体 CylinderGeometry、材质捕捉纹理材质 MeshMatcapMaterial、文字创建和修饰的 FontLoader 和 TextGeometry、使用 Gsap 和它的插件 Physics2DPlugin 创建一些动画、rotateOnAxis 方法实现绕轴自转等。

对了,后续我专门新建了一个专门针对 Three.js 系列的专栏【Three.js 奥德赛进阶之旅】,是掘金签约的专栏 📚。从基础入门开始,全方位了解 Three.js 的各种特性,并结合和应用对应特性,实现令人眼前一亮的 Web 创意页面,进而逐步挖掘 Three.js 和 WebGL 深层次的知识。在这里推广一下,大家感兴趣的话可以关注一波 😘。

效果

页面 📃 主体内容主要由四部分组成,分别是:文字 1000!、文字 THANK YOU、掘金三维 Logo、以及 纸片礼花 🎉。其中文字各自具有翻转动画,掘金 Logo 有自转动画效果,当用 🖱 鼠标点击屏幕时,会出现 *★,°*:.☆( ̄▽ ̄)/$:*.°★* 。 撒花效果。

打开以下链接中的任意一个可以在线预览效果。本页面适配PC端和移动端,大屏访问效果更佳。

  • 👁‍🗨 在线预览地址1:3d-eosin.vercel.app/#/fans
  • 👁‍🗨 在线预览地址2:dragonir.github.io/3d/#/fans

码上掘金

实现

📦 资源引入

首先在顶部引入开发必备的资源,除了基础的 React 和样式表之外,THREE 是 Three.js 库;OrbitControls 用于镜头轨道控制,可以使用鼠标移动或旋转模型;Text 是用于创建文字模型的一个类;Confetti 是一个用于创建礼花效果的类,在后面内容中会做详细介绍。

1
2
3
4
5
6
js复制代码import './index.styl';
import React from 'react';
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";
import Text from '@/assets/utils/Text';
import Confetti from '@/assets/utils/Confetti';

📃 页面结构

页面主要结构非常简单,其中 .webgl 用于渲染 3D 元素;.logo 是页面上的一些图标装饰,.github 是存放本项目的 Github 链接地址。

1
2
3
4
5
6
html复制代码<div className='fans_page'>
<canvas className='webgl'></canvas>
<i className='logo'></i>
<i className='logo click'></i>
<a className='github' href='https://github.com/dragonir/3d' target='_blank'></a>
</div>

创建 Logo

创建 Logo 时,先创建一个 Group,然后将 Logo 的各个部分添加到 Group 中,这样有利于对 Logo 整体调整位置和添加动画,也有利于页面加载性能。接着,通过以下步骤创建 Logo 模型的三部分:

  • 创建通用的材质 MeshMatcapMaterial,Logo 模型的所有组成网格都将使用这种材质;
  • 使用 ConeGeometry 创建顶部的 四棱锥,并应用材质;
  • 使用 CylinderGeometry 创建中间的 四棱台,并应用材质;
  • 使用 CylinderGeometry 创建底部的 四棱台,并应用材质;
  • 将上述网格模型添加到 Group 中,并调整整体的位置、大小,并设置倾斜角度以便获得更好的页面视觉效果。

📌 在实际开发中,应用了 ConeBufferGeometry、CylinderBufferGeometry 代替 ConeGeometry 和 CylinderGeometry,以便获得更好的性能。

本示例中模型的计算参数如上图所示,顶部四棱柱的四个面都是边长为 4 的等边三角形,其余两个棱台的侧边长也是 4,其他边的长度参数都可以通过勾股定理以及三角函数计算得出,本文中不做详细计算。(PS:模型示意图是用 Windows 画图工具画的,有点丑 🤣)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
js复制代码const logo = new THREE.Group();
// 材质捕捉纹理材质
const logoMaterial = new THREE.MeshMatcapMaterial({
matcap: this.matcaps.logoMatcap,
side: THREE.DoubleSide,
});
// 顶部四棱锥
const cone = new THREE.Mesh(new THREE.ConeGeometry(4, 4, 4), logoMaterial);
logo.add(cone);
// 中间四棱台
const cylinder = new THREE.Mesh(new THREE.CylinderGeometry(6, 10, 4, 4, 1), logoMaterial);
cylinder.position.y = -6
logo.add(cylinder);
// 底部四棱台
const cylinder2 = new THREE.Mesh(new THREE.CylinderGeometry(12, 16, 4, 4, 1), logoMaterial);
cylinder2.position.y = -12
logo.add(cylinder2);
logo.position.set(0, 0, 0);
logo.scale.set(11, 11, 11);
// 设置倾斜角度
logo.rotateY(Math.PI * 0.2);
logo.rotateZ(Math.PI * 0.1);
scene.add(logo);

💡 知识点 圆锥几何体ConeGeometry

圆锥几何体 ConeGeometry,是一个用于生成圆锥几何体的类,侧面分段数越多则越圆,本例中分段数为 4,所以看起来是个四棱锥。

构造函数:

1
js复制代码ConeGeometry(radius: Float, height: Float, radialSegments: Integer, heightSegments: Integer, openEnded: Boolean, thetaStart: Float, thetaLength: Float);

参数说明:

  • radius:圆锥底部的半径,默认值为 1。
  • height:圆锥的高度,默认值为 1。
  • radialSegments:圆锥侧面周围的分段数,默认为 8。
  • heightSegments:圆锥侧面沿着其高度的分段数,默认值为 1。
  • openEnded:一个 Boolean 值,指明该圆锥的底面是开放的还是封顶的。默认值为 false,即其底面默认是封顶的。
  • thetaStart:第一个分段的起始角度,默认为 0。
  • thetaLength:圆锥底面圆扇区的中心角,通常被称为 θ。默认值是 2*PI,这使其成为一个完整的圆锥。

💡 知识点 圆柱几何体CylinderGeometry

圆柱几何体 CylinderGeometry,是一个用于生成圆柱几何体的类。本文中 Logo 的中间和底部就由此类生成。

构造函数:

1
js复制代码CylinderGeometry(radiusTop: Float, radiusBottom: Float, height: Float, radialSegments: Integer, heightSegments: Integer, openEnded : Boolean, thetaStart: Float, thetaLength: Float)

参数说明:

  • radiusTop:圆柱的顶部半径,默认值是 1。
  • radiusBottom:圆柱的底部半径,默认值是 1。
  • height:圆柱的高度,默认值是 1。
  • radialSegments:圆柱侧面周围的分段数,默认为 8。
  • heightSegments:圆柱侧面沿着其高度的分段数,默认值为 1。
  • openEnded:一个 Boolean值,指明该圆锥的底面是开放的还是封顶的。默认值为 false,即其底面默认是封顶的。
  • thetaStart:第一个分段的起始角度,默认为 0。
  • thetaLength:圆柱底面圆扇区的中心角,通常被称为 θ。默认值是 2*PI,这使其成为一个完整的圆柱。

💡 知识点 材质捕捉纹理材质MeshMatcapMaterial

MeshMatcapMaterial 由一个材质捕捉 MatCap或光照球 纹理所定义,其编码了材质的颜色与明暗。由于 mapcap 图像文件编码了烘焙过的光照,因此MeshMatcapMaterial不对灯光作出反应。它可以投射阴影到一个接受阴影的物体上,但不会产生自身阴影或是接收阴影。

构造函数:

1
js复制代码MeshMatcapMaterial(parameters: Object)

parameters:可选,用于定义材质外观的对象,具有一个或多个属性,材质的任何属性都可以从此处传入,包括从 Material 继承的任何属性。

  • .color[Color]:材质的颜色,默认值为白色 0xffffff。
  • .matcap[Texture]:matcap 贴图,默认为 null。
  • 其他Material基类的共有属性等。

MeshMatcapMaterial 是一种非常好用的材质,简单使用这种材质就能实现复杂的纹理效果,如本文中 Logo 的光泽效果,以及后续文字的金属效果以及透明玻璃效果,选择合适的材质,可以实现各种各样的神奇效果。下面这张图就是本文中所有元素的材质贴图,可以看出它们是一个个光照球体样式。

除了在 Blender、Photoshop等设计软件中生成 MeshMatcapMaterial 之外,下面几个网站可以免费下载各种好看的材质,并且具有在线实时预览功能,大家可以根据页面元素内容和自身需求找到合适的材质图片,感兴趣的话可以亲手试试看 😉。

🔗 observablehq.com/@makio135/m… 🔗 github.com/nidorx/matc…

🔗 jeanmoreno.com/unity/matca…jeanmoreno.com/unity/matca…

创建文字 1000!

接着,来创建文字,此时需要引入 FontLoader,用于加载字体文件,它返回一个字体实例,然后使用 TextGeometry 创建文字网格,将它添加到场景中就可以了。

1
2
3
4
5
6
7
8
9
10
11
js复制代码import { FontLoader } from 'three/examples/jsm/loaders/FontLoader';
import { TextGeometry } from 'three/examples/jsm/geometries/TextGeometry';

fontLoader.load('fontface.json', font => {
textMesh.geometry = new TextGeometry('1000!', {
font: font,
size: 100,
height: 40
});
scene.add(textMesh);
});

看起来非常普通对不对,此时可以对 TextGeometry 进行对字符的厚度、斜角大小等参数的调整,我们可以按类似下面这种稍微优化一下,直至调整到自己满意的结果为止。

1
2
3
4
5
6
7
8
9
10
11
js复制代码textMesh.geometry = new TextGeometry('1000!', {
font: font,
size: 100,
height: 40,
curveSegments: 100,
bevelEnabled: true,
bevelThickness: 10,
bevelSize: 10,
bevelOffset: 2,
bevelSegments: 10
});

看看优化后的效果,瞬间高大上了有木有 ✨!

创建文字 THANK YOU

使用同样的方法添加 THANK YOU 文字网格到场景中,并为它设置半透明玻璃效果的 MeshMatcapMaterial 和文字厚度斜角样式。

📌 关于文字网格的详细应用可以看看我的这篇文章 《使用Three.js实现神奇的3D文字悬浮效果》,本文中不再赘述了。

创建文字动画

文字创建完成后,可以给它们添加一些文字翻转动画效果。动画效果是通过 Gsap 实现的,本文中给1000! 文字添加了一个缩放并翻转的动画效果,给 THANK YOU 添加了一个上下翻转的动画效果,可以参考如下方法来实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
js复制代码import gsap from 'gsap';
// 上下翻转动画
zoomAndFlip() {
gsap.timeline({
repeat: -1,
defaults: {
duration: 2,
ease: 'elastic.out(1.2, 1)',
stagger: 0.1,
},
})
.to(this.meshesPosition, { z: this.meshesPosition[0].z + 100 }, 'start')
.to(this.meshesRotation, { duration: 2, y: Math.PI * 2 }, 'start')
.to(this.meshesRotation, { duration: 2, y: Math.PI * 4 }, 'end')
.to(this.meshesPosition, { z: this.meshesPosition[0].z }, 'end');
}

创建礼花 🎉

页面每次打开以及点击屏幕时,可以产生礼花效果。其中礼花中的每个小碎片使用了面基础缓冲模型 PlaneBufferGeometry 以及 MeshBasicMaterial 基础材质构成,在场景中创建三束礼花,每束礼花内的碎片位置和大小随机,并在一段时间后自动消失。同样,礼花的动画效果也是使用了 Gsap,并且使用了它的插件 Physics2DPlugin 来实现,Physics2DPlugin 插件可以模拟物理动画效果包括重力、速度、加速度、摩擦力动画等,有了它就能更好地实现礼花爆炸和散落效果。可以像本文中这样使用它们:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
js复制代码import gsap from 'gsap';
const physics2D = require('./physics2D');
gsap.registerPlugin(physics2D.Physics2DPlugin);
// 对每一片礼花应用动画效果
gsap.to(this.confettiSprites[id], DECAY, {
physics2D: {
velocity,
angle,
gravity,
friction,
},
ease: 'power4.easeIn',
onComplete: () => {
_.pull(this.confettiSpriteIds, id);
this.parent.remove(this.meshes[id]);
this.meshes[id].material.dispose();
delete this.confettiSprites[id];
},
});

💡 知识点 Physics2DPlugin

Physics2DPlugin 设置二维物理动画抛物线效果可选参数:

  • velocity:初始速度
  • angle:角度
  • gravity:重力
  • acceleration:加速度
  • accelerationAngle:加速度角度
  • friction:摩擦力

点击页面时触发动画

1
2
3
4
js复制代码window.addEventListener('pointerdown', e => {
e.preventDefault();
this.confetti && this.confetti.pop();
});

📌 文字和礼花效果在实际中实现,其实是分别封装了两个类,方便从外部调用,具体实现详细代码可以访问文末提供的源码链接。

缩放监听及重绘动画

添加页面缩放适配和重绘动画来更新相机和轨道控制器等。在重绘动画中,给 Logo 添加了一个绕自身 Y轴 旋转的效果,可以通过 rotateOnAxis 实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
js复制代码window.addEventListener('resize', () => {
this.width = window.innerWidth;
this.height = window.innerHeight;
this.camera.aspect = this.width / this.height;
this.camera.updateProjectionMatrix();
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
this.renderer.setSize(this.width, this.height);
}, {
passive: true
});

const animate = () => {
requestAnimationFrame(animate);
controls && controls.update();
// 旋转动画
logo && logo.rotateOnAxis(axis, Math.PI / 400);
renderer.render(scene, camera);
}

❓ 大家可以亲自动手试试 rotateOnAxis 和 rotateY 实现的旋转效果有何不同来区分两者。

💡 知识点 rotateOnAxis

.rotateOnAxis 是 Three.js 中三维物体基类 Object3D 的一个方法,它可以在局部空间中绕着该物体的轴来旋转一个物体,假设这个轴已被标准化,它的使用方法如下所示。

1
js复制代码.rotateOnAxis(axis: Vector3, angle: Float)
  • axis:一个在局部空间中的标准化向量。
  • angle:角度,以弧度来表示。

样式细节优化

到这一步,页面的功能已经全部完成了 🍾,最后可以装饰一下页面,如将 renderer 设置为透明,然后在 CSS 中使用一张好看的科技感图片作为页面背景,最后加上几个角落里的图片装饰物和 Github 图标链接,加一点点 CSS 动画,页面整体视觉效果就得到了不错的提升 😉。最后再次感谢大家关注!🙇‍ 谢谢、栓Q、阿里嘎多

🔗 源码地址:github.com/dragonir/3d…

总结

本文包含的知识点主要包括:

  • 圆锥几何体 ConeGeometry
  • 圆柱几何体 CylinderGeometry
  • 材质捕捉纹理材质 MeshMatcapMaterial
  • 文字创建和修饰的 FontLoader 和 TextGeometry
  • 使用 Gsap 和它的插件 Physics2DPlugin 创建一些动画
  • rotateOnAxis 方法实现绕轴自转

想了解其他前端知识或其他未在本文中详细描述的 Web 3D 开发技术相关知识,可阅读我往期的文章。转载请注明原文地址和作者。如果觉得文章对你有帮助,不要忘了一键三连哦 👍。

附录

  • 新建【Three.js 进阶之旅】Three.js 系列专栏 👈
  • [1]. 🌴 Three.js 打造缤纷夏日3D梦中情岛
  • [2]. 🔥 Three.js 实现炫酷的赛博朋克风格3D数字地球大屏
  • [3]. 🐼 Three.js 实现2022冬奥主题3D趣味页面,含冰墩墩
  • 更多往期【3D】专栏访问 👈
  • ...
  • [1]. 📷 前端实现很哇塞的浏览器端扫码功能
  • [2]. 🌏 前端瓦片地图加载之塞尔达传说旷野之息
  • [3]. 🌊 使用前端技术实现静态图片局部流动效果
  • 更多往期【前端】专栏访问 👈
  • ...

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

一类有趣的无限缓存OOM现象

发表于 2022-09-09

许久没水文章了,今天突然想水水。给大家分享一下一年多以前遇到的一个无限缓存的 OOM 现象

首先

想必大家都知道OOM是啥吧,我就不扯花里胡哨的了,直接进入正题。先说一个背景故事,我司app扫码框架用的zxing,在很长一段时间以前,做过一系列的扫码优化,稍微列一下跟今天主题相关的改动:

  1. 串行处理改成并发处理,zxing的原生处理流程是通过CameraManager获取到一帧的数据之后,通过DecodeHandler去处理,处理完成之后再去获取下一帧,我们给改成了线程池去调度:
  • 单帧decode任务入队列之后立即获取下一帧数据
  • 二维码识别成功则停止其他解析任务
  1. 为了有更大的识别区域,选择对整张拍摄图片进行解码,保证中心框框没对准二维码也能识别到

现象

当时测试反馈,手上一个很古老的 Android 5.0 的机器,打开扫一扫必崩,一看错误栈,是个OOM。

机器找不到了,我就不贴现象的堆栈了(埋在时光里了,懒得挖了)。

排查OOM三板斧

板斧一、 通过一定手段,抓取崩溃时的或者崩溃前的内存快照

咦,一年前的hprof文件还在?确实被我找到了。。。

从图中我们能获得哪些信息?

  1. 用户OOM时,byte数组的 java 堆占用是爆炸的
  2. 用户OOM时,byte数组里,有大量的 3M 的byte数组
  3. 3M的byte 数组是被 zxing 的 DecodeHandler$2 引用的

板斧二、从内存对照出发,大胆猜测找到坏死根源

我们既然知道了 大对象 是被 DecodeHandler$2 引用的,那么 DecodeHandler$2 是个啥呀?

1
2
3
4
5
6
7
8
scss复制代码mDecodeExecutor.execute(new Runnable() {
@Override
public void run() {
for (Reader reader : mReaders) {
decodeInternal(data, width, height, reader, fullScreenFrame);
}
}
});

所以稍微转动一下脑瓜子就能知道,必然是堆积了太多的 Runnable,每个Runnable 持有了一个 data 大对象才导致了这个OOM问题。

但是为啥会堆积太多 Runnable 呢?结合一下只有 Android 5.0 机器会OOM,我们大胆猜测一下,就是因为这个机器消费(或者说解码)单张 Bitmap 太慢,同时像上面所说的,我们单帧decode任务入队列之后立即获取下一帧数据并入队下一帧decode 任务,这就导致大对象堆积在了LinkedBlockingDeque中。

OK,到这里原因也清楚了,改掉就完事了。

板斧三、 吃个口香糖舒缓一下心情

呵呵…

解决方案

解决方案其实很简单,从问题出发即可,问题是啥?我生产面包速度是一天10个,一个一斤,但是一天只能吃三斤,那岂不就一天就会多7斤囤货,假如囤货到了100斤地球会毁灭,怎么解决呢?

  1. 吃快点,一天吃10斤
  2. 少生产点,要么生产个数减少,要么生产单个重量减少,要么二者一起
  3. 生产前检查一下吃完没,吃完再生产都来得及,实在不行定个阈值觉得不够吃了再生产嘛。

那么自然而然的就大概知道有哪几种解决办法了:

  1. 生产的小点 - 隔几帧插一张全屏帧即可(如果要保留不在框框内也能解码的特性的话)
  2. 生产前检查一下吃完没 - 线程池的线程空闲时,才去 enqueue decode 任务
  3. 生产单个重量减少 - 限制队列大小
  4. blalala

总结

装模作样的总结一下。

这个例子是一年前遇到的,今天想水篇文章又突然想到了这个事就拿来写写,我总结为:线程池调度 + 进阻塞队列单任务数据过大 + 处理任务过慢

线程池调度任务是啥场景?

  • 有个 Queue,来了任务,先入队
  • 有个 ThreadPool ,空闲了,从 Queue 取任务。

那么,当入队的数据结构占内存太大,且 ThreadPool 处理速度小于 入队速度呢?就会造成 Queue 中数据越来越多,直到 OOM。

扫一扫完美的满足了上面条件

  • 入队频率足够高
  • 入队对象足够大
  • 处理速度足够慢。

在这个例子中,做的不足的地方:

  1. 追求并发未考虑机器性能
  2. 大对象处理不够谨慎

当然,总结是为了避免未来同样的惨案发生,大家可以想想还会有什么类似的场景吧,转动一下聪明的小脑袋瓜~

未来展望

装模作样展望一下,未来展望就是,以后有空多水水贴子吧(不是多水水贴吧)。

你可能感兴趣

Android QUIC 实践 - 基于 OKHttp 扩展出 Cronet 拦截器 - 掘金 (juejin.cn)

Android启动优化实践 - 秒开率从17%提升至75% - 掘金 (juejin.cn)

如何科学的进行Android包体积优化 - 掘金 (juejin.cn)

Android稳定性:Looper兜底框架实现线上容灾(二) - 掘金 (juejin.cn)

基于 Booster ASM API的配置化 hook 方案封装 - 掘金 (juejin.cn)

记 AndroidStudio Tracer工具导致的编译失败 - 掘金 (juejin.cn)

Android 启动优化案例-WebView非预期初始化排查 - 掘金 (juejin.cn)

chromium-net - 跟随 Cronet 的脚步探索大致流程(1) - 掘金 (juejin.cn)

Android稳定性:可远程配置化的Looper兜底框架 - 掘金 (juejin.cn)

一类有趣的无限缓存OOM现象 - 掘金 (juejin.cn)

Android - 一种新奇的冷启动速度优化思路(Fragment极度懒加载 + Layout子线程预加载) - 掘金 (juejin.cn)

Android - 彻底消灭OOM的实战经验分享(千分之1.5 -> 万分之0.2) - 掘金 (juejin.cn)

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

【项目实战】—— SSM 图书管理系统 概述 准备 实现 运

发表于 2022-09-04

本文已参与「新人创作礼」活动,一起开启掘金创作之路。

概述

JavaEE的期末大作业,基于 SSM 开发的一次项目实战,严格的实行三级权限管理:访客,会员,管理员,大致的功能实现如下,
思维导图
资源下载点这!

准备

  • 环境:
+ IDEA
+ Tomcat 9+
+ MySQL 5.7+
+ Maven 3.6+
  • 技术:
+ Mybatis
+ Spring
+ SpringMVC
+ jQuery
+ Bootstrap
+ Semantic

实现

搭建数据库

数据库表

表名 内容
users 存储会员和管理员的登录信息,如:会员名、会员登录密码、管理员名、管理员登录密码、身份等等。
information 存储会员的个人信息,如:会员名、性别、生日、个人头像、个性签名、余额、信誉等级、借书数量、购买数量、积分点等等。
books 存储书籍的具体信息,如:书籍编号、书名、书籍数量、书籍图片、书籍作者、书籍价格等等。
comments 存储书籍的评论信息,如:评论编号、书籍编号、评论者、评论内容、评论时间等等。
borrow 存储书籍的借阅时间信息,如:书籍编号、书名、借书开始时间、借书时长等等。
spend_list 存储会员的充值和消费信息,如:会员名、充值记录、消费记录、余额变化、现有余额、充值或消费时间等等。
stock_list 存储进货的详细信息,如:所需书籍编号、所需书籍名称、进货数量、进货地址、进货时间等等。

配置文件

  1. 主要有 mybatis,spring,springmvc 的配置文件以及 web.xml 的配置,详见资源包,这里就一笔带过,
    配置文件
  2. 主要实现 pojo 层,dao 层,service 层,同样一笔带过,
    pojo
    dao
    service

代码编写

感兴趣的可以下载资源包看一下,主要就是实现 controller 层和 view 层,

目录有点长,就不截图了。

运行展示

访客

首页ing,
首页
书籍详情ing,略显粗糙,主要是为了展示和实现后端的一些功能,
图书详情
访客是不能进行借阅,购买或评论书籍的,当用户点击时,会先验证身份,如果是访客的话,则会被告知“请先登录”,
提示

书籍搜索ing,访客,会员和管理员都可以通过搜索来查找自己想要搜索的书籍名称或者作者,
书籍搜索
注册ing,访客注册之后就能够成为会员啦~
注册

会员

登录ing,访客注册成功后,就可以登录了,
登录
首页ing,这是会员的首页,有用 jQuery 做的动画效果,
会员首页

个人资料ing,可以上传头像,修改相关个人信息,充值余额,升级信誉,查看消费记录,以及借书买书详情等,
个人资料

消费记录ing,
消费记录

借书详情ing,在这里可以进行续借和归还,如果超时归还则会降低信誉等级,
借书详情

购书详情ing,
购书详情

评论总览ing,会员可以删除自己的评论,
评论总览

修改密码ing,利用 onblur 属性伪造实时检测,并且有显示密码功能,
修改密码

管理员

首页ing,类似于会员的首页,
管理员首页

书籍列表ing,管理员可以在这新增,更改和删除书籍,
书籍列表

新增书籍ing,
新增书籍

更改书籍ing,这里除了能够修改书籍信息,同时能看到这本书的所有评价以及所有购买信息,
更改书籍管理员可以删除会员的不当评论,
评论详情)购买总览

会员管理ing,管理员可修改会员的相关信息或者删除会员,即当会员选择注销账号时,
会员管理管理员能够修改会员的余额(maybe 不太好?),也能调整会员的信誉等级,
会员详情同时也能看到会员对所有书籍的评论,
会员评论总览

会员借阅详情ing,管理员可以看到所有的会员借书详情,同时也可以提醒快超时或者已经超时的会员对相关书本进行归还,
会员借阅详情

会员已购详情ing,
会员已购详情进货管理ing,这是批处理的进货,管理员需要进啥填啥就好了,简单示意一下,
进货管理
且带有进货记录,方便回溯,
进货记录

问题解决

做程序时遇到的问题,选几个比较有针对性的,

  1. 关于js中执行顺序问题的解决?

因为后端用session来传递图片的保存地址,所以当一次完成图片存储操作后,session中绑定对象的值还是存在的,当我们第二次及之后提交的话,就会变成将上一次的图片保存地址更新到了数据库当中,造成这个问题的原因是将两次提交写进了一个函数里(如下),

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
js复制代码function upload(){
var IMG = new FormData(document.getElementById("uploadIMG"));
var item = new FormData(document.getElementById("item"));
$.ajax({
url:"/book/writePhoto",
type:"post",
data:IMG,
processData:false,
contentType:false,
});
$.post({
url:"/book/updateBook",
data:item,
processData:false,
contentType:false,
});
alert("修改成功!");
window.location = "${pageContext.request.contextPath}allBook";
}

function Return(){
window.location = "${pageContext.request.contextPath}allBook"
}

但是代码并不是按顺序执行的,所以就造成了拿原有session所绑定对象的值去更新了数据库,然后才是更新session绑定对象的值,如下图所示,其中Photo Address是图片上传后所保存的地址,而upAddr是session所绑定对象的值,booAddr则是通过book.getPhoto()得到的值,
1)之后,在两个提交之间插入了alert来进行一个打断,果然就正常了,但是一次提交出现两个alert就显得怪怪的,因此就想到用sleep()函数,去查了一下js的sleep形式,发现 JavaScript 有setTimeout()方法来实现设定一段时间后执行某个任务,但写法很丑陋,需要提供回调函数:

1
js复制代码setTimeout(function(){ alert("Hello"); }, 3000);

JavaScript Promise API是新出现了一个API,借助 Promise,我们可以对setTimeout函数进行改良,下面就是把setTimeout()封装成一个返回Promise的sleep()函数。

1
2
3
4
5
6
7
8
js复制代码function sleep (time) {
return new Promise((resolve) => setTimeout(resolve, time));
}

// 用法
sleep(500).then(() => {
// 这里写sleep之后需要去做的事情
})

改进代码之后的运行结果就正常了!
2

  1. 关于使用EL表达式对两个对象的值进行比较?

为了可以契合的使用下拉框中 option 的 select 属性,

使用如下代码进行编写,

1
2
3
4
5
6
7
8
9
10
11
12
html复制代码<select name="credit">
<c:forEach var="credit" items="${creditList}">
<c:choose>
<c:when test="${credit eq member.getCredit()}">
<option value="${credit}" selected>${member.getCredit()}</option>
</c:when>
<c:otherwise>
<option value="${credit}">${credit}</option>
</c:otherwise>
</c:choose>
</c:forEach>
</select>

其中${credit eq member.getCredit()}不能改为${credit}.equals(${member.getCredit()}),

在 Expression Language 中,仅可以使用 == 或 eq 运算符来比较对象值。在幕后,他们实际上将使用Object#equals()。这样做是因为,直到使用当前的 EL 2.1 版本,才能调用具有除标准getter(和setter)方法之外的其他签名的方法(在即将到来的EL 2.2中是可能的)。

上述正确语句在幕后的大致解释为

1
jsp复制代码jspContext.findAttribute("credit").equals(member.getCredit());
  1. 关于SSM框架下的分页功能实现?

做管理系统时,必然会碰到实现分页以及页面查询功能,在不使用插件的前提下,

先创建实体类Page.class,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
java复制代码package com.idiot.pojo;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@AllArgsConstructor
@NoArgsConstructor
public class Page {
int pageSize; //页面显示记录的数量
int pageCount; //表示页面总数
int rowCount; //表示记录总数
int pageCurrent; //表示当前页面为第几页
int start; //表示当前为第几条记录

public Page(String pageNo, int pageSize, int total) {
this.pageSize = pageSize;
if (pageNo==null || pageNo.trim().length()==0){
this.pageCurrent = 1;
} else {
this.pageCurrent = Integer.parseInt(pageNo);
}
this.rowCount = total;
this.pageCount = (this.rowCount+this.pageSize)/this.pageSize;
if (this.pageCurrent > this.pageCount){
this.pageCurrent = this.pageCount;
}
if (this.pageCurrent < 1){
this.pageCurrent = 1;
}
this.start = (this.getPageCurrent()-1)*this.getPageSize();
}
}

其中,pageNo表示需要跳转到第几页面,pageSize表示一个页面显示记录的数量,total表示该数据库表中总的记录数量,

然后在控制类中进行编写,以BookController.java为例,

1
2
3
4
5
6
7
8
9
10
11
12
java复制代码@RequestMapping("/allBook")
public String list(String pageNo, Model model) {
int total = bookService.getTotalBooks();
Page p = new Page(pageNo,8,total);
HashMap<String,Object> map = new HashMap<String,Object>();
map.put("start",p.getStart());
map.put("size", p.getPageSize());
List<Books> bookList = bookService.queryAllBook(map);
model.addAttribute("bookList", bookList);
model.addAttribute("page", p);
return "manager/books/allBook";
}

其中代码段中用到的两个SQL语句如下,

1
2
3
4
5
6
7
8
9
10
11
12
sql复制代码<!--获取所有书本数量-->
<select id="getTotalBooks" resultType="int">
select count(bookID) from books
</select>

<!--查询全部Book-->
<select id="queryAllBook" parameterType="Map" resultType="Books">
SELECT * from books
<if test="start!=null and size!=null">
limit #{start},#{size}
</if>
</select>

最后以allBook.jsp为例,展示在jsp中的应用,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
html复制代码<tbody>
<c:forEach var="book" items="${requestScope.get('bookList')}">
<tr>
<td><img src="${pageContext.request.contextPath}/book/readPhoto?id=${book.getBookID()}" width="40px" height="40px"></td>
<td>${book.getBookID()}</td>
<td>${book.getBookName()}</td>
<td>${book.getBookCounts()}</td>
<td>${book.getDetail()}</td>
<td>
<a href="${pageContext.request.contextPath}/book/toUpdateBook?id=${book.getBookID()}">更改</a> |
<a href="${pageContext.request.contextPath}/book/del/${book.getBookID()}">删除</a>
</td>
</tr>
</c:forEach>
</tbody>

<label>第${requestScope.page.getPageCurrent()}/${page.pageCount}页</label>
<a href="/book/allBook?pageNo=1">首页</a>
<a href="/book/allBook?pageNo=${page.pageCurrent-1}" >上一页</a>
<a href="/book/allBook?pageNo=${page.pageCurrent+1}" >下一页</a>
<a href="/book/allBook?pageNo=${page.pageCount}">尾页</a> 跳转到:
<input type="text" style="width:30px" id="turnPage" />页
<input type="button" onclick="startTurn()" value="跳转" />
<script type="text/javascript">
function startTurn(){
var turnPage = document.getElementById("turnPage").value;
if(turnPage > ${page.pageCount}){
alert("超过最大页数,请重新输入!");
return false;
}
if(turnPage < 1){
alert("低于最小页数,请重新输入!");
return false;
}
var shref="/book/allBook?pageNo="+turnPage;
window.location.href=shref;
}
</script>

其中 js 里的两个 if 判定可有可无,因为已经在构造函数里进行了处理,程序还是具有较高的鲁棒性!

  1. 关于正则表达式防止充值时非法输入?

在充值时,如果会员恶意输入的话,会导致程序出现问题,

因此为了避免此问题,用正则表达式编写js方法,

1
2
3
4
5
6
7
js复制代码function clearNoNum(obj) {
obj.value = obj.value.replace(/[^\d.]/g, ""); //清除"数字"和"."以外的字符
obj.value = obj.value.replace(/^0/g, ""); //验证第一个字符不是0
obj.value = obj.value.replace(/^\./g, ""); //验证第一个字符是数字而不是.
obj.value = obj.value.replace(/\.{2,}/g, "."); //只保留第一个'.'清除多余的'.'
obj.value = obj.value.replace(".", "$#$").replace(/\./g, "").replace("$#$", "."); //保证'.'只出现一次'.'而不能出现两次以上
}

上述方法既不允许第一位是0,也不允许第一位是.,

在输入框的标签中调用即可,使用onkeyup属性,

1
html复制代码<input type="text" name="money" id="money" onkeyup="clearNoNum(money)">
  1. 关于前端批处理提交后端接收处理问题?

在进货管理中,为了方便管理员操作,提高效率,对进货进行批处理操作,这时就出现了两个问题,如何获取多组数据以及如何提交给后端,

如何获取多组数据?

因为内容是由 EL 表达式写的,因此就没用到表单,而且还用了 forEach,这才是问题的关键所在,所以如何获取多组数据出现了困难,

1
2
3
4
5
6
7
8
9
10
html复制代码<tbody>
<c:forEach var="book" items="${requestScope.get('bookList')}">
<tr>
<td><img src="${pageContext.request.contextPath}/book/readPhoto?id=${book.getBookID()}" width="40px" height="40px"></td>
<td>${book.getBookName()}</td>
<td><input type="text"></td>
<td><input type="text"></td>
</tr>
</c:forEach>
</tbody>

经过一番查阅,发现了一个重要方法 HTML DOM getElementsByClassName(),主要作用就是获取所有指定类名的元素,

1
js复制代码var x = document.getElementsByClassName("example");

什么意思呢,就是说只要 HTML 中的元素的 class 相同,那么都会被 x 获取,

那么根据其特性,我们只要将要获取的数据的所在元素起个 class 名即可,如下,

1
2
3
4
5
6
7
8
9
html复制代码<c:forEach var="book" items="${requestScope.get('bookList')}">
<tr>
<input type="text" class="bookID" value="${book.getBookID()}" hidden>
<td><img src="${pageContext.request.contextPath}/book/readPhoto?id=${book.getBookID()}" width="40px" height="40px"></td>
<td class="bookName">${book.getBookName()}</td>
<td><input class="addr" type="text"></td>
<td><input class="nums" type="text"></td>
</tr>
</c:forEach>

编写 js 进行获取数据,

1
2
3
4
js复制代码var bookID = document.getElementsByClassName("bookID");
var bookName = document.getElementsByClassName("bookName");
var addr = document.getElementsByClassName("addr");
var nums = document.getElementsByClassName("nums");

不过要注意的是,以上的 js 对象只是获得了元素对象,如果想获取元素里的值,则需要写上相对应的方法,

比如 <input> 标签就用 .value,而 <td> 标签则用 .innerHTML 来获取数据,

如何将多组值传给后端?

这么多组数据的话,如果一个一个传就显得很不方便,这时想着将他们全部合并成一个数组,类似于 Java 当中的List<..>,如下

1
2
3
4
5
6
7
8
9
10
js复制代码var list = [];
for (i = 0; i < bookID.length; i++) {
list.push({
bookID: bookID[i].value,
bookName: bookName[i].innerHTML,
counts: nums[i].value,
address: addr[i].value
})
console.log(list[i]);
}

前端使用 jquery 向后台传递数组类型的参数,Java 后台直接通过 List 类型接收,会发现无法取到参数,因此需要将其转化成 json,

先导入 jar 包,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
pom复制代码<!--json依赖-->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.9.9</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-core</artifactId>
<version>2.9.9</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-annotations</artifactId>
<version>2.9.9</version>
</dependency>

然后编写 ajax 进行提交,

1
2
3
4
5
6
7
8
9
10
js复制代码$.ajax({
cache: true,
type: "POST",
url: '/Manager/updateStocking',
// 指定请求的数据格式为json,实际上传的是json字符串
data: JSON.stringify(list),
//指定请求的数据格式为json,这样后台才能用@RequestBody 接受java bean
contentType: 'application/json;charset=utf-8',
async: false,
});

后端则需要用到 @ResponseBody 和 @RequestBody 来接收数据,

1
2
3
4
java复制代码@RequestMapping(value = "/updateStocking", method = RequestMethod.POST)
public void updateStocking(@RequestBody List<Stock_list> list) {
System.out.println(list);
}

@ResponseBody:

@ResponseBody 注解的作用是将 controller 的方法返回的对象通过适当的转换器转换为指定的格式之后,写入到 response 对象的 body 区,通常用来返回 JSON 数据或者是 XML 数据,需要注意的是,在使用此注解之后不会再走视图处理器,而是直接将数据写入到输入流中,他的效果等同于通过 response 对象输出指定格式的数据,

例子如下,两个方法是等价的,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
java复制代码@Controller
public class ResponseController {
@RequestMapping("/response")
public void response(HttpServletResponse response) throws IOException {
User user = new User();
user.setEmail("123@qq.com");
user.setId(001);
user.setPassword("******");
user.setUserName("tom");

response.getWriter().write(JSON.toJSON(user).toString());
}

@ResponseBody
@RequestMapping("/re")
public User response() {
User user = new User();
user.setEmail("123@qq.com");
user.setId(001);
user.setPassword("******");
user.setUserName("tom");
return user;
}
}

@RequestBody:

@RequestBody 主要用来接收前端传递给后端的 JSON 字符串中的数据的(请求体中的数据的),

GET方式无请求体,所以使用 @RequestBody接收数据时,前端不能使用GET方式提交数据,而是用POST方式进行提交,

在后端的同一个接收方法里,@RequestBody 与 @RequestParam() 可以同时使用,@RequestBody最多只能有一个,而@RequestParam()可以有多个,

注意:关于 ajax 的相关问题?

在用 ajax 的时候,会碰到ajax 将数据提交给 controller 方法且方法顺利执行之后, 界面却不跳转的情况,这里猜测其实是将值返回给了前端,而不是交给视图解析器了,因此,可以配合 ResponseBody 注解,

controller 返回参数,利用 @ResponseBody 返回给前端 JSON 格式,然后在 ajax 的 success 函数里面调用返回值,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
js复制代码$.ajax({
cache: true,
type: "POST",
url: '/Manager/updateStocking',
// 指定请求的数据格式为json,实际上传的是json字符串
data: JSON.stringify(list),
//指定请求的数据格式为json,这样后台才能用@RequestBody 接受java bean
contentType: 'application/json;charset=utf-8',
// dataType: "json",
async: false,
success: function (data,status){
if (data == "success"){
alert("进货成功!")
window.location.href="${pageContext.request.contextPath}/Manager/toReturnIndex"
} else {
alert("进货失败!")
history.back()
}
},
error : function(data,status) {
alert("数据上传失败: "+status);
}
});

同时这里要注意的是,不能使用 dataType: "json",不然会报 parsererror 的错误,因为 dataType: "json" 会试图将 controller 的返回值解析成 JSON ,但当返回值是一个字符串或者其他值时,它并不是一个真正的 JSON,解析器会解析失败的!

后记

这次项目实战令人受益匪浅,虽然在 debug 的过程中会令人烦躁,毕竟百度里的很多问题都是千篇一律的解决方案,可能发文的人压根不知道问题在哪,但最终还是慢慢给磨出来了,实践出真知,诚不欺我也!

本来是打算拓展这个项目跟移动端搞联动的,可是后来发现前后端存在耦合,没有完全分离,就暂时没法让移动端调用后端接口了,所以这个想法只能暂缓了,叹气…

关于这个项目还是可以继续修改和拓展的,欢迎大家在下方评论区留言讨论,如有不足,也请各位大佬指出!

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

jsPDF + html2canvas A4分页截断 完美解

发表于 2022-09-01

业务需求

网页html生成A4大小分页的pdf,翻遍了整个互联网发现没有很系统的整理与分析,甚至对jsPDF的解析也没有几篇。遇到过几次,用的比较多,完成代码编写后特此整理分析,自我记录。

业务难点

  1. 存在图片/组件/文字被分割的现象,即分页处理
  2. 包括页头、页脚、上下安全间隔的情况
  3. 富文本分页情况

处理思路

通过深度搜索优先遍历,从顶部遍历需要转换的HTML节点, 并将节点分为三种情况进行处理(1. 普通节点。2. 需要进行分页处理并且内部可能包含也需要分页处理子节点的节点。3. 需要进行分页内部不包含需要分页处理的节点,即深度搜索的终点节点),通过从高到低遍历维护一个分页数组pages,该数组记录每一页的起始位置,如:pages[0] 对应 第一页起始位置, pages[1] 对应 第二页起始位置

图解如下:

分页图解.png

通过深度遍历后得出每页起始位置的数组,遍历数组,通过jspdf的addImage接口对canvas进行画面截取,由于addImage只能固定位置的左上角起始点,不能进行非常精确的上下定位截取(下一节会详解addImage),会造成截取多余的内容(如上图页面1中pages[1] 下方的内容会和 页面2 中 pages[1] 下方的内容会一样(除长度外),而页面1中pages[1] 下方的内容是多余的(是属于页面2的内容))因此需要对页面不需要的内容 使用jspdf的addBlank进行空白遮挡处理。

jsPDF.addImage详解

官方文档链接addImage - Documentation (artskydj.github.io)

image.png

需要注意的点是坐标(x,y) 的取值, (x,y)对应的是添加图片的左上角取值,宽高则是根据转化成canvas的宽高取值,图解如下

image.png

因此在对一个长图片进行截取时,往往将y值设未负数,让需要截取图片的起始位置落于当前的pdf页面内,在当前案例下,每一页的图片摆放坐标y = -pages[i]

image.png

jsPDF.rect详解

文档链接 context2d - Documentation (artskydj.github.io)

image.png

该接口的参数 (x,y)坐标、宽高 与addImage接口的一致
当前pdf页需要的内容的高度为 pages[i] - pages[i-1], 除去顶部这个高度外以下的内容都是不需要的,因此得到每一页添加空白的y坐标值为- pages[i] - pages[i-1],高度h为一页pdf的高度(此处为A4页的高度) - pages[i] - pages[i-1],宽度为A4宽度,x为0, 图解如下:

image.png

深度优先遍历三种类型的节点

通过深度优先遍历操作,可以从高到低去遍历需要进行跨页判断的元素,检测是否跨页,并记录分页点,从而避免跨页问题。

1. 普通节点

当遍历到普通节点,即不需要进行分页判断的节点时,只需要进行 2步操作:

  1. 当前节点距离顶部的高度 - pages最后一位元素的值(即上一页的分界点)得出的差值是否 大于 页面的高度 , 如果大于,则证明当前节点已经跨页,进行操作pages.push(pages[pages.length - 1] + 一页PDF的高度)
  2. 对子节点进行深度遍历

2. 需要进行跨页判断,且内部也含有 可能跨页/需要进行跨页判断 的节点

当元素进行到该类型的节点时, 需要进行3步操作:

  1. 需要进行与普通节点第一步相同的判断
  2. (检测当前节点距离顶部的距离 + 节点自身的高度) 是否大于 (pages 最后一位元素(即当前页 顶部位置) + 一页PDF的高度(当前指A4的高度))

如果条件为真,则证明该节点属于跨页元素,距离页面顶部距离的值top 是分页点,往pages中
push top

  1. 且由于内部还存在需要进行跨页检测的节点,因此需要对子节点进行深度遍历

3. 需要进行跨页判断,但内部不含有可能跨页/需要进行跨页判断 的节点, 即深度终点

该节点只需要进行 内部含有可能跨页/需要进行跨页判断 的节点 的第一第二步操作, 由于内部不再含有,因此不需要遍历子节点,为搜索的叶子节点。

html2Canvas生成图片模糊导致导出的PDF也模糊的问题

通过 scale 参数, 对canvas进行等比放大,可以使canvas生成的图片更清晰。

代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
js复制代码// 将元素转化为canvas元素
// 通过 放大 提高清晰度
// width为内容宽度
async function toCanvas(element, width) {
// canvas元素
const canvas = await html2canvas(element, {
// allowTaint: true, // 允许渲染跨域图片
scale: window.devicePixelRatio * 3 // 增加清晰度
});
// 获取canavs转化后的宽度
const canvasWidth = canvas.width;
// 获取canvas转化后的高度
const canvasHeight = canvas.height;
// 高度转化为PDF的高度
const height = (width / canvasWidth) * canvasHeight;
// 转化成图片Data
const canvasData = canvas.toDataURL('image/jpeg', 1.0);
//console.log(canvasData)
return { width, height, data: canvasData };
}

样例及代码

gitee仓库: output_pdf_demo: jsPDF + html2canvas , 网页HTML导出A4格式PDF 处理分页切割问题 (gitee.com)

npm install & npm run serve 即可运行

image.png

分页效果:

富文本分页:

image.png

table行分页:

image.png

组件分页:

image.png

样例注意事项

样例比上述讲的情况内,引入了页眉、页脚、还有上下左右间距的情况,图解如下:

image.png

需要做的额外处理:

  1. 图片摆放的Y坐标由原来的-pages[i] 变成了 baseY + 页头元素高度 - pages[i]
  2. 中间实际内容部分与页眉/页脚之间的边距也需要进行遮白处理
  3. 内容的高度才为PDF页面的实际高度,判断分页的依据应该以内容高度为准
  4. 富文本文字的分页处理

核心代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
js复制代码import jsPDF from 'jspdf';
import html2canvas from 'html2canvas';
import { message } from 'ant-design-vue';
const A4_WIDTH = 592.28;
const A4_HEIGHT = 841.89;
// 将元素转化为canvas元素
// 通过 放大 提高清晰度
// width为内容宽度
async function toCanvas(element, width) {
// canvas元素
const canvas = await html2canvas(element, {
// allowTaint: true, // 允许渲染跨域图片
scale: window.devicePixelRatio * 2 // 增加清晰度
});
// 获取canavs转化后的宽度
const canvasWidth = canvas.width;
// 获取canvas转化后的高度
const canvasHeight = canvas.height;
// 高度转化为PDF的高度
const height = (width / canvasWidth) * canvasHeight;
// 转化成图片Data
const canvasData = canvas.toDataURL('image/jpeg', 1.0);
//console.log(canvasData)
return { width, height, data: canvasData };
}
/**
* 生成pdf(A4多页pdf截断问题, 包括页眉、页脚 和 上下左右留空的护理)
* @param {Object} param
* @param {HTMLElement} param.element - 需要转换的dom根节点
* @param {number} [param.contentWidth=550] - 一页pdf的内容宽度,0-592.28
* @param {string} [param.filename='document.pdf'] - pdf文件名
* @param {HTMLElement} param.header - 页眉dom元素
* @param {HTMLElement} param.footer - 页脚dom元素
*/
export async function outputPDF({ element, contentWidth = 550,
footer, header, filename = "测试A4分页.pdf" }) {
if (!(element instanceof HTMLElement)) {
return;
}
// jsPDFs实例
const pdf = new jsPDF({
unit: 'pt',
format: 'a4',
orientation: 'p',
});

// 一页的高度, 转换宽度为一页元素的宽度
const { width, height, data } = await toCanvas(element, contentWidth);

// 添加页脚
async function addHeader(header, pdf, contentWidth) {
const { height: headerHeight, data: headerData, width: hWidth } = await toCanvas(header, contentWidth);
pdf.addImage(headerData, 'JPEG', 0, 0, contentWidth, headerHeight);
}

// 添加页眉
async function addFooter(pageNum, now, footer, pdf, contentWidth) {
const newFooter = footer.cloneNode(true);
newFooter.querySelector('.pdf-footer-page').innerText = now;
newFooter.querySelector('.pdf-footer-page-count').innerText = pageNum;
document.documentElement.append(newFooter);
const { height: footerHeight, data: footerData, width: fWidth } = await toCanvas(newFooter, contentWidth);
pdf.addImage(footerData, 'JPEG', 0, A4_HEIGHT - footerHeight, contentWidth, footerHeight)

}

// 添加
function addImage(_x, _y, pdf, data, width, height) {
pdf.addImage(data, 'JPEG', _x, _y, width, height);
}

// 增加空白遮挡
function addBlank(x, y, width, height, pdf) {
pdf.setFillColor(255, 255, 255);
pdf.rect(x, y, Math.ceil(width), Math.ceil(height), 'F');
};

// 页脚元素 经过转换后在PDF页面的高度
const { height: tfooterHeight } = await toCanvas(footer, contentWidth)

// 页眉元素 经过转换后在PDF的高度
const { height: theaderHeight } = await toCanvas(header, contentWidth);

// 距离PDF左边的距离,/ 2 表示居中
const baseX = (A4_WIDTH - contentWidth) / 2; // 预留空间给左边
// 距离PDF 页眉和页脚的间距, 留白留空
const baseY = 15;

// 出去页头、页眉、还有内容与两者之间的间距后 每页内容的实际高度
const originalPageHeight = (A4_HEIGHT - tfooterHeight - theaderHeight - 2 * baseY);

// 元素在网页页面的宽度
const elementWidth = element.offsetWidth;

// PDF内容宽度 和 在HTML中宽度 的比, 用于将 元素在网页的高度 转化为 PDF内容内的高度, 将 元素距离网页顶部的高度 转化为 距离Canvas顶部的高度
const rate = contentWidth / elementWidth

// 每一页的分页坐标, PDF高度, 初始值为根元素距离顶部的距离
const pages = [rate * getElementTop(element)];

// 获取元素距离网页顶部的距离
// 通过遍历offsetParant获取距离顶端元素的高度值
function getElementTop(element) {
let actualTop = element.offsetTop;
let current = element.offsetParent;

while (current && current !== null) {
actualTop += current.offsetTop;
current = current.offsetParent;
}
return actualTop;
}



// 遍历正常的元素节点
function traversingNodes(nodes) {
for (let i = 0; i < nodes.length; ++i) {
const one = nodes[i];
// 需要判断跨页且内部存在跨页的元素
const isDivideInside = one.classList && one.classList.contains('divide-inside');
// 图片元素不需要继续深入,作为深度终点
const isIMG = one.tagName === 'IMG';
// table的每一行元素也是深度终点
const isTableCol = one.classList && ((one.classList.contains('ant-table-row')));
// 特殊的富文本元素
const isEditor = one.classList && (one.classList.contains('editor'));
// 对需要处理分页的元素,计算是否跨界,若跨界,则直接将顶部位置作为分页位置,进行分页,且子元素不需要再进行判断
let { offsetHeight } = one;
// 计算出最终高度
let offsetTop = getElementTop(one);

// dom转换后距离顶部的高度
// 转换成canvas高度
const top = rate * (offsetTop)

// 对于需要进行分页且内部存在需要分页(即不属于深度终点)的元素进行处理
if (isDivideInside) {
// 执行位置更新操作
updatePos(rate * offsetHeight, top, one);
// 执行深度遍历操作
traversingNodes(one.childNodes);
}
// 对于深度终点元素进行处理
else if (isTableCol || isIMG) {
// dom高度转换成生成pdf的实际高度
// 代码不考虑dom定位、边距、边框等因素,需在dom里自行考虑,如将box-sizing设置为border-box
updatePos(rate * offsetHeight, top, one);
}
else if (isEditor) {
// 执行位置更新操作
updatePos(rate * offsetHeight, top, one);
// 遍历富文本节点
traversingEditor(one.childNodes)
}
// 对于普通元素,则判断是否高度超过分页值,并且深入
else {
// 执行位置更新操作
updateNomalElPos(top)
// 遍历子节点
traversingNodes(one.childNodes);
}
}
return;
}

// 对于富文本元素,观察所得段落之间都是以<p> / <img> 元素相隔,因此不需要进行深度遍历 (仅针对个人遇到的情况)
function traversingEditor(nodes) {
// 遍历子节点
for (let i = 0; i < nodes.length; ++i) {
const one = nodes[i];
let { offsetHeight } = one;
let offsetTop = getElementTop(one);
const top = contentWidth / elementWidth * (offsetTop)
updatePos(contentWidth / elementWidth * offsetHeight, top, one);
}
}

// 普通元素更新位置的方法
// 普通元素只需要考虑到是否到达了分页点,即当前距离顶部高度 - 上一个分页点的高度 大于 正常一页的高度,则需要载入分页点
function updateNomalElPos(top) {
if (top - (pages.length > 0 ? pages[pages.length - 1] : 0) > originalPageHeight) {
pages.push((pages.length > 0 ? pages[pages.length - 1] : 0) + originalPageHeight);
}
}

// 可能跨页元素位置更新的方法
// 需要考虑分页元素,则需要考虑两种情况
// 1. 普通达顶情况,如上
// 2. 当前距离顶部高度加上元素自身高度 大于 整页高度,则需要载入一个分页点
function updatePos(eheight, top) {
// 如果高度已经超过当前页,则证明可以分页了
if (top - (pages.length > 0 ? pages[pages.length - 1] : 0) >= originalPageHeight) {
pages.push((pages.length > 0 ? pages[pages.length - 1] : 0) + originalPageHeight);
}
// 若 距离当前页顶部的高度 加上元素自身的高度 大于 一页内容的高度, 则证明元素跨页,将当前高度作为分页位置
else if ((top + eheight - (pages.length > 0 ? pages[pages.length - 1] : 0) > originalPageHeight) && (top != (pages.length > 0 ? pages[pages.length - 1] : 0))) {
pages.push(top);
}
}

// 深度遍历节点的方法
traversingNodes(element.childNodes);
// 可能会存在遍历到底部元素为深度节点,可能存在最后一页位置未截取到的情况
if (pages[pages.length - 1] + originalPageHeight < height) {
pages.push(pages[pages.length - 1] + originalPageHeight);
}
//console.log({ pages, contentWidth, width,height })



// 根据分页位置 开始分页
for (let i = 0; i < pages.length; ++i) {
message.success(`共${pages.length}页, 生成第${i + 1}页`)
// 根据分页位置新增图片
addImage(baseX, baseY + theaderHeight - pages[i], pdf, data, width, height);
// 将 内容 与 页眉之间留空留白的部分进行遮白处理
addBlank(0, theaderHeight, A4_WIDTH, baseY, pdf);
// 将 内容 与 页脚之间留空留白的部分进行遮白处理
addBlank(0, A4_HEIGHT - baseY - tfooterHeight, A4_WIDTH, baseY, pdf);
// 对于除最后一页外,对 内容 的多余部分进行遮白处理
if (i < pages.length - 1) {
// 获取当前页面需要的内容部分高度
const imageHeight = pages[i + 1] - pages[i];
// 对多余的内容部分进行遮白
addBlank(0, baseY + imageHeight + theaderHeight, A4_WIDTH, A4_HEIGHT - (imageHeight), pdf);
}
// 添加页眉
await addHeader(header, pdf, A4_WIDTH)
// 添加页脚
await addFooter(pages.length, i + 1, footer, pdf, A4_WIDTH);

// 若不是最后一页,则分页
if (i !== pages.length - 1) {
// 增加分页
pdf.addPage();
}
}
return pdf.save(filename)
}

参考文档及博文

jsPDF - Documentation (artskydj.github.io)

配置型 | HTML2CANVAS 中文文档 (allenchinese.github.io)

【原创】jspdf+html2canvas生成多页pdf防截断处理 - 简书 (jianshu.com)

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Dart 218 发布,Objective-C 和 Swi

发表于 2022-08-31

原文链接: medium.com/dartlang/da…

Dart 2.18 版本开始提供与 Objective-C 和 Swift 交互的能力预览,以及在这基础上构建的新 iOS / macOS 包支持。

Dart 2.18 还包含对通用函数的类型推断改进、异步代码的性能改进、新的 pub.dev 功能支持以及对工具和核心库的整理。

最后,还有最新的 null safety 迁移状态解析,以及通往完全 null safety 的重要路线图更新。

Dart 支持与 Objective-C 和 Swift 交互的能力

在 2020 年的时候我们预览了用于调用原生 C API 的 Dart 外函数接口(FFI),并于 2021 年 3 月在 Dart 2.12 中发布了它。

自该版本发布以来,大量软件包利用此功能与现有的原生C API集成,例如: file_picker、printing、win32、objectbox、realm、isar、tflite_flutter和 dbus 等。

Dart 团队希望支持所运行平台上所有主要语言的交互能力,而 Dart 2.18达到了实现这一目标的下一个里程碑。

在 2.18, Dart 代码可以调用 Objective-C 和 Swift 代码,这通常用于调用 macOS 和 iOS 平台上的API,Dart在任何应用中都支持这种互操作机制,从CLI 应用到后端代码和 Flutter UI。

这种新机制其实是利用了 Objective-C 和 Swift 代码可以基于 API 绑定 C 代码公开,Dart API 包装了生成工具 ffigen ,可以从 API 标头创建这些绑定。

使用Objective-C的时区示例

macOS 有一个 API 可用于查询 NSTimeZone 上公开的时区信息,开发者可以查询该 API 以了解用户为其设备配置的时区和 UTC 时区偏移量。

以下示例中 Objective-C 使用此时区 API 获取系统时区和GMT偏移量:

1
2
3
4
5
6
7
8
9
10
objectivec复制代码#import <Foundation/Foundation.h>

int main(int argc, const char * argv[]) {
   @autoreleasepool {
       NSTimeZone *timezone = [NSTimeZone systemTimeZone]; // Get current time zone.
       NSLog(@"Timezone name: %@", timezone.name);
       NSLog(@"Timezone offset GMT: %ld hours", timezone.secondsFromGMT/60/60);
  }
   return 0;
}

这里导入了 Foundation.h,其中包含 Apple Foundation 库的 API headers。

接下来,在 main 方法中,它从 NSTimeZone 类调用了 systemTimeZone 方法,此方法返回设备上选定时区的 NSTimeZone 实例。

最后,应用向控制台输出两行结果,其中包含时区名称和UTC偏移量(以小时为单位)。

如果运行此程序,它应该会返回类似于以下内容的东西,具体取决于开发者的位置:

1
2
yaml复制代码Timezone name: Europe/Copenhagen
Timezone offset GMT: 2 hours

使用 Dart 的时区示例

让我们使用新的 Dart 与 Objective-C 一起重新实现上面的结果。

首先创建一个新的 Dart CLI :

1
lua复制代码$ dart create timezones

然后编辑 pubspec文件以包含 ffigen 配置,配置指向头文件,并列出了哪些 Objective-C 接口应该生成包装器:

1
2
3
4
5
6
7
8
9
10
11
12
13
vbnet复制代码
ffigen:
name: TimeZoneLibrary
language: objc
output: "foundation_bindings.dart"
exclude-all-by-default: true
objc-interfaces:
  include:
    - "NSTimeZone"
headers:
  entry-points:
    - "/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/System/Library/Frameworks/Foundation.framework/
        Headers/NSTimeZone.h"

这就为 NSTimeZone.h 中的 headers 选择 Objective-C 绑定,并仅包括NSTimeZone接口中的API,要生成 wrappers, 可以允行 ffigen:

1
arduino复制代码$ dart run ffigen

该命令会创建一个新文件 foundation_bindings.dart,其中包含一堆生成的API绑定,使用该绑定文件,就可以编写 Dart main 方法,此方法镜像Objective-C 代码:

1
2
3
4
5
6
7
8
9
10
11
dart复制代码void main(List<String> args) async {
 const dylibPath =
     '/System/Library/Frameworks/Foundation.framework/Versions/Current/Foundation';
 final lib = TimeZoneLibrary(DynamicLibrary.open(dylibPath));

 final timeZone = NSTimeZone.getLocalTimeZone(lib);
 if (timeZone != null) {
   print('Timezone name: ${timeZone.name}');
   print('Offset from GMT: ${timeZone.secondsFromGMT / 60 / 60} hours');
}
}

就这样,从 Dart 2.18 开始,这种新的支持在实验状态下可用,该能力增强了Dart 的交互支持,以直接调用 macOS 和 iOS API 支持。

并且这也反向补充了 Flutter 的插件,提供了允许开发者直接从 Dart 代码调用macOS 和 iOS API 的能力。

要了解有关这种互操作性的更多信息,请参阅 Objective-C 和 Swift 交互指南。

特定于平台的http库

Dart 里包括一个通用的多平台http库,该库允许开着编写代码而无需考虑平台细节,但是有时候开发者可能希望编写特定于特定 native 平台的 网络 API的代码,例如:苹果的网络 库NSURLSession允许指定仅限 WiFi 或 VPN的网络。

为了支持这些用例,我们为 macOS 和 iOS 平台创建了一个新的网络包 cupertino_http,该能力建立在上一节中提到的 Objective-C 直接交互的基础上。

Cupertino http library 示例

以下示例将 Flutter 的 http 客户端设置为在其他平台上使用 cupertino_http库,以及 dart:io 下的 http 库:

1
2
3
4
5
6
7
8
9
10
ini复制代码
late Client client;
if (Platform.isIOS || Platform.isMacOS) {
 final config = URLSessionConfiguration.ephemeralSessionConfiguration()
  ..allowsCellularAccess = false
  ..allowsExpensiveNetworkAccess = false;
 client = CupertinoClient.fromSessionConfiguration(config);
} else {
 client = Client(); // Uses an HTTP client based on dart:io
}

初始配置后,应用会对特定客户端进行后续网络调用,例如 http get() 请求现在类似于以下内容:

1
2
3
4
5
6
7
dart复制代码final response = await get(
 Uri.https(
   'www.googleapis.com',
   '/books/v1/volumes',
  {'q': 'HTTP', 'maxResults': '40', 'printType': 'books'},
),
);

当开发者无法使用通用客户端接口时,就可以直接使用 cupertino_http 库调用苹果的网络API:

1
2
3
4
5
6
7
8
9
ini复制代码final session = URLSession.sessionWithConfiguration(
URLSessionConfiguration.backgroundSession('com.example.bgdownload'),
onFinishedDownloading: (s, t, fileUri) {
actualContent = File.fromUri(fileUri).readAsStringSync();
});

final task = session.downloadTaskWithRequest(
URLRequest.fromUrl(Uri.https(...))
..resume();

多平台应用程序中特定于平台的网络

在设计该功能时,目标仍然是使应用尽支持更多的平台,为了实现这个目标,我们为基本的 http 操作保留了通用的多平台 http API 集,并允许为每个平台配置要使用的网络库。

package:http 将需要编写的特定于平台的代码量降至最低,此 API 可以按平台配置,但以独立于平台的方式使用。

Dart 2.18 提供了对两个对于 package:http 特定于平台的 http 库的实验性支持:

  • cupertino_http 基于 NSURLSession 的 macOS/iOS 支持。
  • cronet_http基于 Cronet,Android 上流行的网络库支持。

将一个通用客户端 API 与多个 HTTP 实现相结合,以获得特定于平台的行为,同时仍然从所有平台的一组共享源中维护应用。

改进的类型推断

Dart 使用了许多通用函数,例如 fold方法,它将元素集合减少为单个值,如计算整数列表的总和:

1
2
3
ini复制代码List<int> numbers = [1, 2, 3];
final sum = numbers.fold(0, (x, y) => x + y);
print(‘The sum of $numbers is $sum’);

对于 Dart 2.17 或更早版本,这个方法返回类型错误:

1
arduino复制代码line 2 • The operator ‘+’ can’t be unconditionally invoked because the receiver can be ‘null’.

Dart 2.18 改进了类型推断,前面的示例通过了静态分析,可以推断出 x 和 y 都是不可为空的整数,此更改允许开发者编写更简洁的 Dart 代码,同时保留强推断类型的完整可靠性属性。

异步性能改进

此版本的 Dart 改进了 Dart VM 应用 async 方法和 async*/sync*生成器功能的方式。

这减少了代码大小,在两个大型内部 Google 应用程序中,我们看到 AOT 快照大小减少了约 10%,还可以看到微基准测试的性能有所提高。

这些变化包括额外的小行为变化;要了解更多信息,请参阅更改日志。

pub.dev 改进

结合 2.18 版本,我们对 pub.dev包 存储库进行了两项更改。

个人业余时间通过 pub.dev 维护和发布的可能会产生一些时间上的投入,为了促进赞助,我们现在在 中支持一个新 funding 标签,pubspec包发布者可以使用该标签列出指向一种或多种赞助包的方式的链接。然后这些链接显示pub.dev在侧边栏中:

要了解更多信息,请参阅pubspec文档。

此外,我们希望鼓励丰富的开源软件包生态系统,为了突出这一点,自动包评分对使用 OSI 批准的许可证 的 pub.dev包额外奖励 10 分。

一些重大变化

Dart 非常注重简单和易学的能力,在添加新功能时,我们一直在努力保持谨慎的平衡。

保持简单的一种方法是删除历史功能和 API,Dart 2.18 清理了此类别中的项目,包括一些较小的重大更改:

  • 我们早在 2020 年 10 月就添加了统一的 dart CLI 开发人员工具,在 2.18 中我们完成了过渡。此版本删除了最后两个已弃用的工具 dart2js (use dart compile js) 和 dartanalyzer (use dart analyze)。
  • 随着语言版本控制的引入,pub生成了一个新的解析文件:.dart_tool/package_config.json 。 之前的 .packages 文件使用了一种不能包含版本的格式,而现在我们停止使用 .packages文件,如果你有任何.packages文件,现在可以删除它们了。
  • 不能使用未扩展的类的混合 Object(重大更改#48167)。
  • dart:io 的 RedirectException的 uri 属性已更改为可为空(重大更改#49045)。
  • dart:io遵循 SCREAMING_SNAKE 约定的网络 API 中的常量已被删除(重大更改# 34218;以前已弃用),请改用相应的 lowerCamelCase 常量。
  • Dart VM 在退出时不再恢复初始终端设置,更改 Stdin 设置 lineMode 的 echoMode 现在负责在程序退出时恢复设置(重大更改#45630)。

空安全更新

自 2020 年 11 月发布测试版和 2021 年 3 月发布 Dart 2.12 以来,我们很高兴看到 null 安全性的广泛使用。

首先,大多数流行包的开发人员都在 pub.dev 迁移到了零安全性,分析表明,100% 的前 250 个和 98% 的前 1000 个最常用的包支持零安全。

其次,大多数应用开发人员在具有完全空安全迁移的代码库中工作,这是至关重要的条件,在迁移所有代码和所有依赖项(包括传递性)之前, Dart 健全的 null safety 不会发挥作用。

下图显示了 flutter run 在引入零安全和没有引起之间的对比,随着应用开始迁移到零安全,开发人员进行了部分迁移,但仍存在部分内容未迁移到 null safety。

随着时间的推移可以看到, null safety 使用在健康地增长。到上月底,与不使用 null safety 相比, null safety 多出四倍,所以我们希望,在接下来的几个季度中,我们将看到 100% 的可靠零安全方法。

重要的零安全路线图更新

同时支持空安全和非空安全会增加开销和复杂性。

首先,Dart 开发者需要学习和理解这两种模式,每当阅读一段 Dart 代码时,检查语言版本以查看类型是否默认为非空(Dart 2.12 及更高版本)或默认可空(Dart 2.11 及更早版本)。

其次,在我们的编译器和运行时同时支持这两种模式会减慢 Dart SDK 的发展以支持新功能。

基于非空安全的开销和上一节中提到的非常积极的采用数字,我们的目标是过渡到仅支持可靠的空值安全,并停止非空值安全和不健全的空值安全模式,我们暂时将其定于 2023 年年中发布。

这将意味着停止对 Dart 2.11 及更早版本的支持,具有低于 2.12 的 SDK 约束的 Pubspec 文件将不再在 Dart 3 及更高版本中解析。

在包含语言标记的源代码中,如果设置为小于 2.12(例如// @dart=2.9)也会失败。

如果已迁移到可靠的 null 安全性,那么你的代码将在 Dart 3 中以完全的 null 安全性工作,如果还没有,请立即迁移!

要了解有关这些更改的更多信息,请参阅此 GitHub 问题。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

美团组件化事件总线方案改进:ModularEventBus

发表于 2022-08-30

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第11天,点击查看活动详情

请点赞关注,你的支持对我意义重大。

🔥 Hi,我是小彭。本文已收录到 GitHub · AndroidFamily 中。这里有 Android 进阶成长知识体系,有志同道合的朋友,关注公众号 [彭旭锐] 带你建立核心竞争力。

前言

大家好,我是小彭。2 年前,我们在 为了组件化改造学习十几家大厂的技术博客 这篇文章里收集过各大厂的组件化方案。其中,有美团收银团队分享的组件化总线框架 modular-event 让我们印象深刻。然而,美团并未将该框架开源,我们只能望梅止渴。

在学习和借鉴美团 modular-event 方案中很多优秀的设计思想后,我亦发现方案中依然存在不一致风险和不足,故我决定对方案进行改进并向社区开源。项目主页为 Github · ModularEventBus,演示 Demo 可直接下载:
Demo apk。

欢迎提 Issue 帮助修复缺陷,欢迎提 Pull Request 增加新的 Feature,有用请点赞给 Star,给小彭一点创作的动力,谢谢。


这篇文章是 组件化系列文章第 5 篇,相关 Android 工程化专栏完整文章列表:

一、Gradle 基础:

  • 1、Gradle 基础 :Wrapper、Groovy、生命周期、Project、Task、增量
  • 2、Gradle 插件:Plugin、Extension 扩展、NamedDomainObjectContainer、调试
  • 3、Gradle 依赖管理
  • 4、Maven 发布:SHAPSHOT 快照、uploadArchives、Nexus、AAR
  • 5、Gradle 插件案例:EasyPrivacy、so 文件适配 64 位架构、ABI

二、AGP 插件:

  • 1、AGP 构建过程
  • 2、AGP 常用配置项:Manifest、BuildConfig、buildTypes、壳工程、环境切换
  • 3、APG Transform:AOP、TransformTask、增量、字节码、Dex
  • 4、AGP 代码混淆:ProGuard、R8、Optimize、Keep、组件化
  • 5、APK 签名:认证、完整性、v1、v2、v3、Zip、Wallet
  • 6、AGP 案例:多渠道打包

三、组件化开发:

  • 1、方案积累:有赞、蘑菇街、得到、携程、支付宝、手淘、爱奇艺、微信、美团
  • 2、组件化架构基础
  • 3、ARouter 源码分析
  • 4、组件化案例:通用方案
  • 5、组件化案例:组件化事件总线框架(本文)
  • 6、组件化案例:组件化 Key-Value 框架

四、AOP 面向切面编程:

  • 1、AOP 基础
  • 2、Java 注解
  • 3、Java 注解处理器:APT、javac
  • 4、Java 动态代理:代理模式、Proxy、字节码
  • 5、Java ServiceLoader:服务发现、SPI、META-INF
  • 6、AspectJ 框架:Transform
  • 7、Javassist 框架
  • 8、ASM 框架
  • 9、AspectJ 案例:限制按钮点击抖动

五、相关计算机基础

  • 1、Base64 编码
  • 2、安全传输:加密、摘要、签名、CA 证书、防窃听、完整性、认证

  1. 认识事件总线

1.1 事件总线的优点

事件总线框架最大的优点是 ”解耦“,即事件发布者与事件订阅者的解耦,事件的发布者不需要关心是否有人订阅该事件,也不需要关心是谁订阅该事件,代码耦合度较低。因此,事件总线框架更适合作为全局的事件通信方案,或者组件间通信的辅助方案。

1.2 事件总线的缺点

然而,成也萧何败萧何。有人觉得事件总线好用,亦有人觉得事件总线不好用,归根结底还是因为事件总线太容易被滥用了,用时一时爽,维护火葬场。我将事件总线框架存在的问题概括为以下 5 种常见问题:

  • 1、消息难溯源: 在阅读源码的过程中,如果需要查找发布事件或订阅事件的地方,只能通过查找事件引用的方式进行溯源,增大了理解代码逻辑的难度。特别是当项目中到处是临时事件时,难度会大大增加;
  • 2、临时事件滥用: 由于框架对事件定义没有强制约束,开发者可以随意地在项目的各个角落定义事件。导致整个项目都是临时事件飞来飞去,增大后期维护的难度;
  • 3、数据类型转换错误: LiveDataBus 等事件总线框架需要开发者手动输入事件数据类型,当订阅方与发送方使用不同的数据类型时,会发生类型转换错误。在发生事件命名冲突时,出错的概率会大大增加,存在隐患;
  • 4、事件命名重复: 由于框架对事件命名没有强制约束,不同组件有可能定义重名的事件,产生逻辑错误。如果重名的事件还使用了不同的数据类型,还会出现类型转换错误,存在隐患;
  • 5、事件命名疏忽: 与 ”事件命名重复“ 类似,由于框架对事件命名没有检查,有可能出现开发者复制粘贴后忘记修改事件变量值的问题,或者变量值拼写错误(例如 login_success 拼写为 login_succese),那么订阅方将永远收不到事件。

1.3 ModularEventBus 的解决方案

ModularEventBus 组件化事件总线框架的优点是: 在保持发布者与订阅者的解耦的优势下,解决上述事件总线框架中存在的通病。 具体通过以下 5 个手段实现:

  • 1、事件声明聚合: 发布者和订阅者只能使用预定义的事件,严格禁止使用临时事件,事件需要按照约定聚合定义在一个文件中(解决临时事件滥用问题);
  • 2、区分不同组件的同名事件: 在定义事件时需要指定事件所属 moduleName,框架自动使用 "[moduleName]$$[eventName]" 作为最终的事件名(解决事件命名重复问题);
  • 3、事件数据类型声明: 在定义事件时需要指定事件的数据类型,框架自动使用该数据类型发送和订阅事件(解决数据类型转换错误问题);
  • 4、接口强约束: 运行时使用事件类发布和订阅事件,框架自动使用事件定义的事件名和数据类型,而不需要手动输入事件名和数据类型(解决事件命名命名错误);
  • 5、APT 生成接口类: 框架在编译时使用 APT 注解处理器自动生成事件接口类。

1.4 与美团 modular-event 对比有哪些什么不同?

  • modular-event 使用静态常量定义事件,为什么 ModularEventBus 用接口定义事件?

美团 modular-event 使用常量引入了重复信息,存在不一致风险。例如开发者复制一行常量后,只修改常量名但忘记修改值,这种错误往往很难被发现。而 ModularEventBus 使用方法名作为事件名,方法返回值作为事件数据类型,不会引入重复信息且更加简洁。

modular-event 事件定义

  • modular-event 使用动态代理,为什么 ModularEventBus 不需要?

美团 modular-event 使用动态代理 API 统一接管了事件的发布和订阅,但考虑到这部分代理逻辑非常简单(获取事件名并交给 LiveDataBus 完成后续的发布和订阅逻辑),且框架本身已经引入了编译时 APT 技术,完全可以在编译时生成这部分代理逻辑,没必要使用动态代理 API。

  • 更多特性支持:

此外 ModularEventBus 还支持生成事件文档、空数据拦截、泛型事件、自动清除空闲事件等特性。


  1. ModularEventBus 能做什么?

ModularEventBus 是一款帮助 Android App 解决事件总线滥用问题的框架,亦可作为组件化基础设施。 其解决方案是通过注解定义事件,由编译时 APT 注解处理器进行合法性检查和自动生成事件接口,以实现对事件定义、发布和订阅的强约束。

2.1 常见事件总线框架对比

以下从多个维度对比常见的事件总线框架( ✅ 良好支持、✔️ 支持、❌ 不支持):

事件总线 ModularEventBus modular-event SmartEventBus LiveEventBus LiveDataBus EventBus RxBus
开发者 @彭旭锐 @美团 @JeremyLiao @JeremyLiao / @greenrobot /
Github Star 0 未开源 146 3.4k / 24.1k /
生成事件文档 ✅ ❌ ❌ ❌ ❌ ❌ ❌
空数据拦截 ✅ ❌ ❌ ❌ ❌ ❌ ❌
无数据事件 ✔️ ❌ ❌ ❌ ❌ ❌ ❌
泛型事件 ✅ ❌ ✔️ ✔️ ❌ ❌ ❌
自动清除空闲事件 ✅ ❌ ✅ ✅ ❌ ❌ ❌
事件强约束 ✅ ✔️ ✔️ ❌ ❌ ❌ ❌
生命周期感知 ✅ ✅ ✅ ✅ ✅ ❌ ❌
延迟发送事件 ✅ ✅ ✅ ✅ ✅ ❌ ❌
有序接收事件 ✅ ✅ ✅ ✅ ✅ ✅ ✅
订阅 Sticky 事件 ✅ ✅ ✅ ✅ ✅ ✅ ✅
清除 Sticky 事件 ❌ ❌ ❌ ❌ ❌ ✅ ✅
移除事件 ✅ ❌ ❌ ❌ ❌ ✅ ✅
线程调度 ❌ ❌ ❌ ❌ ❌ ✅ ✅
跨进程 / 跨 App ❌(可支持) ❌ ✅ ✅ ❌ ❌ ❌
关键原理 APT+静态代理 APT+动态代理 APT+静态代理 LiveData LiveData APT RxJava

2.2 ModularEventBus 特性一览

1、事件强约束

✅ 支持零配置快速使用;

✅ 支持 APT 注解处理器自动生成事件接口类;

✅ 支持编译时合法性校验和警告提示;

✅ 支持生成事件文档;

✅ 支持增量编译;

2、Lifecycle 生命周期感知

✅ 内置基于 LiveData 的 LiveDataBus;

✅ 支持自动取消订阅,避免内存泄漏;

✅ 支持安全地发送事件与接收事件,避免产生空指针异常或不必要的性能损耗;

✅ 支持永久订阅事件;

✅ 支持自动清除没有关联订阅者的空闲 LiveData 以释放内存;

3、更多特性支持

✅ 支持 Java / Kotlin;

✅ 支持 AndroidX;

✅ 支持订阅 Sticky 粘性事件,支持移除事件;

✅ 支持 Generic 泛型事件,如 List<String> 事件;

✅ 支持拦截空数据;

✅ 支持只发布事件不携带数据的无数据事件;

✅ 支持延迟发送事件;

✅ 支持有序接收事件。


  1. ModularEventBus 快速使用

  • 1、添加依赖

模块级 build.gradle

1
2
3
4
5
6
7
8
9
10
11
gradle复制代码plugins {
id 'com.android.application' // 或 id 'com.android.library'
id 'org.jetbrains.kotlin.android'
id 'kotlin-kapt'
}
dependencies {
// 替换成最新版本
implementation 'io.github.pengxurui:modular-eventbus-api:1.0.4'
kapt 'io.github.pengxurui:modular-eventbus-compiler:1.0.4'
...
}
  • 2、定义事件数据类型(可选): 定义事件关联的数据类型,对于只发布事件而不需要携带数据的场景,可以不定义事件类型。

UserInfo.kt

1
kotlin复制代码data class UserInfo(val userName: String)
  • 3、定义事件: 使用接口定义事件名和事件数据类型,并使用 @EventGroup 注解修饰该接口:

LoginEvents.kt

1
2
3
4
5
6
7
8
9
10
kotlin复制代码@EventGroup
interface LoginEvents {

// 事件名:login
// 事件数据类型:UserInfo
fun login(): UserInfo

// 事件名:logout
fun logout()
}
  • 4、执行注解处理器: 执行 Make Project 或 Rebuild Project 等多种方式都可以触发注解处理器,处理器将根据事件定义自动生成相应的事件接口。例如,LoginEvents 对应的事件类为:

EventDefineOfLoginEvents.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
kotlin复制代码/**
* Auto generate code, do not modify!!!
* @see com.pengxr.sampleloginlib.events.LoginEvents
*/
@SuppressWarnings("unchecked")
public class EventDefineOfLoginEvents implements IEventGroup {
private EventDefineOfLoginEvents() {
}

public static IEvent<UserInfo> login() {
return (IEvent<UserInfo>) (ModularEventBus.INSTANCE.createObservable("com.pengxr.sampleloginlib.events.LoginEvents$$login", UserInfo.class, false, true));
}

public static IEvent<Void> logout() {
return (IEvent<Void>) (ModularEventBus.INSTANCE.createObservable("com.pengxr.sampleloginlib.events.LoginEvents$$logout", Void.class, true, false));
}
}
  • 5、订阅事件: 使用 EventDefineOfLoginEvents 事件类提供的静态方法订阅事件:

订阅者示例

1
2
3
4
5
6
7
8
9
kotlin复制代码// 以生命周期感知模式订阅事件(不需要手动注销订阅)
EventDefineOfLoginEvents.login().observe(this) { value: UserInfo? ->
// Do something.
}

// 以永久模式订阅事件(需要手动注销订阅)
EventDefineOfLoginEvents.logout().observeForever { _: Void? ->
// Do something.
}
  • 6、发布事件: 使用 EventDefineOfLoginEvents 提供的静态方法发布事件:

发布者示例

1
2
3
kotlin复制代码EventDefineOfLoginEvents.login().post(UserInfo("XIAOPENG"))

EventDefineOfLoginEvents.logout().post(null)
  • 7、添加混淆规则(如果使用了 minifyEnabled true):
1
2
3
kotlin复制代码-dontwarn com.pengxr.modular.eventbus.generated.**
-keep class com.pengxr.modular.eventbus.generated.** { *; }
-keep @com.pengxr.modular.eventbus.facade.annotation.EventGroup class * {*;} # 可选

  1. 完整使用文档

4.1 定义事件

  • 使用注解定义事件:
+ **`@EventGroup` 注解:** `@EventGroup` 注解用于定义事件组,修饰于 interface 接口上,在该类中定义的每个方法均视为一个事件定义;
+ **`@Event` 注解:** `@Event` 注解用于事件组中的事件定义,亦可省略。

模板程序如下:

com.pengxr.sample.events.MainEvents.kt

1
2
3
4
5
6
7
8
9
kotlin复制代码// 事件组
@EventGroup
interface MainEvents {

// 事件
// @Event 可以省略
@Event
fun open(): String
}

提示: 以上即定义了一个 MainEvents 事件组,其中包含一个 com.pengxr.sample.events.MainEvents$$open 事件且数据类型为 String 类型。

亦兼容将 @EventGroup 修饰于 class 类而非 interface 接口,但会有编译时警告: Annotated @EventGroup on a class type [IllegalEvent], expected a interface. Is that really what you want?

错误示例

1
2
3
4
5
6
7
kotlin复制代码@EventGroup
class IllegalEvent {

fun illegalEvent() {

}
}
  • 使用 @Ignore 注解忽略定义: 使用 @Ignore 注解可以排除事件类或事件方法,使其不被视为事件定义。

示例程序

1
2
3
4
5
6
7
8
9
10
11
kotlin复制代码// 可以修饰于事件组
@Ignore
@EventGroup
interface IgnoreEvent {

// 亦可修饰于事件
@Ignore
fun ignoredMethod()

fun method()
}
  • 使用 @Deprecated 注解提示过时: 使用 @Deprecated 注解可以标记事件为过时。与 @Ignore 不同是,@Deprecated 修饰的类或方法依然是有效的事件定义。

示例程序

1
2
3
4
5
6
7
8
less复制代码// 虽然过时,但依然是有效的事件定义
@Deprecated("Don't use it.")
@EventGroup
interface DeprecatedEvent {

@Deprecated("Don't use it.")
fun deprecatedMethod()
}
  • 定义事件数据类型: 事件方法返回值即表示事件数据类型,支持泛型(如 List<String>),支持不携带数据的无数据事件。以下均为合法定义:

Java 示例程序

1
2
3
4
5
6
7
8
9
csharp复制代码// 事件数据类型为 String
String stringEventInJava();

// 事件数据类型为 List<String>
List<String> listEventInJava();

// 以下均视为无数据事件
void voidEventInJava1();
Void voidEventInJava2();

Kotlin 示例程序

1
2
3
4
5
6
7
8
9
10
kotlin复制代码// 事件数据类型为 String
fun stringEventInKotlin(): String

// 事件数据类型为 List<String>
fun listEventInKotlin(): List<String>

// 以下均视为无数据事件
fun voidEventInKotlin1()
fun voidEventInKotlin2(): Unit
fun voidEventInKotlin3(): Unit?
  • 定义事件数据可空性: 使用 @Nullable 或 @NonNull 注解表示事件数据可空性,默认为可空类型。以下均为合法定义:

Java 示例程序

1
2
3
4
5
6
7
8
arduino复制代码@NonNull
String nonNullEventInJava();

@Nullable
String nullableEventInJava();

// 默认视为 @Nullable
String eventInJava();

Kotlin 示例程序

1
2
3
4
kotlin复制代码fun nonNullEventInKotlin(): String

// 提示:Kotlin 编译器将返回类型上的 ? 号视为 @org.jetbrains.annotations.Nullable
fun nullableEventInKotlin(): String?

以下为支持的可空性注解:

1
2
3
4
5
6
7
kotlin复制代码org.jetbrains.annotations.Nullable
android.annotation.Nullable
androidx.annotation.Nullable

org.jetbrains.annotations.NotNull
android.annotation.NonNull
androidx.annotation.NonNull
  • 定义自动清除事件: 支持配置在事件没有关联的订阅者时自动被清除(以释放内存),默认值为 false。可以使用 @EventGroup 注解或 @Event 注解进行修改,以 @Event 的取值优先。

示例程序

1
2
3
4
5
6
7
8
9
kotlin复制代码@EventGroup(autoClear = true)
interface MainEvents {

@Event(autoClear = false)
fun normalEvent(): String

// 继承 @EventGroup 中的 autoClear 取值
fun autoClearEvent(): String
}
  • 定义事件所属组件名: 为避免不同组件中的事件名重复,框架自动使用 "[moduleName]$$[eventName]" 作为最终的事件名。默认使用事件组的 [全限定类名] 作为 moduleName,可以使用 @EventGroup 注解进行修改。

示例程序

com.pengxr.sample.events.MainEvents.kt

1
2
3
4
5
kotlin复制代码@EventGroup(moduleName = "main")
interface MainEvents {

fun open(): String
}

提示: 以上即定义了一个 MainEvents 事件组,其中包含一个 main$$open 事件且数据类型为 String 类型。

4.2 执行注解处理器

在完成事件定义后,执行 Make Project 或 Rebuild Project 等多种方式都可以触发注解处理器,处理器将根据事件定义自动生成相应的事件接口。例如, MainEvents 对应的事件接口为:

com.pengxr.modular.eventbus.generated.events.com.pengxr.sample.events.EventDefineOfMainEvents.java

1
2
3
4
5
6
7
8
9
10
11
12
13
typescript复制代码/**
* Auto generate code, do not modify!!!
* @see com.pengxr.sample.events.MainEvents
*/
@SuppressWarnings("unchecked")
public class EventDefineOfMainEvents implements IEventGroup {
private EventDefineOfMainEvents() {
}

public static IEvent<String> open() {
return (IEvent<String>) (ModularEventBus.INSTANCE.createObservable("main$$open", String.class, false, false));
}
}

EventDefineOfMainEvents 中的静态方法与 MainEvent 事件组中的每个事件一一对应,直接通过静态方法即可获取事件实例,而不再通过手动输入事件名字符串或事件数据类型,故可避免事件名错误或数据类型错误等问题。

所有的事件实例均是 IEvent 泛型接口的实现类,例如 open 事件属于 IEvent<String> 类型的事件实例。发布事件和订阅事件需要用到 IEvent 接口中定义的一系列 post 方法和 observe 方法,IEvent 接口的完整定义如下:

IEvent.kt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
less复制代码interface IEvent<T> {

/**
* 发布事件,允许在子线程发布
*/
@AnyThread
fun post(value: T?)

/**
* 延迟发布事件,允许在子线程发布
*/
@AnyThread
fun postDelay(value: T?, delay: Long)

/**
* 延迟发布事件,在准备发布前会检查 producer 处于活跃状态,允许在子线程发布
*
* @param producer 发布者的 LifecycleOwner
*/
@AnyThread
fun postDelay(value: T?, delay: Long, producer: LifecycleOwner)

/**
* 发布事件,允许在子线程发布,确保订阅者按照发布顺序接收事件
*/
@AnyThread
fun postOrderly(value: T?)

/**
* 以生命周期感知模式订阅事件(不需要手动注销订阅)
*/
@AnyThread
fun observe(consumer: LifecycleOwner, observer: Observer<T?>)

/**
* 以生命周期感知模式粘性订阅事件(不需要手动注销订阅)
*/
@AnyThread
fun observeSticky(consumer: LifecycleOwner, observer: Observer<T?>)

/**
* 以永久模式订阅事件(需要手动注销订阅)
*/
fun observeForever(observer: Observer<T?>)

/**
* 以永久模式粘性订阅事件(需要手动注销订阅)
*
* @param observer Event observer.
*/
@AnyThread
fun observeStickyForever(observer: Observer<T?>)

/**
* 注销订阅者
*/
@AnyThread
fun removeObserver(observer: Observer<T?>)

/**
* 移除事件,关联的订阅者关系也会被解除
*/
@AnyThread
fun removeEvent()
}

4.3 订阅事件

使用 IEvent 接口定义的一系列 observe() 接口订阅事件,使用示例:

示例程序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
kotlin复制代码// 以生命周期感知模式订阅(不需要手动注销订阅)
EventDefineOfMainEvents.open().observe(this) {
// do something.
}

// 以生命周期感知模式、且粘性模式订阅(不需要手动注销订阅)
EventDefineOfMainEvents.open().observeSticky(this) {
// do something.
}

val foreverObserver = Observer<String?> {
// do something.
}

// 以永久模式订阅(需要手动注销订阅)
EventDefineOfMainEvents.open().observeForever(foreverObserver)

// 以永久模式,且粘性模式订阅(需要手动注销订阅)
EventDefineOfMainEvents.open().observeStickyForever(foreverObserver)

// 移除观察者
EventDefineOfMainEvents.open().removeObserver(foreverObserver)

4.4 发布事件

使用 IEvent 接口定义的一系列 post() 接口发布事件,使用示例:

示例程序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
kotlin复制代码// 发布事件,允许在子线程发布
EventDefineOfMainEvents.open().post("XIAO PENG")

// 延迟发布事件,允许在子线程发布
EventDefineOfMainEvents.open().postDelay("XIAO PENG", 5000)

// 延迟发布事件,在准备发布前会检查 producer 处于活跃状态,允许在子线程发布。
EventDefineOfMainEvents.open().postDelay("XIAO PENG", 5000, this)

// 发布事件,允许在子线程发布,确保订阅者按照发布顺序接收事件
EventDefineOfMainEvents.open().postOrderly("XIAO PENG")

// 移除事件
EventDefineOfMainEvents.open().removeEvent()

4.5 更多功能

  • 生成事件文档(可选): 支持生成事件文档,需要在 Gradle 配置中开启:

模块级 build.gradle

1
2
3
4
5
6
7
8
9
10
11
12
13
ini复制代码// 需要生成事件文档的模块就增加配置:
android {
defaultConfig {
javaCompileOptions {
annotationProcessorOptions {
arguments = [
MODULAR_EVENTBUS_GENERATE_DOC: "enable",
MODULAR_EVENTBUS_MODULE_NAME : project.getName()
]
}
}
}
}

文档生成路径: build/generated/source/kapt/[buildType]/com/pengxr/modular/eventbus/generated/docs/eventgroup-of-[MODULAR_EVENTBUS_MODULE_NAME].json

  • 配置(可选):
    • debug(Boolean): 调试模式开关;
    • throwNullEventException(Boolean): 非空事件发布空数据时是否抛出 NullEventException 异常,在 release 模式默认为只拦截不抛出异常,在 debug 模式默认为拦截且抛出异常;
    • setEventListener(IEventListener): 全局监听接口。

示例程序

1
2
3
4
5
6
7
kotlin复制代码ModularEventBus.debug(true)
.throwNullEventException(true)
.setEventListener(object : IEventListener {
override fun <T> onEventPost(eventName: String, event: BaseEvent<T>, data: T?) {
Log.i(TAG, "onEventPost: $eventName, event = $event, data = $data")
}
})

  1. 未来功能规划

  • 支持跨进程 / 跨 App:LiveEventBus 框架支持跨进程 / 跨 App,未来根据使用反馈考虑实现该 Feature;
  • 支持替换内部 EventBus 工厂:ModularEventBus 已预设计事件总线工厂 IEventFactory,未来根据使用反馈考虑公开该 API;
  • 支持基于 Kotlin Flow 的 IEventFactory 工厂;
  • 编译时检查在不同 @EventGroup 中设置相同 modulaName 且相同 eventName,但事件数据类型不同的异常。

  1. 共同成长

  • 欢迎提 Issue 帮助修复缺陷;
  • 欢迎提 Pull Request 增加新的 Feature,让 ModularEventBus 变得更加强大,你的 ID 会出现在 Contributors 中;
  • 欢迎加 作者微信 与作者交流,欢迎加入交流群找到志同道合的伙伴

参考资料

  • Android 消息总线的演进之路:用 LiveDataBus 替代 RxBus、EventBus —— 海亮(美团)著
  • Android 组件化方案及组件消息总线 modular-event 实战 —— 海亮(美团)著

我是小彭,带你构建 Android 知识体系。技术和职场问题,请关注公众号 [彭旭锐]私信我提问。

i

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Gradle 构建工具

发表于 2022-08-24

⭐️ 本文已收录到 AndroidFamily,技术和职场问题,请关注公众号 [彭旭锐] 和 [BaguTree Pro] 知识星球提问。

Gradle 作为官方主推的构建系统,目前已经深度应用于 Android 的多个技术体系中,例如组件化开发、产物构建、单元测试等。可见,要成为 Android 高级工程师 Gradle 是必须掌握的知识点。

本文是 Gradle 构建工具系列的第 4 篇文章,完整文章目录请移步到文章末尾~

小彭今天和群友讨论了一下学习方法的问题,觉得还挺感同身受的。有时候我们遇到不懂的地方,潜意识会产生厌恶和恐惧,大脑会驱使我们去学习和查看这个不懂的地方,结果有可能是陷入到另一个不懂的循环里,忘记了最初的目的。关于系统化学习和碎片化学习,你的想法是怎样的呢?评论区里告诉我吧。

前言

当一个开发者的水平提升到一定程度时,会有由内向外输出价值的需求,包括发布开源项目。而要发布开源组件,则需要将组件发布到公开的远程仓库,如 Jitpack、JenCenter 和 MavenCentral。其中,MavenCentral 是最流行的中央仓库,也是 Gradle 默认使用的仓库之一。

在这篇文章里,我将手把手带你发布组件到 MavenCentral 中央仓库。本文的示例程序使用小彭的开源项目 ModularEventBus 有用请给 Star,谢谢。

这不仅仅是一份攻略,还带着踩过一个个坑留下的泪和挠掉一根根落的宝贵发丝~


操作指引:

  1. 概念梳理

1.1 什么是 POM?

POM(Project Object Model)指项目对象模型,用于描述项目构件的基本信息。一个有效的 POM 节点中主要包含以下参数:

参数 描述 举例
groupId 组织 / 公司名 io.github.pengxurui
artifactId 组件名 modular-eventbus-annotation
version 组件版本 1.0.0
packaging 格式 jar

1.2 什么是仓库(repository)

在项目中,我们会需要依赖各种各样的二方库或三方库,这些依赖一定会存放在某个位置(Place),这个 “位置” 就叫做仓库。使用仓库可以帮助我们管理项目构件,例如 jar、aar 等等。

主流的构建工具都有 2 个层次的仓库概念:

  • 1、本地仓库: 无论使用 Linux 还是 Window,计算机中会有一个目录用来存放从中央仓库或远程仓库下载的依赖文件;
  • 2、远程仓库: 包括中央仓库和私有仓库。中央仓库是开源社区提供的仓库,是绝大多数开源库的存放位置。比如 Maven 社区的中央仓库 Maven Central;私有仓库是公司或组织的自定义仓库,可以理解为二方库的存放位置。

1.3 Sonatype、Nexus 和 Maven 的关系:

  • Sonatype: 完整名称是 Sonatype OSSRH(OSS Repository Hosting),为开源项目提供免费的中央存储仓库服务。其中需要用到 Nexus 作为仓库管理器;
  • Nexus: 完整名称是 Sonatype Nexus Repository Manager,是 Sonatype 的另一款产品,用作提供仓库管理器。Sonatype 基于 Nexus 提供中央仓库,各个公司也可以使用 Nexus 搭建私有仓库;
  • Maven: 完整名称是 Apache Maven,是一种构建系统。除了 Maven 之外,Apache Ant 和 Gradle 都可以发布组件。

  1. 新建 Sonatype 项目

从这一节开始,我将带你一步步完成发布组件到中央仓库的操作(带你踩坑)。

2.1 准备 Sonatype JIRA 账号

进入 Sonatype 仪表盘界面,登录或注册新账号:issues.sonatype.org:

2.2 新建工单

点击仪表盘面板右上角的 ”新建“ 按钮,按照以下步骤向 Sonotype 提交新建项目的工单:

填写方法总结如下:

  • 项目: 使用默认选项 Community Support - Open Source Project Repository Hosting (OSSRH);
  • 问题类型: 使用默认选项 New Project;
  • 概要: 填写 Github 仓库相同的名称,以方便查找;
  • GroupId 组织名: 填写发布组件时使用的 groupId,后续步骤中会检查你是否真实拥有该 groupId,所以不可以随便填写,有 2 种填写方式:
    • **使用 Github 账号:**按照 io.github.[Github 用户名] 的格式填写,后续步骤中 Sonatype 通过要求我们在个人 Github 仓库中新建指定名称的临时代码库的方式来做身份验证;
    • 使用个人域名: 按照逆序域名的格式填写,例如个人域名为 oss.sonotype.org ,则填写 org.sonotype.oss 。
  • Project URL 项目地址: 填写 Github 项目地址,例如: https://github.com/pengxurui/ModularEventBus ;
  • SCM url 版本控制地址: 在 Github 项目地址后加 .git ,例如 https://github.com/pengxurui/ModularEventBus.git 。

2.3 验证 GroupId 所有权

点击弹出的消息进入工单详情页,刚新建的工单要等待 Sonotype 机器人回复,等待大概十几分钟后,在工单底部的评论区会告诉我们怎么操作。

至此,Sonotype 项目准备完毕。


  1. 新建 GPG 密钥对

GPG(GNU Privacy Guard) 是基于 OpenPGP 标准实现的加密软件,它提供了对文件的非对称加密和签名验证功能。所有发布到 Maven 仓库的文件都需要进行 GPG 签名,以验证文件的合法性。

3.1 安装 GPG 软件

安装 GPG 软件有两种方式:

  • 方式 1 - 下载安装包: 通过 GPG 官方 下载安装包,这个我没试过;
  • 方式 2 - 通过 Homebrew 安装: 使用 Homebrew 执行以下命令:

命令行

1
2
bash复制代码# 通过 Homebrew 安装 gpg
brew install gpg

如果本地没有 Homebrew 环境则需要先安装,这里也容易踩坑。小彭本地原本就有 Homebrew 环境,但是安装 gpg 的过程中各种报错,最后还是用了最暴力的解法才解决 —— 卸载重装 Homebrew:(

参考资料: MacOS下开发环境配置–homebrew的安装

3.2 生成 GPG 密钥对

使用 --generate-key 参数,按照指引填写相关信息和 passphrase 私钥口令。另外,使用 --list-keys 参数可以查看当前系统中生成过的密钥。

命令行

1
2
3
4
5
bash复制代码# 密钥生成命令
gpg --generate-key

# 密钥查看命令
gpg --list-keys

命令行演示

GPG 在生成密钥对时,会要求开发者做一些随机的举动,以给随机数加入足够多的扰动,稍等片刻就会生成完成了。完成后可以随时使用 —list-keys 参数查看密钥对信息:

命令行演示

解释一下其中的信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
bash复制代码/Users/pengxurui/.gnupg/pubring.kbx
-----------------------------------
pub ed25519 2022-08-23 [SC] [expires: 2024-08-22]
D8BCD08568BE5D2D634DD99EFD4ECE3B54DE73AA
uid [ultimate] test <test@gmail.com>
sub cv25519 2022-08-23 [E] [expires: 2024-08-22]

# pubring.kbx:本地存储公钥的文件
# 2022-08-23 [SC] [expires: 2024-08-22]:表示密钥对的创建时间和失效时间
# test <test@gmail.com>:用户名和邮箱
# ed25519:表示生成公钥的算法
# cv25519:表示生成私钥的算法
# D8BCD08568BE5D2D634DD99EFD4ECE3B54DE73AA:密钥指纹 / KeyId

至此,你已经在本地生成一串新的密钥对,现在你手上有:

  • 密钥指纹 / KeyId: 密钥指纹是密钥对的唯一标识,即上面 D8BCD08568BE5D2D634DD99EFD4ECE3B54DE73AA 这一串。有时也可以使用较短的格式,取其最后 8 个字符,即 B54DE73AA 这一串;
  • 公钥: 该密钥指纹对应的公钥;
  • 私钥: 该密钥指纹对应的私钥;
  • passphrase 密钥口令: 生成密钥对时输入的口令,私钥与密钥口令共同组成密钥对的私有信息。

3.3 删除密钥对

有时候需要删除密钥对,可以使用以下命令:

1
2
3
4
5
6
bash复制代码# 先删除私钥后,才能删除公钥

# 删除私钥
gpg --delete-secret-keys [密钥指纹]
# 删除公钥
gpg --delete-keys [密钥指纹]

3.4 上传公钥

密钥对中的公钥信息需要公开,其他人才能拿到公钥来验证你签名的数据,公开的方法就是上传到公钥服务器。公钥服务器是专门储存用户公钥的服务器,并且会用交换机制将数据同步给其它公钥服务器,因此你只要上传到其中一个服务器即可。我最后是上传到 hkp://keyserver.ubuntu.com 服务器的。以下服务器都可以尝试:

  • pool.sks-keyservers.net
  • keys.openpgp.org
  • keyserver.ubuntu.com
  • pgp.mit.edu

命令行

1
2
3
4
kotlin复制代码// 上传公钥
gpg --keyserver 【服务器地址】:11371 --send-keys 【密钥指纹】
// 验证公钥
gpg --keyserver 【服务器地址】:11371 --recv-keys 【密钥指纹】

3.5 导出密钥文件

后文发布组件的时候需要用到密钥口令和私钥文件,可以使用以下参数导出

命令行

1
2
3
4
5
6
bash复制代码# 默认导出到本地目录 /User/[用户名]/

# 导出公钥
gpg --export 【密钥指纹】 > xiaopeng_pub.gpg
# 导出私钥
gpg --export-secret-keys 【密钥指纹】 > xiaopeng_pri.gpg

3.6 踩坑:PGPException: unknown public key algorithm encountered

我在发布组件时遇到 PGPException: unknown public key algorithm encountered 报错,最后排查下来是使用了 Gradle signing 插件不支持 EDDSA 算法,需要使用 RSA 算法。

可以看到上文 3.1 节生成的公钥,可以看到是 ed 开头的,表示使用的是 EDDSA 算法,应该是不同版本中的 --generate-key 参数使用的默认算法不一样。

3.1 节生成的公钥信息

1
bash复制代码pub   ed25519 2022-08-23 [SC] [expires: 2024-08-22]

解决方法是使用 --full-generate-key 参数选择使用 RSA 算法生成密钥对:

命令行演示

至此,密钥对准备完毕。


  1. 配置发布脚本

完成 Sonatype 项目和密钥对的准备工作后,现在着手配置项目的 Gradle 脚本了。Gradle 提供了两个 Maven 插件:

  • maven 插件: 旧版发布插件,从 Gradle 7.0 开始无法使用;
  • maven-publish 插件: 新版发布插件。

我最初的想法是分别整理出这两个插件的通用脚本,一开始是参考 ARouter 项目里的 publish.gradle 脚本,过程中也遇到各种问题,例如 Javadoc generation failed ,可能是因为 ARouter 是纯 Java 实现的,所以暴露的问题较少。耽搁了一周后,刚好这两天在看 LeakCanary 源码,果然在 LeakCanary 里发现宝藏 —— vanniktech 的发布插件!

报错

1
2
bash复制代码Execution failed for task ':eventbus_api:androidJavadocs'.
> Javadoc generation failed. Generated Javadoc options file (useful for troubleshooting): '/Users/pengxurui/workspace/public/ModularEventBus/eventbus_api/build/tmp/androidJavadocs/javadoc.options'

4.1 使用 maven 插件发布

这块脚本是参考 ARouter 项目中 publish.gradle 脚本的,我在此基础上增加了注释和少量改动,如果遇到生成 Javadoc 出现问题,可以把 archives androidJavadocsJar 这一行注释掉。

maven_sonatype.gradle

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
groovy复制代码// 在 ARouter 项目的 publish.gradle 上修改

apply plugin: 'maven'
apply plugin: 'signing'

version = VERSION_NAME
group = GROUP

// 是否 Release 发布(根据是否包含 SNAPSHOT 判断)
def isReleaseBuild() {
return VERSION_NAME.contains("SNAPSHOT") == false
}

// Central Repository: https://central.sonatype.org/publish/publish-guide/
// Release 仓库地址(默认先发布到 staging 暂存库,需要手动发布到中央仓库)
def getReleaseRepositoryUrl() {
return hasProperty('RELEASE_REPOSITORY_URL') ? RELEASE_REPOSITORY_URL : "https://s01.oss.sonatype.org/service/local/staging/deploy/maven2/"
}

// Snapshot 仓库地址
def getSnapshotRepositoryUrl() {
return hasProperty('SNAPSHOT_REPOSITORY_URL') ? SNAPSHOT_REPOSITORY_URL : "https://s01.oss.sonatype.org/content/repositories/snapshots/"
}

// 仓库账号
def getRepositoryUsername() {
return hasProperty('SONATYPE_NEXUS_USERNAME') ? SONATYPE_NEXUS_USERNAME : ""
}

// 仓库密码
def getRepositoryPassword() {
return hasProperty('SONATYPE_NEXUS_PASSWORD') ? SONATYPE_NEXUS_PASSWORD : ""
}

// 组件配置
def configurePom(pom) {
// 组织名
pom.groupId = GROUP
// 组件名
pom.artifactId = POM_ARTIFACT_ID
// 组件版本
pom.version = VERSION_NAME

pom.project {
// 名称
name POM_NAME
// 发布格式
packaging POM_PACKAGING
// 描述信息
description POM_DESCRIPTION
// 主页
url POM_URL

scm {
url POM_SCM_URL
connection POM_SCM_CONNECTION
developerConnection POM_SCM_DEV_CONNECTION
}

// Licenses 信息
licenses {
license {
name POM_LICENCE_NAME
url POM_LICENCE_URL
distribution POM_LICENCE_DIST
}
}

// 开发者信息
developers {
developer {
id POM_DEVELOPER_ID
name POM_DEVELOPER_NAME
}
}
}
}

afterEvaluate { project ->
// 配置 Maven 插件的 uploadArchives 任务
uploadArchives {
repositories {
mavenDeployer {
// 配置发布前需要签名
beforeDeployment { MavenDeployment deployment -> signing.signPom(deployment) }
// 配置 Release 仓库地址与账号密码
repository(url: getReleaseRepositoryUrl()) {
authentication(userName: getRepositoryUsername(), password: getRepositoryPassword())
}
// 配置 Snapshot 仓库地址与账号密码
snapshotRepository(url: getSnapshotRepositoryUrl()) {
authentication(userName: getRepositoryUsername(), password: getRepositoryPassword())
}
// 配置 POM 信息
configurePom(pom)
}
}
}
// 配置 Maven 本地发布任务
tasks.create("installLocally", Upload) {
configuration = configurations.archives

repositories {
mavenDeployer {
// 本地仓库地址
repository(url: "file://${rootProject.buildDir}/localMaven")
// 配置 POM 信息
configurePom(pom)
}
}
}

// 配置签名参数,部分需要在 local.properties 中配置
signing {
required { isReleaseBuild() && gradle.taskGraph.hasTask("uploadArchives") }
sign configurations.archives
}

if (project.getPlugins().hasPlugin('com.android.application') || project.getPlugins().hasPlugin('com.android.library')) {
// Android 类型组件
task install(type: Upload, dependsOn: assemble) { // 依赖于 AGP assemble 任务
repositories.mavenInstaller {
configuration = configurations.archives

configurePom(pom)
}
}

task androidJavadocs(type: Javadoc) {
source = android.sourceSets.main.java.source
classpath += project.files(android.getBootClasspath().join(File.pathSeparator))
}

task androidJavadocsJar(type: Jar, dependsOn: androidJavadocs) {
classifier = 'javadoc'
from androidJavadocs.destinationDir
}

// 生成源码产物
task androidSourcesJar(type: Jar) {
classifier = 'sources'
from android.sourceSets.main.java.source
}
} else {
// 纯 Java / Kotlin 类型组件(如 Gradle 插件、APT 组件)
install {
repositories.mavenInstaller {
configurePom(pom)
}
}

// 生成源码产物
task sourcesJar(type: Jar, dependsOn: classes) {
classifier = 'sources'
from sourceSets.main.allSource
}

// 生成 javadoc 产物
task javadocJar(type: Jar, dependsOn: javadoc) {
classifier = 'javadoc'
from javadoc.destinationDir
}
}

// Java8 适配
if (JavaVersion.current().isJava8Compatible()) {
allprojects {
tasks.withType(Javadoc) {
options.addStringOption('Xdoclint:none', '-quiet')
}
}
}

// 配置源码和 Javadoc 发布产物
if (!isReleaseBuild()) {
// 快照版本跳过,提高效率
artifacts {
if (project.getPlugins().hasPlugin('com.android.application') || project.getPlugins().hasPlugin('com.android.library')) {
// Android 类型组件
archives androidSourcesJar // 源码
archives androidJavadocsJar // Javadoc,如果报错需要把这一行注释掉
} else {
// 纯 Java / Kotlin 类型组件(如 Gradle 插件、APT 组件)
archives sourcesJar // 源码
archives javadocJar // Javadoc
}
}
}
}

在需要发布的组件里应用这个脚本后,在 gradle.properties 里配置相关参数后就可以发布了。具体可以参考示例程序 ModularEventBus 中被注释掉的参数,也可以参考 ARouter 项目,这里就不展开了,建议用 4.2 节 vanniktech 的发布插件。

项目级 gradle.properties

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
groovy复制代码######################################################################
# for maven_sonatype.gradle
######################################################################
# GROUP=io.github.pengxurui
#
# POM_URL=https://github.com/pengxurui/ModularEventBus/
# POM_SCM_URL=https://github.com/pengxurui/ModularEventBus/
# POM_SCM_CONNECTION=scm:git:git:github.com/pengxurui/ModularEventBus.git
# POM_SCM_DEV_CONNECTION=scm:git:ssh://git@github.com/pengxurui/ModularEventBus.git
#
# POM_LICENCE_NAME=The Apache Software License, Version 2.0
# POM_LICENCE_URL=http://www.apache.org/licenses/LICENSE-2.0.txt
# POM_LICENCE_DIST=repo
#
# POM_DEVELOPER_ID=pengxurui
# POM_DEVELOPER_NAME=Peng Xurui
#
# SONATYPE_NEXUS_USERNAME=[provide your Sonatype user name]
# SONATYPE_NEXUS_PASSWORD=[provide your Sonatype password]
#
# signing.keyId=[provide you gpg key]
# signing.password=[provide you gpg passphrase]
# signing.secretKeyRingFile=[provide you gpg secret file]

模块级 gradle.properties

1
2
3
4
5
6
7
8
groovy复制代码######################################################################
# for maven_sonatype.gradle
######################################################################
# POM_NAME=ModularEventBus Annotations
# POM_ARTIFACT_ID=modular-eventbus-annotation
# POM_PACKAGING=jar
# POM_DESCRIPTION=The annotation used in ModularEventBus api
# VERSION_NAME=1.0.0

4.2 使用 vanniktech 的发布插件(推荐)

gradle-maven-publish-plugin 是一个外国大佬 vanniktech 开源的 Gradle 插件,需要使用 Gradle 7.2.0 以上的 Gradle 环境。它会创建一个 publish Task,支持将 Java、Kotlin 或 Android 组件发布到任何 Maven 仓库,同时也支持发布携带 Java / Kotlin 代码的 Javadoc 产物和 Sources 产物。虽然目前(2022/08/24)这个项目的最新版本只是 0.21.0,不过既然已经在 LeakCanary 上验证过,大胆用起来吧。

以下为配置步骤:在项目级 build.gradle 中添加插件地址,在模块级 build.gradle 中应用插件:

项目级 build.gradle

1
2
3
4
5
6
7
8
9
10
11
12
groovy复制代码buildscript {
repositories {
mavenCentral()
}
dependencies {
// vanniktech 发布插件
classpath 'com.vanniktech:gradle-maven-publish-plugin:0.18.0'
// Kotlin Javadoc,非必须
classpath "org.jetbrains.dokka:dokka-gradle-plugin:1.6.20"
// 最新版 1.7.10 和 0.21.0 组合有问题,应该是没兼容好。上面两个版本组合我验证过是可以的。
}
}

模块级 build.gradle

1
2
3
groovy复制代码apply plugin: "com.vanniktech.maven.publish"
// Kotlin Javadoc,非必须。如果有这个插件,发布时会生成 Javadoc,会延长发布时间。建议在 snapshot 阶段关闭
apply plugin: "org.jetbrains.dokka"

Sync 项目后,插件会为模块增加两个 Task 任务:

  • publish: 发布到远程 Maven 仓库,默认是 Sonatype 中央仓库;
  • publishToMavenLocal: 发布到当前机器的本地 Maven 仓库,即 ~/.m2/repository。

Gradle 面板

4.3 配置 vanniktech 插件的发布参数

分别在项目级 gradle.properties 和模块级 gradle.properties 中配置以下参数:

项目级 gradle.properties

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
groovy复制代码######################################################################
# for vanniktech
######################################################################
# 服务器地址
SONATYPE_HOST=S01

# 发布 release 组件时是否签名
RELEASE_SIGNING_ENABLED=true

# 组织名
GROUP=io.github.pengxurui

# 主页
POM_URL=https://github.com/pengxurui/ModularEventBus/

# 版本控制信息
POM_SCM_URL=https://github.com/pengxurui/ModularEventBus/
POM_SCM_CONNECTION=scm:git:git:github.com/pengxurui/ModularEventBus.git
POM_SCM_DEV_CONNECTION=scm:git:ssh://git@github.com/pengxurui/ModularEventBus.git

# Licenses 信息
POM_LICENSE_NAME=The Apache Software License, Version 2.0
POM_LICENSE_URL=https://www.apache.org/licenses/LICENSE-2.0.txt
POM_LICENSE_DIST=repo

# 开发者信息
POM_DEVELOPER_ID=pengxurui
POM_DEVELOPER_NAME=Peng Xurui
POM_DEVELOPER_URL=https://github.com/pengxurui/

mavenCentralUsername=[填 Sonatype 账号名]
mavenCentralPassword=[填 Sonatype 密码]

signing.keyId=[密钥指纹,取后 8 位即可]
signing.password=[passphrase 密钥口令]
signing.secretKeyRingFile=[导出的私钥文件路径,如 /Users/pengxurui/xxx.gpg]

模块级 gradle.properties

1
2
3
4
5
groovy复制代码POM_NAME=ModularEventBus Annotations
POM_ARTIFACT_ID=modular-eventbus-annotation
POM_PACKAGING=jar
POM_DESCRIPTION=The annotation used in ModularEventBus api
VERSION_NAME=1.0.0

特别注意:私有信息不要提交到 git 版本管理中,可以写在 local.properties 中,等到要发布组件时再复制到 gradle.properties 中。而私钥文件也不要保存在当前工程的目录里,可以统一放到工程外的一个目录。

至此,所有准备工作完成。

4.4 浅尝一下 vanniktech 插件的源码

毕竟发布逻辑都被人家封装在插件里了,有必要知道它背后的工作,浅尝一下。

  • 支持的 Snoatype 服务器:

SonatypeHost.kt

1
2
3
4
5
6
kotlin复制代码enum class SonatypeHost(
internal val rootUrl: String
) {
DEFAULT("https://oss.sonatype.org"),
S01("https://s01.oss.sonatype.org"),
}
  • 支持 Dokka 插件,需要手动依赖:

MavenPublishPlugin.kt

1
2
3
4
5
6
7
kotlin复制代码private fun Project.defaultJavaDocOption(): JavadocJar? {
return if (plugins.hasPlugin("org.jetbrains.dokka") || plugins.hasPlugin("org.jetbrains.dokka-android")) {
JavadocJar.Dokka(findDokkaTask())
} else {
null
}
}
  • 支持多种模块类型:

MavenPublishPlugin.kt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
kotlin复制代码afterEvaluate {
when {
plugins.hasPlugin("org.jetbrains.kotlin.multiplatform") -> {} // Handled above.
plugins.hasPlugin("com.android.library") -> {} // Handled above.
plugins.hasPlugin("java-gradle-plugin") ->
baseExtension.configure(GradlePlugin(defaultJavaDocOption() ?: javadoc()))
plugins.hasPlugin("org.jetbrains.kotlin.jvm") ->
baseExtension.configure(KotlinJvm(defaultJavaDocOption() ?: javadoc()))
plugins.hasPlugin("org.jetbrains.kotlin.js") ->
baseExtension.configure(KotlinJs(defaultJavaDocOption() ?: JavadocJar.Empty()))
plugins.hasPlugin("java-library") ->
baseExtension.configure(JavaLibrary(defaultJavaDocOption() ?: javadoc()))
plugins.hasPlugin("java") ->
baseExtension.configure(JavaLibrary(defaultJavaDocOption() ?: javadoc()))
else -> logger.warn("No compatible plugin found in project $name for publishing")
}
}

  1. 发布组件到 MavenCentral 仓库

终于终于,所有准备和配置工作都完成了!在发布之前,有必要先解释下 Sonatype 中用到的仓库地址:

5.1 仓库地址

如果你没有自定义发布的 Maven 仓库,vanniktech 插件默认会发布到 Sonatype 管理的中央仓库中。由于历史原因,Sonatype 中央仓库有 2 个域名:

  • s01.oss.sonatype.org/
  • oss.sonatype.org/

按照 官方的说法 ,oss.sonatype.org 是过时的,从 2021 年 2 月开始启用 s01.oss.sonatype.org/

截图

官方也会提示目前最新的仓库地址:

5.2 Staging 暂存库

细心的朋友会发现官方提供的 snapshot 仓库和 release 仓库的格式不一样,为什么呢?—— 这是因为发布 release 组件是敏感操作,一旦组件发布 release 版本到中央仓库,就永远无法修改或删除这个版本的组件内容(这个规则是出于稳定性和可靠性考虑,如果可以修改,那些本地已经下载过组件的用户就得不到最新内容了)。所以 Sonatype 对发布 snapshot 组件和 release 组件采取了不同策略:

  • snapshot 组件: 直接发布到 snapshot 中央仓库;
  • release 组件: 使用 Staging 暂存策略,release 组件需要先发布到暂存库,经过测试验证通过后,再由开发者手动提升到 release 中央仓库。
1
2
3
arduino复制代码中央 release 仓库:"https://s01.oss.sonatype.org/content/repositories/releases"
中央 snapshot 仓库:"https://s01.oss.sonatype.org/content/repositories/snapshots"
暂存库:"https://s01.oss.sonatype.org/service/local/staging/deploy/maven2"

vanniktech 插件默认也是按照 Sonatype 的策略走的,浅看一下源码:

MavenPublishBaseExtension.kt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
kotlin复制代码// 暂存库:
if (stagingRepositoryId != null) {
repo.setUrl("${host.rootUrl}/service/local/staging/deployByRepositoryId/$stagingRepositoryId/")
} else {
repo.setUrl("${host.rootUrl}/service/local/staging/deploy/maven2/")
}

// snapshot 库:
if (it.version.toString().endsWith("SNAPSHOT")) {
if (stagingRepositoryId != null) {
throw IllegalArgumentException("Staging repositories are not supported for SNAPSHOT versions.")
}
repo.setUrl("${host.rootUrl}/content/repositories/snapshots/")
}

5.3 发布 snapshot 组件

版本号中带 SNAPSHOT 将被视为 snapshot 组件,会直接发布到 snapshot 中央仓库。经过小彭验证,确实在前端发布后,立马可以在 snapshot 中央仓库搜索到,例如 小彭的组件。

验证截图

5.4 发布 release 组件到 Staging 暂存库

版本号中未带 SNAPSHOT 将视为 release 组件,发布 release 组件后,进入 Nexus 面板查看暂存库(右上角 Log in 登录):

操作截图

5.5 发布 release 组件到中央仓库

确认要发布组件后,先点击 Close,再点击 Release 即可发布:

操作截图

Close 的过程会对组件进行验证,验证失败的话就会报错了。你可以直接从 Activity 面板中查看报错提示,我遇到的几次问题都是参数缺失的小问题。

报错提示

点击 Drop 按钮删除有问题的组件:

操作截图

如果验证通过,Release 按钮就会高亮,点击按钮就终于终于发布了。

操作截图

5.6 查看已发布的 release 组件

发布成功后,有 3 种方式查看自己的组件:

  • 方法 1 - 在 Sonatype Nexus 面板上查看:

操作截图

  • 方法 2 - 在 release 中央仓库的文件目录中查看,例如 小彭的 release 组件 :

操作截图

  • 方式 3 - 在 MavenCentral 搜索栏 查找: 这是最正式的方式,缺点是不实时更新,大概有 的延迟,而前两种方式在发布后立即更新:

操作截图

按照 官方的说法 ,发布后的组件会在 30 分钟内同步到中央仓库,但搜索功能需要达到 4 个小时:

1
2
3
vbnet复制代码Upon release, your component will be published to Central: 
this typically occurs within 30 minutes,
though updates to search can take up to four hours.

5.6 依赖已发布的组件

怎么依赖大家都懂。讲一下仓库吧,如果是已经发布到 release 中央仓库,你的工程只要包含 mavenCentral() 这个仓库地址就可以了。

示例程序

1
2
3
4
5
6
7
8
9
10
groovy复制代码repositories {
// 中央仓库(不包含 snapshot 中央仓库)
mavenCentral()
// release 中央仓库
maven { url 'https://s01.oss.sonatype.org/content/repositories/releases'}
// snapshot 中央仓库
maven { url 'https://s01.oss.sonatype.org/content/repositories/snapshots/'}
// 暂存库,用于验证
maven { url "https://s01.oss.sonatype.org/service/local/staging/deploy/maven2"}
}

  1. 报错记录

  • Sonatype 账号密码错误:
1
2
groovy复制代码Failed to publish publication 'maven' to repository 'mavenCentral'
> Could not PUT 'https://s01.oss.sonatype.org/service/local/staging/deploy/maven2/io/github/pengxurui/modular-eventbus-annotation/1.0.2/modular-eventbus-annotation-1.0.2.jar'. Received status code 401 from server: Unauthorized
  • GPG 密钥错误:
1
2
3
groovy复制代码Execution failed for task ':eventbus_annotation:signMavenPublication'.
> Error while evaluating property 'signatory' of task ':eventbus_annotation:signMavenPublication'
> org.bouncycastle.openpgp.PGPException: checksum mismatch at 0 of 20
  • GPG 密钥算法错误:
1
2
3
groovy复制代码Execution failed for task ':eventbus_annotation:signMavenPublication'.
> Error while evaluating property 'signatory' of task ':eventbus_annotation:signMavenPublication'
> org.bouncycastle.openpgp.PGPException: unknown public key algorithm encountered
  • Javadoc 生成报错:
1
2
java复制代码Execution failed for task ':eventbus_api:androidJavadocs'.
> Javadoc generation failed. Generated Javadoc options file (useful for troubleshooting): '/Users/pengxurui/workspace/public/ModularEventBus/eventbus_api/build/tmp/androidJavadocs/javadoc.options'
  • vanniktech 插件与 Dokka 插件兼容问题:
1
2
3
rust复制代码Execution failed for task ':eventbus_api:javaDocReleaseGeneration'.
> 'void org.jetbrains.dokka.DokkaSourceSetImpl.<init>(java.lang.String, org.jetbrains.dokka.DokkaSourceSetID,
...
  • POM 验证错误:


  1. 寻求 Sonatype 官方帮助

如果你在使用 Sonatype 的过程中遇到任何问题,可以尝试向官方提问。我试过一次,10 分钟后就收到回复了,还是很 Nice 的。

操作截图

操作截图


  1. 总结

恭喜,到这里,我们已经能够实现发布开源项目到 MavenCentral 中央仓库。还没完,引出两个问题:

  • Github Action: 每次发布都需要我们手动执行 upload 任务,Github 仓库中的 Releases 面板也不会同步显示手动发布的版本记录。 我们期望的效果是在 Github 仓库上发布一个 Release 版本时,自动触发将该版本发布到 MavenCentral 中央仓库。 这需要用到 Github 提供的 CI/CD 服务 —— Github Action;
  • ModularEventBus: 本文的示例程序,它是做什么的呢?

关注我,带你了解更多。


2022 年 8 月 30 日更新

ModularEventBus 组件化事件总线框架现已发布,点击查看


参考资料

  • Sonotype · 常见问题 Q&A —— Sonotype 官方文档
  • Sonatype · GPG —— Sonatype 官方文档
  • Sonatype · Gradle —— Sonatype 官方文档
  • Sonatype · Managing Staging Repositories —— Sonatype 官方文档
  • Sonatype · Release —— Sonatype 官方文档
  • Github · 生成新 GPG 密钥 —— Github 官方文档
  • Github · Adding a GPG key to your GitHub account —— Github 官方文档
  • Github · gradle-maven-publish-plugin —— vanniktech 著
  • Dokka · Using the Gradle plugin —— Dokka 官方文档
  • GPG 入门教程 —— 阮一峰 著
  • PGPException: unknown public key algorithm encountered 问题 —— Java侠 著

推荐阅读

Gradle 构建工具完整目录如下(2023/07/12 更新):

  • #1 为什么说 Gradle 是 Android 进阶绕不去的坎
  • #2 手把手带你自定义 Gradle 插件
  • #3 Maven 发布插件使用攻略(以 Nexus / Jitpack 为例)
  • #4 来开源吧!发布开源组件到 MavenCentral 仓库超详细攻略
  • #5 又冲突了!如何理解依赖冲突与版本决议?

整理中…

⭐️ 永远相信美好的事情即将发生,欢迎加入小彭的 Android 交流社群~

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Android 开源库

发表于 2022-08-22

⭐️ 本文已收录到 AndroidFamily,技术和职场问题,请关注公众号 [彭旭锐] 和 [BaguTree Pro] 知识星球提问。

当一个开发者或团队的水平积累到一定程度,会有自内向外输出价值的需求。在这个专栏里,小彭将为你分享 Android 方向主流开源组件的实现原理,包括网络、存储、UI、监控等。

本文是 Android 开源库系列的第 7 篇文章,完整文章目录请移步到文章末尾~

前言

LeakCanary 是我们非常熟悉内存泄漏检测工具,它能够帮助开发者非常高效便捷地检测 Android 中常见的内存泄漏。在各大厂自研的内存泄漏检测框架(如腾讯 Matrix 和快手 Koom)的帮助文档中,也会引述 LeakCanary 原理分析。

不吹不黑,LeakCanary 源码中除了实现内存泄漏的监控方案外,还有非常多值得学习的编程技巧,只有沉下心去阅读的人才能够真正体会到。在这篇文章里,我将带你从入门开始掌握 LeakCanary 的使用场景以及使用方法,再介绍 LeakCanary 的工作流程和高级用法,最后通过源码解析深入理解原理。本文示例程序已上传到 Github: DemoHall · HelloLeakCanary ,有用请给 Star 支持,谢谢。

提示: 本文源码分析基于 2022 年 4 月发布的 LeakCanary 2.9.1。


本文原理分析涉及的 Java 虚拟机内存管理基础:

  • 1、垃圾回收机制
  • 2、引用机制:说一下 Java 的四种引用类型
  • 3、Finalizer 机制:为什么 finalize() 方法只会执行一次

本文源码分析涉及的 Android 原理基础:

  • Android Jetpack 开发套件 #3 为什么 Activity 都重建了 ViewModel 还存在?
  • Android Jetpack 开发套件 #7 AndroidX Fragment 核心原理分析
  • Android Jetpack 开发套件 #9 食之无味!App Startup 可能比你想象中要简单
  • 4、Framework · ContentProvider 启动过程分析
  • 5、Framework · Activity 启动过程分析
  • 6、Framework · Service 启动过程分析

学习路线图:


  1. 认识 LeakCanary

1.1 什么是内存泄漏?

内存泄露(Memory Leaks)指不再使用的对象或数据没有被回收,随着内存泄漏的堆积,应用性能会逐渐变差,甚至发生 OOM 奔溃。在 Android 应用中的内存泄漏可以分为 2 类:

  • Java 内存泄露: 不再使用的对象被生命周期更长的 GC Root 引用,无法被判定为垃圾对象而导致内存泄漏(LeakCanary 只能监控 Java 内存泄漏);
  • Native 内存泄露: Native 内存没有垃圾回收机制,未手动回收导致内存泄漏。

1.2 为什么要使用 LeakCanary?

LeakCanray 是 Square 开源的 Java 内存泄漏分析工具,用于在实验室阶段检测 Android 应用中常见中的内存泄漏。

LeakCanary 的特点或优势在于提前预判出 Android 应用中最常见且影响较大的内存泄漏场景,并对此做针对性的监测手段。 这使得 LeakCanary 相比于其他排查内存泄漏的方案(如分析 OOM 异常时的堆栈日志、MAT 分析工具)更加高效。因为当内存泄漏堆积而内存不足时,应用可能从任何一次无关紧要的内存分配中抛出 OOM,堆栈日志只能体现最后一次内存分配的堆栈信息,而无法体现出导致发生 OOM 的主要原因。

目前,LeakCanary 支持以下五种 Android 场景中的内存泄漏监测:

  • 1、已销毁的 Activity 对象(进入 DESTROYED 状态);
  • 2、已销毁的 Fragment 对象和 Fragment View 对象(进入 DESTROYED 状态);
  • 3、已清除的的 ViewModel 对象(进入 CLEARED 状态);
  • 4、已销毁的的 Service 对象(进入 DESTROYED 状态);
  • 5、已从 WindowManager 中移除的 RootView 对象;

1.3 LeakCanary 怎么实现内存泄漏监控?

LeakCanary 通过以下 2 点实现内存泄漏监控:

  • 1、在 Android Framework 中注册无用对象监听: 通过全局监听器或者 Hook 的方式,在 Android Framework 上监听 Activity 和 Service 等对象进入无用状态的时机(例如在 Activity#onDestroy() 后,产生一个无用 Activity 对象);
  • 2、利用引用对象可感知对象垃圾回收的机制判定内存泄漏: 为无用对象包装弱引用,并在一段时间后(默认为五秒)观察弱引用是否如期进入关联的引用队列,是则说明未发生泄漏,否则说明发生泄漏(无用对象被强引用持有,导致无法回收,即泄漏)。

详细的源码分析下文内容。


  1. 理解 LeakCanary 的工作流程

虽然 LeakCanary 的使用方法非常简单,但是并不意味着 LeakCanary 的工作流程也非常简单。在了解 LeakCanary 的使用方法和深入 LeakCanary 的源码之前,我们先理解 LeakCanary 的核心工作流程,我将其概括为以下 5 个阶段:

  • 1、注册无用对象监听: 在 Android Framework 中注册监听器,感知五种 Android 内存泄漏场景中产生无用对象的时机(例如在 Activity#onDestroy() 后,产生一个无用 Activity 对象);
  • 2、监控内存泄漏: 为无用对象关联弱引用对象,如果一段时间后引用对象没有按预期进入引用队列,则认为对象发生内存泄漏。由于分析堆快照是耗时工作,所以 LeakCanary 不会每次发现内存泄漏对象都进行分析工作,而是内存泄漏对象计数到达阈值才会触发分析工作。在计数未到达阈值的过程中,LeakCanary 会发送一条系统通知,你也可以点击该通知提前触发分析工作;

收集过程中的系统通知消息

提示: LeakCanary 为不同的 App 状态设置了不同默认阈值:App 可见时阈值为 5 个泄漏对象,App 不可见时阈值为 1 个泄漏对象。举个例子,如果 App 在前台可见并且已经收集了 4 个泄漏的对象,此时 App 退到后台,LeakCanary 会在五秒后触发分析工作。

  • 3、Java Heap Dump: 当泄漏对象计数达到阈值时,会触发 Java Heap Dump 并生成 .hprof 文件存储到文件系统中。Heap Dump 的过程中会锁堆,会使应用冻结一段时间;

Heap Dump 过程中的全局对话框

  • 4、分析堆快照: LeakCanary 会根据应用的依赖项,选择 WorkManager 多进程、WorkManager 异步任务或 Thread 异步任务其中一种策略来执行分析(例如,LeakCanary 会检查应用有 leakcanary-android-process 依赖项,才会使用 WorkManager 多进程策略)。分析过程 LeakCanary 使用 Shark 分析 .hprof 文件,替换了 LeakCanary 1.0 使用的 haha ;
  • 5、输出分析报告: 当分析工作完成后,LeakCanary 会在 Logcat 打印分析结果,也会发送一条系统通知消息。点击通知消息可以跳转到可视化分析报告页面,也可以点击 LeakCanary 生成的桌面快捷方式进入。

分析结束后的系统通知消息

新增的启动图标

可视化分析报告

至此,LeakCanary 一次内存泄漏分析工作流程执行完毕。


  1. LeakCanary 的基本用法

这一节,我们来介绍 LeakCanary 的基础用法。

3.1 将 LeakCanary 添加到项目中

在 build.gradle 中添加 LeakCanary 依赖,此外不需要调用任何初始化 API(LeakCanary 内部默认使用了 ContentProvider 实现无侵入初始化)。另外,因为 LeakCanary 是只在实验室环境使用的工具,所以这里要记得使用 debugImplementation 依赖配置。

build.gradle

1
2
3
4
groovy复制代码dependencies {
// debugImplementation because LeakCanary should only run in debug builds.
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.9.1'
}

3.2 手动初始化 LeakCanary

LeakCanary 2.0 默认采用了 ContentProvider 机制实现了无侵入初始化,为了给予开发者手动初始化 LeakCanary 的可能性,LeakCanary 在 ContentProvider 中设置了布尔值开关:

AndroidManifest.xml

1
2
3
4
5
6
7
xml复制代码<application>
<provider
android:name="leakcanary.internal.MainProcessAppWatcherInstaller"
android:authorities="${applicationId}.leakcanary-installer"
android:enabled="@bool/leak_canary_watcher_auto_install"
android:exported="false"/>
</application>

开发者只需要在资源文件里覆写 @bool/eak_canary_watcher_auto_install 布尔值来关闭自动初始化,并在合适的时机手动调用 AppWatcher#manualInstall 。

values.xml

1
2
3
xml复制代码<resources>
<bool name="leak_canary_watcher_auto_install">false</bool>
</resources>

3.3 自定义 LeakCanary 配置

LeakCanary 为开发者提供了便捷的配置 API,并且这个配置 API 在初始化前后都允许调用。

示例程序

1
2
3
4
5
java复制代码// Java 语法
LeakCanary.Config config = LeakCanary.getConfig().newBuilder()
.retainedVisibleThreshold(3)
.build();
LeakCanary.setConfig(config);
1
2
3
4
kotlin复制代码// Kotlin 语法
LeakCanary.config = LeakCanary.config.copy(
retainedVisibleThreshold = 3
)

以下用一个表格总结 LeakCanary 主要的配置项:

配置项 描述 默认值
dumpHeap: Boolean Heap Dump 分析开关 true
dumpHeapWhenDebugging: Boolean 调试时 Heap Dump 分析开关 false
retainedVisibleThreshold: Int App 可见时泄漏计数阈值 5
objectInspectors: List 对象检索器 AndroidObjectInspectors.appDefaults
computeRetainedHeapSize: Boolean 是否计算泄漏内存空间 true
maxStoredHeapDumps: Int 最大堆快照存储数量 7
requestWriteExternalStoragePermission: Boolean 是否请求文件存储权限 true
leakingObjectFinder: LeakingObjectFinder 引用链分析器 KeyedWeakReferenceFinder
heapDumper: HeapDumper Heap Dump 执行器 Debug.dumpHprofData
eventListeners: List 事件监听器 多个内部监听器

  1. 解读 LeakCanary 分析报告

内存泄漏分析报告是 LeakCanary 所有监控和分析工作后输出的目标产物,要根据修复内存泄漏,首先就要求开发者能够读懂 LeakCanary 的分析报告。我将 LeakCanary 的分析报告总结为以下 4 个要点:

4.1 泄漏对象的引用链

泄漏对象的引用链是分析报告的核心信息,LeakCanary 会收集泄漏对象到 GC Root 的完整引用链信息。例如,以下示例程序在 static 变量中持有一个 Helper 对象,当 Helper 被期望被垃圾回收时用 AppWatcher 监测该对象,如果未按预期被回收,则会输出以下分析报告:

示例程序

1
2
3
4
5
6
7
8
9
java复制代码class Helper {
}

class Utils {
public static Helper helper = new Helper();
}

// Helper 无用后监测
AppWatcher.objectWatcher.watch(helper, "Helper is no longer useful")

Logcat 日志

1
2
3
4
5
6
7
8
9
10
bash复制代码┬───
│ GC Root: Local variable in native code
│
├─ dalvik.system.PathClassLoader instance
│ ↓ PathClassLoader.runtimeInternalObjects // 表示 PathClassLoader 中的 runtimeInternalObjects 字段,它是一个 Object 数组
├─ java.lang.Object[] array
│ ↓ Object[].[43] // 表示 Object 数组的第 43 位,它是一个 Utils 类型引用
├─ com.example.Utils class
│ ↓ static Utils.helper // 表示 Utils 的 static 字段,它是一个 Helper 类型引用
╰→ java.example.Helper

解释一下其中的符号:

  • ├ 代表一个 Java 对象;
  • │ ↓ 代表一个 Java 引用,关联的实际对象在下一行;
  • ╰→ 代表泄漏的对象,即 AppWatcher.objectWatcher.watch() 直接监控的对象。

4.2 按引用链签名分组

用减少重复的排查工作,LeakCanary 会将相同问题重复触发的内存泄漏进行分组,分组方法是按引用链的签名。引用链签名是对引用链上经过的每个对象的类型拼接后取哈希值,既然应用链完全相同,就没必要重复排查了。

例如,对于泄漏对象 instance,对应的泄漏签名计算公式如下:

Logcat 日志

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
bash复制代码...
│
├─ com.example.leakcanary.LeakingSingleton class
│ Leaking: NO (a class is never leaking)
│ ↓ static LeakingSingleton.leakedViews
│ ~~~~~~~~~~~
├─ java.util.ArrayList instance
│ Leaking: UNKNOWN
│ ↓ ArrayList.elementData
│ ~~~~~~~~~~~
├─ java.lang.Object[] array
│ Leaking: UNKNOWN
│ ↓ Object[].[0]
│ ~~~
├─ android.widget.TextView instance
│ Leaking: YES (View.mContext references a destroyed activity)

对应的签名计算公式

1
2
3
4
5
6
7
bash复制代码val leakSignature = sha1Hash(
"com.example.leakcanary.LeakingSingleton.leakedView" +
"java.util.ArrayList.elementData" +
"java.lang.Object[].[x]"
)
println(leakSignature)
// dbfa277d7e5624792e8b60bc950cd164190a11aa

4.3 使用 ~ 标记怀疑对象

为了提高排查内存泄漏的效率,LeakCanary 会自动帮助我们根据对象的生命周期信息或状态信息缩小排查范围,排除原本就具有全局生命周期的对象,剩下的用 ~~~ 下划线标记为怀疑对象。

例如,在以下内存泄漏报告中,ExampleApplication 对象被 FontsContract.sContext 静态变量持有,表面看起来是 sContext 静态变量导致内存泄漏。其实不是,因为 ExampleApplication 的生命周期是全局的且永远不会被垃圾回收的,所以内存泄漏的根本原因一定不是因为 sContext 持有 ExampleApplication 引起的,sContext 这条引用可以排除,所以它不会用 ~~~ 下划线标记。

4.4 按 Application Leaks 和 Library Leaks 分类

为了提高排查内存泄漏的效率,LeakCanary 会自动将泄漏报告划分为 2 类:

  • Application Leaks: 应用层代码产生的内存泄漏,包括项目代码和第三方库代码;
  • Library Leaks: Android Framework 产生的内存泄漏,开发者几乎无法做什么,可以忽略。

其实,Library Leaks 这个名词起得并不好,应该叫作 Framework Leaks。 小彭最初在阅读官方文档后,以为 Library Leaks 是只第三方库代码产生的内存泄漏,LeakCanary 还提到开发者对于 Library Leaks 几乎无法做什么,让我一度很好奇 LeakCanary 是如何定义二方库和三方库。最后还是通过源码才得知,Library Leaks 原来是指 Android Framework 中产生的内存泄漏,例如什么 TextView、InputMethodManager 之类的。

Logcat 中的 Library Leak 标记

1
2
3
4
5
6
7
8
9
10
11
12
13
bash复制代码====================================
HEAP ANALYSIS RESULT
====================================
0 APPLICATION LEAKS

====================================
1 LIBRARY LEAK

...
┬───
│ GC Root: Local variable in native code
│
...

可视化分析报告中的 Library Leak 标记


  1. LeakCanary 的进阶用法

5.1 使用 App Startup 初始化 LeakCanary

LeakCanary 2.8 提供了对 Jetpack · App Startup 的支持。如果想使用 App Startup 初始化 LeakCanary,只需要替换为另一个依赖。不过,毕竟 LeakCanary 是主要在实验室环境使用的工具,这个优化的意义并不大。

build.gradle

1
2
3
4
5
groovy复制代码dependencies {
// 替换为另一个依赖
// debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.9.1'
debugImplementation 'com.squareup.leakcanary:leakcanary-android-startup:2.9.1'
}

对应的 App Startup 启动器源码:

AppWatcherStartupInitializer.kt

1
2
3
4
5
6
7
kotlin复制代码internal class AppWatcherStartupInitializer : Initializer<AppWatcherStartupInitializer> {
override fun create(context: Context) = apply {
val application = context.applicationContext as Application
AppWatcher.manualInstall(application)
}
override fun dependencies() = emptyList<Class<out Initializer<*>>>()
}

5.2 在子进程执行 LeakCanary 分析工作

由于 LeakCanary 分析堆快照的过程存在一定的内存消耗,整个分析过程一般会持续几十秒,对于一些性能差的机型会造成明显的卡顿甚至 ANR。为了优化内存占用和卡顿问题,LeakCanary 2.8 提供了对多进程的支持。开发者只需要依赖 LeakCanary 的多进程依赖项,LeakCanary 会自动将分析工作转移到子进程中(基于 androidX.work.multiprocess):

build.gradle

1
2
3
4
5
groovy复制代码dependencies {
// 官方文档对多进程功能的介绍有矛盾,经过测试,以下两个依赖都需要
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.9.1'
debugImplementation 'com.squareup.leakcanary:leakcanary-android-process:2.9.1'
}

同时,开发者需要在自定义 Application 中检查当前进程信息,避免在 LeakCanary 的子进程中执行不必要的初始化操作:

ExampleApplication.kt

1
2
3
4
5
6
7
8
9
kotlin复制代码class ExampleApplication : Application() {
override fun onCreate() {
if (LeakCanaryProcess.isInAnalyzerProcess(this)) {
return
}
super.onCreate()
// normal init goes here, skipped in :leakcanary process.
}
}

Logcat 进程选项

Logcat 日志

1
java复制代码LeakCanary: Enqueuing heap analysis for /storage/emulated/0/Download/leakcanary-com.pengxr.helloleakcanary/2022-08-22_19-54-24_331.hprof on WorkManager remote worker

5.3 使用快手 Koom 加快 Dump 速度

LeakCanary 默认的 Java Heap Dump 使用的是 Debug.dumpHprofData() ,在 Dump 的过程中会有较长时间的应用冻结时间。 快手技术团队在开源框架 Koom 中提出了优化方案:利用 Copy-on-Write 思想,fork 子进程再进行 Heap Dump 操作。

LeakCanary 配置项可以修改 Heap Dump 执行器,示例程序如下:

示例程序

1
2
3
4
5
6
7
8
9
10
11
12
kotlin复制代码// 依赖: 
debugImplementation "com.kuaishou.koom:koom-java-leak:2.2.0"

// 使用默认配置初始化 Koom
DefaultInitTask.init(application)
// 自定义 LeakCanary 配置
LeakCanary.config = LeakCanary.config.copy(
// 自定义 Heap Dump 执行器
heapDumper = {
ForkJvmHeapDumper.getInstance().dump(it.absolutePath)
}
)

Logcat 日志对比

1
2
3
4
5
6
7
8
9
10
11
12
bash复制代码// 使用默认的 Debug.dumpHprofData() 的日志
helloleakcanar: hprof: heap dump "/storage/emulated/0/Download/leakcanary-com.pengxr.helloleakcanary/2022-08-22_18-47-28_674.hprof" starting...
helloleakcanar: hprof: heap dump completed (34MB) in 1.552s objects 549530 objects with stack traces 0
LeakCanary: Enqueuing heap analysis for /storage/emulated/0/Download/leakcanary-com.pengxr.helloleakcanary/2022-08-22_19-58-13_310.hprof on WorkManager remote worker
...

// 使用快手 Koom Heap Dump 的日志
OOMMonitor_ForkJvmHeapDumper: dump /storage/emulated/0/Download/leakcanary-com.pengxr.helloleakcanary/2022-08-22_19-54-24_331.hprof
OOMMonitor_ForkJvmHeapDumper: before suspend and fork.
OOMMonitor_ForkJvmHeapDumper: dump true, notify from pid 8567
LeakCanary: Enqueuing heap analysis for /storage/emulated/0/Download/leakcanary-com.pengxr.helloleakcanary/2022-08-22_19-54-24_331.hprof on WorkManager remote worker
...

看一眼 Koom 源码:

ForkJvmHeapDumper.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
java复制代码public synchronized boolean dump(String path) {
boolean dumpRes = false;
int pid = suspendAndFork();
if (pid == 0) {
// Child process
Debug.dumpHprofData(path);
exitProcess();
} else if (pid > 0) {
// Parent process
dumpRes = resumeAndWait(pid);
}
return dumpRes;
}

private native void nativeInit();
private native int suspendAndFork();
private native boolean resumeAndWait(int pid);
private native void exitProcess();

5.4 自定义标记引用信息

LeakCanary 配置项可以自定义 ObjectInspector 对象检索器,在引用链上的节点中标记必要的信息和状态。标记信息会显示在分析报告中,并且会影响报告中的提示。

  • notLeakingReasons 标记: 标记非泄漏原因后,节点为 NOT_LEAKING 状态,并在分析报告中会显示 Leaking: NO (notLeakingReasons) ;
  • leakingReasons 标记: 标记泄漏原因后,节点为 LEAKING 状态,在分析报告中会显示 Leaking: YES (leakingReasons) ;
  • 缺省: 节点为 UNKNOWN 状态,在分析报告中会显示 Leaking: UNKNOWN 。

示例程序如下:

示例程序

1
2
3
4
5
6
7
8
9
10
kotlin复制代码// 自定义 LeakCanary 配置
LeakCanary.config = LeakCanary.config.copy(
// 自定义对象检索器
objectInspectors = LeakCanary.config.objectInspectors + ObjectInspector { reporter ->
// reporter.notLeakingReasons += "非泄漏原因"
// reporter.leakingReasons += "泄漏原因"
} + AppSingletonInspector(
// 标记全局类的类名即可
)
)

另外,引用链 LEAKING 节点以后到第一个 NOT_LEAKING 节点中间的节点,才会用 ~~~ 下划线标记为怀疑对象。例如:


  1. LeakCanary 实现原理分析

使用一张示意图表示 LeakCanary 的基本架构:

6.1 LeakCanary 如何实现自动初始化?

旧版本的 LeakCanary 需要在 Application 中调用相关初始化 API,而在 LeakCanary v2 版本中却不再需要手动初始化,为什么呢?—— 这是因为 LeakCanary 利用了 ContentProvider 的初始化机制来间接调用初始化 API。

ContentProvider 的常规用法是提供内容服务,而另一个特殊的用法是提供无侵入的初始化机制,这在第三方库中很常见,Jetpack 中提供的轻量级初始化框架 App Startup 也是基于 ContentProvider 的方案。

MainProcessAppWatcherInstaller.kt

1
2
3
4
5
6
7
8
9
kotlin复制代码internal class MainProcessAppWatcherInstaller : ContentProvider() {
override fun onCreate(): Boolean {
// 初始化 LeakCanary
val application = context!!.applicationContext as Application
AppWatcher.manualInstall(application)
return true
}
...
}

6.2 LeakCanary 初始化过程分析

LeakCanary 的初始化工程可以概括为 2 项内容:

  • 1、初始化 LeakCanary 内部分析引擎;
  • 2、在 Android Framework 上注册五种 Android 泄漏场景的监控。

AppWathcer.kt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
kotlin复制代码// LeakCanary 初始化 API
@JvmOverloads
fun manualInstall(
application: Application,
retainedDelayMillis: Long = TimeUnit.SECONDS.toMillis(5),
watchersToInstall: List<InstallableWatcher> = appDefaultWatchers(application)
) {
checkMainThread()
...
// 初始化 InternalLeakCanary 内部引擎 (已简化为等价代码,后文会提到)
InternalLeakCanary(application)
// 注册五种 Android 泄漏场景的监控 Hook 点
watchersToInstall.forEach {
it.install()
}
}

fun appDefaultWatchers(
application: Application,
reachabilityWatcher: ReachabilityWatcher = objectWatcher
): List<InstallableWatcher> {
// 对应 5 种 Android 泄漏场景(后文具体分析)
return listOf(
ActivityWatcher(application, reachabilityWatcher),
FragmentAndViewModelWatcher(application, reachabilityWatcher),
RootViewWatcher(reachabilityWatcher),
ServiceWatcher(reachabilityWatcher)
)
}

下面展开具体分析:


初始化内容 1 - 初始化 LeakCanary 内部分析引擎: 创建 HeapDumpTrigger 触发器,并在 Android Framework 上注册前后台切换监听、前台 Activity 监听和 ObjectWatcher 的泄漏监听。

InternalLeakCanary.kt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
kotlin复制代码override fun invoke(application: Application) {
_application = application

// 1. 检查是否运行在 debug 构建变体,否则抛出异常
checkRunningInDebuggableBuild()

// 2. 注册泄漏回调,在 ObjectWathcer 判定对象发生泄漏会后回调 onObjectRetained() 方法
AppWatcher.objectWatcher.addOnObjectRetainedListener(this)

// 3. 垃圾回收触发器(用于调用 Runtime.getRuntime().gc())
val gcTrigger = GcTrigger.Default
// 4. 配置提供器
val configProvider = { LeakCanary.config }
// 5. (主角) 创建 HeapDump 触发器
heapDumpTrigger = HeapDumpTrigger(...)

// 6. App 前后台切换监听
application.registerVisibilityListener { applicationVisible ->
this.applicationVisible = applicationVisible
heapDumpTrigger.onApplicationVisibilityChanged(applicationVisible)
}
// 7. 前台 Activity 监听(用于发送 Heap Dump 进行中的全局 Toast)
registerResumedActivityListener(application)

// 8. 增加可视化分析报告的桌面快捷入口
addDynamicShortcut(application)
}

override fun onObjectRetained() = scheduleRetainedObjectCheck()

fun scheduleRetainedObjectCheck() {
heapDumpTrigger.scheduleRetainedObjectCheck()
}

HeapDumpTrigger.kt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
kotlin复制代码// App 前后台切换状态变化回调
fun onApplicationVisibilityChanged(applicationVisible: Boolean) {
if (applicationVisible) {
// App 可见
applicationInvisibleAt = -1L
} else {
// App 不可见
applicationInvisibleAt = SystemClock.uptimeMillis()
scheduleRetainedObjectCheck(delayMillis = AppWatcher.retainedDelayMillis)
}
}

fun scheduleRetainedObjectCheck(delayMillis: Long = 0L) {
// 已简化:源码此处使用时间戳拦截,避免重复 postDelayed
backgroundHandler.postDelayed({
checkScheduledAt = 0
checkRetainedObjects()
}, delayMillis)
}

初始化内容 2 - 在 Android Framework 中注入对五种 Android 泄漏场景的监控: 实现在对象的使用生命周期结束后,自动将对象交给 ObjectWatcher 进行监控。

以下为 5 种 Android 泄漏场景的监控原理分析:

  • 1、Activity 监控: 通过 Application#registerActivityLifecycleCallbacks(…) 接口监听 Activity#onDestroy 事件,将当前 Activity 对象交给 ObjectWatcher 监控;

ActivityWatcher.kt

1
2
3
4
5
6
kotlin复制代码private val lifecycleCallbacks = object : Application.ActivityLifecycleCallbacks by noOpDelegate() {
override fun onActivityDestroyed(activity: Activity) {
// reachabilityWatcher 即 ObjectWatcher
reachabilityWatcher.expectWeaklyReachable(activity /*被监控对象*/, "${activity::class.java.name} received Activity#onDestroy() callback")
}
}
  • 2、Fragment 与 Fragment View 监控: 通过 FragmentAndViewModelWatcher 实现,首先是通过 Application#registerActivityLifecycleCallbacks(…) 接口监听 Activity#onCreate 事件,再通过 FragmentManager#registerFragmentLifecycleCallbacks(…) 接口监听 Fragment 的生命周期:

FragmentAndViewModelWatcher.kt

1
2
3
4
5
6
7
8
9
10
11
12
kotlin复制代码// fragmentDestroyWatchers 是一个 Lambda 表达式数组
// 对应原生、AndroidX 和 Support 三个版本 Fragment 的 Hook 工具
private val fragmentDestroyWatchers: List<(Activity) -> Unit> = 略...

private val lifecycleCallbacks = object : Application.ActivityLifecycleCallbacks by noOpDelegate() {
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
for (watcher in fragmentDestroyWatchers) {
// 最终调用到下文的 invokde() 方法
watcher(activity)
}
}
}

以 AndroidX Fragment 为例:

AndroidXFragmentDestroyWatcher.kt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
kotlin复制代码override fun invoke(activity: Activity) {
// 这里在 Activity#onCreate 状态执行:
if (activity is FragmentActivity) {
val supportFragmentManager = activity.supportFragmentManager
// 注册 Fragment 生命周期监听
supportFragmentManager.registerFragmentLifecycleCallbacks(fragmentLifecycleCallbacks, true)
// 注册 Activity 级别 ViewModel Hook
ViewModelClearedWatcher.install(activity, reachabilityWatcher)
}
}

private val fragmentLifecycleCallbacks = object : FragmentManager.FragmentLifecycleCallbacks() {

override fun onFragmentCreated(fm: FragmentManager, fragment: Fragment, savedInstanceState: Bundle?) {
// 注册 Fragment 级别 ViewModel Hook
ViewModelClearedWatcher.install(fragment, reachabilityWatcher)
}

override fun onFragmentViewDestroyed(fm: FragmentManager, fragment: Fragment) {
// reachabilityWatcher 即 ObjectWatcher
reachabilityWatcher.expectWeaklyReachable(fragment.view /*被监控对象*/, "${fragment::class.java.name} received Fragment#onDestroyView() callback " + "(references to its views should be cleared to prevent leaks)")
}

override fun onFragmentDestroyed(fm: FragmentManager, fragment: Fragment) {
// reachabilityWatcher 即 ObjectWatcher
reachabilityWatcher.expectWeaklyReachable(fragment /*被监控对象*/, "${fragment::class.java.name} received Fragment#onDestroy() callback")
}
}
  • 3、ViewModel 监控: 由于 Android Framework 未提供设置 ViewModel#onClear() 全局监听的方法,所以 LeakCanary 是通过 Hook 的方式实现。即:在 Activity#onCreate 和 Fragment#onCreate 事件中实例化一个自定义ViewModel,在进入 ViewModel#onClear() 方法时,通过反射获取当前作用域中所有的 ViewModel 对象交给 ObjectWatcher 监控。

ViewModelClearedWatcher.kt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
kotlin复制代码// ViewModel 的子类
internal class ViewModelClearedWatcher(
storeOwner: ViewModelStoreOwner,
private val reachabilityWatcher: ReachabilityWatcher
) : ViewModel() {

// 反射获取 ViewModelStore 中的 ViewModel 映射表,即可获取当前作用域所有 ViewModel 对象
private val viewModelMap: Map<String, ViewModel>? = try {
val mMapField = ViewModelStore::class.java.getDeclaredField("mMap")
mMapField.isAccessible = true
mMapField[storeOwner.viewModelStore] as Map<String, ViewModel>
} catch (ignored: Exception) {
null
}

override fun onCleared() {
// 遍历当前作用域所有 ViewModel 对象
viewModelMap?.values?.forEach { viewModel ->
// reachabilityWatcher 即 ObjectWatcher
reachabilityWatcher.expectWeaklyReachable(viewModel /*被监控对象*/, "${viewModel::class.java.name} received ViewModel#onCleared() callback")
}
}

companion object {
// 直接在 storeOwner 作用域实例化 ViewModelClearedWatcher 对象
fun install(storeOwner: ViewModelStoreOwner, reachabilityWatcher: ReachabilityWatcher) {
val provider = ViewModelProvider(storeOwner, object : Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T =
ViewModelClearedWatcher(storeOwner, reachabilityWatcher) as T
})
provider.get(ViewModelClearedWatcher::class.java)
}
}
}
  • 4、Service 监控: 由于 Android Framework 未提供设置 Service#onDestroy() 全局监听的方法,所以 LeakCanary 是通过 Hook 的方式实现的。

Service 监控这部分源码比较复杂了,需要通过 2 步 Hook 来实现:

  • 1、Hook 主线程消息循环的 mH.mCallback 回调,监听其中的 STOP_SERVICE 消息,将即将 Destroy 的 Service 对象暂存起来(由于 ActivityThread.H 中没有 DESTROY_SERVICE 消息,所以不能直接监听到 onDestroy() 事件,需要第 2 步);
  • 2、使用动态代理 Hook AMS 与 App 通信的的 IActivityManager Binder 对象,代理其中的 serviceDoneExecuting() 方法,视为 Service#onDestroy() 的执行时机,拿到暂存的 Service 对象交给 ObjectWatcher 监控。

源码摘要如下:

ServiceWatcher.kt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
kotlin复制代码private var uninstallActivityThreadHandlerCallback: (() -> Unit)? = null

// 暂存即将 Destroy 的 Service
private val servicesToBeDestroyed = WeakHashMap<IBinder, WeakReference<Service>>()

override fun install() {
// 1. Hook mH.mCallback
swapActivityThreadHandlerCallback { mCallback /*原对象*/ ->
// uninstallActivityThreadHandlerCallback:用于取消 Hook
uninstallActivityThreadHandlerCallback = {
swapActivityThreadHandlerCallback {
mCallback
}
}
// 新对象(lambda 表达式的末行就是返回值)
Handler.Callback { msg ->
// 1.1 Service#onStop() 事件
if (msg.what == STOP_SERVICE) {
val key = msg.obj as IBinder
// 1.2 activityThreadServices:反射获取 ActivityThread mServices 映射表 <IBinder, CreateServiceData>
activityThreadServices[key]?.let {
// 1.3 暂存即将 Destroy 的 Service
servicesToBeDestroyed[token] = WeakReference(service)
}
}
// 1.4 继续执行 Framework 原有逻辑
mCallback?.handleMessage(msg) ?: false
}
}
// 2. Hook AMS IActivityManager
swapActivityManager { activityManagerInterface, activityManagerInstance /*原对象*/ ->
// uninstallActivityManager:用于取消 Hook
uninstallActivityManager = {
swapActivityManager { _, _ ->
activityManagerInstance
}
}
// 新对象(lambda 表达式的末行就是返回值)
Proxy.newProxyInstance(activityManagerInterface.classLoader, arrayOf(activityManagerInterface)) { _, method, args ->
// 2.1 代理 serviceDoneExecuting() 方法
if (METHOD_SERVICE_DONE_EXECUTING == method.name) {
// 2.2 取出暂存的即将 Destroy 的 Service
val token = args!![0] as IBinder
if (servicesToBeDestroyed.containsKey(token)) {
servicesToBeDestroyed.remove(token)?.also { serviceWeakReference ->
// 2.3 交给 ObjectWatcher 监控
serviceWeakReference.get()?.let { service ->
reachabilityWatcher.expectWeaklyReachable(service /*被监控对象*/, "${service::class.java.name} received Service#onDestroy() callback")
}
}
}
}
// 2.4 继续执行 Framework 原有逻辑
method.invoke(activityManagerInstance, *args)
}
}
}

override fun uninstall() {
// 关闭 mH.mCallback 的 Hook
uninstallActivityManager?.invoke()
uninstallActivityThreadHandlerCallback?.invoke()
uninstallActivityManager = null
uninstallActivityThreadHandlerCallback = null
}

// 使用反射修改 ActivityThread 的主线程消息循环的 mH.mCallback
// swap 是一个 lambda 表达式,参数为原对象,返回值为注入的新对象
private fun swapActivityThreadHandlerCallback(swap: (Handler.Callback?) -> Handler.Callback?) {
val mHField = activityThreadClass.getDeclaredField("mH").apply { isAccessible = true }
val mH = mHField[activityThreadInstance] as Handler

val mCallbackField = Handler::class.java.getDeclaredField("mCallback").apply { isAccessible = true }
val mCallback = mCallbackField[mH] as Handler.Callback?
// 将 swap 的返回值作为新对象,实现 Hook
mCallbackField[mH] = swap(mCallback)
}

// 使用反射修改 AMS 与 App 通信的 IActivityManager Binder 对象
// swap 是一个 lambda 表达式,参数为 IActivityManager 的 Class 对象和接口原实现对象,返回值为注入的新对象
private fun swapActivityManager(swap: (Class<*>, Any) -> Any) {
val singletonClass = Class.forName("android.util.Singleton")
val mInstanceField = singletonClass.getDeclaredField("mInstance").apply { isAccessible = true }

val singletonGetMethod = singletonClass.getDeclaredMethod("get")

val (className, fieldName) = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
"android.app.ActivityManager" to "IActivityManagerSingleton"
} else {
"android.app.ActivityManagerNative" to "gDefault"
}

val activityManagerClass = Class.forName(className)
val activityManagerSingletonField = activityManagerClass.getDeclaredField(fieldName).apply { isAccessible = true }
val activityManagerSingletonInstance = activityManagerSingletonField[activityManagerClass]

// Calling get() instead of reading from the field directly to ensure the singleton is
// created.
val activityManagerInstance = singletonGetMethod.invoke(activityManagerSingletonInstance)

val iActivityManagerInterface = Class.forName("android.app.IActivityManager")
// 将 swap 的返回值作为新对象,实现 Hook
mInstanceField[activityManagerSingletonInstance] = swap(iActivityManagerInterface, activityManagerInstance!!)
}
  • 5、RootView 监控: 由于 Android Framework 未提供设置全局监听 RootView 从 WindowManager 中移除的方法,所以 LeakCanary 是通过 Hook 的方式实现的,这一块是通过 squareup 另一个开源库 curtains 实现的。

RootView 监控这部分源码也比较复杂了,需要通过 2 步 Hook 来实现:

  • 1、Hook WMS 服务内部的 WindowManagerGlobal.mViews RootView 列表,获取 RootView 新增和移除的时机;
  • 2、检查 View 对应的 Window 类型,如果是 Dialog 或 DreamService 等类型,则在注册 View#addOnAttachStateChangeListener() 监听,在其中的 onViewDetachedFromWindow() 回调中将 View 对象交给 ObjectWatcher 监控。

LeakCanary 源码摘要如下:

RootViewWatcher.kt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
kotlin复制代码override fun install() {
// 1. 注册 RootView 监听
Curtains.onRootViewsChangedListeners += listener
}

private val listener = OnRootViewAddedListener { rootView ->
val trackDetached = when(rootView.windowType) {
PHONE_WINDOW -> {
when (rootView.phoneWindow?.callback?.wrappedCallback) {
// Activity 类型已经在 ActivityWatcher 中监控了,不需要重复监控
is Activity -> false
is Dialog -> {
// leak_canary_watcher_watch_dismissed_dialogs:Dialog 监控开关
val resources = rootView.context.applicationContext.resources
resources.getBoolean(R.bool.leak_canary_watcher_watch_dismissed_dialogs)
}
// DreamService 屏保等
else -> true
}
}
POPUP_WINDOW -> false
TOOLTIP, TOAST, UNKNOWN -> true
}
if (trackDetached) {
// 2. 注册 View#addOnAttachStateChangeListener 监听
rootView.addOnAttachStateChangeListener(object : OnAttachStateChangeListener {
val watchDetachedView = Runnable {
// 3. 交给 ObjectWatcher 监控
reachabilityWatcher.expectWeaklyReachable(rootView /*被监控对象*/ , "${rootView::class.java.name} received View#onDetachedFromWindow() callback")
}

override fun onViewAttachedToWindow(v: View) {
mainHandler.removeCallbacks(watchDetachedView)
}

override fun onViewDetachedFromWindow(v: View) {
mainHandler.post(watchDetachedView)
}
})
}
}

curtains 源码摘要如下:

RootViewsSpy.kt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
kotlin复制代码private val delegatingViewList = object : ArrayList<View>() {
// 重写 ArrayList#add 方法
override fun add(element: View): Boolean {
// 回调
listeners.forEach { it.onRootViewsChanged(element, true) }
return super.add(element)
}

// 重写 ArrayList#removeAt 方法
override fun removeAt(index: Int): View {
// 回调
val removedView = super.removeAt(index)
listeners.forEach { it.onRootViewsChanged(removedView, false) }
return removedView
}
}

companion object {
fun install(): RootViewsSpy {
return RootViewsSpy().apply {
WindowManagerSpy.swapWindowManagerGlobalMViews { mViews /*原对象*/ ->
// 新对象(lambda 表达式的末行就是返回值)
delegatingViewList.apply { addAll(mViews) }
}
}
}
}

WindowManageSpy.kt

1
2
3
4
5
6
7
8
9
10
kotlin复制代码// Hook WMS 服务内部的 WindowManagerGlobal.mViews RootView 列表
// swap 是一个 lambda 表达式,参数为原对象,返回值为注入的新对象
fun swapWindowManagerGlobalMViews(swap: (ArrayList<View>) -> ArrayList<View>) {
windowManagerInstance?.let { windowManagerInstance ->
mViewsField?.let { mViewsField ->
val mViews = mViewsField[windowManagerInstance] as ArrayList<View>
mViewsField[windowManagerInstance] = swap(mViews)
}
}
}

至此,LeakCanary 初始化完成,并且成功在 Android Framework 的各个位置安插监控,实现对 Activity 和 Service 等对象进入无用状态的监听。我们可以用一张示意图描述 LeakCanary 的部分结构:

6.3 LeakCanary 如何判定对象泄漏?

在以上步骤中,当对象的使用生命周期结束后,会交给 ObjectWatcher 监控,现在我们来具体看下它是怎么判断对象发生泄漏的。主要逻辑概括为 3 步:

  • 第 1 步: 为被监控对象 watchedObject 创建一个 KeyedWeakReference 弱引用,并存储到 <UUID, KeyedWeakReference> 的映射表中;
  • 第 2 步: postDelay 五秒后检查引用对象是否出现在引用队列中,出现在队列则说明被监控对象未发生泄漏。随后,移除映射表中未泄露的记录,更新泄漏的引用对象的 retainedUptimeMillis 字段以标记为泄漏;
  • 第 3 步: 通过回调 onObjectRetained 告知 LeakCanary 内部发生新的内存泄漏。

源码摘要如下:

AppWatcher.kt

1
2
3
4
5
6
7
8
9
10
kotlin复制代码val objectWatcher = ObjectWatcher(
// lambda 表达式获取当前系统时间
clock = { SystemClock.uptimeMillis() },
// lambda 表达式实现 Executor SAM 接口
checkRetainedExecutor = {
mainHandler.postDelayed(it, retainedDelayMillis)
},
// lambda 表达式获取监控开关
isEnabled = { true }
)

ObjectWatcher.kt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
kotlin复制代码class ObjectWatcher constructor(
private val clock: Clock,
private val checkRetainedExecutor: Executor,
private val isEnabled: () -> Boolean = { true }
) : ReachabilityWatcher {

if (!isEnabled()) {
// 监控开关
return
}

// 被监控的对象映射表 <UUID,KeyedWeakReference>
private val watchedObjects = mutableMapOf<String, KeyedWeakReference>()

// KeyedWeakReference 关联的引用队列,用于判断对象是否泄漏
private val queue = ReferenceQueue<Any>()

// 1. 为 watchedObject 对象增加监控
@Synchronized
override fun expectWeaklyReachable(
watchedObject: Any,
description: String
) {
// 1.1 移除 watchedObjects 中未泄漏的引用对象
removeWeaklyReachableObjects()
// 1.2 新建一个 KeyedWeakReference 引用对象
val key = UUID.randomUUID().toString()
val watchUptimeMillis = clock.uptimeMillis()
watchedObjects[key] = KeyedWeakReference(watchedObject, key, description, watchUptimeMillis, queue)
// 2. 五秒后检查引用对象是否出现在引用队列中,否则判定发生泄漏
// checkRetainedExecutor 相当于 postDelay 五秒后执行 moveToRetained() 方法
checkRetainedExecutor.execute {
moveToRetained(key)
}
}

// 2. 五秒后检查引用对象是否出现在引用队列中,否则说明发生泄漏
@Synchronized
private fun moveToRetained(key: String) {
// 2.1 移除 watchedObjects 中未泄漏的引用对象
removeWeaklyReachableObjects()
// 2.2 依然存在的引用对象被判定发生泄漏
val retainedRef = watchedObjects[key]
if (retainedRef != null) {
retainedRef.retainedUptimeMillis = clock.uptimeMillis()
// 3. 回调通知 LeakCanary 内部处理
onObjectRetainedListeners.forEach { it.onObjectRetained() }
}
}

// 移除未泄漏对象对应的 KeyedWeakReference
private fun removeWeaklyReachableObjects() {
var ref: KeyedWeakReference?
do {
ref = queue.poll() as KeyedWeakReference?
if (ref != null) {
// KeyedWeakReference 出现在引用队列中,说明未发生泄漏
watchedObjects.remove(ref.key)
}
} while (ref != null)
}

// 4. Heap Dump 后移除所有监控时间早于 heapDumpUptimeMillis 的引用对象
@Synchronized
fun clearObjectsWatchedBefore(heapDumpUptimeMillis: Long) {
val weakRefsToRemove = watchedObjects.filter { it.value.watchUptimeMillis <= heapDumpUptimeMillis }
weakRefsToRemove.values.forEach { it.clear() }
watchedObjects.keys.removeAll(weakRefsToRemove.keys)
}

// 获取是否有内存泄漏对象
val hasRetainedObjects: Boolean
@Synchronized get() {
// 移除 watchedObjects 中未泄漏的引用对象
removeWeaklyReachableObjects()
return watchedObjects.any { it.value.retainedUptimeMillis != -1L }
}

// 获取内存泄漏对象计数
val retainedObjectCount: Int
@Synchronized get() {
// 移除 watchedObjects 中未泄漏的引用对象
removeWeaklyReachableObjects()
return watchedObjects.count { it.value.retainedUptimeMillis != -1L }
}
}

被监控对象 watchedObject 关联的弱引用对象:

KeyedWeakReference.kt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
kotlin复制代码class KeyedWeakReference(
// 被监控对象
referent: Any,
// 唯一 Key,根据此字段匹配映射表中的记录
val key: String,
// 描述信息
val description: String,
// 监控开始时间,即引用对象创建时间
val watchUptimeMillis: Long,
// 关联的引用队列
referenceQueue: ReferenceQueue<Any>
) : WeakReference<Any>(referent, referenceQueue) {

// 记录实际对象 referent 被判定为泄漏对象的时间
// -1L 表示非泄漏对象,或者还未判定完成
@Volatile
var retainedUptimeMillis = -1L

override fun clear() {
super.clear()
retainedUptimeMillis = -1L
}

companion object {
// 记录最近一次触发 Heap Dump 的时间
@Volatile
@JvmStatic var heapDumpUptimeMillis = 0L
}
}

6.4 LeakCanary 发现泄漏对象后就会触发分析吗?

ObjectWatcher 判定被监控对象发生泄漏后,会通过接口方法 OnObjectRetainedListener#onObjectRetained() 回调到 LeakCanary 内部的管理器 InternalLeakCanary 处理(在前文 AppWatcher 初始化中提到过)。LeakCanary 不会每次发现内存泄漏对象都进行分析工作,而会进行两个拦截:

  • 拦截 1:泄漏对象计数未达到阈值,或者进入后台时间未达到阈值;
  • 拦截 2:计算距离上一次 HeapDump 未超过 60s。

源码摘要如下:

InternalLeakCanary.kt

1
2
3
4
5
6
7
8
9
10
kotlin复制代码// 从 ObjectWatcher 回调过来
override fun onObjectRetained() = scheduleRetainedObjectCheck()

private lateinit var heapDumpTrigger: HeapDumpTrigger

fun scheduleRetainedObjectCheck() {
if (this::heapDumpTrigger.isInitialized) {
heapDumpTrigger.scheduleRetainedObjectCheck()
}
}

HeapDumpTrigger.kt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
kotlin复制代码fun scheduleRetainedObjectCheck(delayMillis: Long = 0L) {
// 已简化:源码此处使用时间戳拦截,避免重复 postDelayed
backgroundHandler.postDelayed({
checkRetainedObjects()
}, delayMillis)
}

private fun checkRetainedObjects() {
val config = configProvider()

// 泄漏对象计数
var retainedReferenceCount = objectWatcher.retainedObjectCount
if (retainedReferenceCount > 0) {
// 主动触发 GC,并等待 100 ms
gcTrigger.runGc()
// 重新获取泄漏对象计数
retainedReferenceCount = objectWatcher.retainedObjectCount
}

// 拦截 1:泄漏对象计数未达到阈值,或者进入后台时间未达到阈值
if (retainedKeysCount < retainedVisibleThreshold) {
// App 位于前台或者刚刚进入后台
if (applicationVisible || applicationInvisibleLessThanWatchPeriod) {
// 发送通知提醒
showRetainedCountNotification("App visible, waiting until %d retained objects")
// 延迟 2 秒再检查
scheduleRetainedObjectCheck(WAIT_FOR_OBJECT_THRESHOLD_MILLIS)
return;
}
}

// 拦截 2:计算距离上一次 HeapDump 未超过 60s
val now = SystemClock.uptimeMillis()
val elapsedSinceLastDumpMillis = now - lastHeapDumpUptimeMillis
if (elapsedSinceLastDumpMillis < WAIT_BETWEEN_HEAP_DUMPS_MILLIS) {
// 发送通知提醒
showRetainedCountNotification("Last heap dump was less than a minute ago")
// 延迟 (60 - elapsedSinceLastDumpMillis)s 再检查
scheduleRetainedObjectCheck(WAIT_BETWEEN_HEAP_DUMPS_MILLIS - elapsedSinceLastDumpMillis)
return
}

// 移除通知提醒
dismissRetainedCountNotification()
// 触发 HeapDump(此时,应用有可能在后台)
dumpHeap(...)
}

// 真正开始执行 Heap Dump
private fun dumpHeap(...) {
// 1. 获取文件存储提供器
val directoryProvider = InternalLeakCanary.createLeakDirectoryProvider(InternalLeakCanary.application)

// 2. 创建 .hprof File 文件
val heapDumpFile = directoryProvider.newHeapDumpFile()

// 3. 执行 Heap Dump
// Heap Dump 开始时间戳
val heapDumpUptimeMillis = SystemClock.uptimeMillis()
// heapDumper.dumpHeap:最终调用 Debug.dumpHprofData(heapDumpFile.absolutePath)
configProvider().heapDumper.dumpHeap(heapDumpFile)

// 4. 清除 ObjectWatcher 中过期的监控
objectWatcher.clearObjectsWatchedBefore(heapDumpUptimeMillis)

// 5. 分析堆快照
InternalLeakCanary.sendEvent(HeapDump(currentEventUniqueId!!, heapDumpFile, durationMillis, reason))
}

请求 GC 的源码可以看一眼:

GcTrigger.kt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
kotlin复制代码fun interface GcTrigger {

fun runGc()

object Default : GcTrigger {
override fun runGc() {
// Runtime.gc() 相比于 System.gc() 更有可能触发 GC
Runtime.getRuntime().gc()
// 暂停等待 GC
Thread.sleep(100)
System.runFinalization()
}
}
}

6.5 LeakCanary 在哪个线程分析堆快照?

在前面的工作中,LeakCanary 已经成功生成 .hprof 堆快照文件,并且发送了一个 LeakCanary 内部事件 HeapDump。那么这个事件在哪里被消费的呢?

一步步跟踪代码可以看到 LeakCanary 的配置项中设置了多个事件消费者 EventListener,其中与 HeapDump 事件有关的是 when{} 代码块中三个消费者。不过,这三个消费者并不是并存的,而是会根据 App 当前的依赖项而选择最优的执行策略:

  • 策略 1 - WorkerManager 多进程分析
  • 策略 2 - WorkManager 异步分析
  • 策略 3 - 异步线程分析(兜底策略)

LeakCanary 配置项中的事件消费者:

LeakCanary.kt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
kotlin复制代码data class Config(
val eventListeners: List<EventListener> = listOf(
LogcatEventListener,
ToastEventListener,
LazyForwardingEventListener {
if (InternalLeakCanary.formFactor == TV) TvEventListener else NotificationEventListener
},
when {
// 策略 1 - WorkerManager 多进程分析
RemoteWorkManagerHeapAnalyzer.remoteLeakCanaryServiceInClasspath ->RemoteWorkManagerHeapAnalyzer
// 策略 2 - WorkManager 异步分析
WorkManagerHeapAnalyzer.validWorkManagerInClasspath -> WorkManagerHeapAnalyzer
// 策略 3 - 异步线程分析(兜底策略)
else -> BackgroundThreadHeapAnalyzer
}
),
...
)
  • 策略 1 - WorkerManager 多进程分析: 判断是否可以类加载 RemoteLeakCanaryWorkerService ,这个类位于前文提到的 com.squareup.leakcanary:leakcanary-android-process:2.9.1 依赖中。如果可以类加载成功则视为有依赖,使用 WorkerManager 多进程分析;

RemoteWorkManagerHeapAnalyzer.kt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
kotlin复制代码object RemoteWorkManagerHeapAnalyzer : EventListener {

// 通过类加载是否成功,判断是否存在依赖
internal val remoteLeakCanaryServiceInClasspath by lazy {
try {
Class.forName("leakcanary.internal.RemoteLeakCanaryWorkerService")
true
} catch (ignored: Throwable) {
false
}
}

override fun onEvent(event: Event) {
if (event is HeapDump) {
// 创建并分发 WorkManager 多进程请求
val heapAnalysisRequest = OneTimeWorkRequest.Builder(RemoteHeapAnalyzerWorker::class.java).apply {
val dataBuilder = Data.Builder()
.putString(ARGUMENT_PACKAGE_NAME, application.packageName)
.putString(ARGUMENT_CLASS_NAME, REMOTE_SERVICE_CLASS_NAME)
setInputData(event.asWorkerInputData(dataBuilder))
with(WorkManagerHeapAnalyzer) {
addExpeditedFlag()
}
}.build()
WorkManager.getInstance(application).enqueue(heapAnalysisRequest)
}
}
}

RemoteHeapAnalyzerWorker.kt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
kotlin复制代码internal class RemoteHeapAnalyzerWorker(appContext: Context, workerParams: WorkerParameters) : RemoteListenableWorker(appContext, workerParams) {
override fun startRemoteWork(): ListenableFuture<Result> {
val heapDump = inputData.asEvent<HeapDump>()
val result = SettableFuture.create<Result>()
heapAnalyzerThreadHandler.post {
// 1.1 分析堆快照
val doneEvent = AndroidDebugHeapAnalyzer.runAnalysisBlocking(heapDump, isCanceled = {
result.isCancelled
}) { progressEvent ->
// 1.2 发送分析进度事件
if (!result.isCancelled) {
InternalLeakCanary.sendEvent(progressEvent)
}
}
// 1.3 发送分析完成事件
InternalLeakCanary.sendEvent(doneEvent)
result.set(Result.success())
}
return result
}
}
  • 策略 2 - WorkManager 异步分析: 判断是否可以类加载 androidx.work.WorkManager ,如果可以,则使用 WorkManager 异步分析;

WorkManagerHeapAnalyzer.kt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
kotlin复制代码internal val validWorkManagerInClasspath by lazy {
// 判断 WorkManager 依赖,代码略
}

override fun onEvent(event: Event) {
if (event is HeapDump) {
// 创建并分发 WorkManager 请求
val heapAnalysisRequest = OneTimeWorkRequest.Builder(HeapAnalyzerWorker::class.java).apply {
setInputData(event.asWorkerInputData())
addExpeditedFlag()
}.build()
val application = InternalLeakCanary.application
WorkManager.getInstance(application).enqueue(heapAnalysisRequest)
}
}

HeapAnalyzerWorker.kt

1
2
3
4
5
6
7
8
9
10
11
12
kotlin复制代码internal class HeapAnalyzerWorker(appContext: Context, workerParams: WorkerParameters) : Worker(appContext, workerParams) {
override fun doWork(): Result {
// 2.1 分析堆快照
val doneEvent = AndroidDebugHeapAnalyzer.runAnalysisBlocking(inputData.asEvent()) { event ->
// 2.2 发送分析进度事件
InternalLeakCanary.sendEvent(event)
}
// 2.3 发送分析完成事件
InternalLeakCanary.sendEvent(doneEvent)
return Result.success()
}
}
  • 策略 3 - 异步线程分析(兜底策略): 如果以上策略未命中,则直接使用子线程兜底执行。

BackgroundThreadHeapAnalyzer.kt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
kotlin复制代码object BackgroundThreadHeapAnalyzer : EventListener {

// HandlerThread
internal val heapAnalyzerThreadHandler by lazy {
val handlerThread = HandlerThread("HeapAnalyzer")
handlerThread.start()
Handler(handlerThread.looper)
}

override fun onEvent(event: Event) {
if (event is HeapDump) {
// HandlerThread 请求
heapAnalyzerThreadHandler.post {
// 3.1 分析堆快照
val doneEvent = AndroidDebugHeapAnalyzer.runAnalysisBlocking(event) { event ->
// 3.2 发送分析进度事件
InternalLeakCanary.sendEvent(event)
}
// 3.3 发送分析完成事件
InternalLeakCanary.sendEvent(doneEvent)
}
}
}
}

可以看到,不管采用那种执行策略,最终执行的逻辑都是一样的:

  • 1、分析堆快照;
  • 2、发送分析进度事件;
  • 3、发送分析完成事件。

6.5 LeakCanary 如何分析堆快照?

在前面的分析中,我们已经知道 LeakCanary 是通过子线程或者子进程执行 AndroidDebugHeapAnalyzer.runAnalysisBlocking 方法来分析堆快照的,并在分析过程中和分析完成后发送回调事件。现在我们来阅读 LeakCanary 的堆快照分析过程:

AndroidDebugHeapAnalyzer.kt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
kotlin复制代码fun runAnalysisBlocking(
heapDumped: HeapDump,
isCanceled: () -> Boolean = { false },
progressEventListener: (HeapAnalysisProgress) -> Unit
): HeapAnalysisDone<*> {
...
// 1. .hprof 文件
val heapDumpFile = heapDumped.file
// 2. 分析堆快照
val heapAnalysis = analyzeHeap(heapDumpFile, progressListener, isCanceled)
val analysisDoneEvent = ScopedLeaksDb.writableDatabase(application) { db ->
// 3. 将分析报告持久化到 DB
val id = HeapAnalysisTable.insert(db, heapAnalysis)
// 4. 发送分析完成事件(返回到上一级进行发送:InternalLeakCanary.sendEvent(doneEvent))
val showIntent = LeakActivity.createSuccessIntent(application, id)
val leakSignatures = fullHeapAnalysis.allLeaks.map { it.signature }.toSet()
val leakSignatureStatuses = LeakTable.retrieveLeakReadStatuses(db, leakSignatures)
val unreadLeakSignatures = leakSignatureStatuses.filter { (_, read) -> !read}.keys.toSet()
HeapAnalysisSucceeded(heapDumped.uniqueId, fullHeapAnalysis, unreadLeakSignatures ,showIntent)
}
return analysisDoneEvent
}

核心分析方法是 analyzeHeap(…),继续往下走:

AndroidDebugHeapAnalyzer.kt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
kotlin复制代码private fun analyzeHeap(
heapDumpFile: File,
progressListener: OnAnalysisProgressListener,
isCanceled: () -> Boolean
): HeapAnalysis {
...
// Shark 堆快照分析器
val heapAnalyzer = HeapAnalyzer(progressListener)
...
// 构建对象图信息
val sourceProvider = ConstantMemoryMetricsDualSourceProvider(ThrowingCancelableFileSourceProvider(heapDumpFile)
val graph = sourceProvider.openHeapGraph(proguardMapping = proguardMappingReader?.readProguardMapping())
...
// 开始分析
heapAnalyzer.analyze(
heapDumpFile = heapDumpFile,
graph = graph,
leakingObjectFinder = config.leakingObjectFinder, // 默认是 KeyedWeakReferenceFinder
referenceMatchers = config.referenceMatchers, // 默认是 AndroidReferenceMatchers
computeRetainedHeapSize = config.computeRetainedHeapSize, // 默认是 true
objectInspectors = config.objectInspectors, // 默认是 AndroidObjectInspectors
metadataExtractor = config.metadataExtractor // 默认是 AndroidMetadataExtractor
)
}

开始进入 Shark 组件:

shark.HeapAnalyzer.kt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
kotlin复制代码// analyze -> analyze -> FindLeakInput.analyzeGraph
private fun FindLeakInput.analyzeGraph(
metadataExtractor: MetadataExtractor,
leakingObjectFinder: LeakingObjectFinder,
heapDumpFile: File,
analysisStartNanoTime: Long
): HeapAnalysisSuccess {
...
// 1. 在堆快照中寻找泄漏对象,默认是寻找 KeyedWeakReference 类型对象
// leakingObjectFinder 默认是 KeyedWeakReferenceFinder
val leakingObjectIds = leakingObjectFinder.findLeakingObjectIds(graph)
// 2. 分析泄漏对象的最短引用链,并按照应用链签名分类
// applicationLeaks: Application Leaks
// librbuildLeakTracesaryLeaks:Library Leaks
// unreachableObjects:LeakCanary 无法分析出强引用链,可以提 Stack Overflow
val (applicationLeaks, libraryLeaks, unreachableObjects) = findLeaks(leakingObjectIds)
// 3. 返回分析完成事件
return HeapAnalysisSuccess(...)
}

private fun FindLeakInput.findLeaks(leakingObjectIds: Set<Long>): LeaksAndUnreachableObjects {
// PathFinder:引用链分析器
val pathFinder = PathFinder(graph, listener, referenceReader, referenceMatchers)
// pathFindingResults:完整引用链
val pathFindingResults = pathFinder.findPathsFromGcRoots(leakingObjectIds, computeRetainedHeapSize)
// unreachableObjects:LeakCanary 无法分析出强引用链(相当于 LeakCanary 的 Bug)
val unreachableObjects = findUnreachableObjects(pathFindingResults, leakingObjectIds)
// shortestPaths:最短引用链
val shortestPaths = deduplicateShortestPaths(pathFindingResults.pathsToLeakingObjects)
// inspectedObjectsByPath:标记信息
val inspectedObjectsByPath = inspectObjects(shortestPaths)
// retainedSizes:泄漏内存大小
val retainedSizes = computeRetainedSizes(inspectedObjectsByPath, pathFindingResults.dominatorTree)
// 生成单个泄漏问题的分析报告,并按照应用链签名分组,按照 Application Leaks 和 Library Leaks 分类,按照 Application Leaks 和 Library Leaks 分类
// applicationLeaks: Application Leaks
// librbuildLeakTracesaryLeaks:Library Leaks
val (applicationLeaks, librbuildLeakTracesaryLeaks) = buildLeakTraces(shortestPaths, inspectedObjectsByPath, retainedSizes)
return LeaksAndUnreachableObjects(applicationLeaks, libraryLeaks, unreachableObjects)
}

可以看到,堆快照分析最终是交给 Shark 中的 HeapAnalizer 完成的,核心流程是:

  • 1、在堆快照中寻找泄漏对象,默认是寻找 KeyedWeakReference 类型对象;
  • 2、分析 KeyedWeakReference 对象的最短引用链,并按照引用链签名分组,按照 Application Leaks 和 Library Leaks 分类;
  • 3、返回分析完成事件。

第 1 步和第 3 步不用说了,继续分析最复杂的第 2 步:

shark.HeapAnalyzer.kt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
kotlin复制代码// 生成单个泄漏问题的分析报告,并按照应用链签名分组,按照 Application Leaks 和 Library Leaks 分类,按照 Application Leaks 和 Library Leaks 分类
private fun FindLeakInput.buildLeakTraces(
shortestPaths: List<ShortestPath> /*最短引用链*/ ,
inspectedObjectsByPath: List<List<InspectedObject>> /*标记信息*/ ,
retainedSizes: Map<Long, Pair<Int, Int>>? /*泄漏内存大小*/
): Pair<List<ApplicationLeak>, List<LibraryLeak>> {
// Application Leaks
val applicationLeaksMap = mutableMapOf<String, MutableList<LeakTrace>>()
// Library Leaks
val libraryLeaksMap = mutableMapOf<String, Pair<LibraryLeakReferenceMatcher, MutableList<LeakTrace>>>()

shortestPaths.forEachIndexed { pathIndex, shortestPath ->
// 标记信息
val inspectedObjects = inspectedObjectsByPath[pathIndex]
// 实例化引用链上的每个对象快照(非怀疑对象的 leakingStatus 为 NOT_LEAKING)
val leakTraceObjects = buildLeakTraceObjects(inspectedObjects, retainedSizes)
val referencePath = buildReferencePath(shortestPath, leakTraceObjects)
// 分析报告
val leakTrace = LeakTrace(
gcRootType = GcRootType.fromGcRoot(shortestPath.root.gcRoot),
referencePath = referencePath,
leakingObject = leakTraceObjects.last()
)
val firstLibraryLeakMatcher = shortestPath.firstLibraryLeakMatcher()
if (firstLibraryLeakMatcher != null) {
// Library Leaks
val signature: String = firstLibraryLeakMatcher.pattern.toString().createSHA1Hash()
libraryLeaksMap.getOrPut(signature) { firstLibraryLeakMatcher to mutableListOf() }.second += leakTrace
} else {
// Application Leaks
applicationLeaksMap.getOrPut(leakTrace.signature) { mutableListOf() } += leakTrace
}
}
val applicationLeaks = applicationLeaksMap.map { (_, leakTraces) ->
// 实例化为 ApplicationLeak 类型
ApplicationLeak(leakTraces)
}
val libraryLeaks = libraryLeaksMap.map { (_, pair) ->
// 实例化为 LibraryLeak 类型
val (matcher, leakTraces) = pair
LibraryLeak(leakTraces, matcher.pattern, matcher.description)
}
return applicationLeaks to libraryLeaks
}

6.6 LeakCanary 如何筛选 ~ 怀疑对象?

LeakCanary 会使用 ObjectInspector 对象检索器在引用链上的节点中标记必要的信息和状态,标记信息会显示在分析报告中,并且会影响报告中的提示。而引用链 LEAKING 节点以后到第一个 NOT_LEAKING 节点中间的节点,才会用 ~~~ 下划线标记为怀疑对象。

在第 6.5 节中,LeakCanary 通过 leakingObjectFinder 标记引用信息,leakingObjectFinder 默认是 AndroidObjectInspectors.appDefaults ,也可以在配置项中自定义。

1
2
kotlin复制代码// inspectedObjectsByPath:筛选出非怀疑对象(分析报告中 ~~~ 标记的是怀疑对象)
val inspectedObjectsByPath = inspectObjects(shortestPaths)

看一下可视化报告中相关源码:

DisplayLeakAdapter.kt

1
2
3
4
5
6
7
kotlin复制代码...
val reachabilityString = when (leakingStatus) {
UNKNOWN -> extra("UNKNOWN")
NOT_LEAKING -> "NO" + extra(" (${leakingStatusReason})")
LEAKING -> "YES" + extra(" (${leakingStatusReason})")
}
...

LeakTrace.kt

1
2
3
4
5
6
7
8
kotlin复制代码// 是否为怀疑对象
fun referencePathElementIsSuspect(index: Int): Boolean {
return when (referencePath[index].originObject.leakingStatus) {
UNKNOWN -> true
NOT_LEAKING -> index == referencePath.lastIndex || referencePath[index + 1].originObject.leakingStatus != NOT_LEAKING
else -> false
}
}

6.7 LeakCanary 分析完成后的处理

有两个位置处理了 HeapAnalysisSucceeded 事件:

  • Logcat:打印分析报告日志;
  • Notification: 发送分析成功系统通知消息。

LogcatEventListener.kt

1
2
3
4
5
kotlin复制代码object LogcatEventListener : EventListener {
...
SharkLog.d { "\u200B\n${LeakTraceWrapper.wrap(event.heapAnalysis.toString(), 120)}" }
...
}

NotificationEventListener.kt

1
2
3
4
5
6
7
8
9
10
11
12
kotlin复制代码object NotificationEventListener : EventListener {
...
val flags = if (Build.VERSION.SDK_INT >= 23) {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
} else {
PendingIntent.FLAG_UPDATE_CURRENT
}
// 点击通知消息打开可视化分析报告
val pendingIntent = PendingIntent.getActivity(appContext, 1, event.showIntent, flags)
showHeapAnalysisResultNotification(contentTitle,pendingIntent)
...
}

至此,LeakCanary 原理分析完毕。


  1. 总结

到这里,LeakCanary 的使用和原理分析就讲完了。不过,LeakCanary 毕竟是实验室使用的工具,如果要实现线上内存泄漏监控,你知道怎么做吗?要实现 Native 内存泄漏监控又要怎么做?关注我,带你了解更多。

参考资料

  • LeakCanary 官网
  • LeakCanary Github 仓库
  • How Leakcanary leverages WorkManager multi-process —— Pierre-Yves Ricau 著
  • Matrix Android ResourceCanary —— 腾讯 Matrix 说明文档
  • KOOM —— 高性能线上内存监控方案 —— 快手 Koom 说明文档
  • 内存优化(下):内存优化这件事,应该从哪里着手? —— 张绍文 著
  • Android内存泄露检测 LeakCanary 2.0 (Kotlin版) 的实现原理 —— vivo 技术团队 著

推荐阅读

Android 开源库系列完整目录如下(2023/07/12 更新):

  • #1 初代 K-V 存储框架 SharedPreferences,旧时代的余晖?(上)
  • #2 初代 K-V 存储框架 SharedPreferences,旧时代的余晖?(下)
  • #3 IO 框架 Okio 的实现原理,到底哪里 OK?
  • #4 IO 框架 Okio 的实现原理,如何检测超时?
  • #5 序列化框架 Gson 原理分析,可以优化吗?
  • #6 适可而止!看 Glide 如何把生命周期安排得明明白白
  • #7 为什么各大厂自研的内存泄漏检测框架都要参考 LeakCanary?因为它是真强啊!
  • #8 内存缓存框架 LruCache 的实现原理,手写试试?
  • #9 这是一份详细的 EventBus 使用教程

⭐️ 永远相信美好的事情即将发生,欢迎加入小彭的 Android 交流社群~

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

1…878889…956

开发者博客

9558 日志
1953 标签
RSS
© 2025 开发者博客
本站总访问量次
由 Hexo 强力驱动
|
主题 — NexT.Muse v5.1.4
0%