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

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


  • 首页

  • 归档

  • 搜索

数据结构与算法

发表于 2022-07-19

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

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

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

前言

HashMap 是我们熟悉的散列表实现,也是 “面试八股文” 的标准题库之一。今天,我给出一份 HashMap 高频面试题口述简答答案,希望对你刷题有帮助。如果能帮上忙请务必点赞加关注,这对我非常重要。


  1. 认识散列表

1.1 散列表的作用

散列算法是散列表的核心,也就做哈希算法或 Hash 算法,是一个意思。散列算法是一种将任意长度输入转换为固定长度输出的算法,输出的结果就是散列值。基于散列算法实现的散列表,可以实现快速查找元素的特性。

总结一下散列算法的主要性质:

性质 描述
1、单向性(基本性质) 支持从输入生成散列值,不支持从散列值反推输入
2、高效性(基本性质) 单次散列运算计算量低
3、一致性 相同输入重复计算,总是得到相同散列值
4、随机性 散列值在输出值域的分布尽量随机
5、输入敏感性 相似的数据,计算后的散列值差别很大

1.2 什么是散列冲突?

散列算法一定是一种压缩映射,因为输入值域非常大甚至无穷大,而散列值域为一个固定长度的值域。例如,MD5 的输出散列值为 128 位,SHA256 的输出散列值为 256 位,这就存在 2 个不同的输入产生相同输出的可能性,即散列冲突,或哈希冲突、Hash Collision。

这其实只要用鸽巢原理(又称:抽屉原理)就很好理解了,假设有 10 个鸽巢,现有 11 只鸽子,无论分配多么平均,也肯定有一个鸽巢里有两只甚至多只鸽子。举一个直接的例子,Java中的字符串 "Aa" 与 "BB" 的散列值就冲突了:

示例程序

1
2
3
4
ini复制代码String str1 = "Aa";
String str2 = "BB";
System.out.println(str1.hashCode()); 2112
System.out.println(str2.hashCode()); 2112 散列冲突

1.3 如何降低散列冲突概率

虽然散列冲突是无法完全避免的,但可以尽可能降低发生散列冲突的概率。例如:

  • 1、优化散列算法,提高散列值随机性: 将散列值尽可能均匀分布到输出值域的范围内,避免出现 “堆积” 线程。否则,当大部分散列值都堆积在一小块区域上时,势必会增大冲突概率。例如,HashMap 保证容量为 2^n 次幂就是提高随机性的方法。
  • 2、扩大输出值域(即扩容): 在散列值尽可能均匀分布的前提下,扩大输出值域可以直接降低冲突概率。例如,HashMap 在达到阈值时执行扩容,本质上是扩大了输出值域。

  1. HashMap 设计思路

2.1 说一下 HashMap 的底层结构?

HashMap 的底层结构是一个 “数组 + 拉链” 的二维结构,在 Java 7 中使用的是数组 + 链表,而在 Java 8 中当链表长度大于 8 时会转换为红黑树。

那么为什么 HashMap 要采用这样的设计呢?我分为 3 点来回答:

  • 第 1 点:HashMap 的定义是一个散列表,这是一种支持快速查找元素的数据结构,那么其背后就必然会使用到数组随机访问的特点。因此,HashMap 的一维结构就是一个数组,数组元素是一个包含 Key、Value 和 hashcode 的 Entry 节点。当我们需要访问集合元素时,其实就是先通过 key 计算 hashcode,再将 hashCode 对数组长度取余得到数组下标,最后通过下标去数组中找到对应的 Value;
  • 第 2 点:从 Key 到数组下标的转换过程必然是一个压缩映射的过程,因为不同的 key 计算的 hashCode 可能相同,不同的 hashCode 取余得到的数组下标也可能相同,这就是哈希冲突。常规的哈希冲突解决方法有开放地址法和拉链法等,而 HashMap 采用的是拉链法来解决哈希冲突。
  • 第 3 点:为什么 Java 8 要引入红黑树的设计呢?因为当冲突加剧的时候,在链表中寻找对应元素的时间复杂度是 O(n),n 是链表长度。而使用红黑树(近似平衡的二叉搜索树)的话,树形结构的复杂度一般跟树的高度有关,查找复杂度是 O(lgn),时间复杂度更低。

2.2 为什么 HashMap 采用拉链法而不是开放地址法?

我认为 Java 给予 HashMap 的定位是一个相对通用的散列表容器,它应该在面对各种输入的时候都表现稳定。而开发地址法相对来说容易出现数据堆积,在数据量较大时可能出现连续冲突的情况,性能不够稳定。

我们可以举个反例,在 Java 原生的数据结构中,也存在使用开放地址法的散列表 —— 就是 ThreadlLocal。因为项目中不会大量使用 ThreadLocal 线程局部存储,所以它是一个小规模数据场景,这里使用开发地址法是没问题的。

2.3 为什么 HashMap 用红黑树而不是平衡二叉树?

红黑树和平衡二叉树的区别在于它们的平衡强弱不同:

  • 平衡二叉树追求的是一种完全平衡的状态,它的定义是任何结点的左右子树的高度差不会超过 1,这样的优势是树的结点是很平均分配的;
  • 红黑树不追求这种完全平衡,而是追求一种弱平衡的状态,就是让整个树最长路径不会超过最短路径的 2 倍。这样的话,红黑树虽然牺牲了一部分查找的性能效率,但是能够换取一部分维持树平衡状态的成本。

而我们知道 HashMap 的设计定位应该是一个相对通用的散列表,那么它的设计者会希望这样一个数据结构应该具备更强大的稳定性,因此它才选择了红黑树。


  1. HashMap 源码细节

3.1 HashMap 的初始容量

HashMap 的初始容量是 0,这是一种懒加载机制,直到第一次 put 操作才会初始化数组大小,默认大小是 16。

3.2 HashMap 扩容

扩容本质上是扩大了散列算法的输出值域,在散列值尽可能均匀分布的前提下,扩大输出值域可以直接降低冲突概率。当然,由于 HashMap 使用的是拉链法来解决散列冲突,扩容并不是必须的,但是不扩容的话会造成拉链的长度越来越长,导致散列表的时间复杂度会倾向于 O(n) 而不是 O(1)。

HashMap 扩容的触发时机出现在元素个数超过阈值(容量 * loadFactor)的时候时,会将集合的一维数组扩大一倍,然后重新计算每个元素的位置。

3.3 为什么 HashMap 的长度是 2^n 次幂?

这是为了尽量将集合元素均摊到数组的不同位置上。

  • 我们知道 HashMap 在确定元素对应的数组下标时,是采用了 hashCode 对数组长度取余的运算,它其实等价于 hashCode 对数组长度 - 1 的与运算(h % length 等价于 h & (lenght -1),与运算效率更高,偶数才成立);
  • 而 2^n 次幂对应的 length - 1 恰好全是 1(1000-1 = 111),这样就把影响下标的因素归结于 hashCode 本身,因而能够实现尽可能均摊。

3.4 HashMap 中 Key 的匹配判断

1
kotlin复制代码if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))

3.5 为什么经常使用 String 作为 HashMap 的 Key?

这个问题我认为有 2 个原因:

  • 1、不可变类 String 可以避免修改后无法定位键值对: 假设 String 是可变类,当我们在 HashMap 中构建起一个以 String 为 Key 的键值对时,此时对 String 进行修改,那么通过修改后的 String 是无法匹配到刚才构建过的键值对的,因为修改后的 hashCode 可能是变化的。而不可变类可以规避这个问题。
  • 2、String 能够满足 Java 对于 hashCode() 和 equals() 的通用约定: 既两个对象 equals() 相同,则 hashCode() 相同,如果 hashCode() 相同,则 equals() 不一定相同。这个约定是为了避免两个 equals() 相同的 Key 在 HashMap 中存储两个独立的键值对,引起矛盾。

  1. HashMap 线程安全性

4.1 HashMap 线程不安全的原因

  • 数据覆盖问题:如果两个线程并发执行 put 操作,并且两个数据的 hash 值冲突,就可能出现数据覆盖(线程 A 判断 hash 值位置为 null,还未写入数据时挂起,此时线程 B 正常插入数据。接着线程 A 获得时间片,由于线程 A 不会重新判断该位置是否为空,就会把刚才线程 B 写入的数据覆盖掉);
  • 环形链表问题: 在 HashMap 触发扩容时,并且正好两个线程同时在操作同一个链表时,就可能引起指针混乱,形成环型链条(因为 JDK 1.7 版本采用头插法,在扩容时会翻转链表的顺序,而 JDK 1.8 采用尾插法,再扩容时会保持链表原本的顺序)。

4.2 HashMap 和 hashTable 的区别?

  • 1、hashTable 对每个方法都增加了 synchronized;
  • 2、hashTable 不允许 null 作为 Key;

4.3 ConcurrentHashMap 分段锁的原理

HashMap 出现并发问题的核心在于多个线程同时操作同一个链表,而 ConcurrentHashMap 在操作链表前会用 synchronized 对链表的首个元素加锁,从而避免并发问题。


参考资料

  • 散列算法—— 维基百科
  • 都说 HashMap 是线程不安全的,到底体现在哪儿? —— developer 著
  • Java源码分析:HashMap 1.8 相对于1.7 到底更新了什么? —— Carson 著
  • 《数据结构与算法之美》(第21、22章) —— 王争 讲,极客时间 出品

推荐阅读

数据结构与算法系列完整目录如下(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 交流社群~

我正在参与掘金技术社区创作者签约计划招募活动,点击链接报名投稿。

本文转载自: 掘金

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

Android Jetpack 开发套件

发表于 2022-07-19

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

Android Jetpack 开发套件是 Google 推出的 Android 应用开发编程范式,为开发者提供了解决应用开发场景中通用的模式化问题的最佳实践,让开发者可将时间精力集中于真正重要的业务编码工作上。

这篇文章是 Android Jetpack 系列文章的第 3 篇文章,完整目录可以移步至文章末尾~

前言

ViewModel 是 Jetpack 组件中较常用的组件之一,也是实现 MVVM 模式或 MVI 模式的标准组件之一。在这篇文章里,我将与你讨论 ViewModel 实用和面试常见的知识点。如果能帮上忙请务必点赞加关注,这对我非常重要。


  1. 认识 ViewModel

1.1 为什么要使用 ViewModel?

ViewModel 的作用可以区分 2 个维度来理解:

  • 1、界面控制器维度: 在最初的 MVC 模式中,Activity / Fragment 中承担的职责过重,因此,在后续的 UI 开发模式中,我们选择将 Activity / Fragment 中与视图无关的职责抽离出来,在 MVP 模式中叫作 Presenter,在 MVVM 模式中叫作 ViewModel。因此,我们使用 ViewModel 来承担界面控制器的职责,并且配合 LiveData / Flow 实现数据驱动。
  • 2、数据维度: 由于 Activity 存在因配置变更销毁重建的机制,会造成 Activity 中的所有瞬态数据丢失,例如网络请求得到的用户信息、视频播放信息或者异步任务都会丢失。而 ViewModel 能够应对 Activity 因配置变更而重建的场景,在重建的过程中恢复 ViewModel 数据,从而降低用户体验受损。

关于 MVVM 等模式的更多内容,我们在 Android Jetpack 开发套件 #5 Android UI 架构演进:从 MVC 到 MVP、MVVM、MVI 这篇文章讨论过。

MVVM 模式示意图:

MVI 模式示意图:

ViewModel 生命周期示意图:

1.2 ViewModel 的使用方法

  • 1、添加依赖: 在 build.gradle 中添加 ViewModel 依赖,需要注意区分过时的方式:
1
2
3
4
5
6
7
8
9
10
11
gradle复制代码// 过时方式(lifecycle-extensions 不再维护)
implementation "androidx.lifecycle:lifecycle-extensions:2.4.0"

// 目前的方式:
def lifecycle_version = "2.5.0"
// Lifecycle 核心类
implementation "androidx.lifecycle:lifecycle-runtime:$lifecycle_version"
// LiveData
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"
// ViewModel
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
  • 2、模板代码: ViewModel 通常会搭配 LiveData 使用,以下为使用模板,相信大家都很熟悉了:

NameViewModel.kt

1
2
3
4
5
kotlin复制代码class NameViewModel : ViewModel() {
val currentName: MutableLiveData<String> by lazy {
MutableLiveData<String>()
}
}

MainActivity.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复制代码class MainActivity : AppCompatActivity() {

private val model: NameViewModel by viewModels()

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)

// LiveData 观察者
val nameObserver = Observer<String> { newName ->
// 更新视图
nameTextView.text = newName
}

// 注册 LiveData 观察者,this 为生命周期宿主
model.currentName.observe(this, nameObserver)

// 修改 LiveData 数据
button.setOnClickListener {
val anotherName = "John Doe"
model.currentName.value = anotherName
}
}
}

1.3 ViewModel 的创建方式

创建 ViewModel 实例的方式主要有 3 种,它们最终都是通过第 1 种 ViewModelProvider 完成的:

  • 方法 1: ViewModelProvider 是创建 ViewModel 的工具类:

示例程序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
kotlin复制代码// 不带工厂的创建方式
val vm = ViewModelProvider(this).get(MainViewModel::class.java)
// 带工厂的创建方式
val vmWithFactory = ViewModelProvider(this, MainViewModelFactory()).get(MainViewModel::class.java)

// ViewModel 工厂
class MainViewModelFactory(

) : ViewModelProvider.Factory {

private val repository = MainRepository()

override fun <T : ViewModel> create(modelClass: Class<T>): T {
return MainViewModel(repository) as T
}
}
  • 方法 2: 使用 Kotlin by 委托属性,本质上是间接使用了 ViewModelProvider:

示例程序

1
2
3
4
5
6
7
8
9
10
11
12
13
kotlin复制代码// 在 Activity 中使用
class MainActivity : AppCompatActivity() {
// 使用 Activity 的作用域
private val viewModel : MainViewModel by viewModels()
}

// 在 Fragment 中使用
class MainFragment : Fragment() {
// 使用 Activity 的作用域,与 MainActivity 使用同一个对象
val activityViewModel : MainViewModel by activityViewModels()
// 使用 Fragment 的作用域
val viewModel : MainViewModel by viewModels()
}
  • 方法 3: Hilt 提供了注入部分 Jetpack 架构组件的支持

示例程序

1
2
3
4
5
6
7
8
9
10
11
12
13
kotlin复制代码@HiltAndroidApp
class DemoApplication : Application() { ... }

@HiltViewModel
class MainViewModel @Inject constructor() : ViewModel() {
...
}

@AndroidEntryPoint
class MainHiltActivity : AppCompatActivity(){
val viewModel by viewModels<MainViewModel>()
...
}

依赖项

1
2
3
4
gradle复制代码// Hilt ViewModel 支持
implementation "androidx.hilt:hilt-lifecycle-viewmodel:1.0.0"
// Hilt 注解处理器
kapt "androidx.hilt:hilt-compiler:1.0.0"

需要注意的是,虽然可以使用依赖注入普通对象的方式注入 ViewModel,但是这相当于绕过了 ViewModelProvider 来创建 ViewModel。这意味着 ViewModel 实例一定不会存放在 ViewModelStore 中,将失去 ViewModel 恢复界面数据的特性。

错误示例

1
2
3
4
5
kotlin复制代码@AndroidEntryPoint
class MainHiltActivity : AppCompatActivity(){
@Inject
lateinit var viewModel : MainViewModel
}

  1. ViewModel 实现原理分析

2.1 ViewModel 的创建过程

上一节提到,3 种创建 ViewModel 实例的方法最终都是通过 ViewModelProvider 完成的。ViewModelProvider 可以理解为创建 ViewModel 的工具类,它需要 2 个参数:

  • 参数 1 ViewModelStoreOwner: 它对应于 Activity / Fragment 等持有 ViewModel 的宿主,它们内部通过 ViewModelStore 维持一个 ViewModel 的映射表,ViewModelStore 是实现 ViewModel 作用域和数据恢复的关键;
  • 参数 2 Factory: 它对应于 ViewModel 的创建工厂,缺省时将使用默认的 NewInstanceFactory 工厂来反射创建 ViewModel 实例。

创建 ViewModelProvider 工具类后,你将通过 get() 方法来创建 ViewModel 的实例。get() 方法内部首先会通过 ViewModel 的全限定类名从映射表(ViewModelStore)中取缓存,未命中才会通过 ViewModel 工厂创建实例再缓存到映射表中。

正因为同一个 ViewModel 宿主使用的是同一个 ViewModelStore 映射表,因此在同一个宿主上重复调用 ViewModelProvider#get() 返回同一个 ViewModel 实例。

ViewModelProvider.java

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
java复制代码// ViewModel 创建工厂
private final Factory mFactory;
// ViewModel 存储容器
private final ViewModelStore mViewModelStore;

// 默认使用 NewInstanceFactory 反射创建 ViewModel
public ViewModelProvider(ViewModelStoreOwner owner) {
this(owner.getViewModelStore(), ... NewInstanceFactory.getInstance());
}

// 自定义 ViewModel 创建工厂
public ViewModelProvider(ViewModelStoreOwner owner, Factory factory) {
this(owner.getViewModelStore(), factory);
}

// 记录宿主的 ViewModelStore 和 ViewModel 工厂
public ViewModelProvider(ViewModelStore store, Factory factory) {
mFactory = factory;
mViewModelStore = store;
}

@NonNull
@MainThread
public <T extends ViewModel> T get(Class<T> modelClass) {
String canonicalName = modelClass.getCanonicalName();
if (canonicalName == null) {
throw new IllegalArgumentException("Local and anonymous classes can not be ViewModels");
}
// 使用类名作为缓存的 KEY
return get(DEFAULT_KEY + ":" + canonicalName, modelClass);
}

// Fragment
@NonNull
@MainThread
public <T extends ViewModel> T get(String key, Class<T> modelClass) {
// 1. 先从 ViewModelStore 中取缓存
ViewModel viewModel = mViewModelStore.get(key);
if (modelClass.isInstance(viewModel)) {
return (T) viewModel;
}
// 2. 使用 ViewModel 工厂创建实例
viewModel = mFactory.create(modelClass);
...
// 3. 存储到 ViewModelStore
mViewModelStore.put(key, viewModel);
return (T) viewModel;
}

// 默认的 ViewModel 工厂
public static class NewInstanceFactory implements Factory {

private static NewInstanceFactory sInstance;

@NonNull
static NewInstanceFactory getInstance() {
if (sInstance == null) {
sInstance = new NewInstanceFactory();
}
return sInstance;
}

@NonNull
@Override
public <T extends ViewModel> T create(Class<T> modelClass) {
// 反射创建 ViewModel 对象
return modelClass.newInstance();
}
}

ViewModelStore.java

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
java复制代码// ViewModel 本质上就是一个映射表而已
public class ViewModelStore {
// <String - ViewModel> 哈希表
private final HashMap<String, ViewModel> mMap = new HashMap<>();

final void put(String key, ViewModel viewModel) {
ViewModel oldViewModel = mMap.put(key, viewModel);
if (oldViewModel != null) {
oldViewModel.onCleared();
}
}

final ViewModel get(String key) {
return mMap.get(key);
}

Set<String> keys() {
return new HashSet<>(mMap.keySet());
}

public final void clear() {
for (ViewModel vm : mMap.values()) {
vm.clear();
}
mMap.clear();
}
}

ViewModel 宿主是 ViewModelStoreOwner 接口的实现类,例如 Activity:

ViewModelStoreOwner.java

1
2
3
4
java复制代码public interface ViewModelStoreOwner {
@NonNull
ViewModelStore getViewModelStore();
}

androidx.activity.ComponentActivity.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
java复制代码public class ComponentActivity extends androidx.core.app.ComponentActivity implements
ContextAware,
LifecycleOwner,
ViewModelStoreOwner ... {

// ViewModel 的存储容器
private ViewModelStore mViewModelStore;
// ViewModel 的创建工厂
private ViewModelProvider.Factory mDefaultFactory;

@NonNull
@Override
public ViewModelStore getViewModelStore() {
if (mViewModelStore == null) {
// 已简化,后文补全
mViewModelStore = new ViewModelStore();
}
return mViewModelStore;
}
}

2.2 by viewModels() 实现原理分析

by 关键字是 Kotlin 的委托属性,内部也是通过 ViewModelProvider 来创建 ViewModel。关于 Kotlin 委托属性的更多内容,我们在 Kotlin | 委托机制 & 原理 & 应用 这篇文章讨论过,这里不重复。

ActivityViewModelLazy.kt

1
2
3
4
5
6
7
8
9
10
kotlin复制代码@MainThread
public inline fun <reified VM : ViewModel> ComponentActivity.viewModels(
noinline factoryProducer: (() -> Factory)? = null
): Lazy<VM> {
val factoryPromise = factoryProducer ?: {
defaultViewModelProviderFactory
}

return ViewModelLazy(VM::class, { viewModelStore }, factoryPromise)
}

ViewModelLazy.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复制代码public class ViewModelLazy<VM : ViewModel> (
private val viewModelClass: KClass<VM>,
private val storeProducer: () -> ViewModelStore,
private val factoryProducer: () -> ViewModelProvider.Factory
) : Lazy<VM> {
private var cached: VM? = null

override val value: VM
get() {
val viewModel = cached
return if (viewModel == null) {
val factory = factoryProducer()
val store = storeProducer()
// 最终也是通过 ViewModelProvider 创建 ViewModel 实例
ViewModelProvider(store, factory).get(viewModelClass.java).also {
cached = it
}
} else {
viewModel
}
}

override fun isInitialized(): Boolean = cached != null
}

2.3 ViewModel 如何实现不同的作用域

ViewModel 内部会为不同的 ViewModel 宿主分配不同的 ViewModelStore 映射表,不同宿主是从不同的数据源来获取 ViewModel 的实例,因而得以区分作用域。

具体来说,在使用 ViewModelProvider 时,我们需要传入一个 ViewModelStoreOwner 宿主接口,它将在 getViewModelStore() 接口方法中返回一个 ViewModelStore 实例。

  • 对于 Activity 来说,ViewModelStore 实例是直接存储在 Activity 的成员变量中的;
  • 对于 Fragment 来说,ViewModelStore 实例是间接存储在 FragmentManagerViewModel 中的 <Fragment - ViewModelStore> 映射表中的。

这样就实现了不同的 Activity 或 Fragment 分别对应不同的 ViewModelStore 实例,进而区分不同作用域。

androidx.activity.ComponentActivity.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
java复制代码public class ComponentActivity extends androidx.core.app.ComponentActivity implements
ContextAware,
LifecycleOwner,
ViewModelStoreOwner ... {

@NonNull
@Override
public ViewModelStore getViewModelStore() {
if (mViewModelStore == null) {
// 已简化,后文补全
mViewModelStore = new ViewModelStore();
}
return mViewModelStore;
}
}

Fragment.java

1
2
3
4
5
6
java复制代码@NonNull
@Override
public ViewModelStore getViewModelStore() {
// 最终调用 FragmentManagerViewModel#getViewModelStore(Fragment)
return mFragmentManager.getViewModelStore(this);
}

FragmentManagerViewModel.java

