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

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


  • 首页

  • 归档

  • 搜索

01你为什么需要学习K8S 什么人需要学习K8S

发表于 2023-10-19

前言

在”云原生”、”应用上云”火热的这几年,相信大家或多或少都听说过K8S这个可以称得上是容器编排领域事实的存在。

可以看出在2017年之后,K8S热度远超容器编排领域的另外两个工具Mesos和Docker Swarm,并将它们甩开了几十条街,成为了整个容器编排领域的龙头。

随着现在越来越多的企业把业务系统上云之后,大部分的服务都运行在Kubernetes环境中,可以说Kubernetes已经成为了云、运维和容器行业最炙手可热的工具,这也是为什么需要学习Kubernetes最重要的原因。

目前,AWS、Azure、Google、阿里云、腾讯云等主流公有云提供的是基于Kubernetes的容器服务。Rancher、CoreOS、IBM、Mirantis、Oracle、Red Hat、VMWare等无数厂商也在大力研发和推广基于Kubernetes的PaaS产品。

目前国内容器服务平台做的比较好的有腾讯云容器服务TKE和阿里云容器服务ACK,它们都是基于K8S做的二开,有兴趣的读者可以自己了解和尝试使用。

K8S是什么?

K8S是单词Kubernetes的缩写,这个单词在古希腊语中是 [舵手] 的意思,之所以简称其为K8S,是因为’K’字母与’S’字母之间隔着八个单词,为了简便称呼,于是有了K8S这个简称。

K8S起初是Google内部的一个名为Borg的系统,据说Google有超过二十亿的容器运行在Borg上,在积累了十几年的经验之后,Google在2014年重写并开源了该项目,改名为Kubernetes。

K8S在基于容器部署的方式上,提供了一个弹性分布式的框架,支持服务发现与负载均衡、存储、自动部署回滚、自动计算与调度、自动扩缩容等等一系列操作,目的是方便开发者不再需要关注服务运行细节,K8S能够自动进行容器与Pod调度、扩缩容、自动重建等等操作,保证服务尽可能健康的运行。

一句话来概括:K8S解放了开发者的双手,能够最大程度的让部署的服务健康运行,同时能够接入很多第三方工具(如服务监控、数据采集等等),满足开发者的定制化需求。

部署演变之路

传统部署时代

在互联网开发早期,开发者会在物理服务器上直接运行应用程序。以一个Go Web程序举例,很典型的一个部署方式是首先在本地编译好对应的二进制文件,之后上传到服务器,然后运行应用。

由于无法限制在物理服务器中运行的应用程序资源使用,因此会导致资源分配问题。例如,如果在同一台物理服务器上运行多个应用程序,则可能会出现一个应用程序占用大部分资源的情况,从而导致其他应用程序的性能下降。

虚拟化部署时代

为了解决上述问题,虚拟化技术被引入了。虚拟化技术允许你在单个物理服务器上运行多个虚拟机(VM)。虚拟化能够使应用程序在不同VM之间被彼此隔离,且能提高一定的安全性,因为一个应用程序的信息不能被另一应用程序随意访问。

虚拟化能够更好地利用物理服务器的资源,并且因为可以轻松地添加或者更新应用程序,而因此可以具有更高的扩缩容性,以及降低硬件成本等等的好处。通过虚拟化,可以将一组物力资源呈现为可丢弃的虚拟机集群。每个VM是一台完整的计算机,在虚拟化硬件之上运行所有的组件,包括自身的操作系统Guest OS。

容器部署时代

容器类似于VM,但是具有更轻松的隔离特性,使得容器之间可以共享操作系统Host OS,并且容器不会像VM那样虚拟化硬件,例如打印机等等,只是提供一个服务的运行环境。

通常一台物理机只能运行十几或者数十个VM,但是可以启动成千上万的容器。因此,容器和VM比起来是更加轻量级的,且具有和VM一样的特性:每个容器都具有自己的文件系统、CPU、内存、进程空间等。

我们可以简单理解为:一个VM已经是一台完整的计算机了,而容器只是提供了一个服务能够运行的所有环境。

同时,因为容器与基础架构分离,因此可以跨云和OS发行版本进行移植。

容器部署具有以下优势

  • 敏捷部署:比起VM镜像,提高了容器镜像创建的简便性和效率。
  • DEVOPS:由于镜像的不可变性,可以通过快速简单的回滚,提供可靠并且频繁的容器镜像构建和部署。
  • 开发与运维的隔离:在构建、发布的时候创建应用程序容器镜像,而不是在部署的时候,从而将应用程序和基础架构分离。
  • 松耦合:符合微服务架构思想,应用程序被分解成一个个小服务运行在不同的容器中,可以动态部署和管理。
  • 软件/硬件层面隔离:通过namespace实现操作系统层面的隔离,如隔离不同容器之间的文件系统、进程系统等等;通过cgroup实现硬件层面的隔离,提供物理资源上的隔离,避免某些容器占用过多的物理资源CPU、Memory、IO影响到其他容器中的服务质量。

容器时代之后:Serveless

容器阶段之后,虚拟化仍然还在不断演化和衍生,产生了Serveless这个概念。

Serveless英文直译过来的意思是无服务器,这不代表着它真的不需要服务器,而是说服务器对用户不可见了,服务器的维护、管理、资源分配等操作由平台开发商自行维护。一个Serveless很经典的实现就是云函数,即最近火热的FAAS(Function As A Service),函数即服务。

Serveless并不是一个框架或者工具,它本质上是一种软件架构思想,即:用户无需关注应用服务运行的底层资源,比如CPU、Memory、IO的状况,只需要关注自身的业务开发。

Serveless具有以下特点

  • 无穷弹性计算能力:服务应该做到根据请求数量自动水平扩容实例,并且平台开发商应该提供无限的扩容能力。
  • 无需服务器:不需要申请和运维服务器。
  • 开箱即用:无需做任何适配,用户只需要关注自身业务开发,并且能够做到精确的按量计费。

强大的K8S

想像一个场景,假设我们现在把一个微服务架构的程序部署在成百上千个容器上,这些容器分部在不同的机器上,这个时候管理这些容器是一件非常让人头疼的事情。

让我们想想管理这些容器可能会碰到的问题,例如:

  1. 某个容器发生故障,这个时候我们是不是该启动另一个容器?
  2. 某台机器负载过高,那么我们之后的容器是不是不能部署在这台机器上?
  3. 某个服务请求量突增,我们是不是应该多部署几个运行该服务的容器?
  4. 如果某些容器之间需要相互配合怎么办?比如容器A需要容器B的资源,所以容器A一定要在容器B之后运行。
  5. 运行多个容器时,我怎么做到它们的运行结果是原子性的?即要么全部成功,或者全部失败。亦或者如果某一个容器失败,我能够不断重启这个容器以达到我的预期状态。

以上问题,都可以交给K8S来解决,它提供了一系列的功能来帮助我们轻松管理和编排容器,以达到我们的预期状态,同时因为它本身也是一个分布式高可用的组件,所以无需担心K8S出问题。

K8S官方文档这么描述它的功能:

  • 服务发现和负载均衡 Kubernetes 可以使用 DNS 名称或自己的 IP 地址来暴露容器。 如果进入容器的流量很大, Kubernetes 可以负载均衡并分配网络流量,从而使部署稳定。
  • 存储编排 Kubernetes 允许你自动挂载你选择的存储系统,例如本地存储、公共云提供商等。
  • 自动部署和回滚 你可以使用 Kubernetes 描述已部署容器的所需状态, 它可以以受控的速率将实际状态更改为期望状态。 例如,你可以自动化 Kubernetes 来为你的部署创建新容器, 删除现有容器并将它们的所有资源用于新容器。
  • 自动完成装箱计算 你为 Kubernetes 提供许多节点组成的集群,在这个集群上运行容器化的任务。 你告诉 Kubernetes 每个容器需要多少 CPU 和内存 (RAM)。 Kubernetes 可以将这些容器按实际情况调度到你的节点上,以最佳方式利用你的资源。
  • 自我修复 Kubernetes 将重新启动失败的容器、替换容器、杀死不响应用户定义的运行状况检查的容器, 并且在准备好服务之前不将其通告给客户端。
  • 密钥与配置管理 Kubernetes 允许你存储和管理敏感信息,例如密码、OAuth 令牌和 SSH 密钥。 你可以在不重建容器镜像的情况下部署和更新密钥和应用程序配置,也无需在堆栈配置中暴露密钥

什么人需要学习K8S

运维/运开工程师

随着部署模式的演变,现在企业的应用几乎都以容器的方式在开发、测试、生产环境中运行。掌握基于K8S的容器编排工具的运维、开发能力将成为运维/运开工程师的核心竞争力。

软件开发人员

随着开发模式的演变,基于容器的微服务架构已经成为了开发应用首选的架构,而K8S是运行微服务应用的理想平台,市场会需要一批掌握K8S的软件开发人员。

GO开发人员

GO高级开发基本只有两个方向:高级服务端开发工程师和云原生工程师,其中云原生岗位会比高级服务端开发工程师更多。

这里的云原生主要是做Docker、Prometheus、Kubernetes等云原生工具方向等等开发,这也是因为CNCF基金会的一系列产品基本都是使用Go语言写的,Go开发工程师相比于其他人员拥有天然优势。

总结

到这里,每天十分钟轻松入门K8S的01篇: 《你为什么需要学习K8S就结束了》 ,后续会持续更新相关文章,带大家了解K8S架构、K8S组件、如何搭建K8S集群、各种K8S对象、K8S高级特性、K8S-API等等内容。

欢迎大家点赞、收藏、催更~

本文转载自: 掘金

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

难以置信,一个小小的需求让我捣鼓出一个提效的网站来 难以置信

发表于 2023-10-16

难以置信,一个小小的需求让我捣鼓出一个提效的网站来

需求介绍

事情是这样的,有个群友在业务当中碰到一个小小的需求,需求是这样的: 页面当中存在多个输入框,输入框的 value 值是一个数值组成的字符串(盲猜应该是身份证号码),这个字符串的位数是 15 位或者是 18 位,例如:’621848063680370’(15 位)和’621848063688370808’(18 位),然后默认的值是这样的,现在问题来了,需求希望在这些数值中插入空白符号,比如 15 位的数字就按照 6 + 6 + 3 的格式分隔,分隔的时候需要使用空白符号。比如’621848063680370’分隔后应该变成’621848 063680 370’,也就是数字位数到了第 6 位就加个空白符号分隔,…依次类推,而 18 位数字的分割规则则是:6 + 4 + 4 + 4。比如’621848063688370808’应该分隔成’621848 0636 8837 0808’。

这个需求就是对字符串的处理,提到分隔替换,那么我们就可以想到强大的字符串替换方法 replace,这个方法可以接受 2 个参数,一个参数通常是一个正则表达式,第二个参数则是一个回调函数,用于定义替换后返回字符串。因此,我的第一个想法就是使用正则表达式去处理,如何处理呢?

原理分析

首先我们需要去理解这个规则,从需求我们可以发现,不同的位数,规则就会有所不同,因此我们可以提前用一个数据来表示这种规则,为了保持良好的扩展性,我设计了如下字段:

1
2
3
4
5
6
ts复制代码type spaceRule = {
digit: number; // 位数
rule: RegExp; //规则
symbolNumber: number; // 插入符号数量
symbolName: string; // 插入符号
};

可以看到,我设计了四个参数,正如注释所说,每一个参数都有具体的含义,为什么要如此设计参数呢?还是看需求,我们需求首先是限定了数字的位数,只可能是 15 位或者是 18 位,那如果存在 19 位又或者 20 位的场景呢?因此我们需要设计一个位数的参数,然后是每一个位数对应的规则是不一样的,因此我们也需要设计一个 rule 参数,然后是插入符号数量,也许会存在 1 个空白,2 个空白等等场景,或者我们不一定插入空白符号,也有可能是其它符号例如”-“等等,因此就设计 symbolNumber 和 symbolName 参数。

既然规则是类似 6 + 6 + 3 这样的规则,因此我们想到使用正则表达式来完成这个功能是可以的,我们将其拆分开来,分成 3 个分组,第一个分组匹配 6 个数字,第二个分组匹配 6 个数字,第三个分组匹配 3 个数字,然后针对分组之间插入特定的符号即可。

正则表达式中匹配数字可以使用’\d’来表示,然后匹配位数位 6,我们就可以使用量词’{6,}’来表示,因此我们的 6 + 6 + 3 规则就可以写成如下:

1
ts复制代码const rule = /(\d{6,})(\d{6,})(\d{3,})/g;

replace方法核心参数

接下来根据 mdn 对 replace 第二个参数回调函数参数的介绍,我们就知道,如果匹配到了正则表达式,则回调函数的参数会是如下所示:

1
2
3
ts复制代码function replacer(match, p1, p2, /* …, */ pN, offset, string, groups) {
return replacement;
}

其中 p1,p2…pN 就是我们这里需要用到的匹配分组,有个专业的名词叫做捕获组,前面 9 个捕获组对应的就是正则表达式实例对象的$1….$9 属性。

然后其返回值就会用作字符串被替代的部分,因此这里我们可以使用展开运算符将中间的捕获组截取出来,然后利用 join 方法,传入需要插入的符号即可转成符合需求的字符串。

ps: 由于这里经过我对谷歌浏览器的测试,replacer 的倒数第 3 个参数不存在,因此我这里截取结束索引值就是 args.length - 2。

因此,我们可以写出如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
ts复制代码const spaceRule = {
digit: 15,
rule: /(\d{6,})(\d{6,})(\d{3,})/g,
symbolNumber: 1,
symbol: " ",
};
const allInputs = document.querySelectorAll("input");
allInputs.forEach((item) => {
const v = item.value;
const { symbolNumber, symbol, rule } = spaceRule;
item.value = v.replace(rule, (...args) =>
args?.slice(1, args.length - 2)?.join(symbol.repeat(symbolNumber))
);
});

这样就达到了将输入框中 15 位数字中间插入符号的需求,并且满足 6 + 6 + 3 的规则。如果是 18 位数字,规则也变成了 6 + 4 + 4 + 4,我们就只需要修改 digit 和 rule 值即可,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
ts复制代码const spaceRule = {
digit: 18,
rule: /(\d{6,})(\d{4,})(\d{4,})(\d{4,})/g,
symbolNumber: 1,
symbol: " ",
};
const allInputs = document.querySelectorAll("input");
allInputs.forEach((item) => {
const v = item.value;
const { symbolNumber, symbol, rule } = spaceRule;
item.value = v.replace(rule, (...args) =>
args?.slice(1, args.length - 2)?.join(symbol.repeat(symbolNumber))
);
});

可以看到,我们核心的替换逻辑是没有变动的,变动的只是我们定义的规则而已,哪怕是用在 vue 和 react 框架当中,我们也只是修改一些框架特定的语法,但其实核心替换逻辑还是不会变动,比如 vue2 代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
ts复制代码const spaceRule = {
digit: 18,
rule: "6 + 4 + 4 + 4",
symbolNumber: 1,
symbol: " ",
};
export default {
methods: {
onFormatValue(item) {
const { symbolNumber, symbol, rule } = spaceRule;
const regExp = new RegExp(
`${rule
.split("+")
.map((item) => `(\\d{${Number(item)},})`)
.reduce((res, item) => ((res += item), res), "")}`,
"g"
);
const formatValue = item.replace(regExp, (...args) =>
args?.slice(1, args.length - 2)?.join(symbol.repeat(symbolNumber))
);
return formatValue;
},
},
};

vue3 代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
ts复制代码const spaceRule = {
digit: 18,
rule: "6 + 4 + 4 + 4",
symbolNumber: 1,
symbol: " ",
};
const onFormatValue = computed(() => (item) => {
const { symbolNumber, symbol, rule } = spaceRule;
const regExp = new RegExp(
`${rule
.split("+")
.map((item) => `(\\d{${Number(item)},})`)
.reduce((res, item) => ((res += item), res), "")}`,
"g"
);
const formatValue = item.replace(regExp, (...args) =>
args?.slice(1, args.length - 2)?.join(symbol.repeat(symbolNumber))
);
return formatValue;
});

