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

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


  • 首页

  • 归档

  • 搜索

让面试官眼前一亮的深拷贝 前言 JSONparse(JSO

发表于 2024-04-01

前言

此前聊过深浅拷贝问题面试官:手写一个浅拷贝和一个深拷贝(拷贝详解) - 掘金 (juejin.cn)

但是关于深拷贝的解释不够详细,故出此期文章

拷贝指的是将一个引用类型的元素复制到一个新的对象中

拷贝又分为深拷贝和浅拷贝,浅拷贝只拷贝对象的引用地址,深拷贝会层层拷贝每个属性值,不受原对象修改值的影响

大白话理解:浅拷贝拷贝得不深,副本会受原对象的影响,深拷贝拷贝的深,副本不受原对象的影响

浅拷贝常见方法:

  1. Object.assign
  2. 解构
  3. concat
  4. slice

深拷贝常见方法:

  1. JSON.parse(JSON.stringify)
  2. structuredClone

JSON.parse(JSON.stringify)

这个方法仅仅是刚好有深拷贝的效果,先将对象转换成JSON格式,又将JSON格式转换成js对象

1.png

已经看到了它的深拷贝效果,但是这个方法是有缺陷的

它拷贝BigInt大整型会报错,可能因为太大了吧doge

4.png

另外无法拷贝Symbol、function、undefined、null

无法拷贝Symbol其实合情合理,Symbol的含义就是独一无二的值,怎么能被复制?

2.png

它还不能拷贝对象的循环引用

看下面这个🌰,这么写就相当于obj.a.b 又被赋值成了 obj.a,成了一个环

3.png

总结

缺陷:

无法拷贝BigInt(报错)、对象的循环引用(报错)、Symbol、function、undefined

structuredClone

这个方法是官方在2021年打造的structuredClone() - Web API 接口参考 | MDN (mozilla.org)

以前js深拷贝只能用JSON那个方法,但是近几年官方推出了个这个新的方法专门用于深拷贝

这个方法的实现原理就是用的postMessage通信实现的,postMessage可以用来解决跨域

此前跨域文章提到过详谈跨域 - 掘金 (juejin.cn)

先看下不能拷贝什么

5.png

Symbol无法拷贝,合理

6.png

嚯~函数也不能拷贝

其实就这两个东西不能拷贝,其余都可以,包括JSON无法拷贝的BigInt、undefined、对象循环引用

7.png

已经非常完美了,symbol无法拷贝合情合理,但是函数为何不拷贝,我也不理解

总结

缺陷:

无法拷贝Symbol和function(均会报错)

手写拷贝函数

既然官方打造的structuredClone无法拷贝函数,那就自己手写一个,正好,前阵子面试就被问到过这个问题,当时的想法就是先把函数转成字符串,然后再转回来

当然实际情况还需要考虑到this指向,因此需要用call掰一下

1
2
3
4
5
6
7
8
9
10
11
javascript复制代码let fn = function () {
console.log('hello world')
}

function copy (fn) {
let foo = fn.toString()
return new Function(`return ${foo}`).call(fn)
}

let foo = copy(fn)
foo() // hello world

我们直接写一个new Function会得到一个匿名函数,如果往里面传值,里面的值会放入到函数体内,因此只要再return一下就可以把fn拿出来调用,这才有了上面的写法

8.png

手写递归深拷贝

1
2
3
4
5
6
7
8
9
10
11
12
13
vbnet复制代码function deepCopy(obj) {
const objCopy = Array.isArray(obj) ? [] : {}
for (let key in obj) {
if (obj.hasOwnProperty(key)) {
if (obj[key] instanceof Object) {
objCopy[key] = deepCopy(obj[key])
} else {
objCopy[key] = obj[key]
}
}
}
return objCopy
}

注意,这里拿到key,一定不能.key,得是[key],否则副本身上复制到的key真就是key了,用中括号拿到key就是当成变量,用点拿到key就是当成字符串,当然用中括号也可以拿到字符串,自行添加一个字符串即可

另外,如果遍历到的value发现还是引用类型,那么就递归,递归得到的东西再return掉即可

遍历对象一般用for in,而不是for of,for of只能遍历具有迭代起属性的对象,普通对象没有,但是for in这个方法有个缺陷,就是会遍历得很深,可以拿到对象原型身上的方法,通常情况下我们不会去拷贝人家原型身上的方法,因此需要用hasOwnProperty这个方法来隔绝掉,这个方法返回布尔值,对象身上显示具有的属性才为true,不具有的或者隐式具有(原型上)均为false

MessageChannel

管道通信MessageChannel - Web API 接口参考 | MDN (mozilla.org)

这个方法和JSON类似,不是用来做深拷贝的,只不过刚好可以这样被处理成深拷贝的效果

这个方法会创建一个隧道,这样就有两个端口,在其中一个端口喊话,另一个端口就可以收到

这个方法是个异步方法,也就是new MessageChannel需要等待,我用await去解决这个异步,既然用上了await,await右边的函数得是promise才行,因此写成promise的形式

但是这个方法既然是通信,所以你运行完需要终止它,并且只能手动终止

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
javascript复制代码let obj = {
a: {
b: 1
}
}

function deepCopy(obj) {
return new Promise((resolve) => {
const { port1, port2 } = new MessageChannel() // 对象解构的key不能乱写
port1.postMessage(obj) // 喊话obj

obj.a.b = 2 // 测试深拷贝

port2.onmessage = function (msg) { // msg就是port1喊话的内容
resolve(msg.data) // msg被包裹了一层data
}
})
}

async function fn () {
let objCopy = await deepCopy(obj)
console.log(objCopy);
}

fn() // { a: { b: 1 } }

最后

知道管道通信的人不多,面试官要是问你深拷贝的实现,你还能把这个方法讲给面试官听,面试官肯定非常满意

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

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

本文转载自: 掘金

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

服了,一线城市的后端都卷成这样了吗!? 先听TA的故事 北京

发表于 2024-04-01

声明:本文首发在同名公众号:王中阳Go,未经授权禁止转载。

先听TA的故事

投稿的主人公是一名工作5年的后端开发工程师,最近2年用Golang,之前其他语言。去年春节前被裁员了,各种心酸史,好愁人啊。

刚开始找的特别费劲,简历已读不回,也不知道怎么做准备更好。在撞了很多南墙之后,终于摸到了门道,开始能约到面试了。

然后更难顶的事情发生了:经过各种努力和约面,我拿到了北京的两个offer,但是深圳一个都没拿到,我自己更倾向在深圳工作的,实在实在没办法才会去北京。

深圳这边的工作很卷,越面试考察越难,刚开始那几家我还扛得住,主要是八股和算法,后来不少公司更多的是考察各种各样的场景题,甚至还有公司问我如果让我带一个5~10人小团队做项目会考虑哪些事情?

我真是服气了,我只是一个想找20K工作的gopher程序员,要求已经这么高了吗?太卷了。。。。

在各种学习各种突击、踏踏实实提高自己之后,目前已经成功上岸深圳的公司了,在这里真心分享一个靠谱的经验:别想着速成,踏踏实实的提高自己才是王道。

秉承着好人有好报的原则:我授权阳哥把我最近面试了十几家公司,将近30场面试的面经和经验都分享出来,希望对大家有帮助,希望阳哥的粉丝们都能顺利上岸!把我这份好运传递下去!!!

下面开始秀一下最新面经:

北京外包-掌阅科技

面试题:

  1. 自我介绍
  2. 介绍一下你参与的模块的业务以及架构设计 交易流程
  3. 说一下微信支付流程
  4. 介绍一下你最了解的业务的技术实现 做了哪些业务封装?解决了哪些问题?
  5. mongodb的集合是什么?文档是什么?
  6. mongodb的底层数据结构是什么?怎么实现的?怎么存储的?

编程题写代码

使用go实现1000个并发控制并设置执行超时时间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
go复制代码func worker(c context.Context, wg *sync.WaitGroup, id int) {

defer wg.Done()

select {
case <-time.After(time.Second):
fmt.Sprintln("执行完成 %", id)
case <-c.Done():
fmt.Sprintln("请求超时 %", id)
}
}

func main() {

ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()

var wg sync.WaitGroup
wg.Add(1000)

for i := 0; i < 1000; i++ {
go worker(ctx, &wg, i)
}

wg.Wait()
}

北京自研-小刀万维

  1. 了解基础信息 学历、年龄、经验、老家哪里、为什么来北京、为什么离职
  2. 自我介绍
  3. 说一下你负责最多模块的业务?用户中心和交易中心业务
  4. 技术团队多少人?
  5. 项目上线了吗?用户群体多少人?是tob项目?
  6. 介绍一下你涉及的技术栈
  7. go是怎么分配内存的?go启动的时候怎么去划分的?划分为几个区域
  8. 碰到过内存泄露没有?什么情况下会内存泄露?如何去定位排查?
  9. 怎么通过go控制并发数?
  10. 原子操作了解过吗?
  11. map数据结构了解过吗?如何实现数据扩容?
  12. map是不是并发安全的?
  13. map删除一个key 内存会不会释放?
  14. 你平常是怎么学习的?看过什么go相关的书没有吗?
  15. 你短期的目标和长期目标是怎么规划的?计划在哪里定居?
  16. 你还有什么想问的吗?技术团队组成
  17. 你对公司有没有什么要求?

杭州晶绮信息科技有限公司

  1. 自我介绍
  2. 说一个你做的/遇到的一个比较有意思的功能聊一下
  3. 除了偏移量还有什么其它优化的方式吗?
  4. 覆盖索引在mysql如何实现的?底层原理是什么?
  5. 聚簇索引和非聚簇索引在B+树里面存储有什么区别?
  6. B+树里面叶子节点的数据结构是怎么样的?
  7. 联合索引在什么场景下会失效?大概有哪几种情况会失效会违背最左原则?
  8. 做过哪些场景下做过哪些SQL优化
  9. 会使用EXPLAIN?怎么使用的?你会关注哪些指标?重点哪些指标出现异常你会比较注重?
  10. 你项目中kafka中主要的应用场景是什么?
  11. kafka中Partition有了解吗?
  12. kafka如何做幂等处理的?
  13. kafka里面的offset概念你了解吗?
  14. 你们Redis用来做什么的?
  15. 缓存和数据库的一致性如何做的?
  16. 你们有做熔断、限流、降级相关的操作吗?你们业务中哪些地方用到了这些?怎么做的?底层原理是怎么样的?
  17. mongodb有做分片吗?
  18. mongodb设计索引会考虑什么?会考虑分片来设计索引吗?
  19. map的底层数据结构可以说一下吗?
  20. slice底层数据结构是怎么样的?如何扩容?
  21. 程序写完之后你们如何做测试的?
  22. 你们项目搞完之后如何部署的?有了解吗?如何做热更新?如何优雅启停?

编程题

  1. 说下打印顺序
1
2
3
4
5
6
7
8
9
10
11
12
13
go复制代码func main() {
a := []int{1, 2, 3}
b := a[:2]

b = append(b, 4)
fmt.Println(a) // 1,2,4,3

b = append(b, 5)
fmt.Println(a) // 1,2,5,4,3

b[0] = 10
fmt.Println(a) // 1,2,10,4,3
}
  1. 使用go实现一个set 为什么要使用struct{}来做map 的value
  2. 启动3个goroutine 循环100次顺序打印123
  3. 编写一个程序限制10个goroutine执行,每执行完一个goroutine就放一个新的goroutine进来

深圳自研线下万领钧

  1. 自我介绍
  2. 如何部署
  3. jwt
  4. gc
  5. redis用来做什么
  6. http和grpc的区别

杭州云智创心

  1. grpc底层用的什么协议?http2.0和1.1有什么区别?
  2. RPC有几种请求模式?同步请求和异步请求
  3. protobuf了解过吗?和json有什么区别?对比json有什么优势?压缩率对比json来说能达到多少?
  4. 数组和slice的区别是什么?
  5. 使用var 定义一个slice不make能使用吗?
  6. go里面有几种方式可以解决并发安全问题?
  7. mysql 单值索引和联合索引 各自的优缺点?
  8. 如何判断一个字段是否不适合建立索引?
  9. 如果让你去设计一个消息队列你会怎么去设计?
  10. 分布式的环境下需要做数据一致性的话,你有几种设计方案?
  11. 我说了TCC,他继续问这个是强一致性,如果需要最终一致性需要怎么处理?
  12. 服务熔断、限流、治理是怎么使用的?用的第三方框架还是自己去做的
  13. 如果让你去设计一个限流器你怎么去设计?

北京易诚高科推易车网

编程题:

写代码实现两个 goroutine,其中一个产生随机数并写入到 go channel 中,另外一个从 channel 中读取数字并打印到标准输出。最终输出五个随机数。

  1. 如何优化MySQL的?除了索引和锁还有其它优化方式吗?
  2. 索引失效的场景?
  3. redis用到了什么数据类型?应用在项目什么地方?
  4. slice切片扩容说一下
  5. 说一下go里面的内存回收?
  6. 说一下GMP并发模型
  7. docker什么地方用到了,会哪些命令,了结的流程
  8. 介绍一下你项目,说下你负责的地方
  9. 说一下库存超卖的设计思路

北京兆殷特集团外包推易鑫集团

  1. 自我介绍
  2. 介绍简历第一个项目,深入挖掘里面的业务细节聊了接近20分钟
  3. 分布式项目你缓存数据更新怎么做?本地的cache如何更新的
  4. kafka如何确认消息消费成功了?如果ack出问题了呢?如果没确认成功
  5. 一个订单被多次消费有吗?如何解决?消费消息的代码逻辑是怎么样的?
  6. redis数据类型一般用的什么?go里面redis用的第三包用的哪个?
  7. 哈希的过期时间怎么做的?面试管一直说他用的包没有设置过期时间的功能 这块需要了解下过期时间底层如何设计的
  8. 分库分表有用吗? 这边答用了mongodb自动扩展 没有用mysql所以避免了分库分表相关问题
  9. 项目中ES是怎么用的?ES如何优化的?ES的的数据类型
  10. 介绍一下你第二个项目
  11. 说下你文章表的表结构是怎么样的?文章内容如何存储?
  12. 文章内容搜索功能有做吗?怎么做的
  13. protobuf中怎么存储数组切片的?
  14. 文章中点赞数是怎么做的? 针对点赞数设计方案问细节
  15. 你们项目有没有做一些防爬的机制?怎么做的?
  16. redis有遇到丢数据的情况?如何解决?
  17. 切片的底层数据结构是怎么样的?底层数据是存储在堆上还是栈上?
  18. map的底层数据结构是怎么样的?
  19. 空interface的底层数据结构是怎么样的?
  20. 问了下平常用的一些web框架,但没有深入问框架里面的细节?
  21. 框架中的熔断是怎么做的?
  22. mysql的数据库事务隔离级别
  23. mysql事务的的原子性是什么
  24. 一条SQL的具体执行过程可以说说吗?
  25. 去日志文件搜索错误信息 linux命令说下

杭州默安科技

  1. 自我介绍
  2. 介绍一下你的项目,说一下你负责的模块,说里面你觉得设计的比较好的地方 说了一个微服务之间一致性问题,又接着问还有没有,又继续说了 高并发下避免库存超扣 这两个场景追问细节
  3. go map底层 sync.map底层实现
  4. go 有map哈希冲突的可能性?你会怎么解决?
  5. 互斥锁和读写锁区别?读写优先级一堆扯底层原理 一直有goroutine占有读锁/写锁 是不是会有读锁/写锁被阻塞 这个是挖坑题目 然后聊到后面让我结合GMP讲这个锁
  6. 将GMP的东西大致都讲了一遍,后面一直问是不是先进先出然后我说了分片执行,得根据场景来判断
  7. 自旋和GMP结合来讲?自旋解决了什么问题?
  8. mysql索引执行顺序 多个索引mysql如何选择哪个索引先执行
  9. 删除索引会怎么处理?会重构索引树吗?
  10. 索引为什么快?
  11. 有大量的IP格式数据?假如让你设计高效查询,你会怎么设计
  12. mongodb为什么比mysql快,从哪些方面体现出来

杭州稻壳网络

  1. gorm的使用 锁怎么用 sqlx那些用过没
  2. 分布式数据一致性的问题怎么处理?问细节
  3. ES的使用
  4. go-zero和kratos的区别
  5. mysql存储json数据

北京蓝标传媒

  1. 自我介绍
  2. 介绍项目负责模块,深挖业务,分布式事务一致性场景 下单和其它服务数据一致性
  3. 假如你去设计订单服务的时候,你是怎么去组织你代码的一个结构的,如何如何代码会考虑哪些点
  4. 重复支付怎么设计处理
  5. 你们用的kratos,代码是怎么分层的,详细问data层做了些内容
  6. 说下你的职业规划
  7. 设计模式有了解过吗

杭州爱果酱

  1. 自我介绍
  2. 介绍下项目背景,难点,方案 一直抠项目细节
  3. 库存超卖问题设计思路?库存如何更新
  4. 库存更新怎么做的呢?分布式锁
  5. 分布式锁如何设计?setnx和setex有什么区别?如何续期
  6. map、channel底层原理

北京 Runner建霖家居

  1. 自我介绍
  2. 二级缓存cache 数据一致性如何保证 reids呢,如何保持数据一致性?
  3. 分布式锁如何设计的?追问细节,问的很细,实现细节
  4. 你用mongodb的过程中有没有遇到什么问题? mysql和mongodb你觉得有什么区别?你觉得mongodb和mysql哪个性能更好,你怎么看待?mongodb你们这边最大并发怎么样 追问细节
  5. 分布式下单场景数据一致性 如何设计? 追问细节
  6. 社区项目中文章是怎么存储的?内容怎么存储
  7. 文章如何做缓存的?全部缓存进去吗?
  8. 然后介绍第三个项目业务,简历三个项目都问到了
  9. 数组和切片的区别
  10. 说一下内存逃逸?
  11. 函数入参的话,你觉得什么时候适合传值类型什么时候时候传指针类型?
  12. 进程、线程、协程的区别

深圳及刻

  1. nacos是AP还是CP,你们项目中如何使用的?
  2. 说下你们微服务框架的执行流程?
  3. 介绍下你负责模块的业务流程?如何实现的
  4. 库存超卖设计思路说一下 问的超细
  5. 表数据多大?用什么存储?mongodb支持事务吗?
  6. 分布式数据一致性设计思路 问的超细
  7. 微服务限流怎么做?有没有了解底层实现
  8. 消息幂等性如何设计?
  9. 消息队列宕机之后重启怎么知道它上次消费到哪里?offset记录?offset数据存储在哪里?
  10. 用kafka有遇到什么问题?消息堆积 业务流程
  11. 微信支付流程说下 追问里面一些细节 回调方法逻辑 加锁处理
  12. 分布式锁设计思路
  13. 提高QPS你会从哪些方面去设计
  14. mongodb索引底层数据结构是什么?
  15. B树和B+树的区别?
  16. 回表是什么意思?如何减少回表?除了覆盖索引和索引下推还有其它方式吗? 使用主键查询
  17. 使用二级缓存的流程是怎么样的?有了解过go-cache底层用什么数据结构存储的吗?
  18. 你们接入了多个第三方平台?什么设计的? 应该是想问下使用策略模式来实现
  19. ES使用场景?数据怎么放入到ES中的去?如何保证数据一致性的问题
  20. 假如在不影响业务的情况下,让你来设计一个数据迁移的方案你怎么设计?老数据要同步新进来的数据也要考虑
  21. channel了解过吗?项目中哪些场景用到了?你认为channel是个什么东西?channel有几种?channel关闭之后再去读会怎么样?如何知道channel关闭了呢?
  22. 一个主服务同时去调用多个子服务,其中一个服务关闭之后 就中断所有子服务执行 你如何设计实现这个需求
  23. channel底层数据结构是什么?问细节
  24. Mutex底层实现原理?是公平锁还是非公平的锁?饥饿模式下数据是通过什么存储的?
  25. 队列和栈有什么区别?如果让你来实现一个栈你怎么实现?
  26. git的命令、linux命令
  27. redis的持久化如何实现的?redis有遇到什么问题?
  28. redis的淘汰策略什么?你们用的哪个?lru和lfu的区别
  29. 如何做一个切片去重?
  30. 项目中nginx怎么用的?反向代理 负载均衡怎么做的?负载均衡里面的原理有了解吗?
  31. mysql事务隔离级别?你们用的是哪个?有什么问题?RR如何解决可重复读

深圳线上Ximmerse

  1. 自我介绍
  2. go并发有哪些同步机制
  3. channel什么情况下什么时候会发生死锁
  4. 协程泄露是指什么?如何排查协程泄露
  5. 代码调优的手段?代码层面如何做协程调优
  6. mysql调优说一下?覆盖索引和单列索引的优缺点?
  7. mongodb如何存储大文件
  8. nginx和apisit的区别
  9. k8s如何去做路由
  10. 如何大数据导出?导数据把服务拖垮了你会怎么办?怎么定位到具体的代码行?

深圳线上网心科技

  1. 自我介绍 && 个人职业规划 && 最近在看什么书
  2. http每个版本更新的点有了解吗?http状态码了解吗
  3. https如何做中间人攻击
  4. 数据库事务隔离级别讲一下
  5. 在使用mysql的过程中需要注意哪些问题?表设计、索引设计、事务使用、更新表结构、sql注入预编译
  6. redis哪些数据结构用的多一些?跳表了解?redis主从复制了解过吗
  7. redis使用的过程中有些什么需要注意的点? 一直问还有吗
  8. go的内存管理了解吗?针对这个内存管理在实际编码中有什么需要避开的吗? 一直问还有吗
  9. channel缓存和无缓冲区别

一起上岸!

我们搞了一个免费的后端面试真题共享群,互通有无,一起刷题进步。

没准能让你能刷到自己意向公司的最新面试题呢。

感兴趣的朋友们可以加我微信:wangzhongyang1993,备注:面试群。

本文首发在我的同名公众号:王中阳Go,未经授权禁止转载。

本文转载自: 掘金

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

在Jetpack Compose中像使用redux一样轻松管

发表于 2024-04-01

原文地址:# 在Compose中像使用redux一样轻松管理全局状态

写在前面

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

这是系列文章的第五篇,前文:

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

也许只有 useContext 、useReducer 是不够的

书接上回,上次的文章发布后有小伙伴在评论区留言,谈到了关于在组件之间保存状态的问题,我也给予了回答,那就是使用 useContext 进行进一步的状态提升。

但是在之前版本的ComposeHooks 并没有很方便的方式让我们讲 useContext 与 useReducer 配合起来,只使用这二者在暴露多个状态时非常麻烦。

现在全新版本的 redux-react 风格的 hook来啦,它就是 useSelector、useDispatch,现在你可以轻松的构建全局状态,并通过这两个钩子轻松的跨组件获取状态与dispatch函数。它是基于 useContext 的进一步封装,现在你可以不需要再自己去配置 useContext 来管理状态暴露了!

如何使用

关于 reducer 的概念,请先查阅 在Compose中方便的使用MVI思想?试试useReducer!,一些概念我们就不再赘述了,这里依旧使用我最喜欢的 Todos 作为案例 (在Compose中使用状态提升?我提升个P…Provider)。

暴露全局状态

首先我们在根组件使用 ReduxProvider 组件暴露全局状态存储。

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复制代码data class Todo(val name: String, val id: String)

sealed interface TodoAction
data class AddTodo(val todo: Todo) : TodoAction
data class DelTodo(val id: String) : TodoAction

val todoReducer: Reducer<List<Todo>, TodoAction> = { prevState: List<Todo>, action: TodoAction ->
   when (action) {
       is AddTodo -> buildList {
           addAll(prevState)
           add(action.todo)
      }
       is DelTodo ->  prevState.filter { it.id != action.id }
  }
}

val store = createStore {
   todoReducer with emptyList() // 使用中缀函数 with 来连接reducer函数与初始状态
}

@Composable
fun UseReduxExample() {
   ReduxProvider(store = store) {
     
  }
}

前面的代码大抵差别不大,可以看作是使用 useReducer 的 MVI 改造。

关键点在于:val store = createStore { },这里我们需要通过 createStore 函数创建一个全局的状态存储对象,在函数闭包内,我们通过中缀函数 with,连接一个 reducer函数 与一个 初始状态 .

然后我们使用 ReduxProvider(store = store) 将全局状态存储对象进行暴露。

useSelector 获取状态

现在我们的状态已经向下暴露了,因为我们已经将他提升到了 ReduxProvider 在其下的所有组件都可以使用useSelector轻松的获取状态:

1
2
3
4
5
6
7
8
9
kotlin复制代码@Composable
fun TodoList() {
   val todos = useSelector<List<Todo>>() //需要传递 状态 的类型
   Column {
       todos.map {
           TodoItem(item = it)
      }
  }
}

useDispatch 获取 dispatch 函数

只有状态当然是不够的,在 MVI 中我们还需要使用 dispatch函数。

在最新版本中,你可以轻松的通过 useDispatch 来进行获取:

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
kotlin复制代码@Composable
fun Header() {
   val dispatch = useDispatch<TodoAction>() //需要传递 Action 的类型
   val (input, setInput) = useState("")
   Row {
       OutlinedTextField(
           value = input,
           onValueChange = setInput,
      )
       TButton(text = "add") {
           dispatch(AddTodo(Todo(input, NanoId.generate())))
           setInput("")
      }
  }
}


@Composable
fun TodoItem(item: Todo) {
   val dispatch = useDispatch<TodoAction>()
   Row(modifier = Modifier.fillMaxWidth()) {
       Text(text = item.name)
       TButton(text = "del") {
           dispatch(DelTodo(item.id))
      }
  }
}

多个状态

请注意,val store = createStore { } 的闭包中可以传递多个状态,你只需要在这里通过 with 连接 reducer函数 与初始状态,就可以注册暴露多个状态到全局了:

例如:

1
2
3
4
kotlin复制代码val store = createStore {
   otherReducer with OtherData("default", 18) //另一个 reducer
   todoReducer with emptyList()
}

探索更多

项目开源地址:junerver/ComposeHooks

MavenCentral:hooks

1
scss复制代码implementation("xyz.junerver.compose:hooks:1.0.8")

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

本文转载自: 掘金

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

无头组件库既然这么火 🔥 那么我们自己手动实现一个来完成所谓

发表于 2024-04-01

作者:易师傅 、github

声明:本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!

前言

前面咱们已经介绍了,什么是 Headless UI 无头组件库了,以及如何去使用它,我相信同学们看完了之后能够已经在实际项目中运用自如了;

但是我的目的是带领大家能实现一个属于自己的,一个属于公司的、甚至属于公司和个人 KPI 的产物,那么接下来将会手摸手的一步一步指引大家去实现一个真正意义上的 Headless UI 无头组件库;

让我们一起开始动手吧~

说明:为了更好符合国内的大部分用户群体,所以主要实现一个 vue3 的 Headless UI 无头组件库!

一、初始化项目

1. 生成目录 & 初始化

1
2
3
4
5
6
7
8
bash复制代码# 创建目录
mkdir my-project

# 进入
cd my-project

# 初始化
pnpm init

2. 创建 pnpm-workspace.yaml 文件

1
bash复制代码touch pnpm-workspace.yaml

3. 修改 pnpm-workspace.yaml

1
2
3
4
markdown复制代码packages:
- packages/*
- playground
- docs

4. 新增 packages/vue 目录

1
2
3
4
5
6
7
bash复制代码mkdir packages

mkdir packages/vue

cd packages/vue

pnpm init

5. 新增 typescript 依赖

1
2
3
4
5
bash复制代码# 安装到 my-project 根目录下
pnpm i typescript @types/node -Dw

# 初始化
npx tsc --init

6. 新增 README.md 和 LICENSE 文件

1
bash复制代码touch README.md LICENSE

二、安装 eslint 和 simple-git-hooks + commitlint 等基本配置

  1. 配置 eslint 和 @antfu/eslint-config

  1. 安装
1
bash复制代码pnpm i eslint @antfu/eslint-config -Dw

@antfu/eslint-config 是一个由 Anthony Fu 创建的 ESLint 配置包,它包含了 Vue 和 Vanilla JS 项目中常见的最佳实践规则,实际项目可安可不安。
2. 新建 eslint.config.js文件
3. 编辑 eslint.config.js 文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
php复制代码import antfu from '@antfu/eslint-config'

export default antfu({
ignores: ['/dist', '/node_modules', '/packages/**/dist', '/packages/**/node_modules'],
rules: {
'@typescript-eslint/no-non-null-asserted-optional-chain': 'off',
'@typescript-eslint/ban-ts-comment': 'warn',
'@typescript-eslint/consistent-type-definitions': 'off',
'import/first': 'off',
'import/order': 'off',
'symbol-description': 'off',
'no-console': 'warn',
'max-statements-per-line': ['error', { max: 2 }],
'vue/one-component-per-file': 'off',
},
})
  1. 在 package.json 中 script 添加脚本
