或许,找对象真的太难了……
找对象真的太难了,我不由地发出这个感慨
但其实说着也奇怪,明明我每天两点一线,上班了去工位、下班了回宿舍,根本没有其他社交,但我为什么会发出这样的感慨呢?
- 是因为总是在无聊时感到了孤独才希望有个伴,还是看见大家都有伴了才觉得自己孤独?
- 是因为看到了别人功成名就家庭和谐的嫉妒,还是吊儿郎当无所事事地调侃?
- 是因为信息茧房导致我对婚姻产生了偏见而刻意疏远,还是无能狂怒般自卑不敢去尝试?
- ……
如果是针对我个人的话,那应该是自卑了吧。这辈子30多年没什么情感经历,就只有一次相亲后“交往”的经验。那次相亲后异地聊了9个月,中间节假日只有我回去见了3次面,没有矛盾,每天都在线上花个两三个小时也都聊得很开心,但我却总觉得彼此都像个不熟的人,以至于最后“分手”时内心也毫无波澜。
“或许我根本不需要为了找个伴才选择想找对象吧。”我总是这样安慰自己,其实安慰的次数不多,因为我没有经常想。
我每天两点一线的生活很规律,很轻松,最重要的是,我已经非常习惯了。所以从某种程度来讲,“客观上”,我并没有真的想找对象、也没有主动去尝试结交新朋友,“主观上”,现在的社会风向和经济形势,不太利于我尝试告别单身,即便 A 股沪指最近持续性突破十年新高。
光是想,不去做,那可不就是“太难了”。
其实,有那么一瞬间我也想结婚
可怜的是,这并不是基于我个人的想法,而是外界的干扰。我记得我31岁生日那天,凌晨6点过还在睡梦中,外公外婆就打来电话,祝我生日快乐。我很诧异,因为我没想到农忙时节呢,他们还没忙忘了,也因为居然这么早,鸡鸭才刚叫。然后一如既往地说:不要太节约了,吃点好的,照顾好自己……然后,快点找对象,天天瞌睡都睡不好,揪心得很哦。结了婚他们就放心了,不然他们死了都不安心哦。

或许80多岁的老人家觉得,任何事情,只要你想要,那就能发生。我想要天上掉金子,天上就会掉金子;我想要地里喷石油,地里就会喷石油;我想要找对象,自然就有对象……
我倒是习以为常了,只不过那一整天,就没有第二个人祝我生日快乐了。不管是朋友同事,还是父母亲戚,即便是早我一天过生日、而我在生日前一天给她发去生日祝福的堂姐,都没有。
其实我也是习以为常了,可能因为我的生日也是我爷爷的忌日,我总是刻意淡忘它,大部分时候过生日我自己都会忘记,10岁之后就没有任何一次庆祝生日的行为——10岁那年父母要都外出打工,自此再次成为留守儿童。
但今年有那么一瞬间,突然觉得难得只有两个80多岁的老人还记得我生日,一直让他们失望有点于心不忍,更何况,正常来讲,我还能“忤逆”他们多少年呢?
可惜的是,就和那些贩卖焦虑的短视频、营销号一样,总是提出问题、夸大问题、制造矛盾、激化矛盾,但从来不会提供解决方法一样:想结婚了,然后呢?
独身一人在无聊的时候确实是无聊的
最近不知道是上班天天盯着电脑看久了,还是下班游戏玩多了,眼睛特别酸痛,于是我难得的又在下班之后出去逛了逛。
正常的话吃了饭我就玩游戏了,先玩几把NBA 2K,再玩几把英雄联盟手游,再玩几把王者荣耀。
其实曾几何时,我从生理上都厌恶王者荣耀的,因为它把很多中国历史人物文化名人,搞成游戏中乱七八糟的角色,让我无法接受。以至于这么多年来,从同学同事、到堂表兄弟等,都没有机会跟我一起玩。
我也没想到我居然突然之间就接受了,即便这曾经让我生理上讨厌的东西。我记得很清楚,2025年7月2日,我新历生日那天,我下载了王者荣耀,建立了账号,开始游玩,持续十几天有空就一直在玩,一百来把、十几级的账号打到最低等级的王者段位,觉得差不多入门了,想着和老同学、老朋友、老同事们一起玩时,才发现他们都不上线了。或许,随着年龄的增长,这种“年轻时的生理厌恶”都敌不过“无聊时的孤单寂寞”。
所以每次当我一个人出去小道散步闲逛消磨时间时,总会特别在意那些跑步锻炼身体的人、散步话家常的两公婆、坐在摊位小车后面玩手机的摆摊老板……感觉他们都有目的地在做什么,而只有我在漫无目的地走着。
其实这条路我之前走过,至少在我今年生日之前,只不过那个时候,这马路边的行人道,没有这么多杂草、灌木。就像人生路,总是在回忆的时候,才觉得曾经如此宽阔,才懊悔当时未曾踏入。可是,时光一直向前流逝,回忆永远迭代更新。

可能现在生活没有达到预期的我们总是有这样的想法,要是能回到过去就好了。实际上只是想带着现在的记忆回到过去,去弥补一些错过或者错误。似乎真像有多元宇宙,回到过去之后会有新的时间线,补足那些遗憾,每一次的回溯,终究会得到一条完美符合心意的时间线。实际上我觉得,即便我们能回到过去,那也是会失去所有记忆,然后完完整整重复之前做过的事情,又一次的错过或者错误,不会有多条时间线,即便你回去再多次,都只会重复同样的事情,都只是同一条时间线。但只有这一条时间线,其实也就够了。
正因为消极,所以才乐观
其实我一直是希望传播积极乐观心态的,从我以往的文章总能看到有这样的痕迹。但就像那些奢侈品广告一样:你买得起不重要,你买不起才重要。
就像我当年创业板3600多点最高峰买入了很多和创业板强关联基金一样,那时总觉得中国经济一定是蒸蒸日上,最后一路跌倒了1500点,一点点割肉,最后全盘清掉,赔了一些钱,然后不敢再入场。所以我也没赶上或者说是错过了今年大半年的牛市,有点难受。与之相对的,为了求稳买入了大量的债券,却债市正熊,又套在手里,更难受了。
可能这都不是什么大事,毕竟只是对我造成了一些经济损失,并不会影响到我的一如既往平凡普通的物质生活;但是焦虑、担忧、烦闷的心态,却非常影响我的精神状态,严重损坏我本就低迷的精神生活。
我一直有个想法,希望尽量在35岁前能多攒一些钱,这样如果35岁之后某天丢了工作,我就徒步去环游中国。并不是他们那种雄心壮志地环游,什么“朝圣“”啊、“远离浮躁净化心灵”啊。就跟平时一样,大街小巷,散步流浪,随便走走看看,不过变成了走到哪里黑,就到哪里歇。等到钱花光了,人老了走不动,客死他乡,也算得偿所愿了。
如果真的能到这个时候,身后没有拖拽、肩上没有负担,该是多么舒服的境况。
发现了吗,其实我就是这么矛盾,一方面安慰自己钱财乃身外物,不必强求;另一方面又觉得钱财乃必需品,多多益善。原因非常简单,因为我缺这东西,所以看得很重;又因为没本事挣到更多,所以才安慰自己它不重要。
这就是别人说的,看清问题根源比无法解决问题更让人窒息,也就是“无知是福”或者”无知者无畏”的感悟了。
每个人都应该有自己的活法,即便大同小异
正如写代码的人,总是会重复造轮子,偶尔还会乐此不疲。世上的人这么多,大部分的人的都是千篇一律的,事实上,大家都在做的事情,说不定才是对的。大家都重复着读书、工作、结婚、生子、工作、退休、等死的生活,正是因为在和平年代这就是一个非常典型且应该让人轻松愉悦、容易接受的平凡人的人生。
同样功能、完全适配你项目的工具包,一个周下载几百万、上次更新1个月前,一个周下载几十、上次更新5年前,只考虑下载使用的话,你会怎么选呢?
就像正因为他是魔丸才敢高喊“我命由我不由天”,就像有人说对钱不感兴趣;也就像也有人觉得“奋斗用多大劲啊?”就像我以为“忠诚的不绝对就是绝对的不忠诚”只是一个战锤40K的梗而已……这个世界本来就因为科技发展而不断更新,止不住的时代洪流,纷纷扰扰的世界,何必太关注别人关心的事情,兜兜转转可能发现,你特别在意的东西别人根本没放在心上,你漫不经心地言语却刺穿了别人的心脏。所有的一切,在心脏停止跳动之前,其实都无关紧要;而在心脏停止跳动之后,更是毫无意义。
所以我平时有空的时候,也会更新一下我 Github 仓库中几个开源的小项目,虽然没什么技术含量,还借助了很多AI辅助编码,但我在写完测试完成之后的那一刻感觉很舒服,就算之后很久都没再更新、测试,还发现了bug,但那完成时的一瞬间很舒服,就成了我持续不断更新的主观能动性之一。
人总有一死,我一直强调不要太在意他人的眼光,为自己而生活。但如果你根本不知道自己想要过什么样的人生,那么从众并不是什么丢人的行为。 人生短暂,不值得斤斤计较,浪费也是它应该存在的过程片段。
坐在厂门口的女子
我今天出去散步的时候,经过了隔壁厂,恰巧看到一位女士蹲在门口马路牙子上,左手拿着装炸土豆片套着塑料袋的小盒子,右手拿着手机看小说。我在外面溜达了个把小时,回去的时候发现她还蹲着那马路牙子上,可能是同一个位置,只不过只有右手拿着手机继续看着小说。
但我猜测她“可能”并没有一直蹲着那里看小说,因为那装炸土豆片的小纸盒没有在她左手上继续拿着,也没有放在她的身旁……
为什么只是“猜测和可能”呢?谁知道呢,或许她站起来走几步丢到垃圾桶后又蹲回去了,或许只是她吃完了空纸盒子放在旁边被风吹走了;或许她没吃完揉吧揉吧纸盒子随手丢到马路对面去了,又或许甚至可能她变身奥特曼打走了怪兽又变回正常人继续在厂门口看小说了……
如果不是因为间隔这么久,看见她还呆在同一个位置,我可能根本就没在意。就像如果不是出来工作了10年依旧还在原地,我也不必过度“揣摩自己”。
可能因为总是太在意,所以才觉得一切都太难了…… 毕竟“得不到的永远在骚动……”
很难了,一个陌生人有两次遇到的机会。
多数情况下都只有一次机会。换成是我,如果我一开始也是蹲在厂门口的马路牙子上,估计没人会注意到。但我要是一开始就跪在厂门口不停在磕头,说不定就有人会注意到了……
结尾
哈哈,Gotcha!要不是我这几天玩游戏多了眼睛有点酸痛,需要休息一下,我才不会在这里长篇大论无病呻吟呢,都这么久没有更新了是吧,那我玩游戏看视频啥的时可是乐在其中、忘乎所以的,佝偻成一团都还在哈哈大笑呢。
所以,赶紧去做那些让你自己开心的事情吧,享受生活,这才是我们活着的原因之一,其他的事情,fxxk off。
来源:juejin.cn/post/7544259368277852175
我们来说一说什么是联合索引最左匹配原则?
什么是联合索引?
首先,要理解最左匹配原则,得先知道什么是联合索引。
- 单列索引:只针对一个表列创建的索引。例如,为 users 表的 name 字段创建一个索引。
- 联合索引:也叫复合索引,是针对多个表列创建的索引。例如,为 users 表的 (last_name, first_name) 两个字段创建一个联合索引。
这个索引的结构可以想象成类似于电话簿或字典。电话簿是先按姓氏排序,在姓氏相同的情况下,再按名字排序。你无法直接跳过姓氏,快速找到一个特定的名字。
什么是最左匹配原则?
最左匹配原则指的是:在使用联合索引进行查询时,MySQL/SQL数据库从索引的最左前列开始,并且不能跳过中间的列,一直向右匹配,直到遇到范围查询(>、<、BETWEEN、LIKE)就会停止匹配。
这个原则决定了你的 SQL 查询语句是否能够使用以及如何高效地使用这个联合索引。
核心要点:
- 从左到右:索引的使用必须从最左边的列开始。
- 不能跳过:不能跳过联合索引中的某个列去使用后面的列。
- 范围查询右停止:如果某一列使用了范围查询,那么它右边的列将无法使用索引进行进一步筛选。
举例说明
假设我们有一个 users 表,并创建了一个联合索引 idx_name_age,包含 (last_name, age) 两个字段。
| id | last_name | first_name | age | city |
| 1 | Wang | Lei | 20 | Beijing |
| 2 | Zhang | Wei | 25 | Shanghai |
| 3 | Wang | Fang | 22 | Guangzhou |
| 4 | Li | Na | 30 | Shenzhen |
| 5 | Zhang | San | 28 | Beijing |
索引 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) 决定的。索引按照定义的字段顺序构建,所以必须从最左边开始才能利用其有序性。
如何设计好的联合索引?
- 高频查询优先:将最常用于 WHERE 子句的列放在最左边。
- 等值查询优先:将经常进行等值查询(=)的列放在范围查询(>, <, LIKE)的列左边。
- 覆盖索引:如果查询的所有字段都包含在索引中(即覆盖索引),即使不符合最左前缀,数据库也可能直接扫描索引来避免回表,但这通常发生在二级索引扫描中,效率依然不如最左匹配。
来源:juejin.cn/post/7565940210148868148
掌握协程的边界与环境:CoroutineScope 与 CoroutineContext
CoroutineScope 与 CoroutineContext 的概念
CoroutineContext (协程上下文)
CoroutineContext 是协程上下文,包含了协程运行时所需的所有信息。
比如:
- 管理协程流程(生命周期)的
Job。 - 管理线程的
ContinuationInterceptor,它的实现类CoroutineDispatcher决定了协程所运行的线程或线程池。
CoroutineScope (协程作用域)
CoroutineScope 是协程作用域,它通过 coroutineContext 属性持有了当前协程代码块的上下文信息。
比如,我们可以获取 Job 和 ContinuationInterceptor 对象:
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 的另一个作用就是提供了 launch 和 async 协程构建器,我们可以通过它来启动一个协程。
这样,新创建的协程能够自动继承 CoroutineScope 的 coroutineContext。比如利用 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()类似。
那么,它的应用场景是什么?
它的应用场景由它的特性决定,有两个核心场景:
- 在挂起函数中提供
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 左右
}
- 业务逻辑封装并进行异常处理。(最重要)
我们都知道,我们无法在协程外部使用
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 的使用场景就很清楚了,我们需要切换上下文的时候会使用它,并且希望代码是串行执行的,之后还能再切回原来的线程继续往下执行。
虽然
withContext与coroutineScope类似,但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
前端部署,又有新花样?
大多数前端开发者在公司里,很少需要直接操心“部署”这件事——那通常是运维或 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 值得一试。
- 官网:pinme.eth.limo/
- Github:github.com/glitternetw…
这是一个完全开源的项目,开发团队也会持续更新。如果你在测试过程中有想法或需求,不妨去 GitHub 提个 Issue —— 这不仅能帮助项目成长,也能让它更贴近前端开发的实际使用场景!
来源:juejin.cn/post/7547515500453380136
antd 对 ai 下手了!Vue 开发者表示羡慕!

前端开发者应该对 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 反初始化器详解——在实例永远“消失”之前,把该做的事做完
为什么要“反初始化”
- ARC 已经帮我们释放了内存,但“内存”≠“资源”。
可能你打开过文件、有过数据库连接、订阅过通知、甚至握着 GPU 纹理句柄。
- 反初始化器(deinit)是 Swift 给你“最后一声道别”的钩子:
实例即将被销毁 → 系统自动调用 → 你可以把文件关掉、把硬币还回银行、把日志写盘……
- 只有 class 有 deinit,struct / enum 没有;一个类最多一个 deinit;不允许手动显式调用。
deinit 的 6 条铁律
- 无参无括号:
class MyCls {
deinit { // 不能写 deinit() { ... }
// 清理代码
}
}
- 自动调用,调用顺序:子类 deinit 执行完 → 父类 deinit 自动执行。
- 实例“还没死”:deinit 里可访问任意 self 属性,甚至可调用实例方法。
- 不能自己调、不能重载、不能抛异常、不能带 async。
- 如果实例从未被真正强引用(例如刚 init 就赋 nil),deinit 不会触发。
- 若存在循环引用(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 个高频扩展场景
- 关闭文件句柄
class Logger {
private let handle: FileHandle
init(path: String) throws {
handle = try FileHandle(forWritingTo: URL(fileURLWithPath: path))
}
deinit {
handle.closeFile() // 文件一定会被关掉
}
}
- 注销通知中心观察者
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)
}
}
- 释放手动分配的 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 当成“遗嘱执行人”
- 它只负责“身后事”:释放非内存资源、归还全局状态、写日志。
- 它不能保命:如果实例因为循环引用一直活着,就永远走不到 deinit。
- 它不能抢戏:别在 deinit 里做耗时任务(网络、IO),否则可能阻塞主线程或单元测试。
- 用好 weak / unowned + deinit,可以让 Swift 代码在“自动”与“可控”之间取得最佳平衡。
深入底层:deinit 在 SIL & 运行时到底做了什么
swiftc -emit-sil main.swift mainsil
- SIL(Swift Intermediate Language)视角
编译器会为每个类生成一个
sil_vtable,里面存放了类中的所有方法,可以看到deinit中调用的是Player.__deallocating_deinit
Player.__deallocating_deinit中调用的Player.__isolated_deallocating_deinit
Player.__isolated_deallocating_deinit中调用Player.deinit
伪代码:
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,再跑成员销毁,最后归还堆内存。
- 运行时视角
Swift 对象头部有一个 32-byte 的
HeapObject,其中refCounts字段采用“Side Table” 策略。当最后一次
swift_release把引用计数降到 0 时,会立即跳到destroy函数指针 → 也就是上面的 SIL 函数。因此:
- deinit 执行线程 = 最后一次
release发生的线程; - deinit 执行耗时 ≈ 对象大小 + 成员销毁耗时 + 你写的代码耗时;
- 如果 deinit 里再产生强引用(例如把 self 塞进全局数组),对象会被“复活”,但 Swift 5.5 之后禁止这种 resurrection,会直接 trap。
- deinit 执行线程 = 最后一次
多线程与 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
- 继承链
@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 附带内存。
- 注意点
若你在 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
每次对象销毁都会“叮”,办公室同事会投来异样眼光,但你能瞬间听出内存泄漏——当该响的没响,就说明循环引用啦!
来源:juejin.cn/post/7566289235347816486
MyBatis 中 where1=1 一些替换方式
题记
生命中的风景千变万化,但我一直在路上。
风雨兼程,不是为了抵达终点,而是为了沿途的风景。
起因
今天闲来无事,翻翻看看之前的项目。
在看到一个项目的时候,项目框架用的是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("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 给了我们一个极好的解法:
一个统一的决策引擎,让权限系统既灵活又有秩序。
它不是银弹,但能让你在权限处理上的代码更纯净、系统扩展性更好。
来源:juejin.cn/post/7558094123812536361
当上组长一年里,我保住了俩下属
前言
人类的悲喜并不相通,有人欢喜有人愁,更多的是看热闹。
就在上周,"苟住"群里的一个小伙伴也苟不住了。

在苟友们的"墙裂"要求下,他分享了他的经验,以他的视角看看他是怎么操作的。
1. 组织变动,意外晋升
两年前加入公司,依然是一线搬砖的码农。
干到一年的时候公司空降了一位号称有诸多大厂履历的大佬来带领研发,说是要给公司带来全新的变化,用技术创造价值。
大领导第一件事:抓人事,提效率。
在此背景下,公司不少有能力的研发另谋出处,也许我看起来人畜无害,居然被提拔当了小组长。
2. 领取任务,开启副本
当了半年的小组长,我的领导就叫他小领导吧,给我传达了大领导最新规划:团队需要保持冲劲,而实现的手段就是汰换。
用人话来说就是:
当季度KPI得E的人,让其填写绩效改进目标,若下一个季度再得到E,那么就得走人
我们绩效等级是ABCDE,A是传说中的等级,B是几个人有机会,大部分人是C和D,E是垫底。
而我们组就有两位小伙伴得到了E,分别是小A和小B。
小领导意思是让他们直接走得了,大不了再招人顶上,而我想着毕竟大家共事一场,现在大环境寒气满满,我也是过来人,还想再争取争取。
于是分析了他们的基本资料,他俩特点还比较鲜明。
小A资料:
- 96年,单身无房贷
- 技术栈较广,技术深度一般,比较粗心
- 坚持己见,沟通少,有些时候会按照自己的想法来实现功能
小B资料:
- 98年,热恋有房贷
- 技术基础较薄弱,但胜在比较认真
- 容易犯一些技术理解上的问题
了解了小A和小B的历史与现状后,我分别找他们沟通,主要是统一共识:
- 你是否认可本次绩效评估结果?
- 你是否认可绩效改进的点与风险点(未达成被裁)?
- 你是否还愿意在这家公司苟?
最重要是第三点,开诚布公,若是都不想苟了,那就保持现状,不要浪费大家时间,我也不想做无用功。
对于他们,分别做了提升策略:
对于小A:
- 每次开启需求前都要求其认真阅读文档,不清楚的地方一定要做记录并向相关人确认
- 遇到比较复杂的需求,我也会一起参与其中梳理技术方案
- 需求开发完成后,CR代码看是否与技术方案设计一致,若有出入需要记录下来,后续复盘为什么
- 给足时间,保证充分自测
对于小B:
- 每次需求多给点时间,多出的时间用来学习技术、熟悉技术
- 要求其将每个需求拆分为尽可能小的点,涉及到哪些技术要想清楚、弄明白
- 鼓励他不懂就要问,我也随时给他解答疑难问题,并说出一些原理让他感兴趣的话可以继续深究
- 分配给他一些技术调研类的任务,提升技术兴趣点与成就感
3. 结束?还是是另一个开始?
半年后...
好消息是:小A、小B的考核结果是D,达成了绩效改进的目标。
坏消息是:据说新的一轮考核算法会变化,宗旨是确保团队血液新鲜(每年至少得置换10%的人)。
随缘吧,我尽力了,也许下一个是我呢?

来源: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 ==> ProviderContextFiltereone9f9kd21#call ==> ProviderContextFilter#eoneinvokeaccessorpclcbe2 ==> ProviderContextFilter#eoneinvokep882ot3 ==> 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 ==> StockRetryExecutor5188d6e#invoke ==> BaseStockOperation9d76cd9a#invoke ==> StockTransferServiceImpl85bb181e#invoke ==> ValidationAspect#logAndReturn ==> LogAspect#log ==> ThreadLocalRemovalAspect#removal ==> ValidationAspect#validate ==> BaseStockOperation#go ==> StockRepositoryImpl1388ef12#operateStock ==> StockTransferAppServiceImpl1095eafa#increaseStock ==> StockRepositoryImpla1b4dae4#invoke ==> StockTransferServiceImpl#increaseStock ==> DataBaseExecutor#execute ==> StockRetryExecutorb42789a#operateStock ==> StockInitializer85faf510#go ==> StockTransferServiceImplafc21975#increaseStock ==> StockRepositoryImpl#operateStock ==> DataBaseExecutor#operate ==> StockTransferAppServiceImple348d8e1#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 架构 🚀🚀🚀
最近在使用 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处理器:
- 在共享包中定义接口:
// packages/electron-ipc/src/ipc-channels.ts
export const IPC_CHANNELS = {
// ... 现有通道
NEW_FEATURE: 'new-feature',
} as const;
- 实现处理器:
// packages/electron-ipc/src/ipc-handler.ts
ipcMain.handle(IPC_CHANNELS.NEW_FEATURE, () => {
// 实现逻辑
});
- 在应用中注册:
// apps/electron-app/src/main/index.ts
const ipcConfig = new IpcConfig();
ipcConfig.setupHandlers();
- 在前端中使用:
// apps/react-app/src/components/SomeComponent.tsx
const result = await window.electronAPI.invoke('new-feature');
2. 依赖更新流程
当需要更新共享包时:
- 修改共享包:
// packages/electron-core/src/base-app.ts
// 添加新功能
- 自动更新依赖: 由于使用
workspace:*,所有依赖包自动获得更新 - 类型检查:
pnpm run typecheck
- 构建测试:
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架构不仅是一个推荐的选择,更是一个必要的架构决策,它能够显著提高开发效率和代码质量,为项目的长期发展奠定坚实的基础。
来源:juejin.cn/post/7565204846044102671
Vue3.0父传子子传父的血和泪:一个菜鸟的踩坑实录
,没有声明 scope 参数,所以 scope 是 undefined。 正确的写法应该是: 关键点: 中的 scope 参数必须声明,这样才能获取到当前行的数据。 在子组件中,我一开始这样写: 结果表单无法编辑,数据也无法正常回显。 使用 reactive 创建本地副本,并用 watch 监听props变化: 既然 ref 也能创建响应式数据,为什么非要推荐用 reactive? 既然父组件传递了数据,子组件为什么还要监听变化? Vue3的响应式系统特点: 在父组件中,我一开始这样写: 结果发现,有时候弹窗显示的数据不是最新的。 引用传递 vs 值传递: 使用展开运算符创建新对象: 为什么这样做? 经过以上踩坑,我最终实现的完整代码如下: 父组件可以直接在打开editDialog时,直接通过函数传参的形式,把要修改的一行数据传入子组件 子组件先初始化一个form表单数据 然后在打开弹窗这个openDialog方法里接收一个row参数,并将其赋值给form vue3.0子组件的属性和方法默认是不对父组件公开的,我们要使用dedineExpose方法使其对外公开 专人专事,所以编辑也就在编辑弹框里做了 父组件使用子组件也就变成了下面这样 Vue3.0的父子组件通信看似简单,但实际开发中会遇到各种细节问题: 这些坑虽然让人头疼,但踩过之后对Vue3.0的理解会更深入。希望我的踩坑经历能帮到正在Vue3.0路上奋斗的小伙伴们!
解决方案
<el-table-column label="操作" width="150">
<template #default="scope">
<el-button type="primary" link @click="handleEdit(scope.row)">编辑el-button>
<el-button type="danger" link @click="handleDelete(scope.row)">删除el-button>
template>
el-table-column>
坑二:子组件表单数据无法编辑
问题描述
<template>
<el-form :model="localEditData">
<el-form-item label="姓名">
<el-input v-model="localEditData.name" placeholder="请输入姓名" />
el-form-item>
el-form>
template>
问题原因
解决方案
坑三:为什么非要用reactive?用ref行不行?
我的疑问
原因分析
实际对比
// ❌ ref方式 - 可能有问题
const localEditData = ref({...props.editData})
// 如果props.editData是复杂对象,可能响应性不完整
// ✅ reactive方式 - 更稳定
const localEditData = reactive({
id: '',
name: '',
place: ''
})
// 明确初始化所有字段,响应性更可靠
坑四:为什么必须要监听?不用监听行不行?
我的疑问
原因分析
实际场景
// 父组件中
const handleEdit = (data) => {
editItem.value = {...data} // 数据变化
editRef.value.editDialogVisible = true // 打开弹窗
}
// 如果没有watch,子组件的localEditData不会更新
// 弹窗显示的还是上一次的数据
坑五:deep监听很消耗性能,有替代方案吗?
watch(
() => props.editData,
(newVal) => {
Object.assign(localEditData, newVal)
},
{ deep: true, immediate: true } // deep: true 会深度监听,性能消耗大
)
替代方案
方案一:浅层监听 + 手动同步
watch(
() => props.editData,
(newVal) => {
// 手动同步需要的字段,避免深度监听
localEditData.id = newVal.id
localEditData.name = newVal.name
localEditData.place = newVal.place
},
{ immediate: true } // 去掉 deep: true
)
方案二:使用计算属性
const localEditData = computed(() => ({
...props.editData
}))
方案三:监听特定字段
watch(
() => [props.editData.id, props.editData.name, props.editData.place],
([id, name, place]) => {
localEditData.id = id
localEditData.name = name
localEditData.place = place
},
{ immediate: true }
)
坑六:父组件为什么要用展开运算符?
const handleEdit = (data) => {
editItem.value = data // 直接赋值
editRef.value.editDialogVisible = true
}
问题原因
解决方案
const handleEdit = (data) => {
editItem.value = {...data} // 创建新对象,确保响应性
editRef.value.editDialogVisible = true
}
完整解决方案
父组件vue
<div class="app">
<el-table :data="list" style="width: 100%">
<el-table-column type="index" label="序号" width="80">el-table-column>
<el-table-column label="ID" prop="id">el-table-column>
<el-table-column label="姓名" prop="name" width="150">el-table-column>
<el-table-column label="籍贯" prop="place">el-table-column>
<el-table-column label="操作" width="150">
<template #default="scope">
<el-button type="primary" link @click="handleEdit(scope.row)">编辑el-button>
<el-button type="danger" link @click="handleDelete(scope.row)">删除el-button>
template>
el-table-column>
el-table>
<EditDialog
ref="editRef"
:editData="editItem"
@edit-success="handleEditSuccess"
/>
div>
<script setup>
import { ref, onMounted } from 'vue'
import EditDialog from './components/EditDialog.vue'
import axios from 'axios'
const list = ref([])
const editItem = ref(null)
const editRef = ref(null)
onMounted(() => {
getList()
})
const getList = () => {
axios.get('/list').then(res => {
list.value = res.data
}).catch(err => {
console.error('获取列表失败:', err)
})
}
const handleEdit = (data) => {
// 关键:使用展开运算符创建新对象
editItem.value = {...data}
editRef.value.editDialogVisible = true
}
const handleEditSuccess = (data) => {
axios.patch(`/edit/${data.id}`, {
name: data.name,
place: data.place
}).then(() => {
getList()
}).catch(err => {
console.error('编辑失败:', err)
}).finally(() => {
editRef.value.editDialogVisible = false
})
}
script>
子组件
<el-dialog v-model="editDialogVisible" title="编辑" width="400px">
<el-form label-width="50px" :model="localEditData">
<el-form-item label="姓名">
<el-input v-model="localEditData.name" placeholder="请输入姓名" />
el-form-item>
<el-form-item label="籍贯">
<el-input v-model="localEditData.place" placeholder="请输入籍贯" />
el-form-item>
el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="editDialogVisible = false">取消el-button>
<el-button type="primary" @click="emit('edit-success', localEditData)">确认el-button>
span>
template>
el-dialog>
<script setup>
import { ref, reactive, defineProps, defineEmits, defineExpose, watch } from 'vue'
const editDialogVisible = ref(false)
const props = defineProps({
editData: {
type: Object,
default: () => ({})
}
})
const emit = defineEmits(['edit-success'])
defineExpose({editDialogVisible})
// 使用reactive创建本地副本
const localEditData = reactive({
id: '',
name: '',
place: ''
})
// 监听props变化,手动同步字段(避免deep监听)
watch(
() => props.editData,
(newVal) => {
if (newVal && newVal.id) {
localEditData.id = newVal.id
localEditData.name = newVal.name
localEditData.place = newVal.place
}
},
{ immediate: true }
)
script>
如果不是单纯为了练习props,还有另一种方式
const handleEdit = (data) => {
// 这个我做了下更新,vue3.0讲究专人专事,所以修改dialogvisibile的事情还是让子组件自己干吧
editRef.value.openDialog(data)
}
// 表单数据
const form = ref({
name: '',
place: '',
id: ''
})
// 打开弹框
const openDialog = (row) => {
editDialogVisible.value = true
form.value = {...row}
}
// 向父组件暴露打开弹窗的方法,专人专事
defineExpose({ openDialog })
// 向父组件传递编辑完成
const emit = defineEmits(['edit-success'])
// 编辑
const update = () => {
axios.patch(`/edit/${form.value.id}`, {
name: form.value.name,
place: form.value.place
})
emit('edit-success', form.value)
editDialogVisible.value = false
}
<Edit ref="editRef" @edit-success="getList" />
总结
记住:在Vue3.0的世界里,细节决定成败! 🎯*
来源:juejin.cn/post/7522367598815576073
event loop 事件循环
什么是事件循环?
事件循环是 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)
任务队列(也称为宏任务队列)存储着待处理的任务,如:
setTimeout和setInterval回调- I/O 操作
- UI 渲染
- 事件处理程序
3. 微任务队列(Microtask Queue)
微任务队列具有更高的优先级,包括:
- Promise 回调(
.then(),.catch(),.finally()) queueMicrotask()MutationObserver
事件循环的工作流程
事件循环遵循一个简单的循环:
- 执行调用栈中的同步代码
- 当调用栈为空时,检查微任务队列
- 执行所有微任务(直到微任务队列为空)
- 检查宏任务队列,执行一个宏任务
- 重复步骤 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方面,很多东西可以复用。
比方说:表单、表格、搜索栏等等,这部分现阶段大部分都是各写各的,每个项目因为主要的开发不同,各自维护自己的一份。