1
2
3
4
5
6
7
8
9
10
11
12
java复制代码// <Fragment - ViewModelStore> 映射表
private final HashMap<String, ViewModelStore> mViewModelStores = new HashMap<>();

@NonNull
ViewModelStore getViewModelStore(@NonNull Fragment f) {
ViewModelStore viewModelStore = mViewModelStores.get(f.mWho);
if (viewModelStore == null) {
viewModelStore = new ViewModelStore();
mViewModelStores.put(f.mWho, viewModelStore);
}
return viewModelStore;
}

2.4 为什么 Activity 在屏幕旋转重建后可以恢复 ViewModel?

ViewModel 底层是基于原生 Activity 因设备配置变更重建时恢复数据的机制实现的,这个其实跟 Fragment#setRetainInstance(true) 持久 Fragment 的机制是相同的。当 Activity 因配置变更而重建时,我们可以将页面上的数据或状态可以定义为 2 类:

  • 第 1 类 - 配置数据: 例如窗口大小、多语言字符、多主题资源等,当设备配置变更时,需要根据最新的配置重新读取新的数据,因此这部分数据在配置变更后便失去意义,自然也就没有存在的价值;
  • 第 2 类 - 非配置数据: 例如用户信息、视频播放信息、异步任务等非配置相关数据,这些数据跟设备配置没有一点关系,如果在重建 Activity 的过程中丢失,不仅没有必要,而且会损失用户体验(无法快速恢复页面数据,或者丢失页面进度)。

基于以上考虑,Activity 是支持在设备配置变更重建时恢复 第 2 类 - 非配置数据 的,源码中存在 NonConfiguration 字眼的代码,就是与这个机制相关的代码。我将整个过程大概可以概括为 3 个阶段:

  • 阶段 1: 系统在处理 Activity 因配置变更而重建时,会先调用 retainNonConfigurationInstances 获取旧 Activity 中的数据,其中包含 ViewModelStore 实例,而这一份数据会临时存储在当前 Activity 的 ActivityClientRecord(属于当前进程,下文说明);
  • 阶段 2: 在新 Activity 重建后,系统通过在 Activity#onAttach(…) 中将这一份数据传递到新的 Activity 中;
  • 阶段 3: Activity 在构造 ViewModelStore 时,会优先从旧 Activity 传递过来的这份数据中获取,为空才会创建新的 ViewModelStore。

对于 ViewModel 来说,相当于旧 Activity 中所有的 ViewModel 映射表被透明地传递到重建后新的 Activity 中,这就实现了恢复 ViewModel 的功能。总结一下重建前后的实例变化,帮助你理解:

  • Activity: 构造新的实例;
  • ViewModelStore: 保留旧的实例;
  • ViewModel: 保留旧的实例(因为 ViewModel 存储在 ViewModelStore 映射表中);
  • LiveData: 保留旧的实例(因为 LiveData 是 ViewModel 的成员变量);

现在,我们逐一分析这 3 个阶段的源码执行过程:

阶段 1 源码分析:

Activity.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
java复制代码// 阶段 1:获取 Activity 的非配置相关数据
NonConfigurationInstances retainNonConfigurationInstances() {
// 1.1 构造 Activity 级别的非配置数据
Object activity = onRetainNonConfigurationInstance();
// 1.2 构造 Fragment 级别的费配置数据数据
FragmentManagerNonConfig fragments = mFragments.retainNestedNonConfig();

...

// 1.3 构造并返回 NonConfigurationInstances 非配置相关数据类
NonConfigurationInstances nci = new NonConfigurationInstances();

nci.activity = activity;
nci.fragments = fragments;
...
return nci;
}

// 1.1 默认返回 null,由 Activity 子类定义
public Object onRetainNonConfigurationInstance() {
return null;
}

androidx.activity.ComponentActivity.java

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
java复制代码private ViewModelStore mViewModelStore;

// 1.1 ComponentActivity 在 onRetainNonConfigurationInstance() 中写入了 ViewModelStore
@Override
@Nullable
public final Object onRetainNonConfigurationInstance() {
ViewModelStore viewModelStore = mViewModelStore;
// 这一个 if 语句是处理异常边界情况:
// 如果重建的 Activity 没有调用 getViewModelStore(),那么旧的 Activity 中的 ViewModel 并没有被取出来,
// 因此在准备再一次存储当前 Activity 时,需要检查一下旧 Activity 传过来的数据。
if (viewModelStore == null) {
NonConfigurationInstances nc = (NonConfigurationInstances) getLastNonConfigurationInstance();
if (nc != null) {
viewModelStore = nc.viewModelStore;
}
}
// ViewModelStore 为空说明当前 Activity 和旧 Activity 都没有 ViewModel,没必要存储和恢复
if (viewModelStore == null) {
return null;
}
NonConfigurationInstances nci = new NonConfigurationInstances();
// 保存 ViewModelStore 对象
nci.viewModelStore = viewModelStore;
return nci;
}

ActivityThread.java

1
2
java复制代码// Framework 调用 retainNonConfigurationInstances() 获取非配置数据后,
// 会通过当前进程内存临时存储这一份数据,这部分源码我们暂且放到一边。

阶段 2 源码分析:

Activity.java

1
2
3
4
5
6
7
8
9
10
java复制代码// 阶段 2:在 Activity#attach() 中传递旧 Activity 的数据
NonConfigurationInstances mLastNonConfigurationInstances;

final void attach(Context context, ActivityThread aThread,
...
NonConfigurationInstances lastNonConfigurationInstances) {
...
mLastNonConfigurationInstances = lastNonConfigurationInstances;
...
}

至此,旧 Activity 的数据就传递到新 Activity 的成员变量 mLastNonConfigurationInstances 中。

阶段 3 源码分析:

androidx.activity.ComponentActivity.java

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 class ComponentActivity extends androidx.core.app.ComponentActivity implements
ContextAware,
LifecycleOwner,
ViewModelStoreOwner ... {

private ViewModelStore mViewModelStore;
private ViewModelProvider.Factory mDefaultFactory;

// 阶段 3:Activity 的 ViewModelStore 优先使用旧 Activity 传递过来的 ViewModelStore
@NonNull
@Override
public ViewModelStore getViewModelStore() {
if (mViewModelStore == null) {
// 3.1 优先使用旧 Activity 传递过来的 ViewModelStore
NonConfigurationInstances nc = (NonConfigurationInstances) getLastNonConfigurationInstance();
if (nc != null) {
mViewModelStore = nc.viewModelStore;
}
// 3.2 否则创建新的 ViewModelStore
if (mViewModelStore == null) {
mViewModelStore = new ViewModelStore();
}
}
return mViewModelStore;
}
}

Activity.java

1
2
3
4
5
6
7
typescript复制代码// 这个变量在阶段 2 赋值
NonConfigurationInstances mLastNonConfigurationInstances;

// 返回从 attach() 中传递过来的旧 Activity 数据
public Object getLastNonConfigurationInstance() {
return mLastNonConfigurationInstances != null ? mLastNonConfigurationInstances.activity : null;
}

至此,就完成 ViewModel 数据恢复了。


现在,我们回过头来分析下 ActivityThread 这一部分源码:

ActivityThread 中的调用过程:

在 Activity 因配置变更而重建时,系统将执行 Relaunch 重建过程。系统在这个过程中通过同一个 ActivityClientRecord 来完成信息传递,会销毁当前 Activity,紧接着再马上重建同一个 Activity。

  • 阶段 1: 在处理 Destroy 逻辑时,调用 Activity#retainNonConfigurationInstances() 方法获取旧 Activity 中的非配置数据,并临时保存在 ActivityClientRecord 中;
  • 阶段 2: 在处理 Launch 逻辑时,调用 Activity#attach(…) 将 ActivityClientRecord 中临时保存的非配置数据传递到新 Activity 中。

ActivityThread.java

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
java复制代码private void handleRelaunchActivityInner(ActivityClientRecord r, ...) {
final Intent customIntent = r.activity.mIntent;
// 处理 onPause()
performPauseActivity(r, false, reason, null /* pendingActions */);
// 处理 onStop()
callActivityOnStop(r, true /* saveState */, reason);
// 阶段 1:获取 Activity 的非配置相关数据
handleDestroyActivity(r.token, false, configChanges, true, reason);

// 至此,Activity 中的 第 2 类 - 非配置数据就记录在 ActivityClientRecord 中,
// 并通过同一个 ActivityClientRecord 重建一个新的 Activity

// 阶段 2:在 Activity#attach() 中传递旧 Activity 的数据
handleLaunchActivity(r, pendingActions, customIntent);

// 至此,旧 Activity 中的非配置数据已传递到新 Activity
}

public void handleDestroyActivity(IBinder token, boolean finishing, int configChanges, boolean getNonConfigInstance, String reason) {
ActivityClientRecord r = performDestroyActivity(token, finishing, configChanges, getNonConfigInstance, reason);
...
if (finishing) {
ActivityTaskManager.getService().activityDestroyed(token);
}
}

// 阶段 1:获取 Activity 的非配置相关数据
// 参数 finishing 为 false
// 参数 getNonConfigInstance 为 true
ActivityClientRecord performDestroyActivity(IBinder token, boolean finishing, int configChanges, boolean getNonConfigInstance, String reason) {
ActivityClientRecord r = mActivities.get(token);
// 保存非配置数据,调用了阶段 1 中提到的 retainNonConfigurationInstances() 方法
if (getNonConfigInstance) {
r.lastNonConfigurationInstances = r.activity.retainNonConfigurationInstances();
}
// 执行 onDestroy()
mInstrumentation.callActivityOnDestroy(r.activity);
return r;
}

public Activity handleLaunchActivity(ActivityClientRecord r, PendingTransactionActions pendingActions, Intent customIntent) {
final Activity a = performLaunchActivity(r, customIntent);
}

// 阶段 2:在 Activity#attach() 中传递旧 Activity 的数据
private Activity performLaunchActivity(ActivityClientRecord r, Intent customIntent) {
// 创建新的 Activity 实例
Activity activity = mInstrumentation.newActivity(cl, component.getClassName(), r.intent);
// 创建或获取 Application 实例,在这个场景里是获取
Application app = r.packageInfo.makeApplication(false, mInstrumentation);
// 传递 lastNonConfigurationInstances 数据
activity.attach(appContext, ..., r.lastNonConfigurationInstances,...);
// 清空临时变量
r.lastNonConfigurationInstances = null;
...
}

2.5 ViewModel 的数据在什么时候才会清除

ViewModel 的数据会在 Activity 非配置变更触发的销毁时清除,具体分为 3 种情况:

  • 第 1 种: 直接调用 Activity#finish() 或返回键等间接方式;
  • 第 2 种: 异常退出 Activity,例如内存不足;
  • 第 3 种: 强制退出应用。

第 3 种没有给予系统或应用存储数据的时机,内存中的数据自然都会被清除。而前 2 种情况都属于非配置变更触发的,在 Activity 中存在 1 个 Lifecycle 监听:当 Activity 进入 DESTROYED 状态时,如果 Activity 不处于配置变更重建的阶段,将调用 ViewModelStore#clear() 清除 ViewModel 数据。

androidx.activity.ComponentActivity.java

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复制代码public class ComponentActivity extends androidx.core.app.ComponentActivity implements
ContextAware,
LifecycleOwner,
ViewModelStoreOwner ... {

private ViewModelStore mViewModelStore;
private ViewModelProvider.Factory mDefaultFactory;

public ComponentActivity() {
// DESTROYED 状态监听
getLifecycle().addObserver(new LifecycleEventObserver() {
@Override
public void onStateChanged(LifecycleOwner source, Lifecycle.Event event) {
if (event == Lifecycle.Event.ON_DESTROY) {
mContextAwareHelper.clearAvailableContext();
// 是否处于配置变更引起的重建
if (!isChangingConfigurations()) {
getViewModelStore().clear();
}
}
}
});
}
}

Activity.java

1
2
3
4
5
java复制代码boolean mChangingConfigurations = false;

public boolean isChangingConfigurations() {
return mChangingConfigurations;
}

  1. ViewModel 的内存泄漏问题

ViewModel 的内存泄漏是指 Activity 已经销毁,但是 ViewModel 却被其他组件引用。这往往是因为数据层是通过回调监听器的方式返回数据,并且数据层是单例对象或者属于全局生命周期,所以导致 Activity 销毁了,但是数据层依然间接持有 ViewModel 的引用。

如果 ViewModel 是轻量级的或者可以保证数据层操作快速完成,这个泄漏影响不大可以忽略。但如果数据层操作并不能快速完成,或者 ViewModel 存储了重量级数据,就有必要采取措施。例如:

  • 方法 1: 在 ViewModel#onCleared() 中通知数据层丢弃对 ViewModel 回调监听器的引用;
  • 方法 2: 在数据层使用对 ViewModel 回调监听器的弱引用(这要求 ViewModel 必须持有回调监听器的强引用,而不能使用匿名内部类,这会带来编码复杂性);
  • 方法 3: 使用 EventBus 代替回调监听器(这会带来编码复杂性);
  • 方法 4: 使用 LiveData 的 Transformations.switchMap() API 包装数据层的请求方法,这相当于在 ViewModel 和数据层中间使用 LiveData 进行通信。例如:

MyViewModel.java

1
2
3
4
5
6
7
8
9
10
11
12
13
java复制代码// 用户 ID LiveData
MutableLiveData userIdLiveData = new MutableLiveData<String>();

// 用户数据 LiveData
LiveData userLiveData = Transformations.switchMap(userIdLiveData, id ->
// 调用数据层 API
repository.getUserById(id));

// 设置用户 ID
// 每次的 userIdLiveData 的值发生变化,repository.getUserById(id) 将被调用,并将结果设置到 userLiveData 上
public void setUserId(String userId) {
this.userIdLiveData.setValue(userId);
}


  1. ViewModel 和 onSaveInstanceState() 的对比

ViewModel 和 onSaveInstanceState() 都是对数据的恢复机制,但由于它们针对的场景不同,导致它们的实现原理不同,进而优缺点也不同。

  • 1、ViewModel: 使用场景针对于配置变更重建中非配置数据的恢复,由于内存是可以满足这种存储需求的,因此可以选择内存存储。又由于内存空间相对较大,因此可以存储大数据,但会受到内存空间限制;
  • 2、onSaveInstanceState() :使用场景针对于应用被系统回收后重建时对数据的恢复,由于应用进程在这个过程中会消亡,因此不能选择内存存储而只能选择使用持久化存储。又由于这部分数据需要通过 Bundle 机制在应用进程和 AMS 服务之间传递,因此会受到 Binder 事务缓冲区大小限制,只可以存储小规模数据。

如果是正常的 Activity 退出,例如返回键或者 finish(),都不属于 ViewModel 和 onSaveInstanceState() 的应用场景,因此都不会存储和恢复数据。


  1. 总结

到这里,Jetpack 中的 ViewModel 组件就讲完了。下一篇文章,我们来讨论 Android Jetpack 开发套件 #4 有小伙伴说看不懂 LiveData、Flow、Channel,跟我走 。

参考资料

  • ViewModel 概览 —— 官方文档
  • 保存界面状态 —— 官方文档
  • ViewModel 的 SavedState 模块 —— 官方文档
  • ViewModel 和 LiveData:为设计模式打 Call 还是唱反调? —— 官方博文

推荐阅读

Android Jetpack 系列文章目录如下(2023/07/08 更新):

  • #1 Lifecycle:生命周期感知型组件的基础
  • #2 为什么 LiveData 会重放数据,怎么解决?
  • #3 为什么 Activity 都重建了 ViewModel 还存在?
  • #4 有小伙伴说看不懂 LiveData、Flow、Channel,跟我走
  • #5 Android UI 架构演进:从 MVC 到 MVP、MVVM、MVI
  • #6 ViewBinding 与 Kotlin 委托双剑合璧
  • #7 AndroidX Fragment 核心原理分析
  • #8 OnBackPressedDispatcher:Jetpack 处理回退事件的新姿势
  • #9 食之无味!App Startup 可能比你想象中要简单
  • #10 从 Dagger2 到 Hilt 玩转依赖注入(一)

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

我正在参与掘金技术社区创作者签约计划招募活动,点击链接报名投稿。

本文转载自: 掘金

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

Android Jetpack 开发套件

发表于 2022-07-18

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

Android Jetpack 开发套件是 Google 推出的 Android 应用开发编程范式,为开发者提供了解决应用开发场景中通用的模式化问题的最佳实践,让开发者可将时间精力集中于真正重要的业务编码工作上。

这篇文章是 Android Jetpack 系列文章的第 2 篇文章,完整目录可以移步至文章末尾~

前言

  • LiveData 是 Jetpack 组件中较常用的组件之一,曾经也是实现 MVVM 模式的标准组件之一,不过目前 Google 更多推荐使用 Kotlin Flow 来代替 LiveData;
  • 虽然 LiveData 不再是 Google 主推的组件,但考虑到 LiveData 依然存在于大量存量代码中,以及 LiveData 伴随着 Android 生态发展过程中衍生的问题和解决方案,我认为 LiveData 依然有存在的意义。虽然我们不再优先使用 LiveData,但不代表学习 LiveData 没有价值。

  1. 认识 LiveData

1.1 为什么要使用 LiveData?

LiveData 是基于 Lifecycle 框架实现的生命周期感知型数据容器,能够让数据观察者更加安全地应对宿主(Activity / Fragment 等)生命周期变化,核心概括为 2 点:

  • 1、自动取消订阅: 当宿主生命周期进入消亡(DESTROYED)状态时,LiveData 会自动移除观察者,避免内存泄漏;
  • 2、安全地回调数据: 在宿主生命周期状态低于活跃状态(STAETED)时,LiveData 不会回调数据,避免产生空指针异常或不必要的性能损耗;当宿主生命周期不低于活跃状态(STAETED)时,LiveData 会重新尝试回调数据,确保观察者接收到最新的数据。

1.2 LiveData 的使用方法

  • 1、添加依赖: 在 build.gradle 中添加 LiveData 依赖,需要注意区分过时的方式:
1
2
3
4
5
6
7
8
9
10
11
kotlin复制代码// 过时方式(lifecycle-extensions 不再维护)
implementation "androidx.lifecycle:lifecycle-extensions:2.4.0"

// 目前的方式:
def lifecycle_version = "2.5.0"
// Lifecycle 核心类
implementation "androidx.lifecycle:lifecycle-runtime:$lifecycle_version"
// LiveData
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"
// ViewModel
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
  • 2、模板代码: LiveData 通常会搭配 ViewModel 使用,以下为使用模板,相信大家都很熟悉了:

NameViewModel.kt

1
2
3
4
5
kotlin复制代码class NameViewModel : ViewModel() {
val currentName: MutableLiveData<String> by lazy {
MutableLiveData<String>()
}
}

MainActivity.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复制代码class MainActivity : AppCompatActivity() {

private val model: NameViewModel by viewModels()

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)

// LiveData 观察者
val nameObserver = Observer<String> { newName ->
// 更新视图
nameTextView.text = newName
}

// 注册 LiveData 观察者,this 为生命周期宿主
model.currentName.observe(this, nameObserver)

// 修改 LiveData 数据
button.setOnClickListener {
val anotherName = "John Doe"
model.currentName.value = anotherName
}
}
}
  • 3、注册观察者: LiveData 支持两种注册观察者的方式:
    • LiveData#observe(LifecycleOwner, Observer) 带生命周期感知的注册: 更常用的注册方式,这种方式能够获得 LiveData 自动取消订阅和安全地回调数据的特性;
    • LiveData#observeForever(Observer) 永久注册: LiveData 会一直持有观察者的引用,只要数据更新就会回调,因此这种方式必须在合适的时机手动移除观察者。

Observer.java

1
2
3
4
csharp复制代码// 观察者接口
public interface Observer<T> {
void onChanged(T t);
}
  • 4、设置数据: LiveData 设置数据需要利用子类 MutableLiveData 提供的接口:setValue() 为同步设置数据,postValue() 为异步设置数据,内部将 post 到主线程再修改数据。

MutableLiveData.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
scala复制代码public class MutableLiveData<T> extends LiveData<T> {

// 异步设置数据
@Override
public void postValue(T value) {
super.postValue(value);
}

// 同步设置数据
@Override
public void setValue(T value) {
super.setValue(value);
}
}

1.3 LiveData 存在的局限

LiveData 是 Android 生态中一个的简单的生命周期感知型容器。简单即是它的优势,也是它的局限,当然这些局限性不应该算 LiveData 的缺点,因为 LiveData 的设计初衷就是一个简单的数据容器,需要具体问题具体分析。对于简单的数据流场景,使用 LiveData 完全没有问题。

  • 1、LiveData 只能在主线程更新数据: 只能在主线程 setValue,即使 postValue 内部也是切换到主线程执行;
  • 2、LiveData 数据重放问题: 注册新的订阅者,会重新收到 LiveData 存储的数据,这在有些情况下不符合预期(具体见第 TODO 节);
  • 3、LiveData 不防抖问题: 重复 setValue 相同的值,订阅者会收到多次 onChanged() 回调(可以使用 distinctUntilChanged() 优化);
  • 4、LiveData 丢失数据问题: 在数据生产速度 > 数据消费速度时,LiveData 无法观察者能够接收到全部数据。比如在子线程大量 postValue 数据但主线程消费跟不上时,中间就会有一部分数据被忽略。

1.4 LiveData 的替代者

  • 1、RxJava: RxJava 是第三方组织 ReactiveX 开发的组件,Rx 是一个包括 Java、Go 等语言在内的多语言数据流框架。功能强大是它的优势,支持大量丰富的操作符,也支持线程切换和背压。然而 Rx 的学习门槛过高,对开发反而是一种新的负担,也会带来误用的风险。
  • 2、Kotlin Flow: Kotlin Flow 是基于 Kotlin 协程基础能力搭建的一套数据流框架,从功能复杂性上看是介于 LiveData 和 RxJava 之间的解决方案。Kotlin Flow 拥有比 LiveData 更丰富的能力,但裁剪了 RxJava 大量复杂的操作符,做得更加精简。并且在 Kotlin 协程的加持下,Kotlin Flow 目前是 Google 主推的数据流框架。

关于 Kotlin Flow 的更多内容,我们在 4、Flow:LiveData 的替代方案 这篇文章讨论过。


  1. LiveData 实现原理分析

2.1 注册观察者的执行过程

LiveData 支持使用 observe() 或 observeForever() 两种方式注册观察者,其内部会分别包装为 2 种包装对象:

  • 1、observe(): 将观察者包装为 LifecycleBoundObserver 对象,它是 Lifecycle 框架中 LifecycleEventObserver 的实现类,因此它可以绑定到宿主(参数 owner)的生命周期上,这是实现 LiveData 自动取消订阅和安全地回调数据的关键;
  • 2、observeForever(): 将观察者包装为 AlwaysActiveObserver,不会关联宿主生命周期,当然你也可以理解为全局生命周期。

注意: LiveData 内部会禁止一个观察者同时使用 observe() 和 observeForever() 两种注册方式。但同一个 LiveData 可以接收 observe() 和 observeForever() 两种观察者。

LiveData.java

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
java复制代码private SafeIterableMap<Observer<? super T>, ObserverWrapper> mObservers = new SafeIterableMap<>();