1
2
bash复制代码"lint": "eslint .",
"lint:fix": "eslint . --fix",
  1. 测试

image.png

  1. 配置 simple-git-hooks 和 lint-staged

  1. 安装
1
bash复制代码pnpm i simple-git-hooks lint-staged -Dw
  1. package.json 中添加脚本
1
2
3
4
5
6
json复制代码script:{
"prepare": "npx simple-git-hooks",
},
"lint-staged": {
"*": "eslint --fix"
}
  1. 配置 @commitlint/config-conventional 和 @commitlint/cli:

  1. 安装
1
bash复制代码pnpm i @commitlint/config-conventional @commitlint/cli -Dw
  1. 在 package.json 中添加配置和脚本
1
2
3
4
5
6
7
8
9
json复制代码"commitlint": {
"extends": [
"@commitlint/config-conventional"
]
},
"simple-git-hooks": {
"pre-commit": "pnpm lint-staged",
"commit-msg": "pnpm commitlint --edit ${1}"
},

4.执行

根据上面的配置,我们在每次修改文件 git 提交后,都会按照以下顺序执行:

  1. npx simple-git-hooks:执行 simple-git-hooks 命令;
  2. pnpm lint-staged:执行 lint-staged 命令;
  3. eslint --fix:执行 eslint 方法,检查所有的代码是否合格;
  4. 正常提交 commit-msg;