但是领导现在觉得还是维护一套组件库来的比较方便,一来是减少重复工作量,提升开发效率,二来是方便新人加入团队以后尽量与老成员开发风格保持一致。
另外还有一个原因是项目内现在有的用AntDesign,有的用ElmentPlus,这些库的样式和UI设计出来的风格不搭,改起来也非常麻烦。
我听见这个提议以后后背冷汗都下来了。
我再跟大家强调一遍,不要封装组件库!
咱们说说为什么:
抬高开发成本
大部分人都感觉封装组件库是降低了开发成本,但实际上大部分项目并非如此,封装组件库大部分时候都是抬高了开发成本。
项目不同,面对的客户不同,需求也就不同,所以无论是客户方的需求还是UI设计稿都存在一定的差别,有些时候差距很大。
针对项目单独进行开发虽然在表面上看起来是浪费了人力资源,重复了很多工作,但是在后期开发和维护过程中会节省非常多的时间。
这部分都是成本。很多时候组件的开发并不是面对产品或者团队的,而是面向项目和客户的。
这也就导致了组件的开发会存在极大的不确定性,一方面是需求的不确定,另一方面是组件灵活度的不确定。
很多时候开发出来的组件库会衍生出N多个版本,切出N多个分支,最后在各个项目中引用,逐渐变成一个臃肿的垃圾代码集合体。
我不相信有人会在自己的项目上改完以后,还把修改的部分根据他人的反馈再进行调整,最后合并到 master 分支上去。
我从未见过有这个操作的兄弟。
技术达不到封装水平
团队内部技术不在一个水平线上,事实上也不可能在一个水平线上。
有些人的技术好,封装出来的组件确实很契合大多数的业务场景,有些人技术稍逊,封装出来的组件就不一定能契合项目。
但是如果你用他人封装的组件,牵扯到定制化需求的时候势必会改造,这时候改造就有可能会影响其他项目。
尤其一种情况,老项目升级,这是组件库最容易出问题的时候。可能上个版本封版的组件库在老项目运行的非常完美,但是需要升级的项目引用新的组件库的时候就会出现很多问题。
大部分程序员其实都达不到封装组件库的水准。
如果想要试一试可以参考ElmentUI老版本代码,自己封装一下Select、Input、Button这几个组件,看看和这些久经考验的开源组件库编码程序员还差多少。
技术负债严重
承接上一个问题,不是团队内每个人的水平都一样,并且每个人的编码风格也都是不一样的。(Ts最大的作用点)
可能组件库建立初期会节省非常多的重复工作,毕竟拿来就用,而且本身就是封装好的,简直可以为自己鼓掌了。
照着镜子问这是哪个天才编写的组件库,简直不要太棒了。
但是随着时间的推移,你会发现这个组件库越来越难用,需要考虑的方面越来越多,受影响的模块越来越多,你会变得越来越不敢动其中的代码。
项目越来越多,组件库中的分支和版本越来越多,团队中的人有些已经离开,有些人刚来,这时候技术负债就已经形成了。
更不要说大部分人没有写技术文档的习惯,甚至是连写注释的习惯都没有,功能全靠看代码和猜,技术上的负债越来越严重,这个阶段组件库离崩塌就已经不远了。
新项目在立项之初你就会本能的排斥使用组件库,但是对于老项目呢?改是不可能改动的,但是不维护Bug又挂在这儿。
那你到底是选择代码能跑,还是选择...

对个人发展不利
有些兄弟觉得能封装组件库,让自己的代码在这个团队,这个公司永远的流传下去,简直是跟青史留名差不多了。以后谁用到这个组件都会看到author后面写着我的名字。
但事实并非如此!
封装出的组件库大部分情况下会让你"青💩留名",因为后面的每个人用这个组件都会骂,这是哪个zz封装的组件,为啥这么写,这里不应该这么写嘛?
如果你一直呆在这个公司,由你一手搭建的这个组件库将伴随你在这个公司的整个职业生涯。
一时造轮子,一辈子维护轮子!
只要任何人用到你这个组件库,遇到了问题一定会来找你。不管你现在到底有没有在负责这个组件库!
这种通用性的组件库不可能没有问题,但是一旦有了问题找到你,你或者是解决不了,又或者是解决的不及时,都将或多或少的影响你的同事对你的评价。
当所有人都对你封装的这个组件库不满意,并且在开组会的时候提出来因为xx封装的组件库不好使,导致了项目延期,时间一长你的领导会对你有好印象?
结语
希望兄弟们还是要明白,对于一个职场人来说,挣钱最重要,能升上去最重要。其他的所有都是细枝末节,不必太在意。
对于客户和老板而言,能快速交付,把钱挣到手最重要,其他也都是无所谓的小事。
对于咱们自己来说,喜欢折腾是程序员的特质,但是要分清形势。
来源:juejin.cn/post/7532773597850206243
JavaScript 开发必备规范:命名、语法与代码结构指南
在 JavaScript 开发中,遵循良好的编程规范对于构建高效、可维护的代码至关重要。它不仅能提升代码的可读性,让团队成员之间更容易理解和协作,还能减少错误的发生,提高开发效率。本文将详细介绍 JavaScript 编程中的一些重要规范。
一、命名规范
变量和函数命名
采用小驼峰命名法,第一个单词首字母小写,后续单词首字母大写。例如firstName用于表示名字变量,getUserName函数用于获取用户名。这种命名方式能够清晰地区分变量和函数,并且让名称具有语义化,便于理解其用途。避免使用单字母或无意义的命名,如a、b等,除非在特定的循环等场景下有约定俗成的用法。
常量命名
常量通常使用全大写字母,单词之间用下划线分隔,比如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 = 5和let 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 引入了模块系统,使用export和import关键字来管理模块的导出和导入。例如,有一个处理用户数据的模块userModule.js:
// userModule.js
const userData = {
name: '',
age: 0
};
function setUserName(name) {
userData.name = name;
}
function getUserName() {
return userData.name;
}
export { setUserName, getUserName };
在这个模块中,userData和setUserName、getUserName函数都在模块内部作用域中,外部无法直接访问userData。只有通过导出的setUserName和getUserName函数,其他模块才能间接操作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 引擎执行到这部分代码时,就会立即调用这个匿名函数,所以称为 “立即执行函数”。privateVariable和privateFunction都在 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 开发同学 “你在项目中是怎么打日志的”,得到的答案竟然是 “支支吾吾”、“阿巴阿巴”,更有甚者,竟然表示:直接用 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.xml 或 logback-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种设计模式
一.为什么需要理解设计模式?
前端项目会随着需求迭代变得越来越复杂,设计模式的作用就是提前规避 “后期难改、牵一发动全身” 的坑,设计模式的核心价值:解决 “可维护、可扩展” 问题。
1.工厂模式
工厂模式:通过一个统一的 “工厂函数 / 类” 封装对象的创建逻辑,外界只需传入参数(如类型、配置),即可获取所需实例,无需关心实例内部的构造细节。核心是 “创建逻辑与使用逻辑分离”,实现批量、灵活地创建相似对象。
前端应用场景:
1.Axios 实例
2.Vue实例
3.组件库中的 “表单组件工厂”,统一管理所有表单组件的基础属性(如 id、disabled)
2.单例模式:确保全局只有一个实例
核心是为了解决 “重复创建实例导致的资源浪费、状态混乱、逻辑冲突” 问题—— 当某个对象在系统中只需要 “唯一存在” 时,单例模式能确保全局访问到的是同一个实例,从根源避免多实例带来的隐患。
前端典型场景:
1.Vuex单一store实例
2.浏览器的 window 对象
3.原型模式:通过 “复制” 创建新对象
原型模式的核心是 “基于已有对象(原型)复制创建新对象” —— 不是从零开始定义新对象的属性和方法,而是直接 “拷贝” 一个现有对象(原型)的结构,再根据需要修改差异化内容。
前端中原型模式的本质:依托 JavaScript 原型链。
JavaScript 本身就是基于原型的语言,所有对象都有 __proto__ 属性(指向其原型对象),这是原型模式在前端的 “天然实现”。
普通对象原型属性: 只有'proto'属性。
函数原型属性:proto、prototype属性。
prototype:专属属性,只有函数有,用于 "当函数作为构造函数时,给新创建的实例提供原型"。
原型链顶端: Object.prototype.proto :指向null ;
前端典型场景:
1.Object.create()
2.Vue2 的数组方法重写:Vue2 为数组的push、pop等方法添加响应式逻辑,新数组会继承这些重写后的方法。
3.继承
工厂模式与原型模式区别:
工厂模式:
基于参数 / 规则 “全新创建” 对象;
核心目的:封装复杂的创建逻辑,让调用者无需关心对象构造细节。
原型模式
基于 “已有原型对象” 复制生成新对象
核心目的:复用已有对象的属性 / 方法,减少重复定义,支持继承扩展
4.观察者模式:“一对多” 的依赖通知机制
观察者模式(Observer Pattern)是一种 “一对多” 的依赖关系设计模式:
- 存在一个 “被观察者(Subject)” 和多个 “观察者(Observer)”;
- 当被观察者的状态发生变化时,会自动通知所有依赖它的观察者,并触发观察者的更新逻辑;
- 核心是 “解耦被观察者和观察者”—— 双方无需知道彼此的具体实现,只需通过统一的接口通信
前端典型场景:
1.浏览器事件监听(最基础的观察者模式)
浏览器的 DOM 事件本质是观察者模式的实现:
- 被观察者:DOM 元素(如按钮);
- 观察者:事件处理函数(
onclick、onchange等); - 流程:给元素绑定事件(订阅)→ 元素状态变化(如被点击)→ 自动执行所有绑定的事件处理函数(通知观察者)。
- 观察者模式的核心价值是 “状态变化自动同步”
2.状态管理库(Vuex/Pinia/Redux)
Vuex、Redux 等全局状态管理库的核心机制就是观察者模式:
- 被观察者:Store 中的状态(如
state.user、state.cart); - 观察者:依赖该状态的组件;
- 流程:组件订阅状态(
mapState或useSelector)→ 状态更新(commit或dispatch)→ 所有订阅该状态的组件自动重新渲染(收到通知更新)
3. 框架的响应式系统(Vue/React)
Vue 的响应式原理(数据驱动视图)和 React 的状态更新机制,底层都依赖观察者模式:
- Vue:数据对象(
data)是被观察者,视图(DOM)和计算属性是观察者 —— 数据变化时,Vue 自动触发依赖收集的观察者(视图重新渲染、计算属性重新计算)。 - React:
setState触发状态更新时,组件树中依赖该状态的组件(观察者)会被重新渲染(收到通知执行更新)。
5.发布-订阅模式
发布 - 订阅模式是观察者模式的变体,核心是通过一个 “中间者(事件中心)” 实现 “发布者” 和 “订阅者” 的完全解耦 —— 发布者不用知道谁在订阅,订阅者也不用知道谁在发布,双方仅通过事件中心传递消息,就像 “报社(发布者)→ 邮局(事件中心)→ 订报人(订阅者)” 的关系。
- 三大角色:
- 发布者(Publisher) :负责 “发布事件”(比如触发某个状态变化,如用户登录、数据更新),但不直接联系订阅者;
- 订阅者(Subscriber) :负责 “订阅事件”(比如关注 “用户登录” 事件),并定义事件触发时的 “回调逻辑”(比如登录后显示欢迎信息);
- 事件中心(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 拦截数组,只能逐个拦截索引(如 0、1),但存在两个致命问题:
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时,数组会清空所有元素(即删除索引0、1、2),但Object.defineProperty只能知道length被改成了0,无法知道具体哪些元素被删除了。
对于响应式系统来说,需要知道 “哪些元素变化了” 才能精准通知依赖这些元素的视图。但 length 拦截只能知道 “长度变了”,无法定位具体变化的元素,导致依赖这些元素的视图可能不会更新(比如某个视图依赖 arr[0],length=0 后 arr[0] 不存在了,但视图可能还显示旧值)。
2.Vue3 用 Proxy 直接代理整个对象,能拦截所有属性的读写(包括新增、删除、数组操作),是更彻底、更灵活的代理模式实现,这也是 Vue3 响应式比 Vue2 强大的核心原因之一。
总结
最后想强调:设计模式不是必须遵守的 “规则”,而是解决问题的 “工具”。在实际开发中,我们不需要刻意追求 “用满所有模式”,而是根据场景选择合适的工具:
- 需批量创建对象 → 工厂模式
- 需全局唯一实例 → 单例模式
- .....
来源:juejin.cn/post/7563981206674817051
Android实战-Native层thread的实现方案
最近阅读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

同时也可以通过日志打印输出:
adb shell locat | grep hello

以上就是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}
项目目录如下:

通过命令带包得到android_thread可执行文件放入模拟器运行:

来源:juejin.cn/post/7501624826286669859
微服务正在悄然消亡:这是一件美好的事
最近在做的事情正好需要系统地研究微服务与单体架构的取舍与演进。读到这篇文章《Microservices Are Quietly Dying — And It’s Beautiful》,许多观点直击痛点、非常启发,于是我顺手把它翻译出来,分享给大家,也希望能给同样在复杂性与效率之间权衡的团队一些参考。
微服务正在悄然消亡:这是一件美好的事
为了把我们的创业产品扩展到数百万用户,我们搭建了 47 个微服务。
用户从未达到一百万,但我们达到了每月 23,000 美元的 AWS 账单、长达 14 小时的故障,以及一个再也无法高效交付新功能的团队。
那一刻我才意识到:我们并没有在构建产品,而是在搭建一座分布式的自恋纪念碑。

我们都信过的谎言
五年前,微服务几乎是教条。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 勉强粘合在一起的微服务乱麻走得更远。
微服务并不是因为“糟糕”而式微,而是因为我们出于错误的理由使用了它。我们选择了分布式的复杂性而不是本地的自律,选择了运营的负担而不是价值的交付。
那些悄悄回归单体的公司并非承认失败,而是在承认更难的事实:我们一直在解决错误的问题。
所以我想问一个问题:你构建微服务,是在逃避什么?
如果答案是“一个凌乱的代码库”,那我有个坏消息——分布式系统不会修好坏代码,它只会让问题更难被发现。
来源:juejin.cn/post/7563860666349649970
electron-updater实现热更新完整流程
最近项目做了一个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里就可以了。
完成截图


来源:juejin.cn/post/7054811432714108936
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上的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

同时也可以通过日志打印输出:
adb shell locat | grep hello

以上就是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}
项目目录如下:

通过命令带包得到android_thread可执行文件放入模拟器运行:

来源:juejin.cn/post/7501624826286669859
聊聊SliverPersistentHeader优先消费滑动的设计
Flutter中想在滚动体系中实现复杂的效果,肯定逃不开SIlver全家桶,Sliver中提供了很多好用的组件可以实现各种滚动效果,而如果需要实现,QQ好友分组的那种吸顶效果,Flutter可以可以使用SliverPersistentHeader轻松的做到。
带动画的吸顶滑动
那如果需求再复杂一点,吸顶组件滑动的同时还希望增加吸附的动画效果,其实SliverPersistentHeader也可以很轻松的实现。

但是实现这个效果有一个特殊的“Feature”,他会优先消费滑动事件,导致底部滑动没有在我们预期的时机传递给上一级消费。

最近项目中处理这个问题时也没搜到相关的文章,所以今天想来聊聊这个组件的优先消费设计,以及很简单的一个定制效果。
在 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(delegate: delegate);
}
if (pinned) {
return _SliverPinnedPersistentHeader(delegate: delegate);
}
if (floating) {
return _SliverFloatingPersistentHeader(delegate: delegate);
}
return _SliverScrollingPersistentHeader(delegate: delegate);
}
@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,
hasVisualOverflow: true, // 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 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 │0 │01│ 未锁定 │
│ └────────────┴─────┴──┴──┴──┘ │
├──────────────────────────────────────────────────┤
│ 偏向锁 (101) │
│ ┌────────────┬─────┬──┬──┬──┐ │
│ │ 线程ID │epoch│1 │01│ 偏向锁 │
│ └────────────┴─────┴──┴──┴──┘ │
├──────────────────────────────────────────────────┤
│ 轻量级锁 (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
(等待) (等待)
三、功能对比大战 ⚔️
🏁 功能对比表
| 功能 | synchronized | ReentrantLock | 胜者 |
|---|---|---|---|
| 加锁方式 | 自动 | 手动lock/unlock | synchronized ✅ |
| 释放方式 | 自动(异常也会释放) | 必须手动finally | synchronized ✅ |
| 公平锁 | 不支持 | 支持公平/非公平 | ReentrantLock ✅ |
| 可中断 | 不可中断 | lockInterruptibly() | ReentrantLock ✅ |
| 尝试加锁 | 不支持 | tryLock() | ReentrantLock ✅ |
| 超时加锁 | 不支持 | tryLock(timeout) | ReentrantLock ✅ |
| Condition | 只有一个wait/notify | 可多个Condition | ReentrantLock ✅ |
| 性能(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有什么区别?
你的回答:
主要从实现层面和功能层面两个角度对比:
实现层面:
- synchronized是JVM层面的,基于monitor机制,通过对象头的Mark Word实现
- ReentrantLock是API层面的,基于AQS(AbstractQueuedSynchronizer)实现
功能层面,ReentrantLock更强大:
- 可中断:lockInterruptibly()可响应中断
- 可尝试:tryLock()非阻塞获取锁
- 可超时:tryLock(time)超时放弃
- 公平锁:可选择公平或非公平
- 多条件:支持多个Condition
- 可监控:可获取等待线程数等信息
性能对比:
- JDK 1.6之前ReentrantLock性能更好
- JDK 1.6之后synchronized做了大量优化(偏向锁、轻量级锁、锁消除、锁粗化),性能差不多
- synchronized优化包括:无锁→偏向锁→轻量级锁→重量级锁的升级路径
使用建议:
- 简单场景优先synchronized(代码简洁,自动释放)
- 需要高级功能时用ReentrantLock(可中断、超时、公平锁等)
举个例子:
如果只是简单的计数器,用synchronized即可。但如果是银行转账系统,需要可中断、可超时,就应该用ReentrantLock。
八、总结 🎯
选择决策树:
需要同步?
│
Yes
│
┌─────────────┴─────────────┐
│ │
简单场景 复杂场景
(计数器、缓存等) (可中断、超时等)
│ │
synchronized ReentrantLock
│ │
✅ 简单 ✅ 功能强
✅ 自动释放 ⚠️ 需手动
✅ 性能好 ✅ 灵活
记忆口诀:
简单场景synchronized,
复杂需求ReentrantLock,
性能现在差不多,
根据场景来选择!🎵
最后一句话:
synchronized是"自动挡"🚗,简单好用;
ReentrantLock是"手动挡"🏎️,灵活强大!
来源:juejin.cn/post/7563822304766427172
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 保存界面状态方式章节中提到过
| 特性 | remember | rememberSaveable |
|---|---|---|
| 重组时是否保留 | 是(核心功能) | 是(继承 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("查看详情")
}
}
```
```
来源:juejin.cn/post/7559435122451693622
Compose 页面沉浸式体验适配
沉浸式
所谓沉浸式就是适配状态栏和导航栏,使其和应用内容融为一体,有两种方式:
- 全屏展示应用,隐藏掉状态栏和导航栏,达到沉浸式效果;
- 将状态栏和导航栏设置为透明,应用页面内容的颜色延伸到屏幕边缘,注意这里是内容颜色不是内容本身。
实现方案
创建一个 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 添加一个灰色背景,效果如下:



这是三种导航模式的显示效果,导航模式可以在设置中更改:

可以看出三种导航模式显示效果略有不同,双按钮导航和三按钮导航模式下,导航栏会有系统配置的蒙层。
而手势导航模式下,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)
)
}
}
}
}
}
效果如下:



在 API 级别 29 及以上,auto 方法在手势导航的情况下是透明的,设置的颜色不会生效。
在三按钮和双按钮导航模式下,系统将自动应用默认的遮光层。请注意,指定的颜色都不会被使用。在 API 级别 28 及以下,导航栏将根据暗黑模式是否开启来展示指定的颜色。
- lightScrim 当应用处于浅色模式时用于背景的遮光层颜色。
- darkScrim 当应用处于深色模式时用于背景的遮光层颜色。这也用于系统图标颜色始终为浅色的设备。
SystemBarStyle.dark
创建一个新的 [SystemBarStyle] 实例。这种样式始终如一地应用指定的遮光层颜色,而不考虑系统导航模式。参数 scrim 用于背景的遮光层颜色。为了与浅色系统图标形成对比,它应该是深色的。
dark 模式很简单,无论什么导航模式、主题模式,他都显示设置的颜色。

SystemBarStyle.light
创建一个新的 [SystemBarStyle] 实例。这种样式始终如一地应用指定的遮光层颜色,而不考虑系统导航模式。
- 参数 scrim 用于背景的遮光层颜色。为了与深色系统图标形成对比,它应该是浅色的。
- 参数 darkScrim 在系统图标颜色始终为浅色的设备上用于背景的遮光层颜色。它应该是深色的。
与 dark 不同,应用可以强制设置为 light 模式,而不用随系统的主题模式变化而变化,此时 darkScrim 生效。其他情况下使用 scrim。
系统栏背景遮光层
在上面的内容中,我们知道系统会给导航栏和状态栏设置一个遮光层,导航栏和状态栏会随着系统的导航模式和主题模式而变化。
但实际上应用希望呈现沉浸式的效果,就需要无论在上面导航模式、主题模式下都呈现与内容相同的颜色效果,所以需要去掉导航栏和状态栏的遮罩。
当我们什么也不设置,只调用 enableEdgeToEdge() 时,是这样的:

调用去掉导航栏遮罩效果:
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
来源:juejin.cn/post/7429611142706855948
深入理解 JavaScript 报错:TypeError: undefined is not a function
深入理解 JavaScript 报错:TypeError: undefined is not a function
在日常的 JavaScript 开发中,几乎每个人都见过这条令人熟悉又头疼的错误信息:
🚀Taimili 艾米莉 ( 一款免费开源的 taimili.com )
艾米莉 是一款优雅便捷的 GitHub Star 管理和加星工具 ,基于 PHP & javascript 构建, 能对github 得 star fork follow watch 管理和提升,最适合github 的深度用户

作者:开源之眼
链接: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” 的最佳实践
- 使用 const/let 替代 var — 避免变量提升造成的未定义调用
- 模块化代码结构 — 保证依赖先加载
- 给函数添加类型校验
if (typeof fn === 'function') fn();
- 启用严格模式或 TypeScript — 提前发现类型问题
- 避免覆盖全局对象(如
alert,confirm,setTimeout等)
来源:juejin.cn/post/7563220648827715610
消息队列和事件驱动如何实现流量削峰
消息队列和事件驱动架构不仅是实现流量削峰的关键技术,它们之间更是一种相辅相成、紧密协作的关系。下面这个表格可以帮您快速把握它们的核心联系与分工。
| 特性 | 消息队列 (Message Queue) | 事件驱动架构 (Event-Driven Architecture) |
|---|---|---|
| 核心角色 | 实现事件驱动架构的技术工具和传输机制 | 一种架构风格和设计模式 |
| 主要职责 | 提供异步通信通道,负责事件的存储、路由和可靠传递 | 定义系统各组件之间通过事件进行交互的规范 |
| 与流量削峰关系 | 实现流量削峰的具体手段(作为缓冲区) | 流量削峰是其在处理突发流量时的一种自然结果和能力体现 |
| 协作方式 | 事件驱动架构中,事件的生产与消费通常依赖消息队列来传递事件消息 | 为消息队列的应用提供了顶层设计和业务场景 |
🔌 消息队列:流量削峰的实现工具
消息队列在流量削峰中扮演着“缓冲区”或“蓄水池”的关键角色 。其工作流程如下:
- 接收请求:当突发流量到来时,所有请求首先被作为消息发送到消息队列中暂存,而非直接冲击后端业务处理服务 。
- 平滑压力:后端服务可以按照自身的最佳处理能力,以固定的、可控的速度从消息队列中获取请求并进行处理 。
- 解耦与异步:这使得前端请求的接收和后端业务的处理完全解耦。用户可能瞬间收到“请求已接受”的响应,而实际任务则在后台排队有序执行 。
一个典型的例子是秒杀系统 。在短时间内涌入的海量下单请求会被放入消息队列。队列的长度可以起到限制并发数量的作用,超出系统容量的请求可以被快速拒绝,从而保护下游的订单、库存等核心服务不被冲垮,实现削峰填谷 。
🏗️ 事件驱动:流量削峰的指导架构
事件驱动架构是一种从更高层面设计系统交互模式的思想 。当某个重要的状态变化发生时(例如用户下单、订单支付成功),系统会发布一个事件 。其他关心此变化的服务会订阅这些事件,并触发相应的后续操作 。这种“发布-订阅”模式天然就是异步的。
在流量削峰的场景下,事件驱动架构的意义在于:
- 设计上的解耦:它将“触发动作的服务”和“执行动作的服务”从时间和空间上分离开。下单服务完成核心逻辑后,只需发布一个“订单已创建”的事件,而不需要同步调用库存服务、积分服务等。这本身就为引入消息队列作为事件总线来缓冲流量奠定了基础 。
- 结果的可达性:即使某个服务(如积分服务)处理速度较慢,也不会影响核心链路(如扣减库存)。事件会在消息队列中排队,等待积分服务按自己的能力处理,从而实现了服务间的流量隔离和削峰 。
🤝 协同工作场景
消息队列与事件驱动架构协同工作的场景包括:
- 异步任务处理:用户注册后,需要发送邮件和短信。注册服务完成核心逻辑后,发布一个“用户已注册”事件到消息队列。邮件服务和短信服务作为订阅者,异步消费该事件,实现异步处理 。
- 系统应用解耦:订单系统与库存系统之间通过消息队列解耦。订单系统下单后,将消息写入消息队列即可返回成功,库存系统再根据消息队列中的信息进行库存操作,即使库存系统暂时不可用,也不会影响下单 。
- 日志处理与实时监控:使用类似Kafka的消息队列收集应用日志,后续的日志分析、监控报警等服务订阅这些日志流进行处理,解决大量日志传输问题 。
💡 选型与注意事项
在选择和运用这些技术时,需要注意:
- 技术选型:不同消息队列有不同特点。RabbitMQ 以消息可靠性见长;Apache Kafka 专为高吞吐量的实时日志流和数据管道设计,尤其适合日志处理等场景 ;RocketMQ 在阿里内部经历了大规模交易场景的考验 。
- 潜在挑战:
- 复杂性增加:需要维护消息中间件,并处理可能出现的消息重复、丢失、乱序等问题 。
- 数据一致性:异步化带来了最终一致性,需要考虑业务是否能接受 。
- 系统延迟:请求需要排队处理,用户得到最终结果的时间会变长,不适合所有场景。
来源:juejin.cn/post/7563511245087506486
kotlin协程 容易被忽视的CompletableDeferred
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(表示是否第一次完成)。
四、注意事项与常见坑
- 不要泄漏未完成的 CompletableDeferred:把它长期暴露给全局可能会导致内存泄漏,尤其当负责完成它的组件被销毁时。
- 只在受信任的地方完成:不要让多个地方都可能完成且没序的代码混乱;若有竞态,记得检查 complete 的返回值。
- 避免把 CompletableDeferred 当作长生命周期队列:它是一次性完成的;若需要多次事件,请用 Channel / SharedFlow。
- 取消处理:如果等待者可能取消,使用 invokeOnCompletion 或 invokeOnCancellation 做清理(比如取消底层请求)。
- 完成后不要反复设置结果:complete 多次调用只有第一次有效,后续会返回 false。
- 与协程作用域的关系:CompletableDeferred 本身不是 Job,但它有 asJob() 可用于组合;也可以传入父 Job 构造(CompletableDeferred(parentJob))以便取消联动。
来源:juejin.cn/post/7564485874727550976
那些前端老鸟才知道的秘密
前端老鸟才知道的秘密: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 呢?
- 习惯成自然:用了十几年,改不过来了
- 代码压缩:
void 0比undefined字符更少 - 装逼必备:一看就是用 void 0 的,肯定是老鸟(手动狗头)
所以,到底用不用?
我的建议是:知道为什么用,比用什么更重要。
如果你是为了代码风格统一,或者团队约定,用 void 0 没问题。
如果只是为了装老司机... 兄弟,真没必要。现在面试官看到 void 0,第一反应可能是:"这人是刚从 jQuery 时代穿越过来的吗?"
最后送大家一句话:技术选型就像穿衣服,合适比时髦更重要。 知道每个工具为什么存在,比你盲目跟风要强得多。
来源:juejin.cn/post/7563635016283668531
Java 中的 Consumer 与 Supplier 接口
异同分析
Consumer 和 Supplier 是 Java 8 引入的两个重要函数式接口,位于 java.util.function 包中,用于支持函数式编程范式。
相同点
- 都是函数式接口(只有一个抽象方法)
- 都位于
java.util.function包中 - 都用于 Lambda 表达式和方法引用
- 都在 Stream API 和 Optional 类中广泛使用
不同点
| 特性 | Consumer | Supplier |
|---|---|---|
| 方法签名 | 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 的使用场景:
- 遍历集合元素并执行操作
- 处理数据并产生副作用(如打印、保存到数据库)
- 在 Optional 中处理可能存在的值
- 组合多个操作形成处理链
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 的使用场景:
- 延迟初始化或延迟计算
- 提供配置或默认值
- 生成测试数据或模拟对象
- 在 Optional 中提供备选值
- 创建对象工厂
- 实现惰性求值模式
实际应用示例
下面是一个结合使用 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:基础线程安全缓存
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"); // 自动加载
功能对比
| 功能 | 自定义Cache | Guava 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同时过期
十一、总结🎯
核心要点
- 线程安全:ConcurrentHashMap
- 过期策略:定时清理+惰性删除
- 容量限制:LRU淘汰
- 性能优化:分段锁、异步加载
- 监控统计:命中率、容量
生产建议
- 简单场景:自己实现
- 复杂场景:用Guava Cache
- 极致性能:用Caffeine
下期预告: 为什么双重检查锁定(DCL)是错误的?指令重排序的陷阱!🔐
来源:juejin.cn/post/7563511077180473386
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
);
});
}
}
时序图:完整的下载验证流程

关键要点总结
- 双重保障:下载完成 + 完整性验证 = 安全文件
- 进度反馈:让用户知道下载状态
- 自动清理:验证失败时自动删除损坏文件
- 灵活算法:支持多种哈希算法适应不同场景
- 异常处理:网络中断、文件损坏等情况的妥善处理
就像快递员小明不仅要把包裹送到,还要确保包裹完好无损一样,我们的下载管理器既要完成下载,又要保证文件的完整性!
来源:juejin.cn/post/7559190511824519187
面试官:手写一个深色模式切换过渡动画
在开发Web应用时,深色模式已成为现代UI设计的标配功能。然而,许多项目在实现主题切换时仅简单改变CSS变量,缺乏平滑的过渡动画,导致用户体验突兀。作为开发者,我们常被期望在满足功能需求的同时,打造更精致的用户交互体验。面试中,被问及"如何实现流畅的深色模式切换动画"时,很多人可能只答出使用CSS transition,而忽略了现代浏览器的View Transitions API这一高级解决方案。
读完本文,你将掌握:
- 使用View Transitions API实现流畅的主题切换动画
- 理解深色模式切换的核心原理与实现细节
- 能够将这套方案应用到实际项目中,提升用户体验

前言
在实际项目中,深色模式切换几乎是前端的“标配”。常见做法是通过 classList.toggle("dark") 切换样式,再配合 transition 做淡入淡出。然而,这种效果在用户体验上略显生硬:颜色瞬间大面积切换,即便有渐变也会显得突兀。
随着 View Transitions API 的出现,我们可以给“页面状态切换”添加炫酷的过渡动画。今天就带大家实现一个 以点击位置为圆心、扩散切换主题的深色模式动画,读完本文你将收获:
- 了解
document.startViewTransition的工作原理 - 学会用
clipPath+animate控制圆形扩散动画
核心铺垫:我们需要解决什么问题?
在设计方案前,先明确 3 个核心目标:
- 流畅过渡:避免普通
transition的“整体闪烁”,实现局部扩散过渡。 - 交互感强:以用户点击位置为动画圆心,符合直觉。
- 可扩展:方案可适配 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维欧几里得空间中从原点到指定点的距离。

在深色模式切换动画中,我们使用它来计算覆盖整个屏幕的最大圆形半径:
斜边计算:
Math.hypot(maxX, maxY):使用勾股定理计算从点击点到对角的距离

clip-path

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即将发生变化,并允许我们为这些变化创建平滑的过渡动画。
生命周期与关键事件
- 调用startViewTransition:浏览器准备开始视图转换
- 执行回调函数:DOM状态更新
- transition.ready事件:视图转换准备就绪,可以应用动画
- 视图转换完成:动画结束,新状态成为稳定状态
浏览器兼容性处理
在实际应用中,我们需要检查浏览器是否支持此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
- 用户交互:用户点击切换按钮,触发主题切换流程
- 浏览器兼容性检查:判断当前浏览器是否支持View Transitions API
- 降级处理:在不支持API的浏览器中直接切换主题
- 动画核心逻辑:
- 获取点击位置作为动画起点
- 计算覆盖全屏的最大半径
- 启动视图转换过程
- 状态更新:实际执行主题状态更新和CSS类设置
- 动画触发:在视图转换准备就绪后,应用clipPath动画效果
- 完成:动画结束,新主题状态稳定
步骤 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 确保层级正确,否则可能看到“旧页面覆盖新页面”的异常。
效果演示

运行后:
- 点击切换按钮时,以点击点为圆心,圆形扩散覆盖全屏,主题在扩散动画过程中完成切换。
- 若浏览器不支持
View Transitions API(如 Safari),则自动降级为普通切换,不影响使用。
完整demo
延伸与避坑
- 兼容性问题
- View Transitions API 目前在 Chromium 内核浏览器(Chrome 111+、Edge)可用,Safari/Firefox 尚未支持。
- 可加上
isSupported判断,优雅降级。
- 性能优化
- 动画时建议避免页面过多重绘(如大量图片加载),否则会掉帧。
- clip-path 本身是 GPU 加速属性,性能较好。
- 扩展思路
- 除了圆形扩散,还可以用
polygon()实现“百叶窗切换”或“对角线切换”。 - 可以结合 路由切换 做“页面级过渡动画”。
- 除了圆形扩散,还可以用
总结
本文我们用 Vue3 + Element Plus + View Transitions API 实现了一个点击扩散式的深色模式切换动画,核心要点:
- startViewTransition:声明 DOM 状态切换的动画上下文。
- clipPath + animate:控制过渡动画形状与过程。
- computeMaxRadius:计算圆形覆盖全屏的半径。
- 优雅降级:确保不支持 API 的浏览器仍能正常切换。
来源:juejin.cn/post/7546326670648328219
真正的乐观,是做好被裁员的准备 | 跳槽决策四步法
引言
进入社会后,除了结婚、买房这类重要的事情外,跳槽、选择工作是我们最重要的决策。
每次跳槽,都决定了未来一段时间你处于的行业、岗位、收入,在一定程度上影响你的生活方式。
可就是如此重要的事情,我过去几次换工作,做的都不是太好。
我或许会每天都刷招聘网站,可就算刷到了意向的职位,也迟迟不敢在软件上点下“发送简历”按钮,可能是怕准备不充分、怕行情不好、怕离开熟悉的环境……结果拖到最后某一刻,被动离开。
最近看了一本书叫《怎样决定大事》,里面提到了一些做决策的方法,我试着把这套理论用在跳槽上,聊聊怎么样做出最清醒的跳槽决策。
核心用十六个字可以概括:看清处境,把握时机,避免直觉,适应局面,下面正文开始。
看清处境
马云说过员工离职就两个原因:钱没到位,心委屈了。
但真正让人下定决心离职的,从来不是这么简单的二选一,而是一连串复杂又难以理清的现实。
- 比如年底一到,领导又说你没达预期,绩效一如既往地一般;
- 办公室政治让你无所适从,干着最多的活,背着最大的锅;
- 甚至公司的方向都让你怀疑未来是否值得继续坚持。
这些都让你有离职的想法,但是很多小事也不是不能忍。工资算不上多吧,但也是符合市场水平的。繁琐的工作干着有点烦, 但起码已经轻车熟路。
如果你也在犹豫和纠结,首先要弄清楚你自己的处境,你需要有「情景意识」,情景意识分为三个层次

第一层,了解已经发生了什么。
这里就是刚刚提到的,比如不涨薪、领导pua、工作对自己没有任何成长,这些是已经发生的事情。
第二层,了解为什么会发生这种情况。
这里你思考导致现状的原因,比如技术水平不足,领导并没有给你涨薪。也有可能是公司所处的行业发展停滞,公司大量裁员,导致你工作越来越累。也有可能是你的领导没有眼光,发现不了你的优秀。
但需要注意的是,你要分析两到三种可能性,不是一种,也不是十种。
为什么不是一种?因为如果你头脑中只有一种解释,一旦判断错了,你的努力可能就毫无意义,甚至走向错误的方向。
比如工作经验比较少的程序员在遇到工作瓶颈时,常常会下意识归因为“我是不是太菜了?”。
毕竟程序员天生有技术思维,认为技术可以解决所有问题,性能问题?优化代码。bug频发,重构核心逻辑。
但你以为的问题,不一定是问题的全部。
比如现实世界有很多种可能:你的领导根本没打算提拔你,无论你多努力;你所在的部门业务边缘化,再怎么出色也没有舞台;公司战略转向AI,传统技术深耕已经不再受重视……
为什么不是十种?因为你如果考虑的原因太多,你的大脑就会陷入“分析瘫痪”,最终你什么决定也做不了。你需要抓大放小,找准核心矛盾,忽略那些无关紧要事情。
理清发生了什么、为什么发生,我们才能看清——未来会发生什么。
第三层,据此预测接下来会发生什么。
预测未来可能发生的情况,有一个反人性的技巧,是主动思考最坏的结果。
举个例子,你的公司因为经营原因,已经经历了两轮大规模裁员了,幸运的是一直没有裁到你,领导也安慰你好几次:“放心,你很重要。”
你该因为自己没被裁而庆幸吗?事实上你必须做好最坏的打算,那就是你会出现在下一轮的裁员名单上。
你需要提前思考对应的策略,比如开始评估外面的机会,更新简历,提前做准备。那么即使最坏的情况出现,你也不会猝不及防、惊慌失措。
未来是有不确定性的,我们往往会回避思考可怕的结果,但这会让自己在最坏的事情发生时,带来更多的伤害。
就像现在AI快速发展,几年内随时都有可能替代绝大部分基础性岗位,甚至高级的程序员也会被替代,那么我们必须做好现有岗位随时被替代的准备。
真正的乐观,是认真思考最坏的结果后,发现自己扛得住。
把握时机
毕业后我在济南工作,由于工资略显寒酸,互联网发展火热,我便有了去北京工作的念头。
念头归念头,回到现实我就怂了。那时候我根本没有工作经验,异地找工作这件事对我也很陌生,我不知道自己能不能找到工作,更不知道面试都会问什么技术问题。
我一想到这些就感觉头脑一片空白,想准备却无从下手。于是,我的选择是靠打游戏麻痹自己,开始拖延。
拖延了差不多半年,最后因为频繁出差,冲动之下选择裸辞去了北京。由于没有充分的准备,也是历经一番波折。
回顾这段经历,因为离职这件事没有明确的截止时间,我陷入了两种极端:要么因为恐惧未知,反复拖延,最后什么也没做;要么因为短期情绪,冲动行动。
决策不只是决定做什么,还有决定什么时候做。
先说说怎么避免冲动,那就是在做出离职决定之前,你需要先问自己一个简单的问题: “我需要现在离职吗?”
如果答案是否定的,就不着急做出决策。
这是因为我们很容易陷入情绪当中。

比如你给领导提的好几个建议都不被采纳,感觉收到了冷落;技术不如你的同事拿到了比你还好的绩效,或者项目突然增加导致频繁加班。
程序员一定都听过“不要裸辞”这个忠告,一开始我认为这是因为离职后你可能会以为没有收入,导致面试的心态越来越不稳。后来我觉着这个忠告最大的作用,就是避免我们陷入情绪当中,一上头选择裸辞。
就像我当时裸辞后去了北京,由于没有任何准备,投了半个多月简历,一共就接到4个面试,绝大部分投递的简历都是已读不回。
你可能会说我技术很强,面试准备的非常充分,那我是不是可以随时选择离开呢?
你的确会有更多的底气,但是招聘是有招聘旺季的,比如所谓的“金三银四、金九银十”,因为正好处于企业全年、半年总结,企业会根据未来的计划进行人力盘点,释放岗位。但过去这两个节点,比如十一月份到来年一月份,那就是企业的招聘淡季,甚至是裁员季,如果你十月份离职,极容易遇见投递的简历大部分都未读未回。
诸葛亮已经万事俱备,那也得等等东风。
但是,等一等不意味着你什么也不做,你需要积极收集和换工作相关的信息。
改简历、刷题就不说了,现在什么行业比较火热?招聘的要求比起几年前有什么变化?未来什么样得企业最有发展前景?如果离职找工作不顺利,财务状况有没有什么影响?
这些都需要大量信息,并且充满不确定性,所以你需要去主动收集和了解。
当然了,你也不能一直准备下去,就像刷算法、刷面试题这件事,准备的越久,就会陷入边际效应递减,你不可能把所有的知识都学会,对吧?
这时候你就需要给自己制定一个时间框架,比如专心准备3个月,这期间不去面试。3个月后无论准备的如何,都必须让自己开始投递简历面试,避免回避和拖延。
避免直觉
你可能已经了解过很多认知陷阱:确认偏误让我们只寻找支持自己观点的信息;可得性启发让我们高估容易想起的事件发生概率;首因效应让我们过度依赖最初信息。
我举几个找工作容易陷入的认知陷阱。
第一个是「投射偏差」,比如把过去跳槽必涨薪的经验,投射到现在和将来,忽视了市场环境的变化。
18年我去北京时,互联网发展依旧火热,大厂扩招、抢人,程序员跳槽涨薪50%、80%都不是什么难事,如果你在大数据、P2P火热的时候进入相关企业,薪资翻倍的例子屡见不鲜。
可后来随着互联网增速放缓,涨薪越来越难,疫情之后各类企业发展不顺,别说涨薪了,如果被裁员被动找工作,平薪、降薪也都是有可能的。
如果你还按老的认知来,发现怎么涨薪总是不如预期,自然是心理落差极大,如果因为这个拒绝了一些各方面都不错的offer,那就太可惜了。
第二个是「短期结果焦虑」,过于关注短期结果成败,忽略了长远目标和发展。
你做足了准备,兴致勃勃的开始投简历,一连投了十几家都没接到面试,好不容易接到几个面试,结果全都在一面就挂了。
也许你的简历有硬伤,也许是没有准备充分,这很正常,查缺补漏,继续前行就好。
但你不能陷入焦虑和自我怀疑:我履历太差了,好公司根本不会看我的简历;我能力太差了,大厂的面试我根本不可能过。
最可怕的情况就是,因为面试不顺利,仓促入职一家并不满意的公司。

