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

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


  • 首页

  • 归档

  • 搜索

门店智能设备间「通信」原理 最后

发表于 2023-11-27

作者:张义飞

背景

古茗门店的后厨放着有很多设备,功能不一样,类型不一样,而且通信机制也不一样。

像最基本的前端和服务端通信,我们需要定义各种接口去实现我们的业务功能,然后依赖http服务进行通信。但是当两台设备需要通信时,我们必须以物理方式或无线方式将它们连接起来,接着再去通信。

我们经常使用的通信方式有:串口通信(USB、CAN)、蓝牙通信、TCP通信。虽然通信方式各种各样,但是原理上基本一致。

作为通信中最为复杂,但也是最被人所熟知的网络通信,这里先大概讲解一下网络通信的原理,那么再讲其他通信方式时也就很好理解了。

一个简单的网络

假如你是一个电脑:

这个时候你很想找个人聊聊天,这个时候你发现了电脑B,于是你们都开了一个网口,用一个网线连接了起来,于是你们就愉快地聊了起来。

这个时候又来了一位C同学,他也想加入到你们的聊天中去,于是你们又开了一个网口,相互连接起来。

随着加入的同学越来越多,你发现需要开的口越来越多了。

随着加入聊天的人越来越多,你身上被开了n个端口,于是你开始思考,可不可以找个中间人,去帮你们进行传话,这样你们就不用彼此相连了。

这个中间人就叫做集线器。

这个时候你再想和同学B说话,你只需要把消息发给中间的设备就行,这个设备会将信息转发到所有设备。

那同学B如何知道是别人在和自己说话呢?于是你们就做了个约定,这个约定就是我们在交流时带上自己的名字,对方的名字以及要说话的内容。

但是这个中间人不够智能,每次帮你们转话时,都要通知到每个人。

这个时候聪明的你发现,这样很不安全也很浪费资源,那有没有一种机器可以将想说的话直接发送给某个人呢?

这个机器就是交换机。

我们只需要让这个机器维护一个映射表,将mac地址和端口映射起来,这样它就能清楚的知道你要找谁说话了。

mac地址 端口
aa 1
bb 2
cc 3
dd 4

再后来人又变多了,交换机也被插满了,聪明的你发现多找几个传话人,让他们分别负责几个人,这样就轻松地解决了这个问题。

再后来交换机也无法维护这么多mac地址了。于是就有了我们的微型计算机——路由器。

路由器也有自己的mac地址,它会为每个连接它的设备通过DHCP或者手动配置的方式分配一个ip地址,并且记录ip地址和设备mac地址的关系(路由器可以通过ARP或网络拓扑的技术来获取和自己相连交换机上的设备的mac地址)。

到此,你已经有了自己的小圈子了(局域网),但是你想要和别的小圈子进行交流就需要更庞大的网络了。

上面说了很多关于ip地址,mac地址,端口。都是在说一个问题:通信双方如何找到对方。下面我们再来看下,找到对方后,如何让对方彼此明白所传信息的含义。

互联网是如何工作的

HTTP 是互联网的基础协议,用于客户端与服务器之间的通信,它规定了客户端和服务器之间的通信格式,包括请求与响应的格式。基本的工作方式如下图所示:

什么是通信协议?

为了能够进行通信,双方(无论是软件、设备、人员等)需要进行一个约定:

  • 语法(数据格式和编码)utf-8 gbk
  • 语义(控制信息和错误处理)crc
  • 语速(速度匹配和排序)mtu

好比两个人交流时,我们首先要确定的是语言,我们是使用中文还是英文进行沟通。

然后就是组成这些句子的含义,如果你用中文来说突然来了一句“打渣子”,对方肯定一脸懵逼。所以你们需要商量好用什么规范进行沟通,比如我挥挥手,你就知道要和我再见了。

另一个就是传输量的控制,你如果上来就是8000字作文上来,我还是一脸懵逼,我的脑CPU容量只能一次接收十个字的话,多了我就理解不了了,你可以多次和我说,但是一次不要说太多。

应用层级协议HTTP

HTTP 是一种建立在TCP/IP (一种通信协议)之上的应用程序级协议

从网络上获取信息

HTTP 协议为客户端提供了一些东西来向服务器表达其含义:URI、HTTP 方法和 HTTP 标头。

客户端请求

服务端响应

设计一个简单的通信协议

上面我们说了什么是通信协议,那么一个通信协议需要具备那些细节?

通信协议设计核心

  1. 解析效率高
  2. 可扩展

协议设计细节

  1. 数据帧完整性判断
  2. 序列化,反序列化
  3. 协议版本,兼容性
  4. 协议安全,加密,防攻击
  5. 数据压缩

消息完整性判断

  1. 固定大小

以固定大小字节来进行分界,比如每个消息是8个字节,不足8个就进行补0处理

  1. 以特定符号分界

比如使用\r\n表示一个消息传输完毕

  1. 固定消息头和消息体

也就是我们说的header + body

  1. 按照某个时间间隔未收到消息表示完整性

这个是很扯淡的设计,很不推荐。

举个🌰

请求包(部分内容)

序号 标识 数据类型 长度(Byte) 含义 说明
0 head uint8_t 1 包头 保密🤐
1 option uint8_t 1 选项
2 seq uint32_t 4 包序列号
3 cmd uint8_t 1 命令字
4 device_id uint64_t 8 设备序列号
5 head_cs uint16_t 2 头部 CRC16
6 data_len uint16_t 2 数据部分长度
7 data_cs uint16_t 2 数据部分CRC16
8(data) seq_spec uint64_t 8 数据部分 seq
str char[n] n - 8 数据部分 json-rpc 字符串

数据包例子

因为涉及到内部实现,这里不方便给出说明,总之这个就是比较经典的head + body 的设计方式,实现起来比较简单一些,我们主要封装好头部。

body部分还是用json来进行处理,这对业务来说就很简单了,业务层只需要定义好具体请求参数就能得到相应的json信息。

比如: 获取设备信息

请求

1
2
3
4
5
6
json复制代码{
"id" : "123", // 用来区分此次请求id,使用自增id
"jsonrpc":"2.0", // 指定JSON-RPC协议版本的字符串,必须准确写为“2.0”
"method" :"ability.getInfo", // 包含所要调用方法名称的字符串。
"params": null // 调用方法所需要的结构化参数值,该成员参数可以被省略。
}

响应

1
2
3
4
5
6
json复制代码{
"id" :"123",
"code":0,
"result" :{ // 返回数据体
}
}

响应中必须要含有id,这个id表示对客户端那个请求的响应。

至此我们只需要按照JSON-RPC协议进行传输就行了。JSON-RPC是一个无状态且轻量级的远程过程调用(RPC)协议。

客户端是如何发起一个请求和接收响应的

简化一下上述流程

谈谈RPC的设计与实现

RPC(Remote Procedure Call)

在远程必须先定义这个方法,然后才可以通过RPC框架调用该方法。远程调用不仅可以传参数、获取到返回值,还可以捕捉调用过程中的异常。RPC让远程调用就像本地调用一样。

就像我们本地调用一个函数一样,但是它存在以下几个问题

  1. 本地函数调用的结果是可预测的,而 RPC 需要经过网络传输,数据在中途可能因为各种原因丢失。
  2. RPC 调用有可能超时,编写程序时需要考虑该情况。
  3. 重试一个失败的 RPC 调用有可能造成数据重复,需要考虑幂等。
  4. 由于传输数据时需要序列化和反序列化,RPC 在传输复杂对象时会不太方便。

客户端向服务端要数据时

1
ini复制代码const userInfo = rpc.getUserInfo(params);

服务端向客户端要数据时

1
2
3
csharp复制代码 server.registerMethod('getUserInfo', (Parameters params) {
return {"name": "bill"};
});

RPC基本模型

对应的代码结构就是

gateway_client: 就是client stub

gateway_server: 就是server stub

gateway_data_util: 就是负责decoding/encoding

socket_base: 就是transport

说到这里总结起来就是基于JSON-RPC为基础的自定义的通信协议,以socket为通信传输基础实现的一套RPC。

异步转同步

基于端口通信最大的问题就是业务处理起来比较麻烦,如果将异步转换成同步调用,那处理业务就很方便了。

1
2
3
4
5
6
dart复制代码socket.on('message', (data) {
if(data.method == 'userInfo') {
//获取到了userInfo
}
print('Message from server: $data');
});

下面这个就好用了

1
ini复制代码var userInfo = await getUserInfo();

我们只需将每个请求看做是一个future(promise),服务端响应了我们就将结果返回,如果超过某个时间未返回,我们就告诉客户端超时了,如果请求过程出错了,我们就告诉客户端错误信息。

核心实现

  1. 构造待请求map
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
scss复制代码  Future send({
String? method,
int cmd = GatewayCmd.dataPost,
Map<String, dynamic>? parameters,
}) async {
Completer completer = Completer();
//超时处理
completer.future.timeout(const Duration(seconds: timeoutSeconds),
onTimeout: () {
completer.completeError(RpcException.timeout());
});
int id = GatewayDataUtil().seqIncrement();
_send(deviceId, cmd, method, parameters, id);
_pendingRequests[id] = _Request(method, completer, Chain.current());
return completer.future;
}
  1. 发送请求
1
2
3
4
5
6
7
kotlin复制代码class _Request {
final String? method;
final Completer completer;
//也可以在这里进行超时处理

_Request(this.method, this.completer);
}
  1. 响应给客户端
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
dart复制代码void onReceiveSocketData(dynamic response) {
int? id;
//解析
Map? jsonMap = response;
if (jsonMap != null) {
id = int.tryParse(jsonMap["id"]);
}
if (id == null) return;
//寻找request
if (_pendingRequests.containsKey(id)) {
// 从map中移除请求
_Request request = _pendingRequests.remove(id)!;
if (jsonMap != null) {
//将数据返回给客户端
request.completer.complete(jsonMap);
} else {
request.completer.complete();
}
} else {
logger.i("未在代请求列表中:$id,请求列表:${_pendingRequests.toString()}");
}
}
}

几个关键点

  1. 保证每次请求的key值唯一,也就是json-rpc中的参数id, 使用递增id。
  2. 在soket端口中需要区分出是服务端响应给客户端的数据,还是服务端向客户端请求的数据,协议层区分

举个🌰

上面说到的socket是基于tcp实现的。客户端和服务器之间首先建立TCP连接,然后通过Socket发送和接收数据 tcp在网络模型中属于传输层协议,那如果我们要使用数据链路层这块进行通信的话,应该如何设计?

CAN协议

CAN是一种用于局域网的串行通信协议,最初设计用于汽车电子系统中的通信

看着有没有像我们上面说的集线器,所有设备会连接到can总线上,如果两个设备要通信,就往总线上进行发送,所有设备都会收到,不过这些设备带有过滤器,可以过滤掉不是发给自己的数据。

那如果线上两个人同时说话了不就有冲突了吗,can协议中有个仲裁段,id越小越优先进行传输。

这和我们上面所说的通信协议中的规则一样以固定帧开始,已固定帧结束。标准帧:11位,也就是一个字节多点

扩展帧可以达到29位也就是四个字节不到。然后最大能传输的数据是8个字节,如果要传输的数据超过8个字节就要进行分包了。

我们在某款设备中使用的是扩展帧:

位 28 27 26 25 24 23 22 21 20 19 18 17 16 15 14 13 12 11 10 9 8 7 6 5 4 3 2 1 0
含义 命令字 源地址,指发送方的地址 目的地址,指接受方的地址 当前包序号 总包数

我们可以看到协议里面包含了源地址和目的地址,这29位的值越小越容易获取到总线的使用权,所以我们应该利用这个特性来定义一些命令和关键ECU的地址。

所以看到这个数据协议我们想下如何实现我们的异步转同步:

1、如何定义我们的map中的[key]值:

使用目的地址+命令+当前包序号来确定[key]。但是存在相同命令发送重复的问题。

2、如何区分是客户端请求还是服务端响应?

这个协议上无法区分出是我们请求获得的响应,还是服务端主动的请求得到的数据。

解决方案:

  1. 通过是否在客户端的请求列表中来判断是否是客户端发起的请求
1
2
3
4
5
6
7
csharp复制代码var request = _pendingRequests.remove(key);
if (request == null) {
// 服务端主动过来的
} else{
//客户端主动请求的
}
}
  1. 相同key值取消发送
1
2
3
4
5
6
kotlin复制代码//如果连续发送了两个请求一致,把第一个取消掉
if (_pendingRequests[key] != null) {
_Request? request = _pendingRequests.remove(key);
request?.completer.completeError({"error": "任务取消了"});
logger.i("取消当前命令:$cmd,当前通道:$targetAddress");
}

当然以上还有很多更好的解决方案:

  1. 可以通过控制协议中的远程帧:来区分是否是请求还是响应。
  2. 端上控制,防止并发,或者如果产生并发问题,可在用一个队列去缓存相同key值的请求,后续进行发送。

架构图

这个目前是我们设备间通信的一个架构图,其中中间件还是待完善的,目前的mock和缓存,重试逻辑都还在应用层进行管理。

最后提个小问题:大家认为RPC在前端领域里有哪些用处呢?

参考链接

zhuanlan.zhihu.com/p/348473362

mp.weixin.qq.com/s/9frk_VS8V…

mp.weixin.qq.com/s/MVGT_J5ql…

最后

📚 小茗文章推荐:

  • 「前端添加水印」你真的了解全面吗?
  • 一文了解Webpack中Tapable事件机制
  • 古茗打印机技术的演进

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

本文转载自: 掘金

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

手把手带你入门 Threejs Shader 系列(五)

发表于 2023-11-26

上篇文章「手把手带你入门 Three.js Shader 系列(四) - 牛衣古柳 - 20231121」古柳教大家如何用 GLSL 里的内置函数 mix 在之前学过的黑白条纹圆圈等基础上插值出不同颜色,并且在实现对角线渐变时提到不同图案相结合时的常用方法。

文章最后依旧是本系列教程的一个特色,会把当篇文章讲过的所有例子配图“全家照”放上,方便大家对照图片去回忆知识点和不看代码自己实现相关效果。不知道大家都看到最后没有,掌握的如何?如果有不理解的地方,或对古柳的教程有任何意见建议,欢迎评论区或群里提。(欢迎加入「可视化交流群」进行交流,加古柳微信「xiaoaizhj」备注「可视化加群」即可)

棋盘格

之前提到古柳看到这张图里的棋盘格图案,觉得可以讲下如何实现,虽然当时还没动手尝试,但大致思路有的,所以这篇文章就先实现下这个。

上面的棋盘格不够直观,网上随便找张平铺的效果方便去分析。对于这样的网格图案,我们可以尝试按行和按列去拆分,理想情况下将拆分后的图案用之前提到的“加减乘除或取最大最小值”等结合方式套下没准就成功了。

是否真的可行,马上试试看!首先在水平和垂直方向上分别实现3组重复的黑白条纹。经过前几篇文章的学习,相信这里的代码对大家而言已经小菜一碟。

1
2
3
4
5
6
7
8
9
C#复制代码varying vec2 vUv;

void main() {
vec3 mask1 = vec3(step(0.5, fract(vUv.x * 3.0)));
vec3 mask2 = vec3(step(0.5, fract(vUv.y * 3.0)));
vec3 color = mask1;
// vec3 color = mask2;
gl_FragColor = vec4(color, 1.0);
}

接着对两种图案直接“无脑”套下六种常见的组合方式,下图从左到右、从上到下依次就是加、减、乘、除、取最小值、取最大值的结果……

1
2
3
4
5
6
7
8
9
10
11
C#复制代码void main() {
vec3 mask1 = vec3(step(0.5, fract(vUv.x * 3.0)));
vec3 mask2 = vec3(step(0.5, fract(vUv.y * 3.0)));
vec3 color = mask1 + mask2;
// vec3 color = mask1 - mask2;
// vec3 color = mask1 * mask2;
// vec3 color = mask1 / mask2;
// vec3 color = min(mask1, mask2);
// vec3 color = max(mask1, mask2);
gl_FragColor = vec4(color, 1.0);
}

很遗憾,虽然有几个看着比较接近,但没有完全符合我们想要效果的。不知道大家看到这样的结果,第一反应会是什么?古柳最初觉得没准方法还是对的,或许用于组合的两种图案黑白位置偏移下就行。

1
2
3
4
C#复制代码vec3 mask1 = vec3(step(0.5, fract(vUv.x * 3.0)));
// vec3 mask1 = vec3(step(0.5, 1.0 - fract(vUv.x * 3.0)));
// vec3 mask2 = vec3(step(0.5, fract(vUv.y * 3.0)));
vec3 mask2 = vec3(step(0.5, 1.0 - fract(vUv.y * 3.0)));

于是对掉黑白0-1位置后再一顿“无脑”套,发现依旧是效果接近但不是想要的。看来脑子🧠下线还是不行,偏移的思路并不正确,得冷静下来分析下每个区域的数值,看看问题到底出在哪里。

解决 bug

上面的图案看着还是复杂、让人头晕,我们不妨将其简化成只剩一组黑白,毕竟其余部分可通过这组图案的重复得到。

1
2
3
4
5
6
7
8
C#复制代码void main() {
vec3 mask1 = vec3(step(0.5, vUv.x));
vec3 mask2 = vec3(step(0.5, vUv.y));
vec3 color = mask1;
// vec3 color = mask2;
// vec3 color = mask1 + mask2;
gl_FragColor = vec4(color, 1.0);
}

然后我们最最最后一次看看组合后的效果。

也就是说现在我们希望基于简化后的图案组合出下图任意一个效果。