比较 simple-git-hooks 与 husky

1. 共同点:

  • 都是用于管理 Git 钩子(Git hooks)的工具,它们可以帮助开发团队在代码提交、推送等操作时运行预定义的脚本或命令。
  • 可以用来执行代码格式化、静态代码分析、单元测试等任务,以确保代码质量和一致性。
  • 都提供了对本地 Git 钩子的支持,包括但不限于 pre-commit、post-commit、pre-push 等钩子。

2. 差异点:

  • 功能和灵活性:
+ `husky` 提供了更多的功能和灵活性,可以在提交前、提交后、推送前等不同的 Git 钩子上运行任务,并且支持与其他工具(如 lint-staged)的集成。
+ `simple-git-hooks` 则专注于简单的 Git 钩子管理,功能相对较少,主要用于运行基本的脚本任务。
  • 配置和定制:
+ `husky` 提供了更丰富的配置选项和定制能力,可以根据项目的需求定义更复杂的钩子行为。
+ `simple-git-hooks` 更注重简单易用,配置相对简单,适合对 Git 钩子管理要求不高的项目。
  • 生态系统和支持:
+ `husky` 在社区中有着更广泛的应用和支持,拥有更丰富的生态系统和插件,可以满足不同需求。
+ `simple-git-hooks` 的用户群体相对较小,生态系统相对简单。

总结:

选择使用 simple-git-hooks 还是 husky 取决于项目的具体需求和团队的偏好。根据项目的规模、复杂度以及对 Git 钩子管理的需求,选择适合的工具可以提高开发流程的效率和代码质量。

三、配置package/vue核心库

  1. 初始化 package/vue

1
2
3
4
5
bash复制代码# 进入目录
cd package/vue

# 初始化
pnpm init
  1. 安装用到的基本库

  • vue: 这个就不解释了
  • vue-tsc: vue-tsc 是对TypeScript 自身命令行界面 tsc 的一个封装。它的工作方式基本和 tsc 一致。
1
bash复制代码pnpm install vue vue-tsc -D
  1. 配置工具库(可选)

  • @vueuse/core:一个针对 Vue.js 生态系统的工具库,旨在提供一组通用的、经过测试的 Vue 3 组合式 API,帮助开发者更轻松地构建 Vue 应用程序。
1
bash复制代码pnpm install @vueuse/core -D
  1. 配置 tsconfig

  • typescript:不解释
  • @tsconfig/node18: 是 TypeScript 中的一个预定义的配置文件,它适用于 Node.js 18 的项目。在 TypeScript 中,可以使用预定义的配置文件来简化项目的配置过程,而不必手动指定所有的编译选项。
  • @vue/tsconfig:一个 TypeScript 配置文件的包装库,用于简化在 Vue.js 项目中配置 TypeScript 的过程。这个包装库提供了一组预定义的 TypeScript 配置,旨在帮助开发者轻松地配置 TypeScript 在 Vue.js 项目中的使用。如果你们不需要它的配置,可以自己写。

1. 安装 typescript 、@tsconfig/node18 和 @vue/tsconfig

1
bash复制代码pnpm install typescript @tsconfig/node18 @vue/tsconfig -D

2. 添加 tsconfig 配置文件

  • tsconfig.json:ts 配置,不解释
1
2
3
4
json复制代码{
"files": [],
"extends": ["./tsconfig.app.json"]
}
  • tsconfig.app.json:定义项目中所需文件的基本ts编译规则
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
json复制代码{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": [
"env.d.ts",
"src/**/*",
"src/**/*.ts",
"src/**/*.tsx",
"src/**/*.vue"
],
"compilerOptions": {
"paths": {
"@/*": ["src/*"],
},
"target": "esnext",
"module": "esnext",
"moduleResolution": "node",
"strict": true,
"jsx": "preserve",
"sourceMap": true,
"resolveJsonModule": true,
"esModuleInterop": true,
"declaration": false,
"lib": ["esnext", "dom"],
"baseUrl": ".",
"skipLibCheck": true,
"outDir": "dist"
}
}
  • tsconfig.build.json:主要用来打包所用的 ts 编译规则,执行 pnpm build 所需规则(代码与 tsconfig.app.json 类似)
  • tsconfig.node.json:专门用来配置vite.config.ts文件的编译规则
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
json复制代码{
"extends": "@tsconfig/node18/tsconfig.json",
"include": [
"vite.config.*",
"vitest.config.*",
"cypress.config.*",
"nightwatch.conf.*",
"playwright.config.*"
],
"compilerOptions": {
"composite": true,
"module": "ESNext",
"types": ["node"]
}
}
  1. 安装 vite 相关

  • vite:这个就不解释了
  • @vitejs/plugin-vue:Vite 的一个插件,用于处理 Vue 单文件组件(SFC)。
  • vite-plugin-dts:一个 Vite 插件,用于自动生成 TypeScript 类型声明文件(.d.ts 文件)并将其输出到构建目录中。
1
bash复制代码pnpm install vite @vitejs/plugin-vue vite-plugin-dts -D

配置 vite.config.ts

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复制代码import { resolve } from 'node:path'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import dts from 'vite-plugin-dts'

// https://vitejs.dev/config/
export default defineConfig({
plugins: [
vue(),
dts({
tsconfigPath: 'tsconfig.build.json',
cleanVueFileName: true,
exclude: ['src/test/**'],
}),
],

build: {
lib: {
name: 'yi-ui',
fileName: 'index',
entry: resolve(__dirname, 'src/index.ts'),
},
},
})
  1. 新建 src & build 测试

创建 src

1
2
3
bash复制代码mkdir src 

touch src/index.ts

编辑 src/index.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
ts复制代码const a = '1111'

const b = '2222'

const fn = () => {
console.log('fn')
}

const add = (a: number, b: number) => {
return a + b
}

export { a, b, fn, add }

添加 package.json 脚本

1
json复制代码"build": "vite build",

build 构建

1
bash复制代码pnpm build

image.png

结果就会生成了 dist目录,如下图的文件

image.png

因为我们要的是能开箱即用,所以 esm、cjs 的文件格式就要配置好

  1. 配置 exports 默认模块

1
2
3
4
5
6
7
8
9
10
11
json复制代码"exports": {
".": {
"types": "./dist/index.d.ts",
"require": "./dist/index.umd.js",
"import": "./dist/index.mjs"
}
},
"main": "./dist/index.umd.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"typings": "./dist/index.d.ts",

这样我们就可以直接在项目中使用 import 或者 require 来使用库了;

例如:

1
2
3
ts复制代码import { add } from '@yi-ui/vue'

add(1, 2)

在下面讲解的 docs文档 和 playground 也会使用到;


到这里一个最最基本的核心库就完成了最基本的搭建,下一步我们考虑的就是测试问题了

四、单元测试

为什么要用单元测试

因为我们写的是一个上层的工具库,所以单元测试是必不可少的;

毕竟单元测试可以验证库的每个功能模块是否按照预期工作;

在开发阶段就能发现和修复问题,而不是等到系统集成测试甚至上线后才发现,这样可以显著降低修复成本。

等等一系列的原因我们都必须得安排上;

因为咱们主要是开发一个 vue 相关的无头组件库,为了更好的适配,所以咱们的选择就必须是 vitest 了 。

vitest 配置

1. 安装

1
2
3
bash复制代码cs package/vue

pnpm install vitest -D

2.新建 vitest.config.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
ts复制代码import { resolve } from 'node:path'
import { defineConfig } from 'vitest/config'
import Vue from '@vitejs/plugin-vue'

const r = (p: string) => resolve(__dirname, p)

export default defineConfig({
plugins: [Vue()],
resolve: {
alias: {
'@': r('./src'),
},
},
})

3. 配置运行脚本:package.json

1
2
3
4
5
bash复制代码"script": {
...
"test": "vitest",
...
}

4. 新建一个 src/index.test.ts 文件

1
2
3
4
5
6
7
8
ts复制代码import { describe, expect, test, it } from 'vitest'
import { add } from './index'

describe('测试', () => {
test('函数返回值', () => {
expect(add(1, 2)).toBe(3)
})
})

5.执行 pnpm test

image.png

五、配置 docs 文档

为什么要用到文档

  1. 提高易用性与可理解性:
* 详细的文档能够帮助开发者快速上手,减少试错的时间,提升开发效率。
  1. 方便安装和配置:
* 文档应当包含安装指南、依赖说明、编译和构建步骤,确保任何技术水平的开发者都能顺利将其集成到他们的项目中。
  1. 示例与教程:
* 包含示例代码和教程的文档能够直观展示组件库的功能如何实际应用,对于初学者尤为重要。
  1. 维护和更新指南:
* 文档应包含版本更新日志、迁移指南等内容,方便开发者跟踪库的最新变化并适应升级过程。
  1. 社区建设与贡献者引导:
* 文档还应包含贡献指南、代码规范、提交PR和issue的流程等,鼓励社区成员参与到开源库的开发与维护工作中来。

安装配置 vitepress

新建 docs 目录

1
2
3
4
5
bash复制代码mkdir docs

cd docs

pnpm init

安装 vitepress & tailwindcss

1
bash复制代码pnpm i vitepress tailwindcss -D

配置 package.json

1
2
3
4
5
bash复制代码"scripts": {
"docs:dev": "vitepress dev",
"docs:build": "vitepress build",
"docs:preview": "vitepress preview"
},

运行 pnpm docs:dev

image.png

到这里其实文档就基本配置好了~

但是还要与我们的核心库做关联,还要有一些基本config配置和样式等等,这些暂时不表,因为涉及的点太多,待后续完善。

六、配置playground

为什么要配置 playground

一句话概括就是:配置 playground 的主要目的是为了提供给开发者一个交互式的、即时反馈的环境,以更加便捷和直观的方式探索和学习该库的功能。

新建 vue3 项目

1
2
3
bash复制代码mkdir playground

cd playground

初始化 vue3 项目

1
2
bash复制代码
pnpm create vite

新建 nuxt 项目

1
2
3
4
bash复制代码
pnpm create vite

# 选择 nuxt

image.png

引入package/vue核心库

配置 playground/vue3 和 nuxt 项目的 package.json

1
2
3
json复制代码"dependencies": {
"@yi-ui/vue": "link:../../packages/vue",
},

使用 @yi-ui/vue

1
2
3
ts复制代码import { add } from '@yi-ui/vue'

add(1, 2)

七、打包构建

  1. 配置

其实我们在上面配置 package/vue 核心库的时候有添加了一个 build 命令,但是在子项目中 build 不是很方便;

所以为了统一多包管理,需要在根目录的 package.json 下配置一下

1
2
3
4
json复制代码"scripts": {
"clear": "rimraf packages/**/dist",
"build": "pnpm run clear && pnpm -r --filter=./packages/** run build",
},

另外,我们还需要额外安装一下 rimraf,来删除打包的产物 dist 等目录;

1
bash复制代码pnpm install rimraf -Dw

rimraf:一个在 Node.js 环境中常用的 npm 包,用于递归删除文件和文件夹。其名称来源于 “rm -rf” 命令,这是在 Unix/Linux 系统中用于递归删除文件和文件夹的命令。

2.build 打包

1
bash复制代码pnpm build

image.png

八、发布 & 安装使用

  1. 登录 npm(按照提示输入用户名密码邮箱即可)

1
bash复制代码npm login

注意:

  • 如果发布的 npm 包名为:@xxx/yyy 格式,需要先在 npm 注册名为:xxx 的 organization,否则会出现提交不成功;
  • 发布到 npm group 时默认为 private,所以我们需要手动在每个 packages 子包中的 package.json 中添加如下配置;
    "publishConfig": { "access": "public" },
  1. 安装 changesets

因为我们的项目是一个 monorepo 多包项目,所以我们使用普通的办法显然不能了;

那么这时候搭配 pnpm workspace 的工具 changesets 就出现了

1. 安装 changesets

1
css复制代码pnpm i @changesets/cli -Dw

2. 初始化 changesets

1
csharp复制代码pnpm changeset init

3. 完成后项目会出现一个.changeset的文件夹

1
2
3
4
5
lua复制代码|-- my-project
|-- .changeset
|-- config.json
|-- README.md
|-- ...

4. 配置 .changeset/config.json

1
2
3
4
5
6
7
8
9
10
11
12
13
14
json复制代码{
"$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"fixed": [],
"linked": [],
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": [
"@yi-ui/playground",
"@yi-ui/docs"
]
}

5. 配置 package.json 的发布脚本

1
2
3
4
5
6
7
8
9
10
11
json复制代码{
"script": {
// 1. 开始交互式填写变更集,每次发布版本的时候执行,生成对应的 md 文件
"changeset": "changeset",
// 2. 用来统一提升版本号以及对应的md文档
"vp": "changeset version",
// 3. 构建产物后发版
"release": "pnpm build && pnpm release:only",
"release:only": "changeset publish"
}
}

3.发布

1. 随便修改 package/vue 下 src/index.ts 的代码

