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

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


  • 首页

  • 归档

  • 搜索

2023年总结:聊一聊我这10年做过的副业

发表于 2024-01-22

必看大字最新消息重磅公众号首图.jpg

以下是我的2023年终总结,我从公众号同步到掘金,大家可以看看我这10年的副业经验,希望对大家有所帮助。

今天是2023年最后一天,今年是不平凡的一年,也是变动最大的一年,大家也知道,嘟嘟最近离职了,到现在也有72天了,从此开始走自由职业的道路,而能支撑我下决定做自由职业,巧好是这几年不断的副业尝试,每个月赚到的钱可以覆盖我的生活支出,这给我了莫大的信心,我一直相信,我们应该像运营公司一下运营自己,我们平时工作只是用8小时换取对应的报酬而已,那我把这8小时投入到其他领域,理论上也可以创造不一样的价值。

我身边就有2个同学都是做自由职业的,也是程序员,已经2-3年了,他们也是通过自己开拓了稳定的收益模式,我很佩服,因为看到,所以相信。程序员是个高危职业,35+后越来越难,要么你能混到管理层,或者在大厂赚到了足够多的钱,剩下的人还是做编程的话,未来的难度会比较大的,这两年互联网行业红利期已经过了, 有点像土木专业,各大平台现在求稳的比较多,很多业务线都砍掉了,裁员的消息从来不间断过。所以我个人认为每个人需要有这种忧患意识,应该考虑自己的第二条发展曲线。

向我咨询的朋友也挺多,很多人也有这个意识,知道鸡蛋不能放一个篮子里,应该早点思考职场之外的另一条路,但是大部分人迷茫点在于不知道从何入手。

所以,嘟嘟这里准备掏心窝,给大伙说一下我从毕业到现在,这么多年期间,我都尝试了哪些副业,我是为什么想做副业,以及如何一点一点摸索的,希望对迷茫的朋友有点帮助。

我是2012年毕业的,java方向的程序员,不算很聪明,但是为人比较老实肯干,也有一股拼劲,运气好遇到一个好导师,带着我开始了编程之旅。我的第一份副业是接私活,跟着领导干的,这算是被动的参加了,那时候其实还没有主动做副业的心思,想着边做边打磨技术。

第二份副业也算是意外,那时候在玩手游,手机端天龙八部3D的游戏,我玩的内测,选的是和尚,玩的太厉害把内部人员都给干趴下了,获得第一次比武大会的冠军,对方觉得我能力不错,就加我qq说让我当个论坛斑竹,写一些攻略教程之类,每篇约稿200-300,用京东E卡来支付,我一听,完全没问题啊,然后就开开心心了开始了这份意外的副业之旅。一共写了也有几十篇,写作能力这时候就开始得到锻炼了,算是意外的收获吧。

image.png

上面这些都不算主动有副业意识的,但是后来家里巨变,把房子拿去抵押贷款的钱拿去开网吧,后来亏本了,每个月需要还款9000多,父母赚的钱不够还,所以需要我这里帮忙支撑,他们养育了这么久,我肯定需要帮忙承担这个责任的,所以有三年时间,我每个月都是把工资全部给他们,那时候工资很低的,到手也就5000初头,有时候还不够,就开始学会使用信用卡刷出来还,2张卡互相刷,每个月多刷1000-2000,然后年底等发年终奖或者私活项目发钱了再把信用卡还清欠款。

这时候我开始有想法了,因为这三年的痛苦经验,让我对金钱的渴望放大了很多倍,想着必须提高自己,想办法跳槽涨个高工资,于是就开始花更多的时间学习,并开始尝试写博客、自己搭建博客,我记得我最早在自己博客分享的还是hexo搭建个人博客的文章。后面看的知识多了,就开始写公众号,就这样开始了自媒体尝试这条路。当时也没搞清这样能怎样,只是为了想提高自己而做的一些尝试。

图片

后来我离职了,换了一家公司,之所以离职,原因也很简单,一方面准备好了,那时候技术已经很厉害了,另外一方面是欠亲戚的3.5万块钱,亲戚上门讨债了,家里为了还银行贷款,已经拿不出更多的钱,看着父母一脸难办的样子,我心里难受,想到自己公积金里面这4年也存了4万左右,所以就和亲戚说这笔钱我来还,让对方等我离职了就去把公积金里面的钱拿出来,但是亲戚不答应等太久,要我3天内还钱,没办法,人生第一次找同事借钱,领导借了2万,同事借了1.5万,先把这笔钱还了,然后后面离职后把公积金取出来还给同事。

到了新公司,工资翻倍了,一下子感觉生活压力小了一些,但是前面发生的事情一直让我耿耿于怀,是时候做出改变了,当时悟出的一个道理,你不肯改变,是因为你还不够痛。 只有经历过真正的痛苦,才会抓住那一丝丝的机会,感觉那时候就是这份痛苦才能让我坚持每天5点起床学习写博客,并持续很长一段时间。人天生喜欢舒适圈,想要离开舒适圈是反人性的,需要要坚韧的意志力才行。

这时候我开始主动做副业的种子已经埋下了,这期间,2016-2018,随着公众号的运营越来越久,第三份副业收入开始了,就是公众号的流量主和接广告收益,那时候算是公众号发展不错的阶段,一次商单也有1000,一年也能接个几次,当然我的公众号运营的不大好,不然还能有更多的收入。

第四份副业是开始尝试在我的博客上面添加京东联盟的商品链接,用户通过广告点击进去,就会带上我的京东的分佣链接,这样他们买的东西我会有一定的佣金,虽然赚的不多,但是让我知道了CPS这个东西。

接下来就是2018到2019年了,这2年算是我的一个小巅峰期,很多副业都是这个阶段内尝试出来的。因为那时候我终于还清了家里的债务,零零碎碎加起来一共帮家里还掉了20多万的债务。我也搬出来住了,离公司更近点,这样也有了更多的个人时间,方便思考和做项目。

第五份副业也在这期间完成的,就是分享电子书,我取名叫《电子书互惠计划》,通过收集整理市面上的编程电子书,然后整理分类,通过自媒体渠道引流到私域,然后进行售卖,一份卖9.9-29.9不等,我记得一共大概引流了900多人到微信成交。

图片

图片

这种玩法到现在也不过时,各种引流到私域卖资料的一大堆,本质一样,只是平台换一下而已。

资料整理永远都不过时的,商业的本质是价值交换,只要你能提供价值,满足用户需求或者痛点,并且提供的内容超出了定价,就会有用户愿意买单。

第六份副业还是2018年,和上面微信卖电子书集合差不多,不过这个免费提供下载,但是用的是通城网盘的形式,我资源都放在上面,然后提供下载,用户点击后会弹出广告页面,每个点击1毛钱吧,下面是一些收益截图,这个赚的不多,没有投入太多的时间去推广。

图片

我的第七份副业是做资源网站,盗版的,我记得也是2018年2月份就完成了,这个本来不打算说的,做了几个月就停了,因为风险太大了,容易被搞侵权。慕课网一发邮件过来,我就要下掉对应的视频教程。搭建这个不难,用discuz搭建的,源码是php,改了几个大模块,页面效果做得很好。然后接入下支付系统就开搞了,资源很简单,去其他同行买个永久会员,然后自己百度网盘再开个会员就行了,另外就是服务器和域名成本。赚了几千就停掉了这个项目。

但是这个项目让我学会了搭建网站、接入支付等技巧。你会发现5-7这三个副业有关联性,都是我边做边思考的成果。

图片

第八份副业也是在这个期间弄得,忘记是18年还是19年了,就是做淘宝客,那时候微信机器人非常的火,我也是网上看到这个东西,发现好像可以尝试搞一搞,因为第四份提到过做京东联盟广告的,知道CPS这个玩意,所以一看到微信机器人淘宝客这个东西,我直接就懂了赚钱的本质了,然后就花了2天时间就搞定了最基础的搭建,直接上网搜资料,淘宝搜这方面的软件,通过排除法最终选择了悠哉这家,当时软件是800元永久使用,然后就是配置参数,自动回复的一些配置就搞定了,当时真是佩服我自己,算是执行力的巅峰了,完全陷入了一种心流的状态,非常迅速的解决遇到的各种难题。

这个项目给我带来的收益很多,至少有10万+吧,最巅峰的时候是月入8000+,微信好友加到4000多人,但是后来发生了巨变,微信大封号,还是永久的,那一天算是淘宝客的寒冬了,2020年4月11号我记得,我也比较惨没躲过去,因为我微信昵称带了返利机器人这几个字,直接被命中,账号GG了,最后别人教我用发名片的形式,拯救了大概200多个消费比较多的用户,弄了个新微信来做,现在佛系做这么久人数也有600多了,以前嘟嘟在公众号也推广过好几次,很多老粉应该关注过。数据都在,一些老用户可以关注下,没准还有没提现的金额呢。

图片

第九份副业是关于抖音的,应该是2018年尝试做的,小说CPS,还是我同学和我说的,基于微信服务号搭建一个小说系统,有用户充值的话,1:9分成,我们分9层,系统抽1层,还是很划算的,然后就是去抖音上面发短视频,我是做言情小说推荐的,当时为了做好这个,也学了一些剪辑以及AE功能,这项目零零碎碎给我赚了2万多,印象最深刻的就是一个视频爆款了,来私信的人非常多,我在公司一个下午都在引流到微信。那天成交了有6000多块钱。这就是互联网自媒体的魅力啊,不稳定性,天花板高。我同学比我做得更好,赚了10几万。我后来没做,因为我电脑崩溃了,重装系统,导致大半年的资料都没了,有兴趣可以看我以前写的。资料都没了,我就不做这块了,后来抖音严打这块,听我同学说这块收益越来越少。

图片

通过这个副业,我尝到了自媒体的甜头,知道了引流卖资料的玩法,后来因为自己傻逼,抖音发了国庆做头像引流公众号的视频,被永久封号了,抖音的尝试就停止了哈哈。

2019年我又跳槽了,跟着一个领导出来创业,放弃了刚刚晋升项目组长的岗位(上面一级领导走了,我就顶上来带4个新人)。由于新工作比较忙,我就暂停了很多副业的尝试,2019年的话就一个副业项目再尝试,还是淘宝客类型,不过是APP的推广,好省APP,这个算第十份副业, 当时应该在公众号也分享过很多篇文章,自用省钱,分享赚钱的逻辑。我看我博客2019年11月还发了这篇。140天,大概做了5个月吧。

图片

图片

当时应该是做到了团长,下级大概2550人,虽然2020年就不做这个了,但是现在每个月还能给我带来几十的收入,你能信吗哈哈哈。我找下今天的截图。

image.png

image.png

这些都是躺赚的,大概这个时候我就开始爱上了这种睡后收入,睡后收入也就成了我选型副业的一个很大参考方向。

另外还有个京东的app,叫啥忘了和这个类似的,也只赚了小几千就不展开说了。

到此,算是一个阶段吧

图片

2020年因为业务需要,我被迫放弃java业务,让我去研究前端3D技术,我当时也比较喜欢新挑战,所以就答应了,所以那时候开始,一直在研究3D Threejs这块技术,java就先放一边了,所以我公众号停止更新了,就是因为这个原因。那时候也刚好爆发疫情,对了,那时候又开始玩手游,叫什么剑侠情缘三好像,当一个门派的帮主。

就这样,消失了一年,21年的时候看到别人分享的一个叫外卖cps的项目,一看就发现这个好熟悉啊,又是我喜欢的cps项目,就冲进去了,加了一个群,里面大家都是想做这块的,然后我的噩梦就开始了。当时太自大了,加上群里面大家互相吹捧很上头,一个群一共投入了500多万做这个项目,被返利网那个狗公司赚麻了,我们去买了对方的粉,地铁粉1个6.5元,商业粉一个8元,我一共投入了快50万,被他们吹的6个月回本两眼冒光,忘了风险这块,结果就是血亏20多万,好在这个项目目前还在稳定回本中,每个月3000-4000收益,这算是我第一个失败的副业,第十一份副业。我有写过一个复盘,放到副篇,设置了付费,有兴趣的可以来看看我这几十万亏损的复盘吧,到2023年应该还亏10几万。也不知道后面收益会不会越来越少,能回一点是一点。

图片

这个事情对我打击太大了,真的是,越努力越亏钱,这算是我第一份大投资,估计也是我这辈子唯一的一次了,当时确实是飘了。所以那份惨痛的复盘值得一看。

第十二份副业,是做知乎好物推荐,2021年在生财上面看到的,然后就花钱报名了个训练营,在知乎上面推荐商品,然后赚佣金的,这个也是CPS,赚的是京东的佣金。这个项目我大概尝试了几个月吧,也有几千的收入,后来没做了,因为跑去弄别的东西了,如果当初坚持做,可能也能有几万收入。到现在进去也是每个月都有一点钱,我研究下这东西是不是还可以继续做后面。

图片

图片

第十三份副业,也不算是副业吧,因为亏钱了,就是区块链,当时群里有人再分享玩炼游,每天都能赚钱,也有人暴富,我就跟去玩了,玩了大半年,玩了飞船、Baby、Bsw各种游戏,结果还亏了小几万,大起大落太严重了,不适合我,从此我也认清了现实,暴富的机会从来不属于我,我还是继续老老实实的做副业吧。不过通过这次,我也学了一些区块链的技术以及链上抢购的玩法,当时写代码去抢东西,还被我抢到了,还是蛮厉害的。

第十四份副业, 是做淘宝虚拟物品,就是通过卖一些虚拟物品,再配合一些自动发货插件,直接就解放了我双手,每天需要回答客户的问题很少。这也算是我满意的一种睡后收入了,最好的时候月收入有3000+,平时500-1000吧。

第十五份副业,收入不到1000,是跟着别人玩这个七夕青蛙的,就是有收钱帮人去给对方的朋友送祝福,嘲讽对方单身,孤寡孤寡孤寡孤寡。我纯粹是因为好玩才去尝试的,结果效果还特别好,一个人被嘲笑了,也付钱让我嘲笑回去,乐死我了。

图片

真的是什么都可以是生意,还有月老红线啥的。

我刚才翻了下,2022年我没有做新项目,大概2021年10月底-2022年8月都在弄区块链这块的,弄得我精疲力竭。后来最后受不了,就认清现实放弃区块链这块的尝试。

唯一让我亏钱的项目就是上面的外卖CPS和区块链项目了。

然后就到了2023年了,那时候公司业务开始出现问题,出现了拖欠工资的情况,好在我有副业可以做,生活暂时不受影响。

到了3月份的时候,我参加了生财的最后一次航海,我选择了AI绘画,这次的航海意外的让我开启了第十六份副业, 参加完这次航海后,经过一个月的技术沉淀,我就开始尝试AI变现了,一开始是做AI绘画的陪跑群,不过这个就内测了10几个用户,收费99元,分享我写的成体系的 midjourney 的绘画教程,然后顺便卖gpt和 midjourney 的账号,后面还有帮企业收gpt4 api账号,最后是gpt4的会员账号体系,这个方向,短短3个月给我带来了4万多的收益,现在虽然就剩下gpt账号和会员充值业务,但每个月也都能给我带来稳定的收益。

第十七份副业是尝试做AI公众号和百家号,不过收益不行,就几百块,做的有点晚,风口过去了。

第十八份副业尝试是写小报童手册和分销知识星球,加起来也有2000+多吧。

第十九份副业尝试就是短视频了,视频号、抖音、小红书三个平台都有尝试,收益不多,光涨粉了,不过短视频自媒体这块我目前还在继续坚持做,我相信未来可以做成。

第二十份副业尝试微信红包封面项目了,这个很简单,跟着教程把店铺开起来,接下来就是引流,这个项目上限比较低,成本也低,算是适合新人尝试的一个副业。我这波这块卖红包也就赚了小几百块。

第二十一份副业就是闲鱼开店卖货,这个也是12月刚开始没多久,主要是学习了闲鱼上开店、商品上架、文案主图制作、补单操作。算是简单完成了下初步的了解,目前也只是出了一单。后面还要继续努力才行。

以上就是我这10年尝试过的各种副业项目,不数不知道,都超过20个了。

其实2018年之前还属于被动的做副业,18年之后才算主动真正的花心思去尝试做副业,每做成一个副业的0-1,那种成就感是很爽的,也容易激励自己去继续做下去。

我看看这21个项目里面,现在还在做的有如下这些:

微信机器人淘宝客

淘宝虚拟店

gpt账号会员体系

小报童手册

AI公众号体系

短视频尝试

微信红包封面项目

闲鱼开店

每个月几十上百收入的就不说了,老实说我很喜欢CPS体系的项目,因为足够简单,做到后面可以是睡后收入,有一个老哥的计划就是做10个睡后收入项目,这样自己就自由了。

每个月几十上百收入的就不说了,老实说我很喜欢CPS体系的项目,因为足够简单,做到后面可以是睡后收入,有一个老哥的计划就是做10个睡后收入项目,这样自己就自由了。

下面说一下我学到的认知:

1:别人说的认知,并不是你的认知,只有自己经历过,才会有深刻的印象和共鸣,所以我说的这些你就当作参考。

2:一切生意的本质是流量;一切交易的本质是信任。想做副业,想清楚人货场这个商业核心理论。

3:纸上谈兵终觉浅,唯有下场实战把手弄脏,你才能收获第一手经验,出来混,最重要的是出来,所以我才很推崇生财的大航海,这是非常适合新人练手的机会,一套完整的航海手册,12天的打卡次数要求,逼着你去学去实操,还有几百个人陪着你一起,遇到问题相互谈论。这样会比自己一个人去玩容易的多。

4:刚开始做项目,没赚到钱不重要,心态放平,学到一些东西才是最重要的,你学会的每一个技能卡片,都会为你下一次的项目尝试提供帮助。

5:早点搭建自己的赚钱兵器库,把你学到的任何能提高你赚钱能力的都塞进去,引流能力,变现能力,商业认知等。

图片

6:流水不争先,争的是滔滔不绝。一开始做不出成绩没关系,相信自己,坚持下去。相信坚持的力量,相信1万小时定律。

今晚听别人直播听到凌晨1点半,文章写到现在快凌晨3点了,真是无语哈哈,准备收尾了,说一下2024年的计划,也算立一个Flag,希望自己努力加油,好运眷顾。

1:认真用飞书制定项目计划,按计划推动每一个项目。

2:持续在AI领域深耕,做一个自媒体,找到变现方式。

3:提高个人认知,学习并掌握100个底层思维模型。

4:开始定制跑步计划,每周不少于三次。

5:付费链接更高一级的圈子,找风口,吃红利,抱大腿。

6:做一个利他的人,愿世界对我温柔以待。

好了,顶不住了,准备睡觉。

在这里嘟嘟向大家献上最衷心的祝福:

愿大家善待这一年努力拼搏善良的自己,希望新的一年,所有的奔赴都有意义!

我是嘟嘟,十年程序员,35岁被迫离职开启自由职业之路。

我最近在日更公众号,已经日更90天+了,每天把我所思所想所做都会写成日记,希望对你们有参考借鉴作用。
这是我一个35岁离职程序员的自我探索之路。

本文转载自: 掘金

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

三个背烂了的八股文,变成两个场景题之后,打得我一脸懵逼。

发表于 2024-01-22

你好呀,我是歪歪。

这篇文章来盘一下我最近遇到的两个有意思的代码案例,有意思的点在于,拿到代码后,你一眼望去,没有任何毛病。然后一顿分析,会发现破绽藏的还比较的深。

几个基础招式的一套组合拳下来,直接把我打懵逼了。

你也来看看,是不是你跺你也麻。

第一个场景

首先第一个是这样的:

一个读者给我发来的一个关于线程池使用的疑问,同时附上了一个可以复现问题的 Demo。

我打开 Demo 一看,一共就这几行代码,结合问题描述来看想着应该不是啥复杂的问题:

我拿过来 Demo,根本就没看代码,直接扔到 IDEA 里面跑了两次,想着是先看看具体报错是什么,然后再去分析代码。

但是两次程序都正常结束了。

好吧,既然没有异常,我也大概的瞅了一眼 Demo,重点关注在了 CountDownLatch 的用法上。

我是横看竖看也没看出问题,因为我一直都是这样用的,这就是正确的用法啊。