我们将图案分成四块区域,以0或1数值的方式重新审视基础图案和组合后的图案。这里古柳就不画图了,大家直接看下面的分析应该就能懂。先看相加的结果,如果有办法把2的位置变成0那么相加就能生效,但似乎没有特别直观的方法可以对每个位置都操作而只把2变成0且其他位置保持不变,暂时想不到那就先跳过;

1
2
3
4
5
6
7
8
9
10
11
12
13
diff复制代码基础图案1 / mask1:
0 1
0 1
基础图案2 / mask2:
1 1
0 0

相加后 / mask1 + mask2:
1 2
0 1
相减后 / mask1 - mask2:
-1 0
0 1

接着看相减,原来左上角的黑色是-1,那么只需通过 abs() 函数取绝对值变成1即白色就行,同时其他位置取绝对值后保持原样,原来离我们想要的效果就差在这一点上!

于是我们回到最初的3组黑白图案,在相减后取绝对值就如愿实现出棋盘格效果。

1
2
3
4
5
6
C#复制代码void main() {
vec3 mask1 = vec3(step(0.5, fract(vUv.x * 3.0)));
vec3 mask2 = vec3(step(0.5, fract(vUv.y * 3.0)));
vec3 color = abs(mask1 - mask2);
gl_FragColor = vec4(color, 1.0);
}

彩色格子

接着我们将上面的0/1数值作为 mixer 去插值颜色,彩色棋盘格就轻松搞定。下图用了前篇文章的两种颜色和黑色白色,并且改变了几何体和重复次数。需要注意的是:球体上的条纹也好、棋盘格也好,因为 uv 的缘故一直看着比较怪,因为这个问题不是当前的重点,所以留待以后有机会再去解决。

1
2
3
4
5
6
7
8
9
10
11
C#复制代码void main() {
vec3 color1 = vec3(1.0, 1.0, 0.0);
// vec3 color1 = vec3(0.0, 0.0, 0.0);
vec3 color2 = vec3(0.0, 1.0, 1.0);
vec3 mask1 = vec3(step(0.5, fract(vUv.x * 3.0)));
vec3 mask2 = vec3(step(0.5, fract(vUv.y * 3.0)));
// vec3 color = abs(mask1 - mask2);
vec3 mixer = abs(mask1 - mask2);
vec3 color = mix(color1, color2, mixer);
gl_FragColor = vec4(color, 1.0);
}

棋盘格的实现可能还有其他方法,古柳自己随便搜索和询问GPT发现给出的代码也与上述不同。其他方法就留给大家自行了解了,当然欢迎评论区或群里提供你的思路。最后放个图,不知道大家是否想起某位“故人”某个角色呢?

通过图形去理解和培养直觉

上面花了好多篇幅讲解古柳在实现棋盘格过程中使用的方法、遇到的问题和解决思路,之所以这样写,也是古柳觉得这比直接告诉大家几行代码然后效果就出来了有意义地多。

而且 shader 里无法像 JavaScript 那样 console.log() 打印数据或 debug 调试,所以大家碰到问题可能会不知道如何解决,此时将数值转换成颜色,多通过图形实际的效果去理解和培养直觉或许是个不错的学习方式。

abs+圆环

这一点刚好和古柳自己前些天重新看 Three.js Journey、看到里面 shader 章节讲圆环实现用到 abs 时同样受到启发,并且正好也用到 abs。虽然取绝对值大家再熟悉不过,但我们还是看看实际 shader 里会在哪用到 abs。

下图我们计算了 vUv 离(0.5, 0.5)位置的距离并以此作为颜色,左图表示了数值从中心0.0往四周增大的效果。

1
2
3
4
5
6
C#复制代码void main() {
float strength = distance(vUv, vec2(0.5));
// float strength = step(0.2, distance(vUv, vec2(0.5)));
vec3 color = vec3(strength);
gl_FragColor = vec4(color, 1.0);
}

假如我们把数值减去0.2,此时数值就从-0.2向四周增大到正数。此时中间区域都是全黑的,图中看着像有灰色的,可能是错觉,验证过用负数vec3(-0.1)作为颜色确实就是全黑的。

1
C#复制代码float strength = distance(vUv, vec2(0.5)) - 0.2;

此时取绝对值就变成从离中心0.2的位置数值为0.0而向外和向内都是增大。中心区域因为数值大于0.0出现灰色。

1
C#复制代码float strength = abs(distance(vUv, vec2(0.5)) - 0.2);

然后对一个较小的数取 step 就能画出圆环效果,圆环上是数值小于0.01的变成0.0,外部和内部是大于0.01的变成1.0。

1
2
C#复制代码float strength = step(0.01, abs(distance(vUv, vec2(0.5)) - 0.2));
// float strength = 1.0 - step(0.01, abs(distance(vUv, vec2(0.5)) - 0.2));

一些启发

大家也能发现在 shader 里绘制一些效果,和一般诸如 svg、canvas 2D 等大家更熟悉更常规的方式非常不同。当看到一些新效果如这里的圆环,一开始大家可能觉得前几章没学过这个,对于怎么绘制完全没头绪,但如果建立起从图形去看到背后数值分布,再到如何从我们之前学过的效果进行变化的思维,或许思路就打开了。

比如上述过程,从中心向四周数值增大的操作我们已经会了、那么先减去一个数值变化出负数范围、再取绝对值变成从某个位置向内向外数值增大,最后 step 一下圆环就成功了;反过来,看到圆环黑色的为0,向外往内都是白色为1,数值往两个方向增大,就会想起先减去数值变化出负数再取绝对值的操作……

自2022年4月古柳知道 shader 至今已1年半多,但如上所说,也是前些天看到圆环的实现时重新有了这样的启发。(欢迎加入「可视化交流群」进行交流,加古柳微信「xiaoaizhj」备注「可视化加群」即可,也有机会围观古柳朋友圈,实时追踪最新动态!)

想起自学以来很长一段时间,看过很多 shader 教程后,常常感觉离了代码后学过的知识完全用不起来,好像每个教程之间都是割裂的,也许就是因为一些 shader 特殊的思维还没建立起来。如果大家看这个系列文章时也觉得一下画圆圈、一下画条纹、一下画棋盘格比较割裂,不如用上面的方式,多站在图形实际效果和背后数值分布的思路上去想想,或者对 shader 里的实现思路能有更进一步地理解和掌握。

对角线渐变用 abs 再度实现

上面讲了这么多,听起来还是有点虚是不是,不如看点实际的。不知道大家有没有想起上篇文章里我们想实现的对角线上0到1再到0的渐变方式,重新看是不是就发现和圆环效果是类似的,我们希望中间白色即数值1,往两边变化到黑色即数值0,这不就和上面 abs 取绝对值操作一样嘛,此时根本不需要通过两组图案去组合。

1
2
3
4
5
6
7
C#复制代码void main() {
float mixer1 = vUv.x + vUv.y;
float mixer2 = 2.0 - (vUv.x + vUv.y);
float mixer = min(mixer1, mixer2);
vec3 color = vec3(mixer);
gl_FragColor = vec4(color, 1.0);
}

vUv.x+vUv.y 从左下角到右上角范围是0.0-2.0,我们先减去1.0变化到-1.0-1.0,再取绝对值变成1.0-0.0-1.0,最后用1.0减去变成0.0-1.0-0.0就是所需的效果了。是不是有种豁然开朗的感觉。

1
2
3
4
5
6
7
8
C#复制代码void main() {
// float mixer = vUv.x + vUv.y;
// float mixer = vUv.x + vUv.y - 1.0;
// float mixer = abs((vUv.x + vUv.y - 1.0));
float mixer = 1.0 - abs((vUv.x + vUv.y - 1.0));
vec3 color = vec3(mixer);
gl_FragColor = vec4(color, 1.0);
}

额外例子

如果大家还好奇 abs 在哪用到,这里再放个古柳前不久看到的视频教程,虽然里面涉及的顶点着色器我们在下一篇才讲到,但相信有些人本身就有基础那么提前去拓展学习下也是不错的。当然后续文章可能也会讲解这个例子到底如何实现,还看不懂的朋友不必着急,一步步学下去就行。

  • 链接:www.bilibili.com/video/BV1Su…
  • 链接:www.youtube.com/watch?v=ixE…

抗锯齿 aastep

最后还有个问题一直留着没带大家解决,就是类似下面颜色突变图形的边缘存在明显锯齿现象。

1
2
3
4
5
6
7
8
C#复制代码varying vec2 vUv;

void main() {
float strength = step(0.25, distance(vUv, vec2(0.5)));
// float strength = step(0.01, abs(distance(vUv, vec2(0.5)) - 0.2));
vec3 color = vec3(strength);
gl_FragColor = vec4(color, 1.0);
}

在 Three.js 里我们一般通过设置 renderer 里的 antialias 为 true 和设置 pixel ratio 来开启抗锯齿。

1
2
3
4
js复制代码const renderer = new THREE.WebGLRenderer({
antialias: true,
});
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));

在 shader 里可以通过非内置的 aastep 函数(anti-alias smoothstep utility function)来替换 step 函数,两者接收的参数一致所以很好理解。谷歌搜索 glsl aastep 会找到函数的实现,将其拷贝到我们片元着色器 main 函数前面即可。

  • 链接:github.com/glslify/gls…
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
C#复制代码float aastep(float threshold, float value) {
#ifdef GL_OES_standard_derivatives
float afwidth = length(vec2(dFdx(value), dFdy(value))) * 0.70710678118654757;
return smoothstep(threshold-afwidth, threshold+afwidth, value);
#else
return step(threshold, value);
#endif
}

void main() {
// float strength = aastep(0.25, distance(vUv, vec2(0.5)));
float strength = aastep(0.01, abs(distance(vUv, vec2(0.5)) - 0.2));
vec3 color = vec3(strength);
gl_FragColor = vec4(color, 1.0);
}

不过此时需要在 ShaderMaterial 里设置 extensions: { derivatives: true } 才能生效,否则会报错。

1
2
3
4
5
6
7
8
js复制代码const material = new THREE.ShaderMaterial({
uniforms: { uTime: { value: 0 } },
vertexShader: vertex,
fragmentShader: fragment,
extensions: {
derivatives: true,
},
});

可以看到效果很好,边缘很平滑!

smoothstep

当然我们看 aastep 函数的实现里面用到了 smoothstep() 内置函数。smoothstep(edge1, edge2, x) 接收3个参数,当 x<=edge1 时返回0.0;当 x>=edge2 时返回1.0;当 edge1<x<edge2 时返回平滑插值的数值。

  • 链接:registry.khronos.org/OpenGL-Refp…

一个简单的例子看看运用 smoothstep 后 vUv.x 在0.2-0.8之间平滑过渡的效果。

1
2
3
4
5
6
7
C#复制代码varying vec2 vUv;

void main() {
float strength = smoothstep(0.2, 0.8, vUv.x);
vec3 color = vec3(strength);
gl_FragColor = vec4(color, 1.0);
}

原本 smoothstep 和 mix、distance、length、clamp、sin、step 等内置函数一样都是 GLSL 里很常用、随处可见的,但因为古柳一直也还没想到比较好的例子来讲解 smoothstep,所以有意在前面的文章里都没去提起,这里既然出现了,那就先暂时提一嘴,等后续有好的例子再多讲讲。

小结

原本古柳想着从下篇文章开始先讲讲顶点着色器,毕竟讲了好几篇片元着色器可以“换换口味”了,因此一开始合计着本文作为第一阶段片元着色器的最后一篇,可以讲下棋盘格的实现以及下面这种一块块区域里产生一个相同数值的离散化操作,如果内容还不够的话再讲下如何在 shader 里显示纹理贴图。

顺带讲下离散化后的数值可以用于实现这种栅格动画效果,当然具体实现还不会先讲,但吊吊大家胃口,等顶点着色器讲的差不多再看看回过头讲这个例子。

但如大家所见,这些内容暂时来不急讲了(不过胃口还是得吊上,哈哈),本文早已破4千字也该结束了。

其实在最近两篇文章写作中,有太多内容是古柳自己一开始没预料到会那么去写的,想实现的效果、碰上的问题、解决的方法、一些经验的总结与启发,好多是在边实现边写的过程中自然而然地展开的,而这样的过程也让古柳自己受益匪浅,一些此前没有的思路都在不断浮现出来。当然因此导致一些原本想讲的内容来不及讲,不过后续总有机会再讲所以问题倒也不大。

如果本系列只是简单介绍下 GLSL 内置函数如何使用,那么将与其他教程无多大差别,而截至目前所呈现出来的样貌已经令古柳自己也有些满意的,不知道大家经过这五篇文章的学习,对本系列教程的评价又是如何,是否也有所收获呢,欢迎评论区和群里回复;如觉得之前或之后的教程中有没讲清楚的内容或有任何意见建议都欢迎提。

那么下一篇文章开始我们将进入第二阶段的学习,正式上手顶点着色器,这也意味着距离讲解这个让古柳见识到 shader 有多酷的入坑之作 Pepyaka 的实现也快了(再次吊个胃口)。敬请期待!

  • 链接:www.bilibili.com/video/BV1UZ…

当然在开启新的征程前,也别忘了回顾本篇教程的所有例子。

照例

如果你喜欢本文内容,欢迎以各种方式支持,这也是对古柳输出教程的一种正向鼓励!

最后欢迎加入「可视化交流群」,进群多多交流,对本文任何地方有疑惑的可以群里提问。加古柳微信:xiaoaizhj,备注「可视化加群」即可。

欢迎关注古柳的公众号「牛衣古柳」,并设置星标,以便第一时间收到更新。

本文转载自: 掘金

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

Flutter 小技巧之 316 升级最坑 M3 默认适配

发表于 2023-11-23

如果要说 Flutter 3.16 升级里是最坑的是什么?那我肯定要说是 Material 3 default (M3)。

倒不是说 M3 bug 多,也不是 M3 在 3.16 上使用起来多麻烦,因为虽然从 3.16 开始,MaterialApp 里的 useMaterial3 默认会是 true,但是你是可以直接 使用 useMaterial3: false 来关闭。

那为什么还收坑?因为未来 Material 2 相关的东西会被弃用并删除,所以 Material 3 default(M3) 是一个警告,你可以通过 useMaterial3: false 来关闭无视,但是这个技术债未来会很坑。

难道你还能一直苟着不更新?

为什么说它很坑?因为适配它纯纯是一个体力活,而且还是一个细节工作,M3 是一套配色方案,一套和 M2 「毫不相关」的配色方案:

  • 配色方案代表着它已经帮你默认确定了什么地方应该用什么颜色
  • M2 毫不相干,代表着你之前用这 M2 的 Widget 默认的 UI 效果,用了 M3 会完全不一样

如上图所示,看起来好像是就是:

  • AppBar 配色发生了变化
  • FloatingActionButton 从圆的变成方的,颜色发生变化
  • 默认 Button 按照风格发生了变卦
  • ······

似乎看起来也没什么,但是你知道有多少地方用了 FloatingActionButton ?每个地方的 AppBar 难道都要手动去调整?ElevatedButton 和 TextButton 有没有办法全局配置?本篇就是为了让你少走适配弯路,提供适配思路的角度。

核心还是国内的产品有谁愿意使用 Material Design ? 像这种 M2 到 M3 的变化,对于开发者来说纯粹就是负优化。

开始

首先,官方 Material 3 配色首推是使用 ColorScheme.fromSeed() 来生成配色,当然你也可以通过 ColorScheme.fromImageProvider 的图片来生成配色,不过一般人应该不会这么干,另外还有 ColorScheme.fromSwatch ,不过这个的灵活适配程度不如 fromSeed,所以使用 fromSeed 是比较好的选择。

因为 M3 默认从蓝色系列变成紫色系统,所以如果你用的是默认色系,那就更需要配置来恢复,本篇的目的就是,让 App 在 M3 下恢复到 M2 的 UI 效果,因为它真的不是仅仅一个颜色变化而已。

如果你以前的 ThemeData 是如下所示代码,那么运行之后你会看到,原本应该是 M2 效果的正常列表,现在变成了 M3 那种「无法言喻」的效果,可以看到此时 M3 下 primarySwatch 其实并没有起到作用。

1
2
3
4
dart复制代码ThemeData(
primarySwatch: Colors.blue,
////
)
M2 M3

那么首先我们要做的就是增加 colorScheme ,但是你在加完会发现并没有什么变化,这是因为此时控件还是处于 M3 的色系下,所以接下来我们要首先全局恢复 Appbar。

1
2
3
4
dart复制代码ThemeData(
primarySwatch: Colors.blue,
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
),

Do it。

AppBar

如下代码所示,我们先添加 AppBarTheme ,可以看到 AppBar 的背景这样就变回了蓝色,但是这时候 Appbar 的文本和图标还是黑色。

1
2
3
4
5
6
7
8
dart复制代码ThemeData(
primarySwatch: Colors.blue,
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),

appBarTheme: AppBarTheme(
backgroundColor: Colors.blue,
),
),

为了让图标和文本恢复到 M2 的白色,我们可以在 AppBarTheme 里配置 iconTheme 和 titleTextStyle ,可以看到配置后如下图所示,UI 上 AppBar 已经恢复到 M2 的效果,那么此时你可以会疑惑,为什么修改的配置是 size: 24.0 和 Typography.dense2021.titleLarge ?

1
2
3
4
5
6
7
8
dart复制代码AppBarTheme(
iconTheme: IconThemeData(
color: Colors.white,
size: 24.0,
),
backgroundColor: Colors.blue,
titleTextStyle: Typography.dense2014.titleLarge,
)

其实这就是本篇的核心:在 M2 控件还没被剔除的时候,通过参考源码将 M3 UI 恢复到 M2 。