2. 按照顺序执行

  • 第一步:pnpm changeset
+ 这里会让你去选择版本号、还有发版说明等等
  • 第二步:pnpm vp
+ 生成对应的md文档

至此就会在子包中生成你每次发布版本的 md 文档说明了,如下图:

image.png

3. 运行 pnpm release 的最终发布

image.png

Tips:

playground 和 docs 目录下的包需要在 package.json 中设置 "private": true,否则每次 pnpm release 会把队友的包 `publish 至 npm,从而导致 release 失败。

4.安装使用

安装:

1
bash复制代码pnpm install @yi-ui/vue

使用:

1
2
3
ts复制代码import { add } from '@yi-ui/vue'

add(1, 2)

总结

其实上述的流程,只是一个很基本的搭建,还有更详细配置,例如文档、单元测试等等配置其实不止上述这一点,因为要和核心库做深度绑定;

但是为了不显得文章臃肿,咱们只是一笔带过的间接了解下其最基本的配置。

当然我们不可能就这样抛弃了,所以接下来的文章,将会实现一个最基本无头组件,以及如何耦合单元测试、文档等等。

Headless UI 往期相关文章:

  1. 在 2023 年屌爆了一整年的 shadcn/ui 用的 Headless UI 到底是何方神圣?
  2. 实战开始 🚀 在 React 和 Vue3 中使用 Headless UI 无头组件库

Headless UI (1).png

感谢大家的支持,码字实在不易,其中如若有错误,望指出,如果您觉得文章不错,记得 点赞关注加收藏 哦 ~

关注我,带您一起搞前端 ~

本文转载自: 掘金

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

女朋友不想开Processon会员,我魔改了一个无限制的在线

发表于 2024-04-01

前言

对于复杂的逻辑或者流程来说,画一画流程图可以帮助我们更好的捋清楚逻辑。平时我女朋友也偶尔会用 processon 来画一下流程图, processon 确实是一个很好的软件。

但是免费版只能创建 9 个文件,所以她平时在用的时候只能删了画、画了删,用起来不是那么方便,但是又不想为了这个东西开会员。

于是我找到了一个很棒的开源的流程图软件——draw.io,它同样也提供了在线的地址——drawio在线地址

但她用了一会之后,感觉这个在线地址也不是那么的方便易用,提出了下面的问题:

  1. 这个在线地址部署在国外,平时使用会受网络影响
  2. 本身不提供文件存储的功能,它对接了多种存储介质,比如你可以下载到本地、或者托管到一些云盘或者 GitHub ,虽然说配置起来不是很麻烦,但也并不是开箱即用
  3. 本身不提供文件管理功能,它可以导入一个个文件,感觉就像是一个编辑器而已,并没有把我创建的、编辑的流程图统一管理起来,没有类似文件/文件夹列表的功能

image.png

所以,基于上面的种种问题,我就想着基于 drawio 魔改一个的绘图软件,并且自己后端实现存储,这样就可以让这个东西免费无限制且易用。

其实说是魔改,我们改的东西不多,主要是改变存储、读取的方式,以及有一些功能不需要的可以做一些删减,最后就是自己做一个平台把文件与流程图串起来。

这是项目的体验地址,欢迎大家体验:🌟🌟体验地址🌟🌟

前端实现

首先先把 drawio 的代码拉下来,拉下来之后只需要关注 src/main/webapp 这个目录,所有的前端代码都在里面。

image.png

把前端跑起来

入口是 src/main/webapp/index.html 这个文件,我使用了 express 起了一个服务,一来是充当静态资源服务器,二来是充当开发环境的代理,规避接口调用时的跨域问题。

新建一个 server.js 文件,填入如下内容

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
js复制代码// server.js
const express = require("express");
const { createProxyMiddleware } = require("http-proxy-middleware");
const path = require("path");
const compression = require("compression");
const app = express();

app.use(compression());

// 静态资源服务
app.use(express.static(path.join(__dirname, "./src/main/webapp")));

// 接口转发
app.use(
"/draw-io",
createProxyMiddleware({
target: "http://draw.eztool.top",
changeOrigin: true,
pathRewrite: {
"^/draw-io": "/draw-io", // 转发的时候去掉前缀
},
})
);

// 启动服务器
const port = 3000;
app.listen(port, () => {
console.log(`Server is running on http://localhost:${port}`);
});

然后运行一下这个 node 脚本,启动服务。

启动服务之后,这里有两点需要注意的地方:

  1. 如果你的服务器动在 3000 端口,那么你需要访问 http://localhost:3000/?dev=1
  2. 修改 src/main/webapp/index.html 的如下代码,不然静态资源的加载会有问题

image.png

随意修改一些东西,然后打开上述的链接,如果看到修改生效,那么就证明我们的开发环境启动成功了

image.png

初始化数据

大概看了一下 drawio 的代码,发现流程图的内容是 xml 格式的。对于文件的初始化流程,可以大概找到 App.js 的如下代码,绿色代码是我新加的。

image.png

mock 一下数据,把文件的标题与内容通过实例化一个 LocalFile ,并调用 loadFile 加载到画布上

image.png

然后我们就可以得到一幅流程图如下图所示

image.png

这里真正实现的时候,是根据 id 调用后端接口,去拿标题和内容,然后加载到编辑器中,到这一步,读取数据已经完成。

保存数据

由于我们上面选择的存储介质是 LocalFile ,所以保存内容的逻辑在 LocalFile 这个类中,具体在下面打印的位置

image.png

在这个位置,我们可以把数据同步给后端进行更新。

除了内容之外,标题的更新我们也需要考虑。

image.png

标题更新的时候会走到下面这个方法,我们可以在这个方法中来发送接口给后端更新文档的标题

image.png

至此,基本的数据流向问题已经解决,在流程图层面,我们已经解决了读取数据及更新数据的问题,解决了这两个问题之后,我们就可以把流程图内容信息存在我们自己的服务器中。

其他配置项修改

还有一些其他配置项的修改,这个就根据我们自身的情况来,看看哪些东西是我们不想要的,哪些东西是要改的。

这里就得耐心去读一下它的源码了,没什么技巧,找到你自己想要改的地方,改它。这里我举两个例子。

第一个,图标的修改

image.png

上面框出来的图标,在下面的文件中,修改成你想要的图标就行。

image.png

第二个,菜单的修改,我这里对【文件】这个菜单删了很多,只保留了我觉得必要的东西

image.png

菜单在下面这个文件中,基本上就是找到你不想要的东西把它注释掉或者删掉。

image.png

打包部署

这个项目打包的工具用的 ant ,它是一个基于 java 的打包工具。所以要打包我们先要装好 java 的 jdk 以及 ant 。

安装好后进入到 /etc/build 这个目录下,执行 ant 命令,就可以发现打包成功了。

image.png

部署的时候使用 nginx 开了一个目录,然后我比较偷懒。我把整个 webapp 目录都丢了上去,但是呢 webapp 目录又很大,我也不想每次通过 ftp 工具去传。

于是我就建了一个 git 仓库,把在我本地的 webapp 目录推了上去,然后在服务器拉取这个仓库。这样做了以后,我每次通过 ant 打包完之后推送代码,然后在服务器 pull 一下,代码就更新了。

还有一点要注意的是,这个项目的前端并没有使用一些现代化的打包工具,打包出来的文件名不会有 hash 。

为了避免缓存导致代码不生效的问题,我在 nginx 配置的时候使用了协商缓存,配置如下

1
2
3
css复制代码  location ~* \.(js|css|html)$ {
add_header cache-control no-cache;
}

image.png

首屏加载优化

在打包部署完成之后,发现了加载还是挺慢的,一个是我服务器的带宽比较小,另一个是确实加载了几个比较大的 js 文件。

image.png

首先 app.min.js 这个是主包,是不能省略的。然后看到 stenceils.min.js 跟 extensions.min.js ,看看他们可不可以不阻塞主流程。

大概看了一下 stenceils.min.js 的内容,它里面都是模版,好家伙,怪不得这么大;其次关于 extensions.min.js ,看了一下 /etc/build/build.xml 打包文件,它大概是做一些拓展逻辑的,比如说一些导入导出之类的。

所以这两个包的加载完毕与否,是不影响正常的主绘制流程使用的。

image.png

这里便是上面提到的两个包的加载入口,让这个加载函数加载完第一个包之后就执行回调函数即可。

image.png

这样之后,我们不需要再等待这个包加载完成就能开始用主要的绘图逻辑,这个包加载了 13S ,也就是说,我们不需要再等这 13S 。

image.png

首屏加载速度提升了 10多秒 啊兄弟们,恐怖如斯~

现在没有缓存的情况下,首屏加载 3S 左右,还是挺丝滑的

Kapture 2024-03-31 at 23.52.52.gif

平台实现

我另外用 React 实现了一个用户登录、管理文件的平台,目前做的功能有:

  • 登录/注册/修改密码
  • 文件列表,新增,修改,删除,重命名

image.png

image.png

image.png

image.png

目前来说做的还比较简单,只提供了最基本的文件管理功能,这个平台跟上面的绘图页面可以理解为是两个项目。

在新建或者打开的时候,会从这个平台跳转到绘图项目:

1
2
3
4
5
6
7
8
9
10
js复制代码export const openDraw = (id) => {
const dev = location.href.includes("localhost");
let url;
if (dev) {
url = `http://localhost:3000?dev=1&id=${id}`;
} else {
url = `http://draw.eztool.top/draw?id=${id}`;
}
window.open(url);
};

后端

后端使用的 java ,使用的是 SpringBoot 搭建的项目。

相关技术栈:

  • JWT:鉴权
  • MongoDB:使用 Mongo-Plus 作为数据存储
  • Redis:缓存
  • Spring mail:邮件发送验证码
  • transmittable-thread-local:上下文信息传输

后端实现主要分为三块:

鉴权

在之前做 工具网站 的时候用到了 sa-token 框架,这个框架整体来说功能挺强大的,但对于小网站来说可能很多东西都不太需要。所以我这次基于 hutool 的工具类自己包了一层,实现了一个较为简洁的 JWT 鉴权流程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
java复制代码
@Slf4j
@UtilityClass
public class JwtUtil {

String jwtStr = "xxxxxxx";

public String getToken(User user) {
return JWTUtil.createToken(BeanUtil.toBean(user,Map.class), jwtStr.getBytes());
}

public Boolean verify(String token) {
return JWTUtil.verify(token, jwtStr.getBytes());
}


public void isLogin(String token){
if (StrUtil.isBlank(token)) {
throw new BusinessException("请先登录");
}
if (!verify(token)) {
throw new BusinessException("请先登录");
}
}

public User getUser(String token){
isLogin(token);
JWTPayload payload = JWTUtil.parseToken(token).getPayload();
if (Objects.isNull(payload)){
throw new BusinessException("用户信息为空");
}
return User.tpJWTPayload(payload);
}

}

这里封装了一个设置 token 以及解析 token 的工具类,登录成功后 token 就被设置到 cookie 中,请求过来时解析 cookie 中的 token 以获取用户信息。

用户信息

这里包含了用户的注册、登录、修改密码等功能。

用户信息表的结构如下:

1
2
3
4
5
6
7
8
9
10
json复制代码{
"userId": "",
"ip": "",
"name": "",
"account": "",
"password": "",
"id": "",
"createTime": "",
"updateTime": ""
}

注册这里用到了邮箱验证码作为校验,验证码发送出去后会存在 redis 中,并设有有效期。

然后注册一个新邮箱作为发送验证码的邮箱,以 163邮箱 为例。

image.png

在这里开通 SMTP

image.png

然后引入邮件依赖

1
2
3
4
xml复制代码<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>

配置yml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
yml复制代码spring:
mail:
# 设置邮箱主机
host: smtp.163.com
# SMTP 服务器的端口
port: 587
# 设置用户名,这里使用你邮箱账号就行
username: 123456789@163.com
# 设置密码,该处的密码是邮箱开启SMTP的授权码而非邮箱密码
password: SODJSHAUHGQWRQWE
default-encoding: UTF-8
protocol: smtps
properties:
mail:
smtp:
ssl:
enable: true

具体的实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
java复制代码@Slf4j
@Service
@RequiredArgsConstructor
public class MailServiceImpl implements MailService {

private final JavaMailSender javaMailSender;

//发送邮件
@Override
public void sendEmail(String email,String subject, String text) {
try {
SimpleMailMessage mailMessage = new SimpleMailMessage();
//你的邮箱账号
mailMessage.setFrom("123456789@163.com");
//接收方的邮箱账号
mailMessage.setTo(email);
//标题
mailMessage.setSubject(subject);
//内容
mailMessage.setText(text);
//发送邮件
javaMailSender.send(mailMessage);
} catch (Exception e) {
e.printStackTrace();
log.info("发送邮箱失败:{}",e.getMessage());
}
}

}

文件表

文件表里包括文件夹跟文件,主要通过 type 去区分。更详尽的表结构字段如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
json复制代码{
"fileId": "", //文件ID
"parentId": "", //上级文件id
"name": "", //名称
"type": "", //文件类型 0=文件 1=文件夹
"content": "", //文件内容
"isSub": false, //是否有下级(针对文件夹点击下拉判断)
"createId": "", //创建用户id
"updateId": "", //修改用户id
"delFlag": "", //逻辑删除 0=正常 1=删除
"id": "",
"createTime": "",
"updateTime": ""
}

剩下的就是关于文件的一些增删改查逻辑,这里就不再放具体的代码。通过维护 parentId 跟 fileId 的对应关系,就可以实现文件树的逻辑。

最后

这就是我基于 drawio 魔改的一个在线绘图软件,对于我们自身的要求来说是够用了。后续的拓展的话,我尽量还是以平台拓展为主,绘图功能拓展为辅,因为这个绘图功能已经很强大了,甚至对我来说,这个绘图我常用的还不到它功能的 10% ,所以我也不太想花太多精力去改它。

后续可能会拓展的点:

  • 模版创建
  • 模版市场
  • 回收站
  • 分享

如果你也有像我们一样的痛点,欢迎你体验我们的站点。如果你觉得有哪里用得觉得不舒服的地方,也欢迎随时与我们反馈。希望这个对你会有帮助~

以上就是本文的全部内容,如果你觉得有意思的话,点点关注点点赞吧~

本文转载自: 掘金

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

古茗如何做前端数据中心 - SDK 设计篇

发表于 2024-04-01

前言

在上一次中,我们谈到了古茗前端数据中心的整体的架构设计,今天我们来具体看一下 sdk 侧的具体设计。