第三个是单一维度决策,面对offer选择时,我们有可能陷入单一维度决策,比如是否大厂,薪资是否足够高,这是我自己总结出来的。
假设你这时候已经拿到了好多个offer,你该选择哪家企业入职呢?你可能特别关注薪资情况,你强烈的倾向于最高薪资的那个offer。你特别在乎名气,于是选择市场上名气最大的那个。
事实证明只考虑一个因素肯定不行,薪资最高的那个可能工作时间长还996,时薪并不比别的offer高。你的确入职了名气最大的那个企业,但做的不是核心业务,绩效不行,技术也没有什么成长。
我之前写过一篇文章,里面介绍了一个简单公式。比如在职业发展中,我觉着几个比较重要的是行业前景、公司文化和具体岗位,薪资当然也是我们衡量的一个重要指标,但其他的因素我们只做参考,而不能作为决策的决定因素。
对于选择offer这件事,我们也可以借助这个思路,识别几个你认为最重要的核心因素进行打分,选择总分最高的那一个。
别考虑太多,也不能考虑太少,这样才能做出最佳决策。
适应局面
即使决策已经做出,一切也并没有结束,你需要持续评估和调整,不断适应新的局面。
而我们面对新局面的反应,在很多时候是有点慢的。
这里我不得不提到AI,我谈不上对AI有着多深的见解,但当今AI巨头的模型,都已经具备了“完成小块的复杂代码”的能力。
我看到网上的一个预测,不出两年,就可以训练出一个可以自我迭代、不断尝试的AI编程高手。
高级程序员,将是最早一批开始被替代的。
当然,被替代的不仅是程序员行业,绘画、设计、金融、编辑,都面临着这个局面。
我提到AI,就是想提醒大家,对于处在行业第一线的我们,对于AI的适应能力有多高?
适应能力强的人,已经逐步用AI去完成越来越多的工作。而适应能力差的人,甚至现在遇见问题还是习惯性的打开搜索引擎,一点一点的翻看别人的资料。
我刚毕业时,深钻技术对职业生涯只有好处,没有坏处。但现在的局面是,如果还一股脑的让自己陷入到源码里面,不如用好AI,解放自己。
面对技术变革,就算没有应用,也要强迫自己了解。
最可怕的就是认为一些变化都与自己无关。
说在最后
做重大决策,主要分四步:看清处境,把握时机,避免直觉,适应局面。
这四步并不只用于跳槽,职业转换、城市迁移、关系选择、生活方式改变,都可以依靠这个模型去思考和行动。
你或许觉着这太麻烦了,但想想我们花了多少时间在鸡毛蒜皮的小事上?可能网购一件物品,为了价格货比三家;吃午饭订外卖,在各种美食间反复纠结;早上为了选择穿什么衣服,不断尝试。
把时间浪费在这些上面,却在重要的决策上匆匆决定,岂不是本末倒置吗?
这是东东拿铁的第88篇原创文章,欢迎关注,喜欢请三连。
来源:juejin.cn/post/7538357382453657626
SwiftUI redraw 机制全景解读:从 @State 到 Diffing
为什么 UIKit 程序员总问“我的状态去哪了?”
| 特性 | UIKit | SwiftUI |
|---|---|---|
| 视图定义与生命周期 | 视图为类(Class),生命周期明确,长期驻留内存 | 视图为值类型(Struct),每次刷新生成新实例 |
| 状态保存方式 | 状态保存在视图对象内部 | Struct 销毁后,状态需由外部系统(如 ObservableObject、@State 等)托管 |
SwiftUI 提供了一堆 Property Wrapper 来“假装”状态还在视图里,核心就是 @State。
@State 到底做了什么?(4 步流水线)
SwiftUI 把一次刷新拆成 4 个微观阶段:
- Invalidation(打脏标)
对用到的属性插 依赖旗标;值改变时插旗为 dirty。
- Recompute(重算 body)
只重算脏旗波及的 body;没读到值的 State 直接跳过。
- Diffing(结构差异)
旧的 View 树 vs 新的 View 树,找出最小集合。
- Redraw(GPU 提交)
Core Animation 仅把真正改动的图层提交给 GPU。
Attribute 系统:给“视图模板”注水
struct DemoView: View {
// 1️⃣ 在视图首次出现时,SwiftUI 为其创建一个持久化的存储槽位
@State private var threshold: CGFloat = 50.0 // ← 生成一个 attribute
var body: some View {
VStack { // ← 生成一个 attribute
Button("改变") {
threshold = 41.24 // 2️⃣ 写入新值 -> 生成 Transaction
}
Text("当前阈值 \(threshold)") // 3️⃣ 读取值 -> 建立依赖
}
}
}
- Transaction:同一“事件循环”里所有 State 变化打包成一次事务。
- Cascade Flag:只要
threshold被打脏,所有读过它的 attribute 都会被连锁打脏。 - Rule:body 里没读到 = 不 recomputed。官方 Instrument 里会显示
body(skipped)。
身份稳定:为什么“同一个”视图才能保持 State
// ❌ 错误示范:切换分支时 struct 类型相同,但身份不同 -> State 丢失
struct MyMusic: View {
@State private var rockNRoll = false
var body: some View {
VStack {
if rockNRoll {
MusicBand(name: "The Rolling Stones") // 新身份
} else {
MusicBand(name: "The Beatles") // 另一个身份
}
}
}
}
// ✅ 正确姿势:保证身份稳定(使用相同视图,只改参数)
struct MyMusic: View {
@State private var rockNRoll = false
var body: some View {
MusicBand(name: rockNRoll ? "The Rolling Stones" : "The Beatles")
}
}
口诀:
“同一视图,不同入参” 用参数传值;
“不同视图” 用 if/else 就会换身份,State 清零。
Body 重算粒度实验
只写不读 → 跳过
struct MovieDetail: View {
let movie: Movie
@State private var favoriteMovies: [String] = []
var body: some View {
VStack {
Button("加收藏") {
favoriteMovies.append(movie.name) // 只写
}
}
}
}
Instrument 显示:MovieDetail.body [skipped]
读写 → 重算,但子视图可跳过
var body: some View {
VStack {
HStack { // 👈 重算,因为读 favoriteMovies
Text(movie.name)
Image(systemName: favoriteMovies.contains(movie.name) ? "star.fill" : "star")
}
Artwork() // 👈 没传参,不 recomputed
Synopsis()
Reviews()
}
}
经验:把“纯展示”拆成无参子视图就能躲过重算。
Equatable:手动告诉 SwiftUI“别算我”
struct FlightDetail: View, Equatable {
let flightNumber: Int
let isDelayed: Bool
// 自定义相等:只看航班号
static func == (lhs: Self, rhs: Self) -> Bool {
lhs.flightNumber == rhs.flightNumber
}
var body: some View {
VStack {
Text("航班 \(flightNumber)")
Text(isDelayed ? "延误" : "准点")
}
}
}
- 若 struct 全是 POD 类型(Int/Bool…),SwiftUI 会跳过你的
==,直接按位比较。 - 想让自定义相等生效:
- 包一层
EquatableView(content: FlightDetail(...)) - 或者
.equatable()修饰符。
- 包一层
@Observable vs ObservableObject:从“对象级”到“属性级”
| 特性 | Combine(旧) | Observation(新) |
|---|---|---|
| 监听机制 | 监听 objectWillChange 发布者 | 监听具体属性的 KeyPath 变化 |
| 更新范围 | 任意 @Published 属性修改触发整个 body 重算 | 仅读取了被修改属性的 body 部分重算 |
| 属性包装器要求 | 需通过 @StateObject/@ObservedObject 管理可观察对象 | 可直接使用 @State var model = MyModel() 声明模型 |
@Observable
final class Phone {
var number = "13800138000"
var battery = 100
}
struct Detail: View {
let phone: Phone // 无需 ObservedObject
var body: some View {
Text("电池 \(phone.battery)") // 只当 battery 变才重算
}
}
扩展场景:把知识用到“极端”界面
- 万级实时股票列表
- Model 用
@Observable把price单独标记; - 行视图实现
Equatable仅对比symbol+price; - 收到 WebSocket 推送时只改
price,其余字段不动 → 一行只重算自己。
- 复杂表单(100+ 输入框)
- 把每个字段拆成独立子视图;
- 用
@FocusState+@ObservableFormModel,保证敲一个字只重算当前TextField; - 提交按钮用
.equatable()锁定,输入过程不刷新。
- 大图轮播 + 陀螺仪
@State保存偏移;- 用
TimelineView按帧读陀螺仪,但把昂贵的图片解码放到后台Task; - 仅当图片索引变化才改
Image(source),避免每帧 diff 大图。
个人总结:从“魔法”到“可预测”
SwiftUI 的刷新机制看似黑盒,实则高度 可确定:
“谁依赖,谁重算;谁相等,谁跳过;谁不变,谁不绘。”
把它当成一个依赖追踪引擎而非“UI 库”,就能解释所有现象:
- 状态放对位置(身份稳定);
- 依赖剪到最细(读多少算多少);
- 比较给到提示(Equatable/Observable);
- 性能用 Instrument 量化(Effect Graph + Core Animation)。
掌握这四步,SwiftUI 不再是“玄学”,而是可推导、可度量、可优化的纯函数式渲染管道。
参考资料 & 工具
- 官方文档:SwiftUI State
- WWDC 23:Discover Observation in SwiftUI
- Xcode 15 ▸ Instruments ▸ SwiftUI template ▸ Effect Graph
- SwiftUI 重绘系统深入了解:属性、重新计算、差异和观察
来源:juejin.cn/post/7556247403710496820
为VSCode扩展开发量身打造的UI库 - vscode-elements
大家好,我是农村程序员,独立开发者,行业观察员,前端之虎陈随易。我会在这里分享关于 独立开发、编程技术、思考感悟 等内容,欢迎关注。
技术群与交朋友请在个人网站联系我,网站 1️⃣:chensuiyi.me,网站 2️⃣:me.yicode.tech。
如果你觉得本文有用,一键三连 (点赞、评论、转发),就是对我最大的支持~

最近抽空在做我的 VSCode 插件 fnMap (函数地图) 的重构工作。

项目结构主要分为 3 部分:
src-extension是扩展的核心能力部分 (相当于后端)。src-webview是界面展示部分 (相当于前端)。src-wasm是新添加的部分,使用国产编程语言MoonBit来写,主要功能就是提供性能优化与部分核心代码加密。

那么重构呢,我想把 UI 换一下,目前用的是 arco-design-vue,字节出品的一个 UI 框架。

本来蛮喜欢的,但根据最近的更新来看,官方主要做 React 版本去了,Vue 版本 4 个多月没动静了。
而且,VSCode 有主题功能,框架如果要适配主题,那得进行不少魔改微调。

于是,我上下求索,找到这样一个为 VSCode 量身打造的 UI 库 vscode-elements。
这效果,与 VSCode 简直绝配。
开源地址在这:https://github.com/vscode-elements/elements
如果你也在开发 VSCode 扩展,不妨了解一下这个。

在我这目前看到的唯一的缺点呢,就是还没有 Vue 版本。

目前的主要版本是基于 Lit 这个框架开发的,也就是前端标准的 Web Components 技术。

我跟踪这个框架几个星期了,今天 vscode-elements 刚发布 v2.0 版本,是时候为我的 fnMap 提供一份力量了。
也欢迎大家来体验我的 fnMap 插件,8月份 (本月) 将会发布 v9.0 版本,在进化的路上,一路前进。
来源:juejin.cn/post/7533807870188470311
Lambda 底层原理全解析
是否好奇过,这样一行代码,编译器背后做了什么?
auto lambda = [](int x) { return x * 2; };
本文将带你深入 Lambda 的底层
一、Lambda回顾
auto lambda = [](int x) { return x + 1; };
int result = lambda(5);
lambda我们很熟悉,是一个对象。
完整语法:[捕获列表] (参数列表) mutable 异常说明->返回类型{函数体}
基本的用法就不说,说几个用的时候注意的点
- & 捕获要注意悬垂引用,不要让捕获的引用,被销毁了还在使用
- this指针捕获,引起的悬垂指针
class MyClass {
int value = 42;
public:
auto getLambda() {
return [this]() { return value; }; //捕获 this 指针
}
};
MyClass* obj = new MyClass();
auto lambda = obj->getLambda();
delete obj;
lambda(); //this 指针悬垂
C++17解决:*this捕获,直接拷贝整个对象
return [*this]() { return value; }; // 拷贝整个对象
3.每个lambda都是唯一的
auto l1 = []() { return 1; };
auto l2 = []() { return 1; };
// l1 和 l2 类型不同!
// typeid(l1) != typeid(l2)
4.转换为函数指针
// 不捕获变量→可以转换
auto l1 = [](int x) { return x + 1; };
int (*fp)(int) = l1;//正确
// 捕获变量→不能转换
int a = 10;
auto l2 = [a](int x) { return a + x; };
int (*fp2)(int) = l2; //编译错误
记住这句话:函数指针=纯粹的代码地址,你一旦有成员变量,operator()就会依赖对象状态(a),无法转换为函数指针,函数指针调用时,不知道a的值从哪里来。
简单来说:lambda本质是对象+代码,而函数指针只能表示纯代码
解决方式:function(可以直接存储Lambda对象)
5.混淆了[=] 和 [&]
class MyClass {
int value = 100;
public:
void test() {
auto lambda = [=]() { //看起来按值捕获
std::cout << value << std::endl;
};
//等价于 [this],捕获的是this指针
//等价于this->value
}
};
6.lambda递归
auto factorial = [](int n) { //无法递归调用自己
return n <= 1 ? 1 : n * factorial(n - 1); // 错误:factorial 未定义
};
//正确做法:C++23显式对象参数
auto factorial = [](this auto self, int n) { // C++23
return n <= 1 ? 1 : n * self(n - 1);
};
7.移动捕获
void process(std::unique_ptr<int>&& ptr) {
auto lambda = [p = std::move(ptr)]() { //移动到 Lambda
std::cout << *p << std::endl;
};
//错误做法
//auto lambda = [&ptr]() { //捕获的是引用
//std::cout << *ptr << std::endl;
//可能导致ptr移动后lambda失效.
lambda();
}
二、Lambda 的本质
Lambda不是普通的函数,也不是普通的对象,它是一个重载了operator()的类对象。
现在来证明一下:代码如下
#include <iostream>
int main() {
auto lambda = [](int x) { return x * 2; };
int result = lambda(5);
std::cout << result << std::endl;
return 0;
}
gdb证明:

观察到lambda是一个结构体,且大小为1字节
引申出几个问题
- 为什么这里是一个空的结构体?
- 为什么大小为1字节?
- 还没有证明他是一个重载了operator()的对象
问题1:为什么这里是一个空的结构体?
我们来按值捕获参数试试:
int main() {
int y=2;
auto lambda = [=](int x) { return x * 2+y * 3; };
int result = lambda(5);
std::cout << result << std::endl;
return 0;
}
gdb:

哦,原来捕获对象会存在这个结构体中,同时我们发现大小为4字节,就为数据的大小。
那我们捕获引用试试呢?

同样也是引用数据类型,但是由于引用底层是存着对象的地址,所以它的大小为8字节,是一个指针的大小。
回到上面,为什么我们一开始的结构体什么数据都没有还是为1字节呢,C++规定了空类大小不为0,最小为1字节(保证每个对象都有唯一的地址)
总结:引用或按值捕获的数据被存在lambda对象内部
问题2:证明他是一个重载了operator()的对象
(1)gdb继续调试:

可以看到,确实是调用了一个operator()
(2)我们在用C++ Insights验证一下
访问:cppinsights.io/
可以查看编译器实际生成的完整类定义

关注到operator()后面是一个const,说明不可以修改捕获的变量,mutable加上后const消失,可自行验证
来源:juejin.cn/post/7564694382999994406
老黄预言成真!全球首个 AI 原生游戏引擎,一句话秒出 GTA 级神作

「【新智元导读】谁曾想,「AI 竟能实时生成」** GTA 级大作。刚刚,谷歌、英伟达等机构联手,震撼发布全球首款 AI 原生 UGC 游戏引擎——Mirage,没有预设关卡,一句话即生游戏,超长十分钟沉浸式体验。」**
全球首款 AI 原生 UGC 游戏引擎诞生了!
今天,谷歌、英伟达、微软等八大机构联手,一同祭出了这款实时 AI 游戏引擎——Mirage。

它不同于传统游戏引擎,而是玩家想象力的「放大器」,任何人可以随心所欲「造」游戏!
如今,团队直接上线了两个超燃的实时演示(试玩版本),一个是 GTA 风格的都市乱斗(Urban Chaos)。
游戏操作延用传统风格,你可以自由走动,移动视角并且奔跑。

另一个是极限竞速:地平线风格的海岸漂移(Coastal drift)。

AI 原生 UGC 游戏的神奇之处就在于,你可以任意改变游戏内容。
比如在 GTA 风格的都市乱斗中,可以通过 Enter 键调出「世界控制」面板后,选择「阴云密布大雨降至」。
游戏画面马上就从晴空万里变成了黑云压城。

比如,在上面的海岸漂移中,通过 Enter 键调出「世界控制」面板后,可以输入「进入沙漠」。

然后游戏画面就会从绿洲直接变成沙漠!简直比《头号玩家》还要一颗赛艇!

老黄曾预言,「用不了十年,我们就能看到游戏中每个像素都是由 AI 实时生成的」。

Mirage 的登场,不仅让这一愿景更近一步,也预示着未来的游戏产业将迎来巨变。

就连 Hassabis 也很看好 Veo 3 生成的视频游戏

「「动嘴」实时体验 GTA 大作」
「10 分钟超长游玩」
传统游戏中,城市布局、任务剧情,一般都是事先设定好的,体验终究有限。
Mirage 彻底打破了这一局限,让玩家在游戏过程中动态创造全新的体验。

AI 大神谢赛宁大赞:太酷了
现在,仅通过自然语言、键盘输入,或是游戏柄,玩家即可请求一条逃生的小象、生成一辆载具,或即时扩展的城市天际线。
举个栗子,来一场倾盆大雨,动动嘴皮子就成了。

再比如,玩超级马里奥时,一句「出现砖块」,Mirage 就会为你实时生成。

可以看到,游戏会立即给出响应,将生成的元素,无缝融合到正在进行的模拟中。
这个 AI 游戏世界不仅仅是可交互的,还能与玩家「共同演化」。

此前,初创公司 Decart 和 Etched 上线的实时神经游戏引擎 Oasis,实现了 20 帧 / 秒的零延迟交互。

这一次,Mirage 的强大能力,直接掀翻了天花板,究竟有多硬核?
· 16 FPS 流畅可玩:在标准清晰度下,享受实时交互体验。
· 动态用户生成内容(UGC):玩家可以用自然语言指令改变世界。
· 更长游戏时间:Mirage 可生成长达数分钟、且视觉效果连贯的交互式游戏体验。
· 云端串流:无需下载,随时随地享受即时跨平台游戏。
· 无限可玩性:每一次游戏,都是独一无二的体验。
· 多模态操控:支持文本、键盘及手柄输入。
从今天起,玩家可以彻底告别千篇一律的预设关卡,在 Mirage 中无限编辑、扩展,甚至从零打造全新的世界。
更令人惊艳的是,Mirage 的画面和操作感,直逼 GTA、Forza 风格的沉浸式体验,远超「我的世界」、「毁灭战士」那样简约化的风格。
它还能支持 5-10 分钟,甚至更长时间的持续游玩,打破了短短几秒的片刻体验。
不论是赛车游戏、角色扮演游戏,还是平台跳跃游戏,Mirage 都能一键生成。
接下来,一起看看 Mirage 如何重塑 UGC 2.0。

「UGC 2.0」
「生成式游戏崛起」
什么是生成式游戏?
就是游戏的未来并非由专家设计师设计,而是完全靠你的想象实时生成、实时游戏。
比如你输入「一辆黄色计程车突然从街角出现,出现在主角右边」。
然后游戏画面中就会实时「生成」一辆描述中的黄色计程车。

再比如输入「一辆灰色轿车从街道右边出现,并停在主角身边」,然后你就会发现游戏画面中「真的出现」一辆灰色轿车。
你甚至可以跳到轿车的车顶。

传统游戏,不论是休闲游戏还是开放世界的 3A 大作,都是预先创作好的。
城市布局、任务剧情都是事先设定好的,玩家的体验最终有限。
Mirage 打破了这一界限,通过自然语言和控制器,玩家可以如同开了「外挂」一般,可以随时随地按照想象扩展游戏。
比如请求一条逃生的小巷、或者生成一辆载具。
MIRAGE 可以生成各种类型的游戏——从竞速游戏到角色扮演游戏再到平台动作游戏。
你可以驾驶未来战机飞行于空旷的末日世界。
这就是 AI 原生 UGC 2.0,在这里,你就是造物主。
在这里,任何人都可以通过简单的文本提示生成属于自己的游戏。
在这里,玩家可以在游戏过程中实时创造、进化并重塑游戏内容。
并且,每一次体验都是独特、动态且无需预先编写脚本的。

「背后技术」
「「世界模型」立大功」
Mirage 的核心是一个实时的交互式「世界模型」,基于 Transformer 和扩散模型完成训练。
该框架整合了多项关键创新,由此能够生成可控、高保真的视频游戏序列。

Mirage 的强大体验,是建立在训练基础之上。
最关键因素,便是从互联网上收集的大规模多样化的游戏数据,提供了足够的广度来捕捉各种游戏机制和风格。
为此,研究团队开发了一款专用的数据记录工具,用于捕捉高质量的人类录制的游戏互动。
这些精心整理的会话,通过细微且高保真的示例丰富了数据集,对于训练模型理解复杂的玩家行为和情境化游戏逻辑至关重要。
然后,收集和记录的数据随后会被输入到一个「垂直训练」流程中——这是一种专注于游戏领域的特定领域训练方法。
这使得模型能够深入内化游戏世界的规则、系统模式和交互动态。
最终,便得到了一个能够生成连贯、真实且灵活的游戏内容的模型,突破了传统预设游戏的限制。
「
」
「「交互式生成」与「实时控制」完美融合」
Mirage 将帧级提示词处理集成至其核心,重新定义了实时交互。
这使得玩家输入的键盘指令和自然语言提示词,都能在游戏过程中被即时解析。

对于玩家来说,通过云游戏实现了随处可玩:
· 动态输入系统:Mirage 以超低延迟处理玩家的输入(主要通过键盘和文本),实现近乎即时的响应。
· 实时输出:视觉更新通过一个全双工通信通道流回浏览器,输入与输出并行处理,从而消除延迟,确保流畅交互。

其底层技术架构是一套定制化的因果 Transformer 模型,并通过以下技术实现了增强。
其中包括,专门的视觉编码器、经过优化的位置编码、专为长时间交互序列而优化的结构。
值得一提的是,Mirage 继承了大模型和扩散模型的优势,支持生成连贯的高质量内容。
这里,研究团队对扩散模型组件采用了先进的蒸馏策略,以同时确保生成速度与内容质量。
玩家可随时通过自然语言重塑游戏环境,触发世界的即时更新。
由 KV 缓存支持的长上下文窗口,能够确保即便世界在实时演变,视觉效果也能保持连贯一致。

「八大顶尖机构联手」
「造出最强 AI 游戏引擎」
Mirage 由一支技术深厚且富有创造力的团队打造,团队成员包括了 AI 研究人员、工程师和设计师。
他们来自谷歌、英伟达、Amazon、SEGA、苹果、微软、卡内基梅隆大学和加州大学圣地亚哥分校。
通过 UGC 2.0,Mirage 致力于推动生成式世界模型的边界——每一次边界的拓展都将颠覆我们的想象力。

同时,生成式玩法不仅仅是一个功能,更是一种全新的媒介。
从报纸、广播到电视、手机,再到 3A 大作和 4K 视频,人类的媒介正在不断进化。
沃顿商学院 CS 教授 Ethan Mollick 实测后表示,Mirage 虽未完全到位,但已取得了进展。


还有网友被 Mirage 实时生成效果彻底惊艳了——这简直像 PS2!