react 代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
ts复制代码const spaceRule = {
digit: 18,
rule: "6 + 4 + 4 + 4",
symbolNumber: 1,
symbol: " ",
};
const FormatInput = () => {
const onFormatValue = React.useCallback((value) => {
const { symbolNumber, symbol, rule } = spaceRule;
const regExp = new RegExp(
`${rule
.split("+")
.map((item) => `(\\d{${Number(item)},})`)
.reduce((res, item) => ((res += item), res), "")}`,
"g"
);
const formatValue = value.replace(regExp, (...args) =>
args?.slice(1, args.length - 2)?.join(symbol.repeat(symbolNumber))
);
return formatValue;
}, []);
return <input type="text" value={onFormatValue("621848063688370808")} />;
};
export default FormatInput;

纵观以上的代码,我们可以发现核心的 js 逻辑是没有变动的,变动的只是一些框架有的概念而已,例如 vue2 中,我们使用方法结合双向绑定指令 v-model 来修改,而 react 也是同理,vue3 我们则是使用计算属性来表示。

网站介绍

基于以上的分析,接下来,就是我们这个提效网站实现的雏形,首先来看一下网站,如下图所示:

preview.png

截图截的不全,更详细可以点这里查看。

通过以上的网站展示,我们已经初步构思好了整个网站的构架:

  1. 创建规则的表单部分。
  2. 预览效果部分。
  3. 代码展示部分。

其中代码展示部分又提供了不同框架和原生版本的展示以及复制,同样的还提供了在线示例的下载,其它就是一些额外展示功能组件,没什么可说的,比如底部链接展示,头部组件,还有就是需求介绍展示组件。

核心原理我们已经知道了,接下来无非就是写好页面架构,技术选型上我们使用的是 vue3 + vite + naive-ui 组件库。

重点代码分析

核心页面我们也不必要介绍,这里只重点提一下一些重要功能的实现点,首先是代码压缩包的下载,我们采用的是 file-saver 和 jszip 库,代码很简单,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
ts复制代码const zip = new JSZip();
zip.file(
`${codeTypeValue.value}-demo.html`,
htmlTemplate(
renderTemplateCode.value.html,
renderTemplateCode.value.js,
codeTypeValue.value
)
);
// 调用zip的generateAsync生成一个blob文件
const content = await zip.generateAsync({ type: "blob" });
// saveAs 方法实现下载
saveAs(content, `${codeTypeValue.value}-demo.zip`);

其实这里的 htmlTemplate 就是构造一个下载代码模板,如下所示:

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
ts复制代码import { CodeTemplateKey } from "./code";

export const getScriptTemplate = (type: CodeTemplateKey) => {
if (type.includes("vue")) {
const src =
type === "vue2"
? "https://cdn.bootcdn.net/ajax/libs/vue/2.6.7/vue.min.js"
: "https://cdn.bootcdn.net/ajax/libs/vue/3.3.4/vue.global.min.js";
return `<script src="${src}"></script>`;
} else if (type === "react") {
return `<script src="https://cdn.bootcdn.net/ajax/libs/react/18.2.0/umd/react.production.min.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/react-dom/18.2.0/umd/react-dom.production.min.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/babel-standalone/7.22.17/babel.min.js"></script>`;
} else {
return "";
}
};
export const htmlTemplate = (
htmlContent: string,
jsContent: string,
type: CodeTemplateKey
) => `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width">
<title>输入框生成插入符号&nbsp;${type}&nbsp;demo</title>
<style>
body {
margin: 0;
}

input {
padding: 8px 24px;
border: 0;
border-radius: 15px;
background-color: #fefefe;
color: rgba(0, 0, 0, .85);
margin: 8px 0;
border: 1px solid #232323;
min-width: 250px;
}
</style>
</head>
<body>
<div id="app">${htmlContent}</div>
${getScriptTemplate(type)}
<script type="${
type === "react" ? "text/babel" : "text/javascript"
}">${jsContent}</script>
</body>
</html>`;

代码模板如下:

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
ts复制代码export const codeTemplate = {
js: (options: IFormValue) => ({
html: Array.from({ length: options.inputNumber })
.map((_, i) => options.inputContent[i])
.map((item) => `<input type="text" value="${item}"/>\n`)
.join(""),
js: `
const spaceRule = {
digit: ${options.digit},
rule: /${options.rule
.split("+")
.map((item) => `(\\d{${Number(item)},})`)
.reduce((res, item) => ((res += item), res), "")}/g,
symbolNumber: ${options.symbolNumber},
symbol: '${options.symbol}'
};
const allInputs = document.querySelectorAll('input');
allInputs.forEach(item => {
const v = item.value;
const { symbolNumber, symbol, rule } = spaceRule;
item.value = v.replace(rule, (...args) => args?.slice(1, args.length - 2)?.join(symbol.repeat(symbolNumber)));
})`,
}),
vue2: (options: IFormValue) => ({
html: Array.from({ length: options.inputNumber })
.map((_, i) => options.inputContent[i])
.map((_) => `<input type="text" v-model="onFormatValue('${_}')" />\n`)
.join(""),
js: `
const spaceRule = {
digit: ${options.digit},
rule: '${options.rule}',
symbolNumber: ${options.symbolNumber},
symbol: '${options.symbol}'
};
export default {
methods:{
onFormatValue(item){
const { symbolNumber,symbol,rule } = spaceRule;
const regExp = new RegExp(\`\${rule.split('+').map(item => \`(\\\\d{\$\{Number(item)\},})\`).reduce((res, item) => (res += item, res), '')}\`, 'g');
const formatValue = item.replace(regExp, (...args) => args?.slice(1, args.length - 2)?.join(symbol.repeat(symbolNumber)));
return formatValue;
}
}
}
`,
}),
vue3: (options: IFormValue) => ({
html: Array.from({ length: options.inputNumber })
.map((_, i) => options.inputContent[i])
.map((_) => `<input type="text" :value="onFormatValue('${_}')" />\n`)
.join(""),
js: `
const spaceRule = {
digit: ${options.digit},
rule: '${options.rule}',
symbolNumber: ${options.symbolNumber},
symbol: '${options.symbol}'
};
const onFormatValue = computed(() => (item) => {
const { symbolNumber,symbol,rule } = spaceRule;
const regExp = new RegExp(\`\${rule.split('+').map(item => \`(\\\\d{\$\{Number(item)\},})\`).reduce((res, item) => (res += item, res), '')}\`, 'g');
const formatValue = item.replace(regExp, (...args) => args?.slice(1, args.length - 2)?.join(symbol.repeat(symbolNumber)));
return formatValue;
})
`,
}),
react: (options: IFormValue) => ({
html: "",
js: `
const spaceRule = {
digit: ${options.digit},
rule: '${options.rule}',
symbolNumber: ${options.symbolNumber},
symbol: '${options.symbol}'
};
const FormatInput = () => {
const onFormatValue = React.useCallback((value) => {
const { symbolNumber,symbol,rule } = spaceRule;
const regExp = new RegExp(\`\${rule.split('+').map(item => \`(\\\\d{\$\{Number(item)\},})\`).reduce((res, item) => (res += item, res), '')}\`, 'g');
const formatValue = value.replace(regExp, (...args) => args?.slice(1, args.length - 2)?.join(symbol.repeat(symbolNumber)));
return formatValue;
},[])
return (
${Array.from({ length: options.inputNumber })
.map((_, i) => options.inputContent[i])
.map((_) => `<input type="text" value={onFormatValue('${_}')} />`)
.join("")}
)
}
export default FormatInput;
`,
}),
};

export const demoCodeTemplate = {
js: (options: IFormValue) => ({
html: Array.from({ length: options.inputNumber })
.map((_, i) => options.inputContent[i])
.map((item) => `<input type="text" value="${item}"/>\n`)
.join(""),
js: `
const spaceRule = {
digit: ${options.digit},
rule: /${options.rule
.split("+")
.map((item) => `(\\d{${Number(item)},})`)
.reduce((res, item) => ((res += item), res), "")}/g,
symbolNumber: ${options.symbolNumber},
symbol: '${options.symbol}'
};
const allInputs = document.querySelectorAll('input');
allInputs.forEach(item => {
const v = item.value;
const { symbolNumber, symbol, rule } = spaceRule;
item.value = v.replace(rule, (...args) => args?.slice(1, args.length - 2)?.join(symbol.repeat(symbolNumber)));
})`,
}),
vue2: (options: IFormValue) => ({
html: Array.from({ length: options.inputNumber })
.map((_, i) => options.inputContent[i])
.map((_) => `<input type="text" v-model="onFormatValue('${_}')" />\n`)
.join(""),
js: `
const spaceRule = {
digit: ${options.digit},
rule: '${options.rule}',
symbolNumber: ${options.symbolNumber},
symbol: '${options.symbol}'
};
new Vue({
el:"#app",
methods:{
onFormatValue(item){
const { symbolNumber,symbol,rule } = spaceRule;
const regExp = new RegExp(\`\${rule.split('+').map(item => \`(\\\\d{\$\{Number(item)\},})\`).reduce((res, item) => (res += item, res), '')}\`, 'g');
const formatValue = item.replace(regExp, (...args) => args?.slice(1, args.length - 2)?.join(symbol.repeat(symbolNumber)));
return formatValue;
}
}
});
`,
}),
vue3: (options: IFormValue) => ({
html: Array.from({ length: options.inputNumber })
.map((_, i) => options.inputContent[i])
.map((_) => `<input type="text" :value="onFormatValue('${_}')" />\n`)
.join(""),
js: `
const spaceRule = {
digit: ${options.digit},
rule: '${options.rule}',
symbolNumber: ${options.symbolNumber},
symbol: '${options.symbol}'
};
Vue.createApp({
setup() {
const onFormatValue = Vue.computed(() => (item) => {
const { symbolNumber,symbol,rule } = spaceRule;
const regExp = new RegExp(\`\${rule.split('+').map(item => \`(\\\\d{\$\{Number(item)\},})\`).reduce((res, item) => (res += item, res), '')}\`, 'g');
const formatValue = item.replace(regExp, (...args) => args?.slice(1, args.length - 2)?.join(symbol.repeat(symbolNumber)));
return formatValue;
})
return {
onFormatValue
}
}
}).mount('#app')
`,
}),
react: (options: IFormValue) => ({
html: "",
js: `
const spaceRule = {
digit: ${options.digit},
rule: '${options.rule}',
symbolNumber: ${options.symbolNumber},
symbol: '${options.symbol}'
};
const FormatInput = () => {
const onFormatValue = React.useCallback((value) => {
const { symbolNumber,symbol,rule } = spaceRule;
const regExp = new RegExp(\`\${rule.split('+').map(item => \`(\\\\d{\$\{Number(item)\},})\`).reduce((res, item) => (res += item, res), '')}\`, 'g');
const formatValue = value.replace(regExp, (...args) => args?.slice(1, args.length - 2)?.join(symbol.repeat(symbolNumber)));
return formatValue;
},[])
return (
${Array.from({ length: options.inputNumber })
.map((_, i) => options.inputContent[i])
.map((_) => `<input type="text" value={onFormatValue('${_}')} />`)
.join("")}
)
}
const root = ReactDOM.createRoot(document.querySelector('#app'));
root.render(<FormatInput />);
`,
}),
};

export type CodeTemplateKey = keyof typeof codeTemplate;

// 代码key列表
export const codeTypeList = Object.keys(codeTemplate) as CodeTemplateKey[];

// 代码版本下拉列表
export const selectCodeTypeList = codeTypeList.map((item) => ({
label: item,
value: item,
}));

然后就是我们的 copy 复制代码功能函数的实现,原理就是利用了 navigator.clipboard api,如果浏览器不支持,我们就使用 document.execCommand api,工具函数代码如下所示:

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
ts复制代码export const copyHandler = (str: string, dialog?: DialogApi) => {
const confirm = (title = "温馨提示", content = "已复制到剪切板") => {
dialog?.success({
title: title,
content: content,
positiveText: "确定",
});
};
const baseCopy = (copyText: string) =>
new Promise<void>((resolve, reject) => {
// 判断是否存在clipboard并且是安全的协议
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard
.writeText(copyText)
.then(() => {
resolve();
})
.catch(() => {
reject(new Error("复制失败"));
});
} else {
// 否则使用被废弃的execCommand
const input = document.createElement("input") as HTMLInputElement;
input.value = copyText;
// 使input不在viewport,同时设置不可见
input.style.position = "absolute";
input.style.left = "-9999px";
input.style.top = "-9999px";
document.body.append(input);
input.focus();
input.select();
// 执行复制命令并移除文本框
if (document.execCommand) {
document.execCommand("copy");
resolve();
} else {
reject(new Error("复制失败"));
}
input.remove();
}
});
baseCopy(str)
.then(() => confirm())
.catch(() => confirm("温馨提示", "复制失败"));
};

遇到的有意思的问题分析

除此之外,其它都是一些很好理解的基础代码,因此不需要讲解,这里讲一个让我觉得有意思的问题,也是在源码当中有备注,那就是被代理的对象会被污染,可以看到我们的 config.ts 里面写了 2 个最基础的表单配置对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
ts复制代码export const defaultFormValue = {
digit: 15,
rule: "6 + 6 + 3",
symbol: " ",
symbolNumber: 1,
inputNumber: 1,
inputContent: ["621848063680370"],
};
export const baseDefaultFormValue = {
digit: 15,
rule: "6 + 6 + 3",
symbol: " ",
symbolNumber: 1,
inputNumber: 1,
inputContent: ["621848063680370"],
};

用来设置表单的初始值对象,我在监听用户修改输入框值之后去改变绑定的初始值,发现绑定的初始值被修改污染了,哪怕我采用了复制对象副本(使用 JSON 和展开运算符来复制),都会修改原始配置对象。我们的表单配置对象是这样的:

1
ts复制代码const formValue = ref({ ...defaultFormValue });

也就是说我对 formValue 的修改会影响到 defaultFormValue,这就让我感觉很奇怪,所以我想创建一个 baseDefaultFormValue 的方式去解决这个问题,这样我在重置表单数据的时候,就能够重置为最初始的数据,如下:

1
2
3
4
5
6
7
8
ts复制代码const handleResetClick = () => {
formRef.value?.restoreValidation();
// 不重新写一个defaultFormValue已经被污染了
formValue.value = {
...baseDefaultFormValue,
};
emit("on-submit", formValue.value);
};

这个问题,目前我还没有分析出原因来,如果有感兴趣的大佬,可以通过参考源码调试看看问题,我就没有时间去研究这个问题呢。

这些点是我觉得值得分析的地方,其它就没啥了,感谢阅读到这里,如果觉得有帮助可以点赞收藏,顺带可以帮我的项目点个 star,感激不尽。

本文转载自: 掘金

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

5分钟回顾webpack的前世今生

发表于 2023-10-16

作者:章文亮

引言

模块化编程是软件设计的一个重要思想。在JavaScript中,处理模块一直是个问题,由于浏览器只能执行JavaScrip、CSS、HTML 代码,所以模块化的前端代码必须进行转换后才能运行。例如 CommonJS 或 AMD,甚至是ECMA 提出的 JavaScript 模块化规范——ES6 模块,这些模块系统要么是在浏览器无法运行,要么是无法被浏览器识别和加载,所以针对不同的模块系统,就需要使用专门的工具将源代码转换成浏览器能执行的代码。

整个转化过程被称为构建,构建过程就是“模块捆绑器”或“模块加载器”发挥作用的地方。

Webpack是JavaScript模块捆绑器。在Webpack之前,已经有针对各类型的代码进行编译和构建的流程,例如使用Browserify对CommonJS模块进行编译和打包,然后将打包的资源通过HTML去加载;或者通过gulp进行任务组排来完成整个前端自动化构建。

但是这些方式的缺点是构建环节脱离,编译、打包以及各类资源的任务都分离开。

Webpack模块系统的出现,能将应用程序的所有资源(例如JavaScript、CSS、HTML、图像等)作为模块进行管理,并将它们打包成一个或多个文件并进行优化。Webpack的强大和灵活性使得其能够处理复杂的依赖关系和资源管理,已经成为了构建工具中的首选。