我们先来回归一下上次的架构设计图,你还记得吗?不记得就再来回顾一下上次的内容吧!
架构设计

总体设计

概要设计图

架构图

使用

don’t talk, show you the code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
js复制代码// 初始化
Track.init({
debug: false,
appId: 1,
initialExtra: () => {
return {
// 强行覆盖默认的 appId,用于微应用中识别子应用
appId: getCurrentApp()?.appId || 1,
userId,
};
},
integrations: {
InstrumentTrack: {
enable: false,
option: {},
},
},
});

// 埋点,两种埋点方式,对于无需参数的可以快捷埋点
function submitTrack(eventName: string, options?: { extends?: any }): void;
function submitTrack({
et: string;
e_name: string;
extends?: any;
}): void;

// 日志,消息内容会被 stringify
interface logger {
error(msg: any): void;
warn(msg: any): void;
info(msg: any): void;
}

通常在日常使用,我们不会直接使用 core 包,为了方便开发的使用,我们已经基于平台二次封装好了 platform 包来对日志上报做了一些更客制化的需求,如:插件的集成、特有api的磨平等;尽可能的提升开发者的体验,做到开箱即食。

platform 暴露的函数只有 init submitTrack logger 三个,分别用于初始化和埋点与日志的上报;具体的参数设计和实现我们在详细设计中再来讨论。

接口格式设计

在概要设计图中,我们知道整个 sdk 中,只存在一个 report 的接口的调用,report 接口是承载一切信息的基础,报文中包含了日志的所有的信息与云端配置的下发:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
js复制代码// paylaod
{
"m": [
{
"time": 1710123186431,
"referer": "http://localhost:3000/",
"type": "log",
"data": {}
}
],
"c": {
"appId": 382,
"env": "prod",
"app": "",
"app_version": "",
"platform": "",
"platform_version": "",
"model": "",
"brand": "",
"userId": "123",
“track”: {}
}
}
// response header
Track-Id: 111111aaaaaa222222bbbbbb
X-Track: a=1;b=25;c=31;d=0

从上放方的 demo 数据可以看出, 接口核心内容为请求的 payload 和返回的 headers,payload 中包含了 m 和 c 字段。

m 是这次上报的所有日志信息,每一次的上报包含了多条日志,通过将多条日志合并至一次请求中发出以减少请求量
作为数据中心,平台不仅承载了埋点信息还包括了业务日志、资源信息、接口信息等不同类别的数据,所有的数据本质上都是一条日志,只通过 type 来区分了不同的业务属性,同时在 data 中添加特有的数据。上方 demo 中的 submitTrack 和 logger 本质上也只是 type 的不同。

c 为本次上报的通用信息,appId 用来识别当前的应用,id 从公司的 devops 中获取,通过保持相同的 appId,来为以后多系统的数据打通做铺垫;同时sdk中的 initialExtra 方法,也可以向该字段中添加额外的数据:

为何上报的数据中有time字段?
我们知道端侧的时间是不可信任的,那为何我们上报时还需要添加 time 字段呢?
那是因为在真正使用的场景时,可能会因为端侧堆栈未满、削峰、限流等原因导致的延迟上报,此时使用服务器的时间就会与真正日志产生的时间存在偏差,因此我们默认信任端侧的时间,使用端侧的时间对数据进行补偿或丢弃

模块

  • CrossPlatform: 提供需要跨平台的方法的实现,包含了 storage 存储、获取路由队列等方法
  • Reporter & Queue:控制日志上报,管理了日志队列、削峰、并发管理等功能
  • ExtraInfo:额外信息,合并了 sdk 内置、插件内置、用户内置的通用信息
  • Configurator:远程配置相关模块,获取云端的相关 sdk 配置
  • Breadcrumb:用户行为日志记录,管理用户行为队列
  • Event:全局事件通信
  • Integrations:插件系统,控制插件的注册、使用等

插件

  • api:接口规范监控 & 接口异常监控
  • behavior:用户行为日志监控
  • cache:日志缓存模块,用于解决日志丢失问题
  • static:静态资源监控
  • track:埋点模块

详细设计

模块拆封

Init

我们前文聊过,暴露出来的init方法其实就是将SDK类实例化过程的一个封装,我们先来看一下 Sdk 类和 init 方法的伪代码:

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
ts复制代码// core
class SDK {
static get instance(): SDK;
// 实例化方法,sdk实例会存在 global 中,避免多次初始化导致重复埋点
static init(option) {
try {
_global.__sdk_instance_ = new SDK(option);
} catch (error) {
console.error('sdk init error: ', error);
}
}
// 远程配置管理
readonly configurator: Configurator;
// SDK 上报器
readonly reporter: Reporter;
// 各种内置模块
...
// 插件列表
private integrations: Record<string, Integration> = {};
constructor(
this.configurator = new Configurator(this);
this.extraInfo = new ExtraInfo(this);
// 初始化各种模块 & 加载插件
...
const totalSwitch = this.initIntegration();
/** 全局开关关闭时或有插件关闭时,强制刷新配置 */
const { report } = configurator.configuration;
if (report === false || (typeof report === 'number' && report !== totalSwitch)) {
configurator.forceUpdate();
}
)
// 各种工具函数
...
}

// 提供便捷的上报日志和埋点的方法
export function logger(): Logger;
export function submitTrack(): SubmitTrack;

// platform

const DEFAULT_INTEGRATIONS = [
InstrumentBehavior,
InstrumentApi,
InstrumentStatic,
InstrumentTrack,
IntegrationCacheData,
];

export init = (options) => {
// 处理options,添加默认值,转换参数格式等
SDK.init({
...options,
transport,
corssPlatform,
integrations(this) {
return convertOptionsToIntegrations.call(this, DEFAULT_INTEGRATIONS, options.integrations);
},
initialExtra: () => inheritData(env.getTags.bind(env), initialExtra)
});
}

core 中的 SDK 类并没有面向开发者进行设计,考虑的便是如何满足通用性的需求。在实例化过程中,是对各个在 core 中实现的模块的初始化,需要注意的是,我们模块的初始化顺序是有要求的,相关的配置、通用数据需要在最前初始化以供后面的进行模块使用。在实例化的最后,我们会去加载插件并更新远端配置。

platform 中已经内置好了默认插件、平台特有的 api 等,开发在使用时无需再对这些进行配置,只需要关注与自己应用相关的配置即可。

Configurator

为了控制大促等场景突发大流量不至于将系统打崩,云端会下发队列长度、削峰开关、限流参数等配置至客户端,该模块就是用来获取并处理云端下发的配置。

模块的定义如下:

1
2
3
4
5
6
7
8
9
10
11
ts复制代码class Configurator {
private configuration: Configuration = {};
private stringConfiguration = '';
private storage: Required<CrossPlatform>['storage'];
/** 强制刷新配置 */
forceUpdate(): void;
/** 格式化配置 */
parse(input?: string, disableCache = false): Configuration;
/** 获取具体配置 */
get(key: string | string[]): any | Record<string, any>;
}

日志的上报和配置的拉取都通过 report 接口进行,在将日志上报之后,对于端侧控制的配置也会在 header 中下发,配置的格式类似:a=2;b=25;c=31;。

在获取了 header 中的值之后需要通过 parse 方法将字符串转换为 json 从而方便后续流程中消费配置;同时考虑到若将所有的日志上报禁用后将无法获取最新的配置,在每次sdk初始化后都会调用 forceUpdate 方法,手动的刷新一遍配置。

你知道吗?
通常在web中,我们会使用sendBeacon来上报日志,从而达到最好的体验;但是sendBeacon只会将数据接入到队列中,然后告知加入队列的成功与非,并不会告知是否发送成功的,更拿不到返回头之类的信息哦;
而且sendBeacon在chrome59~81,浏览器不允许设置 content-type 请求头为 application/x-www-form-urlencoded、multipart/form-data 或者 text/plain 以外的值。一旦出现了这种情况,sendBeacon 就会抛出异常哦!(没错,部分软件的webview会报错)

Reporter & Queue

Reporter 和 Queue是sdk的核心模块,他们决定了日志是否上报、何时上报,他们的格式如下:

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
ts复制代码// Report & Queue是sdk的核心模块
class Reporter {
private queue: Queue;
getQueue(): Queue;
send(data: Data, options: {lazy?: boolean; reportType?: number});
private disabledReport(reportType?: number): boolean;
}

class Queue {
/** 普通队列 */
private stack = [];
/** 历史紧急上报队列信息 */
private immediatelyStack = [];
/** 采样队列 */
private samplingList = [];

/** 是否正在上报 */
private isFlushing = false;
private readonly micro: Promise<any>;
/** 上报任务轮训 */
private timer: NodeJS.Timer | null = null;
/** 异常重试次数 */
private retryTimes = 0;
/** 添加队列,包含采样等逻辑 */
add(rawData: data, options: {lazy?: boolean}): void;
/** 上报数据(理论外部不允许使用) */
report(data: Item[], options?: {lazy?: boolean}): void;
/** 获取当前堆栈 */
getStack(): Item[];
/** 添加上报轮训任务 */
private addListener(): void;
/** 添加队列时预处理上报信息 */
private generateStackItem(data: data): Item;
/** 采样上报队列 */
private sampling(data: Item): Item | undefined;
/** 添加队列 */
private _add(data: Item, lazy = true): void;
/** 是否需要延迟上报 */
private isLazy(lazy = true): boolean;
/** 轮训任务 */
private loop(time = 10000): void;
/** 清空某一种日志类型的采样队列并获取采样 */
private flushSampleItem(type: string, force = false): void;
/** 清空所有采样队列并获取采样 */
private flushSample(): void;
/** 清空普通队列并上报 */
private flushStack(): void;
}

我们可以看到,每个 reporter 都会有一个属于自己的 queue ;
reporter 本身只会通过远程配置来判断是否需要发送对应类型的日志,并调用队列的 add 方法加入到队列中,具体的限流等逻辑都在队列中实现,队列出发上报的时机有以下几种:

  1. 到达队列的长度:在添加队列后,若队列的长度已到达预设,queue就会将现在普通队列中所有的日志信息取出并上报,队列的长度默认为25,同时队列长度会受云端的配置影响
  2. 定时上报:在初始化队列后,会创建一个10s的定时器,每个一段时间会清空队列进行上报,避免用户长时间不进行操作后导致日志的时间与当前时间差过大,清洗时需要对时间间隔比较大的历史数据进行补偿
  3. 削峰上报:在云端开启削峰之后,会默认根据userId作为特征值进行削峰处理;此时队列的长度将为无限大,并且将特征值转换为10进制后,除以60取余的值为他在这分钟能上报的秒数,上报时会以队列长队 * 10分批上报

同时队列还包含了采样和抽样逻辑。

采样:当云端开启采样时,会下发采样队列的长度,日志会先添加到采样的队列,当采样的队列长度到达预设或定时器触法时,才会从采样队列中随机取其中1条添加到普通队列中,需要注意的是虽然云端会根据对应的采样配置将数据等比例翻倍,但是还是会失真;端侧的采样尽可能的少用。

抽样:当云端开启抽样是,会根据特征值(默认userId)来判断对应用户是否需要抽样,与采样相比,抽样仅会等比例的丢失用户的数据,但是符合特征值的的用户的数据是全的。

Integrations

插件模块本身并没有负责的逻辑,仅负责外部插件的加载,在 sdk 初始化时,会执行一下一段脚本,将插件初始化并向插件提供 sdk 实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
ts复制代码(_integrations ?? []).forEach((Integration) => {  
try {
const integrationInstance =
typeof Integration === 'object' ? Integration : new Integration(this);

integrationInstance.instrument();
this.integrations[Integration.constructor.name] = integrationInstance;
totalSwitch += integrationInstance.reportType || 0;
} catch (error: any) {
console.error(error);
this.logger.error(error?.message, 'sdk_error');
}
})

插件

端侧所有的额外功能都是通过插件集成的,处理内置的插件,在初始化时也可以通过 integrations 参数添加开发同学自己定制的插件,每个插件都需要有一个自己特有的 key 和 type,key 为一个可以描述插件的字符串,type 为二进制数字,用于云端墙纸关闭插件:

云端可以下发插件开发强制关闭插件,不会为每个插件都下发一个插件开发,插件开关仅是一个数字,这也是为什么插件的type必须为唯一的二进制数字。
如,现在有两个插件,插件A的type为1,插件B的type为2
当我下发的开关的值为0b0010时,插件A为关,插件B为开,1 & 0b0010转换为布尔值为false,而2 & 0b0010 为true,因此我们可以通过一个数字来管理所有插件的开关

Cache

缓存插件,用于限流时,在关闭前将当次未上报的数据缓存,在下次打开程序时将历史数据进行补偿上报:

考虑到storage有内容大小限制,小程序暂时未支持 cache 模块,后续会通过 文件缓存 来实现 cache 模块。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
ts复制代码class Cache {
/** 初始化插件,db等 */
instrument(): void;
/** 监听关闭事件,缓存堆栈 */
addUnloadEvent(db: DB): void;
/** 初始化时添加堆栈 */
reInsertPrevList(db: DB): void;
}
class DB {
static isSupport: boolean = !!(_global && _global.indexedDB);
private db?: DBDatabase;
connect(options: {name: string; version?: number; tableSchema: Record<string, string | undefined>;}): Promise<void>;
getAll<E = any>(tableName: string): Promise<E[]>
batchAdd(tableName: string, value: any, key?: string): Promise<void>
clear(table: string): Promise<void>;
}

Cache 模块初始化时,会初始化 DB 来存取队列信息;在 beforeunload 时将队列添加至 indexDB 中;
DB 模块是基于 indexDB 的分装,在 connet 时 open indexDB,并确定 version 和 tableSchema 更新本地的表结构。

Static

static 模块会对静态资源的 onload 和 onerror 事件进行劫持上报,web 的劫持比较好处理,实例化 PerformanceObserver 后,对 type 为 resource 的资源进行监听即可;需要注意的是,部分的浏览器对于传 type 会报错,需要用 entryTypes 兼容(是谁不用多说。。。)。

1
2
3
4
5
6
7
8
ts复制代码const observer = new PerformanceObserver((list) => {
// 处理资源
})
try {
observer.observe({type: 'resource', buffer: true});
} catch () {
observer.observe({ entryTypes: ['resource'] });
}