Mirage 指向一个全新的未来,在那里游戏无需下载或者等待设计——它们将被想象、被提示、被生成,并被我们亲身体验。
一切才刚刚开始。
参考资料:
来源:juejin.cn/post/7522421979534835738
android ViewBinding
1. 它是什么 & 有啥用
- 编译期生成与每个布局一一对应的 XXXBinding 类,帮你类型安全地拿到 View 引用;没有反射、没有运行时开销。
- 仅做“找 View”,不包含表达式/双向绑定/观察者(那是 DataBinding 的职责)。
2. 开启方式(Gradle)
android {
buildFeatures { viewBinding = true }
}
- 应用/库模块都可开;想排除某些布局,给布局根元素加:
<LinearLayout
xmlns:tools="http://schemas.android.com/tools"
tools:viewBindingIgnore="true" ... />
3. 生成类与命名规则
- activity_main.xml → ActivityMainBinding
- item_user_info.xml → ItemUserInfoBinding
- 只为有 id 的 View生成字段;布局根通过 binding.root 访问。
4. 三大常用场景
4.1 Activity
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
binding.title.text = "Hello"
binding.button.setOnClickListener { /* ... */ }
}
}
4.2 Fragment(避免内存泄漏的标准写法)
class HomeFragment : Fragment() {
private var _binding: FragmentHomeBinding? = null
private val binding get() = _binding!!
override fun onCreateView(
inflater: LayoutInflater, container: ViewGr0up?, savedInstanceState: Bundle?
): View {
_binding = FragmentHomeBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding.list.adapter = adapter
}
override fun onDestroyView() {
_binding = null // 关键:与 View 的生命周期对齐
}
}
只在 onCreateView ~ onDestroyView 之间使用 binding;不要持有到 Fragment 的字段里跨越 onDestroyView。
可选:更安全的委托
class ViewBindingDelegate<T: ViewBinding>(
val fragment: Fragment,
val binder: (View) -> T
) : ReadOnlyProperty<Fragment, T> {
private var binding: T? = null
override fun getValue(thisRef: Fragment, property: KProperty<*>): T =
binding ?: binder(thisRef.requireView()).also {
binding = it
thisRef.viewLifecycleOwner.lifecycle.addObserver(object: DefaultLifecycleObserver {
override fun onDestroy(owner: LifecycleOwner) { binding = null }
})
}
}
fun <T: ViewBinding> Fragment.viewBinding(binder: (View)->T) =
ViewBindingDelegate(this, binder)
// 用法:private val binding by viewBinding(FragmentHomeBinding::bind)
4.3 RecyclerView.ViewHolder
class UserVH(val binding: ItemUserBinding) : RecyclerView.ViewHolder(binding.root)
override fun onCreateViewHolder(parent: ViewGr0up, viewType: Int): UserVH {
val inflater = LayoutInflater.from(parent.context)
return UserVH(ItemUserBinding.inflate(inflater, parent, false))
}
override fun onBindViewHolder(holder: UserVH, position: Int) {
val item = getItem(position)
holder.binding.name.text = item.name
}
5. inflate / bind 的三种入口
- XXXBinding.inflate(layoutInflater):常用于 Activity。
- XXXBinding.inflate(inflater, parent, attachToParent):用于列表/Fragment。
- 根为 的布局:必须提供非空 parent,且 attachToParent=true。
- XXXBinding.bind(view):当你已有一个 View(比如 Dialog#setContentView(view) 后)再创建 binding。
6. include / merge 的细节
- include:给 一个 android:id,生成的字段类型直接是被包含布局的 Binding:
<include
android:id="@+id/header"
layout="@layout/include_header"/>
- 使用:
binding.header.title.text = "Title"
- merge 根布局:不产生多余容器,使用:
val b = IncludeToolbarBinding.inflate(inflater, parent, /*attachToParent=*/true)
// 注意:merge 必须 attachToParent = true
7. Dialog / BottomSheet / AlertDialog
class EditDialogFragment : DialogFragment() {
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val b = DialogEditBinding.inflate(layoutInflater)
return AlertDialog.Builder(requireContext())
.setView(b.root)
.setPositiveButton("OK") { _, _ -> /* read b.editText */ }
.create()
}
}
8. 与 DataBinding / Compose 的区别与选型
- ViewBinding:只做找 View,最快、最轻,无 包裹、无表达式。推荐大多数传统 View 项目使用。
- DataBinding:支持 @{} 表达式、@BindingAdapter、双向绑定 @={};复杂但强大,编译慢、心智成本高。
- Compose:声明式 UI。新项目优先;老项目可“渐进式”在局部用 ComposeView。
- Compose × ViewBinding 互操作:在 Compose 内直接用 AndroidViewBinding(依赖 ui-viewbinding):
@Composable
fun LegacyCard() {
AndroidViewBinding(factory = LegacyCardBinding::inflate) {
title.text = "Hello from ViewBinding"
}
}
9. 常见坑 & 排查
- Fragment 泄漏:忘记在 onDestroyView() 置空 _binding。—— 现象:导航返回/旋转后崩溃或持有旧 View。
- merge 布局用了 attachToParent=false:导致 IllegalStateException 或看不见 UI。
- 在 onCreate() 就用 Fragment 的 binding:此时 View 还没创建,应在 onViewCreated() 之后使用。
- 重复 inflate:同一布局多次 inflate 却多次 setContentView/addView,导致层级重复/点击穿透。
- 多模块命名冲突:不同模块同名布局会各自产生 Binding,不会冲突;若共享资源注意命名前缀。
- 列表里频繁创建 binding:放在 onCreateViewHolder,不要在 onBindViewHolder 重复 inflate。
10. 实战小抄(可直接套用)
(1)列表条目 ViewHolder 模板)
class MsgVH(val b: ItemMsgBinding) : RecyclerView.ViewHolder(b.root)
override fun onCreateViewHolder(p: ViewGr0up, vt: Int) =
MsgVH(ItemMsgBinding.inflate(LayoutInflater.from(p.context), p, false))
override fun onBindViewHolder(h: MsgVH, pos: Int) = with(h.b) {
title.text = getItem(pos).title
time.text = getItem(pos).time
}
(2)Fragment × ViewBinding × Lifecycle
override fun onViewCreated(v: View, s: Bundle?) {
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.ui.collect { ui -> binding.progress.isVisible = ui.loading }
}
}
}
(3)include 组合标题栏
<!-- layout: activity_main.xml -->
<LinearLayout ...>
<include
android:id="@+id/toolbar"
layout="@layout/include_toolbar"/>
<!-- page content -->
</LinearLayout>
binding.toolbar.title.text = "主页"
binding.toolbar.back.setOnClickListener { onBackPressedDispatcher.onBackPressed() }
来源:juejin.cn/post/7561077821995630644
忍了一年多,我终于对i18n下手了
前言
大家好,我是奈德丽。
过去一年,我主要参与国际机票业务的开发工作,因此每天都要和多语言(i18n)打交道。熟悉我的朋友都知道,我这个人比较“惜力”(并不是,实际上只是忍不下去了),对于重复笨拙的工作非常抵触,于是,我开始思考如何优化团队的多语言管理模式。
痛点背景
先说说我们在机票项目中遇到的困境。
目前机票项目分为 H5 和 PC 两端,团队在维护多语言时主要通过在线 Excel进行管理:
- 一个 Excel 文件,H5 和 PC 各自占一个 sheet 页;
- 每次更新语言,需要先导出 Excel,然后手动跑脚本生成语言文件,再拷贝到项目中。
听起来还算凑合,但随着项目规模的扩大,问题逐渐显现:
- Key 命名混乱
- 有的首字母大写,有的小驼峰、大驼峰混用;
- 没有统一规则,难以模块化管理。
- 不支持模块化
- 目前已有数千条 key;
- 查找、修改、维护都非常痛苦。
- 更新流程繁琐
- 需要手动进入脚本目录,用
node跑脚本; - 生成后再手动复制到项目中。
- 需要手动进入脚本目录,用
下面是一个实际的 Excel 片段,可以感受一下当时的混乱程度:

用原node脚本生成的语言文件如图

在这样的场景下,每次迭代多语言文件更新都像噩梦一样。
尤其是我们很多翻译是通过AI 机翻生成,后续频繁修改的成本极高。
然而,机票项目的代码量太大、历史包袱太重,短期内几乎不可能彻底改造。

新项目,新机会
机票项目虽然不能动,但在我们启动酒店业务新项目时,我决定不能再重蹈覆辙。
因此,在酒店项目中,我从零搭建了这套更高效的 i18n 管理方案。
目标很简单:
- 统一 key 规则,支持模块化,模块与内容间用.隔开,内容之间用下划线隔开;
- 自动化生成多语言 JSON 文件,集成到项目内,不再需要查找转化脚本的位置;
- 一条命令搞定更新,不需要手动拷贝。
于是,我在项目中新增了一个 scripts 目录,并编写了一个 excel-to-json.js 脚本。
在 package.json 中添加如下命令:
{
"scripts": {
"i18n:excel-to-json": "node scripts/excel-to-json.js"
}
}
以后,只需要运行下面一行命令,就能完成所有工作:
pnpm i18n:excel-to-json
再也不用手动寻找脚本路径,也不用手动复制粘贴,效率直接起飞 🚀。
脚本实现
核心逻辑就是:
从 Excel 读取内容 → 转换为 JSON → 输出到项目 i18n 目录。
完整代码如下:
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import XLSX from 'xlsx'
/**
* 语言映射表:Excel 表头 -> 标准语言码
*/
const languageMap = {
'English': 'en',
'简中': 'zh-CN',
'Chinese (Traditional)': 'zh-TW',
'Korean': 'ko',
'Spanish': 'es',
'German Edited': 'de',
'Italian': 'it',
'Norwegian': 'no',
'French': 'fr',
'Arabic': 'ar',
'Thailandese': 'th',
'Malay': 'ms',
}
// 读取 Excel 文件
function readExcel(filePath) {
if (!fs.existsSync(filePath)) {
throw new Error(`❌ Excel 文件未找到: ${filePath}`)
}
const workbook = XLSX.readFile(filePath)
const sheet = workbook.Sheets[workbook.SheetNames[0]]
return XLSX.utils.sheet_to_json(sheet)
}
/**
* 清空输出目录
*/
function clearOutputDir(dirPath) {
if (fs.existsSync(dirPath)) {
fs.readdirSync(dirPath).forEach(file => fs.unlinkSync(path.join(dirPath, file)))
console.log(`🧹 已清空目录: ${dirPath}`)
} else {
fs.mkdirSync(dirPath, { recursive: true })
console.log(`📂 创建目录: ${dirPath}`)
}
}
/**
* 生成 JSON 文件
*/
function generateLocales(rows, outputDir) {
const locales = {}
rows.forEach(row => {
const key = row.Key
if (!key) return
// 遍历语言列
Object.entries(languageMap).forEach(([columnName, langCode]) => {
if (!locales[langCode]) locales[langCode] = {}
const value = row[columnName] || ''
const keys = key.split('.')
let current = locales[langCode]
keys.forEach((k, idx) => {
if (idx === keys.length - 1) {
current[k] = value
} else {
current[k] = current[k] || {}
current = current[k]
}
})
})
})
// 输出文件
Object.entries(locales).forEach(([lang, data]) => {
const filePath = path.join(outputDir, `${lang}.json`)
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8')
console.log(`✅ 生成文件: ${filePath}`)
})
}
/**
* 检测缺失翻译
*/
function detectMissingTranslations(rows) {
const missing = []
rows.forEach(row => {
const key = row.Key
if (!key) return
Object.entries(languageMap).forEach(([columnName, langCode]) => {
const value = row[columnName]
if (!value?.trim()) {
missing.push({ key, lang: langCode })
}
})
})
return missing
}
function logMissingTranslations(missingList) {
if (missingList.length === 0) {
console.log('\n🎉 所有 key 的翻译完整!')
return
}
console.warn('\n⚠️ 以下 key 缺少翻译:')
missingList.forEach(item => {
console.warn(` - key: "${item.key}" 缺少语言: ${item.lang}`)
})
}
function main() {
const desktopPath = path.join(os.homedir(), 'Desktop', 'hotel多语言.xlsx')
const outputDir = path.resolve('src/i18n/locales')
const rows = readExcel(desktopPath)
clearOutputDir(outputDir)
generateLocales(rows, outputDir)
logMissingTranslations(detectMissingTranslations(rows))
}
main()
成果展示
这是在线语言原文档

这是生成后的多语言文件和内容

现在的工作流大幅简化:
| 操作 | 旧流程 | 新流程 |
|---|---|---|
| 运行脚本 | 手动找脚本路径 | pnpm i18n:excel-to-json |
| 文件生成位置 | 生成后手动拷贝 | 自动输出到项目 |
| 检测缺失翻译 | 无 | 自动提示 |
| key 命名管理 | 无统一规则 | 模块化、规范化 |
这套机制目前在酒店项目中运行良好,团队反馈也很积极。
总结
这次改造让我最大的感触是:
旧项目难以推翻重来,但新项目一定要趁早做好架构设计。
通过这次优化,我们不仅解决了多语言维护的痛点,还提升了团队整体开发效率。
而这套方案在未来如果机票项目有机会重构,也可以直接平滑迁移过去。
来源:juejin.cn/post/7553105607417053194
聊聊我们公司的AI应用工程师每天都干啥?
过去两年间,随着我们团队落地和升级的AI产品越来越多,团队中逐渐出现了专门负责AI应用的工程师。
时间一长这些AI应用工程师们也分出了个三六九等,甚至有一些AI应用工程师因为思路无法转变,又退回到了普通工程师的岗位,不再负责AI应用。
今天这篇文章,给大家聊聊,AI应用工程师每天都在干点啥? 优秀的AI应用工程师到底优秀在哪里?
AI应用工程师的生态位
AI应用工程师是处在只会大模型API调用和大模型算法工程师之间的一个生态位,目前还没有一个非常完善的岗位职能,不同的企业对于AI应用工程师的要求也有所不同。
我们这边之所以会需要AI应用工程师这样一个岗位,主要原因是我们要做AI产品的落地,和原有产品的AI化升级,在程序实现时会需要大量的大模型能力和一些特定的AI应用落地方案。
这时,团队中就需要有人对大模型的各项落地方案有所了解,能够配合产品在合适的节点设计合适的方案进行升级,负责将大模型能力转化为实际可用的产品功能
所以AI应用工程师不能只会简单的API调用,同时也不需要去了解太多的大模型底层技术,甚至Python基础都可以不需要(虽然会一些python有好处,但是这不是决定性的能力)。
目前在我们的团队中,他们主要负责:
- 利用代码实现Agent或者workflow的流程
- 实现具体的AI应用落地方案,联网搜索、RAG、微调等
- 与提示词工程师对接完成大模型能力的接入
- 与原有的程序进行结合
- 极简版的流程验证逻辑实现
- 复杂RPA + AI的落地
在其他企业的团队中,对AI应用工程师的要求还有:
- AI工程系统设计,配合产品制定Agent流程
- 编写项目中使用到的提示词
- coze、dify的搭建
- 等等
虽然说像搭建coze、写提示词这类工作,我并不认为这些工作应该是AI应用工程师工作,但是当下这个岗位的职责还没有固定,所以接下来我还是会把这些内容写进应具备的技能中。
大家全都了解一下,以备不时之需。
AI应用工程师需要具备的技能
要完成以上这些职责,AI应用工程师自然也要具备相应的技能, 但是对于Python基础、Pytorch框架、机器学习与深度学习的技术点,要不要进行学习呢?
不鼓励花大量时间学习,但是建议了解
不鼓励学习是因为:你学了你未必也用的到,我带团队在AI领域,TOC、TOB、新产品、新功能上线了不少了,没有用到python语言,并且也没看到python的必须性。当然了,你的团队主要语言是python,那你肯定是应该学的。
建议了解是因为:你毕竟是在做AI相关的内容,有相应的基础知识和技能点,的确在某些时候能带给你一些清晰的思路或者节约一些时间。
反过来再考虑你自己:深入Python基础、Pytorch框架、机器学习与深度学习这些技术点,你想做什么? 当你去深入学习这些的时候,你的目标不应该是AI应用工程师,而是人工智能算法相关的岗位。
学会了你就会不甘心,想去做更高级的岗位,但是那些岗位对学历、经验的要求不是一个半路出家自学能胜任的。
学不会你就是在浪费自己宝贵的生命和本次AI变革中的机会。
所以啊,认准自己的目标,别让自己难做。
那应该掌握的技能是哪些呢? 我们一条一条来说
利用代码实现Agent或者workflow的流程
需要了解Agent和workflow的区别,能够掌握是程序实现Agent和workflow的能力。
简单来说Agent和workflow的区别就是:
- workflow是通过预定义的代码路径协调 LLM 和工具的系统。人类可以在其中的某些节点进行人为的干预。
- Agent是 LLM 动态指导其自身流程和工具使用情况的系统,从而保持对其完成任务的方式的控制。完全有LLM主导,人类无法干预。
在程序上的实现区别:
Agent实际上最核心的代码只有九行, Agent所谓的动态指导其实就是一个while(true)。
async function loop(llm) {
let msg = await userInput();
while (true) {
const { output, tool_calls } = await llm(msg);
if (tool_calls && tool_calls.length > 0) {
msg = await Promise.all(tool_calls.map(tc => handleToolCall(tc)));
} else {
msg = await userInput();
}
}
}
workflow的核心代码流程是提前写好的逻辑流程。
async function main(){
// 流程1:例如上下文处理
const query = await handleContext()
// 流程2:例如RAG
await handleRAG(query)
// 流程3:例如Function call
await handleFC(query)
// 流程4:例如调用API
await handleAPI()
}
实现具体的AI应用落地方案
在流程实现中,AI应用工程师需要把用到的技术点都做好,例如:联网搜索、RAG、微调等
联网搜索:当我们的产品需要用的联网搜索的时候,我们有两个选择:
- 用云端的联网搜索能力,缺点就是收费并且可控性不强,优点是方便省事。
- 自己实现联网搜索能力,优点是可以按照自己的需求指定搜索引擎、检索网站等。缺点就是需要自己编写代码。
RAG:当我们需要用RAG的时候,AI应用工程师应该做的:
- 实现RAG的完整流程
- 告知数据同事,需要怎样的数据,切片、QA、等
- 测试并保证RAG的召回率和准确率
- 对RAG的产出结果负责
微调:当我们需要用到微调的时候,AI应用工程师应该做的:
- 知道要微调什么样的任务,然后协调数据同事去准备相关的数据,并告知准备多少数据量、数据结构是怎样的、内容分布是怎样的
- 拿到数据后选择微调模型、微调平台,进行微调的工作
- 对微调结果进行测评,最终得到满意的结果
- 部署并调用模型
与提示词工程师对接完成大模型能力的接入
这一点不同的企业要求不一样,我们团队是专门培养的提示词工程师,有的团队是需要AI应用工程师来进行提示词的编写和调优
不过无论是不是AI应用工程师来编写提示词,他们都需要了解提示词工程,否则就没办法和提示词工程师进行有效沟通。
AI应用工程师需要与提示词工程师就当前节点提示词的输入、输出的结构和内容进行确定。
AI应用工程师保证输入的准确性,提示词工程师来保证输出的概率。
为什么是概率呢? 众所周知,提示词是不会百分百保证效果的。所以优秀的AI应用工程师在编写程序时会具备这一点的考虑:
例如下面这个例子:
提示词是用来判定当前输入的评论内容是否表达了善意,返回N或者Y。
也就是说这个提示词提示词的输入是评论内容,输出是N或者Y。
请问:AI应用工程师要怎么对输出的Y或者N进行判断?
if(res === 'Y'),这样么?
不,他们写if(res.includes('Y'))。
这里用全等就没有考虑到提示词输出的不确定性,所以有经验的工程师会在这里使用includes
极简版的流程验证逻辑实现
当下的阶段,通常在产品初期设计的流程都不会是最终生成环境的流程,因为在产品处理考虑的一些节点可能不全面,也因为需求会变动。
所以当我们在正式开始编写代码之前,都需要有一个极简版的流程实现,来验证我们的逻辑
有的团队使用coze一类的Agent搭建流程来实现这个验证逻辑, 但是对于一些复杂的流程,coze之类的产品就无能为力了
这时候,就需要AI应用工程师用代码快速实现一个简单流程来验证逻辑。
复杂RPA + AI的落地
随着AI能力的提升,目前最新的思路有RPA + AI来实现近乎全自动的部分工作,这就需要有专门的搭建这套流程的工程师。
这需要了解RPA的能力和AI能力,并且了解如何结合。
这个其实并不应该交给AI应用工程师来做,更适合专门的RPA工程师。
这个看发展吧。
AI工程系统设计,配合产品制定Agent流程
AI工程的系统设计,有些团队会要求AI应用工程师来做。
AI工程的系统设计,也就是应用的Agent或者workflow的设计,这一步是在产品出原型之前,就要设计好。
所以想要设计这部分内容,需要有两个能力:懂业务、懂技术。
这一点还是很难的,能够胜任的AI应用工程师,通常已经不是单纯意义上的工程师了。
给大家补一个当下AI产品落地的流程图:

coze、dify的搭建
coze和dify的搭建,也是部分团队要求的任务,这一点会要求大家按照要求搭建智能体。
这个我这边就不细说了,网上到处都是搭建教程。
结语
AI应用工程师和普通工程师比起来,上下游关系人多了两个提示词工程师、数据人员
提示词工程师需要和AI应用工程师进行沟通,关于提示词的输入、输出。
AI工程师需要了解当前节点的提示词是做什么的,有什么用? 就像了解原型中某个功能节点是做什么的一样。
就是说,AI应用工程师虽然只是做整个产品中的一部分工作,但仍然要了解全景的信息
这也是优秀的工程师优秀的地方,他们不仅能做好自己的工作,还能配合上下游关系人一起,让整个产品实现的更好。
哦对了,最后说一下:
AI应用工程师是一个新的岗位,并没有替代传统工程师,而是在传统工程能力基础上增加了新的维度。
加油!共勉!
☺️你好,我是华洛,如果你对程序员转型AI产品负责人感兴趣,请给我点个赞。
你可以在这里联系我👉http://www.yuque.com/hualuo-fztn…
已入驻公众号【华洛AI转型纪实】,欢迎大家围观,后续会分享大量最近三年来的经验和踩过的坑。
专栏文章
# 从0到1打造企业级AI售前机器人——实战指南三:RAG工程的超级优化
# 从0到1打造企业级AI售前机器人——实战指南二:RAG工程落地之数据处理篇🧐
# 从0到1打造企业级AI售前机器人——实战指南一:根据产品需求和定位进行agent流程设计🧐
来源:juejin.cn/post/7512332419203727371
自定义 View 的 “快递失踪案”:为啥 invalidate () 喊不动 onDraw ()?
讲了个 “快递站送货” 的故事 —— 毕竟 View 的绘制流程,本质就是一场 “指令上报→调度→执行” 的快递游戏。
一、先搞懂:正常情况下,“快递” 是怎么送到的?
我们先把 View 体系比作一个城市快递网络:
- 你写的
自定义View= 小区里的 “快递站”(负责接收指令、安排送货); invalidate()= 你给快递站打 “要送货” 的电话(请求重绘);onDraw()= 快递站的 “送货员”(实际执行绘制逻辑);ViewGr0up(父容器)= “区域调度中心”(转发快递站的请求);ViewRootImpl= 快递总公司(连接快递站和 “城市交通系统”——Android 的 UI 线程);Choreographer= 总公司的 “帧调度室”(负责安排每帧的工作,避免堵车)。
正常送货的时序图(代码 + 流程)
先看一段 “正常能收到货” 的自定义 View 代码:
// 小区快递站(自定义View)
public class NormalCustomView extends View {
private Paint mPaint;
public NormalCustomView(Context context) {
super(context);
mPaint = new Paint();
mPaint.setColor(Color.RED);
mPaint.setTextSize(50);
}
// 送货员(执行绘制)
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
Log.d("快递站", "送货员onDraw出发!画个文字");
canvas.drawText("快递送到啦~", 100, 100, mPaint);
}
}
// 你(开发者)打电话下单
NormalCustomView view = new NormalCustomView(this);
view.invalidate(); // 打“要送货”的电话
这通电话后,“快递” 会按以下流程送到(时序图用文字拆解):