本文主要来扒一扒Webpack的发展进阶史,一起来看看Webpack是如何逐渐从一个简单的模块打包工具,发展成一个全面的前端构建工具和生态系统。

webpack发展历程

webpack从2012年9月发布第一个大版本至2020年10月一共诞生了5个大的版本,我们从下面一张图可以清晰具体地看到每一个版本的主要变化

Webpack发展史.png

Webpack 版本变化方向

  1. Webpack 1:在此之前多是用gulp对各个类型的编译任务进行编排,最后在Html文件中将各种资源引用进来,而Webpack的初始版本横空出世,凭借如下其功能、理念、内核等优点成为众多前端构建工具的最新选择。
  • 理念:一切皆资源,在代码中就能能对Html、Js、Css、图片、文本、JSON等各类资源进行模块化处理。
  • 内核:实现了独有的模块加载机制,引入了模块化打包和代码分割的概念。
  • 功能:集合了编译、打包、代码优化、性能改进等以前各类单一工具的功能,成为前端构建工具标准选择。
  • 特点:通过配置即可完成前端构建任务,同时支持开发者自定义Loader和Plugin对Webpack的生态进行更多的扩展。
  1. Webpack 2: Webpack 2的在第一个版本后足足过了4年,其重点在于满足更多的打包需求以及少量对打包产物的优化
  • 引入对ES6模块的本地支持。
  • 引入import语法,支持按需加载模块。
  • 支持Tree Shaking(无用代码消除)。
  1. Webpack 3:Webpack 3提供了一些优化打包速度的配置,同时对打包体积的优化再次精益求精
  • 引入Scope Hoisting(作用域提升),用于减小打包文件体积。
  • 引入module.noParse选项,用于跳过不需要解析的模块。
  1. Webpack 4:Webpack 4带来了显著的性能提升,同时侧重于用户体验,倡导开箱即用
  • 引入了mode选项,用于配置开发模式或生成模式,减少用户的配置成本,开箱即用
  • 内置Web Workers支持,以提高性能
  1. Webpack 5:Webpack 5继续在构建性能和构建输出上进行了改进,且带来跨应用运行时模块共享的方案
  • 支持WebAssembly模块,使前端能够更高效地执行计算密集型任务。
  • 引入了文件系统持久缓存,提高构建速度
  • 引入Module Federation(模块联邦),允许多个Webpack应用共享模块

webpack打包后的代码分析

为了更方便理解后续章节,我们先看一下Webpack打包后的代码长什么样(为了方便理解,这里以低版本Webpack为例,且不做过多描述)

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
jsx复制代码/******/ (function(modules) { // webpackBootstrap
/******/ // The module cache
/******/ var installedModules = {};

/******/ // The require function
/******/ function __webpack_require__(moduleId) {
/******/ /* 省略 */
/******/ }

/******/ // expose the modules object (__webpack_modules__)
/******/ __webpack_require__.m = modules;

/******/ // expose the module cache
/******/ __webpack_require__.c = installedModules;

/******/ // __webpack_public_path__
/******/ __webpack_require__.p = "";

/******/ // Load entry module and return exports
/******/ return __webpack_require__(0);
/******/ })
/************************************************************************/
/******/ ([
/* 0 */
/***/ (function(module, __webpack_exports__, __webpack_require__) {/*省略*/})
/* 1 */
/***/ (function(module, __webpack_exports__, __webpack_require__) {/*省略*/})
/* 2 */
/***/ (function(module, __webpack_exports__, __webpack_require__) {/*省略*/})
/******/ ]);

可以看到其实入口文件就是一个IIFE(立即执行函数),在这个IIFE里核心包括两块:

  1. 模块系统:Webpack 在IIFE里实现了模块系统所需要的Module、Require、export等方法组织代码。每个模块都被包装在一个函数内,这个函数形成了一个闭包,模块的作用域在这个闭包内。
  2. 模块闭包:IIFE的入参即是Modules,它是一个数组,数组的每一项则是一个模块,每个模块都有自己的作用域。模块和模块之间通过Webpack的模块系统可以进行引用。

webpack的发展长河中,笑到最后和沦为历史

笑到最后:OccurrenceOrderPlugin

有趣的是该插件在Webpack 1叫做OccurenceOrderPlugin,Webpack 2才更名为OccurrenceOrderPlugin,Webpack 3则不需要手动配置该插件了。

插件作用:用于优化模块的顺序,以减小输出文件的体积。其原理基于模块的使用频率,将最常用的模块排在前面,以便更好地利用浏览器的缓存机制。

有了前面对于Webpack打包后的代码分析,OcurrenceOrderPlugin的优化效果也就很好理解了。它的原理主要基于两个概念:模块的使用频率和模块的ID

  1. 模块的使用频率:OccurrenceOrderPlugin 插件会分析在编译过程中每个模块的出现次数。这个出现次数是指模块在其他模块中被引用的次数。插件会统计模块的出现次数,通常情况下,被引用次数更多的模块将被认为更重要,因此会更早地被加载和执行。
  2. 模块的 ID:Webpack 使用数字作为模块的 ID,OccurrenceOrderPlugin 插件会根据模块的出现次数,为每个模块分配一个优化的 ID。这些 ID 的分配是按照出现次数从高到低的顺序进行的,以便出现次数较多的模块获得较短的 ID,这可以减小生成的 JavaScript 文件的大小。假设一共有100个模块,最高的频率为被引用100次,则减小文件体积200B。(确实好像作用很小,但是作为最贴近用户体验的前端er,不应该是追求精益求精嘛)

这个插件的主要目标是减小 JavaScript 文件的体积,并提高加载性能,因为浏览器通常更倾向于缓存较小的文件。通过将频繁使用的模块分配到较短的 ID,可以减小输出文件的体积,并提高缓存的效率。

笑到最后:Scope Hoisting

过去 Webpack 打包时的一个取舍是将 bundle 中各个模块单独打包成闭包。这些打包函数使你的 JavaScript 在浏览器中处理的更慢。相比之下,一些工具像 Closure Compiler 和 RollupJS 可以提升(hoist)或者预编译所有模块到一个闭包中,提升你的代码在浏览器中的执行速度。

而Scope Hoisting 就是实现以上的预编译功能,通过静态分析代码,确定哪些模块之间的依赖关系,然后将这些模块合并到一个函数作用域中。这样,多个模块之间的函数调用关系被转化为更紧凑的代码,减少了函数调用的开销。这样不仅减小了代码体积,同时也提升了运行时性能。

Scope Hoisting 的原理是在 Webpack 的编译过程中自动进行的,开发人员无需手动干预。要启用 Scope Hoisting,你可以使用 Webpack 4 版本中引入的 moduleConcatenation 插件。在 Webpack 5 及更高版本中,Scope Hoisting 是默认启用的,不需要额外的配置。

CommonsChunkPlugin的作用和不足,为何会被optimization.splitChunks所取代

CommonsChunkPlugin 插件,是一个可选的用于建立一个独立chunk的功能,这个文件包括多个入口 chunk 的公共模块。主要配置项包含

1
2
3
4
5
6
7
8
9
10
11
12
json复制代码{
name: string, // or
names: string[],
filename: string,
minChunks: number|Infinity|function(module, count) => boolean,
chunks: string[],
// 通过 chunk name 去选择 chunks 的来源。chunk 必须是 公共chunk 的子模块。
// 如果被忽略,所有的,所有的 入口chunk (entry chunk) 都会被选择。

children: boolean,
deepChildren: boolean,
}

通过上面的配置项可以看到虽然CommonsChunkPlugin将一些重复的模块传入到一个公共的chunk,以减少重复加载的情况,尤其是将第三方库提取到一个单独的文件中,但是其首要依赖是通过Entry Chunk进行的。在Webpack4以及更高的版本当中被optimization.splitChunks所替代,其提供了配置让webpack根据策略来自动进行拆分,被替代的原因主要有以下几点:

  1. 灵活度不足:在配置上相对固定,只能将指定 Entry Chunk的共享模块提取到一个单独的chunk中,可能无法满足复杂的代码拆分需求。
  2. 配置复杂:需要手动指定要提取的模块和插件的顺序,配置起来相对复杂,开发者需要约定好哪些chunk可以被传入,有较高的心智负担。而optimization.splitChunks只需要配置好策略就能够帮你自动拆分。

因此在Webpack 4这个配置和开箱即用的版本里,它自然也就“香消玉损”。只能遗憾地看到一句:

the CommonsChunkPlugin 已经从 Webpack v4 legato 中移除。想要了解在最新版本中如何处理 chunk,请查看 SplitChunksPlugin。

被移除的DedupePlugin

这是 Webpack 1.x 版本中的插件,用于在打包过程中去除重复的模块(deduplication),其原理不知道是通过内容hash,还是依赖调用关系图。但是在Webpack 2中引入了Tree Shaking功能,则不再需要了。原因有以下几点:

  • Tree Shaking控制更精确:能通过静态分析来判断哪些代码是不需要的,实现了更细力度的优化。
  • Scope Hositing减少了重复模块:Webpack 3引入了Scope Hositing,将模块包裹在函数闭包中,进一步减少了重复模块的依赖

因此我们在Webpack的文档中看到:

DedupePlugin has been removed

不再需要 Webpack.optimize.DedupePlugin。请从配置中移除。

总结

或许有些插件你已经看不到它的身影,有些特性早已被webpack内置其中。webpack从第一个版本诞生后一直致力于以下几个方面的提升:

  1. 性能优化:通过去除重复代码、作用域提升、压缩等方式减少代码体积和提高运行时性能。
  2. 构建提效:通过增量编译、缓存机制、并行处理等提升打包速度。
  3. 配置简化:通过内置必要的特性和插件以及简化配置提升易用性。

最后

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

本文转载自: 掘金

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

基于 Booster ASM API的配置化 hook 方案

发表于 2023-10-11

本文示例代码:AndroidSimpleHook-github

背景

booster 是一款专门为移动应用设计的易用、轻量级且可扩展的质量优化框架,其目标主要是为了解决随着 APP 复杂度的提升而带来的性能、稳定性、包体积等一系列质量问题。

booster 项目地址:didi/booster: 🚀Optimizer for mobile applications (github.com)

booster 项目提供了非常多的拿来即用的好用的工具,包括通用 api 封装、性能优化插件、包体积插件、系统 bug 修复插件等等,具体详情可以参考项目 wiki。 而我们今天,就将会使用过 booster 封装的 ASM api 来二次封装个简单的 hook 框架。

无处不在的 hook 需求

不管项目大小,总归有一些通过 hook 技术来进行切面编程的需求,比如:

  1. 检查或者禁用掉项目对于隐私敏感 API 的调用
  2. 特殊日子全局置灰,Activity 可以通过ActivityLifecycleCallbacks切入,但是像 Dialog、PopupWindow 散落各地且没法简单同意注入的就可以简单的 hook 一下关键方法就行
  3. 想让项目中的 logcat 在线上不打印,也可以通过 hook android.util.Log 来实现
  4. so文件不内置且需要动态下发,就可以通过 hook System.load 进入到自定义的下载、检查、加载逻辑

当然,上面提到的这些只是一些比较基础的 hook 需求举例,甚至部分可能不通过 hook 也可以做到,这里只是作为需求引入。

好了,那么当我们有了 hook 的需求后,就要去想法子进行实现了,这很容易让人想到 Transform API,gradle比较远古的版本就支持了 Transform API,允许第三方插件在将已编译的 class 文件转换为 dex 文件之前对其进行操作。所以我们今天的 hook 框架就用 Transform API 来实现。

那就会有同学问,你为啥不自己写个 Transform 插件,而要用 booster 呢?答案很简单,booster 封装过一层 API 后,使用起来更简单哈,「懒」。

怎样进行 hook 更加简单

接下来,我们可以开始考虑考虑,我们的 hook 工具需要变成什么样子才会使用起来更加简单,结论显而易见:敲最少次数的键盘 + 容易阅读,那就是所谓的配置化了,配置化的优点有以下:

  1. 添加一个 hook 需求只需要做个简单配置
  2. 能一眼看全配置了哪些 hook 条目

给大家举个例子,当大家在项目中想要 hook 所有的Dialog#show()方法,在弹任何 Dialog 的时候,全局进行计数,如果只需要写下面这些代码,是不是非常简单:

1
2
3
4
5
6
7
8
9
10
11
12
ini复制代码HookClass(
needHookClass = "android/app/Dialog",
afterHookClass = "com/test/instrument/TestDialog",
hookMethod = HookMethod(methodName = "show")
)

#TestDialog.java
public static dialogCnt = 0;
public static void show(Dialog dialog) {
dialog.show();
dialogCnt++;
}

我们上面只干了以下几个事情:

  1. 指定要 hook 的类和方法
  2. 实现 hook 后的逻辑及指定 hook 后的替换类和方法

这样大家就可以开心的进行想要的 hook,而不需要思考如何去实现 hook 过程。

配置化的 hook 我们希望指定哪些条目可以配置

接下来我们可以开脑洞想想,我们在配置 hook 项时需要有哪些配置,其实大体可以分为两部分:

  1. 指定 hook 处的配置项
  2. 排除不想 hook 处的 filter

那么我们可以大概列出来:

  1. needHookClass - 需要 hook 的 class
  2. afterHookClass - hook 后逻辑实现的 class
  3. hookMethods - 需要被 hook 的方法们
  • methodName - 方法名
  • methodDesc - 方法描述,包括参数类型、返回值类型,比如 (Ljava/lang/String;)Ljava/lang/String; 就表明入参是 string 类型,返回值也是 string 类型
  • methodFilter - 提供一个自定义的过滤器
  1. doNotHookPackages - 哪些包下面的被 hook 类的指定方法调用不进行 hook,当然也可以实现成类似 filter 的东西

当然,如果要做的更加完善,肯定还会有其他更多的配置项,我这里做演示只加这些哈

如何描述 hook 配置

一般来说,在 Android 项目中经常能见到的描述配置化的东西有以下几种方式:

  1. 类似 build.gradle 中的 DSL 方案
  • groovy dsl
  • kotlin dsl
  1. XML 配置解析
  2. json 配置文件
  3. 其他

在这篇文章里,我们将一个不采用,为啥呢?嫌麻烦,由于 kotlin 支持命名参数,我们直接把配置写在 kt 代码里,key用命名参数描述就行了,虽然用 dsl 方案会更加优雅。

那我们将这样描述我们的 hook 配置:

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
ini复制代码//用 kotlin 的命名参数来描述 key-value,先突出一个格式整齐
val hookConfig = HookConfig(
listOf(
HookClass(
needHookClass = "android/app/ActivityManager",
afterHookClass = "com/test/instrument/ShadowActivityManager",
hookMethod = HookMethod(methodName = "getRunningAppProcesses")
),
HookClass(
needHookClass = "android/app/Dialog",
afterHookClass = "com/test/instrument/TestDialog",
hookMethod = HookMethod(methodName = "show")
),
HookClass(
needHookClass = "java/lang/System",
afterHookClass = "com/test/instrument/ShadowSystem",
hookMethods = listOf(
HookMethod(methodName = "load"),
HookMethod(methodName = "loadLibrary"),
),
doNotHookPackages = listOf("com/test/base/tools/soloader")
),
HookClass(
needHookClass = "android/util/Log",
afterHookClass = "com/test/instrument/ShadowLog",
hookMethods = listOf(
HookMethod(methodName = "v"),
HookMethod(methodName = "d"),
HookMethod(methodName = "i"),
HookMethod(methodName = "w"),
HookMethod(methodName = "e"),
HookMethod(methodName = "wtf"),
HookMethod(methodName = "println")
)
),
HookClass(
needHookClass = "android/content/pm/PackageManager",
afterHookClass = "com/taou/instrument/ShadowPackageManager",
hookMethods = listOf(
HookMethod(methodName = "getInstalledPackages"),
HookMethod(methodName = "getInstalledApplications"),
HookMethod(methodName = "queryIntentActivities"),
HookMethod(methodName = "getPackagesForUid"),
),
doNotHookPackages = listOf("com/tencent/")
),
)
)

