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

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


  • 首页

  • 归档

  • 搜索

面试官必问:MySQL并发事务是怎么处理的? 前言 并发事务

发表于 2024-03-14

前言

我们开发人员在进行并发编程时,总是会面临并发带来的安全性和一致性的挑战,为了解决这一问题,我们通常会采用同步机制和锁机制,例如Java中的synchronized关键字和Lock接口。

MySQL同样需要解决并发事务带来的复杂问题,上文简单介绍了MySQL通过事务隔离机制可以解决并发问题,本文将结合案例进行深入剖析,以便掌握其原理并学习其思想。

并发事务情况分析

如果读过之前的文章就会知道,每行数据的读写都是基于数据页操作的。那么在此基础上,并发事务可能存在以下几种情况:

  1. 并发事务读/读数据页中的某行数据。
  2. 并发事务读/写数据页中的某行数据。
  3. 并发事务写/写数据页中的某行数据。

如果没有并发控制的情况下,单纯的读操作是不会对数据造成什么影响。但是,一旦涉及到写操作,情况就会变得很复杂:如果此时有一个事务对某行数据进行写操作,其他事务能否对该行数据进行读取?

这个问题有以下几个情形:

  1. 如果可以,写事务进行回滚后,读事务的数据就不是最新状态了,一致性如何保证?
  2. 如果不可以,读事务是不是只能进行排队等待写事务的完成,性能如何保证?
  3. 如果不排队等待,又怎么保证读事务的数据是最新状态(一致性)?

各隔离级别如何处理并发事务?

到这里应该就看明白了。结合事务隔离级别:

不处理

第一个情形不就是“读未提交”的“脏读”,一致性保证不了一点。

使用锁

第二个情形就是“串行化”,完全通过锁来处理并发事务。使用锁意味着需要竞争,而竞争失败就需要等待,等待就意味着消耗时间,消耗时间就意味着会影响整体并发处理能力。

对于MySQL这样的数据库,性能的高低会直接影响用户的去留,仅仅是“串行化”并发处理是远远不够的。

MVCC

所以,为了兼顾并发事务的一致性和性能问题(也就是第三个情形),就诞生MVCC,也是隔离级别“读已提交”和“可重复读”所运用到的技术。

什么是MVCC?

MVCC 全称 Multi-Version Concurrency Control(多版本并发控制),在数据库管理系统中通过保存数据的多个版本来避免读写冲突,从而提高并发处理能力。

如何理解MVCC?

这里关注两个关键字:多版本、读写冲突。

结合上面的并发事务情况分析:

  1. 单纯的并发读操作不用做任何的并发处理。
  2. 并发写操作又避免不了锁机制。
  3. 并发读写如果不做控制可能会有“脏读”问题,如果使用“串行化”处理并发,又会影响整体性能。

所以只能在并发读写这里进行优化,所谓的避免读写冲突。

接下来就来看一下MVCC是如何在写事务处理的同时,保证读事务不需要排队等待就能获取到数据最新状态的。

MVCC的并发处理

数据的多版本

在《MySQL是如何保证数据不丢失的》,每个DML操作在更新数据页之前,InnoDB会先将数据当前的状态记录在「Undo Log」中。

既然这样,那么读事务直接读取这里的数据不就好了?这样的话,写事务在处理过程中,读事务既不需要排队等待,又能读取到除当前写事务之外最新的数据状态,也避免了因写事务的回滚而造成的“脏读”问题。如下图。

b0ed51e8848f4103bf4642cab88d1fc8~tplv-k3u1fbpfcp-j.png

在并发事务中如果有多个写事务,那么Undo Log是这样的:

ba674f7959fb4536b0821f608bf373aa~tplv-k3u1fbpfcp-j.png

图中的「事务ID」和「回滚指针」是行数据中包含的「隐藏字段」,在 Undo Log 中通过回滚指针进行串联的数据就是指MVCC的「多版本」。

(这里说明下,同时执行DML操作时还是会使用锁来控制的,不会减少对锁的竞争。所以图中有个先后顺序。)

选择数据的某个版本

那么读事务应该以哪个版本的数据为准?

针对这个问题,MVCC通过Read View机制来处理。

Read View是什么?

Read View是事务进行读操作时生成的一个读视图,记录当前活跃事务的ID,分别是:

  1. trx_list:Read View生成时刻正活跃的事务ID。
  2. up_limit_id:trx_list列表中事务ID最小的值。
  3. low_limit_id:已出现过的事务ID的最大值加1。

通过Read View可以判断在当前事务能看到哪个版本的数据。

判断逻辑是这样的:

  • 如果数据行记录的事务ID小于up_limit_id,表示该记录在当前事务开始之前就已经提交了,因此对当前事务是可见的。
  • 如果数据行记录的事务ID大于等于up_limit_id且小于low_limit_id,表示该记录正在被写事务操作,可以读取上个已提交的版本数据。
  • 如果数据行记录的事务ID大于等于low_limit_id,则该记录对当前事务不可见,因为它是在当前事务开始后产生的。