例如在 3.16 的源码里,theme.useMaterial3 ? 这样的代码目前随处可见,而此时 AppBar 里:

  • _AppBarDefaultsM3 下 icon 的颜色是通过 onSurface 字段,大小是 24
  • _AppBarDefaultsM2 下 icon 是直接使用 theme 下默认的样式,也就是 size 24, 颜色白色。

M2 M3

所以我们可以在上面的 IconThemeData 里可以直接配置 color: Colors.white, size: 24.0, 来恢复到 M2 的效果。

当然你也可以配置 ColorScheme 的 onSurface 来改变颜色,但是这个影响返回太大,还是推荐配置 AppBarTheme 的 IconThemeData 。

另外可以看到,此时还有一个 Typography.dense2014.titleLarge ,这又是哪里来的?还是回到_AppBarDefaultsM3 里,在 M3 下, AppBar 使用的是 ThemeData 下的 textTheme.titleLarge ,而默认字体样式配置,基本来自 Typography 对象。

Typography 里默认配置了大量字体配置,例如 Typography.dense2014 对应就是如下所示配置,从上面代码可以看到默认情况下 M2 用的是 Typography.material2014 ,对应就是 Typography.dense2014,也就是在 AppBar 上 Typography.dense2014.titleLarge 就可以让 M3 的 AppBar 文本恢复到 M2 的样式。

看到这里你是否已经学会了大概的思路?

通过 theme.useMaterial3 去检索控件,然后在源码里找到 M2 的实现,然后将其修改到全局的主题设置里,比如 AppBar 的就通过 AppBarTheme 配置,如果是 M2 的实现又引用了某些默认配置,就去检索这些默认配置的起源,所以说 M3 这个坑是一个体力活。

当然,这个思路下,有一些控件适配起来还是会有坑,因为它的变化确实有点大,例如 Card 控件。

Card

如图所示,这是 Card 控件在 M2 和 M3 下的变化,除了默认弧度之后,最主要就是颜色发生了改变,从默认白色变成了带着浅蓝色的效果,但是这里有个坑,就是,此时就算你给 Card 设置 color: Colors.white, ,它也依旧会带着这个浅蓝色的效果。

M2 M3

那么这个颜色如何去除?其实只要 ColorScheme 下设置 surfaceTint 为透明色就可以了,如下图所示,因为 Card 的效果是通过封装 Material 控件实现,而 Material 在 M3 下会通过 elevation 和 surfaceTint 去合成一个覆盖色。

1
2
3
4
5
6
dart复制代码ColorScheme.fromSeed(
seedColor: Colors.blue,

///影响 card 的表色,因为 M3 下是 applySurfaceTint ,在 Material 里
surfaceTint: Colors.transparent,
),

所以根据判断,将 surfaceTint 设置成透明就可以去除 Card 这个覆盖色,这个逻辑在 BottomAppBar 里同样存在,所以如果你需要把它们都恢复都 M2 效果,那么就只需要把 surfaceTint 设置成透明色即可。

image-20231123172627998

所以类似的变动才是 M3 里最坑的点,如果你不了解他们的底层实现,那么在升级之后,发现明明代码给了白色,为什么它还是会有浅蓝色效果?这对于开发者来就是一个找🐛的天坑,所以在这里也用 Card 提供一个解决问题的典型思路。

另外还有一个典型的控件,那就是 FloatingActionButton(FAB) 。

FloatingActionButton

从 M2 到 M3, FloatingActionButton(FAB) 控件最大的变化就是变成了方形,其次颜色也不跟随之前和主题蓝色,我们不说 M3 这个「优化」如何,就说如何恢复到 M2 的效果。

M2 M3

首先按照惯例,肯定有一个叫 floatingActionButtonTheme 的参数,可以用于配置 FloatingActionButtonThemeData ,所以这里我们首先添加上配置,然后通过 shape 先变回原形,并且修改 backgroundColor 变成蓝色。

1
2
3
4
dart复制代码floatingActionButtonTheme: FloatingActionButtonThemeData(
backgroundColor: Colors.blue,
shape: CircleBorder()
),

那么此时剩下的就是 Icon 的颜色,我们当然可以在用到 Icon 的地方手动修改为白色,但是我们希望的是全局配置默认恢复到 M2 时代,所以我们就要去找 FAB 下 Icon 是如何获取到颜色的。

而寻找这个颜色的实现,居然就让我开启了一段漫长的旅程·····

首先 Icon 肯定是通过 IconThemeData 去获取默认颜色,因为 FAB 的主题下没有 iconTheme 可以配置,那么首先就想到配置一个全局的 iconTheme: IconThemeData ,但是神奇的问题来了,配置之后居然无效。

那么就开始往上查找,然后依次返现, FAB 内部是通过 RawMaterialButton 实现的点击,而 RawMaterialButton 内部就有一个 IconTheme.merge 的实现,那么 FAB 里的 Icon 默认应该是使用了 effectiveTextColor 这个颜色。

之后开始经历一番漫长检索关联,最终可以看到:

  • 这个 effectiveTextColor 来自从 FAB 传入的 TextSytle 的 color
  • 而 textSytle 来自 extendedTextStyle
  • 而 extendedTextStyle 来自 foregroundColor
  • foregroundColor 默认来自 floatingActionButtonTheme 的 foregroundColor

image-20231123174431747

所以破案了,需要全局设置 FAB 下 Icon 的颜色,是要配置 FloatingActionButtonThemeData 的 foregroundColor ,这个设定和名称正常情况下谁能想得到呢?

而且这个传递嵌套如此“隐晦”,只能说, FAB 是 Flutter 样式跟踪里很典型的一个代表:传递深,theme 引用复杂,类似 merge/copy 的局部实现太过隐蔽。

1
2
3
4
dart复制代码floatingActionButtonTheme: FloatingActionButtonThemeData(
backgroundColor: Colors.blue,
foregroundColor: Colors.blue,
shape: CircleBorder()),

另外关于 IconThemeData 还有一个冷知识,参数不全的情况下,也就是不满足 isConcrete 的情况下,其他的参数在 of(context) 的时候是会被 fallback 覆盖,这个对于 M3 - M2 的降级适配里也是一个关键信息。

primarySwatch

最后在聊一个 ThemeData 的 primarySwatch,为什么聊它,因为如果你的代码里用了 primaryColorDark 和 primaryColorLight 作为配置,那么使用 ColorScheme.fromSeed 之后,它们会发生一些「奇妙的变化」,所以为了它们可以恢复到 M2 模式,那么设置 primarySwatch 可以将它们恢复到原有的效果。

最后

如下所示是本次升级适配里的示例代码总和,其实 M3 模式下「降级」到 M2 UI 效果真的是一个体力活,类似上面三个典型的例子,都可以看出来跟踪默认 UI 的实现并不轻松,虽然对于 Flutter 团队来说,升级到 M3 可能是一次正向优化,但是对于不喜欢 Material Design 的国区而言,M3 只能是一个负优化,不知道大家同意不?

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
dart复制代码return ThemeData(
///用来适配 Theme.of(context).primaryColorLight 和 primaryColorDark 的颜色变化,不设置可能会是默认蓝色
primarySwatch: color as MaterialColor,

/// Card 在 M3 下,会有 apply Overlay

colorScheme: ColorScheme.fromSeed(
seedColor: color,
primary: color,

brightness: Brightness.light,

///影响 card 的表色,因为 M3 下是 applySurfaceTint ,在 Material 里
surfaceTint: Colors.transparent,
),

/// 受到 iconThemeData.isConcrete 的印象,需要全参数才不会进入 fallback
iconTheme: IconThemeData(
size: 24.0,
fill: 0.0,
weight: 400.0,
grade: 0.0,
opticalSize: 48.0,
color: Colors.white,
opacity: 0.8,
),

///修改 FloatingActionButton的默认主题行为
floatingActionButtonTheme: FloatingActionButtonThemeData(
foregroundColor: Colors.white,
backgroundColor: color,
shape: CircleBorder()),
appBarTheme: AppBarTheme(
iconTheme: IconThemeData(
color: Colors.white,
size: 24.0,
),
backgroundColor: color,
titleTextStyle: Typography.dense2014.titleLarge,
systemOverlayStyle: SystemUiOverlayStyle.light,
),

本文转载自: 掘金

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

开源一个局域网文件共享工具

发表于 2023-11-23

shigen日更文章的博客写手,擅长Java、python、vue、shell等编程语言和各种应用程序、脚本的开发。记录成长,分享认知,留住感动。

hello,夜深了,又是shigen深夜写博客的时间啦,今天分享的内容是《开源一个局域网文件共享工具》。

不知道大家有没有这样的需求,我有多个设备,或者我想给别的同事传递文件,但是不想在互联网上传播(情况很少哈),一般要求我们有相同的软件才行。但是我又不想安装软件,因为安装软件又会占用额外的空间,该怎么办呢?

其实shigen在小的时候很喜欢这个东西:

蓝牙

对,你没有看错,就是叫做bluetooth的东西,那个时候只要蓝牙一打开配对成功,完全可以传输文件和视频了,只不过速度慢得可怜。

其实多年来发现我一直是个很喜欢倒腾的人,在自己学的技术越来越多的时候,我也开始研究文件的传输的。这不,我研究了一下文件的局域网传输。

意思就是大家同处于一个网络环境下,如:一个公司一个家庭,这样的话,我们就具备了一个内网地址,比方说shigen此时的内网地址是:192.168.0.103

我的内网

那隔壁的室友是可以通过局域网和我本机暴露在外的服务进行通信的。是不是很神奇?并且网速是直接拉满,而且不会收取一分钱,不信你去把网线拔了试试,丝毫不受影响。

玩过云服务器的其实也都知道,内网的流量是不收费的。

那现在正题来了,shigen基于这个技术搞了什么呢?一起拭目以待。

我们运行一下服务:

1
vbscript复制代码 file-server

接下来你会发现控制台的输出:

控制台的输出

这里的二维码经过特殊的处理,否则部分平台可能审核不通过。

发现这里出现了一个二维码,并且下方显示了我的服务的内网地址和外网地址。让你的好奇心驱使你拿起手机扫描屏幕的二维码,神奇的事情再次发生:

前端页面

此时页面跳转到了浏览器,出现了一个文件上传的方框(支持拖拽、点击选择文件上传),下方依旧有一个二维码。我们点击上传文件试试:

文件上传

此时,文件很快就上传了,并且我们看看控制台输出:

输出的进度条

展示出了完美的进度条。

那就这么简单吗,其实并不是,shigen还做了一个类似于后端管理的功能,我们在浏览器输入http://192.168.0.103:9000/list,我们会看到项目的文件目录:

项目文件目录

详细的文件列表

我们随便点击一个下载,即可下载文件:

下载文件

此时文件就可放在我们的本地了。

那么觉得这个工具好用的话,可以去shigen的github看看。上边有整个的代码案例供大家参考。当然,代码中也有很多的设计优化点:

  1. 进度条的显示问题
  2. 在终端和前端的二维码显示和布局问题
  3. 文件夹切换问题

shigen后期也会不断的优化,丰富这个功能。


以上就是今天分享的全部内容了,觉得不错的话,记得点赞 在看 关注支持一下哈,您的鼓励和支持将是shigen坚持日更的动力。同时,shigen在多个平台都有文章的同步,也可以同步的浏览和订阅:

平台 账号 链接
CSDN shigen01 shigen的CSDN主页
知乎 gen-2019 shigen的知乎主页
掘金 shigen01 shigen的掘金主页
腾讯云开发者社区 shigen shigen的腾讯云开发者社区主页
微信公众平台 shigen 公众号名:shigen

微信搜一搜

与shigen一起,每天不一样!

本文转载自: 掘金

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

手把手带你入门 Threejs Shader 系列(四)

发表于 2023-11-21

通过前两篇文章的学习,相信大家对片元着色器里的 uv 纹理坐标、GLSL 里的 sin、step、distance 等内置函数,以及如何应用这些知识绘制颜色渐变与突变、重复条纹、圆圈等效果都有了一定了解。如果还不了解可以看看「手把手带你入门 Three.js Shader 系列(二) - 古柳 - 20230716」、
「手把手带你入门 Three.js Shader 系列(三) - 古柳 - 20230725」。

但有个问题还没解决,那些条纹圆圈都是黑白的,而实际中我们希望应用上想要的颜色,比如类似下图两种彩色交替的条纹小球,又该如何实现?(看到这张图想到棋盘格子的效果后续也可以加进来讲解下)

内置函数 mix 线性插值

其实上篇文章末尾有“剧透”,我们用 mix 内置函数对两种颜色进行插值即可轻松实现,之前的代码仍可使用。

mix(x, y, a) 为线性插值,结果为 x*(1-a)+y*a,浮点数 a 的范围是0.0到1.0,根据其数值大小对 x、y 进行插值。当 a=0.0 时,mix 的结果为 x;a=1.0 时,结果为 y;a=0.5 时,结果为 (x+y)/2.0 … 其中 x、y 可以是 float/vec2/vec3/vec4 等数据类型,只要两者类型一致就行,插值后的结果也是同一类型。

1
2
3
4
5
C#复制代码// linear interpolation 线性插值
mix(x, y, 0.0) => x
mix(x, y, 1.0) => y
mix(x, y, 0.5) => (x + y) / 2.0
mix(x, y, a) => x * (1 - a) + y * a

比如当 x、y 为两种 vec3 颜色,用 vUv.x 去插值时,结果就是分别对 rgb 各分量套下上述公式,就能算出具体颜色值。

1
2
3
4
5
6
7
8
9
10
11
C#复制代码mix(color1, color2, vUv.x);
x = color1 = vec3(1.0, 0.0, 0.0) = red
y = color2 = vec3(0.0, 1.0, 0.0) = green
a = vUv.x = 0.0 - 1.0 范围

mix(x, y, a) = x * (1-a) + y * a
当 a = 0.25 =>
x * (1 - 0.25) + y * 0.25
= x * 0.75 + y * 0.25
= vec3(1.0, 0.0, 0.0) * 0.75 + vec3(0.0, 1.0, 0.0) * 0.25
= vec3(0.75, 0.25, 0.00) // 分别对 rgb 分量套下公式得到对应数值

知道了 mix 怎么使用后,我们将前两篇文章里各种方式生成的 0-1 数值再去 mix 两种想要的颜色即可解决上文提到的问题。

不过毕竟这篇文章又是过去快4个月才更新(群里倒是更活跃些,欢迎加入「可视化交流群」进行交流,加古柳微信「xiaoaizhj」备注「可视化加群」即可,也有机会围观古柳朋友圈,实时追踪最新动态!),可能大家也都忘了前面学过的知识,加上大家自己去实现难免会出现些奇奇怪怪的问题,所以还是古柳带大家一起实现下各种效果,并穿插讲解些新的内容。话不多说,进入正题。

黑白渐变与突变

首先从熟悉的黑白渐变讲起,准备好两种 vec3 格式的黑白颜色,通过 vUv.x 去插值颜色,效果和直接用 vec3(vUv.x) 一样。结合 step 就能实现黑白突变的效果。注意 color 变量也是 vec3 具体要和被插值数据的类型一致。

1
2
3
4
5
6
7
8
9
10
11
12
13
C#复制代码varying vec2 vUv;

void main() {
vec3 color1 = vec3(0.0);
vec3 color2 = vec3(1.0);
// 黑白渐变
vec3 color = mix(color1, color2, vUv.x);
// vec3 color = vec3(vUv.x); // 相同效果
// 黑白突变
// vec3 color = mix(color1, color2, step(0.5, vUv.x));
// vec3 color = vec3(step(0.5, vUv.x));
gl_FragColor = vec4(color, 1.0);
}

红绿渐变和突变

用 mix 去插值黑白颜色多少有些“脱裤子放屁——多此一举”,那么替换成实际的两种彩色,比如红色、绿色……嗯,虽然 mix 简单好用的特性开始显现,但这配色也是一言难尽,所以还是赶紧换掉吧!

1
2
3
4
5
6
7
C#复制代码void main() {
vec3 color1 = vec3(1.0, 0.0, 0.0);
vec3 color2 = vec3(0.0, 1.0, 0.0);
vec3 color = mix(color1, color2, vUv.x);
// vec3 color = mix(color1, color2, step(0.5, vUv.x));
gl_FragColor = vec4(color, 1.0);
}

粉色青色黄色渐变和突变

当然这里的目的不在于挑选合适的颜色,而是为了后续讲解方便,所以古柳简单地从 rgb 任意两个分量为 1.0 的三种颜色——黄色、青色、粉色——里选了一组配色,比如黄色搭配青色的看着不错,作为后续演示的配色!另外这里将插值的数值改用 mixer 变量表示,后续公式复杂了抽离出来更直观。

1
2
3
4
5
6
7
8
9
C#复制代码void main() {
// vec3(1.0, 0.0, 1.0) // pink 粉色
vec3 color1 = vec3(1.0, 1.0, 0.0); // yellow 黄色
vec3 color2 = vec3(0.0, 1.0, 1.0); // cyan 青色
float mixer = vUv.x;
// float mixer = step(0.5, vUv.x);
vec3 color = mix(color1, color2, mixer);
gl_FragColor = vec4(color, 1.0);
}

重复条纹+插值颜色

敲定好颜色后,我们对之前实现的重复条纹再应用上颜色,将 vUv.x 乘以3.0再取小数后变成重复3次的0.0-1.0数值作为 mixer 去插值两种颜色即可。替换几何体后彩色条纹小球也就一并实现出来了!