你送货员送货员(onDraw)帧调度室帧调度室(Choreographer)快递总公司快递总公司(ViewRootImpl)区域调度中心区域调度中心(ViewGr0up)快递站快递站(CustomView)你(开发者)你送货员送货员(onDraw)帧调度室帧调度室(Choreographer)快递总公司快递总公司(ViewRootImpl)区域调度中心区域调度中心(ViewGr0up)快递站快递站(CustomView)你(开发者)打call:invalidate()1. 检查自身状态(门开了吗?有货要送吗?)2. 上报:“我要送货,帮我转总公司!”3. 层层转发:“总公司,有个快递站要送货!”4. 申请排期:“下一帧给这个快递站留个位置!”5. 下一帧到了:“可以开始送货流程了!”6. 下达指令:“执行draw(),让送货员出发!”7. 派单:“onDraw,去把货(绘制)送了!”8. 完成:log打印“送货员onDraw出发!”
二、“快递失踪” 的 6 种常见原因(故事 + 代码 + 解决方案)
小明的问题,本质是 “快递在某个环节卡住了”。我们一个个拆穿这些 “卡壳点”—— 每个原因都对应故事里的场景,再给代码验证。
原因 1:快递站 “没开门”(View 不可见)
故事场景:小明早上给快递站打电话,站长接了说:“兄弟,我们还没开门(visibility=GONE),货送不了,挂了啊!”
原理:View 在收到invalidate()后,会先检查visibility属性:
- 只有
visibility == View.VISIBLE时,才会继续上报请求; - 如果是
GONE(完全隐藏,不占空间)或INVISIBLE(隐藏但占空间),直接 “挂电话”,不触发后续流程。
代码验证(坑) :
public class ClosedStationView extends View {
public ClosedStationView(Context context) {
super(context);
// 坑:设置为GONE,快递站没开门
setVisibility(View.GONE);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
Log.d("快递站", "送货员出发!"); // 永远不会打印
}
}
// 你打电话,但快递站没开门
ClosedStationView view = new ClosedStationView(this);
view.invalidate(); // 白打!
解决方案:确保visibility是View.VISIBLE(代码里setVisibility(View.VISIBLE),或 XML 里android:visibility="visible")。
原因 2:快递站 “没地方放货”(宽高为 0)
故事场景:小明这次确认快递站开了门,但站长说:“我们仓库是 0 平米(宽高 = 0),货没地方放,送不了!”
原理:View 绘制需要 “有空间”——getMeasuredWidth()和getMeasuredHeight()必须都大于 0。如果宽高为 0,即使invalidate(),也会跳过后续流程(总不能在 “空气” 里画画吧)。
代码验证(坑) :
<!-- XML里坑:宽高设为0 -->
<com.example.MyView
android:layout_width="0dp"
android:layout_height="0dp" />
public class ZeroSizeView extends View {
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
Log.d("快递站", "送货员出发!"); // 不打印,因为宽高0
}
}
解决方案:
- 检查 XML 的
layout_width/layout_height(别设 0dp); - 代码里避免
setLayoutParams(new LayoutParams(0, 0)); - 重写
onMeasure()时,确保setMeasuredDimension(width, height)的宽高大于 0。
原因 3:快递站 “只中转不送货”(ViewGr0up 默认不绘制)
故事场景:小明找的是 “区域调度中心”(ViewGr0up)当快递站,结果调度中心说:“我们只负责转发子快递站的货,自己不送货(willNotDraw=true)!”
原理:ViewGr0up的默认值willNotDraw = true,意思是 “我是容器,只管子 View 的布局,自己不用绘制”。所以即使你给ViewGr0up调用invalidate(),它也会跳过onDraw()。
代码验证(坑) :
// 区域调度中心(ViewGr0up),默认不送货
public class NoDrawViewGr0up extends ViewGr0up {
public NoDrawViewGr0up(Context context) {
super(context);
// 坑:没改willNotDraw,默认true
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
// 布局子View(省略)
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
Log.d("调度中心", "自己送货!"); // 不打印
}
}
// 你给调度中心打电话
NoDrawViewGr0up group = new NoDrawViewGr0up(this);
group.invalidate(); // 白打!
解决方案:在ViewGr0up的构造里加一句setWillNotDraw(false),告诉它 “我也要自己送货(绘制)”:
public NoDrawViewGr0up(Context context) {
super(context);
setWillNotDraw(false); // 打开“自己绘制”开关
}
原因 4:你 “打错电话”(非 UI 线程调用 invalidate ())
故事场景:小明在外地出差,用 “公用电话”(非 UI 线程)给快递站打电话,结果电话直接被总公司拦截:“非本人手机(UI 线程),不接!”
原理:Android 的 View 体系是线程不安全的,只有创建 View 的 “UI 线程(主线程)” 才能调用invalidate()。非 UI 线程调用会:
- 要么直接抛异常(
Only the original thread that created a view hierarchy can touch its views); - 要么 “悄悄失败”(没抛异常但不触发
onDraw())。
代码验证(坑) :
public class WrongThreadView extends View {
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
Log.d("快递站", "送货员出发!"); // 不打印
}
}
// 你用“公用电话”(非UI线程)打电话
WrongThreadView view = new WrongThreadView(this);
new Thread(() -> {
view.invalidate(); // 非UI线程!要么抛异常,要么白打
}).start();
解决方案:确保在 UI 线程调用invalidate(),常用方式:
- 用
view.post(Runnable):view.post(() -> view.invalidate()); - 用
Handler发消息到主线程; - 在
Activity的runOnUiThread(Runnable)里调用。
原因 5:区域调度中心 “拦截了请求”(父 View 阻断上报)
故事场景:小明的快递站属于 “郊区调度中心”,调度中心跟总公司关系不好,收到快递站的请求后,直接扔了:“不给你转总公司,爱咋咋地!”
原理:View 的invalidate()需要通过ViewParent(父 View)层层上报到ViewRootImpl。如果父 View 重写了invalidateChildInParent()(上报方法)并返回null,就会 “拦截” 请求,导致后续流程中断。
代码验证(坑) :
// 坑爹的区域调度中心(父View),拦截请求
public class BlockParentViewGr0up extends ViewGr0up {
public BlockParentViewGr0up(Context context) {
super(context);
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
// 布局子View(省略)
}
// 重写上报方法,返回null=拦截请求
@Override
public ViewParent invalidateChildInParent(int[] location, Rect dirty) {
Log.d("坑爹调度中心", "拦截请求,不转总公司!");
return null; // 关键:返回null阻断上报
}
}
// 子快递站(被拦截)
public class ChildView extends View {
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
Log.d("子快递站", "送货员出发!"); // 不打印
}
}
// 布局关系:BlockParentViewGr0up包含ChildView
BlockParentViewGr0up parent = new BlockParentViewGr0up(this);
ChildView child = new ChildView(this);
parent.addView(child);
child.invalidate(); // 子View的请求被父View拦截
解决方案:
- 检查父 View 是否重写了
invalidateChildInParent(),避免返回null; - 若父 View 有
clipChildren="true"(XML 属性),且子 View 超出父 View 范围,超出部分的invalidate()也会被拦截,可设clipChildren="false"。
原因 6:快递站 “用了缓存,不用重送”(硬件加速 Layer 缓存)
故事场景:快递站之前送过一次货,把货存在了 “临时仓库”(硬件加速 Layer)里。这次小明再打电话,站长说:“仓库里有现成的,直接拿,不用再让送货员跑一趟!”
原理:当 View 设置了硬件加速 Layer(setLayerType(LAYER_TYPE_HARDWARE, null)),系统会把 View 的绘制结果缓存成一个 “图片(Layer)”。后续调用invalidate()时:
- 如果只是轻微修改(比如文字颜色不变,只改内容),系统直接复用 Layer,不调用
onDraw(); - 只有 Layer 失效(比如 View 大小改变、Layer 类型切换),才会重新调用
onDraw()生成新 Layer。
代码验证(坑) :
public class LayerCacheView extends View {
private Paint mPaint;
private String mText = "第一次送货";
public LayerCacheView(Context context) {
super(context);
mPaint = new Paint();
mPaint.setColor(Color.BLUE);
mPaint.setTextSize(50);
// 坑:设置硬件加速Layer,开启缓存
setLayerType(LAYER_TYPE_HARDWARE, null);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
Log.d("快递站", "送货员出发!当前文字:" + mText); // 只打印一次
canvas.drawText(mText, 100, 100, mPaint);
}
// 你修改文字后打电话
public void updateText() {
mText = "第二次送货";
invalidate(); // 调用后,onDraw不回调(复用Layer缓存)
}
}
// 调用流程
LayerCacheView view = new LayerCacheView(this);
view.invalidate(); // 第一次:onDraw回调(生成Layer)
view.updateText(); // 第二次:invalidate()但onDraw不回调(复用Layer)
解决方案:
- 若需要每次
invalidate()都回调onDraw(),可关闭 Layer:setLayerType(LAYER_TYPE_NONE, null); - 若必须用 Layer,可手动让 Layer 失效:
invalidate()后加setLayerType(LAYER_TYPE_HARDWARE, null)(强制重建 Layer)。
三、总结:“快递失踪” 排查四步法
小明听完故事,半小时就解决了他的问题(原来是忘了给 ViewGr0up 加setWillNotDraw(false))。最后我给他总结了一套 “排查口诀”,小白也能套用:
- 查基础状态:View 是不是
VISIBLE?宽高是不是大于 0?(对应原因 1、2) - 查绘制开关:如果是 ViewGr0up,有没有开
setWillNotDraw(false)?(对应原因 3) - 查线程归属:
invalidate()是不是在 UI 线程调用的?(对应原因 4) - 查拦截和缓存:父 View 有没有拦截请求?View 是不是开了硬件加速 Layer?(对应原因 5、6)
按这四步走,90% 的 “invalidate () 不回调 onDraw ()” 问题都能解决。记住:View 的绘制流程就像快递,每个环节都不能少,卡住一个就 “送货失败”~
来源:juejin.cn/post/7559399860119224383
Android 性能调优与故障排查:ADB 诊断命令终极指南
在 Android 开发与测试的日常工作中,快速诊断和解决应用崩溃 (Crash)、无响应 (ANR) 和性能卡顿 (Jank) 是保障应用质量的关键。Android Debug Bridge (ADB) 提供了强大的命令行工具集,能够帮助我们深入系统底层,获取所需的所有诊断数据。
本文将为您全面梳理最常用、最核心的 ADB 诊断命令行工具,助您成为一名高效的故障排查专家。
一、 核心诊断命令:系统快照与错误记录
这些命令用于获取设备某一时刻的全局状态或关键错误记录。
1. 抓取全面的系统诊断报告:adb bugreport
adb bugreport 是最强大的诊断工具,它生成一个关于设备当前状态的全面的、打包的(.zip 格式)系统快照。
| 命令 | 作用 | 备注 |
|---|---|---|
adb bugreport | 生成包含所有诊断信息的 .zip 文件。 | 适用于分析复杂问题、系统级错误,或需提交给平台开发者时。 |
adb bugreport <文件名>.zip | 指定导出的文件名。 | 报告内容包括:完整的 Logcat 历史、ANR/Crash 堆栈、所有 dumpsys 信息等。 |
2. 提取崩溃和 ANR 记录:adb shell dumpsys dropbox
dropbox 服务相当于系统的“黑匣子”,专门收集系统运行过程中的关键错误摘要。
| 命令 | 作用 | 备注 |
|---|---|---|
adb shell dumpsys dropbox --print | 打印所有 dropbox 记录的详细内容。 | 快速检查是否有最近的系统级或应用级崩溃/ANR 记录。 |
adb shell dumpsys dropbox --print > crash.txt | 将所有记录重定向输出到本地 crash.txt 文件。 | 方便离线分析。 |
3. 提取 ANR 堆栈文件
ANR 发生后,系统将所有线程堆栈记录在 traces.txt 中,这是分析 ANR 的核心。
| 命令 | 作用 | 备注 |
|---|---|---|
cat /data/anr/traces.txt | 读取 ANR 发生时的详细堆栈信息。 | ⚠️ 通常需要 Root 权限 (adb root 或 su) 才能访问 /data/anr/ 目录。 |
cat /data/anr/traces.txt > /mnt/sdcard/tt.txt | 在设备内部将受保护的 traces.txt 复制到用户存储区。 | 复制后,可使用 adb pull 导出到电脑。 |
二、 实时日志与基础性能分析
这些是日常调试和性能监控最频繁使用的命令。
1. Logcat 日志操作
| 命令 | 作用 | 备注 |
|---|---|---|
adb logcat | 实时打印设备所有日志。 | 可通过 tag 和 level(如 *:E 只看错误)进行过滤。 |
adb logcat -c | 清除设备上当前的日志缓冲区。 | 建议在测试前执行,以确保日志干净、聚焦。 |
adb logcat -d > log.txt | 将设备上当前缓存的所有日志导出到本地文件。 | -d (dump) 参数用于导出当前缓存,而非实时监听。 |
2. CPU 与内存监控
| 命令 | 作用 | 关注问题 |
|---|---|---|
adb shell ps -t / adb shell top -m 10 | 实时查看进程的 CPU、内存占用情况。 | 性能监控,快速定位资源消耗高的进程。 |
adb shell dumpsys meminfo [package_name] | 获取特定应用的详细内存使用情况(Java Heap, Native Heap 等)。 | 内存泄漏、OOM(Out of Memory)分析的核心工具。 |
adb shell dumpsys cpuinfo | 获取设备整体和各个进程的 CPU 使用率。 | 诊断后台过度使用 CPU 导致的耗电或发热问题。 |
三、 性能与卡顿(Jank)分析
专门用于分析应用启动速度和 UI 流畅度的命令。
| 命令 | 作用 | 备注 |
|---|---|---|
adb shell dumpsys gfxinfo [package_name] | 抓取应用的图形渲染性能数据。 | 包含丢帧 (Jank) 统计和渲染时间线,用于分析 UI 卡顿问题。 |
adb shell am crash [package_name] | 强制让目标应用崩溃。 | 用于测试崩溃报告系统的稳定性和流程。 |
adb shell am start -W [package_name]/[activity_name] | 启动指定的 Activity 并等待初始化完成,同时打印启动耗时。 | 用于量化分析应用启动速度(Total Time, Wait Time)。 |
四、 深入系统诊断(dumpsys 子集)
dumpsys 可以针对不同的系统服务进行深入诊断。
| 命令 | 关注领域 | 作用 |
|---|---|---|
adb shell dumpsys activity | Activity Manager (AMS) | 获取当前运行的 Activity 栈、后台进程列表等,用于分析应用生命周期和任务管理问题。 |
adb shell dumpsys battery | 电池状态 | 获取设备的电池和充电状态。 |
adb shell dumpsys power | 电源管理 (PMS) | 获取唤醒锁 (Wake Locks) 的持有情况,用于分析设备无法休眠导致的持续耗电问题。 |
adb shell dumpsys window windows | 窗口管理 (WMS) | 获取当前屏幕上可见的窗口列表、层级和焦点情况,用于分析屏幕显示或输入事件问题。 |
五、 文件系统操作(导出/导入文件)
这些命令是确保诊断文件能够顺利在设备和电脑间传输的基础。
| 命令 | 作用 | 示例 |
|---|---|---|
adb pull [remote_path] [local_path] | 从设备拉取文件到电脑。 | adb pull /sdcard/tt.txt . (将文件拉取到电脑当前目录)。 |
adb push [local_path] [remote_path] | 从电脑推送文件到设备。 | 通常用于推送测试用例或工具。 |
实际分析流程
在实际的故障排查中,开发者通常遵循以下高效流程:
- 准备阶段: 执行
adb logcat -c清除旧日志,确保日志的清洁度。 - 复现问题: 在设备上准确重现崩溃、ANR 或卡顿的现象。
- 抓取证据:
- 一般问题: 立即执行
adb logcat -d > log.txt抓取当前的日志缓冲区。 - 严重复杂问题: 立即执行
adb bugreport抓取最全面的系统报告。 - ANR 问题: 如果有权限,则导出
/data/anr/traces.txt文件进行线程分析。
- 一般问题: 立即执行
掌握并熟练运用这些 ADB 诊断命令,将极大地提升您在 Android 故障排查和性能优化的效率。
来源:juejin.cn/post/7564540677470126121
从马斯克到司马华鹏:当“引导程序”跨越东西文明
引导程序与构建者:东西方视野的奇妙交汇
当埃隆·马斯克提出"碳基生命只是硅基生命的引导程序"这一惊人观点时,在大洋彼岸的中国南京,一位创业者已经将这个理念践行了七年之久。2017年春季,南京软件谷的一间普通办公室里,司马华鹏在白板上画下三条递进曲线时,或许并未意识到自己正在定义一个新的时代。
当时,AlphaGo击败李世石引发的AI热潮尚未退去,业界讨论仍集中在"技术工具"层面。然而司马华鹏已经看到了更宏大的图景——人类正站在文明转型的历史关口,从碳基文明向硅基文明过渡。"我们不是在建造更先进的工具,而是在孕育新的生命形式。"面对团队疑惑的目光,他坚定地写下了"硅基智能"四个字。这一刻,中国首个明确提出"硅基文明"概念的企业诞生了。
超前布局:在迷雾中看清未来
2017年的中国科技圈,共享经济、O2O等模式仍是资本宠儿,AI创业公司大多选择在安防、金融、医疗等垂直领域寻找快速商业化路径。司马华鹏却选择了一条更为艰难但也更为宏大的道路——为世界创造1亿硅基劳动力。
这一选择背后是他对技术发展规律的深刻洞察。早在2008年欧洲创业期间,司马华鹏就注意到人工智能技术背后的摩尔定律支撑,意识到这不仅是算法的改进,更是生命形态演进的前兆。"当我们谈论生命时,通常指的是碳基生命。但生命的本质是信息的流动和处理,而非特定的物质载体。"他在内部会议上的这番论述,成为公司创立的理论基石。

硅基智能创始人司马华鹏先生出席2024年36氪WISE大会
生命三部曲:构建完整的进化哲学
司马华鹏不仅是一位创业者,更是一位思想者。他构建的生命三个版本理论,至今仍具有强大的解释力。
生命1.0(生物进化阶段)的硬件和软件都受基因限制;生命2.0(文化进化阶段)的人类突破了软件上限,可以通过学习无限扩展认知;而生命3.0(设计进化阶段)的AI将实现硬件和软件的双重解放。这套理论不仅勾勒了生命演化轨迹,更为理解AI本质提供了全新视角。
"我们正在见证生命3.0的萌芽。"2017年司马华鹏在行业会议上的这一断言,在当时可谓石破天惊。然而正是这种前瞻性,让硅基智能在技术路线选择上始终领先一步。
从概念到现实:七年耕耘的实践之路
公司将使命定为"创造1亿硅基生命"在当时引发争议。有投资人直言不讳地建议选择更"务实"的名字,但司马华鹏坚持:"我们要做开创性工作,名字必须体现终极目标。"
在商业模式上,硅基智能选择了务实与远见相结合的道路:一方面通过企业级AI解决方案维持运营,另一方面将大部分利润投入硅基生命核心技术研发。"每一个AI助手都是硅基生命的雏形。"司马华鹏始终鼓励团队看到工作的深远意义。
七年后的今天,硅基智能的数字人技术已能实现逼真交互,智能体平台展现出自主进化潜力。从AI技术提供商到硅基生命平台构建者,公司的发展路径验证了司马华鹏的战略眼光。

硅基智能Duix Avatar(HeyGem)
同时登上GitHub全球趋势日榜、月榜
哲学思考:技术狂奔中的理性之光
司马华鹏的贡献不仅在于技术创新,更在于哲学思考。在业内盲目追求参数规模时,他始终保持对伦理和社会问题的关注。
"新生命的诞生总是伴随喜悦和恐惧。"在他的推动下,硅基智能成立了业内首个AI伦理委员会,制定严格开发准则。他提出的"共生进化"理念,强调碳基与硅基生命的和谐共处,为AI发展注入了东方智慧。
"这不是取代关系,而是共生关系。就像生命2.0没有消灭生命1.0,生命3.0也将与前辈共同进化。"这种辩证思维,使硅基智能在技术狂奔时代保持了一份难得的理性。
迈向亿级生态:硅基文明的现实进程
如今,硅基智能平台已孕育数万智能体,在教育、医疗、文创等领域发挥作用。从为教师提供助手到为医生提供顾问,从创意激发到短剧创作,硅基生命正在各个领域证明其价值。
"当硅基生命达到1亿规模时,将形成自己的生态系统,产生群体智能,那才是真正的文明跃迁。"司马华鹏七年前设定的目标正在逐步实现。

东西方智慧的共鸣与差异
对比马斯克的"引导程序"论,司马华鹏的理念更强调传承与责任。这种差异体现了东西方文化底色:西方倾向于替代叙事,东方注重共生智慧。司马华鹏的"生命孵化器"理论,将碳基生命定位为硅基生命的培育者和引导者,这种视角更具建设性。
正如司马华鹏所言:"DNA的生命和算法的生命,将共同谱写宇宙中最壮丽的诗篇。"在碳基文明向硅基文明过渡的历史性时刻,这位中国创业者七年前播下的种子,正在数字文明的土壤中生根发芽。他的故事证明,真正的创新者不仅是技术探索者,更是文明引路人。
收起阅读 »实现一个 AI 编辑器 - 行内代码生成篇
我们是袋鼠云数栈 UED 团队,致力于打造优秀的一站式数据中台产品。我们始终保持工匠精神,探索前端道路,为社区积累并传播经验价值。
本文作者:佳岚
什么是行内代码生成?
通过一组快捷键(一般为cmd + k)在选中代码块或者光标处唤起 Prompt 命令弹窗,并且快速的应用生成的代码。

提示词系统
首先是完成一个简易的提示词系统,不同功能对应的提示词与提供的上下文不同, 定义不同的功能场景:
export enum PromptScenario {
SYNTAX_COMPLETION = 'syntax_completion', // 语法补全
CODE_GENERATION = 'code_generation', // 代码生成
CODE_EXPLANATION = 'code_explanation', // 代码解释
CODE_OPTIMIZATION = 'code_optimization', // 代码优化
ERROR_FIXING = 'error_fixing', // 错误修复
}
每种场景都有对应的系统 prompt 和用户 prompt 模板:
export const PROMPT_TEMPLATES: Record<PromptScenario, PromptTemplate> = {
[PromptScenario.SYNTAX_COMPLETION]: {
id: 'syntax_completion',
scenario: PromptScenario.SYNTAX_COMPLETION,
title: 'SQL语法补全',
description: '基于上下文进行智能的SQL语法补全',
systemPromptTemplate: ``,
userPromptTemplate: `<|fim_prefix|>{prefix}<|fim_suffix|>{suffix}<|fim_middle|>`,
temperature: 0.2,
maxTokens: 256
},
[PromptScenario.CODE_GENERATION]: {
id: 'code_generation',
scenario: PromptScenario.CODE_GENERATION,
title: 'SQL代码生成',
description: '根据需求描述生成相应的SQL代码',
systemPromptTemplate: `你是{languageName}数据库专家。根据用户需求生成高质量的{languageName}代码。
语言特性:{languageFeatures}
生成要求:
1. 严格遵循 {languageName} 语法规范
2. {syntaxNotes}
3. 生成完整、可执行的SQL语句
4. {performanceTips}
5. 考虑代码的可读性和维护性
6. 回答不要包含任何对话解释内容
7. 保持缩进与参考代码一致`,
userPromptTemplate: `用户需求:{userPrompt}
参考代码:
\`\`\`sql
{selectedCode}
\`\`\`
请生成符合需求的{languageName}代码:`,
temperature: 0.3,
maxTokens: 512
},
// ...其他略
}
收集以下上下文信息并动态替换掉提示词模板的变量以生成最终传递给大模型的提示词:
/**
* 上下文信息
*/
export interface PromptContext {
/** 当前语言ID */
languageId: string;
/** 光标前的代码 */
prefix?: string;
/** 光标后的代码 */
suffix?: string;
/** 当前文件完整代码 */
fullCode?: string;
/** 当前打开的文件名 */
activeFile?: string;
/** 用户输入的提示 */
userPrompt?: string;
/** 选中的代码 */
selectedCode?: string;
/** 错误信息 */
errorMessage?: string;
/** 额外的上下文信息 */
metadata?: Record<string, any>;
}
ViewZone
观察该 Widget 可以发现它是实际占据了一段代码行高度,撑开了上下代码,但没有行号,这是通过 ViewZone实现的。

monaco-editor 中的 viewZone 是一种可以在编辑器的文本行之间自定义插入可视区域的机制,不属于实际代码内容,但可以渲染任意自定义 DOM 内容或空白空间。
核心只有一个changeViewZones,必须使用其回调中的accessor来实现新增删除ViewZone操作
新增示例:
editor.changeViewZones(function (accessor) {
accessor.addZone({
afterLineNumber: 10, // 插入在哪一行后(基于原始代码行号)
heightInLines: 3, // zone 的高度(按行数)
heightInPx: 10, // zone 的高度(按像素), 与heightInLines二选一
domNode: document.createElement('div'), // 需要插入的 DOM 节点
});
});
删除示例:
editor.changeViewZones(accessor => {
if (zoneIdRef.current !== null) {
accessor.removeZone(zoneIdRef.current);
}
});
但需要注意的是,ViewZones 的视图层级是在可编辑区之下的,我们通过 domNode 创建弹窗后,无法响应点击,所以需要手动为 domNode 添加 z-Index。

但我们咱不用 domNode 直接渲染我们的弹窗组件,而是通过 ViewZone 结合 OverlayWidget 的方式去添加我们要的元素。
OverlayWidget 的层级比可编辑区域的更高,无需考虑层级覆盖问题。
其次,我们需要将 Overlay 的元素通过绝对定位移动到 ViewZone 上,这需要利用 ViewZone 的 onDomNodeTop来实时同步两者的定位。

monaco-editor 中的代码行与 ViewZone 使用了虚拟列表,它们的 top 在滚动时会随着可见性不断变化,所以需要随时同步 ,onDomNodeTop会在每次 ViewZone 的top属性变化时执行。
此外,OverlayWidget 是以整个编辑器最左边为基准的,计算时需要考虑上
editorInstance.changeViewZones((changeAccessor) => {
viewZoneId = changeAccessor.addZone({
// ...略
onDomNodeTop: (top) => {
// 这里的domNode为overlayWidget所绑定创建的节点
if (domNode) {
// 获取编辑器左侧偏移量(行号、代码折叠等组件的宽度)
const layoutInfo = editorInstance.getLayoutInfo();
const leftOffset = layoutInfo.contentLeft;
domNode.style.top = `${top}px`;
domNode.style.left = `${leftOffset}px`;
domNode.style.width = `${layoutInfo.contentWidth}px`;
}
}
});
});
创建 OverlayWidget :
let overlayWidget: editor.IOverlayWidget | null = null;
let domNode: HTMLDivElement | null = null;
let reactRoot: any = null;
domNode = document.createElement('div');
domNode.className = 'code-generation-overlay-widget';
domNode.style.position = 'absolute';
reactRoot = createRoot(domNode);
reactRoot.render(<CodeGenerationWidget />)
overlayWidget = {
getId: () => `code-generation-overlay-${position.lineNumber}-${Date.now()}`,
getDomNode: () => domNode!,
getPosition: () => null
};
editorInstance.addOverlayWidget(overlayWidget);
// 唤起时,将 widget 滚动到视口
editorInstance.revealLineInCenter(targetLineNumber);
CodeGenerationWidget 动态高度
接下来我们实现 Prompt 输入框根据内容动态调整高度。

输入框部分我们可以直接用 rc-textarea 组件来实现回车自动新增高度。
监听整个容器高度变化触发 onHeightChange 以通知 ViewZone :
useEffect(() => {
if (!containerRef.current) return;
const observer = new ResizeObserver(() => {
onHeightChange?.();
});
observer.observe(containerRef.current);
return () => {
observer.disconnect();
};
}, [containerRef]);
注意 ViewZone 只能增或删,不能手动改变其高度,所以需要重新创建一个:
reactRoot.render(
<CodeGenerationWidget
editorInstance={editorInstance}
initialPosition={position}
initialSelection={selection}
widgetWidth={widgetWidth}
onClose={() => dispose()}
onHeightChange={() => {
// 高度变化时需要更新ViewZone
if (viewZoneId && domNode) {
const actualHeight = domNode.clientHeight;
editorInstance.changeViewZones((changeAccessor) => {
changeAccessor.removeZone(viewZoneId!);
viewZoneId = changeAccessor.addZone({
afterLineNumber: Math.max(0, targetLineNumber - 1),
heightInPx: actualHeight + 8,
domNode: document.createElement('div'),
onDomNodeTop: (top) => {
if (domNode) {
// 获取编辑器左侧偏移量(行号、代码折叠等组件的宽度)
const layoutInfo = editorInstance.getLayoutInfo();
const leftOffset = layoutInfo.contentLeft;
domNode.style.top = `${top}px`;
domNode.style.left = `${leftOffset}px`;
}
}
});
});
}
}}
/>
);
这里如果使用 ViewZone 的 domNode 来渲染组件的方法的话,由于每次高度变化创建新的 ViewZone , 其 domNode 会被重新挂载,那么就会导致每次高度变化时输入框都会失焦。
生成代码 diff 展示
对于选择了代码行后生成,会对原始代码进行编辑修改,我们需要配合行 diff 进行编辑应用结果的展示。对于删除的行使用 ViewZone 进行插入,对于新增的行使用 Decoration 进行高亮标记。