于是从拿到 Demo 到定位问题,不到两分钟,我直接得出了一个大胆的结论,那就是:常规用法,没有问题:

然后我们就结束了这次对话。

过了一会,我准备关闭 IDEA 了。鬼使神差的,我又点了一次运行。

你猜怎么着?

居然真的报错了,抛出了 rejectedExecution 异常,意思是线程池满了。

哦哟,这就有点意思了。

带大家一起盘一盘。

首先我们还是过一下代码,为了减少干扰项,便于理解,我把他给我的 Demo 稍微简化了一点,但是整体逻辑没有发生任何变化。

简化后的完整代码是这样的,你直接粘过去,引入一个 guava 的包就能跑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
ini复制代码import com.google.common.collect.Lists;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.*;

public class Test {

    private static final ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(64, 64, 0, TimeUnit.MINUTES, new ArrayBlockingQueue<>(32));

    public static void main(String[] args) {
        List<Integer> list = new ArrayList<>();
        for (int i = 0; i < 400; i++) {
            list.add(i);
        }
        for (int i = 0; i < 100; i++) {
            List<List<Integer>> sublist = Lists.partition(list, 400 / 32);
            int n = sublist.size();
            CountDownLatch countDownLatch = new CountDownLatch(n);
            for (int j = 0; j < n; j++) {
                threadPoolExecutor.execute(() -> {
                    try {
                        Thread.sleep(1000);
                    } catch (Exception e) {
                        e.printStackTrace();
                    } finally {
                        countDownLatch.countDown();
                    }
                });
            }
            try {
                countDownLatch.await();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("===============>  详情任务 - 任务处理完成");
        }
        System.out.println("都执行完成了");
    }
}
/**
 * <dependency>
 *     <groupId>com.google.guava</groupId>
 *     <artifactId>guava</artifactId>
 *     <version>31.1-jre</version>
 * </dependency>
 */

一起分析一波代码啊。

首先定义了一个线程池:

private static final ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(64, 64, 0, TimeUnit.MINUTES, new ArrayBlockingQueue<>(32));

该线程池核心大小数和最大线程数都是 64,队列长度为 32,也就是说这个线程池同时能容纳的任务数是 64+32=96。

main 方法里面是这样的:

在实际代码中,肯定是有具体的业务含义的,这里为了脱敏,就用 List 来表示一下,这个点你知道就行。

编号为 ① 的地方,是在给往 list 里面放 400 个数据,你可以认为是 400 个任务。

编号为 ② 的地方,这个 List 是 guava 的 List,含义是把 400 个任务拆分开,每一组有 400/32=12.5 个任务,向下取整,就是 12 个。

具体是什么个意思呢,我给你看一下 debug 的截图你就知道了:

400 个任务分组,每一组 12 个任务,那就可以拆出来 34 组,最后一组只有 4 个任务:

但是这都不重要,一点都不重要好吧。

因为后续他根本就没有用这个 list ,只是用到了 size 的大小,即 34 。

所以你甚至还能拿到一个更加简洁的代码:

为什么我最开始的时候不直接给你这个最简化的代码,甚至还让你多引入一个包呢?

因为歪师傅就是想体现这个简化代码的过程。

按照我写文章的经验,在定位问题的时候,一定要尽量多的减少干扰项。排除干扰项的过程,也是梳理问题的过程,很多问题在排除干扰项的时候,就逐渐的能摸清楚大概是怎么回事儿。

如果你遇到一个让你摸不着头脑的问题,那就先从排除干扰项做起。

好了,说回我们的代码。现在我们的代码就只有这几行了,核心逻辑就是我圈起来的这个方法:

而圈起来这个部分,主要是线程池结合 CountDownLatch 的使用。

对于 CountDownLatch 我一般只关注两个地方。

第一个是 new 的时候传入的“令牌数”和调用 countDown 方法的次数能不能匹配上。只有保持一致,程序才能正常运行。

第二个地方就是 countDown 方法的调用是不是在 finally 方法里面。

这两个点,在 Demo 中都是正确的。

所以现在从程序分析不出来问题,我们怎么办?

那就从异常信息往回推算。

我们的异常信息是什么?

触发了线程池拒绝策略:

什么时候会出现线程池拒绝策略呢?

核心线程数用完了,队列满了,最大线程数也用完了的时候。

但是按理来说,由于有 countDownLatch.await() 的存在,在执行完 for 循环中的 34 次 countDownLatch.countDown() 方法之前,主线程一定是阻塞等待的。

而 countDownLatch.countDown() 方法在 finally 方法中调用,如果主线程继续运行,执行外层的 for 循环,放新的任务进来,那说明线程池里面的任务也一定执行完成了。

线程池里面的任务执行完成了,那么核心线程就一定会释放出来等着接受下一波循环的任务。

这样捋下来,感觉还是没毛病啊?

除非线程池里面的任务执行完成了,核心线程就一定会释放出来等着接受下一波循环的任务,但是不会立马释放出来。

什么意思呢?

就是当一个核心线程执行完成任务之后,到它进入下一次可以开始处理任务的状态之间,有时间差。

而由于这个时间差的存在,导致第一波的核心线程虽然全部执行完成了 countDownLatch.countDown(),让主线程继续运行下去。 但是,在线程池中还有少量线程未再次进入“可以处理任务”的状态,还在进行一些收尾的工作。

从而导致,第二波任务进来的时候,需要开启新的核心线程数来执行。

放进来的任务速度,快于核心线程的“收尾工作”的时间,最终导致线程池满了,触发拒绝策略。

需要说明的是,这个原因都是基于我个人的猜想和推测。这个结论不一定真的正确,但是伟人曾经说过:大胆假设,小心求证。

所以,为了证明这个猜想,我需要找到实锤证据。

从哪里找实锤呢?

源码之下,无秘密。

当我有了这个猜想之后,我立马就想到了线程池的这个方法:

java.util.concurrent.ThreadPoolExecutor#runWorker

标号为 ① 的地方是执行线程 run 方法,也就是这一行代码执行完成之后,一个任务就算是执行完成了。对应到我们的 Demo 也就是这部分执行完成了:

这部分执行完成了,countDownLatch.countDown() 方法也执行完成了。

但是这个核心线程还没跑完呢,它还要继续往下走,执行标号为 ② 和 ③ 处的收尾工作。

在核心线程执行“收尾工作”时,主线程又咔咔就跑起来了,下一波任务就扔进来了。

这不就是时间差吗?

另外,我再问一个问题:线程池里面的一个线程是什么时候处于“来吧,哥们,我可以处理任务了”的状态的?

是不是要执行到红框框着的这个地方 WAITING 着:

java.util.concurrent.ThreadPoolExecutor#getTask

那在执行到这个红框框之前,还有一大坨代码呢,它们不是收尾工作,属于“就绪准备工作”。

现在我们再捋一捋啊。

线程池里面的一个线程在执行完成任务之后,到下一次可以执行任务的状态之间,有一个“收尾工作”和“就绪准备工作”,这两个工作都是非常快就可以执行完成的。

但是这“两个工作”和“主线程继续往线程池里面扔任务的动作”之间,没有先后逻辑控制。

从程序上讲,这是两个独立的线程逻辑,谁先谁后,都有可能。

如果“两个工作”先完成,那么后面扔进来的任务一定是可以复用线程的,不会触发新开线程的逻辑,也就不会触发拒绝策略。

如果“主线程继续往线程池里面扔任务的动作”先完成,那么就会先开启新线程,从而有可能触发拒绝策略。

所以最终的执行结果可能是不报错,也可能是抛出异常。

同时也回答了这个问题:为什么提高线程池的队列长度,就不抛出异常了?

因为队列长度越长,核心线程数不够的时候,任务大不了在队列里面堆着。而且只会堆一小会儿,但是这一小会,给了核心线程足够的时间去完成“两个工作”,然后就能开始消耗队列里面的任务。

另外,提出问题的小伙伴说换成 tomcat 的线程池就不会被拒绝了:

也是同理,因为 tomcat 的线程池重写了拒绝策略,一个任务被拒绝之后会进行重试,尝试把任务仍回到队列中去,重试是有可能会成功的。

对应的源码是这个部分:

org.apache.tomcat.util.threads.ThreadPoolExecutor#execute(java.lang.Runnable, long, java.util.concurrent.TimeUnit)

这就是我从源码中找到的实锤。

但是我觉得锤的还不够死,我得想办法让这个问题必现一下。

怎么弄呢?

如果要让问题必现,那么就是延长“核心线程完成两个工作”的时间,让主线程扔任务的动作”的动作先于它完成。

很简单,看这里,afterExecute 方法:

线程池给你留了一个统计数据的口子,我们就可以基于这个口子搞事情嘛,比如睡一下下:

1
2
3
4
5
6
7
8
9
10
11
12
java复制代码private static final ThreadPoolExecutor threadPoolExecutor =
        new ThreadPoolExecutor(64, 64, 0, TimeUnit.MINUTES,
                new ArrayBlockingQueue<>(32)) {
            @Override
            protected void afterExecute(Runnable r, Throwable t) {
                try {
                    Thread.sleep(5000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        };

由于收尾任务的时间过长,这样“主线程扔任务的动作”有极大概率的是先执行的,导致触发拒绝策略:

到这里,这个问题其实就算是分析完成了。

但是我还想分享一个我在验证过程中的一个验证思路,虽然这个思路最终并没有得到我想要的结论,但是技多不压身,你抽空学学,以后万一用得上呢。

前面说了,在我重写了 afterExecute 方法之后,一定会触发拒绝策略。

那么我在触发拒绝策略的时候,dump 一把线程,通过 dump 文件观察线程状态,是不是就可以看到线程池里面的线程,可能还在 RUNNING 状态,但是是在执行“两个工作”呢?

于是就有了这样的代码:

我自定义了一个拒绝策略,在触发拒绝策略的时候,dump 一把线程池:

但是很不幸,最终 dump 出来的结果并不是我期望的,线程池里面的线程,不是在 TIMED_WAITING 状态就是在 WAITING 状态,没有一个是 RUNNING 的。

为什么?

很简单,因为在触发拒绝策略之后,dump 完成之前,这之间代码执行的时间,完全够线程池里面的线程完成“两个工作”。

虽然你 dump 了,但是还是晚了一点。

这一点,可以通过在 dump 前面输出一点日志进行观察验证:

虽然我没有通过 dump 文件验证到我的观点,但是你可以学习一下这个手段。

在正常的业务逻辑中触发拒绝策略的时候,可以 dump 一把,方便你分析。

那么问题就来了?

怎么去 dump 呢?

关键代码就这一行:

JVMUtil.jstack(jStackStream);

这个方法其实是 Dubbo 里面的一个工具,我只是引用了一下 Dubbo 的包:

但是你完全可以把这个工具类粘出去,粘到你的项目中去。

你的代码很好,现在它是我的了。

最后,我还是必须要再补充一句:

以上从问题的定位到问题的复现,都是基于我个人的分析,从猜测出发,最终进行验证的。有可能我猜错了,那么整个论证过程可能都是错的。你可以把 Demo 粘过去跑一跑,带着怀疑一切的眼光去审视它,如果你有不同的看法,可以告诉我,我学习一下。

最后,你想想整个过程。

拆开了看,无非是线程池和 CountDownLatch 的八股文的考察,这两个玩意都是面试热点考察部分,大家应该都背的滚瓜烂熟。

在实际工作中,这两个东西碰撞在一起也是经常有的写法,但是没想到的是,在套上一层简单的 for 循环之后,完全就变成了一个复杂的问题了。

这玩意着实是把我打懵逼了。以后把 CountDownLatch 放在 for 循环里面的场景,都需要多多注意一下了。

第二个场景

这个场景就简单很多了。

当时有个小伙伴在群里扔了一个截图:

需要注意的是, if(!lock) 他截图的时候是给错了,真实的写法是 if(lock),lock 为 true 的时候就是加锁成功,进入 if。

同时这个代码这一行是有事务的:

写一个对应的伪代码是这样的:

1
2
3
4
5
6
7
8
9
10
11
scss复制代码if(加锁成功){
    try{
        //save有事务注解,并且确认调用的service对象是被代理的对象,即事务的写法一定是正确的
        return service.save();
    } catch(Exception e){
        //异常打印   
    } finally {
        //释放锁
        unlock(lockKey);
    }
}

就上面这个写法,先加锁,再开启事务,执行事务方法,接着提交事务,最后解锁,反正歪师傅横看竖看是没有发现有任何毛病的。

但是提供截图的小伙伴是这样描述的。

当他是这样写的时候,从结果来看,程序是先加锁,再开启事务,执行事务方法,然后解锁,最后才提交事务:

当时我就觉得:这现象完全超出了我的认知,绝不可能。

紧接着他提供了第二张截图:

他说这样拆开写的时候,事务就能正常生效了:

这两个写法的唯一区别就是一个是直接 return,一个是先返回了一个 resultModel 然后在 return。

在实际效果上,我认为是没有任何差异的。

但是他说这样写会导致锁释放的时机不一样。

我还是觉得:

然而突然有人冒出来说了一句: try 带着 finally 的时候,在执行 return 语句之前会先执行 finally 里面的逻辑。会不会是这个原因导致的呢?

按照这个逻辑推,先执行了 finally 里面的释放锁逻辑,再执行了 return 语句对应的表达式,也就是事务的方法。那么确实是会导致锁释放在事务执行之前。

就是这句话直接给我干懵逼了,CPU 都快烧了,感觉哪里不对,又说不上来为什么。

虽然很反直觉,但是我也记得八股文就是这样写的啊,于是我开始觉得有点意思了。

所以我搞了一个 Demo,准备本地复现一下。

当时想着,如果能复现,这可是一个违背直觉的巨坑啊,是一个很好的写作素材。

可惜,没有复现:

最后这个哥们也重新去定位了原因,发现是其他的 BUG 导致的。

另外,关于前面“try 带着 finally”的说法其实说的并不严谨,应该是当 try 中带有 return 时,会先执行 return 前的代码,然后把需要 return 的信息暂存起来,接着再执行 finally 中的代码,最后再通过 return 返回之前保存的信息。

这才是写在八股文里面的正确答案。

要永远牢记另一位伟人说过:实践是检验真理的唯一标准。

遇事不决,就搞个 Demo 跑跑。

关于这个场景,其实也很简单,拆开来看就是关于事务和锁碰撞在一起时的注意事项以及 try-return-finally 的执行顺序这两个基础八股而已。

但是当着两个糅在一起的时候,确实有那么几个瞬间让我眼前一黑,又打得我一脸懵逼。

最后,事务和锁碰撞在一起的情况,上个伪代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
csharp复制代码@Service
public class ServiceOne{
    // 设置一把可重入的公平锁
    private Lock lock = new ReentrantLock(true);
    
    @Transactional(rollbackFor = Exception.class)
    public Result  func(long seckillId, long userId) {
        lock.lock();
        // 执行数据库操作——查询商品库存数量
        // 如果 库存数量 满足要求 执行数据库操作——减少库存数量——模拟卖出货物操作
        lock.unlock();
    }
}

如果你五秒钟没看出这个代码的问题,秒杀这个问题的话,那歪师傅推荐你个假粉丝看看这篇文章::《几行烂代码,我赔了16万。》

好了,就酱,打完收工~

本文转载自: 掘金

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

小程序主包体积的优化方案与技术实现

发表于 2024-01-22

F70AE44A-D2CE-4B99-B2A8-372DFF5F1567.png

引言

在使用Taro开发偏大型小程序应用过程中,我们可能经常会遇到这么个问题:小程序的主包体积超过了2M,没办法发布。针对这个问题,本文讲一讲我在业务中经常使用到的4种优化手段。

优化方式

页面分包

微信主包体积限制2MB主包空间寸土寸金,仅放置默认启动页面/TabBar 页面,其他页面均迁移至分包。这也是主包体积最基本的优化方式。

公共模块分包

image.png
改造后分包加载的页面体积不计入主包体积内,但是在默认配置下被多个页面所引用的模块会被打包进主包。这里截取了未做优化页面分包后直接打包后的代码依赖分析图。其中:

  • common.js包含了业务中的公共组件、工具方法、hooks等逻辑
  • common.wxss包含了业务中公共组件的样式、全局样式
  • vendors.js包含了三方依赖逻辑

解决方案

那么我们能不能识别哪些页面使用了这些公共模块,如果某个公共模块虽然被多个分包使用,但是使用它的分包均不在主包中那么我们这个模块是不是应该被打包进对应的分包内减少主包体积占用。

技术实现

文档链接:docs.taro.zone/docs/config…

Taro配置mini.optimizeMainPackage就能实现这一功能Taro官方对这一配置的描述是:可以避免主包没有引入的module不被提取到commonChunks中,该功能会在打包时分析module和chunk的依赖关系,筛选出主包没有引用到的module把它提取到分包内。
开启mini.optimizeMainPackage后的代码依赖分析图如下:
image.png

源码解析

那么Taro是如何实现这一功能的呢?我们来看源码:

  1. 收集分包入口数据用于后续判断chunk是否属于分包
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
typescript复制代码const PLUGIN_NAME = 'MiniSplitChunkPlugin'

export default class MiniSplitChunksPlugin extends SplitChunksPlugin {

// 分包配置
subPackages: SubPackage[]

// 分包根路径
subRoots: string[]

// 分包根路径正则
subRootRegExps: RegExp[]

// ... 省略部分代码 ...

apply (compiler: any) {
this.context = compiler.context

// 获取分包配置
this.subPackages = this.getSubpackageConfig(compiler).map((subPackage: SubPackage) => ({
...subPackage,
root: this.formatSubRoot(subPackage.root) // 格式化根路径,去掉尾部的/
}))

// 获取分包根路径
this.subRoots = this.subPackages.map((subPackage: SubPackage) => subPackage.root)

// 生成分包根路径正则
this.subRootRegExps = this.subRoots.map((subRoot: string) => new RegExp(`^${subRoot}\\/`))

// ... 省略部分代码 ...
}

// ... 省略部分代码 ...
}
  1. 找到分包入口chunk。循环构成chunk的module。其中没有被主包引用,且被多个分包引用的记录在subCommonDeps中。并基于subCommonDeps生成新的cacheGroups配置用于SplitChunksPlugin作为配置拆分chunks。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
javascript复制代码const PLUGIN_NAME = 'MiniSplitChunkPlugin'

export default class MiniSplitChunksPlugin extends SplitChunksPlugin {

// 所有分包公共依赖
subCommonDeps: Map<string, DepInfo>

// 各个分包的公共依赖Map
chunkSubCommons: Map<string, Set<string>>

// 分包三方依赖
subPackagesVendors: Map<string, webpack.compilation.Chunk>


// ... 省略部分代码 ...

apply (compiler: any) {

// ... 省略部分代码 ...

compiler.hooks.compilation.tap(PLUGIN_NAME, (compilation: any) => {
compilation.hooks.optimizeChunks.tap(PLUGIN_NAME, (chunks: webpack.compilation.Chunk[]) => {
const splitChunksOriginConfig = {
...compiler?.options?.optimization?.splitChunks
}

this.subCommonDeps = new Map()
this.chunkSubCommons = new Map()
this.subPackagesVendors = new Map()

/**
* 找出分包入口chunks
*/
const subChunks = chunks.filter(chunk => this.isSubChunk(chunk))

// 不存在分包
if (subChunks.length === 0) {
this.options = SplitChunksPlugin.normalizeOptions(splitChunksOriginConfig)
return
}

subChunks.forEach((subChunk: webpack.compilation.Chunk) => {
subChunk.modulesIterable.forEach((module: any) => {
// ... 省略部分代码 ...
const chunks: webpack.compilation.Chunk[] = Array.from(module.chunksIterable)
const chunkNames: string[] = chunks.map(chunk => chunk.name)
/**
* 找出没有被主包引用,且被多个分包引用的module,并记录在subCommonDeps中
*/
if (!this.hasMainChunk(chunkNames) && this.isSubsDep(chunkNames)) {

// 此处生成 subCommonDeps、subCommonDepChunks 用于生成新的cacheGroups配置
// ... 省略部分代码 ...
}
})
})

/**
* 用新的option配置生成新的cacheGroups配置
*/
this.options = SplitChunksPlugin.normalizeOptions({
...splitChunksOriginConfig,
cacheGroups: {
...splitChunksOriginConfig?.cacheGroups,
...this.getSubPackageVendorsCacheGroup(),
...this.getSubCommonCacheGroup() // 该方法返回值基于 this.subCommonDeps 生成
}
})
})

})
}
// ... 省略部分代码 ...
}
  1. 在SplitChunksPlugin完成chunks拆分后收集分包下的sub-vendors和sub-common下的公共模块信息
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
typescript复制代码export default class MiniSplitChunksPlugin extends SplitChunksPlugin {

// ... 省略部分代码 ...

apply (compiler: any) {

// ... 省略部分代码 ...

compiler.hooks.compilation.tap(PLUGIN_NAME, (compilation: any) => {

// ... 省略部分代码 ...

compilation.hooks.afterOptimizeChunks.tap(PLUGIN_NAME, (chunks: webpack.compilation.Chunk[]) => {
const existSubCommonDeps = new Map()

chunks.forEach(chunk => {
const chunkName = chunk.name

if (this.matchSubVendors(chunk)) {
const subRoot = this.subRoots.find(subRoot => new RegExp(`^${subRoot}\\/`).test(chunkName)) as string

this.subPackagesVendors.set(subRoot, chunk)
}

if (this.matchSubCommon(chunk)) {
const depName = chunkName.replace(new RegExp(`^${this.subCommonDir}\\/(.*)`), '$1')

if (this.subCommonDeps.has(depName)) {
existSubCommonDeps.set(depName, this.subCommonDeps.get(depName))
}
}
})

this.setChunkSubCommons(existSubCommonDeps)
// 这里收集了SplitChunksPlugin 完成 chunks 拆分后分包内的 subCommonDep(ps: 这里的赋值有点奇怪,因为后续的流程并没有使用)
this.subCommonDeps = existSubCommonDeps
})
}
}

setChunkSubCommons (subCommonDeps: Map<string, DepInfo>) {
const chunkSubCommons: Map<string, Set<string>> = new Map()

subCommonDeps.forEach((depInfo: DepInfo, depName: string) => {
const chunks: string[] = [...depInfo.chunks]

chunks.forEach(chunk => {
if (chunkSubCommons.has(chunk)) {
const chunkSubCommon = chunkSubCommons.get(chunk) as Set<string>

chunkSubCommon.add(depName)
chunkSubCommons.set(chunk, chunkSubCommon)
} else {
chunkSubCommons.set(chunk, new Set([depName]))
}
})
})
this.chunkSubCommons = chunkSubCommons
}
// ... 省略部分代码 ...
}
  1. 基于收集的分包下的sub-vendors和sub-common下的公共模块信息。为分包require对应公共模块。SplitChunksPlugin导出路径为编译产物根目录即主包根目录,这里为了不占主包体积所以这里需要将sub-common迁移至对应分包,故此处require的文件路径都是基于分包根目录。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
typescript复制代码export default class MiniSplitChunksPlugin extends SplitChunksPlugin {

// ... 省略部分代码 ...


apply (compiler: any) {

// ... 省略部分代码 ...

compiler.hooks.compilation.tap(PLUGIN_NAME, (compilation: any) => {
compilation.chunkTemplate.hooks.renderWithEntry.tap(PLUGIN_NAME, (modules, chunk) => {
if (this.isSubChunk(chunk)) {
const chunkName = chunk.name
const chunkSubRoot = this.subRoots.find(subRoot => new RegExp(`^${subRoot}\\/`).test(chunkName)) as string
const chunkAbsulutePath = path.resolve(this.distPath, chunkName)
const source = new ConcatSource()
const hasSubVendors = this.subPackagesVendors.has(chunkSubRoot)
const subVendors = this.subPackagesVendors.get(chunkSubRoot) as webpack.compilation.Chunk
const subCommon = [...(this.chunkSubCommons.get(chunkName) || [])]

/**
* require该分包下的sub-vendors
*/
if (hasSubVendors) {
// ... 此处省略文件路径生成逻辑 ...
source.add(`require(${JSON.stringify(relativePath)});\n`)
}

// require sub-common下的模块
if (subCommon.length > 0) {
if (this.needAllInOne()) {
// ... 此处省略文件路径生成逻辑 ...
source.add(`require(${JSON.stringify(relativePath)});\n`)
} else {
subCommon.forEach(moduleName => {
// ... 此处省略文件路径生成逻辑 ...

source.add(`require(${JSON.stringify(relativePath)});\n`)
})
}
}

source.add(modules)
source.add(';')
return source
}
})
}
}

// ... 省略部分代码 ...
}
  1. require的文件路径基于分包根目录。所以对应的文件也需要做迁移。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
typescript复制代码export default class MiniSplitChunksPlugin extends SplitChunksPlugin {

// ... 省略部分代码 ...


apply (compiler: any) {

// ... 省略部分代码 ...

compiler.hooks.emit.tapAsync(PLUGIN_NAME, this.tryAsync((compilation) => {
const assets = compilation.assets
const subChunks = compilation.entries.filter(entry => this.isSubChunk(entry))
const needAllInOne = this.needAllInOne()

subChunks.forEach(subChunk => {
const subChunkName = subChunk.name
const subRoot = this.subRoots.find(subRoot => new RegExp(`^${subRoot}\\/`).test(subChunkName)) as string
const chunkWxssName = `${subChunkName}${FileExtsMap.STYLE}`
const subCommon = [...(this.chunkSubCommons.get(subChunkName) || [])]
const wxssAbsulutePath = path.resolve(this.distPath, chunkWxssName)
const subVendorsWxssPath = path.join(subRoot, `${this.subVendorsName}${FileExtsMap.STYLE}`)
const source = new ConcatSource()

if (subCommon.length > 0) {
let hasSubCssCommon = false
subCommon.forEach(moduleName => {

// ... 省略部分代码 ...

// 复制sub-common下的资源到分包下
for (const key in FileExtsMap) {
const ext = FileExtsMap[key]
const assetName = path.join(this.subCommonDir, `${moduleName}${ext}`)
const subAssetName = path.join(subRoot, assetName)
const assetSource = assets[normalizePath(assetName)]

if (assetSource) {
assets[normalizePath(subAssetName)] = {
size: () => assetSource.source().length,
source: () => assetSource.source()
}
}
}
})

// ... 省略部分代码 ...
}

if (assets[normalizePath(subVendorsWxssPath)]) {
const subVendorsAbsolutePath = path.resolve(this.distPath, subVendorsWxssPath)
const relativePath = this.getRealRelativePath(wxssAbsulutePath, subVendorsAbsolutePath)
source.add(`@import ${JSON.stringify(relativePath)};\n`)
}

if (assets[chunkWxssName]) {
const originSource = assets[chunkWxssName].source()
source.add(originSource)
}

assets[chunkWxssName] = {
size: () => source.source().length,
source: () => source.source()
}
})

// 删除根目录下的sub-common资源文件
for (const assetPath in assets) {
if (new RegExp(`^${this.subCommonDir}\\/.*`).test(assetPath)) {
delete assets[assetPath]
}
}

// ... 省略部分代码 ...
}))
}
}

以上就是 MiniSplitChunksPlugin实现公共模块分包的核心流程

引用方式

在完成公共模块分包后主包体积的确有所减少,但是在后续的迭代中发现公共组件并没有全部都按页面分包打包。以@/components举例,通过排查发现@/components是通过index.ts统一导出内部的子模块页面通过import { ComponentName } from '@/components'方式引入。
而这种导出方式会使webpack将这个@/components识别为一个单独模块,由于主包内存在页面引用@/component下的公共组件,所以@/components会被完整的打包进主包内。

解决方案

解决方法也很简单就是将@/components修改为@/components/component-path,跳过index.ts直接引用内部组件文件。
那么我们如何将现存项目中使用的组件引用路径都替换掉呢?

  • ❎ 人工逐个替换
    • 需要修改使用到公共组件的业务代码工作量大且易出错
    • 且后续全局组件使用也较繁琐需要直接引用文件路径@/components/component-path。
  • ✅ 使用babel插件批量替换
    • 仅需引入插件babel-plugin-import做对应配置,无需修改业务代码。
    • 开发无感,@/components的使用方式不变

技术实现

  1. 引入插件babel-plugin-import,并配置组件路径与组件文件路径之间的转换关系
1
2
3
4
5
6
7
8
9
10
11
12
javascript复制代码module.exports = {
plugins: [
[
'import',
{
libraryName: '@/components',
libraryDirectory: '',
transformToDefaultImport: false,
},
],
],
};
  1. 按照配置的文件名与文件路径之间的转换关系定义组件
1
2
3
4
5
javascript复制代码// component file -> @/components/component-a
export const ComponentA = ()=> <View/>

// business code
import {ComponentA} from '@/components'

这里需要注意如果组件路径与组件名不符合所配置的规范,编译时会找不到对应的组件。

图片资源优化

image.png
这里截取了「古茗点单小程序」在不采用其他优化手段直接将图片资源打包后的代码依赖分析图,可以看到其中图片资源的尺寸足足有22.07MB,这与微信限制的主包大小2MB整整相差了20MB。

解决方案

我们可以将这22.07MB的图片资源上传至云端。在小程序使用时直接使用网络路径。那么打包时这部分资源尺寸就不会计算在主包尺寸中。
那么我们如何将现存项目中使用的图片资源路径替换成网络路径?

  • ❎ 人工逐个替换
    • 需要修改使用到图片资源的业务代码工作量大且易出错
    • 且后续图片资源使用也很繁琐需要开发上传图片资源后使用网络地址编码。
  • ✅ 使用babel插件批量替换
    • 仅需要实现对应的babel插件逻辑并引入,无需修改业务代码
    • 开发无感,图片资源的使用方式不变

技术实现

  1. Taro 编译开始时使用taro插件上传本地图片资源
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
typescript复制代码import type {IPluginContext} from '@tarojs/service'
import {PromisePool} from '@supercharge/promise-pool'
import path from 'path';
import fs from 'fs';
import md5 from 'md5'

const cacheFileName = "imgCache.json"

/**
* 递归查找文件
*/
const travelFiles = (dir: string): string[] => {
const files = fs.readdirSync(dir);
return files.reduce<string[]>((result, file) => {
const filePath = path.join(dir, file);
if (!fs.statSync(filePath).isDirectory()) return [...result, filePath];
return [...result, ...travelFiles(filePath)];
}, [])
}

/**
* 文件路径格式化
*/
const filePathFormat = (ctx: IPluginContext, filePath: string) => {
return filePath.replace(ctx.paths.sourcePath, "@").replace(/\\/g, "/");
}

/**
* 生成文件 key
*/
const generateFileUniqueKey = (filePath: string) => {
const {dir, base, ext} = path.parse(filePath);
const buffer = fs.readFileSync(`${dir}${path.sep}${base}`);
return md5(buffer)
}

const cacheFile = path.join(__dirname, cacheFileName);


interface PluginOpts {
fileDir: string,
upload: (filePath: string, fileKey: string) => Promise<string>

}

module.exports = (ctx: IPluginContext, pluginOpts: PluginOpts) => {
ctx.onBuildStart(async () => {
const {fileDir, upload} = pluginOpts
const fileDirPath = `${ctx.paths.sourcePath}/${fileDir}`;
const filePathList = travelFiles(fileDirPath);

// 上传文件
const {results: fileUrlList} = await PromisePool.withConcurrency(2)
.for(filePathList)
.process(async (filePath) => {
const fileUrl = await upload(filePath, generateFileUniqueKey(filePath))
return {filePath, fileUrl}
})

// 生成文件缓存数据
const fileUrlMap = fileUrlList.reduce((result, item) => {
const tempKey = filePathFormat(ctx, item.filePath)
return {...result, [tempKey]: item.fileUrl}
}, {})

fs.writeFileSync(cacheFile, JSON.stringify(fileUrlMap));
})
}
  1. 使用babel插件替换ts或js中导入的图片
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
typescript复制代码import type {NodePath, PluginItem, PluginPass} from '@babel/core'
import type {ImportDeclaration, Statement} from "@babel/types";
import template from '@babel/template'
import path from "path";
import fs from "fs";

const cacheFileName = "imgCache.json"

const getCacheData = (filePath: string): Record<string, string> => {
try {
fs.accessSync(filePath);
return JSON.parse(fs.readFileSync(filePath).toString());
} catch (error) {
return {}
}

}


module.exports = (): PluginItem => {

const cacheMap = getCacheData(path.join(__dirname, cacheFileName));

return {
visitor: {
ImportDeclaration(importDeclarationAstPath: NodePath<ImportDeclaration>, state: PluginPass) {

if (state.file.opts.filename?.includes("node_modules")) return;

const {node} = importDeclarationAstPath;

const {value} = node.source;

const fileUrl = cacheMap[value]

if (!fileUrl) return;

const [specifier] = node.specifiers

const assignExpression = template.ast(`const ${specifier.local.name} = '${fileUrl}';`);

importDeclarationAstPath.replaceWith(assignExpression as Statement);
}
}
}
}
  1. 使用postcss插件替换样式文件中导入的图片
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
typescript复制代码import {AcceptedPlugin} from "postcss";
import path from "path";
import fs from "fs";

const cacheFileName = "imgCache.json"

const getCacheData = (filePath: string): Record<string, string> => {
try {
fs.accessSync(filePath);
return JSON.parse(fs.readFileSync(filePath).toString());
} catch (error) {
return {}
}

}


const urlRegexp = /url\(['"]([^'"]*)['"]\)/


const filePathFormat = (filePath: string) => filePath.replace('~@', '@')


module.exports = (): AcceptedPlugin => {

const cacheMap = getCacheData(path.join(__dirname, cacheFileName));

return {
postcssPlugin: 'auto-replace-assets-url',

Declaration(decl) {

if (!urlRegexp.test(decl.value)) return

let [_, filePath] = decl.value.match(urlRegexp)!;

filePath = filePathFormat(filePath)

if (!cacheMap[filePath]) return;

decl.value = `url(${cacheMap[filePath]})`

}
}
}

总结

这里介绍了页面分包、公共模块分包和图片资源优化的方式优化小程序包体积。我们还可以共同探讨其他优化策略如:TS枚举编译优化、小程序分包异步化、提取公共样式、原子化样式组件等。

最后

📚 小茗文章推荐:

  • 小程序主包体积的优化方案与技术实现
  • JSPDF + html2canvas A4分页截断
  • Formily JSON Schema 渲染流程及案例浅析

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

本文转载自: 掘金

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

Jetpack Compose -> 分包 & 自定义Com

发表于 2024-01-21

前言

上一章我们讲解了 Compose 基础UI 和 Modifier 关键字,本章主要讲解 Compose 分包以及自定义 Composable;

Compose 如何分包

我们在使用 Button 控件的时候,发现如果我们想给按钮设置文本的时候,Button 函数并没有直接提供设置 text 的参数,要我们自己去调用 Text 进行设置;

1
2
3
4
5
ini复制代码Column {    
Button(onClick = {}) {
Text(text = "我是老A")
}
}

可能到这里的时候,大家就会困惑了,Compose 为什么要这么搞呢?我们可以去源码中一探究竟,我们可以看到 Button 函数是在 androidx.compose.material3 这个包下面

Button 来自 compose.material3 这个组下面的,也就是 Maven 包的 groupId 是 androidx.compose.material3,对应的就是 build.gradle 中的依赖关系

其实Compose 由 androidx 中的 7 个 Maven 组 ID 构成。每个组都包含一套特定用途的功能,并各有专属的版本说明;

Compose 其实一共是分了6层,material 和 material3 是一个,只是不同的分支;每个组下面有不同的分包,我们其实可以看到 ui 下面就有不同的ui、ui-tooling-preview、ui-graphics 等等,Android 团队这么分包,其实是针对 View 系统的一个优化;

View 系统是没有这个分层的,这就导致后期越来越严重的扩展性问题,例如 View 系统中的 ListView,ListView 中有一个对 View 的回收复用机制,这个机制 RecyclerView 是没有办法复用的,也就是它们两个各自维护着一套复用机制,这就是分层不明确导致的;

所以 Compose 在设计之初就明确了分层概念,分层之后的各自扩展,就不会受到限制;

compose.compiler 严格来说,它其实并不属于这7层,它提供的并不是库依赖,它代表的是 kotlin 编译插件,转化 @Composable functions 并启用优化功能,它是负责编译过程的,我们在依赖里面也完全不需要去配置它,只需要在 Compose 的专用配置地方去写上你要的编译插件版本就行,对应的就是这里:

Compose 剩下的 Group 都是我们开发 Compose 的时候会用到的,不过它们有依次递进的依赖关系;

最下层是 compose.runtime 它包含了 Compose 编程模型和状态管理的基本构件块,以及 Compose 编译器插件的目标核心运行时,是最底层的概念模型,比如用来保存状态的 State 就在 compose.runtime,还有 mutableStateOf、remember

往上一层是 compose.ui 它是用来提供 ui 最基础的功能,比如绘制、测量、布局、触摸反馈等最底层的支持,比如我们使用的所有控件函数,最终都会调用到一个叫 Layout 的函数,这个函数就在 ui 这层;

再往上一层是 compose.animation,它是用来构建动画的;

在往上一层是 compose.foundation,它提供的是一套相对完整可靠的 UI 体系,例如 Colum、Row、Image 等都在这一层;

再往上一层就是 comose.material/material3 了,这是一个封装了 一堆 material design 风格控件的包,如果不想使用 MD 风格,可以使用 foundation 层自己组装一套风格出来;

接下来就是同一个组下面的多个包应该如何引用?例如 compose.ui 下的

1
2
3
4
5
6
scss复制代码implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.ui:ui-tooling-preview")
debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest")
androidTestImplementation("androidx.compose.ui:ui-test-junit4")

一般来说,我们只需要引入和组名相同的包就可以了,因为一般这个包就包含了这个组下其他包的所有依赖,除了测试组的这种,例如 compose.ui:ui 下不会包含 compose.ui:ui-test-xxx 和 compose.ui:ui-tooling 因为 test 和 工具类的一般都不会编译进我们的 apk 中;

例如 @Preview 就属于 ui-tooling 下的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
less复制代码@Preview
@Composable
fun preview() {
Column {
Button(onClick = {}) {
Text(text = "我是老A")
}
OutlinedButton(onClick = { /*TODO*/ }) {
Text(text = "我是老A")
}
TextButton(onClick = { /*TODO*/ }) {
Text(text = "我是老A")
}
}
}

还有 material3 提供的一些矢量图组件

1
2
scss复制代码implementation("androidx.compose.material3:material3-icon-extends")
implementation("androidx.compose.material3:material3-icon-core")

也是需要单独依赖的;

compose.ui:ui 一般包含了 ui 下的所有, compose.material3:material3 一般包含了 material 下的所有;

自定义Composable

用自定义函数的方式来写 Composable,而 Composable 是一种简化的方式,它指的是带有这个 Composable 注解的函数,那么这个注解到底是做什么的呢?我们来一探究竟

我们在使用的 Text 函数、Image 函数等其实都带有 Composable 注解,但是这些函数并不是原封不动的被调用的,而是会在编译过程中被动了手脚,给它们增加了一些函数参数,然后在运行的时候,调用的其实是那些被改过的参数更多的版本,比如说它们被加入的其中一个参数就是 Composer 类型的,总之这些 Composable 函数在编译的时候会被 Compose 的编译器插件(Compiler Plugin)修改,添加一些参数,运行的时候也是调用的这些被修改过的函数;

那么,编译器为什么要修改它们呢?

最重要的一点就是:要在代码中增加一些我们没有写出来的功能,这些功能对于开发者来说不需要,只需要在程序运行的时候能用到就可以了,所以编译的时候添加,即方便了开发者,又不影响程序的运行;

这其实也是一种面向切面(AOP)编程的思想;

那么编译器插件又是怎么认出这些函数的呢?它怎么直到哪些应该被修改呢?

靠的就是 @Composable 注解;只有被加了这个注解的才会进行修改,起到了识别符的作用;我们可以来看一个小例子:

如果 ui 函数没有添加 @Composable 注解,编译器直接报错了,就是因为这个函数内部调用了被 @Composable 注解的函数,所以我们可以理解为:所有调用了被 @Composable 注解的函数的函数,也必须添加上 @Composable 注解;说到这里的时候,可能会有人有疑问了,setContent 函数添加了 @Composable 注解了吗?如果没有添加,那么它内部怎么可以调用 Compose 函数?如果添加了,那么 MainActivity 为什么不用添加 @Composable 注解?我们来看看 setContent 的实现:

1
2
3
4
5
kotlin复制代码public fun ComponentActivity.setContent(    
parent: CompositionContext? = null,
content: @Composable () -> Unit) {

}

我们发现,setContent 函数并没有被 @Composable 注解标记,它只是把一个 @Composable 注解的函数作为了参数,所以 setContent 不需要被其注解;但是终归还是需要一个被 @Composeable 注解的函数来调用这个参数,那么这个函数是哪个函数呢?它就是 invokeComposable 函数

默认看不了,我们 Decompile to Java 看下

就是将 composable 强转成了一个 Function2 函数,然后进行调用;

所以自定义 Composable 就是声明的函数被 Composable 注解标记,本质上就是为了方便我们在开发中可以将我们的界面元素进行拆分,从而实现不同的功能;通常我们在自定义 Composable 的时候,直接的只会调用一个 Composable 函数,这样方便我们对于布局的控制

1
2
3
4
5
6
7
kotlin复制代码@Composable
fun ui() {
Column {
Text("老A")
Text("Mars")
}
}

而不是

1
2
3
4
5
kotlin复制代码@Composable
fun ui1() {
Text("老A")
Text("Mars")
}

那么外部在调用 ui1 函数的时候,我们的布局就不受控制了,如果外部调用的时候 放到了 Column 中,那么就会竖向排列,如果放到了 Row 中,就会横向排列,如果放到了 Box 中就会叠加排列;

而 ui 函数我们可以自己控制布局的排列,通过 Column、Row 等函数,而不用受外界调用控制;

自定义 Composable 的应用场景

再说使用场景的时候,我们可以先想领一个问题,自定义 Composable 在传统 View 中的等价物是什么?自定义View?还是 xml 文件?还是 自定义View + xml 文件?

自定义View?

1
2
3
4
5
6
7
kotlin复制代码@Composable    
fun ui() {
Column {
Text(text = "老A")
Text(text = "Mars")
}
}

这种写法,看起来更像传统的 自定义 LinearLayout

1
2
3
4
5
6
7
8
9
10
11
12
13
14
scss复制代码class CustomLinearLayout(context: Context?, attrs: AttributeSet?) : LinearLayout(context, attrs) {        
val name: TextView by lazy { TextView(context) }
val alias: TextView by lazy { TextView(context) }
init {
orientation = VERTICAL
//
name.text = "老A"
alias.text = "Mars"
...
// 省略部分代码
addView(name)
addView(alias)
}
}

看起来更像是 自定义 View 的等价物;

xml文件?

但是,这种简易布局我们一般也不会这样去使用,通常都是直接在 xml 中进行了声明

1
2
3
4
5
6
7
8
9
10
11
12
ini复制代码<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"    
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"/>

<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"/>
</LinearLayout>

这样更直观,便捷,看起来也更像 compose 的写法,一个父控件,两个子控件;

自定义View + xml?

但是如果我们对 Composable 函数做如下改动使用:

1
2
3
4
5
6
7
kotlin复制代码@Composable
fun ui(name: String) {
Column {
Text(text = name)
Text(text = "Mars")
}
}

我们设置了一个 name 作为参数来传入进来,那么我们就可以在调用的时候传入不同的值,来表现不同的数据,而且,这个 Composable 函数还可以这么改

1
2
3
4
5
6
7
8
9
10
11
12
13
14
kotlin复制代码@Composable
fun ui(name: String) {
Column {
val realName = remember {
if (name.length > 8) {
"我是laoA"
} else {
"我是马尔斯"
}
}
Text(text = realName)
Text(text = "Mars")
}
}

对于 Compose 可以这么写,但是对于传统的 xml 实现不了,一旦我们对界面有了定制的需求后,就只能通过自定义 View 来实现了;

所以,看起来自定义 Composable 更像传统 View 的自定义 View + xml 文件!

所以自定义 Composable 的使用场景也就能知道了;

界面声明我们一般是一个 Activity 对应一个 xml 的文件,那么当我们使用 Compose 的时候,也可以一个 MainActivity 对应一个 MainLayout 的 Composable 的函数;

当我们既需要 xml 的简洁有需要自定义view的逻辑处理能力,那么都是可以使用自定义 Composable 的;遇到任务需要对界面有定制需求,就直接使用 Composable 函数处理;

传统自定义 View 还能对布局、绘制、触摸反馈进行定制,这一类的高级自定义 View 在 Compose 中是怎么实现的呢?

其实还是用的自定义 Composable,当然如果你不自定义 Composable,直接硬写也是可以的,但是就失去了扩展、复用的能力,具体写法上,大部分用的是 Modifier,后面章节会详解自定义 Compose 中的高级自定义 View;

好了,自定义 Composable 就讲到这里吧~~

下一章预告

MutableState 和 mutableStateOf 详解;

欢迎三连

来都来了,点个关注,点个赞吧,你的支持是我最大的动力~~

本文转载自: 掘金

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

StreamtoList不能滥用以及与Collectors

发表于 2024-01-21

Stream toList()返回的是只读List原则上不可修改,collect(Collectors.toList())默认返回的是ArrayList,可以增删改查

  1. 背景

在公司看到开发环境突然发现了UnsupportedOperationException 报错,想到了不是自己throw的应该就是操作collection不当。

发现的确是同事使用了类似stringList.stream().filter(number -> Long.parseLong(number) > 1).toList() 以stream.toList()作为返回, 后继续使用了返回值做add操作,导致报错

  1. Stream toList()和 collect(Collectors.toList())的区别

JDK version: 21

IDE: IDEA

从Java16开始,Stream有了直接toList方法, java8时候常用的方法是 stringList.stream().filter(number -> Long.parseLong(number) > 1).collect(Collectors.toList())。

Stream toList()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
java复制代码    /**
* Accumulates the elements of this stream into a {@code List}. The elements in
* the list will be in this stream's encounter order, if one exists. The returned List
* is unmodifiable; calls to any mutator method will always cause
* {@code UnsupportedOperationException} to be thrown. There are no
* guarantees on the implementation type or serializability of the returned List.
*
* <p>The returned instance may be <a href="{@docRoot}/java.base/java/lang/doc-files/ValueBased.html">value-based</a>.
* Callers should make no assumptions about the identity of the returned instances.
* Identity-sensitive operations on these instances (reference equality ({@code ==}),
* identity hash code, and synchronization) are unreliable and should be avoided.
*
* <p>This is a <a href="package-summary.html#StreamOps">terminal operation</a>.
*
* @apiNote If more control over the returned object is required, use
* {@link Collectors#toCollection(Supplier)}.
*
* @implSpec The implementation in this interface returns a List produced as if by the following:
* <pre>{@code
* Collections.unmodifiableList(new ArrayList<>(Arrays.asList(this.toArray())))
* }</pre>
*
* @implNote Most instances of Stream will override this method and provide an implementation
* that is highly optimized compared to the implementation in this interface.
*
* @return a List containing the stream elements
*
* @since 16
*/
@SuppressWarnings("unchecked")
default List<T> toList() {
return (List<T>) Collections.unmodifiableList(new ArrayList<>(Arrays.asList(this.toArray())));
}

查看源码 Stream toList调用的是Collections.unmodifiableList 而在unmodifiableList(List<? extends T> list)实现中,都会返回一个不可修改的List,所以不能使用set/add/remove等改变list数组的方法。

1
2
3
java复制代码 return (list instanceof RandomAccess ?
new UnmodifiableRandomAccessList<>(list) :
new UnmodifiableList<>(list));


但其实也可以修改List的元素的某些属性,例如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
java复制代码        List<String> stringList = List.of("1", "2", "3");
List<String> largeNumberList = stringList.stream().filter(number -> Long.parseLong(number) > 1).toList();
List<String> largeNumberList2 = stringList.stream().filter(number -> Long.parseLong(number) > 1).collect(Collectors.toList());
// largeNumberList.add("4"); // java.lang.UnsupportedOperationException
largeNumberList2.add("4"); //success

// modify custom object attribute
User userZhang = new User("ZhangSan");
User userLi = new User("LiSi");
List<User> userList = List.of(userZhang, userLi);
List<User> filterList = userList.stream().filter(user -> "LiSi".equals(user.name)).toList();

User first = filterList.getFirst();//java 21
first.name = "WangWu";
filterList.forEach(u -> System.out.println(u.name));
//List.of返回的也是不能修改的List
userList.forEach(u -> System.out.print(u.name));

输出结果是:

WangWu

ZhangSanWangWu

Stream collect(Collectors.toList())

返回一个ArrayList 如果没有在toList()方法入参中传入指定Supplier的话, 可以做ArrayList的任何操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
java复制代码    /**
* Returns a {@code Collector} that accumulates the input elements into a
* new {@code List}. There are no guarantees on the type, mutability,
* serializability, or thread-safety of the {@code List} returned; if more
* control over the returned {@code List} is required, use {@link #toCollection(Supplier)}.
*
* @param <T> the type of the input elements
* @return a {@code Collector} which collects all the input elements into a
* {@code List}, in encounter order
*/
public static <T>
Collector<T, ?, List<T>> toList() {
return new CollectorImpl<>(ArrayList::new, List::add,
(left, right) -> { left.addAll(right); return left; },
CH_ID);
}

tips: List.of(),返回的也是不可修改的list, an unmodifiable list. 关于 Unmodifiable Lists 说明

The List.of and List.copyOf static factory methods provide a convenient way to create unmodifiable lists. The List instances created by these methods have the following characteristics:

They are unmodifiable. Elements cannot be added, removed, or replaced. Calling any mutator method on the List will always cause UnsupportedOperationException to be thrown. However, if the contained elements are themselves mutable, this may cause the List’s contents to appear to change.

They disallow null elements. Attempts to create them with null elements result in NullPointerException.

They are serializable if all elements are serializable.

The order of elements in the list is the same as the order of the provided arguments, or of the elements in the provided array.
The lists and their subList views implement the RandomAccess interface.

They are value-based. Programmers should treat instances that are equal as interchangeable and should not use them for synchronization, or unpredictable behavior may occur. For example, in a future release, synchronization may fail. Callers should make no assumptions about the identity of the returned instances. Factories are free to create new instances or reuse existing ones.

They are serialized as specified on the Serialized Form page.

This interface is a member of the Java Collections Framework.

3.如何使用(不考虑性能)

确定其是一个不再被set/add/remove的list 可使用 Stream toList;

如果使用collect(Collectors.toList()) ,sonar或idea自带以及第三方的一些code checker会爆warning, 以本人经验,可以使用collect(Collectors.toCollection(ArrayList::new))来代替

本文转载自: 掘金

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

糟糕,订单超时关闭成功,用户也支付成功了,我该怎么办?

发表于 2024-01-20

在电商及金融类的系统中,用户在下完单之后没有立即付款,订单处于未支付状态,默认情况下订单会有30分钟或者1小时左右(具体时间可以设置)的超时时间。在此期间用户可以选择立即付款,支付成功后订单进入待发货状态,等待商家发货。若超过规定的付款时间,那么订单会被关闭,库存及使用到的优惠(包含优惠券、积分)都将会被返还。那么如果订单超时时间到期需要关闭订单,并且此时正好用户也支付成功了,对于订单服务来说该如何处理呢?

明确订单终态

正常情况下,支付业务只有支付成功和支付失败(用户取消)两种状态,两者中必有一个能进入到终态。必须明确一点的是,无论哪一个都代表着业务的最终态,不能被改变。如果终态随便转换,那么在设计上一定是不合理的。

业务处理

有两种情况:

  • 第一种: 用户支付成功处理成功,订单超时关闭处理失败;
  • 第二种:订单超时关闭处理成功,用户支付成功处理失败。

针对上面的这两种情况,如果用户已经支付成功,面对订单超时关闭的请求可以直接拒绝,也符合正常的业务逻辑。不过,要是出现订单超时关闭成功,但支付成功处理失败了,这样的情况该如何处理呢?用户的钱怎么处理?原路返回吗?

没错,就是原路返回。订单超时关闭了,用户支付成功会触发支付回调,开发人员可以在回调处理的过程中,如果发现订单已经被关闭了,那么就得触发退款流程,把钱退给用户。 这样,即使订单超时关闭了,但是用户付过的钱已经退回了,对用户来说也并不存在损失。

❝

注:退款流程可能存在失败的情况,开发人员需要做好幂等以及兜底方案。

❞

对账机制

类似这些与支付有关的业务,引入对账机制是必要的。根据支付流水以及支付单的状态以及用户的支付、退款情况,逐一比对(支付金额=冲退金额)。如果发现不一致的情况,要根据用户的实付金额进行退款重试,直到完全一致。

加锁

具体来说就是出现了并发问题,无论是用户支付成功回调还是订单即将超时关闭订单,在执行业务逻辑之前,都需要加锁控制,并且一定是分布式锁。谁抢到了这笔单子的锁,谁先处理,没抢到的只能等待下次重试。这样做不仅能够解决并发问题,而且也能够避免异常情况的出现。

总结

上面的方案整体看下来,能够很好的解决订单超时到期关闭,同时用户也支付成功出现的并发问题,不过除此之外,仍需做好幂等以及兜底方案,确保实际业务系统能够正常稳定运行。OK,今天就先分享到这里,感兴趣的朋友可以帮忙点点关注、点点赞,非常感谢!下篇文章再会~~

本文转载自: 掘金

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

SpringBoot中使用LocalDateTime踩坑记录

发表于 2024-01-19

@[toc]

前言

近日心血来潮想做一个开源项目,目标是做一款可以适配多端、功能完备的模板工程,包含后台管理系统和前台系统,开发者基于此项目进行裁剪和扩展来完成自己的功能开发。

本项目基于Java21和SpringBoot3开发,序列化工具使用的是默认的Jackson,使用Spring Data Redis操作Redis缓存。

在定义实体类过程中,日期时间类型的属性我使用了java.time包下的LocalDate和LocalDateTime类,而没有使用java.util包下的Date类。

但在使用过程中遇到了一些问题,于是在此记录下来与诸位分享。

一、为什么推荐使用java.time包的LocalDateTime而不是java.util的Date?

LocalDateTime和Date是Java中表示日期和时间的两种不同的类,它们有一些区别和特点。

  • 类型:LocalDateTime是Java 8引入的新类型,属于Java 8日期时间API(java.time包)。而Date是旧版Java日期时间API(java.util包)中的类。
  • 不可变性:LocalDateTime是不可变的类型,一旦创建后,其值是不可变的,对该类对象的加减等计算操作不会修改原对象,而是会返回一个新的LocalDateTime对象。而Date是可变的类型,可以通过方法修改其值。
  • 线程安全性:LocalDateTime是线程安全的,多个线程可以同时访问和操作不同的LocalDateTime实例。而Date是非线程安全的,如果多个线程同时访问和修改同一个Date实例,可能会导致不可预期的结果。
  • 时间精度:LocalDateTime提供了纳秒级别的时间精度,可以表示更加精确的时间。而Date只能表示毫秒级别的时间精度。
  • 时区处理:LocalDateTime默认不包含时区信息,表示的是本地日期和时间。而Date则包含时区信息,它的实际值会受到系统默认时区的影响。

由于LocalDateTime是Java 8及以上版本的新类型,并提供了更多的功能和灵活性,推荐在新的项目中使用LocalDateTime来处理日期和时间。

对于旧版Java项目,仍然需要使用Date类,但在多线程环境下需要注意其线程安全性。

如果需要在LocalDateTime和Date之间进行转换,可以使用相应的方法进行转换,例如通过LocalDateTime的atZone()方法和Date的toInstant()方法进行转换。

二、使用LocalDateTime和LocalDate时遇到了哪些坑?

2.1 Redis序列化报错

2.1.1 问题现象

在使用RedisTemplate向Redis中插入数据时,遇到了如下报错:

1
2
3
4
5
6
kotlin复制代码2024-01-11T21:33:25.233+08:00 ERROR 13212 --- [nio-8080-exec-1] o.a.c.c.C.[.[.[/].[dispatcherServlet]    : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception

org.springframework.data.redis.serializer.SerializationException: Could not write JSON: Java 8 date/time type `java.time.LocalDateTime` not supported by default: add Module "com.fasterxml.jackson.datatype:jackson-datatype-jsr310" to enable handling (through reference chain: java.util.ArrayList[0]->com.fast.alden.data.model.SysApiResource["createdTime"])
at org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer.serialize(Jackson2JsonRedisSerializer.java:157) ~[spring-data-redis-3.2.0.jar:3.2.0]
at org.springframework.data.redis.core.AbstractOperations.rawValue(AbstractOperations.java:128) ~[spring-data-redis-3.2.0.jar:3.2.0]
at org.springframework.data.redis.core.DefaultValueOperations.set(DefaultValueOperations.java:236) ~[spring-data-redis-3.2.0.jar:3.2.0]

image.png

2.1.2 问题分析

在使用Redis缓存含有LocalDateTime类型变量的实体类时会产生序列化问题,因为Jackson库在默认情况下不支持Java8的LocalDateTime类型的序列化和反序列化。

错误堆栈中也给出了解决方案,添加 com.fasterxml.jackson.datatype:jackson-datatype-jsr310依赖,但光添加依赖是不够的,还我们需要自定义序列化和反序列化的行为。

2.1.3 解决方案

  1. 添加maven依赖
1
2
3
4
5
xml复制代码<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
<version>2.13.0</version>
</dependency>
  1. 修改RedisSerializer Bean配置

在定义RedisSerializer Bean的代码中自定义ObjectMapper对象处理时间属性时的序列化和反序列化行为,LocalDate、LocalDateTime、LocalTime的序列化和反序列化都要自定义,还要禁用将日期序列化为时间戳。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
java复制代码@Configuration
public class RedisConfig {
@Bean
public RedisSerializer<Object> redisSerializer() {
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
// 必须设置,否则无法将JSON转化为对象,会转化成Map类型
objectMapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance, ObjectMapper.DefaultTyping.NON_FINAL);

// 自定义ObjectMapper的时间处理模块
JavaTimeModule javaTimeModule = new JavaTimeModule();

javaTimeModule.addSerializer(LocalDateTime.class, new LocalDateTimeSerializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
javaTimeModule.addDeserializer(LocalDateTime.class, new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));

javaTimeModule.addSerializer(LocalDate.class, new LocalDateSerializer(DateTimeFormatter.ofPattern("yyyy-MM-dd")));
javaTimeModule.addDeserializer(LocalDate.class, new LocalDateDeserializer(DateTimeFormatter.ofPattern("yyyy-MM-dd")));

javaTimeModule.addSerializer(LocalTime.class, new LocalTimeSerializer(DateTimeFormatter.ofPattern("HH:mm:ss")));
javaTimeModule.addDeserializer(LocalTime.class, new LocalTimeDeserializer(DateTimeFormatter.ofPattern("HH:mm:ss")));

objectMapper.registerModule(javaTimeModule);

// 禁用将日期序列化为时间戳的行为
objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);

//创建JSON序列化器
return new Jackson2JsonRedisSerializer<>(objectMapper, Object.class);
}
}

2.2 LocalDateTime和LocalDate类型的属性返回给前端的值格式不正确

2.2.1 问题现象

在application.yml中设置了全局的日期类型的序列化和反序列化格式,在对应字段上也并没有使用@JsonFormat进行特殊设置,但是LocalDateTime类型的属性返回给前端时并没有生效,返回的仍是LocalDateTime默认的ISO标准时间格式的字符串。

1
2
3
4
5
6
7
8
9
yaml复制代码spring:
jackson:
date-format: yyyy-MM-dd HH:mm:ss
time-zone: GMT+8
default-property-inclusion: always
mvc:
format:
date-time: yyyy-MM-dd HH:mm:ss
date: dd/MM/yyyy

image.png

2.2.2 解决方案

自定义Jackson配置,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
java复制代码@Configuration
public class JacksonConfig {
@Bean
public Jackson2ObjectMapperBuilderCustomizer customizer() {
return builder ->
builder.simpleDateFormat("yyyy-MM-dd HH:mm:ss")
// long类型转string, 前端处理Long类型,数值过大会丢失精度
.serializerByType(Long.class, ToStringSerializer.instance)
.serializerByType(Long.TYPE, ToStringSerializer.instance)
.serializationInclusion(JsonInclude.Include.NON_NULL)
//指定反序列化类型,也可以使用@JsonFormat(pattern = "yyyy-MM-dd")替代。主要是mvc接收日期时使用
.deserializerByType(LocalTime.class, new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern("HH:mm:ss")))
.deserializerByType(LocalDate.class, new LocalDateDeserializer(DateTimeFormatter.ofPattern("yyyy-MM-dd")))
.deserializerByType(LocalDateTime.class, new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")))
// 日期序列化,主要返回数据时使用
.serializerByType(LocalTime.class, new LocalTimeSerializer(DateTimeFormatter.ofPattern("HH:mm:ss")))
.serializerByType(LocalDate.class, new LocalDateSerializer(DateTimeFormatter.ofPattern("yyyy-MM-dd")))
.serializerByType(LocalDateTime.class, new LocalDateTimeSerializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
}
}

三、总结

在使用java.time API的过程中,除了会遇到前文所说的序列化问题之外,可能还会遇到以下问题:

  • 时区问题:LocalDateTime不包含时区信息,这可能导致在不同时区的用户之间出现不一致性。为了避免这个问题,您应该考虑使用ZonedDateTime或OffsetDateTime,并确保在处理日期和时间时考虑时区。
  • 数据库交互:当与数据库交互时,要确保数据库列的数据类型与正在使用的Java日期类型相匹配。例如,如果使用的是PostgreSQL,则可能需要使用timestamp without time zone列类型来存储日期和时间。
  • 默认值和验证:在某些情况下,可能希望为日期或时间字段设置默认值或进行验证。使用Spring的验证注解(如@NotNull或@Size)可以帮助我们确保输入的有效性。
  • 跨时区处理:由于LocalDateTime不包含时区信息,当与全球用户互动时,需要特别注意时区转换。考虑使用像Joda-Time这样的库来帮助我们处理复杂的时区转换。
  • 处理过去和未来的日期:在处理历史事件或计划未来的活动时,请确保我们的应用程序能够正确地处理这些日期。考虑使用像Period或Duration这样的类来计算日期之间的差异。

我也会及时的更新后续实践中所遇到的问题,希望与诸位看官一起进步。

如有错误,还望批评指正。

本文转载自: 掘金

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

Android 同频共帧动画效果 同频共帧 现状 & 痛点

发表于 2024-01-19

同频共帧

我们听过“同频共振”,其原理是多个物体物体以同样的频率振动。本篇实现的效果是“同频共帧”,两者有一定的联系,但属于不同的概念。

“同频共帧”的含义是:一种动画效果,以同样的频率、同样的画面展示在多个不同View上。

特点:

  • 动画效果
  • 同样的频率
  • 同一帧 (严格意义上是小于1个vsync信号的帧)
  • 多个不同View同时展示

下面我们使用6个ImageView来同步播放同一个动画。

效果1:

fire_95.gif

效果2:

fire_96.gif

看似这两种动画就是简单的动画,你可能会想,直接创建6个AnimationDrawable不就行了?显然这种做法仅仅适合图片数量有限或者内存较大的设备,另外AnimationDrawable越多,低性能设备由于CPU性能差,可能出现帧不一致的情况。原则上说,本篇是属于动画性能优化的文章。

场景:
我们看一些综艺节目,电视两侧都会出现各种火焰、礼花等效果,而且左右是对称的,想象一下,这种情况如果你给一个超大图片去实现,这个时候占用内存肯定会很大,因此是最好的方式是让动画绘制区域更小。

这种动效其实在手机版QQ上就有,如果你给自己的头像设置为一个动态图,如果在聊天群连发多条消息,那么你就会发现,在同一个页面上你的头像动画是同步同帧率展示的。

现状 & 痛点

现状

我们以帧动画问题展开,要知道帧动画有难以容忍的内存占用问题、以及主线程解码问题,同时包体积问题也相当严重,为此市面上出现了很多方案。libpag、lottie、VapPlayer、AlphaPlayer、APNG、GIF、SVGA、AnimationDrawable等。但你在开发时就会发现,每一种引擎都有自己独特的优势,也有自己独特的劣势,你往往想着用一种引擎统一所有动效实现,但往往现实不允许。

我们来说说几大引擎的优缺点:

libPag: 目前支持功能最多的动效引擎,普通动画性能也非常不错,相比其他引擎快很多。该引擎使用自研渲染引擎和解码器实现,但是对于预合成动效(超长动效和复杂动效可能会用到),由于其使用的是软解,在低配设备上比VapPlayer和AlphaPlayer卡的多,另外lib so相比其他引擎也是大很多。

VapPlayer/AlphaPlayer : 这两种其都是通过alpha 遮罩实现,大部分情况下使用的是设备硬解码器,不过,VapPlayer缺乏硬解码器筛选机制,偶尔有时会拿到软解码器,另外其本身存在音画同步问题,至于AlphaPlayer把播放交给系统和三方播放器,避免了此类问题。但是,如果是音视频类app,他们都有共同的问题,终端设备上硬解码器的实例数量是受限制的,甚至有的设备解码器同一时刻只能使用一个,同时使用这种解码器就会造成业务播放器绿屏、起播失败、解码器卡住等问题。不过解决办法是将特效和业务播放器资源类型隔离,如果业务播放器是使用h264资源,那么动效播放可以使用h265、mpeg2、av1等其他编码类型资源,以此规避解码器竞争。

lottie: lottie目前是比较广为人知的动效引擎,使用也相当广泛,上手难度也比较低,可以解决大部分动效播放问题。但lottie动效也存在一些跨平台兼容性,需要单独适配,其次缺少很多特效支持,性能方面,一般情况下其性能是不如libpag的。不过总体能覆盖到大部分场景,至于兼容性问题每个平台单独提供不同的lottie规则即可。

其实,lottie开发也有一定的难度。开发中常常会遇到的问题是,UI设计人员对于lottie的compose layer理解存在问题,往往会出现将lottie动画做成和帧动画一样的动画,显然,compose layer的思想是多张图片合成,那就意味着图片本身应该有大有小,按一定轨迹运动和渐变,而不是类似帧动画一样一帧一帧简单播放。

APNG、GIF、 WebP: 这类动画属于资源型动画,其本身存在很多缺点,比如占内存和耗cpu,另外APNG和WebP兼容性不足,不过简短的动效的还是可以使用的,特别是WebP在Android、Web、部分iOS设备上优势也比较大。

SVGA:很多平台对这种动画抱有期待,特别是其矢量性质和低内存的特点,然而,其本身面临标准不统一的问题,造成跨平台的能力不足,其次是因为SVGA需要转为Path绘制,但Path的绘制非常消耗性能。

LazyAnimationDrawable:几乎所有的动画对低配设备都不友好,帧动画比上不足比下有余,低配设备上,为了解决libpag、VapPlayer、lottie对低配设备上音视频类app不友好的问题,使用AnimationDrawble显然是不行的,因此我们往往会实现了自己的AnimationDrawable,使其具备兜底的能力,主要原理: 独立线程解码 + 展示一帧预 + 加载下一帧 + 帧缓存,其实也就是LazyAnimationDrawable。

痛点

以上我们罗列了很多问题,看似和我们的主要目的毫无关系,其实我们可以想想,如果使用上述引擎,哪种方式可以实现兼容性更好的“同频共帧”动效呢 ?

实际上,几乎没有引擎能承担此任务,那有没有办法实现呢?

原理

我们很难让每个View同时执行和绘制同样的画面,另一个问题是,如果设计多个View绘制Bitmap,那么还可能造成资源加载的内存OOM的问题。另外一方面如果使用LazyAnimationDrawable、VapX、AlphaPlayer等,同时执行相同的动效,那么解码线程需要创建多个,显然性能、内存问题也是重中之重。

有没有更加简单方法呢 ?

实际上是有的,那就是投影。

我们无论使用CompositeDrawable、LazyAnimationDrawable、AnimationDrawable还是VectorDrawable,我们可以保证在使用个实例的情况下,将画面绘制到不同View上即可。

不过:本篇以AnimationDrawable 为例子实现,其实其他Drawable动画类似。

实现

这种难度也是很高的,如果我们使用一个View 管理器,然后构建一个播放器,显然还要处理View各种状态,显然避免不了耦合问题。这里我们回到开头说过的drawable方案,当然,一个drawable显然无法设置给多个View,这点显然是我们需要处理的难点,此外,每个View的大小也不一致,如何处理这种问题呢。

之前的文章中我们实现了很多动效,但几乎都是基于View本身实现的,但是在Android中,Drawable最容易扩展和移植的渲染组件。通过Drawable提供的接口,我们可以接入libpag、lottie、SVG、APNG、gif,LazyAnimationDrawable、AnimationDrawable等动效,更加方便移植,同时Drawable支持setHotspot和setState接口,可以实现复杂度较低的交互效果。

Drawable Wrapper

一个Drawable不能同时设置给不同的View,但是我们可以创建多个Drawable从目标Drawable上“投影”画面到自身。

这里我们参考Glide中com.bumptech.glide.request.target.FixedSizeDrawable 实现,其原理是通过FixedSizeDrawable代理真实的drawble绘制,从而达到投影效果。在drawable更新时,利用Matrix实现Canvas缩放,即可适配不同大小的View。

1
2
3
4
5
6
7
8
9
10
11
java复制代码FixedSizeDrawable(State state, Drawable wrapped) {
this.state = Preconditions.checkNotNull(state);
this.wrapped = Preconditions.checkNotNull(wrapped);

// We will do our own scaling.
wrapped.setBounds(0, 0, wrapped.getIntrinsicWidth(), wrapped.getIntrinsicHeight());

matrix = new Matrix();
wrappedRect = new RectF(0, 0, wrapped.getIntrinsicWidth(), wrapped.getIntrinsicHeight());
bounds = new RectF();
}

Matrix 的作用:

这里主要是将原有的画面缩放至目标View

1
2
java复制代码matrix.setRectToRect(wrappedRect, drawableBounds, Matrix.ScaleToFit.CENTER);
canvas.concat(matrix); //Canvas Matrix 转换

当然,必要时支持下alpha和colorFilter,以此来实现变色效果,下面是完整实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
java复制代码public static class AnimationDrawableWrapper extends Drawable {

private final Drawable animationDrawable; //动画drawable
private final Matrix matrix = new Matrix();
private final RectF wrappedRect;
private final RectF drawableBounds;
private final Matrix.ScaleToFit scaleToFit;
private int alpha = 255;
private ColorFilter colorFilter;

public AnimationDrawableWrapper(Drawable drawable, Matrix.ScaleToFit scaleToFit) {
this.animationDrawable = drawable;
this.wrappedRect = new RectF(0, 0, drawable.getIntrinsicWidth(), drawable.getIntrinsicHeight());
this.drawableBounds = new RectF();
this.scaleToFit = scaleToFit;
}

@Override
public void draw(Canvas canvas) {
Drawable current = animationDrawable.getCurrent();
if (current == null) {
return;
}
current.setAlpha(this.alpha);
current.setColorFilter(this.colorFilter);
Rect drawableRect = current.getBounds();
wrappedRect.set(drawableRect);
drawableBounds.set(getBounds());

// 变化坐标
matrix.setRectToRect(wrappedRect, drawableBounds, scaleToFit);

int save = canvas.save();

canvas.concat(matrix);
current.draw(canvas);

canvas.restoreToCount(save);

current.setAlpha(255);//还原
current.setColorFilter(null); //还原

}

@Override
public void setAlpha(int alpha) {
this.alpha = alpha;
}

@Override
public void setColorFilter(ColorFilter colorFilter) {
this.colorFilter = colorFilter;
}

@Override
public int getOpacity() {
return PixelFormat.TRANSLUCENT;
}

}

View更新

我们知道AnimationDrawable每一帧都是不一样的,那怎么将每一帧都能绘制在View上呢,了解过Drawable更新机制的开发者都知道,每一个View都实现了Drawable.Callback,当给View设置drawable时,Drawable.Callback也会设置给drawable。

Drawable刷新View时需要调用invalidate,显然是通过Drawable.Callback实现,当然,Drawable自身就实现了更新方法Drawable#invalidateSelf,我们只需要调用改方法刷新View即可触发View#onDraw,从而触发drawable#draw方法。

1
2
3
4
5
6
java复制代码public void invalidateSelf() {
final Callback callback = getCallback();
if (callback != null) {
callback.invalidateDrawable(this);
}
}

更新AnimationDrawable

显然,任何动画都具备时间属性,因此更新Drawable是必要的,View本身是可以通过Drawable.Callback机制更新Drawable的。通过scheduleDrawable和unscheduleDrawable 定时处理Runnable和取消Runnable。

1
2
3
4
5
6
7
8
java复制代码public interface Callback {

void invalidateDrawable(@NonNull Drawable who);

void scheduleDrawable(@NonNull Drawable who, @NonNull Runnable what, long when);

void unscheduleDrawable(@NonNull Drawable who, @NonNull Runnable what);
}

而AnimationDrawable实现了Runnable接口

1
2
3
4
java复制代码@Override
public void run() {
nextFrame(false);
}

然而,如果使用的RecyclerView,那么还可能会出现View 从页面移除的问题,因此依靠View显然是不行的,这里我们引入Handler或者Choreograper。

1
java复制代码this.choreographer = Choreographer.getInstance();

但是,我们什么时候调用呢?显然还得利用Drawable.Callback机制

给animationDrawable设置Drawable.Callback

1
java复制代码this.drawable.setCallback(callback);

更新逻辑实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
java复制代码@Override
public void invalidateDrawable(@NonNull Drawable who) {
//更新所有wrapper
for (int i = 0; i < drawableList.size(); i++) {
WeakReference<AnimationDrawableWrapper> reference = drawableList.get(i);
AnimationDrawableWrapper wrapper = reference.get();
if (wrapper == null) {
return;
}
wrapper.invalidateSelf();
}
}

@Override
public void scheduleDrawable(@NonNull Drawable who, @NonNull Runnable what, long when) {
this.scheduleTask = what;
this.choreographer.postFrameCallbackDelayed(this, when - SystemClock.uptimeMillis());
}

@Override
public void unscheduleDrawable(@NonNull Drawable who, @NonNull Runnable what) {
this.scheduleTask = null;
this.choreographer.removeFrameCallback(this);
}

既然使用Choreographer,那doFrame需要实现的

1
2
3
4
5
6
java复制代码@Override
public void doFrame(long frameTimeNanos) {
if(this.scheduleTask != null) {
this.scheduleTask.run();
}
}

好了,以上就是核心逻辑,到此我们就实现了核心逻辑

完整代码

下面是镜像动画的完整代码,因为是比较成熟的代码,接入了LazyAnimationDrawable。

当然,如果你没有LazyAnimationDrawable的实现的话,删掉LazyAnimationDrawable的引,仅仅通过AnimationDrawable也是可以正常使用的。另外,你还可添加其他动画实现,比如VerctorDrawable等。

我们通过MirrorFrameAnimation,实现同频共帧动画效果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
java复制代码public class MirrorFrameAnimation implements Drawable.Callback, Choreographer.FrameCallback {
private final Drawable drawable;
private final int drawableWidth;
private final int drawableHeight;
private List<WeakReference<AnimationDrawableWrapper>> drawableList = new ArrayList<>();
private Choreographer choreographer;
private Runnable scheduleTask;

public MirrorFrameAnimation(Resources resources, int resId, boolean isLazyDrawableAnimation, int drawableWidth, int drawableHeight) {

//设置宽高,防止AnimationDrawable大小不稳定问题
this.drawableWidth = drawableWidth;
this.drawableHeight = drawableHeight;
this.drawable = isLazyDrawableAnimation ? LazyAnimationDrawableInflater.getDrawable(resources, resId, null) : resources.getDrawable(resId);
this.drawable.setBounds(0, 0, drawableHeight, drawableHeight);
this.drawable.setCallback(this);
this.choreographer = Choreographer.getInstance();
}

public MirrorFrameAnimation(LazyAnimationDrawable lazyAnimationDrawable, int drawableWidth, int drawableHeight) {

//设置宽高,防止AnimationDrawable大小不稳定问题
this.drawableWidth = drawableWidth;
this.drawableHeight = drawableHeight;
this.drawable = lazyAnimationDrawable;
this.drawable.setBounds(0, 0, drawableHeight, drawableHeight);
this.drawable.setCallback(this);
this.choreographer = Choreographer.getInstance();
}

public MirrorFrameAnimation(AnimationDrawable animationDrawable, int drawableWidth, int drawableHeight) {

//设置宽高,防止AnimationDrawable大小不稳定问题
this.drawableWidth = drawableWidth;
this.drawableHeight = drawableHeight;
this.drawable = animationDrawable;
this.drawable.setBounds(0, 0, drawableHeight, drawableHeight);
this.drawable.setCallback(this);
this.choreographer = Choreographer.getInstance();
}

public void start() {
choreographer.removeFrameCallback(this);
if (drawable instanceof AnimationDrawable) {
((AnimationDrawable) drawable).start();
} else if (drawable instanceof LazyAnimationDrawable) {
((LazyAnimationDrawable) drawable).start();
}
}

public void stop() {
choreographer.removeFrameCallback(this);
if (drawable instanceof AnimationDrawable) {
((AnimationDrawable) drawable).stop();
} else if (drawable instanceof LazyAnimationDrawable) {
((LazyAnimationDrawable) drawable).stop();
}
}

/**
* @return The number of frames in the animation
*/
public int getNumberOfFrames() {
if (drawable instanceof AnimationDrawable) {
return ((AnimationDrawable) drawable).getNumberOfFrames();
} else if (drawable instanceof LazyAnimationDrawable) {
return ((LazyAnimationDrawable) drawable).getNumberOfFrames();
}
return 0;
}

/**
* @return The Drawable at the specified frame index
*/
public Drawable getFrame(int index) {
if (drawable instanceof AnimationDrawable) {
return ((AnimationDrawable) drawable).getFrame(index);
} else if (drawable instanceof LazyAnimationDrawable) {
return ((LazyAnimationDrawable) drawable).getDrawableOfFrame(index);
}
return drawable;
}

/**
* @return The duration in milliseconds of the frame at the
* specified index
*/
public int getDuration(int index) {
if (drawable instanceof AnimationDrawable) {
return ((AnimationDrawable) drawable).getDuration(index);
} else if (drawable instanceof LazyAnimationDrawable) {
return ((LazyAnimationDrawable) drawable).getDuration(index);
}
return 0;
}

/**
* @return True of the animation will play once, false otherwise
*/
public boolean isOneShot() {
if (drawable instanceof AnimationDrawable) {
return ((AnimationDrawable) drawable).isOneShot();
} else if (drawable instanceof LazyAnimationDrawable) {
return ((LazyAnimationDrawable) drawable).isOneShot();
}
return true;
}

public boolean isRunning() {
if (drawable instanceof AnimationDrawable) {
return ((AnimationDrawable) drawable).isRunning();
} else if (drawable instanceof LazyAnimationDrawable) {
return ((LazyAnimationDrawable) drawable).isRunning();
}
return false;
}

public long getDuration(){
long duration = 0;
if (drawable instanceof AnimationDrawable) {
AnimationDrawable animationDrawable = (AnimationDrawable) drawable;
int numberOfFrames = animationDrawable.getNumberOfFrames();
for (int i = 0; i < numberOfFrames; i++) {
duration += animationDrawable.getDuration(i);
}
} else if (drawable instanceof LazyAnimationDrawable) {
LazyAnimationDrawable animationDrawable = (LazyAnimationDrawable) drawable;
int numberOfFrames = animationDrawable.getNumberOfFrames();
for (int i = 0; i < numberOfFrames; i++) {
duration += animationDrawable.getDuration(i);
}
}
return duration;
}

public boolean isEndFrame() {
if (drawable instanceof AnimationDrawable) {
AnimationDrawable animationDrawable = (AnimationDrawable) drawable;
int frameIndex = animationDrawable.getNumberOfFrames() - 1;
return animationDrawable.isOneShot() && animationDrawable.getFrame(frameIndex) == animationDrawable.getCurrent();
}
if (drawable instanceof LazyAnimationDrawable) {
LazyAnimationDrawable animationDrawable = (LazyAnimationDrawable) drawable;
int frameIndex = animationDrawable.getNumberOfFrames() - 1;
if (animationDrawable.isOneShot() && animationDrawable.getFrame(frameIndex) == animationDrawable.getCurrentFrame()) {
return true;
}
}
return false;
}

public boolean isEndAnimation() {
if (isEndFrame()) {
return true;
}
if (drawable instanceof LazyAnimationDrawable) {
LazyAnimationDrawable animationDrawable = (LazyAnimationDrawable) drawable;
if (animationDrawable.isLoopFinish()) {
return true;
}
}
return false;
}

/**
* Sets whether the animation should play once or repeat.
*
* @param oneShot Pass true if the animation should only play once
*/
public void setOneShot(boolean oneShot) {
if (drawable instanceof AnimationDrawable) {
((AnimationDrawable) drawable).setOneShot(oneShot);
} else if (drawable instanceof LazyAnimationDrawable) {
((LazyAnimationDrawable) drawable).setOneShot(oneShot);
}
}

public void syncDrawable(View view, Matrix.ScaleToFit scaleToFit) {
if (!(drawable instanceof AnimationDrawable) && !(drawable instanceof LazyAnimationDrawable)) {
if (view instanceof ImageView) {
((ImageView) view).setImageDrawable(drawable);
} else {
view.setBackground(drawable);
}
return;
}

AnimationDrawableWrapper wrapper = new AnimationDrawableWrapper(drawable,scaleToFit);
drawableList.add(new WeakReference<>(wrapper));

if (view instanceof ImageView) {
((ImageView) view).setImageDrawable(wrapper);
} else {
view.setBackground(wrapper);
}
}

@Override
public void invalidateDrawable(Drawable who) {
for (int i = 0; i < drawableList.size(); i++) {
WeakReference<AnimationDrawableWrapper> reference = drawableList.get(i);
AnimationDrawableWrapper wrapper = reference.get();
if (wrapper == null) {
return;
}
wrapper.invalidateSelf();
}
}

@Override
public void scheduleDrawable(Drawable who, Runnable what, long when) {
this.scheduleTask = what;
this.choreographer.postFrameCallbackDelayed(this, when - SystemClock.uptimeMillis());
}

@Override
public void unscheduleDrawable(Drawable who, Runnable what) {
this.scheduleTask = null;
this.choreographer.removeFrameCallback(this);
}


@Override
public void doFrame(long frameTimeNanos) {
if (this.scheduleTask != null) {
this.scheduleTask.run();
}
}

@Nullable
public MirrorFrameAnimation cloneDrawable(int drawableWidth,int drawableHeight) {
Drawable newDrawable = drawable.getConstantState().newDrawable();
if(newDrawable instanceof LazyAnimationDrawable) {
return new MirrorFrameAnimation((LazyAnimationDrawable) newDrawable, drawableWidth, drawableHeight);
}else if(newDrawable instanceof AnimationDrawable) {
return new MirrorFrameAnimation((AnimationDrawable) newDrawable, drawableWidth, drawableHeight);
}
return null;
}

public static class AnimationDrawableWrapper extends Drawable {

private final Drawable animationDrawable; //动画drawable
private final Matrix matrix = new Matrix();
private final RectF wrappedRect;
private final RectF drawableBounds;
private final Matrix.ScaleToFit scaleToFit;
private int alpha = 255;
private ColorFilter colorFilter;

public AnimationDrawableWrapper(Drawable drawable, Matrix.ScaleToFit scaleToFit) {
this.animationDrawable = drawable;
this.wrappedRect = new RectF(0, 0, drawable.getIntrinsicWidth(), drawable.getIntrinsicHeight());
this.drawableBounds = new RectF();
this.scaleToFit = scaleToFit;
}

@Override
public void draw(Canvas canvas) {
Drawable current = animationDrawable.getCurrent();
if (current == null) {
return;
}
current.setAlpha(this.alpha);
current.setColorFilter(this.colorFilter);
Rect drawableRect = current.getBounds();
wrappedRect.set(drawableRect);
drawableBounds.set(getBounds());

// 变化坐标
matrix.setRectToRect(wrappedRect, drawableBounds, scaleToFit);

int save = canvas.save();

canvas.concat(matrix);
current.draw(canvas);

canvas.restoreToCount(save);

current.setAlpha(255);//还原
current.setColorFilter(null); //还原

}

@Override
public void setAlpha(int alpha) {
this.alpha = alpha;
}
@Override
public void setColorFilter(ColorFilter colorFilter) {
this.colorFilter = colorFilter;
}
@Override
public int getOpacity() {
return PixelFormat.TRANSLUCENT;
}

}
}

使用方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
java复制代码int dp2px = (int) dp2px(100);
MirrorFrameAnimation mirrorFrameAnimation = new MirrorFrameAnimation(getResources(),R.drawable.loading_animation,dp2px,dp2px);

mirrorFrameAnimation.syncDrawable(imageView1,Matrix.ScaleToFit.CENTER);
mirrorFrameAnimation.syncDrawable(imageView2,Matrix.ScaleToFit.CENTER);
mirrorFrameAnimation.syncDrawable(imageView3,Matrix.ScaleToFit.FILL);
mirrorFrameAnimation.syncDrawable(imageView4,Matrix.ScaleToFit.FILL);
mirrorFrameAnimation.syncDrawable(imageView5,Matrix.ScaleToFit.CENTER);
mirrorFrameAnimation.syncDrawable(imageView6,Matrix.ScaleToFit.CENTER);

mStart.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
mirrorFrameAnimation.start();
}
});
mStop.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
mirrorFrameAnimation.stop();
}
});

适用范围

图像同步执行需求

我们常常会出现屏幕边缘方向同时展示相同动画的问题,由于每个动画启动存在一定的延时,以及控制逻辑不稳定,往往会出现一边动画播放结束,另一边动画还在展示的情况。

本篇我们实现了“同频共帧动效”,实际上这也是一种对称动画的优化方法。

总结

动效一直是Android设备的上需要花大力气优化的,如果是图像同步执行、对称动效,本篇方案显然可以帮助我们减少线程和内存的消耗。

本文转载自: 掘金

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

工作中用Redis最多的10种场景

发表于 2024-01-18

前言

Redis作为一种优秀的基于key/value的缓存,有非常不错的性能和稳定性,无论是在工作中,还是面试中,都经常会出现。

今天这篇文章就跟大家一起聊聊,我在实际工作中使用Redis的10种场景,希望对你会有所帮助。

图片

此外,本文Gitee:gitee.com/dvsusan/sus… 已经收录,上面有大厂面试完整考点,工作经验分享,欢迎Star。