// 注册方式 1:带生命周期感知的注册方式
@MainThread
public void observe(LifecycleOwner owner, Observer<? super T> observer) {
// 1.1 主线程检查
assertMainThread("observe");
// 1.2 宿主生命周期状态是 DESTROY,则跳过
if (owner.getLifecycle().getCurrentState() == DESTROYED) {
return;
}
// 1.3 将 Observer 包装为 LifecycleBoundObserver
LifecycleBoundObserver wrapper = new LifecycleBoundObserver(owner, observer);
ObserverWrapper existing = mObservers.putIfAbsent(observer, wrapper);
// 1.4 禁止将 Observer 绑定到不同的宿主上
if (existing != null && !existing.isAttachedTo(owner)) {
throw new IllegalArgumentException("Cannot add the same observer with different lifecycles");
}
if (existing != null) {
return;
}
// 1.5 将包装类注册到宿主声明周期上
owner.getLifecycle().addObserver(wrapper);
}

// 注册方式 2:永久注册的方式
@MainThread
public void observeForever(Observer<? super T> observer) {
// 2.1 主线程检查
assertMainThread("observeForever");
// 2.2 将 Observer 包装为 AlwaysActiveObserver
AlwaysActiveObserver wrapper = new AlwaysActiveObserver(observer);
// 2.3 禁止将 Observer 注册到生命周期宿主后又进行永久注册
ObserverWrapper existing = mObservers.putIfAbsent(observer, wrapper);
if (existing instanceof LiveData.LifecycleBoundObserver) {
throw new IllegalArgumentException("Cannot add the same observer with different lifecycles");
}
if (existing != null) {
return;
}
// 2.4 分发最新数据
wrapper.activeStateChanged(true);
}

// 注销观察者
@MainThread
public void removeObserver(@NonNull final Observer<? super T> observer) {
// 主线程检查
assertMainThread("removeObserver");
// 移除
ObserverWrapper removed = mObservers.remove(observer);
if (removed == null) {
return;
}
// removed.detachObserver() 方法:
// LifecycleBoundObserver 最终会调用 Lifecycle#removeObserver()
// AlwaysActiveObserver 为空实现
removed.detachObserver();
removed.activeStateChanged(false);
}

2.2 生命周期感知源码分析

LifecycleBoundObserver 是 LifecycleEventObserver 的实现类,当宿主生命周期变化时,会回调其中的 LifecycleEventObserve#onStateChanged() 方法:

LiveData$ObserverWrapper.java

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
java复制代码private abstract class ObserverWrapper {
final Observer<? super T> mObserver;
boolean mActive;
// 观察者持有的版本号
int mLastVersion = START_VERSION; // -1

ObserverWrapper(Observer<? super T> observer) {
mObserver = observer;
}

abstract boolean shouldBeActive();

boolean isAttachedTo(LifecycleOwner owner) {
return false;
}

void detachObserver() {
}

void activeStateChanged(boolean newActive) {
// 同步宿主的生命状态
if (newActive == mActive) {
return;
}
mActive = newActive;
changeActiveCounter(mActive ? 1 : -1);
// STARTED 状态以上才会尝试分发数据
if (mActive) {
dispatchingValue(this);
}
}
}

Livedata$LifecycleBoundObserver.java

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
java复制代码// 注册方式:observe()
class LifecycleBoundObserver extends ObserverWrapper implements LifecycleEventObserver {
@NonNull
final LifecycleOwner mOwner;

LifecycleBoundObserver(@NonNull LifecycleOwner owner, Observer<? super T> observer) {
super(observer);
mOwner = owner;
}

// 宿主的生命周期大于等于可见状态(STARTED),认为活动状态
@Override
boolean shouldBeActive() {
return mOwner.getLifecycle().getCurrentState().isAtLeast(STARTED);
}

@Override
public void onStateChanged(LifecycleOwner source, Lifecycle.Event event) {
Lifecycle.State currentState = mOwner.getLifecycle().getCurrentState();
// 宿主生命周期进入 DESTROYED 时,会移除观察者
if (currentState == DESTROYED) {
removeObserver(mObserver);
return;
}
Lifecycle.State prevState = null;
while (prevState != currentState) {
prevState = currentState;
// 宿主从非可见状态转为可见状态(STARTED)时,会尝试触发数据分发
activeStateChanged(shouldBeActive());
currentState = mOwner.getLifecycle().getCurrentState();
}
}

@Override
boolean isAttachedTo(LifecycleOwner owner) {
return mOwner == owner;
}

@Override
void detachObserver() {
mOwner.getLifecycle().removeObserver(this);
}
}

AlwaysActiveObserver.java

1
2
3
4
5
6
7
8
9
10
11
12
scala复制代码// 注册方式:observeForever()
private class AlwaysActiveObserver extends ObserverWrapper {

AlwaysActiveObserver(Observer<? super T> observer) {
super(observer);
}

@Override
boolean shouldBeActive() {
return true;
}
}

2.3 同步设置数据的执行过程

LiveData 使用 setValue() 方法进行同步设置数据(必须在主线程调用),需要注意的是,设置数据后并不一定会回调 Observer#onChanged() 分发数据,而是需要同时 2 个条件:

  • 条件 1: 观察者绑定的生命周期处于活跃状态;
    • observeForever() 观察者:一直处于活跃状态;
    • observe() 观察者:owner 宿主生命周期处于活跃状态。
  • 条件 2: 观察者的持有的版本号小于 LiveData 的版本号时。

LiveData.java

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
typescript复制代码// LiveData 持有的版本号
private int mVersion;

// 异步设置数据 postValue() 最终也是调用到 setValue()
@MainThread
protected void setValue(T value) {
// 主线程检查
assertMainThread("setValue");
// 版本号加一
mVersion++;
mData = value;
// 数据分发
dispatchingValue(null);
}

// 数据分发
void dispatchingValue(ObserverWrapper initiator) {
// 这里的标记位和嵌套循环是为了处理在 Observer#onChanged() 中继续调用 setValue(),
// 而产生的递归设置数据的情况,此时会中断旧数据的分发,转而分发新数据,这是丢失数据的第 2 种情况。
if (mDispatchingValue) {
mDispatchInvalidated = true;
return;
}
mDispatchingValue = true;
do {
mDispatchInvalidated = false;
if (initiator != null) {
// onStateChanged() 走这个分支,只需要处理单个观察者
considerNotify(initiator);
initiator = null;
} else {
// setValue() 走这个分支,需要遍历所有观察者
for (Iterator<Map.Entry<Observer<? super T>, ObserverWrapper>> iterator = mObservers.iteratorWithAdditions(); iterator.hasNext(); ) {
considerNotify(iterator.next().getValue());
if (mDispatchInvalidated) {
break;
}
}
}
} while (mDispatchInvalidated);
mDispatchingValue = false;
}

// 尝试触发回调,只有观察者持有的版本号小于 LiveData 持有版本号,才会分发回调
private void considerNotify(ObserverWrapper observer) {
// STARTED 状态以上才会尝试分发数据
if (!observer.mActive) {
return;
}
if (!observer.shouldBeActive()) {
observer.activeStateChanged(false);
return;
}
// 版本对比
if (observer.mLastVersion >= mVersion) {
return;
}
observer.mLastVersion = mVersion;
// 分发回调
observer.mObserver.onChanged((T) mData);
}

总结一下回调 Observer#onChanged() 的情况:

  • 1、注册观察者时,观察者绑定的生命处于活跃状态,并且 LiveData 存在已设置的旧数据;
  • 2、调用 setValue() / postValue() 设置数据时,观察者绑定的生命周期处于活跃状态;
  • 3、观察者绑定的生命周期由非活跃状态转为活跃状态,并且 LiveData 存在未分发到该观察者的数据(即观察者持有的版本号小于 LiveData 持有的版本号);

提示: observeForever() 虽然没有直接绑定生命周期宿主,但可以理解为绑定的生命周期是全局的,因此在移除观察者之前都是活跃状态。

2.4 异步设置数据的执行过程

LiveData 使用 postValue() 方法进行异步设置数据(允许在子线程调用),内部会通过一个临时变量 mPendingData 存储数据,再通过 Handler 将切换到主线程并调用 setValue(临时变量)。因此,当在子线程连续 postValue() 时,可能会出现中间的部分数据不会被观察者接收到。

LiveData.java

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复制代码final Object mDataLock = new Object();

static final Object NOT_SET = new Object();

// 临时变量
volatile Object mPendingData = NOT_SET;

private final Runnable mPostValueRunnable = new Runnable() {
@SuppressWarnings("unchecked")
@Override
public void run() {
Object newValue;
synchronized (mDataLock) {
newValue = mPendingData;
// 重置临时变量
mPendingData = NOT_SET;
}
// 真正修改数据的地方,也是统一到 setValue() 设置数据
setValue((T) newValue);
}
};

protected void postValue(T value) {
boolean postTask;
synchronized (mDataLock) {
// 临时变量被重置时,才会发送修改的 Message,这是出现背压的第 1 种情况
postTask = mPendingData == NOT_SET;
mPendingData = value;
}
if (!postTask) {
return;
}
ArchTaskExecutor.getInstance().postToMainThread(mPostValueRunnable);
}