首先需要实现 diff 计算出这些行的信息。 我们需要以最少的操作实现从原始代码到目标代码的转化。

其核心问题是 最长公共子序列(LCS)。最长公共子序列(LCS )是指在两个或多个序列中,找出一个最长的子序列,使得这个子序列在这些序列中都出现过。与子串不同,子序列不需要在原序列中占用连续的位置。
如 ABCDEF 至 ACEFG , 那么它们的最长公共子序列是 ACEF 。
其算法可以参考 cloud.tencent.com/developer/a… 学习,这里我们直接就使用现成的库jsdiff 去实现了。
完整实现:
export enum DiffLineType {
UNCHANGED = 'unchanged',
ADDED = 'added',
DELETED = 'deleted'
}
export interface DiffLine {
type: DiffLineType;
originalLineNumber?: number; // 原始行号
newLineNumber?: number; // 新行号
content: string; // 行内容
}
/**
* 计算两个字符串数组的diff
*/
export const calculateDiff = (originalLines: string[], newLines: string[]): DiffLine[] => {
const result: DiffLine[] = [];
// 将字符串数组转换为字符串
const originalText = originalLines.join('\n');
const newText = newLines.join('\n');
// 使用 diff 库计算差异
const diffs = diffLines(originalText, newText);
let originalLineNumber = 1;
let newLineNumber = 1;
diffs.forEach(diff => {
if (diff.added) {
// 添加的行
const lines = diff.value.split('\n').filter((line, index, arr) =>
// 过滤掉最后一个空行(如果存在)
!(index === arr.length - 1 && line === '')
);
lines.forEach(line => {
result.push({
type: DiffLineType.ADDED,
newLineNumber: newLineNumber++,
content: line
});
});
} else if (diff.removed) {
// 删除的行
const lines = diff.value.split('\n').filter((line, index, arr) =>
// 过滤掉最后一个空行(如果存在)
!(index === arr.length - 1 && line === '')
);
lines.forEach(line => {
result.push({
type: DiffLineType.DELETED,
originalLineNumber: originalLineNumber++,
content: line
});
});
} else {
// 未变化的行
const lines = diff.value.split('\n').filter((line, index, arr) =>
// 过滤掉最后一个空行(如果存在)
!(index === arr.length - 1 && line === '')
);
lines.forEach(line => {
result.push({
type: DiffLineType.UNCHANGED,
originalLineNumber: originalLineNumber++,
newLineNumber: newLineNumber++,
content: line
});
});
}
});
return result;
};