  1. 统计访问次数

对于很多官方网站的首页,经常会有一些统计首页访问次数的需求。

访问次数只有一个字段,如果保存到数据库中,再最后做汇总显然有些麻烦。

该业务场景可以使用Redis,定义一个key,比如:OFFICIAL_INDEX_VISIT_COUNT。

在Redis中有incr命令,可以实现给value值加1操作:

1
复制代码incr OFFICIAL_INDEX_VISIT_COUNT

当然如果你想一次加的值大于1,可以用incrby命令,例如:

1
复制代码incrby OFFICIAL_INDEX_VISIT_COUNT 5

这样可以一次性加5。

  1. 获取分类树

在很多网站都有分类树的功能,如果没有生成静态的html页面,想通过调用接口的方式获取分类树的数据。

我们一般为了性能考虑,会将分类树的json数据缓存到Redis当中,为了后面在网站当中能够快速获取数据。

不然在接口中需要使用递归查询数据库,然后拼接成分类树的数据结构。

这个过程非常麻烦,而且需要多次查询数据库,性能很差。

因此,可以考虑用一个定时任务,异步将分类树的数据,直接缓存到Redis当中,定义一个key,比如:MALL_CATEGORY_TREE。

然后接口中直接使用MALL_CATEGORY_TREE这个key从缓存中获取数据即可。

可以直接用key/value字符串保存数据。

不过需要注意的是,如果分类树的数据非常多可能会出现大key的问题,优化方案可以参考我的另外一篇文章《分类树,我从2s优化到0.1s》。