小程序的 image 等标签虽然也有这些事件,但是手动的一个个写不现实,因此我们额外支持了 babel-plugin-jsx-inject 的babel插件,该插件支持对指定元素添加对应的属性或父元素,plugin 接受的参数为

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
ts复制代码interface PluginOptionElement {  
/** 唯一key */
identity: string;
/** 需要修改的元素的名称 */
name: string | RegExp | ((pathName: string, path: NodePath) => boolean);
/** 需要修改的属性的名称,仅修改属性时生效 */
attribute?: string;
/** 需要添加的属性或父元素的ast的模版 */
template: string | TemplateFn;
/** 需要import的依赖 */
require?: [];
}

/** 获取继承了原有方法的函数 */
export const getInheritFn = (attribute: string, attributes: Record<string, Node> = {}): string => {
if (!attribute) {
return '';
}
const spreadAttributes = getSpreadAttribute(attributes);
const inheritFn = attributes[attribute];
if (!spreadAttributes?.length) {
return inheritFn ? `%%${attribute}%%(event);` : '';
}

const params = (
!inheritFn
? spreadAttributes
: [
...spreadAttributes,
{
name: `%%${attribute}%%(event)`,
index: (inheritFn as any)._attr_index,
},
]
)
.sort((a, b) => a.index - b.index)
.map(({ name }) => name)
.join(',');

return `_execInheritFn(event, "${attribute}", ${params})`;
};

// demo
const getTriggerFn = (attributes: Record<string, Node>) => {
return `
(event) => {
_gem_t('img:error', event);
${getInheritFn('onError', attributes)}
}
`;
};

export const imgErrorInjectConfig = {
identity: 'img-error',
name: 'Image',
attribute: 'onError',
template: (attributes) => getTriggerFn(attributes),
require: ['import { trigger as _gem_t, _execInheritFn } from "@guming/global-event-mini"'],
};

需要注意的是,添加属性时,不能将原有的属性覆盖了,getInheritFn 方法就是用来解决这个问题的,getInheritFn 包装了一个函数,将原有相同名称的属性,作为所有参数传入方法中,在 _execInheritFn 中,一个一个 pop,取出第一个匹配 attribute 名字的属性来执行。

Track

复用了原有了 [platform]_track 包,在插件中进行了初始化,插件并没有对 track 的逻辑做额外的功能,所有与埋点相关的逻辑都在 track 包中,track 主要对埋点字段的格式进行了处理,同时提供了 TrackScrollView 和 TrackSwiper 进行自动的曝光和点击的埋点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
ts复制代码class Track {
instrument() {
initTrack({
...options!,
eventQueueLimitNum: 1,
request: ({ data }) => {
this.report({
type: MeasurementType.Track,
data,
});
},
});
sdk.extraInfo.addExtra('track', () => {
return trackStore.getCommonInfo();
});
this.trackClick();
}

/** 劫持点击,有 data-track-option 属性的元素点击事件自动埋点 */
private trackClick(): void;
}

总结

数据平台端侧的 sdk 除了需要考虑满足数据上报和埋点的需求外还要考虑性能、稳定性和拓展性等方面的因素;满足了在未来流量不断增加的场景下的可靠性,为产品提供更好的数据支持。希望这篇文章对大家能有一点灵感和帮助。

最后

📚 小茗文章推荐:

  • 小程序用户登录:安全性与用户体验的平衡
  • formily原来是这样解决这些表单难题
  • 古茗是如何将小程序编译速度提升3倍的

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

本文转载自: 掘金

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

通过搜索引擎让大模型获取实时数据-实现类似 perplexi

发表于 2024-03-31

你好,我是 shengjk1,多年大厂经验,努力构建 通俗易懂的、好玩的编程语言教程。 欢迎关注!你会有如下收益:

  1. 了解大厂经验
  2. 拥有和大厂相匹配的技术等

希望看什么,评论或者私信告诉我!

一、前言

汇报一下这周末的工作,主要是开发了一门课程:通过搜索引擎让大模型获取实时数据,第一次开发一门课程,难免会有很多不熟悉和做的不好的地方。

已经训练好的大模型有气数据的局限性,比如 GPT-4,只有 2023年4月之前的数据。关于最新发生的一些事情,它无法回答。
目前已经有一些公司在做类似的事情:让大模型获取最新数据,从而让用户得到更加满意的答案,比如 perplexity。

二、初衷

这门课其实就是简单解析 perplexity 的背后原理。perplexity 不知道有没有听说过,其估值或翻番至10亿美元

1
text复制代码Perplexity AI 提供类似于 Google Search 和 Bing Search 的搜索服务,用户可以用自然语言输入问题,可以获得类似于 ChatGPT 的答案。

整体使用的效果是这样的:
通过搜索引擎获取数据,然后通过大模型总结后进行回答。

三、实现方式

搜索引擎+大模型,目前仅仅实现了后端,前段并没有做。当然了整体的效果肯定是不如 perplexity,毕竟 perplexity 有自己训练的大模型,以及要做自己的搜索引擎,另外我也没有进行任何优化。仅仅是探索 perplexity 背后的技术

四、总结

文章汇报了新开发的课程,主要涉及通过搜索引擎实现大模型获取实时数据的过程。初衷在于解析Perplexity的原理,作者介绍了该模型以及其提供的搜索服务。同时,作者也提及目前实现的局限性和技术探索的过程。

本文转载自: 掘金

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

Shell脚本速通指南(比小米Su7还快)

发表于 2024-03-30

前言:基础

Centos默认的脚本解析器是bash(bash也是软连接文件sh -> bash)

脚本第一行声明bash脚本:#!/bin/bash

脚本执行

1、sh+脚本名称

2、给脚本赋予权限chmod u+x 脚本名称,绝对路径and相对路直接径运行

一、变量

需要sorce /etc/profile和sorce ~/.bashrc重新加载环境变量

printenv输出当前环境变量

1、全局变量 /etc/profile

2、局部变量~/.bashrc

3、定义自定义变量export Su7=21.69,输出变量 echo $Su7

1、关于自定义变量

记住:用户登录之后开启一个解释器bash(-号),当启动一个脚本文件;重新启动一个bash(二号)去执行脚本,二号bash是一号子bash。

1
2
3
4
5
6
7
8
sh复制代码[shishu@hadoop1]$ export B=su7
[shishu@hadoop1]$ echo $B
[shishu@hadoop1]$ cat helloworld.sh
echo "helloworld'
echo $B
[shishu@hadoop1]$ sh helloworld.sh
helloword
su7

如果再去开一个窗口,就意味着开了一个新的bash,不能继承这个同级别的bash变量。

2、特殊变量 $ 和 $#

$n 功能描述:n为数字,$0代表该脚本名称,$1-$9代表第一到第九个参数,十以上的参数,十以上的参数需要用大括号包含,如${10}

$# 传参数个数

1
2
3
4
5
6
7
8
9
shell复制代码[root@clb1 shell]# cat teshu.sh
#!/bin/bash
#传参个数
echo $#
#参数输出
echo "$0 $1 $2"
[root@clb1 shell]# sh teshu.sh xiaomi su7
2
teshu.sh xiaomi su7

3、特殊变量 $* 和 $@

$*

功能描述:这个变量代表命令行中所有的参数,$*把所有的参数看成一个整体

$@

功能描述:这个变量也代表命令行中所有的参数,不过$@把每个参数区分对待

4、特殊变量 $?

$?

功能描述:最后一次执行的命令的返回状态。如果这个变量的值为0,证明上一个命令正确执行;如果这个$?变量的值为非0(具体是哪个数,由命令自己来决定),则证明上一个命令执行不正确了。

1
sh复制代码echo $?

二、数学计算

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
sh复制代码#!/bin/bash
A=100
B=2
#第一种写法
C=$((A+B))
#第二种
D=$[A+B]
#第三种
E=`expr $A + $B`
echo $E

#综合运算(2+3)*5
F=$[(2+3)*5]
#expr `expr 2+3` * 5
echo $F

三、条件判断

1、常用判断条件

(1) 两个整数之间比较

  • = 字符串比较
  • -lt 小于(less than)
  • -le 小于等于(less equal)
  • -eq 等于(equal)
  • -ne 不等于(Not equal)
  • -gt 大于(greater than)
  • -ge 大于等于(greater equal)

(2) 按顺文件权限进行判断

-r 有读的权限(read)

-w 有写的权限(write)

-x 有执行的权限(execute)

(3) 按顺文件关型进行判断

-f 文件存在并且是一个常规的文件(file)

-e 文件存在(existence)

-d 文件存在并是一个目录(directory)

2 、练习实操

(1) 逻辑语句 && 和 ||

&& 前面条件成立,后面语句才会执行(逻辑与)

|| 前面条件不成立,后面语句才会执行(逻辑或)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
sh复制代码[root@clb1 shell]# cat luoji.sh
#!/bin/bash
#&& 逻辑与 和 逻辑或
echo $#
[ $# -gt 2 ] && echo "参数的个数大于2"
[ $# -lt 2 ] || echo "参数的个数大于2----"
[root@clb1 shell]# sh luoji.sh 1 2 3
3
参数的个数大于2
参数的个数大于2----
[root@clb1 shell]# sh luoji.sh 1 2
2
参数的个数大于2----
[root@clb1 shell]# sh luoji.sh 1
1
(2) 利用命令的执行结果进行判断

判断用户是否存在,创建用户

参考:echo $? 输出的0 和 1 ,表示Y 与 F

1
2
3
4
sh复制代码#!/bin/bash
#如果用户zhangsan不存在,则添加用户zhangsan
id zhangsan &> /dev/null && echo "zhangsan用户存在,不用添加"
id zhangsan &> /dev/null || useradd zhangsan

四、流程控制

1、if 判断

基本格式

注意事项: (1) [ 条件判断式 ],中括号和条件判断式之间必须有空格

(2) if后要有空格

1
2
3
4
5
6
7
8
9
10
sh复制代码if [ 条件判断句 ];then
  程序
fi

#或者

if [ 条件判断句 ]
   then
  程序
fi

案例:判断年龄

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
sh复制代码[root@clb1 shell]# cat if.sh
#!/bin/bash
if [ $1 -lt 18 ];then
   echo "未成年"
elif [ $1 -ge 18 -a $1 -le 30 ];then
   echo "青年"
else
   echo "中老年"
fi
[root@clb1 shell]# sh if.sh 22
青年
[root@clb1 shell]# sh if.sh 33
中老年
[root@clb1 shell]# sh if.sh 3
未成年

2、case语句

基本格式

注意事项

1)case行尾必须为单词“in”,每一个模式匹配必须以右括号 )结束

2)双分号 ;; 表示命令序列结束,相当于java中的break。

3)最后的*)表示默认模式,相当于java中的default。

1
2
3
4
5
6
7
8
9
10
11
sh复制代码case $变量名 in
“值1”)
如果变呈的值等于值1,则执行程序1
;;
“值2”)
如果变星的值等于值2,则执行程序2
;;
*)
如果变量的值都不是以上的值,则执行此程序
;;
esac

案例:判断输入

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
sh复制代码[root@clb1 shell]# cat case.sh
#!/bin/bash
case $1 in
"start")
echo "你输入的是start"
;;
"stop")
echo "你输入的是stop"
;;
*)
echo "你输入的是其他"
;;
esac
[root@clb1 shell]# sh case.sh stop
你输入的是stop
[root@clb1 shell]# sh case.sh start
你输入的是start
[root@clb1 shell]# sh case.sh st
你输入的是其他

3、for 循环

(1) 用法一

基础语法

1
2
3
4
sh复制代码for ((初始值;循环控制条件;变量变化))
do
程序
done

案例:1到100的和

1
2
3
4
5
6
7
sh复制代码#!/bin/bash

for ((i=0;i<=100;i++));do
s=$[$s+$i]
done

echo "1到100的和是$s"
(2) 用法二

基础语法

1
2
3
4
sh复制代码for 变量 in 值1 值2 值3...
do
程序
done

案例 1:1到100的和

1
2
3
4
5
6
sh复制代码[root@clb1 shell]# cat sum100.2.sh
#!/bin/bash
for i in {1..100};do
s=$[$s+$i]
done
echo "1到100的和是$s"
1
2
3
4
5
6
sh复制代码[root@clb1 shell]# cat sum100.2.sh
#!/bin/bash
for i in `seq 1 100`;do
s=$[$s+$i]
done
echo "1到100的和是$s"

案例 2:显示/root下面所有文件名称

案例告诉我们,在for循环中,空格和换行都可以进行分割字符

1
2
3
4
5
sh复制代码[root@clb1 shell]# cat file.sh
#!/bin/bash
for i in `ls /root`;do
echo $i
done

案例 3:区分$*和$@

只有$*和$@带上双引号代表的意义才不同

踩坑提示

1
2
3
4
> bash复制代码echo "我是你的"
> echo"我是你的"
>
>

在脚本中echo后面有没有空格的含义是不一样的。

如果没有空格整个语句会被认为是一条命令。例如下面的报错

1
2
3
4
5
> markdown复制代码qufen.sh:行3: echo传入脚本的参数是:qqq: 未找到命令
> qufen.sh:行3: echo传入脚本的参数是:www: 未找到命令
> --------------
>
>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
sh复制代码[root@clb1 shell]# cat qufen.sh
#先测试$*
for i in "$*";do
echo "传入脚本的参数是:$i"
done
echo "--------------"
#测试$@
for j in "$@";do
echo "传入脚本的参数是:$j"
done
[root@clb1 shell]# sh qufen.sh 123 321
传入脚本的参数是:123 321
--------------
传入脚本的参数是:123
传入脚本的参数是:321

4、while循环

案例 :1到100的和

1
2
3
4
5
6
7
8
9
10
11
12
sh复制代码[root@clb1 shell]# cat sum100.3.sh
#!/bin/bash
i=1
sum=0
while [ $i -le 100 ];do
let sum=sum+i
#这行代码也可以写成:sum=$[$sum+$i]
let i++
done
echo "从1到100的和是:$sum"
[root@clb1 shell]# bash sum100.3.sh
从1到100的和是:5050

五、read读取控制台输入

1、基础语法

read(选项)(参数)

选项:

-p:指定读取值时的提示符

-t: 指定读取值时等待的时间(秒)

参数:

变量:指定读取值的变量名

2、练习实操

案例 : 求1到输入数字的和

1
2
3
4
5
6
7
8
9
10
11
12
13
sh复制代码[root@clb1 shell]# cat sumx.sh
#!/bin/bash
read -p "请输入的数字" x
i=1
sum=0
while [ $i -le $x ];do
let sum=sum+i
let i++
done
echo "1到$x的和是$sum"
[root@clb1 shell]# sh sumx.sh
请输入的数字10
1到10的和是55