上述这份配置表里将会涉及以下几个 class 文件。一个是 HookConfig,就是我们所谓的配置,HookClass 是我们需要 hook 的目标类,HookMethod 是我们需要 hook 的目标方法。

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
kotlin复制代码class HookConfig(val hookClassList: List<HookClass>)

data class HookClass(
val needHookClass: String,
val afterHookClass: String,
val hookMethods: List<HookMethod>,
val doNotHookPackages: List<String> = emptyList()
) {
constructor(
needHookClass: String,
afterHookClass: String,
hookMethod: HookMethod,
doNotHookPackages: List<String> = emptyList()
) : this(
needHookClass,
afterHookClass,
listOf(hookMethod),
doNotHookPackages
)
}

data class HookMethod(
val methodName: String,
val methodDesc: String = "",
val methodFilter: ((methodNode: MethodInsnNode) -> Boolean) = { _ -> true },
)

如何实现配置下就能完成 hook

我们参考How to Create Customized Transformer · didi/booster Wiki (github.com) 来自定义 Transformer,具体实现步骤为:

  1. 自定义一个 HookTransformer 来实现ClassTransformer接口,同时加上注解@AutoService(ClassTransformer::class)
  2. 将 HookTransformer 放到 buildSrc 包中
  3. 没了。。

在我们实际的项目中,我们可能大部分的 hook 都是在 hook 某个对象方法或者静态方法,所以我们这篇文章里 只介绍针对对象方法、静态方法的 hook ,而且这两种场景也是最容易实现的,因此作为演示内容。

大家可以想一想,当我们要 hook 某个方法并且转到另一个实现方法时,怎么做会更简单呢?假如是一个对象方法:

1
2
ini复制代码Dialog dialog = new Dialog(context);
dialog.show();

我们要 hook 它的话,有两种方式:

1. 继承 Dialog

重写 show 方法,同时将对象构造给替换掉,即:

1
2
3
4
5
6
7
8
9
scala复制代码Dialog dialog = new DialogTest(context);
dialog.show();

public class DialogTest extends Dialog{
@override
publich void show(){
xxx
}
}

2. 替换 show 方法

替换掉 show 方法,转成静态方法实现,即:

1
2
3
4
5
6
7
8
9
java复制代码Dialog dialog = new Dialog(context);
DialogUtils.show(dialog);

public class DialogUtils{
publich static void show(dialog){
xxx
dialog.show();
}
}

出于以下两点考虑,本文将采用转静态方法的 hook 方式来进行 hook:

  1. 实现简单,只需两步:
  • 修改 opcode 为 Opcodes.INVOKESTATIC
  • 如果是对象方法,在方法的参数列表中加上 hookClass 本身,就可以将调用对象加在参数里进行传递
  1. 对象方法和静态方法的 hook 实现逻辑几乎没有区别

好,那就开整。

我们先描述一下我们的 hook 实现大体流程:

  1. 针对每个 class 文件,遍历 class 的每个 method
  2. 针对每个 method,遍历操作指令,找出所有的方法指令
  3. 针对这些方法指令进行我们的 hook 过滤
  • owner 是我们的 hookClass
  • owner 不能是我们的 afterHookClass
  • owner 不能在我们的 doNotHookPackages 里
  • opcode 必须是 INVOKESTATIC、INVOKEVIRTUAL、INVOKEINTERFACE,opcode 参考
  • methodName匹配
  • methodDesc 匹配
  • methodFilter 不能过滤
  1. 针对每个需要 hook 的 method 进行处理
  • opcode 统一替换成 INVOKESTATIC
  • owner 统一替换成 afterHookClass
  • 如果是对象方法,在 desc 上,加上该对象参数

具体代码如下:

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
kotlin复制代码//1. build.gradle 中 implementation 下 booster-api 的依赖
implementation "com.didiglobal.booster:booster-transform-asm:$boosterVersion"
//2. 实现自定义的 Transformer
@AutoService(ClassTransformer::class)
class HookTransformer : ClassTransformer {
override fun transform(context: TransformContext, klass: ClassNode): ClassNode {
klass.methods.forEach { method ->
method.instructions?.iterator()?.asIterable()
?.filterIsInstance(MethodInsnNode::class.java)?.let { methodInsnNode ->
for (i in methodInsnNode.indices) {
val methodNode = methodInsnNode[i]
//检查owner,先匹配当前方法调用的 owner,根据 owner 找到 HookClass,找不到则说明不需要hook
val hookClass = hookConfig.hookClassList.firstOrNull {
//1. owner要匹配,2. 当前处理的 class 不能是 afterHookClass,3. 当前处理的 class 不能在过滤的包中
(methodNode.owner == it.needHookClass || context.klassPool[it.needHookClass].isAssignableFrom(
methodNode.owner
)) &&
klass.name != it.afterHookClass &&
it.doNotHookPackages.none { doNotHookPackage ->
klass.name.startsWith(
doNotHookPackage
)
}
}
hookClass ?: continue

//检查方法类型不匹配,https://blog.csdn.net/LuoZheng4698729/article/details/104971966
if (methodNode.opcode != Opcodes.INVOKESTATIC && methodNode.opcode != Opcodes.INVOKEVIRTUAL && methodNode.opcode != Opcodes.INVOKEINTERFACE) {
continue
}

//检查方法名、方法描述不匹配,或者方法名、方法描述被过滤
var methodNeedHook = false
for (index in hookClass.hookMethods.indices) {
val hookMethod = hookClass.hookMethods[index]
if (hookMethod.methodName == methodNode.name
&& (hookMethod.methodDesc == methodNode.desc || hookMethod.methodDesc.isEmpty())
&& hookMethod.methodFilter(methodNode)
) {
methodNeedHook = true
break
}
}
if (!methodNeedHook) {
continue
}

//开始hook
methodNode.owner = hookClass.afterHookClass
//对象方法、接口方法需要调整为static方法
if (methodNode.opcode != Opcodes.INVOKESTATIC) {
methodNode.desc =
methodNode.desc.replace("(", "(L${hookClass.needHookClass};")
}
methodNode.opcode = Opcodes.INVOKESTATIC
methodNode.itf = false
}
}
}
return klass
}
}

写在最后

至此,我们实现了一个「实现容易,使用容易」的 hook 框架,想要 hook 一个方法仅需要如下两步:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
ini复制代码1. 在指定配置文件里加上 hook 配置
HookClass(
needHookClass = "android/app/Dialog",
afterHookClass = "com/test/instrument/TestDialog",
hookMethod = HookMethod(methodName = "show")
)

2. 实现一下 hook 后的替换方法
#TestDialog.java
public static dialogCnt = 0;
public static void show(Dialog dialog) {
dialog.show();
dialogCnt++;
}

当然,由于是基础版本,实际上并没有实现的非常完善,比如:

  1. 未实现构造函数的 hook
  2. 只能 hook 项目代码,无法 hook Framework 代码
  3. 无法 hook native 代码

但是,这并不影响这个 hook 框架非常好用哈。

展望

后续我会基于本文中描述的 hook 框架:

  1. 进行功能扩展,支持 hook 构造方法
  2. 分享相关的使用案例,如 so 动态下载、全局置灰等,当支持 hook 构造方法后,就可以用来做线程优化了。
  3. 其他…

你可能感兴趣

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)

本文转载自: 掘金

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

记 AndroidStudio Tracer工具导致的编译失

发表于 2023-10-08

一、前言

大概说一下我们项目一些工具版本情况:

  • JDK : 11
  • gradle:7.3.3
  • AGP:7.2.2
  • Android Studio 版本:

二、编译报错

这天我高高兴兴的开始编译,点了一下 IDE 的这个按钮:

突然,来了一拨飘红:

完整内容如下:

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
csharp复制代码OpenJDK 64-Bit Server VM warning: Ignoring option MaxPermSize; support was removed in 8.0
Exception in thread "main" java.lang.UnsupportedClassVersionError: com/android/tools/tracer/agent/TraceAgent has been compiled by a more recent version of the Java Runtime (class file version 61.0), this version of the Java Runtime only recognizes class file versions up to 55.0
at java.base/java.lang.ClassLoader.findBootstrapClass(Native Method)
at java.base/java.lang.ClassLoader.findBootstrapClassOrNull(ClassLoader.java:1263)
at java.base/java.lang.System$2.findBootstrapClassOrNull(System.java:2147)
at java.base/jdk.internal.loader.ClassLoaders$BootClassLoader.loadClassOrNull(ClassLoaders.java:118)
at java.base/jdk.internal.loader.BuiltinClassLoader.loadClassOrNull(BuiltinClassLoader.java:616)
at java.base/jdk.internal.loader.BuiltinClassLoader.loadClassOrNull(BuiltinClassLoader.java:640)
at java.base/jdk.internal.loader.BuiltinClassLoader.loadClassOrNull(BuiltinClassLoader.java:616)
at java.base/jdk.internal.loader.BuiltinClassLoader.loadClass(BuiltinClassLoader.java:579)
at java.base/jdk.internal.loader.ClassLoaders$AppClassLoader.loadClass(ClassLoaders.java:178)
at java.base/java.lang.ClassLoader.loadClass(ClassLoader.java:527)
at java.instrument/sun.instrument.InstrumentationImpl.loadClassAndStartAgent(InstrumentationImpl.java:431)
at java.instrument/sun.instrument.InstrumentationImpl.loadClassAndCallPremain(InstrumentationImpl.java:525)
*** java.lang.instrument ASSERTION FAILED ***: "result" with message agent load/premain call failed at src/java.instrument/share/native/libinstrument/JPLISAgent.c line: 422
FATAL ERROR in native method: processing of -javaagent failed, processJavaStart failed
Native frames: (J=compiled Java code, A=aot compiled Java code, j=interpreted, Vv=VM code, C=native code)
V [libjvm.dylib+0x3829cc] jni_FatalError+0x138
V [libjvm.dylib+0x481ce4] JvmtiExport::post_vm_initialized()+0x18c
V [libjvm.dylib+0x708640] Threads::create_vm(JavaVMInitArgs*, bool*)+0x70c
V [libjvm.dylib+0x3a0c28] JNI_CreateJavaVM+0x78
C [libjli.dylib+0x4fe4] JavaMain+0x104
C [libjli.dylib+0x7b38] ThreadJavaMain+0xc
C [libsystem_pthread.dylib+0x6fa8] _pthread_start+0x94

-----------------------
Check the JVM arguments defined for the gradle process in:
- gradle.properties in project root directory

同时,我用命令行进行编译 **./gradlew clean assembleDebug** ,发现可以正常编译通过,这就有点神奇了。我也尝试了重启大法,**重启 IDE、重启电脑、invalidate caches & restart** 都不 work

三、冷静分析

首先看到报错的第一时间,肯定是看清楚报的具体是什么错误,是什么东西导致的。

从错误里我们能看到,一个叫 com/android/tools/tracer/agent/TraceAgent 的文件已经被编译成了 61.0 版本的 class 文件,我们配置的 java 编译环境只能够识别 55.0 版本的 class 文件。从下面表格能看出来,一个是 JDK17,一个是 JDK11。

疑惑一:哪里配的 jdk17?

我们检查一下我们 IDE 上的一些配置,包括:

  1. gradle JDK 配置:
  2. gradle build tools配置:

并没有看到有任何的 JDK 17的配置。

疑惑二:**com/android/tools/tracer/agent/TraceAgent** 究竟是个啥文件?

顺手google一下,找到了这个文件:/studio-master-dev/tracer/BUILD 这个文件里有个配置就是我们报错的这个文件。

四、摸清问题

摸清问题前,我们汇总一下现有的一些信息:

  1. AndroidStudio 的编译不通过,但是命令行能编译通过
  2. 清 IDE 的 cache及一些其他的重启大法都不好使
  3. 报错是说 TraceAgent 是个 JDK17 的 class,无法被我们的编译环境配置的 JDK11 识别
  4. 我们项目里、IDE 的配置里都没有 JDK 17 的配置
  5. gogole 一下 TraceAgent,能在 /studio-master-dev/tracer/BUILD 里找到相关内容

/studio-master-dev/tracer/BUILD 是个啥玩意儿?

看起来是 AndroidStudio 的一个编译期会生效的工具,名叫 tracer,如果真是这样的话:

  1. 能解释的清为啥 IDE 编不过,但是命令行可以,因为命令行编译的过程中,肯定不会有 tracer 的工具在 work。
  2. 能解释的清为啥清各种 IDE 和重启大法不好使,以及为啥报错的是个 JDK17 的 class,是Android Studio的工具肯定不会在大家编译 app 的时候现编然后生效的,只可能是工具提前已经编译好了,而且是用 JDK17 编译的。

接着我们去 IDE 的设置里尝试搜一下 trace 关键字看看,找到了一个好像有关系的设置 Trace Gradle import with profile

我们试着把他关闭一下,重新编译下看看:

最后再正反印证下,把 tracer 的开关打开,果然,再次编译失败,而且跟之前错误一毛一样。

再后面的 tracer 工具到底是个啥,怎么 work 的,没啥兴趣去了解了。

五、写在最后

  1. 遇到问题不要慌,从肉眼可见的线索去发散的多想想
  2. 大家喜欢周末,大家也喜欢下班,国庆节后的两天 周末+下班 大家应该爽死了。

你可能感兴趣

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

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

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

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

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

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

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

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

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

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

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

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

本文转载自: 掘金

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

🚀遥遥领先!古茗门店菜单智能化的探索

发表于 2023-09-25

作者:徐桑 & 刘哲

你进来后的内心OS:菜单还能搞出什么花样? 行,接着往下看。

背景

为了方便各位了解电子菜单是什么,所以先放一下关于古茗菜单的”演变过程”,如图可见可以看到古茗菜单的演变是从纸质版到现在的电子版,从投放图片到现在投放h5链接,中间的过程经历了什么以及我们为什么要这么做,下面带着各位的疑问以及不解我们开始发车啦~

贴图:

注: 图片版电子菜单和h5版本电子菜单贴出不同的菜单是为了方便区分是两种不同的类型,但实际上图片版电子菜单也是可以通过建站时拖拽物料进行一比一还原~

业务现状

在h5版本的电子菜单上线之前,我们的电子屏一直投放的是一张图片,但是古茗目前有8k家门店,会根据不同的区域和不同的品牌店(古茗茶饮/goottt)下发不相同的菜单,于是一些问题就被暴露出来了,例如:

  • 每次茶饮有更新时,品牌设计部的小伙伴都需要去设计100-200张菜单设计图,并且无法保证设计师出图的菜单完全无误。
  • 图片无法及时同步商品上下架信息,顾客通过菜单进行点单后门店告知商品已售罄。
  • 无法同步展示促销活动,影响履约效率,降低顾客点单体验

通过上述的问题我们决定搭建一套可以帮助品牌设计师快速设计的一套交付体系。

收集需求

既然要开始做了则先去收集一些相关业务方的诉求,诉求如下:

  • 部门A:需要展示完整菜单;需要置顶新品展示,所以每次都需要根据地区和上新更换菜单;
  • 部门B:需要展示精简菜单;配合新店做活动,品牌也需要根据需求来更换;
  • 部门C:需要展示促销活动、展示划线价,用作小程序引流和价格策略的落地;
  • 部门D:需要菜单上可以实时同步展示商品的销售状态和活动标签;

总结:菜单版式会不定期调整,固定版式无法满足需求。

基于以上诉求,则产生了自定义建站、定制化通用物料的等等需求,于是乎就产生了下面的内容:

  1. 后台建站设计方案。
  2. 前台电子菜单渲染方案。

建站设计方案

功能预览

首先设计师是习惯了sketch或者Photoshop这些作图软件的交互方式的,通过拖拽组合的方式更符合设计师操作习惯,总不能让他们通过命令行工具来生成图吧,所以我们做了这样一个工具,并且为了使得业务使用起来更加自由所以采用了自由画布。

实现原理

技术选型

因为需要对节点实现拖拽,放大缩小,自由排布,配置等操作和图形学相关,首先想到Canvas或者SVG,对比如下