1
2
3
4
5
6
7
8
C#复制代码void main() {
vec3 color1 = vec3(1.0, 1.0, 0.0);
vec3 color2 = vec3(0.0, 1.0, 1.0);
float mixer = fract(vUv.x * 3.0);
// float mixer = step(0.5, fract(vUv.y * 3.0));
vec3 color = mix(color1, color2, mixer);
gl_FragColor = vec4(color, 1.0);
}

圆圈+插值颜色

在圆圈效果上应用同样很简单,将离中心的距离数值作为 mixer 去插值颜色即可。有了前几篇的讲解,相信大家对这里的实现应该不会有什么疑问了。重复圆圈和替换几何体后的效果也一并附上。

1
2
3
4
5
6
7
8
9
C#复制代码void main() {
vec3 color1 = vec3(1.0, 1.0, 0.0);
vec3 color2 = vec3(0.0, 1.0, 1.0);
float mixer = length(vUv - vec2(0.5));
// float mixer = length(fract(vUv * 3.0) - vec2(0.5));
mixer = step(0.25, mixer);
vec3 color = mix(color1, color2, mixer);
gl_FragColor = vec4(color, 1.0);
}

对角线上颜色渐变

前几个例子都是炒炒冷饭,所以讲解得很快,这里古柳突然想到可以讲下如何在对角线上应用颜色渐变,并借此讲解些新内容。

比如我们想在左下角到右上角的对角线上进行渐变,直接用 vUv.x+vUv.y 作为 mixer 会发现当到达 (0.5,0.5) 后再往右上方数值都是大于1.0,所以插值出来的颜色都会是 color2 即青色。

1
2
3
4
5
6
7
8
C#复制代码void main() {
vec3 color1 = vec3(1.0, 1.0, 0.0);
vec3 color2 = vec3(0.0, 1.0, 1.0);
float mixer = vUv.x + vUv.y;
vec3 color = mix(color1, color2, mixer);
// vec3 color = vec3(mixer);
gl_FragColor = vec4(color, 1.0);
}

解决办法很简单,因为此时从左下角到右上角 mixer 范围是 0.0-2.0,那么除以2.0缩小一倍变回0.0-1.0即可。

1
C#复制代码float mixer = (vUv.x + vUv.y) / 2.0;

如何想将颜色反过来,可以把 mixer 数值用 1.0 减去,这样就从 0.0-1.0 变化到 1.0-0.0;或者直接在 mix 时调换两种颜色顺序也行。

1
2
3
C#复制代码float mixer = 1.0 - (vUv.x + vUv.y) / 2.0;
// 或者调换颜色顺序,任选其一
// vec3 color = mix(color2, color1, mixer);

对角线上颜色突变

将 mixer 数值通过 step 函数处理就能得到颜色突变的三角形拼接效果。

1
2
3
4
5
6
7
8
9
C#复制代码void main() {
vec3 color1 = vec3(1.0, 1.0, 0.0);
vec3 color2 = vec3(0.0, 1.0, 1.0);
float mixer = (vUv.x + vUv.y) / 2.0;
// float mixer = 1.0 - (vUv.x + vUv.y) / 2.0;
mixer = step(0.5, mixer);
vec3 color = mix(color1, color2, mixer);
gl_FragColor = vec4(color, 1.0);
}

对角线上渐变过去再渐变回来(来去之间x)

前面的渐变都是有去无回的,那么如何实现这种从黄色渐变到青色再渐变回黄色的效果呢?这里古柳建议大家在此暂停阅读、先尝试下自己实现,如果之前没接触过下面所讲的方法,应该还是蛮难想到该如何实现的吧!

希望大家真的尝试过后再看这里的讲解,那样印象更深,当然毫无头绪也很正常,不卖关子了,新知识来袭!

从左下角到右上角 vUv.x+vUv.y 数值从 0.0-2.0 变化,在中间位置数值就是1.0为青色,这说明左下方没什么问题,想渐变回去,就是要把右上方 1.0-2.0 变到 1.0-0.0,此时数值就是和左下方沿着对角线对称。

也就是说我们的目的是得到图中第3个效果0.0到1.0再到0.0,目前有了第1个效果 vUv.x+vUv.y,第2个效果结合上面颜色反过来的操作,可以通过 2.0-(vUv.x+vUv.y) 得到,接下来的问题就是如何把这两个效果进行结合?

1
2
3
C#复制代码float mixer1 = vUv.x + vUv.y;
float mixer2 = 2.0 - (vUv.x + vUv.y);
float mixer = ???

一般涉及这种结合的,比较常见的就是相加、相减、相乘、相除、或取最小值、取最大值。我们将一些位置的uv值带入上述公式去进行验证,比如左下角(0.0,0.0)、右上角(1.0,1.0)、左下方某点(0.1,0.3)、右上方某点(0.8,0.7)…可以快速排除掉加减乘除都不符合,而取两者的最小值正好符合要求。

1
2
3
4
5
6
7
8
ini复制代码左下方某点 (0.1,0.3) 
mixer1 => vUv.x+vUv.y=0.4
mixer2 => 2.0-(vUv.x+vUv.y)=1.6
mixer => 0.4 <= min(0.4, 1.6)
右上方某点 (0.8,0.7)
mixer1 => vUv.x+vUv.y=1.5
mixer2 => 2.0-(vUv.x+vUv.y)=0.5
mixer => 0.5 <= min(1.5, 0.5)

于是完整代码如下。

1
2
3
4
5
6
7
8
9
10
11
C#复制代码void main() {
vec3 color1 = vec3(1.0, 1.0, 0.0);
vec3 color2 = vec3(0.0, 1.0, 1.0);
float mixer1 = vUv.x + vUv.y;
float mixer2 = 2.0 - (vUv.x + vUv.y);
float mixer = min(mixer1, mixer2);
// mixer = step(0.5, mixer);
vec3 color = mix(color1, color2, mixer);
// vec3 color = vec3(mixer);
gl_FragColor = vec4(color, 1.0);
}

当然如果大家懒得去验证,也可以在代码里直接修改组合方式,通过图形直观地看是不是想要的效果这样也行。下图依次为加、减、乘、除、取最小值对应的情况。

1
2
3
4
5
6
7
8
9
10
11
C#复制代码float mixer1 = vUv.x + vUv.y;
float mixer2 = 2.0 - (vUv.x + vUv.y);

float mixer = mixer1 + mixer2;
// float mixer = mixer1 - mixer2;
// float mixer = mixer1 * mixer2;
// float mixer = mixer1 / mixer2;
// float mixer = min(mixer1, mixer2);

// vec3 color = vec3(mixer);
vec3 color = mix(color1, color2, mixer);

可以看出来相乘时的效果和实际所需的取最小值的效果最为接近,一开始古柳看下面这张图前两种效果时,把其中白色的位置数值误认为都是1.0,然后代入几个位置的数值后发现,相乘好像也可以,比如 (0.3,0.4) => mixer1=0.7、mixer2=白色=1.0 => mixer=0.7*1.0=0.7 那确实也行,而忘了白色部分是 vUv.x+vUv.y 或 2.0-(vUv.x+vUv.y) 数值里1.0-2.0的范围,相乘后数值就不对了。这样的错误其实很真实、也很有意思,所以古柳觉得写出来和大家分享下也不错。

限制最大值后再相乘

当然题外话,沿着上面的思路,将错就错,我们只需把数值1.0-2.0的值限制到最大值1.0,借助内置函数 clamp(x, min, max) 将 x 值限制到 min-max 之间,那么将 mixer1、mixer2 的值限制到0.0-1.0后,再相乘就行了。

1
2
3
4
5
C#复制代码float mixer1 = vUv.x + vUv.y;
mixer1 = clamp(mixer1, 0.0, 1.0);
float mixer2 = 2.0 - (vUv.x + vUv.y);
mixer2 = clamp(mixer2, 0.0, 1.0);
float mixer = mixer1 * mixer2;

下图依次为最初相乘、限制0.0-1.0后相乘、取最小值的效果,可以看出后两者效果一致。所以有时候出错了反而会尝试出别的可行方法,倒也是意外之喜!

图形化的思路(暂时不理解也没关系)

不过上面的讲解思路是古柳在写文章时才想到的,对于其他类似两种数值进行结合的操作,这种代入特殊位置的数值并快速验证下加减乘除或取最小值最大值的方法,或者直接去应用看实际效果的方法更通用。

但回到这里的例子,古柳自己一开始的思路并不是那样的。

因为之前在其他地方学到过类似操作,所以印象里就是以直线的图形化效果去理解该怎样组合出 mixer。这里用到 desmos 这个图形计算器网站(别问我“图形计算器”是什么意思,我也是谷歌copy过来的,反正就是个能画函数图的网站,大家看其他人的 shader 讲解有时也会用到借助该网站讲某些数值变化情况),我们将 vUv.x+vUv.y 看成是个整体作为 x,那么一开始从 0.0-2.0 的过程其实就是 y=x 红色直线这样变化,我们希望在中间(0.5,0.5)也就是 x=1.0=y=mixer 时后续直线下降回到0.0,其实这也是 y=2-x 这条直线在 1.0-2.0 这段的变化。

  • 链接:https://www.desmos.com/calculator?lang=zh-CN

那么只要大家还有点中学数学的印象,看到这样的直线,可能就会想起直接取两个直线的最小值 min(x, 2-x) 也就是图中绿线部分,就是我们所需的结合两者的方式。

当然这里的讲解大家暂时没搞懂也没关系,有这么个印象,没准哪天看到0到1再回到0的变化方式,能想起这种直线图,然后直接反应过来可以用 min 取最小值那也不错。

本文例子配图合集

最后照旧是本篇文章所有例子的配图,方便大家对照着去自行实现出每个具体的效果。另外每篇文章几十个配图,也是古柳希望以这种方式让大家在学习抽象的 shader 过程中能更直观好懂的去理解每一步的效果,希望大家觉得很有用!

如果你喜欢本文内容,欢迎以各种方式支持,这也是对古柳输出教程的一种正向鼓励!

照例

最后欢迎加入「可视化交流群」,进群多多交流,对本文任何地方有疑惑的可以群里提问。加古柳微信:xiaoaizhj,备注「可视化加群」即可。

欢迎关注古柳的公众号「牛衣古柳」,并设置星标,以便第一时间收到更新。

本文转载自: 掘金

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

ELF 通过 Sysv Hash & Gnu Hash 查找

发表于 2023-11-21

简介

在做elf plt hook的时候,我们需要根据符号名去查找符号信息(除了hook,动态连接器等程序也需要查找符号),通常有3种方法:

  1. 顺序遍历符号表,这种方法最直观,但性能较差
  2. 基于 sysv hash 表查找(DT_HASH)
  3. 基于 gnu hash 表查找(DT_GNU_HASH)

在做符号查找之前,需要一些准备工作:

  1. 需要找到 dynamic segment (PT_DYNAMIC) 在内存中的位置
  2. 解析 dynamic segment,从中解析出:
    1. 动态符号表(DT_SYMTAB)的位置
    2. 动态字符串表(DT_STRTAB)的位置
    3. sysv hash 表(DT_HASH)的位置,如果有的话
    4. gnu hash 表(DT_GNU_HASH)的位置,如果有的话
1
2
3
4
5
6
7
8
9
10
11
c++复制代码struct Dynamic {  
// 动态字符串表信息 (.dynstr)
ElfW(Addr) dynstrSecAddr;// DT_STRTAB,动态字符串表地址
// 动态符号表信息 (.dynsym)
ElfW(Addr) dynsymSecAddr;// DT_SYMTAB,动态符号表地址
// hash section addr
ElfW(Addr) hashSecAddr;// DT_HASH,sysv hash
ElfW(Addr) gnuHashSecAddr;// DT_GNU_HASH,gnu hash

// ... others
};

顺序遍历查找

这个方案很好理解,但是性能比较差,尤其是查找一个当前elf中不存在的符号时。

另外实现的话似乎还有点麻烦,主要是因为 dynamic segment 中没有给出动态符号表的大小,或者动态符号表的结束地址。这个信息可以从 section header table 中获取,但是 section header table 未必会被mmap到内存中的,而 elf 文件我们也不一定有权限访问。(当然可以通过sysv hash 和 gnu hash来获得动态符号表的符号数目,文末有提到,但是既然通过顺序遍历查找,就假设不依赖sysv hash & gnu hash)

基于Sysv Hash查找符号

Sysv Hash 哈希函数
1
2
3
4
5
6
7
8
9
10
11
12
c++复制代码static uint32_t sysvHash(const char* symbolName) {  
uint32_t h = 0, g;
for (char c = *symbolName; c;) {
h = (h << 4) + c;
if ((g = h & 0xf0000000)) {
h ^= g >> 24;
}
h &= ~g;
c = *++symbolName;
}
return h;
}
Sysv Hash 表的结构

sysv hash 表包含4部分数据:

  1. nbucket: uint32_t 类型,表示 bucket 的数目
  2. nchain: uint32_t 类型,表示 chain 的数目(跟动态符号表的符号个数相同)
  3. buckets: nbucket 个 uint32_t 元素的数组,通过 symbolNameHash % nbucket 可以找到符号对应的 bucket,其value就是链中第一个符号在符号表中的索引
  4. chains: nchain 个 uint32_t 元素的数组,每个链起始于 index = buckets[symbolNameHash % nbucket],而链中下一个符号的索引则是:chains[index],其中每个 index 的值都是符号在符号表中的索引。
1
2
3
4
5
6
c++复制代码struct SysvHash {  
uint32_t nbucket;
uint32_t nchain;
uint32_t* buckets;
uint32_t* chains;
};

buckets 和 chains中的元素都是符号表中符号的索引。

基于sysv hash表的查找步骤
  1. 根据上面给出的sysvHash函数计算出符号名的哈希值hash
  2. 通过 hash % nbucket 找到符号所对应的 bucket,而 index = bucket[hash % nbucket] 就是链中的第一个元素,index 对应的就是符号在符号表中的索引
  3. 如果 index == 0 则未找到该符号,流程结束。否则判断符号表中索引为index的符号是否是我们要找的符号,判断方法就是看当前符号名跟要找的符号名是否相等
  4. 如果符号名相等,则找到目标符号,流程结束。否则根据 index 从 chain 中找到下一个符号索引:index = chain[index]
  5. 跳转到第3步继续执行
查找代码
1
2
3
4
5
6
7
8
9
c++复制代码uint32_t Elf::findSymbolIndexBySysvHash(const char* symbolName) const {  
uint32_t hash = sysvHash(symbolName);
for (uint32_t index = sysvHash_->buckets[hash % sysvHash_->nbucket]; index; index = sysvHash_->chains[index]) {
if (strcmp(symbolName, (const char*)dynamic_.dynstrSecAddr + ((const ElfW(Sym)*)dynamic_.dynsymSecAddr)[index].st_name) == 0) {
return index;
}
}
return 0;
}

基于Gnu Hash查找符号

Gnu Hash 哈希函数
1
2
3
4
5
6
7
8
c++复制代码static uint32_t gnuHash(const char* symbolName) {  
uint32_t h = 5381;
for (char c = *symbolName; c;) {
h = (h << 5) + h + c;
c = *++symbolName;
}
return h;
}
Gnu Hash表的结构
  1. nbucket: uint32_t 类型,表示 buckets的元素个数
  2. symndx: uint32_t 类型,表示gnu hash支持查找符号索引 >= symndx 的符号,索引小于这个值的不支持直接通过 gnu hash 查找,可以遍历 [1, symndx) 挨个比对符号名查找
  3. bloomSize,bloomShift,blooms:布隆过滤器需要的数据,用于快速判断一个符号是否查不到
  4. buckets: nbucket 个 uint32_t 元素的数组,通过 symbolNameHash % nbucket 可以找到符号对应的 bucket,其value就是链中第一个符号在符号表中的索引
  5. chains:chains的索引跟符号表的索引是一一对应的关系,有个 symndx 的偏移。chains中的值是对应符号名的哈希值。哈希值的最后一位被用来判断是否是链尾,如果最后一位是1则表示符号是当前链的最后一个元素。
1
2
3
4
5
6
7
8
9
c++复制代码struct GnuHash {  
uint32_t nbucket;
uint32_t symndx;
uint32_t bloomSize;
uint32_t bloomShift;
ElfW(Addr)* blooms;// 32位elf每个元素32bit uint32_t,64位elf每个元素64bit uint64_t
uint32_t* buckets;
uint32_t* chains;
};
Gnu Hash 对符号表结构的要求

gnu hash要求符号表分为2部分:

  1. 前面一部分主要是 UNDEF symbol 以及 FILE symbol之类的,这些不能通过 gnu hash 查找,他们的索引区间为:[0, symndx),这部分符号不需要有序
  2. 后面一部分 symbol ,gnu hash 要求他们是有序的:符号需要按照 symbolNameHash % nbucket的值进行升序排列,因此同一个bucket中的符号是连续存放的
快速判断一个符号是否无法通过 gnu hash 找到

Gnu Hash中带有一个 k=2的布隆过滤器,对同一个符号名计算两个哈希值:hash1 = gnuHash(symbolName),hash2 = hash1 >> bloomShift。如果blooms[(hash1 / ELFCLASS_BITS) % bloomSize] 中 第 hash1 % ELFCLASS_BITS位和hash2 % ELFCLASS_BITS位不都为1,那么说明当前符号无法查到,就不必进行后续的查找过程了。(32位elf:ELFCLASS_BITS = 32,64位elf:ELFCLASS_BITS = 64)
另外这两个位都为1的情况并不代表符号就存在,需要后续的查找流程来判断。