for循环

1
2
3
4
5
6
7
sh复制代码#!/bin/bash
read -t 3 -p "请在3秒内请输入的数字" x
sum=0
for ((i=1;$i<=$x;i++));do
let sum=sum+i
done
echo "1到$x的和是$sum"

六、函数

1、系统函数

(1)basename

基本语法

basename命令会删掉所有的前缀包括最后一个(/)字符

也可以指定后缀,来切除末尾

1
2
3
4
sh复制代码[root@clb1 shell]# basename /root/shell/sumx.sh
sumx.sh
[root@clb1 shell]# basename /root/shell/sumx.sh .sh
sumx
(2)dirname

基本语法

dirname 文件绝对路径

功能描述:从给定的包含绝对路径的文件名中去除文件名(非目录的部分)然后返回剩下的路径(目录的部分)

1
2
sh复制代码[root@clb1 shell]# dirname /root/shell/sumx.sh
/root/shell
(3)综合案例

更换txt文件为sh文件

1
2
3
4
5
6
7
8
9
10
11
sh复制代码[root@clb1 shell]# cat zhixing.sh
#!/bin/bash
i=0
for f in `ls /root/shell/*.txt`;do
basename=`basename $f .txt`
file=$basename".sh"
mv $f $file
let i++
echo "已更改$i个文件"
echo "本次已经修改源文件$f变为$file"
done

image-20240330180502761

2、自定义函数

基本用法

1、必须在调用函数地方之前,先声明函数,shel脚本是逐行运行。不会像其它语言一样先编译。 2、函数返回值,只能通过$?系统变量获得,可以显示加:return返回,如果不加,将以最后一条命令运行结果,作为返回值。return后跟数值n(0-255)

案例练习1:自定义两数之和sum()

利用result承接输入值,利用echo $?输出。但是return后跟数值n(0-255)有范围,所以建议用下面优化版本。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
sh复制代码[root@clb1 shell]# cat zidingyi.1.sh
#!/bin/bash
#自定义函数
function sum(){
let s=$1+$2
return $s
}
sum 100 200
echo $?
[root@clb1 shell]# sh zidingyi.1.sh
44
[root@clb1 shell]# cat zidingyi.2.sh
#!/bin/bash
#自定义函数
function sum(){
let s=$1+$2
return $s
}
sum 10 200
echo $?
[root@clb1 shell]# sh zidingyi.2.sh
210

优化版本

1
2
3
4
5
6
7
8
9
10
11
sh复制代码[root@clb1 shell]# cat zidingyi.3.sh
#!/bin/bash
#自定义函数
function sum(){
let s=$1+$2
echo $s
}
result=`sum 100 200`
echo $result
[root@clb1 shell]# sh zidingyi.3.sh
300
案例练习2:求数据的阶乘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
sh复制代码[root@clb1 shell]# cat jiecheng.sh
#!/bin/bash
if [ $1 -eq 1 ];then
echo "参数错误,程序退出!"
exit 3 #返回的状态码 也就是echo #?输出的
fi
function jiecheng(){
n=$1
if [ $n -le 1 ];then
echo 1 #当n=1的时候,函数的返回值
return 0 #返回的执行状态
elif [ $n -gt 1 ];then
let pre_n=$n-1
temp=$(jiecheng $pre_n)
let result=n*temp
echo $result
return 0
fi
}
jiecheng $1
[root@clb1 shell]# sh jiecheng.sh 5
120

七、Shell工具

1、cut

1
2
3
4
sh复制代码[root@clb1 shell]# echo $PATH | cut -d : -f1
/usr/local/sbin
[root@clb1 shell]# grep "/bin/bash$" /etc/passwd |cut -d : -f1
root

2、awk

1
2
3
4
sh复制代码[root@clb1 shell]# awk -F ":" 'BEGIN{print "username,shell"} $NF ~ "/bin/bash$"{print $1","$NF} END{print "END"}' /etc/passwd
username,shell
root,/bin/bash
END

案例分享

本文转载自: 掘金

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

【面试基础】HashMap在什么条件触发链表变成红黑树?

发表于 2024-03-30

面试官:我看简历里有写掌握常用的数据结构,hashMap肯定天天打交道吧?HashMap在什么条件触发链表变成红黑树?

我:当数据size超过64个的时候吧。
面试官:哦?这个说法不太准确。。。

HashMap天天打交道,可是我们搬砖人只把他当成工具,不知道一些细节,以及他怎么保证查找添加删除效率的,是不是有点对不起他,那我们今天就来啃啃这个HashMap以及红黑树,让你自信面对红黑树,再也不谈”红黑树“就大脑宕机,超出范围了。
红黑树很复杂本文讲不完,可以在这里看我写的《【全网最详细图文讲解】❤ 彻底搞懂你 @红黑树》

如果你现在已经打开IDEA,建议一起打开源码,没有也没关系,先看懂图,建议收藏后面在自己对照源码再次领悟。

一、
HashMap 是 Java 中的一个非常重要的数据结构,它实现了 Map<K,V>接口 ,该结构基于哈希表,主要用于存储键值对。
HashMap 的重要特性包括:

  • 无序性:HashMap 不保证顺序,特别是不保证顺序会随着时间的推移保持一致。
  • 键唯一性:每个键最多只能映射到一个值。
  • 时间复杂度:理想情况下,HashMap 提供对插入、删除和定位元素的常数时间性能(O(1)),非理想的情况下,尤其是发生大量哈希冲突时,比如哈希函数的设计不良,导致大量的键映射到相同的哈希桶中,这时候HashMap的性能在链表可以退化为 O(n),红黑树为O(log n)。
  • 空值:HashMap 允许存储 NULL 值作为键或者值。

HashMap 的内部实现是一个动态数组,数组的每个槽位又是一个链表或者红黑树(当链表长度过长时会转化为红黑树以优化搜索性能,变成红黑树实际情况数据可能的到以及亿上级别,普通客户端使用很少出现)。当发生哈希碰撞,即不同的键被哈希到同一个槽位时,键值对会以链表的形式存储在该槽位上。

hashmap_pic1.png

HashMap 的性能和那些有关?

  1. 哈希函数
    哈希函数的质量直接影响到键值对在HashMap中的分布。一个好的哈希函数应该能够将键均匀地分布在不同的哈希桶中,减少冲突。如果哈希函数导致许多键聚集在相同的桶中,性能会显著降低。
  2. 初始容量和负载因子
    初始容量是HashMap创建时的哈希表大小。
    负载因子是哈希表在其容量自动增加之前可以达到多满的一种度量。当哈希表中的条目数超过负载因子与当前容量的乘积时,哈希表会进行扩容。
    这两个参数决定了哈希表需要进行扩容的频率。频繁的扩容会影响性能,因为它涉及到重新计算现有键的哈希值并将它们重新分配到新的桶中。

源码分析

1. Node<K,V>,TreeNode<K,V>节点结构

HashMap中使用Node<K,V>[] table 来存放map<K,V>键值对。

hashmap_pic2.png
Node<K,V>,TreeNode<K,V>的声明如下:

hashmap_pic3.png

1
2
3
4
java复制代码   // 创建一个Node
Node<K,V> newNode(int hash, K key, V value, Node<K,V> next) {
return new Node<>(hash, key, value, next);
}

Node<K,V>表示table中的每个节点,每个节点包含hash,key,value,next。每个table[i]是一个链表的头,也就是相同hash值的元素都在一个hash bins中,next可以看出此时属于单链表结构。

hashmap_pic4.png

hashmap_pic5.png

1
2
3
4
java复制代码   // 创建一个TreeNode
TreeNode<K,V> newTreeNode(int hash, K key, V value, Node<K,V> next) {
return new TreeNode<>(hash, key, value, next);
}

TreeNode中多出parent,left,right,prev,red字段, 这个prev字段通常用于保持一个部分的双向链表,以便在删除操作中方便地找到并断开与前一个节点的连接。这种做法不是红黑树数据结构的标准部分,但它可以用于优化某些操作。在删除一个节点时,必须重构树以保持红黑树的性质。这个prev字段的存在可以帮助在不违背红黑性质的情况下,更快地修复树的链接,因为它为节点提供了一种返回到前一个节点的直接方式,从而减少了在删除操作中需要执行的比较和旋转次数。

TreeNode继承LinkedHashMapEntry,其实最终是Node<K,V> ,LinkedHashMap是直接继承HashMap, TreeNode继承LinkedHashMapEntry 是为了更好兼容LinkedHashMap

2. 初始化

hashMap初始化构造方法有4个,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
java复制代码   //可以自定义初始容量initialCapacity和加载因子loadFactor
public HashMap(int initialCapacity, float loadFactor) {
... loadFactor);
this.loadFactor = loadFactor;
//初始化扩容阀值,此时不会更具initialCapacity实例化table的,此时table的为空。
this.threshold = tableSizeFor(initialCapacity);
}
//默认加载因子loadFactor,DEFAULT_LOAD_FACTOR = 0.75
//值0.75是一个在时间和空间成本上取得较好平衡的经验值。它是工程领域基于大量实验和实践得出的结果,对大多数情况来说提供了良好的性能
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
//无参构造,此时
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR;
}
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}
final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {//evict,指明是否要替换已有的映射
int s = m.size();
if (s > 0) {
//如果当前table为空,重新计算threshold
if (table == null) { // pre-size
//通过当前的装载因子来除,得到了在保持装载因子不变的情况下,理论上所需要的桶(bucket)数量,也就是table数组的大小
float ft = ((float)s / loadFactor) + 1.0F;
int t = ((ft < (float)MAXIMUM_CAPACITY) ?
(int)ft : MAXIMUM_CAPACITY);
if (t > threshold)//如果t大于当前的threshold值,重新设置threshold
threshold = tableSizeFor(t);//更具容量重新计算
}
else if (s > threshold)
resize();//扩容
//循环追加value
for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) {
K key = e.getKey();
V value = e.getValue();
putVal(hash(key), key, value, false, evict);
}
}
}
//返回大于等于给定目标容量的最小2的n次方的数
static final int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
  • DEFAULT_LOAD_FACTOR值0.75是一个在时间和空间成本上取得较好平衡的经验值。它是工程领域基于大量实验和实践得出的结果,对大多数情况来说提供了良好的性能
  • tableSizeFor返回大于等于给定目标容量的最小2的n次方的数,算法很巧。对n (cap-1)无符号右移1,2,4,8,16位,得到一个所有位均为1的数,举例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
java复制代码{
int n = 16 - 1; // n = 15, 即二进制的 0000 1111

n |= n >>> 1; // n = 0000 1111 | 0000 0111 = 0000 1111
n |= n >>> 2; // n = 0000 1111 | 0000 0011 = 0000 1111
n |= n >>> 4; // n = 0000 1111 | 0000 0000 = 0000 1111
n |= n >>> 8; // 对于一个int类型,8位和16位的移动不会改变值,因为数字已经足够大
n |= n >>> 16; // 确保覆盖所有可能的1位

return n + 1; // n = 15 + 1 = 16
}

{
int n = 18 - 1; // n = 17, 即二进制的 0001 0001

n |= n >>> 1; // n = 0001 0001 | 0000 1000 = 0001 1001
n |= n >>> 2; // n = 0001 1001 | 0000 0110 = 0001 1111
n |= n >>> 4; // n = 0001 1111 | 0000 0001 = 0001 1111
n |= n >>> 8; // n = 0001 1111 | 0000 0000 = 0001 1111
n |= n >>> 16; // n = 0001 1111 | 0000 0000 = 0001 1111

return n + 1; // n = 31 + 1 = 32
}

3. hash函数

在上面我们已经看到putMapEntries调用的方法putVal来添加元素:

1
java复制代码 putVal(hash(key), key, value, false, evict);

可以看到putVal中对于新增的节点,使用newNode方法(可见下面添加源码),其中hash(key) 就是计算key的hash值的哈希函数。

1
2
3
4
5
6
java复制代码//静态方法,
static final int hash(Object key) {
int h;
//(h = key.hashCode()) ^ (h >>> 16) 使用 key.hashCode()计算h,h 与 (h >>> 16 )异或运算
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

第一步:h = key.hashCode();h是一个4个字节的int,假如此时h = 0x98438919

第二步:h >>> 16 ;这个是将h无符号右移,得到 hr = 0x00009843

第三步:h ^ hr;异或操作,return 0x9843115a

为了避免在哈希表中出现冲突,这个方法通过将哈希值的高位部分与低位部分进行异或(XOR)运算来扩散影响,以此增加低位的随机性,因为高位的信息通常不会参与到哈希表索引计算中,所以需要将低位异或,增加哈希值的均匀性。

4. 添加

添加方法源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
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
java复制代码 final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
//如果当前为空,则进行扩容,即初始化table大小
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;

//按照 (n - 1) & hash 来寻找下标,n为当前table的大小,可以理解为对hash 的(n-1)取模。
//如果此时对应下标为空,则直接添加。
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;

if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p; // //如果k值已经存在,并且相等,后面根据onlyIfAbsent覆盖原来的值。
else if (p instanceof TreeNode)
//p为当前相同hash值的链表第一个,如果当前已经是TreeNode,根据红黑树结构添加节点,后面将红黑树在细讲
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
//否则轮询p链表,
for (int binCount = 0; ; ++binCount) {
//到链表结尾,也没有相同的key值,就添加到链尾
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
//!!!!!!!!!!
//添加完了,再次判断 当前p链表的节点数是不是大于等于8(TREEIFY_THRESHOLD = 8),如果是的尝试将该链表树化
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
//有相同的,则替换
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
//添加成功,size增加一个,并判断是否大于threshold阈值,如果大于就有触发一次扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
//将一个hash bin(哈希桶)中的节点 尝试变成红黑树结构
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
//!!!!!!
// MIN_TREEIFY_CAPACITY = 64
//如果tab.length < MIN_TREEIFY_CAPACITY ,此时table的个数小于64 个,就直接扩容一次,
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
else if ((e = tab[index = (n - 1) & hash]) != null) {
TreeNode<K,V> hd = null, tl = null;
do {
//将节点变成TreeNode
TreeNode<K,V> p = replacementTreeNode(e, null);
if (tl == null)
hd = p;//hd 链表头指针
else {
p.prev = tl;
tl.next = p;
}
tl = p;
} while ((e = e.next) != null);

if ((tab[index] = hd) != null)
//如果大于64,就将hd有链表结构整理变成红黑树结构
hd.treeify(tab);
}
}

添加元素的过程包含hash bin(哈希桶)的树化过程,变成红黑树的触发条件是当一个hash bin(哈希桶)的链表程度大于8,并且当前table的length是大于64 ,才开始将此此时的hash bin(哈希桶)由链表结构变成红黑树结构。

5. 扩容

扩容过程也很重要,因为一次扩容将会触发hash表的重新移位,方法源码中如下

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
java复制代码final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) {
//oldCap大于0,之前触发过resize,table已经有值了
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
} else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
//newCap 变成原来oldCap( table length)的两倍,如果此时oldCap大于初始容量(16)就将新的扩容阀值变成之前两倍。
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0) // 如果oldCap= 0,但是 oldThr大于0 ,那么直接将新的容量变成oldThr
newCap = oldThr;
else { //都等于0的时候,直接赋值默认初始容量,
newCap = DEFAULT_INITIAL_CAPACITY;
// 新的threshold位 16 * 0.75 = 12
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) {
//前面 如果oldCap= 0,但是 oldThr大于0,已经设置了newCap
float ft = (float)newCap * loadFactor;
// 重新设置newThr = newCap * loadFactor
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}