总结一下 LiveData 可能丢失数据的场景,此时观察者可能不会接收到所有的数据:

  • 情况 1(背压问题): 使用 postValue() 异步设置数据,并且观察者的消费速度小于数据生产速度;
  • 情况 2: 在观察者处理回调(Observer#obChanged())的过程中重新设置新数据,此时会中断旧数据的分发,部分观察者将无法接收到旧数据;
  • 情况 3: 观察者绑定的生命周期处于非活跃状态时,连续使用 setValue() / postValue() 设置数据时,观察将无法接收到中间的数据。

注意: 丢失数据不一定是需要解决的问题,需要视场景分析。

2.5 LiveData 数据重放原因分析

LiveData 的数据重放问题也叫作数据倒灌、粘性事件,核心源码在 LiveData#considerNotify(Observer) 中:

  • 首先,LiveData 和观察者各自会持有一个版本号 version,每次 LiveData#setValue 或 postValue 后,LiveData 持有的版本号会自增 1。在 LiveData#considerNotify(Observer) 尝试分发数据时,会判断观察者持有版本号是否小于 LiveData 的版本号(Observer#mLastVersion >= LiveData#mVersion 是否成立),如果成立则说明这个观察者还没有消费最新的数据版本。
  • 而观察者的持有的初始版本号是 -1,因此当注册新观察者并且正好宿主的生命周期是大于等于可见状态(STARTED)时,就会尝试分发数据,这就是数据重放。

为什么 Google 要把 LiveData 设计为粘性呢?LiveData 重放问题需要区分场景来看 —— 状态适合重放,而事件不适合重放:

  • 当 LiveData 作为一个状态使用时,在注册新观察者时重放已有状态是合理的;
  • 当 LiveData 作为一个事件使用时,在注册新观察者时重放已经分发过的事件就是不合理的。

  1. LiveData 数据重放问题的解决方案

这里我们总结一下业界提出处理 LiveData 数据重放问题的方案:

3.1 Event 事件包装器

实现一个事件包装器,内部使用一个标志位标记事件是否已经被消费过。这样的话,当观察者收到重放的数据时,由于其中的标记位已经显示被消费,因此会抛弃该事件。

不过,虽然这个方法能够解决数据倒灌问题,但是会有副作用:对于多个观察者的情况,只允许第一个观察者消费,而后续的观察者无法消费实现,这一般是不能满足需求的。

1
kotlin复制代码open class Event<out T>(private val content: T)

3.2 SingleLiveData 事件包装器变型方案

SingeLiveData 是 Google 官方的方案,在 LiveData 内部通过一个原子标志位来标记事件是否已经被消费过。这个方法本质上和 Event 实现包装器是一样的,因此也存在完全相同的副作用。

SingleLiveEvent.java

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
java复制代码public class SingleLiveEvent<T> extends MutableLiveData<T> {

private static final String TAG = "SingleLiveEvent";

// 消费标记位
private final AtomicBoolean mPending = new AtomicBoolean(false);

@MainThread
public void observe(LifecycleOwner owner, final Observer<T> observer) {

if (hasActiveObservers()) {
Log.w(TAG, "Multiple observers registered but only one will be notified of changes.");
}

// Observe the internal MutableLiveData
super.observe(owner, new Observer<T>() {
@Override
public void onChanged(@Nullable T t) {
if (mPending.compareAndSet(true, false)) {
observer.onChanged(t);
}
}
});
}

@MainThread
public void setValue(@Nullable T t) {
mPending.set(true);
super.setValue(t);
}

/**
* Used for cases where T is Void, to make calls cleaner.
*/
@MainThread
public void call() {
setValue(null);
}
}

3.3 反射修改观察者版本号

业界分享出来的一个方案,不确定思路原创源。实现方法是在注册新观察者时,通过反射的手段将观察者持有的版本号(Observer#mLastVersion)同步为 LiveData 的版本号。缺点是使用反射,但确实能够解决多观察者问题。

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
ini复制代码private void hook(@NonNull Observer<T> observer) throws Exception {
//get wrapper's version
Class<LiveData> classLiveData = LiveData.class;
Field fieldObservers = classLiveData.getDeclaredField("mObservers");
fieldObservers.setAccessible(true);
Object objectObservers = fieldObservers.get(this);
Class<?> classObservers = objectObservers.getClass();
Method methodGet = classObservers.getDeclaredMethod("get", Object.class);
methodGet.setAccessible(true);
Object objectWrapperEntry = methodGet.invoke(objectObservers, observer);
Object objectWrapper = null;
if (objectWrapperEntry instanceof Map.Entry) {
objectWrapper = ((Map.Entry) objectWrapperEntry).getValue();
}
if (objectWrapper == null) {
throw new NullPointerException("Wrapper can not be bull!");
}
Class<?> classObserverWrapper = objectWrapper.getClass().getSuperclass();
Field fieldLastVersion = classObserverWrapper.getDeclaredField("mLastVersion");
fieldLastVersion.setAccessible(true);
//get livedata's version
Field fieldVersion = classLiveData.getDeclaredField("mVersion");
fieldVersion.setAccessible(true);
Object objectVersion = fieldVersion.get(this);
//set wrapper's version
fieldLastVersion.set(objectWrapper, objectVersion);
}

3.4 UnPeekLiveData 反射方案优化

UnPeekLiveData 是 KunMinX 提出并开源的方案,主要思路是将 LiveData 源码中的 Observer#mLastVersion 和 LiveData#mVersion 在子类中重新实现一遍。在 UnPeekLiveData 中会有一个原子整型来标记数据版本,并且每个 Observer 在注册时会拿到当前 LiveData 的最新数据版本,而在 Observer#onChanged 中会对比两个版本号来决定是否分发。这个过程中没有使用反射,也不会存在不支持多观察者的问题。

ProtectedUnPeekLiveData.java

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
less复制代码public class ProtectedUnPeekLiveData<T> extends LiveData<T> {

private final static int START_VERSION = -1;

private final AtomicInteger mCurrentVersion = new AtomicInteger(START_VERSION);

protected boolean isAllowNullValue;

@Override
public void observe(@NonNull LifecycleOwner owner, @NonNull Observer<? super T> observer) {
super.observe(owner, createObserverWrapper(observer, mCurrentVersion.get()));
}

@Override
public void observeForever(@NonNull Observer<? super T> observer) {
super.observeForever(createObserverWrapper(observer, mCurrentVersion.get()));
}

public void observeSticky(@NonNull LifecycleOwner owner, @NonNull Observer<T> observer) {
super.observe(owner, createObserverWrapper(observer, START_VERSION));
}

public void observeStickyForever(@NonNull Observer<? super T> observer) {
super.observeForever(createObserverWrapper(observer, START_VERSION));
}

@Override
protected void setValue(T value) {
mCurrentVersion.getAndIncrement();
super.setValue(value);
}

class ObserverWrapper implements Observer<T> {
private final Observer<? super T> mObserver;
private int mVersion = START_VERSION;

public ObserverWrapper(@NonNull Observer<? super T> observer, int version) {
this.mObserver = observer;
this.mVersion = version;
}

@Override
public void onChanged(T t) {
if (mCurrentVersion.get() > mVersion && (t != null || isAllowNullValue)) {
mObserver.onChanged(t);
}
}

@SuppressWarnings("unchecked")
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
ObserverWrapper that = (ObserverWrapper) o;
return Objects.equals(mObserver, that.mObserver);
}

@Override
public int hashCode() {
return Objects.hash(mObserver);
}
}

@Override
public void removeObserver(@NonNull Observer<? super T> observer) {
if (observer.getClass().isAssignableFrom(ObserverWrapper.class)) {
super.removeObserver(observer);
} else {
super.removeObserver(createObserverWrapper(observer, START_VERSION));
}
}

private ObserverWrapper createObserverWrapper(@NonNull Observer<? super T> observer, int version) {
return new ObserverWrapper(observer, version);
}

public void clear() {
super.setValue(null);
}
}

UnPeekLiveData.java

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
typescript复制代码public class UnPeekLiveData<T> extends ProtectedUnPeekLiveData<T> {

@Override
public void setValue(T value) {
super.setValue(value);
}

@Override
public void postValue(T value) {
super.postValue(value);
}

public static class Builder<T> {

private boolean isAllowNullValue;

public Builder<T> setAllowNullValue(boolean allowNullValue) {
this.isAllowNullValue = allowNullValue;
return this;
}

public UnPeekLiveData<T> create() {
UnPeekLiveData<T> liveData = new UnPeekLiveData<>();
liveData.isAllowNullValue = this.isAllowNullValue;
return liveData;
}
}
}

3.5 Kotlin Flow

Google 对 Flow 的定位是 Kotlin 环境下对 LiveData 的替代品,使用 SharedFlow 可以控制重放数量,可以设置为 0 表示禁止重放。


  1. 基于 LiveData 的事件总线 LiveDataBus

如果我们把事件理解为一种数据,LiveData 可以推数据自然也可以推事件,于是有人将 LiveData 封装为 “广播”,从而实现 “事件发送者” 和 “事件观察者” 的代码解耦,例如美团版本的 LiveDataBus。相较于 EventBus,LiveDataBus 实现更强的生命周期安全;相较于接口,LiveData 的约束力更弱。

4.1 LiveDataBus 什么场景适合?

无论是 EventBus 还是 LiveDataBus,它们本质上都是 “多对多的广播”,它们仅适合作为全局的事件通信,而页面内的事件通信应该继续采用 ViewModel + LiveData 等方案。这是因为事件总线缺乏 MVVM 模式建立的唯一可信源约束,事件发出后很难定位是哪个消息源推送出来的。

4.2 LiveDataBus 的实现

LiveDataBus 代码不多,核心在于使用哈希表保存事件名到 LiveData 的映射关系:

LiveDataBus.java

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
typescript复制代码public final class LiveDataBus {

// 事件名 - LiveData 哈希表
private final Map<String, BusMutableLiveData<Object>> bus;

private LiveDataBus() {
bus = new HashMap<>();
}

// 全局单例模式
private static class SingletonHolder {
private static final LiveDataBus DEFAULT_BUS = new LiveDataBus();
}

public static LiveDataBus get() {
return SingletonHolder.DEFAULT_BUS;
}

// 根据事件名映射 LiveData
public <T> MutableLiveData<T> with(String key, Class<T> type) {
if (!bus.containsKey(key)) {
// 构造新的 LiveData 对象
bus.put(key, new BusMutableLiveData<>());
}
return (MutableLiveData<T>) bus.get(key);
}

// 根据事件名映射 LiveData
public MutableLiveData<Object> with(String key) {
return with(key, Object.class);
}

private static class ObserverWrapper<T> implements Observer<T> {

private Observer<T> observer;

public ObserverWrapper(Observer<T> observer) {
this.observer = observer;
}

@Override
public void onChanged(@Nullable T t) {
if (observer != null) {
if (isCallOnObserve()) {
return;
}
observer.onChanged(t);
}
}

private boolean isCallOnObserve() {
StackTraceElement[] stackTrace = Thread.currentThread().getStackTrace();
if (stackTrace != null && stackTrace.length > 0) {
for (StackTraceElement element : stackTrace) {
if ("android.arch.lifecycle.LiveData".equals(element.getClassName()) &&
"observeForever".equals(element.getMethodName())) {
return true;
}
}
}
return false;
}
}

private static class BusMutableLiveData<T> extends MutableLiveData<T> {

private Map<Observer, Observer> observerMap = new HashMap<>();

@Override
public void observe(@NonNull LifecycleOwner owner, @NonNull Observer<T> observer) {
super.observe(owner, observer);
try {
hook(observer);
} catch (Exception e) {
e.printStackTrace();
}
}

@Override
public void observeForever(@NonNull Observer<T> observer) {
if (!observerMap.containsKey(observer)) {
observerMap.put(observer, new ObserverWrapper(observer));
}
super.observeForever(observerMap.get(observer));
}

@Override
public void removeObserver(@NonNull Observer<T> observer) {
Observer realObserver = null;
if (observerMap.containsKey(observer)) {
realObserver = observerMap.remove(observer);
} else {
realObserver = observer;
}
super.removeObserver(realObserver);
}

// 也可以使用其他方案
private void hook(@NonNull Observer<T> observer) throws Exception {
//get wrapper's version
Class<LiveData> classLiveData = LiveData.class;
Field fieldObservers = classLiveData.getDeclaredField("mObservers");
fieldObservers.setAccessible(true);
Object objectObservers = fieldObservers.get(this);
Class<?> classObservers = objectObservers.getClass();
Method methodGet = classObservers.getDeclaredMethod("get", Object.class);
methodGet.setAccessible(true);
Object objectWrapperEntry = methodGet.invoke(objectObservers, observer);
Object objectWrapper = null;
if (objectWrapperEntry instanceof Map.Entry) {
objectWrapper = ((Map.Entry) objectWrapperEntry).getValue();
}
if (objectWrapper == null) {
throw new NullPointerException("Wrapper can not be bull!");
}
Class<?> classObserverWrapper = objectWrapper.getClass().getSuperclass();
Field fieldLastVersion = classObserverWrapper.getDeclaredField("mLastVersion");
fieldLastVersion.setAccessible(true);
//get livedata's version
Field fieldVersion = classLiveData.getDeclaredField("mVersion");
fieldVersion.setAccessible(true);
Object objectVersion = fieldVersion.get(this);
//set wrapper's version
fieldLastVersion.set(objectWrapper, objectVersion);
}
}
}

使用 LiveDataBus:

1
2
3
4
5
6
7
8
9
less复制代码LiveDataBus.get().with("key_test").setValue("");

LiveDataBus.get()
.with("key_test", String.class)
.observe(this, new Observer<String>() {
@Override
public void onChanged(@Nullable String s) {
}
});

4.3 如何加强 LiveDataBus 事件约束

无论是 EventBus 还是 LiveDataBus 都没有对事件定义进行约束,不同开发者 / 不同组件可能会定义相同的事件字符串而导致冲突。

为了优化这个问题,可以使用美团 ModularEventBus 方案:用接口定义事件来实现强约束,在动态代理中取 接口名_方法名 作为事件名,再完成后续 LiveDataBus 的交互。

LiveDataBus.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
kotlin复制代码class LiveDataBus {
fun <E> of(clz: Class<E>): E {
if(!clz.isInterface){
throw IllegalArgumentException("API declarations must be interfaces.")
}
if(0 < clz.interfaces.size){
throw IllegalArgumentException("API interfaces must not extend other interfaces.")
}
return Proxy.newProxyInstance(clz.classLoader, arrayOf(clz), InvocationHandler { _, method, _->
// 取“接口名_方法名”作为事件名,再转交给 LiveDataBus
return@InvocationHandler get().with(
"${clz.canonicalName}_${method.name}",
(method.genericReturnType as ParameterizedType).actualTypeArguments[0].javaClass)
}) as E
}
}

另外,事件接口可以交给 APT 注解处理器生成:通过 DemoEvent 定义事件名常量,用 APT 将事件名转换为事件接口的方法:

DemoEvents.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
java复制代码//可以指定module,若不指定,则使用包名作为module名
@ModuleEvents()
public class DemoEvents {

//不指定消息类型,那么消息的类型默认为Object
public static final String EVENT1 = "event1";

//指定消息类型为自定义Bean
@EventType(TestEventBean.class)
public static final String EVENT2 = "event2";

//指定消息类型为java原生类型
@EventType(String.class)
public static final String EVENT3 = "event3";
}

EventsDefineOfDemoEvents.java

1
2
3
4
5
6
7
8
9
scss复制代码package com.sankuai.erp.modularevent.generated.com.meituan.jeremy.module_b_export;

public interface EventsDefineOfDemoEvents extends com.sankuai.erp.modularevent.base.IEventsDefine {
com.sankuai.erp.modularevent.Observable<java.lang.Object> EVENT1();

com.sankuai.erp.modularevent.Observable<com.meituan.jeremy.module_b_export.TestEventBean> EVENT2();

com.sankuai.erp.modularevent.Observable<java.lang.String> EVENT3();
}

使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
scss复制代码LiveDataBus
.get()
.of(EventsDefineOfDemoEvents::class.java)
.EVENT1()
.post(true)

LiveDataBus
.get()
.of(EventsDefineOfDemoEvents::class.java)
.EVENT1()
.observe(this, Observer {
Log.i(LOG, it.toString())
})

—— 图片引用自美团技术博客


  1. 总结

到这里,Jetpack 中的 LiveData 组件就讲完了,由于美团的 modular-event 并没有开源,下篇文章我们直接来做一次学习落地。关注我,带你了解更多。


2022 年 8 月 30 日更新

ModularEventBus 组件化事件总线框架现已发布,点击查看

参考资料

  • LiveData 概览 —— 官方文档
  • 重学安卓:吃透 LiveData 本质,享用可靠消息鉴权机制 —— KunMinX 著
  • 重学安卓:LiveData 数据倒灌 “背景缘由全貌” 独家解析 —— KunMinX 著
  • 关于 LiveData 粘性事件所带来问题的解决方案—— 慕尼黑 著
  • 带你了解 LiveData 重放污染的前世今生—— 徐宜生 著
  • Android 消息总线的演进之路:用 LiveDataBus 替代 RxBus、EventBus —— 美团技术团队
  • Android 组件化方案及组件消息总线 modular-event 实战 —— 美团技术团队
  • 基于 LiveData 实现事件总线思路和方案 —— toothpickTina 著

推荐阅读

Android Jetpack 系列文章目录如下(2023/07/08 更新):

  • #1 Lifecycle:生命周期感知型组件的基础
  • #2 为什么 LiveData 会重放数据,怎么解决?
  • #3 为什么 Activity 都重建了 ViewModel 还存在?
  • #4 有小伙伴说看不懂 LiveData、Flow、Channel,跟我走
  • #5 Android UI 架构演进:从 MVC 到 MVP、MVVM、MVI
  • #6 ViewBinding 与 Kotlin 委托双剑合璧
  • #7 AndroidX Fragment 核心原理分析
  • #8 OnBackPressedDispatcher:Jetpack 处理回退事件的新姿势
  • #9 食之无味!App Startup 可能比你想象中要简单
  • #10 从 Dagger2 到 Hilt 玩转依赖注入(一)

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

我正在参与掘金技术社区创作者签约计划招募活动,点击链接报名投稿。

本文转载自: 掘金

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

讲透JAVA Stream的collect用法与原理,远比你

发表于 2022-07-18

大家好,又见面了。

在我前面的文章《吃透JAVA的Stream流操作,多年实践总结》中呢,对Stream的整体情况进行了细致全面的讲解,也大概介绍了下结果收集器Collectors的常见用法 —— 但远不是全部。

本篇文章就来专门剖析collect操作,一起解锁更多高级玩法,让Stream操作真正的成为我们编码中的神兵利器。

初识Collector

先看一个简单的场景:

现有集团内所有人员列表,需要从中筛选出上海子公司的全部人员

假定人员信息数据如下:

姓名 子公司 部门 年龄 工资
大壮 上海公司 研发一部 28 3000
二牛 上海公司 研发一部 24 2000
铁柱 上海公司 研发二部 34 5000
翠花 南京公司 测试一部 27 3000
玲玲 南京公司 测试二部 31 4000

如果你曾经用过Stream流,或者你看过我前面关于Stream用法介绍的文章,那么借助Stream可以很轻松的实现上述诉求:

1
2
3
4
5
6
java复制代码public void filterEmployeesByCompany() {
List<Employee> employees = getAllEmployees().stream()
.filter(employee -> "上海公司".equals(employee.getSubCompany()))
.collect(Collectors.toList());
System.out.println(employees);
}

上述代码中,先创建流,然后通过一系列中间流操作(filter方法)进行业务层面的处理,然后经由终止操作(collect方法)将处理后的结果输出为List对象。

但我们实际面对的需求场景中,往往会有一些更复杂的诉求,比如说:

现有集团内所有人员列表,需要从中筛选出上海子公司的全部人员,并按照部门进行分组

其实也就是加了个新的分组诉求,那就是先按照前面的代码实现逻辑基础上,再对结果进行分组处理就好咯:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
java复制代码public void filterEmployeesThenGroup() {
// 先 筛选
List<Employee> employees = getAllEmployees().stream()
.filter(employee -> "上海公司".equals(employee.getSubCompany()))
.collect(Collectors.toList());
// 再 分组
Map<String, List<Employee>> resultMap = new HashMap<>();
for (Employee employee : employees) {
List<Employee> groupList = resultMap
.computeIfAbsent(employee.getDepartment(), k -> new ArrayList<>());
groupList.add(employee);
}
System.out.println(resultMap);
}

似乎也没啥毛病,相信很多同学实际编码中也是这么处理的。但其实我们也可以使用Stream操作直接完成:

1
2
3
4
5
6
java复制代码public void filterEmployeesThenGroupByStream() {
Map<String, List<Employee>> resultMap = getAllEmployees().stream()
.filter(employee -> "上海公司".equals(employee.getSubCompany()))
.collect(Collectors.groupingBy(Employee::getDepartment));
System.out.println(resultMap);
}

两种写法都可以得到相同的结果:

1
2
3
4
css复制代码{
研发二部=[Employee(subCompany=上海公司, department=研发二部, name=铁柱, age=34, salary=5000)],
研发一部=[Employee(subCompany=上海公司, department=研发一部, name=大壮, age=28, salary=3000), Employee(subCompany=上海公司, department=研发一部, name=二牛, age=24, salary=2000)]
}

上述2种写法相比而言,第二种是不是代码上要简洁很多?而且是不是有种自注释的味道了?

通过collect方法的合理恰当利用,可以让Stream适应更多实际的使用场景,大大的提升我们的开发编码效率。下面就一起来全面认识下collect、解锁更多高级操作吧。

collect\Collector\Collectors区别与关联

刚接触Stream收集器的时候,很多同学都会被collect,Collector,Collectors这几个概念搞的晕头转向,甚至还有很多人即使已经使用Stream好多年,也只是知道collect里面需要传入类似Collectors.toList()这种简单的用法,对其背后的细节也不甚了解。

这里以一个collect收集器最简单的使用场景来剖析说明下其中的关系:

📢概括来说:

1️⃣ collect是Stream流的一个终止方法,会使用传入的收集器(入参)对结果执行相关的操作,这个收集器必须是Collector接口的某个具体实现类

2️⃣ Collector是一个接口,collect方法的收集器是Collector接口的具体实现类

3️⃣ Collectors是一个工具类,提供了很多的静态工厂方法,提供了很多Collector接口的具体实现类,是为了方便程序员使用而预置的一些较为通用的收集器(如果不使用Collectors类,而是自己去实现Collector接口,也可以)。

Collector使用与剖析

到这里我们可以看出,Stream结果收集操作的本质,其实就是将Stream中的元素通过收集器定义的函数处理逻辑进行加工,然后输出加工后的结果。

根据其执行的操作类型来划分,又可将收集器分为几种不同的大类:

下面分别阐述下。

恒等处理Collector

所谓恒等处理,指的就是Stream的元素在经过Collector函数处理前后完全不变,例如toList()操作,只是最终将结果从Stream中取出放入到List对象中,并没有对元素本身做任何的更改处理:

恒等处理类型的Collector是实际编码中最常被使用的一种,比如:

1
2
3
java复制代码list.stream().collect(Collectors.toList());
list.stream().collect(Collectors.toSet());
list.stream().collect(Collectors.toCollection());

归约汇总Collector

对于归约汇总类的操作,Stream流中的元素逐个遍历,进入到Collector处理函数中,然后会与上一个元素的处理结果进行合并处理,并得到一个新的结果,以此类推,直到遍历完成后,输出最终的结果。比如Collectors.summingInt()方法的处理逻辑如下:

比如本文开头举的例子,如果需要计算上海子公司每个月需要支付的员工总工资,使用Collectors.summingInt()可以这么实现:

1
2
3
4
5
6
java复制代码public void calculateSum() {
Integer salarySum = getAllEmployees().stream()
.filter(employee -> "上海公司".equals(employee.getSubCompany()))
.collect(Collectors.summingInt(Employee::getSalary));
System.out.println(salarySum);
}

需要注意的是,这里的汇总计算,不单单只数学层面的累加汇总,而是一个广义上的汇总概念,即将多个元素进行处理操作,最终生成1个结果的操作,比如计算Stream中最大值的操作,最终也是多个元素中,最终得到一个结果:

还是用之前举的例子,现在需要知道上海子公司里面工资最高的员工信息,我么可以这么实现:

1
2
3
4
5
6
java复制代码public void findHighestSalaryEmployee() {
Optional<Employee> highestSalaryEmployee = getAllEmployees().stream()
.filter(employee -> "上海公司".equals(employee.getSubCompany()))
.collect(Collectors.maxBy(Comparator.comparingInt(Employee::getSalary)));
System.out.println(highestSalaryEmployee.get());
}

因为这里我们要演示collect的用法,所以用了上述的写法。实际的时候JDK为了方便使用,也提供了上述逻辑的简化封装,我们可以直接使用max()方法来简化,即上述代码与下面的写法等价:

1
2
3
4
5
6
java复制代码public void findHighestSalaryEmployee2() {
Optional<Employee> highestSalaryEmployee = getAllEmployees().stream()
.filter(employee -> "上海公司".equals(employee.getSubCompany()))
.max(Comparator.comparingInt(Employee::getSalary));
System.out.println(highestSalaryEmployee.get());
}

分组分区Collector

Collectors工具类中提供了groupingBy方法用来得到一个分组操作Collector,其内部处理逻辑可以参见下图的说明:

groupingBy()操作需要指定两个关键输入,即分组函数和值收集器:

  • 分组函数:一个处理函数,用于基于指定的元素进行处理,返回一个用于分组的值(即分组结果HashMap的Key值),对于经过此函数处理后返回值相同的元素,将被分配到同一个组里。
  • 值收集器:对于分组后的数据元素的进一步处理转换逻辑,此处还是一个常规的Collector收集器,和collect()方法中传入的收集器完全等同(可以想想俄罗斯套娃,一个概念)。

对于groupingBy分组操作而言,分组函数与值收集器二者必不可少。为了方便使用,在Collectors工具类中,提供了两个groupingBy重载实现,其中有一个方法只需要传入一个分组函数即可,这是因为其默认使用了toList()作为值收集器:

例如:仅仅是做一个常规的数据分组操作时,可以仅传入一个分组函数即可:

1
2
3
4
5
6
7
java复制代码public void groupBySubCompany() {
// 按照子公司维度将员工分组
Map<String, List<Employee>> resultMap =
getAllEmployees().stream()
.collect(Collectors.groupingBy(Employee::getSubCompany));
System.out.println(resultMap);
}

这样collect返回的结果,就是一个HashMap,其每一个HashValue的值为一个List类型。

而如果不仅需要分组,还需要对分组后的数据进行处理的时候,则需要同时给定分组函数以及值收集器:

1
2
3
4
5
6
7
java复制代码public void groupAndCaculate() {
// 按照子公司分组,并统计每个子公司的员工数
Map<String, Long> resultMap = getAllEmployees().stream()
.collect(Collectors.groupingBy(Employee::getSubCompany,
Collectors.counting()));
System.out.println(resultMap);
}

这样就同时实现了分组与组内数据的处理操作:

1
复制代码{南京公司=2, 上海公司=3}

上面的代码中Collectors.groupingBy()是一个分组Collector,而其内又传入了一个归约汇总Collector Collectors.counting(),也就是一个收集器中嵌套了另一个收集器。

除了上述演示的场景外,还有一种特殊的分组操作,其分组的key类型仅为布尔值,这种情况,我们也可以通过Collectors.partitioningBy()提供的分区收集器来实现。

例如:

统计上海公司和非上海公司的员工总数, true表示是上海公司,false表示非上海公司

使用分区收集器的方式,可以这么实现:

1
2
3
4
5
6
java复制代码public void partitionByCompanyAndDepartment() {
Map<Boolean, Long> resultMap = getAllEmployees().stream()
.collect(Collectors.partitioningBy(e -> "上海公司".equals(e.getSubCompany()),
Collectors.counting()));
System.out.println(resultMap);
}

结果如下:

1
ini复制代码{false=2, true=3}

Collectors.partitioningBy()分区收集器的使用方式与Collectors.groupingBy()分组收集器的使用方式相同。单纯从使用维度来看,分组收集器的分组函数返回值为布尔值,则效果等同于一个分区收集器。

Collector的叠加嵌套

有的时候,我们需要根据先根据某个维度进行分组后,再根据第二维度进一步的分组,然后再对分组后的结果进一步的处理操作,这种场景里面,我们就可以通过Collector收集器的叠加嵌套使用来实现。

例如下面的需求:

现有整个集团全体员工的列表,需要统计各子公司内各部门下的员工人数。

使用Stream的嵌套Collector,我们可以这么实现:

1
2
3
4
5
6
7
8
java复制代码public void groupByCompanyAndDepartment() {
// 按照子公司+部门双层维度,统计各个部门内的人员数
Map<String, Map<String, Long>> resultMap = getAllEmployees().stream()
.collect(Collectors.groupingBy(Employee::getSubCompany,
Collectors.groupingBy(Employee::getDepartment,
Collectors.counting())));
System.out.println(resultMap);
}

可以看下输出结果,达到了需求预期的诉求:

1
2
3
4
5
6
7
8
markdown复制代码{
南京公司={
测试二部=1,
测试一部=1},
上海公司={
研发二部=1,
研发一部=2}
}

上面的代码中,就是一个典型的Collector嵌套处理的例子,同时也是一个典型的多级分组的实现逻辑。对代码的整体处理过程进行剖析,大致逻辑如下:

借助多个Collector嵌套使用,可以让我们解锁很多复杂场景处理能力。你可以将这个操作想象为一个套娃操作,如果愿意,你可以无限嵌套下去(实际中不太可能会有如此荒诞的场景)。

Collectors提供的收集器

为了方便程序员使用呢,JDK中的Collectors工具类封装提供了很多现成的Collector实现类,可供编码时直接使用,对常用的收集器介绍如下:

方法 含义说明
toList 将流中的元素收集到一个List中
toSet 将流中的元素收集到一个Set中
toCollection 将流中的元素收集到一个Collection中
toMap 将流中的元素映射收集到一个Map中
counting 统计流中的元素个数
summingInt 计算流中指定int字段的累加总和。针对不同类型的数字类型,有不同的方法,比如summingDouble等
averagingInt 计算流中指定int字段的平均值。针对不同类型的数字类型,有不同的方法,比如averagingLong等
joining 将流中所有元素(或者元素的指定字段)字符串值进行拼接,可以指定拼接连接符,或者首尾拼接字符
maxBy 根据给定的比较器,选择出值最大的元素
minBy 根据给定的比较器,选择出值最小的元素
groupingBy 根据给定的分组函数的值进行分组,输出一个Map对象
partitioningBy 根据给定的分区函数的值进行分区,输出一个Map对象,且key始终为布尔值类型
collectingAndThen 包裹另一个收集器,对其结果进行二次加工转换
reducing 从给定的初始值开始,将元素进行逐个的处理,最终将所有元素计算为最终的1个值输出

上述的大部分方法,前面都有使用示例,这里对collectAndThen补充介绍下。

collectAndThen对应的收集器,必须传入一个真正用于结果收集处理的实际收集器downstream以及一个finisher方法,当downstream收集器计算出结果后,使用finisher方法对结果进行二次处理,并将处理结果作为最终结果返回。

还是拿之前的例子来举例:

给定集团所有员工列表,找出上海公司中工资最高的员工。

我们可以写出如下代码:

1
2
3
4
5
6
java复制代码public void findHighestSalaryEmployee() {
Optional<Employee> highestSalaryEmployee = getAllEmployees().stream()
.filter(employee -> "上海公司".equals(employee.getSubCompany()))
.collect(Collectors.maxBy(Comparator.comparingInt(Employee::getSalary)));
System.out.println(highestSalaryEmployee.get());
}

但是这个结果最终输出的是个Optional<Employee>类型,使用的时候比较麻烦,那能不能直接返回我们需要的Employee类型呢?这里就可以借助collectAndThen来实现:

1
2
3
4
5
6
7
8
9
10
java复制代码public void testCollectAndThen() {
Employee employeeResult = getAllEmployees().stream()
.filter(employee -> "上海公司".equals(employee.getSubCompany()))
.collect(
Collectors.collectingAndThen(
Collectors.maxBy(Comparator.comparingInt(Employee::getSalary)),
Optional::get)
);
System.out.println(employeeResult);
}

这样就可以啦,是不是超简单的?

开发个自定义收集器

前面我们演示了很多Collectors工具类中提供的收集器的用法,上一节中列出来的Collectors提供的常用收集器,也可以覆盖大部分场景的开发诉求了。

但也许在项目中,我们会遇到一些定制化的场景,现有的收集器无法满足我们的诉求,这个时候,我们也可以自己来实现定制化的收集器。

Collector接口介绍

我们知道,所谓的收集器,其实就是一个Collector接口的具体实现类。所以如果想要定制自己的收集器,首先要先了解Collector接口到底有哪些方法需要我们去实现,以及各个方法的作用与用途。

当我们新建一个MyCollector类并声明实现Collector接口的时候,会发现需要我们实现5个接口:

这5个接口的含义说明归纳如下:

接口名称 功能含义说明
supplier 创建新的结果容器,可以是一个容器,也可以是一个累加器实例,总之是用来存储结果数据的
accumlator 元素进入收集器中的具体处理操作
finisher 当所有元素都处理完成后,在返回结果前的对结果的最终处理操作,当然也可以选择不做任何处理,直接返回
combiner 各个子流的处理结果最终如何合并到一起去,比如并行流处理场景,元素会被切分为好多个分片进行并行处理,最终各个分片的数据需要合并为一个整体结果,即通过此方法来指定子结果的合并逻辑
characteristics 对此收集器处理行为的补充描述,比如此收集器是否允许并行流中处理,是否finisher方法必须要有等等,此处返回一个Set集合,里面的候选值是固定的几个可选项。

对于characteristics返回set集合中的可选值,说明如下:

取值 含义说明
UNORDERED 声明此收集器的汇总归约结果与Stream流元素遍历顺序无关,不受元素处理顺序影响
CONCURRENT 声明此收集器可以多个线程并行处理,允许并行流中进行处理
IDENTITY_FINISH 声明此收集器的finisher方法是一个恒等操作,可以跳过

现在,我们知道了这5个接口方法各自的含义与用途了,那么作为一个Collector收集器,这几个接口之间是如何配合处理并将Stream数据收集为需要的输出结果的呢?下面这张图可以清晰的阐述这一过程:

当然,如果我们的Collector是支持在并行流中使用的,则其处理过程会稍有不同:

为了对上述方法有个直观的理解,我们可以看下Collectors.toList()这个收集器的实现源码:

1
2
3
4
5
6
7
8
java复制代码static final Set<Collector.Characteristics> CH_ID
= Collections.unmodifiableSet(EnumSet.of(Collector.Characteristics.IDENTITY_FINISH));

public static <T> Collector<T, ?, List<T>> toList() {
return new CollectorImpl<>((Supplier<List<T>>) ArrayList::new, List::add,
(left, right) -> { left.addAll(right); return left; },
CH_ID);
}

对上述代码拆解分析如下:

  • supplier方法:ArrayList::new,即new了个ArrayList作为结果存储容器。
  • accumulator方法:List::add,也就是对于stream中的每个元素,都调用list.add()方法添加到结果容器追踪。
  • combiner方法:(left, right) -> { left.addAll(right); return left; },也就是对于并行操作生成的各个子ArrayList结果,最终通过list.addAll()方法合并为最终结果。
  • finisher方法:没提供,使用的默认的,因为无需做任何处理,属于恒等操作。
  • characteristics:返回的是IDENTITY_FINISH,也即最终结果直接返回,无需finisher方法去二次加工。注意这里没有声明CONCURRENT,因为ArrayList是个非线程安全的容器,所以这个收集器是不支持在并发过程中使用。

通过上面的逐个方法描述,再联想下Collectors.toList()的具体表现,想必对各个接口方法的含义应该有了比较直观的理解了吧?

实现Collector接口

既然已经搞清楚Collector接口中的主要方法作用,那就可以开始动手写自己的收集器啦。新建一个class类,然后声明实现Collector接口,然后去实现具体的接口方法就行咯。

前面介绍过,Collectors.summingInt收集器是用来计算每个元素中某个int类型字段的总和的,假设我们需要一个新的累加功能:

计算流中每个元素的某个int字段值平方的总和

下面,我们就一起来自定义一个收集器来实现此功能。

  • supplier方法

supplier方法的职责,是创建一个结果存储累加的容器。既然我们要计算多个值的累加结果,那首先就是要先声明一个int sum = 0用来存储累加结果。但是为了让我们的收集器可以支持在并发模式下使用,我们这里可以采用线程安全的AtomicInteger来实现。

所以我们便可以确定supplier方法的实现逻辑了:

1
2
3
4
5
java复制代码@Override
public Supplier<AtomicInteger> supplier() {
// 指定用于最终结果的收集,此处返回new AtomicInteger(0),后续在此基础上累加
return () -> new AtomicInteger(0);
}
  • accumulator方法

accumulator方法是实现具体的计算逻辑的,也是整个Collector的核心业务逻辑所在的方法。收集器处理的时候,Stream流中的元素会逐个进入到Collector中,然后由accumulator方法来进行逐个计算:

1
2
3
4
5
6
7
8
java复制代码@Override
public BiConsumer<AtomicInteger, T> accumulator() {
// 每个元素进入的时候的遍历策略,当前元素值的平方与sum结果进行累加
return (sum, current) -> {
int intValue = mapper.applyAsInt(current);
sum.addAndGet(intValue * intValue);
};
}

这里也补充说下,收集器中的几个方法中,仅有accumulator是需要重复执行的,有几个元素就会执行几次,其余的方法都不会直接与Stream中的元素打交道。

  • combiner方法

因为我们前面supplier方法中使用了线程安全的AtomicInteger作为结果容器,所以其支持在并行流中使用。根据上面介绍,并行流是将Stream切分为多个分片,然后分别对分片进行计算处理得到分片各自的结果,最后这些分片的结果需要合并为同一份总的结果,这个如何合并,就是此处我们需要实现的:

1
2
3
4
5
6
7
8
java复制代码@Override
public BinaryOperator<AtomicInteger> combiner() {
// 多个分段结果处理的策略,直接相加
return (sum1, sum2) -> {
sum1.addAndGet(sum2.get());
return sum1;
};
}

因为我们这里是要做一个数字平方的总和,所以这里对于分片后的结果,我们直接累加到一起即可。

  • finisher方法

我们的收集器目标结果是输出一个累加的Integer结果值,但是为了保证并发流中的线程安全,我们使用AtomicInteger作为了结果容器。也就是最终我们需要将内部的AtomicInteger对象转换为Integer对象,所以finisher方法我们的实现逻辑如下:

1
2
3
4
5
6
7
java复制代码@Override
public Function<AtomicInteger, Integer> finisher() {
// 结果处理完成之后对结果的二次处理
// 为了支持多线程并发处理,此处内部使用了AtomicInteger作为了结果累加器
// 但是收集器最终需要返回Integer类型值,此处进行对结果的转换
return AtomicInteger::get;
}
  • characteristics方法

这里呢,我们声明下该Collector收集器的一些特性就行了:

  1. 因为我们实现的收集器是允许并行流中使用的,所以我们声明了CONCURRENT属性;
  2. 作为一个数字累加算总和的操作,对元素的先后计算顺序并没有关系,所以我们也同时声明UNORDERED属性;
  3. 因为我们的finisher方法里面是做了个结果处理转换操作的,并非是一个恒等处理操作,所以这里就不能声明IDENTITY_FINISH属性。

基于此分析,此方法的实现如下:

1
2
3
4
5
6
7
8
9
10
11
java复制代码@Override
public Set<Characteristics> characteristics() {
Set<Characteristics> characteristics = new HashSet<>();
// 指定该收集器支持并发处理(前面也发现我们采用了线程安全的AtomicInteger方式)
characteristics.add(Characteristics.CONCURRENT);
// 声明元素数据处理的先后顺序不影响最终收集的结果
characteristics.add(Characteristics.UNORDERED);
// 注意:这里没有添加下面这句,因为finisher方法对结果进行了处理,非恒等转换
// characteristics.add(Characteristics.IDENTITY_FINISH);
return characteristics;
}

这样呢,我们的自定义收集器就实现好了,如果需要完整代码,可以到文末的github仓库地址上获取。

我们使用下自己定义的收集器看看:

1
2
3
4
5
java复制代码public void testMyCollector() {
Integer result = Stream.of(new Score(1), new Score(2), new Score(3), new Score(4))
.collect(new MyCollector<>(Score::getScore));
System.out.println(result);
}

输出结果:

1
复制代码30

完全符合我们的预期,自定义收集器就实现好了。回头再看下,是不是挺简单的?

总结

好啦,关于Java中Stream的collect用法与Collector收集器的内容,这里就给大家分享到这里咯。看到这里,不知道你是否掌握了呢?是否还有什么疑问或者更好的见解呢?欢迎多多留言切磋交流。

2023.09.13 补充

Java Stream的两篇文档发表之后,受到了小伙伴们的热情支持,2篇文章收获了累计 10w+阅读、2k+点赞以及 5k+收藏的记录,本篇文档更是获选2022掘金年度爆款好文的奖项。能够得到众多小伙伴的认可,是技术分享过程中最开心的事情。不少小伙伴在评论中提出了一些的疑问或自己的独到见解,也在评论区中进行了热烈的互动讨论。梳理了下相关评论内容,针对大家讨论的比较热烈的几个点进行了汇总与阐述,也算是对原有2篇Stream系列文档的补充。有兴趣的小伙伴可以点击《再聊Java Stream的一些实战技能与注意点》一睹为快。

📢此外:

  • 关于本文中涉及的演示代码的完整示例,我已经整理并提交到github中,如果您有需要,可以自取:github.com/veezean/Jav…

我是悟道,聊技术、又不仅仅聊技术~

如果觉得有用,请点个关注,也可关注下我的公众号【架构悟道】,获取更及时更新。

期待与你一起探讨,一起成长为更好的自己。


我正在参与掘金技术社区创作者签约计划招募活动,点击链接报名投稿。

本文转载自: 掘金

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

Android Jetpack 开发套件

发表于 2022-07-15

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

Android Jetpack 开发套件是 Google 推出的 Android 应用开发编程范式,为开发者提供了解决应用开发场景中通用的模式化问题的最佳实践,让开发者可将时间精力集中于真正重要的业务编码工作上。

这篇文章是 Android Jetpack 系列文章的第 1 篇文章,完整目录可以移步至文章末尾~

前言

  • 生命周期是 Activity 的核心特性之一,也是 Android 视图开发无法规避的重要问题。 为了更加健壮地处理生命周期问题,Google 的解决方案是将生命周期定义为一套标准的行为模式,即 Lifecycle 框架。 这种方式不仅简化了在 Activity / Fragment 等生命周期宿主中分发生命周期事件的复杂度,还提供了自定义生命周期宿主的标准模板;
  • Lifecycle 是多个 Jetpack 组件的基础,例如我们熟悉的 LiveData 就是以 Lifecycle 为基础实现的生命周期感知型数据容器,因此我们选择将 Lifecycle 放在 Jetpack 系列的第一篇。

  1. 认识 Lifecycle

1.1 为什么要使用 Lifecycle?

Lifecycle 的主要作用是简化实现生命周期感知型组件的复杂度。 在传统的方式中,需要手动从外部宿主(如 Activity、Fragment 或自定义宿主)中将生命周期事件分发到功能组件内部,这势必会造成宿主代码复杂度增加。例如:

MyActivity.kt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
jsx复制代码// Activity 宿主
class MyActivity : AppCompatActivity() {

private val myWorker = MyWorker()

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// 分发生命周期事件
myWorker.init()
}

override fun onStart(){
super.onStart()
// 分发生命周期事件
myWorker.onStart()
}

override fun onStop() {
super.onStop()
// 分发生命周期事件
myWorker.onStop()
}
}