最终我们认为,在强交互并且节点不会特别多(100+)的情况下,SVG是更优选,所以技术选型为Antv-X6,原因如下:

  • 事件处理和JavaScript的事件差不多。而Canvas的事件方式有以下缺点
1. 只能在Canvas整体上绑定事件,通过图形和点击处的碰撞判断是否点到了相应区域,万一图形是多边形,还得去恶补数学原理。
2. 事件会冲突,例如drag和click,dbclick和click需要自己区分。
3. 没有事件模型,无冒泡和捕获。
  • 相比较来说,SVG自定义节点方便,并且SVG拥有foreignObject的原因,可以通过HTML的方式来自定义模块,来把一个React组件当做自定义组件。应该不会有人喜欢用类似Canvas那样的命令式代码去实现一个组件吧。

如何自定义节点

在上面我们提到了SVG拥有foreignObject标签,通过这个标签我们可以写任何的html语法,例如:

1
2
3
4
5
6
7
xml复制代码<svg xmlns="http://www.w3.org/2000/svg">
<foreignObject width="120" height="50">
<body xmlns="http://www.w3.org/1999/xhtml">
<p>文字。</p>
</body>
</foreignObject>
</svg>

如果你不想自己写一堆SVG标签来描述图形的话,现在有另外的选择了,每一次拖入到画布的内容,就相当于插入了一个foreignObject标签,所以标签里的内容可以是React组件,当然如果你喜欢也可以是Vue组件,HTML组件,Angular组件,只要是html结构。

我们用的是React函数式组件的,好处就是无状态,数据驱动视图,View = Fn(props) ,他受传入的props的影响来展示不同的视图,比如受到右侧的配置栏的影响。并且所见即所得,现在拖拽生成是什么样子,后续在电子屏上展示也是一模一样。

数据联动带动视图联动

显然,我们目前后台开发用的都是Antd,通过每一种节点模块配置一个表单组件的方法,在最外层监听表单变化并且同步更改节点对应的Schema。在上面我们也提到了View = Fn(props) ,并且Svg内部渲染的是可以是任何类型的组件或Html,所以我们只需要将表单所更改的数据同步到Props中就可以实现数据联动带动视图联动。

onValuesChange只能监听用户操作的表单变动,代码的主动操作表单变化无法监听(因为会引起重复渲染),shouldUpdate是一个比较前后值变化,返回布尔值决定是否更新的api,所以能监听到所有变化(业务开发千万别在这里做一些多余操作)

对于画布实例、操作的历史记录等等都会放在全局数据来进行管理,并且对于全局数据的管理没有引用多余的包,仅仅用了useContext和useReducer去做状态管理。

「如无必要,勿增实体」,对一个简单需求的过度抽象封装,或者引入更大的解决方案,可能只会徒增成本,并不会带来效率的提升

描述页面的信息协议

通过上述的操作我们已经可以实现建站操作,背后所1比1渲染出的节点,实际是赋予了“协议”中所定义的各种属性值,包含了节点ID,位置信息,所传入的props等等,导出例子如下:

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
css复制代码{
"cells": [{
"position": {
"x": 0,
"y": 0
},
"size": {
"width": 1920,
"height": 1080
},
"view": "react-shape-view",
"shape": "background-node",
"data": {
"backColor": "#fff",
"backType": "backColor"
...
},
"id": "wrapper-node",
"zIndex": 0,
"children": ["aeb782ee-8df1-43ea-ade3-a4696eb5232d"]
}, {
"position": {
"x": 610,
"y": 330
},
"size": {
"width": 430,
"height": 134
},
"view": "react-shape-view",
"shape": "custom-react-node",
"data": {
"componentType": "list-product",
"type": "product"
...
},
"id": "aeb782ee-8df1-43ea-ade3-a4696eb5232d",
"parent": "wrapper-node"
},
...]
}

通过上述导出的数据结构我们可以看到其包含了位置、长宽、物料类型等等,这些数据结构我们会在后面在后续菜单渲染时使用。

其他&展望

在web图形化编辑器,已经有很多成熟的产品了,例如低代码编辑器,在线图形编辑,视频剪辑,在如何突破浏览器的性能瓶颈,figma已经是图形编辑器的性能天花板了。

  • 在语言层面上突破瓶颈,js是解释型语言,在执行的时候需要转化一次,对于CPU密集型的操作,比如图片视频剪辑和3D游戏等有天然的瓶颈,如果用了wasm,无需解释执行,直接把二进制码变成机器码。并且wasm和js可以相互通信共存,把js的灵活性和c++的高性能结合起来。做法是用C++写代码,然后转成js代码在浏览器上执行,在2017年之前,他们用asm.js来转代码,2017年之后浏览器实装WebAssembly了,也就是wasm,直接顺水推舟完美切换,并且性能暴涨3倍,12s加载的画板文件只要4s就能加载出来。
  • 在硬件层面上突破瓶颈,用webGl来绘制图形,可以调用GPU硬件加速,让画面更加流畅

电子菜单渲染

前置问题

  1. 如何把建站的模版展示在电子菜单屏上?
  2. 如何针对不同的门店做内容隔离?
  3. 如何保证菜单内容的稳定展示?

如何把建站的模版展示在电子屏

我们电子菜单是在一个第三方的Android应用中渲染的,可以依托这个宿主环境来获取一些信息比如说清除整个电子屏缓存、获取门店编码、存储内存、版本等信息的操作,在Android通过webView来去渲染对应的h5网页,并且会在初始化完毕后通过桥接来调用我们在js中定义好的入口函数,并传参一些电子屏相关的信息,我们通过这个JS函数,来开启后续一系列逻辑的执行。

知道了渲染的宿主环境后,然后我们了解一下如何把对应的模块渲染在电子屏上,首先先看一下电子菜单导出的部分数据结构(下面以列表型商品组件为例),通过下面的数据结构可以看到position属性中存在节点的x和y轴坐标,也就可以通过坐标确定出此节点在这幅画上的位置了。

拥有位置坐标后,需要确认当前在此位置上如何绘制出建站时的样子。实际上在建站和渲染时,所用到的都是同一套自定义组件,所以只需要将建站时导出的数据传入到此自定义组件就可以1比1还原出建站时所使用的物料组件,也就是上述所说的View = Fn(props) ,那么多个节点在多个位置渲染就会得到一幅画,如下图所示。

如何在门店维度进行菜单内容的隔离?

众所周知,古茗有多个品牌,例如古茗茶饮、零氧化(虽然没见过)、goottt。品牌不同,所展示的内容也应有所差异。

又例如,在古茗茶饮这个品牌下,门店经常会换活动,今天的秋天第一杯奶茶,明天的七夕节活动,大后天的xxx新品上新,这使得我们每次在建站时添加的物料组件的都不同,我们每次需要一个给门店下发一个固定形态,所以版本的概念产生了,版本再往前推,此版本主要是来描述作图或设计方案的固定格式,所以模版的概念也产生了。

将模版下发给门店前,需要将模版执行”定稿”操作,”定稿”这个动作主要是为了保证打包后产生的版本产物所有的形式、状态、内容是固定的,通过”定稿”会将当前物料组件和render层的逻辑打包为以hash值命名的产物来作为当前资源快照,对门店下发菜单时,只需要将门店与hash版本绑定,并让门店访问hash版本的资源,这样就可以保证我们即使变更了物料组件和render层的逻辑,之前已经下发的模版对应的元数据和渲染层仍然可以适配,因为所有东西都被定格在了那瞬间,保证了端上展示的菜单形态更稳定。

定稿(资源打包)完毕后,通过回调就可以将物料组件版本hash和JSON与模版绑定,如下图所示,一个模版版本对应的一个固定的JSON和一个固定的物料组件版本,这样就可以实现只需要对门店下发不同的模版,就可以实现菜单内容隔离了。

如何保证电子菜单端上稳定性

每个门店的网络情况不同,对于网络比较差的门店,需要做的是让这些门店在加载一次过后,后续的加载无论失败都可以正常展示,所以将其打造成为一个PWA应用,增加离线使用能力。

Service Worker的主要思想是在页面和网络之间增加一个拦截器,用来缓存和拦截请求,由于POST请求不可以被CacheStorage缓存的原因,所以我们针对接口请求和资源请求分别缓存在IndexDB与CacheStorage中,使得门店弱网与断网的情况下仍然可以正常展示菜单,堂食点单不被影响。

总结

随着古茗门店的增加,公司整体信息化的诉求越来越强。我们想要提高门店整体的经营效率,避免人力出现的各种问题和带来的损耗,也想提高用户的点餐体验,所以才产生了电子屏菜单这个项目。

技术是为了服务于业务的,业务也是证明技术的最好场景,不能脱离业务去搞一些不切实际的技术建设,也反对强行把自己的做的技术项目匹配到业务里,强制性对别人进行落地,美其名曰”赋能”。优秀的工程师是能从业务中发现问题,设计解决方案,最终用技术的手段解决问题的。

单纯的一项技术没有好与坏,只有适不适合。例如低代码的搭建后台在大部分场景,尤其是交互复杂的场景,并不适合前端开发者去用,反而是增加大家的开发成本。但是如果是给没有前端开发能力的人用,例如本项目中给电子菜单屏幕的设计师用,就能解决问题,带来技术本身的价值。

最后

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

本文转载自: 掘金

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

Android 启动优化案例-WebView非预期初始化排查

发表于 2023-09-21

去年年底做启动优化时,有个比较好玩的 case 给大家分享下,希望大家能从我的分享里 get 到我在做一些问题排查修复时是怎么看上去又low又土又高效的。

  1. 现象

在我们使用 Perfetto 进行app 启动过程性能观测时,在 UI 线程发现了一段 几十毫秒接近百毫秒 的非预期Webview初始化的耗时(机器环境:小米10 pro),在线上用户机器上这段代码执行时间可能会更长。

为什么说非预期呢:

  • 首页没有WebView的使用、预加载
  • X5内核的初始化也在启动流程之后
  1. 顺藤摸瓜

一般当我们发现了这种问题后,我们应该如何应对呢?

  • 搞懂流程,如果在排查启动性能时,发现了不符合预期的主(子)线程耗时,第一步就是摸清楚这段耗时代码是怎么被调用的。
  • 见招拆招,当我们知道代码如何被调用的之后,就可以想办法进行修复工作,如果是因为项目代码在错误的时机被调用,那就延后或者去除相关调用

WebViewChromiumAwInit.java

那我们开始第一步,搞懂流程,我们能看到图中耗时代码块被调用的系统方法是:

WebViewChromiumAwInit.startChromiumLocked,由于 Perfetto 并看不到 App 相关的堆栈信息,所以我们无法直接知道到底是哪行代码引起的。

那我们就去跟跟 webview 源码,看看具体情况,点进 WebViewChromiumAwInit.java

页面看相关代码,发现 startChromiumLocked 是被 ensureChromiumStartedLocked 方法调用的:

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
scss复制代码// This method is not private only because the downstream subclass needs to access it,
// it shouldn't be accessed from anywhere else.
/* package */
void ensureChromiumStartedLocked(boolean fromThreadSafeFunction) {
assert Thread.holdsLock(mLock);
if (mInitState == INIT_FINISHED) { // Early-out for the common case.
return;
}
if (mInitState == INIT_NOT_STARTED) {
// If we're the first thread to enter ensureChromiumStartedLocked, we need to determine
// which thread will be the UI thread; declare init has started so that no other thread
// will try to do this.
mInitState = INIT_STARTED;
setChromiumUiThreadLocked(fromThreadSafeFunction);
}
if (ThreadUtils.runningOnUiThread()) {
// If we are currently running on the UI thread then we must do init now. If there was
// already a task posted to the UI thread from another thread to do it, it will just
// no-op when it runs.
startChromiumLocked();
return;
}
mIsPostedFromBackgroundThread = true;
// If we're not running on the UI thread (because init was triggered by a thread-safe
// function), post init to the UI thread, since init is *not* thread-safe.
AwThreadUtils.postToUiThreadLooper(new Runnable() {
@Override
public void run() {
synchronized (mLock) {
startChromiumLocked();
}
}
});
// Wait for the UI thread to finish init.
while (mInitState != INIT_FINISHED) {
try {
mLock.wait();
} catch (InterruptedException e) {
// Keep trying; we can't abort init as WebView APIs do not declare that they throw
// InterruptedException.
}
}
}

那么 ensureChromiumStartedLocked 方法又是被谁调用的呢?我们在WebViewChromiumAwInit.java 文件里大致找一下就能找到以下嫌疑人,第一反应是“这也太多了吧,这咋排查啊”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
scss复制代码-getAwTracingController
-getAwProxyController
-startYourEngines
-getStatics
-getDefaultGeolocationPermissions
-getDefaultServiceWorkerController
-getWebIconDatabase
-getDefaultWebStorage
-getDefaultWebViewDatabase

public class WebViewChromiumAwInit {
public AwTracingController getAwTracingController() {
synchronized (mLock) {
if (mAwTracingController == null) {
ensureChromiumStartedLocked(true);
}
}
return mAwTracingController;
}
public AwProxyController getAwProxyController() {
synchronized (mLock) {
if (mAwProxyController == null) {
ensureChromiumStartedLocked(true);
}
}
return mAwProxyController;
}
void startYourEngines(boolean fromThreadSafeFunction) {
synchronized (mLock) {
ensureChromiumStartedLocked(fromThreadSafeFunction);
}
}

public SharedStatics getStatics() {
synchronized (mLock) {
if (mSharedStatics == null) {
ensureChromiumStartedLocked(true);
}
}
return mSharedStatics;
}

public GeolocationPermissions getDefaultGeolocationPermissions() {
synchronized (mLock) {
if (mDefaultGeolocationPermissions == null) {
ensureChromiumStartedLocked(true);
}
}
return mDefaultGeolocationPermissions;
}

public AwServiceWorkerController getDefaultServiceWorkerController() {
synchronized (mLock) {
if (mDefaultServiceWorkerController == null) {
ensureChromiumStartedLocked(true);
}
}
return mDefaultServiceWorkerController;
}
public android.webkit.WebIconDatabase getWebIconDatabase() {
synchronized (mLock) {
ensureChromiumStartedLocked(true);
if (mWebIconDatabase == null) {
mWebIconDatabase = new WebIconDatabaseAdapter();
}
}
return mWebIconDatabase;
}

public WebStorage getDefaultWebStorage() {
synchronized (mLock) {
if (mDefaultWebStorage == null) {
ensureChromiumStartedLocked(true);
}
}
return mDefaultWebStorage;
}

public WebViewDatabase getDefaultWebViewDatabase(final Context context) {
synchronized (mLock) {
ensureChromiumStartedLocked(true);
if (mDefaultWebViewDatabase == null) {
mDefaultWebViewDatabase = new WebViewDatabaseAdapter(mFactory,
HttpAuthDatabase.newInstance(context, HTTP_AUTH_DATABASE_FILE),
mDefaultBrowserContext);
}
}
return mDefaultWebViewDatabase;
}
}

WebViewChromiumFactoryProvider.java

经过上面对的简单分析,我们大概知道了WebViewChromiumAwInit.startChromiumLocked是被 ensureChromiumStartedLocked 方法调用的,而ensureChromiumStartedLocked 方法会被以下几个方法调用,那我们接下来的工作就需要找到下面这几个方法到底被谁调用了。

1
2
3
4
5
6
7
8
9
diff复制代码-getAwTracingController
-getAwProxyController
-startYourEngines
-getStatics
-getDefaultGeolocationPermissions
-getDefaultServiceWorkerController
-getWebIconDatabase
-getDefaultWebStorage
-getDefaultWebViewDatabase

到这里,分享我的一个土方法,我们要找到底哪个地方会调用这些方法,那就找一个不认识的、看上去不会被别人提起的方法,进行 google,我们一眼就选中了getDefaultServiceWorkerController 方法,没办法,谁叫我不认识你呢。虽然方法笨,但是架不住效率啊。于是乎,我们把它揪出来了 - WebViewChromiumFactoryProvider.java