(这里说明下,事务ID是递增的。

案例说明

接下来,通过一张图具体看一下Read View怎么判断的。

fd0eec53e7f94b9b87bdbf8efc3e2619~tplv-k3u1fbpfcp-j (1).png

图中有4个并发事务,并且在同一时刻开启了事务。

  1. 查询1是事务tx03在事务tx01已修改未提交时进行查询,事务tx02的update还未开始执行,所以当前数据的事务ID=tx01,活跃的事务ID为[tx01、tx02、tx03、tx04],按照Read View的逻辑:
    • tx01 不小于 up_limit_id(tx01),所以当前行记录age=19不可见。
    • tx01 大于等于 up_limit_id(tx01) 且小于 low_limit_id(tx05),可以读取上个已提交(XXX)的数据,也就是age=18。
  2. 查询2是事务tx03在事务tx01已提交,事务tx02已修改未提交时进行查询,所以当前数据的事务ID=tx02,活跃的事务ID为[tx02、tx03、tx04],按照Read View的逻辑:
    • tx02 不小于 up_limit_id(tx02),所以当前行记录age=20不可见。
    • tx02 大于等于 up_limit_id(tx02) 且小于 low_limit_id(tx05),可以读取上个已提交(tx01)的数据,也就是age=19。
  3. 查询3是事务tx04在事务tx02已提交时进行查询,所以当前数据的事务ID=tx02,由于是可重复读,所以在事务开始就生成了活跃的事务ID[tx01、tx02、tx03、tx04],按照Read View的逻辑:
    • tx02 不小于 up_limit_id(tx01),所以当前行记录age=20不可见。
    • tx02 大于等于 up_limit_id(tx01) 且小于 low_limit_id(tx05),可以读取上个已提交(XXX)的数据,也就是age=18。

各位可以按照这个逻辑,自行设置场景进行代入验证。

总结

基于上述,有以下总结:

  1. MySQL通过事务隔离、锁机制、MVCC处理并发事务。
  2. 事务隔离“读未提交”不做并发处理,不保证数据一致性。
  3. 事务隔离“串行化”通过锁机制进行并发处理,并发性能低下。
  4. 事务隔离“读已提交”和“可重复读”通过MVCC进行并发处理,并发性能高。
  5. MVCC是通过Undo Log(多版本)结合Read View(快照)实现了无锁读并解决了一致性问题。

本文转载自: 掘金

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

详谈跨域 同源策略-跨域 解决跨域 JSONP Cors n

发表于 2024-03-14

这两天一直被面试官问到跨域,但是自己从来没有系统性地总结过,本期文章就来把跨域这个问题给聊明白来,也希望对春招的各位有所帮助

同源策略-跨域

跨域就是浏览器的同源策略生效的时候

后端返回给浏览器的数据会被浏览器的同源策略给拦截下来

就拿百度官网为🌰,https://www.baidu.com,其实这个地址是被处理了,理应是这样https//192.168.31.45:8080/user,正常来讲一个url地址是由四个部分组成,也就是协议号https,域名192.168.31.45,端口号8080以及路径user

我们可以正常写一个前端,假设那个地址就是百度的后端地址,我们可以从那里拿到百度的数据,我们现在想想,这个情景科学吗?显然不科学,数据怎么能被不认识的人随便拿!因此浏览器针对这个问题,里面有个同源策略,也就是协议号-域名-端口号三部分必须是一样的,浏览器才认为你们是一家公司的,但凡三者有一个不同,那么浏览器就会把这个请求拦截下来,此时就是跨域

比如这样的地址就是符合同源https//192.168.31.45:8080/user和https//192.168.31.45:8080/list,只有最后的路径不同

也就可以这样理解,字节的前端朝着字节后端发接口请求,字节就会响应回去,然后百度前端也朝着字节后端发接口请求,字节后端会响应会去,后端是不负责判断谁来请求的,是个人请求都会返回,但是这个过程中,浏览器的同源就发现了不对,因此把后端返回的响应给拦截了下来

1.png

因此跨域发生在后端响应阶段

同源策略的目的就是一个安全性,怎么可能是个人都可以拿自己的数据

接下来我们需要解决跨域

回答这个之前我们先要明白为什么要解决跨域

为什么要解决跨域

假设我们在公司写项目,前端用vue写的,项目跑在http://192.168.31.1:8080,后端用go写的,跑在http://192.168.31.2:8080,尽管连接着一个wifi在同一个局域网内,其ip地址最后的数字还是不同的,两台设备的ip地址是不可能一样的,并且有时候,其端口号也是不同的

这是开发阶段,前后端需要联调,前端发现这个接口怎么都调用不了,问题就来了

所以为何要解决跨域,解决跨域方便程序员进行开发,开发阶段好调试

同源策略安全的同时,给程序员上了一层颈箍咒

解决跨域

解决跨域就是让同源策略发挥不了作用,后端响应回数据时可以正常作用

解决跨域有很多种方法,但是常用的就只有四种,这四种一定得掌握

JSONP

先简单实现下,前后端交互

1
2
3
4
5
6
7
8
go复制代码// 目录
client
index.html
server // npm init -y
node_modules
app.js
package-lock.json
package.json

先把app.js写成这样,完全可以接受吧~(引用的koa

1
2
3
4
5
6
7
8
9
10
11
12
13
javascript复制代码const Koa = require('koa')
const app = new Koa()

const main = (ctx, next) => {
ctx.body = {
data: 'hello world'
}
}

app.use(main)
app.listen(3000, () => {
console.log('listening on port 3000');
})

前端的话,简单写个页面,点击按钮获取后端返回的数据

index.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
xml复制代码<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<button id="btn">获取数据</button>

<script>
let btn = document.getElementById('btn')
btn.addEventListener('click', () => {
// fetch发请求
fetch('http://localhost:3000')
.then(response => {
return response.json() // fetch需要我们格式化数据
})
.then(res => {
console.log(res)
})
})
</script>
</body>
</html>

这样就实现了从后端拿数据,好,我们现在用liver server跑一下,点击按钮,出现报错!

2.png

has been blocked by CORS就是出现了跨域

当我们自己写全栈项目的时候,因为跨域导致自己的前端都无法调用自己的后端,感觉很气!

这个代码我改巴改巴,我把fetch请求注释掉,用script的src来请求,你会发现,不再同源了

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
xml复制代码<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<button id="btn">获取数据</button>

<script src="http://localhost:3000"></script>
<script>
let btn = document.getElementById('btn')
btn.addEventListener('click', () => {
// fetch发请求
// fetch('http://localhost:3000')
// .then(response => {
// return response.json() // fetch需要我们格式化数据
// })
// .then(res => {
// console.log(res)
// })
})
</script>
</body>
</html>

3.png

嚯~居然不报错!

聪明的你这个时候就发现了,这不就是我们引入第三方源码的手段嘛,就那个CDN引入,引入那个资源也没有报错!

当我们使用ajax发接口请求的时候一定是会受同源的影响,但是我们通过script的src去请求数据,并没有受到同源的影响

其实不法分子解决跨域就是通过这个手段

如果这个手段也受同源的影响,前端代码就写不了了,根本无法引入第三方的库

好,现在我们就钻这个空子

自己封装一个函数jsonp用于发接口请求,在函数里面自己生成一个script标签,这个函数可以接收url,cb参数,并且给script挂上一个src属性,放上url和cb。然后通过appendChild把script放到body里面去,这个时候就已经保证了这个函数可以利用script发请求了

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
xml复制代码<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<button id="btn">获取数据</button>

<script>

function jsonp (url, cb) {
return new Promise((resolve, reject) => {
const script = document.createElement('script') // 可以创建h5的任意标签
script.src = `${url}?cb=${cb}` // http://localhost:3000?cb='callback' 前端写死一个字符串传给后端
document.body.appendChild(script) // 把script添加到body中去,浏览器会自动发请求了
})
}

let btn = document.getElementById('btn')
btn.addEventListener('click', () => {
jsonp('http://localhost:3000', 'callback')
.then(res => {
console.log('后端返回的结果:'+res);
})
})
</script>
</body>
</html>

点击按钮,确实有个网络请求

4.png

既然前端发送了请求,那么此时后端一定收到了请求,并且里面把前端传进来的callback字符串传了过来

我们可以在后端的main方法中,打印下ctx.query

5.png

好,现在对后端代码改巴改巴,我把前端传给我的callback再带上自己数据返回给前端,如下,用的字符串模板拼接~

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
ini复制代码const Koa = require('koa')
const app = new Koa()

const main = (ctx, next) => {
console.log(ctx.query);

const data = '给前端的数据'
const cb = ctx.query.cb // callback字符串
const str = `${cb}('${data}')` // callback('给前端的数据')字符串

ctx.body = str
}

app.use(main)
app.listen(3000, () => {
console.log('listening on port 3000');
})

这个时候前端收到数据是报错的,因为cb还没有定义呢~

6.png

好,现在来到前端,我在全局window上挂上这个cb,其值我写成函数体

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
xml复制代码<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<button id="btn">获取数据</button>

<script>

function jsonp (url, cb) {
return new Promise((resolve, reject) => {
const script = document.createElement('script') // 可以创建h5的任意标签
script.src = `${url}?cb=${cb}` // http://localhost:3000?cb='callback' 前端写死一个字符串传给后端
document.body.appendChild(script) // 把script添加到body中去,浏览器会自动发请求了

window[cb] = (data) => { // 把callback挂到window上去,然后值是一个函数体
console.log(data);
}
})
}

let btn = document.getElementById('btn')
btn.addEventListener('click', () => {
jsonp('http://localhost:3000', 'callback')
.then(res => {
console.log('后端返回的结果:'+res);
})
})
</script>
</body>
</html>

我们打印在这个参数data,就是后端往cb括号中放入的数据,也就是那句话

7.png

既然有打印,说名这个cb函数被触发了,可是前端并没有触发它,那就只能是后端触发的,并且传了参数进来!

我现在将log打印改成resolve,这样后面的then就能打印出后端返回的数据了

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
xml复制代码<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<button id="btn">获取数据</button>

<script>

function jsonp (url, cb) {
return new Promise((resolve, reject) => {
const script = document.createElement('script') // 可以创建h5的任意标签
script.src = `${url}?cb=${cb}` // http://localhost:3000?cb='callback' 前端写死一个字符串传给后端
document.body.appendChild(script) // 把script添加到body中去,浏览器会自动发请求了

window[cb] = (data) => { // 把callback挂到window上去,然后值是一个函数体
resolve(data);
}
})
}

let btn = document.getElementById('btn')
btn.addEventListener('click', () => {
jsonp('http://localhost:3000', 'callback')
.then(res => {
console.log('后端返回的结果:'+res);
})
})
</script>
</body>
</html>

8.png

我们没有跨域,但是也拿到了后端返回的数据~

解释下整个过程:

  1. 借助script的src属性给后端发一个请求,且携带一个参数callback;
  2. 前端在window上添加了一个callback函数;
  3. 后端接收到这个参数callback后,将要返回给前端的数据data和这个参数callback进行拼接,成callback(data),并返回给前端;
  4. 因为window上已经有一个callback函数,后端又返回了一个形入callback(data),浏览器会将字符串执行成callback的调用

9.png

因此JSONP核心理念就是借助script标签上的src属性不受同源策略的影响这一机制,来实现跨域

总结

  1. ajax请求受到同源策略的影响,但是script上的src属性不受同源策略的影响,并且该属性也会导致浏览器发送一个请求
  2. 缺点:1. 必须后端配合(拿到参数再拼接回去);2. 只能用于get请求,浏览器执行script的src请求默认就是get方式(正常开发很多接口都是post);

Cors

Cors(Cross-Origin Resource Sharing)

在http协议中,每个请求都可以拆分成两部分,一个请求头,一个请求体

  • 请求头比较小,里面包含了这个请求的基本信息,从哪去哪儿
  • 请求体就放的是参数,数据包

后端返回的就是响应头和响应体,这个时候我们对这个响应头写入一些参数,告诉浏览器我后端的数据所有的前端请求都可以拿走

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
javascript复制代码const http = require('http')

const server = http.createServer((req, res) => {
res.writeHead(200, { // 对响应头
'Access-Control-Allow-Origin': '*' // *代表所有后端所有地址,浏览器直接接收就可以
})

let data = {
msg: "hello cors"
}
res.end(JSON.stringify(data)) // 向前端返回数据
})

server.listen(3000, () => {
console.log('listening on port 3000');
})

前端不需要任何变化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
xml复制代码<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<button id="btn">获取数据</button>

<script>
let btn = document.getElementById('btn');
btn.addEventListener('click', () => {
fetch('http://localhost:3000')
.then(res => res.json())
.then(res => {
console.log(res);
})
})
</script>
</body>
</html>

10.png

好了,这样就实现了解决跨域,但是目前的写法比较偷懒,肯定不能写*,实际开发不可能允许所有的接口请求

比如我现在前端写在本地,就是localhost,所以把*换成http://127.0.0.1:5501

多个ip需要接口,就多配几个白名单

总结

后端通过设置响应头来告诉浏览器不要拒绝接收后端的响应

这个方法明显比JSONP好用多了,前端不需要任何操作,只需要后端简单配置下cors即可

node代理

假设我现在写了个前端,希望拿到网易云的数据,我可以从前端向网易云的后端拿,我还可以选择自己写个后端,自己的后端去往网易云的后端拿数据,这个过程中不经过浏览器,就不会跨域,后面就是自己的前端从自己的后端拿数据cor下就好,这就是node代理

vite这个构建工具就是用node写的,我们写vue项目的时候,就可以用node代理的形式去解决跨域问题

好,我现在用vite模仿下,App.vue我让其首页挂载完毕就朝着后端发请求

1
2
3
4
5
6
7
8
9
10
11
xml复制代码<script setup>
import axios from 'axios'
import { onMounted } from 'vue'

onMounted(() => {
axios.get('http://localhost:3000')
.then((res) => {
console.log(res);
})
})
</script>

这里用的axios发请求,axios需要自己安装npm i axios

然后自己的后端如下,没有使用cors,一定会发生跨域

1
2
3
4
5
6
7
8
9
10
11
12
javascript复制代码const http = require('http')

const server = http.createServer((req, res) => {
let data = {
msg: "hello nodo-proxy"
}
res.end(JSON.stringify(data)) // 向前端返回数据
})

server.listen(3000, () => {
console.log('listening on port 3000');
})

11.png

配置vite.config.js

如何解决呢?我们直接来到vite.config.js文件中配置server,如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
javascript复制代码import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()], // vite源码是node写的
server: { // 和网络请求相关的配置
proxy: {
'/api': { // 只要前端是向/api发请求,都是发到target,比如axios.get('/api')
target: 'http://localhost:3000',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '') // 后端路径本身就有/api就把它去掉
}
}
}
})

vite解决跨域:开发服务器选项 | Vite 官方中文文档 (vitejs.dev)

这个配置的意思是,只要前端朝/api发请求,那么就会转发到target中,也就是localhots:3000,然后改变源,如果后端路径本身就有/api,那么就重写为空

好了,所以现在只需要把前端的请求地址改成/api即可

1
2
3
4
5
6
7
8
9
10
11
xml复制代码<script setup>
import axios from 'axios'
import { onMounted } from 'vue'

onMounted(() => {
axios.get('/api')
.then((res) => {
console.log(res);
})
})
</script>

像是修改完配置文件,都需要项目重新启动下

好了,成功解决跨域,从后端拿到数据

12.png

好,问题来了,vite只是我们开发阶段使用的构建工具而已,项目最后是要打包上线的,因此到了生产阶段,vite打包后的这个配置信息是会消失的,不对,是整个vite的源码都会被剔除掉

因此这个方法只适用于开发环境,上线的跨域只能用别的方法

总结

vite帮我们启动了一个node服务,且帮我们朝着http://locahost:3000发请求,因为后端之间没有同源策略,所以,vite中的node服务能直接请求到数据,再提供给前端使用

但是给到前端依旧会跨域,只是因为里面已经自带cors了,看不出来

缺点:只能在开发环境中生效

截至目前,前端是没有一个优雅的手段可以阶段跨域的,JSONP很麻烦,而且只能get,然后Cors是后端干的,然后node只能开发阶段生效,其实这个三个方法通常都是用在开发环境下

nginx代理

这个机制和Cors差不多,做白名单的配置,都是配置请求头,需要后端在服务器上安装nginx,实现所有的请求可以实现nginx代理,nginx是linux的语法,而非js

这个方案可以解决生产环境下的跨域,也就是可以项目上线且不跨域,公司项目一般就是用这种方法解决跨域

nginx和cors机制差不多,为何不用nginx呢?

如何你写了三个后端项目,那么三个后端项目都需要配置Cors,nginx可以一起配置掉

具体实现以后再出期文章详聊,涉及到项目上线

其实这四种跨域方案足够你项目的开发了,不过面试官可能会问你还有吗,那些就是些不常用的方法

面试官:还有吗?

domain

在iframe中,当父级页面和子级页面的子域不同时,通过设置document.domain = 'xx',来将xx定为基础域,从而实现跨域

iframe的作用就是一个html可以嵌套另一个html

比如我这里,两个页面,父级页面中通过iframe嵌套了个子页面,父级页面定义个参数,子级页面打印这个参数,当然得实现非同源的时候打印才能证明实现跨域,用live-server是同源运行的

live-server安装:npm i -g live-server

index.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
xml复制代码<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<h2>父页面</h2>

<iframe src="http://127.0.0.1:5501/%E9%9D%A2%E8%AF%95%E9%A2%98/%E7%99%BE%E5%BA%A6%E9%9D%A2%E8%AF%95%E9%A2%98/domain/child.html" frameborder="0"></iframe>

<script>
document.domain = '127.0.0.1'

var user = 'admin'
</script>
</body>
</html>

child.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
xml复制代码<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<h4>子页面</h4>

<script>
document.domain = '127.0.0.1'

console.log(window.parent.user);
</script>
</body>
</html>

比如当你希望在www.example.com中使用api.example.com的api,为防止跨域,就可以两个页面设置相同的document.domain,也就是基础域,这样就不会跨域

postMessage

实现一个深拷贝可以借助管道通信,也就是postMessage,还有个structured clone,这是js自带的

postMessage主要作用就是用来做管道通信的,既然涉及到通信,那就会遇到跨域的问题

按道理两个页面交互需要一个点击事件,这里我们不用点击事件,a,b页面交互信息如下,不发生跨域

a.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
xml复制代码<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<h2>a.html</h2>

<iframe src="http://127.0.0.1:5501/%E9%9D%A2%E8%AF%95%E9%A2%98/%E7%99%BE%E5%BA%A6%E9%9D%A2%E8%AF%95%E9%A2%98/postMessage/b.html" frameborder="0" id="iframe"></iframe>

<script>
// 给b发送数据
let iframe = document.getElementById('iframe')
iframe.onload = function() {
let data = {
name: 'Dolphin'
}
iframe.contentWindow.postMessage(JSON.stringify(data), 'http://127.0.0.1:5501') // a向b发送这个data数据
}
// 监听b传过来的数据
window.addEventListener('message', function(e) {
console.log(e.data);
})
</script>
</body>
</html>

b.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
xml复制代码<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<h4>b.html</h4>

<script>
window.addEventListener('message', function(e) {
console.log(JSON.parse(e.data)); // 可以拿到a的数据

if (e.data) { // 回应a,拿到数据
setTimeout(function() {
window.parent.postMessage('我接受到了', 'http://127.0.0.1:5501')
}, 2000)
}
})
</script>
</body>
</html>

