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

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


  • 首页

  • 归档

  • 搜索

再聊Java Stream的一些实战技能与注意点

发表于 2023-09-13

大家好,又见面了。

在此前我的文章中,曾分2篇详细探讨了下JAVA中Stream流的相关操作,2篇文章收获了累计 10w+阅读、2k+点赞以及 5k+收藏的记录。能够得到众多小伙伴的认可,是技术分享过程中最开心的事情。

  • 吃透JAVA的Stream流操作,多年实践总结
  • 讲透JAVA Stream的collect用法与原理,远比你想象的更强大

不少小伙伴在评论中提出了一些的疑问或自己的独到见解,也在评论区中进行了热烈的互动讨论。梳理了下相关评论内容,针对此前文章中没有提及的一些典型讨论点拿出来聊一聊,也是作为对此前两篇Java Stream相关文章内容的补充完善。

Stream处理时列表到底循环了多少次

看下面这段Stream使用的常见场景:

1
2
3
4
5
java复制代码Stream.of(17, 22, 35, 12, 37)
.filter(age -> age > 18)
.filter(age -> age < 35)
.map(age -> age + "岁")
.collect(Collectors.toList());

在这段代码里面,同时有2个 filter操作和1个 map操作以及1个 collect操作,那么这段代码执行的时候,究竟是对这个list执行了几次循环操作呢?是每一个Stream步骤都会进行一次遍历操作吗?为了验证这个问题,我们将上述代码改写一下,打印下每个步骤的结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
java复制代码        List<String> ages = Stream.of(17,22,35,12,37)
.filter(age -> {
System.out.println("filter1 处理:" + age);
return age > 18;
})
.filter(age -> {
System.out.println("filter2 处理:" + age);
return age < 35;
})
.map(age -> {
System.out.println("map 处理:" + age);
return age + "岁";
})
.collect(Collectors.toList());

先执行,得到如下的执行结果。其实结果已经很明显的可以看出,stream流处理的时候,是对列表进行了一次循环,然后顺序的执行给定的stream执行语句。

按照上述输出的结果,可以看出其处理的过程可以等价于如下的常规写法:

1
2
3
4
5
6
7
8
9
10
java复制代码        List<Integer> ages = Arrays.asList(17,22,35,12,37);
List<String> results = new ArrayList<>();
for (Integer age : ages) {
if (age > 18) {
if (age < 35) {
results.add(age + "岁");
}
}
}
System.out.println(results);

所以,Stream并不会去遍历很多次。其实上述逻辑也符合Stream 流水线加工的整体模式,试想一下,一条流水线上分环节加工一件商品,同一件产品也不会在流水线上加工2次的吧~

Stream究竟是让代码更易读还是更难懂

自Java8引入了 Lambda、函数式接口、Stream等新鲜内容以来,针对使用Stream或Lambda语法究竟是让代码更易懂还是更复杂的争议,一直就没有停止过。有的同学会觉得Stream语法的方式,一眼就可以看出业务逻辑本身的含义,也有一些同学认为使用了Stream之后代码的可读性降低了很多。

其实,这是个人编码模式与理念上的不同感知而已。Stream主打的就是让代码更聚焦自身逻辑,省去其余繁文缛节对代码逻辑的干扰,整体编码上会更加的简洁。但是刚接触的时候,难免会需要一定的适应期。技术总是在不断迭代、不断拥抱新技术、不去刻意排斥新技术,或许是一个更好的选项。

那么,话说回来,如何让自己能够一眼看懂Stream代码、感受到Stream的简洁之美呢?分享个人的一个经验:

  1. 先了解几个常见的Stream的api的功能含义(Stream的API封装的很优秀,很多都是字面意义就可以理解)
  2. 改变意识,聚焦纯粹的业务逻辑本身,不要在乎具体写法细节

下面举了个例子,如何用上述的2条方法,快速的让自己理解一段Stream代码表达的意思。

那么上面这段代码的含义就是,先根据员工子公司过滤所有上海公司的人员,再获取员工工资最高的那个人信息。怎么样?按照这个方法,是不是可以发现,Stream的方式,确实更加容易理解了呢~

在IDEA中debug调试Stream代码段

技术分享其实是一个双向的过程,分享的同时,也是自我学习与提升的机会,除了可以梳理发现一些自己之前忽略的知识点并加以巩固,还可以在互动的时候get到新的技能。

比如,我在此前的 Java Stream介绍的文章中,有提过基于Stream进行编码的时候会导致代码 debug调试的时候会比较困难,尤其是那种只有一行Lambda表达式的情况(因为如果代码逻辑多行编写的时候,可以在代码块内部打断点,这样其实也可以进行debug调试)。

关于这一点,很多小伙伴也有相同的感受,比如下面这个评论:

你以为这就结束了?接下来一个小伙伴的提示,“震惊”了众人!纳尼?原来Stream代码段也是可以debug单步调试的?

跟踪Stream中单步处理过程的操作入口按钮长这样:

并且,另一个小伙伴补充说这是IDEA从 2019.03版本开始有的功能:

嗯?难怪呢,我一直用的2019.02版本的,所以才没用上这个功能(强行给自己找了个台阶、哈哈哈)。于是,我悄悄的将自己的idea升级到了最新的2023.02版本(PS:新版本的UI挺好看,就是bug贼多)。好啦,言归正传,那么究竟应该如何利用IDEA来实现单步DEBUG呢?一一起来感受下吧。

在代码行前面添加断点的时候,如果要打断点的这行代码里面包含Stream中间方法(map\filter\sort之类的)的时候,会提示让选择断点的具体类型。

一共有三种类型断点可供选择:

  • Line:断点打在这一行上,不会进入到具体的Stream执行函数块中
  • Lambda:代码打在内部的lambda代码块上
  • Line and Lambda:代码走到这行或者执行这一行具体的函数块内容的时候,都会进入断点

下面这个图可以更清晰的解释清楚上述三者的区别。一般来说,我们debug的时候,更多的是关注自身的业务具体逻辑,而不会过多去关注Stream执行框架的运转逻辑,所以大部分情况下,我们选择第二个Lambda选项即可。

按照上面所述,我们在代码行前面添加一个Lambda类型断点,然后debug模式启动程序执行,等到断点进入的时候便可以正常的进行debug并查看内部的处理逻辑了。

如果遇到图中这种只有一行的lambda形式代码,想要看下返回值到底是什么的,可以选中执行的片段,然后 ALT+F8打开Evaluate界面(或者右键选择 Evaluate Expression),点击 Evaludate按钮执行查看具体结果。

大部分情况下,掌握这一点,已经可以应付日常的开发过程中对Stream代码逻辑的debug诉求了。但是上述过程偏向于细节,如果需要看下整个Stream代码段整体层面的执行与数据变化过程,就需要上面提到的Stream Trace功能。要想使用该功能,断点的位置也是有讲究的,必须要将断点打在stream开流的地方,否则看不到任何内容。另外,对于一些新版本的IDEA而言,这个入口也比较隐蔽,藏在了下拉菜单中,就像下面这个样子。

我们找到Trace Current Stream Chain并点击,可以打开Stream Trace界面,这里以chain链的方式,和stream代码块逻辑对应,分步骤展示了每个stream处理环节的执行结果。比如我们以 filter环节为例,窗口中以左右视图的形式,左侧显示了原始输入的内容,右侧是经过filter处理后符合条件并保留下来的数据内容,并且还有连接线进行指引,一眼就可以看出哪些元素是被过滤舍弃了的:

不止于此,Stream Trace除了提供上述分步查看结果的能力,还支持直接显示整体的链路执行全貌。点击Stream Trace窗口左下角的 Flat Mode按钮即可切换到全貌模式,可以看到最初原始数据,如何一步步被处理并得到最终的结果。

看到这里,以后还会说Stream不好调试吗?至少我不会了。

小心Collectors.toMap出现key值重复报错

在我们常规的HashMap的 put(key,value)操作中,一般很少会关注key是否已经在map中存在,因为put方法的策略是存在会覆盖已有的数据。但是在Stream中,使用 Collectors.toMap方法来实现的时候,可能稍不留神就会踩坑。所以,有小伙伴在评论区热心的提示,在使用此方法的时候需要手动加上 mergeFunction以防止key冲突。

这个究竟是怎么回事呢?我们看下面的这段代码:

1
2
3
4
5
6
7
java复制代码public void testCollectStopOptions() {
List<Dept> ids = Arrays.asList(new Dept(17), new Dept(22), new Dept(22));
// collect成HashMap,key为id,value为Dept对象
Map<Integer, Dept> collectMap = ids.stream()
.collect(Collectors.toMap(Dept::getId, dept -> dept));
System.out.println("collectMap:" + collectMap);
}

执行上述代码,不出意外的话会出意外。如下结果:

1
2
3
4
5
6
7
8
9
10
11
php复制代码Exception in thread "main" java.lang.IllegalStateException: Duplicate key Dept{id=22}
at java.util.stream.Collectors.lambda$throwingMerger$0(Collectors.java:133)
at java.util.HashMap.merge(HashMap.java:1254)
at java.util.stream.Collectors.lambda$toMap$58(Collectors.java:1320)
at java.util.stream.ReduceOps$3ReducingSink.accept(ReduceOps.java:169)
at java.util.Spliterators$ArraySpliterator.forEachRemaining(Spliterators.java:948)
at java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:481)
at java.util.stream.AbstractPipeline.wrapAndCopyInto(AbstractPipeline.java:471)
at java.util.stream.ReduceOps$ReduceOp.evaluateSequential(ReduceOps.java:708)
at java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:234)
at java.util.stream.ReferencePipeline.collect(ReferencePipeline.java:499)

因为在收集器进行map转换的时候,由于出现了重复的key,所以抛出异常了。 为什么会出现异常呢?为什么不是以为的覆盖呢?我们看下源码的实现逻辑:

可以看出,默认情况下如果出现重复key值,会对外抛出IllegalStateException异常。同时,我们看到,它其实也有提供重载方法,可以由使用者自行指定key值重复的时候的执行策略:

所以,我们的目标是出现重复值的时候,使用新的值覆盖已有的值而非抛出异常,那我们直接手动指定下让toMap按照我们的要求进行处理,就可以啦。改造下前面的那段代码,传入自行实现的 mergeFunction函数块,即指定下如果key重复的时候,以新一份的数据为准:

1
2
3
4
5
6
7
8
9
10
java复制代码    public void testCollectStopOptions() {
List<Dept> ids = Arrays.asList(new Dept(17), new Dept(22), new Dept(22));
// collect成HashMap,key为id,value为Dept对象
Map<Integer, Dept> collectMap = ids.stream()
.collect(Collectors.toMap(
Dept::getId,
dept -> dept,
(exist, newOne) -> newOne));
System.out.println("collectMap:" + collectMap);
}

再次执行,终于看到我们预期中的结果了:

1
bash复制代码collectMap:{17=Dept{id=17}, 22=Dept{id=22}}

By The Way,个人感觉JDK在这块的默认实现逻辑有点不合理。虽然现在默认的抛异常方式,可以强制让使用端感知并去指定自己的逻辑,但这默认逻辑与map的put操作默认逻辑不一致,也让很多人都会无辜踩坑。如果将默认值改为有则覆盖的方式,或许会更符合常理一些 —— 毕竟被广泛使用的HashMap的源码里,put操作默认就是覆盖的,不信可以看HashMap源码的实现逻辑:

慎用peek承载业务处理逻辑

peek和 foreach在Stream流操作中,都可以实现对元素的遍历操作。区别点在与peek属于中间方法,而foreach属于终止方法。这也就意味着peek只能作为管道中途的一个处理步骤,而没法直接执行得到结果,其后面必须还要有其它终止操作的时候才会被执行;而foreach作为无返回值的终止方法,则可以直接执行相关操作。

那么,只要有终止方法一起,peek方法就一定会被执行吗?非也!看版本、看场景! 比如在 JDK1.8版本中,下面这段代码中的peek方法会正常执行,但是到了 JDK17中就会被自动优化掉而不执行peek中的逻辑:

1
2
3
4
java复制代码    public void testPeekAndforeach() {
List<String> sentences = Arrays.asList("hello world", "Jia Gou Wu Dao");
sentences.stream().peek(sentence -> System.out.println(sentence)).count();
}

至于原因,可以看下JDK17官方API文档中的描述:

因为对于 findFirst、count之类的方法,peek操作被视为与结果无关联的操作,直接被优化掉不执行了。所以说最好按照API设计时预期的场景去使用API,避免自己给自己埋坑。

我们从peek的源码的注释上可以看出,peek的推荐使用场景是用于一些调试场景,可以借助peek来将各个元素的信息打印出来,便于开发过程中的调试与问题定位分析。

我们再看下peek这个词的含义解释:

既然开发者给它起了这么个名字,似乎确实仅是为了窥视执行过程中数据的变化情况。为了避免让自己踩坑,最好按照设计者推荐的用途用法进行使用,否则即使现在没问题,也不能保证后续版本中不会出问题。

img

字符串拼接明明有join,那么Stream中Collectors.join存在意义是啥

在介绍Stream流的收集器时,有介绍过使用 Collectors.joining来实现多个字符串元素之间按照要求进行拼接的实现。比如将给定的一堆字符串用逗号分隔拼接起来,可以这么写:

1
2
3
4
5
java复制代码    public void testCollectJoinStrings() {
List<String> ids = Arrays.asList("AAA", "BBB", "CCC");
String joinResult = ids.stream().collect(Collectors.joining(","));
System.out.println(joinResult);
}

有很多同学就提出字符串元素拼接直接用 String.join就可以了,完全没必要搞这么复杂。

如果是纯字符串简单拼接的场景,确实直接String.join会更简单一些,这种情况下使用Stream进行拼接的确有些大材小用了。 但是 joining的方法优势要体现在Stream体系中,也就是与其余Stream操作可以结合起来综合处理。String.join对于简单的字符串拼接是OK的,但是如果是一个Object对象列表,要求将Object某一个字段按照指定的拼接符去拼接的时候,就力不从心了——而这就是使用 Collectors.joining的时机了。比如下面的实例:

小结

好啦,关于Java Stream相关的内容点的补充,就聊到这里啦。如果需要全面了解Java Stream的相关内容,可以看我此前分享的文档。那么,你对Java Stream是否还有哪些疑问或者自己的独特理解呢?欢迎一起交流下。

传送门:

  • 吃透JAVA的Stream流操作,多年实践总结
  • 讲透JAVA Stream的collect用法与原理,远比你想象的更强大

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

如果觉得有用,请点赞 + 关注让我感受到您的支持。也可以关注下我的公众号【架构悟道】,获取更及时的更新。

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

本文转载自: 掘金

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

WebSocket 协议分析

发表于 2023-09-11

温馨Tips:阅读本文大约需要 10 分钟。

基本介绍

维基百科定义

WebSocket 是一种网络传输协议,可在单个 TCP 连接上进行全双工通信,位于 OSI 模型的应用层。WebSocket 协议在 2011 年由 IETF 标准化为 RFC 6455,后由 RFC 7936 补充规范。Web IDL 中的 WebSocket API 由 W3C 标准化。

WebSocket 使得客户端和服务器之间的数据交换变得更加简单,允许服务端主动向客户端推送数据。在 WebSocket API 中,浏览器和服务器只需要完成一次握手,两者之间就可以创建持久性的连接,并进行双向数据传输。

名词解释:

  • IETF: 全称 Internet Engineering Task Force(互联网工程任务组),是一个开放的标准组织,负责开发和推广自愿互联网标准,特别是构成 TCP/IP 协议族的标准。
  • Web IDL: 是一种接口描述语言格式,用于描述要在 Web 浏览器中实现的应用程序编程接口。

浏览器支持

使用场景

凡是对 “消息” 推送有较高及时性的场景,都可以使用 WebSocket,例如:客服服务、邮件通知等服务。

历史

在浏览器未实现 WebSocket 的时候,想要实现服务端推送,通常会使用 轮询 或 长轮询 技术实现。

轮询

无脑定时发送请求去判断有没有新的“消息”。

长轮询

流程:

  1. 浏览器发送请求发送到服务器。
  2. 服务器在有“消息”之前不会关闭连接。
  3. 当有新“消息”时 或 挂起超时,服务器将对其请求作出响应。
  4. 浏览器立即发出一个新的请求。

下面是 QQ 邮箱网页版长轮询请求截图:

WebSocket 优点

  • 较少的控制开销
  • 更强的实时性
  • 保持连接状态(无需基于类似 cookie 机制去维持会话状态)
  • 更好的二进制支持
  • 更好的压缩效果

知识普及

二进制

基本概念

  • bit: 位,二进制的最小单位,1 位表示 1 个 0 或 1
  • byte: 字节,每 8 位叫做 1 字节(Node buffer 类型:Uint8Array,即 buffer 中每个元素代表一个 8 位无符号数字,范围 0~255)
  • KB: 1024 个字节
  • MB: 1024 kb
  • 有符号:二进制的第一位用作符号位(通常是 0 代表正数,1 代表负数),这样实际可表示的数值范围绝对值会减少一半(Int8Array表示 8 位有符号二进制数组,每个元素范围为:-128~127)
  • 无符号:将有符号的第一位也用作数值表示,Uint8Array 就是一个无符号二进制数组

运算

image.png

示例

1
2
3
4
复制代码   1 1 0 0 1 1 0 0
& 1 0 0 1 1 0 1 1
————————————————————
1 0 0 0 1 0 0 0
1
2
3
4
复制代码   1 1 0 0 1 1 0 0
| 1 0 0 1 1 0 1 1
————————————————————
1 1 0 1 1 1 1 1
1
2
3
4
复制代码   1 1 0 0 1 1 0 0
^ 1 0 0 1 1 0 1 1
————————————————————
0 1 0 1 0 1 1 1

Note:用相同的数异或二次等于本身:0b1010 == (0b1010 ^ 0b1100 ^ 0b1100) == 10

1
2
3
复制代码 ~ 1 1 0 0 1 1 0 0
————————————————————
0 0 1 1 0 0 1 1
1
2
3
bash复制代码   1 1 0 0 1 1 0 0 << 1
—————————————————————————
1 0 0 1 1 0 0 0

Note:全部位向左移动一位,高位(左侧)的一个 1 被丢弃,低位(右侧)补充一个 0

1
2
3
复制代码   1 1 0 0 1 1 0 0 >> 1
—————————————————————————
0 1 1 0 0 1 1 0

大小端

  • 大端小端是不同的字节顺序存储方式,统称为字节序;
  • 大端模式,高位在前,低位在后(和我们的阅读习惯一致)
  • 小端模式, 与大端模式相反

Stream

流提供了两个主要优点:

  • 内存效率: 无需加载大量的数据到内存中即可进行处理。
  • 时间效率: 当获得数据之后即可立即开始处理数据,这样所需的时间更少,而不必等到整个数据有效负载可用才开始。

与 buffer 处理文件的内存占用对比

分别使用 buffer 及 stream 两种方式,对一个约为 61M 的文件做 md5 hash 摘要计算,来对比内存占用情况:

Buffer
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
js复制代码const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const { performance } = require('perf_hooks');
const v8 = require('v8');