而使用 Lifecycle 组件后,能够将分发宿主生命周期事件的方法迁移到功能组件内部,宿主不再需要直接参与调整功能组件的生命周期。例如:

MyActivity.kt

1
2
3
4
5
6
7
8
9
10
11
jsx复制代码// Activity 宿主
class MyActivity : AppCompatActivity() {

private val myWorker = MyWorker()

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// 注册观察者
lifecycle.addObserver(myWorker)
}
}

MyWorker.kt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
jsx复制代码class MyWorker : LifecycleEventObserver {

override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
// 分发生命周期事件
when (event) {
Lifecycle.Event.ON_CREATE -> init()
Lifecycle.Event.ON_START -> onStart()
Lifecycle.Event.ON_STOP -> onStop()
}
}

private fun init() {
...
}

private fun onStart() {
...
}

private fun onStop() {
...
}
}

1.2 Lifecycle 的设计思路

Lifecycle 整体上采用了观察者模式,核心的 API 是 LifecycleObserver 和 LifecycleOwner:

  • LifecycleObserver: 观察者 API;
  • LifecycleOwner: 被观察者 API,生命周期宿主需要实现该接口,并将生命周期状态分发 Lifecycle,从而间接分发给被观察者;
  • Lifecycle: 定义了生命周期的标准行为模式,属于 Lifecycle 框架的核心类,另外框架还提供了一个默认实现 LifecycleRegistry。

LifecycleObserver.java

1
2
jsx复制代码public interface LifecycleObserver {
}

LifecycleOwner.java

1
2
3
4
jsx复制代码public interface LifecycleOwner {
@NonNull
Lifecycle getLifecycle();
}

1.3 Lifecycle 的使用方法

  • 添加依赖: 在 build.gradle 中添加 Lifecycle 依赖,需要注意区分过时的方式:

模块 build.gradle

1
2
3
4
5
6
7
8
9
10
11
12
13
jsx复制代码// 过时方式(lifecycle-extensions 不再维护)
implementation "androidx.lifecycle:lifecycle-extensions:2.4.0"

// 目前的方式:
def lifecycle_version = "2.5.0"

// Lifecycle 核心类
implementation "androidx.lifecycle:lifecycle-runtime:$lifecycle_version"
// Lifecycle 注解处理器(用于处理 @OnLifecycleEvent 注解)
kapt "androidx.lifecycle:lifecycle-compiler:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycle_version"
// 应用进程级别 Lifecycle
implementation "androidx.lifecycle:lifecycle-process:$lifecycle_version"
  • 注册观察者: Lifecycle 通过 addObserver(LifecycleObserver) 接口注册观察者,支持通过注解或非注解的方式注册观察者,共分为 3 种:
+ ~~**1、LifecycleObserver(注解方式 ,不推荐):**~~ 在这个场景使用注解处理有种杀鸡用牛刀的嫌疑,并没有比其他两种方式有优势。注解方式存在注解处理过程,并且如果在依赖时遗漏注解处理器的话,还会退化为使用反射回调,因此不推荐使用。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
jsx复制代码lifecycle.addObserver(object : LifecycleObserver {

@OnLifecycleEvent(Lifecycle.Event.ON_CREATE)
fun create() = {}

@OnLifecycleEvent(Lifecycle.Event.ON_START)
fun start() = {}

@OnLifecycleEvent(Lifecycle.Event.ON_RESUME)
fun resume() = {}

@OnLifecycleEvent(Lifecycle.Event.ON_PAUSE)
fun pause() = {}

@OnLifecycleEvent(Lifecycle.Event.ON_STOP)
fun stop() = {}

@OnLifecycleEvent(Lifecycle.Event.ON_DESTROY)
fun destroy() = {}
})
+ **2、LifecycleEventObserver(非注解方式,推荐)**
1
2
3
4
5
6
7
8
9
10
11
12
13
jsx复制代码lifecycle.addObserver(object : LifecycleEventObserver {
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
when (event) {
ON_CREATE -> {}
ON_START -> {}
ON_RESUME -> {}
ON_PAUSE -> {}
ON_STOP -> {}
ON_DESTROY -> {}
ON_ANY -> {}
}
}
})
+ **3、DefaultLifecycleObserver(非注解方式,推荐)**
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
kotlin复制代码// DefaultLifecycleObserver 是 FullLifecycleObserver 接口的空实现
lifecycle.addObserver(object : DefaultLifecycleObserver {

override fun onCreate(owner: LifecycleOwner) {}

override fun onStart(owner: LifecycleOwner) {}

override fun onResume(owner: LifecycleOwner) {}

override fun onPause(owner: LifecycleOwner) {}

override fun onStop(owner: LifecycleOwner) {}

override fun onDestroy(owner: LifecycleOwner) {}
})

注意: Lifecycle 内部会禁止一个观察者注册到多个宿主上。这很好理解,要是绑定了多个宿主的话,Lifecycle 就不知道以哪个宿主的生命周期为准了。

1.4 预定义的宿主

目前,Android 预定义的 Lifecycle 宿主有 3 个:Activity、Fragment 和应用进程级别的宿主 ProcessLifecycleOwner:

  • 1、Activity(具体实现在 androidx.activity.ComponentActivity)
  • 2、Fragment
  • 3、ProcessLifecycleOwner

前两个宿主大家都很熟悉了,第 3 个宿主 ProcessLifecycleOwner 则提供整个应用进程级别 Activity 的生命周期,能够支持非毫秒级别精度监听应用前后台切换的场景。

  • Lifecycle.Event.ON_CREATE: 在应用进程启动时分发,只会分发一次;
  • Lifecycle.Event.ON_START:在应用进程进入前台(STARTED)时分发,可能分发多次;
  • Lifecycle.Event.ON_RESUME:在应用进程进入前台(RESUMED)时分发,可能分发多次;
  • Lifecycle.Event.ON_PAUSE:在应用退出前台(PAUSED)时分发,可能分发多次;
  • Lifecycle.Event.ON_STOP:在应用退出前台(STOPPED)时分发,可能分发多次;
  • Lifecycle.EVENT.ON_DESTROY:注意,不会被分发。

使用示例

1
2
3
4
5
jsx复制代码ProcessLifecycleOwner.get().lifecycle.addObserver(object: LifecycleEventObserver{
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
...
}
})

1.5 自定义宿主

观察者必须绑定到宿主 LifecycleOwner 上,你可以使用系统预定义的宿主,或根据需要自定义宿主。主要步骤是实现 LifecycleOwner 并在内部将生命周期事件分发给调度器 LifecycleRegistry。模板如下:

LifecycleOwner.java

1
2
3
jsx复制代码public interface LifecycleOwner {
Lifecycle getLifecycle();
}

MyLifecycleOwner.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
jsx复制代码/**
* 自定义宿主模板
*/
class MyLifecycleOwner : LifecycleOwner {

private val mLifecycleRegistry = LifecycleRegistry(this)

override fun getLifecycle() = mLifecycleRegistry

fun create() {
// 并将生命周期状态分发给被观察者
mLifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE)
}

fun start() {
// 并将生命周期状态分发给被观察者
mLifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START)
}

fun stop() {
// 并将生命周期状态分发给被观察者
mLifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_STOP)
}
...
}

  1. Lifecycle 实现原理分析

2.1 注册观察者的执行过程

Lifecycle#addObserver() 最终会分发到调度器 LifecycleRegistry 中,其中会将观察者和观察者持有的状态包装为一个节点,并且在注册时将观察者状态同步推进到与宿主相同的状态中。

LifecycleRegistry.java

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
jsx复制代码private FastSafeIterableMap<LifecycleObserver, ObserverWithState> mObserverMap = new FastSafeIterableMap<>();

private State mState;

@Override
public void addObserver(LifecycleObserver observer) {
// 观察者的初始状态:要么是 DESTROYED,要么是 INITIALIZED,确保观察者可以介绍到完整的事件流
State initialState = mState == DESTROYED ? DESTROYED : INITIALIZED;
ObserverWithState statefulObserver = new ObserverWithState(observer, initialState);
ObserverWithState previous = mObserverMap.putIfAbsent(observer, statefulObserver);

...

// 将观察者推进到宿主最新的状态
State targetState = calculateTargetState(observer);
while ((statefulObserver.mState.compareTo(targetState) < 0 && mObserverMap.contains(observer))) {
pushParentState(statefulObserver.mState);
statefulObserver.dispatchEvent(lifecycleOwner, upEvent(statefulObserver.mState));
popParentState();
// mState / subling may have been changed recalculate
targetState = calculateTargetState(observer);
}
...
}

@Override
public void removeObserver(@NonNull LifecycleObserver observer) {
mObserverMap.remove(observer);
}

// ObserverWithState:观察者及其观察状态
static class ObserverWithState {
State mState;
LifecycleEventObserver mLifecycleObserver;

ObserverWithState(LifecycleObserver observer, State initialState) {
// 用适配器包装观察者,实现对不同形式观察者的统一分发
mLifecycleObserver = Lifecycling.lifecycleEventObserver(observer);
mState = initialState;
}
}

2.2 Lifecycle 如何适配不同类型的观察者

为了适配上面提到的不同类型的观察者,LifecycleRegistry 还为它们提供了一个适配层:非注解的方式会包装为一个 LifecycleEventObserver 的适配器对象,对于注解的方式,如果项目中引入了 annotationProcessor "androidx.lifecycle:lifecycle-compiler:$lifecycle_version" ,会在编译时生成工具类 MyObserver_LifecycleAdapter ,否则会使用反射回调注解方法。

LifecycleRegistry.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
jsx复制代码// ObserverWithState:观察者及其观察状态
static class ObserverWithState {
State mState;
// 适配器
LifecycleEventObserver mLifecycleObserver;

ObserverWithState(LifecycleObserver observer, State initialState) {
// 用适配器包装观察者,实现对不同形式观察者的统一分发
mLifecycleObserver = Lifecycling.lifecycleEventObserver(observer);
mState = initialState;
}

void dispatchEvent(LifecycleOwner owner, Event event) {
// 通过事件获得下一个状态
State newState = getStateAfter(event);
mState = min(mState, newState);
// 回调 onStateChanged() 方法
mLifecycleObserver.onStateChanged(owner, event);
mState = newState;
}
}

Lifecycling.java

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
jsx复制代码@NonNull
static LifecycleEventObserver lifecycleEventObserver(Object object) {
boolean isLifecycleEventObserver = object instanceof LifecycleEventObserver;
boolean isFullLifecycleObserver = object instanceof FullLifecycleObserver;
// 1. 观察者同时实现 LifecycleEventObserver 和 FullLifecycleObserver
if (isLifecycleEventObserver && isFullLifecycleObserver) {
return new FullLifecycleObserverAdapter((FullLifecycleObserver) object, (LifecycleEventObserver) object);
}
// 2. 观察者只实现 FullLifecycleObserver
if (isFullLifecycleObserver) {
return new FullLifecycleObserverAdapter((FullLifecycleObserver) object, null);
}
// 3. 观察者只实现 LifecycleEventObserver
if (isLifecycleEventObserver) {
return (LifecycleEventObserver) object;
}

// 4. 观察者使用注解方式:
final Class<?> klass = object.getClass();
int type = getObserverConstructorType(klass);
if (type == GENERATED_CALLBACK) {
// APT 自动生成的 MyObserver_LifecycleAdapter
List<Constructor<? extends GeneratedAdapter>> constructors = sClassToAdapters.get(klass);
if (constructors.size() == 1) {
GeneratedAdapter generatedAdapter = createGeneratedAdapter( constructors.get(0), object);
return new SingleGeneratedAdapterObserver(generatedAdapter);
}
GeneratedAdapter[] adapters = new GeneratedAdapter[constructors.size()];
for (int i = 0; i < constructors.size(); i++) {
adapters[i] = createGeneratedAdapter(constructors.get(i), object);
}
return new CompositeGeneratedAdaptersObserver(adapters);
}
// 反射调用
return new ReflectiveGenericLifecycleObserver(object);
}

FullLifecycleObserverAdapter.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
jsx复制代码class FullLifecycleObserverAdapter implements LifecycleEventObserver {
private final FullLifecycleObserver mFullLifecycleObserver;
private final LifecycleEventObserver mLifecycleEventObserver;

FullLifecycleObserverAdapter(FullLifecycleObserver fullLifecycleObserver,LifecycleEventObserver lifecycleEventObserver) {
mFullLifecycleObserver = fullLifecycleObserver;
mLifecycleEventObserver = lifecycleEventObserver;
}

@Override
public void onStateChanged(@NonNull LifecycleOwner source, @NonNull Lifecycle.Event event) {
// 分发到 mFullLifecycleObserver 和 mLifecycleEventObserver
}
}

2.3 Lifecycle 如何感知 Activity 生命周期

宿主的生命周期事件需要分发到调度器 LifecycleRegistry 中,在高版本有直接观察 Activity 生命周期的 API,而在低版本使用无界面的 Fragment 间接观察 Activity 的生命周期。

androidx.activity.ComponentActivity.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
jsx复制代码public class ComponentActivity extends androidx.core.app.ComponentActivity implements LifecycleOwner ...{
private final LifecycleRegistry mLifecycleRegistry = new LifecycleRegistry(this);

@NonNull
@Override
public Lifecycle getLifecycle() {
return mLifecycleRegistry;
}

@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
...
ReportFragment.injectIfNeededIn(this);
...
}
}

ReportFragment.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
jsx复制代码// 空白 Fragment
public class ReportFragment extends Fragment {
public static void injectIfNeededIn(Activity activity) {
if (Build.VERSION.SDK_INT >= 29) {
// 在高版本有直接观察 Activity 生命周期的 API
activity.registerActivityLifecycleCallbacks(new LifecycleCallbacks());
}
// 在低版本使用无界面的 Fragment 间接观察 Activity 的生命周期
android.app.FragmentManager manager = activity.getFragmentManager();
if (manager.findFragmentByTag(REPORT_FRAGMENT_TAG) == null) {
manager.beginTransaction().add(new ReportFragment(), REPORT_FRAGMENT_TAG).commit();
// Hopefully, we are the first to make a transaction.
manager.executePendingTransactions();
}
}

// 从 registerActivityLifecycleCallbacks() 或 Fragment 回调回来
static void dispatch(Activity activity, Lifecycle.Event event) {
...
// 分发声明周期事件
activity.getLifecycle().handleLifecycleEvent(event);
}
}

2.4 Lifecycle 分发生命周期事件的过程

当宿主的生命周期发生变化时,会分发到 LifecycleRegistry#handleLifecycleEvent(Lifecycle.Event),将观察者的状态回调到最新的状态上。

LifecycleRegistry.java

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
jsx复制代码private FastSafeIterableMap<LifecycleObserver, ObserverWithState> mObserverMap =new FastSafeIterableMap<>();

private final WeakReference<LifecycleOwner> mLifecycleOwner;

public LifecycleRegistry(@NonNull LifecycleOwner provider) {
mLifecycleOwner = new WeakReference<>(provider);
mState = INITIALIZED;
}

// 分发生命周期事件
public void handleLifecycleEvent(Lifecycle.Event event) {
// 通过事件获得下一个状态
State next = getStateAfter(event);
// 执行状态转移
moveToState(next);
}

private void moveToState(State next) {
if (mState == next) {
return;
}
mState = next;
if (mHandlingEvent || mAddingObserverCounter != 0) {
mNewEventOccurred = true;
// we will figure out what to do on upper level.
return;
}
mHandlingEvent = true;
sync();
mHandlingEvent = false;
}

private void sync() {
// isSynced() 判断所有观察者状态是否同步到最新状态
while (!isSynced()) {
mNewEventOccurred = false;
if (mState.compareTo(mObserverMap.eldest().getValue().mState) < 0) {
// 生命周期回退,最终调用 ObserverWithState#dispatchEvent() 分发事件
backwardPass(lifecycleOwner);
}
Entry<LifecycleObserver, ObserverWithState> newest = mObserverMap.newest();
if (!mNewEventOccurred && newest != null && mState.compareTo(newest.getValue().mState) > 0) {
// 生命周期前进,最终调用 ObserverWithState#dispatchEvent() 分发事件
forwardPass(lifecycleOwner);
}
}
mNewEventOccurred = false;
}

  1. Lifecycle 实践案例