13.png

还有个websocket来解决跨域,以后再来单独详聊

最后

正常来讲,JSONP,Cors,node代理和nginx足够解决跨域了,如果面试官问你,还能答出个几个冷门方法就再合适不过了

如果你对春招感兴趣,可以加我的个人微信:Dolphin_Fung,我和我的小伙伴们有个面试群,可以进群讨论你面试过程中遇到的问题,我们一起解决

另外有不懂之处欢迎在评论区留言,如果觉得文章对你学习有所帮助,还请”点赞+评论+收藏“一键三连,感谢支持!

本文转载自: 掘金

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

拖更两个月,因为我结婚了😄😄,美美婚照奉上,求祝福鼓励一波接

发表于 2024-03-14

准备结婚是一段充满期待、忙碌和感动的过程,它可能会带给人各种各样的感受。以下是我准备结婚过程感受:

  1. 幸福和兴奋: 确定了结婚的决定后,人们通常会感到幸福和兴奋,因为将与自己爱的人共度一生,开始了新的生活篇章。我确定结婚相对仓促吧,短短3,4个月。家里面催的很,有点半推半就的感觉吧,但是是认真的哈。
  2. 紧张和焦虑: 结婚是人生中一件大事,准备结婚的过程可能会让人感到紧张和焦虑。担心婚礼的安排是否完美,担心自己是否能够成为一个好的伴侣等等。确实有点这种焦虑感。大概新人或多或少都有,毕竟没经历过,但是总体上来说还好,我结婚和家里分工明确,酒席家里搞定,我不参与,我只负责婚礼场景选定和化妆师、跟拍一系列与我们相关的事情。这样总的下来效率高。也不会因为事情繁多导致自己着急。
  3. 忙碌和疲劳: 准备结婚需要做很多准备工作,包括筹备婚礼、购买婚纱礼服、安排婚礼场地等等,这些工作会让人感到忙碌和疲劳。累的一批,给累惨了婚礼附近那几天,整个人都忙的晕头转向。
  4. 感动和温馨: 在筹备婚礼的过程中,常常会收到来自亲朋好友的祝福和关爱,这些温馨的举动会让人感动不已,加深对彼此的情感和亲情。我婚礼伴郎和主持都是我好朋友兄弟,不仅省了一笔钱,效果还不错,没有正规主持人那样正式,主打一个就是随意一点,,挺好的。
  5. 对未来的期待: 结婚意味着进入了一个新的生活阶段,人们对未来充满了期待和憧憬,希望能够与另一半共同实现梦想,共同面对生活中的挑战。结完婚没多久就回来上班了,虽然现在生活和之前没啥区别,但是确实是一个小家庭了,需要两个继续再接再厉,奥利给💪🏻💪🏻💪🏻

YT102021.jpg

本文转载自: 掘金

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

利用IDEA/WebStorm/pycharm的【随处搜索】

发表于 2024-03-14

强大的搜索功能:随处搜索(Search Everywhere)

jetbrains公司开发了很多功能丰富的IDE,其随处搜索(Search Everywhere)功能非常实用。通过双击Shift键操作,就能够调用出这个功能强大的搜索窗口。这个搜索不仅能帮助找到项目中的文件、类、符号等,还额外提供了一些便捷的小工具,如计算器。

使用随处搜索进行计算

在随处搜索窗口中,我们可以直接输入数学表达式,如1+12*2/(3-1),就会立即显示出计算结果。这个小巧的计算器功能可以在我们需要快速做一些计算时省去切换计算器应用的麻烦。

image.png

除了简单的数学运算,我们还可以使用一些内置的数学函数,比如sqrt(1024),输入后,它会立即显示结果。

image.png

如果我们不需要使用计算器功能,可以在随处搜索窗口的筛选菜单中进行配置。只需要取消计算器复选框的勾选,这个功能就会被关闭。

image.png

通过文件名快速搜索

要在编辑器中找到一个文件,我们可以直接输入该文件的名字或者包含它的目录和文件名。不需要精确到每一个字符,甚至连目录分隔符都可以省略。比如,要打开src/page/my/index.scss文件,只需输入myindex.scss并敲击回车键,文件即会立刻打开。

image.png

使用首字母搜索文件

此外,如果文件路径较长或者记不清完整名称,可以采用首字母搜索。假定我们要找的文件是src/page/my/index.scss,我们可以简单输入m/is,编辑器会将搜索范围缩小到符合这一首字母缩写的文件。

image.png

对于采用驼峰命名的组件文件,比如NewComponent.ts,我们同样可以通过输入每个单词首字母的大写形式进行搜索,即输入NC。

image.png

搜索项目外的文件

在编辑器中,默认情况下随处搜索只覆盖项目文件。如果我们想要搜索项目之外的文件,比如node_modules中的某个文件,只需在搜索框的右上角勾选包括非项目条目。例如,通过输入tcd就能找到taro.config.d.ts文件。

image.png

精确搜索代码中的函数和变量

在开发时,我们经常需要快速定位代码中的特定函数或变量的定义。以index.tsx为例,我们已经定义了handleTextClick函数和count状态。为了在庞大的代码中迅速找到这些定义,可以使用随处搜索功能来提高效率。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
tsx复制代码// index.tsx

import {View, Text} from '@tarojs/components'
import {useState} from 'react';

import './index.scss'

export default function Index() {

const [count, setCount] = useState(1);

function handleTextClick() {
console.log(1)
}

return (
<View className='home'>
<Text onClick={handleTextClick}>辰火流光</Text>
</View>
);
}

当直接在随处搜索窗口中输入count时,会发现搜索结果并不符合预期。这是因为默认情况下,搜索窗口会显示包括类、命令、文件、符号等所有类型的结果。此时,可以通过按Alt+←键,切换到文本选项卡专门搜索文本内容。

动画.gif

使用快捷键切换顶部选项卡

使用Alt+←和Alt+→键在不同的搜索选项卡(如“符号”、“文件”和“文本”)之间切换,我们也可以直接点击顶部的选项卡进行手动切换。

高效操作技巧

jetbrains公司的这些编辑器提供了强大且复杂的菜单系统,但当需要深入多层菜单来执行特定操作时,这可能会显得有些繁琐。例如,在其中一款编辑器(WebStorm)中启用ESLint的保存时运行–fix功能,路径就比较长:设置 → 搜索ESLint → 点击“语言和框架-JavaScript-代码质量工具-ESLint”。

image.png

为提高效率,随处搜索功能中包含了一个操作选项卡。只需输入希望执行的操作关键词,如--fix,就可以快速定位到对应的设置选项。

动画.gif

筛选

随处搜索窗口还提供了筛选功能,极大地优化了搜索效果。点击随处搜索窗口右侧的筛选按钮会弹出一个复选框列表,列表内容随着选项卡的切换而变化。例如,在Git选项卡下,我们会看到提交记录、本地分支、远程分支、标记等相关选项。

image.png

总结

随处搜索(Search Everywhere)是JetBrains系列IDE中的一个极具实用性的功能。这个功能可以通过双击Shift键快速呼出,并允许用户在一个集成的搜索窗口中查找项目文件、类、符号等,甚至包括执行简单的数学运算。

功能亮点

  • 计算器工具:不必离开编辑器即可进行数学计算,例如键入1+12*2/(3-1)直接得到结果,提升工作效率。
  • 文件搜索:通过文件名、目录和文件名组合搜索,甚至使用首字母缩写快速定位具体文件,简化了查找过程。
  • 代码查询:针对特定函数或变量,使用“文本”选项卡过滤出纯文本结果,准确追踪代码定义位置。
  • 系统操作:操作选项卡能快速导航至深层菜单的特定设置,例如启用ESLint的自动修复功能--fix,无需层层点击。
  • 筛选功能:根据不同的操作和内容,使用筛选按钮细化搜索结果,避免信息过载。

总体益处

随处搜索功能的引入显著提升了开发者的工作效率。无论是简单的快速计算,还是复杂的文件和代码查找,或是快捷的操作搜索和筛选,它都为用户提供了灵活多变,简捷直观的搜索功能,以便随时查找所需信息。

本文转载自: 掘金

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

三种方式使用纯 CSS 实现星级评分

发表于 2024-03-14

本文介绍三种使用纯 CSS 实现星级评分的方式。每种都值得细品一番~

aa.gif

五角星取自 Element Plus 的 svg 资源

image.png

1
2
3
4
5
6
js复制代码<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" style="">
<path
fill="currentColor"
d="M283.84 867.84 512 747.776l228.16 119.936a6.4 6.4 0 0 0 9.28-6.72l-43.52-254.08 184.512-179.904a6.4 6.4 0 0 0-3.52-10.88l-255.104-37.12L517.76 147.904a6.4 6.4 0 0 0-11.52 0L392.192 379.072l-255.104 37.12a6.4 6.4 0 0 0-3.52 10.88L318.08 606.976l-43.584 254.08a6.4 6.4 0 0 0 9.28 6.72z">
</path>
</svg>

三种实现方式的 html 结构是一样的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
html复制代码<div>
<input type="radio" name="radio" id="radio1">
<label for="radio1">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" style=""><path fill="currentColor" d="M283.84 867.84 512 747.776l228.16 119.936a6.4 6.4 0 0 0 9.28-6.72l-43.52-254.08 184.512-179.904a6.4 6.4 0 0 0-3.52-10.88l-255.104-37.12L517.76 147.904a6.4 6.4 0 0 0-11.52 0L392.192 379.072l-255.104 37.12a6.4 6.4 0 0 0-3.52 10.88L318.08 606.976l-43.584 254.08a6.4 6.4 0 0 0 9.28 6.72z"></path></svg>
</label>
<input type="radio" name="radio" id="radio2">
<label for="radio2">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" style=""><path fill="currentColor" d="M283.84 867.84 512 747.776l228.16 119.936a6.4 6.4 0 0 0 9.28-6.72l-43.52-254.08 184.512-179.904a6.4 6.4 0 0 0-3.52-10.88l-255.104-37.12L517.76 147.904a6.4 6.4 0 0 0-11.52 0L392.192 379.072l-255.104 37.12a6.4 6.4 0 0 0-3.52 10.88L318.08 606.976l-43.584 254.08a6.4 6.4 0 0 0 9.28 6.72z"></path></svg>
</label>
<input type="radio" name="radio" id="radio3">
<label for="radio3">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" style=""><path fill="currentColor" d="M283.84 867.84 512 747.776l228.16 119.936a6.4 6.4 0 0 0 9.28-6.72l-43.52-254.08 184.512-179.904a6.4 6.4 0 0 0-3.52-10.88l-255.104-37.12L517.76 147.904a6.4 6.4 0 0 0-11.52 0L392.192 379.072l-255.104 37.12a6.4 6.4 0 0 0-3.52 10.88L318.08 606.976l-43.584 254.08a6.4 6.4 0 0 0 9.28 6.72z"></path></svg>
</label>
<input type="radio" name="radio" id="radio4">
<label for="radio4">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" style=""><path fill="currentColor" d="M283.84 867.84 512 747.776l228.16 119.936a6.4 6.4 0 0 0 9.28-6.72l-43.52-254.08 184.512-179.904a6.4 6.4 0 0 0-3.52-10.88l-255.104-37.12L517.76 147.904a6.4 6.4 0 0 0-11.52 0L392.192 379.072l-255.104 37.12a6.4 6.4 0 0 0-3.52 10.88L318.08 606.976l-43.584 254.08a6.4 6.4 0 0 0 9.28 6.72z"></path></svg>
</label>
<input type="radio" name="radio" id="radio5">
<label for="radio5">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" style=""><path fill="currentColor" d="M283.84 867.84 512 747.776l228.16 119.936a6.4 6.4 0 0 0 9.28-6.72l-43.52-254.08 184.512-179.904a6.4 6.4 0 0 0-3.52-10.88l-255.104-37.12L517.76 147.904a6.4 6.4 0 0 0-11.52 0L392.192 379.072l-255.104 37.12a6.4 6.4 0 0 0-3.52 10.88L318.08 606.976l-43.584 254.08a6.4 6.4 0 0 0 9.28 6.72z"></path></svg>
</label>
</div>