基于gnu hash表的查找步骤
  1. 通过上面的gnuHash函数计算出符号名的哈希值 hash
  2. 通过布隆过滤器来判断符号是否一定查不到,如果是,则结束流程。
  3. 通过 hash % nbucket 找到符号所对应的 bucket,而 index = buckets[hash % nbucket] 就是链中的第一个元素,index 对应的就是符号在符号表中的索引,如果 index == 0,说明当前bucket是空的,符号未找到,结束流程。
  4. 因为chains中存有符号名的哈希值(不包括最后一位),因此我们可以先对比哈希值
    1. 如果哈希值相同,再对比符号名,如果符号名相同,则找到符号,结束流程。
    2. 如果哈希值的最后一位是 1,表明已经到达链尾,符号未找到,结束流程。
    3. 因为相同bucket的符号是连续存放的,所以链中下一个符号的索引就是 index + 1,跳到第4步继续
查找代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
c++复制代码uint32_t Elf::findSymbolIndexByGnuHash(const char* symbolName) const {  
uint32_t hash = gnuHash(symbolName);

ElfW(Addr) ELFCLASS_BITS = sizeof(ElfW(Addr)) << 3;
ElfW(Addr) word = gnuHash_->blooms[(hash / ELFCLASS_BITS) % gnuHash_->bloomSize];
ElfW(Addr) mask = ((ElfW(Addr))1 << (hash % ELFCLASS_BITS))
| ((ElfW(Addr)) 1 << ((hash >> gnuHash_->bloomShift) % ELFCLASS_BITS));

if ((word & mask) != mask) {
return 0;
}

uint32_t idx = gnuHash_->buckets[hash % gnuHash_->nbucket];
if (idx == 0) {
return 0;
}

while (true) {
const char* symname = (const char*)dynamic_.dynstrSecAddr + ((ElfW(Sym)*)dynamic_.dynsymSecAddr)[idx].st_name;
const uint32_t h = gnuHash_->chains[idx - gnuHash_->symndx];

if ((hash | 1) == (h | 1)
&& strcmp(symbolName, symname) == 0) {
return idx;
}

if (h & 1) {
break;
}
++idx;
}
return 0;
}
基于gnu hash查找UNDEF符号

如果要基于 gnu hash 查找 UNDEF 的符号的话,那么可以遍历[1, symndx)区间内的符号,对比其符号名:

1
2
3
4
5
6
7
8
9
c++复制代码uint32_t Elf::findUndefineSymbolIndexByGnuHash(const char* symbolName) const {  
for (uint32_t idx = 1, end = gnuHash_->symndx; idx < end; ++idx) {
const char* symname = (const char*)dynamic_.dynstrSecAddr + ((ElfW(Sym)*)dynamic_.dynsymSecAddr)[idx].st_name;
if (strcmp(symbolName, symname) == 0) {
return idx;
}
}
return 0;
}

基于Sysv Hash 和 Gnu Hash 查找的对比

  1. gnu hash对符号表内的符号排序有要求,而sysv hash没有要求,因此在同一个elf文件中,这2种hash表可以共存。可以通过连接器的--hash-style参数来指定hash表的类型:sysv, gnu or both
  2. sysv hash 可用于查找 UNDEF symbol,而 gnu hash 查找UNDEF symbol则需要对 [1, symndx)区间内的符号名遍历对比,这个case下 sysv hash可能性能更好
  3. 通过布隆过滤器,对于一些不存在的符号,gnu hash(可能但不一定)可以快速判断出来,这个case下gnu hash性能更好
  4. gnu hash中 chains 中存的是符号名的哈希值(不包含最后一个bit),因此在比较符号名之前,可以先比较哈希值是否相同,uint32_t 类型的哈希值比字符串比较要快得多(符号名有时候会很长,比如c++参数类型也是包含在函数符号名中的),这个case下gnu hash性能更好
  5. gnu hash中,同一个bucket中的符号连续存放,而sysv hash链中元素是链式非连续的,这个case下gnu hash cache更友好,性能更好
  6. 如果要获取动态符号表中符号的个数(section header table没mmap的内存,且elf文件无权限访问的话),sysv hash比较方便: nchain跟符号个数相同。而通过gnu hash就比较麻烦:需要遍历 最大的bucket(buckets[nbucket-1],如果其值为0,也就是bucket为空,则往前推一个)链到最后一个元素,他的 index + 1 就是符号个数

备注

  1. 上面代码中通过 ((ElfW(Sym)*)dynamic_.dynsymSecAddr)[idx]来获取索引是idx的符号,这个其实有个隐含要求:sizeof(ElfW(Sym)) == SYMENT,SYMENT 为 Dynamic segment中 DT_SYMENT 所指定的符号项的大小,之前看到一个文档说:elf规范中要求的是 SYMENT >= sizeof(ElfW(Sym)),也就是说允许他们之间有 gap(比如后续扩展可能追加字段),所以稳妥的方式应该是:*((ElfW(Sym)*)dynamic_.dynsymSecAddr + idx * SYMENT),对于 Phdr 之类的也类似。
  2. 查找符号的时候其实还需要考虑Symbol Versioning,不过这个跟本文讨论的关系不大,此处忽略

本文转载自: 掘金

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

如何科学的进行Android包体积优化

发表于 2023-11-20

这篇文章会分享小厂如何做包体积优化相关主题,涉及内容包括:1) Android包体积优化的一种可能是比较标准的推进做法,2) 大致流程的心路历程和思考方式,3) 如何去总结和分享你们进行过的包体积优化项目。本文不仅仅是一篇分享,还是我个人比较喜欢的总结报告写法。

一、前言

移动 App 特别关注投放转化率指标,而 App 包体积是影响用户新增的重要因素,而 App 的包体积又是影响投放转化率的重要因素。

Google 2016 年公布的研究报告显示,包体积每上升 6MB 就会带来下载转化率降低 1%, 当包体积增大到 100MB 时就会有断崖式的下跌 。对于应用商店,普遍有一个流量自动下载的最大阈值,如 应用宝,下载的app超过100M,用流量下载时,会弹窗提示用户是否继续下载,这对下载转化率影响是比较大的。

现在流量虽然变得更廉价一点,但是用户的心理是不会变的,当 App 出现在应用市场的相同位置时,包体积越大,用户下载意愿可能越低。

而且包体积或直接或间接地影响着下载转化率、安装时间、运行内存、磁盘空间等重要指标,所以投入精力扫除积弊、发掘更深层次的体积优化项是十分必要的。

某手:

  • 1M = 一年kw级推广费

某条极速版:

  • Google 2016 年公布的研究报告显示,包体积每上升 6MB 就会带来下载转化率降低 1%,当包体积增大到 100MB 时就会有断崖式的下跌。
  • 通过插件化,将常规优化后达 120M+的包体积降到了 13M 左右,最小版本降至 4M,包体积缩小至原先的 3.33%。

某德:

  • 包体积大小,是俞xx直接拍的,就要求 x年x月x日 前削减到100M。

某淘:

  • 包大小做得比较“霸权”“独裁”,新业务超过 1M 要总裁审批,一般在平台组都卡掉了。

二、评估优化需求

在开展工作前,我们首先得先有两点判断:

  1. 是否需要进行优化
  2. 优化到多少算符合预期

那具体应该如何进行判断呢?有个比较简单的方法就是对标,找到对标对象进行对标。

对于小厂来说,一般对标对象有:

  1. 竞品App
  2. 业内人气App

基于对标对象,我们可以粗略的有如下判断:

  1. 如果我们App跟竞品App包体积差不多或略高,那就有必要进行包体积优化。
  2. 优化到跟业内人气App中包体积最小的那一档差不多即可。

上述判断还是基于用户视角,假如你的 用户需求 比较简单,好几个app都可以满足, 你会安装200M的产品,还是50M的产品。再有,假如用户在App商店无意间看到你们的App,有点兴趣体验体验,但是看到包体积是200M,他有多大概率会继续下载,换成50M呢?

三、包体积优化基本思想

我们在做包体积优化前,一定要定好我们的大方向,我们怎么优化,能做哪些,哪些可以现在做,哪些没必要现在做,

1. 抓各个环节

我们最终是要优化App的包体积,那么App包组成部分有哪些,我们针对每一个部分去研究如何减少其体积,就可以达到我们最终的效果。

2. 系统化方案先行,再来实操

优化Android包体积这个事情,有一定的探索性,但是很多内容或者说手段都是业内在广为流传的,既然如此,我们应该总结业内可优化手段,并逐一进行分析,研究是否适合你们App,是否需要应用到你们App。如果没有方案的埋头扎进去,往往会因为陷入细节,而丢失了全局视野。

3. 明确风险收益比及成本收益比

方案与方案之间是有区别的。如果一个方案能减少包体积2M,但是线上可能会崩溃,你会做吗? 如果一个方案能减少包体积2M,但是开发成本要一个月,你会做吗?

4. 明确指标以及形成一套监控防劣化体系

干任何一件以优化为目标的事情时,一定要明确优化的指标,我们要做App的包体积优化,那么我们的指标为:减少App安装包 apk 的大小。

当我们指标明确之后,我们还需要对现有指标进行监控,这样有两个好处:

  • 明确优化收益
  • 防止劣化

那我们就可以在某个关键的时间节点进行包体积的统计和上报,一般时间节点有:

  • App发版打包时(粒度更粗)
  • 开发分支有新的 commit 合入时(粒度更细)

两种粒度各有各的好处,但是目标是一样的:

  • 监控每次打包的包体积,可以行成指标曲线进行持续观察
  • 在包体积增加超出预期时进行及时报警

5. 把包体积这个指标刻在脑子里

自动化能发现已经发生的问题,但是把包体积这个指标刻在脑子里,能避免问题的发生。

四、自家App包体积一览

1. Android Apk结构

APK 主要由五个部分组成,分别是:

  • Dex:.class 文件处理后的产物,Android 系统的可执行文件
  • Resource:资源文件,主要包括 layout、drawable、animator,通过 R.XXX.id 引用
  • Assets:资源文件,通过 AssetManager 进行加载
  • Library:so 库存放目录
  • META-INF:APK 签名有关的信息

132d21f3-a1b6-43f6-99d0-fad7ffb81412.png

2. 你们Apk各个部分都长啥样,长多大?

这里选取示例App某个版本的安装包来做举例分析,下面是包结构图:

QQ截图20231109021600.png

浅浅分析一波包内内容

成分 体积 备注
assets文件夹 77.8M 下载.png 能看到Assets文件夹里,有着75M的RN bundle
lib 75.2M a7d410c9-75ab-4c27-9d21-9c8953567d00.png 由于我们App是兼容包,即同时兼容64位、32位,所以lib目录很大
dex 16M 这部分涉及到我们自己的代码及三方库代码
res 6.3M 这里包含各种图片资源、布局文件等
resources.arsc 1.2M
其他 若干

五、优化方案草案

通过调研业内常规、非常规手段,以及结合你们App的包体积现状,可以提前对优化包体积做出比较详尽的可实现、低风险、高收益方案,注意这几个点非常重要:

  • 可实现 - 可实现可以简单理解为实现成本低,一般各种性能稳定性指标都是循序渐进的推进,所以往往一期优化时,选的实操方案都是实现成本比较低的,这样能相对快速的得到比较符合心里预期的优化效果。
  • 低风险 - 线上风险必须控制在可接受的程度,这里的风险不仅仅是说App运行的性能稳定性风险,还需要判断是否会增加线上问题排查的难度,当然还会有其他的我没提到的风险项。
  • 高收益 - 不解释

所以基于我们需要的是可实现、低风险、高收益的方案,我们可以基于上面我贴的APK案例,来大致预演可能会采用哪些方案:

1. 缩减动态化方案内置代码包、资源包

一般的小厂都会比较大量的采用如RN、H5等动态化方案,不可避免的App内就会有一堆内置文件,我们看到我们示例的App中,RN内置包占了整个包体积超过 30%。当出现这种情况时,可以针对内置代码包、资源包单独立子项去推进。

那么如何进行推进呢?有同学就会说了,业务方不让我把这些玩意儿从APK里面删掉,说影响他们打开页面速度,影响页面打开速度就会影响一级指标影响收入。

这时为了说服业务方,我们就得拿出一些证据,用来证明内置包的全部移除或者部分移除并不会对业务产生影响,或者说影响足够小。那就可以采取如下一些推进步骤:

  • 明确全部内置包或者部分内置包不内置的影响,假如内置包是 RN 的页面bundle,那给业务方两个数据基本上就能够说明影响
    • 页面bundle现下比例,假如因为本地没有内置的bundle,打开页面需要同步进行等待下载完成才能加载的话,现场下载比例是个比较有说服力的数据。
      • 线上bundle更新耗时,我们可以统计用户启动App后的一段指定时间,90分位能下载多少个bundle,50分位能下载多少个,10分位、5分位能下载多少个,来告诉业务方,老用户、新用户、老用户新登录等各种场景,到达业务页面的时候,有多少比例的用户能完成bundle更新。
  • 明确什么样的资源需要内置,同样用RN页面bundle举例,假如App的首页就是RN页面,那这玩意儿就必须内置了,假如一个页面在犄角旮旯,日pv才不到100,那就完全可以不需要内置。
  • 给出内置资源名单
  • 拿着内置名单和上面明确的不内置影响统计,找业务方拉会, 这一步最好是从上往下进行推进,而不是同级推进

2. 分架构打包

分架构打包能减少libs文件夹体积,libs文件夹里会包含不同架构的 so 库集合。

首先我们最终apk包是要上传到应用商店的,应用商店得支持双包上传。答案确实是支持,且应用商店推荐双包上传。

Android 官方也是有相关的api支持分架构打包:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
php复制代码splits {
// 基于不同的abi架构配置不同的apk
abi {
// 必须为true,打包才会为不同的abi生成不同的apk
enable true
// 默认情况下,包含了所有的ABI。
// 所以使用reset()清空所有的ABI,再使用include指定我们想要生成的架构armeabi-v7a、arm-v8a
reset()
// 逗号分隔列表的形式指定 Gradle 应针对哪些 ABI 生成 APK。只与 reset() 结合使用,以指定确切的 ABI 列表。
include "armeabi-v7a", "arm64-v8a"
// 是否生成通用的apk,也就是包含所有ABI的apk。如果设为 true,那么除了按 ABI 生成的 APK 之外,Gradle 还会生成一个通用 APK。
universalApk true
}
}

这里需要注意的是,线上并不是所有的手机都支持 64位 的安装包,应用商店可以双包上传,线上灰度更新可以下发32位的安装包或者是 32/64 兼容包。

3. So 压缩

分架构打包是减少so的数量,so压缩是减少so的单个体积。

1
ini复制代码android:extractNativeLibs="true"

android:extractNativeLibs = true时,gradle打包时会对工程中的so库进行压缩,最终生成apk包的体积会减小。

但用户在手机端进行apk安装时,系统会对压缩后的so库进行解压,从而造成用户安装apk的时间变长。

若开发人员未对android:extractNativeLibs进行特殊配置,android:extractNativeLibs默认值:

  • minSdkVersion < 23 或 Android Gradle plugin < 3.6.0情况下,打包时 android:extractNativeLibs=true
  • minSdkVersion >= 23 并且 Android Gradle plugin >= 3.6.0情况下,打包时android:extractNativeLibs=false

4. 大so动态下发

我们能看到有些so库单个体积超大,放在apk里,就算能压缩,压缩后体积仍然很大,可能会占到 app体积超过 10%。针对这种情况,选择动态下发。

a91934eb-420a-4e74-8b38-5e899876d89a.png

动态下发的so如何进行加载

我们采用ASM的方案,对代码中所有的 System.load、System.loadLibrary 进行hook,进入到我们自己的逻辑,这样我们就可以走下面流程:

  1. 下载so库
  2. 解压so库
  3. 校验so库
  4. 加载so库

这里需要注意的一点就是,当动态下发的so没有下载、解压、校验、加载完之前,如果用户进入到了相关的业务场景,必须有兜底机制。比如在样例App的场景中,使用了 opencv 库来做图片的二维码识别,当so没下载下来时,要识别二维码就会被兜底到 zxing。

而且由于我们有较好的Hook框架的封装,所以我们需要hook时,仅仅需要进行配置即可:

这里可以参考我之前的博客和github上demo项目:

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

AndroidSimpleHook-github

5. 大文件压缩优化,对内置的未压缩大文件进行,压缩文件用高压缩率的压缩算法

假如apk里有内置的大文件,可以通过对其进行压缩从而减少包体积,压缩时可以选用高压缩率的算法。

6. 代码优化

  • 去除无用代码、资源

去除无用代码我们可以用官方的Lint检查工具

  • 去除无用三方库
  • 减少ENUM的使用

每减少一个ENUM可以减少大约1.0到1.4 KB的大小,假如有10000个枚举对象,那不就减少了14M?美滋滋啊,但实际上具体还是要看项目代码情况来考虑,毕竟不是所有的项目里都有 10000 个枚举。

7. 资源优化

  • 无用资源文件清理

去除无用资源文件可以通过lint工具来做,也可以通过微信开源的 ApkChecker来完成。

github.com/Tencent/mat…

图片压缩、转webp

图片压缩可以使用TinyPng,AndroidStudio也有相关插件,官方术语就是:

使用智能的无损压缩技术来减少图片文件的大小,通过智能的选择颜色的数量,减少存储的字节,但是效果基本是和压缩前一样的。

图片着色器

相同图片只是颜色不同的话,完全可以只放一个图片,在内存里操作 Drawable,完成颜色替换。

图片动态下发

如果本地有大图,且使用要求为未压缩,或者压缩之后仍然很大,可以适当的选择动态下载该图。

resources.arsc资源混淆