//设置新的 table,threshold
threshold = newThr;
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;

//这里判断是需要移位,将老的table中hash值,按照新的长度重新安排索引
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
if (e.next == null)//如果老的hash bin 只有链表头,重新赋值到新的索引
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
//如果是树的结构移位,split方法原理和下面链表移位类似
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else {
//不是树的结构,还是移位该hash bin 到新的索引。这里的算法也很巧妙,我们后面举例分析
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}

扩容过程最重要的部分就是移位哈希桶 到新的table索引的过程。一开始很难理解loHead,loTail,hiHead,hiTail。我们需要明确前提条件,当前哈希桶中的所有元素key的hash值与原本 (oldcap-1) 取模是是一样的,但是和当前(newcap-1)取模可能一样也可能不一样,因为cap都是2的幂次方。所以移位要么还在原来的索引,要么在新的索引。所以loHead 可以理解为将要在原本索引index的表头指针,也就是其他博主说的低位链表表头,hiHead同理是新的索引的表头,即高位表头指针。

还有一个比较难理解的点是为什么要使用(e.hash & oldCap) == 0 来区分loHead,hiHead。我们举例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
yaml复制代码假设当前oldcape容量为 16,newcap为32
1.如果当前hash 低位字节:0000 1011
0000 1011 & (16-1)
0000 1011 & 0000 1111 = 0000 1011 (11 十进制)
0000 1011 & (32-1)
0000 1011 & 0001 1111 = 0000 1011 (11 十进制)
此时hash值 对应在容量 16 和 32 都是下标索引为11
此时 0000 1011 & (16)
0000 1011 & 0001 0000 = 0000 0000 (0 十进制)
说明如果直接和 oldcap 与运算为0 ,那么下标不变。

2.如果我们换一个高位的hash值:1011 1011
1011 1011 & (16-1)
1011 1011 & 0000 1111 = 0000 1011 (11 十进制)
1011 1011 & (32-1)
1011 1011 & 0001 1111 = 0001 1011 (27 十进制)

此时
此时 1011 1011 & (16)
1011 1011 & 0001 0000 = 0001 0000 (16 十进制)
直接和 oldcap 与运算为16不为0 ,
那么下标就是 11(原本下标 ) + 16(oldcap) = 27

举例中的两个hash值老的oldcap中,经过 hash &(oldcap -1) 都是相同索引,在同一个链表,经过这样操作以后,这两个元素被分到不同的hash桶中了。(此处给如此巧妙的算法掌声(^▽^)👏👏👏👏🏻)

6. HashMap 序列化

HashMap是通过实现如下方法直接将元素数量(size), key, value等写入到了ObjectOutputStream中,实现的定制化的序列化和反序列化。在Serializable接口中有关于这种做法的说明。

1
2
3
4
java复制代码 private void writeObject(java.io.ObjectOutputStream out)
throws IOException
private void readObject(java.io.ObjectInputStream in)
throws IOException, ClassNotFoundException;

而hashmap重写 writeObject, readObject,如下:

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复制代码    final int capacity() {
return (table != null) ? table.length :
(threshold > 0) ? threshold :
DEFAULT_INITIAL_CAPACITY;
}

private void writeObject(java.io.ObjectOutputStream s)
throws IOException {
int buckets = capacity();
s.defaultWriteObject();
s.writeInt(buckets);//保存 capacity
s.writeInt(size);// 保存元素个数
internalWriteEntries(s);//轮询保存 key,value 对象
}
void internalWriteEntries(java.io.ObjectOutputStream s) throws IOException {
Node<K,V>[] tab;
if (size > 0 && (tab = table) != null) {
for (int i = 0; i < tab.length; ++i) {
for (Node<K,V> e = tab[i]; e != null; e = e.next) {
s.writeObject(e.key);
s.writeObject(e.value);
}
}
}
}
private void readObject(java.io.ObjectInputStream s)
throws IOException, ClassNotFoundException {
// Read in the threshold (ignored), loadfactor, and any hidden stuff
s.defaultReadObject();
reinitialize();
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new InvalidObjectException("Illegal load factor: " +
loadFactor);
s.readInt(); // Read and ignore number of buckets
int mappings = s.readInt(); // Read number of mappings (size)
if (mappings > 0) { // (if zero, use defaults)
...略//(计算cap的值)
Node<K,V>[] tab = (Node<K,V>[])new Node[cap];
table = tab;
// Read the keys and values, and put the mappings in the HashMap
for (int i = 0; i < mappings; i++) {
K key = (K) s.readObject();
V value = (V) s.readObject();
putVal(hash(key), key, value, false, false);
}
}
}

后面查询和删除和上边的关键之处差不多,本文就不赘述了。

总结

把hashmap的结果简单的复习了一遍,我们对源码分析部分做一个总结,下面以经常被问到hashmap相关问题的方式总结一下,这个答案都在上文源码分析部分。

Q:HashMap 的初始容量和负载因子是什么?

初始容量是创建 HashMap 时的数组大小,默认是 16。负载因子用于计算初始容量的扩容阈值,扩容阈值用来控制当前衡量 HashMap 在其容量自动增加之前可以有多满,负载因子默认是 0.75。负载因子越高,阈值越高,出发扩容之前存储元素越多,但对于哈希表来说,空间利用率提高了,查询速度可能会降低。

Q: 何时需要扩容呢?

A: 在使用put方法添加键值对的时候,首先会判断table表是否为空,是的话resize一次初始化table大小,每次完成了put操作添加成功(不是替换的情况)以后,都判断一下++size是否大于threshold,如果大于则进行扩容。

Q: 那么threshold又是如何得到的呢?

A: 旧的table的长度大于0的时候,新的扩容和阈值都是原来的2倍,旧的table为空的时候,默认情况下简单来讲threshold = length * loadfactor(默认为0.75)。

Q: 扩容时具体做什么呢?

A: 首先计算出新的数组长度和新的threshold(阈值). 简单来讲,旧的table不为空,新的hash table的length(newCap) 是原来的2倍(位运算左移一位),新的threshold也为原来的2倍。 创建新的Node数组赋值给当前table,将原来数组中的元素安装hash &(newcap -1)重新映射到新的数组索引中。

Q:HashMap在什么条件触发链表变成红黑树?

A:添加元素的过程包含hash bin(哈希桶)的树化过程,变成红黑树的触发条件是当一个hash bin(哈希桶)的链表程度大于8,并且当前table的length是大于64 ,才开始将此此时的hash bin(哈希桶)由链表结构变成红黑树结构。

为什么要将单链表变成红黑树呢,那我们就来分析分析红黑树的特点。这个部分我单独一篇文章来讲。

Q:如何判断两个键 k1 和 k2 是相同的?

A: 在源码中判断key是否相等使用p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))) 那么也就是 k1.equals(k2) 为 true 并且 k1.hashCode() == k2.hashCode(),那么可以判断两个键是相同的,这两个条件都需要满足。

Q:HashMap类是实现了Serializable接口的,那么为何其中的table, entrySet变量都标为transient呢?

A:我们知道,table数组中元素分布的下标位置是根据元素中key的hash进行散列运算得到的,而hash运算是native的,不同平台得到的结果可能是不相同的。举一个简单的例子,假设我们在目前的平台有键值对 key1-value1,计算出key1的hash为1, 计算后存在table数组中下标为1的地方,假设table被序列化了,并传输到了另外的平台,并反序列化为了原来的HashMap,key1-value1仍然存在下标1的位置,当在这个平台运行get(“key1”)的时候,可能计算出key1的hash为2,就有可能到下标为2的地方去找该元素,这样就出错了。

其他相关问题

Q:HashMap 和 Hashtable 的区别是什么?

A:哈希表(Hashtable)是线程安全的,它的每个方法基本上都是同步的,而 HashMap 不是线程安全的。此外,HashMap 允许使用一个空键和多个空值,而 Hashtable 不允许空键或空值。

Q:什么时候使用 TreeMap 而不是 HashMap?
当你需要保持键的顺序时,应该使用 TreeMap。TreeMap 实现了 SortedMap 接口,可以确保键处于排序状态,但是它的操作比 HashMap 的平均复杂度要高。

Q:如何自定义类作为 HashMap 的键?

A:如果要使用自定义对象作为键,需要重写 equals() 和 hashCode() 方法。这样才能保证当对象的逻辑内容相同的时候,它们的哈希码也是相同的。

Q:在并发环境下,如何安全地使用 HashMap?

A:在并发环境下,可以使用 Collections.synchronizedMap(new HashMap<…>()) 或者 ConcurrentHashMap 来实现线程安全的哈希映射。

本文转载自: 掘金

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

自带天气APP太臃肿?手写一个换掉它

发表于 2024-03-30

天气APP是我们平时比较常用的一个APP,用来关注近期天气情况,给我们的出行安排提供便利。现在的手机一般都是自带有天气APP的,我的Mate20也有华为官方的天气。一开始的时候用着还挺好的,不知从何时起,华为的天气APP在启动的时候会显示广告,虽然看着很烦,但是还勉强能接受。后来,这个天气APP变本加厉,打开后里面到处都是广告,而且越来越臃肿,占用空间达到了211MB,一个天气应用真的需要占用这么大的空间吗,我决定自己动手写一个天气APP试试。我的手机是鸿蒙系统,API版本是6,所以我选择使用DevEco作为开发工具,因为API版本太低了,所以使用js和java作为开发语言,无法使用ets。

Screenshot_20240330_074727_com.android.settings.png

选择天气API

天气API有很多,针对不同的业务量有不同的收费方案,之前用的是彩云天气,免费,而且预报也比较准确,后来突然不能用了,去官网看到公告彩云天气不再免费了,所以只能换API。免费API里面做的比较好的是和风天气,和风天气提供的天气信息比较多,调用也简单,还提供了开源的丰富的天气图标供使用,所以选择了和风天气。

和风天气提供的天气信息有:

  • 实时天气
  • 分钟天气预报
  • 逐小时天气预报
  • 每日天气预报
  • 天气预警
  • 生活指数
  • 空气质量
  • 太阳和月亮信息

天气功能需求

  1. 多区域天气。打开APP时默认显示的是当前实时位置的天气预报,可左右滑动切换查看不同的区域天气。
  2. 添加区域天气。通过右上角的搜索按钮打开区域搜索页面,在搜索页面输入地区,点击搜索,显示匹配的区域列表。点击列表项添加到区域天气列表中。可在主页面左右滑动切换。
  3. 删除区域天气。在主页面对应的区域天气预报页,通过右上角的菜单按钮打开菜单,菜单中删除选项,点击删除从区域天气列表中移除当前区域。
  4. 天气内容。天气内容按从上到下分块展示,从上到下依次是:
1. 实时天气。包括温度,天气,体感温度,湿度,风力。
2. 降雨/降雪图。展示未来2小时的降雨/降雪量。
3. 24小时天气。展示未来24小时的天气情况,包括时间,天气,温度,可左右滑动查看。
4. 7日天气。展示包含今天在内的7天天气情况,包括星期,日间天气,夜间天气,最低温度,最高温度,温度条形图。
5. 空气质量。包括总的空气质量和分项空气质量,总的天气质量以仪表盘的形式展示,根据空气质量等级以不同颜色区分。分项空气质量包括PM10,PM2.5,二氧化氮,二氧化硫,一氧化碳,臭氧,以分项名称,质量指数,条形图的形式展示。
6. 太阳和月亮。以图表的形式展示太阳和月亮的轨迹,还有日出,日落,月升,月落的时间。
7. 生活指数。展示当天的生活指数,如运动,洗车,穿衣等。

天气APP设计图1.png

天气APP设计图2.png

天气APP设计图3.png

开发思路

  1. 申明权限,天气需要用到位置和网络权限,在config.json文件中配置对应的权限
  2. 在需要用到位置信息的地方申请权限
  3. 创建数据库和表,用于保存天气数据,不用频繁的调接口获取天气信息,接口一般都有次数限制。
  4. 创建一个DataAbility用于数据库操作,将天气数据的增删改查逻辑写好。一开始的是没有用DataAbility,使用的时候发现数据有时候对,有时候不对,因为不同的Context会创建不同的数据库,即使数据库名一样。
  5. 创建一个ServiceAbility用于页面数据交互,查询天气的时候优先从数据库中取,如果数据库中不存在,则调接口查询,如果存在,则判断是否超过有效期,如果超过,则重新调接口查询,如果没有超过,则直接使用数据库的数据。
  6. 创建一个Widget用于桌面小部件显示天气信息。
  7. 为了使应用体积尽可能的小,不使用第三方库,网络请求字节用java api写。不适用位图,使用矢量图,或者自己用canvas绘制。

成果展示

Screenshot_20240330_130954_net.dingyong.weather.jpg

Screenshot_20240330_131840_com.android.settings.png

大小只有1.54MB,还可以,所以我把自带的华为天气给卸载了,用自己开发的这个轻量,满足需求,没有冗余功能,没有广告的天气APP。

现在越来越多的应用过于臃肿,动不动几百几千兆的大小,大量用户不需要的功能和关联的数据占用用户手机空间,部分应用将功能的使用权交给了用户,但没有将功能的存在交给用户去选择,我觉得还不够好。如果应用开发者不愿意去做,要么忍者,要么就另想他法。

那么,下一个要卸载的应用是谁呢?

本文转载自: 掘金

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

1…434445…956

开发者博客

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