  1. 做分布式锁

分布式锁可能是使用Redis最常见的场景之一,相对于其他的分布式锁,比如:数据库分布式锁或者Zookeeper分布式锁,基于Redis的分布式锁,有更好的性能,被广泛使用于实际工作中。

我们使用下面这段代码可以加锁:

1
2
3
4
5
6
7
8
9
kotlin复制代码try{
  String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime);
  if ("OK".equals(result)) {
      return true;
  }
  return false;
} finally {
    unlock(lockKey);
}

但上面这段代码在有些场景下,会有一些问题,释放锁可能会释放了别人的锁。

说实话Redis分布式锁虽说很常用,但坑也挺多的,如果用不好的话,很容易踩坑。

如果大家对Redis分布式锁的一些坑比较感兴趣,可以看看我的另一篇文章《聊聊redis分布式锁的8大坑》,文章中有非常详细的介绍。

  1. 做排行榜

很多网站有排行榜的功能,比如:商城中有商品销量的排行榜,游戏网站有玩家获得积分的排行榜。

通常情况下,我们可以使用Sorted Set保存排行榜的数据。

使用ZADD可以添加排行榜的数据,使用ZRANGE可以获取排行榜的数据。

例如:

1
2
3
4
arduino复制代码ZADD rank:score 100 "周星驰"
ZADD rank:score 90 "周杰伦"
ZADD rank:score 80 "周润发"
ZRANGE rank:score 0 -1 WITHSCORES