resources.arsc这个文件是存放在APK包中的,他是由AAPT工具在打包过程中生成的,他本身是一个资源的索引表,里面维护者资源ID、Name、Path或者Value的对应关系,AssetManager通过这个索引表,就可以通过资源的ID找到这个资源对应的文件或者数据。

通过对apk 中的resources.arsc进行内容修改,来对apk进行深度压缩。这里可以采用微信的AndResGuard方案。

github.com/shwenzhang/…

8. 三方库优化

移除无用三方库

移除无用三方库需要人肉扫描 build.gradle 文件,一个一个的去检查依赖的三方库是否被我们代码所使用。

功能重复的三方库整合

特别常见的case,RN 用的图片加载库是 Fresco,客户端用的图片加载库是 Glide,他们都是用来加载图片,可以通过删除一个库,让项目依赖的库少一个。

  • 修改三方库源码,不需要的代码进行剔除

一个三方库往往不会被用到全部功能,比如曾经很火的 XUtils github.com/wyouflf/xUt…

XUtils是一个工具大杂烩,但是假如我只用它来加载图片,其他工具是不是就完全无用,可以进行剔除。

9. 去除 DebugItem 包含的 debug信息与行号信息

在讲解什么是 deubg 信息与行号信息之前,我们需要先了解 Dex 的一些知识。

我们都知道,JVM 运行时加载的是 .class 文件,而 Android 为了使包大小更加紧凑、运行时更加高效就发明了 Dalvik 和 ART 虚拟机,两种虚拟机运行的都是 .dex 文件,当然 ART 虚拟机还可以同时运行 oat 文件。

所以 Dex 文件里的信息内容和 Class 文件包含的信息是一样的,不同的是 Dex 文件对 Class 中的信息做了去重,一个 Dex 包含了很多的 Class 文件,并且在结构上有比较大的差异,Class 是流式的结构,Dex 是分区结构,Dex 内部的各个区块间通过 offset 来进行索引。

为了在应用出现问题时,我们能在调试的时候去显示相应的调试信息或者上报 crash 或者主动获取调用堆栈的时候能通过 debugItem 来获取对应的行号,我们都会在混淆配置中加上下面的规则:

1
diff复制代码-keepattributes SourceFile, LineNumberTable

这样就会保留 Dex 中的 debug 与行号信息。根据 Google 官方的数据,debugItem 一般占 Dex 的比例有 5% 左右

10. ReDex

ReDex 是 Facebook 开发的一个 Android 字节码的优化工具。它提供了 .dex 文件的读写和分析框架,并提供一组优化策略来提升字节码。官方提供预期优化效果:对dex文件优化为 8%

github.com/facebook/re…

11. R 文件瘦身

当 Android 应用程序被编译,会自动生成一个 R 类,其中包含了所有 res/ 目录下资源的 ID。包括布局文件layout,资源文件,图片(values下所有文件)等。在写java代码需要用这些资源的时候,你可以使用 R 类,通过子类+资源名或者直接使用资源 ID 来访问资源。R.java文件是活动的Java文件,如MainActivity.java的和资源如strings.xml之间的胶水

通过R文件常量内联,达到R文件瘦身的效果。

github.com/bytedance/B…

12. 可能的更多方案

除了我上面列到的一些,市面上还有一些其他的方案,有复杂的有不复杂的,有收益高的有收益低的,大家可以在掘金上搜索Android包体积优化,就能搜到大部分了,当然,在大厂里,还会有很多很极致的方案,比如:

  • 去掉kotlin生成的许多模板代码、判空代码
  • 去除布局文件里不需要的冗余内容
  • …

思想是这么个思想,大家在实操的时候,思路就是先调研方案,调研完成之后再选型。

六、基于风险收益比及成本收益比敲定最终实现方案

这一步的重点是:明确风险收益比及成本收益比

方案与方案之间是有区别的。

  • 如果一个方案能减少包体积2M,但是线上可能会崩溃,你会做吗?
  • 如果一个方案能减少包体积2M,但是开发成本要一个月,你会做吗?

这里我们在示例App的基础上,对每个手段进行仔细分析,包括:

  1. 预期效果
  2. 成本
  3. 风险

就这样,当我们制定完成我们的目标方案之后,就可以放手干了。

手段 预期效果 成本 是否要做 进度 备注
重点优化项
- 缩减RN 内置bundle 预期效果:177.43M -> 114.43M 中 ✅ RN 内置bundle缩减,xxxx版本带上
- 分架构打包,64位、32位分开打包 预期效果:32位:117.43M -> 71.9M 64位:117.43M -> 87.6M 低 ✅ xxxx
- so压缩方案 预期效果:32位:71.9M -> 55.5M 64位:87.6M -> 58.3M 低 ✅ xxxx
- 大so文件动态下发 预期效果:32位:55.5M -> 50.7M 64位:58.3M -> 51.7M 中 ✅ xxxx
大文件优化
- zip优化,对内置的压缩文件替换压缩算法 预期针对 assets 文件 ❌ 针对不同类型文件选取不同高压缩率算法
代码优化 (dex文件为 15.6M)
- 去除无用代码 Android Lint ✅ xxx
- 减少ENUM的使用 全部代码 enum类 一共60个,就算全删了也只是减少 84k ✅ xxx 每减少一个ENUM可以减少大约1.0到1.4 KB的大小
资源优化 (目前res目录大小为 6.3M,emoji目录大小为 770k)
- 无用资源文件清理 Android Lint ✅ xxx 用ApkChecker再跑一次
- 图片压缩、转webp TinyPng ✅ xxx
- 图片着色器 ✅ xxx
- 图片动态下发 主要是针对比较大的图,实际上经过TinyPng 压缩后,图片大小已经大大减小 ✅ xxx
- resources.arsc资源混淆 AndResGuard两年不维护,花了一小时没完全跑起来,但看到了大致优化效果,1.3M -> 920k ❌ github.com/shwenzhang/…
三方库优化 (dex文件为 15.6M)
- 移除无用三方库 ✅ 检查一下
- 移除无用三方so库 ✅
- 功能重复三方库整合 ✅
- 修改三方库源码,不需要的代码进行剔除 ✅
极致优化,附 ByteX 插件情况
- 去除 DebugItem 包含的 debug信息与行号信息 ❌ mp.weixin.qq.com/s/_gnT2kjqp…
- ReDex 对dex文件优化为 8%,即在当前dex总和 15.6M的基础上,可以减少 1.2M ❌ Dex 压缩,首次启动解压dexhttps://github.com/facebook/redexhttps://juejin.cn/post/7104228637594877965
- R 文件瘦身 ❌ 现成方案:github.com/bytedance/B… failed for task ‘:app:transformClassesWithShrinkRFileForQaRelease’.> java.lang.RuntimeException: This feature requires ASM7

七、确定优化效果

当我们进行了一系列的或大或小的改动之后,如何描述最终优化效果?给两张对比图不就行了,无图言X。

八、总结

大家在进行一些有挑战性或者是比较有意义的项目时,其实可以多进行总结,总结的好处有什么我就不多解释了,懂的都懂哈。

比如我们这里可以装模作样的这样总结一下:

做的好的方面

  1. 足够系统化
  2. 前置调研足够充分
  3. 风险、收益、成本考虑足够充分
  4. 各方面沟通足够充分
  5. 优化决心足够大

也可以告诉自己及读者几句话

  1. 这是一个系统的需要持续去投入人力的事情,万万不可有了一定结果之后放松警惕
  2. 别人能做的,我们也能做,只要有足够的决心去做
  3. 做事不能太讲究所谓的方法论,不然会掉入陷阱,但是确实要讲究方法论
  4. 有些事情你做好了,可能仅仅是因为做这个事情的人是你,如果是别人来做,也能将这件事情做好

九、展望

一般来说,进行总结之后,都得来一些展望,给未来的自己挖点坑,给总结的读者画点饼。比如我们这里就可以这样继续装模作样的展望一下:

上面已经反复提及了,当前这一期的优化工作,重点考量的指标是风险收益比及成本收益比,所以一些极致的或者成本收益比较高的优化手段并没有被采用,所以后续还是有很多事情可以深入的干下去。

  1. resources.arsc资源混淆
  2. 去除 DebugItem 包含的 debug信息与行号信息
  3. ReDex
  4. R 文件瘦身
  5. So unwind 优化
  6. kotlin相关优化
  7. …

十、真正的总结

这里我就发散性的随便总结下吧。。。也不深入纠结了。

  1. 包体积优化是个庞大的工程项目,不仅仅需要优化,还需要防劣化,优化过程中还会涉及到业务冲突,说白了就是某些东西从APK包中移除了,或多或少会有些影响,还需要去跟业务方达成意见一致。
  2. 大家不管在做什么优化课题时,最好是分步骤分工期的去进行,不要一口吃成胖子,如果上来就追求完全极致的优化效果,往往会带来两个负面风险:1) 优化工期拉长,时间成本成倍增加,2)可能影响线上App或者线下各种工具的运行稳定性。
  3. 系统化的调研、成本 + 风险 + 收益的总和考虑非常重要,任何优化项目开始进行或者进行过程中,都需要牢牢的印在脑子里,每日三省你身。
  4. 遇到困难不要畏惧,各种优化项目往往会遇到很多阻力,比如方案实现太难、业务沟通太难等等,一块石头硬磕磕不动的时候换个方向磕,换方向也磕不动那就换块石头磕,比如假设业务方沟通不动,那就换个角度,把你和业务方放在同一角色上,给业务方找收益或者。
  5. 做的项目是啥或者说研究的方向是啥其实不是最重要的,我们这种普通程序员更重要的是解决问题的能力,因为你们做的事情,换个人用同样的时间成本或者更多的时间成本,往往也能做好,所以是你做好的这件事情其实没那么重要,更重要的是遇到其他问题或者有其他的疑难杂症和系统性问题时,知道你一定能做好。

你可能感兴趣

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)

本文转载自: 掘金

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

「前端添加水印」你真的了解全面吗?

发表于 2023-11-20

苏苏.png

背景

在古茗日常业务中,经常会给加盟商下发各种资料,例如:奶茶的配方、设备的清洗、卫生的标准等等等。这些资料都是一些内部资料,从信息安全维度不能被泄露和盗取出去。所以会给下发的资料加上水印。这些资料可能是纯文本,也可能是文本加图片的。因此,我们要做好以下两个方面:

  • 通过对页面增加水印,可以从系统级别防止别人盗取我们的页面信息
  • 通过对单独的图片加水印 - 防止图片保存时没有水印

页面水印

方案设计

实现页面水印的方式有很多,可以看一些常用页面加水印的方案,具体如下:

  • 方案一:fixed 定位的 div 元素,重复渲染 div 元素来添加水印。会创建很多无关的 DOM 元素
  • 方案二:fixed 定位 canvas 元素,重复填充水印。始终会创建一个无关的 canvas 元素
  • 方案三:canvas + 伪类。不会创建无关元素,且兼容性好
  • 方案四:svg + 伪类。不会创建无关元素,但兼容性略差于 canvas

这些方案,都有一个通用的缺点,那就是将元素删掉,或者将类名删掉,都能去除页面水印。

基于实现成本和安全性维度的考虑,最终方案选型:方案三,同时增加了通过MutationObserver - Web API 接口参考 | MDN 解决了删除类名导致水印删除的问题。

核心功能点:

  • 把签名信息,通过 Canvas 生成背景图
  • 利用伪类将背景图添加到需要生成水印的区域上
  • 通过 MutationObserver , 解决了删除类名导致水印删除的问题

代码实现

把签名信息,通过Canvas生成背景图

  • 利用 Canvas 来绘制背景图,背景内容为水印的内容
  • 通过 toDataURL 将 Canvas 转换成图片,格式为 image/png
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
js复制代码interface IImgOptions {
content: string[]; // 水印的内容,可传递多个水印
canvasHeight: number; // 画布的高度
canvasWidth: number; // 画布的宽度
}
const createImgBase = (options: IImgOptions) => {
const { content, canvasHeight, canvasWidth } = options;
const canvas = document.createElement('canvas'); // 创建一个画布
const ctx = canvas.getContext('2d');
// 设置画布的宽高
canvas.width = canvasHeight;
canvas.height = canvasWidth;
if (ctx) {
ctx.rotate((-10 * Math.PI) / 180); // 偏移一点距离
ctx.fillStyle = 'rgba(100,100,100,0.2)'; // 设置绘制的颜色
ctx.font = '40px'; // 设置字体的大小
// 遍历水印内容
content.forEach((text, index) => {
ctx.fillText(text, 10, 30 * (index + 1)); // 拉开30的间距
});
}
return canvas.toDataURL('image/png'); // 转换程data url,可供img直接使用
};

利用伪类将背景图添加到整个页面上

  • 给需要添加水印元素添加一个对应的伪元素,将第一步通过 Canvas 生成的 data url 作为背景
  • 创建一个 style 元素,将伪元素放在 style.innerHTML 中,然后 appendChild 到 head 中,此时,页面水印就加完了
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
js复制代码const genWaterMark = ({
content,
className,
canvasHeight = 140,
canvasWidth = 150,
}) => {
const dataURL = createImgBase({ content, canvasHeight, canvasWidth });
const defaultStyle = document.createElement('style');
defaultStyle.innerHTML = `.${className}::after {
content: '';
display: block;
width: 100%;
height: 100vh;
background-image: url(${dataURL});
background-repeat: repeat;
pointer-events: none;
position: fixed;
top: 0;
left: 0;
}`;
document.head.appendChild(defaultStyle);
};


// 使用方式
const Content = () => {
useEffect(() => {
genWaterMark({
content: [userName, userPhone, '内部机密材料', '严禁外泄!'],
className: 'my-page-container',
});
}, []);
return (
<div className="my-page-container" id="my-page-container">
<div className="my-info">
<div className="title">这是测试标题</div>
<div className="content">
// ...我想这是机密内容 * n
</div>
</div>
</div>
)
}

// css样式
.my-page-container {
height: calc(100vh - 104px);
overflow: hidden;

.my-info {
display: flex;
flex: 1;
flex-direction: column;
height: 100%;
padding: 24px;
overflow-y: auto;

// .title & .content 一些不重要的css
}

页面效果如下:脱敏处理,截图未展示姓名和手机号。

利用MutationObserver,防止被人删除className

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
js复制代码const listenerDOMChange = (className: string) => {
const targetNode = document.querySelector(`.${className}`);
const observer = new MutationObserver((mutationsList) => {
for (let mutation of mutationsList) {
if (mutation.type === 'attributes' && mutation.attributeName === 'class' && targetNode) { // 监听属性并且属性名为class的变更
const curClassVal = targetNode.getAttribute('class') || '';
if (curClassVal.indexOf(className) === -1) { // 监听到className被删除了,手动加回去
targetNode.setAttribute('class', `${className} ${curClassVal}`);
}
}
}
});

observer.observe(targetNode as Node, {
attributes: true,
});
};
const genWaterMark = ({
content,
className,
canvasHeight = 140,
canvasWidth = 150,
}: IWaterMark) => {
// 监听class的变更
listenerDOMChange(className);
const dataURL = createImgBase({ content, canvasHeight, canvasWidth });
const defaultStyle = document.createElement('style');
// 省略
document.head.appendChild(defaultStyle);
};

注意点

注意点①:

  • 问题:

上述方案的水印是占据整个页面的,但有些水印期望是在特定区域的。

  • 解决方案:

利用定位,实现在特定区域增加水印

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
js复制代码// 通过设置position: absolute来实现
const genWaterMark = ({
content,
className,
canvasHeight = 140,
canvasWidth = 150,
}: IWaterMark) => {
const dataURL = createImgBase({ content, canvasHeight, canvasWidth });
const defaultStyle = document.createElement('style');
defaultStyle.innerHTML = `.${className}::after {
content: '';
display: block;
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 100%;
background-image: url(${dataURL});
background-repeat: repeat;
pointer-events: none;
}`;
document.head.appendChild(defaultStyle);
};

// 使用方式
const Content = () => {
useEffect(() => {
genWaterMark({
content: [userName, userPhone, '内部机密材料', '严禁外泄!'],
className: 'wait-task-wrap',
});
}, []);
return (
<View className="my-page-container" id="my-page-container">
// ...一些不重要的代码
<View className="wait-task-wrap"></View>
</View>
)
}

// css样式
.wait-task-wrap {
// 一些不重要的样式
position: relative;
}

页面效果如下:

图片水印

方案设计

在资料中,存在很多图片,但页面水印,对图片你来说就我们要对图片进行预览并且支持保存。此时页面背景水印就没有用啦,我们下载下来的图片还是不带水印的。针对这种现象,我们有以下一些常用的解决方案

  • 方案一:服务端添加水印,安全,但是服务端压力大且性能慢
  • 方案二:借助 oss 添加水印,简便但是不通用
  • 方案三:canvas 方案,安全但性能慢

本文着重介绍后两种前端添加水印的方式。

代码实现

借助oss

将oss地址转成带水印的oss地址
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
js复制代码// oss水印中的文字进行url安全的base64编码
const getSafeBase64Code = (name: string) => {
return window
.btoa(unescape(encodeURIComponent(name)))
.replace(/+/g, '-')
.replace(//+/g, '_');
};

const genOSSImageWaterMark = (imgSrc: string) => {
const { userName, userPhone } = JSON.parse(window.localStorage.getItem('userInfo') || '{}');

return `${imgSrc}?x-oss-process=image/watermark,text_${getSafeBase64Code(
`${userName}-${userPhone}`
)},rotate_325,t_10,color_000000,size_30,fill_1,g_nw,x_30,y_30`;
};

// 使用
const ImageWaterMark = () => {
return (
<Image
src={genOSSImageWaterMark('xxx图片地址xxx')}
/>
)
}

页面效果如下:

)

注意点

注意点①

  • 问题:有些图片某些区域是透明的,导致透明的区域上不了色。(效果如图一)
  • 解决方案:ui 告诉我们,png 图片导出默认是透明的,但是 jpg 默认会将透明的地方填充白色的背景,所以,我们查阅对图片进行格式转换的参数说明及实例_对象存储-阿里云帮助中心文档得出,只需要加上 x-oss-process=image/format,jpg, 对之前的 genOSSImageWaterMark 进行改造,对非 jpg 的图片都转成 jpg 的图片
1
2
3
4
5
6
7
8
9
10
js复制代码const genOSSImageWaterMark = (imgSrc: string) => {
const imgType = imgSrc.split('.').slice(-1)[0];
const { userName, userPhone } = JSON.parse(window.localStorage.getItem('userInfo') || '{}');

return `${imgSrc}?${
imgType !== 'jpg' ? 'x-oss-process=image/format,jpg,' : ''
}x-oss-process=image/watermark,text_${getSafeBase64Code(
`${userName}-${userPhone}`
)},rotate_325,t_10,color_000000,size_30,fill_1,g_nw,x_30,y_30`;
};

效果如下:

注意点②

  • 问题:字体写死,导致水印在大图上特别小,小图上特别大。(效果如图二)
  • 解决方案:根据图片比,计算字体大小。
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
js复制代码interface IImageProps {
width: number;
height: number;
}
// 获取图片的宽高
const getImageWH = async (src): Promise<IImageProps> => {
const img = new Image();
img.src = src;
await new Promise((resolve) => (img.onload = resolve)); // 等图片加载完
return new Promise((resolve) => {
resolve({
width: img.width,
height: img.height,
});
});
};
const genOSSImageWaterMark = async (imgSrc: string) => {
const { userName, userPhone } = JSON.parse(window.localStorage.getItem('userInfo') || '{}');
const imgType = imgSrc.split('.').slice(-1)[0];
const { width, height } = await getImageWH(imgSrc);
const min = Math.min(width, height);
// 根据官网上的测试图片,宽度为400,设置字体为10,水印展示效果很好,所以图片比为40
const size = Math.ceil(min / 40);
const src = `${imgSrc}?${
imgType !== 'jpg' ? 'x-oss-process=image/format,jpg,' : ''
}x-oss-process=image/watermark,text_${getSafeBase64Code(
`${userName}-${userPhone}`
)},rotate_325,t_100,color_ff0000,size_${size},fill_1,g_nw,x_30,y_30`;
return src;
};

效果如下:

)