3.1 使用 Lifecycle 解决 Dialog 内存泄漏

在 Activity 结束时,如果 Activity 上还存在未关闭的 Dialog,则会导致内存泄漏:

1
jsx复制代码WindowLeaked: Activtiy MainActivity has leaked window DecorView@dfxxxx[MainActivity] thas was originally added here

解决方法:

  • 方法 1:在 Activity#onDestroy() 中手动调用 Dialog#dismiss();
  • 方法 2:替换为 DialogFragment,内部会在 Fragment#onDestroyView() 时关闭 Dialog;
  • 方法 3:自定义 BaseDialog,使用 Lifecycle 监听宿主 DESTROYED 生命周期关闭 Dialog:

BaseDialog.kt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
jsx复制代码class BaseDialog(context: Context) : Dialog(context), LifecycleEventObserver {
init {
if (context is ComponentActivity) {
context.lifecycle.addObserver(this)
}
}

override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
if (Lifecycle.Event.ON_DESTROY == event) {
if (isShowing) {
dismiss()
}
}
}
}

3.2 生命周期感知型协程

Lifecycle 也加强了对 Kotlin 协程的支持 LifecycleCoroutineScope,我们可以构造出与生命周期相关联的协程作用域,主要支持 2 个特性:

  • 1、在宿主消亡(DESTROYED)时,自动取消协程;
  • 2、在宿主离开指定生命周期状态时挂起,在宿主重新进入指定生命周期状态时恢复协程(例如 launchWhenResumed)。

使用示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
jsx复制代码// 示例 1
lifecycleScope.launch {

}
// 示例 2(内部等价于示例 3)
lifecycleScope.launchWhenResumed {

}
// 示例 3
lifecycleScope.launch {
whenResumed {

}
}

1、自动取消协程实现原理分析: 核心在于 LifecycleCoroutineScopeImpl 中,内部在初始化时会注册一个观察者到宿主生命周期上,并在宿主进入 DESTROYED 时取消(cancel)协程。

LifecycleOwner.kt

1
2
3
jsx复制代码// 基于 LifecycleOwner 的扩展函数
public val LifecycleOwner.lifecycleScope: LifecycleCoroutineScope
get() = lifecycle.coroutineScope

Lifecycle.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
jsx复制代码public val Lifecycle.coroutineScope: LifecycleCoroutineScope
get() {
// 已简化
val newScope = LifecycleCoroutineScopeImpl(
this,
SupervisorJob() + Dispatchers.Main.immediate
)
newScope.register()
return newScope
}

public abstract class LifecycleCoroutineScope internal constructor() : CoroutineScope {
internal abstract val lifecycle: Lifecycle

...

// 开启协程再调用 whenResumed
public fun launchWhenResumed(block: suspend CoroutineScope.() -> Unit): Job = launch {
lifecycle.whenResumed(block)
}
}

// 实现类
internal class LifecycleCoroutineScopeImpl(
override val lifecycle: Lifecycle,
override val coroutineContext: CoroutineContext
) : LifecycleCoroutineScope(), LifecycleEventObserver {
init {
// 立即取消协程
if (lifecycle.currentState == Lifecycle.State.DESTROYED) {
coroutineContext.cancel()
}
}

fun register() {
// 绑定宿主生命周期
launch(Dispatchers.Main.immediate) {
if (lifecycle.currentState >= Lifecycle.State.INITIALIZED) {
lifecycle.addObserver(this@LifecycleCoroutineScopeImpl)
} else {
coroutineContext.cancel()
}
}
}

override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
// 分发宿主生命周期事件
if (lifecycle.currentState <= Lifecycle.State.DESTROYED) {
// 取消协程
lifecycle.removeObserver(this)
coroutineContext.cancel()
}
}
}

2、关联指定生命周期实现原理分析: 实现原理也是类似的,launchWhenResumed() 内部在 LifecycleContro 中注册观察者,最终通过协程调度器 PausingDispatcher 挂起(pause)或恢复(resume)协程。

PausingDispatcher.kt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
jsx复制代码public suspend fun <T> LifecycleOwner.whenResumed(block: suspend CoroutineScope.() -> T): T =
lifecycle.whenResumed(block)

public suspend fun <T> Lifecycle.whenResumed(block: suspend CoroutineScope.() -> T): T {
return whenStateAtLeast(Lifecycle.State.RESUMED, block)
}

public suspend fun <T> Lifecycle.whenStateAtLeast(
minState: Lifecycle.State,
block: suspend CoroutineScope.() -> T
): T = withContext(Dispatchers.Main.immediate) {
val job = coroutineContext[Job] ?: error("when[State] methods should have a parent job")
// 分发器,内部持有一个分发队列,用于支持暂停协程
val dispatcher = PausingDispatcher()
val controller = LifecycleController(this@whenStateAtLeast, minState, dispatcher.dispatchQueue, job)
try {
withContext(dispatcher, block)
} finally {
controller.finish()
}
}

LifecycleController.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
jsx复制代码@MainThread
internal class LifecycleController(
private val lifecycle: Lifecycle,
private val minState: Lifecycle.State,
private val dispatchQueue: DispatchQueue,
parentJob: Job
) {
private val observer = LifecycleEventObserver { source, _ ->
// 分发宿主生命周期事件
if (source.lifecycle.currentState == Lifecycle.State.DESTROYED) {
// 取消协程
parentJob.cancel()
lifecycle.removeObserver(observer)
dispatchQueue.finish()
} else if (source.lifecycle.currentState < minState) {
// 暂停协程
dispatchQueue.pause()
} else {
// 恢复协程
dispatchQueue.resume()
}
}

init {
// 直接取消协程
if (lifecycle.currentState == Lifecycle.State.DESTROYED) {
// 取消协程
parentJob.cancel()
lifecycle.removeObserver(observer)
dispatchQueue.finish()
} else {
lifecycle.addObserver(observer)
}
}
}

3.3 安全地观察 Flow 数据流

我们知道,Kotlin Flow 不具备生命周期感知的能力(当然了,Flow 是 Kotlin 生态的组件,不是仅针对 Android 生态的组件),那么 Flow 观察者如何保证在安全的生命周期订阅数据呢?

  • 方法 1:使用生命周期感知型协程(不推荐)
  • 方法 2:使用 Flow#flowWithLifecycle() API(推荐)

具体分析在 4、Flow:LiveData 的替代方案 这篇文章里都讲过,这里不重复。


  1. 总结

到这里,Jetpack 中最基础的 Lifecycle 组件就讲完了,下几篇文章我们将讨论基于 Lifecycle 实现的其他 Jetpack 组件,你知道是什么吗?关注我,带你了解更多。

参考资料

  • 使用生命周期感知型组件处理生命周期 —— 官方文档
  • Lifecycle,看完这次就真的懂了 —— g小志 著
  • 使用 ProcessLifecycle 优雅地监听应用前后台切换 —— Flywith24 著

推荐阅读

Android Jetpack 系列文章目录如下(2023/07/08 更新):

  • #1 Lifecycle:生命周期感知型组件的基础
  • #2 为什么 LiveData 会重放数据,怎么解决?
  • #3 为什么 Activity 都重建了 ViewModel 还存在?
  • #4 有小伙伴说看不懂 LiveData、Flow、Channel,跟我走
  • #5 Android UI 架构演进:从 MVC 到 MVP、MVVM、MVI
  • #6 ViewBinding 与 Kotlin 委托双剑合璧
  • #7 AndroidX Fragment 核心原理分析
  • #8 OnBackPressedDispatcher:Jetpack 处理回退事件的新姿势
  • #9 食之无味!App Startup 可能比你想象中要简单
  • #10 从 Dagger2 到 Hilt 玩转依赖注入(一)

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

我正在参与掘金技术社区创作者签约计划招募活动,点击链接报名投稿。

本文转载自: 掘金

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

17张图带你深度剖析 ArrayDeque(JDK双端队列)

发表于 2022-07-15

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

ArrayDeque(JDK双端队列)源码深度剖析

前言

在本篇文章当中主要跟大家介绍JDK给我们提供的一种用数组实现的双端队列,在之前的文章LinkedList源码剖析当中我们已经介绍了一种双端队列,不过与ArrayDeque不同的是,LinkedList的双端队列使用双向链表实现的。

双端队列整体分析

我们通常所谈论到的队列都是一端进一端出,而双端队列的两端则都是可进可出。下面是双端队列的几个操作:

  • 数据从双端队列左侧进入。
  • 数据从双端队列右侧进入。

  • 数据从双端队列左侧弹出。

  • 数据从双端队列右侧弹出。

而在ArrayDeque当中也给我们提供了对应的方法去实现,比如下面这个例子就是上图对应的代码操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
java复制代码public void test() {
ArrayDeque<Integer> deque = new ArrayDeque<>();
deque.addLast(100);
System.out.println(deque);
deque.addFirst(55);
System.out.println(deque);
deque.addLast(-55);
System.out.println(deque);
deque.removeFirst();
System.out.println(deque);
deque.removeLast();
System.out.println(deque);
}
// 输出结果
[100]
[55, 100]
[55, 100, -55]
[100, -55]
[100]

数组实现ArrayDeque(双端队列)的原理

ArrayDeque底层是使用数组实现的,而且数组的长度必须是2的整数次幂,这么操作的原因是为了后面位运算好操作。在ArrayDeque当中有两个整形变量head和tail,分别指向右侧的第一个进入队列的数据和左侧第一个进行队列的数据,整个内存布局如下图所示:

其中tail指的位置没有数据,head指的位置存在数据。

  • 当我们需要从左往右增加数据时(入队),内存当中数据变化情况如下:

  • 当我们需要从右往做左增加数据时(入队),内存当中数据变化情况如下:

  • 当我们需要从右往左删除数据时(出队),内存当中数据变化情况如下:

  • 当我们需要从左往右删除数据时(出队),内存当中数据变化情况如下:

底层数据遍历顺序和逻辑顺序

上面主要谈论到的数组在内存当中的布局,但是他是具体的物理存储数据的顺序,这个顺序和我们的逻辑上的顺序是不一样的,根据上面的插入顺序,我们可以画出下面的图,大家可以仔细分析一下这个图的顺序问题。

上图当中队列左侧的如队顺序是0, 1, 2, 3,右侧入队的顺序为15, 14, 13, 12, 11, 10, 9, 8,因此在逻辑上我们的队列当中的数据布局如下图所示:

根据前面一小节谈到的输入在入队的时候数组当中数据的变化我们可以知道,数据在数组当中的布局为:

ArrayDeque类关键字段分析

1
2
3
4
5
6
7
8
java复制代码// 底层用于存储具体数据的数组
transient Object[] elements;
// 这就是前面谈到的 head
transient int head;
// 与上文谈到的 tail 含义一样
transient int tail;
// MIN_INITIAL_CAPACITY 表示数组 elements 的最短长度
private static final int MIN_INITIAL_CAPACITY = 8;

以上就是ArrayDeque当中的最主要的字段,其含义还是比较容易理解的!

ArrayDeque构造函数分析

  • 默认构造函数,数组默认申请的长度为16。
1
2
3
java复制代码public ArrayDeque() {
elements = new Object[16];
}
  • 指定数组长度的初始化长度,下面列出了改构造函数涉及的所有函数。
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
java复制代码public ArrayDeque(int numElements) {
allocateElements(numElements);
}

private void allocateElements(int numElements) {
elements = new Object[calculateSize(numElements)];
}
private static int calculateSize(int numElements) {
int initialCapacity = MIN_INITIAL_CAPACITY;
// Find the best power of two to hold elements.
// Tests "<=" because arrays aren't kept full.
if (numElements >= initialCapacity) {
initialCapacity = numElements;
initialCapacity |= (initialCapacity >>> 1);
initialCapacity |= (initialCapacity >>> 2);
initialCapacity |= (initialCapacity >>> 4);
initialCapacity |= (initialCapacity >>> 8);
initialCapacity |= (initialCapacity >>> 16);
initialCapacity++;

if (initialCapacity < 0) // Too many elements, must back off
initialCapacity >>>= 1;// Good luck allocating 2 ^ 30 elements
}
return initialCapacity;
}

上面的最难理解的就是函数calculateSize了,他的主要作用是如果用户输入的长度小于MIN_INITIAL_CAPACITY时,返回MIN_INITIAL_CAPACITY。否则返回比initialCapacity大的第一个是2的整数幂的整数,比如说如果输入的是9返回的16,输入4返回8。

calculateSize的代码还是很难理解的,让我们一点一点的来分析。首先我们使用一个2的整数次幂的数进行上面移位操作的操作!


从上图当中我们会发现,我们在一个数的二进制数的32位放一个1,经过移位之后最终32位的比特数字全部变成了1。根据上面数字变化的规律我们可以发现,任何一个比特经过上面移位的变化,这个比特后面的31个比特位都会变成1,像下图那样:

因此上述的移位操作的结果只取决于最高一位的比特值为1,移位操作后它后面的所有比特位的值全为1,而在上面函数的最后,我们返回的结果就是上面移位之后的结果 +1。又因为移位之后最高位的1到最低位的1之间的比特值全为1,当我们+1之后他会不断的进位,最终只有一个比特位置是1,因此它是2的整数倍。

经过上述过程分析,我们就可以立即函数calculateSize了。

ArrayDeque关键函数分析

addLast函数分析

1
2
3
4
5
6
7
8
9
10
11
12
java复制代码// tail 的初始值为 0 
public void addLast(E e) {
if (e == null)
throw new NullPointerException();
elements[tail] = e;
// 这里进行的 & 位运算 相当于取余数操作
// (tail + 1) & (elements.length - 1) == (tail + 1) % elements.length
// 这个操作主要是用于判断数组是否满了,如果满了则需要扩容
// 同时这个操作将 tail + 1,即 tail = tail + 1
if ( (tail = (tail + 1) & (elements.length - 1)) == head)
doubleCapacity();
}

代码(tail + 1) & (elements.length - 1) == (tail + 1) % elements.length成立的原因是任意一个数aaa对2n2^n2n进行取余数操作和aaa跟2n−12^n - 12n−1进行&运算的结果相等,即:

a%2n=a&(2n−1)a% 2^n = a & (2^n - 1)a%2n=a&(2n−1)
从上面的代码来看下标为tail的位置是没有数据的,是一个空位置。

addFirst函数分析

1
2
3
4
5
6
7
8
9
10
11
12
13
java复制代码// head 的初始值为 0 
public void addFirst(E e) {
if (e == null)
throw new NullPointerException();
// 若此时数组长度elements.length = 16
// 那么下面代码执行过后 head = 15
// 下面代码的操作结果和下面两行代码含义一致
// elements[(head - 1 + elements.length) % elements.length] = e
// head = (head - 1 + elements.length) % elements.length
elements[head = (head - 1) & (elements.length - 1)] = e;
if (head == tail)
doubleCapacity();
}

上面代码操作结果和上文当中我们提到的,在队列当中从右向左加入数据一样。从上面的代码看,我们可以发现下标为head的位置是存在数据的。

doubleCapacity函数分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
java复制代码private void doubleCapacity() {
assert head == tail;
int p = head;
int n = elements.length;
int r = n - p; // number of elements to the right of p
int newCapacity = n << 1;
if (newCapacity < 0)
throw new IllegalStateException("Sorry, deque too big");
Object[] a = new Object[newCapacity];
// arraycopy(Object src, int srcPos,
Object dest, int destPos,
int length)
// 上面是函数 System.arraycopy 的函数参数列表
// 大家可以参考上面理解下面的拷贝代码
System.arraycopy(elements, p, a, 0, r);
System.arraycopy(elements, 0, a, r, p);
elements = a;
head = 0;
tail = n;
}

上面的代码还是比较简单的,这里给大家一个图示,大家就更加容易理解了:

22.png
扩容之后将原来数组的数据拷贝到了新数组当中,虽然数据在旧数组和新数组当中的顺序发生变化了,但是他们的相对顺序却没有发生变化,他们的逻辑顺序也是一样的,这里的逻辑可能有点绕,大家在这里可以好好思考一下。

pollLast和pollFirst函数分析

这两个函数的代码就比较简单了,大家可以根据前文所谈到的内容和图示去理解下面的代码。

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
java复制代码public E pollLast() {
// 计算出待删除的数据的下标
int t = (tail - 1) & (elements.length - 1);
@SuppressWarnings("unchecked")
E result = (E) elements[t];
if (result == null)
return null;
// 将需要删除的数据的下标值设置为 null 这样这块内存就
// 可以被回收了
elements[t] = null;
tail = t;
return result;
}

public E pollFirst() {
int h = head;
@SuppressWarnings("unchecked")
E result = (E) elements[h];
// Element is null if deque empty
if (result == null)
return null;
elements[h] = null; // Must null out slot
head = (h + 1) & (elements.length - 1);
return result;
}

总结

在本篇文章当中,主要跟大家分享了ArrayDeque的设计原理,和他的底层实现过程。ArrayDeque底层数组当中的数据顺序和队列的逻辑顺序这部分可能比较抽象,大家可以根据图示好好体会一下!!!

以上就是本篇文章的所有内容了,希望大家有所收获,我是LeHung,我们下期再见!!!都看到这里了,给孩子一个赞(star)吧,)))免费的哦))!!!


更多精彩内容合集可访问项目:github.com/Chang-LeHun…

关注公众号:一无是处的研究僧,了解更多计算机(Java、Python、计算机系统基础、算法与数据结构)知识。

本文转载自: 掘金

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

若川说"可能是历史上最简单的一期omitjs"源码共读,但

发表于 2022-07-13

本文参加了由公众号@若川视野 发起的每周源码共读活动,点击了解详情一起参与。

【若川视野 x 源码共读】第36期 | 可能是历史上最简单的一期 omit.js 剔除对象中的属性点击了解本期详情一起参与。

前言

本文实践所有代码已上传,源码地址:github.com/aehyok/omit…

从不同的视角出发,来学习源码知识,通过本文可以了解到以下知识:

1
2
3
4
5
6
7
go复制代码1、准备源代码
2、了解package.json依赖
3、omit方法解析
4、将原来的单元测试修改为jest
5、让项目支持typescript
6、npm publish实践
7、总结

1、 准备源码

1
2
3
4
5
6
7
8
9
10
11
javascript复制代码// 浏览器打开源码地址
https://github.com/benjycui/omit.js

//点击`fork`,复制一份代码到自己的仓库
git clone git@github.com:aehyok/omit.js.git

//通过vscode打开项目后,安装依赖
npm i

// 安装完依赖后,执行测试用例
npm run test

image.png

可以发现一个测试集合,两个测试方法。

2、查看package.json中的依赖

2.1、 father依赖包

万万没想到原来是umijs家族中的,功能如此强大,细节我就不说了,直接看如下官网描述。

image.png

2.2、@umijs/fabric依赖包

也是umijs家族中的

image.png

看了它的官方文档,通过安装依赖后,里面包含了 eslint、prettier、stylelint的基础配置。

image.png

看了这里的配置,通过require.resolve将封装的配置加载进来,这个思路也非常的不错。

这里之前刚好学习过另外一个开源仓库 如何为前端项目一键自动添加eslint和prettier的支持,它这里是通过封装然后运行指令,直接将eslint和prettier的配置文件在项目中生成了。而@umijs/fabric这里是引用了封装的配置文件。

两种思路的碰撞,没有谁比谁更好,只是说场景的适用性,对我学习来说,又多了一个思路。紧接着我又去看了一下**@umijs/fabric**的源代码,看着有点迷,但还是看明白了大致的逻辑,如有时间,等有空捞一捞。

2.3、assert

原来这也是一个写单元测试的npm包,周下载量为10,421,399,github上的star274
我顺手查了一下jest的周下载量,16,359,268,但github上的star却达到了39,5k,大趋势来说还是要用jest,毕竟功能更强大。但是一些比较老的项目,确实都使用了assert,比如lodash,大部分前端应该都熟悉或者使用很广泛的一个npm库。

2.4、np

A better npm publish

通过官网的第一句便可以看出来,这个np对于发包,也就是发布到npmjs上,非常有用的一个工具。

2.5、 prepublishOnly

scripts中的一个指令,在npm publish命令前执行

1
2
javascript复制代码"compile": "father build",
"prepublishOnly": "npm run compile && np --yolo --no-publish",

3、查看omit方法

1
2
3
4
5
6
7
8
9
10
11
javascript复制代码function omit(obj, fields) {
// eslint-disable-next-line prefer-object-spread
const shallowCopy = Object.assign({}, obj);
for (let i = 0; i < fields.length; i += 1) {
const key = fields[i];
delete shallowCopy[key];
}
return shallowCopy;
}

export default omit;

其中Object.assign()方法用于对象的合并,将源对象(source)的所有可枚举属性,复制到目标对象(target)。

通过Object.assign浅拷贝一份对象,因为源对象用了一个{}空的对象。然后对循环传入的fileds数组,将存在于对象上的key进行移除,这样移除的时候不会对传入的obj对象产生影响。
但是这里单层的循环,对于嵌套的对象就不起作用了。

同时你也可以去查看lodash,也有同样的方法,链接地址 www.lodashjs.com/docs/lodash…

4、jest重写测试用例

  • 安装依赖
1
javascript复制代码npm i jest -D
  • 添加jest.config.js配置文件
1
javascript复制代码npx jest --init

然后根据选择提示进行如下选择

image.png

执行完成后,在根目录下生成了jest.config.js文件。
以及重写了package.json中的scripts

1
2
3
4
javascript复制代码"test": "jest",

// 之前是
father test

现在可以执行一下命令npm run test,发现报错了,如下图所示

image.png

这里主要是因为我没有配置babel进行转换,可以通过如下命令进行安装并配置

1
javascript复制代码npm i babel-jest @babel/core @babel/preset-env -D

安装完毕后,在根目录配置好babel.config.js

1
2
3
4
5
6
7
8
9
10
11
12
13
javascript复制代码 // babel.config.js
module.exports = {
presets: [
[
'@babel/preset-env',
{
targets: {
node: 'current', //针对当前node版本进行编译,删除该行可能导致`npm start`报错
},
},
],
],
};

配置完毕后,再次运行命令npm run test,执行成功

将assert改为jest的方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
javascript复制代码// import assert from 'assert';
import omit from '../src';

describe('omit', () => {
it('should create a shallow copy', () => {
const benjy = { name: 'Benjy' };
const copy = omit(benjy, []);
expect(copy).toEqual(benjy)
});

it('should drop fields which are passed in', () => {
const benjy = { name: 'Benjy', age: 18 };
const target1 = omit(benjy, ['age'])
const target2 = omit(benjy, ['age', 'name'])
expect(target1).toEqual({ name: "Benjy"})

expect(target2).toEqual({})
});
});

主要就是将原来的assert改为了jest。

image.png

2.3、新增一个方法
在src/index.js添加一个求和方法

1
2
3
javascript复制代码export const sum = (x, y) => {
return x + y;
}

然后在测试用例文件中添加单元测试

1
2
3
4
5
6
7
javascript复制代码import omit, { sum } from '../src';