返回数据:

1
2
3
4
5
6
arduino复制代码1) "周星驰"
2) "100"
3) "周杰伦"
4) "90"
5) "周润发"
6) "80"
  1. 记录用户登录状态

通常下,用户登录成功之后,用户登录之后的状态信息,会保存到Redis中。

这样后面该用户访问其他接口的时候,会直接从Redis中查询用户登录状态,如果可以查到数据,说明用户已登录,则允许做后续的操作。

如果从Redis中没有查到用户登录状态,说明该用户没有登录,或者登录状态失效了,则直接跳转到用户登录页面。

使用Redis保存用户登录状态,有个好处是它可以设置一个过期时间,比如:该时间可以设置成30分钟。

1
c复制代码jedis.set(userId, userInfo, 1800);

在Redis内部有专门的job,会将过期的数据删除,也有获取数据时实时删除的逻辑。

  1. 限流

使用Redis还有一个非常常用的的业务场景是做限流。

当然还有其他的限流方式,比如:使用nginx,但使用Redis控制可以更精细。

比如:限制同一个ip,1分钟之内只能访问10次接口,10分钟之内只能访问50次接口,1天之内只能访问100次接口。

如果超过次数,则接口直接返回:请求太频繁了,请稍后重试。

跟上面保存用户登录状态类似,需要在Redis中保存用户的请求记录。