利用 radio + label 的方式实现点击效果;将 label 的 for 属性保持和 radio 的 id 一致,并将 radio 框隐藏,这样点击 label 就是点击 radio 了;label 在这里就是每个星星;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
css复制代码html,body{
width:100%;height:100%;
}
body{
display:flex;
justify-content:center;
align-items:center;
}
div{
display : flex;
justify-content:center;
align-items:center;
}

div input{
display:none;
}

div label{
width:50px;height:50px;
padding:0 4px;
color:#ccc;
cursor:pointer;
}

html 布局效果如下:

image.png

通常星级评分效果包括鼠标滑入和点击,滑入或点击到第几颗星的位置,该位置之前的星高亮,之后的星不高亮或者有高亮的则取消高亮;

接下来分别阐述三种 CSS 实现方式;

1、:has()选择器 + input:checked

当点击星星时,高亮当前星星

1
2
3
css复制代码input:checked + label{
color:gold;
}

input:checked + label 表示 选择紧挨着已选中 input 框的后面一个 label;

当鼠标移入星星时,高亮当前星星,并且该位置之后的星星取消高亮;

1
2
3
4
5
6
7
css复制代码label:hover{
cursor:pointer;
color:gold;
& ~ label{
color:#ccc!important;
}
}

那么如何让该位置之前的星星也高亮呢,目前的兄弟选择器包括 + 和 ~ ,但都不能选择之前的兄弟元素;此时 :has() 选择器就登场了;

:has() 提供了一种针对引用元素选择父元素或者先前的兄弟元素的方法。

比如:

a:has(p) 表示选择包含子元素 p 的 a 元素;

a:has(> p) 表示选择有直接后代 p 元素的 a 元素,也就是 p 只能是 a 的 “儿子” 元素;

a:has(+ p) 表示选择后面紧跟着的兄弟元素是 p 的 a 元素;

所以回到上面问题,当鼠标移入星星时,让该位置之前的所有星星也高亮,可以这么做

1
2
3
css复制代码div:has(label:hover) label:not(:hover,:hover ~ *){
color:gold;
}

label:not(:hover,:hover ~ *) 表示排除当前 hover 的 label 和之后的所有元素;也就自然选择了前面所有星星;

bb.gif

同样,当点击星星时,点亮当前选择的之前所有的星星也如此

1
2
3
css复制代码div:has(input:checked) label:not(input:checked ~ label){
color:gold;
}

div:has(input:checked) 表示选择包含被选中的 input 的 div;

label:not(input:checked ~ label) 表示排除当前选中的 input 后面的所有 label,也就选择到前面所有的 label 了;

完整示例

cc.gif

2、:indeterminate + input:checked 巧妙实现

这种实现的思路是,假设初始所有的星星都是高亮的,鼠标移入或点击时保持前面星星的高亮,取消后面星星的高亮;

1
2
3
4
5
6
css复制代码div label{
width:50px;height:50px;
padding:0 4px;
color:gold; => 默认星星高亮
cursor:pointer;
}

image.png

然后当鼠标移入或点击时,取消该位置后面的星星的高亮

1
2
3
4
css复制代码div input:checked ~ input + label,
div label:hover ~ label{
color:#ccc;
}

gg.gif

但一开始默认设置的星星是高亮的,但页面上并不想在 radio 未被选中时高亮,这时 :indeterminate 就登场了;

:indeterminate 表示任意的状态不确定的表单元素。对于 radio 元素,:indeterminate 表示当表单中具有相同名称值的所有单选按钮均未被选中时。

所以这里设置每个星星在对应的 radio 的未被选中时非高亮;并且只是在初始状态,鼠标移入时这种初始状态就应该被改变

1
2
3
4
5
css复制代码div:not(:hover) input:indeterminate + label,
div:not(:hover) input:checked ~ input + label,
div input:hover ~ input + label{
color:#ccc;
}

:not() 表示用来匹配不符合一组选择器的元素;div:not(:hover) 表示鼠标移入时,不匹配这行规则,这样在初始状态下或者在鼠标点击星星后,鼠标移入仍然会高亮当前点击位置之前的星星;

这样效果就达到了;

完整示例

cc.gif

3、flex-direction:row-reverse; + input:checked 巧妙实现

目前 html 布局是从左到右布局,但如果我们倒过来呢,从右到左布局;

1
2
3
4
5
6
7
css复制代码div{
width:300px;
display:flex;
/* 从右往左排列 */
flex-direction:row-reverse;
justify-content:space-around;
}

dd.png

那么之前利用 :has() 选择之前的兄弟元素现在就可以直接用 ~ 来选择了;

1
2
3
4
5
6
7
8
9
css复制代码// 之前
label:not(input:checked ~ label){
color:gold;
}

// 现在
label:hover ~ label{
color:gold;
}

dd.gif

点击星星也是

1
2
3
css复制代码input:checked ~ label{
color:gold;
}

但是这样还不够完善

ee.gif

当我们点击第二颗星星时,鼠标滑入到第三个星星,第二颗星星并没有取消高亮,所以这里还是得借助下 :has()

1
2
3
css复制代码label:has(~ label:hover){
color:#ccc;
}

上面表示选择后面被 hover 的兄弟元素的元素,也就是 hover 元素的前面的所有元素;这样就没问题了;

完整示例

ff.gif

总结

以上使用了三种纯 css 实现星级评分的方式;

  • :has()选择器 + input:checked
  • :not()选择器 + input:checked
  • flex-direction:row-reverse; + input:checked 巧妙实现

特别是 :has() 选择器可以选择之前的兄弟元素,搭配 :not() 能发挥很多作用,以前很多需要用 js 实现的效果或许现在可以用 :has() 来试试了;

附上 :has() 和 :not() 的兼容性截图

:has()


:not()


各位看官们,如果对本文感兴趣,麻烦动动你们的发财手,点点赞~

本文转载自: 掘金

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

吾辈楷模!国人开源的Redis客户端被Redis官方收购了!

发表于 2024-03-14

不久前开源圈子里的一则消息在网上引起了一阵关注和讨论。

一个由国人开发者所打造的开源项目被 Redis 公司官方给收购了,作者自己也发了动态,表示感谢项目9年以来的陪伴,同时也希望她未来一切都好。

这个开源项目的名字叫做:ioredis,相信不少小伙伴也用过。

目前在GitHub上我们可以看到,ioredis项目的开源地址已经被迁移至 Redis 官方旗下了。

iosredis是国人开发者所打造的一个Redis客户端,基于TypeScript所编写,以健壮性、高性能以及功能强大为特色,并且被很多大公司所使用。

截止到目前,该项目在GitHub上已累计获得超过 13000 个 Star标星和 1000+ Fork。

作者自己曾表示,自己创建这个开源项目的初衷也很简单,那就是当年在这方面并没有找到一个令自己满意的开源库,于是决定自己动手来打造一个,于是就利用闲暇时间,自己从零开发并开源了 ioredis 。

直到2022 年 8 月 30 日,历时整整7年,ioredis 成为了 Node.js 最流行的 Redis 客户端。

而直到如今,这个项目从个人的 side project 到被开源公司官方收购,作者9 年的坚持属实令人佩服,吾辈楷模啊!

而拜访了这位开发者的GitHub后我们会发现,作者非常热衷于创造工具,除了刚被收购的名作ioredis之外,主页还有非常多的开源项目,并且关注量都不低。

而且从作者发的一些动态来看,这也是一个热爱生活的有趣灵魂。

有一说一,个人开源作者真的挺不容易的,像上面这样的个人开源项目被官方收购的毕竟是个例,其实好多个人的开源项目到后期由于各种主客观原因,渐渐都停止更新和维护了。

大家都知道,伴随着这两年互联网行业的寒意,软件产业里的不少环节也受到了波动。行业不景气,连开源项目的主动维护也变得越来越少了。

毕竟连企业也要降本增效,而开源往往并不能带来快速直接的实际效益。付出了如果没有回报,便会很难坚持下去。

而对于一名学习者而言,参与开源项目的意义是不言而喻的,之前咱们这里也曾多次提及。

参与开源项目除了可以提升自身技术能力,收获项目开发经验之外,还可以让自己保持与开源社区其他优秀开发者之间的联系与沟通,并建立自己的技术影响力,另外参与优秀开源项目的经历也会成为自己求职简历上的一大亮点。

所以如果精力允许,利用业余时间来参与或维护一些开源项目,这对技术开发者来说,也是一段难得的经历!

注:本文在GitHub开源仓库「编程之路」 github.com/rd2coding/R… 中已经收录,里面有我整理的6大编程方向(岗位)的自学路线+知识点大梳理、面试考点、我的简历、几本硬核pdf笔记,以及程序员生活和感悟,欢迎star。

本文转载自: 掘金

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

超级详细的RabbitMQ入门篇(有代码!有代码!有代码!)

发表于 2024-03-13

超级详细的RabbitMQ入门篇(有代码!有代码!有代码!)

1.1. MQ的相关概念

1.1.1 什么是MQ

本质就是一个队列,只不过队列里存放的是message而已,一种跨进程的通信机制,用于上下游传递消息,是一种“逻辑解耦+物理解耦”的消息通信服务,使用其后,消息发送上游只需要依赖MQ,不用依赖服务本身。

1.1.2 为什么要用MQ

1. 流量削峰

利用消息队列来做缓冲,将某一秒的大量请求放在队列中做缓冲,分散成一段时间来进行处理,这样做可能影响用户的使用体验,因为客户的请求也许一段时间后才会被真正的去进行业务逻辑处理,但是也比系统崩掉强。

2. 应用解耦

例如,用户登录A系统后,需要像B系统发送请求,记录当前登录用户的信息日志,如果这个时候B挂掉了,那这个调用保存日志的接口就会报错,

如果使用了MQ,信息就会保存在队列中,过了几分钟后,当B正常启动后,B系统监听消息队列的类或者方法依然能够正常的获取到刚刚发送过来的消息,进行日志保存的操作。并且不会影响实施监督其他操作的正常执行。

3. 异步处理

有些服务间调用是异步的,例如A调用B,B需要花很长时间去执行,但是A需要知道B什么时候能执行完,以前一般都有俩种方式,A每隔一段时间去调用B的查询api去查询,或者A提供一个callback api,B执行完之后调用api通知A服务,这俩种方式都处理不是很优雅。

使用MQ可以解决这个问题,A调用B服务后,只需要监听B处理完成的消息,当B处理完成后,会发送一条消息给MQ,MQ会将此消息转发给A服务,这样A服务就不哟循环调用B的查询API,也不用提供一个回调函数,同样B也不需要这些操作,A服务还能及时的得到异步处理成功的消息。

image.png

1.1.3. MQ的分类

1.MQ对比图

image.png
(网上找的图,大家自行对比一下各个消息中间件的优劣好处,根据自己业务进行技术选型)

1.2 RabbitMQ

1.2.1. RabbitMQ的概念

基于AMQP协议,erlang语言开发,是部署最广泛的开源消息中间件,是最受欢迎的开源消息中间件之一。

1.2.2. 四大核心概念

1. 生产者

产生数据发送消息的程序时生产者。

2. 交换机(Exchange)

消息枢纽,一方面,接收生产者发送的消息。另一方面,知道如何处理消息,例如递交给某个特别队列、递交给所有队列、或是将消息丢弃。到底如何操作,取决于Exchange的类型。交换机的一类一般会有4种

2.1 Direct Exchange

直连交换机:将一个或多个队列绑定到交换机上,该交换机会通过你设置的路由key(routingKey)去往指定的队列种投送消息,消费者再通过监听指定的某个队列去获取到消息。实现消息的传递。

image.png

2.2 fanout交换机

扇出型交换机:生产者将消息发送给交换机后,fanout交换机 会以广播的形式,把消息发送给绑定该交换机上的每一个队列,例如:用户注册了账号密码后,需要将注册成功的消息发送到用户的手机和邮件上,只需要将手机这个队列以及邮件这个队列绑定到该交换机上,消费者监听这俩个队列后,就知道用户注册了信息,就需要去做出相应的业务操作。

image.png

2.3 topic交换机

主题交换机:该交换机与direcrt交换机有点类似,也是通过匹配路由键的形式往指定的消息的队列去发送消息,但是它与

direcrt交换机不同的地方在于,它的匹配原则更加的灵活多变,有点类似与模糊匹配。路由键(routeing key)种有3种特殊符号分别为 : ***** ,# , .(点)。