注意点③

  • 问题:

用户直接将后缀删了,水印也就没了

  • 解决方式:

oss 设置安全级别,不带水印不可访问

通过canvas给图片增加水印

技术方案设计

  • 图片路径转成 canvas
  • canvas 添加水印
  • canvas 转成 img

代码实现

1
2
3
4
5
6
7
8
9
10
js复制代码const genOSSImageWaterMark = async (imgSrc: string) => {
const canvas = document.createElement('canvas');
// ① 图片路径转成canvas
await imgSrc2Canvas(canvas, imgSrc);
// ② canvas添加水印
addWatermark(canvas);

// ③ canvas转成img
return canvas.toDataURL('image/png');
};

使用

1
2
3
4
5
6
7
js复制代码const genOSSImageWaterMark = async (imgSrc: string) => {
const canvas = document.createElement('canvas');
await imgSrc2Canvas(canvas, imgSrc);
addWatermark(canvas);

return canvas.toDataURL('image/png');
};
图片路径转成canvas
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
js复制代码const imgSrc2Canvas = (cav: HTMLCanvasElement, imgSrc: string) => {
return new Promise(async (resolve) => {
const image = new Image();
image.src = imgSrc;
// ① 为图片设置crossOrigin属性,防止Failed to execute 'toDataURL' on 'HTMLCanvasElement'
image.setAttribute('crossOrigin', 'anonymous');
// ② 解决渲染图片为透明图层
await new Promise((resolve) => (image.onload = resolve));
cav.width = image.width;
cav.height = image.height;
const ctx = cav.getContext('2d');
if (ctx) {
ctx.drawImage(image, 0, 0);
}
resolve(cav);
});
};
canvas添加文字水印
  • 通过二维数组的渲染,来填充文本
    • 通过画布的宽度以及水印的宽度来计算 X 轴的渲染次数
      • 通过画布的宽度以及你想打印的疏密程度来计算 Y 轴的渲染次数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
js复制代码const addWatermark = async (canvas, imgSrc) => {
const { userName, userPhone } = JSON.parse(window.localStorage.getItem('userInfo') || '{}');
const ctx = canvas.getContext('2d');
ctx.fillStyle = 'rgba(100,100,100,0.2)'; // 字体颜色
ctx.font = `24px serif`;
ctx.translate(0, 0);
ctx.rotate((5 * Math.PI) / 180); // 旋转角度

const repeatX = Math.floor(canvas.width / 240); // 100 为每个水印的基本宽度
const repeatY = Math.floor(canvas.height / 150);
for (let i = 0; i < repeatX; i++) {
for (let j = 1; j < repeatY; j++) {
ctx.fillText(`${userName}-${userPhone}`, 240 * 2 * i, 150 * j); // 控制水印的疏密
}
}
};

页面效果如下:

注意点

注意点①:

  • 问题:页面报错如下

  • 原因:当 img 元素的 src 不符合同源准则时,会阻止读取 canvas 的内容。因为此时 img 元素放在 canvas 中时,canvas 元素会被标记为被污染的,而在被污染的 canvas 中调用 toDataUrl 将会报错
  • 解决方案:
1
2
js复制代码// 为image设置crossOrigin属性
image.setAttribute('crossOrigin', 'anonymous');

注意点②:

  • 问题:渲染的图片为透明的图片

  • 原因:图片还未渲染完,就返回了 canvas。
  • 解决方案:等图片渲染完了,再开始画到 canvas 中
1
js复制代码await new Promise((resolve) => (image.onload = resolve));

总结

本文主要讲了两个话题:页面水印 & 图片水印。页面水印很简单,基本上就是利用 canvas 渲染水印,再利用伪类将 canvas 的水印渲染在特定的区域。图片相对而言会复杂一些,在渲染水印之前,得先把图片渲染上去,针对大图,性能可能会慢一点。所以,如果对水印要求不是很严格并且图片是存储在 oss 的,那利用 oss 来加水印也不失为一种好选择。但如果从安全性来考虑,那肯定是服务端加水印会更合适一点。

最后

📚 小茗文章推荐:

  • 一文了解Webpack中Tapable事件机制
  • 古茗打印机技术的演进
  • 5分钟带你了解,古茗的代码发布与回滚

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

本文转载自: 掘金

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

面试官:手写一个浅拷贝和一个深拷贝(拷贝详解)

发表于 2023-11-18

两个拷贝都是JavaScript中应该必会的知识点,这两个东西基本上面试都会被问到,手写两个拷贝也理应是有手就会,如果你还不清楚拷贝是啥,或者不懂如何手写代码,不妨现在看下去

JavaScript中拷贝一般都是针对引用类型来说,先给大家看下深浅分别是什么样子的

1
2
3
4
ini复制代码let a = 1
let b = a
a = 2
console.log(b) // 1

这里a赋值到b后,b的值不会受a的变化而变化,传值而非址,这里其实也有底层逻辑在里面的,不妨看看我这篇预编译

像这样,一个数据拷贝另一个数据,原数据改变不会影响到另一个数据的拷贝,就是深拷贝,其实很好理解,就是拷贝得很彻底!不会再变了

1
2
3
4
5
6
ini复制代码let obj1 = {
age: 18
}
let obj2 = obj1
obj1.age = 20
console.log(obj2.age) // 20

引用类型在调用栈中存放的是地址,传址而非值,引用类型真正存放在堆中,obj1赋值给obj2是地址,因此obj1和obj2共用一个地址,你改我也改

像这样,一个数据拷贝另一个数据,原数据改变会影响到另一个数据,就是浅拷贝,同理,就是拷贝得不彻底,后面还能变

既然拷贝都是针对引用类型来说,我们从这里就可以看出,普通对象的赋值是一个浅拷贝,那有没有其他手段,也能实现出浅拷贝,下面就开始介绍实现浅拷贝的方法

浅拷贝

下面两个是对象的浅拷贝常见手段

Object.create(x)

上次见这个方法还是创造对象的时候,这个方法创造的对象是一个空对象,并且会隐式继承原对象的属性

举个栗子🌰

1
2
3
4
5
6
css复制代码let a = {
name: '小黑子'
}
let b = Object.create(a)
console.log(b.name) // 小黑子
console.log(b) // {}

我们展开这个{}

1.png

现在看看这个方法的浅拷贝体现

1
2
3
4
5
6
css复制代码let a = {
name: '小黑子'
}
let b = Object.create(a)
a.name = '大黑子'
console.log(b.name) // 大黑子

没有问题,你改我也改

Object.assign({}, x)

Object.assign是用来合并对象的,我们先认识下

举个栗子🌰

1
2
3
4
5
6
7
8
9
10
11
css复制代码let a = {
name: '小黑子',
hobby: {
n: 'running'
}
}
let b = {
age: 18
}
let c = Object.assign(a, b)
console.log(c) // { name: '小黑子', hobby: { n: 'running' }, age: 18 }

我们现在来看下它的浅拷贝体现

1
2
3
4
5
6
7
8
9
css复制代码let a = {
name: '小黑子',
hobby: {
n: 'running'
}
}
let b = Object.assign({}, a)
a.name = '大黑子'
console.log(b.name) // 小黑子 --- WTF???

啊哈!笔者你逗我,说好的浅拷贝呢,别急,我们再来试试

1
2
3
4
5
6
7
8
9
css复制代码let a = {
name: '小黑子',
hobby: {
n: 'running'
}
}
let b = Object.assign({}, a)
a.hobby = {}
console.log(b.hobby) // running --- WTF??????

啊?.png

额……怎么回事,这两个栗子都是深拷贝啊!Hold on! 我们再来看一个

1
2
3
4
5
6
7
8
9
css复制代码let a = {
name: '小黑子',
hobby: {
n: 'running'
}
}
let b = Object.assign({}, a)
a.hobby.n = 'coding'
console.log(b.hobby) // coding

这里确实是浅拷贝,那这个方法怎么说,怎么既有浅拷贝又有深拷贝,我们不妨仔细分析下,a对象中有个hobby键,这个键的值又是一个对象,a.hobby.n = 'coding'而这个操作其实就是针对一个引用类型就行修改,一定会随之改变的,也就是说,assign是浅拷贝操作,如果对象的属性是引用类型,则就相当于拷贝了一个引用地址。当然,你也可以说深得不够彻底(前两个),还是浅!

数组也有浅拷贝的手段,下面四个是数组浅拷贝

concat

用于合并数组,返回一个新数组,不会修改原数组

浅拷贝体现🌰

1
2
3
4
ini复制代码let arr = [1, 2, 3, {a: 10}]
let newArr = [].concat(arr)
arr[3].a = 1
console.log(newArr) // [ 1, 2, 3, { a: 1 } ]

没有问题

slice

提取数组的一部分,返回一个新数组,不会修改原数组,参数一是起始下标,参数二是终止下标,左闭右开

浅拷贝体现🌰

1
2
3
4
5
ini复制代码let arr = [1, 2, 3, {a: 10}]
let newArr = arr.slice(0)
// 只有一个参数0,也就是提取整个数组
arr[3].a = 1
console.log(newArr) // [ 1, 2, 3, { a: 1 } ]

没有问题

打消你的疑惑:

既然都上slice了,我splice为何不可浅拷贝?

splice可以返回值,确实是可以获得一个新的数组,但是你要清楚,我们是来拷贝的,splice会影响原数组,拷贝完了你还把人家给删了,怕不是……

数组解构

[...arr]数组解构是es6新增的方法,解构是从数组中提取元素进行赋值

浅拷贝体现🌰

1
2
3
4
ini复制代码let arr = [1, 2, 3, {a: 10}]
let newArr = [...arr]
arr[3].a = 1
console.log(newArr) // [ 1, 2, 3, { a: 1 } ]

没有问题

arr.toReversed().reverse()

arr.toReversed()方法对应着reverse()都是用来颠倒数组,这个方法比较新,可能大家的node还不认识,可以去浏览器试试。阮一峰老师书中也有介绍这个方法,这里放个链接,请点击。因为reverse会影响原数组,并返回一个新数组,所以我们不用两个reverse,toReversed()不影响原数组,所以这个方法刚好可以进行一个拷贝

浅拷贝体现🌰

1
2
3
4
ini复制代码let arr = [1, 2, 3, {a: 10}]
let newArr = [...arr]
arr[3].a = 1
console.log(newArr) // [ 1, 2, 3, { a: 1 } ]

没有问题

for

在手写之前,我们需要认识下for in和for of此前文章没有详细讲过

for有三种遍历,一种是普通的for循环,还有两种分别为for in 和for of

for of

for of是天生给具有迭代器(Iterator)属性的数据结构遍历的,普通对象是没有的,数组有,因此你也可以拿他来遍历数组,今天我们主要是针对对象,所以这个方法我们不用