比如:key是用户ip,value是访问的次数从1开始,后面每访问一次则加1。

如果value超过一定的次数,则直接拦截这种异常的ip。

当然也需要设置一个过期时间,异常ip如果超过这个过期时间,比如:1天,则恢复正常了,该ip可以再发起请求了。

或者限制同一个用户id。

  1. 位统计

比如现在有个需求:有个网站需要统计一周内连续登陆的用户,以及一个月内登陆过的用户。

这个需求使用传统的数据库,实现起来比较麻烦,但使用Redis的bitmap让我们可以实时的进行类似的统计。

bitmap 是二进制的byte数组,也可以简单理解成是一个普通字符串。它将二进制数据存储在byte数组中以达到存储数据的目的。

保存数据命令使用setbit,语法:

1
sql复制代码setbit key offset value

具体示例:

1
sql复制代码setbit user:view:2024-01-17 123456 1

往bitmap数组中设置了用户id=123456的登录状态为1,标记2024-01-17已登录。

然后通过命令getbit获取数据,语法:

1
sql复制代码getbit key offset

具体示例:

1
sql复制代码getbit user:view:2024-01-17 123456

如果获取的值是1,说明这一天登录了。

如果我们想统计一周内连续登录的用户,只需要遍历用户id,根据日期中数组中去查询状态即可。

最近就业形式比较困难,为了感谢各位小伙伴对苏三一直以来的支持,我特地创建了一些工作内推群, 看看能不能帮助到大家。

你可以在群里发布招聘信息,也可以内推工作,也可以在群里投递简历找工作,也可以在群里交流面试或者工作的话题。

图片

进群方式

添加,苏三的私人微信:su_san_java,备注:内推+所在城市,即可加入。

  1. 缓存加速

我们在工作中使用Redis作为缓存加速,这种用法也是非常常见的。

如果查询订单数据,先从Redis缓存中查询,如果缓存中存在,则直接将数据返回给用户。

如果缓存中不存在,则再从数据库中查询数据,如果数据存在,则将数据保存到缓存中,然后再返回给用户。

如果缓存和数据库都不存在,则直接给用户返回数据不存在。

流程图如下:图片)但使用缓存加速的业务场景,需要注意一下,可能会出现:缓存击穿、穿透和雪崩等问题,感兴趣的小伙伴,可以看看我的另一篇文章《烂大街的缓存穿透、缓存击穿和缓存雪崩,你真的懂了?》,里面有非常详细的介绍。

  1. 做消息队列

我们说起队列经常想到是:kafka、rabbitMQ、RocketMQ等这些分布式消息队列。

其实Redis也有消息队列的功能,我们之前有个支付系统,就是用的Redis队列功能。

PubSub(发布订阅)是Redis2.0版本引入的消息传递模型。

顾名思义,消费者可以订阅一个或多个channel,生产者向对应channel发送消息后,所有订阅者都能收到相关消息。对应channel发送消息后,所有订阅者都能收到相关消息。

在java代码中可以实现MessageListener接口,来消费队列中的消息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
typescript复制代码@Slf4j
@Component
public class RedisMessageListenerListener implements MessageListener {
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    @Override
    public void onMessage(Message message, byte[] pattern) {
        String channel = new String(pattern);
        RedisSerializer<?> valueSerializer = redisTemplate.getValueSerializer();
        Object deserialize = valueSerializer.deserialize(message.getBody());
        if (deserialize == null) return;
        String md5DigestAsHex = DigestUtils.md5DigestAsHex(deserialize.toString().getBytes(StandardCharsets.UTF_8));
        Boolean result = redisTemplate.opsForValue().setIfAbsent(md5DigestAsHex, "1", 20, TimeUnit.SECONDS);
        if (Boolean.TRUE.equals(result)) {
            log.info("接收的结果:{}", deserialize.toString());
        } else {
            log.info("其他服务处理中");
        }
    }
}
  1. 生成全局ID

在有些需要生成全局ID的业务场景,其实也可以使用Redis。

可以使用incrby命令,利用原子性操作,可以执行下面这个命令:

1
复制代码incrby userid 10000

在分库分表的场景,对于有些批量操作,我们可以从Redis中,一次性拿一批id出来,然后给业务系统使用。

最后说一句(求关注,别白嫖我)

如果这篇文章对您有所帮助,或者有所启发的话,帮忙扫描下发二维码关注一下,您的支持是我坚持写作最大的动力。

求一键三连:点赞、转发、在看。

关注公众号:【苏三说技术】,在公众号中回复:面试、代码神器、开发手册、时间管理有超赞的粉丝福利,另外回复:加群,可以跟很多BAT大厂的前辈交流和学习。

本文转载自: 掘金

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

乖乖,咱不用BeanUtilcopy了,咱试试这款神级工具

发表于 2024-01-18

引言

在现代Java应用程序开发中,处理对象之间的映射是一个常见而且必不可少的任务。随着项目规模的增长,手动编写繁琐的映射代码不仅耗时且容易出错,因此开发者们一直在寻找更高效的解决方案。比如基于Dozer封装的或者Spring自带的BeanUtil.copyProperties对应对象之间的属性拷贝。但是Dozer采用运行时映射的方式,通过反射在运行时动态生成映射代码。这意味着在每次映射时都需要进行反射操作,Dozer在处理复杂映射时可能需要额外的配置和自定义转换器,可能导致一定的性能开销,尤其在大型项目中可能表现不佳。另外在处理处理复杂映射(例如字段名称不一致,某些字段不需要映射)时可能需要额外的配置和自定义转换器,使用起来并不是那么的便捷。那么此时MapStruct变应用而生,成为简化Java Bean映射的利器。

MapStruct是一款基于注解和编译时代码生成的工具,旨在简化Java Bean之间的映射过程。通过在编译时生成高效的映射代码,避免了运行时的性能开销,使得映射过程更加高效。MapStruct不仅消除了手写映射代码的痛苦,还提供了性能优势。它支持在Java Bean之间进行映射,并通过使用注解标记映射方法和类,提供了一种声明性的方式定义映射规则,简化了映射代码的编写。使得开发者能够专注于业务逻辑而不必过多关注对象之间的转换。并且它还支持自定义转换器和表达式,适用于处理各种复杂的映射场景。

下面我们就开始介绍如何使用MapStruct来高效的完成对象之间的映射。

如何MapStruct使用

使用MapStruct进行Java Bean映射通常包括几个基本步骤,包括项目配置、注解标记、自定义转换器等。以下是详细的使用步骤:

1、依赖

1
2
3
4
5
xml复制代码<dependency>
    <groupId>org.mapstruct</groupId>
    <artifactId>mapstruct</artifactId>
    <version>1.5.5.Final</version>
</dependency>

同时在 pom.xml 需要正确配置MapStruct的依赖和注解处理器插件。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
xml复制代码<build>  
    <plugins>        
        <plugin>            
            <groupId>org.apache.maven.plugins</groupId>  
            <artifactId>maven-compiler-plugin</artifactId>  
            <configuration>                 
                <annotationProcessorPaths>                   
                    <path>                        
                        <groupId>org.mapstruct</groupId>  
                        <artifactId>mapstruct-processor</artifactId>  
                        <version>1.5.5.Final</version>  
                    </path>                    
                    <path>                        
                        <groupId>org.projectlombok</groupId>  
                        <artifactId>lombok</artifactId>  
                        <version>1.18.22</version>  
                    </path>                    
                    <path>                        
                        <groupId>org.projectlombok</groupId>  
                        <artifactId>lombok-mapstruct-binding</artifactId>  
                        <version>0.2.0</version>  
                    </path>                
                </annotationProcessorPaths>            
            </configuration>        
        </plugin>    
    </plugins>
</build>

当然如果你同时使用了lombok,也需要同时配置lombok编译生成代码的插件。

2、创建映射接口

创建一个Java接口,并使用@Mapper注解标记它。例如:

1
2
3
4
5
6
7
java复制代码@Mapper
public interface MyMapper {
    MyMapper INSTANCE = Mappers.getMapper(MyMapper.class);

    TargetObject sourceToTarget(SourceObject source);
    // 定义其他映射方法
}

上述代码定义了一个映射接口MyMapper,其中有一个映射方法sourceToTarget用于将SourceObject映射为TargetObject。INSTANCE字段用于获取映射器的实例。

此时我们编译项目之后,可以看见生成的MyMapper实现类中的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
ini复制代码@Override  
public TargetObject sourceToTarget(SourceObject source) {  
    if ( source == null ) {  
        return null;  
    }  

    TargetObject targetObject = new TargetObject();  

    targetObject.setUserName( source.getUserName() );  
    targetObject.setUserId( source.getUserId() );  
    targetObject.setSex( source.getSex() );  

    return targetObject;  
}

这样就省去了我们自己手写两个对象之间的字段映射,避免了大量的重复工作,大大增加了开发效率,其次也是最重要的一点就是我们可以很直观的看见两个对象之间的字段映射关系,不像Dozer那样每次基于反射区实现映射,我们无法看见两边的字段的映射,出现问题后不方便排查,功能上不可控。

很重要的一点提示:我们要养成在写完一个映射方法后,要养成一定一定提前编译看一下生成的实现类方法是否正确,同时也看看是否存在字段映射关系设置错误导致编译不通过。

3、映射接口使用

在业务代码或者其他代码方法中,我们可以直接使用MyConverter.INSTANCE.sourceToTarget(source)进行source与target之间的转换。

1
2
3
bash复制代码TargetObject handleObject(SourceObject source){  
    return MyConverter.INSTANCE.sourceToTarget(source);  
}

怎么样?是不是很简单。接下来让我们继续介绍MapStruct的详细功能,揭开它神秘的面纱。。。。。

MapStruct常用注解