我们大概了解一下 WebViewChromiumFactoryProvider 大概是个什么角色,WebViewChromiumFactoryProvider 实现了 WebViewFactoryProvider 接口,简单理解就是 WebView 的工厂,App 如果要创建 WebView,就会通过 WebViewFactoryProvider 接口的实现类进行 createWebView,所以其实就是个工厂模式。通过抽象规范 api,保证兼容性和可移植性可扩展性。

我们在这个文件中也如愿以偿的看到了上面列出来的几个方法的调用。WebViewChromiumFactoryProvider 在接口方法的实现中,调用了WebViewChromiumAwInit 里的一系列方法,如下:

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
typescript复制代码//WebViewChromiumFactoryProvider.java
@Override
public WebViewProvider createWebView(WebView webView, WebView.PrivateAccess privateAccess) {
return new WebViewChromium(this, webView, privateAccess, mShouldDisableThreadChecking);
}

//我们截取一段
@Override
public GeolocationPermissions getGeolocationPermissions() {
return mAwInit.getDefaultGeolocationPermissions();
}
@Override
public CookieManager getCookieManager() {
return mAwInit.getDefaultCookieManager();
}
@Override
public ServiceWorkerController getServiceWorkerController() {
synchronized (mAwInit.getLock()) {
if (mServiceWorkerController == null) {
mServiceWorkerController = new ServiceWorkerControllerAdapter(
mAwInit.getDefaultServiceWorkerController());
}
}
return mServiceWorkerController;
}
@Override
public TokenBindingService getTokenBindingService() {
return null;
}
@Override
public android.webkit.WebIconDatabase getWebIconDatabase() {
return mAwInit.getWebIconDatabase();
}
@Override
public WebStorage getWebStorage() {
return mAwInit.getDefaultWebStorage();
}
@Override
public WebViewDatabase getWebViewDatabase(final Context context) {
return mAwInit.getDefaultWebViewDatabase(context);
}
WebViewDelegate getWebViewDelegate() {
return mWebViewDelegate;
}
WebViewContentsClientAdapter createWebViewContentsClientAdapter(WebView webView,
Context context) {
try (ScopedSysTraceEvent e = ScopedSysTraceEvent.scoped(
"WebViewChromiumFactoryProvider.insideCreateWebViewContentsClientAdapter")) {
return new WebViewContentsClientAdapter(webView, context, mWebViewDelegate);
}
}
void startYourEngines(boolean onMainThread) {
try (ScopedSysTraceEvent e1 = ScopedSysTraceEvent.scoped(
"WebViewChromiumFactoryProvider.startYourEngines")) {
mAwInit.startYourEngines(onMainThread);
}
}
boolean hasStarted() {
return mAwInit.hasStarted();
}
  1. 确定问题

我们上面通过阅读 WebViewChromiumFactoryProvider.java 、WebViewChromiumAwInit.java 这两个文件具体代码实现,有了一个比较清晰的思路。

App 在初始化过程中,调用到了 WebViewFactoryProvider 接口实现类的某个方法,这个方法调用了 WebViewChromiumAwInit 的下面方法中的其中一个或者多个。那其实问题就清晰了,我们只需要找到,我们 app 启动阶段到底哪行代码,会调用到 WebViewFactoryProvider 接口某个接口方法就行。

1
2
3
4
5
6
7
8
9
diff复制代码-getAwTracingController
-getAwProxyController
-startYourEngines
-getStatics
-getDefaultGeolocationPermissions
-getDefaultServiceWorkerController
-getWebIconDatabase
-getDefaultWebStorage
-getDefaultWebViewDatabase

由于 WebView 的代码并不会打包进 app 里,App 用的 WebView 内核都是用的 Android 系统负责内置、升级的 WebView 内核代码,所以通过 transform 的方式进行 hook 调用是不行的,这里我们采用动态代理的方式,对 WebViewFactoryProvider 接口方法进行 hook,我们通过动态代理生成一个 proxy 对象,通过反射的方式,替换掉 android.webkit.WebViewFactory 的 sProviderInstance 对象。

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
java复制代码    ##WebViewFactory
@SystemApi
public final class WebViewFactory{
//...
@UnsupportedAppUsage
private static WebViewFactoryProvider sProviderInstance;
//...
}


##动态代理
try {
Class clas = Class.forName("android.webkit.WebViewFactory");
Method method = clas.getDeclaredMethod("getProvider");
method.setAccessible(true);
Object obj = method.invoke(null);

Object hookService = Proxy.newProxyInstance(obj.getClass().getClassLoader(), obj.getClass().getSuperclass().getInterfaces(),
new InvocationHandler() {
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
Log.d("zttt", "hookService method: " + method.getName());
new RuntimeException(method.getName()).printStackTrace();
return method.invoke(obj, args);
}
});

Field field = clas.getDeclaredField("sProviderInstance");
field.setAccessible(true);
field.set(null, hookService);
} catch (Exception e) {
e.printStackTrace();
}

替换掉 sProviderInstance 之后,我们就可以在我们的代理逻辑中,加上断点来进行调试,最终找到了造成 WebView非预期初始化的始作俑者:WebSettings.getDefaultUserAgent。

  1. 解决问题

事情到这里就好解决了,只需要对 WebSettings.getDefaultUserAgent 进行编译期的Hook,重定向到带缓存defaultUserAgent 的相关方法就行了,本地有缓存则直接读取,本地没有则立即读取,得益于之前我在项目中实现的使用方便的 配置化 Hook 框架,这种小打小闹的 Hook 工作不到一分钟就能完成。

当然,这里还需要考虑一个问题,那就是当用户机器的 defaultUserAgent 发生变化之后,怎么才能及时的更新本地缓存以及网络请求中用上新的defaultUserAgent。我们的做法是:

  • 当本地没有缓存时,立刻调用 WebSettings.getDefaultUserAgent 拿值并更新缓存;
  • 每次App启动阶段结束之后,会在子线程中去调用WebSettings.getDefaultUserAgent 拿值并更新缓存。

这样处理之后,将 defaultUserAgent 发生变化之后的影响最小化,系统 WebView 升级本身就是极度不频繁的事情,在这种 case 下我们舍弃了下一次 App 打开前几个网络请求的 defaultUserAgent 正确性也是合理的,这也是我们考量 「风险收益比」的一个经典case。

  1. 确认问题被解决

通过上述 hook,我们重新打包 run 一遍 app,在启动阶段已经观察不到相关耗时了。

搞定,收工,不仅解决问题效率高,写博客也效率高,一会就整完了,简直就像是季度绩效考核前的产品,出方案和上线的效率就一个字,嗖。

你可能感兴趣

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)

本文转载自: 掘金

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

古茗前端第一届论坛 - Vue篇 前言 最后

发表于 2023-09-21

前言

在这个知识大爆炸💥的时代,各式各样的信息充斥着我们的学习与生活,这些信息都在尽可能的满足我们的需求。小茗想了很久。发现,大家都是带着问题去寻找答案,信息过度却成为了我们和答案之间的阻碍,能不能让答案来自动寻找问题呢?

各位不仅可以在评论区阐明自己的需求/问题,也可以就自己的观点对某个问题进行回复讨论。

当然我们在每期中只会围绕一个主题进行,如果大家有更好的主题也可以在评论区中留言.

这篇文章是由大家所构成,我们:

  • 既是提问者也是回答者;
  • 既是需求方也是供给方;
  • 既解答疑问也巩固知识;

杭州时值亚运,希望大家出行愉快、生活开心。

最后

📚 小茗文章推荐:

  • 一个破RPC,又又又让我复习了一遍计算机网络
  • WebSocket 协议分析
  • Formily 在古茗工单系统中的实践落地

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

本文转载自: 掘金

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

速度优化:GC抑制 GC 执行的流程 抑制 GC 执行的方案

发表于 2023-09-19

我们知道,充分且合理地使用 CPU 资源是提升速度的本质因素之一。提升 CPU 利用率,除了前面提到的优化方案外,还有很多其他的方案,比如我们还可以通过分析 CPU 的使用情况寻找优化点。

Android 官方提供了完善的分析 CPU 使用的工具,如抓 Trace 或者 AndroidStudio 中自带的 Profile 工具,如果不熟悉使用的可以参考官方文档,讲解非常详细,这里就不过多介绍了。

在通过 Profile 分析 CPU 使用时, 我们 经常会发现 HeapTaskDaemon 线程 占用了较高 CPU 时间,这个线程实际是虚拟机用来执行 GC 操作的。 下图是 Demo 中的 CPU 使用分析,可以看到 HeapTaskDaemon 线程有大块处于 Running 状态的时间。

从 Android 5 开始,Dalvik 虚拟机被替换成了 ART 虚拟机,ART 虚拟机在进行 GC 的时候,虽然不再执行 Stop The World 逻辑来停止一切其他任务,但并不意味着 GC 操作便不会再导致卡顿。ART虚拟机上, GC 操作依然会导致卡顿,主要原因是该操作会抢占很多 CPU 资源,从而导致核心线程无法获得足够的 CPU 时间片而卡顿或者变慢。HeapTaskDaemon 线程除了抢占 CPU 时间片,还会因为有较多内存操作而持有内存相关的锁,其他任务无法得到锁自然就变慢了。

所以当我们执行核心场景,比如启动,打开页面或者滑动 List 时,如果能抑制 GC 的执行,就能让核心任务获得更多的 CPU 时间,表现出更好的性能。

这一章,我们就来学习如何对 GC 进行抑制。因为涉及比较多复杂的知识点,内容上会有一定的难度,希望通过今天的学习我们能一起弄懂它们,踏上进阶之路。

GC 执行的流程

想要抑制 GC 执行,我们首先要熟悉 GC 的执行流程,然后从流程中寻找突破点,在前面学习通过“黑科技”手段优化虚拟内存时,我们也是这样的思路。既然 HeapTaskDaemon 线程抢占了较多的 CPU,我们就直接从 HeapTaskDaemon 这个线程来分析,看看这个线程到底是做什么的。

HeapTaskDaemon 线程的起源

通过全局搜索 HeapTaskDaemon 关键字,发现它是在 Java 层创建的线程,并位于 Daemons.java 对象中。

分析源码可以发现,HeapTaskDaemon 继承自 Daemon 对象。Daemon 对象实际是一个 Runnale,并且内部会创建一个线程,用于执行当前这个 Daemon Runnable,这个内部线程的线程名就叫 HeapTaskDaemon。到这里,我们就知道了这个线程的起源。

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
Java复制代码private static class HeapTaskDaemon extends Daemon {
private static final HeapTaskDaemon INSTANCE = new HeapTaskDaemon();

HeapTaskDaemon() {
super("HeapTaskDaemon");
}

public void runInternal() {
……
VMRuntime.getRuntime().runHeapTasks();
}
}

private static abstract class Daemon implements Runnable {
@UnsupportedAppUsage
private Thread thread;
private String name;
private boolean postZygoteFork;

protected Daemon(String name) {
this.name = name;
}

@UnsupportedAppUsage
public synchronized void start() {
startInternal();
}

public synchronized void startPostZygoteFork() {
postZygoteFork = true;
startInternal();
}

// zygote 进程启动就会启动当前线程
public void startInternal() {
if (thread != null) {
throw new IllegalStateException("already running");
}
thread = new Thread(ThreadGroup.systemThreadGroup, this, name);
thread.setDaemon(true);
thread.setSystemDaemon(true);
thread.start();
}

public final void run() {
……
try {
runInternal();
} catch (Throwable ex) {
……
throw ex;
}
}

public abstract void runInternal();

……

}

知道了 HeapTaskDaemon 线程的起源,我们接着看看它是干什么的。

HeapTaskDaemon 线程的作用

HeapTaskDaemon 是一个守护线程,随着 Zygote 进程启动便会启动,该线程的 run 方法也比较简单,就是执行 runInternal 这个抽象函数,该抽象函数的实现方法中会执行 VMRuntime.getRuntime().runHeapTasks() 方法,runHeapTasks() 函数会执行 RunAllTasks 这个 Native 函数,它位于 task_processor.cc 这个类中。

1
2
3
c++复制代码static void VMRuntime_runHeapTasks(JNIEnv* env, jobject) {
Runtime::Current()->GetHeap()->GetTaskProcessor()->RunAllTasks(ThreadForEnv(env));
}

通过源码一路跟踪下来,可以看到 HeapTaskDaemon 线程的 run 方法中真正做的事情,实际只是在无限循环的调用 GetTask 函数获取 HeapTask 并执行。GetTask 中会不断从 tasks 集合中取出 HeapTask 来执行,并且对于需要延时的 HeapTask ,会阻塞到目标时间。

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
c++复制代码void TaskProcessor::RunAllTasks(Thread* self) {
while (true) {
HeapTask* task = GetTask(self);
if (task != nullptr) {
task->Run(self);
task->Finalize();
} else if (!IsRunning()) {
break;
}
}
}

std::multiset<HeapTask*, CompareByTargetRunTime> tasks_ ;

HeapTask* TaskProcessor::GetTask(Thread* self) {
……
while (true) {
if (tasks_.empty()) {
//如果 tasks 集合为空,则休眠线程
cond_.Wait(self);
} else {
// 如果 task是集合不会空,则取出第一个 HeapTask
const uint64_t current_time = NanoTime();
HeapTask* task = *tasks_.begin();

uint64_t target_time = task->GetTargetRunTime();
if (!is_running_ || target_time <= current_time) {
tasks_.erase(tasks_.begin());
return task;
}
// 对于延时执行的 HeapTask,这里会进行等待,直到目标时间
const uint64_t delta_time = target_time - current_time;
const uint64_t ms_delta = NsToMs(delta_time);
const uint64_t ns_delta = delta_time - MsToNs(ms_delta);
cond_.TimedWait(self, static_cast<int64_t>(ms_delta), static_cast<int32_t>(ns_delta));
}
}
UNREACHABLE();
}

到这里,抑制 GC 的思路其实已经出来,我们有 2 种做法:

  1. 添加一个自己的 HeapTask 到 tasks 集合中,并且在我们自己的 HeapTask 中进行休眠,此时便会阻塞 HeapTaskDaemon 线程 ,达到抑制该线程执行的目的 ;
  1. 获取到系统的 HeapTask,并让这个 HeapTask 休眠,同样能达到抑制 HeapTaskDaemon 线程 执行的目的。

HeapTask 分析

这两种方案都需要 HeapTask 进行操作,为了让方案顺利实施,我们需要继续分析 HeapTask 是干什么的。

通过源码分析可以发现,HeapTask 实际上依次继承自 SelfDeletingTask 、Task 和 Closure 这三个类,Task 类定义了 Finalize 这个虚函数,Closure 类定义了 Run 这个虚函数。什么是虚函数呢?我们可以先把它理解成 Java 的抽象函数,virtual 关键字就类似于 Java 的 abstract 关键字 既然是抽象函数,就需要子类来实现,SelfDeletingTask 实现了 Finalize 这个虚函数,用于对象析构使用。Run 函数的实现,则会交给 HeapTask 的子类。

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
c++复制代码class HeapTask : public SelfDeletingTask {
public:
explicit HeapTask(uint64_t target_run_time) : target_run_time_(target_run_time) {
}
uint64_t GetTargetRunTime() const {
return target_run_time_;
}

private:
void SetTargetRunTime(uint64_t new_target_run_time) { //延时时间设置接口
target_run_time_ = new_target_run_time;
}

uint64_t target_run_time_;
friend class TaskProcessor;
};

class SelfDeletingTask : public Task {
public:
virtual ~SelfDeletingTask() { }
virtual void Finalize() {
delete this;
}
};

class Task : public Closure {
public:
// 定义 Finalize 虚函数
virtual void Finalize() { }
};

class Closure {
public:
virtual ~Closure() { }
// 定义 Run 虚函数
virtual void Run(Thread* self) = 0;
};

还是通过全局搜索,发现 Android 系统中继承自 HeapTask 的子类有下面这些。