这个方法我此前文章有讲到过,贴个链接(这个文章没啥流量(悲)

Iterator-Set-Map-WeakSet-弱引用详解 - 掘金 (juejin.cn)

用例🌰

1
2
3
4
scss复制代码let arr = ['a', 'b', 'c', 'd', 'e']
for(let item of arr){
console.log(item);
} // abcde

for in

for in是专门用来遍历对象的,既然能遍历对象也就能遍历数组。但是对今天的拷贝来说有个缺陷,就是for in甚至可以遍历到隐式具有的属性,我们拷贝不会去拷贝人家隐式的属性

请看下面的栗子🌰

1
2
3
4
5
6
7
8
9
10
11
12
javascript复制代码let obj = {
name: '小黑子',
age: 18,
hobby: {
type: 'coding'
}
}
let newObj = Object.create(obj)
newObj.sex = 'boy'
for(let key in newObj){
console.log(key);
} // sex name age hobby

如果我们想要手搓一个浅拷贝,必然是要遍历出对象的key的,但是我们不能要人家的隐式东西,解决这个问题,我们只需要判断一下,刚好有个属性就是判断隐式属性的

newObj.hasOwnProperty(key)

该方法返回布尔值,true代表显示,false代表不具有或者隐式

因此我们在for中添加一个判断就可以隔绝掉隐式属性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
vbnet复制代码let obj = {
name: '小黑子',
age: 18,
hobby: {
type: 'coding'
}
}
let newObj = Object.create(obj)
newObj.sex = 'boy'
for(let key in newObj){
if(newObj.hasOwnProperty(key)){
console.log(key);
}
} // key

面试官:手搓一个浅拷贝

思路:既然是拷贝,必然是对一个对象进行拷贝,拷贝一般就是拷贝对象和数组,其余不考虑,因此这里必然先对形参判断一下。如果接收的形参是对象就给一个空对象,是数组就先给一个空数组,然后再进行遍历赋值,赋值必然是复制人家的key和value,人家的value如果还是一个引用类型,那刚好就是一个浅拷贝,因为赋的是地址

开始手搓

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
javascript复制代码function shalldowCopy(obj){
// 只拷贝引用类型
if(typeof obj !== 'object' || obj == null) return

let objCopy = obj instanceof Array ? [] : {}

for(let key in obj){
// 不要隐式
if(obj.hasOwnProperty(key)){
// objCopy.key是个字符串,[]可以当成变量
objCopy[key] = obj[key]
}
}
return objCopy
}

试试看🌰

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
ini复制代码let obj = {
name: '小黑子',
age: 18,
hobby: {
type: 'coding'
}
}
let arr = ['a', {n: 1}, 1, undefined, null]
let newObj = shalldowCopy(obj)
let newArr = shalldowCopy(arr)
obj.age = 20
obj.hobby.type = 'swimming'
arr[0] = 'b'
arr[1].n = 2
console.log(newObj); // { name: '小黑子', age: 18, hobby: { type: 'swimming' } }
console.log(newArr); // [ 'a', { n: 2 }, 1, undefined, null ]

完美!

深拷贝

深拷贝只有一个自带的方法:JSON.parse(JSON.stringify(obj))

JSON.parse(JSON.stringify(obj))

JSON是前后端数据传输的一种数据交互格式,类似对象,它的key都是字符串

JSON.stringify(obj)

这个方法将对象转换成JSON字符串格式

举个栗子🌰

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
css复制代码let obj = {
name: '小黑子',
age: 18,
hobby: {
type: 'coding'
},
a: undefined,
b: null,
c: function() {},
d: {
n: 100
},
e: Symbol('hello')
}
console.log(JSON.stringify(obj)); // {"name":"小黑子","age":18,"hobby":{"type":"coding"},"b":null,"d":{"n":100}}

key都变成了字符串

JSON.parse(JSON.stringify(obj))

将JSON格式转换成对象

上面那个栗子🌰

1
2
3
4
5
6
7
8
9
yaml复制代码console.log(JSON.parse(JSON.stringify(obj))); 
// 输出如下
{
name: '小黑子',
age: 18,
hobby: { type: 'coding' },
b: null,
d: { n: 100 }
}

大家发现没有,这个方法把人家的undefined,function,symbol都吞掉了,起始还有一个bigint也无法展示,bigint会报错

深拷贝体现🌰

1
2
3
4
5
6
7
8
9
10
11
12
13
14
javascript复制代码let obj = {
name: '小黑子',
age: 18,
hobby: {
type: 'coding'
},
b: null,
d: {
n: 100
}
}
let newObj = JSON.parse(JSON.stringify(obj));
obj.hobby.type = 'running'
console.log(newObj.hobby.type); // coding

没有问题,是深拷贝

循环引用

对象之间存在相互引用的现象

举个栗子🌰

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
yaml复制代码let obj = {
name: '小黑子',
age: 18,
hobby: {
type: 'coding'
},
b: null,
c: function() {},
d: {
n: 100
}
}
obj.c = obj.d
obj.d.n = obj.c
console.log(obj)
// 输出如下
{
name: '小黑子',
age: 18,
hobby: { type: 'coding' },
b: null,
c: <ref *1> { n: [Circular *1] }, // 循环
d: <ref *1> { n: [Circular *1] } // 循环
}

如果是带有循环引用的对象,我能否对其用JSON.parse(JSON.stringify(obj))进行深拷贝呢?

答案是不行的,会报错

因此这个方法有两个缺陷

  • 无法拷贝undefined,function,symbol,BigInt这几种数据类型
  • 无法处理循环引用

面试官:手搓一个深拷贝

实际上,深拷贝被面试问到的概率更大

思路:既然你对象中的引用类型不能随之修改,碰到这种我们直接再创建一个新的对象就可以了!这里用递归的思想会很巧妙,刚好递归的时候就创建了一个新的引用对象,其余和浅拷贝一样的,当然如果key是原始类型,就是直接赋值

开始手搓

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
scss复制代码function deepCopy(obj){
let objCopy = {}
for(let key in obj){
if(obj.hasOwnProperty(key)){
//obj[key]原始可以直接用,非原始就操作一下
if(obj[key] instanceof Object){
// 引用类型 递归就可以创建一个新的objCopy
// 右边就是hobby这个对象
objCopy[key] = deepCopy(obj[key])
}else{
// 原始类型
objCopy[key] = obj[key]
}
// 出口:if进不去就出来了
}
}
return objCopy
}

试试看🌰

1
2
3
4
5
6
7
8
9
10
ini复制代码let obj = {
name: '小黑子',
age: 18,
hobby: {
type: 'coding'
}
}
let newObj = deepCopy(obj)
obj.hobby.type = 'running'
console.log(newObj.hobby.type); // coding

完美!

如果你传的是数组,你就直接前面进行一个判断就可以,实际上,上面的手搓代码足以让面试官认可你了,如果非要刁钻!非得给你所有的数据类型,我们就用下面的方法,当然JSON.parse(JSON.stringify(obj))深拷贝的两个缺陷你也可以去引用下面两个库去解决

underscore

lodash

上面两个库都可以作为很好的资源去学习源码,像什么拷贝,去重,扁平化各种API都有封装,我们要做的是去实现他的源码

求赞.jpg


如果觉得本文对你有帮助的话,可以给个免费的赞吗[doge] 还有可以给我的gitee链接codeSpace: 记录coding中的点点滴滴 (gitee.com)点一个免费的star吗[星星眼]

本文转载自: 掘金

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

Iterator-Set-Map-WeakSet-弱引用详解

发表于 2023-11-17

js引用数据类型:对象、数组、函数、日期、正则、set、map

在讲set-map之前我需要带大家认识下迭代器iterator这个属性

迭代器iterator

能够被for…of遍历的东西才具有迭代器属性

  • 这个属性字节面试被问过

for...of

for...of用例如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
javascript复制代码javascript
复制代码
var arr = [1, 2, 3]
var str = 'abc'
var obj = {
a: 1,
b: 2,
c: 3
}

for(var item of arr){
console.log(item)
} // 1 2 3
for(var item of str){
console.log(item)
} // a b c
for(var item of obj){
console.log(obj)
} // 报错:obj is not iterable

js中遍历方法太多了,但for...of这个遍历方法是专门供具有迭代器属性的数据结构来使用的。从上面用例我们就可以看出,数组、字符串都具有迭代器属性,但是普通对象是没有的,这里我对对象强调普通二字是因为Set和Map。

Set

  1. 是一种key和value相等的特殊对象
  2. set对象中的值是唯一的
  3. 具有迭代器(iterator)属性

set是一个类数组

上一次聊类数组还是介绍arguments这个东西,大家感兴趣可以去看看关于arguments这个函数属性的文章深入认识js四大常用数据类型判断,一次性带你搞明白(内含面试热点)! - 掘金 (juejin.cn)

arguments是函数中形参的统称

什么是类数组呢?

类数组本质上是一种对象,它与数组类似,具有数组的下标和长度属性,其他数组的方法它都没有,如pop、push等

set如何创建呢?又如何查看呢?

1
2
3
4
vbscript复制代码vbscript
复制代码
let set = new Set([1,2,3,4])
console.log(set) // Set(4) {1, 2, 3, 4}

set是没有符号表达,它的表示方法就是上面这样,接受的参数必须是个数组,并且值唯一,如果我们重复放元素进去,最终得到的元素将会是去重后的

如果我们重复放值进去

1
2
3
4
vbscript复制代码vbscript
复制代码
let set = new Set([1,2,2,3,3,4])
console.log(set) // Set(4) {1, 2, 3, 4}

第一次见识到这个东西的时候我觉得很牛,完全可以用set方法对一个数组去重

去重

1
2
3
4
5
6
7
8
9
10
11
javascript复制代码javascript
复制代码
var arr = [1,1,1,2,2,'1']
function unique(){
return Array.from(new Set(arr))
}
console.log(unique(arr)) // [ 1, 2, '1' ]

//可以用箭头函数对代码进行优化,这里是解构了类数组
var unique = (arr) => [...new Set(arr)]
console.log(unique(arr)) // [ 1, 2, '1' ]

想要用set对一个数组去重,最后输出还是一个数组,就必须进行一个转换,Array.from的作用就是专门用来将类数组转换成数组,当然你也可以直接用es6新增的解构进行输出,解构同样适用于类数组

下面开始介绍set的方法

has方法

has方法用于查看set中值是否存在

1
2
3
4
vbscript复制代码vbscript
复制代码
let set = new Set([1,2,3,4])
console.log(set.has(1)); // true

set并不能直接通过下标方法进行查看单个值,只能判断值有没有

add方法

add方法其实就像数组中的push,从尾部增加新值

1
2
3
4
5
vbscript复制代码vbscript
复制代码
let set = new Set([1,2,3,4])
set.add(5)
console.log(set) // Set(5) { 1, 2, 3, 4, 5 }

size属性

size就是数组的length,查看大小

1
2
3
4
arduino复制代码arduino
复制代码
let set = new Set([1,2,3,4])
console.log(set.size) // 4

delete方法

删除指定的元素

1
2
3
4
5
sql复制代码sql
复制代码
let set = new Set(['a','b','c'])
set.delete('a')
console.log(set) // Set(2) { 'b', 'c' }

clear方法

清空set中所有的值

1
2
3
4
5
vbscript复制代码vbscript
复制代码
let set = new Set([1,2,3,4])
set.clear()
console.log(set) // Set(0) {}

forEach方法

对set进行遍历

1
2
3
4
5
6
7
8
9
10
dart复制代码dart
复制代码
let set = new Set(['a', 'b', 'c'])
set.forEach((item,index,set) =>{
console.log(item,index,set);
})
// 下面是输出
a a Set(3) { 'a', 'b', 'c' }
b b Set(3) { 'a', 'b', 'c' }
c c Set(3) { 'a', 'b', 'c' }

从这里可以发现:set中的key和value是相同的!

既然set可以被自己的方法遍历,那能否被for...of遍历,看看他是否具有迭代器属性

1
2
3
4
5
6
7
csharp复制代码csharp
复制代码
let set = new Set(['a', 'b', 'c'])
// for(var item of set.keys())
for(var item of set){
console.log(item)
} // a b c

是有的,不过我们用set.keys()迭代会更多一点

这些方法都是用实例对象来调用的,因此我们可以推断出这些方法都是挂在Set原型上的,不妨去浏览器看看都有哪些

1.png

常用的方法其实上面已经都介绍了,里面的Symbol(Symbol.iterator)就是迭代器属性

Map

  1. 可以用任意数据类型作key的一种对象
  2. 也具有迭代器属性

普通对象的key是个字符串类型,我们不妨试试把一个数组挂到一个对象里面去当key

1
2
3
4
5
6
7
8
9
10
11
css复制代码css
复制代码
let obj = {
a: 1,
b: 2
}

var arr = [1,2]
// obj.arr = 3
obj[arr] = 3
console.log(obj); // { a: 1, b: 2, '1,2': 3 }

这里需要注意,当你想添加某个key去作对象的属性的时候,我们要用中括号,而非点,如果这里用obj.arr = 3,那么arr则被当成一个key

根据这个输出结果可以看出,这个数组中的元素成为了字符串。

所以map就解决了这个问题,map的意义,就是可以用任意数据类型做key,因此,你可以理解为进阶版的对象,弥补了普通对象的不足之处,这个东西出现的频率高于set。

map和set同样,都没有符号去表达,如何创建呢,如下

1
2
3
4
lua复制代码lua
复制代码
let map = new Map([['name','小黑子'],['age',18]])
console.log(map); // Map(2) { 'name' => '小黑子', 'age' => 18 }

括号里面是也是个数组的形式,每个元素作为一个键值对

下面开始介绍map的方法

get方法

获取键对应的值

1
2
3
4
arduino复制代码arduino
复制代码
let map = new Map([['name','小黑子'],['age',18]])
console.log(map.get('name')) // 小黑子

set方法

这里的set是map中的方法,不是上面讲的set引用类型,不要搞混淆了噢

1
2
3
4
5
6
7
8
arduino复制代码arduino
复制代码
let map = new Map([['name','小黑子'],['age',18]])
let obj = {a: 1}
map.set(obj,'哈哈')
console.log(map) // Map(3) { 'name' => '小黑子', 'age' => 18, { a: 1 } => '哈哈' }
console.log(map.get({a: 1})) // undefined
console.log(map.get(obj)) // 哈哈

这里解释下为什么倒数第二个不能打印,get查找方法是根据引用来查的,也就是地址,因此必须要用一个东西来装这个地址再来get。

这里突然想起一个小知识点,{} === {}

1
2
3
less复制代码less
复制代码
console.log({} === {}) // false

对于引用类型来说,每个对象都是不一样的,这里指的是地址噢,哪怕内容相同,上期文章说过,三个等于号===对于引用类型来讲,比较的是值,数据类型和地址。

forEach方法

其实map和set的这个方法都是遍历,但是map有点不同在于参数顺序反了

1
2
3
4
5
6
7
8
9
dart复制代码dart
复制代码
let map = new Map([['name','小黑子'],['age',18]])
map.forEach((value,key,map)=>{
console.log(value,key,map)
})
// 下面是输出结果
小黑子 name Map(2) { 'name' => '小黑子', 'age' => 18 }
18 age Map(2) { 'name' => '小黑子', 'age' => 18 }

map这里是先输出值,再输出键

map是否有迭代器呢

1
2
3
4
5
6
7
8
9
10
arduino复制代码arduino
复制代码
let map = new Map([['name','小黑子'],['age',18]])
// for(var item of map.entries())
for(var item of map){
console.log(map)
}
// 输出结果
[ 'name', '小黑子' ]
[ 'age', 18 ]

也有,但是我们通常会用map.entries()

另外和set一样,也有has、delete、clear、size、keys等方法

我们去浏览器中看看map原型的方法

2.png

基本上都带大家认识了一遍,各位小伙伴也可以自己试试。

WeakSet

WeakSet和Set类似,你也完全可以理解为阉割之后的Set

  • WeakSet只能存储对象和Symbol类型
  • WeakSet没有迭代器属性,不可遍历
  • WeakSet只有add、delete、has方法
  • WeakSet是弱引用,不被垃圾回收机制所注意

下面就按照这个顺序讲一下:

WeakSet创建和Set一样,用实例对象

1
2
3
javascript复制代码javascript
复制代码
let ws = new WeakSet()

现在我们来验证下存放的类型是不是只能是对象和Symbol

1
2
3
4
5
csharp复制代码csharp
复制代码
ws.add(1) // TypeError: Invalid value used in weak set
ws.add(Symbol('hello')) // WeakSet {Symbol(hello)}
ws.add({}) // WeakSet {Symbol(hello),{}}

没问题的,这里的原始类型我只验证了数字类型,原始7个中除了Symbol中,其余都是会报错的,引用类型都可以当作对象,你放函数,日期,数组都可以

我看到很多人的介绍文章都说只能是对象,不能是原始,咳咳,什么年代的文章,该提醒他们去更新下了

WeakSet是阉割版的Set,它没有迭代器属性,额……这个我咋解释,要不你们就接受他吧[doge]

因为它不被垃圾回收机制注意,随时被收走,不会被遍历,合理!

WeakSet是阉割版的Set,里面的方法只剩下这三个(add、delete、has)了,和上面的Set一样,我这里也不赘述了

弱引用和垃圾回收机制—重头戏来了!

弱引用:如果WeakSet中的对象在其他地方没有强引用,那么垃圾回收机制会自动清理掉该对象

垃圾回收机制真要讲这里是讲不完的,但是可以说的是,JavaScript中垃圾回收机制是自动进行的,不像C语言一样,需要free手动释放

讲到垃圾回收机制了,就要扯到内存,穿插一个小知识点(突然想到的)

1
2
3
4
5
css复制代码css
复制代码
const a = []
a.push(1)
console.log(a) // [1]

肯定有小伙伴以为这里会报错,const不能改变啊,但是这里其实就是因为一个内存问题,引用类型真正存在堆(heap)中,存在栈(stack)中的只是一个地址,所以a其实是个地址,a.push(1)就是沿着地址从栈跑到堆中找到这个数组,添加一个新值,最后输出a这个数组

好,回到正题,我们以前用普通对象的时候都是一个强引用,比如

1
2
3
4
css复制代码css
复制代码
let obj= {name: '小黑子'}
console.log(obj) // {name: '小黑子'}

这段代码对于浏览器来说,执行第一行代码的时候,垃圾回收机制(Garbage Collection),我们就形象地称他为清洁工(GC)吧,这个清洁工他会看下下面是否引用(强引用)了,下面需要打印这个对象就是一个强引用,如果没有强引用,执行完了let obj= {name: '小黑子'}这行代码,清洁工就会把它扫走

obj = null

这是众多垃圾回收机制中的一种,称为引用计数(这也是早期的GC方法,因为无法用在循环中,现在不再使用)。让一个对象等于null,就是给了这个对象一个垃圾的标签,等待清洁工去清理

1
2
3
4
5
ini复制代码ini
复制代码
let obj= {name: '小黑子'}
obj = null
console.log(obj) // null

而弱引用对于清洁工(GC)来说,GC是无视它的,一个对象只要是被WeakSet了,就会被弱引用,那么清洁工就会来清理它,这么说,那WeakSet的存在意义是什么?我们不妨来验证下是否果真如此

3.png

啊?浏览器的GC没有清理它!这是什么情况?

那我手动去清理它

4.png

GC还是没有去清理他,好吧,不卖关子了!其实这个bug曾经在StackOverflow里面广为流传,其原因是浏览器的GC是不受我们控制的,我们也无法得知GC何时执行

我们也可以在node中查看heapUsed

当然我们运行需要输入node --expose-gc myTest.js才能查看

1
2
3
4
5
6
7
8
9
10
11
12
13
14
javascript复制代码javascript
复制代码
global.gc()
console.log(process.memoryUsage()); // 初始heapUsed: 4612776 ≈ 4.4M
let obj= {name: '小黑子',age: new Array(5 * 1024 *1024)}
// 上面的Array这么写是为了让占用空间更大
let ws = new WeakSet()
ws.add(obj)
global.gc()
console.log(process.memoryUsage()); // 弱引入一个对象heapUsed: 46873120 ≈ 44.7M

obj = null
global.gc()
console.log(process.memoryUsage()); // 让obj被扫走heapUsed: 4929872 ≈ 4.7M

node中需要我们手动扫走他,浏览器扫没扫走你也无法控制

阮一峰老师讲弱引用的话术是下面这样的

5.png

话语比较官方,可能比较难以理解,总之,你要清楚的是浏览器清理机制你也无法控制,弱引用的对象如果后面没有强引用,GC会自动把他清走

应用场景

WeakSet其实就是可以解决一个内存泄漏问题,比如我们给一个按钮打上禁用,如果我们用了Set,这样是强引用,会浪费空间,WeakSet就可以回收掉这个浪费的空间,因为理论上来讲他就是空的,迟早会被清空掉

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
xml复制代码xml
复制代码
<div id="wrap">
<button id ="btn">确认</button>
</div>

<script>
let wrap = document.getElementById('wrap');
let btn = document.getElementById('btn');//节点对象
//给btn打上标签
//set是强引用,浪费空间,用new WeakSet()可以回收掉浪费的空间,理论上认为它是空
const disabledElement = new WeakSet()
disabledElement.add(btn)
btn.addEventListener('click', () =>{
wrap.removeChild(btn)
console.log(disabledElement);
})
</script>

WeakMap和WeakSet同理,是弱引用版本的Map,随时可能被浏览器GC清走

文章参考书籍:ECMAScript6入门—阮一峰

Set 和 Map 数据结构 - ECMAScript 6入门 (ruanyifeng.com)


如果觉得本文对你有帮助的话,可以给个免费的赞吗[doge] 还有可以给我的gitee链接codeSpace: 记录coding中的点点滴滴 (gitee.com)点一个免费的star吗[星星眼]

本文转载自: 掘金

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

1…697071…956

开发者博客

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