其中:*****指代的 就是有且只有一个占位符,# 代表这0个或者多个占位符, **.**用于分割路由键。

例如: 现在有三个队列绑定在topic交换机中,绑定的路由键分别为: #.sms.# , #.blog.#,#.email.*

现在生产者需要发送消息。如图

image.png

根据匹配原则, #.sms.# , #.blog.#,#.email.* 三者队列都会拿到交换机投递的 消息。

那如果routingkey变成com.sms.blog.email了呢 。这种情况下,那么能拿到的消息的队列就是 #.sms.#, #.blog.#,而#.email.*在交换机那拿不到信息,因为 * 号代表email后面必须指定一段路由。而email后面没有多余的路由了,所以就导致了交换机与该队列并没有绑定成功。

image.png

2.4 Header交换机

头交换机,不处理路由键。而是根据发送的消息内容中的headers属性进行匹配。在绑定Queue与Exchange时指定一组键值对;当消息发送到RabbitMQ时会取到该消息的headers与Exchange绑定时指定的键值对进行匹配;如果完全匹配则消息会路由到该队列,否则不会路由到该队列。headers属性是一个键值对,可以是Hashtable,键值对的值可以是任何类型。而fanout,direct,topic 的路由键都需要要字符串形式的。

匹配规则x-match有下列两种类型:

x-match = all :表示所有的键值对都匹配才能接受到消息

x-match = any :表示只要有键值对匹配就能接受到消息

image.png

3. 队列(queue)

RabbitMQ内部使用的一种数据结构,本质上是一个消息缓冲区域,可以接收交换机派发的多个生产者的消息,也可以将消息发送给多个消费者。同时一个队列也可以绑定多个不同类型的交换机。

4. 消费者

大多时候是一个等待接收消息的程序,生产者,消费者,消息中间件很多时候都不是在同一台机器上,同一个应用程序既可以是生产者也可以是消费者。

1.2.2. RabbitMQ的消息模型

image.png

image.png

1. 简单模式

一个队列一个或多个消费者,当多个消费者同时监听一个队列时,他们不能同时消费一条消息,而是随机消费消息,即一个队列中的一条消息,只能被一个消费者消费。

image.png

1.1 配置类
1
2
3
4
5
6
7
8
9
10
11
12
13
java复制代码@Configuration
public class RabbitSimpleConfig {
public static final String SIMPLE_QUEUE = "simple_queue";
@Bean
public Queue simpleQueue(){
/**
* 参数明细
* 1.队列的名称
* 2.是否持久化
*/
return new Queue(SIMPLE_QUEUE, true, false, false);
}
}
1.2 生产者
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
java复制代码@Component
public class RabbitSimpleService {

@Autowired
RabbitTemplate rabbitTemplate;

public void sendSimpleMessage(){

String msg = "简单模式------------当前时间为:" + new Date().getTime();
//这里交换机为空,但是rabbitmq会选择一个direct默认交换机。
rabbitTemplate.convertAndSend("",RabbitSimpleConfig.SIMPLE_QUEUE,msg);
System.out.println("简单模式-----信息发送成功");
}

}
1.3 消费者
1
2
3
4
5
6
7
8
java复制代码@Component
public class RabbitSimpleConsumer {

@RabbitListener(queues = "simple_queue")
public void simpleReceiveMsg(String msg){
System.out.println("simple模式----消费者1接收到消息为:" + msg);
}
}
1.4 结果图

注意:俩个消费者同时监听的一个队列,但是只有一个消费者能拿到消息去消费。

image.png

image.png

2. 工作模式(Work Queues)

image.png
Work Queues与简单模式相比,多了一个或一些消费端,多个消费端共同消费一个队列中的消息,默认情况下,RabbitMQ将按顺序将每个消息发送给 下一个使用者。平均而言,每个消费者都会收到相同数量 的消息。这种分发消息的方式称为循环

应用场景:对于任务国中或任务较多情况使用工作队列可以提高任务处理的速度。

2.1 配置类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
java复制代码@Configuration
public class RabbitWorkConfig {

public static final String WORK_QUEUE = "work_queue";
@Bean
public Queue workQueue(){
/**
* 参数明细
* 1.队列的名称
* 2.是否持久化
* 3.
* 4.是否自动删除
*/
return new Queue(WORK_QUEUE, true, false, false);
}
}
2.2 生产者
1
2
3
4
5
6
7
8
9
10
11
12
13
14
java复制代码@Component
public class RabbitWorkService {

@Autowired
RabbitTemplate rabbitTemplate;

public void sendWorkMessage() {
for (int i = 0; i < 10; i++) {
String msg = i + "--工作模式------------当前时间为:" + new Date().getTime();
rabbitTemplate.convertAndSend("", RabbitWorkConfig.WORK_QUEUE, msg);
System.out.println("工作模式-----信息发送成功--" + i);
}
}
}
2.3 消费者

默认为消费者轮询消费信息

1
2
3
4
5
6
7
8
9
10
11
12
13
java复制代码@Component
public class RabbitWorkConsumer {

@RabbitListener(queues = "work_queue")
public void simpleReceiveMsg(String msg){
System.out.println("work模式----消费者1接收到消息为:" + msg);
}

@RabbitListener(queues = "work_queue")
public void simpleReceiveMsg2(String msg){
System.out.println("work模式----消费者2接收到消息为:" + msg);
}
}
2.4 效果图

image.png

image.png

3. 发布订阅模式

发布订阅模式: 1.每个消费者监听自己的队列。

2.生产者将消息发给交换机,由交换机发给指定绑定的队列,这样保证每个绑定交换机队列 都可以收到消息

相比与前面的模型,多了一个exhange交换机的角色,但是实际上前面也用到了交换机,只不过我们并没有指定出来,如果没有去指定就会去使用rabbitmq提供的默认交换机default.direct

一般发布订阅模式都会使用fanout交换机去进行使用。

3.1 配置类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
java复制代码@Configuration
public class RabbitMQFanoutConfig {
//1.声明创建交换机
@Bean
public FanoutExchange fanoutExchange(){
/**
* 参数明细
* 1.交换机的名称
* 2.是否持久化
* 3.是否自动删除
*/
return new FanoutExchange(RabbitConstant.FANOUT_EXCHANGES, true, false);
}
//2.声明队列
//发送短信的队列
@Bean
public Queue smsQueue(){
return new Queue(RabbitConstant.QUEUE_FORM_SMS,true);
}

//发送博客的队列
@Bean
public Queue blogQueue(){
return new Queue(RabbitConstant.QUEUE_FORM_BLOG,true);
}
//发送邮件的队列
@Bean
public Queue emailQueue(){
return new Queue(RabbitConstant.QUEUE_FORM_EMAIL,true);
}
//3.绑定交换机和队列
@Bean
public Binding smsBinding(){
return BindingBuilder.bind(smsQueue()).to(fanoutExchange());
}
@Bean
public Binding blogBinding(){
return BindingBuilder.bind(blogQueue()).to(fanoutExchange());
}
@Bean
public Binding emailBinding(){
return BindingBuilder.bind(emailQueue()).to(fanoutExchange());
}
}
3.2 生产者
1
2
3
4
5
java复制代码    public void sendFanoutMsg(){
String msg = "注册成功了,发起fanout模式的消息,您的用户是张三,id是"+ UUID.randomUUID();
rabbitTemplate.convertAndSend(RabbitConstant.FANOUT_EXCHANGES,"",msg);
System.out.println("发送消息成功");
}
3.3 消费者
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
java复制代码@Service
public class RabbitFanoutComsumer {

@RabbitListener(queues = {RabbitConstant.QUEUE_FORM_BLOG})
public void blogReceiveMsg(String message){
System.out.println("博客消费者收到了消息:" + message);
}

//1.@RabbitListener标注在方法上,直接监听指定的队列,此时接收的参数需要与发送市类型一致
//2.@RabbitListener 标注在类上面表示当有收到消息的时候,就交给 @RabbitHandler 的方法处理,根据接受的参数类型进入具体的方法中
@RabbitListener(queues = {RabbitConstant.QUEUE_FORM_SMS})
public void ssmReceiveMsg(String message){
System.out.println("短信消费者收到了消息:" + message);
}

@RabbitListener(queues = {RabbitConstant.QUEUE_FORM_EMAIL})
public void emailReceiveMsg(String message){
System.out.println("邮件消费者收到了消息:" + message);
}
}
3.4 效果图

image.png

image.png

4. routing模式

路由模式:一般用于direct交换机,队列与交换机的绑定,不能是任意绑定了,而是要指定一个 RoutingKey 。当生产者向交换机递交消息的时候,会传递一个RoutingKey,交换机再根据路由键去进行匹配接收消息的队列。

image-20220713145153769转存失败,建议直接上传图片文件

4.1 配置类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
java复制代码@Configuration
public class RabbitMQDirectConfig {
//1.声明创建交换机
@Bean
public DirectExchange directExchange(){
/**
* 参数明细
* 1.交换机的名称
* 2.是否持久化
* 3.是否自动删除
*/
return new DirectExchange(RabbitConstant.DIRECT_EXCHANGES, true, false);
}
//2.声明队列
//发送短信的队列
@Bean
public Queue smsDirectQueue(){
return new Queue(RabbitConstant.QUEUE_FORM_SMS,true);
}
//发送博客的队列
@Bean
public Queue blogDirectQueue(){
return new Queue(RabbitConstant.QUEUE_FORM_BLOG,true);
}
//发送邮件的队列
@Bean
public Queue emailDirectQueue(){
/**
* 参数明细
* 1.队列的名称
* 2.是否持久化
*/
return new Queue(RabbitConstant.QUEUE_FORM_EMAIL,true);
}
//3.绑定交换机和队列
@Bean
public Binding smsDirectBinding(){
return BindingBuilder.bind(smsDirectQueue()).to(directExchange()).with("sms");
}
@Bean
public Binding blogDirectBinding(){
return BindingBuilder.bind(blogDirectQueue()).to(directExchange()).with("blog");
}
@Bean
public Binding emailDirectBinding(){
return BindingBuilder.bind(emailDirectQueue()).to(directExchange()).with("email");
}
}
4.2 生产者
1
2
3
4
5
6
7
8
9
java复制代码    //发送字符串类型的数据
public void sendDirectMsg(){
String msg = "注册成功了,发起direct模式的消息,您的用户是张三,id是"+ UUID.randomUUID();
//给短信发送消息
rabbitTemplate.convertAndSend(RabbitConstant.DIRECT_EXCHANGES,"sms",msg);
//给博客发送消息
rabbitTemplate.convertAndSend(RabbitConstant.DIRECT_EXCHANGES,"blog",msg);
System.out.println("发送消息成功");
}
4.3 消费者
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
java复制代码@RabbitListener(queues = {RabbitConstant.QUEUE_FORM_BLOG})
public class RabbitBlogDirectConsumer {



@RabbitHandler
public void receiveMsg(String msg){
System.out.println("博客消费者接收到消息:" + msg);
}

@RabbitHandler
public void receiveMsg(byte[] msg){
System.out.println("博客消费者接收到消息:" + new String(msg));
}
}


@RabbitListener(queues = {RabbitConstant.QUEUE_FORM_EMAIL})
public class RabbitEmailDirectConsumer {



@RabbitHandler
public void receiveMsg(String msg){
System.out.println("邮件消费者接收到消息:" + msg);
}

@RabbitHandler
public void receiveMsg(byte[] msg){
System.out.println("邮件消费者接收到消息:" + new String(msg));
}
}

@RabbitListener(queues = {RabbitConstant.QUEUE_FORM_SMS})
public class RabbitSMSDirectConsumer {



@RabbitHandler
public void receiveMsg(String msg){
System.out.println("短信消费者接收到消息:" + msg);
}

@RabbitHandler
public void receiveMsg(byte[] msg){
System.out.println("短信消费者接收到消息:" + new String(msg));
}
}
4.4 效果图

image.png

image.png

5. topic模式

主题模式:一般使用主题交换机,Topic 类型与 Direct 相比,都是可以根据 RoutingKey 把消息路由到不同的队列。只不过 Topic 类型Exchange 可以让队列在绑定 Routing key 的时候使用通配符

Routingkey 一般都是有一个或多个单词组成,多个单词之间以”.”分割,例如: sms.blog

通配符规则:
1.# :匹配一个或多个词
2.* :匹配不多不少恰好1个词