了解MapStruct的注解及其属性是非常重要的,因为它们定义了映射规则和行为。以下是MapStruct中常用的注解及其属性:

1.@Mapper

用于标记一个接口或抽象类,用于定义对象之间的映射规则。它有多个属性可以配置映射器的功能。以下是 @Mapper 注解的一些常用属性:

1.1 componentModel

指定生成的映射器实例的组件模型,以便与应用框架集成。他有”default”(默认值)、”cdi”、”spring”等可选值(具体参考MappingConstants.ComponentModel)。我们着重介绍一下default以及spring:

  • default:MapStruct的默认组件模型

在默认模式下,MapStruct 会生成一个无参数的构造函数的映射器实例。映射器实例的创建和管理由 MapStruct自动处理。实例通常通过 Mappers.getMapper(Class)获取。适用于简单的映射场景,无需额外的依赖注入或容器管理。

  • spring:使用Spring Framework的组件模型

在 Spring 模式下,MapStruct 会生成一个使用 @Component 注解标记的映射器实例,从而允许通过 Spring 的 IoC 容器进行管理和依赖注入。适用于 Spring 框架中的应用,可以利用 Spring 的依赖注入功能。稍后我们会介绍这种模型的使用,也是我们日常使用SpringBoot开发时用的比较多的模型。比如上例中,我们使用spring的模型,则生成的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
java复制代码@Component  
public class MySpringMapperImpl implements MySpringMapper {  

    @Override  
    public TargetObject sourceToTarget(SourceObject source) {  
        if ( source == null ) {  
            return null;  
        }  

        TargetObject targetObject = new TargetObject();  

        targetObject.setUserName( source.getUserName() );  
        targetObject.setUserId( source.getUserId() );  
        targetObject.setSex( source.getSex() );  

        return targetObject;  
    }  
}

可以看见实现类中自动加上了@Component,注入到Spring的容器中管理。

  • cdi:使用 Contexts and Dependency Injection (CDI) 的组件模型。

在 CDI 模式下,MapStruct 会生成一个使用 @Dependent 注解标记的映射器实例,允许通过 CDI 容器进行管理和依赖注入。适用于Java EE 或Jakarta EE中使用 CDI 的应用,可以利用 CDI 容器进行管理。

其余的大家感兴趣的可以去阅读源码,平时使用不多,这里就不过多介绍了。

1.2 uses

指定映射器使用的自定义转换器。自定义转换器是在映射过程中调用的方法,用于处理特定类型之间的自定义映射逻辑。如果我们两个对象之间有一个字段的属性值需要特殊处理之后在进行映射,即需要加上一些转换逻辑,我们就可以自定义一个转换器,然后在映射器中使用转换器中的方法。例如:SoureObject中的有一个枚举值,但是转换到TargetObject中时需要转换为具体的说明,那么此时我们就可以使用自定义转换器。

我们自定义一个转换器,并且定义一个转换方法:

1
2
3
4
5
6
7
kotlin复制代码public class MyConverter {  

    @Named("convertSexDesc")  
    public String convertSexDesc(Integer sex){  
        return SexEnum.descOfCode(sex);  
    }  
}

然后再映射器MyMapper中使用uses指定转换器,同时使用@Mapping注解指定两个字段的映射规则:

1
2
3
4
5
6
7
8
ini复制代码@Mapper(uses = {MyConverter.class})  
public interface MyMapper {  

    MyMapper INSTANCE = Mappers.getMapper(MyMapper.class);  

    @Mapping(target = "sex", source = "sex", qualifiedByName = "convertSexDesc")  
    TargetObject sourceToTarget(SourceObject source);  
}

编译后可以看见实现类中生成的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
java复制代码public class MyMapperImpl implements MyMapper {  

    private final MyConverter myConverter = new MyConverter();  

    @Override  
    public TargetObject sourceToTarget(SourceObject source) {  
        if ( source == null ) {  
            return null;  
        }  

        TargetObject targetObject = new TargetObject();  

        targetObject.setSex( myConverter.convertSexDesc( source.getSex() ) );  
        targetObject.setUserName( source.getUserName() );  
        targetObject.setUserId( source.getUserId() );  

        return targetObject;  
    }  
}

当然假如你的转换器或者转换方法,是你这个映射器独有,其他映射器不会使用这个转换方法,那么你可以直接在MyMapper中定义一个default的转换方法,就不必使用uses引入转换器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
kotlin复制代码@Mapper  
public interface MyMapper {  

    MyMapper INSTANCE = Mappers.getMapper(MyMapper.class);  

    @Mapping(target = "sex", source = "sex", qualifiedByName = "convertSexDesc")  
    TargetObject sourceToTarget(SourceObject source);  


    @Named("convertSexDesc")  
    default String convertSexDesc(Integer sex){  
        return SexEnum.descOfCode(sex);  
    }  
}

编译后生成的实现类中,直接可以调用到这个方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
java复制代码public class MyMapperImpl implements MyMapper {  

    @Override  
    public TargetObject sourceToTarget(SourceObject source) {  
        if ( source == null ) {  
            return null;  
        }  

        TargetObject targetObject = new TargetObject();  

        targetObject.setSex( convertSexDesc( source.getSex() ) );  
        targetObject.setUserName( source.getUserName() );  
        targetObject.setUserId( source.getUserId() );  

        return targetObject;  
    }  
}

在Java中,接口可以包含默认方法(Default Methods)。默认方法是在接口中提供一个默认的实现,这样在接口的实现类中就不需要强制性地实现该方法了。默认方法使用关键字 default 进行声明。

1.3 imports

导入其他类的全限定名,使其在生成的映射器接口中可见。比如我们可以导入其他的工具类去处理我们的字段,例如:StringUtils, CollectionUtils,MapUtils,或者一些枚举类等。同常运用@Mapping中的expression上。

1
2
3
4
5
6
7
8
java复制代码@Mapper(imports = {StringUtils.class, SexEnum.class})  
public interface MyMapper {  

    MyMapper INSTANCE = Mappers.getMapper(MyMapper.class);  

    @Mapping(target = "sex",  expression = "java(SexEnum.descOfCode(source.getSex()))")  
    TargetObject sourceToTarget(SourceObject source);
}

编译后生成的实现类中直接import了imports中定义的类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
java复制代码import com.springboot.code.mapstruct.SexEnum;
import org.springframework.util.StringUtils;

public class MyMapperImpl implements MyMapper {  

    @Override  
    public TargetObject sourceToTarget(SourceObject source) {  
        if ( source == null ) {  
            return null;  
        }  

        TargetObject targetObject = new TargetObject();  

        targetObject.setUserName( source.getUserName() );  
        targetObject.setUserId( source.getUserId() );  

        targetObject.setSex( SexEnum.descOfCode(source.getSex()) );  

        return targetObject;  
    }  
}

当然我们也可以不使用imports去导入其他的类,那我们在使用这些类的方法时,必须写上他们的全路径:

1
2
3
4
5
6
7
8
java复制代码@Mapper  
public interface MyMapper {  

    MyMapper INSTANCE = Mappers.getMapper(MyMapper.class);  

    @Mapping(target = "sex",  expression = "java(com.springboot.code.mapstruct.SexEnum.descOfCode(source.getSex()))")  
    TargetObject sourceToTarget(SourceObject source);
}

编译后生成的实现类中,就不会import类了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
java复制代码public class MyMapperImpl implements MyMapper {  

    @Override  
    public TargetObject sourceToTarget(SourceObject source) {  
        if ( source == null ) {  
            return null;  
        }  

        TargetObject targetObject = new TargetObject();  

        targetObject.setUserName( source.getUserName() );  
        targetObject.setUserId( source.getUserId() );  

        targetObject.setSex( com.springboot.code.mapstruct.SexEnum.descOfCode(source.getSex()) );  

        return targetObject;  
    }  
}
1.4 config

config 属性允许你指定一个映射器配置类,该配置类用于提供全局的配置选项。通过配置类,你可以定义一些全局行为,例如处理 null 值的策略、映射器名称、映射器组件模型等。

我们使用@MapperConfig定义一个映射器配置类 MyMapperConfig:

1
2
3
4
5
6
7
8
ini复制代码@MapperConfig(  
        nullValueCheckStrategy = NullValueCheckStrategy.ALWAYS,  
        componentModel = "default",  
        uses = MyConverter.class,  
        unmappedTargetPolicy = org.mapstruct.ReportingPolicy.WARN  
)  
public interface MyMapperConfig {  
}

然后再MyMapper中指定config:

1
2
3
4
5
6
7
8
ini复制代码@Mapper(config = MyMapperConfig.class)  
public interface MyMapper {  

    MyMapper INSTANCE = Mappers.getMapper(MyMapper.class);  

    @Mapping(target = "sex", source = "sex", qualifiedByName = "convertSexDesc")  
    TargetObject sourceToTarget(SourceObject source);
 }

我们可以集中管理映射器的一些全局行为,而不需要在每个映射器中重复配置。

在实际应用中,你可以根据项目需求定义不同的映射器配置类,用于管理不同的全局配置选项。这有助于提高代码的组织性和可维护性。

1.5 nullValueCheckStrategy

用于指定映射器对源对象字段的null值进行检查的策略。检查策略枚举类NullValueCheckStrategy值如下:

  • ALWAYS:始终对源值进行NULL检查。

生成的实现类中,都是源值进行判NULL:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
java复制代码@Override  
public TargetObject sourceToTarget(SourceObject source) {  
    if ( source == null ) {  
        return null;  
    }  

    TargetObject targetObject = new TargetObject();  

    if ( source.getSex() != null ) {  
        targetObject.setSex( myConverter.convertSexDesc( source.getSex() ) );  
    }  
    if ( source.getUserName() != null ) {  
        targetObject.setUserName( source.getUserName() );  
    }  
    if ( source.getUserId() != null ) {  
        targetObject.setUserId( source.getUserId() );  
    }  

    return targetObject;  
}
  • ON_IMPLICIT_CONVERSION:不检查NULL值,直接将源值赋值给目标值

除了上述的属性值之外,还有一些其他的属性值,例如:

  • unmappedSourcePolicy: 未映射源对象字段的处理策略。
  • unmappedTargetPolicy: 未映射目标对象字段的处理策略。

可选值:ReportingPolicy.IGNORE(忽略未映射字段,默认)、ReportingPolicy.WARN(警告)、ReportingPolicy.ERROR(抛出错误)。

以及其他的一些属性值,如果需要用到的同学,可以看一下源码中的介绍,这里就不过多叙述了。

2.@MapperConfig

注解用于定义映射器配置类,它允许在一个单独的配置类中集中管理映射器的全局配置选项。可以将一些全局的配置选项集中在一个配置类中,使得映射器的配置更为清晰和可维护。在实际应用中,可以根据需要定义不同的映射器配置类,以便在不同的场景中使用。配置类可以在映射器中通过@Mapper的config属性引入。它大部分的属性值跟@Mapper一致。

1
2
3
4
5
6
7
8
ini复制代码@MapperConfig(  
        nullValueCheckStrategy = NullValueCheckStrategy.ON_IMPLICIT_CONVERSION,  
        componentModel = "default",  
        uses = MyConverter.class,  
        unmappedTargetPolicy = org.mapstruct.ReportingPolicy.WARN  
)  
public interface MyMapperConfig {  
}

然后再MyMapper中指定config:

1
2
3
4
5
6
7
8
ini复制代码@Mapper(config = MyMapperConfig.class)  
public interface MyMapper {  

    MyMapper INSTANCE = Mappers.getMapper(MyMapper.class);  

    @Mapping(target = "sex", source = "sex", qualifiedByName = "convertSexDesc")  
    TargetObject sourceToTarget(SourceObject source);
 }

3.@Mapping

用于自定义映射器方法中的映射规则。它允许你指定源对象和目标对象之间字段的映射关系。