describe('sum', () => {
it('两数字之和', () => {
expect(sum(3,4)).toEqual(7)
})
})

最后通过命令检测单元测试npm run test

image.png

通过修改jest.config.js,还可以查看到单元测试覆盖率,看一下效果

1
2
javascript复制代码  collectCoverage: true,
coverageDirectory: "coverage",

image.png

同时也会生成一个文件coverage

image.png

5、支持TypeScript改写

安装typescript依赖

1
javascript复制代码npm i -D typescript

安装tsc,并初始化typescript配置

1
javascript复制代码npx tsc --init

执行完毕后,在根目录下会生成tsconfig.json

再来安装ts-jest

1
javascript复制代码npm i -D ts-jest

在jest.config.js中添加一行配置

1
2
3
4
5
javascript复制代码
module.exports = {
preset: 'ts-jest',
// ...
};

现在可以将src,以及test文件夹下的index.js文件名修改为index.ts文件

src下的文件同时要修改文件中的方法,来支持ts类型的支持

1
2
3
4
5
6
7
8
9
10
11
12
13
14
javascript复制代码function omit(obj: Object, fields: string[]) {
// eslint-disable-next-line prefer-object-spread
const shallowCopy: any = Object.assign({}, obj);
for (let i = 0; i < fields.length; i += 1) {
const key = fields[i];
delete shallowCopy[key];
}
return shallowCopy;
}

export const sum = (x: number, y: number) => {
return x + y;
};
export default omit;

查看测试文件后有报错

image.png

此时需要安装ts的jest type 的支持

1
javascript复制代码npm i -D @types/jest

安装完毕后,执行npm run test

image.png

ts文件的测试用例执行成功,说明typescript配置生效了。

7、发布到npm

  • 修改package.json中的name,防止跟原作者冲突了
1
javascript复制代码  "name": "aehyok.omit.js",
  • npmjs注册账号
    记得还要设置邮箱,登录和发布时都需要
  • npm login 或者npm adduser
  • npm publish

image.png

  • 查看npmjs

image.png

8、总结

  • 了解Object.assign用法,以及浅克隆,还可以去了解实践一下浅克隆和深克隆
  • npm内置指令 prepublishOnly,发布之前执行的
  • omit方法学习
  • 修改源码支持jest单元测试
  • 修改源码支持typescript
  • 实践npm publish

本文实践所有代码已上传,源码地址:github.com/aehyok/omit…

我正在参与掘金技术社区创作者签约计划招募活动,点击链接报名投稿。

本文转载自: 掘金

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

吃透JAVA的Stream流操作,多年实践总结

发表于 2022-07-11

在JAVA中,涉及到对数组、Collection等集合类中的元素进行操作的时候,通常会通过循环的方式进行逐个处理,或者使用Stream的方式进行处理。

例如,现在有这么一个需求:

从给定句子中返回单词长度大于5的单词列表,按长度倒序输出,最多返回3个

在JAVA7及之前的代码中,我们会可以照如下的方式进行实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
java复制代码public List<String> sortGetTop3LongWords(@NotNull String sentence) {
// 先切割句子,获取具体的单词信息
String[] words = sentence.split(" ");
List<String> wordList = new ArrayList<>();
// 循环判断单词的长度,先过滤出符合长度要求的单词
for (String word : words) {
if (word.length() > 5) {
wordList.add(word);
}
}
// 对符合条件的列表按照长度进行排序
wordList.sort((o1, o2) -> o2.length() - o1.length());
// 判断list结果长度,如果大于3则截取前三个数据的子list返回
if (wordList.size() > 3) {
wordList = wordList.subList(0, 3);
}
return wordList;
}

在JAVA8及之后的版本中,借助Stream流,我们可以更加优雅的写出如下代码:

1
2
3
4
5
6
7
8
java复制代码
public List<String> sortGetTop3LongWordsByStream(@NotNull String sentence) {
return Arrays.stream(sentence.split(" "))
.filter(word -> word.length() > 5)
.sorted((o1, o2) -> o2.length() - o1.length())
.limit(3)
.collect(Collectors.toList());
}

直观感受上,Stream的实现方式代码更加简洁、一气呵成。很多的同学在代码中也经常使用Stream流,但是对Stream流的认知往往也是仅限于会一些简单的filter、map、collect等操作,但JAVA的Stream可以适用的场景与能力远不止这些。

那么问题来了:Stream相较于传统的foreach的方式处理,到底有啥优势?

这里我们可以先搁置这个问题,先整体全面的了解下Stream,然后再来讨论下这个问题。

笔者结合在团队中多年的代码检视遇到的情况,结合平时项目编码实践经验,对Stream的核心要点与易混淆用法、典型使用场景等进行了详细的梳理总结,希望可以帮助大家对Stream有个更全面的认知,也可以更加高效的应用到项目开发中去。

Stream初相识

概括讲,可以将Stream流操作分为3种类型:

  • 创建Stream
  • Stream中间处理
  • 终止Steam

每个Stream管道操作类型都包含若干API方法,先列举下各个API方法的功能介绍。

  • 开始管道

主要负责新建一个Stream流,或者基于现有的数组、List、Set、Map等集合类型对象创建出新的Stream流。

API 功能说明
stream() 创建出一个新的stream串行流对象
parallelStream() 创建出一个可并行执行的stream流对象
Stream.of() 通过给定的一系列元素创建一个新的Stream串行流对象

  • 中间管道

负责对Stream进行处理操作,并返回一个新的Stream对象,中间管道操作可以进行叠加。

API 功能说明
filter() 按照条件过滤符合要求的元素, 返回新的stream流
map() 将已有元素转换为另一个对象类型,一对一逻辑,返回新的stream流
flatMap() 将已有元素转换为另一个对象类型,一对多逻辑,即原来一个元素对象可能会转换为1个或者多个新类型的元素,返回新的stream流
limit() 仅保留集合前面指定个数的元素,返回新的stream流
skip() 跳过集合前面指定个数的元素,返回新的stream流
concat() 将两个流的数据合并起来为1个新的流,返回新的stream流
distinct() 对Stream中所有元素进行去重,返回新的stream流
sorted() 对stream中所有的元素按照指定规则进行排序,返回新的stream流
peek() 对stream流中的每个元素进行逐个遍历处理,返回处理后的stream流

  • 终止管道

顾名思义,通过终止管道操作之后,Stream流将会结束,最后可能会执行某些逻辑处理,或者是按照要求返回某些执行后的结果数据。

API 功能说明
count() 返回stream处理后最终的元素个数
max() 返回stream处理后的元素最大值
min() 返回stream处理后的元素最小值
findFirst() 找到第一个符合条件的元素时则终止流处理
findAny() 找到任何一个符合条件的元素时则退出流处理,这个对于串行流时与findFirst相同,对于并行流时比较高效,任何分片中找到都会终止后续计算逻辑
anyMatch() 返回一个boolean值,类似于isContains(),用于判断是否有符合条件的元素
allMatch() 返回一个boolean值,用于判断是否所有元素都符合条件
noneMatch() 返回一个boolean值, 用于判断是否所有元素都不符合条件
collect() 将流转换为指定的类型,通过Collectors进行指定
toArray() 将流转换为数组
iterator() 将流转换为Iterator对象
foreach() 无返回值,对元素进行逐个遍历,然后执行给定的处理逻辑

Stream方法使用

map与flatMap

map与flatMap都是用于转换已有的元素为其它元素,区别点在于:

  • map 必须是一对一的,即每个元素都只能转换为1个新的元素
  • flatMap 可以是一对多的,即每个元素都可以转换为1个或者多个新的元素

比如:有一个字符串ID列表,现在需要将其转为User对象列表。可以使用map来实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
java复制代码
/**
* 演示map的用途:一对一转换
*/
public void stringToIntMap() {
List<String> ids = Arrays.asList("205", "105", "308", "469", "627", "193", "111");
// 使用流操作
List<User> results = ids.stream()
.map(id -> {
User user = new User();
user.setId(id);
return user;
})
.collect(Collectors.toList());
System.out.println(results);
}

执行之后,会发现每一个元素都被转换为对应新的元素,但是前后总元素个数是一致的:

1
2
3
4
5
6
7
8
bash复制代码
[User{id='205'},
User{id='105'},
User{id='308'},
User{id='469'},
User{id='627'},
User{id='193'},
User{id='111'}]

再比如:现有一个句子列表,需要将句子中每个单词都提取出来得到一个所有单词列表。这种情况用map就搞不定了,需要flatMap上场了:

1
2
3
4
5
6
7
8
9
java复制代码
public void stringToIntFlatmap() {
List<String> sentences = Arrays.asList("hello world","Jia Gou Wu Dao");
// 使用流操作
List<String> results = sentences.stream()
.flatMap(sentence -> Arrays.stream(sentence.split(" ")))
.collect(Collectors.toList());
System.out.println(results);
}

执行结果如下,可以看到结果列表中元素个数是比原始列表元素个数要多的:

1
2
csharp复制代码
[hello, world, Jia, Gou, Wu, Dao]

这里需要补充一句,flatMap操作的时候其实是先每个元素处理并返回一个新的Stream,然后将多个Stream展开合并为了一个完整的新的Stream,如下:

peek和foreach方法

peek和foreach,都可以用于对元素进行遍历然后逐个的进行处理。

但根据前面的介绍,peek属于中间方法,而foreach属于终止方法。这也就意味着peek只能作为管道中途的一个处理步骤,而没法直接执行得到结果,其后面必须还要有其它终止操作的时候才会被执行;而foreach作为无返回值的终止方法,则可以直接执行相关操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
java复制代码
public void testPeekAndforeach() {
List<String> sentences = Arrays.asList("hello world","Jia Gou Wu Dao");
// 演示点1: 仅peek操作,最终不会执行
System.out.println("----before peek----");
sentences.stream().peek(sentence -> System.out.println(sentence));
System.out.println("----after peek----");
// 演示点2: 仅foreach操作,最终会执行
System.out.println("----before foreach----");
sentences.stream().forEach(sentence -> System.out.println(sentence));
System.out.println("----after foreach----");
// 演示点3: peek操作后面增加终止操作,peek会执行
System.out.println("----before peek and count----");
sentences.stream().peek(sentence -> System.out.println(sentence)).count();
System.out.println("----after peek and count----");
}

输出结果可以看出,peek独自调用时并没有被执行、但peek后面加上终止操作之后便可以被执行,而foreach可以直接被执行:

1
2
3
4
5
6
7
8
9
10
11
css复制代码
----before peek----
----after peek----
----before foreach----
hello world
Jia Gou Wu Dao
----after foreach----
----before peek and count----
hello world
Jia Gou Wu Dao
----after peek and count----

filter、sorted、distinct、limit

这几个都是常用的Stream的中间操作方法,具体的方法的含义在上面的表格里面有说明。具体使用的时候,可以根据需要选择一个或者多个进行组合使用,或者同时使用多个相同方法的组合:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
java复制代码
public void testGetTargetUsers() {
List<String> ids = Arrays.asList("205","10","308","49","627","193","111", "193");
// 使用流操作
List<Dept> results = ids.stream()
.filter(s -> s.length() > 2)
.distinct()
.map(Integer::valueOf)
.sorted(Comparator.comparingInt(o -> o))
.limit(3)
.map(id -> new Dept(id))
.collect(Collectors.toList());
System.out.println(results);
}

上面的代码片段的处理逻辑很清晰:

  1. 使用filter过滤掉不符合条件的数据
  2. 通过distinct对存量元素进行去重操作
  3. 通过map操作将字符串转成整数类型
  4. 借助sorted指定按照数字大小正序排列
  5. 使用limit截取排在前3位的元素
  6. 又一次使用map将id转为Dept对象类型
  7. 使用collect终止操作将最终处理后的数据收集到list中

输出结果:

1
bash复制代码[Dept{id=111},  Dept{id=193},  Dept{id=205}]

简单结果终止方法

按照前面介绍的,终止方法里面像count、max、min、findAny、findFirst、anyMatch、allMatch、nonneMatch等方法,均属于这里说的简单结果终止方法。所谓简单,指的是其结果形式是数字、布尔值或者Optional对象值等。

1
2
3
4
5
6
7
8
9
10
11
12
java复制代码
public void testSimpleStopOptions() {
List<String> ids = Arrays.asList("205", "10", "308", "49", "627", "193", "111", "193");
// 统计stream操作后剩余的元素个数
System.out.println(ids.stream().filter(s -> s.length() > 2).count());
// 判断是否有元素值等于205
System.out.println(ids.stream().filter(s -> s.length() > 2).anyMatch("205"::equals));
// findFirst操作
ids.stream().filter(s -> s.length() > 2)
.findFirst()
.ifPresent(s -> System.out.println("findFirst:" + s));
}

执行后结果为:

1
2
3
4
vbnet复制代码
6
true
findFirst:205

避坑提醒

这里需要补充提醒下,一旦一个Stream被执行了终止操作之后,后续便不可以再读这个流执行其他的操作了,否则会报错,看下面示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
java复制代码
public void testHandleStreamAfterClosed() {
List<String> ids = Arrays.asList("205", "10", "308", "49", "627", "193", "111", "193");
Stream<String> stream = ids.stream().filter(s -> s.length() > 2);
// 统计stream操作后剩余的元素个数
System.out.println(stream.count());
System.out.println("-----下面会报错-----");
// 判断是否有元素值等于205
try {
System.out.println(stream.anyMatch("205"::equals));
} catch (Exception e) {
e.printStackTrace();
}
System.out.println("-----上面会报错-----");
}

执行的时候,结果如下:

1
2
3
4
5
6
7
8
9
css复制代码
6
-----下面会报错-----
java.lang.IllegalStateException: stream has already been operated upon or closed
at java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:229)
at java.util.stream.ReferencePipeline.anyMatch(ReferencePipeline.java:449)
at com.veezean.skills.stream.StreamService.testHandleStreamAfterClosed(StreamService.java:153)
at com.veezean.skills.stream.StreamService.main(StreamService.java:176)
-----上面会报错-----

因为stream已经被执行count()终止方法了,所以对stream再执行anyMatch方法的时候,就会报错stream has already been operated upon or closed,这一点在使用的时候需要特别注意。

结果收集终止方法

因为Stream主要用于对集合数据的处理场景,所以除了上面几种获取简单结果的终止方法之外,更多的场景是获取一个集合类的结果对象,比如List、Set或者HashMap等。

这里就需要collect方法出场了,它可以支持生成如下类型的结果数据:

  • 一个集合类,比如List、Set或者HashMap等
  • StringBuilder对象,支持将多个字符串进行拼接处理并输出拼接后结果
  • 一个可以记录个数或者计算总和的对象(数据批量运算统计)

生成集合

应该算是collect最常被使用到的一个场景了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
java复制代码
public void testCollectStopOptions() {
List<Dept> ids = Arrays.asList(new Dept(17), new Dept(22), new Dept(23));
// collect成list
List<Dept> collectList = ids.stream().filter(dept -> dept.getId() > 20)
.collect(Collectors.toList());
System.out.println("collectList:" + collectList);
// collect成Set
Set<Dept> collectSet = ids.stream().filter(dept -> dept.getId() > 20)
.collect(Collectors.toSet());
System.out.println("collectSet:" + collectSet);
// collect成HashMap,key为id,value为Dept对象
Map<Integer, Dept> collectMap = ids.stream().filter(dept -> dept.getId() > 20)
.collect(Collectors.toMap(Dept::getId, dept -> dept));
System.out.println("collectMap:" + collectMap);
}

结果如下:

1
2
3
4
bash复制代码
collectList:[Dept{id=22}, Dept{id=23}]
collectSet:[Dept{id=23}, Dept{id=22}]
collectMap:{22=Dept{id=22}, 23=Dept{id=23}}

生成拼接字符串

将一个List或者数组中的值拼接到一个字符串里并以逗号分隔开,这个场景相信大家都不陌生吧?

如果通过for循环和StringBuilder去循环拼接,还得考虑下最后一个逗号如何处理的问题,很繁琐:

1
2
3
4
5
6
7
8
9
10
11
java复制代码
public void testForJoinStrings() {
List<String> ids = Arrays.asList("205", "10", "308", "49", "627", "193", "111", "193");
StringBuilder builder = new StringBuilder();
for (String id : ids) {
builder.append(id).append(',');
}
// 去掉末尾多拼接的逗号
builder.deleteCharAt(builder.length() - 1);
System.out.println("拼接后:" + builder.toString());
}

但是现在有了Stream,使用collect可以轻而易举的实现:

1
2
3
4
5
6
java复制代码
public void testCollectJoinStrings() {
List<String> ids = Arrays.asList("205", "10", "308", "49", "627", "193", "111", "193");
String joinResult = ids.stream().collect(Collectors.joining(","));
System.out.println("拼接后:" + joinResult);
}

两种方式都可以得到完全相同的结果,但Stream的方式更优雅:

1
复制代码拼接后:205,10,308,49,627,193,111,193

📢 敲黑板:

关于这里的说明,评论区中很多的小伙伴提出过疑问,就是这个场景其实使用 String.join() 就可以搞定了,并不需要上面使用 stream 的方式去实现。这里要声明下,Stream的魅力之处就在于其可以结合到其它的业务逻辑中进行处理,让代码逻辑更加的自然、一气呵成。如果纯粹是个String字符串拼接的诉求,确实没有必要使用Stream来实现,毕竟杀鸡焉用牛刀嘛~ 但是可以看看下面给出的这个示例,便可以感受出使用Stream进行字符串拼接的真正魅力所在。

数据批量数学运算

还有一种场景,实际使用的时候可能会比较少,就是使用collect生成数字数据的总和信息,也可以了解下实现方式:

1
2
3
4
5
6
7
8
9
10
java复制代码
public void testNumberCalculate() {
List<Integer> ids = Arrays.asList(10, 20, 30, 40, 50);
// 计算平均值
Double average = ids.stream().collect(Collectors.averagingInt(value -> value));
System.out.println("平均值:" + average);
// 数据统计信息
IntSummaryStatistics summary = ids.stream().collect(Collectors.summarizingInt(value -> value));
System.out.println("数据统计信息: " + summary);
}

上面的例子中,使用collect方法来对list中元素值进行数学运算,结果如下:

1
2
3
python复制代码
平均值:30.0
总和: IntSummaryStatistics{count=5, sum=150, min=10, average=30.000000, max=50}

并行Stream

机制说明

使用并行流,可以有效利用计算机的多CPU硬件,提升逻辑的执行速度。并行流通过将一整个stream划分为多个片段,然后对各个分片流并行执行处理逻辑,最后将各个分片流的执行结果汇总为一个整体流。

约束与限制

并行流类似于多线程在并行处理,所以与多线程场景相关的一些问题同样会存在,比如死锁等问题,所以在并行流终止执行的函数逻辑,必须要保证线程安全。

回答最初的问题

到这里,关于JAVA Stream的相关概念与用法介绍,基本就讲完了。我们再把焦点切回本文刚开始时提及的一个问题:

Stream相较于传统的foreach的方式处理stream,到底有啥优势?

根据前面的介绍,我们应该可以得出如下几点答案:

  • 代码更简洁、偏声明式的编码风格,更容易体现出代码的逻辑意图
  • 逻辑间解耦,一个stream中间处理逻辑,无需关注上游与下游的内容,只需要按约定实现自身逻辑即可
  • 并行流场景效率会比迭代器逐个循环更高
  • 函数式接口,延迟执行的特性,中间管道操作不管有多少步骤都不会立即执行,只有遇到终止操作的时候才会开始执行,可以避免一些中间不必要的操作消耗

当然了,Stream也不全是优点,在有些方面也有其弊端:

  • 代码调测debug不便
  • 程序员从历史写法切换到Stream时,需要一定的适应时间

总结

好啦,关于JAVA Stream的理解要点与使用技能的阐述就先到这里啦。那通过上面的介绍,各位小伙伴们是否已经跃跃欲试了呢?快去项目中使用体验下吧!当然啦,如果有疑问,也欢迎找我一起探讨探讨咯。

补充1:

受限于篇幅限制,本文对collect的用法也只是简单介绍,但是Stream中collect的能力远比想象的要强大,为了能够将这部分讲清楚,我针对Stream的collect用法与原理写了一篇专门文章,点击👉👉《讲透JAVA Stream的collect用法与原理,远比你想象的更强大》👈👈助你解锁更多Stream的高级玩法。

2023.09.13 补充2

Java Stream的两篇文档发表之后,受到了小伙伴们的热情支持,2篇文章收获了累计 10w+阅读、2k+点赞以及 5k+收藏的记录,本篇文档更是获选2022掘金年度爆款好文的奖项。能够得到众多小伙伴的认可,是技术分享过程中最开心的事情。不少小伙伴在评论中提出了一些的疑问或自己的独到见解,也在评论区中进行了热烈的互动讨论。梳理了下相关评论内容,针对大家讨论的比较热烈的几个点进行了汇总与阐述,也算是对原有2篇Stream系列文档的补充。有兴趣的小伙伴可以点击《再聊Java Stream的一些实战技能与注意点》一睹为快。

此外:

  • 关于本文中涉及的演示代码的完整示例,我已经整理并提交到github中,如果您有需要,可以自取:github.com/veezean/Jav…

我是悟道,聊技术、又不仅仅聊技术~

如果觉得有用,请点个关注,也可关注下我的公众号【架构悟道】,获取更及时更新。

期待与你一起探讨,一起成长为更好的自己。


我正在参与掘金技术社区创作者签约计划招募活动,点击链接报名投稿。

本文转载自: 掘金

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

【若川视野 x 源码共读】第36期 可能是历史上最简单的

发表于 2022-07-11

源码共读前言

为了能帮助到更多对源码感兴趣、想学会看源码、提升自己写作和前端技术能力的同学。 帮助读者夯实基础,查漏补缺,开阔眼界,拓宽视野,知其然知其所以然。

我倾力组织了每周一起学200行左右的源码共读活动。我写有《学习源码整体架构系列》20余篇,走过路过的小伙伴可以点击关注下这个目前是掘金关注数最多的专栏。

欢迎点此扫码加我微信 ruochuan02 交流,参与 源码共读 活动,每周大家一起学习200行左右的源码,共同进步。可以持续关注我@若川。

从易到难推荐学习顺序

活动介绍和顺序具体看这里从易到难推荐学习顺序

提交笔记

提交笔记方式
简言之:看任务,看辅助文章、看源码,交流讨论,在掘金写笔记,写好后提交到本文评论区。

为了给大家谋福利,另外给大家的文章带来更多阅读量,便于搜索,从2022年3月27日起,笔记可以直接发布在掘金,以《标题自取》标题不限,可以取个好标题,容易被掘金推荐。

笔记文章开头加两句话:

  • 本文参加了由公众号@若川视野 发起的每周源码共读活动, 点击了解详情一起参与。
  • 这是源码共读的第xx期,链接:xxx。

笔记文章最后,建议写上总结、收获、感受等。

  • 开头第一句作用是:方便每月统计评优,掘金赞助送小礼物。顺便帮忙宣传推广,让更多人参与进来,一起学习。
  • 开头第二句作用是:加上是多少期,当前任务说明的链接,方便读者知道这是什么活动。