function bufferImpl() {
v8.writeHeapSnapshot('buffer.1.heapsnapshot');

const start = performance.now();
const filename = path.join(__dirname, 'test.zip');
const buffer = fs.readFileSync(filename);
const hash = crypto.createHash('md5').update(buffer).digest('hex');

v8.writeHeapSnapshot('buffer.2.heapsnapshot');

console.log('Buffer Impl:');

console.log(' Hash:', hash);
console.log(' Cost:', performance.now() - start);
}

Stream
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
js复制代码const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const { performance } = require('perf_hooks');
const v8 = require('v8');

function streamImpl() {
v8.writeHeapSnapshot('stream.1.heapsnapshot');

const start = performance.now();
const filename = path.join(__dirname, 'test.zip');
const stream = fs.createReadStream(filename);
const md5 = crypto.createHash('md5').setEncoding('hex');
const hash = stream.pipe(md5);

v8.writeHeapSnapshot('stream.2.heapsnapshot');

console.log('Stream Impl:');

process.stdout.write(' Hash: ');
hash.pipe(process.stdout);
hash.on('end', () => {
console.log('\n Cost:', performance.now() - start);
});
}

Node Stream API

stream.Writable

常用方法:

  • .write(chunk) 写入数据片段
stream.Readable

常用事件:

  • data 接收到数据片段
  • error 错误
  • end 读取结束

常用方法:

  • .pipe(writeable) 通俗来讲,就是把 “读” 管道和 “写” 管道头尾相接,这样数据就可以从一个管道直接 “流” 到另一个管道,方便操作。

最大传输单元(MTU)

泛指通讯协议中的最大传输单元。一般用来说明 TCP/IP 四层协议中数据链路层的最大传输单元,不同类型的网络 MTU 也会不同,我们普遍使用的以太网的 MTU 是 1500,即最大只能传输 1500 字节的数据帧。可以通过 ifconfig命令查看电脑各个网卡的 MTU。

最大分片大小(MSS)

指 TCP 建立连接后双方约定的可传输的最大 TCP 报文长度,是 TCP 用来限制应用层可发送的最大字节数。如果底层的MTU是1500byte,则 MSS = 1500 - 20(IP Header) - 20 (TCP Header) = 1460 byte。

图片来源:互联网协议介绍 · 应用层

Nagle 算法

作用:减少网络拥塞

规则:

  1. 如果包长度达到 MSS,则允许发送
  2. 如果包含 FIN,则允许发送
  3. 如果设置了TCP_NODELAY,则允许发送
  4. 未设置 TCP_CORK 选项时,若所有发出去的小数据包(包长度小于 MSS )均被确认,则允许发送
  5. 上述条件都未满足,但发生了超时(一般为200ms),则立即发送。

大概意思就是:数据包不是立马发送的,而且要在一定延迟下(200ms)等待下一个包,如果加起来小于 MSS,这将这些包一起发送出去。

TCP 粘包和拆包

受 MSS 及 Nagle 算法等因素影响,TCP 在发送时会遇到粘包和拆包的情况:

协议分析