下面大致介绍一下每一个 HeapTask 的作用。

  • ConcurrentGCTask:当 Java 内存到达阈值时,便会执行这个 Task,用于执行并发 GC。
  • CollectorTransitionTask:前后台切换时,便会执行这个 Task,用于切换 GC 的类型,比如到后台时,便会切换成拷贝回收这种 GC 机制。
  • HeapTrimTask:GC 完成之后,如果需要将堆中空闲的内存归还给内核,则会执行这个 Task 来处理。
  • TriggerPostForkCCGcTask:Android8 开始,系统为了在启动时避免 GC 操作,会执行这个 Task,将 HeapTaskDaemon 线程阻塞 2 秒。
  • ReduceTargetFootprintTask:和 TriggerPostForkCCGcTask 配合使用。
  • ClearedReferenceTask:在对象回收时,会执行该 Task,Task 中调用 Java 层的ReferenceQueue.add 方法, 将被回收对象引用添加到 ReferenceQueue 队列中。LeakCanary 便是用 ReferenceQueue 队列来判断内存泄漏。
  • NotifyStartupCompletedTask:启动完成后执行的一个 Task,用于校验使用。

因为 Task 比较多,我们就不每一个都去分析它的实现了,这里仅以 ConcurrentGCTask 这一个 Task 为例子讲解它的原理和机制。

ConcurrentGCTask 分析

在《Java 堆内存优化》中讲到过,当我们创建对象时,最终虚拟机会调用 AllocObjectWithAllocator 方法,到 Java 堆中为这个对象申请内存空间。申请空间的操作就不重复讲了,我们主要看触发 ConcurrentGCTask 的流程。

通过源码可以看到,如果判断是并发 GC,或者堆内存达到 concurrent_start_bytes_ (这个值是一个动态值,系统会根据当前条件,动态调整这个值的大小)阈值时,就会调用 RequestConcurrentGCAndSaveObject 方法。

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
c++复制代码inline mirror::Object* Heap::AllocObjectWithAllocator(Thread* self,
ObjPtr<mirror::Class> klass,
size_t byte_count,
AllocatorType allocator,
const PreFenceVisitor& pre_fence_visitor) {
……
bool need_gc = false;
uint32_t starting_gc_num; // o.w. GC number at which we observed need for GC.
{
……
if (bytes_tl_bulk_allocated > 0) {
……
// 如果是并发 GC ,或者达到了阈值,则need_gc为true
if (IsGcConcurrent() && UNLIKELY(ShouldConcurrentGCForJava(new_num_bytes_allocated))) {
need_gc = true;
}
……
}
}
……
if (need_gc) {
// Do this only once thread suspension is allowed again, and we're done with kInstrumented.
RequestConcurrentGCAndSaveObject(self, /*force_full=*/ false, starting_gc_num, &obj);
}
……
return obj.Ptr();
}

inline bool Heap::ShouldConcurrentGCForJava(size_t new_num_bytes_allocated) {
return new_num_bytes_allocated >= concurrent_start_bytes_;
}

RequestConcurrentGCAndSaveObject 方法中实际上就是创建 ConcurrentGCTask,并调用 task_processor_ 对象的 AddTask 方法,将该 Task 添加到 tasks 集合里去。ConcurrentGCTask 里面具体做的事情,就是执行并发 GC 了,这属于虚拟机模块的知识,就不展开讲了。

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
c++复制代码void Heap::RequestConcurrentGCAndSaveObject(Thread* self,
bool force_full,
uint32_t observed_gc_num,
ObjPtr<mirror::Object>* obj) {
RequestConcurrentGC(self, kGcCauseBackground, force_full, observed_gc_num);
}

bool Heap::RequestConcurrentGC(Thread* self,
GcCause cause,
bool force_full,
uint32_t observed_gc_num) {
uint32_t max_gc_requested = max_gc_requested_.load(std::memory_order_relaxed);
if (!GCNumberLt(observed_gc_num, max_gc_requested)) {
if (CanAddHeapTask(self)) {
if (max_gc_requested_.CompareAndSetStrongRelaxed(max_gc_requested, observed_gc_num + 1)) {
task_processor_->AddTask(self, new ConcurrentGCTask(NanoTime(), // Start straight away.
cause,
force_full,
observed_gc_num + 1));
}
……
return true;
}
return false;
}
return true;
}

如果你对 GC 机制比较有兴趣,可以将其他的 HeapTask 都分析一下,这样能加深你对 ART GC 机制的了解。了解了 HeapTaskDaemon 线程以及相关的流程,下面我们进入实战,看看如何抑制 GC 的执行。

抑制 GC 执行的方案

在上面的分析过程中,已经提到了 2 种方案:

  1. 添加一个自己的 HeapTask 到 tasks 集合中,并且在我们自己的 HeapTask 中进行休眠,此时便会阻塞 HeapTaskDaemon 线程,达到抑制该线程的目的;
  1. 获取到系统的 HeapTask,并让这个 HeapTask 休眠,同样能达到抑制 HeapTaskDaemon 线程执行的目的。

从 Android8 开始,应用启动时使用第 1 种方案,将 GC 延后 2 秒才执行。对于系统来说,这种方案非常简单,因为系统能直接拿到 TaskProcessor 对象,往里面添加自定义 task 就行。 但是对于应用来说,这种方案相对复杂,复杂的原因在后面会讲到,所以本章中介绍的是第二种方案,下面以系统的 ConcurrentGCTask 为例,我们看看如何让这个 Task 休眠 。

当我们想要调用某个方法时,需要在代码中持有方法的对象,然后才能进行方法的调用,当代码被编译时,编译器会将这个对象编译成内存中的一个地址。但是当我们在代码中拿不到目标对象时,就没法使用这个对象了,即使这个对象会被加载到进程的虚拟内存中。

如果我们想要在自己的 native 方法中,执行 libart 这个 so 库中 ConcurrentGCTask 对象的 Run 方法 ,常规手段办不到,因为我们拿不到 ConcurrentGCTask 对象,更别说执行对象里面的方法了 。

此时,只能使用非常规手段了。libart.so 这个库实际上是已经加载进我们应用的虚拟内存中了,这个方法也被存放在应用用户空间的某一块内存地址上。这时,我们只需要找到这个 Run 方法的地址,就可以操作它了。那怎么才能找到 Run 方法在内存中的地址呢?我们需要用到这个方法的符号,并通过符号在 libart 这个 so 库的内存范围中去寻找其对应的符号表,这样我们就能获取符号对应方法的内存地址了。那么什么是符号呢?

符号

编译器在将 C++ 源代码编译成目标文件时,会将函数和变量的名字进行修饰,生成符号名,所以符号是相应的函数和变量修饰后的名称。编译器不同,生成的符号也不一样,比如通过 GCC 编译器来编译下面这几个函数,对应的符号则如下:

函数 符号
int func(int) _Z4funci
float func(float) _Z4funcf
int Test::func(int) _ZN4Test4funcEi

以 int Test::func(int) 这个函数为例,GCC 在生成方法的符号时,都以 _Z 开头,对于嵌套的名字,后面紧跟 N,然后跟着各个名称空间和类的名称长度及名称,所以是 4Test4func,再以 E 结尾(非嵌套的方法名不需要 E ),最后跟着入参类型,那么这个函数的符号连起来就是 _ZN4Test4funcEi。我们不需要去熟悉这些规则,大致了解就行。

在《Native内存优化》这篇文章中,讲到了通过 dladdr 函数获取到的 dli_sname 和 dli_saddr 字段,就是方法的符号和这个符号对应的方法地址。下图中的 (Z16CaptureBacktracePPVM)(0x7032a1145c) 、(Z16printNativeStackV)(0X7032a11640) 等数据就是方法对应的符号,以及符号对应方法的地址。如果对内容记不清了,可以再回头看一下这章。

为了包体积和安全考虑,我们一般会将 so 去符号,这样我们在 dladdr 函数中就没法根据符号定位到方法名以及地址了,从上图也可以看到,去符号后的数据为 (null)(0x0)。

幸运的是,在 libart.so 中,很多对象和方法都是有符号的,之所以保留这些符号,可能是需要用于调试或者异常定位使用。通过符号,我们就能找到对应的函数地址了。话说回来,我们为什么不介绍第一种方案呢?也是因为 TaskProcessor 这个对象没有符号,我们无法拿到这个对象,但在第二种方案中,各种 HeapTask 的子类符号是有保留的,所以我们就能拿到这些 Task 的对象和函数的内存地址。有了地址,就有了操作的可行性。下面就来看一下要怎么做吧!

符号查找

为了便于分析,我们先从 root 手机中拉取一份 libart.so 到本地,在设备的 shell 窗口中执行下面指令即可。libart 这个 so 库一般存放在 /system/lib/ 目录中。

1
Shell复制代码cp /system/lib/libart.so /sdcard/libart.so

符号信息都是统一放在符号表(.symtab)中的,和 .bss,.text 这些段一样,符号表 .symtab 也属于 ELF 文件中的一个段。我们通过 readelf 工具的 -S 命令来读取 libart 库的段信息。可以看到 so 中是包含了 .symtab 这个段的。

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
Shell复制代码aarch64-linux-android-readelf -S libart.so 
There are 31 section headers, starting at offset 0x5978e8:

Section Headers:
[Nr] Name Type Addr Off Size ES Flg Lk Inf Al
[ 0] NULL 00000000 000000 000000 00 0 0 0
[ 1] .note.android.ide NOTE 0000c154 000154 000018 00 A 0 0 4
[ 2] .note.gnu.build-i NOTE 0000c16c 00016c 000020 00 A 0 0 4
[ 3] .dynsym DYNSYM 0000c18c 00018c 01a800 10 A 4 1 4
[ 4] .dynstr STRTAB 0002698c 01a98c 070a56 00 A 0 0 1
[ 5] .gnu.hash GNU_HASH 000973e4 08b3e4 00c570 04 A 3 0 4
[ 6] .gnu.version VERSYM 000a3954 097954 003500 02 A 3 0 2
[ 7] .gnu.version_d VERDEF 000a6e54 09ae54 00001c 00 A 4 1 4
[ 8] .gnu.version_r VERNEED 000a6e70 09ae70 000090 00 A 4 3 4
[ 9] .rel.dyn LOOS+0x1 000a6f00 09af00 002a80 01 A 0 0 4
[10] .rel.plt REL 000a9980 09d980 000bf8 08 AI 3 11 4
[11] .plt PROGBITS 000aa578 09e578 001208 00 AX 0 0 4
[12] .text PROGBITS 000ab800 09f800 374a20 00 AX 0 0 512
[13] .ARM.exidx ARM_EXIDX 00420220 414220 00c2d8 08 AL 12 0 4
[14] .rodata PROGBITS 0042c500 420500 027794 00 A 0 0 16
[15] .ARM.extab PROGBITS 00453c94 447c94 000858 00 A 0 0 4
[16] .eh_frame PROGBITS 004544ec 4484ec 0041c4 00 A 0 0 4
[17] .eh_frame_hdr PROGBITS 004586b0 44c6b0 0006fc 00 A 0 0 4
[18] .fini_array FINI_ARRAY 0045a410 44d410 000004 00 WA 0 0 4
[19] .data.rel.ro PROGBITS 0045a420 44d420 006ab8 00 WA 0 0 16
[20] .init_array INIT_ARRAY 00460ed8 453ed8 000058 00 WA 0 0 4
[21] .dynamic DYNAMIC 00460f30 453f30 000170 08 WA 4 0 4
[22] .got PROGBITS 004610a0 4540a0 000f60 00 WA 0 0 4
[23] .data PROGBITS 00462000 455000 001290 00 WA 0 0 16
[24] .bss NOBITS 00463290 456290 001fe1 00 WA 0 0 16
[25] .comment PROGBITS 00000000 456290 000065 01 MS 0 0 1
[26] .note.gnu.gold-ve NOTE 00000000 4562f8 00001c 00 0 0 4
[27] .ARM.attributes ARM_ATTRIBUTES 00000000 456314 000044 00 0 0 1
[28] .shstrtab STRTAB 00000000 456358 000143 00 0 0 1
[29] .symtab SYMTAB 00000000 45649c 066a90 10 30 19498 4
[30] .strtab STRTAB 00000000 4bcf2c 0da9bc 00 0 0 1
Key to Flags:
W (write), A (alloc), X (execute), M (merge), S (strings), I (info),
L (link order), O (extra OS processing required), G (group), T (TLS),
C (compressed), x (unknown), o (OS specific), E (exclude),
y (noread), p (processor specific)

既然符号表是 so 库中的一个段,那么查找这个符号就不难了,和之前 plt hook 方案中查找 dynamic 段中 got 表的函数一样,也是 2 步。

  1. 找到 so 库的首地址,并转换成 ELF 格式。
  1. 找到 ELF 文件中的 .symtab 段,并遍历该段,找到我们想要的符号信息,并取出地址。

解析 maps 文件可以找到 so 库地址的方法我们就不再讲了,这里重点看看第 2 个步骤。

  1. 遍历 ELF 文件中的 Section 段,并寻找 symtab 段。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
c++复制代码unsigned long symbolAddr;  
unsigned int symbolSize;
//将 so_addr 强制转换成Elf_Ehdr格式
Elf_Ehdr *header = (Elf_Ehdr *) (so_addr);
// 获取段头部表的地址
Elf_Shdr *seg_table = (Elf_Phdr *) (so_addr + header->e_shoff);

// 段的数量
size_t seg_count = header->e_shnum;
//遍历段,寻找symtab段地址
for (int i = 0; i < seg_count ; i++) {
seg_table += header->e_shentsize
if (seg_table->sh_type == SHT_SYMTAB) {
//so基地址加symtab段的偏移地址,就是symtab段的实际地址
symbolAddr = seg_table->sh_offset + so_addr;
symbolSize = seg_table->sh_size;
break;
}
}
  1. 遍历 symtab 段,寻找目标符号,并获取符号对应函数的地址。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
