注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

最近很多人都在说 “前端已死”,讲讲我的看法

web
我记得去年脉脉的论调还都是 客户端已死,前后端还都是一片祥和,有秀工资的,有咨询客户端转前端的,怎么最近打开脉脉一看,风向变了?我有时候会想,开源是个好东西,拉低了技术的门槛,好像再难的需求,只要善用搜索引擎,都能找到前人喂到嘴边的答案,如果没有开源文化,甚至...
继续阅读 »

现状

我记得去年脉脉的论调还都是 客户端已死,前后端还都是一片祥和,有秀工资的,有咨询客户端转前端的,怎么最近打开脉脉一看,风向变了?

随便刷几下,出来的信息都是 前端已死,这种悲观信息,还有失业找不到工作的。


思考

我有时候会想,开源是个好东西,拉低了技术的门槛,好像再难的需求,只要善用搜索引擎,都能找到前人喂到嘴边的答案,如果没有开源文化,甚至自己能不能进入互联网开发行业都不好说。

记得那一年:

我第一次接触技术领域,开始在百度输入前端开发相关的问题,发现出来的结果都是 csdn、博客园、简书等网站的内容,打开了新天地,我开始吸取前辈们的经验和心得,他们分享的各种书上学不到的知识让我解决了工作中遇到的各种问题,让我感叹 开源真好

我发现程序员们都推崇谷歌搜索和谷歌浏览器,于是自己也开始使用,发现 真香

我日夜沉迷在技术研究上,为自己找到了一份兴趣爱好和职业相结合的工作而庆幸和兴奋;

大佬们都说要追求 高复用、高内聚、低耦合、易拓展,于是我忙不停蹄的学习这些概念和应用实践;

后来社区开始讨论 低代码,自动化,人工智能,大家好像都蛮兴奋的;

后来听说 客户端开发 不行了,小程序、h5 分走了大量的市场需求,我很庆幸,当初选择了前端,但我隐隐有些不安,因为我发现自己达到了 瓶颈,从业务开发中已经难有太多的技术提升了;

我开始学习 Java, springboot + mysql + mybatisPlus 做了一些简单的 crud,貌似并不是很难,我又有了些自信,以后前端干不了了,还可以搞后端;

我相信 如果一种思想不能拿出来给公众思辨,那么它和不存在没什么两样,所以我开始写博客,我认为这是最好的学习方式,但是我还不太擅长,22年一年才写了20+篇;

然后 chatGPT 出来了,我注册体验了一番,它真的可以在很多场景下协助我提升效率,这点资本也看得到,所以直接后果就是市场可能不需要那么多 开发者了, 这不局限在前端,甚至 chatGPT 更擅长写后端代码;

最后,互联网市场已进入红海之争了,存量市场下,开源节流是正常的操作,去年就经常看到外国各大大厂裁员的消息,国内就更不可能独善其身了;

总结

若没有开源文化,会不会互联网开发,也是一个越老越吃香的职业呢 [微笑]?我不知道,我是开源的受益者,我也愿意为开源做贡献,但是我不会期待它能给我带来多大的商业收益了,开源和商业付费之间,是两种文化之争;

最近core-js 作者对开源社区的“控诉”更是印证了我的看法: core-js 作者快被缺钱“拖垮”了:全职做开源维护 9 年,月均收入从 2500 美元锐减到 400 美元

前端已死 更多的是一种焦虑情绪的表达,市场确实不太好,但这并不是针对前端,整个互联网行业衰败的表现而已,对此持不同意见的怕是只剩培训机构了吧;

前端老鸟,市场还是需要和欠缺的,只是对于初中级前端太卷了,我建议应届生不要继续入门前端了,搞搞嵌入式开发,或者芯片之类的,门槛高一些。

总之,不用过于悲观,互联网风口过去了,还会有下一个风口,比如 web3, 人工智能,都可能带来新的市场机会,作为时代前沿的参与者,程序员因该更容易抓住这样的机会吧。

作者:Ethan_Zhou
来源:juejin.cn/post/7201047960826052667

收起阅读 »

24 岁技术人不太平凡的一年

一年前,我整夜混迹于魔都淮海中路的酒吧迪厅,而白天,在外滩海景餐厅吃着四位数一顿的西餐。租住的洋房里陈列着各种奢侈品…… 一年后,我结婚了,并且买了套房子。 一、魔都 2021 年,是我爸妈非常兴奋的一年。因为那年我去了上海,找了一份年薪 60 几万的工作。他...
继续阅读 »

一年前,我整夜混迹于魔都淮海中路的酒吧迪厅,而白天,在外滩海景餐厅吃着四位数一顿的西餐。租住的洋房里陈列着各种奢侈品……


一年后,我结婚了,并且买了套房子。


一、魔都


2021 年,是我爸妈非常兴奋的一年。因为那年我去了上海,找了一份年薪 60 几万的工作。他们可以和邻居亲戚们炫耀他们的儿子了。



但我没什么感觉,对我而言,60 万只是手机上的一串数字。而且我知道,60 万并不多。因为那个整日听我汇报的、大腹便便的、看上去不怎么聪明的中年人拿着远比我高的薪水,我和他都一样,只是一条资本家的狗而已。


我一向不打算结婚,也不打算要孩子,也就意味着我不需要存钱。


我不会像我的领导那样为了在上海买一套 1200 万的房子而过得那样抠搜。我可以花一万多租一个小洋房,我可以在家里塞满那些昂贵的电器,我可以每周去国金、恒隆、新天地消费。我会买最新款的苹果全家桶,甚至连手机壳都要买 Lv 的。


但即使这样,钱还是花不完。


别人都因为没钱而烦恼,而我却因为钱花不完而烦恼。


后来,在机缘巧合之下,我开始了绚丽多彩的夜生活。在夜店,只需要花 1 万块钱,就可以当一晚上的王。这种快感是难以描述的。我成了 Myst、LolaClub、Ak、wm、pops 们的常客。开始了一段纸醉金迷的日子。


二、求职


找工作是一件再容易不过的事,因为几乎所有人都有工作。人只要降低要求,就一定能够找到工作。找工作难,是因为你渴望的工作岗位所需要的能力与你当前的能力并不匹配。


刚来到上海的时候,我非常自信,整天到处闲逛。朋友问我为什么不准备面试题?我说没有这种必要,他说不是很理解,我说那算了,不用理解。


因为技术上的知识,就那么多。你有自己的理解,有自己的经验,完全不需要胆怯,也没必要胆怯。合适就去,不合适就再看看。


能否找到好工作,更多的是你和面试官是否聊得来;你所做的事情,是否和这家公司的业务所匹配;你的经历、价值观和这家公司的愿景使命价值观是否契合。


我清楚我的能力究竟如何,我知道我应该去哪种公司。


但我需要了解这些公司的不同。于是我投了很多简历,大小公司都有。像携程、美团、得物、拼多多、小红书、微盟这些公司基本上都有去面试。跑了很多地方,徐家汇、北外滩、五角场、陆家嘴、奉贤岛、静安寺……。


全职面试,一天四五场,很忙。


但不到一周就确定好了 offer。


朋友说我是一个果断的人,我只是觉得没必要消耗时间。


明白自己的能力上限在哪,懂得在合适的环境最大化释放自己的价值。就一定可以收获一份合适的工作,或者说是一份事业。


找到自己的使命,就可战无不胜。


三、爱情


人缺什么,就会去寻找什么。


在 24 岁的年纪里,我缺少的不是梦想、金钱或者使命感,而是爱情。


我遇到了 Emma,她带给我的滋味,与我所有的想象都不一样。


我在上海,她在三亚。我们在相隔千里的城市,我坐着飞机去找她。结果因为晚了几分钟,于是我买了张全价票,硬是要在当天飞过去,因为我等这一天已经等了半个月了。


我们住在三亚的希尔顿。在三亚有两个希尔顿,结果她找错了,去了另一个希尔顿,后来我们很庆幸幸亏没有人开门。


虽然 Emma 笨笨的,但我喜欢她这样。


我们去了亚龙湾、天涯海角和南海观音。


三亚虽然是中国最南方的岛最南侧的城市,但有个外号是东北第四城。前三个是黑龙江、吉林和辽宁。一个城市的名字可能是错的,但外号绝对不会错。海南到处都是东北人。


爱情刚开始都会轰轰烈烈,我会给 Emma 精心挑选手链,去国金帮她挑选面膜,亲手给她写情书,那些情书至今还在我们的衣柜里。后来去全上海只有两个店的 Graff 买了一对情侣对戒。


我不介意给心爱的女人花钱。我觉得,一个男人把钱都花在哪儿了,就知道这个男人的心在哪儿。


当然,认识久了,特别是同居之后,这种轰轰烈烈式的浪漫就会褪去,更多的是生活中的点点滴滴。


有人说过一句话:


和喜欢的人在一起的快乐有两种,一种是一起看电影旅行一起看星星月亮分享所有的浪漫。还有一种则是分吃一盒冰淇淋一起逛超市逛宜家把浪漫落地在所有和生活有关的细节里。


但无论是前者还是后者,我都想和你一起,因为重要的是和你,而不是浪漫。


往事历历在目,虽然现在很难再有当时的那种感觉,可我更享受当下的温馨。


四、旅游


认识 Emma 之后,我们开始养成了旅游的习惯,每个月都会留出两三天时间一起出去旅游。


2 月,去了丽江。爬了玉龙雪山、逛了拉市海和木府。



玉龙雪山真的很神奇,在丽江市任何一个角落,都可以抬头看见白雪皑皑的雪山。


2 月 14 号是情人节,我在丽江的街头给 Emma 买了一束玫瑰。



3 月,我在上海,本来打算去北京,结果发生航变。然后再打算做高铁去杭州西湖,结果杭州顺丰出事了。哪都去不了,只能在上海本地旅游。去了陆家嘴的上海中心大厦。其实很早之前我就和朋友去过一次了,这次去的主要目的是和 Emma 锁同心锁。


我记得我们去的时候,已经是晚上九点五十,工作人员说十点关门,因为疫情原因,明天开始将无限期关停,具体开放时间等通知。如果现在上去,我们只能玩十分钟。没有犹豫,花了四百多买了两张票上去上了把锁。



那时我还不知道,这种繁华将在不久后彻底归于死寂。


后面几天,我们在上海很多地方溜达,外滩、豫园、迪士尼乐园。


迪士尼乐园是上海人流量最大的游乐场,几乎全年都是爆满。但这次疫情期间的迪士尼乐园的游客很少,很多项目都不需要排队。


但那天真的很冷,我们为了看烟花冻得瑟瑟发抖。夜晚的迪士尼城堡真的很美。



4-7 月上海封城,哪儿也没去。


8 月去了博鳌亚洲论坛。如果不是去开会的话,真没什么好玩的。



9 月打算坐轮船去广州徐闻、再坐绿皮火车去拉萨,因为我们认为这样会很浪漫。结果因疫情原因,没办法离开海南,只去了骑驴老街和世纪大桥旁边的云洞书店。




这座书店建在海边,进入看书要提前预约。


实际上没几个看书的,全是美女在拍照。


10 月去了济南大明湖和北京的天安门广场、八大胡同等地方。


很没意思,图我都不想放。


预约了故宫门票,并且打算去八达岭长城。但是在北京土生土长四十年的同事告诉我,他从来没去过故宫,并且不建议去八达岭长城。于是我把故宫的门票退了,并且把八达岭长城的计划也取消了。


11 月一整个月都在忙着买房子的事情,天天跟着中介跑,哪儿也没去。


五、封控、方舱、辞职与离沪


上海疫情暴发,改变了很多事。


这一部分我删掉了,涉及敏感话题。


总结来说就是:封控,挨饿,感染奥密克戎,进方舱,出舱,继续封控。


6 月 1 日,上海终于开放了。


虽然可以离开小区,但离开上海还是很难。


首先我需要收拾家里的东西,然后需要寄回我爸妈家,但当时所有的物流都处于瘫痪状态。当时我也想过直接丢掉,但我的东西真的很多,有二十多个箱子,差不多是我的全部家当了。


再之后需要购买飞机票,但所有的航班都在无限期航变。我给飞机场的工作人员打了很多次电话,没事就问。后来她们有些不耐烦了,告诉我,现在没有一架飞机能从上海飞出去,让我不要再打了。


可我不死心。


最后我选择了乘坐火车。


上海没有直达海口的火车,所以需要在南昌中转。由于我是上海来的,所以当地的政策是要隔离 7 天,除非你不出火车站。上海到南昌的火车是第一天下午到,而南昌到海口的火车是第二天的中午才出发。这也就意味着我要在南昌站睡一晚。而从上海到海口的整个路程,需要花费 3 天。这对以前的我来说是不可思议的。


但爱情赋予我的力量是难以想象的。


我在火车站旁边的超市买了一套被子就上了去南昌的火车,然后躺在按摩椅上睡了一晚。


第四天一大早,我终于到了海南。


六、求职


2022 年,是非常难找工作的。


特别是我准备搬到海南。海南是没有大型互联网公司的,连个月薪上万的工作都找不到。这样估计连自己都养不活。


所以我准备找一份远程工作。相比较于国内,国外远程工作的机会明显更多,可惜我英语不好,连一场基本的面试所需要的词汇都不够。所以我只能找一些国内的远程机会。


机缘巧合之下,终于找到了一家刚在美国融到资的创业公司。


他们需要一位带有产品属性和创造力的前端架构师。前前后后面了大概一周,我成功拿到 offer。这是我第一次加入远程团队。


印象中面试过程中技术相关的点聊了以下几点:



  • WebSocket 的弊端。

  • Nodejs 的 Stream。

  • TLV 相比 JSON 的优劣势。

  • HTTP 1.1/2 和 HTTP3(QUIC)的优劣势。

  • 对云的看法。

  • 真正影响前端性能的点。

  • 团队管理方面的一些问题。


因为公司主要业务是做云,所以大多数时间都是在聊云相关的问题。老板是一个拥有 20 年技术背景的老技术人,还是腾讯云 TVP。在一些技术上的观点非常犀利,有自己的独到之处。这一点上我的感受是非常明显的。


全球化边缘云怎么做?


现代应用的性能瓶颈往往不再是渲染性能。但各大前端框架所关注的点却一直是渲染。以至于现代主流前端框架都采用 Island 架构。


但另一个很影响性能的原因是时延。这不仅仅是前端性能的瓶颈,同时也是全球化分布式系统的性能瓶颈。


设想一下,你是做全球化的业务,你的开发团队在芝加哥,所以你在芝加哥买了个机房部署了你们的系统,但你的用户在法兰克福。无论你的系统如何优化,你的用户总是感觉卡顿。因为你们相隔 4000 多英里的物理距离,就一定要遭受 4000 多英里的网络延迟。


那该怎么办呢?很简单,在法兰克福也部署一套一模一样的系统就好了,用户连接距离自己最近的服务器就好了,这样就能避免高网络时延,这就是全球分布式。但如果你的用户也有来自印度的、来自新加坡、来自芬兰的。你该怎么办呢?难道全球 200 多个地区都逐一部署?那样明显不现实,因为成本太高了,99% 的公司都支付不起这笔费用。


所以很多云服务商都提供了全球化的云服务托管,这样就可以以低成本实现上述目标。


但大多数云服务商的做法也并不是在全球那么多地区部署那么多物理服务器。而是在全球主要的十几个地区买十几台 AWS 的裸机就够了。可以以大洲维度划分。有些做得比较好的云服务商可能已经部署了几十个节点,这样性能会更好。


这类云服务商中的一些小而美的团队有 vercel、deno deploy。这也是我们竞争目标之一。


招聘者到底在招什么人?


当时由于各种裁员,大环境不好,面试者非常多,但合适的人又非常少。后来据老板透露,他面试了 48 个人,才找到我。起初我还不信,以为这是常规 PUA,后来我开始负责三面的时候,在面试登记表中发现确实有那么多面试记录。其中竟然有熟人,也有一个前端网红。


后来我总结,很多人抱怨人难招,那他们到底在招什么人?


抛开技术不谈,他们需要有创造力的人。


爱因斯坦说:想象力比知识更重要。想象力是创造力的要素之一,也是创造力的前提。


那些活得不是很实际的人,会比那些活得踏踏实实的人有更大的机会去完成创造。


为什么要抛开技术不谈?因为职业生涯到了一定高度后,已经非唯技术论了。大家的技术都已经到了 80 分以上,单纯谈论技术,已经很难比较出明显差异,这时就要看你能用已有的技术,来做点什么,也就是创造力。这也是为什么我在文中没怎么谈技术面试的原因。


如果将马斯洛需求理论转化为工程师需求理论,那么同样可以有五级:




  1. 独立完成系统设计与实现。




  2. 具有做产品的悟性,关注易用、性能与稳定。




  3. 做极致的产品。对技术、组织能力、市场、用户心理都有很全面深入的理解。代表人物张小龙。




  4. 能够给世界带来惊喜的人。代表人物沃兹尼亚克。




  5. 推动人类文明进步,开创一个全新行业的人。代表人物马斯克。




绝大多数人都停留在前三个维度上,我也不例外。但我信奉尼采的超人主义,人是可以不断完善自我、超越自我、蜕变与进化的。既然沃兹尼亚克和马斯克能够做到的事情,我为什么不能去做呢?所以我会一直向着他们的方向前进。或许最终我并不能达到那种高度,但我相信我仍然可以做出一些不错的产品。


七、创业


搬到海南之后,时间明显多了起来。不需要上下班通勤,不去夜店酒吧,甚至连下楼都懒得去。三五天出去一次,去小区打一桶纯净水,再去京东超市买一大堆菜和食物,囤在冰箱。如果不好买到的东西,我都会选择网购。我一般会从山姆、网易严选、京东上面网购。


九月份十月份,海南暴发疫情,一共举行了十几轮全员核酸检测,我一轮都没参加。主要是用不到,出不去小区没关系,外卖可以送到小区门口,小区内的京东超市也正常营业,丝毫不影响我的日常起居。


所以我盘算着要做些事情。刚好之前手里经营着几个副业,虽然都没有赚到什么钱。但是局已经布好了。其中一个是互联网 IT 教育机构。是我和我的合伙人在 20 年就开始运营的,现在已经可以在知乎、B 站等渠道花一些营销费用进行招生。我们定价是 4200 一个月(后来涨价到 4800),一期教三个月左右。通过前几期的经验,我们预估,如果一期有十几个人到二十个人报名,就有大概将近 10 万多的收入,还是蛮可观的。所以我准备多花些精力做一下这个教育机构。


培训的内容是前端,目标是零基础就业。因为我在前端这方面有很大的优势,而且当时前端招聘异常火热。初级前端工程师需要掌握的技能也不多,HTML、CSS、JavaScript、Git、Vue,就这么几块内容而已。相比较成型慢的后端,前端是可以速成的,所以我认为这条路非常可行。


我负责的这一期,前期招了将近 20 人。



而下一期还没开课,已经招到了 5 个人。一切都向着好的方向发展。


但很快,十月、十一月左右,招生突然变难了。连知乎上咨询的人都明显变少了。我们复盘了下原因,原来是网上疯传现在是前所未有的互联网寒冬,加上各种大厂裁员的消息频发,搞得人心惶惶。甚至有人扬言,22 年进 IT 行业还不如去养猪。


因为我的合伙人是全职,他在北京生活花费很高,加上前期没有节制地投入营销费用,他已经开始负债了。但是营销费用是不能停的,一旦知+ 停掉,就很难继续招生。


后来经过再三讨论,最终还是决定先停掉。虽然我们笃定 IT 培训是红海,但感觉短期的大环境不行,不适合继续硬撑下去。



目前我带着这期学员即将毕业,而我们也会在 23 年停止招生。


培训的这个过程很累,一边要产出营销内容,一边要完善和设计教材、备课、一边还要批改作业。同时每天完成两个小时的不间断直播授课,不仅仅是对精力的考验,也是对嗓子的考验。


虽然创业未成,但失败是我们成长路上的垫脚石,而且这也谈不上什么失败。


我把经验教训总结如下,希望对你有所启发。


1. 市场和运营太重要了


中国有句谚语叫“酒香不怕巷子深”。意思是如果酒真香,不怕藏于深巷,人们会闻香而至。除了酒,这句话也隐喻好产品自己会说话,真才子自有人赏识。


但实际上,这是大错特错。


随着我阅历、知识、认知的提升,我越发觉得,往往越是这种一说就明白、人人都信的大道理,越是错的。


傅军老师讲过一句话:感受不等于经验、经验不等于知识、知识不等于定律、定律不等于真理。我深受启发。在这个快速发展的信息时代,必须保持独立思考的能力。


我思考的结果是:酒香也怕巷子深。


连可口可乐、路易威登、香奈儿这种世界顶级品牌每年都需要持续不断地花费大量的费用用于营销。由此可见营销的重要性,营销是一定有回报的。


我们的酒要香,同时也不能藏在巷子里。而且,我们的酒不需要特别香,只要不至于苦得无法下咽,就能卖出去。畅销品不一定就是质量好,某些国产品牌的手机就是一个简单的例子。


所以,只需要保证产品是 60 分以上就足够了,剩下的大部分精力都应该放在营销上面。绝大多数只懂得专注于打造 100 分产品的创业公司,基本上都死了。


正确的路径应该是:Poc、Alpha、Beta、MVP。就像下图所示,造车的过程中,产品在任何一个时间点都是可用的。



另外,决定一个公司成功的,一定是运营部或者市场部,绝对不是技术部。但是技术部有可能会让一个公司走向失败。如果一个公司要裁员,技术部门很可能首当其冲。当然这也要看公司文化,但大部分的 Boss 普遍更看重运营与市场。


2. 不要等万事俱备再开始


在创业早期,我们的主要招生渠道是 B 站,但我没有短视频经验。于是我买了暴漫 B 站 UP 主的课程。



本来以为买了课程就意味着学会了做视频,但实际上不是这样的。


做好一个 UP 主需要掌握非常多的知识,选题、文案、表达、拍摄、剪辑、互动,各个环节都有大学问。我很快发现不可能把整个课程全部研究透再去做视频。我需要保持频率地更新视频才能有更多人的关注。


随着业务的发展,后面主战场转到了知乎,工作内容也变成了软文和硬广。UP 主的很多技能还没来得及实践就被搁置了。


知乎也有一个写作训练营,学费大概也要三四千的样子。



我听了试听的三节课,感觉要学习的东西一样很多,所以我没有买这个课程。因为我知道我没有那么多精力。


最重要的是,我发现即使我没有什么技巧和套路,我写的软文一样有很多浏览量和转化率。知+平台的数据可以直接告诉我这篇文章或者回答写得好不好。


同时,很多相关的知识不是一成不变的,它们不是传统知识,需要从实际事物中脚踏实地地学习,我们没办法系统学习。


我们不可能做好所有的准备,但需要先去做。人生也该如此,保持渐进式。


3. 接受糟糕,不要完美主义


初创公司很多东西都是混乱的,缺乏完善的系统和结构,一切似乎都是拍脑袋决定。


报销很混乱、营销费用很混乱、内容管理很混乱、合同很混乱;甚至连招生清单都和实际上课的人对不上……


但我发现这是一个必须接受的现状,如果一切都井井有条,那就不是初创公司了。所以必须要适应这种乱糟糟、混乱的环境。


八、结婚与买房


朋友说我是一个果断的人。


因为从认识,到结婚、买房,一共用了不到一年时间。


其实起初我是强烈不建议买房的,我不看好中国的楼市,而且我们一个月花几千块租房住得非常舒服。而且我的父母在北方的城市也已经有两套房子了。但 Emma 认为没有房子不像个家,没安全感。安全感对一个女人实在太重要了,我想以她对我的了解,她怕我哪天突然悄无声息地就离开她。想了想,确实是这样,所以我就买了。


我们看了海口很多套房子,本来计划买个小两居,但看了几次,都觉得太紧凑了。我是要在家工作的,我有一张很大的双人桌,要单独留一个房间来放它。最后挑了一个三居室的房子。这种感觉和买电脑差不多,本来一台普通 Thinkpad 的预算,最终买了个 MacBookPro 顶配。



房子很漂亮,我们也很喜欢。



不过呢,同时也背负了两百万的贷款。不能再像以前那样随便花钱了。


买房只需要挑房屋布局、装修和地段,其他不需要关心。


买房贷款的注意事项:选择分期,选择还款周期最久的,不要提前还。今年由于疫情原因,房贷大概是 4.5 个点,比前几年要低。买房前保持一年流水,收入大于月供 3 倍。


还需要注意,买完房,还要考虑装修费用和家电的费用。


非刚需不建议买房,刚需的话不会考虑买房的弊端。


至于为什么结婚?


我和 Emma 都是不婚主义者。但是买房子如果不结婚,就只能写一个人的名字。但我们都不愿意写自己的名字。最后没办法,决定去办理结婚证。


但我们没有举办传统的婚礼,我们都不喜欢熙熙攘攘、吵吵闹闹。我们只想过好我们自己的生活,不希望任何人来打扰我们。


这辈子我搬了至少十次家,只有这次是搬进自己的家。


九、学习


今年在学习上主要有四个方面:



  • Web3。

  • 英语。

  • 游戏开发。

  • 自媒体。


目前对我来说,Web3 是最重要的事情。虽然我还没有 All in web3,但也差不多了。有人说 Web3 是骗局,但我认为不是骗局。


我承认 Web3 有非常多的漏洞,比如 NFT 的图片压根没上链,链上存储的只是一堆 URL。同时几乎所有用户都没有直接与区块链交互,而是与一堆中心化的平台交互。比如我们使用 MetaMask,其实数据流过了 MetaMask 和 Infura。目前的 Web3 并没有完全去中心化。


没有一个新鲜事物在一诞生就是完美的。正是由于这些不完美的地方,所以才需要我们去完善它们。


目前我已经加入了一个 Web3 团队。虽然团队不大,但是他们做的事情非常吸引我。Web3 是一个高速发展的火箭,对现在的人来说,你先上去再说,何必在乎坐在哪个位置上?


我很期待 2030 年的 Web3。


如果要进入 Web3,需要学习技术有很多,我把一些重要的技术栈列举如下:



  • Web3.js or Ethers.js or Wgami:与区块链交互的前端 SDK。

  • Solidity:智能合约编程语言。

  • Hardhat:以太坊开发环境。

  • IPFS:Web3 文件存储系统。


除了上述的技术之外,更多的是概念上的理解,比如 NFT、GameFi 相关的诸多概念等。


学习英语是因为我打算出国,同时我现在的工作环境也有很多英语,我不想让语言成为我继续提升的障碍。


至于游戏开发和自媒体,纯粹是想赚钱。


游戏开发主要是微信小游戏,靠广告费就能赚钱。微信小游戏的制作成本很低,两个人的小型团队就可以开发数款微信小游戏。而且赚钱和游戏质量并不成正比。跳一跳、羊了个羊都是成功案例。当然我的目标不是做那么大,大概只需要做到它们几百分之一的体量就非常不错了,要知道微信有将近 10 亿用户,总有人会玩你的游戏。


另外我学习自媒体和微信小游戏的原因差不多。自媒体的盘子越来越大。在 B 站、抖音这些短视频平台上,有着上千亿甚至更多的市场。这给更多人创造了创造收入的空间。


只要去做,总会有所收获。


在学习的过程中,我会记录大量笔记以及自我思考。但很少会更文。


大概在 9 月份,我参加了掘金的两次更文活动,输出了一些内容。


这个过程很累,但也有所收获。






后来考虑到时间和精力问题,所以很少更文了。


很遗憾,掘金目前只能依靠平台自身给创作者奖励,收入非常有限。如果掘金能够将变现程度发展成像 B 站、公众号或抖音那样,估计也会有更多的创作者加入。连 CSDN、博客园这些老牌产品都做不到。究其原因,还是这种性质的产品受众没有那么广泛,盘子太小了,体量自然无法涨上去。希望未来掘金能够找到属于自己的商业模式。


最后还是很感谢掘金这个平台,给了技术人们一个分享交流的空间。


十、未来


未来三年的计划是出国读硕,并全面转入 Web3 领域。


我不看好中国的经济,并且感觉在中国做生意会越来越难做。而且我更喜欢西方文化、氛围和环境。


在经历了国内疫情反复封控之后,我更加坚定了出国的打算。我不想继续生活在大陆。


我想看看外面的世界,同时系统化地提升一下自己的 CS 知识。


目前出国的思路是申请混合制研究生,F1、OPT、H1B、Green Card。目标是一所在 Chicago QS TOP500 垫底的高校。


另一条出国的路线是直接出国找一份美国的工作,但对目前的我来说是相当难,主要还是因为语言。


之所以是未来三年的计划,也是因为我的英语实在太差,目前按照 CEFR 标准,只达到了 A2。


按照 FSI 英语母语者学习外语的难度排名,中文这类语言是最难学习的。反过来,中文母语者学习英语,也是最难的。真正掌握大概需要 2200 小时,如果每天学习 3-4 小时的话,需要 88 周,将近两年。


FSI 的图片我删掉了,因为那张图上的中国版图有争议。


我的语言天赋很一般,甚至有些差。所以学习效果并不理想。我也不打算短时间内冲刺英语,因为那不太靠谱。我选择花更久的时间去磨它。


之前也想过去 Helsinki,但那边收入不高,税收太高。纠结了很久,还是觉得现在还年轻,多少还是应该有些压力的,去那种地方实在太适合养老了。


以上并不是故事,是我的亲身经历,分享出来的初衷是为大家提供更多职业和人生的思路与思考。希望对你有所帮助!




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

做软件开发20年,我学到的20件事

本文已获得原作者Justin Etheredge的翻译授权 原文链接 写在前面  你即将读的这篇文章会给你很多建议,向前辈学习对于获得成功是很有帮助的,但我们经常会忘记很重要的一点,就是几乎所有的建议都是有其特定场景和上下文的,但当人们给出建议的时候却很少带着...
继续阅读 »

本文已获得原作者Justin Etheredge的翻译授权 原文链接


写在前面


 你即将读的这篇文章会给你很多建议,向前辈学习对于获得成功是很有帮助的,但我们经常会忘记很重要的一点,就是几乎所有的建议都是有其特定场景和上下文的,但当人们给出建议的时候却很少带着上下文。
 一家一直以来以“收费低”而获得成功,并经营了二十年的公司可能会给出的建议是“你只需要多收些钱!”。
 “你需要把所有应用都构建成微服务”这个建议可能来自于一个靠快速构建的单体应用获得成千上万客户,在开始遇到规模问题时转向微服务的团队。


 如果不理解上下文,这些建议就毫无意义,甚至是有害的。如果这些人早些时候听从了自己的建议,那结果如何也很难讲。我们很可能处在自己经历的顶峰,但却在用现在的视角看待别人的问题。


 首先我想介绍一下我的建议从哪儿来,我职业生涯的前半段是一名软件工程师,为各种小型企业和初创企业工作,然后进入咨询行业,并在一些非常大的企业工作。后来自己创建了Simple Thread,团队从2人发展到25人。10年前,我们主要与中小型企业合作,现在与大大小小不同的企业合作。


我的建议来自于这些人:



  1. 几乎总是在小而精干的团队中,必须用很少的资源做很多事情。

  2. 重视可工作软件而不是特定的工具。

  3. 在维护多个系统的同时,一直有新的项目要启动。

  4. 把工程师的生产力看得比大多数其他因素都重要。
    我过去20年的经历塑造了我对软件的看法,并引导我形成了一些信念,我试图将这些信念精简并整理成一个列表,希望你会觉得它对你有所帮助。


我的列表


1.“我依然知道的不够多”


 “你怎么会不知道BGP是什么?“ “你从来没听说过Rust吗?”。我们很多人经常听到过类似的话。很多人喜欢软件开发的一个重要的原因是我们是终身学习者,软件开发中,无论你从哪个方向看,都有广阔的知识前景在各个方向发展,并且每天都在扩大。这意味着与其他职业中花费几十年的人相比,你即使已经花费了数十年,但可能仍然有巨大的知识断层,有很多新知识需要学习,你可能因为担心不能胜任而陷入焦虑。你越早意识到这一点,你就能越早摆脱这种时常的焦虑,从而放平心态,乐于向别人学习以及教授他人。


2.软件最难的部分是构建正确的东西


 我知道这已经是陈词滥调了,但是还是有很多软件工程师不相信这一点,因为他们认为这会贬低他们所做的工作。我个人认为这是无稽之谈。相反,它强调了我们工作环境的复杂性和非理性,这更突出了我们所面临的挑战。你可能可以设计出全世界技术上最牛的东西,但却没有人愿意使用它,这种事经常发生。设计软件主要是一种倾听活动,我们经常不得不一半是软件工程师,一半是心理学家,一半是人类学家。在这个设计过程中投资自己,无论是通过专门的用户体验团队的成员还是通过简单的自学,都会带来巨大的回报。因为构建错误软件的成本可不仅仅是浪费了工程师的时间。


3.最好的软件工程师会像设计师一样思考


 优秀的软件工程师会深入考虑他们代码的用户体验。他们可能不会用这些术语来考虑它,而是考虑它是外部API、编程式API、用户界面、协议还是任何其他接口;优秀的工程师会考虑谁会使用它,为什么会使用它,如何使用它,以及对这些用户来说什么是重要的。牢记用户的需求才是好的用户体验的核心。


4.最好的代码是没有代码,或者不需要维护的代码


 任何职业的人解决问题的过程中都会在自己擅长的方面犯错误,这是人的本性。大多数软件工程师在编写代码免不了会犯错误,尤其是当还没有可行的非技术性解决方案时。工程团队总是倾向于在已经有很多轮子的时候重新发明轮子。有很多原因让你自己重新做一个轮子,但一定要警惕有毒的“Not invented here”综合症,不能闭门造车,妄自尊大,尽量复用和寻找非技术性解决方案。


5.软件是达到目的的一种手段


 任何软件工程师的主要工作都是交付价值。很少有软件开发人员能理解这一点,更少人能内化它。真正的内在化会带来一种不同的解决问题的方式,以及一种不同的看待工具的方式。如果你真的相信软件是屈从于结果的,你就会准备好真正找到“适合这项工作的工具”,而这个工具可能根本不是软件。


6.有时候你不得不停止磨刀,开始切东西


 有些人倾向于一头扎进问题中,然后开始编写代码解决问题。有些人却倾向于花大量时间研究和调查,但却让自己陷进问题中。在这种情况下,给自己设定一个最后期限,然后开始探索解决方案。当你开始解决这个问题的时候,你会很快学到更多的东西,这将引导你迭代形成一个更好的解决方案。


7.如果你不能很好地把握全局的可能性,你就无法设计出一个好的系统


 这是我在每天的工作中不断努力的事情。与开发者生态保持同步是一项巨大的工作,但了解开发者生态中的可能性却是至关重要的。如果你不了解在一个给定的生态系统中什么是可能的,什么是可用的,那么你就不可能设计出一个合理的解决方案来解决所有的问题,除非是最简单的问题。总而言之,要警惕那些很长时间没有编写任何代码的系统设计者。


8.每个系统最终都很糟糕,克服它吧


 比雅尼·斯特劳斯特鲁普(Bjarne Stroustrup)有一句话是这样说的: “世界上只有两种语言,一种是人们抱怨的语言,另一种是没人用的语言。”。这也可以扩展到大型系统。不存在“正确”的架构,你永远无法偿还所有的技术债务,你永远无法设计出完美的界面,你的测试总是太慢。这不是个能让事情变得更好的借口,而是一种让你看问题的方式。少担心优雅和完美;相反,要努力持续改进,创建一个你的团队喜欢并可持续提供价值的环境。


9.没人去问“为什么”


 抓住任何机会去质疑那些“一直以来都是这样做的”假设和方法。有新队员加入?那就注意他们在哪里感到困惑,他们问了什么问题。有一个没有意义的新功能需求?确保你理解了目标,以及是什么驱动了这种功能的需求。如果你得不到一个明确的答案,继续问为什么,直到你明白。


10.我们应该更加关注如何避免0.1x程序员,而不是寻找10x程序员


 10倍的程序员其实是一个愚蠢说法。认为一个人可以在一天内完成另一个有能力、勤奋、同样有经验的程序员可以在两周内完成的任务是愚蠢的。我见过程序员抛出10倍的代码量,然后你必须用10倍的时间来修正它。一个人成为10倍程序员的唯一方法就是将他与0.1倍程序员进行比较。有些人浪费时间,不寻求反馈,不测试他们的代码,不考虑边界情况等等。我们更应该关心的是让0.1x程序员远离我们的团队,而不是找到神秘的10x程序员。


11.高级工程师和初级工程师之间最大的区别之一就是他们对事情应该如何发展形成了自己的观点


 没有什么比高级工程师对他们的工具或如何构建软件一无所知更让我担心的了。我宁愿有人给我一些强烈的反对观点,也不愿他们没有任何观点。如果你正在使用你的工具,并且你并不喜欢或讨厌它们,那么你就需要体验更多。您需要探索其他语言、库和范式。没有什么方法比积极地寻找别人如何用不同的工具和技术来完成任务能更快地提升你的技能了。


12.人们不是真的想要创新


 人们经常谈论创新,但他们通常寻找的是廉价的胜利和新奇的东西。如果你真的在创新,改变人们做事的方式,那么大部分的反馈都是负面的。如果你相信你正在做的事情,并且知道它真的会改善事情,那么就做好长期斗争的准备。


13.数据是系统中最重要的部分


 我见过许多对数据完整性要求很高的系统。在这样的系统中,任何发生在关键路径之外的事情都会创建部分数据或脏数据。将来处理这些数据可能会成为一场噩梦。请记住,您的数据可能比代码库存在的时间更长。把精力花在保持它的有序和清洁上,从长远来看它会得到很好的回报。


14.寻找技术”鲨鱼“


 许多留下来的老技术是”鲨鱼“,而不是”恐龙“。他们能够很好地解决问题,并在技术不断快速变化的今天生存了下来。只有在有一个很好的理由的情况下,再去替换它们。这些工具不会华而不实,也不会令人兴奋,但是它们可以完成工作,避免很多不必要的不眠之夜。


15.不要把谦卑误认为无知


 有很多软件工程师在没有被提问的时候,是不怎么发表意见的。永远不要以为别人没有他们的观点摆在你面前,你就觉得他们没有什么观点。有时候最吵的人恰恰是我们最不想听的人。与你周围的人交谈,寻求他们的反馈和建议。你会有意外收获。


16.软件工程师应该定期写作


 软件工程师应该定期写博客,写日志,写文档,去做任何保持书面沟通技能的事情。写作可以帮助你思考问题,并帮助你与团队和未来的自己更有效地沟通。良好的书面沟通能力是任何软件工程师都需要掌握的最重要的技能之一。


17.保持流程尽可能精简


 如今,每个人都想变得敏捷,“敏捷”就是把事情分成小块,学习,然后迭代。如果有人试图把更多的东西塞进去,那他很可能是在卖东西。想想你有多少次听到来自你最喜欢的技术公司或大型开源项目的人在吹嘘他们的Scrum流程有多棒?在你知道你需要更多的东西之前,请依靠流程。相信你的团队,他们会完成任务。


18.软件工程师,像所有人一样,需要有归属感


 如果你把某人和他的工作成果分开,他就不会那么在乎他的工作。我认为这几乎是同义反复。归属感是跨职能团队工作得如此出色的主要原因,也是DevOps变得如此流行的原因。这并不全是关于交接和低效的问题,而是关于从开始到结束去参与和享受整个过程,并直接负责交付价值。让一群充满激情的人完全拥有设计、构建和交付一个软件(或者其他任何东西)的所有权,奇妙的事情就会发生。


19.面试对于判断一个团队成员是否优秀几乎毫无价值


 面试最好是试着了解对方是谁,以及他们对某一特定专业领域有多大兴趣。试图弄清楚一个团队成员会有多好是徒劳的努力。相信我,一个人有多聪明或多有知识也不能很好地表明他们将是一个优秀的团队成员。没有人会在面试中告诉你,他们会不可靠,会骂人,会夸夸其谈,或者从不准时出席会议。人们可能会说他们在这些事情上有“信号”……“如果他们在第一次面试时就问请假,那么他们就永远不会在那里了!” 但这些都是胡扯。如果你使用这样的信号,你只是在猜测,并将优秀的候选人拒之门外。


20.始终努力构建一个更小的系统


 有很多的力量将推动你预先建立更大的系统。预算分配,无法决定哪些功能应该被削减,希望交付系统的“最佳版本”。所有这些事情会迫使我们过度建设。你应该与之抗争。在构建系统的过程中,你会学到很多东西,最终迭代得到的系统将比你最初设计的系统要好得多。令人惊讶的是,这很难说服大多数人。


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

工作7年的程序员,明白了如何正确的"卷"

大家好,我是大鹅,一名在互联网行业,工作了7年的程序员。 今天和大家聊聊工作7年后,一些感悟吧。 背景 近两年,出台和落地的反垄断法,明确指出要防止资本无序扩张。 这也就导致现在的各大互联网公司,不能再去染指其他已有的传统行业,只能专注自己目前存量的这些业务。...
继续阅读 »

大家好,我是大鹅,一名在互联网行业,工作了7年的程序员。


今天和大家聊聊工作7年后,一些感悟吧。


背景


近两年,出台和落地的反垄断法,明确指出要防止资本无序扩张。


这也就导致现在的各大互联网公司,不能再去染指其他已有的传统行业,只能专注自己目前存量的这些业务。或者通过技术创新,开辟出新的行业。


但创新这种东西,可遇不可求,互联网进入到目前这个阶段,能做的,基本都已经有公司在做了。


互联网创造出来的工作机会,例如程序员、产品经理、运营等等,已经进入了一个存量市场的时代。



无意义的卷


我反对的“卷”,是通过疯狂加班,以工作时长换取更高产出的“卷” ,而不是个人学习成长这一方面的“卷”。


这个类型的“卷”,不论是对自己,还是对于这个行业所带动的就业机会,都是不可持续且有害的事情。


对自己:


短期内,因为卷工作时长,的确可以获得了更高的产出,顺带着获得了更多表现自己和让上级看到的机会,这一点是符合逻辑的。


但从长远来看,到了一定岁数,体力肯定不如年轻人了,这时候再被年轻人卷,那真是冤冤相报何时了。。



对就业机会:


正常是下午6点下班,卷的人晚上11-12点才走。


一个业务需求正常开发一个月,硬是压缩到半个月,把1个人当成2个人用。


这样做会进一步缩减这个存量市场的工作机会,因为老板们发现裁掉一部分人,依然可以正常运转。


对于老板来说,他更赚了,因为减少了成本


对于打工人来说,很亏,因为付出了大于1人力的工作时长和产出,得到的却还是1人力的回报。



如果不“卷”,能得到什么?


1、首先应该可以释放出一部分新的工作机会出来,让这个行业可以容纳更多的人。


因为之前通过疯狂加班,1个人干2个人的事情这种情况得到制止,意味着需要新增人员才能完成以前的工作量。


2、不用疯狂加班,身体好了,也有时间了。



我认为正确的“卷”


背景交代清楚了,回到主题,如何正确的“卷”


这里没有什么长篇大论,我认为正确的卷,应该是想清楚自己的方向,坚持不断的学习和积累,让自己拥有这个领域足够的专业度和深度,这才是自己的核心优势。卷的是毅力和积累,而不是体力。


同时,还可以对外输出自己的知识,让自己在这个行业拥有一定的知名度,这可以很大的提高自己的抗风险能力。



写在最后


我个人的力量有限,我不觉得我能影响多少人,但能影响一点,也总是好的。


也希望看到这个文章的你,能一起帮助这个行业,让它的风气,变得更好一点。


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

从基本的知识技能出发,分析Android工程师到架构师的转变

从基本的知识技能出发,分析一下Android工程师到架构师的转变是什么,该如何成为一个架构师 首先成为一名Android工程师 成为一名Android工程师需要掌握以下基本技能和知识: Java编程语言: Java是Android应用程序开发的主要编程语言...
继续阅读 »

从基本的知识技能出发,分析一下Android工程师到架构师的转变是什么,该如何成为一个架构师



首先成为一名Android工程师


成为一名Android工程师需要掌握以下基本技能和知识:



  1. Java编程语言: Java是Android应用程序开发的主要编程语言。您需要掌握Java的基本概念、语法和编程范例。

  2. Android应用程序开发:学习Android应用程序开发需要掌握Android框架、Android SDK、Gradle构建系统等基本概念。您需要了解如何创建Android应用程序、构建用户界面、处理用户输入、访问网络、存储数据等。

  3. Android开发工具:为了开发Android应用程序,您需要了解Android Studio、Eclipse等集成开发环境(IDE),以及其他Android开发工具,如布局编辑器、调试工具等。

  4. 版本控制工具:版本控制工具是一种管理代码和开发项目的工具,如Git和SVN。学习如何使用这些工具可以帮助您更好地管理和维护您的代码。

  5. 软件设计和模式:了解软件设计和模式可以帮助您编写可维护、可扩展的代码。您需要掌握MVC、MVP、MVVM等设计模式,以及OO(面向对象)和设计原则,如SOLID原则。

  6. 学习社区:加入Android开发社区可以帮助您了解最新的开发趋势和最佳实践,解决问题和获取帮助。您可以加入Android开发者论坛、GitHub、Stack Overflow等社区。


对于这些基础知识技能,我们可以规划以下步骤:



  1. 学习Java编程语言:学习Java编程语言的基础知识、语法和范例。

  2. 学习Android开发:学习Android开发,包括Android框架、Android SDK、Gradle构建系统等基础知识。

  3. 练习开发:练习开发Android应用程序,开发简单的应用程序并添加不同的功能。

  4. 学习软件设计和模式:学习软件设计和模式,以编写可维护、可扩展的代码。

  5. 学习版本控制工具:学习版本控制工具,如Git和SVN,以便更好地管理和维护您的代码。

  6. 加入Android开发社区:加入Android开发社区,学习最新的开发趋势和最佳实践,获取帮助和解决问题。

  7. 参加培训和课程:参加Android开发的培训和课程可以帮助您加速学习和提高技能。


蜕变为高级Android工程师


要成为一名高级Android工程师,需要进一步提升自己的技能和知识,并有足够的实践经验,其实大家能看出来,经验是成为Android高级工程师的必备项。


那我们可以从以下方面进行蜕变:



  1. 持续学习:Android开发技术不断更新和进化,持续学习是必须的。您可以阅读Android官方文档、博客、开发者论坛等,了解最新的技术和开发趋势。

  2. 提高编程能力:除了掌握Java编程语言,您还应该了解Kotlin、Flutter、React Native等其他移动开发技术,并且要熟练掌握多种编程语言。

  3. 深入了解Android框架:深入了解Android框架的内部机制,包括Activity生命周期、Fragment、View、Service、BroadcastReceiver、Content Provider等等,对于解决问题和优化性能非常重要。

  4. 掌握Android性能优化技巧:为了使应用程序运行更流畅和更快速,需要掌握各种性能优化技巧,例如渲染优化、内存管理、网络优化、布局优化等等。

  5. 掌握软件设计和架构:掌握设计模式和软件架构,可以帮助您编写高效、可维护和可扩展的代码。了解MVP、MVVM、Clean Architecture等架构模式可以提高代码质量。

  6. 参与开源项目:参与开源项目可以让您接触到更广泛的技术和工具,并可以获得更多的实践经验。

  7. 培养团队协作和沟通技能:高级Android工程师通常需要与其他团队成员一起工作,需要具备优秀的沟通和协作技能,以确保项目顺利进行。

  8. 获得证书:获得相关的证书可以提高您的专业水平,例如Google的Android开发者认证证书。


总之,要成为一名高级Android工程师需要持续学习和不断实践,并且具备深入了解Android框架、性能优化技巧、软件设计和架构、团队协作和沟通技能等多方面的能力。


高级Android工程师怎么成为架构师呢?


要成为专业的Android架构师,需要在Android开发方面积累更多的经验和知识,同时需要在软件架构方面有更深入的理解和能力


平时可以积累架构方面的知识,从宏观到微观,从架构到需求,做事要有一步三思考,将扩展性玩的6,集合业务,架构师的职业在一定程度上是为公司节省成本,当你的项目开发迭代变得容易,bug易于管理,扩展随插随拔,你的架构能力也就OK了,首先可以从以下几点去做准备。



  1. 深入了解软件设计和架构:作为架构师,您需要具备设计和架构的能力。可以通过阅读相关的书籍和文章,了解设计模式、软件架构模式等理论知识,还要结合实践进行深入理解。

  2. 熟悉常用的架构模式:MVP、MVVM、Clean Architecture等是常用的Android架构模式,熟练掌握这些模式可以帮助您设计高效、可维护和可扩展的应用程序。

  3. 学习如何进行系统架构设计:系统架构设计需要全局考虑,包括数据流、组件之间的关系、可扩展性等等。学习如何进行系统架构设计可以帮助您更好地组织应用程序的结构,提高代码质量。

  4. 了解最佳实践:Android开发领域有许多最佳实践,例如避免使用过于庞大的Activity、Fragment、减少不必要的内存分配等等。了解这些最佳实践可以帮助您在设计应用程序架构时更加谨慎。

  5. 了解Android开发生态系统:Android开发生态系统包括许多工具、库和框架,例如RxJava、Retrofit、Dagger等等。了解这些工具的特点和优缺点可以帮助您更好地设计应用程序架构。

  6. 参与开源项目:参与开源项目可以让您接触到更广泛的技术和工具,并可以获得更多的实践经验。

  7. 培养团队协作和沟通技能:作为架构师,您需要与其他团队成员进行沟通和协作,需要具备优秀的沟通和协作技能。


总之,成为专业的Android架构师需要掌握软件设计和架构、熟悉常用的架构模式、了解最佳实践、学习如何进行系统架构设计、了解Android开发生态系统等多方面的能力。同时,需要不断学习和实践,积累经验,提高自己的能力。


让这些书籍提升你自己


Android 开发基础:



  • 《第一行代码》:作者是郭霖,介绍了Android开发的基础知识和实践方法,适合初学者。

  • 《Android编程权威指南》:作者是Bill Phillips、Chris Stewart和Kristin Marsicano,介绍了Android开发的全面知识,包括Java编程、UI设计、数据存储和网络通信等方面。

  • 《Java编程思想》:作者是Bruce Eckel,介绍了Java编程的基础知识和编程思想,是Java编程的入门经典。

  • 《Effective Java》:作者是Joshua Bloch,介绍了Java编程的最佳实践,对于提升Java编程技能非常有帮助。


Android 高级进阶



  • 《Android源码设计模式解析与实战》:作者是杨波,介绍了Android源码中常见的设计模式及其应用场景,适合进阶学习。

  • 《Android应用架构设计指南》:作者是贺博,介绍了常见的Android架构模式及其应用方法,以及如何进行应用架构设计,适合有一定经验的开发者。

  • 《Android开发艺术探索》:作者是任玉刚,介绍了Android开发中常见的技术和实践,深入浅出地讲解了一些高级的技术原理和实现方式。

  • 《Android系统源代码情景分析》:作者是徐医生,介绍了Android系统的架构和源码分析,深入剖析了Android系统中的各种核心组件和机制。


Android 架构师



  • 《软件架构设计》:作者是Martin Fowler,介绍了软件架构设计的基本原则和方法,适合初学者。

  • 《大型网站技术架构》:作者是李智慧,介绍了大型网站的架构设计和技术选型,适合深入学习。

  • 《Android源码情景分析》:作者是余晟,介绍了Android源码的内部实现和设计原理,对理解Android系统架构和开发设计有很大帮助。

  • 《软件架构实践》:作者是贾卓辉,介绍了软件架构实践的方法和案例,包括架构模式、设计原则、应用场景和架构风格等方面的内容。

  • 《大型网站架构模式》:作者是曹政,介绍了大型网站的架构设计和技术实践,包括架构模式、性能优化、高可用性和数据分析等方面的内容。


关于架构入门,特别推荐


《架构整洁之道》,是由著名软件工程师 Robert C. Martin(Bob大叔)所著,是一本介绍软件架构设计和开发实践的书籍。这本书的思想主要是基于作者多年的实践经验和教学经验,提出了一系列关于软件架构和开发实践的最佳实践和原则,如单一职责原则、依赖倒置原则、接口隔离原则、里氏替换原则、开闭原则、迪米特法则等。通过这些原则的实践,可以使软件系统更加灵活、可扩展、易维护和易测试。


总体来说,这本书是一本非常优秀的软件开发书籍,对软件架构设计和开发实践提出了很多有用的建议和指导。它的思想可以帮助开发者避免一些常见的错误和陷阱,提高代码质量和软件设计水平。


除了原则和实践,这本书还介绍了很多实际案例和代码示例,让读者可以更加深入地理解和应用其中的思想和方法。不过需要注意的是,这本书内容比较深入和技术性较强,可能对于初学者来说会有一定的难度。如果你已经有一定的开发经验并且想要提高自己的软件架构和开发能力,那么这本书是非常值得阅读和学习的。


最后


一起讨论吧,你们认为这个转变还需要什么?当然列举的学习步骤和书籍都是片面的,还有更好的方法吗?


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

上手试试 Compose for ios

前言 前段时间看了阿黄哥的一篇介绍Compose for ios 的文章 Compose跨平台第三弹:体验Compose for iOS(黄林晴) juejin.cn/post/719577… 才知道 compose 已经可以跨ios端了,自己也打算上手试...
继续阅读 »

前言


前段时间看了阿黄哥的一篇介绍Compose for ios 的文章



Compose跨平台第三弹:体验Compose for iOS(黄林晴)


juejin.cn/post/719577…



才知道 compose 已经可以跨ios端了,自己也打算上手试试。等自己实际上手之后,才发现有很多坑,尤其是配置环境方面,所以打算写一篇文章记录一下。


开始


接下来,我会从新建一个项目,依赖配置,环境配置,一直到可以使用Compose 写一个Hello WordDemo, 这样的步骤来介绍一个我曾遇见的坑以及解决方法。


如果要尝试ios方面的东西,一定是需要mac系统的,无论是用mac 电脑 还是使用虚拟机,并且还需要Xocde 这个巨无霸。



首先来介绍一个我使用的环境:



  • mac os 12.6.3

  • Xcode 13.2.1

  • Kotlin 1.8.0

  • Compsoe 1.3.0


我之前研究过KMM,曾尝试写了一个Demo,当时的mac 系统是 10.17 版本,最多只能下载Xcode 12.3,而这个版本的Xcode 只能编译 Kotlin 1.5.31,想要用高版本的kotlin 就得需要使用 12.5版本的Xcdoe,所以我就是直接将mac 系统升级到了 12.6.3Xcode 我是下载的13.2.1,直接下载最新版的Xcode14 应该也可以。这是关于环境版本需要注意的一些点。


现在开始正式的搭建项目。


首先要安装一个Android Studio 插件,直接 在插件市场搜索 Kotlin Multiplatform Mobile 就可以。


安装完成之后,在新建项目的时候 就可以看到在最后多出来两个项目模板,



这里使用第一个模板。


创建出来目录结构大概是这个样子的:




  • androidApp就是运行在Android平台上的。

  • iosApp 就是运行在ios平台上的。

  • shared就是两者通用的部分。


shared 中又分为androidMainiosMaincommonMain 三个部分。


主要是在commonMain中定义行为,然后分别在androidMainiosMain 中分别实现,这个Demo 中主要是展示系统版本。


interface Platform {
    val name: String
}

expect fun getPlatform(): Platform

expect 关键字是将此声明标记为是平台相关的,并期待从模块中实现。


然后在对应模块中实现:


//android
class AndroidPlatform : Platform {
    override val name: String = "Android ${android.os.Build.VERSION.SDK_INT}"
}

actual fun getPlatform(): Platform = AndroidPlatform()

//ios
class IOSPlatform: Platform {
    override val name: String = UIDevice.currentDevice.systemName() + " " + UIDevice.currentDevice.systemVersion
}

actual fun getPlatform(): Platform = IOSPlatform()

actual : 表示多平台项目中的一个平台相关实现



Kotlin关键字 参见:


http://www.kotlincn.net/docs/refere…





引用自:http://www.kotlincn.net/docs/refere…



这个模板项目大概就了解这么多,接下我们开始引入Compose相关依赖。


首先在settings.gradle.kts 中添加仓库:


pluginManagement {
    repositories {
        google()
        gradlePluginPortal()
        mavenCentral()
        //添加这两行
        maven(uri("https://plugins.gradle.org/m2/")) // For kotlinter-gradle
        maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
    }
}

dependencyResolutionManagement {
    repositories {
        google()
        mavenCentral()
//      添加这个
        maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
    }
}

然后引入插件和依赖:


根目录build.gradle.kts
plugins {
    //trick: for the same plugin versions in all sub-modules
    id("com.android.application").version("7.4.1").apply(false)
    id("com.android.library").version("7.4.1").apply(false)
    kotlin("android").version("1.8.0").apply(false)
    kotlin("multiplatform").version("1.8.0").apply(false)
    //添加此行
    id("org.jetbrains.compose") version "1.3.0" apply false
}

share Module 的 build.gradle.kts
plugins {
    kotlin("multiplatform")
    id("com.android.library")
    //添加此行
    id("org.jetbrains.compose")
}


    sourceSets {
        val commonMain by getting{
            //添加依赖
            dependencies {
                with(compose) {
                    implementation(ui)
                    implementation(foundation)
                    implementation(material)
                    implementation(runtime)
                }
            }
        }
      ....
    }

然后再进行编译的时候,这里会报错:


ERROR: Compose targets '[uikit]' are experimental and may have bugs!
But, if you still want to use them, add to gradle.properties:
org.jetbrains.compose.experimental.uikit.enabled=true

这里是需要在gradle.properties 中添加:


org.jetbrains.compose.experimental.uikit.enabled=true

然后在编译ios module的时候,可以选择直接从这列选择iosApp



有时候可能因为Xcode环境问题,这里的iosApp 会标记着一个红色的x号,提示找不到设别,或者其他关于Xcode的问题,此时可以直接点击iosApp module 下的 iosApp.xcodeproj ,可以直接用Xcode 来打开编译。还可以直接直接跑 linkDebugFrameworkIosX64 这个task 来直接编译。


此时编译我是碰见了一个异常:


e: Module "org.jetbrains.compose.runtime:runtime-saveable (org.jetbrains.compose.runtime:runtime-saveable-uikitx64)" has a reference to symbol androidx.compose.runtime/remember|-2215966373931868872[0]. Neither the module itself nor its dependencies contain such declaration.

This could happen if the required dependency is missing in the project. Or if there is a dependency of "org.jetbrains.compose.runtime:runtime-saveable (org.jetbrains.compose.runtime:runtime-saveable-uikitx64)" that has a different version in the project than the version that "org.jetbrains.compose.runtime:runtime-saveable (org.jetbrains.compose.runtime:runtime-saveable-uikitx64): 1.3.0" was initially compiled with. Please check that the project configuration is correct and has consistent versions of all required dependencies.

出现这个错误是需要在gradle.properties 中添加:


kotlin.native.cacheKind=none


参见:github.com/JetBrains/c…



然后编译出现了一个报错信息巨多的异常:


//只粘贴了最主要的一个异常信息
Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/ld invocation reported errors

这里是需要在sharebuild.gradle.kts中加上这个配置:


    listOf(
        iosX64(),
        iosArm64(),
        iosSimulatorArm64()
    ).forEach {
        it.binaries.framework {
            baseName = "shared"
            //加上此行
            isStatic = true
        }
    }

    /**
     * Specifies if the framework is linked as a static library (false by default).
     * 指定框架是否作为静态库链接(默认情况下为false)。
     */
    var isStatic = false

然后再编译的时候还遇到一个异常信息:


//org.gradle.api.UnknownDomainObjectException: KotlinTarget with name 'uikitX64' not found.

这个是在sharebuild.gradle.kts中加上如下配置:


   
kotlin{
    val args = listOf(
        "-linker-option", "-framework", "-linker-option", "Metal",
        "-linker-option", "-framework", "-linker-option", "CoreText",
        "-linker-option", "-framework", "-linker-option", "CoreGraphics"
    )

    //org.gradle.api.UnknownDomainObjectException: KotlinTarget with name 'uikitX64' not found.
    iosX64("uikitX64") {
        binaries {
            executable {
                entryPoint = "main"
                freeCompilerArgs = freeCompilerArgs + args
            }
        }
    }
    iosArm64("uikitArm64") {
        binaries {
            executable {
                entryPoint = "main"
                freeCompilerArgs = freeCompilerArgs + args
                freeCompilerArgs = freeCompilerArgs + "-Xdisable-phases=VerifyBitcode"
            }
        }
    }
}

然后再编译就可以正常编译通过了。


下面我们就可以在两端使用Compose了。


首先在commonMain中写一个Composable,用来供给两端调用:


@Composable
internal fun KMMComposeView(device:String){
    Box(contentAlignment = Alignment.Center) {
        Text("Compose跨端 $device view")
    }
}

这里一定要写上internal 关键字,internal 是将一个声明 标记为在当前模块可见。



internal 官方文档


http://www.kotlincn.net/docs/refere…



不然在ios 调用定义好的Compose 的时候产生下面的异常:


Undefined symbols for architecture x86_64:
  "_kfun:com.xl.kmmdemo#KMMComposeView(kotlin.String){}", referenced from:
      _objc2kotlin_kfun:com.xl.kmmdemo#KMMComposeView(kotlin.String){} in shared(result.o)

然后再两端添加各自的调用:


androidMain的 Platform.kt
@Composable
fun MyKMMView(){
    KMMComposeView("Android")
}

iosMain的 Platform.kt

fun MyKMMView(): UIViewController = Application("ComposeMultiplatformApp") {
    KMMComposeView(UIDevice.currentDevice.systemName())
}

最后在androidApp module中直接调用 MyKMMView() 就行了,iosApp 想要使用的话,我们还得修改一下iosApp moudle的代码:


我们呢需要将 iosApp/iosApp/iOSApp.swift 的原有代码:


import SwiftUI

@main
struct iOSApp: App {
 var body: some Scene {
  WindowGroup {
   ContentView()
  }
 }
}

替换为:


import SwiftUI
import shared

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
    var myWindow: UIWindow?

    func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
    ) -> Bool {
        myWindow = UIWindow(frame: UIScreen.main.bounds)
        //主要关注这一行, 这里是调用我们自己在iosMain里面定义的 KMMViewController 
        let mainViewController = PlatformKt.KMMViewController()
        myWindow?.rootViewController = mainViewController
        myWindow?.makeKeyAndVisible()
        return true
    }

     func application(
        _ application: UIApplication,
        supportedInterfaceOrientationsFor supportedInterfaceOrientationsForWindow: UIWindow?
     ) -> UIInterfaceOrientationMask {
         return UIInterfaceOrientationMask.all
    }
}

最后来看看效果:



用来演示效果的代码非常简单,就是使用一个Text 组件来展示 设备的类型。


写在最后


自己在上手的时候本以为很简单,结果就是不断踩坑,同时ios相关知识也比较匮乏,有些问题的解决方案网上的答案也非常少,最后是四五天才能正常跑起来这个Demo。现在是将这些坑都记录下来,希望能给其他的同学能够提供一些帮助。


后面的计划会尝试使用ktor接入一些网络请求,然后写一个跨端的开源项目,如果再遇见什么坑会继续分享这个踩坑系列。


关于Compose for Desktop 之前也有过尝试,是写了一个adb GUI的工具项目,非常简单 ,没遇见什么坑,就是Compose的约束布局没有。目前工作中经常用到的一些工具 也是使用Compose写的。


今天的碎碎念就到这里了,这次写的也比较细,比较碎,大家要是有什么问题,欢迎一起交流。


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

Flutter 3.7 之快速理解 toImageSync 是什么?能做什么?

随着 Flutter 3.7 的更新, dart:ui 下多了 Picture.toImageSync 和 Scene.toImageSync 这两个方法,和Picture.toImage 以及 Scene.toImage 不同的是 ,toImageSy...
继续阅读 »

随着 Flutter 3.7 的更新, dart:ui 下多了 Picture.toImageSyncScene.toImageSync 这两个方法,和Picture.toImage 以及 Scene.toImage 不同的是 ,toImageSync 是一个同步执行方法,所以它不需要 await 等待,而调用 toImageSync 会直接返回一个 Image 的句柄,并在 Engine 后台会异步对这个 Image 进行光栅化处理。


前言


toImageSync 有什么用?不是有个 toImage 方法了,为什么要多一个 Sync 这样的同步方法?




  • 目前 toImageSync 最大的特点就是图像会在 GPU 中常驻 ,所以对比 toImage 生成的图像,它的绘制速度会更快,并且可以重复利用,提高效率。



    toImage 生成的图像也可以实现 GPU 常驻,但目前没有未实现而已。





  • toImageSync 是一个同步方法,在某些场景上弥补了 toImage 必须是异步的不足。





toImageSync 的使用场景上,官方也列举了一些用途,例如:



  • 快速捕捉一张昂贵的栅格化图片,用户支持跨多帧重复使用

  • 应用在图片的多路过滤器上

  • 应用在自定义着色器上


具体在 Flutter Framework 里,目前 toImageSync 最直观的实现,就是被使用在 Android 默认的页面切换动画 ZoomPageTransitionsBuilder 上,得意于 toImageSync 的特性,Android 上的页面切换动画的性能,几乎减少了帧光栅化一半的时间,从而减少了掉帧和提高了刷新率。



当然,这是通过牺牲了一些其他特性来实现,后面我们会讲到。



SnapshotWidget


前面说了 toImageSync 让 Android 的默认页面切换动画性能得到了大幅提升,那究竟是如何实现的呢?这就要聊到 Flutter 3.7 里新增加的 SnapshotWidget


其实一开始 SnapshotWidget 是被定义为 RasterWidget ,从初始定义上看它的 Target 更大,但是最终在落地的时候,被简化处理为了 SnapshotWidget ,而从使用上看确实 Snapshot 更符合它的设定。



概念


SnapshotWidget 的作用是可以将 Child 变成的快照(ui.Image)从而替换它们进行显示,简而言之就是把子控件都变成一个快照图片,而 SnapshotWidget 得到快照的办法就是 Scene.toImageSync



那么到这里,你应该知道为什么 toImageSync 可以提高 Android 上的页面切换动画的性能了吧?因为 SnapshotWidget 会在页面跳转时把 Child 变成的快照,而 toImageSync 栅格化的图片还可以跨多帧重复使用。



那么问题来了,SnapshotWidget 既然是通过 toImageSync 将 Child 变成的快照(ui.Image)来提高性能,那么带来的副作用是什么?


答案是动画效果,因为子控件都变成了快照,所以如果 Child 控件带有动画效果,会呈现“冻结”状态,更形象的对比如下图所示:















FadeUpwardsPageTransitionsBuilderZoomPageTransitionsBuilder

默认情况下 Flutter 在 Android 上的页面切换效果使用的是 ZoomPageTransitionsBuilder ,而 ZoomPageTransitionsBuilder 里在页面切换时会开启 SnapshotWidget 的截图能力,所以可以看到,它在页面跳转时,对比 FadeUpwardsPageTransitionsBuilder 动图, ZoomPageTransitionsBuilder 的红色方块和掘金动画会停止。



因为动画很短,所以可以在代码里设置 timeDilation = 40.0;SchedulerBinding.resetEpoch 来全局减慢动画执行的速度,另外可以配置 MaterialApp ThemeData 下对应的 pageTransitionsTheme 来切换页面跳转效果。



所以在官方的定义中,SnapshotWidget 是用来协助执行一些简短的动画效果,比如一些 scale 、 skew 或者 blurs 动画在一些复杂的 child 构建上开销会很大,而使用 toImageSync 实现的 SnapshotWidget 可以依赖光栅缓存:



对于一些简短的动画,例如 ZoomPageTransitionsBuilder 的页面跳转, SnapshotWidget 会将页面内的 children 都转化为快照(ui.Image),尽管页面切换时会导致 child 动画“冻结”,但是实际页面切换时长很短,所以看不出什么异常,而带来的切换动画流畅度是清晰可见的



再举个更直观的例子,如下代码所示,运行后我们可以看到一个旋转的 logo 在屏幕上随机滚动,这里分别使用了 AnimatedSlideAnimatedRotation 执行移动和旋转动画。


Timer.periodic(const Duration(seconds: 2), (timer) {
final random = Random();
x = random.nextInt(6) - 3;
y = random.nextInt(6) - 3;
r = random.nextDouble() * 2 * pi;
setState(() {});
});

AnimatedSlide(
offset: Offset(x.floorToDouble(), y.floorToDouble()),
duration: Duration(milliseconds: 1500),
curve: Curves.easeInOut,
child: AnimatedRotation(
turns: r,
duration: Duration(milliseconds: 1500),
child: Image.asset(
'static/test_logo.png',
width: 100,
height: 100,
),
),
)


如果这时候在 AnimatedRotation 上层加多一个 SnapshotWidget ,并且打开 allowSnapshotting ,可以看到此时 logo 不再转动,因为整个 child 已经被转化为快照(ui.Image)。











所以 SnapshotWidget 不适用于子控件还需要继续动画或有交互响应的地方,例如轮播图。



使用


如之前的代码所示,使用 SnapshotWidget 也相对简单,你只需要配置 SnapshotController ,然后通过 allowSnapshotting 控制子控件是否渲染为快照即可。


 controller.allowSnapshotting = true;

SnapshotWidget 在捕获快照时,会生成一个全新的 OffsetLayerPaintingContext,然后通过 super.paint 完成内容捕获(这也是为什么不支持 PlatformView 的原因之一),之后通过 toImageSync 得到完整的快照(ui.Image)数据,并交给 SnapshotPainter 进行绘制。










所以 SnapshotWidget 完成图片绘制会需要一个 SnapshotPainter ,默认它是通过内置的 _DefaultSnapshotPainter 实现,当然我们也可以自定义实现 SnapshotPainter 来完成自定义逻辑。



从实现上看,SnapshotPainter 用来绘制子控件快照的接口,正如上面代码所示,会根据 child 是否支持捕获(_childRaster == null),从而选择调用 paintpaintSnapshot 来实现绘制。



另外,目前受制于 toImageSync 的底层实现, SnapshotWidget 无法捕获 PlatformView 子控件,如果遇到 PlatformView,SnapshotWidget 会根据 SnapshotMode 来决定它的行为:



















normal默认行为,如果遇到无法捕获快照的子控件,直接 thrown
permissive宽松行为,遇到无法捕获快照的子控件,使用未快照的子对象渲染
forced强制行为,遇到无法捕获快照的子控件直接忽略

另外 SnapshotPainter 可以通过调用 notifyListeners 触发 SnapshotWidget 使用相同的光栅进行重绘,简单来说就是:



你可以在不需要重新生成新快照的情况下,对当然快照进行一些缩放、模糊、旋转等效果,这对性能会有很大提升



所以在 SnapshotPainter 里主要需要实现的是 paintpaintSnapshot 两个方法:




  • paintSnapshot 是绘制 child 快照时会被调用




  • paint 方法里主要是通过 painter (对应 super.paint)这个 Callback 绘制 child ,当快照被禁用或者 permissive 模式下遭遇 PlatformView 时会调用此方法





举个例子,如下代码所示,在 paintSnapshot 方法里,通过调整 Paint ..color ,可以在前面的小 Logo 快照上添加透明度效果:


class TestPainter extends SnapshotPainter {
final Animation<double> animation;

TestPainter({
required this.animation,
});

@override
void paint(PaintingContext context, ui.Offset offset, Size size,
PaintingContextCallback painter) {}

@override
void paintSnapshot(PaintingContext context, Offset offset, Size size,
ui.Image image, Size sourceSize, double pixelRatio) {
final Rect src = Rect.fromLTWH(0, 0, sourceSize.width, sourceSize.height);
final Rect dst =
Rect.fromLTWH(offset.dx, offset.dy, size.width, size.height);
final Paint paint = Paint()
..color = Color.fromRGBO(0, 0, 0, animation.value)
..filterQuality = FilterQuality.low;
context.canvas.drawImageRect(image, src, dst, paint);
}

@override
void dispose() {
super.dispose();
}

@override
bool shouldRepaint(covariant TestPainter oldDelegate) {
return oldDelegate.animation.value != animation.value;
}
}


其实还可以把移动的动画部分挪到 paintSnapshot 里,然后通过对 animation 的状态进行管理,然后通过 notifyListeners 直接更新快照绘制,这样在性能上会更有优势,Android 上的 ZoomPageTransitionsBuilder 就是类似实现。


  animation.addListener(notifyListeners);
animation.addStatusListener(_onStatusChange);

void _onStatusChange(_) {
notifyListeners();
}
@override
void paintSnapshot(PaintingContext context, Offset offset, Size size, ui.Image image, Size sourceSize, double pixelRatio) {
_drawMove(context, offset, size);
}

@override
void paint(PaintingContext context, ui.Offset offset, Size size, PaintingContextCallback painter) {
switch (animation.status) {
case AnimationStatus.completed:
case AnimationStatus.dismissed:
return painter(context, offset);
case AnimationStatus.forward:
case AnimationStatus.reverse:
}
....
}


更多详细可以参考系统 ZoomPageTransitionsBuilder 里的代码实现。



拓展探索


其实除了 SnapshotWidget 之外,RepaintBoundary 也支持了 toImageSync , 因为 toImageSync 获取到的是 GPU 中的常驻数据,所以在实现类似控件截图和高亮指引等场景绘制上,理论上应该可以得到更好的性能预期。


final RenderRepaintBoundary boundary =
globalKey.currentContext!.findRenderObject()! as RenderRepaintBoundary;
final ui.Image image = boundary.toImageSync();

除此之外,dart:ui 里的 Scene_Image 对象其实都是 NativeFieldWrapperClass1 ,以前我们解释过:NativeFieldWrapperClass1 就是它的逻辑是由不同平台的 Engine 区分实现











所以如果你直接在 flutter/bin/cache/pkg/sky_engine/lib/ui/compositing.dart 下去断点 toImageSync 是无法成功执行到断点位置的,因为它的真实实现在对应平台的 Engine 实现。




另外,前面我们一直说 toImageSync 对比 toImage 是 GPU 常驻,那它们的区别在哪里?从上图我们就可以看出:



  • toImageSync 执行了 Scene:RasterizeToImage 并返回 Dart_Null 句柄

  • toImage 执行了 Picture:RasterizeLayerTreeToImage 并直接返回


简单展开来说,就是:



  • toImageSync 最终是通过 SkImage::MakeFromTexture 通过纹理得到一个 GPU SkImage 图片

  • toImage 是通过 makeImageSnapshotmakeRasterImage 生成 SkImagemakeRasterImage 是一个复制图像到 CPU 内存的操作。












其实一开始 toImageSync 是被命令为 toGpuImage ,但是为了更形象通用,最后才修改为 toImageSync



toImageSync 等相关功能的落地可以说同样历经了漫长的讨论,关于是否提供这样一个 API 到最终落地,其执行难度丝毫不比 background isolate 简单,比如:是否定义异常场景,遇到错误是否需要在Framwork 层消化,是否真的需要这样的接口来提高性能等等。












toImageSync 等相关功能最终能落地,其中最重要的一点我认为是:



toGoulmage gives the framework the ability to take performance into their own hands, which is important given that our priorities don't always line up.



最后


toImageSync 只是一个简单的 API ,但是它的背后经历了很多故事,同时 toImageSync 和它对应的封装 SnapshotWidget ,最终的目的就是提高 Flutter 运行的性能。


也许目前对于你来说 toImageSync 并不是必须的,甚至 SnapshotWidget 看起来也很鸡肋,但是一旦你需要处理复杂的绘制场景时, toImageSync 就是你必不可少的菜刀。


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

final的那些事儿

final作为java中基础常用的关键字,相信大家都很熟悉,大家都晓得final可以修饰类,方法和变量且具有以下特性: final修饰类时,该类不可被继承 final修饰方法时,该方法不可被重写 final修饰变量时,如果该变量是基础数据类型,则只能赋值一次...
继续阅读 »

final作为java中基础常用的关键字,相信大家都很熟悉,大家都晓得final可以修饰类,方法和变量且具有以下特性:



  • final修饰类时,该类不可被继承

  • final修饰方法时,该方法不可被重写

  • final修饰变量时,如果该变量是基础数据类型,则只能赋值一次,如果该变量是对象类型,则其指向的地址只能赋值一次


那么除了这些就没有了吗?看以下问题:



  • 当匿名内部类引用函数中的局部变量时,局部变量是基础数据类型会怎样?对象类型会怎样?为什么需要final修饰?

  • 当匿名内部类引用函数中的局部变量时,这个变量在堆上还是栈上?

  • 当对象类型局部变量用final修饰后,如果被子线程持有,在子线程的属性值修改能被主线程感知吗?


如果这些问题你都晓得,那么恭喜你,不用继续往下看了。


当匿名内部类引用函数局部变量时,为什么需要final修饰?


内部类中不修改函数局部变量值


要验证该问题,我们先来简单编写一个内部类引用函数中局部变量的示例,代码如下:


 public interface Callback {
     void doWork();
 }
 
 public class FinalTest {
     public FinalTest() {
    }
 
     public void execute() {
         int variable = 10;
         setCallback(new Callback() {
             @Override
             public void doWork() {
                 System.out.println("method variable is:" + variable);
            }
        });
    }
 
     public void setCallback(Callback callback) {
 
    }
 }
 
 public class Main {
     public static void main(String[] args) {
         FinalTest finalTest  = new FinalTest();
         finalTest.execute();
    }
 }

可以看到在上述代码中,我们在FinalTest的execute方法中,通过Callback这个匿名内部类引用了execute函数中的variable变量,此时虽然variable没有被final修饰,但是代码仍然是可以运行的(基于JDK 1.8)。


有同学要说了,你又乱说,看看你的问题,问的是当内部类引用函数局部变量时,为什么需要final修饰?这没有final不照样跑的好好的?别急,我们来看下该程序的字节码,FinalTest类对应的字节码如下所示:


1-3-3-1


可以看到编译后自动为variable变量添加了final关键词修饰,那么为什么需要使用final关键词来修饰呢?主要有以下几点原因:



  • 生命周期不同: 从运行时内存分区一文中可知,函数中的局部变量作为线程的私有数据,被存储在虚拟机栈对应的栈帧中,当函数结束执行后出栈,而Callback类的doWork方法其调用时机与execute函数的执行结束时机明显不一致,故如果不使用final修饰,不能保证doWork方法执行时variable变量仍然存在。

  • 数据不同步: 从变量数据同步的角度来看,局部变量在传递到内部类时,是以备份的形式拷贝到自己的构造函数中,加以存储利用,如果不添加final修饰,则内外部修改互不同步,造成脏数据。


那么为什么使用fina就能解决以上问题呢?(仅针对基础类型讨论,对象类型见下一部分)


生命周期不同

继续查看FinalTest类字节码,可以看到当variable变量使用final修饰后,其被存储于常量池中,而常量池存储于方法区中,进而生命周期必然是大于Callback类的,variable变量相关字节码如下图:


1-3-3-2


1-3-3-3


数据不同步

查看通过new Callback创建的FinalTest匿名内部类的字节码,代码如下所示:


1-3-3-4


可以看到编译生成的 FinalTest$1这个匿名内部类继承自Callback接口,其通过构造函数持有了外部类FinalTest的引用以及int类型的var2,当variable被final修饰时,由于其值不可修改,故外部的variable变量和 FinalTest$1构造函数中传入的var2值始终保持一致,故而不存在数据不同步的问题。



从这里我们明显可以看出匿名内部类会持有外部类的引用,Handler导致的Activity内部泄漏的场景就是这样引发的


同理也可以看出,为什么匿名内部类访问外部类的成员变量不需要final修饰,主要是这种访问关系都可以转化为通过外部类引用间接访问



内部类中修改函数局部变量值


仍以上文中代码为例,我们在doWork中修改variable变量的值,来看下会怎么样?


1-3-3-5


从上图可以看出编译器提示我们将variable转化成一个单元素的数组,按照编译器提示修改,果然可以正常运行了


1-3-3-6


那么这是为什么呢?不是说final修饰的变量值不能变吗?编译器bug了?


当然不是,这里我们需要明白单元素数组它不是一个基础类型变量,它指向的是一块内存地址,当其被final修饰时,说的是该变量不能重新指向一块新的内存地址,而不是内存地址处存储的内容不可以变化,也就是说我们不能再次执行variable = {20}这种赋值操作(PS:类对象同理,变量不可以重新赋值成新的对象,但是对象的成员属性取值可以发生变化)。



为对对象的指向地址修改和成员属性修改做区分,下文中将成员属性修改简称为内容修改,将地址修改简称为值修改



同时我们也可以从这里了解到当匿名内部类持有函数的局部变量时,是通过符号引用获取的(类结构中常量池中字段说明可以参考<<深入理解Java虚拟机>>), FinalTest$1类中val$variable变量声明及在常量池中引用如下图所示:


1-3-3-8


1-3-3-8


子线程修改final修饰局部变量内容,是否可同步?


仍以上文代码为例,修改FinalTest类如下所示:


 public class FinalTest {
     public FinalTest() {
    }
 
     public void execute() {
         int variable = 10;
         setCallback(new Callback() {
             @Override
             public void doWork() {
                 System.out.println("method variable is:" + variable);
            }
        });
    }
 
     public void execute2() {
         final int[] variable = {10};
         ExecutorService executorService = Executors.newFixedThreadPool(5);
         executorService.execute(new Runnable() {
             @Override
             public void run() {
                 System.out.println("variable[0] is:" + variable[0]);
                 variable[0] = 100;
                 System.out.println("change variable[0] is:" + variable[0]);
            }
        });
         try {
             Thread.sleep(2000);
        } catch (InterruptedException e) {
             throw new RuntimeException(e);
        }
         System.out.println("after change variable[0] is:" + variable[0]);
    }
 
     public void setCallback(Callback callback) {
    }
 }

在Main中运行execute2,执行结果如下图所示:


1-3-3-9


可以看到,数组元素的值确定发生了改变,这也就意味着被final修饰的变量,其内容修改在多线程环境下具有可见性。



final在多线程环境下具有可见性



总结


final1


final,static与synchronized



























关键词修饰类型作用
final类,方法,变量修饰类,则类不可继承; 修饰方法,则方法不可被子类重写; 修饰变量,则变量只能初始化一次
static内部类,方法,变量,代码段修饰内部类,则该类只能访问外部类的静态成员变量和方法,在Handler内存泄漏的修复方案中就有静态内部类的方式; 修饰方法,则该方法可以直接通过类名访问 修饰变量,则该变量可以直接通过类名访问,在类的实例中,静态变量共享同一份内存空间,故其具有全局性质 修饰代码段,则该代码段在类加载的时候就会执行,由于类加载是多线程安全的,所以可以通过静态代码段实现一些初始化操作而不用担心多线程问题
synchronized方法,代码段修饰方法时,则该方法为同步方法,使用该方法所在的类对象做为锁对象,多线程环境下,排队执行 修饰代码段时,一般会指定所使用的锁对象,多线程环境下,该代码段排队执行

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

拥有思想,你就是高级、资深、专家、架构师

当然要想成为高级工程师或者架构师,光看书是不行的,书本上来的东西都是工具型编程的体现,何为工具型编程呢? 就是说可以依据书本、网络等渠道就能完成的编程就是工具型编程,那怎么解决呢? 为什么要提升编程思想 这个问题我想大家都有答案,编程思想就是一个程序员的灵魂...
继续阅读 »

当然要想成为高级工程师或者架构师,光看书是不行的,书本上来的东西都是工具型编程的体现,何为工具型编程呢?


就是说可以依据书本、网络等渠道就能完成的编程就是工具型编程,那怎么解决呢?


为什么要提升编程思想



这个问题我想大家都有答案,编程思想就是一个程序员的灵魂,没有灵魂的程序员,只配ctrl + C/V.



专业一点来讲,提升编程思想的重要性在于它能够帮助开发者更好地解决问题、提高效率、减少错误,并提高代码的可读性、可维护性和可扩展性,而这些点位就是成为一个高级Android工程师或者架构师必不可少的技能,也是每一个程序员应该具备的技能。在国外,很多面试更看重的是学习能力和编程思想,其实也是,一个10年的经验丰富的程序员学习一门新的语言或者技术如同探囊取物,对于一个公司、一个团队、一个业务成本来讲,有这样一个人是最经济的。更具体来讲:



  1. 解决问题能力:良好的编程思想能够帮助开发者更好地理解问题,设计出高效、可靠、可扩展的解决方案。

  2. 代码质量提升:优秀的编程思想可以帮助开发者写出易于阅读、易于维护的代码,并使其更加健壮、可靠、可测试。

  3. 工作效率提高:合理的编程思想可以使开发者更加高效地编写代码,并降低代码调试和修复的时间。

  4. 技术实力提升:良好的编程思想可以使开发者更加深入地理解编程语言和计算机科学原理,并在实践中掌握更多的技能和技巧。

  5. 职业发展:具备良好编程思想的开发者在技术水平和职业发展方面具有更好的竞争力和前景。


良好的编程思想可以帮助开发者更好地解决问题、提高效率、提高代码质量和可维护性,并在职业发展中具有更好的前景和竞争力,这也就成了了中、高、架构等分级程序员的区别之分。


如何提升自己的编程思想



  1. 练习算法和数据结构:熟悉算法和数据结构可以帮助你更好地理解和解决问题,优化你的代码并提高你的代码质量。

  2. 阅读源代码:阅读其他优秀项目的源代码可以帮助你学习其他开发人员的编程思想,理解他们是如何解决问题的,进而提高自己的编程思维。

  3. 学习设计模式:设计模式是一种常用的编程思想,它可以帮助你更好地组织你的代码,提高代码的可维护性和可扩展性。

  4. 参与开源项目:参与开源项目可以帮助你学习其他开发人员的编程思想,理解他们是如何解决问题的,同时也可以帮助你获取更多的开发经验和知识。

  5. 持续学习:跟上Android开发的最新技术和趋势可以让你更好地了解开发环境和市场需求,并提升自己的编程思想。

  6. 经常review自己的代码:经常review自己的代码可以帮助你发现自己代码中的问题并及时改进,同时也可以帮助你学习其他开发人员的编程思想。


接下来,我们对这些步骤的项目进行分析和说明,


练习算法和数据结构



熟悉算法和数据结构可以帮助你更好地理解和解决问题,优化你的代码并提高你的代码质量, Android 这种移动平台,性能要求是非常高的,但是他的机型众多,系统不一,所以我们应该从编程角度就减少不必要的麻烦。怎么练习呢?




  1. 选择适合自己的算法练习平台:例如LeetCode、HackerRank、Codeforces等。这些平台都提供了大量的算法题目,可以帮助你提高算法水平。

  2. 学习基础算法:如排序、查找、树、图等算法,这些算法是其他算法的基础,掌握好基础算法对于提高算法能力非常有帮助。

  3. 练习算法的具体类型:例如贪心算法、动态规划、分治算法等,这些算法在实际开发中非常常见,掌握好这些算法可以让你更好地解决实际问题。

  4. 尝试实现算法:通过手写实现一些经典算法,你可以更好地理解算法的思想和实现方式,并加深对算法的理解。

  5. 参与算法竞赛:参与算法竞赛可以帮助你提高算法能力,同时也可以认识到其他优秀的算法工程师。


很多人在开发过程中公司是不会要求有算法参与的,特别是在Android端,也很少有人在开发中精心设计一款算法用于Android业务,Android的数据量级都在可控范围内,但是优秀的程序员不是应公司要求而编程的,我们因该在面对数据的时候,自然而然的想到算法,想到时间复杂度、空间复杂度的问题算法-时间复杂度 这是之前我在公司分享过的一篇文章,大家可以参考一下,基本涵盖了时间复杂度及在不同场景中的计算方式以及在特殊场景下的计算概念。


举几个例子,怎么将算法运用到平时的开发中呢?



  1. 优化算法复杂度:在实际开发中,我们常常需要处理大量数据,如果算法复杂度高,就容易导致程序运行缓慢。因此,优化算法复杂度是非常重要的。比如,在ListView或RecyclerView中使用二分查找算法可以快速查找到指定位置的数据。

  2. 应用动态规划算法:动态规划算法可以用于解决一些经典问题,例如最长公共子序列、背包问题等。在实际开发中,我们也可以应用动态规划算法解决一些问题,例如路径规划、字符串匹配等。

  3. 应用贪心算法:贪心算法是一种可以获得近似最优解的算法,可以用于一些优化问题。在Android开发中,例如布局优化、图片压缩等方面,也可以应用贪心算法来达到优化的效果。

  4. 应用其他常用算法:除了上述算法外,其他常用算法也可以应用于Android开发中,例如图像处理算法、机器学习算法等。对于一些比较复杂的问题,我们也可以引入其他算法来解决。


反正就是学之前要理解对应算法的大致用途,在类似场景中,直接尝试搬套,先在伪代码中演算其结果,结果正趋向时果断使用。


阅读源码



阅读其他优秀项目的源代码可以帮助你学习其他开发人员的编程思想,理解他们是如何解决问题的,进而提高自己的编程思维



前边不是说了,工具型编程可以不用看吗,是的,但是阅读源码不是查看工具,而是提升你的编程思想,借鉴思想是人类进化的最主要体现之一,多个思想的碰撞也能造就更成功的事件。那怎么阅读源码呢?这个每个人都有自己的方法,阅读后要善于变通,运用到自己的项目中,我是这么做的。



  1. 选择合适的开源项目:选择一个合适的开源项目非常重要。你可以选择一些知名度比较高的项目,例如Retrofit、OkHttp、Glide等,这些项目通常质量比较高,也有一定的文档和教程。

  2. 确定目标和问题:在阅读源码前,你需要明确自己的目标和问题。例如,你想了解某个库的实现原理,或者你想解决一个具体的问题。

  3. 仔细阅读源码:在阅读源码时,需要仔细阅读每一个类、方法、变量的注释,了解每一个细节。同时也需要了解项目的整体结构和运行流程。

  4. 了解技术背景和思路:在阅读源码时,你需要了解作者的技术背景和思路,了解为什么选择了某种实现方式,这样可以更好地理解代码。

  5. 实践运用:通过阅读源码,你可以学到许多好的编程思想和技巧,你需要将这些思想和技巧运用到自己的开发中,并且尝试创新,将这些思想和技巧进一步发扬光大。


阅读源码需要持之以恒,需要不断地实践和思考,才能真正学习到他人的编程思想,并将其运用到自己的开发中。


学习设计模式



设计模式本就是编程思想的总结,是先辈们的经验绘制的利刃,它可以帮助你更好地组织你的代码,提高代码的可维护性和可扩展性。




  1. 学习设计模式的基本概念:学习设计模式前,需要了解面向对象编程的基本概念,例如继承、多态、接口等。同时也需要掌握一些基本的设计原则,例如单一职责原则、开闭原则等。

  2. 学习设计模式的分类和应用场景:学习设计模式时,需要了解每个设计模式的分类和应用场景,例如创建型模式、结构型模式、行为型模式等。你需要了解每个模式的特点,以及何时应该选择使用它们。

  3. 练习设计模式的实现:练习实现设计模式是学习设计模式的关键。你可以使用一些例子,例如写一个简单的计算器、写一个文件读写程序等,通过练习来加深对设计模式的理解。

  4. 将设计模式应用到实际项目中:将设计模式应用到实际项目中是学习设计模式的最终目标。你需要从项目需求出发,结合实际场景选择合适的设计模式。举例来说,下面是一些在Android开发中常用的设计模式:

    • 单例模式:用于创建全局唯一的实例对象,例如Application类和数据库操作类等。

    • 适配器模式:用于将一个类的接口转换成客户端期望的另一个接口,例如ListView的Adapter。

    • 工厂模式:用于创建对象,例如Glide中的RequestManager和RequestBuilder等。

    • 观察者模式:用于实现事件机制,例如Android中的广播机制、LiveData等。




学习设计模式需要不断练习和思考,通过不断地练习和实践,才能真正将设计模式灵活运用到自己的项目中。


参与开源或者尝试商业SDK开发



参与开源,很多同学是没有时间的,并且国内缺少很多开发团队项目,都是以公司或者团队模式开源的,个人想在直接参与比较困难,所以有条件的同学可以参与商业SDK的开发,
商业SDK比较特殊的点在于受众不同,但是他所涉及的编程思想较为复杂,会涉及到很多设计模式和架构模式。



比如,Android商业SDK开发涉及到很多方面,下面列举一些常见的考虑点以及经常使用的架构和设计模式:



  1. 安全性:SDK需要考虑用户隐私保护和数据安全,确保不会泄露敏感信息。

  2. 稳定性:SDK需要保证在不同的环境下运行稳定,不会因为异常情况而崩溃。

  3. 可扩展性:SDK需要考虑未来的扩展和升级,能够方便地添加新的功能和支持更多的设备和系统版本。

  4. 性能:SDK需要保证在各种设备和网络条件下,响应速度和性能都有足够的表现。

  5. 兼容性:SDK需要考虑在不同版本的Android系统和各种厂商的设备上,都能够正常运行。


经常用到的架构和设计模式包括:



  1. MVVM架构:MVVM是Model-View-ViewModel的简称,通过将视图、模型和视图模型分离,可以实现更好的代码组织和更容易的测试。

  2. 单例模式:单例模式是一种创建全局唯一对象的模式,在SDK中常用于创建全局的配置、管理器等。

  3. 工厂模式:工厂模式是一种创建对象的模式,SDK中常用于创建和管理复杂的对象。

  4. 观察者模式:观察者模式是一种事件机制,SDK中常用于通知应用程序有新的数据或事件到达。

  5. 适配器模式:适配器模式用于将一个接口转换成另一个接口,SDK中常用于将SDK提供的接口适配成应用程序需要的接口。

  6. 策略模式:策略模式是一种动态地改变对象的行为的模式,SDK中常用于在运行时选择不同的算法实现。


Android商业SDK开发需要综合考虑多个方面,选择适合的架构和设计模式能够提高代码质量、开发效率和维护性。


了解市场、了解业务,不要埋头敲代码



掌握最新的市场需求,比如网络框架的发展历程,从开始的HttpURLConnection的自己封装使用,到okhttp,再到retrofit, 再后来的结构协程、Flow等等,其实核心没有变化就是网络请求,但是,从高内聚到,逐层解耦,变的是其编程的思想。



CodeReview



可以参考该文章,此文章描述了CodeReview 的流程和方法,值得借鉴,CodeReview 是一个天然的提升自己业务需求的过程,
zhuanlan.zhihu.com/p/604492247



经常写开发文档



设计和编写开发文档是一个很重要的工作,它不仅能够提升自己的编程思想,也能够帮助团队提高协作效率和减少沟通成本.



如果要求你在开发一个需求前对着墙或者对着人讲一遍开发思路,你可能讲不出来,也不好意思,且没有留存,开发文档可以满足你,当你写开发文档时,你记录了你的对整个需求的开发,以及你编程的功底,日益累积后,你的思想自然会水涨船高,因为你写开发文档的过程就是在锻炼自己,比如我在前公司开发国际化适配时写的文档(当然只是我的粗鄙想法国际化ICU4J 适配及SDK设计,我会先分析问题,为什么?然后设计,并且会思考可能遇到的问题,也一并解决了。时间长了,设计模式、思想也会得到提升。


当然,也要分场景去设计,按需求去设计,可以采纳以下建议:
设计和编写开发文档是一个很重要的工作,它不仅能够提升自己的编程思想,也能够帮助团队提高协作效率和减少沟通成本。下面是一些关于如何设计一份好的开发文档的建议:



  1. 明确文档的目标和受众:在编写文档之前,需要明确文档的目标和受众,确定文档需要包含的内容和写作风格

  2. 使用清晰的语言和示例:使用简洁、清晰的语言描述问题,使用示例代码和截图帮助读者理解问题。

  3. 分层次组织文档:文档应该按照逻辑和功能分层次组织,每一层都有明确的目标和内容。

  4. 使用图表和图形化工具:图表和图形化工具能够有效地展示复杂的概念和数据,帮助读者更好地理解文档内容。

  5. 定期更新和维护文档:开发文档需要定期更新和维护,以反映最新的代码和功能。


通过设计一份好的开发文档,可以提升自己的编程思想,使得代码更加清晰和易于维护,同时也能够提高团队的协作效率和代码质量。


向上有组织的反馈



经常向领导有组织的汇报开发进度、问题、结果,不仅可以提升编程思想,还能够提高自己的工作效率和沟通能力



首先,向领导汇报开发进度、问题和结果,可以让自己更加清晰地了解项目的进展情况和任务的优先级,帮助自己更好地掌控项目进度和管理时间。


其次,通过向领导汇报问题,可以促使自己更加深入地了解问题的本质和解决方案,同时也能够得到领导的反馈和指导,帮助自己更快地解决问题。


最后,向领导汇报开发结果,可以帮助自己更好地总结经验和教训,促进自己的成长和提高编程思想。同时,也能够让领导更清晰地了解自己的工作成果,提高领导对自己的认可和评价。


向领导有组织地汇报开发进度、问题和结果,不仅能够提升编程思想,还能够提高工作效率和沟通能力,促进自己的成长和发展。


总结



  1. 编程思想的提升



  • 学习数据结构和算法,尤其是常见的算法类型和实际应用

  • 阅读优秀开源代码,理解代码架构和设计思想,学习开发最佳实践

  • 学习设计模式,尤其是常见的设计模式和应用场景



  1. 实际项目开发中的应用



  • 通过代码重构,优化代码质量和可维护性

  • 运用算法解决实际问题,例如性能优化、数据处理、机器学习等

  • 运用设计模式解决实际问题,例如代码复用、扩展性、灵活性等



  1. 沟通与协作能力的提高



  • 与团队成员保持良好的沟通,及时反馈问题和进展情况

  • 向领导有组织地汇报开发进度、问题和结果,以提高工作效率和沟通能力

  • 参加技术社区活动,交流分享经验和知识,提高团队的技术实力和协作能力


以上是这些方面的核心点,当然每个方面都有很多细节需要关注和完善,需要持续学习和实践。


附件



以下是我之前为项目解决老项目的图片框架问题而设计的文档,因名称原因只能图片展示



首先,交代了背景,存在的问题


image.png


针对问题,提出设计思想


image.png


开始设计,从物理结构到架构


image.png


image.png


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

程序员英语学习指南

动机为什么程序员要学习英语?工作:我们每天接触的代码都是英文的、包括很多技术文档也是英文的学习:最新最前沿的技术最开始都是只有English版本就业:学好英语让你的就业范围扩大到全球,而不只限于国内目标读: 流畅的阅读英文文档、英文论坛。写: 可以简单的写一些...
继续阅读 »

动机

为什么程序员要学习英语?

  • 工作:我们每天接触的代码都是英文的、包括很多技术文档也是英文的

  • 学习:最新最前沿的技术最开始都是只有English版本

  • 就业:学好英语让你的就业范围扩大到全球,而不只限于国内

目标

读: 流畅的阅读英文文档、英文论坛。

写: 可以简单的写一些英文的Issue、Readme等。

借助 APP

多邻国

多邻国是一个免费的多语言学习网站,界面清爽友好,对零基础非常友好,更有游戏般通关的体验。

不止可以学习英语更支持40+种语言的学习。

安卓、IOS都支持。

墨墨背单词

墨墨背单词是根据艾宾浩斯遗忘曲线的算法打造的高效抗遗忘,精准规划海量记忆的一款APP.

可以将不会的单词记录下到墨墨里就可以做到反复练习,强化记忆了。

安卓、IOS都支持。

借助影视剧

  • 老友记

  • 摩登家庭

  • 绝望主妇

  • ...(根据自己爱好选择)

浏览器插件

阅读提升

技术类

Vue、React、Node官方文档都可以,除此之外可以看看其他的技术网站:

新闻类

如果你比较关注实时新闻的话,不妨多看看这些。

总结

没事刷刷美剧、看看英文论坛、新闻,然后将不会的单词在墨墨中反复练习。

然后经常逛逛Dev、StackOverflow等论坛,即可增加技术能力也可以熟悉学习英文。

再比如写中文文章写出来后尝试用英语也写一遍等等。

也可以和同学、朋友、网友一起尝试用英文沟通,如果有认识外国人(最近很火的chatGPT也可以)沟通就更好了。

总结一下,大概的方法:

  1. 多看英文文档、论坛

  2. 多使用英语交流、写作

  3. 观看英语电影,电视和新闻

  4. 使用英语学习软件和网站,比如多邻国这种

  5. 加入英语学习小组或找英语学习伙伴一起学习

  6. 并将不会的单词记录下到墨墨反复练习

最后,关键在于持之以恒

作者:九旬
来源:juejin.cn/post/7199882882139373625

收起阅读 »

我终于统一了团队的技术方案设计模板

团队的技术方案设计模板不管我们是做业务开发,还是做基础建设,虽然产品诉求千差万别,但是我们必然需要做好方案设计,然后还需要进行方案设计评审。之前我们团队的一些成员,甚至有些 T9 级别的同学,竟然都写不好一个技术方案设计文档。究其根本,主要还是没有形成自己的方...
继续阅读 »

团队的技术方案设计模板

不管我们是做业务开发,还是做基础建设,虽然产品诉求千差万别,但是我们必然需要做好方案设计,然后还需要进行方案设计评审。

之前我们团队的一些成员,甚至有些 T9 级别的同学,竟然都写不好一个技术方案设计文档。究其根本,主要还是没有形成自己的方法论,从我个人工作这么多年的经验来看,技术方案设计是可以总结出一套方法论或者说框架套路来的。为此,我总结出了一套通用的技术方案设计模板(提纲),然后在我们团队内部进行了统一,后面还推广到了整个中心,大家按照这个模板来写方案设计,绝对让你的领导满意。

大家参考我这个方案设计模板(提纲)和相关介绍来做自己的方案设计的时候,可以根据自己的实际业务情况和背景做相关目录的删减,最后得出自己最终的方案设计,然后再去进行方案评审。

精简版-技术方案设计模板(提纲)

精简版的模板如下,一般的方案设计,大家都可以参考这个提纲来写:


详细版-技术方案设计模板(提纲)

相对详细和复杂的版本如下:



下面是技术方案设计模板在每一章节的简单说明,用来帮助你理清每个章节大概要写什么内容

一,现状

现状,主要是用来描述当前这个业务(项目)的一些基本情况介绍和相关的背景。你的方案设计出来之后,是需要给你的 leader 或者团队其他成员进行评审或者查看,甚至是要给更高级别的人来评审。但是别人不可能都和你一样清楚你的项目,因此首先,你要把你项目的基本情况和背景都说清楚,让大家达成一个共识,站在同一个起点上,才能进行后面的方案评审和讨论。

业务背景

业务背景就是你这个业务(项目)的基本介绍,包括但不限于:

  • 项目名称

  • 业务描述

技术背景

技术背景就是你这个业务是基于什么样的技术背景下来构建的,我们的技术方案可能是从 0 到 1 来构建,也能是基于现有的方案来优化,但是不管是什么场景,一定都会存在相关的技术背景,因此包括但不限于:

  • 现有技术积淀

  • 现有架构描述

  • 现有系统的整体容量

二,需求

需求,很重要!技术人员千万不要忽略需求,因为不管你的技术有多牛逼,都一定为需求服务的,不管这个需求是技术需求,还是业务需求,一定都是要为需求服务。而需求,就是你这个技术方案的起点,技术方案一切都是围绕需求来设计,当然,这个需求可以是当下的需求,也可以包含未来潜在的需求。

只有把需求介绍清楚之后,大家才能知道你方案设计里面的所有设计和对应的折中点是否可行,也才能比较好的去评审你的方案。

业务需求

业务需求就是你这个业务具体要做的事情,包括但不限于:

  • 要改造的内容

  • 要实现的新需求

业务痛点

  • 涉及到的业务痛点有哪些

性能需求

我们做需求的时候,对于技术人员,不能只看业务需求,业务需求可能是项目管理人员,也可能是产品人员提出来的,他们只会重点关注业务的可行性,只会关注业务的逻辑。但是技术人员,要从这个业务需求里面考虑清楚我们满足这个业务之下的性能需求点,比如我做一个秒杀活动,如果你不考虑性能,可能活动一上来,服务就挂掉了。性能需求包括但不限于:

  • 预估系统平均容量

  • 预估系统峰值容量

  • 可伸缩性

  • 其他的一些性能要求点,比如安全性等

三,方案描述

前面把现状和需求说清楚后,终于到了我们的重头戏,方案描述这里了。一般我们做方案,可能会有几个可选的方案,但是你不清楚哪个方案最合适,因此你需要把相关可能的方案都描述清楚,然后给出你认为的最合适的方案,然后让大家来评审和决策,看是否同意你的意见或者有其他更好的意见。

如果没有方案对比,那么可以省略掉这一章节

方案1

概述

一句话概括方案的亮点,比如说:高性能、可扩展、双写、主从分离、分库分表、扩容等。

详细说明

详细说明这里需要图文结合,包括但不限于架构图、流程图 等。把你整个方案的架构和模块、细节流程都描述清楚

性能目标

性能一般来说可能包含以下部分:

  • 日平均请求:一般来自产品人员的评估;

  • 平均QPS:日平均请求 除以 4w秒得出,为什么是4w秒呢,24小时化为86400秒,取用户活跃时间为白天算,除2得4w秒;

  • 峰值QPS:一般可以以QPS的2~4倍计算;

性能评估

给出方案的基准数据,并按性能需求评估需要使用的资源数量。

  • 单机并发量

  • 单机容量

  • 按照预估性能需求,预估资源数量(应用服务器、缓存、存储、队列等)

  • 伸缩方式

方案优缺点

列出方案的优缺点,优缺点要具有确定性,最好是通过量化的指标来说明

方案2

可选的另外一种方案,模板和上面一样。

方案对比

前面给出了多种可选的方案,那么这里就是进行一个简单的对比,然后给出你觉得最优的方案和原因,这就是你的决策。

有了你自己的决策(倾向)的方案后,接下来的设计就应该更多的偏向你倾向的方案去做设计和描述

四,线上方案

线上方案是对上面你更倾向的方案的更为细致的描述。

架构图

整体架构是如何,把架构图画上

关键设计点 和 设计折衷

把几个关键、重点的点的设计思想表述出来,用来确保你的方案的大体方向是 OK 的。

因为没有一个方案设计是最完美,方案设计都是逐步演进和优化的,方案设计是要最符合当前的背景的。因此,一定会有你设计的关键点和折衷点,这也就是前面为何要把项目的各种业务背景和技术背景都说清楚的原因。

业务流程

整体流程是如何,弄一个整体流程图、核心流程图出来,然后分业务场景把各个业务场景的流程图也画出来,并且做好相关介绍

模块划分

有了业务流程,那么必然要针对这个业务流程的各个环节来划分模块,模块的划分需要考虑我们架构设计的一些原则,比如:架构分层、业务分模块、微服务化、高内聚低耦合 等。然后把每个模块的功能点都说清楚

异常边界【重要】

异常边界是比较重要的,一般情况下,大部分人都能考虑到正常的处理流程,对于异常的边界考虑的比较少,但是线上出问题,大部分都是异常情况导致,因此这里非常重要!!!

我们可以通过一个 xmind 格式去整理相关的异常边界,这样有助于自己在实现的时候有足够的把控度,也便于别人去 review 你的方案和具体实现(如 coding)

异常边界需要考虑:

  • 涉及到了哪些模块

  • 涉及到了哪些流程

  • 每个模块、流程出现了各种可能情况的处理是?

  • 系统底层原因导致的异常的处理是 ?

统计、监控

线上运行的项目,一定需要有各种监控,除了公司内部的基建的监控外,我们可能还需要从业务内部实现自定义的一些业务监控和相关技术统计

灰度、回滚策略

  • 如何灰度?

  • 如何回滚?

容灾方案

容灾就是当出现 IDC 异常的情况下,怎么容灾,这个可以根据实际情况去考虑。

五,部署拓扑

线上部署拓扑如何,上下游是如何

六,风险评估

标识所选方案的风险,提出解决此风险发生时候的应对策略,比如:上线失败时的回滚策略。

潜在风险

  • 相关的改动有哪些风险点

  • 不兼容点?

  • 当前设计方案目前存在哪些问题?

  • 潜在有哪些问题

七,阶段规划【架构演进规划】

架构怎么演进

阶段如何规划

每个阶段该达成什么目标

第一阶段

第二阶段

第三阶段

八,工作量评估

工作量评估也是一个重要的环节,这里需要细化到每个模块、每个接口的设计分别需要多长时间,一定要同时包括开发时间、联调时间、测试时间。

来源: 后端系统和架构

收起阅读 »

TCP 长连接层的设计和在 IM 项目的实战应用

TCP 长连接接入层的连接管理TCP 长连接的管理思路实现思路IM 架构中的 TCP 长连接接入层的 NET 连接一般会很多,比如单台服务器至少会有几十万,有的甚至会到百万连接;这个长连接的维持,也就代表中会有这么多客户端(用户)的接入。那么我们怎么去管理这些...
继续阅读 »

TCP 长连接接入层的连接管理

TCP 长连接的管理思路

实现思路

IM 架构中的 TCP 长连接接入层的 NET 连接一般会很多,比如单台服务器至少会有几十万,有的甚至会到百万连接;这个长连接的维持,也就代表中会有这么多客户端(用户)的接入。那么我们怎么去管理这些连接?当有数据需要下发的时候,怎么能够快速根据连接信息找到用户、或者根据用户快速定位到网络连接?这就需要我们能够有一个合适的数据结构去维护,并且我们需要考虑一些其他的点比如快速定位、机器内存大小等。

最容易想到的一个思路是通过 map 数据结构来管理,比如 map<conn,user>,因为每个用户的 uid(user)是唯一,因此,这样做,初期来看,并不会有太大的影响;但是试想一下,这个只能单向定位,只能根据 Conn 网络连接查找用户,那么我想根据用户信息快速查找到对应的 Conn 然后下发数据怎么办呢?

为此,一个更为合适的做法就是将用户和网络连接进行一一对应,这样,不仅是可以相互查找,并且查找定位的时间复杂度总是 O(1)。具体实现的 Golang 代码如下,只列出关键信息:

// Conn 与 User 一一映射,用来优化 map 查询方式

type Conn struct {
  conn       net.Conn // TCP 网络 连接信息
    user       *User     // 客户端用户信息(一般包含 uid、name等)
}

type User struct {
  uid             int64
  Name             string
  conn             *Conn
}

这样的结构设计,就是 Conn 里面包含了 User、User 里面包含了 Conn,这样就是一一对应,不管多少数据量,查询定位的时间复杂度都是 O(1)。这里应用了一个思想就是空间换时间,因为我们当前的机器的内存是很大的,所以就可以利用这个空间换时间的思想,快速查询。

应用场景

IM 系统中,必然会有这么几个操作:

  • • 用来连接(accept)

  • • 用户登录(login)

TCP Socket 编程模型是:

socket -> bind -> listen -> accept -> recv -> send -> close

因此对 IM 接入层来说,首先会收到用户的 accept 请求,accept 成功之后,我们就有了 Conn 信息,然后我们开始填充 Conn 结构 和 User 结构,这里算是初步建立起了对应关系,但是 User 中的信息还不够,还需要用户登录之后才有更多的数据。

连接成功之后,用户就会发起登录请求,登录成功之后,就会有了足够的 User 信息,这样就可以根据相关信息相互定位了。登录成功之后,长连接接入层还需要给用户回应 ACK ,因此在登录包之后,长连接接入层就可以从 User 结构中取出 Conn 进行回包给用户(客户端)。

随后的操作中,我们可以根据业务场景的需要,从 User(uid)中快速定位到 Conn,然后发送消息给客户端;也可以根据 Conn 快速定位到 User,更新 User 信息,或者获取 User 信息。

TCP 长连接心跳超时的处理

再来看看另外一个场景,首先,我们要清楚,长连接接入层一定是有多个的,一台机器肯定扛不住,也无法做到高可用。因此在每个接入层节点中的处理上,还有一点非常重要的就是,维持着大量长连接后,如果客户端一直没有请求,或者客户端以为异常导致关闭了连接但是服务端并不知晓,那么这些无用的长连接,服务端肯定是需要清理的,避免占用大量资源。

怎么实现?当然需要通过心跳来保持连接,如果心跳超时则踢出连接。心跳这里多说一句,一般固定心跳设置为 4.5 分钟,还有更为合适的智能心跳策略。我们现在重点在于管理 TCP 长连接,不讨论心跳策略的实现。

上面的 TCP 长连接的管理思路是需要一一对应,方便相互查找,那么针对心跳是否超时,这个和用户没有关系,因此只需 Conn 的处理。通过一个红黑树可以搞定,通过递归地从根节点向左遍历节点,直到左节点为空,可以查找树中的所有 Conn 的超时情况。

Golang 的代码片段如下:

var timeoutTree *rbtree.Rbtree  //红黑树


type TimeoutInfo struct {
  conn   *Conn         // 连接信息
  latestTime time.Time //心跳的最新时间
}


每次收到心跳包都重新更新时间
func AddTimeoutCheckInfo(conn *Conn) {
  timeoutTree.Insert(&TimeoutInfo{conn: conn, latestTime: time.Now()})
}

独立协程来遍历扫描并清除超时的连接:

    for {
      // 遍历
      item := timeoutTree.Min()

      // 取连接、取最新时间
      latestTime := item.(*TimeoutInfo).latestTime
      conn := item.(*TimeoutInfo).conn

      // 计算连接的最新时间是否超时,超时则关闭连接和清理
      if timeout {
          timeoutTree.Delete(item)
          conn.Close()
      }
  }

TCP 长连接层的负载均衡策略

既然长连接接入层节点有多个,并且可以随时根据需要扩缩容,然而客户端并不清楚你服务端到底部署了多少台节点,那么客户端该怎么发起连接呢?怎么做才能保证合理的负载均衡呢?

一般的负载均衡策略如 RR 轮询,是否能够满足 IM 的诉求呢?试想这么一个真实的场景,当前线上有 5 台机器,每台机器负载都很高了,此时连接会很不稳定,客户端出现频繁重连。此时肯定需要扩容,OK,那么扩容了 2 台,然后 client 建连如果还是轮询,那么新扩容的机器,还是不能马上分散其他机器上的压力,压力还是会往老的机器上面去打,显然不合理。

因此,针对 IM 场景,最合理的负载均衡策略,就是根据连接数来负载均衡,客户端新发起连接需要接入的接入层节点一定是连接数最少的,因为每台节点会需要控制最大连接数的限制才能保证最优性能,并且能够及时给压力大的节点减压。

怎么实现呢?这里就需要有一个服务注册发现的组件(如 Etcd)来帮助我们达到诉求。首先,接入层启动后,往 Etcd 里面注册信息,并且再在后续的生命周期中,定期更新当前节点已有的连接数到 Etcd 中;然后我们需要有一个 Router Server,这个服务去 watch Etcd 中的接入层节点信息,Etcd 的使用可以参考etcd/clientv3;然后实时计算,得到一个列表排序,这个排序是按照节点数最少的节点排序的。

然后 Router Server 提供一个 HTTP 服务的 API 接口,用来返回所有节点中连接数最少的节点的一批 IP 列表(一般可以 3 个)给到客户端。为何不是返回一个呢?因为我们返回的节点,可能因为其他原因导致连接不上,或者连接不稳定,那么此时 客户端就可以有备选方案,选择返回的下一个节点建连。

涉及点包括:

  • • 接入层注册信息(节点 IP 和 port、节点连接数)

  • • 路由层 watch 接入层的信息

  • • 路由层计算路由算法

  • • 路由层提供 HTTP 接口返回合适的节点 IP 列表

TCP 长连接接入层服务的优雅重启和缩容

对于通用的长连接接入层而言

长连接接入层是和用户客户端直接相连的,客户端通过 TCP 长连接连接到接入层,因此接入层如果需要重启,那么必然会导致客户端连接断开,发生重连。如果此时用户正在发送消息,那么必然会导致发送异常,从而影响用户体验。

那么我们需要怎么实现接入层,才能保证重启或者缩容的时候,不影响用户、对用户无感知呢?有这么几个思路:

  1. \1. 接入层做的足够轻量,尽量只是维持 TCP 长连接和数据包的转发,所有其他业务逻辑,尽量转发到业务层去处理,接入层与业务逻辑层严格分离;因为业务层逻辑是需要频繁变动,而接入层的长连接维持可以做到尽量不变,这样就会尽可能的减少重启。

  2. \2. 接入层尽可能的做到无状态化,方便随时的扩缩容;这样就需要有一个叫用户中心的服务来保存用户的各种状态和信息,如在线状态、离线状态、用户是通过哪个接入层节点连接的;通过这个方式,用户就可以随意接入到任何接入层节点,并且接入层节点也可随时扩缩容;这样的话,业务逻辑层就可以和用户中心通过 RPC 通信获取用户的各种连接信息和是否在线的状态,然后精准下发消息到指定接入层,然后接入层将消息下发给客户端用户。

  3. \3. 主动迁移信令。增加一条信令和客户端进行交互,服务端如果要重启/缩容,那么主动告知连接在此接入层节点上的所有客户端,服务端主动发送迁移信令,比如 publish(迁移信令,100%),表示发送给所有此接入层节点上的客户端,客户端收到此迁移信令后,就主动进行重新连接其他节点。因为是客户端主动断开重连其他节点的,虽然还是会有重连,但是客户端是主动发起的,因此可以通过代码逻辑来保证从业务逻辑上不会影响用户的体验,这样的话,用户在操作上就会无感知,从而提升用户体验。同时,接入层节点要发送主动迁移信令之前,需要先从服务发现与注册中心(Etcd)中下线自己,避免重连的时候还继续连接到此节点。然后当重启之前,也需要判断一下是否当前节点上所有的用户连接都已经迁移到其他节点上了。

长连接接入层的优雅扩容方案

扩容方案是指在线用户越来越多,当前已有的接入层节点已经扛不住了,需要扩容接入层节点来分摊在线用户的连接和请求。这里分两种情况考虑:

  • • 其他节点的压力还相对较小,但是事先预知到需要扩容,也就是提前扩容。此时按照路由层的最小连接数优先接入请求的策略并无不妥,新扩容的可以均摊流量,原有的节点也不会因为压力过大而导致性能问题。

  • • 其他节点压力已经扛不住了,需要紧急扩容并且快速给老的节点减压。这个时候,如果还仅仅只是新增节点,然后根据原有的负载均衡路由策略来减压是达不到减压效果的,因为只有新的连接才会接入到新扩容的节点;原有老的节点上的连接如果没有断连那么还是继续维持在原有节点上,因此根本不能给老的节点减压。

  • • 所以,就需要服务端有更好的机制,通过服务端的机制来促使客户端重新连接到新的节点上,从而进行减压。这里,还是需要一个迁移信令,但是这个信令服务端只是需要随机发送给部分比例的用户,比如 publish(迁移信令,30%),表示发送迁移信令给 30% 比例的用户,让这 30%的用户重连到新的节点上。

TCP 长连接层节点怎么防止攻击

基本的防火墙策略

公司内常规的防火墙策略,通过 iptable 设置 iptables 的防火墙策略。比如限制只能接收指定 IP 和 Port 的包,避免攻击者通过节点上其他端口的漏洞登录机器;比如只接收某些协议(TCP)的包。

SYN 攻击

SYN 攻击是一个典型的 DDOS 攻击,具体就是攻击客户端在短时间内伪造大量不存在的 IP 地址,然后向服务端发送 TCP 握手连接的 SYN 请求包,服务端收到 SYN 包后会回复 ACK 确认包,并等待客户端的 ACK 确认。但是,由于源 IP 地址不是真实有效的,因此服务端需要不断的重发直至 63s 超时后才会断开连接。这些伪造的 SYN 包将长时间占用未连接队列,引起网络堵塞甚至系统瘫痪,让正常的 TCP 握手连接请求不能处理。通过 netstat -n -p TCP | grep SYN_RECV 可以查看是否有大量 SYN_RECV 状态,如果有则可能存在 SYN 攻击。

Linux 在系统层面上,提供了三个选项来应对相关攻击:

  • • tcp_max_syn_backlog,增大 SYN 连接数

  • • tcp_synack_retries,减少重试次数

  • • tcp_abort_on_overflow,过载直接丢弃,拒绝连接

另外,还有一个 tcp_syncookies 参数可以缓解,当 SYN 队列满了后,TCP 会通过相关信息(源 IP、源 port)制造出一个 SYN Cookie 返回,如果是攻击者则不会有响应,如果是正常连接,则会把这个 SYN Cookie 发回来,然后服务端可以通过 SYN Cookie 建连接。

TCP 长连接层面上

黑名单机制

可以静态或者动态配置黑名单列表,处于黑名单中的 IP 列表则直接拒绝 accept 建连;服务端执行 accept 之后,首先先判断 remote IP 是否存在于黑名单中,如果是则直接 close 连接;如果不是则继续下一步。

限制建连速度

IM 系统为了防止恶意攻击,需要防止单个 IP 大量频繁建连,避免异常 socket 连接数爆满;因此需要限制每个 IP 每秒建立速度,如果单个 IP 在单位时间内建连的连接数超过一定阈值(如 100)该值,则将 IP 列入黑名单并且同时关闭此连接

怎么实现呢?分如下几步。

\1. 定义一个合理的防攻击的数据结构,里面包含 connRates 字段、startTime 字段。

  • • startTime 表示此连接接入的初始时间

  • • connRates 用来对统计时间内的接入 IP 做累加

\2. 服务端每次 accept 之后,针对这个 Conn 连接,先判断当前时间和此连接的 startTime 的差值是否已经超过一个统计周期,如果超过则清零重置;如果没有超过,则对此连接的 IP 做累加。

\3. 然后判断 IP 累加的结果是否超过阈值,如果超过则加入黑名单并且 close 连接;如果没有超过则进行下一步的请求。

限制发包速度

IM 系统要能够发送消息包,必然需要先进行登录操作,登录主要是为了鉴权,从而获取得到正确的 token,才能正常登录。为了避免 token 等被窃取,为了更为安全,登录之后发送消息的频率也需要进行控制;控制的机制就是针对单个连接限制每秒处理包的上限,在单位时间内收到的包的请求数量超过一定阈值(如 100p/s)则直接丢弃。

怎么实现呢?需要几个步骤:

  • • 针对每个 Conn 的数据结构,增加一个 packetsNum 字段;

  • • 当前 Conn 每收到一个包,先计算统计时间内 packetsNum 的次数是否超过阈值,然后 packetsNum++;如果超过阈值则丢包并返回错误;

  • • 开一个定时器,每隔一个统计时间周期,清零 packetsNum。

TLS 加密传输

TLS 安全传输层协议用于在两个通信应用程序之间提供保密性和数据完整性,是我们 IM 系统中保证消息传输过程中不被截获、篡改、伪造的常用手段。

TLS 过程使用到了对称加密、非对称加密、CA 认证等,安全性非常高;但是相比于 TCP 传输会多了几个秘钥相关的环节,从而导致整个握手阶段会多出 1~2 个 RTT 的耗时;不过只是握手阶段的耗时对我们 IM 的应用场景并不影响。为此,为了安全性,尽可能的使用 TLS 来建立 TCP 连接

来源: 后端系统和架构

收起阅读 »

一文了解高性能架构和系统设计经验

高性能架构和系统设计经验高性能和高并发,听着就有点类似,并且他们还经常一起提及,比如提高我们的并发性能,显然,高性能可以提高我们的并发,但是细化来看,他们是有区别的,他们的考量点的维度不同。高性能需要我们从单机维度到整体维度去考虑,更多的是先从编码角度、架构使...
继续阅读 »

高性能架构和系统设计经验


高性能和高并发,听着就有点类似,并且他们还经常一起提及,比如提高我们的并发性能,显然,高性能可以提高我们的并发,但是细化来看,他们是有区别的,他们的考量点的维度不同。高性能需要我们从单机维度到整体维度去考虑,更多的是先从编码角度、架构使用角度去让我们的单机(单实例)有更好的性能,然后再从整个系统层面来拥有更好的性能;高并发则直接是全局角度来让我们的系统在全链路下都能够抗住更多的并发请求。

一、高性能架构和系统设计的几个层面

高性能架构设计主要集中在单机优化、服务集群优化、编码优化三方面。但架构层面的设计是高性能的基础,如果架构层面的设计没有做到高性能,仅依靠优化编码,对整体系统的提升是有限的。我们从一个全局角度来看高性能的系统设计,需要整体考虑的包括如下几个层面:

  • 前端层面。 后端优化的再好,如果前端(客户端)的性能不 ok,那么对用户而言,他们的体感还是很差的,因此前端层也是有必要考虑的,只是不在我们本文的设计范围之内,在实际工作中是需要进行探讨的。

  • 编码实现层面:代码逻辑的分层、分模块、协程、资源复用(对象池,线程池等)、异步、IO 多路复用(异步非阻塞)、并发、无锁设计、设计模式等。

  • 单机架构设计层面: IO 多路复用、Reactor 和 Proactor 架构模式

  • 系统架构设计层面:架构分层、业务分模块、集群(集中式、分布式)、缓存(多级缓存、本地缓存)、消息队列(异步、削峰)

  • 基础建设层面:机房、机器、资源分配

  • 运维部署层面:容器化部署、弹性伸缩

  • 性能测试优化层面: 性能压测、性能分析、性能优化

二、前端层面

后端优化的再好,如果前端(客户端)的性能不 ok,那么对用户而言,他们的体感还是很差的,因此前端层也是有必要考虑的,只是不在我们本文的设计范围之内,在实际工作中是需要进行探讨的。

这里简单说明下,从我个人工作的经历来看,前端(客户端)这里可以优化的点包括但不限于:数据预加载、数据本地缓存、业务逻辑前置处理、CDN 加速、请求压缩、异步处理、合并请求、长连接、静态资源等

三、编码实现层面

编码实现层面:代码逻辑的分层、分模块、协程、资源复用(对象池,线程池等)、异步、IO 多路复用(异步非阻塞)、并发、无锁设计、设计模式等。

多线程、多协程

大多数情况下,多进程、多线程、多协程都可以大大提高我们的并发性能,尤其是是多协程。

在网络框架层面,现在一般成熟的后端系统框架(服务化框架)都是支持多线程、多协程的,因此对于网络框架这点,我们只要是引用相对成熟的服务化框架来实现我们的业务,基本上可以不用过多考虑和设计。

在业务层面,如果是 Go 语言,天然支持大量并发,并且创建 Go 的协程非常容易,一个 go 关键字就搞定,因此多协程那就非常容易了,Go 里面可以创建大量协程来提高我们的并发性能。如果是其他语言,我们尽可能的使用多协程、多线程去执行我们的业务逻辑。

无锁设计(lock free)

在多线程、多协程的框架下,如果我们并发的线程(协程)之间访问共享资源,那么需要特别注意,要么通过加锁、要么通过无锁化设计,否则没有任何处理的访问共享资源会产生意想不到的结果。而加锁的设计,在并发较大的时候,如果锁的力度不合适,或者频繁的加锁解锁,又会使我们的性能严重下降。

为此,在追求高性能的时候,大家就比较推崇无锁化的设计。目前很多后台底层设计,为了避免共享资源的竞争,都采用了无锁化设计,特别是在底层框架上。无锁化主要有两种实现,无锁队列和原子操作。

  • 无锁队列。可以通过 链表或者 RingBuffer(循环数组)来实现无锁队列。

  • 原子操作。利用硬件同步原语 CAS 来实现各种无锁的数据结构。比如 Go 语言中的 atomic 包、C++11 语言中的 atomic 库。

数据序列化

为什么要说 数据序列化协议?因为我们的系统,要么就是各个后端微服务之间通过 RPC 做交互,要么就是通过 HTTP/TCP 协议和前端(终端)做交互,因此不可避免的需要我们进行网络数据传输。而数据,只有序列化后,才方便进行网络传输。

序列化就是将数据结构或对象转换成二进制串的过程,也就是编码的过程,序列化后,会把数据转换为二进制串,然后可以进行网络传输;反序列化就是在序列化过程中所生成的二进制串转换成数据结构或者对象的过程,将二进制转换为对象后业务才好进行后续的逻辑处理。

常见的序列化协议如下

  • Protocol Buffer(PB)

  • JSON

  • XML

  • 内置类型(如 java 语言就有 java.io.Serializable)

常见的序列化协议的对比在网上有各种性能的对比,这里就不在贴相关截图了,只说结论:从性能上和使用广泛度上来看,后端服务之间现在一般推荐使用 PB。如果和前端交互,由于 HTTP 协议只能支持 JSON,因此一般只能 JSON。

池化技术(资源复用)

池化技术是非常常见的一个提高性能的技术,池化的核心思想就是对资源进行复用,减少重复创建销毁所带来的开销。复用就是创建一个池子,然后再在这个池子里面对各种资源进行统一分配和调度,不是创建后就释放,而是统一放到池子里面来复用,这样可以减少重复创建和销毁,从而提高性能。而这个资源就包括我们编程中常见到如 线程资源、网络连接资源、内存资源,具体到对应的池化技术层面就是 线程池(协程池)、连接池、内存池等。

  • 线程池(协程池)。本质都是进程、线程、协程这些维度的一个池子,先创建合适数量的线程(协程)并且初始处于休眠状态,然后当需要用到的时候,从池子里面唤醒一个,然后执行业务逻辑,处理完业务逻辑后,资源并不释放,而是直接放回池子里面休眠,等待后续的请求被唤醒,这样重复利用。

    • 创建线程的开销是很大的,因此如果来一个请求就频创建一个线程、进程,那么请求的性能肯定不会太高。

  • 连接池。这个是最常用的,一般我们都要操作 MySQL、Redis 等存储资源,同样的,我们并不是每次请求 MySQL、Redis 等存储的时候就新创建一个连接去访问数据,而是初始化的时候就创建合适数量的连接放到池子里面,当需要连接去访问数据的时候,从池子里面获取一个空闲的连接去访问数据,访问完了之后不释放连接,而是放回池子里面。

    • 连接池需要保证连接的可用性,就是这个连接和 MySQL、Redis 等存储是必须要定期发送数据来保证连接的,要不然会被断开。同时我们要针对已经失效(断开)的连接进行检测和摘除。

  • 内存池。常规的情况下,我们都是直接调用 new、malloc 等 Linux 操作系统的 API 来申请分配内存,而每次申请的内存块的大小不定,所以,当我们频繁 分配内存、回收内存的时候,会造成大量的内存碎片,同时每次使用内存都要重新分配也会降低性能。内存池就是先预先分配足够大的一块内存,当做我们的内存池,然后每次用户请求分配内存的时候,就会返回内存池中的一块空闲的内存,并将这块内存的标志置为已使用,当内存使用完毕释放内存的时候,也不是真正地调用 free 或 delete 来释放内存,而是把这块内存直接放回内存池内并且同时把标志置为空闲。一般业内都有相关的套件来帮我们来做这个事情,比如在 C/C++ 语言里面,都有相关库去封装原生的 malloc,glibc 实现了一个 ptmalloc 库,Google 实现了一个 tcmalloc 库。

  • 对象池。其实前面几种类型的池化技术,其实都可以作为对象池的各种应用,因为各种资源都可以当做一个对象。对象池就是避免大量创建同一个类型的对象,从而进行池化,保证对象的可复用性。

异步IO 和 异步流程

异步有两个层面的意思:

  • IO 层面的异步调用

  • 业务逻辑层面的异步流程

异步是相对同步而言的,同步就是要等待前面一个事情执行完毕才能继续执行,异步就是可以不用等待,可想而知,异步的性能要比同步好很多。

IO 层面的异步调用

针对 IO 层面的异步调用,就是我们常说的 I/O 模型,有 阻塞、非阻塞、同步、异步这几种类型。在 Linux 操作系统内核中,内置了 5 种不同的 IO 交互模式,分别是阻塞 IO、非阻塞 IO、多路复用 IO、信号驱动 IO、异步 IO

针对网络 IO 模型而言,Linux 下,使用最多性能较好的是同步非阻塞模型,具体代表是 AIO,而 Windows 下的代表作 IOCP 则实现了真正的异步非阻塞 I/O。

业务逻辑层面的异步流程

业务逻辑层面的异步流程,就是指让我们的应用程序在业务逻辑上可以异步的执行。通常比较复杂的业务,都会有很多步骤流程,如果所有步骤都是同步的话,那么当这些步骤中有一步卡住,那么整个流程都会卡住,这样的流程显然性能不会很高。为此,在业内,我们如果想要提高性能,提高并发,那么基本上都会采用异步流程的方式。

举个实际的应用案例,针对 IM 系统的发送消息的这个场景,比如微信发送消息,那么当客户端发送的消息,服务端收到后,这个消息肯定要落地存储,这个发送的流程才能算完毕,但是,如果每条消息,服务端都真正存储到 DB 后再返回给客户端说已经正确收到,那么这个性能显然会很低,因为我们知道,写 DB 的性能是很低的,尤其是像微信这种每天有大量消息的 APP。那么这个流程就可以异步化,服务端收到消息后,先把消息写入消息队列,写入队列成功就返回给客户端,然后异步流程去从消息队列里面消费数据然后落地存储到 DB 里面,这样性能就非常高了,因为消息队列的性能会很高。而比较低性能的操作都是异步处理。

并发流程

并发流程,同样是针对我们上层的应用程序而言的,我们在处理业务逻辑的时候,尤其是相对负责的业务逻辑,一般下游都可能会有多个请求,或者说多个流程,如果依赖的下游多个请求之间没有强依赖关系,那么我们可以将这些请求的流程并发处理,这个是后端系统设计里面非常常见的优化手段。

通过并发的处理流程,可以将串行的叠加处理耗时优化为单个处理耗时,这样就大大的降低了整体耗时,举个例子,一个商品活动页面,渲染的数据包括 用户基本信息、用户活动积分、用户推荐商品列表。那么当收到这个用户的请求的时候,我们需要 查询用户的基本信息、用户的活动积分,还有用户的商品推荐,而这 3 个步骤完全是没有相互依赖关系的,因此,我们可以并发去分别查询,这样可以极大的减少耗时,从而提高我们的性能。

四、单机架构设计层面

单机优化的关键点

单机优化层面就是要尽量提升单机的性能,将单机的性能发挥到极致的其中一个关键点就是我们服务器采取的并发模型,然后在这个模型下,去设计好我们的服务器对连接的管理、对请求的处理流程。而这些就涉及到我们的多协程、多线程的进程模型和异步非阻塞、同步非阻塞的 IO 模型。

在具体实现细节上,针对连接的管理,要想提高性能,那么就要采用 IO 多路复用技术,可以参考I/O Multiplexing查看,I/O 多路复用技术的两个关键点在于:

  • 当多条连接共用一个阻塞对象后,进程只需要在一个阻塞对象上等待,而无须再轮询所有连接,常见的实现方式有 select、epoll、kqueue 等。

  • 当某条连接有新的数据可以处理时,操作系统会通知进程,进程从阻塞状态返回,开始进行业务处理。

IO 多路复用(epoll 模型)

基本上来说,异步 I/O 模型的发展技术是: select -> poll -> epoll -> aio -> libevent -> libuv。

而且现在大家比较熟悉和使用的最多的恐怕就是 epoll 和 aio ,尤其是 epoll 模型,基本是 Linux 后端系统下的大部分框架和软件都是采用 epoll 模型。

但是,需要特别强调的是,仅仅依靠 epoll 不是万能的,连接数太多的时候单进程的 epoll 也是不行的。

Reactor 和 Proactor 架构模式

epoll 只是一个 IO 多路复用的模型,在后端系统设计里面,要想实现单机的高性能,那在 IO 多路复用基础之上,我们的整个网络框架,还需要配合池化技术来提高我们的性能。因此,业界一般都是采用 I/O 多路复用 + 线程池(协程池、进程池)的方式来提高性能。与之对应的,在业界常用的两个单机高性能的架构模式就是Reactor 和 Proactor 模式。Reactor 模式属于非阻塞同步网络模型,Proactor 模式属于非阻塞异步网络模型。

在业内开源软件里面,Redis 采用的是 单 Reactor 单进程的方式,Memcache 采用的是 多 Reactor 多线程的方式,Nginx 采用的是多 Reactor 多进程的方式。关于 的详细介绍,可以查看The Design and Implementation of the Reactor

Redis 可以用单进程 Reactor 模式的是因为 Redis 的应用场景是内部访问,并发数一般不会超过 1w,而 Nginx 必须用多进程 Reactor 模式是因为 Nginx 是外网访问,并发数很容易超过 1w,因此我们的网络架构模式,必须要通过 I/O 多路复用 + 线程池(协程池、进程池)来配合。

可以看到,单机优化层面其实和编码层面上的多协程、异步 IO、 池化技术都是有强关联的。这里也是一个知识相通的典型,我们所学的一些基础层面的知识点,在架构层面、模型层面都是有用武之地的。

五、系统架构设计层面

架构设计层面:架构分层、业务分模块、集群(集中式、分布式)、缓存(多级缓存、本地缓存)、消息队列(异步、削峰)

架构和模块划分的设计

整个系统想要有一个高性能,那么首先就需要有个合理的架构设计,这里需要根据一些架构设计原则,比如高内聚低耦合,职责单一等来去构建我们的架构。最有效的方式包括架构分层设计、业务分模块设计。

这么设计之后,在整体的系统性能优化上,后面就会有比较大的优化空间,从而不至于后面想要优化就根本无从下手,只能重构系统。

服务化框架的设计

目前的互联网时代,我们基本上都是采用微服务来搭建我们的系统,而微服务化的必要条件就是要有一套服务化框架,这个服务化框架最核心的功能包括 RPC 请求和最基础的服务治理策略(服务注册和发现、负载均衡等)。

为此,这里服务化框架的性能就尤为重要,这里主要包括这个服务化框架里面实现:

  • 数据处理。

    • 数据序列化协议,一般有些采用 PB 协议,不管是从性能还是维护都是最优的。

    • 数据压缩,一般采用 gzip 压缩,压缩后可以减少网络上的数据传输。

  • 网络模型。

    • 同步还是异步流程,如果是 Go 语言,那么可以来一个请求 go 一个协程来处理。

    • 是否有相关连接池的能力。

    • 其他的一些优化。

负载均衡

负载均衡系统是水平扩展的关键技术,通过负载均衡,相当于可以把流量分散到不同的机器的不同的服务实例里面,这样每个服务实例都可以承担一部分请求,从而可以提高我们的整体系统的性能。

对于负载均衡的方式,大都是在客户端发现模式(client-side) 来实现服务路和负载均衡,一般也都会支持常见的负载均衡策略,如随机,轮训,hash,权重,连接数【连接数越少,优先级越高】。

合理采用各种队列

在后端系统设计里面,很多流程和请求并不要求实时处理,更不需要做到强一致,大部分情况下,我们只需要实现最终一致性就可以了。故而,我们通过队列,就可以使我们的系统能够实现异步处理逻辑、流程削峰、业务模块解耦、柔性事务等多种效果,从而可以完成最终一致性,并且能够极大的提高我们系统的性能。

我们常见的队列包括

  • 消息队列:使用的最为广泛的队列之一,代表作有 RabbitMQ、RocketMQ、Kafka 等。可以用来实现异步逻辑、削峰、解耦等多种效果。从而可以极大的提高我们的性能

  • 延迟队列:延时队列相比于普通队列最大的区别就体现在其延时的属性上,普通队列的元素是先进先出,按入队顺序进行处理,而延时队列中的元素在入队时会指定一个延迟时间,表示其希望能够在经过该指定时间后处理。延迟队列的目的是为了异步处理。延迟队列的应用场景其实也非常的广泛,比如说以下的场景:

    • 到期后自动执行指定操作。

    • 在指定时间之前自动执行某些动作

    • 查询某个任务是否完成,未完成等待一定时间再次查询

    • 回调通知,当回调失败时,等待后重试

  • 任务队列:将任务提交到队列中异步执行,最常见的就是线程池的任务队列。

各级缓存的设计

分布式缓存

分布式缓存的代表作有 Redis、Memcache。通过分布式缓存,我们可以不直接读数据库,而是读取缓存来获取数据,可以极大的提高我们读数据的性能。而一般的业务都是读多写少,因此,对我们的整体性能的提高是非常有效的手段,而且是必须的手段。

本地缓存

本地缓存可以从几个维度来看:

  • 客户端的本地缓存:针对一些不常改变的数据,客户端也可以缓存,这样就可以避免请求后端,从而可以改善性能

  • 后端服务的本地缓存:后端服务中,一般都会采用分布式缓存,但是,有些场景下,如果我们的数据量比较小,那么可以直接将这些数据缓存到进程里面,这样直接通过内存读取,而不用网络耗时,性能会更高。但是本地缓存一般只会缓存少量数据。数据量太大就不合适。

多级缓存

多级缓存是一个更为高级的缓存架构设计,比如最简单的模式可以是 本地缓存 + 分布式缓存这样形成一个多级缓存架构。

我们把全量要缓存的数据都放到分布式缓存里面,然后把一些热点的少量缓存放到本地缓存里面,这样大部分热点数据都能够从本地直接读取,而其他非热点的数据还是通过分布式缓存读取,这样可以极大的提高我们的性能,提高并发能力。

举个例子,电商系统里面,我们做一个活动页,活动页的前面 10 个商品是特卖商品,然后后面的其他商品就是常规商品,因为是活动页面,那么这个页面的访问肯定就会非常大。而活动页面的前 10 个商品,必然是用户首先进来页面就一定会看到的,而用户想要继续看其他商品,那么就需要在手机上手动上滑刷新一下。这个场景下,前面 10 个商品的访问量无疑是最大的,而用户手动上滑刷新后的请求就会少很多。为此,我们可以把全量商品都缓存在分布式缓存如 redis 里面,然后再在这个基础之上,把前面 10 个商品的信息缓存到本地,这样,当活动开始后,拉取的第一页 10 个商品数据,都是从本地缓存拉取的,本地读取性能会非常高,因为内存读取就行,完全不需要网络交互。

其他的模式,可以 本地缓存 + 二级分布式缓存 + 一级分布式缓存,也就是针对分布式缓存再做一层分级,这样每一级的缓存都能抗一部分的量,因此整体来看,能够对外提供的性能就足够高。

缓存预热

通过异步任务提前将接下来要大量访问的数据预热到我们缓存里面。这样当有请求的突峰的时候,可以从容应对。

其他高性能的 NoSQL

除了 Redis、本地缓存这些,其他的一些 NoSQL 中,MongoDB、Elasticserach 也是常见的性能很高的组件,我们可以根据适用场景,合理选用。

比如我们在电商系统里面,我们针对商品的搜索、推荐都是采用 Elasticserach 来实现。

存储的设计

数据分区

数据分区是把数据按一定的方式分成多个区(比如通过地理位置),不同的数据区来分担不同区的流量,这需要一个数据路由的中间件,但会导致跨库的 Join 和跨库的事务非常复杂。

将数据分布到多个分区有两种比较典型的方案:

  • 根据键做哈希,根据哈希值选择对应的数据节点。

  • 根据范围分区,某一段连续的键都保存在一个数据节点上。

分库分表

一般来说,影响数据库最大的性能问题有两个,一个是对数据库的操作,一个是数据库中数据的大小。对于前者,我们需要从业务上来优化。一方面,简化业务,不要在数据库上做太多的关联查询,而对于一些更为复杂的用于做报表或是搜索的数据库操作,应该把其移到更适合的地方。比如,用 ElasticSearch 来做查询,用 Hadoop 或别的数据分析软件来做报表分析。对于后者,一般就是拆分。分库分表技术,有些地方也称为 Sharding、分片,通过分库分表可以提高我们的读写性能

分库分表有垂直切分和水平切分两种:

  • 垂直切分(分库),一般按照业务功能模块来划分,分库后分表部署到不同的库上。分库是为了提高并发能力,比如读写请求量大就需要分库。

  • 水平切分(分表),当一个表中的数据量过大时,我们可以把该表的数据通过各种 ID 的 hash 散列来划分,比如 用户 ID、订单 ID 的 hash。分表更多的是应对性能问题,比如查询慢的问题。单表一般情况下,千万级别后各种性能就开始下降了,就要考虑开始分表了。

分表包括垂直切分和水平切分,而分区只能起到水平切分的作用。

读写分离

互联网系统大多数都是读多写少,因此读写分离可以帮助主库抗量,读写分离就是将读的请求量改为从库承担,写还是主库来承担。一般我们都是一主多从的架构,既可以抗量,又可以保证数据不丢。

冷热分离

针对业务场景而言,如果数据有冷热之分的话,可以将历史冷数据与当前热数据分开存储,这样可以减轻当前热数据的存储量,可以提高性能。

我们常见的存储系统比如 MySQL、Elasticserach 等都可以支持。

分布式数据库

分布式数据库的基本思想是将原来集中式数据库中的数据分散存储到多个通过网络连接的数据存储节点上,以获取更大的存储容量和更高的并发访问量,从而提高我们的性能。现在传统的关系型数据库已经开始从集中式模型向分布式架构发展了。一般云服务厂商,都会提供分布式数据库的解决方案,比如腾讯云的 TDSQL MySQL 版,TDSQL for MySQL 是腾讯打造的一款分布式数据库产品,具备强一致高可用、全球部署架构、分布式水平扩展、高性能、企业级安全等特性,同时提供智能 DBA、自动化运营、监控告警等配套设施,为客户提供完整的分布式数据库解决方案。

六、基础建设层面

基础建设层面,大体分为 3 大块:

  • 机房层面,主要关注机房的网络出口带宽、入口带宽。一般这个对我们业务开发来说,都接触不到,但是这里还是需要注意,如果机房带宽不够,那么我们的服务就支撑不了大的并发,从而也没法让我们的系统有一个好的性能。

  • 机器配置层面,服务器本身的性能要足够好,包括 CPU、内存、磁盘(SSD)等资源。同理,一般这个对我们业务开发来说,都接触不到,但是如果机器配置较差,那么我们的服务部署在这样的机器上面,也无法充分发挥,从而使得我们的业系统也无法拥有一个好的性能。

  • 资源使用层面,我们要合理的分配 CPU 和内存等相关资源,一般 CPU 的使用率不要超过 70%-80%,超过这个阈值后,我们服务的性能就会开始下降,因此一般我们在 70% 的时候就要开始执行扩容。如果是 K8s 容器部署的话,我们可以设置 CPU 使用率超过指定阈值后就自动扩容。当然,如果是物理机部署,或者其他方式,可以同样的进行监控和及时扩容。也就是说,要保证我们所需的各种资源(CPU、内存、磁盘、带宽)都在一个合理的范围。

七、运维部署层面

在运维部署层面做好相关建设,是有助于提高我们系统的整体性能的。比如,我们可以通过容器化部署做到弹性伸缩,通过弹性伸缩的能力,可以使得我们的服务,在资源分配使用上,一直保持合理的 CPU、内存等资源的使用率。

八、性能测试优化层面

我们从架构设计层面、编码实现层面按照高性能的解决方案和思路实现了我们系统之后,理论上,我们的系统性能不会太差,但是,具体我们的系统性能如何?是否存在可优化点?代码的实现是否有性能问题?我们的依赖服务是否存在性能问题?等等,这些对我们大部分人来说,如果没有一个合理的性能压测和分析,那么可能还是黑盒的。

因此,针对我们研发人员而言,在高性能架构设计方面的最后一个环节,就是进行性能测试优化,具体包括三个环节:

  • 性能压测。针对系统的各个环节先分别做压测,然后有条件的情况下,最好能够做全链路压测。

  • 性能分析。压测后,最优的分析方式是结合火焰图去分析,看看性能最差的是哪里,是否有可优化的点。一定是先找到性能最差的进行优化,这样事半功倍

  • 性能优化。找到可优化点后,进行优化。然后反复这三个步骤,直到你认为性能已经完全符合预期。

作者:AllenWu
来源:juejin.cn/post/7198476152633163831

收起阅读 »

关于我加了一行日志搞崩了服务这件小事(下)

接:关于我加了一行日志搞崩了服务这件小事(上)// 方案一 - 这里会根据当前属性名和clazz来判断是否被忽略了,详见@JsonType注解           boolean ignor...
继续阅读 »

接:关于我加了一行日志搞崩了服务这件小事(上)

// 方案一 - 这里会根据当前属性名和clazz来判断是否被忽略了,详见@JsonType注解
           boolean ignore = isJSONTypeIgnore(clazzpropertyName);
// 如果忽略了,就不再往下走了
           if (ignore) {
               continue;
          }
//此时根据属性和类获取对应的值对象。
           Field field = ParserConfig.getField(clazzpropertyName);
           JSONField fieldAnnotation = null;
           if (field != null) {
               //方案二 - 会获取属性对应的JSONField注解
               // 如果该注解的serialize属性是false,那么也不会继续往下去加载逻辑
               fieldAnnotation = field.getAnnotation(JSONField.class);
               if (fieldAnnotation != null) {
                   if (!fieldAnnotation.serialize()) {
                       continue;
                  }
//获取顺序
                   ordinal = fieldAnnotation.ordinal();
                   serialzeFeatures = SerializerFeature.of(fieldAnnotation.serialzeFeatures());
                   if (fieldAnnotation.name().length() != 0) {
                       //获取名字
                       propertyName = fieldAnnotation.name();
                       if (aliasMap != null) {
                           propertyName = aliasMap.get(propertyName);
                           if (propertyName == null) {
                               continue;
                          }
                      }
                  }
                   if (fieldAnnotation.label().length() != 0) {
                       label = fieldAnnotation.label();
                  }
              }
          }
           if (aliasMap != null) {
               propertyName = aliasMap.get(propertyName);
               if (propertyName == null) {
                   continue;
              }
          }
           //这里会新构建一个fieldInfo对象,并存放到fieldInfoMap中进行保存
           FieldInfo fieldInfo = new FieldInfo(propertyNamemethodfieldclazznullordinalserialzeFeatures,
                                               annotationfieldAnnotationlabel);
           fieldInfoMap.put(propertyNamefieldInfo);
      }
       //紧接着第二部分是关于isXXX的方法
       if (methodName.startsWith("is")) {
           if (methodName.length() < 3) {
               continue;
          }
           char c2 = methodName.charAt(2);
           String propertyName;
           if (Character.isUpperCase(c2)) {
               if (compatibleWithJavaBean) {
                   propertyName = decapitalize(methodName.substring(2));
              } else {
                   propertyName = Character.toLowerCase(methodName.charAt(2)) + methodName.substring(3);
              }
          } else if (...) {
          //同上面几乎一样,也是针对is_x这类特殊写法做了处理。
          }else {
               continue;
          }
           Field field = ParserConfig.getField(clazzpropertyName);
           if (field == null) {
               field = ParserConfig.getField(clazzmethodName);
          }
           JSONField fieldAnnotation = null;
           if (field != null) {
               //同样是对JSONField注解做处理。
               fieldAnnotation = field.getAnnotation(JSONField.class);
               if (fieldAnnotation != null) {
                   if (!fieldAnnotation.serialize()) {
                       continue;
                  }
                   ordinal = fieldAnnotation.ordinal();
                   serialzeFeatures = SerializerFeature.of(fieldAnnotation.serialzeFeatures());
                   if (fieldAnnotation.name().length() != 0) {
                       propertyName = fieldAnnotation.name();
                       if (aliasMap != null) {
                           propertyName = aliasMap.get(propertyName);
                           if (propertyName == null) {
                               continue;
                          }
                      }
                  }
                   if (fieldAnnotation.label().length() != 0) {
                       label = fieldAnnotation.label();
                  }
              }
          }
           if (aliasMap != null) {
               propertyName = aliasMap.get(propertyName);
               if (propertyName == null) {
                   continue;
              }
          }
           FieldInfo fieldInfo = new FieldInfo(propertyNamemethodfieldclazznullordinalserialzeFeatures,
                                               annotationfieldAnnotationlabel);
           fieldInfoMap.put(propertyNamefieldInfo);
      }
  }
//最后,又是对所有的常规属性做相应的处理,避免因为某个属性没写getX()方法而得不到序列化。整体的加载逻辑同上。
   for (Field field : clazz.getFields()) {
       if (Modifier.isStatic(field.getModifiers())) {
           continue;
      }
       JSONField fieldAnnotation = field.getAnnotation(JSONField.class);
       int ordinal = 0serialzeFeatures = 0;
       String propertyName = field.getName();
       String label = null;
       if (fieldAnnotation != null) {
           if (!fieldAnnotation.serialize()) {
               continue;
          }
           ordinal = fieldAnnotation.ordinal();
           serialzeFeatures = SerializerFeature.of(fieldAnnotation.serialzeFeatures());
           if (fieldAnnotation.name().length() != 0) {
               propertyName = fieldAnnotation.name();
          }
           if (fieldAnnotation.label().length() != 0) {
               label = fieldAnnotation.label();
          }
      }
       if (aliasMap != null) {
           propertyName = aliasMap.get(propertyName);
           if (propertyName == null) {
               continue;
          }
      }

       if (!fieldInfoMap.containsKey(propertyName)) {
           FieldInfo fieldInfo = new FieldInfo(propertyNamenullfieldclazznullordinalserialzeFeatures,
                                               nullfieldAnnotationlabel);
           fieldInfoMap.put(propertyNamefieldInfo);
      }
  }

   List<FieldInfo> fieldInfoList = new ArrayList<FieldInfo>();

   boolean containsAll = false;
   String[] orders = null;

   JSONType annotation = clazz.getAnnotation(JSONType.class);
   if (annotation != null) {
       orders = annotation.orders();

       if (orders != null && orders.length == fieldInfoMap.size()) {
           containsAll = true;
           for (String item : orders) {
               if (!fieldInfoMap.containsKey(item)) {
                   containsAll = false;
                   break;
              }
          }
      } else {
           containsAll = false;
      }
  }

   if (containsAll) {
       for (String item : orders) {
           FieldInfo fieldInfo = fieldInfoMap.get(item);
           fieldInfoList.add(fieldInfo);
      }
  } else {
       for (FieldInfo fieldInfo : fieldInfoMap.values()) {
           fieldInfoList.add(fieldInfo);
      }
       if (sorted) {
           Collections.sort(fieldInfoList);
      }
  }
   return fieldInfoList;
}

代码有点长,听我一点点地慢慢解释。整个代码其实比较容易理解,我尝试从我们常规角度来理解下。fastJson组件的发明者认为,类中常见需要序列化的类型有三种:

1、getX()方法;

2、isX()方法;

3、没有写getX()方法的固有变量。

围绕这三种类型他做的事都是类似的。这里我们先以getX()方法为例子展开说明,要获取到所有的getX()方法,并对他们解析,主要分为以下四个步骤:

1、获取到所有的类下的方法信息

这个可以通过class<?>.getMethods()方法获得,如下是我coreData类的所有方法。


2、判断符合规范的getXXX方法

在获取到了所有的method以后,我们自然需要判断哪些是符合规范的getXX方法。在组件中是这么判断的:

if (methodName.startsWith("get")) {
   //此时做相应的处理逻辑  
}

没错,就是这么粗暴简单。

3、根据JSONType判断是否需要加载

那么获取到这些方法就一定要加载了吗?当然不是!对于getter方法,fastJson会首先判断当前的属性,是否已被包含在了类的@JSONType(ignores = "xxx")下,如果包含在了其中,那么此时就不会去将该方法保存到待序列化的列表中。局限点在于该种写法只会对get方法生效,对于isXXX和普通属性是不会生效的。

// 方案一 - 这里会根据当前属性名和clazz来判断是否被忽略了,详见@JsonType注解
boolean ignore = isJSONTypeIgnore(clazzpropertyName);
// 如果忽略了,就不再往下走了
if (ignore) {
   continue;
}


4、根据JSONField判断是否需要加载

什么?你说采用JSONType写一大堆不方便?fastJson自然也是想到了,那么此时就可以采用@JSONField(serialize = false)的方式在对单独的属性或方法进行标注。也能起到忽略的作用。


到此,以getXX()方法的解析判断就完成了,当然其中还有一些更为细致的判断逻辑,如跳过getMetaClass、返回值为空的跳过等等逻辑。但大体上已经不影响我们的分析了。isXXX和固有变亮的解析几乎相似。至此,我们已经大致了解了整个解析的原理。当然为了验证我们的逻辑的正确性,我对原本coreData的代码做了一下改造并进行了试验,具体内容如下所示:

@Data
@JSONType(ignores = "funcProperties")
public class CoreData {

   //正常的属性
   public String normalProperties = "normalProperties";

   /**
    * 以get开头的方法
    * @return
    */
   public String getFuncProperties(){
       double a = 2/0;
       return "getFuncProperties";
  }

   /**
    * 以is开头的方法
    * @return
    */
   @JSONField(serialize = false)
   public Boolean isType(){
       return true;
  }

   /**
    * 用于跳过,检查方法是否判断
    * @return
    */
   public String skipFuncProperties(){
       double a = 2/0;
       return "getFuncProperties";
  }
}

简要来说,这里对getFuncProperties方法,我才用了@JSONType(ignores = "funcProperties")将其进行忽略,而对于isType方法,我则用单个的@JSONField(serialize = false)对其进行忽略,如果我们的结论成立,那么此时应该只会保存一个normalProperties属性的输出,且不存在出现报错的情况。


事实证明,我们是对的。

案件总结与反思:

在经历了这次惨痛的教训之后,有哪些是值得我们深入关注去思考和反思的呢?

1、在编写方法的时候尽量避免才用getXXX、isXXX的方法进行书写,这会导致部分框架的解析出现问题。(这个点也是我曾经在JAVA开发手册中看到的,想必也是前人被坑过了。)

2、如果非要这样写,那么此时需要评估好当前这个方法是否需要被一些框架进行解析,如果不需要,尝试对这些类型属性添加基本的忽略操作。类似@JSONField(seralize = false)、@Trasient等注解。

3、避免在对象中参杂进复杂的业务逻辑。(当然这条并不一定正常,对于DDD的充血模型,有时候是需要一定的业务逻辑的混合的。)

吃一堑长一智,如此一来才能避免在未来犯下相同的错误呀~


作者:DrLauPen
来源:
juejin.cn/post/7134513215890784293



收起阅读 »

关于我加了一行日志搞崩了服务这件小事(上)

周三的时候,组内出现了一个线上问题,影响到了若干个用户的下单、支付等操作。然而实际查询到问题的原因时,发现只是由于一行小小的日志打印导致的错误。1、对案件的发生进行回顾;3、对案件总结与反思案件回顾 找到代码行后却让值班同学感到疑惑:“这个明显是fastjso...
继续阅读 »

前言

周三的时候,组内出现了一个线上问题,影响到了若干个用户的下单、支付等操作。然而实际查询到问题的原因时,发现只是由于一行小小的日志打印导致的错误。

以下的文章内容分为主要分为三部分:

1、对案件的发生进行回顾;

2、分析案件发生的原因;

3、对案件总结与反思

以三章内容来回顾出现的问题,以及提供未来的预防策略。

案件回顾

周三的时候,服务频繁收到报警,系统频繁爆出空指针异常。值班同学根据报错的错误栈,快速定位到了错误的代码行。

at com.alibaba.fastjson.serializer.JSONSerializer.write(JSONSerializer.java:285)
at com.alibaba.fastjson.JSON.toJSONString(JSON.java:696)

找到代码行后却让值班同学感到疑惑:“这个明显是fastjson的日志打印呀,这也会有什么错误么?”。旁边的同事看完却惊呼一声:“fastJson打印日志会调用对象内的其余的get方法的呀!”。

(PS:该对象是一个DDD的核心域对象,其中包含一些业务场景方法被命名为getXXX方法的,因此执行Json序列化打印也就可能因为部分数据为空而出现空指针。)

定位到了问题原因,本着优先止损的原则,值班同事快速上线代码删除了这行日志打印。系统暂时的恢复了正常,没有再出现新增的报错信息了。然而后续还有漫长的数据修复、更正的过程。

案件分析:

案件复原:

本质上来说,这起线上事故出现的原因主要是因为fastJson序列化时,会将手工编写的一些方法认为是待输出属性对象,那么如果这些方法包含一些业务逻辑代码的时候,就会存在出现异常的风险。这里我们简单复现一下场景:

@Data
public class CoreData {
   //正常的属性
   public String normalProperties = "normalProperties";

   /**
    * 以get开头的方法 不是期望输出的属性
    * @return
    */
   public String getFuncProperties(){
       return "getFuncProperties";
  }

   /**
    * 以is开头的方法 不是期望输出的属性
    * @return
    */
   public Boolean isType(){
       return true;
  }
}

如上代码是我们编写的一个纯代码类,可以看到,我们实际期望设置的属性应该只有一个normalPropertites。

public static void main(String[] args) {
   CoreData data = new CoreData();
   String dataString = JSONObject.toJSONString(data);
   System.out.println(dataString); // 对应正常的业务逻辑
}

进而我还写了一段针对当前对象进行打印的代码,从上可以看到,就是简单的对对象进行JSON序列化后打印输出。按照我们的期望来说,只是期望输出normalProperties这一个固有的字符串属性。随后我运行了代码,得到了如下的结果:


可以看到,一个类型+两个方法,都被JSON序列化后输出了。那么如果此时我们在getFuncProperties()这样的方法中如果出现了异常,就会影响整个业务的运行。例如我们把方法改成如下的例子:

public String getFuncProperties(){
   double a = 2/0;
   return "getFuncProperties";
}


可以看到,我们原本的逻辑可能只是想输出normalProperties属性,但是因为getFuncProperties2/0是无法进行运算的,导致了系统直接报错了。那么此时,main函数中的输出方法(对应于我们正常业务逻辑),也就无法再继续执行了,而这在生产环境上无疑是致命的。

背后原理:

(PS: 以下讨论内容均基于1.2.9版本的fastJson。)

根据报错的问题点,结合debug,很快找到了问题所在:


com.alibaba.fastjson.serializer.JSONSerializer#write(java.lang.Object)这个方法中,Fastjson所创建的ObjectSerializer对象中,nature下所包含的getters对象有三个。这明显不符合我们的预期。那么我们就需要找到他是如何获取到这三个方法的。紧跟着我们进行追入,在com.alibaba.fastjson.serializer.SerializeConfig#getObjectWriter方法下找到了这行代码:

put(clazzcreateJavaBeanSerializer(clazz));

很明显,这里的createJavaBeanSerializer(clazz)创建了javaBean的序列化器。对于该方法,其主要的逻辑流程就是判断当前的对象类型是否符合使用ASM的序列化器。这里一通判断下来,是符合采用ASM序列化的要求的,因此,我们又进一步定位到了如下代码:

ObjectSerializer asmSerializer = createASMSerializer(clazz);

createASMSerializer对应的方法中,最关键的代码莫过于下面这行了:

List<FieldInfo> unsortedGetters = TypeUtils.computeGetters(clazzjsonTypealiasMapfalse);

这力的代码会生成对应的fieldInfo对象,也正好对应了前面我们涉及到的那三个方法,这里让我们仔细看一下com.alibaba.fastjson.util.TypeUtils#computeGetters所对应的代码:

public static List<FieldInfo> computeGetters(Class clazzJSONType jsonTypeMap<StringString> aliasMapboolean sorted) {
   Map<StringFieldInfo> fieldInfoMap = new LinkedHashMap<StringFieldInfo>();
   for (Method method : clazz.getMethods()) {
       String methodName = method.getName();
       int ordinal = 0serialzeFeatures = 0;
       String label = null;
//判读当前方法是否为静态的
       if (Modifier.isStatic(method.getModifiers())) {
           continue;
      }
//若返回值为void则此时不需要处理
       if (method.getReturnType().equals(Void.TYPE)) {
           continue;
      }
//若此时入参不为空则跳过
       if (method.getParameterTypes().length != 0) {
           continue;
      }
//若返回类型是类加载器也进行跳过。
       if (method.getReturnType() == ClassLoader.class) {
           continue;
      }
//若方法名是getMetaClass也跳过
       if (method.getName().equals("getMetaClass")
           && method.getReturnType().getName().equals("groovy.lang.MetaClass")) {
           continue;
      }
//获取方法的有关JSONField的注释
       JSONField annotation = method.getAnnotation(JSONField.class);
       if (annotation == null) {
           //若当前类为空,则再获取父类的。
           annotation = getSupperMethodAnnotation(clazzmethod);
      }
       if (annotation != null) {
           //若父类不为空则进行序列化的判断,我们使用的例子无继承,这部分先忽略不看。
          ......
      }
       //重点来了,判断当前是否以get开头
       if (methodName.startsWith("get")) {
           //长度小于4,即不满足getXX的格式的,直接跳过。
           if (methodName.length() < 4) {
               continue;
          }
           //getClass的进行跳过
           if (methodName.equals("getClass")) {
               continue;
          }
//获取第四个位置的字符
           char c3 = methodName.charAt(3);
           String propertyName;
           if (Character.isUpperCase(c3|| c3 > 512 ) {
               //若方法遵循驼峰的写法:则依次取出对应的名称信息
               if (compatibleWithJavaBean) {
                   propertyName = decapitalize(methodName.substring(3));
              } else {
                   propertyName = Character.toLowerCase(methodName.charAt(3)) + methodName.substring(4);
              }
          } else if (...) {
               //这里针对部分特殊的写法:如get_X、getfX做了特殊的判断处理。
          } else {
               continue;
          }

续:关于我加了一行日志搞崩了服务这件小事(下)

作者:DrLauPen
来源:juejin.cn/post/7134513215890784293

收起阅读 »

实现一个微信录音功能过程

web
功能原型图其实就是微信发送语音的功能。没有转文字的功能。拆解需求根据原型图可以很容易的得出我们需要做的内容包括下面三个部分:接入微信的语音SDK调用微信SDK的API逻辑界面和交互的实现其中第一点和第二点属于业务逻辑部分,第三点属于交互逻辑部分。对于业务逻辑和...
继续阅读 »

功能原型图


其实就是微信发送语音的功能。没有转文字的功能。

拆解需求

根据原型图可以很容易的得出我们需要做的内容包括下面三个部分:

  1. 接入微信的语音SDK

  2. 调用微信SDK的API逻辑

  3. 界面和交互的实现

其中第一点和第二点属于业务逻辑部分,第三点属于交互逻辑部分。对于业务逻辑和交互逻辑的关系在我的另外一篇文章描述过,我在vue中是这样拆分组件的 - 掘金 (juejin.cn)

从原型图可以分析出如下的流程图:

评估时间

第三事情是评估时间。在接到这个需求的时候,我们需要假设我们在此之前没有接入过微信相关的SDK,并以此为前提进行工期的评估。

可以将该用户故事拆分为如下任务:

  • 微信语音SDK的技术调研(0.5天)

  • 输出开发设计文档(0.5天)

  • 接入微信语音SDK(0.5天)

  • 编码(1天)

  • 自测(0.5天)

随后将上面的时间都乘以2! 自此才可以将估算的工期上报给产品。多年的经验告诉自己,自己一开始估算的工期从来没够过。自行估算的时候,幻想的是在工作的时候能够一直保持专注。

就我自己而言,做不到,上班不可能不摸鱼!也是必须要摸鱼的。乘以2才是刚够而已。

代码实现

都说在实现代码之前要先设计,谋定而后动。我是这样做的,先想好文件夹创建,然后到文件的创建,再到具体文件中写出大体的框架。

需求并不复杂,只是一个界面中的一个模块。所以我只需要一个Record.vue来承载界面,一个use-record-layout.js来承载业务逻辑,以及一个use-record-interact.js来承接交互逻辑。

|__im-record
  |__Record.vue
  |__use-record-layout.js
  |__use-record-interact.js

为了便于说明,将这个聊天的界面简化如下:

<script setup>
import { useNamespace } from "@/use-namespace";
const ns = useNamespace('chat')
</script>
<template>
 <header :class="ns.b('header')"></header>
 <main :class="ns.b('main')">
   <section :class="[ns.b('record'), ns.w('record', 'toast')]">
     <div :class="ns.w('record', 'speak')"></div>
     <div :class="ns.w('record', 'pause')"></div>
   </section>
 </main>
 <footer :class="ns.w('button', 'wrap')">
   <button :class="ns.b('button')">
     <span>
      按住 说话
     </span>
   </button>
 </footer>
</template>

通过上面的代码片段可知,我们的主要的界面在section标签的record部分。

use-record-layout.js的主题代码如下:

  const recordStyle = {
   default: { }, // 默认样式/确定发送录音
   recording: { }, // 录音中
   pause: { }, // 暂停录音
   cancel: { } // 取消录音
}

 const init = () => {
   initEvent()
   initStyle()
}

 const initStyle = () => {
   recordStyle.default.is = true
}

 const initEvent = () => {
   el.addEventListener('touchstart', handleTouchstart)
   el.addEventListener('touchmove', handleTouchmove)
   el.addEventListener('touchend', handleTouchend)
}

 const axis = {
   posStart: 0, // 初始化起点坐标
   posMove: 0 // 初始化滑动坐标
}
 const handleTouchstart = (event) => {
   event.preventDefault()
   axis.posStart = event.touches[0].pageY
   recordStyle.recording.is = true
}
 const handleTouchmove = (event) => {
   event.preventDefault()
   axis.posMove = event.targetTouches[0].pageY
   const diffMove = axis.posMove - axis.posStart
   if (diffMove > DEFAULT_AXIS) {
     recordStyle.recording.is = true
  }
}
 const handleTouchend = (event) => {
   event.preventDefault()
   recordStyle.default.is = true
}

 init()

其中recordStyle是交互的结果,在这个需求当中,我们的界面的四种变化都对应其中一个的样式。

use-record-interact.js也很简单,注册微信录音功能 ➡️

const wx = 'wx'
const useRecordInteract = () => {
 const isAuth = localStorage.getItem('allowWxRecord')
 // 获取录音权限
 const authRecord = () => {
   if (!isAuth) {
     wx.startRecord()
     return
  }

   return isAuth
}
 // 停止录音
 const stopRecord = () => {}
 // 上传录音
 const uploadRecord = () => {}
 
}

交互逻辑和业务逻辑的联动通过recordStyle对象的存取属性来实现,代码片段如下:

const interact = useRecordInteract()
const recordStyle = {
   default: {
     _is: false,
     get is() {
       return this._is
    },
     set is(value) {
       this._is = value
       if (value) {
         this.recording.is = false
         this.pause.is = false
         this.cancel.is = false
         
         interact.uploadRecord()
      }
    }
  },
   //...
}

实现了业务逻辑和交互逻辑的分离。

作者:砂糖橘加盐
来源:juejin.cn/post/7201491839815745597

收起阅读 »

如何自动打开你的 App?

相信大家在刷 某博 / 某书 / 某音 的时候,最能体会什么叫做 条条大路通 tao bao。经常是你打开一个 App,不小心点了下屏幕,就又打开了另一个 App 了。那么这种自动打开一个 App 到底是怎么实现的呢?URL Scheme首先是最原始的方式 U...
继续阅读 »


相信大家在刷 某博 / 某书 / 某音 的时候,最能体会什么叫做 条条大路通 tao bao。经常是你打开一个 App,不小心点了下屏幕,就又打开了另一个 App 了。

那么这种自动打开一个 App 到底是怎么实现的呢?

URL Scheme

首先是最原始的方式 URL Scheme。

URL Scheme 是一种特殊的 URL,用于定位到某个应用以及应用的某个功能。

它的格式一般是: [scheme:][//authority][path][?query]

scheme 代表要打开的应用,每个上架应用商店的 App 所注册的 scheme 都是唯一的;后面的参数代表应用下的某个功能及其参数。

在 IOS 上配置 URL Scheme

在 XCode 里可以轻松配置


在 Android 上配置 URL Scheme

Android 的配置也很简单,在 AndroidManifest.xml 文件下添加以下配置即可


通过访问链接自动打开 App

配置完成后,只要访问 URL Scheme 链接,系统便会自动打开对应 scheme 的 App。

因此,我们可以实现一个简单的 H5 页面来承载这个跳转逻辑,然后在页面中通过调用 location.href=schemeUrl 或者 <a href='schemeUrl' /> 等方式来触发访问链接,从而自动打开 App

优缺点分析

优点: 这个是最原始的方案,因此最大的优点就是兼容性好

缺点:

  1. 通过 scheme url 这种方式唤起 App,对于 H5 中间页面是无法感知的,并不知道是否已经成功打开 App

  2. 部分浏览器有安全限制,自动跳转会被拦截,必须用户手动触发跳转(即 location.href 行不通,必须 a 标签)

  3. 一些 App 会限制可访问的 scheme,你必须要在白名单内,否则也会被拦截跳转

  4. 通过 scheme url 唤起 App 时,浏览器会提示你是否确定要打开该 App,会影响用户体验

DeepLink

通过上述缺点我们可以看出,传统的 URL Scheme 在用户体验上是存在一定缺陷的。

因此,DeepLink 诞生了。

DeepLink 的宗旨就是通过传统的 HTT P链接就可以唤醒app,而如果用户没有安装APP,则会跳转到该链接对应的页面。

IOS Universal Link

在 IOS 上一般称之为 Universal Link。

【配置你的 Universal Link 域名】

首先要去 Apple 的开发者平台上配置你的 domains,假设是: mysite.com


【配置 apple-app-site-association 文件】

在该域名根目录下创建一个 .well-known 路径,并在该路径下放置 apple-app-site-association 文件。

文件内容包含 appID 以及 path,path如果配置 /app 则表示访问该域名下的 /app 路径均能唤起App

该文件内容大致如下:

{
   "applinks": {
       "apps": [],
       "details": [
          {
               "appID": "xxx", // 你的应用的 appID
               "paths": [ "/app/*"]
          }
      ]
  }
}
复制代码

【系统获取配置文件】

上面两步配置成功后,当用户 首次安装App 或者后续每次 覆盖安装App 时,系统都会主动去拉取域名下的配置文件。

即系统会主动去拉取 https://mysite.com/.well-known/apple-app-site-association 这个文件

然后根据返回的 appID 以及 path 判断访问哪些路径是需要唤起哪个App

【自动唤起 App】

当系统成功获取配置文件后,只要用户访问 mysite.com/app/xxx 链接,系统便会自动唤起你的 App。

同时,客户端还可以进行一些自定义逻辑处理:

客户端会接收到 NSUserActivity 对象,其 actionType 为 NSUserActivityTypeBrowsingWeb,因此客户端可以在接收到该对象后做一些跳转逻辑处理。


Android DeepLink

与 IOS Universal Link 原理相似,Android系统也能够直接通过网站地址打开应用程序对应的内容页面,而不需要用户选择使用哪个应用来处理网站地址

【配置 AndroidManifest.xml】 在 AndroidManifest 配置文件中添加对应域名的 intent-filter:

scheme 为 https / http;

host 则是你的域名,假设是: mysite.com


【生成 assetlinks.json 文件】

首先要去 Google developers.google.com/digital-ass… 生成你的 assetlinks json 文件。


【配置 assetlinks.json 文件】

生成文件后,同样的需要在该域名根目录下创建一个 .well-known 路径,并在该路径下放置 assetlinks.json 配置文件,文件内容包含应用的package name 和对应签名的sha哈希

【系统获取配置文件】

配置成功后,当用户 首次安装App 或者后续每次 覆盖安装App 时,系统会进行以下校验:

  1. 如果 intent-filter 的 autoVerify 设置为 true,那么系统会验证其

  • Action 是否为 android.intent.action.VIEW

  • Category 是否为android.intent.category.BROWSABLE 和 android.intent.category.DEFAULT

  • Data scheme 是否为 http 或 https

  1. 如果上述条件都满足,那么系统将会拉取该域名下的 json 配置文件,同时将 App 设置为该域名链接的默认处理App

【自动唤起 App】

当系统成功获取配置文件后,只要用户访问 mysite.com/app/xxx 链接,系统便会自动唤起你的 App。

优缺点分析

【优点】

  1. 用户体验好:可以直接打开 App,没有弹窗提示

  2. 唤起App失败则会跳转链接对应的页面

【缺点】

  1. iOS 9 以后才支持 Universal Link,

  2. Android 6.0 以后才支持 DeepLink

  3. DeepLink 需要依赖远程配置文件,无法保证每次都能成功拉取到配置文件

推荐方案: DeepLink + H5 兜底

基于前面两种方案的优缺点,我推荐的解决方案是配置 DeepLink,同时再加上一个 H5 页面作为兜底。

首先按照前面 DeepLink 的教程先配置好 DeepLink,其中访问路径配置为 https://mysite.com/app

接着,我们就可以在 https://mysite.com/app 路径下做文章了。在该路径下放置一个 H5 页面,内容可以是引导用户打开你的 App。

当用户访问 DeepLink 没有自动打开你的 App 时,此时用户会进入浏览器,并访问 https://mysite.com/app 这个 H5 页面。

在 H5 页面中,你可以通过浏览器 ua 获取当前的系统以及版本:

  1. 如果是 Android 6.0 以下,那么可以尝试用 URL Scheme 去唤起 App

  2. 如果是 IOS / Android 6.0 及以上,那么此时可以判断用户未安装 App。这种情况下可以做些额外的逻辑,比如重定向到应用商店引导用户去下载之类的

作者:龙飞_longfe
来源:juejin.cn/post/7201521440612974649

收起阅读 »

IM 即时通讯实战:环信Web IM极速集成

前置技能Node.js 环境已搭建。npm 包管理工具的基本使用。Vue2 或者 Vue3 框架基本掌握或使用。学习目标项目中集成 IM 即时通讯实战利用环信 IM Web SDK 快速实现在 Vue.js 中发送出一条 Hello World!一、了解环信 ...
继续阅读 »

前置技能

  • Node.js 环境已搭建。
  • npm 包管理工具的基本使用。
  • Vue2 或者 Vue3 框架基本掌握或使用。

学习目标

  • 项目中集成 IM 即时通讯实战
  • 利用环信 IM Web SDK 快速实现在 Vue.js 中发送出一条 Hello World!

一、了解环信 IM

  1. 什么是环信 IM?

    环信即时通讯为开发者提供高可靠、低时延、高并发、安全、全球化的通信云服务,支持单聊、群聊、聊天室。提供多平台 SDK 支持,包括:Android、iOS、Web;同时,提供 EaseIM 和 EaseIMKit 以及服务端 REST API,帮助开发者快速构建端到端通信的场景。

  2. 学习完环信 WebIM 之后可以干嘛?

    可以在任意 Web 应用中极速集成搭建即时通讯功能,无论是自己搭建 IM 应用,还是实现产品需求均可以灵活集成进入到自己的项目之中。

二、环信 WebIM 实现通讯的基本流程

前置准备

  1. 有效的开发者 AppKey。 ( 注册环信)(注册参考文档)
  2. 使用 Vue-cli 创建一个空白项目,或已经具备已有待集成项目(此篇文章以 Vue3 为示例,Vue2 同样可以参考此文章)。
  3. 在项目中使用 npm 或者 yarn 安装环信 WebSDK 包,easemob-websdk
  4. 下载环信官方 Vue3-Demo

我们开始

初期配置

在确保已进行 npm install easemob-websdk 安装了环信 SDK 包,并已经下载了 Vue3 官方 Demo,将项目中的 IM 文件拖入自己的项目中。

此文件共两个功能:

  • 引入环信 WebIM-SDK
  • 将引入的 SDK 进行实例化

在这里插入图片描述

DEFAULT_APPKEY修改为自己已注册的 Appkey。

配置监听

<script setup>
import { EaseChatClient } from '@/IM/initwebsdk'
/* SDK连接 相关监听 */
EaseChatClient.addEventHandler('connection', {
onConnected: () => {}, //与环信服务器建联成功回调。
onDisconnected: () => {}, //与环信服务器断开成功回调。
onOnline: () => {}, // 本机网络连接成功。
onOffline: () => {},// 本机网络掉线。
onError: (error) => {}, //SDK Error 回调
})
/* 好友关系相关监听 */
EaseChatClient.addEventHandler('friendListen', {
// 收到好友邀请触发此方法。
onContactInvited: (data) => {},
// 联系人被删除时触发此方法。
onContactDeleted: (data) => {},
// 新增联系人会触发此方法。
onContactAdded: (data) => {},
// 好友请求被拒绝时触发此方法。
onContactRefuse: (data) => {},
// 好友请求被同意时触发此方法。
onContactAgreed: (data) => {}
})
/* message 相关监听 */
EaseChatClient.addEventHandler('messageListen', {
onTextMessage: function (message) {}, // 收到文本消息。
onEmojiMessage: function (message) {}, // 收到表情消息。
onImageMessage: function (message) {}, // 收到图片消息。
onCmdMessage: function (message) {}, // 收到命令消息。
onAudioMessage: function (message) {}, // 收到音频消息。
onLocationMessage: function (message) {}, // 收到位置消息。
onFileMessage: function (message) {}, // 收到文件消息。
onCustomMessage: function (message) {}, // 收到自定义消息。
onVideoMessage: function (message) {}, // 收到视频消息。
onRecallMessage: function (message) {}, // 收到消息撤回回执。
})
</script>

创建测试 ID

在这里插入图片描述

登录环信

这一步是所有后续操作的第一步

<script setup>
import { EaseChatClient } from '@/IM/initwebsdk'
const loginValue = reactive({
user: '', //你的测试环信ID
password: '' //你的测试环信ID密码
})
//登录接口调用
const loginIM = async () => {
try {
await EaseChatClient.open({
user: loginValue.username.toLowerCase(),
pwd: loginValue.password.toLowerCase()
}
);
} catch (error) {
console.log('>>>>登录失败', error);
}
}
</script>

紧接着是开始聊天部分。

好友关系

完成这个功能 需要将该项目开启两个页面,一个申请,一个接收,这样才能看到效果

两种方式:手动关联一个好友,第二种再创建一个测试 ID 之后,调用 SDK 添加好友。

方式一:测试时最简单的方式,手动关联好友

  1. 在管理后台中手动再创建一个 ID
    image.png

2.并手动将新创建的 ID 关联为好友。
在这里插入图片描述

方式二:开发时调用 SDK 接口添加好友

//申请添加好友
const applyAddFriends = () => {
EaseChatClient.addContact(targetId, '我想加你为好友!');
};
//接收方登录将会触发
EaseChatClient.addEventHandler('friendListen', {
// 收到好友邀请触发此方法。
onContactInvited: (data) => {
//同意申请
EaseChatClient.acceptContactInvite(data.from);
//拒绝申请
EaseChatClient.declineContactInvite(data.from);
},
});

进入页面获取好友列表并自行渲染。

<script setup>
//获取好友列表
const friendListData = reactive({})
const { data } = await EaseChatClient.getContacts()
data.length > 0 &&
data.map(item => (friendListData[item] = { hxId: item }))
</script>

收发消息

完成这个功能 需要将该项目开启两个页面,一个发送,一个接收,这样才能看到效果

发送方发送一条文本消息:

<script setup>
const props = defineProps({
nowPickInfo: {
type: Object,
required: true,
default: () => ({})
}
})
const { nowPickInfo } = toRefs(props)
const { ALL_MESSAGE_TYPE, CHAT_TYPE } = messageType
//发送文本内容
const textContent = ref('')
const sendTextMessage = _.debounce(async () => {
//如果输入框全部为空格同样拒绝发送
if (textContent.value.match(/^\s*$/)) return
const msgOptions = {
id: nowPickInfo.value.id, //要发送的目标ID
chatType: nowPickInfo.value.chatType,
msg: textContent.value,
}
textContent.value = '' //发送后清空输入框
try {
await store.dispatch('sendShowTypeMessage', { msgType: ALL_MESSAGE_TYPE.TEXT, msgOptions })
} catch (error) {
console.log('>>>>>>>发送失败+++++++', error)
}
}, 50)
</script>

接收方接收消息

/* message 相关监听 */
EaseChatClient.addEventHandler('messageListen', {
onTextMessage: function (message) {
console.log('>>>>收到文本消息');
pushNewMessage(message); //在缓存中Push一条新消息。
}, // 收到文本消息。
});

缓存的消息结构示例

messageList:{
//以好友的ID为KEY,如果获取则直接messageList[friendId]取到对应的消息。
friendId:[
{
chatType:"singleChat", //聊天类型 单聊或者群聊
ext:{}, //消息扩展
from:friendId, //消息来源ID
id:"1111864344594875684", //消息的唯一ID
msg:"Hello World!",//消息内容
time:1676440891009,//消息发送时间
to:myId,//发送目标ID
type:"txt" //消息来源
},
{
chatType:"singleChat",
ext:{},
from:friendId,
id:"1111864344594875684",
msg:"Hello World2!",
time:1676440891009,
to:myId,
type:"txt"
}
],
friendId2:[
{
chatType:"singleChat",
ext:{},
from:friendId,
id:"1111864344594875684",
msg:"Hello World!",
time:1676440891009,
to:myId,
type:"txt"
},
]
}

渲染消息列表

<script setup>
import { reactive, ref, computed, toRefs } from 'vue'
//获取其id对应的消息内容
const messageData = computed(() => {
//如果Message.messageList中不存在的话调用拉取漫游取一下历史消息
return nowPickInfo.value.id && store.state.Message.messageList[nowPickInfo.value.id] || fechHistoryMessage('fistLoad')()
})
<template>
<div>
<div class="messageList_box" v-for="(msgBody, index) in messageData" :key="msgBody.id">
<div v-if="!msgBody.isRecall && msgBody.type !== ALL_MESSAGE_TYPE.INFORM" class="message_box_item"
:style="{ flexDirection: (isMyself(msgBody) ? 'row-reverse' : 'row') }">
<div class="message_item_time">{{ handleMsgTimeShow(msgBody.time, index) || '' }}</div>
<el-avatar class="message_item_avator"
:src="isMyself(msgBody) ? loginUserInfo.avatarurl : otherUserInfo(msgBody.from).avatarurl || defaultAvatar">
</el-avatar>
<el-dropdown class="message_box_content"
:class="[isMyself(msgBody) ? 'message_box_content_mine' : 'message_box_content_other']"
trigger="contextmenu" placement="bottom-end">
<!-- 文本类型消息 -->
<p style="padding: 10px" v-if="msgBody.type === ALL_MESSAGE_TYPE.TEXT">
{{ msgBody.msg }}
</p>
<!-- 图片类型消息 -->
<!-- <div> -->
<el-image v-if="msgBody.type === ALL_MESSAGE_TYPE.IMAGE" style="border-radius:5px;"
:src="msgBody.thumb" :preview-src-list="[msgBody.url]" :initial-index="1" fit="cover" />
<!-- </div> -->
<!-- 语音类型消息 -->
<div :class="['message_box_content_audio', isMyself(msgBody) ? 'message_box_content_audio_mine' : 'message_box_content_audio_other']"
v-if="msgBody.type === ALL_MESSAGE_TYPE.AUDIO" @click="startplayAudio(msgBody, index)"
:style="`width:${msgBody.length * 10}px`">
<span class="audio_length_text">
{{ msgBody.length }}′′
</span>
<div :class="[isMyself(msgBody) ? 'play_audio_icon_mine' : 'play_audio_icon_other', audioPlayStatus.playIndex === index && 'start_play_audio']"
style=" background-size: 100% 100%;">
</div>
</div>
<div v-if="msgBody.type === ALL_MESSAGE_TYPE.LOCAL">
<p style="padding: 10px">[暂不支持位置消息展示]</p>
</div>
<!-- 文件类型消息 -->
<div v-if="msgBody.type === ALL_MESSAGE_TYPE.FILE" class="message_box_content_file">
<div class="file_text_box">
<div class="file_name">{{ msgBody.filename }}</div>
<div class="file_size">{{ fileSizeFormat(msgBody.file_length) }}</div>
<a class="file_download" :href="msgBody.url" download>点击下载</a>
</div>
<span class="iconfont icon-wenjian"></span>
</div>
<!-- 自定义类型消息 -->
<div v-if="msgBody.type === ALL_MESSAGE_TYPE.CUSTOM" class="message_box_content_custom">
<template v-if="msgBody.customEvent && CUSTOM_TYPE[msgBody.customEvent]">
<div class="user_card">
<div class="user_card_main">
<!-- 头像 -->
<el-avatar shape="circle" :size="50"
:src="msgBody.customExts && msgBody.customExts.avatarurl || msgBody.customExts.avatar || defaultAvatar"
fit="cover" />
<!-- 昵称 -->
<span class="nickname">{{ msgBody.customExts && msgBody.customExts.nickname ||
msgBody.customExts.uid
}}</span>
</div>
<el-divider style="margin:5px 0; border-top:1px solid black;" />
<p style="font-size: 8px;">个人名片</p>
</div>
</template>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-if="msgBody.type === ALL_MESSAGE_TYPE.TEXT && isSupported"
@click="copyTextMessages(msgBody.msg)">
复制
</el-dropdown-item>
<el-dropdown-item v-if="isMyself(msgBody)" @click="recallMessage(msgBody)">
撤回
</el-dropdown-item>
<el-dropdown-item @click="deleteMessage(msgBody)">
删除
</el-dropdown-item>
<el-dropdown-item v-if="!isMyself(msgBody)" @click="informOnMessage(msgBody)">
举报
</el-dropdown-item>
</el-dropdown-menu>
</template>

</el-dropdown>
</div>
<div v-if="msgBody.isRecall" class="recall_style">{{ isMyself(msgBody) ? "你" : `${msgBody.from}`
}}撤回了一条消息<span class="reEdit" v-show="isMyself(msgBody) && msgBody.type === ALL_MESSAGE_TYPE.TEXT"
@click="reEdit(msgBody.msg)">重新编辑</span></div>
<div v-if="msgBody.type === ALL_MESSAGE_TYPE.INFORM" class="inform_style">
<p>
{{ msgBody.msg }}
</p>
</div>

</div>
<ReportMessage ref="reportMessage" />
</div>


</template>
</script>

了解即时通讯IM及应用场景请访问:环信官网
更多集成IM教程请访问:IMGeek社区

收起阅读 »

七道Android面试题,先来简单热个身

马上就要到招(tiao)聘(cao)旺季金三银四了,一批一批的社会精英在寻找自己的下一家的同时,也开始着手为面试做准备,回想起自己这些年,也大大小小经历过不少面试,有被面试过,也有当过面试官,其中也总结出了两个观点,一个就是不花一定的时间背些八股文还真的不行,...
继续阅读 »

马上就要到招(tiao)聘(cao)旺季金三银四了,一批一批的社会精英在寻找自己的下一家的同时,也开始着手为面试做准备,回想起自己这些年,也大大小小经历过不少面试,有被面试过,也有当过面试官,其中也总结出了两个观点,一个就是不花一定的时间背些八股文还真的不行,一些扯皮的话别去听,都是在害人,另一个就是面试造火箭,入职拧螺丝毕竟都是少数,真正一场合格的面试问的东西,都是实际开发过程中会遇到的,下面我就说几个我遇到过的面试题吧

为什么ArrayMap比HashMap更适合Android开发

我们一般习惯在项目当中使用HashMap去存储键值队这样的数据,所以往往在android面试当中HashMap是必问环节,但有次面试我记得被问到了有没有有过ArrayMap,我只能说有印象,毕竟用的最多的还是HashMap,然后那个面试官又问我,觉得Android里面更适合用ArrayMap还是HashMap,我就说不上来了,因为也没看过ArrayMap的源码,后来回去看了下才给弄明白了,现在就简单对比下ArrayMap与HashMap的特点

HashMap

  • HashMap的数据结构为数组加链表的结构,jdk1.8之后改为数组加链表加红黑树的结构

  • put的时候,会先计算key的hashcode,然后去数组中寻找这个hashcode的下标,如果数据为空就先resize,然后检查对应下标值(下标值=(数组长度-1)&hashcode)里面是否为空,空则生成一个entry插入,否就判断hascode与key值是否分别都相等,如果相等则覆盖,如果不等就发生哈希冲突,生成一个新的entry插入到链表后面,如果此时链表长度已经大于8且数组长度大于64,则先转成树,将entry添加到树里面

  • get的时候,也是先去查找数组对应下标值里面是否为空,如果不为空且key与hascode都相等,直接返回value,否就判断该节点是否为一个树节点,是就在树里面返回对应entry,否就去遍历整个链表,找出key值相等的entry并返回

ArrayMap

  • 内部维护两个数组,一个是int类型的数组(mHashes)保存key的hashcode,另一个是Object的数组(mArray),用来保存与mHashes对应的key-value

  • put数据的时候,首先用二分查找法找出mHashes里面的下标index来存放hashcode,在mArray对应下标index<<1与(index<<1)+1的位置存放key与value

  • get数据的时候,同样也是用二分查找法找出与key值对应的下标index,接着再从mArray的(index<<1)+1位置将value取出

对比

  • HashMap在存放数据的时候,无论存放的量是多少,首先是会生成一个Entry对象,这个就比较浪费内存空间,而ArrayMap只是把数据插入到数组中,不用生成新的对象

  • 存放大量数据的时候,ArrayMap性能上就不如HashMap,因为ArrayMap使用的是二分查找法找的下标,当数据多了下标值找起来时间就花的久,此外还需要将所有数据往后移再插入数据,而HashMap只要插入到链表或者树后面即可

所以这就是为什么,在没有那么大的数据量需求下,Android在性能角度上比较适合用ArrayMap

为什么Arrays.asList后往里add数据会报错

这个问题我当初问过不少人,不缺乏一些资历比较深的大佬,但是他们基本都表示不清楚,这说明平时我们研究Glide,OkHttp这样的三方库源码比较多,而像一些比较基础的往往会被人忽略,而有些问题如果被忽略了,往往会产生一些捉摸不透的问题,比如有的人喜欢用Arrays.asList去生成一个List

val dataList = Arrays.asList(1,2,3)
dataList.add(4)

但是当我们往这个List里面add数据的时候,我们会发现,crash了,看到的日志是


不被支持的操作,这让首次遇到这样问题的人肯定是一脸懵,List不让添加数据了吗?之前明明可以的啊,但是之前我们创建一个List是这样创建的


它所在的包是java.util.ArrayList里面,我们看下里面的代码

public boolean add(E e) {
  ensureCapacityInternal(size + 1); // Increments modCount!!
  elementData[size++] = e;
  return true;
}
public void add(int index, E element) {
  if (index > size || index < 0)
      throw new IndexOutOfBoundsException(outOfBoundsMsg(index));

  ensureCapacityInternal(size + 1); // Increments modCount!!
  System.arraycopy(elementData, index, elementData, index + 1,
                    size - index);
  elementData[index] = element;
  size++;
}

是存在add方法的,我们再回头再去看看asList生成的List


是在java.util.Arrays包里面的,而这里面的ArrayList我们看到了,并没有去实现List接口,所以也就没有add,get等方法,另外在kotlin里面,我们会看到一个细节,当你敲完Arrays.asList的时候,编译器会提示你,可以转换成listof函数,而这个还是我们知道生成的list都是只能读取,不能往里写数据

Thread.sleep(0)到底“睡没睡”

记得在上上家公司,接手的第一个需求就是做一个动画,这个动画需要一个延迟启动的功能,我那个时候想都没想加了个Thread.sleep(3000),后来被领导批了,不可以用Thread.sleep实现延迟功能,那会还不太明白,后来知道了,Thread.sleep(3000)不一定真的暂停三秒,我们来举个例子

println("start:${System.currentTimeMillis()}")
Thread(Runnable {
   Thread.sleep(3000)
   println("end:${System.currentTimeMillis()}")
}).start()

我们在主线程先打印一条数据展示时间,然后开启一个子线程,在里面sleep三秒以后在打印一下时间,我们看下结果如何

start:1675665421590
end:1675665424591

好像对了又好像没对,为什么是过了3001毫秒才打印出来呢?有的人会说,1毫秒而已,忽略嘛,那我们把上面的代码改下再试试

println("start:${System.currentTimeMillis()}")
Thread(Runnable {
   Thread.sleep(0)
   println("end:${System.currentTimeMillis()}")
}).start()

现在sleep了0毫秒,那是不是两条打印日志应该是一样的呢,我们看看结果

start:1675666764475
end:1675666764477

这下子给整不会了,明明sleep0毫秒,那么多出来的2毫秒是怎么回事呢?其实在Android操作系统中,每个线程使用cpu资源都是有优先级的,优先级高的才有资格使用,而操作系统则是在一个线程释放cpu资源以后,重新计算所有线程的优先级来重新分配cpu资源,所以sleep真正的意义不是暂停,而是在接下去的时间内不参与cpu的竞争,等到cpu重新分配完资源以后,如果优先级没变,那么继续执行,所以sleep(0)秒的真正含义是触发cpu资源重新分配

View.post为什么可以获取控件的宽高

我们都知道在onCreate里面想要获取一个控件的宽高,如果直接获取是拿不到的

val mWith = bindingView.mainButton.width
val mHeight = bindingView.mainButton.height
println("按钮宽:$mWith,高:$mHeight")
......
按钮宽:0,高:0

而如果想要获取宽高,则必须调用View.post的方法

bindingView.mainButton.post {
   val mWith = bindingView.mainButton.width
   val mHeight = bindingView.mainButton.height
   println("按钮宽:$mWith,高:$mHeight")
}
......
按钮宽:979,高:187

很神奇,加个post就可以在同样的地方获取控件宽高了,至于为什么呢?我们来分析一下

简单的来说

Activity生命周期,onCreate方法里面视图还在绘制过程中,所以没法直接获取宽高,而在post方法中执行,就是在线程里面获取宽高,这个线程会在视图没有绘制完成的时候放在一个等待队列里面,等到视图绘制执行完毕以后再去执行队列里面的线程,所以在post里面也可以获取宽高

复杂的来说

我们首先从View.post方法里面开始看


这个代码里面的两个框子,说明了post方法做了两件事情,当mAttachInfo不为空的时候,直接让mHandler去执行线程action,当mAttachInfo为空的时候,将线程放在了一个队列里面,从注释里面的第一个单词Postpone就可以知道,这个action是要推迟进行,什么时候进行呢,我们在慢慢看,既然是判断当mAttachInfo不为空才去执行线程,那我们找找什么时候对mAttachInfo赋值,整个View的源码里面只有一处是对mAttachInfo赋值的,那就是在dispatchAttachedToWindow 这个方法里面,我们看下

void dispatchAttachedToWindow(AttachInfo info, int visibility) {
mAttachInfo = info;
...省略部分源码...

// Transfer all pending runnables.
if (mRunQueue != null) {
mRunQueue.executeActions(info.mHandler);
mRunQueue = null;
}

}

当走到dispatchAttachedToWindow这个方法的时候,mAttachInfo才不为空,也就是从这里开始,我们就可以获取控件的宽高等信息了,另外我们顺着这个方法往下看,可以发现,之前的那个队列在这里开始执行了,现在就关键在于,什么时候执行dispatchAttachedToWindow这个方法,这个时候就要去ViewRootIml类里面查看,发现只有一处调用了这个方法,那就是在performTraversals这个方法里面

private void performTraversals() {
...省略部分源码...
host.dispatchAttachedToWindow(mAttachInfo, 0);
...省略部分源码...
// Ask host how big it wants to be
performMeasure(childWidthMeasureSpec, childHeightMeasureSpec);
...省略部分源码...
performLayout(lp, mWidth, mHeight);
...省略部分源码...
performDraw();
}

performTraversals这个方法我们就很熟悉了,整个View的绘制流程都在里面,所以只有当mAttachInfo在这个环节赋值了,才可以得到视图的信息

IdleHandler到底有啥用

Handler是面试的时候必问的环节,除了问一下那四大组件之外,有的面试官还会问一下IdleHandler,那IdleHandler到底是什么呢,它是干什么用的呢,我们来看看

Message next() {
...省略部分代码...
synchronized (this) {
// If first time idle, then get the number of idlers to run.
// Idle handles only run if the queue is empty or if the first message
// in the queue (possibly a barrier) is due to be handled in the future.
if (pendingIdleHandlerCount < 0
&& (mMessages == null || now < mMessages.when)) {
pendingIdleHandlerCount = mIdleHandlers.size();
}
if (pendingIdleHandlerCount <= 0) {
// No idle handlers to run. Loop and wait some more.
mBlocked = true;
continue;
}

if (mPendingIdleHandlers == null) {
mPendingIdleHandlers = new IdleHandler[Math.max(pendingIdleHandlerCount, 4)];
}
mPendingIdleHandlers = mIdleHandlers.toArray(mPendingIdleHandlers);
}

// Run the idle handlers.
// We only ever reach this code block during the first iteration.
for (int i = 0; i < pendingIdleHandlerCount; i++) {
final IdleHandler idler = mPendingIdleHandlers[i];
mPendingIdleHandlers[i] = null; // release the reference to the handler

boolean keep = false;
try {
keep = idler.queueIdle();
} catch (Throwable t) {
Log.wtf(TAG, "IdleHandler threw exception", t);
}

if (!keep) {
synchronized (this) {
mIdleHandlers.remove(idler);
}
}
}

}

只有在MessageQueue中的next方法里面出现了IdleHandler,作用也很明显,当消息队列在遍历队列中的消息的时候,当消息已经处理完了,或者只存在延迟消息的时候,就会去处理mPendingIdleHandlers里面每一个idleHandler的事件,而这些事件都是通过方法addIdleHandler注册进去的

Looper.myQueue().addIdleHandler {
false
}

addIdlehandler接受的参数是一个返回值为布尔类型的函数类型参数,至于这个返回值是true还是false,我们从next()方法中就能了解到,当为false的时候,事件处理完以后,这个IdleHandler就会从数组中删除,下次再去遍历执行这个idleHandler数组的时候,该事件就没有了,如果为true的话,该事件不会被删除,下次依然会被执行,所以我们按需设置。现在我们可以利用idlehandler去解决上面讲到的在onCreate里面获取控件宽高的问题

Looper.myQueue().addIdleHandler {
val mWith = bindingView.mainButton.width
val mHeight = bindingView.mainButton.height
println("按钮宽:$mWith,高:$mHeight")
false
}

当MessageQueue中的消息处理完的时候,我们的视图绘制也完成了,所以这个时候肯定也能获取控件的宽高,我们在IdleHandler里面执行了同样的代码之后,运行后的结果如下

按钮宽:979,高:187

除此之外,我们还可以做点别的事情,比如我们常说的不要在主线程里面做一些耗时的工作,这样会降低页面启动速度,严重的还会出现ANR,这样的场景除了开辟子线程去处理耗时操作之外,我们现在还可以用IdleHandler,这里举个例子,我们在主线程中给sp塞入一些数据,然后在把这些数据读取出来,看看耗时多久

println(System.currentTimeMillis())
val testData = "aabbbbakjsdhjkahsjkasdjasdhjakshdjkahsdjkhasjdkhjaskhdjkashdjkhasjkhas" +
"jkhdaabbbbakjsdhjkahsjkasdjasdhjakshdjkahsdjkhasjdkhjaskhdjkashdjkhasjkhasjkhd" +
"aabbbbakjsdhjkahsjkasdjasdhjakshdjkahsdjkhasjdkhjaskhdjkashdjkhasjkhasjkhd" +
"aabbbbakjsdhjkahsjkasdjasdhjakshdjkahsdjkhasjdkhjaskhdjkashdjkhasjkhasjkhd" +
"aabbbbakjsdhjkahsjkasdjasdhjakshdjkahsdjkhasjdkhjaskhdjkashdjkhasjkhasjkhd" +
"aabbbbakjsdhjkahsjkasdjasdhjakshdjkahsdjkhasjdkhjaskhdjkashdjkhasjkhasjkhd"
sharePreference = getSharedPreferences(packageName, MODE_PRIVATE)
for (i in 1..5000) {
sharePreference.edit().putString("test$i", testData).commit()
}
for (i in 1..5000){
sharePreference.getString("test$i","")
}
println(System.currentTimeMillis())

......运行结果
1676260921617
1676260942770

我们看到在塞入5000次数据,再读取5000次数据之后,一共耗时大概20秒,同时也阻塞了主线程,导致的现象是页面一片空白,只有等读写操作结束了,页面才展示出来,我们接着把读写操作的代码用IdleHandler执行一下看看

Looper.myQueue().addIdleHandler {
sharePreference = getSharedPreferences(packageName, MODE_PRIVATE)
val editor = sharePreference.edit()
for (i in 1..5000) {
editor.putString("test$i", testData).commit()
}
for (i in 1..5000){
sharePreference.getString("test$i","")
}
println(System.currentTimeMillis())
false
}
......运行结果
1676264286760
1676264308294

运行结果依然耗时二十秒左右,但区别在于这个时候页面不会受到读写操作的阻塞,很快就展示出来了,说明读写操作的确是等到页面渲染完才开始工作,上面过程没有放效果图主要是因为时间太长了,会影响gif的体验,有兴趣的可以自己试一下

如何让指定视图不被软键盘遮挡

我们通常使用android:windowSoftInputMode属性来控制软键盘弹出之后移动界面,让输入框不被遮挡,但是有些场景下,键盘永远都会挡住一些我们使用频次比较高的控件,比如现在我们有个登录页面,大概的样子长这样


它的布局文件是这样

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

<ImageView
android:layout_width="200dp"
android:layout_height="200dp"
android:layout_centerHorizontal="true"
android:layout_marginTop="100dp"
android:src="@mipmap/ic_launcher_round" />

<androidx.appcompat.widget.LinearLayoutCompat
android:id="@+id/ll_view1"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_alignParentBottom="true"
android:layout_marginBottom="120dp"
android:gravity="center"
android:orientation="vertical">

<EditText
android:id="@+id/main_edit"
android:layout_width="match_parent"
android:layout_height="40dp"
android:hint="请输入用户名"
android:textColor="@color/black"
android:textSize="15sp" />

<EditText
android:id="@+id/main_edit2"
android:layout_width="match_parent"
android:layout_height="40dp"
android:layout_marginTop="30dp"
android:hint="请输入密码"
android:textColor="@color/black"
android:textSize="15sp" />

<Button
android:layout_width="match_parent"
android:layout_height="50dp"
android:layout_marginHorizontal="10dp"
android:layout_marginTop="20dp"
android:text="登录" />

</androidx.appcompat.widget.LinearLayoutCompat>

</RelativeLayout>

在这样一个页面里面,由于输入框与登录按钮都比较靠页面下方,导致当输入完内容想要点击登录按钮时候,必须再一次关闭键盘才行,这样的操作在体验上就比较大打折扣了


现在希望可以键盘弹出之后,按钮也展示在键盘上面,这样就不用收起弹框以后才能点击按钮了,这样一来,windowSoftInputMode这一个属性已经不够用了,我们要想一下其他方案

  • 首先,需要让按钮也展示在键盘上方,那只能让布局整体上移把按钮露出来,在这里我们可以改变LayoutParam的bottomMargin参数来实现

  • 其次,需要知道键盘什么时候弹出,我们都知道android里面并没有提供任何监听事件来告诉我们键盘什么时候弹出,我们只能从其他角度入手,那就是监听根布局可视区域大小的变化

ViewTreeObserver

我们先获取视图树的观察者,使用addOnGlobalLayoutListener去监听全局视图的变化

bindingView.mainroot.viewTreeObserver.addOnGlobalLayoutListener {

}

接下去就是要获取根视图的可视化区域了,如何来获取呢?View里面有这么一个方法,那就是getWindowVisibleDisplayFrame,我们看下源码注释就知道它是干什么的了


一大堆英文没必要都去看,只需要看最后一句就好了,大概意思就是获取能够展示给用户的可用区域,所以我们在监听器里面加上这个方法

bindingView.mainroot.viewTreeObserver.addOnGlobalLayoutListener {
val rect = Rect()
bindingView.mainroot.getWindowVisibleDisplayFrame(rect)
}

当键盘弹出或者收起的时候,rect的高度就会跟着变化,我们就可以用这个作为条件来改变bottomMargin的值,现在我们增加一个变量oldDelta来保存前一个rect变化的高度值,用来做比较,完整的代码如下

var oldDelta = 0
val params:RelativeLayout.LayoutParams = bindingView.llView1.layoutParams as RelativeLayout.LayoutParams
val originBottom = params.bottomMargin
bindingView.mainroot.viewTreeObserver.addOnGlobalLayoutListener {
val rect = Rect()
bindingView.mainroot.getWindowVisibleDisplayFrame(rect)
val deltaHeight = r.height()
if (oldDelta != deltaHeight) {
if (oldDelta != 0) {
if (oldDelta > deltaHeight) {
params.bottomMargin = oldDelta - deltaHeight
} else if (oldDelta < deltaHeight) {
params.bottomMargin = originBottom
}
bindingView.llView1.layoutParams = params
}
oldDelta = deltaHeight
}
}

最终效果如下


弹出后页面有个抖动是因为本身有个页面平移的效果,然后再去计算layoutparam,如果不想抖动可以在布局外层套个scrollView,用smoothScrollTo把页面滑上去就可以了,有兴趣的可以业余时间试一下

为什么LiveData的postValue会丢失数据

LiveData已经问世好多年了,大家都很喜欢用,因为它上手方便,一般知道塞数据用setValue和postValue,监听数据使用observer就可以了,然而实际开发中我遇到过好多人,一会这里用setValue一会那里用postValue,或者交替着用,这种做法也不能严格意义上说错,毕竟运行起来的确没问题,但是这种做法确实是存在风险隐患,那就是连续postValue会丢数据,我们来做个实验,连续setValue十个数据和连续postValue十个数据,收到的结果都分别是什么

var testData = MutableLiveData<Int>()
fun play(){
for (i in 1..10) {
testData.value = i
}
}

mainViewModel.testData.observe(this) {
println("收到:$it")
}

//执行结果
收到:1
收到:2
收到:3
收到:4
收到:5
收到:6
收到:7
收到:8
收到:9
收到:10

setValue十次数据都可以收到,现在把setValue改成postValue再来试试

var testData = MutableLiveData<Int>()
fun play(){
for (i in 1..10) {
testData.postValue(i)
}
}

得到的结果是

收到:10

只收到了最后一条数据10,这是为什么呢?我们进入postValue里面看看里面的源码就知道了


主要看红框里面,有一个synchronized同步锁锁住了一个代码块,我们称为代码块1,锁的对象是mDataLock,代码块1做的事情先是给postTask这个布尔值赋值,接着把传进来的值赋给mPendingData,那我们知道了,postTask除了第一个被执行的时候,值是true,结下去等mPendingData有值了以后就都为false,前提是mPendingData没有被重置为NOT_SET,然后我们顺着代码往下看,会看到代码接下来就要到一个mPostValueRunnable的线程里面去了,我们看下这个线程


发现同样的锁,锁住了另一块代码块,我们称为代码块2,这个代码块里面恰好是把mPendingData的值赋给newValue以后,重置为NOT_SET,这样一来,postValue又可以接受新的值了,所以这也是正常情况下每次postValue都可以接受到值的原因,但是我们想想连续postValue的场景,我们知道如果synchronized如果修饰一段代码块,那么当这段代码块获取到锁的时候,就具有优先级,只有当全部执行完以后才会释放锁,所以当代码块1连续被访问时候,代码块2是不会被执行的,只有等到代码块1执行完,释放了锁,代码块2才会被执行,而这个时候,mPendingData已经是最新的值了,之前的值已经全部被覆盖了,所以我们说的postValue会丢数据,其实说错了,应该是postValue只会发送最新数据

总结

这篇文章讲到的面试题还仅仅只是过去几年遇到的,现在面试估计除了一些常规问题之外,比重会更倾向于Kotlin,Compose,Flutter的知识点,所以只有不断的日积月累,让自己的知识点更加的全面,才能在目前竞争激烈的行情趋势下逆流而上,不会被拍打在沙滩上

作者:Coffeeee
来源:juejin.cn/post/7199537072302374969

收起阅读 »

【设计模式】Kotlin 与 Java 中的单例

单例模式 单例模式是一个很常见的设计模式,尤其是在 Java 中应用非常广泛。单例模式的定义是保证一个类仅有一个实例,并提供一个访问它的全局访问点。 Java 中的单例模式 Java 中存在多种单例模式的实现方案,最经典的包括: 懒汉式 饿汉式 双...
继续阅读 »

单例模式


单例模式是一个很常见的设计模式,尤其是在 Java 中应用非常广泛。单例模式的定义是保证一个类仅有一个实例,并提供一个访问它的全局访问点。


Java 中的单例模式


Java 中存在多种单例模式的实现方案,最经典的包括:




  • 懒汉式




  • 饿汉式




  • 双重校验锁




饿汉式 / 静态单例


Java 中懒汉式单例如其名一样,“饿” 体现在不管单例对象是否存在,都直接进行初始化:


public final class SingleManager {
@NotNull
public static final SingleManager INSTANCE = new SingleManager();

private SingleManager() {}

public static SingleManager getInstance() {
return INSTANCE;
}
}

实际上,这也是静态单例。


懒汉式 / 延迟初始化


Java 中的懒汉式核心特点在于“懒” ,它不像饿汉式,无论 INSTANCE 是否已经存在值,都进行初始化;而是在调用 get 方法时,检查引用对象是否为空,如果为空再去初始化:


public final class SingleManager {
public static SingleManager INSTANCE;

private SingleManager() {}

public static SingleManager getInstance() {
if (INSTANCE == null) {
INSTANCE = new SingleManager();
}
return INSTANCE;
}
}

双重校验锁


public final class SingleManager {
// volatile 防止指令重排,确保原子操作的顺序性
public volatile static SingleManager INSTANCE;

private SingleManager() {}

public static SingleManager getInstance() {
// 第一次判空,减少进入同步锁的次数,提高效率
if (INSTANCE == null) {
// 确保同步
synchronized (SingleManager.class) {
// 确保加锁后,引用仍是空的
if (INSTANCE == null) {
INSTANCE = new SingleManager();
}
}
}
return INSTANCE;
}
}

Kotlin 中的单例模式


object 关键字


Kotlin 提供了比 Java 更方便的语法糖 object 关键字,能够更方便地实现单例模式:


object SingleManager {
fun main() {}
}

使用:


// used in kotlin
SingleManager.main()

// used in java
SingleManager.Companion.main();


如果要在 Java 中的使用方式与 Kotlin 使用方式一致,可以在方法上添加 @JvmStatic 注解:


object SingleManager {
@JvmStatic
fun main() {}
}

// used in java
SingleManager.main();


object 关键字实现的单例,编译为 Java 字节码的实现是:


public final class SingleManager {
@NotNull
public static final SingleManager INSTANCE;

public final void main() {
}

private SingleManager() {
}

static {
SingleManager var0 = new SingleManager();
INSTANCE = var0;
}
}

这是一种标准的 Java 静态单例实现。


Kotlin 懒汉式


在一些特殊的情况,例如你的单例对象要保存一些不适合放在静态类中的引用,那么使用 object 就不是合适的方案了,例如,Android 中的上下文 Context 、View 都不适合在静态类中进行引用,IDE 也会提醒你这样会造成内存泄漏:


image-20230216202522160.png


一种好的解决方案是在 Kotlin 中使用懒汉式的写法:


class SingleManager {
companion object {
private var instance: SingleManager? = null

fun getInstance(): SingleManager {
if (instance == null) {
instance = SingleManager()
}
return instance!!
}
}

var view: View? = null
}

但是这样仍然会提醒你不要引用:


image-20230216202859206.png


但如果引用的对象是你自定义的 View :


class BaseView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null
) : FrameLayout(context, attrs)

在 kotlin 中懒汉式是不会提示你的:


image-20230216203102547.png


可以看出,使用 object 关键字,仍然会飘黄,提醒你可能存在内存泄漏。


本质上来说,没有提示实际上也是存在内存泄漏的隐患的。 虽然可以骗过 IDE 但不应该欺骗自己。


写在最后


Kotlin 作为一门更新的 JVM 语言,它提供了很多语法糖突破了 Java 的一些固定写法,有些设计模式已经不再适合新的语言(例如 Builder 模式在 Kotlin 中很少会出现了)。虽然新语言简化了代码的复杂度、简化了写法,但不能简化知识点,例如,使用 Kotlin 需要一个线程安全的单例,仍然可以使用双重校验锁的写法。本质上还是要搞清楚底层逻辑。


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

Flutter WebView 性能优化,让 h5 像原生页面一样优秀

WebView 页面的体验上之所以不如原生页面,主要是因为原生页面可以马上显示出页面骨架,一下子就能看到内容。WebView 需要先根据 url 去加载 html,加载到 html 后才能加载 css ,css 加载完成后才能正常显示页面内容,至少多出两步网络...
继续阅读 »

WebView 页面的体验上之所以不如原生页面,主要是因为原生页面可以马上显示出页面骨架,一下子就能看到内容。WebView 需要先根据 url 去加载 html,加载到 html 后才能加载 css ,css 加载完成后才能正常显示页面内容,至少多出两步网络请求。有的页面是用 js 渲染的,这样时间会更长。要想让 WebView 页面能接近 Flutter 页面的体验,主要就是要省掉网络请求的时间。


做优化要考虑到很多方面,在成本与收益之间做平衡。如果不是新开项目,需要考虑项目当前的情况。下面分两种情况讨论一下。


服务端渲染


页面 html 已经在服务端拼接完成。只需要 html,css 就可以正常查看页面(主要内容不受影响)。如果你的项目的页面是这样的,那么我们已经有了一个好的起点。


WebView 要显示一个页面,需要串行下面的过程。通过 url 加载到 html 后再加载 css,css 加载完成后显示页面。


url -> html -> css -> 显示

我们可以对 css 的请求做一下优化。优化方案有两种



  1. 内联 css 到 html

  2. 把 css 缓存到本地。


第一种方案比较容易做,修改一下页面的打包方案即可。很容易实现一份代码打包出两个页面,一个外链 css ,一个内联css。但坏处也是很明显的,每次都加载同样的 css,会增加网络传输,如果网络不佳的话,对首屏时间可能会产生明显的影响。就算抛开首屏时间,也会对用户的流量造成浪费。


第二种方案可以解决 css 重复打包的问题。首先要考虑的问题是:css 放在本地的哪个地方?


css 放哪里


有两个地方可以放



  1. 放在 asset,和 app 一起打包发布,好处是简单可靠,坏处是不方便更新。

  2. 放在 文档目录,好处是可以随时更新,坏处是逻辑上会复杂一些。



文档目录用于存储只能由该应用访问的文件,系统不会清除该目录,只有在删除应用时才会消失。



从技术上来说,这两种方案都是可以的。先说下不方便更新的问题:既然 app 的其它页面都不能随便更新,为什么不能接受这个页面的样式不能随便更新?如果是害怕版本冲突,那也好解决,发一次版,更新一次页面地址,每个版本都有其对应的页面地址,这样就不会冲突了。根本原因是掌控的诱惑,即使你能控制住诱惑,你的老板也控制不住。所以还是老老实实选第二种方案吧。


放哪里的问题解决了,接下来要考虑的是如何更新 css 的问题。


更新 css


因为有可能 app 启动后第一个展示的就是这个页面,所以要在 app 启动后第一时间就更新 css。但又有一个问题,每次启动都更新同样的内容是在浪费流量。解决办法是加一个配置,每次启动后第一时间加载这个配置,通过配置信息来判断要不要更新 css。



这个配置一定要很小,比如可以用二进制 01 表示true false,当然了可能不需要这么极端,用一个 map 就好。



如何利用本地 css 快速显示页面


在 app 上启动一个本地 http server 提供 css。 我们可以在打包的时候把 css 的外链写成本地 http,比如 http://localhost:8080/index.css


除了 css,页面的重要图片,字体等静态资源也可以放在本地,只要加载到 html 就可以立即显示页面,省了一步需要串行的网络请求。


到这里服务端渲染页面的优化就完成了,还是很简单的吧,示例代码在后面。


浏览器渲染


近年来,随着 vue,react 的兴起,由 js 在浏览器中拼接 html 逐渐成为主流。虽然可以用同构的方案,但那样会增加成本,除非必须,一般都是只在浏览器渲染。可能你的页面正是这样的。我们来分析一下。


WebView 要显示一个页面,需要串行下面的过程。通过 url 加载到 html 后再加载 css、js,js 请求完数据后才能显示页面。


url -> html -> css,js -> js 去加载数据 -> 显示

和服务端渲染的页面相比,首次请求时间更长。多出了 js 加载数据的时间。除了要缓存 css,还要缓存 js 和数据。缓存 js 是必须的,缓存数据是可选的。好消息是 html 只有骨架,没有内容,可以连 html 也一起缓存。


缓存 js,html 的方案和缓存 css 的方案是一样的。缓存数据会面临数据更新的难题,所以只可以缓存少量不需要时时更新的少量重要数据,不需要所有数据都缓存。app 的原生页面也是需要加载数据的,也不是每种数据都要缓存。


数据更新之所以说是一个难题,是因为很多内容数据是需要即时更新的。但数据已经下发到客户端,已经缓存起来,客户端不再发起新的请求,如何通知客户端进行数据更新?虽然有轮询,socket,服务端推送等方案可以尝试,但开发成本都比较高,和获得的收益相比,代价太大。


当缓存了 html,css,js 等静态资源后,h5 就已经和原生页面站在同一起跑线上了,对于只读的页面,体验上相差无几。



加载数据后还有js 拼接 html 的时间,和加载的时间相比,只要硬件还可以的情况下,消耗的时间可以忽略




图片不适合用缓存 css 的方案,因为图片太大也太多。只能预加载少量最重要的图片,其它大量图片只能对二次加载做优化,我们会在后面讨论



浏览器渲染的页面也需要打包的配合,需要把所有的要缓存的静态资源地址都换成本地地址,这就要求发布的时候一份代码需要发布两个页面。一个是给浏览器用的,资源都通过网络加载。一个是给 WebView 用的,资源都从本地获取。


思路已经有了,具体实现就简单了。下面我给出关键环节的示例代码,供大家参考。


如何启动本地server


本地不需要 https,用 http 用行了,但是需要在 AndroidManifest.xml 的 applictation 中做如下配置 android:usesCleartextTraffic="true"


import 'package:shelf/shelf_io.dart' as io;
import 'package:shelf_static/shelf_static.dart';
import 'package:path_provider/path_provider.dart';

Future<void> initServer(webRoot) async {
var documentDirectory = await getApplicationDocumentsDirectory();

var handler =
createStaticHandler('${documentDirectory.path}/$webRoot', defaultDocument: 'index.html');
io.serve(handler, 'localhost', 8080);
}

createStaticHandler 负责处理静态资源。



如果要兼容 windows 系统,路径需要用 path 插件的 join 方法拼接



如何让 WebView 的页面请求走本地服务


两种方案:



  1. 打包的时候需要缓存的页面的地址都改成本地地址

  2. 对页面请求 在 WebView 中进行拦截,让已经缓存的页面走本地 server。


相比之下,第 2 种方案都好一些。可以通过配置文件灵活修改哪些页面需要缓存。


在下面的示例代码中 ,cachedPagePaths 存储着需要缓存的页面的 path。


import 'dart:async';
import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';

class MyWebView extends StatefulWidget {
const MyWebView({super.key, required this.url, this.cachedPagePaths = const []});
final String url;
final List<String> cachedPagePaths;

@override
State<MyWebView> createState() => _MyWebViewState();
}

class _MyWebViewState extends State<MyWebView> {
late final WebViewController controller;

@override
void initState() {
controller = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setNavigationDelegate(NavigationDelegate(
onNavigationRequest: (request) async {
var uri = Uri.parse(request.url);
// TODO: 还应该判断下 host
if (widget.cachedPagePaths.contains(uri.path)) {
var url = 'http://localhost:8080/${uri.path}';
Future.microtask(() {
controller.loadRequest(Uri.parse(url));
});
return NavigationDecision.prevent;
} else {
return NavigationDecision.navigate;
}
},
))
..loadRequest(Uri.parse(widget.url));
super.initState();
}
@override
void didUpdateWidget(covariant MyWebView oldWidget) {

if(oldWidget.url!=widget.url){
controller.loadRequest(Uri.parse(widget.url));
}

super.didUpdateWidget(oldWidget);
}
@override
Widget build(BuildContext context) {
return Column(
children: [Expanded(child: WebViewWidget(controller: controller))],
);
}
}

优化图片请求


如果页面中有很多图片,你会发现,体验上还是不如 Flutter 页面,为什么呢?原来 Flutter Image Widget 使用了缓存,把请求到的图片都缓存了起来。 要达到相同的体验,h5 页面也需要实现相同的缓存功能。



关于 Flutter 图片请参见 快速掌握 Flutter 图片开发核心技能



代码实现


要如何实现呢?只需要两步。



  1. 打包的时候需要把图片的外链请求改成本地请求

  2. 本地 server 对图片请求进行拦截,优先读缓存,没有再去请求网络。


第 1 条我举个例子,比如图片的地址为 https://juejin.com/logo.png ,打包的时候需要修改为 http://localhost:8080/logo.png


第 2 条的实现上,我们取个巧,借用 Flutter 中的 NetworkImage,NetworkImage 有缓存的功能。


下面给出完整示例代码,贴到 main.dart 中就能运行。运行代码后看到一段文字和一张图片。


注意先安装相关的插件,插件的名字 import 里有。



import 'dart:io';
import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart';
import 'dart:async';
import 'dart:typed_data';
import 'package:shelf/shelf.dart';
import 'package:shelf/shelf_io.dart' as io;
import 'package:shelf_static/shelf_static.dart';
import 'dart:ui' as ui;
import 'package:webview_flutter/webview_flutter.dart';

const htmlString = '''
<!DOCTYPE html>
<head>
<title>webview demo | IAM17</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0,
maximum-scale=1.0, user-scalable=no,viewport-fit=cover" />
<style>
*{
margin:0;
padding:0;
}
body{
background:#BBDFFC;
text-align:center;
color:#C45F84;
font-size:20px;
}
img{width:90%;}
p{margin:30px 0;}
</style>
</head>
<html>
<body>
<p>大家好,我是 17</p>
<img src='http://localhost:8080/tos-cn-i-k3u1fbpfcp/
c6208b50f419481283fcca8c44a2e3af~tplv-k3u1fbpfcp-watermark.image'/>
</body>
</html>
''';
void main() async {
runApp(const MyApp());
}

class MyApp extends StatefulWidget {
const MyApp({super.key});
@override
State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
WebViewController? controller;
@override
void initState() {
init();
super.initState();
}

init() async {
var server = Server17(remoteHost: 'p6-juejin.byteimg.com');
await server.init();

var filePath = '${server.webRoot}/index.html';
var indexFile = File(filePath);
await indexFile.writeAsString(htmlString);
setState(() {
controller = WebViewController()
..loadRequest(Uri.parse('http://localhost:${server.port}/index.html'));
});
}

@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
body: SafeArea(
child: controller == null
? Container()
: WebViewWidget(controller: controller!),
),
));
}
}

class Server17 {
Server17(
{this.remoteSchema = 'https',
required this.remoteHost,
this.port = 8080,
this.webFolder = 'www'});
final String remoteSchema;
final String remoteHost;

final int port;
final String webFolder;
String? _webRoot;
String get webRoot {
if (_webRoot == null) throw Exception('请在初始化后读取');
return _webRoot!;
}

init() async {
var documentDirectory = await getApplicationDocumentsDirectory();
_webRoot = '${documentDirectory.path}/$webFolder';
await _createDir(_webRoot!);
var handler = Cascade()
.add(getImageHandler)
.add(createStaticHandler(_webRoot!, defaultDocument: 'index.html'))
.handler;

io.serve(handler, InternetAddress.loopbackIPv4, port);
}

_createDir(String path) async {
var dir = Directory(path);
var exist = dir.existsSync();
if (exist) {
return;
}
await dir.create();
}

Future<Uint8List?> loadImage(String url) async {
Completer<ui.Image> completer = Completer<ui.Image>();
ImageStreamListener? listener;
ImageStream stream = NetworkImage(url).resolve(ImageConfiguration.empty);
listener = ImageStreamListener((ImageInfo frame, bool sync) {
final ui.Image image = frame.image;
completer.complete(image);
if (listener != null) {
stream.removeListener(listener);
}
});
stream.addListener(listener);
var uiImage = await completer.future;
var pngBytes = await uiImage.toByteData(format: ui.ImageByteFormat.png);
if (pngBytes != null) {
return pngBytes.buffer.asUint8List();
}
return null;
}

FutureOr<Response> getImageHandler(Request request) async {
if (RegExp(
r'\.(png|image)$',
).hasMatch(request.url.path)) {
var url = '$remoteSchema://$remoteHost/${request.url.path}';
var imageData = await loadImage(url);
//TODO: 如果 imageData 为空,改成错误图片
return Response.ok(imageData);
} else {
return Response.notFound('next');
}
}
}

代码逻辑



  1. 在本地文档目录的 www 文件夹中准备了一个 index.html 文件

  2. 启动本地 server,通过访问 http://localhost:8080/index.html 请求本地页面。

  3. server 收到请求后,对图片请求进行拦截,通过 NetworkImage 返回图片。


第 2 条。本例中是直接访问的 localhost,实际应用中,页面地址是外链地址,通过拦截的方式请求本地。如何做页面地址拦截前面已经给出示例了。


第 3 条。打包后的时候对所有图片地址都写成了本地地址,改成本地地址的目的就是为了让图片请求都由本地 server 响应。本地 server 拿到 图片地址后,再改回网络地址,通过 NetworkImage 请求图片。NetworkImage 会首先判断有没有缓存,有直接用,没有就发起网络请求,然后再缓存。


可能你觉得有点绕,既然最后还要用网络地址,为什么还要先写成本地地址,象拦截页面请求那样拦截图片请求不香吗?答案是不可以。两个原因。



  1. webview_flutter 只能拦截页面请求。

  2. 本地 server 不方便拦截 443 端口。


对比于拦截 443 端口,修改打包方案要容易的多。


关于图片类型


在示例代码中,用 RegExp( r'\.(png|image)$',) 判断是否要响应请求。从正则可以看出,以 png 或 image 结果的图片都能响应请求。判断 image 是因为示例中的图片地址是以 image 结尾的。


示例代码只能支持 png 格式的图片,示例图片虽然是 image 结尾,但格式也是 png 格式。如果要支持更多格式的图片,需要用到第三方库。


关于图片地址


如果图片地址失改,可以自行换一个,随使在网上找个 png 图片 地址就行。


把图片缓存到磁盘。


我们演示了把图片缓存到内存,当 app 被杀掉,缓存都没了,除非缓存到磁盘。这项工作已经有插件帮我们做了。
用 cached_network_image 替换 NetworkImage,稍加改动就可以实现磁盘缓存了。


总结一下


服务端染页面方案



  1. 打包的时候需要打出两个页面,一个页面的 css 外链接是外网,一个页面的 css 链接是本地。

  2. 在 App 启动的时候根据配置信息预加载 css 存到文档目录。

  3. 启动本地 server 响应 css 的请求。


浏览器渲染方案



  1. 打包的时候需要打出两个页面,一个页面的 css,js 链接是外网,一个页面的 css,js 链接是本地。

  2. 在 App 启动的时候根据配置信息预加载 html,css,js 存到文档目录。

  3. 根据配置信息拦截页面请求,已经缓存的页面改走本地 server。

  4. 启动本地 server 响应 html,css,js 的请求


图片缓存


如果不做图片缓存,通过前面两个方案,h5 速度就已经得到大大提高了。如果有余力,可以做图片缓存。图片缓存是可选的,是对前面两种方案的加强。



  1. 给 app 用的页面打包的时候把图片地址换成本地地址。

  2. 启动本地 server 响应图片请求,有缓存就读缓存,没有缓存走网络。


可能你的项目不同,有不同的方案,欢迎一起讨论。


本文到这里就结束了,谢谢观看。


番外


为了给自己一点压力,上一篇 在 Flutter 中使用 webview_flutter 4.0 | js 交互 中我就预告说今天要发这篇性能优化的文章。结果压力是有的了,但却没能按时完工(理想情况是周日下午完工,这样可以休息一下)。一个原因是 升级 flutter 报错,浪费了一个上午,再有就是写了一版后,并不满意,又重写了一版,最后才定稿。一直写到深夜才把主要内容写完。早上起来又做了补充修改。


由于时间紧,有不妥之处,还请各位大佬雅正。


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

在 Flutter 中使用 webview_flutter 4.0 | js 交互

大家好,我是 17。 已经有很多关于 Flutter WebView 的文章了,为什么还要写一篇。两个原因: Flutter WebView 是 Flutter 开发的必备技能 现有的文章都是关于老版本的,新版本 4.x 有了重要变化,基于 3.x 的代码很...
继续阅读 »

大家好,我是 17。


已经有很多关于 Flutter WebView 的文章了,为什么还要写一篇。两个原因:



  1. Flutter WebView 是 Flutter 开发的必备技能

  2. 现有的文章都是关于老版本的,新版本 4.x 有了重要变化,基于 3.x 的代码很多要重写。

本篇讲 js 交互。首先了解下 4.0 有哪些重大变化。



  1. 最大的变化就是 WebView 类已被删除,其功能已拆分为 WebViewController 和 WebViewWidget。让我们可以提前初始化 WebViewController。

  2. Android 的 PlatformView 的实现目前不再可配置。它在版本 23+ 上使用 Texture Layer Hybrid Compositiond,在版本 19-23 回退到 Hybrid Composition。


第 2 条的变化让我们不需要再写判断 android 的代码了。


还有 api 的变化。总的来说,让我们的编码更加容易了。


写本文的时候,Flutter WebView 的版本是 4.0.2


环境准备


虽然文档上写的是支持 addroid SDK 19+ or 20+, 但我们最好写 21 或更高,不是说会影响 Flutter WebView 的使用,而是太低了会影响其它插件的使用。如果能写 23 就更好了,这样可以用 Texture Layer Hybrid Compositiond 了。


android {
defaultConfig {
minSdkVersion 21
}
}

iOS 支持 9.0 以上,新版本的 flutter 默认配置是 ios 11.0 ,所以我们按 Flutter 默认的配置就好。


安装 webview_flutter


flutter pub add webview_flutter

最简示例


一般举例都是先发一个 hello world,咱们也发一个最简单的,先跑起来。


完整代码,贴到 main.dart 就能运行



  1. 引用 webview_flutter 插件

  2. 创建 controller

  3. 用 WebViewWidget 展示内容


import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';

const htmlString = '''
<!DOCTYPE html>
<head>
<title>webview demo | IAM17</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0,
maximum-scale=1.0, user-scalable=no,viewport-fit=cover" />
<style>
*{
margin:0;
padding:0;
}
body{
background:#BBDFFC;
display:flex;
justify-content:center;
align-items:center;
height:100px;
color:#C45F84;
font-size:20px;
}
</style>
</head>
<html>
<body>
<div >大家好,我是 17</div>
</body>
</html>
''';

void main() {
runApp(const MyApp());
}

class MyApp extends StatelessWidget {
const MyApp({super.key});

@override
Widget build(BuildContext context) {
return const MaterialApp(
home: Scaffold(
body: SafeArea(child: MyWebView()),
));
}
}

class MyWebView extends StatefulWidget {
const MyWebView({super.key});

@override
State<MyWebView> createState() => _MyWebViewState();
}

class _MyWebViewState extends State<MyWebView> {
late final WebViewController controller;
double height = 0;
@override
void initState() {
controller = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..loadHtmlString(htmlString);

super.initState();
}

@override
Widget build(BuildContext context) {
return Column(
children: [Expanded(child: WebViewWidget(controller: controller))],
);
}
}

执行代码,你将看到如下内容



WebView 内容的可以通过网址获取,但这样不方便演示各种效果,所以直接用 htmlString 替代了,效果是一样的。


默认情况下 javascript 是被禁用的。必须手动开启 setJavaScriptMode(JavaScriptMode.unrestricted),否则对于绝大多数的网页都没法用了。


WebView 的小大


WebViewWidget 会尝试让自己获得最大高度和最大宽度,所以 WebView 必须放在有限宽度和有限高度的 Widget 中。一般会用 SizedBox 这样的容器把 WebView 包起来。但是 WebView 内容的高度是未知的,要如何设置 SizedBox 的 height 呢?


一种方案是 height 采用固定高度,如果 WebView 内容过多,可以用上下滑动的方式来查看所有内容。如果 WebView 的内容高度是变化的,用固定高度可能会产生大块空白,这个时候应该把 height 设置成 WebView 内容的高度。


那么问题来了,如何获得 WebView 内容的高度?最理想的情况是网页是自己能控制的,让网页自己报告高度。


网页自己报告高度


在 htmlString 中 增加 js


<body>
<div class="content">大家好,我是 17</div>
<script>
const resizeObserver = new ResizeObserver(entries =>
Report.postMessage(document.scrollingElement.scrollHeight))
resizeObserver.observe(document.body)
</script>
</body>


如果WebView 不支持 ResizeObserver 可以直接在合适的时机调用 Report.postMessage(document.scrollingElement.scrollHeight))



dart 代码中



  1. 增加一个变量 height ,初始值为 0。

  2. 增加 ScriptChannel,注意名字和前面 script 中的名字必须一样,本例中名字叫 Report

  3. 用 SizedBox 替换 Expanded,限定 WebViewWidget 的高度。


class _MyWebViewState extends State<MyWebView> {
late final WebViewController controller;
double height = 0;
@override
void initState() {
controller = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..addJavaScriptChannel('Report', onMessageReceived: (message) {
setState(() {
height = double.parse(message.message);
});
})
..loadHtmlString(htmlString);

super.initState();
}

@override
Widget build(BuildContext context) {
return Column(
children: [
SizedBox(height: height, child: WebViewWidget(controller: controller)),

],
);
}
}

修改 html 代码中的 body 的样式 height:100pxheight:200px;,重新运行代码(restart,hot reload 不生效 ),发现 SizedBox 也变为 200px 高了。


无法修改页面


如果页面我们无权修改也没有办法协调修改,那就只能通过注入 js 方式获取了。


如果页面的高度只由静态 css 决定,可以简单的加一个小延时,直接获取高度即可。


controller.setNavigationDelegate(NavigationDelegate(
onPageFinished: (url) async {
await Future.delayed(Duration(milliseconds: 50));
var message = await controller.runJavaScriptReturningResult(
'document.scrollingElement.scrollHeight');
setState(() {
height =double.parse(message.toString());
});
},
));

如果页面加载完成后 js 又对页面进行了修改,这个时间就很难预估了。js 可以随时修改页面,导致高度改变,所以要想时时跟踪页面高度,只能靠监听。如果 webview 不支持 ResizeObserver,还可以用 setInterval。


 void initState() {
controller = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..addJavaScriptChannel('Report', onMessageReceived: (message) {
var msgHeight = double.parse(message.message);
setState(() {
height = msgHeight;
});
})
..setNavigationDelegate(NavigationDelegate(
onPageFinished: (url) async {
// 注入 js
controller.runJavaScript(
'''const resizeObserver = new ResizeObserver(entries =>
Report.postMessage(document.scrollingElement.scrollHeight))
resizeObserver.observe(document.body)''');
},
))
..loadHtmlString(htmlString);

super.initState();
}

必须等到页面加载完成后再注入 js,否则页面文档还不存在,往哪里注入啊。


因为代码都在 dart 这边,免去了和页面开发沟通的成本。既使 WebView 加载的页面中可能还有链接,跳到另一个地址,js 注入的代码依然有效!


页面的高度可能会在很短时间内连续变化,我们可以只对最后一次的高度变化做更新,用 Timer 可以做到。页面高度要限制一个最大值,否则超出最大允许的高度就报错了。


可能你会觉得既然注入的方式这么多优点,不需要页面报告那种方式了,都用这种注入的方式就可以了。实际上每种方式都有它的利弊,不然我就不会介绍了。页面报告的方式在于灵活,想什么时候报告就什么时候报告,页面高度变化了,也可以不报告。在页面没有内容的时候可以先报告一个预估的高度,会让页面避免从 0 开始突然变高。尽量把主动权交给页面,因为页面是可以随时修改的,app 不能!


在网页中调用 Flutter 页面


拦截 url


url 以 /android 结尾时,跳到对应的原生页面。否则继续原来的请求。


onNavigationRequest: (request) {
if (request.url.endsWith('/android')) {
// 跳到原生页面
return NavigationDecision.prevent;
} else {
// 继续原来的请求
return NavigationDecision.navigate;
}
},

触发方式有两种



  1. 用 A 标签 <a href='/ios'>跳到 Flutter 页面</a>

  2. 用 js 跳转 window.location.href='完整页面地址'


用 js 跳转的地址一定是完整的页面地址。比如这样写都是可以的



  1. https://juejin.cn

  2. aa:/bb


schema 可以自定义,但不能没有。这样写是无效的 /android


js 调用 JavaScriptChannel 定义的方法


先定义跳转的通道对象为 Jump


  void initState() {
controller = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..addJavaScriptChannel('Jump', onMessageReceived: (message) {
//根据 message 信息跳转
})
..loadHtmlString(htmlString);

super.initState();
}

在页面中执行 Jump.postMessage('video');


实际上,flutter 拿到页面传过来的信息后,除了可以跳转到 flutter 页面,还可以执行其它功能,比如调取相机。


总结


通过两个示例演示了页面与 flutter 通信的 3 种方式



  1. flutter 拦截 url

  2. flutter 设置 JavaScriptChannel

  3. flutter 向页面注入 js


向页面注入 js 需要等页面加载完成后再注入。注入 js 的能力非常强大的。几乎可以对页面做任意修改。比如



  • 删除页面中不想要的部分

  • 修改页面的样式

  • 增加页面的功能,比如给页面增加一个按钮,点按钮跳到原生页面,就好像原来的页面就有这个功能一样。


删除页面中不想要的部分,这是有实际意义的。页面都会有页头,这可能和 app 的头部冲突。有了注入 js 这个利器,可以在不修改页面的情况下,直接在 app 中不显示页头。


修改页面样式,这个你懂的,既然能注入 js ,也就是能注入 css 了。相比于直接用 js 修改页面样式,注入 css 的方式更加容易维护。


当然了,凡事有利有弊,不要滥用这个功能。在 app 单方面修改页面,将来页面修改的时候可能会翻车,即使做好沟通,也会给页面开发造成限制或麻烦,所以如何做一定要权衡各方面的得失。


app 不像页面那样可以随时修改,所以要优先考虑让页面实现功能,尽量把控制权交给页面(说两遍了,因为很重要)。js 注入这种操作不是万不得已不要做,把它做为最后的选项。


最后说一点,示例中为了方便演示用 loadHtmlString,实际应用中一般是用 loadRequest 加载网址。


loadHtmlString(htmlString) loadRequest(Uri.parse('https://juejin.cn'))


本文到这里就结束了。谢谢观看!


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

简历中的项目经历可以怎么写?

概述工作这10多年来,也经常做招聘的工作,面试过的人超过50人次了,而看过的候选人的简历则有几百份了,但是清晰且能突出重点的简历,确实很少遇到。这里基本可以说明一个问题,很多候选人是不太清楚如何写出一份好的简历的。下面基于简历中的项目经历,重点铺开说一下。在社...
继续阅读 »

概述

工作这10多年来,也经常做招聘的工作,面试过的人超过50人次了,而看过的候选人的简历则有几百份了,但是清晰且能突出重点的简历,确实很少遇到。这里基本可以说明一个问题,很多候选人是不太清楚如何写出一份好的简历的。

下面基于简历中的项目经历,重点铺开说一下。在社招中,项目经历面试官重点考察的地方。

写项目经历需要注意的地方

项目经历是介绍你实战经历的地方,同时也能反映你对已掌握的技能的使用情况。对于应聘偏技术类的岗位来说,这块非常的重要。

下面会以支付中心作为例子进行阐述。

  • 项目背景,也即是你一定要非常清楚启动这个项目的缘由是啥。如果这个都说不清楚的话,那说明,你真的就是埋头干活,偏执行的角色。对项目并没有一个整体的认识。就算你只是这个项目的普通参与者,也需要主动的去了解和理解该项目立项的原因。有个注意的地方是,项目背景的文字描述不要太长,一两句就可以了。比如说:当前支付中心耦合在订单系统中,为了提升支付模块的稳定性、维护性、性能和扩展性,需要将支付模块独立出来,统一为其他内部系统提供支付能力;

  • 项目功能介绍,介绍一下这个项目能做什么,有什么核心模块,需要应付什么量级的流量。以支付中心为例子:为内部的订单系统提供支付能力,对内提供了微信、支付宝、抖音、海外、信用卡、钱包、礼品卡以及组合支付的支付、回调、退款、查询、业务对账等能力。平时需要应付每秒1万的支付请求。

  • 技术架构设计,这里考察的是技术选型的严谨性和模块设计的合理性。如果项目用到了RabbitMQ、Redis、Kafka等一些技术,你自己心里一定有个底,就是当时为什么选用这些技术,是经过深思熟虑的吗?是经过了很多轮的技术栈对比后决定使用的吗。也即是技术选型是一个严谨的论证的一个过程。而设计这块,则要说清楚模块划分的缘由以及解决方案。还是以支付中心为例子:通过支付网关,对外提供统一的接口,而内部则通过支付路由模块,进行具体的支付方式路由,并把单独的支付方式,以物理单元进行隔离,避免各种支付方式在出故障时,相互影响。为了应付高频的支付动作,采用数据库分库的方式缓解写的压力。

  • 我负责的模块,如果你参与的项目是部门核心项目,但是自己参与的模块确是边缘模块或者只是参与了很小的一部分,虽然你也能在这个项目里,得到成长。但是那是称不上个人亮点的。因为面试官会更倾向于:你为这个项目做了什么贡献,因为你,项目有了什么好的改变和突破性进展。因此,做项目的时候,不妨跟自己的领导多反馈一下,希望能独立主导一些重要的模块。如果领导觉得当前的你还无法独立hold住重要的模块,你也不要气馁,平时多多提升自己,争取后续能主导一些重要模块。这个真的很重要,为了将来的自己,你必须得这么做。在做项目的时候,如果你长期一直起着螺丝钉的作用的话,对你极其不利,甚至可以说,你是在浪费时间。

  • 难点和踩过的坑,难点也即是亮点。在你负责的模块里,具体的难点是什么,你是通过什么方案解决的。而解决的过程中,又遇到什么大坑?怎么优化的。这个其实是一种引导,把面试官引入到你自己比较熟悉又印象深刻的领域,如果你准备充分的话,是能给面试官一个好的印象的,是能加分的。同时能解决掉难点,对自身成长也是有利的,且还能说明的你韧性不错,有追求。

  • 取得的成效,不能只是重视过程,而不重视结果,这是不可取的。你需要用结果和数据体现你的价值。比如说,支付中心上线后,你负责的业务模块,慢调用和慢SQL消失了,接口响应速度提升了10倍,上线半年,无任何大故障。等等。

项目经历写几个合适?

如果按照上面的的方式来书写项目的话,那每个项目的文字描述是不短的,一个项目的描述就大概要占用半页了。因此,简历里的项目不能太多,2到3个就可以了。项目主要在精不在多,把自己负责比较多的且能作为自己的一个亮点的核心项目,说清楚道明白,更为重要。

现在的你应该做什么?

赶紧好好总结一些当前和之前做过的项目,按照上面列的方式,好好梳理和思考一下,提炼一些重要的内容出来。争取能作为自己履历的亮点。如果你发现到目前为止,还没有能为自己带来竞争力的项目,那赶紧好好反思一下,赶紧争取去做。

小结

如果你不是什么名人或者知名大佬,学历和履历也一般般,那么你只能通过曾经做过好的项目来增强自己的竞争力了。HR也会通过你的项目经历来了解你的能力。项目经历一定要真实,要突出亮点和难点,并说清楚自己在项目起到什么作用。

作者:SamDeepThinking
来源:juejin.cn/post/7200953096893136955

收起阅读 »

真的有必要用微前端框架么?

web
前言 最近公司项目在用qiankun构建微前端的应用,深深体会到微前端的魅力,无框架限制,主应用统一管理,弹窗的统一位置等。如果是刚开始就植入微前端还好,不过基本上都是后期老项目植入微前端,各种拆分模块,也是一件很头疼的事情。 基石 我们为什么要用微前端 大的...
继续阅读 »

前言


最近公司项目在用qiankun构建微前端的应用,深深体会到微前端的魅力,无框架限制,主应用统一管理,弹窗的统一位置等。如果是刚开始就植入微前端还好,不过基本上都是后期老项目植入微前端,各种拆分模块,也是一件很头疼的事情。


基石


我们为什么要用微前端


大的应用体量维护成本是很高的,拆分成单独的模块,由主应用处理登录等通用逻辑,子应用来只负责模块的业务实现,这样不管资源加载、按需加载、人员维护成本降低、增量升级、独立部署都有很好的体检提升。当然前提是体量非常大的web应用可以这么做,但是开始做的时候你会很头疼各种拆解带来的不确定性,但是长痛不如短痛。


Why Not Iframe


下面是我从qiankun文档摘抄的:


iframe 最大的特性就是提供了浏览器原生的硬隔离方案,不论是样式隔离、js 隔离这类问题统统都能被完美解决。那么为什么不用iframe呢 ?



  1. url 不同步。浏览器刷新 iframe url 状态丢失等(本地缓存不就行了?)

  2. UI 不同步,DOM 结构不共享(主应用控制不就行了?子应用通过postMessage传递数据给父应用)

  3. 一次性加载,慢!(个人感觉就是项目体积小了!跟iframe有啥区别么?)

  4. 全局上下文完全隔离,内存变量不共享。(当然这里通过postMessage是可以实现通信的!)


image.png


所以其实用iframe就够了,微前端是不是有点kpi的味道呢?当然学习下源码还是对自己有提升的,万一iframe没有,是不是可以手撸一个呢?


源码入口


image.png


最核心的就是手动加载loadMicroApp、registerMicroApps注册微应用,start开始构建这3个api,但其实qiankun的核心是基于single-spa框架封装的, 我们看下single-spa做了些什么,以及single-spa内部核心api的registerApplication做了什么


single-spa


single-spa是一个框架,用于将多个JavaScript微前端组合在一个前端应用程序中。使用单一页面中心构建前端可以带来许多好处,例如:



registerApplication 注册应用


export function reroute (pendingPromises = [], eventArguments) {
//...
const {
appsToUnload,
appsToUnmount,
appsToLoad,
appsToMount,
} = getAppChanges(); //返回不同生命周期的队列

//记录基础的应用信息
let appsThatChanged,
navigationIsCanceled = false,
oldUrl = currentUrl,
newUrl = (currentUrl = window.location.href);

//这里根据是否已挂载做处理
if (isStarted()) {
//....
} else {
appsThatChanged = appsToLoad;
return loadApps();
}
//加载apps
function loadApps () {
return Promise.resolve().then(() => {
const loadPromises = appsToLoad.map(toLoadPromise);

return (
Promise.all(loadPromises)
.then(callAllEventListeners)
// there are no mounted apps, before start() is called, so we always return []
.then(() => [])
.catch((err) => {
callAllEventListeners();
throw err;
})
);
});
}
//根据app状态改变发布对应的事件
function performAppChanges () {
return Promise.resolve().then(() => {
// https://github.com/single-spa/single-spa/issues/545
window.dispatchEvent(
new CustomEvent(
appsThatChanged.length === 0
? "single-spa:before-no-app-change"
: "single-spa:before-app-change",
getCustomEventDetail(true)
)
);
//...做了大量的自定义事件以及卸载事件
}
//....
}
复制代码

说实话这里的源码很绕,这里只摘取最关键的,在registerApplication内部,将qiankun的registerMicroApps的参数传入做些兼容判断,然后调用了一个核心的reroute方法, 这里删除了不必要的干扰信息,说白了single-spa做了spa的生命周期的管理,每个应用有单独的html做页面的加载,但是环境的隔绝是需要qiankun做的


getAppChanges 状态管理


export function getAppChanges () {
const appsToUnload = [],
appsToUnmount = [],
appsToLoad = [],
appsToMount = [];

// We re-attempt to download applications in LOAD_ERROR after a timeout of 200 milliseconds
const currentTime = new Date().getTime();

apps.forEach((app) => {
const appShouldBeActive =
app.status !== SKIP_BECAUSE_BROKEN && shouldBeActive(app);

switch (app.status) {
case LOAD_ERROR:
if (appShouldBeActive && currentTime - app.loadErrorTime >= 200) {
appsToLoad.push(app);
}
break;
case NOT_LOADED:
case LOADING_SOURCE_CODE:
if (appShouldBeActive) {
appsToLoad.push(app);
}
break;
case NOT_BOOTSTRAPPED:
case NOT_MOUNTED:
if (!appShouldBeActive && getAppUnloadInfo(toName(app))) {
appsToUnload.push(app);
} else if (appShouldBeActive) {
appsToMount.push(app);
}
break;
case MOUNTED:
if (!appShouldBeActive) {
appsToUnmount.push(app);
}
break;
// all other statuses are ignored
}
});

return { appsToUnload, appsToUnmount, appsToLoad, appsToMount };
}
复制代码

getAppChange返回了应用一旦改变那么不同生命周期的队列会更新


registerMicroApps注册微应用


简单的看下用法,qiankun的核心api并不多


image.png


image.png


registerMicroApps注册微应用中,传入apps的信息,如name、路由匹配规则、container挂载dom等,和生命周期的钩子lifeCycles。核心的应用状态管理在single-spa中,而qiankun在外层又做了统一的上层的主应用封装。这里比较重要的是loadApp,里面统一处理解决了全局变量混入的问题也就是沙箱隔离,样式隔离等。下面是loadApp的一部分核心逻辑


环境隔离


1. 全局变量的隔离


createSandboxContainer 创建沙箱


image.png


这里核心就是createSandboxContainer的用法,这里简单讲下几个核心的参数,initialAppWarpperGetter用于处理检查是否有无包裹的dom元素,scpredCSS是代表样式是否已经被隔离状态, useLooseSandbox和speedySandbox处理不同状态的沙箱。
,在里面核心的方法是patchAtBootstrapping做了不同的沙箱隔离方式的隔绝处理


patchAtBootstrapping 启动器


image.png


patchAtBootstrapping中的策略者模式,我们可以看到有3种沙箱的处理方式,legacyProxy、Proxy、Snapshot,并分别对应了pathLooseSandbox、pathStrictSandbox、patchLooseSandbox,下面简单解析下原理,理解即可。



Snapshot沙箱隔离



先将主应用的window拷贝一份,一旦微应用切换到主应用做回退,如果微应用切换,那么会提前生成微应用的diff过程的对象,然后回退。而缺陷就是diff属性量一旦过大会性能不好



Legacy沙箱隔离



那么与Snapshot最大的不同是用了Proxy来处理,set做记录,一旦应用切换就回退,相对于我不断循环遍历diff,性能好了不少



Proxy沙箱隔离



前面两种应用场景在于都是一个路由对应一个微应用,那么如果是多个微应用同时出现在一个页面中,那么环境是不是不可控了呢。这种情况就不能在window直接操作,而是要每个应用都要有一个独立的fakeWindow,这样区分环境后,数据处理尽量在fakeWindow上处理,而不是原生window


Proxy模式的核心的我们看下pathStrictSandbox源码


pathStrictSandbox 严格模式


image.png


Proxy代理模式的沙箱,通过Object.defineProperty来拦截对象属性,但是不可枚举可写入, 这样每次切换应用我都重新获取新的nativeGlobal


nativeGlobal 全局对象


export const nativeGlobal = new Function('return this')();
复制代码

通过new Function来更安全的返回全局对象


2. DOM的隔离


image.png


image.png


image.png


很明显这里是通过ShadowDOM来实现dom的隔离,我们常见的比如video、audio标签内部都是可以看到shadowDOM实现的,同时我们也可以看到做了兼容性的处理


3. 样式隔离


image.png
scopedCSS代表是否要隔离css,如果要隔离首先去判断将微应用的根元素挂载qiankun的属性标记,然后遍历所有style标签,css.process对每个内部的样式属性名做了模块化的处理,而appInstanceId就是做微应用样式隔离的id区分


通信


import-html-entry


qiankun用的是import-html-entry这个库的execSceipts方法来请求获得并解析脚本的,然后直接把html插入到容器里,所以应用间需要允许跨域才行,在importEntry你可以发现他使用了浏览器空闲的api,requestIdleCallback以及为基础实现预加载prefetch


image.png


image.png


image.png


initGlobalState 全局状态


image.png


image.png


image.png


我们主要看下initGlobalState,通过emitGlobal来触发更新全局状态,从上图可以看出核心通过deps发布订阅模式来管理每个微应用,然后更新状态。返回的onGlobalChange和setGlobalState来监听变化和触发通知。状态管理还是比较简单的。


总结


花了几天时间看了源码,收获还是挺大的,微前端其实主要有3个的核心点在于应用通信、应用的生命周期及状态管理、沙箱环境隔离。相对来说iframe足够满足我们业务需求了,微前端提供了一种思路还是不错的,但是真的有必要用qiankun么?


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

JavaScript 预编译

web
预编译发生在什么时候预编译发生在函数执行的前一刻一. 预编译的抽象理解函数声明整体提升变量,声明提升举个例子<script type="text/javascript">    test();    func...
继续阅读 »

预编译发生在什么时候

预编译发生在函数执行的前一刻


一. 预编译的抽象理解

  • 函数声明整体提升

  • 变量,声明提升

举个例子

<script type="text/javascript">
   test();
   function test() {
  console.log('我是test');
  }
   a = 10;
   var a;
   console.log(a);
</script>

这里控制台能正常输出就是因为在预编译时将函数的声明整体和变量的声明提升到了代码的最顶部,所以代码中先调用函数或者是先给变量赋值再声明都没有问题,当然这是抽象的概念,记住这个已经能解决8-90%的变成问题了,如果你想了解具体的流程,可以接着往下看。


二. 局部函数预编译过程

  1. 创建AO(Activation Object)对象

  2. 找形参和变量声明,将变量和形参名作为AO对象的属性名,默认值为undefind

  3. 将实参值和形参相关联

  4. 找函数声明,值为其函数体


例子一

<script type="text/javascript">
function test(a) {
var b = 10;
function c() {
}
}
test(1);
</script>

根据过程来我们可以得出右边的AO对象中的数值,a最终保存的是1,因为第三步需要把形参和实参相关联


例子二

<script type="text/javascript">
function test(a) {            
var a = 10;          
function a() {              
}
}
  test(1);
</script>

对象中同名的属性会只保留一个,所以经过第二步寻找变量声明和形参的时候,AO对象中会有一个a,且默认值为undefind,经过第三步实参值和形参相关联后,a的值变为1,经过第四步找函数声明的时候发现有个名字为a的函数,AO中本身就有a的属性名了,所以这个时候会将a的函数体赋值给AO中的a。


如果上面的例子能够搞懂的话,咱们就可以接着玩一个题

例子三

<script type="text/javascript">
function test(a) {
console.log(a);
var a = 10;
console.log(a);
function a() {
consoel.log('这是函数a');
}
}
test(1);
</script>

如果你心中的结果跟答案一致,说明已经清楚了局部函数预编译的四个步骤了



到test函数执行之前AO对象中a的值为a的函数体这个应该没有什么问题吧?所以第一个log打印出来的是a的函数体,第二个log之前由于有var a = 10;,这个经过变量的声明提升后可以看做是a = 10;,走到这里这里AO中的a被赋值为10,所以第二个log打印的就是10。


二. 全局函数预编译过程

  1. 创建GO(Global Object)对象

  2. 找变量声明,将变量名作为GO对象的属性名,默认值为undefind

  3. 找函数声明,值为其函数体

全局函数的过程跟局部函数差不多,由于全局函数没有形参和实参的传递,所以省略了一个步骤。

例子四

<script type="text/javascript">
console.log(a);
var a = 1111;
console.log(a);
function a() {
console.log('这里是a函数');
}
</script>

这里例子四的输出和转化结果跟例子三差不多,只不过是AO对象变成了GO对象。

例子五

<script type="text/javascript">
console.log(a);
var a = 10;
function a(a) {
console.log(a);
}
a(1);
</script>

这里可以看看第二个log打印的是什么,还是会报错?


第一个log打印a函数的函数体没有问题吧?第二个log为什么会报错呢?因为GO对象中保存的a属性在第一次log的时候保存的是a的函数体,但是下面有个a=10;*的赋值,这个时候GO中的a就被修改成10了,后面调用*a(1)*函数的时候,找到GO中的a,这个时候a是number数字而不是函数,所以会报错说*a is not a function

例子六

<script type="text/javascript">
var a = 1;
   var b = 2;
function test(a) {
       var a;
console.log(a);  // 输出10
a = 100;
console.log(a);  // 输出100
       console.log(b); // 输出2
}
test(10);
console.log(a);  // 输出1
</script>

看看例子六的输出结果是不是符合自己的心里预期。我们都知道函数内部有变量会优先使用自身内部的变量,其实也可以转化成AO和GO来理解。 第一个log输出的时候找到自身的AO中有属性a,且这个值是实参传递过来的,所以是10。 第二个log由于前面有a=100所以a被赋值成了100。 第三个log会找自己的AO,发现自己的AO里没有这个b属性,就会去找到父函数的AO,由于这里父函数是全局函数了,所以就去找GO里有没有b属性,有的话就输出了GO里的b的值,所以是2。(如果这里GO中也没有b属性的话,就会报b is not defined的错误了) 第四个log输出的就是自身AO也就是GO中的a属性的值了。


看完这几个例子相信你应该对预编译有个比较清晰的认识了,这里的面试题很多,但是万变不离其中,我们只需要把AO和GO分析出来,那么就可以清晰的了解到函数运行过程中每一步的每个属性值分别是什么了。

作者:Charlin丶
来源:juejin.cn/post/7200681438315642941

收起阅读 »

北京租房的 请避坑

序心平气和的解决了问题,但是想吐槽划重点 说的地方是 北京昌平沙河 永利家园, 一切都是我个人感觉,请客观看待基本条件我们是一室一厅,屋里一个床,客厅一个床事件刚来的时候 当时我说 客厅这个床不太好啊 。房东说 正常就一个屋里床,客厅这个床 不算,可以要也可以...
继续阅读 »

心平气和的解决了问题,但是想吐槽

划重点 说的地方是 北京昌平沙河 永利家园, 一切都是我个人感觉,请客观看待

基本条件

我们是一室一厅,屋里一个床,客厅一个床

事件

刚来的时候 当时我说 客厅这个床不太好啊 。房东说 正常就一个屋里床,客厅这个床 不算,可以要也可以不要。当时听了这话 感觉无所谓,放这呗。

后来 没几天 就发现客厅床坏的,之前几天搬家 没关注 ,这坐坐就不行了。。。心里已经骂了,不过一想 这是送的,算了 无所谓

后台 在10月初 我们找房东 说这个客厅床我们不要了,当时说没事,能处理


我说谢谢,然后后台有事 没继续跟进,今天 我上班,我媳妇和房东联系,本来不错,然后

修床的人 说 找房东处理


房东说 找修床的人处理


然后可能合计我媳妇 好欺负,说是要赔偿,自己当时怎么拼的这个客厅床,不清楚? 不明白? 还用我明说?

我媳妇难受了,说不过


结尾

我这脾气欺负我媳妇,我管不了了,直接电话干过去。中间的步骤省略

结果就是 不用赔偿,丢了就行。

另外

这家还有一个问题,其他费用比较多,比较坑。

只有你最后付钱的时候 才会告诉你 每个月有一个40的什么费用来的,名头我忘了

最后

实在忍不住了,希望多一个人看到 都是好的,希望大家租房 别踩坑,自己知道就好,别说在哪知道的哈,大家都不容易

作者:雨夜之寂
来源:juejin.cn/post/7159475467987189767

收起阅读 »

在安卓项目中使用 FFmpeg 实现 GIF 拼接(可扩展为实现视频会议多人同屏效果)

前言在我的项目 隐云图解制作 中,有一个功能是按照一定规则将多张 gif 拼接成一张 gif。当然,这里说的拼接是类似于拼图一样的拼接,而不是简单粗暴的把多个 gif 合成一个 gif 并按顺序播放。大致效果如下:注意:上面的动图只展示了预览效果,没有展示实际...
继续阅读 »

前言

在我的项目 隐云图解制作 中,有一个功能是按照一定规则将多张 gif 拼接成一张 gif。

当然,这里说的拼接是类似于拼图一样的拼接,而不是简单粗暴的把多个 gif 合成一个 gif 并按顺序播放。

大致效果如下:


注意:上面的动图只展示了预览效果,没有展示实际合成效果,但是合成效果和预览效果是一摸一样的,有兴趣的话,我可以再开一篇文章讲解怎么实现这个预览效果

实现方法

FFmpeg 简介

在开始之前先简单介绍一下什么是 FFmpeg,不过我相信只要是稍微接触过一点音视频的开发者都知道 FFmpeg。

FFmpeg 是一个开放源代码的自由软件,可以执行音频和视频多种格式的录影、转换、串流功能,包含了 libavcodec ——这是一个用于多个项目中音频和视频的解码器库,以及 libavformat ——一个音频与视频格式转换库。

简单来说,只要是和音视频相关的操作,几乎都可以使用 FFmpeg 来实现。

当然,FFmpeg 是一个纯命令行工具,所以我在这里简单介绍几个本文需要用到的参数:

  1. -y 若指定的输出文件已存在则强制覆盖

  2. -i 设置输入文件,可以设置多个

  3. -filter_complex 设置复杂滤镜,我们这次要实现的拼接 gif 就是依靠这个参数完成

在安卓中使用 FFmpeg

我现在使用的库是 ffmpeg-kit 使用这个库可以直接集成 FFmpeg 到项目中,并且能够方便的执行 FFmpeg 命令。

该库执行 FFmpeg 很简单,只需要:

val session = FFmpegKit.executeWithArguments("your cmd text")
if (ReturnCode.isSuccess(session.returnCode)) {
   Log.i(TAG, "Command execution completed successfully.")
} else if (ReturnCode.isCancel(session.returnCode)) {
   Log.i(TAG, "Command execution cancelled by user.")
} else {
   Log.e(TAG, String.format("Command execution fail with state %s and rc %s.%s", session.state, session.returnCode, session.failStackTrace))
}

因为我需要自己管理线程,所以使用的是同步执行

另外,我几乎试过当前 GitHub 上最近还在维护所有的 FFmpeg for Android 库,甚至还自己写过一个,但是都或多或少的有点问题,最终只有这个库能够适配我的需求。

在此弱弱的吐槽一下某些“开源”库,只提供二进制包,不提供编译脚本,也不提供源代码,提供的二进制包缺少了某些依赖,我想自己动手编译都没法编译,一看 README ,好嘛,定制编译请联系作者付费获取,合着这开源开了个寂寞啊。

拼接命令

我们先来看一段完整的拼接命令,我会详细讲解各个参数的作用,最后再讲解如何动态生成需要的命令。

完整命令:

# 覆盖输出文件
-y

# 输入文件
-i jointBg.png
-i 1.gif
-i 2.gif
-i 3.gif
-i 4.gif

# 开始进行滤镜转换
-filter_complex
[0:v]pad=1280:2161[bg];
[1:v]scale=640:1137[gif0];
[2:v]scale=640:368[gif1];
[3:v]scale=640:1024[gif2];
[4:v]scale=640:368[gif3];

[bg][gif0] overlay=0:0[over0];
[over0][gif1] overlay=640:0[over1];
[over1][gif2] overlay=0:1137[over2];
[over2][gif3] overlay=640:368

# 输出路径
out.gif

为了方便查看,我使用换行分割了命令,使用时可不能加换行哦

在这段代码中,我们使用 -y 参数指定如果输出文件已存在则覆盖。

接下来使用 -i 参数输入了 5 个文件,其中 jointBg.png 是我生成的一个 1x1 像素的图片,用于后面扩展成背景画布,其他的 gif 文件就是要拼接的源文件。

然后使用 -filter_complex 表示要做一个复杂滤镜,后面跟着的都是这个复杂滤镜的参数:

[0:v]pad=1280:2161[bg]; 表示将输入的第一个文件作为视频打开,并将其当成画板,同时缩放分辨率为 1280x2161 (后面会讲这些分辨率是怎么来的),最后取名为 bg

[1:v]scale=640:1137[gif0]; 表示将输入的第二个文件作为视频打开,并缩放分辨率至 640x1137 , 最后取别名为 gif0

下面的三行语句作用相同。

然后就是开始拼接:

[bg][gif0] overlay=0:0[over0]; 表示将 gif0 覆盖到 bg 上,并且覆盖的起点坐标为 0x0 ,最后将该其取名为 over0

下面的三行代码作用相同。

简单理解一下这个过程:

  1. 创建一个图片,并缩放尺寸至事先计算出来的最终拼接成品的尺寸作为背景

  2. 依次将输入的文件缩放至事先计算好的尺寸

  3. 依次将缩放后的输入文件覆盖(叠加)到背景上

动画演示:


仅作演示便于理解,实际拼接时一般都是放大 bg , 缩小 gif,并且 gif 将完全覆盖住 bg

计算尺寸

上一节中的命令涉及到很多缩放过程,那么这个缩放的尺寸是如何得到的呢?

这一节我们将讲解如何计算尺寸。

首先,我们需要知道的是,当前这个功能,一共有三种拼接模式:

  1. 横向拼接

  2. 纵向拼接

  3. 宫格拼接


本文主要讲解的是宫格拼接,宫格拼接的样式即文章开头的预览效果那种。

既然是宫格拼接,那么绕不开的就是如果拼接的动图尺寸不一致,怎么确保拼接出来的动图美观?

这里我们有两种策略,由用户自行选择:

  1. 完全以最小尺寸的图片为基准,将所有图片强制缩放到最小尺寸,这样可能会造成部分动图被拉伸失真。

  2. 以所有图片中的最小宽度为基准,等比例缩放其他图片,这样可以确保所有图片都不会失真,但是拼接出来的成品将不是一个完美的矩形,而是一个留有黑色背景的异形图片。

确定了我们使用的两种缩放策略,下面就是开始计算成品的总尺寸和每张输入图片的需要缩放尺寸。

不过在此之前,我们需要遍历所有输入图片,拿到所有图片的原始尺寸和所有图片中的最小尺寸:

val jointGifResolution: MutableList<MutableList<Int>> = ArrayList() // 所有动图的原始尺寸 list
var minValue = Int.MAX_VALUE  // 最小宽度(别问我为什么不命名成 minWidth ,问就是兼容性)
var minValue2 = Int.MAX_VALUE  // 最小高度

for (uri in gifUris) {
   val gifDrawable = GifDrawable(context.contentResolver, uri)
   val height = gifDrawable.intrinsicHeight  // 当前 gif 的原始高度
   val width = gifDrawable.intrinsicWidth  // 当前 gif 的原始宽度
   jointGifResolution.add(mutableListOf(width, height))  // 将尺寸加入 list
   
   // 计算最小宽高
   if (minValue > width) {
       minValue = width
  }
   if (minValue2 > height) {
       minValue2 = height
  }
}

其中,gifUris 即事先获取到的所有输入动图的 uri 列表。

这里我们使用到了 GifDrawable 获取动图的尺寸,因为这不是本文的重点,所以不多加解释,读者只需知道这样可以拿到 gif 的原始尺寸即可。

拿到所有动图的原始宽高和最小宽高后,下一步是计算需要的缩放值:

var totalHeight = 0
var totalWidth = 0

var squareIndex = 0
val squareTotalHeight: MutableList<Int> = arrayListOf()

jointGifResolution.forEachIndexed { index, resolution ->
   val jointWidth = minValue // 无论使用缩放策略 1 还是 2,缩放宽度都是最小宽度
   val jointHeight = when (scaleMode) {
       // 如果使用缩放策略 2 则需要按比例计算出缩放高度
       GifTools.JointScaleModeWithRatio -> resolution[1] * minValue / resolution[0]
       // 如果使用缩放策略 1 则直接强制缩放到最小高度
       else -> minValue2
  }
   // 因为宫格拼接只能使用 2 的 n 次幂张图片,所以每行图片数量可以根据图片总数算出,不过太麻烦,所以这里我打了个表,直接从表里面拿
   // val JointGifSquareLineLength = hashMapOf(4 to 2, 9 to 3, 16 to 4, 25 to 5, 36 to 6, 49 to 7, 64 to 8, 81 to 9, 100 to 10)
   var lineLength = GifTools.JointGifSquareLineLength[jointGifResolution.size]
   if (lineLength == null) {
       lineLength = sqrt(jointGifResolution.size.toDouble()).toInt()
  }
   
   if (scaleMode == GifTools.JointScaleModeWithRatio) { // 使用等比缩放策略
       
       if (index < lineLength) {  // 所有图片宽度都是一样的,所以直接加一行的宽度得到的就是最大宽度
           totalWidth += jointWidth
      }
       try {
           // 这里是获取每一列的当前行高,并将其加起来,最终遍历完会得到当前列的高度
           val tempIndex = squareIndex % lineLength
           Log.e(TAG, "getJointGifResolution: temp index = $tempIndex")
           if (squareTotalHeight.size <= tempIndex) {
               squareTotalHeight.add(tempIndex, 0)
          }
           squareTotalHeight[tempIndex] = squareTotalHeight[tempIndex] + jointHeight
      } catch (e: java.lang.Exception) {
           Log.e(TAG, "getJointGifResolution: ", e)
      }
       
       // 将缩放尺寸更新至尺寸列表
       jointGifResolution[index] = mutableListOf(jointWidth, jointHeight)
  } else {
       // 如果不是按比例缩放,则直接将最小宽高存入总宽高
       if (index < lineLength) {
           totalHeight += min(jointHeight, jointWidth)
           totalWidth += min(jointHeight, jointWidth)
      }
       
       // 将缩放尺寸更新至尺寸列表
       jointGifResolution[index] = mutableListOf(min(jointHeight, jointWidth), min(jointHeight, jointWidth))
  }
   squareIndex++
}

上面的代码我已经加了详细的注释,至此所有图片的缩放尺寸已计算出来。

即,总尺寸为:

if (scaleMode != GifTools.JointScaleModeWithRatio) {
   jointGifResolution.add(mutableListOf(totalWidth, totalHeight))
}
else {
   Log.e(TAG, "getJointGifResolution: $squareTotalHeight")
   jointGifResolution.add(mutableListOf(totalWidth, Collections.max(squareTotalHeight)))
}

最小宽高为:

jointGifResolution.add(mutableListOf(minValue, minValue2))

对了,你可能会奇怪,为什么我要把总尺寸和最小宽高存入缩放尺寸 list,哈哈,这是因为我懒,所以我对这个 list 的定义是:

/**
*
* 遍历获取所有 gifUris 中的动图分辨率
*
* 并将经过处理后的所有长、宽之和存入 [size-2] ;
*
* 将最小的长宽存入 [size-1]
* */

动态生成命令

完成了尺寸的计算,下一步是按照输入文件和计算出来的尺寸动态的生成 FFmpeg 命令。

不过在这之前,我们需要先创建一个 1x1 的图片,用来扩展成背景:

private suspend fun createJointBgPic(context: Context): File? {
   val drawable = ColorDrawable(Color.parseColor("#FFFFFFFF"))
   val bitmap = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888)
   val canvas = Canvas(bitmap)
   drawable.draw(canvas)
   return try {
       Tools.saveBitmap2File(bitmap, "jointBg", context.externalCacheDir)
  } catch (e: Exception) {
       log2text("Create cache bg fail!", "e", e)
       null
  }
}

然后从尺寸列表中取出并删除追加在末尾的总尺寸和最小尺寸:

// 别看了,没写错,就是两个 size-1 ,为啥?你猜
val minResolution = gifResolution.removeAt(gifResolution.size - 1)
val totalResolution = gifResolution.removeAt(gifResolution.size - 1)

然后,就是开始拼接命令,这里我为了方便使用,自己写了一个 FFmpeg 命令的 Builder:

/**
* @author equationl
* */
public class FFMpegArgumentsBuilder {
private final String[] cmd;

public static class Builder {
private final ArrayList<String> cmd = new ArrayList<>();

/**
* Such as add [arg, value] to cmd[]
* */
public Builder setArgWithValue(String arg, String value) {
this.cmd.add(arg);
this.cmd.add(value);
return this;
}

/**
* Such as add arg to cmd[]
* */
public Builder setArg(String arg) {
this.cmd.add(arg);
return this;
}

/**
* Such as "-ss time"
* */
public Builder setStartTime(String time) {
this.cmd.add("-ss");
this.cmd.add(time);
return this;
}

/**
* Such as "-to time"
* */
public Builder setEndTime(String time) {
this.cmd.add("-to");
this.cmd.add(time);
return this;
}

/**
* Such as "-i input"
* */
public Builder setInput(String input) {
this.cmd.add("-i");
this.cmd.add(input);
return this;
}

/**
* <p>Such as "-t time"</p>
* <p>Note: call this before addInput() will limit input duration time; call before addOutput() will limit output duration time.</p>
* */
public Builder setDurationTime(String time) {
this.cmd.add("-t");
this.cmd.add(time);
return this;
}

/**
* <p>if isOverride is true, add "-y"; else add "-n"</p>
* <p>if do not set this arg, FFMpeg may ask for if override existed output file</p>
* */
public Builder setOverride(Boolean isOverride) {
if (isOverride) {
this.cmd.add("-y");
}
else {
this.cmd.add("-n");
}
return this;
}

/**
* Add output file to cmd[].<b>You must call this at end.</b>
* */
public Builder setOutput(String output) {
this.cmd.add(output);
return this;
}

/**
* <p>Set input/output file format</p>
* <p>Such as "-f format"</p>
* */
public Builder setFormat(String format) {
this.cmd.add("-f");
this.cmd.add(format);
return this;
}

/**
* Set video filter
* Such as "-vf filter"
* */
public Builder setVideoFilter(String filter) {
this.cmd.add("-vf");
this.cmd.add(filter);
return this;
}

/**
* Set frame rate, Such as "-r frameRate"
* */
public Builder setFrameRate(String frameRate) {
this.cmd.add("-r");
this.cmd.add(frameRate);
return this;
}

/**
* Set frame size, Such as "-s frameSize"
* */
public Builder setFrameSize(String frameSize) {
this.cmd.add("-s");
this.cmd.add(frameSize);
return this;
}

public FFMpegArgumentsBuilder build() {
return new FFMpegArgumentsBuilder(this, false);
}

/**
* Build cmd
*
* @param isAddFFmpeg true: Add a ffmpeg flag in first
* */
public FFMpegArgumentsBuilder build(Boolean isAddFFmpeg) {
return new FFMpegArgumentsBuilder(this, isAddFFmpeg);
}
}

public String[] getCmd() {
return this.cmd;
}

private FFMpegArgumentsBuilder(Builder b, Boolean isAddFFmpeg) {
if (isAddFFmpeg) {
b.cmd.add(0, "ffmpeg");
}
this.cmd = b.cmd.toArray(new String[0]);
}

}

开始生成命令文本:

首先是输入文件等,

val cmdBuilder = FFMpegArgumentsBuilder.Builder()
cmdBuilder.setOverride(true) // -y
.setInput(jointBg.absolutePath) // -i 输入背景

for (uri in gifUris) { //输入GIF
cmdBuilder.setInput(FileUtils.getMediaAbsolutePath(context, uri)) // -i
}

cmdBuilder.setArg("-filter_complex") //添加过滤器

然后是添加过滤器参数,

//过滤器参数
var cmdFilter = ""

//设置背景并扩展分辨率到 total
cmdFilter += "[0:v]pad=${totalResolution[0]}:${totalResolution[1]}[bg];"

//将输入文件缩放并取别名为 gifX (X为索引)
gifResolution.forEachIndexed { index, mutableList ->
cmdFilter += "[${index+1}:v]scale=${mutableList[0]}:${mutableList[1]}[gif$index];"
}

cmdFilter += "[bg][gif0] overlay=0:0[over0];" //将第一个GIF叠加 bg 的 0:0 (即画面左下角)

//开始叠加剩余动图
cmdFilter += getCmdFilterOverlaySquare(gifUris, gifResolution)

其中,getCmdFilterOverlaySquare 用于计算 gif 的摆放坐标,并合成参数命令,实现如下:

private fun getCmdFilterOverlaySquare(gifUris: ArrayList<Uri>, gifResolution: MutableList<MutableList<Int>>): String {
// "[bg][gif0] overlay=0:0[over0];"
var cmdFilter = ""
var h: Int
var w: Int
var index = 0
var lineLength = GifTools.JointGifSquareLineLength[gifUris.size]
if (lineLength == null) {
lineLength = sqrt(gifUris.size.toDouble()).toInt()
}

for (i in 0 until lineLength) {
for (j in 0 until lineLength) {
if ((i==lineLength-1 && j==lineLength-1) || (i==0 && j==0)) { //最后一张单独处理,第一张已处理
continue
}
if (j==0) { //竖排第一个,w当然等于 0
w = 0
} else {
w = 0
for (k in 0 until j) {
w += gifResolution[i*lineLength+k][0]
}
}
if (i==0) { //横排第一个,h等于0
h = 0
} else {
h = 0
for (k in j until index step lineLength) {
h += gifResolution[k][1]
}
}

cmdFilter += "[over${index}][gif${index+1}] overlay=$w:$h[over${index + 1}];"
index++
}
}

w = 0
for (i in 0 until lineLength-1) {
w += gifResolution[i+lineLength*(lineLength-1)][0]
}

h = 0
for (i in lineLength-1 until lineLength*lineLength-1 step lineLength) {
h += gifResolution[i][1]
}

cmdFilter += "[over${index}][gif${index+1}] overlay=$w:$h"

return cmdFilter
}

上述代码不难理解,总之就是根据遍历到的 gif 索引,判断它应该所处的坐标,然后加入过滤器参数。

最后,将过滤参数加入命令,加入输出文件路径,即可拿到最终命令文本 cmd

cmdBuilder.setArg(cmdFilter)
cmdBuilder.setOutput(resultPath)

val cmd = cmdBuilder.build(false).cmd

最后,只要将这个命令文本仍给 FFmpeg 执行即可!

总结

虽然本文仅仅说的是如何拼接 Gif , 但是 FFmpeg 是十分强大的,我这个属于是抛砖引玉。

相信各位有过这样一种需求,那就是做一个多人同屏的实时会议功能,如果在看本文之前你可能不知所措,但是看完本文你一定会觉得这是小菜一碟。

因为 FFmpeg 原生支持串流,支持视频处理,你只要把我这里的输入文件改成串流,输出文件改成串流,再按照你的需求改一下坐标,那不就完成了吗?

作者:equationl
来源:juejin.cn/post/7136325945937362952

收起阅读 »

为什么要选择VersionCatalog来做依赖管理?

虾扯淡很多人都介绍过Gradle 7.+提供新依赖管理工具VersionCatalog,我就不过多介绍这个了。我们最近也算是成功接入了VersionCatalog,过程也还是有点曲折的,总体来说我觉得确实比我们当前的ext,或者说是用buildSrc的形式进行...
继续阅读 »

虾扯淡

很多人都介绍过Gradle 7.+提供新依赖管理工具VersionCatalog,我就不过多介绍这个了。我们最近也算是成功接入了VersionCatalog,过程也还是有点曲折的,总体来说我觉得确实比我们当前的ext,或者说是用buildSrc的形式进行依赖管理是个更成熟的方案吧。下面是几个介绍的文章,尤其可以看看三七哥哥的。

之前大部分文章只介绍了技术方案,很少会去横向对比几个技术方案之间的优劣。从我们最近一个月的使用结果上来看吧,接下来我给大家分析下实际的优劣,仅仅只代表个人看法, 上表格了。

因为VersionCatalog使用的文件格式是toml,所以后续可能会用toml进行简称。

extbuildSrctoml
声明域*.gradle*.java *.kt*.toml
可修改可修改不可修改不可修改
写法花里胡哨静态变量固定写法 xxx.xxx.xxx
校验随便写编译时校验同步时校验

声明域: 指的是我们在哪里声明这些依赖管理。其中ext可以在绝大部分的.gradle中去进行声明,所以就会导致依赖声明的过于零散。而这部分问题就不存在于buildSrc和toml中,他们只能被声明在固定的位置上。

可修改性: 特制声明的依赖能否被修改,ext声明是在内存空间内,而ext的本质其实就是一个Any他可以存放任意的东西,如果出现同名的则会是后面声明的把前面声明的覆盖掉,这就是一个非常不稳定的属性,而buildSrc则是由class来声明的,我们没有办法在gradle中去修改这部分,所以相对来说是稳定的。而toml也类似,基于固定格式反序列化成代码。不具备修改的能力。

写法: ext这方面是真的拉胯,比如支持libs.abc或者libs."abc"或者libs.["abc"]还可以单引号,就非常的随意,而且极为不统一。这也是我们本次改动中碰到问题最多的时候。其他两种写法都相对比较固定,类似java/kt 中的静态常量。

校验: ext就是爱咋写咋写吧,反正也没有很好的校验啥的。而buildSrc则是基于java的代码编译来的,toml因为是一个新的文件格式,所以内置了一套相对比较强的语法校验,如果不合规则会报错,并显示错误行数。

据说buildSrc对于增量编译的适配等其实不太良好,而且我们是一个复杂的巨型复合构建的工程,所以个人并不太推荐buildSrc。

可以参考这篇文章第二章 Stop using Gradle buildSrc. Use composite builds instead

由此可证哦,VersionCatalog雀食是一个非常好的选择,尤其如果你们当前还是在使用的是ext的情况下。

巨型工程最麻烦的事情其实另外一点就是技术栈的切换,因为要改起来的地方可真的就是太多了,首先就是要先解决复合构建的情况下全局只有一份注册的逻辑,其二就是把当前工程的ext全部转移到toml中,然后要最好和之前的方式接近,尽量保证最小改动。最后则是所有工程都改一下!!!!!!!!(要我狗命)

共享配置

GradleSample demo 工程如下,其中plugin-version就是

我们也采取了之前Gradle 奇淫技巧之initscript pluginManagement一样的方式,通过initscript做到复合构建内共享插件的能力。

另外我们把VersionCatalog作为一个extension抛出来在外部完成注册。

catalogs {
  script = new File(rootProjectDir, "depencies.gradle")

  versionCatalogs {
      create("libs") { from(files("${rootProjectDir.path}/toml/dependencies.versions.toml")) }
      create("module") { from(files("${rootProjectDir.path}/toml/module.versions.toml")) }
  }
  dependencyResolutionManagement {
      repositories {
          maven { setUrl("https://maven.aliyun.com/repository/central/") }
          maven {
              setUrl("https://storage.googleapis.com/r8-releases/raw")
          }
          gradlePluginPortal()
          google()
          mavenLocal()
          maven {
              url "https://dl.bintray.com/kotlin/kotlin-eap"
          }
      }
  }

}

通过这部分配置就可以把共享的部分注入进工程内。然后就是很沙雕的改改改了,把所有的ext全部迁移到我们新的toml上去,然后注册出多个。

命令行工具

TheNext 虾开发的撒币cli工具 专门解决虾的撒币问题

以前也说过了我们工程的模块数量巨大,然后又因为ext的写法风骚,所以我们基本所有的写依赖的地方都要改,就是真的工作量巨大。

一个优秀的摸鱼工程师最重要的天赋就是要学会转化生产力,把这种简单又繁琐的工作交给命令行来解决。所以这就有了TheNext的一个新能力,基于当前的文件目录修改所有的.gradle文件,然后把非标准的ext的写法全部进行一次替换。


效果如图所示。

代码逻辑如下,我们首先会遍历整个工程的文件目录,然后发现.gradle后缀的文件,之后通过正则匹配出dependencies,然后进行把一些"" '' []等等都删掉,然后把- _更换成.,这样就能完成简单的自动替换了。

package com.kronos.mebium.android

import com.beust.jcommander.JCommander
import com.kronos.mebium.action.Handler
import com.kronos.mebium.entity.CommandEntity
import com.kronos.mebium.file.getRootProjectDir
import com.kronos.mebium.utils.green
import com.kronos.mebium.utils.red
import com.kronos.mebium.utils.yellow
import java.io.File
import java.util.Scanner

/**
*
* @Author LiABao
* @Since 2022/12/8
*
*/
class DependenciesHandler : Handler {

   val scanner = Scanner(System.`in`)
   var isSkip = false

   override fun handle(args: Array<String>) {
       isSkip = args.contains(skip)
       val realArgs = if (isSkip) {
           arrayListOf<String>().apply {
               args.forEach {
                   if (it != skip) {
                       add(it)
                  }
              }
          }.toTypedArray()
      } else {
           args
      }
       val commandEntity = CommandEntity()
       JCommander.newBuilder().addObject(commandEntity).build().parse(*realArgs)
       val first = commandEntity.file
       val name = commandEntity.name
       val root = first
       val files = root.walkTopDown().filter {
           it.isFile && it.name.contains(".gradle")
      }
       val overrideList = mutableListOf<Pair<File, File>>()
       files.forEach {
           onGradleCheck(it)?.apply {
               overrideList.add(it to this)
          }
      }
       confirm(overrideList)
  }

   private fun confirm(overrideList: MutableList<Pair<File, File>>) {
       if (overrideList.isEmpty()) {
           return
      }
       println("if you want overwrite all this file ? input y to confirm \r\n".red())
       val input = scanner.next()
       if (input == "y") {
           overrideList.forEach {
               it.first.delete()
               it.second.renameTo(it.first)
          }
           print("replace success \r\n ".green())
      } else {
           print("skip\r\n ".yellow())
      }
  }

   private val pattern =
       "(\\D\\S*)(implementation|Implementation|compileOnly|CompileOnly|test|Test|api|Api|kapt|Kapt|Processor)([ (])(\\D\\S*)".toPattern()

   private fun onGradleCheck(file: File): File? {
       var override = false
       val lines = file.readLines()
       val newLines = mutableListOf<String>()
       lines.forEach { line ->
           val matcher = pattern.matcher(line)
           if (matcher.find()) {
               val libs = matcher.group(4)
               if (!libs.contains(":") && !libs.contains("files(")) {
                   val newLibs =
                       libs.replace("\'", "").replace("\"", "").replace("-", ".").replace("_", ".")
                          .replace("kotlin.libs", "kotlinlibs").replace("[", ".").replace("]", "")
                   if (newLibs == libs) {
                       newLines.add(line)
                       return@forEach
                  }
                   print("fileName: ${file.name} dependencies : $line \r\n")
                   if (isSkip) {
                       override = true
                       newLines.add(line.replace(libs, newLibs))
                       print("$libs do you want replace to $newLibs   \r\n ".green())
                       return@forEach
                  }
                   print("$libs do you want replace to $newLibs ? input y to replace \r\n ".red())
                   while (true) {
                       val input = scanner.next()
                       if (input == "y") {
                           print("replace success\r\n".green())
                           override = true
                           newLines.add(line.replace(libs, newLibs))
                           return@forEach
                      } else {
                           print("skip\r\n ".yellow())
                           break
                      }
                  }
              }
          }
           newLines.add(line)
      }
       if (override) {
           val newFile = File(file.parent, file.name.removeSuffix(".gradle") + ".temp")
           newLines.forEach {
               newFile.appendText(it + "\r\n")
          }
           return newFile
      }
       return null
  }
}

const val skip = "--skip"

代码就基本是这样,如果有正则带佬可以帮忙优化下正则的。

然后这个工具也可以多次复用,因为我这个需求没有办法很快的被合入,需要频繁的rebase master的代码,每次rebase完之后都要进行二次修改,真的吐了。

验收

每个新功能开发最后都是要进行验收的,尤其是技改需求,你到时候把功能搞坏了到时候可是要背黑锅的啊。而且这种需求也没有办法要求测试进行特别系统性的测试,所以还是要开发自己想办法了。

我们拉取了apk包的依赖,然后用HashSet进行了拉平,去除重复依赖,然后通过diff对比前后差异,在基本符合预期的情况下我们就可以进行快速的合入。

结尾

其实本文的核心是给大家分析下几种依赖管理方式的优劣,然后对于还在使用gradle ext的大佬,其实可以逐渐考虑进行替换了。

作者:究极逮虾户
来源:juejin.cn/post/7190277951614058555

收起阅读 »

安卓与串口通信-实践篇

前言在上一篇文章中我们讲解了关于串口的基础知识,没有看过的同学推荐先看一下,否则你可能会不太理解这篇文章所述的某些内容。这篇文章我们将讲解安卓端的串口通信实践,即如何使用串口通信实现安卓设备与其他设备例如PLC主板之间数据交互。需要注意的是正如上一篇文章所说的...
继续阅读 »

前言

在上一篇文章中我们讲解了关于串口的基础知识,没有看过的同学推荐先看一下,否则你可能会不太理解这篇文章所述的某些内容。

这篇文章我们将讲解安卓端的串口通信实践,即如何使用串口通信实现安卓设备与其他设备例如PLC主板之间数据交互。

需要注意的是正如上一篇文章所说的,我目前的条件只允许我使用 ESP32 开发版烧录 Arduino 程序与安卓真机(小米10U)进行串口通信演示。

准备工作

由于我们需要使用 ESP32 烧录 Arduino 程序演示安卓端的串口通信,所以在开始之前我们应该先把程序烧录好。

那么烧录一个怎样的程序呢?

很简单,我这里直接烧了一个 ESP32 使用 9600 的波特率进行串口通信,程序内容就是 ESP32 不断的向串口发送数据 “e” ,并且监听串口数据,如果接收到数据 “o” 则打开开发版上自带的 LED 灯,如果接收到数据 “c” 则关闭这个 LED 灯。

代码如下:

#define LED 12

void setup() {
Serial.begin(9600);
pinMode(LED, OUTPUT);
}

void loop() {
if (Serial.available()) {
  char c = Serial.read();
  if (c == 'o') {
    digitalWrite(LED, HIGH);
  }
  if (c == 'c') {
    digitalWrite(LED, LOW);
  }
}

Serial.write('e');

delay(100);
}

上面的 12 号 Pin 是这块开发版的 LED。

使用 Arduino自带串口监视器测试结果:

1.gif

可以看到,确实如我们设想的通过串口不断的发送字符 “e”,并且在接收到字符 “o” 后点亮了 LED。

安卓实现串口通信

原理概述

众所周知,安卓其实是基于 Linux 的操作系统,所以在安卓中对于串口的处理与 Linux 一致。

在 Linux 中串口会被视为一个“设备”,并体现为 /dev/ttys 文件。

/dev/ttys 又被称为字符终端,例如 ttys0 对应的是 DOS/Windows 系统中的 COM1 串口文件。

通常,我们可以简单理解,如果我们插入了某个串口设备,则这个设备与 Linux 的通信会由 /dev/ttys 文件进行 “中转”。

即,如果 Linux 想要发送数据给串口设备,则可以通过往 /dev/ttys 文件中直接写入要发送的数据来实现,如:

echo test > /dev/ttyS1 这个命令会将 “test” 这串字符发送给串口设备。

如果想读取串口发送的数据也是一样的,可以通过读取 /dev/ttys 文件内容实现。

所以,如果我们在安卓中想要实现串口通信,大概率也会想到直接读取/写入这个特殊文件。

android-serialport-api

在上文中我们说到,在安卓中也可以通过与 Linux 一样的方式--直接读写 /dev/ttys 实现串口通信。

但是其实并不需要我们自己去处理读写和数据的解析,因为谷歌官方给出了一个解决方案:android-serialport-api

为了便于理解,我们会大致说一下这个解决方案的源码,但是就不上示例了,至于为什么,同学们往下看就知道了。另外,虽然这个方案历史比较悠久,也很长时间没有人维护了,但是并不意味着不能使用了,只是使用条件比较苛刻,当然,我司目前使用的还是这套方案(哈哈哈哈)。

不过这里我们不直接看 android-serialport-api 的源码,而是通过其他大佬二次封装的库来看: Android-SerialPort-API

在这个库中,通过

// 默认直接初始化,使用8N1(8数据位、无校验位、1停止位),path为串口路径(如 /dev/ttys1),baudrate 为波特率
SerialPort serialPort = new SerialPort(path, baudrate);

// 使用可选参数配置初始化,可配置数据位、校验位、停止位 - 7E2(7数据位、偶校验、2停止位)
SerialPort serialPort = SerialPort
  .newBuilder(path, baudrate)
// 校验位;0:无校验位(NONE,默认);1:奇校验位(ODD);2:偶校验位(EVEN)
//   .parity(2)
// 数据位,默认8;可选值为5~8
//   .dataBits(7)
// 停止位,默认1;1:1位停止位;2:2位停止位
//   .stopBits(2)
  .build();

初始化串口,然后通过:

InputStream in = serialPort.getInputStream();
OutputStream out = serialPort.getOutputStream();

获取到输入/输出流,通过读取/写入这两个流来实现与串口设备的数据通信。

我们首先来看看初始化串口是怎么做的。

2.png

首先检查了当前是否具有串口文件的读写权限,如果没有则通过 shell 命令更改权限为 666 ,更改后再次检查是否有权限,如果还是没有就抛出异常。

注意这里的执行 shell 时使用的 runtime 是 Runtime.getRuntime().exec(sSuPath); 也就是说,它是通过 root 权限来执行这段命令的!

换句话说,如果想要通过这种方式实现串口通信,必须要有 ROOT 权限!这就是我说我不会给出示例的原因,因为我手头的设备无法 ROOT 啊。至于为啥我司还能继续使用这种方案的原因也很简单,因为我们工控机的安卓设备都是定制版的啊,拥有 ROOT 权限不是基本操作?

确定权限可用后通过 open 方法拿到一个类型为 FileDescriptor 的变量 mFd ,最后通过这个 mFd 拿到输入输出流。

所以核心在于 open 方法,而 open 方法是一个 native 方法,即 C 代码:

private native FileDescriptor open(String absolutePath, int baudrate, int dataBits, int parity,
   int stopBits, int flags);

C 的源码这里就不放了,只需要知道它做的工作就是打开了 /dev/ttys 文件(准确的说是“终端”),然后通过传递进去的这些参数去按串口规则解析数据,最后返回一个 java 的 FileDescriptor 对象。

在 java 中我们再通过这个 FileDescriptor 对象可以拿到输入/输出流。

原理说起来是十分的简单。

看完通信部分的原理后,我们再来看看我们如何查找可用的串口呢?

其实和 Linux 上也一样:

public Vector<File> getDevices() {
   if (mDevices == null) {
       mDevices = new Vector<File>();
       File dev = new File("/dev");
       
       File[] files = dev.listFiles();

       if (files != null) {
           int i;
           for (i = 0; i < files.length; i++) {
               if (files[i].getAbsolutePath().startsWith(mDeviceRoot)) {
                   Log.d(TAG, "Found new device: " + files[i]);
                   mDevices.add(files[i]);
              }
          }
      }
  }
   return mDevices;
}

也是通过直接遍历 /dev 下的文件,只不过这里做了一些额外的过滤。

或者也可以通过读取 /proc/tty/drivers 配置文件后过滤:

Vector<Driver> getDrivers() throws IOException {
   if (mDrivers == null) {
       mDrivers = new Vector<Driver>();
       LineNumberReader r = new LineNumberReader(new FileReader("/proc/tty/drivers"));
       String l;
       while ((l = r.readLine()) != null) {
           // Issue 3:
           // Since driver name may contain spaces, we do not extract driver name with split()
           String drivername = l.substring(0, 0x15).trim();
           String[] w = l.split(" +");
           if ((w.length >= 5) && (w[w.length - 1].equals("serial"))) {
               Log.d(TAG, "Found new driver " + drivername + " on " + w[w.length - 4]);
               mDrivers.add(new Driver(drivername, w[w.length - 4]));
          }
      }
       r.close();
  }
   return mDrivers;
}

关于读取可用串口设备,其实从这里的路径也可以看出,都是系统路径,也就是说,如果没有权限,大概率也是读取不到东西的。

这就是使用与 Linux 一样的方式去读取串口数据的基本原理,那么问题来了,既然我说这个方法使用条件比较苛刻,那么更易用的替代方案是什么呢?

我们下面就会介绍,那就是使用安卓的 USB host (USB主机)的功能。

USB host

Android 3.1(API 级别 12)或更高版本的平台直接支持 USB 配件和主机模式。USB 配件模式还作为插件库向后移植到 Android 2.3.4(API 级别 10)中,以支持更广泛的设备。设备制造商可以选择是否在设备的系统映像中添加该插件库。

在安卓 3.1 版本开始,支持将USB作为主机模式(USB host)使用,而我们如果想要通过 USB 读取串口数据则需要依赖于这个主机模式。

在正式开始介绍USB主机模式前,我们先简要介绍一下安卓上支持的USB模式。

安卓上的USB支持三种模式:设备模式、主机模式、配件模式。

设备模式即我们常用的直接将安卓设备连接至电脑上,此时电脑上显示为 USB 外设,即可以当成 “U盘” 使用拷贝数据,不过现在安卓普遍还支持 MTP模式(作为摄像头)、文件传输模式(即当U盘用)、网卡模式等。

主机模式即将我们的安卓设备作为主机,连接其他外设,此时安卓设备就相当于上面设备模式中的电脑。此时安卓设备可以连接键盘、鼠标、U盘以及嵌入式应用USB转串口、转I2C等设备。但是如果想要将安卓设备作为主机模式可能需要一条支持 OTG 的数据线或转接头。(Micro-USB 或 USB type-c 转 USB-A 口)

而在 USB 配件模式下,外部 USB 硬件充当 USB 主机。配件示例可能包括机器人控制器、扩展坞、诊断和音乐设备、自助服务终端、读卡器等等。这样,不具备主机功能的 Android 设备就能够与 USB 硬件互动。Android USB 配件必须设计为与 Android 设备兼容,并且必须遵守 Android 配件通信协议。

设备模式与配件模式的区别在于在配件模式下,除了 adb 之外,主机还可以看到其他 USB 功能。

usb-host-accessory.png

使用USB主机模式与外设交互数据

在介绍完安卓中的三种USB模式后,下面我们开始介绍如何使用USB主机模式。当然,这里只是大概介绍原生APi的使用方法,我们在实际使用中一般都都是直接使用大佬编写的第三方库。

准备工作

在开始正式使用USB主机模式时我们需要先做一些准备工作。

首先我们需要在清单文件(AndroidManifest.xml)中添加:

<!-- 声明需要USB主机模式支持,避免不支持的设备安装了该应用 -->
<uses-feature android:name="android.hardware.usb.host" />

<!-- …… -->

<!-- 声明需要接收USB连接事件 -->
<action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" />

一个完整的清单文件示例如下:

<manifest ...>
   <uses-feature android:name="android.hardware.usb.host" />
   <uses-sdk android:minSdkVersion="12" />
  ...
   <application>
       <activity ...>
          ...
           <intent-filter>
               <action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" />
           </intent-filter>
       </activity>
   </application>
</manifest>

声明好清单文件后,我们就可以查找当前可用的设备信息了:

private fun scanDevice(context: Context) {
val manager = context.getSystemService(Context.USB_SERVICE) as UsbManager
val deviceList: HashMap<String, UsbDevice> = manager.deviceList
Log.i(TAG, "scanDevice: $deviceList")
}

将 ESP32 开发版插上手机,运行程序,输出如下:

3.png

可以看到,正确的查找到了我们的 ESP32 开发版。

这里提一下,因为我们的手机只有一个 USB 口,此时已经插上了 ESP32 开发版,所以无法再通过数据线直接连接电脑的 ADB 了,此时我们需要使用无线 ADB,具体怎么使用无线 ADB,请自行搜索。

另外,如果我们想要通过查找到设备后请求连接的方式连接到串口设备的话,还需要额外申请权限。(同理,如果我们直接在清单文件中提前声明需要连接的设备则不需要额外申请权限,具体可以看看参考资料5,这里不再赘述)

首先声明一个广播接收器,用于接收授权结果:

private lateinit var permissionIntent: PendingIntent

private const val ACTION_USB_PERMISSION = "com.android.example.USB_PERMISSION"

private val usbReceiver = object : BroadcastReceiver() {

override fun onReceive(context: Context, intent: Intent) {
if (ACTION_USB_PERMISSION == intent.action) {
synchronized(this) {
val device: UsbDevice? = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE)

if (intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)) {
device?.apply {
// 已授权,可以在这里开始请求连接
connectDevice(context, device)
}
} else {
Log.d(TAG, "permission denied for device $device")
}
}
}
}
}

声明好之后在 Acticity 的 OnCreate 中注册这个广播接收器:

permissionIntent = PendingIntent.getBroadcast(this, 0, Intent(ACTION_USB_PERMISSION), FLAG_MUTABLE)
val filter = IntentFilter(ACTION_USB_PERMISSION)
registerReceiver(usbReceiver, filter)

最后,在查找到设备后,调用 manager.requestPermission(deviceList.values.first(), permissionIntent) 弹出对话框申请权限。

连接到设备并收发数据

完成上述的准备工作后,我们终于可以连接搜索到的设备并进行数据交互了:

private fun connectDevice(context: Context, device: UsbDevice) {
val usbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager

CoroutineScope(Dispatchers.IO).launch {
device.getInterface(0).also { intf ->
intf.getEndpoint(0).also { endpoint ->
usbManager.openDevice(device)?.apply {
claimInterface(intf, forceClaim)
while (true) {
val validLength = bulkTransfer(endpoint, bytes, bytes.size, TIMEOUT)
if (validLength > 0) {
val result = bytes.copyOfRange(0, validLength)
Log.i(TAG, "connectDevice: length = $validLength")
Log.i(TAG, "connectDevice: byte = ${result.contentToString()}")
}
else {
Log.i(TAG, "connectDevice: Not recv data!")
}
}
}
}
}
}
}

在上面的代码中,我们使用 usbManager.openDevice 打开了指定的设备,即连接到设备。

然后通过 bulkTransfer 接收数据,它会将接收到的数据写入缓冲数组 bytes 中,并返回成功接收到的数据长度。

运行程序,连接设备,日志打印如下:

4.png

可以看到,输出的数据并不是我们预料中的数据。

这是因为这是非常原始的数据,如果我们想要读取数据,还需要针对不同的串口转USB芯片或协议编写驱动程序才能获取到正确的数据。

顺道一提,如果想要将数据写入串口数据的话可以使用 controlTransfer()

所以,我们在实际生产环境中使用的都是基于此封装好的第三方库。

这里推荐使用 usb-serial-for-android

usb-serial-for-android

使用这个库的第一步当然是导入依赖:

// 添加仓库
allprojects {
repositories {
...
maven { url 'https://jitpack.io' }
}
}
// 添加依赖
dependencies {
implementation 'com.github.mik3y:usb-serial-for-android:3.4.6'
}

添加完依赖同样需要在清单文件中添加相应字段以及处理权限,因为和上述使用原生API一致,所以这里不再赘述。

和原生 API 不同的是,因为我们此时已经知道了我们的 ESP32 主板的设备信息,以及使用的驱动(CDC),所以我们就不使用原生的查找可用设备的方法了,我们这里直接指定我们已知的这个设备(当然,你也可以继续使用原生API的查找和连接方法):

private fun scanDevice(context: Context) {
val manager = context.getSystemService(Context.USB_SERVICE) as UsbManager

val customTable = ProbeTable()
// 添加我们的设备信息,三个参数分别为 vendroId、productId、驱动程序
customTable.addProduct(0x1a86, 0x55d3, CdcAcmSerialDriver::class.java)

val prober = UsbSerialProber(customTable)
// 查找指定的设备是否存在
val drivers: List<UsbSerialDriver> = prober.findAllDrivers(manager)

if (drivers.isNotEmpty()) {
val driver = drivers[0]
// 这个设备存在,连接到这个设备
val connection = manager.openDevice(driver.device)
}
else {
Log.i(TAG, "scanDevice: 无设备!")
}
}

连接到设备后,下一步就是和数据交互,这里封装的十分方便,只需要获取到 UsbSerialPort 后,直接调用它的 read()write() 即可读写数据:

port = driver.ports[0] // 大多数设备都只有一个 port,所以大多数情况下直接取第一个就行

port.open(connection)
// 设置连接参数,波特率9600,以及 “8N1”
port.setParameters(9600, 8, UsbSerialPort.STOPBITS_1, UsbSerialPort.PARITY_NONE)

// 读取数据
val responseBuffer = ByteArray(1024)
port.read(responseBuffer, 0)

// 写入数据
val sendData = byteArrayOf(0x6F)
port.write(sendData, 0)

此时,一个完整的,用于测试我们上述 ESP32 程序的代码如下:

@Composable
fun SerialScreen() {
val context = LocalContext.current


Column(
Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Button(onClick = { scanDevice(context) }) {
Text(text = "查找并连接设备")
}

Button(onClick = { switchLight(true) }) {
Text(text = "开灯")
}
Button(onClick = { switchLight(false) }) {
Text(text = "关灯")
}

}
}

private fun scanDevice(context: Context) {
val manager = context.getSystemService(Context.USB_SERVICE) as UsbManager

val customTable = ProbeTable()
customTable.addProduct(0x1a86, 0x55d3, CdcAcmSerialDriver::class.java)

val prober = UsbSerialProber(customTable)
val drivers: List<UsbSerialDriver> = prober.findAllDrivers(manager)

if (drivers.isNotEmpty()) {
val driver = drivers[0]

val connection = manager.openDevice(driver.device)
if (connection == null) {
Log.i(TAG, "scanDevice: 连接失败")
return
}

port = driver.ports[0]

port.open(connection)
port.setParameters(9600, 8, UsbSerialPort.STOPBITS_1, UsbSerialPort.PARITY_NONE)

Log.i(TAG, "scanDevice: Connect success!")

CoroutineScope(Dispatchers.IO).launch {
while (true) {
val responseBuffer = ByteArray(1024)

val len = port.read(responseBuffer, 0)

Log.i(TAG, "scanDevice: recv data = ${responseBuffer.copyOfRange(0, len).contentToString()}")
}
}
}
else {
Log.i(TAG, "scanDevice: 无设备!")
}
}

private fun switchLight(isON: Boolean) {
val sendData = if (isON) byteArrayOf(0x6F) else byteArrayOf(0x63)

port.write(sendData, 0)
}

运行这个程序,并且连接设备,输出如下:

5.png

可以看到输出的是 byte 的 101,转换为 ASCII 即为 “e”。

然后我们点击 “开灯”、“关灯” 效果如下:

6.gif

对了,这里发送的数据 “0x6F” 即 ASCII “o” 的十六进制,同理,“0x63” 即 “c”。

可以看到,可以完美的和我们的 ESP32 开发版进行通信。

实例

无论使用什么方式与串口通信,我们在安卓APP的代码层面能够拿到的数据已经是处理好了的数据。

即,在上一篇文章中我们说过串口通信的一帧数据包括起始位、数据位、校验位、停止位。但是我们在安卓中使用时一般拿到的都只有 数据位 的数据,其他数据已经在底层被解析好了,无需我们去关心怎么解析,或者使用。

我们可以直接拿到的就是可用数据。

这里举一个我之前用过的某型号驱动版的例子。

这块驱动版关于通信的信息如图:

7.png

可以看到,它采用了 RS485 的通信方式,波特率支持 9600 或 38400,8位数据位,无校验,1位停止位。

并且,它还规定了一个数据协议。

在它定义的协议中,第一位为地址;第二位为指令;第三位到第N位为数据内容;最后两位为CRC校验。

需要注意的是,这里定义的协议是基于串口通信的,不要把这个协议和串口通信搞混了,简单来说就是在串口通信协议的数据位中又定义了一个自己的协议。

而且可以看到,虽然定义串口参数时没有指定校验,但是在它自己的协议中指定了使用 CRC 校验。

另外,弱弱的吐槽一句,这个驱动版的协议真的不好使。

在实际使用过程中,主机与驱动版的通信数据无法保证一定会在同一个数据帧中发送完成,所以可能会造成“粘包”、“分包”现象,也就是说,数据可能会分几次发过来,而且你不好判断这数据是上次没发送完的数据还是新的数据。

我使用过的另外一款驱动版就方便的多,因为它会在帧头加上开始符号和数据长度,帧尾加上结束符号。

这样一来,即使出现“粘包”、“分包”我们也能很好的给它解析出来。

当然,它这样设计协议肯定是有它的道理的,无非就是减少通信代价之类的。

我还遇到过一款十分简洁的驱动版,直接发送一个整数过去表示执行对应的指令。

驱动版回传的数据同样非常简单,就是一个数字,然后事先约定各个数字表示什么意思……

说归说,我们还是继续来看这款驱动版的通信协议:

8.png

这是它的其中一个指令内容,我们发送指令 “1” 过去后,它会返回当前驱动版的型号和版本信息给我们。

因为我们的主板是定制工控主板,所以使用的通信方式是直接用 android-serialport-api。

最终发送与接收回复也很简单:

/**
* 将十六进制字符串转成 ByteArray
* */
private fun hexStrToBytes(hexString: String): ByteArray {
   check(hexString.length % 2 == 0) { return ByteArray(0) }

   return hexString.chunked(2)
      .map { it.toInt(16).toByte() }
      .toByteArray()
}

private fun isReceivedLegalData(receiveBuffer: ByteArray): Boolean {

   val rcvData = receiveBuffer.copyOf()  //重新拷贝一个使用,避免原数据被清零

   if (cmd.cmdId.checkDataFormat(rcvData)) {  //检查回复数据格式
       isPkgLost = false
       if (cmd.cmdId.isResponseBelong(rcvData)) {  //检查回复命令来源
           if (!AdhShareData.instance.getIsUsingCrc()) {  //如果不开启CRC检验则直接返回 true
               resolveRcvData(cmdRcvDataCallback, rcvData, cmd.cmdId)
               coroutineScope.launch(Dispatchers.Main) {
                   cmdResponseCallback?.onCmdResponse(ResponseStatus.Success, rcvData, 0, rcvData.size, cmd.cmdId)
              }
               return true
          }

           if (cmd.cmdId.checkCrc(rcvData)) {  //检验CRC
                resolveRcvData(cmdRcvDataCallback, rcvData, cmd.cmdId)
               coroutineScope.launch(Dispatchers.Main) {
                   cmdResponseCallback?.onCmdResponse(ResponseStatus.Success, rcvData, 0, rcvData.size, cmd.cmdId)
              }

               return true
          }
           else {
               coroutineScope.launch(Dispatchers.Main) {
                   cmdResponseCallback?.onCmdResponse(ResponseStatus.FailCauseCrcError, ByteArray(0), -1, -1, cmd.cmdId)
              }

               return false
          }
      }
       else {
           coroutineScope.launch(Dispatchers.Main) {
               cmdResponseCallback?.onCmdResponse(ResponseStatus.FailCauseNotFromThisCmd, ByteArray(0), -1, -1, cmd.cmdId)
          }

           return false
      }
  }
   else {  //数据不符合,可能是遇到了分包,继续等待下一个数据,然后合并
       isPkgLost = true
       return isReceivedLegalData(cmd)
       /*coroutineScope.launch(Dispatchers.Main) {
           cmdResponseCallback?.onCmdResponse(ResponseStatus.FailCauseWrongFormat, ByteArray(0), -1, -1, cmd.cmdId)
       }

       return false */
  }
}

// ……省略初始化和连接代码

// 发送数据
val bytes = hexStrToBytes("0201C110")
outputStream.write(bytes, 0, bytes.size)

// 解析数据
val recvBuffer = ByteArray(0)
inputStream.read(recvBuffer)

while (receiveBuffer.isEmpty()) {
  delay(10)
}

isReceivedLegalData()

本来打算直接发我封装好的这个驱动版的协议库的,想了想,好像不太合适,所以就大概抽出了这些不完整的代码,懂这个意思就行了,哈哈。

总结

从上面介绍的两种方式可以看出,两种方式使用各有优缺点。

使用 android-serialport-api 可以直接读取串口数据内容,不需要转USB接口,不需要驱动支持,但是需要 ROOT,适合于定制安卓主板上已经预留了 RS232 或 RS485 接口且设备已 ROOT 的情况下使用。

而使用 USB host ,可以直接读取USB接口转接的串口数据,不需要ROOT,但是只支持有驱动的串口转USB芯片,且只支持使用USB接口,不支持直接连接串口设备。

各位可以根据自己的实际情况灵活选择使用什么方式来实现串口通信。

当然,除了现在介绍的这些串口通信,其实还有一个通信协议在实际使用中用的非常多,那就是 MODBUS 协议。

下一篇文章,我们将介绍 MODBUS。

参考资料

  1. android-serialport-api

  2. What is tty?

  3. Text-Terminal-HOWTO

  4. Terminal Special Files

  5. USB host

  6. Android开启OTG功能/USB Host API功能

作者:equationl
来源:https://juejin.cn/post/7171347086032502792

收起阅读 »

少发几百块工资就闹情绪要离职,这种计较的员工有留的必要吗?

为什么要少分几百块钱的工资,作为一个老板不诚信,谁还愿意跟你干。如果是因为效益不好,要跟员工说清楚,这个月欠发,下月一定要补上。即便是亏损,赊钱也应该是老板的,不能让员工替你亏损,你多赚钱的时候,也没发给员工。作为员工要和老板沟通,到底什么原因少发的,如果是暂...
继续阅读 »

为什么要少分几百块钱的工资,作为一个老板不诚信,谁还愿意跟你干。如果是因为效益不好,要跟员工说清楚,这个月欠发,下月一定要补上。即便是亏损,赊钱也应该是老板的,不能让员工替你亏损,你多赚钱的时候,也没发给员工。

作为员工要和老板沟通,到底什么原因少发的,如果是暂时的,以后补发,也可以理解。如果想赖账,劝你不要跟他干了。

几百块钱,对一个月薪3000—4000元的工资来讲,不是一个小数。一个农民工一个月的生活费,也就几百块钱。如果老板平时发的工资高,一个月万把,少发几百块钱也无所谓。发的工资少,又想克扣工资的老板,肯定不是好老板。

克扣员工工资,还说员工计较,拿着不是当理说。不是你留不留问题,是员工还愿意跟你干不干的问题。

从另一个角度看:

这不是员工有没有必要留下的问题,而是你自己懂不懂管理的问题。如果我是老板,肯定辞掉你,因为你根本就不配做管理者。

工资是员工的劳动所得,克扣工资就是不尊重员工的劳动成果。

劳动法明确规定,用人单位要按时足额发放工资。你现在明确承认将员工的工资少发了几百元,也就是说你的行为不仅仅严重损害了员工的合法权益,更是一种违法行为。

几百元确实不是大金额,但对于按件计酬的一线员工来说,也许要生产上千个配件;对于没有保障的保洁工来说,也许要打扫十天卫生;对于凭业绩拿奖金的销售来说,也许要成交上万元的订单…

员工付出了艰辛的劳动,劳动成果就被你无理由给克扣了,换作谁也会据理力争。凭什么啊,你为什么不会多给员工几百元的?

试想,如果你的工资是5k,老板无端克扣500,就发给你4.5k,难道你不会有情绪?如果老板不及时将工资补给你,你确信会继续干下去…

尊重员工不是一句空话,要从尊重员工的劳动成果做起!

员工闹情绪要离职,是给你的警钟。

作为管理者应该明白,员工遇事与你交流和争论,内心还是尊重你的,通过争论希望你能从公平公正的角度看问题,进而作出双赢的选择。

当争论后你仍固执己见,员工闹下小情绪是为了引起你的注意,再重新考虑。此时,管理者若不能把握解决问题的机会,员工就会对你完全失去信任,提出离职。

表面看,你好像赚到了几百元,其实你已经失去载舟之水,且终将被水颠覆。你让身边的人感到厌恶,公司形象严重受损,个人声誉一片狼藉,团队终将因你的无知而崩盘。

如果你不能及时改正错误,长此以往,你将因私自克扣员工的工资而走上不归路。少发的几百元哪去了?是终饱私囊,还是进入了小金库,这可都是违规违法的举动。

识时务者为俊杰,你应当将少发的几百元及时补发给员工,并且要深刻认识自己所犯错误的严重性。

此种情况下,即使员工离职,公司也要对员工进行经济补偿。

通常情况下,员工主动辞职,公司无需经济补偿;但是,由于你没有足额发放员工工资,且员工与你交涉后你拒绝改正。此时员工提出辞职,然后去申请仲裁,你肯定要作出经济补偿。

为了几百元,公司不仅损失了一名得力干将,还要对员工作出经济补偿,这是名副其实的得不偿失啊。

近者悦,远者来。这是管理者应该铭记的真理!

来源:maimai.cn/article/detail?fid=1741200421&efid=erVeYTis_A2HbjzYYIRU0A

收起阅读 »

宽表为什么横行?

宽表在BI业务中比比皆是,每次建设BI系统时首先要做的就是准备宽表。有时系统中的宽表可能会有上千个字段,经常因为“过宽”超过了数据库表字段数量限制还要再拆分。为什么大家乐此不疲地造宽表呢?主要原因有两个。一是为了提高查询性能。现代BI通常使用关系数据库作为后台...
继续阅读 »

宽表在BI业务中比比皆是,每次建设BI系统时首先要做的就是准备宽表。有时系统中的宽表可能会有上千个字段,经常因为“过宽”超过了数据库表字段数量限制还要再拆分。

为什么大家乐此不疲地造宽表呢?主要原因有两个。

一是为了提高查询性能。现代BI通常使用关系数据库作为后台,而SQL通常使用的HASH JOIN算法,在关联表数量和关联层级变多的时候,计算性能会急剧下降,有七八个表三四层级关联时就能观察到这个现象,而BI业务中的关联复杂度远远超过这个规模,直接使用SQL的JOIN就无法达到前端立等可取的查询需要了。为了避免关联带来的性能问题,就要先将关联消除,即将多表事先关联好采用单表存储(也就是宽表),再查询的时候就可以不用再关联,从而达到提升查询性能的目的。

二是为了降低业务难度。因为多表关联尤其是复杂关联在BI前端很难表达和使用。如果采用自动关联(根据字段类型等信息匹配)当遇到同维字段(如一个表有2个以上地区字段)时会“晕掉”不知道该关联哪个,表间循环关联或自关联的情况也无法处理;如果将众多表开放给用户来自行选择关联,由于业务用户无法理解表间关系而几乎没有可用性;分步关联可以描述复杂的关联需求,但一旦前一步出错就要推倒重来。所以,无论采用何种方式,工程实现和用户使用都很麻烦。但是基于单表来做就会简单很多,业务用户使用时没有什么障碍,因此将多表组织成宽表就成了“自然而然”的事情。

不过,凡事都有两面性,我们看到宽表好处而大量应用的同时,其缺点也不容忽视,有些缺点会对应用产生极大影响。下面来看一下。

宽表的缺点

数据冗余容量大

宽表不符合范式要求,将多个表合并成一个表会存在大量冗余数据,冗余程度跟原表数据量和表间关系有关,通常如果存在多层外键表,其冗余程度会呈指数级上升。大量数据冗余不仅会带来存储上的压力(多个表组合出来的宽表数量可能非常多)造成数据库容量问题,在查询计算时由于大量冗余数据参与运算还会影响计算性能,导致虽然用了宽表但仍然查询很慢。

数据错误

由于宽表不符合三范式要求,数据存储时可能出现一致性错误(脏写)。比如同一个销售员在不同记录中可能存储了不同的性别,同一个供应商在不同记录中的所在地可能出现矛盾。基于这样的数据做分析结果显然不对,而这种错误非常隐蔽很难被发现。

另外,如果构建的宽表不合理还会出现汇总错误。比如基于一对多的A表和B表构建宽表,如果A中有计算指标(如金额),在宽表中就会重复,基于重复的指标再汇总就会出现错误。

灵活性差

宽表本质上是一种按需建模的手段,根据业务需求来构建宽表(虽然理论上可以把所有表的组合都形成宽表,但这只存在于理论上,如果要实际操作会发现需要的存储空间大到完全无法接受的程度),这就出现了一个矛盾:BI系统建设的初衷主要是为了满足业务灵活查询的需要,即事先并不知道业务需求,有些查询是在业务开展过程中逐渐催生出来的,有些是业务用户临时起意的查询,这种灵活多变的需求采用宽表这种要事先加工的解决办法极为矛盾,想要获得宽表的好就得牺牲灵活性,可谓鱼与熊掌不可兼得。

可用性问题

除了以上问题,宽表由于字段过多还会引起可用性低的问题。一个事实表会对应多个维表,维表又有维表,而且表之间还可能存在自关联/循环关联的情况,这种结构在数据库系统中很常见,基于这些结构的表构建宽表,尤其要表达多个层级的时候,宽表字段数量会急剧增加,经常可能达到成百上千个(有的数据库表有字段数量限制,这时又要横向分表),试想一下,在用户接入界面如果出现上千个字段要怎么用?这就是宽表带来的可用性差的问题。

总体来看,宽表的坏处在很多场景中经常要大于好处,那为什么宽表还大量横行呢?

因为没办法。一直没有比宽表更好的方案来解决前面提到的查询性能和业务难度的问题。其实只要解决这两个问题,宽表就可以不用,由宽表产生的各类问题也就解决了。

SPL+DQL消灭宽表

借助开源集算器SPL可以完成这个目标。

SPL(Structured Process Language)是一个开源结构化数据计算引擎,本身提供了不依赖数据库的强大计算能力,SPL内置了很多高性能算法,尤其是对关联运算做了优化,对不同的关联场景采用不同的手段,可以大幅提升关联性能,从而不用宽表也能实时关联以满足多维分析时效性的需要。同时,SPL还提供了高性能存储,配合高效算法可以进一步发挥性能优势。

只有高性能还不够,SPL原生的计算语法不适合多维分析应用接入(生成SPL语句对BI系统改造较大)。目前大部分多维分析前端都是基于SQL开发的,但SQL体系(不用宽表时)在描述复杂关联计算上又很困难,基于这样的原因,SPL设计了专门的类SQL查询语法DQL(Dimensional Query Language)用于构建语义层。前端生成DQL语句,DQL Server将其转换成SPL语句,再基于SPL计算引擎和存储引擎完成查询返回给前端,实现全链路BI查询。需要注意的是,SPL只作为计算引擎存在,前端界面仍要由用户自行实现(或选用相应产品)。


SPL:关联实现技术

SPL如何不用宽表也能实现实时关联以满足性能要求的目标?

在BI业务中绝大部分的JOIN都是等值JOIN,也就是关联条件为等式的 JOIN。SPL把等值关联分为外键关联和主键关联。外键关联是指用一个表的非主键字段,去关联另一个表的主键,前者称为事实表,后者称为维表,两个表是多对一的关系,比如订单表和客户表。主键关联是指用一个表的主键关联另一个表的主键或部分主键,比如客户表和 VIP 客户表(一对一)、订单表和订单明细表(一对多)。

这两类 JOIN 都涉及到主键,如果充分利用这个特征采用不同的算法,就可以实现高性能的实时关联了。

不过很遗憾,SQL 对 JOIN 的定义并不涉及主键,只是两个表做笛卡尔积后再按某种条件过滤。这个定义很简单也很宽泛,几乎可以描述一切。但是,如果严格按这个定义去实现 JOIN,理论上没办法在计算时利用主键的特征来提高性能,只能是工程上做些有限的优化,在情况较复杂时(表多且层次多)经常无效。

SPL 改变了 JOIN 的定义,针对这两类 JOIN 分别处理,就可以利用主键的特征来减少运算量,从而提高计算性能。

外键关联

和SQL不同,SPL中明确地区分了维表和事实表。BI系统中的维表都通常不大,可以事先读入内存建立索引,这样在关联时可以少计算一半的HASH值。

对于多层维表(维表还有维表的情况)还可以用外键地址化的技术做好预关联。即将维表(本表)的外键字段值转换成对应维表(外键表)记录的地址。这样被关联的维表数据可以直接用地址取出而不必再进行HASH值计算和比对,多层维表仅仅是多个按地址取值的时间,和单层维表时的关联性能基本相当。

类似的,如果事实表也不大可以全部读入内存时,也可以通过预关联的方式解决事实表与维表的关联问题,提升关联效率。

预关联可以在系统启动时一次性读入并做好,以后直接使用即可。

当事实表较大无法全内存时,SPL 提供了外键序号化方法:将事实表中的外键字段值转换为维表对应记录的序号。关联计算时,用序号取出对应维表记录,这样可以获得和外键地址化类似的效果,同样能避免HASH值的计算和比对,大幅提升关联性能。

主键关联

有的事实表还有明细表,比如订单和订单明细,二者通过主键和部分主键进行关联,前者作为主表后者作为子表(还有通过全部主键关联的称为同维表,可以看做主子表的特例)。主子表都是事实表,涉及的数据量都比较大。

SPL为此采用了有序归并方法:预先将外存表按照主键有序存储,关联时顺序取出数据做归并,不需要产生临时缓存,只用很小的内存就可以完成计算。而SQL采用的HASH分堆算法复杂度较高,不仅要计算HASH值进行对比,还会产生临时缓存的读写动作,运算性能很差。

HASH 分堆技术实现并行困难,多线程要同时向某个分堆缓存数据,造成共享资源冲突;某个分堆关联时又会消费大量内存,无法实施较大的并行数量。而有序归则易于分段并行。数据有序时,子表就可以根据主表键值进行同步对齐分段以保证正确性,无需缓存,且因为占用内存很少可以采用较大的并行数,从而获得更高性能。

预先排序的成本虽高,但是一次性做好即可,以后就总能使用归并算法实现 JOIN,性能可以提高很多。同时,SPL 也提供了在有追加数据时仍然保持数据整体有序的方案。

对于主子表关联SPL还可以采用更有效的存储形式将主子表一体化存储,子表作为主表的集合字段,其取值是由与该主表数据相关的多条子表记录构成。这相当于预先实现了关联,再计算时直接取数计算即可,不需要比对,存储量也更少,性能更高。

存储机制

高性能离不开有效的存储。SPL也提供了列式存储,在BI计算中可以大幅降低数据读取量以提升读取效率。SPL列存采用了独有的倍增分段技术,相对传统列存分块并行方案要在很大数据量时(否则并行会受到限制)才会发挥优势不同,这个技术可以使SPL列存在数据量不很大时也能获得良好的并行分段效果,充分发挥并行优势。

SPL还提供了针对数据类型的优化机制,可以显著提升多维分析中的切片运算性能。比如将枚举型维度转换成整数,在查询时将切片条件转换成布尔值构成的对位序列,在比较时就可以直接从序列指定位置取出切片判断结果。还有将多个标签维度(取值是或否的维度,这种维度在多维分析中大量存在)存储在一个整数字段中的标签位维度技术(一个整数字段可以存储16个标签),不仅大幅减少存储量,在计算时还可以针对多个标签同时做按位计算从而大幅提升计算性能。

有了这些高效机制以后,我们就可以在BI分析中不再使用宽表,转而基于SPL存储和算法做实时关联,性能比宽表还更高(没有冗余数据读取量更小,更快)。

不过,只有这些还不够,SPL原生语法还不适合BI前端直接访问,这就需要适合的语义转换技术,通过适合的方式将用户操作转换成SPL语法进行查询。

这就需要DQL了。

DQL:关联描述技术

DQL是SPL之上的语义层构建工具,在这一层完成对于SPL数据关联关系的描述(建模)再为上层应用服务。即将SPL存储映射成DQL表,再基于表来描述数据关联关系。


通过对数据表关系描述以后形成了一种以维度为中心的总线式结构(不同于E-R图中的网状结构),中间是维度,表与表之间不直接相关都通过维度过渡。


基于这种结构下的关联查询(DQL语句)会很好表达。比如要根据订单表(orders)、客户表(customer)、销售员表(employee)以及城市表(city)查询:本年度华东的销售人员,在全国各销售区的销售额

用SQL写起来是这样的:

SEL ECT
ct1.area,o.emp_id,sum(o.amount) somt
FROM
orders o
JOIN customer c ON o.cus_id = c.cus_id
JOIN city ct1 ON c.city_id = ct1.city_id
JOIN employee e ON o.emp_id = e.emp_id
JOIN city ct2 ON e.city_id = ct2.city_id
WHERE
ct2.area = 'east' AND year(o.order_date)= 2022
GRO UP BY
ct1.area, o.emp_id

多个表关联要JOIN多次,同一个地区表要反复关联两次才能查到销售员和客户的所在区域,对于这种情况BI前端表达起来会很吃力,如果将关联开放出来,用户又很难理解。

那么DQL是怎么处理的呢?

DQL写法:

SEL ECT
cus_id.city_id.area,emp_id,sum(amount) somt
FROM
orders
WHERE
emp_id.city_id.area == "east" AND year(order_date)== 2022
BY
cus_id.city_id.area,emp_id

DQL不需要JOIN多个表,只基于orders单表查询就可以了,外键指向表的字段当成属性直接使用,有多少层都可以引用下去,很好表达。像查询客户所在地区通过cus_id.city_id.area一直写下去就可以了,这样就消除了关联,将多表关联查询转化成单表查询。

更进一步,我们再基于DQL开发BI前端界面就很容易,比如可以做成这样:


用树结构分多级表达多层维表关联,这样的多维分析页面不仅容易开发,普通业务用户使用时也很容易理解,这就是DQL的效力。

总结一下,宽表的目的是为了解决BI查询性能和前端工程实现问题,而宽表会带来数据冗余和灵活性差等问题。通过SPL的实时关联技术与高效存储可以解决性能问题,而且性能比宽表更高,同时不存在数据冗余,存储空间也更小(压缩);DQL构建的语义层解决了多维分析前端工程的实现问题,让实时关联成为可能,,灵活性更高(不再局限于宽表的按需建模),界面也更容易实现,应用范围更广。

SPL+DQL继承(超越)宽表的优点同时改善其缺点,这才是BI该有的样子。

SPL资料

作者:Java中文社群
来源:juejin.cn/post/7200033099752554553

收起阅读 »

使用 koin 作为 Android 注入工具,真香

koin 为 Android 提供了简单易用的 API 接口,让你简单轻松地接入 koin 框架。[koin 在 Android 中的 gradle 配置]mp.weixin.qq.com/s/bscC7mO4O…1.Application 类中 startK...
继续阅读 »

koin 为 Android 提供了简单易用的 API 接口,让你简单轻松地接入 koin 框架。


[koin 在 Android 中的 gradle 配置]

mp.weixin.qq.com/s/bscC7mO4O…

1.Application 类中 startKoin

从您的类中,您可以使用该函数并注入 Android 上下文,如下所示:

Application startKoin androidContext
class MainApplication : Application() {

   override fun onCreate() {
       super.onCreate()

       startKoin {
           // Log Koin into Android logger
           androidLogger()
           // Reference Android context
           androidContext(this@MainApplication)
           // Load modules
           modules(myAppModules)
      }

  }
}

如果您需要从另一个 Android 类启动 Koin,您可以使用该函数为您的 Android 实例提供如下:startKoin Context

startKoin {
   //inject Android context
   androidContext(/* your android context */)
   // ...
}

2. 额外配置

从您的 Koin 配置(在块代码中),您还可以配置 Koin 的多个部分。startKoin { }

2.1 Koin Logging for Android

koin 提供了 log 的 Android 实现。

startKoin {
   // use Android logger - Level.INFO by default
   androidLogger()
   // ...
}

2.2 加载属性

您可以在文件中使用 Koin 属性来存储键/值:assets/koin.properties

startKoin {
   // ...
   // use properties from assets/koin.properties
   androidFileProperties()

}

3. Android 中注入对象实例

3.1 为 Android 类做准备

koin 提供了KoinComponents 扩展,Android 组件都具有这种扩展,这些组件包括 Activity Fragment Service ComponentCallbacks

您可以通过如下方式访问 Kotlin 扩展:

by inject()- 来自 Koin 容器的延迟计算实例

get() - 从 Koin 容器中获取实例

我们可以将一个属性声明为惰性注入:

module {
   // definition of Presenter
   factory { Presenter() }
}
class DetailActivity : AppCompatActivity() {

   // Lazy inject Presenter
   override val presenter : Presenter by inject()

   override fun onCreate(savedInstanceState: Bundle?) {
       //...
  }
}

或者我们可以直接得到一个实例:

override fun onCreate(savedInstanceState: Bundle?) {
   super.onCreate(savedInstanceState)

   // Retrieve a Presenter instance
   val presenter : Presenter = get()
}

注意:如果你的类没有扩展,只需添加 KoinComponent 接口,如果你需要或来自另一个类的实例。inject() get()

3.2 Android Context 使用

class MainApplication : Application() {

   override fun onCreate() {
       super.onCreate()

       startKoin {
           //inject Android context
           androidContext(this@MainApplication)
           // ...
      }

  }
}

在你的定义中,下面的函数允许你在 Koin 模块中获取实例,以帮助你简单地编写需要实例的表达式。androidContext() androidApplication() Context Application

val appModule = module {

  // create a Presenter instance with injection of R.string.mystring resources from Android
  factory {
      MyPresenter(androidContext().resources.getString(R.string.mystring))
  }
}

4. 用于 Android 的 DSL 构造函数

4.1 DSL 构造函数

Koin 现在提供了一种新的 DSL 关键字,允许您直接面向类构造函数,并避免在 lambda 表达式中键入您的定义。

对于 Android,这意味着以下新的构造函数 DSL 关键字:

viewModelOf()`- 相当于`viewModel { }
fragmentOf()`- 相当于`fragment { }
workerOf()`- 相当于`worker { }

注意:请务必在类名之前使用,以定位类构造函数::

4.2 Android DSL 函数示例

给定一个具有以下组件的 Android 应用程序:

// A simple service
class SimpleServiceImpl() : SimpleService

// a Presenter, using SimpleService and can receive "id" injected param
class FactoryPresenter(val id: String, val service: SimpleService)

// a ViewModel that can receive "id" injected param, use SimpleService and get SavedStateHandle
class SimpleViewModel(val id: String, val service: SimpleService, val handle: SavedStateHandle) : ViewModel()

// a scoped Session, that can received link to the MyActivity (from scope)
class Session(val activity: MyActivity)

// a Worker, using SimpleService and getting Context & WorkerParameters
class SimpleWorker(
private val simpleService: SimpleService,
appContext: Context,
private val params: WorkerParameters
) : CoroutineWorker(appContext, params)

我们可以这样声明它们:

module {
singleOf(::SimpleServiceImpl){ bind<SimpleService>() }

factoryOf(::FactoryPresenter)

viewModelOf(::SimpleViewModel)

scope<MyActivity>(){
scopedOf(::Session)
}

workerOf(::SimpleWorker)
}

5. Android 中的 koin 多模块使用

通过使用 Koin,您可以描述模块中的定义。在本节中,我们将了解如何声明,组织和链接模块。

5.1 koin 多模块

组件不必位于同一模块中。模块是帮助您组织定义的逻辑空间,并且可以依赖于其他定义 模块。定义是惰性的,然后仅在组件请求它时才解析。

让我们举个例子,链接的组件位于单独的模块中:

// ComponentB <- ComponentA
class ComponentA()
class ComponentB(val componentA : ComponentA)

val moduleA = module {
// Singleton ComponentA
single { ComponentA() }
}

val moduleB = module {
// Singleton ComponentB with linked instance ComponentA
single { ComponentB(get()) }
}

我们只需要在启动 Koin 容器时声明已使用模块的列表:

class MainApplication : Application() {

override fun onCreate() {
super.onCreate()

startKoin {
// ...

// Load modules
modules(moduleA, moduleB)
}

}
}

5.2 模块包含

类中提供了一个新函数,它允许您通过以有组织和结构化的方式包含其他模块来组合模块includes() Module

新模块有 2 个突出特点:

将大型模块拆分为更小、更集中的模块。

在模块化项目中,它允许您更精细地控制模块可见性(请参阅下面的示例)。

它是如何工作的?让我们采用一些模块,我们将模块包含在:parentModule

// `:feature` module
val childModule1 = module {
/* Other definitions here. */
}
val childModule2 = module {
/* Other definitions here. */
}
val parentModule = module {
includes(childModule1, childModule2)
}

// `:app` module
startKoin { modules(parentModule) }

请注意,我们不需要显式设置所有模块:通过包含,声明的所有模块将自动加载。

parentModule includes childModule1 childModule2 parentModule childModule1 childModule2

信息:模块加载现在经过优化,可以展平所有模块图,并避免重复的模块定义。

最后,您可以包含多个嵌套或重复的模块,Koin 将扁平化所有包含的模块,删除重复项:

// :feature module
val dataModule = module {
/* Other definitions here. */
}
val domainModule = module {
/* Other definitions here. */
}
val featureModule1 = module {
includes(domainModule, dataModule)
}
val featureModule2 = module {
includes(domainModule, dataModule)
}
// :app module
class MainApplication : Application() {

override fun onCreate() {
super.onCreate()

startKoin {
// ...

// Load modules
modules(featureModule1, featureModule2)
}

}
}

请注意,所有模块将只包含一次:dataModule domainModule featureModule1 featureModule2

5.3 Android ViewModel 和 Navigation

Gradle 模块引入了一个新的 DSL 关键字,该关键字作为补充,以帮助声明 ViewModel 组件并将其绑定到 Android 组件生命周期。关键字也可用允许您使用其构造函数声明 ViewModel。koin-android viewModel singlefactory viewModelOf

val appModule = module {

// ViewModel for Detail View
viewModel { DetailViewModel(get(), get()) }

// or directly with constructor
viewModelOf(::DetailViewModel)
}

声明的组件必须至少扩展类。您可以指定如何注入类的构造函数 并使用该函数注入依赖项。android.arch.lifecycle.ViewModel get()

注意:关键字有助于声明 ViewModel 的工厂实例。此实例将由内部 ViewModelFactory 处理,并在需要时重新附加 ViewModel 实例。它还将允许注入参数。viewModel viewModelOf

5.4 注入 ViewModel

在 Android 组件中使用 viewModel ,Activity Fragment Service

by viewModel()- 惰性委托属性,用于将视图模型注入到属性中

getViewModel()- 直接获取视图模型实例

class DetailActivity : AppCompatActivity() {

// Lazy inject ViewModel
val detailViewModel: DetailViewModel by viewModel()
}

5.5 Activity 共享 ViewModel

一个 ViewModel 实例可以在 Fragment 及其主 Activity 之间共享。

要在使用中注入共享视图模型,请执行以下操作:Fragment

by activityViewModel()- 惰性委托属性,用于将共享 viewModel 实例注入到属性中

get ActivityViewModel()- 直接获取共享 viewModel 实例

只需声明一次视图模型:

val weatherAppModule = module {

// WeatherViewModel declaration for Weather View components
viewModel { WeatherViewModel(get(), get()) }
}

注意:viewModel 的限定符将作为 viewModel 的标记处理

并在 Activity 和 Fragment 中重复使用它:

class WeatherActivity : AppCompatActivity() {

/*
* Declare WeatherViewModel with Koin and allow constructor dependency injection
*/
private val weatherViewModel by viewModel<WeatherViewModel>()
}

class WeatherHeaderFragment : Fragment() {

/*
* Declare shared WeatherViewModel with WeatherActivity
*/
private val weatherViewModel by activityViewModel<WeatherViewModel>()
}

class WeatherListFragment : Fragment() {

/*
* Declare shared WeatherViewModel with WeatherActivity
*/
private val weatherViewModel by activityViewModel<WeatherViewModel>()
}

5.6 将参数传递给构造函数

向 viewModel 传入参数,示例代码如下:

模块中

val appModule = module {

// ViewModel for Detail View with id as parameter injection
viewModel { parameters -> DetailViewModel(id = parameters.get(), get(), get()) }
// ViewModel for Detail View with id as parameter injection, resolved from graph
viewModel { DetailViewModel(get(), get(), get()) }
// or Constructor DSL
viewModelOf(::DetailViewModel)
}

依赖注入点传入参数

class DetailActivity : AppCompatActivity() {

val id : String // id of the view

// Lazy inject ViewModel with id parameter
val detailViewModel: DetailViewModel by viewModel{ parametersOf(id)}
}

5.7 SavedStateHandle 注入

添加键入到构造函数的新属性以处理 ViewModel 状态:SavedStateHandle

class MyStateVM(val handle: SavedStateHandle, val myService : MyService) : ViewModel() 在 Koin 模块中,只需使用或参数解析它:get()

viewModel { MyStateVM(get(), get()) } 或使用构造函数 DSL:

viewModelOf(::MyStateVM) 在 Activity Fragment

by viewModel()- 惰性委托属性,用于将状态视图模型实例注入属性

getViewModel()- 直接获取状态视图模型实例

class DetailActivity : AppCompatActivity() {

// MyStateVM viewModel injected with SavedStateHandle
val myStateVM: MyStateVM by viewModel()
}

5.8 Navigation 导航图中的 viewModel

您可以将 ViewModel 实例的范围限定为导航图。只需要传入 ID 给by koinNavGraphViewModel()

class NavFragment : Fragment() {

val mainViewModel: NavViewModel by koinNavGraphViewModel(R.id.my_graph)

}

5.9 viewModel 通用 API

Koin 提供了一些“底层”API 来直接调整您的 ViewModel 实例。viewModelForClass ComponentActivity Fragment

ComponentActivity.viewModelForClass(
clazz: KClass<T>,
qualifier: Qualifier? = null,
owner: ViewModelStoreOwner = this,
state: BundleDefinition? = null,
key: String? = null,
parameters: ParametersDefinition? = null,
): Lazy<T>

还提供了顶级函数:

fun <T : ViewModel> getLazyViewModelForClass(
clazz: KClass<T>,
owner: ViewModelStoreOwner,
scope: Scope = GlobalContext.get().scopeRegistry.rootScope,
qualifier: Qualifier? = null,
state: BundleDefinition? = null,
key: String? = null,
parameters: ParametersDefinition? = null,
): Lazy<T>

5.10 ViewModel API - Java Compat

必须将 Java 兼容性添加到依赖项中:

// Java Compatibility
implementation "io.insert-koin:koin-android-compat:$koin_version"
您可以使用以下函数或静态函数将 ViewModel 实例注入到 Java 代码库中:viewModel() getViewModel() ViewModelCompat

@JvmOverloads
@JvmStatic
@MainThread
fun <T : ViewModel> getViewModel(
owner: ViewModelStoreOwner,
clazz: Class<T>,
qualifier: Qualifier? = null,
parameters: ParametersDefinition? = null
)

6. 在 Jetpack Compose 中注入

请先了解 Jetpack Compose 相关内容:

developer.android.com/jetpack/com…

6.1 注入@Composable

在编写可组合函数时,您可以访问以下 Koin API:

get()- 从 Koin 容器中获取实例

getKoin()- 获取当前 Koin 实例

对于声明“MyService”组件的模块:

val androidModule = module {

single { MyService() }
}

我们可以像这样获取您的实例:

@Composable
fun App() {
val myService = get<MyService>()
}

注意:为了在 Jetpack Compose 的功能方面保持一致,最好的编写方法是将实例直接注入到函数属性中。这种方式允许使用 Koin 进行默认实现,但保持开放状态以根据需要注入实例。

@Composable
fun App(myService: MyService = get()) {
}

6.2 viewModel @Composable

与访问经典单/工厂实例的方式相同,您可以访问以下 Koin ViewModel API:

getViewModel()`或 - 获取实例`koinViewModel()

对于声明“MyViewModel”组件的模块:

module {
viewModel { MyViewModel() }
// or constructor DSL
viewModelOf(::MyViewModel)
}

我们可以像这样获取您的实例:

@Composable
fun App() {
val vm = koinViewModel<MyViewModel>()
}

我们可以在函数参数中获取您的实例:

@Composable
fun App(vm : MyViewModel = koinViewModel()) {

}

7. 管理 Android 作用域

Android 组件,如Activity、Fragment、Service都有生命周期,这些组件都是由 System 实例化,组件中有相应的生命周期回调。

正因为 Android 组件具有生命周期属性,所以不能在 koin 中传入组件实例。按照生命周期长短,组件可分为三类:

  • • 长周期组件(Service、database)——由多个屏幕使用,永不丢弃

  • • 中等周期组件(User session)——由多个屏幕使用,必须在一段时间后删除

  • • 短周期组件(ViewModel) ——仅由一个 Screen 使用,必须在 Screen 末尾删除

对于长周期组件,我们通常在应用全局使用 single 创建单实例

在 MVP 架构模式下,Presenter 是短周期组件

在 Activity 中创建方式如下

class DetailActivity : AppCompatActivity() {

// injected Presenter
override val presenter : Presenter by inject()

我们也可以在 module 中创建

我们使用 factory 作用域创建 Presenter 实例

val androidModule = module {

// Factory instance of Presenter
factory { Presenter() }
}

生成绑定到作用域的实例 scope

val androidModule = module {

scope<DetailActivity> {
scoped { Presenter() }
}
}

大多数 Android 内存泄漏来自从非 Android 组件引用 UI/Android 组件。系统保留引用在它上面,不能通过垃圾收集完全回收它。

7.1 申明 Android 作用域

要限定 Android 组件上的依赖关系,您必须使用如下所示的块声明一个作用域:scope

class MyPresenter()
class MyAdapter(val presenter : MyPresenter)

module {
// Declare scope for MyActivity
scope<MyActivity> {
// get MyPresenter instance from current scope
scoped { MyAdapter(get()) }
scoped { MyPresenter() }
}
}

7.2 Android Scope 类

Koin 提供了 Android 生命周期组件相关的 Scope 类ScopeActivity Retained ScopeActivity ScopeFragment

class MyActivity : ScopeActivity() {

// MyPresenter is resolved from MyActivity's scope
val presenter : MyPresenter by inject()
}

Android Scope 需要与接口一起使用来实现这样的字段:AndroidScopeComponent scope

abstract class ScopeActivity(
@LayoutRes contentLayoutId: Int = 0,
) : AppCompatActivity(contentLayoutId), AndroidScopeComponent {

override val scope: Scope by activityScope()

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

checkNotNull(scope)
}
}

我们需要使用接口并实现属性。这将设置类使用的默认 Scope。AndroidScopeComponent scope

7.3 Android Scope 接口

要创建绑定到 Android 组件的 Koin 作用域,只需使用以下函数:

createActivityScope()- 为当前 Activity 创建 Scope(必须声明 Scope 部分)

createActivityRetainedScope()- 为当前 Activity 创建 RetainedScope(由 ViewModel Lifecycle 支持)(必须声明 Scope 部分)

createFragmentScope()- 为当前 Fragment 创建 Scope 并链接到父 Activity Scope 这些函数可作为委托使用,以实现不同类型的作用域:

activityScope()- 为当前 Activity 创建 Scope(必须声明 Scope 部分)

activityRetainedScope()- 为当前 Activity 创建 RetainedScope(由 ViewModel Lifecycle 支持)(必须声明 Scope 部分)

fragmentScope()- 为当前 Fragment 创建 Scope 并链接到父 Activity Scope

class MyActivity() : AppCompatActivity(contentLayoutId), AndroidScopeComponent {

override val scope: Scope by activityScope()

}

我们还可以使用以下内容设置保留范围(由 ViewModel 生命周期提供支持):

class MyActivity() : AppCompatActivity(contentLayoutId), AndroidScopeComponent {

override val scope: Scope by activityRetainedScope()
}

如果您不想使用 Android Scope 类,则可以使用自己的类并使用 Scope 创建 API AndroidScopeComponent

7.4 Scope 链接

Scope 链接允许在具有自定义作用域的组件之间共享实例。在更广泛的用法中,您可以跨组件使用实例。例如,如果我们需要共享一个实例。Scope UserSession

首先声明一个范围定义:

module {
// Shared user session data
scope(named("session")) {
scoped { UserSession() }
}
}

当需要开始使用实例时,请为其创建范围:UserSession

val ourSession = getKoin().createScope("ourSession",named("session"))

// link ourSession scope to current `scope`, from ScopeActivity or ScopeFragment
scope.linkTo(ourSession)

然后在您需要的任何地方使用它:

class MyActivity1 : ScopeActivity() {

fun reuseSession(){
val ourSession = getKoin().createScope("ourSession",named("session"))

// link ourSession scope to current `scope`, from ScopeActivity or ScopeFragment
scope.linkTo(ourSession)

// will look at MyActivity1's Scope + ourSession scope to resolve
val userSession = get<UserSession>()
}
}
class MyActivity2 : ScopeActivity() {

fun reuseSession(){
val ourSession = getKoin().createScope("ourSession",named("session"))

// link ourSession scope to current `scope`, from ScopeActivity or ScopeFragment
scope.linkTo(ourSession)

// will look at MyActivity2's Scope + ourSession scope to resolve
val userSession = get<UserSession>()
}
}

8.Fragment Factory

由于 AndroidX 已经发布了软件包系列以扩展 Android 的功能 androidx.fragment Fragment

developer.android.com/jetpack/and…

8.1 Fragment Factory

自版本以来,已经引入了 ,一个专门用于创建类实例的类:2.1.0-alpha-3 FragmentFactory Fragment

developer.android.com/reference/k…

Koin 也提供了创建 Fragment 的工厂类 KoinFragmentFactory Fragment

8.2 设置 Fragment Factory

首先,在 KoinApplication 声明中,使用关键字设置默认实例:fragmentFactory() KoinFragmentFactory

 startKoin {
// setup a KoinFragmentFactory instance
fragmentFactory()

modules(...)
}

8.3 声明并注入 Fragment

声明一个 Fragment 并在 module 中注入

class MyFragment(val myService: MyService) : Fragment() {


}
val appModule = module {
single { MyService() }
fragment { MyFragment(get()) }
}

8.4 获取 Fragment

使用setupKoinFragmentFactory() 设置 FragmentFactory

查询您的 Fragment ,使用supportFragmentManager

supportFragmentManager.beginTransaction()
.replace<MyFragment>(R.id.mvvm_frame)
.commit()

加入可选参数

supportFragmentManager.beginTransaction()
.replace<MyFragment>(
containerViewId = R.id.mvvm_frame,
args = MyBundle(),
tag = MyString()
)

8.5 Fragment Factory & Koin Scopes

如果你想使用 Koin Activity Scope,你必须在你的 Scope 声明你的 Fragment 作为一个定义:scoped

val appModule = module {
scope<MyActivity> {
fragment { MyFragment(get()) }
}
}

并使用您的 Scope 设置您的 Koin Fragment Factory:setupKoinFragmentFactory(lifecycleScope)

class MyActivity : AppCompatActivity() {

override fun onCreate(savedInstanceState: Bundle?) {
// Koin Fragment Factory
setupKoinFragmentFactory(lifecycleScope)

super.onCreate(savedInstanceState)
//...
}
}

9. WorkManager 的 Koin 注入

koin 为 WorkManager 提供单独的组件包 koin-androidx-workmanager

首先,在 KoinApplication 声明中,使用关键字来设置自定义 WorkManager 实例:workManagerFactory()

class MainApplication : Application(), KoinComponent {

override fun onCreate() {
super.onCreate()
startKoin {
// setup a WorkManager instance
workManagerFactory()
modules(...)
}
setupWorkManagerFactory()
}

AndroidManifest.xml 修改,避免使用默认的

    <application . . .>
. . .
<provider
android:name="androidx.work.impl.WorkManagerInitializer"
android:authorities="${applicationId}.workmanager-init"
tools:node="remove" />
</application>

9.1 声明 ListenableWorker

val appModule = module {
single { MyService() }
worker { MyListenableWorker(get()) }
}

9.2 创建额外的 WorkManagerFactory

class MainApplication : Application(), KoinComponent {

override fun onCreate() {
super.onCreate()

startKoin {
workManagerFactory(workFactory1, workFactory2)
. . .
}

setupWorkManagerFactory()
}

}

如果 Koin 和 workFactory1 提供的 WorkManagerFactory 都可以实例化 ListenableWorker,则 Koin 提供的工厂将是使用的工厂。

9.3 更改 koin lib 本身的清单

如果 koin-androidx-workmanager 中的默认 Factory 被禁用,而应用程序开发人员不初始化 koin 的工作管理器基础架构,他最终将没有可用的工作管理器工厂。

针对上面的情况,我们做如下 DSL 改进:

val workerFactoryModule = module {
factory<WorkFactory> { WorkFactory1() }
factory<WorkFactory> { WorkFactory2() }
}

然后让 koin 内部做类似的事情

fun Application.setupWorkManagerFactory(
// no vararg for WorkerFactory
) {
. . .
getKoin().getAll<WorkerFactory>()
.forEach {
delegatingWorkerFactory.addFactory(it)
}
}

参考链接

insert-koin.io/

作者:Calvin873
来源:juejin.cn/post/7189917106580750395

收起阅读 »

码农如何提高自己的品味

前言软件研发工程师俗称程序员经常对业界外的人自谦作码农,一来给自己不菲的收入找个不错的说辞(像农民伯伯那样辛勤耕耘挣来的血汗钱),二来也是自嘲这个行业确实辛苦,辛苦得没时间捯饬,甚至没有驼背、脱发加持都说不过去。不过时间久了,行外人还真就相信了程序员就是一帮没...
继续阅读 »

前言

软件研发工程师俗称程序员经常对业界外的人自谦作码农,一来给自己不菲的收入找个不错的说辞(像农民伯伯那样辛勤耕耘挣来的血汗钱),二来也是自嘲这个行业确实辛苦,辛苦得没时间捯饬,甚至没有驼背、脱发加持都说不过去。不过时间久了,行外人还真就相信了程序员就是一帮没品味,木讷的low货,大部分的文艺作品中也都是这么表现程序员的。可是我今天要说一下我的感受,编程是个艺术活,程序员是最聪明的一群人,我们的品味也可以像艺术家一样。

言归正转,你是不是以为我今天要教你穿搭?不不不,这依然是一篇技术文章,想学穿搭女士学陈舒婷(《狂飙》中的大嫂),男士找陈舒婷那样的女朋友就好了。笔者今天教你怎样有“品味”的写代码。


以下几点可提升“品味”

说明:以下是笔者的经验之谈具有部分主观性,不赞同的欢迎拍砖,要想体系化提升编码功底建议读《XX公司Java编码规范》、《Effective Java》、《代码整洁之道》。以下几点部分具有通用性,部分仅限于java语言,其它语言的同学绕过即可。

优雅防重

关于成体系的防重讲解,笔者之后打算写一篇文章介绍,今天只讲一种优雅的方式:

如果你的业务场景满足以下两个条件:

1 业务接口重复调用的概率不是很高

2 入参有明确业务主键如:订单ID,商品ID,文章ID,运单ID等

在这种场景下,非常适合乐观防重,思路就是代码处理不主动做防重,只在监测到重复提交后做相应处理。

如何监测到重复提交呢?MySQL唯一索引 + org.springframework.dao.DuplicateKeyException

代码如下:

public int createContent(ContentOverviewEntity contentEntity) {
try{
return contentOverviewRepository.createContent(contentEntity);
}catch (DuplicateKeyException dke){
log.warn("repeat content:{}",contentEntity.toString());
}
return 0;
}

用好lambda表达式

lambda表达式已经是一个老生常谈的话题了,笔者认为,初级程序员向中级进阶的必经之路就是攻克lambda表达式,lambda表达式和面向对象编程是两个编程理念,《架构整洁之道》里曾提到有三种编程范式,结构化编程(面向过程编程)、面向对象编程、函数式编程。初次接触lambda表达式肯定特别不适应,但如果熟悉以后你将打开一个编程方式的新思路。本文不讲lambda,只讲如下例子:

比如你想把一个二维表数据进行分组,可采用以下一行代码实现

List<ActionAggregation> actAggs = ....
Map<String, List<ActionAggregation>> collect =
  actAggs.stream()
  .collect(Collectors.groupingBy(ActionAggregation :: containWoNosStr,LinkedHashMap::new,Collectors.toList()));

用好卫语句

各个大场的JAVA编程规范里基本都有这条建议,但我见过的代码里,把它用好的不多,卫语句对提升代码的可维护性有着很大的作用,想像一下,在一个10层if 缩进的接口里找代码逻辑是一件多么痛苦的事情,有人说,哪有10层的缩进啊,别说,笔者还真的在一个微服务里的一个核心接口看到了这种代码,该接口被过多的人接手导致了这样的局面。系统接手人过多以后,代码腐化的速度超出你的想像。

下面举例说明:

没有用卫语句的代码,很多层缩进

if (title.equals(newTitle)){
if (...) {
if (...) {
if (...) {

}
}else{

}
}else{

}
}

使用了卫语句的代码,缩进很少

if (!title.equals(newTitle)) {
return xxx;
}
if (...) {
return xxx;
}else{
return yyy;
}
if (...) {
return zzz;
}

避免双重循环

简单说双重循环会将代码逻辑的时间复杂度扩大至O(n^2)

如果有按key匹配两个列表的场景建议使用以下方式:

1 将列表1 进行map化

2 循环列表2,从map中获取值

代码示例如下:

List<WorkOrderChain> allPre = ...
List<WorkOrderChain> chains = ...
Map<String, WorkOrderChain> preMap = allPre.stream().collect(Collectors.toMap(WorkOrderChain::getWoNext, item -> item,(v1, v2)->v1));
chains.forEach(item->{
WorkOrderChain preWo = preMap.get(item.getWoNo());
if (preWo!=null){
item.setIsHead(1);
}else{
item.setIsHead(0);
}
});

@see @link来设计RPC的API

程序员们还经常自嘲的几个词有:API工程师,中间件装配工等,既然咱平时写API写的比较多,那种就把它写到极致@see @link的作用是让使用方可以方便的链接到枚举类型的对象上,方便阅读

示例如下:

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ContentProcessDto implements Serializable {
   /**
    * 内容ID
    */
   private String contentId;
   /**
    * @see com.jd.jr.community.common.enums.ContentTypeEnum
    */
   private Integer contentType;
   /**
    * @see com.jd.jr.community.common.enums.ContentQualityGradeEnum
    */
   private Integer qualityGrade;
}

日志打印避免只打整个参数

研发经常为了省事,直接将入参这样打印

log.info("operateRelationParam:{}", JSONObject.toJSONString(request));

该日志进了日志系统后,研发在搜索日志的时候,很难根据业务主键排查问题

如果改进成以下方式,便可方便的进行日志搜索

log.info("operateRelationParam,id:{},req:{}", request.getId(),JSONObject.toJSONString(request));

如上:只需要全词匹配“operateRelationParam,id:111”,即可找到业务主键111的业务日志。

用异常捕获替代方法参数传递

我们经常面对的一种情况是:从子方法中获取返回的值来标识程序接下来的走向,这种方式笔者认为不够优雅。

举例:以下代码paramCheck和deleteContent方法,返回了这两个方法的执行结果,调用方通过返回结果判断程序走向

public RpcResult<String> deleteContent(ContentOptDto contentOptDto) {
log.info("deleteContentParam:{}", contentOptDto.toString());
try{
RpcResult<?> paramCheckRet = this.paramCheck(contentOptDto);
if (paramCheckRet.isSgmFail()){
return RpcResult.getSgmFail("非法参数:"+paramCheckRet.getMsg());
}
ContentOverviewEntity contentEntity = DozerMapperUtil.map(contentOptDto,ContentOverviewEntity.class);
RpcResult<?> delRet = contentEventHandleAbility.deleteContent(contentEntity);
if (delRet.isSgmFail()){
return RpcResult.getSgmFail("业务处理异常:"+delRet.getMsg());
}
}catch (Exception e){
log.error("deleteContent exception:",e);
return RpcResult.getSgmFail("内部处理错误");
}
return RpcResult.getSgmSuccess();
}

我们可以通过自定义异常的方式解决:子方法抛出不同的异常,调用方catch不同异常以便进行不同逻辑的处理,这样调用方特别清爽,不必做返回结果判断

代码示例如下:

public RpcResult<String> deleteContent(ContentOptDto contentOptDto) {
log.info("deleteContentParam:{}", contentOptDto.toString());
try{
this.paramCheck(contentOptDto);
ContentOverviewEntity contentEntity = DozerMapperUtil.map(contentOptDto,ContentOverviewEntity.class);
contentEventHandleAbility.deleteContent(contentEntity);
}catch(IllegalStateException pe){
log.error("deleteContentParam error:"+pe.getMessage(),pe);
return RpcResult.getSgmFail("非法参数:"+pe.getMessage());
}catch(BusinessException be){
log.error("deleteContentBusiness error:"+be.getMessage(),be);
return RpcResult.getSgmFail("业务处理异常:"+be.getMessage());
}catch (Exception e){
log.error("deleteContent exception:",e);
return RpcResult.getSgmFail("内部处理错误");
}
return RpcResult.getSgmSuccess();
}

自定义SpringBoot的Banner

别再让你的Spring Boot启动banner千篇一律,spring 支持自定义banner,该技能对业务功能实现没任何卵用,但会给枯燥的编程生活添加一点乐趣。

以下是官方文档的说明: docs.spring.io/spring-boot…

另外你还需要ASCII艺术字生成工具: tools.kalvinbg.cn/txt/ascii

效果如下:

   _ _                   _                     _                 _       
(_|_)_ __ __ _ __| | ___ _ __ __ _ | |__ ___ ___ | |_ ___
| | | '_ \ / _` | / _` |/ _ \| '_ \ / _` | | '_ \ / _ \ / _ \| __/ __|
| | | | | | (_| | | (_| | (_) | | | | (_| | | |_) | (_) | (_) | |_\__ \
_/ |_|_| |_|\__, | \__,_|\___/|_| |_|\__, | |_.__/ \___/ \___/ \__|___/
|__/ |___/ |___/

多用Java语法糖

编程语言中java的语法是相对繁琐的,用过golang的或scala的人感觉特别明显。java提供了10多种语法糖,写代码常使用语法糖,给人一种 “这哥们java用得通透” 的感觉。

举例:try-with-resource语法,当一个外部资源的句柄对象实现了AutoCloseable接口,JDK7中便可以利用try-with-resource语法更优雅的关闭资源,消除板式代码。

try (FileInputStream inputStream = new FileInputStream(new File("test"))) {
System.out.println(inputStream.read());
} catch (IOException e) {
throw new RuntimeException(e.getMessage(), e);
}

利用链式编程

链式编程,也叫级联式编程,调用对象的函数时返回一个this对象指向对象本身,达到链式效果,可以级联调用。链式编程的优点是:编程性强、可读性强、代码简洁。

举例:假如觉得官方提供的容器不够方便,可以自定义,代码如下,但更建议使用开源的经过验证的类库如guava包中的工具类

/**
  链式map
*/
public class ChainMap<K,V> {
   private Map<K,V> innerMap = new HashMap<>();
   public V get(K key) {
       return innerMap.get(key);
  }

   public ChainMap<K,V> chainPut(K key, V value) {
       innerMap.put(key, value);
       return this;
  }

   public static void main(String[] args) {
       ChainMap<String,Object> chainMap = new ChainMap<>();
       chainMap.chainPut("a","1")
              .chainPut("b","2")
              .chainPut("c","3");
  }
}

作者:京东科技 文涛
来源:juejin.cn/post/7197604280705908793


收起阅读 »

心血来潮,这次我用代码“敲”木鱼

web
技术栈 面对这种寿命短,后期也基本不需要维护的项目(更没有复杂的网络请求一说),本篇文章直接使用原生JavaScript进行开发。或者您也可以尝试一下低代码 关于低代码,您大可放心的阅读此篇干货文章《低代码都做了什么?(为什么?怎么实现Low-Code?)》...
继续阅读 »

技术栈


面对这种寿命短,后期也基本不需要维护的项目(更没有复杂的网络请求一说),本篇文章直接使用原生JavaScript进行开发。或者您也可以尝试一下低代码



关于低代码,您大可放心的阅读此篇干货文章《低代码都做了什么?(为什么?怎么实现Low-Code?)》




至于TypeScript,您可以通过《谈谈写TypeScript实践而来的心得体会》这篇文章快速上手或进阶TS



实现


页面布局


wooden_fish_page_html.png


图中右侧标出了三个部分:



  1. img标签用于指定木鱼的图片url地址,在木鱼进行缩放时,对该标签增加/删除css类名即可

  2. 每次敲击时所产生的文字由p标签生成,且所有的p标签都存在于div标签之下

  3. audio标签会在敲击时播放声音



本篇文章不会涉及具体的Html、Css部分。如有疑问,请在此项目的GitHub中找到答案



逻辑部分


准备工作


通过JavaScript获取要操作的真实dom


const dom = {
// 木鱼
woodenFish: document.querySelector("img"),
// 文字浮层
text: document.querySelector(".w-f-c-text"),
// 音频
audio: document.querySelector("audio")
}
复制代码

木鱼缩放


这里的思路是敲击时给img追加一个带有css animation的样式类,该animation的作用是让木鱼进行一次缩放,例如


.w-f-c-i-size {
/** 这里的animation只会执行一次缩放,所以后面会通过增加/删除该类名来达到可以进行n次缩放的效果 */
animation: wooden-fish-size 0.3s;
}

@keyframes wooden-fish-size {
0% {
transform: scale(1);
}
50% {
transform: scale(0.9);
}
100% {
transform: scale(1);
}
}
复制代码

样式搞定之后,通过原生JavaScript提供的dom classList进行css样式类名的增加与删除。dom classList共有四个方法:



  1. add:在指定节点上增加一个样式类名

  2. remove:在指定节点上删除一个样式类名

  3. toggle:在指定节点A上若已有样式类名a,则将a删除;若没有样式类名a,则添加类名a

  4. replace:将指定节点上的样式类名替换为另一个样式类名。效果同String##replace一致


const woodenFish = {
// 封装一个用于增加/删除类名的方法
className(type) {
dom.woodenFish.classList[type]("w-f-c-i-size")
},
size() {
this.className("add")
setTimeout(() => this.className("remove"), 300)
}
}
复制代码

size方法用于进行一次木鱼的缩放。调用该方法时,首先为img标签增加类w-f-c-i-size,在300毫秒后,再将该类名移除


为什么是300毫秒?因为css animation的持续时间为300毫秒


需要注意的是,size方法中的thiswoodenFish对象,所以this.className就相当于woodenFish.className



关于this或其它JavaScript的问题,您可以在《JavaScript每日一题》专栏中找到对应的题目进行练习



文字浮层


const woodenFish = {
className() {},
size() {},
createText() {
const p = document.createElement("p")
p.innerText = "功德+1"
dom.text.appendChild(p)
}
}
复制代码

createText方法用于创建一个p标签,该标签的文字内容为“功德+1”,随后将该标签追加在div下即可


小tip:JSX(或react)中书写HTML类型的注释


博主在此刻书写document.createElement这个原生方法时,突然想到了最近用到的一个原生属性outerHTML,该属性与innerHTML的区别就不再赘述。在JSX中书写html类型的注释,使用大括号的形式({/** */})是不可以的,因为在编译时这些东西都会被扔掉,此时可以使用由React提供的dangerouslySetInnerHTML属性,但体验感不太好。所以可以使用ouertHTML配合ref来解决,vue同理,例如:


const HtmlComment: FC<HtmlCommentType> = ({ children }) => {
const virtual = useRef<HTMLSpanElement>(null)
useEffect(() => {
virtual.current!.outerHTML = `<!-- ${children} -->`
}, [])
return <span ref={virtual} />
}
复制代码

H5控制手机震动


const vibrate = () => {
const navigator = window.navigator
if (!("vibrate" in navigator)) return
navigator.vibrate =
navigator.vibrate ||
navigator.webkitVibrate ||
navigator.mozVibrate ||
navigator.msVibrate
if (!navigator.vibrate) return
// 上面的代码全是进行兼容性判断,只有下面这一行是发起手机震动的API
navigator.vibrate(300)
}
复制代码

像发起手机震动这类Api,首先就要进行兼容性判断,所以上面vibrate方法的90%部分都在进行兼容性判断。注意,window.navigator提供了一个用于发起设备震动的方法,即window.navigator.vibrate


window.navigator.vibrate方法的参数:



  1. 一个number类型的值


这种方式表示震动持续多长时间,例如window.navigator.vibrate(300),则表示震动持续300毫秒



  1. 一个number类型的数组


这种方式表示震动、暂停间隔的时间。例如window.navigator.vibrate([100, 30, 100]),则表示先震动100毫秒,随后暂停30毫秒,然后再震动100毫秒


window.navigator.vibrate方法在震动成功时返回true,否则返回false


vibrate兼容性


vibrate.png


浏览器全屏操作


const toggleFullScreen = () => {
if (!document.fullscreenElement)
return document.documentElement.requestFullscreen()
if (!document.exitFullscreen) return
document.exitFullscreen()
}

document.addEventListener("keydown", (e) =>
e.keyCode == 13 ? toggleFullScreen() : false
)
复制代码

toggleFullScreen方法会在全屏或非全屏之间来回切换,用到了以下属性/方法:



  1. document.fullscreenElement 返回当前正在以全屏模式显示的元素,如果没有,则返回null

  2. document.documentElement.requestFullscreen 用于发起全屏请求。若全屏请求成功,则该函数返回成功的Promise对象,否则返回失败的Promise对象。



在全屏成功时,全屏显示的元素会触发fullscreenchange事件;类似于输入框在输入时会触发onchange事件




  1. document.exitFullscreen 方法用于使当前元素退出全屏模式


随后为document绑定keydown事件,如果按下了回车键,则在全屏/非全屏之间切换,否则不做出任何操作


音频事件操作


之前博主在写播放器的时候就发现音频的属性、方法、事件很多很多,所以此处只列举两个本项目中用到的方法



  1. play()   使播放开始

  2. pause() 使播放暂停


制作完成


通过以上几个步骤就已经完成了所有要用到的东西,最后只需为木鱼注册“敲击”事件即可


dom.woodenFish.addEventListener("click", () => {
// 木鱼缩放
woodenFish.size()
// 创建文字浮层
woodenFish.createText()
// 播放敲击木鱼的声音
dom.audio.play()
// 发起手机震动
vibrate()
})
复制代码

文末


从一次心血来潮,到自己从0至1完成这个简单而有趣的小项目,无论是技术角度,还是个人收获角度来讲,都是收获满满!现在您可以通过以下两个地址来 “功德+1” :



  1. 在线使用地址

  2. GitHub地址,该仓库会持续制作一些package,欢迎Star !



由于时间匆忙,文中错误之处在所难免,敬请读者斧正。如果您觉得本篇文章还不错,欢迎点赞收藏和关注,我们下篇文章见!


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

z-index不生效?让我们来掀开它的面具

web
前言 hi大家好,我是小鱼,今天复习的是z-index。之前以为自己很了解它,可是在工作中总会遇到一些不思其解的问题,后来去深入学习了层叠上下文、层叠等级、层叠顺序,才发现z-index只是其中的一叶小舟,今天就一起来看看它背后到底隐藏着什么。 z-index...
继续阅读 »

前言


hi大家好,我是小鱼,今天复习的是z-index。之前以为自己很了解它,可是在工作中总会遇到一些不思其解的问题,后来去深入学习了层叠上下文层叠等级层叠顺序,才发现z-index只是其中的一叶小舟,今天就一起来看看它背后到底隐藏着什么。


z-index


.container {
z-index: auto | <integer> ;
}
复制代码


  • z-index 属性是允许给一个负值的。

  • z-index 属性支持 CSS3 animation 动画。

  • 在 CSS 2.1 的时候,需要配合 position 属性且值不为 static 时使用。


这个属性大家应该都很熟悉了,指定了元素及其子元素的 在 Z 轴上面的顺序,而 Z 轴上面的顺序 可以决定当元素发生覆盖的时候,哪个元素在上面。 z-index值大的元素会覆盖较低的。


不知道大家在工作中有没有遇到过这种情况,明明给其设置了z-index并且也设置了position不为static,但是样式并不是你所想的那样。可能这里大家对z-index不太了解,判断元素在Z轴上的顺序,不仅仅是z-index值的大小,接下来给大家解释层叠上下文层叠等级层叠顺序


image.png


层叠上下文


层叠上下文(stacking context),是HTML中一个三维的概念。如果一个元素含有层叠上下文,我们可以理解为这个元素在Z轴上就“高人一等”。



大家应该都玩过王者荣耀,里面的段位就是一个层级概念。你可以把「层叠上下文」理解为上了最强王者的人,还有很多没上王者的人,我们可以看成是菜鸡。那王者选手和菜鸡之间就形成了一个差距,这个差距也就是在Z轴上的距离,王者选手离荣耀王者就更近了一步,这里的“荣耀王者”可以看成是我们的屏幕观察者。



这样抽象解释完大家应该明白了什么是层叠上下文。继续往下看


层叠等级


层叠等级(stacking level),决定了同一个层叠上下文中元素在Z轴上的显示顺序。这里又牵扯出一个level,那么这个等级指的又是什么呢?


所有的元素都有层叠等级,包括层叠上下文元素,层叠上下文元素的层叠等级可以理解为是什么普通王者,无双,荣耀传奇之类。然后,对于普通元素的层叠等级,我们的探讨仅仅局限在当前层叠上下文元素中。为什么呢?因为否则没有意义。



还是回到王者荣耀,元素具有层叠上下文就相当于是王者段位,但是王者里面又分为普通王者,无双王者和荣耀王者还有传奇王者,那我们如果拿普通王者的韩信和传奇王者的韩信相比较实际上是没有意义的,那不吊打吗,那他牛不牛逼是由段位决定的(排除一些意外情况哈哈哈)。



层叠上下文的创建


说白了就是一个元素如何才能变成层叠上下文元素?


层叠上下文是由一些特点的CSS属性创建的,分为三点:



  1. 页面根元素天生具有层叠上下文,称之为“根层叠上下文”。

  2. 普通元素设置position属性为static值并设置z-index属性为具体数值,产生层叠上下文。

  3. 其他CSS3中的新属性也可以

    1. flex 容器的子元素,且 z-index 值不为 auto

    2. grid 容器的子元素,且 z-index 值不为 auto

    3. opacity 属性值小于 1 的元素

    4. transform 属性值不为 none 的元素

    5. filter 属性值不为 none 的元素

    6. isolation 属性值为 isolate 的元素

    7. -webkit-overflow-scrolling 属性值为 touch 的元素;




简单写两个例子


栗子一


.box1,.box2 {
position: relative;
width: 100px;
height: 100px;
}
.a,.c {
width: 100px;
height: 100px;
position: absolute;
font-size: 20px;
padding: 5px;
color: white;
border: 1px solid rgb(119, 119, 119);
}
.a {
background-color: rgb(0, 163, 168);
z-index: 1;
}
.c {
background-color: rgb(0, 168, 84);
z-index: 2;
left: 50px;
top: -50px;
}

<div class="box1">
<div class="a">A</div>
</div>
<div class="box2">
<div class="c">C</div>
</div>
复制代码

image.png



因为box1,box2都没有设置z-index,所以没有创建层叠上下文,所以其子元素都处于‘根层叠上下文’中,在同一个层叠上下文领域,层叠水平值大的那一个覆盖小的那一个。



栗子2


只帖了修改部分


.box1 {
z-index: 2;
}
.box2 {
z-index: 1;
}

.a {
background-color: rgb(0, 163, 168);
z-index: 1;
}
.b {
background-color: rgb(21, 84, 180);
z-index: 2;
left: 50px;
top: 50px;
}
.c {
background-color: rgb(0, 168, 84);
z-index: 999;
left: 100px;
top: 50px;
}
复制代码

image.png



大家可以发现我们给C盒子设置的z-index为999远大于A、B两个盒子,效果却出现在他俩下面。那是因为给两个父盒子分别设置了z-index,创建了两个不同的层叠上下文,而box1的z-index值大,所以排在上面,这里验证了层叠等级。



栗子3


有一个父元素绝对定位,它有一个子元素也是绝对定位,父元素z-index大于子元素z-index,为何子元素还是在父元素的上面?如何让这个子元素放在父元素的下面。


.parent {
width: 100%;
height: 500px;
background-color: rgb(243, 151, 45);
position: absolute;
z-index: 1;
}

.child {
width: 20%;
height: 150px;
background-color: rgb(211, 56, 56);
position: absolute;
z-index: 0;
}

<div class="parent">
<div class="child">C</div>
</div>
复制代码

效果却是这样


image.png


解决方案




  1. 因为父元素和子元素之间,z-index是无法对比的,同级之间的z-index才能对比。可以考虑换一种方式,两个div做同级,外面包一层父元素,根据共同的父元素定位、做层级区分就可以。




  2. 父元素不指定 z-index, 而子元素 z-index 为 -1




结论


普通元素的层叠等级优先由层叠上下文决定,所以,层叠等级的比较只有在当前层叠上下文元素中才有意义。


层叠顺序


层叠顺序(stacking order),表示元素发生层叠时候有着特定的垂直显示顺序,注意,这里跟上面两个不一样,上面的层叠上下文和层叠等级是概念,而这里的层叠顺序是规则


上图


image.png


在不考虑CSS3的情况下,当元素发生层叠时,层叠顺序遵循上面图中的规则。



这里稍微解释下为什么内联元素的层叠顺序要比浮动元素和块状元素都高?有些同学可能觉得浮动元素和块状元素要更屌一点,图中我标注了内联样式是内容,因为网页中最重要的是内容,文字和浮动图片的时候优先确保显示文字。



层叠准则



  1. 谁大谁上: 当具有明显层叠等级的时候,在同一个层叠上下文领域,z-indx大的那一个覆盖小的那一个。

  2. 后来居上: 当元素的层叠等级一致、层叠顺序相同的时候,在DOM流中处于后面的元素会覆盖前面的元素。


栗子4


.box1,.box2 {
position: relative;
width: 100px;
height: 100px;
}
.box1 {
z-index: 0;
}
.box2 {
z-index: 0;
}
.a,.c {
width: 100px;
height: 100px;
position: absolute;
font-size: 20px;
padding: 5px;
color: white;
border: 1px solid rgb(119, 119, 119);
}
.a {
background-color: rgb(0, 163, 168);
z-index: 999;
}
.c {
background-color: rgb(0, 168, 84);
z-index: 1;
left: 50px;
top: -50px;
}

<div class="box1">
<div class="a">A</div>
</div>
<div class="box2">
<div class="c">C</div>
</div>
复制代码

image.png



上面给两个父盒子都设置了z-index为0,这里要注意z-index一旦变成数值,哪怕是0,都会创建一个层叠上下文。当然层叠规则就发生了变化,子元素的层叠顺序比较变成了优先比较其父级的层叠上下文的层叠顺序,尽管a盒子的z-index为999。又由于两个父级都是z-index:0,层叠顺序这一块一样大,这个时候就遵循后来居上原则,根据DOM流中的位置决定谁在上面。也可以说子元素上面的z-index失效了!



end


回顾自己以前使用z-index都不太规范或者滥用,以后一定改正!


image.png


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

程序界鄙视链的终点

前言 不知道是大数据惹的祸,还是我的被迫害妄想症犯了,总是刷到一些哭笑不得的内行笑话系列,想反驳又觉得不该年轻气盛,憋了很久,还是觉得系统性的抒发一下,今天要聊的是关于程序界的鄙视链话题,各位老哥,如果有涉及程序语言部分,欢迎来杠。 主流鄙视链 语言     ...
继续阅读 »

前言


不知道是大数据惹的祸,还是我的被迫害妄想症犯了,总是刷到一些哭笑不得的内行笑话系列,想反驳又觉得不该年轻气盛,憋了很久,还是觉得系统性的抒发一下,今天要聊的是关于程序界的鄙视链话题,各位老哥,如果有涉及程序语言部分,欢迎来杠。


主流鄙视链


语言


    🍺鄙视链的话题由来已久也一直存在,原本只是体现在适用性和从业选择方向上,像是之前有游戏梦想的基本主攻C、C++、MFC、DirectX、MFC、 QT, C# 以PC市场为主,后来的网页应用市场php、VB.NET、perl、asp.net、jsp、flex、flash应用鼎盛时期也占据半壁江山,塞班系统,塞班开发,以及手机市场昙花一现的各种手机应用开发语言,数据库也从sqlserve,oracle,mysql感觉是一段时间之后才慢慢进入视野,mogodb,时序库InfluxDB等等,后来的JQ、node、glup、boostrap、H5、canvas、angular、react、vue,、icon、antd、elment-ui再到混合开发多端应用,再到Objective-C、python、Goland、rust、deno等等等等。


编译器


    🍺编译工具从TurboC、VC6、Dreamweaver、VS2005-VS2022、Eclipse、MyEclipse、idea、Android Studio、WebStorm、vscode、HBuilder X,编辑器换了好几轮。


个体挣扎


    🍺很难想象短短的10几年时间里,经历了这么多轮换血和语言转换,很多过了鼎盛期已经被淘汰,很多半死不过的存续着,相信很多从业者也经历过某个语言从生到死的过程,一直都秉持着技多不压身的准则,一常备、一学习、一了解,虽然很多人都在杠,那个语言更底层,那个语言常青藤,那个语言生命周期最长,入门最难,我能理解,从事某一个语言耕耘良久突然宣告没有市场那种失落感,但这就跟历史一样,有其发展规律,历史框架下的人,都是规律的适应者,并非一成不变的,语言的高度也因其活跃度,主流面临解决的问题相关,所以其实跟绝大多数从业者半毛钱关系都没有,我们也只是受益者,并不代表你的高度到了那个层级,语言鄙视的说法就好像登山的人在嘲笑下山的人,不置可否


上清下沉


    🍺在google还没离场,淘宝还没发家的前夜,微博、金山、PC端游还火爆,工具大神,搜狐还红的时候,还没有什么大厂、外包的提法,都是搞软件的,只是主攻方向不同,能成长能学习就行,公司好有些光环,解决问题是最重要的,后来,我听过一个理论,学历和大厂,至少能保证从业者是优质里面的顶尖部分,乍一听觉得没道理,后来想想,当面试那关的能力划等号,我是选硕士更充门脸还是选专科,用脚也能做出选择,长此以往的上清下沉,盘古开天,辅助以各种奇葩的企业文化,企业鄙视链的说法也就不足为奇了。


价值化


     🍺 “更好的值得更高的待遇”,工资待遇标签化,跟房子有了商业化属性一样,我比你拿的多,说明我方方面面碾压你,即使你不想被贴标签,也会被动的贴上标签,记得我从中型互联网转到传统企业时就被强制贴了一波标签,相信很多人摆平心态,也有这种无奈的体验,体验更差就是从出了名的外包场出来的,相信体感更差,如果你真的有计较,争论着低人一等,干同样的事儿,被区别对待,就跟秀才考功能,跟人攀比吃穿用度有什么差异。


乱象



  • 🍗 有大神在买课,20多岁的架构师、一问缘由,算上加班,工作10年,之前一直是把这个东西当作调侃,没想到有人正儿八经的说出来了,听说现在软件培训费用就要几万,比上个大学还贵,教人在线面试,美化简历等等乱想,“我能有啥害人的心思呢,我只是想帮你”,我只是看上了你荷包里面跳动的money。

  • 🍗 有人在孜孜不倦的教人python爬虫,“线上从入门到进去”,美化点的叫法叫数据采集、预处理,至于高端点儿的识别预测,算法类的东西,tensorflow一般人先不论你的机器跟不跟得上,学历已经卡出去大半人了,如果是测试自动化,稍微还好点儿,其他的真的就有点儿居心叵测了。

  • 🍗 前几年直播编程号称几天0观看,后几年突然就多了,我始终理解不了,看视频能学到啥东西,正儿八经,有目标的实现某个功能目标,不才是正途吗?不知道是不是我太肤浅了。

  • 🍗可能我不分端太久了,换了环境稍稍有点儿不适应,按理说,即使技术有语言有局限性,也不该分不清楚一些常规的状态码和逻辑主次关系,活脱脱完全限制了自己,把自己封印在了一个区域,这还是工作7-8年的,语言的多样性,会让我们的世界变的更大,当你不接受外部的内容,总耕耘在自己熟悉的领域,培养傲慢的同时,也会丧失敬畏。

  • 🍗我不清楚这是不是普遍现象,前端面试多数只会问技术,不会涉及到功能闭环和业务,面了好几个,可能做的事情比较边角,也不会去试图理解做某一个应用的含义,完整性闭合性都说不出来,难道面的姿势不对,没有把准备的东西发挥出来,一到业务就避而不谈或者就说只做功能不涉及到业务。

  • 🍗其后也莫名其妙面了报价30-40的,应该是30多,研究生,天然条件很好,其他的不论,只以面试论,我诧异的是,岗位属于业务擅长,着重点该在业务上,却神奇的写了一些技术,占了很大篇幅,问到具体的业务,条理分明的胡扯,或者涉密,问到技术又开始顾左右而言他。

  • 🍗再有就是我很难相信,一个面试时综合能力还可以的人,业务能力为0的情况,可能王者天生爱执行吧。

  • 🍗以上并不针对个人,只是想说明,做软件,很多人其实只是把它当作糊口的工具,本身其实并不喜欢这份工作,只是恰好工资相对较高,而且每个人对技术的追求分阶段不同,想法认知不同,很多情况要学会保留意见停止争论,待认知线在同一水准后,再适时决定,程序做久了要适当的学会拐弯,不然人为的屏障会越来越让你放弃沟通交流。


我的经历


接触


    🍺细算下来我最早涉及到编程接触的第一门语言是java,那会刚考上大学,得知被调剂到了软件,无所事事跑到网吧了解了一哈啥是编程,跑了个java计算器的例子,第一次有种掌控的感觉,也许这就是编程带来的魅力之一,掌控感,后来上学微机原理,TurboC 输出了第一个程序标配Hello World, 我记得看过一段话,一笔一划码出一个世界,我想我原本应该就是热爱编程的,爱泡图书馆看些软件杂书,记得因为上课在看机器人人工智能算法,被老师注意到,莫名其妙的神经网络BP,从C,C++,C#薅了三遍,后面连带又薅了一波人工智能动态寻路directx渲染的规避,最终没能成功去做游戏,感觉血亏。


过程


    🍺其后的工作经历之前也又提到过,无非就是遇山开山遇水开河,值得骄傲的是从来没因工作的地狱级难度退缩过,正儿八经外头的私活也整了又10年左右了,可能驳杂的技术体系也缘于此,心态比较重要,只要是能成长的都可以去学,熟悉的多了,就不会有恐惧感,我的很多技能点都属于外部创新,工作深挖实践过来的,信心需要培养,不知道你有没有这种中二的经历,每次解决一个疑难杂症,我总是不由自主的喊出来 “我TN真是个天才”,乐此不疲,也许这就是别人说的掌控感。


接触


    🍺我看到很多人在说在中国不过20年,没看到过35岁之后还搞程序的,我本能的忽略了年龄这个问题,其实之前我确确实实看到过一个老哥60岁了,还在搞C++,烟瘾特别大,几乎很短实践就搞出了包含算法预处理的专业软件,当时可能还在自我膨胀中,没有意识到这项工作从0-1的难度有好大,之后也和一个60岁的老哥相处过一段,可能是年龄大了,有些不受招呼,风评不咋好,一块聊过一段,给我们讲了他的当年,合伙创业,失败就业,总之也是波澜壮阔,还有之前我们的总监,40多了长得跟个20多岁的人一样,为人随和,可能相处下来,感受不到年龄的隔阂,给我一种感觉,大家都差不多,提笔回顾,恍惚之间才意识到,当然现在特别是今年,经济不好,再加上各种企业文化,我对我能持续多久有过担忧,但尽最大的努力,留最小的遗憾,是我一直以来,对事儿的态度,如果沉浸在焦虑中,会错过很多风景,反而是在焦虑中浪费了时光.


▨▨▨没什么具体的该怎么做,只能说,适当的多放下身段,多听听周围不同岗位的人对实现具体某一件事情,别人的认知和评判是怎样的,和自己的认知背离是什么原因造成的,自己的原因多补充相关知识,别人的原因多吸取经验教训,如果同一件事情,自己认为很难,充满抱怨,别人觉得简单,思路清晰的解决了问题,该是你充分学习经验的时候


悟道


    🍺戾气重的环境,让我们忘记了回溯,忘记了思考,很多的事情本能的忽略,软件"工具人"的称呼我并不排斥,但之前看贴的时候,看到很多人对这个称谓很不忿,觉得很恶心,但本质上,外包、中型厂、大厂“研发资源”的叫法会更好听吗?不是别人怎么叫,而是我们要认清不足,继续抵足前行,外部的杂音不足挂齿,内心的修炼与自身能力的强大才是我们该争取的,不想当将军的士兵,必然成不了将军,但想当将军的士兵,最终不一定会成为将军,只能说,行进的策略一直让我们时刻准备,时刻充实着,可能这是精神充实的一种“信仰”,但这不妨碍我时刻划定标准在进步着,所以忙着和别人攀比比较有什么意义呢,相较于环境与别人,改变自己才是最容易的吧。


原因刨析


💪关于大厂小厂之前一番讨论:



Me:事实上、有个很严重的分歧点在于,小厂更注重的是全面性,巴不得你从业务、前后端、框架、学习能力、设计能力、甚至商务以及交付能力都具备。往往从技术到支持都是考虑最低成本实现的,需要很强的灵活性和变通能力,而且很多业务都是在软件能力之上有行业经验要求的、所以降工资是一方面,还得适应变态的差异化开发习惯、

另外前段时间面试的时候发现个问题,纯前端有个很严重的弊端,最接近业务,却最不了解业务、问业务都不了解或者说不清楚闭环、

还有就是即便是技术专家、普遍的诉求其实当下不是开拓性市场、屠龙技需要平台才施展的开




前端早早聊:很有道理,大厂面试你的屠龙技,进去后拧 180 米长的复杂螺丝,不好拧,小厂面试你的螺丝功,进去后要求你用屠龙技,一个人全套搞定空间站,全能全干,两边点亮的技能点大有不同,需要的心态也大大不同



💪鄙视链的问题


语言鄙视


    很多讨论其实集中在语言的入门难易度,应用层级的问题,其实跟用这门语言的人关系不大,最接近的关系我能一直用这门语言存续多久,也就是我的语言技能会不会随着实践继续升值。
后端、前端的问题,这个本质是技术局限性引发的,很多事情不去做,只是评价的话,这和你嘲讽搞PPT的人,外行指导内行有什么差别。


年龄鄙视


    之前看到怪谈,通过不写注释,故意错乱结构来提高自己的存在价值,就事论事,能力是能力的问题,有些行为准则是人的问题,好多论调在说过了35岁,谁还需要去投简历,投简历的都是能力不行,还有别人已经挣够了,讲真的,靠打工致富毕竟是少数,都是机缘巧合,绝大部分人还是该忧虑就忧虑,"农民想象当皇帝用金锄头",放开眼界,总有不一样的精彩。


学历鄙视


    早先的一段面试经历,感觉有震撼到我,我没想到还有公司会这么玩,找相关领域的开源作者挨个打电话,他们找到了一位开源作者,当时面我的作者也体验了一把被标签化,他说过一段 “语言只是工具,以实现功能为目的” ,听人力小姐姐介绍情况说,这个开源作者的神奇经历,高中辍学,一直是自由开发者,看了开源内容,质量很高,起点可能比很多人要差,但通过另外一种名片找到了归属,所以能力是真的会闪光,贵在坚持,至于卡学历等等的境遇,那也只说明你和这家公司的八字不合、换家便是。


技术鄙视


    大到社会,小到公司,我们都是职能链上被需要的,很多技术经验丰富的去做架构设计,但厌恶循环往复的业务调整,很多对工作推进执行做的很好的,却没法理解架构设计中一些“脱裤子放屁”的举动,团队中成员可以被替换,但职能分工是必须的,难不成要搞一堆技术大佬天天干仗不成。


待遇鄙视


    我们要为自己的选择负责,最终选定的工作,要么因为待遇高、要么因为压力小,如果你不慎踩坑,实在无法适应,多了解了解别人坚持下去的动机是啥、看到很多在抱怨“死都不去外包,侮辱人格,低人一等”,多想想能力和待遇插值,再有就是精神压力等等之类的,也比抱怨来的实在,大厂诉说着各种福利待遇,至于最终是其内里的红线、精神压力和健康付出状况,各种技术成长之类的,若真剔除自身的向上进取,于工作层面真有那么多高端的技术需要你去钻营嘛,就稳定性而言,我反而觉得大厂是最不受控的,因为真无关你的价值和能力,所以我觉得这个问题应该论证着看,并没有绝对的定性。


你的追求是什么?


    我曾梦想着用代码改变世界,结果我改变了我的代码,我梦想竹杖芒鞋轻胜马,谁怕?一蓑烟雨任平生快意恩仇,潇洒江湖,结果只能护住身前一尺一个家。我梦想达则兼济天下,穷则独善其身,结果我依然穷着,却做不到独善其身,事到如今,我还是会经常想起我的梦想,却也不愤恨自己平凡的半生,无非是,我做着自己喜欢做的事情,这个事情恰巧又是我的工作,我用它支撑着我弱不惊风的家,仅此而已,但也不仅限于此,至少我还在我代码的江湖,追逐着...


结束吧


    有点儿跑题了,最近实在是看到了很多怪像,希望留下你的经历,形成讨论,便于形成良性的参考价值,期待你的加入!!


PPS


    本来吐槽居多,后来枚举语言更替的时候,忽然觉得,历经这么多变迁,每个挣扎着的程序员,其实也在无奈中成就了平凡的伟大,心态开阔,多点儿包容!!!


作者:沈二到不行
来源:https://juejin.cn/post/7129868233900818468
收起阅读 »

为什么有公司规定所有接口都用Post?

web
看到这个标题,你肯定觉得离谱。怎么会有公司规定所有接口都用Post,是架构菜还是开发菜。这可不是夸大其词,这样的公司不少。 在特定的情况下,规定使用Post可以减少不少的麻烦,一起看看。 Answer the question 我们都知道,get请求一半用来获...
继续阅读 »

看到这个标题,你肯定觉得离谱。怎么会有公司规定所有接口都用Post,是架构菜还是开发菜。这可不是夸大其词,这样的公司不少。


在特定的情况下,规定使用Post可以减少不少的麻烦,一起看看。


Answer the question


我们都知道,get请求一半用来获取服务器信息,post一般用来更新信息。get请求能做的,post都能做,get请求不能做的,post也都能做。


如果你的团队都是大佬,或者有着良好的团队规范,所有人都在平均水平线之上,并且有良好的纠错机制,那基本不会制定这样的规则。


但如果团队成员水平参差不齐,尤其是小团队,创业团队,常常上来就开干,没什么规范,纯靠开发者个人素质决定代码质量,这样的团队就不得不制定这样的规范。


毕竟可以减少非常多的问题,Post不用担心URL长度限制,也不会误用缓存。通过一个规则减少了出错的可能,这个决策性价比极高。


造成的结果:公司有新人进来,什么lj公司,还有这种要求,回去就在群里讲段子。


实际上都是有原因的。


有些外包公司或者提供第三方接口的公司也会选择只用Post,就是图个方便。


最佳实践


可能各位大佬都懂了哈,我还是给大家科普下,GET、POST、PUT、DELETE,他们的区别和用法。


GET


GET 方法用于从服务器检索数据。这是一种只读方法,因此它没有改变或损坏数据的风险,使用 GET 的请求应该只被用于获取数据。


GET API 是幂等的。 每次发出多个相同的请求都必须产生相同的结果,直到另一个 API(POST 或 PUT)更改了服务器上资源的状态。


POST


POST 方法用于将实体提交到指定的资源,通常导致在服务器上的状态变化或创建新资源。POST既不安全也不幂等,调用两个相同的 POST 请求将导致两个不同的资源包含相同的信息(资源 ID 除外)。


PUT


主要使用 PUT API更新现有资源(如果资源不存在,则 API 可能决定是否创建新资源)。


DELETE


DELETE 方法删除指定的资源。DELETE 操作是幂等的。如果您删除一个资源,它会从资源集合中删除。
























































GETPOSTPUTDELETE
请求是否有主体可以有
成功的响应是否有主体可以有
安全
幂等
可缓存
HTML表单是否支持

作者:正经程序员
来源:https://juejin.cn/post/7129685508589879327
收起阅读 »

异步阻塞IO是什么鬼?

web
这篇文章我们来聊一个很简单,但是很多人往往分不清的一个问题,同步异步、阻塞非阻塞到底怎么区分? 开篇先问大家一个问题:IO多路复用是同步IO还是异步IO? 先思考一下,再继续往下读。 巨著《Unix网络编程》将IO模型划分为5种,分别是 阻塞IO 非阻塞I...
继续阅读 »

这篇文章我们来聊一个很简单,但是很多人往往分不清的一个问题,同步异步、阻塞非阻塞到底怎么区分?


开篇先问大家一个问题:IO多路复用是同步IO还是异步IO


先思考一下,再继续往下读。




巨著《Unix网络编程》将IO模型划分为5种,分别是



  • 阻塞IO

  • 非阻塞IO

  • IO复用

  • 信号驱动IO

  • 异步IO


个人认为这么分类并不是很好,因为从字面上理解阻塞IO和阻塞IO就已经是数学意义上的全集了,怎么又冒出了后边3种模型,会给初学者带来一些困扰。


接下来进入正文。



文章首发于公众号:「蝉沐风的码场」



1. 一个简单的IO流程


让我们先摒弃我们原本熟知的各种IO模型流程图,先看一个非常简单的IO流程,不涉及任何阻塞非阻塞、同步异步概念的图。


IO流程


客户端发起系统调用之后,内核的操作可以被分成两步:




  • 等待数据


    此阶段网络数据进入网卡,然后网卡将数据放到指定的内存位置,此过程CPU无感知。然后经过网卡发起硬中断,再经过软中断,内核线程将数据发送到socket的内核缓冲区中。




  • 数据拷贝


    数据从socket的内核缓冲区拷贝到用户空间




2. 阻塞与非阻塞


阻塞与非阻塞在API上区别在于socket是否设置了SOCK_NONBLOCK这个参数,默认情况下是阻塞的,设置了该参数则为非阻塞。



2.1 阻塞


假设socket为阻塞模式,则IO调用如下图所示。


阻塞示意图


当处于运行状态的用户线程发起recv系统调用时,如果socket内核缓冲区内没有数据,则内核会将当前线程投入睡眠,让出CPU的占用。


直到网络数据到达网卡,网卡DMA数据到内存,再经过硬中断、软中断,由内核线程唤醒用户线程。


此时socket的数据已经准备就绪,用户线程由用户态进入到内核态,执行数据拷贝,将数据从内核空间拷贝到用户空间,系统调用结束。此阶段,开发者通常认为用户线程处于等待(称为阻塞也行)状态,因为在用户态的角度上,线程确实啥也没干(虽然在内核态干得累死累活)。


2.2 非阻塞


如果将socket设置为非阻塞模式,调用便换了一副光景。


非阻塞示意图


用户线程发起系统调用,如果socket内核缓冲区中没有数据,则系统调用立即返回,不会挂起线程。而线程会继续轮询,直到socket内核缓冲区内有数据为止。


如果socket内核缓冲区内有数据,则用户线程进入内核态,将数据从内核空间拷贝到用户空间,这一步和2.1小节没有区别。


3. 同步与异步


同步异步主要看请求发起方对消息结果的获取方式,是主动获取还是被动通知。区别主要体现在数据拷贝阶段。


3.1 同步


同步我们其实已经见识过了,2.1节和2.2节中的数据拷贝阶段其实都是同步!



注:把同步的流程画在阻塞和非阻塞的第二阶段,并不是说阻塞和非阻塞的第二阶段只能搭配同步手段!



同步指的是数据到达socket内核缓冲区之后,由用户线程参与到数据拷贝过程中,直到数据从内核空间拷贝到用户空间。


因此,IO多路复用,对于应用程序而言,仍然只能算是一种同步,因为应用程序仍然花费时间等待IO结果,等待期间CPU要么用于遍历文件描述符的状态,要么用于休眠等待事件发生。


select为例,用户线程发起select调用,会切换到内核空间,如果没有数据准备就绪,则用户线程阻塞到有数据来为止,select调用结束。结束之后用户线程获取到的只是「内核中有N个socket已经就绪」的这么一个信息,还需要用户线程对着1024长度的描述符数组进行遍历,才能获取到socket中的数据,这就是同步。


举个生活中的例子,我们给物流客服打电话询问我们的包裹是否已到达,如果未到达,我们就先睡一会儿,等到了之后客服给我们打电话把我们喊起来,然后我们屁颠屁颠地去快递驿站拿快递。这就是同步阻塞。


如果我们不想睡,就一直打电话问,直到包裹到了为止,然后再屁颠屁颠地去快递驿站拿快递。这就是同步非阻塞。


问题就是,能不能直接让物流的人把快递直接送到我家,别让我自己去拿啊!这就是异步。


3.2 理想的异步


我们理想中的完美异步应该是用户进程发起非阻塞调用,内核直接返回结果之后,用户线程可以立即处理下一个任务,只需要IO完成之后通过信号或回调函数的方式将数据传递给用户线程。如下图所示。


理想的异步IO


因此,在理想的异步环境下,数据准备阶段和数据拷贝阶段都是由内核完成的,不会对用户线程进行阻塞,这种内核级别的改进自然需要操作系统底层的功能支持。


3.3 现实的异步


现实比理想要骨感一些。


Linux内核并没有太惹眼的异步IO机制,这难不倒各路大神,比如Node的作者采用多线程模拟了这种异步效果。


比如让某个主线程执行主要的非IO逻辑操作,另外再起多个专门用于IO操作的线程,让IO线程进行阻塞IO或者非阻塞IO加轮询的方式来完成数据获取,通过IO线程和主线程之间通信进行数据传递,以此来实现异步。


多线程模拟异步


还有一种方案是Windows上的IOCP,它在某种程度上提供了理想的异步,其内部依然采用的是多线程的原理,不过是内核级别的多线程。


遗憾的是,用Windows做服务器的项目并不是特别多,期待Linux在异步的领域上取得更大的进步吧。


4. 异步阻塞?


说完了同步异步、阻塞非阻塞,一个很自然的操作就是对他们进行排列组合。



  • 同步阻塞

  • 同步非阻塞

  • 异步非阻塞

  • 异步阻塞


但是异步阻塞是什么鬼?按照上文的解释,该IO模型在第一阶段应该是用户线程阻塞,等待数据;第二阶段应该是内核线程(或专门的IO线程)处理IO操作,然后把数据通过事件或者回调的方式通知用户线程,既然如此,那么第一步的阻塞完全没有必要啊!非阻塞调用,然后继续处理其他任务岂不是更好。


因此,压根不存在异步阻塞这种模型哦


5. 千万分清主语是谁


最后给各位提个醒,和别人讨论阻塞非阻塞的时候千万要带上主语。


如果我问你,epoll是阻塞还是非阻塞?你怎么回答?


应该说,epoll_wait这个函数本身是阻塞的,但是epoll会将socket设置为非阻塞。因此单纯把epoll认为阻塞是太委屈它,认为其是非阻塞又抬举它。


具体关于epoll的说明可以参见IO多路复用中的epoll部分。


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

不惑之年谈中年危机

今年正式进入不惑之年。按 2011 年统计数据,国内 40 岁以上的程序员约是 1.5%,乐观来看,我进入了前 5% 的群体。 美国 2016 年此比例已经有 12.6%,大家还是应该乐观点。 大家都知道的国内第一代程序员,求伯君/雷军已经退休或做 CEO 了...
继续阅读 »

今年正式进入不惑之年。按 2011 年统计数据,国内 40 岁以上的程序员约是 1.5%,乐观来看,我进入了前 5% 的群体。


美国 2016 年此比例已经有 12.6%,大家还是应该乐观点。


大家都知道的国内第一代程序员,求伯君/雷军已经退休或做 CEO 了,而目前还在活跃的骨灰级程序员有陈皓(左耳朵耗子)。估计之后应该也会有越来越多的老程序员,或者说是目前活跃的程序员变成老程序员,出现在大家的视野。


骨灰级程序员:陈皓(左耳朵耗子)


程序员这个职业发展起来也就 20 年,还是一个很年轻的职业,年龄焦虑这个事情着实没有必要。


换位思考一下,如果我是公司的技术招聘者,15年内工作经验的都是大有前途的,只要你技术过得去,价格合理,我都愿意招你。


相反,15年以上的程序员就没那么受欢迎了。


因为初入职场就是互联网的蓬勃发展期,人才短缺是一直存在的,知识积累不够不要紧,个人品行不妥也不要紧,只要你敢于承担一些压力,那你的职业道路都会比较顺畅。如果再幸运一些,就能进入大厂。


升职加薪,迎娶白富美,走上人生巅峰


但是,这也造成了一些不良的现象,在这行业,投机份子其实挺多的。什么热门就跟风炒一炒,能不能做成不要紧,最主要是自己的 KPI 好看。另外,互联网企业的优待,让他们多少有些娇气。从大厂出来,薪资待遇要翻倍,要股票要期权,要好资源要好项目。


所以,一般的公司未必能容纳这些人。他们也未必愿意去这些公司。于是,35岁危机就来了。


35岁危机


年轻人其实是很难感受到中年危机的,中年危机与年轻失业的区别就像新冠与感冒的区别。你以为你经历了中年危机,实际上只是年轻失业。


今年经济不景气,裁员潮估计让一部分人离开了这个行业。年纪大的想要回炉再造,是很难的。但是,如果你还年轻,平时多积累,我相信能很轻松地找到一份工作的。


如何面对危机?


年轻人都会说苟住,换个正能量的说法是活在当下。


读好书、做好事,是能切切实实忘掉焦虑的。


今年读的《反脆弱》、《心流》和《毫无意义的工作》,将这三本书放在一起,还是很有意思的,能看到不同的观点:


jpg.jpg




  • 《反脆弱》让我对工作也有了新认识。看似很稳定的工作,会有可能让你过度依赖,如果遭遇失业就手足无措了。而类似的士司机,饿一餐饱一顿的,反而平时就很有充足的经验应对收入不稳定的情况。


    今年的形势让人更趋于进入大公司、国企、公务员单位,然后这些稳定的工作就真的这么值得大家去追随吗?越是追求稳定,最后是否会适得其反?




  • 《毫无意义的工作》今年敲醒了不少人,他提醒我们,日常琐碎的工作中消磨了我们的生命。然而这本书更多是情绪的宣泄,并没有什么好的解决方法。




  • 而《心流》则希望我们投入去做事情,只有投入了才会获得心流,才会有幸福感。同时,它让我意识到,无法逃避的事实是,工作占了我们生命的 1/4 时间。如果无法从中获得乐趣,那我们的人生注定是悲剧的。




这几本书都让我重新思考我与工作的关系,即使今年外部恶劣的情况,我们也应该重拾自身的信心,重新找回我们的热情、专注。


在面对人员缩减,项目被砍的情况下,我们也许可以把目光放在现有的项目上。


就前端而言,SSR 做不了,Docker 做不了,那就看看 nginx 缓存优化能不能做;低代码做不了,那就看看页面模板能不能做;开源做不了,就看看公共组件能不能做;什么都做不了,那首屏优化,静态资源优化,图片压缩也是能做的,而且还能做得很深。


R-C (1).jpg


只要你想,总有做不完的事情。并且这些事情,其实是我们要还的技术债务。


而此时也是做好技术积累的时机。


面向对象、设计模式、函数式编程、类型编程、异步编程这些基础都可以恶补一下;网络安全、网络通信、内存、CPU等等向外延伸的各类计算机知识也是我们必须掌握的。


工作上认真对待自己的每一行代码,生活中认真对待自己的每一分钱。


深挖知识,深入研究,懂得越多,焦虑自然就越少。


有足够的知识与经验,你的中年危机也许永远不会来


最后,对于年龄的焦虑,再推荐大家看看方励老师在“一席”的演讲《即使是像我这把年龄的人,好奇心也从来没变过,因为我们还活在人间的》


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

体验一下可以帮你写代码、写邮件、编故事的神器ChatGPT

注册账号视频教程 最近OpenAI 发布了备受期待的原型通用 ChatGPT,这是一种基于对话的 AI 聊天界面,算是GPT-3(Generative  Pre-trained Transformer 3)的继承者,今天记录一下体验的过程,以前我对AI的理解总...
继续阅读 »

注册账号视频教程
最近OpenAI 发布了备受期待的原型通用 ChatGPT,这是一种基于对话的 AI 聊天界面,算是GPT-3(Generative  Pre-trained Transformer 3)的继承者,今天记录一下体验的过程,以前我对AI的理解总感觉离我很远,现在我好像看到了未来


ChatGPT 几乎可以做任何事情,从写邮件,到写算法,到做面试题,写个歌,帮你编周报,甚至还能帮你写自动化测试,感觉可以取代stackoverflow了


欢迎加我,畅聊前端


1. 工作助手


首先感觉有用的就是帮忙写工作中的邮件 or github的issue,文笔比我自己写的好多了



给我写一个英语商务邮件,语法优雅,友好一些,邮件内容是:下周一早晨我请大壮吃鸡蛋煎饼,顺便邀请开个早会



image.png


代码助手



how to write a login form using React ,Typescript and useReducer?



下面是他生成的代码


import React, { useReducer } from 'react'

type State = {
username: string
password: string
error: string
}

type Action =
| { type: 'SET_USERNAME'; username: string }
| { type: 'SET_PASSWORD'; password: string }
| { type: 'SET_ERROR'; error: string }

function reducer(state: State, action: Action): State {
switch (action.type) {
case 'SET_USERNAME':
return { ...state, username: action.username }
case 'SET_PASSWORD':
return { ...state, password: action.password }
case 'SET_ERROR':
return { ...state, error: action.error }
default:
return state
}
}

function LoginForm() {
const [state, dispatch] = useReducer(reducer, {
username: '',
password: '',
error: '',
})

function handleUsernameChange(event: React.ChangeEvent) {
dispatch({ type: 'SET_USERNAME', username: event.target.value })
}

function handlePasswordChange(event: React.ChangeEvent) {
dispatch({ type: 'SET_PASSWORD', password: event.target.value })
}

function handleSubmit(


一个解析页面script标签内容的正则表达式



比我写的好


image.png



leetcode第一题twosum用javascript怎么做



甚至还有复杂度的优化过程


image.png



write a eslint plugin to forbidden code writing at weekends



还有vscode插件,chrome插件


image.png


面试



出五个高级前端工程师的面试题




来两道算法题
算法还给了答案,真不错



image.png


image.png


Vue代码转成React


把这段Vue3代码转换成react





转换结果, 可以直接运行的


import React, { useState } from 'react'

function TodoList() {
const [msg, setMsg] = useState('Hello World!')
const [todos, setTodos] = useState(['吃饭'])

function add() {
setTodos([...todos, msg])
setMsg('')
}

return (

setMsg(event.target.value)} />


    {todos.map(todo => (
  • {todo}

  • ))}


)
}

然后我把生成的react代码转成angular,在转成vue3,依然是很好的代码质量






自动写单测


甚至可以帮你写测试, 比如给上一段vue代码写点单元测试,给跪了


image.png


Code review


设置还可以代码review, 提示我清单输入框要做非空判断


image.png


写代码


用vue 的写一个组件,可以校验用户输入的邮箱是不是合法,不合法弹出信息提示


image.png


闲的无聊



给Vuejs写一个英文歌曲,摇滚一些



感觉可以直接年会 or vueconf上唱了


Verse 1:
Vue, you're my favorite framework
You make my code more powerful
With your reactive APIs
And your declarative syntax

Chorus:
Vue, oh Vue
You're the one I want to use
Vue, oh Vue
You make my life so easy

Verse 2:
With your components and data-binding
And your virtual DOM rendering
I can create a web app
That's both fast and flexible

Chorus:
Vue, oh Vue
You're the one I want to use
Vue, oh Vue
You make my life so easy

Bridge:
When I need to build a frontend
I know just what to do
I turn to you, Vue
You make it all so simple

Chorus:
Vue, oh Vue
You're the one I want to use
Vue, oh Vue
You make my life so easy

Outro:
Vue, oh Vue
You're the one I want to use
Vue, oh Vue
You make my life so easy


给Reactjs写一个英文歌曲,摇滚一些




一个练习时长两年半的练习生,喜欢唱跳rap 为开头写一个故事




一个人前来买瓜




写一个帮程序员背单词的chrome插件的需求文档




我今天忘记做核酸了,老师要求每天都要做核酸,需要写一份500字的检讨书,认错态度要诚恳 帮忙写一下




今天忘了写日记了,今天主要就是出去遛狗,玩了一下午游戏,帮我写一个500字的日记吧




我们团队想招聘一个高级前端工程师,希望能活泼一些,帮我写个招聘介绍吧


不错,再写一个邀请候选人来面试的邮件吧


候选人不太合适,写一个拒绝他的邮件,委婉一些



这个开放之后,稍微二开一下,每个人都可以拥有自己的贾维斯人工智能助理了,期待


不过老有人说这玩意会取代程序员,取代产品经理,这个我感觉还不至于,可能会淘汰一些入门的岗位,AI本身也需要输入,需要高质量的从业人员贡献产出,所以无论哪个行业,不想被AI取代,还是得提高自己的知识水平啊


体验地址 https://chat.openai.com/chat


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

咱不吃亏,也不能过度自卫

这次我谈谈不吃亏的一种人,他们不吃亏近乎强硬。这类人一点亏都不吃,以至于过度自我保护。 我们公司人事小刘负责考勤统计。发完考勤表之后,有个员工找到他,说出勤少统计了一天。 小刘一听,感觉自己有被指控的风险。 他立刻严厉起来:“每天都来公司,不一定就算全勤。没打...
继续阅读 »

这次我谈谈不吃亏的一种人,他们不吃亏近乎强硬。这类人一点亏都不吃,以至于过度自我保护。


我们公司人事小刘负责考勤统计。发完考勤表之后,有个员工找到他,说出勤少统计了一天。


小刘一听,感觉自己有被指控的风险。


他立刻严厉起来:“每天都来公司,不一定就算全勤。没打卡我是不统计的”。


最后小刘一查,发现是自己统计错了。


小刘反而更加强势了:“这种事情,你应该早点跟我反馈,而且多催着我确认。你自己的事情都不上心,扣个钱啥的只能自己兜着”


这就是明显的不愿意吃亏,即使自己错了,也不愿意让自己置于弱势。


你的反应,决定别人怎么对你。这种连言语的亏都不吃的人,并不会让别人敬畏,反而会让人厌恶,进而影响沟通


我还有一个同事老王。他是一个职场老人,性格嘻嘻哈哈,业务能力也很强。


以前同事小赵和老王合作的时候,小赵宁愿经两层人传话给老王,也不愿意和他直接沟通。


我当时感觉小赵不善于沟通。


后来,当我和老王合作的时候,才体会到小赵的痛苦。


因为,老王是一个什么亏都不吃的人,谁来找他理论,他就怼谁。


你告诉他有疏漏,他会极力掩盖问题,并且怒怼你愚昧无知。


就算你告诉他,说他家着火了。他首先说没有。你一指那不是烧着的吗?他回复,你懂个屁,你知道我几套房吗?我说的是我另一个家没着火。


有不少人,从不吃亏,无论什么情况,都不会让自己处于弱势。


这类人喜欢大呼小叫,你不小心踩他脚了,他会大喊:践踏我的尊严,和你拼了!


心理学讲,愤怒源于恐惧,因为他想逃避当前不利的局面


人总会遇到各种不公的待遇,或误会,或委屈。


遇到争议时,最好需要确认一下,排除自己的问题。


如果自己没错,那么比较好的做法就是:“我认为你说得不合理,首先……其次……最后……”。


不盲目服软,也不得理不饶人,全程平心静气,有理有据。这种人绝对人格魅力爆棚,让人敬佩。


最后,有时候过度强硬也是一种策略,可以很好地过滤和震慑一些不重要的事物。


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

裸辞回家遇见了她

22年,连续跳了二三家公司,辗转七八个城市。 可能还是太年轻,工作上特别急躁,加班太多会觉得太累,没事情做又觉得无聊烦躁。去年年末回老家过年因为一些巧合遇见了她。年初就润,回到了老家。当时因为苏州疫情就没回去,便开始在老家这边的坎坷之旅。 年初千里见网友 说起...
继续阅读 »

22年,连续跳了二三家公司,辗转七八个城市。

可能还是太年轻,工作上特别急躁,加班太多会觉得太累,没事情做又觉得无聊烦躁。去年年末回老家过年因为一些巧合遇见了她。年初就润,回到了老家。当时因为苏州疫情就没回去,便开始在老家这边的坎坷之旅。


年初千里见网友


说起来也是缘分,去年年末的时候,有个人加了我微信,当时也是一头雾水,还以为是传销或者什么。一看名字微信名:“xxx”,也不像是啊,当时没放在心上就随便聊了聊,也没咋放心上。后来我朋友告诉我她推的(因为觉得我挺清秀人品也还行),就把她推给了我。但是我这人自卑又社恐,加上她在我老家那边,就想反正自己好多年也不想回老家那个地方,现在即使网恋也是耽误人家,后面就没咋搭理她。

到过年的时候,我和我妈匆匆忙忙回到了老家,当时家里宅基地刚好重建装修完,背了一屁股的债务,当时很多人劝我不要建房子在老家,有钱直接在省会那边付个首付也比老家强,可我一直觉得这个房子是我奶奶心心念念了一辈子的事情。转念一想一辈人有一辈人的使命,最多就是自己再多奋斗几年就没多去计较。


后面过年期间,我和她某明奇妙的聊起来了,可能是我觉得离她近了,然后就有一丝丝念想吧,当时因为一些特殊原因,过年的时她也在上班。那几天基本每天从早聊到晚,稍微有点暧昧,之后还一起玩游戏,玩了几局,我也很菜没能赢,就这样算是更深一步了解她吧,当时也不好断定她是怎样的人。就觉得她很温柔、活泼、可爱、直爽,后面想了想好像很久好久没用遇到这样的女孩子了吧,前几年也遇到不少女孩子都没有这种感觉。是不是自己单身太久产生的幻觉。经过一段时间的发酵,我向我朋友打听了下她。


我朋友说人品没问题,就是有点矮,我想着女孩子没啥影响,反正我也矮。就决定去见见她,她也没拒绝我。缘分到了如果不抓住的话也不知道下一次是什么时候。其实那时候我们还只是看过照片,彼此感觉都是那种一般人,到了这个年纪(毕业二三年)其实都不是太在乎颜值,只要不是丑得不能见人(颜值好的话肯定是加分项)。虽然我们都在老家,但她兼职那边还是有点远,去那边需要转很多车。但也没什么,我义无反顾去见了他,也许这就是大多数人奔现的样子吧(但我心里是比较排斥这个词的)。


那天早上一大早我就急冲冲起来了,洗个了头,吹了个自认为很帅的发型,戴上小围巾就出发了(那晚上其实下了很大的雪)。因为老家比较远我都比较害怕那边没有班车,因为当时才大年初三,我们那边的习俗是过年几天不跑车,跑车一年的财运都会受影响。到路上果然没让我失望,路上一辆车都没有,也是运气好,我前几天刚好听到我表姐说要去城里,我就问了问,果真就今天去(就觉得很巧合,跟剧本一样),他们把我送到高铁站,道了个谢,就跑去赶了最早一班的高铁。


怀着忐忑的心情出发了,那时差不多路上就是这个样子吧(手机里视频传不上去)。


image.png
在路上的时候她一直强调说自己这样不行,那样不可以怕我嫌弃,我当时倒是不自卑,直接对人家就是一顿安慰。到了省会那边,又辗转几个地方去买花,那时过年基本没什么花店开门。转了几个大的花店市场才发现一家花店,订了一束不大不小的花, 又去超市买了个玩偶和巧克力,放了几颗德芙在衣服包里面(小心机)。前前后后忙完这些已经下午一点了,对比下行程,可能有点赶不上车了。匆忙坐了班车到了她上班那个市区 ,本以为一切都会很顺利,结果到了那边转车的班车停运了,当时其实是迷茫的。不知道要不要住宿等到第二天。


那时我想起本来就是一腔热情才跑过来的,也许过了那个劲就不会有那个动力去面对了,心里默想:“所爱隔山海,山海皆可平”。心疼的打了个车花了差不多五百块(大冤种过年被宰)。就这样踏上最后一段路程。路上见到不一样的山峰,矮而尖而且很密集,那个司机说天眼好像就是建筑在这边吧,路上我就一直想:即使人家见了我嫌弃我这段旅行也算很划算的吧。最终晚上七点到达了目的地,下车了还是有点紧张,我害怕她不喜欢我这样的,毕竟了解不多,也许就是你一厢情愿的认为这就是缘分和命运的安排。


终将相遇


最后一刻,我都还在想,她会不会看到我就跑了,然后不来见我。但应该不至于此,毕竟我相信我的老朋友(七年死党),也相信她的人品。我看见一个人从前面走来我还以为是她,都准备迎上去了,走近一看咋是个阿姨(吓我一跳还以为被骗了),等我反应过来那个阿姨已经走远了。然后一个声音从我对面传来:“我在这,我在这边”,我转头过去惊艳到我了,这这这是本人吗?深邃的眼眸,樱桃小嘴,不是很尖的脸蛋,短发到肩,微风吹起刘海飘啊飘,像飘进了我的心里,头后发带将一些头发束起,然后发带结成蝴蝶结,一身长白棉袄配白皮鞋,显得俏皮又惊艳。我还来不及细想,我就迎了过去,提前想好的台词都没有说出来,倒是显得有一些尴尬。


当时自卑感油然而生,自己觉得配不上她。寒暄了几句我将花递给她,没有惊喜的表情,只有一句:我都没给你准备什么礼物,你这样我会很不好意思的,她这样说我该是开心还是难过呢?我心里觉得大概要凉了。就怕一句:你是个好人,我们就这样吧。其实当时我们也没说啥喜欢啥的就是有点暧昧。所幸没有发生她嫌弃我的事情,我们延着路边一路闲聊下去,一开始我还有点拘谨,毕竟常年当程序员社交能力不是很行。


慢慢的,我们说了很多很多,她请我吃了个饭(之前说过请她没倔过她),一路走着走着,说着大学的事,小时候的事,工作的事,一时间显得我们不是陌生人,而是多年未见的好友,一下子就觉得很轻松很幸福,反正我已经深深的迷上她的人美心善。她也说了离家老远跑来这边上班的原因(不方便透露)。走着走着我发现她的手有点红,就说道:我还给你准备了个惊喜,把手伸进我衣服包里吧,我在里面放了几颗糖,上班那么辛苦有点糖就不苦了。后面我有点唐突抓住她的手,我说给她暖一下太冰了。她说放我包里就暖和了,我看她脸都红了,也觉得有点唐突了。后面发现还是太冰了,没多想就用牵住了她,嘿嘿!她直接害羞的低下了头。一下子幸福感就涌上来了。


后面很晚的时候要分别了,送他回了宿舍,并把包里的玩偶以及剩下的零食一并给了她。她说第二天来送我,我便回了酒店。


第二天我们俩随便吃了点东西(依旧很害羞没敢坐我对面),她就送我上车了,临走时她塞了一个东西在我手里,打开一看昨天的发带,抬头她已走远她小声说了一句:我们有缘再见。也许是想着我在苏州她在遵义太远了吧,可能就是最后一面了,有点伤心也没多问。


微信图片_20220831164746.jpg


感情生活波折


回去的第二天我便回到苏州那边,但是很久之前就谋划着辞职,一方面是觉得在这边技术得不到提升,一方面是觉得想换个环境吧,毕竟这边太闲了让我找不到价值。可能年轻急躁当时没多想就直接裸辞了,期间我对她说:我辞职后来看她,她有点不愿意(说感觉我们的感情有点空中楼阁),可能觉得见一面不足以确定什么吧,我可能觉得给不了他幸福也舍不得割舍吧。


后面裸辞后,蹭着苏州没有因为疫情封禁,直接带了二件衣服就回了老家。(具体细节不说了)


第二次见她,可能觉得有点陌生吧,不过慢慢的就过了那个尴尬期,我们一起去逛公园、去逛街、彼此送小礼物、一起吃饭,即使现在回来依旧觉得很美好。但是我依旧没有表白,可能我觉得这些事顺理成章的不需要。一次巧合我去了她家帮她做家务、洗头、做饭。哈哈哈,像一个家庭主男一样。可能就是那次她才真的喜欢上我的吧。


有一次见面之后因为一些很严重的事我们吵架了,本来以为就要在此结束了。后来我又去见她了,我觉得女孩子有什么顾虑很正常的,也许是不够喜欢啥的,准备最后见一面吧,但见面之后准备好的说辞一句没说,还是像原来那样相处,一下子心里就有点矛盾,后面敞开心扉说开了,心里纠结的问题也就解决了。慢慢的我们也彼此接受了,从一见钟情到建立关系,真的经历很多东西。不管是少了那一段经历我和她都不会有以后。我的果决她的温柔都是缺一不可的。


后续


她考研上岸,我离开苏州在贵阳上班。我们依旧还有很长一段路要走。后续把工作篇发出来(干web前端的)


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

鹅厂组长,北漂 10 年,有房有车,做了一个违背祖宗的决定

前几天是 10 月 24 日,有关注股票的同学,相信大家都过了一个非常难忘的程序员节吧。在此,先祝各位朋友们身体健康,股票基金少亏点,最重要的是不被毕业。 抱歉,当了回标题党,不过在做这个决定之前确实纠结了很久,权衡了各种利弊,就我个人而言,不比「3Q 大战」...
继续阅读 »

前几天是 10 月 24 日,有关注股票的同学,相信大家都过了一个非常难忘的程序员节吧。在此,先祝各位朋友们身体健康,股票基金少亏点,最重要的是不被毕业。


抱歉,当了回标题党,不过在做这个决定之前确实纠结了很久,权衡了各种利弊,就我个人而言,不比「3Q 大战」时腾讯做的「艰难的决定」来的轻松。如今距离这个决定过去了快 3 个月,我也还在适应着这个决定带来的变化。


按照工作汇报的习惯,先说结论:



在北漂整整 10 年后,我回老家合肥上班了



做出这个决定的唯一原因:



没有北京户口,积分落户陪跑了三年,目测 45 岁之前落不上



户口搞不定,意味着孩子将来在北京只能考高职,这断然是不能接受的;所以一开始是打算在北京读几年小学后再回老家,我也能多赚点钱,两全其美。


因为我是一个人在北京,如果在北京上小学,就得让我老婆或者让我父母过来。可是我老婆的职业在北京很难就业,我父母年龄大了,北京人生地不熟的,而且那 P 点大的房子,住的也憋屈。而将来一定是要回去读书的,这相当于他们陪着我在北京折腾了。


或者我继续在北京打工赚钱,老婆孩子仍然在老家?之前的 6 年基本都是我老婆在教育和陪伴孩子,我除了逢年过节,每个月回去一到两趟。孩子天生过敏体质,经常要往医院跑,生病时我也帮不上忙,所以时常被抱怨”丧偶式育儿“,我也只能跟渣男一样说些”多喝热水“之类的废话。今年由于那啥,有整整 4 个多月没回家了,孩子都差点”笑问客从何处来“了。。。


5月中旬,积分落户截止,看到贴吧上网友晒出的分数和排名,预计今年的分数线是 105.4,而实际分数线是 105.42,比去年的 100.88 多了 4.54 分。而一般人的年自然增长分数是 4 分,这意味着如果没有特殊加分,永远赶不上分数线的增长。我今年的分数是 90.8,排名 60000 左右,每年 6000 个名额,即使没有人弯道超车,落户也得 10 年后了,孩子都上高一了,不能在初二之前搞到户口,就表示和大学说拜拜了。


经过我的一番仔细的测算,甚至用了杠杆原理和人品守恒定理等复杂公式,最终得到了如下结论:



我这辈子与北京户口无缘了



所以,思前想后,在没有户口的前提下,无论是老婆孩子来北京,还是继续之前的异地,都不是好的解决方案。既然将来孩子一定是在合肥高考,为了减少不必要的折腾,那就只剩唯一的选择了,我回合肥上班,兼顾下家里。


看上去是个挺自然的选择,但是:



我在腾讯是组长,团队 20 余人;回去是普通工程师,工资比腾讯打骨折



不得不说,合肥真的是互联网洼地,就没几个公司招人,更别说薪资匹配和管理岗位了。因此,回合肥意味着我要放弃”高薪“和来之不易的”管理“职位,从头开始,加上合肥这互联网环境,基本是给我的职业生涯判了死刑。所以在 5 月底之前就没考虑过这个选项,甚至 3 月份时还买了个显示器和 1.6m * 0.8m 的大桌子,在北京继续大干一场,而在之前的 10 年里,我都是用笔记本干活的,从未用过外接显示器。


5 月初,脉脉开始频繁传出毕业的事,我所在的部门因为是盈利的,没有毕业的风险。但是营收压力巨大,作为底层的管理者,每天需要处理非常非常多的来自上级、下级以及甲方的繁杂事务,上半年几乎都是凌晨 1 点之后才能睡觉。所以,回去当个普通工程师,每天干完手里的活就跑路,貌似也不是那么不能接受。毕竟自己也当过几年 leader 了,leader 对自己而言也没那么神秘,况且我这还是主动激流勇退,又不是被撸下来的。好吧,也只能这样安慰自己了,中年人,要学会跟自己和解。后面有空时,我分享下作为 leader 和普通工程师所看到的不一样的东西。


在艰难地说服自己接受之后,剩下的就是走各种流程了:


1. 5月底,联系在合肥工作的同学帮忙内推;6月初,通过面试。我就找了一家,其他家估计性价比不行,也不想继续面了
2. 6月底告诉总监,7月中旬告诉团队,陆续约或被约吃散伙饭
3. 7月29日,下午办完离职手续,晚上坐卧铺离开北京
4. 8月1日,到新公司报道

7 月份时,我还干了一件大事,耗时两整天,历经 1200 公里,不惧烈日与暴雨,把我的本田 125 踏板摩托车从北京骑到了合肥,没有拍视频,只能用高德的导航记录作为证据了:


北京骑摩托回合肥


这是导航中断的地方,晚上能见度不行,在山东花了 70 大洋,随便找了个宾馆住下了,第二天早上出发时拍的,发现居然是水泊梁山附近,差点落草为寇:


水泊梁山


骑车这两天,路上发生了挺多有意思的事,以后有时间再分享。到家那天,是我的结婚 10 周年纪念日,我没有提前说我要回来,更没说骑着摩托车回来,当我告诉孩子他妈时,问她我是不是很牛逼,得到的答复是:



我觉得你是傻逼



言归正传,在离开北京前几天,我找团队里的同学都聊了聊,对我的选择,非常鲜明的形成了两个派系:


1. 未婚 || 工作 5 年以内的:不理解,为啥放弃管理岗位,未来本可以有更好的发展的,太可惜了,打骨折的降薪更不能接受

2. 已婚 || 工作 5 年以上的:理解,支持,甚至羡慕;既然迟早都要回去,那就早点回,多陪陪家人,年龄大了更不好回;降薪很正常,跟房价也同步,不能既要又要
复制代码

确实,不同的人生阶段有着不同的想法,我现在是第 2 阶段,需要兼顾家庭和工作了,不能像之前那样把工作当成唯一爱好了。


在家上班的日子挺好的,现在加班不多,就是稍微有点远,单趟得 1 个小时左右。晚上和周末可以陪孩子玩玩,虽然他不喜欢跟我玩🐶。哦,对了,我还有个重要任务 - 做饭和洗碗。真的是悔不当初啊,我就不应该说会做饭的,更不应该把饭做的那么好吃,现在变成我工作以外的最重要的业务了。。。


比较难受的是,现在公司的机器配置一般,M1 的 MBP,16G 内存,512G 硬盘,2K 显示器。除了 CPU 还行,内存和硬盘,都是快 10 年前的配置了,就这还得用上 3 年,想想就头疼,省钱省在刀刃上了,属于是。作为对比,腾讯的机器配置是:



M1 Pro MBP,32G 内存 + 1T SSD + 4K 显示器


客户端开发,再额外配置一台 27寸的 iMac(i9 + 32G内存 + 1T SSD)



由奢入俭难,在习惯了高配置机器后,现在的机器总觉得速度不行,即使很多时候,它和高配机没有区别。作为开发,尤其是客户端开发,AndroidStudio/Xcode 都是内存大户,16G 实在是捉襟见肘,非常影响搬砖效率。公司不允许用自己的电脑,否则我就自己买台 64G 内存的 MBP 干活用了。不过,换个角度,编译时间变长,公司提供了带薪摸鱼的机会,也可以算是个福利🐶


另外,比较失落的就是每个月发工资的日子了,比之前少了太多了,说没感觉是不可能的,还在努力适应中。不过这都是小事,毕竟年底发年终奖时,会更加失落,hhhh😭😭😭😭


先写这么多吧,后面有时间的话,再分享一些有意思的事吧,工作上的或生活上的。


遥想去年码农节时,我还在考虑把房子从昌平换到海淀,好让孩子能有个“海淀学籍”,当时还做了点笔记:


买房笔记


没想到,一年后的我回合肥了,更想不到一年后的腾讯,股价竟然从 500 跌到 206 了(10月28日,200.8 了)。真的是世事难料,大家保重身体,好好活着,多陪陪家人,一起静待春暖花开💪🏻💪🏻


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

Activity生命周期监控方案

实际开发中,我们经常需要在Activity的onResume或者onStop中进行全局资源的获取或释放,那么怎么去监控Activity生命周期变化呢? 通知式监控 一般情况下,我们可以在资源管理类中提供onActivityResume,onActivitySt...
继续阅读 »

实际开发中,我们经常需要在Activity的onResume或者onStop中进行全局资源的获取或释放,那么怎么去监控Activity生命周期变化呢?


通知式监控


一般情况下,我们可以在资源管理类中提供onActivityResume,onActivityStop之类的公共接口来实现该需求,这种情况下,需要在Activity内部的各个生命周期函数中手动调用资源管理类的对应函数,实现如下所示:


 // 资源管理类
 public class ResourceManager {
     private static final String TAG = "ResourceManager";
 
     public void onActivityResume() {
         Log.d(TAG,"doing something in onActivityResume");
    }
     
     public void onActivityStop() {
         Log.d(TAG,"doing something in onActivityStop");
    }
 }

 public class NotifyAcLifecycleActivity extends AppCompatActivity {
 
     private ResourceManager mResourceManager = new ResourceManager();
 
     @Override
     protected void onCreate(Bundle savedInstanceState) {
         super.onCreate(savedInstanceState);
         setContentView(R.layout.activity_notify_ac_lifecycle);
    }
 
     @Override
     protected void onResume() {
         super.onResume();
         mResourceManager.onActivityResume();
    }
 
     @Override
     protected void onStop() {
         super.onStop();
         mResourceManager.onActivityStop();
    }
 }

可以看出,通知式实现的生命周期监控具有以下显著缺陷:



  • 代码侵入性强:需要在Activity中手动调用资源管理类的对应公共方法

  • 耦合严重:资源管理类的公共方法和Activity生命周期函数强耦合,当资源管理类的数量发生变化时,新增或者删除,都需改动Activity代码


监听式监控


即然通知式监控具有那么多的缺陷,那么我们怎么来解决该问题呢?从操作意图可以看出,我们期望在Activity生命周期变化的时候资源管理类能收到通知,换句话说就是资源管理类可以监听到Activity的生命周期变更,说到监听,我们自然而言的想到了设计模式中的观察者模式



观察者模式包含了被观察者和观察者两个角色,描述的是当被观察者状态发生变化时,所有依赖于该被观察者的观察者都可以接收到通知并根据需要完成操作



由观察者模式定义来看,Activity应该是被观察者,资源管理器应该是观察者,为进一步解耦,我们引入接口,定义观察者接口如下所示:


 public interface LifecycleObserver {
     void onActivityResume();
 
     void onActivityStop();
 }

在被观察者(Activity)中通知观察者,修改的代码如下:


 public class ObserverLifecycleActivity extends AppCompatActivity {
 
     private LifecycleObserver mObserver;
 
     @Override
     protected void onCreate(Bundle savedInstanceState) {
         super.onCreate(savedInstanceState);
         setContentView(R.layout.activity_observer_lifecycle);
    }
 
     public void setObserver(LifecycleObserver observer) {
         mObserver = observer;
    }
 
     @Override
     protected void onResume() {
         super.onResume();
         if (mObserver != null) {
             mObserver.onActivityResume();
        }
    }
 
     @Override
     protected void onStop() {
         super.onStop();
         if (mObserver != null) {
             mObserver.onActivityStop();
        }
    }
 }

使需要观察的对象实现观察者接口,并在onCreate中完成观察,代码如下:


 public class ResourceManager implements LifecycleObserver{
     private static final String TAG = "ResourceManager";
     
     @Override
     public void onActivityResume() {
         Log.d(TAG,"doing something in onActivityResume");
    }
 
     @Override
     public void onActivityStop() {
         Log.d(TAG,"doing something in onActivityStop");
    }
 }

 @Override
 protected void onCreate(Bundle savedInstanceState) {
     super.onCreate(savedInstanceState);
     setContentView(R.layout.activity_observer_lifecycle);
     setObserver(new ResourceManager());
 }

这样就通过LifecycleObserver完成了ResourceManager观察Activity生命周期变化的操作,如果不需要接收通知,不调用setObserver方法即可。


简单业务中,上述实现没问题,单随着业务的逐步扩大,资源管理器可能不止一个,而且并不一定需要一直监听变化,在一定情况下,可能需要移除,接下来我们进一步修改被观察者中关于观察者的管理,使其支撑多个观察者以及动态移除观察者,代码如下:


 public class ObserverLifecycleActivity extends AppCompatActivity {
 
     private List<LifecycleObserver> mObservers = new ArrayList<>();
 
     @Override
     protected void onCreate(Bundle savedInstanceState) {
         super.onCreate(savedInstanceState);
         setContentView(R.layout.activity_observer_lifecycle);
         addObserver(new ResourceManager());
    }
 
     public void addObserver(LifecycleObserver observer) {
         mObservers.add(observer);
    }
 
     public void removeObserver(LifecycleObserver observer) {
         mObservers.remove(observer);
    }
 
     @Override
     protected void onResume() {
         super.onResume();
         if (mObservers != null && !mObservers.isEmpty()) {
             for (LifecycleObserver observer : mObservers) {
                 observer.onActivityResume();
            }
        }
    }
 
     @Override
     protected void onStop() {
         super.onStop();
         if (mObservers != null && !mObservers.isEmpty()) {
             for (LifecycleObserver observer : mObservers) {
                 observer.onActivityStop();
            }
        }
    }
 }

从上述实现可以看出,该方案具有以下缺点:



  • 不适用于多Activity场景

  • 仍然需要耦合Activity的addObserver和removeObserver方法


ActivityLifecycleCallbacks


上面都是开发者实现的,那么系统内部有没有已经实现的方案呢?查看源码,可以找到ActivityLifecycleCallbacks,其定义如下:


     public interface ActivityLifecycleCallbacks {
 
         /**
          * Called as the first step of the Activity being created. This is always called before
          * {@link Activity#onCreate}.
          */

         default void onActivityPreCreated(@NonNull Activity activity,
                 @Nullable Bundle savedInstanceState) {
        }
 
         /**
          * Called when the Activity calls {@link Activity#onCreate super.onCreate()}.
          */

         void onActivityCreated(@NonNull Activity activity, @Nullable Bundle savedInstanceState);
 
         /**
          * Called as the last step of the Activity being created. This is always called after
          * {@link Activity#onCreate}.
          */

         default void onActivityPostCreated(@NonNull Activity activity,
                 @Nullable Bundle savedInstanceState) {
        }
 
         /**
          * Called as the first step of the Activity being started. This is always called before
          * {@link Activity#onStart}.
          */

         default void onActivityPreStarted(@NonNull Activity activity) {
        }
 
         /**
          * Called when the Activity calls {@link Activity#onStart super.onStart()}.
          */

         void onActivityStarted(@NonNull Activity activity);
 
         /**
          * Called as the last step of the Activity being started. This is always called after
          * {@link Activity#onStart}.
          */

         default void onActivityPostStarted(@NonNull Activity activity) {
        }
 
         /**
          * Called as the first step of the Activity being resumed. This is always called before
          * {@link Activity#onResume}.
          */

         default void onActivityPreResumed(@NonNull Activity activity) {
        }
 
         /**
          * Called when the Activity calls {@link Activity#onResume super.onResume()}.
          */

         void onActivityResumed(@NonNull Activity activity);
 
         /**
          * Called as the last step of the Activity being resumed. This is always called after
          * {@link Activity#onResume} and {@link Activity#onPostResume}.
          */

         default void onActivityPostResumed(@NonNull Activity activity) {
        }
 
         /**
          * Called as the first step of the Activity being paused. This is always called before
          * {@link Activity#onPause}.
          */

         default void onActivityPrePaused(@NonNull Activity activity) {
        }
 
         /**
          * Called when the Activity calls {@link Activity#onPause super.onPause()}.
          */

         void onActivityPaused(@NonNull Activity activity);
 
         /**
          * Called as the last step of the Activity being paused. This is always called after
          * {@link Activity#onPause}.
          */

         default void onActivityPostPaused(@NonNull Activity activity) {
        }
 
         /**
          * Called as the first step of the Activity being stopped. This is always called before
          * {@link Activity#onStop}.
          */

         default void onActivityPreStopped(@NonNull Activity activity) {
        }
 
         /**
          * Called when the Activity calls {@link Activity#onStop super.onStop()}.
          */

         void onActivityStopped(@NonNull Activity activity);
 
         /**
          * Called as the last step of the Activity being stopped. This is always called after
          * {@link Activity#onStop}.
          */

         default void onActivityPostStopped(@NonNull Activity activity) {
        }
 
         /**
          * Called as the first step of the Activity saving its instance state. This is always
          * called before {@link Activity#onSaveInstanceState}.
          */

         default void onActivityPreSaveInstanceState(@NonNull Activity activity,
                 @NonNull Bundle outState) {
        }
 
         /**
          * Called when the Activity calls
          * {@link Activity#onSaveInstanceState super.onSaveInstanceState()}.
          */

         void onActivitySaveInstanceState(@NonNull Activity activity, @NonNull Bundle outState);
 
         /**
          * Called as the last step of the Activity saving its instance state. This is always
          * called after{@link Activity#onSaveInstanceState}.
          */

         default void onActivityPostSaveInstanceState(@NonNull Activity activity,
                 @NonNull Bundle outState) {
        }
 
         /**
          * Called as the first step of the Activity being destroyed. This is always called before
          * {@link Activity#onDestroy}.
          */

         default void onActivityPreDestroyed(@NonNull Activity activity) {
        }
 
         /**
          * Called when the Activity calls {@link Activity#onDestroy super.onDestroy()}.
          */

         void onActivityDestroyed(@NonNull Activity activity);
 
         /**
          * Called as the last step of the Activity being destroyed. This is always called after
          * {@link Activity#onDestroy}.
          */

         default void onActivityPostDestroyed(@NonNull Activity activity) {
        }
 
         /**
          * Called when the Activity configuration was changed.
          * @hide
          */

         default void onActivityConfigurationChanged(@NonNull Activity activity) {
        }
    }

从接口函数可以看出这是用于监听Activity生命周期事件的回调,我们可以在Application中使用registerActivityLifecycleCallbacks注册Activity生命周期的全局监听,当有Activity的生命周期发生变化时,就会回调该接口中的方法,代码如下:


registerActivityLifecycleCallbacks(new ActivityLifecycleCallbacks() {
@Override
public void onActivityCreated(@NonNull Activity activity, @Nullable Bundle savedInstanceState) {

}

@Override
public void onActivityStarted(@NonNull Activity activity) {

}

@Override
public void onActivityResumed(@NonNull Activity activity) {

}

@Override
public void onActivityPaused(@NonNull Activity activity) {

}

@Override
public void onActivityStopped(@NonNull Activity activity) {

}

@Override
public void onActivitySaveInstanceState(@NonNull Activity activity, @NonNull Bundle outState) {

}

@Override
public void onActivityDestroyed(@NonNull Activity activity) {

}
});

随后我们就可以根据回调的Activity对象判定应该由哪个资源管理器响应对应的生命周期变化。



通常情况下,我们可以依赖该方法实现以下需求:



  • 自定义的全局的Activity栈管理

  • 用户行为统计收集

  • Activity切入前后台后的资源申请或释放

  • 应用前后台判定

  • 页面数据保存与恢复

  • ... etc



Lifecycle in Jetpack


Lifecycle相关内容可以参考前面发布的系列文章:


Instrumentation


从Activity启动流程可知每个Activity生命周期变化时,ActivityThread都会通过其内部持有的Instrumentation类的对象进行分发,如果我们能自定义Instrumentation类,用我们自定义的Instrumentation类对象替换这个成员变量,那么自然可以通过这个自定义Instrumentation类对象来监听Activity生命周期变化。


那么怎么修改ActivityThread类的mInstrumentation成员呢?自然要用反射实现了。


自定义Instrumentation类如下所示:


public class CustomInstrumentation extends Instrumentation {
private static final String TAG = "CustomInstrumentation";
private Instrumentation mBaseInstrumentation;

public CustomInstrumentation(Instrumentation instrumentation) {
super();
mBaseInstrumentation = instrumentation;
}

@Override
public void callActivityOnResume(Activity activity) {
super.callActivityOnResume(activity);
Log.d(TAG, "callActivityOnResume " + activity.toString());
}

@Override
public void callActivityOnStop(Activity activity) {
super.callActivityOnStop(activity);
Log.d(TAG, "callActivityOnStop " + activity.toString());
}
}

在Application的attachBaseContext函数中反射修改ActivityThread的mInstrumentation成员为CustomInstrumentation类的对象,相关代码如下:


    @Override
protected void attachBaseContext(Context base) {
hookInstrumentation();
super.attachBaseContext(base);
}
public void hookInstrumentation() {
Class<?> activityThread;
try{
activityThread = Class.forName("android.app.ActivityThread");
Method sCurrentActivityThread = activityThread.getDeclaredMethod("currentActivityThread");
sCurrentActivityThread.setAccessible(true);
//获取ActivityThread 对象
Object activityThreadObject = sCurrentActivityThread.invoke(null);

//获取 Instrumentation 对象
Field mInstrumentation = activityThread.getDeclaredField("mInstrumentation");
mInstrumentation.setAccessible(true);
Instrumentation instrumentation = (Instrumentation) mInstrumentation.get(activityThreadObject);
CustomInstrumentation customInstrumentation = new CustomInstrumentation(instrumentation);
//将我们的 customInstrumentation 设置进去
mInstrumentation.set(activityThreadObject, customInstrumentation);
}catch (Exception e){
e.printStackTrace();
}
}

编写两个Activity分别为MainActivity和NotifyAcLifecycleActivity,在MainActivity中点击按钮跳转到NotifyAcLifecycleActivity,日志输出如下:


2-1-3-2


可以拿出,虽然正常代理到了Activity的生命周期变更,但是每次Activity启动都会爆出Uninitialized ActivityThread, likely app-created Instrumentation, disabling AppComponentFactory的异常,查看源码,查找该问题的原因:


// Instrumentation.java
private ActivityThread mThread = null;

private AppComponentFactory getFactory(String pkg) {
if (pkg == null) {
Log.e(TAG, "No pkg specified, disabling AppComponentFactory");
return AppComponentFactory.DEFAULT;
}
if (mThread == null) {
Log.e(TAG, "Uninitialized ActivityThread, likely app-created Instrumentation,"
+ " disabling AppComponentFactory", new Throwable());
return AppComponentFactory.DEFAULT;
}
LoadedApk apk = mThread.peekPackageInfo(pkg, true);
// This is in the case of starting up "android".
if (apk == null) apk = mThread.getSystemContext().mPackageInfo;
return apk.getAppFactory();
}

final void basicInit(ActivityThread thread) {
mThread = thread;
}

可以看到当mThread成员为空时,会抛出该问题,mThread是在basicInit中赋值的,由于我们创建的CustomInstrumentation对象没有调用该函数,故mThread必然为空,那么如何规避该问题呢?方案主要有两个方向




  • 初始化CustomInstrumentation对象的mThread对象


    反射获取原始Instrumentation对象的mThread取值,然后设置到自定义的CustomInstrumentation对象中




  • 针对getFactory方法使用的函数,将函数重写,调用原始Instrumentation对应的函数




这里我们使用第二个方案,在CustomInstrumentation中重写newActivity方法,使用原始的Instrumentation对象代理,代码如下:


public Activity newActivity(ClassLoader cl, String className,
Intent intent)
throws InstantiationException, IllegalAccessException,
ClassNotFoundException {

return mBaseInstrumentation.newActivity(cl, className, intent);
}

再次运行,可以看到日志中不再打印该异常,同时我们也能正常监听到Activity生命周期变化了,详细日志如下:


2-1-3-3


综上,我们就可以在自定义Instrumentation类的callActivityOnStop方法中过滤某些Activity,在其切入后台时进行资源的释放。



不难看出,自定义Instrumentation走通后,我们可以在该类中接管系统的Activity启动,进而将某个目标Activity替换成我们自己的Activity,这也是插件化实现中的一个核心步骤


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

浅谈开发中对数据的编码和封装

前言 前几天写了一篇对跨端通讯的思考,当时顺便想到了数据这一块,所以也可以整理一下,单独拿出来说说平时开发中涉及到哪些对数据的处理方式。 Base64 之前详细写过一篇关于Base64的文章 juejin.cn/post/715584… 简单来说,Base64...
继续阅读 »

前言


前几天写了一篇对跨端通讯的思考,当时顺便想到了数据这一块,所以也可以整理一下,单独拿出来说说平时开发中涉及到哪些对数据的处理方式。


Base64


之前详细写过一篇关于Base64的文章


juejin.cn/post/715584…


简单来说,Base64就是把你的数据转成只有64个无特殊符号的字符,主要常用于加密后之类的生成的字节数组,转成Base64方便进行数据传输,如果不转的话就会是乱码,会看得很难受,这些乱码在某些编码下展示出来的是豆腐块,有点扯远了。

还有就是比如中文啊,emoji啊这类的字符,在某些情况下也需要转成Base64进行传输。


缺点就是只用64个字符表示,还有我之前分析过base64的转换原理,通过其原理能很容易看出,最终的转换结果相比转换前的数据更加长。


JSON/XML


这些就比较常见了,对数据按照一定的格式进行封装。为什么要说这个呢?因为他这些封装是约定熟成的方式,和上面的Base64的转换方式就不同,相当于是大家约定好按照这个格式包装数据,然后传输,自己再按照这个格式去解开拿到数据。


多数用于跨端传输,像客户端请求服务端拿数据,那不也就是跨端嘛,其实这个所有人都用到,但为什么说这个呢?还是那个跨端通信的问题,跨端通信没办法直接传对象,实际传对象的效果是转json传的String,然后另外一端再创建一个自己端的对象,解析json,把json数据填充进去。


还有,既然是约定的,那其实我们自己也可以按照我们自己的约定去做跨端的数据传送,只不过json这种格式,是已经设计得很好了,你很难再去约定一种比这个格式更好的封装。


PS:不要觉得json大家都在用,都形成肌肉记忆了,没有什么难的。其实比如像gson\fastjson这些,人家去研究解析json的算法,也是一个技术点。你觉得简单,那是因为你在使用,但让你从0去做,你不一定能做出来。


URL编码


又叫做urlencode,顾名思义用于url连接中的一种对数据的操作。

它将特殊字符转成16进制并且在前面加%,那同理解析拿数据的时候也是根据%去做判断。


为什么会出现这种编码呢?主要是为了防止冲突,我们都知道比如get请求都会在url链接后面拼参数,防止在传输中出现问题,所以把特殊字符都进行编码。


比如http://www.baidu.com/?aaaaaaa 会编码成https%3A%2F%2Fwww.baidu.com%2F%3Faaaaaaa


该编码主要用于对url的处理。


驼峰和下划线


这其实是一个命名方式,不同的端有不同的命名习惯,比如java习惯就是用驼峰,但是还是跨端问题,有些时候存在写死的情况,当然这个代码不是你写的,也可能是前人留下的(我没有暗示什么)。但如果你的代码中出现两种命名方式会让代码看着比较乱。没关系我们可以做个转换,我这里以下划线转驼峰为例


private String lineToHump(String str) {
if (TextUtils.isEmpty(str)) {
return str;
}

String[] strs = str.split("_");
if (strs.length < 2) {
return str;
}

StringBuilder result = new StringBuilder(strs[0]);
for (int i = 1; i < strs.length; i++) {
String upper = (strs[i].charAt(0) + "").toUpperCase();
if (strs[i].length() > 1) {
result.append(upper).append(strs[i].substring(1));
} else {
result.append(upper);
}
}
return result.toString();
}

可以写个转换方法,我这里只是随便写个Demo,这段代码是还能进行优化的,主要大概就是这个意思。


上面说的json主要是为了说数据的封装和解封,这里主要是说数据的转换,我的意思是在开发中,我们也会出现不同端的数据形式不同,我们不需要在代码中向其它端进行妥协,只用写个方法去做数据的转换,在本端还是正常写本端的代码就行。


摘要


摘要算法,简单来说就是将原数据以一种算法生成一段很小的新数据,这段新数据主要是用来标识这段原数据。怎么还有点绕,总之就是生成一个字符串来标识原数据 。对任意一组输入数据进行计算,得到一个固定长度的输出。


也可称之为哈希算法,最重要的是它取决于它的这个设计思想,它是一个不可能逆的过程,一般不能根据摘要拿到原数据,注意我用了一般,因为这个世界上存在很多老六。


摘要算法中当前最经典的是SHA算法和MD算法,SHA-1、SHA-256和MD5。其中他们加密过程可以单独写一篇文章来说,这里就不过多解释。


摘要算法最主要的运用场景是校验数据的完整性和是否有被篡改。比如CA证书的校验,android签名的校验,会拿原数据做摘要和传过来的摘要相对比,是否一样,如果不一样说明数据有被篡改过。再比如我本地有个视频,我怎么判断后台这个视频是不是更新了,要不要下载,可以对视频文件做MD5,然后和后台文件的MD5进行对比,如果一样说明视频没有更新,如果不一样说明视频有更新或者本地的视频不完整(PS:对文件做摘要可是一个耗时的过程。)


加密


讲完摘要可以趁热打铁说说加密,加密顾名思义就是把明文数据转成密文,然后另一方拿到密文之后再转成明文。


加密和摘要不同在于,它们的本质都不同,摘要是为了验证数据,加密是为了安全传输数据。它们在表现上的不同体现在,摘要是不可逆,加密是可逆的。


加密在当前的设计上又分为对称加密和非对称加密,主流的对称加密是AES算法,主流的非对称加密是RSA算法。对称加密的加密和解密使用的密钥是相同的,非对称是不同的 ,所以非对称加密更为安全,但是也会更耗时。


当然你也可以不用这些算法,如果你是直接接触这些算法,好像是要付专利费的,每年给多少钱别人才给你用这个算法,资本家不就喜欢搞这种东西吗?扯远了。你也可以使用自己约定的算法,只不过在高手面前可能你的算法相当于裸奔,要是你真能设计出和这些算法旗鼓相当的算法,你也不会来看我这么捞的文章。


所以加密,是为了保证数据的安全,如果你传输的数据觉得被看了也无所谓,那就不用加密,因为它耗时。如果你只是为了防止数据被改,也不用加密,用摘要就行。如果你是为了传输seed,那我建议你加密[狗头]


通信协议


json那里我们有说,它就是双方约定好的数据格式。以小见大,通信协议也是双方约定的一种数据传输的过程。通信协议会更为严谨,而且会很多不同,各家有各家的通信协议,不是像json这种就是大家都用一样的。


比如我们的网络传输,就有很多协议,http协议、tcpip协议等,这些在网络中是规定好的,大家都用这一套。再比如蓝牙协议,也是要按照同一个规范去使用。但是硬件的协议就多种多样了,不同的硬件厂商会定义不同的通信协议。


二维码


二维码也是对数据封装的一种形式,可以通过把数据变成图像,然后是扫码后再获取到数据,这么一种模式我感觉能想出这个法子的人挺牛逼的。


它所涉及的内容很多,具体可以参考这篇文章,我觉得这个大佬写得挺好的 二维码生成原理 - 知乎 (zhihu.com)


我之前自己去用java实现,最终没画出来,感觉原理是没问题的,应该是我哪里细节没处理好,这里就简单介绍一下就行。其实简单来说,它就是有一个模板的情况下,把数据填充到模板里面。


这里借大佬的图,模板就是这样的


a45320800e92856b7aae045d60f83421526e33d6d105eeb8704cb24720e1218aQzpcVXNlcnNcODYxMzVcQXBwRGF0YVxSb2FtaW5nXERpbmdUYWxrXDE2NTUxOTM0NF92MlxJbWFnZUZpbGVzXDE2NzYzNTQwOTU4NDNfNzUzQTk0RUEtQTNBMC00MTg1LUE4MUYtRjNGODhDOUQ2MTNDLnBuZw==.png


然后按照规则去填充数据


dc6457c59e0ac7cbae2436b2b8b0f7e5b26d5f716619b323f0bc3d070ac5b1bdQzpcVXNlcnNcODYxMzVcQXBwRGF0YVxSb2FtaW5nXERpbmdUYWxrXDE2NTUxOTM0NF92MlxJbWFnZUZpbGVzXDE2NzYzNTQxMjYxODlfOUE5Q0I4RkMtRTUxNy00ZjJiLUFBRDMtREM3RTZDQjQwOEZCLnBuZw==.png


这样去填充,其实会让黑点分布不均匀,填充之后还会做一个转换。


但是二维码也有缺点,缺点就是数据量大的时候,你的二维码很难被识别出,但是不得不说能想出这个方法,能设计出这个东西的人,确实牛逼。


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

七道Android面试题,先来简单热个身

马上就要到招(tiao)聘(cao)旺季金三银四了,一批一批的社会精英在寻找自己的下一家的同时,也开始着手为面试做准备,回想起自己这些年,也大大小小经历过不少面试,有被面试过,也有当过面试官,其中也总结出了两个观点,一个就是不花一定的时间背些八股文还真的不行,...
继续阅读 »

马上就要到招(tiao)聘(cao)旺季金三银四了,一批一批的社会精英在寻找自己的下一家的同时,也开始着手为面试做准备,回想起自己这些年,也大大小小经历过不少面试,有被面试过,也有当过面试官,其中也总结出了两个观点,一个就是不花一定的时间背些八股文还真的不行,一些扯皮的话别去听,都是在害人,另一个就是面试造火箭,入职拧螺丝毕竟都是少数,真正一场合格的面试问的东西,都是实际开发过程中会遇到的,下面我就说几个我遇到过的面试题吧


为什么ArrayMap比HashMap更适合Android开发


我们一般习惯在项目当中使用HashMap去存储键值队这样的数据,所以往往在android面试当中HashMap是必问环节,但有次面试我记得被问到了有没有有过ArrayMap,我只能说有印象,毕竟用的最多的还是HashMap,然后那个面试官又问我,觉得Android里面更适合用ArrayMap还是HashMap,我就说不上来了,因为也没看过ArrayMap的源码,后来回去看了下才给弄明白了,现在就简单对比下ArrayMap与HashMap的特点


HashMap



  • HashMap的数据结构为数组加链表的结构,jdk1.8之后改为数组加链表加红黑树的结构

  • put的时候,会先计算key的hashcode,然后去数组中寻找这个hashcode的下标,如果数据为空就先resize,然后检查对应下标值(下标值=(数组长度-1)&hashcode)里面是否为空,空则生成一个entry插入,否就判断hascode与key值是否分别都相等,如果相等则覆盖,如果不等就发生哈希冲突,生成一个新的entry插入到链表后面,如果此时链表长度已经大于8且数组长度大于64,则先转成树,将entry添加到树里面

  • get的时候,也是先去查找数组对应下标值里面是否为空,如果不为空且key与hascode都相等,直接返回value,否就判断该节点是否为一个树节点,是就在树里面返回对应entry,否就去遍历整个链表,找出key值相等的entry并返回


ArrayMap



  • 内部维护两个数组,一个是int类型的数组(mHashes)保存key的hashcode,另一个是Object的数组(mArray),用来保存与mHashes对应的key-value

  • put数据的时候,首先用二分查找法找出mHashes里面的下标index来存放hashcode,在mArray对应下标index<<1与(index<<1)+1的位置存放key与value

  • get数据的时候,同样也是用二分查找法找出与key值对应的下标index,接着再从mArray的(index<<1)+1位置将value取出


对比



  • HashMap在存放数据的时候,无论存放的量是多少,首先是会生成一个Entry对象,这个就比较浪费内存空间,而ArrayMap只是把数据插入到数组中,不用生成新的对象

  • 存放大量数据的时候,ArrayMap性能上就不如HashMap,因为ArrayMap使用的是二分查找法找的下标,当数据多了下标值找起来时间就花的久,此外还需要将所有数据往后移再插入数据,而HashMap只要插入到链表或者树后面即可


所以这就是为什么,在没有那么大的数据量需求下,Android在性能角度上比较适合用ArrayMap


为什么Arrays.asList后往里add数据会报错


这个问题我当初问过不少人,不缺乏一些资历比较深的大佬,但是他们基本都表示不清楚,这说明平时我们研究Glide,OkHttp这样的三方库源码比较多,而像一些比较基础的往往会被人忽略,而有些问题如果被忽略了,往往会产生一些捉摸不透的问题,比如有的人喜欢用Arrays.asList去生成一个List


val dataList = Arrays.asList(1,2,3)
dataList.add(4)

但是当我们往这个List里面add数据的时候,我们会发现,crash了,看到的日志是


image.png
不被支持的操作,这让首次遇到这样问题的人肯定是一脸懵,List不让添加数据了吗?之前明明可以的啊,但是之前我们创建一个List是这样创建的


image.png
它所在的包是java.util.ArrayList里面,我们看下里面的代码


public boolean add(E e) {
ensureCapacityInternal(size + 1); // Increments modCount!!
elementData[size++] = e;
return true;
}
public void add(int index, E element) {
if (index > size || index < 0)
throw new IndexOutOfBoundsException(outOfBoundsMsg(index));

ensureCapacityInternal(size + 1); // Increments modCount!!
System.arraycopy(elementData, index, elementData, index + 1,
size - index);
elementData[index] = element;
size++;
}

是存在add方法的,我们再回头再去看看asList生成的List


image.png
是在java.util.Arrays包里面的,而这里面的ArrayList我们看到了,并没有去实现List接口,所以也就没有add,get等方法,另外在kotlin里面,我们会看到一个细节,当你敲完Arrays.asList的时候,编译器会提示你,可以转换成listof函数,而这个还是我们知道生成的list都是只能读取,不能往里写数据


Thread.sleep(0)到底“睡没睡”


记得在上上家公司,接手的第一个需求就是做一个动画,这个动画需要一个延迟启动的功能,我那个时候想都没想加了个Thread.sleep(3000),后来被领导批了,不可以用Thread.sleep实现延迟功能,那会还不太明白,后来知道了,Thread.sleep(3000)不一定真的暂停三秒,我们来举个例子


println("start:${System.currentTimeMillis()}")
Thread(Runnable {
Thread.sleep(3000)
println("end:${System.currentTimeMillis()}")
}).start()

我们在主线程先打印一条数据展示时间,然后开启一个子线程,在里面sleep三秒以后在打印一下时间,我们看下结果如何


start:1675665421590
end:1675665424591

好像对了又好像没对,为什么是过了3001毫秒才打印出来呢?有的人会说,1毫秒而已,忽略嘛,那我们把上面的代码改下再试试


println("start:${System.currentTimeMillis()}")
Thread(Runnable {
Thread.sleep(0)
println("end:${System.currentTimeMillis()}")
}).start()

现在sleep了0毫秒,那是不是两条打印日志应该是一样的呢,我们看看结果


start:1675666764475
end:1675666764477

这下子给整不会了,明明sleep0毫秒,那么多出来的2毫秒是怎么回事呢?其实在Android操作系统中,每个线程使用cpu资源都是有优先级的,优先级高的才有资格使用,而操作系统则是在一个线程释放cpu资源以后,重新计算所有线程的优先级来重新分配cpu资源,所以sleep真正的意义不是暂停,而是在接下去的时间内不参与cpu的竞争,等到cpu重新分配完资源以后,如果优先级没变,那么继续执行,所以sleep(0)秒的真正含义是触发cpu资源重新分配


View.post为什么可以获取控件的宽高


我们都知道在onCreate里面想要获取一个控件的宽高,如果直接获取是拿不到的


val mWith = bindingView.mainButton.width
val mHeight = bindingView.mainButton.height
println("按钮宽:$mWith,高:$mHeight")
......
按钮宽:0,高:0

而如果想要获取宽高,则必须调用View.post的方法


bindingView.mainButton.post {
val mWith = bindingView.mainButton.width
val mHeight = bindingView.mainButton.height
println("按钮宽:$mWith,高:$mHeight")
}
......
按钮宽:979,高:187

很神奇,加个post就可以在同样的地方获取控件宽高了,至于为什么呢?我们来分析一下


简单的来说


Activity生命周期,onCreate方法里面视图还在绘制过程中,所以没法直接获取宽高,而在post方法中执行,就是在线程里面获取宽高,这个线程会在视图没有绘制完成的时候放在一个等待队列里面,等到视图绘制执行完毕以后再去执行队列里面的线程,所以在post里面也可以获取宽高


复杂的来说


我们首先从View.post方法里面开始看


image.png


这个代码里面的两个框子,说明了post方法做了两件事情,当mAttachInfo不为空的时候,直接让mHandler去执行线程action,当mAttachInfo为空的时候,将线程放在了一个队列里面,从注释里面的第一个单词Postpone就可以知道,这个action是要推迟进行,什么时候进行呢,我们在慢慢看,既然是判断当mAttachInfo不为空才去执行线程,那我们找找什么时候对mAttachInfo赋值,整个View的源码里面只有一处是对mAttachInfo赋值的,那就是在dispatchAttachedToWindow
这个方法里面,我们看下


void dispatchAttachedToWindow(AttachInfo info, int visibility) {
mAttachInfo = info;
...省略部分源码...

// Transfer all pending runnables.
if (mRunQueue != null) {
mRunQueue.executeActions(info.mHandler);
mRunQueue = null;
}

}

当走到dispatchAttachedToWindow这个方法的时候,mAttachInfo才不为空,也就是从这里开始,我们就可以获取控件的宽高等信息了,另外我们顺着这个方法往下看,可以发现,之前的那个队列在这里开始执行了,现在就关键在于,什么时候执行dispatchAttachedToWindow这个方法,这个时候就要去ViewRootIml类里面查看,发现只有一处调用了这个方法,那就是在performTraversals这个方法里面


private void performTraversals() {
...省略部分源码...
host.dispatchAttachedToWindow(mAttachInfo, 0);
...省略部分源码...
// Ask host how big it wants to be
performMeasure(childWidthMeasureSpec, childHeightMeasureSpec);
...省略部分源码...
performLayout(lp, mWidth, mHeight);
...省略部分源码...
performDraw();
}

performTraversals这个方法我们就很熟悉了,整个View的绘制流程都在里面,所以只有当mAttachInfo在这个环节赋值了,才可以得到视图的信息


IdleHandler到底有啥用


Handler是面试的时候必问的环节,除了问一下那四大组件之外,有的面试官还会问一下IdleHandler,那IdleHandler到底是什么呢,它是干什么用的呢,我们来看看


Message next() {
...省略部分代码...
synchronized (this) {
// If first time idle, then get the number of idlers to run.
// Idle handles only run if the queue is empty or if the first message
// in the queue (possibly a barrier) is due to be handled in the future.
if (pendingIdleHandlerCount < 0
&& (mMessages == null || now < mMessages.when)) {
pendingIdleHandlerCount = mIdleHandlers.size();
}
if (pendingIdleHandlerCount <= 0) {
// No idle handlers to run. Loop and wait some more.
mBlocked = true;
continue;
}

if (mPendingIdleHandlers == null) {
mPendingIdleHandlers = new IdleHandler[Math.max(pendingIdleHandlerCount, 4)];
}
mPendingIdleHandlers = mIdleHandlers.toArray(mPendingIdleHandlers);
}

// Run the idle handlers.
// We only ever reach this code block during the first iteration.
for (int i = 0; i < pendingIdleHandlerCount; i++) {
final IdleHandler idler = mPendingIdleHandlers[i];
mPendingIdleHandlers[i] = null; // release the reference to the handler

boolean keep = false;
try {
keep = idler.queueIdle();
} catch (Throwable t) {
Log.wtf(TAG, "IdleHandler threw exception", t);
}

if (!keep) {
synchronized (this) {
mIdleHandlers.remove(idler);
}
}
}

}

只有在MessageQueue中的next方法里面出现了IdleHandler,作用也很明显,当消息队列在遍历队列中的消息的时候,当消息已经处理完了,或者只存在延迟消息的时候,就会去处理mPendingIdleHandlers里面每一个idleHandler的事件,而这些事件都是通过方法addIdleHandler注册进去的


Looper.myQueue().addIdleHandler {
false
}

addIdlehandler接受的参数是一个返回值为布尔类型的函数类型参数,至于这个返回值是true还是false,我们从next()方法中就能了解到,当为false的时候,事件处理完以后,这个IdleHandler就会从数组中删除,下次再去遍历执行这个idleHandler数组的时候,该事件就没有了,如果为true的话,该事件不会被删除,下次依然会被执行,所以我们按需设置。现在我们可以利用idlehandler去解决上面讲到的在onCreate里面获取控件宽高的问题


Looper.myQueue().addIdleHandler {
val mWith = bindingView.mainButton.width
val mHeight = bindingView.mainButton.height
println("按钮宽:$mWith,高:$mHeight")
false
}

当MessageQueue中的消息处理完的时候,我们的视图绘制也完成了,所以这个时候肯定也能获取控件的宽高,我们在IdleHandler里面执行了同样的代码之后,运行后的结果如下


按钮宽:979,高:187

除此之外,我们还可以做点别的事情,比如我们常说的不要在主线程里面做一些耗时的工作,这样会降低页面启动速度,严重的还会出现ANR,这样的场景除了开辟子线程去处理耗时操作之外,我们现在还可以用IdleHandler,这里举个例子,我们在主线程中给sp塞入一些数据,然后在把这些数据读取出来,看看耗时多久


println(System.currentTimeMillis())
val testData = "aabbbbakjsdhjkahsjkasdjasdhjakshdjkahsdjkhasjdkhjaskhdjkashdjkhasjkhas" +
"jkhdaabbbbakjsdhjkahsjkasdjasdhjakshdjkahsdjkhasjdkhjaskhdjkashdjkhasjkhasjkhd" +
"aabbbbakjsdhjkahsjkasdjasdhjakshdjkahsdjkhasjdkhjaskhdjkashdjkhasjkhasjkhd" +
"aabbbbakjsdhjkahsjkasdjasdhjakshdjkahsdjkhasjdkhjaskhdjkashdjkhasjkhasjkhd" +
"aabbbbakjsdhjkahsjkasdjasdhjakshdjkahsdjkhasjdkhjaskhdjkashdjkhasjkhasjkhd" +
"aabbbbakjsdhjkahsjkasdjasdhjakshdjkahsdjkhasjdkhjaskhdjkashdjkhasjkhasjkhd"
sharePreference = getSharedPreferences(packageName, MODE_PRIVATE)
for (i in 1..5000) {
sharePreference.edit().putString("test$i", testData).commit()
}
for (i in 1..5000){
sharePreference.getString("test$i","")
}
println(System.currentTimeMillis())

......运行结果
1676260921617
1676260942770

我们看到在塞入5000次数据,再读取5000次数据之后,一共耗时大概20秒,同时也阻塞了主线程,导致的现象是页面一片空白,只有等读写操作结束了,页面才展示出来,我们接着把读写操作的代码用IdleHandler执行一下看看


Looper.myQueue().addIdleHandler {
sharePreference = getSharedPreferences(packageName, MODE_PRIVATE)
val editor = sharePreference.edit()
for (i in 1..5000) {
editor.putString("test$i", testData).commit()
}
for (i in 1..5000){
sharePreference.getString("test$i","")
}
println(System.currentTimeMillis())
false
}
......运行结果
1676264286760
1676264308294

运行结果依然耗时二十秒左右,但区别在于这个时候页面不会受到读写操作的阻塞,很快就展示出来了,说明读写操作的确是等到页面渲染完才开始工作,上面过程没有放效果图主要是因为时间太长了,会影响gif的体验,有兴趣的可以自己试一下


如何让指定视图不被软键盘遮挡


我们通常使用android:windowSoftInputMode属性来控制软键盘弹出之后移动界面,让输入框不被遮挡,但是有些场景下,键盘永远都会挡住一些我们使用频次比较高的控件,比如现在我们有个登录页面,大概的样子长这样


image.png


它的布局文件是这样


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

<ImageView
android:layout_width="200dp"
android:layout_height="200dp"
android:layout_centerHorizontal="true"
android:layout_marginTop="100dp"
android:src="@mipmap/ic_launcher_round" />

<androidx.appcompat.widget.LinearLayoutCompat
android:id="@+id/ll_view1"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_alignParentBottom="true"
android:layout_marginBottom="120dp"
android:gravity="center"
android:orientation="vertical">

<EditText
android:id="@+id/main_edit"
android:layout_width="match_parent"
android:layout_height="40dp"
android:hint="请输入用户名"
android:textColor="@color/black"
android:textSize="15sp" />

<EditText
android:id="@+id/main_edit2"
android:layout_width="match_parent"
android:layout_height="40dp"
android:layout_marginTop="30dp"
android:hint="请输入密码"
android:textColor="@color/black"
android:textSize="15sp" />

<Button
android:layout_width="match_parent"
android:layout_height="50dp"
android:layout_marginHorizontal="10dp"
android:layout_marginTop="20dp"
android:text="登录" />

</androidx.appcompat.widget.LinearLayoutCompat>

</RelativeLayout>

在这样一个页面里面,由于输入框与登录按钮都比较靠页面下方,导致当输入完内容想要点击登录按钮时候,必须再一次关闭键盘才行,这样的操作在体验上就比较大打折扣了


aaaa.gif


现在希望可以键盘弹出之后,按钮也展示在键盘上面,这样就不用收起弹框以后才能点击按钮了,这样一来,windowSoftInputMode这一个属性已经不够用了,我们要想一下其他方案



  • 首先,需要让按钮也展示在键盘上方,那只能让布局整体上移把按钮露出来,在这里我们可以改变LayoutParam的bottomMargin参数来实现

  • 其次,需要知道键盘什么时候弹出,我们都知道android里面并没有提供任何监听事件来告诉我们键盘什么时候弹出,我们只能从其他角度入手,那就是监听根布局可视区域大小的变化


ViewTreeObserver


我们先获取视图树的观察者,使用addOnGlobalLayoutListener去监听全局视图的变化


bindingView.mainroot.viewTreeObserver.addOnGlobalLayoutListener {

}

接下去就是要获取根视图的可视化区域了,如何来获取呢?View里面有这么一个方法,那就是getWindowVisibleDisplayFrame,我们看下源码注释就知道它是干什么的了


image.png


一大堆英文没必要都去看,只需要看最后一句就好了,大概意思就是获取能够展示给用户的可用区域,所以我们在监听器里面加上这个方法


bindingView.mainroot.viewTreeObserver.addOnGlobalLayoutListener {
val rect = Rect()
bindingView.mainroot.getWindowVisibleDisplayFrame(rect)
}

当键盘弹出或者收起的时候,rect的高度就会跟着变化,我们就可以用这个作为条件来改变bottomMargin的值,现在我们增加一个变量oldDelta来保存前一个rect变化的高度值,用来做比较,完整的代码如下


var oldDelta = 0
val params:RelativeLayout.LayoutParams = bindingView.llView1.layoutParams as RelativeLayout.LayoutParams
val originBottom = params.bottomMargin
bindingView.mainroot.viewTreeObserver.addOnGlobalLayoutListener {
val rect = Rect()
bindingView.mainroot.getWindowVisibleDisplayFrame(rect)
val deltaHeight = r.height()
if (oldDelta != deltaHeight) {
if (oldDelta != 0) {
if (oldDelta > deltaHeight) {
params.bottomMargin = oldDelta - deltaHeight
} else if (oldDelta < deltaHeight) {
params.bottomMargin = originBottom
}
bindingView.llView1.layoutParams = params
}
oldDelta = deltaHeight
}
}

最终效果如下


aaaa2.gif


弹出后页面有个抖动是因为本身有个页面平移的效果,然后再去计算layoutparam,如果不想抖动可以在布局外层套个scrollView,用smoothScrollTo把页面滑上去就可以了,有兴趣的可以业余时间试一下


为什么LiveData的postValue会丢失数据


LiveData已经问世好多年了,大家都很喜欢用,因为它上手方便,一般知道塞数据用setValue和postValue,监听数据使用observer就可以了,然而实际开发中我遇到过好多人,一会这里用setValue一会那里用postValue,或者交替着用,这种做法也不能严格意义上说错,毕竟运行起来的确没问题,但是这种做法确实是存在风险隐患,那就是连续postValue会丢数据,我们来做个实验,连续setValue十个数据和连续postValue十个数据,收到的结果都分别是什么


var testData = MutableLiveData<Int>()
fun play(){
for (i in 1..10) {
testData.value = i
}
}

mainViewModel.testData.observe(this) {
println("收到:$it")
}

//执行结果
收到:1
收到:2
收到:3
收到:4
收到:5
收到:6
收到:7
收到:8
收到:9
收到:10

setValue十次数据都可以收到,现在把setValue改成postValue再来试试


var testData = MutableLiveData<Int>()
fun play(){
for (i in 1..10) {
testData.postValue(i)
}
}

得到的结果是


收到:10

只收到了最后一条数据10,这是为什么呢?我们进入postValue里面看看里面的源码就知道了


image.png
主要看红框里面,有一个synchronized同步锁锁住了一个代码块,我们称为代码块1,锁的对象是mDataLock,代码块1做的事情先是给postTask这个布尔值赋值,接着把传进来的值赋给mPendingData,那我们知道了,postTask除了第一个被执行的时候,值是true,结下去等mPendingData有值了以后就都为false,前提是mPendingData没有被重置为NOT_SET,然后我们顺着代码往下看,会看到代码接下来就要到一个mPostValueRunnable的线程里面去了,我们看下这个线程


image.png


发现同样的锁,锁住了另一块代码块,我们称为代码块2,这个代码块里面恰好是把mPendingData的值赋给newValue以后,重置为NOT_SET,这样一来,postValue又可以接受新的值了,所以这也是正常情况下每次postValue都可以接受到值的原因,但是我们想想连续postValue的场景,我们知道如果synchronized如果修饰一段代码块,那么当这段代码块获取到锁的时候,就具有优先级,只有当全部执行完以后才会释放锁,所以当代码块1连续被访问时候,代码块2是不会被执行的,只有等到代码块1执行完,释放了锁,代码块2才会被执行,而这个时候,mPendingData已经是最新的值了,之前的值已经全部被覆盖了,所以我们说的postValue会丢数据,其实说错了,应该是postValue只会发送最新数据


总结


这篇文章讲到的面试题还仅仅只是过去几年遇到的,现在面试估计除了一些常规问题之外,比重会更倾向于Kotlin,Compose,Flutter的知识点,所以只有不断的日积月累,让自己的知识点更加的全面,才能在目前竞争激烈的行情趋势下逆流而上,不会被拍打在沙滩上


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

你还在傻傻的npm run serve吗?快来尝尝这个!

web
背景 大家在日常开发中应该经常会有需要切换不同环境地址的情况。当一个项目代码切换环境地址时,vue-cli没有能够感知文件的变化,所以代理的还是旧的地址,所以通常我们需要执行npm run serve进行项目重跑,而项目重跑往往意味着长时间...
继续阅读 »

背景


大家在日常开发中应该经常会有需要切换不同环境地址的情况。当一个项目代码切换环境地址时,vue-cli没有能够感知文件的变化,所以代理的还是旧的地址,所以通常我们需要执行npm run serve进行项目重跑,而项目重跑往往意味着长时间的等待,非常痛苦!


image.png


方案调研


事实上,其实我们只是需要重启webpack为我们启动的proxy代理服务,或许能够从webpack的代理服务插件中找到解决方法。



从webpack官网可以看到proxy服务其实是由
http-proxy-middleware提供的,或许我们能够从中找到解决方法。


初步方案


在http-proxy-middleware的配置选项中,除了我们常见的target,还有router。router返回一个字符串的服务地址,当两个选项都配置了的情况下,会优先使用router函数的返回值,只有当router的返回值不可用时,才会使用target的值。


我们可以利用这一点来重新配置我们的项目代码。参考文档在这里


// vue.config.js
const { defineConfig } = require('@vue/cli-service')
const { proxy } = require('./environments/proxy.js')
module.exports = defineConfig({
devServer:{
proxy
},
})

复制代码

// proxy.js 
const fs = require('fs')
const path = require('path')
const encoding = 'utf-8'

const getContent = filename => {
const dir = path.resolve(process.cwd(), 'environments')
return fs.readFileSync(path.resolve(dir, filename), { encoding })
}

const jsonParse = obj => { return Function('"use strict";return (' + obj + ')')() }

const getConfig = () => { try {
return jsonParse(getContent('proxy-config.json'))
} catch (e) { return {} } }

module.exports = {
proxy: {
// 接口匹配规则自行修改
'/api': {
// 这里必须要有字符串来进行占位
// 如果报错Invaild Url,将target改成有效的url字符串即可,如http://localhost:9001
target: 'that must have a empty placeholder',
changeOrigin: true,
router: () => (getConfig() || {}).target || ''
}
}
}

复制代码

// proxy-config.json
{ "target": "http://localhost:9001" }
复制代码

自此,当我们需要修改环境地址时,只需要修改proxy-config.json文件便能够实时生效,不再需要npm run serve


重点代码分析


实现代码中其实最主要的就是getContent这个方法,我们项目在每次发起http请求时都会调用router中的函数,而getContent则会通过node的fs服务,对我们的环境地址文件进行实时读取,从而指向我们最新修改的环境地址。


方案总结


在按照参考文档配置了项目代码之后,我们发现确实能够及时指向新的环境地址,再也不需要重启代码,不需要长时间的等待了。但是,我们多了两个需要维护的文件,每次我们修改环境地址时,不仅需要修改config中的api,还需要修改proxy-config.json中的target!


有没有可能在只需要修改config文件的情况下,实现代理地址动态修改呢?


方案优化


从上面的重点代码分析中,可以看到只要我们可以在router函数执行时,拿到正确的config文件中导出的api属性的值,也可以实现同样的效果!


这是不是意味着只要我们在函数中对config文件进行require请求,读取api的值,再return出去就能及时修改代理指向了呢?


没错,你会发现无论你怎么修改,函数内require取到的api永远是不变的,还是服务刚启动时的环境地址。


image.png


参考源码可以知道,这是因为我们在使用require请求文件信息时,node会解析出我们传入的字符串的文件路径的绝对路径,并且以绝对路径为键值,对该文件进行缓存




因此,如果我们在执行require函数时打断点进行观察的话,会发现require上面有一个cache缓存了已经加载过的文件。


image.png


这也恰恰说明了只要我们能够删除掉文件保存在require中的缓存,我们就能够拿到最新的文件内容,那么我们也可以据此得出我们的最终优化方案。


// vue.config.js
const hotRequire = modulePath => {
// require.resolve可以通过相对路径获取绝对路径
// 以绝对路径为键值删除require中的对应文件的缓存
delete require.cache[require.resolve(modulePath)]
// 重新获取文件内容
const target = require(modulePath)
return target
}

...
proxy: {
'/api': {
// 如果router有效优先取router返回的值
target: 'that must have a empty placeholder',
changeOrigin: true,
// 每次发起http请求都会执行router函数
router: () => (hotRequire('./src/utils/config') || {}).api || '',
ws: true,
pathRewrite: {
'^/api': ''
}
}
}
复制代码

自此,我们项目修改环境地址将不在需要重启项目,也不需要维护额外的文件夹,再也不需要痛苦等待了!


作者:37.2℃同志
来源:https://juejin.cn/post/7198696282336313400
收起阅读 »

终于理解~Android 模块化里的资源冲突

本文翻译自 Understanding resource conflicts in Android,原作者:Adam Campbell⚽ 前言作为 Android 开发者,我们常常需要去管理非常多不同的资源文件,编译时这些资源文件会被统一地收集和整合到同一个包...
继续阅读 »

本文翻译自 Understanding resource conflicts in Android,原作者:Adam Campbell

⚽ 前言

作为 Android 开发者,我们常常需要去管理非常多不同的资源文件,编译时这些资源文件会被统一地收集和整合到同一个包下面。根据官方的《Configure your build》文档介绍的构建过程可以总结这个过程:

  1. 编译器会将源码文件转换成包含了二进制字节码、能运行在 Android 设备上的 DEX 文件,而其他文件则被转换成编译后资源。

  2. APK 打包工具则会将 DEX 文件和编译后资源组合成独立的 APK 文件。

但如果资源的命名发生了碰撞、冲突,会对编译产生什么影响?

事实证明这个影响是不确定的,尤其是涉及到构建外部 Library。

本文将探究一些不同的资源冲突案例,并逐个说明怎样才能安全地命名资源

🇦🇷 App module 内资源冲突

先来看个最简单的资源冲突的案例:同一个资源文件中出现两个命名、类型一样的资源定义,比如:

 <!--strings.xml-->
<resources>
    <string name="hello_world">Hello World!</string>
    <string name="hello_world">Hello World!</string>
</resources>

试图去编译的话,会导致显而易见的错误提示:

 FAILURE: Build failed with an exception.

* What went wrong:
Execution failed for task ':app:mergeDebugResources'.
> /.../strings.xml: Error: Found item String/hello_world more than one time

类似的,另一种常见冲突是在多个文件里定义冲突的资源:

 <!--strings.xml-->
<resources>
    <string name="hello_world">Hello World!</string>
</resources>

<!--other_strings.xml-->
<resources>
    <string name="hello_world">Hello World!</string>
</resources>

我们会收到类似的编译错误,而这次的错误将列出所有发生冲突的具体文件位置。

 FAILURE: Build failed with an exception.

* What went wrong:
Execution failed for task ':app:mergeDebugResources'.
> [string/hello_world] /.../other_strings.xml
  [string/hello_world] /.../strings.xml: Error: Duplicate resources

Android 平台上资源的运作方式变得愈加清晰。我们需要为 App module 指定在类型、名称、设备配置等限定组合下的唯一资源。也就是说,当 App module 引用 string/hello_world 资源的时候,有且仅有一个值被解析出来。开发者们必须解决发生的资源冲突,可以选择删除那些内容重复的资源、重命名仍然需要的资源、亦或移动到其他限定条件下的资源文件。

更多关于资源和限定的信息可以参考官方的《App resources overview》 文档。

🇩🇪 Library 和 App module 的资源冲突

下面这个案例,我们将研究 Library module 定义了一个和 App module 重复的资源而引发的冲突。

 <!--app/../strings.xml-->
<resources>
    <string name="hello">Hello from the App!</string>
</resources>

<!--library/../strings.xml-->
<resources>
    <string name="hello">Hello from the Library!</string>
</resources>

当你编译上面的代码的时候,发现竟然通过了。从我们上个章节的发现来看,我们可以推测 Android 肯定采用了一个规则,去确保在这种场景下仍能够找到一个独有的 string/hello 资源值。

根据官方的《Create an Android library》文档:

编译工具会将来自 Library module 的资源和独立的 App module 资源进行合并。如果双方均具备一个资源 ID 的话,将采用 App 的资源。

这样的话,将会对模块化的 App 开发造成什么影响?比如我们在 Library 中定义了这么一个 TextView 布局:

 <!--library/../text_view.xml-->
<TextView
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:text="@string/hello"
    xmlns:android="http://schemas.android.com/apk/res/android" />

AS 中该布局的预览是这样的。


现在我们决定将这个 TextView 导入到 App module 的布局中:

 <!--app/../activity_main.xml-->
<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:gravity="center"
    tools:context=".MainActivity"
    >

    <include layout="@layout/text_view" />

</LinearLayout>

无论是 AS 中预览还是实际运行,我们可以看到下面的一个显示结果:


不仅是通过布局访问 string/hello 的 App module 会拿到 “Hello from the App!”,Library 本身拿到的也是如此。基于这个原因,我们需要警惕不要无意覆盖 Lbrary 中的资源定义。

🇧🇷 Library 之间的资源冲突

再一个案例,我们将讨论下当多个 Library 里定义了冲突的资源,会发生什么。

首先来看下如下的布局,如果这样写的话会产生什么结果?

 <!--library1/../strings.xml-->
<resources>
    <string name="hello">Hello from Library 1!</string>
</resources>

<!--library2/../strings.xml-->
<resources>
    <string name="hello">Hello from Library 2!</string>
</resources>

<!--app/../activity_main.xml-->
<TextView
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:text="@string/hello" />

string/hello 将会被显示成什么?

事实上这取决于 App build.gradle 文件里依赖这些 Library 的顺序。再次到官方的《Create an Android library》文档里找答案:

如果多个 AAR 库之间发生了冲突,依赖列表里第一个列出(在依赖关系块的顶部)的资源将会被使用。

假使 App module 有这样的依赖列表:

 dependencies {
implementation project(":library1")
implementation project(":library2")
...
}

最后 string/hello 的值将会被编译成 Hello from Library 1!

那么如果这两个 implementation 代码调换顺序,比如 implementation project(":library2") 在前、 implementation project(":library1") 在后,资源值则会被编译成 Hello from Library 2!

从这种微妙的变化可以非常直观地看到,依赖顺序可以轻易地改变 App 的资源展示结果。

🇪🇸 自定义 Attributes 的资源冲突

目前为止讨论的示例都是针对 string 资源的使用,然而需要特别留意的是自定义 attributes 这种有趣的资源类型。

看下如下的 attr 定义:

 <!--app/../attrs.xml-->
<resources>
<declare-styleable name="CustomStyleable">
<attr name="freeText" format="string"/>
</declare-styleable>

<declare-styleable name="CustomStyleable2">
<attr name="freeText" format="string"/>
</declare-styleable>
</resources>

大家可能都认为上面的写法能通过编译、不会报错,而事实上这种写法必将导致下面的编译错误:

 Execution failed for task ':app:mergeDebugResources'.
> /.../attrs.xml: Error: Found item Attr/freeText more than one time

但如果 2 个 Library 也采用了这样的自定义 attr 写法:

 <!--library1/../attrs.xml-->
<resources>
<declare-styleable name="CustomStyleable">
<attr name="freeText" format="string"/>
</declare-styleable>
</resources>

<!--library2/../attrs.xml-->
<resources>
<declare-styleable name="CustomStyleable2">
<attr name="freeText" format="string"/>
</declare-styleable>
</resources>

事实上它却能够通过编译。

然而,如果我们进一步将 Library2 的 attr 做些调整,比如改为 <attr name="freeText" format="boolean"/>。再次编译,它竟然又失败了,而且出现了更多令人费解的错误:

 * What went wrong:
Execution failed for task ':app:mergeDebugResources'.
> A failure occurred while executing com.android.build.gradle.internal.tasks.Workers$ActionFacade
> Android resource compilation failed
/.../library2/build/intermediates/packaged_res/debug/values/values.xml:4:5-6:25: AAPT: error: duplicate value for resource 'attr/freeText' with config ''.
/.../library2/build/intermediates/packaged_res/debug/values/values.xml:4:5-6:25: AAPT: error: resource previously defined here.
/.../app/build/intermediates/incremental/mergeDebugResources/merged.dir/values/values.xml: AAPT: error: file failed to compile.

上面错误的一个重点是: mergeDebugResources/merged.dir/values/values.xml: AAPT: error: file failed to compile

到底是怎么回事呢?

事实上 values.xml 的编译指的是为 App module 生成 R 类。编译期间,AAPT 会尝试在 R 类里为每个资源属性生成独一无二的值。而对于 styleable 类型里的每个自定义 attr,都会在 R 类里生成 2 个的属性值。

第一个是 styleable 命名空间属性值(位于 R.styleable 包下),第二个是全局的 attr 属性值(位于 R.attr 包下)。对于这个探讨的特殊案例,我们则遇到了全局属性值的冲突,并且由于此冲突造成存在 3 个属性值:

  • R.styleable.CustomStyleable_freeText:来自 Library1,用于解析 string 格式的、名称为 freeText 的 attr

  • R.styleable.CustomStyleable2_freeText:来自 Library2,用于解析 boolean 格式的、名称为 freeText 的 attr

  • R.attr.freeText:无法被成功解析,源自我们给它赋予了来自 2 个 Library 的数值,而它们的格式不同,造成了冲突

前面能通过编译的示例是因为 Library 间同名的 R.attr.freeText 格式也相同,最终为 App module 编译到的是独一无二的数值。需要注意:每个 module 具备自己的 R 类,我们不能总是指望属性的数值在 Library 间保持一致。

再次看下官方的《Create an Android library》文档的建议:

当你构建依赖其他 Library 的 App module 时,Library module 们将会被编译成 AAR 文件再添加到 App module 中。所以,每个 Library 都会具备自己的 R 类,用 Library 的包名进行命名。所有包都会创建从 App module 和 Library module 生成的 R 类,包括 App module 的包和 Library moudle 的包。

📝 结语

所以我们能从上面的这些探讨得到什么启发?

是资源编译过程的复杂和微妙吗?

确实是的。但是作为开发者,我们能为自己和团队做的是:解释清楚定义的资源想要做什么,也就是说可以加上名称前缀。我们最喜欢的官方文档《Create an Android library》也提到了这宝贵的一点:

通用的资源 ID 应当避免发生资源冲突,可以考虑使用前缀或其他一致的、对 module 来说独一无二的命名方案(抑或是整个项目都是独一无二的命名)。

根据这个建议,比较好的做法是在我们的项目和团队中建立一个模式:在 module 中的所有资源前加上它的 module 名称,例如library_help_text

这将带来两个好处:

  1. 大大降低了名称冲突的概率。

  2. 明确资源覆盖的意图。

    比如也在 App module 中创建 library_help_text 的话,则表明开发者是有意地覆盖 Library module 中的某些定义。有的时候我们的确会想去覆盖一些其他资源,而这样的编码方式可以明确地告诉自己和团队,在编译的时候会发生预期的覆盖。

抛开内部开发不谈,至少是所有公开的资源都应该加上前缀,尤其是作为一个供应商或者开源项目去发布我们的 library。

可以往的经验来看,Google 自己的 library 也没有对所有的资源进行恰当地前缀命名。这将导致意外的副作用:依赖我们发行的 library 可能会因为命名冲突引发 App 编译失败。

Not a great look!

例如,我们可以看到 Material Design library 会给它们的颜色资源统一地添加 mtrl 的前缀。可是 styleable 下嵌套的 attribute resources 却没有使用 material 之类的前缀。

所以你会看到:假使一个 module 依赖了 Material library,同时依赖的另一个 library 中包含了与 Material library 一样名称的 attribute,那么在为这个 moudle 生成 R 类的时候,会发生冲突的可能。

🙏 鸣谢

本篇文章受到了下面文章或文档的启发和帮助:

📚 原文

作者:TechMerger
来源:juejin.cn/post/7170562275374268447

收起阅读 »

由浅入深,聊聊OkHttp的那些事(很长,很细节)

引言 在 Android 开发的世界中,有一些组件,无论应用层技术再怎么迭代,作为基础支持,它们依然在那里。 比如当我们提到网络库时,总会下意识想到一个名字,即 OkHttp 。 尽管对于大多数开发者而言,通常情况下使用的是往往它的封装版本 Retrofit ...
继续阅读 »

引言


Android 开发的世界中,有一些组件,无论应用层技术再怎么迭代,作为基础支持,它们依然在那里。
比如当我们提到网络库时,总会下意识想到一个名字,即 OkHttp


尽管对于大多数开发者而言,通常情况下使用的是往往它的封装版本 Retrofit ,不过其底层依然离不开 Okhttp 作为基础支撑。而无论是自研网络库的二次封装,还是个人使用,OkHttp 也往往都是不二之选。


故本篇将以最新视角开始,用力一瞥 OkHttp 的设计魅力。


本文对应的 OkHttp 版本: 4.10.0



本篇定位 中高难度,将从背景到使用方式,再到设计思想与源码解析,尽可能全面、易懂。



背景


每一个技术都有其变迁的历史背景与特性,本小节,我们将聊一聊 Android网络库 的迭代史,作为开篇引语,润润眼。 🔖


关于 Android网络库 的迭代历史,如下图所示:


petterp-image


具体进展如下:




  • HttpClient


    Android1.0 时推出。但存在诸多问题,比如内存泄漏,频繁的GC等。5.0后,已被弃用;




  • HttpURLConnection


    Android2.2 时推出,比 HttpClient 更快更稳定,Android4.4 之后底层已经被 Okhttp 替代;




  • volley


    Google 2013年开源,基于 HttpURLConnection 的封装,具有良好的扩展性和适用性,不过对于复杂请求或者大量网络请求时,性能较差。目前依然有不少项目使用(通常是老代码的维护);




  • okhttp


    Square 2013年开源,基于 原生Http 的底层设计,具有 快速稳定节省资源 等特点。是目前诸多热门网络请求库的底层实现,比如 RetrofitRxHttp 等;




  • Retrofit


    Square 2013年开源,基于 OkHttp 的封装,目前 主流 的网络请求库。


    通过注解方式配置网络请求、REST风格 api、解耦彻底、经常会搭配 Rx等 实现 框架联动;







上述的整个过程,也正是伴随了 Android 开发的各个时期,如果将上述分为 5个阶段 的话,那么则为:



HttpClient -> HttpURLConnection -> volley -> okhttp -> Retrofit*



通过 Android网络库 的迭代历史,我们不难发现,技术变迁越来越趋于稳定,而 OkHttp 也已经成为了基础组件中不可所缺的一员。


设计思想


当聊到OkHttp的设计思想,我们想知道什么?



应用层去看,熟练的开发者会直接喊出拦截器,巴拉巴拉…


而作为初学者,可能更希望的事广度与解惑,OkHttp 到底牛在了什么地方,或者说常说的 拦截器到底是什么 ? 🧐



在官方的描述中,OkHttp 是一个高效的 Http请求框架 ,旨在 简化 客户端网络请求,提高 请求效率。


具体设计思想与特性如下:



  • 连接复用 :避免在每个请求之间重新建立连接。

  • 连接池 降低了请求延迟 (HTTP/2不可用情况下);

  • 自动重试 :在请求失败时自动重试请求,从而提高请求可靠性。

  • 自动处理缓存 :会按照预定的缓存策略处理缓存,以便最大化网络效率。

  • 支持HTTP/2, 并且允许对同一个主机的所有请求共享一个套接字(HTTP/2);

  • 简化Api:Api设计简单明了,易于使用,可以轻松发起请求获取响应,并处理异常。

  • 支持gzip压缩 :OkHttp支持gzip压缩,以便通过减少网络数据的大小来提高网络效率。


特别的,如果我们的服务器或者域名有 多个IP地址OkHttp 将在 第一次 连接失败时尝试替代原有的地址(对于 IPv4+IPv6 和托管在冗余数据中心的服务是必需的)。并且支持现代 TLS 功能(TLS 1.3、ALPN、证书固定)。它可以配置为回退以实现广泛的连接。



总的来说,其设计思想是通过 简化请求过程提高请求效率提高请求可靠性,从而提供 更快的响应速度



应用层的整个请求框架图如下:


okhttp


使用方式


在开始探究设计原理与思想之前,我们还是要先看看最基础的使用方式,以便为后续做一些铺垫。


// build.gradle
implementation "com.squareup.okhttp3:okhttp:4.10.0"
复制代码

// Android Manifest
<uses-permission android:name="android.permission.INTERNET" />
复制代码

发起一个get请求


image-20230210152634416


拦截器的使用


image-20230210152655622


总结起来就是下面几步:




  1. 创建 OkHttpClient 对象;

  2. 构建 Request ;

  3. 调用 OkHttpClient 执行 request 请求 ;

  4. 同步阻塞 或者 异步回调 方式接收结果;



更多使用方式,可以在搜索其他同学的教程,这里仅仅只是作为后续解析原理时的必要基础支撑。


源码分析


基础配置


OkHttpClient


val client = OkHttpClient.Builder().xxx.build()
复制代码

由上述调用方式,我们便可以猜出,这里使用了 构建者模式 去配置默认的参数,所以直接去看 OkHttpClient.Builder 支持的参数即可,具体如下:


image-20230210152738025


具体的属性意思在代码中也都有注释,这里我们就不在多提了。


需要注意的是,在使用过程中,对于 OkHttpClient 我们还是应该缓存下来或者使用单例模式以便后续复用,因为其相对而言还是比较重。




Request


指客户端发送到服务器的 HTTP请求


OkHttp 中,可以使用 Request 对象来构建请求,然后使用 OkHttpClient 对象来发送请求。
通常情况下,一个请求包括了 请求头请求方法请求路径请求参数url地址 等信息。主要是用来请求服务器返回某些资源,如网页、图片、数据等。


具体源码如下所示:


Request.Builder().url("https://www.baidu.com").build()
复制代码

open class Builder {
// url地址
internal var url: HttpUrl? = null
// 请求方式
internal var method: String
// 请求头
internal var headers: Headers.Builder
// 请求体
internal var body: RequestBody? = null
// 请求tag
internal var tags: MutableMap<Class<*>, Any>
}
复制代码



发起请求


execute()


用于执行 同步请求 时调用,具体源码如下:


client.newCall(request).execute()
复制代码

接下来我们再去看看 client.newCall() , 即请求发起时的逻辑。


image-20230210152833933


当我们使用 OkHttpClient.newCall() 方法时,实际是创建了一个新的 RealCall 对象,用于 应用层与网络层之间的桥梁,用于处理连接、请求、响应以及流 ,其默认构造函数中需要传递 okhttpClient 对象以及 request


接着,使用了 RealCall 对象调用了其 execute() 方法开始发起请求,该方法内部会将当前的 call 加入我们 Dispatcher 分发器内部的 runningSyncCalls 队列中取,等待被执行。接着调用 getResponseWithInterceptorChain() ,使用拦截器获取本次请求响应的内容,这也即我们接下来要关注的步骤。




enqueue()


执行 异步请求 时调用,具体源码如下:


client.newCall(request).enqueue(CallBack)
复制代码

image-20230210153019986


当我们调用 RealCall.enqueue() 执行异步请求时,会先将本次请求加入 Dispather.readyAsyncCalls 队列中等待执行,如果当前请求是 webSocket 请求,则查找与当前请求是同一个 host 的请求,如果存在一致的请求,则复用先前的请求。


接下来调用 promoteAndExecute() 将所有符合条件可以请求的 Call 从等待队列中添加到 可请求队列 中,再遍历该请求队列,将其添加到 线程池 中去执行。


继续沿着上面的源码,我们去看 asyncCall.executeOn(executorService) ,如下所示:


image-20230210153055350


上述逻辑也很简单,当我们将任务添加到线程池后,当任务被执行时,即触发 run() 方法的调用。该方法中会去调用 getResponseWithInterceptorChain() 从而使用拦截器链获取服务器响应,从而完成本次请求。请求成功后则调用我们开始时的 callback对象 的 onResponse() 方法,异常(即失败时)则调用 onFailure() 方法。




拦截器链


在上面我们知道,他们最终都走到了 RealCall.getResponseWithInterceptorChain() 方法,即使用 拦截器链 获取本次请求的响应内容。不过对于初看OkHttp源码的同学,这一步应用会有点迷惑,拦截器链 是什么东东👾?


在解释 拦截器链 之前,我们不妨先看一下 RealCall.getResponseWithInterceptorChain() 方法对应的源码实现,然后再去解释为什么,也许更容易理解。


具体源码如下:


image-20230210155112457


上述的逻辑非常简单,内部会先创建一个局部拦截器集合,然后将我们自己设置的普通拦截器添加到该集合中,然后添加核心的5大拦截器,接着再将我们自定义的网络拦截器也添加到该集合中,最终才添加了真正用于执行网络请求的拦截器。接着创建了一个拦截器责任链 RealInterceptorChain ,并调用其 proceed() 方法开始执行本次请求。




责任链模式


在上面我们说到了,要解释 OkHttp 的拦截器链,我们有必要简单聊一下什么是责任链模式?



责任链模式(Chain of Responsibility)是一种处理请求的模式,它让多个处理器都有机会处理该请求,直到其中某个处理成功为止。责任链模式把多个处理器串成链,然后让请求在链上传递。


摘自 责任链模式 @廖雪峰



Android 中常见的事件分发为例:当我们的手指点击屏幕开始,用户的触摸事件从 Activity 开始分发,接着从 windows 开始分发到具体的 contentView(ViewGroup) 上,开始调用其 dispatchTouEvent() 方法进行事件分发。在这个方法内,如果当前 ViewGroup 不进行拦截,则默认会继续向下分发,寻找当前 ViewGroup 下对应的触摸位置 View ,如果该 View 是一个 ViewGroup ,则重复上述步骤。如果事件被某个 view 拦截,则触发其 onTouchEvent() 方法,接着交由该view去消费该事件。而如果事件传递到最上层 view 还是没人消费,则该事件开始按照原路返回,先交给当前 view 自己的 onTouchEvent() ,因为自己不消费,则调用其 父ViewGrouponTouchEvent() ,如此层层传递,最终又交给了 Act 自行处理。上述这个流程,就是 责任链模式 的一种体现。


如下图所示:


img



上图来自 Android事件分发机制三:事件分发工作流程 @一只修仙的猿





看完什么是责任链模式,让我们将思路转回到 OkHttp 上面,我们再去看一下 RealInterceptorChain 源码。


image-20230210153338845


image-20230210153424035


上述逻辑如下:




  • getResponseWithInterceptorChain() 方法内部最终调用 RealInterceptorChain.proceed() 时,内部传入了一个默认的index ,这个 index 就代表了当前要调用的 拦截器item ,并在方法内部每次创建一个新的 RealInterceptorChain 链,index+1,再调用当前拦截器 intercept() 方法时,然后将下一个链传入;




  • 最开始调用的是用户自定义的 普通拦截器,如果上述我们添加了一个 CustomLogInterceptor 的拦截器,当获取 response 时,我们需要调用 Interceptor.Chain.proceed() ,而此时的 chain 正是下一个拦截器对应的 RealInterceptorChain




  • 上述流程里,index从0开始,以此类推,一直到链条末尾,即 拦截器集合长度-1处;




  • 当遇到最后一个拦截器 CallServerInterceptor 时,此时因为已经是最后一个拦截器,链条肯定要结束了,所以其内部肯定也不会调用 proceed() 方法。


    相应的,为什么我们在前面说 它 是真正执行与服务器建立实际通讯的拦截器?


    因为这个里会获取与服务器通讯的 response ,即最初响应结果,然后将其返回上一个拦截器,即我们的网络拦截器,再接着又向上返回,最终返回到我们的普通拦截器处,从而完成整个链路的路由。




参照上面的流程,即大致思路图如下:


petterp-image


拦截器


RetryAndFollowUpInterceptor


见名知意,用于 请求失败重试 工作以及 重定向 的后续请求工作,同时还会对 连接 做一些初始化工作。


image-20230210155454132


上述的逻辑,我们分为四段进行分析:



  1. 请求时如果遇到异常,则根据情况去尝试恢复,如果不能恢复,则抛出异常,跳过本次请求;如果请求成功,则在 finally 里释放资源;

  2. 如果请求是重试之后的请求,那么将重试前请求的响应体设置为null,并添加到当前响应体的 priorResponse 字段中;

  3. 根据当前的responseCode判断是否需要重试,若不需要,则返回 response ;若需要,则返回 request ,并在后续检查当前重试次数是否达到阈值;

  4. 重复上述步骤,直到步骤三成功。


在第一步时,获取 response 时,需要调用 realChain.proceed(request) ,如果你还记得上述的责任链,所以这里触发了下面的拦截器执行,即 BridgeInterceptor




BridgeInterceptor


用于 客户端和服务器 之间的沟通 桥梁 ,负责将用户构建的请求转换为服务器需要的请求。比如添加 content-typecookie 等,再将服务器返回的 response 做一些处理,转换为客户端所需要的 response,比如移除 Content-Encoding ,具体见下面源码所示:


image-20230210154444955


上述逻辑如下:



  1. 首先调用 chain.request() 获取原始请求数据,然后开始重新构建请求头,添加 header 以及 cookie 等信息;

  2. 将第一步构建好的新的 request 传入 chain.proceed() ,从而触发下一个拦截器的执行,并得到 服务器返回的 response。然后保存 response 携带的 cookie,并移除 header 中的 Content-EncodingContent-Length,并同步修改 body




CacheInterceptor


见名知意,其用于网络缓存,开发者可以通过 OkHttpClient.cache() 方法来配置缓存,在底层的实现处,缓存拦截器通过 CacheStrategy 来判断是使用网络还是缓存来构建 response。具体的 cache 策略采用的是 DiskLruCache


Cache的策略如下图所示:


image-20230210155727822


具体源码如下所示:


image-20230210155609448


具体的逻辑如上图所示,具体可以参照上述的 Cache 流程图,这里我们再说一下 CacheStrategy 这个类,即决定何时使用 网络请求、响应缓存。


CacheStrategy


image-20230210155853603




ConnectInterceptor


实现与服务器真正的连接。


image-20230210154605273


上述流程如下:



  • 初始化 一个 exchange 对象;

  • 根据 exchange 对象来复制创建一个新的连接责任链;

  • 执行该连接责任链。


那 Exchange 是什么呢?



在官方的解释里,其用于 传递单个 HTTP 请求和响应对,在 ExchangeCode 的基础上担负了一些管理及事件分发的作用。


具体而言,ExchangeRequest 相对应,新建一个请求时就会创建一个 Exchange,该 Exchange 负责将这个请求发送出去并读取到响应数据,而具体的发送与接收数据使用的则是 ExchangeCodec



相应的,ExchangeCode 又是什么呢?



ExchangeCodec 负责对 request 编码及解码 Response ,即写入请求及读取响应,我们的请求及响应数据都是通过它来读写。


通俗一点就是,ExchangeCodec 是请求处理器,它内部封装了 OkHttp 中执行网络请求的细节实现,其通过接受一个 Request 对象,并在内部进行处理,最终生成一个符合 HTTP 协议标准的网络请求,然后接受服务器返回的HTTP响应,并生成一个 Response 对象,从而完成网络请求的整个过程。



额外的,我们还需要再提一个类,ExchangeFinder



用于寻找可用的 Exchange ,然后发送下一个请求并接受下一个响应。



虽然上述流程看起来似乎很简单,但我们还是要分析下具体的流程,源码如下所示:


RealCall.initExchange()

初始化 Exchage 的过程。


ExchangeFinder 找到一个新的或者已经存在的 ExchangeCodec,然后初始化 Exchange ,以此来承载接下来的HTTP请求和响应对。


image-20230210154713820




ExchangeFinder.find()

查找 ExchangeCodec(请求响应编码器) 的过程。


image-20230210154640516


接下来我们看看查找 RealConnection 的具体过程:


image-20230210160033258


上述的整个流程如下:


上述会先通过 ExchangeFinderRealConnecionPool 中尝试寻找已经存在的连接,未找到则会重新创建一个 RealConnection(连接) 对象,并将其添加到连接池里,开始连接。然后根据找到或者新创建 RealConnection 对象,并根据当前请求协议创建不同的 ExchangeCodec 对象并返回,最后初始化一个 Exchange 交换器并返回,从而实现了 Exchange 的初始化过程。


在具体找寻 RealConnection 的过程中,一共尝试了5次,具体如下:



  1. 尝试重连 call 中的 connection,此时不需要重新获取连接;

  2. 尝试从连接池中获取一个连接,不带路由与多路复用;

  3. 再次尝试从连接池中获取一个连接,带路由,不带多路复用;

  4. 手动创建一个新连接;

  5. 再次尝试从连接池中获取一个连接,带路由与多路复用;


Exchange 初始化完成后,再复制该对象创建一个新的 Exchange ,并执行下一个责任链,从而完成连接的建立。




networkInterceptors


网络拦截器,即 client.networkInterceptors 中自定义拦截器,与普通的拦截器 client.interceptors 不同的是:


由于网络拦截器处于倒数第二层,在 RetryAndFollowUpInterceptor 失败或者 CacheInterceptor 返回缓存的情况下,网络拦截器无法被执行。而普通拦截器由于第一步就被就执行到,所以不受这个限制。




CallServerInterceptor


链中的最后一个拦截器,也即与服务器进行通信的拦截器,利用 HttpCodec 进行数据请求、响应数据的读写。


具体源码如下:


image-20230210160138216


先写入要发送的请求头,然后根据条件判断是否写入要发送的请求体。当请求结束后,解析服务器返回的响应头,构建一个新的 response 并返回;如果 response.code100,则重新读取响应体并构建新的 response。因为这是最底层的拦截器,所以这里肯定不会再调用 proceed() 再往下执行。


小结


至此,关于 OkHttp 的分析,到这里就结束了。为了便于理解,我们再串一遍整个思路:


OkHttp 中,RealCallCall 的实现类,其负责 执行网络请求 。其中,请求 requestDispatcher 进行调度,其中 异步调用 时,会将请求放到到线程池中去执行; 而同步的请求则只是会添加到 Dispatcher 中去管理,并不会有线程池参与协调执行。


在具体的请求过程中,网络请求依次会经过下列拦截器组成的责任链,最后发送到服务器。



  1. 普通拦截器,client.interceptors()

  2. 重试、重定向拦截器 RetryAndFollowUpInterceptor

  3. 用于客户端与服务器桥梁,将用户请求转换为服务器请求,将服务器响应转换为用户响应的的 BridgeInterceptor

  4. 决定是否需要请求服务器并写入缓存再返回还是直接返回服务器响应缓存的 CacheInterceptor;

  5. 与服务器建立连接的 ConnectInterceptor

  6. 网络拦截器,client.networkInterceptors();

  7. 执行网络请求的 CallServerInterceptor;


而相应的服务器响应体则会从 CallServerInterceptor 开始依次往前开始返回,最后由客户端进行处理。



需要注意的是,当我们 RetryAndFollowUpInterceptor 异常或者 CacheInterceptor 拦截器直接返回了有效缓存,后续的拦截器将不会执行。



常见问题


OkHttp如何判断缓存有效性?


这里其实主要说的是 CacheInterceptor 拦截器里的逻辑,具体如下:


OkHttp 使用 HTTP协议 中的 缓存控制机制 来判断缓存是否有效。如果请求头中包含 "Cache-Control""If-None-Match" / "If-Modified-Since" 字段,OkHttp 将根据这些字段的值来决定是否使用缓存或从网络请求响应。



Cache-Control 指 包含缓存控制的指令,例如 "no-cache""max-age" ;


If-None-Match 指 客户端缓存的响应的ETag值,如果服务器返回相同的 ETag 值,则说明响应未修改,缓存有效;


If-Modified-Since 指 客户端缓存的响应的最后修改时间,如果服务器确定响应在此时间后未更改,则返回304 Not Modified状态码,表示缓存有效。



相应的,OkHttp 也支持自定义缓存有效性控制,开发者可以创建一个 CacheControl 对象,并将其作为请求头添加到 Request 中,如下所示:


// 禁止OkHttp使用缓存
val cacheControl = CacheControl.Builder()
.noCache()
.build()
val request = Request.Builder()
.cacheControl(cacheControl)
.url("https://www.baidu.com")
.build()
复制代码

OkHttp如何复用TCP连接?


这个其实主要说的是 ConnectInterceptor 拦截器中初始化 Exchange 时内部做的事,具体如下:


OkHttp 使用连接池 RealConnectionPool 管理所有连接,连接池将所有活动的连接存储在池中,并维护了一个空闲的连接列表(TaskQueue),当需要新的连接时,优先尝试从这个池中找,如果没找到,则 重新创建 一个 RealConnection 连接对象,并将其添加到连接池中。在具体的寻找连接的过程中,一共进行了下面5次尝试:



  1. 尝试重连 RealCall 中的 connection,此时不需要重新获取连接;

  2. 尝试从连接池中获取一个连接,不带路由与多路复用;

  3. 再次尝试从连接池中获取一个连接,带路由,不带多路复用;

  4. 手动创建一个新连接;

  5. 再次尝试从连接池中获取一个连接,带路由与多路复用;


当然 OkHttp 也支持自定义连接池,具体如下:


image-20230210154740343


上述代码中,创建了一个新的连接池,并设置其保留最多 maxIdleConnections 个空闲连接,并且连接的存活期为 keepAliveDuration 分钟。


OKHttp复用TCP连接的好处是什么?


OkHttp 是由连接池管理所有连接,通过连接池,从而可以限制连接的 最大数量,并且对于空闲的连接有相应的 存活期限 ,以便在长时间不使用后关闭连接。当请求结束时,并且将保留该连接,便于后续 复用 。从而实现了在多个请求之间共享连接,避免多次建立和关闭TCP连接的开销,提高请求效率。


OkHttp中的请求和响应 与 网络请求和响应,这两者有什么不同?


OkHttp 中的的请求和响应指的是客户端创建的请求对象 Request 和 服务端返回的响应对象 Response,这两个对象用于定义请求和响应的信息。网络请求和响应指的是客户端向服务端发送请求,服务端返回相应的过程。


总的来说就是,请求和响应是应用程序内部自己的事,网络请求和响应则是发生在网络上的请求和响应过程


OkHttp 应用拦截器和网络拦截器的区别?



  • 从调用方式上而言,应用拦截器指的是 OkhttpClient.intercetors ,网络拦截器指的是 OkHttpClient.netIntercetors

  • 从整个责任链的调用来看,应用拦截器一定会被执行一次,而网络拦截器不一定会执行或者执行多次情况,比如当我们 RetryAndFollowUpInterceptor 异常或者 CacheInterceptor 拦截器直接返回了有效缓存,后续的拦截器将不会执行,相应的网络拦截器也自然不会执行到;当我们发生 错误重试 或者 网络重定向 时,网络拦截器此时可能就会执行多次。

  • 其次,除了 CallServerInterceptorCacheIntercerceptor 缓存有效之外,每个拦截器都应该至少调用一次 realChain.proceed() 方法。但应用拦截器可以调用多次 processed() 方法,因为其在请求流程中是可以递归调用;而网络拦截器只能调用一次 processed() 方法,否则将导致请求重复提交,影响性能,另外,网络拦截器没有对请求做修改的可能性,因此不需要再次调用 processed() 方法。

  • 使用方式的 本质而言,应用拦截器可以 拦截和修改请求和响应 ,但 不能修改网络请求和响应 。比如使用应用拦截器添加请求参数、缓存请求结果;网络拦截器可以拦截和修改网络请求和响应。例如使用网络拦截器添加请求头、修改请求内容、检查响应码等。

  • 在相应的执行顺序上,网络拦截器是 先进先出(FIFO) ,应用拦截器是 先进后出(FILO) 的方式执行。


结语


本篇中,我们从网络库的迭代历史,一直到 OkHttp 的使用方式、设计思想、源码探索,最后又聊了聊常见的一些问题,从而较系统的了解了 OkHttp 的方方面面,也解释了 OkHttp应用层 的相关问题,当然这些问题我相信也仅仅只是冰山一角🧩。 更多面试相关,或者实际问题,仍需要我们自己再进行完善,从而形成全面的透析力。


这篇文章断断续续写了将近两周,其中肯定有不少部分存在缺陷或者逻辑漏洞,如果您发现了,也可以告诉我。


通过这篇文章,于我个人而言,也是完成了对于 OkHttp应用层 一次较系统的了解,从而也完善了知识拼图中重要的一块,期待作为读者的你也能有如此或者更深的体会。🏃🏻


更多


这是 解码系列 - OkHttp 篇,如果你觉得这个系列写的还不错,不妨点个关注催更一波,当然也可以看看其他篇:





参阅



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

Android电量优化,让你的手机续航更持久

节能减排,从我做起。一款Android应用如果非常耗电,是一定会被主人嫌弃的。自从Android手机的主人用了你开发的app,一天下来,也没干啥事,电就没了。那么他就会想尽办法找出耗电量杀手,当他找出后,很有可能你开发的app就被无情的卸载了。为了避免这种事情...
继续阅读 »

节能减排,从我做起。一款Android应用如果非常耗电,是一定会被主人嫌弃的。自从Android手机的主人用了你开发的app,一天下来,也没干啥事,电就没了。那么他就会想尽办法找出耗电量杀手,当他找出后,很有可能你开发的app就被无情的卸载了。为了避免这种事情发生,我们就要想想办法让我们的应用不那么耗电,电都用在该用的时候和地方。

通过power_profile.xml查看各个手机硬件的耗电量

Google要求手机硬件生产商都要放入power_profile.xml文件到ROM里面。有些不太负责的手机生产商,就乱配,也没有真正测试过。但我们还是可以大概知道耗电的硬件都有哪些。

先从ibotpeaches.github.io/Apktool/ 下载apktool反编译工具,然后执行adb命令,将手机framework的资源apk拉取出来。

adb pull /system/framework/framework-res.apk ./

然后我们用下载好的反编译工具,将framework-res.apk进行反编译。

java -jar apktool_2.7.0.jar d framework-res.apk

apktool_2.7.0.jar换成你下载的具体的jar包名称。 power_profile.xml文件的目录如下:

framework-res/res/xml/power_profile.xml

<?xml version="1.0" encoding="utf-8"?>
<device name="Android">
   <item name="ambient.on">0.1</item>
   <item name="screen.on">0.1</item>
   <item name="screen.full">0.1</item>
   <item name="bluetooth.active">0.1</item>
   <item name="bluetooth.on">0.1</item>
   <item name="wifi.on">0.1</item>
   <item name="wifi.active">0.1</item>
   <item name="wifi.scan">0.1</item>
   <item name="audio">0.1</item>
   <item name="video">0.1</item>
   <item name="camera.flashlight">0.1</item>
   <item name="camera.avg">0.1</item>
   <item name="gps.on">0.1</item>
   <item name="radio.active">0.1</item>
   <item name="radio.scanning">0.1</item>
   <array name="radio.on">
       <value>0.2</value>
       <value>0.1</value>
   </array>
   <array name="cpu.active">
       <value>0.1</value>
   </array>
   <array name="cpu.clusters.cores">
       <value>1</value>
   </array>
   <array name="cpu.speeds.cluster0">
       <value>400000</value>
   </array>
   <array name="cpu.active.cluster0">
       <value>0.1</value>
   </array>
   <item name="cpu.idle">0.1</item>
   <array name="memory.bandwidths">
       <value>22.7</value>
   </array>
   <item name="battery.capacity">1000</item>
   <item name="wifi.controller.idle">0</item>
   <item name="wifi.controller.rx">0</item>
   <item name="wifi.controller.tx">0</item>
   <array name="wifi.controller.tx_levels" />
   <item name="wifi.controller.voltage">0</item>
   <array name="wifi.batchedscan">
       <value>.0002</value>
       <value>.002</value>
       <value>.02</value>
       <value>.2</value>
       <value>2</value>
   </array>
   <item name="modem.controller.sleep">0</item>
   <item name="modem.controller.idle">0</item>
   <item name="modem.controller.rx">0</item>
   <array name="modem.controller.tx">
       <value>0</value>
       <value>0</value>
       <value>0</value>
       <value>0</value>
       <value>0</value>
   </array>
   <item name="modem.controller.voltage">0</item>
   <array name="gps.signalqualitybased">
       <value>0</value>
       <value>0</value>
   </array>
   <item name="gps.voltage">0</item>
</device>

抓到不负责任的手机生产商一枚,好家伙,这么多0.1,明眼人一看就知道这是为了应付Google。尽管这样,我们还是可以从中知道,耗电的有Screen(屏幕亮屏)、Bluetooth(蓝牙)、Wi-Fi(无线局域网)、Audio(音频播放)、Video(视频播放)、Radio(蜂窝数据网络)、Camera的Flashlight(相机闪光灯)和GPS(全球定位系统)等。

电量杀手简介

Screen

屏幕是非常耗电的一个硬件,不要问我为什么。屏幕主要有LCD和OLED两种。LCD屏幕白色光线从屏幕背后的灯管发出,尽管屏幕显示黑屏,依旧耗电,这种屏幕逐渐被淘汰,如果你翻出个早点的功能机,或许能看到。那么大部分Android手机都是OLED的屏幕,每个像素点都是独立的发光单元,屏幕黑屏时,所有像素都不发光。有必要时,让屏幕息屏很重要,当然手机也有自动息屏的时间设置,这个不太需要我们操心。

Radio数据网络和Wi-Fi无线网络

网络也是非常耗电的,其中又以数据网络的耗电更多于Wi-Fi的耗电。所以请尽量引导用户使用Wi-Fi网络使用app的部分功能,比如下载文件。

GPS

GPS也是很耗电的硬件,所以不要动不动就请求地理位置,GPS平常是要关闭的,除非你在使用定位和导航等功能,这样你的手机续航会更好。

WakeLock

如果使用了WakeLock,是可以有效防止息屏情况下的CPU休眠,但是如果不用了,你不释放掉锁的话,则会带来很大的电量的开销。

查看手机耗电的历史记录

// 上次拔掉电源到现在的耗电情况
adb shell dumpsys batterystats --unplugged

你在逗我?让我看命令行的输出?后面我们来使用Battery Historian的图表进行分析。

使用Battery Historian分析手机耗电量

安装Docker

Docker下载网址 docs.docker.com/desktop/ins…

使用Docker容器编排

docker run -p 9999:9999 gcr.io/android-battery-historian/stable:3.0 --port 9999

获取bugreport文件

Android7.0及以上

adb bugreport bugreport.zip

Android6.0及以下

adb bugreport > bugreport.txt

上传bugreport文件进行分析

在浏览器地址栏输入http://localhost:9999


点击Browse按钮并上传bugreport.zip或bugreport.txt生成分析图表。


我们可以通过时间轴来分析应用当下的电池使用情况,比较耗电的是哪部分硬件。

使用JobScheduler来合理执行后台任务

JobScheduler是Android5.0版本推出的API,允许开发者在符合某些条件时创建执行在后台的任务。比如接通电源的情况下才执行某些耗电量大的操作,也可以把一些不紧急的任务在合适的时候批量处理,还可以避开低电量的情况下执行某些任务。

作者:dora
来源:juejin.cn/post/7196321890301575226

收起阅读 »