1. 通过一次 HTTP 握手,完成协议升级 (rfc6455#section-1.2)

首先客户端发送如下 Headers:

1
2
3
4
5
6
7
8
9
http复制代码GET /chat HTTP/1.1
Host: server.example.com
Origin: http://example.com

Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Protocol: chat, superchat
Sec-WebSocket-Version: 13

服务端响应如下 Headers:

1
2
3
4
5
http复制代码HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Sec-WebSocket-Protocol: chat

其中,服务端响应的 Sec-WebSocket-Accept 字段的值生成算法伪代码如下:

  1. let str = Sec-WebSocket-Key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
  2. base64(sha1( str ))

备注: 字符串 "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" 是协议指定的唯一 GUID

当完成这次握手动作,客户端与服务端就建立了一个长连接,后续两端的消息收发等操作靠解析数据帧来完成。

2. 数据帧解析 (rfc6455#section-5.2)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
lua复制代码 
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-------+-+-------------+-------------------------------+
|F|R|R|R| opcode|M| Payload len | Extended payload length |
|I|S|S|S| (4) |A| (7) | (16/64) |
|N|V|V|V| |S| | (if payload len==126/127) |
| |1|2|3| |K| | |
+-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
| Extended payload length continued, if payload len == 127 |
+ - - - - - - - - - - - - - - - +-------------------------------+
| |Masking-key, if MASK set to 1 |
+-------------------------------+-------------------------------+
| Masking-key (continued) | Payload Data |
+-------------------------------- - - - - - - - - - - - - - - - +
: Payload Data continued ... :
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
| Payload Data continued ... |
+---------------------------------------------------------------+
  • FIN: 占 1 位,表示这是消息的最后一个片段,(如果为 1,表示当前已经结束了,如果为0,表示消息分片还没结束)
  • RSV1, RSV2, RSV3: 各占1位,表示扩展协议 (extensions)
  • opcode: 占4位,表示操作码,各操作码含义如下:
+ %x0 : 表示消息帧的延续(一般来说配合 FIN = 0 使用)
+ %x1 : 表示传输的内容是文本
+ %x2 : 表示传输的内容是二进制
+ %x3-7 : 给未来保留的非控制帧的操作吗
+ %x8 : 表示连接关闭
+ %x9 : 表示一个 ping
+ %xA : 表示一个 pong
+ %xB-F : 给未来保留的控制帧的操作码
  • MASK: 占1位,表示是否使用掩码
  • Payload len: 占7位:表示数据的字节数,该长度值的含义如下:
+ 如果值的范围是:0~125,则数据的真实长度就是该值
+ 如果值为:126,则以无符号整数的方式读取接下来的 16 位,读取结果为数据的真实长度
+ 如果值为:127,则以无符号整数的方式读取接下来的 64 位,读取结果为数据的真实长度

备注:Payload len 实际占有的位数可能有:7 (0~125)、16 (126)、64 (127)

  • Masking-key: 如果使用了掩码( MASK = 1), 则占 32 位,否则,不占空间
  • Payload Data:数据,包含2部分:
+ 扩展数据(握手时协商了扩展的时候才有,并要明确给出扩展数据的字节数)
+ 应用数据

3. 一些细节

3.1 关于掩码(rfc6455#section-5.3)

掩码用于加密或解密载荷数据,加密和解码的计算方式都如下所示 (nodejs 代码):

1
2
3
4
5
6
7
8
9
10
js复制代码/**
* 处理控制帧
* @param data 数据
* @param maskingKey 掩码key(4个字节)
*/
function mask(data: Buffer, maskingKey: Buffer) {
for (let i = 0; i < data.length; i++) {
data[i] = data[i] ^ maskingKey[i % 4]
}
}

协议规定:客户端向服务端发送数据是,必须使用掩码;而服务端向客户端发送数据时,则不能使用掩码(rfc6455#section-5.1)

3.2 关于 ping 和 pong 操作 (rfc6455#section-5.5.2)

ping 和 pong 通常用于检测两端是否处于连接正常的状态(心跳检测)

协议规定: 一端向向一端发送 ping 操作帧时,另一端必须发送一个 pong 操作帧作为响应

4. 实现 demo 示例

请参阅:github.com/peakchen90/…

一些思考

HTTP 握手时为啥要使用 \r\n 作为换行符?

参考:www.rfc-editor.org/rfc/rfc2616…

发送二进制文件怎么携带发送者的用户信息?

可以实现一个自定义二进制协议。如下所示,二进制的第一个 8 位用来存放用户昵称的字节长度 L(最大支持 256 个字节,使用 UTF-8 编码字符大约能放 64 个中文),其后仅接着占用 L 个字节存放真实昵称使用 UTF-8 编码后的二进制序列,最后部分为真实的二进制文件内容。

1
2
3
4
5
6
7
8
9
js复制代码// 0 0 0 0 0 0 0 0 : 昵称长度
// ... : 昵称位置
// ... : 二进制数据位置
const nickBuffer = Buffer.from(sender['nickname']);
const headerBuffer = Buffer.allocUnsafe(1 + nickBuffer.length);
headerBuffer.writeUInt8(nickBuffer.length, 0);
headerBuffer.set(nickBuffer, 1);

wss.broadcast([headerBuffer, message], true);

WebSocket 如何处理大文件?

大文件的内存占用对于服务端来说是致命的。客户端可以使用 Blob, 服务端只做消息转发的功能,不要在服务端组合分片内容完成后再下发。

IP 协议职责是?TCP 协议的职责是?

IP 用于找到主机,TCP 用于找到主机的程序(根据 port)

参考链接

  • 中英文双语对照版:RFC 6455: The WebSocket Protocol 中文翻译
  • 英文版协议 : RFC 6455: The WebSocket Protocol
  • WebSocket:5分钟从入门到精通:juejin.cn/post/684490…
  • ws: a Node.js WebSocket library : github.com/websockets/…
  • 本文 demo : github.com/peakchen90/…

最后

关注公众号「Goodme前端团队」,获取更多干货实践,欢迎交流分享~

本文转载自: 掘金

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

业务 前后端实现二维码扫码登录-深度剖析 简介 梳理 总结

发表于 2023-09-10

简介

还是老样子,先把产品的需求设计给理一理先。如果没有做过这一块的前端小伙伴,可能一脸懵逼。没事,我也一样,万事开头都不简单,所以需要我们花点时间精力梳理一下

需求:

  1. 登录二维码有效期默认2分钟,超过2分钟提示用户刷新
  2. 扫码成功后,跳转扫码成功页面,用户在移动端点击确认登录后,登录进入应用

理解

  1. 登录二维码有效期默认2分钟,超过2分钟提示用户刷新: 在生成登录二维码时,后端服务器将生成一个二维码标识符,并设置一个2分钟的有效期。后端可以通过生成时间戳或其他方法来跟踪二维码的有效性。前端在展示二维码的同时也开始计时,在二维码有效期结束前,如果用户未完成扫码登录,可以提示用户刷新二维码来获取一个新的二维码和标识符。
  2. 扫码成功后,跳转扫码成功页面,用户在移动端点击确认登录后,登录进入应用: 一旦用户在移动设备上成功扫描二维码,并点击确认登录按钮,前端应轮询向后端发送请求,确认用户的登录操作。

梳理

通过上面简介中的初步分析,可以大致画一下下面的流程图,这样方便我们一步步去实现

流程图

用户扫码成功确认登录验证成功验证失败用户扫码失败或超时是否生成二维码展示二维码等待用户扫码
开始轮询跳转扫码成功页面用户在移动端
点击确认登录验证登录信息用户登录成功进入应用程序应用程序主页显示登录失败提示二维码
是否过期提示用户刷新二维码
接口设计


根据上面分析出来的流程图,我们了解到

登录二维码有效期默认2分钟,超过2分钟提示用户刷新

在生成登录二维码时,后端服务器将生成一个二维码标识符,并设置一个2分钟的有效期。后端可以通过生成时间戳或其他方法来跟踪二维码的有效性,与此同时,在前端展示出二维码图之后,前端就需要进行轮询监听调后端的接口,而后端也是通过该接口来反馈给前端是否有人扫码成功、或者失败、或者过期

后端接口设计需要将这几个状态标记出来,比如

reason: QRCODE_SUCCESS、QRCODE_ERROR、QRCODE_EXPIRE;

前端通过轮询的接口接收到这些不同状态做不同的处理逻辑即可

那么后端需要设计的就以下这三个接口,基本满足市面上大部分扫码需求了:

1. 二维码生成

如果有不同端扫码的需求,可以加上一个platform平台参数,如下

打一个广告嘿嘿:

gh_db79ec2f6f73_860.jpg

接口命名

POST: /v1/accounts/qrcode/

获取二维码

platform:

GKOL_PC // xxpc端

GKOL_WEB // xxweb端

GKOL_WEB_MANAGER // xx管理端

GKOL_WEB_OPERATE // xx后台

GKOL_WEB_ORGANIZE // xx后台

body参数:

1
2
3
4
5
6
7
ts复制代码export interface Request {
/**
* 重新生成/取消时需要给
*/
code?: string;
platform: string;
}

响应结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
ts复制代码export interface Response {
/**
* 二维码,前缀+base64串,格式:rk://scanforpclogin/{qrcode}
* 二维码json解析结构
* {
* "id":"",
* "expire":1685696389,
* "prefix":"rk://scanforpclogin/"
* "platform":"Rxx_PC"
* }
*/
png: string;
}

2. 扫描二维码

主要给移动端用来扫码调的, 移动端先扫码web端的二维码图片,解析得到id,然后将id作为参数传进该扫描二维码接口

接口命名

POST: /v1/accounts/qrcode_fill

body参

可以省略,用户信息是放在header的token中的

响应结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
ts复制代码export interface Response {
/**
* 身份卡ID,为空就不限制
*/
card_id: string;
/**
* 二维码令牌,二维码解析结果
*/
id: string;
/**
* 状态,SCAN 已扫描
* VERIFY 已确认
* CANCEL 取消
*/
step: string;
}

3. 账号登陆

web端通过轮询该接口,监听移动端的扫码状态,如扫描二维码的三个状态:

  • SCAN 已扫描
  • VERIFY 已确认
  • CANCEL 取消

然后就是另外的三个状态:QRCODE_SUCCESS、QRCODE_ERROR、QRCODE_EXPIRE
通过这些状态,基本都能实现市面上的关于二维码的扫码业务逻辑了;

接口命名

POST /v1/passport/guest

body参数

image.png

响应结构

image.png

前端实现

这里主要解说上面所提的环节以及接口返回的格式做一下简略的处理总结

1. 二维码生成接口返回

image.png

将图中的png结果,按照接口文档拼接成base64格式后,通过qrcode-parser解析数据

import qrcodeParser from 'qrcode-parser';

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
ts复制代码    V1CreateQRcode().then((res) => {
qrcodeUrl.value = `data:image/png;base64,${res.data?.png}`;
qrcodeParser(res.data?.png)
.then((result: any) => {
console.log(JSON.parse(result));
QrCodeData.value = result ? JSON.parse(result) : null;
// setUseAsyncHookInit();
console.log(flush);
// flush();
recover();
// qrcodeUrl.value = JSON.stringify(res.data?.png);
})
.catch((err) => {
console.log(err);
});
});

解析后即可得到如下结果:

image.png

  1. 接口轮询Hook的封装
    调轮询的hook接口实现监听和逻辑处理,而关于hook的实现,将在这篇文章详细介绍
    轻松驾驭异步定时:Vue 3的useIntervalAsync解析

image.png

总结

登录二维码有效期默认2分钟,超过2分钟提示用户刷新: 在生成登录二维码时,后端服务器将生成一个二维码标识符,并设置一个2分钟的有效期,即生成二维码中解析的数据

1
2
3
4
5
6
js复制代码{
"id": "", // 生成二维码的标识符,这个要传给账号登录的code参数
"expire": 1685696389, // 有效时间,就是生成该二维码的时间,
"prefix": "[rk://scanforpclogin/]()",
"platform": "Rxx_PC"
}

后端通过生成时间戳来expire,跟踪二维码的有效性,当然也可以通过其他方法实现。

前端在展示二维码后,开始轮询调账号登录接口

image.png

将二维码标识传到code字段中,在二维码有效期结束前,如果用户未完成扫码登录,可以提示用户刷新二维码来获取一个新的二维码和标识符。
如下:

image.png

关于其他的错误成功等状态,就根据自己的业务需求进行操作,比如扫码成功后,跳转扫码成功页面,用户在移动端点击确认登录后,登录进入应用: 一旦用户在移动设备上成功扫描二维码,并点击确认登录按钮,前端应立即向后端发送请求,确认用户的登录操作

后端服务器检查接收到的请求中的标识符是否与之前生成的相匹配,并验证标识符的有效性和状态。如果匹配和验证成功,则表示用户已经完成了登录操作。后端服务器可以更新用户的登录状态,并将用户重定向到进入应用程序的页面。

本文转载自: 掘金

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

chromium-net - 跟随 Cronet 的脚步探索

发表于 2023-09-04

Cronet是什么?

From chatgpt

Cronet(Chromium Network Stack)是Google Chrome浏览器的网络堆栈的开源版本,用于处理网络请求和响应。它是一个用于Android平台的网络库,提供了高性能的网络功能,包括HTTP请求、数据下载、WebSocket支持等。Cronet的目标是为Android应用程序提供高效、可靠和安全的网络通信能力。

以下是Cronet的一些特点和功能:

  1. 性能优化: Cronet旨在提供高性能的网络通信,通过多线程处理和异步操作来最大程度地减少网络延迟。
  2. HTTP/2支持: Cronet支持HTTP/2,这是一种现代的、高效的HTTP协议,可以加快页面加载速度并减少网络资源的浪费。
  3. 安全性: Cronet支持HTTPS,可以确保数据在传输过程中的加密和安全性。
  4. Quic支持: Cronet还支持QUIC协议,这是Google开发的一种基于UDP的传输协议,旨在提供更低的延迟和更高的性能。
  5. WebSocket支持: Cronet可以用于处理WebSocket连接,使应用程序可以进行实时双向通信。
  6. 缓存管理: Cronet可以自动管理缓存,以提高资源的重复利用率,并减少不必要的网络请求。
  7. 可配置性: Cronet允许开发人员配置各种网络参数,以满足应用程序的需求。

Cronet最初是为Chrome浏览器的Android版本开发的,但后来被开源并提供给其他Android应用程序开发人员使用。它为开发人员提供了更多灵活性和控制,使他们能够更好地管理应用程序的网络通信,同时受益于Google Chrome团队的网络优化经验。

需要注意的是,Cronet在不同版本中可能会有一些变化和改进,因此在使用时应查阅官方文档以获取最新的信息和指南。

Chromium-net是什么?

From chatgpt

Chromium Net(简称Chromium网络)是Chromium项目的一部分,用于处理网络通信的底层组件。Chromium是一个开源项目,它是Google Chrome浏览器的基础,因此Chromium Net在Chrome浏览器中用于处理网络请求和响应。

以下是Chromium Net的一些主要特点和功能:

  1. 网络协议支持: Chromium Net支持各种网络协议,包括HTTP、HTTPS、HTTP/2、QUIC(Quick UDP Internet Connections)等。这使得Chrome浏览器能够与Web服务器进行高效的通信,并利用现代网络协议提供更快的页面加载速度和更好的性能。
  2. 网络安全性: Chromium Net负责处理HTTPS通信,确保通过SSL/TLS加密传输的数据的安全性。它还实施了一系列的安全策略和机制,用于保护用户免受网络攻击和恶意网站的威胁。
  3. 代理支持: 该组件允许配置代理服务器,以允许网络请求通过代理服务器进行中转,从而实现更高级的网络控制和隐私保护。
  4. 流量管理: Chromium Net可以帮助浏览器管理网络流量,包括请求和响应的缓存、重定向、流量控制等。
  5. 数据压缩: 它支持数据压缩,以减少传输的数据量,提高网络性能。
  6. HTTP缓存: Chromium Net包括HTTP缓存管理,以在本地存储和管理已下载的资源,以减少重复下载和提高页面加载速度。

Chromium Net是Chromium项目中的一个关键组件,它使得Chrome浏览器能够在各种网络条件下提供出色的性能和安全性。除了在Chrome浏览器中使用外,Chromium Net的一些部分也可以在其他项目中使用,以处理网络通信。它的开源性质使得开发人员可以查看其源代码并进行自定义,以满足特定需求。需要注意的是,Chromium Net是一个复杂的网络堆栈,需要深入的专业知识来理解和配置。

如何使用Cronet发起请求

Cronet提供两种发起请求的方式,异步和同步。

  1. 异步

val requestBuilder = cronetEngine.newUrlRequestBuilder(url, callback, executorService)
requestBuilder.build().start()
2. ### 同步

val connection = engine.openConnection(url)
URL.setURLStreamHandlerFactory(engine.createURLStreamHandlerFactory())

跟代码

  1. JAVA 层

本文将跟着异步调用的方式跟着代码看网络请求大致流程,所以我们跟代码的入口是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
csharp复制代码CronetUrlRequest.start()

//CronetUrlRequest.java
@Override
public void start() {
synchronized (mUrlRequestAdapterLock) {
...
startInternalLocked();
}
}

private void startInternalLocked() {
CronetUrlRequestJni.get().start(mUrlRequestAdapter, CronetUrlRequest.this);
}

//CronetUrlRequestJni.class
public void start(long nativePtr, CronetUrlRequest caller) {
GEN_JNI.org_chromium_net_impl_CronetUrlRequest_start(nativePtr, caller);
}
  1. cronet_url_request.cc

从上面的流程可以看到,java 层就是纯粹的包装层,C++层的入口是 cronet_url_request.cc#start(),那我们直接去看这个文件。

1
2
3
4
5
6
7
8
9
10
less复制代码//cronet_url_request.cc
void CronetURLRequest::Start() {
DCHECK(!context_->IsOnNetworkThread());
context_->PostTaskToNetworkThread(
FROM_HERE,
base::BindOnce(&CronetURLRequest::NetworkTasks::Start,
base::Unretained(&network_tasks_),
base::Unretained(context_), initial_method_,
std::move(initial_request_headers_), std::move(upload_)));
}

实际上逻辑是在 NetworkTasks 中

1
2
3
4
5
6
7
8
9
10
11
12
13
rust复制代码//CronetRequest::NetworkTasks
void CronetURLRequest::NetworkTasks::Start(
CronetURLRequestContext* context,
const std::string& method,
std::unique_ptr<net::HttpRequestHeaders> request_headers,
std::unique_ptr<net::UploadDataStream> upload) {
url_request_ = context->GetURLRequestContext()->CreateRequest(
initial_url_, net::DEFAULT_PRIORITY, this, MISSING_TRAFFIC_ANNOTATION);
//... 设置一堆请求头、优先级之类的东西
url_request_->set_method(method);
//...
url_request_->Start();
}

从上面这个函数体来看,cronet 的 C++ 层代码跑到这里基本流程就结束了,后续流程嫁接进 chromuim-net 模块了,先通过 url_request_context 构造 url_request,再调用 url_request 的 Start() 方法发起请求。

  1. net/url_request/url_request_context.cc

构造 url_request

1
2
3
4
5
6
7
8
9
10
csharp复制代码//net/url_request/url_request_context.cc
std::unique_ptr<URLRequest> URLRequestContext::CreateRequest(
const GURL& url,
RequestPriority priority,
URLRequest::Delegate* delegate,
NetworkTrafficAnnotationTag traffic_annotation,
bool is_for_websockets) const {
return base::WrapUnique(new URLRequest(
url, priority, delegate, this, traffic_annotation, is_for_websockets));
}
  1. net/url_request/url_request.cc

调用 url_request 的 Start() 函数进入 chromium-net 的核心网络请求流程。

1
2
3
4
5
6
7
8
9
scss复制代码//net/url_request/url_request.cc
void URLRequest::Start() {
//... 检查一堆东西
load_timing_info_ = LoadTimingInfo();
load_timing_info_.request_start_time = response_info_.request_time;
load_timing_info_.request_start = base::TimeTicks::Now();
//... 省去network_delegate逻辑
StartJob(context_->job_factory()->CreateJob(this));
}

StartJob(context_->job_factory()->CreateJob(this))这一句核心代码包含两个元素,一个是通过 URLRequestJobFactory 构造一个 URLRequestJob,然后 startJob,这两个类所在的文件是:

  • net/url_request/url_request_job_factory.h
  • net/url_request/url_request_job.h

在继续往底下深入看之前,用我们自己的思路大概猜一下,URLRequestJob 是单个网络请求任务的包装类,内部包含着各个阶段的流转,而 URLRequest 是 job 的外观类。我们继续看 StartJob 函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
scss复制代码//net/url_request/url_request.cc
void URLRequest::StartJob(std::unique_ptr<URLRequestJob> job) {
//跟上面一样的,向 job 设置一堆东西,同时设置进去一堆 callback
job_ = std::move(job);
job_->SetExtraRequestHeaders(extra_request_headers_);
job_->SetPriority(priority_);
job_->SetRequestHeadersCallback(request_headers_callback_);
job_->SetEarlyResponseHeadersCallback(early_response_headers_callback_);
job_->SetResponseHeadersCallback(response_headers_callback_);

//... 省略

// Start() always completes asynchronously.
//
// Status is generally set by URLRequestJob itself, but Start() calls
// directly into the URLRequestJob subclass, so URLRequestJob can't set it
// here.
status_ = ERR_IO_PENDING;
job_->Start();
}

这里可以看到,当 URLRequestJob start 后,URLRequest 的状态变成了 ERR_IO_PENDING,这是 chromium-net里到处都能见到的状态机流转来实现异步等待的方式,有许多地方设置状态,状态机收到状态后做相应的响应,ERR_IO_PENDING 的状态说明当前线程已经 pending 了,需要等待 IO 线程执行完任务后解除 pending 状态。

  1. net/url_request/url_request_job.h

那我们继续往下看,目前跟到了 URLRequestJob 的 start 方法了

1
2
3
4
5
6
7
csharp复制代码// net/url_request/url_request_job.h

// If any error occurs while starting the Job, NotifyStartError should be
// called asynchronously.
// This helps ensure that all errors follow more similar notification code
// paths, which should simplify testing.
virtual void Start() = 0;

URLRequestJob::Start()方法是个虚方法,是由子类实现具体的方法体的,因为我们重点关注 http 的请求,所以 直接去 url_request_http_job 里看 http 协议相关实现即可

  1. net/url_request/url_request_http_job.cc

直接看 Start() 方法

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
scss复制代码//net/url_request/url_request_http_job.cc
void URLRequestHttpJob::Start() {
//给 HttpRequestInfo 设置一堆东西
request_info_.url = request_->url();
request_info_.method = request_->method();
//...省略一堆
//注入 header
AddExtraHeaders();
//注入 cookie 并且开始请求
AddCookieHeaderAndStart();
}

//net/url_request/url_request_http_job.cc
void URLRequestHttpJob::AddCookieHeaderAndStart() {
//省略一堆东西,如果是需要异步拿 Cookie 的话,拿完 Cookie 后,入口会变成 SetCookieHeaderAndStart
StartTransaction();
}
//net/url_request/url_request_http_job.cc
void URLRequestHttpJob::StartTransaction() {
//省略network_delegate逻辑
StartTransactionInternal();
}

//net/url_request/url_request_http_job.cc
void URLRequestHttpJob::StartTransactionInternal() {
//省略一堆transaction_已经存在,走 restart 的逻辑,这里会去通过 http_transaction_factory 创建 HttpTransaction
//这里的 http_transaction_factory 是 HttpNetworkLayer,transaction 是 HttpNetworkTransaction
rv = request_->context()->http_transaction_factory()->CreateTransaction(
priority_, &transaction_);
//省略一堆 wss:// 和 wso://逻辑
if (rv == OK) {
// 给 transation 注入一堆 callback,
transaction_->SetConnectedCallback(base::BindRepeating(
&URLRequestHttpJob::NotifyConnectedCallback, base::Unretained(this)));
transaction_->SetRequestHeadersCallback(request_headers_callback_);

rv = transaction_->Start(
&request_info_,
base::BindOnce(&URLRequestHttpJob::OnStartCompleted,
base::Unretained(this)),
request_->net_log());
}
//...省略对外部的回调
}
  1. net/http/http_network_transaction.cc

那我们继续看 HttpNetworkTransaction 的 Start() 入口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
scss复制代码//net/http/http_network_transaction.cc
int HttpNetworkTransaction::Start(const HttpRequestInfo* request_info,
CompletionOnceCallback callback,
const NetLogWithSource& net_log) {
//...
session_->GetSSLConfig(&server_ssl_config_, &proxy_ssl_config_);
//...
next_state_ = STATE_NOTIFY_BEFORE_CREATE_STREAM;
int rv = DoLoop(OK);
if (rv == ERR_IO_PENDING)
callback_ = std::move(callback);
// This always returns ERR_IO_PENDING because DoCreateStream() does, but
// GenerateNetworkErrorLoggingReportIfError() should be called here if any
// other net::Error can be returned.
DCHECK_EQ(rv, ERR_IO_PENDING);
return rv;
}

这里出现了上面我们说过的状态机,通过设置 next_state_(即下一步动作), DoLoop 转动状态轮盘,执行下一步任务,这里下一步的状态是准备创建 stream,stream 是请求流,可以理解为服务器和客户端 TCP 连接内用于交换帧数据的独立双向序列,如果协议是 http1.0,那么一个 TCP 连接只有一个 stream,如果是 http2,由于多路复用的特性,多个 stream 可以共用一个 TCP 连接。 既然这里已经走到了创建 stream 的状态了,大概也可以猜到,网络请求的状态流转就是依赖HttpNetworkTransaction 中的状态机,这有点像是 OKHttp 中的 InterceptorChain,一个是通过状态流转,一个是链式推进,那我们看看 HttpNetworkTransaction::DoLoop 函数究竟长啥样。

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
ini复制代码//net/http/http_network_transaction.cc
int HttpNetworkTransaction::DoLoop(int result) {
DCHECK(next_state_ != STATE_NONE);
int rv = result;
do {
State state = next_state_;
next_state_ = STATE_NONE;
switch (state) {
case STATE_NOTIFY_BEFORE_CREATE_STREAM:
//准备创建 stream,直接下一状态
rv = DoNotifyBeforeCreateStream();
break;
case STATE_CREATE_STREAM:
//创建 stream,整个过程包括从 SocketPool 分配 Socket、握手、建连过程,这里需要异步等待
//因此 DoCreateStream 方法会返回 ERR_IO_PENDING 暂时退出循环
//同时,http_network_transaction 实现了HttpStreamRequest::Delegate,即成功/失败后的 callback
//等到 HttpStream 创建完成后,会回调实现的HttpStreamRequest::Delegate::OnStreamReady 方法
//在 OnStreamReady方法里,调用OnIOComplete,继续 DoLoop,进入下一个状态
//注意,这里的 HttpStream 只是持有了 Socket,并没有在这个 Socket 上初始化 stream
rv = DoCreateStream();
break;
case STATE_CREATE_STREAM_COMPLETE:
//创建 HttpStream 结束,如果是 H2,是 SpdyHttpStream,如果是 QUIC,是 QuicHttpStream
rv = DoCreateStreamComplete(rv);
break;
case STATE_INIT_STREAM:
//构造SpdySession,SpdySession 将持有 SessionSocket
//通过 SpdySession 构造 SpdyStream,这个类将直接操作 SpdyBuffer(IOBuffer)
//用来读写字节流,所以这里的 initStream 类似于
//BufferedInputStream bis = new BufferedInputStream(socket.getInputStream());
rv = DoInitStream();
break;
case STATE_INIT_STREAM_COMPLETE:
//SpdyStream 创建完成后,回调上层,连接成功了,在这个函数里会 invoke connected_callback_ 进入下一状态
rv = DoInitStreamComplete(rv);
break;
case STATE_CONNECTED_CALLBACK_COMPLETE:
//connected_callback_ 被调用了,这个函数内只改了next_state_ 继续loop
rv = DoConnectedCallbackComplete(rv);
break;
case STATE_GENERATE_PROXY_AUTH_TOKEN:
//如果没有 http proxy,会直接下一状态
rv = DoGenerateProxyAuthToken();
break;
case STATE_GENERATE_PROXY_AUTH_TOKEN_COMPLETE:
//这个函数内只改了next_state_ 继续loop
rv = DoGenerateProxyAuthTokenComplete(rv);
break;
case STATE_GENERATE_SERVER_AUTH_TOKEN:
//如果请求不是 PRIVACY_MODE_DISABLE,就会直接跳过,是的话,就会生成 auth token
//如果开启了 privacy mode,server 将无法追踪到请求来源
rv = DoGenerateServerAuthToken();
break;
case STATE_GENERATE_SERVER_AUTH_TOKEN_COMPLETE:
//这个函数内只改了next_state_ 继续loop
rv = DoGenerateServerAuthTokenComplete(rv);
break;
case STATE_INIT_REQUEST_BODY:
//post、put、patch、delete 等 method 需要上传 requestBody 的 case 才会走到
//里面会调用 upload_data_stream->Init 来 init request body
//upload_data_stream 是 java 层通过 requestBuilder.setUploadDataProvider() 传入并在合适时机创建出来的的
rv = DoInitRequestBody();
break;
case STATE_INIT_REQUEST_BODY_COMPLETE:
//这个函数内只改了next_state_ 继续loop
rv = DoInitRequestBodyComplete(rv);
break;
case STATE_BUILD_REQUEST:
//构建request_headers_ 和 body
rv = DoBuildRequest();
break;
case STATE_BUILD_REQUEST_COMPLETE:
//这个函数内只改了next_state_ 继续loop
rv = DoBuildRequestComplete(rv);
break;
case STATE_SEND_REQUEST:
//1. 设置请求幂等性 - idempotency
//2. 发送请求,调用SpdyStream::SendRequest 来发送请求头和数据,同时传入io_callback,随后进入 ERR_IO_PENDING 状态
//SendRequest 最终会调用到 SpdySession::EnqueueStreamWrite 来写入数据,当数据写入后,如果仍然有数据未写入
//会在 OnDataSent 中,调用 QueueNextDataFrame 来入队下一个data frame
//等到数据全部发送完成后,会回调io_callback,解除 pending 状态
rv = DoSendRequest();
break;
case STATE_SEND_REQUEST_COMPLETE:
//如果写入失败,则返回 IOError,否则 next_state_=STATE_READ_HEADERS,继续 loop
rv = DoSendRequestComplete(rv);
break;
case STATE_READ_HEADERS:
// 会立马调用 ReadResponseHeaders,调用这个方法后,默认会进入 ERR_IO_PENDING 状态,等待 IO 返回
// 同时会将 io_callback 继续传入,等待 readHeader 结束后解除 pending 状态
rv = DoReadHeaders();
break;
case STATE_READ_HEADERS_COMPLETE:
//会检查 read header的结果,检查是否有 io error,以及检查状态码、content-encoding
rv = DoReadHeadersComplete(rv);
break;
case STATE_READ_BODY:
//当读完 header 后,data 部分就开始读取
rv = DoReadBody();
break;
case STATE_READ_BODY_COMPLETE:
rv = DoReadBodyComplete(rv);
break;
case STATE_DRAIN_BODY_FOR_AUTH_RESTART:
rv = DoDrainBodyForAuthRestart();
break;
case STATE_DRAIN_BODY_FOR_AUTH_RESTART_COMPLETE:
rv = DoDrainBodyForAuthRestartComplete(rv);
break;
default:
NOTREACHED() << "bad state";
rv = ERR_FAILED;
break;
}
} while (rv != ERR_IO_PENDING && next_state_ != STATE_NONE);

return rv;
}

从上面代码中,我们可以看到,HttpNetworkTransaction 中的状态机从初始的STATE_NOTIFY_BEFORE_CREATE_STREAM 状态开始轮转,逐个状态往下推进,最终完成了整个网络请求。代码中的每个状态在代码中的顺序,就是一个普通网络请求的生命周期,上一个状态执行完后,只要不是 IO_PENDING,在 Doxxx 中总会指定下一状态,并且继续 loop,从而执行下一状态的对应工作。

总结

我们的这一期分享中,并没有深入细节,只是从比较高的视野去看整体的流程,后续会继续深入细节处,分享更加细节的chromium-net相关知识点,包括但不仅限于:

  1. socket pool 和 OKHttp 的ConnectionPool 异同
  2. cache 控制
  3. 设计模式
  4. …

受限于本人有限的水平,文中必然有描述错误或者理解错误的地方,如看到请指正。

你可能感兴趣

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

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

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

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

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

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

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

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

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

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

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

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

本文转载自: 掘金

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

Formily 在古茗工单系统中的实践落地

发表于 2023-09-04

温馨Tips:阅读本文大约需要 7 分钟,可以细细阅读~

背景

古茗是茶饮连锁品牌,所以我们会有很多业务围绕着加盟商展开,比如供应链、报货、财务、机料、上传下达等等,在这不一一列举了,前面的可能比较好理解,那上传下达是什么呢?其实就是古茗总部和门店沟通、信息传达的一种方式,本文就围绕着上传下达这个业务展开给大家介绍下我们技术实现的演进和落地。

上传下达

上传下达,分为门店上传和总部下达:

  • 门店上传:简单理解就是门店给总部反馈消息、申请售后等等的渠道;
  • 总部下达:简而言之是总部给门店下发的消息、任务、公告等等;

门店上传的主要承载形式是工单,但上传的工单的来源还有其他,比如人工客服;总部下达是总部下发给门店的任务,其实交互形式也类似工单,我们在业务上没有给他划分到工单,但在前端来说技术方案其实是相通的。

yuque_diagram.jpg

工单系统的演进

yuque_diagram (1).jpg

在最开始时期简单粗暴:来一工单撸一个页面;但业务在发展,面对越来越多工单这种方式已经满足不了诉求,而且开发效率低,维护困难。

接下来我们探索了表单模型描述+渲染的方案,表单模型在三方平台简道云进行维护,简道云提供了可视化搭建表单的能力,前端获取到表单模型描述后在我们的页面进行渲染,但这种方案也逐步暴露了缺陷:

  1. 简道云表单模型对于前端来说很冗余,比如说简道云表单模型会有几十个字段,但需要用户填写的可能只需要几个字段。
  2. 表单校验麻烦。
  3. 简道云表单模型描述不了联动,比如在选择A时C字段显示,在选择B时C字段隐藏。
  4. 前端实现渲染不优雅,会有很多if else的逻辑。

所以我们需要一个支持联动、高性能、校验能力强、优雅的表单解决方案,这时候Formily进入了我们的视野,经过调研发现Formily完全满足我们的诉求,所以我们目前的表单解决方案就是Formily,并由Formily为核心打造了古茗的工单开发生态。

Formily工单生态

Formily

Formily 是一个数据+协议驱动的表单解决方案,构建了从基础表单到低代码领域的高性能通用基础能力,JSON Schema独立存在,给UI桥接层消费,保证了协议驱动在不同UI框架下的绝对一致性,不需要重复实现协议解析逻辑,同时其配套的跨框架+跨终端组件生态体系,也能让开发人员更高效的开发日常业务表单,尽可能的减少了重复冗余的逻辑实现。

核心优势
  • 高性能
  • 开箱即用
  • 联动逻辑实现高效
  • 跨端能力,逻辑可跨框架,跨终端复用
  • 动态渲染能力
核心劣势
  • 学习成本较高,虽然 2.x 已经在大量收敛概念,但还是存在一定的学习成本。

image.png

技术栈选型

从上面Formily的分层架构图上可以看出,我们首先需要选择UI桥接层和拓展组件库,UI桥接层我们选择了React,拓展组件库在中后台选择了Antd,在B端小程序出于我们使用Taro的解决方案考虑最开始选择的是Taro UI,但在使用中发现Taro UI并不是特别易用,加之为了满足设计团队的诉求和统一B端设计交互,我们前端团队自研了组件库Gudesion,然后把Formily的拓展组件库迁移到了Gudesion。

下面是组件库文档截图(如果大家感兴趣,可以邀请Gudesion作者做后续分享)

image (1).png

对接拓展组件库(Gudesign)

Formily文档:react.formilyjs.org/zh-CN/api/s…

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
typescript复制代码import { connect, mapReadPretty, mapProps } from '@formily/react';
import { getPrefixCls } from '@guming/formily-shared';
import { Input as GudInput, InputProps, TextareaProps } from '@guming/gudesign';
import cls from 'classnames';
import React from 'react';
import { PreviewText } from '../preview-text';
import { getFinallyLayout } from '../shared';

import './index.less';

export type FGInputProps = Omit<InputProps, 'required' | 'label' | 'defaultValue' | 'value'>;
export type FGTextAreaProps = Omit<TextareaProps, 'required' | 'defaultValue' | 'value'>;

export const Input: React.FC<FGInputProps> & {
TextArea?: React.FC<FGTextAreaProps>;
} = connect(
GudInput,
mapProps((props, field) => {
return {
...(getFinallyLayout(field.componentType, field.decoratorProps.layout) === 'column'
? { align: 'left' }
: { align: 'right' }),
placeholder: '请输入',
...props,
className: cls(`${getPrefixCls('comp-input')}`, props.className),
// 不需要以下属性
required: undefined,
label: undefined,
defaultValue: undefined,
} as InputProps;
}),
mapReadPretty(PreviewText.Input)
);

export default Input;

表单配置平台

上面介绍了Formily是一个数据+协议驱动的表单解决方案,所以我们也搭建了一个配置JSON Schema的表单描述配置平台。JSON Schema的配置方式可以查阅Formily官网文档:react.formilyjs.org/zh-CN/api/s…

image (2).png

业务场景落地

基础依赖工作准备好了,那想要具体在业务中去使用,还需要做什么呢?以下以总部给门店下发任务为例,列举一个简单的场景:

我们需要收集门店门头尺寸的情况,以便在给门店在制定菜单、宣传横幅等时能更加清晰明确统计各种尺寸的数量。

首先配置JSON Scheme,然后对端渲染成表单,让门店能填写和提交表单;

然后下发给门店的任务,不仅需要需要考虑是否每个门店都收到任务,还需要保证数据的完整性、正确性,所以还需要引入流程节点的能力;表现就是门店填写完任务表单后,会有一个角色来对门店提交的数据做正确性审核,以及督促门店及时完成任务,在古茗这个岗位叫督导,负责片区门店的管理、监督工作;当然其他可能还有其他后续节点,比如财务等等。

有了流程节点后,在每个流程节点内,可能还需要支持某些表单额外的诉求,举个简单例子,在门店节点时,有一个字段不需要门店填写,那就需要隐藏这个字段;然后到了督导审核的节点,这个字段需要展示给督导,那么就需要显示。

所以我们设计了在下发的任务工单的数据结构:

  1. 表单的JSON Schema描述:用于渲染表单。
  2. 表单数据:用于初始化表单和表单提交后记录表单数据详情。
  3. 节点定义: 判断工单处于什么流程,门店节点(SHOP_TASK)、督导节点(SUPERVISOR_TASK)、财务节点等等。
  4. 节点属性
    • 流程处理逻辑
    • 节点的自定义处理逻辑
    • 节点操作的Hook
    • 节点上下文
表单的数据结构
1
2
3
4
5
6
7
8
9
10
11
12
13
arduino复制代码{
jsonSchema // 表单描述
fromData // 表单数据
effects: {
nodeA: { // 节点,门店\督导
hook // 自定义处理逻辑
action // 流程处理
scope // js block 需要的上下文字段
beforeSubmit // 表单提交前勾子
afterSubmit // 表单提交后勾子
}
}
}
effects配置
  • hook

image (3).png

执行流程

image (4).png

解析器

effects里面存在着自定义逻辑处理、流程处理、勾子,这些代码在给到表单渲染时都是字符串,那么怎么执行呢?我们第一反应肯定是想到eval、new Function,但由于小程序安全机制「小程序对动态执行脚本的限制」,不支持eval、new Function这些常用的字符串表达式执行方式

image (5).png

所以我们需要探索能执行字符串表达式的方法:

老方案

最开始我们需要支持的能力较简单时,比如解析单行表达式,

image (6).png

首先会获取到表达式,$deps[0]为Formily的内置表达式作用域

1
bash复制代码$deps[0] === '3' ? 'visible' : 'none'
  • 解析执行
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
javascript复制代码import { Parser } from 'acorn';

export function CustomCompiler(input, scope) {
const parseNode = Parser.parse(input, {
ecmaVersion: 'latest',
locations: false,
});
const targetNode = parseNode?.body?.[0];

if (!targetNode) return false;

const customParser = new CustomParse({ node: targetNode, scope, input });
const result = customParser.execute();

return result;
}
  • Parse:astexplorer.net/

image (7).png

  • CustomParse类(长度原因,省略了代码)
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
javascript复制代码import { PARSE_FN_MAP } from './config';

export class CustomParse {
constructor(props) {
const { node, scope, input } = props;
this.node = node;
this.scope = scope;
this.input = input;
this.nodeType = node.expression.type;
}
// a ? b : c
parseConditional(options) {
const {
currentNode: { test, consequent, alternate },
} = options;
const testVal = this[PARSE_FN_MAP[test.type]]({ currentNode: test });
const consequentVal = this[PARSE_FN_MAP[consequent.type]]({ currentNode: consequent });
const alternateVal = this[PARSE_FN_MAP[alternate.type]]({ currentNode: alternate });

return testVal ? consequentVal : alternateVal;
}
// 1
parseLiteral() {}
// a
parseIdentifier() {}
// a.b.c
parseMember() {}
// !a
parseUnary() {}
// a && b ...
parseLogical() {}
// a === b ...
parseBinary() {}
// a.b()
parseCall() {}
// a?.b || a?.b()
parseChain() {}

execute() {
const parseOptions = { currentNode: this.node.expression };
const fnName = PARSE_FN_MAP[this.nodeType];
if (!fnName) return;
return this[fnName](parseOptions);
}
}
  • 配置
1
2
3
4
5
6
7
8
9
10
11
arduino复制代码export const PARSE_FN_MAP = {
Identifier: 'parseIdentifier',
MemberExpression: 'parseMember',
LogicalExpression: 'parseLogical',
BinaryExpression: 'parseBinary',
Literal: 'parseLiteral',
ConditionalExpression: 'parseConditional',
UnaryExpression: 'parseUnary',
CallExpression: 'parseCall',
ChainExpression: 'parseChain',
};
目前方案

但当需要支持越来越复杂的表达式(支持多行语句、支持ES6等等)时我们就去社区拥抱了开源

1
2
3
4
5
6
7
ini复制代码export function customCompiler(input, scope) {
const sandbox = new Sandbox();
const exec = sandbox.compile(`return ${input}`);
const result = exec({ ...scope, dayjs }).run();

return result;
}
  • 用于解析执行json schema配置内的表达式
1
2
3
javascript复制代码import { Schema } from '@formily/react';

Schema.registerCompiler(customCompiler);
  • 用于解析执行effects内方法
1
2
3
4
5
6
css复制代码import { customCompiler } from '@guming/***';
customCompiler(effects?.[nodeId]?.hook, {
form: f,
...basicEffectsParams,
...(effects?.[nodeId]?.scope || {}),
});

但是目前的方式也有缺陷,上下文不稳定,所以大家有知道更好的开源解析器的话可以推荐给我们,感激不尽;

推荐

如果对解析器感兴趣的这里推荐几篇文章,这里不做展开了:

github.com/peakchen90/…

github.com/jamiebuilds…

预览

表单渲染效果如图

image (8).png

未来

目前的表单配置还没有可视化搭建能力,所以都是由我们前端开发同学手动编写JSON schema,由于Formily支持的能力很强大,有很多配置,再加上组件支持的属性配置,就会导致手工配置一个表单json schema上手有难度,而且容易出错,会耗费大量的时间,更主要的业务方不能脱离技术独立使用,所以团队的同学已经着手支持配置平台可视化搭建能力。

如下图,主体分为左中右三块,左边的组件和右边的组件配置都是通过读取Gudesign组件库和自定义组件的typescript配置动态生成,保证了表单和组件能力的一致性;应该不久后就会上线交付业务方使用(如果大家感兴趣,也可以邀请作者做后续分享)

image (9).png

总结

Formily在性能、低代码、跨端、联动上的能力给我们业务带来了很大的助力,如果你的业务也急需这些能力,不妨一试,还是很值得推荐的。

当然Formily不是一本万利,引入Formily也带来了其他问题,比如Formily的理解成本和上手难度比其他方案更高,当json schema越来越多、越来越大时带来的的维护成本也会更高,简单业务场景的ROI可能不高。

最后叠下甲,我们的方案可能不是最完美的,只是目前适合我们当前的业务,我们会随着业务发展逐步探索更优的解决方案,大家如果有好的想法也可以沟通交流讨论。

最后

关注公众号「Goodme前端团队」,获取更多干货实践,欢迎交流分享~

本文转载自: 掘金

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

结合 OpenCore 分析安卓应用程序 Crash 问题

发表于 2023-08-30

关于 OpenCoreSDK

详情可参考上一篇文章 Android 应用程序如何抓取 Coredump

配置 Coredump

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
java复制代码public class App extends Application {

@Override
protected void attachBaseContext(Context base) {
super.attachBaseContext(base);

Coredump.getInstance().init();
Coredump.getInstance().setCoreDir(getExternalFilesDir(null).getAbsolutePath());
Coredump.getInstance().setCoreMode(Coredump.MODE_COPY | Coredump.MODE_PTRACE);
//Coredump.getInstance().setCoreMode(Coredump.MODE_COPY);
//Coredump.getInstance().setCoreMode(Coredump.MODE_PTRACE);
Coredump.getInstance().enable(Coredump.JAVA);
Coredump.getInstance().enable(Coredump.NATIVE);
}
}

测试 Java OOM 场景

1
2
3
4
5
6
7
8
9
java复制代码public class LeakMemory {
private byte[] data = new byte[65536];

public LeakMemory() {
for (int i = 0; i < data.length; i++) {
data[i] = (byte) 0xaa;
}
}
}
1
2
3
4
5
6
7
8
9
10
11
java复制代码void doOOM(View view) {
ArrayList<LeakMemory> array = new ArrayList<LeakMemory>();
new Thread(new Runnable() {
@Override
public void run() {
while (true) {
array.add(new LeakMemory());
}
}
}).start();
}

当我们点击 “doOOM” 的按钮时,程序会不停的创建一个 LeakMemory 对象,直到程序发生 OOM Crash,当发生 Java Crash 时,会被 Opencore 拦截触发抓取 Coredump。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
java复制代码AndroidRuntime: java.lang.OutOfMemoryError: Failed to allocate a 65552 byte allocation with 63712 free bytes and 62KB until OOM, target footprint 268435456, growth limit 268435456
AndroidRuntime: at penguin.opencore.coretester.LeakMemory.<init>(LeakMemory.java:4)
AndroidRuntime: at penguin.opencore.coretester.MainActivity$2.run(MainActivity.java:55)
AndroidRuntime: at java.lang.Thread.run(Thread.java:1012)

Opencore-arm64: Wait (19990) coredump
Opencore-arm64: Coredump /storage/emulated/0/Android/data/penguin.opencore.coretester/files/core.19935 ...
Opencore-arm64: WriteCoreLoadSegment Mode(3)
Opencore-arm64: [0x5ffff08000] /mali csf (deleted) Not in self.
Opencore-arm64: [0x5ffff1b000] /mali csf (deleted) Not in self.
Opencore-arm64: [0x5ffff2e000] /mali csf (deleted) Not in self.
Opencore-arm64: [0x5ffff41000] /mali csf (deleted) Not in self.
Opencore-arm64: [0x5ffff54000] /mali csf (deleted) Not in self.
Opencore-arm64: [0x7c98a00000] /memfd:jit-cache (deleted) Not in self.
Opencore-arm64: [0x7c9dde0000] /memfd:jit-cache (deleted) Not in self.
Opencore-arm64: Coredump Done.
Opencore: /storage/emulated/0/Android/data/penguin.opencore.coretester/files/core.19935

我们即可拿个到该 OOM 问题的内存现场,那么我们可以进行离线内存分析,对于 ART 虚拟机的应用程序,我们有专门的分析工具可以辅助。类似的也可用 GDB、LLDB 来完成。

解析 Java 堆栈定位 Execption 类型。

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
java复制代码art-parser> bt 19987
"Thread-4" prio=0 tid=2 Waiting
| group="" sCount=0 ucsCount=0 flags=0 obj=0x12c80f10 self=0x7d57f78400
| sysTid=19987 nice=<unknown> cgrp=<unknown> sched=<unknown> handle=0x7c9dbdfcb0
| stack=0x7c9dadc000-0x7c9dade000 stackSize=0x103cb0
| held mutexes=
x0 0x0000007c185a5db0 x1 0x0000000000000080 x2 0x0000000000000000 x3 0x0000000000000000
x4 0x0000000000000000 x5 0x0000000000000000 x6 0x0000000000000000 x7 0x0000007c9dbddf60
x8 0x0000000000000062 x9 0x4a022fd738159c35 x10 0x0000000000000003 x11 0x0000000000000002
x12 0x0000007c185ad9fc x13 0x000000004cec4ec5 x14 0x0000007c9dbde7d0 x15 0x0000000000000004
x16 0x0000007cb360f7c8 x17 0x0000007d4a94b200 x18 0x0000007c39c64000 x19 0x0000007c185a5da0
x20 0x0000007d57f78400 x21 0x0000007c185a5db0 x22 0x0000000000000000 x23 0x0000000000000000
x24 0x0000007c187f1040 x25 0x0000007c9dbe0000 x26 0x0000000000000001 x27 0x0000007d57feec20
x28 0x0000007d57feebe0 x29 0x0000007c9dbde5a0
lr 0x0000007cb3081f6c sp 0x0000007c9dbde590 pc 0x0000007d4a94b21c pst 0x0000000060001000
FP[0x7c9dbde5a0] PC[0x7d4a94b21c] native: #00 (syscall+0x1c) /apex/com.android.runtime/lib64/bionic/libc.so
FP[0x7c9dbde5a0] PC[0x7cb3081f6c] native: #01 (art::ConditionVariable::WaitHoldingLocks(art::Thread*)+0x8c) /apex/com.android.art/lib64/libart.so
FP[0x7c9dbde630] PC[0x7cb3372eb4] native: #02 (art::Monitor::Wait(art::Thread*, long, int, bool, art::ThreadState)+0x274) /apex/com.android.art/lib64/libart.so
FP[0x7c9dbde6d0] PC[0x7cb33746ac] native: #03 (art::Monitor::Wait(art::Thread*, art::ObjPtr<art::mirror::Object>, long, int, bool, art::ThreadState)+0x12c) /apex/com.android.art/lib64/libart.so
QF[0x7c9dbde720] PC[0x0000000000] at dex-pc 0x0000000000 java.lang.Object.wait(Native method) //AM[0x706f1370]
- waiting on <0x12e52f78> (java.lang.Object)
QF[0x7c9dbde7d0] PC[0x7cb300a914] at dex-pc 0x7caeea69f2 java.lang.Object.wait //AM[0x706f1350]
QF[0x7c9dbde8c0] PC[0x7cb300a258] at dex-pc 0x7caeea69d8 java.lang.Object.wait //AM[0x706f1330]
QF[0x7c9dbde9a0] PC[0x7cb300a258] at dex-pc 0x7c9bac1d1a penguin.opencore.sdk.Coredump.waitCore //AM[0x7c9c5584e0]
QF[0x7c9dbdea80] PC[0x7cb300a258] at dex-pc 0x7c9bac1bf4 penguin.opencore.sdk.Coredump.doCoredump //AM[0x7c9c558400]
QF[0x7c9dbdeb50] PC[0x7cb300a258] at dex-pc 0x7c9bac1ace penguin.opencore.sdk.Coredump$JavaCrashHandler.uncaughtException //AM[0x7c9c5585a0]
QF[0x7c9dbdec30] PC[0x7cb300b078] at dex-pc 0x7caeeb1714 java.lang.ThreadGroup.uncaughtException //AM[0x70676b78]
QF[0x7c9dbded30] PC[0x7cb300a258] at dex-pc 0x7caeeb1700 java.lang.ThreadGroup.uncaughtException //AM[0x70676b78]
QF[0x7c9dbdee30] PC[0x7cb300b078] at dex-pc 0x7caeeb287e java.lang.Thread.dispatchUncaughtException //AM[0x707001e0]

展开寄存器查看 Java 函数调用参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
java复制代码...
QF[0x7c9dbdee30] PC[0x7cb300b078] at dex-pc 0x7caeeb287e java.lang.Thread.dispatchUncaughtException //AM[0x707001e0]
{
Virtual registers
{
v0 = 0x12c811f0 v1 = 0x70517d70 v2 = 0x12c80f10 v3 = 0x12cc0000
}
Physical registers
{
x19 = 0x7d57f78400 x20 = 0x0 x21 = 0x0 x22 = 0x7caeeb287e
x23 = 0x3072 x24 = 0x7cb3000880 x25 = 0x7c9dbdee58 x26 = 0x7064c9a0
x27 = 0x8 x28 = 0x7c9dbdee80 x29 = 0x7c9dbdee68 x30 = 0x7cb300b078
}
}

字节码反编译确定 Throwable 对象所在寄存器。

1
2
3
4
java复制代码art-parser> disassemble 0x707001e0 -i 0x7caeeb287e
void java.lang.Thread.dispatchUncaughtException(java.lang.Throwable) [dex_method_idx=3796]
DEX CODE:
0x7caeeb287e: 3072 0eba 0321 | invoke-interface {v1, v2, v3}, void java.lang.Thread$UncaughtExceptionHandler.uncaughtException(java.lang.Thread, java.lang.Throwable) // method@3770

解析 Throwable 错误信息。

1
2
3
4
5
6
7
8
9
10
11
12
java复制代码art-parser> dumpjava 0x12cc0000
Throwable java.lang.OutOfMemoryError
Failed to allocate a 65552 byte allocation with 63712 free bytes and 62KB until OOM, target footprint 268435456, growth limit 268435456
penguin.opencore.coretester.LeakMemory.<init>(LeakMemory.java:4)
penguin.opencore.coretester.MainActivity$2.run(MainActivity.java:55)
java.lang.Thread.run(Thread.java:1012)
Caused by:
Throwable java.lang.OutOfMemoryError
Failed to allocate a 65552 byte allocation with 63712 free bytes and 62KB until OOM, target footprint 268435456, growth limit 268435456
penguin.opencore.coretester.LeakMemory.<init>(LeakMemory.java:4)
penguin.opencore.coretester.MainActivity$2.run(MainActivity.java:55)
java.lang.Thread.run(Thread.java:1012)

统计 Java 堆内存对象占用的 ShallowSize 大小以及分配数量,可见 byte[] 数组占用了 200+M 内存。并与 LeakMemory 对象分配的数量高度吻合。

1
2
3
4
5
6
7
8
9
10
11
12
java复制代码art-parser> top 10 -a -d
Address Allocations ShallowSize NativeSize ClassName
0x705050f8 3820 249731739 0 byte[]
0x12c80f88 3805 45660 0 penguin.opencore.coretester.LeakMemory
0x70566f78 1753 561064 0 java.lang.String
0x705052b8 1029 60360 0 java.lang.Object[]
0x705bf568 761 15220 0 java.util.ArrayList
0x70507aa0 741 26676 0 sun.misc.Cleaner
0x7079f8e0 736 17664 0 libcore.util.NativeAllocationRegistry$CleanerThunk
0x70547510 681 608344 0 int[]
0x70547828 650 162232 0 long[]
0x70cac990 422 10128 0 android.graphics.Rect

解析每一个 LeakMemory 内存分布, 可确定 LeakMemory 对象均持有一个 byte[] 数组,并且每一个 byte[] 大小为 65552 字节, 因此 LeakMemory 对象存在泄露。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
java复制代码art-parser> p 0x12f69ec8 -r
Size: 0x10
Padding: 0x4
Object Name: penguin.opencore.coretester.LeakMemory
iFields of penguin.opencore.coretester.LeakMemory
[0x8] byte[] data = 0x86907000
iFields of java.lang.Object
[0x0] java.lang.Class shadow$_klass_ = 0x12c80f88
[0x4] int shadow$_monitor_ = 0x0
References:
>> 0x12f57108 java.lang.Object[]

art-parser> p 0x86907000
Size: 0x10010
Padding: 0x4
Array Name: byte[]
[0] 0xaa
[1] 0xaa
[2] 0xaa
...
[65535] 0xaa

由于 LeakMemory 被线程的局部变量 java.util.ArrayList 所持有,无法 GC 回收,因此造成 OOM。

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
java复制代码art-parser> p 0x12f57108
Size: 0x4120
Padding: 0x4
Array Name: java.lang.Object[]
[0] 0x12f5b228
[1] 0x12f5b238
...
[4163] 0x0
References:
>> 0x12c81078 java.util.ArrayList

art-parser> p 0x12c81078 -r
Size: 0x18
Padding: 0x4
Object Name: java.util.ArrayList
iFields of java.util.ArrayList
[0xc] java.lang.Object[] elementData = 0x12f57108
[0x10] int size = 0xedd
iFields of java.util.AbstractList
[0x8] int modCount = 0xedd
iFields of java.util.AbstractCollection
iFields of java.lang.Object
[0x0] java.lang.Class shadow$_klass_ = 0x705bf568
[0x4] int shadow$_monitor_ = 0x0
References:
>> 0x12c81090 penguin.opencore.coretester.MainActivity$2

art-parser> p 0x12c81090 -r
Size: 0x10
Object Name: penguin.opencore.coretester.MainActivity$2
iFields of penguin.opencore.coretester.MainActivity$2
[0x8] penguin.opencore.coretester.MainActivity this$0 = 0x12cac780
[0xc] java.util.ArrayList val$array = 0x12c81078
iFields of java.lang.Object
[0x0] java.lang.Class shadow$_klass_ = 0x12de6d50
[0x4] int shadow$_monitor_ = 0x0
References:
>> 0x12c80f10 java.lang.Thread

测试 Native Crash 场景

该测试用例在 Android12~Android13 之间使用,要看到这个 Native Crash 问题编译 apk 注意 debuggable 的条件,当 debuggable 为 false,此问题必现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
java复制代码public class NterpTester {

class Entry {}

private List<Entry> mList;

public void setList() {
mList = new ArrayList<>();
}

@Override
public String toString() {
return "Penguin." + mList;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
java复制代码void doTest(View view) {
Log.i("Opencore", "test");
new Thread(new Runnable() {
@Override
public void run() {
while(true) {
nterpTester.toString();
}
}
}).start();

new Thread(new Runnable() {
@Override
public void run() {
while (true) {
nterpTester.setList();
}
}
}).start();
}

当 debuggable 为 true 时,需做一些小小的 HOOK 处理才能复现,我们需要将 setList 这个 Java 函数运行方式修改为 Nterp 解析运行。

1
2
3
4
5
6
7
8
9
10
11
12
13
java复制代码void doHook(View view) {
Log.i("Opencore", "doHook");
try {
Method setListMethod = NterpTester.class.getMethod("setList", null);
Field artMethodField = Executable.class.getDeclaredField("artMethod");
artMethodField.setAccessible(true);
long artMethod = artMethodField.getLong(setListMethod);
Log.i("Opencore", artMethodField.getName() + " = 0x" + Long.toHexString(artMethod));
native_call(artMethod);
} catch (Exception e) {
e.printStackTrace();
}
}

强行修改 entry_point_from_quick_compiled_code_ 让其跳转函数到 ExecuteNterpImpl。

1
2
3
4
5
6
7
8
9
10
11
12
c++复制代码extern "C"
JNIEXPORT void JNICALL
Java_penguin_opencore_coretester_MainActivity_native_1call(JNIEnv *env, jobject thiz, jlong art_method_ptr) {
#if defined (__aarch64__) || defined (__arm64__)
ArtMethod *method = reinterpret_cast<ArtMethod *>(art_method_ptr);
void *art_load = read_module_address("/apex/com.android.art/lib64/libart.so");
int nterp_offset = read_symb_offset("/apex/com.android.art/lib64/libart.so", "ExecuteNterpImpl", STT_FUNC);
void* ExecuteNterpImpl = reinterpret_cast<void *>((uint64_t)art_load + nterp_offset);
LOGI("%p %p %p", method, art_load, ExecuteNterpImpl);
method->ptr_sized_fields_.entry_point_from_quick_compiled_code_ = ExecuteNterpImpl;
#endif
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
java复制代码Opencore-SDK: Init opencore environment..
Opencore: test
Opencore: doHook
Opencore: artMethod = 0x7c9c56ded0
Opencore: 57, 0000000000200090, 000000000005d34f, ExecuteNterpImpl, 0x7c9da1a82a, 0x7c9d10e000
Opencore: 0x7c9c56ded0 0x7cb2e00000 0x7cb3000090
Opencore-arm64: Wait (20399) coredump
Opencore-arm64: Coredump /storage/emulated/0/Android/data/penguin.opencore.coretester/files/core.20356 ...
Opencore-arm64: WriteCoreLoadSegment Mode(3)
Opencore-arm64: [0x5ffff08000] /mali csf (deleted) Not in self.
Opencore-arm64: [0x5ffff1b000] /mali csf (deleted) Not in self.
Opencore-arm64: [0x5ffff2e000] /mali csf (deleted) Not in self.
Opencore-arm64: [0x5ffff41000] /mali csf (deleted) Not in self.
Opencore-arm64: [0x5ffff54000] /mali csf (deleted) Not in self.
Opencore-arm64: [0x7c98a00000] /memfd:jit-cache (deleted) Not in self.
Opencore-arm64: [0x7c9d940000] [anon:dalvik-CompilerMetadata] Not in self.
Opencore-arm64: [0x7c9dde0000] /memfd:jit-cache (deleted) Not in self.
Opencore-arm64: Coredump Done.

可看到堆栈在 java 函数 java.lang.String.valueOf 处发生 Native Crash。

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
java复制代码art-parser> bt 20397 -adj
"Thread-4" prio=0 tid=29 Unknown
| group="" sCount=0 ucsCount=0 flags=4 obj=0x17200d10 self=0x7c162dac00
| sysTid=20397 nice=<unknown> cgrp=<unknown> sched=<unknown> handle=0x7c16fcacb0
| stack=0x7c16ec7000-0x7c16ec9000 stackSize=0x103cb0
| held mutexes= "mutator lock"
x0 0x00000000ffffffff x1 0x0000007d474c9b98 x2 0x0000000000000000 x3 0x0000000000000000
x4 0x0000007d474c82a0 x5 0x0000000000b0863a x6 0x0000007d474c9708 x7 0x706d756465726f63
x8 0x0000000000000104 x9 0x4a022fd738159c35 x10 0x0000000000000002 x11 0xfffffffffffffffd
x12 0x0000007d474c83c0 x13 0x0000000000000016 x14 0x0000007d474c9708 x15 0x000000ce9c37561b
x16 0x0000007d4a9cf298 x17 0x0000007d4a9a9010 x18 0x0000007c123ae000 x19 0x0000007d580029c0
x20 0x000000000000000b x21 0x0000007d474c9da0 x22 0x0000007d474c9e48 x23 0x0000007d572e86f8
x24 0x0000007c16fcb000 x25 0x0000007c16fcb000 x26 0x000000000000000b x27 0x0000007d572e8318
x28 0x0000000000000000 x29 0x0000007d474c9ca0
lr 0x0000007c9b084f80 sp 0x0000007d474c9b60 pc 0x0000007d4a9a9014 pst 0x0000000060001000
FP[0x7d474c9ca0] PC[0x7d4a9a9014] native: #00 (wait4+0x4) /apex/com.android.runtime/lib64/bionic/libc.so
FP[0x7d474c9ca0] PC[0x7c9b084f80] native: #01 (OpencoreImpl::DoCoreDump()+0x64) /data/app/~~AeyWfgE18eFG7ZKpLRTTqw==/penguin.opencore.coretester-7NCCv5-T4UIf09uA5DJyVA==/base.apk!/lib/arm64-v8a/libopencore.so
FP[0x7d474c9cf0] PC[0x7c9b082aa8] native: #02 (Opencore::HandleSignal(int)+0x88) /data/app/~~AeyWfgE18eFG7ZKpLRTTqw==/penguin.opencore.coretester-7NCCv5-T4UIf09uA5DJyVA==/base.apk!/lib/arm64-v8a/libopencore.so
FP[0x7d474c9d40] PC[0x7d572e31fc] native: #03 (art::SignalChain::Handler(int, siginfo*, void*)+0x14c) /apex/com.android.art/lib64/libsigchain.so
FP[0x7d474caff0] PC[0x7d594dd84c] native: #04 () [vdso]
<<maybe handle signal>>
x0 0x0000000000000000 x1 0x000000001409fec0 x2 0x000000001409fec0 x3 0x00000000172012be
x4 0x0000000014166e20 x5 0x0000000000000000 x6 0x00000000000011e0 x7 0x0000000000000010
x8 0x4a022fd738159c35 x9 0x4a022fd738159c35 x10 0x0000000000000000 x11 0x000000000000000b
x12 0x0000000014166d9c x13 0x0000000000000001 x14 0xfffffffffc000000 x15 0x0000000000000030
x16 0x0000007d5802af28 x17 0x0000000000000000 x18 0x0000007c123ae000 x19 0x0000007c162dac00
x20 0x0000000000000001 x21 0x0000000000000000 x22 0x000000001409fec0 x23 0x000000001409fec0
x24 0x000000001409fec0 x25 0x0000007c16fca940 x26 0x000000007064c218 x27 0x0000000000000004
x28 0x0000007c16fca950 x29 0x000000009cb5f9b0
lr 0x000000009cb5bd80 sp 0x0000007c16fca7b0 pc 0x000000009cb60ffc pst 0x0000000040001000
QF[0x7c16fca7b0] PC[0x009cb60ffc] at dex-pc 0x7caeeaebe0 java.lang.String.valueOf //AM[0x706ff3b8]
QF[0x7c16fca7e0] PC[0x009cb5bd80] at dex-pc 0x7caeeacb80 java.lang.StringBuilder.append //AM[0x706a2198]
QF[0x7c16fca830] PC[0x009cb6130c] at dex-pc 0x7c9be8cf22 penguin.opencore.coretester.NterpTester.toString //AM[0x7c9c56def0]
QF[0x7c16fca870] PC[0x009cb5f95c] at dex-pc 0x7c9be8cb94 penguin.opencore.coretester.MainActivity$3.run //AM[0x7c9c56e118]
QF[0x7c16fca920] PC[0x7cb300b078] at dex-pc 0x7caeeb2b60 java.lang.Thread.run //AM[0x70700400]

解析机器码可看到在指令 ldr x0,[x0, #192] 上发生段错误。此时 x0 实际上是 x1 对象的 klass_ 成员变量的值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
asm复制代码(gdb) x /32i 0x9cb60fb0
0x9cb60fb0: sub x16, sp, #0x2, lsl #12
0x9cb60fb4: ldr wzr, [x16]
0x9cb60fb8: str x0, [sp, #-48]!
0x9cb60fbc: stp x22, x30, [sp, #32]
0x9cb60fc0: str wzr, [sp, #28]
0x9cb60fc4: ldr w16, [x19]
0x9cb60fc8: tst w16, #0x7
0x9cb60fcc: b.ne 0x9cb61030 // b.any
0x9cb60fd0: mov x16, #0xaf28 // #44840
0x9cb60fd4: movk x16, #0x5802, lsl #16
0x9cb60fd8: movk x16, #0x7d, lsl #32
0x9cb60fdc: ldrb w17, [x16]
0x9cb60fe0: cbnz w17, 0x9cb6103c
0x9cb60fe4: cbnz w1, 0x9cb60ff4
0x9cb60fe8: ldr w0, 0x9cb61068
0x9cb60fec: mov x22, x1
0x9cb60ff0: b 0x9cb61008
0x9cb60ff4: mov x22, x1
0x9cb60ff8: ldr w0, [x1]
0x9cb60ffc: ldr x0, [x0, #192]
0x9cb61000: ldr x30, [x0, #24]
0x9cb61004: blr x30
0x9cb61008: mov x1, x0

而 x1 对象为 java.util.ArrayList, 并且最终内存转储后,klass_ = 0x705bf568,并非空指针。

1
2
3
4
5
6
7
8
9
10
11
12
13
java复制代码art-parser> p 0x000000001409fec0   
Size: 0x18
Padding: 0x4
Object Name: java.util.ArrayList
iFields of java.util.ArrayList
[0xc] java.lang.Object[] elementData = 0x7053e720
[0x10] int size = 0x0
iFields of java.util.AbstractList
[0x8] int modCount = 0x0
iFields of java.util.AbstractCollection
iFields of java.lang.Object
[0x0] java.lang.Class shadow$_klass_ = 0x705bf568
[0x4] int shadow$_monitor_ = 0x20000000

该问题这里不进行详细解答,感兴趣的可以私信我,该问主要原因在于 mList = new ArrayList<>(); 该代码为 Nterp 解释运行下的 new-instance 这条字节码身上。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
java复制代码art-parser> disassemble 0x7c9c56ded0 --full
void penguin.opencore.coretester.NterpTester.setList() [dex_method_idx=325]
DEX CODE:
0x7c9be8cf5c: 0022 0113 | new-instance v0, java.util.ArrayList // type@TypeIndex[275]
0x7c9be8cf60: 1070 0127 0000 | invoke-direct {v0}, void java.util.ArrayList.<init>() // method@295
0x7c9be8cf66: 105b 3261 | iput-object v0, v1, Ljava/util/List; penguin.opencore.coretester.NterpTester.mList // field@12897
0x7c9be8cf6a: 000e | return-void
OatQuickMethodHeader(0x7cb300008c)
code_offset: 0x7cb3000090
code_size: 0xf0b4
NterpMethodFrameInfo
frame_size_in_bytes: 0xd0
core_spill_mask: 0x7ff80000 (x19, x20, x21, x22, x23, x24, x25, x26, x27, x28, x29, x30)
fp_spill_mask: 0xff00 (x8, x9, x10, x11, x12, x13, x14, x15)
OAT CODE:
[0x7cb3000090-0x7cb300f144]

具体修复可对 mList 成员对象添加 volatile 修饰来避免,只有 Android14 之后才修复。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
asm复制代码// https://android-review.googlesource.com/c/platform/art/+/2583193

%def op_new_instance():
EXPORT_PC
// Fast-path which gets the class from thread-local cache.
% fetch_from_thread_cache("x0", miss_label="2f")
TEST_IF_MARKING 3f
4:
ldr lr, [xSELF, #THREAD_ALLOC_OBJECT_ENTRYPOINT_OFFSET]
blr lr
dmb ishst // need fence for making object's class visible
1:
lsr w1, wINST, #8 // w1 <- A
SET_VREG_OBJECT w0, w1 // fp[A] <- value
FETCH_ADVANCE_INST 2
GET_INST_OPCODE ip
GOTO_OPCODE ip
2:
mov x0, xSELF
ldr x1, [sp]
mov x2, xPC

本文转载自: 掘金

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

深入剖析Python的单例模式实现 一、引言 二、单例的应用

发表于 2023-08-28

一、引言

单例模式是一种常见的设计模式,它限制一个类只能生成一个实例。在Python开发中,我们该如何实现单例模式呢?本文将通过一个简单的例子,使用Python的元类来实现一个线程安全的单例类,并比较说明使用装饰器实现单例的优劣。

单例模式看起来简单,但是想要做到完全线程安全并支持子类继承,还有一定的难度。本文将从单例模式的概念和应用场景开始,一步步分析线程安全的单例类该如何设计,加锁来保证线程安全等。

二、单例的应用场景

  1. 系统只需要一个实例对象,比如配置类、日志、工具类等。使用单例可以直接保证全局只存在一个实例。
  2. 控制资源访问,比如一个硬件资源只允许一个进程访问,或者打印机只允许一个任务执行打印操作。
  3. 频繁创建和销毁实例会带来较高的系统开销,使用单例可以减少内存占用和性能消耗。比如任务池、连接池等。
  4. 想确保一个类只有一个可见的实例,并提供一个全局访问点,如线程池、缓存、会话对象等。
  5. 当类状态需要频繁保存和恢复时,可以让类成为单例,避免每次获取实例后都要恢复状态的操作。
  6. 在面向对象中,如果有状态共享的需求,可以将共享状态和逻辑封装在一个单例类中。
  7. 单例可以简化代码,从而降低维护成本。在不需要多个实例的情况下,单例可以消除判断逻辑。

如下是一些伪代码的单例DEMO

配置类

配置类信息在程序运行期间仅需要一个实例,使用单例模式可以保证全局唯一:

1
2
3
4
5
6
python复制代码class Config(metaclass=SingletonMetaCls):
def __init__(self):
self.config = {'timeout':100, 'port':8000}

# 访问配置
print(Config().config)

2. 日志类

日志类也只需要一个实例输出日志信息即可:

1
2
3
4
5
6
7
8
9
python复制代码class Logger(metaclass=SingletonMetaCls):
def __init__(self):
print("初始化logger")

def log(self, msg):
print(f"log: {msg}")

# 使用
Logger().log("测试日志")

任务池

控制任务池的资源个数,只初始化指定数量的连接:

1
2
3
4
5
6
python复制代码class TaskPool(metaclass=SingletonMetaCls):
def __init__(self, size=10):
print(f"初始化{size}个任务到池中")

# 初始化一个10大小的任务池
pool = TaskPool()

总之,任何只需要一个实例、不保存状态的工具/帮助类,你需要限制实例个数的场景,都可以考虑使用单例模式实现。

三、单例的实现

重写 __new__ 方法

Python 的 new 对象不像java等其他语言一样,一些初学者可能会误认为 __init__ 方法是构造对象,实则不是,__init__方法是初始化对象属性,而__new__ 方法才是真正构造类实例对象。因此我们可以通过 重写__new__方法 并在方法内部添加判断逻辑,来限制一个类只创建一个实例。

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
python复制代码class Singleton(object):
_instance = None

def __new__(cls, *args, **kwargs):
# 重写 __new__ 实现单例
if not cls._instance:
cls._instance = super().__new__(cls, *args, **kwargs)
return cls._instance

def __init__(self):
# 初始化实例属性
self.demo_name = "Singleton Demo"


s1 = Singleton()
s2 = Singleton()

print("s1 demo_name", s1.demo_name)
print("s2 demo_name", s2.demo_name)
print("s1", s1)
print("s2", s2)
print("s1 is s2", s1 is s2)
print("s1 == s2", s1 == s2)


>>> out
s1 demo_name Singleton Demo
s2 demo_name Singleton Demo
s1 <__main__.Singleton object at 0x1051dcb80>
s2 <__main__.Singleton object at 0x1051dcb80>
s1 is s2 True
s1 == s2 True

通过重写 __new__ 实现单例,就可以实现最简单的单例模式。可以发现创建的对象的内存地址都是一样的。

上面的实现模式属于懒汉模式,还有一种叫做饿汉模式。先简单介绍下这两种模式概念。

懒汉模式

懒汉模式是等到需要才创建实例,比如:

一个游戏需要读取玩家存档数据的类,如果玩家没有存档,就不需要创建该类的实例,等玩家第一次存档时再实例化该类,读取并保存游戏状态。这种情况下使用懒汉模式更合适,不会提前占用内存资源。

饿汉模式

饿汉模式是提前创建实例,比如:

一个数据库连接池类,系统启动时就需要初始化一个指定大小的连接池,以备后续使用。这里需要饿汉模式提前创建并准备好数据库连接池,否则后面需要数据库连接时会出现延迟。

  • 懒汉是按需创建,节省资源
  • 饿汉是准备实例,避免后续延迟

饿汉代码实现

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
python复制代码#!/usr/bin/python3
# -*- coding: utf-8 -*-
# @Author: Hui
# @Desc: { 单例DEMO }
# @Date: 2023/08/22 09:27


class BaseSingleton(object):
_instance = None

@classmethod
def instance(cls):
if not cls._instance:
cls._instance = cls()
return cls._instance


class Singleton(BaseSingleton):
# _instance = Singleton() 这是错误的语法

def __new__(cls, *args, **kwargs):
if not cls._instance:
BaseSingleton.instance()
return cls._instance

def __init__(self):
# 初始化实例属性
self.demo_name = "Singleton Demo"


s1 = Singleton.instance()
s2 = Singleton.instance()
s3 = Singleton()
s4 = Singleton()

print("s1", s1)
print("s2", s2)
print("s3", s3)
print("s4", s4)
print("s1 is s2", s1 is s2)
print("s2 is s3", s2 is s3)
print("s3 is s4", s3 is s4)


>>> out
s1 <__main__.BaseSingleton object at 0x1051dcf70>
s2 <__main__.BaseSingleton object at 0x1051dcf70>
s3 <__main__.BaseSingleton object at 0x1051dcf70>
s4 <__main__.BaseSingleton object at 0x1051dcf70>
s1 is s2 True
s2 is s3 True
s3 is s4 True

python的饿汉模式,不能直接在类中构造自身对象,如下是错误的写法

1
2
python复制代码class Singleton(BaseSingleton):
_instance = Singleton() # 这是错误的语法

因此这里通过添加一个静态方法 instance() 来实现饿汉单例模式,但感觉有点不太像,就是要在new 对象前先通过 instance() 方法初始化下对象实例,到后面在其他模块使用已经存在的实例即可。但有时候就是想,instance() 是单例,Singleton() 这种new 对象不是,那就不要重写 __new__ 方法即可。因为有时候new对象想重新初始化属性。

虽然通过重写 __new__ 方法,实现了单例模式,但不够完善,在并发的情况下还是会创建多个实例,属于线程不安全,因此还是需要改造下,这里先展示并发问题,具体改造看下面装饰器的写法。

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
python复制代码class Singleton(object):
_instance = None

def __new__(cls, *args, **kwargs):
# 重写 __new__ 实现单例
if not cls._instance:
print("new instance")
cls._instance = super().__new__(cls, *args, **kwargs)

return cls._instance

def __init__(self):
# 初始化实例属性
self.demo_name = "Singleton Demo"


def create_obj(num):
s = Singleton()
print(f"s{num}", s)


# 多线程测试并发
with ThreadPoolExecutor() as pool:
for i in range(3, 10):
pool.submit(create_obj, i)

测试效果,这个要多运行几遍才有概率复现

可以发现,多线程的打印是凌乱的,但已经可以证明有2个线程创建了两实例对象 new instance,对象的内存地址也不一样。这是由于 if not cls._instance: 操作是非原子性操作的导致的并发问题。

装饰器写法

重写 __new__ 方法还是比较容易懂,但不太方便使用,每个类都要重写这个方法就很麻烦,逻辑都是一样的,因此我上面抽了一个 BaseSingleton 类来做,通过继承来复用代码。还有一种方式就是通过装饰器来实现单例,把共用的逻辑放到装饰器中做,然后再处理下并发问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
python复制代码def singleton(cls_obj):
"""单例装饰器"""
_instance_dic = {}
_instance_lock = threading.Lock()

@functools.wraps(cls_obj)
def wrapper(*args, **kwargs):
if cls_obj in _instance_dic:
# 实例字典中存在则直接返回
return _instance_dic.get(cls_obj)

with _instance_lock: # 互斥锁,防止多线程竞争,导致创建多实例
if cls_obj not in _instance_dic:
# 实例字典中没有,则创建对象实例,存入字典中
_instance_dic[cls_obj] = cls_obj(*args, **kwargs)
return _instance_dic.get(cls_obj)

return wrapper

由于 if cls_obj not in _instance_dic 判断是非原子性操作故而会引发多线程并发问题。

它大致会转换成以下字节码指令执行:

  1. 加载_instance_dic到栈顶
1
2
python复制代码Copy code
LOAD_GLOBAL 0 (_instance_dic)
  1. 加载cls_obj到栈顶
1
2
python复制代码Copy code
LOAD_FAST 0 (cls_obj)
  1. 调用__contains__方法检查是否在字典中
1
2
python复制代码Copy code
CONTAINS_OP
  1. 根据返回值进行跳转
1
2
python复制代码Copy code
POP_JUMP_IF_FALSE <target>

如果cls_obj不在_instance_dic中,就会跳转到target位置,也就是if块内的代码。

可以看到校验是否在字典中是在多个指令中完成,不是一个原子操作。

在多线程环境下,如果多个线程同时执行到这里,都可能会通过校验,然后创建实例添加到字典中,从而导致线程不安全。

故而在装饰器中通过线程的互斥锁来解决并发问题,然后通过字典来判断是否存在类的实例对象,存在直接返回,不存在创建对象实例存入字典中来达到单例的效果。

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
python复制代码@singleton
class Foo(object):

def __init__(self):
self.bar = "bar"


@singleton
class Demo(object):

def __init__(self):
self.demo_name = "singleton_demo"


f1 = Foo()
f2 = Foo()
print(f1)
print(f2)
print("f1 is f2", f1 is f2)

d1 = Demo()
d2 = Demo()
print(d1)
print(d2)
print("d1 is d2", d1 is d2)


>>> out
<__main__.Foo object at 0x102f56c70>
<__main__.Foo object at 0x102f56c70>
f1 is f2 True
<__main__.Demo object at 0x102f56d60>
<__main__.Demo object at 0x102f56d60>
d1 is d2 True

并发安全验证

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
python复制代码
@singleton
class Foo(object):

def __init__(self):
self.bar = "bar"

def two_bar(self):
return self.bar * 2


def create_obj(num):
foo = Foo()
print(f"foo{num}", foo)
return foo


with ThreadPoolExecutor() as pool:
for i in range(10):
pool.submit(create_obj, i)

ok,对象实例都是 <main.Foo object at 0x1016ed700>, 大家可以多运行几次,加了锁不会出现多个实例对象了。

这里发现被装饰的类都实现了单例模式,接下来我们一探究竟,在装饰器内部打印些东西,看看其工作原理。

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
python复制代码
def singleton(cls_obj):
"""单例装饰器"""
print("cls_obj", cls_obj)

_instance_dic = {}
_instance_lock = threading.Lock()

@functools.wraps(cls_obj)
def wrapper(*args, **kwargs):
if cls_obj in _instance_dic:
# 实例字典中存在则直接返回
return _instance_dic.get(cls_obj)

with _instance_lock: # 互斥锁,防止多线程竞争,导致创建多实例
if cls_obj not in _instance_dic:
# 实例字典中没有,则创建对象实例,存入字典中
_instance_dic[cls_obj] = cls_obj(*args, **kwargs)

print("_instance_dic", _instance_dic, "\n")

return _instance_dic.get(cls_obj)

return wrapper


@singleton
class Foo(object):

def __init__(self):
self.bar = "bar"


@singleton
class Demo(object):

def __init__(self):
self.demo_name = "singleton_demo"


f1 = Foo()
f2 = Foo()
print(f1)
print(f2)
print("f1 is f2", f1 is f2)

d1 = Demo()
d2 = Demo()
print(d1)
print(d2)
print("d1 is d2", d1 is d2)

模块在初始化的时候,其实就会把类初始化形成类对象,注意不是类的实例对象。

  • 装饰器的原理就是python解释器识别到 @singleton 的语法糖时自动把类对象的引用传递给 singleton 装饰器函数
  • 此时装饰器会返回一个新的函数对象(wrapper)出去,把类对象重新赋值了
+ **Foo = singleton(Foo) = wrapper**
+ **Demo = singleton(Demo) = wrapper**
  • 到创建对象实例时,Foo() 实则变成了是调用函数 wrapper() 来创建对象
  • 然后每个类都维护了一份 _instance = {} 实例字典,来确保这个类创建的对象只有一份
+ Key 是类对象,eg:Foo、Demo
+ Value 是类的实例对象,eg:Foo(),Demo()

可能大家会不了解类对象的概念,可以先看看我这篇文章 你真的了解Python中的类class? - 掘金 然后再回来看就更容易看懂了。

但装饰器实现的单例模式装饰方便、代码简洁,但是破坏了类的类型,把类变成了函数,导致编写代码的时候没有提示,也不知道有什么属性与方法,所以实际使用起来及其不方便。

接下来就是引出另一种写法,元类实现单例。

元类写法

元类是一种非常晦涩的知识点,一般场景都用不上,但知道元类的原理,后面需要用到时,可以帮助你更好的抽象与封装。

元类就是创建 类对象的类,type 就是元类

可以先了解下元类的知识点:追溯Python类的鼻祖——元类 - 掘金

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
python复制代码#!/usr/bin/python3
# -*- coding: utf-8 -*-
# @Author: Hui
# @Desc: { 元类模块 }
# @Date: 2022/11/26 16:43
import threading


class SingletonMetaCls(type):
""" 单例元类 """
_instance_lock = threading.Lock()

def __init__(cls, *args, **kwargs):
cls._instance = None
super().__init__(*args, **kwargs)

def __call__(cls, *args, **kwargs):
if cls._instance:
# 存在实例对象直接返回,减少锁竞争,提高性能
return cls._instance

with cls._instance_lock:
if not cls._instance:
cls._instance = super().__call__(*args, **kwargs)
return cls._instance

使用单例元类进行单例的封装会比装饰器的更好一些,装饰器封装的单例,再实际使用的过程中不太方便,IDE一些开发工具不知道这个类有什么属性,元类就不会,继承也可以实现单例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
python复制代码class Foo(metaclass=SingletonMetaCls):

def __init__(self):
self.bar = "bar"

def tow_bar(self):
return self.bar * 2


foo1 = Foo()
foo2 = Foo()
print("foo1 is foo2", foo1 is foo2)

>>> out
foo1 is foo2 True

继承案例

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
python复制代码class Foo(metaclass=SingletonMetaCls):

def __init__(self):
self.bar = "bar"

def tow_bar(self):
return self.bar * 2


foo1 = Foo()
foo2 = Foo()
print("foo1 is foo2", foo1 is foo2)
print("foo1 is foo2", foo1 is foo2)


class Demo(Foo):

def __init__(self):
self.bar = "demo_bar"


demo1 = Demo()
demo2 = Demo()
print("demo1 is demo2", demo1 is demo2)
print("demo2 two_bar", demo2.tow_bar())


>>> out
foo1 is foo2 True
foo2 two_bar barbar
demo1 is demo2 True
demo2 two_bar demo_bardemo_bar

元类实现原理

  • 加载Foo、Demo等类时,发现指定了元类 metaclass=SingletonMetaCls, 则会让指定的元类来帮助创建类对象
  • 此时 SingletonMetaCls 会调用__init__ 来创建类对象,然后通过super() 让 type 来创建类对象
+ type(类名, 父类元组, 类属性字典)
+ 并动态加了个 cls.\_instance 属性
  • Foo()、Demo(),创建实例对象时,是Foo、Demo类对象触发了(),所以调用 call() 魔法属性来构造对象实例,存到cls._instance中
  • 下次再创建实例对象,则是先判断是否有,有直接返回,没有则创建

可以打印一些信息来验证

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
python复制代码import threading


class SingletonMetaCls(type):
""" 单例元类 """
_instance_lock = threading.Lock()

def __init__(cls, *args, **kwargs):
cls._instance = None
print("SingletonMetaCls __init__", cls)
print("args", args)
print("kwargs", kwargs)
super().__init__(*args, **kwargs)

def _init_instance(cls, *args, **kwargs):
if cls._instance:
# 存在实例对象直接返回,减少锁竞争,提高性能
print("cls._instance", cls._instance)
return cls._instance

with cls._instance_lock:
if cls._instance is None:
cls._instance = super().__call__(*args, **kwargs)
return cls._instance

def __call__(cls, *args, **kwargs):
print("SingletonMetaCls __call__ cur cls", cls)
instance = cls._init_instance()
reinit = kwargs.get("reinit", True)
if reinit:
# 默认都重新初始化单例对象属性
instance.__init__(*args, **kwargs)
return instance
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
python复制代码from py_tools.meta_cls import SingletonMetaCls


class Foo(metaclass=SingletonMetaCls):

def __init__(self):
print("Foo __init__")
self.bar = "bar"

def __new__(cls, *args, **kwargs):
print("Foo __new__")
return super().__new__(cls, *args, **kwargs)

def tow_bar(self):
return self.bar * 2


# foo1 = Foo()
# foo2 = Foo()
# print("foo1 is foo2", foo1 is foo2)
# print("foo2 two_bar", foo2.tow_bar())


class Demo(Foo):

def __init__(self):
self.bar = "demo_bar"

模块加载时就会走元类的__init__

1
2
3
4
5
6
7
python复制代码SingletonMetaCls __init__ <class '__main__.Foo'>
args ('Foo', (), {'__module__': '__main__', '__qualname__': 'Foo', '__init__': <function Foo.__init__ at 0x100eb0310>, '__new__': <function Foo.__new__ at 0x100ed53a0>, 'tow_bar': <function Foo.tow_bar at 0x100ed5430>, '__classcell__': <cell at 0x100eaafd0: SingletonMetaCls object at 0x1217193b0>})
kwargs {}

SingletonMetaCls __init__ <class '__main__.Demo'>
args ('Demo', (<class '__main__.Foo'>,), {'__module__': '__main__', '__qualname__': 'Demo', '__init__': <function Demo.__init__ at 0x100ed54c0>})
kwargs {}

看看new对象的时候的打印信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
python复制代码class Foo(metaclass=SingletonMetaCls):

def __init__(self):
print("Foo __init__")
self.bar = "bar"

def __new__(cls, *args, **kwargs):
print("Foo __new__")
return super().__new__(cls, *args, **kwargs)

def tow_bar(self):
return self.bar * 2


foo1 = Foo()
foo2 = Foo()
print("foo1 is foo2", foo1 is foo2)

输出信息如下

1
2
3
4
5
6
7
8
9
10
11
12
13
python复制代码SingletonMetaCls __init__ <class '__main__.Foo'>
args ('Foo', (), {'__module__': '__main__', '__qualname__': 'Foo', '__init__': <function Foo.__init__ at 0x104998550>, '__new__': <function Foo.__new__ at 0x1049d5550>, 'tow_bar': <function Foo.tow_bar at 0x1049d55e0>, '__classcell__': <cell at 0x104991fd0: SingletonMetaCls object at 0x12f626fb0>})
kwargs {}

SingletonMetaCls __call__ cur cls <class '__main__.Foo'>
Foo __new__
Foo __init__
Foo __init__

SingletonMetaCls __call__ cur cls <class '__main__.Foo'>
cls._instance <__main__.Foo object at 0x1049b3f70>
Foo __init__
foo1 is foo2 True

可以发现跟我上面的说的一致,这里引出了好多魔法属性来验证,可以先看看 Python中的魔法属性 - 掘金

四、总结

  • 类重写 new 易懂,但每个类都要重写太冗余了
+ 故抽出 BaseSingleton 基类,复用逻辑通过 instance() 来实现单例(**推荐**)
+ 如果要构造实例属性会有点不太方便
  • 装饰器写法也是复用了创建单例的逻辑,装饰起来方便、简洁
+ 但实际使用装饰过的类不方便,没有类属性提示
  • 元类的写法会有点难与绕,实际使用起来方便,多继承也实现了单例(推荐)
+ 使用起来和平常使用类没有区别
+ 还可以通过**reinit**参数来控制是否重新初始化实例对象属性
  • 通过线程的互斥锁来解决并发问题
+ 双重判断来减少锁竞争,提高性能
  • 当然还有其他的方式实现单例,例如通过Python的模块导入,来保证只会创建一个实例

五、源代码

有些细节,我没有展开讲,大家可以下载源代码,亲自实践下。

HuiDBK/py-tools: 打造 Python 开发常用的工具,让Coding变得更简单 (github.com)

本文转载自: 掘金

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

弃用qiankun!看古茗中后台架构如何破局

发表于 2023-08-21

温馨Tips:阅读本文大约需要 10 分钟~

引言

我们团队之前发布过一篇文章,介绍了古茗前端到底在做什么,当然这只包含了我们团队内做的一部分事情。以端侧来划分,主要包括:中后台 web 端、H5 端、4 端小程序、Electron PC 端、Android/Flutter 客户端,我们需要为这些端侧方向做一些通用能力的解决方案,同时也需要深入每个端侧细分领域做它们特有的技术沉淀。本文主要介绍古茗在中后台技术方向的一些思考和技术沉淀。

业务现状

古茗目前有大量的中后台业务诉求,包括:权限系统、会员系统、商品中心、拓展系统、营运系统、财务系统、门店系统、供应链系统等多个子系统,服务于内部产品、技术、运营,及外部加盟商等用户群体,这些子系统分别由不同业务线的同学负责开发和维护,而这些子系统还没有系统性的技术沉淀。随着业务体量和业务复杂度的不断增大,在“降本增效”的大背景下,如何保证业务快速且高质量交付是我们面临的技术挑战。

技术演进

evolution

如上述技术演进路线图所示,我们的中后台技术架构大致经历了 4 个阶段:当业务起步时,我们很自然的使用了单应用开发模式;当业务发展到一定的体量,多人协同开发变得困难时,我们拆分了多个子系统维护,并使用了 systemjs 来加载子系统资源(微前端模式的雏形);当遇到有三方库的多版本隔离诉求时,我们又引入了 qiankun 微前端框架来隔离多个子系统,保证各子系统间互不影响;那是什么原因让我们放弃 qiankun,转向完全自研的一套中后台解决方案?

弃用 qiankun?

其实准确来说,qiankun 并不能算是一个完整的中后台解决方案,而是一个微前端框架,它在整个技术体系里面只充当子应用管理的角色。我们在技术演进过程中使用了 qiankun 尝试解决子应用的隔离问题,但同时也带来了一些新的问题:某些场景跳转路由后视图无反应;某些三方库集成进来后导致奇怪的 bug…。同时,还存在一些问题无法使用 qiankun 解决,如:子应用入口配置、样式隔离、运维部署、路由冲突、规范混乱、要求资源必须跨域等。

探索方向

我们重新思考了古茗中后台技术探索方向是什么,也就是中后台技术架构到底要解决什么问题,并确定了当下的 2 大方向:研发效率、用户体验。由此我们推导出了中后台技术探索要做的第一件事情是“统一”,这也为我们整个架构的基础设计确立了方向。

goal

架构设计

我们的整体架构是围绕着“统一”和“规范” 2 大原则来设计,目标是提升整个团队的研发效能。我们认为好的架构应该是边界清晰的,而不是一味的往上面堆功能,所以我们思考更多的是,如果没有这个功能,能否把这件事情做好。

取个“好”名字

我老板曾说过一个好的(技术)产品要做的第一件事就是取个“好”名字。我们给这套中后台架构方案取名叫「Mars」,将相关的 NPM 包、组件库、SDK、甚至部署路径都使用 mars 关键字来命名,将这个产品名字深入人心,形成团队内共同的产品认知。这样的好处是可以加强团队对这套技术方案的认同感,以及减少沟通负担,大家一提到 mars,就都知道在说哪一件事。

框架设计

mars-arch

正如上述大图所示,我们是基于微前端的思路做的应用框架设计,所以与市面上大多数的微前端框架的设计思路、分层结构大同小异。这里还是稍微介绍一下整个流程:当用户访问网站时,首先经过网关层,请求到基座应用的资源并渲染基础布局和菜单,当监听路由变化时,加载并渲染路由关联的子应用及页面。

但是,市面上大部分的微前端框架往往需要在基座应用上配置子应用的 name、entry、avtiveRule 等信息,因为框架需要根据这些信息来决定什么时机去加载哪一个子应用,以及如何加载子应用的资源。这就意味着每新增一个子应用,都需要去基座维护这份配置。更要命的是,不同环境的 entry 可能还要根据不同的环境做区别判断。如果遇到本地开发时需要跳转至其他子应用的场景,那也是非常不好的开发体验。所以,这是我们万万不能接受的。

针对这一痛点,我们想到了 2 种解决思路:

  1. 把基座应用的配置放到云端,用 node 作为中间层来维护各子应用的信息,每次新增应用、发布资源后同步更新,问题就是这套方案实现成本比较高,且新增子应用还是需要维护子应用的信息,只是转移到在云端维护了。
  2. 使用约定式路由及部署路径,当我们识别到一个约定的路由路径时,可以反推它的应用 ID 及部署资源路径,完全 0 配置。很明显,我们选择了这种方案。

约定式路由及部署路径

路由约定

我们制定了如下的标准 Mars 路由规范:

1
2
3
4
bash复制代码  /mars/appId/path/some?name=ferret
\_/ \_/ \_____/ \_______/
| | | |
标识 appId path query
  1. 路由必须以 /mars 开头(为了兼容历史路由包袱)
  2. 其后就是 appId ,这是子应用的唯一标识
  3. 最后的 path 和 query 部分就是业务自身的路由和参数

部署路径约定

我们制定了如下的标准 Mars 子应用部署路径规范:

1
2
3
4
bash复制代码  https://cdn.example.com/mars/[appId]/[env]/manifest.json
\__________________/ \_/ \___/ \_/ \________/
| | | | |
cdn 域名 标识 appId 环境 入口资源清单

从上述部署路径规范可以看出,整个路径就 appId 和 env 2 个变量是不确定的,而 env 可以在发布时确定,因此可由 appId 推导出完整的部署路径。而根据路由约定,我们可以很容易的从路由中解析出 appId,由此就可以拿到完整的 manifest.json 部署路径 ,并以此获取到整个子应用的入口资源信息。

编译应用

虽然制定了上述 2 大规范,但是如何保障规范落地,防止规范腐化也是非常重要的一个问题。我们是通过编译手段来强制约束执行的(毕竟“人”往往是靠不住的😄)。

依赖工程化体系

提示:Kone 是古茗内部前端工程化的工具产品。

首先,子应用需要配置一个工程配置文件,并注册 @guming/kone-plugin-mars 插件来完成子应用的本地开发、构建、发布等工程化相关的任务。其中:配置项 appId 就代表约定路由中的 appId 和 部署路径中的 appId,也是子应用的唯一标识。

工程配置文件:kone.config.json

1
2
3
4
5
6
json复制代码{
"plugins": ["@guming/kone-plugin-mars"],
"mars": {
"appId": "demo"
}
}

编译流程

然后,子应用通过静态化配置式(json 配置)注册路由,由编译器去解析配置文件,注册路由,以及生成子应用 mount 、unmount 生命周期方法。这样实现有以下 3 个好处:

  • 完整的路由 path 由编译生成,可以非常好的保障约定式路由落地
  • 生命周期方法由编译生成,减少项目中的模板代码,同样可以约束子应用的渲染和卸载按照预定的方式执行
  • 可以约束不规范的路由 path 定义,例如我们会禁用掉 :param 形式的动态路由

应用配置文件:src/app.json

1
2
3
4
5
6
7
8
9
10
11
12
13
14
json复制代码{
"routes": [
{
"path": "/some/list",
"component": "./pages/list",
"description": "列表页"
},
{
"path": "/some/detail",
"component": "./pages/detail",
"description": "详情页"
}
]
}

上述示例最终会生成路由:/mars/demo/some/list、/mars/demo/some/detail。

webpack-loader 实现:

解析 src/app.json 需要通过一个自定义的 webpack-loader 来实现,部分示例代码如下:

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复制代码import path from 'path';
import qs from 'qs';

export default function marsAppLoader(source) {
const { appId } = qs.parse(this.resourceQuery.slice(1));
let config;
try {
config = JSON.parse(source);
} catch (err) {
this.emitError(err);
return;
}

const { routes = [] } = config;

const routePathSet = new Set();
const routeRuntimes = [];
const basename = `/mars/${appId}`;

for (let i = 0; i < routes.length; i++) {
const item = routes[i];
if (routePathSet.has(item.path.toLowerCase())) {
this.emitError(new Error(`重复定义的路由 path: ${item.path}`));
return;
}

routeRuntimes.push(
`routes[${i}] = { ` +
`path: ${JSON.stringify(basename + item.path)}, ` +
`component: _default(require(${JSON.stringify(item.component)})) ` +
`}`
);
routePathSet.add(item.path.toLowerCase());
}

return `
const React = require('react');
const ReactDOM = require('react-dom');

// 从 mars sdk 中引入 runtime 代码
const { __internals__ } = require('@guming/mars');
const { defineApp, _default } = __internals__;

const routes = new Array(${routeRuntimes.length});
${routeRuntimes.join('\n')}

// define mars app: ${appId}
defineApp({
appId: '${appId}',
routes,
});

`.trim();
}

将 src/app.json 作为编译入口并经过此 webpack-loader 编译之后,将自动编译关联的路由组件,创建子应用路由渲染模板,注册生命周期方法等,并最终输出 manifest.json 文件作为子应用的入口(类似 index.html),根据入口文件的内容就可以去加载入口的 js、css 资源并触发 mount 生命周期方法执行渲染逻辑。生成的 manifest.json 内容格式如下:

1
2
3
4
5
6
7
8
json复制代码{
"js": [
"https://cdn.example.com/mars/demo/prod/app.a0dd6a27.js"
],
"css": [
"https://cdn.example.com/mars/demo/prod/app.230ff1ef.css"
]
}

聊聊沙箱隔离

一个好的沙箱隔离方案往往是市面上微前端框架最大的卖点,我们团队内也曾引入 qiankun 来解决子应用间隔离的痛点问题。而我想让大家回归到自己团队和业务里思考一下:“我们团队需要隔离?不做隔离有什么问题”。而我们团队给出的答案是:不隔离 JS,要隔离 CSS,理由如下:

  1. 不隔离 JS 可能会有什么问题:window 全局变量污染?能污染到哪儿去,最多也就内存泄露,对于现代 B 端应用来说,个别内容泄露几乎可以忽略不计;三方库不能混用版本?如文章开头所提及的,我们要做的第一件事就是统一,其中就包括统一常用三方库版本,在统一的前提下这种问题也就不存在了。当然也有例外情况,比如高德地图 sdk 在不同子系统需要隔离(使用了不同的 key),针对这种问题我们的策略就是专项解决;当然,最后的理由是一套非常好的 JS 隔离方案实现成本太高了,需要考虑太多的问题和场景,这些问题让我们意识到隔离 JS 带来的实际价值可能不太高。
  2. 由于 CSS 的作用域是全局的,所以非常容易造成子应用间的样式污染,其次,CSS 隔离是容易实现的,我们本身就基于编译做了很多约束的事情,同样也可以用于 CSS 隔离方案中。实现方案也非常简单,就是通过实现一个 postcss 插件,将子应用中引入的所有 css 样式都加上特有的作用域前缀,例如:
1
2
3
css复制代码.red {
color: red;
}

将会编译成:

1
2
3
css复制代码.mars__demo .red {
color: red;
}

当然,某些场景可能就是需要全局样式,如 antd 弹层内容默认就会在子应用内容区外,造成隔离后的样式失效。针对这种场景,我们的解法是用隔离白名单机制,使用也非常简单,在最前面加上 :global 选择器,编译就会直接跳过,示例:

1
2
3
4
5
less复制代码:global {
.some-modal-cls {
font-size: 14px;
}
}

将会编译成:

1
2
3
css复制代码.some-modal-cls {
font-size: 14px;
}

除此之外,在子应用卸载的时候,还会禁用掉子应用的 CSS 样式,这是如何做到的?首先,当加载资源的时候,会找到该资源的 CSSStyleSheet 对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
js复制代码const link = document.createElement('link');
link.setAttribute('href', this.url);
link.setAttribute('rel', 'stylesheet');
link.addEventListener('load', () => {
// 找到当前资源对应的 CSSStyleSheet 对象
const styleSheets = document.styleSheets;
for (let i = styleSheets.length - 1; i >= 0; i--) {
const sheet = styleSheets[i];
if (sheet.ownerNode === this.node) {
this.sheet = sheet;
break;
}
}
});

当卸载资源的时候,将该资源关联的 CSSStyleSheet 对象的 disabled 属性设置为 true 即可禁用样式:

1
2
3
js复制代码if (this.sheet) {
this.sheet.disabled = true;
}

框架 SDK 设计

sdk

框架 SDK 按照使用场景可以归为 3 类,分别是:子应用、基座应用、编译器。同样的遵循我们的一贯原则,如果一个 API 可以满足诉求,就不会提供 2 个 API,尽可能保证团队内的代码风格都是统一的。例如:路由跳转 SDK 只提供唯一 API,并通过编译手段禁用掉其他路由跳转方式(如引入 react-router-dom 的 API 会报错):

1
2
3
4
5
6
7
8
9
10
11
ts复制代码import { mars } from '@guming/mars';

// 跳转路由:/mars/demo/some/detail?a=123
mars.navigate('/mars/demo/some/detail', {
params: { a: '123' }
});

// 获取路由参数
const { pathname, params } = mars.getLocation();
// pathname: /mars/demo/some/detail
// params: { a: '123' }

当然,我们也会根据实际情况提供一些便利的 API,例如:跳转路由要写完整的 /mars/[appId] 路由前缀太繁琐,所以我们提供了一个语法糖来减少样板代码,在路由最前面使用 : 来代替 /mars/[appId] 前缀(仅在当前子应用内跳转有效):

1
2
3
4
5
6
ts复制代码import { mars } from '@guming/mars';

// 跳转路由:/mars/demo/some/detail?a=123
mars.navigate(':/some/detail', {
params: { a: '123' }
});

另外,值得一提的是在基座应用上使用的一个 API bootstrap(),得益于这个 API 的设计,我们可以快速创建多个基座应用(不同域名),还能使用这个 API 在本地开发的时候启动一个基座来模拟本地开发环境,提升开发体验。

本地开发体验

开发模拟器

为更好的支持本地开发环境,我们提供了一套本地开发模拟器,在子应用启动本地服务的时候,会自动启动一个模拟的基座应用,拥有和真实基座应用几乎一样的布局,运行环境,集成的登录逻辑等。除此之外,开发模拟器还提供了辅助开发的「debug 小组件」,比如通过 debug 工具可以动态修改本地开发代理规则,保存之后立即生效。

simulator

IDE 支持

为了提升开发体验,我们分别开发了 Webstorm 插件 和 VSCode 插件服务于 Mars 应用,目前支持路由组件配置的路径补全、点击跳转、配置校验等功能。此外,我们会为配置文件提供 json schema,配置后将会获得 IDE 的自动补全能力和配置校验能力。

app.json

历史项目迁移

技术架构演进对于业务项目来说最大的问题在于,如何完成历史项目向新架构的迁移改造?我们团队投入 2 个人花了 2 个月时间将 12 个历史项目全部迁移至最新的架构上,这里分享一些我们在迁移过程中的经验。

定目标

首先要确定历史项目迁移这件事情一定是要做的,这是毋庸置疑的,而且要越快越好。所以我们要做的第一件事就是制定迁移计划,最后确定投入 2 个人力,大约花 2 个月时间将历史项目全部迁移至新的架构。第二件事情就是确定改造的范围(确定边界,不做非目标范围内的改造),对于我们的业务现状来说,主要包括:

  • 统一 react、react-dom 版本为 17.0.2
  • 统一 antd 版本为 4.24.8
  • 统一路由
  • 统一接入 request 请求库
  • 统一接入工程化体系
  • 统一环境变量

梳理 SOP

因为迁移的流程不算简单,迁移要做的事情还挺多的,所以接下来要做的一件事就是梳理迁移流程 SOP,SOP 文档要细化到每种可能的场景,以及遇到问题对应的解法,让后续项目的迁移可以傻瓜式的按照标准流程去操作即可。我们的做法是,先以一个项目作为试点,一边迁移一边梳理 SOP,如果在迁移其他项目中发现有遗漏的场景,再持续补充这份 SOP 文档。

例如:之前项目中使用了 dva 框架,但是它的 router 和 model 是耦合的,这样就无法使用我们制定的统一路由方案,对此我们的解法是,通过 hack dva 的源代码,将 model 前置注入到应用中,完成与路由的解耦。

上线方案

由于业务迭代频繁,所以我们代码改造持续的时间不能太长,否则要多次合并代码冲突,而我们的经验就是,项目改造从拉分支到发布上线,要在 1 周内完成。当然,整个上线过程还遇到许多需要解决的问题,比如在测试参与较少的情况下如何保障代码质量,包括:业务回归的策略,回滚策略,信息同步等等。

总结

之前看到 Umi 4 设计思路文字稿 里面有句话我觉得特别有道理:“社区要开放,团队要约束”,我们团队也在努力践行“团队约束”这一原则,因为它为团队带来的收益是非常高的。

没有最完美的方案,只有最适合自己的方案,以上这套架构方案只是基于当下古茗前端团队现状做的选择后的结果,可能并不适合每个团队,希望本文的这些思考和技术沉淀能对您有所帮助和启发。

最后

关注公众号「Goodme前端团队」,获取更多干货实践,欢迎交流分享~

本文转载自: 掘金

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

【建议收藏】106道Android核心面试题及答案汇总(总结

发表于 2023-08-16

前言

对于Android的学习,很多⼈可能学了之后,不知道⾃⼰处于哪个阶段,也不到究竟要学到哪个程度,验证⾃⼰学得如何最好的⾯试,就是尝试去⾯试,⽽⾯试⽆⾮就是问你⼀些⾯试题。

而我之前也有整理过一些面试题,但不是很完整。

所小编重新整理了这些 Android ⾯试题,从 Java 基础,并发,虚拟机到Android Framework,开源框架,性能优化,并且附带了详细的答案,⽆论是想⾯试还是想看看⾃⼰学得如何,那么这份⾯试题,都值得你去学习。

希望能帮助到你面试前的复习并且找到一个好的工作,也节省你在网上搜索资料的时间来学习

整理不易,点赞+收藏+关注是对博主最大的支持

第1-10期 Java核心基础面试题

面试官:Java中提供了抽象类还有接口,开发中如何去选择呢?

面试官:重载和重写是什么意思,区别是什么?

面试官:静态内部类是什么?和非静态内部类的区别是什么?

面试官:Java中在传参数时是将值进行传递,还是传递引用?

面试官:使用equals和==进行比较的区别

面试官:String s = new String(“xxx”);创建了几个String对象?

面试官:finally中的代码一定会执行吗?try里有return,finally还执行么

面试官:Java异常机制中,异常Exception与错误Error区别

面试官:序列Parcelable,Serializable的区别?

面试官:为什么Intent传递对象为什么需要序列化?

第11-14期 Java深入泛型与注解面试题

面试题:泛型是什么,泛型擦除呢?

面试题:List能否转为List

面试题:Java的泛型中super 和 extends 有什么区别?

面试题:注解是什么?有哪些使用场景?

第15-24期 Java并发编程面试题

面试题:假如只有一个cpu,单核,多线程还有用吗

面试题:sychronied修饰普通方法和静态方法的区别?什么是可见性?

面试题:Synchronized在JDK1.6之后做了哪些优化

面试题:CAS无锁编程的原理

面试题:AQS原理

面试题:ReentrantLock的实现原理

面试题:Synchronized的原理以及与ReentrantLock的区别。

面试题:volatile关键字干了什么?(什么叫指令重排)

面试题:volatile 能否保证线程安全?在DCL上的作用是什么?

面试题:volatile和synchronize有什么区别?

第25-34期 Java虚拟机原理面试题

面试题:描述JVM类加载过程

面试题:请描述new一个对象的流程

面试题:Java对象会不会分配到栈中?

面试题:GC的流程是怎么样的?介绍下GC回收机制与分代回收策略

面试题:Java中对象如何晋升到老年代?

面试题:判断对象是否被回收,有哪些GC算法,虚拟机使用最多的是什么算法?

面试题:Class会不会回收?用不到的Class怎么回收?

面试题:Java中有几种引用关系,它们的区别是什么?

面试题:描述JVM内存模型

面试题:StackOverFlow与OOM的区别?分别发生在什么时候,JVM栈中存储的是什么,堆存储的是什么?

第35-44期 Java反射类加载与动态代理面试题

面试题:PathClassLoader与DexClassLoader的区别是什么?

面试题:什么是双亲委托机制,为什么需要双亲委托机制?

面试题:Android中加载类的方法有哪些?有什么区别?

面试题:ClassNotFound的有可能的原因是什么?

面试题:odex了解吗?解释型和编译型有什么区别?

面试题:说说反射的应用场景,哪些框架?

面试题:反射为什么慢?

面试题:动态代理是什么?如何实现?

面试题:动态代理的方法怎么初始化的?

面试题:CGLIB动态代理

第45-54期 网络编程面试题

面试题:请你描述TCP三次握手与四次挥手的过程与意义

面试题:谈谈你对TCP与UDP的区别是什么的理解

面试题:谈谈你对TCP 流量控制与拥塞控制的理解

面试题:谈谈你对Http与Https的关系理解

面试题:SSL握手的过程都经历过什么

面试题:谈谈你对Http的post与get请求区别的理解

面试题:输入一串URL到浏览器都经历过什么?

面试题:断点续传原理

面试题:如何保证下载文件的完整性

第55-58期 Kotlin面试题

面试题:Kotlin内置标准函数let的原理是什么?

面试题:Kotlin语言的run高阶函数的原理是什么?

面试题:Kotlin语言泛型的形变是什么?

面试题:Kotlin协程在工作中有用过吗?

第59-68期 Android 高级UI面试题

面试题:View的绘制原理

面试题:View绘制流程与自定义View注意点

面试题:自定义view与viewgroup的区别

面试题:View的绘制流程是从Activity的哪个生命周期方法开始执行的

面试题:Activity,Window,View三者的联系和区别

面试题:在onResume中是否可以测量宽高

面试题:如何更新UI,为什么子线程不能更新UI?

面试题:DecorView, ViewRootImpl,View之间的关系

面试题:自定义View执行invalidate()方法,为什么有时候不会回调onDraw()

面试题:invalidate() 和 postInvalicate() 区别

第69-78期 Android Framework面试题

面试题:Android中多进程通信的方式有哪些?

面试题:描述下Binder机制原理?

面试题:为什么 Android 要采用 Binder 作为 IPC 机制?

面试题:Binder线程池的工作过程是什么样?

面试题:AIDL 的全称是什么?如何工作?能处理哪些类型的数据?

面试题:Android中Pid&Uid的区别和联系

面试题:Handler怎么进行线程通信,原理是什么?

面试题:ThreadLocal的原理,以及在Looper是如何应用的?

面试题:Handler如果没有消息处理是阻塞的还是非阻塞的?

面试题:handler.post(Runnable) runnable是如何执行的?

第79-88期 Android组件内核面试题

面试题:Acitvity的生命周期,如何摧毁一个Activity?

面试题:Activity的4大启动模式,与开发中需要注意的问题,如onNewIntent() 的调用

面试题:Intent显示跳转与隐式跳转,如何使用?

面试题:Activity A跳转B,B跳转C,A不能直接跳转到C,A如何传递消息给C?

面试题:Activity如何保存状态的?

面试题:请描诉Activity的启动流程,从点击图标开始。

面试题:Service的生命周期是什么样的?

面试题:你会在什么情况下使用Service?

面试题:Service和Thread的区别?

面试题:IntentService与Service的区别?

第89-98期 Android性能优化面试题

面试题:一张图片100x100在内存中的大小?

面试题:内存优化,内存抖动和内存泄漏。

面试题:什么时候会发生内存泄漏?举几个例子

面试题:Bitmap压缩,质量100%与90%的区别?

面试题:TraceView的使用,查找CPU占用

面试题:内存泄漏查找

面试题:Android四大组件(以及Application)的onCreate/onReceiver方法中Thread.sleep(),会产生几个ANR?

面试题:当前项目中是如何进行性能优化分析的

面试题:冷启动、热启动的概念

面试题:优化View层次过深问题,选择哪个布局比较好?

第99-106期 开源框架面试题

Android开源框架面试题:组件化在项目中的意义

Android开源框架面试题:组件化中的ARouter原理

Android开源框架面试题:谈一下你对APT技术的理解

Android开源框架面试题:谈谈Glide框架的缓存机制设计

Android项目中使用Glide框架出现内存溢出,应该是什么原因?

Android开源框架面试题:Android如何发起网络请求,你有用过相关框架码?OkHttp框架解决了你什么问题?

Android开源框架面试题:RxJava框架线程切换的原理,RxJava1与RxJava2有哪些区别?

Android开源框架面试题:谈谈LiveData的生命周期是怎么监听的?

最后

特意整理出了有分类目录的Android面试题,方便大家平时复习和收藏。

  • 数据结构与算法面试题
  • Java核心基础面试题
  • Java深入泛型与注解面试题
  • Java并发编程面试题
  • Java虚拟机原理面试题
  • Java反射类加载与动态代理面试题
  • 网络编程面试题
  • Kotlin核心面试题
  • 高级UI面试题
  • Android Framework面试题
  • Android组件内核面试题
  • 程序性能优化与数据持久化面试题
  • 开源框架面试题

这些Android面试题都整理打包好了→: 点击下载

所有的面试题目都不是一成不变的,面试题目只是给大家一个借鉴作用,最主要的是给自己增加知识的储备,有备无患。

本文转载自: 掘金

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

flutter自定义底部导航栏 开始

发表于 2023-08-16

开始

想做个有意思的底部导航栏,不想用官方的,找了一些参考,发现这个比较有意思,还有动画。

github.com/tunitowen/t…

image.png

遗憾的但是拿过来这个并不能直接用

思路

在布局上来看 底部导航栏是一个横向的布局 我们用 Row 就可以了,每Bar切换是与页面绑定的,而且Bar的个数
是不固定的,并且每个Bar都要与对应的页面相绑定。

新建一个 Bar的类存储每个导航栏的信息

1
2
3
4
5
6
7
8
9
dart复制代码class TabItem {
final String title;
final IconData iconData;

TabItem({
required this.iconData,
required this.title,
});
}

当然你还可以扩充 比如设置这个加个颜色什么之类的 。

简单的底部导航栏

效果

4t4h9-f2mj6.gif

通过传入的导航,根据导航数量进行构建底部导航栏,barItems里添加每个导航的样式。

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
dart复制代码
List<Widget> _buildBarItems() {
List<Widget> barItems = [];
for (int i = 0; i < widget.tabItem.length; i++) {
barItems.add(Expanded(
child: Stack(
fit: StackFit.expand,
children: [
SizedBox(
height: double.infinity,
width: double.infinity,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
widget.tabItem[i].title,
style: const TextStyle(fontWeight: FontWeight.w600),
),
)
),
SizedBox(
height: double.infinity,
width: double.infinity,
child:IconButton(
highlightColor: Colors.transparent,
splashColor: Colors.transparent,
padding: const EdgeInsets.all(0),
alignment: const Alignment(-0.8, 1.5),
icon: Icon(
widget.tabItem[i].iconData,
color: widget.iconBackgroundColor,
),
onPressed: () {
setState(() {
barIndex = i;
widget.onBarTap(barIndex);
});
},
),
)

],
),
));
}
return barItems;
}

把导航栏这个放入Row 横向布局里即可

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
dart复制代码class EasyBar extends StatefulWidget {
final Color? iconBackgroundColor;
final Color? backgroundColor;
final List<TabItem> tabItem;
final Function onBarTap;

const EasyBar(
{Key? key,
this.iconBackgroundColor = Colors.deepPurple,
required this.tabItem,
required this.onBarTap,
this.backgroundColor = Colors.white})
: super(key: key);

@override
State<EasyBar> createState() => _EasyBarState();
}

class _EasyBarState extends State<EasyBar> with TickerProviderStateMixin {

double fabIconAlpha = 1;

///是1的话在中间
int barIndex = 1;

@override
void initState() {
super.initState();
}

@override
Widget build(BuildContext context) {
return Stack(
clipBehavior: Clip.none,
alignment: Alignment.topCenter,
children: <Widget>[
Container(
height: 65,
padding: const EdgeInsets.only(bottom: 18),
decoration: BoxDecoration(
color: widget.backgroundColor,
boxShadow: const [BoxShadow(color: Colors.black12, offset: Offset(0, -1), blurRadius: 8)]),
child: Row(
mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.center,
children: _buildBarItems(),
),
),
],
);
}

把点击事件暴露出来处理,最主要的是页面的index 要与导航的index所绑定

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
dart复制代码int selectedBarIndex = 1;
List<Widget> pages = [];

@override
void initState() {
super.initState();
pages
..add(Scaffold(
body: Container(width: double.infinity, height: double.infinity, color: Colors.lightBlue,),
))
..add(Scaffold(body: Container(width: double.infinity, height: double.infinity, color: Colors.pinkAccent)))
..add(Scaffold(
body: Container(width: double.infinity, height: double.infinity, color: Colors.amber,),
));
}

@override
Widget build(BuildContext context) {
return Scaffold(
body: pages[selectedBarIndex],
bottomNavigationBar: EasyBar(
backgroundColor: Colors.pink,
tabItem: widget.barItems,
onBarTap: (index) {
setState(() {
//底部的index和页面绑定
selectedBarIndex = index;
});
},
),
);
}

动画的底部导航栏

效果

ipw9c-o5lky.gif

通过传入的导航的数量构建底部导航栏还是大同小异,唯一的区别是,当选中的时候 icon变透明,以及title从底部移动上来的两个动画效果 使用了 AnimatedOpacity 控制透明度 AnimatedAlign 来控制标题的位置

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
less复制代码List<Widget> _buildBarItems() {
List<Widget> barItems = [];
for (int i = 0; i < widget.tabItem.length; i++) {
barItems.add(Expanded(
child: Stack(
fit: StackFit.expand,
children: [
SizedBox(
height: double.infinity,
width: double.infinity,
child: AnimatedAlign(
// curve: Curves.easeIn,//动画曲线
duration: Duration(milliseconds: widget.animationMilliseconds!),
alignment: Alignment(0, (barIndex == i)? 3 : 8),//控制文字的位置
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
widget.tabItem[i].title,
style: const TextStyle(fontWeight: FontWeight.w600),
),
)),
),
SizedBox(
height: double.infinity,
width: double.infinity,
child:AnimatedOpacity(
duration: Duration(milliseconds: widget.animationMilliseconds!),//动画时长
opacity: (barIndex == i) ? 0 : 1,////控制icon的透明度
child: IconButton(
highlightColor: Colors.transparent,
splashColor: Colors.transparent,
padding: const EdgeInsets.all(0),
alignment: const Alignment(0, 0),
icon: Icon(
widget.tabItem[i].iconData,
color: widget.iconBackgroundColor,
),
onPressed: () {
setState(() {
barIndex = i;
widget.onBarTap(barIndex);
_positionTween.begin = _positionAnimation.value; //叠叠圆X轴开始位置
_positionTween.end = i - 1; //叠叠圆X轴结束的位置
_animationController.reset();
_fadeOutController.reset();
_animationController.forward();
_fadeOutController.forward();
});
},
),
),
)

],
),
));
}
return barItems;
}

image.png

这个圆我们观察他!
大圆叠小圆 上面还有个icon

叠叠圆

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
dart复制代码Positioned(
top: -45,
left: 0,
right: 0,
child: IgnorePointer(
child: Container(
decoration: const BoxDecoration(color: Colors.transparent),
child: Align(
heightFactor: 0.5,
alignment: Alignment(_positionAnimation.value, -1),
//_positionAnimation.value控制这个叠叠的圆的位置,根据点的导航修改圆的x轴的位置
child: FractionallySizedBox(
widthFactor: 1/3,//控制占的宽度
child: Stack(
alignment: Alignment.center,
children: <Widget>[
SizedBox(
height: 90,
width: 90,
child: ClipRect(
clipper: HalfClipper(),//裁剪
child: Center(
child: Container(
width: 70,
height: 70,
decoration: BoxDecoration(
color: widget.backgroundColor,
shape: BoxShape.circle,
boxShadow: const [BoxShadow(color: Colors.black12, blurRadius: 8)])),
)),
),
SizedBox(
height: 60,
width: 60,
child: Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
color: widget.iconBackgroundColor,
border: Border.all(color: Colors.white, width: 5, style: BorderStyle.none)),
child: Padding(
padding: const EdgeInsets.all(0.0),
child: Opacity(
opacity: fabIconAlpha,//白色图标切换时的动画
child: Icon(
widget.tabItem[barIndex].iconData,
color: Colors.white,
),
),
),
),
)
],
),
),
),
),
),
),

要想在stack中子组件超出父组件的约束 只需要stack的 clipBehavior属性设置 Clip.none

完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
dart复制代码import 'package:flutter/material.dart';
import 'package:vector_math/vector_math.dart' as vector;

class BottomBar extends StatefulWidget {
final Color? iconBackgroundColor;
final Color? backgroundColor;
final List<TabItem> tabItem;
final Function onBarTap;
final int? animationMilliseconds;

const BottomBar(
{Key? key,
this.iconBackgroundColor = Colors.deepPurple,
required this.tabItem,
required this.onBarTap,
this.animationMilliseconds = 300,
this.backgroundColor = Colors.white})
: super(key: key);

@override
State<BottomBar> createState() => _BottomBarState();
}

class _BottomBarState extends State<BottomBar> with TickerProviderStateMixin {
late AnimationController _animationController;
late Tween<double> _positionTween;
late Animation<double> _positionAnimation;

late AnimationController _fadeOutController;
late Animation<double> _fadeFabOutAnimation;
late Animation<double> _fadeFabInAnimation;

double fabIconAlpha = 1;

///是1的话在中间
int barIndex = 1;

@override
void initState() {
super.initState();

_animationController =
AnimationController(vsync: this, duration: Duration(milliseconds: widget.animationMilliseconds!));
_fadeOutController =
AnimationController(vsync: this, duration: Duration(milliseconds: (widget.animationMilliseconds! ~/ 5)));

_positionTween = Tween<double>(begin: 0, end: 0);
_positionAnimation = _positionTween.animate(CurvedAnimation(parent: _animationController, curve: Curves.easeOut))
..addListener(() {
setState(() {});
});

_fadeFabOutAnimation =
Tween<double>(begin: 1, end: 0).animate(CurvedAnimation(parent: _fadeOutController, curve: Curves.easeOut))
..addListener(() {
setState(() {
fabIconAlpha = _fadeFabOutAnimation.value;
});
})
..addStatusListener((AnimationStatus status) {
if (status == AnimationStatus.completed) {
setState(() {});
}
});

_fadeFabInAnimation = Tween<double>(begin: 0, end: 1)
.animate(CurvedAnimation(parent: _animationController, curve: const Interval(0.8, 1, curve: Curves.easeOut)))
..addListener(() {
setState(() {
fabIconAlpha = _fadeFabInAnimation.value;
});
});
}

@override
Widget build(BuildContext context) {
return Stack(
clipBehavior: Clip.none,
alignment: Alignment.topCenter,
children: <Widget>[
Container(
height: 65,
padding: const EdgeInsets.only(bottom: 18),
decoration: BoxDecoration(
color: widget.backgroundColor,
boxShadow: const [BoxShadow(color: Colors.black12, offset: Offset(0, -1), blurRadius: 8)]),
child: Row(
mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.center,
children: _buildBarItems(),
),
),
Positioned(
top: -45,
left: 0,
right: 0,
child: IgnorePointer(
child: Container(
decoration: const BoxDecoration(color: Colors.transparent),
child: Align(
heightFactor: 0.5,
alignment: Alignment(_positionAnimation.value, -1),//_positionAnimation.value控制这个叠叠的圆的位置,根据点的导航修改圆的x轴的位置
child: FractionallySizedBox(
widthFactor: 1/3,//控制占的宽度
child: Stack(
alignment: Alignment.center,
children: <Widget>[
SizedBox(
height: 90,
width: 90,
child: ClipRect(
clipper: HalfClipper(),//裁剪
child: Center(
child: Container(
width: 70,
height: 70,
decoration: BoxDecoration(
color: widget.backgroundColor,
shape: BoxShape.circle,
boxShadow: const [BoxShadow(color: Colors.black12, blurRadius: 8)])),
)),
),
SizedBox(
height: 60,
width: 60,
child: Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
color: widget.iconBackgroundColor,
border: Border.all(color: Colors.white, width: 5, style: BorderStyle.none)),
child: Padding(
padding: const EdgeInsets.all(0.0),
child: Opacity(
opacity: fabIconAlpha,//白色图标切换时的动画
child: Icon(
widget.tabItem[barIndex].iconData,
color: Colors.white,
),
),
),
),
)
],
),
),
),
),
),
),
],
);
}

List<Widget> _buildBarItems() {
List<Widget> barItems = [];
for (int i = 0; i < widget.tabItem.length; i++) {
barItems.add(Expanded(
child: Stack(
fit: StackFit.expand,
children: [
SizedBox(
height: double.infinity,
width: double.infinity,
child: AnimatedAlign(
// curve: Curves.easeIn,//动画曲线
duration: Duration(milliseconds: widget.animationMilliseconds!),
alignment: Alignment(0, (barIndex == i)? 3 : 8),//控制文字的位置
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
widget.tabItem[i].title,
style: const TextStyle(fontWeight: FontWeight.w600),
),
)),
),
SizedBox(
height: double.infinity,
width: double.infinity,
child:AnimatedOpacity(
duration: Duration(milliseconds: widget.animationMilliseconds!),//动画时长
opacity: (barIndex == i) ? 0 : 1,////控制icon的透明度
child: IconButton(
highlightColor: Colors.transparent,
splashColor: Colors.transparent,
padding: const EdgeInsets.all(0),
alignment: const Alignment(0, 0),
icon: Icon(
widget.tabItem[i].iconData,
color: widget.iconBackgroundColor,
),
onPressed: () {
setState(() {
barIndex = i;
widget.onBarTap(barIndex);
_positionTween.begin = _positionAnimation.value; //圆开始位置
_positionTween.end = i - 1; //圆结束的位置
_animationController.reset();
_fadeOutController.reset();
_animationController.forward();
_fadeOutController.forward();
});
},
),
),
)

],
),
));
}
return barItems;
}
}

//裁剪成一半
class HalfClipper extends CustomClipper<Rect> {
@override
Rect getClip(Size size) {
final rect = Rect.fromLTWH(0, 0, size.width, size.height / 2);
return rect;
}

@override
bool shouldReclip(CustomClipper<Rect> oldClipper) {
return true;
}
}

class TabItem {
final String title;
final IconData iconData;

TabItem({
required this.iconData,
required this.title,
});
}

ENDING 动画真好玩~

本文转载自: 掘金

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

1…737475…956

开发者博客

9558 日志
1953 标签
RSS
© 2025 开发者博客
本站总访问量次
由 Hexo 强力驱动
|
主题 — NexT.Muse v5.1.4
0%