c++复制代码//确定symtab中符号的数量
size_t symtab_num= (symbolSize / sizeof(Elf_Sym)
//将 sybtal 段地址强制转换成 Elf_Sym 结构体
Elf_Sym *symtab = (Elf_Sym *)symbolAddr;
//遍历 sybtab 中的符号,并进行对比
for (k = 0; k < symtab_num; k++) {
//如果和想要查找的符号名ratget一致,则返回符号对应函数的地址
if (strcmp(strtab + symtab->st_name, target) == 0) {
void *ret = so_addr + symtab->st_value;
return ret;
}
//移动到下一个符号地址上
symtab++;
}

//Elf_Sym 的数据结构如下
typedef struct elf_sym {
Elf32_Word st_name; //符号名
Elf32_Addr st_value; //符号对应的值的偏移地址
Elf32_Word st_size; //符号的大小
……
} Elf_Sym;

可以看到,通过符号寻找地址的逻辑并不复杂。我们也可以回头再看看《Native 内存优化:so 库申请的内存优化》这篇文章中 plt hook 的方案实现,会发现寻找 .dynamic 段时的操作和这里寻找 .symtab 段是有区别的。plt hook 方案中,我们 遍历 的是 Program 段,这里遍历的是 Section 段。Program 实际只是按照 Section 的读写权限和属性特征,将 Section 重新组织了一次,然后加载进内存中,这样能节约更多的内存空间。

我们可以通过 readelf -l 命令,查看 libart.so 按照 Program 段的组织方式,可以看到 Program Headers 只有 9 个,而 Section Headers 有 31 个,这 31 个 Section 会按照 Type 的区别,整合到这 9 个 Program 中。

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
kotlin复制代码aarch64-linux-android-readelf -l libart.so 

Elf file type is DYN (Shared object file)
Entry point 0x0
There are 9 program headers, starting at offset 52

Program Headers:
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
PHDR 0x000034 0x0000c034 0x0000c034 0x00120 0x00120 R 0x4
LOAD 0x000000 0x0000c000 0x0000c000 0x44cdac 0x44cdac R E 0x1000
LOAD 0x44d410 0x0045a410 0x0045a410 0x08e80 0x0ae61 RW 0x1000
DYNAMIC 0x453f30 0x00460f30 0x00460f30 0x00170 0x00170 RW 0x4
NOTE 0x000154 0x0000c154 0x0000c154 0x00038 0x00038 R 0x4
GNU_EH_FRAME 0x44c6b0 0x004586b0 0x004586b0 0x006fc 0x006fc R 0x4
GNU_STACK 0x000000 0x00000000 0x00000000 0x00000 0x00000 RW 0x0
EXIDX 0x414220 0x00420220 0x00420220 0x0c2d8 0x0c2d8 R 0x4
GNU_RELRO 0x44d410 0x0045a410 0x0045a410 0x07bf0 0x07bf0 RW 0x10

Section to Segment mapping:
Segment Sections...
00
01 .note.android.ident .note.gnu.build-id .dynsym .dynstr .gnu.hash .gnu.version .gnu.version_d .gnu.version_r .rel.dyn .rel.plt .plt .text .ARM.exidx .rodata .ARM.extab .eh_frame .eh_frame_hdr
02 .fini_array .data.rel.ro .init_array .dynamic .got .data .bss
03 .dynamic
04 .note.android.ident .note.gnu.build-id
05 .eh_frame_hdr
06
07 .ARM.exidx
08 .fini_array .data.rel.ro .init_array .dynamic .got
None .comment .note.gnu.gold-version .ARM.attributes .shstrtab .symtab .strtab

不管是遍历 Section 段 ,还是遍历 Program 段,都能实现在 ELF 文件中查找数据的目的。通过这两种在 ELF 文件中查找数据的方案,可以让我们对 ELF 文件有一个更全面的了解。

虽然已经反复演示过了如何查找 ELF 文件中数据的操作,但是这里还是建议大家用成熟的开源工具来做这个事情,因为真正在线上应用中使用时,我们需要考虑到查找的性能,版本的兼容等各种因素,一不小心可能就出问题了。比如用 ndk_dlopen 这个开源库来实现 so 库和符号的查找就很简单,通过下面两个函数就能快速实现功能。

1
2
3
4
c++复制代码//打开 so
ndk_dlopen()
//根据符号查找函数地址
ndk_dlsym()

当然, 除了 ndk_dlopen 这个开源库,你可以找一些其他的成熟的开源框架来完成上面的逻辑,GitHub 都有很多。

获取 ConcurrentGCTask 的 Run 函数地址

了解了如何通过符号查找函数地址,我们再来看一下 ConcurrentGCTask 对象的 Run 函数的符号是什么。我们通过 readelf -s libart.so 指令来读取 libart 中所有的符号,可以看到 libart.so 的符号非常多,有 2 万多个。

1
2
3
4
5
6
7
8
9
10
11
Shell复制代码Symbol table '.symtab' contains 26281 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 00000000 0 NOTYPE LOCAL DEFAULT UND
1: 00000000 0 FILE LOCAL DEFAULT ABS crtbegin_so.c
2: 000acf34 0 NOTYPE LOCAL DEFAULT 12 $a
3: 000acf50 0 NOTYPE LOCAL DEFAULT 12 $d
4: 0045a410 0 NOTYPE LOCAL DEFAULT 18 $d
5: 00462000 0 NOTYPE LOCAL DEFAULT 23 $d
……
16846: 001b0ff1 36 FUNC LOCAL HIDDEN 12 _ZN3art2gc4Heap16ConcurrentGCTask3RunEPNS_6ThreadE
……

当我们稍微了解一下 libart 中符号的生成规则,就能找到 ConcurrentGCTask 对象的 Run 函数的符号,它位于 16846 行,即 _ZN3art2gc4Heap16ConcurrentGCTask3RunEPNS_6ThreadE。

有了 Run 函数的符号,我们就很容易拿到地址了,这里以 ndk_dlopen 开源工具做演示:

1
2
3
4
5
6
7
8
c++复制代码//初始化 ndk_dlopen
ndk_init(env);

//以RTLD_NOW模式打开动态库libart.so,拿到句柄,RTLD_NOW即解析出每个未定义变量的地址
void *handle = ndk_dlopen("libart.so", RTLD_NOW);

//通过符号拿到地址
void *runAddress = ndk_dlsym(handle," _ZN3art2gc4Heap16ConcurrentGCTask3RunEPNS_6ThreadE");

简单的两行代码,我们就成功拿到了 ConcurrentGCTask 的 Run 函数的地址,这个时候只需要插入我们自己的代码,修改这个函数让它休眠就能成功阻塞 HeapTaskDaemon 线程了。修改这个函数可以用《Native内存优化》这篇文章中提到的 inline hook 方式,我们直接使用文中提到的开源的 inline hook 工具即可,使用起来也很简单,这里就当做课后作业留给你自己去实现了。

小结

当我们掌握本章的知识点后,我们的优化手段就大大扩展了。除了 GC 线程,在开头的图片中,我们也可以看到 Jit thread pool 线程占有了较多的 CPU 时间,这个线程我们同样可以用本章学到的知识点来优化,并且本章的知识点在逆向、安全、外挂等领域都会被经常使用,希望你能掌握好。

到这里,你是不是觉得自己迈入了高手之路呢!切记不要眼高手低,只有当你理解、吸收本章的内容,并能基于它们举一反三,扩展出更多的优化方案时,你才真正迈进了高手之路!

本文转载自: 掘金

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

一个破RPC,又又又让我复习了一遍计算机网络

发表于 2023-09-18

作者:于水增

如题,我又不换工作,为什么要写这篇文章🙄️。。。

背景

我所在的小组中有一部分同学是和设备打交道的,通过周会的分享会了解到设备的控制、设备间的通信设计等等。。。

另外以前在做需求的时候会经常从服务端同学口中听到RPC调用,那么浅浅了解下吧

先抛出几个问题

  1. RPC是谁?做什么工作的?芳龄几何…(跑题)
  2. 怎样用,能玩出花吗?
  3. 我一个前端,跟我有毛关系?🙄️

先了解下RPC的概念

RPC 全称Remote Procedure Call,即远程过程调用。在维基百科中简单理解就是,程序员无需关注网络通信的复杂性,在实际开发中调用远程方法就像调用本地程序一样简单。

说白了,就是我们经常干的函数调用,既然是远程调用,就需要解决不同进程或服务间相互数据传输也就是通信问题。

那么数据是如何在计算机中传输的?

聊聊数据是如何在不同应用之间传输的?

先回顾下OSI模型(开放式系统互联模型)

这个模型很牛X,因为它就像画了一个圈,然后业界大佬们就在这个圈圈中设计各种协议,来满足我们日益变化的需求。

根据Wiki中对各层的介绍,整理出下边图表,列出了每层中我们比较熟悉的协议,个别陌生的也做了相应的备注,但是并不是所有场景的通信都会走完每一层。比如服务与服务的通信可以直接跳过应用层、系统内部可以直接物理层、设备与设备通信也是在物理层(比如往U盘里边下点电影。。。)

作为一个用户,无论是浏览器中打开一个链接(🈲️非法链接),还是打开微信去聊天。整个消息在网络上的传递是满足TCP/IP 4层概念模型的,数据层层传递(数据处理、建立连接、寻址。。。),最终满足用户的使用需求。

简单来看就是这样

其实,客户端与服务端的远比这个复杂,系统为我们做了很多事情,大体可以分为以下几个阶段

  1. 域名解析,查询IP(通过DNS服务查询IP地址)
  2. 建立TCP连接(我们常说的三次握手是在这个阶段完成)

a. 调用socket组件创建套接字

b. 调用connect与服务端的套接字建立管道

  1. 收发数据

a. 数据拆包

b. 数据确认(通过ACK号确认数据是否收到)

  1. 断开连接并删除套接字(四次挥手阶段。但是并不会立马删除套接字,主要防止像ACK号丢失的这种异常场景,比如客户端在发最后一个ACK后随即删除了套接字,但是服务端没有收到,所以服务端会再次发送一遍FIN,但是此时客户端已经删了呀,并且有个新的套接字被分配到了上次相同的端口号,那么FIN就会发给新的应用程序了,也就是我们常说的还没开始就结束了😂)

这部分内容很多,在每一步操作系统默默做了很多事,聊的很细的话肯本聊不完。。

好了,可以了,我要说RPC了。。。

假如我们有下面这个场景

商品中心需要去校验库存,那么就需要两个服务进行通信,在这里就可以用RPC来实现,库存中心只需要提供一个校验库存的方法供商品中心调用。那为啥不用HTTP?别问,问就是性能不好。。。

首先,我们目前用的比较多的还是HTTP/1.1的版本,它有很多缺陷,比如消息头部增加了很多看似冗余的字段。同时还有为了方便内部解析加了很多的换行符、回车符等。另外还需要考虑浏览器的各种行为。总之服务与服务之间并不需要这些。

而RPC的灵活性、定制化程度更高。可优化的空间很大,所以很多微服务架构中都选择使用RPC。

在OSI模型图中我们发现RPC存在于会话层,其实它实现的方式有多种,可以基于HTTP/Websocket、TCP/UDP,甚至可以在物理层去实现。所以它更像一个规范、一个调用方式。目前大部分的RPC底层使用TCP。谷歌的gRPC是基于HTTP/2实现的。

基于HTTP来实现个RPC(虽然没什么用)

感觉基于HTTP/1.1版本来实现的RPC有点鸡肋。更像是约定了一个数据格式,本身还是通过JSON来序列化。HTTP/2改进了很多,甚至比很多RPC的性能还要好。谷歌的gRPC的底层实现就是HTTP/2。

这个小demo是用JSON-RPC来实现的,看下定义

JSON-RPC

是一种轻量级的远程调用(RPC)协议,它使用JSON(Javascript Object Notation)作为数据交换格式,通过HTTP或其他传输协议在客户端和服务器之间进行通信,具有简单、轻量级、易于使用的特点。

JSON大家都知道,经常出现在项目配置文件,到Restful API规范里的响应数据、JSON Schema等。它具有易读写、易解析,具有很好的约束性。

详细的jsonrpc规范可参考 www.jsonrpc.org/specificati…

用HTTP实现JSON-RPC和普通HTTP请求有什么区别?

  1. 无非是将post请求中key/value格式替换为JSON格式
  2. 服务端解析json,通过method和params两个属性进行处理业务/数据
  3. 响应头中的Content-type变成了application/json-rpc

Nodejs + HTTP + JSON-RPC

异常对象设计

当RPC调用遇到错误时,响应对象必须包含以下几个属性

1
2
3
4
5
typescript复制代码type ErrorBody = {
code: number; // 错误编码
message: string; // 异常描述
data?: any; // 附加信息的原始或结构化值,由服务器定义
};

官方文档给出以下code

1
2
3
4
5
6
7
8
9
vbscript复制代码/*
| code | message | meaning |
| -32700 | Parse error | Invalid JSON was received by the server.An error occurred on the server while parsing the JSON text. |
| -32600 | Invalid Request | The JSON sent is not a valid Request object. |
| -32601 | Method not found | The method does not exist / is not available. |
| -32602 | Invalid params | Invalid method parameter(s). |
| -32603 | Internal error | Internal JSON-RPC error. |
| -32000 to -32099 | Server error | Reserved for implementation-defined server-errors. |
*/

请求参数设计

1
2
3
4
5
6
typescript复制代码type RequestBody = {
jsonrpc: string; // 版本号,分为1.0和2.0两个版本,因存在兼容性问题,必须保证为2.0
method: string; // 方法名,以单词 rpc 开头
params?: any; // 一个结构化值,方法调用期间要使用的参数值
id: number|string; // 唯一标识,由客户端建立
}

响应结果设计

其中result与error为互斥关系,即成功只返回result,异常只返回error。

1
2
3
4
5
6
typescript复制代码type ResponseBody = {
jsonrpc: string; // 必须保证为2.0
result?: any;
error?: ErrorBody;
id: number|string; // 必须与请求的ID相同
}

封装一个JSONRPC

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
kotlin复制代码export default class JSONRPC {
version: string = "2.0";

errorMsg = {
[-32700]: "Parse Error.",
[-32600]: "Invalid Request.",
[-32601]: "Method Not Found.",
[-32602]: "Invalid Params.",
[-32603]: "Internal Error.",
};

methods = {};

normalize(rpc, obj) {
obj.id = rpc && typeof rpc.id === "number" ? rpc.id : null;
obj.jsonrpc = this.version;
// 如果错误根据错误不存在错误信息的话代码获取错误信息
if (obj.error && !obj.error.message) {
obj.error.message = this.errorMsg[obj.error.code] || obj.error.message;
}
return obj;
}

/**
* JSONRPC 请求处理
* @param {Object} rpc
* @param {Function} response 响应回调
*/
handleRequest(rpc, response) {
// ...版本与一些参数验证逻辑

//函数查找
const method = this.methods[rpc.method];
if (typeof method !== "function") {
return response(this.normalize(rpc, { error: { code: -32601 } })); // 函数或方法未找到
}

// 调用函数将其执行结果作为响应结果返回客户端
try {
response(this.normalize(rpc, { result: method.apply(this, rpc.params) }));
} catch (error: unknown) {
if (error instanceof Error) {
response(this.normalize(rpc, { error: { code: -32000, message: error.message } }));
} else {
response(
this.normalize(rpc, { error: { code: 0, message: "unknown error" } })
);
}
}
}
}

起一个HTTP服务

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
javascript复制代码import { createServer } from "http";
import url from "url";
import JSONPRC from "./rpc";

const HOST = "localhost";
const PORT = 8080;
const RPC = new JSONPRC();
// 添加方法
RPC.methods = {
rpcDivide(a, b) {
if (b === 0) throw Error("Not allow 0");
return a / b;
},
};
// 路由设计,供客户端调用
const routes = {
"/rpc-divide": (request, response) => {
if (request.method === "POST") {
let data = "";
request.setEncoding("utf8");
request.addListener("data", (chunk) => {
data += chunk;
});
request.addListener("end", () => {
RPC.handleRequest(JSON.parse(data), (obj) => {
const body = JSON.stringify(obj);
response.writeHead(200, {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(body),
});
response.end(body);
});
});
} else {
response.end("hello nodejs http server");
}
},
};

const server = createServer((request, response) => {
// 解析请求,包括文件名
const pathname = url.parse(request.url || "").pathname || "";
const route = routes[pathname];
if (route) {
route(request, response);
} else {
response.end("hello nodejs http server");
}
});

server.listen(PORT, HOST, 0, () => {
console.log(`server is listening on http://${HOST}:${PORT} ...`);
});

运行效果

最终看下Postman的请求结果

出现非法请求时会跑出异常

完整实例代码在这里github.com/YSZ0927/nod… 感兴趣的可以试着调试下。。。

前端领域也能看到RPC的影子

Chrome远程调试工具

CDP(Chrome Devtools Protocol)是Chrome 的调试协议,用来调用 Chrome 内部的方法实现 js,css ,dom 的开发调试。它可以实现调试目标页面与控制台的相互通信。

首先Devtools由Fronend、Backend、Protocol、Message Channel构成。其中Protocol就是基于JSON-RPC来实现的,而Fronend与Backend之间则通过Websocket实现消息通信

如何调试?

  1. 终端启动本地Chrome实例
1
css复制代码sudo /Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --remote-debugging-port=9222 --remote-allow-origins=http://127.0.0.1:9222
  1. 随便输入一个调试网址,然后通过http://127.0.0.1:9222/json来获取 websocket target

  1. 新开tab页面http://127.0.0.1:9222/{devtoolsFrontendUrl}

感兴趣的可以在这篇文章 详细了解CDP的原理

JSON-RPC在门店业务中的应用场景

在门店里店员可以通过平板来操控设备。那就需要平板与设备与模组之间的通信

总结

  • RPC的作用主要是让我们屏蔽网络编程的复杂性,实现远程调用像调用本地方法一样的效果
  • RPC的实现有多种,可以基于HTTP1.1/HTTP2、Websocket、TCP、UDP任何一种
  • RPC与HTTP没有可比性,包括任何技术而言都是在某些场景下是相对合适的
  • JSON-RPC依赖JSON数据标准,兼容性高,大部分语言都支持且有相应的库。主打轻量级,简单,易用。

最后

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

本文转载自: 掘金

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

1…727374…956

开发者博客

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