image.png

5.1 配置类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
java复制代码@Configuration
public class RabbitMQTopicConfig {
@Bean
public TopicExchange topicExchange(){
return new TopicExchange(RabbitConstant.TOPIC_EXCHANGES);
}
//2.声明队列
//发送短信的队列
@Bean
public Queue smsTopicQueue(){
return new Queue(RabbitConstant.QUEUE_FORM_SMS,true);
}
//发送博客的队列
@Bean
public Queue blogTopicQueue(){
return new Queue(RabbitConstant.QUEUE_FORM_BLOG,true);
}
//发送邮件的队列
@Bean
public Queue emailTopicQueue(){
return new Queue(RabbitConstant.QUEUE_FORM_EMAIL,true);
}
//3.绑定交换机和队列
@Bean
public Binding smsTopicBinding(){
//其中 # 代表这匹配0个,一个或者多个, *代表有且只能一个
return BindingBuilder.bind(smsTopicQueue()).to(topicExchange()).with("#.sms.#");
}
@Bean
public Binding blogTopicBinding(){
return BindingBuilder.bind(blogTopicQueue()).to(topicExchange()).with("#.blog.#");
}
@Bean
public Binding emailTopicBinding(){
return BindingBuilder.bind(emailTopicQueue()).to(topicExchange()).with("#.email.*");
}
}
5.2 生产者
1
2
3
4
5
6
java复制代码    public void sendTopicMsg(){
String msg = "注册成功了,发起Topic模式的消息,您的用户是张三,id是"+ UUID.randomUUID();
//给短信发送消息
rabbitTemplate.convertAndSend(RabbitConstant.TOPIC_EXCHANGES,"com.blog.email.sms",msg.getBytes());
System.out.println("发送消息成功");
}
5.3 消费者
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
java复制代码@RabbitListener(queues = {RabbitConstant.QUEUE_FORM_BLOG})
public class RabbitBlogTopicConsumer {



@RabbitHandler
public void receiveMsg(String msg){
System.out.println("博客消费者接收到topic消息:" + msg);
}

@RabbitHandler
public void receiveMsg(byte[] msg){
System.out.println("博客消费者接收到topic消息:" + new String(msg));
}
}

@RabbitListener(queues = {RabbitConstant.QUEUE_FORM_EMAIL})
public class RabbitEmailTopicConsumer {



@RabbitHandler
public void receiveMsg(String msg){
System.out.println("邮件消费者接收到topic消息:" + msg);
}

@RabbitHandler
public void receiveMsg(byte[] msg){
System.out.println("邮件消费者接收到topic消息:" + new String(msg));
}
}

@RabbitListener(queues = {RabbitConstant.QUEUE_FORM_SMS})
public class RabbitSMSTopicConsumer {



@RabbitHandler
public void receiveMsg(String msg){
System.out.println("短信消费者接收到topic消息:" + msg);
}

@RabbitHandler
public void receiveMsg(byte[] msg){
System.out.println("短信消费者接收到topic消息:" + new String(msg));
}
}
5.4 效果图

image.png

image.png

6.RPC模式

对于某些业务场景,我们希望在使用rabbitMQ后,不仅仅只是需要通知到服务端,也需要服务端给客户端一个返回的结果,这里就需要使用到RPC模式。(这个事实上工作会用的很少,要了解的就自力更生了哦~)

1.2.3 消息确认机制

为了保证消息队列的可靠的达到消费者,RabbitMQ提供了消息的确认机制,

(1)手动确认

消费者在订阅队列中,可以指定autoAck的参数,如果该参数为false,则表示为手动确认,rabbitMq会等待消费者显式地回复确认信号才从内存(或者磁盘中)移除。

采用消息确认机制后,只要设置autoaAck参数为false,消费者就会有足够的时间去处理业务逻辑,不用担心消费者再处理消息的时候突然挂掉导致消失丢失的问题,因为rabbitMQ会一直等待消费者去显示的调用确认的方法,并且rabbitMQ也不会为未确认的消息设置过期的时间

(2)自动确认

如果为true,RabbitMQ会自动把消息设置为确认,然后在内存中删除,而不管消费者是否真的消费完成。这就是自动自动确认

最后

大致就介绍这么多了,大部分都是一些对rabbitMQ一些基础的使用和了解,其实实际工作中需要了解的不仅于此,后续有空还会补充一些rabbitMQ一些更全面的功能,以及一些实际中功能的实践。

本文转载自: 掘金

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