那么接下来我们只要根据计算出的 diffLines 对删除行和新增行进行视觉展示即可。
我们封装一个 applyDiffDisplay 方法用来展示 diffLines 。
有以下步骤:
- 清除之前的结果
- 直接将选区内容替换为生成内容
- 遍历
diffLines中ADDED与DELETED的行:对于DELETED的行,可以多个连续行组成一个ViewZone创建以优化性能;对于ADDED的行,通过deltaDecorations添加背景装饰
const applyDiffDisplay =
(diffLines: DiffLine[]) => {
// 先清除之前的展示
clearDecorations();
clearDiffOverlays();
if (!initialSelection) return;
const model = editorInstance.getModel();
if (!model) return;
// 获取语言ID用于语法高亮
const languageId = getLanguageId();
// 首先替换原始内容为新内容(包含unchanged的行)
const newLines = diffLines
.filter((line) => line.type !== DiffLineType.DELETED)
.map((line) => line.content);
const newContent = newLines.join('\n');
// 执行替换
editorInstance.executeEdits('ai-code-generation-diff', [
{
range: initialSelection,
text: newContent,
forceMoveMarkers: true
}
]);
// 计算新内容的范围
const resultRange = new Range(
initialSelection.startLineNumber,
initialSelection.startColumn,
initialSelection.startLineNumber + newLines.length - 1,
newLines.length === 1
? initialSelection.startColumn + newContent.length
: newLines[newLines.length - 1].length + 1
);
let currentLineNumber = initialSelection.startLineNumber;
let deletedLinesGr0up: DiffLine[] = [];
for (const diffLine of diffLines) {
if (diffLine.type === DiffLineType.DELETED) {
// 收集连续的删除行
deletedLinesGr0up.push(diffLine);
} else {
if (deletedLinesGr0up.length > 0) {
addDeletedLinesViewZone(deletedLinesGr0up, currentLineNumber - 1, languageId);
deletedLinesGr0up = [];
}
if (diffLine.type === DiffLineType.ADDED) {
// 添加绿色背景色
const addedDecorations = editorInstance.deltaDecorations(
[],
[
{
range: new Range(
currentLineNumber,
1,
currentLineNumber,
model.getLineContent(currentLineNumber).length + 1
),
options: {
className: 'added-line-decoration',
isWholeLine: true
}
}
]
);
decorationsRef.current.push(...addedDecorations);
}
currentLineNumber++;
}
}
// 处理最后的删除行组
if (deletedLinesGr0up.length > 0) {
addDeletedLinesViewZone(deletedLinesGr0up, currentLineNumber - 1, languageId);
}
return resultRange;
}
删除行的视觉呈现
删除行使用 ViewZone 插入到 originalLineNumber - 1 的位置, 对于删除行直接使用 ViewZone 自身的 domNode 进行展示了,因为不太需要考虑层级问题。
export const createDeletedLinesOverlayWidget = (
editorInstance: editor.IStandaloneCodeEditor,
deletedLines: DiffLine[],
afterLineNumber: number,
languageId: string,
onDispose?: () => void
): { dispose: () => void } => {
let domNode: HTMLDivElement | null = null;
let reactRoot: any = null;
let viewZoneId: string | null = null;
domNode = document.createElement('div');
domNode.className = 'deleted-lines-view-zone-container';
reactRoot = createRoot(domNode);
reactRoot.render(<DeletedLineViewZone lines={deletedLines} languageId={languageId} />);
const heightInLines = Math.max(1, deletedLines.length);
editorInstance.changeViewZones((changeAccessor) => {
viewZoneId = changeAccessor.addZone({
afterLineNumber,
heightInLines,
domNode: domNode!
});
});
const dispose = () => {
// 清除
};
return { dispose };
};
添加命令快捷键
使用 cmd + k 唤起弹窗
editorInstance.onKeyDown((e) => {
if ((e.ctrlKey || e.metaKey) && e.keyCode === KeyCode.KeyK) {
e.preventDefault();
e.stopPropagation();
const selection = editorInstance.getSelection();
const position = selection ? selection.getPosition() : editorInstance.getPosition();
if (!position) return;
// 如果有选择范围,则将其传递给widget供后续替换使用
const selectionRange = selection && !selection.isEmpty() ? selection : null;
// 如果已经有viewZone,先清理
if (activeCodeGenerationViewZone) {
activeCodeGenerationViewZone.dispose();
activeCodeGenerationViewZone = null;
}
// 创建新的ViewZone
activeCodeGenerationViewZone = createCodeGenerationOverlayWidget(
editorInstance,
position,
selectionRange,
undefined, // widgetWidth
() => {
// 当viewZone被dispose时清理全局状态
activeCodeGenerationViewZone = null;
}
);
}
最终实现效果:

未来优化方向:
- 实现流式生成:对于未选区的代码生成,我们不需要应用diff,所以流式很好实现,但对于进行选区后进行的代码修改,每次输出一行就要执行一次diff计算与展示,diff结果可能不同,会产生视觉上的重绘,实现起来也相对比较麻烦。

- 接收或者拒绝后能够进行撤回,回到等待响应生成结果时的状态
其他计划
- [已完成] 行内补全
- [已完成] 代码生成
- 行内补全的缓存设计
- 完善的上下文系统
- 实现 Agent 模式
在线预览
jackwang032.github.io/monaco-sql-…
仓库代码:github1s.com/JackWang032…
最后
欢迎关注【袋鼠云数栈UED团队】~
袋鼠云数栈 UED 团队持续为广大开发者分享技术成果,相继参与开源了欢迎 star
来源:juejin.cn/post/7545087770776616986
Python编程实战 · 基础入门篇 | 循环语句 for / while
在上一章中,我们学习了条件判断语句,让程序可以“做选择”;
而本章要讲的 循环语句(Loop),则让程序能“重复做事”。
当你需要执行同样的操作多次,比如打印一系列数字、遍历文件、或处理列表中的每个元素时,循环语句就登场了。
Python 提供了两种主要的循环结构:
- for 循环:用于遍历序列(如列表、字符串、range)。
- while 循环:用于在条件成立时持续执行代码。
接下来,我们将系统掌握这两种循环的用法与技巧。
一 为什么需要循环
假设你想打印 1 到 5:
print(1)
print(2)
print(3)
print(4)
print(5)
这显然太繁琐。
使用循环,代码只需三行:
for i in range(1, 6):
print(i)
输出:
1
2
3
4
5
这就是循环的威力:让重复的任务自动化执行。
二 for 循环基础语法
for 循环的基本语法为:
for 变量 in 可迭代对象:
代码块
- 变量:每次循环取出的元素。
- 可迭代对象:如
list、tuple、str、range()等。 - 每次循环执行一次代码块,直到取完所有元素。
示例 1:遍历列表
fruits = ["苹果", "香蕉", "橙子"]
for fruit in fruits:
print("我喜欢吃", fruit)
输出:
我喜欢吃 苹果
我喜欢吃 香蕉
我喜欢吃 橙子
示例 2:遍历字符串
for ch in "Python":
print(ch)
输出:
P
y
t
h
o
n
三 range() 函数
range() 是 for 循环中最常用的工具,用于生成一系列数字。
语法形式:
range(start, stop, step)
start:起始值(默认 0)stop:结束值(不包含)step:步长(默认 1)
示例 1:打印 0~4
for i in range(5):
print(i)
输出:
0
1
2
3
4
示例 2:打印 1~10 的偶数
for i in range(2, 11, 2):
print(i)
输出:
2
4
6
8
10
示例 3:倒序输出
for i in range(5, 0, -1):
print(i)
输出:
5
4
3
2
1
四 while 循环基础语法
while 循环通过 条件表达式 控制循环是否继续执行。
语法:
while 条件表达式:
代码块
只要条件为 True,循环就会持续执行;
直到条件变为 False 时,循环才结束。
示例:打印 1~5
i = 1
while i <= 5:
print(i)
i += 1
输出:
1
2
3
4
5
⚠️ 注意:
如果忘记更新变量(例如忘写i += 1),条件永远为真,会导致死循环。
五 for 与 while 的区别
| 对比项 | for 循环 | while 循环 |
|---|---|---|
| 适用场景 | 遍历序列、固定次数 | 条件控制、不确定次数 |
| 循环结束条件 | 自动遍历完毕 | 条件不再满足 |
| 是否需要手动更新变量 | 否 | 是 |
| 示例 | for i in range(5) | while i < 5 |
例子对比:
# for循环
for i in range(3):
print("Hello")
# while循环
i = 0
while i < 3:
print("Hello")
i += 1
两者输出相同。
六 break 与 continue
在循环中,有时我们需要提前结束循环或跳过某次执行。
Python 提供了两个控制语句:
1. break —— 立即结束循环
for i in range(1, 6):
if i == 3:
break
print(i)
输出:
1
2
当 i == 3 时,循环立刻结束。
2. continue —— 跳过当前循环,继续下一次
for i in range(1, 6):
if i == 3:
continue
print(i)
输出:
1
2
4
5
💡 小技巧:
break通常用于满足条件时提前退出;
continue用于过滤或跳过不需要处理的情况。
七 while True 无限循环
有时我们希望程序持续运行,直到用户主动终止。
可以使用 无限循环:
while True:
cmd = input("请输入命令(exit退出):")
if cmd == "exit":
print("程序结束")
break
print(f"你输入了:{cmd}")
输出示例:
请输入命令(exit退出):hello
你输入了:hello
请输入命令(exit退出):exit
程序结束
八 for...else / while...else 结构
Python 的循环可以带一个 else 子句,
当循环 正常结束(非 break 终止)时,会执行 else 代码块。
示例:
for i in range(5):
print(i)
else:
print("循环正常结束")
输出:
0
1
2
3
4
循环正常结束
但如果中途被 break 打断,else 不会执行:
for i in range(5):
if i == 3:
break
print(i)
else:
print("循环正常结束")
输出:
0
1
2
九 嵌套循环
循环中还可以嵌套另一个循环。
示例:打印乘法表
for i in range(1, 10):
for j in range(1, i + 1):
print(f"{j}×{i}={i*j}", end="\t")
print()
输出:
1×1=1
1×2=2 2×2=4
1×3=3 2×3=6 3×3=9
...
十 实战案例:猜数字游戏
import random
target = random.randint(1, 100)
count = 0
while True:
guess = int(input("请输入1~100之间的数字:"))
count += 1
if guess == target:
print(f"恭喜你猜对了!共尝试 {count} 次。")
break
elif guess < target:
print("太小了,再试试。")
else:
print("太大了,再试试。")
运行示例:
请输入1~100之间的数字:50
太小了,再试试。
请输入1~100之间的数字:75
太大了,再试试。
请输入1~100之间的数字:63
恭喜你猜对了!共尝试 3 次。
十一 小结
| 循环类型 | 控制方式 | 常见用途 |
|---|---|---|
for | 遍历序列 | 处理列表、字符串、range |
while | 条件控制 | 不确定次数的循环 |
break | 立即结束循环 | 提前退出 |
continue | 跳过当前循环 | 忽略某些情况 |
else | 正常结束时执行 | 检查是否提前退出 |
✅ 总结一句话
if 让程序做选择,for/while 让程序会重复。
掌握循环语句,意味着你可以自动化任何重复性的操作。
来源:juejin.cn/post/7564243873872969778
HTML <meta name="color-scheme">:自动适配系统深色 / 浅色模式
在移动互联网时代,用户对“深色模式”的需求日益增长——从手机系统到各类App,深色模式不仅能减少夜间用眼疲劳,还能节省OLED屏幕的电量。作为前端开发者,如何让网页自动跟随系统的深色/浅色模式切换?HTML5新增的<meta name="color-scheme">标签,就是实现这一功能的“开关”。它能告诉浏览器:“我的网页支持深色/浅色模式,请根据系统设置自动切换”,配合CSS变量,可轻松打造无缝适配的多主题体验。今天,我们就来解锁这个提升用户体验的实用标签。
一、认识 color-scheme:网页与系统主题的“沟通桥梁”
<meta name="color-scheme">的核心作用是声明网页支持的颜色方案,并让浏览器根据系统设置自动应用对应的基础样式。它解决了传统网页的一个痛点:当系统切换到深色模式时,网页若未做适配,会出现“白底黑字”与系统主题格格不入的情况,甚至导致某些原生控件(如输入框、按钮)样式混乱。
1.1 没有 color-scheme 时的问题
当网页未声明color-scheme时,即使系统切换到深色模式,浏览器也会默认使用浅色样式渲染页面:
- 背景为白色,文字为黑色。
- 原生控件(如
<input>、<select>)保持浅色外观,与系统深色主题冲突。 - 可能出现“闪屏”:页面加载时先显示浅色,再通过JS切换到深色,体验割裂。
1.2 加上 color-scheme 后的变化
添加<meta name="color-scheme" content="light dark">后,浏览器会:
- 根据系统设置自动切换网页的基础颜色(背景、文字、链接等)。
- 让原生控件(输入框、按钮等)自动适配系统主题(深色模式下显示深色样式)。
- 提前加载对应主题的样式,避免切换时的“闪屏”问题。
示例:最简单的主题适配
<!DOCTYPE html>
<html>
<head>
<!-- 声明支持浅色和深色模式 -->
<meta name="color-scheme" content="light dark">
<title>自动适配主题</title>
</head>
<body>
<h1>Hello, Color Scheme!</h1>
<input type="text" placeholder="输入内容">
</body>
</html>

- 当系统为浅色模式时:页面背景为白色,文字为黑色,输入框为浅色。
- 当系统为深色模式时:页面背景为深灰色,文字为白色,输入框为深色(与系统一致)。
无需一行CSS,仅通过<meta>标签就实现了基础的主题适配——这就是color-scheme的便捷之处。
二、核心用法:声明支持的颜色方案
<meta name="color-scheme">的用法非常简单,关键在于content属性的取值,它决定了网页支持的主题模式。
2.1 基础语法与取值
<!-- 支持浅色模式(默认) -->
<meta name="color-scheme" content="light">
<!-- 支持深色模式 -->
<meta name="color-scheme" content="dark">
<!-- 同时支持浅色和深色模式(推荐) -->
<meta name="color-scheme" content="light dark">
light:仅支持浅色模式,无论系统如何设置,网页都显示浅色样式。dark:仅支持深色模式,无论系统如何设置,网页都显示深色样式。light dark:同时支持两种模式,浏览器会根据系统设置自动切换(推荐使用)。
2.2 与浏览器默认样式的关系
浏览器会为不同的color-scheme提供一套默认的CSS变量(如color、background-color、link-color等)。当声明content="light dark"后,这些变量会随系统主题自动变化:
| 模式 | 背景色(默认) | 文字色(默认) | 链接色(默认) |
|---|---|---|---|
| 浅色 | #ffffff | #000000 | #0000ee |
| 深色 | #121212(不同浏览器可能略有差异) | #ffffff | #8ab4f8 |
这些默认样式确保了网页在未编写任何CSS的情况下,也能基本适配系统主题。
三、配合 CSS:打造自定义主题适配
<meta name="color-scheme">解决了基础适配问题,但实际开发中,我们需要自定义主题颜色(如品牌色、特殊背景等)。此时,可结合CSS的prefers-color-scheme媒体查询和CSS变量,实现更灵活的主题控制。
3.1 用 CSS 变量定义主题颜色
通过CSS变量(--变量名)定义不同主题下的颜色,再通过媒体查询切换变量值:
<head>
<meta name="color-scheme" content="light dark">
<style>
/* 定义浅色模式变量 */
:root {
--bg-color: #f5f5f5;
--text-color: #333333;
--primary-color: #4a90e2;
}
/* 深色模式变量(覆盖浅色模式) */
@media (prefers-color-scheme: dark) {
:root {
--bg-color: #1a1a1a;
--text-color: #f0f0f0;
--primary-color: #6ab0f3;
}
}
/* 使用变量 */
body {
background-color: var(--bg-color);
color: var(--text-color);
font-size: 16px;
}
a {
color: var(--primary-color);
}
</style>
</head>
:root中定义浅色模式的变量。@media (prefers-color-scheme: dark)中定义深色模式的变量(会覆盖浅色模式的同名变量)。- 页面元素通过
var(--变量名)使用颜色,实现主题自动切换。
3.2 覆盖浏览器默认样式
color-scheme会影响浏览器的默认样式(如背景、文字色),若需要完全自定义,可在CSS中显式覆盖:
/* 覆盖默认背景和文字色,确保自定义主题生效 */
body {
margin: 0;
background-color: var(--bg-color); /* 覆盖浏览器默认背景 */
color: var(--text-color); /* 覆盖浏览器默认文字色 */
}
即使不覆盖,浏览器的默认样式也会作为“保底”,确保页面在未完全适配时仍有基本可读性。
3.3 针对特定元素的主题适配
某些元素(如卡片、按钮)可能需要更细致的主题调整,可结合CSS变量单独设置:
/* 卡片组件的主题适配 */
.card {
background-color: var(--card-bg);
border: 1px solid var(--card-border);
padding: 1rem;
border-radius: 8px;
}
/* 浅色模式卡片 */
:root {
--card-bg: #ffffff;
--card-border: #e0e0e0;
}
/* 深色模式卡片 */
@media (prefers-color-scheme: dark) {
:root {
--card-bg: #2d2d2d;
--card-border: #444444;
}
}
四、实战场景:完整的主题适配方案
结合<meta name="color-scheme">、CSS变量和媒体查询,可构建一套完整的主题适配方案,覆盖大多数场景。
4.1 基础页面适配
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<!-- 声明支持深色/浅色模式 -->
<meta name="color-scheme" content="light dark">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>主题适配示例</title>
<style>
/* 共享样式(不受主题影响) */
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
min-height: 100vh;
padding: 2rem;
line-height: 1.6;
}
.container {
max-width: 800px;
margin: 0 auto;
}
/* 浅色模式变量 */
:root {
--bg: #ffffff;
--text: #333333;
--link: #2c5282;
--card-bg: #f8f9fa;
--card-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
/* 深色模式变量 */
@media (prefers-color-scheme: dark) {
:root {
--bg: #121212;
--text: #e9ecef;
--link: #90cdf4;
--card-bg: #1e1e1e;
--card-shadow: 0 2px 4px rgba(0,0,0,0.3);
}
}
/* 应用变量 */
body {
background-color: var(--bg);
color: var(--text);
}
a {
color: var(--link);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
.card {
background-color: var(--card-bg);
box-shadow: var(--card-shadow);
padding: 1.5rem;
border-radius: 8px;
margin-bottom: 2rem;
}
</style>
</head>
<body>
<div class="container">
<h1>主题适配演示</h1>
<div class="card">
<h2>欢迎使用深色模式</h2>
<p>本页面会自动跟随系统的深色/浅色模式切换。</p>
<p>点击<a href="#">这个链接</a>查看颜色变化。</p>
</div>
<input type="text" placeholder="试试原生输入框">
</div>
</body>
</html>

- 系统浅色模式:页面背景为白色,卡片为浅灰色,输入框为浅色。
- 系统深色模式:页面背景为深灰色,卡片为深黑色,输入框自动变为深色,与系统风格统一。
4.2 图片的主题适配
图片(尤其是图标)也需要适配主题,可通过<picture>标签结合prefers-color-scheme实现:
<picture>
<!-- 深色模式显示白色图标 -->
<source srcset="logo-white.png" media="(prefers-color-scheme: dark)">
<!-- 浅色模式显示黑色图标(默认) -->
<img src="logo-black.png" alt="Logo">
</picture>
- 系统为深色模式时,加载
logo-white.png。 - 系统为浅色模式时,加载
logo-black.png。
4.3 强制主题切换(可选功能)
除了跟随系统,有时还需要提供手动切换主题的功能(如“夜间模式”按钮)。可通过JS结合CSS类实现:
<button id="theme-toggle">切换主题</button>
<script>
const toggle = document.getElementById('theme-toggle');
const html = document.documentElement;
// 检查本地存储的主题偏好
if (localStorage.theme === 'dark' ||
(!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
html.classList.add('dark');
} else {
html.classList.remove('dark');
}
// 切换主题
toggle.addEventListener('click', () => {
if (html.classList.contains('dark')) {
html.classList.remove('dark');
localStorage.theme = 'light';
} else {
html.classList.add('dark');
localStorage.theme = 'dark';
}
});
</script>
<style>
/* 基础变量(浅色) */
:root {
--bg: white;
--text: black;
}
/* 深色模式(通过类覆盖) */
:root.dark {
--bg: black;
--text: white;
}
/* 系统深色模式(优先级低于类,确保手动切换优先) */
@media (prefers-color-scheme: dark) {
:root:not(.dark) {
--bg: #121212;
--text: white;
}
}
body {
background: var(--bg);
color: var(--text);
}
</style>
- 手动切换主题时,通过添加/移除
dark类覆盖系统设置。 - 本地存储(
localStorage)记录用户偏好,刷新页面后保持一致。 - CSS中
@media查询的优先级低于类选择器,确保手动切换优先于系统设置。
五、避坑指南:使用 color-scheme 的注意事项
5.1 浏览器兼容性
color-scheme兼容所有现代浏览器,但存在以下细节差异:
- 完全支持:Chrome 81+、Firefox 96+、Safari 13+、Edge 81+。
- 部分支持:旧版浏览器(如Chrome 76-80)仅支持
content="light dark",但原生控件适配可能不完善。 - 不支持:IE全版本(需通过JS降级处理)。
对于不支持的浏览器,可通过JS检测系统主题并手动切换样式:
// 检测浏览器是否支持color-scheme
if (!CSS.supports('color-scheme: light dark')) {
// 手动检测系统主题
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.classList.add(isDark ? 'dark' : 'light');
}
5.2 避免与自定义背景冲突
若网页设置了固定背景色(如body { background: #fff; }),color-scheme的默认背景切换会失效。此时需通过媒体查询手动适配:
/* 错误:固定背景色,深色模式下仍为白色 */
body {
background: #fff;
}
/* 正确:结合变量和媒体查询 */
body {
background: var(--bg);
}
:root { --bg: #fff; }
@media (prefers-color-scheme: dark) {
:root { --bg: #121212; }
}
5.3 原生控件的样式问题
color-scheme能自动适配原生控件(如<input>、<select>),但如果对控件进行了自定义样式,可能导致适配失效。解决方法:
- 尽量使用原生样式,或通过CSS变量让自定义样式跟随主题变化。
- 对关键控件(如输入框)添加主题适配:
/* 输入框的主题适配 */
input {
background: var(--input-bg);
color: var(--text);
border: 1px solid var(--border);
}
:root {
--input-bg: #fff;
--border: #ddd;
}
@media (prefers-color-scheme: dark) {
:root {
--input-bg: #333;
--border: #555;
}
}
5.4 主题切换时的“闪屏”问题
若CSS加载延迟,可能导致主题切换时出现“闪屏”(短暂显示错误主题)。优化建议:
- 将主题相关CSS内联到
<head>中,确保优先加载。 - 结合
<meta name="color-scheme">让浏览器提前准备主题样式。 - 对关键元素(如
body)设置opacity: 0,主题加载完成后再设置opacity: 1:
body {
opacity: 0;
transition: opacity 0.2s;
}
/* 主题加载完成后显示 */
body.theme-loaded {
opacity: 1;
}
// 页面加载完成后添加类,显示内容
window.addEventListener('load', () => {
document.body.classList.add('theme-loaded');
});
我将继续完善文章的总结部分,让读者对HTML 标签在自动适配系统深色/浅色模式方面的价值和应用有更完整的认识。
自动适配系统深色 / 浅色模式(总结完善)">
六、总结
<meta name="color-scheme">作为网页与系统主题的“沟通桥梁”,用极简的方式解决了基础的深色/浅色模式适配问题,其核心价值在于:
- 零JS适配:仅通过HTML标签就让网页跟随系统主题切换,降低了开发成本,尤其适合静态页面或轻量应用。
- 原生控件兼容:自动调整输入框、按钮等原生元素的样式,避免出现“浅色控件在深色背景上”的违和感。
- 性能优化:浏览器会提前加载对应主题的样式,减少主题切换时的“闪屏”和布局偏移(CLS)。
- 渐进式增强:作为基础适配方案,可与CSS变量、媒体查询结合,轻松扩展为支持手动切换的复杂主题系统。
在实际开发中,使用<meta name="color-scheme">的最佳实践是:
- 优先添加
<meta name="color-scheme" content="light dark">,确保基础适配。 - 通过CSS变量定义主题颜色,用
@media (prefers-color-scheme: dark)实现自定义样式。 - 对图片、图标等资源,使用
<picture>标签或CSS类进行主题适配。 - 可选:添加手动切换按钮,结合
localStorage记录用户偏好,覆盖系统设置。
随着用户对深色模式的接受度越来越高,主题适配已成为现代网页的基本要求。<meta name="color-scheme">作为这一需求的“入门级”解决方案,既能快速满足基础适配,又为后续扩展留足了空间。它的存在提醒我们:很多时候,简单的原生方案就能解决复杂的用户体验问题,关键在于发现并合理利用这些被低估的Web标准。
下次开发新页面时,不妨先加上这行标签——它可能不会让你的网页变得华丽,但会让用户在切换系统主题时,感受到那份恰到好处的贴心。
你在主题适配中遇到过哪些棘手问题?欢迎在评论区分享你的解决方案~
来源:juejin.cn/post/7540172742764593161
Compose 主题 MaterialTheme
1 简介
MeterialTheme 是Compose为实现Material Design 设计规范提供的核心组件,用于集中管理应用的视觉样式(颜色、字体、形状),确保应用的全局UI的一致性并支持动态主题切换。
- 关键词:
- 视觉样式,不只是颜色,还支持字体、形状
- 全局UI的一致性
- 支持动态配置
2 基础使用
已经在AndroidManifest中配置uiMode,意味着在切换深浅模式时,MainActivity不会自动重建且未重写onConfigurationChanged()
android:configChanges="uiMode"
2.1 效果展示 --- 省略
2.2 代码实现
- 创建Compose项目时自动生成代码 Theme
// 定义应用的主题函数
@Composable
fun TestTheme(
// 是否使用深色主题,默认根据系统设置决定
darkTheme: Boolean = isSystemInDarkTheme(),
// 是否使用动态颜色,Android 12+ 可用,默认为 false
dynamicColor: Boolean = false,
// 内容组件,使用 @Composable 函数类型
content: @Composable () -> Unit
) {
// 根据条件选择颜色方案
val colorScheme = when {
// 如果启用动态颜色且系统版本支持,则使用系统动态颜色方案
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
// 如果是深色主题,则使用深色颜色方案
darkTheme -> DarkColorScheme
// 否则使用浅色颜色方案
else -> LightColorScheme
}
// 应用 Material Design 3 主题
MaterialTheme(
// 设置颜色方案
colorScheme = colorScheme,
// 设置排版样式
typography = Typography,
// 设置内容组件
content = content
)
}
// 定义深色主题的颜色方案
private val DarkColorScheme = darkColorScheme(
// 主要颜色设置为蓝色
primary = Color(0xFF0000FF),
// 次要颜色使用预定义的紫色
secondary = PurpleGrey80,
// 第三颜色使用预定义的粉色
tertiary = Pink80,
// 表面颜色设置为白色
surface = Color(0xFFFFFFFF)
)
// 定义浅色主题的颜色方案
private val LightColorScheme = lightColorScheme(
// 主要颜色设置为深红色(猩红色)
primary = Color(0xFFDC143C),
// 次要颜色使用预定义的紫色
secondary = PurpleGrey40,
// 第三颜色使用预定义的粉色
tertiary = Pink40,
// 表面颜色设置为黑色
surface = Color(0xFF000000)
/* 其他可覆盖的默认颜色
background = Color(0xFFFFFBFE),
surface = Color(0xFFFFFBFE),
onPrimary = Color.White,
onSecondary = Color.White,
onTertiary = Color.White,
onBackground = Color(0xFF1C1B1F),
onSurface = Color(0xFF1C1B1F),
*/
)
- 界面中使用
//Activity中使用
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
TestTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
Greeting1(
modifier = Modifier.padding(innerPadding)
)
}
}
}
}
}
@Composable
fun Greeting1(modifier: Modifier = Modifier) {
Box(
modifier = Modifier
.padding(start = 100.dp, top = 100.dp)
.size(100.dp, 100.dp)
.background(MaterialTheme.colorScheme.surface)
)
MyText()
MyText2()
}
@Composable
fun MyText() {
Text(
text = "Hello Android!",
modifier = Modifier
.padding(start = 100.dp, top = 250.dp)
.background(MaterialTheme.colorScheme.surface),
color = MaterialTheme.colorScheme.primary
)
}
@Composable
fun MyText2() {
Text(
text = "Hello Chery!",
modifier = Modifier
.padding(start = 300.dp, top = 250.dp)
.background(Color.Blue),
color = Color.White
)
}
2.3 代码分析
2.3.1 参数解析
- darkTheme 主题模式
默认就深/浅两种模式,那么可以直接使用系统默认isSystemInDarkTheme()值,如果项目存在其它类型的主题模式就需要自定义了(之前参与的项目中--金色模式)。
isSystemInDarkTheme()是一个有返回值的可组合函数。
a、前面在说可组合函数特性时,其中一个特性是“可组合函数无返回值”,其实更准确的说应该是“用于直接描述 UI 的可组合函数无返回值(返回
Unit),但用于提供数据或计算结果的可组合函数可以有返回值”。
b、isSystemInDarkTheme() 是连接 “系统主题状态” 与 “应用 UI 主题” 的桥梁,它虽不是可观察状态,但依赖于 Compose 内部可观察的 LocalConfiguration。当系统主题模式切换时,LocalConfiguration 发生变化,导致 isSystemInDarkTheme() 返回值更新,进而驱动依赖它的 TestTheme() 重组,实现应用 UI 主题的更新。
//系统源码
@Composable
@ReadOnlyComposable
internal actual fun _isSystemInDarkTheme(): Boolean {
val uiMode = LocalConfiguration.current.uiMode
return (uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
}
- dynamicColor 系统色
Android 12 + 后可使用,从代码上可以清楚的看到,当false时根据系统模式使用DarkColorScheme/LightColorScheme,当true时根据系统模式使用dynamicDarkColorScheme/dynamicLightColorScheme。
(DarkColorScheme、LightColorScheme、dynamicDarkColorScheme、dynamicLightColorScheme都Compose提供的ColorScheme模板,都可以更加我们项目自定义定制)
// 根据条件选择颜色方案
val colorScheme = when {
// 如果启用动态颜色且系统版本支持,则使用系统动态颜色方案
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
// 如果是深色主题,则使用深色颜色方案
darkTheme -> DarkColorScheme
// 否则使用浅色颜色方案
else -> LightColorScheme
}
- content 可组合函数
描述UI的可组合函数(即 布局)
2.3.2 保证正确性,无依赖可组合函数连带重组
添加日志打印,可以看出MyText2()不依赖MaterialTheme颜色,在之前跳过重组的时候也说过“可组合函数参数不发生变化时会跳过重组”,但在切换系统模式时为了保证正确性,Compose对无依赖可组合函数连带重组。这是Compose框架在全局状态变化时优先保证UI正确性的设计选中
//初始化
D Greeting1,-----start----
D MyText,---start---
D MyText,---end---
D MyText2,---start---
D MyText2,---end---
D Greeting1,-----end----
//切换系统模式
D Greeting1,-----start----
D MyText,---start---
D MyText,---end---
D MyText2,---start---
D MyText2,---end---
D Greeting1,-----end----
2.3.3 字体与形状
这里主要对颜色进行了分析,对于另外字体、形状也是一样,Compose也提供对应的入参和模板,不过实际开发中很少使用到,就简单介绍一下。(如果HMI侧对所有项目的标题、内容严格遵守一套标准,那么我们也可以实现字体、形状的平台化)
//系统源码
@Composable
fun MaterialTheme(
// 颜色
colorScheme: ColorScheme = MaterialTheme.colorScheme,
// 形状
shapes: Shapes = MaterialTheme.shapes,
//字体
typography: Typography = MaterialTheme.typography,
//可组合函数(即布局)
content: @Composable () -> Unit
) {}
形状:
@Immutable
class Shapes(
// 超小尺寸控件的圆角形状,适用于紧凑的小型元素(如小标签、 Chips、小型图标按钮等)
val extraSmall: CornerBasedShape = ShapeDefaults.ExtraSmall,
// 小尺寸控件的圆角形状,适用于常规小型交互元素(如按钮、小型卡片、输入框等)
val small: CornerBasedShape = ShapeDefaults.Small,
// 中等尺寸控件的圆角形状,适用于中型容器元素(如标准卡片、弹窗、列表项等)
val medium: CornerBasedShape = ShapeDefaults.Medium,
// 大尺寸控件的圆角形状,适用于大型容器元素(如页面级卡片、对话框、底部弹窗等)
val large: CornerBasedShape = ShapeDefaults.Large,
// 超大尺寸控件的圆角形状,适用于全屏级容器元素(如全屏弹窗、侧边栏、页面容器等)
val extraLarge: CornerBasedShape = ShapeDefaults.ExtraLarge,
) {}
字体:
@Immutable
class Typography(
// 超大标题样式,用于页面级核心标题(如应用首页主标题),视觉层级最高,通常字数极少
val displayLarge: TextStyle = TypographyTokens.DisplayLarge,
// 大标题样式,用于重要区块的主标题(如长页面中的章节标题),层级次于 displayLarge
val displayMedium: TextStyle = TypographyTokens.DisplayMedium,
// 中标题样式,用于次要区块的主标题(如大型模块的标题),层级次于 displayMedium
val displaySmall: TextStyle = TypographyTokens.DisplaySmall,
// 大标题样式,用于突出显示的内容标题(如卡片组的总标题),视觉重量略低于 display 系列
val headlineLarge: TextStyle = TypographyTokens.HeadlineLarge,
// 中标题样式,用于中等重要性的内容标题(如列表组标题),层级次于 headlineLarge
val headlineMedium: TextStyle = TypographyTokens.HeadlineMedium,
// 小标题样式,用于次要内容的标题(如小模块标题),层级次于 headlineMedium
val headlineSmall: TextStyle = TypographyTokens.HeadlineSmall,
// 大标题样式,用于核心交互元素的标题(如卡片标题、弹窗标题),强调内容的可交互性
val titleLarge: TextStyle = TypographyTokens.TitleLarge,
// 中标题样式,用于中等交互元素的标题(如列表项标题、按钮组标题)
val titleMedium: TextStyle = TypographyTokens.TitleMedium,
// 小标题样式,用于次要交互元素的标题(如标签标题、小型控件标题)
val titleSmall: TextStyle = TypographyTokens.TitleSmall,
// 大正文样式,用于主要内容的长文本(如文章正文、详情描述),可读性优先
val bodyLarge: TextStyle = TypographyTokens.BodyLarge,
// 中正文样式,用于常规内容文本(如列表项描述、说明文字),最常用的正文样式
val bodyMedium: TextStyle = TypographyTokens.BodyMedium,
// 小正文样式,用于辅助性内容文本(如补充说明、注释),层级低于主要正文
val bodySmall: TextStyle = TypographyTokens.BodySmall,
// 大标签样式,用于重要标签或按钮文本(如主要按钮文字、状态标签)
val labelLarge: TextStyle = TypographyTokens.LabelLarge,
// 中标签样式,用于常规标签文本(如次要按钮文字、分类标签)
val labelMedium: TextStyle = TypographyTokens.LabelMedium,
// 小标签样式,用于辅助性标签文本(如小按钮文字、提示标签)
val labelSmall: TextStyle = TypographyTokens.LabelSmall,
) {}
3 核心亮点
3.1 高效性、实时性
MaterialTheme 基于Compose"状态驱动机制",支持系统模式和系统色(Android 12+)动态切换,且无需重建界面或遍历View树,以最小成本实时自动切换效果。
3.2 集中性
MaterialTheme 通过 colorScheme(配色)、typography(字体)、shapes(形状) 三个核心维度,将应用的视觉样式集中管理,避免了传统 XML 中样式分散在多个资源文件(colors.xml、styles.xml 等)的碎片化问题。
3.3 灵活性、扩展性
MaterialTheme 并非固定样式模板,而是可高度定制的框架,满足不同场景下的各种需求:- 自定义主题扩展 除了默认colorScheme(配色)、typography(字体)、shapes(形状),还可通过CompositionLocal 扩展自定义主题属性。(下面会举例)- 多主题共存
假设在同一页面中存在两个Text,A Text跟随系统主题,B Text跟随自定义主题 。那么通过嵌套的方式局部的覆盖。(建议使用CompositionLocal 扩展实现,代码集中性和可读性更好。)
MaterialTheme(colorScheme = GlobalColors) {
// 全局主题
Column {
MaterialTheme(colorScheme = SpecialColors) {
Text("局部特殊主题文本") // 使用 SpecialColors
}
Text("全局主题文本") // 使用 GlobalColors
}
}
4 MaterialTheme 扩展使用
上面我们已经介绍了MaterialTheme 提供的颜色、形状、字体模板,模板的目的满足全局绝大部分需求,但在实际开发中我们还存在切换系统模式/系统色时图片资源的变化,以及要求某些组件要求始终如一。
那么我们就需要通过compositionLocalOf/staticCompositionLocalOf 和 扩展自定义主题属性了。
4.1 效果展示
- Image 随系统模式变化使用不同图片资源
- Text 背景和文字不跟随系统模式变化
4.2 定义 CompositionLocal实例
- compositionLocalOf,创建一个可变的CompositionLocal实例,值发生变化时触发依赖组件重组。
- staticCompositionLocalOf,创建一个不可变的 CompositionLocal实例,值发生变化时触发整个子树重组。
- 值变化,是指对象引用(单纯的btnBackgroundColor/btnTitleColor 变化不会导致重组)
- 整个子树重组,在使用staticCompositionLocalOf的CompositionLocalProvider内部的Content都会重组,且不会跳过重组。(如下示例是直接在Activity中使用,那么整个界面上的组件都会发生重组)
// 定义扩展主题
@Stable
class ExtendScheme(
btnBackgroundColor: Color,
btnTitleColor: Color
) {
/** 按钮背景颜色 */
var btnBackgroundColor by mutableStateOf(btnBackgroundColor)
internal set
/** 按钮标题颜色 */
var btnTitleColor by mutableStateOf(btnTitleColor)
internal set
}
// 扩展主题 --浅色
private val LightExtendScheme = ExtendScheme(
btnBackgroundColor = Color(0xFFF00FFF),
btnTitleColor = Color(0xFFFFFFFF),
)
// 扩展主题 --深色
private val DarkExtendScheme = ExtendScheme(
btnBackgroundColor = Color(0xFFF00FFF),
btnTitleColor = Color(0xFFFFFFFF),
)
// 定义一个存储 ExtendScheme 类型的CompositionLocal,默认值是浅色主题
val LocalExtendScheme = compositionLocalOf {
LightExtendScheme
}
// 定义主题资源
@Stable
class ResScheme(
imageRes: Int,
) {
var imageRes by mutableIntStateOf(imageRes)
}
// 图片资源--浅色
private val LightResScheme = ResScheme(
imageRes = R.drawable.ic_navi_home_light,
)
// 图片资源--深色
private val DarkResScheme = ResScheme(
imageRes = R.drawable.ic_navi_home_drak,
)
// 定义一个存储 ResScheme 类型的CompositionLocal,默认值是浅色资源
val LocalResScheme = compositionLocalOf {
LightResScheme
}
4.3 CompositionLocalProvider 提供数据
CompositionLocalProvider是Compose中用于在Compoasable(可组合函数)树中传递数据的核心组件,允许你在某个层级定义“局部全局变量”,让其所有子组件(无论嵌套多深)都可以便捷访问,解决了:
- 传统父组件 -> 子组件 ->孙组件这种层层传递的方式。
- 有点类似于静态变量,但相对于静态变量的全局性和唯一性,CompositionLocalProvider作用范围仅限于其内部的所有子组件,所以可以理解为“局部全局变量”
// 定义应用的主题函数
@Composable
fun TestTheme(
// 是否使用深色主题,默认根据系统设置决定
darkTheme: Boolean = isSystemInDarkTheme(),
// 是否使用动态颜色,Android 12+ 可用,默认为 false
dynamicColor: Boolean = false,
// 内容组件,使用 @Composable 函数类型
content: @Composable () -> Unit
) {
// 。。。。。 省略前面的
// 定义扩展主题
val extendScheme = if (darkTheme) {
DarkExtendScheme
} else {
LightExtendScheme
}
// 定义图片资源
val resScheme = if (darkTheme) {
DarkResScheme
} else {
LightResScheme
}
// 应用 Material Design 3 主题
MaterialTheme(
// 设置颜色方案
colorScheme = colorScheme,
// 设置排版样式
typography = Typography,
// 设置内容组件
content = {
// 提供LocalExtendScheme 和 LocalResScheme 数据,内部所有组件都可以访问
CompositionLocalProvider(
LocalExtendScheme provides extendScheme,
LocalResScheme provides resScheme
) {
content()
}
}
)
}
4.4 使用
在Theme中根据需求配置完成后,无需再关心后续的系统模式/系统色变化了。
@Composable
fun Greeting1(modifier: Modifier = Modifier) {
Image(
modifier = Modifier
.padding(start = 300.dp, top = 100.dp)
.size(200.dp, 200.dp)
.background(Color.Gray),
// 使用图片资源
painter = painterResource(LocalResScheme.current.imageRes),
contentDescription = null,
)
Text(
text = "Hello Android!",
modifier = Modifier
.padding(start = 200.dp, top = 500.dp)
.size(300.dp, 200.dp)
//使用扩展颜色
.background(LocalExtendScheme.current.btnBackgroundColor),
color = LocalExtendScheme.current.btnTitleColor
)
}
5 参考资料
- 基础组件、布局组件使用
来源:juejin.cn/post/7559469775732981779
学习webhook与coze实现ai code review
AI代码审查工具
github github.com/zhangjiadi2…
测试可使用内网穿透工具将本地服务暴露到公网, 然后配置对应webhook. 日志目前只保留发送请求的message以及ai审查报告 .
ai建议使用coze, 直接使用gpt相关接口, 暂时每次都得携带大量文本 .
项目概述
这是一个基于Node.js开发的智能代码审查工具(demo)
核心特性
🚀 多AI服务支持
- 硅基流动AI: 基于深度学习的代码分析引擎
- Coze智能体: 专业的代码审查AI助手
- 动态切换: 支持运行时切换不同的AI服务
🔗 无缝集成
- GitHub Webhook: 自动监听代码推送事件
- 实时处理: 提交后立即触发审查流程
- 零配置部署: 简单的环境变量配置即可运行
📊 智能分析
- 代码质量评估: 全面分析代码结构、性能和安全性
- 最佳实践建议: 基于行业标准提供改进建议
- 多语言支持: 支持JavaScript、Python、Java等主流编程语言
💾 结果持久化
- 本地存储: 审查结果自动保存为结构化文本文件
- 历史追踪: 完整的审查历史记录
- 便于查阅: 清晰的文件命名和内容格式
技术架构
系统架构图
GitHub Repository
↓ (Webhook)
Express Server
↓
Webhook Handler
↓
GitHub Service ←→ AI Service Factory
↓ ↓
Diff Analysis [SiliconFlow | Coze]
↓ ↓
File Storage ←── Review Results
核心组件
1. Web服务层 (src/index.js)
- 基于Express.js的HTTP服务器
- 提供健康检查、日志查看等管理接口
- 优雅的错误处理和请求日志
2. Webhook处理器 (src/routes/webhook.js)
- GitHub事件监听和处理
- 提交数据解析和验证
- 异步任务调度
3. GitHub服务 (src/services/github.js)
- GitHub API集成
- 代码差异获取
- 智能文件过滤(仅处理代码文件)
4. AI服务工厂 (src/services/ai/)
- 基础抽象类 (
base.js): 定义AI服务通用接口 - 硅基流动服务 (
siliconflow.js): 集成硅基流动AI API - Coze服务 (
coze.js): 集成Coze智能体平台 - 服务工厂 (
index.js): 动态服务选择和管理
工作流程
1. 代码提交触发
sequenceDiagram
Developer->>GitHub: git push
GitHub->>AI Review Tool: Webhook Event
AI Review Tool->>GitHub API: Get Commit Diff
GitHub API-->>AI Review Tool: Return Diff Data
2. AI分析处理
sequenceDiagram
AI Review Tool->>AI Service: Send Code Diff
AI Service->>AI Provider: API Request
AI Provider-->>AI Service: Analysis Result
AI Service-->>AI Review Tool: Formatted Review
3. 结果存储
sequenceDiagram
AI Review Tool->>File System: Save Review
AI Review Tool->>Logs: Record Process
AI Review Tool-->>GitHub: Response OK
安装与配置
环境要求
- Node.js 14.0+
- npm 6.0+
快速开始
- 克隆项目
git clone
cd ai-code-review
- 安装依赖
npm install
- 环境配置
cp .env.example .env
# 编辑.env文件,配置必要的API密钥
- 启动服务
# 开发模式
npm run dev
# 生产模式
npm start
配置说明
基础配置
# 服务端口
PORT=3000
# 环境类型
NODE_ENV=development
GitHub集成
# GitHub访问令牌(可选,用于私有仓库)
GITHUB_TOKEN_AI=your_github_token
AI服务配置
# 当前使用的AI服务类型
AI_SERVICE_TYPE=coze
# 硅基流动AI配置
SILICONFLOW_API_KEY=your_siliconflow_key
SILICONFLOW_MODEL=deepseek-chat
# Coze智能体配置
COZE_API_URL=https://api.coze.cn/v3/chat
COZE_API_KEY=your_coze_key
COZE_BOT_ID=your_bot_id
使用指南
GitHub Webhook配置
- 进入GitHub仓库设置页面
- 选择"Webhooks" → "Add webhook"
- 配置参数:
- Payload URL:
http://your-domain.com/webhook/github - Content type:
application/json - Events: 选择"Just the push event"
- Payload URL:
- 保存配置
审查结果查看
审查结果自动保存在reviews/目录下,文件命名格式:
review_[service]_[commit_id]_[timestamp].txt
示例文件内容:
代码审查报告 (coze)
==========================================
提交ID: abc123def456
提交信息: 修复用户登录bug
作者: 张三
审查时间: 2024-01-01T10:00:00.000Z
详细建议:
------------------------------------------
1. 安全性建议:
- 建议在密码验证前添加输入验证
- 考虑使用bcrypt进行密码哈希
2. 性能优化:
- 数据库查询可以添加索引优化
- 建议使用连接池管理数据库连接
3. 代码规范:
- 变量命名建议使用驼峰命名法
- 建议添加必要的错误处理
项目结构
ai-code-review/
├── src/ # 源代码目录
│ ├── index.js # 应用入口文件
│ ├── routes/ # 路由处理
│ │ ├── webhook.js # Webhook事件处理
│ │ ├── debug.js # 调试接口
│ │ └── logs.js # 日志查看接口
│ ├── services/ # 核心服务
│ │ ├── ai/ # AI服务模块
│ │ │ ├── base.js # AI服务基类
│ │ │ ├── index.js # 服务工厂
│ │ │ ├── siliconflow.js # 硅基流动AI服务
│ │ │ └── coze.js # Coze智能体服务
│ │ ├── github.js # GitHub API服务
│ │ └── logger.js # 日志服务
│ ├── middleware/ # 中间件(预留)
│ ├── utils/ # 工具函数(预留)
│ └── public/ # 静态资源
├── reviews/ # 审查结果存储
├── messages/ # AI请求消息存储
├── logs/ # 系统日志
├── test/ # 测试文件
├── package.json # 项目配置
├── .env # 环境变量
└── README.md # 项目说明
开发特性
代码质量保障
- ESLint: 代码风格检查
- 错误处理: 完善的异常捕获机制
- 日志系统: 详细的操作日志记录
扩展性设计
- 插件化架构: 易于添加新的AI服务
- 配置驱动: 通过环境变量灵活配置
- 模块化设计: 清晰的代码组织结构
性能优化
- 异步处理: 非阻塞的事件处理
- 智能过滤: 仅处理代码文件,忽略配置和资源文件
- 错误恢复: 优雅的错误处理,避免服务中断
最佳实践
安全建议
- 使用HTTPS部署生产环境
- 定期轮换API密钥
- 限制GitHub Token权限范围
- 配置防火墙规则
性能优化
- 定期清理历史文件
- 监控API调用频率
- 配置适当的超时时间
- 使用负载均衡(高并发场景)
来源:juejin.cn/post/7530106539467669544









