注册
环信即时通讯云

环信即时通讯云

单聊、群聊、聊天室...
环信开发文档

环信开发文档

Demo体验

Demo体验

场景Demo,开箱即用
RTE开发者社区

RTE开发者社区

汇聚音视频领域技术干货,分享行业资讯
技术讨论区

技术讨论区

技术交流、答疑
资源下载

资源下载

收集了海量宝藏开发资源
iOS Library

iOS Library

不需要辛辛苦苦的去找轮子, 这里都有
Android Library

Android Library

不需要辛辛苦苦的去找轮子, 这里都有

颜色网站为啥都收费?自己做个要花多少钱?

web
你是小阿巴,一位没有对象的程序员。这天深夜,你打开了某个颜色网站,准备鉴赏一些精彩的视频教程。结果一个大大的付费弹窗阻挡了你!你心想:可恶,为啥颜色网站都要收费啊?作为一名程序员,你怎能甘心?于是你决定自己做一个,不就是上传视频、播放视频嘛?这时,经常给大家分...
继续阅读 »

你是小阿巴,一位没有对象的程序员。

这天深夜,你打开了某个颜色网站,准备鉴赏一些精彩的视频教程。

结果一个大大的付费弹窗阻挡了你!

你心想:可恶,为啥颜色网站都要收费啊?

作为一名程序员,你怎能甘心?

于是你决定自己做一个,不就是上传视频、播放视频嘛?

这时,经常给大家分享 AI 和编程知识的 鱼皮 突然从你身后冒了出来:天真!你知道自己做一个要花多少钱么?

你吓了一跳:我又没做过这种网站,怎么知道要花多少?

难道,你做过?

鱼皮一本正经:哼,当然…… 没有。

不过我做过可以看视频的、技术栈完全类似的 编程学习网站,所以很清楚这类网站的成本。

你来了兴趣:哦?愿闻其详。

鱼皮笑了笑:那我就以 编程导航 项目为例,从网站开发、上线到运营的完整流程,给你算算做一个视频网站到底要花多少钱。还能教你怎么省钱哦~

你点了个赞,并递上了两个硬币:好啊,快说快说!


鱼皮特别感谢朋友们的支持,你们的鼓励是我持续创作的动力 🌹!

⚠️ 友情声明:以下成本是基于个人经验 + 专业云服务商价格的估算(不考虑折扣),仅供参考。

⭐️ 推荐观看本文对应视频版:bilibili.com/video/BV1nJ…

服务器

想让别人访问你的网站,首先你要有一台服务器。

你点点头:我知道,代码文件都要放到服务器上运行,用户通过浏览器访问网站,其实是在向服务器请求网页文件和数据。

那服务器怎么选呢?

鱼皮:服务器的配置要看你的网站规模。刚开始做个小型视频网站,可以用入门配置的轻量应用服务器 (比如 2 核 CPU、2G 内存、4M 带宽) ,一年几百块就够了。

等后续用户多了,服务器带宽跟不上了再升级。比如 4 核 CPU、16G 内存、14M 带宽,一年差不多几千块。

你:几百块?比我想的便宜啊。

鱼皮:没错,国内云服务现在竞争很激烈、动不动就搞优惠。

但是要注意,如果你想做 “那种网站”,就要考虑用海外服务器了(好处是不用备案)。

咳咳,我们不谈这个……

数据库

有了服务器,还得有数据库,用来存储网站的用户信息、视频信息、评论点赞这些数据。

你:这个简单,数据库不就是 MySQL、PostgreSQL 这些嘛,装在服务器上不就行了?

鱼皮:是可以的,但我更建议使用云数据库服务,比如阿里云 RDS 或者腾讯云的云数据库。

你:为啥?不是要多花钱吗?

鱼皮:因为云数据库更稳定,而且自带备份、容灾、监控这些功能,你自己搞的话,还要费时费力安装维护,万一数据丢了可就麻烦了。

你:确实,那得多少钱?

鱼皮:入门级的云数据库(比如 2 核 4G 内存、100GB 硬盘)包年大概 2000 元左右。后面用户多了、数据量大了,就要升级配置(比如 4 核 16G),那一年就要 1 万多了。不过那个时候你已经赚麻了……

Redis

鱼皮:对了,我还建议你加个 Redis 缓存。

你挠了挠头:Redis?之前看过你的 讲解视频。这个是必须的吗?

鱼皮:刚开始可以没有,但如果你想让网站数据能更快加载,强烈建议用。

你想啊,视频网站用户一进来都要查看视频列表、热门推荐这些,如果用 Redis 把热点数据缓存起来,响应速度能快好几倍,还能帮数据库分摊查询压力。

你:确实,网站更快用户更爽,也更愿意付费。那 Redis 要多少钱?

鱼皮:Redis 比数据库便宜一些。入门级的 Redis 服务一年大概 1000 元左右。

你松了口气:也还行吧,看来做个视频网站也花不了多少钱啊!

对象存储

鱼皮:别急,接下来才是重点!

我问问你,视频文件保存在哪儿?

你不假思索:当然是存在服务器的硬盘上!

鱼皮哈哈大笑:别开玩笑了,一个高清视频动不动就几百 MB 甚至几个 G,你那点儿服务器硬盘能存几个视频?

而且服务器带宽有限,如果同时有很多用户看视频,服务器根本撑不住!

你:那咋办啊!

鱼皮:更好的做法是用 对象存储,比如阿里云 OSS、腾讯云 COS。

对象存储是专门用来存海量文件的云服务,它容量几乎无限、可以弹性扩展,而且访问速度快、稳定性高,很适合存储图片和音视频这些大文件。

你:贵吗?

鱼皮:存储本身不贵,100GB 一年也就几十块钱。但 真正贵的是流量费用

用户每看一次视频,都要从对象存储下载数据,这就产生了流量。

如果一个 1 GB 的视频被完整播放 1000 次,那就是 1000 GB 的流量,大概 500 块钱。

你看那些视频网站,每天光 1 个视频可能就有 10 万人看过,价格可想而知。

你惊讶地说不出话来:阿巴阿巴……

视频转码

鱼皮接着说:这还不够!对于视频网站,你还要做 视频转码。因为用户上传的视频格式、分辨率、编码方式都不一样,你需要把它们统一转成适合网页播放的格式,还要生成不同清晰度的版本让用户选择(标清、高清、超清)。

你:啊,那不是要多存好几个不同清晰度的视频文件?

鱼皮:没错,而且转码本身也是要钱的!

一般按照清晰度和视频分钟数计费。如果你上传 1000 个小时的高清视频,光转码费就得几千块!

CDN 加速

你急了:怎么做个视频网站处处都要花钱啊!有没有便宜点的办法?

鱼皮笑道:可以用 CDN。

你:CDN是啥?听着就高级!

鱼皮:CDN 叫内容分发网络,简单说就是把你的视频缓存到全国各地的服务器节点上。用户看视频的时候,从最近的节点拿数据,不仅速度更快,而且流量费比对象存储便宜不少。

你眼睛一亮:这么好?那不是必用 CDN!

鱼皮:没错,一般建议对象存储配合 CDN 使用。

而且视频网站 一定要做好流量防刷和安全防护

现在有的平台自带了流量防盗刷功能:

此外,建议手动添加更多流量安全配置。

1)设置访问频率限制,防止短时间被盗刷大量流量

2)还要配置 CDN 的流量告警,超过阈值及时得到通知

3)还要启用 referer 防盗链,防止别人盗用你的视频链接,用你的流量做网站捞钱。

如果不做这些,可能分分钟给你刷破产了!

你:这我知道,之前看过很多你破产和被攻击的视频!

鱼皮:我 ***!

视频点播

你:为了给用户看个视频,我要先用对象存储保存文件、再通过云服务转码视频、再通过 CDN 给用户加速访问,感觉很麻烦啊!

鱼皮神秘一笑:嘿嘿,其实还有更简单的方案 —— 视频点播服务,这是快速实现视频网站的核心。

只需要通过官方提供的 SDK 代码包和示例代码,就能快速完成视频上传、转码、多清晰度切换、加密保护等功能。

此外,还提供了 CDN 内容加速和各端的视频播放器。

你双眼放光:这么厉害,如果我自己从零开发这些功能,至少得好几个月啊!

鱼皮:没错,视频点播服务相当于帮你做了整合,能大幅提高开发效率。

但是它的费用也包含了存储费、转码费和流量费,价格跟前面提到的方案不相上下。

你叹了口气:唉,主要还是流量费太贵了啊……

网站上线还要准备啥?

鱼皮:讲完了开发视频网站需要的技术,接下来说说网站上线还需要的其他东西。

你:啊?还有啥?

鱼皮:首先,你得有个 域名 给用户访问吧?总不能让人家记你的 IP 地址吧?

不过别担心,普通域名一年也就几十块钱(比如我的 codefather.cn 才 38 / 年)。

当然,如果是稀缺的好域名就比较贵了,几百几千万的都有!

你:别说了,俺随便买个便宜的就行……

鱼皮:买了域名还得配 SSL 证书,因为现在做网站都得用 HTTPS 加密传输,不然浏览器会提示 “不安全”,用户看了就跑了。

刚开始可以直接用 Let's Encrypt 提供的免费证书,但只有 3 个月有效期,到期要手动续期,比较麻烦。

想省心的话可以买付费证书,便宜的一年几百块。

你:了解,那我就先用免费的,看来上线也花不了几个钱。

鱼皮:哎,可不能这么说,网站正式上线运营后,花钱的地方可多着呢!尤其是安全防护。

安全防护

做视频网站要面对两大安全威胁。第一个是 内容安全,你总不能让用户随便上传违规视频吧?万一上传了不该传的内容,网站直接就被封了。

你紧张起来:对啊,我人工审核也看不过来啊…… 怎么办?

鱼皮:可以用内容审核服务。视频审核包含画面和声音两部分,比文字审核更贵,审核 1000 小时视频,大概几千块。

你:还有第二个威胁呢?

鱼皮:第二个是最最最难应对的 网络攻击。做视频网站,尤其是有付费内容的,特别容易被攻击。DDoS 流量攻击想把你冲垮、SQL 注入想偷你数据、XSS 攻击想搞你用户、爬虫想盗你视频……

你:这么坏的吗?那我咋防啊!

鱼皮:常用的是 Web 应用防火墙(WAF)和 DDoS 防护服务。Web 防火墙能防 SQL 注入、XSS 攻击这些应用层攻击,而 DDoS 防护能抵御大规模流量冲击。

但是这些商业级服务都挺贵的,可能一年就是几万几十万……

你惊呼:我为了防止被攻击,还要搭这么多钱?!

鱼皮笑了:好消息是,有些云服务商会提供一点点免费的 DDoS 基础防护,还有相对便宜的轻量版 DDoS 防护包。

我的建议是,刚开始就先用免费的,加上代码里做好防 SQL 注入、XSS 这些安全措施,其实够用了。等网站真做起来、有收入了,再花钱买商业级的防护服务就好。

你点了点头:是呀,如果没收入,被攻击就被攻击吧,哼!

鱼皮微笑道:你这心态也不错哈哈。除了刚才说的这些,随着你网站的成熟,还可能会用到很多第三方服务,比如短信验证码、邮件推送、 等等,这些也都是成本。

总成本

讲到这里,你应该已经了解了视频网站的整个技术架构和成本。

最后再总结一下,如果一个人做个小型的视频网站,一年到底要花多少钱?

你看着这个表,倒吸一口凉气:视频网站的成本真高啊……

鱼皮:没错,这还只是保守估计。如果你的网站真火了,每天几万人看视频,一年光流量费就得有几十万吧。

而且刚才说的都只是网站本身的成本,如果你一个人做累了,要组个团队开发呢?

按照一线城市的成本算算,前端开发 + 后端开发 + 测试工程师 + 运维工程师,再加上五险一金,差不多每月要接近 10 万了。

你瞪大眼睛:那一年就是一百万?

鱼皮:没错,人力成本才是最贵的。

你:好了你别说了,我不做了,我不做了!我现在终于理解为什么那些网站都要收费了……

鱼皮:不过说实话,虽然成本不低,但那些网站收费真的太贵了,其实成本远没那么高,更多的是利用人性赚取暴利!

所以比起花钱看那些乱七八糟的网站,把钱和时间投资在学习上,才是最有价值的。

你点了点头:这次一定!再看一期你的教程,我就睡觉啦~

更多

💻 编程学习交流:编程导航 📃 简历快速制作:老鱼简历 ✏️ 面试刷题神器:面试鸭


作者:程序员鱼皮
来源:juejin.cn/post/7572961448537882651
收起阅读 »

就因为package.json里少了个^号,我们公司赔了客户十万块

web
写这篇文章的时候,我刚通宵处理完一个P0级(最高级别)的线上事故,天刚亮,烟灰缸是满的🚬。事故的原因,说出来你可能不信,不是什么服务器宕机,也不是什么黑客攻击,就因为我们package.json里的一个依赖项,少写了一个小小的^(脱字符号) 。这个小...
继续阅读 »

image.png

写这篇文章的时候,我刚通宵处理完一个P0级(最高级别)的线上事故,天刚亮,烟灰缸是满的🚬。

事故的原因,说出来你可能不信,不是什么服务器宕机,也不是什么黑客攻击,就因为我们package.json里的一个依赖项,少写了一个小小的^(脱字符号) 。

这个小小的失误,导致我们给客户A的数据计算模块,在一次平平无奇的依赖更新后,全线崩溃。而我们,直到客户的业务方打电话来投诉,才发现问题。

等我们回滚、修复、安抚客户,已经是7个小时后。按照合同的SLA(服务等级协议),我们公司需要为这次长时间的服务中断,赔付客户十万块

老板在事故复盘会上,倒没说什么重话,只是默默地把合同复印件放在了桌上。

满脸写着无奈.gif

今天,我不想抱怨什么,只想把这个价值 十万块 的教训,原原本本地分享出来,希望能给所有前端、乃至所有工程师,敲响一个警钟。


事故是怎么发生的?

我们先来复盘一下事故的现场。

我们有一个给客户A定制的Node.js数据处理服务。它依赖了我们内部的另一个核心工具库@internal/core

在项目的package.json里,依赖是这么写的:

{
"name": "customer-a-service",
"dependencies": {
"@internal/core": "1.3.5",
"express": "^4.18.2",
"lodash": "^4.17.21"
// ...
}
}

注意看,expresslodash前面,都有一个^符号,而我们的@internal/core没有

这个^代表什么?它告诉npm/pnpm/yarn:“我希望安装1.x.x版本里,大于等于1.3.5最新版本。”

而没有^,代表什么?它代表:我安装1.3.5这一个版本,锁死它,不许变。

问题就出在这里。

上周,core库的同事,修复了一个严重的性能Bug,发布了1.3.6版本,并且在公司群里通知了所有人。

我们组里负责这个项目的同学,看到了通知,也很负责任。他想:core库升级了,我也得跟着升。

于是,他看了看package.json,发现项目里用的是1.3.5。他以为,只要他去core库的仓库,把1.3.5这个tag删掉,然后把1.3.6的tag打上去,CI/CD在下次部署时,重新pnpm install,就会自动拉取到最新的代码。

他错了!


最致命的锁死版本

因为我们的依赖写的是"1.3.5",而不是"^1.3.5",所以我们的pnpm-lock.yaml文件里,把这个依赖的解析规则,彻底锁死在了1.3.5

无论core库的同事怎么发布1.3.61.3.7,甚至2.0.0...

只要我们不去手动修改package.json,我们的CI/CD流水线,在执行pnpm install时,永远、永远,都只会去寻找那个被写死的1.3.5版本。

然后,灾难发生了。

core库的同事,在发布1.3.6后,为了保持仓库整洁,就把1.3.5那个旧的git tag删掉了

然后,客户A的项目,某天下午需要做一个常规的文案更新,触发了部署流水线。

流水线执行到pnpm install时,pnpm拿着lock文件,忠实地去找@internal/core@1.3.5这个包...

“Error: Package '1.3.5' not found.”

流水线崩溃了。一个本该5分钟完成的文案更新,导致了整个服务7个小时的宕机😖


十万块换来的血泪教训

事故复盘会上,我们所有人都沉默了。我们复盘的,不是谁的锅,而是我们对依赖管理这个最基础的认知,出了多大的偏差。

^ (Caret) 和 ~ (Tilde) 不是选填,而是必填

  • ^ (脱字符) :^1.3.5 意味着 1.x.x (x >= 5)。这是最推荐的写法。它允许我们自动享受到所有 非破坏性 的小版本和补丁更新(比如1.3.61.4.0),这也是npm install默认的行为。
  • ~ (波浪号) :~1.3.5 意味着 1.3.x (x >= 5)。它只允许补丁更新,不允许小版本更新。太保守了,一般不推荐。
  • (啥也不写) :1.3.5 意味着锁死。除非你是reactvue这种需要和生态强绑定的宿主,否则,永远不要在你的业务项目里这么干!

我们团队现在强制规定,所有package.json里的依赖,必须、必须、必须使用^

关于lock文件

我们以前对lock文件(pnpm-lock.yamlpackage-lock.json)的理解太浅了,以为它只是个缓存。

现在我才明白,package.json里的^1.3.5,只是在定义一个规则。

而pnpm-lock.yaml,才是基于这个规则,去计算出的最终答案。

lock文件,才是保证你同事、你电脑、CI服务器,能安装一模一样的依赖树的唯一路径。它必须被提交到Git

依赖更新,是一个主动的行为,不是被动的

我们以前太天真了,以为只要依赖发了新版,我们就该自动用上。

这次事故,让我们明白:依赖更新,是一个严肃的、需要主动管理和测试的行为。

我们现在的流程是:

image.png

  1. 使用pnpm update --interactivepnpm会列出所有可以安全更新的包(基于^规则)。
  2. 本地测试:在本地跑一遍完整的测试用例,确保没问题。
  3. 提交PR:把更新后的pnpm-lock.yaml文件,作为一个单独的PR提交,并写清楚更新了哪些核心依赖。
  4. CI/CD验证:让CI/CD在staging环境,用这个新的lock文件,跑一遍完整的E2E(端到端)测试。

这十万块,是技术Leader(我)的失职,也是我们整个团队,为基础不牢付出的最昂贵的一笔学费。

一个小小的^,背后是整个npm生态的依赖管理的核心。

分享出来,不是为了博眼球,是真的希望大家能回去检查一下自己的package.json

看看你的依赖前面,那个小小的^,它还在吗?😠


作者:ErpanOmer
来源:juejin.cn/post/7568418604812632073
收起阅读 »

微信小游戏包体限制4M,一个字体就11.24M,怎么玩?

web
引言哈喽大家好,很多时候,我们的游戏项目为了美观和保证风格的统一,都会用到外部字体库。但是,外部字体库通常是完整的字库,体积非常的大,例如完整的simkai字体库就达到了11.24MB。要知道,现在的微信小游戏限制主包的大小不能超过4M,即使你把字体放在分包,...
继续阅读 »

引言

哈喽大家好,很多时候,我们的游戏项目为了美观和保证风格的统一,都会用到外部字体库。

但是,外部字体库通常是完整的字库,体积非常的大,例如完整的simkai字体库就达到了11.24MB

要知道,现在的微信小游戏限制主包的大小不能超过4M,即使你把字体放在分包,占去近50%的代码包大小,想想也不太合适。

因此,我们如果想要能够顺利地在游戏中用上漂亮的字体,那我们得想办法将字库瘦下来。

言归正传,本期将带小伙伴们一起来看下,如何将我们想用的字库从11.24M瘦到不到1M 。

本文源工程可在文末获取,小伙伴们自行前往。

精简字库原理

据了解,一个完整的字库估计有3~4万个汉字,但实际上我们游戏项目需要用到的可能只占10%~20%,甚至更少,像其中的一些汉字囧、烎、嫑、勥、忈、巭、怹、颪、氼、兲‌,别说用,笔者连读都不会读。(会读的小伙伴请打在评论区,我给你点赞)

游戏项目中,用到文字的地方通常包含下面几个:

  • 1.游戏配置(*.json),一般配置里面的中文最多。 
  • 2.预制体(*.prefab),有些静态的文字通常就在预制体的Label里。 
  • 3.场景(*.scene、*.fire)同上。 
  • 4.代码(*.ts),写死在代码里的。 

因此,要瘦身字体,按照以下2个步骤即可:

  • 1.通过工具将上述地方的文字提取出来。
  • 2.通过工具从字库中的保留我们提取到的文字,其余的删除。

精简字库实例

1.提取中文字

要提取中文字,我们只需要按照上面的原理,遍历我们的游戏项目中的游戏配置预制体场景代码进行匹配即可。

其中遍历文件,笔者使用的是glob

匹配中文字的正则表达式是/[\u4e00-\u9fff]/g

2.精简字库

这里我们使用百度出品的字体子集化工具Fontmin。可以直接通过npm install fontmin进行安装。

工具的使用也非常简单,通过传入原字体保留的字符字体输出目录,最后通过fontmin.run这个API生成即可。

3.效果演示

通过node font-minifier.js --project=C:\Users\Administrator\Desktop\demo --source=C:\Users\Administrator\Desktop\simkai.ttf传入工程目录和原字体路径即可。

执行结果可以看到扫描的所有文件。

提取到的所有中文字。

生成的文件及其大小。

精简后的字体大小为802K

更进一步

除去我们遍历出来的游戏设定的中文字,其实还有一部分中文字我们是不确定的,那就是用户自定义的内容,例如名字和聊天文字。

想要处理这一部分文字,我们只能通过预设,猜到用户会自定义的内容,从而预设保留,可以通过网络上分享的常用内容来完成。

此外工具可以集成到插件或者打包系统里面去,这样后续就不用考虑相关问题,自动生成所需字库即可。

结语

通过上述方法,可以将字库大幅度精简到能够使用的状态,但是也会有一定的瑕疵。

不知道小伙伴们有没有更完美的办法呢?

本文源工程可通过私信发送 fontminifier 获取。

我是"亿元程序员",一位有着8年游戏行业经验的主程。在游戏开发中,希望能给到您帮助, 也希望通过您能帮助到大家。

AD:笔者线上的小游戏《打螺丝闯关》《贪吃蛇掌机经典》《重力迷宫球》《填色之旅》《方块掌机经典》大家可以自行点击搜索体验。

实不相瞒,想要个爱心!请把该文章分享给你觉得有需要的其他小伙伴。谢谢!

推荐专栏:

知识付费专栏

你知道和不知道的微信小游戏常用API整理,赶紧收藏用起来~

100个Cocos实例

8年主程手把手打造Cocos独立游戏开发框架

和8年游戏主程一起学习设计模式

从零开始开发贪吃蛇小游戏到上线系列


作者:亿元程序员
来源:juejin.cn/post/7572087181608353842
收起阅读 »

白嫖党的快乐,我在安卓手机上搭了服务器+内网穿透,再也不用买服务器了

web
起因因为去年买的腾讯云服务器到期了,我一看续租的话要459元,作为白嫖党这是万万不能接受的!于是我就想:能否搞一个简单的服务器,能跑基本的项目就好了。然后我就在掘金上看到了一篇文章:如何将旧的Android手机改造为家用服务器最后结合全网,搜到了如下两种方案:...
继续阅读 »

起因

因为去年买的腾讯云服务器到期了,我一看续租的话要459元,作为白嫖党这是万万不能接受的!

image.png

于是我就想:能否搞一个简单的服务器,能跑基本的项目就好了。

然后我就在掘金上看到了一篇文章:如何将旧的Android手机改造为家用服务器

最后结合全网,搜到了如下两种方案:

  • KSWEB
  • Ternux

综合对比下,我选择用Termux试试。

前提

想要跑起来Termux,首先你要有一个安卓手机。

于是我就开始逛咸鱼,最后选了一款IQOO Neo5型号的,12+256G(有些小毛病),花了315元,这个内存跑服务应该是够了的。

开始安装

安装Termux

1)通过github或者APKFab应用商店安装Termux。

2)更新和安装基础软件包

pkg update && pkg upgrade -y 
pkg install wget curl nano -y

安装nodejs

由于本人是前端开发,所有用的服务都是nodejs写的,所以只安装node相关的东西

pkg install nodejs
// 安装PHP或其他的同理,示例如下:
pkg install php

安装完成后,打印一下看看是否成功了

image.png

安装其他

由于我的项目也有nodejs服务端,所以还需要安装以下:

  • mysql 数据库
  • ssh 远程连接
  • redis 缓存
  • cpolar 内网穿透(本地部署的项目,外网无法访问,用它来给外网访问)
  • nginx 高性能代理

具体的教程就不展示细节了,推荐几个教程地址,仅供参考:

设置完ssh后,就可以在电脑上的Xshell连接登录了,注意了:

默认端口号为8022,不是22

默认端口号为8022,不是22

默认端口号为8022,不是22

连接成功后是这样的

image.png

其中有一个用户身份验证,用户名输入whoami查看

image.png

放入几个项目

我用Xftp传入几个vue项目和nodejs项目

image.png

启动服务

项目放入后,启动的服务应该是只能局域网访问的,几个vue项目都是打包的dist文件,所以需要配置nginx代理,关键配置如下,有多少个项目,就来多少个server就行,慢慢配吧。

因为个人项目不多,也不找其他高大上的管理工具了

 # 加解密 配置
server {
listen 5290;
server_name 192.168.3.155;

location / {
root /data/data/com.termux/files/home/vue/rui-utils-crypt/dist;
index index.html index.htm;
}

error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /data/data/com.termux/files/usr/share/nginx/html;
}
}

# 个人博客 配置
server {
listen 5173;
server_name 192.168.3.155;

location / {
root /data/data/com.termux/files/home/vue/vite-press-blog/dist;
index index.html index.htm;
}

error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /data/data/com.termux/files/usr/share/nginx/html;
}
}

# 若依 - nodejs-vue
server {
listen 5000;
server_name 192.168.3.155;

location / {
# dist为静态资源文件夹,dist内有index.html,
root /data/data/com.termux/files/home/vue/ruoyi-vue/dist;
index index.html index.htm;
# 解决单页面应用中history模式不能刷新的bug
try_files $uri $uri/ /index.html;
# try_files $uri $uri/ =404;
}

# 服务器代理实现跨域
location /prod-api/ {
proxy_pass http://192.168.3.155:7002/; # 将/api/开头的url转向该域名
#如果报错则使用这一行代替上一行 proxy_pass http://localhost:8000; 将/api/开头的url转向该域名
rewrite "^/prod-api/(.*)$" /$1 break ; # 最终url中去掉/api前缀
}

# 静态资源优化 - 添加 ^~ 前缀提高匹配优先级
location ^~ /assets/ {
root /data/data/com.termux/files/home/vue/ruoyi-vue/dist;
expires 12h;
error_log /dev/null;
access_log /dev/null;
}


#ERROR-PAGE-START 错误页配置,可以注释、删除或修改
error_page 404 /404.html;

#REWRITE-START URL重写规则引用,修改后将导致面板设置的伪静态规则失效
# include /www/server/panel/vhost/rewrite/60.204.201.111.conf;
#REWRITE-END

#禁止访问的文件或目录
location ~ ^/(.user.ini|.htaccess|.git|.env|.svn|.project|LICENSE|README.md)
{
return 404;
}

#一键申请SSL证书验证目录相关设置
location ~ .well-known{
allow all;
}

#禁止在证书验证目录放入敏感文件
if ( $uri ~ "^/.well-known/.*.(php|jsp|py|js|css|lua|ts|go|zip|tar.gz|rar|7z|sql|bak)$" ) {
return 403;
}

location ~ .*.(gif|jpg|jpeg|png|bmp|swf|ico)$
{
expires 30d;
error_log /dev/null;
access_log /dev/null;
}

location ~ .*.(js|css)?$
{
expires 12h;
error_log /dev/null;
access_log /dev/null;
}
access_log /data/data/com.termux/files/usr/var/log/nginx/access.log;
error_log /data/data/com.termux/files/usr/var/log/nginx/error.log;
}

本地访问

本人手机的ip为:192.168.3.155,端口用nginx的配置项即可,在Termux中输入nginx来启动,这样就可以本地访问了。

不知道ip的可以输入ifconfig来查看

image.png 先访问一下192.168.3.155:5173

image.png 可以看到,在电脑上已经能访问手机上启动的服务了。

但是我们需要外网也能访问,这就需要前面说的内网穿透了。

内网穿透

本项目的内网穿透选的是cpolar,教程见上文链接。

因为我装了sv工具,所以我输入sv up cpolar就启动了cpolar,启动后在电脑上输入手机IP + 9200端口号即可登录cpolar后台

image.png

配置本地的端口号: image.png 配置完后,就可以在在线隧道列表菜单看到已配置的了

image.png 然后我们就可以在公网地址访问了,复制列表的地址,打开:

image.png

至此,我们已经可以在外网访问手机上、部署的vue打包项目了。但是此时没有后端服务,接下来我们同时部署后端的服务。

部署nodejs后端

先运行以下命令,启动redis和数据库

redis-server --daemonize yes 

mysqld_safe &

然后根据nodejs的启动方法启动即可,一般为node 入口文件.js

我的启动成功如下

image.png

对应的前端地址如下:6331dea4.r5.cpolar.top/index

这个前后端是我用nodejs改写的java版若依管理后台,源码地址:gitee.com/ruirui-stud… 我以前的文章也有介绍的

最后,如果需要启动多个nodejs项目,可以用pm2管理

注意:本文的地址可能无法访问,因为手机我有别的用处,不一定随时开着


作者:前端没钱
来源:juejin.cn/post/7537893826595700788

收起阅读 »

Electron 淘汰!新的跨端框架来了!性能飙升!

web
用过 Electron 的兄弟都懂,好处是“会前端就能写桌面”,坏处嘛,三座大山压得喘不过气:体积巨婴空项目打出来 100 M+,每次更新又得 80 M,用户宽带不要钱?内存老虎开个“Hello World”常驻&nbs...
继续阅读 »

用过 Electron 的兄弟都懂,好处是“会前端就能写桌面”,坏处嘛,三座大山压得喘不过气:

  • 体积巨婴
    空项目打出来 100 M+,每次更新又得 80 M,用户宽带不要钱?
  • 内存老虎
    开个“Hello World”常驻 300 M,再开几个窗口,直接 1 G 起步,Mac 用户看着彩虹转圈怀疑人生。
  • 启动慢动作
    双击图标 → 图标跳 → 白屏 3 秒 → 终于看见界面,节奏堪比 56 K 猫拨号。

老板还天天催:“两周给我 MVP!”—— 抱着 Electron,就像抱着一只会写代码的胖熊猫,可爱但跑不动。

主角登场:GPUI

Rust 圈最近冒出一个“狠角色”——GPUI

GPUI,是 Zed 编辑器团队推出的 Rust UI 框架,以 GPU 加速和高效渲染模式悄然崛起。

它不卖广告,纯开源,一句话介绍:直接拿显卡画界面,浏览器啥的全部踢出去

  • 底层用 wgpuMetal / Vulkan / DX12 想调谁就调谁;
  • 上层给前端味道的 DSL,写起来像 React,跑起来却是纯原生;
  • 安装包 12 M,内存 50 M,启动 0.4 秒,表格滑到 60 帧不带喘。

说人话:把 Electron 的“胖身子”抽真空,留下一身腱子肉。

亮点:为什么值得换坑?

场景Electron 现实GPUI 现实
安装包100 M+ 是常态12 M 起步,单文件都能带走
空载内存一开 300 M,再开几个窗口直奔 1 G50 M 晃悠,再多窗口也淡定
启动速度白屏 2~3 秒肉眼可见 0.4 秒
大数据表格十万行就卡成 PPT百万行照样 60 fps,滑到飞起
主题切换重载 or 重启一行代码,热切换,深色浅色瞬间完成

外加 60+现成组件:按钮、表格、树、日历、Markdown、穿梭框……皮肤直接照搬 Web 圈最火的 shadcn/ui,设计师不用改稿,开发直接复制粘贴。

五分钟上手:从零到 Hello Window

① 先装 Rust

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

一路回车即可,30 秒搞定。

② 新建工程

cargo new my-app && cd my-app

③ 把依赖写进 Cargo.toml

[dependencies]
gpui = "0.2"
gpui-component = "0.1"

④ src/main.rs 写几行

use gpui::*;

fn main() {
App::new().run(|cx: &mut AppContext| {
Window::new("win", cx)
.title("我的第一个 GPUI 窗口")
.build(cx, |cx| {
Label::new("Hello,GPUI!", cx)
})
.unwrap();
});
}

⑤ 跑!

cargo run

三秒后窗口蹦出来,Hello 世界完成。没有黑框,没有白屏,体验跟原生记事本一样丝滑。

写代码像 React,跑起来像 C++

组件化 + 事件回调,前端同学一看就懂:

Button::new("点我下单", cx)
.style(ButtonStyle::Primary)
.on_click(|_, cx| {
println!("订单已发送");
notify_user("成交!", cx);
})

背后是 Rust 的零成本抽象,编译完就是机器码,没有浏览器,没有虚拟机,没有 GC 卡顿,性能直接拉满。

老网页也别扔,一键塞进来

历史项目里还有 React 报表?开 webview 特性就行:

gpui-component = { version = "0.1", features = ["webview"] }

窗口里留一块“浏览器区域”,把旧地址挂进去,零改动复用,妈妈再也不用担心重写代码。

Electron 依然是老大哥,但“胖身子”在 2025 年真的有点跟不上节奏。
新项目、新团队、新想法,不妨给 GPUI 一个机会——试过之后,你可能再也回不去了。


作者:前端开发爱好者
来源:juejin.cn/post/7568192652287787062
收起阅读 »

我天,Java 已沦为老四。。

略想了一下才发现,自己好像有大半年都没有关注过 TIOBE 社区了。TIOBE 编程社区相信大家都听过,这是一个查看各种编程语言流行程度和趋势的社区,每个月都有榜单更新,每年也会有年度榜单和总结出炉。昨晚在家整理浏览器收藏夹时,才想起了 TIOBE ...
继续阅读 »

略想了一下才发现,自己好像有大半年都没有关注过 TIOBE 社区了。

TIOBE 编程社区相信大家都听过,这是一个查看各种编程语言流行程度和趋势的社区,每个月都有榜单更新,每年也会有年度榜单和总结出炉。

昨晚在家整理浏览器收藏夹时,才想起了 TIOBE 社区,于是打开看了一眼最近的 TIOBE 编程语言社区指数

没想到,Java 居然已经跌出前三了,并且和第一名 Python 的差距也进一步拉开到了近 18%。

回想起几年前,Java 曾是何等地风光。

各种基于 Java 技术栈所打造的 Web 后端、互联网服务成为了移动互联网时代的中坚力量,同时以 Java 开发为主的后端岗位也是无数求职者们竞相选择的目标。

然而这才过去几年,如今的 Java 似乎也没有了当年那种无与争锋的强劲势头,由此可见 AI 领域的持续进化和繁荣对它的冲击到底有多大。

用数据说话最有说服力。

拉了一下最近这二十多年来 Java 的 TIOBE 社区指数变化趋势看了看,情况似乎不容客观。

可以明显看到的是一个:呈震荡式下降的趋势

Java语言的TIOBE社区指数变化

现如今,Java 日常跌出前三已经成为了常态,并且和常居榜首的 Python 的差距也是越拉越大了。

在目前最新发布的 TIOBE Index 榜单中排名前十的编程语言分别是:

  • Python
  • C++
  • C
  • Java
  • C#
  • JavaScript
  • Visual Basic
  • Go
  • Perl
  • Delphi/Object Pascal

其中 Python 可谓是一骑绝尘,与排名第二的 C++ 甚至拉开了近 17% 的差距,呈现了断崖式领先的格局。

不愧是 AI 领域当仁不让的“宠儿”,这势头其他编程语言简直是望尘莫及!

另外还值得一提的就是 C 语言。

最近这几个月 C 语言的 TIOBE Index Ratings 比率一直在回升,这说明其生命力还是非常繁荣的,这对于一个已经诞生 50 多年的编程语言来说,着实不易。

C 语言于上个世纪 70 年代初诞生于贝尔实验室,由丹尼斯·里奇(Dennis MacAlistair Ritchie)以肯·汤普森(Kenneth Lane Thompson)所设计的 B 语言为基础改进发展而来的。

C语言之父:丹尼斯·里奇

就像之前 TIOBE 社区上所描述的,这可能主要和当下物联网(IoT)技术的发展繁荣,以及和当今发布的大量小型智能设备有关。毕竟 C 语言运行于这些对性能有着苛刻要求的小型设备时,性能依然是最出色的。

说到底,编程语言本身并没有所谓的优劣之分,只有合适的应用场景与项目需求

按照官方的说法,TIOBE 榜单编程语言指数的计算和主流搜索引擎上不同编程语言的搜索命中数是有关的,所以某一程度上来说,可以反映出某个编程语言的热门程度(流行程度、受关注程度)。

而通过观察一个时间跨度范围内的 TIOBE 指数变化,则可以一定程度上看出某个编程语言的发展趋势,这对于学习者来说,可以作为一个参考。

Java:我啥场面没见过

曾经的 Java 可谓是互联网时代不可或缺的存在。早几年的 Java 曲线一直处于高位游走,彼时的 Java 正是构成当下互联网生态繁荣的重要编程语言,无数的 Web 后端、互联网服务,甚至是移动端开发等等都是 Java 的擅长领域。

而如今随着 AI 领域的发展和繁荣,曾经的扛把子如今似乎也感受到了前所未有的压力。

C语言:我厉兵秣马

流水的语言,铁打的 C。

C 语言总是一个经久不衰的经典编程语言,同时也是为数不多总能闯进榜单前三的经典编程语言。

自诞生之日起,C 语言就凭借其灵活性、细粒度和高性能等特性获得了无可替代的位置,就像上文说的,随着如今的万物互联的物联网(IoT)领域的兴起,C 语言地位依然很稳。

C++:我稳中求进

C++ 的确是一门强大的语言,但语言本身的包袱也的确是不小,而且最近这几年的指数趋势稳中求进,加油吧老大哥。

Python:我逆流而上

当别的编程语言都在震荡甚至下跌之时,Python 这几年却强势上扬,这主要和当下的数据科学、机器学习、人工智能等科学领域的繁荣有着很大的关系。

PHP:我现在有点慌

PHP:我不管,我才是世界上最好的编程语言,不接受反驳(手动doge)。


好了,那以上就是今天的内容分享了,感谢大家的阅读,我们下篇见。

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


作者:CodeSheep
来源:juejin.cn/post/7540497727161417766
收起阅读 »

Flutter官方拒绝适配鸿蒙的真相:不是技术问题,而是...

这两年随着鸿蒙系统相关的争议变多,讨论Flutter 在鸿蒙上的适配的争议也开始变多了。比如前段时间写了一篇文章讨论用Flutter开发鸿蒙应用。Flutter 3.35倒逼鸿蒙:兼容or出局,没有第三条路!就有人评论说应该是Flutter官方适配鸿蒙,而不是...
继续阅读 »

这两年随着鸿蒙系统相关的争议变多,讨论Flutter 在鸿蒙上的适配的争议也开始变多了。

比如前段时间写了一篇文章讨论用Flutter开发鸿蒙应用。

Flutter 3.35倒逼鸿蒙:兼容or出局,没有第三条路!

就有人评论说应该是Flutter官方适配鸿蒙,而不是鸿蒙适配Flutter。

其实这么说也是有一点道理的(虽然不多),今天老刘就展开分析以下到底应该是谁来适配谁?


从技术角度看:Flutter确实应该主动适配鸿蒙

Flutter作为跨平台框架,它的核心价值就是"一套代码,多端运行",所以如果不能适配重要平台,那就失去了跨平台的意义。

在这里插入图片描述

就像当年Flutter必须适配iOS和Android一样。

这不是谁求谁的问题,这是技术逻辑的问题。

Flutter从诞生那天起,就打着"Write once, run anywhere"的旗号。

但是事实是Flutter官方确实没有表现出适配意愿。


现实情况更复杂:这是一个博弈过程

理想很丰满,现实很骨感。

技术逻辑是一回事,商业逻辑是另一回事。

在当前的经济形势下,各个企业去增加一个独立的鸿蒙团队的成本是难以接受的。

Flutter的价值就在于能够有效的降低这种成本。

因此站在鸿蒙的角度,是应该主动适配Flutter的,而不是等待Flutter官方适配。

其实不仅仅是Flutter,主流的跨平台框架鸿蒙官方都有必要去主动适配。

这就像是一个新开的商场,你不能指望品牌商主动来入驻。

你得主动去招商,提供优惠政策,比如免费装修。

鸿蒙的困境:

  • 用户基数还很少,开发者投入意愿不强。
  • 生态建设需要时间,短期内难以完全替代Android。
  • 政策推动有限,最终还是要靠技术魅力。 在这里插入图片描述

Flutter的考量:

  • Google作为Flutter的主导者,对鸿蒙的态度可能比较复杂。

    这个有国际形势的原因,具体背后有哪些权衡咱也不知道,咱也不敢说。

  • 本质的原因是鸿蒙的体量还不够。

    就好像当年微软的Windows Phone,技术很好,没有足够的市场份额,开发者就不会买账。

所以从谁受益的角度来看,明显鸿蒙方面去适配Flutter的收益更大。


鸿蒙已经在做Flutter适配

话说回来,其实鸿蒙方面已经在为包括Flutter在内的跨平台框架做适配了。

而且动作还不小。

关键时间线

让我们先看看这几年鸿蒙Flutter适配的关键节点:

2021年1月 - 美团外卖MTFlutter团队率先突破。

发布《让Flutter在鸿蒙系统上跑起来》技术文章。

应该是业界首次公开的Flutter鸿蒙适配探索。

2023年8月 - 华为在HDC大会正式发声。

发布HarmonyOS NEXT,确定第一批跨平台框架适配名单:

  • Flutter
  • React Native
  • 京东Taro
  • uni-app

2023年9月 - OpenHarmony-SIG组织正式开源Flutter适配项目。

基于Flutter 3.7版本进行适配。

这意味着适配工作从企业内部走向了开源社区。

2024年8月 - 三方库适配取得重大进展。

深开鸿、开鸿智谷、鸿湖万联完成36个Flutter三方库适配。

其中9个完成测试验收。

具体适配工作有哪些

从技术层面来看,鸿蒙适配Flutter主要需要做这几件事:

嵌入层开发

重新实现Flutter嵌入层以适配鸿蒙平台。

这是最核心的工作,相当于给Flutter换了一个"底盘"。

Flutter Engine移植

基于Android版本进行鸿蒙平台的移植。

这里有个巧妙的地方,鸿蒙系统延用了Android的很多技术方案。

比如Vulkan图形API。

所以把Impeller这样的渲染引擎移植过来,并不需要大动干戈。

开发工具适配

Flutter Tools支持构建HAP包。

这样开发者就可以用熟悉的Flutter命令行工具直接构建鸿蒙应用了。

生态建设的困局

但是,技术适配只是第一步,真正的挑战在于生态建设。

简单来说就是:Flutter有了,但是三方库还没有完全适配好。

从技术原理来说,如果是纯Dart的三方库,适配起来应该比较简单。

大概率是能直接运行的,或者极少的修改就能运行。

但是如果涉及到原生代码的三方库,那就麻烦了。

需要重新移植Android/iOS的原生代码到鸿蒙平台。

这个工作量就比较大了。

而且很多三方库的维护者可能对鸿蒙平台并不熟悉,更没有去适配的意愿。

对鸿蒙上各种开发框架来说都是这样的,基础库的不完善造成了开发者移植app的困难,进一步造成了App数量的缺少,即使移植过来也可能是功能缺失的。

应用数量和质量都不够就很难快速提升用户量,用户量不够就很难吸引足够多的开发者。

这就形成了一个恶性循环。

在这里插入图片描述

总结

其实说到底,这也不能说是什么博弈。

任何一个跨平台框架都不可能去适配所有的系统。

就像Flutter也没有适配塞班、Windows Phone这些已经消失的系统一样。

反过来说,作为体量还不够大的系统,主动去提供更好的应用移植解决方案,确实是快速建立生态的最佳路径。

老刘作为一个开发人员,我觉得一个新的系统要想快速建立生态,其实更好的方案是向上提供一套和现有最流行系统(比如Android)兼容的系统级API。

这样大部分应用可以用最小的代价迁移到新系统上。

如果你真的觉得现有的系统API有很大的缺陷,也完全可以在现有API基础上做增量优化。

如果你的优化真的有很大先进性,随着开发者增加,自然有人会使用。

当然这只是开发者的角度。

很多事情也不是给开发者做的。

连API都是全新的全自主研发系统和兼容API的系统,对很多不懂技术的人来说还是有很大差别的。

另一方面,鸿蒙系统这种设计在智能家居、汽车等不太依赖现有生态的场景下,也有自己的优势。

毕竟在这些新兴领域,大家都是从零开始,没有历史包袱。

鸿蒙的分布式架构、万物互联的理念,在这些场景下确实有独特的价值。

所以,与其纠结谁适配谁,不如关注技术本身能解决什么问题。

Flutter适配鸿蒙也好,鸿蒙适配Flutter也好,最终受益的都是开发者和用户。

技术的发展从来不是零和游戏,而是共同进步的过程。

如果看到这里的同学对客户端或者Flutter开发感兴趣,欢迎联系老刘,我们互相学习。 私信免费领老刘整理的《Flutter开发手册》,覆盖90%应用开发场景。 可以作为Flutter学习的知识地图。

—— laoliu_dev


作者:程序员老刘
来源:juejin.cn/post/7569038855610007562
收起阅读 »

从「[1,2,3].map (parseInt)」踩坑,吃透 JS 数组 map 与包装类核心逻辑

web
你有没有遇到过这样的诡异场景:明明以为 [1,2,3].map(parseInt) 会返回 [1,2,3],实际运行却得到 [1, NaN, NaN]?这行看似简单的代码,藏着 JS 数组方法、函数传参、包装类等多个核心...
继续阅读 »

你有没有遇到过这样的诡异场景:明明以为 [1,2,3].map(parseInt) 会返回 [1,2,3],实际运行却得到 [1, NaN, NaN]

这行看似简单的代码,藏着 JS 数组方法、函数传参、包装类等多个核心知识点的关联。今天我们就从这个经典坑点切入,一步步拆解 map 方法的底层逻辑,顺带理清 NaN、包装类、字符串处理等容易混淆的知识点。

一、先踩坑:为什么 [1,2,3].map (parseInt) 不是 [1,2,3]?

要搞懂这个问题,我们得先明确两个关键:map 方法的参数传递规则,以及 parseInt 的工作原理。

1. map 方法的真正传参逻辑

MDN 明确说明:map 方法会遍历原数组,对每个元素调用回调函数,并将三个参数依次传入回调:

  • 当前遍历的元素(item)
  • 元素的索引(index)
  • 原数组本身(arr)

也就是说,[1,2,3].map(parseInt) 等价于:

javascript

运行

[1,2,3].map((item, index, arr) => {
return parseInt(item, index, arr);
});

这里的关键是:map 会强制传递三个参数给回调,而不是只传我们以为的 “元素本身”。

2. parseInt 的参数陷阱

parseInt 的语法是 parseInt(string, radix),它只接收两个有效参数:

  • 第一个参数:要转换的字符串(非字符串会先转字符串)
  • 第二个参数:基数(进制,范围 2-36,0 或省略则默认 10 进制)
  • 第三个参数会被直接忽略

结合 map 的传参,我们逐次分析遍历过程:

  • 第一次遍历:item=1,index=0 → parseInt (1, 0)。基数 0 等价于 10 进制,结果 1。
  • 第二次遍历:item=2,index=1 → parseInt (2, 1)。基数 1 无效(必须≥2),结果 NaN。
  • 第三次遍历:item=3,index=2 → parseInt (3, 2)。2 进制中只有 0 和 1,3 无效,结果 NaN。

这就是为什么最终结果是 [1, NaN, NaN] —— 不是 map 或 parseInt 本身有问题,而是参数传递的 “错位匹配” 导致的。

3. 正确写法是什么?

如果想通过 map 实现 “数组元素转数字”,正确做法是明确回调函数的参数,只给 parseInt 传需要的值:

javascript

运行

// 方法1:手动控制参数
[1,2,3].map(item => parseInt(item));
// 方法2:使用Number简化
[1,2,3].map(Number);
// 两种写法结果都是 [1,2,3]

二、吃透 map 方法:不止是 “遍历 + 返回”

解决了坑点,我们再深入理解 map 的核心特性 —— 它是 ES6 数组新增的纯函数(不改变原数组,返回新数组),这也是它和 forEach 的核心区别。

1. map 的核心规则(必记)

  • 不改变原数组:无论回调函数做什么操作,原数组的元素都不会被修改。
  • 返回新数组:新数组长度与原数组一致,每个元素是回调函数的返回值。
  • 跳过空元素:map 会忽略数组中的 empty 空位(forEach 也会),但不会忽略 undefined 和 null。

示例验证:

javascript

运行

const arr = [1, 2, 3, , 5]; // 第4位是empty
const newArr = arr.map(item => item * 2);
console.log(newArr); // [2,4,6, ,10](保留空位)
console.log(arr); // [1,2,3, ,5](原数组不变)

2. 实用场景:从基础到进阶

map 的核心价值是 “数据转换”,日常开发中高频使用:

  • 基础转换:数组元素的统一处理(如平方、转格式)

    javascript

    运行

    const arr = [1,2,3,4,5,6];
    const squares = arr.map(item => item * item); // [1,4,9,16,25,36]
  • 复杂转换:提取对象数组的特定属性

    javascript

    运行

    const users = [{name: '张三'}, {name: '李四'}, {name: '王五'}];
    const names = users.map(user => user.name); // ['张三', '李四', '王五']

三、延伸知识点:NaN 与包装类,JS 的 “隐式魔法”

在分析 map 和 parseInt 的过程中,我们遇到了 NaN,而 JS 中字符串能调用length方法的特性,又涉及到 “包装类” 的隐式逻辑 —— 这两个知识点是理解 JS “面向对象特性” 的关键。

1. NaN:不是数字的 “数字”

NaN 的全称是 “Not a Number”,但 typeof 检测结果是number,这是它的第一个反直觉点。

什么时候会出现 NaN?

  • 无效的数学运算:0/0Math.sqrt(-1)"abc"-10
  • 类型转换失败:parseInt("hello")Number(undefined)
  • 注意:Infinity(6/0)和-Infinity(-6/0)不是 NaN,它们是有效的 “无穷大” 数值。

如何正确判断 NaN?

因为NaN === NaN的结果是false(NaN 不等于任何值,包括它自己),所以必须用专门的方法:

javascript

运行

// 推荐:ES6新增的Number.isNaN(只检测NaN)
Number.isNaN(parseInt("hello")); // true

// 不推荐:window.isNaN(会先转换类型,误判情况多)
isNaN("hello"); // true("hello"转数字是NaN)
isNaN(123); // false

2. 包装类:JS 让 “简单类型” 拥有对象能力

JS 是完全面向对象的语言,但我们平时写的"hello".length520.1314.toFixed(2),看起来是 “简单数据类型调用对象方法”—— 这背后就是包装类的隐式操作。

包装类的工作流程

当你对字符串、数字、布尔值这些简单类型调用方法时,JS 会自动做三件事:

  1. 用对应的构造函数(String、Number、Boolean)创建一个临时对象(包装对象);
  2. 通过这个临时对象调用方法(如 length、toFixed);
  3. 方法调用结束后,立即销毁临时对象,释放内存。

用代码还原这个过程:

javascript

运行

let str = "hello";
console.log(str.length); // 实际执行过程:
const tempObj = new String(str); // 1. 创建包装对象
console.log(tempObj.length); // 2. 调用方法
tempObj = null; // 3. 销毁对象

关键区别:简单类型 vs 包装对象

javascript

运行

let str1 = "hello"; // 简单类型(string)
let str2 = new String("hello"); // 包装对象(object)

console.log(typeof str1); // "string"
console.log(typeof str2); // "object"
console.log(str1.length === str2.length); // true(方法调用结果一致)

四、拓展:字符串处理的常见误区(length、slice、substring)

包装类让字符串拥有了对象方法,但字符串处理中也有不少容易踩坑的点,结合笔记中的案例总结:

1. length 的 “坑”:emoji 占几个字符?

JS 的字符串用 UTF-16 编码存储,常规字符(如 a、中)占 1 个 16 位单位,emoji 和生僻字占 2 个及以上。length 属性统计的是 “16 位单位个数”,而非视觉上的 “字符个数”:

javascript

运行

console.log('a'.length); // 1(常规字符)
console.log('中'.length); // 1(常规字符)
console.log("𝄞".length); // 2(emoji占2个单位)
console.log("👋".length); // 2(emoji占2个单位)

2. slice vs substring:负数索引与起始位置

两者都用于截取字符串,但处理负数索引和起始位置的逻辑不同:

  • 负数索引:slice 支持从后往前截取(-1 是最后一位),substring 会把负数转为 0;
  • 起始位置:slice 严格按 “前参为起点,后参为终点”,substring 会自动交换大小值(小的当起点)。

示例对比:

javascript

运行

const str = "hello";
console.log(str.slice(-3, -1)); // "ll"(从后数第3位到第1位)
console.log(str.substring(-3, -1)); // ""(负数转00>0无结果)
console.log(str.slice(3, 1)); // ""3>1无结果)
console.log(str.substring(3, 1)); // "el"(自动交换为1-3

五、总结:从坑点到体系化知识

回到最初的[1,2,3].map(parseInt),这个坑的本质是 “对 API 参数传递规则的理解不透彻”。但顺着这个坑,我们串联起了:

  • map 方法的参数传递、纯函数特性;
  • parseInt 的基数规则、类型转换逻辑;
  • NaN 的特性与判断方法;
  • 包装类的隐式工作流程;
  • 字符串处理的常见误区。

JS 的很多 “诡异现象”,本质都是对底层逻辑的不了解。掌握这些核心知识点后,再遇到类似问题时,就能快速定位根源 —— 这也是我们从 “踩坑” 到 “成长” 的关键。

最后留一个小思考:["10","20","30"].map(parseI


作者:生椰丝绒拿铁
来源:juejin.cn/post/7569898158835777577
收起阅读 »

线程池中的坑:线程数配置不当导致任务堆积与拒绝策略失效

“线程池我不是早就会了吗?corePoolSize、maxPoolSize、queueSize 都能背下来!”—— 真正出事故的时候你就知道,配置这仨数,坑多得跟高考数学题一样。一、线上事故复盘:任务全卡死,日志一片寂静几个月前有个定时任务服务,凌晨会并发处理...
继续阅读 »

“线程池我不是早就会了吗?corePoolSize、maxPoolSize、queueSize 都能背下来!”

—— 真正出事故的时候你就知道,配置这仨数,坑多得跟高考数学题一样。


一、线上事故复盘:任务全卡死,日志一片寂静

几个月前有个定时任务服务,凌晨会并发处理上千个文件。按理说线程池能轻松抗住。
结果那天凌晨,监控报警:任务积压 5 万条,机器 CPU 却只有 3%!

去看线程 dump:


pool-1-thread-1 waiting on queue.take()
pool-1-thread-2 waiting on queue.take()
...

线程都在等任务,但任务明明在队列里!

当时线程池配置如下:

new ThreadPoolExecutor(
5,
10,
60L, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(10000),
new ThreadPoolExecutor.AbortPolicy()
);

看起来没毛病对吧?
实际结果是:拒绝策略从未生效、maxPoolSize 永远没机会触发。


二、真相:线程池参数不是你想的那样配的

要理解问题,得先知道 ThreadPoolExecutor 的任务提交流程。

任务提交 → 核心线程是否满?
↓ 否 → 新建核心线程
↓ 是 → 队列是否满?
↓ 否 → 放入队列等待
↓ 是 → 是否小于最大线程数?
↓ 是 → 创建非核心线程
↓ 否 → 拒绝策略触发

也就是说:
只要队列没满,线程池就不会创建非核心线程。

所以:

  • 你的 corePoolSize = 5
  • 队列能放 10000 个任务;
  • maxPoolSize = 10 永远不会触发;
  • 线程永远就那 5 个在干活;
  • 队列里的任务越堆越多,拒绝策略永远“假死”。

三、踩坑场景实录

场景错误配置结果
高频接口异步任务LinkedBlockingQueue<>(10000)队列太大 → 拒绝策略形同虚设
IO密集型任务核心线程过少(如5)CPU空闲但任务堆积
CPU密集型任务核心线程过多(如50)上下文切换浪费CPU
线程池共用多个模块共用一个 pool某任务阻塞导致全局“死锁”

四、正确配置姿势(我现在都这么配)

思路很简单:

小队列 + 合理核心数 + 合理拒绝策略
而不是 “大队列 + 盲目扩大线程数”。

例如 CPU 密集型任务:

int cores = Runtime.getRuntime().availableProcessors();
ThreadPoolExecutor executor = new ThreadPoolExecutor(
cores + 1,
cores + 1,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<>(100),
new ThreadPoolExecutor.CallerRunsPolicy()
);

IO 密集型任务:

int cores = Runtime.getRuntime().availableProcessors();
ThreadPoolExecutor executor = new ThreadPoolExecutor(
cores * 2,
cores * 4,
60L, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(200),
new ThreadPoolExecutor.CallerRunsPolicy()
);

关键思想:

  • 宁可拒绝,也不要堆积。
  • 拒绝意味着“系统过载”,堆积意味着“慢性自杀”。

五、拒绝策略的“假死”与自定义方案

内置的 4 种拒绝策略:

  • AbortPolicy:直接抛异常(最安全)
  • CallerRunsPolicy:调用方线程执行(可限流)
  • DiscardPolicy:悄悄丢弃任务(最危险)
  • DiscardOldestPolicy:丢最老的(仍可能乱序)

如果你想更智能一点,可以自定义:

new ThreadPoolExecutor.AbortPolicy() {
@Override
public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
log.warn("任务被拒绝,当前队列:{}", e.getQueue().size());
// 可以上报监控 / 发报警
}
};

六、监控才是救命稻草

别等到队列堆积了才发现问题。
我建议给线程池加实时监控,比如:

ScheduledExecutorService monitor = Executors.newScheduledThreadPool(1);
monitor.scheduleAtFixedRate(() -> {
log.info("PoolSize={}, Active={}, QueueSize={}",
executor.getPoolSize(),
executor.getActiveCount(),
executor.getQueue().size());
}, 0, 5, TimeUnit.SECONDS);

这样你能第一时间看到线程数没涨、队列在爆


🧠 七、总结(踩坑后记)

项目错误思路正确思路
corePoolSize设太小根据 CPU/I/O 特性动态计算
queueCapacity设太大保持小容量以触发拒绝策略
maxPoolSize没触发仅当队列满后才会启用
拒绝策略默认 Abort建议自定义/限流处理
监控没有定期打印状态日志

最后一句话:

“线程池是救命的工具,用不好就变慢性毒药。”


✍️ 写在最后

如果你看到这里,不妨想想自己的系统里有多少个 newFixedThreadPool、多少个默认 LinkedBlockingQueue 没有限制大小。
你以为是“优化”,其实是定时炸弹。


作者:菜鸟的迷茫
来源:juejin.cn/post/7566476530500223003
收起阅读 »

中石化将开源组件二次封装申请专利,这个操作你怎么看?

web
开源项目推荐:uView Pro 正式开源!70+ Vue3 组件重构完成,uni-app 组件库新选择一. 前言昨天看到了一篇关于 “中石化申请基于 vue 的文件上传组件二次封装方法和装置专利,解决文件上传功能开发繁琐问题” 的新闻。今天特地在专利系统检索...
继续阅读 »

开源项目推荐:

一. 前言

昨天看到了一篇关于 “中石化申请基于 vue 的文件上传组件二次封装方法和装置专利,解决文件上传功能开发繁琐问题” 的新闻。

今天特地在专利系统检索了一下,竟然是真的,令人不禁大跌眼镜!用的全是开源组件,最后还把它们变成了自己的专利!这波操作属实厉害啊!

image.png

image.png

难道以后要用这种方式上传文件,要交专利费了?哈哈....

说来好笑,有掘友指出有单词拼写错误,我又查看一下专利文件,竟然还真有拼写错误...

image.png

二. 了解一下

本专利是通过在 vue 页面中自定义 el-upload 组件和 el-progress 组件的使用,解决了文件上传功能开发步骤繁琐和第三方组件无法满足业务需求的问题,实现了简化开发、提高效率和灵活性的效果。

1. 摘要

本发明提供了一种基于 vue 的文件上传组件的二次封装方法和装置,解决了针对于文件上传功能的开发步骤繁琐,复杂,且上传功能的第三方组件无法完全满足业务需求的问题。

该基于 vue 的文件上传组件的二次封装方法包括:在 vue 页面中创建 el‑upload 组件和 el‑progress 组件;

基于所述 el‑upload 组件获取目标上传文件的大小,并判断所述目标上传文件的大小是否符合上传标准;若是,上传所述目标上传文件,并基于所述 el‑progress 组件获取上传进度;上传完成后,对上传的所述目标上传文件进行预处理并存储;

对存储的所述目标上传文件进行封装,并获得 vue 组件。

技术流程图:

Snipaste_2025-06-12_17-07-28.png

二次封装装置模块:

image.png

2. 解决的技术问题

现有技术中文件上传功能的开发步骤繁琐复杂,第三方组件无法完全满足业务需求。

3. 采用的技术手段

通过在 vue 页面中引入 el-upload 组件和 el-progress 组件,自定义上传方法和进度条绑定,获取文件大小和上传进度,进行预处理和存储,并将其封装成可重复使用的 vue 组件。

4. 产生的技术功效

简化了文件上传功能的开发步骤,节省了开发时间和效率,避免了代码沉冗,降低了后期维护成本,并提高了文件上传功能的灵活性。

三. 实现一下

这种简单的上传文件+上传进度显示不是最基本的业务封装吗?相信这是每个前端开发工程师必备的基础技能。

所以我们趁热打铁,我们也来实现一下。

我也先来个流程图,梳理一下文件上传过程:

image.png

1. el-upload + el-progress 组合

  • el-upload 负责文件选择、上传。
  • el-progress 负责展示上传进度。

2. 文件大小校验

  • 使用 el-upload 的 before-upload 钩子,判断文件大小是否符合标准。

3. 上传进度获取

  • 使用 el-upload 的 on-progress 钩子,实时更新进度条。

4. 上传完成后的预处理与存储

  • 上传完成后,触发自定义钩子(如 beforeStoreonStore),进行预处理和存储。

5. 封装为 Vue 组件

  • 通过 props、emits、插槽等方式,暴露灵活的接口,便于业务页面集成。

都懒得自己动手,让 Cursor 来实现一下。Cursor 还是一如既往的强大,基本上一次询问就能成功!我表示 Cursor 在手,天下我有!

113.gif

UploaderWrapper 自定义组件:



<template>
<div class="file-uploader">
<ElUpload
:action="action"
:before-upload="beforeUpload"
:on-progress="handleProgress"
:on-success="handleSuccess"
:on-error="handleError"
:limit="limit"
:on-exceed="handleExceed"
:show-file-list="showFileList"
:multiple="multiple"
:accept="accept"
v-model:file-list="fileList"
:on-remove="handleRemove"
>

<template #trigger>
<ElButton type="primary"> 选择文件上传 ElButton>
template>

<template #tip>
<div class="el-upload__tip">
支持的文件类型: {{ accept }},单个文件不超过 {{ maxSize }}MB
div>
template>
ElUpload>

<ElProgress
v-if="isUploading"
:percentage="uploadPercent"
:status="uploadPercent === 100 ? 'success' : ''"
class="mt-4"
/>

div>
template>

<style scoped>
.file-uploader {
width: 100%;
}
.el-upload__tip {
font-size: 12px;
color: #606266;
margin-top: 8px;
}
style>

使用方式:



<template>
<ElCard class="mb-5 w-80">
<template #header> 文件上传演示 template>
<UploaderWrapper
action="/api/upload"
:max-size="5"
:before-store="beforeStore"
:on-store="onStore"
/>

ElCard>
template>

效果如下所示:

119.gif

声明:“代码仅供演示,不要使用,以免有专利侵权风险,慎重!”

四. 思考一下

从开发者的角度来看,这个专利事件是否能给我们带来了一些值得思考影响和启示:

  1. 技术创新的边界问题
  • 使用开源组件进行二次封装是否应该被授予专利?
  • 是否对开源社区的发展可能产生负面影响?
  1. 对日常开发的影响
  • 如果专利获得授权,其他公司使用类似的文件上传组件封装方案是否可能面临法律风险?
  • 开发者是否需要寻找替代方案或支付专利费用?
  1. 对开源社区的影响
  • 可能打击开发者对开源项目的贡献热情,自己辛苦开源项目为别人做了嫁衣?
  • 是否会影响开源组件的使用和二次开发
  • 可能导致更多公司效仿,将开源组件的二次封装申请专利,因为毕竟专利对公司的招投标挺大的

五. 后记

“中石化作为传统能源企业,都能积极拥抱前端技术,还将内部技术方案申请专利,体现了他们对知识产权的重视?”

那我们是不是要在技术创新和知识产权保护之间找到平衡点,既要保护创新,又不能阻碍技术的发展。

而作为开发者的我们呢?这么简单的封装都能申请专利成功的话,那么...,大家有什么想法,是不是现在强的可怕?哈哈...

专利来源于国家知识产权局

申请公布号:CN120122937A


作者:前端梦工厂
来源:juejin.cn/post/7514858513442078754
收起阅读 »

Canvas 高性能K线图的架构方案

web
前言证券行业,最难的前端组件,也就是k线图了。指标还可以添加、功能还可以扩展, 但思路要清晰。作为一个从证券行业毕业的前端从业者,我想分享下自己的项目经验。1、H5 K线图,支持无限左右滑动、样式可随意定制;2、纯canvas制作,不借助任何第三方图表库;3、...
继续阅读 »

前言

证券行业,最难的前端组件,也就是k线图了。
指标还可以添加、功能还可以扩展, 但思路要清晰。
作为一个从证券行业毕业的前端从业者,
我想分享下自己的项目经验。

1、H5 K线图,支持无限左右滑动、样式可随意定制;
2、纯canvas制作,不借助任何第三方图表库;
3、阅读本文,需要有 canvas 基础知识;

滑动K线图组件    Github Page 预览地址

股票详情页源码    Github Page 预览地址

注意:以上的 demo 还有一些 bug, 没时间修复, 预览地址是直接在 github 上部署的, 所以最好通过 vpn科学上网,否则可能访问不了,然后再在移动端打开页面。 另外, 上面的股票详情页, 还没有做自适应,等我有时间再改。

一、先看最终的效果

1、GIF动图如下

gif222.gif gif.gif

2、支持样式自定义

用可以屏幕取色器,获取东方财富的配色 codeinword.com/eyedropper

图一、图二,是参考东方财富黑白皮肤的配色, 图三是参考腾讯自选股的配色。

q1.png q2.png q3.png

二、canvas 注意事项

1、整数坐标,会导致模糊

canvas 在画线段, 通常会出现以下代码:

cxt.moveTo(x1, y1);
cxt.lineTo(x2, y2);
cxt.lineWidth = 1;
cxt.stroke();

假设上面的两个点是(1,10)和(5,10),那么画出来的实际上是一条横线,
理论上横线的粗度是1px,且该横线被 y=10 切成两半,
上半部分粗度是 0.5px, 下半部分粗度也是 0.5px,
这样横线的整体粗度才会是 1px。

但是 canvas 不是这样处理的, canvas 默认线条会与整数对齐,
也就是横线的上部分不会是 y=9.5px, 而是 y=9px;
横线的下半部分也不是 y=10.5px, 而是 y=11px;
从而横线的粗度看起来不是1px,而是2px。

并且由于粗度被拉伸,颜色也会被淡化,那怎么解决这个问题呢?

处理方式也很简单, 通过 cxt.translate(0.5, 0.5) 将坐标往下移动 0.5 个像素,
然后接下来的所有点, 都保证是整数即可, 这样就能保证不会被拉伸。

典型的代码如下:

cxt.translate(0.5, 0.5);
cxt.moveTo(Math.floor(x1), Math.floor(y1));
cxt.lineTo(Math.floor(x2), Math.floor(y2));
cxt.lineWidth = 1;
cxt.stroke();

在我的代码中, 也体现了类似的处理。

2、如何处理高像素比带来的模糊

设备像素比越高,理论上应该越清晰,因为原来用一个小方块来渲染1px, 现在用2个(dpr=2的情况)小方块来渲染,应该更清晰才对,但是canvas不是这样的。

例如,通过js获取父容器 div 的宽度是 width, 这时候如果设置 canvas.width = width,在设备像素比为2的时候, canvas 画出来的宽度为css对应宽度的一半, 如果强制通过 css 将 canvas 宽度设置为 width, 则 canvas 会被拉长一倍, 导致出现锯齿模糊。

注意了吗?上面所说的 canvas.width=width 与 css 设置的 #canvas { width: width } 起到的效果是不一样的。不要随便通过 css 去设置 canvas 的宽高, 容易被拉伸变形或者导致模糊。

通用的处理方式是:

//初始化高清Canvas
function initHDCanvas() {
const rect = hdCanvas.getBoundingClientRect();

//设置Canvas内部尺寸为显示尺寸乘以设备像素比
const dpr = window.devicePixelRatio || 1;
hdCanvas.width = rect.width * dpr;
hdCanvas.height = rect.height * dpr;

//设置Canvas显示尺寸保持不变
hdCanvas.style.width = rect.width + 'px';
hdCanvas.style.height = rect.height + 'px';

//获取上下文并缩放
const ctx = hdCanvas.getContext('2d');
ctx.scale(dpr, dpr);

//接下来,你可以自由发挥
}

三、样式配置

为了方便样式自定义, 我独立出一个默认的配置对象 defaultKlineConfig, 参数的含义如下图所示,其实下图这个风格的标注, 是通过 excalidraw 这个软件画的, 也是 canvas 做的开源软件, 可见 canvas 在前端可视化领域的重要性, 这个扯远了,打住。

333.png

如上图, 整个canvas 画板, 分成 5 部分,
每一部分的高度, 都可以设置,
其中主图和副图的高度,是通过比例来计算的:
mainChartHeight = restHeight * mainChartHeightPercent
其中,restHeigh 是画板总高度 height 减去其他几部分计算的, 如下:
restHeight = height - lineMessageHeight - tradeMessageHeight - xLabelHeight

十字交叉线的颜色, X轴 与 Y轴 的 tooltip 背景色、字体大小的参数如下:

7777.png

四、均线计算

从上面的图可以看出, 需要画 5日均线、10日均线、20日均线, 成交量快线(10日)、成交量慢线(20日) 但是, 接口没有给出当日的均线值, 需要自己计算。

5日均线 = (过去4个成交日的收盘价总和 + 今日收盘价)/ 5

10日均线 = (过去9个成交日的收盘价总和 + 今日收盘价)/ 10

20日均线 = (过去19个成交日的收盘价总和 + 今日收盘价)/ 20

成交量快线 = (过去9日成交量 + 今日成交量)/ 10

成交量慢线 = (过去19日成交量 + 今日成交量)/ 20

所以, 当获取 lmt(一屏的蜡烛图个数)个数据时, 为了计算均线, 需要至少将前 19 个(我的代码写20)数据都获取到。当前一个均线已经获取到, 下一个均线就不需要再累加20个值再得平均数, 可以省一点计算:

今日20日均线值 = (昨日均线值 * 20 - 前面第20个的收盘价 + 今日收盘价)/ 20;

五、分层渲染

为了减少重绘,提高性能,可以将K线图做分层渲染。那分几层合适?我认为是三层。

  1. 第一层, 不动层
  2. 第二层,变动层
  3. 第三层,交互层

不动层

首先, 网格是固定的, 也就是说,当页面拖拽、或者长按出现十字交叉的时候,底部的网格线是不变的,如果每次拖拽,都需要重绘网格,那这个其实是没有必要的开销,可以将网格放在最底层(例如 z-index:0),一次性绘制后,就不要再重绘。

变动层

由于拖拽的时候,蜡烛柱体,均线,Y轴刻度, X轴刻度, 都需要重绘, 这一块是无法改变的事实, 所以, 变动层放在中间层(例如 z-index:1),也是最繁忙的一层,并且该层不响应触摸事件。

交互层

最后, 交互层由于要捕捉用户的触摸行为, 所以,这一层要在最上层(例如 z-index:2)。

交互层监听触摸事件:当页面快速滑动, 则响应拖拽事件, 即K线图的时间线会左右滑动;当用户长按之后才滑动, 则出现十字交叉浮层。

交互层的好处是, 当响应十字交叉浮层时, 只需要绘制横线、竖线、对应X轴和Y轴的值,而不需要重绘蜡烛柱体和均线, 可以减少重绘,最大程度减少渲染压力。

六、基础几何绘制

网格线

首先计算出主图的高度 this.mainChartHeight, 将主图从上到下等分为4部分,再在左右两边画出竖线,形成主图的网格,副图是成交量图, 只需画一个矩形边框即可,用 strokeRect 即可画出。

//画出网格线
private drawGridLine() {
//获取配置参数
const { gridColor, lineMessageHeight, xLabelHeight, width, height } = this.config;
//画出K线图的5条横线
const split = this.mainChartHeight / 4;
this.canvasCxt.beginPath();
this.canvasCxt.lineWidth = 0.5;
this.canvasCxt.strokeStyle = gridColor;
for (let i = 0; i <= 4; i++) {
const splitHeight = Math.floor(split * i) + lineMessageHeight!;
this.drawLine(0, splitHeight, width, splitHeight);
}
//画出K线图的2条竖线
this.drawLine(0, lineMessageHeight!, 0, lineMessageHeight! + this.mainChartHeight);
this.drawLine(width, lineMessageHeight!, width, lineMessageHeight! + this.mainChartHeight);
//画出成交量的矩形
this.canvasCxt.strokeRect(
0,
height - xLabelHeight! - this.subChartHeight,
width,
this.subChartHeight,
);
}

//画出两个点形成的直线
private drawLine(x1: number, y1: number, x2: number, y2: number) {
this.canvasCxt.moveTo(x1, y1);
this.canvasCxt.lineTo(x2, y2);
this.canvasCxt.stroke();
}

画各类均线

1、首先计算出一屏的股价最大值 max , 股价最小值 min ,成交量最大值 maxAmount。

2、当某一个点的均线为 value, 根据最大值、最小值、索引index, 计算出坐标点(x, y), 画均线的时候, 第一个点用 moveTo(x0, y0),其他点用 lineTo(xn yn), 最后 stroke 连起来即可。

3、当然, 每一条线设置下颜色, 即 stokeStyle。

  //画出各类均线
private drawLines(max: number, min: number, maxAmount: number) {
//将宽度分成n个小区间, 一个小区间画一个蜡烛, 每个区间的宽度是 splitW
const splitW = this.config.width / this.config.lmt!;
//画一下5日均线
this.canvasCxt.beginPath();
this.canvasCxt.strokeStyle = this.config.ma5Color;
this.canvasCxt.lineWidth = 1;
let isTheFirstItem = true;
for (
let i = this.startIndex;
i < this.arrayList.length && i < this.startIndex + this.config.lmt!;
i++
) {
const index = i - this.startIndex;
let value = this.arrayList[i].ju5;
if (value === 0) {
continue;
}
const x = Math.floor(index * splitW + 0.5 * splitW);
const y = Math.floor(
((max - value) / (max - min)) * this.mainChartHeight + this.config.lineMessageHeight!,
);
if (isTheFirstItem) {
this.canvasCxt.moveTo(x, y);
isTheFirstItem = false;
} else {
this.canvasCxt.lineTo(x, y);
}
}
this.canvasCxt.stroke();
}

画出蜡烛柱体

666.png 999.png

当收盘价大于等于开盘价, 选用上面左边红色的样式; 当收盘价小于开盘价, 选用上面右边绿色的样式。

以红色蜡烛为例, 最高点 A(x0, y0),最低点是 B(x1, y1),
高度 height、宽度 width 都是相对于坐标轴的,
红色矩形左上角的顶点是 D(x, y)。

为了画出红色蜡烛, 先后顺序别搞混:

  1. AB 这条竖线,通过 moveTo,lineTo 画出来;
  2. 定义一个矩形 cxt.rect(x, y, width, heigth);
  3. 通过 fill 将矩形内部填充为白色, 这时候白色矩形会覆盖掉红色竖线的一部分;
  4. 再通过 stroke 描出矩形的红色边框

按照上面这个顺序, 竖线会被覆盖掉一部分,同时,矩形内部的白色填充不会挤压矩形的红色边框, 如果先 stroke 再 fill,容易出现白色填充覆盖红色边框,矩形可能会变模糊,或者使得红色变淡,极其不友好,所以按照我上面的顺序,可以减少不必要的麻烦。

画出文字

canvas 画出文字, 典型的代码如下

 this.canvasCxt.beginPath();
this.canvasCxt.font = `${this.config.yLabelFontSize}px "Segoe UI", Arial, sans-serif`;
this.canvasCxt.textBaseline = 'alphabetic';
this.canvasCxt.fillStyle = this.config.yLabelColor;

注意textBaseline 默认对齐方式是 alphabetic, 但 middle 往往更好用, 能实现垂直居中,但我发现垂直居中也不是很居中,所以会特意加减1、2个像素;

当然还有个textAlign, 能实现水平对齐方式, 左右对齐都可以, 例如上图最左、最右的时间标签。

七、交互设计

根据上面的GIF动图, 可以知道, 本次做的移动端 K 线图, 最重要的两个交互是:

  1. 快速拖拽,K线图随时间轴左右滑动
  2. 长按滑动,出现十字交叉tooltip

上面的交互,其实是比较复杂的,所以需要先设计一个简单的数据结构:

  1. 首先页面存放一个列表 arrayList
  2. 保存一个数字标识 startIndex,表示当前屏幕从 startIndex 开始画蜡烛图

当用户往右快速拖拽时, startIndex 根据用户拖拽的距离, 适当变小; 当用户往左快速拖拽时, startIndex 根据用户拖拽的距离, 适当变大。

那 arrayList 到底多长合适, 因为股票可能有十几年的数据, 甚至上百年的数据, 我不能一次性拉取这个股票的所有数据吧?

当然,站在软件性能、消耗等角度,也不应该一次性拉取所有的数据, 我的答案是 arraylist 最多保存5屏的数据量,用户看到的屏幕, 应该是接近中间这一屏,也就是第3屏的数据, 左右两边各保存2屏数据,这样,用户拖拽的时候,可以比较流畅,而不是每次拖拽都要等拉取数据再去渲染。

那什么时候拉取新的数据呢? 用户触摸完后,当startIndex左边的数据少于2屏,开始拉取左边的数据; 用户触摸完后,当startIndex右边的数据少于2屏,开始拉取右边的数据;

那如果用户一直往右拖拽, 是不是就一直往左边添加数据, 这个 arraylist 是不是会变得很长?

当然不是,例如,当我往 arraylist 的左边添加数据的时候,startIndex 也会跟着变动, 因为用户看到的第一条柱体,在 arraylist 的索引已经变了。当我往 arraylist 的某一边添加数据后, arraylist 的另一边如果数据超过 2 屏, 要适当裁掉一些数据, 这样 arraylist 的总数, 始终保持在 5 屏左右,就不会占用太多的存放空间。

总体思想是, 从 startIndex 开始渲染屏幕的第一条柱体, 当前屏幕的左右两边, 都预留2屏数据,防止用户拖拽频繁调用接口, 导致卡顿; 同时也控制了 arraylist 的长度, 这是虚拟列表的变形,这样设计,可以做一个 高性能 的k线图。

八、触摸事件解耦

根据上面的分析:

  1. 快速拖拽, K线图左右移动
  2. 长按再滑动, 出现十字交叉tooltip

以上两种拖拽,都在 touchmove 事件中触发, 那怎么区分开呢? 典型的 touchstart、 touchmove 、 touchend 解耦如下:

let timer = null;
let startX = 0;
let startY = 0;
let isLongPress = false;

canvas.addEventListener('touchstart', (e) => {
startX = e.touches[0].clientX;
startY = e.touches[0].clientY;
isLongPress = false;

timer = setTimeout(() => {
isLongPress = true;
// 显示十字光标hover
showCrossHair(e);
}, 500);
});

canvas.addEventListener('touchmove', (e) => {
if (isLongPress) {
// 长按移动时更新十字光标位置
updateCrossHair(e);
} else {
// 快按拖动时移动K线图
clearTimeout(timer);
moveKLineChart(e);
}
});

canvas.addEventListener('touchend', () => {
clearTimeout(timer);
if (isLongPress) {
// 长按结束隐藏十字光标
hideCrossHair();
}
isLongPress = false;
});

// 关闭十字光标
function hideCrossHair() {
// 隐藏逻辑
}

根据上面的框架, 再详细补充下代码就可以了。 然后再在 touchend 事件中, 新增或减少 arraylist 的数据量。

九、性能优化

其实, 做到上面的设计,性能已经很好了,可以监控帧率来看下滑动的流畅程度。

总结下我做了什么操作,来提高整体的性能:

1、分层渲染

将K线图画在3个canvas上。

  1. 不动层只需要绘画一次;
  2. 变动层根据需要而变动;
  3. 交互层独立出来,不会影响其它层,变动层的大量蜡烛柱体、均线等也不会受交互层的影响

2、离屏渲染

当需要在K线上标注一些icon时, 这些 icon 可以先离屏渲染, 需要的时候, 再copy到变动层对应的位置,这样比临时抱佛脚去画,要省很多时间,也能提高新能。

3、设置数据缓冲区

就是屏幕只渲染一屏数据, 但是在当前屏的左右两边,各缓存了2屏数据, 超过5屏数据的时候,及时裁掉多余的数据, 这样arraylist的数据量始终保持在5屏, 控制了数据量,有效的控制了占用空间。

4、节流防抖

touchmove 会很频繁触发, 可通过节流来控制,减少不必要的渲染。

十、部署到GitHub Pages

1、安装gh-pages包

npm install --save-dev gh-pages

2、package.json 添加如下配置

注意, Stock 这个需要对应github的仓库名

{
"homepage": "https://fhrddx.github.io/Stock",
"scripts": {
"predeploy": "npm run build",
"deploy": "gh-pages -d build"
}
}

3、运行部署命令

npm run build
npm run deploy

1.png

最后, 访问上面的链接(注意,在国内可能要开vpn)

fhrddx.github.io/Stock/

这样, github pages 部署成功, 访问上面链接, 可以看到如下效果。

2.png

github page 的部署需要将仓库设置为 public, 这个我挺反感的, 可以用 vercel 部署, 也就是将 github 账号与 vercel 关联起来, 项目的 package.js 的 homepage 设置为 “.” , 然后 vercel 可以点击一下, 一键部署, 常见的命令行如下:

# 1. 安装 Vercel CLI
npm install -g vercel

#
2. 在项目根目录登录 Vercel
vercel login

#
3. 部署项目
npm run build
vercel --prod

# 或者直接部署 build 文件夹

vercel --prod --build

作者:VincentFHR
来源:juejin.cn/post/7556154928059334666

收起阅读 »

Elasticsearch 避坑指南:我在项目中总结的 14 条实用经验

刚开始接触 Elasticsearch 时,我觉得它就像个黑盒子——数据往里一扔,查询语句一写,结果就出来了。直到负责公司核心业务的搜索模块后,我才发现这个黑盒子里面藏着无数需要注意的细节。今天就把我在实际项目中积累的 ES 使用经验分享给大家,主要从索引设计...
继续阅读 »

刚开始接触 Elasticsearch 时,我觉得它就像个黑盒子——数据往里一扔,查询语句一写,结果就出来了。直到负责公司核心业务的搜索模块后,我才发现这个黑盒子里面藏着无数需要注意的细节。


今天就把我在实际项目中积累的 ES 使用经验分享给大家,主要从索引设计字段类型查询优化集群管理架构设计这几个方面来展开。

20251102-4.jpg

索引设计:从基础到进阶

1. 索引别名(alias):为变更留条后路

刚开始做项目时,我习惯直接用索引名。直到有一次需要修改字段类型,才发现 ES 不支持直接修改映射,也不支持修改主分片数,必须重建索引。(**新增字段是可以的)

解决方案很简单:使用索引别名。业务代码中永远使用别名,重建索引时只需要切换别名的指向,整个过程用户无感知。

这就好比给索引起了个"外号",里面怎么换内容都不影响外面的人称呼它。

2. Routing 路由:让查询更精准

在做 SaaS 电商系统时,我发现查询某个商家的订单数据特别慢。原来,默认情况下ES根据文档ID的哈希值分配分片,导致同一个商家的数据分散在不同分片上。

优化方案:使用商家 ID 作为 routing key,存储和查询数据时指定routing key。这样,同一个商家的所有数据都会存储在同一个分片上。

效果对比

  • 优化前:查询要扫描所有分片(比如3个分片都要查)
  • 优化后:只需要查1个分片
  • 结果:查询速度直接翻倍,资源消耗还更少

3. 分片拆分:应对数据增长

当单个索引数据量持续增长时,单纯增加分片数并不是最佳方案。

我的经验是

  • 业务索引:单个分片控制在 10-30GB
  • 搜索索引:10GB 以内更合适
  • 日志索引:可以放宽到 20-50GB

对于 SaaS 系统,ES单索引数据较大,且存在“超级大商户”,导致数据倾斜严重时,可以按商家ID%64取模进行索引拆分,比如 orders_001 到 orders_064,每个索引包含部分商家的数据,然后再根据商户ID指定routing key。

请根据业务数据量业务要求,选择最适合的分片拆分规则 和routing key路由算法,同时不要因为拆分不合理,导致ES节点中存在大量分片。

ES默认单节点分片最大值为1000(7.0版本后),可以参考ES官方建议,堆内存分片数量维持大约1:20的比例


字段类型:选择比努力重要

4. Text vs Keyword:理解它们的本质区别

曾经有个坑:用户手机号用 text 类型存储,结果搜索完整的手机号却搜不到。原来 text 类型会被分词,13800138000 可能被拆成 13800138000 等片段。

正确做法

  • 需要分词搜索的用 text(如商品描述)
  • 需要精确匹配的用 keyword(如订单号、手机号),适合 term、terms 等精确查询
  • 效果:keyword 类型的 term 查询速度更快,存储空间更小

5. 多字段映射(multi-fields):按需使用不浪费

ES 默认会为 text 字段创建 keyword 子字段,但这并不总是必要的。

我的选择

  • 确定字段需要精确匹配和聚合时:启用 multi-fields
  • 只用于全文搜索时:禁用 multi-fields
  • 好处:节省存储空间,提升写入速度

6. 排序字段:选对类型提升性能

用 keyword 字段做数值排序是个常见误区。比如价格排序,100 会排在 99 前面,因为它是按字符串顺序比较的。

推荐做法

  • 数值排序:用 long、integer 类型
  • 时间排序:用 date 类型
  • 提升效果:排序速度提升明显,内存占用也更少

查询优化:平衡速度与精度

7. 模糊查询:了解正确的打开方式

在 ES 7.9 之前wildcard 查询是个性能陷阱。它基于正则表达式引擎,前导通配符会导致全量词项扫描。

现在的方案

  • ES7.9+:使用 wildcard 字段类型
  • 优势:底层使用优化的 n-gram + 二进制 doc value 机制,性能提升显著

提示:ES7.9前后版本wildcard的具体介绍,可以参考我的上一篇文章

《与产品经理的“模糊”对决:Elasticsearch实现MySQL LIKE '%xxx%'》

8. 分页查询:避免深度分页的坑

产品经理曾要求实现"无限滚动",我展示了深度分页的性能数据后,大家达成共识:业务层面避免深度分页才是根本解决方案。就像淘宝、Google 这样的大厂,也都对分页做了限制,这不仅是技术考量,更是用户体验的最优选择。

技术方案(仅在确实无法避免时考虑):

  • 浅分页:使用 from/size,适合前几页的常规分页
  • Scroll:适合大数据量导出,但需要维护 scroll_id 和历史快照,对服务器资源消耗较大
  • search_after:基于上一页最后一条记录进行分页,但无法跳转任意页面,且频繁查询会增加服务器压力

需要强调的是,这些技术方案都存在各自的局限性,业务设计上的规避始终是最佳选择


集群管理:保障稳定运行

9. 索引生命周期:自动化运维

日志数据的特点是源源不断,如果不加管理,磁盘很快就会被撑满。

我的做法

  • 按天创建索引(如 log_20231201)
  • 设置保留策略(保留7天或30天)
  • 结合模板自动化管理

10. 准实时性:理解刷新机制

很多新手会困惑:为什么数据写入后不能立即搜索?

原理ES 默认 1 秒刷新一次索引,这是为了在实时性和写入性能之间取得平衡。

调整建议

  • 实时性要求高:保持 1s
  • 写入量大:适当调大 refresh_interval

补充说明:如果需要更新后立即能查询到,通常有两种方案:

  1. 让前端直接展示刚提交的数据,等下一次调用接口时再查询 ES
  2. 更新完后,前端延迟 1.5 秒后再查询

关键点:业务需求不一定都要后端实现,可以结合前端一起考虑解决方案。

11. 内存配置:32G 限制的真相

为什么 ES 官方建议不要超过 32G 内存?

技术原因:Java 的压缩指针技术在 32G 以内有效,超过这个限制会浪费大量内存。

实践建议:单个节点配置约50%内存,留出部分给操作系统。


架构设计:合理的分工协作

12. ES 与数据库:各司其职

曾经试图在 ES 里存储完整的业务数据,结果遇到数据一致性问题。

现在的方案

  • ES:存储搜索条件和文档 ID
  • 数据库:存储完整业务数据
  • 查询:ES 找 ID,数据库取详情

好处:既享受 ES 的搜索能力,又保证数据的强一致性。

13. 嵌套对象:保持数据关联性

处理商品规格这类数组数据时,用普通的 object 类型会导致数据扁平化,破坏对象间的关联。

解决方案:使用 nested 类型,保持数组内对象的独立性,确保查询结果的准确性。

14. 副本配置:读写平衡的艺术

副本可以提升查询能力,但也不是越多越好。

经验值

  • 大多数场景:1 个副本足够
  • 高查询压力:可适当增加
  • 注意:副本越多,写入压力越大

写在最后

这些经验都是在解决实际问题中慢慢积累的。就像修路一样,开始可能只是简单铺平,随着车流量的增加,需要不断优化——设置红绿灯、划分车道、建立立交桥。使用 ES 也是同样的道理,随着业务的发展,需要不断调整和优化。

最大的体会是:理解原理比记住命令更重要。只有明白了为什么这样设计,才能在遇到新问题时找到合适的解决方案。

如果有人问我:"ES 怎么才能用得更好?"我的回答是:"先理解业务场景,再选择技术方案。就像我们之前做的模糊搜索,不是简单地用 wildcard,而是根据 ES 版本选择最优解。"

技术的价值不在于多复杂,而在于能否优雅地解决实际问题。与大家共勉。


作者:红尘旅人
来源:juejin.cn/post/7569959427879567370
收起阅读 »

Python实战:用高德地图API批量获取地址所属街道并写回Excel

在日常的数据处理工作中,我们经常需要根据公司、事件或门店的注册地址,批量获取其所在的街道信息,例如“浦东新区张江镇”“徐汇区龙华街道”等。 手动查询显然低效,而借助 Python + 高德地图API,我们可以轻松实现自动化批量查询并将结果写入 Exc...
继续阅读 »

在日常的数据处理工作中,我们经常需要根据公司、事件或门店的注册地址,批量获取其所在的街道信息,例如“浦东新区张江镇”“徐汇区龙华街道”等。 手动查询显然低效,而借助 Python + 高德地图API,我们可以轻松实现自动化批量查询并将结果写入 Excel 文件中。

本文将完整展示一个从 Excel 读取地址 → 调用高德API → 获取街道 → 写回Excel的实用脚本,并讲解实现细节与优化思路。


一、功能概述

这段脚本的功能可以总结为四步:

  1. 从 Excel 文件中读取地址数据;
  2. 调用高德地图地理编码(geocode)与逆地理编码(regeo)接口获取街道名称;
  3. 自动将查询结果写回到 Excel 的新列中;
  4. 对查询失败的地址进行重试与记录,保证数据尽量完整。

二、项目依赖与准备工作

在开始之前,请确保安装以下依赖:

pip install pandas openpyxl requests

并在高德开放平台申请一个 API Key,申请地址为: 👉 lbs.amap.com/api/webserv…

拿到 key 后,将它填入脚本开头的配置部分:

key = "你的高德API_KEY"

三、核心逻辑讲解

1. Excel文件读取与列处理

脚本使用 pandas 和 openpyxl 结合读取 Excel 文件:

df = pd.read_excel(input_file)
if '注册地址' not in df.columns:
df['注册地址'] = df.iloc[:,16]
addresses = df['注册地址'].tolist()

这段代码首先读取整个 Excel,然后确认是否存在“注册地址”列; 如果没有,则自动取第 17 列(索引16)作为地址列,保证兼容不同格式的表格。

随后,脚本用 openpyxl 打开同一个文件,以保留单元格样式,准备写入新的“街道”列:

wb = load_workbook(input_file)
ws = wb.active
ws.insert_cols(target_col)
ws.cell(row=header_row_index, column=target_col, value="街道")

这样既能读取数据,又能保持表格原有格式,方便下游人员直接查看。


2. 调用高德API获取街道信息

核心的查询函数如下:

def get_street_from_amap(address, retries=max_retries):
if not isinstance(address, str) or not address.strip():
return ""
for attempt in range(1, retries+1):
try:
geo_resp = requests.get(
"https://restapi.amap.com/v3/geocode/geo",
params={"key": key, "address": address, "city": "上海"},
timeout=15
).json()

if not geo_resp.get("geocodes"):
continue

location = geo_resp["geocodes"][0]["location"]

regeo_resp = requests.get(
"https://restapi.amap.com/v3/geocode/regeo",
params={"key": key, "location": location, "extensions": "base", "radius":500},
timeout=15
).json()

if regeo_resp.get("regeocode"):
township = regeo_resp["regeocode"]["addressComponent"].get("township","") or ""
return township
except Exception as e:
print(f"[尝试 {attempt}/{retries}] 地址查询失败: {address}, 错误: {e}")
time.sleep(sleep_time + random.random()*0.5)
return

这段逻辑分为两步:

  1. 正向地理编码(geocode):根据地址字符串获取经纬度;
  2. 逆向地理编码(regeo):根据经纬度反查街道名称(township)。

并加入了异常重试机制随机延时,防止频繁请求触发高德API限流。


3. 批量查询与缓存优化

查询过程通过循环实现:

cache = {}
failed_addresses = []

for row_idx, addr in enumerate(addresses, start=header_row_index+1):
if not isinstance(addr,str) or not addr.strip():
ws.cell(row=row_idx, column=target_col, value="")
continue
if addr in cache:
township = cache[addr]
else:
township = get_street_from_amap(addr)
if township is :
failed_addresses.append((row_idx, addr))
township = ""
cache[addr] = township
time.sleep(sleep_time + random.random()*0.5)
ws.cell(row=row_idx, column=target_col, value=township)

这里有几个优化点:

  • 缓存(cache)机制:如果同一地址出现多次,只请求一次;
  • 延时策略sleep_time + random.random()*0.5,避免被API风控;
  • 实时进度输出:每50行打印一次进度。

4. 失败重试与错误记录

对于第一次查询失败的地址,脚本会自动发起第二轮重查:

if failed_addresses:
print(f"第一次查询失败地址共 {len(failed_addresses)} 条,开始自动重查……")
still_failed = []
for row_idx, addr in failed_addresses:
township = get_street_from_amap(addr)
if township is :
still_failed.append((row_idx, addr))
township = ""
cache[addr] = township
ws.cell(row=row_idx, column=target_col, value=township)
time.sleep(sleep_time + random.random()*0.5)
failed_addresses = still_failed

最终仍查询失败的地址会被写入单独的 Excel 文件:

if failed_addresses:
df_fail = pd.DataFrame([addr for _, addr in failed_addresses], columns=["地址"])
df_fail.to_excel(failed_file, index=False)

这样可以方便人工二次处理,比如手动调整地址格式或补录缺失信息。


四、运行结果

执行脚本后,控制台会显示类似输出:

已处理 50 行,最近地址:上海市浦东新区张江路123号  张江镇
已处理 100 行,最近地址:上海市浦东新区川沙路56号 川沙新镇
第一次查询失败地址共 5 条,开始自动重查……
完成,已保存:事件列表-上海浦东-带街道.xlsx
最终仍失败的地址已保存到 查询失败地址.xlsx

最终输出文件中会新增一列“街道”,完整保留原有格式:

注册地址街道
上海市浦东新区张江路123号张江镇
上海市浦东新区川沙路56号川沙新镇

五、实用建议与扩展方向

  1. 批量查询速度控制

    • 高德API对单IP有请求频率限制,建议控制每秒请求数。
    • 若数据量大,可考虑多线程+限速队列模式。
  2. 地址清洗预处理

    • 可先对地址进行正则清洗,去掉多余标点、括号、空格等,提高命中率。
  3. 多城市适配

    • 当前城市固定为“上海”,可通过参数配置实现全国适配。
  4. 异常日志记录

    • 建议在重查阶段输出更多日志,例如返回状态码、错误类型,方便调试。
  5. 接口替代方案

    • 若数据量巨大,可以使用高德地图的批量地理编码接口(支持最多 10 条一次),进一步提升效率。

六、总结

本文通过一个实战案例展示了如何用 Python + 高德地图API 实现“批量地址→街道归属”的自动化处理。 整个过程涵盖了数据读取、接口调用、异常重试、结果写回等完整流程,既是一个实用工具脚本,也体现了 Python 在数据自动化中的强大能力。

核心亮点:

模块功能
pandas + openpyxl高效读取与写入 Excel
requests调用高德API进行地理解析
缓存与重试机制提高查询稳定性与速度
自动生成失败文件方便人工补录与质量控制

如果你日常需要处理大量企业、门店、事件地址,这个脚本可以帮你节省大量时间。



作者:程序员爱钓鱼
来源:juejin.cn/post/7569825640851619875
收起阅读 »

从零基于 Canvas 实现一个文本渲染器

web
一、前因后果1.1 目的起因是女朋友想做小红薯的账号。她每天会先把小故事写好复制到手机上,然后按照一定的图片规格一张张裁剪,最后发布到平台上。接着我就在想,有没有什么高效的文本转成图片并且能够自动分页的方案呢?查了很久都没找到合适的方案,最后还是决定自己写一个...
继续阅读 »

一、前因后果

1.1 目的

起因是女朋友想做小红薯的账号。她每天会先把小故事写好复制到手机上,然后按照一定的图片规格一张张裁剪,最后发布到平台上。

画板

接着我就在想,有没有什么高效的文本转成图片并且能够自动分页的方案呢?查了很久都没找到合适的方案,最后还是决定自己写一个转换工具。

1.2 需求

总结了一下我的场景,发现需求如下:

  1. 能够内容转成指定尺寸的图片
  2. 能够设置字体、背景、样式
  3. 支持自动换行、自定义换行
  4. 支持分页
  5. 支持图片下载

根据输入文本,通过 Canvas 渲染

展示全部分页图片的内容,可以批量下载

1.3 思路

基于需求,最后决定采用 Canvas 的绘制方案。思路如下:

  1. 根据输入文本,完成分行、分页的计算
  2. 将分页数据绘制到 Canvas
  3. 批量将 Canvas 的内容导出图片进行下载

1.4 难点

做的时候发现几个核心的问题难点,分别是换行、分页的问题。

I. 换行问题

在 canvas 中进行文本绘制不同于 HTML 标签,文本是不能自动换行的。所以这个需要自己去计算什么时候换行,在哪个字符段该换行。

II. 分页问题

当内容超出当前页了,我们希望能够自动进行分页,这个就需要去计算行高页面内容区高度

二、设计方案

下面介绍一下 Canvas 文本渲染器的设计方案。主要会围绕着文本计算、文本绘制、导出图片来讲解。

2.1 如何计算文本

计算文本主要做的事情就是,根据用户输入的文本和想要生成的图片、字体参数,来计算需要分多少页,每一页具体要展示多少行,每一行要展示多少内容。

I. 分词

要实现换行,就要知道一句话中从哪个分词开始是超出了当前行的最大宽度。这个分词可能是某个中文字符、某个英文单词、某串数字、某个其他字符等。

这里我们只讨论简单的中英文数字的场景

所以要做的第一步,就是将输入的文本拆解成一个个分词,然后去筛选掉空的字符。

画板

核心代码如下:

II. 分行

现在已经将文本拆分成了足够细的分词。接下来要做的就是将每个分词不断地塞入每一行中。

你可以把每一行理解成一个固定宽度的容器,一但某个分词塞不进了,就得创建一个新的容器再把这个分词塞进去。

画板

所这个遍历的过程,需要知道行的最大宽度以及当前分词的真实宽度。最大宽度的计算规则,根据用户设置的页面宽度减去左右边距的宽度,就是内容区的最大行宽度。

画板

代码如下:

分词的真实宽度则是用 canvas 中的 measureText 来测量字符串的宽度。

接下来就是一个累加的过程。把分词加入当前行,判断是否超出最大宽度,如果超出就新起一行。

最后遍历完后就会得出所有的分行内容。

上面是分行大致的思路,但在实际的代码实现上,会随着分词类型、换行需求的增加变得更加复杂。例如:

  1. 英文单词前面需要追加空格

  1. 用户想自定义空行逻辑:匹配到句号自动换行并且空一行。

III. 分页

有了分行的数据,就可以进行分页了。其实逻辑差不多,只不过一个是横向,一个是纵向。把每个页当成一个固定高度的容器,不断的把行塞进去,塞不进就新起一个页容器。

画板

这个遍历的过程需要知道页面内容区的最大高度以及每一行的行高。行高一般是用户设置的,所以只需关注页面内容区的最大高度的计算规则。

画板

代码如下:

分页计算流程,遍历 lines 把分行不断塞入当前页。如果超出高度就放到新的页面。最后就会得出所有分页的数据。

2.2 如何绘制

有了分页数据以后,绘制主要做的事情就是根据用户设置的样式(页面边距、字体和背景)来渲染每一页具体的内容。

I. 指定图片尺寸

通过设置 Canvas 画布的大小即可。

II. 绘制背景

背景直接通过 fillRect 绘制即可

III. 绘制内容

绘制内容的时候,主要考虑两个点:

  1. 设置字体样式
  2. 根据边距、行高计算每行绘制的位置

2.3 如何导出图片

导出图片就是将 Canvas 上的内容转成 DataURL 然后下载成图片

三、最后

基于上面的思路,你不仅仅可以开发一个简单的文本渲染器,你甚至可以做一个复杂的编辑器哦~

最后我将这个工具封装成了一个 npm 库,直接导入这个库就可以完成文本到图片的一个转换了。

image.png

如果感兴趣,完整代码放置在 GitHub 了:github.com/zixingtangm…


作者:唐某人丶
来源:juejin.cn/post/7485758756911857683

收起阅读 »

Java/Kotlin 泛型

泛型是 Java 和 Kotlin 中用于实现 "参数化类型" 的核心机制,允许类、接口、方法在定义时不指定具体类型,而是在使用时动态指定,从而实现代码复用和类型安全。基本定义泛型类/接口Java:通过 class 类名 或 interface 接口...
继续阅读 »

泛型是 Java 和 Kotlin 中用于实现 "参数化类型" 的核心机制,允许类、接口、方法在定义时不指定具体类型,而是在使用时动态指定,从而实现代码复用和类型安全。

基本定义

泛型类/接口

Java:通过 class 类名 或 interface 接口名 定义,类型参数放在类/接口名后。

// 泛型类
public class Box {
private T value;

public void set(T value) {
this.value = value;
}

public T get() {
return value;
}
}

// 泛型接口
public interface List {
void add(T element);

T get(int index);
}

kotlin:语法类似,通过 class 类名 或 interface 接口名 定义,类型参数同样放在名称后。

// 泛型类
class Box<T>(private var value: T) {
fun set(value: T) {
this.value = value
}

fun get(): T = value
}

// 泛型接口
interface List<T> {
fun add(element: T)
fun get(index: Int): T
}

泛型方法

Java:类型参数声明在方法返回值前,需显式声明。

public static  T getFirstElement(List list) {
return list.get(0);
}

kotlin:类型参数声明在方法名前,语法更紧凑,且编译器类型推断更智能。

fun  getFirst(list: List): T = list[0]

类型擦除

Java 和 Kotlin 的泛型都基于类型擦除实现:编译时检查类型安全,运行时泛型类型信息被擦除(仅保留原始类型)。

Java

 List strList = new ArrayList<>();
List intList = new ArrayList<>();

// 运行时泛型信息被擦除,均为 ArrayList 类型
System.out.println(strList.getClass() == intList.getClass()); // 输出 true

Kotlin

val strList: List = listOf()
val intList: List<Int> = listOf()

// 同样擦除,运行时类型相同
println(strList.javaClass == intList.javaClass) // 输出 true

无法在运行时检查泛型具体类型(如 obj instanceof List 编译错误)。

变异

变异描述泛型类型与子类型的关系,核心问题:若 A 是 B 的子类型,Generic 与 Generic 是什么关系?

Java 需在每次使用泛型时通过通配符控制变异(使用处处理),而 Kotlin 在定义泛型时一次性声明(声明处处理),更简洁且避免重复逻辑。

协变

Java:Generic 是 Generic 的子类型(A 是 B 的子类型),用 ? extends B 表示。

List intList = new ArrayList<>();
Listextends Number> numList = intList; // 合法:Integer 是 Number 子类型,协变

Number n = numList.get(0); // 合法:能读(输出 Number)
numList.add(1); // 编译错误:无法确定具体类型,写操作不安全

泛型类型只能输出 B 及其子类型(读操作安全,写操作受限)。

Kotlin:用 out 标记类型参数,表示泛型类型是生产者,只能输出 T,即作为返回值。不能输入 T,会导致类型不安全。即若 A : B,则 Generic : Generic

val intList: List<Int> = listOf(1, 2, 3)
val numList: List = intList // 合法:协变允许这种赋值

Kotlin 通过 out 关键字在泛型定义时声明协变,如下所示:

// 协变接口:T 只能作为输出(返回值)
interface DataReader<out T> {
fun read(): T // 读取数据,返回 T 类型(输出操作,合法)
}

class IntReader : DataReader<Int> {
private var current = 1
override fun read(): Int {
return current++
}
}

val intReader: DataReader<Int> = IntReader()

// 利用协变特性,将 Int 读取器赋值给 Number 读取器变量,(因为 Int 是 Number 的子类型,协变允许)
val numberReader: DataReader = intReader // 合法

逆变

Java:Generic 是 Generic 的子类型(A 是 B 的子类型),用 ? super A 表示。泛型类型只能输入 A 及其子类型(写操作安全,读操作受限)。

List numList = new ArrayList<>();
Listsuper Integer> intSuperList = numList; // 合法:Number 是 Integer 父类型,逆变

intSuperList.add(1); // 合法:能写(输入 Integer)
Number n = intSuperList.get(0); // 编译错误:只能读为 Object,类型不安全

kotlin:用 in 标记类型参数,表示泛型类型是消费者(只能输入 T,即作为参数)。即若 A : B,则 Generic : Generic

// 逆变接口:T 只能作为输入(方法参数)
interface DataProcessor<in T> {
fun process(data: T) // 处理数据,接收 T 类型参数(输入操作,合法)
}

// 处理所有 Number 类型(父类型处理器)
class NumberProcessor : DataProcessor<Number> {
override fun process(data: Number) {
println("NumberProcessor process: $data")
}
}

val numberProcessor: DataProcessor = NumberProcessor()
val intProcessor: DataProcessor<Int> = numberProcessor

不变

默认情况下,Generic 与 Generic 无父子关系(即使 A 是 B 的子类型)。对于 kotlin 来说,未用 out/in 标记时,泛型类型默认不变。

通配符与星投影

Java:无界通配符 ?,表示任意类型,读写均受限,可读为 Object,可写 null(无意义)

List anyList = new ArrayList();
Object obj = anyList.get(0); // 合法:读为 Object
anyList.add(null); // 合法:只能加 null
anyList.add("a"); // 编译错误:类型不确定

Kotlin:星投影 *,是 Kotlin 对未知类型参数的简化表示,行为与 Java 通配符类似,但规则更明确:

  • 对于 Generic(协变且有上界):Generic<*> 等价于 Generic
  • 对于 Generic(逆变):Generic<*> 等价于 Generic(Nothing 是 Kotlin 所有类型的子类型)。
  • 对于 Generic(不变且有上界):Generic<*> 等价于 Generic

Kotlin 标准库的 List 是协变的(List),其星投影 List<*> 等价于 List

val anyList: List<*> = listOf("a", 1) // 等价于 List
val obj: Any? = anyList[0] // 合法:读为 Any?
// anyList.add("b") // 编译错误:星投影不可写(类似 Java ?)

泛型约束

泛型约束用于限制类型参数的范围(如只能是某类的子类)。

上界约束(限制为某类型的子类型)

Java:用 extends 声明,类型参数必须是指定类型的子类型。

// T 必须是 Number 的子类型(如 Integer、Double)
public class NumberBoxextends Number> {
private T value;
}

NumberBox intBox = new NumberBox<>();

Kotlin:用 : 声明,语法更简洁。

// T 必须是 Number 的子类型
class NumberBox<T : Number>(private var value: T)

// 合法
val intBox = NumberBox(1) // 推断 T 为 Int
// 不合法
// val strBox = NumberBox("a")

多个上界约束

Java:用 & 连接,且类必须放在接口前(只能有一个类上界)。

// T 必须是 Number 子类且实现 Comparable
public class ComparableNumberBoxextends Number & Comparable> {
}

Kotlin:用 where 子句,支持多个上界,顺序无限制。

// T 必须是 Number 子类且实现 Comparable
class ComparableNumberBox<T> where T : Number, T : Comparable<T>

Java 和 kotlin 都不直接支持下界约束,但是,Java 可以通过通配符 ? super A,kotlin 可以通过 in 逆变实现类似的效果。

Kotlin 独有的泛型特性:reified

由于类型擦除,Java 无法在运行时获取泛型类型信息(如 T.class 不合法)。但 Kotlin 通过 inline 函数配合 reified 关键字,可在运行时保留类型信息。

inline fun <reified T : Activity> startActivity(
context: Context,
extras: Bundle? = null
)
{
val intent = Intent(context, T::class.java)
extras?.let { intent.putExtras(it) }
context.startActivity(intent)
}


作者:阿健君
来源:juejin.cn/post/7567822836484718630
收起阅读 »

用 “奶茶连锁店的部门分工” 理解各种 CoroutineScope

故事背景:“协程奶茶连锁店” 生意火爆,总部为了高效管理,设立了 4 个核心部门,每个部门负责不同类型的任务,且有严格的 “上下班时间”(生命周期):总公司长期项目组(GlobalScope) :负责跨门店的长期任务(如年度供应链优化),除非主动停掉...
继续阅读 »

故事背景:

“协程奶茶连锁店” 生意火爆,总部为了高效管理,设立了 4 个核心部门,每个部门负责不同类型的任务,且有严格的 “上下班时间”(生命周期):

  • 总公司长期项目组(GlobalScope) :负责跨门店的长期任务(如年度供应链优化),除非主动停掉,否则一直运行。
  • 前台接待组(MainScope) :负责门店前台的即时服务(如点单、结账),必须在前台营业时间内工作,打烊时全部停手。
  • 后厨备餐组(ViewModelScope) :配合前台备餐,前台换班(ViewModel 销毁)时,未完成的备餐任务必须立刻停掉。
  • 临时促销组(LifecycleScope) :负责门店外的临时促销,摊位撤掉(Activity/Fragment 销毁)时,促销活动马上终止。

这些部门本质上都是CoroutineScope,但因 “职责” 不同,它们的coroutineContext(核心配置)和 “生命周期触发条件” 不同。下面逐个拆解:

一、总公司长期项目组:GlobalScope

部门特点:

  • 任务周期长(可能跨多天),不受单个门店生命周期影响。
  • 没有 “自动下班” 机制,必须手动终止,否则会一直运行(可能 “加班摸鱼” 导致资源浪费)。

代码里的 GlobalScope:

import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

fun main() = runBlocking {
// 启动总公司的长期任务(优化供应链)
GlobalScope.launch {
repeat(10) { i -> // 模拟10天的任务
println("第${i+1}天:优化供应链中...")
delay(1000) // 每天做一点
}
}

// 模拟门店只观察3天就关门
delay(3500)
println("门店关门了,但总公司任务还在跑...")
}

运行结果

第1天:优化供应链中...
第2天:优化供应链中...
第3天:优化供应链中...
门店关门了,但总公司任务还在跑...
第4天:优化供应链中...
// 即使主线程结束,任务仍在后台运行(直到JVM退出)

实现原理:

GlobalScopecoroutineContext是固定的:

object GlobalScope : CoroutineScope {
override val coroutineContext: CoroutineContext
get() = Dispatchers.Default + SupervisorJob()
}
  • Dispatcher.Default:默认在后台线程池执行(适合计算密集型任务)。
  • SupervisorJob() :特殊的 “组长”,它的子任务失败不会影响其他子任务(比如某个门店的供应链优化失败,不影响其他门店),且没有父 Job(所以不会被其他 Scope 取消)。

为什么少用?

就像总公司的长期任务如果不手动停,会一直占用人力物力,GlobalScope启动的协程如果忘记取消,会导致内存泄漏(比如 Android 中 Activity 销毁后,协程还在访问已销毁的 View)。

二、前台接待组:MainScope

部门特点:

  • 只在前台营业时间工作(比如门店 9:00-21:00),负责和顾客直接交互(必须在主线程执行)。
  • 打烊时(手动调用cancel()),所有接待任务立刻停止。

代码里的 MainScope:

import kotlinx.coroutines.MainScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking

fun main() = runBlocking {
// 创建前台接待组(指定在主线程工作)
val frontDeskScope = MainScope()

// 启动接待任务:点单、结账
frontDeskScope.launch {
println("开始点单(线程:${Thread.currentThread().name})")
delay(1000) // 模拟点单耗时
println("点单完成,开始结账")
}

// 模拟21:00打烊
delay(500)
println("前台打烊!停止所有接待任务")
frontDeskScope.cancel() // 手动取消
}

运行结果

开始点单(线程:main) // 确保在主线程执行
前台打烊!停止所有接待任务
// 后续“结账”不会执行,因为被取消了

实现原理:

MainScopecoroutineContext是:

fun MainScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
  • Dispatchers.Main:强制在主线程执行(Android 中对应 UI 线程,确保能更新 UI)。
  • SupervisorJob() :子任务失败不影响其他任务(比如一个顾客点单失败,不影响另一个顾客结账)。
  • 手动取消:必须通过scope.cancel()触发(比如 Android 中在onDestroy调用)。

三、后厨备餐组:ViewModelScope

部门特点:

  • 专门配合前台备餐,前台换班(ViewModel 被销毁)时,自动停止所有备餐任务(避免 “给已走的顾客做奶茶”)。

代码里的 ViewModelScope(Android 场景):

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

class TeaViewModel : ViewModel() {
fun startPrepareTea() {
// 后厨备餐任务(自动绑定ViewModel生命周期)
viewModelScope.launch {
println("开始备餐:煮珍珠...")
delay(2000) // 模拟备餐耗时
println("备餐完成!")
}
}

// ViewModel销毁时(如Activity.finish),系统自动调用
override fun onCleared() {
super.onCleared()
println("前台换班,后厨停止备餐")
}
}

实现原理:

viewModelScope是 ViewModel 的扩展属性,其coroutineContext为:

val ViewModel.viewModelScope: CoroutineScope
get() = CoroutineScope(ViewModelCoroutineScope.DispatcherProvider.main + job)
  • Dispatcher.Main:默认在主线程(也可指定其他线程)。
  • 内部 Job:和 ViewModel 绑定,当 ViewModel 的onCleared()被调用时,这个 Job 会自动cancel(),所有子协程随之终止。

四、临时促销组:LifecycleScope

部门特点:

  • 在门店外的临时摊位工作,摊位撤掉(Activity/Fragment 销毁)时,自动停止所有促销活动。

代码里的 LifecycleScope(Android 场景):

import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

class PromotionFragment : Fragment() {
override fun onStart() {
super.onStart()
// 临时促销任务(自动绑定Fragment生命周期)
lifecycleScope.launch {
println("开始促销:发传单...")
delay(3000) // 模拟促销耗时
println("促销结束!")
}
}

// Fragment销毁时,系统自动触发
override fun onDestroy() {
super.onDestroy()
println("摊位撤掉,促销停止")
}
}

实现原理:

lifecycleScopeLifecycleOwner(如 Activity/Fragment)的扩展属性,其coroutineContext为:

val LifecycleOwner.lifecycleScope: CoroutineScope
get() = lifecycle.coroutineScope
  • 内部关联Lifecycle,当生命周期走到ON_DESTROY时,自动调用cancel()
  • 默认使用Dispatchers.Main,确保能更新 UI(如更新促销进度)。

五、各 Scope 的生命周期时序图

1. GlobalScope 时序图(无自动取消)

┌───────────┐      ┌─────────────┐      ┌───────────┐
│ 用户代码 │ │ GlobalScope │ │ 子协程 │
└─────┬─────┘ └───────┬─────┘ └─────┬─────┘
│ │ │
│ launch协程 │ │
├───────────────────>│ │
│ │ 启动子协程 │
│ ├─────────────────>│
│ │ │ 执行中...
│ │ │<─────┐
│ (无自动取消) │ │ │
│ │ │ │
│ (需手动cancel) │ │ │
│ (否则一直运行) │ │ │

2. ViewModelScope 时序图(ViewModel 销毁时取消)

┌─────────────┐      ┌────────────────┐      ┌───────────┐
ViewModel │ │ ViewModelScope │ │ 子协程 │
└───────┬─────┘ └───────┬────────┘ └─────┬─────┘
│ │ │
│ 初始化scope │ │
├───────────────────>│ │
│ │ │
launch协程 │ │
├───────────────────>│ │
│ │ 启动子协程 │
│ ├────────────────────>│
│ │ │ 执行中...
│ │ │
onCleared()触发 │ │
├───────────────────>│ │
│ │ 调用Job.cancel() │
│ ├────────────────────>│
│ │ │ 终止执行
│ │ │<─────┐

3. LifecycleScope 时序图(ON_DESTROY 时取消)

┌─────────────┐      ┌────────────────┐      ┌───────────┐
Activity │ │ LifecycleScope │ │ 子协程 │
└───────┬─────┘ └───────┬────────┘ └─────┘─────┘
│ │ │
│ 初始化scope │ │
├───────────────────>│ │
│ │ │
launch协程 │ │
├───────────────────>│ │
│ │ 启动子协程 │
│ ├────────────────────>│
│ │ │ 执行中...
│ │ │
│ 生命周期到ON_DESTROY│ │
├───────────────────>│ │
│ │ 调用Job.cancel() │
│ ├────────────────────>│
│ │ │ 终止执行
│ │ │<─────┐

总结:选对 Scope,就像找对部门

Scope 类型生命周期绑定核心配置(coroutineContext)适用场景
GlobalScope无(需手动取消)Dispatchers.Default + SupervisorJob跨组件的长期任务(谨慎使用)
MainScope手动控制Dispatchers.Main + SupervisorJob顶层 UI 组件(如 Application)
ViewModelScopeViewModel.onClearedDispatchers.Main + 内部 Job配合 ViewModel 的备餐 / 数据请求
LifecycleScopeActivity/Fragment 销毁Dispatchers.Main + Lifecycle 关联 Job临时 UI 任务(如促销、弹窗倒计时)

记住:Scope 的本质是给协程找个 “归属”,让协程在合适的时间开始,在该结束的时候乖乖结束,避免 “野协程” 捣乱~


作者:Android童话镇
来源:juejin.cn/post/7563197374418157604

收起阅读 »

或许,找对象真的太难了……

找对象真的太难了,我不由地发出这个感慨 但其实说着也奇怪,明明我每天两点一线,上班了去工位、下班了回宿舍,根本没有其他社交,但我为什么会发出这样的感慨呢? 是因为总是在无聊时感到了孤独才希望有个伴,还是看见大家都有伴了才觉得自己孤独? 是因为看到了别人功成名...
继续阅读 »

找对象真的太难了,我不由地发出这个感慨


但其实说着也奇怪,明明我每天两点一线,上班了去工位、下班了回宿舍,根本没有其他社交,但我为什么会发出这样的感慨呢?



  • 是因为总是在无聊时感到了孤独才希望有个伴,还是看见大家都有伴了才觉得自己孤独?

  • 是因为看到了别人功成名就家庭和谐的嫉妒,还是吊儿郎当无所事事地调侃?

  • 是因为信息茧房导致我对婚姻产生了偏见而刻意疏远,还是无能狂怒般自卑不敢去尝试?

  • ……


如果是针对我个人的话,那应该是自卑了吧。这辈子30多年没什么情感经历,就只有一次相亲后“交往”的经验。那次相亲后异地聊了9个月,中间节假日只有我回去见了3次面,没有矛盾,每天都在线上花个两三个小时也都聊得很开心,但我却总觉得彼此都像个不熟的人,以至于最后“分手”时内心也毫无波澜。


“或许我根本不需要为了找个伴才选择想找对象吧。”我总是这样安慰自己,其实安慰的次数不多,因为我没有经常想。


我每天两点一线的生活很规律,很轻松,最重要的是,我已经非常习惯了。所以从某种程度来讲,“客观上”,我并没有真的想找对象、也没有主动去尝试结交新朋友,“主观上”,现在的社会风向和经济形势,不太利于我尝试告别单身,即便 A 股沪指最近持续性突破十年新高。


光是想,不去做,那可不就是“太难了”


其实,有那么一瞬间我也想结婚


可怜的是,这并不是基于我个人的想法,而是外界的干扰。我记得我31岁生日那天,凌晨6点过还在睡梦中,外公外婆就打来电话,祝我生日快乐。我很诧异,因为我没想到农忙时节呢,他们还没忙忘了,也因为居然这么早,鸡鸭才刚叫。然后一如既往地说:不要太节约了,吃点好的,照顾好自己……然后,快点找对象,天天瞌睡都睡不好,揪心得很哦。结了婚他们就放心了,不然他们死了都不安心哦。


IMG_20250901_072216.jpg


或许80多岁的老人家觉得,任何事情,只要你想要,那就能发生。我想要天上掉金子,天上就会掉金子;我想要地里喷石油,地里就会喷石油;我想要找对象,自然就有对象……


我倒是习以为常了,只不过那一整天,就没有第二个人祝我生日快乐了。不管是朋友同事,还是父母亲戚,即便是早我一天过生日、而我在生日前一天给她发去生日祝福的堂姐,都没有。


其实我也是习以为常了,可能因为我的生日也是我爷爷的忌日,我总是刻意淡忘它,大部分时候过生日我自己都会忘记,10岁之后就没有任何一次庆祝生日的行为——10岁那年父母要都外出打工,自此再次成为留守儿童。


但今年有那么一瞬间,突然觉得难得只有两个80多岁的老人还记得我生日,一直让他们失望有点于心不忍,更何况,正常来讲,我还能“忤逆”他们多少年呢?


可惜的是,就和那些贩卖焦虑的短视频、营销号一样,总是提出问题、夸大问题、制造矛盾、激化矛盾,但从来不会提供解决方法一样:想结婚了,然后呢?


独身一人在无聊的时候确实是无聊的


最近不知道是上班天天盯着电脑看久了,还是下班游戏玩多了,眼睛特别酸痛,于是我难得的又在下班之后出去逛了逛。


正常的话吃了饭我就玩游戏了,先玩几把NBA 2K,再玩几把英雄联盟手游,再玩几把王者荣耀。


其实曾几何时,我从生理上都厌恶王者荣耀的,因为它把很多中国历史人物文化名人,搞成游戏中乱七八糟的角色,让我无法接受。以至于这么多年来,从同学同事、到堂表兄弟等,都没有机会跟我一起玩。


我也没想到我居然突然之间就接受了,即便这曾经让我生理上讨厌的东西。我记得很清楚,2025年7月2日,我新历生日那天,我下载了王者荣耀,建立了账号,开始游玩,持续十几天有空就一直在玩,一百来把、十几级的账号打到最低等级的王者段位,觉得差不多入门了,想着和老同学、老朋友、老同事们一起玩时,才发现他们都不上线了。或许,随着年龄的增长,这种“年轻时的生理厌恶”都敌不过“无聊时的孤单寂寞”。


所以每次当我一个人出去小道散步闲逛消磨时间时,总会特别在意那些跑步锻炼身体的人、散步话家常的两公婆、坐在摊位小车后面玩手机的摆摊老板……感觉他们都有目的地在做什么,而只有我在漫无目的地走着。


其实这条路我之前走过,至少在我今年生日之前,只不过那个时候,这马路边的行人道,没有这么多杂草、灌木。就像人生路,总是在回忆的时候,才觉得曾经如此宽阔,才懊悔当时未曾踏入。可是,时光一直向前流逝,回忆永远迭代更新。


路上杂草.png


可能现在生活没有达到预期的我们总是有这样的想法,要是能回到过去就好了。实际上只是想带着现在的记忆回到过去,去弥补一些错过或者错误。似乎真像有多元宇宙,回到过去之后会有新的时间线,补足那些遗憾,每一次的回溯,终究会得到一条完美符合心意的时间线。实际上我觉得,即便我们能回到过去,那也是会失去所有记忆,然后完完整整重复之前做过的事情,又一次的错过或者错误,不会有多条时间线,即便你回去再多次,都只会重复同样的事情,都只是同一条时间线。但只有这一条时间线,其实也就够了。


正因为消极,所以才乐观


其实我一直是希望传播积极乐观心态的,从我以往的文章总能看到有这样的痕迹。但就像那些奢侈品广告一样:你买得起不重要,你买不起才重要


就像我当年创业板3600多点最高峰买入了很多和创业板强关联基金一样,那时总觉得中国经济一定是蒸蒸日上,最后一路跌倒了1500点,一点点割肉,最后全盘清掉,赔了一些钱,然后不敢再入场。所以我也没赶上或者说是错过了今年大半年的牛市,有点难受。与之相对的,为了求稳买入了大量的债券,却债市正熊,又套在手里,更难受了。


可能这都不是什么大事,毕竟只是对我造成了一些经济损失,并不会影响到我的一如既往平凡普通的物质生活;但是焦虑、担忧、烦闷的心态,却非常影响我的精神状态,严重损坏我本就低迷的精神生活。


我一直有个想法,希望尽量在35岁前能多攒一些钱,这样如果35岁之后某天丢了工作,我就徒步去环游中国。并不是他们那种雄心壮志地环游,什么“朝圣“”啊、“远离浮躁净化心灵”啊。就跟平时一样,大街小巷,散步流浪,随便走走看看,不过变成了走到哪里黑,就到哪里歇。等到钱花光了,人老了走不动,客死他乡,也算得偿所愿了。


如果真的能到这个时候,身后没有拖拽、肩上没有负担,该是多么舒服的境况。


发现了吗,其实我就是这么矛盾,一方面安慰自己钱财乃身外物,不必强求;另一方面又觉得钱财乃必需品,多多益善。原因非常简单,因为我缺这东西,所以看得很重;又因为没本事挣到更多,所以才安慰自己它不重要。


这就是别人说的,看清问题根源比无法解决问题更让人窒息,也就是“无知是福”或者”无知者无畏”的感悟了。


每个人都应该有自己的活法,即便大同小异


正如写代码的人,总是会重复造轮子,偶尔还会乐此不疲。世上的人这么多,大部分的人的都是千篇一律的,事实上,大家都在做的事情,说不定才是对的。大家都重复着读书、工作、结婚、生子、工作、退休、等死的生活,正是因为在和平年代这就是一个非常典型且应该让人轻松愉悦、容易接受的平凡人的人生。


同样功能、完全适配你项目的工具包,一个周下载几百万、上次更新1个月前,一个周下载几十、上次更新5年前,只考虑下载使用的话,你会怎么选呢?


就像正因为他是魔丸才敢高喊“我命由我不由天”,就像有人说对钱不感兴趣;也就像也有人觉得“奋斗用多大劲啊?”就像我以为“忠诚的不绝对就是绝对的不忠诚”只是一个战锤40K的梗而已……这个世界本来就因为科技发展而不断更新,止不住的时代洪流,纷纷扰扰的世界,何必太关注别人关心的事情,兜兜转转可能发现,你特别在意的东西别人根本没放在心上,你漫不经心地言语却刺穿了别人的心脏。所有的一切,在心脏停止跳动之前,其实都无关紧要;而在心脏停止跳动之后,更是毫无意义


所以我平时有空的时候,也会更新一下我 Github 仓库中几个开源的小项目,虽然没什么技术含量,还借助了很多AI辅助编码,但我在写完测试完成之后的那一刻感觉很舒服,就算之后很久都没再更新、测试,还发现了bug,但那完成时的一瞬间很舒服,就成了我持续不断更新的主观能动性之一。


人总有一死,我一直强调不要太在意他人的眼光,为自己而生活。但如果你根本不知道自己想要过什么样的人生,那么从众并不是什么丢人的行为。 人生短暂,不值得斤斤计较,浪费也是它应该存在的过程片段。


坐在厂门口的女子


我今天出去散步的时候,经过了隔壁厂,恰巧看到一位女士蹲在门口马路牙子上,左手拿着装炸土豆片套着塑料袋的小盒子,右手拿着手机看小说。我在外面溜达了个把小时,回去的时候发现她还蹲着那马路牙子上,可能是同一个位置,只不过只有右手拿着手机继续看着小说。


但我猜测她“可能”并没有一直蹲着那里看小说,因为那装炸土豆片的小纸盒没有在她左手上继续拿着,也没有放在她的身旁……


为什么只是“猜测和可能”呢?谁知道呢,或许她站起来走几步丢到垃圾桶后又蹲回去了,或许只是她吃完了空纸盒子放在旁边被风吹走了;或许她没吃完揉吧揉吧纸盒子随手丢到马路对面去了,又或许甚至可能她变身奥特曼打走了怪兽又变回正常人继续在厂门口看小说了……


如果不是因为间隔这么久,看见她还呆在同一个位置,我可能根本就没在意。就像如果不是出来工作了10年依旧还在原地,我也不必过度“揣摩自己”。


可能因为总是太在意,所以才觉得一切都太难了…… 毕竟“得不到的永远在骚动……”


很难了,一个陌生人有两次遇到的机会。


多数情况下都只有一次机会。换成是我,如果我一开始也是蹲在厂门口的马路牙子上,估计没人会注意到。但我要是一开始就跪在厂门口不停在磕头,说不定就有人会注意到了……


结尾


哈哈,Gotcha!要不是我这几天玩游戏多了眼睛有点酸痛,需要休息一下,我才不会在这里长篇大论无病呻吟呢,都这么久没有更新了是吧,那我玩游戏看视频啥的时可是乐在其中、忘乎所以的,佝偻成一团都还在哈哈大笑呢。


所以,赶紧去做那些让你自己开心的事情吧,享受生活,这才是我们活着的原因之一,其他的事情,fxxk off。


作者:小流苏生
来源:juejin.cn/post/7544259368277852175
收起阅读 »

我们来说一说什么是联合索引最左匹配原则?

什么是联合索引? 首先,要理解最左匹配原则,得先知道什么是联合索引。 单列索引:只针对一个表列创建的索引。例如,为 users 表的 name 字段创建一个索引。 联合索引:也叫复合索引,是针对多个表列创建的索引。例如,为 users 表的 (last_na...
继续阅读 »

什么是联合索引?


首先,要理解最左匹配原则,得先知道什么是联合索引。



  • 单列索引:只针对一个表列创建的索引。例如,为 users 表的 name 字段创建一个索引。

  • 联合索引:也叫复合索引,是针对多个表列创建的索引。例如,为 users 表的 (last_name, first_name) 两个字段创建一个联合索引。


这个索引的结构可以想象成类似于电话簿或字典。电话簿是先按姓氏排序,在姓氏相同的情况下,再按名字排序。你无法直接跳过姓氏,快速找到一个特定的名字。


什么是最左匹配原则?


最左匹配原则指的是:在使用联合索引进行查询时,MySQL/SQL数据库从索引的最左前列开始,并且不能跳过中间的列,一直向右匹配,直到遇到范围查询(>、<、BETWEEN、LIKE)就会停止匹配。


这个原则决定了你的 SQL 查询语句是否能够使用以及如何高效地使用这个联合索引。


核心要点:



  1. 从左到右:索引的使用必须从最左边的列开始。

  2. 不能跳过:不能跳过联合索引中的某个列去使用后面的列。

  3. 范围查询右停止:如果某一列使用了范围查询,那么它右边的列将无法使用索引进行进一步筛选。


举例说明


假设我们有一个 users 表,并创建了一个联合索引 idx_name_age,包含 (last_name, age) 两个字段。


idlast_namefirst_nameagecity
1WangLei20Beijing
2ZhangWei25Shanghai
3WangFang22Guangzhou
4LiNa30Shenzhen
5ZhangSan28Beijing

索引 idx_name_age 在磁盘上大致是这样排序的(先按 last_name 排序,last_name 相同再按 age 排序):


(Li, 30) (Wang, 20) (Wang, 22) (Zhang, 25) (Zhang, 28)


现在,我们来看不同的查询场景:


✅ 场景一:完全匹配最左列


SELECT * FROM users WHERE last_name = 'Wang';



  • 分析:查询条件包含了索引的最左列 last_name。

  • 索引使用情况:✅ 可以使用索引。数据库可以快速在索引树中找到所有 last_name = 'Wang' 的记录((Wang, 20) 和 (Wang, 22))。


✅ 场景二:匹配所有列


SELECT * FROM users WHERE last_name = 'Wang' AND age = 22;



  • 分析:查询条件包含了索引的所有列,并且顺序与索引定义一致。

  • 索引使用情况:✅ 可以高效使用索引。数据库先定位到 last_name = 'Wang',然后在这些结果中快速找到 age = 22 的记录。


✅ 场景三:匹配最左连续列


SELECT * FROM users WHERE last_name = 'Zhang';



  • 分析:虽然只用了 last_name,但它是索引的最左列。

  • 索引使用情况:✅ 可以使用索引。和场景一类似。


❌ 场景四:跳过最左列


SELECT * FROM users WHERE age = 25;



  • 分析:查询条件没有包含索引的最左列 last_name。

  • 索引使用情况:❌ 无法使用索引。这就像让你在电话簿里直接找所有叫“伟”的人,你必须翻遍整个电话簿,也就是全表扫描。


⚠️ 场景五:包含最左列,但中间有断档


-- 假设我们有一个三个字段的索引 (col1, col2, col3) -- 查询条件为 WHERE col1 = 'a' AND col3 = 'c';



  • 分析:虽然包含了最左列 col1,但跳过了 col2 直接查询 col3。

  • 索引使用情况:✅ 部分使用索引。数据库只能使用 col1 来缩小范围,找到所有 col1 = 'a' 的记录。对于 col3 的过滤,它无法利用索引,需要在第一步的结果集中进行逐行筛选。


⚠️ 场景六:最左列是范围查询


SELECT * FROM users WHERE last_name > 'Li' AND age = 25;



  • 分析:最左列 last_name 使用了范围查询 >。

  • 索引使用情况:✅ 部分使用索引。数据库可以使用索引找到所有 last_name > 'Li' 的记录(即从 Wang 开始往后的所有记录)。但是,对于 age = 25 这个条件,由于 last_name 已经是范围匹配,age 列在索引中是无序的,因此数据库无法再利用索引对 age 进行快速筛选,只能在 last_name > 'Li' 的结果集中逐行检查 age。


总结与最佳实践


最左匹配原则的本质是由索引的数据结构(B+Tree) 决定的。索引按照定义的字段顺序构建,所以必须从最左边开始才能利用其有序性。


如何设计好的联合索引?



  1. 高频查询优先:将最常用于 WHERE 子句的列放在最左边。

  2. 等值查询优先:将经常进行等值查询(=)的列放在范围查询(>, <, LIKE)的列左边。

  3. 覆盖索引:如果查询的所有字段都包含在索引中(即覆盖索引),即使不符合最左前缀,数据库也可能直接扫描索引来避免回表,但这通常发生在二级索引扫描中,效率依然不如最左匹配。


作者:程序员小假
来源:juejin.cn/post/7565940210148868148
收起阅读 »

掌握协程的边界与环境:CoroutineScope 与 CoroutineContext

CoroutineScope 与 CoroutineContext 的概念 CoroutineContext (协程上下文) CoroutineContext 是协程上下文,包含了协程运行时所需的所有信息。 比如: 管理协程流程(生命周期)的 Job。 管理...
继续阅读 »

CoroutineScope 与 CoroutineContext 的概念


CoroutineContext (协程上下文)


CoroutineContext 是协程上下文,包含了协程运行时所需的所有信息。


比如:



  • 管理协程流程(生命周期)的 Job

  • 管理线程的 ContinuationInterceptor,它的实现类 CoroutineDispatcher 决定了协程所运行的线程或线程池。


CoroutineScope (协程作用域)


CoroutineScope 是协程作用域,它通过 coroutineContext 属性持有了当前协程代码块的上下文信息。


比如,我们可以获取 JobContinuationInterceptor 对象:


fun main() = runBlocking<Unit> {
val scope = CoroutineScope(EmptyCoroutineContext) // scope 并没有持有已有协程的上下文
val outerJob = scope.launch {
val innerJob = coroutineContext[Job]
val interceptor = coroutineContext[ContinuationInterceptor]
println("job: $innerJob, interceptor: $interceptor")
}

outerJob.join()
}

CoroutineScope 的另一个作用就是提供了 launchasync 协程构建器,我们可以通过它来启动一个协程。


这样,新创建的协程能够自动继承 CoroutineScopecoroutineContext。比如利用 Job,可以建立起父子关系,从而实现结构化并发。


GlobalScope


GlobalScope 是一个单例的 CoroutineScope 对象,所以我们在任何地方通过它来启动协程。


它的第二个特点是,它的 coroutineContext 属性是 EmptyCoroutineContext,也就是说它没有内置的 Job


@DelicateCoroutinesApi
public object GlobalScope : CoroutineScope {
/**
* Returns [EmptyCoroutineContext].
*/

override val coroutineContext: CoroutineContext
get() = EmptyCoroutineContext
}

即使是我们手动创建的 CoroutineScope,其内部也是有 Job 的。


// 手动创建 CoroutineScope
CoroutineScope(EmptyCoroutineContext)

// CoroutineScope.kt
@Suppress("FunctionName")
public fun CoroutineScope(context: CoroutineContext): CoroutineScope =
ContextScope(if (context[Job] != null) context else context + Job()) // 自动创建Job对象

所以我们在 GlobalScope.coroutineContext 中是获取不到 Job 的:


@OptIn(DelicateCoroutinesApi::class)
fun main() = runBlocking<Unit> {
val job: Job? = GlobalScope.coroutineContext[Job]
if (job == null) {
println("job is null")
}
try {
val jobNotNull: Job = GlobalScope.coroutineContext.job
} catch (e: IllegalStateException) {
println("job is null, exception is: $e")
}
}

运行结果:


job is null
job is null, exception is: java.lang.IllegalStateException: Current context doesn't contain Job in it: EmptyCoroutineContext

那么,这有什么用吗?


其实,GlobalScope 所启动的协程没有父 Job


这就意味着:



  • 当前协程不和其他 Job 的生命周期绑定,比如不会随着某个界面的关闭而自动取消。

  • 它是顶级协程,生命周期默认为整个应用的生命周期。

  • 它发生异常,并不会影响到其他协程和 GlobalScope。反之,GlobalScope 本身也无法级联取消所有任务,因为它所启动的协程是完全独立的。


总结:GlobalScope 就是用来启动那些不与组件生命周期绑定,而是与整个应用生命周期保持一致的全局任务,比如一个日志上报任务。


关键在使用时,可能会有资源泄露的风险,需要正确管理好协程的生命周期。


Context 的三个实用工具


在挂起函数中获取 CoroutineContext


如果我们要在一个挂起函数中获取 CoroutineContext,我们不得不给将其作为 CoroutineScope 的扩展函数。


suspend fun CoroutineScope.printContinuationInterceptor() {
delay(1000)
val interceptor = coroutineContext[ContinuationInterceptor]
println("interceptor: $interceptor")
}

但我们知道挂起函数的外部一定有协程存在,所以是存在 CoroutineContext 的。为此,Kotlin 协程库提供了一个顶层的 coroutineContext 属性,这个属性的 get() 函数是一个挂起函数,它能在任何挂起函数中访问到当前正在执行的协程的 CoroutineContext


import kotlin.coroutines.coroutineContext

suspend fun printContinuationInterceptor() {
delay(1000)
val interceptor = coroutineContext[ContinuationInterceptor]
println("interceptor: $interceptor")
}

另外,还有一个 currentCoroutineContext() 函数也能获取到 CoroutineContext,它内部实现也是 coroutineContext 属性。


为什么需要这个函数?


为了解决命名冲突,比如下面这段代码。


private fun mySuspendFun() {
flow<String> {
// 顶层属性
coroutineContext
}

GlobalScope.launch {
flow<String> {
// this 的成员属性优先级高于顶层属性
// 所以是外层 launch 的 CoroutineScope 的成员属性 coroutineContext
coroutineContext
}
}
}

在这种情况下,如果需要明确属性的源头,就需要使用 currentCoroutineContext() 函数,它会调用到那个顶层的属性。


CoroutineName 协程命名


CoroutineName 是一个协程上下文信息,我们可以使用它来给协程设置一个名称。


fun main() = runBlocking<Unit> {
val scope = CoroutineScope(EmptyCoroutineContext)
val name = CoroutineName("coroutine-1")
val job = scope.launch(name) {
val coroutineName = coroutineContext[CoroutineName]
println("current coroutine name: $coroutineName")
}

job.join()
}

运行结果:


current coroutine name: CoroutineName(coroutine-1)

它主要用于测试和调试,你可以使用它来区分哪些日志是哪个协程打印的。


自定义 CoroutineContext


如果我们要给协程附加一些功能,我们可以考虑自定义 CoroutineContext



如果是简单的标记,可以优先考虑使用 CoroutineName



自定义 CoroutineContext 需要实现 CoroutineContext.Element,并且提供 Key。为此,Kotlin 协程库提供了 AbstractCoroutineContextElement 来简化这个过程。我们只需这样,即可创建一个用于协程内部记录日志的 Context


// 继承 AbstractCoroutineContextElement,并把 Key 传给父构造函数
class CoroutineLogger(val tag: String) : AbstractCoroutineContextElement(CoroutineLogger) {

// 声明专属的 Key
companion object Key : CoroutineContext.Key<CoroutineLogger>

// 添加专属功能
fun log(message: String) {
println("[$tag] $message")
}
}

使用示例:


fun main() = runBlocking<Unit> {
val scope = CoroutineScope(Dispatchers.Default)
val job = scope.launch(CoroutineLogger("Test")) {
val logger = coroutineContext[CoroutineLogger]
logger?.log("Start")
delay(5000)
logger?.log("End")
}
job.join()
}

运行结果:


[Test] Start
[Test] End

coroutineScope() 与 withContext()


coroutineScope 串行的异常封装器


coroutineScope 是一个挂起函数,它会挂起当前协程,直到执行完内部的所有代码(包括会等待内部启动的所有子协程执行完毕),最后一行代码的执行结果会作为函数的返回值。


coroutineScope 会创建一个新的 CoroutineScope,在这个作用域中执行 block 代码块。并且这个作用域严格继承了父上下文(coroutineContext),并会在内部创建一个新的 Job,作为父 Job 的子 Job



coroutineScope 从效果上来看,和 launch().join() 类似。



那么,它的应用场景是什么?


它的应用场景由它的特性决定,有两个核心场景:



  1. 在挂起函数中提供 CoroutineScope。(最常用)


    suspend fun CoroutineScope.mySuspendFunction() {
    delay(1000)
    launch {
    println("launch")
    }
    }

    如果你要在挂起函数中启动一个新的协程,你只好将其定义为 CoroutineScope 的扩展函数。不过,你也可以使用 coroutineScope 来提供作用域。


    它能提供作用域,是因为挂起函数的外部一定存在着协程,所以一定具有 CoroutineScope


    suspend fun doConcurrentWork() {
    val startTime = System.currentTimeMillis()
    coroutineScope {
    val task1 = async { // 任务1
    delay(5000)
    }
    val task2 = async { // 任务2
    delay(3000)
    }
    } // // 挂起,直到上面两个 async 都完成
    val endTime = System.currentTimeMillis()
    println("Total execution time: ${endTime - startTime}") // 5000 左右
    }


  2. 业务逻辑封装并进行异常处理。(最重要)


    我们都知道,我们无法在协程外部使用 try-catch 捕获协程内部的异常。


    但使用 coroutineScope 函数可以,当它内部的任何子协程失败了,它会将这个异常重新抛出来,这时我们可以使用 try-catch 来捕获。


    fun main() = runBlocking<Unit> {
    try {
    coroutineScope {
    val data1 = async {
    "user-1"
    }
    val data2 = async {
    throw IllegalStateException("error")
    }

    awaitAll(data1, data2)
    }
    } catch (e: Exception) {
    println("exception is: $e")
    }
    }

    运行结果:


    exception is: java.lang.IllegalStateException: error

    原因也很简单,因为它是一个串行的挂起函数,外部协程会被挂起,直到它执行完毕。如果它的内部出现了异常,外部协程是能够知晓的。


    coroutineScope 可以将并发的崩溃变为可被捕获、处理的异常,常用于处理并发错误。



withContext 串行的上下文切换器


我们再来看 withContext,其实它和 coroutineScope 几乎一样。



它也是一个串行的挂起函数,也会返回代码块的结果,内部也是启动了一个新的协程。



它和 coroutineScope 的唯一的不同是,withContext 允许我们传递上下文。你也可以这么想,coroutineScope 就是一个不改变任何上下文的 withContext


withContext(EmptyCoroutineContext) { // 沿用旧的 CoroutineContext

}

withContext(coroutineContext) { // 使用旧的 CoroutineContext

}

withContext 的使用场景就很清楚了,我们需要切换上下文的时候会使用它,并且希望代码是串行执行的,之后还能再切回原来的线程继续往下执行。



虽然 withContextcoroutineScope 类似,但 coroutineScope 更多用于封装业务异常。



suspend fun getUserProfile() {
// 当前在 Dispatchers.Main
val profile = withContext(Dispatchers.IO) {
// 自动切换到 IO 线程
Thread.sleep(3000) // 耗时操作
"the user profile"
}

// 自动切回 Dispatchers.Main
println("the user profile is $profile")
}

CoroutineContext 的加、取操作


加法:合并与替换


两个 CoroutineContext 相加调用的是 plus()


public operator fun plus(context: CoroutineContext): CoroutineContext =
if (context === EmptyCoroutineContext) this else // fast path -- avoid lambda creation
context.fold(this) { acc, element ->
val removed = acc.minusKey(element.key)
if (removed === EmptyCoroutineContext) element else {
// make sure interceptor is always last in the context (and thus is fast to get when present)
val interceptor = removed[ContinuationInterceptor]
if (interceptor == null) CombinedContext(removed, element) else {
val left = removed.minusKey(ContinuationInterceptor)
if (left === EmptyCoroutineContext) CombinedContext(element, interceptor) else
CombinedContext(CombinedContext(left, element), interceptor)
}
}
}

其中关键在于 CombinedContext,它是 CoroutineContext 的实现类:


// CoroutineContextImpl.kt
@SinceKotlin("1.3")
internal class CombinedContext(
private val left: CoroutineContext,
private val element: Element // Element 也是 `CoroutineContext` 的实现类
) : CoroutineContext, Serializable

它会将操作符两边的上下文使用 CombinedContext 对象包裹(合并),如果两个上下文具有相同的 Key,加号右侧的会替换左侧的。


比如 Dispatchers.IO + Job() + CoroutineName("my-name") 一共会进行三次合并,得到三个 CombinedContext 对象,不会进行替换。


fun main() = runBlocking<Unit> {
val handler = CoroutineExceptionHandler { _, throwable ->
println("Caught $throwable")
}
val job =
launch(Dispatchers.IO + Job() + CoroutineName("my-name") + handler) {
println(coroutineContext)
}
job.join()
}

运行结果:


[CoroutineName(my-name), com.example.coroutinetest.TestKtKt$main$1$invokeSuspend$$inlined$CoroutineExceptionHandler$1@6667fe3b, StandaloneCoroutine{Active}@26b2b4fd, Dispatchers.IO]

如果在末尾再加上一个 CoroutineName("your_name"),会进行一次替换,运行结果是:[com.example.coroutinetest.TestKtKt$main$1$invokeSuspend$$inlined$CoroutineExceptionHandler$1@6667fe3b, CoroutineName(your_name), StandaloneCoroutine{Active}@26b2b4fd, Dispatchers.IO]


[] 取值


[] 取值其实调用的是 CoroutineContext.get() 函数,它会从上下文(CombinedContext 树)中找到我们需要的信息。


@SinceKotlin("1.3")
public interface CoroutineContext {
/**
* Returns the element with the given [key] from this context or `null`.
*/

public operator fun <E : Element> get(key: Key<E>): E?

// ...
}

我们填入的参数其实是每一个接口的伴生对象 Key,每个伴生对象都实现了 CoroutineContext.Key<T> 接口,并将泛型指定为了当前接口。


ContinuationInterceptor 为例:


@SinceKotlin("1.3")
public interface ContinuationInterceptor : CoroutineContext.Element {
/**
* The key that defines *the* context interceptor.
*/

public companion object Key : CoroutineContext.Key<ContinuationInterceptor>

// ...
}

比如我们要获取上下文中的 CoroutineDispatcher,我们可以这样做:


fun main() = runBlocking<Unit> {
val scope = CoroutineScope(Dispatchers.Default)
val job = scope.launch {
val dispatcher = coroutineContext[ContinuationInterceptor] as CoroutineDispatcher // 强转
println("CoroutineDispatcher is $dispatcher")
}
job.join()
}

作者:雨白
来源:juejin.cn/post/7564230484126892071
收起阅读 »

前端部署,又有新花样?

web
大多数前端开发者在公司里,很少需要直接操心“部署”这件事——那通常是运维或 DevOps 的工作。 但一旦回到个人项目,情况就完全不一样了。写个小博客、搭个文档站,或者搞个 demo 想给朋友看,部署往往成了最大的拦路虎。 常见的选择无非是 Vercel、Ne...
继续阅读 »

大多数前端开发者在公司里,很少需要直接操心“部署”这件事——那通常是运维或 DevOps 的工作。


但一旦回到个人项目,情况就完全不一样了。写个小博客、搭个文档站,或者搞个 demo 想给朋友看,部署往往成了最大的拦路虎。


常见的选择无非是 Vercel、Netlify 或 GitHub Pages。它们表面上“一键部署”,但细节其实并不轻松:要注册平台账号、要配置域名,还得接受平台的各种限制。国内的一些云服务商(比如阿里云、腾讯云)管控更严格,操作门槛也更高。更让人担心的是,一旦平台宕机,或者因为地区网络问题导致访问不稳定,你的项目可能随时“消失”在用户面前。虽然这种情况不常见,但始终让人心里不踏实。


很多时候,我们只是想快速上线一个小页面,不想被部署流程拖累,有没有更好的方法?


一个更轻的办法


前段时间我发现了一个开源工具 PinMe,主打的就是“极简部署”。



它的使用体验非常直接:



  • 不需要服务器

  • 不用注册账号

  • 在项目目录敲一条命令,就能把项目打包上传到 IPFS 网络

  • 很快,你就能拿到一个可访问的地址


实际用起来的感受就是一个字:


整个过程几乎没有繁琐配置,不需要绑定平台账号,也不用担心流量限制或收费。


这让很多场景变得顺手:



  • 临时展示一个 demo,不必折腾服务器

  • 写了个静态博客,不想搞 CI/CD 流程

  • 做了个活动页或 landing page,随时上线就好


以前这些需求可能要纠结“用 GitHub Pages 还是 Vercel”,现在有了 PinMe,直接一键上链就行。


体验一把


接下来看看它在真实场景下的表现:部署流程有多简化?访问速度如何?和传统方案相比有没有优势?


测试项目


为了覆盖不同体量的场景,这次我选了俩类项目来测试:



  • 小型项目:fuwari(开源的个人博客项目),打包体积约 4 MB。

  • 中大型项目:Soybean Admin(开源的后台管理系统),打包体积约 15 MB。


部署项目


PinMe 提供了两种方式:命令行可视化界面



这两种方式我们都来试一下。


命令行部署


先全局安装:


npm install -g pinme

然后一条命令上传:


pinme upload <folder/file-path>

比如上传 Soybean Admin,文件大小 15MB:



输入命令之后,等着就可以了:



只用了两分钟,终端返回的 URL 就能直接访问项目的控制页面。还能绑定自己的域名:



点击网站链接就可以看到已经部署好的项目,访问速度还是挺快的:



同样地,上传个人博客也是一样的流程。



部署完成:



可视化部署


不习惯命令行?PinMe 也提供了网页上传,进度条实时显示:



部署完成后会自动进入管理页面:



经过测试,部署速度和命令行几乎一致。


其他功能


历时记录


部署过的网站都能在主页的 History 查看:



历史部署记录:



也可以用命令行:


pinme list

历史部署记录:



删除网站


如果不再需要某个项目,执行以下命令即可:


pinme rm

PinMe 背后的“硬核支撑”


如果只看表层,PinMe 就像一个极简的托管工具。但要理解它为什么能做到“不依赖平台”,还得看看它背后的底层逻辑。


PinMe 的底层依赖 IPFS,这是一个去中心化的分布式文件系统。


要理解它的意义,得先聊聊“去中心化”这个概念。


传统互联网是中心化的:你访问一个网站时,浏览器会通过 DNS 找到某台服务器,然后从这台服务器获取内容。这条链路依赖强烈,一旦 DNS 被劫持、服务器宕机、服务商下线,网站就无法访问。



去中心化的思路完全不同:



  • 数据不是放在单一服务器,而是分布在全球节点中

  • 访问不依赖“位置”,而是通过内容哈希来检索

  • 只要有节点存储这份内容,就能访问到,不怕单点故障


这意味着:



  • 更稳定:即使部分节点宕机,内容依然能从其他节点获取。

  • 防篡改:文件哪怕改动一个字节,对应的 CID 也会完全不同,从机制上保障了前端资源的完整性和安全性。

  • 更自由:不再受制于中心化平台,文件真正由用户自己掌控。


当然,IPFS 地址(哈希)太长,不适合直接记忆和分享。这时候就需要 ENS(Ethereum Name Service)。它和 DNS 类似,但记录存储在以太坊区块链上,不可能被篡改。比如你可以把 myblog.eth 指向某个 IPFS 哈希,别人只要输入 ENS 域名就能访问,不依赖传统 DNS,自然也不会被劫持。



换句话说:



ENS + IPFS = 内容去中心化 + 域名去中心化




前端个人项目瞬间就有了更高的自由度和安全性。


一点初步感受


PinMe 并不是要取代 Vercel 这类成熟平台,但它带来了一种新的选择:更简单、更自由、更去中心化


如果你只是想快速上线一个小项目,或者对去中心化部署感兴趣,PinMe 值得一试。





这是一个完全开源的项目,开发团队也会持续更新。如果你在测试过程中有想法或需求,不妨去 GitHub 提个 Issue —— 这不仅能帮助项目成长,也能让它更贴近前端开发的实际使用场景!


作者:CUGGZ
来源:juejin.cn/post/7547515500453380136
收起阅读 »

antd 对 ai 下手了!Vue 开发者表示羡慕!

web
前端开发者应该对 Ant Design 不陌生,特别是 React 开发者,antd 应该是组件库的标配了。 近年来随着 AI 的爆火,凡是想要接入 AI 的都想搞一套自己的 AI 交互界面。专注于 AI 场景组件库的开源项目倒不是很多见,近日 antd 宣布...
继续阅读 »


前端开发者应该对 Ant Design 不陌生,特别是 React 开发者,antd 应该是组件库的标配了。


近年来随着 AI 的爆火,凡是想要接入 AI 的都想搞一套自己的 AI 交互界面。专注于 AI 场景组件库的开源项目倒不是很多见,近日 antd 宣布推出 Ant Design X 1.0 🚀 ,这是一个基于 Ant Design 的全新 AGI 组件库,使用 React 构建 AI 驱动的用户交互变得更简单了,它可以无缝集成 AI 聊天组件和 API 服务,简化 AI 界面的开发流程。


该项目已在 Github 开源,拥有 1.6K Star!



看了网友的评论,看来大家还是需要的!当前的 Ant Design X 只支持 React 项目,看来 Vue 开发者要羡慕了...



ant-design-x 特性



  • 🌈 源自企业级 AI 产品的最佳实践:基于 RICH 交互范式,提供卓越的 AI 交互体验

  • 🧩 灵活多样的原子组件:覆盖绝大部分 AI 对话场景,助力快速构建个性化 AI 交互页面

  • ⚡ 开箱即用的模型对接能力:轻松对接符合 OpenAI 标准的模型推理服务

  • 🔄 高效管理对话数据流:提供好用的数据流管理功能,让开发更高效

  • 📦 丰富的样板间支持:提供多种模板,快速启动 LUI 应用开发

  • 🛡 TypeScript 全覆盖:采用 TypeScript 开发,提供完整类型支持,提升开发体验与可靠性

  • 🎨 深度主题定制能力:支持细粒度的样式调整,满足各种场景的个性化需求


支持组件


以下圈中的部分为 ant-design-x 支持的组件。可以看到主要都是基于 AI Chat 场景的组件设计。现在你可以基于这些组件自由组装搭建一个自己的 AI 界面。



ant-design-x 也提供了一个完整 AI Chat 的 Demo 演示,可以查看 Demo 的代码并直接使用。



更多组件详细内容可参考 组件文档


使用


以下命令安装 @ant-design/x 依赖。


注意,ant-design-x 是基于 Ant Design,因此还需要安装依赖 antd


yarn add antd @ant-design/x

import React from 'react';
import {
// 消息气泡
Bubble,
// 发送框
Sender,
} from '@ant-design/x';

const messages = [
{
content: 'Hello, Ant Design X!',
role: 'user',
},
];
const App = () => (
<div>
<Bubble.List items={messages} />
<Sender />
</div>

);

export default App;

Ant Design X 前生 ProChat


不知道有没有小伙伴们使用过 ProChat,这个库后面的维护可能会有些不确定性,其维护者表示 “24 年下半年后就没有更多精力来维护这个项目了,Github 上的 Issue 存留了很多,这边只能尽量把一些恶性 Bug 修复



如上所示,也回答了其和 Ant Design X 的关系:ProChat 是 x 的前生,新用户请直接使用 x,老用户也请尽快迁移到 x


感兴趣的朋友们可以去试试哦!


作者:智见君
来源:juejin.cn/post/7444878635717443595
收起阅读 »

Swift 反初始化器详解——在实例永远“消失”之前,把该做的事做完

iOS
为什么要“反初始化”ARC 已经帮我们释放了内存,但“内存”≠“资源”。可能你打开过文件、有过数据库连接、订阅过通知、甚至握着 GPU 纹理句柄。反初始化器(deinit)是 Swift 给你“最后一声道别”的钩子:实例即将被销毁 → 系统自动调用 → 你可以...
继续阅读 »

为什么要“反初始化”

  1. ARC 已经帮我们释放了内存,但“内存”≠“资源”。

    可能你打开过文件、有过数据库连接、订阅过通知、甚至握着 GPU 纹理句柄。

  2. 反初始化器(deinit)是 Swift 给你“最后一声道别”的钩子:

    实例即将被销毁 → 系统自动调用 → 你可以把文件关掉、把硬币还回银行、把日志写盘……

  3. 只有 class 有 deinit,struct / enum 没有;一个类最多一个 deinit;不允许手动显式调用。

deinit 的 6 条铁律

  1. 无参无括号:
class MyCls {
deinit { // 不能写 deinit() { ... }
// 清理代码
}
}
  1. 自动调用,调用顺序:子类 deinit 执行完 → 父类 deinit 自动执行。
  2. 实例“还没死”:deinit 里可访问任意 self 属性,甚至可调用实例方法。
  3. 不能自己调、不能重载、不能抛异常、不能带 async。
  4. 如果实例从未被真正强引用(例如刚 init 就赋 nil),deinit 不会触发。
  5. 若存在循环引用(strong reference cycle),deinit 永远不会触发——必须先解环。

示例

import Foundation

// MARK: - 银行:管理游戏世界唯一货币
@MainActor
class Bank {
// 静态共享实例 + 私有初始化,保证“全世界只有一家银行”
static let shared = Bank()
private init() {}

// 剩余硬币,private(set) 让外部只读
private(set) var coinsInBank = 10_000

/// 发放硬币;返回实际发出的数量(可能不够)
func distribute(coins number: Int) -> Int {
let numberToVend = min(number, coinsInBank)
coinsInBank -= numberToVend
print("银行发放 \(numberToVend) 枚,剩余 \(coinsInBank)")
return numberToVend
}

/// 回收硬币
func receive(coins number: Int) {
coinsInBank += number
print("银行回收 \(number) 枚,当前 \(coinsInBank)")
}
}

// MARK: - 玩家:从银行拿硬币,离开时自动归还
@MainActor
class Player {
var coinsInPurse: Int

/// 指定构造器:向银行申请“启动资金”
init(coins: Int) {
let received = Bank.shared.distribute(coins: coins)
coinsInPurse = received
print("玩家初始化,钱包得到 \(received)")
}

/// 赢钱:从银行再拿一笔
func win(coins: Int) {
let won = Bank.shared.distribute(coins: coins)
coinsInPurse += won
print("玩家赢得 \(won),钱包现在 \(coinsInPurse)")
}

/// 反初始化器:人走茶不凉,硬币先还银行
@MainActor
deinit {
print("玩家 deinit 开始,归还 \(coinsInPurse)")
Bank.shared.receive(coins: coinsInPurse)
print("玩家 deinit 结束")
}
}

// MARK: - 游戏主流程
@MainActor
func gameDemo() {
print("=== 游戏开始 ===")

// 1. 创建玩家;注意用可选类型,因为玩家随时可能 leave
var playerOne: Player? = Player(coins: 100)
// 如果不加调试打印,可简写:playerOne?.win(coins: 2000)
if let p = playerOne {
print("玩家当前硬币:\(p.coinsInPurse)")
p.win(coins: 2_000)
}

// 2. 玩家离开游戏;引用置 nil → 强引用归零 → deinit 被调用
print("玩家离开,引用置 nil")
playerOne = nil

print("=== 游戏结束 ===")
}

gameDemo()

运行结果

=== 游戏开始 ===
银行发放 100 枚,剩余 9900
玩家初始化,钱包得到 100
玩家当前硬币:100
银行发放 2000 枚,剩余 7900
玩家赢得 2000,钱包现在 2100
玩家离开,引用置 nil
玩家 deinit 开始,归还 2100
银行回收 2100 枚,当前 10000
玩家 deinit 结束
=== 游戏结束 ===

3 个高频扩展场景

  1. 关闭文件句柄
class Logger {
private let handle: FileHandle
init(path: String) throws {
handle = try FileHandle(forWritingTo: URL(fileURLWithPath: path))
}
deinit {
handle.closeFile() // 文件一定会被关掉
}
}
  1. 注销通知中心观察者
class KeyboardManager {
private var tokens: [NSObjectProtocol] = []
init() {
tokens.append(
NotificationCenter.default.addObserver(forName: UIResponder.keyboardWillShowNotification, object: nil, queue: .main) { _ in }
)
}
deinit {
tokens.forEach(NotificationCenter.default.removeObserver)
}
}
  1. 释放手动分配的 C 内存 / GPU 纹理
class Texture {
private var raw: UnsafeMutableRawPointer?
init(size: Int) {
raw = malloc(size)
}
deinit {
free(raw) // 防止内存泄漏
}
}

常见踩坑与排查清单

现象可能原因排查工具
deinit 从不打印出现强引用循环Xcode Memory Graph / leaks 命令
子类 deinit 未调用父类 init 失败提前 return在 init 各阶段加打印
访问属性崩溃在 deinit 里访问了 weak / unowned 已释放属性改用 strong 或提前判空

小结:把 deinit 当成“遗嘱执行人”

  1. 它只负责“身后事”:释放非内存资源、归还全局状态、写日志。
  2. 它不能保命:如果实例因为循环引用一直活着,就永远走不到 deinit。
  3. 它不能抢戏:别在 deinit 里做耗时任务(网络、IO),否则可能阻塞主线程或单元测试。
  4. 用好 weak / unowned + deinit,可以让 Swift 代码在“自动”与“可控”之间取得最佳平衡。

深入底层:deinit 在 SIL & 运行时到底做了什么

swiftc -emit-sil main.swift mainsil

  1. SIL(Swift Intermediate Language)视角

    编译器会为每个类生成一个 sil_vtable,里面存放了类中的所有方法,可以看到deinit中调用的是Player.__deallocating_deinit

    image.png

    Player.__deallocating_deinit中调用的 Player.__isolated_deallocating_deinit

    image.png

    Player.__isolated_deallocating_deinit中调用Player.deinit

    image.png

    伪代码:

   sil @destroy_Player : $@convention(method) (@owned Player) -> () {
bb0(%0 : $Player):
// 1. 调用 deinit
%2 = function_ref @$s4main6PlayerCfZ : $@convention(thin) (@owned Player) -> ()
%3 = apply %2(%0) : $@convention(method) (@guaranteed Player) -> @owned Builtin.NativeObject // user: %4
// 2. 销毁存储属性
destroy_addr %0.#coinsInPurse
// 3. 释放整个对象内存
strong_release %5
}

结论:deinit 只是“销毁流水线”里的一环;先跑 deinit,再跑成员销毁,最后归还堆内存。

  1. 运行时视角

    Swift 对象头部有一个 32-byte 的 HeapObject,其中 refCounts 字段采用“Side Table” 策略。

    当最后一次 swift_release 把引用计数降到 0 时,会立即跳到 destroy 函数指针 → 也就是上面的 SIL 函数。

    因此:

    • deinit 执行线程 = 最后一次 release 发生的线程;
    • deinit 执行耗时 ≈ 对象大小 + 成员销毁耗时 + 你写的代码耗时;
    • 如果 deinit 里再产生强引用(例如把 self 塞进全局数组),对象会被“复活”,但 Swift 5.5 之后禁止这种 resurrection,会直接 trap。

多线程与 deinit 的 4 个实战坑

场景风险正确姿势
子线程释放主线程创建的实例deinit 里刷新 UI用 DispatchQueue.main.async 或 MainActor.assertIsolated()
deinit 里加锁可能和 init 锁顺序相反 → 死锁尽量无锁;必须加锁时统一层级
deinit 里用 unowned 访问外部对象外部对象可能已释放改用 weak 并判空
deinit 里继续派发异步任务任务持有 self → 循环复活使用 Task { [weak self] in ... }

与 Objective-C 的交叉:dealloc vs deinit

  1. 继承链
@objc class BaseNS: NSObject {
deinit { print("Swift deinit") } // 实际上会生成 -dealloc 方法
}

编译器把 deinit 映射成 Objective-C 的 -dealloc,并在末尾自动插入 [super dealloc](ARC 下自动插入)。
2. 混编时序

  • Swift 侧先跑完 deinit;
  • 再跑 Objective-C 侧生成的 -dealloc
  • 最后 NSObject 的 -dealloc 释放 isa 与 ARC 附带内存。
  1. 注意点

    若你在 Objective-C 侧手动 override -dealloc,记得不要显式调用 [super dealloc](ARC 会自动加),否则编译报错。

Swift 5.9 新动向:move-only struct 的 deinit

SE-0390 已经落地 move-only ~Copyable struct,也可以写 deinit!

struct FileDescriptor: ~Copyable {
private let fd: Int32
init(path: String) throws { fd = open(path, O_RDONLY) }
deinit { // struct 也能有 deinit!
close(fd)
}
}

规则:

  • 只要值被消耗(consume)或生命周期结束,deinit 就执行;
  • 不能同时实现 deinit 和 Copyable
  • 用于文件句柄、GPU 描述符等“必须唯一所有权”场景,彻底告别 class + deinit 的性能损耗。

一张“思维导图”收尾

class 实例

├─ refCount == 0
├─ 否:继续浪
└─ 是:进入 destroy 流水线
1. 子类 deinit
2. 父类 deinit
3. 销毁所有存储属性
4. 归还堆内存

├─ 线程:最后一次 release 线程
├─ 复活:Swift 5.5+ 禁止,直接 trap

彩蛋:把 deinit 做成“叮”一声

#if DEBUG
deinit {
// 只调一次,不会循环引用
DispatchQueue.main.async {
AudioServicesPlaySystemSound(1057) // 键盘“叮”
}
}
#endif

每次对象销毁都会“叮”,办公室同事会投来异样眼光,但你能瞬间听出内存泄漏——当该响的没响,就说明循环引用啦!


作者:unravel2025
来源:juejin.cn/post/7566289235347816486
收起阅读 »

MyBatis 中 where1=1 一些替换方式

题记 生命中的风景千变万化,但我一直在路上。 风雨兼程,不是为了抵达终点,而是为了沿途的风景。 起因 今天闲来无事,翻翻看看之前的项目。 在看到一个项目的时候,项目框架用的是SpringMvc+Spring+Mybatis。项目里面注释时间写的是201...
继续阅读 »

题记



生命中的风景千变万化,但我一直在路上。




风雨兼程,不是为了抵达终点,而是为了沿途的风景。



起因


今天闲来无事,翻翻看看之前的项目。


在看到一个项目的时候,项目框架用的是SpringMvc+Spring+Mybatis。项目里面注释时间写的是2018年,时间长了,里面有好多语法现在看起来好麻烦的样子呀!


说有它就有,这不就有一个吗?在Mybatis配置的xml中,有好多where 1=1 拼接Sql的方式,看的人头都大了。想着改一下吧,又一想,代码已经时间长了,如果出现问题找谁,就先不管了。


话是这样说,但在实际工作中,还是会有方法可以代替的,下面我们一起来看看吧!


替换方式


在 MyBatis 中,WHERE 1=1 通常用来在多条件查询情况下下进行SQL 拼接,其目的就是避免在没有条件时出现语法错误。


但这种写法不够优雅,可通过以下方式进行替代:


1. 使用 <where> 标签(推荐)


MyBatis 的 <where> 标签会自动处理 SQL 的 WHERE 语句,移除多余的 AND 或 OR 关键字。


看实例:


<select id="selectUsers" resultType="User"> 
SELECT * FROM user
<where>
<if test="username != null and username != ''">
AND username = #{username}
</if>
<if test="age != null">
AND age = #{age}
</if>
</where>
</select>

效果说明:



  • 当无参数时,此时执行的Sql语句为:SELECT * FROM user

  • 当仅传 username 时,此时执行的Sql语句为:SELECT * FROM user WHERE username = ?

  • 当传 username 和 age 时,此时执行的Sql语句为:SELECT * FROM user WHERE username = ? AND age = ?


2. 使用 <trim> 标签自定义


<trim> 可更灵活地处理 SQL 片段,通过设置 prefix 和 prefixOverrides 属性模拟 <where> 的功能。


看实例:


<select id="selectUsers" resultType="User">
SELECT * FROM user
<trim prefix="WHERE" prefixOverrides="AND |OR ">
<if test="username != null and username != ''">
AND username = #{username}
</if>
<if test="age != null">
AND age = #{age}
</if>
</trim>
</select>

说明:



  • prefix="WHERE":在条件前添加 WHERE 关键字。

  • prefixOverrides="AND |OR ":移除条件前多余的 AND 或 OR


3. 使用 <choose><when><otherwise>


类似Java在进行判断中常用的 switch-case语句,此方式适用于多条件互斥的场景。


看实例:


<select id="selectUsers" resultType="User">
SELECT * FROM user
<where>
<choose>
<when test="username != null and username != ''">
username = #{username}
</when>
<when test="age != null">
age = #{age}
</when>
<otherwise>
1=1 <!-- 仅在无任何条件时使用 -->
</otherwise>
</choose>
</where>
</select>

4. Java代码判断控制


在 Service 层根据条件动态选择不同的 SQL 语句。


看实例:


public List<User> getUsers(String username, Integer age) {
if (username != null && !username.isEmpty()) {
return userMapper.selectByUsername(username);
} else if (age != null) {
return userMapper.selectByAge(age);
} else {
return userMapper.selectAll();
}
}

具体方式对比与选择


方式适用场景优点缺点
<where>多条件动态组合自动处理 WHERE 和 AND需MyBatis 框架支持
<trim>复杂 SQL 片段处理灵活度比较高配置稍繁琐
<choose>多条件互斥选择逻辑清晰无明确条件时仍需 1=1
Java代码判断控制条件逻辑复杂完全可控增加Service层代码复杂度

总结


推荐优先使用 <where> 标签,它能自动处理 SQL 语法,避免冗余代码。只有在需要更精细控制时,才考虑 <trim> 或其他方式。尽量避免在 XML 中使用 WHERE 1=1,保持 SQL 的简洁性和规范性。


展望



世间万物皆美好, 终有归途暖心潮。




在纷繁的世界里,保持内心的宁静与坚定,让每一步都走向完美的结局。



作者:熊猫片沃子
来源:juejin.cn/post/7534892673107804214
收起阅读 »

​从RBAC到ABAC的进阶之路:基于jCasbin实现无侵入的SpringBoot权限校验​

一、前言:当权限判断写满业务代码 几乎所有企业系统,都逃不过“权限”这道关。 从“谁能看”、“谁能改”到“谁能审批”,权限逻辑贯穿了业务的方方面面。 起初,大多数项目使用最常见的 RBAC(基于角色的访问控制) 模型 if (user.hasRole("adm...
继续阅读 »

一、前言:当权限判断写满业务代码


几乎所有企业系统,都逃不过“权限”这道关。

从“谁能看”、“谁能改”到“谁能审批”,权限逻辑贯穿了业务的方方面面。


起初,大多数项目使用最常见的 RBAC(基于角色的访问控制) 模型


if (user.hasRole("admin")) {
documentService.update(doc);
}

逻辑简单、上手快,看似能解决 80% 的问题。

但随着业务复杂度上升,RBAC 很快会失控。


比如你可能遇到以下需求 👇



  • “文档的作者可以编辑自己的文档”;

  • “同部门的经理也可以编辑该文档”;

  • “外部合作方仅能查看共享文档”;

  • “项目归档后,所有人都只读”。


这些场景无法用“角色”简单定义,

于是权限判断开始蔓延在业务代码各处,像这样:


if (user.getId().equals(doc.getOwnerId()) 
|| (user.getDept().equals(doc.getDept()) && user.isManager())) {
// 编辑文档
} else {
throw new AccessDeniedException("无权限");
}

时间久了,这些判断像杂草一样蔓延。

权限逻辑与业务逻辑纠缠不清,修改一处可能引发连锁反应。

可维护性、可测试性、可演化性统统崩盘。


二、RBAC 的天花板:角色无法描述现实世界


RBAC 的问题在于:它过于静态

“角色”可以描述一类人,但描述不了上下文。


举个例子:



研发经理能编辑本部门的文档,但不能编辑市场部的。



在 RBAC 下,你只能再创建新角色:

研发经理市场经理项目经理……

角色越来越多,最终爆炸。


而现实世界的权限,往往与“属性”有关:



  • 用户的部门

  • 资源的拥有者

  • 操作发生的时间 / 状态


这些动态因素,是 RBAC 无法覆盖的。

于是我们需要一个更灵活的模型 —— ABAC


三、ABAC:基于属性的访问控制


ABAC(Attribute-Based Access Control) 的核心理念是:



授权决策 = 函数(主体属性、资源属性、操作属性、环境属性)



概念含义示例
Subject(主体)谁在访问用户A,部门=研发部
Object(资源)访问什么文档1,ownerId=A,部门=研发部
Action(操作)做什么edit / read / delete
Policy(策略)允许条件user.dept == doc.dept && act == "edit"

一句话总结:



ABAC 不关心用户是谁,而关心“用户和资源具有什么属性”。



举例说明:



“用户可以编辑自己部门的文档,或自己创建的文档。”



简单、直观、灵活。


四、引入 JCasbin:让授权逻辑从代码中消失


JCasbin(github.com/casbin/jcas…) 是一个优秀的 Java 权限引擎,支持多种模型(RBAC、ABAC)。


它最大的价值在于:

把授权逻辑从代码中抽离,让代码只负责执行业务。


在 JCasbin 中,我们通过定义:



  • 模型文件(model) :规则框架;

  • 策略文件(policy) :具体规则。


然后由 Casbin 引擎来执行判断。


五、核心实现:几行配置搞定动态权限


模型文件 model.conf


[request_definition]
r = sub, obj, act

[policy_definition]
p = sub_rule, obj_rule, act

[policy_effect]
e = some(where (p.eft == allow))

[matchers]
m = eval(p.sub_rule) && eval(p.obj_rule) && r.act == p.act

策略文件 policy.csv


p, r.sub.dept == r.obj.dept, true, edit
p, r.sub.id == r.obj.ownerId, true, edit
p, true, true, read

解释:



  • 同部门可编辑;

  • 作者可编辑;

  • 所有人可阅读。


在代码中调用


Enforcer enforcer = new Enforcer("model.conf", "policy.csv");

User user = new User("u1", "研发部");
Document doc = new Document("d1", "研发部", "u1");

boolean canEdit = enforcer.enforce(user, doc, "edit");
System.out.println("是否有编辑权限:" + canEdit);

输出:


是否有编辑权限:true

无需任何 if-else,逻辑全在外部配置中定义

业务代码只需调用 Enforcer,简单又优雅。




六、在 Spring Boot 中实现“无感校验”


实际项目中,我们希望权限校验能“自动触发”,

这可以通过 注解 + AOP 切面 的方式实现。


定义注解


@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface CheckPermission {
String action();
}

编写切面


@Aspect
@Component
public class PermissionAspect {

@Autowired
private Enforcer enforcer;

@Before("@annotation(checkPermission)")
public void checkAuth(JoinPoint jp, CheckPermission checkPermission) {
Object user = getCurrentUser();
Object resource = getRequestResource(jp);
String action = checkPermission.action();

if (!enforcer.enforce(user, resource, action)) {
throw new AccessDeniedException("无权限执行操作:" + action);
}
}
}

在业务代码中使用


@CheckPermission(action = "edit")
@PostMapping("/doc/edit")
public void editDoc(@RequestBody Document doc) {
documentService.update(doc);
}

✅ 授权逻辑彻底从业务中解耦,权限统一由 Casbin 引擎处理。


七、策略动态化与分布式支持


在生产环境中,权限策略通常存储在数据库中,而非文件。

JCasbin 支持多种扩展方式:


JDBCAdapter adapter = new JDBCAdapter(dataSource);
Enforcer enforcer = new Enforcer("model.conf", adapter);

支持特性包括:



  • 💽 MySQL / PostgreSQL 等持久化;

  • 🔄 Redis Watcher 实现多节点策略热更新;

  • ⚡ SyncedEnforcer 支持高并发一致性。


这样修改权限规则就无需重新部署代码,权限即改即生效


八、总结


引入 JCasbin 后,项目结构会发生显著变化👇


优势描述
逻辑解耦授权逻辑完全从业务代码中剥离
灵活配置权限规则动态可改、可热更新
可扩展可根据属性定义复杂条件
统一决策所有权限判断走同一引擎
可测试策略可单测,无需跑整套业务流程

最重要的是:新增规则无需改代码

只要在策略表里加一条记录,就能实现全新的授权逻辑。


权限系统的复杂,不在于“能不能判断”,

而在于——“判断逻辑放在哪儿”。


当项目越做越大,你会发现:



真正的架构能力,不是多写逻辑,而是让逻辑有边界。



JCasbin 给了我们一个极好的解法:

一个统一的决策引擎,让权限系统既灵活又有秩序。


它不是银弹,但能让你在权限处理上的代码更纯净、系统扩展性更好。


github.com/yuboon/java…


作者:风象南
来源:juejin.cn/post/7558094123812536361
收起阅读 »

当上组长一年里,我保住了俩下属

前言 人类的悲喜并不相通,有人欢喜有人愁,更多的是看热闹。 就在上周,"苟住"群里的一个小伙伴也苟不住了。 在苟友们的"墙裂"要求下,他分享了他的经验,以他的视角看看他是怎么操作的。 1. 组织变动,意外晋升 两年前加入公司,依然是一线搬砖的码农。 干到一...
继续阅读 »

前言


人类的悲喜并不相通,有人欢喜有人愁,更多的是看热闹。


就在上周,"苟住"群里的一个小伙伴也苟不住了。



image.png


在苟友们的"墙裂"要求下,他分享了他的经验,以他的视角看看他是怎么操作的。


1. 组织变动,意外晋升


两年前加入公司,依然是一线搬砖的码农。

干到一年的时候公司空降了一位号称有诸多大厂履历的大佬来带领研发,说是要给公司带来全新的变化,用技术创造价值。

大领导第一件事:抓人事,提效率。

在此背景下,公司不少有能力的研发另谋出处,也许我看起来人畜无害,居然被提拔当了小组长。


2. 领取任务,开启副本


当了半年的小组长,我的领导就叫他小领导吧,给我传达了大领导最新规划:团队需要保持冲劲,而实现的手段就是汰换。

用人话来说就是:



当季度KPI得E的人,让其填写绩效改进目标,若下一个季度再得到E,那么就得走人



我们绩效等级是ABCDE,A是传说中的等级,B是几个人有机会,大部分人是C和D,E是垫底。

而我们组就有两位小伙伴得到了E,分别是小A和小B。

小领导意思是让他们直接走得了,大不了再招人顶上,而我想着毕竟大家共事一场,现在大环境寒气满满,我也是过来人,还想再争取争取。

于是分析了他们的基本资料,他俩特点还比较鲜明。


小A资料:




  1. 96年,单身无房贷

  2. 技术栈较广,技术深度一般,比较粗心

  3. 坚持己见,沟通少,有些时候会按照自己的想法来实现功能



小B资料:




  1. 98年,热恋有房贷

  2. 技术基础较薄弱,但胜在比较认真

  3. 容易犯一些技术理解上的问题



了解了小A和小B的历史与现状后,我分别找他们沟通,主要是统一共识:




  1. 你是否认可本次绩效评估结果?

  2. 你是否认可绩效改进的点与风险点(未达成被裁)?

  3. 你是否还愿意在这家公司苟?



最重要是第三点,开诚布公,若是都不想苟了,那就保持现状,不要浪费大家时间,我也不想做无用功。

对于他们,分别做了提升策略:


对于小A:




  1. 每次开启需求前都要求其认真阅读文档,不清楚的地方一定要做记录并向相关人确认

  2. 遇到比较复杂的需求,我也会一起参与其中梳理技术方案

  3. 需求开发完成后,CR代码看是否与技术方案设计一致,若有出入需要记录下来,后续复盘为什么

  4. 给足时间,保证充分自测



对于小B:




  1. 每次需求多给点时间,多出的时间用来学习技术、熟悉技术

  2. 要求其将每个需求拆分为尽可能小的点,涉及到哪些技术要想清楚、弄明白

  3. 鼓励他不懂就要问,我也随时给他解答疑难问题,并说出一些原理让他感兴趣的话可以继续深究

  4. 分配给他一些技术调研类的任务,提升技术兴趣点与成就感



3. 结束?还是是另一个开始?


半年后...


好消息是:小A、小B的考核结果是D,达成了绩效改进的目标。

坏消息是:据说新的一轮考核算法会变化,宗旨是确保团队血液新鲜(每年至少得置换10%的人)。


随缘吧,我尽力了,也许下一个是我呢?



image-20250730002436026.png


作者:小鱼人爱编程
来源:juejin.cn/post/7532334931021824034
收起阅读 »

研发排查问题的利器:一款方法调用栈跟踪工具

导语 本文从日常值班问题排查痛点出发,分析方法复用的调用链路和上下文业务逻辑,通过思考分析,借助栈帧开发了一个方法调用栈的链式跟踪工具,便于展示一次请求的方法串行调用链,有助于快速定位代码来源和流量入口,有效提升研发和运维排查定位效率。期望在大家面临类似痛点...
继续阅读 »

导语



本文从日常值班问题排查痛点出发,分析方法复用的调用链路和上下文业务逻辑,通过思考分析,借助栈帧开发了一个方法调用栈的链式跟踪工具,便于展示一次请求的方法串行调用链,有助于快速定位代码来源和流量入口,有效提升研发和运维排查定位效率。期望在大家面临类似痛点时可以提供一些实践经验和参考,也欢迎大家合适的场景下接入使用。






现状分析


在系统值班时,经常会有人拿着报错截图前来咨询,作为值班研发,我们则需要获取尽可能多的信息,帮助我们分析报错场景,便于排查识别问题。


例如,下图就是一个常见的的报错信息截图示例。


从图中,我们可以初步获取到一些信息:


•菜单名称:变更单下架,我们这是变更单下架操作时的一个报错提醒。


•报错信息:序列号状态为离库状态,请检查。


•其他辅助信息:例如用户扫描或输入的86开头编码,SKU、商品名称、储位等。


这时会有一些常见的排查思路:


1、根据提示,将用户输入的86编码,按照提示文案去检查用户数据,即作为序列号编码,去看一下序列号是否存在,是否真的是离库了。


2、如果86编码确实是序列号,而且真的是离库了,那么基本上可以快速结案了,这个86编码确实是序列号并且是已离库,正如提示文案所示,这时跟提问人做好解释答疑即可。


3、如果第2步排查完,发现86编码不是序列号编码,或并非离库状态,即与提示文案不符,这时就要定位报错文案的代码来源,继续查看代码逻辑来进行判案了。(这种也比较常见,一种是报错场景较多,但提示文案比较单一,不便于在提示文案中覆盖所有报错场景;另一种提示文案陈旧未跟随需求演变而更新。这两点可以通过细分场景细化对应的报错文案,或更新报错文案,使得报错文案更优更新,但不是本文讨论的重点。)


4、如何根据报错文案快速找到代码来源呢?一般我们会在代码库中搜索提示文案,或者在日志中检索报错信息,辅助定位代码来源,后者依赖于代码中打印了该报错信息,且日志级别配置能够确保该信息打印到日志文件中。


5、倘若我们根据提示文案搜索代码时,发现该提示文案有多处代码出现,此时就较为复杂了,我们需要进一步识别,哪个才与本次报错直接有关。












每个方法向上追溯,又发现调用来源众多:





























在业务复杂的系统中,方法复用比较常见,不同的上下文和参数传递,也有着不同的业务逻辑判断和走向。


这时,基本上进入到本文要讨论的痛点:如何根据有限的提示信息快速定位代码来源?以便于分析报错业务场景,答疑解惑或快速处理问题。


屏幕前的小伙伴,如果你也经常值班排查问题,应该也会有类似的痛点所在。


启发


这是我想到了Exception异常机制,作为一名Coder,我们对异常堆栈再熟悉不过了,异常堆栈是一个“可爱”又“可恨”的东西,“可爱”在于异常堆栈确实可以帮助我们快速定位问题所在,“可恨”在于有异常基本上就是有问题,堆栈让我们审美疲劳,累觉不爱。


下面是一个Java语言的异常堆栈信息示例:












异常类体系和异常处理机制在本文中不是重点,不做过多赘述,本文重点希望能从异常堆栈中获取一些启发。


让我们近距离再观察一下我们的老朋友。


在异常堆栈信息中,主要有四类信息:


•全限定类名


•方法名


•文件名


•代码行号


这四类信息可以帮助我们有效定位代码来源,而且堆栈中记录行先后顺序,也表示着异常发生的第一现场、第二现场、第三现场、……,以此传递。


这让我想起了JVM方法栈中的栈帧。


每当一个方法被调用时,JVM会为该方法创建一个新的栈帧,并将其压入当前线程的栈(也称为调用栈或执行栈)中。栈帧包含了方法执行所需的所有信息,包括局部变量、操作数栈、常量池引用等。












思路


从Java中的Throwable中,可以看到staceTrace的get和set,这个StackTraceElement数组里面存放的信息就是我们在异常堆栈中经常看到的信息。
































再次放一下这张图,方便对照着看。












StackTraceElement类的注释中赫然写着:



StackTraceElement represents a stack frame.



对,StackTraceElement代表着一个栈帧。


这个StackTraceElement就是我要找的东西,即使非异常情况下,每个线程在执行方法调用时都会记录栈帧信息。












按照方法调用先后顺序,将调用栈中方法依次串联起来,就像糖葫芦一样,就可以得到我想要的方法调用链了。


NEXT,我可以动工写个工具了。


工具开发


工具的核心代码并不复杂,StackTraceElement 也是 Java JDK 中现成的,我所做的工作主要是从中过滤出必要的信息,加工简化成,按照顺序整理成链式信息,方便我们一眼就可以看出来方法的调用链。












入参介绍


pretty: 表示是只拼接类和方法,不拼接文件名和行号,非 pretty 是四个都会拼接。


simple: 表示会过滤一些我们代码中场景的代理增强出来的方法的信息输出。


specifiedPrefix: 指定保留相应的包路径堆栈信息,去掉一些过多的中间件信息。


其他还会过滤一些常见代理的堆栈信息:


•FastClassBySpringCGLIB


•EnhancerBySpringCGLIB


•lambda$


•Aspect


•Interceptor


对此,还封装了一些默认参数的方法,使用起来更为方便。












还有一些其他工具方法也可以使用:












使用效果


1、不过滤中间件、代理增强方法的调用栈信息



Thread#run ==> ThreadPoolExecutorWorker#run ==> ThreadPoolExecutor#runWorker ==> BaseTask#run ==> JSFTask#doRun ==> ProviderProxyInvoker#invoke ==> FilterChain#invoke ==> SystemTimeCheckFilter#invoke ==> ProviderExceptionFilter#invoke ==> ProviderContextFilter#invoke ==> InstMethodsInter#intercept ==> ProviderContextFiltereoneauxiliaryauxiliary9f9kd21#call ==> ProviderContextFilter#eoneoriginaloriginalinvokep882ot3p882ot3accessoreoneeonepclcbe2 ==> ProviderContextFilter#eoneoriginaloriginalinvokep882ot3 ==> ProviderGenericFilter#invoke ==> ProviderUnitValidationFilter#invoke ==> ProviderHttpGWFilter#invoke ==> ProviderInvokeLimitFilter#invoke ==> ProviderMethodCheckFilter#invoke ==> ProviderTimeoutFilter#invoke ==> ValidationFilter#invoke ==> ProviderConcurrentsFilter#invoke ==> ProviderSecurityFilter#invoke ==> WmsRpcExceptionFilter#invoke ==> WmsRpcExceptionFilter#invoke4provider ==> AdmissionControlJsfFilter#invoke ==> AdmissionControlJsfFilter#providerSide ==> AdmissionControlJsfFilter#processRequest ==> ChainedDeadlineJsfFilter#invoke ==> ChainedDeadlineJsfFilter#providerSide ==> JsfPerformanceMonitor#invoke ==> AbstractMiddlewarePerformanceMonitor#doExecute ==> PerformanceMonitorTemplateComposite#execute ==> PerformanceMonitorTemplateComposite#lambdaexecute0 ==> PerformanceMonitorTemplateUmp#execute ==> PerformanceMonitorTemplateComposite#lambdaexecute0 ==> PerformanceMonitorTemplatePayload#execute ==> JsfPerformanceMonitor#lambdainvoke0 ==> JsfPerformanceMonitor#doInvoke ==> ProviderInvokeFilter#invoke ==> ProviderInvokeFilter#reflectInvoke ==> Method#invoke ==> DelegatingMethodAccessorImpl#invoke ==> GeneratedMethodAccessor1704#invoke ==> CglibAopProxyDynamicAdvisedInterceptor#intercept ==> CglibAopProxyCglibMethodInvocation#proceed ==> ReflectiveMethodInvocation#proceed ==> ExposeInvocationInterceptor#invoke ==> CglibAopProxyCglibMethodInvocation#proceed ==> ReflectiveMethodInvocation#proceed ==> AspectJAroundAdvice#invoke ==> AbstractAspectJAdvice#invokeAdviceMethod ==> AbstractAspectJAdvice#invokeAdviceMethodWithGivenArgs ==> Method#invoke ==> DelegatingMethodAccessorImpl#invoke ==> GeneratedMethodAccessor344#invoke ==> MethodInvocationProceedingJoinPoint#proceed ==> CglibAopProxyCglibMethodInvocation#proceed ==> ReflectiveMethodInvocation#proceed ==> AspectJAroundAdvice#invoke ==> AbstractAspectJAdvice#invokeAdviceMethod ==> AbstractAspectJAdvice#invokeAdviceMethodWithGivenArgs ==> Method#invoke ==> DelegatingMethodAccessorImpl#invoke ==> GeneratedMethodAccessor1276#invoke ==> MethodInvocationProceedingJoinPoint#proceed ==> CglibAopProxyCglibMethodInvocation#proceed ==> ReflectiveMethodInvocation#proceed ==> AspectJAroundAdvice#invoke ==> AbstractAspectJAdvice#invokeAdviceMethod ==> AbstractAspectJAdvice#invokeAdviceMethodWithGivenArgs ==> Method#invoke ==> DelegatingMethodAccessorImpl#invoke ==> GeneratedMethodAccessor868#invoke ==> MethodInvocationProceedingJoinPoint#proceed ==> CglibAopProxyCglibMethodInvocation#proceed ==> ReflectiveMethodInvocation#proceed ==> AspectJAroundAdvice#invoke ==> AbstractAspectJAdvice#invokeAdviceMethod ==> AbstractAspectJAdvice#invokeAdviceMethodWithGivenArgs ==> Method#invoke ==> DelegatingMethodAccessorImpl#invoke ==> GeneratedMethodAccessor869#invoke ==> MethodInvocationProceedingJoinPoint#proceed ==> CglibAopProxyCglibMethodInvocation#proceed ==> ReflectiveMethodInvocation#proceed ==> AspectJAroundAdvice#invoke ==> AbstractAspectJAdvice#invokeAdviceMethod ==> AbstractAspectJAdvice#invokeAdviceMethodWithGivenArgs ==> Method#invoke ==> DelegatingMethodAccessorImpl#invoke ==> GeneratedMethodAccessor1642#invoke ==> MagicAspect#magic ==> MethodInvocationProceedingJoinPoint#proceed ==> CglibAopProxyCglibMethodInvocation#proceed ==> ReflectiveMethodInvocation#proceed ==> CglibAopProxyCglibMethodInvocation#invokeJoinpoint ==> MethodProxy#invoke ==> CglibAopProxyDynamicAdvisedInterceptor#intercept ==> CglibAopProxyCglibMethodInvocation#proceed ==> ReflectiveMethodInvocation#proceed ==> ExposeInvocationInterceptor#invoke ==> CglibAopProxyCglibMethodInvocation#proceed ==> ReflectiveMethodInvocation#proceed ==> AspectJAroundAdvice#invoke ==> AbstractAspectJAdvice#invokeAdviceMethod ==> AbstractAspectJAdvice#invokeAdviceMethodWithGivenArgs ==> Method#invoke ==> DelegatingMethodAccessorImpl#invoke ==> GeneratedMethodAccessor868#invoke ==> MethodInvocationProceedingJoinPoint#proceed ==> CglibAopProxyCglibMethodInvocation#proceed ==> ReflectiveMethodInvocation#proceed ==> AspectJAroundAdvice#invoke ==> AbstractAspectJAdvice#invokeAdviceMethod ==> AbstractAspectJAdvice#invokeAdviceMethodWithGivenArgs ==> Method#invoke ==> DelegatingMethodAccessorImpl#invoke ==> GeneratedMethodAccessor869#invoke ==> MethodInvocationProceedingJoinPoint#proceed ==> CglibAopProxyCglibMethodInvocation#proceed ==> ReflectiveMethodInvocation#proceed ==> CglibAopProxyCglibMethodInvocation#invokeJoinpoint ==> MethodProxy#invoke ==> CglibAopProxyDynamicAdvisedInterceptor#intercept ==> CglibAopProxyCglibMethodInvocation#proceed ==> ReflectiveMethodInvocation#proceed ==> AspectJAroundAdvice#invoke ==> AbstractAspectJAdvice#invokeAdviceMethod ==> AbstractAspectJAdvice#invokeAdviceMethodWithGivenArgs ==> Method#invoke ==> DelegatingMethodAccessorImpl#invoke ==> GeneratedMethodAccessor1295#invoke ==> MethodInvocationProceedingJoinPoint#proceed ==> CglibAopProxyCglibMethodInvocation#proceed ==> ReflectiveMethodInvocation#proceed ==> ExposeInvocationInterceptor#invoke ==> CglibAopProxyCglibMethodInvocation#proceed ==> ReflectiveMethodInvocation#proceed ==> AspectJAroundAdvice#invoke ==> AbstractAspectJAdvice#invokeAdviceMethod ==> AbstractAspectJAdvice#invokeAdviceMethodWithGivenArgs ==> Method#invoke ==> DelegatingMethodAccessorImpl#invoke ==> GeneratedMethodAccessor868#invoke ==> MethodInvocationProceedingJoinPoint#proceed ==> CglibAopProxyCglibMethodInvocation#proceed ==> ReflectiveMethodInvocation#proceed ==> CglibAopProxyCglibMethodInvocation#invokeJoinpoint ==> MethodProxy#invoke ==> CglibAopProxyDynamicAdvisedInterceptor#intercept ==> CglibAopProxyCglibMethodInvocation#proceed ==> ReflectiveMethodInvocation#proceed ==> ExposeInvocationInterceptor#invoke ==> CglibAopProxyCglibMethodInvocation#proceed ==> ReflectiveMethodInvocation#proceed ==> CglibAopProxyCglibMethodInvocation#invokeJoinpoint ==> MethodProxy#invoke ==> CglibAopProxyDynamicAdvisedInterceptor#intercept ==> CglibAopProxyCglibMethodInvocation#proceed ==> ReflectiveMethodInvocation#proceed ==> AnnotationAwareRetryOperationsInterceptor#invoke ==> RetryOperationsInterceptor#invoke ==> RetryTemplate#execute ==> RetryTemplate#doExecute ==> RetryOperationsInterceptor1#doWithRetry ==> CglibAopProxyCglibMethodInvocation#proceed ==> ReflectiveMethodInvocation#proceed ==> CglibAopProxyCglibMethodInvocation#invokeJoinpoint ==> MethodProxy#invoke ==> CglibAopProxyDynamicAdvisedInterceptor#intercept ==> CglibAopProxyCglibMethodInvocation#proceed ==> ReflectiveMethodInvocation#proceed ==> ExposeInvocationInterceptor#invoke ==> CglibAopProxyCglibMethodInvocation#proceed ==> ReflectiveMethodInvocation#proceed ==> AspectJAroundAdvice#invoke ==> AbstractAspectJAdvice#invokeAdviceMethod ==> AbstractAspectJAdvice#invokeAdviceMethodWithGivenArgs ==> Method#invoke ==> DelegatingMethodAccessorImpl#invoke ==> GeneratedMethodAccessor1276#invoke ==> MethodInvocationProceedingJoinPoint#proceed ==> CglibAopProxyCglibMethodInvocation#proceed ==> ReflectiveMethodInvocation#proceed ==> TransactionInterceptor#invoke ==> TransactionAspectSupport#invokeWithinTransaction ==> CglibAopProxyCglibMethodInvocation#proceed ==> ReflectiveMethodInvocation#proceed ==> AspectJAroundAdvice#invoke ==> AbstractAspectJAdvice#invokeAdviceMethod ==> AbstractAspectJAdvice#invokeAdviceMethodWithGivenArgs ==> Method#invoke ==> DelegatingMethodAccessorImpl#invoke ==> GeneratedMethodAccessor869#invoke ==> MethodInvocationProceedingJoinPoint#proceed ==> CglibAopProxyCglibMethodInvocation#proceed ==> ReflectiveMethodInvocation#proceed ==> PersistenceExceptionTranslationInterceptor#invoke ==> CglibAopProxyCglibMethodInvocation#proceed ==> ReflectiveMethodInvocation#proceed ==> CglibAopProxy$CglibMethodInvocation#invokeJoinpoint ==> MethodProxy#invoke ==> StackTraceUtils#trace



2、指定包路径过滤中间件后的调用栈栈信息



LockAspect#lock ==> StockTransferAppServiceImpl#increaseStock ==> MonitorAspect#monitor ==> StockRetryExecutor#operateStock ==> StockRetryExecutorFastClassBySpringCGLIBFastClassBySpringCGLIB5188d6e#invoke ==> BaseStockOperationFastClassBySpringCGLIBFastClassBySpringCGLIB9d76cd9a#invoke ==> StockTransferServiceImplFastClassBySpringCGLIBFastClassBySpringCGLIB85bb181e#invoke ==> ValidationAspect#logAndReturn ==> LogAspect#log ==> ThreadLocalRemovalAspect#removal ==> ValidationAspect#validate ==> BaseStockOperation#go ==> StockRepositoryImplEnhancerBySpringCGLIBEnhancerBySpringCGLIB1388ef12#operateStock ==> StockTransferAppServiceImplEnhancerBySpringCGLIBEnhancerBySpringCGLIB1095eafa#increaseStock ==> StockRepositoryImplFastClassBySpringCGLIBFastClassBySpringCGLIBa1b4dae4#invoke ==> StockTransferServiceImpl#increaseStock ==> DataBaseExecutor#execute ==> StockRetryExecutorEnhancerBySpringCGLIBEnhancerBySpringCGLIBb42789a#operateStock ==> StockInitializerEnhancerBySpringCGLIBEnhancerBySpringCGLIB85faf510#go ==> StockTransferServiceImplEnhancerBySpringCGLIBEnhancerBySpringCGLIBafc21975#increaseStock ==> StockRepositoryImpl#operateStock ==> DataBaseExecutor#operate ==> StockTransferAppServiceImplFastClassBySpringCGLIBFastClassBySpringCGLIBe348d8e1#invoke



3、去掉Spring代理增强之后的调用栈信息



LogAspect#log ==> LockAspect#lock ==> ValidationAspect#validate ==> ValidationAspect#logAndReturn ==> MonitorAspect#monitor ==> StockTransferAppServiceImpl#decreaseStock ==> ThreadLocalRemovalAspect#removal ==> StockTransferServiceImpl#decreaseStock ==> StockOperationLoader#go ==> BaseStockOperation#go ==> DataBaseExecutor#operate ==> DataBaseExecutor#execute ==> StockRetryExecutor#operateStock ==> StockRepositoryImpl#operateStock



4、去掉一些自定义代理之后的调用栈栈信息



StockTransferAppServiceImpl#increaseStock ==> StockTransferServiceImpl#increaseStock ==> BaseStockOperation#go ==> DataBaseExecutor#operate ==> DataBaseExecutor#execute ==> StockRetryExecutor#operateStock ==> StockRepositoryImpl#operateStock



5、如果带上文件名和行号后的调用栈栈信息



StockTransferAppServiceImpl#increaseStock(StockTransferAppServiceImpl.java:103) ==> StockTransferServiceImpl#increaseStock(StockTransferServiceImpl.java:168) ==> BaseStockOperation#go(BaseStockOperation.java:152) ==> BaseStockOperation#go(BaseStockOperation.java:181) ==> BaseStockOperation#go(BaseStockOperation.java:172) ==> DataBaseExecutor#operate(DataBaseExecutor.java:34) ==> DataBaseExecutor#operate(DataBaseExecutor.java:64) ==> DataBaseExecutor#execute(DataBaseExecutor.java:79) ==> StockRetryExecutor#operateStock(StockRetryExecutor.java:64) ==> StockRepositoryImpl#operateStock(StockRepositoryImpl.java:303)






线上应用实践









接入方法调用栈跟踪工具后,根据报错提示词,可以检索到对应日志,从 ImmediateTransferController#offShelf ==> AopConfig#pointApiExpression ==> TransferOffShelfAppServiceImpl#offShelf ==> TransferOffShelfAppServiceImpl#doOffShelf 中顺藤摸瓜可以快速找到流量入口的代码位置。

























适用场景


该方法调用栈工具类,可以在一些堆栈信息进行辅助排查分析的地方进行预埋,例如:


•业务异常时输出堆栈到日志信息中。


•业务监控告警信息中加入调用栈信息。


•一些复用方法调用复杂场景下,打印调用栈信息,展示调用链,方便分析。


•其他一些场景等。


延伸


在《如何一眼定位SQL的代码来源:一款SQL染色标记的简易MyBatis插件》一文中,我发布了一款SQL染色插件,该插件目前已有statementId信息,还支持通过SQLMarkingThreadLocal传递自定义附加信息。其他BGBU的技术小伙伴,也有呼声,希望在statementId基础上可以继续追溯入口方法。通过本文引入的方法调用栈跟踪工具,我在SQL染色插件中增加了方法调用栈染色信息。


SQL染色工具新版特性,欢迎大家先在TEST和UAT环境尝鲜试用,TEST和UAT环境验证没问题后,再逐步推广正式环境。


升级方法:


1、sword-mybatis-plugins版本升级至1.0.8-SNAPSHOT。


2、同时新引入本文的工具依赖


<!-- http://sd.jd.com/article/45616?shareId=105168&isHideShareButton=1 -->
<dependency>
<groupId>com.jd.sword</groupId>
<artifactId>sword-utils-common</artifactId>
<version>1.0.3-SNAPSHOT</version>
</dependency>

3、mybatis config xml 配置文件按最新配置调整


<!-- http://sd.jd.com/article/42942?shareId=105168&isHideShareButton=1 -->
<!-- SQLMarking Plugin,放在第一个Plugin的位置,不影响其他组件,但不强要求位置,也可以灵活调整顺序位置 -->
<plugin interceptor="com.jd.sword.mybatis.plugin.sql.SQLMarkingInterceptor">
<!-- 是否开启SQL染色标记插件 -->
<property name="enabled" value="true"/>
<!-- 是否开启方法调用栈跟踪 -->
<property name="stackTraceEnabled" value="true"/>
<!-- 指定需要方法调用栈跟踪的package,减少信息量,value配置为自己工程的package路径,多个路径用英文逗号分割 -->
<property name="specifiedStackTracePackages" value="com.jdwl.wms.stock"/>
<!-- 忽略而不进行方法堆栈跟踪的类名列表,多个用英文逗号分割,减少信息量 -->
<property name="ignoredStackTraceClassNames" value=""/>
<!-- 结合CPU利用率和性能考虑,方法调用栈跟踪采集率配置采集率,配置示例: m/n,表示n个里面抽m个进行采集跟踪 -->
<!-- 预发环境和测试环境可以配置全采集,例如配置1/1,生产环境可以结合CPU利用率和性能考虑按需配置采集率 -->
<property name="stackTraceSamplingRate" value="1/2"/>
<!-- 是否允许SQL染色标记作为前缀,默认false表示仅作为后缀 -->
<property name="startsWithMarkingAllowed" value="false"/>
<!-- 方法调用栈跟踪最大深度,减少信息量 -->
<property name="maxStackDepth" value="10"/>
</plugin>




或代码配置方式


/**
* SQLMarking Plugin
* http://sd.jd.com/article/42942?shareId=105168&isHideShareButton=1
*
* @return
*/

@Bean
public SQLMarkingInterceptor sQLMarkingInterceptor() {
SQLMarkingInterceptor sQLMarkingInterceptor = new SQLMarkingInterceptor();
Properties properties = new Properties();
// 是否开启SQL染色标记插件
properties.setProperty("enabled", "true");
// 是否开启方法调用栈跟踪
properties.setProperty("stackTraceEnabled", "true");
// 指定需要方法调用栈跟踪的package,减少信息量,value配置为自己工程的package路径,多个路径用英文逗号分割
properties.setProperty("specifiedStackTracePackages", "com.jdwl.wms.picking");
// 结合CPU利用率和性能考虑,方法调用栈跟踪采集率配置采集率,配置示例: m/n,表示n个里面抽m个进行采集跟踪
// 预发环境和测试环境可以配置全采集,例如配置1/1,生产环境可以结合CPU利用率和性能考虑按需配置采集率
properties.setProperty("stackTraceSamplingRate", "1/2");
// 是否允许SQL染色标记作为前缀,默认false表示仅作为后缀
properties.setProperty("startsWithMarkingAllowed", "false");
sQLMarkingInterceptor.setProperties(properties);
return sQLMarkingInterceptor;
}

接入效果


SELECT
id,
tenant_code,
warehouse_no,
sku,
location_no,
container_level_1,
container_level_2,
lot_no,
sku_level,
owner_no,
pack_code,
conversion_rate,
stock_qty,
prepicked_qty,
premoved_qty,
frozen_qty,
diff_qty,
broken_qty,
status,
md5_value,
version,
create_user,
update_user,
create_time,
update_time,
extend_content
FROM
st_stock
WHERE
deleted = 0
AND warehouse_no = ?
AND location_no IN(?)
AND container_level_1 IN(?)
AND container_level_2 IN(?)
AND sku IN(?)
/* [SQLMarking] statementId: com.jdwl.wms.stock.infrastructure.jdbc.main.dao.StockQueryDao.selectExtendedStockByLocation, stackTrace: BaseJmqConsumer#onMessage ==> StockInfoConsumer#handle ==> StockInfoConsumer#handleEvent ==> StockExtendContentFiller#fillExtendContent ==> StockInitializer#queryStockByWarehouse ==> StockInitializer#batchQueryStockByWarehouse ==> StockInitializer#queryByLocationAndSku ==> StockQueryRepositoryImpl#queryExtendedStockByLocationAndSku, warehouseNo: 6_6_601 */

如何接入本文工具?


如果小伙伴也有类似使用诉求,大家可以先在测试、UAT环境接入试用,然后再逐步推广线上生产环境。


1、新引入本文的工具依赖


<dependency>
<groupId>com.jd.sword</groupId>
<artifactId>sword-utils-common</artifactId>
<version>1.0.3-SNAPSHOT</version>
</dependency>

2、使用工具类静态方法


com.jd.sword.utils.common.runtime.StackTraceUtils#simpleTrace()

com.jd.sword.utils.common.runtime.StackTraceUtils#simpleTrace(java.lang.String...)

com.jd.sword.utils.common.runtime.StackTraceUtils#trace()

com.jd.sword.utils.common.runtime.StackTraceUtils#trace(java.lang.String...)

com.jd.sword.utils.common.runtime.StackTraceUtils#trace(boolean)

com.jd.sword.utils.common.runtime.StackTraceUtils#trace(boolean, boolean, java.lang.String...)

作者:京东云开发者
来源:juejin.cn/post/7565423807570952198
收起阅读 »

为什么 Electron 项目推荐使用 Monorepo 架构 🚀🚀🚀

web
最近在使用 NestJs 和 NextJs 在做一个协同文档 DocFlow,如果感兴趣,欢迎 star,有任何疑问,欢迎加我微信进行咨询 yunmz777在现代前端开发中,Monorepo(单一代码仓库)架构已经成为大型项目的首选方案。对...
继续阅读 »

最近在使用 NestJs 和 NextJs 在做一个协同文档 DocFlow,如果感兴趣,欢迎 star,有任何疑问,欢迎加我微信进行咨询 yunmz777

在现代前端开发中,Monorepo(单一代码仓库)架构已经成为大型项目的首选方案。对于Electron应用开发而言,Monorepo架构更是带来了诸多优势。本文将以一个实际的Electron项目为例,深入探讨为什么Electron项目强烈推荐使用Monorepo架构,以及它如何解决传统多仓库架构的痛点。

什么是Monorepo

Monorepo是一种软件开发策略,它将多个相关的项目或包存储在同一个代码仓库中。与传统的多仓库(Multi-repo)架构不同,Monorepo允许开发团队在单一代码库中管理多个相互依赖的模块。

Electron项目的复杂性分析

Electron应用通常包含以下核心组件:

  • 主进程(Main Process):负责创建和管理应用窗口
  • 渲染进程(Renderer Process):运行前端UI代码
  • 预加载脚本(Preload Scripts):安全地桥接主进程和渲染进程
  • 共享代码库:业务逻辑、工具函数、类型定义等
  • 构建配置:Webpack、Vite等构建工具配置
  • 打包配置:Electron Builder等打包工具配置

这种多层次的架构使得代码组织变得复杂,传统的多仓库架构往往无法很好地处理这些组件之间的依赖关系。

实际项目结构深度解析

让我们以您的项目为例,深入分析Monorepo架构的实际应用:

项目整体架构

electron-app/
├── apps/ # 应用层
│ ├── electron-app/ # Electron主应用
│ │ ├── src/
│ │ │ ├── main/ # 主进程代码
│ │ │ └── preload/ # 预加载脚本
│ │ ├── build/ # 构建配置
│ │ └── package.json # 应用依赖
│ └── react-app/ # React前端应用
│ ├── src/
│ │ ├── components/ # React组件
│ │ └── page/ # 页面组件
│ └── package.json # 前端依赖
├── packages/ # 共享包层
│ ├── electron-core/ # 核心业务逻辑
│ │ ├── src/
│ │ │ ├── base-app.ts # 基础应用类
│ │ │ ├── app-config.ts # 应用配置
│ │ │ ├── menu-config.ts # 菜单配置
│ │ │ └── ffmpeg-service.ts # FFmpeg服务
│ │ └── package.json
│ ├── electron-ipc/ # IPC通信封装
│ │ ├── src/
│ │ │ ├── ipc-handler.ts # IPC处理器
│ │ │ ├── ipc-channels.ts # IPC通道定义
│ │ │ └── ipc-config.ts # IPC配置
│ │ └── package.json
│ └── electron-window/ # 窗口管理
│ ├── src/
│ │ ├── window-manager.ts # 窗口管理器
│ │ └── window-factory.ts # 窗口工厂
│ └── package.json
├── scripts/ # 构建脚本
├── package.json # 根配置
├── pnpm-workspace.yaml # Workspace配置
├── turbo.json # Turbo构建配置
└── tsconfig.json # TypeScript配置

核心配置文件分析

1. pnpm-workspace.yaml - 工作空间配置

packages:
- 'apps/*'
- 'packages/electron-*'

这个配置定义了工作空间的范围,告诉pnpm哪些目录包含包。这种配置的优势:

  • 统一依赖管理:所有包共享同一个node_modules
  • 版本一致性:确保所有包使用相同版本的依赖
  • 安装效率:避免重复安装相同的依赖

2. turbo.json - 构建管道配置

{
"$schema": "https://turbo.build/schema.json",
"globalDependencies": ["/.env.*local"],
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/", "out/", "build/", ".next/"]
},
"dev": {
"cache": false,
"persistent": true
},
"lint": {
"dependsOn": []
},
"typecheck": {
"dependsOn": ["^build"]
},
"test": {
"dependsOn": ["^build"]
},
"clean": {
"cache": false
},
"format": {
"cache": false
}
}
}

这个配置定义了构建管道,实现了:

  • 依赖关系管理:dependsOn: ["^build"]确保依赖包先构建
  • 增量构建:只构建发生变化的包
  • 并行执行:多个独立任务可以并行运行
  • 缓存机制:避免重复构建

3. 根package.json - 统一脚本管理

{
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev",
"lint": "turbo run lint -- --fix",
"typecheck": "turbo run typecheck",
"electron:dev": "turbo run dev --filter=@monorepo/react-app && turbo run dev --filter=my-electron-app",
"electron:build": "turbo run build --filter=@monorepo/react-app && turbo run build --filter=my-electron-app"
}
}

Monorepo架构的六大核心优势

1. 统一的依赖管理

传统多仓库架构的问题:

  • 每个子项目都需要独立管理依赖
  • 容易出现版本不一致的问题
  • 重复安装相同的依赖,浪费磁盘空间

Monorepo解决方案:

在您的项目中,所有包都使用workspace:*协议引用内部依赖:

// apps/electron-app/package.json
{
"dependencies": {
"@monorepo/electron-core": "workspace:*",
"@monorepo/electron-window": "workspace:*",
"@monorepo/electron-ipc": "workspace:*"
}
}

这种配置的优势:

  • 版本一致性:所有包使用相同版本的内部依赖
  • 实时更新:修改共享包后,依赖包立即获得更新
  • 避免重复:pnpm的符号链接机制避免重复安装

2. 代码共享与复用

实际案例分析:

BaseApp基类的共享

// packages/electron-core/src/base-app.ts
export abstract class BaseApp {
protected config: AppConfig;

constructor(config: AppConfig) {
this.config = config;
}

abstract initialize(): void;

protected setupAppEvents(): void {
app.on('activate', () => {
if (this.shouldCreateWindow()) {
this.createWindow();
}
});

app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
}

protected abstract shouldCreateWindow(): boolean;
protected abstract createWindow(): void;
}

这个基类被多个应用共享,提供了:

  • 统一的生命周期管理:所有Electron应用都遵循相同的生命周期
  • 代码复用:避免在每个应用中重复实现相同的逻辑
  • 类型安全:通过抽象类确保所有子类实现必要的方法

IPC通信的封装

// packages/electron-ipc/src/ipc-handler.ts
export class ElectronIpcHandler implements IpcHandler {
setupHandlers(): void {
// Basic IPC handlers
ipcMain.on('ping', () => console.log('pong'));

// App info handlers
ipcMain.handle('get-app-version', () => {
return process.env.npm_package_version || '1.0.0';
});

ipcMain.handle('get-platform', () => {
return process.platform;
});

// System info handlers
ipcMain.handle('get-system-info', () => {
return {
platform: process.platform,
arch: process.arch,
version: process.version,
nodeVersion: process.versions.node,
electronVersion: process.versions.electron,
};
});
}
}

这个IPC处理器提供了:

  • 统一的通信接口:所有IPC通信都通过标准化的接口
  • 类型安全:通过TypeScript接口确保通信的类型安全
  • 可扩展性:易于添加新的IPC处理器

3. 原子性提交

传统多仓库架构的问题:

  • 跨仓库的修改需要分别提交
  • 容易出现不一致的状态
  • 难以追踪相关的修改

Monorepo解决方案:

在您的项目中,一次提交可以同时修改多个相关文件:

# 一次提交同时修改多个包
git add packages/electron-core/src/base-app.ts
git add packages/electron-ipc/src/ipc-handler.ts
git add apps/electron-app/src/main/index.ts
git commit -m "feat: 重构应用基类和IPC处理器"

这种提交方式的优势:

  • 原子性:相关修改作为一个整体提交
  • 一致性:确保所有相关文件的状态一致
  • 可追溯性:通过git历史可以追踪完整的修改过程

4. 统一的构建和测试

实际构建流程分析:

Turbo构建管道

{
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/", "out/", "build/", ".next/"]
}
}
}

这个配置实现了:

  • 依赖构建:^build确保依赖包先构建
  • 增量构建:只构建发生变化的包
  • 并行构建:多个独立包可以并行构建

实际构建命令

# 构建所有包
pnpm run build

# 只构建Electron应用
pnpm run electron:build

# 只构建React应用
pnpm run react:build

5. 更好的开发体验

一站式开发环境:

# 启动整个开发环境
pnpm run dev

# 启动Electron开发环境
pnpm run electron:dev

这种开发体验的优势:

  • 单一命令启动:一个命令启动整个开发环境
  • 热重载:修改代码后自动重新加载
  • 统一调试:可以在同一个IDE中调试所有代码

6. 类型安全

TypeScript项目引用:

// tsconfig.json
{
"compilerOptions": {
"composite": true,
"declaration": true,
"declarationMap": true
},
"references": [
{ "path": "./packages/electron-core" },
{ "path": "./packages/electron-ipc" },
{ "path": "./packages/electron-window" },
{ "path": "./apps/electron-app" },
{ "path": "./apps/react-app" }
]
}

这种配置实现了:

  • 增量编译:只编译发生变化的文件
  • 类型检查:确保所有包的类型定义一致
  • 智能提示:IDE可以提供完整的类型提示

实际开发流程分析

1. 新功能开发流程

假设要添加一个新的IPC处理器:

  1. 在共享包中定义接口:
// packages/electron-ipc/src/ipc-channels.ts
export const IPC_CHANNELS = {
// ... 现有通道
NEW_FEATURE: 'new-feature',
} as const;
  1. 实现处理器:
// packages/electron-ipc/src/ipc-handler.ts
ipcMain.handle(IPC_CHANNELS.NEW_FEATURE, () => {
// 实现逻辑
});
  1. 在应用中注册:
// apps/electron-app/src/main/index.ts
const ipcConfig = new IpcConfig();
ipcConfig.setupHandlers();
  1. 在前端中使用:
// apps/react-app/src/components/SomeComponent.tsx
const result = await window.electronAPI.invoke('new-feature');

2. 依赖更新流程

当需要更新共享包时:

  1. 修改共享包:
// packages/electron-core/src/base-app.ts
// 添加新功能
  1. 自动更新依赖: 由于使用workspace:*,所有依赖包自动获得更新
  2. 类型检查:
pnpm run typecheck
  1. 构建测试:
pnpm run build

性能优化分析

1. 构建性能

Turbo缓存机制:

  • 构建结果缓存到.turbo目录
  • 只有发生变化的包才会重新构建
  • 并行构建多个独立包

实际性能提升:

  • 首次构建:~30秒
  • 增量构建:~5秒
  • 缓存命中:~1秒

2. 开发性能

热重载优化:

  • 只重新加载发生变化的模块
  • 保持应用状态
  • 快速反馈循环

3. 安装性能

pnpm优势:

  • 符号链接避免重复安装
  • 全局缓存减少网络请求
  • 并行安装提高速度

最佳实践总结

1. 包划分原则

按功能模块划分:

  • electron-core:核心业务逻辑
  • electron-ipc:IPC通信
  • electron-window:窗口管理

避免过度拆分:

  • 不要为了拆分而拆分
  • 保持包的职责单一
  • 考虑包的维护成本

2. 依赖管理

使用workspace协议:

{
"dependencies": {
"@monorepo/electron-core": "workspace:*"
}
}

避免循环依赖:

  • 使用依赖图分析工具
  • 定期检查依赖关系
  • 重构消除循环依赖

3. 构建优化

利用Turbo缓存:

  • 合理设置outputs目录
  • 使用dependsOn管理依赖
  • 避免不必要的重新构建

4. 代码规范

统一配置:

  • ESLint配置统一管理
  • Prettier格式化统一
  • TypeScript配置统一

迁移策略

1. 评估现有项目

分析您当前的项目结构:

  • 识别可复用的代码
  • 分析依赖关系
  • 确定迁移优先级

2. 选择工具链

基于您的项目,推荐的工具链:

  • 包管理器:pnpm(已使用)
  • 构建工具:Turbo(已使用)
  • 类型检查:TypeScript(已使用)

3. 逐步迁移

第一阶段:迁移核心包

  • 将共享代码提取到packages目录
  • 设置workspace配置
  • 更新依赖引用

第二阶段:迁移应用

  • 重构应用代码使用共享包
  • 更新构建配置
  • 测试功能完整性

第三阶段:优化配置

  • 优化Turbo配置
  • 设置CI/CD流程
  • 性能调优

总结

Monorepo架构为Electron项目带来了显著优势:统一的依赖管理通过pnpm workspace实现版本一致性,代码共享与复用让BaseApp、IPC处理器等核心组件被多个应用共享,原子性提交确保相关修改作为一个整体提交,统一的构建和测试通过Turbo实现增量构建和并行执行,更好的开发体验提供一站式开发环境,类型安全通过TypeScript项目引用实现完整的类型检查。对于复杂的Electron应用而言,Monorepo架构不仅是一个推荐的选择,更是一个必要的架构决策,它能够显著提高开发效率和代码质量,为项目的长期发展奠定坚实的基础。


作者:Moment
来源:juejin.cn/post/7565204846044102671
收起阅读 »

Vue3.0父传子子传父的血和泪:一个菜鸟的踩坑实录

web
,没有声明 scope 参数,所以 scope 是 undefined。 解决方案 正确的写法应该是: <el-table-column label="操作" width="150"> <...
继续阅读 »

event loop 事件循环

web
什么是事件循环? 事件循环是 JavaScript 运行时的一个核心机制,它管理着代码的执行顺序。它是一种机制,用于处理异步操作,事件循环的核心是一个循环,它不断地检查调用栈和任务队列,以确保代码按照正确的顺序执行。 JavaScript 的单线程本质 Jav...
继续阅读 »

什么是事件循环?


事件循环是 JavaScript 运行时的一个核心机制,它管理着代码的执行顺序。它是一种机制,用于处理异步操作,事件循环的核心是一个循环,它不断地检查调用栈和任务队列,以确保代码按照正确的顺序执行。


JavaScript 的单线程本质


JavaScript 被设计为单线程语言,这意味着它只有一个调用栈,一次只能执行一段代码。这听起来像是一个限制,但正是这种简单性让 JavaScript 如此易于使用。


console.log('开始'); // 1

setTimeout(() => {
console.log('定时器回调'); // 3
}, 1000);

console.log('结束'); // 2

// 输出顺序:
// 开始
// 结束
// 定时器回调

事件循环的组成部分


1. 调用栈(Call Stack)


调用栈是 JavaScript 执行代码的地方。当函数被调用时,它会被推入栈顶;当函数返回时,它会从栈顶弹出。


function first() {
console.log('第一个函数');
second();
}

function second() {
console.log('第二个函数');
}

first();

2. 任务队列(Task Queue)


任务队列(也称为宏任务队列)存储着待处理的任务,如:



  • setTimeoutsetInterval 回调

  • I/O 操作

  • UI 渲染

  • 事件处理程序


3. 微任务队列(Microtask Queue)


微任务队列具有更高的优先级,包括:



  • Promise 回调(.then(), .catch(), .finally()

  • queueMicrotask()

  • MutationObserver


事件循环的工作流程


事件循环遵循一个简单的循环:



  1. 执行调用栈中的同步代码

  2. 当调用栈为空时,检查微任务队列

  3. 执行所有微任务(直到微任务队列为空)

  4. 检查宏任务队列,执行一个宏任务

  5. 重复步骤 2-4


console.log('脚本开始'); // 同步代码

setTimeout(() => {
console.log('setTimeout'); // 宏任务
}, 0);

Promise.resolve()
.then(() => {
console.log('Promise 1'); // 微任务
})
.then(() => {
console.log('Promise 2'); // 微任务
});

console.log('脚本结束'); // 同步代码

// 输出顺序:
// 脚本开始
// 脚本结束
// Promise 1
// Promise 2
// setTimeout

实际应用示例


场景 1:用户交互与数据获取


// 模拟用户点击和API调用
document.getElementById('button').addEventListener('click', () => {
console.log('点击事件处理'); // 宏任务

// 微任务优先于渲染
Promise.resolve().then(() => {
console.log('Promise 在点击中');
});

// 模拟API调用
fetch('/api/data')
.then(response => response.json())
.then(data => {
console.log('获取到的数据:', data); // 微任务
});
});

console.log('脚本加载完成');

场景 2:动画性能优化


// 不推荐的写法 - 可能阻塞渲染
function processHeavyData() {
const data = Array.from({length: 100000}, (_, i) => i);
return data.map(x => Math.sqrt(x)).filter(x => x > 10);
}

// 推荐的写法 - 使用事件循环分块处理
function processInChunks(data, chunkSize = 1000) {
let index = 0;

function processChunk() {
const chunk = data.slice(index, index + chunkSize);

// 处理当前块
chunk.forEach(item => {
// 处理逻辑
});

index += chunkSize;

if (index < data.length) {
// 使用 setTimeout 让出控制权,允许渲染
setTimeout(processChunk, 0);
}
}

processChunk();
}

常见陷阱与最佳实践


陷阱 1:阻塞事件循环


// ❌ 避免 - 长时间运行的同步操作
function blockingOperation() {
const start = Date.now();
while (Date.now() - start < 5000) {
// 阻塞5秒
}
console.log('操作完成');
}

// ✅ 推荐 - 使用异步操作
async function nonBlockingOperation() {
await new Promise(resolve => setTimeout(resolve, 5000));
console.log('操作完成');
}

陷阱 2:微任务递归


// ❌ 可能导致微任务无限循环
function dangerousRecursion() {
Promise.resolve().then(dangerousRecursion);
}

// ✅ 使用 setImmediate 或 setTimeout 打破循环
function safeRecursion() {
Promise.resolve().then(() => {
setTimeout(safeRecursion, 0);
});
}

现代 JavaScript 中的事件循环


async/await 与事件循环


async function asyncExample() {
console.log('开始 async 函数');

await Promise.resolve();
console.log('在 await 之后'); // 微任务

const result = await fetch('/api/data');
console.log('数据获取完成'); // 微任务
}

console.log('脚本开始');
asyncExample();
console.log('脚本结束');

// 输出顺序:
// 脚本开始
// 开始 async 函数
// 脚本结束
// 在 await 之后
// 数据获取完成

调试技巧


1. 使用 console 理解执行顺序


console.log('同步 1');

setTimeout(() => console.log('宏任务 1'), 0);

Promise.resolve()
.then(() => console.log('微任务 1'))
.then(() => console.log('微任务 2'));

queueMicrotask(() => console.log('微任务 3'));

console.log('同步 2');

2. 性能监控


// 测量任务执行时间
const startTime = performance.now();

setTimeout(() => {
const endTime = performance.now();
console.log(`任务执行耗时: ${endTime - startTime}ms`);
}, 0);

执行顺序问题


网上很经典的面试题


async function async1 () {
console.log('async1 start')
await async2()
console.log('async1 end')
}

async function async2 () {
console.log('async2')
}

console.log('script start')

setTimeout(function () {
console.log('setTimeout')
}, 0)

async1()

new Promise (function (resolve) {
console.log('promise1')
resolve()
}).then (function () {
console.log('promise2')
})

console.log('script end')


输出结果


script start
async1 start
async2
promise1
script end
async1 end
promise2
setTimeout

总结


理解 JavaScript 事件循环对于编写高效、响应迅速的应用程序至关重要。记住这些关键点:



  • 同步代码首先执行

  • 微任务在同步代码之后、渲染之前执行

  • 宏任务在微任务之后执行

  • 避免阻塞主线程

  • 合理使用微任务和宏任务


掌握事件循环机制将帮助你写出更好的异步代码,避免常见的性能问题,并创建更流畅的用户体验。


希望这篇博客能帮助你更好地理解 JavaScript 的事件循环机制!如果你有任何问题或想法,欢迎在评论区讨论。


作者:读忆
来源:juejin.cn/post/7565766784159776809
收起阅读 »

再说一遍!不要封装组件库!

最近公司里事儿比较多,项目也比较杂,但是因为公司的项目主要是聚焦OA方面,很多东西可以复用。 比方说:表单、表格、搜索栏等等,这部分现阶段大部分都是各写各的,每个项目因为主要的开发不同,各自维护自己的一份。 但是领导现在觉得还是维护一套组件库来的比较方便,一...
继续阅读 »

最近公司里事儿比较多,项目也比较杂,但是因为公司的项目主要是聚焦OA方面,很多东西可以复用。


比方说:表单、表格、搜索栏等等,这部分现阶段大部分都是各写各的,每个项目因为主要的开发不同,各自维护自己的一份。


image.png


但是领导现在觉得还是维护一套组件库来的比较方便,一来是减少重复工作量,提升开发效率,二来是方便新人加入团队以后尽量与老成员开发风格保持一致。


另外还有一个原因是项目内现在有的用AntDesign,有的用ElmentPlus,这些库的样式和UI设计出来的风格不搭,改起来也非常麻烦。


我听见这个提议以后后背冷汗都下来了。


我再跟大家强调一遍,不要封装组件库!


咱们说说为什么:


抬高开发成本


大部分人都感觉封装组件库是降低了开发成本,但实际上大部分项目并非如此,封装组件库大部分时候都是抬高了开发成本。


项目不同,面对的客户不同,需求也就不同,所以无论是客户方的需求还是UI设计稿都存在一定的差别,有些时候差距很大。


针对项目单独进行开发虽然在表面上看起来是浪费了人力资源,重复了很多工作,但是在后期开发和维护过程中会节省非常多的时间。


这部分都是成本。很多时候组件的开发并不是面对产品或者团队的,而是面向项目和客户的。


这也就导致了组件的开发会存在极大的不确定性,一方面是需求的不确定,另一方面是组件灵活度的不确定。


很多时候开发出来的组件库会衍生出N多个版本,切出N多个分支,最后在各个项目中引用,逐渐变成一个臃肿的垃圾代码集合体。


我不相信有人会在自己的项目上改完以后,还把修改的部分根据他人的反馈再进行调整,最后合并到 master 分支上去。


我从未见过有这个操作的兄弟。


技术达不到封装水平


团队内部技术不在一个水平线上,事实上也不可能在一个水平线上。


有些人的技术好,封装出来的组件确实很契合大多数的业务场景,有些人技术稍逊,封装出来的组件就不一定能契合项目。


但是如果你用他人封装的组件,牵扯到定制化需求的时候势必会改造,这时候改造就有可能会影响其他项目。


尤其一种情况,老项目升级,这是组件库最容易出问题的时候。可能上个版本封版的组件库在老项目运行的非常完美,但是需要升级的项目引用新的组件库的时候就会出现很多问题。


大部分程序员其实都达不到封装组件库的水准。


如果想要试一试可以参考ElmentUI老版本代码,自己封装一下Select、Input、Button这几个组件,看看和这些久经考验的开源组件库编码程序员还差多少。


技术负债严重


承接上一个问题,不是团队内每个人的水平都一样,并且每个人的编码风格也都是不一样的。(Ts最大的作用点)


可能组件库建立初期会节省非常多的重复工作,毕竟拿来就用,而且本身就是封装好的,简直可以为自己鼓掌了。


照着镜子问这是哪个天才编写的组件库,简直不要太棒了。


但是随着时间的推移,你会发现这个组件库越来越难用,需要考虑的方面越来越多,受影响的模块越来越多,你会变得越来越不敢动其中的代码。


项目越来越多,组件库中的分支和版本越来越多,团队中的人有些已经离开,有些人刚来,这时候技术负债就已经形成了。


更不要说大部分人没有写技术文档的习惯,甚至是连写注释的习惯都没有,功能全靠看代码和猜,技术上的负债越来越严重,这个阶段组件库离崩塌就已经不远了。


新项目在立项之初你就会本能的排斥使用组件库,但是对于老项目呢?改是不可能改动的,但是不维护Bug又挂在这儿。


那你到底是选择代码能跑,还是选择...


image.png


对个人发展不利


有些兄弟觉得能封装组件库,让自己的代码在这个团队,这个公司永远的流传下去,简直是跟青史留名差不多了。以后谁用到这个组件都会看到author后面写着我的名字。


但事实并非如此!


封装出的组件库大部分情况下会让你"青💩留名",因为后面的每个人用这个组件都会骂,这是哪个zz封装的组件,为啥这么写,这里不应该这么写嘛?


如果你一直呆在这个公司,由你一手搭建的这个组件库将伴随你在这个公司的整个职业生涯。


一时造轮子,一辈子维护轮子!


只要任何人用到你这个组件库,遇到了问题一定会来找你。不管你现在到底有没有在负责这个组件库!


这种通用性的组件库不可能没有问题,但是一旦有了问题找到你,你或者是解决不了,又或者是解决的不及时,都将或多或少的影响你的同事对你的评价。


当所有人都对你封装的这个组件库不满意,并且在开组会的时候提出来因为xx封装的组件库不好使,导致了项目延期,时间一长你的领导会对你有好印象?


结语


希望兄弟们还是要明白,对于一个职场人来说,挣钱最重要,能升上去最重要。其他的所有都是细枝末节,不必太在意。


对于客户和老板而言,能快速交付,把钱挣到手最重要,其他也都是无所谓的小事。


对于咱们自己来说,喜欢折腾是程序员的特质,但是要分清形势。


作者:李剑一
来源:juejin.cn/post/7532773597850206243
收起阅读 »

JavaScript 开发必备规范:命名、语法与代码结构指南

web
在 JavaScript 开发中,遵循良好的编程规范对于构建高效、可维护的代码至关重要。它不仅能提升代码的可读性,让团队成员之间更容易理解和协作,还能减少错误的发生,提高开发效率。本文将详细介绍 JavaScript 编程中的一些重要规范。 一、命名规范 变...
继续阅读 »

在 JavaScript 开发中,遵循良好的编程规范对于构建高效、可维护的代码至关重要。它不仅能提升代码的可读性,让团队成员之间更容易理解和协作,还能减少错误的发生,提高开发效率。本文将详细介绍 JavaScript 编程中的一些重要规范。



一、命名规范


变量和函数命名


采用小驼峰命名法,第一个单词首字母小写,后续单词首字母大写。例如firstName用于表示名字变量,getUserName函数用于获取用户名。这种命名方式能够清晰地区分变量和函数,并且让名称具有语义化,便于理解其用途。避免使用单字母或无意义的命名,如ab等,除非在特定的循环等场景下有约定俗成的用法。


常量命名


常量通常使用全大写字母,单词之间用下划线分隔,比如MAX_COUNT表示最大计数,API_URL表示 API 的链接地址。这样的命名方式能够直观地让开发者知道该变量是一个常量,其值在程序运行过程中不会改变。


二、语法规范


使用严格模式


在脚本或函数的开头添加'use strict';开启严格模式。严格模式下,JavaScript 会进行更严格的语法检查,比如禁止使用未声明的变量,防止意外创建全局变量等常见错误。它有助于开发者养成良好的编程习惯,提高代码的质量和稳定性。


// 严格模式
function strictWithExample() {
'use strict';
var obj = { x: 1 };
// 抛出 SyntaxError
with (obj) {
console.log(x);
}
}
strictWithExample();

语句结束加分号


尽管 JavaScript 在某些情况下可以省略分号,但为了避免潜在的错误和代码歧义,强烈建议在每条语句结束后都加上分号。


例如let num = 5let num = 5;,前者在一些复杂的代码结构中可能会因为自动分号插入机制而出现意想不到的问题,而后者则明确地表示了语句的结束。


let num = 5
console.log(num)
[1, 2, 3].forEach(function (element) {
console.log(element);
});

在上述代码中,let num = 5 后面没有分号,由于 [ 是 JavaScript 中的数组字面量符号,同时也可以用于数组的索引访问操作(例如 arr[0]),所以引擎会认为你可能想要对 num 进行某种与数组相关的操作,比如 num[1, 2, 3](虽然这在语法上是错误的,因为 num 是一个数字,不是数组)。


代码缩进


统一使用 2 个或 4 个空格进行缩进,这能让代码的层次结构一目了然。比如在嵌套的if - else语句、循环语句等结构中,合理的缩进能清晰地展示代码的逻辑关系,使代码更易于阅读和维护。


代码块使用大括号


即使代码块中只有一条语句,也建议使用大括号括起来。例如:


if (condition) {
doSomething();
}

这样在后续需要添加更多语句到代码块中时,能避免因遗漏大括号而导致的语法错误。


三、比较操作规范


尽量使用===!==进行比较操作,避免使用==!=。因为==!=在比较时会进行类型转换,这可能会带来意外结果。例如'5' == 5会返回true,而'5' === 5会返回false,在实际开发中,明确知道数据类型并使用全等操作符能减少错误的发生。


四、代码结构规范


避免全局变量污染


在 JavaScript 开发中,尤其是构建大型项目时,全局变量带来的问题不容小觑。全局变量如同在公共空间随意摆放的物品,极易引发混乱。在一个复杂项目中,可能有多个开发人员同时工作,不同模块的代码相互交织。如果每个模块都随意创建全局变量,很容易出现命名冲突。



  • 例如,一个模块定义了全局变量count用于记录某个操作的次数,另一个模块可能也需要使用count变量来记录其他信息,这就会导致变量值被意外覆盖,引发难以排查的错误。


同时,在大型项目中,代码的维护和调试本身就具有挑战性。全局变量的存在会使问题变得更加棘手。因为全局变量在整个程序的生命周期内都存在,其值可能在程序的任何地方被修改。当出现错误时,开发人员很难确定是哪个部分的代码对全局变量进行了不恰当的修改,增加了调试的难度和时间成本。


模块化


为了解决这些问题,模块化是一种非常有效的手段。通过将相关的功能代码封装在一个模块中,每个模块都有自己独立的作用域。在 JavaScript 中,ES6 引入了模块系统,使用exportimport关键字来管理模块的导出和导入。例如,有一个处理用户数据的模块userModule.js


// userModule.js
const userData = {
name: '',
age: 0
};

function setUserName(name) {
userData.name = name;
}

function getUserName() {
return userData.name;
}

export { setUserName, getUserName };

在这个模块中,userDatasetUserNamegetUserName函数都在模块内部作用域中,外部无法直接访问userData。只有通过导出的setUserNamegetUserName函数,其他模块才能间接操作userData。在其他模块中使用时,可以这样导入:


// main.js
import { setUserName, getUserName } from './userModule.js';

setUserName('John');
console.log(getUserName());

这样就有效地避免了全局变量的使用,降低了命名冲突的风险,同时也使得代码的结构更加清晰,易于维护和调试。


立即执行函数表达式(IIFE)


另一种方式是使用立即执行函数表达式(IIFE)。在 JavaScript 中,通过将函数定义包裹在括号中,并紧接着在后面加上括号进行调用,便形成了一个 IIFE。IIFE 能够创建一个独立的函数作用域,在该作用域内定义的变量和函数均为私有。这就确保了函数内部的变量和函数不会被外部随意访问和修改 。例如:


const app = (function () {
let privateVariable = 10;

function privateFunction() {
console.log('This is a private function.');
}

return {
publicFunction: function () {
privateFunction();
console.log('The value of private variable is:', privateVariable);
}
};
})();

app.publicFunction();

在上述代码中,



  • (function () {... })():在包裹匿名函数的括号后面再添加一对括号(),这对括号用于立即调用前面定义的匿名函数。当 JavaScript 引擎执行到这部分代码时,就会立即调用这个匿名函数,所以称为 “立即执行函数”。

  • privateVariableprivateFunction都在 IIFE 内部的私有作用域中,因此外部无法直接访问它们。通过返回一个包含publicFunction的对象,向外暴露了一个公共接口,这样一来既实现了功能,又避免了全局变量污染。


合理使用注释


在关键代码逻辑处添加注释,解释代码的功能、用途、算法思路等。注释要简洁准确,避免过度注释。



  • 例如在一个复杂的算法函数前,可以注释说明该算法的作用、输入参数和返回值的含义,方便其他开发者理解代码。

  • 但不要在过于简单的代码上添加冗余注释,如let num = 1; // 定义一个数字变量,这样的注释对理解代码没有实质性帮助。


五、注释规范


注释分为单行注释和多行注释。单行注释使用//,用于对某一行代码进行简单解释。多行注释使用/* */,适合对一段代码块进行详细说明。在写注释时,要确保注释与代码同步更新,避免代码修改后注释不再准确的情况。


六、异步编程规范


随着 JavaScript 在前端和后端开发中的广泛应用,异步编程变得越来越重要。使用async/await语法可以让异步代码看起来更像同步代码,提高代码的可读性。例如:


async function getData() {
try {
let response = await fetch('https://example.com/api');
let data = await response.json();
return data;
} catch (error) {
console.error('获取数据失败', error);
}
}

在处理多个异步操作时,要注意合理控制并发数量,避免因过多并发请求导致性能问题。


七、代码格式化规范


使用代码格式化工具,如 Prettier、ESLint 等,能够自动按照设定的规则对代码进行格式化。它可以统一代码风格,包括缩进、空格、换行等,使团队成员的代码风格保持一致,减少因风格差异带来的冲突和阅读障碍。


八、代码复用


尽量编写可复用的代码,通过函数封装、模块封装等方式,将重复使用的代码逻辑提取出来。



  • 例如,在多个地方需要对数据进行格式化处理,可以编写一个通用的数据格式化函数,在需要的地方调用,这样不仅能减少代码量,还方便维护和修改。


九、错误处理


在代码中要合理处理错误,使用try - catch块捕获可能出现的异常。对于异步操作,也要通过try - catch或者.catch方法来处理错误。



  • 例如在网络请求失败时,要及时向用户反馈错误信息,而不是让程序崩溃。同时,可以自定义错误类型,以便在不同的业务场景下进行更精准的错误处理。



遵循这些 JavaScript 编程规范,能够帮助开发者写出更整洁、高效、易于维护的代码。在实际开发中,团队可以根据项目需求进一步细化和完善这些规范,以提升整个项目的质量。



作者:逆袭的小黄鸭
来源:juejin.cn/post/7493346464920404003
收起阅读 »

我发现很多程序员都不会打日志。。

大家好,我是程序员鱼皮。我发现很多程序员都不打日志,有的是 不想 打、有的是 意识不到 要打、还有的是 真不会 打日志啊! 前段时间的模拟面试中,我问了几位应届的 Java 开发同学 “你在项目中是怎么打日志的”,得到的答案竟然是 “支支吾吾”、“阿巴阿巴”,...
继续阅读 »

大家好,我是程序员鱼皮。我发现很多程序员都不打日志,有的是 不想 打、有的是 意识不到 要打、还有的是 真不会 打日志啊!


前段时间的模拟面试中,我问了几位应届的 Java 开发同学 “你在项目中是怎么打日志的”,得到的答案竟然是 “支支吾吾”、“阿巴阿巴”,更有甚者,竟然表示:直接用 System.out.println() 打印一下吧。。。



要知道,日志是我们系统出现错误时,最快速有效的定位工具,没有日志给出的错误信息,遇到报错你就会一脸懵逼;而且日志还可以用来记录业务信息,比如记录用户执行的每个操作,不仅可以用于分析改进系统,同时在遇到非法操作时,也能很快找到凶手。


因此,对于程序员来说,日志记录是重要的基本功。但很多同学并没有系统学习过日志操作、缺乏经验,所以我写下这篇文章,分享自己在开发项目中记录日志的方法和最佳实践,希望对大家有帮助~


一、日志记录的方法


日志框架选型


有很多 Java 的日志框架和工具库,可以帮我们用一行代码快速完成日志记录。


在学习日志记录之前,很多同学应该是通过 System.out.println 输出信息来调试程序的,简单方便。


但是,System.out.println 存在很严重的问题!



首先,System.out.println 是一个同步方法,每次调用都会导致 I/O 操作,比较耗时,频繁使用甚至会严重影响应用程序的性能,所以不建议在生产环境使用。此外,它只能输出简单的信息到标准控制台,无法灵活设置日志级别、格式、输出位置等。


所以我们一般会选择专业的 Java 日志框架或工具库,比如经典的 Apache Log4j 和它的升级版 Log4j 2,还有 Spring Boot 默认集成的 Logback 库。不仅可以帮我们用一行代码更快地完成日志记录,还能灵活调整格式、设置日志级别、将日志写入到文件中、压缩日志等。


可能还有同学听说过 SLF4J(Simple Logging Facade for Java),看英文名就知道了,这玩意并不是一个具体的日志实现,而是为各种日志框架提供简单统一接口的日志门面(抽象层)。


啥是门面?


举个例子,现在我们要记录日志了,先联系到前台接待人员 SLF4J,它说必须要让我们选择日志的级别(debug / info / warn / error),然后要提供日志的内容。确认之后,SLF4J 自己不干活,屁颠屁颠儿地去找具体的日志实现框架,比如 Logback,然后由 Logback 进行日志写入。



这样做有什么好处呢?无论我们选择哪套日志框架、或者后期要切换日志框架,调用的方法始终是相同的,不用再去更改日志调用代码,比如将 log.info 改为 log.printInfo。


既然 SLF4J 只是玩抽象,那么 Log4j、Log4j 2 和 Logback 应该选择哪一个呢?



值得一提的是,SLF4J、Log4j 和 Logback 竟然都是同一个作者(俄罗斯程序员 Ceki Gülcü)。



首先,Log4j 已经停止维护,直接排除。Log4j 2 和 Logback 基本都能满足功能需求,那么就看性能、稳定性和易用性。



  • 从性能来说,Log4j 2 和 Logback 虽然都支持异步日志,但是 Log4j 基于 LMAX Disruptor 高性能异步处理库实现,性能更高。

  • 从稳定性来说,虽然这些日志库都被曝出过漏洞,但 Log4j 2 的漏洞更为致命,姑且算是 Logback 得一分。

  • 从易用性来说,二者差不多,但 Logback 是 SLF4J 的原生实现、Log4j2 需要额外使用 SLF4J 绑定器实现。


再加上 Spring Boot 默认集成了 Logback,如果没有特殊的性能需求,我会更推荐初学者选择 Logback,都不用引入额外的库了~


使用日志框架


日志框架的使用非常简单,一般需要先获取到 Logger 日志对象,然后调用 logger.xxx(比如 logger.info)就能输出日志了。


最传统的方法就是通过 LoggerFactory 手动获取 Logger,示例代码如下:


import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class MyService {
   private static final Logger logger = LoggerFactory.getLogger(MyService.class);

   public void doSomething() {
       logger.info("执行了一些操作");
  }
}

上述代码中,我们通过调用日志工厂并传入当前类,创建了一个 logger。但由于每个类的类名都不同,我们又经常复制这行代码到不同的类中,就很容易忘记修改类名。


所以我们可以使用 this.getClass 动态获取当前类的实例,来创建 Logger 对象:


public class MyService {
   private final Logger logger = LoggerFactory.getLogger(this.getClass());

   public void doSomething() {
       logger.info("执行了一些操作");
  }
}

给每个类都复制一遍这行代码,就能愉快地打日志了。


但我觉得这样做还是有点麻烦,我连复制粘贴都懒得做,怎么办?


还有更简单的方式,使用 Lombok 工具库提供的 @Slf4j 注解,可以自动为当前类生成一个名为 log 的 SLF4J Logger 对象,简化了 Logger 的定义过程。示例代码如下:


import lombok.extern.slf4j.Slf4j;

@Slf4j
public class MyService {
   public void doSomething() {
       log.info("执行了一些操作");
  }
}

这也是我比较推荐的方式,效率杠杠的。



此外,你可以通过修改日志配置文件(比如 logback.xmllogback-spring.xml)来设置日志输出的格式、级别、输出路径等。日志配置文件比较复杂,不建议大家去记忆语法,随用随查即可。



二、日志记录的最佳实践


学习完日志记录的方法后,再分享一些我个人记录日志的经验。内容较多,大家可以先了解一下,实际开发中按需运用。


1、合理选择日志级别


日志级别的作用是标识日志的重要程度,常见的级别有:



  • TRACE:最细粒度的信息,通常只在开发过程中使用,用于跟踪程序的执行路径。

  • DEBUG:调试信息,记录程序运行时的内部状态和变量值。

  • INFO:一般信息,记录系统的关键运行状态和业务流程。

  • WARN:警告信息,表示可能存在潜在问题,但系统仍可继续运行。

  • ERROR:错误信息,表示出现了影响系统功能的问题,需要及时处理。

  • FATAL:致命错误,表示系统可能无法继续运行,需要立即关注。


其中,用的最多的当属 DEBUG、INFO、WARN 和 ERROR 了。


建议在开发环境使用低级别日志(比如 DEBUG),以获取详细的信息;生产环境使用高级别日志(比如 INFO 或 WARN),减少日志量,降低性能开销的同时,防止重要信息被无用日志淹没。


注意一点,日志级别未必是一成不变的,假如有一天你的程序出错了,但是看日志找不到任何有效信息,可能就需要降低下日志输出级别了。


2、正确记录日志信息


当要输出的日志内容中存在变量时,建议使用参数化日志,也就是在日志信息中使用占位符(比如 {}),由日志框架在运行时替换为实际参数值。


比如输出一行用户登录日志:


// 不推荐
logger.debug("用户ID:" + userId + " 登录成功。");

// 推荐
logger.debug("用户ID:{} 登录成功。", userId);

这样做不仅让日志清晰易读;而且在日志级别低于当前记录级别时,不会执行字符串拼接,从而避免了字符串拼接带来的性能开销、以及潜在的 NullPointerException 问题。所以建议在所有日志记录中,使用参数化的方式替代字符串拼接。


此外,在输出异常信息时,建议同时记录上下文信息、以及完整的异常堆栈信息,便于排查问题:


try {
   // 业务逻辑
catch (Exception e) {
logger.error("处理用户ID:{} 时发生异常:", userId, e);
}

3、控制日志输出量


过多的日志不仅会占用更多的磁盘空间,还会增加系统的 I/O 负担,影响系统性能。


因此,除了根据环境设置合适的日志级别外,还要尽量避免在循环中输出日志。


可以添加条件来控制,比如在批量处理时,每处理 1000 条数据时才记录一次:


if (index % 1000 == 0) {
   logger.info("已处理 {} 条记录", index);
}

或者在循环中利用 StringBuilder 进行字符串拼接,循环结束后统一输出:


StringBuilder logBuilder = new StringBuilder("处理结果:");
for (Item item : items) {
   try {
       processItem(item);
       logBuilder.append(String.format("成功[ID=%s], ", item.getId()));
  } catch (Exception e) {
       logBuilder.append(String.format("失败[ID=%s, 原因=%s], ", item.getId(), e.getMessage()));
  }
}
logger.info(logBuilder.toString());

如果参数的计算开销较大,且当前日志级别不需要输出,应该在记录前进行级别检查,从而避免多余的参数计算:


if (logger.isDebugEnabled()) {
   logger.debug("复杂对象信息:{}"expensiveToComputeObject());
}

此外,还可以通过更改日志配置文件整体过滤掉特定级别的日志,来防止日志刷屏:


<!-- Logback 示例 -->
<appender name="LIMITED" class="ch.qos.logback.classic.AsyncAppender">
<!-- 只允许 INFO 级别及以上的日志通过 -->
   <filter class="ch.qos.logback.classic.filter.ThresholdFilter">
       <level>INFO</level>
   </filter>
   <!-- 配置其他属性 -->
</appender>

4、把控时机和内容


很多开发者(尤其是线上经验不丰富的开发者)并没有养成记录日志的习惯,觉得记录日志不重要,等到出了问题无法排查的时候才追悔莫及。


一般情况下,需要在系统的关键流程和重要业务节点记录日志,比如用户登录、订单处理、支付等都是关键业务,建议多记录日志。


对于重要的方法,建议在入口和出口记录重要的参数和返回值,便于快速还原现场、复现问题。


对于调用链较长的操作,确保在每个环节都有日志,以便追踪到问题所在的环节。


如果你不想区分上面这些情况,我的建议是尽量在前期多记录一些日志,后面再慢慢移除掉不需要的日志。比如可以利用 AOP 切面编程在每个业务方法执行前输出执行信息:


@Aspect
@Component
public class LoggingAspect {

   @Before("execution(* com.example.service..*(..))")
   public void logBeforeMethod(JoinPoint joinPoint) {
       Logger logger = LoggerFactory.getLogger(joinPoint.getTarget().getClass());
       logger.info("方法 {} 开始执行", joinPoint.getSignature().getName());
  }
}

利用 AOP,还可以自动打印每个 Controller 接口的请求参数和返回值,这样就不会错过任何一次调用信息了。


不过这样做也有一个很重要的点,注意不要在日志中记录了敏感信息,比如用户密码。万一你的日志不小心泄露出去,就相当于泄露了大量用户的信息。



5、日志管理


随着日志文件的持续增长,会导致磁盘空间耗尽,影响系统正常运行,所以我们需要一些策略来对日志进行管理。


首先是设置日志的滚动策略,可以根据文件大小或日期,自动对日志文件进行切分。比如按文件大小滚动:


<!-- 按大小滚动 -->
<rollingPolicy class="ch.qos.logback.core.rolling.SizeBasedRollingPolicy">
   <maxFileSize>10MB</maxFileSize>
</rollingPolicy>

如果日志文件大小达到 10MB,Logback 会将当前日志文件重命名为 app.log.1 或其他命名模式(具体由文件名模式决定),然后创建新的 app.log 文件继续写入日志。


还有按照时间日期滚动:


<!-- 按时间滚动 -->
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
   <fileNamePattern>logs/app-%d{yyyy-MM-dd}.log</fileNamePattern>
</rollingPolicy>

上述配置表示每天创建一个新的日志文件,%d{yyyy-MM-dd} 表示按照日期命名日志文件,例如 app-2024-11-21.log


还可以通过 maxHistory 属性,限制保留的历史日志文件数量或天数:


<maxHistory>30</maxHistory>

这样一来,我们就可以按照天数查看指定的日志,单个日志文件也不会很大,提高了日志检索效率。


对于用户较多的企业级项目,日志的增长是飞快的,因此建议开启日志压缩功能,节省磁盘空间。


<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
   <fileNamePattern>logs/app-%d{yyyy-MM-dd}.log.gz</fileNamePattern>
</rollingPolicy>

上述配置表示:每天生成一个新的日志文件,旧的日志文件会被压缩存储。


除了配置日志切分和压缩外,我们还需要定期审查日志,查看日志的有效性和空间占用情况,从日志中发现系统的问题、清理无用的日志信息等。


如果你想偷懒,也可以写个自动化清理脚本,定期清理过期的日志文件,释放磁盘空间。比如:


# 每月清理一次超过 90 天的日志文件
find /var/log/myapp/ -type f -mtime +90 -exec rm {} ;

6、统一日志格式


统一的日志格式有助于日志的解析、搜索和分析,特别是在分布式系统中。


我举个例子大家就能感受到这么做的重要性了。


统一的日志格式:


2024-11-21 14:30:15.123 [main] INFO com.example.service.UserService - 用户ID:12345 登录成功
2024-11-21 14:30:16.789 [main] ERROR com.example.service.UserService - 用户ID:12345 登录失败,原因:密码错误
2024-11-21 14:30:17.456 [main] DEBUG com.example.dao.UserDao - 执行SQL:[SELECT * FROM users WHERE id=12345]
2024-11-21 14:30:18.654 [main] WARN com.example.config.AppConfig - 配置项 `timeout` 使用默认值:3000ms
2024-11-21 14:30:19.001 [main] INFO com.example.Main - 应用启动成功,耗时:2.34秒

这段日志整齐清晰,支持按照时间、线程、级别、类名和内容搜索。


不统一的日志格式:


2024/11/21 14:30 登录成功 用户ID: 12345
2024-11-21 14:30:16 错误 用户12345登录失败!密码不对
DEBUG 执行SQL SELECT * FROM users WHERE id=12345
Timeout = default
应用启动成功

emm,看到这种日志我直接原地爆炸!



建议每个项目都要明确约定和配置一套日志输出规范,确保日志中包含时间戳、日志级别、线程、类名、方法名、消息等关键信息。


<!-- 控制台日志输出 -->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
   <encoder>
       <!-- 日志格式 -->
       <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
   </encoder>
</appender>

也可以直接使用标准化格式,比如 JSON,确保所有日志遵循相同的结构,便于后续对日志进行分析处理:


<encoder class="net.logstash.logback.encoder.LoggingEventCompositeJsonEncoder">
   <!-- 配置 JSON 编码器 -->
</encoder>

此外,你还可以通过 MDC(Mapped Diagnostic Context)给日志添加额外的上下文信息,比如用户 ID、请求 ID 等,方便追踪。在 Java 代码中,可以为 MDC 变量设置值:


MDC.put("requestId""666");
MDC.put("userId""yupi");
logger.info("用户请求处理完成");
MDC.clear();

对应的日志配置如下:


<!-- 文件日志配置 -->
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
   <encoder>
       <!-- 包含 MDC 信息 -->
       <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - [%X{requestId}] [%X{userId}] %msg%n</pattern>
   </encoder>
</appender>

这样,每个请求、每个用户的操作一目了然。


7、使用异步日志


对于追求性能的操作,可以使用异步日志,将日志的写入操作放在单独的线程中,减少对主线程的阻塞,从而提升系统性能。


除了自己开线程去执行 log 操作之外,还可以直接修改配置来开启 Logback 的异步日志功能:


<!-- 异步 Appender -->
<appender name="ASYNC" class="ch.qos.logback.classic.AsyncAppender">
   <queueSize>500</queueSize> <!-- 队列大小 -->
   <discardingThreshold>0</discardingThreshold> <!-- 丢弃阈值,0 表示不丢弃 -->
   <neverBlock>true</neverBlock> <!-- 队列满时是否阻塞主线程,true 表示不阻塞 -->
   <appender-ref ref="CONSOLE" /> <!-- 生效的日志目标 -->
   <appender-ref ref="FILE" />
</appender>

上述配置的关键是配置缓冲队列,要设置合适的队列大小和丢弃策略,防止日志积压或丢失。


8、集成日志收集系统


在比较成熟的公司中,我们可能会使用更专业的日志管理和分析系统,比如 ELK(Elasticsearch、Logstash、Kibana)。不仅不用每次都登录到服务器上查看日志文件,还可以更灵活地搜索日志。


但是搭建和运维 ELK 的成本还是比较大的,对于小团队,我的建议是不要急着搞这一套。




OK,就分享到这里,洋洋洒洒 4000 多字,希望这篇文章能帮助大家意识到日志记录的重要性,并养成良好的日志记录习惯。学会的话给鱼皮点个赞吧~


日志不是写给机器看的,是写给未来的你和你的队友看的!


更多


💻 编程学习交流:编程导航

📃 简历快速制作:老鱼简历

✏️ 面试刷题神器:面试鸭


作者:程序员鱼皮
来源:juejin.cn/post/7439785794917072896
收起阅读 »

前端常见的6种设计模式

web
一.为什么需要理解设计模式? 前端项目会随着需求迭代变得越来越复杂,设计模式的作用就是提前规避 “后期难改、牵一发动全身” 的坑,设计模式的核心价值:解决 “可维护、可扩展” 问题。 1.工厂模式 工厂模式:通过一个统一的 “工厂函数 / 类” 封装对象的创建...
继续阅读 »

一.为什么需要理解设计模式?


前端项目会随着需求迭代变得越来越复杂,设计模式的作用就是提前规避 “后期难改、牵一发动全身” 的坑,设计模式的核心价值:解决 “可维护、可扩展” 问题。


1.工厂模式


工厂模式:通过一个统一的 “工厂函数 / 类” 封装对象的创建逻辑,外界只需传入参数(如类型、配置),即可获取所需实例,无需关心实例内部的构造细节。核心是 “创建逻辑与使用逻辑分离”,实现批量、灵活地创建相似对象。


前端应用场景


1.Axios 实例


2.Vue实例


3.组件库中的 “表单组件工厂”,统一管理所有表单组件的基础属性(如 iddisabled


2.单例模式:确保全局只有一个实例


核心是为了解决 “重复创建实例导致的资源浪费、状态混乱、逻辑冲突” 问题—— 当某个对象在系统中只需要 “唯一存在” 时,单例模式能确保全局访问到的是同一个实例,从根源避免多实例带来的隐患。


前端典型场景:


1.Vuex单一store实例


2.浏览器的 window 对象


3.原型模式:通过 “复制” 创建新对象


原型模式的核心是 “基于已有对象(原型)复制创建新对象” —— 不是从零开始定义新对象的属性和方法,而是直接 “拷贝” 一个现有对象(原型)的结构,再根据需要修改差异化内容。


前端中原型模式的本质:依托 JavaScript 原型链。


JavaScript 本身就是基于原型的语言,所有对象都有 __proto__ 属性(指向其原型对象),这是原型模式在前端的 “天然实现”。


普通对象原型属性: 只有'proto'属性。


函数原型属性:proto、prototype属性。


prototype专属属性,只有函数有,用于 "当函数作为构造函数时,给新创建的实例提供原型"。


原型链顶端: Object.prototype.proto :指向null ;


前端典型场景:


1.Object.create()


2.Vue2 的数组方法重写:Vue2 为数组的pushpop等方法添加响应式逻辑,新数组会继承这些重写后的方法。


3.继承


工厂模式与原型模式区别:


工厂模式


基于参数 / 规则 “全新创建” 对象;


核心目的:封装复杂的创建逻辑,让调用者无需关心对象构造细节。


原型模式


基于 “已有原型对象” 复制生成新对象


核心目的:复用已有对象的属性 / 方法,减少重复定义,支持继承扩展


4.观察者模式:“一对多” 的依赖通知机制


观察者模式(Observer Pattern)是一种 “一对多” 的依赖关系设计模式:



  • 存在一个 “被观察者(Subject)” 和多个 “观察者(Observer)”;

  • 当被观察者的状态发生变化时,会自动通知所有依赖它的观察者,并触发观察者的更新逻辑;

  • 核心是 “解耦被观察者和观察者”—— 双方无需知道彼此的具体实现,只需通过统一的接口通信



前端典型场景:


1.浏览器事件监听(最基础的观察者模式)

浏览器的 DOM 事件本质是观察者模式的实现:



  • 被观察者:DOM 元素(如按钮);

  • 观察者:事件处理函数(onclickonchange 等);

  • 流程:给元素绑定事件(订阅)→ 元素状态变化(如被点击)→ 自动执行所有绑定的事件处理函数(通知观察者)。

  • 观察者模式的核心价值是 “状态变化自动同步


2.状态管理库(Vuex/Pinia/Redux)

Vuex、Redux 等全局状态管理库的核心机制就是观察者模式:



  • 被观察者:Store 中的状态(如 state.userstate.cart);

  • 观察者:依赖该状态的组件;

  • 流程:组件订阅状态(mapState 或 useSelector)→ 状态更新(commit 或 dispatch)→ 所有订阅该状态的组件自动重新渲染(收到通知更新)


3. 框架的响应式系统(Vue/React)

Vue 的响应式原理(数据驱动视图)和 React 的状态更新机制,底层都依赖观察者模式:



  • Vue:数据对象(data)是被观察者,视图(DOM)和计算属性是观察者 —— 数据变化时,Vue 自动触发依赖收集的观察者(视图重新渲染、计算属性重新计算)。

  • ReactsetState 触发状态更新时,组件树中依赖该状态的组件(观察者)会被重新渲染(收到通知执行更新)。


5.发布-订阅模式


发布 - 订阅模式是观察者模式的变体,核心是通过一个 “中间者(事件中心)” 实现 “发布者” 和 “订阅者” 的完全解耦 —— 发布者不用知道谁在订阅,订阅者也不用知道谁在发布,双方仅通过事件中心传递消息,就像 “报社(发布者)→ 邮局(事件中心)→ 订报人(订阅者)” 的关系。



  • 三大角色



    1. 发布者(Publisher) :负责 “发布事件”(比如触发某个状态变化,如用户登录、数据更新),但不直接联系订阅者;

    2. 订阅者(Subscriber) :负责 “订阅事件”(比如关注 “用户登录” 事件),并定义事件触发时的 “回调逻辑”(比如登录后显示欢迎信息);

    3. 事件中心(Event Bus) :中间枢纽,负责存储 “事件 - 订阅者” 的映射关系,接收发布者的事件并通知所有订阅者。



  • 核心逻辑:订阅者先在事件中心 “订阅” 某个事件 → 发布者在事件中心 “发布” 该事件 → 事件中心找到所有订阅该事件的订阅者,触发它们的回调。


与观察者模式区别:


维度观察者模式发布 - 订阅模式
依赖关系被观察者直接持有观察者列表发布者和订阅者无直接依赖,靠事件中心连接
耦合程度较高(被观察者知道有哪些观察者)极低(双方不知道彼此存在)
适用场景单一被观察者、观察者明确的场景跨模块、多发布者 / 多订阅者的复杂场景
典型例子Vue 响应式(data 直接通知依赖的 DOM)跨组件通信(事件总线)、全局状态更新

前端典型场景:


1.跨组件通信(事件总线 Event Bus)


2.全局状态管理(如 Redux 的 Action 机制)



  • 发布者:组件通过 dispatch(action) 发布 “状态变更事件”;

  • 事件中心:Redux 的 Store,存储状态并管理订阅者;

  • 订阅者:组件通过 store.subscribe(() => { ... }) 订阅状态变化,状态更新时重新渲染。



状态管理库到底是观察者模式还是发布 - 订阅模式?


状态管理库(如 Vuex、Redux)之所以会让人觉得 “既是观察者模式,又是发布 - 订阅模式”,是因为它们融合了两种模式的核心思想—— 在底层实现上,既保留了观察者模式 “状态与依赖直接关联” 的特性,又通过 “中间层” 实现了发布 - 订阅模式的 “解耦” 优势,本质是两种模式的结合与优化


1. 底层:状态与组件的 “观察者模式”(直接依赖)


状态管理库中, “全局状态” 与 “依赖该状态的组件”  之间是典型的观察者模式:



  • 被观察者:全局状态(如 Vuex 的 state、Redux 的 store);

  • 观察者:订阅了该状态的组件;

  • 逻辑:当状态发生变化时,会直接通知所有依赖它的组件(观察者),触发组件重新渲染。


这一层的核心是 “精准依赖”—— 组件只订阅自己需要的状态(比如 Vue 的 mapState、Redux 的 useSelector),状态变化时只有相关组件会被通知,避免无效更新。


2. 上层:组件与状态的 “发布 - 订阅模式”(解耦通信)


状态管理库中, “组件触发状态变更” 与 “状态变更通知组件”  的过程,通过 “中间层(如 commit/dispatch)” 实现,类似发布 - 订阅模式:



  • 发布者:触发状态变更的组件(通过 store.commit('increment') 或 dispatch(action) 发布 “状态变更事件”);

  • 事件中心:状态管理库的核心逻辑(如 Vuex 的 Store 实例、Redux 的 dispatch 机制);

  • 订阅者:依赖状态的组件(通过 subscribe 或计算属性订阅状态)。


这一层的核心是 “解耦”—— 组件不需要知道谁会处理状态变更,也不需要知道哪些组件依赖该状态;状态管理库作为中间层,接收 “发布” 的变更请求,处理后再 “通知” 订阅者,双方完全隔离。


6.代理模式


代理模式(Proxy Pattern)是一种 “通过中间代理对象控制对原始对象的访问” 的设计模式 —— 不直接操作目标对象,而是通过一个 “代理” 来间接访问,代理可以在访问前后添加额外逻辑(如权限校验、缓存、日志记录等)。


核心作用:“控制访问” 与 “增强功能”

前端典型场景:


1. 权限控制代理(限制访问)

2.Vue3响应式核心

用 “中间商” 的思路理解 Vue3 响应式:


  • 目标对象:你定义的 data 数据(如 { count: 0, user: { name: '张三' } });

  • 代理对象:Vue3 通过 reactive() 或 ref() 创建的 “响应式代理”(本质是 Proxy 实例);

  • 调用者:组件中的模板(视图)或业务逻辑(如 {{ count }} 或 count.value++);

  • 代理的 “附加操作” :拦截数据的读取(get)和修改(set),在读取时 “收集依赖”(记录哪些地方用到了这个数据),在修改时 “触发更新”(通知依赖的地方重新渲染)。


1. 目标对象:原始数据 const target = { count: 0 }; 
2. 依赖收集的容器:记录哪些函数依赖了数据(比如视图渲染函数)
const deps = new Set();
3. 创建代理对象(核心:拦截读写,添加响应式逻辑)
const reactiveProxy = new Proxy(target,
{
// 拦截“读取数据”操作(如访问 count 时)
get(target, key){
// 附加操作1:
收集依赖(假设当前正在执行的函数是依赖)
if (currentEffect) { deps.add(currentEffect); // 把依赖存起来 }
return target[key]; // 返回原始值 },
}
// 拦截“修改数据”操作(如 count++ 时)
set(target, key, value) {
// 更新原始数据
target[key] = value;
// 附加操作2:触发更新(通知所有依赖重新执行)
deps.forEach(effect => effect()); return true; } });
}

扩展:Vue3响应式对比vue2响应式

1.Vue2 用的是 Object.defineProperty 拦截属性,只能拦截已存在的属性(对新增属性、数组索引修改不友好);


具体原因拆解:

Object.defineProperty 的工作方式是给对象的某个具体属性添加 getter/setter


但数组本质是特殊对象(属性是索引,如 arr[0]arr[1]),如果用 Object.defineProperty 拦截数组,只能逐个拦截索引(如 01),但存在两个致命问题:


1.问题一:无法拦截数组的原生方法(push/pop/splice 等)
数组的常用操作(如 push 新增元素、splice 删除元素)是通过调用数组原型上的方法实现的,这些方法会直接修改数组本身,但 Object.defineProperty 无法拦截 “方法调用”,只能拦截 “属性读写”。所以最终Vue2采取了这7个数组方法的重写。


 arrayMethods[method] = function(...args) {
// 先调用原生方法(比如 push 实际添加元素)
const result = arrayProto[method].apply(this, args);
// 手动触发更新(通知依赖重新渲染)
notifyUpdate();
return result;

2.问题二:拦截数组索引的成本极高,且不实用。



  • 初始化成本高:数组长度可能很大(甚至动态变化),提前拦截所有索引会浪费性能;

  • 数组长度变化无法拦截
    当 arr.length = 0 时,数组会清空所有元素(即删除索引 012),但 Object.defineProperty 只能知道 length 被改成了 0无法知道具体哪些元素被删除了


对于响应式系统来说,需要知道 “哪些元素变化了” 才能精准通知依赖这些元素的视图。但 length 拦截只能知道 “长度变了”,无法定位具体变化的元素,导致依赖这些元素的视图可能不会更新(比如某个视图依赖 arr[0]length=0 后 arr[0] 不存在了,但视图可能还显示旧值)。


2.Vue3 用 Proxy 直接代理整个对象,能拦截所有属性的读写(包括新增、删除、数组操作),是更彻底、更灵活的代理模式实现,这也是 Vue3 响应式比 Vue2 强大的核心原因之一。


总结


最后想强调:设计模式不是必须遵守的 “规则”,而是解决问题的 “工具”。在实际开发中,我们不需要刻意追求 “用满所有模式”,而是根据场景选择合适的工具:



  • 需批量创建对象 → 工厂模式

  • 需全局唯一实例 → 单例模式

  • .....


参考文章:juejin.cn/post/754253…


作者:大杯咖啡
来源:juejin.cn/post/7563981206674817051
收起阅读 »

Android实战-Native层thread的实现方案

最近阅读Android源码,很多地方都涉及到了线程的概念,比如BootAnimation,应用层的线程倒是略懂一二,Framework的线程却是知之甚少,因此特此记录如下: Android的Native层thread的实现方案一般有两种: Linux上的po...
继续阅读 »

最近阅读Android源码,很多地方都涉及到了线程的概念,比如BootAnimation,应用层的线程倒是略懂一二,Framework的线程却是知之甚少,因此特此记录如下:


Android的Native层thread的实现方案一般有两种:



  • Linux上的posix线程方案

  • Native层面封装的Thread类(用的最多)


posix线程方案


首先创建空文件夹项目-Linux_Thread


其次在Linux_Thread文件夹下创建一个文件thread_posix.c:主要逻辑是在主线程中创建一个子线程mythread,主线程等待子线程执行完毕后,退出进程:


#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <utils/Log.h>

//线程执行逻辑
void *thread_posix_function(void *arg)
{
(void*) arg;
int i;
for(i = 0; i < 40; i++)
{
printf("hello thread i + %d\n", i);
ALOGD("hello thread i + %d\n", i);
sleep(1);
}
return NULL;
}

int main(void)
{

pthread_t mythread;
//创建线程并执行
if(pthread_create(&mythread, NULL, thread_posix_function, NULL))
{
ALOGD("error createing thread");
abort();
}

sleep(1);
//等待mythread线程执行结束
if(pthread_join(mythread, NULL))
{
ALOGD("error joining thread");
abort();
}
ALOGD("hello thread has run end exit\n");
//退出
exit(0);
}

然后在Linux_Thread文件夹下创建项目构建文件Android.mk中将文件编译为可执行文件:


LOCAL_PATH:=${call my-dir}
include ${CLEAR_VARS}
LOCAL_MODULE := linux_thread
LOCAL_SHARED_LIBRARIES := liblog
LOCAL_SRC_FILES := thread_posix.c
LOCAL_PRELINK_MODULE := false
include ${BUILD_EXECUTABLE}

最后确认Linux_Thread项目位于aosp源码文件夹内,开始编译Linux_Thread项目:


source build/envsetup.sh
lunch
make linux_thread

执行成功后,找到输出的可执行文件linux_thread,将文件push到Android设备中去:


adb push linux_thread /data/local/tmp/

注意如果出现报错 Permission denied,需要对文件进行权限修改:


chmod -R 777 linux_thread

开始启动linux_thread:


./linux_thread

image.png


同时也可以通过日志打印输出:


 adb shell locat | grep hello

屏幕截图 2025-05-06 173436.png


以上就是posix线程方案的实现。


Native层的Thread类方案


源码分析


Native层即Framework层的C++部分,Thread的相关代码位置



头文件:system/core/libutils/include/utils/Thread.h


源文件:system/core/libutils/Threads.cpp



# system/core/libutils/include/utils/Thread.h
class Thread : virtual public RefBase
{
public:
explicit Thread(bool canCallJava = true);
virtual ~Thread();

virtual status_t run( const char* name,
int32_t priority = PRIORITY_DEFAULT,
size_t stack = 0)
;

virtual void requestExit();

virtual status_t readyToRun();

status_t requestExitAndWait();

status_t join();

bool isRunning() const;

...
}

Thread继承于RefBase,在构造函数中对canCallJava变量默认赋值为ture,同时声明了一些方法,这些方法都在源文件中实现。


status_t Thread::run(const char* name, int32_t priority, size_t stack)
{
...
if (mRunning) {
// thread already started
return INVALID_OPERATION;
}
...
mRunning = true;

bool res;
if (mCanCallJava) {
res = createThreadEtc(_threadLoop,
this, name, priority, stack, &mThread);
} else {
res = androidCreateRawThreadEtc(_threadLoop,
this, name, priority, stack, &mThread);
}

if (res == false) {
mStatus = UNKNOWN_ERROR; // something happened!
mRunning = false;
mThread = thread_id_t(-1);
mHoldSelf.clear(); // "this" may have gone away after this.

return UNKNOWN_ERROR;
}
return OK;

// Exiting scope of mLock is a memory barrier and allows new thread to run
}

int androidCreateRawThreadEtc(android_thread_func_t entryFunction,
void *userData,
const char* threadName __android_unused,
int32_t threadPriority,
size_t threadStackSize,
android_thread_id_t *threadId)

{
...
errno = 0;
pthread_t thread;
int result = pthread_create(&thread, &attr,
(android_pthread_entry)entryFunction, userData);
pthread_attr_destroy(&attr);
if (result != 0) {
ALOGE("androidCreateRawThreadEtc failed (entry=%p, res=%d, %s)\n"
"(android threadPriority=%d)",
entryFunction, result, strerror(errno), threadPriority);
return 0;
}
...
return 1;
}

int Thread::_threadLoop(void* user)
{
...
bool first = true;

do {
bool result;
//是否第一次执行
if (first) {
first = false;
self->mStatus = self->readyToRun();
result = (self->mStatus == OK);

if (result && !self->exitPending()) {
result = self->threadLoop();
}
} else {
result = self->threadLoop();
}

// establish a scope for mLock
{
Mutex::Autolock _l(self->mLock);
//根据结果来跳出循环
if (result == false || self->mExitPending) {
self->mExitPending = true;
self->mRunning = false;
self->mThread = thread_id_t(-1);
self->mThreadExitedCondition.broadcast();
break;
}
}
strong.clear();
strong = weak.promote();
} while(strong != nullptr);

return 0;
}

在run方法中,mCanCallJava变量一般为false,是在thread创建的时候赋值的。从而进入androidCreateRawThreadEtc()方法创建线程,在此函数中,可以看见还是通过pthread_create()方法创建Linux的posix线程(可以理解为Native的Thread就是对posiz的一个封装);线程运行时回调的函数参数entryFunction值为_threadLoop函数,因此创建的线程会回调到_threadLoop函数中去;_threadLoop函数里则是一个循环逻辑,线程第一次魂环会调用readyToRun()函数,然后再调用threadLoop函数执行线程逻辑,后面就会根据threadLoop执行的结果来判断是否再继续执行下去。


代码练习


头文件MyThread.h


#ifndef _MYTHREAD_H
#define _MYTHREAD_H

#include <utils/threads.h>

namespace android
{
class MyThread: public Thread
{
public:
MyThread();
//创建线程对象就会被调用
virtual void onFirstRef();
//线程创建第一次运行时会被调用
virtual status_t readyToRun();
//根据返回值是否继续执行
virtual bool threadLoop();
virtual void requestExit();

private:
int hasRunCount = 0;
};
}
#endif

源文件MyThread.cpp


#define LOG_TAG "MyThread"

#include <utils/Log.h>
#include "MyThread.h"

namespace android
{
//通过构造函数对mCanCallJava赋值为false
MyThread::MyThread(): Thread(false)
{
ALOGD("MyThread");
}

bool MyThread::threadLoop()
{
ALOGD("threadLoop hasRunCount = %d", hasRunCount);
hasRunCount++;
//计算10次后返回false,表示逻辑结束,线程不需要再继续执行咯
if(hasRunCount == 10)
{
return false;
}
return true;
}

void MyThread::onFirstRef()
{
ALOGD("onFirstRef");
}

status_t MyThread::readyToRun()
{
ALOGD("readyToRun");
return 0;
}

void MyThread::requestExit()
{
ALOGD("requestExit");
}
}

程序入口Main.cpp


#define LOG_TAG "Main"

#include <utils/Log.h>
#include <utils/threads.h>
#include "MyThread.h"

using namespace android;

int main()
{

sp<MyThread> thread = new MyThread;
thread->run("MyThread", PRIORITY_URGENT_DISPLAY);
while(1)
{
if(!thread->isRunning())
{
ALOGD("main thread -> isRunning == false");
break;
}
}

ALOGD("main end");
return 0;
}

项目构建文件Android.mk


LOCAL_PATH:=${call my-dir}
include ${CLEAR_VARS}
LOCAL_MODULE := android_thread
LOCAL_SHARED_LIBRARIES := libandroid_runtime \
libcutils \
libutils \
liblog
LOCAL_SRC_FILES := MyThread.cpp \
Main.cpp \

LOCAL_PRELINK_MODULE := false
include ${BUILD_EXECUTABLE}

项目目录如下:
image.png


通过命令带包得到android_thread可执行文件放入模拟器运行:


屏幕截图 2025-05-08 140036.png


作者:抛空
来源:juejin.cn/post/7501624826286669859
收起阅读 »

微服务正在悄然消亡:这是一件美好的事

最近在做的事情正好需要系统地研究微服务与单体架构的取舍与演进。读到这篇文章《Microservices Are Quietly Dying — And It’s Beautiful》,许多观点直击痛点、非常启发,于是我顺手把它翻译出来,分享给大家,也希望能给同...
继续阅读 »

最近在做的事情正好需要系统地研究微服务与单体架构的取舍与演进。读到这篇文章《Microservices Are Quietly Dying — And It’s Beautiful》,许多观点直击痛点、非常启发,于是我顺手把它翻译出来,分享给大家,也希望能给同样在复杂性与效率之间权衡的团队一些参考。


微服务正在悄然消亡:这是一件美好的事


为了把我们的创业产品扩展到数百万用户,我们搭建了 47 个微服务。


用户从未达到一百万,但我们达到了每月 23,000 美元的 AWS 账单、长达 14 小时的故障,以及一个再也无法高效交付新功能的团队。


那一刻我才意识到:我们并没有在构建产品,而是在搭建一座分布式的自恋纪念碑。


image.png


我们都信过的谎言


五年前,微服务几乎是教条。Netflix 用它,Uber 用它。每一场技术大会、每一篇 Medium 文章、每一位资深架构师都在高喊同一句话:单体不具备可扩展性,微服务才是答案。


于是我们照做了。我们把 Rails 单体拆成一个个服务:用户服务、认证服务、支付服务、通知服务、分析服务、邮件服务;然后是子服务,再然后是调用服务的服务,层层套叠。


到第六个月,我们已经在 12 个 GitHub 仓库里维护 47 个服务。我们的部署流水线像一张地铁图,架构图需要 4K 显示器才能看清。


当“最佳实践”变成“最差实践”


我们不断告诫自己:一切都在运转。我们有 Kubernetes,有服务网格,有用 Jaeger 的分布式追踪,有 ELK 的日志——我们很“现代”。


但那些光鲜的微服务文章从不提的一点是:分布式的隐性税


每一个新功能都变成跨团队的协商。想给用户资料加一个字段?那意味着要改五个服务、提三个 PR、协调两周,并进行一次像劫案电影一样精心编排的数据库迁移。


我们的预发布环境成本甚至高于生产环境,因为想测试任何东西,都需要把一切都跑起来。47 个服务在 Docker Compose 里同时启动,内存被疯狂吞噬。


那个彻夜崩溃的夜晚


凌晨 2:47,Slack 被消息炸翻。


生产环境宕了。不是某一个服务——是所有服务。支付服务连不上用户服务,通知服务不断超时,API 网关对每个请求都返回 503。


我打开分布式追踪面板:一万五千个 span,全线飘红。瀑布图像抽象艺术。我花了 40 分钟才定位出故障起点。


结果呢?一位初级开发在认证服务上发布了一个配置变更,只是一个环境变量。它让令牌校验多了 2 秒延迟,这个延迟在 11 个下游服务间层层传递,超时叠加、断路器触发、重试逻辑制造请求风暴,整个系统在自身重量下轰然倒塌。


我们搭了一座纸牌屋,却称之为“容错架构”。


我们花了六个小时才修复。并不是因为 bug 复杂——它只是一个配置的单行改动,而是因为排查分布式系统就像破获一桩谋杀案:每个目击者说着不同的语言,而且有一半在撒谎。


那个被忽略的低语


一周后,在复盘会上,我们的 CTO 说了句让所有人不自在的话:


“要不我们……回去?”


回到单体。回到一个仓库。回到简单。


会议室一片沉默。你能感到认知失调。我们是工程师,我们很“高级”。单体是给传统公司和训练营毕业生用的,不是给一家正打造未来的 A 轮初创公司用的。


但随后有人把指标展开:平均恢复时间 4.2 小时;部署频率每周 2.3 次(从单体时代的每周 12 次一路下滑);云成本增长速度比营收快 40%。


数字不会说谎。是架构在拖垮我们。


美丽的回归


我们用了三个月做整合。47 个服务归并成一个模块划分清晰的 Rails 应用;Kubernetes 变成负载均衡后面的三台 EC2;12 个仓库的工作流收敛成一个边界明确的仓库。


结果简直让人尴尬。


部署时间从 25 分钟降到 90 秒;AWS 账单从 23,000 美元降到 3,800 美元;P95 延迟提升了 60%,因为我们消除了 80% 的网络调用。更重要的是——我们又开始按时交付功能了。


开发者不再说“我需要和三个团队协调”,而是开始说“午饭前给你”。


我们的“分布式系统”变回了结构良好的应用。边界上下文变成 Rails 引擎,服务调用变成方法调用,Kafka 变成后台任务,“编排层”……就是 Rails 控制器。


它更快,它更省,它更好。


我们真正学到的是什么


这是真相:我们为此付出两年时间和 40 万美元才领悟——


微服务不是一种纯粹的架构模式,而是一种组织模式。Netflix 需要它,因为他们有 200 个团队。你没有。Uber 需要它,因为他们一天发布 4,000 次。你没有。


复杂性之所以诱人,是因为它看起来像进步。 拥有 47 个服务、Kubernetes、服务网格和分布式追踪,看起来很“专业”;而一个单体加一套 Postgres,看起来很“业余”。


但复杂性是一种税。它以认知负担、运营开销、开发者幸福感和交付速度为代价。


而大多数初创公司根本付不起这笔税。


我们花了两年时间为并不存在的规模做优化,同时牺牲了能让我们真正达到规模的简单性。


你不需要 50 个微服务,你需要的是自律


软件架构的“肮脏秘密”是:好的设计在任何规模都奏效。


一个结构良好的单体,拥有清晰的模块、明确的边界上下文和合理的关注点分离,比一团由希望和 YAML 勉强粘合在一起的微服务乱麻走得更远。


微服务并不是因为“糟糕”而式微,而是因为我们出于错误的理由使用了它。我们选择了分布式的复杂性而不是本地的自律,选择了运营的负担而不是价值的交付。


那些悄悄回归单体的公司并非承认失败,而是在承认更难的事实:我们一直在解决错误的问题。


所以我想问一个问题:你构建微服务,是在逃避什么?


如果答案是“一个凌乱的代码库”,那我有个坏消息——分布式系统不会修好坏代码,它只会让问题更难被发现。


作者:程序猿DD
来源:juejin.cn/post/7563860666349649970
收起阅读 »

electron-updater实现热更新完整流程

web
最近项目做了一个electron项目,记录一下本次客户端热更新中对electron-updater的使用以及遇到的一些问题。 一、配置electron-builder 在electron-builder的配置文件"build"中增加 "publish": [ ...
继续阅读 »

最近项目做了一个electron项目,记录一下本次客户端热更新中对electron-updater的使用以及遇到的一些问题。


一、配置electron-builder


在electron-builder的配置文件"build"中增加


"publish": [
{
"provider": "generic",
"url": "oss://xxx",
}
]

url: 打包出来的文件存放的地址,配置之后会生成latest.yml文件。electron-updater会去比较这个文件,判断是否需要更新。


二、electron-updater的使用


官方文档: http://www.electron.build/auto-update…


主进程


import { autoUpdater } from "electron-updater";
const { ipcMain } = require("electron");

// 配置提供更新的程序,及build中配置的url
autoUpdater.setFeedURL("oss://xxx")
// 是否自动更新,如果为true,当可以更新时(update-available)自动执行更新下载。
autoUpdater.autoDownload = false

// 1. 在渲染进程里触发获取更新,开始进行更新流程。 (根据具体需求)
ipcMain.on("checkForUpdates", (e, arg) => {
autoUpdater.checkForUpdates();
});

autoUpdater.on("error", function (error) {
printUpdaterMessage('error');
mainWindow.webContents.send("updateError", error);
});

// 2. 开始检查是否有更新
autoUpdater.on("checking-for-update", function () {
printUpdaterMessage('checking');
});

// 3. 有更新时触发
autoUpdater.on("update-available", function (info) {
printUpdaterMessage('updateAvailable');
// 4. 告诉渲染进程有更新,info包含新版本信息
mainWindow.webContents.send("updateAvailable", info);
});

// 7. 收到确认更新提示,执行下载
ipcMain.on('comfirmUpdate', () => {
autoUpdater.downloadUpdate()
})

autoUpdater.on("update-not-available", function (info) {
printUpdaterMessage('updateNotAvailable');
});

// 8. 下载进度,包含进度百分比、下载速度、已下载字节、总字节等
// ps: 调试时,想重复更新,会因为缓存导致该事件不执行,下载直接完成,可找到C:\Users\40551\AppData\Local\xxx-updater\pending下的缓存文件将其删除(这是我本地的路径)
autoUpdater.on("download-progress", function (progressObj) {
printUpdaterMessage('downloadProgress');
mainWindow.webContents.send("downloadProgress", progressObj);
});

// 10. 下载完成,告诉渲染进程,是否立即执行更新安装操作
autoUpdater.on("update-downloaded", function () {
mainWindow.webContents.send("updateDownloaded");
// 12. 立即更新安装
ipcMain.on("updateNow", (e, arg) => {
autoUpdater.quitAndInstall();
});
}
);

// 将日志在渲染进程里面打印出来
function printUpdaterMessage(arg) {
let message = {
error: "更新出错",
checking: "正在检查更新",
updateAvailable: "检测到新版本",
downloadProgress: "下载中",
updateNotAvailable: "无新版本",
};
mainWindow.webContents.send("printUpdaterMessage", message[arg]??arg);
}


渲染进程:


// 5. 收到主进程可更新的消息,做自己的业务逻辑
ipcRenderer.on('updateAvailable', (event, data) => {
// do sth.
})

// 6. 点击确认更新
ipcRenderer.send('comfirmUpdate')

// 9. 收到进度信息,做进度条
ipcRenderer.on('downloadProgress', (event, data) => {
// do sth.
})

// 11. 下载完成,反馈给用户是否立即更新
ipcRenderer.on('updateDownloaded', (event, data) => {
// do sth.
})

// 12. 告诉主进程,立即更新
ipcRenderer.send("updateNow");

本地环境


如果想在本地环境调试更新,会报错找不到dev-app-update.yml文件
需要自己在根目录(或报错时显示的目录下)手动新建一个dev-app-update.yml里就可以了。文件,将打包生成好的latest.yml复制到dev-app-update.yml里就可以了。


完成截图


image.png


image.png


作者:致命一击
来源:juejin.cn/post/7054811432714108936
收起阅读 »

Spring Boot 分布式事务高阶玩法:从入门到精通

嘿,各位 Java 小伙伴们!今天咱们要来聊聊 Spring Boot 里一个超酷炫但又有点让人头疼的家伙 —— 分布式事务。这玩意儿就像是一场大型派对的组织者,要确保派对上所有的活动(操作)要么都顺顺利利地进行,要么就一起取消,绝对不能出现有的活动进行了一半...
继续阅读 »

嘿,各位 Java 小伙伴们!今天咱们要来聊聊 Spring Boot 里一个超酷炫但又有点让人头疼的家伙 —— 分布式事务。这玩意儿就像是一场大型派对的组织者,要确保派对上所有的活动(操作)要么都顺顺利利地进行,要么就一起取消,绝对不能出现有的活动进行了一半,有的却没开始的尴尬局面。


为啥要有分布式事务


在以前那种单体应用的小世界里,事务处理就像在自己家里整理东西,所有的东西(数据)都在一个地方,要保证操作的一致性很容易。但随着业务越来越复杂,应用变成了分布式的 “大杂烩”,各个服务就像住在不同房子里的小伙伴,这时候再想保证所有操作都一致,就需要分布式事务这个 “超级协调员” 出场啦。


Spring Boot 里的分布式事务支持


Spring Boot 对分布式事务的支持就像是给你配备了一套超级工具包。其中,@Transactional注解大家肯定都很熟悉,在单体应用里它就是事务管理的小能手。但在分布式场景下,我们还有更厉害的武器,比如基于 XA 协议的分布式事务管理器,以及像 Seata 这样的开源框架。


XA 协议的分布式事务管理器


XA 协议就像是一个国际通用的 “交流规则”,它规定了数据库和事务管理器之间怎么沟通。在 Spring Boot 里使用 XA 协议的分布式事务管理器,就像是给各个服务的数据库都请了一个翻译,让它们能准确地交流事务相关的信息。


下面我们来看一段简单的代码示例,假设我们有两个服务,一个是订单服务,一个是库存服务,我们要在创建订单的同时扣减库存,并且保证这两个操作要么都成功,要么都失败。


首先,我们需要配置 XA 数据源,这里以 MySQL 为例:


@Configuration
public class XADataSourceConfig {
@Bean
@ConfigurationProperties(prefix = "spring.datasource")
public DataSourceProperties dataSourceProperties() {
return new DataSourceProperties();
}
@Bean
public DataSource dataSource() {
return dataSourceProperties().initializeDataSourceBuilder()
.type(com.mysql.cj.jdbc.MysqlXADataSource.class)
.build();
}
}

然后,配置事务管理器:


@Configuration
public class XATransactionConfig {
@Autowired
private DataSource dataSource;
@Bean
public PlatformTransactionManager transactionManager() throws SQLException {
return new JtaTransactionManager(new UserTransactionFactory(), new TransactionManagerFactory(dataSource));
}
}

接下来,在业务代码里使用@Transactional注解:


@Service
public class OrderService {
@Autowired
private OrderRepository orderRepository;
@Autowired
private StockService stockService;
@Transactional
public void createOrder(Order order) {
orderRepository.save(order);
stockService.decreaseStock(order.getProductId(), order.getQuantity());
}
}

在这个例子里,createOrder方法上的@Transactional注解就像一个 “指挥官”,它会协调订单保存和库存扣减这两个操作,确保它们在同一个事务里执行。


Seata 框架


Seata 就像是一个更智能、更强大的 “事务指挥官”。它有三个重要的组件:TC(Transaction Coordinator)事务协调器、TM(Transaction Manager)事务管理器和 RM(Resource Manager)资源管理器。TC 就像一个调度中心,TM 负责发起和管理事务,RM 则负责管理资源和提交 / 回滚事务。


使用 Seata,我们首先要在项目里引入相关依赖:


<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-seata</artifactId>
</dependency>

然后,配置 Seata 客户端:


seata:
application-id: ${spring.application.name}
tx-service-group: my_test_tx_group
enable-auto-data-source-proxy: true
client:
rm:
async-commit-buffer-limit: 10000
lock:
retry-interval: 10
retry-times: 30
retry-policy-branch-rollback-on-conflict: true
tm:
commit-retry-count: 5
rollback-retry-count: 5
undo:
data-validation: true
log-serialization: jackson
log-table: undo_log

在业务代码里,我们使用@GlobalTransactional注解来开启全局事务:


@Service
public class OrderService {
@Autowired
private OrderRepository orderRepository;
@Autowired
private StockService stockService;
@GlobalTransactional
public void createOrder(Order order) {
orderRepository.save(order);
stockService.decreaseStock(order.getProductId(), order.getQuantity());
}
}

这里的@GlobalTransactional注解就像是给整个分布式事务场景下了一道 “圣旨”,让所有涉及到的服务都按照统一的事务规则来执行。


总结


分布式事务虽然复杂,但有了 Spring Boot 提供的强大支持,以及像 Seata 这样优秀的框架,我们也能轻松应对。就像掌握了一门高超的魔法,让我们的分布式系统变得更加可靠和强大。希望今天的分享能让大家对 Spring Boot 中的分布式事务有更深入的理解,在开发的道路上一路 “开挂”,解决各种复杂的业务场景。


作者:装睡鹿先生
来源:juejin.cn/post/7490588889948061750
收起阅读 »

Android实战-Native层thread的实现方案

最近阅读Android源码,很多地方都涉及到了线程的概念,比如BootAnimation,应用层的线程倒是略懂一二,Framework的线程却是知之甚少,因此特此记录如下: Android的Native层thread的实现方案一般有两种: Linux上的po...
继续阅读 »

最近阅读Android源码,很多地方都涉及到了线程的概念,比如BootAnimation,应用层的线程倒是略懂一二,Framework的线程却是知之甚少,因此特此记录如下:


Android的Native层thread的实现方案一般有两种:



  • Linux上的posix线程方案

  • Native层面封装的Thread类(用的最多)


posix线程方案


首先创建空文件夹项目-Linux_Thread


其次在Linux_Thread文件夹下创建一个文件thread_posix.c:主要逻辑是在主线程中创建一个子线程mythread,主线程等待子线程执行完毕后,退出进程:


#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <utils/Log.h>

//线程执行逻辑
void *thread_posix_function(void *arg)
{
(void*) arg;
int i;
for(i = 0; i < 40; i++)
{
printf("hello thread i + %d\n", i);
ALOGD("hello thread i + %d\n", i);
sleep(1);
}
return NULL;
}

int main(void)
{

pthread_t mythread;
//创建线程并执行
if(pthread_create(&mythread, NULL, thread_posix_function, NULL))
{
ALOGD("error createing thread");
abort();
}

sleep(1);
//等待mythread线程执行结束
if(pthread_join(mythread, NULL))
{
ALOGD("error joining thread");
abort();
}
ALOGD("hello thread has run end exit\n");
//退出
exit(0);
}

然后在Linux_Thread文件夹下创建项目构建文件Android.mk中将文件编译为可执行文件:


LOCAL_PATH:=${call my-dir}
include ${CLEAR_VARS}
LOCAL_MODULE := linux_thread
LOCAL_SHARED_LIBRARIES := liblog
LOCAL_SRC_FILES := thread_posix.c
LOCAL_PRELINK_MODULE := false
include ${BUILD_EXECUTABLE}

最后确认Linux_Thread项目位于aosp源码文件夹内,开始编译Linux_Thread项目:


source build/envsetup.sh
lunch
make linux_thread

执行成功后,找到输出的可执行文件linux_thread,将文件push到Android设备中去:


adb push linux_thread /data/local/tmp/

注意如果出现报错 Permission denied,需要对文件进行权限修改:


chmod -R 777 linux_thread

开始启动linux_thread:


./linux_thread

image.png


同时也可以通过日志打印输出:


 adb shell locat | grep hello

屏幕截图 2025-05-06 173436.png


以上就是posix线程方案的实现。


Native层的Thread类方案


源码分析


Native层即Framework层的C++部分,Thread的相关代码位置



头文件:system/core/libutils/include/utils/Thread.h


源文件:system/core/libutils/Threads.cpp



# system/core/libutils/include/utils/Thread.h
class Thread : virtual public RefBase
{
public:
explicit Thread(bool canCallJava = true);
virtual ~Thread();

virtual status_t run( const char* name,
int32_t priority = PRIORITY_DEFAULT,
size_t stack = 0)
;

virtual void requestExit();

virtual status_t readyToRun();

status_t requestExitAndWait();

status_t join();

bool isRunning() const;

...
}

Thread继承于RefBase,在构造函数中对canCallJava变量默认赋值为ture,同时声明了一些方法,这些方法都在源文件中实现。


status_t Thread::run(const char* name, int32_t priority, size_t stack)
{
...
if (mRunning) {
// thread already started
return INVALID_OPERATION;
}
...
mRunning = true;

bool res;
if (mCanCallJava) {
res = createThreadEtc(_threadLoop,
this, name, priority, stack, &mThread);
} else {
res = androidCreateRawThreadEtc(_threadLoop,
this, name, priority, stack, &mThread);
}

if (res == false) {
mStatus = UNKNOWN_ERROR; // something happened!
mRunning = false;
mThread = thread_id_t(-1);
mHoldSelf.clear(); // "this" may have gone away after this.

return UNKNOWN_ERROR;
}
return OK;

// Exiting scope of mLock is a memory barrier and allows new thread to run
}

int androidCreateRawThreadEtc(android_thread_func_t entryFunction,
void *userData,
const char* threadName __android_unused,
int32_t threadPriority,
size_t threadStackSize,
android_thread_id_t *threadId)

{
...
errno = 0;
pthread_t thread;
int result = pthread_create(&thread, &attr,
(android_pthread_entry)entryFunction, userData);
pthread_attr_destroy(&attr);
if (result != 0) {
ALOGE("androidCreateRawThreadEtc failed (entry=%p, res=%d, %s)\n"
"(android threadPriority=%d)",
entryFunction, result, strerror(errno), threadPriority);
return 0;
}
...
return 1;
}

int Thread::_threadLoop(void* user)
{
...
bool first = true;

do {
bool result;
//是否第一次执行
if (first) {
first = false;
self->mStatus = self->readyToRun();
result = (self->mStatus == OK);

if (result && !self->exitPending()) {
result = self->threadLoop();
}
} else {
result = self->threadLoop();
}

// establish a scope for mLock
{
Mutex::Autolock _l(self->mLock);
//根据结果来跳出循环
if (result == false || self->mExitPending) {
self->mExitPending = true;
self->mRunning = false;
self->mThread = thread_id_t(-1);
self->mThreadExitedCondition.broadcast();
break;
}
}
strong.clear();
strong = weak.promote();
} while(strong != nullptr);

return 0;
}

在run方法中,mCanCallJava变量一般为false,是在thread创建的时候赋值的。从而进入androidCreateRawThreadEtc()方法创建线程,在此函数中,可以看见还是通过pthread_create()方法创建Linux的posix线程(可以理解为Native的Thread就是对posiz的一个封装);线程运行时回调的函数参数entryFunction值为_threadLoop函数,因此创建的线程会回调到_threadLoop函数中去;_threadLoop函数里则是一个循环逻辑,线程第一次魂环会调用readyToRun()函数,然后再调用threadLoop函数执行线程逻辑,后面就会根据threadLoop执行的结果来判断是否再继续执行下去。


代码练习


头文件MyThread.h


#ifndef _MYTHREAD_H
#define _MYTHREAD_H

#include <utils/threads.h>

namespace android
{
class MyThread: public Thread
{
public:
MyThread();
//创建线程对象就会被调用
virtual void onFirstRef();
//线程创建第一次运行时会被调用
virtual status_t readyToRun();
//根据返回值是否继续执行
virtual bool threadLoop();
virtual void requestExit();

private:
int hasRunCount = 0;
};
}
#endif

源文件MyThread.cpp


#define LOG_TAG "MyThread"

#include <utils/Log.h>
#include "MyThread.h"

namespace android
{
//通过构造函数对mCanCallJava赋值为false
MyThread::MyThread(): Thread(false)
{
ALOGD("MyThread");
}

bool MyThread::threadLoop()
{
ALOGD("threadLoop hasRunCount = %d", hasRunCount);
hasRunCount++;
//计算10次后返回false,表示逻辑结束,线程不需要再继续执行咯
if(hasRunCount == 10)
{
return false;
}
return true;
}

void MyThread::onFirstRef()
{
ALOGD("onFirstRef");
}

status_t MyThread::readyToRun()
{
ALOGD("readyToRun");
return 0;
}

void MyThread::requestExit()
{
ALOGD("requestExit");
}
}

程序入口Main.cpp


#define LOG_TAG "Main"

#include <utils/Log.h>
#include <utils/threads.h>
#include "MyThread.h"

using namespace android;

int main()
{

sp<MyThread> thread = new MyThread;
thread->run("MyThread", PRIORITY_URGENT_DISPLAY);
while(1)
{
if(!thread->isRunning())
{
ALOGD("main thread -> isRunning == false");
break;
}
}

ALOGD("main end");
return 0;
}

项目构建文件Android.mk


LOCAL_PATH:=${call my-dir}
include ${CLEAR_VARS}
LOCAL_MODULE := android_thread
LOCAL_SHARED_LIBRARIES := libandroid_runtime \
libcutils \
libutils \
liblog
LOCAL_SRC_FILES := MyThread.cpp \
Main.cpp \

LOCAL_PRELINK_MODULE := false
include ${BUILD_EXECUTABLE}

项目目录如下:
image.png


通过命令带包得到android_thread可执行文件放入模拟器运行:


屏幕截图 2025-05-08 140036.png


作者:抛空
来源:juejin.cn/post/7501624826286669859
收起阅读 »

聊聊SliverPersistentHeader优先消费滑动的设计

Flutter中想在滚动体系中实现复杂的效果,肯定逃不开SIlver全家桶,Sliver中提供了很多好用的组件可以实现各种滚动效果,而如果需要实现,QQ好友分组的那种吸顶效果,Flutter可以可以使用SliverPersistentHeader轻松的做到。 ...
继续阅读 »

Flutter中想在滚动体系中实现复杂的效果,肯定逃不开SIlver全家桶,Sliver中提供了很多好用的组件可以实现各种滚动效果,而如果需要实现,QQ好友分组的那种吸顶效果,Flutter可以可以使用SliverPersistentHeader轻松的做到。


带动画的吸顶滑动


那如果需求再复杂一点,吸顶组件滑动的同时还希望增加吸附的动画效果,其实SliverPersistentHeader也可以很轻松的实现。


吸附效果.gif


但是实现这个效果有一个特殊的“Feature”,他会优先消费滑动事件,导致底部滑动没有在我们预期的时机传递给上一级消费。


提前消费.gif


最近项目中处理这个问题时也没搜到相关的文章,所以今天想来聊聊这个组件的优先消费设计,以及很简单的一个定制效果。


在 Flutter 中,SliverPersistentHeader是实现“滚动时动态变化且可持久化”头部的核心组件,其浮动模式(floating: true)的动画交互(如滚动停止自动吸附、反向滚动立即展开)是通过多组件协同实现的。


瞅瞅源码


SliverPersistentHeader


那就从SliverPersistentHeader开始,让我们看看是如何实现动画的吸顶效果


class SliverPersistentHeader extends StatelessWidget {
  const SliverPersistentHeader({
    
super.key,
    required 
this.delegate,
    
this.pinned = false,
    
this.floating = false,
  }
)
;

  final bool floating;

  @override
  Widget build(BuildContext context)
 {
    if (floating && pinned) {
      return _SliverFloatingPinnedPersistentHeader(delegatedelegate);
    }
    if (pinned) {
      return _SliverPinnedPersistentHeader(delegatedelegate);
    }
    if (floating) {
      return _SliverFloatingPersistentHeader(delegatedelegate);
    }
    return _SliverScrollingPersistentHeader(delegatedelegate);
  }

  @override
  void debugFillProperties(DiagnosticPropertiesBuilder properties)
 {
    super.debugFillProperties(properties);
    ....
  }
}

首先这个组件并不直接实现渲染逻辑,而是根据我们传入的flaoting和pinned的配置委派给不同的内部实现类,其中关于floating的有2个内部类分别是_SliverFloatingPinnedPersistentHeader和_SliverFloatingPersistentHeader,两个最后的实现逻辑类似,都会创建同一个Element实现效果。


_SliverFloatingPersistentHeader


以_SliverFloatingPersistentHeader的举例看逻辑


class _SliverFloatingPersistentHeader extends _SliverPersistentHeaderRenderObjectWidget {
  const _SliverFloatingPersistentHeader({
    required super.
delegate,
  })
 : super(
    floating: 
true,
  )
;

  @override
  _RenderSliverPersistentHeaderForWidgetsMixin createRenderObject(BuildContext context) {
    return _RenderSliverFloatingPersistentHeaderForWidgets(
      vsync: delegate.vsync,
      snapConfiguration: delegate.snapConfiguration,
      stretchConfiguration: delegate.stretchConfiguration,
      showOnScreenConfiguration: delegate.showOnScreenConfiguration,
    );
  }

  @override
  void updateRenderObject(BuildContext context, _RenderSliverFloatingPersistentHeaderForWidgets renderObject) {
    renderObject.vsync = delegate.vsync;
    renderObject.snapConfiguration = delegate.snapConfiguration;
    renderObject.stretchConfiguration = delegate.stretchConfiguration;
    renderObject.showOnScreenConfiguration = delegate.showOnScreenConfiguration;
  }
}

其中的核心是createRenderObject和updateRenderObject,后者的作用热重载和更新配置信息,前者的作用的是创建一个_RenderSliverFloatingPersistentHeaderForWidgets,在这个RenderObject它内部处理了复杂的逻辑,例如:



  • 响应滚动方向变化;

  • 控制 header 出现/消失动画;

  • 通过 ScrollPosition.hold() 暂停用户滚动;

  • 使用 _FloatingHeaderState 管理动画控制器(AnimationController)。



记住这个RenderObject,后面还会见到它



_SliverPersistentHeaderRenderObjectWidget


可以看到_SliverFloatingPersistentHeader继承于_SliverPersistentHeaderRenderObjectWidget,先看看它的代码


abstract class _SliverPersistentHeaderRenderObjectWidget extends RenderObjectWidget {
  const _SliverPersistentHeaderRenderObjectWidget({
    required 
this.delegate,
    
this.floating = false,
  })
;

  final SliverPersistentHeaderDelegate delegate;
  final bool floating;

  @override
  _SliverPersistentHeaderElement createElement() => _SliverPersistentHeaderElement(this, floating: floating);

  @override
  _RenderSliverPersistentHeaderForWidgetsMixin createRenderObject(BuildContext context);

  @override
  void debugFillProperties(DiagnosticPropertiesBuilder description) {
    super.debugFillProperties(description);
    description.add(
      DiagnosticsProperty(
        'delegate',
        delegate,
      ),
    );
  }
}

核心逻辑是通过createElement创建了_SliverPersistentHeaderElement,到这里为止刚好对应上flutter渲染树中的三层架构:


层级类名作用
Widget_SliverPersistentHeaderRenderObjectWidget定义静态配置(delegate、floating)
Element_SliverPersistentHeaderElement管理生命周期与子节点(build、mount、update)
RenderObject_RenderSliverPersistentHeaderForWidgetsMixin真正参与布局绘制

简单的说就是:



  • _SliverPersistentHeaderRenderObjectWidget 负责描述,

  • _SliverPersistentHeaderElement 负责执行,

  • _RenderSliverPersistentHeaderForWidgetsMixin 负责绘制。


_SliverPersistentHeaderElement


那这个Element长啥样呢


class _SliverPersistentHeaderElement extends RenderObjectElement {
  _SliverPersistentHeaderElement(
    _SliverPersistentHeaderRenderObjectWidget super.widget, {
    this.floating = false,
  });

  final bool floating;

  @override
  _RenderSliverPersistentHeaderForWidgetsMixin get renderObject => super.renderObject as _RenderSliverPersistentHeaderForWidgetsMixin;

  @override
  void mount(Element? parent, Object? newSlot) {
    super.mount(parent, newSlot);
    renderObject._element = this;
  }

  @override
  void unmount() {
    renderObject._element = null;
    super.unmount();
  }

  @override
  void update(_SliverPersistentHeaderRenderObjectWidget newWidget) {
    ...
  }

  @override
  void performRebuild() {
    super.performRebuild();
    renderObject.triggerRebuild();
  }

  Element? child;

  void _build(double shrinkOffset, bool overlapsContent) {
    owner!.buildScope(this, () {
      final _SliverPersistentHeaderRenderObjectWidget sliverPersistentHeaderRenderObjectWidget = widget as _SliverPersistentHeaderRenderObjectWidget;
      child = updateChild(
        child,
        floating
          ? _FloatingHeader(child: sliverPersistentHeaderRenderObjectWidget.delegate.build(
            this,
            shrinkOffset,
            overlapsContent
          ))
          : sliverPersistentHeaderRenderObjectWidget.delegate.build(this, shrinkOffset, overlapsContent),
        null,
      );
    });
  }

 ...
}

大致的流程是这样的



  • RenderSliverPersistentHeader.performLayout() 在滚动时触发;

  • 它会调用 _element._build(shrinkOffset, overlapsContent);

  • _build() 会重新构建 header 对应的 Widget;

  • 若 floating 模式,则额外包一层 _FloatingHeader;

  • 通过 updateChild() 更新或替换当前子 Element;

  • 生成的 child 会对应到 renderObject.child。


_FloatingHeader


class _FloatingHeaderState extends State<_FloatingHeader{
  ScrollPosition? _position;

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    if (_position != null) {
      _position!.isScrollingNotifier.removeListener(_isScrollingListener);
    }
    _position = Scrollable.maybeOf(context)?.position;
    if (_position != null) {
      _position!.isScrollingNotifier.addListener(_isScrollingListener);
    }
  }

  @override
  void dispose() {
    if (_position != null) {
      _position!.isScrollingNotifier.removeListener(_isScrollingListener);
    }
    super.dispose();
  }

  RenderSliverFloatingPersistentHeader? _headerRenderer() {
    return context.findAncestorRenderObjectOfType();
  }

  void _isScrollingListener() {
    assert(_position != null);

    // When a scroll stops, then maybe snap the app bar int0 view.
    // Similarly, when a scroll starts, then maybe stop the snap animation.
    // Update the scrolling direction as well for pointer scrolling updates.
    final RenderSliverFloatingPersistentHeader? header = _headerRenderer();
    if (_position!.isScrollingNotifier.value) {
      header?.updateScrollStartDirection(_position!.userScrollDirection);
      // Only SliverAppBars support snapping, headers will not snap.
      header?.maybeStopSnapAnimation(_position!.userScrollDirection);
    } else {
      // Only SliverAppBars support snapping, headers will not snap.
      header?.maybeStartSnapAnimation(_position!.userScrollDirection);
    }
  }

  @override
  Widget build(BuildContext context) => widget.child;
}

这里面监听滚动状态并控制吸附动画的触发/停止,而控制吸附动画的触发和停止的就是RenderSliverFloatingPersistentHeader,也就是前面_RenderSliverFloatingPersistentHeaderForWidgets所继承的类


_RenderSliverFloatingPersistentHeaderForWidgets


void updateScrollStartDirection(ScrollDirection direction) {
  _lastStartedScrollDirection = direction;
}

void maybeStopSnapAnimation(ScrollDirection direction) {
  _controller?.stop();
}

void maybeStartSnapAnimation(ScrollDirection direction) {
  final FloatingHeaderSnapConfiguration? snap = snapConfiguration;
  if (snap == null) {
    return;
  }
  if (direction == ScrollDirection.forward && _effectiveScrollOffset! <= 0.0) {
    return;
  }
  if (direction == ScrollDirection.reverse && _effectiveScrollOffset! >= maxExtent) {
    return;
  }

  _updateAnimation(
    snap.duration,
    direction == ScrollDirection.forward ? 0.0 : maxExtent,
    snap.curve,
  );
  _controller?.forward(from: 0.0);
}

void _updateAnimation(Duration duration, double endValue, Curve curve) {
  assert(
    vsync != null,
    'vsync must not be null if the floating header changes size animatedly.',
  );

  final AnimationController effectiveController =
    _controller ??= AnimationController(vsync: vsync!, duration: duration)
      ..addListener(() {
          if (_effectiveScrollOffset == _animation.value) {
            return;
          }
          _effectiveScrollOffset = _animation.value;
          markNeedsLayout();
        });

  _animation = effectiveController.drive(
    Tween(
      begin: _effectiveScrollOffset,
      end: endValue,
    ).chain(CurveTween(curve: curve)),
  );
}

可以看到核心思路就是: 创建 AnimationController



  • 如果 _controller 为空,则创建一个新的,绑定 vsync(防止动画掉帧);

  • 添加监听器,每帧更新 _effectiveScrollOffset 并调用 markNeedsLayout() 通知 RenderObject 重新布局。 创建 Tween + Curve

  • _animation 表示 header 从当前偏移量 _effectiveScrollOffset 到目标 endValue 的动画;

  • 使用 CurveTween 实现动画曲线(如 easeInOut)。 动画驱动布局

  • 每次动画值变化,RenderObject 会重新计算 header 的位置;

  • _effectiveScrollOffset 在 Render 层直接影响 layout 时 header 的显示/收缩状态。



那为什么SliverPersistentHeader的滑动会被优先消费呢?



@override
void performLayout() {
  final SliverConstraints constraints = this.constraints;
  final double maxExtent = this.maxExtent;
  final bool overlapsContent = constraints.overlap > 0.0;
  layoutChild(constraints.scrollOffset, maxExtent, overlapsContent: overlapsContent);
  final double effectiveRemainingPaintExtent = math.max(0, constraints.remainingPaintExtent - constraints.overlap);
  final double layoutExtent = clampDouble(maxExtent - constraints.scrollOffset, 0.0, effectiveRemainingPaintExtent);
  final double stretchOffset = stretchConfiguration != null ?
    constraints.overlap.abs() :
    0.0;
  geometry = SliverGeometry(
    scrollExtent: maxExtent,
    paintOrigin: constraints.overlap,
    paintExtent: math.min(childExtent, effectiveRemainingPaintExtent),
    layoutExtent: layoutExtent,
    maxPaintExtent: maxExtent + stretchOffset,
    maxScrollObstructionExtent: minExtent,
    cacheExtent: layoutExtent > 0.0 ? -constraints.cacheOrigin + layoutExtent : layoutExtent,
    hasVisualOverflowtrue// Conservatively say we do have overflow to avoid complexity.
  );
}

其中layoutExtent的计算就是Header “优先消费滚动”的关键:



  • constraints.scrollOffset:代表当前 sliver 被上层滚动消耗的距离。

  • maxExtent:header 最大高度。

  • 当滚动时,scrollOffset 增大 ⇒ layoutExtent 减小 ⇒ header 收起;

  • 当滚动向下 ⇒ scrollOffset 减小 ⇒ layoutExtent 增大 ⇒ header 露出。


在header尚未完全隐藏(scrollOffset < maxExtent)之前,layoutExtent仍然大于0,意味着这个Header还在继续“吃掉”scrollOffset,下一个Sliver还拿不到这个滚动距离。


换句话说


Header 在Layout阶段主动根据scrollOffset调整可见高度,并在未完全隐藏时持续消耗滚动距离,导致下层列表“迟迟不动”——这就是“优先消费滑动”的根本原因。


利用机制解决问题


那我们又希望有这层动画效果,又不希望滑动被提前消费应该怎么做呢,思路有很多种



  • 重写sliver,去除这层消费

  • 手动接收滑动的offset,模仿实现顶部吸附的动画效果

  • 利用sliver接收的滑动实现我们需要的动画效果


第一种情况下sliver中的很多类是内部类,需要手动复制出来,成本极高


第二种思路需要手动兼容和原本布局的滑动冲突情况


在最快、最简思路下,第三种方案应该是最优解


class CustomSnapHeaderDemo extends StatefulWidget {
  const CustomSnapHeaderDemo({super.key});

  @override
  State createState() => _CustomSnapHeaderDemoState();
}

class _CustomSnapHeaderDemoState extends State<CustomSnapHeaderDemo{
  late final ScrollController _scrollController;
  late ScrollPosition _scrollPosition;

  /// header 的高度
  static const double _headerExtent = 120.0;

  @override
  void initState() {
    super.initState();
    _scrollController = ScrollController();

    /// 等待第一帧绘制后再拿到 ScrollPosition
    WidgetsBinding.instance.addPostFrameCallback((_) {
      _scrollPosition = _scrollController.position;
      // 监听滚动状态变化
      _scrollPosition.isScrollingNotifier.addListener(_onScrollStateChanged);
    });
  }

  @override
  void dispose() {
    _scrollPosition.isScrollingNotifier.removeListener(_onScrollStateChanged);
    _scrollController.dispose();
    super.dispose();
  }

  /// 滚动状态监听器
  void _onScrollStateChanged() {
    final isScrolling = _scrollPosition.isScrollingNotifier.value;
    if (!isScrolling) {
      // 滚动停止时触发吸附逻辑
      _maybeSnapHeader();
    }
  }

  /// 自定义吸附逻辑:
  /// 当 header 显示一半以上时,吸附到完全展开;
  /// 否则隐藏到底部。
  void _maybeSnapHeader() {
    // 当前滚动偏移
    final currentOffset = _scrollPosition.pixels;

    // header 最大可滚动距离
    final maxHeaderOffset = _headerExtent / 2;

    // 如果当前偏移量 < headerExtent,说明 header 仍部分可见
    if (currentOffset >= 0 && currentOffset <= maxHeaderOffset) {
      final visibleRatio = 1.0 - (currentOffset / maxHeaderOffset);
      if (visibleRatio > 0.5) {
        // 吸附展开
        _animateTo(0);
      } else {
        // 吸附隐藏
        _animateTo(maxHeaderOffset);
      }
    }
  }

  /// 平滑滚动到目标位置
  void _animateTo(double targetOffset) {
    _scrollController.animateTo(
      targetOffset,
      duration: const Duration(milliseconds: 250),
      curve: Curves.easeOut,
    )
;
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('SliverPersistentHeader Demo')),
      body: CustomScrollView(
        controller: _scrollController,
        slivers: [
          SliverPersistentHeader(
            pinned: true,
            floating: false,
            delegate: _CustomHeaderDelegate(
              extent: _headerExtent,
            ),
          ),

          // 模拟长列表内容
          SliverList.builder(
            itemCount: 50,
            itemBuilder: (context, index) {
              return ListTile(
                title: Text('Item $index'),
              );
            },
          ),
        ],
      ),
    );
  }
}

class _CustomHeaderDelegate extends SliverPersistentHeaderDelegate {
  final double extent;

  _CustomHeaderDelegate({required this.extent});

  @override
  double get minExtent => extent / 2// 最小高度
  @override
  double get maxExtent => extent; // 最大高度

  @override
  Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {
    final percent = 1.0 - (shrinkOffset / maxExtent);
    return Container(
      color: Colors.blue,
      alignment: Alignment.center,
      child: const Text(
        
'自定义吸附 Header',
        style: TextStyle(
          color: Colors.white,
          fontSize: 
22,
          fontWeight: FontWeight.bold,
        )
,
      ),
    )
;
  }

  @override
  bool shouldRebuild(covariant _CustomHeaderDelegate oldDelegate) {
    return oldDelegate.extent != extent;
  }
}

作者:柿蒂
来源:juejin.cn/post/7564661612319293455
收起阅读 »

⚔️ ReentrantLock大战synchronized:谁是锁界王者?

一、选手登场!🎬 🔵 蓝方:synchronized(老牌选手) // synchronized:Java自带的语法糖 public synchronized void method() { // 临界区代码 } // 或者 public void ...
继续阅读 »

一、选手登场!🎬


🔵 蓝方:synchronized(老牌选手)


// synchronized:Java自带的语法糖
public synchronized void method() {
// 临界区代码
}

// 或者
public void method() {
synchronized(this) {
// 临界区代码
}
}

特点:



  • 📜 JDK 1.0就有了,资历老

  • 🎯 简单粗暴,写法简单

  • 🤖 JVM级别实现,自动释放

  • 💰 免费午餐,不需要手动管理


🔴 红方:ReentrantLock(新锐选手)


// ReentrantLock:JDK 1.5引入
ReentrantLock lock = new ReentrantLock();

public void method() {
lock.lock(); // 手动加锁
try {
// 临界区代码
} finally {
lock.unlock(); // 必须手动释放!
}
}

特点:



  • 🆕 JDK 1.5新秀,年轻有活力

  • 🎨 功能丰富,花样多

  • 🏗️ API级别实现,灵活强大

  • ⚠️ 需要手动管理,容易忘记释放


二、底层实现对决 💻


Round 1: synchronized的底层实现


1️⃣ 对象头结构(Mark Word)


Java对象内存布局:
┌────────────────────────────────────┐
│ 对象头 (Object Header) │
│ ┌─────────────────────────────┐ │
│ │ Mark Word (8字节) │ ← 存储锁信息
│ ├─────────────────────────────┤ │
│ │ 类型指针 (4/8字节) │ │
│ └─────────────────────────────┘ │
├────────────────────────────────────┤
│ 实例数据 (Instance Data) │
├────────────────────────────────────┤
│ 对齐填充 (Padding) │
└────────────────────────────────────┘

Mark Word在不同锁状态下的变化:


64位虚拟机的Mark Word(8字节=64位)

┌──────────────────────────────────────────────────┐
│ 无锁状态 (001) │
│ ┌────────────┬─────┬──┬──┬──┐ │
│ │ hashcode │ age │001│ 未锁定 │
│ └────────────┴─────┴──┴──┴──┘ │
├──────────────────────────────────────────────────┤
│ 偏向锁 (101) │
│ ┌────────────┬─────┬──┬──┬──┐ │
│ │ 线程ID │epoch│101│ 偏向锁 │
│ └────────────┴─────┴──┴──┴──┘ │
├──────────────────────────────────────────────────┤
│ 轻量级锁 (00) │
│ ┌────────────────────────────┬──┐ │
│ │ 栈中锁记录指针 │00│ 轻量级锁 │
│ └────────────────────────────┴──┘ │
├──────────────────────────────────────────────────┤
│ 重量级锁 (10) │
│ ┌────────────────────────────┬──┐ │
│ │ Monitor对象指针 │10│ 重量级锁 │
│ └────────────────────────────┴──┘ │
└──────────────────────────────────────────────────┘

2️⃣ 锁升级过程(重点!)


                    锁升级路径

无锁状态 偏向锁 轻量级锁 重量级锁
│ │ │ │
│ 第一次访问 │ 有竞争 │ 竞争激烈 │
├──────────────→ ├──────────────→ ├──────────────→ │
│ │ │ │
│ │ CAS失败 │ 自旋失败 │
│ │ │ │

🚶 一个人 🚶 还是一个人 🚶🚶 两个人 🚶🚶🚶 一群人
走路 (偏向这个人) 抢着走 排队走

详细解释:


阶段1:无锁 → 偏向锁


// 第一次有线程访问synchronized块
Thread-1第一次进入:
1. 对象处于无锁状态
2. Thread-1通过CAS在Mark Word中记录自己的线程ID
3. 成功!升级为偏向锁,偏向Thread-1
4. 下次Thread-1再来,发现Mark Word里是自己的ID,直接进入!
(就像VIP通道,不用检查)✨

生活比喻:
你第一次去常去的咖啡店☕,店员记住了你的脸。
下次你来,店员一看是你,直接给你做你的老口味,不用问!

阶段2:偏向锁 → 轻量级锁


Thread-2也想进入:
1. 发现偏向锁偏向的是Thread-1
2. Thread-1已经退出了,撤销偏向锁
3. 升级为轻量级锁
4. Thread-2通过CAS在栈帧中创建Lock Record
5. CAS将对象头的Mark Word复制到Lock Record
6. CAS将对象头指向Lock Record
7. 成功!获取轻量级锁 🎉

生活比喻:
咖啡店来了第二个客人,店员发现需要排队系统了。
拿出号码牌,谁先抢到谁先点单(自旋CAS)🎫

阶段3:轻量级锁 → 重量级锁


Thread-3、Thread-4、Thread-5也来了:
1. 多个线程竞争,CAS自旋失败
2. 自旋一定次数后,升级为重量级锁
3. 没抢到的线程进入阻塞队列
4. 需要操作系统介入,线程挂起(park)😴

生活比喻:
咖啡店人太多了!需要叫号系统 + 座位等待区。
没叫到号的人坐下来等,不用一直站着抢(操作系统介入)🪑

3️⃣ 字节码层面


public synchronized void method() {
System.out.println("hello");
}

字节码:


public synchronized void method();
descriptor: ()V
flags: ACC_PUBLIC, ACC_SYNCHRONIZED 看这里!方法标记
Code:
stack=2, locals=1, args_size=1
0: getstatic #2
3: ldc #3
5: invokevirtual #4
8: return

同步块字节码:


public void method() {
synchronized(this) {
System.out.println("hello");
}
}

public void method();
Code:
0: aload_0
1: dup
2: astore_1
3: monitorenter 进入monitor
4: getstatic #2
7: ldc #3
9: invokevirtual #4
12: aload_1
13: monitorexit 退出monitor
14: goto 22
17: astore_2
18: aload_1
19: monitorexit 异常时也要退出
20: aload_2
21: athrow
22: return

Round 2: ReentrantLock的底层实现


基于AQS(AbstractQueuedSynchronizer)实现:


// ReentrantLock内部
public class ReentrantLock {
private final Sync sync;

// 抽象同步器
abstract static class Sync extends AbstractQueuedSynchronizer {
// ...
}

// 非公平锁实现
static final class NonfairSync extends Sync {
final void lock() {
// 先CAS抢一次
if (compareAndSetState(0, 1))
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1); // 进入AQS队列
}
}

// 公平锁实现
static final class FairSync extends Sync {
final void lock() {
acquire(1); // 直接排队,不插队
}
}
}

数据结构:


ReentrantLock

├─ Sync (继承AQS)
│ ├─ state: int (0=未锁,>0=重入次数)
│ └─ exclusiveOwnerThread: Thread (持锁线程)

└─ CLH队列
Head → Node1 → Node2 → Tail
↓ ↓
Thread2 Thread3
(等待) (等待)

三、功能对比大战 ⚔️


🏁 功能对比表


功能synchronizedReentrantLock胜者
加锁方式自动手动lock/unlocksynchronized ✅
释放方式自动(异常也会释放)必须手动finallysynchronized ✅
公平锁不支持支持公平/非公平ReentrantLock ✅
可中断不可中断lockInterruptibly()ReentrantLock ✅
尝试加锁不支持tryLock()ReentrantLock ✅
超时加锁不支持tryLock(timeout)ReentrantLock ✅
Condition只有一个wait/notify可多个ConditionReentrantLock ✅
性能(JDK6+)优化后差不多差不多平局 ⚖️
使用难度简单复杂,易出错synchronized ✅
锁信息不易查看getQueueLength()等ReentrantLock ✅

🎯 详细功能对比


1️⃣ 可中断锁


// ❌ synchronized不可中断
Thread t = new Thread(() -> {
synchronized(lock) {
// 即使调用t.interrupt(),这里也不会响应
while(true) {
// 死循环
}
}
});

// ✅ ReentrantLock可中断
Thread t = new Thread(() -> {
try {
lock.lockInterruptibly(); // 可响应中断
// ...
} catch (InterruptedException e) {
System.out.println("被中断了!");
}
});
t.start();
Thread.sleep(100);
t.interrupt(); // 可以中断!

2️⃣ 尝试加锁


// ❌ synchronized没有tryLock
synchronized(lock) {
// 要么拿到锁,要么一直等
}

// ✅ ReentrantLock可以尝试
if (lock.tryLock()) { // 尝试获取,不阻塞
try {
// 拿到锁了
} finally {
lock.unlock();
}
} else {
// 没拿到,去做别的事
System.out.println("锁被占用,我去干别的");
}

// ✅ 还支持超时
if (lock.tryLock(3, TimeUnit.SECONDS)) { // 等3秒
try {
// 拿到了
} finally {
lock.unlock();
}
} else {
// 3秒还没拿到,放弃
System.out.println("等太久了,不等了");
}

3️⃣ 公平锁


// ❌ synchronized只能是非公平锁
synchronized(lock) {
// 后来的线程可能插队
}

// ✅ ReentrantLock可选公平/非公平
ReentrantLock fairLock = new ReentrantLock(true); // 公平锁
ReentrantLock unfairLock = new ReentrantLock(false); // 非公平锁(默认)

公平锁 vs 非公平锁:


非公平锁(吞吐量高):
Thread-1持锁 → Thread-2排队 → Thread-3排队

释放锁!

Thread-4刚好来了,直接抢!(插队)✂️
虽然Thread-2先来,但Thread-4先抢到

公平锁(先来后到):
Thread-1持锁 → Thread-2排队 → Thread-3排队

释放锁!

Thread-4来了,但要排队到最后!
Thread-2先到先得 ✅

4️⃣ 多个条件变量


// ❌ synchronized只有一个等待队列
synchronized(lock) {
lock.wait(); // 只有一个等待队列
lock.notify(); // 随机唤醒一个
}

// ✅ ReentrantLock可以有多个Condition
ReentrantLock lock = new ReentrantLock();
Condition notFull = lock.newCondition(); // 条件1:未满
Condition notEmpty = lock.newCondition(); // 条件2:非空

// 生产者
lock.lock();
try {
while (queue.isFull()) {
notFull.await(); // 等待"未满"条件
}
queue.add(item);
notEmpty.signal(); // 唤醒"非空"条件的线程
} finally {
lock.unlock();
}

// 消费者
lock.lock();
try {
while (queue.isEmpty()) {
notEmpty.await(); // 等待"非空"条件
}
queue.remove();
notFull.signal(); // 唤醒"未满"条件的线程
} finally {
lock.unlock();
}

四、性能对决 🏎️


JDK 1.5时代:ReentrantLock完胜


JDK 1.5性能测试(100万次加锁):
synchronized: 2850ms 😓
ReentrantLock: 1200ms 🚀

ReentrantLock快2倍多!

JDK 1.6之后:synchronized反击!


JDK 1.6对synchronized做了大量优化:



  • ✅ 偏向锁(Biased Locking)

  • ✅ 轻量级锁(Lightweight Locking)

  • ✅ 自适应自旋(Adaptive Spinning)

  • ✅ 锁粗化(Lock Coarsening)

  • ✅ 锁消除(Lock Elimination)


JDK 1.8性能测试(100万次加锁):
synchronized: 1250ms 🚀
ReentrantLock: 1200ms 🚀

几乎一样了!

优化技术解析


1️⃣ 偏向锁


// 同一个线程反复进入
for (int i = 0; i < 1000000; i++) {
synchronized(obj) {
// 偏向锁:第一次CAS,后续直接进入
// 性能接近无锁!✨
}
}

2️⃣ 锁消除


public String concat(String s1, String s2) {
// StringBuffer是线程安全的,有synchronized
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb.toString();
}

// JVM发现sb是局部变量,不可能有竞争
// 自动消除StringBuffer内部的synchronized!
// 性能大幅提升!🚀

3️⃣ 锁粗化


// ❌ 原代码:频繁加锁解锁
for (int i = 0; i < 1000; i++) {
synchronized(obj) {
// 很短的操作
}
}

// ✅ JVM优化后:锁粗化
synchronized(obj) { // 把锁提到循环外
for (int i = 0; i < 1000; i++) {
// 很短的操作
}
}

五、使用场景推荐 📝


优先使用synchronized的场景


1️⃣ 简单的同步场景


// 简单的计数器
private int count = 0;

public synchronized void increment() {
count++;
}

2️⃣ 方法级别的同步


public synchronized void method() {
// 整个方法同步,简单明了
}

3️⃣ 不需要高级功能


// 只需要基本的互斥,不需要tryLock、Condition等
synchronized(lock) {
// 业务代码
}

优先使用ReentrantLock的场景


1️⃣ 需要可中断的锁


// 可以响应中断,避免死锁
lock.lockInterruptibly();

2️⃣ 需要尝试加锁


// 拿不到锁就去做别的事
if (lock.tryLock()) {
// ...
}

3️⃣ 需要公平锁


// 严格按照先来后到
ReentrantLock fairLock = new ReentrantLock(true);

4️⃣ 需要多个条件变量


// 生产者-消费者模式
Condition notFull = lock.newCondition();
Condition notEmpty = lock.newCondition();

5️⃣ 需要获取锁的信息


// 查看等待的线程数
int waiting = lock.getQueueLength();
// 查看是否有线程在等待
boolean hasWaiters = lock.hasQueuedThreads();

六、常见坑点 ⚠️


坑1:ReentrantLock忘记unlock


// ❌ 危险!如果中间抛异常,永远不会释放锁
lock.lock();
doSomething(); // 可能抛异常
lock.unlock(); // 不会执行!💣

// ✅ 正确写法
lock.lock();
try {
doSomething();
} finally {
lock.unlock(); // 一定会执行
}

坑2:synchronized锁错对象


// ❌ 每次都是新对象,不起作用!
public void method() {
synchronized(new Object()) { // 💣 错误!
// 相当于没加锁
}
}

// ✅ 正确写法
private final Object lock = new Object();
public void method() {
synchronized(lock) {
// ...
}
}

坑3:锁的粒度太大


// ❌ 锁的范围太大,性能差
public synchronized void method() { // 整个方法都锁住
doA(); // 不需要同步
doB(); // 需要同步
doC(); // 不需要同步
}

// ✅ 缩小锁范围
public void method() {
doA();
synchronized(lock) {
doB(); // 只锁需要的部分
}
doC();
}

七、面试应答模板 🎤


面试官:synchronized和ReentrantLock有什么区别?


你的回答:


主要从实现层面和功能层面两个角度对比:


实现层面:



  1. synchronized是JVM层面的,基于monitor机制,通过对象头的Mark Word实现

  2. ReentrantLock是API层面的,基于AQS(AbstractQueuedSynchronizer)实现


功能层面,ReentrantLock更强大:



  1. 可中断:lockInterruptibly()可响应中断

  2. 可尝试:tryLock()非阻塞获取锁

  3. 可超时:tryLock(time)超时放弃

  4. 公平锁:可选择公平或非公平

  5. 多条件:支持多个Condition

  6. 可监控:可获取等待线程数等信息


性能对比:



  • JDK 1.6之前ReentrantLock性能更好

  • JDK 1.6之后synchronized做了大量优化(偏向锁、轻量级锁、锁消除、锁粗化),性能差不多

  • synchronized优化包括:无锁→偏向锁→轻量级锁→重量级锁的升级路径


使用建议:



  • 简单场景优先synchronized(代码简洁,自动释放)

  • 需要高级功能时用ReentrantLock(可中断、超时、公平锁等)


举个例子:
如果只是简单的计数器,用synchronized即可。但如果是银行转账系统,需要可中断、可超时,就应该用ReentrantLock。


八、总结 🎯


选择决策树:
需要同步?

Yes

┌─────────────┴─────────────┐
│ │
简单场景 复杂场景
(计数器、缓存等) (可中断、超时等)
│ │
synchronized ReentrantLock
│ │
✅ 简单 ✅ 功能强
✅ 自动释放 ⚠️ 需手动
✅ 性能好 ✅ 灵活

记忆口诀:



简单场景synchronized,

复杂需求ReentrantLock,

性能现在差不多,

根据场景来选择!🎵



最后一句话:

synchronized是"自动挡"🚗,简单好用;

ReentrantLock是"手动挡"🏎️,灵活强大!


作者:用户6854537597769
来源:juejin.cn/post/7563822304766427172
收起阅读 »

Compose 重组优化

1、重组优化的核心思想 定义:状态变化时,让尽可能少的可组合函数以尽可能快的速度执行完成。 关键词:尽可能少、尽可能快 2、常见重组优化   其实在前面介绍Compose的时候,我们也多少提到过一些重组优化,这里主要是将前面提到过的重组优化、实际开发中常见...
继续阅读 »

1、重组优化的核心思想



  • 定义:状态变化时,让尽可能少的可组合函数以尽可能快的速度执行完成。

  • 关键词:尽可能少、尽可能快


2、常见重组优化


  其实在前面介绍Compose的时候,我们也多少提到过一些重组优化,这里主要是将前面提到过的重组优化、实际开发中常见错误怎么重组优化以及Compose中还提供了哪些重组优化的API做一次汇总,帮助我们刚上手时“避坑”。


  2.1 控制重组范围:


让状态变化只影响“必要区域”


 2.1.1 拆分复杂的可组合函数: 避免“牵一发而动全身”



  • 优化前:因name对象未被缓存,每次重组后都会创建新的对象,进而导致名称Text()在每次点击后都会重组。


点击操作 -> age累加 -> Test()重组 -> 重新创建name -> name Text()重组。


@Composable
fun Test(){
val name = "Hello world!!"
//可观察状态
var age by remember { mutableIntStateOf(18) }

Column {
//名称
Text(text = name)
//年龄
Text(
modifier = Modifier.clickable(onClick = { //点击后累加
age++
} ),
text = "$age",)
}
}


  • 优化后:点击操作后,与age依赖的函数只有AgeTest(),Test()和NameTest()函数不受其影响。


@Composable
fun Test(){
Column {
//名称
NameTest()
//年龄
AgeTest()
}
}

@Composable
fun NameTest() {
Text(text = "Hello world!!")
}
@Composable
fun AgeTest() {
//可观察状态
var age by remember { mutableIntStateOf(18) }
Text(
modifier = Modifier.clickable(onClick = { //点击后累加
age++
} ),
text = "$age",)
}

 2.1.2 列表用key控制重组颗粒度:避免“批量无效重组”


在使用LazyColumn/LazyRow 未指定 key 时,默认用 “列表索引” 作为标识,列表增删 / 排序时会导致大量无关项重组。


如果我们没有指定key,那么默认key就是index,假如我们删除第一项(index =0),会导致后续所有的索性变更(即都会左移:2->1,1->0),从而导致全部重组。--此时后面item无内容变化


指定key后,Compose识别后面item无内容变化,不会重组。--重组数量从“N -> 1”


@Composable
fun ProductList(products: List<Product>) {
LazyColumn(modifier = Modifier.fillMaxSize()) {
// 指定 key 为 product.id:唯一标识每个列表项
items(
items = products,
key = { product -> product.id } // 核心优化:用唯一 ID 替代索引
) { product ->
ShopItem(
product = product,
isFavorite = false,
onFavoriteClick = {}
)
Divider()
}
}
}

  2.2 避免无效重组:    让“不变的状态”不发生重组


Compose 简介和基础使用1 简介 1.1 背景 2019 年 5 月(首次亮相)在 Google I/O 大会上, - 掘金
中2.4.5.2 保存界面状态方式章节中提到过


特性rememberrememberSaveable
重组时是否保留是(核心功能)是(继承 remember 的能力)
重建时是否保留否(状态随组件实例销毁)是(通过 Bundle 持久化)
适用数据类型任意类型基本类型、可序列化类型(或需自定义保存逻辑)
性能开销低(内存级保存)略高(涉及 Bundle 读写)
典型使用场景临时状态(如列表展开 / 折叠)需持久化的用户输入(如表单、设置)

  2.2.1 remember


remember 是Compose API提供的缓存接口,避免每次重组时重新创建对象或者重新计算。
如下,“val showName = "Hello world!!--$name"”写法上面分析过,每次点击后name Text()都会发生重组。通过remember 缓存,那么只有name发生变化时name Text()才会重组。


@Composable
fun Test(name: String){
//状态
// val showName = "Hello world!!--$name"
//remember 普通缓存
val showName by remember(name) { mutableStateOf("Hello world!!--$name") }
var age by remember { mutableIntStateOf(18) }

// rememberSaveable 跨配置状态缓存
val showName by rememberSaveable(name) { mutableStateOf("Hello world!!--$name") }
var age by rememberSaveable { mutableIntStateOf(18) }

Column {
//名称
Text(text = showName)

//年龄
Text(
modifier = Modifier.clickable(onClick = { //点击后累加
age++
} ),
text = "$age",)
}
}

  2.2.2 rememberSaveable


rememberSaveable 是Compose API提供的缓存接口,当状态需要在配置变更(如屏幕旋转、语言切换)后保留时,使用 rememberSaveable 可以实现跨配置的状态缓存,避免状态丢失和不必要的重新计算。


如上示例假设showName、age需要在屏幕旋转、语言切换后保留之前状态,那么就可以用rememberSaveable 缓存。


  2.2.3 rememberUpdatedState


副作用生命周期大于状态的变化周期(例如副作用中延迟、循环等),且副作用中需要获取最新的状态值。
分析:- LaunchedEffect(Unit)副作用中使用Unit表示没监听任何状态,所以只在首次重组时创建启动协程,后续重组不会再重新创建新的启动协程,并且旧的协程也不会被打断。



  • reportMessage 是可观察状态,内部直接通过副作用使用时,协程捕获到的是这个状态的引用,所以修改后内部延迟也能打印最新的值。而通过参数传递时传递的是具体的值(String),所以不使用rememberUpdatedState只能打印旧值,使用后rememberUpdatedState可以监听值的变化,保证副作用中打印的是最新的值。


@Composable
fun ReportMessageScreen() {
// 父组件管理的消息状态,可动态更新
var reportMessage by remember { mutableStateOf("初始消息") }

// 子组件:负责延迟上报消息
MessageReporter(currentMessage = reportMessage)

LaunchedEffect(Unit) {
delay(5000) // 延迟3秒上报
// 问题:即使currentMessage已更新,仍会上报初始值
Log.d("Report", "内部上报: $reportMessage")
}

// 按钮:更新消息内容
Button(onClick = { reportMessage = "用户修改后的新消息" } ) {
Text(reportMessage)
}

}

@Composable
fun MessageReporter(currentMessage: String) {
Log.d("Report","MessageReporter----start---")
// 错误做法:不使用rememberUpdatedState
LaunchedEffect(Unit) {
delay(5000) // 延迟3秒上报
// 问题:即使currentMessage已更新,仍会上报初始值
Log.d("Report", "错误上报: $currentMessage")
}

// 正确做法:必须使用rememberUpdatedState
val latestMessage by rememberUpdatedState(currentMessage)
LaunchedEffect(Unit) {
delay(10000) // 延迟3秒上报
// 确保上报的是最新值
Log.d("Report", "正确上报: $latestMessage")
}
Log.d("Report","MessageReporter----end---")
}

//日志打印
//初始化
2025-09-11 20:27:26.742 6847-6847 D MessageReporter----start---
2025-09-11 20:27:26.749 6847-6847 D MessageReporter----end---
//点击后
2025-09-11 20:27:29.077 6847-6847 D MessageReporter----start---
2025-09-11 20:27:29.077 6847-6847 D MessageReporter----end---
//延迟消息
2025-09-11 20:27:32.096 6847-6847 D 错误上报: 初始消息
2025-09-11 20:27:37.098 6847-6847 D 正确上报: 用户修改后的新消息

  2.2.4 derivedStateOf


通过派生状态的结果去重,避免因 “依赖状态频繁变化但结果不变” 导致的重组。


示例:只有当userName和password都不为空时才需要重组按钮


@Composable
fun LoginScreen() {
// 状态源1:用户名输入
var username by remember { mutableStateOf("") }
// 状态源2:密码输入
var password by remember { mutableStateOf("") }

//错误写法,每次输入username或者password时,isLoginEnabled都会导致按钮重组
//val isLoginEnabled = username.isNotEmpty() && password.isNotEmpty()

//正确写法 用derivedStateOf组合两个状态,判断按钮是否可点击
val isLoginEnabled by remember {
derivedStateOf {
// 同时依赖username和password两个状态
username.isNotEmpty() && password.isNotEmpty()
}
}

// 依赖isLoginEnabled的按钮
Button(
onClick = { /* 登录逻辑 */ },
enabled = isLoginEnabled
) {Text("登录")}
}

   2.2.5 标记稳定类型 :@Stable/@Immutable


自定义数据类未标记稳定类型,Compose 无法判断其是否变化,可能会 “过度谨慎” 地触发重组。
原因:Compose 默认认为 “未标记的自定义类是不稳定的”,即使所有属性都是val。


        2.2.5.1 @Stable/@Immutable 使用

所以如下未标记时,在Test()重组时,即使person对象本身和name、age没有发生变化,都可能导致name Text()或者age Text()发生重组(过度谨慎重组)


优化方法:添加@Stable/@Immutable标记,防止Compose因过度谨慎带来的不必要的重组。


// 未标记稳定类型的自定义数据类
data class Person(val name: String, val age: Int)

//@Immutable(完全不可变,name和age都是val不可变类型)
//data class Person(val name: String, val age: Int)

//@Stable(稳定类型(不一定完全不可变),name是var可变,age是不可变类型)
//data class Person(var name: String, val age: Int)
@Composable
fun Test(person: Person){
Column {
//名称
Text(text = person.name)

//年龄
Text(text = "${person.age}",)
}
}

        2.2.5.2 @Stable/@Immutable 区别


  • @Immutable 标记的完全不可变的类,只要引用没变Compose就认为内部数据一定没变,不需要重组。特性:


    类的所有属性都是val(不可变)



//正确
@Immutable
data class Book(
val id: Int, // val 不可变
val title: String // val 不可变
)

// 错误:包含 var 属性
@Immutable
data class Book(
val id: Int,
var title: String // var 可变,违反条件
)

// Book 对象做为Composable入参
@Composable
fun Test(book: Book){
Column {
//名称
Text(text = person.title)
}
}

所有属性类型本身也是不可变的(或被@Immutable标记)


// 自定义不可变类(满足 @Immutable 条件)
@Immutable
data class Author(
val name: String, // String 是不可变类型
val age: Int // Int 是不可变类型
)

// 引用 @Immutable 类型的属性
@Immutable
data class Book(
val id: Int,
val title: String,
// Author 被 @Immutable 标记,满足条件
// 如果Author 没有被 @Immutable 标记,则不满足条件
val author: Author
)

// 错误:属性类型是可变的 MutableList
@Immutable
data class Book(
val id: Int,
val tags: MutableList<String> // MutableList 是可变类型,违反条件
)

类本身没有任何可修改状态(包括间接引用对象)


// 最底层:不可变类型
@Immutable
data class Address(
val city: String,
val street: String
)

// 中间层:引用不可变类型
@Immutable
data class User(
val name: String,
val address: Address // Address 是 @Immutable 类型
)

// 顶层:引用不可变类型
@Immutable
data class Order(
val id: Int,
val user: User // User 是 @Immutable 类型
)


  • @Stable 标记稳定的类(可存在可变属性),引用没变且内部状态能被追踪,Compose就能精准判断只有内部状态发生变化时才触发重组,避免无效重组。特性:



    • 类中存在可变属性var

    • 可变属性必须是可被追踪的




@Stable
class User {
// var age = 18 普通变量,不可追踪、被观察,变化后不会触发重组
var age by mutableStateOf(18) // 变化可追踪
}

// 用 User 作为入参的 Composable
@Composable
fun UserInfo(user: User) {
Text("年龄:${user.age}") // 依赖 user.age
}

也就是说要么引用变了(肯定要检查并重组),要么内部状态变了(Compose 能感知到),不会出现引用和内部状态都不变的情况下重组了,也不会出现 “状态变了但 Compose 不知道” 的情况。因此 Compose 可以放心地优化重组逻辑,既不会漏更 UI,也不会做无用功。


       2.2.5.3 总结

使用@Stable/@Immutable标记自定义类目的:因Compose 默认认为 “未标记的自定义类是不稳定的”,可能会发生”过度谨慎“重组。- 添加@Immutable注解完全不可变的类,Compose只有引用对象发生变化时需要重组,自定义类中不可存在可变属性。



  • 添加@Stable注解稳定的类(可存在可变属性),Compose只有引用对象发生变化或内部状态发生变化时需要重组,自定义类中允许存在可变属性。


    2.2.6 snapshotFlow      高频防抖


@Composable
fun SearchInput() {
var searchQuery by remember { mutableStateOf("") }

// 错误:每次输入字符都会触发重组,直接执行搜索,高频调用
//LaunchedEffect(searchQuery) {
// // 模拟搜索网络请求
// Log.d("Search", "搜索:$searchQuery")
// }

// 正确:将状态转为Flow,添加300ms防抖,仅停止输入后执行
LaunchedEffect(Unit) {
snapshotFlow { searchQuery } // 转换Compose状态为Flow
.debounce(300) // 防抖:300ms内无变化才继续
.collect { query ->
if (query.isNotEmpty()) {
Log.d("Search", "搜索:$query") // 仅停止输入后执行
}
}
}


TextField(
value = searchQuery,
onValueChange = { searchQuery = it },
label = { Text("输入搜索内容") }
)
}

  2.3 优化重组效率:


让必须重组的过程 “更快”


    2.3.1 减少可组合函数内的耗时操作


可组合函数应只做 “描述 UI” 的轻量操作,禁止在其中直接执行 IO、网络请求、复杂计算。


@Composable
fun UserProfile(userId: String) {
//错误示例,在组合函数中直接请求网络,每次重组时都会发起网络请求
//fetchDataFromNetwork() // 网络请求(副作用)

var user by remember { mutableStateOf<User?>(null) }

// 副作用:网络请求,依赖 userId(userId 变化时会重新执行)
LaunchedEffect(userId) {
// 耗时操作放在协程中,不阻塞主线程
user = api.fetchUser(userId) // 网络请求(副作用)
}

if (user != null) {
Text("Name: ${user?.name}")
} else {
CircularProgressIndicator()
}
}

    2.3.2 避免在重组中创建新对象


每次重组时创建新对象(如Lambda)会被 Compose 视为 “参数变化”,触发子组件重组。      温故而知新,之前实际开发中也都没注意到这些。- Lambda


//错误示例
@Composable
fun UserProfile(user: User) {
// 每次重组都会创建新的 Lambda 实例
Button(onClick = {
// 处理点击事件
navigateToUserDetail(user.id)
}) {
Text("查看详情")
}
}

//正确示例
@Composable
fun UserProfile(user: User) {
// 无依赖的 remember,仅在首次组合时创建一次 Lambda
val onClick = remember {
{ navigateToUserDetail(user.id) }
}

Button(onClick = onClick) {
Text("查看详情")
}
}
```
```

作者:用户06090525522
来源:juejin.cn/post/7559435122451693622
收起阅读 »

Compose 页面沉浸式体验适配

沉浸式 所谓沉浸式就是适配状态栏和导航栏,使其和应用内容融为一体,有两种方式: 全屏展示应用,隐藏掉状态栏和导航栏,达到沉浸式效果; 将状态栏和导航栏设置为透明,应用页面内容的颜色延伸到屏幕边缘,注意这里是内容颜色不是内容本身。 实现方案 创建一个 And...
继续阅读 »

沉浸式


所谓沉浸式就是适配状态栏和导航栏,使其和应用内容融为一体,有两种方式:



  • 全屏展示应用,隐藏掉状态栏和导航栏,达到沉浸式效果;

  • 将状态栏和导航栏设置为透明,应用页面内容的颜色延伸到屏幕边缘,注意这里是内容颜色不是内容本身。


实现方案


创建一个 Android Compose 项目,会默认生成 MainActivity 的代码:


class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
ImmersiveDemoTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
Greeting(
name = "Android",
modifier = Modifier.padding(innerPadding)
)
}
}
}
}
}

enableEdgeToEdge


在 onCreate 中会默认调用 enableEdgeToEdge(),这个方法是 ComponentActivity 的拓展方法,用来将 Activity 的内容延展到边缘,将状态栏设置为透明,导航栏根据导航模式呈现不同的效果,为这个 Activity 添加一个灰色背景,效果如下:


Screenshot_1729824579.png


Screenshot_1729822985.png


Screenshot_1729824516.png


这是三种导航模式的显示效果,导航模式可以在设置中更改:


Screenshot_1729822926.png


可以看出三种导航模式显示效果略有不同,双按钮导航和三按钮导航模式下,导航栏会有系统配置的蒙层。
而手势导航模式下,Activity 内容的背景是延伸到状态栏和导航栏的。


enableEdgeToEdge() 是 ComponentActivity 的拓展方法:


/**
* 对这个 ComponentActivity 开启边到边的显示
*
* 要使用默认样式进行设置,在你的 Activity's onCreate 方法中调用这个方法:
* ```
* override fun onCreate(savedInstanceState: Bundle?) {
* enableEdgeToEdge()
* super.onCreate(savedInstanceState)
* ...
* }
* ```
*
* 默认样式会在系统能够强制实施对比度的时候(在 API 29 及以上版本),把系统栏设置为透明背景。
* 在旧的平台上(只有 三按钮导航、双按钮导航模式),会应用一个类似的遮光层以确保与系统栏有对比度。
* See [SystemBarStyle] for more customization options.
*
* @param statusBarStyle The [SystemBarStyle] for the status bar.
* @param navigationBarStyle The [SystemBarStyle] for the navigation bar.
*/

@JvmName("enable")
@JvmOverloads
fun ComponentActivity.enableEdgeToEdge(
statusBarStyle: SystemBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT)
,
navigationBarStyle: SystemBarStyle = SystemBarStyle.auto(DefaultLightScrim, DefaultDarkScrim)
) {
val view = window.decorView
val statusBarIsDark = statusBarStyle.detectDarkMode(view.resources)
val navigationBarIsDark = navigationBarStyle.detectDarkMode(view.resources)
val impl = Impl ?: if (Build.VERSION.SDK_INT >= 29) {
EdgeToEdgeApi29()
} else if (Build.VERSION.SDK_INT >= 26) {
EdgeToEdgeApi26()
} else if (Build.VERSION.SDK_INT >= 23) {
EdgeToEdgeApi23()
} else if (Build.VERSION.SDK_INT >= 21) {
EdgeToEdgeApi21()
} else {
EdgeToEdgeBase()
}.also { Impl = it }
impl.setUp(
statusBarStyle, navigationBarStyle, window, view, statusBarIsDark, navigationBarIsDark
)
}

这个方法的注释中也描述三按钮和双按钮导航模式会有遮光层。


SystemBarStyle


enableEdgeToEdge() 方法中无论是导航栏还是状态栏的 Style 都是 SystemBarStyle 类型,SystemBarStyle 提供默认的系统风格,并且具有自动监测 dark 模式的能力。


SystemBarStyle 源码大致如下:


/**
* [enableEdgeToEdge] 中使用的状态栏或导航栏的样式。
*/

class SystemBarStyle private constructor(
private val lightScrim: Int,
internal val darkScrim: Int,
internal val nightMode: Int,
internal val detectDarkMode: (Resources) -> Boolean
) {

companion object {
@JvmStatic
@JvmOverloads
fun auto(
@ColorInt lightScrim: Int,
@ColorInt darkScrim: Int,
detectDarkMode: (Resources) -> Boolean = { resources ->
(resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK)
==
Configuration.UI_MODE_NIGHT_YES
}
): SystemBarStyle

@JvmStatic
fun dark(@ColorInt scrim: Int): SystemBarStyle

@JvmStatic
fun light(@ColorInt scrim: Int, @ColorInt darkScrim: Int): SystemBarStyle
}

internal fun getScrim(isDark: Boolean) = if (isDark) darkScrim else lightScrim

internal fun getScrimWithEnforcedContrast(isDark: Boolean): Int {
return when {
nightMode == UiModeManager.MODE_NIGHT_AUTO -> Color.TRANSPARENT
isDark -> darkScrim
else -> lightScrim
}
}
}

SystemBarStyle 提供了三个初始化方法,auto、dark、light,auto,三个方法的行为各不相同。


SystemBarStyle.auto


写个例子:


class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge(
navigationBarStyle = SystemBarStyle.dark(Color.Red.toArgb()) // set color for navigationBar
)
setContent {
ImmersiveDemoTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
Spacer(modifier = Modifier.fillMaxSize().background(Color.Cyan))
Greeting(
name = "Android"
modifier = Modifier.padding(innerPadding)
)
}
}
}
}
}

效果如下:


Screenshot_1729824579.png


Screenshot_1729824483.png


Screenshot_1729822956.png


在 API 级别 29 及以上,auto 方法在手势导航的情况下是透明的,设置的颜色不会生效。


在三按钮和双按钮导航模式下,系统将自动应用默认的遮光层。请注意,指定的颜色都不会被使用。在 API 级别 28 及以下,导航栏将根据暗黑模式是否开启来展示指定的颜色。



  • lightScrim 当应用处于浅色模式时用于背景的遮光层颜色。

  • darkScrim 当应用处于深色模式时用于背景的遮光层颜色。这也用于系统图标颜色始终为浅色的设备。


SystemBarStyle.dark


创建一个新的 [SystemBarStyle] 实例。这种样式始终如一地应用指定的遮光层颜色,而不考虑系统导航模式。参数 scrim 用于背景的遮光层颜色。为了与浅色系统图标形成对比,它应该是深色的。


dark 模式很简单,无论什么导航模式、主题模式,他都显示设置的颜色。


Screenshot_1729828540.png


SystemBarStyle.light


创建一个新的 [SystemBarStyle] 实例。这种样式始终如一地应用指定的遮光层颜色,而不考虑系统导航模式。



  • 参数 scrim 用于背景的遮光层颜色。为了与深色系统图标形成对比,它应该是浅色的。

  • 参数 darkScrim 在系统图标颜色始终为浅色的设备上用于背景的遮光层颜色。它应该是深色的。


与 dark 不同,应用可以强制设置为 light 模式,而不用随系统的主题模式变化而变化,此时 darkScrim 生效。其他情况下使用 scrim。


系统栏背景遮光层


在上面的内容中,我们知道系统会给导航栏和状态栏设置一个遮光层,导航栏和状态栏会随着系统的导航模式和主题模式而变化。


但实际上应用希望呈现沉浸式的效果,就需要无论在上面导航模式、主题模式下都呈现与内容相同的颜色效果,所以需要去掉导航栏和状态栏的遮罩。


当我们什么也不设置,只调用 enableEdgeToEdge() 时,是这样的:


Screenshot_1729835423.png


调用去掉导航栏遮罩效果:


if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// 去掉导航栏遮罩
window.isNavigationBarContrastEnforced = false
}

isNavigationBarContrastEnforced 属性可以关闭强制使用导航栏遮罩,源码如下:


    /**
* 当请求完全透明的背景时,设置系统是否应该确保导航栏有足够的对比度
*
* 如果设置为这个值,系统将确定是否需要一个遮光层来确保导航栏与这个应用的内容有足够的对比度,
* 并相应地设置一个适当的有效的导航栏背景颜色。
*
* 当导航栏颜色具有非零的透明度值时,这个属性的值没有效果。
*
* @see android.R.attr#enforceNavigationBarContrast
* @see #isNavigationBarContrastEnforced
* @see #setNavigationBarColor
*/

public void setNavigationBarContrastEnforced(boolean enforceContrast) {
}

同样地,对于状态栏也有相同的属性:


    /**
* 当请求完全透明的背景时,设置系统是否应该确保栏有足够的对比度
*
* 如果设置为这个值,系统将确定是否需要一个遮光层来确保状态栏与这个应用的内容有足够的对比度,
* 并相应地设置一个适当的有效的导航栏背景颜色。
*
* 当导航栏颜色具有非零的透明度值时,这个属性的值没有效果。
*
* @see android.R.attr#enforceStatusBarContrast
* @see #isStatusBarContrastEnforced
* @see #setStatusBarColor
*/

public void setStatusBarContrastEnforced(boolean ensureContrast) {
}

所以去掉遮光层效果如下:


if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// 去掉导航栏遮罩
window.isNavigationBarContrastEnforced = false
// 去掉状态栏遮罩
window.isStatusBarContrastEnforced = false
}

系统栏前景色


在状态栏和导航栏中有一些图标,比如状态栏中的电量图标、手势导航模式下的导航条图标,这些图标会随着系统主题(dark or light)变化为深色 icon 或是浅色 icon,



  • 当系统为 dark 主题模式下,icon 是浅色的,以和背景达成一种对比效果;

  • 当系统为 light 主题模式下,icon 是深色的。


		/**
* 如果为 true,则将状态栏的前景色更改为浅色,以便可以清晰地读取栏上的项目。
* 如果为 false,则恢复为默认外观。
*
* 此方法在 API 级别小于 23 时没有效果。
*
* 一旦调用此方法,直接修改 “systemUiVisibility” 以更改外观是未定义的行为。
*
* @see #isAppearanceLightStatusBars()
*/

public void setAppearanceLightStatusBars(boolean isLight) {
mImpl.setAppearanceLightStatusBars(isLight);
}

同样地,有对导航栏设置的 API:


    /**
* 如果为 true,则将导航栏的前景色更改为浅色,以便可以清晰地读取栏上的项目。
* 如果为 false,则恢复为默认外观。
*
* 此方法在 API 级别小于 26 时没有效果。
*
* 一旦调用此方法,直接修改 “systemUiVisibility” 以更改外观是未定义的行为。
*
* @see #isAppearanceLightNavigationBars()
*/

public void setAppearanceLightNavigationBars(boolean isLight) {
mImpl.setAppearanceLightNavigationBars(isLight);
}

完整的设置方法:


val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView)
windowInsetsController.isAppearanceLightStatusBars = false
windowInsetsController.isAppearanceLightNavigationBars = false

作者:自动化BUG制造器
来源:juejin.cn/post/7429611142706855948
收起阅读 »

深入理解 JavaScript 报错:TypeError: undefined is not a function

web
深入理解 JavaScript 报错:TypeError: undefined is not a function 在日常的 JavaScript 开发中,几乎每个人都见过这条令人熟悉又头疼的错误信息: 🚀Taimili 艾米莉 ( 一款免费开源的 taimi...
继续阅读 »

深入理解 JavaScript 报错:TypeError: undefined is not a function


在日常的 JavaScript 开发中,几乎每个人都见过这条令人熟悉又头疼的错误信息:


🚀Taimili 艾米莉 ( 一款免费开源的 taimili.com )


艾米莉 是一款优雅便捷的  GitHub Star 管理和加星工具 ,基于 PHP & javascript 构建, 能对github 得 star fork follow watch 管理和提升,最适合github 的深度用户


image.png


作者:开源之眼

链接:juejin.cn/post/755906…

来源:稀土掘金

著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。



TypeError: undefined is not a function



这行报错简短却致命,尤其当代码行数成千上万时,找到问题根源往往需要一点侦探技巧。本文将从原理、常见原因、排查方法和最佳实践四个方面深入讲解这一错误。




一、错误的本质是什么?


首先要知道:

在 JavaScript 中,一切几乎都是对象,包括函数。

当你调用一个变量并在后面加上 () 时,JavaScript 会假设该变量是一个函数对象,并尝试执行它。


let fn;
fn(); // ❌ TypeError: fn is not a function

在上面的例子中,fn 的值是 undefined,但我们却尝试执行它,于是引发了经典错误:



TypeError: undefined is not a function



简单来说:



“你正在试图执行一个并不是函数的东西。”





二、常见的触发场景


让我们来看一些在实际项目中常见的触发情境。


1. 调用未定义的函数


sayHello(); // ❌ TypeError: sayHello is not a function

var sayHello = function() {
console.log("Hello");
};


原因var 声明的变量会提升,但赋值不会。执行到函数调用时,sayHello 还是 undefined



✅ 正确写法:


function sayHello() {
console.log("Hello");
}
sayHello(); // ✅ Hello

或者:


const sayHello = () => console.log("Hello");
sayHello(); // ✅ Hello



2. 调用了对象上不存在的方法


const user = {};
user.login(); // ❌ TypeError: user.login is not a function


原因user 对象没有 login 方法,访问结果是 undefined



✅ 正确做法:


const user = {
login() {
console.log("User logged in");
}
};
user.login(); // ✅ User logged in



3. 第三方库或异步加载未完成


// 某个库尚未加载完成
myLibrary.init(); // ❌ TypeError: myLibrary.init is not a function


原因:脚本加载顺序错误或资源未加载完。



✅ 解决方案:


<script src="mylib.js" onload="initApp()"></script>

或使用现代模块化方式:


import myLibrary from './mylib.js';
myLibrary.init();



4. 被覆盖的函数名


let alert = "Hello";
alert("Hi"); // ❌ TypeError: alert is not a function


原因:内置函数被变量覆盖。



✅ 解决方案:


避免重名:


let message = "Hello";
window.alert("Hi"); // ✅



5. this 指向错误


const obj = {
run() {
console.log("Running");
}
};

const run = obj.run;
run(); // ❌ TypeError: undefined is not a function (在严格模式下)


原因this 丢失导致方法不再属于原对象。



✅ 解决方案:


const boundRun = obj.run.bind(obj);
boundRun(); // ✅ Running

或直接调用:


obj.run(); // ✅ Running



三、排查思路与调试技巧


当遇到这个错误时,不要慌。按照以下步骤排查:


✅ 1. 查看错误堆栈(stack trace)


浏览器控制台一般会指明出错的文件与行号。

打开 DevTools → Console → 点击错误行号,即可定位具体位置。


✅ 2. 打印变量类型


使用 typeof 或 console.log 检查被调用的变量:


console.log(typeof myFunc); // 应该输出 'function'

✅ 3. 检查函数定义顺序


尤其是在使用 var 或异步加载模块时,注意执行顺序。


✅ 4. 检查导入导出是否匹配


在模块化开发中,这类错误经常来自错误的导入:


// ❌ 错误示例
import { utils } from './utils.js';
utils(); // TypeError: utils is not a function

✅ 应确认模块导出方式:


// utils.js
export default function utils() {}

然后正确导入:


import utils from './utils.js';
utils(); // ✅



四、防止 “undefined is not a function” 的最佳实践



  1. 使用 const/let 替代 var — 避免变量提升造成的未定义调用

  2. 模块化代码结构 — 保证依赖先加载

  3. 给函数添加类型校验


    if (typeof fn === 'function') fn();


  4. 启用严格模式或 TypeScript — 提前发现类型问题

  5. 避免覆盖全局对象(如 alertconfirmsetTimeout 等)


作者:开源之眼
来源:juejin.cn/post/7563220648827715610
收起阅读 »

消息队列和事件驱动如何实现流量削峰

消息队列和事件驱动架构不仅是实现流量削峰的关键技术,它们之间更是一种相辅相成、紧密协作的关系。下面这个表格可以帮您快速把握它们的核心联系与分工。 特性消息队列 (Message Queue)事件驱动架构 (Event-Driven Architecture)​...
继续阅读 »

消息队列事件驱动架构不仅是实现流量削峰的关键技术,它们之间更是一种相辅相成、紧密协作的关系。下面这个表格可以帮您快速把握它们的核心联系与分工。


特性消息队列 (Message Queue)事件驱动架构 (Event-Driven Architecture)
核心角色实现事件驱动架构的技术工具传输机制一种架构风格设计模式
主要职责提供异步通信通道,负责事件的存储路由可靠传递定义系统各组件之间通过事件进行交互的规范
与流量削峰关系实现流量削峰的具体手段​(作为缓冲区)流量削峰是其在处理突发流量时的一种自然结果能力体现
协作方式事件驱动架构中,事件的生产与消费通常依赖消息队列来传递事件消息为消息队列的应用提供了顶层设计业务场景

🔌 消息队列:流量削峰的实现工具


消息队列在流量削峰中扮演着“缓冲区”或“蓄水池”的关键角色 。其工作流程如下:



  1. 接收请求​:当突发流量到来时,所有请求首先被作为消息发送到消息队列中暂存,而非直接冲击后端业务处理服务 。

  2. 平滑压力​:后端服务可以按照自身的最佳处理能力,以固定的、可控的速度从消息队列中获取请求并进行处理 。

  3. 解耦与异步​:这使得前端请求的接收和后端业务的处理完全解耦。用户可能瞬间收到“请求已接受”的响应,而实际任务则在后台排队有序执行 。


一个典型的例子是秒杀系统​ 。在短时间内涌入的海量下单请求会被放入消息队列。队列的长度可以起到限制并发数量的作用,超出系统容量的请求可以被快速拒绝,从而保护下游的订单、库存等核心服务不被冲垮,实现削峰填谷 。


🏗️ 事件驱动:流量削峰的指导架构


事件驱动架构是一种从更高层面设计系统交互模式的思想 。当某个重要的状态变化发生时(例如用户下单、订单支付成功),系统会发布一个事件​ 。其他关心此变化的服务会订阅这些事件,并触发相应的后续操作 。这种“发布-订阅”模式天然就是异步的。


在流量削峰的场景下,事件驱动架构的意义在于:



  • 设计上的解耦​:它将“触发动作的服务”和“执行动作的服务”从时间和空间上分离开。下单服务完成核心逻辑后,只需发布一个“订单已创建”的事件,而不需要同步调用库存服务、积分服务等。这本身就为引入消息队列作为事件总线来缓冲流量奠定了基础 。

  • 结果的可达性​:即使某个服务(如积分服务)处理速度较慢,也不会影响核心链路(如扣减库存)。事件会在消息队列中排队,等待积分服务按自己的能力处理,从而实现了服务间的流量隔离和削峰 。


🤝 协同工作场景


消息队列与事件驱动架构协同工作的场景包括:



  • 异步任务处理​:用户注册后,需要发送邮件和短信。注册服务完成核心逻辑后,发布一个“用户已注册”事件到消息队列。邮件服务和短信服务作为订阅者,异步消费该事件,实现异步处理 。

  • 系统应用解耦​:订单系统与库存系统之间通过消息队列解耦。订单系统下单后,将消息写入消息队列即可返回成功,库存系统再根据消息队列中的信息进行库存操作,即使库存系统暂时不可用,也不会影响下单 。

  • 日志处理与实时监控​:使用类似Kafka的消息队列收集应用日志,后续的日志分析、监控报警等服务订阅这些日志流进行处理,解决大量日志传输问题 。


💡 选型与注意事项


在选择和运用这些技术时,需要注意:



  • 技术选型​:不同消息队列有不同特点。​RabbitMQ​ 以消息可靠性见长;Apache Kafka​ 专为高吞吐量的实时日志流和数据管道设计,尤其适合日志处理等场景 ;RocketMQ​ 在阿里内部经历了大规模交易场景的考验 。

  • 潜在挑战​:



    • 复杂性增加​:需要维护消息中间件,并处理可能出现的消息重复、丢失、乱序等问题 。

    • 数据一致性​:异步化带来了最终一致性,需要考虑业务是否能接受 。

    • 系统延迟​:请求需要排队处理,用户得到最终结果的时间会变长,不适合所有场景。




作者:IT橘子皮
来源:juejin.cn/post/7563511245087506486
收起阅读 »

kotlin协程 容易被忽视的CompletableDeferred

CompletableDeferred是一个 可手动完成 的 Deferred, 它实现了 Deferred(可以 await()),也提供了 complete(value) / completeExceptionally(e) / cancel() 等方法,...
继续阅读 »

CompletableDeferred是一个 可手动完成 的 Deferred, 它实现了 Deferred(可以 await()),也提供了 complete(value) / completeExceptionally(e) / cancel() 等方法,并由外部触发结果。它创建后可能处于未完成状态,任意线程或协程都能把它完成或失败,等待方使用 await() 来获取结果。


一、与Deferred、suspendCancellableCoroutine和Channel的区别



  • vs Deferred(由 async 返回):async 的 Deferred 是由协程体自己完成;CompletableDeferred 允许外部完成。

  • vs suspendCancellableCoroutine:两者都能把回调桥接到协程,suspendCancellableCoroutine 适合一次性封装回调;CompletableDeferred 更适合“外部多方在不同时间完成/通知”的场景(比如事件总线、跨协程信号、测试用的手动完成)。

  • vs Channel:Channel 是多次消息传递而CompletableDeferred 是单次结果(一次性)。


二、经典应用场景


1、桥接回调


一般的桥接回调推荐用suspendCancellableCoroutine,但如果外部多方的桥接推荐用CompletableDeferred。下面是一个简单的网络请求的例子。


suspend fun Call.awaitResponse(): Response = suspendCancellableCoroutine { cont ->
    enqueue(object : Callback {
        override fun onFailure(call: Call, e: IOException) {
            if (cont.isActive) cont.resumeWithException(e)
        }

        override fun onResponse(call: Call, response: Response) {
//response记得use{}关闭
            if (cont.isActive) cont.resume(response)
        }
    })
    cont.invokeOnCancellation { cancel() }
}

如果你想用 CompletableDeferred(当回调可能多次触发或在别处完成时非常好):


fun makeRequest(): CompletableDeferred<String> {
val deferred = CompletableDeferred<String>()
httpClient.newCall(request).enqueue(object: Callback {
override fun onFailure(call: Call, e: IOException) {
deferred.completeExceptionally(e)
}
override fun onResponse(call: Call, response: Response) {
val s = response.body?.string() ?: ""
deferred.complete(s)
response.close()
}
})
return deferred
}

// 使用处
lifecycleScope.launch {
try {
val result = makeRequest().await()
} catch (e: Throwable) { ... }
}

上面的桥接肯定推荐suspendCancellableCoroutine来完成,只是为了展示CompletableDeferred也可以完成回调的桥接。下面看一个多处回调桥接的例子:


// 使用 CompletableDeferred 桥接多回调
suspend fun wakeUpDevice(): Boolean = coroutineScope {
    val deferred = CompletableDeferred<Boolean>()
    val finished = AtomicBoolean(false)
val failCount = AtomicInteger(0)

    // 本地唤醒
    sendLocalWakeUp(
        onSuccess = {
            if (finished.compareAndSet(false, true)) {
                deferred.complete(true)
            }
        },

        onFail = {
            if (failCount.incrementAndGet() == 2) {
                deferred.complete(false)
            }
        }
    )

    // 云端唤醒
    sendCloudWakeUp(
        onSuccess = {
            if (finished.compareAndSet(false, true)) {
                deferred.complete(true)
            }
        },

        onFail = {
            if (failCount.incrementAndGet() == 2) {  
deferred.complete(false)            
}
        }
    )

    // 统一 suspend 等待结果
    deferred.await()
}




// 示例调用
fun main() = runBlocking {
    val result = wakeUpDevice()
    println("唤醒结果:$result")
}

上面就是多处回调桥接的例子,只要有一次成功就成功,都失败才算失败。


2、多生产者单消费者“谁先到就用谁”


complete函数只能被调用一次,后续调用无效,基于这个特性可以完成多生产者单消费者-谁先到就用谁的需求。一个简单的示例如下:


//两个异步来源(A、B)谁先返回就用谁的结果
val winner = CompletableDeferred<String>()
fun sourceA() { /* 异步获取 */ winner.complete("fromA") }
fun sourceB() { /* 异步获取 */ winner.complete("fromB") }
lifecycleScope.launch {
    val result = winner.await() // 谁先 complete 就是结果
}

3、协程间通知


这是一个比较巧妙的用法,例如一个协程做准备工作,另一个协程等待完成:


val ready = CompletableDeferred<Unit>()

launch {
prepareResources()
ready.complete(Unit)
}

launch {
ready.await() //挂起阻塞,等待准备(prepareResources)完成
startWork() //另一个协程的执行部分
}

扩展思维:ready.complete(Unit)还可以在用户输入后,点击按钮后等,可以发挥想象。


4、与select、超时结合


select 表达式同时等待多个挂起操作采用第一个完成的结果。


fun CoroutineScope.fetchWithFallbacks(): CompletableDeferred<String> {
val result = CompletableDeferred<String>()

launch {
try {
val data = select<String> {
primarySource().onAwait { it }
secondarySource().onAwait { it }
onTimeout(800) { throw TimeoutException("Timeout") }
}
result.complete(data)
} catch (e: Exception) {
// 启动异步恢复任务(不立即completeExceptionally)
launch {
delay(1000)
try {
val recovery = recoverySource()
result.complete(recovery)
} catch (e2: Exception) {
result.completeExceptionally(e2) // 真正失败
}
}
}
}

return result
}

三、取消与异常传播



  • 如果 CompletableDeferred 被 cancel(),等待的协程会收到 CancellationException。

  • 如果在完成前等待者被取消,完成者仍然可以 complete() —— 但 await() 不会再返回值(因为等待者已被取消)。

  • completeExceptionally(e) 会使 await() 抛出相同异常。

  • complete() 返回 true 或 false(表示是否第一次完成)。


四、注意事项与常见坑



  1. 不要泄漏未完成的 CompletableDeferred:把它长期暴露给全局可能会导致内存泄漏,尤其当负责完成它的组件被销毁时。

  2. 只在受信任的地方完成:不要让多个地方都可能完成且没序的代码混乱;若有竞态,记得检查 complete 的返回值。

  3. 避免把 CompletableDeferred 当作长生命周期队列:它是一次性完成的;若需要多次事件,请用 Channel / SharedFlow。

  4. 取消处理:如果等待者可能取消,使用 invokeOnCompletion 或 invokeOnCancellation 做清理(比如取消底层请求)。

  5. 完成后不要反复设置结果:complete 多次调用只有第一次有效,后续会返回 false。

  6. 与协程作用域的关系:CompletableDeferred 本身不是 Job,但它有 asJob() 可用于组合;也可以传入父 Job 构造(CompletableDeferred(parentJob))以便取消联动。


作者:TimeFine
来源:juejin.cn/post/7564485874727550976
收起阅读 »

那些前端老鸟才知道的秘密

web
前端老鸟才知道的秘密:void(0),这东西到底有什么用 那天我盯着同事的代码看了半天,心里默念:这货是不是写错了? 前几天 review 代码,看到一个小年轻写了这么一行: const foo = void 0; 我当时就乐了,心想:" 这孩子是不是被...
继续阅读 »

前端老鸟才知道的秘密:void(0),这东西到底有什么用



那天我盯着同事的代码看了半天,心里默念:这货是不是写错了?



前几天 review 代码,看到一个小年轻写了这么一行:


const foo = void 0;

我当时就乐了,心想:" 这孩子是不是被产品经理逼疯了?直接写undefined不香吗?非得整这出?"


但转念一想,不对啊,这写法我好像在哪儿见过... 仔细一琢磨,卧槽,这不就是前端老司机的暗号吗!


所以,void 0 到底是个啥?


简单来说,void 0就是强行返回 undefined的一种写法。


你可能会问:"那我直接写 undefined 不就完事了?干嘛要多此一举?"


问得好!这就要从前端开发的 "血泪史" 说起了。


那些年被 undefined 坑过的日子


在 JavaScript 的远古时期(其实就是 ES5 之前),undefined 这个变量是可以被重写的!


没错,你没听错,就是那个表示 "未定义" 的 undefined,它自己都可能被定义成别的东西...


// 在古老的浏览器里,你可以这么玩(现在别试了)
undefined = "我是谁?我在哪?";
console.log(undefined); // 输出:"我是谁?我在哪?"

这就很尴尬了 —— 你用来判断是否未定义的变量,自己都可能被篡改!


这时候,void 0就闪亮登场了。


void 0 的三大绝技


1. 绝对安全的 undefined


void操作符有个特点:不管后面接什么,都返回 undefined


void 0 // undefined
void "hello" // undefined
void {} // undefined
void function(){} // undefined

所以void 0就成了获取真正 undefined 的最可靠方式。


2. 阻止链接跳转的老司机


还记得以前写<a href="javascript:void(0)">吗?这就是为了防止点击链接后页面跳转。


虽然现在大家都用event.preventDefault()了,但这可是老一辈前端人的集体记忆啊!


3. 立即执行函数的替代方案


有些老代码里你会看到:


void function() {
// 立即执行的代码
}();

这其实是为了避免函数声明被误认为是语句开头。


现在还需要 void 0 吗?


说实话,在现代前端开发中,直接用undefined已经足够安全了。ES5 之后的规范规定 undefined 是只读的,不能再被重写。


但为什么还有老司机在用 void 0 呢?



  1. 习惯成自然:用了十几年,改不过来了

  2. 代码压缩void 0undefined字符更少

  3. 装逼必备:一看就是用 void 0 的,肯定是老鸟(手动狗头)


所以,到底用不用?


我的建议是:知道为什么用,比用什么更重要


如果你是为了代码风格统一,或者团队约定,用 void 0 没问题。


如果只是为了装老司机... 兄弟,真没必要。现在面试官看到 void 0,第一反应可能是:"这人是刚从 jQuery 时代穿越过来的吗?"




最后送大家一句话:技术选型就像穿衣服,合适比时髦更重要。  知道每个工具为什么存在,比你盲目跟风要强得多。


作者:hmfy
来源:juejin.cn/post/7563635016283668531
收起阅读 »

Java 中的 Consumer 与 Supplier 接口

异同分析 Consumer 和 Supplier 是 Java 8 引入的两个重要函数式接口,位于 java.util.function 包中,用于支持函数式编程范式。 相同点 都是函数式接口(只有一个抽象方法) 都位于 java.util.function...
继续阅读 »

异同分析


Consumer 和 Supplier 是 Java 8 引入的两个重要函数式接口,位于 java.util.function 包中,用于支持函数式编程范式。


相同点



  1. 都是函数式接口(只有一个抽象方法)

  2. 都位于 java.util.function 包中

  3. 都用于 Lambda 表达式和方法引用

  4. 都在 Stream API 和 Optional 类中广泛使用


不同点


特性ConsumerSupplier
方法签名void accept(T t)T get()
参数接受一个输入参数无输入参数
返回值无返回值返回一个值
主要用途消费数据提供数据
类比方法中的参数方法中的返回值

详细分析与使用场景


Consumer 接口


Consumer 表示接受单个输入参数但不返回结果的操作。


import java.util.function.Consumer;
import java.util.Arrays;
import java.util.List;

public class ConsumerExample {
public static void main(String[] args) {
// 基本用法
Consumer<String> printConsumer = s -> System.out.println(s);
printConsumer.accept("Hello Consumer!");

// 方法引用方式
Consumer<String> methodRefConsumer = System.out::println;
methodRefConsumer.accept("Hello Method Reference!");

// 集合遍历中的应用
List<String> names = Arrays.asList("Alice", "Bob", "Charlie");
names.forEach(printConsumer);

// andThen 方法组合多个 Consumer
Consumer<String> upperCaseConsumer = s -> System.out.println(s.toUpperCase());
Consumer<String> decoratedConsumer = s -> System.out.println("*** " + s + " ***");

Consumer<String> combinedConsumer = upperCaseConsumer.andThen(decoratedConsumer);
combinedConsumer.accept("functional interface");

// 在 Optional 中的使用
java.util.Optional<String> optional = java.util.Optional.of("Present");
optional.ifPresent(combinedConsumer);
}
}

Consumer 的使用场景



  1. 遍历集合元素并执行操作

  2. 处理数据并产生副作用(如打印、保存到数据库)

  3. 在 Optional 中处理可能存在的值

  4. 组合多个操作形成处理链


Supplier 接口


Supplier 表示一个供应商,不需要传入参数但返回一个值。


import java.util.function.Supplier;
import java.util.List;
import java.util.Random;
import java.util.stream.Stream;

public class SupplierExample {
public static void main(String[] args) {
// 基本用法
Supplier<String> stringSupplier = () -> "Hello from Supplier!";
System.out.println(stringSupplier.get());

// 方法引用方式
Supplier<Double> randomSupplier = Math::random;
System.out.println("Random number: " + randomSupplier.get());

// 对象工厂
Supplier<List<String>> listSupplier = () -> java.util.Arrays.asList("A", "B", "C");
System.out.println("List from supplier: " + listSupplier.get());

// 延迟计算/初始化
Supplier<ExpensiveObject> expensiveObjectSupplier = () -> {
System.out.println("Creating expensive object...");
return new ExpensiveObject();
};

System.out.println("Supplier created but no object yet...");
// 只有在调用 get() 时才会创建对象
ExpensiveObject obj = expensiveObjectSupplier.get();

// 在 Stream 中生成无限流
Supplier<Integer> randomIntSupplier = () -> new Random().nextInt(100);
Stream.generate(randomIntSupplier)
.limit(5)
.forEach(System.out::println);

// 在 Optional 中作为备选值
java.util.Optional<String> emptyOptional = java.util.Optional.empty();
String value = emptyOptional.orElseGet(() -> "Default from supplier");
System.out.println("Value from empty optional: " + value);
}

static class ExpensiveObject {
ExpensiveObject() {
// 模拟耗时操作
try { Thread.sleep(1000); } catch (InterruptedException e) {}
}
}
}

Supplier 的使用场景



  1. 延迟初始化或延迟计算

  2. 提供配置或默认值

  3. 生成测试数据或模拟对象

  4. 在 Optional 中提供备选值

  5. 创建对象工厂

  6. 实现惰性求值模式


实际应用示例


下面是一个结合使用 Consumer 和 Supplier 的示例:


import java.util.function.Consumer;
import java.util.function.Supplier;
import java.util.logging.Logger;

public class CombinedExample {
private static final Logger logger = Logger.getLogger(CombinedExample.class.getName());

public static void main(String[] args) {
// 创建一个数据处理器,结合了 Supplier 和 Consumer
processData(
() -> { // Supplier - 提供数据
// 模拟从数据库或API获取数据
return new String[] {"Data1", "Data2", "Data3"};
},
data -> { // Consumer - 处理数据
for (String item : data) {
System.out.println("Processing: " + item);
}
},
error -> { // Consumer - 错误处理
logger.severe("Error occurred: " + error.getMessage());
}
);
}

public static <T> void processData(Supplier<T> dataSupplier,
Consumer<T> dataProcessor,
Consumer<Exception> errorHandler)
{
try {
T data = dataSupplier.get(); // 从Supplier获取数据
dataProcessor.accept(data); // 用Consumer处理数据
} catch (Exception e) {
errorHandler.accept(e); // 用Consumer处理错误
}
}
}

总结



  • Consumer 用于表示接受输入并执行操作但不返回结果的函数,常见于需要处理数据并产生副作用的场景

  • Supplier 用于表示无需输入但返回结果的函数,常见于延迟计算、提供数据和工厂模式场景

  • 两者都是函数式编程中的重要构建块,可以组合使用创建灵活的数据处理管道

  • 在 Stream API、Optional 和现代 Java 框架中广泛应用


理解这两个接口的差异和适用场景有助于编写更简洁、更表达力的 Java 代码,特别是在使用 Stream API 和函数式编程范式时。


作者:往事随风去
来源:juejin.cn/post/7548717557531623464
收起阅读 »

线程安全过期缓存:手写Guava Cache🗄️

缓存是性能优化的利器,但如何保证线程安全、支持过期、防止内存泄漏?让我们从零开始,打造一个生产级缓存! 一、开场:缓存的核心需求🎯 基础需求 线程安全:多线程并发读写 过期淘汰:自动删除过期数据 容量限制:防止内存溢出 性能优化:高并发访问 生活类比: ...
继续阅读 »

缓存是性能优化的利器,但如何保证线程安全、支持过期、防止内存泄漏?让我们从零开始,打造一个生产级缓存!



一、开场:缓存的核心需求🎯


基础需求



  1. 线程安全:多线程并发读写

  2. 过期淘汰:自动删除过期数据

  3. 容量限制:防止内存溢出

  4. 性能优化:高并发访问


生活类比:


缓存像冰箱🧊:



  • 存储食物(数据)

  • 定期检查过期(过期策略)

  • 空间有限(容量限制)

  • 多人使用(线程安全)




二、版本1:基础线程安全缓存


public class SimpleCache<K, V> {

private final ConcurrentHashMap<K, CacheEntry<V>> cache =
new ConcurrentHashMap<>();

// 缓存项
static class CacheEntry<V> {
final V value;
final long expireTime; // 过期时间戳

CacheEntry(V value, long ttl) {
this.value = value;
this.expireTime = System.currentTimeMillis() + ttl;
}

boolean isExpired() {
return System.currentTimeMillis() > expireTime;
}
}

/**
* 存入缓存
*/

public void put(K key, V value, long ttlMillis) {
cache.put(key, new CacheEntry<>(value, ttlMillis));
}

/**
* 获取缓存
*/

public V get(K key) {
CacheEntry<V> entry = cache.get(key);

if (entry == null) {
return null;
}

// 检查是否过期
if (entry.isExpired()) {
cache.remove(key); // 惰性删除
return null;
}

return entry.value;
}

/**
* 删除缓存
*/

public void remove(K key) {
cache.remove(key);
}

/**
* 清空缓存
*/

public void clear() {
cache.clear();
}

/**
* 缓存大小
*/

public int size() {
return cache.size();
}
}

使用示例:


SimpleCache<String, User> cache = new SimpleCache<>();

// 存入缓存,5秒过期
cache.put("user:1", new User("张三"), 5000);

// 获取缓存
User user = cache.get("user:1"); // 5秒内返回User对象

Thread.sleep(6000);

User expired = cache.get("user:1"); // 返回null(已过期)

问题:



  • ❌ 过期数据需要访问时才删除(惰性删除)

  • ❌ 没有容量限制,可能OOM

  • ❌ 没有定时清理,内存泄漏




三、版本2:支持定时清理🔧


public class CacheWithCleanup<K, V> {

private final ConcurrentHashMap<K, CacheEntry<V>> cache =
new ConcurrentHashMap<>();

private final ScheduledExecutorService cleanupExecutor;

static class CacheEntry<V> {
final V value;
final long expireTime;

CacheEntry(V value, long ttl) {
this.value = value;
this.expireTime = System.currentTimeMillis() + ttl;
}

boolean isExpired() {
return System.currentTimeMillis() > expireTime;
}
}

public CacheWithCleanup() {
this.cleanupExecutor = Executors.newSingleThreadScheduledExecutor(
new ThreadFactoryBuilder()
.setNameFormat("cache-cleanup-%d")
.setDaemon(true)
.build()
);

// 每秒清理一次过期数据
cleanupExecutor.scheduleAtFixedRate(
this::cleanup,
1, 1, TimeUnit.SECONDS
);
}

public void put(K key, V value, long ttlMillis) {
cache.put(key, new CacheEntry<>(value, ttlMillis));
}

public V get(K key) {
CacheEntry<V> entry = cache.get(key);

if (entry == null || entry.isExpired()) {
cache.remove(key);
return null;
}

return entry.value;
}

/**
* 定时清理过期数据
*/

private void cleanup() {
cache.entrySet().removeIf(entry -> entry.getValue().isExpired());
}

/**
* 关闭缓存
*/

public void shutdown() {
cleanupExecutor.shutdown();
cache.clear();
}
}

改进:



  • ✅ 定时清理过期数据

  • ✅ 不依赖访问触发删除


问题:



  • ❌ 还是没有容量限制

  • ❌ 没有LRU淘汰策略




四、版本3:完整的缓存实现(LRU+过期)⭐


import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;

public class AdvancedCache<K, V> {

// 缓存容量
private final int maxSize;

// 存储:ConcurrentHashMap + LinkedHashMap(LRU)
private final ConcurrentHashMap<K, CacheEntry<V>> cache;

// 定时清理线程
private final ScheduledExecutorService cleanupExecutor;

// 统计信息
private final AtomicInteger hitCount = new AtomicInteger(0);
private final AtomicInteger missCount = new AtomicInteger(0);

// 缓存项
static class CacheEntry<V> {
final V value;
final long expireTime;
volatile long lastAccessTime; // 最后访问时间

CacheEntry(V value, long ttl) {
this.value = value;
this.expireTime = System.currentTimeMillis() + ttl;
this.lastAccessTime = System.currentTimeMillis();
}

boolean isExpired() {
return System.currentTimeMillis() > expireTime;
}

void updateAccessTime() {
this.lastAccessTime = System.currentTimeMillis();
}
}

public AdvancedCache(int maxSize) {
this.maxSize = maxSize;
this.cache = new ConcurrentHashMap<>(maxSize);

this.cleanupExecutor = Executors.newSingleThreadScheduledExecutor(
new ThreadFactoryBuilder()
.setNameFormat("cache-cleanup-%d")
.setDaemon(true)
.build()
);

// 每秒清理过期数据
cleanupExecutor.scheduleAtFixedRate(
this::cleanup,
1, 1, TimeUnit.SECONDS
);
}

/**
* 存入缓存
*/

public void put(K key, V value, long ttlMillis) {
// 检查容量
if (cache.size() >= maxSize) {
evictLRU(); // LRU淘汰
}

cache.put(key, new CacheEntry<>(value, ttlMillis));
}

/**
* 获取缓存
*/

public V get(K key) {
CacheEntry<V> entry = cache.get(key);

if (entry == null) {
missCount.incrementAndGet();
return null;
}

// 检查过期
if (entry.isExpired()) {
cache.remove(key);
missCount.incrementAndGet();
return null;
}

// 更新访问时间
entry.updateAccessTime();
hitCount.incrementAndGet();

return entry.value;
}

/**
* 带回调的获取(类似Guava Cache)
*/

public V get(K key, Callable<V> loader, long ttlMillis) {
CacheEntry<V> entry = cache.get(key);

// 缓存命中且未过期
if (entry != null && !entry.isExpired()) {
entry.updateAccessTime();
hitCount.incrementAndGet();
return entry.value;
}

// 缓存未命中,加载数据
try {
V value = loader.call();
put(key, value, ttlMillis);
return value;
} catch (Exception e) {
throw new RuntimeException("加载数据失败", e);
}
}

/**
* LRU淘汰:移除最久未访问的
*/

private void evictLRU() {
K lruKey = null;
long oldestAccessTime = Long.MAX_VALUE;

// 找出最久未访问的key
for (Map.Entry<K, CacheEntry<V>> entry : cache.entrySet()) {
long accessTime = entry.getValue().lastAccessTime;
if (accessTime < oldestAccessTime) {
oldestAccessTime = accessTime;
lruKey = entry.getKey();
}
}

if (lruKey != null) {
cache.remove(lruKey);
}
}

/**
* 定时清理过期数据
*/

private void cleanup() {
cache.entrySet().removeIf(entry -> entry.getValue().isExpired());
}

/**
* 获取缓存命中率
*/

public double getHitRate() {
int total = hitCount.get() + missCount.get();
return total == 0 ? 0 : (double) hitCount.get() / total;
}

/**
* 获取统计信息
*/

public String getStats() {
return String.format(
"缓存统计: 大小=%d, 命中=%d, 未命中=%d, 命中率=%.2f%%",
cache.size(),
hitCount.get(),
missCount.get(),
getHitRate() * 100
);
}

/**
* 关闭缓存
*/

public void shutdown() {
cleanupExecutor.shutdown();
cache.clear();
}
}



五、完整使用示例📝


public class CacheExample {

public static void main(String[] args) throws InterruptedException {

// 创建缓存:最大100个,5秒过期
AdvancedCache<String, User> cache = new AdvancedCache<>(100);

// 1. 基本使用
cache.put("user:1", new User("张三", 20), 5000);
User user = cache.get("user:1");
System.out.println("获取缓存: " + user);

// 2. 带回调的获取(自动加载)
User user2 = cache.get("user:2", () -> {
// 模拟从数据库加载
System.out.println("从数据库加载 user:2");
return new User("李四", 25);
}, 5000);
System.out.println("加载数据: " + user2);

// 3. 再次获取(命中缓存)
User cached = cache.get("user:2");
System.out.println("命中缓存: " + cached);

// 4. 等待过期
Thread.sleep(6000);
User expired = cache.get("user:1");
System.out.println("过期数据: " + expired); // null

// 5. 查看统计
System.out.println(cache.getStats());

// 6. 关闭缓存
cache.shutdown();
}
}

输出:


获取缓存: User{name='张三', age=20}
从数据库加载 user:2
加载数据: User{name='李四', age=25}
命中缓存: User{name='李四', age=25}
过期数据: null
缓存统计: 大小=1, 命中=2, 未命中=1, 命中率=66.67%



六、实战:用户Session缓存🔐


public class SessionCache {

private final AdvancedCache<String, UserSession> cache;

public SessionCache() {
this.cache = new AdvancedCache<>(10000); // 最大1万个session
}

/**
* 创建Session
*/

public String createSession(Long userId) {
String sessionId = UUID.randomUUID().toString();
UserSession session = new UserSession(userId, LocalDateTime.now());

// 30分钟过期
cache.put(sessionId, session, 30 * 60 * 1000);

return sessionId;
}

/**
* 获取Session
*/

public UserSession getSession(String sessionId) {
return cache.get(sessionId);
}

/**
* 刷新Session(延长过期时间)
*/

public void refreshSession(String sessionId) {
UserSession session = cache.get(sessionId);
if (session != null) {
// 重新设置30分钟过期
cache.put(sessionId, session, 30 * 60 * 1000);
}
}

/**
* 删除Session(登出)
*/

public void removeSession(String sessionId) {
cache.remove(sessionId);
}

static class UserSession {
final Long userId;
final LocalDateTime createTime;

UserSession(Long userId, LocalDateTime createTime) {
this.userId = userId;
this.createTime = createTime;
}
}
}



七、与Guava Cache对比📊


Guava Cache的使用


LoadingCache<String, User> cache = CacheBuilder.newBuilder()
.maximumSize(1000) // 最大容量
.expireAfterWrite(5, TimeUnit.MINUTES) // 写入后过期
.expireAfterAccess(10, TimeUnit.MINUTES) // 访问后过期
.recordStats() // 记录统计
.build(new CacheLoader<String, User>() {
@Override
public User load(String key) throws Exception {
return loadUserFromDB(key); // 加载数据
}
});

// 使用
User user = cache.get("user:1"); // 自动加载

功能对比


功能自定义CacheGuava Cache
线程安全
过期时间
LRU淘汰
自动加载
弱引用
统计信息
监听器
刷新

建议:



  • 简单场景:自定义实现

  • 生产环境:用Guava Cache或Caffeine




八、性能优化技巧⚡


技巧1:分段锁


public class SegmentedCache<K, V> {

private final int segments = 16;
private final AdvancedCache<K, V>[] caches;

@SuppressWarnings("unchecked")
public SegmentedCache(int totalSize) {
this.caches = new AdvancedCache[segments];
int sizePerSegment = totalSize / segments;

for (int i = 0; i < segments; i++) {
caches[i] = new AdvancedCache<>(sizePerSegment);
}
}

private AdvancedCache<K, V> getCache(K key) {
int hash = key.hashCode();
int index = (hash & Integer.MAX_VALUE) % segments;
return caches[index];
}

public void put(K key, V value, long ttl) {
getCache(key).put(key, value, ttl);
}

public V get(K key) {
return getCache(key).get(key);
}
}

技巧2:异步加载


public class AsyncCache<K, V> {

private final AdvancedCache<K, CompletableFuture<V>> cache;
private final ExecutorService loadExecutor;

public CompletableFuture<V> get(K key, Callable<V> loader, long ttl) {
return cache.get(key, () ->
CompletableFuture.supplyAsync(() -> {
try {
return loader.call();
} catch (Exception e) {
throw new CompletionException(e);
}
}, loadExecutor),
ttl
);
}
}



九、常见陷阱⚠️


陷阱1:缓存穿透


// ❌ 错误:不存在的key反复查询数据库
public User getUser(String userId) {
User user = cache.get(userId);
if (user == null) {
user = loadFromDB(userId); // 每次都查数据库
if (user != null) {
cache.put(userId, user, 5000);
}
}
return user;
}

// ✅ 正确:缓存空对象
public User getUser(String userId) {
User user = cache.get(userId);
if (user == null) {
user = loadFromDB(userId);
// 即使是null也缓存,但设置短过期时间
cache.put(userId, user != null ? user : NULL_USER, 1000);
}
return user == NULL_USER ? null : user;
}

陷阱2:缓存雪崩


// ❌ 错误:所有key同时过期
for (String key : keys) {
cache.put(key, value, 5000); // 5秒后同时过期
}

// ✅ 正确:过期时间随机化
for (String key : keys) {
long ttl = 5000 + ThreadLocalRandom.current().nextInt(1000);
cache.put(key, value, ttl); // 5-6秒随机过期
}



十、面试高频问答💯


Q1: 如何保证缓存的线程安全?


A:



  • 使用ConcurrentHashMap

  • volatile保证可见性

  • CAS操作保证原子性


Q2: 如何实现过期淘汰?


A:



  • 惰性删除:访问时检查过期

  • 定时删除:定时任务扫描

  • 两者结合


Q3: 如何实现LRU?


A:



  • 记录访问时间

  • 容量满时淘汰最久未访问的


Q4: 缓存穿透/击穿/雪崩的区别?


A:



  • 穿透:查询不存在的key,缓存和DB都没有

  • 击穿:热点key过期,大量请求打到DB

  • 雪崩:大量key同时过期




十一、总结🎯


核心要点



  1. 线程安全:ConcurrentHashMap

  2. 过期策略:定时清理+惰性删除

  3. 容量限制:LRU淘汰

  4. 性能优化:分段锁、异步加载

  5. 监控统计:命中率、容量


生产建议



  • 简单场景:自己实现

  • 复杂场景:用Guava Cache

  • 极致性能:用Caffeine




下期预告: 为什么双重检查锁定(DCL)是错误的?指令重排序的陷阱!🔐


作者:用户6854537597769
来源:juejin.cn/post/7563511077180473386
收起阅读 »

Android文件下载完整性保证:快递员小明的故事

有趣的故事:快递员小明的包裹保卫战 想象一下,小明是个快递员,负责从仓库(服务器)运送包裹(文件)到客户(Android设备)。但路上有各种意外: 数据损坏:就像包裹被雨淋湿 网络中断:就像送货路上遇到施工 恶意篡改:就像包裹被坏人调包 小明如何确保客户收...
继续阅读 »

有趣的故事:快递员小明的包裹保卫战


想象一下,小明是个快递员,负责从仓库(服务器)运送包裹(文件)到客户(Android设备)。但路上有各种意外:



  • 数据损坏:就像包裹被雨淋湿

  • 网络中断:就像送货路上遇到施工

  • 恶意篡改:就像包裹被坏人调包


小明如何确保客户收到的包裹完好无损呢?


核心技术原理


1. 校验和验证(Checksum) - "包裹清单核对"


就像快递员对照清单检查物品数量:


// MD5校验 - 快速但安全性较低
public boolean verifyFileMD5(File file, String expectedMD5) {
try {
MessageDigest md = MessageDigest.getInstance("MD5");
FileInputStream fis = new FileInputStream(file);
byte[] buffer = new byte[8192];
int length;
while ((length = fis.read(buffer)) != -1) {
md.update(buffer, 0, length);
}
byte[] digest = md.digest();

// 转换为十六进制字符串
StringBuilder sb = new StringBuilder();
for (byte b : digest) {
sb.append(String.format("x", b));
}
String actualMD5 = sb.toString();

fis.close();
return actualMD5.equals(expectedMD5.toLowerCase());
} catch (Exception e) {
e.printStackTrace();
return false;
}
}

2. SHA系列校验 - "高级防伪验证"


// SHA-256校验 - 更安全的选择
public boolean verifyFileSHA256(File file, String expectedSHA256) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
FileInputStream fis = new FileInputStream(file);
byte[] buffer = new byte[8192];
int length;
while ((length = fis.read(buffer)) != -1) {
digest.update(buffer, 0, length);
}
byte[] hash = digest.digest();

StringBuilder hexString = new StringBuilder();
for (byte b : hash) {
String hex = Integer.toHexString(0xff & b);
if (hex.length() == 1) hexString.append('0');
hexString.append(hex);
}

fis.close();
return hexString.toString().equals(expectedSHA256.toLowerCase());
} catch (Exception e) {
e.printStackTrace();
return false;
}
}

3. 完整下载管理器实现


public class SecureDownloadManager {
private Context context;
private DownloadListener listener;

public interface DownloadListener {
void onDownloadProgress(int progress);
void onDownloadSuccess(File file);
void onDownloadFailed(String error);
void onIntegrityCheckFailed();
}

public SecureDownloadManager(Context context, DownloadListener listener) {
this.context = context;
this.listener = listener;
}

public void downloadFileWithVerification(String fileUrl,
String fileName,
String expectedHash,
HashType hashType)
{
new DownloadTask(fileUrl, fileName, expectedHash, hashType).execute();
}

private class DownloadTask extends AsyncTask<Void, Integer, Boolean> {
private String fileUrl;
private String fileName;
private String expectedHash;
private HashType hashType;
private File downloadedFile;

public DownloadTask(String fileUrl, String fileName,
String expectedHash, HashType hashType)
{
this.fileUrl = fileUrl;
this.fileName = fileName;
this.expectedHash = expectedHash;
this.hashType = hashType;
}

@Override
protected Boolean doInBackground(Void... voids) {
try {
// 创建目标文件
File downloadsDir = context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS);
downloadedFile = new File(downloadsDir, fileName);

// 开始下载
URL url = new URL(fileUrl);
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.connect();

// 检查响应码
if (connection.getResponseCode() != HttpURLConnection.HTTP_OK) {
return false;
}

// 获取文件大小用于进度计算
int fileLength = connection.getContentLength();

// 下载文件
InputStream input = connection.getInputStream();
FileOutputStream output = new FileOutputStream(downloadedFile);

byte[] buffer = new byte[4096];
long total = 0;
int count;
while ((count = input.read(buffer)) != -1) {
// 如果用户取消了任务
if (isCancelled()) {
input.close();
output.close();
downloadedFile.delete();
return false;
}
total += count;

// 发布进度
if (fileLength > 0) {
publishProgress((int) (total * 100 / fileLength));
}

output.write(buffer, 0, count);
}

output.flush();
output.close();
input.close();

// 验证文件完整性
return verifyFileIntegrity(downloadedFile, expectedHash, hashType);

} catch (Exception e) {
e.printStackTrace();
if (downloadedFile != null && downloadedFile.exists()) {
downloadedFile.delete();
}
return false;
}
}

@Override
protected void onProgressUpdate(Integer... values) {
if (listener != null) {
listener.onDownloadProgress(values[0]);
}
}

@Override
protected void onPostExecute(Boolean success) {
if (success) {
if (listener != null) {
listener.onDownloadSuccess(downloadedFile);
}
} else {
if (downloadedFile != null && downloadedFile.exists()) {
downloadedFile.delete();
}
if (listener != null) {
listener.onIntegrityCheckFailed();
}
}
}
}

private boolean verifyFileIntegrity(File file, String expectedHash, HashType hashType) {
try {
String actualHash;
switch (hashType) {
case MD5:
actualHash = calculateMD5(file);
break;
case SHA256:
actualHash = calculateSHA256(file);
break;
case SHA1:
actualHash = calculateSHA1(file);
break;
default:
actualHash = calculateSHA256(file);
}
return actualHash != null && actualHash.equalsIgnoreCase(expectedHash);
} catch (Exception e) {
e.printStackTrace();
return false;
}
}

public enum HashType {
MD5, SHA1, SHA256
}
}

4. 使用示例


public class MainActivity extends AppCompatActivity {
private SecureDownloadManager downloadManager;
private ProgressBar progressBar;
private TextView statusText;

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);

progressBar = findViewById(R.id.progressBar);
statusText = findViewById(R.id.statusText);

downloadManager = new SecureDownloadManager(this, new SecureDownloadManager.DownloadListener() {
@Override
public void onDownloadProgress(int progress) {
runOnUiThread(() -> {
progressBar.setProgress(progress);
statusText.setText("下载中: " + progress + "%");
});
}

@Override
public void onDownloadSuccess(File file) {
runOnUiThread(() -> {
statusText.setText("下载完成且文件完整!");
Toast.makeText(MainActivity.this, "文件验证成功", Toast.LENGTH_SHORT).show();
});
}

@Override
public void onDownloadFailed(String error) {
runOnUiThread(() -> {
statusText.setText("下载失败: " + error);
});
}

@Override
public void onIntegrityCheckFailed() {
runOnUiThread(() -> {
statusText.setText("文件完整性验证失败!");
Toast.makeText(MainActivity.this, "文件可能已损坏", Toast.LENGTH_LONG).show();
});
}
});

// 开始下载
Button downloadBtn = findViewById(R.id.downloadBtn);
downloadBtn.setOnClickListener(v -> {
String fileUrl = "https://example.com/file.zip";
String expectedSHA256 = "a1b2c3d4e5f6789012345678901234567890123456789012345678901234";

downloadManager.downloadFileWithVerification(
fileUrl,
"myfile.zip",
expectedSHA256,
SecureDownloadManager.HashType.SHA256
);
});
}
}

时序图:完整的下载验证流程


deepseek_mermaid_20251010_1d824c.png


关键要点总结



  1. 双重保障:下载完成 + 完整性验证 = 安全文件

  2. 进度反馈:让用户知道下载状态

  3. 自动清理:验证失败时自动删除损坏文件

  4. 灵活算法:支持多种哈希算法适应不同场景

  5. 异常处理:网络中断、文件损坏等情况的妥善处理


就像快递员小明不仅要把包裹送到,还要确保包裹完好无损一样,我们的下载管理器既要完成下载,又要保证文件的完整性!


作者:Android童话镇
来源:juejin.cn/post/7559190511824519187
收起阅读 »

面试官:手写一个深色模式切换过渡动画

web
在开发Web应用时,深色模式已成为现代UI设计的标配功能。然而,许多项目在实现主题切换时仅简单改变CSS变量,缺乏平滑的过渡动画,导致用户体验突兀。作为开发者,我们常被期望在满足功能需求的同时,打造更精致的用户交互体验。面试中,被问及"如何实现流畅的深色模式切...
继续阅读 »

在开发Web应用时,深色模式已成为现代UI设计的标配功能。然而,许多项目在实现主题切换时仅简单改变CSS变量,缺乏平滑的过渡动画,导致用户体验突兀。作为开发者,我们常被期望在满足功能需求的同时,打造更精致的用户交互体验。面试中,被问及"如何实现流畅的深色模式切换动画"时,很多人可能只答出使用CSS transition,而忽略了现代浏览器的View Transitions API这一高级解决方案。


读完本文,你将掌握:



  1. 使用View Transitions API实现流畅的主题切换动画

  2. 理解深色模式切换的核心原理与实现细节

  3. 能够将这套方案应用到实际项目中,提升用户体验


image.png

前言


在实际项目中,深色模式切换几乎是前端的“标配”。常见做法是通过 classList.toggle("dark") 切换样式,再配合 transition 做淡入淡出。然而,这种效果在用户体验上略显生硬:颜色瞬间大面积切换,即便有渐变也会显得突兀。


随着 View Transitions API 的出现,我们可以给“页面状态切换”添加炫酷的过渡动画。今天就带大家实现一个 以点击位置为圆心、扩散切换主题的深色模式动画,读完本文你将收获:



  • 了解 document.startViewTransition 的工作原理

  • 学会用 clipPath + animate 控制圆形扩散动画




核心铺垫:我们需要解决什么问题?


在设计方案前,先明确 3 个核心目标:



  1. 流畅过渡:避免普通 transition 的“整体闪烁”,实现局部扩散过渡。

  2. 交互感强:以用户点击位置为动画圆心,符合直觉。

  3. 可扩展:方案可适配 Vue3 组件体系,不依赖复杂第三方库。


为此,我们需要用到几个关键技术点:



  • View Transitions API:提供 document.startViewTransition,可以对 DOM 状态切换设置过渡动画。

  • clip-path:通过 circle(r at x y) 定义动画圆形,从 0px 扩展到最大半径。

  • computeMaxRadius:计算从点击点到四角的最大距离,确保圆形覆盖全屏。

  • .animate:使用 document.documentElement.animate 精确控制过渡过程。


Math.hypot:计算平面上点到原点的距离


Math.hypot()是ES2017引入的一个JavaScript函数,用于计算所有参数平方和的平方根,即计算n维欧几里得空间中从原点到指定点的距离。


image.png

在深色模式切换动画中,我们使用它来计算覆盖整个屏幕的最大圆形半径:


斜边计算


Math.hypot(maxX, maxY):使用勾股定理计算从点击点到对角的距离


image.png


clip-path


recording.gif

clip-path是CSS属性,允许我们定义元素的可见区域,将其裁剪为基本形状或SVG路径。在深色模式切换动画中,我们用它创建从点击点向外扩散的圆形动画效果。


<basic-shape>一种形状,其大小和位置由 <geometry-box> 的值定义。如果没有指定 <geometry-box>,则将使用 border-box 用为参考框。取值可为以下值中的任意一个:



  • inset()


    定义一个 inset 矩形。


  • circle()


    定义一个圆形(使用一个半径和一个圆心位置)。


  • ellipse()


    定义一个椭圆(使用两个半径和一个圆心位置)。


  • polygon()


    定义一个多边形(使用一个 SVG 填充规则和一组顶点)。


  • path()


    定义一个任意形状(使用一个可选的 SVG 填充规则和一个 SVG 路径定义)。



这里使用circle()来实现效果


该函数接受以下参数:



  • 半径:定义圆形的大小(0px到计算的最大半径)

  • at关键词:分隔半径和中心点位置

  • 中心点位置:使用x y坐标指定圆形中心


startViewTransition:浏览器视图转换API


基本概念


document.startViewTransition()是View Transitions API的核心方法,它告诉浏览器DOM即将发生变化,并允许我们为这些变化创建平滑的过渡动画。


生命周期与关键事件



  1. 调用startViewTransition:浏览器准备开始视图转换

  2. 执行回调函数:DOM状态更新

  3. transition.ready事件:视图转换准备就绪,可以应用动画

  4. 视图转换完成:动画结束,新状态成为稳定状态


浏览器兼容性处理


在实际应用中,我们需要检查浏览器是否支持此API:


const isAppearanceTransition =
document.startViewTransition &&
!window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!isAppearanceTransition) {
// 不支持View Transitions API时的降级处理
isDark.value = !isDark.value;
setupThemeClass(isDark.value);
return;
}

这种处理确保在不支持新特性的浏览器中,功能仍然可用,只是没有动画效果。


核心实现:从逻辑到代码


graph TD

A[用户点击切换按钮] --> B{浏览器是否支持<br/>View Transitions API?}
B -- 否 --> C[直接切换主题变量<br/>无动画效果]
B -- 是 --> D[获取点击坐标X,Y]
D --> E[计算覆盖全屏的最大半径]
E --> F[启动视图转换]
F --> G[执行回调函数<br/>更新isDark状态]
G --> H[设置HTML的dark class<br/>更新CSS变量]
H --> I[等待DOM更新完成<br/>nextTick]
I --> J[视图转换准备就绪]
J --> K[应用clipPath动画<br/>从点击点向外扩散]
K --> L[动画完成<br/>主题切换完成]

style B fill:#f9f,stroke:#333,stroke-width:2px
style K fill:#9cf,stroke:#333,stroke-width:2px


  1. 用户交互:用户点击切换按钮,触发主题切换流程

  2. 浏览器兼容性检查:判断当前浏览器是否支持View Transitions API

  3. 降级处理:在不支持API的浏览器中直接切换主题

  4. 动画核心逻辑



    • 获取点击位置作为动画起点

    • 计算覆盖全屏的最大半径

    • 启动视图转换过程



  5. 状态更新:实际执行主题状态更新和CSS类设置

  6. 动画触发:在视图转换准备就绪后,应用clipPath动画效果

  7. 完成:动画结束,新主题状态稳定


步骤 1:封装主题切换


    function setupThemeClass(isDark) {
document.documentElement.classList.toggle("dark", isDark);
localStorage.setItem("theme", isDark ? "dark" : "light");
}

作用:控制 html.dark 类名,完成主题切换。




步骤 2:计算扩散最大半径


    function computeMaxRadius(x, y) {
const maxX = Math.max(x, window.innerWidth - x);
const maxY = Math.max(y, window.innerHeight - y);
return Math.hypot(maxX, maxY); // √(maxX² + maxY²)
}


作用:确保无论点击哪里,扩散圆都能覆盖屏幕。




步骤 3:触发 View Transition


    function onToggleClick(event) {
const isSupported =
document.startViewTransition &&
!window.matchMedia("(prefers-reduced-motion: reduce)").matches;

if (!isSupported) {
// 回退方案:直接切换
isDark.value = !isDark.value;
setupThemeClass(isDark.value);
return;
}

const x = event.clientX;
const y = event.clientY;
const endRadius = computeMaxRadius(x, y);

// 开启视图过渡
const transition = document.startViewTransition(async () => {
isDark.value = !isDark.value;
setupThemeClass(isDark.value);
await nextTick(); // 等 Vue DOM 更新
});

transition.ready.then(() => {
const clipPath = [
`circle(0px at ${x}px ${y}px)`,
`circle(${endRadius}px at ${x}px ${y}px)`,
];

document.documentElement.animate(
{
clipPath: isDark.value ? [...clipPath].reverse() : clipPath,
},
{
duration: 450,
easing: "ease-in",
pseudoElement: isDark.value
? "::view-transition-old(root)"
: "::view-transition-new(root)",
}
);
});
}

要点:


*startViewTransition 接收一个回调函数,里面执行 DOM 更新(切换主题)。


*transition.ready.then(...) 可以在 DOM 更新后定义动画效果。


*clipPath 数组定义了从 小圆 → 大圆 的扩散过程。


*pseudoElement 控制是对 新视图 还是 旧视图 应用动画。




步骤 4:覆盖默认过渡样式



::view-transition-new(root),
::view-transition-old(root) {
animation: none;
mix-blend-mode: normal;
}

::view-transition-old(root) {
z-index: 1;
}

::view-transition-new(root) {
z-index: 2147483646;
}

html.dark::view-transition-old(root) {
z-index: 2147483646;
}

html.dark::view-transition-new(root) {
z-index: 1;
}

作用:取消默认动画,手动用 clipPath 控制。通过 z-index 确保层级正确,否则可能看到“旧页面覆盖新页面”的异常。




效果演示


recording.gif

运行后:



  • 点击切换按钮时,以点击点为圆心,圆形扩散覆盖全屏,主题在扩散动画过程中完成切换。

  • 若浏览器不支持 View Transitions API(如 Safari),则自动降级为普通切换,不影响使用。


完整demo





延伸与避坑



  1. 兼容性问题



    • View Transitions API 目前在 Chromium 内核浏览器(Chrome 111+、Edge)可用,Safari/Firefox 尚未支持。

    • 可加上 isSupported 判断,优雅降级。



  2. 性能优化



    • 动画时建议避免页面过多重绘(如大量图片加载),否则会掉帧。

    • clip-path 本身是 GPU 加速属性,性能较好。



  3. 扩展思路



    • 除了圆形扩散,还可以用 polygon() 实现“百叶窗切换”或“对角线切换”。

    • 可以结合 路由切换 做“页面级过渡动画”。






总结


本文我们用 Vue3 + Element Plus + View Transitions API 实现了一个点击扩散式的深色模式切换动画,核心要点:



  • startViewTransition:声明 DOM 状态切换的动画上下文。

  • clipPath + animate:控制过渡动画形状与过程。

  • computeMaxRadius:计算圆形覆盖全屏的半径。

  • 优雅降级:确保不支持 API 的浏览器仍能正常切换。


作者:张海潮
来源:juejin.cn/post/7546326670648328219
收起阅读 »