【Android 13源码分析】WMS-添加窗口(addWi

发表于 2024-03-13

在了解完Activity启动流程后,现在目标应用的进程已经启动了,但是离用户在屏幕上看到Activity下的UI内容还有一段距离。
一个窗口想要显示在屏幕上,还需要经过3大步骤:

窗口显示三部曲.png

    1. addWindow流程这一步是创建 WindowState,并且挂载到窗口树上。
    1. relayout流程addWindow执行后,WindowState就创建和挂载好了,但是WindowState毕竟也是一个容器,没有真正的UI内容。
      执行relayout流程时会触发真正持有UI数据的Surface的创建,然后会将这个Surface返回到应用进程,应用进程在进行View的绘制三部曲。
      除了创建Surface的逻辑,relayoutWindow流程还会触发窗口位置的摆放逻辑。
    1. finishDrawing流程真正显示到屏幕上的内容不是Activity也不是Window,而是View树绘制的内容。
      经过上面2步,应用进程已经有Surface了, 并且执行绘制三部曲,也就是说View树的UI数据也就都绘制到Surface下的buff中了。绘制完成就需要通知SurfaceFlinger进行合成了,
      只有经过SurfaceFlinger处理后,才能真正显示到屏幕上。

绝大部分情况下,一个窗口的显示,这三步是是必经流程。(开机动画是直接通过SurfaceFlinger绘制的)

本篇分析第一步:addWindow流程

当前为Activity短暂的一生系列的第三块内容, 建议先看完 WindowContainer窗口层级 和Activity启动流程

  1. 概述

先对比一下一个应用启动后窗口的区别:

启动Activity后层级结构树对比.png

红色部分就是启动应用后多出来的部分,在 DefaultTaskDisplayArea 节点下多出来这么一个层级:

1
2
3
arduino复制代码Task
ActivityRecord
WindowState (就是那个 9c20028)

其中 Task 和 ActivityRecord 是如何挂载上去的在Activity启动流程已经介绍了,本篇要分析的 addWindow 的流程最重要的就是搞明白窗口对应的WindowState是如何创建并且挂载到窗口树中的。

整个流程框图如下:

addWindow一级框图.png

    1. 应用进程首先会创建出一个的Window
    1. 执行WindowManagerGlobal::addView方法,最终触发ViewRootImpl::setView方法来触发夸进程通信,通知WMS执行addWindow逻辑
    1. 应用和WMS通信是通过Session这个类,具体是调用了 Session::addToDisplayAsUser这个方法
    1. system_server进程的WMS执行addWindow方法时,会根据参数创建出一个WindowState,并且将其挂载到对应的WindowToken下(也就是挂载到窗口树中)

后面的内容也是围绕着这4点做详细解释首先介绍App进程的处理,然后介绍system_server进程的处理。

  1. APP进程流程

先看应用层是需要做哪些事:应用进程启动后,会执行LaunchActivityItem和ResumeActivityItem 这2个事务,对应执行到Activity的onCreate和onResume生命周期,这其中肯定也涉及到了Window相关的操作。

调用链如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
arduino复制代码LaunchActivityItem::execute
ActivityThread::handleLaunchActivity
ActivityThread::performLaunchActivity
Instrumentation::newActivity --- 创建Activity
Activity::attach --- 创建Window
Window::init
Window::setWindowManager
Instrumentation::callActivityOnCreate
Activity::performCreate
Activity::onCreate

ResumeActivityItem::execute
ActivityThread::handleResumeActivity
ActivityThread::performResumeActivity
Activity::performResume
Instrumentation::callActivityOnResume
Activity::onResume
WindowManagerImpl::addView --- 创建ViewRootImpl
WindowManagerGlobal::addView
ViewRootImpl::setView --- 与WMS通信 addView

根据这个调用链可知:先执行onResume,再执行addView。所以执行了onResume只是Activity可见,不代表View都显示了,可能都还没触发WMS的绘制,如果后续的任何一个地方出了问题,我们写在XML里的布局都不会显示出来。(以前写App的时候以为执行了onResume屏幕上就显示UI了)

2.1 创建Window逻辑

这块的执行是在 LaunchActivityItem 事务的执行流程中,到Activity的onCreate的生命周期之间, 具体的位置可以看上面的调用链,开始撸代码.

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
ini复制代码# ActivityThread
private Activity performLaunchActivity(ActivityClientRecord r, Intent customIntent) {
......
// 定义window
Window window = null;
if (r.mPendingRemoveWindow != null && r.mPreserveWindow) {
// 正常不执行这里
window = r.mPendingRemoveWindow;
r.mPendingRemoveWindow = null;
r.mPendingRemoveWindowManager = null;
}
......
// 注意token传递的是ActivityRecord的token
// 这里的window正常逻辑为null
activity.attach(...,r.token,, window, ...);
......
}

# Activity
final void attach(......) {
......
// 创建PhoneWindow
mWindow = new PhoneWindow(this, window, activityConfigCallback);
// 一些设置
mWindow.setWindowControllerCallback(mWindowControllerCallback);
// 留意这边将Activity设置为setCallback
mWindow.setCallback(this);
mWindow.setOnWindowDismissedCallback(this);
mWindow.getLayoutInflater().setPrivateFactory(this);
......
// 设置window的token为 ActivityRecord
mToken = token;
......
mWindow.setWindowManager(
(WindowManager)context.getSystemService(Context.WINDOW_SERVICE),
mToken, mComponent.flattenToString(),
(info.flags & ActivityInfo.FLAG_HARDWARE_ACCELERATED) != 0);

if (mParent != null) {
mWindow.setContainer(mParent.getWindow());
}
mWindowManager = mWindow.getWindowManager();
......
}

我们在Activity通过getWindow方法返回的就是这个mWindow,首先得确认这个,别分析了半天分析错了Window对象,毕竟framework的代码这么多。

这里有个面试点,在Activity::attach中看到mWindow 原來是一个PhoneWindow的对象,PhoneWindow是Window的唯一子类,Window是个抽象类,所以也确实没有办法直接new。

然后是一堆设置,这里需要注意 setCallback 方法,是将Activity设置给了Window,这里有什么用呢? 像configruation的改变和input事件的传递流程都是先走到Window的,因为在WMS模块没有Activity的概念,只有Window,那么最后是怎么走到Activity呢?就是这里设置的setCallback。当然这个在当前分析的addWindow流程没有关系,但是需要有点印象。

再下面的setWindowManager和getWindowManager两个方法也很有意思,因为咋一看有点矛盾,在一个地方set又get感觉很多余,因为这里set和get返回的对象,其实不是同一个对象。

2.1.1 setWindowManager,getWindowManager

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
ini复制代码# Window
// 应用Token
private IBinder mAppToken;

// wm :WindowManager对象,注意下传进来的值
// appToken :这个就是AMS中与当前Activity对应的ActivityRecord
// appName :Activity全类名
public void setWindowManager(WindowManager wm, IBinder appToken, String appName,
boolean hardwareAccelerated) {
// 将ActivityRecord设置给mAppToken
mAppToken = appToken;
mAppName = appName;
mHardwareAccelerated = hardwareAccelerated;
if (wm == null) {
wm = (WindowManager)mContext.getSystemService(Context.WINDOW_SERVICE);
}
// 根据强制也能看出 mWindowManager 是WindowManagerImpl的类型
mWindowManager = ((WindowManagerImpl)wm).createLocalWindowManager(this);
}
public WindowManager getWindowManager() {
return mWindowManager;
}

这里将传递进来的 wm, 强转成WindowManagerImpl 后调用其 createLocalWindowManager方法。

该函数重新创建返回了一个 WindowManagerImpl 对象。 所以说setWindowManager 和 setWindowManager 的不是同一个对象, WindowManagerImpl::createLocalWindowManager方法如下:

1
2
3
4
5
6
7
8
9
10
ini复制代码# WindowManagerImpl
public WindowManagerImpl createLocalWindowManager(Window parentWindow) {
return new WindowManagerImpl(mContext, parentWindow, mWindowContextToken);
}
private WindowManagerImpl(Context context, Window parentWindow,
@Nullable IBinder windowContextToken) {
mContext = context;
mParentWindow = parentWindow;
mWindowContextToken = windowContextToken;
}

这边注意的是将 Window 设置给了 mParentWindow。 相当于通过新创建的PhonWindow创建了一个WindowManagerImpl,作为其mWindowManager的对象。

到这里创建Window相关的就分析完了,创建的这个Window其实是 PhoneWindow 接下来看APP层的addWindow是如何触发的。

建议要理清楚 Window, PhoneWindow ,WindowManagerImpl,WindowManager这几个类的区别和联系,不要搞混了。

2.2 addWindow相关

执行到onCreate之前, 已经创建好了Window,所以我们在Activity::onCreate可以把我们的XML布局设置过去,也能通过 getWindow来做一些操作。

Window创建好后就要执行 addWindow逻辑了,根据调用链,是 ResumeActivityItem事务触发的,这个事务最终会执行到 Activity::onResume生命周期。

接下来看一遍代码的执行流程:

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
ini复制代码# ActivityThread
public void handleResumeActivity(ActivityClientRecord r, boolean finalStateRequest,
boolean isForward, String reason) {
......
// 触发onResume
if (!performResumeActivity(r, finalStateRequest, reason)) {
return;
}
......
// 拿到activity
final Activity a = r.activity;
......
if (r.window == null && !a.mFinished && willBeVisible) {
// 将本地的window设置到activityRecord中
r.window = r.activity.getWindow();
// 获取DecorView
View decor = r.window.getDecorView();
// 设置不可见 在后面调用Activity::makeVisible会设为可见
decor.setVisibility(View.INVISIBLE);
ViewManager wm = a.getWindowManager();
// 获取参数
WindowManager.LayoutParams l = r.window.getAttributes();
// DecorView设置给Activity
a.mDecor = decor;
// 设置Activity的windowType,注意这个type,才是应用的窗口类型
l.type = WindowManager.LayoutParams.TYPE_BASE_APPLICATION;
......
if (a.mVisibleFromClient) {
if (!a.mWindowAdded) {
// 重点:执行addView,并设置mWindowAdded=true
a.mWindowAdded = true;
wm.addView(decor, l);
} else {
a.onWindowAttributesChanged(l);
}
}

} else if (!willBeVisible) {
if (localLOGV) Slog.v(TAG, "Launch " + r + " mStartedActivity set");
r.hideForNow = true;
}
}

主要是一些属性的设置然后执行addView, 这里比较需要注意的就是Activity的windowType为TYPE_BASE_APPLICATION = 1, 还有个TYPE_APPLICATION=2,目前已知是在创建ActivityRecord时使用。
然后我们已经知道wm就是WindowManagerImpl了,但是不应该是addWindow么,怎么成addView了呢?接着去看下面流程。

1
2
3
4
5
6
7
8
9
10
less复制代码# WindowManagerImpl
// 单例
private final WindowManagerGlobal mGlobal = WindowManagerGlobal.getInstance()

@Override
public void addView(@NonNull View view, @NonNull ViewGroup.LayoutParams params) {
applyTokens(params);
mGlobal.addView(view, params, mContext.getDisplayNoVerify(), mParentWindow,
mContext.getUserId());
}

这个方法并没有啥复杂的,直接调到了WindowManagerGlobal,不过这里也有2个需要注意的点:

    1. WindowManagerGlobal是个单例,那就是说一个进程仅此一个
    1. 这里将mParentWindow传递了过去,上面分析的时候知道这个mParentWindow其实就是我们创建的PhoneWindow
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
csharp复制代码# WindowManagerGlobal

public void addView(View view, ViewGroup.LayoutParams params,
Display display, Window parentWindow, int userId) {
......
final WindowManager.LayoutParams wparams = (WindowManager.LayoutParams) params;
if (parentWindow != null) {
// 调整window参数,设置token,比如title,和硬件加速的标志位
parentWindow.adjustLayoutParamsForSubWindow(wparams);
} else {
......
}
ViewRootImpl root;
View panelParentView = null;
synchronized (mLock) {
......
// 这一段的意思是如果执行过addView的话,再执行就报错
int index = findViewLocked(view, false);
if (index >= 0) {
if (mDyingViews.contains(view)) {
// Don't wait for MSG_DIE to make it's way through root's queue.
mRoots.get(index).doDie();
} else {
throw new IllegalStateException("View " + view
+ " has already been added to the window manager.");
}
// The previous removeView() had not completed executing. Now it has.
}
......
IWindowSession windowlessSession = null;
......
// 对应应用来说windowlessSession是为null的
if (windowlessSession == null) {
// 重点* 创建ViewRootImpl
root = new ViewRootImpl(view.getContext(), display);
} else {
root = new ViewRootImpl(view.getContext(), display,
windowlessSession);
}
// 设置参数到 decorView
view.setLayoutParams(wparams);
// 添加到对应集合,看得出来在WindowManagerGlobal中这3个对象应该是要一一对应的
mViews.add(view);
mRoots.add(root);
mParams.add(wparams);
// do this last because it fires off messages to start doing things
try {
// 重点 * 调用ViewRootImpl::setView
root.setView(view, wparams, panelParentView, userId);
} catch (RuntimeException e) {
// BadTokenException or InvalidDisplayException, clean up.
if (index >= 0) {
removeViewLocked(index, true);
}
throw e;
}
}
}

这段代码最重要的就是做了2件事:

    1. ViewRootImpl的创建 (ViewRootImpl在整个WMS系统中是非常重要的一个类)
    1. 执行ViewRootImplL::setView方法, 这里也是应用进程处理的终点,剩下的就是跨进程交给WMS处理了

2.3 ViewRootImpl::setView

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
scss复制代码# ViewRootImpl

final IWindowSession mWindowSession;

public void setView(View view, WindowManager.LayoutParams attrs, View panelParentView,
int userId) {
synchronized (this) {
// 当前第一次执行肯定为null
if (mView == null) {
mView = view;
......
mAdded = true; // 表示已经add
int res; // 定义稍后跨进程add返回的结果
requestLayout(); // 非常重要的方法--请求布局更新
InputChannel inputChannel = null; // input事件相关
if ((mWindowAttributes.inputFeatures
& WindowManager.LayoutParams.INPUT_FEATURE_NO_INPUT_CHANNEL) == 0) {
inputChannel = new InputChannel();
}
......
try {
......
// 重点* 这里通过binder通信,调用WMS的 addWindow方法
res = mWindowSession.addToDisplayAsUser(mWindow, mWindowAttributes,
getHostVisibility(), mDisplay.getDisplayId(), userId,
mInsetsController.getRequestedVisibilities(), inputChannel, mTempInsets,
mTempControls);
......
}
// 后续流程与addWindow主流程无关,但是也非常重要
......
// 计算window的尺寸
final Rect displayCutoutSafe = mTempRect;
state.getDisplayCutoutSafe(displayCutoutSafe);
final WindowConfiguration winConfig = getConfiguration().windowConfiguration;
mWindowLayout.computeFrames(mWindowAttributes, state,
displayCutoutSafe, winConfig.getBounds(), winConfig.getWindowingMode(),
UNSPECIFIED_LENGTH, UNSPECIFIED_LENGTH,
mInsetsController.getRequestedVisibilities(),
getAttachedWindowFrame(), 1f /* compactScale */, mTmpFrames);
setFrame(mTmpFrames.frame);
......
if (res < WindowManagerGlobal.ADD_OKAY) {
......// 对WMS调用后的结果判断是什么错误
}
......
view.assignParent(this); //这就是为什么decorView调用getParent返回的是ViewRootImpl的原因
......
}
}
}

这个方法是核心方法,处理了很多事,都加载备注上了。为了有一个宏观的印象,这里将其触发的各个调用链整理出来。

1
2
3
4
5
6
7
8
9
10
11
12
13
arduino复制代码ViewRootImpl::setView
ViewRootImpl::requestLayout
ViewRootImpl::scheduleTraversals
ViewRootImpl.TraversalRunnable::run --- Vsync相关--scheduleTraversals
ViewRootImpl::doTraversal
ViewRootImpl::performTraversals
ViewRootImpl::relayoutWindow --- relayoutWindow
ViewRootImpl::performMeasure --- View绘制三部曲
ViewRootImpl::performLayout
ViewRootImpl::performDraw
ViewRootImpl::createSyncIfNeeded --- 绘制完成finishDrawing
WindowSession.addToDisplayAsUser --- addWindow
WindowLayout::computeFrames --- 窗口大小计算

这里要注意:虽然看顺序好像 addWindow流程是在relayoutWindow执行前,但是因为 doTraversal是异步的,所以还是先执行addWindow流程的。

回到当前主题,继续跟踪addWindow流程,也就是addToDisplayAsUser,看来上面的setView方法还有2个点不清楚:

  1. mWindowSession是什么?
  2. 参数里的mWindow是什么?

2.3.1 ViewRootImpl的mWindowSession是什么

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
arduino复制代码# ViewRootImpl

final W mWindow;
final IWindowSession mWindowSession;

public ViewRootImpl(Context context, Display display) {
this(context, display, WindowManagerGlobal.getWindowSession(),
false /* useSfChoreographer */);
}
public ViewRootImpl(@UiContext Context context, Display display, IWindowSession session,
boolean useSfChoreographer) {
mContext = context;
mWindowSession = session;
......
mWindow = new W(this);
......
}

WindowManagerGlobal::addView下构造ViewRootImpl的通过2个参数的构造方法,所以他的session就是WindowManagerGlobal.getWindowSession()

这里提一嘴,既然会这么设计,那么说明在Framework中肯定不是这一种方式获取session,比如画中画就是另一种,以后会提到,当前留个印象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
java复制代码# WindowManagerGlobal

@UnsupportedAppUsage
public static IWindowSession getWindowSession() {
synchronized (WindowManagerGlobal.class) {
if (sWindowSession == null) {
try {
// Emulate the legacy behavior. The global instance of InputMethodManager
// was instantiated here.
// TODO(b/116157766): Remove this hack after cleaning up @UnsupportedAppUsage
InputMethodManager.ensureDefaultInstanceForDefaultDisplayIfNecessary();
IWindowManager windowManager = getWindowManagerService();
sWindowSession = windowManager.openSession(
new IWindowSessionCallback.Stub() {
@Override
public void onAnimatorScaleChanged(float scale) {
ValueAnimator.setDurationScale(scale);
}
});
} catch (RemoteException e) {
throw e.rethrowFromSystemServer();
}
}
return sWindowSession;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
scala复制代码# WindowManagerService
@Override
public IWindowSession openSession(IWindowSessionCallback callback) {
return new Session(this, callback);
}
# Session

class Session extends IWindowSession.Stub implements IBinder.DeathRecipient {
final WindowManagerService mService;
......
@Override
public int addToDisplayAsUser(IWindow window, WindowManager.LayoutParams attrs,int viewVisibility, int displayId, int userId, InsetsVisibilities requestedVisibilities,
InputChannel outInputChannel, InsetsState outInsetsState,
InsetsSourceControl[] outActiveControls) {
return mService.addWindow(this, window, attrs, viewVisibility, displayId, userId,
requestedVisibilities, outInputChannel, outInsetsState, outActiveControls);
}
}

所以这里的WindowManagerGlobal::getWindowSession返回的就是一个Session对象。Session继承IWindowSession.Stub,并且内部持有WMS引用。

2.3.2 ViewRootImpl的mWindow是什么

调用addToDisplayAsUser这个方法传递的mWindow,他并不是前面分析的Activity的那个Window,上面mWindow也是在ViewRootImpl的构造方法里赋值的。那这个W是什么呢?

1
2
3
4
5
6
7
8
9
10
11
scala复制代码# ViewRootImpl

static class W extends IWindow.Stub {
private final WeakReference<ViewRootImpl> mViewAncestor;
private final IWindowSession mWindowSession;

W(ViewRootImpl viewAncestor) {
mViewAncestor = new WeakReference<ViewRootImpl>(viewAncestor);
mWindowSession = viewAncestor.mWindowSession;
}
}

所以这里的mWindow只是一个内部类W的对象,这个W继承了IWindow.Stub,那也是用例binder通信的,W内部有一个ViewRootImpl弱引用。

2.4 APP进程小结

addWindow的流程,在APP进程到此就结束了,后面的逻辑由WMS执行。

2.4.1 二级框图

addWindow二级框图.png

应用进程启动后,会执行2个事务,分别触发到Activity的 onCreate和onResume2个常见的生命周期,所以这里分为了2个分支。

onCreate 分支:

    1. 首先肯定是要创建Activity的
    1. 然后创建出Window,Window是抽象类,PhoneWindow是Window的唯一实现类。
    1. 执行到 onCreate 生命周期

onResume 分支:

    1. 先触发了 onResume 的执行流程
    1. 执行WindowManagerImpl::addView
    1. 创建核心类 ViewRootImpl
    1. 执行关键函数 ViewRootImpl::setView ,跨进程通信后续流程就由WMS执行了

2.4.2 知识点小结:

一个进程内有个Window的总管家:WindowManagerGlobal。当Activity创建并初始化PhoneWindow后,WindowManagerImpl会调用WindowManagerGlobal的addView方法,将后续流程交给其处理。

WindowManagerGlobal会创建一个ViewRootImpl,WindowManagerGlobal内部还有3个集合,将Window的DecorView,参数LayoutParams和新创建的ViewRootImpl一一添加到对应的集合中。

后续流程由ViewRootImpl进行,ViewRootImpl的addView方式会通过Session最终调用到WMS的addWindow方法。

    1. Activity里的window其实是PhoneWindow,因为Window是抽象类,而PhoneWindow是其唯一子类
    1. Window的windowManager是WindowManagerImpl,内部有2个重要成员,DevordViw和WindowManagerImpl,Window本身并没有内容所以DevordViw才是UI的实际载体
    1. WindowManagerGlobal是单例类,一个进程只有一个,内部维护了3个集合

还有3个重要的方法:

Activity::attach

    1. 创建了PhoneWindow
    1. 设置了WindowManagerImpl作为WindowManager

WindowManagerGlobal::addView

    1. 创建ViewRootImpl
    1. 执行ViewRootImpl::setView

ViewRootImpl::setView

    1. IWindowSession::addToDisplayAsUser : 代表着addWindow流程在App进程结束,后面由WMS进行
    1. requestLayout:请求更新布局,触发relayoutWindow流程
    1. computeFrames : 计算窗口大小

ViewRootImpl::setView 这个方法比较长,这也是我目前比较熟悉的几个事情,也许还有重要分支被我忽略了。

其实有一个疑问,明明addWindo流程,但是到了WindowManagerImpl就变成了addView,传递的也是DecoreView,再到和WMS同信的时候,参数里连DecoreView都不剩了,这怎么能叫addWindow流程呢?
带着这个疑问下一篇将介绍WMS到底是怎么做的。

【Android 13源码分析】WMS-添加窗口(addWindow)流程-1-应用进程处理

【Android 13源码分析】WMS-添加窗口(addWindow)流程-2-SystemService进程处理

本文转载自: 掘金

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

H5推送,为什么都用WebSocket?

发表于 2024-03-13
大家好,我是石头~


最近大盘在3000点附近磨蹭,我也随大众去网上浏览了下感兴趣的几只股票,看下行情怎样。
看了一会,还是垃圾行情,不咋地,不过看着页面上的那些实时刷新分时图和五档行情,倒是想起公司以前就因为这个实时数据刷新的问题,差点引起一次生产事故。

HTTP轮询差点导致生产事故

那是一个给用户展示实时数据的需求,产品的要求是用户数据发生变动,需要在30秒内给客户展示出来。


当时由于数据展示的页面入口较深,负责的后端开发就让H5通过轮询调用的方式来实现数据刷新。


然而,由于客户端开发的失误,将此页面在APP打开时就进行了初始化,导致数据请求量暴涨,服务端压力大增,差点就把服务端打爆了。

fa7049166c79454eb87f3890d1aa6f4b.webp

H5推送,应该用什么?

既然用HTTP做实时数据刷新有风险,那么,应该用什么方式来实现?


一般要实现服务端推送,都需要用到长连接,而能够做到长连接的只有WebSocket、UDP和TCP,而且,WebSocket是在TCP之上构建的一种高级应用层协议。大家觉得我们应该用哪一种?


其实,大家只要网上查一下,基本都会被推荐使用WebSocket,那么,为什么要用WebSocket?

u=2157318451,827303453&fm=253&fmt=auto&app=138&f=JPEG.webp

为什么要用WebSocket?

这个我们可以从以下几个方面来看:
  • 易用性与兼容性:WebSocket兼容现代浏览器(HTML5标准),可以直接在H5页面中使用JavaScript API与后端进行交互,无需复杂的轮询机制,而且支持全双工通信。而TCP层级的通信通常不适合直接在纯浏览器环境中使用,因为浏览器API主要面向HTTP(S)协议栈,若要用TCP,往往需要借助Socket.IO、Flash Socket或其他插件,或者在服务器端代理并通过WebSocket、Comet等方式间接与客户端通信。
  • 开发复杂度与维护成本:WebSocket已经封装好了一套完整的握手、心跳、断线重连机制,对于开发者而言,使用WebSocket API相对简单。而TCP 开发则需要处理更多的底层细节,包括但不限于连接管理、错误处理、协议设计等,这对于前端开发人员来说门槛较高。
  • 资源消耗与性能:WebSocket 在建立连接之后可以保持持久连接,减少了每次请求都要建立连接和断开连接带来的资源消耗,提升了性能。而虽然TCP连接也可以维持长久,但如果是自定义TCP协议,由于没有WebSocket的标准化复用和优化机制,可能在大规模并发场景下,资源管理和性能控制更为复杂。
  • 移动设备支持:WebSocket在移动端浏览器上的支持同样广泛,对于跨平台的H5应用兼容性较好。若采用原生TCP,移动设备上的兼容性和开发难度会进一步加大。

websocket01.jpg

结论

综上所述,H5实时数据推送建议使用WebSocket,但是在使用WebSocket的时候,大家对其安全机制要多关注,避免出现安全漏洞。

**MORE | 更多精彩文章**

  • JWT重放漏洞如何攻防?你的系统安全吗?
  • JWT vs Session:到底哪个才是你的菜?
  • JWT:你真的了解它吗?
  • 别再这么写POST请求了~
  • 揭秘布谷鸟过滤器:一场数据过滤中的“鸠占鹊巢”大戏!

本文转载自: 掘金

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

在Jetpack Compose中父组件如何调用子组件的函数

发表于 2024-03-13

原文地址:# 在Compose中父组件如何调用子组件的函数?

写在前面

本文中提及的use开头的函数,都出自与我的 ComposeHooks 项目,它提供了一系列 React Hooks 风格的状态封装函数,可以帮你更好的使用 Compose,无需关系复杂的状态管理,专心于业务与UI组件。

这是系列文章的第已篇,全部文章:

  • 在Compose中使用useRequest轻松管理网络请求
  • 在Compose中使用状态提升?我提升个P…Provider
  • 在Compose中父组件如何调用子组件的函数?
  • 在Compose中方便的使用MVI思想?试试useReducer!

咋一看标题你可能会觉得这有什么好研究的,请仔细看我的描述:在父组件中调用子组件的函数!

众所周知,如果我们希望让子组件调用父组件的函数,可以如下方式:

  • 传递函数参数给子组件的方式, 最常用,但是嵌套层级多了之后很麻烦
  • 通过 useContext 向子组件暴露,参考在Compose中使用状态提升?我提升个P…Provider

那么问题来了,父组件如何调用子组件的函数呢?

Compose 不同于传统的 View 体系,每一个组件都是@Composable注解的函数,没有实例对象,无法对外暴露函数。

你可能会说,父组件调用子组件函数有必要么?

试想这样一个场景,我们有一个复杂的展示页面,里面有 banner轮播图、用户信息、资讯List 等等,他们被放在一个大的可滑动组件中,现在我们要实现这个可滑动组件下拉刷新。

这时事件不再是有子组件发出(不同于”状态向下、事件向上”),而是父组件发出的,此时应该如何处理?

一个简单的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
kotlin复制代码
@Composable
fun Container() {
Column {
TButton("refresh"){
TODO() //如何实现
}
(0..10).map {
SubComponent(index = it)
}
}
}

@Composable
fun SubComponent(index:Int) {
val (state, setState) = useState(0.0)
//刷新函数在子组件
fun refresh() {
setState(Random.nextDouble())
}
Column {
Row {
Text(text = "index $index: $state")
TButton(text = "refresh") {
// 子组件可以轻松的刷新自己
refresh()
}
}
Divider(modifier = Modifier.fillMaxWidth())
}
}

方法1:继续状态提升↑!!

我们可以继续贯彻状态提升的思想,父组件的刷新只改变顶层状态,由子组件监听状态变化,实现刷新:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
kotlin复制代码@Composable
fun Container() {
// 状态提升到父组件
val (isRefresh, _, setIsRefresh) = useBoolean(false)
val scope = rememberCoroutineScope()
Column {
TButton("refresh") {
scope.launch {
setIsRefresh(true)
delay(1.seconds)
setIsRefresh(false)
}
}
//.............
}
}

@Composable
fun SubComponent(index: Int, refreshState: Boolean) {
val (state, setState) = useState(0.0)
fun refresh() {
setState(Random.nextDouble())
}
// 子组件监听状态
useEffect(refreshState) {
if (refreshState) {
refresh()
}
}
//.............
}

这样做当然是OK的,代码也可以完美运行,但是我们需要给所有组件加上新的参数,以及 useEffect 监听状态变化;

原始代码同样存在我在上一篇文章中提到的问题,一旦层级过多,就会存在大量中间传递,如果修改就牵一发动全身。

如果你想用这种方法,我建议使用 useContext 进行优化:

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复制代码val RefreshContext = createContext(false)

@Composable
fun Container() {
//.............
Column {
TButton("refresh") {
//.............
}
// 通过 Provider 向子组件暴露状态
RefreshContext.Provider(isRefresh){
(0..10).map {
SubComponent(index = it)
}
}
}
}

@Composable
fun SubComponent(index: Int) {
//.............
// 刷新状态通过 useContext获取
val refreshState =useContext(context = RefreshContext)
useEffect(refreshState) {
if (refreshState) {
refresh()
}
}
//.............
}

方法2:使用 useEvent

为什么叫这个名字是因为它的使用有一点类似 EventBus 的订阅发布模式,我们先来看改造后的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
kotlin复制代码@Composable
fun Container() {
val post = useEventPublish<Unit>()
Column {
TButton("refresh") {
post(Unit)
}
(0..10).map {
SubComponent(index = it)
}
}
}

@Composable
fun SubComponent(index: Int) {
val (state, setState) = useState(0.0)
fun refresh() {
setState(Random.nextDouble())
}
useEventSubscribe { _: Unit ->
refresh()
}
//.............
}

使用方法大致如下:

  1. 使用 useEventPublish<Unit>() 拿到一个 post 发布函数,

尖括号中的是我们需要传递的Event参数类型,由于这里只是简单演示,我直接使用了 Unit
2. 在需要订阅的事件的组件中使用useEventSubscribe注册一个函数

注意在闭包中声明正确的类型!
3. 发布者调用 post 函数,发布事件

与使用 useContext 相似,我们可以用它来解耦父子组件之间的关系,无需担心层级嵌套的问题。

但是同时也要注意,useEventSubscribe 在组件不可见时是自动反注册的,所以需要在可见范围内才能正确的响应。

现在我们可以回答标题的问题了,父组件如何调用子组件函数:

  1. 无法直接调用,因为组件是函数
  2. 通过状态提升,父组件修改状态。子组件监听状态变化间接调用
  3. 使用useEvent相关钩子,通过订阅发布模式,解耦组件关系、实现跨组件通信

Tips

Tips: useEvent 更多是为了用于跨组件通信,并不只是用来解决父组件调用子组件函数哦,post 函数可以传递任意实例。它和 EventBus 是几乎相同的。相同事件类型的订阅者,会接收到相同的事件发布。

探索更多

项目开源地址:junerver/ComposeHooks

MavenCentral:hooks

1
kotlin复制代码implementation("xyz.junerver.compose:hooks:1.0.5")

欢迎使用、勘误、pr、star。

本文转载自: 掘金

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

1…484950…956

开发者博客

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