3.1 source`和 target
  • source 含义: 源对象字段的名称或表达式。
  • target 含义: 目标对象字段的名称。
1
2
ini复制代码@Mapping(target = "sourceField", source = "sourceField")  
TargetObject sourceToTarget(SourceObject source);

或者使用表达式的方式:

1
2
ini复制代码@Mapping(expression = "java(source.getSourceField())", target = "targetField")
TargetObject sourceToTarget(SourceObject source);
3.2 qualifiedByName 和 qualifiedBy
  • qualifiedByName: 指定使用自定义转换器方法进行映射。

定义一个转换器MyNameConverter:

1
2
3
4
5
6
7
typescript复制代码public class MyNameConverter {  

    @Named("convertUserName")  
    public String convertUserName(String userName){  
        return Optional.ofNullable(userName).map(String::toUpperCase).orElse(userName);  
    }  
}

使用自定义转换器的方法:

1
2
3
4
5
6
7
ini复制代码@Mapper( uses = {MyNameConverter.class}, nullValueCheckStrategy = NullValueCheckStrategy.ALWAYS)  
public interface MyMapper {  

    MyMapper INSTANCE = Mappers.getMapper(MyMapper.class);  

    @Mapping(target = "userName", source = "userName", qualifiedByName = "convertUserName")  
    TargetObject sourceToTarget(SourceObject source);
  • qualifiedBy: 指定使用基于@qualifier注解的转换方法

先定义一个基于@qualifier(mapstruct包下)的作用于转换器类上的注解@StrConverter:

1
2
3
4
5
less复制代码@Qualifier  
@Target(ElementType.TYPE)  
@Retention(RetentionPolicy.CLASS)  
public @interface StrConverter {  
}

再定义一个基于@qualifier(mapstruct包下)的作用于转换器方法上的注解@NameUpper:

1
2
3
4
5
less复制代码@Qualifier  
@Target(ElementType.METHOD)  
@Retention(RetentionPolicy.CLASS)  
public @interface NameUpper {  
}

最后定义一个自定义转换器MyNameConverter:

1
2
3
4
5
6
7
8
9
typescript复制代码@StrConverter  
public class MyNameConverter {  


    @NameUpper  
    public String convertUserName(String userName){  
        return Optional.ofNullable(userName).map(String::toUpperCase).orElse(userName);  
    }  
}

然后我们在@Mappinbg中通过使用:

1
2
3
4
5
kotlin复制代码@Mapper(uses = {MyNameConverter.class} ,nullValueCheckStrategy = NullValueCheckStrategy.ALWAYS)  
public interface MyMapper {
    @Mapping(target = "userName", source = "userName", qualifiedBy = NameUpper.class) 
    TargetObject sourceToTarget(SourceObject source);
}

最终两种方式编译后的结果是一致的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
java复制代码public class MyMapperImpl implements MyMapper {  

    private final MyNameConverter myNameConverter = new MyNameConverter();  

    @Override  
    public TargetObject sourceToTarget(SourceObject source) {  
        if ( source == null ) {  
            return null;  
        }  

        TargetObject targetObject = new TargetObject();  

        if ( source.getUserName() != null ) {  
            targetObject.setUserName( myNameConverter.convertUserName( source.getUserName() ) );  
        }  
        if ( source.getUserId() != null ) {  
            targetObject.setUserId( source.getUserId() );  
        }  

        return targetObject;  
    }  
}

以上基于qualifiedBy的使用示例参考自@Qualifier源码文档。

3.3 ignore

是否忽略某字段的映射。为true时忽略。

1
2
ini复制代码@Mapping(target = "sex", source = "sex", ignore = true)
TargetObject sourceToTarget(SourceObject source);

编译后实现类中不会对这个字段进行赋值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
java复制代码@Override  
public TargetObject sourceToTarget(SourceObject source) {  
    if ( source == null ) {  
        return null;  
    }  

    TargetObject targetObject = new TargetObject();  

    if ( source.getUserName() != null ) {  
        targetObject.setUserName( source.getUserName() );  
    }  
    if ( source.getUserId() != null ) {  
        targetObject.setUserId( source.getUserId() );  
    }  

    return targetObject;  
}
3.4 defaultExpression

指定默认表达式,当源对象字段为 null 时使用。

1
2
ini复制代码@Mapping(target = "sex", source = "sex", defaultExpression = "java(SexEnum.MAN.desc)")
TargetObject sourceToTarget(SourceObject source);

编译后实现类:

1
2
3
4
5
6
javascript复制代码 if ( source.getSex() != null ) {  
        targetObject.setSex( String.valueOf( source.getSex() ) );  
  }  
  else {  
    targetObject.setSex( SexEnum.MAN.desc );  
  }

defaultExpression不能与expression,defaultValue,constant一起使用。

3.5 defaultValue

指定默认值,当源对象字段为 null 时使用。

1
2
ini复制代码@Mapping(target = "sex", source = "sex", defaultValue = "男人")  
TargetObject sourceToTarget(SourceObject source);

编译后:

1
2
3
4
5
6
javascript复制代码if ( source.getSex() != null ) {  
    targetObject.setSex( String.valueOf( source.getSex() ) );  
}  
else {  
    targetObject.setSex( "男人" );  
}

defaultValue不能与expression,defaultExpression,constant一起使用。

3.6 constant

将目标对象的字段设置为该常量。不从源对象中映射值。

1
2
ini复制代码@Mapping(target = "source", constant = "API")  
TargetObject sourceToTarget(SourceObject source);

编译后:

1
arduino复制代码targetObject.setSource( "API" );

constant不能与defaultExpression,expression,defaultValue,constant, source一起使用。

3.7 expression

通过表达式完成映射。要基于该字符串设置指定的目标属性。目前,Java 是唯一受支持的“表达式语言”,表达式必须使用以下格式以 Java 表达式的形式给出:java(<EXPRESSION>)。

1
2
java复制代码@Mapping(target = "sex", expression = "java(SexEnum.descOfCode(source.getSex()))")  
TargetObject sourceToTarget(SourceObject source);

编译后:

1
less复制代码targetObject.setSex( SexEnum.descOfCode(source.getSex()) );

expression不能与source, defaultValue, defaultExpression, qualifiedBy, qualifiedByName 以及constant 一起使用

3.8 dateFormat

指定日期格式化模式,仅适用于日期类型的字段。可以实现String类型时间和Date相互转换,基于SimpleDateFormat实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
kotlin复制代码@Data  
public class TargetObject {
    private String createTime;  

    private Date loginDate;
}

@Data  
public class SourceObject {
    private Date createTime;  

    private String loginDate;
}


@Mapping(target = "createTime", source = "createTime", dateFormat = "yyyy-MM-dd HH:mm:ss")  
@Mapping(target = "loginDate", source = "loginDate", dateFormat = "yyyy-MM-dd")  
TargetObject sourceToTarget(SourceObject source);

编译后:

1
2
3
4
5
6
7
8
9
10
11
vbscript复制代码if ( source.getCreateTime() != null ) {  
    targetObject.setCreateTime( new SimpleDateFormat( "yyyy-MM-dd HH:mm:ss" ).format( source.getCreateTime() ) );  
}  
try {  
    if ( source.getLoginDate() != null ) {  
        targetObject.setLoginDate( new SimpleDateFormat( "yyyy-MM-dd" ).parse( source.getLoginDate() ) );  
    }  
}  
catch ( ParseException e ) {  
    throw new RuntimeException( e );  
}
3.9 numberFormat

指定数值格式化格式,仅适用Number类型的字段。可以实现String类型数值与Number相互转换,基于DecimalFormat实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
kotlin复制代码@Data  
public class TargetObject {
    private double amountDouble;  

    private String amountStr;
}

@Data  
public class SourceObject {
    private String amountStr;  

    private double amountDouble;
}

@Mapping(target = "amountDouble", source = "amountStr", numberFormat = "#,###.00")  
@Mapping(target = "amountStr", source = "amountDouble", numberFormat = "#,###.00")  
TargetObject sourceToTarget(SourceObject source);

编译后:

1
2
3
4
5
6
7
8
9
less复制代码try {  
    if ( source.getAmountStr() != null ) {  
        targetObject.setAmountDouble( new DecimalFormat( "#,###.00" ).parse( source.getAmountStr() ).doubleValue() );  
    }  
}  
catch ( ParseException e ) {  
    throw new RuntimeException( e );  
}  
targetObject.setAmountStr( new DecimalFormat( "#,###.00" ).format( source.getAmountDouble() ) );

还有其他的属性,这里就不过多叙述了,有兴趣或者需要的可以阅读源码。

4.@Mappings

包含多个@Mapping注解,将多个字段映射规则组合在一起,使代码更清晰。

1
2
3
4
5
6
7
8
9
less复制代码@Mappings({  
        @Mapping(target = "source", constant = "API"),  
        @Mapping(target = "sex", expression = "java(SexEnum.descOfCode(source.getSex()))"),  
        @Mapping(target = "createTime", source = "createTime", dateFormat = "yyyy-MM-dd HH:mm:ss"),  
        @Mapping(target = "loginDate", source = "loginDate", dateFormat = "yyyy-MM-dd"),  
        @Mapping(target = "amountDouble", source = "amountStr", numberFormat = "#,###.00"),  
        @Mapping(target = "amountStr", source = "amountDouble", numberFormat = "#,###.00")  
})  
TargetObject sourceToTarget(SourceObject source);

5.@Named

用于标记自定义转换器或者映射器中的某个方法的名称。一般配合qualifiedByName 使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
arduino复制代码/**
* 标记映射方法名称
*/
@Named("sourceToTarget")  
TargetObject sourceToTarget(SourceObject source);  

/**
* 标记转换器方法名称
*/
@Named("convertSexDesc")  
default String convertSexDesc(Integer sex){  
    return SexEnum.descOfCode(sex);  
}

我们在定义自己的转换器方法时,最好把方法都加上@Named的注解标记你的方法名称,否则如果后续代码中再写一个同类型的不同方法名的转换方法时编译报错:不明确的映射方法。

image.png

image.png

6. @IterableMapping

1
go复制代码用于集合映射,定义集合元素的映射规则。其中一些属性例如:`qualifiedByName`,`qualifiedBy`以及`dateFormat`,`numberFormat`参考`@Mapping`中的用法。
1
2
3
4
5
less复制代码@Named("sourceToTarget")  
TargetObject sourceToTarget(SourceObject source);  

@IterableMapping(qualifiedByName = "sourceToTarget")  
List<TargetObject> sourceToTargetList(List<SourceObject> sourceObjectList);

编译后的实现类代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
typescript复制代码@Override  
public List<TargetObject> sourceToTargetList(List<SourceObject> sourceObjectList) {  
    if ( sourceObjectList == null ) {  
        return null;  
    }  

    List<TargetObject> list = new ArrayList<TargetObject>( sourceObjectList.size() );  
    for ( SourceObject sourceObject : sourceObjectList ) {  
        list.add( sourceToTarget( sourceObject ) );  
    }  

    return list;  
}

可看出它内部循环调用sourceToTarget的方法完成list的转换。

需要特别注意,在写集合类型的转换时一定要配合IterableMapping的qualifiedByName和Named使用,如果不使用@IterableMapping中显示声明循环使用的方法时,它的内部会重新生成一个映射方法去使用。这样会在开发过程中出现一些莫名其妙的忽然就不好使的错误。。。。。

1
2
3
4
5
6
7
8
less复制代码    @Named("sourceToTarget")  
    TargetObject sourceToTarget(SourceObject source);  

    @Named("sourceToTarget2")  
    TargetObject sourceToTarget2(SourceObject source);  

//    @IterableMapping(qualifiedByName = "sourceToTarget")  
    List<TargetObject> sourceToTargetList(List<SourceObject> sourceObjectList);

编译后,实现类中代码可以看出并没有使用以上两个方法,而是重新生成的:

image.png

image.png

image.png

image.png

7.@MappingTarget

标记在映射方法的目标对象参数上,允许在映射方法中修改目标对象的属性。当目标对象已经创建了,此时可以将目标对象也当做参数传递到映射器方法中。

1
2
3
4
less复制代码@Mapping(target = "source", constant = "API")  
@Mapping(target = "sex", expression = "java(SexEnum.descOfCode(source.getSex()))")  
@Named("sourceToTarget3")
void sourceToTarget3(@MappingTarget TargetObject targetObject, SourceObject source);

编译后实现类代码:

1
2
3
4
5
6
7
8
9
10
11
12
typescript复制代码@Override  
public void sourceToTarget3(TargetObject targetObject, SourceObject source) {  
    if ( source == null ) {  
        return;  
    }  

    targetObject.setUserName( source.getUserName() );  
    targetObject.setUserId( source.getUserId() );  

    targetObject.setSource( "API" );  
    targetObject.setSex( SexEnum.descOfCode(source.getSex()) );  
}

8.@InheritConfiguration

它用于在映射接口中引用另一个映射方法的配置。主要用于减少代码重复,提高映射方法的可维护性。

1
2
3
4
5
6
7
8
9
10
11
12
13
less复制代码    @Mappings({  
            @Mapping(target = "source", constant = "API"),  
            @Mapping(target = "sex", expression = "java(SexEnum.descOfCode(source.getSex()))")
    })  
    @Named("sourceToTarget")  
    TargetObject sourceToTarget(SourceObject source);

    @InheritConfiguration(name = "sourceToTarget")  
    @Named("sourceToTarget2")  
    TargetObject sourceToTarget2(SourceObject source);

    @InheritConfiguration(name = "sourceToTarget")  
    void sourceToTarget4(@MappingTarget TargetObject targetObject, SourceObject source);

sourceToTarget2和sourceToTarget4就可以直接继承使用sourceToTarget的规则了。避免了再次定义一份相同的规则。

9. @BeanMapping

用于配置映射方法级别的注解,它允许在单个映射方法上指定一些特定的配置。例如忽略某些属性、配置映射条件等(开始我们在@Mapper中定义)。它提供了一种在方法级别自定义映射行为的方式。

1
2
3
less复制代码@BeanMapping(nullValueCheckStrategy = NullValueCheckStrategy.ALWAYS)  
@Named("sourceToTarget2")  
TargetObject sourceToTarget2(SourceObject source);

编译后实现的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
typescript复制代码@Override  
public TargetObject sourceToTarget2(SourceObject source) {  
    if ( source == null ) {  
        return null;  
    }  

    TargetObject targetObject = new TargetObject();  

    if ( source.getUserName() != null ) {  
        targetObject.setUserName( source.getUserName() );  
    }  
    if ( source.getUserId() != null ) {  
        targetObject.setUserId( source.getUserId() );  
    }  
    if ( source.getSex() != null ) {  
        targetObject.setSex( String.valueOf( source.getSex() ) );  
    }  

    return targetObject;  
}

校验了源对象值的null

10.@ValueMapping

用于自定义枚举类型或其他可映射类型的值映射。该注解允许在枚举类型映射时,定义自定义的值映射规则,使得在映射中可以转换不同的枚举值。他只有两个属性值:

  • source:只能取值:枚举值名称,MappingConstants.NULL,MappingConstants.ANY_REMAINING,MappingConstants.ANY_UNMAPPED
  • target: 只能取值:枚举值名称MappingConstants.NULL,MappingConstants.ANY_UNMAPPED
1
2
3
4
5
6
7
8
9
10
11
less复制代码  public enum OrderType { RETAIL, B2B, C2C, EXTRA, STANDARD, NORMAL }

  public enum ExternalOrderType { RETAIL, B2B, SPECIAL, DEFAULT }

  @ValueMappings({  
        @ValueMapping(target = "SPECIAL", source = "EXTRA"),  
        @ValueMapping(target = "DEFAULT", source = "STANDARD"),  
        @ValueMapping(target = "DEFAULT", source = "NORMAL"),  
        @ValueMapping(target = MappingConstants.THROW_EXCEPTION, source = "C2C" )  
})  
ExternalOrderTypeEnum mapOrderType(OrderTypeEnum orderType);

编译后实现类代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
ini复制代码@Override  
public ExternalOrderTypeEnum mapOrderType(OrderTypeEnum orderType) {  
    if ( orderType == null ) {  
        return null;  
    }  

    ExternalOrderTypeEnum externalOrderTypeEnum;  

    switch ( orderType ) {  
        case EXTRA: externalOrderTypeEnum = ExternalOrderTypeEnum.SPECIAL;  
        break;  
        case STANDARD: externalOrderTypeEnum = ExternalOrderTypeEnum.DEFAULT;  
        break;  
        case NORMAL: externalOrderTypeEnum = ExternalOrderTypeEnum.DEFAULT;  
        break;  
        case C2C: throw new IllegalArgumentException( "Unexpected enum constant: " + orderType );  
        case RETAIL: externalOrderTypeEnum = ExternalOrderTypeEnum.RETAIL;  
        break;  
        case B2B: externalOrderTypeEnum = ExternalOrderTypeEnum.B2B;  
        break;  
        default: throw new IllegalArgumentException( "Unexpected enum constant: " + orderType );  
    }  

    return externalOrderTypeEnum;  
}

11.@Context

@Context注解在MapStruct框架中用于标记映射方法的参数,使得这些参数作为映射上下文来处理。被标注为@Context的参数会在适用的情况下传递给其他映射方法、@ObjectFactory方法或者@BeforeMapping和@AfterMapping方法,从而可以在自定义代码中使用它们。

具体作用如下:

  • 传递上下文信息: 当MapStruct执行映射操作时,它会将带有@Context注解的参数值向下传递到关联的方法中。这意味着你可以在不同的映射阶段(包括属性映射、对象工厂方法调用以及映射前后的处理方法)共享和利用这些上下文数据。
  • 调用相关方法: MapStruct还会检查带有@Context注解的参数类型上是否声明了@BeforeMapping或@AfterMapping方法,并在适用时对提供的上下文参数值调用这些方法。
  • 空值处理: 注意,MapStruct不会在调用与@Context注解参数相关的映射前后方法或对象工厂方法之前进行空值检查。调用者需要确保在这种情况下不传递null值。
  • 生成代码的要求: 为了使生成的代码能够正确调用带有@Context参数的方法,正在生成的映射方法声明必须至少包含那些相同类型(或可赋值类型)的@Context参数。MapStruct不会为缺失的@Context参数创建新实例,也不会以null代替它们传递。

因此,@Context注解提供了一种机制,允许开发者在映射过程中携带并传播额外的状态或配置信息,增强了映射逻辑的灵活性和定制能力。

一个简单的用法示例:

1
2
3
4
5
6
7
8
9
less复制代码    @Named("sourceToTarget5")  
    @Mapping(target = "createTime", constant = "createTime", qualifiedByName = "formatDate")  
    TargetObject sourceToTarget5(SourceObject source, @Context ContextObject contextObject);  

    @Named("formatDate")  
    default String formatDate(LocalDateTime createTime, @Context ContextObject contextObject){  
    DateTimeFormatter dateTimeFormatter = contextObject.getDateTimeFormatter();  
    return dateTimeFormatter.format(createTime);  
}

生成的实现类代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
typescript复制代码@Override  
public TargetObject sourceToTarget5(SourceObject source, ContextObject contextObject) {  
    if ( source == null ) {  
        return null;  
    }  

    TargetObject targetObject = new TargetObject();  

    targetObject.setUserName( source.getUserName() );  
    targetObject.setUserId( source.getUserId() );  
    if ( source.getSex() != null ) {  
        targetObject.setSex( String.valueOf( source.getSex() ) );  
    }  

    targetObject.setCreateTime( formatDate( LocalDateTime.parse( "createTime" ), contextObject ) );  

    return targetObject;  
}

12.@BeforeMapping

这个注解可以标注在一个没有返回值的方法上,该方法会在执行实际映射操作前被调用。在此方法中可以通过@Context注入上下文对象,并根据需要对源对象或上下文进行修改或预处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
less复制代码    @Named("sourceToTarget5")  
    @Mapping(target = "createTime", constant = "createTime", qualifiedByName = "formatDate")  
    TargetObject sourceToTarget5(SourceObject source, @Context ContextObject contextObject);  

    @Named("formatDate")  
    default String formatDate(LocalDateTime createTime, @Context ContextObject contextObject){  
        DateTimeFormatter dateTimeFormatter = contextObject.getDateTimeFormatter();  
        return dateTimeFormatter.format(createTime);  
    }  

    @BeforeMapping  
    default void beforeFormatDate(@Context ContextObject context) {  
        // 在映射之前初始化或更新上下文中的信息  
        context.setDateTimeFormatter(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));  
}

编译后生成的实现类代码中,会发现在sourceToTarget5的方法第一行会调用beforeFormatDate这个方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
scss复制代码@Override  
public TargetObject sourceToTarget5(SourceObject source, ContextObject contextObject) {  
    // 第一行调用@BeforeMapping的方法
    beforeFormatDate( contextObject );  

    if ( source == null ) {  
        return null;  
    }  

    TargetObject targetObject = new TargetObject();  

    targetObject.setUserName( source.getUserName() );  
    targetObject.setUserId( source.getUserId() );  
    if ( source.getSex() != null ) {  
        targetObject.setSex( String.valueOf( source.getSex() ) );  
    }  

    targetObject.setCreateTime( formatDate( LocalDateTime.parse( "createTime" ), contextObject ) );  

    return targetObject;  
}

13.@AfterMapping

这个注解同样可以标注在一个没有返回值的方法上,但它会在完成所有属性映射后被调用。你可以在这里执行一些额外的转换逻辑或者基于映射结果和上下文进行后期处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
less复制代码@Named("sourceToTarget5")  
@Mapping(target = "createTime", constant = "createTime", qualifiedByName = "formatDate")  
TargetObject sourceToTarget5(SourceObject source, @Context ContextObject contextObject);  

@Named("formatDate")  
default String formatDate(LocalDateTime createTime, @Context ContextObject contextObject){  
    DateTimeFormatter dateTimeFormatter = contextObject.getDateTimeFormatter();  
    return dateTimeFormatter.format(createTime);  
}  

@BeforeMapping  
default void beforeFormatDate(@Context ContextObject context) {  
// 在映射之前初始化或更新上下文中的信息  
    context.setDateTimeFormatter(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));  
}  

@AfterMapping  
default void afterHandler(SourceObject source, @MappingTarget TargetObject targetObject, @Context ContextObject contextObject){  
    targetObject.setContext(contextObject.getContext());  
}

编译后,可以发现在sourceTarget5的实现方法中的最后会调用afterHandler方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
scss复制代码@Override  
public TargetObject sourceToTarget5(SourceObject source, ContextObject contextObject) {  
    beforeFormatDate( contextObject );  

    if ( source == null ) {  
        return null;  
    }  

    TargetObject targetObject = new TargetObject();  

    targetObject.setUserName( source.getUserName() );  
    targetObject.setUserId( source.getUserId() );  
    if ( source.getSex() != null ) {  
        targetObject.setSex( String.valueOf( source.getSex() ) );  
    }  

    targetObject.setCreateTime( formatDate( LocalDateTime.parse( "createTime" ), contextObject ) );  

    afterHandler( source, targetObject, contextObject );  

    return targetObject;  
}

@BeforeMapping 和 @AfterMapping 注解的方法默认会作用于在同一接口内使用了相同参数类型的映射方法上。如果想要在一个地方定义一个通用的前置或后置处理逻辑,并让它应用于多个映射方法,可以编写一个不带具体映射源和目标参数的方法,并在需要应用这些逻辑的所有映射方法上保持相同的@Context参数类型。

14.@ObjectFactory

此注解用于声明一个工厂方法,该方法在目标对象实例化阶段被调用。这里也可以通过@Context获取到上下文信息,以便在创建目标对象时就考虑到某些上下文依赖。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
less复制代码@Named("sourceToTarget5")  
@Mapping(target = "createTime", constant = "createTime", qualifiedByName = "formatDate")  
TargetObject sourceToTarget5(SourceObject source, @Context ContextObject contextObject);  

@Named("formatDate")  
default String formatDate(LocalDateTime createTime, @Context ContextObject contextObject){  
    DateTimeFormatter dateTimeFormatter = contextObject.getDateTimeFormatter();  
    return dateTimeFormatter.format(createTime);  
}  

@BeforeMapping  
default void beforeFormatDate(@Context ContextObject context) {  
    // 在映射之前初始化或更新上下文中的信息  
    context.setDateTimeFormatter(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));  
}  

@AfterMapping  
default void afterHandler(SourceObject source, @MappingTarget TargetObject targetObject, @Context ContextObject contextObject){  
    targetObject.setContext(contextObject.getContext());  
}  

@ObjectFactory  
default TargetObject createTargetObject(@Context ContextObject contextObject){  
    TargetObject targetObject = new TargetObject();  
    // 根据上下文初始化dto的一些属性  
    targetObject.setContext(contextObject.getContext());  
    return targetObject;  
}

编译后生成的实现类中,会看见TargetObject会通过createTargetObject方法创建:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
scss复制代码@Override  
public TargetObject sourceToTarget5(SourceObject source, ContextObject contextObject) {  
    beforeFormatDate( contextObject );  

    if ( source == null ) {  
    return null;  
    }  

    TargetObject targetObject = createTargetObject( contextObject );  

    targetObject.setUserName( source.getUserName() );  
    targetObject.setUserId( source.getUserId() );  
    if ( source.getSex() != null ) {  
        targetObject.setSex( String.valueOf( source.getSex() ) );  
    }  

    targetObject.setCreateTime( formatDate( LocalDateTime.parse( "createTime" ), contextObject ) );  

    afterHandler( source, targetObject, contextObject );  

    return targetObject;  
}

@ObjectFactory 标记的方法则更具有针对性,它通常用于为特定的目标对象创建实例。如果你定义了一个@ObjectFactory方法且没有指定具体映射方法,则这个工厂方法会作为默认的实例化方式,在所有未明确提供实例化方法的映射目标对象时被调用。

SpringBoot集成

上面我们说到了@Mapper注解以及他的属性componentModel,将该值设置为Spring也就是MappingConstants.ComponentModel.SPRING值时,这个映射器生成的实现类就可以被Spring容器管理,这样就可以在使用时就可以注入到其他组件中了。

1
2
3
4
5
6
7
8
9
less复制代码@Mapper(uses = {MyNameConverter.class}, imports = {SexEnum.class}, componentModel = MappingConstants.ComponentModel.SPRING)  
public interface MyMapper {
    @Mappings({  
    @Mapping(target = "source", constant = "API"),  
    @Mapping(target = "sex", expression = "java(SexEnum.descOfCode(source.getSex()))"),  
    })  
    @Named("sourceToTarget")  
    TargetObject sourceToTarget(SourceObject source);
}

生成的实现类自动加上@Component注解,并将其注册为Spring Bean,:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
java复制代码@Component  
public class MyMapperImpl implements MyMapper {
    @Override  
    public TargetObject sourceToTarget(SourceObject source) {  
        if ( source == null ) {  
            return null;  
        }  

        TargetObject targetObject = new TargetObject();  

        targetObject.setUserName( source.getUserName() );  
        targetObject.setUserId( source.getUserId() );  
        if ( source.getCreateTime() != null ) {  
            targetObject.setCreateTime( DateTimeFormatter.ISO_LOCAL_DATE_TIME.format( source.getCreateTime() ) );  
        }  

        targetObject.setSource( "API" );  
        targetObject.setSex( SexEnum.descOfCode(source.getSex()) );  

        return targetObject;  
    }
}

这样就可以在其他组件中注入MyMapper:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
typescript复制代码@SpringBootTest  
public class SpringbootCodeApplicationTests {
    private MyMapper mapper;

    @Test  
    void testMapper(){  
        TargetObject targetObject = mapper.sourceToTarget(new SourceObject());  
        System.out.println(targetObject.getSex());  
    }

    @Autowired  
    public void setMapper(MyMapper mapper) {  
    this.mapper = mapper;  
}

总结

MapStruct是一个利用注解和编译时代码生成技术的Java Bean映射工具,通过在接口上定义映射规则并自动创建实现类,极大地简化了对象转换过程。相比于手动编写映射代码及运行时反射工具如Dozer,MapStruct提供了更高的性能、更好的可读性和易于维护性。它支持灵活的字段映射配置、自定义转换逻辑,并可通过组件模型适应不同框架,是提升开发效率与降低维护成本的理想对象映射解决方案。

写在最后:可能大家觉得要防御性编程,但是咱可以把编译后实现类的代码CV到你的代码里面就可以了,这样免去了自己手写get,set方法映射,这样不出错,还可以节省时间摸鱼。。。。

本文已收录于我的个人博客:码农Academy的技术博客,专注分享Java技术干货,包括Java基础、Spring Boot、Spring Cloud、Mysql、Redis、Elasticsearch、中间件、架构设计、面试题、程序员攻略等

本文转载自: 掘金

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

1…616263…956

开发者博客

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