笔记写完后,到当前期活动的文章评论区留言自己的文章和笔记特点。方便大家查阅学习交流讨论。

往期所有笔记存放在语雀讨论区。

任务发布时间

7月11日 - 7月31日。可以按照自己节奏学习,提交笔记即可(不一定要严格按照我规定的时间)。往期共读也可以及时复习,笔记未完成可以继续完成。

语雀本期任务说明链接

语雀有树形菜单,更方便查看,所以也放下语雀的这一期链接

学习任务

  • omit.js 剔除对象中的属性
  • github仓库 github.com/benjycui/om… 主要看这个。然后写笔记写文章记录。
  • npm www.npmjs.com/package/omi…
  • 这期比较简单,主要学会通过测试用例调试源码。 可以多关注怎么发布npm包的、commonjs、esm、测试用例 、ts 等(也可以不关注)。
  • 建议克隆代码下来,关注测试用例,自己多通过测试用例调试,自己调试过才能够学会,感受更深一些。
  • 关于如何调试看这篇:新手向:前端程序员必学基本技能——调试JS代码
  • 根据大家问卷反馈情况,多设置一些相对简单的,先让大家参与进来,让大家觉得源码也不难。
  • 最后大家没填问卷的,有空抽几分钟来填下源码共读活动问卷~你们的反馈至关重要wj.qq.com/s2/9304505/…
  • 以下为选学
    • 类似 TS Omit 参考文章 zhuanlan.zhihu.com/p/397398407
      • underscore.js 也有这个方法 omit underscorejs.org/docs/module…
      • lodash 也有 omit docs-lodash.com/v4/omit/

参考文章

  • 若川说”可能是历史上最简单的一期omit.js”源码共读,但我学到了这些
  • 看文章,看源码,交流讨论,写笔记发布在掘金/语雀。再在这篇文章下评论放上提交笔记的链接。

本文转载自: 掘金

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

【若川视野 x 源码共读】第9期 create-vue

发表于 2022-07-05

本文参加了由公众号@若川视野 发起的每周源码共读活动,点击了解详情一起参与。

这是源码共读的第9期,链接:juejin.cn

1. 学习目标

  1. 学会全新的官方脚手架工具 create-vue 的使用和原理
  2. 学会使用 VSCode 直接打开 github 项目
  3. 学会使用测试用例调试源码
  4. 学以致用,为公司初始化项目写脚手架工具

2. 源码地址

github.com/vuejs/creat…

线上vsCode阅读

vsCode打开github项目
用 vscode.dev/github/ 替换掉 github.com/ 即可

3. 关于create-vue的使用

执行命令npm init vue@next,根据提示选择使用的技术栈,给出后续操作提示并生成项目。如图所示:

image.png

这里的npm init vue@next实际是执行的npx create-vue@next

涉及的知识点:

  • npm init XX 等同于 npx creat-XX npm init
  • npx安装包时,会临时下载,用完就删除 (npm 5.2版本开始支持) npx
  • @next 是在发布时增加的标签 npm publish --tag next 对应某个版本 默认是latest
  • 查看tag对应的版本npm dist-tag ls XX
  • creat-vue比@vue/cli快,原因在于相对依赖少,代码行数少

4. 调试准备

4.1 项目克隆

首先,将项目clone到本地

1
bash复制代码git clone git@github.com:vuejs/create-vue.git

如果想克隆到自己的项目并保留原代码库create-vue的提交记录,参考如下操作:

  1. 新建仓库
  2. 克隆仓库到本地
1
2
bash复制代码git clone git@github.com:baosisi07/create-vue-analysi.git
cd create-vue-analysi
  1. 使用Git Subtree将源代码clone到当前目录的create-vue下
1
2
3
4
5
6
7
swift复制代码git subtree add --prefix=create-vue git@github.com:vuejs/create-vue.git main
// 初始化
// git subtree add --prefix=用来放S项目的相对路径 S项目git地址 xxx分支
// 提交更改 (自动遍历之前的提交记录,自动找到S项目的提交记录)
// git subtree push --prefix=S项目的路径 S项目git地址 xxx分支
// 在其他项目更新S项目
// git subtree pull --prefix=S项目的路径 S项目git地址 xxx分支

Git Subtree用于在多个项目间双向同步子项目,比如A项目使用子项目S,S有单独的仓库进行管理,S项目更新可以在A项目中同步到,在A项目中对S进行修改提交,也会同步到S的代码库,如果B项目也使用了S,那么,B也可以同步到S的更新。

4.2 package.json解析

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
json复制代码{
"name": "create-vue",
"version": "3.2.2",
"description": "An easy way to start a Vue project",
// type定义了node如何解析.js文件,默认是 CommonJS 此时表示此包采用ES module语法解析.js
"type": "module",
// bin指定可执行脚本。所以我们可以使用 npx create-vue
"bin": {
"create-vue": "outfile.cjs"
},
// 包下载安装完成时包括的所有文件
"files": [
"outfile.cjs",
"template"
],
// 设置了此软件包/应用程序在哪个版本的 Node.js 上运行
"engines": {
"node": "^14.16.0 || >=16.0.0"
},
// 定义npm脚本(shell脚本)命令
"scripts": {
"prepare": "husky install",
"format": "prettier --write .",
"build": "zx ./scripts/build.mjs",
"snapshot": "zx ./scripts/snapshot.mjs",
"pretest": "run-s build snapshot",
"test": "zx ./scripts/test.mjs",
"prepublishOnly": "zx ./scripts/prepublish.mjs"
},
}
npm钩子

npm 脚本有pre和post两个钩子,完成一些准备工作和清理工作。npm钩子
除了常见的一些声明周期钩子,有些钩子会在除了pre-Event和Post-Event钩子之外执行,比如

  • prepare, prepublish, prepublishOnly, prepack, postpack
  • prepare (npm 4 引入)等同于prepublish
+ 在pack和publish之前执行
+ install不带参数时运行 install的钩子postinstall之后执行
+ prepublish之后,prepublishOnly之前执行
  • prepublish (已废弃)
+ 因为在publish和install时都会运行,令人疑惑,所以废弃,后来用prepare来代替
+ 不会在publish时执行,但会在ci和install时执行
  • prepublishOnly

在prepared和packed之前执行,仅在publish时执行
自定义钩子可以通过npm_lifecycle_event变量获取当前正在运行的脚本名称。如:

1
2
3
4
js复制代码const target = process.env.npm_lifecycle_event
if(tartget === 'preMyScript') {
console.log('running preMyScript')
}
husky + lint-staged

husky使得使用git hook变得容易

如果想在install之后自动开启git钩子,可以在prepare中定义,像上面package.json中的配置

lint-staged对将要提交的内容进⾏lint校验或prettier格式化,结合husky使提交内容更规范
在package.json中配置即可,例如:

1
2
3
4
5
6
7
8
json复制代码{
name: 'create-vue',
"lint-staged": {
"*.{js,ts,vue,json}": [
"prettier --write"
]
}
}
run-s

这个命令来自 npm-run-all,它是一个CLI工具,可以并行或顺序运行多个npm脚本。
共提供了三个命令:

  • npm-run-all 默认串行执行
  • run-s npm-run-all -s (sequentially)简写 串行执行 等同于 run script1 && run script2
  • run-p npm-run-all -p (parallel)简写 并行执行 等同于 run script1 & run script2
zx

bash命令虽然好,但是涉及一些复杂的操作时,并不能很好的书写脚本。zx提供了像书写js一样来写脚本,它对子进程进行合理的包装,通过传参的方式提供给我们简单的方法,使编写bash脚本变得更容易。
安装:

1
css复制代码npm i -g zx

使用:

  1. 首先,为了在最顶层使用await,我们将脚本文件后缀名改为.mjs
  2. 在zx脚本文件开头添加
1
javascript复制代码#!/usr/bin/env zx
  1. 运行脚本
1
bash复制代码zx ./script.mjs

zx常用函数 zx文档

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
js复制代码// 所有函数都是直接使用,无需引入的

// $使用
let name = 'foo & bar'
await $`mkdir ${name}`

// cd()改变目录
cd('/tmp')

// fetch()是node-fetch的包装
let resp = await fetch('https://medv.io')

// question()对readline包的包装
let bear = await question('What kind of bear is best? ')

// sleep()对setTimeout的包装
await sleep(1000)

// echo()相当于console.log()
let branch = await $`git branch --show-current`

echo`Current branch is ${branch}.`
// or
echo('Current branch is', branch)

5. 源码预热

我们通过运行初始化命令可以看到,create-vue完成了以下功能:

  1. 创建默认文件vue-project,可以自定义输入文件名
  2. 提供使用频率比较高的库供用户选择并生成相应的模版
  3. 完成项目创建,并提供运行提示

由package.json中可以看到,执行create-vue实际是执行了outfile.cjs,而outfile.cjs是根目录下的index.ts所生成。

我们先看下index.ts的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
js复制代码#!/usr/bin/env node

import * as fs from 'fs'
import * as path from 'path'

import minimist from 'minimist'
import prompts from 'prompts'
import { red, green, bold } from 'kolorist'

import renderTemplate from './utils/renderTemplate'
import { postOrderDirectoryTraverse, preOrderDirectoryTraverse } from './utils/directoryTraverse'
import generateReadme from './utils/generateReadme'
import getCommand from './utils/getCommand'
import renderEslint from './utils/renderEslint'
import banner from './utils/banner'

async function init() {
...
}
init().catch((e) => {
console.error(e)
})

5.1 使用的基础包:

minimist

主要作用就是解析命令行参数。看示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
js复制代码// example/parse.js
// process.argv是一个数组,数组的第一个元素是执行node进程的可执行文件的绝对路径 第二个是被执行脚本的路径 后面的则是实际的参数值
var argv = require('minimist')(process.argv.slice(2));
console.log(argv);

$ node example/parse.js -a beep -b boop
{ _: [], a: 'beep', b: 'boop' }

$ node example/parse.js -x 3 -y 4 -n5 -abc --beep=boop foo bar baz
{ _: [ 'foo', 'bar', 'baz' ],
x: 3,
y: 4,
n: 5,
a: true,
b: true,
c: true,
beep: 'boop' }
prompts

prompts用于收集用户信息的交互式命令行工具。

语法

prompts(prompts, options)

prompts: Object | Array

options.onSubmit: Function

options.onCancel: Function

每项prompt可能包含如下属性:

1
2
3
4
5
6
7
8
9
10
11
json复制代码{
type: String | Function,
name: String | Function,
message: String | Function,
initial: String | Function | Async Function
format: Function | Async Function,
onRender: Function
onState: Function
stdin: Readable
stdout: Writeable
}

其中的Function可以接受三个参数(prev, values, prompt)

  • prev指上一询问项的值
  • valuses指前面所有的结果集合
  • prompt指上一个prompt对象
    使用类型即type类型有:

若为null等falsey类值时则会跳过当前询问项

  • text
  • password
  • invisible
  • number
  • confirm
  • list
  • toggle
  • select
  • multiselect
  • autocompleteMultiselect
  • autocomplete
  • date

使用示例:

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
js复制代码const prompts = require('prompts');

(async () => {
const response = await prompts({
type: 'text',
name: 'meaning',
message: 'What is the meaning of life?'
});
// response => {meaning: value} 以name做为key
console.log(response.meaning);
})();

// 链式

const questions = [
{
type: 'text',
name: 'username',
message: 'What is your GitHub username?'
},
{
type: 'number',
name: 'age',
message: 'How old are you?'
},
{
type: 'text',
name: 'about',
message: 'Tell something about yourself',
initial: 'Why should I?'
}
];

(async () => {
const onCancel = prompt => {
console.log('Never stop prompting!');
return true;
}
const onSubmit = (prompt, answer) => console.log(`Thanks I got ${answer} from ${prompt.name}`);
const response = await prompts(questions, { onSubmit, onCancel }
// response => { username, age, about } 包含questions的name的对象

);

})();
kolorist

定义标准输入/输出的颜色,颜色示例:

image.png

还有一个使用到的库 gradient-string用于定义渐变字符串
这里的banner就是这个库生成的结果,如图:

image.png
使用示例:

1
2
3
4
5
js复制代码require('gradient-string')([
{ color: '#42d392', pos: 0 },
{ color: '#42d392', pos: 0.1 },
{ color: '#647eff', pos: 1 }
])('Vue.js - The Progressive JavaScript Framework'))

5.2 使用的工具函数:

renderTemplate(src, dist)
  • 将src的目录或文件递归地拷贝到dist下
  • 以_命名的文件会替换为以.命名
  • package.json如果已存在dist中,则对其内容进行merge处理,而不是替换
getCommand(manager, script)

通过参数选择包管理器和执行的脚本命令

1
2
js复制代码getCommand('npm', 'test') => npm run test
getCommand('yarn', 'install') => yarn
renderEslint(rootdir, options)
  • 根据是否需要typeScript、prettier、Cypress等生成相应的dependencies及scripts到package.json中
  • 生成相应的.eslintrc.cjs

6. 源码解析

6.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
js复制代码async function init() {
// 获取进程的当前目录
const cwd = process.cwd()

const argv = minimist(process.argv.slice(2), {
alias: {
typescript: ['ts'],
'with-tests': ['tests'],
router: ['vue-router']
},
// all arguments are treated as booleans
boolean: true
})
console.log(argv)
// ??为空值操作符 与||类似 区别在于??仅在左边值为`null` 或 `undefined`时才返回右边的值 比||可靠
// isFeatureFlagsUsed用于标记参数,
const isFeatureFlagsUsed =
typeof (
argv.default ??
argv.ts ??
argv.jsx ??
argv.router ??
argv.pinia ??
argv.tests ??
argv.vitest ??
argv.cypress ??
argv.eslint
) === 'boolean'
console.log(isFeatureFlagsUsed)
// 取命令行的第一个参数 作为projectName 默认vue-project
let targetDir = argv._[0]
const defaultProjectName = !targetDir ? 'vue-project' : targetDir
}

传入的第一个参数作为projectName,此时会跳过相关询问项,直接跳到后面的询问,如下图,继续询问ts配置。

image.png

除了传入projectName,如果传入包含在isFeatureFlagsUsed中的任一参数,并且值为true时,则直接跳过所有询问项,直接生成。可以看到minimist处理的别名和原名的值都是存在的且是同步的。

image.png

6.2 交互式询问配置

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
js复制代码result = await prompts(
[
{
name: 'projectName',
type: targetDir ? null : 'text',
message: 'Project name:',
initial: defaultProjectName,
// 状态变化的回调 设置新的目录名称 供后面的询问项使用
onState: (state) => (targetDir = String(state.value).trim() || defaultProjectName)
},

...

{
name: 'needsPrettier',
type: (prev, values) => {
if (isFeatureFlagsUsed || !values.needsEslint) {
// 如果不支持Eslint 则自动跳过此项询问
return null
}
return 'toggle'
},
message: 'Add Prettier for code formatting?',
initial: false,
active: 'Yes',
inactive: 'No'
}
],
{
onCancel: () => {
throw new Error(red('✖') + ' Operation cancelled')
}
}
)

前面的prompts有过了解的话,这里其实很好理解,大致会询问以下信息:

  • 项目名称:
    • 是否覆盖已存在的重名项目?
    • 为package.json输入一个合法的名称
  • 项目语言: JavaScript / TypeScript
  • 是否支持JSX
  • 是否安装Vue Router以满足单页面应用
  • 是否安装状态管理工具Pinia了解更多
  • 是否安装单元测试工具Vitest了解更多
  • 是否安装端到端或单元测试工具Cypress了解更多
  • 是否支持代码质量检测ESLint
  • 是否安装Prettier对代码进行格式化

6.3 生成初始化项目所需文件

1. 根据询问结果定义各个配置项变量,供后续使用
1
2
3
4
5
6
7
8
9
10
11
12
13
js复制代码const {
projectName,
packageName = projectName ?? defaultProjectName,
shouldOverwrite = argv.force,
needsJsx = argv.jsx,
needsTypeScript = argv.typescript,
needsRouter = argv.router,
needsPinia = argv.pinia,
needsCypress = argv.cypress || argv.tests,
needsVitest = argv.vitest || argv.tests,
needsEslint = argv.eslint || argv['eslint-with-prettier'],
needsPrettier = argv['eslint-with-prettier']
} = result
2. 创建新项目目录,已存在则清空
1
2
3
4
5
js复制代码if (fs.existsSync(root) && shouldOverwrite) {
emptyDir(root)
} else if (!fs.existsSync(root)) {
fs.mkdirSync(root)
}
3. 新建package.json并添加name和version
1
2
js复制代码const pkg = { name: packageName, version: '0.0.0' }
fs.writeFileSync(path.resolve(root, 'package.json'), JSON.stringify(pkg, null, 2))
4. 根据各个配置渲染引用模版生成文件
  • 渲染基础模版
  • 渲染包对应的配置项config,主要更新package.json中的依赖和配置项,添加config类的文件
    • ts和Eslint需要对支持的包进行单独的渲染配置
  • 生成示例代码文件
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
js复制代码const templateRoot = path.resolve(__dirname, 'template')
const render = function render(templateName) {
const templateDir = path.resolve(templateRoot, templateName)
renderTemplate(templateDir, root) //这里是拷贝文件及package.json合并操作
}

// 渲染基础模板
render('base')

// 根据变量值渲染对应的config 包含其独有的配置文件及package.json配置项
if (needsJsx) {
render('config/jsx')
}

...

if (needsTypeScript) {
render('config/typescript')

// 使用ts的话,会对其他的模块添加支持ts的配置
render('tsconfig/base')
if (needsCypress) {
render('tsconfig/cypress')
}
if (needsCypressCT) {
render('tsconfig/cypress-ct')
}
if (needsVitest) {
render('tsconfig/vitest')
}
}

// 使用ESlint的话,其关联的几个包会增加额外的Dependency、scripts,并生成相应的eslintrc文件
if (needsEslint) {
renderEslint(root, { needsTypeScript, needsCypress, needsCypressCT, needsPrettier })
}

// 生成示例代码
// 基础组件 包含ts或router的示例页面
const codeTemplate =
(needsTypeScript ? 'typescript-' : '') +
(needsRouter ? 'router' : 'default')
render(`code/${codeTemplate}`)

// 配置pinia或router的入口文件
if (needsPinia && needsRouter) {
render('entry/router-and-pinia')
} else if (needsPinia) {
render('entry/pinia')
} else if (needsRouter) {
render('entry/router')
} else {
render('entry/default')
}

如图template目录为可以使用的模版,根据配置项来渲染相应的package.json配置(多个配置项会做合并处理),或其他配置文件,比如vite.config.js,这里的base是基础模版。

image.png

5. 支持TS与否,对模版文件进行处理

支持ts时

  • 将模版中的js文件转换为ts
    • 如果存在ts文件,则移除js文件
    • 不存在则重命名为ts
  • 移除jsconfig.json,因为有tsconfig.json
  • 替换index.html的入口js文件为ts
    不支持时
  • 清理ts文件
6. 生成README.md文件并给出运行提示
  • process.env.npm_config_user_agent动态取用户使用的包管理工具
  • 包管理工具使用优先级 pnpm > yarn > npm
  • 动态生成README.md的安装提示及其他配置引导
  • 打印运行提示,利用kolorist的方法添加样式
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
js复制代码const userAgent = process.env.npm_config_user_agent ?? ''
const packageManager = /pnpm/.test(userAgent) ? 'pnpm' : /yarn/.test(userAgent) ? 'yarn' : 'npm'

fs.writeFileSync(
path.resolve(root, 'README.md'),
generateReadme({
projectName: result.projectName ?? defaultProjectName,
packageManager,
needsTypeScript,
needsVitest,
needsCypress,
needsCypressCT,
needsEslint
})
)

console.log(`\nDone. Now run:\n`)
if (root !== cwd) {
console.log(` ${bold(green(`cd ${path.relative(cwd, root)}`))}`)
}
console.log(` ${bold(green(getCommand(packageManager, 'install')))}`)
if (needsPrettier) {
console.log(` ${bold(green(getCommand(packageManager, 'lint')))}`)
}
console.log(` ${bold(green(getCommand(packageManager, 'dev')))}`)
console.log()

7. 测试

npm run test 实际先后执行了 build、snapshot和test

build就是我们上面分析的outfile.cjs的内容

7.1 snapshot的作用

  1. 找到['typescript', 'jsx', 'router', 'pinia', 'vitest', 'cypress']这几个包组合的所有可能性 (还有default)
  2. ['typescript', 'jsx', 'router', 'pinia'](测试无关)为前缀,with-tests结尾,(包括with-tests)生成所有可能的组合
  3. 依次遍历以上所有的组合,以'-'拼接生成相应的目录名,在相应目录下生成项目文件(删除前一次的目录及文件)
  4. 把所有的生成项目的组合放到playground/目录下

7.2 test做了啥

主要是对playground/下的项目文件进行测试

需要注意的是: 这里的测试命令(如: pnpm test:unit)在运行build的时候就会从template中拿过来了

具体的行为如下:

  1. 如果目录名含有vitest,执行pnpm test:unit
  2. 如果目录名含有cypress,执行pnpm build然后执行pnpm test:e2e:ci(页面测试 url方式打开)
  3. 如果目录名不含vitest,则使用cypress的组件测试
  4. 项目名以with-tests结尾,依次执行
    • pnpm test:unit
    • pnpm build
    • pnpm test:e2e:ci
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
js复制代码for (const projectName of fs.readdirSync(playgroundDir)) {
if (projectName.includes('vitest')) {
cd(path.resolve(playgroundDir, projectName))

console.log(`Running unit tests in ${projectName}`)
await $`pnpm test:unit`
}

if (projectName.includes('cypress')) {
cd(path.resolve(playgroundDir, projectName))

console.log(`Building ${projectName}`)
await $`pnpm build`

console.log(`Running e2e tests in ${projectName}`)
await $`pnpm test:e2e:ci`

if (!projectName.includes('vitest')) {
try {
await `pnpm test:unit:ci`
} catch (e) {
console.error(`Component Testing in ${projectName} fails:`)
console.error(e)
}
}
}

// 等同于 `--vitest --cypress`
if (projectName.endsWith('with-tests')) {
cd(path.resolve(playgroundDir, projectName))

console.log(`Running unit tests in ${projectName}`)
await $`pnpm test:unit`

console.log(`Building ${projectName}`)
await $`pnpm build`

console.log(`Running e2e tests in ${projectName}`)
await $`pnpm test:e2e:ci`
}
}

8. 总结

create-vue确实很快很好用,以前只知道怎么用,这下知道怎么写了,哈哈哈哈哈 过程比较漫长,这篇源码读了很久,也写了很久,但是最终的目的达到了(也不能为了写文章而写文章,是吧)。学到了很多优秀的工具,比如zx、start-server-and-test,编码的思想,比如模版渲染,还有一些编码技巧,比如snapshot生成组合那里(<<运算符的使用)等等。

下面就差实践了,后面为公司写一个脚手架工具😊。

本文转载自: 掘金

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

1…909192…956

开发者博客

9558 日志
1953 标签
RSS
© 2025 开发者博客
本站总访问量次
由 Hexo 强力驱动
|
主题 — NexT.Muse v5.1.4
0%