注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

文档都写不好,当个屁的架构师!

大家好,我是冰河~~ 最近有很多小伙伴,也不乏身边的一些同事问我:哎,架构师为什么要写这么多文档啊?有啥用呢?不能跟开发一样多写写代码吗?天天写文档,又感觉自己的文档写不好,有什么写文档的技巧吗? 今天也正好看到一篇文章,就给大家统一回复下这个问题。 软件设计...
继续阅读 »

大家好,我是冰河~~


最近有很多小伙伴,也不乏身边的一些同事问我:哎,架构师为什么要写这么多文档啊?有啥用呢?不能跟开发一样多写写代码吗?天天写文档,又感觉自己的文档写不好,有什么写文档的技巧吗?


今天也正好看到一篇文章,就给大家统一回复下这个问题。


软件设计文档就是架构师的主要工作成果,它需要阐释工作过程中的各种诉求,描绘软件的完整蓝图,而软件设计文档的主要组成部分就是软件模型。


软件设计过程可以拆分成 需求分析、概要设计和详细设计 三个阶段。


在需求分析阶段,主要是通过用例图来描述系统的功能与使用场景;对于关键的业务流程,可以通过活动图描述;如果在需求阶段就提出要和现有的某些子系统整合,那么可以通过时序图描述新系统和原来的子系统的调用关系;可以通过简化的类图进行领域模型抽象,并描述核心领域对象之间的关系;如果某些对象内部会有复杂的状态变化,比如用户、订单这些,可以用状态图进行描述。


在概要设计阶段,通过部署图描述系统最终的物理蓝图;通过组件图以及组件时序图设计软件主要模块及其关系;还可以通过组件活动图描述组件间的流程逻辑。


在详细设计阶段,主要输出的就是类图和类的时序图,指导最终的代码开发,如果某个类方法内部有比较复杂的逻辑,那么可以将这个方法的逻辑用活动图进行描述。


我们在每个设计阶段使用几种UML模型对领域或者系统进行建模,然后将这些模型配上必要的文字说明写入到文档中,就可以构成一篇软件设计文档了。


由于时间关系,今天就跟大家聊到这里,后续给大家分享系统写架构文档的方法论。


好了,今天就到这儿吧,我是冰河,我们下期见~~


作者:冰_河
来源:juejin.cn/post/7330835892276838441
收起阅读 »

简单一招竟把nginx服务器性能提升50倍

需求背景 接到重点业务需求要分轮次展示数据,预估最高承接 9w 的 QPS,作为后端工程师下意识的就是把接口写好,分级缓存、机器扩容、线程拉满等等一系列连招准备,再因为数据更新频次两只手都数得过来,我们采取了最稳妥的处理方式,直接生成静态文件拿 CDN 抗量 ...
继续阅读 »

需求背景


接到重点业务需求要分轮次展示数据,预估最高承接 9w 的 QPS,作为后端工程师下意识的就是把接口写好,分级缓存、机器扩容、线程拉满等等一系列连招准备,再因为数据更新频次两只手都数得过来,我们采取了最稳妥的处理方式,直接生成静态文件拿 CDN 抗量


架构流程大致如下所示:



数据更新后会重新生成新一轮次的文件,刷新 CDN 的时候会触发大量回源请求,应用服务器极端情况得 hold 住这 9w 的 QPS


第一次压测


双机房一共 40 台 4C 的机器,25KB 数据文件,5w 的 QPS 直接把 CPU 打到 90%


这明显不符合业务需求啊,咋办?先无脑加机器试试呗


就在这时测试同学反馈压测的数据不对,最后一轮文件最大会有 125KB,雪上加霜


于是乎文件替换,机器数量整体翻一倍扩到 80 台,服务端 CPU 依然是瓶颈,QPS 加不上去了



到底是哪里在消耗 CPU 资源呢,整体架构已经简单到不能再简单了


这时候我们注意到为了节省网络带宽 nginx 开启了 gzip 压缩,是不是这小子搞的鬼


server
{
listen 80;

gzip on;
gzip_disable "msie6";
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_buffers 16 8k;
gzip_http_version 1.1;
gzip_types text/plain application/css text/css application/xml text/javascript application/javascript application/x-javascript;

......
}



第二次压测


为了验证这个猜想,我们把 nginx 中的 gzip 压缩率从 6 调成 2,以减少 CPU 的计算量



gzip_comp_level 2;



这轮压下来 CPU 还是很快被打满,但 QPS 勉强能达到 9w,坐实了确实是 gzip 在耗 CPU



nginx 作为家喻户晓的 web 服务器,以高性能高并发著称,区区一个静态数据文件就把应用服务器压的这么高,一定是哪里不对


第三次压测


明确了 gzip 在耗 CPU 之后我们潜下心来查阅了相关资料,发现了一丝进展


html/css/js 等静态文件通常包含大量空格、标签等重复字符,重复出现的部分使用「距离加长度」表达可以减少字符数,进而大幅降低带宽,这就是 gzip 无损压缩的基本原理


作为一种端到端的压缩技术,gzip 约定文件在服务端压缩完成,传输中保持不变,直到抵达客户端。这不妥妥的理论依据嘛~


nginx 中的 gzip 压缩分为动态压缩和静态压缩两种


•动态压缩


服务器给客户端返回响应时,消耗自身的资源进行实时压缩,保证客户端拿到 gzip 格式的文件


这个模块是默认编译的,详情可以查看 nginx.org/en/docs/htt…


•静态压缩


直接将预先压缩过的 .gz 文件返回给客户端,不再实时压缩文件,如果找不到 .gz 文件,会使用对应的原始文件


这个模块需要单独编译,详情可以查看 nginx.org/en/docs/htt…


如果开启了 gzip_static always,而且客户端不支持 gzip,还可以在服务端加装 gunzip 来帮助客户端解压,这里我们就不需要了


查了一下 jdos 自带的 nginx 已经编译了 ngx_http_gzip_static_module,省去了重新编译的麻烦事



接下来通过 GZIPOutputStream 在本地额外生成一个 .gz 的文件,nginx 配置上静态压缩再来一次



gzip_static on;




面对 9w 的QPS,40 台机器只用了 7% 的 CPU 使用率完美扛下


为了探底继续加压,应用服务器 CPU 增长缓慢,直到网络流出速率被拉到了 89MB/s,担心影响宿主机其他容器停止压力,此时 QPS 已经来到 27w


qps 5w->27w 提升 5 倍,CPU 90%->7% 降低 10 倍,整体性能翻了 50 倍不止,这回舒服了~


写在最后


经过一连串的分析实践,似乎静态压缩存在“压倒性”优势,那什么场景适合动态压缩,什么场景适合静态压缩呢?一番探讨后得出以下结论



纯静态不会变化的文件适合静态压缩,提前使用gzip压缩好避免CPU和带宽的浪费。动态压缩适合API接口返回给前端数据这种动态的场景,数据会发生变化,这时候就需要nginx根据返回内容动态压缩,以节省服务器带宽



作为一名后端工程师,nginx 是我们的老相识了,抬头不见低头见。日常工作中配一配转发规则,查一查 header 设置,基本都是把 nginx 作为反向代理使用。这次是直接访问静态资源,调整过程的一系列优化加深了我们对 gzip 的动态压缩和静态压缩的基本认识,这在 NG 老炮儿眼里显得微不足道,但对于我们来说却是一次难得的技能拓展机会


在之前的职业生涯里,我们一直聚焦于业务架构设计与开发,对性能的优化似乎已经形成思维惯性。面对大数据量长事务请求,减少循环变批量,增大并发,增加缓存,实在不行走异步任务解决,一般瓶颈都出现在 I/O 层面,毕竟磁盘慢嘛,减少与数据库的交互次数往往就有效果,其他大概率不是问题。这回有点儿不一样,CPU 被打起来的原因就是出现了大量数据计算,在高并发请求前,任何一个环节都可能产生性能问题


作者:京东零售 闫创


来源:京东云开发者社区 转载请注明来源


作者:京东云开发者
来源:juejin.cn/post/7328766815101206547
收起阅读 »

多租户架构设计思考

共享数据库,共享表 描述 所有租户的数据都在同一个数据库表内,以租户字段:tenant_id来区分。 优点 成本低,实现方式简单,适合中小型项目的快速实现。 缺点 数据隔离性差,某一个租户的数据量大的时候,会影响其他租户数据的操作效率。 需要在表上增加租户字...
继续阅读 »

共享数据库,共享表


描述


所有租户的数据都在同一个数据库表内,以租户字段:tenant_id来区分。


优点


成本低,实现方式简单,适合中小型项目的快速实现。


缺点



  • 数据隔离性差,某一个租户的数据量大的时候,会影响其他租户数据的操作效率。

  • 需要在表上增加租户字段,对系统有一定的侵入性。

  • 数据备份困难,因为所有租户的数据混合在一起,所以针对某个租户数据的备份、恢复会比较麻烦。


实现方式


**方式一:**编写Mybatis拦截器,拦截增删改查操作,动态的增加租户条件,如:


SELECT * FROM sys_user;

修改成:


SELECTG * FROM sys_user WHERE tenant_id = 100;

这种方案并不靠谱,因为动态修改SQL语句不是一个好的处理方式,如果SQL解析没有做好,或者出现复杂SQL,那么很容易产生bug。


**方式二:**编写Mybatis拦截器,拦截增删改查操作,判断是否有租户条件,如:


SELECT * FROM sys_user WHERE id=1;

使用jsqlparser工具解析SQL,判断出该SQL语句没有tenant_id的条件,那么抛出异常,不允许执行。


这种方案比较稳妥,因为只做判断不做修改。


查询操作的优先级不高,如果不在乎数据敏感,可以不拦截。


要注意的是修改操作,稍不注意容易被某一个租户影响其他租户的数据。


共享数据库,独立一张表


描述


所有租户的数据都在同一个数据库中,但是各自有一个独立的表,如:


# 1号租户的用户表
sys_user_1

# 2号租户的用户表
sys_user_2

...

优点


成本低,数据隔离性比共享表稍好,并且不用新增租户字段,对系统没有侵入性。


缺点



  • 数据隔离性虽然比共享表好了些,但是因为仍在同一数据库下,所以某一个租户影响其他租户的数据操作效率问题依然存在。

  • 数据备份困难的问题依然存在。


实现方式


**方式一:**编写Mybatis拦截器,拦截增删改查操作,动态的修改表名称,如:


SELECT * FROM sys_user;

修改成:


SELECT * FROM sys_user_1;

同样的,这种动态修改SQL语句的方式并不推荐,所以我们有另一种方式。


**方式二:**将表名作为参数传入


本来在Mapper.xml中,查询语句是这样的:


SELECT * FROM sys_user WHERE id = #{userId};

现在改成:


SELECT * FROM #{tableName} WHERE id = #{userId};

这样可以避免动态修改SQL语句操作。


独立数据库


描述


每个租户都单独分配一个数据库,数据完全独立,如:


database_1;
database_2;
...

优点



  • 数据隔离性最好,不需要添加租户id字段,租户之间不会被彼此影响。

  • 便于数据备份和恢复。

  • 便于扩展。


缺点



  • 经费成本高,尤其在有多个租户的情况下。

  • 运维成本高。


结论


一般来说,当数据量不高的时候,选择共享数据库共享表的方式,表内加个租户id字段做区分,数据量或者用户量多起来,就可以直接升级到独立数据库的方式,因为独立表的方式处理起来是有些麻烦的,倒不如加个字段来的方便。


作者:失败的面
来源:juejin.cn/post/7282953307529953291
收起阅读 »

你要写过年,就不能只写万家灯火与团圆

昨天晚上八点过,下了地铁,走到出租屋的楼下,原本热闹的小区,也变得冷冷清清的,来到我经常吃肉沫粉的小店门口,小雨缠绵,透过那层破旧的透明胶纸,看到老板和老板娘在收拾行李。 我轻轻撩开胶纸,问老板还有吃的吗,他笑着说:兄弟,刚好还有最后一份,你来得正巧,卖给你后...
继续阅读 »

昨天晚上八点过,下了地铁,走到出租屋的楼下,原本热闹的小区,也变得冷冷清清的,来到我经常吃肉沫粉的小店门口,小雨缠绵,透过那层破旧的透明胶纸,看到老板和老板娘在收拾行李。


我轻轻撩开胶纸,问老板还有吃的吗,他笑着说:兄弟,刚好还有最后一份,你来得正巧,卖给你后我们就该回家了。


可以看出他们心中是很开心的,两个孩子也在不停叨唠:回家了,回家了。


老板做好粉给我端来,可能是最后一份,料加得特别足,我吃了一半就饱了,然后擦了擦嘴,给老板说了声新年快乐,老板和老板娘笑眯眯回了我一句:兄弟,新年快乐,明年见!


于是我就上楼了,往日上楼都有不少人在等电梯,今日五个电梯门都停在一楼,大家都回去过年了吧,再过两天,这个城市可能会更加冷清。


回到出租屋后,坐在桌子前,回想很多事情,我觉得可以动笔了!


一.“不想回家过年”的人


下班后,打了个滴滴去20公里以外的地方办点事,一上车和师傅就开始聊了起来,师傅问我还不回家过年吗?


图片


我对他说:还有好几天呢,除夕再回去。


我反问他:只有几天过年了,为啥还不回去过年呢?


他说道:平时跑车都没啥生意,过年生意好一点,多跑几天,和你一样也是除夕当天才回去。


他说好几天没遇到我这么大的单了,60块钱,平时都是10块,8块的,一天也就能跑两百块钱左右,最近一天能跑500左右。


我们在车上一直聊,聊他的年轻时进厂打工过年回家的时光,他14岁时就去浙江进厂,每年回家过年也就能带几千块钱过年,有一年从义乌坐了三天的大巴车回来,路上堵车,事故,经历“九九八十一难”才到家。


回来过年打了几天的麻将,几千块钱全部输完后,给家里要了几百块钱后,又灰溜溜地出门进厂了。这样的日子反反复复了六七年,一分钱都没存到。


后面觉得这样不行,于是家里给他说了个媳妇,还是卖了一块土地,才勉强把彩礼凑齐了。


成家后有了孩子,压力大了,于是就在家乡的县城干工地,一干就是十几年,直到35岁的时候,存了十几万块钱,2019年在县城首付买了一套房子。


没过多久,疫情就来了,他说没活干,收入彻底断了,但是房贷没有断,于是刷信用卡,借钱来还房贷,后面疫情稍微放开后,就想办法搞了一个二手车来跑,那会跑十几二十公里都很难拉到一个人,一天勉强能跑八九十块钱,勉强能够一家人吃饭,但是房贷还是要想其它的办法。


他说为啥不敢提前回去过年,就是因为还要还房贷,所以不敢松一口气。


聊了大概一个小时,一路堵车,我到站了,下车后他递了一支烟给我,说道:兄弟,很久没有和别人聊这么久了,新年快乐。


我也对他说了一句新年快乐。


我给了一个好评,并且打赏了十块钱。


是啊,我何尝不是很久没有和别人聊这么久了呢,我们都在自己该走的路上马不停蹄奔跑,一切还不是为了生活!


没有谁不想提前回家过年,没有谁不想回家看看父母,没有谁不想回家去感受热乎乎的饭菜!


可是回到了家,生活又该怎么继续继续呢?


二.想回家过年却回不了的人


上个月从广州回来,广州南站已经是人山人海了,那会朋友说抢回广西的票已经很难抢了,都不知道还能不能回去过年。


图片


这两天和在广东打工的朋友聊了下,他说根本抢不到票,不知道还能不能回家。


我打开了手机购票软件,全是暂无余票,建议抢票,抢到票的人是幸运的,但是抢不到票的人,此刻心中又是何种感受。


因为只有火车,高铁,大巴是中国大部分人能消费得起的,大部分根本不舍得买一张机票。


和滴滴师傅聊天时,他说他的哥哥和嫂子现在还在义乌进厂,由于抢不到火车票和高铁票,他们看了看机票,需要1400元,两个人就需要差不多3000多,这已经顶得上他们一个人一个月的工资了。


所以想了想还是不回了,打了几千块钱给家里的老人和孩子,让他们自己过年了。


可能下一次见到家中的老父母和孩子又是下一年了,不知道下次回家的时候,孩子看他们的眼神是不是会有一丝陌生,老人的眼神是不是又多了几分期待。


还记得在我小时候,父母在外省打工,过年的时候,他们背着很大的牛仔背包,里面有被子,衣服,只要能带回来的东西都带回来了,那时候父母还算年轻,但是回到家的时候我却感觉有点陌生。


因为长时间不见他们,当见到他们的时候,虽然心里很高兴,但是却一时表现不出来,反而会流下泪水。


我在农村看了太多这样的场景,爸爸妈妈在外打工,过年回来过年,孩子在门前呆呆坐着,叫了他几声都没答应,最后大哭了起来。


是啊,有谁能在几年时间里没见到自己的爸爸妈妈,当见到的时候能不大哭呢?


不过这就是中国大部分农村的实际情况,父母因为要赚钱回来修房子,供孩子上学,所以很多父母过年不舍得花费太多路费回来。


除了交通工具和回家路费的限制,还有很多因为工作不能回家过年的人,他们很想回来,但是却不能回来,他们有工人,有白领,有交警,有驻守边疆的战士......


此刻,不管你过年在厂区里面加班,在写字楼工作,在路上指挥车辆,还是在祖国的边疆驻守。


我都对你们表示尊敬,祝你们新年快乐!


三.不敢回家过年的人


总有人有家不敢回。


图片


可能网上的过年文案都是阖家欢乐,大团圆,但是在社会的深处,总有很多人不敢回家,或者不好意思回家。


远在深圳的朋友,和我聊天说不敢回家过年了,钱是钱没赚到,女朋友是女朋友没找到,回去面对逐渐变老的父母,心中不忍。


这几年赚钱是真的特别难,朋友在深圳搞销售,因为销售很不稳定,并且是个苦活,他一个月也就能赚几千块钱,除了花销,还要还债,就留不下几个钱。


后面觉得送外卖可能能多赚一点,但是送了不久,和别人电车又撞了,还受了伤,于是只能放弃,直接去找了一个工厂进。


我们大多数人总是看到大城市的繁华,以为都能赚到钱。但是大城市里面,大部分人都是拿着最微薄的工资,干着最累的活,最后还存不了几个钱。


可能你觉得在几十层的写字楼里面工作的白领都是年薪几十几百万,但是实际情况是,大多数都是几千块,每天通勤都是按小时来计算,加班后回到出租屋已经累趴,一趟就睡。


但是一年下来却赚不了几个钱,在亲人朋友的眼中以为你在大城市混得不错,但是苦只有自己知道。


所以带着这种压力和心理负担,很多人不敢回家。


还有一些怕回去被催婚,被相亲,被攀比,所以索性直接留在打工的地方过年,因为觉得自己不甘随便找个人结婚,不想去和谁比这比那,索性选择一个人留下来。


也许大年三十你看到了漫天的烟花,饭桌上丰盛的菜肴,但是总有人在没人看到的地方吃着泡面,烟花爆开的一瞬间,他的眼泪刚好掉下。


我经历过这样的日子,我曾看到别人团圆而自己孤身一人而落泪,也曾看到万家灯火而自己在黑暗中哭泣。


四.无家可回的人


总有人想过年,但是却没有家回的人。


图片


在我还是学生的时候,有一个朋友过年不知道去哪里过,他常年都在外面打工,过年的时候回来,我们在一起喝酒,一起聊天,但是到最后,每个人都回家了,他独自一个人去酒店了。


他父母在他小的时候就离婚了,并且父母都对他不管不顾,在他十几岁的时候就独自出门打工了,他已经没啥亲人了,所以回到家乡只是来找一个曾经的感觉。


还记得前两年,除夕的前一天我们在一起玩耍,我叫他和我去我家一起过年,他拒绝了,后面被另外的两个朋友硬拉着去他们家过年。


当时他的眼睛里面充满泪花,我从他的眼神里面看到了别人没有的坚强。


是呀,可能在我们的世界里,过年是个再寻常不过的日子了,但是在他的世界里,过年却是一件无法奢求的事情。


像我朋友这样情况的人还是比较多的。


不过我的朋友,请你相信,你失去的终究会翻倍给你偿还,你得到的会加倍给你馈赠。


---------------


行笔到此,心中百感交集。


过年是中国人独有的传统,在这个日子里面,是团圆,是喜庆,是期待……


按理这个日子应该用华丽的辞藻和温馨的言语来写。


但是在自己经历了很多事,看到了很多现实场景的时候,我无法动笔去写空洞的句子。


最后给“不想回家过年”,想回家过年却回不了,不敢回家过年,无家可回,无年可过的朋友们说一句,也给我自己说一句。


这个世界总有一盏灯会为你亮着,总有一个眼神,为你等待着。


新年快乐!


作者:苏格拉的底牌
来源:juejin.cn/post/7331940066960195584
收起阅读 »

一种好用的KV存储封装方案

一、 概述 众所周知,用kotlin委托属性去封装KV存储库,可以优化数据的访问。 封装方法有多种,各有优劣。 通过反复实践,笔者摸索出一套比较好用的方案,借此文做个简单的分享。 代码已上传Github: github.com/BillyWei01/… 项目...
继续阅读 »

一、 概述


众所周知,用kotlin委托属性去封装KV存储库,可以优化数据的访问。

封装方法有多种,各有优劣。

通过反复实践,笔者摸索出一套比较好用的方案,借此文做个简单的分享。


代码已上传Github: github.com/BillyWei01/…

项目中是基于SharePreferences封装的,但这套方案也适用于其他类型的KV存储框架。


二、 封装方法


此方案封装了两类委托:



  1. 基础类型

    基础类型包括 [boolean, int, float, long, double, String, Set<String>, Object] 等类型。

    其中,Set<String> 本可以通过 Object 类型囊括,

    但因为Set<String>是 SharePreferences 内置支持的类型,这里我们就直接内置支持了。

  2. 扩展key的基础类型

    基础类型的委托,定义属性时需传入常量的key,通过委托所访问到的是key对应的value

    而开发中有时候需要【常量+变量】的key,基础类型的委托无法实现。

    为此,方案中实现了一个 CombineKV 类。

    CombineKV通过组合[key+extKey]实现通过两级key来访问value的效果。

    此外,方案基于CombineKV封装了各种基础类型的委托,用于简化API,以及约束所访问的value的类型。


2.1 委托实现


基础类型BasicDelegate.kt

扩展key的基础类型: ExtDelegate.kt


这里举例一下基础类型中的Boolean类型的委托实现:


class BooleanProperty(private val key: String, private val defValue: Boolean) :
ReadWriteProperty<KVData, Boolean> {
override fun getValue(thisRef: KVData, property: KProperty<*>): Boolean {
return thisRef.kv.getBoolean(key, defValue)
}

override fun setValue(thisRef: KVData, property: KProperty<*>, value: Boolean) {
thisRef.kv.putBoolean(key, value)
}
}

class NullableBooleanProperty(private val key: String) :
ReadWriteProperty<KVData, Boolean?> {
override fun getValue(thisRef: KVData, property: KProperty<*>): Boolean? {
return thisRef.kv.getBoolean(key)
}

override fun setValue(thisRef: KVData, property: KProperty<*>, value: Boolean?) {
thisRef.kv.putBoolean(key, value)
}
}

经典的 ReadWriteProperty 实现:

分别重写 getValue 和 setValue 方法,方法中调用KV存储的读写API。

由于kotlin区分了可空类型和非空类型,方案中也分别封装了可空和非空两种委托。


2.2 基类定义


实现了委托之后,我们将各种委托API封装到一个基类中:KVData


abstract class KVData {
// 存储接口
abstract val kv: KVStore

// 基础类型
protected fun boolean(key: String, defValue: Boolean = false) = BooleanProperty(key, defValue)
protected fun int(key: String, defValue: Int = 0) = IntProperty(key, defValue)
protected fun float(key: String, defValue: Float = 0f) = FloatProperty(key, defValue)
protected fun long(key: String, defValue: Long = 0L) = LongProperty(key, defValue)
protected fun double(key: String, defValue: Double = 0.0) = DoubleProperty(key, defValue)
protected fun string(key: String, defValue: String = "") = StringProperty(key, defValue)
protected fun stringSet(key: String, defValue: Set<String> = emptySet()) = StringSetProperty(key, defValue)
protected fun <T> obj(key: String, encoder: ObjectEncoder<T>, defValue: T) = ObjectProperty(key, encoder, defValue)

// 可空的基础类型
protected fun nullableBoolean(key: String) = NullableBooleanProperty(key)
protected fun nullableInt(key: String) = NullableIntProperty(key)
protected fun nullableFloat(key: String) = NullableFloatProperty(key)
protected fun nullableLong(key: String) = NullableLongProperty(key)
protected fun nullableDouble(key: String) = NullableDoubleProperty(key)
protected fun nullableString(key: String) = NullableStringProperty(key)
protected fun nullableStringSet(key: String) = NullableStringSetProperty(key)
protected fun <T> nullableObj(key: String, encoder: NullableObjectEncoder<T>) = NullableObjectProperty(key, encoder)

// 扩展key的基础类型
protected fun extBoolean(key: String, defValue: Boolean = false) = ExtBooleanProperty(key, defValue)
protected fun extInt(key: String, defValue: Int = 0) = ExtIntProperty(key, defValue)
protected fun extFloat(key: String, defValue: Float = 0f) = ExtFloatProperty(key, defValue)
protected fun extLong(key: String, defValue: Long = 0L) = ExtLongProperty(key, defValue)
protected fun extDouble(key: String, defValue: Double = 0.0) = ExtDoubleProperty(key, defValue)
protected fun extString(key: String, defValue: String = "") = ExtStringProperty(key, defValue)
protected fun extStringSet(key: String, defValue: Set<String> = emptySet()) = ExtStringSetProperty(key, defValue)
protected fun <T> extObj(key: String, encoder: ObjectEncoder<T>, defValue: T) = ExtObjectProperty(key, encoder, defValue)

// 扩展key的可空的基础类型
protected fun extNullableBoolean(key: String) = ExtNullableBooleanProperty(key)
protected fun extNullableInt(key: String) = ExtNullableIntProperty(key)
protected fun extNullableFloat(key: String) = ExtNullableFloatProperty(key)
protected fun extNullableLong(key: String) = ExtNullableLongProperty(key)
protected fun extNullableDouble(key: String) = ExtNullableDoubleProperty(key)
protected fun extNullableString(key: String) = ExtNullableStringProperty(key)
protected fun extNullableStringSet(key: String) = ExtNullableStringSetProperty(key)
protected fun <T> extNullableObj(key: String, encoder: NullableObjectEncoder<T>) = ExtNullableObjectProperty(key, encoder)

// CombineKV
protected fun combineKV(key: String) = CombineKVProperty(key)
}

使用时,继承KVData,然后实现kv, 返回一个KVStore的实现类即可。


举例,如果用SharedPreferences实现KVStore,可如下实现:


class SpKV(name: String): KVStore {
private val sp: SharedPreferences =
AppContext.context.getSharedPreferences(name, Context.MODE_PRIVATE)
private val editor: SharedPreferences.Editor = sp.edit()

override fun putBoolean(key: String, value: Boolean?) {
if (value == null) {
editor.remove(key).apply()
} else {
editor.putBoolean(key, value).apply()
}
}

override fun getBoolean(key: String): Boolean? {
return if (sp.contains(key)) sp.getBoolean(key, false) else null
}

// ...... 其他类型
}


更多实现可参考: SpKV


三、 使用方法


object LocalSetting : KVData("local_setting") {
override val kv: KVStore by lazy {
SpKV(name)
}
// 是否开启开发者入口
var enableDeveloper by boolean("enable_developer")

// 用户ID
var userId by long("user_id")

// id -> name 的映射。
val idToName by extNullableString("id_to_name")

// 收藏
val favorites by extStringSet("favorites")

var gender by obj("gender", Gender.CONVERTER, Gender.UNKNOWN)
}


定义委托属性的方法很简单:



  • 和定义变量类似,需要声明变量名类型

  • 和变量声明不同,需要传入key

  • 如果要定义自定义类型,需要传入转换器(实现字符串和对象类型的转换),以及默认值


基本类型的读写,和变量的读写一样。

例如:


fun test1(){
// 写入
LocalSetting.userId = 10001L
LocalSetting.gender = Gender.FEMALE

// 读取
val uid = LocalSetting.userId
val gender = LocalSetting.gender
}

读写扩展key的基本类型,则和Map的语法类似:


fun test2() {
if (LocalSetting.idToName[1] == null || LocalSetting.idToName[2] == null) {
Log.d("TAG", "Put values to idToName")
LocalSetting.idToName[1] = "Jonn"
LocalSetting.idToName[2] = "Mary"
} else {
Log.d("TAG", "There are values in idToName")
}
Log.d("TAG", "idToName values: " +
"1 -> ${LocalSetting.idToName[1]}, " +
"2 -> ${LocalSetting.idToName[2]}"
)
}

扩展key的基本类型,extKey是Any类型,也就是说,以上代码的[],可以传入任意类型的参数。


四、数据隔离


4.1 用户隔离


不同环境(开发环境/测试环境),不同用户,最好数据实例是分开的,相互不干扰。

比方说有 uid='001' 和 uid='002' 两个用户的数据,如果需要隔离两者的数据,有多种方法,例如:



  1. 拼接uid到key中。


    如果是在原始的SharePreferences的基础上,是比较好实现的,直接put(key+uid, value)即可;

    但是如果用委托属性定义,可以用上面定义的扩展key的类型。


  2. 拼接uid到文件名中。


    但是不同用户的数据糅合到一个文件中,对性能多少有些影响:



    • 在多用户的情况下,实例的数据膨胀;

    • 每次访问value, 都需要拼接uid到key上。


    因此,可以将不同用户的数据保存到不同的实例中。

    具体的做法,就是拼接uid到路径或者文件名上。



基于此分析,我们定义两种类型的基类:



  • GlobalKV: 全局数据,切换环境和用户,不影响GlobalKV所访问的数据实例。

  • UserKV: 用户数据,需要同时区分 “服务器环境“ 和 ”用户ID“。


open class GlobalKV(name: String) : KVData() {
override val kv: KVStore by lazy {
SpKV(name)
}
}

abstract class UserKV(
private val name: String,
private val userId: Long
) : KVData() {
override val kv: SpKV by lazy {
// 拼接UID作为文件名
val fileName = "${name}_${userId}_${AppContext.env.tag}"
if (AppContext.debug) {
SpKV(fileName)
} else {
// 如果是release包,可以对文件名做个md5,以便匿藏uid等信息
SpKV(Utils.getMD5(fileName.toByteArray()))
}
}
}

UserKV实例:


/**
* 用户信息
*/

class UserInfo(uid: Long) : UserKV("user_info", uid) {
companion object {
private val map = ArrayMap<Long, UserInfo>()

// 返回当前用户的实例
fun get(): UserInfo {
return get(AppContext.uid)
}

// 根据uid返回对应的实例
@Synchronized
fun get(uid: Long): UserInfo {
return map.getOrPut(uid) {
UserInfo(uid)
}
}
}

var gender by intEnum("gender", Gender.CONVERTER)
var isVip by boolean("is_vip")

// ... 其他变量
}

UserKV的实例不能是单例(不同的uid对应不同的实例)。

因此,可以定义companion对象,用来缓存实例,以及提供获取实例的API。


保存和读取方法如下:

先调用get()方法获取,然后其他用法就和前面描述的用法一样了。


UserInfo.get().gender = Gender.FEMALE

val gender = UserInfo.get().gender

4.2 环境隔离


有一类数据,需要区分环境,但是和用户无关。

这种情况,可以用UserKV, 然后uid传0(或者其他的uid用不到的数值)。


/**
* 远程设置
*/

object RemoteSetting : UserKV("remote_setting", 0L) {
// 某项功能的AB测试分组
val fun1ABTestGr0up by int("fun1_ab_test_group")

// 服务端下发的配置项
val setting by combineKV("setting")
}

五、小结


通过属性委托封装KV存储的API,可使原来“类名 + 操作 + key”的方式,变更为“类名 + 属性”的方式,从而简化KV存储的使用。
另外,这套方案也提到了保存不同用户数据到不同实例的演示。


方案内容不多,但其中包含一些比较实用的技巧,希望对各位读者有所帮助。


作者:呼啸长风
来源:juejin.cn/post/7323449163420303370
收起阅读 »

java 实现后缀表达式

一、概述 后缀表达式(也称为逆波兰表达式)是一种数学表达式的表示方法,其中操作符位于操作数的后面。这种表示法消除了括号,并且在计算机科学和计算中非常有用,因为它更容易计算和解析。 与中缀表达式(通常我们使用的数学表达式,例如"a * (b + c)")不同,后...
继续阅读 »

一、概述


后缀表达式(也称为逆波兰表达式)是一种数学表达式的表示方法,其中操作符位于操作数的后面。这种表示法消除了括号,并且在计算机科学和计算中非常有用,因为它更容易计算和解析。


与中缀表达式(通常我们使用的数学表达式,例如"a * (b + c)")不同,后缀表达式的运算符放在操作数之后,例如:“a b c + *”。后缀表达式的计算方法是从左到右遍历表达式,遇到操作数时将其压入栈,遇到操作符时从栈中弹出所需数量的操作数进行计算,然后将结果重新压入栈。这个过程一直持续到整个表达式处理完毕,最终栈中只剩下一个结果,即表达式的计算结果。


后缀表达式具有以下优点:



  1. 不需要括号,因此消除了歧义。

  2. 更容易计算,因为遵循一定的计算顺序。

  3. 适用于计算机的堆栈操作,因此在编译器和计算器中经常使用。


转换中缀表达式为后缀表达式需要使用算法,通常是栈数据结构。


二、后缀表达式的运算顺序


后缀表达式的运算顺序是从左到右遍历表达式,遇到操作数时将其压入栈,遇到操作符时从栈中弹出所需数量的操作数进行计算,然后将计算结果重新压入栈。这个过程一直持续到整个表达式处理完毕,最终栈中只剩下一个结果,即表达式的计算结果。


后缀表达式的运算顺序是非常直观的,它遵循从左到右的顺序。当计算后缀表达式时,按照以下规则:



  1. 从左到右扫描后缀表达式中的每个元素(操作数或操作符)。

  2. 如果遇到操作数,将其推入栈。

  3. 如果遇到操作符,从栈中弹出所需数量的操作数进行计算,然后将计算结果推回栈中。

  4. 重复这个过程,直到遍历完整个后缀表达式。


三、常规表达式转化为后缀表达式



  • 创建两个栈,一个用于操作符(操作符栈),另一个用于输出后缀表达式(输出栈)。

  • 从左到右遍历中缀表达式的每个元素。

  • 如果是操作数,将其添加到输出栈。

  • 如果是操作符:

  • 如果操作符栈为空,直接将该操作符推入操作符栈。

    否则,比较该操作符与操作符栈栈顶的操作符的优先级。如果当前操作符的优先级较高,将其推入操作符栈。

    如果当前操作符的优先级较低或相等,从操作符栈中弹出并添加到输出栈,然后重复比较直到可以推入操作符栈。

    如果遇到左括号"(“,直接推入操作符栈。

    如果遇到右括号”)“,将操作符栈中的操作符弹出并添加到输出栈,直到遇到匹配的左括号”("。

    最后,将操作符栈中的剩余操作符全部弹出并添加到输出栈。

    完成遍历后,输出栈中的内容就是中缀表达式转化为后缀表达式的结果。


四、代码实现


/**
* 定义操作符的优先级
*/

private Map<String, Integer> opList =
Map.of("(",3,")",3,"*",2,"/",2,"+",1,"-",1);

public List<String> getPostExp(List<String> source) {

// 数字栈
Stack<String> dataStack = new Stack<>();
// 操作数栈
Stack<String> opStack = new Stack<>();
// 操作数集合
for (int i = 0; i < source.size(); i++) {
String d = source.get(i).trim();
// 操作符的操作
if (opList.containsKey(d)) {
operHandler(d,opStack,dataStack);
} else {
// 操作数直接入栈
dataStack.push(d);
}
}
// 操作数栈中的数据,到压入到栈中
while (!opStack.isEmpty()) {
dataStack.push(opStack.pop());
}
List<String> result = new ArrayList<>();
while (!dataStack.isEmpty()) {
String pop = dataStack.pop();
result.add(pop);
}
// 对数组进行翻转
return CollUtil.reverse(result);
}

/**
* 对操作数栈的操作
* @param d,当前操作符
* @param opStack 操作数栈
*/

private void operHandler(String d, Stack<String> opStack,Stack<String> dataStack) {
// 操作数栈为空
if (opStack.isEmpty()) {
opStack.push(d);
return;
}
// 如果遇到左括号"(“,直接推入操作符栈。
if (d.equals("(")) {
opStack.push(d);
return;
}
// 如果遇到右括号”)“,将操作符栈中的操作符弹出并添加到输出栈,直到遇到匹配的左括号”("。
if (d.equals(")")) {
while (!opStack.isEmpty()) {
String pop = opStack.pop();
// 不是左括号
if (!pop.equals("(")) {
dataStack.push(pop);
} else {
return;
}
}
}
// 操作数栈不为空
while (!opStack.isEmpty()) {
// 获取栈顶元素和优先级
String peek = opStack.peek();
Integer v = opList.get(peek);
// 获取当前元素优先级
Integer c = opList.get(d);
// 如果当前操作符的优先级较低或相等,且不为(),从操作符栈中弹出并添加到输出栈,然后重复比较直到可以推入操作符栈
if (c < v && v != 3) {
// 出栈
opStack.pop();
// 压入结果集栈
dataStack.push(peek);
} else {
// 操作符与操作符栈栈顶的操作符的优先级。如果当前操作符的优先级较高,将其推入操作符栈。
opStack.push(d);
break;
}
}
}

测试代码如下:


PostfixExpre postfixExpre = new PostfixExpre();

List<String> postExp = postfixExpre.getPostExp(
Arrays.asList("9", "+", "(" , "3", "-", "1", ")", "*", "3", "+", "10", "/", "2"));

System.out.println(postExp);

输出如下:


[9, 3, 1, -, 3, *, 10, 2, /, +, +]


五、求后缀表示值


使用栈来实现


    /****
* 计算后缀表达式的值
* @param source
* @return
*/

public double calcPostfixExpe(List<String> source) {

Stack<String> data = new Stack<>();
for (int i = 0; i < source.size(); i++) {
String s = source.get(i);
// 如果是操作数
if (opList.containsKey(s)) {
String d2 = data.pop();
String d1 = data.pop();
Double i1 = Double.valueOf(d1);
Double i2 = Double.valueOf(d2);
Double result = null;
switch (s) {
case "+":
result = i1 + i2;break;
case "-":
result = i1 - i2;break;
case "*":
result = i1 * i2;break;
case "/":
result = i1 / i2;break;
}
data.push(String.valueOf(result));
} else {
// 如果是操作数,进栈操作
data.push(s);
}
}
// 获取结果
String pop = data.pop();
return Double.valueOf(pop);
}

测试


PostfixExpre postfixExpre = new PostfixExpre();

List<String> postExp = postfixExpre.getPostExp(
Arrays.asList("9", "+", "(" , "3", "-", "1", ")", "*", "3", "+", "10", "/", "2"));

System.out.println(postExp);

double v = postfixExpre.calcPostfixExpe(postExp);

System.out.println(v);

结果如下:


[9, 3, 1, -, 3, *, 10, 2, /, +, +]
20.0

作者:小希爸爸
来源:juejin.cn/post/7330583100059762697
收起阅读 »

我发现了 Android 指纹认证 Api 内存泄漏

我发现了 Android 指纹认证 Api 内存泄漏 目前很多市面上的手机基本都有指纹登陆功能。Google 也提供了调用相关功能 API,安全类的App 也基本都在使用。接下来就一起捋一捋今天的主角 BiometricPrompt 先说问题,使用Biome...
继续阅读 »

我发现了 Android 指纹认证 Api 内存泄漏


目前很多市面上的手机基本都有指纹登陆功能。Google 也提供了调用相关功能 API,安全类的App 也基本都在使用。接下来就一起捋一捋今天的主角 BiometricPrompt


先说问题,使用BiometricPrompt 会造成内存泄漏,目前该问题试了 Android 11 到 13 都发生,而且没有什么好的办法。目前想到的最好的方法是漏的少一点。当然谁有好的办法欢迎留言。


问题再现


先看动画


在这里插入图片描述


动画中操作如下



  1. MainAcitivity 跳转到 SecondActivity

  2. SecondActivity 调用 BiometricPrompt 三次

  3. 从SecondActivity 返回到 MainAcitivity


以下是使用 BiometricPrompt 的代码


public fun showBiometricPromptDialog() {
val keyguardManager = getSystemService(
Context.KEYGUARD_SERVICE
) as KeyguardManager;

if (keyguardManager.isKeyguardSecure) {
var biometricPromptBuild = BiometricPrompt.Builder(this).apply {// this is SecondActivity
setTitle("verify")
setAllowedAuthenticators(BiometricManager.Authenticators.DEVICE_CREDENTIAL or BiometricManager.Authenticators.BIOMETRIC_WEAK)
}
val biometricPromp = biometricPromptBuild.build()
biometricPromp.authenticate(CancellationSignal(), mExecutor, object :
BiometricPrompt.AuthenticationCallback() {

})
}
else {
Log.d("TAG", "showLockScreen: isKeyguardSecure is false");
}
}

以上逻辑 biometricPromp 是局部变量,应该没有问题才对。


内存泄漏如下


在这里插入图片描述
可以看到每启动一次生物认证,创建的 BiometricPrompt 都不会被回收。


规避方案:


修改方案也简单


方案一:



  1. biometricPromp 改为全局变量。

  2. this 改为 applicationContext


方案一存在的问题,SecondActivity 可能频繁创建,所以 biometricPromp 还会存在多个实例。


方案二(目前想到的最优方案):



  1. biometricPromp 改为单例

  2. this 改为 applicationContext


修改后,App memory 中只存在一个 biometricPromp ,且没有 Activity 被泄漏。


想到这里,应该会觉得奇怪,biometricPromp 为什么不会被回收?提供的 API 都看过了,没有发现什么方法可以解决这个问题。直觉告诉我这个可能是系统问题,下来分析下BiometricPrompt 吧。


BiometricPrompt 源码分析


在这里插入图片描述


App 相关信息通过 BiometricPrompt 传递到 System 进程,System 进程再通知 SystemUI 显示认证界面。


App 信息传递到 System 进程,应该会使用 Binder。这个查找 BiometricPrompt 使用哪些 Binder。


private final IBiometricServiceReceiver mBiometricServiceReceiver =
new IBiometricServiceReceiver.Stub() {

......
}

源码中发现 IBiometricServiceReceiver 比较可疑,IBiometricServiceReceiver 是匿名内部类,内部是持有 BiometricPrompt 对象的引用。


接下来看下 System Server 进程信息(注:系统是 UserDebug 的手机,才可以查看,买的手机版本是不支持的)


在这里插入图片描述



😂 App 使用优化后(方案二)App 只存在一个 IBiometricServiceReceiver ,而 system 进程中存在三个 IBiometricServiceReceiver 的 binder proxy。 每次启动 BiometricPrompt 都会创建一个。这个就不解释为什么会出现三个binder proxy,感兴趣可以看下面推荐的文章。GC root 是 AuthSession。

再看下 AuthSession 的实例数


在这里插入图片描述


果然 AuthSession 也存在三个。


在这里插入图片描述


这里有个知识点,binder 也是有生命周期的,三个 Proxy 这篇文章也是解释了的。有兴趣的可以了看下。


Binder | 对象的生命周期


一开始,我以为 AuthSession 没有被置空,看下代码,发现 AOSP 的代码,还是比较严谨的,有置空的操作。


细心的同学发现,上图中 AuthSession 没有被任何对象引用,AuthSession 就是 GC Root,哈哈哈。


问题解密


一个实例什么情况可以作为GC Root,有兴趣的同学,可以自行百度,这里就不卖关子了,直接说问题吧。


Binder.linkToDeath()


public void linkToDeath(@NonNull DeathRecipient recipient, int flags) {
}

需要传递 IBinder.DeathRecipient ,这个 DeathRecipient 会被作为 GC root。当调用 unlinkToDeath(@NonNull DeathRecipient recipient, int flags),GC root 才被收回。


AuthSession 初始化的时候,会调用 IBiometricServiceReceiver .linkToDeath。


public final class AuthSession implements IBinder.DeathRecipient {
AuthSession(@NonNull Context context,
......
@NonNull IBiometricServiceReceiver clientReceiver,
......
) {
Slog.d(TAG, "Creating AuthSession with: " + preAuthInfo);
......
try {
mClientReceiver.asBinder().linkToDeath(this, 0 /* flags */);//this 变成 GC root
} catch (RemoteException e) {
Slog.w(TAG, "Unable to link to death");
}

setSensorsToStateUnknown();
}
}

Jni 中 通过 env->NewGlobalRef(object),告诉虚拟机 AuthSession 是 GC Root。


core/jni/android_util_Binder.cpp

static void android_os_BinderProxy_linkToDeath(JNIEnv* env, jobject obj,
jobject recipient, jint flags)
// throws RemoteException
{
if (recipient == NULL) {
jniThrowNullPointerException(env, NULL);
return;
}

BinderProxyNativeData *nd = getBPNativeData(env, obj);
IBinder* target = nd->mObject.get();

LOGDEATH("linkToDeath: binder=%p recipient=%p\n", target, recipient);

if (!target->localBinder()) {
DeathRecipientList* list = nd->mOrgue.get();
sp<JavaDeathRecipient> jdr = new JavaDeathRecipient(env, recipient, list);//java 中 DeathRecipient 会被封装为 JavaDeathRecipient
status_t err = target->linkToDeath(jdr, NULL, flags);
if (err != NO_ERROR) {
// Failure adding the death recipient, so clear its reference
// now.
jdr->clearReference();
signalExceptionForError(env, obj, err, true /*canThrowRemoteException*/);
}
}
}

JavaDeathRecipient(JNIEnv* env, jobject object, const sp<DeathRecipientList>& list)
: mVM(jnienv_to_javavm(env)), mObject(env->NewGlobalRef(object)),// object -> DeathRecipient 变为 GC root
mObjectWeak(NULL), mList(list)
{
// These objects manage their own lifetimes so are responsible for final bookkeeping.
// The list holds a strong reference to this object.
LOGDEATH("Adding JDR %p to DRL %p", this, list.get());
list->add(this);

gNumDeathRefsCreated.fetch_add(1, std::memory_order_relaxed);
gcIfManyNewRefs(env);
}

unlinkToDeath 最终会在 Jni 中 通过 env->DeleteGlobalRef(mObject),告诉虚拟机 AuthSession 不是GC root。


virtual ~JavaDeathRecipient()
{
//ALOGI("Removing death ref: recipient=%p\n", mObject);
gNumDeathRefsDeleted.fetch_add(1, std::memory_order_relaxed);
JNIEnv* env = javavm_to_jnienv(mVM);
if (mObject != NULL) {
env->DeleteGlobalRef(mObject);// object -> DeathRecipient GC root 被撤销
} else {
env->DeleteWeakGlobalRef(mObjectWeak);
}
}

解决方式


AuthSession 置空的时候调用 IBiometricServiceReceiver 的 unlinkToDeath 方法。


总结


以上梳理的其实就是 Binder 的造成的内存泄漏。


问题严重性来看,也不算什么大问题,因为调用 BiometricPrompt 的进程被杀,system 进程相关实例也就回收释放了。一般 app 也不太可能出现,常驻进程,而且还频繁调用手机认证的。


这里主要介绍了一种容易被忽略的内存泄漏,Binder.linktoDeath()。
Google issuetracker


参考资料


Binder | 对象的生命周期


作者:Jingle_zhang
来源:juejin.cn/post/7202066794299129914
收起阅读 »

曹贼,莫要动‘我’网站 —— MutationObserver

web
前言 本篇主要讲述了如何禁止阻止控制台调试以及使用了MutationObserver构造方法禁止修改DOM样式。 正文 话说在三国时期,大都督周瑜为了向世人宣布自己盛世容颜的妻子小乔,并专门创建了一个网站来展示自己的妻子 这么好看的看的小乔,谁看谁不糊,更何...
继续阅读 »

前言


本篇主要讲述了如何禁止阻止控制台调试以及使用了MutationObserver构造方法禁止修改DOM样式。


正文


话说在三国时期,大都督周瑜为了向世人宣布自己盛世容颜的妻子小乔,并专门创建了一个网站来展示自己的妻子


image.png
这么好看的看的小乔,谁看谁不糊,更何况曹老板。这天,曹操在浏览网页的时候,无意间发现了周瑜的这个网站,看着美若天仙的小乔,曹操的眼泪止不住的从嘴角流了下来。赶紧将网站上的照片保存了下来。

这个消息最后传到了周瑜的耳朵里,他只是想展示小乔,可不是为了让别人下载的。于是在自己的网站上做了一些预防措施。

为了防止他人直接在网站上直接下载图片,周瑜将右键的默认事件给关闭了,并且为了防止有人打开控制台,并对图片保存,采取了以下方法:


禁用右键和F12键


//给整个document添加右击事件,并阻止默认行为
document.addEventListener("contextmenu", function (e) {
e.preventDefault();
return false;
});

//给整个页面禁用f12按键 keyCode即将被禁用 不再推荐使用 但仍可以使用
document.addEventListener("keydown", function (e) {
//当点了f3\f6\f10之后,即使禁用了f12键依旧可以打开控制台,所以一并禁用
if (
[115, 118, 121, 123].includes(e.keyCode) ||
["F3", "F6", "F10", "F12"].includes(e.key) ||
["F3", "F6", "F10", "F12"].includes(e.code) ||
//ctrl+f 效果和f3效果一样 点开搜索之后依旧可以点击f12 打开控制台 所以一并禁用
//缺点是此网站不再能够 **全局搜索**
(e.ctrlKey && (e.key == "f" || e.code == "KeyF" || e.keyCode == 70))||
//禁用专门用于打开控制台的组合键
(e.ctrlKey && e.shiftKey && (e.key == "i" || e.code == "KeyI" || e.keyCode == 73))
) {
e.preventDefault();
return false;
}
});

当曹操再次想保存小乔照片的时候,发现使用网页的另存了已经没用了。这能难倒曹老板吗,破解方法,在浏览器的右上角进行操作就可打开控制台,这个地方是浏览器自带的,没办法禁用


image.png
这番操作之后,曹操可以选择元素保存那个图片了。周瑜的得知了自己的禁用措施被破解后,赶忙连夜加班打补丁,于是又加了一些操作,禁止打开控制台后进行操作


禁用控制台


如何判定控制台被打开了,可以使用窗口大小来判定


function resize() {
var threshold = 100;
//窗口的外部减窗口内超过100就判定窗口被打开了
var widthThreshold = window.outerWidth - window.innerWidth > threshold;
var heightThreshold = window.outerHeight - window.innerHeight > threshold;
if (widthThreshold || heightThreshold) {
console.log("控制台打开了");
}
}
window.addEventListener("resize", resize);

但是也容易被破解,只要让控制台变成弹窗窗口就可以了


也可以使用定时器进行无限debugger,因为只有在控制台打开的时候debugger才会生效。关闭控制台的时候,并不会影响功能。当前网页内存占用比较大的时候,定时器的占用并不明显。在当前网页占用比较小的时候,一直开着定时器才会有较为明显的提升


  setInterval(() => {
(function () {})["constructor"]("debugger")();
}, 500);

破解方法一样有,在debugger的位置右键禁用调试就可以了。这样控制台就可以正常操作了


image.png
既然有方法破解,就还要做一层措施,既然是要保存图片,那就把img转成canvas,这样即使打开控制台也没办法进行对图片的保存


//获取dom
const img = document.querySelector(".img");
const canvas = document.querySelector("#canvas");
//img转成canvas
canvas.width = img.width;
canvas.height = img.height;
ctx = canvas.getContext("2d");
ctx.drawImage(img, 0, 0, img.width, img.height);
document.body.removeChild(img);

经过一夜的努力,该加的措施都加上了。周瑜心想这下就没办法保存我的小乔了吧。

来到曹操这边,再次打开周瑜的小破站,还想故技重施时,发现已经有了各种显示,最后也没难倒曹操,那些阻碍也都被破解了。但是到保存图片的时候傻眼了,竟然已经不是图片格式了,那就没办法下载了呀。但是小乔真的很养神,曹操心有不甘,于是使用了最后一招,既然没办法下载那就截图,虽然有损画质,但是依旧能看。


得知如此情况的大都督周瑜不淡定了,从未见过如此厚颜无耻之人,竟然使用截图。


006APoFYly1g2qcclw1frg308w06ox2t.gif
话说魔高一尺,道高一丈,周瑜再次熬夜加班进行对网站的优化。于是使用了全屏水印+MutationObserver监听水印dom的方法。即使截图也让他看着不舒服。


MutationObserver


MutationObserver是一个构造函数,接口提供了监视对 DOM 树所做更改的能力。它被设计为旧的 Mutation Events 功能的替代品,该功能是 DOM3 Events 规范的一部分。

它接收一个回调函数,每当监听的dom发生改变时,就会调用这个函数,函数传入一个参数,数组包对象的格式,里面记录着dom的变化以及dom的信息。


image.png
返回的实例是一个新的、包含监听 DOM 变化回调函数的 MutationObserver 对象。有三个方法observedisconnecttakeRecords



  • observe接收两个参数,第一个为要监听的dom元素,第二个则是一些配置对象,当调用 observe() 时,childListattributes 和 characterData 中,必须有一个参数为 true。否则会抛出 TypeError 异常。配置对象如下:

    • subtree:当为 true 时,将会监听以 target 为根节点的整个子树。包括子树中所有节点的属性,而不仅仅是针对 target。默认值为 false

    • childList:当为 true 时,监听 target 节点中发生的节点的新增与删除(同时,如果 subtree 为 true,会针对整个子树生效)。默认值为 false

    • attributes:当为 true 时观察所有监听的节点属性值的变化。默认值为 true,当声明了 attributeFilter 或 attributeOldValue,默认值则为 false

    • attributeFilter:一个用于声明哪些属性名会被监听的数组。如果不声明该属性,所有属性的变化都将触发通知。

    • attributeOldValue:当为 true 时,记录上一次被监听的节点的属性变化;可查阅监听属性值了解关于观察属性变化和属性值记录的详情。默认值为 false

    • characterDate:当为 true 时,监听声明的 target 节点上所有字符的变化。默认值为 true,如果声明了 characterDataOldValue,默认值则为 false

    • characterDateOldValue:当为 true 时,记录前一个被监听的节点中发生的文本变化。默认值为 false



  • disconnect方法用来停止观察(当被观察dom节点被删除后,会自动停止对该dom的观察),不接受任何参数

  • takeRecords:方法返回已检测到但尚未由观察者的回调函数处理的所有匹配 DOM 更改的列表,使变更队列保持为空。此方法最常见的使用场景是在断开观察者之前立即获取所有未处理的更改记录,以便在停止观察者时可以处理任何未处理的更改。


该构造函数监听的dom即使在控制台中被更改属性或值,也会被监听到。


使用MutationObserver对水印dom进行监听,并限制更改。


<style>
//定义水印的样式
#watermark {
width: 100vw;
height: 100vh;
position: absolute;
left: 0;
top: 0;
font-size: 34px;
color: #32323238;
font-weight: 700;
display: flex;
flex-wrap: wrap;
justify-content: space-evenly;
align-content: space-evenly;
z-index: 9999999;
}
#watermark span {
transform: rotate(45deg);
}
</style>

<script>
//获取水印dom
const watermark = document.querySelector("#watermark");
//克隆水印dom ,用作后备,永远不要改变
const _watermark = watermark.cloneNode(true);
//获取水印dom的父节点
const d = watermark.parentNode;
//获取水印dom的后一个节点
let referenceNode;
[...d.children].forEach((item, index) => {
if (item == watermark) referenceNode = d.children[index + 1];
});
//定义MutationObserver实例observe方法的配置对象
const prop = {
childList: true,//针对整个子树
attributes: true,//属性变化
characterData: true,//监听节点上字符变化
subtree: true,//监听以target为根节点的整个dom树
};
//定义MutationObserver
const observer = new MutationObserver(function (mutations) {
//在这里每次坚挺的dom发生改变时 都会运行,传入的参数为数组对象格式
mutations.forEach((item) => {
//这里可以只针对监听dom的样式来判断
if (item.attributeName === "style") {
//获取父节点的所有子节点,因为时伪数组,使用扩展运算符转以下
[...d.children].forEach((v) => {
//判断一下,是父节点里的那个节点被改变了,并且删除那个被改变的节点(也就是删除水印节点)
if (item.target.id && v == document.querySelector(`#${item.target.id}`)) {
v.remove();
}
});
//原水印节点被删除了,这里使用克隆的水印节点,再次克隆
const __watermark = _watermark.cloneNode(true);
//这里的this指向是MutationObserver的实例对象,所以同样可以使用observe监听dom
//监听第二次克隆的dom
this.observe(__watermark, prop);
//因为水印dom被删除了,再将克隆的水印dom添加到原来的位置 就是referenceNode节点的前面
d.insertBefore(__watermark, referenceNode);
}
});
});
在初始化的时候监听初始化的水印dom
observer.observe(watermark, prop);
</script>



这样,每当对水印dom进行更改样式的时候,就会删除该节点,并重新添加一个初始的水印dom,即使突破重重困难打开开控制台,用户也是无法对dom 进行操作。


视频转Gif_爱给网_aigei_com.gif


隔天曹操再次打开网页,发现网页上的水印,心里不足为惧,心想区区水印能难倒自己?操作到最后却发现,不论如何对水印dom进行操作,都无法改变样式。虽说只是为了保存图片,但是截图有着这样水印,任谁也不舒服呀。曹操大怒,刚吃了两口的饭啪的一下就盖在了桌子上......


20230508094549_33500.gif
然而曹操不知道的是,在控制台中,获取dom节点右键是可以只下载获取的那个节点的......


image.png


结尾


文章主要是以鬼畜恶搞的方式讲述了,如何禁止用户打开控制台(还有重写toSring,consloe.log等一些方法,但我并没有没有实现,所以这里并没有写上),并且如何使用MutationObserver构造函数来监听页面中的dom元素。其实大多情况下并没有这方面的项目需求,完全可以当扩展知识看了。


写的不好的地方可以提出意见,虚心请教!


作者:iceCode
来源:juejin.cn/post/7290862554657423396
收起阅读 »

什么是Spring Boot中的@Async

异步方法 随着硬件和软件的高度发展,现代应用变得更加复杂和要求更高。由于 高需求,工程师总是试图寻找新的方法来提高应用程序性能和响应能力。慢节奏应用程序的一种解决方案是实施异步方法。异步处理是一种执行任务并发运行的进程或函数,无需等待一个任务完成后再开始另一个...
继续阅读 »

异步方法


随着硬件和软件的高度发展,现代应用变得更加复杂和要求更高。由于

高需求,工程师总是试图寻找新的方法来提高应用程序性能和响应能力。慢节奏应用程序的一种解决方案是实施异步方法。异步处理是一种执行任务并发运行的进程或函数,无需等待一个任务完成后再开始另一个任务。在本文中,我将尝试探索 Spring Boot 中的异步方法和 @Async 注解,试图解释多线程和并发之间的区别,以及何时使用或避免它。


Spring中的@Async是什么?


Spring 中的 @Async 注解支持方法调用的异步处理。它指示框架在单独的线程中执行该方法,允许调用者继续执行而无需等待该方法完成。这

提高了应用程序的整体响应能力和吞吐量。


要使用@Async,您必须首先通过将@EnableAsync注释添加到配置类来在应用程序中启用异步处理:


@Configuration
@EnableAsync
public class AppConfig {
}

接下来,用@Async注解来注解你想要异步执行的方法:



@Service
public class AsyncService {
@Async
public void asyncMethod() {
// Perform time-consuming task
}
}

@Async 与多线程和并发有何不同?


有时,区分多线程和并发与并行执行可能会让人感到困惑,但是,两者都与并行执行相关。他们每个人都有自己的用例和实现:



  • @Async 注解是 Spring 框架特定的抽象,它支持异步执行。它提供了轻松使用异步的能力,在后台处理所有艰苦的工作,例如线程创建、管理和执行。这使用户能够专注于业务逻辑而不是底层细节。

  • 多线程是一个通用概念,通常指操作系统或程序同时管理多个线程的能力。由于 @Async 帮助我们自动完成所有艰苦的工作,在这种情况下,我们可以手动处理所有这些工作并创建一个多线程环境。 Java 具有ThreadExecutorService等必要的类来创建和使用多线程。

  • 并发是一个更广泛的概念,它涵盖多线程和并行执行技术。它是

    系统在一个或多个处理器上同时执行多个任务的能力。


综上所述,@Async是一种更高层次的抽象,它为开发人员简化了异步处理,而多线程和并发更多的是手动管理并行执行。


何时使用 @Async 以及何时避免它。


使用异步方法似乎非常直观,但是,必须考虑到这种方法也有注意事项。


在以下情况下使用@Async:



  • 您拥有可以并发运行的独立且耗时的任务,而不会影响应用程序的响应能力。

  • 您需要一种简单而干净的方法来启用异步处理,而无需深入研究低级线程管理。


在以下情况下避免使用 @Async:



  • 您想要异步执行的任务具有复杂的依赖性或需要大量的协调。在这种情况下,您可能需要使用更高级的并发 API,例如CompletableFuture或反应式编程库,例如 Project Reactor。

  • 您必须精确控制线程的管理方式,例如自定义线程池或高级同步机制。在这些情况下,请考虑使用 Java 的ExecutorService或其他并发实用程序。


在 Spring Boot 应用程序中使用 @Async。


在此示例中,我们将创建一个简单的 Spring Boot 应用程序来演示 @Async 的使用。

让我们创建一个简单的订单管理服务。



  1. 创建一个具有最低依赖要求的新 Spring Boot 项目:


    org.springframework.boot:spring-boot-starter

    org.springframework.boot:spring-boot-starter-web

    Web 依赖用于 REST 端点演示目的。 @Async 带有引导启动程序。


  2. 将 @EnableAsync 注释添加到主类或应用程序配置类(如果我们使用它):


@SpringBootApplication
@EnableAsync
public class AsyncDemoApplication {
public static void main(String[] args) {
SpringApplication.run(AsyncDemoApplication.class, args);
}
}

@Configuration
@EnableAsync
public class ApplicationConfig {}


  1. 对于最佳解决方案,我们可以做的是,创建一个自定义 Executor bean 并根据我们的需要在同一个 Configuration 类中对其进行自定义:


   @Configuration
@EnableAsync
public class ApplicationConfig {

@Bean
public Executor getAsyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5);
executor.setMaxPoolSize(10);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("");
executor.initialize();
return executor;
}
}

通过此配置,我们可以控制最大和默认线程池大小。以及其他有用的定制。



  1. 使用 @Async 方法创建 OrderService 类:


@Service
public class OrderService {

@Async
public void saveOrderDetails(Order order) throws InterruptedException {
Thread.sleep(2000);
System.out.println(order.name());
}

@Async
public CompletableFuture<String> saveOrderDetailsFuture(Order order) throws InterruptedException {
System.out.println("Execute method with return type + " + Thread.currentThread().getName());
String result = "Hello From CompletableFuture. Order: ".concat(order.name());
Thread.sleep(5000);
return CompletableFuture.completedFuture(result);
}

@Async
public CompletableFuture<String> compute(Order order) throws InterruptedException {
String result = "Hello From CompletableFuture CHAIN. Order: ".concat(order.name());
Thread.sleep(5000);
return CompletableFuture.completedFuture(result);
}
}

我们在这里所做的是创建 3 种不同的异步方法。第一个saveOrderDetails服务是一个简单的异步

服务,它将开始异步计算。如果我们想使用现代异步Java功能,

例如CompletableFuture,我们可以通过服务来实现saveOrderDetailsFuture。通过这个服务,我们可以调用一个线程来等待@Async的结果。应该注意的是,CompletableFuture.get()在结果可用之前会阻塞。如果我们想在结果可用时执行进一步的异步操作,我们可以使用thenApplythenAccept或 CompletableFuture 提供的其他方法。



  1. 创建一个 REST 控制器来触发异步方法:


@RestController
public class AsyncController {

private final OrderService orderService;

public OrderController(OrderService orderService) {
this.orderService = orderService;
}

@PostMapping("/process")
public ResponseEntity<Void> process(@RequestBody Order order) throws InterruptedException {
System.out.println("PROCESSING STARTED");
orderService.saveOrderDetails(order);
return ResponseEntity.ok(null);
}

@PostMapping("/process/future")
public ResponseEntity<String> processFuture(@RequestBody Order order) throws InterruptedException, ExecutionException {
System.out.println("PROCESSING STARTED");
CompletableFuture<String> orderDetailsFuture = orderService.saveOrderDetailsFuture(order);
return ResponseEntity.ok(orderDetailsFuture.get());
}

@PostMapping("/process/future/chain")
public ResponseEntity<Void> processFutureChain(@RequestBody Order order) throws InterruptedException, ExecutionException {
System.out.println("PROCESSING STARTED");
CompletableFuture<String> computeResult = orderService.compute(order);
computeResult.thenApply(result -> result).thenAccept(System.out::println);
return ResponseEntity.ok(null);
}
}

现在,当我们访问/process端点时,服务器将立即返回响应,同时

继续saveOrderDetails()在后台执行。 2秒后,服务完成。第二个端点 -/process/future将使用我们的第二个选项,CompletableFuture在这种情况下,5 秒后,服务将完成,并将结果存储在CompletableFuture我们可以进一步使用future.get()来访问结果。在最后一个端点 - 中/process/future/chain,我们优化并使用了异步计算。控制器使用相同的服务方法CompletableFuture,但不久之后,我们将使用thenApply,thenAccept方法。服务器立即返回响应,我们不需要等待5秒,计算将在后台完成。在这种情况下,最重要的一点是对异步服务的调用,在我们的例子中compute()必须从同一类的外部完成。如果我们在一个方法上使用@Async并在同一个类中调用它,它将不起作用。这是因为Spring使用代理来添加异步行为,并且在内部调用方法会绕过代理。为了使其发挥作用,我们可以:



  • 将 @Async 方法移至单独的服务或组件。

  • 使用 ApplicationContext 获取代理并调用其上的方法。


总结


Spring 中的 @Async 注解是在应用程序中启用异步处理的强大工具。通过使用@Async,我们不需要陷入并发管理和多线程的复杂性来增强应用程序的响应能力和性能。但要决定何时使用 @Async 或使用替代并发

使用程序,了解其局限性和用例非常重要。


作者:it键盘侠
来源:juejin.cn/post/7330227149176881161
收起阅读 »

前端实现 word 转 png

web
在此之前 word 转图片的需求都是在后端实现的,用的是 itext 库,但是 itext 收费的,商用需要付费。近期公司内部安全部门发出侵权警告,要求整改。 所以采用前端实现 word 文档转图片功能。 一、需求 用户在页面上上传 .docx 格式的文件...
继续阅读 »

在此之前 word 转图片的需求都是在后端实现的,用的是 itext 库,但是 itext 收费的,商用需要付费。近期公司内部安全部门发出侵权警告,要求整改。


所以采用前端实现 word 文档转图片功能。



一、需求



  1. 用户在页面上上传 .docx 格式的文件

  2. 前端拿到文件,解析并生成 .png 图片

  3. 上传该图片到文件服务器,并将图片地址作为缩略图字段


二、难点


目前来看,前端暂时无法直接实现将 .docx 文档转成图片格式的需求


三、解决方案


既然直接转无法实现,那就采用迂回战术



  1. 先转成 html(用到库 docx-preview

  2. 再将 html 转成 canvas(用到库 html2canvas

  3. 最后将 canvas 转成 png


四、实现步骤




  1. .docx 文件先转成 html 格式,并插入到目标节点中


    安装 docx-preview 依赖: pnpm add docx-preview --save




jsx
复制代码
import { useEffect } from 'react';
import * as docx from 'docx-preview';

export default ({ file }) => {
useEffect(() => {
// file 为上传好的 docx 格式文件
docx2Html(file);
}, [file]);

/**
* @description: docx 文件转 html
* @param {*} file: docx 格式文件
* @return {*}
*/
const docx2Html = file => {
if (!file) {
return;
}
// 只处理 docx 文件
const suffix = file.name?.substr(file.name.lastIndexOf('.') + 1).toLowerCase();
if (suffix !== 'docx') {
return;
}
// 生成 html 后挂载的 dom 节点
const htmlContentDom = document.querySelector('#htmlContent');
const docxOptions = Object.assign(docx.defaultOptions, {
debug: true,
experimental: true,
});
docx.renderAsync(file, htmlContentDom, null, docxOptions).then(() => {
console.log('docx 转 html 完成');
});
};

return <div id='htmlContent' />;
};

此时,在 idhtmlContent 的节点下,就可以看到转换后的 html 内容了( htmlContent 节点的宽高等 css 样式自行添加)




  1. html 转成 canvas


    安装 html2canvas 依赖: pnpm add html2canvas --save




jsx
复制代码
import html2canvas from 'html2canvas';

/**
* @description: dom 元素转为图片
* @return {*}
*/
const handleDom2Img = async () => {
// 生成 html 后挂载的 dom 节点
const htmlContentDom = document.querySelector('#htmlContent');
// 获取刚刚生成的 dom 元素
const htmlContent = htmlContentDom.querySelectorAll('.docx-wrapper>section')[0];
// 创建 canvas 元素
const canvasDom = document.createElement('canvas');
// 获取 dom 宽高
const w = parseInt(window.getComputedStyle(htmlContent).width, 10);
// const h = parseInt(window.getComputedStyle(htmlContent).height, 10);

// 设定 canvas 元素属性宽高为 DOM 节点宽高 * 像素比
const scale = window.devicePixelRatio; // 缩放比例
canvasDom.width = w * scale; // 取文档宽度
canvasDom.height = w * scale; // 缩略图是正方形,所以高度跟宽度保持一致

// 按比例增加分辨率,将绘制内容放大对应比例
const canvas = await html2canvas(htmlContent, {
canvas: canvasDom,
scale,
useCORS: true,
});
return canvas;
};


  1. 将生成好的 canvas对象转成 .png 文件,并下载


jsx
复制代码
// 将 canvas 转为 base64 图片
const base64Str = canvas.toDataURL();

// 下载图片
const imgName = `图片_${new Date().valueOf()}`;
const aElement = document.createElement('a');
aElement.href = base64Str;
aElement.download = `${imgName}.png`;
document.body.appendChild(aElement);
aElement.click();
document.body.removeChild(aElement);
window.URL.revokeObjectURL(base64Str);

五、总结


前端无法直接实现将 .docx 文档转成图片格式,所以要先将 .docx 文档转换成 html 格式,并插入页面文档节点中,然后根据 html 内容生成canvas对象,最后将 canvas对象转成 .png 文件


有以下两个缺点:



  1. 只能转 .docx 格式的 word 文档,暂不支持 .doc 格式;

  2. 无法自动获取文档第一页来生成图片内容,需要先将 word 所有页面生成为 html,再通过 canvas 手动裁切,来确定图片宽高。

作者:Tim_
链接:https://juejin.cn/post/7331799381896151067
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

Android-桌面小组件RemoteViews播放动画

一、前言 前段时间什么比较火?当然是木鱼了,木鱼一敲,烦恼全消~在这个节奏越来越快的社会上,算是一个不错的解压利器! 我们也紧跟时事,推出了  我要敲木鱼(各大市场均可以下载哦~) 咳咳,扯远了,说回正题 我们在后台收到大量反馈,说是希望添加桌面组件敲木鱼功能...
继续阅读 »

一、前言


前段时间什么比较火?当然是木鱼了,木鱼一敲,烦恼全消~在这个节奏越来越快的社会上,算是一个不错的解压利器!


我们也紧跟时事,推出了  我要敲木鱼(各大市场均可以下载哦~)


咳咳,扯远了,说回正题


我们在后台收到大量反馈,说是希望添加桌面组件敲木鱼功能。好嘛,用户的话就是圣旨,那必须要安排上,正好我也练练手。


老规矩,先来看下我实现的效果



这个功能看着很简单对吧,却也花了我一天半的时间。主要用来实现敲击动画了!!


二、代码实现


1、新建小组件



 2、修改界面样式


主要会生成3个关键文件(文件名根据你设置的来)

①、APPWidget  类,继承于 AppWidgetProvider,本质是一个 BroadCastReceiver


②、layout/widget.xml ,小组件布局文件


③、xml/widget_info.xml ,小组件信息说明文件


同时会在 AndroidManifest中注册好


类似如下代码:


     <receiver
android:name=".receiver.MuyuAppWidgetBig"
android:exported="false">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
<action android:name="com.fyspring.bluetooth.receiver.action_appwidget_muyu_knock" />
</intent-filter>

<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/app_widget_info_big" />
</receiver>

3、添加敲木鱼逻辑代码


通过 APPWidget 的模板代码我们知道,内部通过 RemoteViews 来进行更新View,而我们都知道 RemoteViews 是无法通过 findViewById 来转成对应的 view,更无法对其添加 Animator。那么我们该怎么办来给桌面木鱼组件添加一个 缩放动画呢?


给你三秒时间考虑下,这里我可花了一天时间来研究....


通过 layoutAnimation !!!


layoutAnimation 是在 ViewGr0up 创建之后,显示时作用的,作用时间是:ViewGr0up 的首次创建显示,之后再有改变就不行了。


虽然 RemoteViews 不能执行 findViewById,但它提供了两个关键方法: remoteViews.removeAllViews  和  remoteViews.addView 。如果我们在点击时,向组件布局中添加一个带有 layoutAnimation 的布局,不是就可以间接播放动画了么?


关键代码:


private fun doAnimation(context: Context?, remoteViews: RemoteViews?) {
remoteViews?.removeAllViews(R.id.muyu_rl)
val remoteViews2 = RemoteViews(context?.packageName, R.layout.anim_layout)
remoteViews2.setImageViewResource(R.id.widget_muyu_iv, R.mipmap.ic_muyu)
remoteViews?.addView(R.id.muyu_rl, remoteViews2)
}

小组件布局:


<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
style="@style/Widget.BlueToothDemo.AppWidget.Container"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/shape_round_bg"
android:theme="@style/Theme.BlueToothDemo.AppWidgetContainer">

<LinearLayout
android:layout_width="140dp"
android:layout_height="140dp"
android:gravity="center_horizontal"
android:orientation="vertical">

<TextView
android:id="@+id/appwidget_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerHorizontal="true"
android:contentDescription="测试桌面木鱼"
android:text="已敲0次"
android:textColor="@color/white"
android:textSize="18sp"
android:textStyle="bold" />

<RelativeLayout
android:id="@+id/muyu_rl"
android:layout_width="match_parent"
android:layout_height="match_parent">

<ImageView
android:id="@+id/widget_muyu_iv"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_centerHorizontal="true"
android:layout_margin="15dp"
android:src="@mipmap/ic_muyu" />

</RelativeLayout>
</LinearLayout>
</RelativeLayout>

添加替换的动画布局(anim_layout.xml),注意两边的木鱼ImgView 的 ID保持一致,因为要统一设置点击事件!!


<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layoutAnimation="@anim/muyu_anim">

<ImageView
android:id="@+id/widget_muyu_iv"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:src="@mipmap/ic_muyu2" />
</RelativeLayout>

动画文件:(muyu_anim.xml)


<?xml version="1.0" encoding="utf-8"?>
<layoutAnimation xmlns:android="http://schemas.android.com/apk/res/android"
android:animation="@anim/scale_anim"/>


动画文件:(scale_anim.xml)


<?xml version="1.0" encoding="utf-8"?>
<scale xmlns:android="http://schemas.android.com/apk/res/android"
android:duration="100"
android:fromXScale="0.9"
android:fromYScale="0.9"
android:interpolator="@android:anim/accelerate_interpolator"
android:pivotX="50%"
android:pivotY="50%"
android:toXScale="1"
android:toYScale="1" />

关键动画代码就是以上这些,如果有问题欢迎私信。希望大家在新的一年里,木鱼一敲,烦恼全消~


欢迎体验下我做的木鱼,记得搜  我要敲木鱼  哦~~


作者:今夜太冷不宜私奔丶
来源:juejin.cn/post/7323025855154962459
收起阅读 »

低成本创建数字孪生场景-开发篇

web
介绍 本文承接《低成本创建数字孪生场景-数据篇》,获取到了数据之后就是愉快的开发环节,我们用到了开源项目CesiumJS做为GIS技术框架。 CesiumJS项目从创建至今已经有12年历史,提供了创建3D地球和2D地图的强大功能,它支持多种地图数据源,可以创建...
继续阅读 »

介绍


本文承接《低成本创建数字孪生场景-数据篇》,获取到了数据之后就是愉快的开发环节,我们用到了开源项目CesiumJS做为GIS技术框架。


CesiumJS项目从创建至今已经有12年历史,提供了创建3D地球和2D地图的强大功能,它支持多种地图数据源,可以创建复杂的3D地形和城市模型。CesiumJS的功能强大,但入门难度比较高,需要提前了解很多概念和设计理念,为方便理解本案例仅仅使用其提供的一些基础功能。


Guanlianx_5.gif


需求说明


为了搭建1个简易的山区小乡镇场景,我们首先梳理下需求,把任务分解好。



  1. 在底图上叠加各种图层

    • 支持叠加地形图层、3DTiles图层、数据图层

    • 支持多种方式分发图层数据



  2. 鼠标与图层元素的交互

    • 鼠标移动时,使用屏幕事件处理器监听事件,获取当前屏幕坐标

    • 如果已经有高亮的元素,将其恢复为正常状态

    • 以当前屏幕坐标为起点发送射线,获取射线命中的元素,如果有命中的元素就高亮它

    • 鼠标点击时使用屏幕事件处理器获取命中元素,如果命中了,就判断元素是否描边状态,有则取消描边,没有则增加描边轮廓



  3. 加载Gltf等其他模型

    • 模型与其他图层元素一样,可以被光标拾取

    • 模型支持播放自带动画




准备工作


数据分发服务


当前案例涉及的图层数据以文件类为主,为方便多处使用,需要将图层的服务独立部署,这里有两个方案:



  1. 自行搭建静态文件服务器,网上搜一个常用的node.js静态服务脚本即可

  2. 把文件放到cesium ion上,如果你要使用cesium ion资产,需要注意配好defaultAccessToken,具体调用方式看下文的代码实现


安装依赖


以下为本案例的前端工程使用的核心框架版本


依赖版本
vue^3.2.37
vite^2.9.14
Cesium^1.112.0

代码实现



  1. 地图基本场景,本示例使用vite+vue3开发,html非常简单,只需要一个

    标签即可,在cesiumjs中以Viewer为起点调用其他控件,因此我们实例化一个Cesium.viewer, 这里面有非常多配置参数,详细请看开发文档


    import * as Cesium from 'cesium'
    import 'cesium/Build/Cesium/Widgets/widgets.css'

    Cesium.Ion.defaultAccessToken = '可以把一些GIS资产放到Cesium ION上托管,Tokenw为调用凭证'

    // 地图中心
    const center = [1150, 29]

    // cesium实例
    let viewer = null

    // 容器
    const cesiumContainer = ref(null)

    onMounted(async () => {
    await init()
    })

    async function init() {
    viewer = new Cesium.Viewer(cesiumContainer.value, {
    timeline: true, //显示时间轴
    animation: true, //开启动画
    sceneModePicker: true, //场景内容可点击
    baseLayerPicker: true, //图层可点击
    infoBox: false, // 自动信息弹窗
    shouldAnimate: true // 允许播放动画
    })
    // 初始化镜头视角
    restoreCameraView()

    // 开启地形深度检测
    viewer.scene.globe.depthTestAgainstTerrain = true
    // 开启全局光照
    viewer.scene.globe.enableLighting = true
    // 开启阴影
    viewer.shadows = true

    })

    // 设置初始镜头
    function restoreCameraView(){
    viewer.camera.flyTo({
    destination: Cesium.Cartesian3.fromDegrees(center[0], center[1], 0),
    orientation: {
    heading: Cesium.Math.toRadians(0), // 相机的方向
    pitch: Cesium.Math.toRadians(-90), // 相机的俯仰角度
    roll: 0 // 相机的滚动角度
    }
    })
    }

    // 加载地形图层
    async function initTerrainLayer() {
    const tileset = await Cesium.CesiumTerrainProvider.fromUrl(
    'http://localhost:9003/terrain/c8Wcm59W/',
    {
    requestWaterMask: true,
    requestVertexNormals: false
    }
    )
    viewer.terrainProvider = tileset
    }


  2. 在地图上叠加地形图层,图层数据可以自行部署


    // 方法1: 加载本地地形图层
    async function initTerrainLayer() {
    const tileset = await Cesium.CesiumTerrainProvider.fromUrl(
    'http://localhost:9003/terrain/c8Wcm59W/',
    {
    requestWaterMask: true,
    requestVertexNormals: false
    }
    )
    viewer.terrainProvider = tileset
    }

    // 方法2: 加载Ion地形图层
    async function initTerrainLayer() {
    const tileset = await Cesium.CesiumTerrainProvider.fromIonAssetId(1,{
    requestVertexNormals: true
    }
    )
    viewer.terrainProvider = tileset
    }


  3. 加载3DTiles图层,与地形图层类似,换成了Cesium3DTileset类。需要注意使用url加载需要自行解决跨域问题


    const tileset = await Cesium.Cesium3DTileset.fromUrl(
    'http://localhost:9003/model/tHuVnsJXZ/tileset.json',
    {}
    )
    // 将图层加入到场景
    viewer.scene.primitives.add(tileset)

    // 适当调整图层位置
    const translation = getTransformMatrix(tileset, { x: 0, y: 0, z: 86 })
    tileset.modelMatrix = Cesium.Matrix4.fromTranslation(translation)

    // 获取变化矩阵
    function getTransformMatrix (tileset, { x, y, z }) {
    // 高度偏差,正数为向上偏,负数为向下偏,根据真实的模型位置不断进行调整
    const heightOffset = z
    // 计算tileset的绑定范围
    const boundingSphere = tileset.boundingSphere
    // 计算中心点位置
    const cartographic = Cesium.Cartographic.fromCartesian(boundingSphere.center)
    // 计算中心点位置坐标
    const surface = Cesium.Cartesian3.fromRadians(cartographic.longitude,
    cartographic.latitude, 0)
    // 偏移后的三维坐标
    const offset = Cesium.Cartesian3.fromRadians(cartographic.longitude + x,
    cartographic.latitude + y, heightOffset)

    return Cesium.Cartesian3.subtract(offset, surface, new Cesium.Cartesian3())
    }


  4. 鼠标事件交互,鼠标悬浮,在改变选中元素的状态之前,需要将它的当前状态保存下来以便下次可以恢复。


    // 缓存高亮状态
    const highlighted = {
    feature: undefined,
    originalColor: new Cesium.Color()
    }

    // 鼠标与物体交互事件
    function initMouseInteract () {
    // 事件处理器
    const handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas)

    // 鼠标悬浮选中
    handler.setInputAction((event) => {
    // 将原有高亮对象恢复
    if (Cesium.defined(highlighted.feature)) {
    highlighted.feature.color = highlighted.originalColor
    highlighted.feature = undefined
    }
    // 获取选中对象
    const pickedFeature = viewer.scene.pick(event.endPosition)

    if (Cesium.defined(pickedFeature)) {
    // 高亮选中对象
    if (pickedFeature !== moveSelected.feature) {
    highlighted.feature = pickedFeature
    Cesium.Color.clone(pickedFeature.color, highlighted.originalColor)
    pickedFeature.color = Cesium.Color.YELLOW
    }
    }
    }, Cesium.ScreenSpaceEventType.MOUSE_MOVE)


  5. 鼠标事件,鼠标点击,描边轮廓使用了Cesium自带的后期效果处理器,不需要自行编写着色器等操作,因此实现起来很便捷。只需要将选中的元素放到 效果的selected对象数组内就行了。


    // 缓存后期效果
    let edgeEffect = null

    function initMouseInteract(){
    // 鼠标点击选中
    handler.setInputAction((event) => {

    // 获取选中对象
    const pickedFeature = viewer.scene.pick(event.position)

    if (!Cesium.defined(pickedFeature)) {
    return null
    } else {

    // 描边效果:兼容GLTF和3DTiles
    setEdgeEffect(pickedFeature.primitive || pickedFeature)

    // 如果拾取的要素包含属性信息,则打印出来
    if (Cesium.defined(pickedFeature.getPropertyIds)) {
    const propertyNames = pickedFeature.getPropertyIds()
    const props = propertyNames.map(key => {
    return {
    name: key,
    value: pickedFeature.getProperty(key)
    }
    })
    console.info(props)
    }
    }
    }, Cesium.ScreenSpaceEventType.LEFT_CLICK)
    }

    // 选中描边
    function setEdgeEffect (feature) {
    if (edgeEffect == null) {
    // 后期效果
    const postProcessStages = viewer.scene.postProcessStages

    // 增加轮廓线
    const stage = Cesium.PostProcessStageLibrary.createEdgeDetectionStage()
    stage.uniforms.color = Cesium.Color.LIME //描边颜色
    stage.uniforms.length = 0.05 // 产生描边的阀值
    stage.selected = [] // 用于放置对元素

    // 将描边效果放到场景后期效果中
    const silhouette = Cesium.PostProcessStageLibrary.createSilhouetteStage([stage])
    postProcessStages.add(silhouette)

    edgeEffect = stage
    }

    // 选多个元素进行描边
    const matchIndex = edgeEffect.selected.findIndex(v => v._batchId === feature._batchId)
    if (matchIndex > -1) {
    edgeEffect.selected.splice(matchIndex, 1)
    } else {
    edgeEffect.selected.push(feature)
    }

    }


  6. 加载gltf模型, gltf加载后需要进行一次矩阵变换modelMatrix, 加载后启动指定索引的动画进行播放。


    // 加载模型
    async function loadGLTF () {

    let animations = null

    let modelMatrix = Cesium.Transforms.eastNorthUpToFixedFrame(
    Cesium.Cartesian3.fromDegrees(lng,lat,altitude)
    )

    const model = await Cesium.Model.fromGltfAsync({
    url: './static/gltf/windmill.glb',
    modelMatrix: modelMatrix,
    scale: 30,
    // minimumPixelSize: 128, // 设定模型最小显示尺寸
    gltfCallback: (gltf) => {
    animations = gltf.animations
    }
    })

    model.readyEvent.addEventListener(() => {
    const ani = model.activeAnimations.add({
    index: animations.length - 1, // 播放第几个动画
    loop: Cesium.ModelAnimationLoop.REPEAT, //循环播放
    multiplier: 1.0 //播放速度
    })
    ani.start.addEventListener(function (model, animation) {
    console.log(`动画开始: ${animation.name}`)
    })
    })

    viewer.scene.primitives.add(model)
    }



部署说明



  1. 场景演示包括前端工程、GIS数据分发服务、服务端接口几个部分

  2. 前端工程使用vue3开发,其中CesiumJs通过NPM依赖包引入

  3. 场景中相关图层均为静态文件,可放入主工程静态目录中,也可以独立部署(需解决跨域访问),或者使用cesiumlab3分发服务便于管理

  4. web端场景对终端设备和浏览器有一定要求,具体配置需要进一步测试


总结


在本文中并没有涉及到服务端数据的接入,数据接入进来后,我们可以利用Cesium在GIS开发领域强大功能,与three.js的webGL开发优势,两者相互融合创建更多数据可视化效果。那么关于Cesium和three.js的融合开发还在初步探索阶段,希望下一次有精彩内容分享给大家。


Hengjiang3.gif


相关链接


最新版cesium集成threejs


Cesium和Three.js结合的5个方案


Cesium实现更实用的3D描边效果


作者:gyratesky
来源:juejin.cn/post/7331626882552872986
收起阅读 »

前端将dom转换成图片

web
一、问题描述 在工作的过程中会遇到要将dom变成图片并下载的问题,刚开始使用的html2canvas进行转换的,但是我老大告诉我说,咱们做的是面向国外的网页,插件太大会导致页面加载慢的问题(国外部分地区网络没有国内这么发达),就让我用原生dom或一些比较小的插...
继续阅读 »

一、问题描述


在工作的过程中会遇到要将dom变成图片并下载的问题,刚开始使用的html2canvas进行转换的,但是我老大告诉我说,咱们做的是面向国外的网页,插件太大会导致页面加载慢的问题(国外部分地区网络没有国内这么发达),就让我用原生dom或一些比较小的插件,在原生dom下载的时候遇到了context.drawImage(element, 0, 0, width, height)这一方法传入参数要传类型HTMLCanvasElement的问题,所以要将一个HTMLElement转换成HTMLCanvasElement,但是经过一些信息的查找,我发现有个很好用且轻量化的插件,可以完美解决这一问题,所以这里给大家推荐一个轻量级的插件dom-to-image(23kb),这个插件可以不用进行类型转换,直接将dom元素转换成需要的文件格式。


二、dom-to-image的使用


2.1 dom-to-image的安装


在终端输入以下代码进行dom-to-image安装



npm install dom-to-image



2.2 dom-to-image引入


2.2.1 vue项目引入


在需要使用这个插件的页面使用以下代码进行局部引入


import domToImage from 'dom-to-image';

然后就可以通过以下代码进行图片的转换了


const palGradientGap = document.getElementById('element')
const canvas = document.createElement('canvas')
canvas.width = element.offsetWidth
canvas.height = element.offsetHeight
this.domtoimage.toPng(element).then(function (canvas) {
const link = document.createElement('a')
link.href = canvas
link.download = 'image.png' // 下载文件的名称
link.click()
})

当然也可以进行全局引入
创建一个domToImage.js文件写入以下代码


import Vue from 'vue'; 
import domToImage from 'dom-to-image';
const domToImagePlugin = {
install(Vue) {
Vue.prototype.$domToImage = domToImage;
}
};
Vue.use(domToImagePlugin);

然后再入口文件main.js写入以下代码全局引入插件


import Vue from 'vue'
import App from './App.vue'
import './domToImage.js'; // 引入全局插件
Vue.config.productionTip = false
new Vue({ render: h => h(App), }).$mount('#app')

三、dom-to-image相关方法



  1. toSvg(node: Node, options?: Options): Promise<string>:将 DOM 元素转换为 SVG 图片,并返回一个 Promise 对象。


    参数说明:



    • node:要转换为图片的 DOM 元素。

    • options:可选参数对象,用于配置转换选项。



  2. toPng(node: Node, options?: Options): Promise<string>:将 DOM 元素转换为 PNG 图片,并返回一个 Promise 对象。


    参数说明:



    • node:要转换为图片的 DOM 元素。

    • options:可选参数对象,用于配置转换选项。



  3. toJpeg(node: Node, options?: Options): Promise<string>:将 DOM 元素转换为 JPEG 图片,并返回一个 Promise 对象。


    参数说明:



    • node:要转换为图片的 DOM 元素。

    • options:可选参数对象,用于配置转换选项。



  4. toBlob(node: Node, options?: Options): Promise<Blob>:将 DOM 元素转换为 Blob 对象,并返回一个 Promise 对象。


    参数说明:



    • node:要转换为图片的 DOM 元素。

    • options:可选参数对象,用于配置转换选项。



  5. toPixelData(node: Node, options?: Options): Promise<Uint8ClampedArray>:将 DOM 元素转换为像素数据,并返回一个 Promise 对象。


    参数说明:



    • node:要转换为图片的 DOM 元素。

    • options:可选参数对象,用于配置转换选项。



  6. toCanvas(node: Node, options?: Options): Promise<HTMLCanvasElement>:将 DOM 元素转换为 Canvas 对象,并返回一个 Promise 对象。


    参数说明:



    • node:要转换为图片的 DOM 元素。

    • options:可选参数对象,用于配置转换选项。




其中,Options 参数是一个可选的配置对象,用于设置转换选项。以下是一些常用的选项:



  • width:输出图像的宽度,默认值为元素的实际宽度。

  • height:输出图像的高度,默认值为元素的实际高度。

  • style:要应用于元素的样式对象。

  • filter:要应用于元素的 CSS 滤镜。

  • bgcolor:输出图像的背景颜色,默认值为透明。

  • quality:输出图像的质量,仅适用于 JPEG 格式,默认值为 0.92。


作者:crazy三笠
来源:juejin.cn/post/7331626882553937946
收起阅读 »

新来个架构师,把xxl-job原理讲的炉火纯青~~

大家好,我是三友~~ 今天来继续探秘系列,扒一扒轻量级的分布式任务调度平台Xxl-Job背后的架构原理 公众号:三友的java日记 核心概念 这里还是老样子,为了保证文章的完整性和连贯性,方便那些没有使用过的小伙伴更加容易接受文章的内容,快速讲一讲Xxl-...
继续阅读 »

大家好,我是三友~~


今天来继续探秘系列,扒一扒轻量级的分布式任务调度平台Xxl-Job背后的架构原理



公众号:三友的java日记



核心概念


这里还是老样子,为了保证文章的完整性和连贯性,方便那些没有使用过的小伙伴更加容易接受文章的内容,快速讲一讲Xxl-Job中的概念和使用


如果你已经使用过了,可直接跳过本节和下一节,快进到后面原理部分讲解


1、调度中心


调度中心是一个单独的Web服务,主要是用来触发定时任务的执行


它提供了一些页面操作,我们可以很方便地去管理这些定时任务的触发逻辑


调度中心依赖数据库,所以数据都是存在数据库中的


调度中心也支持集群模式,但是它们所依赖的数据库必须是同一个


所以同一个集群中的调度中心实例之间是没有任何通信的,数据都是通过数据库共享的



2、执行器


执行器是用来执行具体的任务逻辑的


执行器你可以理解为就是平时开发的服务,一个服务实例对应一个执行器实例


每个执行器有自己的名字,为了方便,你可以将执行器的名字设置成服务名


3、任务


任务什么意思就不用多说了


一个执行器中也是可以有多个任务的



总的来说,调用中心是用来控制定时任务的触发逻辑,而执行器是具体执行任务的,这是一种任务和触发逻辑分离的设计思想,这种方式的好处就是使任务更加灵活,可以随时被调用,还可以被不同的调度规则触发。




来个Demo


1、搭建调度中心


调度中心搭建很简单,先下载源码



github.com/xuxueli/xxl…



然后改一下数据库连接信息,执行一下在项目源码中的/doc/db下的sql文件



启动可以打成一个jar包,或者本地启动就是可以的


启动完成之后,访问下面这个地址就可以访问到控制台页面了



http://localhost:8080/xxl-job-admin/toLogin



用户名密码默认是 admin/123456


2、执行器和任务添加


添加一个名为sanyou-xxljob-demo执行器



任务添加



执行器选择我们刚刚添加的,指定任务名称为TestJob,corn表达式的意思是每秒执行一次


创建完之后需要启动一下任务,默认是关闭状态,也就不会执行




创建执行器和任务其实就是CRUD,并没有复杂的业务逻辑



按照如上配置的整个Demo的意思就是


每隔1s,执行一次sanyou-xxljob-demo这个执行器中的TestJob任务


3、创建执行器和任务


引入依赖


<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
        <version>2.2.5.RELEASE</version>
    </dependency>
    <dependency>
        <groupId>com.xuxueli</groupId>
        <artifactId>xxl-job-core</artifactId>
        <version>2.4.0</version>
    </dependency>
</dependencies>

配置XxlJobSpringExecutor这个Bean


@Configuration
public class XxlJobConfiguration {

    @Bean
    public XxlJobSpringExecutor xxlJobExecutor() {
        XxlJobSpringExecutor xxlJobSpringExecutor = new XxlJobSpringExecutor();
        //设置调用中心的连接地址
        xxlJobSpringExecutor.setAdminAddresses("http://localhost:8080/xxl-job-admin");
        //设置执行器的名称
        xxlJobSpringExecutor.setAppname("sanyou-xxljob-demo");
        //设置一个端口,后面会讲作用
        xxlJobSpringExecutor.setPort(9999);
        //这个token是保证访问安全的,默认是这个,当然可以自定义,
        // 但需要保证调度中心配置的xxl.job.accessToken属性跟这个token是一样的
        xxlJobSpringExecutor.setAccessToken("default_token");
        //任务执行日志存放的目录
        xxlJobSpringExecutor.setLogPath("./");
        return xxlJobSpringExecutor;
    }

}

XxlJobSpringExecutor这个类的作用,后面会着重讲


通过@XxlJob指定一个名为TestJob的任务,这个任务名需要跟前面页面配置的对应上


@Component
public class TestJob {

    private static final Logger logger = LoggerFactory.getLogger(TestJob.class);

    @XxlJob("TestJob")
    public void testJob() {
        logger.info("TestJob任务执行了。。。");
    }

}

所以如果顺利的话,每隔1s钟就会打印一句TestJob任务执行了。。。


启动项目,注意修改一下端口,因为调用中心默认也是8080,本地起会端口冲突


最终执行结果如下,符合预期



讲完概念和使用部分,接下来就来好好讲一讲Xxl-Job核心的实现原理


从执行器启动说起


前面Demo中使用到了一个很重要的一个类



XxlJobSpringExecutor



这个类就是整个执行器启动的入口



这个类实现了SmartInitializingSingleton接口


所以经过Bean的生命周期,一定会调用afterSingletonsInstantiated这个方法的实现


这个方法干了很多初始化的事,这里我挑三个重要的讲,其余的等到具体的功能的时候再提


1、初始化JobHandler


JobHandler是个什么?


所谓的JobHandler其实就是一个定时任务的封装



一个定时任务会对应一个JobHandler对象


当执行器执行任务的时候,就会调用JobHandler的execute方法


JobHandler有三种实现:



  • MethodJobHandler

  • GlueJobHandler

  • ScriptJobHandler


MethodJobHandler是通过反射来调用方法执行任务



所以MethodJobHandler的任务的实现就是一个方法,刚好我们demo中的例子任务其实就是一个方法


所以Demo中的任务最终被封装成一个MethodJobHandler


GlueJobHandler比较有意思,它支持动态修改任务执行的代码


当你在创建任务的时候,需要指定运行模式为GLUE(Java)



之后需要在操作按钮点击GLUE IDE编写Java代码



代码必须得实现IJobHandler接口,之后任务执行的时候就会执行execute方法的实现


如果你需要修改任务的逻辑,只需要重新编辑即可,不需要重启服务


ScriptJobHandler,通过名字也可以看出,是专门处理一些脚本的


运行模式除了BEANGLUE(Java)之外,其余都是脚本模式


而本节的主旨,所谓的初始化JobHandler就是指,执行器启动的时候会去Spring容器中找到加了@XxlJob注解的Bean


解析注解,然后封装成一个MethodJobHandler对象,最终存到XxlJobSpringExecutor成员变量的一个本地的Map缓存中



缓存key就是任务的名字



至于GlueJobHandler和ScriptJobHandler都是任务触发时才会创建


除了上面这几种,你也自己实现JobHandler,手动注册到JobHandler的缓存中,也是可以通过调度中心触发的


2、创建一个Http服务器


除了初始化JobHandler之外,执行器还会创建一个Http服务器


这个服务器端口号就是通过XxlJobSpringExecutor配置的端口,demo中就是设置的是9999,底层是基于Netty实现的



这个Http服务端会接收来自调度中心的请求


当执行器接收到调度中心的请求时,会把请求交给ExecutorBizImpl来处理



这个类非常重要,所有调度中心的请求都是这里处理的


ExecutorBizImpl实现了ExecutorBiz接口


当你翻源码的时候会发现,ExecutorBiz还有一个ExecutorBizClient实现



ExecutorBizClient的实现就是发送http请求,所以这个实现类是在调度中心使用的,用来访问执行器提供的http接口



3、注册到调度中心


当执行器启动的时候,会启动一个注册线程,这个线程会往调度中心注册当前执行器的信息,包括两部分数据



  • 执行器的名字,也就是设置的appname

  • 执行器所在机器的ip和端口,这样调度中心就可以访问到这个执行器提供的Http接口


前面提到每个服务实例都会对应一个执行器实例,所以调用中心会保存每个执行器实例的地址




这里你可以把调度中心的功能类比成注册中心



任务触发原理


弄明白执行器启动时干了哪些事,接下来讲一讲Xxl-Job最最核心的功能,那就是任务触发的原理


任务触发原理我会分下面5个小点来讲解



  • 任务如何触发?

  • 快慢线程池的异步触发任务优化

  • 如何选择执行器实例?

  • 执行器如何去执行任务?

  • 任务执行结果的回调


1、任务如何触发?


调度中心在启动的时候,会开启一个线程,这个线程的作用就是来计算任务触发时机,这里我把这个线程称为调度线程


这个调度线程会去查询xxl_job_info这张表


这张表存了任务的一些基本信息和任务下一次执行的时间


调度线程会去查询下一次执行的时间 <= 当前时间 + 5s的任务


这个5s是XxlJob写死的,被称为预读时间,提前读出来,保证任务能准时触发


举个例子,假设当前时间是2023-11-29 08:00:10,这里的查询就会查出下一次任务执行时间在2023-11-29 08:00:15之前执行的任务



查询到任务之后,调度线程会去将这些任务根据执行时间划分为三个部分:



  • 当前时间已经超过任务下一次执行时间5s以上,也就是需要在2023-11-29 08:00:05(不包括05s)之前的执行的任务

  • 当前时间已经超过任务下一次执行时间,但是但不足5s,也就是在2023-11-29 08:00:052023-11-29 08:00:10(不包括10s)之间执行的任务

  • 还未到触发时间,但是一定是5s内就会触发执行的



对于第一部分的已经超过5s以上时间的任务,会根据任务配置的调度过期策略来选择要不要执行



调度过期策略就两种,就是字面意思



  • 直接忽略这个已经过期的任务

  • 立马执行一次这个过期的任务


对于第二部分的超时时间在5s以内的任务,就直接立马执行一次,之后如果判断任务下一次执行时间就在5s内,会直接放到一个时间轮里面,等待下一次触发执行


对于第三部分任务,由于还没到执行时间,所以不会立马执行,也是直接放到时间轮里面,等待触发执行


当这批任务处理完成之后,不论是前面是什么情况,调度线程都会去重新计算每个任务的下一次触发时间,然后更新xxl_job_info这张表的下一次执行时间


到此,一次调度的计算就算完成了


之后调度线程还会继续重复上面的步骤,查任务,调度任务,更新任务下次执行时间,一直死循环下去,这就实现了任务到了执行时间就会触发的功能


这里在任务触发的时候还有一个很有意思的细节


由于调度中心可以是集群的形式,每个调度中心实例都有调度线程,那么如何保证任务在同一时间只会被其中的一个调度中心触发一次?


我猜你第一时间肯定想到分布式锁,但是怎么加呢?


XxlJob实现就比较有意思了,它是基于八股文中常说的通过数据库来实现的分布式锁的


在调度之前,调度线程会尝试执行下面这句sql



就是这个sql



select * from xxl_job_lock where lock_name = 'schedule_lock' for update



一旦执行成功,说明当前调度中心成功抢到了锁,接下来就可以执行调度任务了


当调度任务执行完之后再去关闭连接,从而释放锁


由于每次执行之前都需要去获取锁,这样就保证在调度中心集群中,同时只有一个调度中心执行调度任务


最后画一张图来总结一下这一小节



2、快慢线程池的异步触发任务优化


当任务达到了触发条件,并不是由调度线程直接去触发执行器的任务执行


调度线程会将这个触发的任务交给线程池去执行


所以上图中的最后一部分触发任务执行其实是线程池异步去执行的


那么,为什么要使用线程池异步呢?


主要是因为触发任务,需要通过Http接口调用具体的执行器实例去触发任务



这一过程必然会耗费时间,如果调度线程去做,就会耽误调度的效率


所以就通过异步线程去做,调度线程只负责判断任务是否需要执行


并且,Xxl-Job为了进一步优化任务的触发,将这个触发任务执行的线程池划分成快线程池慢线程池两个线程池



在调用执行器的Http接口触发任务执行的时候,Xxl-Job会去记录每个任务的触发所耗费的时间


注意并不是任务执行时间,只是整个Http请求耗时时间,这是因为执行器执行任务是异步执行的,所以整个时间不包括任务执行时间,这个后面会详细说


当任务一次触发的时间超过500ms,那么这个任务的慢次数就会加1


如果这个任务一分钟内触发的慢次数超过10次,接下来就会将触发任务交给慢线程池去执行


所以快慢线程池就是避免那种频繁触发并且每次触发时间还很长的任务阻塞其它任务的触发的情况发生


3、如何选择执行器实例?


上一节说到,当任务需要触发的时候,调度中心会向执行器发送Http请求,执行器去执行具体的任务


那么问题来了



由于一个执行器会有很多实例,那么应该向哪个实例请求?



这其实就跟任务配置时设置的路由策略有关了



从图上可以看出xxljob支持多种路由策略


除了分片广播,其余的具体的算法实现都是通过ExecutorRouter的实现类来实现的



这里简单讲一讲各种算法的原理,有兴趣的小伙伴可以去看看内部的实现细节


第一个、最后一个、轮询、随机都很简单,没什么好说的


一致性Hash讲起来比较复杂,你可以先看看这篇文章,再去查看Xxl-Job的代码实现



zhuanlan.zhihu.com/p/470368641



最不经常使用(LFU:Least Frequently Used):Xxl-Job内部会有一个缓存,统计每个任务每个地址的使用次数,每次都选择使用次数最少的地址,这个缓存每隔24小时重置一次


最近最久未使用(LRU:Least Recently Used):将地址存到LinkedHashMap中,它利用LinkedHashMap可以根据元素访问(get/put)顺序来给元素排序的特性,快速找到最近最久未使用(未访问)的节点


故障转移:调度中心都会去请求每个执行器,只要能接收到响应,说明执行器正常,那么任务就会交给这个执行器去执行


忙碌转移:调度中心也会去请求每个执行器,判断执行器是不是正在执行当前需要执行的任务(任务执行时间过长,导致上一次任务还没执行完,下一次又触发了),如果在执行,说明忙碌,不能用,否则就可以用


分片广播:XxlJob给每个执行器分配一个编号,从0开始递增,然后向所有执行器触发任务,告诉每个执行器自己的编号和总共执行器的数据


我们可以通过XxlJobHelper#getShardIndex获取到编号,XxlJobHelper#getShardTotal获取到执行器的总数据量


分片广播就是将任务量分散到各个执行器,每个执行器只执行一部分任务,加快任务的处理


举个例子,比如你现在需要处理30w条数据,有3个执行器,此时使用分片广播,那么此时可将任务分成3分,每份10w条数据,执行器根据自己的编号选择对应的那份10w数据处理



当选择好了具体的执行器实例之后,调用中心就会携带一些触发的参数,发送Http请求,触发任务


4、执行器如何去执行任务?


相信你一定记得我前面在说执行器启动是会创建一个Http服务器的时候提到这么一句



当执行器接收到调度中心的请求时,会把请求交给ExecutorBizImpl来处理



所以前面提到的故障转移和忙碌转移请求执行器进行判断,最终执行器也是交给ExecutorBizImpl处理的


执行器处理触发请求是这个ExecutorBizImpl的run方法实现的



当执行器接收到请求,在正常情况下,执行器会去为这个任务创建一个单独的线程,这个线程被称为JobThread



每个任务在触发的时候都有单独的线程去执行,保证不同的任务执行互不影响



之后任务并不是直接交给线程处理的,而是直接放到一个内存队列中,线程直接从队列中获取任务



这里我相信你一定有个疑惑



为什么不直接处理,而是交给队列,从队列中获取任务呢?



那就得讲讲不正常的情况了


如果调度中心选择的执行器实例正在处理定时任务,那么此时该怎么处理呢?**


这时就跟阻塞处理策略有关了



阻塞处理策略总共有三种:



  • 单机串行

  • 丢弃后续调度

  • 覆盖之前调度


单机串行的实现就是将任务放到队列中,由于队列是先进先出的,所以就实现串行,这也是为什么放在队列的原因


丢弃调度的实现就是执行器什么事都不用干就可以了,自然而然任务就丢了


覆盖之前调度的实现就很暴力了,他是直接重新创建一个JobThread来执行任务,并且尝试打断之前的正在处理任务的JobThread,丢弃之前队列中的任务



打断是通过Thread#interrupt方法实现的,所以正在处理的任务还是有可能继续运行,并不是说一打断正在运行的任务就终止了



这里需要注意的一点就是,阻塞处理策略是对于单个执行器上的任务来生效的,不同执行器实例上的同一个任务是互不影响的


比如说,有一个任务有两个执行器A和B,路由策略是轮询


任务第一次触发的时候选择了执行器实例A,由于任务执行时间长,任务第二次触发的时候,执行器的路由到了B,此时A的任务还在执行,但是B感知不到A的任务在执行,所以此时B就直接执行了任务


所以此时你配置的什么阻塞处理策略就没什么用了


如果业务中需要保证定时任务同一时间只有一个能运行,需要把任务路由到同一个执行器上,比如路由策略就选择第一个


5、任务执行结果的回调


当任务处理完成之后,执行器会将任务执行的结果发送给调度中心



如上图所示,这整个过程也是异步化的



  • JobThread会将任务执行的结果发送到一个内存队列中

  • 执行器启动的时候会开启一个处发送任务执行结果的线程:TriggerCallbackThread

  • 这个线程会不停地从队列中获取所有的执行结果,将执行结果批量发送给调度中心

  • 调用中心接收到请求时,会根据执行的结果修改这次任务的执行状态和进行一些后续的事,比如失败了是否需要重试,是否有子任务需要触发等等


到此,一次任务的就算真正处理完成了


最后


最后我从官网捞了一张Xxl-Job架构图



奈何作者不更新呐,导致这个图稍微有点老了,有点跟现有的架构对不上


比如说图中的自研RPC(xxl-rpc)部分已经替换成了Http协议,这主要是拥抱生态,方便跨语言接入


但是不要紧,大体还是符合现在的整个的架构


从架构图中也可以看出来,本文除了日志部分的内容没有提到,其它的整个核心逻辑基本上都讲到了


而日志部分其实是个辅助的作用,让你更方便查看任务的运行情况,对任务的触发逻辑是没有影响的,所以就没讲了


所以从本文的讲解再到官方架构图,你会发现整个Xxl-Job不论是使用还是实现都是比较简单的,非常的轻量级


说点什么


好了,到这又又成功讲完了一款框架或者说是中间件的核心架构原理,不知道你有没有什么一点收获


如果你觉得有点收获,欢迎点赞、在看、收藏、转发分享给其他需要的人


你的支持就是我更新文章最大的动力,非常地感谢!


其实这篇文章我在十一月上旬的时候我就打算写了


但是由于十一月上旬之后我遇到一系列烦心事,导致我实在是没有精力去写


现在到月底了,虽然烦心事只增不少,但是我还是想了想,觉得不能再拖了,最后也是连续肝了几个晚上,才算真正完成


所以如果你发现文章有什么不足和问题,也欢迎批评指正


好了,本文就讲到这里了,让我们下期再见,拜拜!


作者:zzyang90
来源:juejin.cn/post/7329860521241640971
收起阅读 »

为什么不推荐用 UUID 作为 Mysql 的主键

学习改变命运,技术铸就辉煌。 大家好,我是銘,全栈开发程序员。 UUID 是什么 我们先来了解一下 UUID 是什么?UUID 是指Universally Unique Identifier,翻译为中文是通用唯一识别码,UUID 的目的是让分布式系统中的所有...
继续阅读 »

学习改变命运,技术铸就辉煌。



大家好,我是銘,全栈开发程序员。


UUID 是什么


我们先来了解一下 UUID 是什么?UUID 是指Universally Unique Identifier,翻译为中文是通用唯一识别码,UUID 的目的是让分布式系统中的所有元素都能有唯一的识别信息。如此一来,每个人都可以创建不与其它人冲突的 UUID,就不需考虑数据库创建时的名称重复问题。


UUID 的十六个八位字节被表示为 32个十六进制数字,以连字号分隔的五组来显示,形式为 8-4-4-4-12,总共有 36个字符(即三十二个英数字母和四个连字号)。例如:


123e4567-e89b-12d3-a456-426655440000
xxxxxxxx-xxxx-Mxxx-Nxxx-xxxxxxxxxxxx

能否用 UUID 做主键


先说答案 , 能,但是性能会比使用自增主键差一些,那原因是什么,我们具体分析:


我们平时建表的时候,一般都像下面这样,不会去使用 UUID,使用AUTO INCREMENT直接把主键 id 设置成自增,每次 +1


CREATE TABLE `user`(
`id` int NOT NULL AUTO INCREMENT COMMENT '主键',
`name` char(10NOT NULL DEFAULT '' COMMENT '名字',
 PRIMARY KEY (`id`)
 )ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

那为什么把主键设置成自增呢, 我们在数据库保存数据的时候,就类似与下面的表格一样,这每一行数据,都是**保存在一个 16K 大小的页里 **。


idnameage
1张三11
2李四22
3王五33

每次都去遍历所有的行性能会不好,于是为了加速搜索,我们可以根据主键 id,从小到大排列这些行数据,将这些数据页用双向链表的形式组织起来,再将这些页里的部分信息提取出来放到一个新的 16kb 的数据页里,再加入层级的概念。于是,一个个数据页就被组织起来了,成为了一棵 B+ 树索引。


当我们在建表 sql 里面声明 AUTO INCREMENT 的时候,myqsl 的 innodb 引擎,就会为主键 id 生成一个主键索引,里面就是通过 B+ 树的形式来维护这套索引。


那么现在,我们需要关注两个点,



  1. 数据页大小是固定的 16k

  2. 数据页内,以及数据页之间,数据主键 id 是从小到大排序的


所以,由于数据页大小固定了 16k ,当我们需要插入一条数据的时候,数据页就会慢慢的被放满,当超过 16k 的时候,这个数据页就可能会进行分裂。


针对 B+ 树的叶子节点,如果主键是自增的,那么它产生的 id 每次都比前次要大,所以每次都会将数据家在 B+ 树的尾部,B+ 树的叶子节点本质是双向链表,查找它的首部和尾部,时间复杂度 O(1),如果此时最末尾的数据也满了,那创建个新的页就好。


如果bb,上次 id=12111111,这次 id=343435455,那么为了让新加入数据后 B+ 树的叶子节点海涅那个保持有序,那么就需要旺叶子节点的中间找,查找的时间复杂度是 O(lgn),如果这个页满了,那就需要进行页分裂,并且页分裂的操作是需要加悲观锁的。


所以,我们一般都建议把主键设置成自增,这样可以提高效率,提高性能


那什么情况下不设置主键自增


mysql分库分表下的id


在分库分表的情况下,插入的 id 都是专门的 id 服务生成的,如果要严格按照自增的话,那么一般就会通过 redis 来生成,按批次去获得,比如一次性获取几百个,用完了再去获取,但是如果 redis 服务挂了,功能就完全没法用了,那有么有不依赖与第三方组件的方法呢?


雪花算法


使用时间戳+机器码+流水号,一个字段实现了时间顺序、机器编码、创建时间。去中心化,方便排序,随便多表多库复制,并可抽取出生成时间,雪花ID主要是用在数据库集群上,去中心化,ID不会冲突又能相对排序。


总结


一般情况下,我们不推荐使用 UUID 来作为数据库的主键,只有分库分表的时候,才建议使用 UUID 来作为主键。


作者:銘聊技术
来源:juejin.cn/post/7328366295091200038
收起阅读 »

JS 前端框架的新年预言

web
免责声明 本文属于是语冰的直男翻译了属于是,略有删改,仅供粉丝参考。英文原味版请传送 2024 Predictions by JavaScript Frontend Framework Maintainers。 本期共享的是 —— 来自 React/Next...
继续阅读 »

免责声明


本文属于是语冰的直男翻译了属于是,略有删改,仅供粉丝参考。英文原味版请传送 2024 Predictions by JavaScript Frontend Framework Maintainers



本期共享的是 —— 来自 React/Next.js/Angular/Solid 的维护者和创建者科普了它们计划在新年里框架改进的未来规划。


fe-2024.png


React:新年预览


Meta(前脸书)的 React 工程经理 E.W. 表示,React 团队预计在新的一年里会有更多的框架采用 RSC(React 服务服务端组件)。


“对于大多数人而言,RSC 已经对其所了解的 React 作用域产生了重大变化,从只是一个 UI 层,到对构建 App 的方式产生更大的影响,以享受最佳的用户体验和开发体验,尤其以前对于 SPA(单页应用程序)还不够好,”E.W. 如是说。


虽然它没有具体爆料来年的任何新进展,但 E.W. 确实表示它们会发布并共享某些去年开始可公开的进展。举个栗子,在 React Advanced 上,该团队向与会者展示了 React Forget,这是 React 的自动记忆编译器。E.W. 表示,React Forget 意味着,开发者不再需要使用 useMemo/useCallback


“在 React Native EU 上,我们表示,从 0.73 版本开始,我们会把 Web 开发者熟悉的 Chrome 开发工具移植到 React Native 中,”E.W. 补充道。“我们还共享了我们对 Static Hermes 的研究,它是我们的 JS 原生编译器,它不仅有可能加快 React Native App 的速度,还能从根本上改变 JS 的有效用途。”


Next.js:正在运行的新编译器


Next.js 推出了一款新的 App 服务器,旨在支持去年的 RSC(React 服务端组件)和 SA(服务端操作)。Vercel 的产品主管 L.R. 表示,它会继续支持旧版的 App 服务器,并且它们的路由系统是可互换的。这种互操作性意味着,开发者可以把时间花在添加新功能上。


“有些客户已经使用 Next.js 开发了五六年,它们采用这些新功能也需要多年时间,”L.R. 讲道。“我们希望让大家尽可能顺利地度过这段旅程。”


新的一年里,Next.js 想要解决一大坨问题,但其中一个优先事项可能是简化缓存。它说,就开发体验而言,这可能会更容易。


“通常情况下,生态系统中的一大坨开发者必须引入一大坨额外软件包,或学习如何使用其他工具来请求、缓存和重新验证,”L.R. 说。“Next.js 现在已经内置了一大坨十分给力的同款功能,但这也意味着,大家需要学习其他东西,目前用户初步的反馈是,‘这很棒棒哒;它十分给力,但如果能更简单一点的话,我们会不吝赞词。’”


Next.js 团队还会继续关注性能优化,它称之为“我们的持续投资”。


它补充道,这可能会在新年里以新编译器的形式出现,这将加快在开发者的机器上启动 Next.js 的速度。该编译器已经投入使用了大约一年,Vercel 一直在内部将其用于其产品和 App。它说,由 Rust 提供支持的编译器,在无缓存的情况下比以前有缓存的编译器更快。


L.R. 说:“我们推出该功能指日可待,大家都可以默认启动它,而且它比现存的 Webpack 编译解决方案更快。” “开发者希望它们的工具更快。它们永远不会抱怨它变得更快。因此,很有趣的是,可以看到工具作者,而不是工具的用户,而是实际的工具开发者转向 Rust 等较低阶的工具,帮助斩获咫尺之间的性能优势。”


目标三是继续为 Next.js 的未来 10 年奠定基础。


“你知道的,这个新的路由系统显然让我们十分鸡冻。我们相信这是未来的基础,”它说。“但这也需要时间。大家会尝试,用户会提出功能请求,它们会希望看到事情发生改变。我们认为这是未来五到十年的一项非常长期的投资。”


它补充说,“有一天”但可能不是今年的目标是,寻求一种更棒的方案来处理 Next.js 内部的内容。


“今天,它能奏效,我们仍然可以连接到想要的任何内容源,但存在某些方案可以简化开发体验,”它补充道。“与其说这是一项要求,不如说是一种美好的享受,这就是为什么私以为我们无法在新年实现此目标,但我想在未来用它搞点事情。”


Angular:可选的 Zone.js


谷歌 Angular DevRel 技术主管兼经理 M.G. 表示,在过去的一年里,Angular 的两大成就是:



  • 引入了 Signal(信号)的细粒度响应性

  • 引入了可延迟视图


它讲道,明年会在此基础上,进一步关注细粒度响应性,并使 Zone.js 成为可选选项。


在 Angular 中,Zone 是跨异步任务持续存在的执行上下文。Zone 的 GitHub 仓库对此进行了详细解释,但 Zone 有五大职责,包括但不限于拦截异步任务调度和包装错误处理的回调,以及跨异步操作的 Zone 追踪。Zone.js 可以创建跨异步操作持久存在的上下文,并为异步操作提供生命周期钩子。


“我们正在探索为现存项目启用可选的 Zone.js,开发者应该可以通过重构现存 App 来利用该功能,”M.G. 如是说。“诉诸可选的 Zone.js,我们期望优化加载时间,并提升初始渲染速度。研究细粒度响应性将其提升到另一个水平,使我们能够只检测组件模板的局部变化。”


它说,这些功能将带来更快的运行时间。


在另一个性能游戏中,Angular 正在考虑是否默认启用混合渲染。它补充说,可以选择退出混合渲染,因为它会增加托管要求和成本。


“我们瞄到了 SSG(静态站点生成)和 SSR(服务端渲染)的巨大价值,凭借我们在 v17 中奠定的坚硬基建,我们正在努力进行最后的润色,以便从一开始就实现这种体验,”M.G. 如是说。


它补充道,另一个优先事项是落实 Signal 的征求意见。


开发者还可能会见证 Angular 文档的改进。根据其开发者调查,开发者希望享受进阶的学习体验,其中一部分包括使 Angular.dev 成为 Angular 的全新官网主页。它补充道,开发者还优先考虑了初始加载时间(混合渲染、部分水合和可选的 Zone.js 部分应该解决此问题),以及组件创作(Angular 计划进一步简化组件创作)。


“我们致力于可持续迭代功能,并与时俱进地渐进增强它们,”M.G. 讲道。“开发者将能够从新年里的所有优化中受益,并将在接下来的几年中享受更好的开发体验和性能。”


Solid:聚焦原语


“Solid 之父”R.C. 表示,Solid 开发者可以关注新年的 SolidStart 1.0 和 Solid.js 2.0。SolidStart 是一个元框架,这意味着,它构建于 Solid.js 框架之上。它说,它相相当于 Svelte 的 SvelteKit。


SolidStart 的官网文档是这样解释的:


“Web App 通常包含一大坨组件:数据库、服务器、前端、打包器、数据请求/变更、缓存和基建。编排这些组件极具挑战性,并且通常需要跨 App 堆栈大量共享状态和冗余逻辑。进入 SolidStart:一种元框架,它提供了将所有这些组件万法归一的平台。”


由于 SolidStart 仍处于测试阶段,R.C. 基本上有机会使用生态系统中已有的内容来使其变得更好。


“其中一个重要的部分是,我们现在不再编写自己的部署适配器,而是使用 Nitro,它也为 Nuxt 框架提供支持,这让我们可以部署到所有不同的平台,”R.C. 讲道。


另一个例子是,任何 Solid 路由器都可以在 SolidStart 中奏效。


“这意味着,对路由器的底层部分大量更新,这样它们能够“梦幻联动”,但我非常满意的最终结果是,我们的志愿者小团队需要维护的代码更少了,而且它为开发者提供了很大的灵活性和控制力,”它说。“它们不会被迫采用单一的解决方案,这对我而言兹事体大,因为每个人都有自己的需求。正如我所言,如果您构建正确的基建,并弄清楚这些构建模块是什么,大家可以做更多的事情。”


它说,最终的结果是一个具有“可交换”部分的元框架,而且不太我行我素。在越来越多的元框架决定开发者技术方案的世界中,Solid 团队一直在思考正确的原语片段的影响。


“于我而言,它始终是关于构建基元块,这是一个非常工程化的焦点,我认为这是它与众不同的部分原因,”它说。“我一直喜欢提供选择,而且私以为如果我们有正确的原语、正确的片段,我们就可以构建正确的解决方案。”


它表示,Solid 2.0 应该会在新年中后期的某个时间点发布。它说,目前它们正在设计如何处理异步系统的原型。


“Solid 2.0 也将是重量级版本,因为我们正在重新审视响应式系统,并研究如何解决异步 Signal 或异步系统,”R.C. 讲道。


它补充道,Solid 试图平衡控制与性能。


“我们的社区中有一大坨热心人,它们非常有技术头脑,既关心性能,也关心控制,”它说。“我们确实吸引了一大坨自己真正想要控制构建的方方面面的用户。”


作者:人猫神话
来源:juejin.cn/post/7331925629082566707
收起阅读 »

什么样的领导值得追随?

俗话说:士为知己者死,女为悦己者容。作为一名技术人员,什么样的领导值得追随?今天就来一起聊一聊。 1. 有实权 权力意味着什么?哈哈,懂的都懂。纵观现在的互联网大厂,盘根错节,明争暗斗的利益团体一个又一个,所以你追随的领导一定要有实权,跟着有实权的领导,很容易...
继续阅读 »



俗话说:士为知己者死,女为悦己者容。作为一名技术人员,什么样的领导值得追随?今天就来一起聊一聊。


1. 有实权


权力意味着什么?哈哈,懂的都懂。纵观现在的互联网大厂,盘根错节,明争暗斗的利益团体一个又一个,所以你追随的领导一定要有实权,跟着有实权的领导,很容易获得优质,能拿结果的资源,晋升也就有了更好故事。大厂里一直流传着这样一句暗语:“代码写得好,不如 PPT做得好,PPT做得好,不如老板舔得好”。


如果你追随的领导形同虚设,在公司就很容易边缘化,在团队合作上也可能没有多大的话语权,试想,跟着这样的领导,前途在哪里?


2. 有能力


尽管**实权**在成败上起着决定性的作用,但是绝大多数的技术人员都比较单纯,不愿意参与那种复杂的权利争斗,而且,技术人,能力才是立足之根本。因此,需要选择有能力的领导,要和强者一起赛跑。有能力的领导可以让你快速的学习,快速的成长,对于未来的职业发展才能做到进可攻,退可守。


有能力主要体现在下面几点:



  1. 技术和业务能力,是团队的一个指明灯;

  2. 能辨才,领导一定要能掌握团队成员的优缺点,在项目上能够根据各自所长合理分配任务。

  3. 将才,作为领导,也许技术能力不是最强的,但是一定要有领导人才的能力,也就是我们说的将才。 


     



3. 有担当


作为技术人,在职业生涯中犯错误是在所难免的,比如出现比较大的线上事故,这个时候,领导愿不愿意和组员一起承担责任,就能很好的体现领导有没有担当,值不值得追随。


4. 会分享


特别喜欢《亮剑》中李云龙的角色,尽管他满嘴骂骂咧咧,但是在利益分配上他绝对是王者,为了团队的荣誉他可以和上级叫板,为了团队的利益他可以和其他部队的领导呲牙咧嘴,所以,作为他的下属心甘情愿为他赴死,他带领的队伍战斗力超强。


反观职场,领导能不能主动满足下属的诉求,愿不愿意主动为下属争取利益,能不能为下属指定合理的成长计划,都能充分体现出领导愿不愿意和组员一起分享。


5. 有野心


“一个不想当将军的士兵不是一个好士兵”,“一个不想当大领导的领导不是一个没有野心领导”,如果一个在权利,业务,能力上都是野心勃勃的领导,不能证明他一定很好,但是一定不会很差。如果你跟的领导对于团队扩展和业务发展都没有很大的欲望,在公司内部不会去抢资源,拿结果,甚至出现得过且过,甘于平庸,那么,跟随这样的领导,你也只能平庸。


6. 奖罚分明


对于领导,团队管理是他最重要的职责,因此,如何能管好团队?如何激发组员的斗志?这就要求领导一定赏罚分明,团队一定要有清晰的赏罚制度,不要搞平均主义,有竞争才能让组员有动力,为团队创造更多的价值。


如何选择领导呢?


本文总结了值得追随的领导可能具备的 6个特质,但是很多小伙伴会问:我在跳槽前根本不知道自己的领导是谁,他的能力如何?我该如何选择领导?这里也总结几个意见,希望能帮到你:



  1. 跳槽尽量选择技术内推,这样就可以从他那边知道你未来的领导大概是什么样子,具备什么样的能力。

  2. 公司内部转岗,平时可以多关注不同部门的领导,择机选择自己喜欢的领导转岗。

  3. 改命,如果真的没有遇到欣赏自己的领导,就要更加提升技术,让自己具备选择领导的资本。


金无足赤,人无完人,或许你的领导无法同时具备上述 6个特质,但是,只要拥有3~4个,我个人觉得该领导从一定意义上就已经很优秀。而且,日常工作和领导相处的时候,我们需要多换位思考,领导的哪些做法让你不爽,哪些做法让你心悦诚服,如果有一天你也当了领导,你该如何服众。就算不做领导,换位思考,也可以让自己在团队中更好地沟通,成长。


最后,把猿哥的座右铭送给你: 投资自己才是最大的财富。 由于水平有限,如果文章存在缺点和错误,欢迎批评指正。


作者:猿java
来源:juejin.cn/post/7329807974968131618
收起阅读 »

Android开发中“真正”的仓库模式

原文标题:The “Real” Repository Pattern in Android 原文地址:proandroiddev.com/the-real-re… 原文发表日期:2019.9.5 作者:Denis Brandi 翻译:tommwq 翻译日期:2...
继续阅读 »

  • 原文标题:The “Real” Repository Pattern in Android

  • 原文地址:proandroiddev.com/the-real-re…

  • 原文发表日期:2019.9.5

  • 作者:Denis Brandi

  • 翻译:tommwq

  • 翻译日期:2024.1.3



Figure 1: 仓库模式


多年来我见过很多仓库模式的实现,我想其中大部分是错误而无益的。


下面是我所见最多的5个错误(一些甚至出现在Android官方文档中):



  1. 仓库返回DTO而非领域模型。

  2. 数据源(如ApiService、Dao等)使用同一个DTO。

  3. 每个端点集合使用一个仓库,而非每个实体(或DDD聚合根)使用一个仓库。

  4. 仓库缓存全部模型,即使是频繁更新的域。

  5. 数据源被多个仓库共享使用。


那么要如何把仓库模式做对呢?


1. 你需要领域模型


这是仓库模式的关键点,我想开发者难以正确实现仓库模式的原因在于他们不理解领域是什么。


引用Martin Fowler的话,领域模型是:



领域中同时包含行为和数据的对象模型。



领域模型基本上表示企业范围的业务规则。


对于不熟悉领域驱动设计构建块或分层架构(六边形架构,洋葱架构,干净架构等)的人来说,有三种领域模型:



  1. 实体:实体是具有标识(ID)的简单对象,通常是可变的。

  2. 值对象:没有标识的不可变对象。

  3. 聚合根(仅限DDD):与其他实体绑定在一起的实体(通常是一组关联对象的聚合)。


对于简单领域,这些模型看起来与数据库和网络模型(DTO)很像,不过它们也有很多差异:



  • 领域模型包含数据和过程,其结构最适于应用程序。

  • DTO是表示JSON/XML格式请求/应答或数据库表的对象模型,其结构最适于远程通信。


Listing 1: 领域模型示例


// Entity
data class Product(
val id: String,
val name: String,
val price: Price,
val isFavourite: Boolean
) {
// Value object
data class Price(
val nowPrice: Double,
val wasPrice: Double
) {
companion object {
val EMPTY = Price(0.0, 0.0)
}
}
}

Listing 2: 网络DTO示例


// Network DTO
data class NetworkProduct(
@SerializedName("id")
val id: String?,
@SerializedName("name")
val name: String?,
@SerializedName("nowPrice")
val nowPrice: Double?,
@SerializedName("wasPrice")
val wasPrice: Double?
)

Listing 3: 数据库DTO示例


// Database DTO
@Entity(tableName = "Product")
data class DBProduct(
@PrimaryKey
@ColumnInfo(name = "id")
val id: String,
@ColumnInfo(name = "name")
val name: String,
@ColumnInfo(name = "nowPrice")
val nowPrice: Double,
@ColumnInfo(name = "wasPrice")
val wasPrice: Double
)

如你所见,领域模型不依赖框架,对象字段提倡使用多值属性(正如你看到的Price逻辑分组),并使用空对象模式(域不可为空)。而DTO则与框架(Gson、Room)耦合。


幸好有这样的隔离:



  • 应用程序的开发变得更容易,因为不需要检查空值,多值属性也减少了字段数量。

  • 数据源变更不会影响高层策略。

  • 避免了“上帝模型”,带来更多的关注点分离。

  • 糟糕的后端接口不会影响高层策略(想象一下,如果你需要执行两个网络请求,因为后端无法在一个接口中提供所有信息。你会让这个问题影响你的整个代码库吗?)


2. 你需要数据转换器


这是将DTO转换成领域模型,以及进行反向转换的地方。


多数开发者认为这种转换是无趣又无效的,他们喜欢将整个代码库,从数据源到界面,与DTO耦合。


这也许能让第一个版本更快交付,但不在表示层中隐藏业务规则和用例,而是省略领域层并将界面与数据源耦合会产生一些只会在生产环境遇到的故障(比如后端没有发送空字符串,而是发送null,并因此引发NPE)。


以我所见,转换器写起来快,测起来也简单。即使实现过程缺乏趣味,它也能保护我们不会因数据源行为的改变而受到意外影响。


如果你没有时间(或者干脆懒得)进行数据转换,你可以使用对象转换框架,比如如modelmapper.org/ ,来加速开发过程。


我不喜欢在代码中使用框架,为减少样板代码,我建立了一个泛型转换接口,以免为每个转换器建立独立接口:


interface Mapper<I, O> {
fun map(input: I): O
}

以及一组泛型列表转换器,以免实现特定的“列表到列表”转换:


// Non-nullable to Non-nullable
interface ListMapper<I, O>: Mapper<List<I>, List<O>>

class ListMapperImpl<I, O>(
private val mapper: Mapper<I, O>
) : ListMapper<I, O> {
override fun map(input: List<I>): List<O> {
return input.map { mapper.map(it) }
}
}


// Nullable to Non-nullable
interface NullableInputListMapper<I, O>: Mapper<List<I>?, List<O>>

class NullableInputListMapperImpl<I, O>(
private val mapper: Mapper<I, O>
) : NullableInputListMapper<I, O> {
override fun map(input: List<I>?): List<O> {
return input?.map { mapper.map(it) }.orEmpty()
}
}


// Non-nullable to Nullable
interface NullableOutputListMapper<I, O>: Mapper<List<I>, List<O>?>

class NullableOutputListMapperImpl<I, O>(
private val mapper: Mapper<I, O>
) : NullableOutputListMapper<I, O> {
override fun map(input: List<I>): List<O>? {
return if (input.isEmpty()) null else input.map { mapper.map(it) }
}
}

注:在这篇文章中我展示了如何使用简单的函数式编程,以更少的样板代码实现相同的功能。


3. 你需要为每个数据源建立独立模型


假设在网络和数据库中使用同一个模型:


@Entity(tableName = "Product")
data class ProductDTO(
@PrimaryKey
@ColumnInfo(name = "id")
@SerializedName("id")
val id: String?,
@ColumnInfo(name = "name")
@SerializedName("name")
val name: String?,
@ColumnInfo(name = "nowPrice")
@SerializedName("nowPrice")
val nowPrice: Double?,
@ColumnInfo(name = "wasPrice")
@SerializedName("wasPrice")
val wasPrice: Double?
)

刚开始你可能会认为这比使用两个模型开发起来要快得多,但是你注意到它的风险了吗?


如果没有,我可以为你列出一些:



  • 你可能会缓存不必要的内容。

  • 在响应中添加新字段将需要变更数据库(除非添加@Ignore注解)。

  • 所有不应当在请求中发送的字段都需要添加@Transient注解。

  • 除非使用新字段,否则必须要求网络和数据库中的同名字段使用相同的数据类型(例如你无法解析网络响应中的字符串nowPrice并缓存双精度浮点数nowPrice)。


如你所见,这种方法最终将比独立模型需要更多的维护工作。


4. 你应该只缓存所需内容


如果要显示存储在远程目录中的产品列表,并且对本地保存的愿望清单中的每个产品显示经典的心形图标。


对于这个需求,需要:



  • 获取产品列表。

  • 检查本地存储,确认产品是否在愿望清单中。


这个领域模型很像前面例子,添加了一个字段表示产品是否在愿望清单中:


// Entity
data class Product(
val id: String,
val name: String,
val price: Price,
val isFavourite: Boolean
) {
// Value object
data class Price(
val nowPrice: Double,
val wasPrice: Double
) {
companion object {
val EMPTY = Price(0.0, 0.0)
}
}
}

网络模型也和前面的示例类似,数据库模型则不再需要。


对于本地的愿望清单,可以将产品id保存在SharedPreferences中。不要使用数据库把简单的事情复杂化。


最后是仓库代码:


class ProductRepositoryImpl(
private val productApiService: ProductApiService,
private val productDataMapper: Mapper<DataProduct, Product>,
private val productPreferences: ProductPreferences
) : ProductRepository {

override fun getProducts(): Single<Result<List<Product>>> {
return productApiService.getProducts().map {
when(it) {
is Result.Success -> Result.Success(mapProducts(it.value))
is Result.Failure -> Result.Failure<List<Product>>(it.throwable)
}
}
}

private fun mapProducts(networkProductList: List<NetworkProduct>): List<Product> {
return networkProductList.map {
productDataMapper.map(DataProduct(it, productPreferences.isFavourite(it.id)))
}
}
}

其中依赖的类定义如下:


// A wrapper for handling failing requests
sealed class Result<T> {
data class Success<T>(val value: T) : Result<T>()
data class Failure<T>(val throwable: Throwable) : Result<T>()
}

// A DataSource for the SharedPreferences
interface ProductPreferences {
fun isFavourite(id: String?): Boolean
}

// A DataSource for the Remote DB
interface ProductApiService {
fun getProducts(): Single<Result<List<NetworkProduct>>>
fun getWishlist(productIds: List<String>): Single<Result<List<NetworkProduct>>>
}

// A cluster of DTOs to be mapped int0 a Product
data class DataProduct(
val networkProduct: NetworkProduct,
val isFavourite: Boolean
)

现在,如果只想获取愿望清单中的产品要怎么做呢?实现方式是类似的:


class ProductRepositoryImpl(
private val productApiService: ProductApiService,
private val productDataMapper: Mapper<DataProduct, Product>,
private val productPreferences: ProductPreferences
) : ProductRepository {

override fun getWishlist(): Single<Result<List<Product>>> {
return productApiService.getWishlist(productPreferences.getFavourites()).map {
when (it) {
is Result.Success -> Result.Success(mapWishlist(it.value))
is Result.Failure -> Result.Failure<List<Product>>(it.throwable)
}
}
}

private fun mapWishlist(wishlist: List<NetworkProduct>): List<Product> {
return wishlist.map {
productDataMapper.map(DataProduct(it, true))
}
}
}

5. 后记


我多次熟练使用这种模式,我想它是一个时间节约神器,尤其在大型项目中。


然而我多次看到开发者使用这种模式仅仅是因为“不得不”,而非他们了解它的真正优势。


希望你觉得这篇文章有趣也有用。


作者:tommwq
来源:juejin.cn/post/7319698586421542953
收起阅读 »

211 毕业就入职 30 人的小公司是什么体验

为什么“选择”了 30 人的小公司? 作为一个 211 毕业的学生,进入 30 人的小公司不管是 8 年前还是现在,应该都是比较稀少的,但是当面的我阴差阳错进了这样一个小公司。 为什么我选择进入这样一个 30 人的小公司呢?主要原因是因为没得选。 当时我在大学...
继续阅读 »

为什么“选择”了 30 人的小公司?


作为一个 211 毕业的学生,进入 30 人的小公司不管是 8 年前还是现在,应该都是比较稀少的,但是当面的我阴差阳错进了这样一个小公司。


为什么我选择进入这样一个 30 人的小公司呢?主要原因是因为没得选。


当时我在大学读的商科,跟计算机有关的课程只学了计算机基础、数据库基础和 C 语言编程基础,而且那个时候觉得这几门课程都是编外课程,没有好好学,C 语言课程期末考试还是老师放水以 60 分擦边通过。


社会消息闭塞,大学都要毕业了,也不知道社会上有哪些岗位,同寝室的同学也在打游戏中度过。


之后被一个考验小组拉进去考验,他们都准备的金融学专硕,我家穷,就准备考经济学硕士,结果没考上(现在还是比较庆幸没考上的,否则现在不知道干啥去了,个人性格也不适合证券之类的工作)。


没考上,毕业之后也不知道干啥,就来北京又考了一年,又没考上。之后进了一个小的 Java 培训机构培训,从此入行!


毕竟没什么基础,结课之后面试了几家,因为生活难以为继了,选择第一个给 offer 的 30 人小公司。


现在工作 8 年了,也经历了从 30 人的小公司、 2000 人+的传统上市企业、互联网大小厂,有兴趣可以看之前的文章:。


与大公司相比,小公司有哪些不好的地方


首先,工作环境一般都是一栋楼里面的一个小办公室,甚至有的直接在居民楼里办公,办公环境没有大公司好;


其次,薪资福利待遇相比大公司更低,而且社保等基础福利打折扣,很多小公司缴纳社保和公积金都是按照当地最低标准缴纳,相对大部分大公司会少很多钱;


再次,管理混乱,不管是老板还是管理者,都没有受过相应的教育或者训练,比较随心所欲,很多决策都是老板的一言堂,很难总结出来统一的成功经验。


小公司有哪些优点


首先,小公司对能力的培养更加全面,你可能需要同时干产产品经理、开发、测试、运维等多个角色的活,更能理解整个软件的生命周期,如果你要换岗位,如果你有在小公司的工作经历,可能会更加容易。


其次,小公司更加自由,做一个项目,它不会限制你使用的技术,只要你能实现需求,不会管你用的什么技术、什么技术方案,你可以更加容易的实现你的技术想法,验证你的想法。


再次,小公司可能更好交朋友,因为小公司人少,更多的是刚毕业的学生,更容易真心相待,我现在从进入社会之后交的朋友,有好几个都是第一家小公司的时候交的。


最后,培养更加全面,公司有一个同事,因为各方面比较优秀,在甲方爸爸的心中认可度比较高,自己成立了一个小公司,还是接原来甲方的需求,成功的从小员工变身为老板,后来还扩招了好几个员工,妥妥的打败大厂一般总监。


收获


感谢这家公司,给了我这样一个,没有技术背景、没有实习经历、技术也不够强的毕业生一个入行的机会。


在这家公司,我收获了 IT 圈的第一波朋友,也收获了工程化的思想,积攒了各类技术的经验,为我之后的工作提供了丰厚的积累。


而且,在这里,我积累了大量的技术经验和经历,也为跳槽到大公司提供了跳板。


最后,欢迎大家分享自己入职小公司的经历,让更多人了解小公司,给自己的职业选择多一个方向!


作者:六七十三
来源:juejin.cn/post/7287053284787683363
收起阅读 »

为什么要用雪花ID替代数据库自增ID?

今天咱们来看一道数据库中比较经典的面试问题:为什么要使用雪花 ID 替代数据库自增 ID?同时这道题也出现在了浩鲸科技的 Java 面试中,下面我们一起来看吧。 浩鲸科技的面试题如下:   1.什么是雪花 ID? 雪花 ID(Snowflake ID...
继续阅读 »

今天咱们来看一道数据库中比较经典的面试问题:为什么要使用雪花 ID 替代数据库自增 ID?同时这道题也出现在了浩鲸科技的 Java 面试中,下面我们一起来看吧。


浩鲸科技的面试题如下:
image.png 


1.什么是雪花 ID?


雪花 ID(Snowflake ID)是一个用于分布式系统中生成唯一 ID 的算法,由 Twitter 公司提出。它的设计目标是在分布式环境下高效地生成全局唯一的 ID,具有一定的有序性。


雪花 ID 的结构如下所示:
image.png
这四部分代表的含义



  1. 符号位:最高位是符号位,始终为 0,1 表示负数,0 表示正数,ID 都是正整数,所以固定为 0。

  2. 时间戳部分:由 41 位组成,精确到毫秒级。可以使用该 41 位表示的时间戳来表示的时间可以使用 69 年。

  3. 节点 ID 部分:由 10 位组成,用于表示机器节点的唯一标识符。在同一毫秒内,不同的节点生成的 ID 会有所不同。

  4. 序列号部分:由 12 位组成,用于标识同一毫秒内生成的不同 ID 序列。在同一毫秒内,可以生成 4096 个不同的 ID。


2.Java 版雪花算法实现


接下来,我们来实现一个 Java 版的雪花算法:


public class SnowflakeIdGenerator {

// 定义雪花 ID 的各部分位数
private static final long TIMESTAMP_BITS = 41L;
private static final long NODE_ID_BITS = 10L;
private static final long SEQUENCE_BITS = 12L;

// 定义起始时间戳(可根据实际情况调整)
private static final long EPOCH = 1609459200000L;

// 定义最大取值范围
private static final long MAX_NODE_ID = (1L << NODE_ID_BITS) - 1;
private static final long MAX_SEQUENCE = (1L << SEQUENCE_BITS) - 1;

// 定义偏移量
private static final long TIMESTAMP_SHIFT = NODE_ID_BITS + SEQUENCE_BITS;
private static final long NODE_ID_SHIFT = SEQUENCE_BITS;

private final long nodeId;
private long lastTimestamp = -1L;
private long sequence = 0L;

public SnowflakeIdGenerator(long nodeId) {
if (nodeId < 0 || nodeId > MAX_NODE_ID) {
throw new IllegalArgumentException("Invalid node ID");
}
this.nodeId = nodeId;
}

public synchronized long generateId() {
long currentTimestamp = timestamp();
if (currentTimestamp < lastTimestamp) {
throw new IllegalStateException("Clock moved backwards");
}
if (currentTimestamp == lastTimestamp) {
sequence = (sequence + 1) & MAX_SEQUENCE;
if (sequence == 0) {
currentTimestamp = untilNextMillis(lastTimestamp);
}
} else {
sequence = 0L;
}
lastTimestamp = currentTimestamp;
return ((currentTimestamp - EPOCH) << TIMESTAMP_SHIFT) |
(nodeId << NODE_ID_SHIFT) |
sequence;
}

private long timestamp() {
return System.currentTimeMillis();
}

private long untilNextMillis(long lastTimestamp) {
long currentTimestamp = timestamp();
while (currentTimestamp <= lastTimestamp) {
currentTimestamp = timestamp();
}
return currentTimestamp;
}
}

调用代码如下:


public class Main {
public static void main(String[] args) {
// 创建一个雪花 ID 生成器实例,传入节点 ID
SnowflakeIdGenerator idGenerator = new SnowflakeIdGenerator(1);
// 生成 ID
long id = idGenerator.generateId();
System.out.println(id);
}
}

其中,nodeId 表示当前节点的唯一标识,可以根据实际情况进行设置。generateId 方法用于生成雪花 ID,采用同步方式确保线程安全。具体的生成逻辑遵循雪花 ID 的位运算规则,结合当前时间戳、节点 ID 和序列号生成唯一的 ID。



需要注意的是,示例中的时间戳获取方法使用了 System.currentTimeMillis(),根据实际需要可以替换为其他更精确的时间戳获取方式。同时,需要确保节点 ID 的唯一性,避免不同节点生成的 ID 重复。



3.雪花算法问题


虽然雪花算法是一种被广泛采用的分布式唯一 ID 生成算法,但它也存在以下几个问题:



  1. 时间回拨问题:雪花算法生成的 ID 依赖于系统的时间戳,要求系统的时钟必须是单调递增的。如果系统的时钟发生回拨,可能导致生成的 ID 重复。时间回拨是指系统的时钟在某个时间点之后突然往回走(人为设置),即出现了时间上的逆流情况。

  2. 时钟回拨带来的可用性和性能问题:由于时间依赖性,当系统时钟发生回拨时,雪花算法需要进行额外的处理,如等待系统时钟追上上一次生成 ID 的时间戳或抛出异常。这种处理会对算法的可用性和性能产生一定影响。

  3. 节点 ID 依赖问题:雪花算法需要为每个节点分配唯一的节点 ID 来保证生成的 ID 的全局唯一性。节点 ID 的分配需要有一定的管理和调度,特别是在动态扩容或缩容时,节点 ID 的管理可能较为复杂。


4.如何解决时间回拨问题?


百度 UidGenerator 框架中解决了时间回拨的问题,并且解决方案比较经典,所以咱们这里就来给大家分享一下百度 UidGenerator 是怎么解决时间回拨问题的?



UidGenerator 介绍:UidGenerator 是百度开源的一个分布式唯一 ID 生成器,它是基于 Snowflake 算法的改进版本。与传统的 Snowflake 算法相比,UidGenerator 在高并发场景下具有更好的性能和可用性。它的实现源码在:github.com/baidu/uid-g…



UidGenerator 是这样解决时间回拨问题的:UidGenerator 的每个实例中,都维护一个本地时钟缓存,用于记录当前时间戳。这个本地时钟会定期与系统时钟进行同步,如果检测到系统时钟往前走了(出现了时钟回拨),则将本地时钟调整为系统时钟。


4.为什么要使用雪花 ID 替代数据库自增 ID?


数据库自增 ID 只适用于单机环境,但如果是分布式环境,是将数据库进行分库、分表或数据库分片等操作时,那么数据库自增 ID 就有问题了。


例如,数据库分片之后,会在同一张业务表的分片数据库中产生相同 ID(数据库自增 ID 是由每个数据库单独记录和增加的),这样就会导致,同一个业务表的竟然有相同的 ID,而且相同 ID 背后存储的数据又完全不同,这样业务查询的时候就出问题了。


所以为了解决这个问题,就必须使用分布式中能保证唯一性的雪花 ID 来替代数据库的自增 ID。


5.扩展:使用 UUID 替代雪花 ID 行不行?


如果单从唯一性来考虑的话,那么 UUID 和雪花 ID 的效果是一致的,二者都能保证分布式系统下的数据唯一性,但是即使这样,也不建议使用 UUID 替代雪花 ID,因为这样做的问题有以下两个:



  1. 可读性问题:UUID 内容很长,但没有业务含义,就是一堆看不懂的“字母”。

  2. 性能问题:UUID 是字符串类型,而字符串类型在数据库的查询中效率很低。


所以,基于以上两个原因,不建议使用 UUID 来替代雪花 ID。


小结


数据库自增 ID 只适用于单机数据库环境,而对于分库、分表、数据分片来说,自增 ID 不具备唯一性,所以要要使用雪花 ID 来替代数据库自增 ID。但雪花算法依然存在一些问题,例如时间回拨问题、节点过度依赖问题等,所以此时,可以使用雪花算法的改进框架,如百度的 UidGenerator 来作为数据库的 ID 生成方案会比较好。


作者:Java中文社群
来源:juejin.cn/post/7307066138487521289
收起阅读 »

安卓拍照、裁切、选取图片实践

安卓拍照、裁切、选取图片实践 前言 最近项目里面有用到裁切功能,没弄多复杂,就是系统自带的,顺便就总结了一下系统拍照、裁切、选取的使用。网上的资料说实话真是没什么营养,但是Android官网上的说明也有点太简单了,真就要实践出真理。 更新 最近花了点时间把拍照...
继续阅读 »

安卓拍照、裁切、选取图片实践


前言


最近项目里面有用到裁切功能,没弄多复杂,就是系统自带的,顺便就总结了一下系统拍照、裁切、选取的使用。网上的资料说实话真是没什么营养,但是Android官网上的说明也有点太简单了,真就要实践出真理。


更新


最近花了点时间把拍照、裁切的功能整理了下,并解决了下Android11上裁切闪退的问题、相册裁切闪退问题,就不多写一篇文章了,可以看我github的demo:


TakePhotoFragment.kt


BitmapFileUtil.kt


拍照


本来拍照是没什么难度的,不就是调用intent去系统相机拍照么,但是由于文件权限问题,Uri这东西就能把人很头疼。下面是代码(onActivityResult见后文):


    private fun openCamera() {
val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
// 应用外部私有目录:files-Pictures
val picFile = createFile("Camera")
val photoUri = getUriForFile(picFile)
// 保存路径,不要uri,读取bitmap时麻烦
picturePath = picFile.absolutePath
// 给目标应用一个临时授权
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
//android11以后强制分区存储,外部资源无法访问,所以添加一个输出保存位置,然后取值操作
intent.putExtra(MediaStore.EXTRA_OUTPUT, photoUri)
startActivityForResult(intent, REQUEST_CAMERA_CODE)
}

private fun createFile(type: String): File {
// 在相册创建一个临时文件
val picFile = File(requireContext().getExternalFilesDir(Environment.DIRECTORY_PICTURES),
"${type}_${System.currentTimeMillis()}.jpg")
try {
if (picFile.exists()) {
picFile.delete()
}
picFile.createNewFile()
} catch (e: IOException) {
e.printStackTrace()
}

// 临时文件,后面会加long型随机数
// return File.createTempFile(
// type,
// ".jpg",
// requireContext().getExternalFilesDir(Environment.DIRECTORY_PICTURES)
// )

return picFile
}

private fun getUriForFile(file: File): Uri {
// 转换为uri
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
//适配Android 7.0文件权限,通过FileProvider创建一个content类型的Uri
FileProvider.getUriForFile(
requireActivity(),
"com.xxx.xxx.fileProvider", file
)
} else {
Uri.fromFile(file)
}
}

简单说明


这里的file是使用getExternalFilesDir和Environment.DIRECTORY_PICTURES生成的,它的文件保存在应用外部私有目录:files-Pictures里面。这里要注意不能存放在内部的私有目录里面,不然是无法访问的,外部私有目录虽然也是私有的,但是外面是可以访问的,这里拿官网上的说明:



在搭载 Android 9(API 级别 28)或更低版本的设备上,只要您的应用具有适当的存储权限,就可以访问属于其他应用的应用专用文件。为了让用户更好地管理自己的文件并减少混乱,以 Android 10(API 级别 29)及更高版本为目标平台的应用在默认情况下被授予了对外部存储空间的分区访问权限(即分区存储)。启用分区存储后,应用将无法访问属于其他应用的应用专属目录。



Uri的获取


再一个比较麻烦的就是Uri的获取了,网上有一大堆资料,不过我这也贴一下,网上的可能有问题。



manifest.xml



        <provider
android:name="androidx.core.content.FileProvider"
android:authorities="com.xxx.xxx.fileProvider"
android:exported="false"
android:grantUriPermissions="true">

<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths"
/>

</provider>


res -> xml -> file_paths.xml



<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<!--1、对应内部内存卡根目录:Context.getFileDir()-->
<files-path
name="int_root"
path="/" />

<!--2、对应应用默认缓存根目录:Context.getCacheDir()-->
<cache-path
name="app_cache"
path="/" />

<!--3、对应外部内存卡根目录:Environment.getExternalStorageDirectory()-->
<external-path
name="ext_root"
path="/" />

<!--4、对应外部内存卡根目录下的APP公共目录:Context.getExternalFileDir(String)-->
<external-files-path
name="ext_pub"
path="/" />

<!--5、对应外部内存卡根目录下的APP缓存目录:Context.getExternalCacheDir()-->
<external-cache-path
name="ext_cache"
path="/" />

</paths>

ps. 注意authorities这个最好填自己的包名,不然有两个应用用了同样的authorities,后面的应用会安装不上。


path里面填 “/” 和 “*” 是有区别的,前者包含了子目录,后面只包含当前目录,最好就是用 “/”,不然创建个子文件夹,到时候访问搞出了线上问题,那就凉凉喽(还好我遇到的时候测试测出来了)。


打开相册


这里打开相册用的是SAF框架,使用intent去选取(onActivityResult见后文)。


    private fun openAlbum() {
val intent = Intent()
intent.type = "image/*"
intent.action = "android.intent.action.GET_CONTENT"
intent.addCategory("android.intent.category.OPENABLE")
startActivityForResult(intent, REQUEST_ALBUM_CODE)
}

裁切


裁切这里比较麻烦,参数比较多,而且Uri那里有坑,不能使用provider,再一个就是图片传递那因为安卓版本变更,不会传略缩图了,很坑。


    private fun cropImage(path: String) {
cropImage(getUriForFile(File(path)))
}

private fun cropImage(uri: Uri) {
val intent = Intent("com.android.camera.action.CROP")
// Android 7.0需要临时添加读取Url的权限
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
// intent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
intent.setDataAndType(uri, "image/*")
// 使图片处于可裁剪状态
intent.putExtra("crop", "true")
// 裁剪框的比例(根据需要显示的图片比例进行设置)
// if (Build.MANUFACTURER.contains("HUAWEI")) {
// //硬件厂商为华为的,默认是圆形裁剪框,这里让它无法成圆形
// intent.putExtra("aspectX", 9999)
// intent.putExtra("aspectY", 9998)
// } else {
// //其他手机一般默认为方形
// intent.putExtra("aspectX", 1)
// intent.putExtra("aspectY", 1)
// }

// 设置裁剪区域的形状,默认为矩形,也可设置为圆形,可能无效
// intent.putExtra("circleCrop", true);
// 让裁剪框支持缩放
intent.putExtra("scale", true)
// 属性控制裁剪完毕,保存的图片的大小格式。太大会OOM(return-data)
// intent.putExtra("outputX", 400)
// intent.putExtra("outputY", 400)

// 生成临时文件
val cropFile = createFile("Crop")
// 裁切图片时不能使用provider的uri,否则无法保存
// val cropUri = getUriForFile(cropFile)
val cropUri = Uri.fromFile(cropFile)
intent.putExtra(MediaStore.EXTRA_OUTPUT, cropUri)
// 记录临时位置
cropPicPath = cropFile.absolutePath

// 设置图片的输出格式
intent.putExtra("outputFormat", Bitmap.CompressFormat.JPEG.toString())

// return-data=true传递的为缩略图,小米手机默认传递大图, Android 11以上设置为true会闪退
intent.putExtra("return-data", false)

startActivityForResult(intent, REQUEST_CROP_CODE)
}

回调处理


下面是对上面三个操作的回调处理,一开始我觉得uri没什么用,还制造麻烦,后面发现可以通过流打开uri,再去获取bitmap,好像又不是那么麻烦了。


    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (resultCode == RESULT_OK) {
when(requestCode) {
REQUEST_CAMERA_CODE -> {
// 通知系统文件更新
// requireContext().sendBroadcast(Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE,
// Uri.fromFile(File(picturePath))))
if (!enableCrop) {
val bitmap = getBitmap(picturePath)
bitmap?.let {
// 显示图片
binding.image.setImageBitmap(it)
}
}else {
cropImage(picturePath)
}
}
REQUEST_ALBUM_CODE -> {
data?.data?.let { uri ->
if (!enableCrop) {
val bitmap = getBitmap("", uri)
bitmap?.let {
// 显示图片
binding.image.setImageBitmap(it)
}
}else {
cropImage(uri)
}
}
}
REQUEST_CROP_CODE -> {
val bitmap = getBitmap(cropPicPath)
bitmap?.let {
// 显示图片
binding.image.setImageBitmap(it)
}
}
}
}
}

private fun getBitmap(path: String, uri: Uri? = null): Bitmap? {
var bitmap: Bitmap?
val options = BitmapFactory.Options()
// 先不读取,仅获取信息
options.inJustDecodeBounds = true
if (uri == null) {
BitmapFactory.decodeFile(path, options)
}else {
val input = requireContext().contentResolver.openInputStream(uri)
BitmapFactory.decodeStream(input, null, options)
}

// 预获取信息,大图压缩后加载
val width = options.outWidth
val height = options.outHeight
Log.d("TAG", "before compress: width = " +
options.outWidth + ", height = " + options.outHeight)

// 尺寸压缩
var size = 1
while (width / size >= MAX_WIDTH || height / size >= MAX_HEIGHT) {
size *= 2
}
options.inSampleSize = size
options.inJustDecodeBounds = false
bitmap = if (uri == null) {
BitmapFactory.decodeFile(path, options)
}else {
val input = requireContext().contentResolver.openInputStream(uri)
BitmapFactory.decodeStream(input, null, options)
}
Log.d("TAG", "after compress: width = " +
options.outWidth + ", height = " + options.outHeight)

// 质量压缩
val baos = ByteArrayOutputStream()
bitmap!!.compress(Bitmap.CompressFormat.JPEG, 80, baos)
val bais = ByteArrayInputStream(baos.toByteArray())
options.inSampleSize = 1
bitmap = BitmapFactory.decodeStream(bais, null, options)

return bitmap
}

这里还做了一个图片的质量压缩和采样压缩,需要注意的是采样压缩的采样率只能是2的倍数,如果需要按任意比例采样,需要用到Matrix,不是很难,读者可以研究下。


权限问题


如果你发现你没有申请权限,那你的去申请一下相机权限;如果你发现你还申请了储存权限,那你可以试一下去掉储存权限,实际还是可以使用的,因为这里并没有用到外部储存,都是应用的私有储存内,具体关于储存的适配,可以看我转载的这几篇文章,我觉得写的非常好:


Android 存储基础


Android 10、11 存储完全适配(上)


Android 10、11 存储完全适配(下)


结语


以上代码都经过我这里实践了,确认了可用,可能写法不是最优,可以避免使用绝对路径,只使用Uri。至于请求码、布局什么的,读者自己改一下加一个就行,核心部分已经在这了。如果需要完整代码,可以看下篇文章末尾!


Android 不申请权限储存、删除相册图片


作者:方大可
来源:juejin.cn/post/7222874734186037285
收起阅读 »

去寺庙做义工,有益身心健康

《乔布斯传》中写到:乔布斯把对事物专注的能力和对简洁的热爱归功于他的禅修。他说:“禅修磨炼了他对直觉的欣赏能力,教他如何过滤掉任何能分散时间和精力的其它不必要的事情,在他的身上培养出了专注基于至简主义的审美观。” 如何在当今物欲横流的浮躁社会里不沦陷其中?如何...
继续阅读 »

《乔布斯传》中写到:乔布斯把对事物专注的能力和对简洁的热爱归功于他的禅修。他说:“禅修磨炼了他对直觉的欣赏能力,教他如何过滤掉任何能分散时间和精力的其它不必要的事情,在他的身上培养出了专注基于至简主义的审美观。”


如何在当今物欲横流的浮躁社会里不沦陷其中?如何在每天奔波忙碌之后却不内心疲惫、焦虑?如何在巨大的工作与生活压力下保持一颗平和的心?如何在经历感情、友情和亲情的起起落落后看破放下?如何改变透支健康和生命的人生模式?


程序员无疑是一个高压的职业,尤其是在头部公司工作的程序员们,工作压力更是大。并且在互联网行业,禅修并不是一件新鲜事。我们不一定要正儿八经地参加禅修活动,只是去寺庙走一走,呼吸一下新鲜空气,给寺庙干点活,对身心健康的帮助也会很大。


我与寺庙


我最早接触寺庙是在2011年上军校的时候,我的一个老师,作为大校,经常在课上分享他周末在南京附近寺庙的奇闻轶事,也会分享他自己的一些人生体验和感悟,勾起了我对寺庙生活的向往。


2013年,作为现役军人,我跑到了江西庐山的东林寺做了一个礼拜的义工,在那里,每天早上四点起床早上寺庙早课,负责三餐的行堂,也作为机动义工,干一些杂活,比如卸菜、组装床等,晚上有时也可以听寺庙的传统文化课。


2013年底,我申请退出现役,于是14年春就可以休假了,根据流程不定期去各部门办理手续即可,期间一个周末,我弟带我去凤凰岭玩,偶遇一个皈依法会,为了能看到传说中的北大数学天才,我填了一个义工表,参加了皈依仪式。


因为没有考虑政府安排的工作,所以打算考个研,期间不时会去凤凰岭的寺庙参加活动。考完研后,到18年春季,周末节假日,基本都会去这个寺庙做义工,累计得有200天以上。


期间,作为骨干义工,参与了该寺庙组织的第二至第四届的IT禅修营,负责行堂、住宿和辅导员等相关的工作。


很多人都听过这样一件往事:2010年,张小龙(微信之父)偶然入住一个寺院,当时正是微信研发的关键时刻,因为几个技术难题,张小龙连续几天彻夜难眠,终于一气之下把资料撕得粉碎。


没想到负责打扫卫生的僧人看到后,竟然帮他把资料重新粘贴了起来,还顺手写下了几条建议。张小龙非常惊讶,打听过后才知道这位扫地僧出家前曾混迹IT界,是个著名的极客。


经扫地僧点化,张小龙回到广州苦攻一年后,微信终于大成。这件事传的很广也很玄乎,可信度不会太高,不过故事中张小龙入住的寺院,就是我常去的寺庙。


至于在故事中懂得IT的扫地僧,在这里遇到其实不是什么奇怪的事,你还有可能遇到第47届国际数学奥赛金牌得主贤宇法师,他来自北大数学系;或者是禅兴法师,他是清华大学流体力学博士;又或者贤启法师,他是清华大学核能和热能物理博士。


“扫地只不过是我的表面工作,我真正的职业是一位研究僧。” 《少林足球》这句台词的背后,隐藏着关于这个寺庙“高知僧团”的一个段子。


因为各种不可描述的原因,18年9月之后,我就很少去这个寺庙了,但我知道我依然很向往寺庙的生活。于是22年春,我下定决心离开北京去深圳,其中就有考虑到深圳后,可以去弘法寺或弘源寺做义工。


去了一次弘法寺,感觉那边人太多,后面去了一次弘源寺后,感觉这里比较适合我,人少很安静,不堵车的话,开车只需20分钟到家。


目前,只要我有时间,我都会去弘源寺干一天临时义工,或者住上几天。


何为禅?


禅,是心智的高度成熟状态。直至印度词汇传入,汉语音译为“禅那”,后世简称为“禅”,汉译意思有“静虑”、“思维修”等。


禅修的方法就是禅法,禅法是心法,并不固着于某种具体形式,也不限于宗教派别。从泛义来说,任何一种方法,如果能够让你的心灵成熟,了解生命的本质,让心获得更高层次的证悟,并从而获得生命究竟意义的了悟。这样的方法就是禅修!


从狭义来说,在绵延传承数千年的漫长时空里,形成各种系统的修行方法,存在于各种教派中。现存主要有传承并盛行于南传佛教国家的原始佛教禅法与传承并盛行于中国汉传佛教的祖师禅。


如来禅是佛陀的原始教法,注重基础练习,强调修行止观。祖师禅是中国禅宗祖师的教法,强调悟性、觉性,推崇顿悟,以参话头为代表,以开悟、明心见性为目的。


我们普遍缺乏自我觉察,甚至误解了包括自由在内的生命状态真义。禅修中,会进入深刻自我觉察中,有机会与自己整合,从而开启真我。


近年来,禅修在西方非常流行,像美国的学校、医疗机构和高科技公司都广泛地在进行打坐、禅修。美国有些科学家曾做过一个实验,实验对象是长期禅修的修行人。在实验室中,实验者一边用脑电波图测量脑波的变化,一边用功能性核磁共振测量脑部活动的位置。


最后得出结论:通过禅修,不但能够短期改变脑部的活动,而且非常有可能促成脑部永久的变化。这就是说:通过禅定,可以有效断除人的焦虑、哀伤等很多负面情绪,创造出心灵的幸福感,甚至可以重塑大脑结构。


禅修能够修复心智、疗愈抑郁、提升智慧,让我们重获身心的全面健康!禅修让人的内心变得安静。在禅修时,人能放松下来,专注于呼吸,使内心归于平静,身体和心灵才有了真正的对话与接触。


“禅修是未来科技世界中的生存必需品”时代杂志曾在封面报道中这样写道。在硅谷,禅修被认为是新的咖啡因,一种能释放能量与创造力的全新“燃料”。


禅修也帮助过谷歌、Facebook、Twitter高管们走出困惑,国内比较知名则有搜狐的张朝阳和阿里的马云,还有微信之父张小龙的传说。


对于他们来说,商海的起伏伴随着心海的沉浮,庞大的财富、名声与地位带来的更多的不是快乐,但是禅修,却在一定程度上给他们指点迷津,带领他们脱离现代社会的痛苦、让内心更加平静!


乔布斯的禅修故事


乔布斯和禅修,一直有着很深的渊源。乔布斯是当世最伟大的企业家之一,同时也是一名虔诚的禅宗教徒。他少有慧根,17岁那年,他远赴印度寻找圣人寻求精神启蒙,18岁那年,他开始追随日本禅师乙川弘文学习曹洞宗的禅法。


年轻的时候,乔布斯去印度,在印度体验,呆了七个月。乔布斯在印度干了些什么,我们不得而知。不过据我推测,也就是四处逛一逛,看一看,可能会去一些寺庙,拜访一些僧人。


我从来不认为,他遇到了什么高人,或者在印度的小村庄一待,精神就受到了莫大的洗礼。变化永远都是从内在发生的,外在的不过是缘分,是过客,负责提供一个合适的环境,或者提供一些必要的刺激。


但我们知道,从此以后,乔布斯的人生,就开始变得不一样了。乔布斯的人生追求是“改变世界”,当年他劝说百事可乐总裁,来担任苹果CEO的时候所说的话:“你是愿意一辈子卖糖水,还是跟我一起改变这个世界?”激励了无数心怀梦想的朋友。


早在1973年乔布斯已经对禅有较深的领悟了。他这样说:“我对那些超越物质的形而上的学说极感兴趣,也开始注意到比知觉及意识更高的层次——直觉和顿悟。”


他还说:“因为时间有限,不要带着面具为别人而活,不要让别人的意见左右自己内心的想法,最重要的是要勇敢地忠于自己内心的直觉。”


乔布斯说:“你不能预先把点点滴滴串在一起;唯有未来回顾时,你才会明白那些点点滴滴是如何串在一起的。所以你得相信,你现在所体会的东西,将来多少会连接在一块。你得信任某个东西,直觉也好,命运也好,生命也好,或者业力。这种作法从来没让我失望,也让我的人生整个不同起来。”


他大学时学的书法,被他用来设计能够印刷出漂亮字体的计算机,尽管他在大学选修书法课时,完全不知道学这玩意能有什么用。他被自己创立的苹果公司开除,于是转行去做动画,结果在做动画的时候,遇到了自己未来的妻子。


人呐实在不知道,自己可不可以预料。你说我一个被自己创立的公司开除的失业狗,怎么就在第二份工作里遇到了一生的挚爱呢?若干年后,他回顾起自己的人生,他把这些点点滴滴串了起来,他发现,他所经历过的每一件事,都有着特殊的意义。


所以,无论面对怎样的困境,我们都不必悲观绝望,因为在剧本结束之前,你永远不知道,自己现在面对的这件事,到底是坏事还是好事。


所谓创新就是无中生有,包括思想、产品、艺术等,重大的创新我们称为颠覆。通过那则著名的广告《think different》他告诉世人:“因为只有那些疯狂到以為自己能够改变世界的人,才能真正地改变世界。”乔布斯确实改变了世界,而且不是一次,至少五次颠覆了这个世界:



  • 通过苹果电脑Apple-I,开启了个人电脑时代;

  • 通过皮克斯电脑动画公司,颠覆了整个动漫产业;

  • 通过iPod,颠覆了整个音乐产业;

  • 通过iPhone,颠覆了整个通讯产业;

  • 通过iPad,重新定义并颠覆了平板PC行业。


程序员与禅修


编程是一门需要高度专注和创造力的艺术,它要求程序员们在面对复杂问题和压力时,能够保持内心的安宁和平静。在这个快节奏、竞争激烈的行业中,如何修炼内心的禅意境界,成为程序员们更好地发挥潜力的关键。


在编程的世界里,专注是至关重要的品质。通过培养内在专注力,程序员能够集中精力去解决问题,避免被外界的干扰所困扰。以下是几种培养内在专注的方法:



  • 冥想和呼吸练习:  通过冥想和深呼吸来调整身心状态,让自己平静下来。坚持每天进行一段时间的冥想练习,可以提高专注力和注意力的稳定性。

  • 时间管理:  制定合理的工作计划和时间表,将任务分解为小的可管理的部分,避免心理上的压力。通过专注于每个小任务,逐步完成整个项目。

  • 限制干扰:  将手机静音、关闭社交媒体和聊天工具等干扰源,创造一个安静的工作环境。使用专注工作法(如番茄钟),集中精力在一项任务上,直到完成。


编程过程中会遇到各种问题和挑战,有时甚至会感到沮丧和失望。然而,保持平和的心态是非常重要的,它可以帮助程序员更好地应对压力和困难。以下是一些培养平和心态的技巧:



  • 接受不完美性:  程序永远不会是完美的,因为它们总是在不断发展和改进中。接受这一事实,并学会从错误中汲取教训。不要过于苛求自己,给自己一些宽容和理解。

  • 积极思考:  关注积极的方面,让自己的思维更加积极向上。遇到问题时,寻找解决方案而非抱怨。积极的心态能够帮助你更好地应对挑战和困难。

  • 放松和休息:  给自己合理的休息时间,让大脑得到充分的放松和恢复。休息和娱乐能够帮助你调整心态,保持平和的状态。


编程往往是一个团队合作的过程,与他人合作的能力对于一个程序员来说至关重要。以下是一些建立团队合作意识和促进内心安宁的方法:



  • 沟通与分享:  与团队成员保持良好的沟通,分享想法和问题。倾听他人的观点和建议,尊重不同的意见。积极参与和贡献团队,建立合作关系。

  • 友善和尊重:  培养友好、尊重和包容的态度。尊重他人的工作和努力,给予鼓励和支持。与团队成员建立良好的关系,创造和谐的工作环境。

  • 共享成功:  当团队取得成功时,与他人一起分享喜悦和成就感。相信团队的力量,相信集体的智慧和努力。


修炼内心安宁需要时间和长期的自我管理。通过培养专注力、平和心态、创造力和团队合作意识,程序员们可以在面对复杂的编程任务和挑战时保持内心的安宁和平静。


禅修有许多不同的境界,其中最典型的可能包括:



  • 懵懂:刚开始禅修时,可能会觉得茫然和困惑,不知该如何开始。

  • 困扰:在进行深度内省和冥想时,可能会遇到很多烦恼和难题,需耐心思考和解决。

  • 安和:通过不断地练习和开放自己的心灵,可能会进入一种更加平和和沉静的状态。

  • 祥和:当一些心理障碍得到解决,你会感受到一种更深层的平静和和谐。

  • 转化:通过不断的冥想与内省,你可以向内看到自己的内心,获得对自己和世界的新的认识和多样的观察角度。

  • 整体意识:通过冥想,您将能够超越个人的视野和言语本身,深入探究宇宙的内心,领悟更加深入和广泛的境界和意识。


程序员写代码的境界:



  • 懵懂:刚熟悉编程语言,不知做什么。

  • 困扰:可以实现需求,但仍然会被需求所困,需要耐心思考和解决。

  • 安和:通过不断练习已经可以轻易实现需求,更加平和沉静。

  • 祥和:全栈。

  • 转化:做自己的产品。

  • 整体意识:有自己的公司。


一个创业设想


打开小红书,与“疗愈”相关的笔记高达236万篇,禅修、瑜伽、颂钵等新兴疗愈方法层出不穷,无论是性价比还是高消费,总有一种疗愈方法适合你。


比起去网红景点打卡拍照卷构图卷妆造,越来越多的年轻人正在借助上香、拜神、颂钵、冥想等更为“佛系”的方式去追寻内心的宁静。放空大脑,呼吸之间天地的能量被尽数吸收体内,一切紧张、焦虑都被稀释,现实的残酷和精神的困顿,都在此间找到了出口。


在过去,简单的瑜伽和冥想就能达到这种目的,但伴随着疗愈文化的兴起与壮大,不断在传统方式之上叠加buff,才是新兴疗愈的终极奥义。


从目标人群来看,不同的禅修对应不同的人群。比如临平青龙寺即将在8月开启的禅修,就分为了企业禅修、教育禅修、功能禅修、共修禅、突破禅、网络共修等多种形式。但从禅修内容来看,各个寺庙的安排不尽相同,但基本上跳脱不出早晚功课、上殿过堂、出坡劳作、诵经礼忏、佛学讲座等环节。


艺术疗愈,是截然不同于起参禅悟道这种更亲近自然,还原本真的另一疗愈流派。具体可以细分为戏剧疗愈、绘画疗愈、音乐疗愈等多种形式。当理论逐渐趋向现代化,投入在此间的花费,也成正比增长。


绘画疗愈 ,顾名思义就是通过绘画的方式来表达自己内心的情绪。画幅的大小、用笔的轻重、空间的配置、色彩的使用,都在某种程度上反映着创作者潜意识的情感与冲突。


在绘画过程中,绘画者也同样会获得纾解和满足。也有一些课程会在绘画创作之外,添加绘画作品鉴赏的内容,通过一幅画去窥视作者的内心,寻求心灵上的共鸣,也是舒缓压力的一种渠道。


疗愈市场之所以能够发展,还是因为有越来越多人的负面情绪需要治愈。不论是工作压力还是亲密关系所带来的情绪内耗,总要有一个释放的出口。


当前,我正在尝试依托自营绘馆老师提供优质课件,打造艺培课件分享的平台或社区,做平台前期研发投入比较大,当前融资也比较困难,同时自己也需要疗愈。


所以,最近也在调研市场,评估是否可以依托自营的门店,组织绘画手工+寺庙行禅+技术专题分享的IT艺术禅修营活动,两天含住宿1999元,包括半天寺庙义工体验、半天禅修、半天绘画手工课和半天的技术专题分享。


不知道,这样的活动,大家会考虑参加吗?


总结


出家人抛弃尘世各种欲望出家修行值得尊重,但却不是修行的唯一方法,佛经里著名的维摩洁居士就是在家修行,也取得了非凡成就,六祖惠能就非常鼓励大家在世间修行,他说:“佛法在世间,不离世间觉,离世觅菩提,恰如求兔角”。


普通人的修行是在红尘欲望中的修行,和出家人截然不同,但无分高下,同样可以证悟,工作就是他们最好的修练道场。禅学的理论学习并不困难,但这只是万里长征的第一步,最重要的是,我们要在日常实践中证悟。


简单可能比复杂更难做到:你必须努力理清思路,从而使其变得简单。但最终这是值得的,因为一旦你做到了,便可以创造奇迹。”乔布斯所说的这种专注和简单是直接相关的,如果太复杂,心即散乱,就很难保持专注,只有简单,才能做到专注,只有专注,才能极致。


作者:三一习惯
来源:juejin.cn/post/7292781589477687350
收起阅读 »

浏览器沙盒你知多少😍

web
开题话: 😍随着业务环境的快速变化,安全性是开发人员和测试人员在现代 Web 开发周期中面临的最大挑战之一。构建和部署现代 Web 应用程序的复杂性会导致更多的安全漏洞。根据 IBM 和 Ponemon Institute 的数据泄露成本报告,2021 年,数...
继续阅读 »

开题话:


😍随着业务环境的快速变化,安全性是开发人员和测试人员在现代 Web 开发周期中面临的最大挑战之一。构建和部署现代 Web 应用程序的复杂性会导致更多的安全漏洞。根据 IBM 和 Ponemon Institute 的数据泄露成本报告,2021 年,数据泄露成本从 3 万美元(86 年的平均成本)上升到 2019 万美元,啧啧啧,这是该报告 4 年来的最高平均成本


因此,网络安全在软件开发生命周期中变得越来越重要,以确保用户数据安全和隐私。如果你可以开发和测试网站和 Web 应用程序而不必担心安全漏洞,那不是很好吗?👏沙盒是一种可以帮助你实现此目的的技术。沙盒是一种安全隔离应用程序、Web 浏览器和一段代码的方法。它可以防止恶意或有故障的应用程序攻击或监视你的 Web 资源和本地系统。


举个栗子➡, 在现实世界中,沙盒是被墙壁包围的儿童游乐区。它允许孩子们玩沙子,而草坪周围没有沙子。同样,沙盒浏览器创建了一个隔离的环境,用户可以在其中从第三方来源下载和安装应用程序,并在安全、隔离的环境中操作它们,即使他们行为可疑。因此,沙盒浏览器可以保护你的计算机免受额外的安全风险。


下面我们说说什么是浏览器沙盒吧!😘


本文将探讨什么是浏览器沙盒、不同类型的沙盒的优点和重要性,以及如何实现沙盒。


一、什么是浏览器沙盒?


为了防止系统或 Web 应用程序中出现安全漏洞,开发人员需要弄清楚如何处理它们。这是浏览器沙盒派上用场的时候。浏览器沙箱提供了一个安全的虚拟环境来测试有害代码或运行第三方软件,而不会损害系统的数据或本地文件。


例如,如果你在沙盒中下载恶意附件,它不会损坏系统的现有文件或资源。沙盒具有同源功能,它允许JavaScript在网页上添加或自定义元素,同时限制对外部JSON文件的访问。


今天,流行的网络浏览器,如Chrome,Firefox和Edge,都带有内置的沙箱。沙盒浏览器的最终目标是保护你的机器免受与浏览相关的风险。因此,如果用户从网站下载恶意软件,该软件将下载到浏览器的沙箱中。关闭沙箱时,其中的所有内容(包括有害代码)都会被清除。


浏览器沙盒使用两种隔离技术来保护用户的 Web 浏览活动和系统硬件、本地 PC 和网络:



  • 本地浏览器隔离

  • 远程浏览器隔离


本地浏览器隔离


本地浏览器隔离是一种传统的浏览器隔离技术,它在沙盒中运行虚拟浏览器或在用户的本地基础结构上运行虚拟机。它有助于将数据与外部安全威胁和不安全浏览隔离开来。例如,如果恶意元素潜入,影响将仅限于沙盒浏览器和虚拟机。


远程浏览器隔离


远程浏览器隔离涉及一种虚拟化技术,其中浏览器在基于云的服务器(公共云和私有云)上运行。在远程隔离中,用户的本地系统没有浏览活动,浏览器沙盒、过滤和风险评估在远程服务器上进行。


远程浏览器隔离涉及两种隔离用户本地基础结构和 Web 内容的方法:



  1. DOM 镜像:在这种技术中,浏览器并不完全与用户的本地系统隔离。但是,DOM 镜像技术会过滤恶意内容,并将其余内容呈现给用户。

  2. 可视化流式处理:此技术提供完全的远程浏览器隔离。可视化流式处理的工作方式类似于 VDI(虚拟桌面基础结构)系统,其中浏览器在基于云的服务器上运行,并将视觉输出显示到用户的本地计算机。


二、为什么浏览器沙盒很重要?


现代 Web 技术正在迅速扩展,从而使用户能够顺利开发和发布网站和 Web 应用程序。与此同时,对Web应用程序的需求也在以前所未有的速度增长。根据Imperva的一项调查,Web应用程序是50%数据泄露的来源。因此,拥有一个安全、受控的环境(如沙盒浏览器)至关重要,以便在不危及本地基础设施和系统资源的情况下执行操作。


例如,用户在沙盒中运行 Web 浏览器。如果恶意代码或文件利用 Web 浏览器漏洞,则沙盒中的影响受到限制。此外,引爆程序可以帮助发现新的漏洞并在 Web 浏览器中缓解它们。但是,如果禁用沙盒浏览器,恶意程序可以利用 Web 浏览器漏洞并损坏用户的本地系统和资源。


三、沙盒的好处


将沙盒合并到 Web 开发工作流中有很多优点。下面提到了一些优点😍😍:



  • 沙盒使设备和操作系统免于面临潜在威胁。

  • 与未经授权的一方或供应商合作时,最好使用沙盒环境。在部署内容之前,你可以使用沙盒来测试可疑代码或软件。

  • 沙盒可以帮助防止零日攻击。由于开发人员无法发现漏洞的即时补丁,因此零日攻击本质上是有害的。因此,沙盒通过向系统隐藏恶意软件来减轻损害。

  • 沙盒环境隔离威胁和病毒。这有助于网络专家研究和分析威胁趋势。它可以防止未来的入侵和识别网络漏洞。

  • 沙盒应用程序是一种混合解决方案,这意味着它们可以在本地和远程部署(基于云的服务器)。混合系统比传统解决方案更安全、更可靠、更具成本效益。

  • 沙盒和 RDP(远程桌面协议)设置可帮助企业确保安全的外部网络连接。

  • 沙盒可以与防病毒或其他安全工具和策略结合使用,以增强整个安全生态系统。


四、哪些应用正在沙盒化😎?


我们在日常工作流程中使用的大部分资产(如在线浏览器、网页、PDF、移动应用程序和 Windows 应用程序)都是沙盒化的。


下面列出了正在沙盒化的应用:



  • Web 浏览器:可能易受攻击的浏览器在沙盒环境中运行。

  • 浏览器插件:加载内容时,浏览器插件在沙盒中运行。沙盒浏览器插件(如 Java)更容易受到攻击。

  • 网页:浏览器以沙盒模式加载网页。由于网页是内置的 JavaScript,因此它无法访问本地计算机上的文件。

  • 移动应用:与 Android 和 iOS 一样,移动操作系统在沙盒模式下运行其应用。如果他们希望访问你的位置、联系人或其他信息,他们会弹出权限框。

  • Windows 软件和程序:在对系统文件进行更改之前,Windows 操作系统中的用户帐户控制 (UAC) 会请求你的许可。UAC 的功能类似于沙盒,但它不提供完整的保护。但是,不应禁用它。


五、不同类型的沙盒


在浏览器沙盒的这一部分中,我们将讨论不同类型的沙盒。沙盒分为三类:



  1. 应用程序沙盒

  2. 浏览器沙盒

  3. 安全沙盒


应用程序沙盒


使用应用程序沙箱,你可以在沙盒中运行不受信任的应用程序,以防止它们损坏本地系统或窃取数据。它有助于创建一个安全的环境,使应用程序可以在其中运行而不会损坏系统。通过将应用与用户的本地计算机隔离,应用程序沙盒增强了应用的完整性。


浏览器沙盒


可以在沙盒中执行基于浏览器的潜在恶意应用程序,以防止它们对你的本地基础架构造成损害。它导致建立一个安全的环境,在该环境中,Web 应用程序可以在不影响系统的情况下运行。引爆技术可以帮助发现 Web 浏览器中的新漏洞并缓解其。


安全沙盒


安全沙盒允许你探索和检测可疑代码。它扫描附件并识别潜在有害网站的列表,并确定是否下载或安装受感染的文件。


六、使用内置沙盒浏览器进行沙盒分析


沙盒预装在流行的浏览器中,如Chromium,Firefox和Edge,以保护你的系统免受浏览漏洞的影响。让我们看看沙盒在不同浏览器中的工作原理:


Chromium浏览器沙盒


Google Chrome和Microsoft Edge建立在Chromium浏览器上。代理和目标是构成 Chromium 浏览器沙箱的两个进程。目标进程是子进程,而浏览器进程是代理进程。目标进程的代码在沙盒环境中执行。代理进程在子进程和硬件资源之间操作,为子进程提供资源。


火狐浏览器沙盒


为了保护本地系统免受威胁,Firefox 在沙箱中执行不受信任的代码。Firefox 浏览器是使用父进程和子进程沙盒化的。浏览时,潜在的恶意程序会在沙盒中运行。在沙盒期间,父进程是子进程与其余系统资源之间的中介。


你可以更改 Firefox 浏览器中的沙盒程度,使其限制最少、中等或高度严格:



  • 级别 0:限制最少

  • 1级:中等

  • 第 2 级:高度限制


要检查 Firefox 沙盒浏览器的级别,请在地址栏中传递以下命令:


arduino
复制代码
about:config

在页面上,它将加载 Firefox 可配置变量。现在,在配置页面上点击“CTRL + F”,在搜索框中输入以下命令,然后按“Enter”。


image.png


image.png


Edge浏览器沙盒


当启动 Edge 沙盒浏览器 Windows 10 时,你将看到一个全新的桌面,该桌面仅具有“回收站”和 Edge 快捷方式。它显示“开始菜单”和其他图标,但它们在此沙盒环境中不起作用。你可以在标准Windows 10上访问它们,而不是沙盒Windows 10。


关闭 Edge 浏览器沙盒后,你的浏览器历史记录将不再可用。你的 ISP 可能会跟踪沙盒中的操作,但此数据不可审核。


七、禁用谷歌浏览器沙盒


在执行基于 Chrome 的沙盒测试时,你可能会遇到这样一种情况,即沙盒功能可能会导致 Chrome 浏览器闪烁以下错误:“应用程序初始化失败”。


在这种情况下,你可能需要停用 Chrome 浏览器沙盒。以下是以下步骤:



  1. 如果你没有 Google Chrome 沙盒快捷方式,请创建一个。

  2. 右键单击快捷方式,然后选择“属性”。选择属性

  3. 在目标中提供的应用路径中输入以下命令:



css
复制代码
--no-sandbox

属性 2



  1. 单击“应用”,然后单击“确定”。


八、浏览器沙盒:它是100%安全的吗💢?


大多数 Web 浏览器都使用沙盒。但是,互联网仍然是病毒和其他恶意软件的来源。沙盒的级别似乎有所不同。不同的 Web 浏览器以不同的方式实现沙盒,因此很难弄清楚它们是如何工作的。但是,这并不意味着所有网络浏览器都是不安全的。另一方面,浏览器沙箱可以使它们更安全。


但是,如果你问它是否提供100%的安全性,答案是否定的。如果某些浏览器组件使用 Flash 和 ActiveX,则它们可能会延伸到沙箱之外。


九、写在最后


企业受到高级持续性威胁 (APT) 的攻击,沙盒可以保护它们。通过查看前方的情况,你可以为未知攻击做好准备。你可以在隔离的环境中测试和开发应用程序,而不会因沙盒而损害本地系统资产。Sandboxie,BitBox和其他沙盒工具在市场上可用。但是,在沙盒中设置和安装不同的浏览器需要时间。


下回再说说前端——浏览器的安全性吧,本文就说到这里了😘


感谢jym浏览本文🤞,若有更好的建议,欢迎评论区讨论哈🌹。


如果jym觉得这篇干货很有用,加个关关🥰,点个赞赞🥰,后面我会继续卷,分享更多干货!感谢支持!😍


作者:珑墨
链接:https://juejin.cn/post/7241492186917847077
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

业绩一般般,如何写出漂亮的年终总结

互联网公司都讲究狼性文化。其中有一个政策就是强制的绩效考评。比如阿里的361政策,就是说每年年底都要对员工进行考评。30%的人超出预期,也就是3.75;60%的人符合预期,也就是3.5;10%的人不符合预期,也就是3.25。 为什么说是强制的绩效考评,是因为团...
继续阅读 »

互联网公司都讲究狼性文化。其中有一个政策就是强制的绩效考评。比如阿里的361政策,就是说每年年底都要对员工进行考评。30%的人超出预期,也就是3.75;60%的人符合预期,也就是3.5;10%的人不符合预期,也就是3.25。


为什么说是强制的绩效考评,是因为团队中如果有10个人。**无论这10个人产出有多多,业绩结果有多好,都会有一个人被评为不符合预期。**你能明白这个有多恶心了吧。



这篇文章的技巧任何岗位都适用,但是本人是研发,熟悉研发的工作内容,所以例子从研发的角度来写。



为什么年终总结很重要


虽然说这个很恶心。但绩效考评直接影响到年终奖以及下个财年的晋升,所以年底的年终总结一定要认真对待。尤其是全年的业绩相对一般般,这个时候更加要从年终总结中去体现价值


为什么这么说?有两方面的原因。


一个是因为老板并不很清楚每个人全年都做了什么,有哪些产出,遇到并解决了哪些困难。年终总结就一个很好的梳理机会,每个人的自评,能让老板更清晰的了解到每个人做了什么,从而做出排序。


另外,一级主管做的排序,并不一定是最终结果。大概率会在二级主管那边,把几个主管的排名进行重新排序。每个主管都希望自己团队多一些超出预期,少一些不符合预期。所以,针对在361边界的几个人,会被单独拿出来讨论,进行激烈的PK。这个PK的素材就是你的自评内容,我们要主动去准备主管能拿出来PK的素材,不然主管想帮你都帮不了。


所以你知道年终总结有多重要了吧。尤其是你在过去的一年中,没有做出让其他团队主管能记住的凸出表现,年终总结很多时候就决定了你能否多拿几个月年终奖。


今天就说一说业绩一般的情况下,如何写出漂亮的年终总结。内容有点多,写完发现有点长,所以我会分两篇内容,下一篇会把剩下的几个方法整理出来。感兴趣的可以公众号上看,我的公众号叫做写代码的浩


方法一:如果没有质量,那就先凑份量


业绩一般,往往不是没有工作量,而是没有亮点。很可能你做了很多事情,每天忙的焦头烂额,但这些事情做完后,并没有十分理想的结果。


就比如,你做了15个项目,每个项目都按时完成,但是每个项目上线后,都没有超预期的业务结果。


普通员工就会这样写:“按时完成需求”。稍微好一点的,会写“按时完成15个需求,没有线上bug。”


看到这种内容,主管会是什么反应,我不知道,但是我的反应是:哦、嗯、就这样吧。如果你的排序在不及预期的边界上, 你主管怎么去和另外一个组的主管PK,说你其实很优秀,做了很多工作。大概率PK失败,你还要怪主管为什么给你不及预期,是不是针对你,别人都是嫡系。


那应该怎么写。如果没有质量,那就先凑分量。你要让别人看了你的自评,觉得做了很多工作,且做事过程有计划有执行,不是胡乱做一通。主要是体现没有好结果(功劳),不是你的问题


可以试试这么写(挑核心项目写细节)



全年完成15个项目,按时完成,没有线上bug。


核心项目结果如下:


业务一:


目标:xxx


完成:调研了xx,完成了xx,业务结果xx


后续计划:xxx



方法二:越是没有内涵,越要重视形式


业绩一般,那么态度一定要好。 如何体现态度呢,就是用专业的形式暗示老板,你是一个态度认真的人。


就比如你的一项工作是做性能优化,优化目标没有达成。


普通员工会这么写:“性能优化目标xxx,优化到了xx”。这不是一看就知道没有达到目标,不及预期吗。主管看到这种也是只能叹气,想帮你都帮不了。


可以试试这么写(方法一也可以用,这里只讲解方法二)



性能优化:


目标:xxx


完成:


小目标1完成了xx,完成度80%


小目标2完成了xx,完成度120%



解释一下:



  1. 1. 把一个目标继续细分,对每一个细分目标进行罗列完成度。

  2. 2. 对关键数据使用不同颜色进行凸出

  3. 3. 数据多用图表


你看,这样一来,大目标虽然没完成,但是小目标有完成了的呀。是不是主管去PK时,就能有理由帮到你了。


总结


好了,已经1500字了,写的太长大家不喜欢看,后面几个技巧下一篇再继续写。觉得有收获可以关注下写代码的浩。


当然了,对绩效结果的影响,业绩一定是第一位的,平时一定要好好做项目,而不是“写PPT”。但是在年底,业绩一般的情况下,我们能做的就不多了,写好年终总结,算是我们最后的挣扎了吧。




我是写代码的浩,关注我,带你了解更多职场信息,也欢迎转发文章,传递行业真相!


文章看到这里,记得点右下角在看+点赞,感谢!****


作者:写代码的浩
来源:juejin.cn/post/7330825015049453578
收起阅读 »

为什么忘记密码时只能重设,不把旧密码告诉我?

某天小明在整理他的收藏夹时发现了一个以前很常逛,但已经将近半年多没去的一个论坛。小明想回去看看那边变得怎么样了,于是点进去那个论坛,输入了帐号密码,得到了密码错误的提示。 尝试了几次之后,系统提示小明可以使用「忘记密码」的功能,所以小明填了自己的 email ...
继续阅读 »

某天小明在整理他的收藏夹时发现了一个以前很常逛,但已经将近半年多没去的一个论坛。小明想回去看看那边变得怎么样了,于是点进去那个论坛,输入了帐号密码,得到了密码错误的提示。


尝试了几次之后,系统提示小明可以使用「忘记密码」的功能,所以小明填了自己的 email 之后去收件箱里查看,发现系统传来一个「重设密码」的链接。虽然说最后小明成功利用重新设定的密码登入,但有个问题让他百思不得其解:



奇怪呀,为啥要我重新设置密码,把旧的密码发到邮箱里给我不就好了?



应该有许多人都跟小明一样,有过类似的疑惑。把旧密码发给我不是很好吗,干嘛强迫我换密码?


这一个看似简单的问题,背后其实藏了许多信息安全相关的概念,就让我们慢慢寻找问题的答案,顺便学习一些基本的信息安全知识吧!


被偷走的数据库


大家应该很常看到新闻说哪个网站的数据又被偷走了,顾客个人数据全部都泄露出去。像是国外知名的网域代管网站 GoDaddy[1] 就泄露了 120 万笔用户数据,像国内的微博、淘宝等也有过数据泄露的情况发生。


这边我想带大家探讨的两个问题是:



  1. 数据真的这么容易泄露吗?

  2. 数据泄露之后,可能造成什么后果?


我们先来看第一个问题,有很多安全性的漏洞可以造成数据泄露,而有些漏洞的攻击方式,比你想的还简单一百倍。


图片


你想像中的黑客可能像上面那样,打着一大堆不知道在干嘛的指令,画面上出现很多黑底白字或是绿字的画面,完全搞不懂在干嘛,但是做着做着网站就被攻破下来了。


而事实上有些漏洞,可能在地址栏上面改几个字就攻击成功了,就算你不懂任何代码也做得到。


举例来说好了,假设今天有个购物网站,你买了一些东西之后送出订单,订单成立后跳转到订单页面,上面有着一大堆你的个人数据,例如说:姓名、收货地址、联络电话以及 Email 等等。


然后你发现订单页面的网址是 https://shop.xiaoming.cn/orders?id=14597


而正好你的订单编号也是 14597,在好奇心的驱使之下,你就试着把数字改成 14596,然后按下 Enter。


当网站载入完成之后,你竟然还真的能看到编号为 14596 的订单,上面出现一个你不认识的人的姓名、收货地址、联络电话跟 Email。


有些攻击就是这么朴实无华且枯燥,只要改个字就能看到属于其他人的数据。这时候如果你会写程序的话,就可以写个脚本自动去抓 id 是 1 一直到 id 是 15000 的数据,你就拿到了这个购物网站 15000 笔订单的资讯,也就是一万多个顾客的个人数据。


这过程中没有什么黑底白字的画面,也不用一直疯狂打字,唯一需要的只有改数字,个人数据就轻松到手。


这类型的漏洞有个专有名词,称为 IDOR,全名是:Insecure direct object references,大约就是不安全的直接数据存取的意思。漏洞产生的原因就是工程师在开发时,并没有注意到权限控管,因此让使用者能存取到其他人的数据。


有些人看到这边可能以为我只是为了文章浅显易懂,所以才举一个简化的例子,现实生活中的攻击才没这么简单。


这句话算是对了一半,大部分的网站确实都不会有这么明显的一个漏洞,攻击方式会更复杂一点。但可怕的是,还真的有些网站就是这么简单,就是改个数字就可以拿到别人的数据。


例如说这两个就是 IDOR 的真实漏洞:



  1. 享健身 xarefit 任意访问/下载所有会员个人数据[2]

  2. DoorGods 防疫门神实联制系统 IDOR 导致个人数据泄露[3]


对,不要怀疑,就真的只是在网址上改个数字而已这么容易。


以后只要看到网址列上有这种数字,就可以试着去改改看,搞不好不会写程序的你也可以发现 IDOR 的漏洞。


除了这种只要改个东西的漏洞之外,还有另外一个很常见但是需要一点技术能力才能攻破的漏洞,叫做 SQL Injection。


先来讲讲 SQL 是什么,简单来说就是跟数据库查询东西的一种程序语言。既然说是语言那就会有固定语法,若是以中文举例,大概就像是:



去找「订单数据」,给我「id 是 1 的」,按照「建立时间」排序



用「」框起来的部分代表可以变动,而其他关键字例如说「去找」、「给我」这些都是固定的,因为语法要固定才能写程序去解析。


同样以上面假想的购物网站为例,如果网址是 https://shop.xiaoming.cn/orders?id=14597 ,那网站去跟数据库拿数据时,指令大概就是:



去找「订单数据」,给我「id 是 14597 的」



因为网址列上的 id 是 14597 嘛,所以这个 id 就会被放到查询的指令去,如果 id 是别的,那查询的指令也会不一样。


这时候如果我的 id 不是数字,而是「1 的顺便给我使用者数据」,查询就变成:



去找「订单数据」,给我「id 是 1 的顺便给我使用者数据」



那整个网站的使用者数据就顺便被我抓下来了。


这个攻击之所以叫做 SQL injection,重点就在于那个 injection,攻击者「注入」了一段文字被当作指令的一部分执行,所以攻击者就可以执行任意查询。


比起上面讲的 IDOR,SQL injection 通常会更为致命,因为不只是订单数据本身,连其他数据也会被一起捞出来。所以除了订单数据,会员数据跟商品数据都有可能一起泄露。


这边也随便找两个公开的案例:



  1. 北一女中网站存在 SQL Injection 漏洞[4]

  2. 桃园高中 网站 SQL injection[5]


而防御方式就是不要把使用者输入的「1 的顺便给我使用者数据」直接当作指令,而是经过一些处理,让整段查询变成:「给我 id 是:『1 的顺便给我使用者数据』的数据」,那因为没有这个 id,所以什么事也不会发生。


个人数据泄漏了,然后呢?


前面我们已经看到了针对那些没有做好防御的网站,个人数据泄露是多么容易的一件事情。


那个人数据泄漏之后,对使用者会有什么影响呢?


大家最感同身受的应该就是诈骗电话吧,例如说某些买书的网站或是订房网站,打过来跟你说什么要分期退款,为了博取你的信任,连你买了哪本书,订了哪个房间,或是你家地址跟姓名全都讲得出来。


这些都是因为数据泄露的缘故,诈骗集团才会知道的这么清楚。


但除了这些个人数据以外,还有两个东西也会泄露,那就是你的帐号跟密码。


也许你会想说:「不就帐号跟密码吗,我就在那个网站上面改密码以后再用就好啦!」


事情也许没有你想的这么简单。如果你没有用密码管理软件的话,我大胆猜测你所有的密码可能都是同一个。因为怕记不起来嘛,所以干脆都用同一个密码。


这时候如果账号和密码泄露,黑客是不是就可以拿这个账号和密码去其他服务试试看?


拿去登你的 Google,登你的 Facebook,这时候用同一个密码的人就会被登进去。所以从表面看只是一个购物网站被入侵,但造成的结果却是你的 Google 还有 Facebook 也一起被盗了。


所以,有时候某个网站被盗帐号可能不是那个网站的问题,而是黑客在其他地方拿到了你的帐号密码,就来这边试试看,没想到就中了。


对于网站的开发者而言,保护好使用者的个人数据是天经地义的事情,保护密码也是,有没有什么好方法可以保护密码呢?


加密吗?把密码用某些算法加密,这样数据库储存的就会是加密后的结果,尽管被偷走了,黑客只要没有解密的方法就解不开。


听起来似乎是最安全的做法了,但其实还有一个问题,那就是网站的开发者还是会知道怎么解密,如果有工程师监守自盗怎么办?他还是可以知道每个使用者的密码是什么,可以把这些资讯拿去卖或者是自己利用。


嗯…似乎我们也不能怎么样,因为无论如何,开发者都需要有方法知道数据库存的密码究竟是多少吧?不然在登入的时候怎么确认帐号密码是对的?


再者,这样听起来应该够安全了,要怎么样才能更安全?难道要连网站的开发者都无法解密,都不知道密码是什么才够安全吗?


Bingo!答对了,就是要这样没错!


没有人知道你的密码,包括网站本身


事实上,网站的数据库是不会储存你的密码的。


或更精确地说,不会储存你的「原始密码」,但会储存密码经过某种运算后的结果,而且最重要的是, 这个运算是无法还原的


直接举例比较快,假设今天有个很简单的算法,可以把密码做转换,转换方式是:「数字不做转换,英文字母把 a 换成 1,b 换成 2…z 换成 26」,以此类推,第几个字母就换成几,大小写不分都一样(先假设不会有符号)。


如果密码是 abc123,转换完就变成 123123。


在使用者注册的时候,网站就把使用者输入的 abc123 转成 123123,然后存到数据库里面。因此数据库存的密码是 123123,而不是 abc123。


当使用者登入时,我们就再把输入的值用同样的逻辑转换,如果输入一样,转换后的结果就会一样对吧?就知道密码是不是正确的。


当黑客把数据库偷走以后,会拿到 123123 这组密码,那一样啊,不是可以推论出原本是 abc123 吗?不不不,没这么简单。


123123、abcabc、12cab3…这些密码转换之后,不也是 123123 吗?所以尽管知道转换规则跟结果,却没有办法还原成「唯一一个密码」,这就是这个算法厉害的地方!


这样的转换就叫做哈希(Hash),abc123 每次 hash 过后的结果都会是 123123,但是从 123123 却无法得到输入一定是 abc123,因为有其他种可能性存在。


这就是 hash 跟加密最大的不同。


加密跟解密是成对的,如果可以加密就一定可以解密,所以你知道密文跟密钥,就可以知道明文。但 hash 不同,你知道 hash 算法的结果,却无法逆推出原本的输入是什么。


而这个机制最常见的应用之一,就在于密码的储存。


在注册时把 hash 过后的密码存进数据库,登入时把输入的密码 hash 过后跟数据库比对,就知道密码是否正确。就算数据库被偷,黑客也不知道使用者的密码是什么,因为逆推不出来。


这就是为什么忘记密码的时候,网站不会跟你讲原本的密码是什么,因为网站本身也不知道啊!


所以不能「找回密码」,只能「重设密码」,因为重设就代表你输入新的密码,然后网站把新的密码 hash 之后存进数据库,未来登入时就会用这组新的 hash 去比对。


有些人可能会注意到这样的储存方式似乎有个漏洞,延续前面的例子,数据库存的是 123123 而我的原始密码是 abc123,这样如果用「abcabc」,hash 过后也是 123123,不就也可以登入吗?这样不太对吧,这不是我的密码嘛


有两个不同的输入却产生出同一个输出,这种状况称为碰撞(hash collision),碰撞一定会发生,但如果算法设计的好,碰撞的机率就超级无敌小,小到几乎可以忽略。


前面提的转换规则只是为了方便举例,真实世界中用的算法复杂许多,就算只有一个字不同,结果都会天差地远,以 SHA256 这个算法为例:



  1. abc123 => 6ca13d52ca70c883e0f0bb101e425a89e8624de51db2d2392593af6a84118090

  2. abc124 => cd7011e7a6b27d44ce22a71a4cdfc2c47d5c67e335319ed7f6ae72cc03d7d63f


类似的输入却产生截然不同的输出。


像我前面举例用的转换就是不安全的 hash 算法,要尽量避免使用或是避免自己设计,尽可能使用密码学家跟专家设计出的算法,像是上面提到的 SHA256。


在使用这些算法的时候,也要特别注意一下是否安全,因为有些算法虽然也是由专家设计,但已经被证明是不安全的,例如说密码用 MD5 来 hash 后储存就是不安全的,可以参考:Is MD5 considered insecure?[6]


储存 hash 后的值就没事了吗?


抱歉,其实只储存密码 hash 过后的值是不够的。


咦,为什么?我刚刚不是说没办法反推出结果吗,那为什么不够?


虽然说没办法反推出结果,但攻击者可以利用「输入一样,输出一定一样」的特性,先建好一个人数据料库。


举例来说,假设有个很常见的密码 abc123,hash 过后的值是 6ca13d,那攻击者就可以先算好,然后把这个关系存在数据库,所以攻击者的数据库里面就可能会有一百万组最常见密码的清单,里面有着每个密码跟它 hash 过后的值。


那接下来只要在 hash 过后的数据库发现 6ca13d,攻击者就可以透过查表的方式,查出原本的密码是 abc123。这不是利用算法反推结果,这只是利用现有数据来查询而已。


为了防御这种攻击,还要做一件事情叫做加盐(Salting),没错,就是盐巴的那个盐。通常会帮每个使用者产生一个独一无二的盐,例如说 5ab3od(实际上会更长,可能 16 或 32 个字以上),接着把我的密码 abc123 加上我的盐,变成 abc1235ab3od,然后用这个加盐过后的结果去做 hash。


为什么要这样做呢?


因为攻击者预先准备好的表格中,比起 abc123,出现 abc1235ab3od 的机率显然更低,同时又因为长度变长了,暴力破解的难度变得更高。如此一来,密码就变得更难破解了。


结语


忘记密码时网站不会把密码发给我,因为网站自己都不知道我的密码是什么。虽然听起来不太可能,但实际状况就是如此。为了安全性,这是必须的手段。


要达成这样的目的,背后最重要的技术原理就是 hash,「同样的密码会产生同样的 hash 值,但从 hash 值没办法对应回原本的密码」就是秘诀所在。


反之,如果你发现有网站可以找回你的密码,那就得要多加注意,有可能网站数据库存的不是 hash 值而是你的密码。在这种状况下,万一有天数据库被入侵,账号和密码被偷走,黑客就能得知你真实的密码,然后去试其他的服务。


有关于密码管理,现在浏览器也有功能可以自动帮你产生密码外加记忆密码,或也可以使用现成的密码管理软件,都可以在不同网站产生不同的密码。


这篇希望能让对这个领域陌生的读者们也能知道一些基本的概念,包括:



  1. 有些网站比你想得脆弱很多,改个网址就可以拿到别人的数据

  2. 对于安全性做得不好的网站,拿到整个人数据料库不是一件难事

  3. 忘记密码只能重设,不能找回,是因为网站也不知道你的密码

  4. 如果有网站可以把旧密码给你,那你得要小心一点


作者:写bug写bug
来源:juejin.cn/post/7330922611512262671
收起阅读 »

尾递归优化是一场谎言

web
TLDR 本文是对蹦床函数的应用案例。 蹦床函数跟尾递归优化都是为了解决递归次数过多导致调用栈溢出的问题。 蹦床函数的原理:令原函数返回一个可执行函数,由蹦床函数来控制执行时机,使执行函数与其父函数无执行调用关系。 处理递归栈溢出还有递归转迭代、异步执行等方...
继续阅读 »

TLDR



  • 本文是对蹦床函数的应用案例。

  • 蹦床函数跟尾递归优化都是为了解决递归次数过多导致调用栈溢出的问题。

  • 蹦床函数的原理:令原函数返回一个可执行函数,由蹦床函数来控制执行时机,使执行函数与其父函数无执行调用关系。

  • 处理递归栈溢出还有递归转迭代、异步执行等方案,但蹦床函数对代码的改动量很低,也不影响原代码的阅读和执行逻辑。


起因


接到一个需求,需要对 markdown 进行分句。大致如下:


// 原始 md
aaa。bbb[^1^]。ccc。

// 转换后 md
aaa。x:[bbb[^1^]]。ccc。

即把以[^*^]结尾的句子给包起来。


方案思路比较简单:



  • 写一个函数,入参为起始位置,大概这样:walk(startIndex)

  • 起始位置设为最后一个字符位置,往前找,如果找到[^*^]则往前找句子结束符(如。!?-),找到后就把整句话用x:[]包起来。

  • 继续调用这个函数,入参startIndex为刚刚那个句子的起始位置。

  • 直到入参为0(即找到头了),结束执行。


完美,补充完测试用例,开心光速下班 😃。





命运所有的馈赠早已暗中标好了价格。





报错


一天后:


image.png


image.png


栈溢出了。


同事的需求需要往这段 markdown 里填充上万字符,我的递归函数爆栈了 🤯🤯🤯。


一时间脑海里立刻出现 3 条解决方案:



  • 问 gpt

  • 问 gpt

  • 问 gpt


image.png
得到了6种解决方案:



  1. 尾递归优化(Tail Call Optimization)

  2. 循环替换递归( Loop) 将递归转换为等效的循环

  3. 使用堆栈来管理状态(Manual Stack Management) 手动使用数组作为堆栈来保存需要的状态,模拟递归的过程。

  4. 使用异步递归( Async Recursion) 通过把递归调用放在setTimeout、setImmediate或Promise中来异步执行,可以避免同步递归调用造成的堆栈溢出。

  5. 节流递归(Throttling Recursion) 通过定期将递归调用事件推迟到下一个事件循环迭代中,你可以避免堆栈溢出。

  6. Trampoline 函数(Trampoline Function) Trampoline 是一种编程技巧,允许你改写递归函数,使其成为迭代的,而不需要占用新的调用栈帧。


简单评估下这几个方案:



  • 方案2和3对代码的改动量较大,递归变迭代,实在懒得改也懒得验证,哒咩 ❌

  • 方案4和5把同步逻辑改成了异步,对代码逻辑的侵入性太强,哒咩 ❌

  • 方案1的尾递归优化我现在就在用,无效,哒咩 ❌


方案一让我感觉撞了鬼:为什么我写的尾递归优化没生效?


搜了下才发现尾递归优化可谓名存实亡,主流浏览器全都不支持尾递归优化:


compat-table.github.io/compat-tabl…


image.png


详见文章:尾递归的后续探究-腾讯云开发者社区-腾讯云


解决


5/6的方案都被否掉,看来看去只能使用 Trampoline 函数,即蹦床函数。


我们看下 gpt给的示例,以解释蹦床函数做了些什么:


function trampoline(fn) {
return function(...args) {
let result = fn(...args);
while (typeof result === 'function') {
result = result();
}
return result;
};
}

function sum(x, y) {
if (y > 0) {
return () => sum(x + 1, y - 1);
} else {
return x;
}
}

let safeSum = trampoline(sum);
safeSum(1, 100000);

这里对原函数的修改在第13行,正常的递归会直接执行sum函数,而这段优化里则改成返回了一个函数,我们姑且称之为 handler 函数。


而 trampoline 函数的作用则是执行 sum 函数并判断返回值,如果返回值是函数(即 handler 函数),则继续执行该函数,直到返回值是数字。整个判断&执行过程使用 while循环。


蹦床函数之所以能够摆脱递归调用栈限制,是因为 handler 函数是由蹦床函数执行的,handler 函数执行前,它的父函数 sum 函数已经执行完毕了,handler 的执行跟 sum 的执行没有堆栈关系。




完美,



  • 补充测试用例。

  • 加上try catch防止白屏。

  • 加上memo防止每次render都递归计算。


开心光速下班 😃。


作者:tumars
来源:juejin.cn/post/7330521390510440511
收起阅读 »

Vite 4.3 为何性能爆表?

web
免责声明 本文属于是语冰的直男翻译了属于是,略有删改,仅供粉丝参考。英文原味版请传送 How we made Vite 4.3 faaaaster。 本期共享的是 —— Vite 4.3 性能大幅提升的幕后技术细节。 地球人都知道,Vite 4.3 相比 V...
继续阅读 »

免责声明


本文属于是语冰的直男翻译了属于是,略有删改,仅供粉丝参考。英文原味版请传送 How we made Vite 4.3 faaaaster



本期共享的是 —— Vite 4.3 性能大幅提升的幕后技术细节。


地球人都知道,Vite 4.3 相比 Vite 4.2 取得了惊人的性能提升。


01-vite.png


fs.realpathSync 的问题


Nodejs 中有一个有趣的 realpathSync 问题,它指出 fs.realpathSyncfs.realpathSync.native70 倍。


但由于在 Windows 上的行为不同,Vite 4.2 只在非 Windows 系统上使用 fs.realpathSync.native。为了搞定此问题,Vite 4.3 在 Windows 上调用 fs.realpathSync.native 时添加了网络驱动验证。


Vite 从未放弃 Windows,它真的......我哭死。


JS 优化


不要错过编程语言优化。Vite 4.3 中若干有趣的 JS 优化案例:


*yield 重构为回调函数


Vite 使用 tsconfck 来查找和解析 tsconfig 文件。tsconfck 用于通过 *yield 遍历目标目录,生成器的短板之一在于,它需要更多的内存空间来存储其 Generator 对象,且生成器中存在一大坨生成器上下文切换运行。因此,自 v2.1.1 以来,该核心库用回调函数重构了 *yield


startsWith/endsWith 重构为 ===


Vite 4.2 使用 startsWith/endsWith 来检查热门 URL 中的前置和后置 '/'。我们测评了 str.startsWith('x') 和 str[0] === 'x' 的执行基准跑分,发现 ===startsWith 快约 20%。同时 endsWith=== 慢约 60%。


避免重新创建正则表达式


Vite 需要一大坨正则表达式来匹配字符串,其中大多数都是静态的,所以最好只使用它们的单例。Vite 4.3 优化了正则表达式,这样可以重复使用它们。


放弃生成自定义错误


为了更好的开发体验,Vite 4.2 中存在若干自定义错误。这些错误可能会导致额外的计算和垃圾收集,降低 Vite 的速度。在 Vite 4.3 中,我们不得不放弃生成某些热门的自定义错误(比如 package.json NOT_FOUND 错误),并直接抛出原始错误,获取更好的性能。


更机智的解析策略


Vite 会解析所有已接收的 URL 和路径,获取目标模块。


Vite 4.2 中存在一大坨冗余的解析逻辑和非必要的模块搜索。 Vite 4.3 使解析逻辑更精简、更严格、更准确,减少计算量和 fs 调用。


更简单的解析


Vite 4.2 重度依赖 resolve 模块来解析依赖的 package.json,当我们偷看 resolve 模块的源码时,发现在解析 package.json 时存在一大坨无用逻辑。Vite 4.3 弃用了 resolve 模块,遵循更精简的解析逻辑:直接检查嵌套父目录中是否存在 package.json


更严格的解析


Vite 需要调用 Nodejs 的 fs API 来查找模块。但 IO 成本昂贵。Vite 4.3 缩小了文件搜索范围,并跳过搜索某些特殊路径,尽量减少 fs 调用。举个栗子:



  1. 由于 # 符号不会出现在 URL 中,且用户可以控制源文件路径中不存在 # 符号,因此 Vite 4.3 不再检查用户源文件中带有 # 符号的路径,而只在 node_modules 中搜索它们。

  2. 在 Unix 系统中,Vite 4.2 首先检查根目录内的每个绝对路径,对于大多数路径而言问题不大,但如果绝对路径以 root 开头,那大概率会失败。为了在 /root/root 不存在时,跳过搜索 /root/root/path-to-file,Vite 4.3 会在开头判断 /root/root 是否作为目录存在,并预缓存结果。

  3. 当 Vite 服务器接收到 @fs/xxx@vite/xxx 时,无需再次解析这些 URL。Vite 4.3 直接返回之前缓存的结果,不再重新解析。


更准确的解析


当文件路径为目录时,Vite 4.2 会递归解析模块,这会导致不必要的重复计算。Vite 4.3 将递归解析扁平化,针对不同类型的路径对症下药。拍平后缓存某些 fs 调用也更容易。


package


Vite 4.3 打破了解析 node_modules 包数据的性能瓶颈。


Vite 4.2 使用绝对文件路径作为包数据缓存键。这还不够,因为 Vite 必须在 pkg/foo/barpkg/foo/baz 中遍历相同的目录。


Vite 4.3 不仅使用绝对路径(/root/node_modules/pkg/foo/bar.js/root/node_modules/pkg/foo/baz.js),还使用遍历的目录(/root/node_modules/pkg/foo/root/node_modules/pkg)作为 pkg 缓存的键。


另一种情况是,Vite 4.2 在单个函数内查找深度导入路径的 package.json,举个栗子,当 Vite 4.2 解析 a/b/c/d 这样的文件路径时,它首先检查根 a/package.json 是否存在,如果不存在,那就按 a/b/c/package.json -> a/b/package.json 的顺序查找最近的 package.json,但事实上,查找根 package.json 和最近的 package.json 应该分而治之,因为它们需要不同的解析上下文。Vite 4.3 将根 package.json 和最接近的 package.json 的解析分而治之,这样它们就不会混合。


非阻塞任务


作为一种按需服务,Vite 开发服务器无需备妥所有东东就能启动。


非阻塞 tsconfig 解析


Vite 服务器在预打包 ts/tsx 时需要 tsconfig 的数据。


Vite 4.2 在服务器启动前,会在 configResolved 插件钩子中等待解析 tsconfig 的数据。一旦服务器启动而尚未备妥 tsconfig 的数据,即使该请求稍后可能需要等待 tsconfig 解析,页面请求也可以访问服务器,


Vite 4.3 在服务器启动前初始化 tsconfig 解析,但服务器不会等待它。解析过程在后台运行。一旦 ts 相关的请求进来,它就必须等待 tsconfig 解析完成。


非阻塞文件处理


Vite 中存在一大坨 fs 调用,其中某些是同步的。这些同步 fs 调用可能会阻塞主线程。Vite 4.3 将其更改为异步。此外,异步函数的并行化也更容易。关于异步函数,我们关心的一件事是,解析后可能需要释放一大坨 Promise 对象。得益于更机智的解析策略,释放 fs - Promise 对象的成本要低得多。


HMR 防抖


请考虑两个简单的依赖链 C <- B <- AD <- B <- A,当编辑 A 时,HMR 会将两者从 A 传播到 CD。这导致 AB 在 Vite 4.2 中更新了两次。


Vite 4.3 会缓存这些遍历过的模块,避免多次探索它们。这可能会对那些具有组件集装导入的文件结构产生重大影响。这对于 git checkout 触发的 HMR 也有好处。


并行化


并行化始终是获取更好性能的不错选择。在 Vite 4.3 中,我们并行化了若干核心功能,包括但不限于导入分析、提取 deps 的导出、解析模块 url 和运行批量优化器。并行化之后确实有令人印象深刻的改进。


基准测试生态系统



  • vite-benchmark:Vite 使用此仓库来测评每个提交的跑分,如果您正在使用 Vite 开发大型项目,我们很乐意测试您的仓库,以获得更全面的性能。

  • vite-plugin-inspect:vite-plugin-inspect 从 v0.7.20 开始支持显示插件的钩子时间,并且将来会有更多的跑分图,请向我们反馈您的需求。

  • vite-plugin-warmup:预热您的 Vite 服务器,并提升页面加载速度!


《前端 9 点半》每日更新,持续关注,坚持阅读,每天一次,进步一点


谢谢大家的点赞,掰掰~


26-cat.gif


作者:人猫神话
来源:juejin.cn/post/7331361547011801115
收起阅读 »

如何写一个无侵入式的动态权限申请Android框架?

1、核心逻辑 在Activity或者fragment中,写在几个方法写一些注释,用来表示权限申请成功,申请失败,多次拒绝。以上就是使用者需要做的。 简单吧,简单就对了,不用传任何上下文。只需要写注解。给大家看下。 public class MainActivi...
继续阅读 »

1、核心逻辑


在Activity或者fragment中,写在几个方法写一些注释,用来表示权限申请成功申请失败多次拒绝。以上就是使用者需要做的。


简单吧,简单就对了,不用传任何上下文。只需要写注解。给大家看下。


public class MainActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
}
public void permissionRequestTest(View view) {
testRequest();
}
// 申请权限 函数名可以随意些
@Permission(value = Manifest.permission.READ_EXTERNAL_STORAGE, requestCode = 200)
public void testRequest() {
Toast.makeText(this, "权限申请成功...", Toast.LENGTH_SHORT).show();
}
// 权限被取消 函数名可以随意些
@PermissionCancel
public void testCancel() {
Toast.makeText(this, "权限被拒绝", Toast.LENGTH_SHORT).show();
}

// 多次拒绝,还勾选了“不再提示”
@PermissionDenied
public void testDenied() {
Toast.makeText(this, "权限被拒绝(用户勾选了 不再提示),注意:你必须要去设置中打开此权限,否则功能无法使用", Toast.LENGTH_SHORT).show();
}


2、实现


需要用到的技术有Aspect、注解、反射


2.1、Aspect


它的作用就是劫持被注释的方法的执行。比如上方testRequest()是用来请求权限的,但是我在ASPECT中配置拦截@permission注释的方法。先做判断。


如果没有听过Aspect的话,AOP面向切面编程,大家应该听说过,它可以用来配置事务、做日志、权限验证、在用户请求时做一些处理等等。而用@Aspect做一个切面,就可以直接实现。


2.2、PermissionAspect

我们会创建一个PermissionAspect类,整一个切点,让@Permission被劫持。


// AOP 思维 切面的思维
// 切点 --- 是注解 @
// * * 任何函数 都可以使用 此注解
//(..) 我要带参数 带的参数就是后面那个 @annotation(permission)意思就是 参数里是permission。这样我就拿到了Permission注解它里面的参数
//这样通过切点就拿到了下面这个注解
//@Permission(value = Manifest.permission.READ_EXTERNAL_STORAGE, requestCode = 200) 这就是 Permission permission
@Pointcut
("execution(@com.derry.premissionstudy.permission.annotation.Permission * *(..)) && @annotation(permission)")
//那么这个
// @Permission == permission
public void pointActionMethod(Permission permission) {

} // 切点函数

//切面
@Around("pointActionMethod(permission)")
public void aProceedingJoinPoint(final ProceedingJoinPoint point,Permission permission) throws Throwable{
//我需要拿到 MainActivity this


这样@Permission就被切点劫持了,然后方法就会跑到切面aProceedingJoinPoint。然后获取上下文Context,把权限请求交给一个透明的Activity来做。做完之后判断结果,用户是同意了还是拒绝了还是曲线了。同意了直接执行point.proceed(),其他方式则通过Activity或者fragment获取带注解的方法,反射执行即可。


//切面
@Around("pointActionMethod(permission)")
public void aProceedingJoinPoint(final ProceedingJoinPoint point,Permission permission) throws Throwable{
//我需要拿到 MainActivity this

Context context = null;
// MainActivity this == thisObject
final Object thisobject = point.getThis();

// context初始化
if(thisobject instanceof Context){
context = (Context) thisobject;
} else if(thisobject instanceof Fragment){
context = ((Fragment) thisobject).getActivity();
}

// 判断是否为null
if (null == context || permission == null) {
throw new IllegalAccessException("null == context || permission == null is null");
}


//trestRequest 次函数被控制了 不会执
//
//动态申请 危险权限 透明的空白的Ativity
//这里一定要得知接口三个状态 已经授权 取消授权 拒绝授权
//调用 空白的Actiivty 开始授权

MyPermissionActivity.requestPermissionAction(context, permission.value(), permission.requestCode(), new IPermission() {
@Override
public void ganted() {
try {
point.proceed();
} catch (Throwable throwable) {
throwable.printStackTrace();
}
}
@Override
public void cancel() {
PermissionUtils.invokeAnnotion(thisobject, PermissionCancel.class);
}

@Override
public void denied() {
PermissionUtils.invokeAnnotion(thisobject, PermissionDenied.class);
}
});
}

2.3、空白执行权限的Activity


执行请求权限的Activity的的相应方法会流到PermissionAspect,然后到空白Activity请求。请求完之后的结果,再通过回调传回去就好了。



public class MyPermissionActivity extends AppCompatActivity {



// 定义权限处理的标记, ---- 接收用户传递进来的
private final static String PARAM_PERMSSION = "param_permission";
private final static String PARAM_PERMSSION_CODE = "param_permission_code";
public final static int PARAM_PERMSSION_CODE_DEFAULT = -1;


private String[] permissions;
private int requestCode;


// 方便回调的监听 告诉外交 已授权,被拒绝,被取消
private static IPermission iPermissionListener;

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_my_permission);

permissions = getIntent().getStringArrayExtra(PARAM_PERMSSION);
requestCode = getIntent().getIntExtra(PARAM_PERMSSION_CODE, PARAM_PERMSSION_CODE_DEFAULT);


if(permissions == null){
this.finish();
return;
}

// 能够走到这里,就开始去检查,是否已经授权了
boolean permissionRequest = PermissionUtils.hasPermissionRequest(this,permissions);
if (permissionRequest) {
// 通过监听接口,告诉外交,已经授权了
iPermissionListener.ganted();

this.finish();
return;
}
ActivityCompat.requestPermissions(this,permissions,requestCode);
}


@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);

if(PermissionUtils.requestPermissionSuccess(grantResults)){
iPermissionListener.ganted();//已经授权成功了 告知AspectJ
this.finish();
}

// 没有成功,可能是用户 不听话
// 如果用户点击了,拒绝(勾选了”不再提醒“) 等操作
if(!PermissionUtils.shouldShowRequestPermissionRationable(this,permissions)){
iPermissionListener.denied();

this.finish();
return;
}
// 取消
iPermissionListener.cancel(); // 接口告知 AspectJ
this.finish();
return;
}


// 让此Activity不要有任何动画
@Override
public void finish() {
super.finish();
overridePendingTransition(0, 0);
}


public static void requestPermissionAction(Context context, String[] permissions, int requestCode, IPermission iPermissionLIstener){

MyPermissionActivity.iPermissionListener = iPermissionLIstener;
Intent intent = new Intent(context,MyPermissionActivity.class);
//效果
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
Bundle bundle = new Bundle();

Log.d("TAG", "requestPermissionAction: "+requestCode);
bundle.putInt(PARAM_PERMSSION_CODE,requestCode);
bundle.putStringArray(PARAM_PERMSSION,permissions);
intent.putExtras(bundle);
context.startActivity(intent);
}
}

2.4、其它


app gradle中


 
apply plugin: 'com.android.application'


buildscript {
repositories {
mavenCentral()
}

dependencies {
classpath 'org.aspectj:aspectjtools:1.8.9'
classpath 'org.aspectj:aspectjweaver:1.8.9'
}
}

android {
compileSdkVersion 29
buildToolsVersion "29.0.2"
defaultConfig {
applicationId "com.netease.premissionstudy"
minSdkVersion 19
targetSdkVersion 29
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
}

dependencies {

implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation 'androidx.appcompat:appcompat:1.1.0'
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
testImplementation 'junit:junit:4.12'
androidTestImplementation 'androidx.test.ext:junit:1.1.1'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'

implementation 'org.aspectj:aspectjrt:1.8.13'
}


import org.aspectj.bridge.IMessage
import org.aspectj.bridge.MessageHandler
import org.aspectj.tools.ajc.Main

final def log = project.logger
final def variants = project.android.applicationVariants

variants.all { variant ->

if (!variant.buildType.isDebuggable()) {
log.debug("Skipping non-debuggable build type '${variant.buildType.name}'.")
return;
}

JavaCompile javaCompile = variant.javaCompile

javaCompile.doLast {

String[] args = ["-showWeaveInfo",

"-1.8",

"-inpath", javaCompile.destinationDir.toString(),

"-aspectpath", javaCompile.classpath.asPath,

"-d", javaCompile.destinationDir.toString(),

"-classpath", javaCompile.classpath.asPath,

"-bootclasspath", project.android.bootClasspath.join(File.pathSeparator)]

log.debug "ajc args: " + Arrays.toString(args)

MessageHandler handler = new MessageHandler(true);

new Main().run(args, handler);

for (IMessage message : handler.getMessages(null, true)) {

switch (message.getKind()) {

case IMessage.ABORT:

case IMessage.ERROR:

case IMessage.FAIL:

log.error message.message, message.thrown

break;

case IMessage.WARNING:

log.warn message.message, message.thrown

break;

case IMessage.INFO:

log.info message.message, message.thrown

break;

case IMessage.DEBUG:

log.debug message.message, message.thrown

break;
}
}
}
}

项目 gradle中



buildscript {
repositories {
google()
jcenter()

}
dependencies {
classpath 'com.android.tools.build:gradle:3.5.1'

classpath 'org.aspectj:aspectjtools:1.8.9'
classpath 'org.aspectj:aspectjweaver:1.8.9'

// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
}
}

allprojects {
repositories {
google()
jcenter()

}
}

task clean(type: Delete) {
delete rootProject.buildDir
}

3、总结


其实核心就是,用aspect去劫持注解,然后让一个公共的Activity来处理这个事情,然后回调,再反射其它方法执行。
等有时间了给他打成一个包。


作者:KentWang
来源:juejin.cn/post/7245836790039445562
收起阅读 »

什么情况下Activity会被杀掉呢?

首先一个报错来作为开篇: Caused by androidx.fragment.app.Fragment$InstantiationException Unable to instantiate fragment xxx: could not find Fr...
继续阅读 »

首先一个报错来作为开篇:


Caused by androidx.fragment.app.Fragment$InstantiationException
Unable to instantiate fragment xxx: could not find Fragment constructor

这个报错原因就是Fragment如果重载了有参的构造方法,没有实现默认无参构造方法。Activity被回收又回来尝试重新恢复Fragment的时候报错的。


那如何模拟Activity被回收呢?

可能有人知道,一个方便快捷的方法就是:打开 开发者选项 - 不保留活动,这样每次Activity回到后台都会被回收,也就可以很方便的测试这种case。


但抛开这种方式我怎么来复现这种情况呢?

这里我提出一种方式:我是不是可以打开我的App,按Home回到后台,然后疯狂的打开手机里其他的大型应用或者游戏这类的能占用大量手机内存的App,等手机内存占用大的时候是不是可以复现这种情况呢?


结论是不可以,不要混淆两个概念,系统内存不足App内存不足,两者能引起的后果也是不同的



  • 系统内存不足 -> 杀掉应用进程

  • App内存不足 -> 杀掉后台Activity


首先明确一点,Android框架对进程创建与管理进行了封装,对于APP开发者只需知道Android四大组件的使用。当Activity, Service, ContentProvider, BroadcastReceiver任一组件启动时,当其所承载的进程存在则直接使用,不存在则由框架代码自动调用startProcessLocked创建进程。所以说对APP来说进程几乎是透明的,但了解进程对于深刻理解Android系统是至关关键的。


1. 系统内存不够 -> 杀掉应用进程


1.1. LKM简介

Android底层还是基于Linux,在Linux中低内存是会有oom killer去杀掉一些进程去释放内存,而Android中的lowmemorykiller就是在此基础上做了一些调整来的。因为手机上的内存毕竟比较有限,而Android中APP在不使用之后并不是马上被杀掉,虽然上层ActivityManagerService中也有很多关于进程的调度以及杀进程的手段,但是毕竟还需要考虑手机剩余内存的实际情况,lowmemorykiller的作用就是当内存比较紧张的时候去及时杀掉一些ActivityManagerService还没来得及杀掉但是对用户来说不那么重要的进程,回收一些内存,保证手机的正常运行。


lowmemkiller中会涉及到几个重要的概念:

/sys/module/lowmemorykiller/parameters/minfree:里面是以”,”分割的一组数,每个数字代表一个内存级别

/sys/module/lowmemorykiller/parameters/adj: 对应上面的一组数,每个数组代表一个进程优先级级别


比如:

/sys/module/lowmemorykiller/parameters/minfree:18432, 23040, 27648, 32256, 55296, 80640

/sys/module/lowmemorykiller/parameters/adj: 0, 100, 200, 300, 900, 906


代表的意思是两组数一一对应:



  • 当手机内存低于80640时,就去杀掉优先级906以及以上级别的进程

  • 当内存低于55296时,就去杀掉优先级900以及以上的进程


可能每个手机的配置是不一样的,可以查看一下手头的手机,需要root。


1.2. 如何查看ADJ

如何查看进程的ADJ呢?比如我们想看QQ的adj


-> adb shell ps | grep "qq" 
UID PID PPID C STIME TTY TIME CMD
u0_a140 9456 959 2 10:03:07 ? 00:00:22 com.tencent.mobileqq
u0_a140 9987 959 1 10:03:13 ? 00:00:07 com.tencent.mobileqq:mini3
u0_a140 16347 959 0 01:32:48 ? 00:01:12 com.tencent.mobileqq:MSF
u0_a140 21475 959 0 19:47:33 ? 00:01:25 com.tencent.mobileqq:qzone

# 看到QQ的PID为 9456,这个时候打开QQ,让QQ来到前台
-> adb shell cat /proc/9456/oom_score_adj
0

# 随便打开一个其他的App
-> adb shell cat /proc/9456/oom_score_adj
700

# 再随便打开另外一个其他的App
-> adb shell cat /proc/9456/oom_score_adj
900

我们可以看到adj是在根据用户的行为不断变化的,前台的时候是0,到后台是700,回到后台后再打开其他App后是900

常见ADJ级别如下:


ADJ级别取值含义
NATIVE_ADJ-1000native进程
SYSTEM_ADJ-900仅指system_server进程
PERSISTENT_PROC_ADJ-800系统persistent进程
PERSISTENT_SERVICE_ADJ-700关联着系统或persistent进程
FOREGROUND_APP_ADJ0前台进程
VISIBLE_APP_ADJ100可见进程
PERCEPTIBLE_APP_ADJ200可感知进程,比如后台音乐播放
BACKUP_APP_ADJ300备份进程
HEAVY_WEIGHT_APP_ADJ400重量级进程
SERVICE_ADJ500服务进程
HOME_APP_ADJ600Home进程
PREVIOUS_APP_ADJ700上一个进程
SERVICE_B_ADJ800B List中的Service
CACHED_APP_MIN_ADJ900不可见进程的adj最小值
CACHED_APP_MAX_ADJ906不可见进程的adj最大值

So,当系统内存不足的时候会kill掉整个进程,皮之不存毛将焉附,Activity也就不在了,当然也不是开头说的那个case。


2. App内存不足 -> 杀掉后台Activity


上面分析了是直接kill掉进程的情况,一旦出现进程被kill掉,说明内存情况已经到了万劫不复的情况了,抛开内存泄漏的情况下,framework也需要一些策略来避免无内存可用的情况。下面我们来找一找fw里面回收Activity的逻辑(代码Base Android-30)。



Android Studio查看源码无法查看com.android.internal包名下的代码,双击Shift,勾选右上角Include non-prject Items.



入口定位到ActivityThreadattach方法,ActivityThread是App的入口程序,main方法中创建并调用atttach


// ActivityThread.java
private void attach(boolean system, long startSeq) {
...
// Watch for getting close to heap limit.
BinderInternal.addGcWatcher(new Runnable() {
@Override public void run() {
// mSomeActivitiesChanged在生命周期变化的时候会修改为true
if (!mSomeActivitiesChanged) {
return;
}
Runtime runtime = Runtime.getRuntime();
long dalvikMax = runtime.maxMemory();
long dalvikUsed = runtime.totalMemory() - runtime.freeMemory();
if (dalvikUsed > ((3*dalvikMax)/4)) {
mSomeActivitiesChanged = false;
try {
ActivityTaskManager.getService().releaseSomeActivities(mAppThread);
} catch (RemoteException e) {
throw e.rethrowFromSystemServer();
}
}
}
});
...
}

这里关注BinderInternal.addGcWatcher, 下面有几个点需要理清:



  1. addGcWatcher是干嘛的,这个Runnable什么时候会被执行。

  2. 这里的maxMemory() / totalMemory() / freeMemory()都怎么理解,值有什么意义

  3. releaseSomeActivities()做了什么事情,回收Activity的逻辑是什么。


还有一个小的点是这里还用了mSomeActivitiesChanged这个标记位来标记让检测工作不会过于频繁的执行,检测到需要releaseSomeActivities后会有一个mSomeActivitiesChanged = false;赋值。而所有的mSomeActivitiesChanged = true操作都在handleStartActivity/handleResumeActivity...等等这些操作Activity声明周期的地方。控制了只有Activity声明周期变化了之后才会继续去检测是否需要回收。


2.1. GcWatcher

BinderInternal.addGcWatcher是个静态方法,相关代码如下:


public class BinderInternal {
private static final String TAG = "BinderInternal";
static WeakReference<GcWatcher> sGcWatcher = new WeakReference<GcWatcher>(new GcWatcher());
static ArrayList<Runnable> sGcWatchers = new ArrayList<>();
static Runnable[] sTmpWatchers = new Runnable[1];

static final class GcWatcher {
@Override
protected void finalize() throws Throwable {
handleGc();
sLastGcTime = SystemClock.uptimeMillis();
synchronized (sGcWatchers) {
sTmpWatchers = sGcWatchers.toArray(sTmpWatchers);
}
for (int i=0; i<sTmpWatchers.length; i++) {
if (sTmpWatchers[i] != null) {
sTmpWatchers[i].run();
}
}
sGcWatcher = new WeakReference<GcWatcher>(new GcWatcher());
}
}

public static void addGcWatcher(Runnable watcher) {
synchronized (sGcWatchers) {
sGcWatchers.add(watcher);
}
}
...
}

两个重要的角色:sGcWatcherssGcWatcher



  • sGcWatchers保存了调用BinderInternal.addGcWatcher后需要执行的Runnable(也就是检测是否需要kill Activity的Runnable)。

  • sGcWatcher是个装了new GcWatcher()的弱引用。


弱引用的规则是如果一个对象只有一个弱引用来引用它,那GC的时候就会回收这个对象。那很明显new出来的这个GcWatcher()只会有sGcWatcher这一个弱引用来引用它,所以每次GC都会回收这个GcWatcher对象,而回收的时候会调用这个对象的finalize()方法,finalize()方法中会将之前注册的Runnable来执行掉。
注意哈,这里并没有移除sGcWatcher中的Runnable,也就是一开始通过addGcWatcher(Runnable watcher)进来的runnable一直都在,不管执行多少次run的都是它。


为什么整个系统中addGcWatcher只有一个调用的地方,但是sGcWatchers确实一个List呢?我在自己写了这么一段代码并且想着怎么能反射搞到系统当前的BinderInternal一探究竟的时候明白了一点点,我觉着他们就是怕有人主动调用了addGcWatcher给弄了好多个GcWatcher导致系统的失效了才搞了个List吧。。


2.2. App可用的内存

上面的Runnable是如何检测当前的系统内存不足的呢?通过以下的代码


        Runtime runtime = Runtime.getRuntime();
long dalvikMax = runtime.maxMemory();
long dalvikUsed = runtime.totalMemory() - runtime.freeMemory();
if (dalvikUsed > ((3*dalvikMax)/4)) { ... }

看变量名字就知道,在使用的内存到达总内存的3/4的时候去做一些事情,这几个方法的注释如下:


    /**
* Returns the amount of free memory in the Java Virtual Machine.
* Calling the gc method may result in increasing the value returned by freeMemory.
* @return an approximation to the total amount of memory currently available for future allocated objects, measured in bytes.
*/

public native long freeMemory();

/**
* Returns the total amount of memory in the Java virtual machine.
* The value returned by this method may vary over time, depending on the host environment.
* @return the total amount of memory currently available for current and future objects, measured in bytes.
*/

public native long totalMemory();

/**
* Returns the maximum amount of memory that the Java virtual machine will attempt to use.
* If there is no inherent limit then the value java.lang.Long#MAX_VALUE will be returned.
* @return the maximum amount of memory that the virtual machine will attempt to use, measured in bytes
*/

public native long maxMemory();

首先确认每个App到底有多少内存可以用,这些Runtime的值都是谁来控制的呢?


可以使用adb shell getprop | grep "dalvik.vm.heap"命令来查看手机给每个虚拟机进程所分配的堆配置信息:


yocn@yocn ~ % adb shell getprop | grep "dalvik.vm.heap"
[dalvik.vm.heapgrowthlimit]: [256m]
[dalvik.vm.heapmaxfree]: [8m]
[dalvik.vm.heapminfree]: [512k]
[dalvik.vm.heapsize]: [512m]
[dalvik.vm.heapstartsize]: [8m]
[dalvik.vm.heaptargetutilization]: [0.75]

这些值分别是什么意思呢?



  • [dalvik.vm.heapgrowthlimit]和[dalvik.vm.heapsize]都是当前应用进程可分配内存的最大限制,一般heapgrowthlimit < heapsize,如果在Manifest中的application标签中声明android:largeHeap=“true”,APP直到heapsize才OOM,否则达到heapgrowthlimit就OOM

  • [dalvik.vm.heapstartsize] Java堆的起始大小,指定了Davlik虚拟机在启动的时候向系统申请的物理内存的大小,后面再根据需要逐渐向系统申请更多的物理内存,直到达到MAX

  • [dalvik.vm.heapminfree] 堆最小空闲值,GC后

  • [dalvik.vm.heapmaxfree] 堆最大空闲值

  • [dalvik.vm.heaptargetutilization] 堆目标利用率


比较难理解的就是heapminfree、heapmaxfree和heaptargetutilization了,按照上面的方法来说:
在满足 heapminfree < freeMemory() < heapmaxfree的情况下使得(totalMemory() - freeMemory()) / totalMemory()接近heaptargetutilization


所以一开始的代码就是当前使用的内存到达分配的内存的3/4的时候会调用releaseSomeActivities去kill掉某些Activity.


2.3. releaseSomeActivities

releaseSomeActivities在API 29前后差别很大,我们来分别看一下。


2.3.1. 基于API 28的版本的releaseSomeActivities实现如下:

// step①:ActivityManagerService.java
@Override
public void releaseSomeActivities(IApplicationThread appInt) {
synchronized(this) {
final long origId = Binder.clearCallingIdentity();
try {
ProcessRecord app = getRecordForAppLocked(appInt);
mStackSupervisor.releaseSomeActivitiesLocked(app, "low-mem");
} finally {
Binder.restoreCallingIdentity(origId);
}
}
}

// step②:ActivityStackSupervisor.java
void releaseSomeActivitiesLocked(ProcessRecord app, String reason) {
TaskRecord firstTask = null;
ArraySet<TaskRecord> tasks = null;
for (int i = 0; i < app.activities.size(); i++) {
ActivityRecord r = app.activities.get(i);
// 如果当前有正在销毁状态的Activity,Do Nothing
if (r.finishing || r.state == DESTROYING || r.state == DESTROYED) {
return;
}
// 只有Activity在可以销毁状态的时候才继续往下走
if (r.visible || !r.stopped || !r.haveState || r.state == RESUMED || r.state == PAUSING
|| r.state == PAUSED || r.state == STOPPING) {
continue;
}
if (r.task != null) {
if (firstTask == null) {
firstTask = r.task;
} else if (firstTask != r.task) {
// 2.1 只有存在两个以上的Task的时候才会到这里
if (tasks == null) {
tasks = new ArraySet<>();
tasks.add(firstTask);
}
tasks.add(r.task);
}
}
}
// 2.2 只有存在两个以上的Task的时候才不为空
if (tasks == null) {
if (DEBUG_RELEASE) Slog.d(TAG_RELEASE, "Didn't find two or more tasks to release");
return;
}
// If we have activities in multiple tasks that are in a position to be destroyed,
// let's iterate through the tasks and release the oldest one.
// 2.3 遍历找到ActivityStack释放最旧的那个
final int numDisplays = mActivityDisplays.size();
for (int displayNdx = 0; displayNdx < numDisplays; ++displayNdx) {
final ArrayList<ActivityStack> stacks = mActivityDisplays.valueAt(displayNdx).mStacks;
// Step through all stacks starting from behind, to hit the oldest things first.
// 从后面开始遍历,从最旧的开始匹配
for (int stackNdx = 0; stackNdx < stacks.size(); stackNdx++) {
final ActivityStack stack = stacks.get(stackNdx);
// Try to release activities in this stack; if we manage to, we are done.
// 尝试在这个stack里面销毁这些Activities,如果成功就返回。
if (stack.releaseSomeActivitiesLocked(app, tasks, reason) > 0) {
return;
}
}
}
}

上面代码都加了注释,我们来理一理重点需要关注的点。整个流程可以观察tasks的走向



  • 2.1 & 2.2: 第一次循环会给firstTask赋值,当firstTask != r.task的时候才会给tasks赋值,后续会继续对tasks操作。所以单栈的应用不会回收,如果tasks为null,就直接return了,什么都不做

  • 2.3: 这一大段的双重for循环其实都没有第一步遍历出来的tasks参与,真正释放Activity的操作在ActivityStack中,所以尝试找到这些tasks对应的ActivityStack,让ActivityStack去销毁tasks,直到成功销毁。


继续查看releaseSomeActivitiesLocked:


// step③ ActivityStack.java
final int releaseSomeActivitiesLocked(ProcessRecord app, ArraySet<TaskRecord> tasks, String reason) {
// Iterate over tasks starting at the back (oldest) first.
int maxTasks = tasks.size() / 4;
if (maxTasks < 1) {
maxTasks = 1;
}
// 3.1 maxTasks至少为1,至少清理一个
int numReleased = 0;
for (int taskNdx = 0; taskNdx < mTaskHistory.size() && maxTasks > 0; taskNdx++) {
final TaskRecord task = mTaskHistory.get(taskNdx);
if (!tasks.contains(task)) {
continue;
}
int curNum = 0;
final ArrayList<ActivityRecord> activities = task.mActivities;
for (int actNdx = 0; actNdx < activities.size(); actNdx++) {
final ActivityRecord activity = activities.get(actNdx);
if (activity.app == app && activity.isDestroyable()) {
destroyActivityLocked(activity, true, reason);
if (activities.get(actNdx) != activity) {
// Was removed from list, back up so we don't miss the next one.
// 3.2 destroyActivityLocked后续会调用TaskRecord.removeActivity(),所以这里需要将index--
actNdx--;
}
curNum++;
}
}
if (curNum > 0) {
numReleased += curNum;
// 移除一个,继续循环需要判断 maxTasks > 0
maxTasks--;
if (mTaskHistory.get(taskNdx) != task) {
// The entire task got removed, back up so we don't miss the next one.
// 3.3 如果整个task都被移除了,这里同样需要将获取Task的index--。移除操作在上面3.1的destroyActivityLocked,移除Activity过程中,如果task为空了,会将task移除
taskNdx--;
}
}
}
return numReleased;
}


  • 3.1: ActivityStack利用maxTasks 保证,最多清理tasks.size() / 4,最少清理1个TaskRecord,同时,至少要保证保留一个前台可见TaskRecord,比如如果有两个TaskRecord,则清理先前的一个,保留前台显示的这个,如果三个,则还要看看最老的是否被有效清理,也就是是否有Activity被清理,如果有则只清理一个,保留两个,如果没有,则继续清理次老的,保留一个前台展示的,如果有四个,类似,如果有5个,则至少两个清理。一般APP中,很少有超过两个TaskRecord的。

  • 3.2: 这里清理的逻辑很清楚,for循环,如果定位到了期望的activity就清理掉,但这里这个actNdx--是为什么呢?注释说activity从list中移除了,为了能继续往下走,需要index--,但在这个方法中并没有将activity从lsit中移除的操作,那肯定是在destroyActivityLocked方法中。继续追进去可以一直追到TaskRecord.java#removeActivity(),从当前的TaskRecord的mActivities中移除了,所以需要index--。

  • 3.3: 我们弄懂了上面的actNdx--之后也就知道这里为什么要index--了,在ActivityStack.java#removeActivityFromHistoryLocked()中有


	if (lastActivity) {
removeTask(task, reason, REMOVE_TASK_MODE_DESTROYING);
}

如果task中没有activity了,需要将这个task移除掉。


以上就是基于API 28的releaseSomeActivities分析。


2.3.2. 基于29+的版本的releaseSomeActivities实现如下:

// ActivityTaskManagerService.java
@Override
public void releaseSomeActivities(IApplicationThread appInt) {
synchronized (mGlobalLock) {
final long origId = Binder.clearCallingIdentity();
try {
final WindowProcessController app = getProcessController(appInt);
app.releaseSomeActivities("low-mem");
} finally {
Binder.restoreCallingIdentity(origId);
}
}
}

// WindowProcessController.java
void releaseSomeActivities(String reason) {
// Examine all activities currently running in the process. Candidate activities that can be destroyed.
// 检查进程里所有的activity,看哪些可以被关掉
ArrayList<ActivityRecord> candidates = null;
if (DEBUG_RELEASE) Slog.d(TAG_RELEASE, "Trying to release some activities in " + this);
for (int i = 0; i < mActivities.size(); i++) {
final ActivityRecord r = mActivities.get(i);
// First, if we find an activity that is in the process of being destroyed,
// then we just aren't going to do anything for now; we want things to settle
// down before we try to prune more activities.
// 首先,如果我们发现一个activity正在执行关闭中,在关掉这个activity之前什么都不做
if (r.finishing || r.isState(DESTROYING, DESTROYED)) {
if (DEBUG_RELEASE) Slog.d(TAG_RELEASE, "Abort release; already destroying: " + r);
return;
}
// Don't consider any activities that are currently not in a state where they can be destroyed.
// 如果当前activity不在可关闭的state的时候,不做处理
if (r.mVisibleRequested || !r.stopped || !r.hasSavedState() || !r.isDestroyable()
|| r.isState(STARTED, RESUMED, PAUSING, PAUSED, STOPPING)) {
if (DEBUG_RELEASE) Slog.d(TAG_RELEASE, "Not releasing in-use activity: " + r);
continue;
}

if (r.getParent() != null) {
if (candidates == null) {
candidates = new ArrayList<>();
}
candidates.add(r);
}
}

if (candidates != null) {
// Sort based on z-order in hierarchy.
candidates.sort(WindowContainer::compareTo);
// Release some older activities
int maxRelease = Math.max(candidates.size(), 1);
do {
final ActivityRecord r = candidates.remove(0);
r.destroyImmediately(true /*removeFromApp*/, reason);
--maxRelease;
} while (maxRelease > 0);
}
}

新版本的releaseSomeActivities放到了ActivityTaskManagerService.java这个类中,这个类是API 29新添加的,承载部分AMS的工作。
相比API 28基于Task栈的回收Activity策略,新版本策略简单清晰, 也激进了很多。


遍历所有Activity,刨掉那些不在可销毁状态的Activity,按照Activity堆叠的顺序,也就是Z轴的顺序,从老到新销毁activity。


有兴趣的读者可以自行编写测试代码,分别在API 28和API 28+的手机上测试看一下回收策略是否跟上面分析的一致。

也可以参考我写的TestKillActivity,单栈和多栈的情况下在高于API 28和低于API 28的手机上的表现。


总结:



  1. 系统内存不足时LMK会根据内存配置项来kill掉进程释放内存

  2. kill时会按照进程的ADJ规则来kill

  3. App内存不足时由GcWatcher来决定回收Activity的时机

  4. 可以使用getprop命令来查看当前手机的JVM内存分配和OOM配置

  5. releaseSomeActivities在API 28和API 28+的差别很大,低版本会根据Task数量来决定清理哪个task的。高版本简单粗暴,遍历activity,按照z order排序,优先release掉更老的activity。


参考资料:
Android lowmemorykiller分析
解读Android进程优先级ADJ算法
http://www.jianshu.com/p/3233c33f6…
juejin.cn/post/706306…
Android可见APP的不可见任务栈(TaskRecord)销毁分析


作者:Yocn
来源:juejin.cn/post/7231742100844871736
收起阅读 »

好坑啊,调用了同事写的基础代码,bug藏得还挺深!!

起因 事情的起因是我调用了同事的一个函数,这个函数返回了一个map[string]string结构体的变量optionMap(请忽略为什么要返回map结构体,后面有机会再讲),这个函数主要是查DB取获取当前系统的space_id和pkey,返回的内容基本上如下...
继续阅读 »

起因


事情的起因是我调用了同事的一个函数,这个函数返回了一个map[string]string结构体的变量optionMap(请忽略为什么要返回map结构体,后面有机会再讲),这个函数主要是查DB取获取当前系统的space_id和pkey,返回的内容基本上如下


 // 返回
optionMap = map[string]string{
"space_id":"xxx",
"pkey": "xxxx",
}

然后我修改了这个变量,添加了



optionMap["is_base"] = 1

然后我就return出我当前的函数了,然后在同一个请求内,但是当我再一次请求同事的函数时,返回给我的却是


 optionMap = map[string]string{
"space_id":"xxx",
"pkey": "xxxx",
"is_base": 1,
}

what! 怎么后面再次请求同事的的函数总是会多一个参数呢!!!!


经过


看了同事写的函数我才发现,原来他在内部使用了gin上下文去做了一个缓存,大概的代码意思减少重复space基础信息的查询,存入上下文中做缓存,提高代码效率,这里我写了一个示例大家可以看下


// 同事的代码
func BadReturnMap(ctx *gin.Context, key string) map[string]interface{} {
m := make(map[string]interface{})
// 查询缓存
value, ok := ctx.Get(key)
if ok {
bm, ok := value.(map[string]interface{})
if ok {
return bm
}
}
// io查询后存入变量
m["a"] = 1
// 保存缓存
fmt.Println("set cache: ")
fmt.Println(m) // map[a:1]
ctx.Set(key, m)
return m
}

// 我的使用
func TestBadReturnMap() {
fmt.Println("bad return map start")
ctx := &gin.Context{}
key := "cached:map_key"
mapOpt := BadReturnMap(ctx, key)
fmt.Printf("%p\n", mapOpt) // 0xc0003a6750 指向地址
mapOpt["b"] = 1
value, ok := ctx.Get(key)
fmt.Printf("%p\n", mapOpt) // 0xc0003a6750 指向地址
if ok {
fmt.Println("get cache: ")
fmt.Println(value.(map[string]interface{})) // map[a:1 def:1]
} else {
fmt.Println("unknown")
}
fmt.Println("bad return map end")
}

打印结果是


bad return map start
set cache:
map[a:1]
0xc0003a6750
0xc0003a6750
get cache:
map[a:1 b:1]
bad return map end

解释


在Go语言中,map是引用类型,当将一个map赋值给另一个变量时,实际上是将它们指向同一个底层的map对象。因此,当你修改其中一个变量的map时,另一个变量也会受到影响。


当你将函数内m变量赋值给外部函数内的变量时,它们实际上指向同一个map对象。所以当你在外部函数内修改mapOpt的值时,原始的缓存也会被修改。


如图所示


image.png


如何修改


当然修改方式有很多种,我这里列举了一种就是序列化存储到缓存然后反序列化取,如果你有更好的方式可下方留言



func ReturnMap(ctx *gin.Context, key string) map[string]interface{} {
m := make(map[string]interface{})
value, ok := ctx.Get(key)
if ok {
bytes := value.([]byte)
err := json.Unmarshal(bytes, &m)
if err != nil {
panic(err)
}
return m
}
// io查询后存入变量
m["a"] = 1
jsonBytes, err := json.Marshal(m)
if err != nil {
panic(err)
}
fmt.Println("set cache: ")
fmt.Println(m)
ctx.Set(key, jsonBytes)

return m
}

func TestReturnMap() {
fmt.Println("return map start")
ctx := &gin.Context{}
key := "cached:map_key"
mapOpt := ReturnMap(ctx, key)
fmt.Printf("%p\n", mapOpt)

mapOpt["b"] = 1
fmt.Printf("%p\n", mapOpt)

value, ok := ctx.Get(key)
if ok {
m := make(map[string]interface{})
bytes := value.([]byte)
err := json.Unmarshal(bytes, &m)
if err != nil {
panic(err)
}
fmt.Println("get cache: ")
fmt.Println(m)
} else {
fmt.Println("unknown")
}
fmt.Println("return map end")
}


打印的结果为:


return map start
set cache:
map[a:1]
0xc0003a6870
0xc0003a6870
get cache:
map[a:1]
return map end

知识点


作者:沙蒿同学
来源:juejin.cn/post/7330869056411058239
收起阅读 »

百度输入法在候选词区域植入广告,网友:真nb!

web
V2EX 用户发帖称,百度输入法最新版本在候选词区域植入了广告。具体表现为,如果用户要打 “招商银行” 四个字,当输入 “招商” 之后,候选词的首位是 “★热门加盟店排行” 的链接,点击后会进入名为「加盟星榜单」的广告页面。https://www.v2ex.c...
继续阅读 »

V2EX 用户发帖称,百度输入法最新版本在候选词区域植入了广告。

具体表现为,如果用户要打 “招商银行” 四个字,当输入 “招商” 之后,候选词的首位是 “★热门加盟店排行” 的链接,点击后会进入名为「加盟星榜单」的广告页面。

https://www.v2ex.com/t/1011440

别的不说,想出这个功能的产品经理真是个人才,因此评论区有用户感叹道:


不说用户体验怎么样,不得不说这个键盘的候选词广告想法确实超前,不光超前,还实现了。
根据输入内容,直接用候选词的方式推送广告,从源头出发拿到用户的一手数据,直接甩掉了各种中间商。速度也更快,更精确的投送。
可以说是真 nb 呀


知名科技博主阑夕对此评论道:“你都打出招商两个字了,一定是想加盟店铺做生意吧?逻辑极其通顺智能,对不对?这真的是人类能够企及的创新吗,太牛逼了。


作者:架构师大咖
来源:mp.weixin.qq.com/s/0KR2F_a9q2_9JSS8nXtodQ
收起阅读 »

从uni-app中去掉编译后微信小程序的滚动条

web
首先如果你使用的是页面级滚动,即使uni-app中的pages.json中有相关配置,在编译到小程序中也是没有效果的,因为小程序原生不支持,如下: 那么我们去看微信的官方回复: 所以得出一个结论,要想隐藏滚动条,我们必须使用scroll-view视图组件...
继续阅读 »

首先如果你使用的是页面级滚动,即使uni-app中的pages.json中有相关配置,在编译到小程序中也是没有效果的,因为小程序原生不支持,如下:



那么我们去看微信的官方回复:




所以得出一个结论,要想隐藏滚动条,我们必须使用scroll-view视图组件


那么在uni-app页面滚动是不是scroll-view,答案是的,但是我们没办法在顶层设置,因为官方没有暴露相关api,那么要想去掉滚动条,我们就只能在自己的页面使用scroll-view视图组件,取代全局的滚动视图。


下面上简易代码


<template>
<scroll-view scroll-y="true" :show-scrollbar="false" :enhanced="true" class="main">
<view class="list" v-for="iten in 30">列表{{iten}}</view>
</scroll-view>
</template>


<style lang="scss" scoped>
.main{
height: 100vh;
}
.list{
border: 1xp solid black;
margin: 20rpx auto;
text-align: center;
line-height: 100rpx;
}
</style>

效果图:


初版.gif


如果你的组件不是占满全屏,比如有头部导航


这时候有两种做法:


1.将头部标签放到scroll-view内部,然后固定定位


<template>
<scroll-view scroll-y="true" :show-scrollbar="false" :enhanced="true" class="main">
<view class="nav">导航nav</view>
<view class="list-container">
<view class="list" v-for="iten in 30">列表{{iten}}</view>
</view>
</scroll-view>
</template>

<style lang="scss" scoped>
.main{
height: 100vh;
}
.list-container{
margin-top: 200rpx;
}
.list{
border: 1xp solid black;
margin: 20rpx auto;
text-align: center;
line-height: 100rpx;
}
.nav{
position: fixed;
top: 0;
line-height: 200rpx;
padding-top: 20rpx;
width: 100vw;
text-align: center;
border: 1px solid black;
background-color: #fff;
}
</style>

效果图:


230187154229138168229133168229177143.gif


2.将scroll-view的高度设置为视口余下高度


这里注意一下在移动端尽量较少的使用cale()计算高度


所以这里我们使用flex布局


<template>
<view class="content">
<view class="nav">导航nav</view>
<scroll-view scroll-y="true" :show-scrollbar="false" :enhanced="true" class="main">
<view class="list" v-for="iten in 30">列表{{iten}}</view>
</scroll-view>
</view>
</template>

<style lang="scss" scoped>
.content{
height: 100vh;
width: 100vw;
overflow: hidden;
display: flex;
flex-direction: column;
}
.main{
flex-grow: 1;
}
.list{
border: 1xp solid black;
margin: 20rpx auto;
text-align: center;
line-height: 100rpx;
}
.nav{
height: 200rpx;
line-height: 200rpx;
width: 100vw;
text-align: center;
border: 1px solid black;
background-color: #fff;
}
</style>

效果图:


230187154229138168229133168229177143.gif


如果有帮助到你的话,记得点个赞哦!


猫咪.gif


作者:aways
来源:juejin.cn/post/7330655456883654667
收起阅读 »

解锁 JSON.stringify() 7 个鲜为人知的坑

web
在本文中,我们将探讨与JSON.stringify()相关的各种坑。 1. 处理undefined、Function和Symbol值 在前端中 undefined、Function和Symbol值不是有效的JSON值。在转换过程中遇到它们时,它们会被省略(在对...
继续阅读 »

在本文中,我们将探讨与JSON.stringify()相关的各种坑。


1. 处理undefined、Function和Symbol值


在前端中 undefinedFunctionSymbol值不是有效的JSON值。在转换过程中遇到它们时,它们会被省略(在对象中),或者被更改为null(在数组中)。


例如:

const obj = { foo: function() {}, bar: undefined, baz: Symbol('example') };  
const jsonString = JSON.stringify(obj);
console.log(jsonString); // 输出: '{}'

const obj2 = {arr: [function(){}]};
console.log(JSON.stringify(obj2)); // 输出: {"arr":[null]}

2. 布尔、数字和字符串对象


布尔、数字和字符串对象在字符串化过程中会被转换为它们对应的原始值。

const boolObj = new Boolean(true);  
const jsonString = JSON.stringify(boolObj);
console.log(jsonString); // 输出: 'true'

3. 忽略Symbol键的属性


Symbol键属性在字符串化过程中完全被忽略,即使使用替换函数也是如此。这意味着与Symbol键关联的任何数据都将在生成的JSON字符串中被排除。

const obj = { [Symbol('example')]: 'value' };  
const jsonString = JSON.stringify(obj);
console.log(jsonString); // 输出: '{}'

const obj2 = {[Symbol('example')]: [function(){}]};
console.log(JSON.stringify(obj2)); // 输出 '{}'

4. 处理无穷大(Infinity)、NaN和Null值


Infinity、NaN 和 null 值在字符串化过程中都被视为 null。

const obj = { value: Infinity, error: NaN, nothing: null };  
const jsonString = JSON.stringify(obj);
console.log(jsonString); // 输出: '{"value":null,"error":null,"nothing":null}'

5. Date对象被视为字符串


Date实例通过实现toJSON()函数来返回一个字符串(与date.toISOString()相同),因此在字符串化过程中被视为字符串。

const dateObj = new Date();
const jsonString = JSON.stringify(dateObj);
console.log(jsonString); // 输出:"2024-01-31T09:42:00.179Z"

6. 循环引用异常


如果 JSON.stringify() 遇到具有循环引用的对象,它会抛出一个错误。循环引用发生在一个对象在循环中引用自身的情况下。

const circularObj = { self: null };
circularObj.self = circularObj;
JSON.stringify(circularObj); // Uncaught TypeError: Converting circular structure to JSON

7. BigInt转换错误


使用JSON.stringify()转换BigInt类型的值时引发错误。

const bigIntValue = BigInt(42);  
JSON.stringify(bigIntValue); // Uncaught TypeError: Do not know how to serialize a BigInt

各位同学如果在开发中还遇到过不一样的坑,还请评论区补充互相讨论


作者:StriveToY
来源:juejin.cn/post/7330289404731047936
收起阅读 »

大公司如何做 APP:背后的开发流程和技术

我记得五六年前,当我在 Android 开发领域尚处初出茅庐阶段之时,我曾有一个执念——想看下大公司在研发一款产品的流程和技术上跟小公司有什么区别。公司之大,对开发来说,不在于员工规模,而在于产品的用户量级。只有用户量级够大,研发过程中的小问题才会被放大。当用...
继续阅读 »

我记得五六年前,当我在 Android 开发领域尚处初出茅庐阶段之时,我曾有一个执念——想看下大公司在研发一款产品的流程和技术上跟小公司有什么区别。公司之大,对开发来说,不在于员工规模,而在于产品的用户量级。只有用户量级够大,研发过程中的小问题才会被放大。当用户量级够大,公司才愿意在技术上投入更多的人力资源。因此,在大公司里做技术,对个人的眼界、技术细节和深度的提升都有帮助。


我记得之前我曾跟同事调侃说,有一天我离职了,我可以说我毕业了,因为我这几年学到了很多。现在我想借这个机会总结下这些年在公司里经历的让我印象深刻的技术。


1、研发流程


首先在产品的研发流程上,我把过去公司的研发模式分成两种。


第一种是按需求排期的。在评审阶段一次性评审很多需求,和开发沟通后可能删掉优先级较低的需求,剩下的需求先开发,再测试,最后上线。上线的时间根据开发和测试最终完成的时间确定。


第二种是双周迭代模式,属于敏捷开发的一种。这种开发机制里,两周一个版本,时间是固定的。开发、测试和产品不断往时间周期里插入需求。如下图,第一周和第三周的时间是存在重叠的。具体每个阶段留多少时间,可以根据自身的情况决定。如果需求比较大,则可以跨迭代,但发布的时间窗口基本是固定的。


截屏2023-12-30 13.00.33.png


有意思的是,第二种开发机制一直是我之前的一家公司里负责人羡慕的“跑火车”模式。深度参与过两种开发模式之后,我说下我的看法。


首先,第一种开发模式适合排期时间比较长的需求。但是这种方式时间利用率相对较低。比如,在测试阶段,开发一般是没什么事情做的(有的会在这个时间阶段布置支线需求)。这种开发流程也有其好处,即沟通和协调成本相对较低。


注意!在这里,我们比较时间利用率的时候是默认两种模式的每日工作时间是相等的且在法律允许范围内。毕竟,不论哪一种研发流程,强制加班之后,时间利用率都“高”(至少老板这么觉得)。


第二种开发方式的好处:



  1. 响应速度快。可以快速发现问题并修复,适合快速试错。

  2. 时间利用率高。相比于按需求排期的方式,不存在开发和测试的间隙期。


但这种开发方式也有缺点:



  1. 员工压力大,容易造成人员流失。开发和测试时间穿插,开发需要保证开发的质量,否则容易影响整个迭代内开发的进度。

  2. 沟通成本高。排期阶段出现人力冲突需要协调。开发过程中出现问题也需要及时、有效的沟通。因此,在这种开发模式里还有一个角色叫项目经理,负责在中间协调,而第一种开发模式里项目经理的存在感很低。

  3. 这种开发模式中,产品要不断想需求,很容易导致开发的需求本身价值并不大。


做了这么多年开发,让人很难拒绝一个事实是,绝大多数互联网公司的壁垒既不是技术,也不是产品,而是“快速迭代,快速试错”。从这个角度讲,双周迭代开发机制更适应互联网公司的要求。就像我们调侃公司是给电脑配个人,这种开发模式里就是给“研发流水线”配个人,从产品、到开发、到测试,所有人都像是流水线上的一员。


2、一个需求的闭环


以上是需求的研发流程。如果把一个需求从产品提出、到上线、到线上数据回收……整个生命周期列出来,将如下图所示,


需求闭环.drawio.png


这里我整合了几个公司的研发过程。我用颜色分成了几个大的流程。相信每个公司的研发流程里或多或少都会包含其中的几个。在这个闭环里,我说一下我印象比较深刻的几个。


2.1 产品流程


大公司做产品一个显著的特点是数据驱动,一切都拿数据说话。一个需求的提出只是一个假设,开发上线之后效果评估依赖于数据。数据来源主要有埋点上报和舆情监控。


1. 数据埋点


埋点数据不仅用于产品需求的验证,也用于推荐算法的训练。因此,大公司对数据埋点的重视可以说是深入骨髓的。埋点数据也经常被纳入到绩效考核里。


开发埋点大致要经过如下流程,



  • 1). 产品提出需要埋的点。埋点的类型主要包括曝光和点击等,此外还附带一些上报的参数,统计的维度包括用户 uv 和次数 pv.

  • 2). 数据设计埋点。数据拿到产品要埋的点之后,设计埋点,并在埋点平台录入。

  • 3). 端上开发埋点。端上包括移动客户端和 Web,当然埋点框架也要支持 RN 和 H5.

  • 4). 端上验证埋点。端上埋点完成之后需要测试,上报埋点,然后再在平台做埋点校验。

  • 5). 产品提取埋点数据。

  • 6). 异常埋点数据修复。


由此可见,埋点及其校验对开发来说也是需要花费精力的一环。它不仅需要多个角色参与,还需要一个大数据平台,一个录入、校验和数据提取平台,以及端上的上报框架,可以说成本并不低。


2. 舆情监控


老实说,初次接触舆情监控的时候,它还是给了我一点小震撼的。没想到大公司已经把舆情监控做到了软件身上。


舆情监控就是对网络上关于该 APP 的舆情的监控,数据来源不仅包括应用内、外用户提交的反馈,还包括主流社交平台上关于该软件的消息。所有数据在整合到舆情平台之后会经过大数据分析和分类,然后进行监控。舆情监控工具可以做到对产品的负面信息预警,帮助产品经理优化产品,是产品研发流程中重要的一环。


3. AB 实验


很多同学可能对 AB 实验都不陌生。AB 实验就相当于同时提出多套方案,然后左右手博弈,从中择优录用。AB 实验的一个槽点是,它使得你代码中同时存在多份作用相同的代码,像狗皮膏药一样,也不能删除,非常别扭,最后导致的结果是代码堆积如山。


4. 路由体系建设


路由即组件化开发中的页面路由。但是在有些应用里,会通过动态下发路由协议支持运营场景。这在偏运营的应用里比较常见,比如页面的推荐流。一个推荐流里下发的模块可能打开不同的页面,此时,只需要为每个页面配置一个路由路径,然后推荐流里根据需要下发即可。所以,路由体系也需要 Android 和 iOS 双端统一,同时还要兼容 H5 和 RN.


mdn-url-all.png


在路由协议的定义上,我们可以参考 URL 的格式,定义自己的协议、域名、路径以及参数。以 Android 端为例,可以在一个方法里根据路由的协议、域名对原生、RN 和 H5 等进行统一分发。


2.2 开发流程


在开发侧的流程里,我印象深的有以下几个。


1. 重视技术方案和文档


我记得之前在一家公司里只文档平台就换了几个,足见对文档的重视。产品侧当然更重文档,而对研发侧,文档主要有如下几类:1). 周会文档;2).流程和规范;3).技术方案;4).复盘资料等。


对技术方案,现在即便我自己做技术也保留了写大需求技术方案先行的习惯。提前写技术方案有几个好处:



  • 1). 便于事后回忆:当我们对代码模糊的时候,可以通过技术方案快速回忆。

  • 2). 便于风险预知:技术方案也有助于提前预知开发过程中的风险点。前面我们说敏捷开发提前发现风险很重要,而做技术方案就可以做到这点。

  • 3). 便于全面思考:技术方案能帮助我们更全面地思考技术问题。一上来就写代码很容易陷入“只见树木,不见森林”的困境。


2. Mock 开发


Mock 开发也就是基于 Mock 的数据进行开发和测试。在这里它不局限于个人层面(很多人可能有自己 Mock 数据开发的习惯),而是在公司层面将其作为一种开发模式,以实现前后端分离。典型的场景是客户端先上线预埋,而后端开发可能滞后一段时间。为了支持 Mock 开发模式,公司需要专门的平台,提供以接口为维度的 Mock 工具。当客户端切换到 Mock 模式之后,上传到网络请求在后端的网关直接走 Mock 服务器,拉取 Mock 数据而不是真实数据。


这种开发模式显然也是为了适应敏捷开发模式而提出的。它可以避免前后端依赖,减轻人力资源协调的压力。这种开发方式也有其缺点:



  • 1). 数据结构定义之后无法修改。客户端上线之后后端就无法再修改数据结构。因此,即便后端不开发,也需要先投入人力进行方案设计,定义数据结构,并拉客户端进行评审。

  • 2). 缺少真实数据的验证。在传统的开发模式中,测试要经过测试和 UAT 两个环境,而 UAT 本身已经比较接近线上环境,而使用 Mock 开发就完全做不到这么严谨。当我们使用 Mock 数据测试时,如果我们自己的 Mock 的数据本身失真比较严重,那么在意识上你也不会在意数据的合理性,因此容易忽视一些潜在的问题。


3. 灰度和热修复


灰度的机制是,在用户群体中选择部分用户进行应用更新提示的推送。这要求应用本身支持自动更新,同时需要对推送的达到率、用户的更新率进行统计。需要前后端一套机制配合。灰度有助于提前发现应用中存在的问题,这对超大型应用非常有帮助,毕竟,现在上架之后发现问题再修复的成本非常高。


但如果上架之后确实出现了问题就需要走热修复流程。热修复的难点在于热修复包的下发,同时还需要审核流程,因此需要搭建一个平台。这里涉及的细节比较多,后面有时间再梳理吧。


4. 配置下发


配置下发就是通过平台录入配置,推送,然后在客户端读取配置信息。这也是应用非常灵活的一个功能,可以用来下发比如固定的图片、文案等。我之前做个人开发的时候也在服务器上做了配置下发的功能,主要用来绕过某些应用商店的审核,但是在数据结构的抽象上做得比较随意。这里梳理下配置下发的细节。



  • 首先,下发的配置是区分平台特征的。这包括,应用的目标版本(一个范围)、目标平台(Android、iOS、Web、H5 或者 RN)。

  • 其次,为了适应组件化开发,也为了更好地分组管理,下发的配置命名时采用 模块#配置名称 的形式。

  • 最后,下发的数据结构支持,整型、布尔类型、浮点数、字符串和 Json.


我自己在做配置下发的时候还遇到一个比较棘手的问题——多语言适配。国内公司的产品一般只支持中文,这方面就省事得多。


5. 复盘文化


对于敏捷开发,复盘是不可或缺的一环。有助于及时发现问题,纠正和解决问题。复盘的时间可以是定期的,在一个大需求上线之后,或者出现线上问题之后。


3、技术特点


3.1 组件化开发的痛点


在大型应用开发过程中,组件化开发的意义不仅局限于代码结构层面。组件化的作用体现在以下几个层面:



  • 1). 团队配合的利器。想想几十个人往同一份代码仓库里提交代码的场景。组件化可以避免无意义的代码冲突。

  • 2). 提高编译效率。对于大型应用,全源码编译一次的时间可能要几十分钟。将组件打包成 aar 之后可以减少需要编译的代码的数量,提升编译效率。

  • 3). 适应组织架构。将代码细分为各个组件,每个小团队只维护自己的组件,更方便代码权限划分。


那么,在实际开发过程中组件化开发会存在哪些问题呢?


1. 组件拆分不合理


这在从单体开发过渡到组件化开发的应用比较常见,即组件化拆分之后仍然存在某些模块彼此共用,导致提交代码的时候仍然会出现冲突问题。冲突包含两个层面的含义,一是代码文件的 Git 冲突,二是在打包合入过程中发布的 aar 版本冲突。比较常见的是,a 同学合入了代码到主干之后,b 同学没有合并主干到自己的分支就打包,导致发布的 aar 没有包含最新的代码。这涉及打包的问题,是另一个痛点问题,后面再总结。


单就拆分问题来看,避免上述冲突的一个解决办法是在拆分组件过程中尽可能解耦。根据我之前的观察,存在冲突的组件主要是数据结构和 SPI 接口。这是我之前公司没做好的地方——数据结构仓库和 SPI 接口是共用的。对于它们的组件化拆分,我待过的另一家公司做得更好。他们是如下拆分的,这里以 A 和 B 来命名两个业务模块。那么,在拆分的时候做如下处理,


模块:A-api
模块:A
模块:B-api
模块:B

即每个业务模块拆分成 api 和实现两部分。api 模块里包含需要共享的数据结构和 SPI 接口,实现模块里是接口的具体实现。当模块 A 需要和模块 B 进行交互的时候,只需要依赖 B 的 api 模块。可以参考开源项目:arch-android.


2. 打包合入的痛点


上面我们提到了一种冲突的情况。在我之前的公司里,每个组件有明确的负责人,在每个迭代开发的时候,组件负责人负责拉最新 release 分支。其他同学在该分支的开发需要经过负责人同意再合入到该分支。那么在最终打包的过程中,只需要保证这个分支的 aar 包含了全部最新的代码即可。也就是说,这种打包方式只关心每个 aar 的版本,而不关心实际的代码。因为它最终打包是基于 aar 而不是全源码编译。


这种打包方式存在最新的分支代码没有被打包的风险。一种可行的规避方法是,在平台通过 Git tag 和 commit 判断该分支是否已经包含最新代码。此外,还可能存在某个模块修改了 SPI 接口,而另一个模块没有更新,导致运行时异常的风险。


另一个公司是基于全源码编译的。不过,全源码编译只在最终打包阶段或者某个固定的时间点进行,而不是每次合入都全源码编译(一次耗时太久)。同时,虽然每个模块有明确的负责人,但是打包的 aar 不是基于当前 release 分支,而是自己的开发分支。这是为了保障当前 release 分支始终是可用的。合并代码到 release 分支的同时需要更新 aar 的版本。但它也存在问题,如果合并到 release 而没有打包 aar,那么可能导致 release 分支无法使用。如果打包了 aar 但是此时其他同学也打包了 aar,则可能导致本次打包的 aar 落后,需要重新打包。因此,这种合入方式也是苦不堪言。


有一种方法可以避免上述问题,即将打包和合入事件设计成一个消息队列。每次合入之前自动化执行上述操作,那么自然就可以保证每次操作的原子性(因为本身就是单线程的)。


对比两种打包和合入流程,显然第二种方式更靠谱。不过,它需要设计一个流程。这需要花费一点功夫。


3. 自动化切源码


我在之前的一家公司开发时,在开发过程中需要引用另一个模块的修改时,需要对另一个模块打 SNAPSHOT 包。这可行,但有些麻烦。之前我也尝试过手动修改 settings.gradle 文件进行源码依赖开发。不过,太麻烦了。


后来在另一个公司里看到一个方案,即动态切换到源码开发。可以将某个依赖替换为源码而只需要修改脚本即可。这个实践很棒,我已经把它应用到独立开发中。之前已经梳理过《组件化开发必备:Gradle 依赖切换源码的实践》.


3.2 大前端化开发


1. React Native


如今的就业环境,哪个 Android 开发不是同时会五六门手艺。跨平台开发几乎是不可避免的。


之前的公司为什么选择 React Native 而不是 Flutter 等新锐跨平台技术呢?我当时还刻意问了这个问题。主要原因:



  • 1). 首先是 React Native 相对更加成熟,毕竟我看了下 Github 第一个版本发布已经是 9 年前的事情了,并且至今依旧非常活跃。

  • 2). React Native 最近更新了 JavaScript 引擎,页面启动时间、包大小和内存占用性能都有显著提升。参考这篇文章《干货 | 加载速度提升15%,携程对RN新一代JS引擎Hermes的调研》.

  • 3). 从团队人才配置上,对 React Native 熟悉的更多。


React Native 开发是另一个领域的东西,不在本文讨论范围内。每个公司选择 React Native 可能有它的目的。比如,我之前的一家公司存粹是为了提效,即一次开发双端运行。而另一家公司,则是为了兼顾提效和动态化。如果只为提效,那么本地编译和打包 js bundle 就可以满足需求。若要追求动态化,就需要搭建一个 RN 包下发平台。实际上,在这个公司开发 RN 的整个流程,除了编码环节,从代码 clone 到最终发布都是在平台上执行的。平台搭建涉及的细节比较多,以后用到再总结。对于端侧,RN 的动态化依赖本地路由以及 RN 容器。


2. BFF + DSL


DSL 是一种 UI 动态下发的方案。相比于 React Native,DSL 下发的维度更细,是控件级别的(而 RN 是页面级别的)。简单的理解是,客户端和后端约定 UI 格式,然后按照预定的格式下发的数据。客户端获取到数据之后渲染。DSL 不适合需要复杂动画的场景。若确实要复杂动画,则需要自定义控件。


工作流程如下图中左侧部分所示,右侧部分是每个角色的责任。


DSL workflow.drawio.png


客户端将当前页面和位置信息传给 DSL 服务器。服务器根据上传的信息和位置信息找到业务接口,调用业务接口拉取数据。获取到数据后根据开发过程中配置的脚本对数据进行处理。数据处理完成之后再交给 DSL 服务器渲染。渲染完成之后将数据下发给客户端。客户端再根据下发的 UI 信息进行渲染。其中接口数据的处理是通过 BFF 实现的,由客户端通过编写 Groovy 脚本实现数据结构的转换。


这种工作流程中,大部分逻辑在客户端这边,需要预埋点位信息。预埋之后可以根据需求进行下发。这种开发的一个痛点在于调试成本高。因为 DSL 服务器是一个黑盒调用。中间需要配置的信息过多,搭建 UI 和编写脚本的平台分散,出现问题不易排查。


总结


所谓他山之石,可以攻玉。在这篇文章中,我只是选取了几个自己印象深刻的技术点,零零碎碎地写了很多,比较散。对于有这方面需求的人,会有借鉴意义。


作者:开发者如是说
来源:juejin.cn/post/7326268908984434697
收起阅读 »

功能问题:如何限制同一账号只能在一处登录?

大家好,我是大澈! 本文约1200+字,整篇阅读大约需要2分钟。 感谢关注微信公众号:“程序员大澈”,免费领取"面试礼包"一份,然后免费加入问答群,从此让解决问题的你不再孤单! 1. 需求分析 前阵子,和问答群里一个前端朋友,随便唠了唠。期间他问了我一个问题,...
继续阅读 »

大家好,我是大澈!


本文约1200+字,整篇阅读大约需要2分钟。


感谢关注微信公众号:“程序员大澈”,免费领取"面试礼包"一份,然后免费加入问答群,从此让解决问题的你不再孤单!


1. 需求分析


前阵子,和问答群里一个前端朋友,随便唠了唠。期间他问了我一个问题,让我印象深刻。


他问的是,限制同一账号只能在一处设备上登录,是如何实现的?并且,他还把这个功能称为“单点登录”。


我说这不叫“单点登录”,这是“单设备登录”。


于是,当时对此概念区分不清的他,和我在语言上开始了深度纠缠。


所以在后面我就想,这个功能问题有必要整理一下,分享给现在还不清楚两者概念的朋友们。


图片



2. 功能实现


先聊聊“单点登录”和“单设备登录”区别,再说说实现“单设备登录”的步骤。


2.1 单点登录和单设备登录的区别


“单点登录”和“单设备登录”是两个完全不同的概念。


单设备登录指:在某个给定的时间,同一用户只能在一台设备上进行登录,如果在其他设备上尝试登录,先前的会话将被中断或注销。


单点登录(简称SSO)指:允许用户使用一组凭据(如用户名和密码)登录到一个系统,然后可以在多个相关系统中,无需重新登录即可访问受保护的资源。


关于“单点登录”的实现,这里简单说一下。一般有两种方式:若后端处理,部署一个认证中心,这是标准做法;若前端处理,可以用LocalStorage做跨域缓存。


2.2 单设备登录的实现


要实现单设备登录,一般来说,有两种方式:使用数据库记录登录状态 和 使用令牌验证机制 。


使用令牌验证机制 的实现步骤如下:


• 用户登录时生成token,将账号作为key,token作为value,并设置过期时间存入redis中。


• 当用户访问应用时,在拦截器中解析token,获取账号,然后用账号去redis中获取相应的value。


• 如果获取到的value的token与当前用户携带的token一致,则允许访问;如果不一致,则提示前端重复登录,让前端清除token,并跳转到登录页面。


• 当用户在另一台设备登录时,其token也会存入redis中,这样就刷新了token的值和redis的过期时间。


图片


使用数据库记录登录状态 的实现步骤如下:


• 在用户登录时,记录用户的账号信息、登录设备的唯一标识符(如设备ID或IP地址)以及登录时间等信息到数据库中的一个登录表。


• 每次用户的登录请求都会查询数据库中的登录表,检查是否存在该用户的登录记录。如果存在记录,则比对登录设备的标识符和当前设备的标识符是否相同。


• 如果当前设备与登录设备不匹配,拒绝登录并提示用户在其他设备上已登录。若匹配,则更新登录时间。


• 当用户主动退出登录或超过一定时间没有操作时,清除该用户的登录记录。




作者:程序员大澈
来源:juejin.cn/post/7320166206215340072
收起阅读 »

Android架构设计 搞懂应用架构设计原则,不要再生搬硬套的使用MVVM、MVI

首先,谷歌官方似乎并没有把自己建议的应用架构命名为 MVVM 或 MVI, MVVM 和 MVI是开发者根据不同时期官方应用架构指南的特点,达成的一个统一称谓。 对于学习这两个种架构,我们需要自己去理解官方应用架构指南,否则只能生搬硬套的使用他人理解的 MVV...
继续阅读 »

首先,谷歌官方似乎并没有把自己建议的应用架构命名为 MVVM 或 MVI, MVVM 和 MVI是开发者根据不同时期官方应用架构指南的特点,达成的一个统一称谓。


对于学习这两个种架构,我们需要自己去理解官方应用架构指南,否则只能生搬硬套的使用他人理解的 MVVM 和 MVI。



首先看看现在最新的官方应用架构指南,是如何建议我们搭建应用架构的。


一,官方应用架构指南


1,架构的原则


应用架构定义了应用的各个部分之间的界限以及每个部分应承担的职责。谷歌建议按照以下原则设计应用架构。


1,分离关注点


2,通过数据模型驱动界面


3,单一数据源


4,单向数据流


2,谷歌推荐的应用架构


每个应用应至少有两个层:



  • 界面层 - 在屏幕上显示应用数据。

  • 数据层 - 包含应用的业务逻辑并公开应用数据。


可以额外添加一个名为“网域层”的架构层,以简化和重复使用界面层与数据层之间的交互。


总结下来,我们的应用架构应该有三层:界面层、网域层、数据层。


其中网域层可选,即无论你的应用中有没有网域层,与你的应用架构是 MVVM 还是 MVI 无关。


image.png


个人的理解是:界面层、网域层、数据层应该是应用级别的,而不是页面级别的。


如果我认为分层是页面级别的,那么我在接到一个业务需求 A 时(一般一个业务会新建一个页面activity 或 fragment 来承接),我的实现思路是:



  • 新建页面 A_Activity

  • 新建状态容器 A_ViewModel

  • 新建数据层 A_Repository


这样的话,数据层 A_Repository 和 界面层:界面元素 A_Activity + 状态容器 A_ViewModel 强相关,数据层无复用性可言。


如果我认为分层是应用级别的,那么在接到业务A 时,实现思路是:



  • 新建页面 A_Activity

  • 新建状态容器 A_ViewModel

  • 首先从应用的数据层寻找业务 A 需要使用的 业务数据 是否有对应的存储仓库 Respository,如果有,则复用(A_ViewModel 中依赖 Repository),拿到业务数据后,转成 UI 数据;如果无,则创建 这种业务数据 的存储仓库 Repository,后续其他界面层如果页使用到这种业务数据,可以直接复用这种业务数据对应的 Repository。


2.1,界面层架构设计指导


界面层在架构中的作用


界面的作用是在屏幕上显示应用数据,并充当主要的用户互动点。


从数据层获取是业务数据,有时候需要界面层将业务数据转换成 UI 数据供界面元素显示。


界面层的组成


界面层由以下两部分组成:



  • 界面元素:在屏幕上呈现数据的界面元素可以使用 View 或 Jetpack Compose 函数实现。

  • 状态容器:用于存储数据、向界面提供数据以及处理逻辑的状态容器(如 ViewModel 类)。


image.png


界面层的架构设计遵循的原则


这里以一个常见的列表页面为案例进行讲解,这个列表页面有以下交互:



  • 打开页面时,网络数据回来之前展示一个加载中 view。

  • 首次打卡页面,如果没有数据或者网络请求发生错误,展示一个错误 view。

  • 具备下拉刷新能力,刷新后,如果有数据,则替换列表数据;如果无返回数据,则弹出一个 Toast。


接着我们用这个业务,按照以下原则进行分析:


1, 定义界面状态


界面元素 加上 界面状态 才是用户看到的界面。


image.png


上面说的列表页面,根据它的业务需求,需要有以下界面状态



  • 展示加载中 view 的界面状态

  • 展示加载错误 view 的界面状态

  • 列表数据 view 界面状态

  • Toast view 界面状态

  • 刷新完成 view 界面状态


无论采用 MVVM 还是 MVI,都需要这些界面状态,只是他们的实现细节不同,具体可以看下面的讲解。


2,定义状态容器


状态容器:就是存放我们定义的界面状态,并且包含执行相应任务所必需的逻辑的类。


ViewModel 类型是推荐的状态容器,用于管理屏幕级界面状态,具有数据层访问权限。但并不是只能用 ViewModel作为状态容器。


无论采用 MVVM 还是 MVI,都需要定义状态容器,来存放界面状态。


3,使用单向数据流管理状态


看看官方在界面层的架构指导图:


image.png


界面状态数据流动是单向的,只能从 状态容器 到 界面元素。


界面发生的事件 events(如刷新、加载更多等事件)流动是单向的,只能从 界面元素 到 状态容器。


无论采用 MVVM 还是 MVI,都需要使用单向数据流管理状态。


4,唯一数据源


唯一数据源针对的是:定义的界面状态 和 界面发生的事件。


界面状态唯一数据源指的是将定义的多个界面状态,封装在一个类中,如上面的列表业务,不采用唯一数据源,界面状态的声明为:


/**
* 加载失败 UI 状态,显示失败图
* 首屏获取的数据为空、首屏请求数据失败时展示失败图
* 初始值:隐藏
*/

val loadingError: StateFlow<Boolean>
get() = _loadingError
private val _loadingError = MutableStateFlow<Boolean>(false)

/**
* 正在加载 UI 状态,显示加载中图
* 首屏时请求网络时展示加载中图
* 初始值:展示
*/

val isLoading: StateFlow<Boolean>
get() = _isLoading
private val _isLoading = MutableStateFlow<Boolean>(true)

/**
* 加载成功后回来的列表 UI 状态,将 list 数据展示到列表上
*/

val newsList: StateFlow<MutableList<News>>
get() = _newsList
private val _newsList = MutableStateFlow<MutableList<News>>(mutableListOf())

/**
* 加载完成 UI 状态
*/

val loadingFinish: StateFlow<Boolean>
get() = _loadingFinish
private val _loadingFinish = MutableStateFlow<Boolean>(false)

/**
* 界面 toast UI 状态
*/

val toastMessage: StateFlow<String>
get() = _toastMessage
private val _toastMessage = MutableStateFlow<String>("")

采用唯一数据源声明界面状态时,代码如下:


sealed interface NewsUiState  {

object IsLoading: NewsUiState

object LoadingError: NewsUiState

object LoadingFinish: NewsUiState

data class Success(val newsList: MutableList<News>): NewsUiState

data class ToastMessage(val message: String = ""): NewsUiState

}


val newsUiState: StateFlow<NewsUiState>
get() = _newsUiState

private val _newsUiState: MutableStateFlow<NewsUiState> =
MutableStateFlow(NewsUiState.IsLoading)


界面发生的事件的唯一数据源指的是将界面发生的事件封装在一个类中,然后统一处理。比如上面描述的列表业务,它的界面事件有 初始化列表事件(首屏请求网络数据)、刷新事件、加载更多事件。


不采用唯一数据源,界面事件的调用实现逻辑为:在 activity 中直接调用 viewModel 提供的 initData、freshData 和 loadMoreData 方法;


采用唯一数据源,界面事件的调用实现逻辑为,先将事件中封装在一个 Intent 中,viewModel 中提供一个统一的事件入口处理方法 dispatchIntent,在 activity 中 各个场景下都调用 viewModel#dispatchIntent,代码如下:


sealed interface NewsActivityIntent {
data class InitDataIntent(val type: String = "init") : NewsActivityIntent

data class RefreshDataIntent(val type: String = "refresh") : NewsActivityIntent

data class LoadMoreDataIntent(val type: String = "loadMore") : NewsActivityIntent
}

fun dispatchIntent(intent: NewsActivityIntent) {
when (intent) {
is NewsActivityIntent.InitDataIntent -> {
//初始化逻辑
initNewsData()
}
is NewsActivityIntent.RefreshDataIntent -> {
//刷新逻辑
refreshNewsData()
}
is NewsActivityIntent.LoadMoreDataIntent -> {
//加载更多逻辑
loadMoreNewsData()
}
}
}


因为有了唯一数据源这一特点,才将最新的应用架构称为 MVI,MVVM 不具备这一特点。


5,向界面公开界面状态的方式


在状态容器中定义界面状态后,下一步思考的是如何将提供的状态发送给界面。


谷歌推荐使用 LiveData 或 StateFlow 等可观察数据容器中公开界面状态。这样做的优点有:



  • 解耦界面元素(activity 或 fragment) 与 状态容器,如:activity 持有 viewModel 的引用,viewModel 不需要持有 activity 的引用。


无论采用 MVVM 还是 MVI,都需要向界面公开界面状态,公开的方式也可以是一样的。


6,使用界面状态


在界面中使用界面状态时,对于 LiveData,可以使用 observe() 方法;对于 Kotlin 数据流,您可以使用 collect() 方法或其变体。


注意:在界面中使用可观察数据容器时,需要考虑界面的生命周期。因为当未向用户显示视图时,界面不应观察界面状态。使用 LiveData 时,LifecycleOwner 会隐式处理生命周期问题。使用数据流时,最好通过适当的协程作用域和 repeatOnLifecycle API,如:


class NewsActivity : AppCompatActivity() {

    private val viewModel: NewsViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        ...

        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.uiState.collect {
                    // Update UI elements
                }
            }
        }
    }
}

无论采用 MVVM 还是 MVI,都需要使用界面状态,使用的方式都是一样的。


2.2,数据层架构设计指导


数据层在架构中的作用


数据层包含应用数据和业务逻辑。业务逻辑决定应用的价值,它由现实世界的业务规则组成,这些规则决定着应用数据的创建、存储和更改方式。


数据层的架构设计


数据层由多个仓库组成,其中每个仓库都可以包含零到多个数据源。您应该为应用中处理的每种不同类型的数据分别创建一个存储库类。例如,您可以为与电影相关的数据创建一个 MoviesRepository 类,或者为与付款相关的数据创建一个 PaymentsRepository 类。


每个数据源类应仅负责处理一个数据源,数据源可以是文件、网络来源或本地数据库。


层次结构中的其他层不能直接访问数据源;数据层的入口点始终是存储库类。


公开 API


数据层中的类通常会公开函数,以执行一次性的创建、读取、更新和删除 (CRUD) 调用,或接收关于数据随时间变化的通知。对于每种情况,数据层都应公开以下内容:



  • 一次性操作:在 Kotlin 中,数据层应公开挂起函数;对于 Java 编程语言,数据层应公开用于提供回调来通知操作结果的函数。

  • 接收关于数据随时间变化的通知:在 Kotlin 中,数据层应公开数据流,对于 Java 编程语言,数据层应公开用于发出新数据的回调。


class ExampleRepository(
    private val exampleRemoteDataSource: ExampleRemoteDataSource, // network
    private val exampleLocalDataSource: ExampleLocalDataSource // database
) {

    val data: Flow<Example> = ...

    suspend fun modifyData(example: Example) { ... }
}

多层存储库


在某些涉及更复杂业务要求的情况下,存储库可能需要依赖于其他存储库。这可能是因为所涉及的数据是来自多个数据源的数据聚合,或者是因为相应职责需要封装在其他存储库类中。


例如,负责处理用户身份验证数据的存储库 UserRepository 可以依赖于其他存储库(例如 LoginRepository 和 RegistrationRepository,以满足其要求。


image.png


注意:传统上,一些开发者将依赖于其他存储库类的存储库类称为 manager,例如称为 UserManager 而非 UserRepository。


数据层生命周期


如果该类的职责作用于应用至关重要,可以将该类的实例的作用域限定为 Application 类。


如果只需要在应用内的特定流程(例如注册流程或登录流程)中重复使用同一实例,则应将该实例的作用域限定为负责相应流程的生命周期的类。例如,可以将包含内存中数据的 RegistrationRepository 的作用域限定为 RegistrationActivity。


数据层定位思考


数据层不应该是页面级别的(一个页面对应一个数据层),而应该是应用级别的(数据层有多个存储仓库,每种数据类型有一个对应的存储仓库,不同的界面层可以复用存储仓库)。


比如我做的应用是运动健康app,用户的睡眠相关的数据有一个 SleepResposity,用户体重相关的数据有一个 WeightReposity,由于应用中很多界面都可能需要展示用户的睡眠数据和体重数据,所以 SleepResposity 和 WeightReposity 可以供不同界面层使用。


二,MVVM


1,MVVM 架构图


image.png


2,MVVM 实现一个具体业务


使用上面提到的列表页面业务,按照 MVVM 架构实现如下:


2.1,界面层的实现


界面层实现时,需要遵循以下几点。


1,选择实现界面的元素


界面元素可以用 view 或 compose 来实现,这里用 view 实现。


2,提供一个状态容器


这里使用 ViewModel 作为状态容器;状态容器用来存放界面状态变量;ViewModel 是官方推荐的状态容器,而不是必须使用它作为状态容器。


3,定义界面状态


这个需求中我们根据业务描述,定义出多个界面状态。


/**
* 加载失败 UI 状态,显示失败图
* 首屏获取的数据为空、首屏请求数据失败时展示失败图
* 初始值:隐藏
*/

val loadingError: StateFlow<Boolean>
get() = _loadingError
private val _loadingError = MutableStateFlow<Boolean>(false)

/**
* 正在加载 UI 状态,显示加载中图
* 首屏时请求网络时展示加载中图
* 初始值:展示
*/

val isLoading: StateFlow<Boolean>
get() = _isLoading
private val _isLoading = MutableStateFlow<Boolean>(true)

/**
* 加载成功后回来的列表 UI 状态,将 list 数据展示到列表上
*/

val newsList: StateFlow<MutableList<News>>
get() = _newsList
private val _newsList = MutableStateFlow<MutableList<News>>(mutableListOf())

/**
* 加载完成 UI 状态
*/

val loadingFinish: StateFlow<Boolean>
get() = _loadingFinish
private val _loadingFinish = MutableStateFlow<Boolean>(false)

/**
* 界面 toast UI 状态
*/

val toastMessage: StateFlow<String>
get() = _toastMessage
private val _toastMessage = MutableStateFlow<String>("")

4,公开界面状态


这里选择数据流 StateFlow 公开界面状态。当然也可以选择 LiveData 公开界面状态。


5,使用/订阅界面状态


我这里使用的是数据流 StateFlow 公开的界面状态,所以在界面层相对应的使用 flow#collect 订阅界面状态。


6,数据模型驱动界面


结合上面几点,界面层的实现代码为:


界面元素的实现:


class NewsActivity: ComponentActivity() {

private var mBinding: ActivityNewsBinding? = null
private var mAdapter: NewsListAdapter? = null
private val mViewModel = NewsViewModel()

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
mBinding = ActivityNewsBinding.inflate(layoutInflater)
setContentView(mBinding?.root)
initView()
initObserver()
initData()
}

private fun initView() {
mBinding?.listView?.layoutManager = LinearLayoutManager(this)
mAdapter = NewsListAdapter()
mBinding?.listView?.adapter = mAdapter

mBinding?.refreshView?.setOnRefreshListener {
mViewModel.refreshNewsData()
}
}

private fun initData() {
mViewModel.getNewsData()
}

private fun initObserver() {
lifecycleScope.launch {
lifecycle.repeatOnLifecycle(Lifecycle.State.CREATED) {
launch {
mViewModel.isLoading.collect {
if (it) {
mBinding?.loadingView?.visibility = View.VISIBLE
} else {
mBinding?.loadingView?.visibility = View.GONE
}
}
}
launch {
mViewModel.loadingError.collect {
if (it) {
mBinding?.loadingError?.visibility = View.VISIBLE
} else {
mBinding?.loadingError?.visibility = View.GONE
}
}
}
launch {
mViewModel.loadingFinish.collect {
if (it) {
mBinding?.refreshView?.isRefreshing = false
}
}
}
launch {
mViewModel.toastMessage.collect {
if (it.isNotEmpty()) {
showToast(it)
}
}
}
launch {
mViewModel.newsList.collect {
if (it.isNotEmpty()) {
mBinding?.loadingError?.visibility = View.GONE
mBinding?.loadingView?.visibility = View.GONE
mBinding?.refreshView?.visibility = View.VISIBLE
mAdapter?.setData(it)
}
}
}
}
}
}

}

状态容器的实现:


class NewsViewModel : ViewModel() {

private val repository = NewsRepository()

/**
* 加载失败 UI 状态,显示失败图
* 首屏获取的数据为空、首屏请求数据失败时展示失败图
* 初始值:隐藏
*/

val loadingError: StateFlow<Boolean>
get() = _loadingError
private val _loadingError = MutableStateFlow<Boolean>(false)

/**
* 正在加载 UI 状态,显示加载中图
* 首屏时请求网络时展示加载中图
* 初始值:展示
*/

val isLoading: StateFlow<Boolean>
get() = _isLoading
private val _isLoading = MutableStateFlow<Boolean>(true)

/**
* 加载成功后回来的列表 UI 状态,将 list 数据展示到列表上
*/

val newsList: StateFlow<MutableList<News>>
get() = _newsList
private val _newsList = MutableStateFlow<MutableList<News>>(mutableListOf())

/**
* 加载完成 UI 状态
*/

val loadingFinish: StateFlow<Boolean>
get() = _loadingFinish
private val _loadingFinish = MutableStateFlow<Boolean>(false)

/**
* 界面 toast UI 状态
*/

val toastMessage: StateFlow<String>
get() = _toastMessage
private val _toastMessage = MutableStateFlow<String>("")

fun getNewsData() {
viewModelScope.launch(Dispatchers.IO) {
val list = repository.getNewsList()
if (list.isNullOrEmpty()) {
_loadingError.emit(true)
} else {
_newsList.emit(list)
}
}
}

fun refreshNewsData() {
viewModelScope.launch(Dispatchers.IO) {
val list = repository.getNewsList()
_loadingFinish.emit(true)
if (list.isNullOrEmpty()) {
_toastMessage.emit("暂时没有更新数据")
} else {
_newsList.emit(list)
}
}
}
}

2.2,数据层的实现


这里的数据层只有一个新闻列表数据结构的存储仓库 NewsRepository,另外获取新闻信息属于一次性操作,根据数据层架构设计,直接使用 suspend 就好。


class NewsRepository {

suspend fun getNewsList(): MutableList<News>? {
delay(2000)

val list = mutableListOf<News>()
val news = News("标题", "描述信息")
list.add(news)
list.add(news)
list.add(news)
list.add(news)
return list
}
}

个人的一些理解:


1, 数据层不应该是界面级别的,而应该是应用级别的


数据层不应该是界面级别的,即一个页面对应一个 Repository;数据层应该是应用级别的,即一个应用有一个或多个数据层,每个数据层中有多个存储仓库 Respository,存储仓库可以在不同的界面层复用。


之前我一直认为,一个页面对应一个数据层,一个页面对应一个 Repository。但后来发现这种理解不太对。上面的例子中 NewsViewModel 只用到 NewsRepository,是因为这个新闻列表业务中只用到新闻列表数据这种数据,假如列表中还可以点赞 那我们就需要新建一种点赞存储仓库 LikeRepository,来处理点赞数据,这时 NewsViewModel 与 Repository 的关系是这样:


class NewsViewModel : ViewModel() {

private val newsRepository = NewsRepository()
private val likeRepository = LikeRepository()
}


数据层提供的 新闻列表数据处理能力 NewsRepository 和 点赞数据处理能力 LikeRepository,应该是应用界别的,可以供不同的界面复用。


2,数据层应该是“不变的”


这里的不变不是说数据层的业务逻辑不变,而是指无论是 MVP、MVVM 还是 MVI,他们应该可以共用数据层。


2.3,网域层的实现


网域层是可选的,是否具备网域层,跟架构是否为 MVVM 无关,这个案例中不适用网域层。


三,MVI


1,MVI 架构图


image.png


2,MVI 实现一个具体业务


同样使用上面 MVVM 实现的新闻业务。按照 MVI 架构实现如下:


2.1,界面层的实现


除了和 MVVM 遵循以下几点相同原则之外:


1,选择实现界面的元素


2,提供一个状态容器


3,定义界面状态


4,公开界面状态


5,使用/订阅界面状态


6,单向数据流


MVI 还需要遵循原则:


1,单一数据源


所以 MVI 需要:1,把界面状态聚合起来;2,把界面事件聚合起来。


综合上面的原则,采用 MVI 实现界面的实现如下:


界面元素、聚合界面状态、聚合界面事件 代码:


sealed interface NewsUiState  {

object IsLoading: NewsUiState

object LoadingError: NewsUiState

object LoadingFinish: NewsUiState

data class Success(val newsList: MutableList<News>): NewsUiState

data class ToastMessage(val message: String = ""): NewsUiState

}


sealed interface NewsActivityIntent {
data class InitDataIntent(val type: String = "init") : NewsActivityIntent

data class RefreshDataIntent(val type: String = "refresh") : NewsActivityIntent

data class LoadMoreDataIntent(val type: String = "loadMore") : NewsActivityIntent
}

class NewsActivity: ComponentActivity() {

private var mBinding: ActivityNewsBinding? = null
private var mAdapter: NewsListAdapter? = null
private val mViewModel = NewsViewModel()

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
mBinding = ActivityNewsBinding.inflate(layoutInflater)
setContentView(mBinding?.root)
initView()
initObserver()
initData()
}

private fun initView() {
mBinding?.listView?.layoutManager = LinearLayoutManager(this)
mAdapter = NewsListAdapter()
mBinding?.listView?.adapter = mAdapter

mBinding?.refreshView?.setOnRefreshListener {
mViewModel.dispatchIntent(NewsActivityIntent.RefreshDataIntent())
}
}

private fun initData() {
mViewModel.dispatchIntent(NewsActivityIntent.InitDataIntent())
}

private fun loadMoreData() {
mViewModel.dispatchIntent(NewsActivityIntent.LoadMoreDataIntent())
}

private fun initObserver() {
lifecycleScope.launch {
lifecycle.repeatOnLifecycle(Lifecycle.State.CREATED) {
launch {
mViewModel.newsUiState.collect {
//更新UI
}
}
}
}
}

}


状态容器代码:


class NewsViewModel : ViewModel() {

private val repository = NewsRepository()

val newsUiState: StateFlow<NewsUiState>
get() = _newsUiState

private val _newsUiState: MutableStateFlow<NewsUiState> =
MutableStateFlow(NewsUiState.IsLoading)

fun dispatchIntent(intent: NewsActivityIntent) {
when (intent) {
is NewsActivityIntent.InitDataIntent -> {
//初始化逻辑
initNewsData()
}
is NewsActivityIntent.RefreshDataIntent -> {
//刷新逻辑
refreshNewsData()
}
is NewsActivityIntent.LoadMoreDataIntent -> {
//加载更多逻辑
loadMoreNewsData()
}
}
}

/**
* 初始化
*/

private fun initNewsData() {
viewModelScope.launch(Dispatchers.IO) {
val list = repository.getNewsList()
if (list.isNullOrEmpty()) {
_newsUiState.emit(NewsUiState.LoadingError)
} else {
_newsUiState.emit(NewsUiState.Success(list))
}
}
}

/**
* 刷新
*/

private fun refreshNewsData() {
viewModelScope.launch(Dispatchers.IO) {
val list = repository.getNewsList()
_newsUiState.emit(NewsUiState.LoadingFinish)
if (list.isNullOrEmpty()) {
_newsUiState.emit(NewsUiState.ToastMessage("暂时没有新数据"))
} else {
_newsUiState.emit(NewsUiState.Success(list))
}
}
}

/**
* 没有实现
*/

private fun loadMoreNewsData() {

}

}

2.2,数据层与网域层的实现


界面层:参考上面 MVVM 的数据层介绍,无论 MVP、MVVM、MVI,不同应用架构的数据层应该是不变的,即通用。


网域层:应用架构是否具备网域层不影响它是什么类型的架构,这里的列表业务没有网域层。


作者:cola_wang
来源:juejin.cn/post/7278659049191686196
收起阅读 »

Java项目要不要部署在Docker里?

部署Java项目有很多种方式,传统的方式是直接在物理机或虚拟机上部署应用,但为什么现在容器化部署变得越来越流行, 个人觉得原因有以下几个: 1、 环境一致性:使用Docker可以确保开发、测试和生产环境的一致性,避免出现“在我机器上能跑”的问题。 2、 快速部...
继续阅读 »

部署Java项目有很多种方式,传统的方式是直接在物理机或虚拟机上部署应用,但为什么现在容器化部署变得越来越流行,
个人觉得原因有以下几个:


1、 环境一致性:使用Docker可以确保开发、测试和生产环境的一致性,避免出现“在我机器上能跑”的问题。

2、 快速部署:Docker镜像一旦构建完成,可以快速部署到任何支持Docker的宿主机上。

3、 易于扩展:结合编排工具如 Kubernetes,可以轻松管理服务的伸缩和负载均衡。

4、 资源隔离:容器化可以提供更好的资源使用隔离和限制,提高系统的稳定性。

5、 更轻便地微服务化:容器很适合微服务架构,每个服务可以单独打包、部署和扩展。


至于是否要在Docker里部署,这取决于项目和团队的具体需求。


如果你的团队追求快速迭代、想要环境一致性,或者计划实现微服务架构,那么使用Docker是一个很好的选择。


但如果项目比较小,或者团队对容器技术不熟,想使用容器化部署应用,可能会增加学习和维护的成本,那就需要权衡利弊了。


 


如果你决定使用Docker来部署Java项目,大概的步骤是这样的:


1、 编写Dockerfile:这是一个文本文件,包含了从基础镜像获取、复制应用文件、设置环境变量到运行应用的所有命令。

2、 构建镜像:使用docker build命令根据Dockerfile构建成一个可运行的镜像。

3、 运行容器:使用docker run命令从镜像启动一个或多个容器实例。

4、 (可选)使用Docker Compose或Kubernetes等工具部署和管理容器。


部署在Docker里的Java项目,通常都会需要一个精心编写的Dockerfile和一些配置管理,确保应用可以无障碍地在容器中运行。




下面简单演示一个如何使用Docker来部署一个简单的Spring Boot Java项目。


 


首先,我们需要安装Docker,你可以从Docker官网下载合适的版本安装,安装完后可以通过运行docker --version来检查是否安装成功。


Docker 安装步骤在在这里就不详细说明了,可以参考这篇文章:CentOS Docker 安装


项目部署步骤:


步骤1:编写Dockerfile


Dockerfile是一个文本文件,它包含了一系列的指令和参数,用于定义如何构建你的Docker镜像。
以下是一个典型的Dockerfile示例,用于部署一个Spring Boot应用:


# 使用官方提供的Java运行环境作为基础镜像,根据自己的需求,选择合适的JDK版本,这里以 1.8 为例
FROM openjdk:8-jdk-alpine

# 配置环境变量
ENV APP_FILE myapp.jar
ENV APP_HOME /usr/app

# 在容器内创建一个目录作为工作目录
WORKDIR $APP_HOME

# 将构建好的jar包复制到容器内的工作目录下
COPY target/*.jar $APP_FILE

# 暴露容器内部的端口给外部使用
EXPOSE 8080

# 启动Java应用
ENTRYPOINT ["java","-jar","${APP_FILE}"]

注释解释:



  • FROM openjdk:8-jdk-alpine:这告诉Docker使用一个轻量级的Java 8 JDK版本作为基础镜像。

  • ENV:设置环境变量,这里设置了应用的jar包名称和存放路径。

  • WORKDIR:设定工作目录,之后的COPY等命令都会在这个目录下执行。

  • COPY:将本地的jar文件复制到镜像中。

  • EXPOSE:将容器的8080端口暴露出去,以便外部可以访问容器内的应用。

  • ENTRYPOINT:容器启动时执行的命令,这里是运行Java应用的命令。


步骤2:构建镜像


在Dockerfile所在的目录运行下面的命令来构建你的镜像:


docker build -t my-java-app .

这里的-t标记用于给新创建的镜像设置一个名称,.是上下文路径,指向Dockerfile所在的当前目录。


步骤3:运行容器


构建好镜像后,你可以使用下面的命令来运行容器:


docker run -d -p 8080:8080 --name my-running-app my-java-app

这里的-d标记意味着在后台运行容器,-p标记用于将容器的8080端口映射到宿主机的8080端口,--name用于给容器设置名字。


到这里,如果一切顺利,你的Spring Boot应用就会在Docker容器中启动,
并且宿主机的8080端口会转发到容器内部的同一端口上,你可以通过访问http://xxxx:8080来查看应用是否在运行。


步骤4:使用Docker Compose或Kubernetes等工具部署和管理容器


接下来我们来讲讲如何使用Docker Compose来管理和部署容器。
Docker Compose是一个用于定义和运行多容器Docker应用的工具。使用Compose,你可以通过一个YAML文件来配置你的应用的服务,然后只需要一个简单的命令即可创建和启动所有的服务。


就拿上面的例子来说,我们来创建一个docker-compose.yml 文件来运行Spring Boot应用。


先确保你已经安装了Docker Compose,然后创建以下内容的docker-compose.yml文件:


version: '3'
services:
my-java-app:
build: .
ports:
- "8080:8080"
environment:
SPRING_PROFILES_ACTIVE: "prod"
volumes:
- "app-logs:/var/log/my-java-app"

volumes:
app-logs:

注释解释:



  • version:指定了我们使用的Compose文件版本。

  • services:定义了我们需要运行的服务。

    • my-java-app:这是我们服务的名称。

    • build: .:告诉Compose在当前目录下查找Dockerfile来构建镜像。

    • ports:将容器端口映射到主机端口。

    • environment:设置环境变量,这里我们假设应用使用Spring Profiles,定义了prod作为激活的配置文件。

    • volumes:定义了数据卷,这里我们将宿主机的一个卷挂载到容器中,用于存储日志等数据。




创建好docker-compose.yml文件后,只需要运行以下命令即可:


docker-compose up -d

这条命令会根据你的docker-compose.yml文件启动所有定义的服务。 -d 参数表明要在后台运行服务。


如果你需要停止并移除所有服务,可以使用:


docker-compose down

使用Docker Compose的好处是,你可以在一个文件中定义整个应用的服务以及它们之间的依赖,然后一键启动或停止所有服务,非常适合本地开发和测试。


至于Kubernetes,它是一个开源的容器编排系统,用于自动部署、扩展和管理容器化应用。


 


Kubernetes的学习曲线相对陡峭,适合用于更复杂的生产环境。如果你想要进一步了解Kubernetes:


推荐几个 Kubernetes 学习的文章



总结


总的来说,容器化是Java项目部署的一种高效、现代化方式,适合于追求快速迭代和微服务架构的团队。
对于不熟悉容器技术的团队或者个人开发者而言,需要考虑学习和维护的成本,合适自己的才是最好的,也不必追求别人用什么你就用什么,得不偿失。


作者:小郑说编程i
来源:juejin.cn/post/7330102782538055689
收起阅读 »

uni-app开发小程序:项目架构以及经验分享

uni-app开发小程序:项目架构以及经验分享 2022年的时候,公司为了快速完成产品并上线,所以选用微信小程序为载体;由于后期还是打算开发App;虽然公司有ios和Android,但是如果能一套代码打包多端,一定程度上可以解决成本;前端技术栈也是vue,在...
继续阅读 »

uni-app开发小程序:项目架构以及经验分享



2022年的时候,公司为了快速完成产品并上线,所以选用微信小程序为载体;由于后期还是打算开发App;虽然公司有iosAndroid,但是如果能一套代码打包多端,一定程度上可以解决成本;前端技术栈也是vue,在考察选择了uni-app。后来多个小程序项目都采用了uni-app开发,积累了一定的经验以及封装了较多业务组件,这里就分享一下uni-app项目的整体架构、常用方法封装以及注意事项。全文代码都会放到github,先赞后看,年入百万!



创建项目


uni-app提供了两种创建项目的方式:




⚠️需要注意的是,一定要根据项目需求来选择项目的创建方式;如果只是单独的开发小程序App,且开发环境单一,可以使用HBuilderX可视化工具创建。如果多端开发,以及同一套代码可能会打包生成多个小程序建议使用vue-cli进行创建,不然后期想搞自动化构建以及按指定条件进行编译比较痛苦。关于按条件编译,文章后面会有详细说明。



使用vue-cli安装和运行比较简单:


1.全局安装 vue-cli


npm install -g @vue/cli

2.创建uni-app


vue create -p dcloudio/uni-preset-vue 项目名称

3.进入项目文件夹


cd 项目名称

4.运行项目,如果是已微信小程序为主,可以在package.json中的命令改为:


"scripts": {
"serve": "npm run dev:mp-weixin"
}

然后执行


npm run serve

使用cli创建项目默认不带css预编译,需要手动安装一下,这里已sass为例:


npm i sass --save-dev
npm i sass-loader --save-dev

整体项目架构


通过HBuilderX或者vue-cli创建的项目,目录结构有稍许不同,但基本没什么差异,这里就按vue-cli创建的项目为例,整体架构配置如下:


    ├──dist 编译后的文件路径
├──package.json 配置项
├──src 核心内容
├──api 项目接口
├──components 全局公共组件
├──config 项目配置文件
├──pages 主包
├──static 全局静态资源
├──store vuex
├──mixins 全局混入
├──utils 公共方法
├──App.vue 应用配置,配置App全局样式以及监听
├──main.js Vue初始化入口文件
├──manifest.json 配置应用名称、appid等打包信息
├──pages.json 配置页面路由、导航条、选项卡等页面类信息
└──uni.scss 全局样式

封装方法


工欲善其事,必先利其器。在开发之前,我们可以把一些全局通用的方法进行封装,以及把uni-app提供的api进行二次封装,方便使用。全局的公共方法我们都会放到/src/utils文件夹下。


封装常用方法


下面这些方法都放在/src/utils/utils.js中,文章末尾会提供github链接方便查看。如果项目较大,建议把方法根据功能定义不同的js文件。


小程序Toast提示


/**
* 提示方法
* @param {String} title 提示文字
* @param {String} icon icon图片
* @param {Number} duration 提示时间
*/

export function toast(title, icon = 'none', duration = 1500) {
if(title) {
uni.showToast({
title,
icon,
duration
})
}
}

缓存操作(设置/获取/删除/清空)


/**
* 缓存操作
* @param {String} val
*/

export function setStorageSync(key, data) {
uni.setStorageSync(key, data)
}

export function getStorageSync(key) {
return uni.getStorageSync(key)
}

export function removeStorageSync(key) {
return uni.removeStorageSync(key)
}

export function clearStorageSync() {
return uni.clearStorageSync()
}

页面跳转


/**
* 页面跳转
* @param {'navigateTo' | 'redirectTo' | 'reLaunch' | 'switchTab' | 'navigateBack' | number } url 转跳路径
* @param {String} params 跳转时携带的参数
* @param {String} type 转跳方式
**/

export function useRouter(url, params = {}, type = 'navigateTo') {
try {
if (Object.keys(params).length) url = `${url}?data=${encodeURIComponent(JSON.stringify(params))}`
if (type === 'navigateBack') {
uni[type]({ delta: url })
} else {
uni[type]({ url })
}
} catch (error) {
console.error(error)
}
}

图片预览


/**
* 预览图片
* @param {Array} urls 图片链接
*/

export function previewImage(urls, itemList = ['发送给朋友', '保存图片', '收藏']) {
uni.previewImage({
urls,
longPressActions: {
itemList,
fail: function (error) {
console.error(error,'===previewImage')
}
}
})
}

图片下载


/**
* 保存图片到本地
* @param {String} filePath 图片临时路径
**/

export function saveImage(filePath) {
if (!filePath) return false
uni.saveImageToPhotosAlbum({
filePath,
success: (res) => {
toast('图片保存成功', 'success')
},
fail: (err) => {
if (err.errMsg === 'saveImageToPhotosAlbum:fail:auth denied' || err.errMsg === 'saveImageToPhotosAlbum:fail auth deny') {
uni.showModal({
title: '提示',
content: '需要您授权保存相册',
showCancel: false,
success: (modalSuccess) => {
uni.openSetting({
success(settingdata) {
if (settingdata.authSetting['scope.writePhotosAlbum']) {
uni.showModal({
title: '提示',
content: '获取权限成功,再次点击图片即可保存',
showCancel: false
})
} else {
uni.showModal({
title: '提示',
content: '获取权限失败,将无法保存到相册哦~',
showCancel: false
})
}
},
fail(failData) {
console.log('failData', failData)
}
})
}
})
}
}
})
}

更多函数就不在文章中展示了,已经放到/src/utils/utils,js里面,具体可以到github查看。


请求封装


为了减少在页面中的请求代码,所以我们要对uni-app提供的请求方式进行二次封装,在/src/utils文件夹下建立request.js,具体代码如下:



import {toast, clearStorageSync, getStorageSync, useRouter} from './utils'
import {BASE_URL} from '@/config/index'

const baseRequest = async (url, method, data, loading = true) =>{
header.token = getStorageSync('token') || ''
return new Promise((reslove, reject) => {
loading && uni.showLoading({title: 'loading'})
uni.request({
url: BASE_URL + url,
method: method || 'GET',
header: header,
timeout: 10000,
data: data || {},
success: (successData) => {
const res = successData.data
uni.hideLoading()
if(successData.statusCode == 200){
if(res.resultCode == 'PA-G998'){
clearStorageSync()
useRouter('/pages/login/index', 'reLaunch')
}else{
reslove(res.data)
}
}else{
toast('网络连接失败,请稍后重试')
reject(res)
}
},
fail: (msg) => {
uni.hideLoading()
toast('网络连接失败,请稍后重试')
reject(msg)
}
})
})
}

const request = {};

['options', 'get', 'post', 'put', 'head', 'delete', 'trace', 'connect'].forEach((method) => {
request[method] = (api, data, loading) => baseRequest(api, method, data, loading)
})

export default request

请求封装好以后,我们在/src/api文件夹下按业务模块建立对应的api文件,拿获取用户信息接口举例子:


/src/api文件夹下建立user.js,然后引入request.js


import request from '@/utils/request'

//个人信息
export const info = data => request.post('/v1/api/info', data)

在页面中直接使用:


import {info} from '@/api/user.js'

export default {
methods: {
async getUserinfo() {
let info = await info()
console.log('用户信息==', info)
}
}
}

版本切换


很多场景下,需要根据不同的环境去切换不同的请求域名、APPID等字段,这时候就需要通过环境变量来进行区分。下面案例我们就分为三个环境:开发环境(dev)、测试环境(test)、生产环境(prod)。


建立env文件


在项目根目录建立下面三个文件并写入内容(常量名要以VUE开头命名):


.env.dev(开发环境)


VUE_APP_MODE=build
VUE_APP_ID=wxbb53ae105735a06b
VUE_APP_BASE=https://www.baidu.dev.com

.env.test(测试环境)


VUE_APP_MODE=build
VUE_APP_ID=wxbb53ae105735a06c
VUE_APP_BASE=https://www.baidu.test.com

.env.prod(生产环境)


VUE_APP_MODE=wxbb53ae105735a06d
VUE_APP_ID=prod
VUE_APP_BASE=https://www.baidu.prod.com

修改package.json文件


"scripts": {
"dev:mp-weixin": "cross-env UNI_PLATFORM=mp-weixin vue-cli-service uni-build --watch --mode dev",
"build:mp-weixin": "cross-env UNI_PLATFORM=mp-weixin vue-cli-service uni-build --watch --mode prod"
},

然后执行


npm run dev:mp-weixin

/src/pages/index/index.vue下,打印:


onLoad() {
console.log(process.env.VUE_APP_MODE, '====VUE_APP_BASE')
console.log(process.env.VUE_APP_BASE, '====VUE_APP_BASE')
},

此时输出结果就是


dev ====VUE_APP_BASE
https://www.baidu.dev.com ====VUE_APP_BASE

动态修改appid


如果同一套代码,需要打包生成多个小程序,就需要动态修改appid了;文章开头说过appid在/src/manifest.json文件中配置,但json文件又不能直接写变量,这时候就可以参考官方 提出的解决方案:建立vue.config.js文件,具体操作如下。


在根目录下建立vue.config.js文件写入以下内容:


// 读取 manifest.json ,修改后重新写入
const fs = require('fs')

const manifestPath = './src/manifest.json'
let Manifest = fs.readFileSync(manifestPath, { encoding: 'utf-8' })
function replaceManifest(path, value) {
const arr = path.split('.')
const len = arr.length
const lastItem = arr[len - 1]

let i = 0
let ManifestArr = Manifest.split(/\n/)

for (let index = 0; index < ManifestArr.length; index++) {
const item = ManifestArr[index]
if (new RegExp(`"${arr[i]}"`).test(item)) ++i
if (i === len) {
const hasComma = /,/.test(item)
ManifestArr[index] = item.replace(
new RegExp(`"${lastItem}"[\\s\\S]*:[\\s\\S]*`),
`"${lastItem}": ${value}${hasComma ? ',' : ''}`
)
break
}
}

Manifest = ManifestArr.join('\n')
}
// 读取环境变量内容
replaceManifest('mp-weixin.appid', `"${process.env.VUE_APP_ID}"`)

fs.writeFileSync(manifestPath, Manifest, {
flag: 'w'
})

结尾


关于uni-app项目的起步工作就到这里了,后面有机会写一套完整的uni搭建电商小程序项目,记得关注。代码已经提交到github,如果对你有帮助,记得点个star!


作者:陇锦
来源:juejin.cn/post/7259589417736847416
收起阅读 »

😳骚操作玩这么花的吗?Android基于Act实现事件的录制与回放!

基于Activity封装实现录制与回放 前言 在前文中我们通过 ViewGr0up 实现过自己的录制与回放,但是那只是用于复(学)习,并不能真正在实际开发中应用上,或者说能用但是不好用需要大量的修改, 而大厂实现的录制与回放方案有很多种但大多都没有开源。一般在...
继续阅读 »

基于Activity封装实现录制与回放


前言


在前文中我们通过 ViewGr0up 实现过自己的录制与回放,但是那只是用于复(学)习,并不能真正在实际开发中应用上,或者说能用但是不好用需要大量的修改,


而大厂实现的录制与回放方案有很多种但大多都没有开源。一般在大厂会对应用的稳定性进行监控,不管是测试还是线上监控,都离不开用户操作的录制与回放。


一个 App 开发完成上架之后,一般我们会收集用户设备的内存帧率,崩溃信息,ANR信息等,这些都是基操,但是现在平台会提出了更高的要求,录制用户操作与回放用户操作,很多大厂都在进行这方面的探索。


目前业内做的比较好的录制与回放稳定性平台搭建包括不限于美团,爱奇艺,字节,网易,货拉拉等。


不同于测试阶段可以用 PC + ADB 实现录制与操作的思路,在应用内部我们就需要预先埋点用户的事件操作与回放逻辑,并且生成对应的日志信息。


那么实现录制与回放有哪些方法?哪一种更方便呢?本文只是探讨一下基于 Activity 实现的,比较简单的、比较基本的录制与回放功能,方便大家参考。


当然本文只是基于 Demo 性质,只用于本机录制本机回放,如果真要做到兼容多平台多设备,如需要ORC文本识别与图片识别进行定位,屏幕大小适配坐标等其他一系列的深入优化就不在本文的探讨范围。其实只要实现了核心功能,其他都是细枝末节需要时间打磨。


那么话不多说,Let's go


300.png


一、定义事件


在前文 ViewGr0up 的文章中,我们知道了事件的伪造与保存,如何定制伪造事件时间轴,如何分发伪造事件,本文也是一个思路。


整体思路基于前文 ViewGr0up 的例子,还是把事件用对象封装起来,只是我们封装的对象换成了 MotionEvent ,并且不需要修改内部的操作时间了,我们用事件对象的 time 时间来制作伪造事件触发的时间轴。


这样对于事件的录制我们就能直接通过 Activity 的事件分发 dispatchTouchEvent 中直接保存我们的事件对象了。


基于这个思路,我们的事件的对象封装:


public class EventState {
public MotionEvent event; //事件
public long time; //开始录制到该事件发生的时间
}

Activity的事件集合,方便后期扩展为多个Activity的事件队列,如果只需要录制一个 Activity 的事件那么则可以无需双重队列。


/**
* 以Activity为单位,以队列的形式存储MotionEvent
*/

public class ActEventStates {
/**
* 存储元素为一个队列,存放一个Act中的操作状态。如果有多个Act,则是双重队列
*/

public static Queue<Queue<EventState>> eventStates = new LinkedList<>();

public static boolean isRecord = false; //是否在录制

public static boolean isPlay = false; //是否在播放
}

为什么要用 Queue ?


首先我们只需要回放一次,如果想回放多次可以用持久化存储,对于已经回放过的事件我们不希望还存在内存中,特别是后期做多 Activity 之间的跳转之后的回放,如果之前的事件还存在内存中会有重复回放的问题,而用 List 去手动管理没有 Queue 方便。


二、录制


先定义一个开始与停止的方法:


  //开启录制
fun startRecord() {
//如果是录制状态
if (ActEventStates.isRecord) {
ActEventStates.isPlay = false

//初始化队列,对应一个Act是一个队列
activityEvents = LinkedList()
// Act录制事件的开始时间
startTime = System.currentTimeMillis()
//保存到内存中
ActEventStates.eventStates.add(activityEvents)
}
}

//停止录制
fun stopRecord() {
val state = EventState()
state.event = null
state.time = System.currentTimeMillis() - startTime
activityEvents?.add(state)
}

基于Act的录制,直接在分发事件的时候把事件从 Activity 级别就录制进去,这样只要在 Activity 层级之下的操作都能实现录制与回放了:


    override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
//只有在录制状态下才会保存事件并添加到队列中
if (ActEventStates.isRecord && activityEvents != null) {
//不要直接存原始的 MotionEvent,因为用过就回收的,之前我们是通过自定义 Event 来做的,这里简单一点直接重新伪造一次其实更方便
val obtain = MotionEvent.obtain(ev)
//初始化自己的 EventState 用于保存当前事件对象
val state = EventState()
//赋值当前事件,用伪造过的事件
state.event = obtain
//赋值当前事件发生的时间
state.time = System.currentTimeMillis() - startTime
//把每一次事件 EventState 对象添加到队列中
activityEvents?.add(state)
}
return super.dispatchTouchEvent(ev)
}

每一行代码都尽量给出注释。


三、回放


其实和我们之前的 ViewGr0up 的思路是一致的,只是把自定义的事件换成原生的 MotionEvent 来保存,还是根据 Handler 分发不同事件的时间轴。


    //回放录制
fun playRecord() {
//如果是播放状态
if (ActEventStates.isPlay) {
ActEventStates.isRecord = false

//延时1秒开始播放
handler.postDelayed({
Thread {
if (!ActEventStates.eventStates.isEmpty()) {
//遍历每一个Act的事件,支持多个Act的录制与回放
val pop = ActEventStates.eventStates.remove()
while (!pop.isEmpty()) {
val state = pop.remove()
//根据事件的时间顺序播放
handler.postDelayed({
if (state.event == null) {
YYLogUtils.w("没了,回放录制完成")
} else {
dispatchTouchEvent(state.event)
}
}, state.time)

}
}
}.start()
}, 1000)
}
}

在当前的 Activity 中录制与回放的效果,具体的使用与效果:


    startRecode.click {
ActEventStates.isRecord = true
toast("开始录制")
startRecord()
}

endRecode.click {
ActEventStates.isRecord = false
toast("停止录制")
stopRecord()
}

//点击回放
btnReplay.click {
ActEventStates.isPlay = true
toast("回放录制")
playRecord()
}

act_record01.gif


单独的 Activity 上录制与回放是可以了,但是我们的应用又不是 Compose 或 Flutter,我们大部分项目还是多 Activity 的,如何实现多 Activity 跳转之后的录制与回放才是真正的问题。


四、多Activity的录制与回放


由于我们之前定义的数据格式就是 Queue 队列,所以我们很方便的就能实现多 Activity 的录制与回放效果,只需要在每一个 Activity 的 onResume 方法中尝试录制与播放即可。


由于当前的 Queue 的数据格式的性质,回放完成之后就没有了,跳转 Activity 之后就无需从头开始播放,特别适合这个场景。


只是需要注意的点是 Activity 的返回除了 Appbar 的页面返回按钮点击,我们还能使用系统的返回键或国产OS的左侧右侧滑动返回操作,所以我们需要对系统的返回操作单独做处理,修改之后的核心代码如下:


abstract class BaseActivity<VM : BaseViewModel> : AbsActivity() {

...

// ================== 事件录制 ======================

var handler = Handler(Looper.getMainLooper())

/**
* 存放当前activity中的事件
*/

private var activityEvents: Queue<EventState>? = null

/**
* 当前activity可见之后的时间点,每次 onResume 之后都创建一个新的队列,同时也赋值新的statetime
*/

private var startTime: Long = 0


override fun onResume() {
super.onResume()
startRecord() //尝试录制
playRecord() //尝试回放
}

//开启录制
protected fun startRecord() {
//如果是录制状态
if (ActEventStates.isRecord) {
ActEventStates.isPlay = false

//初始化队列,对应一个Act是一个队列
activityEvents = LinkedList()
// Act录制事件的开始时间
startTime = System.currentTimeMillis()
//保存到内存中
ActEventStates.eventStates.add(activityEvents)
}
}

//停止录制
protected fun stopRecord() {
val state = EventState()
state.event = null
state.time = System.currentTimeMillis() - startTime
activityEvents?.add(state)
}

override fun onBackPressed() {
val state = EventState()
state.event = null
state.isBackPress = true
state.time = System.currentTimeMillis() - startTime
activityEvents?.add(state)
super.onBackPressed()
}

//回放录制
protected fun playRecord() {
//如果是播放状态
if (ActEventStates.isPlay) {
ActEventStates.isRecord = false

//延时1秒开始播放
handler.postDelayed({
Thread {
if (!ActEventStates.eventStates.isEmpty()) {
//遍历每一个Act的事件,支持多个Act的录制与回放
val pop = ActEventStates.eventStates.remove()
while (!pop.isEmpty()) {
val state = pop.remove()
//根据事件的时间顺序播放
handler.postDelayed({
if (state.event == null) {
if (state.isBackPress) {
YYLogUtils.w("手动调用系统返回按键")
onBackPressed() //手动调用系统返回按键
} else {
YYLogUtils.w("没了,回放录制完成")
}

} else {
dispatchTouchEvent(state.event)
}
}, state.time)

}
}
}.start()
}, 1000)
}
}

override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
//只有在录制状态下才会保存事件并添加到队列中
if (ActEventStates.isRecord && activityEvents != null) {
//不要直接存原始的 MotionEvent,因为用过就回收的,之前我们是通过自定义 Event 来做的,这里简单一点直接重新伪造一次其实更方便
val obtain = MotionEvent.obtain(ev)
//初始化自己的 EventState 用于保存当前事件对象
val state = EventState()
//赋值当前事件,用伪造过的事件
state.event = obtain
//赋值当前事件发生的时间
state.time = System.currentTimeMillis() - startTime
//把每一次事件 EventState 对象添加到队列中
activityEvents?.add(state)
}
return super.dispatchTouchEvent(ev)
}
}

对于事件的封装我们添加了是否是系统返回的标记:


public class EventState {
public boolean isBackPress;
public MotionEvent event; //事件
public long time; //开始录制到该事件发生的时间
}

使用的方式就没有变化,我们添加几个 Activity 的跳转试试:


    startRecode.click {
ActEventStates.isRecord = true
toast("开始录制")
startRecord()
}

endRecode.click {
ActEventStates.isRecord = false
toast("停止录制")
stopRecord()
}

//点击回放
btnReplay.click {
ActEventStates.isPlay = true
toast("回放录制")
playRecord()
}

btnJump1.click {
TemperatureViewActivity.startInstance()
}
btnJump2.click {
ViewGr0up9Activity.startInstance()
}

效果:


act_record02.gif


为了区分实际手指操作与回放的操作的差异,我打开了开发者选项中的触摸反馈,第一次效果是带触摸反馈的,回放录制的效果是没有触摸反馈的,并且支持 Appbar的返回按键与系统的返回键。


如果想回放多次,则需要在停止录制的时候把事件保存到本地,如何保存对象到本地?和前文一样的思路,可以用Json,可以压缩,可以加密,甚至可以自定义数据格式与解析,这一个步骤就无需我多说了吧。


后记


回到前文,虽然自动化测试中我们常用到录制与回放的功能,但是对于线上的监控与云真机回放对于的操作,其实与类似Python自动化脚本还是有区别,与 PC + ADB 的方式也有区别,基于App本身实现的可以更好的用于线上的稳定性监控。


当然了由于本文是实验性质并不完善,浅尝辄止,只是提供一个思路,真要实现完整的功能并不是一个人短时间能搞出来的,如果你想要实现类似的功能可以参考实现。


比如后期如我们需要区分事件类型,点击的文本与图标,使用文本或图片识别进行定位,输入框的适配,等等一系列的功能并不是那么的容易还有很长的路要走,想起来都头皮发麻。


好了,关于最基础的功能来说的话,本机的 App 应用的录制与回放就讲到这里,那么除此方式之外还有哪些更方便的实现方式呢?我也很好奇,也欢迎大家交流讨论哦!


而对于本机其他第三方 App 应用的录制与回放又有哪些方式实现呢?这又是完全不同的另一个故事了。


言归正传,关于本文的内容如果想查看源码可以点击这里 【传送门】。你也可以关注我的这个Kotlin项目,我有时间都会持续更新。


惯例,我如有讲解不到位或错漏的地方,希望同学们可以指出交流。


如果感觉本文对你有一点点的启发,还望你能点赞支持一下,你的支持是我最大的动力啦!


Ok,这一期就此完结。



作者:Newki
来源:juejin.cn/post/7330104253825646601
收起阅读 »

拒绝代码PUA,优雅地迭代业务代码

最初的美好 没有历史包袱,就没有压力,就是美好的。 假设项目启动了这样一个业务——造车:生产一辆小汽车(Car),分别在不同的零件车间(车架(Sheel)、发动机(Engine)、车轮(Wheel))安装相应的零件,所有零件安装完成后回到提车车间就可以提车。 ...
继续阅读 »

最初的美好


没有历史包袱,就没有压力,就是美好的。


假设项目启动了这样一个业务——造车:生产一辆小汽车(Car),分别在不同的零件车间(车架(Sheel)、发动机(Engine)、车轮(Wheel))安装相应的零件,所有零件安装完成后回到提车车间就可以提车。


Ugly1.gif


这样的需求开发起来很简单:



  • 数据实体


data class Car(
var shell: Shell? = null,
var engine: Engine? = null,
var wheel: Wheel? = null,
) : Serializable {
override fun toString(): String {
return "Car: Shell(${shell}), Engine(${engine}), Wheel(${wheel})"
}
}

data class Shell(
...
) : Serializable

data class Engine(
...
) : Serializable

data class Wheel(
...
) : Serializable


  • 零件车间(以车架为例)


class ShellFactoryActivity : AppCompatActivity() {
private lateinit var btn: Button
private lateinit var back: Button
private lateinit var status: TextView

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_shell_factory)
val car = intent.getSerializableExtra("car") as Car
status = findViewById(R.id.status)
btn = findViewById(R.id.btn)
btn.setOnClickListener {
car.shell = Shell(
id = 1,
name = "比亚迪车架",
type = 1
)
status.text = car.toString()
}
back = findViewById(R.id.back)
back.setOnClickListener {
setResult(RESULT_OK, intent.apply {
putExtra("car", car)
})
finish()
}
}
}


class EngineFactoryActivity : AppCompatActivity() {
// 和安装车架流程一样
}

class WheelFactoryActivity : AppCompatActivity() {
// 和安装车架流程一样
}


  • 提车车间


class MainActivity : AppCompatActivity() {
private var car: Car? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
car = Car()
refreshStatus()
findViewById<Button>(R.id.shell).setOnClickListener {
val it = Intent(this, ShellFactoryActivity::class.java)
it.putExtra("car", car)
startActivityForResult(it, REQUEST_SHELL)
}
findViewById<Button>(R.id.engine).setOnClickListener {
val it = Intent(this, EngineFactoryActivity::class.java)
it.putExtra("car", car)
startActivityForResult(it, REQUEST_ENGINE)
}
findViewById<Button>(R.id.wheel).setOnClickListener {
val it = Intent(this, WheelFactoryActivity::class.java)
it.putExtra("car", car)
startActivityForResult(it, REQUEST_WHEEL)
}
}

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (resultCode != RESULT_OK) return
when (requestCode) {
REQUEST_SHELL -> {
Log.i(TAG, "安装车架完成")
car = data?.getSerializableExtra("car") as Car
}
REQUEST_ENGINE -> {
Log.i(TAG, "安装发动机完成")
car = data?.getSerializableExtra("car") as Car
}
REQUEST_WHEEL -> {
Log.i(TAG, "安装车轮完成")
car = data?.getSerializableExtra("car") as Car
}
}
refreshStatus()
}

private fun refreshStatus() {
findViewById<TextView>(R.id.status).text = car?.toString()
findViewById<Button>(R.id.save).run {
isEnabled = car?.shell != null && car?.engine != null && car?.wheel != null
setOnClickListener {
Toast.makeText(this@MainActivity, "提车咯!", Toast.LENGTH_SHORT).show()
}
}
}

companion object {
private const val TAG = "MainActivity"
const val REQUEST_SHELL = 1
const val REQUEST_ENGINE = 2
const val REQUEST_WHEEL = 3
}
}

即使是初学者也能看出来,业务实现起来很简单,通过ActivitystartActivityForResult就能跳转到相应的零件车间,安装好零件回到提车车间就完事了。


开始迭代


往往业务的第一个版本就是这么简单,感觉也没什么好重构的。


但是业务难免会进行迭代。比如业务迭代到1.1版本:客户想要给汽车装上行车电脑,而安装行车电脑不需要跳转到另一个车间,而是在提车车间操作,但是需要很长的时间。


Ugly2.gif


看起来也简单,新增一个Computer实体类和ComputerFactoryHelper


object ComputerFactoryHelper {
fun provideComputer(block: Computer.() -> Unit) {
Thread.sleep(5_000)
block(Computer())
}
}

data class Computer(
val id: Int = 1,
val name: String = "行车电脑",
val cpu: String = "麒麟90000"
) : Serializable {
override fun toString(): String {
return "$name-$cpu"
}
}

再在提车车间新增按钮和逻辑代码:


findViewById<Button>(R.id.computer).setOnClickListener {
object : Thread() {
override fun run() {
ComputerFactoryHelper.provideComputer {
car?.computer = this
runOnUiThread { refreshStatus() }
}
}
}.start()

}

目前看起来也没啥难的,那是因为我们模拟的业务场景足够简单,但是相信很多实际项目的屎山代码,就是通过这样的业务迭代,一点一点地堆积而成的。


从迭代到崩溃


咱们来采访一下最近被一个小小的业务迭代需求搞崩溃的Android开发——小王。



记者:小王你好,听说最近你Emo了,甚至多次萌生了就地辞职的念头?


小王:最近AI不是很火吗,产品给我提了一个需求,在上传音乐时可以选择在后端生成一个AI视频,然后一起上传。


记者:哦?这不是一个小需求吗?


小王:但是我打开目前上传业务的代码就傻了啊!就说Activity吧,有:BasePublishActivity,BasePublishFinallyActivity,SinglePublishMusicActivity,MultiPublishMusicActivity,PublishFinallyActivity,PublishCutMusicFinallyActivity, Publish(好多好多)FinallyActivity... 当然,这只是冰山一角。再说上传流程。如果只上传一首音乐,需要先调一个接口/sts拿到一个Oss Token,再调用第三方的Oss库上传文件,拿到一个url,然后再把这个url和其他的信息(标题、标签等)组成一个HashMap,再调用一个接口/save提交到后端,相当于调3个接口... 如果要批量上传N个音乐,就要调3 * N个接口,如果还要给每个音乐配M个图片,就要调3 * N+3 * N * M个接口... 如果上传一个音乐配一个本地视频,就要调3 * 2 * N个接口,并且,上传视频流程还不一样的是,需要在调用/save接口之后再调用第三方Oss上传视频文件...再说数据类。上面提到上传过程中需要添加图片、视频、活动类型啥的,代码里封装了一个EditInfo类足足有30个属性!,由于是Java代码并且实现了Parcelable接口,光一个Data类就有400多行!你以为这就完了?EditInfo需要在上传时转成PublishInfo类,PublishInfo还可以转成PublishDraft,PublishDraft可以保存到数据库中,从数据库中可以读取PublishDraft然后转成EditInfo再重新编辑...


记者:(感觉小王精神状态有点问题,于是掐掉了直播画面)



相信小王的这种情况,很多业务开发同学都经历过吧。回头再看一下前面的造车业务,其实和小王的上传业务一样,就是一开始很简单,迭代个7、8个版本就开始陷入一种困境:即使迭代需求再小,开发起来都很困难。


优雅地迭代业务代码?


假如咱们想要优雅地迭代业务代码,应该怎么做呢?


小王遇到的这座屎山,咱们现在就不要去碰了,先就从前面提到的造车业务开始吧。


很多同学会想到重构,俺也一样。接下来,我就要讨论一下如何优雅安全地重构既有业务代码。



先抛出一个观点:对于程序员来说,想要保持“优雅”,最重要的品质就是抽象。



❓ 这时可能有同学就要反驳我了:过早的抽象没有必要。


❗ 别急,我现在要说的抽象,并不是代码层面的抽象,而是对业务的抽象,乃至对技术思维的抽象


什么是代码层面的抽象?比如刚刚的Shell/Engine/WheelFactoryActivity,其实是可以抽象为BaseFactoryActivity,然后通过实现其中的零件类型就行了。但我不会建议你这么做,为啥?看看小王刚才的疯言疯语就明白了。各个XxxFactoryActivity看着差不多,但在实际项目中很可能会开枝散叶,各自迭代出不同的业务细节。到那时,项目里就是各种BaseXxxActivityXxxV1ActivityXxxV2Activity...


那什么又是业务的抽象?直接上代码:


interface CarFactory {
val factory: suspend Car.() -> Car
}

造车业务,无论在哪个环节,都是在Car上装配零件(或者任何出其不意的操作),然后产生一个新的Car;另外,这个环节无论是跳转到零件车间安装零件,还是在提车车间里安装行车电脑,都是耗时操作,所以需要加suspend关键词。


❓ 这时可能有同学说:害!你这和BaseFactoryActivity有啥区别,不就是把抽象类换成接口了吗?


❗ 别急,我并没有要让XxxFactoryActivity去继承CarFactory啊,想想小王吧,这个XxxFactoryActivity就相当于他的同事在两年前写的代码,小王肯定打死都不会想去修改这里面的代码的。


Computer是新业务,我们只改它。首先我们根据这个接口把ComputerFactoryHelper改一下:


object ComputerFactoryHelper : CarFactory {
private suspend fun provideComputer(block: Computer.() -> Unit) {
delay(5_000)
block(Computer())
}

override val factory: suspend Car.() -> Car = {
provideComputer {
computer = this
}
this
}
}

那么,在提车车间就可以这样改:


private var computerFactory: CarFactory = ComputerFactoryHelper
findViewById<Button>(R.id.computer).setOnClickListener {
lifecycleScope.launchWhenResumed {
computerFactory.factory.invoke(car)
refreshStatus()
}
}

❓ 那么XxxFactoryActivity相关的流程又应该怎么重构呢?


Emo时间


我先反问一下大家,为啥咱们Android程序员总是盯着Activity不放呢?


我再反问一下大家,为啥咱们Android程序员总是盯着Activity不放呢?


我再再反问一下大家,为啥咱们Android程序员总是盯着Activity不放呢?


甚至,很多人即使学了ComposeFlutter,仍然对Activity心心念念。



当你在一千个日日夜夜里,重复地写着XxxActivity,写onCreate/onResume/onDestroy,写startActivityForResult/onActivityResult时,当你每次想要换工作,打开面经背诵Activity的启动模式,生命周期,AMS原理时,可曾对Activity有过厌倦,可曾思考过编程的意义?


你也曾努力查阅Activity的源码,学习MVP/MVVM/MVI架构,试图让你的Activity保持清洁,但无论你怎么努力,却始终活在Activity的阴影之下。


你有没有想过,咱们正在被Activity PUA



说实话,作为一名INFP,本人不是很适合做程序员。相比技术栈的丰富和技术原理的深入,我更看重的是写代码的感受。如果写代码都能被PUA,那我还怎么愉快的写代码?


当我Emo了很久之后,我意识到了,我一直在被代码PUA,不光是同事的代码,也有自己的代码,甚至有Android框架,以及外国大佬不断推出的各种新技术新框架。



对对对!你们都没有问题,是我太菜了555555555



优雅转身


Emo过后,还是得回到残酷的职场啊!但是我们要优雅地转身回来!


❓ 刚才不是说要处理XxxFactoryActivity相关业务吗?


❗ 这时我就要提到另外一种抽象:技术思维的抽象


Activity?F*ck off!


Activity的跳转返回啥的,也无非就是一次耗时操作嘛,咱们也应该将它抽象为CarFactory,就是这个东东:


interface CarFactory {
val factory: suspend Car.() -> Car
}

基于这个信念,我从记忆中想到这么一个东东:ActivityResultLauncher


说实话,我以前都没用过这玩意儿,但是我这时好像抓到了救命稻草。


随便搜了个教程并谢谢他,参考这篇博客,我们可以把startActivityForResultonActivityResult这套流程,封装成一次异步调用。


open class BaseActivity : AppCompatActivity() {
private lateinit var startActivityForResultLauncher: StartActivityForResultLauncher

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
startActivityForResultLauncher = StartActivityForResultLauncher(this)
}

fun startActivityForResult(
intent: Intent,
callback: (resultCode: Int, data: Intent?) -> Unit
)
{
startActivityForResultLauncher.launch(intent) {
callback.invoke(it.resultCode, it.data)
}
}
}

MainActivity继承BaseActivity,就可以绕过Activity了,后面的事情就简单了。只要咱们了解过协程,就能轻易想到异步转同步这一普通操作:suspendCoroutine


于是,我们就可以在不修改XxxFactoryActivity的情况下,写出基于CarFactory的代码了。还是以车架车间为例:


class ShellFactoryHelper(private val activity: BaseActivity) : CarFactory {

override val factory: suspend Car.() -> Car = {
suspendCoroutine { continuation ->
val it = Intent(activity, ShellFactoryActivity::class.java)
it.putExtra("car", this)
activity.startActivityForResult(it) { resultCode, data ->
(data?.getSerializableExtra("car") as? Car)?.let {
Log.i(TAG, "安装车壳完成")
shell = it.shell
continuation.resumeWith(Result.success(this))
}
}
}
}
}

然后在提车车间,和Computer业务同样的使用方式:


private var shellFactory: CarFactory = ShellFactoryHelper(this)
findViewById<Button>(R.id.shell).setOnClickListener {
lifecycleScope.launchWhenResumed {
shellFactory.factory.invoke(car)
refreshStatus()
}
}

最终,在我们的提车车间,依赖的就是一些CarFactory,所有的业务操作都是抽象的。到达这个阶段,相信大家都有了自己的一些想法了(比如维护一个carFactoryList,用Hilt管理CarFactory依赖,泛型封装等),想要继续怎么重构/维护,就全看自己的实际情况了。


class MainActivity : BaseActivity() {
private var car: Car = Car()
private var computerFactory: CarFactory = ComputerFactoryHelper
private var engineFactory: CarFactory = EngineFactoryHelper(this)
private var shellFactory: CarFactory = ShellFactoryHelper(this)
private var wheelFactory: CarFactory = WheelFactoryHelper(this)

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
refreshStatus()
findViewById<Button>(R.id.shell).setOnClickListener {
lifecycleScope.launchWhenResumed {
shellFactory.factory.invoke(car)
refreshStatus()
}
}
findViewById<Button>(R.id.engine).setOnClickListener {
lifecycleScope.launchWhenResumed {
engineFactory.factory.invoke(car)
refreshStatus()
}
}
findViewById<Button>(R.id.wheel).setOnClickListener {
lifecycleScope.launchWhenResumed {
wheelFactory.factory.invoke(car)
refreshStatus()
}
}
findViewById<Button>(R.id.computer).setOnClickListener {
lifecycleScope.launchWhenResumed {
Toast.makeText(this@MainActivity, "稍等一会儿", Toast.LENGTH_LONG).show()
computerFactory.factory.invoke(car)
Toast.makeText(this@MainActivity, "装好了!", Toast.LENGTH_LONG).show()
refreshStatus()
}
}
}

private fun refreshStatus() {
findViewById<TextView>(R.id.status).text = car.toString()
findViewById<Button>(R.id.save).run {
isEnabled = car.shell != null && car.engine != null && car.wheel != null && car.computer != null
setOnClickListener {
Toast.makeText(this@MainActivity, "提车咯!", Toast.LENGTH_SHORT).show()
}
}
}
}

总结



  • 抽象是程序员保持优雅的最重要能力。

  • 抽象不应局限在代码层面,而是要上升到业务,乃至技术思维上。

  • 有意识地对代码PUA说:No!

  • 学习新技术时,不应只学会调用,也不应迷失在技术原理上,更重要的是花哨的技术和朴实的编程思想之间的化学反应。


作者:blackfrog
来源:juejin.cn/post/7274084216286036004
收起阅读 »

终于搞明白了什么是同步屏障

背景 今天突然听到隔壁在讨论同步屏障,听到这个名字,我依稀记得 Handler 里面是有同步屏障机制的,但是具体的原理怎么有点模糊不清呢?就像一个明星,你明明看着面熟,就是想不起来他叫啥,让我这样的强迫症患者无比难受,所以抽时间来扒一扒同步屏障。 同步屏障机制...
继续阅读 »

背景


今天突然听到隔壁在讨论同步屏障,听到这个名字,我依稀记得 Handler 里面是有同步屏障机制的,但是具体的原理怎么有点模糊不清呢?就像一个明星,你明明看着面熟,就是想不起来他叫啥,让我这样的强迫症患者无比难受,所以抽时间来扒一扒同步屏障。


同步屏障机制


1. 直奔主题,同步屏障机制这几个字听起来很牛逼,能浅显的解释一下,先让大家明白它的作用是啥不?


同步屏障实际上就是字面意思,可以理解为建立一道屏障,隔离同步消息,优先处理消息队列中的异步消息进行处理,所以才叫同步屏障。


2. 第二个问题,同步消息又是啥呢?异步消息和同步消息有啥不一样呢?


要回答这个问题,我们就得了解一下 MessageMessage 的消息种类分为三种:



  • 普通消息(同步消息)

  • 异步消息

  • 同步屏障消息


我们平时使用 Handler 发送的消息基本都是普通消息,中规中矩的排到消息队列中,轮到它了再乖乖地出来执行。


考虑一个场景,我现在往 UI 线程发送了一个消息,想要绘制一个关键的 View,但是现在 UI 线程的消息队列里面消息已经爆满了,我的这条消息迟迟都没有办法得到处理,导致这个关键 View 绘制不出来,用户使用的时候很恼怒,一气之下给出差评这是什么垃圾 app,卡的要死。


此时,同步屏障就派上用场了。如果消息队列里面存在了同步屏障消息,那么它就会优先寻找我们想要先处理的消息,把它从队列里面取出来,可以理解为加急处理。那同步屏障机制怎么知道我们想优先处理的是哪条消息呢?如果一条消息如果是异步消息,那同步屏障机制就会优先对它处理。


3.那要如何设置异步消息呢?怎样的消息才算一条异步消息呢?


Message 已经提供了现成的标记位 isAsynchronous 用来标志这条消息是不是异步消息。


4.能看看源码了解下官方到底怎么实现的吗?


看看怎么往消息队列 MessageQueue 中插入同步屏障消息吧。


private int postSyncBarrier(long when) {
synchronized (this) {
final int token = mNextBarrierToken++;
final Message msg = Message.obtain();
msg.markInUse();
msg.when = when;
msg.arg1 = token;

Message prev = null;
// 当前消息队列
Message p = mMessages;
if (when != 0) {
// 根据when找到同步屏障消息插入的位置
while (p != null && p.when <= when) {
prev = p;
p = p.next;
}
}
// 插入同步屏障消息
if (prev != null) {
msg.next = p;
prev.next = msg;
} else {
msg.next = p;
// 前面没有消息的话,同步屏障消息变成队首了
mMessages = msg;
}
return token;
}
}

在代码关键位置我都做了注释,简单来说呢,其实就像是遍历一个链表,根据 when 来找到同步屏障消息应该插入的位置。


5.同步屏障消息好像只设置了when,没有target呢?


这个问题发现了华点,熟悉 Handler 的朋友都知道,插入消息到消息队列的时候,系统会判断当前的消息有没有 targettarget 的作用就是标记了这个消息最终要由哪个 Handler 进行处理,没有 target 会抛异常。


boolean enqueueMessage(Message msg, long when) {
// target不能为空
if (msg.target == null) {
throw new IllegalArgumentException("Message must have a target.");
}
...
}

问题 4 的源码分析中,同步屏障消息没有设置过 target,所以它肯定不是通过 enqueueMessage() 添加到消息队列里面的啦。很明显就是通过 postSyncBarrier() 方法,把一个没有 target 的消息插入到消息队列里面的。


6.上面我都明白了,下面该说说同步屏障到底是怎么优先处理异步消息的吧?


OK,插入了同步屏障消息之后,消息队列也还是正常出队的,显然在队列获取下一个消息的时候,可能对同步屏障消息有什么特殊的判断逻辑。看看 MessageQueuenext 方法:


Message next() {
...
// msg.target == null,很明显是一个同步屏障消息
if (msg != null && msg.target == null) {
// Stalled by a barrier. Find the next asynchronous message in the queue.
do {
prevMsg = msg;
msg = msg.next;
} while (msg != null && !msg.isAsynchronous());
}
...
}

方法代码很长,看源码最主要还是看关键逻辑,也没必要一行一行的啃源码。这个方法中相信你一眼就发现了
msg.target == null,前面刚说过同步屏障消息的 target 就是空的,很显然这里就是对同步屏障消息的特殊处理逻辑。用了一个 do...while 循环,消息如果不是异步的,就遍历下一个消息,直到找到异步消息,也就是 msg.isAsynchronous() == true


7.原来如此,那如果消息队列中没有异步消息咋办?


如果队列中没有异步消息,就会休眠等待被唤醒。所以 postSyncBarrier()removeSyncBarrier() 必须成对出现,否则会导致消息队列中的同步消息不会被执行,出现假死情况。


8.系统的 postSyncBarrier() 貌似也没提供给外部访问啊?这我们要怎么使用?


确实我们没办法直接访问 postSyncBarrier() 方法创建同步屏障消息。你可能会想到不让访问我就反射调用呗,也不是不可以。


但我们也可以另辟蹊径,虽然没办法创建同步屏障消息,但是我们可以创建异步消息啊!只要系统创建了同步屏障消息,不就能找到我们自己创建的异步消息啦。


系统提供了两个方法创建异步 Handler


public static Handler createAsync(@NonNull Looper looper) {
if (looper == null) throw new NullPointerException("looper must not be null");
// 这个true就是代表是异步的
return new Handler(looper, null, true);
}

public static Handler createAsync(@NonNull Looper looper, @NonNull Callback callback) {
if (looper == null) throw new NullPointerException("looper must not be null");
if (callback == null) throw new NullPointerException("callback must not be null");
return new Handler(looper, callback, true);
}

异步 Handler 发送的就是异步消息。


9.那系统什么时候会去添加同步屏障呢?


有对 View 的工作流程比较了解的朋友想必已经知道了,在 ViewRootImplrequestLayout 方法中,系统就会添加一个同步屏障。


不了解也没关系,这里我简单说一下。


(1)创建 DecorView


当我们启动了 Activity 后,系统最终会执行到 ActivityThreadhandleLaunchActivity 方法中:


final Activity a = performLaunchActivity(r, customIntent);

这里我们只截取了重要的一行代码,在 performLaunchActivity 中执行的就是 Activity 的创建逻辑,因此也会进行 DecorView 的创建,此时的 DecorView 只是进行了初始化,添加了布局文件,对用户来说,依然是不可见的。


(2)加载 DecorView 到 Window


onCreate 结束后,我们来看下 onResume 对应的 handleResumeActivity 方法:


@Override
public void handleResumeActivity(ActivityClientRecord r, boolean finalStateRequest,
boolean isForward, String reason)
{
...
// 1.performResumeActivity 回调用 Activity 的 onResume
if (!performResumeActivity(r, finalStateRequest, reason)) {
return;
}
...
final Activity a = r.activity;
...
if (r.window == null && !a.mFinished && willBeVisible) {
r.window = r.activity.getWindow();
// 2.获取 decorview
View decor = r.window.getDecorView();
// 3.decor 现在还不可见
decor.setVisibility(View.INVISIBLE);
ViewManager wm = a.getWindowManager();
WindowManager.LayoutParams l = r.window.getAttributes();
a.mDecor = decor;
l.type = WindowManager.LayoutParams.TYPE_BASE_APPLICATION;
l.softInputMode |= forwardBit;
...
if (a.mVisibleFromClient) {
if (!a.mWindowAdded) {
a.mWindowAdded = true;
// 4.decor 添加到 WindowManger中
wm.addView(decor, l);
} else {
a.onWindowAttributesChanged(l);
}
}
}
...
}

注释 4 处,DecorView 会通过 WindowManager 执行了 addView() 方法后加载到 Window 中,而该方法实际上是会最终调用到 WindowManagerGlobaladdView() 中。


(3)创建 ViewRootImpl 对象,调用 setView() 方法


// WindowManagerGlobal.ddView()
root = new ViewRootImpl(view.getContext(), display);
root.setView(view, wparams, panelParentView);

WindowManagerGlobaladdView() 会先创建一个 ViewRootImpl 实例,然后将 DecorView 作为参数传给 ViewRootImpl,通过 setView() 方法进行 View 的处理。setView() 的内部主要就是通过 requestLayout 方法来请求开始测量、布局和绘制流程


(4)requestLayout() 和 scheduleTraversals()


@Override
public void requestLayout() {
if (!mHandlingLayoutInLayoutRequest) {
checkThread();
mLayoutRequested = true;
// 主要方法
scheduleTraversals();
}
}

void scheduleTraversals() {
if (!mTraversalScheduled) {
// 1.将mTraversalScheduled标记为true,表示View的测量、布局和绘制过程已经被请求。
mTraversalScheduled = true;
// 2.往主线程发送一个同步屏障消息
mTraversalBarrier = mHandler.getLooper().getQueue().postSyncBarrier();
// 3.注册回调,当监听到VSYNC信号到达时,执行该异步消息
mChoreographer.postCallback(
Choreographer.CALLBACK_TRAVERSAL, mTraversalRunnable, null);
notifyRendererOfFramePending();
pokeDrawLockIfNeeded();
}
}

看到了吧,注释 2 的代码熟悉的很,系统调用了 postSyncBarrier() 来创建同步屏障了。那注释 3 是啥意思呢?mChoreographer 是一个 Choreographer 对象。


要理解 Choreographer 的话,还要明白 VSYNC


我们的手机屏幕刷新频率是 1s 内屏幕刷新的次数,比如 60Hz、120Hz 等。60Hz表示屏幕在一秒内刷新 60 次,也就是每隔 16.6ms 刷新一次。屏幕会在每次刷新的时候发出一个 VSYNC 信号,通知CPU进行绘制计算,每收到 VSYNC,CPU 就开始处理各帧数据。这时 Choreographer 就上场啦,当有 VSYNC 信号到来时,会唤醒 Choreographer,触发指定的工作。它提供了一个回调功能,让业务知道 VSYNC 信号来了,可以进行下一帧的绘制了,也就是注释 3 使用的 postCallback 方法。


当监听到 VSYNC 信号后,会回调来执行 mTraversalRunnable 这个 Runnable 对象。


final class TraversalRunnable implements Runnable {
@Override
public void run() {
doTraversal();
}
}

void doTraversal() {
if (mTraversalScheduled) {
mTraversalScheduled = false;
// 移除同步屏障
mHandler.getLooper().getQueue().removeSyncBarrier(mTraversalBarrier);

if (mProfile) {
Debug.startMethodTracing("ViewAncestor");
}
// View的绘制入口方法
performTraversals();

if (mProfile) {
Debug.stopMethodTracing();
mProfile = false;
}
}
}

在这个 Runnable 里面,会移除同步屏障。然后调用 performTraversals 这个View 的工作流程的入口方法完成对 View 的绘制。


这回明白了吧,系统会在调用 requestLayout() 的时候创建同步屏障,等到下一个 VSYNC 信号到来时才会执行相应的绘制任务并移除同步屏障。所以在等待 VSYNC 信号到来的期间,就可以执行我们自己的异步消息了。


参考


requestLayout竟然涉及到这么多知识点


关于Handler同步屏障你可能不知道的问题


“终于懂了” 系列:Android屏幕刷新机制—VSync、Choreographer 全面理解


作者:搬砖的代码民工
来源:juejin.cn/post/7258850748150104120
收起阅读 »

Android 居然还能这样抓捕和利用主线程碎片时间

本文作者: zy 图片来自:unsplash.com 在 Android 应用开发过程中,我们会将一些耗时任务放在子线程进行处理,从而避免出现主线程卡顿的情况。但是不可避免的,依然会出现有些任务必须要在主线程中执行,如果主线程需要执行的任务过多,会出现卡顿...
继续阅读 »

本文作者: zy




图片来自:unsplash.com


在 Android 应用开发过程中,我们会将一些耗时任务放在子线程进行处理,从而避免出现主线程卡顿的情况。但是不可避免的,依然会出现有些任务必须要在主线程中执行,如果主线程需要执行的任务过多,会出现卡顿的情况,那么接下来我们就应该思考如何解决这个问题。


背景与现状


在 Android 应用开发的过程中,对于必须要在主线程执行的代码逻辑,可以使用由 Android 系统提供的主线程空闲任务调度器 IdleHandler 来处理。但如果空闲任务调度器执行任务过于耗时,依然会导致 APP 卡顿或者跳帧。另外如果开发者想要移除部分的空闲调度器任务,是无法实现不了的。只能选择全部移除。


分析


为了减少主线程的卡顿,提高主线程资源的利用率,我们通过系统源码了解到页面渲染的部分关键过程。



上图所示,当页面 View 有更新操作时,会通过 Choreographer 去注册一个 VSYNC 信号监听,等待 VSYNC 信号的到来,VSYNC 信号到来后,会执行我们熟知的 measure,layout,draw 方法,然后将视图数据通过 swipeBuffer 移交给屏幕的 DataBuffer 区域,等待进一步处理。在这个过程中,如果绘制操作比较耗时,掺杂了我们的业务逻辑,页面就会变得卡顿,如果每一帧的绘制都是在两个标准的 VSYNC 信号之间完成的,页面操作和展示就会变得非常流畅。分析发现,当一个 VSYNC 信号到来之后,如果页面的绘制能够提前完成,那么主线程会有一段时间的空窗期,如果我们能利用这段空窗期做点事情,那么就可以解决主线程任务过多造成主线程卡顿问题。主线程空窗期示意图如下图所示。



VSYNC 信号到达应用层后,经历 measure,layout,draw 几个阶段,这里统称为 render 阶段,render 阶段结束之后,如果 MessageQueue 没有其它的消息,这时候主线程就会处于空闲状态,等待视图刷新触发下一个 VSYNC 信号的到来。这里我们通过 Choreographer 来监听 VSYNC 信号的到来作为开始标记,以及 render 结束后的信号作为结束标记。结束标记和开始标记之间的时间差就是当前帧率下的主线程实际耗时也就是 render 时长,当前设备标准帧率时长( 图示以 60HZ 的刷新帧率,16.6ms 一帧的周期为基准 )与 render 时长的时间差就是我们可利用的主线程时长,有了这个时长以及 render 结束的触发点,就可以执行我们主线程的任务了。


具体方案


主线程碎片时间管理通过四个模块来实现,分别是帧率耗时监控模块,空闲时间切片模块,耗时任务拆分模块,子任务智能调度模块。 在帧率耗时监控模块,通过 HOOK 系统对象,注入自己的监听回调,来获取当前帧的渲染开始时间点和结束时间时间点。在空闲时间切片模块,生成当前帧可用的空闲时间。利用耗时任务拆分模块获取到可以被调度执行的子任务,最后由子任务智能调度系统负责子任务的调度执行以及记录每个任务在当前 CPU 的执行耗时情况,在初始化的时候通过读取上次 CPU 任务执行耗时的数据生成一个任务耗时记录表,用于给空闲时间切片模块提供时间更加精准的任务匹配,防止出现跳帧的情况。


帧率耗时监控模块


分析模块中,我们阐述了 render 阶段表示的是 View 视图树的计算阶段,包含了视图树的测量,布局,绘制。当完成这些任务之后,将剩下的工作交给系统渲染阶段来处理,系统渲染阶段会负责将视图渲染至屏幕上,这里我们需要关心的就是 render 阶段,这个阶段完成之后,即可认为当前帧的主线程工作完成了,等待接受下一个 VSYNC 信号的到来。在 View 视图树的计算阶段中,由于每一次需要计算页面视图树的复杂程度不一样,因此 VSYNC 中各个刷新周期的 render 阶段耗时也是不一样的,我们就需要监控每一个 VSYNC 信号到来之后 View 视图树计算阶段的耗时。 View 视图树监控( 帧率耗时监控模块 )全流程如下图所示



帧率耗时监控模块执行步骤:



  • 步骤一:在应用启动阶段,获取当前进程的系统的 Choreographer 对象

  • 步骤二:创建视图帧开始渲染的监听回调,该回调除了首次由开发者手动注入至 Choreographer 对象中,后续的注入均由监听回调自己注入,当监听到渲染开始的回调后,再次将回调自己注入至 Choreographer 对象中,这样就能实现监听每一帧渲染开始的时间点,同时记录帧渲染开始时间

  • 步骤三:创建视图帧结束渲染的监听回调,和开始渲染的监听回调注册流程类似,最终也是获取到每一帧渲染结束的时间点,将帧渲染结束时间记录下来

  • 步骤四:在监听每一帧渲染结束之后,计算开始时间和结束时间的差值,这就是我们需要的每一帧可用的时间切片


其中 Choreographer 是系统提供用于 View 视图树的计算以及与屏幕交互渲染的类,由 Choreographer 来监听 VSYNC 信号,信号到来之后,就会通知 View 视图树进行计算处理,当处理完成之后,将计算后的数据交给屏幕进行渲染。当前模块利用反射机制向 Choreographer 中注入渲染开始和渲染结束的监控回调,监控代码插入位置如下图所示



帧率的耗时监控就是在 render 阶段,通过插入帧率开始回调监听和帧率结束回调监听来计算得出的。


空闲时间切片


我们可以通过耗时监控模块获取到两个时间戳,分别是 View 视图树计算阶段渲染开始的时间戳和渲染结束的时间戳,我们需要的空闲时间就是两者的差值。View 视图树计算阶段的 render 部分完成之后,视图的绘制就会交给系统进行渲染,而这个渲染的过程是在其他线程和进程进行执行,这样,当前 APP 的主线程就会空闲下来,我们就可以利用这个空闲时间做点其他的时间,这个空闲时间就被称为空闲时间切片


耗时任务拆分


有了主线程可用的空闲时间切片,接下来我们就需要将我们的耗时任务进行一个拆分,如何找到耗时任务呢?这里我们使用 systrace 进行耗时方法采集



上图所示,当前业务有一个 300MS 的主线程耗时逻辑,后面的几个 VSYNC 信号周期都很空闲,我们可以将当前耗时的任务进行拆分切割,然后将拆分后的任务打散至后面空闲的时间切片中延后执行,如图



接下来定义一套数据结构,将拆分的任务当作一个子任务用自定义的数据结构保存起来(要注意内存泄漏的问题,页面销毁后,如果还存在任务未执行,需要把未执行的任务全部清空)


class TraceTask(val bucketType: Int = BUCKET_TYPE_PRIORITY_30, val taskId: String = "", private val task: (() -> Unit)) {
fun invokeTask() {
task.invoke()
}
}

到这里,可执行的子任务集就准备好了。


子任务智能调度


空闲时间切片和子任务集生成后,就可以通知任务调度系统进行子任务的执行调度,在空闲时间切片中插入适合当前时间切片执行的任务,如当前空闲时间切片只有 3ms,那么就应该从 3ms 及以下的任务桶中把需要执行的任务选出来,然后执行任务。整个模块的流程图如图所示



子任务智能调度执行步骤:



  • 步骤一:由 VSYNC 消息触发的结束监听模块开始执行,获取当前需要添加的子任务,如果没有要添加的子任务就走子任务的执行逻辑,如果存在,就走子任务的数据绑定和子任务添加逻辑

  • 步骤二:子任务的数据绑定逻辑,将子任务和页面的生命周期进行绑定,这样做的好处是当页面销毁之后,绑定的子任务会自动删除,防止出现内存泄漏的情况。生命周期绑定之后,还需要绑定该子任务历史执行耗时,该模块是智能任务调度的核心,绑定历史执行耗时信息之后,在取子任务阶段,就可以快速获取到当前时间切片下可执行的任务了

  • 步骤三:获取绑定后的子任务,添加到耗时任务表中,使用MAP+链表结构,方便任务的快速获取与增删

  • 步骤四:判断当前是否存在子任务,如果存在可执行的子任务,则执行下一步操作,如果不存在可执行的子任务,跳出并结束当前流程。这里的任务查找是查看耗时任务表中是否还有任务元素存在

  • 步骤五:判断当前是否为调度超时模式,如果当前非调度超时模式,则获取空闲时间切片剩余可用的时长,通过剩余时长去耗时任务队列中查找当前时长内可用的任务,如果找到可执行任务后,则执行任务,同时减掉当前任务执行时长,获取到更新后的时间切片可用时长,然后回到步骤四继续循环。如果没有找到任务,则结束当前流程

  • 步骤六:如果当前为调度超时模式,则忽略剩余切片可用时长,找到耗时任务队列第一个任务元素,获取并执行。


智能任务调度核心


智能任务调度核心主要负责计算出当前任务的实际耗时,这样做的目的是确保任务执行的时长不会超过空闲时间切片的剩余时长,例如:空闲时间切片剩余时长是 6ms ,那么智能任务调度核心就需要负责找出6ms以内能够完成的任务。当前任务第一次的时长是由开发者给出的默认时长( 开发者在自己手机系统上执行后得出的实际任务时长 ),当任务执行一次之后,会将任务在当前系统上的实际耗时保存下来,每条任务会保存最近 5 条数据。后续再取任务时长的时候,会将当前任务的历史执行时长的最大值取出,当作该任务的执行时长保留下来。所有任务执行时长数据会保存在 SD 卡上,在 APP 启动时,子线程进行任务执行时长的数据加载,将数据加载至手机运行内存中,加快后续读取任务时长的速度,在本次任务执行结束之后,需要将获取到新一轮的执行时长更新至内存中,等待页面关闭时,统一将数据写入至 SD 中。 智能任务调度核心时长获取以及保存示意图如图所示



任务队列结构


这里我们我们采用 MAP 表(KEY-VALUE)来存储数据,其中 KEY 为 INT 型,以任务执行耗时作为 KEY,VALUE 为链表结构,链表的增删效率非常高。使用链表的结构来保存当前耗时 KEY 下的所有任务。链表结构如图所示



调度超时模式


空闲时间切片的最大剩余时长不会超过 16.6ms ,在不同机型上,由于机器性能差异,导致各个任务的实际执行耗时可能会超过 16.6ms,在智能任务调度阶段,可能就会出现有个别超时任务一直无法和空闲时间切片的剩余时长匹配上,因此这里会提供一种兜底超时逻辑,当任务队列 1s 内都没有任何任务被调度执行( 60HZ 的情况下,1s 会有 60 次的帧率调用,也就是会有 60 次的任务调度执行),但是队列又不为空,可以说明当前存在异常超时的任务,为了保证所有任务的正常执行,这里会设置一个调度超时模式的标志状态,当进入调度超时模式中后,会上报当前异常任务,由开发者判断当前任务是因为手机性能问题超时,还是任务拆分不合理导致的。而程序也会再次进入判断逻辑中,逻辑判断发现当前处于调度超时模式时,不会检测当前剩余时长,而是直接取 MAP 表中的第一个元素,获取第一个任务并执行。从而保证所有添加的耗时任务,无论是否匹配上,都会得到执行.


总结


通过任务拆分+主线程空闲时间调度的方式,可以有效的利用主线程的空闲时间,让它来合理的帮助我们完成主线程逻辑的执行,而不会对主线程造成拥堵,给用户带来更好的操作体验。


作者:网易云音乐技术团队
来源:juejin.cn/post/7329028515382820916
收起阅读 »

分享:一个超实用的文字截断技巧

web
文字截断是 Web 开发中非常普遍的一个需求,原因无他,很多时候我们无法确定展示文本的容器的宽度,如果不使用文字截断,要么文本被生硬的截断隐藏、要么文本破坏了预期中的布局。 Tailwind CSS 提供的文字截断的原子类: .truncate { over...
继续阅读 »

文字截断是 Web 开发中非常普遍的一个需求,原因无他,很多时候我们无法确定展示文本的容器的宽度,如果不使用文字截断,要么文本被生硬的截断隐藏、要么文本破坏了预期中的布局。


Tailwind CSS 提供的文字截断的原子类:


.truncate {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}

这 3 组 CSS 规则共同作用才可实现用 ... 截断文字的效果,缺一不可。



  • overflow: hidden 表示容器空间不足时内容应该隐藏,而非默认的 visible 完全展示

  • white-space: nowrap 表示文本容器宽度不足时,不能换行,其默认值为 normal,该行为不太好预测,但大部分情况下,它等同于 wrap 即文本尽可能的换行

  • text-overflow: ellipsis 指定文本省略使用 ... ,该属性默认值为 clip ,表示文本直接截断什么也不显示,这样展示容易对用户造成误解,因此使用 ... 更合适


接下来介绍一个在 PC Web 上很实用的交互效果:在需要截断的文本后面,紧跟一个鼠标 hover 上去才会展示的按钮, 执行一些和省略文本强相关、轻操作的动作。


Untitled.gif


如图所示,鼠标 hover 表示的按钮可以用来快速的编辑「标题」。下面介绍一下它的纯 CSS 实现。


首先,我们来实现一个基础版的。


Untitled 1.gif


代码:


<div class="container">
<p class="complex-line truncate">
<span class="truncate">海和天相连成为海岸线</span>
<span class="icon">❤️</span>
</p>
<p class="truncate">鱼和水相濡以沫的世界</p>
</div>

<style>
.truncate {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.container {
max-width: 100px;
}
.complex-line {
display: flex;
}
.complex-line:hover .icon {
display: block;
}
.icon {
display: none;
}
</style>

一些重点:



  • 容器 .container 必须是宽度固定、或者最大宽度固定的,以此确保容器不会因为子元素无止境的扩充。比如你可以设置容器的 widthmax-width 。在某些情况下,即使设置了 max-width 但不生效,此时可以尝试添加 overflow: hidden

  • 含有按钮的行 .complex-line 和子元素中展示文字的标签( .complex-line 下的 span.truncate),都要添加文字截断类 .truncate

  • 按钮 .icon 默认不展示,hover 当前行的时候才展示,这个也很常规,主要通过设置其 display 属性实现。

  • 🌟 接下来一步很巧妙,就是为含有按钮的行 .complex-line ,设置其为 Flex 容器,这主要是借助 Flex Item 的 flex-shrink 属性默认值为 1 的特性,允许含有文字的 Flex Item span.truncate 在按钮 span.icon 需要展示的时候进一步缩放,腾出空间,用于完整展示按钮。


这样便实现了基础版的文字截断 + 可以自动显示隐藏按钮的交互。


接下来再看看文章开头提到的这个交互:


Untitled.gif


它和基础版的样式最大的不同就是它是多列的,这里可以使用 Grid 网格布局实现。


<div class="container">
<label>标题:</label>
<p class="complex-line truncate">
<span class="truncate">高质量人才生产总基地</span>
<span class="icon">✏️</span
</p>
<label>编号:</label>
<p class="truncate">No.9781498128959</p>
</div>

<style>
.container {
display: grid;
grid-template-columns: auto 1fr;
/** ... */
}
</style>

其他样式代码和基础版中的一致,不再赘述。


总结


为了实现本文介绍的这种交互,应该确保:



  • 容器的宽度或最大宽度应该是固定的

  • 按钮前面的那个展示文字的元素,和它们的父元素,都应该有 .truncate 文字截断效果

  • 按钮的父元素应该为 Flex 容器,使得按钮显示时,文字所在标签可以进一步缩放


作者:人间观察员
来源:juejin.cn/post/7330464865315094554
收起阅读 »

Android 开发小技巧:属性扩展让代码写起来更轻松更易读

一、优化了什么问题 在Android开发中经常碰到设置文本颜色、背景色、背景资源等,每次都要写一大堆代码,如下所示: button.apply { background = ContextCompat.getDrawable(this@MainActi...
继续阅读 »

一、优化了什么问题


在Android开发中经常碰到设置文本颜色、背景色、背景资源等,每次都要写一大堆代码,如下所示:


button.apply {
background = ContextCompat.getDrawable(this@MainActivity,R.drawable.dinosaur)
setBackgroundColor(ContextCompat.getColor(this@MainActivity,R.color.black))
}

写多了脑壳痛,那能不能像前端语言那样简洁优雅呢?之前见过一种写法就是对Int进行扩展,就会看到奇奇怪怪的代码,如下:


//扩展函数
fun Int.getColor(): Int {
return ContextCompat.getColor(appContext, this)
}

//调用
button.apply {
setBackgroundColor(R.color.purple_700.getColor())
}

那能不能把一些常用的设置控件UI的方法都改造成setter访问方法呢?


二、通过扩展属性使代码更易写易读


在XML中常用的基础控件有TextView、ImageView、Button、EditText,那先从这几个控件入手就可以了。扩展属性只能在类的基础上添加属性,并不能覆盖,这一点需要注意。只需要下面几个扩展属性的方法,代码写起来就会舒服和易读很多。


/**
* 设置文本的颜色,Button、EditText、TextView都用此方法设置textColor
*/

var TextView.textColor: Int
get() {
return this.textColors.defaultColor
}
set(value) {
this.setTextColor(ContextCompat.getColor(this.context, value))
}

/**
* 给View设置drawable和mipmap资源
* 常见的控件都是继承View,所以都能用
*/

var View.backgroundResource: Int?
get() = 0 //get方法无意义返回0
set(value) {
if (value == null) {
//去掉背景
this.background = null
} else {
this.background = ContextCompat.getDrawable(this.context, value)
}
}

/**
* 给View设置背景颜色
* 常见的控件都是继承View,所以都能用
*/

var View.backgroundColor: Int
get() {
return (this.background as ColorDrawable).color
}
set(value) {
this.setBackgroundColor(ContextCompat.getColor(this.context, value))
}

/**
* 给ImageView设置drawable和mipmap资源
*/

var ImageView.imageResource: Int?
get() = 0 //get方法无意义返回0
set(value) {
if (value == null) {
//去掉图片
this.setImageDrawable(null)
} else {
this.setImageDrawable(ContextCompat.getDrawable(this.context, value))
}
}

/**
* 点击事件。可防止重复点击。
* ClickUtils.OnDebouncingClickListener是AndroidUtilCode库中的方法,项目中一般都有
*/

var View.onClick: (View) -> Unit
get() = {} //get方法无意义返回空的lambda
set(value) {
this.setOnClickListener(object : ClickUtils.OnDebouncingClickListener() {
override fun onDebouncingClick(v: View) {
value.invoke(v)
}
})
}

在TextView中使用


textView.apply {
text = getString(R.string.app_name)
//设置文本颜色
textColor = R.color.white
textSize = 15F
//设置背景颜色
backgroundColor = R.color.black
onClick = { //防止重复点击的点击事件

}
}

在ImageView中使用


imageView.apply {
//设置背景颜色
backgroundColor = R.color.black
//设置背景资源
backgroundResource = R.drawable.dinosaur
//设置src资源
imageResource = R.drawable.dinosaur
//如果项目中有创建Drawable的方法可以继续用background
background = shape(Shape.RECTANGLE) {
solid(R.color.white))
corners(8F.dp2px)
}
onClick = { //防止重复点击的点击事件

}
}

其他控件类似。


上面只是改造了常见控件的常用属性的写法,让代码写起来更简洁,更易读,并且添加了防止重复点击的方法,不至于在其他页面又写类似的逻辑。


作者:TimeFine
来源:juejin.cn/post/7311619723317510155
收起阅读 »

消息通知文字竖向无缝轮播组件实现历程

web
背景 最近有个需求需要做一个无缝轮播的消息通知,并且需要抽离成通用组件,记录下实现这个组件的历程。 先看效果 实现过程 思考(part-1) 因为刚开始给的设计稿是没有动画效果的,我刚开始想的效果是只有红色加粗的文字轮播,其他文字不变;然后想着看下有没...
继续阅读 »

背景



最近有个需求需要做一个无缝轮播的消息通知,并且需要抽离成通用组件,记录下实现这个组件的历程。



先看效果
noticeBar.gif


实现过程


思考(part-1)



因为刚开始给的设计稿是没有动画效果的,我刚开始想的效果是只有红色加粗的文字轮播,其他文字不变;然后想着看下有没有已经实现好的轮子,找到一个 js 库:react-text-loop;但是经过我的考虑,如果只是部分文字滚动,红色加粗的文字可能宽度不一样,会导致其他文字换位,所以还是想着整条文字滚动会比较好。



使用 antd-m Swiper 实现(part-2)



想到这个滚动效果在移动端应该很常见,antd-m 应该可能会有组件吧,去瞧瞧👀;noticeBar 组件只有横向滚动文字,没有竖直滚动的。既然没有,那就用其他方式实现吧,最简单的就是用 Swiper 组件设置成 竖向滚动,因为我负责的项目基本都用 antd-m,所以就用 antd-m 的 Swiper 来实现了。



实现过程遇到的问题


antd-m 的 Swiper 组件竖向滚动必须指定高度

在使用 antd-m 的 Swiper 组件竖向滚动的方式好像有问题,但是看文档的使用又是正常,结果发现竖向滚动需要指定高度,所以文档还是要仔细看清楚: Swiper 竖向文档


依赖冲突问题

在自己仓库使用很正常,一点问题都没有;然后打算抽离到我们项目的组件库中,然后在把项目中使用替换成组件库的包,过程很顺畅;过了段时间另一个项目要使用我们的组件,然后我就把包发给他,结果他说他项目里用不了,会报错。


然后 clone 他的项目试了下,果然是有问题的,因为他们项目里用的是 antd-m 2.x,2.x 没有 Swiper 组件,而我的组件库依赖的是 antd-m 5.x,看了下他们仓库用的是antd-m 2.x 和 5.x 共存的方式,可以看一下这个 antd-m 迁移指南,如果要两个版本共存且之前用的是组件按需导入,那么组件按需导入的配置也会有问题,因为两个版本的文件差异比较大,所以需要改一下按需导入的配置:


module.exports = {
"plugins": [
// 原配置
// [
// 'import',
// {
// libraryName: 'antd-mobile',
// libraryDirectory: 'lib',
// style: 'css',
// },
// 'antd-mobile',
// ]

// 修改为
[
'import',
{
libraryName: 'antd-mobile',
customName: (name, file) => {
const { filename } = file.opts;
if (filename.includes('/es/')) {
return `antd-mobile/es/components/${name}`;
}
return `antd-mobile/lib/${name}`;
},
style: (name, file) => {
const { filename } = file.opts;
if (filename.includes('/es/')) {
// 如果项目已经有 global 文件,return false 即可
// 如果没有,这样配置后就不需要手动引入 global 文件
return 'antd-mobile/es/global';
}
return `${name}/style/css`;
},
},
'antd-mobile',
]
]
}

想彻底解决这个依赖冲突问题


其实修改完配置之后使用就正常了,但是我考虑到如果之后想使用这个组件,如果 antd-m 版本不是 5.x,那么有一个项目就要改一个配置,很烦人;而且 antd-m Swiper 竖向需要指定高度,如果都需要指定高度了,那么我直接实现一个滚动动画应该也很简单吧,说干就干。



自己手写一个轮播组件(pard-3)



手动实现轮播还是比较简单的,只不过无缝轮播那里需要处理下,传统的方式都是通过在轮播 item 首尾复制 item 插入,当轮播到最后一个,用复制的 item 来承接,之后在回归原位正常滚动。



手写实现思路



  1. 传入轮播容器的高度,使用 transform: translate3d(0px, ${容器高度}, 0px); 每次移动列表 translateY 的距离为容器的高度。

  2. 处理无缝轮播,因为这个组件没有手动滑动轮播,自由自动向下轮播,所以不需要考虑反方向的轮播处理;当轮播到最后一个 item,那么就将第一个 item transform: translateY(${轮播列表高度}),这时候第一个就在最后一个下面,监听轮播列表 onTransitionEnd,判断当前是否轮播到第一个,是的话就将轮播列表的 translateY 的距离归 0。


最终实现代码



其实最后是封装成了一个 react 组件,但是掘金上为了大家看到更好的效果,用原生的方式简单写了下。如果需要封装成 react/vue 组件参考下方代码应该就够了。



容器未 hidden 效果



组件封装的设计



这里放一下我封装组件设计的 props



interface IProps {
/** 轮播通知 list */
list: any[];
/** noticebar 显隐控制 */
visible?: boolean;
/** 单个轮播 item 的高度, 传入 750 设计稿的数字,会转成 vw */
swiperHeight?: number;
/** 每条通知轮播切换的间隔 */
interval?: number;
/** 轮播动画的持续时间 */
animationDuration?: number;
/** 是否展示关闭按钮 */
closeable?: boolean;
/** 关闭按钮点击的回调 */
onClose?: () => void;
/** 自定义轮播 item 的内容 */
renderItem?: (item: any) => React.ReactNode;
/** notice 的标题 */
noticeTitle?: ReactNode;
/** notice 右边自定义 icon */
rightIcon?: ReactNode;
/** 是否展示 notice 左边 icon */
showLeftIcon?: boolean;
/** notice 左边自定义 icon */
leftIcon?: ReactNode;
/** 自定义类名 */
className?: string;
}

作者:wait
来源:juejin.cn/post/7330054489079169065
收起阅读 »

如何通过Kotlin协程, 简化"连续依次弹窗(Dialog队列)"的需求

效果预览 代码预览 lifecycleScope.launch { showDialog("签到活动", "签到领10000币") // 直到dialog被关闭, 才会继续运行下一行 showDialog("新手任务", "做任务领20000...
继续阅读 »

效果预览


r2t33-r5h2v.gif


代码预览


lifecycleScope.launch {
showDialog("签到活动", "签到领10000币") // 直到dialog被关闭, 才会继续运行下一行
showDialog("新手任务", "做任务领20000币") // 直到dialog被关闭, 才会继续运行下一行
showDialog("首充奖励", "首充6元送神装")
}

代码实现



要做到上一个showDialig()在关闭时才继续运行下一个函数,需要用到协程挂起的特性, 然后在 OnDismiss()回调中将协程恢复, 为了将这种基于回调的方法包装成协程挂起函数, 可以使用suspendCancellableCoroutine函数



suspend fun showDialog(title: String, content: String) = suspendCancellableCoroutine { continuation ->
MaterialAlertDialogBuilder(this)
.setTitle(title)
.setMessage(content)
.setPositiveButton("我知道了") { dialog, which ->
dialog.dismiss()
}
.setOnDismissListener {
continuation.resume(Unit)
}
.show()
}

进阶玩法



跳过某些弹窗, 例如第二个弹窗只显示一次



suspend fun showDialogOnce(title: String, content: String) = suspendCancellableCoroutine { continuation ->
val showed = SPUtils.getInstance().getBoolean(title) // SharedPreferences工具类
if (showed) {
continuation.resume(Unit)
return@suspendCancellableCoroutine
}
MaterialAlertDialogBuilder(this)
.setTitle(title)
.setMessage(content)
.setPositiveButton("我知道了") { dialog, which ->
dialog.dismiss()
}
.setOnDismissListener {
continuation.resume(Unit)
}
.show()
SPUtils.getInstance().put(title, true)
}

调用时只需要这样:


lifecycleScope.launch {
showDialog("签到活动", "签到领10000币")
showDialogOnce("新手任务", "做任务领20000币")
showDialog("首充奖励", "首充6元送神装")
}

这样'新手任务'的弹窗只会首次弹出, 后续只会弹出第一和第三个


作者:Joehaivo飞羽
来源:juejin.cn/post/7275943125821571106
收起阅读 »

提升网站性能的秘诀:为什么Nginx是高效服务器的代名词?

在这个信息爆炸的时代,每当你在浏览器中输入一个网址,背后都有一个强大的服务器在默默地工作。而在这些服务器中,有一个名字你可能听说过无数次——Nginx。今天,就让我们一起探索这个神奇的工具。一、Nginx是什么Nginx(发音为“enginex”)是一个开源的...
继续阅读 »

在这个信息爆炸的时代,每当你在浏览器中输入一个网址,背后都有一个强大的服务器在默默地工作。而在这些服务器中,有一个名字你可能听说过无数次——Nginx。今天,就让我们一起探索这个神奇的工具。

一、Nginx是什么

Nginx(发音为“enginex”)是一个开源的高性能HTTP和反向代理服务器。它由伊戈尔·赛索耶夫(IgorSysoev)于2002年创建,自那时起,Nginx因其稳定性、丰富的功能集、简单的配置文件以及低资源消耗而受到广大开发者和企业的喜爱。

Description

Nginx是一款轻量级的Web服务器/反向代理服务器及电子邮件(IMAP/POP3)代理服务器,在BSD-like协议下发行。

其特点是占有内存少,并发能力强,事实上nginx的并发能力在同类型的网页服务器中表现较好,中国大陆使用nginx网站用户有:百度、京东、新浪、网易、腾讯、淘宝等。

二、Nginx的反向代理与正向代理

Description

正向代理:

我们平时需要访问国外的浏览器是不是很慢,比如我们要看推特,看GitHub等等。我们直接用国内的服务器无法访问国外的服务器,或者是访问很慢。

所以我们需要在本地搭建一个服务器来帮助我们去访问。那这种就是正向代理。(浏览器中配置代理服务器)

反向代理:

那什么是反向代理呢。比如:我们访问淘宝的时候,淘宝内部肯定不是只有一台服务器,它的内部有很多台服务器,那我们进行访问的时候,因为服务器中间session不共享,那我们是不是在服务器之间访问需要频繁登录。

这个时候淘宝搭建一个过渡服务器,对我们是没有任何影响的,我们是登录一次,但是访问所有,这种情况就是反向代理。

对我们来说,客户端对代理是无感知的,客户端不需要任何配置就可以访问,我们只需要把请求发送给反向代理服务器,由反向代理服务器去选择目标服务器获取数据后,再返回给客户端。

此时反向代理服务器和目标服务器对外就是一个服务器,暴露的是代理服务器地址,隐藏了真实服务器的地址。(在服务器中配置代理服务器)

三、Nginx的负载均衡

什么是负载均衡?

负载均衡建立在现有网络结构之上,它提供了一种廉价有效透明的方法扩展网络设备和服务器的带宽、增加吞吐量、加强网络数据处理能力、提高网络的灵活性和可用性。

负载均衡(LoadBalance)其意思就是分摊到多个操作单元上进行执行,例如Web服务器、FTP服务器、企业关键应用服务器和其它关键任务服务器等,从而共同完成工作任务。

Description

负载均衡的主要目的是确保网络流量被平均分发到多个节点,从而提高整体系统的响应速度和可用性。它对于处理高并发请求非常重要,因为它可以防止任何单一节点过载,导致服务中断或性能下降。

Nginx给出来三种关于负载均衡的方式:

轮询法(默认方法):

每个请求按时间顺序逐一分配到不同的后端服务器,如果后端服务器down掉,能自动剔除。

适合服务器配置相当,无状态且短平快的服务使用。也适用于图片服务器集群和纯静态页面服务器集群。

weight权重模式(加权轮询):

指定轮询几率,weight和访问比率成正比,用于后端服务器性能不均的情况。

这种方式比较灵活,当后端服务器性能存在差异的时候,通过配置权重,可以让服务器的性能得到充分发挥,有效利用资源。weight和访问比率成正比,用于后端服务器性能不均的情况。权重越高,在被访问的概率越大。

ip_hash:

上述方式存在一个问题就是说,在负载均衡系统中,假如用户在某台服务器上登录了,那么该用户第二次请求的时候,因为我们是负载均衡系统,每次请求都会重新定位到服务器集群中的某一个。

那么已经登录某一个服务器的用户再重新定位到另一个服务器,其登录信息将会丢失,这样显然是不妥的。

你还在苦恼找不到真正免费的编程学习平台吗?可以试试【云端源想】!课程视频、知识库、微实战、云实验室、一对一咨询……你想要的全部学习资源这里都有,重点是现在还是免费的!

点这里即可查看!

我们可以采用ip_hash指令解决这个问题,如果客户已经访问了某个服务器,当用户再次访问时,会将该请求通过哈希算法,自动定位到该服务器。每个请求按访问ip的hash结果分配,这样每个访客固定访问一个后端服务器,可以解决session的问题。

四、Nginx的动静分离

为了加快网站的解析速度,可以把动态页面和静态页面由不同的服务器来解析,加快解析速度。降低原来单个服务器的压力。

Description

Nginx的静态处理能力很强,但是动态处理能力不足,因此,在企业中常用动静分离技术。

动静分离技术其实是采用代理的方式,在server{}段中加入带正则匹配的location来指定匹配项针对PHP的动静分离:

静态页面交给Nginx处理,动态页面交给PHP-FPM模块或Apache处理。在Nginx的配置中,是通过location配置段配合正则匹配实现静态与动态页面的不同处理方式。

五、Nginx特点

那么,Nginx到底有哪些特点让它如此受欢迎呢?让我们一起来探索。

1、高性能与低消耗

Nginx采用了事件驱动的异步非阻塞模型,这意味着它在处理大量并发连接时,可以有效地使用系统资源。与传统的服务器相比,Nginx可以在较低的硬件配置下提供更高的性能。这对于成本敏感的企业来说,无疑是一个巨大的优势。

2、高并发处理能力

得益于其独特的设计,Nginx能够轻松处理数万甚至数十万的并发连接,而不会对性能造成太大影响。这一点对于流量高峰期的网站尤为重要,它可以保证用户在任何时候访问网站都能获得良好的体验。

3、灵活的配置

Nginx的配置文件非常灵活,支持各种复杂的设置。无论是负载均衡、缓存静态内容,还是SSL/TLS加密,Nginx都能通过简单的配置来实现。这种灵活性使得Nginx可以轻松适应各种不同的使用场景。

4、社区支持与模块扩展

Nginx拥有一个活跃的开发社区,不断有新的功能和优化被加入到官方版本中。此外,Nginx还支持第三方模块,这些模块可以扩展Nginx的功能,使其更加强大和多样化。

5、广泛的应用场景

从传统的Web服务器到反向代理、负载均衡器,再到API网关,Nginx几乎可以应用于任何需要处理HTTP请求的场景。它的可靠性和多功能性使得它成为了许多大型互联网公司的基础设施中不可或缺的一部分。

Nginx以其卓越的性能和灵活的配置,赢得了全球开发者的青睐。它不仅仅是一个简单的Web服务器,更是一个强大的工具,能够帮助我们构建更加稳定、高效的网络应用。

无论是初创公司还是大型企业,Nginx都能在其中发挥重要作用。那么,你准备好探索Nginx的世界了吗?让我们一起开启这场技术之旅吧!

收起阅读 »

工作而已,千万不要上头了!

工作而已,千万不要上头了!请戒掉你那些没必要的“责任心”!职场中,有这么一类人,他们善良、事无巨细、爱操心。只要是自己工作岗位和自己相关的事情,就会负责到底,60分的事情,硬要做到90分,有假期不敢请,感觉项目缺了自己就不行。本着这样「把事做成」的心态,他们经...
继续阅读 »


工作而已,千万不要上头了!

请戒掉你那些没必要的“责任心”!

职场中,有这么一类人,他们善良、事无巨细、爱操心。

只要是自己工作岗位和自己相关的事情,就会负责到底,60分的事情,硬要做到90分,有假期不敢请,感觉项目缺了自己就不行。

本着这样「把事做成」的心态,他们经常头顶
「靠谱」「务实」「件件有着落,事事回音」的标签。

最后累身累心,还不被别人看见,做的越多,错的越多。

这类人是责任心太强了!

在职场这么多年,逐渐发现,责任心很强的人,在职场上普遍都过得不好:
➢ 明明做了很多,升职加薪却轮不到自己;
➢ 领导对别人要求却越来越低,对你却要求越来越高;
➢ 事情越做越多,关键老板还不觉得你做了。

这些现象背后,其实有一个隐秘的雷区:责任心过剩,你就输了!

不要因为责任心过剩,在职场无尽消耗自己。

找到自己内耗的来源后,运用好自己的优势,只做自己擅长的事,成功在职场顺利晋升。

如果你也想找到自己那件真正要做的事,就要去掉那些没用的责任心。

来源:mp.weixin.qq.com/s/p_GAch0qjAI-Or-u5Aj8gw

收起阅读 »

npm被滥用——上传700多个武林外传切片视频

据介绍,这些软件包每个大小约为 54.5MB,包名以 “wlwz” 作为前缀,并附带了应该是代表日期的数字。时间戳显示,这些包至少自 2023 年 12 月 4 日起就一直存在于 npm,但 GitHub 上周已经开始删除。相关链接:https://blog....
继续阅读 »

Sonatype 安全研究团队近日介绍了一起滥用 npm 的案例 —— 他们发现托管在 npm 的 748 个软件包实际上是视频文件。

据介绍,这些软件包每个大小约为 54.5MB,包名以 “wlwz” 作为前缀,并附带了应该是代表日期的数字。时间戳显示,这些包至少自 2023 年 12 月 4 日起就一直存在于 npm,但 GitHub 上周已经开始删除。

每个包中都有以 “.ts” 扩展名结尾的视频剪辑,这表明这些视频剪辑是从 DVD 和蓝光光盘中翻录的。

这里的 ts 不是 TypeScript 文件,而是 transport stream 的缩写,全称为 “MPEG2-TS”:
MPEG2-TS 传输流(MPEG-2 Transport Stream;又称 MPEG-TS、MTS、TS)是一种标准数字封装格式,用来传输和存储视频、音频与频道、节目信息,应用于数字电视广播系统,如 DVB、ATSC、ISDB [3]:118、IPTV 等。

此外,某些包(例如 “wlwz-2312”)在 JSON 文件中包含普通话字幕。

虽然这些视频不会像挖矿程序、垃圾邮件包和依赖性恶意软件那样毒害社区,但这种把开源基础设施当 CDN 的操作无疑是破坏了规则,也违反了供应商的服务条款,各位耗子尾汁吧。

相关链接:https://blog.sonatype.com/npm-flooded-with-748-packages-that-store-movies

收起阅读 »

Vue 依赖注入:一种高效的数据共享方法

web
什么是vue依赖注入? Vue是一个用于构建用户界面的渐进式框架。 它提供了一种简单而灵活的方式来管理组件之间的数据流,即依赖注入(Dependency Injection,DI)。 依赖注入是一种设计模式,它允许一个组件从另一个组件获取它所依赖的数据...
继续阅读 »

什么是vue依赖注入?



Vue是一个用于构建用户界面的渐进式框架。




它提供了一种简单而灵活的方式来管理组件之间的数据流,即依赖注入(Dependency Injection,DI)。



依赖注入是一种设计模式,它允许一个组件从另一个组件获取它所依赖的数据或服务,而不需要自己创建或管理它们。这样可以降低组件之间的耦合度,提高代码的可维护性和可测试性。


依赖注入示意图


在这里插入图片描述

provide和inject



在Vue中,依赖注入的方式是通过provide和inject两个选项来实现的。




provide选项允许一个祖先组件向下提供数据或服务给它的所有后代组件。
inject选项允许一个后代组件接收来自祖先组件的数据或服务。
这两个选项都可以是一个对象或一个函数,对象的键是提供或接收的数据或服务的名称,值是对应的数据或服务。函数的返回值是一个对象,具有相同的格式。



下面是一个简单的例子,演示了如何使用依赖注入的方式共享数据:


父组件


<template>
<div>
<h1>我是祖先组件</h1>
<child-component></child-component>
</div>
</template>

<script>
import ChildComponent from './ChildComponent.vue'

export default {
name: 'AncestorComponent',
components: {
ChildComponent
},
// 提供一个名为message的数据
provide: {
message: 'Hello from ancestor'
}
}
</script>

子组件


<template>
<div>
<h2>我是后代组件</h2>
<p>{{ message }}</p>
</div>
</template>


// 后代组件
<script>
export default {
name: 'ChildComponent',
// 接收一个名为message的数据
inject: ['message']
}
</script>

这样,后代组件就可以直接使用祖先组件提供的数据,而不需要通过props或事件来传递。


需要注意的是,依赖注入的数据是不可响应的,也就是说,如果祖先组件修改了提供的数据,后代组件不会自动更新。
如果需要实现响应性,可以使用一个响应式的对象或者一个返回响应式对象的函数作为provide的值。


实现响应式依赖注入的几种方式


一、提供响应式数据



方法是在提供者组件中使用ref或reactive创建响应式数据,然后通过provide提供给后代组件。后代组件通过inject接收后,就可以响应数据的变化。



提供者:


<template>
<div>
<h1>我是提供者组件</h1>
<button @click="count++">增加计数</button>
<child-component></child-component>
</div>

</template>

<script>
import ChildComponent from './ChildComponent.vue'
import { ref, provide } from 'vue'

export default {
name: 'ProviderComponent',
components: {
ChildComponent
},
setup() {
// 使用ref创建一个响应式的计数器
const count = ref(0)
// 提供给后代组件
provide('count', count)
return {
count
}
}
}
</script>


接收者:


<template>
<div>
<h2>我是接收者组件</h2>
<p>计数器的值是:{{ count }}</p>
</div>

</template>

<script>
import { inject } from 'vue'

export default {
name: 'ChildComponent',
setup() {
// 接收提供者组件提供的响应式对象
const count = inject('count')
return {
count
}
}
}
</script>


二、提供修改数据的方法



提供者组件可以提供修改数据的方法函数,接收者组件调用该方法来更改数据,而不是直接修改注入的数据。



提供者:


<template>
<div>
<h1>我是提供者组件</h1>
<p>消息是:{{ message }}</p>
<child-component></child-component>
</div>

</template>

<script>
import ChildComponent from './ChildComponent.vue'
import { ref, provide } from 'vue'

export default {
name: 'ProviderComponent',
components: {
ChildComponent
},
setup() {
// 使用ref创建一个响应式的消息
const message = ref('Hello')
// 定义一个更改消息的方法
function updateMessage() {
message.value = 'Bye'
}
// 提供给后代组件
provide('message', { message, updateMessage })
return {
message
}
}
}
</script>


接收者:


<template>
<div>
<h2>我是接收者组件</h2>
<p>消息是:{{ message }}</p>
<button @click="updateMessage">更改消息</button>
</div>

</template>

<script>
import { inject } from 'vue'

export default {
name: 'ChildComponent',
setup() {
// 接收提供者组件提供的响应式对象和方法
const { message, updateMessage } = inject('message')
return {
message,
updateMessage
}
}
}
</script>


三、使用readonly包装



通过readonly包装provide的数据,可以防止接收者组件修改数据,保证数据流的一致性。



提供者:


<template>
<div>
<h1>我是提供者组件</h1>
<p>姓名是:{{ name }}</p>
<child-component></child-component>
</div>

</template>

<script>
import ChildComponent from './ChildComponent.vue'
import { ref, provide, readonly } from 'vue'

export default {
name: 'ProviderComponent',
components: {
ChildComponent
},
setup() {
// 使用ref创建一个响应式的姓名
const name = ref('Alice')
// 使用readonly包装提供的值,使其不可修改
provide('name', readonly(name))
return {
name
}
}
}
</script>


接收者:


<template>
<div>
<h2>我是接收者组件</h2>
<p>姓名是:{{ name }}</p>
<button @click="name = 'Bob'">尝试修改姓名</button>
</div>

</template>

<script>
import { inject } from 'vue'

export default {
name: 'ChildComponent',
setup() {
// 接收提供者组件提供的只读对象
const name = inject('name')
return {
name
}
}
}
</script>


四、使用<script setup>



<script setup>组合式写法下,provide和inject默认就是响应式的,无需额外处理。



总结



依赖注入的方式共享数据在Vue中是一种高级特性,它主要用于开发插件或库,或者处理一些特殊的场景。



作者:Yoo前端
来源:juejin.cn/post/7329830481722294272
收起阅读 »