注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

Android打造专有hook,让不规范的代码扼杀在萌芽之中

俗话说,无规矩不成方圆,同样的放在代码里也是十分的贴切,所谓在代码里的规矩,指的就是规范,在一定规范约束下的项目,无论是参与开发还是后期维护,都是非常的直观与便捷,不能说赏心悦目,也可以用健壮可维护来表示;毕竟协同开发的项目,每个人都有自己的一套开发标准,你没...
继续阅读 »

俗话说,无规矩不成方圆,同样的放在代码里也是十分的贴切,所谓在代码里的规矩,指的就是规范,在一定规范约束下的项目,无论是参与开发还是后期维护,都是非常的直观与便捷,不能说赏心悦目,也可以用健壮可维护来表示;毕竟协同开发的项目,每个人都有自己的一套开发标准,你没有一套规范,或者是规范没有落地执行,想想,长此以往,会发生什么?代码堆积如山?维护成本翻倍增加?新人接手困难?等等,所谓的问题会扑面而来。


正所谓规范是一个项目的基石,也是衡量一个项目,是否健壮,稳定,可维护的标准,可谓是相当重要的。我相信,大部分的公司都有自己的一套规范标准,我也相信,很多可能就是一个摆设,毕竟人员的众多,无法做到一一的约束,如果采取人工的检查,无形当中就会投入大量的时间和人力成本,基于此,所谓的规范,也很难执行下去。


介于人工和时间的投入,我在以往的研究与探索中,开发出了一个可视化的代码检查工具,之前进行过分享,《一个便捷操作的Android可视化规范检查》,想了解的老铁可以看一看,本篇文章不做过多介绍,当时只介绍了工具的使用,没有介绍相关功能的开发过程,后续,有时间了,我会整理开源出来,一直忙于开发,老铁们,多谅解。这个可视化的检查工具,虽然大大提高了检查效率,也节省了人力和时间,但有一个潜在的弊端,就是,只能检查提交之后的代码是否符合规范,对于提交之前没有进行检查,也就说,在提交之前,规范也好,不规范也罢,都能提交上来,用工具检查后,进行修改,更改不规范的地方后然后再提交,只能采取这样的一个模式检查。


这样的一个模式,比较符合,最后的代码检查,适用于项目负责人,开发Leader,对组员提交上来的代码进行规范的审阅,其实并不适用于开发人员,不适用不代表着不可用,只不过相对流程上稍微复杂了几步;应对这样的一个因素,如何适用于开发人员,方便在提交代码之前进行规范检查,便整体提上了研发日程,经过几日的研究与编写,一个简单便捷的Android端Git提交专有hook,便应运而生了。


说它简单,是因为不需要编写任何的代码逻辑,只需要寥寥几步命令,便安装完毕,通过配置文件,便可灵活定制属于自己的检查范围。


为了更好的阐述功能及讲述实现过程,便于大家定制自己的开发规范,再加上篇幅的约束,我总结了四篇文章来进行系统的梳理,还请大家,保持关注,今天这篇,主要讲述最终的开发成果,也就是规范工具如何使用,规范这个东西,其实大差不差,大家完全可以使用我自己已经开发好的这套。


这个工具的开发,利用的是git 钩子(hook),当然也是借助的是Node.js来实现的相关功能,下篇文章会详细介绍,我们先来安装程序,来目睹一下实际的效果,安装程序,只需要执行几步命令即可,无需代码介入,在实际的开发中需要开发人员,分别进行安装。


安装流程


1、安装 Node.js,如果已经安装,可直接第2步:


Node.js中允许使用 JavaScript 开发服务端以及命令行程序,我们可以去官网nodejs.org
下载最新版本的安装程序,然后一步一步进行安装就可以了,这个没什么好说的,都是开发人员。


2、安装android_standard


android_standard是最终的工具,里面包含着拦截代码判断的各种逻辑 在项目根目录下执行如下命令:


npm install android_standard --save-dev

执行完命令后,你会发现,你的项目下已经多了一个目录,还有两个json文件,如下图所示:


image.png


node_modules,用来存放下载安装的包文件夹,里面有我们要使用到的功能,其实和Android中lib目录很类似,都是一些提供功能的库。


package.json文件,是配置文件,比如应用的名字,作者,介绍,还有相关的依赖等,和Android中的build.gradle文件类似。


3、创建git配置文件,执行如下命令


node node_modules/android_standard/gitCommitConfig

命令执行成功会返回如下信息:


image.png


此命令执行完后,会在项目根目录下创建gitCommitConfig文件,这个文件很重要,是我们执行相关命令的配置文件,内容如下,大家可以根据自己实际项目需要进行更改。


项目下生成gitCommitConfig.android文件,.android是我自己定义的,至于什么格式,等你自己开发的时候,完全可以自定义,是个文件就行。


image.png


打开后,文件内容如下,此文件是比较重要的,后续所有的规范检查,都要根据这个文件里的参数来执行,大家在使用的时候,就可以通过这个文件来操作具体的规范检查。


image.png


4、更改执行文件,执行如下命令


执行文件,就是需要在上边生成的package.json文件,添加运行程序,使其在git提交时进行hook拦截。


node node_modules/android_standard/package

5、添加git过滤


因为执行完上述命令后,会产生几个文件,而这几个文件是不需要我们上传到远程仓库的,所以我们需要在.gitignore文件里添加忽略,直接复制即可。


/node_modules
package.json
package-lock.json
gitCommitConfig.android

6、后续如果有更新,可命令进行操作:


注:此命令在更新时执行


npm update android_standard --save-dev

7、删除操作


注:后续不想使用了,便可执行如下命令:


npm uninstall android_standard --save-dev

具体使用


通过上述的安装流程,短短几个命令,我们的规范检查便安装完毕,后续只需要通过gitCommitConfig.android文件,来动态的更改参数即可,是不是非常的方便,接下来,我们来实际的操作一番。


关于配置文件的相关参数,也都有注释,一看便知,这里简单针对最后的参数,做一个说明,也就是gitCommand这个参数,true为工具,false为命令方式;true也好,false也好,在主要的功能验证上,没有区别,唯一的区别就是,命令行的方式提交,会有颜色区分,后面有效果。


image.png


我们先来看下命令行下的执行效果,当配置文件开关gitCommitSwitch已开,并且gitCommand为false,其他的配置参数,大家可以根据需要进行改动,在代码提交的时候如下效果:


image.png


在Android studio中提交代码执行效果


image.png


TortoiseGit提交代码执行效果:


image.png


目前呢,针对Android端的规范检查,无论是java还是Kotlin,还是资源文件,都做了一定的适配,经过多方测试,一切正常,如果大家的公司也需要这样的一个hook工具,欢迎使用,也欢迎继续关注接下来的相关实现逻辑文章。


好了各位老铁,这篇文章先到这里,下篇文章会讲述,具体的实现过程,哦,忘记了,上篇结尾的遗留组件化还未更新,那就更新完组件化,接着更新这个,哈哈,敬请期待!


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

做个清醒的程序员之成为少数派

阅读时长约10分钟,统计2604个字。 这是一篇插队的文章。 本来我是有备稿,准备在下周一的时候发布,结果就在上周二,发生了一件事情。这件事情让我产生很多启发,我在这里把它分享给你,希望对你也有所启发。 周二下午,有位老兄加我微信,备注来自博客。这也不足为奇,...
继续阅读 »

阅读时长约10分钟,统计2604个字。


这是一篇插队的文章。


本来我是有备稿,准备在下周一的时候发布,结果就在上周二,发生了一件事情。这件事情让我产生很多启发,我在这里把它分享给你,希望对你也有所启发。


周二下午,有位老兄加我微信,备注来自博客。这也不足为奇,最近更新比较频繁,加了很多人。这位老兄一上来先是肯定了我的文章,随后指出了文中的错误。坦率地讲,自从复活博客之后,这还是第一位指出我错误的朋友,一下子我就来了兴趣。


在本系列文集的《序》中,我原文是这样写的:



我一直奉行一句话:“有道无术,尚可求也;有术无道,则止于术”。这句话出自老子的《道德经》,而且很好理解。



他指出《道德经》里其实没有这句话。但是呢,本着对读者负责的态度,我在写文章的时候确实去查了一下。程序员这个职业大家都懂,比较较真,至少我是这样的。于是我就找到了一些依据,来证明我说的是对的。但很快便发现事实其实不是这样,这位老兄所言非虚,我引的这句话确实并不出自《道德经》。所以,我要在这里向所有读过我上篇文章的朋友道个歉。澄清一下:“有道无术,尚可求也;有术无道,则止于术”,尽管这句话有几分道理,但真的不是《道德经》原文。


好了,故事就到这里结束了。说到这,大家应该也能理解我为什么要把这篇文章拿来插队。一方面趁热打铁,有错误及时声明,另一方面这个故事对我有新的启发。


这位老兄,名为张鸿羽。稍加细聊后,我得知鸿羽兄是有背过原文的,而我没有。我只是看到大部分都这样说,便信以为真,然后也跟着这样说。显然,我成为了大多数人中的一份子。而鸿羽兄是少数派中的一份子。有时候,真理真的掌握在少数人手中。


回想过去几年的工作历程,特别是刚开始工作的那几年,我做的很多工作都是“探索型”的。所谓“探索型”,就是对新技术,或者说是公司的研发部门未曾使用过的技术进行尝试摸索。当然,尝试新技术之前,要能发现新技术。而一项新技术的诞生,总会伴随着官方的宣传,以及一些支持它、拥护它的人高声叫好。但只有真正尝试过,特别是用新技术来实现较为复杂系统的时候,才会知道这项新技术到底优势在哪,劣势又在哪。


诚然,如果让我来总结我尝试新技术、新框架的经验,我会说:大部分新技术或是框架确实弥补了已有框架的不足,但其弥补的程度往往并不是质变的,只是小步优化。甚至有些新兴技术在弥补的同时,还引入了其它的问题。这对于使用它的开发者来说,的确是个坏消息。


但话说回来,没尝试用过,又怎能知道答案呢?技术的发展本就是这样一步一个坎,有时候走一步还退两步的呀。


这或许就是我等软件开发者的宿命,对于现存的技术框架,总是有这样或那样的不满意,觉得用着不顺手。期盼着某一天,某个技术大佬,或者团体,发明了一种新的框架,甚至是新的编程语言。或是直接起义,自己创造一款新的技术框架,能真正地解决那些令我们不满的问题,让软件开发编程成为真正的享受。


但现实是,很多新的技术框架的诞生,都伴随着类似的口号。也总会有勇敢的开发者尝鲜,也总会经历被坑,然后不断填坑的过程。而这些敢于尝鲜的开发者,就是那些最终会成为“少数派”的人。他们知道在各种美好的宣传背后,隐藏着多深的坑。对于这些坑,又该用什么方法去填。


“少数派”或许才是那些头脑最清醒的那一小撮人群。


但是,成为“少数派”不仅意味着失败的尝试,还有大多数人的不理解。甚至更严重一些,就是诋毁,百口莫辩。这需要一颗强大的内心,和与时间做朋友的勇气以及态度。


不过,我为什么鼓励程序员要做“少数派”,而不是成为“大多数”呢?还有另外一个原因,那就是由行业特征决定的。我相信程序员大多都活跃在互联网行业,这个行业是赢家通吃的指数型结构。有点类似财富分配,大部分的财富掌握在少数人的手里。而且无论如何数学建模,或是提高那些穷人的初始资金,最终推演的结局依然如此。


如今,在中国,乃至全世界,所谓“互联网大厂”无非就是那几家,而剩下的呢?数字上远远超过我们熟知的那些大厂,但拥有的财富值却位于指数图表中的长尾之中。这就是指数型的行业的特征,也是程序员这个群体的特征。


如果大家有查相关的数据,可以发现优秀程序员的工作效率往往是普通程序员的好几倍,尽管薪水上的差距不是这样。而大多数都是普通程序员,优秀程序员只属于“少数派”。优秀程序员,拿到需求,会做足够的分析,到了动手的时候,则像个流水线的工人;普通程序员,拿到需求就想赶快动手,面临的有可能是回炉重造。优秀程序员,会充分考虑到使用场景,采用防御式编程来规避可能带来的缺陷;普通程序员,想的只是实现需求,把程序健壮性扔给测试人员。优秀程序员,会考虑代码的可读性,为代码添加合适的注释、每个方法或函数的功能单一、清晰;普通程序员,急于求成,不注重代码规范,导致日后维护困难……


但是,追求效率和追求质量,大多数公司都会选择前者。但做多和做好,结果往往相差甚远。


大部分人倾向于做多、扩张、追求规模化。但殊不知做大的后果往往是成本的上升,利润却不一定变高。但做好却不一样,它追求的是平衡收支,而不是盲目追求利润。更好的做法其实是在做好之前,不要做大。要相信好产品,自然会带来口碑。过分追求大规模,反倒会使高利润远去。而把事情做好的心态,看似发展得慢,实则是条捷径。


回顾我创作的历程,之前的我总想着多写,多写就是扩张,意味着规模。但这种心态往往做不出好书,因为这是效率当先,质量次之的做法。但我身边也有的人,创作很用心,不着急让书早日面试,很认真地创作,比我的速度慢一些。这便是把事情做好的心态。你猜结果如何?人家一年十几万的稿酬,我却只有可怜的几万块。


所以,上面那套理论并不是我胡乱写的,或是从哪本书里看到,就抄过来的。而是真的付出了血和泪,总结出的道理。在此,我劝你做个“清醒”的人。追求效率没错,一旦做得过火,则会适得其反。


另一方面,如果只想成为大多数,可不可以呢?当然也可以,只不过互联网行业或许不再适合。那些符合正态分布的行业才是想成为大多数的那类人的理想去处。


比如,餐饮行业。现在,大家可以想一想,有没有那家餐馆,或是哪个餐饮品牌,能做到赢家通吃?似乎没有,如果也去查这方面的数据,就会发现餐饮行业其实并不是指数分布,而是呈正态分布的。只要能做到普通中位数的水平,就OK了。


真正的高手一般都是“少数派”。他们不仅能力拔群,思考问题时的方法、对世界的认知和一般人都有区别。若要成为软件开发工程师中的“高手”,必须成为“少数派”

作者:萧文翰
来源:juejin.cn/post/7214855127625302053
,成为战场上的传说。

收起阅读 »

22年回家,治好了我的精神焦虑,终于睡了一周的好觉

2023-1-29 脑海的片段:看灿姐打麻将,拍全家福的情景,在奶奶家吃饭,奶奶给我钱,看外婆,去老街,爬山去龙泉寺,妈妈的眼睛,妈妈打呼噜,电梯外的妈妈,大双给奶奶夹菜,小双给豪儿倒洗脚水,妈妈玩抖音,给妈妈染头发 过年回家,在高铁站等了3个小时,没有带孩子...
继续阅读 »

2023-1-29


脑海的片段:看灿姐打麻将,拍全家福的情景,在奶奶家吃饭,奶奶给我钱,看外婆,去老街,爬山去龙泉寺,妈妈的眼睛,妈妈打呼噜,电梯外的妈妈,大双给奶奶夹菜,小双给豪儿倒洗脚水,妈妈玩抖音,给妈妈染头发


过年回家,在高铁站等了3个小时,没有带孩子,两个人在哪里应该都过得挺好,带着孩子,还得管他吃喝,睡觉,妈耶,真累。坐上火车,第一次坐软卧,有门,两层,床比较大,有靠背,说是以前有拖鞋。但是也不够我和儿子睡的。在上铺又总感觉要掉下来。接着好几天没睡好,半夜醒来,心慌,涌出好多口水,想吐。有点吓着了,从来没这样过,真怕自己的心脏有毛病。而且老贺把40度左右的水给我泡酸辣粉,我不想他难受,吃了半碗。好像是到湖北的某个站,上来两个重庆人,很大声,后来那个中年男人还问晨晨几岁了,我才反应过来,这是重庆人,声大,但是每个人都还是热心肠的,看着两个人好像吵架一样,其实他们在聊天,哈哈哈


到了重庆,中午了,我实在是不想洗漱了,带孩子去哪里,都累。下车去了观音桥,看着外面好像不咋的,进去人挤人,全是好吃的,要不是等会要吃美娃鱼,我觉得我可以把每个都吃个遍。看见了红姐一家,聊天下来,我发现红姐也真的是为人母了,围着孩子转,会为了家里的琐事妥协,以前吐槽的老公,在她那也是很爱的,看得出来。可可很可爱,我能感受到一点有女儿的感觉,哈哈哈


2d87fb7c74cd7cd0443ae32725b3331.jpg


1f427cb7d2a0ac9ac3a0c0da1ee11ac.jpg


e4c382de28bf282462671199173e461.jpg
永川下车,打车回家就是8点多了,回家,一路上就别寻思钱就对了,哪哪都是钱。爸爸妈妈说是等我们吃饭,两年没回家了,每次回去,还是觉得就在昨天,周围一切在改变,又好像没变。到家,老妈就开始拍抖音,这是我没有想到的。不过她有自己的世界,乐趣,也挺好的。我说要吃夹沙肉,妈妈爸爸自己做的红豆馅的馅,应该花了不少时间,很好吃,就是不敢多吃了。家里很漂亮,妈妈开始像我展示各种东西,她买的好货,哈哈哈,儿子玩上了,外婆买的垃圾车。当然两个弟弟还是玩手机,看看啥时候开始有改变


晚上睡得很香,3个人都睡得很香,好几个月没有这种感觉了,睡完觉起来,世界都那么清晰,上了厕所就转进了妈妈的被窝。听妈妈说,听妈妈抱怨。第一天,我还是可以挺住的。妈妈更唠叨了,哈哈哈。在天津,是儿子一天800遍的叫妈妈,现在又多了800遍“莎莎”。事实证明,妈妈老了,需要有人关注了。


21号,30,过年,小插曲,对于我来说,不在意,都不算事儿,我只记得菜很好吃。特别是牛肉,下午吃到塞牙,扣了好久。上午买了烟花,买的时候,两只说不感兴趣,结果晚上玩的好嗨,明年一定要多买点


22号,初一,赶集,一家人去赶集,老爸先带去买气球了,一个可爱的草莓气球,贺小狗自己选的。赶集的人不多,没有以前热闹了,卖烟花的,开始卖纸钱,火炮了。走了一圈去火锅店吃牛肉火锅。本来没抱多大期待,结果好好吃,吃了一盘子牛肉。下午跟妈妈去田间摘了好多青棉花,奈何肚子天天不消化,不然还可以吃青团,下午出去走走,也是开心的,听妈妈吐槽爸爸,带儿子玩泥巴。妈妈还说拿钱给我还贷款,我很感动,我不要她的钱,来的很辛苦,自己留着点,以后养老不用愁。


d13c91f66201c308d98bce12f715a01.jpg


背砍很多青棉花


23号,初二,本来每年初二去舅舅家,今年改成了初三。晚起的早晨,睡得依旧的香。今天给妈妈染头发,爸爸做完饭,吃完打麻将去了,我给妈妈染头发,洗头发,还算成功,妈妈开心,我就开心。我们下午决定去走走,小米跟着我们,他要是知道要走8公里,肯定不会跟我们走了,去田间走走,好舒服。路过观音菩萨,土地公公,走了很多坟堆的山里,可惜没有转角走向龙泉寺。明年补上。我给观音菩萨求妈妈睡得好,希望有用。

2fbfd1d965b24ef0da89d0df4f6b330.jpg


去龙泉寺的路


94bd173a9f6501855b10341346dc7c4.jpg


老公说瓦房就是,天快黑了,没继续下去了


24号,初三,早起了,因为爸爸说早上8点出发,结果好像就我当真了,早起,洗头,我还做了一个瑜伽。我们坐车,爸爸妈妈骑车,社恐的我,喜欢热闹,又不知道怎么与人 交流。上坟,好像是我每年比较期待的事,大家聚在一起去看望已故的老人,热闹之余,想念外婆,从她走了起,我每次去她那,都会给她磕三个头,不是秀,是真想她。灿姐喜欢声张,我不在乎别人说什么,我只在乎我心里有她,磕不磕头,是我心里想做的,不是要别人看的。我以为这么久,我会忘记,好像越来越想她了。《寻梦环游记》里说,生的人,没有人记得你,已故的老人就会消失,所以我的外婆应该还没有,因为我还记得她。去了一直害怕的老宅子看,梦里那么真实,那么黑,和老公去看的时候,仿佛在逛博物馆,不害怕了,多了几分怀念。外面的大门没了,被杜老师占了修了栋房子,里面关着鸡,进门两边的棺材没了,因为老人都走了。天井小时候感觉蛮大,中间的台,要走好多步,现在几步就跨过去了。两边的台子没有了,左边的台子是用来洗衣服的,右边的台子是坐着旁边的老人,她天天会在那里叹息,撑唤(一身疼,叫唤),骂人,使唤她老头子。右边的大门锁上了,人家搬出去了,我还记得那户人家的男的姓曾,女的姓贾(重庆话就是真假)。桃屋(饭厅)也小了,就剩下被舅舅烧纸烧黑的外公的遗照,还有边上慈祥的外婆,一个香炉,几根没烧完的香。睡觉的屋,没有了床,都空了,墙被凿了一个大洞,屋里亮了,不吓人了。下面的大屋,中间的灯绳子还在,小时候跑进去,大概知道绳子在哪里,摸一把没摸着就要跑出去,太黑了,太害怕了。天井旁边的窗户还在,以前我和外婆在那砍红薯,熬猪食。厨房都没了,没了。路过一个空屋子就到了现在舅舅住的楼房,大坝子放了几张桌子,是往年的热闹场景。吃饭前,和老公去了小时候上学走的路,还是那个小河,竹林,再也不用在田坎上跑着去上学,却有点怀念跑着去上学的时候。路走不通了,上面成了一片橘子地。坐在坝子里吃饭,真香,踏实。她们打麻将去了,我和老公去了三姨对面的路,去老街,杜老师说对面可以走去老街。对面路不太熟悉,过河的桥还是玉子板。路修成了大道,唯一不好的是有好多野狗,不敢走,老奶奶说带路,不好意思让她带那么远,我们走了老奶奶说的另一条田间小路,看见了田里的老牛,也是惬意。我们拿着两根棍子走了一道。老街的小学,旁边修了新的,老的还在,是以前一样的,不知道那个像保剑锋,去他家排练,他把衣服内衣裤子扔门后面的语文老师还在那个学校不,哈哈哈哈。老街的东西,老建筑都垮了,转转去了新街,好近,以前好像要走很久,现在不到10分钟就到。我们吃了葱油饼和凉面,凉面是那个味,酸甜辣,4块钱一碗,太便宜了。

e6d9a0829d22c82e9555feeb58236eb.jpg


橘子地


e2b35aada3b72a52092e9e324643865.jpg


玉子板的桥,30多年了吧


微信图片_20230201133535.jpg


耕地的牛


微信图片_20230201133517.jpg


去老街路上


微信图片_20230201133549.jpg


中间的灯绳,小时候的噩梦


5bd221108042713193a315bede266f8.jpg


天井旁边的窗户


微信图片_20230201133556.jpg


有大洞的屋,不吓人了


微信图片_20230201133559.jpg


老街的折叠门,最早的折叠门了吧


25号,初四,今天我们家请客,来了好多人,爸爸总是能一个人做很多菜出来,还挺好吃,肚子不争气,吃不了多少,去帮妈妈修了戒指,没想到我的手指比她粗那么多,还是大了。回来看她们打麻将,大家一起聊了些有的没的,比我想的亲切多了,特别是灿姐,好像姐妹亲更多了些,还有就是小双,他甘愿给豪儿倒洗脚水,豪儿说热了,他赶紧给加凉水。我有点不解,我在想,是不是每个人都有自己的圈子,就是学数学的圈子,交集,并集那种圈子,我们在他的并集区域里,不在他的交集区域,有点意思。他应该是算我们家最任性的人,孩子这样挺好的,长大之前至少任性过,这才是小孩子。

26号,初五,定了快3年的全家福,终于要拍上了。早起的我洗头,敷脸。热饭,跟三姨聊了会,她总是能让人觉得她可怜,或许她就是有点可怜吧,但是她又是那么自私,喜欢占便宜。说我去年初一走,聪明,省了200块钱的事,翻篇了,我不计较了。我生怕爸妈弟弟有一点不配合,急眼,这个全家福拍不上。回家一直没有太照顾老公,我感觉他懂我,也确实很理解我,没陪他,他一切都安排的很好,这点我很开心。还好妈妈没有晕车,下车,我们爬了一会山,到了山腰的影楼。有点简陋,一个区里,也还行啦。没想到给我画那么久,我的初衷是让爸妈好看点,仔细点,毕竟下次再让他们来拍,估计很难。妈妈穿婚纱真美,但是我却好想哭,不知道为什么。妈妈是老了,眼睛开始耷拉下来了,生完一场大病,右眼掉的厉害,我让化妆师再贴一个双眼皮贴,对称一下。拍照的地方好冷,可别给我妈妈感冒了,对了,忘记说,中午他们吃泡面的时候,还蛮温馨的,哈哈哈。特别感谢我老公,在我化妆的时候,给他们拍的照片,很好,其实这就算是我的生日礼物了,真的就想拍全家福。两个弟弟真是长高了,小的胖的可爱,大的帅气,爸爸年轻,妈妈美丽。一路下来,挺开心的。爸爸应该也是开心的,我看出来他尴尬了,笑的有点不自然,逗一逗就笑的开心了。他还拿手机照了一些,也是开心的,一家人都笑着拍完一组,除了不配合的儿子,不强求他们拍第二组了。这样开心的收尾挺好的。妈妈担心没拍完,我说拍多了,他们照片多了加照片要多收钱,她就放心了。跟着老公这些年,我慢慢学会了,解释,沟通,交流,站在对方的角度,再有点耐心,其实妈妈就懂了,像个孩子。只是我们身上的琐事太多,耐心在消耗,跟家人,反而多了些任性。下山大家有说有笑,挺快就到了公交点,爸妈希望去店里看看,两个弟弟肯定是不想走了,开心收尾挺好的,我们回家了。到了爷爷家,由于大伯一家没有来,没人做饭,妈妈生气,爸爸就是踏实干活。锅很黑,煤气罐没有气了,拿着大锅上外面炒的。大双烧火,爸爸炒菜,我和妈妈端菜,奶奶找盘子,爷爷站屋里用有些黑的水洗碗,儿子拿着竹子块在坝子里玩,老公时而拍照录像,时而站在那看爷爷弯腰洗碗,他好奇87岁的老人,弯腰低头洗碗,居然头不晕,这就是劳动人民的腻害之处。别说,虽然到处是鸡屎,炒出来的菜,真的蛮香的。我们坐在浓浓鸡屎味,有些霉臭,旁边是洗完碗装着污水的盆,满桌子的菜,大双吃的很香,奶奶来了,他让他涮涮有葱的碗,给她夹了够不着的豆干,他是个善良的孩子,抛开玩手机不说,其实也是个懂事的孩子。老公吃了好多,喝了蜂蜜酒,好像是他爷爷,哈哈哈。我吃了半碗饭,豆干,豆角,和鱼里的菜,其实挺好吃的。别的不重要,爷爷笑咪了眼睛。我和他们的回忆不太好,没有什么深厚的感情,但是长大后,每次去他们那,会觉得温暖,淳朴。他们也希望我去看他们。那晚,妈妈很开心,睡得很香,我们一起睡得,她摆了一会龙门阵,说这么晚了,快睡觉了,明天要早起。开始打呼噜了。希望你每晚都睡那么香,多好。


28215f5ad2d11c633a1d2206471fe55.jpg


爷爷坝子里做饭


微信图片_20230201134322.jpg


准备拍全家福啦


微信图片_20230201134326.jpg


先偷拍一点


微信图片_20230201134329.jpg


双胞胎,一点不像的弟弟


微信图片_20230201134332.jpg


双胞胎,一点不像的哥哥


27号,初六,早上的票很早,以后得长教训,早点订票了。本以为这次中午的票,可以消停的走,但是我一点不着急,感觉肯定有办法,或许内心不想走吧,不然早就着急了。吃了3碗面,真香,可惜没吃上妈妈做的肥肠鱼,好多菜,看五一吧,能不能回去一趟。大概率不行。回去他们也不放假,在管子里也算不上陪伴了。妈妈每次走的时候,都会说,回来都是晚上,走的都是早上,烧白没吃上,肥肠没吃上,土鸭子鸭杂专门给你留的,也没吃上,冰箱里的笋子也没吃上。。。。即使我在家待一个月,也会那么说,就是舍不得我走,巴不得把家里能带的都给我装下,能装的我都装上了,那是妈妈的爱。晚走一小时,好像也没晚多少,妈妈送到电梯那,我知道她为什么不下去,不是懒,是她要哭。爸爸送我们到车站,走的时候,我把贺小狗做好了,赶紧摆了摆手,生怕车快了,爸爸看不到我了,他看着呢,一直盯着呢,没舍得走,直到车走远。。。。又要在车站等3个小时,带着这只狗,咋熬,冰淇淋只撑了十几分钟。老公问我想要什么生日礼物,我开玩笑的说我想回家,真的只是玩笑,不知道为什么留下了泪水,可能家在内心深处吧。

22年回家,治好了我的精神焦虑,终于睡了一周的好觉


作者:用户5176977956251
来源:juejin.cn/post/7194623390991253541
收起阅读 »

从0搭建nestjs项目并部署到本地docker

web
开发目标:快速搭建nestjs项目本地环境,并测试本地打包方便后期部署到服务器。 项目准备:node环境、npm依赖、docker 创建项目并启动 使用typeorm连接mysql 使用class-validate校验入参 使用全局filter处理异常,使用...
继续阅读 »

开发目标:快速搭建nestjs项目本地环境,并测试本地打包方便后期部署到服务器。


项目准备:node环境、npm依赖、docker



  1. 创建项目并启动

  2. 使用typeorm连接mysql

  3. 使用class-validate校验入参

  4. 使用全局filter处理异常,使用全局interceptor处理成功信息

  5. 使用ioredis连接redis

  6. 使用swaager文档

  7. 使用docker-compose打包并运行

  8. 总结


一、创建项目并启动


1、全局安装nestjs并创建项目

npm i -g @nestjs/cli
nest new nest-demo

2、使用热更新模式运行项目

npm run start:dev

此时访问 http://localhost:3000就可以看到 Hello World!


3、使用cli一键生成一个user模块

nest g resource system/user

选择REST API和自动生成CURD


4、设置全局api前缀

src/main.ts


async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.setGlobalPrefix('api'); // 设置全局api前缀
await app.listen(3000);
}
bootstrap();

更多nestjs入门教程查看:# 跟随官网学nestjs之入门


二、使用typeorm连接并操作mysq


1、安装依赖

npm i @nestjs/typeorm typeorm mysql @nestjs/config -S

2、在src下创建 config/env.ts 用来判断当前环境,抛出配置文件地址

src/config/env.ts


import * as fs from 'fs';
import * as path from 'path';
const isProd = process.env.NODE_ENV == 'prod';

function parseEnv() {
const localEnv = path.resolve('.env');
const prodEnv = path.resolve('.env.prod');

if (!fs.existsSync(localEnv) && !fs.existsSync(prodEnv)) {
throw new Error('缺少环境配置文件');
}

const filePath = isProd && fs.existsSync(prodEnv) ? prodEnv : localEnv;
return { path: filePath };
}
export default parseEnv();

3、在src下创建.env配置文件

src/.env


# default
PORT=9000

# database
DB_HOST=localhost
DB_PORT=3306
DB_USER=demo_user
DB_PASSWD=123456
DB_DATABASE=demo_db


4、在app.module内挂载全局配置和mysql

src/app.module.ts


import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigService, ConfigModule } from '@nestjs/config';
import envConfig from './config/env';
import { AppService } from './app.service';
import { UserModule } from './system/user/user.module';

@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true, // 设置为全局
envFilePath: [envConfig.path],
}),
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: async (configService: ConfigService) => ({
type: 'mysql',
host: configService.get('DB_HOST', 'localhost'), // 主机,默认为localhost
port: configService.get<number>('DB_PORT', 3306), // 端口号
username: configService.get('DB_USER', 'root'), // 用户名
password: configService.get('DB_PASSWORD', '123456'), // 密码
database: configService.get('DB_DATABASE', 'test_db'), //数据库名
entities: ['dist/**/*.entity{.ts,.js}'],
timezone: '+08:00', //服务器上配置的时区
synchronize: true, //根据实体自动创建数据库表, 生产环境建议关闭
autoLoadEntities: true,
}),
}),
UserModule,
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}

5、定义userEntity实体

src/system/user/entities/user.entity.ts


import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm';

@Entity('user_tb')
export class UserEntity {
@PrimaryGeneratedColumn()
s_id: string;

@Column({ type: 'varchar', length: 20, default: '', comment: '名称' })
s_name: string;

@Column({ type: 'int', default: 0, comment: '年龄' })
s_age: number;
}

6、user.module内引入entity实体

import { Module } from '@nestjs/common';
import { UserService } from './user.service';
import { UserController } from './user.controller';
// 引入typeorm和Enetiy实例
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserEntity } from './entities/user.entity';

@Module({
imports: [TypeOrmModule.forFeature([UserEntity])],
controllers: [UserController],
providers: [UserService],
}
)

export class UserModule {}

7、在控制器user.controller修改api地址

@Post('create')
create(@Body() createUserDto: CreateUserDto) {
return this.userService.create(createUserDto);
}

地址拼接为:全局前缀api+模块user+自定义create = localhost:3000/api/user/crtate


image.png


image.png


三、使用class-validato校验入参


1、安装依赖

npm i class-validator class-transformer -S

2、配置校验规则

src/system/user/dto/create-user.dto.ts


import { IsNotEmpty } from 'class-validator';

export class CreateUserDto {
@IsNotEmpty({ message: '名称不能为空' })
readonly s_name: string;
}

image.png


更多校验规则查看:git文档


四、使用filter全局错误过滤、interceptor全局成功过滤


1、使用cli自动生成过滤器


nest g filter common/http-exception
nest g interceptor common/transform

2、编写过滤器


src/common/http-exception/http-exception.filter.ts


import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException,
} from '@nestjs/common';

@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
catch(exception: HttpException, host: ArgumentsHost) {
const ctx = host.switchToHttp(); // 获取请求上下文
const response = ctx.getResponse(); // 获取请求上下文中的 response对象
const status = exception.getStatus(); // 获取异常状态码

let resultMessage = exception.message;

// 拦截class-validate错误信息
try {
const exceptionResponse = exception.getResponse() as any;
if (Object.hasOwnProperty.call(exceptionResponse, 'message')) {
resultMessage = exceptionResponse.message;
}
} catch (e) {}

const errorResponse = {
data: null,
message: resultMessage,
code: '9999',
};

// 设置返回的状态码, 请求头,发送错误信息
response.status(status);
response.header('Content-Type', 'application/json; charset=utf-8');
response.send(errorResponse);
}
}

src/common/transform/transform.interceptor.ts


import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { map, Observable } from 'rxjs';

@Injectable()
export class TransformInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
return next.handle().pipe(
map((data) => {
return {
data,
code: '0000',
msg: '请求成功',
};
}),
);
}
}

3、在main.ts里挂载


import { HttpExceptionFilter } from './common/http-exception/http-exception.filter';
import { TransformInterceptor } from './common/transform/transform.interceptor';

async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalFilters(new HttpExceptionFilter()); // 全局注册错误的过滤器(错误异常)
app.useGlobalInterceptors(new TransformInterceptor()); // 全局注册成功过滤器
await app.listen(3000);
}
bootstrap();

手动抛出异常错误只需在service的方法里


throw new HttpException('message', HttpStatus.BAD_REQUEST)


五、使用idredis连接redis


1、安装依赖

npm i ioredis -S

2、在.env文件添加reids配置

# redis
REDIS_HOST=localhost
REIDS_PORT=6379
REIDS_PASSWD=
REIDS_DB=3

3、在common目录下创建cache模块,连接redis

nest g mo cache common && nest g s cache common

src/common/cache/cache.service.ts


import { Injectable, Logger } from '@nestjs/common';
import { Redis } from 'ioredis';
import { ConfigService } from '@nestjs/config';

@Injectable()
export class CacheService {
public client;
constructor(private readonly configService: ConfigService) {
this.getClient();
}

async getClient() {
const client = new Redis({
host: this.configService.get('REDIS_HOST', 'localhost'), // 主机,默认为localhost
port: this.configService.get<number>('REIDS_PORT', 6379), // 端口号
password: this.configService.get('REIDS_PASSWD', ''), // 密码
db: this.configService.get<number>('REIDS_DB', 3),
});
// 连接成功提示
client.on('connect', () =>
Logger.log(
`redis连接成功,端口${this.configService.get<number>(
'REIDS_PORT',
3306,
)}
`
,
),
);
client.on('error', (err) => Logger.error('Redis Error', err));

this.client = client;
}

public async set(key: string, val: string, second?: number) {
const res = await this.client.set(key, val, 'EX', second);
return res === 'OK';
}

public async get(key: string) {
const res = await this.client.get(key);
return res;
}
}

在cache.module内抛出service
src/common/cache/cache.module.ts


@Module({
providers: [CacheService],
exports: [CacheService],
})

4、在user.module内引入cacheModule并在user.service内使用

src/system/user/user.module.ts


import { CacheModule } from 'src/common/cache/cache.module';
@Module({
imports: [CacheModule],
controllers: [UserController],
providers: [UserService],
})

export class UserModule {}

src/system/user/user.service.ts


import { CacheService } from '@src/common/cache/cache.service';

@Injectable()
export class UserService {
constructor(
private readonly cacheService: CacheService,
) {}

async create(createUserDto: CreateUserDto) {
const redisTest = await this.cacheService.get('redisTest');

Logger.log(redisTest, 'redisTest');
if (!redisTest) {
await this.setRedis();
return this.create(createUserDto);
}

...
}
async setRedis() {
const res = await this.cacheService.set(
'redisTest',
'test_val',
12 * 60 * 60,
);
if (!res) {
Logger.log('redis保存失败');
} else {
Logger.log('redis保存成功');
}
}
}

image.png


image.png


六、使用swagger生成文档


1、安装依赖

npm i @nestjs/swagger swagger-ui-express -S

2、在main.ts引入并配置

import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';

async function bootstrap() {
const app = await NestFactory.create(AppModule);
// 设置swaager
const options = new DocumentBuilder()
.setTitle('nest-demo example')
.setDescription('The nest demo API description')
.setVersion('1.0')
.build();
const document = SwaggerModule.createDocument(app, options);
SwaggerModule.setup('swagger', app, document);

...
}
bootstrap();

此时访问http://wwww.localhost:9000/swagge就可以看到文档


image.png


3、在控制器为业务模块和api打上标签

src/system/user/user.controller.ts


import { ApiTags, ApiOperation } from '@nestjs/swagger';

@ApiTags('user')
@Controller('user')
export class UserController {
constructor(private readonly userService: UserService) {}

@ApiOperation({
summary: '创建用户',
})
@Post('create')
create(@Body() createUserDto: CreateUserDto) {
return this.userService.create(createUserDto);
}
}

4、在dto内为字段设置名称

src/system/user/dto/create-user.dto.ts


import { ApiProperty } from '@nestjs/swagger';

export class CreateUserDto {
@ApiProperty({ type: 'string', example: '用户名称' })
@IsNotEmpty({ message: '名称不能为空' })
readonly s_name: string;

@ApiProperty({ type: 'number', example: '用户年龄' })
readonly s_age: number;
}

这时刷新浏览器,就能看到文档更新了


image.png


更多swaager配置查看:官方文档


七、使用docker-compose自动部署到本地docker


1、在根目录下创建docker-compose.yml

version: "3.0"

services:
# docker容器启动的redis默认是没有redis.conf的配置文件,所以用docker启动redis之前,需要先去官网下载redis.conf的配置文件
redis_demo: # 服务名称
container_name: redis_demo # 容器名称
image: daocloud.io/library/redis:6.0.3-alpine3.11 # 使用官方镜像
# 配置redis.conf方式启动
# command: redis-server /usr/local/etc/redis/redis.conf --requirepass 123456 --appendonly yes # 设置redis登录密码 123456、--appendonly yes:这个命令是用于开启redis数据持久化
# 无需配置文件方式启动
command: redis-server --appendonly yes # 开启redis数据持久化
ports:
- 6379:6379 # 本机端口:容器端口
restart: on-failure # 自动重启
volumes:
- ./deploy/redis/db:/data # 把持久化数据挂载到宿主机
- ./deploy/redis/conf/redis.conf:/usr/local/etc/redis/redis.conf # 把redis的配置文件挂载到宿主机
- ./deploy/redis/logs:/logs # 用来存放日志
environment:
- TZ=Asia/Shanghai # 解决容器 时区的问题
networks:
- my-server_demo

mysql_demo:
container_name: mysql_demo
image: daocloud.io/library/mysql:8.0.20 # 使用官方镜像
ports:
- 3306:3306 # 本机端口:容器端口
restart: on-failure
environment:
MYSQL_DATABASE: demo_db
MYSQL_ROOT_PASSWORD: 123456
MYSQL_USER: demo_user
MYSQL_PASSWORD: 123456
MYSQL_ROOT_HOST: '%'
volumes:
- ./deploy/mysql/db:/var/lib/mysql # 用来存放了数据库表文件
- ./deploy/mysql/conf/my.cnf:/etc/my.cnf # 存放自定义的配置文件
# 我们在启动MySQL容器时自动创建我们需要的数据库和表
# mysql官方镜像中提供了容器启动时自动docker-entrypoint-initdb.d下的脚本的功能
- ./deploy/mysql/init:/docker-entrypoint-initdb.d/ # 存放初始化的脚本
networks:
- my-server_demo

server_demo: # nestjs服务
container_name: server_demo
build: # 根据Dockerfile构建镜像
context: .
dockerfile: Dockerfile
ports:
- 9003:9003
restart: on-failure # 设置自动重启,这一步必须设置,主要是存在mysql还没有启动完成就启动了node服务
networks:
- my-server_demo
depends_on: # node服务依赖于mysql和redis
- redis_demo
- mysql_demo

# 声明一下网桥 my-server。
# 重要:将所有服务都挂载在同一网桥即可通过容器名来互相通信了
# 如nestjs连接mysql和redis,可以通过容器名来互相通信
networks:
my-server_demo:

2、在根目录创建Dockerfile文件

FROM daocloud.io/library/node:14.7.0

# 设置时区
ENV TZ=Asia/Shanghai \
DEBIAN_FRONTEND=noninteractive
RUN ln -fs /usr/share/zoneinfo/${TZ} /etc/localtime && echo ${TZ} > /etc/timezone && dpkg-reconfigure --frontend noninteractive tzdata && rm -rf /var/lib/apt/lists/*

# 创建工作目录
RUN mkdir -p /app

# 指定工作目录
WORKDIR /app

# 复制当前代码到/app工作目录
COPY . ./

# npm 源,选用国内镜像源以提高下载速度
RUN npm config set registry https://registry.npm.taobao.org/

# npm 安装依赖
COPY package.json /app/package.json
RUN rm -rf /app/package-lock.json
RUN cd /app && rm -rf /app/node_modules && npm install

# 打包
RUN cd /app && rm -rf /app/dist && npm run build

# 启动服务
# "start:prod": "cross-env NODE_ENV=production node ./dist/src/main.js",
CMD npm run start:prod

EXPOSE 9003

3、修改.env.prod正式环境配置

# default
PORT=9003
HOST=localhost

# database
DB_HOST=mysql_demo #使用容器名称连接
DB_PORT=3306
DB_USER=demo_user
DB_PASSWD=123456
DB_DATABASE=demo_db

# redis
REDIS_HOST=redis_demo #使用容器名称连接
REIDS_PORT=6379
REIDS_PASSWD=
REIDS_DB=3

4、修改main.ts启动端口

import { ConfigService } from '@nestjs/config';

async function bootstrap() {
const app = await NestFactory.create(AppModule);
const configService = app.get(ConfigService); // 获取全局配置
const PORT = configService.get<number>('PORT', 9000);
const HOST = configService.get('HOST', 'localhost');
await app.listen(PORT, () => {
Logger.log(`服务已经启动,接口请访问:http://wwww.${HOST}:${PORT}`);
});
}
bootstrap();

5、前台运行打包

docker-compose up


运行完成后大概率会报错,因为我们使用的mysql账号没有权限,所以需要进行设置


image.png


// 进入mysql容器命令
docker ecex -it mysql_demo /bin/bash
// 登录mysql
mysql -uroot -p123456
// 查询数据库后进入mysql查询数据表
show databases;
use mysql;
show tables;
// 查看user表中的数据
select User,Host from user;
// 刚创建的用户表没有我们设置连接的用户和host,所以需要创建
CREATE USER 'demo_user'@'%' IDENTIFIED BY '123456';
// 给创建的用户赋予权限
GRANT ALL ON *.* TO 'demo_user'@'%';
// 刷新权限
flush privileges;

如果还报错修改下密码即可
Pasted Graphic 1.png


ALTER USER 'demo_user'@'%' IDENTIFIED WITH mysql_native_password BY '123456';

此时项目应该能正常启动并成功访问


image.png


image.png


6、切换后台运行

// Ctrl+C 终止程序后执行后台运行命令
docker-compose up -d

八、总结


docker-compose up正常用来测试本地打包,和第一次构建redismysql容器,后续需要在本地运行开发模式只需保证redismysql容器正常运行即可,如需再次打包,删除server容器和镜像再次执行即可


docker ps -a // 查询docker容器
docker rm server_demo // 删除server容器
docker images // 查询镜像
docker rmi nest-demo_server_demo // 删除server镜像, server镜像名称:项目名称_容器名称
docker-compose up -d // 重新打包

本地开发模式只需关闭server容器,然后在项目内只需 start:dev即可


docker stop server_demo
npm run start:dev

作者:jjggddb
来源:juejin.cn/post/7215844385614528549
收起阅读 »

keepAlive模式下切换页面时缓存页面中el-select已展开的选项框无法自动关闭解决方案

web
问题描述 如下图,在keepAlive缓存的页面中使用element中的select选择器,打开弹出框后不手动关闭,直接切换页面,会出现弹出框仍然展示在页面上的现象。 问题原因 select选择器提供一个属性 popper-append-to-body 为...
继续阅读 »

问题描述


如下图,在keepAlive缓存的页面中使用element中的select选择器,打开弹出框后不手动关闭,直接切换页面,会出现弹出框仍然展示在页面上的现象。


select-bug.gif


问题原因



  1. select选择器提供一个属性 popper-append-to-body 为false时,弹出框是放置在select选择器所在层级中,为true时,允许将弹出框插入至body元素中。


image.png


image.png



  1. 本页面被keepAlive缓存后 再切出本页面时不会触发select选择器组件的blur事件


所以当弹出框被插入至body元素中时,切出缓存页面 无法触发select选择器组件的blur事件,弹出框在body中无法隐藏


解决方法1


设置属性 popper-append-to-body为false,弹出框不会直接插入至body元素中,页面切换后弹出框也会被隐藏


局限性:
某些场景需要设置select选择器上级元素超出隐藏,弹出框如果超出上级元素的范围则无法完全展示


image.png


解决方法2


elementselect选择器源码中弹出框开启关闭由变量visible控制,将elselect组件包装一下,在deactivated生命周期钩子里设置弹出框关闭,注册组件时


image.png


// SelectWrapper 组件
<script lang="ts">
import { Mixins, Component, Watch } from 'vue-property-decorator';
import { Select } from 'element-ui';

@Component({
name: 'ElSelect',
})
export default class ElSelect extends Mixins(Select) {
visible: boolean | undefined;

deactivated() {
this.visible = false;
}
}
</script>

入口文件全局注册新的SelectWrapper组件,替换掉elementselect选择器,这样可以做到在业务组件中无感使用


 app.component('el-select', SelectWrapper);

作者:Eden的前端笔记
来源:juejin.cn/post/7215855138812461115
收起阅读 »

iframe之间的通信

web
前言 iframe 想必大家都挺熟悉的了,就不多说了👍👍。写这篇文章的初衷主要是丰富自己的知识和解决遇到的问题。因为我基本上没接触过 iframe ,所以对它的通信方式不是很了解。前几天,跟我的一个朋友(在下杨公子)聊天时,他提到了 iframe 的通信方式,...
继续阅读 »

前言


iframe 想必大家都挺熟悉的了,就不多说了👍👍。写这篇文章的初衷主要是丰富自己的知识和解决遇到的问题。因为我基本上没接触过 iframe ,所以对它的通信方式不是很了解。前几天,跟我的一个朋友(在下杨公子)聊天时,他提到了 iframe 的通信方式,我觉得很有意思,就开始了解和学习。在这篇文章中,我将分享我所学到的内容,希望对大家有所帮助🤪🤪。


接下来我们就一起来学习一下关于 iframe 通信的相关知识吧😁


iframe通信的几种方式😶‍🌫️😶‍🌫️



  1. URL 传参:父窗口可以通过在 iframe 的 src 属性后添加参数来向子窗口传递数据,子窗口可以通过 location.searchlocation.hash 来获取参数✨✨。



  • 使用 ? 拼接参数,子页面使用 location.search 接收参数


// parent.html
<iframe id="iframe1" src="./child1.html?name=来自parent的消息" frameborder="0"></iframe>

// child1.html
<script>
console.log(window.decodeURIComponent(location.search)) // ?name=来自parent的消息
</script>



  • 使用 # 拼接参数,子页面使用 location.hash 接收参数,同时还可以使用 window.onhashchange 来监听参数的变化。


// parent.html
<iframe id="iframe1" src="./child1.html#name=来自parent的消息" frameborder="0"></iframe>
<script>
const iframe1 = document.getElementById('iframe1')
// 在2s后更改hash
setTimeout(() => {
iframe1.src = './child1.html#age=12'
}, 2000)
</script>


// child1.html
<script>
console.log('hash', window.decodeURIComponent(location.hash)) // #name=来自parent的消息
window.onhashchange = () => {
console.log('hashchange', window.location.hash) // #age=12
}
</script>



⚡⚡需要注意的是通过 URL 传参 的时候,传输携带中文的话,记得使用 decodeURIComponent 进行解码。




  1. window.postMessage:安全、可靠且支持跨域的 iframe 通信方式,它可以在两个窗口之间异步传递消息✨✨✨✨✨。



  • 在发送方中,使用 window.postMessage() 方法向另一个窗口发送消息。该方法接收两个参数:要发送的消息和目标窗口的源(例如,"http://127.0.0.1:5500/child.html" 或 "*")。


window.postMessage('Hello world!', 'http://127.0.0.1:5500/child.html')


  • 在接收方中,使用 window.addEventListener() 方法监听 message 事件。该事件对象包含三个属性:data 表示接收到的数据,origin 表示发送方的源,source 表示发送方窗口的引用。


window.addEventListener('message', function(event) {
// 判断消息是否来自可信任的源
if (event.origin === 'http://127.0.0.1:5500/child.html') {
console.log('message: ' + event.data)
}
})

兼容性,来自 window.postMessage | MDN


image.png



  1. window.name:可以使用一个隐藏的iframe和window.name属性在不同的窗口之间共享数据✨✨。



  • 在子页面中,将要传递给父页面的数据保存在 window.name 属性中。


例如


window.name = 'Hello Parent!';


  • 在父页面中,创建一个隐藏的 iframe 元素,并且将其源设置为子页面的 URL


const iframe = document.createElement('iframe');
iframe.style.display = 'none';
iframe.src = 'http://127.0.0.1:5500/child1.html';
document.body.appendChild(iframe);


  • 在父页面中,等待 iframe 加载完成后,通过访问 iframe.contentWindow.name 属性来获取子页面中保存的数据。


iframe.onload = function() {
const childData = iframe.contentWindow.name;
onsole.log('message:', childData); // 输出:message: Hello Parent!
};


⚡⚡注意:使用 window.name 进行跨域 iframe 通信存在安全性问题,因为所有具有相同名称的窗口都可以访问和修改 window.name




  1. 服务器端转发:可以将消息从一个iframe发送到服务器,然后再由服务器将其转发到另一个iframe。✨✨✨



博客主要记录一些学习的文章,如有不足,望大家指出,谢谢。



作者:树深遇鹿
来源:juejin.cn/post/7215854856731934781
收起阅读 »

女朋友想学webGL修图,安排!

web
前言 看完小白可以用webgl实现修图功能!我们平常生活中都使用过adobe photoshop修图,各种各样的滤镜以及特效眼花缭乱,实现高斯模糊,雕刻,曝光等这些特效看起来似乎很难,那么今天我们来手敲一个简单实现。 之前讲了简单的webgl的原理与点的绘制、...
继续阅读 »

前言


看完小白可以用webgl实现修图功能!我们平常生活中都使用过adobe photoshop修图,各种各样的滤镜以及特效眼花缭乱,实现高斯模糊,雕刻,曝光等这些特效看起来似乎很难,那么今天我们来手敲一个简单实现。


之前讲了简单的webgl的原理与点的绘制、以及webgl在vscode需要注意的点,本文将接着介绍如何做个简单的修图功能,由于篇幅有限,只讲基本的语法、多边形绘制、缓冲区、帧缓存、纹理uv等。


预览


chrome-capture-2023-2-30.gif


canvas也可以更简单的实现,getImageData可以得到点的集合,然后putImageData绘制就行了。但是一些复杂的算法,例如高斯模糊、雕刻效果,貌似就没有webgl灵活了。


createBuffer 缓冲区


缓冲区你可以理解canvas的save()保存状态,但是这里我们一般是点的集合,这里我不会讲具体的api细节,但是知道具体的代码流程就行,就是创建buffer及数据 -> 绑定数据 -> 如何加载


let bufferOrigin = gl.createBuffer()
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(positions), gl.STATIC_DRAW)
gl.bindBuffer(gl.ARRAY_BUFFER, bufferOrigin);

gl.enableVertexAttribArray(positionAttributeLocation); // 告诉缓冲区怎么加载
gl.vertexAttribPointer(positionAttributeLocation, size, type, normalize, stride, offset);

Program 对象


const canvas = document.querySelector("#canvas");
image.width = 540
image.height = 720

canvas.style.width = 540 + 'px'
canvas.style.height = 720 + 'px'

const gl = canvas.getContext("webgl");
if (!gl) {
return;
}
const program = webglUtils.createProgramFromScripts(gl, ["vertex-shader-2d", "fragment-shader-2d"]);

image加载的dom对象,设置宽高,webglUtils是封装的方法,其实就是之前的初始化的着色器,返回program程序对象。


shader 着色器


先看看着色器源码


<script id="vertex-shader-2d" type="x-shader/x-vertex">
attribute vec2 a_position; // attribute在顶点着色器处理
attribute vec2 a_texCoord; // 纹理参数
uniform vec2 u_resolution; // 页面的坐标
attribute vec4 a_composeColor; // 纹理增强的向量
varying vec4 v_composeColor;

void main() {
// 屏幕坐标 -> 裁剪坐标
vec2 zeroToOne = a_position / u_resolution;
vec2 zeroToTwo = zeroToOne * 2.0;

vec2 clipSpace = zeroToTwo - 1.0;

gl_Position = vec4(clipSpace * vec2(1, -1), 0, 1);
v_composeColor = a_composeColor;
v_texCoord = a_texCoord;
}
</script>

attribute类型用于顶点着色器的属性,一般可以在后期动态添加一些控制,但是uniform是只能静态编译的时候就决定了,所以一般用于控制材质、光照等确定的值。为了能控制到片元着色器,那么一定要使用varying这个类型,一般通过变量传递给片元着色器做动态的渲染,所以一般会配合attriubute + varying


<script id="fragment-shader-2d" type="x-shader/x-fragment">
precision mediump float;

uniform sampler2D u_image;
uniform vec2 u_textureSize;
uniform float u_kernel[9];
uniform float u_kernelWeight;
varying vec2 v_texCoord;
varying vec4 v_composeColor;

void main() {
vec2 onePixel = vec2(1.0, 1.0) / u_textureSize;
// 卷积内核的前置处理,u_kernel我们传递的核心数据
vec4 colorSum =
texture2D(u_image, v_texCoord + onePixel * vec2(-1, -1)) * u_kernel[0] +
texture2D(u_image, v_texCoord + onePixel * vec2( 0, -1)) * u_kernel[1] +
//.....
// 计算最终的颜色结果
gl_FragColor = vec4((colorSum / u_kernelWeight).rgb, 1) * v_composeColor;
}
</script>


这里返回的结果gl_FragColor就是最终绘制的颜色。注意颜色范围是0到1需要做个转换,这里的最核心的也就是卷积的算法


卷积


卷积就是一个 3×3 的矩阵, 矩阵中的每一项代表当前处理的像素和周围8个像素的乘法因子, 相乘后将结果加起来除以内核权重(内核中所有值的和或 1.0 ,取二者中较大者)


image.png


像素矩阵 * 修改矩阵 = 赋值于内核也就是中心位置


这也就是我们能处理模糊、锐化等特效的原理, 下面是简单的计算


// 将周围八个点相加用于平均数相除
function computeKernelWeight(kernel) {
const weight = kernel.reduce(function(prev, curr) {
return prev + curr;
});
return weight <= 0 ? 1 : weight;
}

 gl_FragColor = vec4((colorSum / u_kernelWeight).rgb, 1) * v_composeColor;

滤镜


 const kernelsFilter = {
sharpness: {
name: '锐度',
data: [
0, -1, 0,
-1, 5, -1,
0, -1, 0
],
},
gaussianBlur: {
name: '高斯模糊',
data: [
0, 1, 0,
1, 1, 1,
0, 1, 0
],
},
edgeDetect2: {
name: '反相',
data: [
-1, -1, -1,
-1, 8, -1,
-1, -1, -1
],
},
emboss: {
name: '浮雕效果',
data: [
-2, -1, 0,
-1, 1, 1,
0, 1, 2
],
},
};

// 向量乘积的滤镜
const composeFilter = {
light: {
name: '曝光',
data: new Float32Array([1.2, 1.2, 1.2, 1])
},
langmanmeigui: {
name: '浪漫玫瑰',
data: new Float32Array([1.1, 1, 1, 1])
},
// ....
}

将上面的参数传入对上面的着色器,然后通过卷积赋值于gl_FragColor,这样简单的修图工具就大功告成了。


texcoord 纹理


const texcoordLocation = gl.getAttribLocation(program, "a_texCoord");
// ...
gl.vertexAttribPointer(texcoordLocation, size2, type2, normalize2, stride2, offsetVal2);

这里用缓冲区处理,本质图片也就是4个点的矩形,因为每个点其实对应像素和位置, 下面是创建纹理的标准代码,将image传入到textImage2D,然后将缓冲区绑定到这样我们就可以绘制纹理了


 // webgl创建纹理,并设置基本纹理参数,载入image图片
const texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);

//定义纹理处理能力
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);

gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);


帧缓冲


如何给图片施加多种状态的叠加效果,也就是图片 -> 纹理一 -> 纹理一 + 纹理二 -> 画布,那么我们需要用到帧缓冲,其实就是通过不断的bindTexture来覆盖之前的状态。


// 绘制帧缓冲
function drawFrames () {
const originTexture = createAndSetupTexture(gl)
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
let textures = []
let frameBuffers = []
const kernelsFilterList = ['gaussianBlur', 'emboss', 'boxBlur',
'gaussianBlur', 'boxBlur', 'gaussianBlur', 'boxBlur', 'gaussianBlur'] //叠加效果的数组

for (let i = 0; i < kernelsFilterList.length; i++) {
let texture = createAndSetupTexture(gl)
textures.push(texture)

gl.texImage2D(
gl.TEXTURE_2D, 0, gl.RGBA, image.width, image.height, 0,
gl.RGBA, gl.UNSIGNED_BYTE, null);

var fBuffer = gl.createFramebuffer()
frameBuffers.push(fBuffer)
gl.bindFramebuffer(gl.FRAMEBUFFER, fBuffer);
// 绑定纹理到帧缓冲
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0);
}
gl.bindTexture(gl.TEXTURE_2D, originTexture);

for (var i = 0; i < kernelsFilterList.length; i++) {
setFramebuffer(frameBuffers[i], image.width, image.height);
drawWithKernel(kernelsFilterList[i]);
// 叠加
gl.bindTexture(gl.TEXTURE_2D, textures[i]);
}

// 绘制
setFramebuffer(null, canvas.width, canvas.height);
drawWithKernel("normal");

function setFramebuffer (fbo, width, height) {
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo); // 绑定帧缓存
gl.uniform2f(resolutionLocation, width, height); // 设置到裁剪坐标
gl.viewport(0, 0, width, height); // 将裁剪坐标自适应到屏幕坐标
}
}


总结


通过基本的语法、纹理使用、帧缓存等,我们对webgl的基本的2d图形处理有了一定的认知,正常在绘制三角形,四边形,圆形,我们都可以使用缓存区,最后drawArrays绘制,在一些图形的渲染需要保存之前的状态的时候,我们可以使用帧缓存处理。关于当前页面的优化,当前的修图页面应该将各种调色分到不同的glsl文件,同样我们也可以做裁剪,上传图片编辑并下载。



如果觉得文章对你有帮助,不要忘了一键三连 👍



附录



  1. 内卷年代,是该学学WebGL了 - 掘金 (juejin.cn)

  2. 为什么我的WebGL开发这么丝滑 🌊 - 掘金 (juejin.cn)


作者:谦宇
来源:juejin.cn/post/7215977393696522299
收起阅读 »

屡次wx.getLocation申请被拒,让ChatGPT帮我编写申请理由后立马通过

微信小程序完成某些需求功能需要获取精准经纬度,需要微信小程序后台-开发管理-接口设置,申请“获取当前的地理位置、速度(wx.getLocation)”,一直审核不通过,有的人甚至提交了10多次都没有通过,如果你没有掌握方法,及时申请几个月还是一样不给通过。 包...
继续阅读 »

微信小程序完成某些需求功能需要获取精准经纬度,需要微信小程序后台-开发管理-接口设置,申请“获取当前的地理位置、速度(wx.getLocation)”,一直审核不通过,有的人甚至提交了10多次都没有通过,如果你没有掌握方法,及时申请几个月还是一样不给通过。


包括本人也是申请数次未通过,思考是否是理由不够充分,借助ChatGPT帮助我丰富申请理由,我用ChatGPT的回答来申请,立马审核通过。以下是模拟对ChatGPT的提问和回复。
1679989283893.jpg


提问


这是一个社区团购的微信小程序,由于团购服务点功能,需要精准定位到最近的服务点,因此需要wx.getLocation接口申请。请你基于我的需求,编写一个充分的申请原因,并包含操作步骤说明。


ChatGP回答


申请原因:


该社区团购微信小程序需要使用wx.getLocation接口获取用户的地理位置信息,以便精准定位用户所在地区,并显示最近的服务点。服务点根据用户所在地区的需求,提供更加贴近实际的商品和服务,确保用户的用户体验。该接口可以通过用户授权的方式获得用户所在位置信息,不会泄露用户的隐私信息。


操作步骤:


1. 首先在微信小程序中调用wx.getLocation接口进行定位,用户将会收到授权提示,需同意授权以便应用获取用户的地理位置信息。

2.定位成功后,小程序将通过得到的经纬度信息,计算用户位置与服务点之间的距离,并返回最近的服务点信息给用户。

3. 如果用户不同意授权,将无法获取用户地理位置信息,定位功能将无法使用,导致无法提供精准的服务点查找。

4.在使用wx.getLocation接口时,需要注意用户个人隐私问题,小程序应在提供提醒并遵守相关规定的前提下,妥善保护用户隐私信息。


接口申请总结


ChatGPT的回答固然帮助我们丰富了申请原因,但是申请该接口的基本要点还是要掌握。



  1. 确保小程序的服务类目可申请wx.getLocation,最好是主营类目,包含两个类目。

  2. 原因表达充分,主要以用户体验角度,及用户隐私。

  3. 提交的图片、视频,符合申请的步骤。


最后


ChatGPT是自然语言处理工具,需要充分的表达清楚,才可能接近输出我们需要的答案,且我们能够甄别验证它的输出。


其他
# 经验分享:快速通过“获取当前的地理位置、速度(wx.getLocation)接口”审核


作者:ZTrainWilliams
来源:juejin.cn/post/7215465880884674619
收起阅读 »

往往排查很久的问题,最后发现都非常简单。。。

之前线上发生了一个很诡异的异常,网上各种搜索、排查,都没有找到问题,给大家分享一下。 大概在 2 月份的时候,我们的某个应用整合了中间件的 kafka 客户端,发布到灰度和蓝节点进行观察,然后就发现线上某个 Topic 发生了大量的RetriableCommi...
继续阅读 »

之前线上发生了一个很诡异的异常,网上各种搜索、排查,都没有找到问题,给大家分享一下。


大概在 2 月份的时候,我们的某个应用整合了中间件的 kafka 客户端,发布到灰度和蓝节点进行观察,然后就发现线上某个 Topic 发生了大量的RetriableCommitException,并且集中在灰度机器上。


E20:21:59.770 RuntimeException  org.apache.kafka.clients.consumer.RetriableCommitFailedException  ERROR [Consumer clientId=xx-xx.4-0, groupId=xx-xx-consumer_[gray]] Offset commit with offsets {xx-xx-xx-callback-1=OffsetAndMetadata{offset=181894918, leaderEpoch=4, metadata=''}, xx-xx-xx-callback-0=OffsetAndMetadata{offset=181909228, leaderEpoch=5, metadata=''}} failed org.apache.kafka.clients.consumer.RetriableCommitFailedException: Offset commit failed with a retriable exception. You should retry committing the latest consumed offsets.
Caused by: org.apache.kafka.common.errors.TimeoutException: Failed to send request after 30000 ms.


排查


检查了这个 Topic 的流量流入、流出情况,发现并不是很高,至少和 QA 环境的压测流量对比,连零头都没有达到。


但是从发生异常的这个 Topic 的历史流量来看的话,发生问题的那几个时间点的流量又确实比平时高出了很多。



同时我们检查 Broker 集群的负载情况,发现那几个时间点的 CPU 负载也比平时也高出很多(也只是比平时高,整体并不算高)。



对Broker集群的日志排查,也没发现什么特殊的地方。


然后我们对这个应用在QA上进行了模拟,尝试复现,遗憾的是,尽管我们在QA上把生产流量放大到很多倍并尝试了多次,问题还是没能出现。


此时,我们把问题归于当时的网络环境,这个结论在当时其实是站不住脚的,如果那个时刻网络环境发生了抖动的话,其它应用为什么没有这类异常?


可能其它的服务实例网络情况是好的,只是发生问题的这个灰实例网络发生了问题。


那问题又来了,为什么这个实例的其它 Topic 没有报出异常,偏偏问题只出现在这个 Topic 呢?。。。。。。。。。


至此,陷入了僵局,无从下手的感觉。


从这个客户端的开发、测试到压测,如果有 bug 的话,不可能躲过前面那么多环节,偏偏爆发在了生产环境。


没办法了,我们再次进行了一次灰度发布,如果过了一夜没有事情发生,我们就把问题划分到环境问题,如果再次出现问题的话,那就只能把问题划分到我们实现的 Kafka 客户端的问题了。


果不其然,发布后的第二天凌晨1点多,又出现了大量的 RetriableCommitFailedException,只是这次换了个 Topic,并且异常的原因又多出了其它Caused by 。


org.apache.kafka.clients.consumer.RetriableCommitFailedException: Offset commit failed with a retriable exception. You should retry committing the latest consumed offsets.
Caused by: org.apache.kafka.common.errors.DisconnectException
...
...
E16:23:31.640 RuntimeException  org.apache.kafka.clients.consumer.RetriableCommitFailedException  ERROR 
...
...
org.apache.kafka.clients.consumer.RetriableCommitFailedException: Offset commit failed with a retriable exception. You should retry committing the latest consumed offsets.
Caused by: org.apache.kafka.common.errors.TimeoutException: The request timed out.

分析


这次出现的异常与之前异常的不同之处在于:



  1. 1. Topic 变了

  2. 2. 异常Cause变了


而与之前异常又有相同之处:



  1. 1. 只发生在灰度消费者组

  2. 2. 都是RetriableCommitFailedException


RetriableCommitFailedException 意思很明确了,可以重试提交的异常,网上搜了一圈后仅发现StackOverFlow上有一问题描述和我们的现象相似度很高,遗憾的是没人回复这个问题:StackOverFlow。


我们看下 RetriableCommitFailedException 这个异常和产生这个异常的调用层级关系。



除了产生异常的具体 Cause 不同,剩下的都是让我们再 retry,You should retry Commiting the lastest consumed offsets。



从调用层级上来看,我们可以得到几个关键的信息,commit 、 async。


再结合异常发生的实例,我们可以得到有用关键信息: 灰度、commit 、async。


在灰度消息的实现上,我们确实存在着管理位移和手动提交的实现。



看代码的第 62 行,如果当前批次消息经过 filter 的过滤后一条消息都不符合当前实例消费,那么我们就把当前批次进行手动异步提交位移。结合我们在生产的实际情况,在灰度实例上我们确实会把所有的消息都过滤掉,并异步提交位移。


为什么我们封装的客户端提交就会报大量的报错,而使用 spring-kafka 的没有呢?


我们看下Spring对提交位移这块的核心实现逻辑。



可以同步,也可以异步提交,具体那种提交方式就要看 this.containerProperties.isSyncCommits() 这个属性的配置了,然而我们一般也不会去配置这个东西,大部分都是在使用默认配置。



人家默认使用的是同步提交方式,而我们使用的是异步方式。


同步提交和异步提交有什么区别么?


先看下同步提交的实现:



只要遇到了不是不可恢复的异常外,在 timer 参数过期时间范围内重试到成功(这个方法的描述感觉不是很严谨的样子)。



我们在看下异步提交方式的核心实现:



我们不要被第 645 行的 RequestFuture future = sendOffsetCommitRequest(offsets) 所迷惑,它其实并不是发送位移提交的请求,它内部只是把当前请求包装好,放到 private final UnsentRequests unsent = new UnsentRequests(); 这个属性中,同时唤醒真正的发送线程来发送的。



这里不是重点,重点是如果我们的异步提交发生了异常,它只是简单的使用 RetriableCommitFailedException 给我们包装了一层。


重试呢?为什么异步发送产生了可重试异常它不给我们自动重试?


如果我们对多个异步提交进行重试的话,很大可能会导致位移覆盖,从而引发重复消费的问题。


正好,我们遇到的所有异常都是 RetriableCommitException 类型的,也就是说,我们把灰度位移提交的方式修改成同步可重试的提交方式,就可以解决我们遇到的问题了。


作者:艾小仙
来源:juejin.cn/post/7214398563023274021
收起阅读 »

既当产品又当研发,字节大哥手把手带我追求极致

在学校的时候,计算机相关专业的同学应该都或多或少都被“大作业”折磨过,没有为“大作业”熬过夜通过宵的大学生活可以说是不完整的。步入公司后才发现,校园里的“大作业”就像玩具一样,需求明确、解决方案明确、最终产品效果明确、甚至还有前人的作品可以参考,而在公司里要做...
继续阅读 »

在学校的时候,计算机相关专业的同学应该都或多或少都被“大作业”折磨过,没有为“大作业”熬过夜通过宵的大学生活可以说是不完整的。步入公司后才发现,校园里的“大作业”就像玩具一样,需求明确、解决方案明确、最终产品效果明确、甚至还有前人的作品可以参考,而在公司里要做的东西,上面说的特点至少有一个不具备,甚至通通不具备。


而我在字节实习的过程中,所经手的恰恰就是这么一个需求不明确、解决方案不明确、最终产品效果不明确的项目。整个过程中有过焦头烂额毫无进展的时刻也有过欲哭无泪的时刻,还好有我的mentor带着我一路披荆斩棘、过关斩将。


首先和大家讲一下项目背景,当时我在的组是视频会议移动端,经历了近三年大流感的洗礼,相信大家对于视频会议中可能遇到的各种问题如数家珍,包括但不限于没声了、没音了、没画面了、画面卡顿、画面不清晰、画面和语音不同步、同步屏幕时闪退等等等等。作为一个服务企业级的B端产品,出现以上问题时就可能会投诉,然后经过客户成功部门转手到运营再转手到研发这里,研发就需要判断分析一下究竟是我们产品的原因、还是客户本身设备的问题、或者是第三方环境的因素,当用户的量级上来后,这样的客诉就会很多,会严重占用oncall的研发人员的时间以及精力。


我的mentor,一个专注于解决问题、避免重复劳动的人,一个字节范我觉得有E+的人,一个虽然身处移动端但是前后端甚至网络也都会的人,觉得这样很不OK,应该有个工具,能够自动的分析出来客户究竟遇到了什么问题,分析不出来的再找研发进行排查。没有这个工具也不影响业务开发的进展,所以整个项目并不存在时间上的紧迫性,但是呢,有这个工具做出来后肯定会大大降低研发的开发时间,所以项目的必要性还是有的。于是,我作为刚入职的实习新人,这个项目就交给我来做了。


而我,一个还没有从校园中完全出来的新兵蛋子,说实话面对这样的场面是一脸懵逼的,对于要做啥、要怎么做可以说是一无所知,我的mentor在我入职后,让我先了解了解背景,第一周就带着我oncall了,让我知道都可能有样的客诉,手把手给我演示他们正常的排查问题的方式。先了解客户反馈的情况,然后捞出来客户对应时间的设备信息以及设备日志。


说实话,作为一个新人,或者说我本身对于项目有一种畏难心理,碰到一点难题就总是想着往后拖,或者摆烂先不管然后就搁置在一边不想再问津了,但是我的mentor是一个有着坚定信念的人,差不多就是见山开山,见水架桥这种,遇到问题会主动找到相关人员一起解决,可以说就是有那种主人翁,项目owner的意识。于是,我就跟在他的后面,和整个团队的不同角色沟通他们遇到问题时排查的思路,试图总结出来一种通用的流程。在过程中,难免有许多困难,我的第一反应是退缩,但是导师的第一反应是拉会拉上相关人员一起讨论,看看用什么方式可以解决。比如在如何确定设备日志和故障表现的映射关系时,先后调研了多种方式看看相关团队有没有类似的做法以及他们最后实现的效果,包括大数据机器学习、代码状态流转图、自定义规则引擎等多种方式,最后调研后决定采用自定义规则引擎的方式。在实现需求的时候,需要其他团队协作时,他总是直接向前提出自己的需求,而我向一个陌生人发消息之前总要做一些心理建设,总是在担心些什么、害怕些什么,而事实上大家都是打工人,谁也不比谁厉害多少,对方不配合那就拉+1进群一起看看,解决不了就向上暴露问题。


于是,导师披荆斩棘在前,我在后面跟着实现他的设想。我们很快就做出来了第一个版本。通过Python自动化下载设备日志,然后正则匹配筛选出含有特定标记的日志,并对他们的出现频率次数做出判断。因为Python是解释型的语言,所以可以把规则直接写成python语言,用eval或者exec函数进行执行。第一个版本做出来后,导师又积极的带着我去给其他人宣传我们的这个工具。然后根据他们的反馈继续进行相关改进,最后我离职前实现的效果就是@ 一个群里的机器人,告诉他出现问题的ID,他就能自动化的拉下来日志进行排查,然后告诉你他分析的结果,整个交互非常的方便。


一个成功的项目必须要有一个负责的owner,我的导师就向我展示了一个优秀的owner是如何一步步解决问题、排除项目中的难关,如今我也正式成为一名打工人,希望我也能早日如他一般自如的面对工作。


我是日暮与星辰之间,出道两年半的Java选手,相信时间的力量,

作者:日暮与星辰之间
来源:juejin.cn/post/7211801284709138493
一起成为更好的自己!

收起阅读 »

硬盘坏了,一气之下用 js 写了个恢复程序

web
硬盘坏了,一气之下写了个恢复程序 师傅拯救无望 硬盘已经寄过去超过一周了,一问竟然是还没开始弄??? 再过一周,上来就问我分几个区?我要恢复哪些数据?我要恢复的数据在哪个位置? 那好吧,既然给了钱师傅也都放弃了,我也没什么好寄托希望的了。况且经过这三个...
继续阅读 »

硬盘坏了,一气之下写了个恢复程序


师傅拯救无望


硬盘已经寄过去超过一周了,一问竟然是还没开始弄???


2023-03-24-14-15-16.png


再过一周,上来就问我分几个区?我要恢复哪些数据?我要恢复的数据在哪个位置?


2023-03-24-14-18-50.png


2023-03-24-14-19-30.png


2023-03-24-14-20-05.png


那好吧,既然给了钱师傅也都放弃了,我也没什么好寄托希望的了。况且经过这三个星期的缓解,心情已经平复了很多,就像时光,回不来了就是回不来了。


自救之路


在把硬盘寄过去的时间里,等待师傅的修复结果的时间里,我并没有闲着(在摸鱼)。


经过调研,数据恢复方法通常有:



  • 硬件损坏,对坏的盘进行修复

  • 误删或逻辑错误等,文件扫描修复

  • git 重置恢复


很明显,这些都不适用于我现在的场景。因为师傅能不能修好是未知的,我只是数据盘没了,系统盘还在。由于 vscode 的数据目录空间占比较小,就没有搬迁到数据盘里,这刚好可以为恢复代码提供了可能。


这是因为新版 vscode 有一个时间线功能,这个时间线数据是默认存储在用户目录下的。


我从 C:/Users/love/AppData/Roaming/Code/User/History 目录中确实找到了很多名为 entries.json 的文件,结构如下:


{
// 配置版本
"version": 1,
// 原来文件所在位置
"resource": "file:///d%3A/git2/cloudcmd/.madrun.mjs",
// 文件历史
"entries": [
{
// 历史文件存储的名称
"id": "YFRn.mjs",
"source": "工作区编辑",
// 修改的时间
"timestamp": 1656583915880
},
{
"id": "Vfen.mjs",
"timestamp": 1656585664751
},
]
}

通过上面的文件大概可以看到,每一个时间点的文件都保存在另一个随机命名的文件里。而网上的方法基本都是自己一个个手动到目录里去根据最新的 id 去找对应的文件内容,然后创建文件并把内容复制出来。


这个过程恢复一两个文件还好,但我这可是要恢复整个 git 工作区,大概有几十个项目上千个文件。


这时候当然是在网上找找有没有什么 vscode 数据恢复 相关的工具,很遗憾找了大半天都没有找到。


气死我了,一气之下就自己写个!


恢复程序开发步骤


毕竟只要数据在磁盘上,无非就是一个文件读取操作的问题,还要拿在这水文章,见谅见谅。


首先考虑需求:



  • 我要实现一个自动扫描 vscode 数据目录

  • 然后以原始的目录结构还原出来,不需要我自己去创建文件夹和文件

  • 如果还原的文件最新的那份不是我想要的,我还能根据时间线进行对比和选择

  • 扫描出来有N个项目时,我可以指定只还原某此项目

  • 我可以搜索文件、目录名或文件内容进行还原

  • 为了方便,我还要一个看起来不太丑的操作界面


大概就上面这些吧。


然后考虑实现:


我要实现一个自动扫描 vscode 数据目录


要的就是我自己连数据目录和恢复地址也不需要填写,就能自动恢复的那种。那么就让程序来自动查找数据目录。经过调研,各版本的 vscode 的数据目录一般保存在这些地方:


参考: stackoverflow.com/a/72610691


  - win -- C:\Users\Mark\AppData\Roaming\Code\User\History
- win -- C:\Users\Mark\AppData\Roaming\Code - Insiders\User\History
- /home/USER/.config/VSCodium/User/History/
- C:\Users\USER\AppData\Roaming\VSCodium\User\History

大概有上面这些路径,当然不排除使用者故意把默认位置修改掉这种边缘情况,或者使用者就只想扫描某个数据目录的情况,所以我也要支持手动输入目录:


  let { historyPath, toDir } = req.body
const homeDir = os.userInfo().homedir
const pathList = [
historyPath,
`${homeDir}/AppData/Roaming/Code/User/History/`,
`${homeDir}/AppData/Roaming/Code - Insiders/User/History/`,
`${homeDir}/AppData/Roaming/VSCodium/User/History`,
`${homeDir}/.config/VSCodium/User/History/`,
]
historyPath = (() => {
return pathList.find((path) => path && fs.existsSync(path))
})()
toDir = toDir || normalize(`${process.cwd()}/re-store/`)

然后以原始的目录结构还原出来……


这就需要解析扫描到的时间线文件 entries.json 了。我们先把解析结果放到一个 list 中,以下是一个完整的解析方法。


然后再把列表转换为树型,与硬盘上的状态对应起来,这样便于调试数据和可视化。


function scan({ historyPath, toDir } = {}) {
const gitRoot = `${historyPath}/**/entries.json`

fs.existsSync(toDir) === false && fs.mkdirSync(toDir, { recursive: true })
const globbyList = globby.sync([gitRoot], {})

let fileList = globbyList.map((file) => {
const data = require(file)
const dir = path.parse(file).dir
// entries.json 地址
data.from = file
data.fromDir = dir
// 原文件地址
data.resource = decodeURIComponent(data.resource).replace(
/.*?\/\/\/(.*$)/,
`$1`
)
// 原文件存储目录
data.resourceDir = path.parse(data.resource).dir
// 恢复后的完整地址
data.rresource = `${toDir}/${data.resource.replace(/:\//g, `/`)}`
// 恢复后的目录
data.rresourceDir = `${toDir}/${path
.parse(data.resource)
.dir.replace(/:\//g, `/`)}
`

const newItem = [...data.entries].pop()
// 创建文件所在目录
fs.mkdirSync(data.rresourceDir, { recursive: true })
const binary = fs.readFileSync(`${dir}/${newItem.id}`, {
encoding: `binary`,
})
fs.writeFileSync(data.rresource, binary, { encoding: `binary` })
return data
})

const tree = pathToTree(fileList, { key: `resource` })
return tree
}

为了方便,我还要一个看起来不太丑的操作界面


我们要把文件树的形式展示出来,还要方便切换。后面决定使用 macos 的文件管理器风格,大概如下。


image.png


如果还原的文件最新的那份不是我想要的,我还能根据时间线进行对比和选择


理论上这里应该要做一个像 vscode 对比文件那样,有代码高亮功能,并且把有差异的字符高亮出来。


实际上,这个需求得加钱。


2023-03-24-15-09-25.png


由于界面是在浏览器里的,需要自动打开,浏览器与系统交互需要一个接口,所以我们使用 opener 来自动打开浏览器。


使用 get-port 来自动生成接口服务的端口,避免使用时出现占用。


  const opener = require(`opener`)
const { portNumbers, default: getPort } = await import(`get-port`)
const port = await getPort({ port: portNumbers(3000, 3100) })
const server = express()
server.listen(port, `0.0.0.0`, () => {
const link = `http://127.0.0.1:${port}`
opener(link)
})

封装成工具,我为人人


理论上我根本不需要什么 UI 界面,也不需要配置,因为我的文件都恢复出来了我还花时间去搞毛线?


实际上,万一别人也有这个恢复文件的需要呢?那么他只要运行下面这条命令代码就能立刻恢复到当前目录啦!


npx vscode-file-recovery

这就是恢复后的文件在硬盘里的样子啦:


2023-03-24-15-22-23.png


所有代码位于:



建议收藏,以备不时之需。/手动狗头


作者:程序媛李李李李李蕾
来源:juejin.cn/post/7213994684262826040
收起阅读 »

Android开发小技巧-屏幕常亮与高亮的管理

前言 在使用我们国民应用微信和支付宝的时候,打开付款码给别人扫码的时候,那个页面简直亮瞎我的眼,做为一个Android开发者,我就想这个功能是怎么实现的呢? 问题:如何实现屏幕的常量与亮度控制呢?又有哪些方式来实现呢? 一、WakeLock机制 说起应用程序A...
继续阅读 »

前言


在使用我们国民应用微信和支付宝的时候,打开付款码给别人扫码的时候,那个页面简直亮瞎我的眼,做为一个Android开发者,我就想这个功能是怎么实现的呢?


问题:如何实现屏幕的常量与亮度控制呢?又有哪些方式来实现呢?


一、WakeLock机制


说起应用程序App的耗电,其本质就是硬件的消耗电量,硬件耗电单元分为CPU、基带、GPU、WIFI、BT、GPS、LCD/OLED等等。


耗电量的层级 基带非通话时间的能耗基本上在 5mA 左右, 而CPU只要处于非休眠状态,能耗至少在 50mA 以上,GPU执行图形运算时会更高, 另外 LCD/OLED, GPS等硬件又更高。


一般手机待机时,CPU、LCD、WIFI均进入休眠状态,这时 Android 中应用程序的代码也会停止执行,只会有基带处理器的耗电。 这也就是为什么微信比短信相比更加的耗电,答案就是短信使用基带耗电小, 而微信使用CUP耗电大。


Android 为了确保应用程序中关键代码的正确执行,提供了 WakeLock 的API,使得应用程序有权限通过代码阻止CPU进入休眠状态。


WakeLock 阻止应用处理器 CPU 的挂起,确保关键代码的运行,通过中断唤起应用处理器 CPU,可以阻止屏幕变暗。所有的 WakeLock 被释放后,系统会挂起。


例如以下音乐播放器,我们申请了 WakeLock 的情况下,就算按下电源键锁屏了,我们的音乐还是会播放。CPU不会休眠照样会处理我们的应用。


但是如果我们不释放 WakeLock,或者滥用 WakeLock 就会导致电池续航尿崩,用户查看电池消耗查看你的 App 耗电量高就会卸载了。


除了阻止 CPU 休眠,WakeLock 还可以让屏幕常亮,通过设置对应的 levelAndFlags 即可实现,常用的几个levelAndFlags:



  • PARTIAL_WAKE_LOCK:保持CPU 运转,屏幕和键盘灯有可能是关闭的。

  • SCREEN_DIM_WAKE_LOCK:保持CPU 运转,允许保持屏幕显示但有可能是灰的,允许关闭键盘灯

  • SCREEN_BRIGHT_WAKE_LOCK:保持CPU 运转,保持屏幕高亮显示,允许关闭键盘灯

  • FULL_WAKE_LOCK:保持CPU 运转,保持屏幕高亮显示,键盘灯也保持亮度

  • ACQUIRE_CAUSES_WAKEUP:不会唤醒设备,强制屏幕马上高亮显示,键盘灯开启。有一个例外,如果有notification弹出的话,会唤醒设备。

  • ON_AFTER_RELEASE:WakeLock 被释放后,维持屏幕亮度一小段时间,减少 WakeLock 循环时的闪烁情况


怎么使用呢?先声明权限


    <uses-permission android:name="android.permission.WAKE_LOCK"/>

然后直接使用:


    override fun init() {
val powerManager = commContext().getSystemService(Service.POWER_SERVICE) as PowerManager
wakeLock = powerManager.newWakeLock(PowerManager.FULL_WAKE_LOCK, "My Lock")

//是否需计算锁的数量
wakeLock.setReferenceCounted(false)

}

override fun onResume() {
super.onResume()
wakeLock.acquire()

}

override fun onStop() {
super.onStop()
wakeLock.release();
}

这样就可以实现一个简单的屏幕常亮的控制了。


虽然是可以实现逻辑,但是按照上面说的,谷歌可能也是怕我们滥用,导致手机续航尿崩,然后甩锅到Android系列上,说Android系列辣椒续航不行什么什么的,谷歌老早就标记过时了,并提供了新的 Api 实现此功能 SCREEN_ON。


二、KEEP_SCREEN_ON


有两种方式设置,一种是xml内部设置,另一种是通过Activity的window添加flag来设置


    <LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/white"
android:keepScreenOn="true"
android:orientation="vertical"/>



或者oncreate方法中添加flags


    override fun init() {
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}

两种方法都能实现,并且是和Activity生命周期绑定的,当我们退出这个页面就可以退出常量的状态,使用起来也是非常的方便。


那如果是这样一样场景,比如我们使用单Activity+多Fragment的方式,我们需要为其中一个Fragment设置为常亮,切换Fragment的时候动态的切换亮度,那怎么办?


我们需要动态的开关这个flag


window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);

window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);


又或者找到跟视图动态设置keepScreenOn属性 setKeepScreenOn 也是可行的,下面我们会以window的方式做一个工具封装类。


二、最大亮度的设置


最大亮度可以通过当前 Activity 的 window 对象设置 windowLayoutParams ,设置对应的 screenBrightness 值即可实现。


我们可以实现一个工具类来控制常亮的开关和最大亮度的开关,特别适用于单Activity+多Fragment的使用场景。


public class ScreenUtils {

/**
* 获取屏幕宽度
*/

public static int getScreenWidth(Context context) {
WindowManager wm = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
int width = wm.getDefaultDisplay().getWidth();
return width;
}


/**
* 获取屏幕高度
*/

public static int getScreenHeith(Context context) {
WindowManager wm = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
int height = wm.getDefaultDisplay().getHeight();
return height;
}


/**
* 是否使屏幕常亮
*
* @param activity 当前的页面的Activity
*/

public static void keepScreenLongLight(Activity activity, boolean isOpenLight, boolean maxBrightness) {

Window window = activity.getWindow();
if (isOpenLight) {
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
} else {
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
}

WindowManager.LayoutParams windowLayoutParams = window.getAttributes();
windowLayoutParams.screenBrightness = maxBrightness ?
WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_FULL : WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE;
window.setAttributes(windowLayoutParams);
}
}

使用的时候在页面显示的时候常亮且最大亮度即可,然后我们可以在任意地方关闭这些设置。


    override fun onResume() {
super.onResume()

ScreenUtils.keepScreenLongLight(this, true, true)
}


override fun onStop() {
super.onStop()

ScreenUtils.keepScreenLongLight(this, false, false)
}

最大亮度打开页面简直亮瞎我的眼!



OK,完结!



作者:newki
来源:juejin.cn/post/7130424225147387935
收起阅读 »

十六进制常量还有这种玩法

前言 上一篇文章中 juejin.cn/post/715437… ,在源码解析阶段,那些判断16进制的地方,很有意思,加上我以前也写过一篇关于这个的文章 http://www.jianshu.com/p/bff2b84ca… ,所以想在这里做个分享。 状态变量...
继续阅读 »

前言


上一篇文章中 juejin.cn/post/715437… ,在源码解析阶段,那些判断16进制的地方,很有意思,加上我以前也写过一篇关于这个的文章 http://www.jianshu.com/p/bff2b84ca… ,所以想在这里做个分享。


状态变量的一般写法


可能有些朋友平时在开发时定义常量状态是这样定义的:


public static final int SEX_BOY = 0; // 男生
public static final int SEX_GIRL = 1; // 女生

然后看了某篇文章之后,某个经验丰富的说,定义常量时最好使用16进制,再去看了看Android某些类的源码,嗯,好像里面定义常量确实是使用了16进制,于是之后写代码就开始


public static final int SEX_BOY = 0x00; // 男生
public static final int SEX_GIRL = 0x01; // 女生
public static final int SEX_OTHER = 0x02; // 其它

比如说有很多个状态,就从0x01、0x02......0xff 这样列举下去。


这样写对吗?我随便找个源码来举例下,随便从View.java扣出一段代码


e60a26626531e0f7231f1858adc1518.png


为什么是这样定义呢,为什么不是像我们那种写法?


叠加状态的定义方式


其实这个直接说不好解释,跟着我去操作,就理解为什么要这么定义了。


假设我们定义状态,定义成这样


public static final int TYPE1 = 0x01;
public static final int TYPE2 = 0x02;
public static final int TYPE3 = 0x04;
public static final int TYPE4 = 0x08;
public static final int TYPE5 = 0x10;
public static final int TYPE6 = 0x20;
public static final int TYPE7 = 0x40;
public static final int TYPE8 = 0x80;

为什么这么写呢?

我们将16进制转成2进行,上面就对应成


0d39ccd1271903a3cc6f461d2b03217.png


有意思的就在这里,我先说的我想的过程中错误的一个思路 (我觉得挺有意思的,所以可以说下,因为是一个错误的思路,如果不想看可以直接跳看下面的这样定义的原因)


二进制从右往左来说

(1)我用第一位表示性别 000:女 001:男

(2)我用第二位表示角色 000:学生 010:老师

(3)我用第三位表示班级 000:A班 100:B班

那么 “A班的女老师” 我可以表示成 010 = 2

“A班的男老师” 可以表示成 011=3

“B班的女学生”可以表示成 100 .....

这样可以组成8个状态而不会冲突,但是这样的做法是只能用3个状态组合进行比较,而且单个状态下有000表示了3种,而且这种做法同一位上只能表示两种状态,假如我加个C班,那就没辙了。


然后换了一种思考的方法,假如我这样表示状态


public static final int TYPE1 = 0x01;  // 女
public static final int TYPE2 = 0x02; // 男
public static final int TYPE3 = 0x04; // 学生
public static final int TYPE4 = 0x08; // 老师
public static final int TYPE5 = 0x10; // 主任
public static final int TYPE6 = 0x20; // A班
public static final int TYPE7 = 0x40; // B班
public static final int TYPE8 = 0x80; // C班

那么 使用二进制的或运算:

“A班的女老师” 我可以表示成 TYPE6|TYPE1|TYPE4 = 0010 1001 = 41

“A班的男老师” 可以表示成 TYPE6|TYPE2|TYPE4 = 0010 1010 = 42

“B班的女主任”可以表示成 TYPE7|TYPE1|TYPE5 = 0101 0001 = 81

这样也能把多个状态组成一个状态,而且组合状态也能和单个状态进行同等级判断,并且这种做法不会产生重复的状态。


举个例子就是说你平时写


if(性别==女 && 角色 == 老师 && 班级 == A班){
......
}else if(版本 == C班){
......
}

如果用我这种方法定义状态的话,你只用写


if(type == 0x29){
......
}else if(type == 0x80){
......
}

可能有些人说就仅仅为了这样?那我写&&还好过,写成16进制转换转的我脑壳疼。我还不如多写几个&&,而且这样也更容易让别人看懂。
但这个写法不仅仅有这种好处,再举个例子,假如在很多个组合的状态中你需要去判断这个状态是“男”还是“女”等等,多状态下判断单状态多了,也不是说乱,但会写很多代码,但是现在可以直接这样写


public void switchSex(type){
if(type & 0x03 == 0x01){
// 是女生
}else{
// 是男生
}
}

就可以直接这样用二进制的与运算来实现判断。

我也仅仅是举了两个例子,我的意思是这样去定义十六进制常量,方便二进制做运算,二进制还有其他的运算呢,我仅仅举了“或”和“与”,还有什么异或啊,移位啊之类的,而且就算作用不大,按装逼来说,我直接做二进制的运算肯定比你那些乱七八糟的运算来得快吧。


总结


当然这只是我领悟的一种思路,而且我想很多人也知道这种做法,或者用16进制来定义常量不仅仅有这个好处,只是我觉得很有意思,所以想分享一下。


作者:流浪汉kylin
来源:juejin.cn/post/7155474762053992485
收起阅读 »

“勇敢的去走自己的夜路”——走出我的“浪浪山”

引子 2022年,经历过太多太多的故事,也发生了太多太多的事故。 这一年,迷途失措且努力,未来可期却恍惚,我错失了太多的机会,幸然遇到了大家,让我们一同努力见证Cool(小陈同学)的改变,这一年我经历过比赛失利,国奖失之交臂,也遇到了求职季的滑铁卢。但有幸的是...
继续阅读 »

引子


2022年,经历过太多太多的故事,也发生了太多太多的事故。


这一年,迷途失措且努力,未来可期却恍惚,我错失了太多的机会,幸然遇到了大家,让我们一同努力见证Cool(小陈同学)的改变,这一年我经历过比赛失利,国奖失之交臂,也遇到了求职季的滑铁卢。但有幸的是遇到了一堆可爱的掘友,利用掘金的资源也找到了一个工作。


这一年,我走了很远的路,吃了很多的苦,踩了很多的坑,才将这份年终总结交付与星球大伙。也曾有幸与掘友一起分享只属于我们的“情书”


第一节:对你,同样对自己说


今天是 2023年1月1日,这一年,半分努力,半分浑噩,忽隐忽现的理想,支撑着自己踽踽独行。几年前,他应征入伍,算不上什么好选择,也或许并没有选择的权利。北方干冷的空气,窗前停驻的麻雀,以及战友豪迈的言语曾一度让我觉得,南京或许会是我最终的归宿。


在南京的第二个年头,这一年我21周岁,报国的赤心和热血似乎都正热时,我做出了人生的第一个计划,“退役复学”。


感恩军旅生活,让我真正的热爱祖国与持续学习,在二零年上旬,新冠疫情爆发了,一个八十八线小城市的我,除了紧张的气氛外,到也没受到多大的影响,在家依旧忙碌,直到2022年2月10日,我记得非常清楚,写了一天前端(三件套的弱鸡)代码的我,结束了当天的笔记小结,打开了B站,悄然间随机看见了关注了好久的鱼皮居然真的开了学习圈子(编程导航),这让一个对编程说不上爱的萌新,从此爱上了coder与share,(一个利他的博主谁又能不爱呢?),曾经把编程视为作业的我,我发现我能用他code出一个全新的世界,我便一发不可收拾爱上了它(这里的它指的是编程)。


(一)身体是革命的本钱


but 「熬夜 + 不规律的作息 + 不健康的饮食」+ 「年轻」= 无事发生


“年轻人”,似乎总是有一种得天独厚的优势,有精力,有体力。而这对于我这个退役选手更是easy了,这些不太好的习惯也似乎在年轻一代的大水潭中泛不出多少涟漪,凭借着这份“本钱” ,自然能更加心安理得,反正:我还年轻,我还可以熬。


(2) 继续战斗,也请先照顾好自己


疫情消耗掉了大半年的时间,大学断断续续的锻炼,把熬夜换成了早起,开始按时吃早餐,解封后的日子,趁着南方冬天来的很晚,与几个战友开始了跑步的活动,这一阶段体能上确实有了很大的提升,我很享受跑步后,被风吹过的感觉(皮一下:我也曾吹过未来女朋友曾吹过的风)。


说来也很神奇,每次当我没什么精神,只要去跑步,回来冲个澡就会精神百倍,所以我一般傍晚的时候有空就会去跑跑,然后就可以再晚上全身心的写代码,整理笔记(当然最后就是发到星球上面,感受大家阅读后的“指责与指导,哈哈哈”)。


运动本不应被当做一种应该做的任务,而应被看作一种休闲的方式,没必要与别人比较强度,组别,只有自己舒服就是最好的标准。


所以,不管是真的热爱也罢,苦于生计也罢,即使继续战斗也请先照顾好自己


(二) 随波逐流只会让你接近平均值


普通人的危机感总来自他人,而想要成为一个优秀的人,危机感必须来自自身,随波逐流只会让你靠近平均值,总有一种恍惚感,懂得越多,越觉得自己像这个世界的孤儿,与同龄人格格不入,总是自负的认为他人幼稚,就像鲁迅先生说过:“人类的悲欢并不相通......”。听着他们谈论着我 “早就走过的路”,“早就见过的风光”,我也只觉得他们吵闹。


可惜,我在某些时候,总是小气的,心中惰于学习,更不愿将自己的 “财富” 与他人分享,总忧虑别人以己为石,跳向远方,患得患失的一种矛盾,让自己无奈又颓靡。
后来我遇到了鱼皮,我发现分享的乐趣后我便不再随波逐流,持续性努力,以下是我在星球这一年输出的笔记



(ps:请大佬过目,记得留赞)如下:



大数据笔记:wx.zsxq.com/dweb2/index…


运维Devops笔记:wx.zsxq.com/dweb2/index…


低代码Lowcode笔记:wx.zsxq.com/dweb2/index…


yarn的学习:wx.zsxq.com/dweb2/index…


软件设计师:wx.zsxq.com/dweb2/index…


NodeJS笔记:wx.zsxq.com/dweb2/index…


机器学习方面:wx.zsxq.com/dweb2/index…


Vue+pinpa笔记:wx.zsxq.com/dweb2/index…


MySQL笔记:wx.zsxq.com/dweb2/index…


华为鸿蒙认证:wx.zsxq.com/dweb2/index…


软件工程笔记:wx.zsxq.com/dweb2/index…


力扣刷题攻略:wx.zsxq.com/dweb2/index…


ES6模块暴露笔记:wx.zsxq.com/dweb2/index…


ACM算法思维导图:wx.zsxq.com/dweb2/index…


Bootstrap笔记:wx.zsxq.com/dweb2/index…


网络安全资源贴:wx.zsxq.com/dweb2/index…


(三) 不被枯井遮住双眼,保持谦虚及自尊


目光短浅带来的问题是致命的,当你有一天觉得自己好像还不错,好像已经登到峰顶了。那就需要反思一下自己或许已经陷入了“枯井”中,你会这样想,那大概率是被枯井遮住了双眼,你看不到枯井之外的世界,为了一点点成就就沾沾自喜,虽然阶段性的成功也很值得高兴,但千万不要走进这份舒适区中,温水煮青蛙的例子也不少见,走出枯井后,你就会发现外面的世界还是在一个枯井中,你要做的就是不断的往上爬。


永远不要看不起任何人,即使一位在你眼中普普通通的人,他的技术或许逊色你不少,但是他在思想和创造性上总能给你意想不到的惊喜。即使我的学校很普通,但是我的身边仍然有着一批充满韧劲的朋友,希望能通过考研,亦或者对于技术的钻研,弥补自己高考的遗憾,我记得大二那年,我常常在凌晨一点半两点收到微信弹窗大家一起交流一些问题。备战比赛的三点一线生活,学技术的通宵达旦,为了目标不断努力,这样的人仍然值得我尊重与学习,我认为他们拥有了一名大学生应该有的“灵魂”


除此之外,请千万保持自尊,自尊并非别人给的,而是自己给的,如果遇到比自己弱的人就有“自尊”,遇到比自己强的人就畏畏惧惧,没有“自尊”,那么这种自尊就没有任何意义了,闻道有先后,术业有专攻,应当尊重任何在某个方向的前辈,但是也没必要过于拘束,见贤思齐,见不贤而内自省即可。


(四) 远离总是给你负面情绪的人


但是如果你遇到了一些人,总时时刻刻,在学习以及生活上给你一些负面的观点,这种人会严重影响你坚定往枯井上爬的信念,不管你们是什么关系,我给你一个建议——赶快跑(这里现代化的叫法喊:润),有多快,跑多快,如果你们不幸要发生必要的交互,请将这段关系限制在最小范围内,切勿投入感情


(五) 传道授业:若要学知识,必得为人师


这一年我很喜欢读一本书,那就是《软技能:代码之外的生存指南》(下面我会提到)其中有一个章节给我印象很深,即第33章,传道授业:若要学知识,必得为人师,下面我摘了一段:



在你传道授业的时候都会发生什么 当我们初次接触某个课题的时候,我们对于自己对此了解多少往往都会高估。我们很容易自欺欺人,以为已经对某样东西 了如指掌,直到我们试着去教会别人的时候,才能发现事实并非如此。你有没有被别人问过一个非常简单的问题,却震惊地发现自己不能清晰地解答。你刚开始会说:“这个,很明显……”,接下来只有“哦……”。这种情况在我身上屡屡发生。我们自认为已经透彻理解了这个话题,实际上我们只是掌握了表面知识。这就是传道授业的价值。在你的知识集合里面,总有一部分知识你并没有理解透彻到可以向别人解释,而“教”的过程能够迫使你面对这一部分。作为人类,我们的大脑善于模式识别。我们能够识别模式,并且套用这些固定的模式去解决许多问题, 而没有做到“知其然”也“知其所以然”。这种肤浅的理解力无碍于我们完成工作,因而不易被察觉。然而一旦我们试着向别人解释某件事情的运作原理或背后的原因的时候,我们在认知上的漏洞就会暴露出来。不过这并非坏事。我们需要知道自己的弱点,然后才能对症下药。在教别人的时候,你迫使自己面对课题中的难点,深入 探索,从只知皮毛变成完全理解。学习是暂时的,而理解是永久性的。我可以背诵九九乘法表,但是一旦理解了乘法的运算原理,即使突然记性不好,我也可以重做一张乘法表。



我已经记不清很多年前我初中亦还是我高中的一位任课老师曾经说过这么一句话:能教会别人,自己也就没问题了。可惜那个时候的自己压根没提起学习的欲望,当然了,也或许与我自己根本不喜欢枯燥的应试教育有点关系。我也没理解这句话的意思。大学这几年,我很喜欢与朋友交流技术方面的事情,每个人都有很多我意想不到的理解与想法。还有更多时候我更加喜欢帮助朋友解决一些问题,当你什么时候可以将别人的一个问题,用通俗的解释说明 + 简洁却又富有代表意义的实例 + 补充一些自己的理解与看法,说给别人听得时候,最起码,我认为你对于这块内容就真的入门了。当你能够滔滔不绝的讲解给别人一块内容,能合理的安排讲解的引子与顺序的时候,这说明这一块的知识已经在你脑海中有了一条清晰的体系。同时你通过与别人交流的时候,再根据别人对你提供的一些方向好好反思斟酌一下,不断的修改。相信我,当你成功与他人讲解/交流你的知识后,你会爱上这种感觉的。


但是老板和老师可不会等你,很多时候我们都不得不 “填鸭子” 式的学习一些内容,例如根据老师的要求,强制使用一些指定的框架或者技术,或者根据业务/项目组长的需要和安排,你需要快速的学习一些你并不熟悉的内容,凭借我们多年 “应试” 的本事,大家总能很快的就找到这种套路,例如怎么快速搭建环境,怎么配置,如何快速的用起来。但是千万别止步于此,不然终究只是一个CRUD工程师,这也不一定是坏事,当你熟悉如何用一款框架或技术后,再去看一些源码,或许会事半功倍。



作为人类,我们的大脑善于模式识别。我们能够识别模式,并且套用这些固定的模式去解决许多问题, 而没有做到“知其然”也“知其所以然”。



(六) 别让情绪扼杀你状态


(1) 所谓迷茫,都不过是想的太多


总在独处时,开始怀疑自己,我是谁,我在干什么,以后该怎么办......在我理智的那两天,我都会把这种状态归咎于闲的蛋疼。但是确确实实在那种状态下,什么事情都没法下手,最严重的的一种状态,就是会有一种深深的无力感,感觉距离目标实在太远了。这种无力感,会瞬间摧毁你的勇气,让你不敢下手去做些什么。就像是一场噩梦,你明知道应该醒来,却无法挣脱。



鼠鼠我啊是家里唯一的大学生,大学入了党,工作也没有让家里操心,家里人都认为我有出息了,只有鼠鼠觉得鼠鼠是个废物,鼠鼠以前也会想着让妈妈为自己骄傲,让家里人可以开心的生活,可是浪浪山不如鼠鼠所愿,鼠鼠在浪浪山清楚的认识到了阶层的差距,身边的人正活着曾经难以想象的生活,鼠鼠也才知道人生可以那么精彩,它就在我眼前,又好像远在天边。鼠鼠在家里是最强天赋,在浪浪山却是擦锅布,我好像永远走不出浪浪山了,鼠鼠想回下水道,鼠鼠下辈子不想做鼠鼠。



这种状态,都不过是因为想的太多,我们总是在刚起步的时候,就想着终点在哪里;总是在刚学习一项技能的时候,就想着攻克技术难题;总是在与人初次见面之后,就想推心置腹;总是在今天都没有过好的时候,就想着明天该怎么办。我就是这样一个人,常常纠结于各种各样的学习路线上,每次在学习不同的技术的选择上,进行纠结,但其实这两者我明明是有足够的时间兼学的,还有时候明明知道基础要一步一步走扎实,但是却想到后面还有各种各样的新式技术,高级技术等着我,就会又开始所谓的迷茫。


其实这种所谓的迷茫,很多时候都是源自于我们想的太多了,路要一步一步走,饭要一口一口吃,想的太多,就会感到迷茫和焦虑。最好的办法就是,立足当下,安于寂寞,不要太着急看到极大的成果,放平心态,只有你的心里想通了,你的状态就会迅速回归,重振旗鼓


(2) 你总需要一个人走一段路


孤独伴随着,几年前来到几百公里外上学的我,亦或是年后即将开始找实习,找工作的我。


我想我总会有一段时间感觉到莫名的孤独,想找个人聊聊天,却又不想去找,自己戴着耳机,漫无目的的走在路上。以及每次晚上或者凌晨写完东西,躺在床上有一种说不出的感觉,特别的是,我并不感到忧伤,只是感觉空落落的,也不想认识新的朋友,也不想联系家人,却也不知道有些话该和谁说。


即使你人缘很好,常常有三五好友一起相伴,但是总会有一些空隙感到孤独,这源自于你的内心还是不够强大,有的人独行却乐在其中,有的人三无成伴却又内心孤独,因为孤独的人心中并无足乐者,灵魂还是被空虚填满。


所以,请充实自己的生活,多出去走走,多与人交往,给自己多找点自己感兴趣的事情去做,即使感到孤独,也没必要太过沮丧,只需要告诉自己,沮丧,孤独,都是正常的,我们要在自己走的这段路上,让自己成为一个更加闪亮的人。走过自己的一段夜路,终将会有柳暗花明又一村的“闹市”。


(七) 恋爱的本质是「撞」而不是「寻」


(1) 你真的想要谈恋爱吗?


有时候总会想,谈恋爱是「一定要」还是「可以要」亦或者 「没必要」。


总有那么几天,好似陷入了爱情的怪圈。让你平淡无奇的生活荡起了阵阵涟漪,打破了你安稳的生活轨迹。


大部分时候,或许只是你想要摆脱这种“孤零零”的状态,又或者看着别人的“幸福”与“快乐”,激起了你的那份欲望,而欲望总会在你的忍耐中冲昏你的头脑,让你开始憧憬爱情,并且费力的去「寻」去「找」,试图去接触不同的人,试图找出哪一个是适合自己的,或许你会觉得,主动去寻找自己的幸福是一件很美好的事情,不过于我而言,这并不是爱情,我只能把它叫做权衡利弊后的一个选择。


或许有的时候,你只是想找一个人陪你,那也或许并不是爱情。你结束了一天的忙碌,合上了笔记本电脑,关掉了手机,疲惫的倚靠在椅子上,狭小的房间中,只有那盏台灯在一片死寂中发出微弱的光。连点一只烟的动作都觉得多余,他只想一个人安静的待一会,也不知道在想什么。但如果无由头的想起了一些事情,一些人,这个引火线,就会瞬间将情绪点燃,无尽的孤独涌上心头,这个时候,你渴望有一个人陪在你身边,陪你说说话,哪怕陪着你坐一坐,起码让你知道你并不是一个人。自此以后,你开始标榜自己「需要人陪」,看似高尚的理由,其实只是你害怕寂寞的一种借口,就算你真的找到了一个陪着你的人,那你真的爱她吗,可能你只是在你漆黑的房间中又添置了一盏台灯,这样能让你的眼中看起来更加明亮。


(2) 三观一致真的很重要!


这几年也接触过一些异性,或许也有动心过,但是你会发现,不同人看待,处理事物的方式会有截然不同的结果,你认为简直不可理解的事情,在其眼中似乎也稀松平常,或许你不懂她,也许她不懂你,三观这个词的定义实在太模糊,最简单的方法就是看你们在一起的感觉,给你的感觉如果是很舒服的,那么可以进一步了解一下,害,没什么好说的了, 希望你可以找一位能符合你心中期望的另一半。


(3) 顺着人生轨迹走吧,别为了一个人停下来


千万不要陷入单恋的漩涡中,这是致命的,对的人是不需要主动找的,你只需要顺着人生轨迹走,在合适的年纪做合适的 “正事” ,自然而然就会遇到那个人了,如果等到七老八十,也没有遇到,或许这也就是命。或许说的太悲观了,但我仍认为,与其让自己为了追求一个不确定,也或许没有回应的爱情,不如自己欣赏自己孤岛中的美丽。但话也不能太绝对,或许有一天我就会因为所谓的爱情,陷入盲目。爱情这东西,谁说的好呢。但我只要不断告诉自己,一定不要停下来


第二节 这一年我都做了些什么


(一) 学习 + 技术输出


(1) 行百里者半九十


按照原来的计划,从 Java --> JavaWeb --> SSM --> SpringBoot 这个主线就算结束了,其中夹杂着 SQL,Linux,Web,小程序,设计模式等等的一些支线。不过,根据自己的情况和具体需要吗,其实我已经做出了一些重点的调整,我会在后面的目标中去提到。


(2) 一年和球友一起 输出了一百多万字的笔记



先放地址【Java全栈方向】:http://www.yuque.com/icu0/wevy7f


欢迎大佬们关注一下小弟。



一年中,一边学习,一边做总结,做整理,陆陆续续一年也写了200来篇笔记(也可能是文章或者感悟)(不一定纯后端/前端,还有 Linux ,计网等等)记得某个大佬说过写博客和笔记不一定能做到对别人有帮助,但起码对自己有帮助。但是我一直通过大白话概括 + 做图 + 简单示例 + 官方说明的方式写文章,也在努力希望能对别人也有帮助。



(二) 超爱买书的购物狂


这一年买了不少书(买了 == 看了),还有好多想买的都在我的购物车里吃灰,再买我真怕自己变成一个光收藏的 “读书人” 了,来盘点盘点这一年我看了比较有感觉的书(没感觉的和没怎么读的就不提了,如果给我多一点时间,我争取出一篇介绍自己读的书籍的文章)




一件恐怖的事情:我利用一年时间看过了这些书



第三节 明年今日,记得要回头看看



We already walked too far, down to we had forgotten why embarked. ——纪伯伦《先知》


译文:我们已经走得太远,以至于忘记了为什么而出发。



2022年度回顾



2023年新的目标


技术上:


只有写1-2月的,所以我放一个链接,欢迎大家监督我学习



http://www.yuque.com/icu0/qeowns… 《Cool的三两事》



生活上:


  1. 孝敬父母

  2. 勤运动

  3. 照顾好自己的身体

  4. 不要熬夜

  5. 与人交谈,沉稳思虑而后动

  6. 多读书,多出去走走,善待他人


学业上:


  1. 英语四级

  2. 拉取开源项目,为开源项目提PR

  3. 持续输出技术型文章

  4. 专升本上岸


总而言之,2022喜忧参半,有“春风得意马蹄疾,一日看尽长安花”的喜悦,也有“停杯投箸不能食,拔剑四顾心茫然”的忧愁,但我希望我的2023能有“长风破浪会有时,直挂云帆济沧海”。



个人独白:


以上内容皆是一名专科生的自白,感谢自己在大专三年没有一天是“浑浑噩噩式”学习,也没有一天因为当前的荣誉而骄傲满足,同时感谢部队两年的栽培,让我站在低谷依旧能仰望天空,扎根大地,心有猛虎,细嗅蔷薇。


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

让ChatGPT帮我写一篇博客

前言 昨天还是前天在公众号看到美国的一个设计师兼作家的Jackson,在用ChatGPT创业,他让ChatGPT做“老板”,自己充当一个人工联络员,这样的一个故事让他火了,不仅推特粉丝大涨,公司也做得有模有样。这就给我一些启发,今天早上突然想到为什么不能让Ch...
继续阅读 »

前言


昨天还是前天在公众号看到美国的一个设计师兼作家的Jackson,在用ChatGPT创业,他让ChatGPT做“老板”,自己充当一个人工联络员,这样的一个故事让他火了,不仅推特粉丝大涨,公司也做得有模有样。这就给我一些启发,今天早上突然想到为什么不能让ChatGPT也帮我干活呢,所以我让他写了一篇我感兴趣的博客。


什么是交互艺术?



  • 交互艺术的定义和历史


交互艺术(Interactive Art)是指通过观众与作品互动来创造艺术作品的一种艺术形式。观众可以通过各种方式来参与艺术作品的创作过程,例如通过触摸、声音、身体动作、传感器等方式与作品互动,从而影响作品的展示效果和呈现形式。


交互艺术的历史可以追溯到20世纪60年代,当时的科技进步促使艺术家开始尝试使用电子设备和计算机来创建艺术作品。随着计算机技术的不断发展,交互艺术逐渐成为一个独立的艺术领域,许多艺术家开始探索如何将新兴技术与传统艺术形式结合起来,创造出更具互动性、多媒体性和参与性的艺术作品。


在当代艺术中,交互艺术已经成为一个重要的艺术形式,不断涌现出许多富有创意和思想性的作品。交互艺术的发展也受到了数字技术和科技的快速进步的影响,艺术家们可以使用越来越先进的技术来创造更加复杂、多样化的交互艺术作品。



  • 交互艺术与传统艺术形式的区别和相似之处


交互艺术与传统艺术形式在表现形式、创作方式和观赏方式上存在一些区别和相似之处。


区别:



  1. 表现形式不同:传统艺术形式通常是静态的,比如绘画、雕塑等,而交互艺术则常常是动态的,利用科技和互动方式与观众进行互动。

  2. 创作方式不同:传统艺术形式往往是由一个或几个艺术家单独创作完成,而交互艺术则通常需要一个团队来完成,包括设计师、程序员、工程师等多个专业人员。

  3. 观众参与度不同:传统艺术形式通常是观众被动地欣赏,而交互艺术则需要观众的积极参与,观众不再是简单的观看者,而是成为作品的一部分,与作品进行互动。


相似之处:



  1. 创作目的相似:传统艺术和交互艺术都有一个共同的目的,就是通过艺术表达和传递情感、思想和价值观念等。

  2. 创作需要创意:无论是传统艺术还是交互艺术,都需要创意,才能产生具有艺术价值的作品。

  3. 艺术的审美标准类似:无论是传统艺术还是交互艺术,都需要具有一定的审美标准,才能得到观众的认可。


总的来说,交互艺术是一种创新的艺术形式,与传统艺术形式相比具有很多不同的地方,但它们都是为了表达和传递情感、思想和价值观念等而存在的。


交互艺术的表现形式



  • 交互装置


互动装置是一种创新的展示形式,是通过各种技术手段实现人机交互,让观众能够积极参与其中,与艺术品互动,甚至改变艺术品的形态。例如,光线与声音互动的装置、投影与运动互动的装置、使用虚拟现实技术的装置等。



  • 互动性媒体


数字媒体是指以计算机技术为基础,通过数字技术手段,将文本、音频、视频等多种媒体形式进行集成和处理的一种新兴的艺术形式。数字媒体广泛应用于网络、游戏、互动展览、艺术品创作等领域。数字媒体可以通过人机交互来实现观众参与。



  • 跨媒介艺术


跨媒介艺术是指将不同的媒介进行融合,从而创造出全新的艺术形式。在交互艺术中,跨媒介艺术得以大量运用,以实现更为复杂和多样化的表现。主要包括:融合音乐,舞蹈,戏剧,美术,文字等等。


交互艺术的设计过程



  • 设计理念和目标


交互艺术的设计理念和目标可以根据具体项目的不同而有所不同,比如以互动性为主:交互艺术的设计目标是与观众进行互动,让观众成为艺术作品的一部分。互动可以是双向的,也可以是多向的,观众和作品之间可以有各种形式的交流和反馈。以参与性为主:交互艺术作品的设计目的是让观众成为作品的参与者,观众不仅是作品的被动观看者,还可以通过各种方式主动参与到作品中,体验艺术的过程。以创新性为主:交互艺术通常借助科技手段来实现艺术形式的创新,例如虚拟现实、增强现实、人工智能等技术,让观众体验到新颖的艺术形式和感官体验。以实验性为主:交互艺术通常具有实验性质,设计者会尝试各种不同的技术和形式,不断探索和发掘新的艺术表现方式和可能性。以社交性为主:交互艺术作品通常可以带来社交体验,让多个观众之间产生互动和交流,增加观众之间的沟通和共同体验。以可持续性为主:交互艺术的设计也需要考虑作品的可持续性,包括对环境的影响、对观众的健康和安全等方面的考虑。同时还需要考虑作品的维护和管理,确保作品的长期运行和展示。



  • 技术实现和选择


交互艺术的技术实现有多种选择,以下是一些常见的技术实现:



  1. 传感器技术:通过感应器获取观众的运动、声音、触摸等行为,以此来激发或控制艺术作品的变化。

  2. 虚拟现实技术:使用计算机技术和虚拟现实设备(如头戴式显示器、手套式控制器等)创造虚拟空间,使观众可以沉浸在其中与作品进行交互。

  3. 增强现实技术:使用手机、平板电脑等设备,将虚拟图像叠加在现实场景中,使观众可以在真实场景中进行虚拟的交互体验。

  4. 数据可视化技术:使用数据可视化软件和技术将数据转化为图形、动画、声音等形式,让观众可以与数据进行交互并得到更深入的理解。

  5. 互动音乐技术:使用计算机技术和音乐软件,将观众的声音、运动等行为转化为音乐,并与音乐作品进行互动。

  6. 智能机器人技术:使用机器人技术和人工智能技术,创造能够与观众进行交互的智能机器人艺术作品。


除此之外,还有许多其他的技术可以被应用于交互艺术的实现,这取决于艺术家的创造力和技术能力。



  • 用户参与和反馈


用户参与和反馈在交互艺术中起着至关重要的作用,这是因为交互艺术强调观众参与、互动和沟通,与传统艺术形式相比,用户的参与和反馈更能够影响交互艺术的展现和效果。用户参与和反馈可以创造更丰富、更具有互动性的艺术体验。通过参与和反馈,用户可以主动探索和发现艺术作品中的细节,与艺术家进行更深入的互动和交流。用户参与和反馈可以增加用户对交互艺术的参与度,使观众更加融入艺术作品之中,感受到艺术作品所传达的情感和信息。用户反馈可以帮助艺术家改善艺术作品的表现,及时发现并解决问题,让作品更加完善和符合观众的期望。通过参与和反馈,用户可以更好地理解和体验艺术作品,从而对作品产生更深刻的印象和理解,提高作品的艺术价值和影响力。


交互艺术的影响和意义



  • 对艺术和文化的影响


首先,交互艺术提供了新的观看方式和体验方式,通过参与和互动,观众成为了作品的一部分,与作品发生了联系和互动,这种体验方式比传统艺术观看更加身临其境,更能够引起观众的共鸣和情感共鸣。


其次,交互艺术扩展了艺术形式和创作方式的范围,使得艺术家可以使用更多的媒介和技术手段来表现自己的创意和思想,创作出更加复杂、多样化的作品。同时,交互艺术还促进了跨学科的合作和交流,让不同领域的人们汇聚在一起,共同探索艺术的新领域和可能性。


最后,交互艺术也在一定程度上挑战了传统艺术的观念和价值体系,它更加强调观众的参与和互动,追求创意和表达的多样性和自由性,让艺术更加民主化和开放化,更加贴近生活和人们的需求。



  • 对科技和创新的影响


交互艺术与科技、创新密切相关,因为交互艺术往往需要运用先进的科技和技术手段来实现。因此,交互艺术对科技和创新的影响主要表现在以下几个方面:



  1. 推动科技进步和应用:交互艺术在探索人机交互的过程中,往往需要运用先进的科技和技术手段,例如虚拟现实、增强现实、智能算法、传感器技术等等。这些技术的研究和应用,可以推动科技的进步和应用,也可以为其他领域的技术创新提供借鉴和参考。

  2. 催生新兴产业:随着交互艺术的不断发展和普及,一些新兴产业也应运而生,例如虚拟现实、增强现实、智能穿戴等等。这些产业的发展,也为科技和创新提供了新的发展机遇。

  3. 拓展创新思维:交互艺术强调观众参与和互动,鼓励观众从不同的角度去思考和理解作品。这种互动式的艺术形式,不仅可以拓展观众的视野和想象力,也可以激发人们的创新思维,从而为科技和创新带来新的灵感和方向。

  4. 促进科技与文化的融合:交互艺术将科技和文化相结合,探索科技与文化之间的互动和融合。这种融合不仅可以为文化艺术注入新的活力和创新,也可以促进科技和文化之间的相互理解和交流,为科技和创新带来新的思路和方向。



  • 对社会和人类的影响


首先,交互艺术的出现丰富了人们的文化生活,为人们带来了全新的艺术体验。交互艺术将观众从被动的接受者转变为积极的参与者和创造者,让人们更深入地体验艺术,对个人的审美和文化素养的提高有积极的推动作用。


其次,交互艺术对科技和创新的发展也有很大的促进作用。在交互艺术的设计中,常常会运用到各种前沿的科技手段,如人工智能、虚拟现实、增强现实等,这些技术的应用不仅提高了艺术表现的多样性和创新性,也促进了科技的发展和推广。


另外,交互艺术也推动了跨学科的合作与交流。交互艺术的创作需要艺术家、设计师、工程师等多个领域的专业人才进行合作,这种跨学科的合作有助于促进不同领域之间的交流与合作,进一步推动科技和艺术的发展。


最后,交互艺术也对社会产生了深远的影响。交互艺术作为一种探索艺术与科技、人与自然等关系的艺术形式,常常会引起人们对社会和人类的思考和反思。同时,交互艺术还可以作为一种公共艺术形式,为城市文化建设和社区文化发展做出贡献。


一些优秀的交互艺术作品的介绍和分析


《雨林声音之旅》(The Rainforest):这是一个由音乐家、工程师和艺术家合作制作的多媒体艺术作品,旨在通过视听交互体验向人们展示热带雨林生态系统的美丽和脆弱。在这个作品中,观众通过穿戴智能耳机,能够听到热带雨林中各种生物的声音,并随着观众的行动而改变。这个作品既展示了交互艺术对生态保护的关注,也通过技术手段提供了一个沉浸式的交互体验。


《万花筒之舞》(Kaleidoscope Dance):这个作品由艺术家和编程人员合作创作,是一个通过跟随舞者动作变幻图形的互动舞蹈。在这个作品中,观众通过观看跳舞者的身影,看到身影在投影上变幻出不同的几何图形,并随着舞者的动作而变化。这个作品的互动性和美学效果非常出色,展示了交互艺术的创造性和能力。


《未来自然》(Future Natural):这个作品由美国纽约市的艺术家托尼·瑞戈(tony oursler)制作,展示了未来科技与自然环境的融合。这个作品是一个互动装置,观众通过操纵屏幕上的自然元素,比如云、火、风和水,来创造出自己的自然景观。这个作品展示了交互艺术的潜力,让观众在艺术作品中自由探索和创造。


《印象派的视觉音乐》(Visual Music of Impressionism):这是一个通过数字技术还原19世纪法国印象派画家的绘画作品的互动展览。这个展览展示了通过数字技术将视觉艺术与音乐相结合的能力,让观众可以通过触摸屏幕、移动手势和声音互动等方式来探索印象派绘画作品的美学和音乐性。


未来展望:交互艺术的发展和趋势


交互艺术从20世纪末期开始兴起,并在21世纪初逐渐得到了广泛的关注和发展。最初的交互艺术作品主要依赖计算机技术,随着移动设备、传感器、物联网等技术的发展,交互艺术的形式也越来越多样化和复杂化。


近年来,交互艺术已经从传统的展览空间向公共空间和虚拟空间延伸,例如城市中的互动艺术装置和游戏、虚拟现实艺术作品等。同时,交互艺术也更多地关注社会和环境问题,例如气候变化、人类生活和工作的影响等。


未来的趋势将继续突破传统的艺术形式,更多地与科技、社会、文化等领域交叉融合,例如增强现实、人工智能、机器人技术等。同时,交互艺术也将更加强调观众的参与和反馈,更加注重体验和互动的情感性和反思性。


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

ChatGPT正在杀死程序员,讲讲我的看法——AI春晚,Boom!

AI春晚,Boom! 这两周,简直是AI春晚,ChatGPT4、ChatGPT Plugin、Microsoft 365 copilot、Github copilotX、MidJourney V5、Adobe Firefly、NVIDIA一堆新产品... 多到...
继续阅读 »

AI春晚,Boom!


这两周,简直是AI春晚,ChatGPT4、ChatGPT Plugin、Microsoft 365 copilot、Github copilotX、MidJourney V5、Adobe Firefly、NVIDIA一堆新产品... 多到数不过来了,几乎是数不过来了,大产品发布的速度可以用小时来计算,小产品更是多如牛毛,github trends、Hacker News差不多半数都被ChatGPT相关霸榜。


可谓科技圈的“大变局”,上一次大家这么激动,还是iPhone诞生。


接下来,我来说说看我的看法和一些个人预测。


我的几点看法




  1. ChatGPT的诞生,将带来新的革命,第一次信息技术革命主要是加速了信息传递,而此次第二次信息技术革命,则是让信息传递进一步加速的同时,让信息有了自生产能力。例如:你给AI一个标题,AI可以根据过往学习经验,联想一个新的故事。又比如:你写了一段新的代码,可以让AI帮你解释,也许未来帮你写文档。AI可以根据少量的信息,进行信息生产,这是替代人类一部分能力的关键点。




  2. 我认为大语言模型的能力并未被全部释放,AI可以做的还有更多,更有创造力,只不过暂时被封印了。未来的前景空间无限,随便上升一点点都是big boom.




  3. 这只是这一种模型而已... 相信AI行业的投入的资金接下来会是爆炸式的增长。其他模型大概率很快在资金加持下,进行多轮验证。指不定再来个别的超级AI。




  4. 自然语言(提示语)编程诞生,这将是最难的一门语言,短期来看,必然是英语,掌握英语的人未来拥有更大的竞争力。但是自然语言有较大的缺陷,就是难以构建复杂结构和海量参数的提示语,而人类的需求往往是精准的,只用自然语言效率比较低。所以我预测,未来会有更加结构化的语言诞生,它将综合人类的自然语言和高级编程语言,成长为新的热门语言。




  5. 毫无疑问,随着内容创作方面的效率急速提升,大多数白领的工作将会受到极大影响,被调整优化。不进步就会被淘汰,但是也意味着会出现新的机会。




  6. AI程序员不仅正在杀死白领,还无情地杀死另外一些程序员,底层如前端程序员和测试工程师将会受到巨大影响,优化。这其实是因为,相当一部分程序员的工作其实更接近于内容创作(cv工程师),而不是严密的逻辑思考和创造,所以更容易被AI取代。不得不说,程序员的差距就像人与狗的差距一样大。




  7. 新的繁荣即将到来?毫无疑问。纺织机夺走了大部分手工纺织者的工作,但是催生了更庞大的市场,例如服装设计,人的需求促使人朝着更加高级的方向发展了。那新的工作机会呢?可能是自然语言(提示语)工程、AI训练师等等。




  8. 恭喜前端程序员,终于不用写大量页面和CSS了,暂时也许可以考虑往全栈卷了?




  9. 如果你是一个简单的内容工作者或者初级程序员,那么赶紧学吧,否则将面临失业。




  10. 如果你是中高级技术工程师,也赶紧学吧,新的机会正在到来。本来吧,互联网遇冷,就业困难,大家都不想学了,学不动了,现在好了,局势逼着你学。




  11. 好消息是,我们有墙,也可能是坏消息。




  12. 可以大概率肯定的是,就业市场应该很快会回暖,现在就等巨头公司找找方向,开启新市场。


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

23年ChatGPT平替方案和开源案例

作为一个老掉牙的全栈程序员,不愿意参与职场的人情世故,只愿意埋头研究技术。是不是大家也有这种想法?这种想法,目前都是扯淡的,那些人情世故会把你挤走~ 想要纯粹的技术环境,可能或许只有大厂了~ 现实是:ChatGPT每天都在疯狂地学习,疯狂的进步,也在被开发人...
继续阅读 »

作为一个老掉牙的全栈程序员,不愿意参与职场的人情世故,只愿意埋头研究技术。是不是大家也有这种想法?这种想法,目前都是扯淡的,那些人情世故会把你挤走~ 想要纯粹的技术环境,可能或许只有大厂了~



现实是:ChatGPT每天都在疯狂地学习,疯狂的进步,也在被开发人员疯狂的使用。至少,我们团队每天总有一个窗口留给了它。由于最近OpenAI服务器频发故障,进入官网的速度越来越慢,难度越来越大。平替或许不是最好的方案,但有时候却是不可或缺的工具。


据悉:OpenAI团队目前正在努力改善模型的性能和速度,想让模型变得更快、更高效。无独有偶,百度也在近期推出了企业专版产品:文心千烦,网友回复:百度果然没有辜负大家,第一时间没有投入技术研究,而是研究出付费方式


平替案例


以下是群友整理的地址,请勿填写自己的key,以防止被调用。


anzorq-chatgpt-demo.hf.space

chat.openai1s.com [荐]

chat.aifks001.online

gptocean.com

chatgpt.ai [荐]

ai-chat.scholarcn.com

http://www.x5.chat
builtbyjesse.com/lab
http://www.scifmat.work/
aichat.momen.vip/home


开源代码:


github.com/waylaidwand…

github.com/dirk1983/ch…

github.com/Chanzhaoyu/…

github.com/869413421/c…


推荐插件


语音交互插件


Voice Control for ChatGPT:实现语音与chagpt交互,支持多国语言


image.png


下载地址:chrome.google.com/webstore/de…


角色提示市场


AIPRM for ChatGPT,可以内嵌角色,输入风格, 输出风格等多种标签支持(貌似开始收费了)


image.png

下载地址:chrome.google.com/webstore/de…


聊天工具库-彩蛋


聊天工具,可以支持复制,下载聊天记录,下载图片,转成pdf多种工具


image.png


下载地址


Google浏览器插件


ChatGPT 谷歌助手和高亮显示工具,玩的太嗨了


image.png


下载地址:chrome.google.com/webstore/de…


角色脚本插件-彩蛋


可以通过/触发脚本指令,收录近300个专业的角色插件指令,目前尚未发布市场。


image.png


下载地址


接入外网的ChatGPT


可以访问互联网的ChatGPT,数据将不会再停留在2021年9月,而是实时数据检索,通过GPT的语言模型将关键信息提取出来喂养后,给出更加精准的答案


image.png


技术研究


image.png


案例比较多,就提取一个自己玩的比较好的,用node启动的服务,可以在seveless中快速启动。
设置环境变量:OPENAI_API_KEY为自己key即可


import cloud from '@lafjs/cloud'//这个不用管
import axios from 'axios'//这个测试的
import { ChatGPTAPI } from 'chatgpt'
export async function main(ctx: FunctionContext) {
// body, query 为请求参数, auth 是授权对象
const { auth, body, query } = ctx;
const prompt = body.text.content;
const api = new ChatGPTAPI({ apiKey: cloud.env.OPENAI_API_KEY })
let res = await api.sendMessage('你好')
console.log(res.text)
}

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

Android 实现计时器

这周接到个新需求,统计用户在线时长,累积到一定时长后上报,可以通过计时器来实现。本篇文章介绍下安卓端实现计时器的三种方式。 Timer、TimerTask 通过Timer和TimerTask实现计时,代码如下: class TimeChangeExample ...
继续阅读 »

这周接到个新需求,统计用户在线时长,累积到一定时长后上报,可以通过计时器来实现。本篇文章介绍下安卓端实现计时器的三种方式。


Timer、TimerTask


通过TimerTimerTask实现计时,代码如下:


class TimeChangeExample : BaseGestureDetectorActivity() {

private lateinit var binding: LayoutTimeChangeExampleActivityBinding

private val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS")

private var timerHandler = object : Handler(Looper.myLooper() ?: Looper.getMainLooper()) {
override fun handleMessage(msg: Message) {
super.handleMessage(msg)
if (msg.what == 0) {
setCountdownTimeText(msg.obj as Long)
}
}
}
private var timer: Timer? = null
private var timerTask: TimerTask? = null

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = DataBindingUtil.setContentView(this, R.layout.layout_time_change_example_activity)
binding.btnCountdownByTimer.setOnClickListener {
clearText()
binding.tvCountdownText.text = "countdown by timer\n"
startCountdownByTime()
}
binding.btnStopTimer.setOnClickListener {
stopTimer()
}
}

private fun startCountdownByTime() {
stopTimer()
timerTask = object : TimerTask() {
override fun run() {
timerHandler.sendMessage(timerHandler.obtainMessage(0, System.currentTimeMillis()))
}
}
timer = Timer()
timer?.schedule(timerTask, 0, 1000)
}

private fun stopTimer() {
timer?.cancel()
timer = null
timerTask = null
}

private fun setCountdownTimeText(time: Long) {
binding.tvCountdownText.run {
post {
text = text.toString() + "${dateFormat.format(Date(time))}\n"
}
}
}

private fun clearText() {
binding.tvCountdownText.text = ""
}

override fun onDestroy() {
super.onDestroy()
stopTimer()
}
}

效果如图:


两次计时之间的误差都是毫秒级的。


timer -original-original.gif

BroadCastReceiver


通过注册广播,监听系统时间变化实现计时,但是广播回调触发的间隔固定为一分钟,代码如下:


class TimeChangeExample : BaseGestureDetectorActivity() {

private lateinit var binding: LayoutTimeChangeExampleActivityBinding

private val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS")

private val timeChangeBroadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == Intent.ACTION_TIME_TICK) {
setCountdownTimeText(System.currentTimeMillis())
}
}
}

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = DataBindingUtil.setContentView(this, R.layout.layout_time_change_example_activity)
binding.btnCountdownByBroadcast.setOnClickListener {
clearText()
binding.tvCountdownText.text = "countdown by broadcast\n"
startCountdownByBroadcast()
}
binding.btnStopBroadcast.setOnClickListener {
stopBroadcast()
}
}

private fun startCountdownByBroadcast() {
registerReceiver(timeChangeBroadcastReceiver, IntentFilter().apply {
addAction(Intent.ACTION_TIME_TICK)
})
}

private fun stopBroadcast() {
unregisterReceiver(timeChangeBroadcastReceiver)
}

private fun setCountdownTimeText(time: Long) {
binding.tvCountdownText.run {
post {
text = text.toString() + "${dateFormat.format(Date(time))}\n"
}
}
}

private fun clearText() {
binding.tvCountdownText.text = ""
}

override fun onDestroy() {
super.onDestroy()
stopBroadcast()
}
}

效果如图:


两次计时之间的误差都是毫秒级的。


boardcast.png

Handler


通过HandlerRunnable来实现计时,代码如下:


class TimeChangeExample : BaseGestureDetectorActivity() {

private lateinit var binding: LayoutTimeChangeExampleActivityBinding

private val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS")

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = DataBindingUtil.setContentView(this, R.layout.layout_time_change_example_activity)
binding.btnCountdownByHandler.setOnClickListener {
clearText()
binding.tvCountdownText.text = "countdown by handler\n"
startCountdownByHandler()
}
binding.btnStopHandler.setOnClickListener {
stopHandler()
}
}

private val handler = Handler(Looper.myLooper() ?: Looper.getMainLooper())
private val countdownRunnable = object : Runnable {
override fun run() {
setCountdownTimeText(System.currentTimeMillis())
val currentTime = SystemClock.uptimeMillis()
val nextTime = currentTime + (1000 - currentTime % 1000)
handler.postAtTime(this, nextTime)
}
}

private fun startCountdownByHandler() {
val currentTime = SystemClock.uptimeMillis()
val nextTime = currentTime + (1000 - currentTime % 1000)
handler.postAtTime(countdownRunnable, nextTime)
}

private fun stopHandler() {
handler.removeCallbacks(countdownRunnable)
}

private fun setCountdownTimeText(time: Long) {
binding.tvCountdownText.run {
post {
text = text.toString() + "${dateFormat.format(Date(time))}\n"
}
}
}

private fun clearText() {
binding.tvCountdownText.text = ""
}

override fun onDestroy() {
super.onDestroy()
stopHandler()
}
}

效果如图:


两次计时之间的误差都是毫秒级的。


handler -original-original.gif

示例


在示例Demo中添加了相关的演示代码。


ExampleDemo github


ExampleDemo gitee


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

android自定义View: 九宫格解锁

本系列自定义View全部采用kt 系统:mac android studio: 4.1.3 kotlin version1.5.0 gradle: gradle-6.5-bin.zip 废话不多说,先来看今天要完成的效果: 3X3 (样式1)4*4(样式2)...
继续阅读 »

本系列自定义View全部采用kt



系统:mac


android studio: 4.1.3


kotlin version1.5.0


gradle: gradle-6.5-bin.zip


废话不多说,先来看今天要完成的效果:


3X3 (样式1)4*4(样式2)5*5(样式3)
68003856905943AF9D5C44066EC4E13128A4F0086BD411F14D83F0B04E14893C6826652AEDDA18C295974B9A54BC55C6

Tips:不止3X3 或者 5X5 ,如果你想,甚至可以设置10*10


画圆


先以3*3的九宫格来介绍!


image-20220914105128040


我们要画成这样的效果, 画的是有一点丑,但是没关系.


首先来分析一下怎么花,这9个点的位置如何确定:



  • 我们为了平均分, 单个圆的外层矩形 宽 = view.width / 3

  • 高 = 宽

  • 1号圆的圆心位置 = 0个矩形的宽度 = view.width / (3 * 2) + ( view.width / 3 ) * 0

  • 2号圆的圆心位置 = 1号圆的圆心位置 + 1个矩形的宽度 = view.width / (3 * 2) + (view.width / 3) * 1

  • 3号圆的圆心位置 = 1号圆的圆心位置 + 2个矩形的宽度 = view.width / (3 * 2) + (view.width / 3) * 2


高坐标的计算也是如此


来看看目前的代码:


class BlogUnLockView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
//
strokeJoin = Paint.Join.BEVEL
}

// 大圆半径
private val bigRadius by lazy { width / (NUMBER * 2) * 0.7f }

// 小圆半径
private val smallRadius by lazy { bigRadius * 0.2f }

companion object {
const val NUMBER = 3
}

private val unLockPoints = arrayListOf<ArrayList<UnLockBean>>()

override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
// 矩形直径
val diameter = width / NUMBER

//
val ratio = (NUMBER * 2f)
var index = 1

// 循环每一行行
for (i in 0 until NUMBER) {
val list = arrayListOf<UnLockBean>()

// 循环每一列
for (j in 0 until NUMBER) {
list.add(
UnLockBean(
width / ratio + diameter * j,
height / ratio + diameter * i,
index++
)
)
}
unLockPoints.add(list)
}
}

override fun onDraw(canvas: Canvas) {
canvas.drawColor(Color.YELLOW)

unLockPoints.forEach {
it.forEach { data ->
// 绘制大圆
paint.alpha = (255 * 0.6).toInt()
canvas.drawCircle(data.x, data.y, bigRadius, paint)

// 绘制小圆
paint.alpha = 255
canvas.drawCircle(data.x, data.y, smallRadius, paint)
}
}
}
}

当前效果:


image-20220914110551658


目前问题:



  • 整个view占满了屏幕,需要测量


测量代码比较简单,就是让宽和高一样即可


image-20220914111142598


此时改变number变量,就可以设置几行几列:


例如这样:


5*510*10
image-20220914111416881image-20220914111450264

接下来我们就处理手势事件,按下滑动,抬起等,来改变选中


onTouchEvent事件处理


在事件处理之前先来分析一下需要几种事件,对于解锁功能来说:



  • ORIGIN 刚开始,还没有触摸

  • DOWN 正在触摸中(输入密码)

  • UP 触摸结束 (输入密码正确)

  • ERROR 触摸结束 (输入密码错误)


那么就先定义4种颜色,来表示这4种状态:


companion object {

// 原始颜色
private var ORIGIN_COLOR = Color.parseColor("#D8D9D8")

// 按下颜色
private var DOWN_COLOR = Color.parseColor("#3AD94E")

// 抬起颜色
private var UP_COLOR = Color.parseColor("#57D900")

// 错误颜色
private var ERROR_COLOR = Color.parseColor("#D9251E")
}

接下来挨个处理事件


DOWN(按下)


首先需要思考,在按下的时候要做什么事情:



  • 判断是否选中


/*
* TODO 判断是否选中某个圆
* @param x,y: 点击坐标位置
*/

private fun isContains(x: Float, y: Float) = let {
unLockPoints.forEach {
it.forEach { data ->
// 循环所有坐标 判断两个位置是否相同
if (PointF(x, y).contains(PointF(data.x, data.y), bigRadius)) {
return@let data
}
}
}
return@let null
}

// 判断一个点是否在另一个点范围内
fun PointF.contains(b: PointF, bPadding: Float = 0f): Boolean {
val isX = this.x <= b.x + bPadding && this.x >= b.x - bPadding

val isY = this.y <= b.y + bPadding && this.y >= b.y - bPadding
return isX && isY
}

思路: 通过比较 按下位置和所有位置,判断是否有相同的



  • 如果有相同的,那么就返回对应坐标

  • 如果没有相同的,那么就返回null



@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.action) {
MotionEvent.ACTION_DOWN -> {
// 判断是否选中
val pointF = isContains(event.x, event.y)
pointF?.let {
// 将当前类型变为按下类型
it.type = JiuGonGeUnLockView.Type.DOWN
}
}
...
}
invalidate()
return true
}

override fun onDraw(canvas: Canvas) {
// canvas.drawColor(Color.YELLOW)

unLockPoints.forEach {
it.forEach { data ->
// 根据类型设置颜色
paint.color = getTypeColor(data.type)

// 绘制大圆
paint.alpha = (255 * 0.6).toInt()
canvas.drawCircle(data.x, data.y, bigRadius, paint)

// 绘制小圆
paint.alpha = 255
canvas.drawCircle(data.x, data.y, smallRadius, paint)
}
}
}

/// TODO 获取类型对应颜色
private fun getTypeColor(type: JiuGonGeUnLockView.Type): Int {
return when (type) {
JiuGonGeUnLockView.Type.ORIGIN -> ORIGIN_COLOR
JiuGonGeUnLockView.Type.DOWN -> DOWN_COLOR
JiuGonGeUnLockView.Type.UP -> UP_COLOR
JiuGonGeUnLockView.Type.ERROR -> ERROR_COLOR
}
}

当前效果:


B6B94BC2B7487B5894E6840C1F783F7A

MOVE(移动)


move事件和down事件的逻辑是一样的,滑动的过程中判断点是否选中,然后绘制点


@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.action) {
MotionEvent.ACTION_DOWN -> {
val pointF = isContains(event.x, event.y)
pointF?.let {
// 将当前类型改变为按下类型
it.type = JiuGonGeUnLockView.Type.DOWN
}
}
MotionEvent.ACTION_MOVE -> {
val pointF = isContains(event.x, event.y)
pointF?.let {
// 将当前类型改变为按下类型
it.type = JiuGonGeUnLockView.Type.DOWN
}
}

....
}

invalidate()
return true
}

当前效果:


1800F1D0441C219F4F2735B706DFFB9B

可以看出,效果是基本完成了,但是还有一个小错误


通常我们在九宫格的时候,一般都是先按下一个点才能滑动, 否则是不能滑动的,


现在的问题是,直接就可以滑动,所以还需要调整一下


那么我们就需要在down事件中标记一下是否按下,然后在move事件中判断一下


// 是否按下
private var isDOWN = false

@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.action) {
MotionEvent.ACTION_DOWN -> {
val pointF = isContains(event.x, event.y)
pointF?.let {
// 将当前类型改变为按下类型
it.type = JiuGonGeUnLockView.Type.DOWN
isDOWN = true // 表示按下
}
}
MotionEvent.ACTION_MOVE -> {
if (!isDOWN) {
return super.onTouchEvent(event)
}
val pointF = isContains(event.x, event.y)
pointF?.let {
// 将当前类型改变为按下类型
it.type = JiuGonGeUnLockView.Type.DOWN
}
}

MotionEvent.ACTION_CANCEL,
MotionEvent.ACTION_UP -> {
isDOWN = false // 标记没有按下
}
}

invalidate()
return true
}

此时效果:


980BE4943A8EBF10516BAA27E023151B

UP(抬起)


思路分析:


抬起的时候要做很多事情




  • 判断输入密码是否正确



    • 密码输入正确,那么就改变为深绿色

    • 密码输入错误,就改变为红色




  • 完成之后,还需要吧所有的状态清空




在这里的时候,先不判断密码是否成功, 默认都是成功的,



  • 先吧输入的密码toast出来

  • 并且吧状态清空


等结尾的时候再来判断密码.


那么此时肯定是需要将所有选中的都记录下来, 然后在up事件中操作即可


// 记录选中的坐标
private val recordList = arrayListOf<UnLockBean>()

@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.action) {
MotionEvent.ACTION_DOWN -> {
val pointF = isContains(event.x, event.y)
pointF?.let {
// 将当前类型改变为按下类型
it.type = JiuGonGeUnLockView.Type.DOWN
isDOWN = true

recordList.add(it)
}
}
MotionEvent.ACTION_MOVE -> {
if (!isDOWN) {
return super.onTouchEvent(event)
}
val pointF = isContains(event.x, event.y)
pointF?.let {
// 将当前类型改变为按下类型
it.type = JiuGonGeUnLockView.Type.DOWN

// 这里会重复调用,所以需要判断是否包含,如果不包含才添加
if (!recordList.contains(it)) {
recordList.add(it)
}
}
}

MotionEvent.ACTION_CANCEL,
MotionEvent.ACTION_UP -> {
// 将结果打印
recordList.map {
it.index
}.toList() toast context

clear()
}
}

invalidate()
return true
}

/// 清空所有状态
private fun clear() {
recordList.forEach {
// 将所有选中状态还原
it.type = JiuGonGeUnLockView.Type.ORIGIN
}
recordList.clear()
isDOWN = false // 标记没有按下

invalidate()
}

当前效果:


C1A1C9AA5362879D8EB870BC953FFAD9

画连接线


还是以这张图来说:


image-20220914105128040


假设现在需要连接 1,5,6,9


那么可以通过Path()来画线


在DOWN事件中,通过moveTo()移动到1的位置


在MOVE事件中,通过lineTo()画5,6,9的位置 即可


private val path = Path()

@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.action) {
MotionEvent.ACTION_DOWN -> {
val pointF = isContains(event.x, event.y)
pointF?.let {
/// 隐藏部分代码

path.moveTo(it.x, it.y)
}
}
MotionEvent.ACTION_MOVE -> {
val pointF = isContains(event.x, event.y)
pointF?.let {
/// 隐藏部分代码

// 这里会重复调用,所以需要判断是否包含,如果不包含才添加
if (!recordList.contains(it)) {
recordList.add(it)
path.lineTo(it.x, it.y) // 连接到移动的位置
}
}
}

MotionEvent.ACTION_CANCEL,
MotionEvent.ACTION_UP -> {
// 将结果打印
recordList.map {
it.index
}.toList() toast context


clear()
}
}

invalidate()
return true
}

/*
* 作者:史大拿
* 创建时间: 9/14/22 1:38 PM
* TODO 用来清空标记
*/

private fun clear() {
path.reset() // 重置

recordList.forEach {
// 将所有选中状态还原
it.type = JiuGonGeUnLockView.Type.ORIGIN
}
recordList.clear()
isDOWN = false // 标记没有按下
}

override fun onDraw(canvas: Canvas) {
paint.style = Paint.Style.FILL
unLockPoints.forEach {
/// 隐藏部分代码
}

paint.style = Paint.Style.STROKE
paint.strokeWidth = 4.dp
paint.color = DOWN_COLOR // 默认按下颜色
canvas.drawPath(path, paint)
}

当前效果:


93DE90804F77B93312D8547F84F4609B

可以看出,已经完成了画连接线,但是还缺少一条指示当前手指位置的线,


我叫他移动线,,, (好土的名字)


移动线就2个坐标



  • 开始位置 (最后一个选中的位置)

  • 结束位置 (当前手指按下的位置)


private val line = Pair(PointF(), PointF())

@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.action) {
MotionEvent.ACTION_DOWN -> {
val pointF = isContains(event.x, event.y)
pointF?.let {
/// 隐藏代码

line.first.x = it.x
line.first.y = it.y
}
}
MotionEvent.ACTION_MOVE -> {
val pointF = isContains(event.x, event.y)
pointF?.let {
if (!recordList.contains(it)) {
//// 隐藏代码

// 最后一个选中的位置
line.first.x = it.x
line.first.y = it.y
}
}

// 手指的位置
line.second.x = event.x
line.second.y = event.y
}
....
}

invalidate()
return true
}

override fun onDraw(canvas: Canvas) {

paint.style = Paint.Style.FILL
unLockPoints.forEach {
/// 隐藏代码
}

// 绘制连接线
paint.style = Paint.Style.STROKE
paint.strokeWidth = 4.dp
paint.color = DOWN_COLOR // 默认按下颜色
canvas.drawPath(path, paint)

// 绘制移动线
if (line.first.x != 0f && line.second.x != 0f
) {
canvas.drawLine(
line.first.x,
line.first.y,
line.second.x,
line.second.y,
paint
)
}
}

当前效果:


2C05F7D7EB4E102778B87581AA183E79

此时效果就差不多了,画笔默认是实心圆, 来看看空心效果


空心效果


空心效果很简单,只需要调整画笔的style即可


 override fun onDraw(canvas: Canvas) {
// 实心效果
// paint.style = Paint.Style.FILL

// 空心效果
paint.style = Paint.Style.STROKE
paint.strokeWidth = 4.dp

// canvas.drawXXX()
}

当前效果


2F8ECA7B3AE2F9DCAE2FD46F846B66C9

可以看出,此时的效果和我们想的一样,但是画线的时候从小圆圆心穿过了,不太好看


有没有一种办法,让线不从圆心穿过


那么就先来分析一下:


image-20220914144550029


假设现在是从7移动到2


那么就需要连接C点和F点,只需要计算出C点和F点的坐标即可


先来分析现在的已知条件:



  • dx = end.x - start.x

  • dy = end.y - start.y

  • d = (dx平方 + dy平方) 开根号

  • 小圆半径 = smallRadius


那么就可以算出当前的偏移量:



  • offsetX = dx * (smallRadius / d)

  • offsetY = dy * (smallRadius / d)


知道偏移量,就可以算出C和F的坐标:


那么C的坐标为:



  • C.x = start.x + offsetX

  • C.y = start.y + offsetY


那么F的坐标为:



  • F.x = end.x + offsetX

  • F.y = end.y + offsetY


只要C和F的坐标之后


只需要通过path.moveTo() 移动到C的位置


通过path.lineTo() 移动到F的位置即可


@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.action) {
MotionEvent.ACTION_DOWN -> {
/// ...
}
MotionEvent.ACTION_MOVE -> {
val pointF = isContains(event.x, event.y)
pointF?.let {
// 将当前类型改变为按下类型
it.type = JiuGonGeUnLockView.Type.DOWN

// 这里会重复调用,所以需要判断是否包含,如果不包含才添加
if (!recordList.contains(it)) {
recordList.add(it)
if (recordList.size >= 2) {
// TODO 不穿过圆心
val start = recordList[recordList.size - 2]
val end = recordList[recordList.size - 1]

val d = PointF(start.x, start.y).distance(PointF(end.x, end.y))
val dx = (end.x - start.x)
val dy = (end.y - start.y)
val offsetX = dx * smallRadius / d
val offsetY = dy * smallRadius / d

val cX = start.x + offsetX
val cY = start.y + offsetY
path.moveTo(cX, cY)

val fX = end.x - offsetX
val fY = end.y - offsetY
path.lineTo(fX, fY)

// line
line.first.x = it.x + offsetX
line.first.y = it.y + offsetY
}
}
}

// 手指的位置
line.second.x = event.x
line.second.y = event.y
}

/// 隐藏UP代码
}

invalidate()
return true
}


// 计算两点之间的距离
fun PointF.distance(b: PointF): Float = let {
val a = this

// 这里 * 1.0 是为了转Double
val dx = b.x - a.x * 1.0
val dy = b.y - a.y * 1.0
return@let sqrt(dx.pow(2) + dy.pow(2)).toFloat()
}

当前的效果:


18478E736B00DAB45797EC8BC2164F9F

所有的效果基本就差不多了,接下来来比较密码


密码比较


思路分析:



  • 先将正确密码集合传过来,然后和输入的密码做比较

  • 首先先判断两个集合的长度

    • 如果长度不一样,那么密码肯定是不同的,直接标记为错误即可

    • 如果长度一样,只需要比较每一个值是否相同

      • 相同则输入成功,将正确结果回调回去

      • 有一个不相同,则输入失败,标记为错误即可






// 密码
open var password = listOf<Int>()

MotionEvent.ACTION_UP -> {
// 清空移动线
line.first.x = 0f
line.first.y = 0f
line.second.x = 0f
line.second.y = 0f


// 标记是否成功
val isSuccess =
// 先比较长度是否相同
if (recordList.size == password.size) {
val list = recordList.zip(password).filter {
// 通过判断每一个值
it.first.index == it.second
}.toList()

// 如果每一个值都相同,那么就成功
list.size == password.size
} else {
false
}

// 密码错误,将标记改变成成错误
if (!isSuccess) {
recordList.forEach {
it.type = JiuGonGeUnLockView.Type.ERROR
}
"输入失败" toast context
} else {
"输入成功" toast context
}

// 延迟1秒清空
postDelayed({
clear()
}, 1000)
}

23B8401108604115F972F00855280E1C

现在已经可以完成输入密码了,


但是状态还不对,我们希望连接线的颜色和圆的颜色一致,


当然我们可以这样:


override fun onDraw(canvas: Canvas) 
// paint.style = Paint.Style.FILL
paint.style = Paint.Style.STROKE
paint.strokeWidth = 4.dp

unLockPoints.forEach {
it.forEach { data ->

// 根据类型设置颜色
paint.color = getTypeColor(data.type)

// 绘制大圆
paint.alpha = (255 * 0.6).toInt()
canvas.drawCircle(data.x, data.y, bigRadius, paint)

// 绘制小圆
paint.alpha = 255
canvas.drawCircle(data.x, data.y, smallRadius, paint)

// 绘制连接线
canvas.drawPath(path, paint)

// 绘制移动线
if (line.first.x != 0f && line.second.x != 0f
) {
canvas.drawLine(
line.first.x,
line.first.y,
line.second.x,
line.second.y,
paint
)
}
}
}
}

但是我还是选择了通过一个全局变量,来记录当前的状态,然后给连接线和移动线设置颜色


代码很简单,就不展示了,直接看效果:


9D20C8CE4024A396D9AE75D7607F739E

到此时,效果就基本完成了,


但是,写完发现,代码真的太乱了,而且有很多设置的东西,


比如说:



  • 默认颜色

  • 移动颜色

  • 输入成功颜色

  • 输入失败颜色

  • 解锁的大小

    • 例如3,就是3 X 3 5就是5 X 5



  • 样式

    • 空心 or 实心




一般遇到这种情况我认为有2种方式



  • 自定义属性

  • 设计模式


自定义属性用的很多,这里我就通过Adapter模式来优化一下


先来定义规范


abstract class UnLockBaseAdapter {
// 设置宫格个数
// 例如输入3: 表示3*3
abstract fun getNumber(): Int

// 设置样式
abstract fun getStyle(): JiuGonGeUnLockView.Style

/*
* 作者:史大拿
* 创建时间: 9/14/22 10:24 AM
* TODO 画连接线时,是否穿过圆心
*/

open fun lineCenterCircle() = false

// 设置原始颜色
open fun getOriginColor(): Int = let {
return Color.parseColor("#D8D9D8")
}

// 设置按下颜色
open fun getDownColor(): Int = let {
return Color.parseColor("#3AD94E")
}

// 设置抬起颜色
open fun getUpColor(): Int = let {
return Color.parseColor("#57D900")
}

// 设置错误颜色
open fun getErrorColor(): Int = let {
return Color.parseColor("#D9251E")
}
}

实现:


class UnLockAdapter : UnLockBaseAdapter() {
override fun getNumber(): Int = 5

override fun getStyle(): JiuGonGeUnLockView.Style = JiuGonGeUnLockView.Style.STROKE

override fun getOriginColor(): Int {
return Color.YELLOW
}
}

读取数据:


open var adapter: UnLockBaseAdapter? = null

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)

if (adapter == null) {
throw AndroidRuntimeException("请设置Adapter")
}
adapter?.also {
NUMBER = it.getNumber()
ORIGIN_COLOR = it.getOriginColor()
DOWN_COLOR = it.getDownColor()
UP_COLOR = it.getUpColor()
ERROR_COLOR = it.getErrorColor()
}
}

来看看最终效果:


71F2901C6F6BE9DB19BBF98B95CE3FA0

思路参考自


完整代码


原创不易,您的点赞就是对我最大的帮助


其他自定义文章:



作者:史大拿
来源:juejin.cn/post/7143137578080796686
收起阅读 »

Android开发仿掘金Web端登录界面(Kotlin)

Android开发仿掘金Web端登录界面(Kotlin) 前言 各位大佬好,给大家分享一下用Android原生实现掘金Web端的登录界面效果,有哪些可以优化希望大佬们可以指正,那我们开始吧 最终效果图 前期准备 我们需要先把需要的资源给download下来,...
继续阅读 »

Android开发仿掘金Web端登录界面(Kotlin)


前言


各位大佬好,给大家分享一下用Android原生实现掘金Web端的登录界面效果,有哪些可以优化希望大佬们可以指正,那我们开始吧


最终效果图


LPDS_GIF_20220905_182520.gif


前期准备


我们需要先把需要的资源给download下来,我用Chrome来进行这一步



  • 开启Chrmoe的调试模式: 按F12开启或者在设置->更多工具->开发工具


1662367049960.jpg



  • 开是网络抓包:网络->图片


1662367135318.jpg


这样我们就看到了所需要的图片资料了,我们另存一下放入我们的项目


代码


配置Gradle



  • 我们来配置ViewBinding。在build.gradle中的android添加如下代码:


viewBinding {
enabled = true
}


  • 我们需要添加一些依赖


//glide库
implementation 'com.github.bumptech.glide:glide:4.13.0'
annotationProcessor 'com.github.bumptech.glide:compiler:4.13.0'

LoginDialog


我们创建一个LoginDialog.kt文件,并且继承与DialogFragment用于展示登录的UI,具体操作如下



  • dailog_login.xml


layout目录下创建dailog_login.xml文件,用于显示登录的布局,具体代码如下:


<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
//设置这个布局的高度是自适应的
android:layout_height="wrap_content">

//CardView来优化布局(可以快速设置圆角、阴影等操作)
<androidx.cardview.widget.CardView
android:layout_width="0dp"
android:layout_height="wrap_content"
//这里设置88dp是因为最上面的图片高度是96dp,我们这是88dp就可以实现完成重叠效果
android:layout_marginTop="88dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@+id/dialog_top_img">

//为了方便布局在CardView里面添加一个约束布局
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="16dp">

<ImageView
android:id="@+id/imageView3"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="@+id/textView2"
app:layout_constraintEnd_toEndOf="@+id/edit_user"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/ic_baseline_close_24" />

<TextView
android:id="@+id/textView2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="手机登录"
android:textColor="@color/black"
android:textSize="20sp"
android:textStyle="bold"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />

<EditText
android:id="@+id/edit_user"
android:layout_width="0dp"
android:layout_height="50dp"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp"
android:background="@drawable/bg_edit"
android:ems="11"
android:hint="请输入手机号码"
android:inputType="number"
android:maxLength="11"
android:paddingStart="100dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/textView2" />

<EditText
android:id="@+id/edit_pwd"
android:layout_width="0dp"
android:layout_height="50dp"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp"
android:background="@drawable/bg_edit"
android:ems="11"
android:hint="请输入密码"
android:inputType="number"
android:maxLength="4"
android:paddingStart="10dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/edit_user" />

<TextView
android:id="@+id/tv_code"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
android:text="获取验证码"
android:textColor="#007fff"
android:textSize="16sp"
app:layout_constraintBottom_toBottomOf="@+id/edit_pwd"
app:layout_constraintEnd_toEndOf="@+id/edit_pwd"
app:layout_constraintTop_toTopOf="@+id/edit_pwd" />

<LinearLayout
android:id="@+id/linearLayout"
android:layout_width="80dp"
android:layout_height="0dp"
android:orientation="horizontal"
app:layout_constraintBottom_toBottomOf="@+id/edit_user"
app:layout_constraintStart_toStartOf="@+id/edit_user"
app:layout_constraintTop_toTopOf="@+id/edit_user">

<TextView
android:id="@+id/textView5"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center"
android:text="+86"
android:textColor="#000000" />

<ImageView
android:id="@+id/imageView2"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_weight="1"
android:scaleType="center"
app:srcCompat="@drawable/ic_down" />
</LinearLayout>

<TextView
android:id="@+id/btn_login"
android:layout_width="0dp"
android:layout_height="50dp"
android:layout_marginTop="16dp"
android:background="@drawable/bg_btn"
android:gravity="center"
android:text="登录"
android:textColor="@color/white"
android:textSize="16sp"
app:layout_constraintEnd_toEndOf="@+id/edit_pwd"
app:layout_constraintStart_toStartOf="@+id/edit_pwd"
app:layout_constraintTop_toBottomOf="@+id/edit_pwd" />

<TextView
android:id="@+id/textView6"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="其他登录方式"
android:textColor="#007fff"
android:textSize="16sp"
app:layout_constraintStart_toStartOf="@+id/btn_login"
app:layout_constraintTop_toBottomOf="@+id/btn_login" />

<TextView
android:id="@+id/textView7"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="登录即表示同意"
android:textSize="16sp"
app:layout_constraintStart_toStartOf="@+id/btn_login"
app:layout_constraintTop_toBottomOf="@+id/textView6" />

<TextView
android:id="@+id/textView8"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:text="用户协议"
android:textColor="#007fff"
android:textSize="16sp"
app:layout_constraintStart_toEndOf="@+id/textView7"
app:layout_constraintTop_toTopOf="@+id/textView7" />

<TextView
android:id="@+id/textView10"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="2dp"
android:text="、"
android:textSize="16sp"
app:layout_constraintStart_toEndOf="@+id/textView8"
app:layout_constraintTop_toTopOf="@+id/textView8" />

<TextView
android:id="@+id/textView9"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="隐私政策"
android:textColor="#007fff"
android:textSize="16sp"
app:layout_constraintStart_toEndOf="@+id/textView10"
app:layout_constraintTop_toTopOf="@+id/textView7" />

</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.cardview.widget.CardView>

<ImageView
android:id="@+id/dialog_top_img"
android:layout_width="142dp"
android:layout_height="96dp"
android:elevation="2dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/ic_login_2" />

</androidx.constraintlayout.widget.ConstraintLayout>


  • bg_edit.xml


drawable目录下创建bg_edit.xml的资源文件,设置EditText的样式,代码如下:


<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
//不对焦的样式
<item android:state_window_focused="false"
android:drawable="@drawable/bg_edit_nofocused"/>

//对焦的样式
<item android:state_focused="true"
android:drawable="@drawable/bg_edit_focused" />

</selector>


  • bg_edit_nofocused


<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<corners android:radius="2px"/>
<solid android:color="@color/white"/>
<stroke android:color="#e4e6eb" android:width="1dp"/>
</shape>


  • bg_edit_focused


<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="@color/white"/>
<stroke android:color="#007fff" android:width="1dp"/>
</shape>


  • bg_btn.xml


drawable目录下创建bg_btn.xml的资源文件,设置TextView的样式,不用Button是因为设置background较为麻烦,代码如下


<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">

<solid android:color="#007fff"/>
<corners android:radius="2px"/>
</shape>


  • LoginDialo


LoginDialog.kt中代码具体如下:


class LoginDialog : DialogFragment() {
//使用viewBinding
lateinit var mBinding: DialogLoginBinding

override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
)
: View {
//创建布局
mBinding = DialogLoginBinding.inflate(layoutInflater)
return mBinding.root
}

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
//初始化Dialog的相关配置
initDialog()
}

/**
* 初始化dialog相关配置
*
*/

private fun initDialog() {
//设置Dialog的显示大小
setDialogSize()

//设置window的背景为透明色
dialog?.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))

//设置点击空白和返回键不消失
dialog?.setCanceledOnTouchOutside(false)

//设置dialog的动画
dialog?.window?.setWindowAnimations(R.style.dialog_base_anim)
}

/**
* 设置dialog的大小
*
*/

private fun setDialogSize(){
val window = dialog?.window
window?.let {

//获取屏幕信息
val wm = requireContext().getSystemService(Context.WINDOW_SERVICE) as? WindowManager
val display = wm?.defaultDisplay
val point = Point();
display?.getSize(point);


val layoutParams = it.attributes;

//设置宽度为屏幕的百分之90
layoutParams.width = (point.x * 0.9).toInt()
//设置高度为自适应
layoutParams.height = WindowManager.LayoutParams.WRAP_CONTENT
it.attributes = layoutParams
}
}
}

MainActivity



  1. 我们修改一下MainActivity,实现展示一个登录Button,点击后弹出登录界面,具体代码如下:


class MainActivity : AppCompatActivity() {

lateinit var mBinding: ActivityMainBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
mBinding = ActivityMainBinding.inflate(layoutInflater)
setContentView(mBinding.root)

mBinding.button.setOnClickListener {
LoginDialog().show(supportFragmentManager, "")
}
}
}

运行一下


这个时候我们的UI大致就完成了,我们运行看一下,是不是我们所有期望的那样


Screenshot_20220905_171401_com.juejin.login.jpg


登录逻辑


我们完成了UI相关的功能,接下来我们需要开始写,登录相关的逻辑了



  • 点击不同输入框显示不同UI


在web端中,当我们点击输入手机号和请输入密码时,最上面的UI是显示不同,我们先把这个一部分功能实现以下:


我们添加一个initView()方法,专门初始化View相关操作,具体代码如下:


private fun initView() {
//设置焦点变化监听
mBinding.editUser.onFocusChangeListener =
View.OnFocusChangeListener { v, hasFocus ->
//该控件获取了焦点
if(hasFocus){
//设置获取焦点后的UI
Glide.with(this).load(R.drawable.ic_login_2).into(mBinding.dialogTopImg)
}
}

mBinding.editPwd.onFocusChangeListener =
View.OnFocusChangeListener { v, hasFocus ->
//该控件获取了焦点
if(hasFocus){
//设置获取焦点后的UI
Glide.with(this).load(R.drawable.ic_login_1).into(mBinding.dialogTopImg)
}
}

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
//让输入框获取焦点
mBinding.editUser.requestFocus()
}
}

获取验证码


我们知道点击获取验证码会出现一个验证是否为人为操作,当操作完成后发送验证码,并且会有一个60s间隔,并且需要显示出实际秒数,我们使用Captcha库来完成验证,使用CountDownTimer来实现倒计时的效果


添加验证拼图



  • 添加倒计时


val timeDown = object : CountDownTimer(60 * 1000, 1000) {
override fun onTick(millisUntilFinished: Long) {
mBinding.tvCode.text = "${millisUntilFinished / 1000}s"
}

override fun onFinish() {
//设置验证码可点击
mBinding.tvCode.isEnabled = true
//恢复text
mBinding.tvCode.text = "获取验证码"
}

}


  • 添加依赖


implementation 'com.luozm.captcha:captcha:1.1.2'


  • 添加布局


<com.luozm.captcha.Captcha
android:id="@+id/capt_cha"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:elevation="2dp"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="@+id/cardView"
app:layout_constraintStart_toStartOf="@+id/cardView"
app:layout_constraintTop_toTopOf="@+id/cardView"
//随便找一个图片就行了
app:src="@drawable/ic_captcha" />


  • 添加事件监听


mBinding.captCha.setCaptchaListener(object : Captcha.CaptchaListener {
/**
* 验证通过回调
*
* @param time
* @return
*/

override fun onAccess(time: Long): String {
//设置验证码不可点击
mBinding.tvCode.isEnabled = false
//开始倒计时
timeDown.start()
//关闭图片验证
mBinding.captCha.visibility = View.GONE
return "验证通过,耗时" + time + "毫秒";
}

/**
* 验证失败回调
*
* @param failCount
* @return
*/

override fun onFailed(failCount: Int): String {
return "验证失败,已失败" + failCount + "次";
}

override fun onMaxFailed(): String {
Toast.makeText(
this@LoginDialog.requireContext(),
"验证超过次数,你的帐号被封锁",
Toast.LENGTH_SHORT
).show();
return "验证失败,帐号已封锁";
}

})


  • 点击发送验证码


mBinding.tvCode.setOnClickListener {
//显示图片验证
mBinding.captCha.visibility = View.VISIBLE
}

登录



  • 添加登录判断逻辑


我们还是在initView方法中添加代码:


mBinding.btnLogin.setOnClickListener {
//登录按钮不可交互
mBinding.btnLogin.isEnabled =false

//修改UI
mBinding.btnLogin.text = "登录中..."

//开始验证
//判断手机号格式是否正确,这里只做了长度的按断,其实可以用正则来判断,我这里知识简单判断
if(mBinding.editUser.text.toString().length < 11){
Toast.makeText(
this@LoginDialog.requireContext(),
"账号格式错误",
Toast.LENGTH_SHORT
).show();
//登录按钮可交互
mBinding.btnLogin.isEnabled =true
//修改UI
mBinding.btnLogin.text = "登录"
return@setOnClickListener
}
if(mBinding.editPwd.text.toString().length < 4){
Toast.makeText(
this@LoginDialog.requireContext(),
"验证码错误",
Toast.LENGTH_SHORT
).show();
//登录按钮可交互
mBinding.btnLogin.isEnabled =true
//修改UI
mBinding.btnLogin.text = "登录"
return@setOnClickListener
}

Toast.makeText(
this@LoginDialog.requireContext(),
"登录成功",
Toast.LENGTH_SHORT
).show();

dismiss()

}

总结


到这里我们模仿掘金Web端登录就成功了,如果想看源码在这里传送门


作者:zuoz
来源:juejin.cn/post/7139841541350588447
收起阅读 »

Android 隐私合规检测

目前应用市场的隐私合规检查越来越严格,各大手机厂商的检测标准也不一致,经常有这个平台过审了那个平台还有问题出现,按照工信部的要求,工信部隐私合规说明。隐私合规是个不可不重视的点。 我们通常遇到的主要问题: 在用户同意隐私协议之前,不能有收集用户隐私数据的行...
继续阅读 »

目前应用市场的隐私合规检查越来越严格,各大手机厂商的检测标准也不一致,经常有这个平台过审了那个平台还有问题出现,按照工信部的要求,工信部隐私合规说明。隐私合规是个不可不重视的点。


我们通常遇到的主要问题:



  • 在用户同意隐私协议之前,不能有收集用户隐私数据的行为。例如:在用户同意协议之前不能去获取 Android ID、Device ID、MAC 等隐私数据。




  • 在用户同意隐私协议之后,获取权限时必须要符合当前使用场景,例如:我们需要获取手机读写,相机权限,这种需要在真正的读写,打开相机等页面时才能去请求权限。




如上问题处理可分为两种:权限 和 隐私 


  • 权限 需要在对应页面即 app内获取权限时主动设置弹窗等方式给予app相应的权限


'如电话权限,定位权限,相机权限,浮窗权限,读写权限等。在每个申请危险权限前,都需要弹窗说明权限解释说明。'


  • 隐私 为app使用过程中与用户个人相关的个人信息


'如位置,Mac地址,设备id等。就Android端而言,多数隐私信息需要对应授权后才能获取,但目前仍存在部分隐私信息无需授权就可以拿到的'

如何检测


一、第三方检测

京数安扫描平台
国舜
网易云盾


二、静态检测



  • Lint 检查项目


    Lint用于检测静态代码和资源,找到其中不符合预定义规则的地方。可参考网易云隐私合规静态检查




  • 反编译查找对应方法


    反编译主要是为了找出第三方的一些不合规方法调用,但是比较麻烦,全局搜索很不方便




三、动态检测(开源)



  • 1、Xposed


    优点 :Xposed 是比较早的做hook的框架, Xposed框架可以在不修改APK文件的情况下影响程序运行(修改系统)的框架服务,基于它可以制作出许多功能强大的模块,且在功能不冲突的情况下同时运作。Android中一般存在两种hook:sdk hook和ndk hook。native hook的难点在于理解ELF文件与学习ELF文件,Java层Hook则需要了解虚拟机的特性与java上的反射使用。另外还存在全局hook,即结合sdk hook和ndk hook,xposed就是一种典型的全局hook框架。


    缺点:需要手机ROOT




  • 2、VirtualXposed


    优点 :VirtualXposed 是基于VirtualApp 和 epic 实现的,能在非ROOT环境下直接运行Xposed模块 (目前支持5.0~10.0)。其实VirtualXposed就是一个支持Xposed的虚拟机,我们把开发好的Xposed模块和对应需要hook的App安装上去就能实现hook功能。


    缺点:步骤相对麻烦,de.robv.android.xposed 的依赖需要翻墙。




  • 3、epic


    优点 :配置简单,属于运行时hook,说明在动态加载dex也能检测到,也是我目前再用的,可以自定义配置hook 对应的类和方法,并找出当前调用线程堆栈,直接定位到调用的方法。


    缺点:兼容问题,Android 11及以上只能支持 64位,不过这个不影响11以下的使用;只检测java类代码,native没有hook 。




  • 4、PrivacySentry


    接入相对复杂,基于自定义transform , 编译期注解+hook方案,第一个transform收集需要拦截的敏感函数,第二个transform替换敏感函数,运行期收集日志,同时支持游客模式。


    有java.util.zip.ZipException: duplicate entry: META-INF/INDEX.LIST 冲突风险。




  • 5、camille


    使用 python Frida 等工具命令,做hook 模块,手机需要Root,功能强大但相对复杂




  • 6、自定义Asm插件,做代码插入检测


    可以在class->dex时,对相应的类、调用方法,做检测。添加我们的拦截代码




四、epic落地


  • 我这里使用的时 epic 检测,直接依赖:


implementation 'me.weishu:epic:1.0.0'
implementation 'me.weishu.exposed:exposed-xposedapi:0.4.5'

主要核心是 DexposedBridge.findAndHookMethod 方法


//targetClass: 传入 需要hook 的类,如:TelephonyManager.class
//targetMethod:类对应的方法,如:getDeviceId
DexposedBridge.findAndHookMethod(targetClass, targetMethod, new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) throws Throwable {
super.beforeHookedMethod(param);

//被调用的类名
String className = param.method.getDeclaringClass().getName();
//被调用的函数名
String methodName = param.method.getName();
LogAction.log("检测到 " + className + " 被调用: methodName=" + methodName);
//这里可以搜集当前的线程信息,堆栈等,将调用关系打印出来,例如:

//Thread thread = Thread.currentThread();
//StringBuilder stringBuilder = new StringBuilder();
//获取线程信息
//String threadInfo = getThreadInfo(thread);
//stringBuilder.append(threadInfo);
// 返回表示此线程的堆栈转储的堆栈跟踪元素数组。
// 如果这个线程还没有启动,已经启动但还没有被系统计划运行,或者已经终止,这个方法将返回一个零长度的 数组。
//StackTraceElement[] stackTraceElements = thread.getStackTrace();
//String print = printToString2(stackTraceElements);
//stringBuilder.append("线程堆栈日志:").append(print);

//LogAction.log(stringBuilder);
}

@Override
protected void afterHookedMethod(MethodHookParam param) throws Throwable {
super.afterHookedMethod(param);

}
});

例如,我这里用了 leakcanary 做检测时会提示的


image.png


因为我对 android.app.ApplicationPackageManager 这个类做了检测,queryIntentActivities 方法被调用时即触发了beforeHookedMethod


五、集成优化处理



  • 我们可以自己定义一个module模块,单独处理合规检测,利用 debugImplementation 的方式集成,不会影响到线上




  • 可以使用 ContentProvider 做初始化入口,debugImplementation 集成进来即可,在 ContentProvider onCreate 的时候去 start启用 需要hook 的集合类。




  • 可以使用企业微信提供 API  Token,在收到 隐私限制方法被调用时,触发消息发送,方便测试和提示,不需要去看log日志。


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

乡愁

立秋了,你是否开始感到有一丝凉爽了呢?我还没有,关中平原的闷热已经让我生无可恋了,不过此刻故乡的天气甚是宜人,稻田里面的稻子再过一段时间应该就熟了吧,你是否会因为秋而感到一丝乡愁呢! 乡愁/书文 盛夏,路边的绿茵总让人有一丝的惬意,城市里绿化生长出来的木棉总...
继续阅读 »

立秋了,你是否开始感到有一丝凉爽了呢?我还没有,关中平原的闷热已经让我生无可恋了,不过此刻故乡的天气甚是宜人,稻田里面的稻子再过一段时间应该就熟了吧,你是否会因为秋而感到一丝乡愁呢!



乡愁/书文


盛夏,路边的绿茵总让人有一丝的惬意,城市里绿化生长出来的木棉总让人想起它二三月的木棉花,那样的繁花似锦,花开又花落。
花开有时,诚然我们都会淡忘,好比吾日三省吾身,今天你业绩够了么?意向客户呢?客户流失原因是什么?你来说说原因?谁会关心花开花落呢?现实的情况可容不得你沾花惹草,或者让你在池塘里摸鱼?写这些不是写给谁,也不是取悦别人或者迎合别人,纯粹写给自己,说真的闲暇之余好多时间净泡在短视频的世界里了,我想:除了短视频里你所看到的世界还有另外一个你自己导演的短视频。


短视频里面的大佬说着动不动月入上万是一件多么轻松的事。(根据国家统计局全国有6亿人民的月收入是1000)
我曾想所谓的大佬是不是都飘了,真实的世界是真的限制我贫穷的想象了么,其实不然,财富的本身是对物质的追求,当欲望达不到本身的需求时都会变本加厉,但当一个企业也为之生存而不断革新寻求人最终求生的欲望时,那这便叫财富。财富也可以指精神上的财富,心里有了归属才会有家,有了家才会有感情,有了感情才会有了乡愁。


我前些天悄悄把头像改成猫的头像,甚至动摇我发过的毒誓:自此我家再也不养猫
说到猫我些许年前我写的《猫》着实是摘抄了余光中老先生的嫌疑,我要想一想当时写的是不是这样:



小时候

母亲挑着扁担

一头是菜篮子里面有我们爱吃的酸菜豆米

另一头是菜篮子里面有小喵咪爱吃的猪肺肉

长大后

母亲仍然挑着扁担

一头是菜篮子里的秤砣

另一头还是猪肺肉

提到酸菜豆米

至此便有了乡愁



说到吃的我又来劲了,但这不包括我们老家的荞凉粉、水晶凉粉、肠旺面、竹荪、酸菜豆米这些,当然什么北京的刷羊肉,厦门的沙茶面,兰州的拉面,长沙的臭豆腐,山西的刀削面,广东的肠粉,陕西的肉夹馍,东北的猪肉炖粉条,这些我都吃过,除了台湾的担仔面…


此时脑海里瞅见一位歌手在台上说着:哎呦,不错哦!
这让我想起儿时的儿歌:天上的星星不说话,地上的娃娃想妈妈…
说到歌,我着实喜欢蔡琴的《恰是你的温柔》和《被遗忘的时光》跟李宗盛的《爱的代价》。
“走吧!走吧!人总要学着自己长到…”曾经何时,我是那么的颠沛流离呀,涛声依旧,海风冷飕飕把我脸颊吹得发福的望着年三十的夜景,自己第一次在岛上过年,第一次在外有了乡愁。


冒昧问一下,我今年刚回去的家叫不叫乡愁呢,去的时候老家还在下着雪,来的时候已是木棉花盛开的季节


驿站/雷


同事去我的家乡出差,偶然遇到一个路牌,上面写着:“离乡的游子不会明白,当他离开的那一刻,故乡就成了驿站”。同事将这段话发给了我,那一瞬间,我不知道自己在想什么,整个人就像存在一种空明的状态中,我自己也感觉不到我的的存在,我好像已经不是我了!



“起床喽!太阳晒屁股,唱哈不起勒,赶紧起来吃饭了”。




“嬢,你着菜唱卖嘛”“咦!唱会着贵嘛,便宜的,天天来你家买起勒”。




“老板,来份烙锅,再来碗荞凉粉,记到加皮蛋哦”。




“不晓得你着大勒人了,一日三餐都不会按时吃,不按时吃对身体不好勒”。



看到那句话后,这些发生在家乡的画面总是在脑海中浮现,父母的叮嘱、生活的琐碎、好友的相聚,至今让人一直想去捕捉、想去寻找,想去看清那些细节,却又是那么触手可及而又模糊不清。于是只能强忍内心的好奇和冲动,默默地行走在不属于自己的道路上。


乡愁是什么?是飘向远方的蒲公英无法回到母亲身边的无奈,风往哪吹,它便往哪走!是茫茫戈壁中胡杨找不到归属的感慨,光往哪散,它便往哪长,是漂泊他乡的游子最简单、纯粹的情绪,如一缕清水绵绵不绝!母校后上的白马塔,乡村茶山顶的瞭望台,小城中心处的织金阁以及歪头山上的那一片松树林,他们共同眺望着那走在高速上逐渐拉长的影子,呢喃着:“过年你要回家吗?”


离乡一年有余,中途虽回过两次家,但也如同匆匆过客一般,草草和亲人交谈两句,同好友小酌两杯,还不待仔细观赏一下家乡的风景,又急忙踏上离开的路途,去为生活、为理想而奔波,来不及去细细体会回家的那种温馨和愉悦。或许正如那句话一样故乡已然成为了游子的驿站,它仅仅只是一个游子小憩片刻的场所,只能为短暂归乡的人们提供临时的心灵庇护,走在车水马龙的闹市中,眺望着络绎不绝的人群,自己是那么的显眼,那么的格格不入,仿佛这不是自己的家。


漫街的落叶无不在诉说着,风已经有了秋的味道,而我却只能低头细语:“我也不知道过年回不回家”。


同一片星空下,此时的他乡,寡情薄意的乌云将游子唯一寄托思念的月光遮挡,而故乡也是否如此呢?离乡的游子明白了,但此时他已经在异乡了。


时光的表/小四


”年轻的游子不会明白,在离开的那一刻,故乡永远成为了驿站“,他坐的一列列动车无数次路过故乡的田野,一次次飞机穿过故乡顶上的大气层,无数次的故乡金曲掠过耳旁,泪花一次次地投射出门前的几颗杉树,时针一次次地滴答着心灵!


父亲年轻的时候是个修表的工匠,不过从我记事起,他那堆修表工具和一堆破烂的手表已经在一个破旧的麻袋里面沾满了灰尘,于是就过上了背井离乡的打工生活,四处奔波,只求一家人的生活,他能够修复时间,却修复不了满脸的皱纹,父亲可能现在还会记得他年少时在集市上修表的时光吧,就像现在的我时不时的会想起那个夕阳落山的午后,父亲在屋檐下抽着烟,母亲带着眼镜织着毛线拖鞋,我拿着那把破木吉他唱着许巍的《故乡》。


十四岁时,情窦初开,喜欢上一个女孩子,在懵懂无知的年纪里,在百度上搜了表白话语,学会了表白,于是有了一段两小无猜的时光,于是你满眼都是她,送她糖,送她巧克力,也会舞文弄墨的送她画,送她诗,后来,你选了两块”廉价“表,她戴一块,你也戴了一块,她的表秒针比你慢了一秒,你的比她快了一秒,你都会将其调节到零误差,不过,就算两块表的时间零误差,懵懂的开始也会以懵懂结束,过了很多年,你们都没有怎么见过面,你们曾经一起跑步的那片区域已经立起一座又一座亭阁,夜夜灯火通明,游人不断,又过了很多年,你结婚生子,我还在求学,每次回到故乡,我都会去那边走一走,看一看,我们作为独立的个体在一直在各自的世界里生活,寻找,看到你那么开心,我为你开心,我想告诉你,你的选择是对的,我的选择也是对的,你最喜欢听的那首《羞答答的玫瑰静悄悄的开》,也在慢慢的绽放它留给你的情怀!


那一年,你刚进入大学不久,假期你回到了家中,你和好友二人吃着烧烤,喝着高度白酒,他问你学习如何,你问他生活怎么样,一直聊到夜深人静,你们俩醉醺醺的,一个搀扶着一个,在飘着毛毛雨的街道走着,你看见他手上依然戴着高二时你送他的那块表,只不过你的那块在高中毕业就丢了,你只感觉心里一酸,他说这两年我都戴着这块表呢,此后的四五年时光里,我们的见面次数越来越少,他辗转了无数的地方,换了无数的职业,我也为了工作和生活四处行走,你给我过你在北戴河的列车上无比的绝望,在浙江的苦楚生活,我知道你现在的压力很大,日子很不好过,但是我不会去安慰你,你既然选择出来了,那么就应该承担一切后果。


时光稍纵即逝,父亲的修表生涯早在二十世纪就结束了,懵懂年龄慢一秒,快一秒的美好时光早已褪色,在北戴河的绝望,杭州的等待依然要继续,我们手里都有一块表,戴着的时候是有形的表,不戴的时候是无形的表,不论秒针是否跳动,时间都在跳动,这块表是时

作者:刘牌
来源:juejin.cn/post/7129129354096803848
间,更是滴答的乡愁!

收起阅读 »

Android进程间大数据通信:LocalSocket

前言 说起Android进行间通信,大家第一时间会想到AIDL,但是由于Binder机制的限制,AIDL无法传输超大数据。 那么我们如何在进程间传输大数据呢? Android中给我们提供了另外一个机制:LocalSocket 它会在本地创建一个socket通道...
继续阅读 »

前言


说起Android进行间通信,大家第一时间会想到AIDL,但是由于Binder机制的限制,AIDL无法传输超大数据。


那么我们如何在进程间传输大数据呢?


Android中给我们提供了另外一个机制:LocalSocket


它会在本地创建一个socket通道来进行数据传输。


那么它怎么使用?


首先我们需要两个应用:客户端和服务端


服务端初始化


override fun run() {
server = LocalServerSocket("xxxx")
remoteSocket = server?.accept()
...
}

先创建一个LocalServerSocket服务,参数是服务名,注意这个服务名需要唯一,这是两端连接的依据。


然后调用accept函数进行等待客户端连接,这个函数是block线程的,所以例子中另起线程。


当客户端发起连接后,accept就会返回LocalSocket对象,然后就可以进行传输数据了。


客户端初始化


var localSocket = LocalSocket()
localSocket.connect(LocalSocketAddress("xxxx"))

首先创建一个LocalSocket对象


然后创建一个LocalSocketAddress对象,参数是服务名


然后调用connect函数连接到该服务即可。就可以使用这个socket传输数据了。


数据传输


两端的socket对象是一个类,所以两端的发送和接受代码逻辑一致。


通过localSocket.inputStreamlocalSocket.outputStream可以获取到输入输出流,通过对流的读写进行数据传输。


注意,读写流的时候一定要新开线程处理。


因为socket是双向的,所以两端都可以进行收发,即读写


发送数据


var pool = Executors.newSingleThreadExecutor()
var runnable = Runnable {
try {
var out = xxxxSocket.outputStream
out.write(data)
out.flush()
} catch (e: Throwable) {
Log.e("xxx", "xxx", e)
}
}
pool.execute(runnable)

发送数据是主动动作,每次发送都需要另开线程,所以如果是多次,我们需要使用一个线程池来进行管理


如果需要多次发送数据,可以将其进行封装成一个函数


接收数据


接收数据实际上是进行while循环,循环进行读取数据,这个最好在连接成功后就开始,比如客户端


localSocket.connect(LocalSocketAddress("xxx"))
var runnable = Runnable {
while (localSocket.isConnected){
var input = localSocket.inputStream
input.read(data)
...
}
}
Thread(runnable).start()

接收数据实际上是一个while循环不停的进行读取,未读到数据就继续循环,读到数据就进行处理再循环,所以这里只另开一个线程即可,不需要线程池。


传输复杂数据


上面只是简单事例,无法传输复杂数据,如果要传输复杂数据,就需要使用DataInputStreamDataOutputStream


首先需要定义一套协议。


比如定义一个简单的协议:传输的数据分两部分,第一部分是一个int值,表示后面byte数据的长度;第二部分就是byte数据。这样就知道如何进行读写


写数据


var pool = Executors.newSingleThreadExecutor()
var out = DataOutputStream(xxxSocket.outputStream)
var runnable = Runnable {
try {
out.writeInt(data.size)
out.write(data)
out.flush()
} catch (e: Throwable) {
Log.e("xxx", "xxx", e)
}
}
pool.execute(runnable)

读数据


var runnable = Runnable {
var input = DataInputStream(xxxSocket.inputStream)
var outArray = ByteArrayOutputStream()
while (true) {
outArray.reset()
var length = input.readInt()
if(length > 0) {
var buffer = ByteArray(length)
input.read(buffer)
...
}
}

}
Thread(runnable).start()

这样就可以传输复杂数据,不会导致数据错乱。


传输超大数据


上面虽然可以传输复杂数据,但是当我们的数据过大的时候,也会出现问题。


比如传输图片或视频,假设byte数据长度达到1228800,这时我们通过


var buffer = ByteArray(1228800)
input.read(buffer)

无法读取到所有数据,只能读到一部分。而且会造成后面数据的混乱,因为读取位置错位了。


读取的长度大约是65535个字节,这是因为TCP被IP包包着,也会有包大小限制65535。


但是注意!写数据的时候如果数据过大就会自动进行分包,但是读数据的时候如果一次读取貌似无法跨包,这样就导致了上面的结果,只能读一个包,后面的就错乱了。


那么这种超大数据该如何传输呢,我们用循环将其一点点写入,也一点点读出,并根据结果不断的修正偏移。代码:


写入


var pool = Executors.newSingleThreadExecutor()
var out = DataOutputStream(xxxSocket.outputStream)
var runnable = Runnable {
try {
out.writeInt(data.size)
var offset = 0
while ((offset + 1024) <= data.size) {
out.write(data, offset, 1024)
offset += 1024
}
out.write(data, offset, data.size - offset)
out.flush()
} catch (e: Throwable) {
Log.e("xxxx", "xxxx", e)
}

}

pool.execute(runnable)

读取


var input = DataInputStream(xxxSocket.inputStream)
var runnable = Runnable {
var outArray = ByteArrayOutputStream()
while (true) {
outArray.reset()
var length = input.readInt()
if(length > 0) {
var buffer = ByteArray(1024)
var total = 0
while (total + 1024 <= length) {
var count = input.read(buffer)
outArray.write(buffer, 0, count)
total += count
}
var buffer2 = ByteArray(length - total)
input.read(buffer2)
outArray.write(buffer2)
var result = outArray.toByteArray()
...
}
}
}
Thread(runnable).start()

这样可以避免因为分包而导致读取的长度不匹配的问题


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

用ChatGPT提高开发效率(Andoid)

一、前言 我们问了10个问题,开发的、生活中有疑问的,ChartGPT都给了我们答案。这些答案怎么样,请往下看吧。 ChatGPT-1、写一个车牌号的正则表达式 ChatGPT-2、写一个中国大陆车牌号的正则表达式 ChatGPT-3、用kotlin写一个车...
继续阅读 »

一、前言


我们问了10个问题,开发的、生活中有疑问的,ChartGPT都给了我们答案。这些答案怎么样,请往下看吧。



  • ChatGPT-1、写一个车牌号的正则表达式

  • ChatGPT-2、写一个中国大陆车牌号的正则表达式

  • ChatGPT-3、用kotlin写一个车牌号的校验函数

  • ChatGPT-4、用Kotlin写一个String装换成金额的扩展函数

  • ChatGPT-5、用Kotlin写一个String装换成人民币的扩展函数

  • ChatGPT-6、用Kotlin写一个String转换成人民币的扩展函数

  • ChatGPT-7、用Kotlin写一个将TextView的文本内容复制到粘贴板的扩展函数

  • ChatGPT-8、美食探店怎么做?

  • ChatGPT-9、台湾卤肉饭?

  • ChatGPT-10、牛皮弃面馆(柏庐南路店)


二、ChatGPT使用初体验


ChatGPT-1、写一个车牌号的正则表达式
企业微信截图_16796251027919.png


ChatGPT-2、写一个中国大陆车牌号的正则表达式
企业微信截图_1679625500776.png
^[京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼][A-HJ-NP-Z](?!DD)[A-HJ-NP-Z0-9]{4}[A-HJ-NP-Z0-9挂学警港澳]$


ChatGPT-3、用kotlin写一个车牌号的校验函数
企业微信截图_16796251209911.png


三、ChatGPT PK 5年+年程序员


ChatGPT-4、用Kotlin写一个String装换成金额的扩展函数
企业微信截图_16796251209911.png


ChatGPT-5、用Kotlin写一个String装换成人民币的扩展函数
企业微信截图_16796251209911.png


ChatGPT-6、用Kotlin写一个String转换成人民币的扩展函数
1111.png
PK
image2023-3-24_11-2-10.png


ChatGPT-7、用Kotlin写一个将TextView的文本内容复制到粘贴板的扩展函数
image2023-3-24_11-2-10.png
PK
image2023-3-24_11-3-0.png


四、总结


ChatGPT-1、写一个车牌号的正则表达式

ChatGPT-2、写一个中国大陆车牌号的正则表达式

ChatGPT-3、用kotlin写一个车牌号的校验函数


以上问题,明确的告诉了我们答案,并且配上了讲解和使用方法。


极大的减少了我们自己写正则的错误概率,或者网上查,答案质量参差不齐的筛选验证的烦恼。


对这种重复劳动、通用的功能函数,ChatGPT很好用


ChatGPT-4、用Kotlin写一个String装换成金额的扩展函数

ChatGPT-5、用Kotlin写一个String装换成人民币的扩展函数

ChatGPT-6、用Kotlin写一个String转换成人民币的扩展函数

ChatGPT-7、用Kotlin写一个将TextView的文本内容复制到粘贴板的扩展函数


以上问题,实现了在开发过程中实际的问题,直接Copy就可以使用


请注意我的问题,用Kotlin写一个String转换成人民币的扩展函数,只要你问题问的明确,ChatGPT就会给你想要的答案


ChatGPT-4、ChatGPT-5转换写错了,写成了装换,ChatGPT理解了我的意思,并且他自我纠正了


ChatGPT-6、ChatGPT-7,ChatGPT与5年+程序员的PK,功能都实现了,实现思路基本相同。但是5+程序员写的更简洁,自由 (程序员的个人素质了,Lewis对个人要求标准较高,这就是通往大佬的之路)。


你在通往IT大佬的路上,不要轻视ChatGPT哦,我们使用的ChatGPT是通用版,训练出来的模型是面向所有用户的。


一个假设,如果使用Githut上所有的代码训练ChatGPT,训练出来的模型还会比不过5年的程序员吗?Githut+ChatGPT好像都为微软的,这个假设可能已经在实验室阶段了。


以上假设已经有了,GitHub Copilot X GitHub + GPT-4联手的产品


五、ChatGPT最后的胡说八道


ChatGPT-8、美食探店怎么做?
企业微信截图_16796255651978.png


ChatGPT-9、台湾卤肉饭?
企业微信截图_16796256304717.png


ChatGPT-10、牛皮弃面馆(柏庐南路店)
企业微信截图_1679625678718.png


ChatGPT-8、美食探店怎么做?

ChatGPT-9、台湾卤肉饭?

ChatGPT-10、牛皮弃面馆(柏庐南路店)


以上问题,初一看ChatGPT回答的很专业,以下详细说一下


ChatGPT-8、ChatGPT-9,很多人都有疑问的问题,网上资料很多,回答的很专业,也很有条理,挺好。作为一个技术就不都说了,可以看看这个「打不过,就加入」,我和ChatGPT的故事


ChatGPT-10,昆山一家小店,没有名气,主打台湾牛肉面和台湾小吃,ChatGPT就开始胡说八道了,但是显得很专业,他谈的模板就是按照逻辑去介绍一家店。如果你问他一家有名的店或者连锁店(例如:海底捞),他会回答的应该很专业。


其实还有很多疑问的,移动开发未来前景怎么样?ETH今天会不会大跌,短线做空可以吗?,但是毕竟今天周五了,让自己过一个愉快的周末吧^_^。


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

面试官:判断图是否有环

web
面试官让我写一个判断图是否有环,我没写出来,心想又是“面试造火箭,入职拧螺丝”。我把面试官pass了。没想到开发中真的遇到了判断有向图是否有环。 图是一种常见的数据结构,分为有向图和无向图。图是由边和节点组成的。 在前端开发中,接触到图的场景不算多。常见的有流...
继续阅读 »

  • 面试官让我写一个判断图是否有环,我没写出来,心想又是“面试造火箭,入职拧螺丝”。我把面试官pass了。没想到开发中真的遇到了判断有向图是否有环。

  • 图是一种常见的数据结构,分为有向图和无向图。图是由边和节点组成的。

  • 在前端开发中,接触到图的场景不算多。常见的有流程、图形可视化等场景。

  • 我们在配置题目流程时遇到了需要判断图是否有环的需求。


背景



  • 简单介绍需求,通过可视化流程配置答题流程,题目与题目之间用线连接,箭头的方向代表下一个题目。回答完当前题目,根据不同的条件,跳到下一题;如果题目流程中有循环,会导致答题流程无法结束,所以需要校验题目的流程中不能有循环。

  • 下面的是有循环,不符合条件


image.png



  • 下面的是无循环,符合条件


image.png
image.png
image.png
image.png


技术方案



  • 根据需求,我们把题目的流程配置抽象成有向图,题目是节点,题目之间的连线是边。

  • 需求里的有无循环,最终可以转换成图是否有环的问题。从图的某个节点作为起点,根据边的方向出发跳到下一个节点,最终是否回到起点。如果回到起点,就是有循环、有环,否则是无循环、无环。

  • 去除题目和各种条件等无关的结构,数据结构如下。


//边
export interface Edge {
id: string;
source: {
cell: string; //这条边的起点的id
[x: string]: any;
};
target: {
cell: string; //这条边的终点的id
[x: string]: any;
};
data: {
type: 'EDGE',
[x: string]: any;
}
[x: string]: any;
};
//节点
export interface Node {
id: string;
data: {
type: 'NODE';
name: string;
[x: string]: any;
};
[x: string]: any;
};
export type Data = Node | Edge;


  • 测试数据如下


const data: Data[] = [
{
id: '1',
data: {
type: 'NODE',
name: '节点1'
}
},
{
id: '2',
data: {
type: 'NODE',
name: '节点2'
}
},
{
id: '3',
data: {
type: 'NODE',
name: '节点3'
}
},
{
id: '4',
source: {
cell: '1'
},
target: {
cell: '2'
},
data: {
type: 'EDGE'
}
},
{
id: '5',
source: {
cell: '1'
},
target: {
cell: '3'
},
data: {
type: 'EDGE'
}
}
];


  • 根据数据结构和测试数据data:Data[],分为以下几个步骤:

    1. 获得边的集合和节点的集合。

    2. 根据边的集合和节点的集合,获得每个节点的有向邻居节点的集合。即以每个节点的为起点,通过边连接的下一个节点的集合。例如测试数据节点1,通过边id4和边id5,可以连接节点2节点3,所以节点1的邻居节点是节点2节点3,而节点2节点3无有向邻居节点。

    3. 最后根据有向邻居节点的集合,判断是否有环。




具体实现



  • 获得边的集合和节点的集合


const edges: Map<string, Edge> = new Map(), nodes: Map<string, Node> = new Map();
const idMapTargetNodes: Map<string, Node[]> = new Map();
const initGraph = () => {
for (const item of data) {
const { id } = item;
if (item.data.type === 'EDGE') {
edges.set(id, item as Edge);
} else {
nodes.set(id, item as Node);
}
}
};


  • 获取有向邻居节点的集合,这里的集合,可以优化成id。我为了方便处理,存储了节点


const idMapTargetNodes: Map<string, Node[]> = new Map();
const initTargetNodes = () => {
for (const [id, edge] of edges) {
const { source, target } = edge;
const sourceId = source.cell, targetId = target.cell;
if (nodes.has(sourceId) && nodes.has(targetId)) { //防止有空的边,即边的起点和终点不在节点的集合里
const targetNodes = idMapTargetNodes.get(sourceId);
if (Array.isArray(targetNodes)) {
targetNodes.push(nodes.get(targetId) as Node);
} else {
idMapTargetNodes.set(sourceId, [nodes.get(targetId) as Node]);
}
}
}
};


  • 最后判断是否有环,有两种方式:递归和循环。都是深度优先遍历。execute是遍历所有节点,hasCycle是把图的某个节点做为起点,判断是否有环。如果以所有节点为起点,都没有环,说明这个图没有环。

    1. 递归。hasCycle判断当前节点是否有环;checked是做优化,防止某些节点多次检查,回溯阶段,把当前节点加入checkedvisited记录当前执行的hasCycle里是否访问过,如果访问过,就是有环。需要注意的是,每次执行hasCycle时,visited用的是一个变量,所以在回溯阶段需要把当前节点从visited里删除。


    const checked: Set<string> = new Set();
    const hasCycle = (node: Node, visited: Set<Node>) => {
    if (checked.has(node.id)) return false;
    if (visited.has(node)) return true;
    visited.add(node);
    const { id } = node;
    const targetNodes = idMapTargetNodes.get(id);
    if (Array.isArray(targetNodes)) {
    for (const item of targetNodes) {
    if (hasCycle(item, visited)) return true;
    }
    }
    checked.add(node.id);
    visited.delete(node);
    return false;
    };
    const execute = () => {
    const visited: Set<Node> = new Set();
    for (const [id, node] of nodes) {
    if (hasCycle(node, visited)) return true;
    checked.add(id);
    }
    return false;
    };


    1. 循环。checked和递归时,作用一样,这里不做说明。visited是用来判断当前的节点是否遍历过,如果遍历过,就是有环。用循环实现深度优先遍历时,需要用来存储当前链路上的节点,即当前节点已经后代节点。并且从里面获取最后一个节点,作为当前遍历的节点。如果当前节点有向邻居节点不为空,就把有向邻居节点的最后一个节点拿出来压栈;如果有向邻居节点为空,就把当前的节点出栈。在压栈时,如果当前节点在visited里,就说明有环,如果没有就要把这个节点加入到visited。在出栈时,把当前节点从visited里删除掉,因为如果不删掉,当一个节点的多个邻居节点最终指向同一个节点时,会判断为有环。


    const checked: Set<string> = new Set();
    const hasCycle = (node: Node) => {
    const { id } = node;
    if (checked.has(id)) return false;
    const stack = [id];
    const visited: Set<string> = new Set();
    visited.add(id);
    while (stack.length > 0) {
    const lastId = stack[stack.length - 1];
    const targetNodes = idMapTargetNodes.get(lastId) || [];
    if (targetNodes.length > 0) {
    const { id } = targetNodes.pop() as Node;
    if (visited.has(id)) return true;
    stack.push(id);
    visited.add(id);
    } else {
    stack.pop();
    visited.delete(lastId);
    }
    }
    return false;
    };
    const execute = () => {
    for (const [id, node] of nodes) {
    if (hasCycle(node)) return true;
    checked.add(id);
    }
    return false;
    };



总结



  • 要掌握常见的数据结构与算法,本例中用到了图、深度优先遍历。


源码



作者:PlayerWho
来源:juejin.cn/post/7213945427853443131
收起阅读 »

给自己编写一个批量填写日报的工具

web
背景 公司要求我们每天填写工时,每天的时间都花在了哪些地方,干了什么。平时没顾得上填,欠下了一屁股工时债。收到邮件催填通知后,发现要补两个月的工时,填了一会儿,感觉变化的只是日期和工作内容,其它的内容项都是固定内容。一天一天填着实费劲,于是决定写一个填写日报的...
继续阅读 »

背景


公司要求我们每天填写工时,每天的时间都花在了哪些地方,干了什么。平时没顾得上填,欠下了一屁股工时债。收到邮件催填通知后,发现要补两个月的工时,填了一会儿,感觉变化的只是日期和工作内容,其它的内容项都是固定内容。一天一天填着实费劲,于是决定写一个填写日报的小工具,只要在js文件中,补充一下每天的工作内容,然后执行node命令,批量完成工时的填写。


思路



  1. 先根据设置的起始结束时间,查询一下当月有多少个工作日,要补多少天的工时。要排除当月每周周末,法定节假日的日期,加上调休补班的日期。

  2. 根据计算出来的需要补充工时的天数,编辑好要补填的工作内容条数,然后批量发送网络请求,完成工时的填写。


工作日查询实现


发现了一个叫蛙蛙工具的网站,免费提供接口给第三方使用,可以用来查询工作日。每分钟限制查10次。下图是抓取的响应数据:重点说一下要用到的weekend_date_listholiday_date_list字段;



  • weekend_date_list 周末日期

  • holiday_date_list 法定节假日


只要排除这两个数组中的日期,剩下的就是查询日期时间段工作日。


image.png


思路有了,来看看实现。首先要写好发送查询请求的逻辑,要知道请求地址,请求参数,请求参数格式,响应数据内容。其次,拿到响应结果后,生成一个从开始日期到结束日期,格式为YYYY-MM-DD的数组,从这个数组中剔除周末和法定节假日,剩余的日期就是工作日,知道工作日的天数后,就知道要写几天的工作日报。代码如下:


import axios from "axios";
import dayjs from "dayjs";
import { startDate,endDate } from "./config.js";


// 查询从本月的工作日
export const queryWorkingDay = () => {
return new Promise((resolve, reject) => {
const url = "https://www.iamwawa.cn/home/workingday/ajax";
const params = {
start_date: startDate.format("YYYY-MM-DD"),
end_date: endDate.format("YYYY-MM-DD"),
};

axios
.post(url, params, { headers: { "Content-Type": "application/x-www-form-urlencoded" } })
.then(({ data: res }) => {
const { status, data, info } = res;
const {
// 平常的周末
weekend_date_list = [],
// 法定节假日
holiday_date_list = [],
// 工作日天数
working_date_count,
} = data;

// console.log(data);

// 生成设置的当月起始结束日期数组
const dayOfMonth = genNumArr(startDate.date(), endDate.date()).map((day) =>
dayjs().date(day).format("YYYY-MM-DD")
);

// 需要排除的法定节假日和周末日期
const excludeDays = [
weekend_date_list.map((item) => item.date),
holiday_date_list.map((item) => item.date),
].flat();

// 工作日
const workDays = dayOfMonth.filter((day) => !excludeDays.includes(day));

// console.log(status,data,info);
console.log(`本月你需要补充${working_date_count}天日报`);
console.log(`需要填写的日期:`);
workDays.forEach((day) => {
console.log(day);
});

console.log(`需要排除的日期:`);
excludeDays.forEach((day) => {
console.log(day);
});

resolve(workDays);
});
});
};

// 生成连续数字数组
function genNumArr(start, end) {
return Array.from(new Array(end + 1).keys()).slice(start);
}


提交工时实现


先登录填报工时网站, 手动填写一条,在调试模式下查看一下请求地址和请求参数。
1679728451342.png
请求地址我就不贴出来了,这里只提供思路,请求数据为:


{
"workDate": "2023-03-14",
"tapdId": null,
"groupId": 12,
"projectId": 159,
"lineId": 2,
"taskId": 16,
"workContent": "xxxxxx",
"workHours": 8
}

如法炮制查询工作日的方法,发起工时提交请求,结果吃了闭门羹。提示没有权限。


image.png


后面经过排查,发现网络请求的请求头,需要带一个authorization的参数,服务器根据这个参数判断有没有提交权限。这个参数你必须登录原网站才能拿到,把这个参数复制出来,配置到代码中,再发请求,这次很顺利的提交了。


image.png


提交数据跑通之后,要实现批量提交数据就很Easy了,循环调用提交单条数据的接口就可以了。有个细节需要注意一下,提交请求太快,服务器会返回错误,所以每个请求之间加了一个500ms的延时。提交工时的代码如下:


import axios from "axios";
import { queryWorkingDay } from "./queryDay.js";
import { authorization, workContentList } from "./config.js";

// 提交每月的工时
export const submitMonthWorkHour = async () => {
const workDays = await queryWorkingDay();

for (let index = 0, len = workContentList.length; index < len; index++) {
await submitEachDayData(workDays[index], workContentList[index]);
}
};

/**
* 提交每天的工时数据
* @param {*} workDate 工作日期
* @param {*} workContent 工作内容
*/

const submitEachDayData = (workDate, workContent) => {
return new Promise((resolve, reject) => {
const url = "https://xxx/xxx",
authorization,
"Content-Type": "application/json",
};

const params = {
workDate,
workContent,
tapdId: null,
groupId: 12,
projectId: 159,
lineId: 2,
taskId: 16,
workHours: 8,
};

setTimeout(() => {
axios
.post(url, params, { headers })
.then(({ data }) => {
const { ret, retdata, retmsg } = data;
// if (ret === 0) {
console.log(`${workDate}--${retmsg}`);
resolve("ok");
// }
})
.catch((err) => reject(err));
}, 500);
});
};

主流程实现


在package.json中配置两条指令,一条用于查询设置的起始结束时间有多少个工作日,需要补充多少天的工作日报,接着在上面的submitMonthWorkHour方法中,手动编辑,给工作内容列表workContentList填充数据,一条数据对应一天的工作日报。填写完之后,执行提交命令。


{
"license":"MIT",
"scripts": {
"query": "node main.js query",
"submit": "node main.js submit"
},
"dependencies": {
"axios": "^1.3.4",
"dayjs": "^1.11.7"
},
"type": "module",
"devDependencies": {}
}


顺便说一下,node v9+版本,若要使用import/export语法, 需要在package.json中指定 "type": "module"


在主函数中, 根据不同的指令执行不同的操作。实际使用时, 肯定是要先调用yarn query查询补充多少天日报才行。


import { queryWorkingDay } from "./queryDay.js";
import { submitMonthWorkHour } from "./submitData.js";

main();
// 主流程
function main() {
const argv = process.argv;
// 先查询需要补充多少天日报
if (argv.includes("query")) {
queryWorkingDay();
} else if (argv.includes("submit")) {
submitMonthWorkHour();
} else {
console.log('指令错误');
process.exit(1);
}
}


把配置数据放到config.js中, 这里要说一下dayjs().date()dayjs().daysInMonth(), 它们的执行结果都是一个数字,代表的含义是这个月的日期,默认开始时间是当天日期,结束时间是月底日期。可手动修改。


import dayjs from "dayjs";
// 设置查询工作日的开始时间
export const startDate = dayjs().set("date", dayjs().date());
export const endDate = dayjs().set("date", dayjs().daysInMonth());
// 每次先登录一下填报工时的网站,把http请求头中的authorization复制出来
export const authorization = "";
// 手动填写需要补充的工时
export const workContentList = [""];

结语


至此,批量提交日报的小工具就开发完了。爱因斯坦说, 比知识更重要的是想象力。文中列举的知识点大家可能都懂,但是要把这些知识串接起来,开发一个有实用价值的工具,是需要一点灵动和想象力的。而灵动来源于优化意识,需要一个善于发现问题的心灵,洞悉生活中,工作中的痛点,寻找改进之法。 这个小工具已上传至码云,感兴趣的朋友可点击这里下载


作者:去伪存真
来源:juejin.cn/post/7214349925064802362
收起阅读 »

GPT-4都来了,我们还需要刷算法题和背八股文吗?

温馨提示:有点标题党了,本文并不是正常技术分享,而是表达自己的一些观点,如有冒犯,请多包含。 2023年的面试寒冬 从去年到今年各个大厂都在不断裁员,导致今年IT互联网行情很差,很多朋友都缺乏面试机会或者面试不通过。 程序员一旦要开始面试,很多朋友都开始循环...
继续阅读 »

温馨提示:有点标题党了,本文并不是正常技术分享,而是表达自己的一些观点,如有冒犯,请多包含。



2023年的面试寒冬


从去年到今年各个大厂都在不断裁员,导致今年IT互联网行情很差,很多朋友都缺乏面试机会或者面试不通过。


程序员一旦要开始面试,很多朋友都开始循环以下几个步骤:



  • 刷算法题,如:LeetCode各种困难程度的题目

  • 背诵各种八股文,如:浏览器请求一个URL的完整过程

  • 复习各种冷门知识,如:Promise.race(谁先返回就获取谁的结果,后面直接不处理)或Promise.allSettled(结果为Iterator对象,实现next())


也许以往这种复习模式还算不错,因为目前大多数面试流程基本上都是会问上述问题,而面试官问这些问题的最终目的是什么:



  • 算法题,主要考的是逻辑思维能力

  • 八股文,主要考的是基础知识是否足够扎实,知识深度是否足够

  • 冷门知识,主要考的是知识广度,你除了目前使用知识点外还能拥有其他知识面


后续一些项目经验讲述,主要目的在于测试你是否真的参与到项目中,但是这个往往都是容易被忽略的点,这个放到后面再讲。


当下最火的IT技术,ChatGPT智能机器人出来后,它不像普通搜索引擎一样,需要去大量的结果去找到自己的答案。而是直接将答案提供给到你,让你可以更加快速实现功能。


如果ChatGPT普及开来,那么面试是否还需要考算法题和八股文吗?


或者换个问题,当AI技术可以代替绝大部分基础开发工作的时候,面对这种技术变更,作为普通开发者,我们应该如何做技术成长规划呢?


怎么办


看清楚问题


作为普通的开发人员,我们平时大部分开发工作说的不好听一点,其实都是在复制粘贴,简单点说就是在搬砖,从A地方搬到B地方,举几个例子:



  • 利用框架封装好的东西去开发项目,是否有尝试过自己去实现一个框架,如:Vue、React

  • 实现某一个功能的时候,要嘛从现有项目中拷贝,要嘛从网上找对应答案,比如:需要从一个url获取参数,你会自己实现一个,还是拷贝呢?

  • 遇到问题,第一反应基本上先从网上找答案,找到答案看看能否解决,如果可以就不会再往下深入学习


以上基本上就是我们普通开发的发展路线——尽量不去开发轮子,也没有时间和兴趣去开发轮子。


当chatGPT智能机器人出现之后,你会发现你会的机器人都会,而且它还能实现你不能实现的功能,那么我们可以大胆猜测未来会有这么一个产品:



一个低代码智能化平台,只需要资深开发者去输入一些关键性的业务代码,同时优化生成出来的代码逻辑。



举个更简单的例子,原本你手动搬的砖,现在有机器人可以实现自动搬了,或者说当马车被汽车替代了,那么马夫的工作自然也就被司机所替代。


那么问题就很简单了,现状的问题是:


大厂或小厂都在裁员,不需要那么多搬砖工了,而你还在为了更快搬砖,去提高自己的搬砖能力,如:刷算法题、基础知识等,不就等于在锻炼自己臂力让自己能搬更多的砖吗?


解决方案


既然清楚问题了,针对这个问题能有什么解决方案呢?其实是有的,就在于自己的选择。


成为少数人


怎么理解成为少数人呢?主要有两种方式:


第一种方式,无可替代或者很难替代。


你的工作只有你能做,或者你做的工作很难。还是以搬砖为例:



  • 可以在高楼层搬砖

  • 可以在深海里搬砖


简单的说,除了开发业务功能外,你还掌握了其他的技能,说出来可能大家会骂人,如:



  • 写一手漂亮的PPT,能把PPT做堪比艺术品

  • 项目管理能力强,能把握项目进度

  • 有产品思维,你实现的功能比产品经理想得还完整

  • 其他软技能...


第二种方式,成为专家


这种方式需要天赋+机会+努力,缺一不可,简单来说,就是大家搬砖用的工具都是你制作的,如:



  • Vue/React的作者,将很难被淘汰

  • 公司内部的架构师们,项目的整体架构和轮子都由他搭建的,或者公司需要他们去新搭建一套轮子


那么如何成为专家呢?不管是从网上还是现实中,都有很多实现路线,我这里简单总结一下:



  • 努力学习,扩展技术栈,日复一日的坚持学习知识

  • 深入业务领域,将技术与业务结合,不断创造一些新的方案或架构

  • 扩大自己的影响范围,等待机会,创造一套属于自己的架构体系


离开搬砖


如果真的继续干这一行,吃技术饭很难混下来,那么只有选择离开,但是离开也分不同的分开,下面简单说几句。


第一种离开,改变自己,拥抱新方向
当汽车来临的时候,马夫无法继续工作了,那么为何不直接投入汽车司机的行业呢?


既然AI技术已经开始成熟投入使用了,那么作为开发者有什么理由不提前进入AI领域,去尝试一种新的方向呢?


比如:



  • 彻底转行成为AI开发,这里门槛有点高,但是可以去尝试

  • 利用AI开发工具,成为AI的使用者是不是更加合适呢?


第二种离开,永远离开,投入新领域
AI的到来,会实现很多基础开发工作,那么我不做开发了,是不是就不用被淘汰了呢?


正如所说,天涯何处无芳草,哪里都可以混口饭吃。


不要做21世纪的孔乙己,脱不下身上的长袍。


举几个例子:



  • 转技术培训,前提是你在IT开发待的足够久

  • 创业当老板,从打工思维变老板思维,前提是得有钱有资源

  • 早餐摊,卖粥,足以养活一家人,前提是身体健康


总之,心态不要奔溃,被裁员也好,面试不通过也好,这只是当前整个大背景下的一个波浪,更何况目前AI的技术并没有那么发达。


如果以时间为维度,你这次的失败,只是你几十年人中的一个小片段。


如果以空间为维度,你我皆是蝼蚁,宇宙何其庞大,你抬头看片星空,是否心情会更加放松一点。


最后,推荐大家去看一本书《百万富翁的快车道》,名字很俗,但是却能给我们带来一种新的思想模式:



  • 财富不是金钱等物质,而是你所能控制的时间+你的健康+人脉

  • 人生的每个选择,都是你的信念系统做出的,如果你要财富,则需要不断优化自己本身的信念系统


免责声明


本文是个人一些想法,仅供参考。



做一个有温度的技术分享作者 —— Qborfy


作者:QBorfy
来源:juejin.cn/post/7211120847787098171

收起阅读 »

线程池也会导致OOM的原因

1. 前言 我这边从一个问题引出这次的话题,我们可能会在开中碰到一种OOM问题,java.lang.OutOfMemoryError: pthread_create (1040KB stack) failed: Try again 相信很多人碰到过这个错误,很...
继续阅读 »

1. 前言


我这边从一个问题引出这次的话题,我们可能会在开中碰到一种OOM问题,java.lang.OutOfMemoryError: pthread_create (1040KB stack) failed: Try again


相信很多人碰到过这个错误,很容易从网上搜索到出现这个问题的原因是线程过多,那线程过多为什么会导致OOM?线程什么情况下会释放资源?你又能如何做到让它不释放资源?


有的人可能会想到,那既然创建线程过多会导致OOM,那我用线程池不就行了。但是有没有想过,线程池,也可能会造成OOM。其实这里有个很经典的场景,你使用OkHttp的时候不注意,每次请求都创建OkHttpClient,导致线程池过多出现OOM


2. 简单了解线程池


如何去了解线程池,看源码,直接去看是很难看得懂的,要先了解线程池的原理,对它的设计思想有个大概的掌握之后,再去看源码,就会轻松很多,当然这里只了解基础的原理还不够,还需要有一些多线程相关的基础知识。


本篇文章只从部分源码的角度去分析,线程池如何导致OOM的,而不会全部去看所有线程池的源码细节,因为太多了


首先,要了解线程池,首先需要从它的参数入手:



  • corePoolSize:核心线程数量

  • maximumPoolSize:最大线程数量

  • keepAliveTime,unit:非核心线程的存活时间和单位

  • workQueue:阻塞队列

  • ThreadFactory:线程工厂

  • RejectedExecutionHandler:饱和策略


然后你从网上任何一个地方搜都能知道它大致的工作流程是,当一个任务开始执行时,先判断当前线程池数量是否达到核心线程数,没达到则创建一个核心线程来执行任务,如果超过,放到阻塞队列中等待,如果阻塞队列满了,未达到最大线程数,创建一条非核心线程执行任务,如果达到最大线程数,执行饱和策略。在这个过程中,核心线程不会回收,非核心线程会根据keepAliveTime和unit进行回收。


**这里可以多提一嘴,这个过程用了工厂模式ThreadFactory和策略模式RejectedExecutionHandler,关于策略模式可以看我这篇文章 ** juejin.cn/post/719502…


其实从这里就可以看出为什么线程池也会导致OOM了:核心线程不会回收,非核心线程使用完之后会根据keepAliveTime和unit进行回收 ,那核心线程就会一直存活(我这不考虑shutdown()和shutdownNow()这些情况),一直存活就会占用内存,那你如果创建很多线程池,就会OOM。


所以我这篇文章要分析:核心线程不会释放资源的过程,它内部怎么做到的。 只从这部分的源码去进行分析,不会全部都详细讲。


先别急,为了照顾一些基础不太好的朋友,涉及一些基础知识感觉还是要多讲一下。上面提到的线程回收和shutdown方法这些是什么意思?线程执行完它内部的代码后会主动释放资源吗?


我们都知道开发中有个概念叫生命周期,当然线程池和线程也有生命周期(这很重要),在开发中,我们称之为lifecycle。


生命周期当然是设计这个东西的开发者所定义的,我们先看线程池的生命周期,在ThreadPoolExecutor的注释中有写:


*
* The runState provides the main lifecycle control, taking on values:
*
* RUNNING: Accept new tasks and process queued tasks
* SHUTDOWN: Don't accept new tasks, but process queued tasks
* STOP: Don't accept new tasks, don't process queued tasks,
* and interrupt in-progress tasks
* TIDYING: All tasks have terminated, workerCount is zero,
* the thread transitioning to state TIDYING
* will run the terminated() hook method
* TERMINATED: terminated() has completed
*

看得出它的生命周期有RUNNING,SHUTDOWN,STOP,TIDYING和TERMINATED。而shutdown()和shutdownNow()方法会改变生命周期,这里不是对线程池做全面解析,所以先有个大概了解就行,可以暂时理解成这篇文章的所有分析都是针对RUNNING状态下的。


看完线程池的,再看看线程的生命周期。线程的生命周期有:



  • NEW:创建,简单来说就是new出来没start

  • RUNNABLE:运行,简单来说就是start后执行run方法

  • TERMINATED:中止,简单来说就是执行完run方法或者进行中断操作之后会变成这个状态

  • BLOCKED:阻塞,就是加锁之后竞争锁会进入到这个状态

  • WAITING、TIMED_WAITING:休眠,比如sleep方法


这个很重要,需要了解,你要学会线程这块相关的知识点的话,这些生命周期要深刻理解 。比如BLOCKED和WAITING有什么不同?然后学这块又会涉及到锁那一块的知识。以后有时间可以单独写几篇这类的文章,这里先大概有个概念,只需要能先看懂后面的源码就行。


从生命周期的概念你就能知道线程执行完它内部的代码后会主动释放资源,因为它run执行完之后生命周期会到TERMINATED,那这又涉及到了一个知识点,为什么主线程(ActivityThread),执行完run的代码后不会生命周期变成TERMINATED,这又涉及到Looper,就得了解Handler机制,可以看我这篇文章 juejin.cn/post/715882…


扯远了,现在进入正题,先想想,如果是你,你怎么做让核心线程执行完run之后不释放资源,很明显,只要让它不执行到TERMINATED生命周期就行,如何让它不变成TERMINATED状态,只需要让它进入BLOCKED或者WAITING状态就行。所以我的想法是这样的,当这个核心线程执行完这个任务之后,我让它WAITING,等到有新的任务进来的时候我再唤醒它进入RUNNABLE状态。 这是我从理论这个角度去分析的做法,那看看实际ThreadPoolExecutor是怎么做的


3. 线程池部分源码分析


前面说了,不会全部都讲,这里涉及到文章相关内容的流程就是核心线程的任务执行过程,所以这里主要分析核心线程。


当我们使用线程池执行一个任务时,会调用ThreadPoolExecutor的execute方法


public void execute(Runnable command) {
......

int c = ctl.get();
if (workerCountOf(c) < corePoolSize) {
if (addWorker(command, true))
return;
c = ctl.get();
}

// 我们只看核心线程的流程,所以后面的代码不用管
......
}

这个ctl是一个状态相关的代码,可以先不用管,我后面会简单统一做个解释,这里不去管它会比较容易理解,我们现在主要是为了看核心线程的流程。从这里可以看出,当前线程的数量小于核心线程的话执行addWorker方法


private boolean addWorker(Runnable firstTask, boolean core) {
retry:
for (;;) {
int c = ctl.get();
int rs = runStateOf(c);

// Check if queue empty only if necessary.
if (rs >= SHUTDOWN &&
! (rs == SHUTDOWN &&
firstTask == null &&
! workQueue.isEmpty()))
return false;

for (;;) {
int wc = workerCountOf(c);
if (wc >= CAPACITY ||
wc >= (core ? corePoolSize : maximumPoolSize))
return false;
if (compareAndIncrementWorkerCount(c))
break retry;
c = ctl.get(); // Re-read ctl
if (runStateOf(c) != rs)
continue retry;
// else CAS failed due to workerCount change; retry inner loop
}
}

boolean workerStarted = false;
boolean workerAdded = false;
Worker w = null;
try {
w = new Worker(firstTask);
final Thread t = w.thread;
if (t != null) {
final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
// Recheck while holding lock.
// Back out on ThreadFactory failure or if
// shut down before lock acquired.
int rs = runStateOf(ctl.get());

if (rs < SHUTDOWN ||
(rs == SHUTDOWN && firstTask == null)) {
if (t.isAlive()) // precheck that t is startable
throw new IllegalThreadStateException();
workers.add(w);
int s = workers.size();
if (s > largestPoolSize)
largestPoolSize = s;
workerAdded = true;
}
} finally {
mainLock.unlock();
}
if (workerAdded) {
t.start();
workerStarted = true;
}
}
} finally {
if (! workerStarted)
addWorkerFailed(w);
}
return workerStarted;
}

这个addWorker分为上下两部分,我们分别来做解析


private boolean addWorker(Runnable firstTask, boolean core) {
retry:
for (;;) {
int c = ctl.get();
int rs = runStateOf(c);

// Check if queue empty only if necessary.
if (rs >= SHUTDOWN &&
! (rs == SHUTDOWN &&
firstTask == null &&
! workQueue.isEmpty()))
return false;

for (;;) {
int wc = workerCountOf(c);
if (wc >= CAPACITY ||
wc >= (core ? corePoolSize : maximumPoolSize))
return false;
if (compareAndIncrementWorkerCount(c))
break retry;
c = ctl.get(); // Re-read ctl
if (runStateOf(c) != rs)
continue retry;
// else CAS failed due to workerCount change; retry inner loop
}
}

// 下半部分
......
}

这里主要是做了状态判断的一些操作,我说过状态相关的我们可以先不管,但是这里的写法我觉得要单独讲一下为什么会这么写。不然它内部很多代码是这样的,我怕劝退很多人。


首先retry: ...... break retry; 这个语法糖,平常我们开发很少用到,可以去了解一下,这里就是为了跳出循环。 其次,这里的compareAndIncrementWorkerCount内部的代码是AtomicInteger ctl.compareAndSet(expect, expect + 1) ,Atomic的compareAndSet操作搭配死循环,这叫自旋,所以说要看懂这个需要一定的java多线程相关的基础。自旋的目的是为了什么?这就又涉及到了锁的分类中有乐观锁,有悲观锁。不清楚的可以去学一下这些知识,你就知道为什么它要这么做了,这里就不一一解释。包括你看它的源码,能看到,它会很多地方用自旋,很多地方用ReentrantLock,但它就是不用synchronized ,这些都是多线程这块基础的知识,这里不多说了。


看看下半部分


private boolean addWorker(Runnable firstTask, boolean core) {

// 上半部分
......



boolean workerStarted = false;
boolean workerAdded = false;
Worker w = null;
try {
w = new Worker(firstTask);
final Thread t = w.thread;
if (t != null) {
......
if (workerAdded) {
t.start();
workerStarted = true;
}
}
} finally {
......
}
return workerStarted;
}

看到它先创建一个Worker对象,再调用Worker对象内部的线程的start方法,我们看看Worker


private final class Worker
extends AbstractQueuedSynchronizer
implements Runnable
{

private static final long serialVersionUID = 6138294804551838833L;

final Thread thread;
Runnable firstTask;

Worker(Runnable firstTask) {
setState(-1); // inhibit interrupts until runWorker
this.firstTask = firstTask;
this.thread = getThreadFactory().newThread(this);
}

public void run() {
runWorker(this);
}

// 其它方法
......
}

看到它内部主要有两个对象firstTask就是任务,thread就是执行这个任务的线程,而这个线程是通过getThreadFactory().newThread(this)创建出来的,这个就是我们创建ThreadPoolExecutor时传的“线程工厂”

外部调t.start();之后就会执行这里的run方法,因为newThread传了this进去,你可以先简单理解调这个线程start会执行到这个run,然后run中调用runWorker(this);


注意,你想想runWorker(this)方法,包括之后的流程,都是执行在哪个线程中?都是执行在子线程中,因为这个run方法中的代码,都是执行在这个线程中。你一定要理解这一步,不然你自己看源码会可能看懵。 因为有些人长期不接触多线程环境的情况下,你会习惯单线程的思维去看问题,那就很容易出现理解上的错误。


我们继续看看runWorker,时刻提醒你自己,之后的流程都是在子线程中进行,这条子线程的生命周期变为RUNNABLE


final void runWorker(Worker w) {
Thread wt = Thread.currentThread();
Runnable task = w.firstTask;
w.firstTask = null;
w.unlock(); // allow interrupts
boolean completedAbruptly = true;
try {
while (task != null || (task = getTask()) != null) {s
w.lock();

// 中断相关的操作
......

try {
beforeExecute(wt, task);
Throwable thrown = null;
try {
task.run();
} catch (RuntimeException x) {
......
} finally {
afterExecute(task, thrown);
}
} finally {
......
}
}
completedAbruptly = false;
} finally {
processWorkerExit(w, completedAbruptly);
}
}

先讲讲这里的一个开发技巧,task.run()就是执行任务,它前面的beforeExecute和afterExecute就是模板方法设计模式,方便扩展用。

执行完任务后,最后执行processWorkerExit方法


private void processWorkerExit(Worker w, boolean completedAbruptly) {
if (completedAbruptly) // If abrupt, then workerCount wasn't adjusted
decrementWorkerCount();

final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
completedTaskCount += w.completedTasks;
workers.remove(w);
} finally {
mainLock.unlock();
}

tryTerminate();

......
}

workers.remove(w)后执行tryTerminate方法尝试将线程池的生命周期变为TERMINATED


final void tryTerminate() {
for (;;) {
int c = ctl.get();
if (isRunning(c) ||
runStateAtLeast(c, TIDYING) ||
(runStateOf(c) == SHUTDOWN && ! workQueue.isEmpty()))
return;
if (workerCountOf(c) != 0) { // Eligible to terminate
interruptIdleWorkers(ONLY_ONE);
return;
}

final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
if (ctl.compareAndSet(c, ctlOf(TIDYING, 0))) {
try {
terminated();
} finally {
ctl.set(ctlOf(TERMINATED, 0));
termination.signalAll();
}
return;
}
} finally {
mainLock.unlock();
}
// else retry on failed CAS
}
}

先不用管状态的变化,一般一眼都能看得出这里是结束的操作了,我们追踪的核心线程正常在RUNNING状态下是不会执行到这里的。 那我们期望的没任务情况下让线程休眠的操作在哪里?

看回runWorker方法


final void runWorker(Worker w) {
......
boolean completedAbruptly = true;
try {
while (task != null || (task = getTask()) != null) {s
......
}
completedAbruptly = false;
} finally {
processWorkerExit(w, completedAbruptly);
}
}

看到它的while中有个getTask()方法,认真看runWorker方法其实能看出,核心线程执行完一个任务之后会getTask()拿下一个任务去执行,这就是当核心线程满的时候任务会放到阻塞队列中,核心线程执行完任务之后会从阻塞队列中拿下一个任务执行。 getTask()从抽象上来看,就是从队列中拿任务。


private Runnable getTask() {
boolean timedOut = false; // Did the last poll() time out?

for (;;) {
......

try {
Runnable r = timed ?
workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS) :
workQueue.take();
if (r != null)
return r;
timedOut = true;
} catch (InterruptedException retry) {
timedOut = false;
}
}
}

先把timed当成正常情况下为false,然后会执行workQueue.take(),这个workQueue是阻塞队列BlockingQueue, 注意,这里又需要有点基础了。正常有点基础的人看到这里,已经知道这里就是当没有任务会让核心线程休眠的操作,看不懂的,可以先了解下什么是AQS,可以看看我这篇文章 juejin.cn/post/716801…


如果你说你懒得看,行吧,我随便拿个ArrayBlockingQueue给你举例


public E take() throws InterruptedException {
final ReentrantLock lock = this.lock;
lock.lockInterruptibly();
try {
while (count == 0)
notEmpty.await();
return dequeue();
} finally {
lock.unlock();
}
}

notEmpty是Condition,这里调用了Condition的await()方法,然后想想执行这步操作的是在哪条线程上?线程进入WAITING状态了吧,不会进入TERMINATED了吧。


然后当有任务添加之后会唤醒它,它继续在循环中去执行任务。


这就验证了我们的猜想,通过让核心线程进入WAITING状态以此来达到执行完run方法中的任务也不会主动TERMINATED而释放线程。所以核心线程一直占用资源,这里说的资源指的是空间,而cpu的时间片是会让出的。


4. 部分线程池的操作解读


为什么线程池也会导致OOM,上面已经通过源码告诉你,核心线程不会释放内存空间,导致线程池多的情况下也会导致OOM。这里为了方便新手阅读ThreadPoolExecutor相关的代码,还是觉得写一些它内部的设计思想,不然没点基础的话确实很难看懂。


首先就是状态,上面源码中都有关线程池的生命中周期状态(ctl字段),可以看看它怎么设计的


private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0));
private static final int COUNT_BITS = Integer.SIZE - 3; // Integer.SIZE是32
private static final int CAPACITY = (1 << COUNT_BITS) - 1;

// runState is stored in the high-order bits
private static final int RUNNING = -1 << COUNT_BITS;
private static final int SHUTDOWN = 0 << COUNT_BITS;
private static final int STOP = 1 << COUNT_BITS;
private static final int TIDYING = 2 << COUNT_BITS;
private static final int TERMINATED = 3 << COUNT_BITS;

它这里用了两个设计思想,第一个就是用位来表示状态,关于这类型的设计,可以看我这2篇文章 juejin.cn/post/715547…juejin.cn/post/720550…


另外一个设计思想是:用一个变量的高位置表示状态,低位表示数量。 这里就是用高3位来表示生命周期,剩下的低位表示线程的数量。和这个类似的操作有view中的MeasureSpec,也是一个变量表示两个状态。


然后关于设计模式,可以看到它这里最经典的就是用了策略模式,如果你看饱和策略那块的源码,可以好好看看它是怎么设计的。其它的还有工厂、模板之类的,这些也不难,就是策略还是建议学下它怎么去设计的。


然后多线程相关的基础,这个还是比较重要的,这块的基础不好,看ThreadPoolExecutor的源码会相对吃力。比如我上面提过的,线程的生命周期,锁相关的知识,还有AQS等等。如果你熟悉这些,再看这个源码就会轻松很多。


对于总体的设计,你第一看会觉得它的源码很绕,为什么会这样?因为有中断操作+自旋锁+状态的设计 ,它的这种设计就基本可以说是优化代码到极致,比如说状态的设计,就比普通的能省内存,能更方便通过CAS操作。用自旋就是乐观锁,能节省资源等。有中断操作,能让整个系统更灵活。相对的缺点就是不安全,什么意思呢?已是就是这样写代码很容易出BUG,所以这里的让人觉得很绕的代码,就是很多的状态的判断,这些都是为了保证这个流程的安全。


5. 总结


从部分源码的角度去分析,得到的结论是线程池也可能会导致OOM


那再思考一个问题:不断的创建线程池,“一定”会导致OOM吗? 如果你对线程池已经有一定的了解,相信你也知

作者:流浪汉kylin
来源:juejin.cn/post/7210691957790572601
道这个问题的答案。


收起阅读 »

从Flutter到Compose,为什么都在推崇声明式UI?

Compose推出之初,就曾引发广泛的讨论,其中一个比较普遍的声音就是——“🤨这跟Flutter也长得太像了吧?!” 这里说的长得像,实际更多指的是UI编码的风格相似,而关于这种风格有一个专门的术语,叫做声明式UI。 对于那些已经习惯了命令式UI的Androi...
继续阅读 »

Compose推出之初,就曾引发广泛的讨论,其中一个比较普遍的声音就是——“🤨这跟Flutter也长得太像了吧?!”


这里说的长得像,实际更多指的是UI编码的风格相似,而关于这种风格有一个专门的术语,叫做声明式UI


对于那些已经习惯了命令式UI的Android或iOS开发人员来说,刚开始确实很难理解什么是声明式UI。就像当初刚踏入编程领域的我们,同样也很难理解面向过程编程面向对象编程的区别一样。


为了帮助这部分原生开发人员完成从命令式UI到声明式UI的思维转变,本文将结合示例代码编写、动画演示以及生活例子类比等形式,详细介绍声明式UI的概念、优点及其应用。


照例,先奉上思维导图一张,方便复习:





命令式UI的特点


既然命令式UI与声明式UI是相对的,那就让我们先来回顾一下,在一个常规的视图更新流程中,如果采用的是命令式UI,会是怎样的一个操作方式。


以Android为例,首先我们都知道,Android所采用的界面布局,是基于View与ViewGroup对象、以树状结构来进行构建的视图层级。



当我们需要对某个节点的视图进行更新时,通常需要执行以下两个操作步骤:



  1. 使用findViewById()等方法遍历树节点以找到对应的视图。

  2. 通过调用视图对象公开的setter方法更新视图的UI状态


我们以一个最简单的计数器应用为例:



这个应用唯一的逻辑就是“当用户点击"+"号按钮时数字加1”。在传统的Android实现方式下,代码应该是这样子的:


class CounterActivity : AppCompatActivity() {

var count: Int = 0

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_counter)

val countTv = findViewById<TextView>(R.id.count_tv)
countTv.text = count.toString()

val plusBtn = findViewById<Button>(R.id.plus_btn)
plusBtn.setOnClickListener {
count += 1
countTv.text = count.toString()
}

}
}

这段代码看起来没有任何难度,也没有明显的问题。但是,假设我们在下一个版本中添加了更多的需求:




  • 当用户点击"+"号按钮,数字加1的同时在下方容器中添加一个方块。

  • 当用户点击"-"号按钮,数字减1的同时在下方容器中移除一个方块。

  • 当数字为0时,下方容器的背景色变为透明。


现在,我们的代码变成了这样:


class CounterActivity : AppCompatActivity() {

var count: Int = 0

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_counter)

// 数字
val countTv = findViewById<TextView>(R.id.count_tv)
countTv.text = count.toString()

// 方块容器
val blockContainer = findViewById<LinearLayout>(R.id.block_container)

// "+"号按钮
val plusBtn = findViewById<Button>(R.id.plus_btn)
plusBtn.setOnClickListener {
count += 1
countTv.text = count.toString()
// 方块
val block = View(this).apply {
setBackgroundColor(Color.WHITE)
layoutParams = LinearLayout.LayoutParams(40.dp, 40.dp).apply {
bottomMargin = 20.dp
}
}
blockContainer.addView(block)
when {
count > 0 -> {
blockContainer.setBackgroundColor(Color.parseColor("#FF6200EE"))
}
count == 0 -> {
blockContainer.setBackgroundColor(Color.TRANSPARENT)
}
}
}

// "-"号按钮
val minusBtn = findViewById<Button>(R.id.minus_btn)
minusBtn.setOnClickListener {
if(count <= 0) return@setOnClickListener
count -= 1
countTv.text = count.toString()
blockContainer.removeViewAt(0)
when {
count > 0 -> {
blockContainer.setBackgroundColor(Color.parseColor("#FF6200EE"))
}
count == 0 -> {
blockContainer.setBackgroundColor(Color.TRANSPARENT)
}
}
}

}

}

已经开始看得有点难受了吧?这正是命令式UI的特点,侧重于描述怎么做,我们需要像下达命令一样,手动处理每一项UI的更新,如果UI的复杂度足够高的话,就会引发一系列问题,诸如:



  • 可维护性差:需要编写大量的代码逻辑来处理UI变化,这会使代码变得臃肿、复杂、难以维护。

  • 可复用性差:UI的设计与更新逻辑耦合在一起,导致只能在当前程序使用,难以复用。

  • 健壮性差:UI元素之间的关联度高,每个细微的改动都可能一系列未知的连锁反应。


声明式UI的特点


而同样的功能,假如采用的是声明式UI,则代码应该是这样子的:


class _CounterPageState extends State<CounterPage> {
int _count = 0;

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Column(
children: [
// 数字
Text(
_count.toString(),
style: const TextStyle(fontSize: 48),
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
// +"号按钮
ElevatedButton(
onPressed: () {
setState(() {
_count++;
});
},
child: const Text("+")),
// "-"号按钮
ElevatedButton(
onPressed: () {
setState(() {
if (_count == 0) return;
_count--;
});
},
child: const Text("-"))
],
),
Expanded(
// 方块容器
child: Container(
width: 60,
padding: const EdgeInsets.all(10),
color: _count > 0 ? const Color(0xFF6200EE) : Colors.transparent,

child: ListView.separated(
itemCount: _count,
itemBuilder: (BuildContext context, int index) {
// 方块
return Container(width: 40, height: 40, color: Colors.white);
},
separatorBuilder: (BuildContext context, int index) {
return const Divider(color: Colors.transparent, height: 10);
},
),
))
],
),
);
}
}


在这样的代码中,我们几乎看不到任何操作UI更新的代码,而这正是声明式UI的特点,它侧重于描述做什么,而不是怎么做,开发者只需要关注UI应该如何呈现,而不需要关心UI的具体实现过程。


开发者要做的,就只是提供不同UI与不同状态之间的映射关系,而无需编写如何在不同UI之间进行切换的代码。


所谓状态,指的是构建用户界面时所需要的数据,例如一个文本框要显示的内容,一个进度条要显示的进度等。Flutter框架允许我们仅描述当前状态,而转换的工作则由框架完成,当我们改变状态时,用户界面将自动重新构建


下面我们将按照通常情况下,用声明式UI实现一个Flutter应用所需要经历的几个步骤,来详细解析前面计数器应用的代码:



  1. 分析应用可能存在的各种状态


根据我们前面对于“状态”的定义,我们可以很容易地得出,在本例中,数字(_count值)本身即为计数器应用的状态,其中还包括数字为0时的一个特殊状态。



  1. 提供每个不同状态所对应要展示的UI


build方法是将状态转换为UI的方法,它可以在任何需要的时候被框架调用。我们通过重写该方法来声明UI的构造:


对于顶部的文本,只需声明每次都使用最新返回的状态(数字)即可:


Text(
_count.toString(),
...
),

对于方块容器,只需声明当_count的值为0时,容器的背景颜色为透明色,否则为特定颜色:


Container(
color: _count > 0 ? const Color(0xFF6200EE) : Colors.transparent,
...
)

对于方块,只需声明返回的方块个数由_count的值决定:


ListView.separated(
itemCount: _count,
itemBuilder: (BuildContext context, int index) {
// 方块
return Container(width: 40, height: 40, color: Colors.white);
},
...
),


  1. 根据用户交互或数据查询结果更改状态


当由于用户的点击数字发生变化,而我们需要刷新页面时,就可以调用setState方法。setState方法将会驱动build方法生成新的UI:


// "+"号按钮
ElevatedButton(
onPressed: () {
setState(() {
_count++;
});
},
child: const Text("+")),
// "-"号按钮
ElevatedButton(
onPressed: () {
setState(() {
if (_count == 0) return;
_count--;
});
},
child: const Text("-"))
],

可以结合动画演示来回顾这整个过程:



最后,用一个公式来总结一下UI、状态与build方法三者的关系,那就是:



以命令式和声明式分别点一杯奶茶


现在,你能了解命令式UI与声明式UI的区别了吗?如果还是有些抽象,我们可以用一个点奶茶的例子来做个比喻:


当我们用命令式UI的思维方式去点一杯奶茶,相当于我们需要告诉制作者,冲一杯奶茶必须按照煮水、冲茶、加牛奶、加糖这几个步骤,一步步来完成,也即我们需要明确每一个步骤,从而使得我们的想法具体而可操作。


而当我们用声明式UI的思维方式去点一杯奶茶,则相当于我们只需要告诉制作者,我需要一杯“温度适中、口感浓郁、有一点点甜味”的奶茶,而不必关心具体的制作步骤和操作细节。


声明式编程的优点


综合以上内容,我们可以得出声明式UI有以下几个优点:




  • 简化开发:开发者只需要维护状态->UI的映射关系,而不需要关注具体的实现细节,大量的UI实现逻辑被转移到了框架中。




  • 可维护性强:通过函数式编程的方式构建和组合UI组件,使代码更加简洁、清晰、易懂,便于维护。




  • 可复用性强:将UI的设计和实现分离开来,使得同样的UI组件可以在不同的应用程序中使用,提高了代码的可复用性。




总结与展望


总而言之,声明式UI是一种更加高层次、更加抽象的编程方式,其最大的优点在于能极大地简化现有的开发模式,因此在现代应用程序中得到广泛的应用,随着更多框架的采用与更多开发者的加入,声明式UI必将继续发展壮大,成为以后构建用户界面的首选方式。


作者:星际码仔
来源:juejin.cn/post/7212622837063811109
收起阅读 »

Android 带你重新认知属性动画

我报名参加金石计划1期挑战——瓜分10万奖池,这是我的第5篇文章,点击查看活动详情 前言 之前写过一篇关于属性动画简单使用的文章juejin.cn/post/714417… 虽然官方直接提供的属性动画只有4个效果:透明度、位移、旋转、缩放,然后用Set实现组合...
继续阅读 »

我报名参加金石计划1期挑战——瓜分10万奖池,这是我的第5篇文章,点击查看活动详情


前言


之前写过一篇关于属性动画简单使用的文章juejin.cn/post/714417…


虽然官方直接提供的属性动画只有4个效果:透明度、位移、旋转、缩放,然后用Set实现组合,用插值器加一些效果。但其实属性动画能做的超越你的想象,他能做到anything。你可以实现各种你所想象的效果,改图片形状、路径的动画、颜色的变化等(当然这得是矢量图)。而插值器,除了系统提供的那些插值器之外,你还能进行自定义实现你想要的运动效果。


实现的效果


我这里拿个形变的效果来举例。可以先看看实现的效果:


sp.gif


实现要点


要点主要有两点:(1)要去想象,到了这种程度包括更复杂的效果,没有人能教你的,只能靠自己凭借经验和想象力去规划怎么实现。 (2)要计算,一般做这种自定义的往往会涉及计算的成分,所以你要实现的效果越高端,需要计算的操作就越复杂。


思路


我做这个播放矢量图和暂停矢量图之间的形变,这个思路是这样的: 其实那个三角形是由两部分组成,左边是一个矩形(转90度的梯形),右边是一个三角形。然后把两个图形再分别变成长方形。具体计算方式是我把width分成4份,然后配合一个偏移量offset去进行调整(计算的部分没必要太纠结,都是要调整的)


步骤:



  1. 绘制圆底和两个图形

  2. 属性动画

  3. 页面退出后移除动画


1. 绘制圆底和两个图形


一共三个Paint


init {
paint = Paint()
paint2 = Paint()
paint3 = Paint()

paint?.color = context.resources.getColor(R.color.kylin_main_color)
paint?.isAntiAlias = true
paint2?.color = context.resources.getColor(R.color.kylin_white)
paint2?.style = Paint.Style.FILL
paint2?.isAntiAlias = true
paint3?.color = context.resources.getColor(R.color.kylin_white)
paint3?.isAntiAlias = true
}

绘制圆底就比较简单


paint?.let {
canvas?.drawCircle((width/2).toFloat(), (height/2).toFloat(), (width/2).toFloat(),
it
)
}

然后先看看我的一个参考距离的计算(有这个参考距离,才能让图形大小跟着宽高而定,而不是写死)


override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
super.onLayout(changed, left, top, right, bottom)
if (baseDim == 0f){
baseDim = (0.25 * width).toFloat()
}
}

另外两个图用路径实现


if (path1 == null || path2 == null){
path1 = Path()
path2 = Path()
// 设置初始状态
startToStopAnim(0f)
}
paint2?.let { canvas?.drawPath(path1!!, it) }
paint3?.let { canvas?.drawPath(path2!!, it) }

看具体的绘制实现


private fun startToStopAnim(currentValue : Float){
val offset : Int = (baseDim * 0.25 * (1-currentValue)).toInt()

path1?.reset()
path1?.fillType = Path.FillType.WINDING
path1?.moveTo(baseDim + offset, baseDim) // 点1不变
path1?.lineTo(2 * baseDim+ offset - baseDim/3*currentValue,
baseDim + (0.5 * baseDim).toInt() * (1-currentValue))
path1?.lineTo(2 * baseDim+ offset - baseDim/3*currentValue,
2 * baseDim +(0.5 * baseDim).toInt() + (0.5 * baseDim).toInt() * currentValue)
path1?.lineTo(baseDim+ offset, 3 * baseDim) // 点4不变
path1?.close()


path2?.reset()
path2?.fillType = Path.FillType.WINDING
if (currentValue <= 0f) {
path2?.moveTo(2 * baseDim + offset, baseDim + (0.5 * baseDim).toInt())
path2?.lineTo(3 * baseDim + offset, 2 * baseDim)
path2?.lineTo(2 * baseDim + offset, 2 * baseDim + (0.5 * baseDim).toInt())
}else {
path2?.moveTo(2 * baseDim+ offset + baseDim/3*currentValue,
baseDim + (0.5 * baseDim).toInt() * (1-currentValue))
path2?.lineTo(3 * baseDim + offset, baseDim + baseDim * (1-currentValue))
path2?.lineTo(3 * baseDim + offset, 2 * baseDim + baseDim * currentValue)
path2?.lineTo(2 * baseDim+ offset + baseDim/3*currentValue,
2 * baseDim +(0.5 * baseDim).toInt() + (0.5 * baseDim).toInt() * currentValue)
}
path2?.close()
}

这个计算的过程不好解释,加偏移量就是一个调整的过程,可以去掉偏移量offset看看效果就知道为什么要加了。path1代表左边的路径,左边的路径是4个点,path2是右边的路径,右边的路径会根据情况去决定是3个点还是4个点,默认情况是3个。


2、属性动画


fun startToStopChange(){
isRecordingStart = true
if (mValueAnimator1 == null) {
mValueAnimator1 = ValueAnimator.ofFloat(0f, 1f)
mValueAnimator1?.addUpdateListener {
val currentValue: Float = it.animatedValue as Float
startToStopAnim(currentValue)
postInvalidate()
}
mValueAnimator1?.interpolator = AccelerateInterpolator()
}
mValueAnimator1?.setDuration(500)?.start()
}

float类型0到1其实就是实现一个百分比的效果。变过去能实现后,变回来就也就很方便


fun stopToStartChange(){
isRecordingStart = false
if (mValueAnimator2 == null) {
mValueAnimator2 = ValueAnimator.ofFloat(1f, 0f)
mValueAnimator2?.addUpdateListener {
val currentValue: Float = it.animatedValue as Float
startToStopAnim(currentValue)
postInvalidate()
}
mValueAnimator2?.interpolator = AccelerateInterpolator()
}
mValueAnimator2?.setDuration(500)?.start()
}

3.移除动画


view移除后要移除动画


fun close(){
try {
if (mValueAnimator1?.isStarted == true){
mValueAnimator1?.cancel()
}
if (mValueAnimator2?.isStarted == true){
mValueAnimator2?.cancel()
}
}catch (e : Exception){
e.printStackTrace()
}finally {
mValueAnimator1 = null
mValueAnimator2 = null
}
}

然后还要注意,这个动画是耗时操作,所以要做防快速点击。


总结


从代码可以看出,其实并实现起来并不难,难的在于自己要有想象力,要能想出这样的一个过程,比较花费时间的可能就是一个调整的过程,其它也基本没什么技术难度。


我这个也只是简单做了个Demo来演示,你要问能不能实现其它效果,of course,你甚至可以先把三角形变成一个正方形,再变成两个长方形等等,你甚至可以用上贝塞尔来实现带曲线的效果。属性动画就是那么的强大,对于矢量图,它能实现几乎所有的你想要的效果,只有你想不到,没有它做不到。


作者:流浪汉kylin
来源:juejin.cn/post/7144190782205853732
收起阅读 »

有些东西你要是早点懂,也不至于走那么多冤枉路

最近在阅读一些书籍和学习一些技术的时候,有一些心得,再和过去自己在不同阶段的一些经历进行反思,总结一些个人的想法和看法,也希望自己在很多年后再回头来看的时候,不像今天回头去看很多年前一样感到有一丝悔意和不甘。 在大学二年级下学期之前,我是处于一种“无头苍蝇”的...
继续阅读 »

最近在阅读一些书籍和学习一些技术的时候,有一些心得,再和过去自己在不同阶段的一些经历进行反思,总结一些个人的想法和看法,也希望自己在很多年后再回头来看的时候,不像今天回头去看很多年前一样感到有一丝悔意和不甘。


在大学二年级下学期之前,我是处于一种“无头苍蝇”的状态,并不是说自己自甘堕落,破罐子破摔,不是的,相反,我是渴求改变自己的,想学东西的,但是,对于我这种普通本科的学生,虽然学校图书馆有你看不完的书,但是,你总得知道你该看什么书,什么样的对你有帮助,知识的海洋是没有边际的,但是一个人的精力是有限的,当把过多的时间花费在一些对自己没有成长,但是自己却在自嗨的事情上的时候是很可怕的。


就拿读书这件事来说,那时候因为我是“无头苍蝇”,所以就“病急乱投医”,总觉得要去看一点书来充实自己,于是我就看了一些历史,三皇五帝,春秋战国,秦汉三国南北朝都去看了,后面又去看了王阳明,曾国藩,后面是越看越觉得不行,我是学软件工程的,怎么研究起历史来了,然后又去寻求内心的安慰,又去听电台,我记得那时候我最爱听的就是《饮鸩不止渴》和《十点读书》,然后里面的一些鸡汤就把自己灌饱了,就觉得“未来可期”,其实后来才发现是“未来可欺”,不过其实对于像我这样的人,整个社会太多太多,他们想改变自己,想未来有一份不错的事业,他们有梦想,有激情,但是,他们却不知道怎么做,他们的父母不懂这个专业,他们的身边也没啥人懂这个专业,他们从小没见过大的世界,所以导致他们“浪费”了很多时光。


当然,读书是一件十分好的事,听电台也很好,但是,在生命的每一个阶段,你自己应该把时间主要花费在什么上面,这是一个很有智慧的问题,读历史,读人物传记能够让我们有更多的思考空间,有宽广的胸怀,让人遇事从容淡定,因为随着时间的推移,枭雄豪杰不过是一堆白骨,但是眼下我们依旧在生活着,是避不开生计,避不开七情六欲的,所以在头顶星辰大海的同时也要看好眼前的路,在自己没有方向的时候,就看别人怎么做的,如果没有目标参考系,那就做自己专业该做的,然后极力去了解相关资讯和技术,这样即使种不出南瓜,但是也绝对不会少了豆子,最主要的是,这个过程它会去锻炼人,提高自己的思考能力。


不过在自己有了目标以后,怎么去实现这个目标更是一个问题,如果没有条理,没有规划,没有结合社会情况,那么,努力就会显得无力苍白,学生时代的时候,说白了,对于普通本科的学校,大家都差不多,自然没有人有更高的论调,所以都沉迷于表面的浮华,而不去关注其核心原理,所以在学习技术的时候,也会显得毛毛躁躁,不去认真专研,而是沉迷于“多”,“炫”上面,其实着是不对的,不论是计算机专业还是其他专业,知识框架是很多的,但是大多数人少了一种刨根问底的精神,当然,刨根问底也并不一定是一种值得称赞的精神,但是在学生时代如果有刨根问底的精神,那么事必成,因为学生时代正是种子发芽的时候,但是进入社会以后,刨根问底未必就是好事了,它和我们的职业规划,个人性格等有很大的关联,比如你觉得你不可能成为技术专家的条件,你的优势也不在哪里,你还去刨根问底技术,那么这不是明智的选择,但是如果暂时还在一个过渡期,很多东西还不确定,那么是有必要去刨根问底的,而且刨得越深越好。


个人定位无论在那个阶段都是很重要的,它是一种判断力,更是一种智慧,你让韩信去管钱粮他肯定不如萧何,你让萧何去管兵马打仗,他肯定不如韩信,所以,没有那个方向是做好的,没有那个岗位是最值得深耕的,这完全要根据自己的情况来,如果你是一个技术控,对技术有无尽的热爱,加上脑子转得也快,那么,从事技术发展肯定是一个很不错的选择,但是如果对技术不敏感,别人一遍就会,而我十遍都不会,那么可能我真的不适合,但是我对商业有洞察力,对客户有一套打法,那么就没必要去技术哪里死磕,虽然你能花11个小时弄懂,但是硬生生花11小时去弄,因为人最宝贵的就是时间,这样就显得不理智,因为其他它从某种意义上已经证明你确实不适合干这个。


主要就说这些了,虽然都是能懂的道理,但是还是要时刻记录下来,给自己看的同时也希望对别人有启示,往往很多时候是环境和认知限制了我们,所以这时候思考和学习就是最好的解法,在资源不充足的情况下,一定会踩很多坑,滩很多浑水,所以寻求资源是一件十分有必要的事情,多向优秀的人请教,学习,多了解这个社会的运转,不要被关在狭小的信息茧房里。


今天的分享就到这里,感谢你

作者:刘牌
来源:juejin.cn/post/7214858677174452281
的观看,我们下期见。

收起阅读 »

我的 Android 应用安全方案梳理

作为独立开发者,应用被破解是一件非常让人烦恼的事情。之前有同学在我的一篇博文下面问,有没有一些 Android 防破解的方法。在多次加固、破解、再加固、再破解的过程中,我也积累了一些思路和方法。这里分享一下,如果需要用到,可以作一个参考。 先说一个结论,也是我...
继续阅读 »

作为独立开发者,应用被破解是一件非常让人烦恼的事情。之前有同学在我的一篇博文下面问,有没有一些 Android 防破解的方法。在多次加固、破解、再加固、再破解的过程中,我也积累了一些思路和方法。这里分享一下,如果需要用到,可以作一个参考。


先说一个结论,也是我在 Stackoverflow 上面的一个国外程序员的答案,


anti_debug.png


就是说,APK 包已经在别人手上了,我们能做的不过是提升被破解的难度,如果真的遇到非常“执着”的,要破解一样被破解。如果逻辑非常值钱,那么最好还是把逻辑放到服务器上面。此外,加固也是一个可选的方案。不过目前市面上专业的加固价格并不美丽,各大平台年费从 3 万至 8 万不等,并且对个人开发者并不友好。


下面是我开发过程中为了防止应用被破解采取的一些策略。


1、一些必要的基础知识


首先,别人要破解你的软件。如果只是在自己的手机上面使用,那么他可以修改系统的一些方法进行破解。这种不在我的考虑范围内,因为他们的修改只在自己的手机上生效,构不成传播。我关注的是 APK 文件被破解的情况。


我们在加密的时候会用到一些加密或者编码方法。常见的有,非对称加密算法 RSA 等;对称加密算法 DES、3DES 和 AES 等;不可逆的加密 MD5、SHA256 等。


另外,我们会把重要的加密逻辑放到 Native 层来实现,所以一些 JNI 编程的方法也是需要的。不过,如果仅仅是用来作加密的话,对 C/C++ 的要求是没那么高的。对在 Android 中使用 JNI,可以参考我之前的文章《在 Android 中使用 JNI 的总结》


2、签名校验


2.1 基础签名校验


在应用和 so 中作签名校验可以说是最基本的安全策略。在应用中作签名校验可以防止应用被二次打包。因为如果别人修改你的代码,肯定要重新打包,此时签名必然会改变。对 so 作签名校验是很有必要的,除了防止应用被打包,也可以防止你的 so 被别人盗用。


可以使用如下的代码在 java 中进行签名校验,


private static String getAppSignatureHash(final String packageName, final String algorithm) {
if (StringUtils.isSpace(packageName)) return "";
Signature[] signature = getAppSignature(packageName);
if (signature == null || signature.length <= 0) return "";
return StringUtils.bytes2HexString(EncryptUtils.hashTemplate(signature[0].toByteArray(), algorithm))
.replaceAll("(?<=[0-9A-F]{2})[0-9A-F]{2}", ":$0");
}

对于在 Native 层作签名校验,将上述方法翻译成对应的 JNI 调用即可,这里就不赘述了。


上面是签名校验的逻辑,看似美好,实际上稍微碰到有点破解的经验的就顶不住了。我之前遇到的一种破解上述签名校验的方法是,在自定义 Application 的 onCreate() 方法中读取 APK 的签名并存储到全局变量中,然后 Hook 获取应用签名的方法,并把上述读取到的真实的签名信息返回,以此绕过签名校验逻辑。


2.2 Application 类型校验


针对上述这种破解方式,我想到的第一个方法是对当前应用的 Application 类型作校验。因为他们加载 Hook 的逻辑是在自定义的 Application 中完成的,如果他们的 Application 和我们自己的 Application 类路径不一致,那么可以认定应用为破解版。


不过,这种方式作用也有限。我当时采用这种策略是考虑到有的破解者可能就是用一个脚本破解所有应用,所以改动一下可以防止这类破解者。但是,后来我也遇到一些“狠人”。因为我的软件用了 360 加固,所以如果加固壳工程的 Application 也认为是合法的。于是,我就看到了有的破解者在我的加固包之上又做了一层加固...


2.3 另一种签名校验方法


上述签名校验容易被 Hook 绕过,我们还可以采用另一种签名校验方法。


记得之前在《使用 APT 开发组件化框架的若干细节问题》 这篇文章中提到过,ARouter 在加载 APT 生成的路由信息的时候,一种方式是获取软件的 APK,然后从 APK 的 dex 中获取指定包名下的类文件。那么,我们是不是也可以借鉴这种方式来直接对 APK 进行签名校验呢?


首先,你可以采用下面的方法获取软件的 APK,


ApplicationInfo applicationInfo = context.getPackageManager().getApplicationInfo(context.getPackageName(), 0);
File sourceApk = new File(applicationInfo.sourceDir);

获取 APK 签名信息的方法比较多,这里我提供的是 Android 源码中的打包文件的签名代码,代码位置是:android.googlesource.com/platform/to…


这样,当我们拿到 APK 之后,使用上述方法直接对 APK 的签名信息进行校验即可。


3、对重要信息的加密


上述我们提到了一些常用的加密方法,这里介绍下我在设计软件和系统的时候是如何对用户的重要信息作加密处理的。


3.1 使用签名字段防止伪造信息


首先,我的应用在做用户鉴权的时候是通过服务器下发的字段来验证的。为了防止服务器返回的信息被篡改以及在本地被用户篡改,我为返回的鉴权信息增加了签名字段。逻辑是这样的,



  • 服务器查询用户信息之后根据预定义的规则拼接一个字符串,然后使用 SHA256 算法对拼接后的字符串做不可逆向的加密

  • 从服务器拿到用户信息之后会直接丢到 SharedPreference 中(最好加密之后再存储)

  • 当需要做用户鉴权的时候,首先根据之前预定义的规则,对签名字段做校验以判断鉴权信息是否给篡改

  • 如果鉴权信息被篡改,则默认为普通用户权限


除了上述方法之外,为服务器配置 SSL 证书也是比不可少的。现在很多云平台都会提供一年免费的 Trust Asia 的证书(到期可再续费),免费使用即可。


3.2 对写入到本地的键值对做处理


为了防止应用的逻辑被破解,当某些重要的信息(比如上面的鉴权信息)写入到本地的时候,除了做上述处理,我对存储到 SharedPreference 中的键也做了一层处理。主要是使用设备 ID 和键名称拼接,做 SHA256 加密之后作为键值对的键。这里的设备 ID 就是 ANDROID_ID. 虽然 ANDROID_ID 用作设备 ID 并不可靠,但是在这个场景中它可以保证大部分用户存储到本地的键值对中的键是不同的,也就增加了破解者针对某个键值对进行破解的难度。


3.3 重要信息不要直接使用字符串


在代码中直接使用字符串很容易被别人搜索到,一般对于重要的字符串信息,我们可以将其先转换为整数数组。然后再在代码中通过数组得到最终的字符串。比如下面的代码用来将字符串转换为 short 类型的数组,


static short[] getShortsFromBytes(String from) {
byte[] bytesFrom = from.getBytes();
int size = bytes.length%2==0 ? bytes.length/2 : bytes.length/2+1;
short[] shorts = new short[size];
int i = 0;
short s = 0;
for (byte b : bytes) {
if (i % 2 == 0) {
s = (short) (b << 8);
} else {
s = (short) (s | b);
}
shorts[i/2] = s;
i++;
}
return shorts;
}

3.4 Jetpack 中的数据安全


除了上面的一些方法之外,Android 的 Jetpack 对数据安全开发了 Security 库,适用于运行 Android 6.0 和更高版本的设备。Security 库针对的是 Android 应用中读写文件的安全性。详情可以阅读官方文档相关的内容:



更安全地处理数据:developer.android.com/topic/secur…



4、增强混淆字典


混淆之后可以让别人反编译我们的代码之后阅读起来更加困难。这在一定程度上可以增强应用的安全性。默认的混淆字典是 abc 等英文字母组成,还是具有一定的可读性的。我们可以通过配置混淆字典进一步增加阅读的难度:使用特殊符号、0oO 这种相近的字符甚至 java 的关键字来增加阅读的难度。配置的方式是,


# 方法名等混淆指定配置
-obfuscationdictionary dict.txt
# 类名混淆指定配置
-classobfuscationdictionary dict.txt
# 包名混淆指定配置
-packageobfuscationdictionary dict.txt

一般来说,当我们自定义混淆字典的时候需要从下面两个方面呢考虑,



  1. 混淆字典增加反编译识别难度使代码可读性变差

  2. 减小方法和字段名长度从而减小包体积


对于 o0O 这种虽然可读性变差了,但是代码长度相比于默认混淆字典要长一些,这会增加我们应用的包体积。我在选择混淆字典的时候使用的是比较难以记忆的字符。我把混淆字典放到了 Github 上面,需要的可以自取,



混淆字典:github.com/Shouheng88/…



下面是混淆之后的效果,


QQ截图20220216230706.png


这既可以保证包体积不会增大,又增加了阅读的难度。不过当我们反混淆的时候可能会遇到反混淆乱码的问题,比如 SDK 默认的反混淆工具就有这个问题(工具本身的问题)。


5、so 安全性


对 so 的破解,我现在也没有特别好的方法。之前我已经把一些需要高级权限的逻辑搬到了 native 层,但是最终一样被破解。如果是专业的加固,会对 so 同时做加固。我个人目前对 so 也不是特别熟,之前被破解也是因为 so 的内容被修改。后面会对 so 相关的内容做进一步学习和补充。上面提到的 so 的签名校验可以作为安全性检查之一,下面还有一些开发过程中的其他建议可以做参考。


5.1 不要使用布尔类型作为重要 native 方法的返回类型


使用布尔类型作为 native 方法的返回值的一个不好的地方是,别人破解起来会非常容易。因为对于布尔类型,它只有 true 和 false 两种情况。所以,破解者可以很容易地通过将类地方法修改为直接返回 true 或者 false 来绕开校验的逻辑。相对来收更好的方式是返回一个整数或者字符串。


5.2 校验方法的 native 特性


如果一个方法是 native 方法,我们可以通过判断方法的属性信息来判断这个方法是否被修改。上面提到了有些 native 方法如果直接返回布尔类型,可能直接会被篡改为直接返回 true/false 的形式。此时,破解者就把 native 方法修改为普通的方法。所以,我们可以通过判断方法的 native 特性,来判断这个方法是否被别人做了手脚。下面是一个示例方法,


val method = cls.getMethod("method", Int::class.java)
Modifier.isNative(method.modifiers)

6、不要把校验逻辑封装到一个方法里


把一套逻辑封装成一个方法对于常规业务的开发是一个好的习惯。但是把权限校验的逻辑封装到一个方法中就不一定了。因为别人只要把注意力方法在你的这一个方法上面就足够了。这样,只要破解了这一个方法就可以破解你的应用中所有的安全校验逻辑。


但是如果把同一个权限校验的逻辑在所有需要做权限校验的地方都拷贝一份,后续代码维护起来也会非常困难。那么有没有比较折衷的手段,既可以实现逻辑集中维护,又可以把权限校验的逻辑分散到各个需要做权限校验的地方呢?答案是有,只不过要求应用中使用的是 kotlin 语言。


使用 inline 实现权限校验集中管理和分散调用:inline 是 kotlin 的一个关键字,效果类似于 C 语言中的内联。编译的时候会将 inline 方法中的逻辑内联到调用的地方。我们只需要将我们的权限校验的逻辑写到 inline 方法中,然后在需要鉴权的地方调用这个 inline 方法,就可以实现权限校验集中管理和分散调用。这样如果需要破解我们的校验逻辑,需要到每个地方依次进行破解。


此外,


1、权限校验的逻辑最好和业务代码交织在一起而不是分开写。原因如上,分开写别人只要破解这一个方法就够了。
2、C/C++ 层也可以尝试使用 inline 方法


7、使用服务器做安全校验


上面也说了最好的安全措施还是把重要的逻辑放到后端。不过,对于我开发的应用,因为它本身基本是离线使用的,所以,无法在操作过程中使用服务器做鉴权。对此,我使用了两个方案来让服务器参与到防破解中。


其一是,启用版本配置,在应用配置中下发强制升级信息。最初为应用设计服务器的时候我就设计了应用从后端拉取配置信息的接口。这个接口也会同时下发应用的版本信息以及升级的类型。如果是强制升级,那么会弹出一个无法取消的对话框。这样这个版本基本就无法继续使用了。通过这个配置,我们可以通过服务器配置直接禁用被破解的应用版本。


其二,在执行需要高级权限的操作的时候上报服务器。服务器通过后端存储的用户信息判断该用户是否具备该权限。如果不具备权限,那么增加一条违规记录,并记录违规用户的用户信息。后台通过可以配置的形式对单一用户进行禁用。至于这里为什么不直接对用户进行禁用的问题。正如《七武士》中的一个桥段一样,好的防守总是会留一个入口。直接禁用很容易被破解者发现并做相应处理。


另外,最好不要直接抛出异常,弹出的 toast 不要使用明文字符串。因为,上述两种方式都很容易让别人直接定位到我们校验的逻辑的位置。如果不得不抛异常,建议触发 OOM!


总结


写了那么多东西,我也无奈,破解比反破解要容易得多,以上是我在实践过程中总结的一些基本的技巧。对于 Android 应用安全,我还有很多东西需要学习和了解。毕竟,对于应用层开发来说,安全是另一个专业领域的事情。我也只能“防君子不防小人”。后续我学习了更多的内容,做了更多的攻防战,总结更多经验之后再补充。唉,“本是同根生,相煎何太急”!


作者:开发者如是说
来源:juejin.cn/post/7079794266045677575
收起阅读 »

初探 Kotlin Multiplatform Mobile 跨平台原理

一、背景 本文会尝试通过 KMM 编译产物理解一套 kt 代码是如何在多个平台复用的。 KMM 发流程简介 我以开发一个 KMM 日志库为例,简单介绍开发流程是什么: 在 CommonMain 定义接口,用 expect 关键字修饰,表示此接口在不同平台的...
继续阅读 »



一、背景



本文会尝试通过 KMM 编译产物理解一套 kt 代码是如何在多个平台复用的。


KMM 发流程简介


我以开发一个 KMM 日志库为例,简单介绍开发流程是什么:



  1. 在 CommonMain 定义接口,用 expect 关键字修饰,表示此接口在不同平台的实现不一样。

  2. 在具体平台实现接口,并用 actual 关键字修饰


// ----- commonMain -----

expect fun log(tag: String, msg: String)

// ----- androidMain -----

actual fun log(tag: String, msg: String) {
Log.i(tag, msg)
}

// ----- iosMain -----

actual fun log(tag: String, msg: String) {
NSLog("$tag:: %s", msg)
}


  1. 编译、打包、发布


publish_artifacts.png



  1. 依赖具体平台仓库

    1. 如果宿主为 Android App,则依赖对应的 kmm-infra-android

    2. 如果宿主为 iOS App,需要现将 kmm-infra-iosarm64 打包成 Framework,然后 iOS 依赖 Framework

    3. 如果宿主为 KMM 库,则依赖 kmm-infra




二、Common 和具体平台的联系



了解 KMM 基本的开发流程和发布产物后,我们需要继续深入了解发布产物的结构,再来理解 Common 层代码和具体平台代码是如何建立联系的。



Common 层编译产物


├── kmm-infra
   ├── 1.0.0-SNAPSHOT
      ├── kmm-infra-1.0.0-SNAPSHOT-kotlin-tooling-metadata.json
      ├── kmm-infra-1.0.0-SNAPSHOT-sources.jar
      ├── kmm-infra-1.0.0-SNAPSHOT.jar
      ├── kmm-infra-1.0.0-SNAPSHOT.module
      ├── kmm-infra-1.0.0-SNAPSHOT.pom
      └── maven-metadata-local.xml


  • kotlin-tooling-metadata.json,存放了编译工具的相关信息,比如 gradle 版本、KMM 插件版本以及具体平台编译工具的信息,比如 jvm 平台会有 jdk 版本,native 平台会有 konan 版本信息

  • source.jar,Kotlin 源码

  • .jar,存放 .knm (knm是什么,后文会具体介绍) ,其中描述了 expect 的接口

  • .module,见下文


.module 是什么?


用 json 描述编译产物文件结构的清单文件,以及关联 common 和具体平台产物的信息。里面描述的字段较多,我只放一些关键信息,剩余内容感兴趣的读者可以自己研究


{
"variants": [
{
"name": "",
"attributes": {
"org.gradle.category": "",
"org.gradle.usage": "",
"org.jetbrains.kotlin.platform.type": ""
}
"available-at": {
"url": "",
},
"dependencies": [
{
"group": "org.jetbrains.kotlin",
"module": "kotlin-stdlib-common",
"version": {
"requires": "1.8.0"
}
}
]
}
]
}



  • name,当前产物的名称,比如 common 层为 metadataApiElements,具体平台为 {target}{Api/Metadata}Elements-published




  • available-at,具体平台特有的字段,其中 url 指的是具体平台 .module 的文件路径,作为关联 common 和具体平台的桥梁




  • dependencies,描述有哪些依赖




具体平台的 .module


为方便大家更好的理解,这里还是贴出一份完整的 iOS 平台的 .module 文件


{
"formatVersion": "1.1",
"component": {
"url": "../../kmm-infra/1.0.0-SNAPSHOT/kmm-infra-1.0.0-SNAPSHOT.module",
"group": "com.gpt.jarvis.kmm",
"module": "kmm-infra",
"version": "1.0.0-SNAPSHOT",
"attributes": {
"org.gradle.status": "integration"
}
},
"createdBy": {
"gradle": {
"version": "7.4.2"
}
},
"variants": [
{
"name": "iosArm64ApiElements-published",
"attributes": {
"artifactType": "org.jetbrains.kotlin.klib",
"org.gradle.category": "library",
"org.gradle.usage": "kotlin-api",
"org.jetbrains.kotlin.native.target": "ios_arm64",
"org.jetbrains.kotlin.platform.type": "native"
},
"dependencies": [
{
"group": "org.jetbrains.kotlin",
"module": "kotlin-stdlib-common",
"version": {
"requires": "1.8.0"
}
}
],
"files": [
{
"name": "kmm-infra.klib",
"url": "kmm-infra-iosarm64-1.0.0-SNAPSHOT.klib",
"size": 6396,
"sha512": "2ebdb65f7409b86188648c1c9341115ab714ad5579564ce4ec0ee7fb6e0286351f01d43094bc7810d59ab1c4d4fa7887c21ce53bc087c34d129309396ceb85a5",
"sha256": "056914503154535806165c132df52819aedcc93a7b1e731667a3776f4e92ff79",
"sha1": "c43ed6cb8b5bf3f40935230ce3a54b2f27ec1d6a",
"md5": "d79166eda9f4bf67f5907b368f9e9477"
}
]
},
{
"name": "iosArm64MetadataElements-published",
"attributes": {
"artifactType": "org.jetbrains.kotlin.klib",
"org.gradle.category": "library",
"org.gradle.usage": "kotlin-metadata",
"org.jetbrains.kotlin.native.target": "ios_arm64",
"org.jetbrains.kotlin.platform.type": "native"
},
"dependencies": [
{
"group": "org.jetbrains.kotlin",
"module": "kotlin-stdlib-common",
"version": {
"requires": "1.8.0"
}
}
],
"files": [
{
"name": "kmm-infra-iosarm64-1.0.0-SNAPSHOT-metadata.jar",
"url": "kmm-infra-iosarm64-1.0.0-SNAPSHOT-metadata.jar",
"size": 5176,
"sha512": "fa828f456c3214d556942105952cb901900a7495f6ce6030e4e65375926a6989cd1e7b456f772e862d3675742ce2678925a0a12a1aa37f4795e660172d31bbff",
"sha256": "c4de0db2b60846e3b0dbbd25893f3bd35973ae790696e8d39bd3d97d443a7d4c",
"sha1": "e59036a081663f5c5c9f96c72c9c87788233c8bc",
"md5": "9293e982f84b623a5f0daf67c6e7bb33"
}
]
}
]
}


iOS 平台编译产物


我们其实可以通过上面 iOS 平台 .module 文件看到一些描述,有 metadata.jar.klib


├── kmm-infra-iosarm64
│   ├── 1.0.0-SNAPSHOT
│   │   ├── kmm-infra-iosarm64-1.0.0-SNAPSHOT-metadata.jar
│   │   ├── kmm-infra-iosarm64-1.0.0-SNAPSHOT-sources.jar
│   │   ├── kmm-infra-iosarm64-1.0.0-SNAPSHOT.klib
│   │   ├── kmm-infra-iosarm64-1.0.0-SNAPSHOT.module
│   │   ├── kmm-infra-iosarm64-1.0.0-SNAPSHOT.pom
│   │   └── maven-metadata-local.xml
│   └── maven-metadata-local.xml
└── kmm-infra-iosx64
├── 1.0.0-SNAPSHOT
│   ├── kmm-infra-iosx64-1.0.0-SNAPSHOT-metadata.jar
│   ├── kmm-infra-iosx64-1.0.0-SNAPSHOT-sources.jar
│   ├── kmm-infra-iosx64-1.0.0-SNAPSHOT.klib
│   ├── kmm-infra-iosx64-1.0.0-SNAPSHOT.module
│   ├── kmm-infra-iosx64-1.0.0-SNAPSHOT.pom
│   └── maven-metadata-local.xml
└── maven-metadata-local.xml


  • metadata.jar,主要存放了 .knm

  • .klib,也存放了 metadata.jar 中相同的内容,除此以外还有 ir,方便编译器后端继续编程机器码

  • 如果不了解 ir 是什么,可以参考我之前写的 Kotlin Compiler】IR 介绍


.knm 和 .klib 是什么?后文会具体介绍


三、.klib 和 .knm 文件




  1. klib 的文件结构是怎样的?

  2. .knm 是什么文件?为什么只能用 IDEA 浏览?



klib 文件结构


klib 指 Kotlin Library


klib
├── ir
│   ├── bodies.knb
│   ├── debugInfo.knd
│   ├── files.knf
│   ├── irDeclarations.knd
│   ├── signatures.knt
│   ├── strings.knt
│   └── types.knt
├── linkdata
│   ├── module
│   ├── package_com
│   │   └── 0_com.knm
│   ├── package_com.jarvis
│   │   └── 0_jarvis.knm
│   ├── package_com.jarvis.kmm
│   │   └── 0_kmm.knm
│   ├── package_com.jarvis.kmm.infra
│   │   └── 0_infra.knm
│   └── root_package
│   └── 0_.knm
├── manifest
├── resources
└── targets
└── ios_arm64
├── included
├── kotlin
└── native


.knm 的生成过程


knm 指 kotlin native metadata


kt2knm.svg



  1. .kt 经过编译器 frontend, 生成 kotlinIr

  2. 经过 protobuf 序列化后,生成 .knm 文件,这也解释了 vim 打开是乱码的原因

  3. .knm 通过反序列化可以得到 KotlinIr

  4. KotlinIr 通过反编译可以得到代码的细节,这正是在 IDEA 里能看到 .knm 是什么的原因


使用安装 Kotlin Plugin 的 IDEA 查看 knm 文件


idea_knm.png


使用 vim 查看 knm 文件


vim_knm.png


四、iOS 和 KMM 库的关系



iOS 中的依赖库是一组 .h 和二进制文件,所以 KMM 库最终一定要转成 .h 和二进制文件。
KMM 中,iOS 平台的编译产物是 klib


问题:



  1. Kotlin 是怎样依赖并调用 iOS Objective-C 库的?

  2. iOS 是如何使用 KMM 库的?


为了解释上面的两个问题,需要了解 KMM 和 OC 互操作的机制(互相调用),以及 klib 是如何打包



OC 互操作流程


interop_ios.png



  1. Copy iOS 工程中需要用到的 .h 文件(此处也可以直接在 KMM 工程中通过 Cocoapods 插件直接依赖 pod 库)

  2. .h 文件通过 cinterop 工具生成 klib,由于 kotlin 不认识 oc 的 .h,所以需要通过 klib 将 .h 转成 kotlin 认识的形式后才能调用

  3. 将开发完成的 kotlin 代码编译打包,通过 fatFramework 工具输出最终 .h 和二进制文件

  4. iOS 依赖 Umbrella.h 和二进制文件,此流程已经走到 iOS 原生端,和 KMM 无关了


FatFrameWork 流程


assemble_ios.png



  1. KMM 工程打包 klib 并上传

  2. KMM_Umbrella (依赖了很多 KMM 库的全家桶工程) 工程拉取 klib 依赖

  3. 执行 iosFatFramework 任务,输出最终 framework.h 和二进制文件

    • klib 中的 ir 通过 kotlin 编译器后端,编译成对应平台的二进制文件

    • 链接

    • 合并不同架构的二进制文件,比如 iosArm64 iosX64,具体可参考【mac】lipo命令详解

    • 合并头文件

    • 创建 .modulemap 文件,具体细节可以参考 理解 iOS 中的 Modules

    • 生成 info.plist ,此文件是对 framework 的描述清单文件

    • 合成 DSYM( Debugger Symbols) 文件




最终输出结构如下


fat-framework
└── debug
└── KMMUmbrellaFramework.framework
├── Headers
│   └── KMMUmbrellaFramework.h
├── Info.plist
├── KMMUmbrellaFramework
└── Modules
└── module.modulemap


总结


conclusion.png



  1. 通过在 Common 层定义 expect 接口,生成 .knm,以及关联具体平台信息的 .module

  2. 在具体平台通过 actual 实现接口,生成 .klib/.aar/.jar

  3. Android 平台比较特殊,因为 Kotlin 以前只能编译成 JVM 字节码,不存在 ir 概念,K2 Compiler 出现后,统一抽象了编译流程,使得 JVM 也有了自己的编译器后端,也可以通过 IR 编译为 JVM 字节码

  4. iOS 平台通过 .klib 存放 ir,然后经过编译器后端打成 iOS 可以使用的 .framework

  5. 将对应产物接入到对应平台工程


通过对 KMM 编译产物的探索,能让我们更好地理解 KMM 是如何实现跨平台的。


参考



作者:ZzT
来源:juejin.cn/post/7214412608400212028
收起阅读 »

里程碑!ChatGPT插件影响几何?

上周三OpenAI推出了GPT-4,引起了全球轰动,仅仅过去一周多时间,OpenAI又宣布推出插件功能。如果说ChatGPT是AI的“iPhone时刻”,那么插件就是ChatGPT的“App Store”。超强的开发迭代能力,层出不穷的王炸级新产品,让我们不得...
继续阅读 »

上周三OpenAI推出了GPT-4,引起了全球轰动,仅仅过去一周多时间,OpenAI又宣布推出插件功能。如果说ChatGPT是AI的“iPhone时刻”,那么插件就是ChatGPT的“App Store”。超强的开发迭代能力,层出不穷的王炸级新产品,让我们不得不对OpenAI由衷赞叹。


插件发布


3月24日,OpenAI宣布ChatGPT能够支持第三方插件接入,同时为ChatGPT发布了多个插件,它们将帮助ChatGPT联网实时检索信息、运行计算或使用第三方服务。目前装上插件后,用户可以用 ChatGPT 执行以下操作:




  • 检索实时信息:例如体育比赛比分、股价、最新消息等;




  • 检索知识库信息:例如公司文件、个人笔记等;




  • 代表用户执行操作:例如,订机票、订餐等。




由此可以看出,ChatGPT不再是一个单一的知识问答机器人,它将完全走进我们的日常工作和生活,成为全天候平台生态系统。


image.png


根据OpenAI官方发布信息,ChatGPT的第一批第三方插件包含Expedia、FiscalNote、Instacart、KAYAK、Klarna、Milo、OpenTable、Shopify、Slack、Speak、Wolfram、Zapier等11种。例如Open Table插件允许用户搜索可预订的餐厅,Wolfram插件能够提高计算能力,Instacart插件允许ChatGPT在本地商店购物,Zapier可与谷歌Gmail等应用程序连接,适用于办公场景。除了第三方插件,OpenAI同时推出了自己的两款插件——网络浏览器(web browser)和代码解释器(code interpreter)。


网络浏览器


网络浏览器(web browser)是此次最受大家关注的插件,该插件使用微软必应搜索API从网络上检索内容,同时显示它在概括答案时所访问的网站,并在ChatGPT的回应中引用其来源。此前的ChatGPT包括GTP-4训练数据库的信息内容仅限于2021年9月之前的数据,现在通过该插件ChatGPT可以获得网络上最新的数据,极大提高问题回答的时效性。


例如下面的问题中,ChatGPT可以给出2023年的奥斯卡颁奖名单。


image.png


ChatGPT以往是被动接收数据进行推理,开发人员给它投喂大量历史数据,基于这些数据进行推理,从而给出答案。因此,ChatGPT能够获得巨大成功的原因是其出色的推理能力,人总是处于学习知识的过程,要求的是知识回答准确度,对于新鲜度反而要求不高,出色的推理能力有效保证了知识回答的准确性和条理性。相信掘友们没少让ChatGPT写诗,写小说,写代码,这些出色创作并不需要从网络上去获取最新的内容。


但随着大家对ChatGPT的越来越熟悉,使用需求也越来越多,从知识问答扩展到日常生活、工作和出行,个人事务、财务和交际,这就需要当前最新的回答,需要ChatGPT从最新网络数据去检索、分析和推理,也就是从被动接收数据变为主动获取数据,ChatGPT具备了眼睛和耳朵,能够去听去看,去与当前的网络世界进行同步。


代码解释器


代码解释器则能够执行数学问题、数据分析、可视化和文件格式转换等任务,使ChatGPT能够使用Python并在沙盒环境中处理上传和下载任务。简单来说,代码解释器能够为你复杂的问题直接提供代码,省去你大部分的编码工作,也能够阅读代码,修改代码bug。


根据官方的例子,当问到:一块砖的重量是这块砖自身 30% 的重量与 5 个 GPU 的重量之和,已知每吨重量包含了 100 块砖和 200 个 GPU,那么一块砖的重量是多少?


image.png


经过代码解释器后,给出了代码和答案


image.png


这里我们可以看到,回答问题不再简单的文字描述,而是升级到源码描述,说实话惊叹之余感觉到了一丝丝的危机感,假以时日,随着ChatGPT能力不断提升,如果能够完成系统级编程,程序员门槛会不断降低,对于大多数程序员都将形成冲击。


平台生态


通过上面对这次插件的分析可以看到,ChatGPT不再满足于单纯作为一个聊天机器人,插件功能就是ChatGPT的应用商店。在3月34日之前,我们认为的合作关系是ChatGPT接入到其他APP,为其他APP赋能,属于从属低位。在3月34日之后,我们发现所有APP将纳入ChatGPT,形成ChatGPT生态系统,ChatGPT去指挥其他APP工作,属于主导地位。


ChatGPT试图通过插件功能建立生态系统。在这个生态系统中,开发人员可以创建并发布他们自己的插件,借助ChatGPT获取用户和收益。目前手机厂商的应用商店也是这样,第三方应用经过应用商店安装,消费者发生购买行为后,应用商店可以进行抽成获取利润。三个月前,我们担心ChatGPT训练成本非常高,但没有良好的商业化模式,发展困难。现在插件功能出来后,没有一个人会再去担心ChatGPT商业能力,反而是担心自己能不能搭上人工智能的快车,自己会不会被时代所淘汰。


微软魄力


说完OpenAI的重大突破,回过头来,不得不佩服微软的魄力和决心。在必应接入GTP后,必应搜索引擎的访问量增长15%,谷歌降低了1%,一片形势大好,接入GTP的产品就是New Bing,因此这次插件功能的发布最尴尬的反而是New Bing。拥有插件功能的ChatGPT明显比搜索引擎属性加身的New Bing更有发展前景,对于市场前景来说,New Bing基本等同于完整版的必应,微软推陈出新,斩人先斩己。


这与腾讯有点相似,在QQ还是即时通信老大的情况下,坚决推出了微信,时至今日,微信建立起的生态帝国让腾讯无往不利。如果说插件功能即将构建的生态系统,未来将匹敌甚至超过App Store和谷歌搜索的商业价值,已经不会让人觉得是异想天开了。


总结


从GTP3.5到GTP4,从微软Office Copilot到ChatGPT插件,AI技术的演进和应用正以超乎想象的速度袭来,在过去的这几个月,尤其这两周,我们听惯了重磅、史诗、王炸等词,面对日新月异的技术发展,对于个人来说,我们需要选择拥抱新的领域,新的技术,通过了解来调整自己的工作方式和学习目标;对于企业来说,需要熟悉AI技术的现状和未来预期,积极评估面临的冲击和机遇。


作者:柒号华仔
来源:juejin.cn/post/7214489453399474232
收起阅读 »

用ChatGPT提高开发效率(Andoid)

一、前言 我们问了10个问题,开发的、生活中有疑问的,ChartGPT都给了我们答案。这些答案怎么样,请往下看吧。 ChatGPT-1、写一个车牌号的正则表达式 ChatGPT-2、写一个中国大陆车牌号的正则表达式 ChatGPT-3、用kotlin写一个车...
继续阅读 »

一、前言


我们问了10个问题,开发的、生活中有疑问的,ChartGPT都给了我们答案。这些答案怎么样,请往下看吧。



  • ChatGPT-1、写一个车牌号的正则表达式

  • ChatGPT-2、写一个中国大陆车牌号的正则表达式

  • ChatGPT-3、用kotlin写一个车牌号的校验函数

  • ChatGPT-4、用Kotlin写一个String装换成金额的扩展函数

  • ChatGPT-5、用Kotlin写一个String装换成人民币的扩展函数

  • ChatGPT-6、用Kotlin写一个String转换成人民币的扩展函数

  • ChatGPT-7、用Kotlin写一个将TextView的文本内容复制到粘贴板的扩展函数

  • ChatGPT-8、美食探店怎么做?

  • ChatGPT-9、台湾卤肉饭?

  • ChatGPT-10、牛皮弃面馆(柏庐南路店)


二、ChatGPT使用初体验


ChatGPT-1、写一个车牌号的正则表达式
企业微信截图_16796251027919.png


ChatGPT-2、写一个中国大陆车牌号的正则表达式
企业微信截图_1679625500776.png
^[京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼][A-HJ-NP-Z](?!DD)[A-HJ-NP-Z0-9]{4}[A-HJ-NP-Z0-9挂学警港澳]$


ChatGPT-3、用kotlin写一个车牌号的校验函数
企业微信截图_16796251209911.png


三、ChatGPT PK 5年+年程序员


ChatGPT-4、用Kotlin写一个String装换成金额的扩展函数
企业微信截图_16796251209911.png


ChatGPT-5、用Kotlin写一个String装换成人民币的扩展函数
企业微信截图_16796251209911.png


ChatGPT-6、用Kotlin写一个String转换成人民币的扩展函数
1111.png
PK
image2023-3-24_11-2-10.png


ChatGPT-7、用Kotlin写一个将TextView的文本内容复制到粘贴板的扩展函数
image2023-3-24_11-2-10.png
PK
image2023-3-24_11-3-0.png


四、总结


ChatGPT-1、写一个车牌号的正则表达式

ChatGPT-2、写一个中国大陆车牌号的正则表达式

ChatGPT-3、用kotlin写一个车牌号的校验函数


以上问题,明确的告诉了我们答案,并且配上了讲解和使用方法。


极大的减少了我们自己写正则的错误概率,或者网上查,答案质量参差不齐的筛选验证的烦恼。


对这种重复劳动、通用的功能函数,ChatGPT很好用


ChatGPT-4、用Kotlin写一个String装换成金额的扩展函数

ChatGPT-5、用Kotlin写一个String装换成人民币的扩展函数

ChatGPT-6、用Kotlin写一个String转换成人民币的扩展函数

ChatGPT-7、用Kotlin写一个将TextView的文本内容复制到粘贴板的扩展函数


以上问题,实现了在开发过程中实际的问题,直接Copy就可以使用


请注意我的问题,用Kotlin写一个String转换成人民币的扩展函数,只要你问题问的明确,ChatGPT就会给你想要的答案


ChatGPT-4、ChatGPT-5转换写错了,写成了装换,ChatGPT理解了我的意思,并且他自我纠正了


ChatGPT-6、ChatGPT-7,ChatGPT与5年+程序员的PK,功能都实现了,实现思路基本相同。但是5+程序员写的更简洁,自由 (程序员的个人素质了,Lewis对个人要求标准较高,这就是通往大佬的之路)。


你在通往IT大佬的路上,不要轻视ChatGPT哦,我们使用的ChatGPT是通用版,训练出来的模型是面向所有用户的。


一个假设,如果使用Githut上所有的代码训练ChatGPT,训练出来的模型还会比不过5年的程序员吗?Githut+ChatGPT好像都为微软的,这个假设可能已经在实验室阶段了。


以上假设已经有了,GitHub Copilot X GitHub + GPT-4联手的产品


五、ChatGPT最后的胡说八道


ChatGPT-8、美食探店怎么做?
企业微信截图_16796255651978.png


ChatGPT-9、台湾卤肉饭?
企业微信截图_16796256304717.png


ChatGPT-10、牛皮弃面馆(柏庐南路店)
企业微信截图_1679625678718.png


ChatGPT-8、美食探店怎么做?

ChatGPT-9、台湾卤肉饭?

ChatGPT-10、牛皮弃面馆(柏庐南路店)


以上问题,初一看ChatGPT回答的很专业,以下详细说一下


ChatGPT-8、ChatGPT-9,很多人都有疑问的问题,网上资料很多,回答的很专业,也很有条理,挺好。作为一个技术就不都说了,可以看看这个「打不过,就加入」,我和ChatGPT的故事


ChatGPT-10,昆山一家小店,没有名气,主打台湾牛肉面和台湾小吃,ChatGPT就开始胡说八道了,但是显得很专业,他谈的模板就是按照逻辑去介绍一家店。如果你问他一家有名的店或者连锁店(例如:海底捞),他会回答的应该很专业。


其实还有很多疑问的,移动开发未来前景怎么样?ETH今天会不会大跌,短线做空可以吗?,但是毕竟今天周五了,让自己过一个愉快的周末吧^_^。


六、延伸阅读


mp.weixin.qq.com/s/PyH2SzFQ9…


juejin.cn/post/721177…


juejin.cn/post/719767…


mp.weixin.qq.com/s/prXyAUf_8…


mp.weixin.qq.com/s/Y1LUxcInd…


mp.weixin.qq.com/s/a3xZZLRgD…


作者:佳应科技
来源:juejin.cn/post/7215020908238209083
收起阅读 »

我的家庭观影方案

前言 一直都想做到在多个设备上都能够观看电影、追剧,甚至不需要手动下载新剧集。 本来网络平台能够解决我的需求,但是以下问题让我望而却步: 国内的爱优腾平台资源有限,而且会员广告、超前点播等骚操作层出不穷,许多影视资源都没法正常观看(删减)或者根本没有上架。 ...
继续阅读 »

前言


一直都想做到在多个设备上都能够观看电影、追剧,甚至不需要手动下载新剧集。


本来网络平台能够解决我的需求,但是以下问题让我望而却步:



  • 国内的爱优腾平台资源有限,而且会员广告、超前点播等骚操作层出不穷,许多影视资源都没法正常观看(删减)或者根本没有上架。



  • 国外的流媒体平台需要科学手段才能访问,而且资源也越来越分散到包括HBONetflixDisney在内的几个平台上,不再是Netflix一家独大,订阅费也不便宜。



  • 第三方网站不是很稳定,而且码率较低。


因此,我决定采取以下方案:先将影片离线下载到本地,再进行播放。


 


以前一直没能付诸实践,最近刚好有空余时间,并且还有许多剧集想要看,所以来试试这个方案,下面记录我在实践中的折腾过程。


一些方案的转变


SMB + Kodi / infuse + qBittorrent



  • 用我的 windows 电脑24小时开机,充当一个SMB服务器的角色,把影视文件夹共享出来;



  • 下载则使用qBittorrent通过BT和PT站点下载一些资源;



  • 电视端使用Kodi连接SMB服务器进行播放,iPad则用infuse观看。


这个方案其实能用,但是不是很优雅,多端设备的播放记录也无法同步是一个大问题。


Jellyfin + qBittorrent


Jellyfin是一款免费、开源的媒体服务器软件,可以让你在家中的任何设备上流式传输和管理你的媒体文件。



  • 在电脑上安装一个 jellyfin server



  • 各端使用jellyfin客户端即可


这个方案也不错,但是使用下来也有几个问题:



  1. Jellyfin的客户端支持不如Kodi和infuse,有些字幕格式、视频格式只能通过服务端解码;



  1. 最重要的是,Jellyfin的界面在我的电视上显示得不是很美观,而且有时候会出现一些小毛病。


 


现在的方案


文件共享方案


由于之前SMB挂在电脑上需要整天开机,并且对机器的硬盘有损害,所以现在给软路由加了一块移动硬盘来存放共享文件,算是一个轻量的NAS系统了



  • 这里用的是luci-app-samba4这个插件


notion image



  • 需要先挂载一下硬盘,这里我用的ntfs格式的硬盘,可以用ntfs-3g来挂载,开机启动时挂载一下即可 mount -t ntfs-3g /dev/sdb2 /mnt/sdb


notion image



  • 最后只需要配置共享名称,路径,用户名参数就可以开始使用了(可以创建一个单独的用户用于共享)


image.png


剧集管理


因为又想达到自动追剧的目的,这里是用的一个比较常见的方案:


下载软件


因为文件统一存放在软路由上,因此下载也转移到软路由上,也方便24小时做种。


这里直接用了docker 作为容器层来管理,镜像是 linuxserver 管理的 qbit 镜像 docker-qbittorrent,对内网暴露 webui 端口来达到管理的目的


剧集追踪


Sonarr 负责剧集的管理追踪,像这里只需要添加剧集并追踪,就可以自动下载所有剧集,并且更新后第一时间完成下载,这里也用了 linuxserver 的docker镜像


image.png
这里追踪的规则也挺多的,不赘述;也可以手动勾选来完成精细化的控制


notion image


电影追踪


Radarr 负责电影的追踪,与 Sonarr 类似,界面还支持中文 i18n,这里同样用了 linuxserver 的docker镜像


image.png


搜刮器


Prowlarr 搜刮器是用于Sonarr和Radarr对于一些BT、PT站资源的访问,相当于一个转发聚合层,这里再次用了 linuxserver 的docker镜像


支持大多数BT、PT站,手动添加即可


image.png


image.png
添加后在这里添加Sonarr和Radarr客户端即可


notion image


媒体服务器


现在我媒体服务器用的是 Plex,免费的服务对我来说就足够了,已经可完成资源的整理和展示



  • Plex 作为我的媒体服务器,可以在 server 端存储我的观影记录,而且客户端支持很好,不易出现播放不流畅的情况;



  • 媒体文件则通过qBittorrent进行离线下载,然后传输到Plex的媒体库中;



  • 各端使用 Plex 客户端,infuse,kodi 挂载均可


成果


这是最后的效果,可以看到漂亮的海报墙


image.png


总结


目前整套方案结构如下


notion image


作者:Hongzzz
来源:juejin.cn/post/7213332433168990269
收起阅读 »

大喊一声Fuck!代码就能跑了是什么体验?

大喊一声Fuck!代码就能跑了是什么体验? 1 前言 大家好,我是心锁,23届准毕业生。 程序员的世界,最多的不是代码,而是💩山和bug。 近期我在学习过程中,在github找到了这么一个项目,能在我们输错命令之后,大喊一声Fuck即可自动更正命令,据说喊得越...
继续阅读 »

大喊一声Fuck!代码就能跑了是什么体验?


1 前言


大家好,我是心锁,23届准毕业生。


程序员的世界,最多的不是代码,而是💩山和bug。


近期我在学习过程中,在github找到了这么一个项目,能在我们输错命令之后,大喊一声Fuck即可自动更正命令,据说喊得越大声效果越好。


c37237b03e45fed8c2828c6f7abb93b9


2 项目基本介绍


thefuck是一个基于Python编写的项目,它能够自动纠正你在命令行中输入的错误命令。如果你输错了一个命令,只需要在命令行中输入“fuck”,thefuck就会自动纠正你的错误。该项目支持众多的终端和操作系统,包括Linux、macOS和Windows。


43885f5e1f8c7ff2b3392d297c855609


2.1 环境要求



  • python环境(3.4+)


2.2 安装方式


thefuck支持brew安装,非常方便,在macOS和Linux上都可以通过brew安装。


brew install thefuck

也支持通过pip安装,便携性可以说是一流了。


pip3 install thefuck

2.3 配置环境变量


建议将下边的代码配置在环境变量中(.bash_profile.bashrc.zshrc),不要问为什么,问就是有经验。


eval $(thefuck --alias)
eval $(thefuck --alias FUCK)
eval $(thefuck --alias fuck?)
eval $(thefuck --alias fuck?)

接着运行source ~/.bashrc(或其他配置文件,如.zshrc)确认更改立即可用。


3 使用效果


Untitled


03cf7e926946b7d8a3da902841c3c5b1


4 thefuck的工作原理


thefuck的工作原理非常简单。当你输入一个错误的命令时,thefuck会根据你输入的命令和错误提示自动推测你想要输入的正确命令,并将其替换为正确的命令。thefuck能够自动推测正确的命令是因为它内置了大量的规则,这些规则能够帮助thefuck智能地纠正错误的命令。


所以,该项目开放了自定义规则。


4.1 创建自己的规则


如果thefuck内置的规则不能够满足你的需求,你也可以创建自己的规则。thefuck的规则是由普通的Python函数实现的。你可以在~/.config/thefuck/rules目录下创建一个Python脚本,然后在其中定义你的规则函数。


以创建一个名为my_rule的规则为例,具体步骤如下:


4.1.1 创建rule.py文件


~/.config/thefuck/rules目录下创建一个Python脚本,比如my_rules.py


4.1.2 遵循的规则


在自定义脚本中,必须实现以下两个函数,match显然是用来匹配命令是否吻合的函数,而get_new_command则会在match函数返回True时触发。


match(command: Command) -> bool
get_new_command(command: Command) -> str | list[str]

同时可以包含可选函数,side_effect的作用是开启一个副作用,即除了允许原本的命令外,你可以在side_effect做更多操作。


side_effect(old_command: Command, fixed_command: str) -> None

5 yarn_uninstall_to_remove


以创建一个名为yarn_uninstall_to_remove的规则为例,该规则会在我们错误使用yarn uninstall …命令时,自动帮助我们修正成yarn remove … 。具体步骤如下:


5.1 创建yarn_uninstall_to_move.py文件


~/.config/thefuck/rules目录下创建一个Python脚本,yarn_uninstall_to_remove.py


5.2 编写代码


from thefuck.utils import for_app

@for_app('yarn')
def match(command):
return 'uninstall' in command.script

def get_new_command(command):
return command.script.replace('uninstall', 'remove')

priority=1 # 优先级,数字越小优先级越高

5.3 效果


Untitled


6 总结


世界之大,无奇不有。不得不说的是,伴随着AI的逐渐发展,类似这种项目未来一定是优先接入AI者才可以继续发展。


友情提示,喊fuck的时候先设置后双击control打开听写功能,喊完再点击一下control完成输入。


Untitled





作者:源心锁
来源:juejin.cn/post/7213651072145244221
收起阅读 »

怎么算是在工作中负责?

作为打工人,受人之禄,忠人之事。但就像呼兰说的,躺有躺的价格,卷有卷的价格。身为程序员,我们在平时工作中要做到怎样才能算是“负责”了呢? 我们可以把工作边界和范围分为三部分: 个人基本能力 工作内容 工作时间 对自己的基本能力负责 基本能力包括两部分:1)...
继续阅读 »

作为打工人,受人之禄,忠人之事。但就像呼兰说的,躺有躺的价格,卷有卷的价格。身为程序员,我们在平时工作中要做到怎样才能算是“负责”了呢?


我们可以把工作边界和范围分为三部分:



  • 个人基本能力

  • 工作内容

  • 工作时间


对自己的基本能力负责


基本能力包括两部分:1)技术能力,2)熟悉公司系统的能力。


程序员是一个非常需要持续学习的职业,我们在实际工作中,遇到自己不会的问题在所难免,这时可以向别人请教,但是千万不要觉得请教完就没事儿了,我们需要思考复盘自己为什么不会,要想办法补齐自己的知识和技能短板。


我们学的东西一定要在实际工作中使用,这样才能够激发学习的积极性,同时验证自己的学习成果。当公司准备技术升级或者技术转型时,这也是我们为自己的技能升级的好机会。


很多公司都会有自己的内部系统,熟练掌握和使用这些系统,也是我们需要做到的,它的价值在于,内部系统一般都是和公司的整个监控系统集成好的,例如公司内部的SOA框架或者微服务框架,都是和公司内部的监控系统有集成的,即使这个框架再“不好”,公司内部的项目还是会使用,我们需要让自己站得高一些,去看待内部系统在整个公司级别的作用和地位,这样才能更好地发挥自己的技术能力。


对安排的工作负责


程序员职业的特殊性在于,工作本身的具体内容和难度,会随着被安排的工作内容的改变而改变。从对工作负责的角度来说,我们大部分人会付出比当初预想的更多的时间,才能让自己按时完成工作。


如果一件事情的复杂度远远超过之前的预估,在规定的时间内,自己确实无法完成,这时正确的态度不是硬着头皮上,而是将情况理清楚,早点找经理或者负责人,让他们知道事情的进度和之前没有预想到的难度,把事情重新安排一下。


从管理者的角度来看,一件事情安排的不合理,就应该早发现,早计划,重新安排资源。


对工作时间负责


对工作时间负责,是说最好在“实际上班”时间之前到,避免有人找你却找不到的情况。


这不只是为了保证工作时间,而是想强调程序员的工作不止是写代码,还有很多沟通交流的事情,要保证基本的工作时间,才能更有效的和团队交流,确保我们的工作的价值。


对于项目和团队安排的各种会议,要准时参加,如果不能参加,需要提前告知经理或者会议组织者,避免浪费大家的事情。


总之,我们工作中的责任是一点点增加的,负责任的态度和习惯,也是从平时工作中一件件事情中养成的。形成这样的习惯,成为一个受人信任的人,是我们在职场中要培养的重要品质。



作者:技术修行者
来源:juejin.cn/post/7214435063181623351

收起阅读 »

从小到大为何一谈学习就愁眉苦脸

谈“学习”色变 从小到大,我们总有许多东西要学习:除了各个科目如数学、英语的学习,还有一些被爸妈安排学习的舞蹈、钢琴等等。 总之,我们必须持续不断地学习,如果不学习就会学习成绩落后,别无他法。 因此,谈到“学习”这个词,总会可能给人一些不舒服的感觉,不信你看看...
继续阅读 »

谈“学习”色变


从小到大,我们总有许多东西要学习:除了各个科目如数学、英语的学习,还有一些被爸妈安排学习的舞蹈、钢琴等等。


总之,我们必须持续不断地学习,如果不学习就会学习成绩落后,别无他法。


因此,谈到“学习”这个词,总会可能给人一些不舒服的感觉,不信你看看:


爷爷奶奶会说:“把电视关了,快去学习。”


老师会说:“你家孩子啥都好,就是不爱学习。”


每次我爸送我出门,也绝对不会忘记加一句“学习努力一点。”


工作了,老板会说:“一会开个会,组织学习。”公司安排各种培训,也是为了学习...


于是,“学习”这个词会带给我们一些不舒服的感觉,不是让我们想起年轻时埋头于做题参加各种考试的岁月,就是给人一种单调枯燥的“复读机”式的培训、会议之类的低质量教育活动。


其实以上种种都是被动学习,我们一开始就没体会到真正的学习。


羊浸式培训



羊浸(现实中)是指把毫无防备的羊浸到一个大水箱里面做清洗,去除它们身上的寄生虫。



羊排成一队,你抓起一只浸到水箱里,让它感受一次强烈的、陌生的、中毒性的清洗经历。但是,药性会逐渐失效,所以过段时间你不得不对它们再次做清洗。


这种模式,在公司可能会很流行,也称为羊浸式培训。比如召集一大堆不知情的员工,在一个陌生的环境中通过密集的方式,花三到五天的时间培训他们,然后培训完颁发一个证书,宣布他们获得了什么优秀头衔。但培训的效果会逐渐减弱,于是第二年必需再来一次“进修”课程。


说一个自己的故事吧。


在我刚进大一那会,就有学校的师兄师姐向我们推销英语学习,到后面才知道是在培训机构支持下的俱乐部。还加入了 4 天 3 夜的训练营,来自不同学校的学生聚集到一起,其中感受到各种励志的故事,觉得很受用,也的确获得了优秀营员的证书,发誓今后一定要改头换面,学好英语。


但是,现在我还是没能学好口语。是的,"羊浸式"培训不起作用。


殊不知,我们容易把受教育的过程,当做学习者被动接受的过程。我们会被灌输各种知识,而不是自己主动进行学习。


再说一个身边的例子,在我们大三的时候,面临找工作或者考研的两难问题时,培训机构出现了。某 Java 培训机构告诉我们,学 Java 非常好找工作,毫无编程基础也可以快速入门。只要学好基础,会点数据库和SQL基础,学会网络编程,编写 JSP 页面,就可以找到一份 Java 编程的工作了;如果再下点功夫,学习 Structs、Hibernate、Spring 等流行框架,就可以找到好工作了,至少月薪 10K ...。


在我们那个时候,这样的条件和出路是多么诱人。本来是不确定与迷茫的大三时期,突然有这么好的机会摆在面前,不抓住怎么能行?于是,专业半数以上的同学都选择了这个培训班。于是,一个专业通过突击一下子拥有了大片的 Java 开发人员,或者说“快餐式”程序员。


但是,几年下来。当我再问曾经上过培训班的同学,迫于无奈,好几个已经退出 Java 开发人员的队列了。


脱离学校教育之后,我们再谈“学习”


为什么羊浸式方法没有用呢?



  • 学习不是强加于你的,而应该是你主动做的事情

  • 仅仅掌握知识,而不是实践,没有用。要学以致用

  • 随机的方法,没有目标和反馈,往往会导致随机的结果


单纯密集的课堂教育最多只能给你正确的方向,而且紧紧掌握知识的提纲,并不会提高专业水平。


于是,我们不仅该问,脱离学校教育之后,我们该如何学习?


1、 你需要持续的详细目标。



  1. 无论是在职业生涯,还是个人生活中。为了学习和成长,需要设定一些目标,比如我要学好 Python、我要减肥。

  2. 但是这些目标有点泛,我们需要详细一点:什么时候开始学 Python,学好 Python 用来爬虫;要减多少斤才算减肥,是通过控制食量还是增加运动?


2、 持续的反馈以了解你的进展


比如,这个月减了多少斤,这个月学了哪些章节 Python 知识点,只有确切的数字带给我们真实的反馈。



你不必看清你去往何处,不必看清你的目的地和沿途的一切。你只需看清面前的一两米即可。



3 更加主动全面的学习目标


除了个人的目标,还可以指定更大背景下的目标:比如事业、家庭、财务...


将学习变成一件我们必须掌握的事情,用科学的方式。更多关于目标的制定方法可以参考 SMART 模型。


总结


脱离学校后,为了满足我们的兴趣和需求,主

作者:宇宙之一粟
来源:juejin.cn/post/7214427834994982971
动学习才是破解之道。

收起阅读 »

扒了一千多条关于文心一言的评论,我发现这届网友真的不好糊弄…

前两天,百度紧随GPT-4发布了自己的语言模型文心一言。 讲道理,对于国内能够发布这样一个敢于对标CHAT GPT的高质量语言模型,大家应该更多感受到的是赛博朋克与现实生活贴近的真实感,对这个模型应该有着更多的鼓励或赞美。 可不知是因为整个发布会搞的过于像没有...
继续阅读 »

前两天,百度紧随GPT-4发布了自己的语言模型文心一言。


讲道理,对于国内能够发布这样一个敢于对标CHAT GPT的高质量语言模型,大家应该更多感受到的是赛博朋克与现实生活贴近的真实感,对这个模型应该有着更多的鼓励或赞美。


可不知是因为整个发布会搞的过于像没有好好准备的学生毕业答辩PPT,还是它的实际表现并没有那么如人意,大家貌似对文心一言并不那么买账。


于是我决定看一下知乎大神们对文心一言的评价,哪想到随便打开一个问题,居然有600多条回答…



要是我这一条一条翻完所有回答, 估计就得拿出一天来全职摸鱼了 ,那么有没有什么办法能够最快的分析出对待这个问题大家的综合评价呢?


那么今天就让我纱布擦屁股,给大家露一小手,写一个爬虫扒下来所有的回答,再对结果进行一下分析。


WebMagic


正式开始前,咱们得先搞定工具。虽然python写起爬虫来有天然的框架优势,不过鉴于大家都是搞java的,那么我们今天就用java框架来实现一个爬虫。


咱们要使用的工具 WebMagic,就是一款简单灵活的java爬虫框架,总体架构由下面这几部分构成:




  • Downloader:负责从互联网上下载页面,以便后续处理。WebMagic默认使用了Apache HttpClient作为下载工具。

  • PageProcessor:负责解析页面,抽取有用信息,以及发现新的链接。WebMagic使用Jsoup作为HTML解析工具,并基于其开发了解析XPath的工具Xsoup。

  • Scheduler:负责管理待抓取的URL,以及一些去重的工作。WebMagic默认提供了JDK的内存队列来管理URL,并用集合来进行去重。也支持使用Redis进行分布式管理。

  • Pipeline:负责抽取结果的处理,包括计算、持久化到文件、数据库等。WebMagic默认提供了输出到控制台和保存到文件两种结果处理方案。


在4个主要组件中,除了PageProcessor之外,其他3个组件基本都可以复用。而我们实际爬虫中的重点,就是要针对不同网页进行页面元素的分析,进而定制化地开发不同的PageProcessor


下面我们开始准备实战,先引入webmagiccoreextension两个依赖,最新0.8.0版本搞里头:


<dependency>
<groupId>us.codecraftgroupId>
<artifactId>webmagic-coreartifactId>
<version>0.8.0version>
dependency>
<dependency>
<groupId>us.codecraftgroupId>
<artifactId>webmagic-extensionartifactId>
<version>0.8.0version>
dependency>

PageProcessor 与 xpath


在正式开始抓取页面前,我们先看看知乎上一个问题的页面是怎么构成的,还是以上面图中那个问题为例,原问题的地址在这里:



如何看待百度发布的文心一言?



我们先做个简单的测试,来获取这个问题的标题,以及对这个问题的描述


通过浏览器的审查元素,可以看到标题是一个h1的标题元素,并且它的class属性是QuestionHeader-title,而问题的描述部分在一个div中,它的class中包含了QuestionRichText



简单分析完了,按照前面说的,我们要对这个页面定制一个PageProcessor组件抽取信息,直接上代码。


新建一个类实现PageProcessor接口,并实现接口中的process()这个方法即可。


public class WenxinProcessor implements PageProcessor {
private Site site = Site.me()
.setRetryTimes(3).setSleepTime(1000);

@Override
public void process(Page page) {
String title = page.getHtml()
.xpath("//h1[@class='QuestionHeader-title']/text()").toString();
String question= page.getHtml()
.xpath("//div[@class='QuestionRichText']//tidyText()").toString();

System.out.println(title);
System.out.println(question);
}

public Site getSite() {
return site;
}

public static void main(String[] args) {
Spider.create(new WenxinProcessor())
.addUrl("https://www.zhihu.com/question/589929380")
.thread(2)
.run();
}
}

查看运行结果:



可以看到,在代码中通过xpath()这样一个方法,成功拿到了我们要取的两个元素。其实说白了,这个xpath也不是爬虫框架中才有的新玩意,而是一种XML路径语言(XML Path Language),是一种用来确定XML文档中某部分位置的语言。它基于XML的树状结构,提供在数据结构树中找寻节点的能力。


常用的路径表达式包括:


表达式描述
nodename选取此节点的所有子节点。
/从根节点选取。
//从匹配选择的当前节点选择文档中的节点,而不考虑它们的位置。
.选取当前节点。
..选取当前节点的父节点。
@选取属性。

在上面的代码中,//h1[@class='QuestionHeader-title']就表示选取一个类型为h1的节点,并且它有一个class为QuestionHeader-title的属性。


至于后面的text()tidyText()方法,则是用于提取元素中的文本,这些函数不是标准xpath中的,而是webMagic中特有的新方法,这些函数的使用可以参考文档:



webmagic.io/docs/zh/pos…



看到这,你可能还有个问题,这里对于问题的描述部分没有显示完全,你需要在页面上点一下这个显示全部它才会显示详细的信息。



没关系,这里先留个坑,这个问题放在后面解决。


获取提问的答案


我们完善一下上面的代码,尝试获取问题的解答。按照老套路,还是先分析页面元素再用xpath写表达式获取。修改process方法:


@Override
public void process(Page page) {
String contentPath= "div[@class='QuestionAnswers-answers']"+
"//div[@class='RichContent RichContent--unescapable']" +
"//div[@class='RichContent-inner']"+
"/tidyText()";
List answerList = page.getHtml().xpath(contentPath).all();
for (int i = 0; i < answerList.size(); i++) {
System.out.println("第"+(i+1)+"条回答:");
System.out.println(answerList.get(i)+"\n=======");
}
}

在上面的代码中,使用了xpath获取页面中具有相同属性的元素,并将它们存入了List列表中。看一下运行结果:



纳尼?这个问题明明有着689条的回答,为什么我们只爬到了两条答案?


如果你经常用知乎来学习摸鱼的话,其实就会知道对于这种有大量回答的问题,页面刚开始只会默认显示很少的几条的消息,随着你不断的下拉页面才会把新的回答显示出来。


那么如果我想拿到所有的评论应该怎么做呢?这时候就要引出webMagic中另一个神奇的组件Selenium了。


Selenium


简单来说,selenium是一个用于Web应用程序测试的工具,selenium测试可以直接运行在浏览器中,就像真正的用户在操作一样,并且目前主流的大牌浏览器一般都支持这项技术。


所以在爬虫中,我们可以通过编写模仿用户操作的selenium脚本,模拟进行一部分用互操作,比如点击事件或屏幕滚动等等。


WebMagic-Selenium需要依赖于WebDriver,所以我们先进行本地WebDriver的安装操作。


安装WebDriver


查看自己电脑上Chrome版本,可以点击设置->关于chrome查看,也可以直接在地址栏输入chrome://settings/help



可以看到版本号,然后需要下载对应版本的WebDriver,下载地址:



chromedriver.storage.googleapis.com/index.html



打开后,可以看到各个版本,选择与本地浏览器最接近的版本:



点击进入后,根据我们的系统选择对应版本下载即可。



下载完成后,解压到本地目录中,之后在使用selenium模块中会使用到。这个文件建议放在chrome的安装目录下,否则之后在代码中可能会报一个WebDriverException: unknown error: cannot find Chrome binary找不到chrome文件的错误。


修改Selenium源码


webMagic中已经封装了selenium模块的代码,但官方版本的代码有些地方需要修改,我们下载源码后要自己简单改动一下然后重新编译。我这下载了0.8.1-SNAPSHOT版本的代码,官方git地址:



github.com/code4craft/…



修改配置文件地址,在WebDriverPoolselenium配置文件路径写死了,需要改变配置路径:


// 修改前
// private static final String DEFAULT_CONFIG_FILE = "/data/webmagic/webmagic-selenium/config.ini";
// 修改后
private static final String DEFAULT_CONFIG_FILE = "selenium.properties";

resources目录下添加配置文件selenium.properties


# What WebDriver to use for the tests
driver=chrome
# PhantomJS specific config (change according to your installation)
chrome_driver_loglevel=DEBUG

js模拟页面操作


修改SeleniumDownloaderdownload()方法,在代码中的这个位置,作者很贴心的给我们留了一行注释:



意思就是,你可以在这添加鼠标事件或者干点别的什么东西了。我们在这添加页面向下滚动这一模拟事件,每休眠2s就向下滚动一下页面,一共下拉20次:


//模拟下拉,刷新页面
for (int i=0; i < 20; i++){
System.out.println("休眠2s");
try {
//滚动到最底部
((JavascriptExecutor)webDriver)
.executeScript("window.scrollTo(0,document.body.scrollHeight)");
//休眠,等待加载页面
Thread.sleep(2000);
//往回滚一点,否则不加载
((JavascriptExecutor)webDriver)
.executeScript("window.scrollBy(0,-300)");
} catch (InterruptedException e) {
e.printStackTrace();
}
}

修改完成后本地打个包,注意还要修改一下版本号,改成和发行版的不同即可,我这里改成了0.8.1.1-SNAPSHOT


mvn clean install

调用


回到之前的爬虫项目,引入我们自己打好的包:


<dependency>
<groupId>us.codecraftgroupId>
<artifactId>webmagic-seleniumartifactId>
<version>0.8.1.1-SNAPSHOTversion>
dependency>

修改之前的主程序启动时的代码,添加Downloader组件,SeleniumDownloader构造方法的参数中传入我们下好的chrome的webDriver的可执行文件的地址:


public static void main(String[] args) {
Spider.create(new WenxinProcessor())
.addUrl("https://www.zhihu.com/question/589929380")
.thread(2)
.setDownloader(new SeleniumDownloader("D:\\Program Files\\Google\\Chrome\\Application\\chromedriver.exe")
.setSleepTime(1000))
.run();
}

进行测试,可以看到在拉动了40秒窗口后,获取到的答案条数是100条:



通过适当地添加下拉页面的循环的次数,我们就能够获取到当前问题下的全部回答了。


另外,在启动爬虫后我们会看到webDriver弹出了一个chrome的窗口,在这个窗口中有一个提示:Chrome正受到自动测试软件的控制,并且可以看到页面不断的自动下拉情况:



如果不想要这个弹窗的话,可以修改selenium模块的代码进行隐藏。修改WebDriverPoolconfigure()方法,找到这段代码:


if (driver.equals(DRIVER_CHROME)) {
mDriver = new ChromeDriver(sCaps);
}

添加一个隐藏显示的选项,并且在修改完成后,重新打包一下。


if (driver.equals(DRIVER_CHROME)) {
ChromeOptions options=new ChromeOptions();
options.setHeadless(true);
mDriver = new ChromeDriver(options);
}

获取问题详细描述


不知道大家还记不记得在前面还留了一个坑,我们现在获取到的对问题的描述是不全的,需要点一下这个按钮才能显示完全。



同样,这个问题也可以用selenium来解决,在我们下拉页面前,加上这么一个模拟点击事件,就可以获得对问题的详细描述了:


((JavascriptExecutor)webDriver)
.executeScript("document.getElementsByClassName('Button QuestionRichText-more')[0].click()");

看一下执行结果,已经可以拿到完整内容了:



Pipeline


到这里,虽然要爬的数据获取到了,但是要进行分析的话,还需要进行持久化操作。在前面的webMagic的架构图中,介绍过Pipeline组件主要负责结果的处理,所以我们再优化一下代码,添加一个Pipeline负责数据的持久化。


由于数据量也不是非常大,这里我选择了直接存入ElasticSearch中,同时也方便我们进行后续的分析操作,ES组件我使用的是esclientrhl,为了方便我还是把项目整个扔到了 spring里面。


定制一个Pipeline也很简单,实现Pipeline接口并实现里面的process()接口就可以了,通过构造方法传入ES持久化层组件:


@Slf4j
@AllArgsConstructor
public class WenxinPipeline implements Pipeline {
private final ZhihuRepository zhihuRepository;

@Override
public void process(ResultItems resultItems, Task task) {
Map map = resultItems.getAll();
String title = map.get("title").toString();
String question = map.get("question").toString();
List answer = (List) map.get("answer");

ZhihuEntity zhihuEntity;
for (String an : answer) {
zhihuEntity = new ZhihuEntity();
zhihuEntity.setTitle(title);
zhihuEntity.setQuestion(question);
zhihuEntity.setAnswer(an);
try {
zhihuRepository.save(zhihuEntity);
} catch (Exception e) {
e.printStackTrace();
}
}
}
}

把selenium向下拉取页面的次数改成200后,通过接口启动程序:


@GetMapping("wenxin")
public void wenxin() {
new Thread(() -> {
Request request = new Request("https://www.zhihu.com/question/589929380");
WenxinProcessor4 wenxinProcessor = new WenxinProcessor4();
Spider.create(wenxinProcessor)
.addRequest(request)
.addPipeline(new WenxinPipeline(zhihuRepository))
.setDownloader(new SeleniumDownloader("D:\\Program Files\\Google\\Chrome\\Application\\chromedriver.exe")
.setSleepTime(1000))
.run();
}).start();
}

运行完成后,查询一下es中的数据,可以看到,实际爬取到了673条回答。



另外,我们可以在一个爬虫程序中传入多个页面地址,只要页面元素具有相同的规则,那么它们就能用相同的爬虫逻辑处理,在下面的代码中,我们一次性传入多个页面:


Spider.create(new WenxinProcessor4())
.addUrl(new String[]{"https://www.zhihu.com/question/589941496",
"https://www.zhihu.com/question/589904230","https://www.zhihu.com/question/589938328"})
.addPipeline(new WenxinPipeline(zhihuRepository))
.setDownloader(new SeleniumDownloader("D:\\Program Files\\Google\\Chrome\\Application\\chromedriver.exe")
.setSleepTime(1000))
.run();

一顿忙活下来,最终扒下来1300多条数据。



分析


数据落到了ES里后,那我们就可以根据关键字进行分析了,我们先选择10个负面方向的词语进行查询,可以看到查到了403条数据,将近占到了总量的三分之一。



再从各种回答中选择10个正向词语查询,结果大概只有负面方向的一半左右:



不得不说,这届网友真的是很严厉…


Proxy代理


说到爬虫,其实还有一个绕不过去的东西,那就是代理。


像咱们这样的小打小闹,爬个百八十条数据虽然没啥问题,但是如果要去爬取大量数据或是用于商业,还是建议使用一下代理,一方面能够隐藏我们的IP地址起到保护自己的作用,另一方面动态IP也能有效的应对一些反爬策略。


个人在使用中,比较推荐的是隧道代理。简单的来说,如果你购买了IP服务的话,用普通代理方式的话需要你去手动请求接口获取IP地址,再到代码中动态修改。而使用隧道代理的话,就不需要自己提取代理IP了,每条隧道自动提取并使用代理IP转发用户请求,这样我们就可以专注于业务了。


虽然网上也有免费的代理能够能用,但要不然就是失效的太快,要不就是很容易被网站加入黑名单,所以如果追求性能的话还是买个专业点的代理比较好,虽然可能价格不那么便宜就是了。


题外话


看了一大顿下来,从大家略显犀利的言辞来看,大家总体上对文心一言还是不那么满意的。毕竟,在有着CHAT-GPT这么一个优秀的产品做背景板的前提下,这届网友可能没有那么好糊弄。


但是话又说回来,丑媳妇总得见公婆不是?提早暴露缺陷,也有利于国内的这些大厂,看清和一流AI产品之间的真实差距,知难而进,迎头赶上。


那么,这次的分享就到这里,我是Hydra,我们下篇再见。



文中代码已经传到我的git上啦,github.com/trunks2008/… ,欢迎大家来个star鼓励一下~




参考资料:


webmagic.io/docs/zh/pos…


blog.csdn.net/panchang199…





作者:码农参上
来源:juejin.cn/post/7213384511890800696
收起阅读 »

使用 Kotlin 委托,拆分比较复杂的 ViewModel

需求背景 在实际的开发场景中,一个页面的数据,可能是由多个业务的数据来组成的。 使用 MVVM 架构进行实现,在 ViewModel 中存放和处理多个业务的数据,通知 View 层刷新 UI。 传统实现 比如上面的例子,页面由3 个模块数据构成。 我们可...
继续阅读 »

需求背景




  1. 在实际的开发场景中,一个页面的数据,可能是由多个业务的数据来组成的。

  2. 使用 MVVM 架构进行实现,在 ViewModel 中存放和处理多个业务的数据,通知 View 层刷新 UI。


传统实现


比如上面的例子,页面由3 个模块数据构成。


我们可以创建一个 ViewModel ,以及 3个 LiveData 来驱动刷新对应的 UI 。


    class HomeViewModel() : ViewModel() {

private val _newsViewState = MutableLiveData<String>()
val newsViewState: LiveData<String>
get() = _newsViewState

private val _weatherState = MutableLiveData<String>()
val weatherState: LiveData<String>
get() = _weatherState

private val _imageOfTheDayState = MutableLiveData<String>()
val imageOfTheDayState: LiveData<String>
get() = _imageOfTheDayState

fun getNews(){}
fun getWeather(){}
fun getImage(){}

}

这样的实现会有个缺点,就是随着业务的迭代,页面的逻辑变得复杂,这里的 ViewModel 类代码会变复杂,变得臃肿。


这个时候,就可能需要考虑进行拆分 ViewModel


一种实现方法,就是直接简单地拆分为3个 ViewModel,每个 ViewModel 处理对应的业务。但是这样会带来其他问题,就是在 View 层使用的时候,要判断当前是什么业务,然后再去获取对应的ViewModel,使用起来会比较麻烦。


优化实现


目标:



  • 将 ViewModel 拆分成多个子 ViewModel,每个子 ViewModel 只关注处理自身的业务逻辑

  • 尽量考虑代码的可维护性、可扩展性


Kotlin 委托



  • 委托(Delegate)是 Kotlin 的一种语言特性,用于更加优雅地实现代理模式

  • 本质上就是使用了 by 语法后,编译器会帮忙生成相关代码。

  • 类委托: 一个类的方法不在该类中定义,而是直接委托给另一个对象来处理。

  • 基础类和被委托类都实现同一个接口,编译时生成的字节码中,继承自 Base 接口的方法都会委托给BaseImpl 处理。


// 基础接口
interface Base {
fun print()
}

// 基础对象
class BaseImpl(val x: Int) : Base {
override fun print() { print(x) }
}

// 被委托类
class Derived(b: Base) : Base by b

fun main(args: Array<String>) {
val b = BaseImpl(10)
Derived(b).print() // 最终调用了 Base#print()
}

具体实现


定义子 ViewModel 的接口,以及对应的实现类


    interface NewsViewModel {
companion object {
fun create(): NewsViewModel = NewsViewModelImpl()
}

val newsViewState: LiveData<String>

fun getNews()
}

interface WeatherViewModel {
companion object {
fun create(): WeatherViewModel = WeatherViewModelImpl()
}

val weatherState: LiveData<String>

fun getWeather()
}

interface ImageOfTheDayStateViewModel {
companion object {
fun create(): ImageOfTheDayStateViewModel = ImageOfTheDayStateImpl()
}

val imageState: LiveData<String>

fun getImage()
}

class NewsViewModelImpl : NewsViewModel, ViewModel() {
override val newsViewState = MutableLiveData<String>()

override fun getNews() {
newsViewState.postValue("测试")
}
}

class WeatherViewModelImpl : WeatherViewModel, ViewModel() {
override val weatherState = MutableLiveData<String>()

override fun getWeather() {
weatherState.postValue("测试")
}
}

class ImageOfTheDayStateImpl : ImageOfTheDayStateViewModel, ViewModel() {
override val imageState = MutableLiveData<String>()

override fun getImage() {
imageState.postValue("测试")
}
}


  • 把一个大模块,划分成若干个小的业务模块,由对应的 ViewModel 来进行处理,彼此之间尽量保持独立。

  • 定义接口类,提供需要对外暴漏的字段和方法

  • 定义接口实现类,内部负责实现 ViewModel 的业务细节,修改对应字段值,实现相应方法。

  • 这种实现方式,就不需要像上面的例子一样,每次都要多声明一个带划线的私有变量。并且可以对外隐藏更多 ViewModel 的实现细节,封装性更好


组合 ViewModel


image.png


    interface HomeViewModel : NewsViewModel, WeatherViewModel, ImageOfTheDayStateViewModel {
companion object {
fun create(activity: FragmentActivity): HomeViewModel {
return ViewModelProviders.of(activity, object : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
return if (modelClass == HomeViewModelImpl::class.java) {
@Suppress("UNCHECKED_CAST")

val newsViewModel = NewsViewModel.create()
val weatherViewModel = WeatherViewModel.create()
val imageOfTheDayStateImpl = ImageOfTheDayStateViewModel.create()

HomeViewModelImpl(
newsViewModel,
weatherViewModel,
imageOfTheDayStateImpl
) as T
} else {
modelClass.newInstance()
}

}
}).get(HomeViewModelImpl::class.java)
}
}
}

class HomeViewModelImpl(
private val newsViewModel: NewsViewModel,
private val weatherViewModel: WeatherViewModel,
private val imageOfTheDayState: ImageOfTheDayStateViewModel
) : ViewModel(),
HomeViewModel,
NewsViewModel by newsViewModel,
WeatherViewModel by weatherViewModel,
ImageOfTheDayStateViewModel by imageOfTheDayState {

val subViewModels = listOf(newsViewModel, weatherViewModel, imageOfTheDayState)

override fun onCleared() {
subViewModels.filterIsInstance(BaseViewModel::class.java)
.forEach { it.onCleared() }
super.onCleared()
}
}


  • 定义接口类 HomeViewModel,继承了多个子 ViewModel 的接口

  • 定义实现类 HomeViewModelImpl,组合多个子 ViewModel,并通过 Kotlin 类委托的形式,把对应的接口交给相应的实现类来处理

  • 通过这种方式,可以把对应模块的业务逻辑,拆分到对应的子 ViewModel 中进行处理

  • 如果后续需要新增一个新业务数据,只需新增相应的子模块对应的 ViewModel,而无需修改其他子模块对应的 ViewModel。

  • 自定义 ViewModelFactory,提供 create 的静态方法,用于外部获取和创建 HomeViewModel。


使用方式



  • 对于 View 层来说,只需要获取 HomeViewModel 就行了。

  • 调用暴露的方法,最后会委托给对应子 ViewModel 实现类进行处理。


        val viewModel = HomeViewModel.create(this)

viewModel.getNews()
viewModel.getWeather()
viewModel.getImage()

viewModel.newsViewState.observe(this) {

}
viewModel.weatherState.observe(this) {

}
viewModel.imageState.observe(this) {

}

扩展



  • 上面的例子,HomeViewModel 下面,可以由若干个子 ViewMdeol 构成。

  • 随着业务拓展,NewsViewModel、WeatherViewModel、ImageOfTheDayStateViewModel,也可能是分别由若干个子 ViewModel 构成。那也可以参照上面的方式,进行实现,最后会形成一棵”ViewModel 树“,各个节点的 ViewModel 负责处理对应的业务逻辑。


image.png


总结


这里只是提供一种拆分 ViewModel 的思路,在项目中进行应用的话,可以根据需要进行改造。


参考文章


slicing-your-viewmodel-with-delegates


Kotlin | 委托机制 & 原理 & 应用 - 掘金


作者:入魔的冬瓜
来源:juejin.cn/post/7213257917254860861
收起阅读 »

写给Android工程师的协程指南

这是一份写给 Android工程师 的协程指南,希望在平静的2023,给大家带来一些本质或者别样的理解。 引言 在 Android 的开发世界中,关于 异步任务 的处理一直不是件简单事。 面对复杂的业务逻辑,比如多次的异步操作,我们常常会经历回调嵌套的情况,对...
继续阅读 »



这是一份写给 Android工程师 的协程指南,希望在平静的2023,给大家带来一些本质或者别样的理解。


引言


Android 的开发世界中,关于 异步任务 的处理一直不是件简单事。


面对复杂的业务逻辑,比如多次的异步操作,我们常常会经历回调嵌套的情况,对于开发者而言,无疑苦不堪言。😟


Kotlin协程 出现之后,上述问题可以说真正意义上得到了好的解法。其良好的可读性及api设计,使得无论是新手还是老手,都能快速享受到协程带来的舒适体验。


但越是使用顺手的组件,背后也往往隐藏着更复杂的设计。


故此,在本篇,我们将由浅入深,系统且全面的聊聊 Kotlin协程 的思想及相关问题,从而帮助大家更好的理解。



本篇没有难度定位、更多的是作为一个 Kotlin 使用者的基本技术铺垫。



ps: 在B站也有视频版本,结合观看,体验更佳,Android Kotlin 协程分享


写在开始


大概在三年前,那时的我实习期间刚学会 Kotlin ,意气风发,协程Api 调用的也是炉火纯青,对外自称api调用渣渣工程师。


那时候的客户端还没这么饱和,也不像现在这样稳定。


那个时期,曾探寻过几次 Kotlin协程 的设计思想,比如看霍老师、扔物线视频、相关博客等。


但看完后处于一种,懂了,又似乎不是很懂的状态,就一直迷迷糊糊着。


记得后来去面试,有人问我,协程到底是什么?



我回答: 一个在 Kotlin 上以 同步方式写异步代码 的线程框架,底层是使用了 线程池+状态机 的概念,诸如此类,巴拉巴拉。


面试官: 那它到底和线程池有啥区别,我为啥不直接用线程池呢?


我心想:上面不是已经回答了吗,同步方式,爽啊!… 但奈何遭到了一顿白眼。


事后回想,他可能想问的是更深层,多角度的解释,但显然我只停留在使用层次,以及借着别人的几句碎片经验,冠冕堂皇、看似Easy。



直到现在为止,我仍然没有认真去看过协程的底层实现,真是何其的尴尬,再次想起,仍觉不安。


随着近几年对协程的使用以及一些cv经验,相关的api理解也逐渐像那么回事,也有些对Kt代码背后实现进行同步转换的经验。


故此,这篇文章也是对自己三年来的一份答卷。


当然网上对于协程的解析也有很多,无论是从原理或是顶层抽象概括,其中更是不乏优秀的文章与作者。


本文会尽量在这两者中间找到一个合适的折中点,并增加一些特别思考,即不缺深度,又能使初学者对于协程能够有较清晰明了的认知。


好了,让我们开始吧! 🏃🏻


基础铺垫


在开始之前,我们先对基础做一些铺垫,从而便于更好的理解 Kotlin协程


线程


我们知道,线程是 cpu调度 的最小单元,每个cpu所能启动的线程数量往往也是有限的。


在常见的业务开发中,尽管大多数时候我们都是基于单线程,或者最多开启子线程去请求网络,与多线程的 [多] 似乎关系不大。但其实这也属于多线程的一种,不过是少任务的情况。但就算这样,线程在执行时的切换,也是存在这一些小成本,比如从主线程切到子线程去执行异步计算,完成后再从子线程切到主线程去执行UI操作,而这个切换的过程在学术上又被称之为 [上下文切换]


协程


在维基百科中,是这样解释的:



协程是计算机程序的一类组件,推广了协作式多任务子例程,允许执行被挂起与被恢复。相对子例程而言,协程更为一般和灵活,但在实践中使用没有子例程那样广泛。协程更适合于用来实现彼此熟悉的程序组件,如协作式多任务异常处理事件循环迭代器无限列表管道



上面这些词似乎拆开都懂,但连在一起就不懂了。


说的通俗一点就是,协程指的是一种特殊的函数,它可以在执行到某个位置时 暂停 ,并 保存 当前的执行状态,然后 让出 CPU控制权,使得其他代码可以继续执行。当CPU再次调用这个函数时,它会从上次暂停的位置继续执行,而不是从头开始执行。从而使得程序在执行 长时间任务 时更加高效和灵活。


协作式与抢占式


这两个概念通常用于描述操作系统中多任务的处理方式。



  • 协作式指的是 多个任务共享CPU时间 ,并且在没有主动释放CPU的情况下,任务不会被强制中断。相应的,在协作式多任务处理中,任务需要自己决定何时放弃CPU,否则将影响其他任务的执行。

  • 抢占式指的是操作系统可以在没有任务主动放弃CPU的情况下,强制中断 当前任务,以便其他任务可以获得执行。这也就意味着,抢占式多任务通常是需要硬件支持,以便操作系统可以在必要时强制中断任务。


如果将上述概念带入到协程与线程中,当一个线程执行时,它会一直运行,直到被操作系统强制中断或者自己放弃CPU;而协程的协作式则需要协程之间互相配合协作,以便让其他协程也可以获得执行机会,通常情况下,这种协作关系是由应用层(开发者)自行控制。也就意味着相比线程,协程的切换与创建开销比较小,因为其并不需要多次的上下文切换,或者说,线程是真实的操作系统内核线程的隐射,而协程只是在应用层调度,故协程的切换与创建开销比较小。


协程与线程的区别



  • 线程是操作系统调度的基本单位,一个进程可以拥有多个线程,每个线程独立运行,但它们共享进程的资源。线程切换的开销较大,且线程间的通信需要通过共享内存或消息传递等方式实现,容易出现资源竞争、死锁等问题。

  • 协程是用户空间下的轻量级线程,也称为“微线程”。它不依赖操作系统的调度,而是由用户自己控制协程的执行。协程之间的切换只需要保存和恢复少量的状态,开销较小。协程通信和数据共享的方式比线程更加灵活,通常使用消息传递或共享状态的方式实现。

  • 简单来说,协程是一种更加高效、灵活的并发处理方式,但需要用户 自己控制执行流程和协程间的通信 ,而线程则由操作系统负责调度,具有更高的并发度和更强的隔离性,但开销较大。在不同的场景下,可以根据需要选择使用不同的并发处理方式。


那Kotlin协程呢?


在上面,我们说了 线程协程 ,但这个协程指的是 广义协程 这个概念,而不是 Kotlin协程 ,那如果回到 Kotlin协程 呢?


相信不少同学在学习 Kotlin协程 的时候,常常会看到很多人(包括官网)会将线程与协程拉在一起比较,或者经常也能看见一些实验,比如同时启动10w个线程与10w个协程,然后从结果上看两者差距巨大,线程看起来性能巨差,协程又无比的优秀。



此时就会有同学喊,你上个线程池与协程试试啊!用线程试谈什么公平(很有道理)😂。


ps: 如果你真的使用了线程池并且使用了schedule代替Thread.sleep(),会发现,线程比协程显然要更快。当然,这也并不难理解。



那协程到底是什么呢?它和线程池的区别呢?或者说协程的职责呢?


这里我们用 Android官方 的一句话来概括:



协程是一种并发设计模式,您可以在 Android 平台上使用它来 简化 异步执行的代码。协程是我们在 Android 上进行异步编程的推荐解决方案。



简单明了,协程就是用于 Android 上进行 异步编程 的推荐解决方案,或者说其就是一个 异步框架 ,仅此而已,别无其他🙅🏻‍♂️。


那有些同学可能要问了,异步框架多了,为什么要使用协程呢?


因为协程的设计更加先进,比如我们可以同步代码写出类似异步回调的逻辑。这一点,也是Kotlin协程在Android平台最大的特点,即 简化异步代码


相应的,Kotlin协程 具有以下特点:



  • 轻量:您可以在单个线程上运行多个协程,因为协程支持挂起,不会使正在运行协程的线程阻塞。挂起比阻塞节省内存,且支持多个并行操作。

  • 内存泄漏更少:使用结构化并发机制在一个作用域内执行多项操作。

  • 内置取消支持取消操作会自动在运行中的整个协程层次结构内传播。

  • Jetpack 集成:许多 Jetpack 库都包含提供全面协程支持的扩展。某些库还提供自己的协程作用域,可供您用于结构化并发。



上述特点来自Android官网-Android上的Kotlin协程



协程进展



:如非特别标注,本文接下来的协程皆指Kotlin协程。



本小节,我们将看一下Kotlin协程的发展史,从而为大家解释kotlin协程的背景。


image-20230220152147904


Kotlin1.6 之前,协程的版本通常与 kotlin 版本作为对应,但是 1.6 之后,协程的大版本就没有怎么更新了(目前最新是1.7.0-beta),反而是 Kotlin 版本目前最新已经 1.8.10


基本示例


在开始之前,我们还是用一个最基本的示例看一下协程与往常回调写法的区别,在哪里。



比如,我们现在有这样一个场景,需要请求网络,获取数据,然后显示到UI中。



回调写法


fun main() {
// 示例,一般为线程池
thread(name="t1") {
val message = getMessage()
// 或者其他切线程方式,底层都是这样,handler复用
val handler = Handler(Looper.getMainLooper())
handler.post {
showMessage(message)
}
}
}

fun getMessage(): String {
Thread.sleep(1000)
return "123"
}

如上所示,创建了一个线程t1,并在其中调用了 getMessage() 方法,该方法我们使用 Thread.sleep() 模拟网络请求,然后返回一个String数据, 最后使用 handler 将当前要执行的任务发送到主线程去执行从而实现线程切换。


协程写法


fun main() {
val coroutineScope = CoroutineScope(Dispatchers.Main)
coroutineScope.launch {
val message = getMessages()
showMessage(message)
}
}

suspend fun getMessages(): String {
return withContext(Dispatchers.IO) {
delay(1000)
"123"
}
}

如上所示,创建了一个协程作用域,并启动了一个新的子协程c1,该协程内部调用了 getMessages() 方法,用于获得一个 String类型 的消息。然后调用 showMessage() 方法,显示刚才获取的消息。在相应的 getMessages() 方法上,我们增加了 suspend 标记,并在内部使用withContext(Dispatcher.IO) 将当前上下文环境切换到IO协程中,用于延迟等待(假设网络请求),最终返回该结果。



在不谈性能的背景下,上述这两种方式,无疑是协程的代码更加直观简洁,毕竟同步的写法去写异步,这没什么可比性,当然我们也允许部分的性能损失。



挂起与恢复


站在初学者的视角,当聊到挂起与恢复,开发者到底想了解什么?


什么是挂起恢复?挂起是挂起什么?挂起线程吗?还是挂起一个函数?恢复又是具体指什么?又是如何做到恢复的呢?


基础概念


在标准的解释中,如下所示:



在协程中,当我们的代码执行到某个位置时,可以使用特定的关键字来暂停函数的执行,同时保存函数的执行状态,这个过程叫做 [挂起],挂起操作会将控制器交还给调用方,调用方可以继续执行其他任务。


当再次调用被挂起的函数时,它会从上一次暂停的位置开始继续执行,这个过程称为 [恢复]。在恢复操作之后,被挂起的函数会继续执行之前保存的状态,从而可以在不重新计算的情况下继续执行之前的逻辑。



如果切换到 Kotlin 的世界中中,这个特定的关键字就是 suspend 。但并不是说加了这个关键字就一定会挂起,suspend 只是作为一个标记,用于告诉编译器,该函数可能会挂起并暂停执行(即该函数可能会执行耗时操作,并且好事期间会暂停执行并等待耗时操作完成,同时需要将控制权返回给调用方),但至于要不要挂起及保存函数当前的执行状态,最终还是要取决于函数内部是否满足条件。


如下所示,我们用一个示例Gif(出处已找不到)来表示:


img


那用程序员的语言该怎么理解呢?我们用一段代码举例:


coroutineScope.launch(Dispatchers.Main) {
val message = getNetMessages()
showMessage(message)
}

suspend fun getNetMessages(): String {
return withContext(Dispatchers.IO) {
delay(1000)
"123"
}
}


  • 当我们的程序运行到 coroutineScope.launch(Dispatchers.Main) 时,此时会创建一个新协程,并将这个协程放入默认的协程调度器(即Main调度器),同时当前新创建的协程也会成为 coroutineScope 的子协程。

  • 当执行到 getNetMssage() 方法时,此时遇到了 withContext(Dispatchers.IO) ,此时会切换当前协程的上下文到IO调度器(可以理解将当前协程放入IO线程池中执行),此时协程将被挂起,然后我们当前 withContext() 被挂起的状态会通知给外部的调用者,并将当前的状态保存到协程的上下文中,直到IO操作完成。

    • 当遇到 delay(1000) 时,此时再次挂起(这里不是切换线程,而是使用了协程的调度算法),并保存当前的函数状态;

    • delay(1000) 结束后,再次恢复到先前所在的IO调度器,并开始返回 “123”;

    • 当上述逻辑执行完成后,此时 withContext() 会将协程的调度器再次切换到之前开始时的调度器(这里是Main),并恢复之前的函数状态;



  • 此时我们获得了 getNetMssage() 的返回值,继续执行 showMessage()


挂起函数


在上面我们聊到了 Kotlin 的挂起函数,与相关的 挂起恢复 。那 suspend 标志到底做了什么呢?


本小节,我们将就这个问题,从字节码层,展开分析。


我们先看一下 suspend 方法是如何被编译器识别的?如下代码所示:


image-20230304225541849


不难发现,我们带有suspend的函数最终会被转变为一个带 Continutaion 参数,并且返回值为Object(可null)的函数。



上述示例中,原函数没带返回值,你也可以使用带返回值的原函数,结果也是与上述一致。



1. Continucation 是什么?为什么要携带它呢?


在前文中,我们已经提及,suspend 只是一个标志,它的目的是告诉编译器可能会挂起,类似与我们开发中常使用的注解一样,但又比注解更加强大,suspend 标志是编译器级别,而注解是应用级别。从原理上来看,那最终的代码运行时应该怎么记住这些状态呢,或者怎么知道这个方法和其他方法不一样?故此,kotlin编译器 会对带有 suspend 的方法在最终的字节码生成上进行额外更改,这个过程又被称作 CPS转换 (下面会再解释),如下所示:


suspend fun xx()
->
Object xx(Continucation c)

在字节码中,我们原有的函数方法参数中会再增加一个 Continucation ,而 Continuation 就相当于一个参数传递的纽带(或者你也可以理解其就是一个 CallBack ),负责保存函数的执行状态、执行 挂起与恢复 操作,具体如下:


public interface Continuation<in T> {
public val context: CoroutineContext

public fun resumeWith(result: Result<T>)
}

context 参数类似于 Android 开发中的 context 一样,其代表了当前的配置,对使用协程的同学而言,context就相当于当前协程所运行的环境与参数 ,而 resumeWith() 则是负责对我们函数方法进行挂起与恢复(这块我们先这样理解即可)。




1 什么是CPS转换?



CPS(Continuation Passing Style)转换是一种将函数转换为回调函数的编程技术。在 CPS 转换中,一个函数不会像通常那样直接返回结果,而是接受一个额外的回调函数作为参数,用于接收函数的结果。这个回调函数本身也可能接受一个回调函数,形成一个连续的回调链。这种方式可以避免阻塞线程,提高代码的并发性能。



比如,协程通过 CPS 转换来实现异步编程。具体来说,协程在被挂起时,会将当前的执行状态保存到一个回调函数(即挂起函数的 Continuation)中,然后将控制权交回给调用方。当协程准备好恢复时,它会从回调函数中取回执行状态,继续执行。这种方式可以使得异步代码的逻辑更加清晰和易于维护。




2. 为什么还要增一个 Object 类型返回值呢?


这块的直接解释比较麻烦,但是我们可以先思考一下,代码运行时,该怎么知道该方法真的被挂起呢?难道是增加了suspend就要被挂起吗?


故此,还是需要一个返回值,用于确定,该挂起函数是否真的被挂起。


在IDE中,对于使用了suspend的方法而言,如果内部没有其他挂起函数,那么编译器就会提示我们移除suspend标记,如下所示:


image-20230304225126237




3. 为什么返回值类型是Object?


对于挂起函数而言,在协程,是否真的被挂起,通过函数返回值来确定,但相应的,如果我们有挂起函数需要具备返回类型呢?那如果该函数没有挂起呢?如下示例所示:


image-20230304224957432


对于挂起函数而言,返回值有可能是 COROUTINE_SUSPENDEDUnit.INSTANCE 或者最终返回我们方法需要的返回类型结果,所以采用 Object 作为返回值以适应所有结果。


深入探索


在上面,我们看到了 suspend 在底层的转换细节,那回到挂起函数本质上,它到底是怎么做到 **挂起 ** 与 恢复 的呢?


故此,本小节,我们将就着这个问题,从字节码层次,展开分析,力求流程完整明了,不过相对而言可能有点繁琐。


如下代码所示:


fun main() = runBlocking {
val isSuccess = copyFileTo(File("old.mp4"), File("new.mp4"))
println("---copy:$isSuccess")
}


suspend fun copyFileTo(oldFile: File, newFile: File): Boolean {
val isCopySuccess = withContext(Dispatchers.IO) {
try {
oldFile.copyTo(newFile)
// 示例代码,通常这里需要验证字节流或者MD5
true
} catch (e: Exception) {
false
}
}
return isCopySuccess
}

这是一段用于将文件复制到指定文件的示例代码,具体伪字节码如下:


image-20230306214535952



上述的步骤实在是难读,思路整理起来比较绕圈,不过还是建议开发者多理解几遍。



上述的步骤如下:


当左侧 main() 方法开始执行时,因为示例中使用的 runBlocking(),其需要传递一个函数式接口对象,通常我们会以 lambda表达式 的形式去实例化这个函数对象,然后在其中写入我们的业务代码。


所以根据最终的字节码对比,我们的lambda会被转化为如下的形式:


suspend CoroutineScope.() -> Unit
⚡️ ->
(Function2) (new Function2((Continuation) null){}
// 具体伪代码如下所示,为什么会是这样的,下面会解释
class xxx(Continucation) : Function2<CoroutineScope,Continucation,Any> {
fun invoke(Any,Continucation) : Any {}
}

接着当我们的函数被调用时,会触发 invoke() 方法,即我们的函数体开始执行,开始进入我们的业务代码中。因为 invoke() 需要返回一个Object(因为我们的函数体本身也是suspend),这时候,会先创建一个 Continuation 对象,用于执行协程体逻辑,然后去调用 invokeSuspend() 方法从而获得本次的执行结果。



这里为什么要再去创建一个 Continuation?不是在runBlocking()里已经利用lambda表达式实例化了函数对象了吗?


不知道是否会有同学有这个疑问,所以这里依然需要解释一遍。


我们知道,在 kotlin 中,lambda 是匿名内部类的一种实例化方式(简化),所以这里只是给 runBlocking() 函数传递了所需要的方法参数。但是这个 lambda 内部的 invoke() 依然是挂起函数(因为增加过suspend),所以这里的匿名内部类实际上也是实现了 Continuation(默认的只有Funcation1,2,3等等),为了便于底层调用 invoke() 时传递 Continuation ,否则后续挂起恢复流程就断了🔺。相应的,为了延续 invoke() 里的挂起函数流程,编译器在当前匿名类内部又创建了一个 anonymous constructor(无类型) 的内部类(实际上是继承自SuspendLambda),从而在其 ivokeSuspend() 里执行当前挂起函数的状态机。


所以来说,大家可以理解我们传递的 lambda 相当于一个入口,但是其内部(即invoke)的触发方法,又是一个 挂起函数 ,这也就是为什么 invoke() 里需要创建 Continuation ,以及为什么 invoke() 方法参数里需要有 continuation 的原因,以及为什么字节码中会出现 new Function2((Continuation) null) ,Continuation 为null 的情况🤔,因为它压根没有 continuation 啊(不在挂起函数内部😂)。


这里的解释稍许有些啰嗦,但这对于理解全流程将非常有用,如果不是很理解,建议多读几遍。



invokeSuspend() 方法里,即正式进入了函数的状态机,这里的状态标记使用了一个 int 类型的 label 表示。



  • 默认执行 case 0,因为我们接下来要进入 copyFileTo() 方法,而该方法也是一个挂起函数,所以执行该方法后会获得一个返回状态,用于判断该函数是否真的已经挂起。如果返回值是 COROUTINE_SUSPENDED,则证明该函数已经挂起,然后直接 return 当前函数的挂起状态(相当于告诉父callback,当前我内部已经在忙了,你可以先执行自己的事了,等我执行完再通知你),否则继续执行当前剩余逻辑。

  • copyFileTo() 执行结束后,会再次触发当前 invokeSuspend(),因为我们在 case0 里已经更新了label为1,然后正常执行接下来的流程。


我们再去看一下 copyFileTo() 方法,我们在字节码中可以看到,其默认先创建了当前的 ContinuationImpl() ,并在初始化时将父 Continuation 也保存在其中,接着进入状态机开始执行逻辑,因为我们在该方法里有使用 withContext() 切换到IO调度器,所以这里也需要获取 withContext() 的挂起状态,如果成功挂起,则直接 return 当前状态,类似上述 invokeSuspend() 里的流程。


需要注意的,我们 withContext() 范围内,虽然经历了CPS转换,但因为不存在其他挂起函数,所以并不会再返回是否挂起,而是直到我们的逻辑执行结束 ,从而触发 withContext() 内部去调用 resumeWith(),从而恢复外部 copyFileTo() 的执行,重复此流程,从而恢复 runBlocking() 内部的继续执行,然后拿到我们的最终结果。


总结


关于Kotlin协程的挂起与恢复,从字节码去看,核心的 continuation 似乎有点像 callback 的嵌套,但相比 callback ,协程做的更加完善。比如当触发挂起函数调用时,会进入其内部对应的状态机,从而触发状态流转。并且为了避免了 callback 的 重复创建,而每一个挂起函数内部都会复用当前已创建好的 continuation


比如说,对于挂起函数,编译器会对其进行 CPS转换 ,从而使其从:


supend fun test()
->
fun test:(Continuation):Any?

当我们在挂起函数中调用该函数时,编译器就会将当前的 continuation 也一并传入并获得当前函数的结果。在具体调用时,如果挂起函数内部真的挂起(函数返回值为 COROUTINE_SUSPENDED ),则将调用权交还给调用方,然后当前的状态+1。而当该挂起函数内部执行结束时,因为其持有着外部的 continuation ,所以会调用 continuation.resume() 恢复挂起的协程,即调用了 invokeSuspend() ,从而恢复执行先前的逻辑。


而我们常说的状态机,从根本上,其实就是构造了一个 switch 结构的label流转,每个 case 内部都可能又会再对应着一个类似的结构(如果存在挂起函数)。如果我们称其为分层,那每一层也都持有上层的对象,而当我们最底层的函数执行结束时,即开始触发恢复上层逻辑,此时状态回传,从而将子函数的结果返回出去。


协程的矛与盾


当我们在讨论协程时,首先要明确,我们是在说 Kotlin协程 ,下述论点也都是基于这个背景下开始。



相应的,我们也需要一个参照物,如果直接对比线程,未免有些太过于不公平,所以我们选用 线程池协程 进行对比分析。



协程是线程框架吗?


Jvm 平台,因为 协程 底层离不开 Java线程模型 ,故最终的任务也是需要 线程池 最终去承载。所以从底层而言,我们可以通俗且大胆的认为协程就是一个线程框架,这没问题。


[但],这显然不是很合适,或者说,这有点过于糙了!


在文章开始,我们已经提过了,Android官方对其的描述:



协程是一种并发设计模式,您可以在 Android 平台上使用它来简化异步执行的代码。



所以,如果我们从协程本质与设计思想去看待,显然其相比线程池具有更高层次的编程模型,故此时称其为 异步编程框架 也许更为合适。具体原因与分析有如下几点:




  • 从编程模型而言


    协程与线程池两者都是用于处理异步任务或者耗时任务的工具,但两者的编程模型完全不同。线程池或者其他线程框架,往往使用回调函数来处理任务,这种方式常常比较繁琐,业务复杂时,代码可读性较差;而协程则是异步任务同步写法,基于挂起恢复的理念,由程序员自己控制执行顺序,可读性高;




  • 从异常的处理角度而言


    在线程池中,处理异常时,我们可以通过 tryCach 业务代码,或者可以在创建线程池时,自定义 ThreadFactory , 然后使用 Thread.setDefaultUncaughtExceptionHandler() 设置一个默认异常处理方式。相应的,协程通过 异常处理机制 来捕获和处理异常,相对于线程池而言,更加先进。




  • 从调度方式而言


    线程池通过创建一个固定数量的线程池来执行并发任务。每个任务将在一个可用的线程上运行,任务执行结束后,线程将返回线程池以供以后使用,并且通过在队列中等待任务来保持活动状态。如果使用协程,它并不创建新的线程,在jvm平台,其是利用少量的线程来实现并发执行,支持在单线程中执行,并使用 挂起与恢复 机制来允许并发执行。




协程性能很高?



先给结论,通常情况,协程的性能与线程池相差不大,甚至大多数常见场景,协程性能其实是不如直接使用线程池。



同时启动10w线程和协程


在协程官网,我们大概都能看到这样一句话,同时启动10w和线程和协程等等。


我们举个例子来看看,如下所示:


同时启动10w线程同时启动10w协程
image-20230319122629722image-20230319122642399

协程果然比线程快多了,那此时肯定就有同学说了,你拿协程欺负线程,咋不用线程池呢?


使用线程池替代线程


我们继续测试,这次改为线程池:


image-20230319122953566



线程池就是快啊!⚡️



如果你这样想,证明你可能理解错了🙅🏻‍♂️,我们这里只是往线程池里添加了10w个任务,因为我们用例里核心线程数是10,所以,同一时刻,只有10个任务在被处理,所以剩下的任务都在队列中等待。即这里打印的耗时仅仅只是上述代码的耗时,而不是线程池执行任务的总耗时,相比之下协程可是真真实实把10w个都跑完了,所以这两者根本没法比较。


所以我们对上面的逻辑进行更改,如下所示:


image-20230319123353502


总耗时…,没工夫等待了,不过我们可以大概算一下,总耗时16分钟多(10w/10*0.1/60)🤔。


为什么呢?明明底层都是线程池?


如果注意观察的话,线程的等待我们使用的是 sleep() ,而协程是 delay() ,两者的区别在于,前者是真真实实让我们的线程阻塞了指定时间,而后者则是语言级别,故差距很大。所以如果要做到相对公平,我们应该选用支持定时任务的线程池。


使用线程池模拟delay


为了保证相对公平,我们使用 ScheduledExecutorService ,并且将这个线程池转为协程的调度器。


结果如下:


添加10w个任务启动10w个协程
image-20230319131156258image-20230319131213967

???为什么线程池更快呢?😟


因为协程底层,最终任务还是需要我们的线程池来承载,但协程还需要维护自己的微型线程,而这个模型又是语言级别的控制,所以当协程代码转为字节码之后,即需要更多的代码才能实现。相比之下,线程池就简单直接很多,故这也是为什么线程池会快一点的原因。


场景推荐


通常情况下,我们真正耗时的任务都是IO网络 或者其他操作,所以此时协程的应用层的额外操作几乎并不影响大局。或者说面对复杂的异步场景是,此时性能也许并不是我们首先考虑,而如何更清晰的编码与封装实现,才是我们所更关心的。相应的,相比线程池,协程就很擅长这个处理异步任务。比如协程可以通过简化异步操作,也能在很大程度上,能避免我们不当的操作行为导致阻塞UI线程行为,从而提高应用性能。故在某个角度而言,协程的性能相比不恰当的使用线程池,是会更高。


所以如果我们的场景对性能有这极致要求,比如应用启动框架等,那么此时使用协程往往并不是最佳选择。但如果我们的场景是日常的业务开发,那么协程绝对是你的最佳选择。


协程的使用技巧


将协程设置为可取消


在协程中,取消属于协作操作,也就是说,当我们cancel掉某个job之后,相应的协程在挂起与恢复之前并不会立即取消(原因是协程的check时机是在我们状态机的每个步骤里),即也就是说,如果你有某个阻塞操作,协程此时并不会被取消。


如下所示:


image-20230319110607834


如上所示,我们会发现,当我们 cancel() 子协程后,我们的 readFile() 依然会正常执行。


要解释原理也非常简单:


因为 readFile() 并不是挂起函数,并且该方法内部也没有做协程 状态判断


在协程中,我们常用的函数 delay()withContext()ensureActive()yield() 等都提供了检查功能。


我们改动一下上述示例,如下所示:


image-20230319183838882image-20230319183911944

如上所示,我们在 readFile() 中增加了 yield() 方法,而当我们 cancel() 掉子协程时,当 Thread.sleep() 执行结束后,遇到 yield()时,该方法就会判断当前协程作用域是否已经不在活跃,如果满足条件,则直接抛出 CancellationException 异常。


协程的同步问题?


因为 Kotlin协程 是运行在 Java线程模型 基础之上,所以相应的,也存在 同步 问题。


在多线程的情况下,操作执行的顺序是不可预测的。与编译器优化操作的顺序不同,线程无法保证以特定的顺序运行,而上下文切换的操作随时有可能发生。所以如果在访问一个未经处理的状态时,线程很有可能就会访问到过时的数据,丢失必要的更新,或者遇到 资源竞争 等情况。


所以,使用了协程并且涉及可变状态的类必须采取措施使其可控,比如保证协程中的代码所访问的数据是最新的。这样一来,不同的线程之间就不会互相干扰。


如下示例:


image-20230314225515905


上述代码很简单,需要注意的是,为了防止 println() 先于我们的 repeat() 执行结束,我们使用measureTimeMillis()+coroutineScope() 进行嵌套,从而等待 coroutineScope() 内部所有子协程全部执行结束,才退出 measureTimeMillis()


不过从结果来看,不出意外的也存在同步问题,那该怎么解决?



按照Java开发中的习惯,我们可以使用 synchronized ,或者使用 AtomicInteger 管理sum。



常规方式解决


如下所示,我们选用 synchronized 来解决:


image-20230319111855237


如上所示,我们使用了 synchronized 对象锁来解决同步问题。



注意:这里我们锁的是 this@coroutineScope ,而不是 this ,前者代表着我们循环外的作用域对象,而直接使用this则代表了当前协程的作用域对象,并不存在竞争关系。



使用Mutex解决


除去传统的解决方式之外,Kotlin 中还增加了额外的辅助类去解决协程同步问题,其使用起来也更加简单,即 Mutex(互斥锁) ,这也是协程中解决同步问题的推荐方式。


如下示例:


image-20230314230330867


我们创建了一个 Mutex 对象,并使用其 加锁方法 withLock() ,从而避免多协程下的同步问题。相应的,Mutex 也提供了 lock()unLock() 从而控制对共享资源的访问(withLock()是这两者的封装)。


从原理上而言,Mutex 是通过 一个 AtomicInteger 类型的状态记录锁的状态(是否被占用),并使用一个 ConcurrentLinkedQueue 类型的队列来持有 等待持有锁 的协程,从而解决多个协程并发下的同步问题。


相比传统的 synchronized 阻塞线程,Mutex 内部使用了 CAS机制,并且支持协程的挂起恢复,其可扩展性,其都更具有优势;并且在协程的挂起函数中使用 synchronized,也可能会影响协程的正常调度和执行。故无论是上手难度及可读性,Mutex 无疑是更适合协程开发者的。


Mutex是性能的最佳选择吗?


在过往,我们提到 synchronized 都会觉得,它会直接阻塞线程,大家都会不约而同的推荐CAS作为更好的替代。但其实 synchronizedjdk1.6 之后,已经增加了各种优化,比如增加了各种锁去减缓直接加锁所导致的上下文切换耗时。


所以,我们对比一下上述的耗时:


image-20230319185125581image-20230319185132651

为什么 Mutex 的性能其实不如 synchronized 呢?


原因如下



  • Mutex 在处理并发访问时会产生额外的开销,由于 Mutex 是一个互斥锁,它需要操作系统层面的支持来实现,包括支持挂起和恢复、上下文切换和内核态和用户态之间的切换等操作,这些操作都需要较大的系统开销和时间,导致 Mutex 的性能较差。

  • synchronized 采用了一种更加灵活的方式来实现锁的机制,它会检查锁状态,如果没有被持有,则可以立即获取锁。如果锁被持有,则选择等待,或者继续执行其他的任务。从具体的实现上来说,synchronized 底层由jvm保证,在运行过程中,可能会出现偏向锁、轻量级锁、重量级锁等。关于 synchronized 相关的问题,大家也可以去看看我这篇文章 浅析 synchronized 底层实现与锁相关


最后,我们再看一下 KotlinFlow 中关于同步问题的解决方法:


image-20230319120056743


嗯,所以Mutex还要不要用了?🤨


如果我们把视线向上提一级,就会理解,当我们在选用 Kotlin 协程的时候,就已经选择了为了使用方便去容忍牺牲一部分性能。再者说,如果你的业务真的对性能要求极致,那么协程本身其实并不是首选推荐的,此时你应该选用线程池去处理,从而得到性能的最大化,因为协程本身的微型机制就需要做更多的额外操作。


再将视角切回到同步问题的处理上,Mutex 是协程中的推荐解决同步问题的方式,而且支持挂起与恢复,这点是其他同步解决方式无法具备的;再者说,Mutex 的上手难度相比 synchronized 低了不少。而至于性能上的差距,对于我们的业务开发而言,几乎是不会感知到,所以在协程中,Kotlin团队建议我们使用Mutex。


协程的异常处理方式


关于协程的异常处理,其实一直都不是一个简单事,或者说,优雅的处理异常并没那么简单。


在传统的原生的异常处理中,我们处理异常无在乎是这两种:



  • tryCatch();

  • Thread.setDefaultUncaughtExceptionHandler();


后者常用于非主线程的保底,前者用于几乎任何位置。


因为协程底层也是使用的java线程模型,所以上述的方式,在协程的异常处理中,同样有效,如下所示:


image-20230319163635334



上述的 runCatching() 是kotlin中对 tryCatch() 的一种封装。



使用CoroutineExceptionHandler


在协程中,官方建议我们使用 CoroutineExceptionHandler 去处理协程中异常,或者作为协程异常的保底手段,如下所示:


image-20230319164039472


我们定义了一个 CoroutineExceptionHandler,并在初始化 CoroutineScope 时将其传入,从而我们这个协程作用域下的所有子协程发生异常时都将被这个 handler 所拦截。



这里使用了 SupervisorJob() 的原因是,协程的异常是会传递的,比如当一个子协程发生异常时,它会影响它的兄弟协程与它的父协程。而使用了 SupervisorJob() 则意味着,其子协程的异常都将由其自己处理,而不会向外扩散,影响其他协程。



还有一点需要注意的是, CoroutineExceptionHandler 只能用于初始化 CoroutineScope 本身的初始化或者其直接子协程(即scope.launch),否则就算创建子协程时携带了 CoroutineExceptionHandler,也不会生效。


关于协程的异常处理,具体可以看我的这篇文章,里面有详细讲解:Kotlin | 关于协程异常处理,你想知道的都在这里


常见高阶函数


在开发中,有一些高阶函数,对我们特别有用,这里就将其列出来,以便大家开发中进行使用:


image-20230319190852334


如果你对上述的方法都非常了解,那不妨为自己鼓鼓掌。👏


总结


在本篇,我们着力于从全盘看起,理清 Kotlin协程 的方方面面。从 协程背景 到 挂起函数字节码实现,一瞥挂起与恢复的底层实现,从而体会其相应的设计魅力,并针对一些常见问题进行分析与解析,从而建立起协程彻底理解。文章中挂起函数部分的源码部分可能稍显繁琐,但依然建议大家多看几遍流程,从而更好理解。相应的细节问题,也都有详细注释。


最后,让我们再回到这个问题,协程到底是什么呢?



在JVM平台,Kotlin协程就是一个异步编程框架,它可以帮助我们简化异步代码,提升可读性,从而极大减少异步回调所带来的复杂逻辑。



从底层实现来看:



  • kotlin协程基于 java线程模型 ,故底层依然是使用了 线程池 作为任务承载,但相比传统的线程模型,协程在其基础上搭建了一套基于语言级别的 ”微型“ 线程模型。并定义了挂起函数作为相应的子任务,其内部采用了状态机的思想,用于实现协程中的挂起与恢复。

  • 在挂起与恢复的实现上,使用了 suspend 关键字标记的函数被称为挂起函数。其在字节码中,会经过 CPS转换 为一个带有 Continuation 参数,返回值为 Object 的方法。而 Continuation 正是用于保存我们的函数状态、步骤,从而实现挂起恢复,其内部也都包含着上一个 Continuation,正如 callback 的嵌套一样。

  • 当我们的函数被挂起时,我们当前的函数内部会实例化一个 ContinuationImpl() ,其内部 invokeSuspend() 又维护着当前的函数逻辑,并使用一个 label 作为状态进行流转,如果我们的函数内部依然有其他挂起函数,此时也会将当前的 Continuation 对象传入子挂起函数内部,从而实现 Continuation 的传递,并更改当前的函数状态。而当我们最底层的方法执行结束后,此时就会再次触发父 ContinuationImpl 内部的 invokeSuspend() 方法,从而回到调用方的逻辑内部,从而完成挂起函数的恢复。以此类推,直到我们最开始的调用方法内;


从性能上去看:



  • 协程的性能并不优于线程池或者其他异步框架,主要是其做了更多语言级别步骤,但通常情况下,与其他框架的性能几乎一致,因为相比IO的耗时,语言级别的损耗可以几乎忽略不计;


从设计模式去看:



  • 协程使得开发者可以自行管理异步任务,而不同于线程的抢占式任务,并且写成还支持子协程的嵌套关闭、更简便的异常处理机制等,故相比其他异步框架,协程的理念更加先进;


参照



关于我


我是 Petterp ,一个 Android工程师 ,如果本文对你有所帮助,欢迎 点赞、评论、收藏,你的支持是我持续创作的最大鼓励!



欢迎关注我的 公众号(Petterp) ,期待与你一同前进 :)



作者:Petterp
来源:juejin.cn/post/7212311942613385253
收起阅读 »

多端登录如何实现踢人下线

1:项目背景 一个项目往往会有小程序,APP,PC等多端访问,比如淘宝,京东等。这时候就会有一些踢人下线的需求,比如你在一台电脑登录了PC端,这时候你再另外一台电脑也登录PC端,这时候之前在另外一台电脑上就会被强制下线。 或者你登录了PC端,这时候你登陆了AP...
继续阅读 »

1:项目背景


一个项目往往会有小程序,APP,PC等多端访问,比如淘宝,京东等。这时候就会有一些踢人下线的需求,比如你在一台电脑登录了PC端,这时候你再另外一台电脑也登录PC端,这时候之前在另外一台电脑上就会被强制下线。


或者你登录了PC端,这时候你登陆了APP或者小程序,这时候PC端的账号也会被强制下线


2:项目只有PC端


假设我们现在的项目只有PC端,没有小程序或者APP,那么这时候就是很简单了,用户的sessin(也就是所谓的Token)一般都是存储在redis中,session中包括用户ID等一些信息,当然还有一个最重要的就是登录的ip地址。


image.png


1:用户在登录的时候,从redis中获取用户session,如果没有就可以直接登录了


2:用户在另外一台电脑登录,从redis中获取到用户session,这时候用户session是有的,说明用户之前已经登录过了


3:这时候从用户session中获取IP,判断二者的ip是不是相同,如果不同,这时候就要发送一个通知给客户端,让另外一台设备登录的账号强制下线即可


3:项目有PC端和APP端和小程序端


当你的应用有PC端和APP端的时候,我们用户的session如果还是只存一个ip地址,那明显就是不够的,因为很多情况下,我们PC端和APP端是可以同时登录的,比如淘宝,京东等都是,也就是所谓的双端登录


这时候就会有多种情况


单端登录:PC端,APP端,小程序只能有一端登录
双端登录:允许其中二个端登录
三端登录:三个端都可以同时登录

对于三端可以同时登录就很简单,但是现在有个限制,就是app端只能登录一次,不能同时登录,也就是我一个手机登录了APP,另外一个手机登录的话,之前登录的APP端就要强制下线


所以我们的用户session存储的格式如下


{
userId:用户的id
clientType:PC端,小程序端,APP端
imei:就是设备的唯一编号(对于PC端这个值就是ip地址,其余的就是手机设备的一个唯一编号)
}


单端登录


首先我们要知道,用户登录不同的设备那么用户session是不一样的。对于单端登录,那么我们可以拿到用户的所有的session,然后根据clientType和imei号来强制将其它端的用户session删除掉,然后通知客户端强制下线


双端登录


同样拿到所有用户的session,然后根据自己的业务需求来判定哪一端需要强制下线,比如我们现在已经登录了PC端和APP端,这时候登录小程序,现在要让APP端的强制下线。


这时候登录之后获取用户所有的session,这时候会有二个用户session,首先拿到clientType = APP的session,然后来通知客户端这个端需要强制下线。


如果这时候我登录了PC端和一个APP端,这时候我用另外一台手机登录APP端,那么之前那台手机上登录的APP端就要被强制下线,这个时候仅通过clientType是不行的,因为我二个手机登录的clientType都是APP端。所以这时候就要根据imei号来判断了。因为不同的手机imei号是不一样的。


这时候我拿到用户所有的session



PC端的session
sessionA{
userId: 1,
clientType: PC,
imei: "123"
}

APP端的session
sessionA{
userId: 1,
clientType: APP,
imei: "12345"
}

这时候我从另外一台手机登录的时候,生成的session应该是这样的


 APP端的session
sessionA{
userId: 1,
clientType: APP,
imei: "1234567"
}

我发现同一个clientType的session已经有了,这时候我要判断imei号是否一样,imei一样说明是同一台设备,不同说明不是同一台设备,我们只需要把对应设备的账号强制下线即可了


总结


不管是单端登录,双端登录还是多端登录,我们都是根据用户session来判断。只要根据clientType和imei号来就可以满足我们大部分的踢人下线需求了。


作者:我是小趴菜
来源:juejin.cn/post/7213598216884486204
收起阅读 »

百度文心大模型不好用,可能是你打开方式不正确

大众向科普文章,谁都能看懂。 群友AI作画失败了 今天百度文心一言大模型发布会,我看到摸鱼群里 文心模型被群嘲了 。 大图慎点,确实有点吓人。 群里大家用的都是这个:基于ERNIE-ViLG的文心一格 然后大家都在说百度这个好垃圾,还是国外的模型好,还放...
继续阅读 »

大众向科普文章,谁都能看懂。




群友AI作画失败了


今天百度文心一言大模型发布会,我看到摸鱼群里 文心模型被群嘲了


image.png


大图慎点,确实有点吓人。


image.png


群里大家用的都是这个:基于ERNIE-ViLG的文心一格


然后大家都在说百度这个好垃圾,还是国外的模型好,还放了一下国外模型生成的妹子:


image.png


其实可能不是模型垃圾,是没找到正确的打开方式


我生成的


我生成的是下图这样的,我觉得不阴间,并且 还挺好看的


WeChat Screenshot_20230316202725.png


WeChat Screenshot_20230316202735.png


WeChat Screenshot_20230316202744.png


先不要急着说为什么你不生成真人,拿二次元出来算什么。


为什么拿二次元图作对比,我后边会解释。


使用默认风格,虽说没多可爱,但是也没那么阴间吧……


image.png


1.png


国外模型真就比文心好?


先来通俗地说两个概念:


模型


给我们生成图像的就是模型,我们给模型输入文字,模型给我们生成图片。


模型只是一堆代码写好的,像一个什么也不会的小朋友,我们需要去模型怎么生成东西。




  • 如果我们给模型一堆美少女,模型就能画出来一堆美少女,画不出男的。




  • 如果我们给模型一堆风景,那模型就能画风景,画不出来人。




检查点


你训练好的模型,学到了对应的知识,我们把这个知识称为cheakpoint,也就是检查点


比如一个小孩子不会说话:




  • 你教他英语,他以后就会说英语,那我们可以认为这个孩子的checkpoint是英语。




  • 你教他汉语,他以后就是说汉语的,那我们可以说这个孩子的checkpoint是汉语。




checkpoint在这里代指我们教给他的东西。


对比


国外现在我们接触到的是基于Stable Diffusion的。


百度的文心一格是基于ERNIE-VilG的。


为什么国外模型效果好?


国外模型效果好,是因为国外发展早,生态好,因为国外模型的cheakpoint多啊。你使用不同的Checkpoint就能生成不同类型的图片了。


下图是国外社区中Stable Diffusion的Checkpoint的冰山一角:


image.png


那百度呢?百度现在展示的是自己训练的checkpoint,怎么可能和人家那么多比啊。


俗话说双拳难敌四手,俗话说三个臭皮匠顶个诸葛亮……所以你拿人家专门微调好的checkpoint和百度去比,就相当于让一个母语是英语的人一个学了几天英语的中国人比英语水平。


当然你让国外模型的一个Checkpoint去生成它不擅长的东西,它出来的结果也很阴间。


效果对比


先说画妹子


prompt: ((masterpiece,best quality)),1girl, long hair, black hair, long black dress, pigtails, outdoor, red bows, loafer


文本提示: 女,黑头发,双马尾,黑裙子,学生,小皮鞋,黑眼睛,长裙,细节,高清,学生


国外


先展示一下我用国外模型画的二次元人物。这三个checkpoint都是专门生成二次元人物的。


CheakPoint File:meinamix_meinaV8



CheakPoint File:mixProV3_v3



CheakPoint File:pastelMixStylizedAnime_pastelMixPrunedFP16



再来看一下百度


我感觉百度的生成效果已经蛮好的了,毕竟上边三个模型,人家是在专门数据集上训练好的专门用于画二次元妹子的checkpoint,百度作为一个比较通用的模型,能做到这样真的OK了。


WeChat Screenshot_20230316202712.png


不是我尬吹,你直接用Stable Diffusion的原始模型,效果还不如文心一格呢……不信你看:


image.png


所以百度真的不拉胯的……


再来看一下风景


文本提示:雪山,河流,麋鹿,森林,风景,写实,照片,细节,高清


prompt:((masterpiece,best quality)),a high-quality, detailed, and professional image,snowy mountains, rivers, elk, deer, reindeer, forests, scenery,realistic,photos,details,high-definition



因为识别不出elk 麋鹿,所以我还多加了几个buff:鹿deer,驯鹿reindeer



我让一个国外二次元模型画


我的麋鹿呢?那是什么牛马?


WeChat Image_20230316192042.png


文心一格基础模型生成好歹有个麋鹿样子的,至少称得上差强人意:


image.png


但是直接使用Stable Diffusion当前最高版本的基础模型,效果真的没那么牛。


你看到的优秀照片都是选到了很厉害的checkpoint生成出来的成功样例罢了,幸存者偏差。


image.png


然后我换了几个CheckPoint:


checkpoint1:根本找不到麋鹿,训练时候应该就没告诉它麋鹿怎么画。


image.png


checkpoint2:有麋鹿了,还有两只,不过一只没有头。


image.png


checkpoint3:终于有麋鹿了,虽然也是不尽人意,但是这个checkpoint本来画风就比较奇幻,所以我觉得这个麋鹿还OK的。


image.png


那我怎么生成好看的图?


你可能还想说:你是狗托吧,为啥模型在你手里输出的就是正常图片,在我手里就不正常?


看一下群友的:


image.png


看一下我的:


WeChat Screenshot_20230316202838.png


我的老婆是一位身材窈窕的美女可不可以改成老婆,身材好,漂亮呢?然后顺带加上高清,细节修饰词。



高清,细节不加也行的,文心一格画质还挺好,加上这俩纯属我使用stable diffusion后遗症



功能给你了,你好好用啊!好好写写文字提示(prompt),好好选风格,生成出来的东西绝对不会阴间的。


如果你觉得没有说服力,那我拿国外模型给你展示一下:


同一个cheakpoint出来效果完全不同的两个图,而他们的区别仅在于我用的prompt不一样


在第二个图中我在文本提示中写了高画质高质量等词语。


image.png


image.png


这就好比:


你:安安你快夸我。


我:6


你:安安你快夸我,字多一点。


我:牛逼


你:安安你快夸我,字多一点,再多点。


我:牛哇牛哇


你:安安你快夸我,字多一点,再多点,最好能像动漫里软萌的妹子那样。


我:哇,欧尼酱好棒呀!今天也有好好工作呢!


文本提示越精准,模型生成图片的质量就越好。所以想要正常图片你就好好写文本提示。


当然还有其他因素,比如采样步数、负提示等等,但是百度这里没设置,也没法对比,就不说了。




为什么你不用真人模型举例子


因为我还没摸索到怎么生成好看的真人。


比如我造周淑怡:


image.png


有点像,但是也一般,看起来比较假。


但是你们看看人家造的周姐:


高清,还很像! 是模型不行吗?不是的,别人能造出来就证明不是模型的问题,是我的问题,是我还没摸索到怎么生成好看的周姐


image.png


因为我造真人也不好看,我要是拿真人照片举例子,就会显得国外模型也很拉胯,所以我选二次元图举例子。


不信你看我拿外国模型生成的双马尾妹子,也是有点恐怖……


image.png




为什么要写这个文章?


国内本来就不如国外生态好,其实模型本身效果还OK了,但是一些用户打开方式不正确,导致模型被群嘲。


其实没掌握使用技巧的话,试用国外模型也不见得会有多好的结果。


文心大模型也好,还是别的模型也好,背后是工作人员的呕心沥血。文心确实不够完美,但是也不能因为错误的使用方法或怎么一味地去否认它。


刷朋友圈看到一个评论,我感觉特别好:


image.png



“见惯了大场面的Robin今天似乎有些紧张。但不得不说,文心一言或许确实不够完美,但能够在这种环境下把他推出来,值得一些掌声了。”



作为最底层的NLP人,我向百度致敬,致敬他们的勇气,致敬他们的努力和付出。



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

还没用上chatGPT? 看看这几个替代方案吧

chatGPT面世都小半年了, 但是我总在使用的时候发现之前能访问的代理又挂掉了. 非常恼火. 于是楼主整理了一些低门槛且比较稳定的使用 chatGPT 的方案, 供掘友们参考: 1.NAT 该网站是 github 前 CEO, Nat Friedman 创建...
继续阅读 »

chatGPT面世都小半年了, 但是我总在使用的时候发现之前能访问的代理又挂掉了. 非常恼火.


于是楼主整理了一些低门槛且比较稳定的使用 chatGPT 的方案, 供掘友们参考:


1.NAT


该网站是 github 前 CEO, Nat Friedman 创建的, 集合了多种模型,


网站一个有趣的功能模块叫做 Compare, 提问一次可以对比多个模型的回答


image.png


免费+不用科学上网. 注册无需手机号, 只需要邮箱,


2.POE


POE 是国外问答网站 Quora 创立的, Quora大家应该都知道, 就相当于是国外版的知乎.


在POE集合了好几种大语言模型, 主要的两个是chatGPT 和 Claude.


Claude是 Anthropic公司出的模型, 它的最大特点是底层用卷积神经网络实现的, 在资源受限的环境下比较适用



Claude 是一种基于卷积神经网络 (Convolutional Neural Network, CNN) 的图像分类模型。该模型最初由 Google Brain 的研究人员开发,旨在提供一个高效且准确的图像分类解决方案。Claude 模型的设计基于 ResNet 架构,但引入了一些新的特性,包括 SE 模块和 Mixup 数据增强技术。这些特性使得 Claude 在 ImageNet 数据集上达到了 86.1% 的 Top-1 准确率和 98.7% 的 Top-5 准确率,超过了其他一些常用的模型,如 Inception-v3 和 ResNet-152。



image.png


注册需要提供邮箱+手机号. 亲测国内手机号可用


3.newBing


需要下载 Edge 浏览器, 注册一个微软账号, 并申请 newBing 体验


之前体验需要排队, 不过目前可以秒开体验资格


在使用体验上觉得 newBing 最好. newBing 的亮点是会给出信息来源网站, 以及会给出几个更具体更贴切的问题选项, 相当于用户就从做填空题变成了做选择题, 提问起来更高效


image.png


注意在使用 newBing 时候需要翻墙


4.其他国内代理服务


近期伴随着 chatGPT 的爆火, 也涌现了大量的国内代理, 这里就不一一介绍了. 不过国内代理的 chatGPT 服务与官网相比, 使用体验上还是差点, 而且有些代理商还会收费. 如果有能力的话, 还是建议自己注册chatGPT账号, 以获得最佳的使用体验。


结语


以上几种方案楼主都亲测可用. 在使用体验上, newBing 是最好的, 不过需要翻墙; NAT 有别致的 Compare 对比功能, POE 里有独特的卷积神经网络模型


大家可以自行选择, '驯服'属于自己的大语言模型.


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

ChatGPT能否取代程序员?仍然是一个需要认真探讨的问题,对此你怎么看?

导言 ChatGPT能否取代程序员?作为一个AI语言处理程序,ChatGPT已经取得了重大的进展,它可以与人类进行流畅的对话,并能够接受和解释自然语言输入,并输出人类可理解、有意义的回复。然而,它是否能够取代程序员,仍然是一个需要认真探讨的问题。 ChatGP...
继续阅读 »

导言


ChatGPT能否取代程序员?作为一个AI语言处理程序,ChatGPT已经取得了重大的进展,它可以与人类进行流畅的对话,并能够接受和解释自然语言输入,并输出人类可理解、有意义的回复。然而,它是否能够取代程序员,仍然是一个需要认真探讨的问题。


ChatGPT的优势


首先,应该看到的是,ChatGPT具备的一些优势。ChatGPT已经被广泛应用于自然语言处理和对话生成等方面,这使得它可以代替程序员进行一些简单而重复性的工作。对于某些简单的外部调用或数据预处理等流程,ChatGPT可以通过自然语言输入和输出来完成,而无需程序员的干预。


此外,ChatGPT可以减少程序员在编写代码时的错误率。编程需要高度的精确性和细节,人们的犯错率很高,这是因为疏忽或思维方式不同。与人不同,ChatGPT能够在不需要任何帮助的情况下准确地执行指令,这意味着可以减少不必要的人为错误。


ChatGPT的缺陷和局限性


然而,ChatGPT作为一种工具,它仍然存在缺陷和局限性。 ChatGPT不能为程序员提供与业务需求紧密相连的解决方案。程序员不仅需要解决问题,还需要理解业务需求和目标,并设计出相应的解决方案。这需要程序员具备能力在技术和业务级别上进行交互,在这个问题上,ChatGPT的应用有限。



此外,ChatGPT无法在编程过程的所有阶段,提供与人类程序员相同或超过的创造性能力。创造力是人类的重要特征之一,表现在很多场合,如想象、创新等。在设计解决方案时,程序员需要花费大量时间进行创造性思考,以找到最佳的解决方案。这是ChatGPT无法做到的。


此外,ChatGPT也无法处理逻辑错误和紧急情况。在编程过程中,程序员需要处理各种问题和情况,如逻辑错误、紧急情况、文档和代码管理等。这些问题需要人类程序员处理,才能保证程序的稳定性和可靠性。 ChatGPT不能像人类程序员一样处理这些问题。


最后,需要考虑到的是,ChatGPT是AI的一种形式,它本身也存在缺陷和问题。在某些情况下,ChatGPT可能受到训练数据的影响,导致生成的结果不可靠。此外,ChatGPT还无法处理高度复杂的问题。这使得在某些情况下,程序员仍然需要进行人工编程。


总结


总的来说,ChatGPT可以在一定程度上替代程序员,但不能完全取代。尤其在需要进行创意思考、理解业务需求和解决紧急问题等方面,人类程序员的价值得到了体现,而ChatGPT的应用有限。ChatGPT和程序员是可以共存的,ChatGPT的发展将更加有助于程序员将更多的时间投入到解决问题本身。对此你怎么看?


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

面向 ChatGPT 开发 ,我是如何被 AI 从 “逼疯” 到 “觉悟” ,未来又如何落地

对于 ChatGPT 如今大家应该都不陌生,经过这么长时间的「调戏」,相信大家应该都感受用 ChatGPT 「代替」搜索引擎的魅力,例如写周报、定位 Bug、翻译文档等等,而其中不乏一些玩的很「花」的场景,例如: ChatPDF :使用 ChatPDF...
继续阅读 »

对于 ChatGPT 如今大家应该都不陌生,经过这么长时间的「调戏」,相信大家应该都感受用 ChatGPT 「代替」搜索引擎的魅力,例如写周报、定位 Bug、翻译文档等等,而其中不乏一些玩的很「花」的场景,例如:




  • ChatPDF :使用 ChatPDF 读取 PDF 之后,你可以和 PDF 文件进行「交谈」,就好像它是一个完全理解内容的「人」一样,通过它可以总结中心思想,解读专业论文,生成内容摘要,翻译外籍,并且还支持中文输出等





  • BiBiGPT : 一键总结视频内容,主要依赖字幕来做总结,绝对是「二创」作者的摸鱼利器。





所以把 ChatGPT 理解为「搜索引擎」其实并不正确,从上述介绍的两个落地实现上看, ChatGPT 不是单纯的统计模型,它的核心并不是完全依赖于它的「语料库」,更多来自于临场学习的能力「 in-context learning」,这就是 ChatGPT 不同于以往传统 NLP「一切都从语料的统计里学习」的原因



当然,我本身并非人工智能领域的开发者,而作为一个普通开发者,我更关心的是 ChatGPT 可以如何提升我的开(mo)发(yu)效率,只是没想到随手一试,我会被 ChatGPT 的 「 in-context learning」 给「逼疯」。



ChatGPT & UI


相信大家平时「面向」 ChatGPT 开发时,也是通过它来输出「算法」或者「 CURD」 等逻辑居多,因为这部分输出看起来相对会比较直观,而用 ChatGPT 来绘制前端 UI 的人应该不多,因为 UI 效果从代码上看并不直观 ,而且 ChatGPT 对与 UI 的理解目前还处于 「人工智障」的阶段。



但是我偏偏不信邪。。。。。



因为近期开发需求里恰好需要绘制一个具有动画效果的 ⭐️ 按键,面对这么「没有挑战性」的工作我决定尝试交给 ChatGPT 来完成,所以我向 ChatGPT 发起了第一个命令:



「用 Flutter 画一个黄色的五角星」











结果不负众望,关键部分如下代码所示,Flutter 很快就提供了完整的 Dart 代码,并且还针对代码提供了代码相关实现的讲解,不过运行之后可以看到,这时候的 ⭐️ 的样式并不满足我们的需求。



此时顶部的角也太「肥」了 。











所以我随着提出了调整,希望五角星的五个角能够一样大,只是没想到我的描述,开始让 ChatGPT 放飞自我



也许是我的描述并不准确?




在我满怀期待的 cv 代码并运行之后,猝不及防的「五角星」差点没让我喷出一口老血,虽然这也有五个角,但是你管这个叫 「五角星」 ???



这难道不是某个红白机游戏里的小飞机??











甚至于在看到后续 ChatGPT 关于代码的相关讲解时,我觉得它已经开始在「一本正经的胡说八道」,像极了今天早上刚给我提需求的产品经理



哪里可以看出五个角相同了???




接着我继续纠正我的需求,表示我要的是 「一个五个角一样大的黄色五角星」 ,我以为这样的描述应过比较贴切,须不知·····



如下代码所示,其实在看到代码输出 for 循环时我就觉得不对了,但是秉承着「一切以实物为准」的理念,在运行后不出意外的发生了意外,确实是五个角一样大,不过是一个等边五边形。



算一个发胖的 ⭐️ 能解(jiao)释(bian)过去不?











再看 ChatGPT 对于代码的描述,我发现我错了,原来它像的是「理解错需求还在嘴硬的我」,只是它在说「这是一个五角星」的时候眼皮都不会眨一下



AI:确实五个角一样大,五个角一样大的五边形为什么就不能是五角星?你这是歧视体型吗?




所以我继续要求:「我要的是五角星,不是五边形」,还好 ChatGPT 的临场学习能力不错,他又一次「重新定义五角星」,不过我此时我也不抱希望,就是单纯想看看它还能给出什么「惊喜」



不出意外,这个「离谱」的多边形让我心头一紧,就在我想着是否放弃的时候,身为人类无法驯服 AI 「既爱又恨」的复杂情绪,让我最终坚持一定要让 ChatGPT 给我画出一个 ⭐️。










不过心灰意冷之下,我选择让 ChatGPT 重新画一个黄色五角星,没想道这次却有了意外的惊喜,从下面的图片可以看到,此时的 ⭐️ 除了角度不对,形状已经完全满足需求。



所以一个问题我多问几遍,也许就能接近我要的答案?




事实上这也是目前 ChatGPT 的现状,因为「临场学力」能力等因素影响,同一个问题它可能会给出不同的答案,而有的答案其实和我们要的根本不沾边










那么,接下来只要让 ChatGPT 把 ⭐️ 旋转一下角度,应该就可以完成需求····了吧?所以我提出「帮我旋转 180度」的要求。



结果不出意外的还是发生了意外,可能 ChatGPT 理解的 180 度和我不大一样,如下图所示,确实旋转了,只是还是歪的,而基于前面的尝试,我觉得有必要再给它一次机会。










终于,在我换了以下描述之后,ChatGPT 似乎「开窍」了,终于让我如愿以偿的得到了我想要的 ⭐️ 代码。



可以看到,其实这段代码并不复杂,你自己写或者去搜索引擎扒一扒大概会比用 ChatGPT 更快,但是 ChatGPT 的魅力就在于:它也许会错,但是总有一次它会「蒙对」,如果现在还不对,那么未来也会有算对的时候,这就是我感受到的 AI 魅力所在










既然五角星都出来,那么接下来我们来完成最后一步的动画效果,我们希望的是点击 ⭐️ 的时候可以有一个爆炸粒子的动画,所以我提出了:



「希望加上一个动画效果,就是点击的时候星星爆炸成颗粒的动画效果」




结果还是「一口老血」,如下图所示,「星星确实爆炸了」,只是这个爆炸动画看起来是那么的「一言难尽」,感兴趣的可以 cv 下面的源代码自己体验下,不出意外接下来又是新的一轮人类和 AI 的「斗智斗勇」。




class YellowStar extends StatefulWidget {
@override
_YellowStarState createState() => _YellowStarState();
}

class _YellowStarState extends State<YellowStar> with TickerProviderStateMixin {
bool _isExploded = false;
late AnimationController _animationController;
late Animation<double> _animation;

@override
void initState() {
super.initState();
_animationController = AnimationController(
duration: const Duration(milliseconds: 500),
vsync: this,
);
_animation = CurvedAnimation(
parent: _animationController,
curve: Curves.easeInOut,
);
}

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

void _handleTapDown(TapDownDetails details) {
setState(() {
_isExploded = true;
_animationController.forward(from: 0.0);
});
}

void _resetStar() {
setState(() {
_isExploded = false;
_animationController.reset();
});
}

@override
Widget build(BuildContext context) {
return GestureDetector(
onTapDown: _handleTapDown,
onTap: _resetStar,
child: AnimatedContainer(
duration: const Duration(milliseconds: 500),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: _isExploded
? BorderRadius.circular(50)
: BorderRadius.zero,
),
child: _isExploded
? _buildParticles()
: Container(
width: 100,
height: 100,
child: CustomPaint(
painter: YellowStarPainter(),
),
),
),
);
}

Widget _buildParticles() {
return AnimatedBuilder(
animation: _animation,
builder: (context, child) {
return Container(
width: 100,
height: 100,
child: Stack(
children: List.generate(
50,
(index) {
double radius = _animation.value * 50;
double angle = 2 * pi * index / 50;
double x = 50 + cos(angle) * radius;
double y = 50 + sin(angle) * radius;
return Positioned(
left: x,
top: y,
child: Container(
width: 4,
height: 4,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.yellow,
),
),
);
},
),
),
);
},
);
}
}



class YellowStarPainter extends CustomPainter {
final double starSizeRatio = 0.4;
final double centerOffsetRatio = 0.2;
final double rotationOffset = -pi / 2;

@override
void paint(Canvas canvas, Size size) {
double starSize = min(size.width, size.height) * starSizeRatio;
double centerX = size.width / 2;
double centerY = size.height / 2;
double centerOffset = starSize * centerOffsetRatio;

Path path = Path();
Paint paint = Paint()
..color = Colors.yellow
..style = PaintingStyle.fill;

for (int i = 0; i < 5; i++) {
double radians = 2 * pi / 5 * i + rotationOffset;
double x = centerX + cos(radians) * starSize / 2;
double y = centerY + sin(radians) * starSize / 2;
if (i == 0) {
path.moveTo(x, y);
} else {
path.lineTo(x, y);
}

radians += 2 * pi / 10;
x = centerX + cos(radians) * centerOffset;
y = centerY + sin(radians) * centerOffset;
path.lineTo(x, y);
}

path.close();
canvas.drawPath(path, paint);
}

@override
bool shouldRepaint(CustomPainter oldDelegate) => false;
}

经过一轮「鏖战」如下图所示,面对同一个问题,ChatGPT 总能找到不同的实现方式来回答你,只是这些实现里有的无法运行的,有的是运行没有效果的,有的甚至是给你输出了一个 //TODO 爆炸动画 ,总之就是让你「血压飙升」。





当然,「胜利终将属于人类」,如下图所示,在经历了无数次「血压飙升」的场景之后,最终我还是得到了我想要的:点击 ⭐️,然后出现了💥的动画效果,尽管还是不完美,但是基本满足了我后续开发的需求。



这个代码完完全全由 ChatGPT 输出,我一句没改,完整代码贴在了后面。





class YellowStarPage extends StatefulWidget {
@override
_YellowStarPageState createState() => _YellowStarPageState();
}

class _YellowStarPageState extends State<YellowStarPage>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
bool _isExploded = false;

@override
void initState() {
super.initState();
_animationController =
AnimationController(vsync: this, duration: Duration(milliseconds: 500))
..addListener(() {
setState(() {});
});
}

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

void _handleStarTap() {
if (!_isExploded) {
_isExploded = true;
_animationController.forward(from: 0);
Future.delayed(Duration(milliseconds: 500), () {
setState(() {
_isExploded = false;
});
});
}
setState(() {});
}

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Yellow Star')),
body: Center(
child: GestureDetector(
onTap: _handleStarTap,
child: Container(
width: 300,
height: 300,
child: AnimatedBuilder(
animation: _animationController,
builder: (context, child) {
return CustomPaint(
painter: YellowStarPainter(_animationController.value,
isExploded: _isExploded),
);
},
),
),
),
),
);
}
}

class YellowStarPainter extends CustomPainter {
final double starSizeRatio = 0.4;
final double centerOffsetRatio = 0.2;
final double rotationOffset = -pi / 2;

final double animationValue;
final bool isExploded;

YellowStarPainter(this.animationValue, {this.isExploded = false});

@override
void paint(Canvas canvas, Size size) {
double starSize = min(size.width, size.height) * starSizeRatio;
double centerX = size.width / 2;
double centerY = size.height / 2;
double centerOffset = starSize * centerOffsetRatio;

Path path = Path();
Paint paint = Paint()
..color = Colors.yellow
..style = PaintingStyle.fill;

if (isExploded) {
double particleSize = starSize / 30;
paint.strokeWidth = 1;
paint.style = PaintingStyle.fill;
paint.color = Colors.yellow;
Random random = Random();

for (int i = 0; i < 30; i++) {
double dx = random.nextDouble() * starSize - starSize / 2;
double dy = random.nextDouble() * starSize - starSize / 2;
double x = centerX + dx * (1 + animationValue);
double y = centerY + dy * (1 + animationValue);

canvas.drawCircle(Offset(x, y), particleSize, paint);
}
} else {
for (int i = 0; i < 5; i++) {
double radians = 2 * pi / 5 * i + rotationOffset;
double x = centerX + cos(radians) * starSize / 2;
double y = centerY + sin(radians) * starSize / 2;
if (i == 0) {
path.moveTo(x, y);
} else {
path.lineTo(x, y);
}

radians += 2 * pi / 10;
x = centerX + cos(radians) * centerOffset;
y = centerY + sin(radians) * centerOffset;
path.lineTo(x, y);
}

path.close();
canvas.drawPath(path, paint);
}
}

@override
bool shouldRepaint(CustomPainter oldDelegate) => true;
}


最后,给大家欣赏一下我让 ChatGPT 画一只米老鼠的「心路历程」,很明显这一次「人类一败涂地」,从目前的支持上看,让 ChatGPT 输出复杂图像内容并不理想,因为它不的笔画「不会拐弯」。













真的是又爱又恨。



最后


经过上面的一系列「折腾」,可以看到 ChatGPT 并没有我们想象中智能,如果面向 GPT 去开发,甚至可能并不靠谱,因为它并不对单一问题给出固定答案,甚至很多内容都是临场瞎编的,这也是因为大语言模型本身如何保证「正确」是一个复杂的问题,但是 ChatGPT 的魅力也来自于此:



它并不是完全基于语料来的统计来给答案



当然这也和 ChatGPT 本身的属性有关系, ChatGPT 目前的火爆有很大一部分属于「意外」,目前看它不是一个被精心产品化后的 2C 产品,反而 ChatPDFBiBiGPT 这种场景化的包装落地会是它未来的方向之一。


而现在 OpenAI 发布了多模态预训练大模型 CPT-4GPT-4 按照官方的说法是又得到了飞跃式提升:强大的识图能力;文字输入限制提升至 2.5 万字;回答准确性显著提高;能够生成歌词、创意文本,实现风格变化等等



所以我很期待 ChatGPT 可以用 Flutter 帮我画出一只米老鼠, 尽管 ChatGPT 现在可能会让你因为得到 1+1=3 这样的答案而「发疯”」,但是 AI 的魅力在于,它终有一天能得到准确的结果


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

Kotlin委托的原理与使用,以及在Android开发中常用的几个场景

Kotlin委托的常见使用场景 前言 在设计模式中,委托模式(Delegate Pattern)与代理模式都是我们常用的设计模式(Proxy Pattern),两者非常的相似,又有细小的区分。 委托模式中,委托对象和被委托对象都是同一类型的对象,委托对象将任务...
继续阅读 »

Kotlin委托的常见使用场景


前言


在设计模式中,委托模式(Delegate Pattern)与代理模式都是我们常用的设计模式(Proxy Pattern),两者非常的相似,又有细小的区分。


委托模式中,委托对象和被委托对象都是同一类型的对象,委托对象将任务委托给被委托对象来完成。委托模式可以用于实现事件监听器、回调函数等功能。


代理模式中,代理对象与被代理对象是两种不同的对象,代理对象代表被代理对象的功能,代理对象可以控制客户对被代理对象的访问。代理模式可以用于实现远程代理、虚拟代理、安全代理等功能。


以类的委托与代理来举例,委托对象和被委托对象都实现了同一个接口或继承了同一个类,委托对象将任务委托给被委托对象来完成。代理模式中,代理对象与被代理对象实现了同一个接口或继承了同一个类,代理对象代表被代理对象,客户端通过代理对象来访问被代理对象。


两者的区别:


他们虽然都有同一个接口,主要区别在于委托模式中委托对象和被委托对象是同一类型的对象,而代理模式中代理对象与被代理对象是两种不同的对象。总的来说,委托模式是为了将方法的实现交给其他类去完成,而代理模式则是为了控制对象的访问,并在访问前后进行额外的操作。


而我们常用的委托模式怎么使用?在 Java 语言中需要我们手动的实现,而在 Kotlin 语言中直接通过关键字 by 就可以实现委托,其实现更加优雅、简洁了。


我们在开发一个 Android 应用中,常用到的委托分为:



  1. 接口/类的委托

  2. 属性的委托

  3. 结合lazy的延迟委托

  4. 观察者的委托

  5. Map数据的委托


下面我们就一起看看不同种类的委托使用以及在 Android 常见的一些场景中的使用。


一、接口/类委托


我们可以选择使用接口来实现类似的效果,也可以直接传参,当然接口的方式更加的灵活,比如我们这里就以接口比如我定义一个攻击与防御的行为接口:


interface IUserAction {

fun attack()

fun defense()
}

定义了用户的行为,有攻击和防御两种操作!接下来我们就定义一个默认的实现类:


class UserActionImpl : IUserAction {

override fun attack() {
YYLogUtils.w("默认操作-开始执行攻击")
}

override fun defense() {
YYLogUtils.w("默认操作-开始执行防御")
}
}

都是很简单的代码,我们定义一些默认的操作,如果任意类想拥有攻击和防御的能力就直接实现这个接口,如果想自定义攻击和防御则重写对应的方法即可。


如果使用 Java 的方式实现委托,大致代码如下:


class UserDelegate1(private val action: IUserAction) : IUserAction {
override fun attack() {
YYLogUtils.w("UserDelegate1-需要自己实现攻击")
}

override fun defense() {
YYLogUtils.w("UserDelegate1-需要自己实现防御")
}
}

如果使用 Kotlin 的方式实现则是:


class UserDelegate2(private val action: IUserAction) : IUserAction by action

如果 Kotlin 的实现不想默认的实现也可以重写部分的操作:


class UserDelegate3(private val action: IUserAction) : IUserAction by action {

override fun attack() {
YYLogUtils.w("UserDelegate3 - 只重写了攻击")
}
}

那么使用起来就是这样的:


    val actionImpl = UserActionImpl()

UserDelegate1(actionImpl).run {
attack()
defense()
}

UserDelegate2(actionImpl).run {
attack()
defense()
}

UserDelegate3(actionImpl).run {
attack()
defense()
}

打印日志如下:


image.png


其实在 Android 源码中也有不少委托的使用,例如生命周期的 Lifecycle 委托:


Lifecycle 通过委托机制实现其功能。具体来说,组件可以将自己的生命周期状态委托给 LifecycleOwner 对象,LifecycleOwner 对象则负责管理这些组件的生命周期。


例如,在一个 Activity 中,我们可以通过将 Activity 对象作为 LifecycleOwner 对象,并将该对象传递给需要注册生命周期的组件,从而实现组件的生命周期管理。 页面可以使用 getLifecycle() 方法来获取它所依赖的 LifecycleOwner 对象的 Lifecycle 实例,并在需要时将自身的生命周期状态委托给该 Lifecycle 实例。


通过这种委托机制,Lifecycle 实现了一种方便的方式来管理组件的生命周期,避免了手动管理生命周期带来的麻烦和错误。



class AnimUtil private constructor() : DefaultLifecycleObserver {

...

private fun addLoopLifecycleObserver() {
mOwner?.lifecycle?.addObserver(this)
}

// 退出页面的时候释放资源
override fun onDestroy(owner: LifecycleOwner) {
mAnim?.cancel()
destory()
}

}


除此之外委托还特别适用于一些可配置的功能,比如 Resutl-Api 的封装,如果当前页面需要开启 startActivityForResult 的功能,就实现这个接口,不需要这个功能就不实现接口,达到可配置的效果。


/**
* 定义是否需要SAFLauncher
*/
interface ISAFLauncher {

fun <T : ActivityResultCaller> T.initLauncher()

fun getLauncher(): GetSAFLauncher?

}

由于代码是固定的实现,目标Activity也不需要重新实现,我们只需要实现默认的实现即可:


class SAFLauncher : ISAFLauncher {

private var safLauncher: GetSAFLauncher? = null

override fun <T : ActivityResultCaller> T.initLauncher() {
safLauncher = GetSAFLauncher(this)
}

override fun getLauncher(): GetSAFLauncher? = safLauncher

}

使用起来我们直接用默认的实现即可:


class DemoActivity : BaseActivity, ISAFLauncher by SAFLauncher() {

override fun init() {
initLauncher() // 实现了接口还需要初始化Launcher
}

fun gotoOtherPage() {
//使用 Result Launcher 的方式启动,并获取到返回值
getLauncher()?.launch<DemoCircleActivity> { result ->
val result = result.data?.getStringExtra("text")
toast("收到返回的数据:$result")
}

}

}

这样是不是就非常简单了呢?具体如何使用封装 Result Launcher 可以看看我去年的文章 【传送门】


二、属性委托


除了类与接口对象的委托,我们还常用于属性的委托。


我知道了!这么弄就行了。


    private val textStr by "123"

哎?怎么报错了?其实不是这么用的。


属性委托和类委托一样,属性的委托其实是对属性的 set/get 方法的委托。


需要我们把 set/get 方法委托给 setValue/getValue 方法,因此被委托类(真实类)需要提供 setValue/getValue 方法,val属性只需要提供 getValue 方法。


我们修改代码如下:


    private val textStr by TextDelegate()

class TextDelegate {

operator fun getValue(thisRef: Any?, property: KProperty<*>): String {
return "我是赋值给与的文本"
}

}

打印的结果:


image.png


而我们定义一个可读写的属性则可以


  private var textStr by TextDelegate()

class TextDelegate {

operator fun getValue(thisRef: Any?, property: KProperty<*>): String {
return "我是赋值给与的文本"
}

operator fun setValue(thisRef: Any?, property: KProperty<*>, value: String) {
YYLogUtils.w("设置的值为:$value")
}

}

YYLogUtils.w("textStr:$textStr")
textStr = "abc123"

打印则如下:


image.png


为了怕大家写错,我们其实可以用接口来限制,只读的和读写的属性,我们分别可以用 ReadOnlyProperty 与 ReadWriteProperty 来限制:



class TextDelegate : ReadOnlyProperty<Any, String> {
override fun getValue(thisRef: Any, property: KProperty<*>): String {
return "我是赋值给与的文本"
}
}

class TextDelegate : ReadWriteProperty<Any, String> {
override fun getValue(thisRef: Any, property: KProperty<*>): String {
return "我是赋值给与的文本"
}

override fun setValue(thisRef: Any, property: KProperty<*>, value: String) {
YYLogUtils.w("设置的值为:$value")
}
}

那么实现的方式和上面自己实现的效果是一样的。如果要使用属性委托可以选用这种接口限制的方式实现。


我们的属性除了委托给类去实现,同时也能委托给其他属性(Kotlin 1.4+)来实现,例如:


    private var textStr by TextDelegate2()
private var textStr2 by this::textStr

其实是内部委托了对象的 get 和 set 函数。相对委托对象而言性能更好一些。而委托对象去实现,不仅增加了一个委托类,而且还还在初始化时就创建了委托类的实例对象,算起来其实性能并不好。


所以属性的委托不要滥用,如果要用,可以选择委托现成的其他属性来完成,或者使用延迟委托Lazy实现,或者使用更简单的方式实现:


    private val industryName: String
get() {
return "abc123"
}

对于只读的属性,这种方式也是我们常见的使用方式。


三、延迟委托


如果说使用类来实现委托不那么好的话,其实我们可以使用延迟委托。延迟关键字 lazy 接收一个 lambda 表达式,最后一行代表返回值给被推脱的属性。


默认的 Lazy 实现:


    val name: String by lazy {
YYLogUtils.w("第一次调用初始化")
"abc123"
}

YYLogUtils.w(name)
YYLogUtils.w(name)
YYLogUtils.w(name)

只有在第一次使用此属性的时候才会初始化,一旦初始化之后就可以直接获取到值。


日志打印:


image.png


它的内部其实也是使用的是类的委托实现。


public actual fun <T> lazy(initializer: () -> T): Lazy<T> = SynchronizedLazyImpl(initializer)


最终的实现是由 SynchronizedLazyImpl 类生成并实现的:


private class SynchronizedLazyImpl<out T>(initializer: () -> T, lock: Any? = null) : Lazy<T>, Serializable {
private var initializer: (() -> T)? = initializer
@Volatile private var _value: Any? = UNINITIALIZED_VALUE
// final field is required to enable safe publication of constructed instance
private val lock = lock ?: this

override val value: T
get() {
val _v1 = _value
if (_v1 !== UNINITIALIZED_VALUE) {
@Suppress("UNCHECKED_CAST")
return _v1 as T
}

return synchronized(lock) {
val _v2 = _value
if (_v2 !== UNINITIALIZED_VALUE) {
@Suppress("UNCHECKED_CAST") (_v2 as T)
} else {
val typedValue = initializer!!()
_value = typedValue
initializer = null
typedValue
}
}
}

override fun isInitialized(): Boolean = _value !== UNINITIALIZED_VALUE

override fun toString(): String = if (isInitialized()) value.toString() else "Lazy value not initialized yet."

private fun writeReplace(): Any = InitializedLazyImpl(value)
}

我们可以直接看 value 的 get 方法,如果_v1 !== UNINITIALIZED_VALUE 则表明已经初始化过了,就直接返回 value ,否则表明没有初始化过,调用initializer方法,也就是 lazy 的 lambda 表达式返回属性的赋值。


跟我们自己实现类的委托类似,也是实现了getValue方法。只是多了判断是否初始化的一些相关逻辑。


lazy的参数分为三种类型:



  1. SYNCHRONIZED:添加同步锁,使lazy延迟初始化线程安全

  2. PUBLICATION:初始化的lambda表达式,可以在同一时间多次调用,但是只有第一次的返回值作为初始化值

  3. NONE:没有同步锁,非线程安全


默认情况下,对于 lazy 属性的求值是同步锁的(synchronized),是可以保证线程安全的,但是如果不需要线程安全和减少性能花销可以可以使用 lazy(LazyThreadSafetyMode.NONE){} 即可。


四、观察者委托


除了对属性的值进行委托,我们甚至还能对观察到这个变化过程:


使用 observable 委托监听值的变化:


    var values: String by Delegates.observable("默认值") { property, oldValue, newValue ->

YYLogUtils.w("打印值: $oldValue -> $newValue ")
}

values = "第一次修改"
values = "第二次修改"
values = "第三次修改"

打印:


image.png


我们还能使用 vetoable 委托,和 observable 一样可以观察属性的变化,不同的是 vetoable 可以决定是否使用新值。


    var age: Int by Delegates.vetoable(18) { property, oldValue, newValue ->
newValue > oldValue
}

YYLogUtils.w("age:$age")
age = 14
YYLogUtils.w("age:$age")
age = 20
YYLogUtils.w("age:$age")
age = 22
YYLogUtils.w("age:$age")
age = 20
YYLogUtils.w("age:$age")

我们需要返回 booble 值觉得是否使用新值,比如上述的例子就是当新值大于老值的时候才赋值。那么打印的日志就是如下:


image.png


虽然这种方式我们并不常用,一般我们都是使用类似 Flow 之类的工具在源头就处理了逻辑,使用这种方式我们就可以在属性的赋值过程中进行拦截了。在一些特定的场景下还是有用的。


五、Map委托


我们的属性不止可以使用类的委托,延迟的委托,观察的委托,还能委托Map来进行赋值。


当属性的值与 Map 中 key 相同的时候,我们可以把对应 key 的 value 取出来并赋值给属性:


class Member(private val map: Map<String, Any>) {

val name: String by map
val age: Int by map
val dob: Long by map

override fun toString(): String {
return "Member(name='$name', age=$age, dob=$dob)"
}

}

使用:


        val member = Member(mapOf("name" to "guanyu", "age" to 36, Pair("dob", 1234567890L)))
YYLogUtils.w("member:$member")

打印的日志:


image.png


但是需要注意的是,map 中的 key 名字必须要和属性的名字一致才行,否则委托后运行解析时会抛出 NoSuchElementException 异常提示。


例如我们在 Member 对象中加入一个并不存在的 address 属性,再次运行就会报错。


image.png


而我们把 Int 的 age 属性赋值给为字符串也会报类型转换异常:


image.png


所以一定要一一对应才行哦,我怎么感觉有一点 TypeScript 结构赋值的那味道 - - !


image.png


总结


委托虽好不要滥用。委托毕竟还是中间多了一个委托类,如果没必要可以直接赋值实现,而不需要多一个中间类占用内存。


我们可以通过接口委托来实现一些可选的配置。通过委托类实现属性的监听与赋值。可以减少一些模板代码,达到低耦合高内聚的效果,可以提高程序的可维护性、可扩展性和可重用性。


对于属性的类委托,我们可以将属性的读取和写入操作委托给另一个对象,或者另一个属性,或者使用延迟委托来推迟对象的创建直到第一次访问。


对于 map 的委托,我们需要仔细对应属性与 key 的一致性。以免出现错误,这是运行时的错误,有可能出现在生产环境上的。


那么大家都是怎么使用的呢?有没有更好的方式呢?或者你有遇到的坑也都可以在评论区交流一下,大家可以互相学习进步。如有本文有一些错漏的地方,希望同学们可以指出。


如果感觉本文对你有一点点的帮助,还望你能点赞支持一下,你的支持是我最大的动力。


本文的部分代码可以在我的 Kotlin 测试项目中看到,【传送门】。你也可以关注我的这个Kotlin项目,我有时间都会持续更新。


Ok,这一期就此完结。



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

StartService别乱用,小心IllegalStateException

startService可以很方便的启动一个service服务,也可以运行在单独的进程。但是如果在后台调用了startService,则很可能会抛出一个崩溃。Caused by: java.lang.IllegalStateException: Not all...
继续阅读 »

startService可以很方便的启动一个service服务,也可以运行在单独的进程。

但是如果在后台调用了startService,则很可能会抛出一个崩溃。

Caused by: java.lang.IllegalStateException: Not allowed to start service Intent { cmp=com.test.broadservice/.MyService }: app is in background uid UidRecord{b67c471 u0a86 RCVR idle change:uncached procs:1 seq(0,0,0)}
at android.app.ContextImpl.startServiceCommon(ContextImpl.java:1577)
at android.app.ContextImpl.startService(ContextImpl.java:1532)
at android.content.ContextWrapper.startService(ContextWrapper.java:664)
at android.content.ContextWrapper.startService(ContextWrapper.java:664)
at android.app.ActivityThread$H.handleMessage(ActivityThread.java:1661)
at android.os.Handler.dispatchMessage(Handler.java:106)
at android.os.Looper.loop(Looper.java:193)
at android.app.ActivityThread.main(ActivityThread.java:6669)
at java.lang.reflect.Method.invoke(Native Method)
at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:493)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:858)

错误原因

Android8.0之后,系统增加了对后台Service的限制,如果应用处于后台,调用startService会抛出IllegalStateException

意思是,在后台的应用,不允许调用startService启动一个后台服务,否则就会抛出异常。

解决方法

1. 使用startForegroundService

使用方法比较简单,这里就不列出代码了。

有几点需要注意:

  • 用此方法启动前台服务,会在用户的通知栏上显示
  • 必须在5s内调用服务的startForeground方法,否则会发生ANR
2. 使用JobScheduler

使用JobScheduler可以实现在后台运行任务。

定义一个JobService:

public class MyJobService extends JobService {
@Override
public boolean onStartJob(JobParameters params) {
return false;
}

@Override
public boolean onStopJob(JobParameters params) {
return false;
}
}

onStartJob

  • 运行在主进程,需要避免执行耗时的操作。
  • 返回true:表示任务还在继续执行
  • 返回false:表示任务已执行完
  • 执行完之后,可以调jobFinished方法来通知系统任务已完成。

onStopJob:

  • 当条件不满足的时候,会回调这个方法。
  • 返回true:表示条件满足时,再次执行任务
  • 返回false:表示任务完全结束

注册JobService

        <service
android:name=".MyJobService"
android:permission="android.permission.BIND_JOB_SERVICE" />

定义一个JobInfo

JobInfo.Builder builder = new JobInfo.Builder(111, new ComponentName(this, MyJobService.class));
// todo 设置任务的参数

将任务发布给系统:

JobScheduler jobScheduler = (JobScheduler) getSystemService(Context.JOB_SCHEDULER_SERVICE);
jobScheduler.schedule(builder.build());
3. 使用WorkManager

WorkManagerJobScheduler的升级版本,且支持多进程,可以将任务运行到单独的进程中。具体使用方法可以参考之前的文章:应用退出后继续运行后台任务,来试试WorkManager吧!

总结

Android 8.0版本以上,在后台调用startService会抛出IllegalStateException异常,需要改用其他的方式来使用。

常用的方法有如下三种:

  • startForegroundService: 这种方式会在用户的通知栏显示UI。
  • JobScheduler:可以实现后台无感知运行任务。
  • WorkManagerJetpack里的库,JobScheduler的升级版,支持多进程。


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

从SharedPreferences和MMKV看本地数据迁移

1. 前言 之前也有听说过MMKV,但是一直没时间去看,前段时间去简单看看它的相关内容之后觉得挺有意思的,然后就想要不要用MMKV把SP给替换了,这时就又想到了一些数据迁移的问题,所以这次简单谈谈SharedPreferences和MMKV,主要我还是想谈谈数...
继续阅读 »

1. 前言


之前也有听说过MMKV,但是一直没时间去看,前段时间去简单看看它的相关内容之后觉得挺有意思的,然后就想要不要用MMKV把SP给替换了,这时就又想到了一些数据迁移的问题,所以这次简单谈谈SharedPreferences和MMKV,主要我还是想谈谈数据迁移这个问题。


2. MMKV


腾讯的MMKV,挺牛逼,为什么牛逼,很有想法,这也从侧面体现出想要做出牛逼的东西,你得敢想,然后你想出一套方案之后,还能去实现它。或许你看它的原理你觉得还行,也没多复杂什么的,但你能从0到1的过程想出这个方案然后去实现它吗?


首先要知道它为什么被设计出来,通过官方的介绍:需要一个性能非常高的通用 key-value 存储组件,我们考察了 SharedPreferences、NSUserDefaults、SQLite 等常见组件,发现都没能满足如此苛刻的性能要求。看得出是为了提升性能


那是不是说我觉得MMKV性能比SP好,所以我就用它?并不是这样的,如果你只是用key-value的组件去存状态等少量数据,而且不会频繁的读写,那SP是完全够用的,并且没必要引入MMKV。但是如果你存储的数据大数据复杂,并且频繁读写,假如你这次数据都没写完,又开始写下一次了,那就会有性能上的问题,这时候用MMKV去代替SP完全是一个很好的方案。


因为我当前的项目没有这样的需求,没达到这样的量级,所以暂不需要用到MMKV,但是我简单看了它的原理,比较核心的我觉得就两个思想:mmap和protobuf,其它的append啊这些都是在这基础上进一步优化的操作,核心的就是mmap和protobuf,特别是mmap。所以为什么说牛逼,因为如果是你做,没有参考的情况下,你能想出用mmap这种方案去优化吗?


什么是mmap,内存映射mmap,如果了解过Binder机制,那应该对它多多少少有些印象,如果不知道内存映射是什么,建议可以先去看看Binder机制,了解下一次拷贝的概念,再回来看mmap就知道是什么操作了,就知道为什么它要使用这种思路去做性能提升。


再看看另一个点protobuf,protobuf是一种数据存储格式,它所占用的空间更小,所以也是一个优化的点,占的空间越小,存储时所需要的空间就越小,传送也越快。


2. SharedPreferences


android经常使用的组件,喜欢用它是因为使用起来方便。可以简单看看它是怎么实现的,然后对比一下上面的MMKV。


一般我们调用都是SharedPreferences.Editor的commit()或者apply,然后点进去看发现Editor是一个接口,SharedPreferences也同样是个接口,点它的类看获取它的地方发现在Context里面


public abstract SharedPreferences getSharedPreferences(File file, @PreferencesMode int mode);

看它的子类实现在ContextWrapper里面


@Override
public SharedPreferences getSharedPreferences(File file, int mode) {
return mBase.getSharedPreferences(file, mode);
}

mBase就是Context,点之后又跳到Context里面了,完了,芭比Q了,死循环了,找不到SharedPreferences的实现类了。为什么要讲这个,其实如果你看源码比较多,你就会发现有个习惯,一般具体的实现类都是在抽象接口的后面加Impl,所以我们找SharedPreferencesImpl,当然你还有个办法能找到,就是百度。然后看SharedPreferencesImpl的commit方法


@Override
public boolean commit() {
......

MemoryCommitResult mcr = commitToMemory();

SharedPreferencesImpl.this.enqueueDiskWrite(
mcr, null /* sync write on this thread okay */);
try {
mcr.writtenToDiskLatch.await();
} catch (InterruptedException e) {
return false;
} finally {
if (DEBUG) {
Log.d(TAG, mFile.getName() + ":" + mcr.memoryStateGeneration
+ " committed after " + (System.currentTimeMillis() - startTime)
+ " ms");
}
}
notifyListeners(mcr);
return mcr.writeToDiskResult;
}

commitToMemory里面只是把数据包装成MemoryCommitResult,然后给enqueueDiskWrite方法


private void enqueueDiskWrite(final MemoryCommitResult mcr,
final Runnable postWriteRunnable) {
......

final Runnable writeToDiskRunnable = new Runnable() {
@Override
public void run() {
synchronized (mWritingToDiskLock) {
writeToFile(mcr, isFromSyncCommit);
}
......
}
};

......
QueuedWork.queue(writeToDiskRunnable, !isFromSyncCommit);
}

QueuedWork.queue就是放到队列操作,这个就不说的,来看writeToFile(挺长的,我这截取中间一部分)


try {
FileOutputStream str = createFileOutputStream(mFile);

if (DEBUG) {
outputStreamCreateTime = System.currentTimeMillis();
}

if (str == null) {
mcr.setDiskWriteResult(false, false);
return;
}
XmlUtils.writeMapXml(mcr.mapToWriteToDisk, str);

writeTime = System.currentTimeMillis();

FileUtils.sync(str);

fsyncTime = System.currentTimeMillis();

str.close();
ContextImpl.setFilePermissionsFromMode(mFile.getPath(), mMode, 0);

if (DEBUG) {
setPermTime = System.currentTimeMillis();
}

try {
final StructStat stat = Os.stat(mFile.getPath());
synchronized (mLock) {
mStatTimestamp = stat.st_mtim;
mStatSize = stat.st_size;
}
} catch (ErrnoException e) {
// Do nothing
}

if (DEBUG) {
fstatTime = System.currentTimeMillis();
}

// Writing was successful, delete the backup file if there is one.
mBackupFile.delete();

if (DEBUG) {
deleteTime = System.currentTimeMillis();
}

mDiskStateGeneration = mcr.memoryStateGeneration;

mcr.setDiskWriteResult(true, true);

if (DEBUG) {
Log.d(TAG, "write: " + (existsTime - startTime) + "/"
+ (backupExistsTime - startTime) + "/"
+ (outputStreamCreateTime - startTime) + "/"
+ (writeTime - startTime) + "/"
+ (fsyncTime - startTime) + "/"
+ (setPermTime - startTime) + "/"
+ (fstatTime - startTime) + "/"
+ (deleteTime - startTime));
}

long fsyncDuration = fsyncTime - writeTime;
mSyncTimes.add((int) fsyncDuration);
mNumSync++;

if (DEBUG || mNumSync % 1024 == 0 || fsyncDuration > MAX_FSYNC_DURATION_MILLIS) {
mSyncTimes.log(TAG, "Time required to fsync " + mFile + ": ");
}

return;
} catch (XmlPullParserException e) {
Log.w(TAG, "writeToFile: Got exception:", e);
} catch (IOException e) {
Log.w(TAG, "writeToFile: Got exception:", e);
}

其实能很明显第一眼就看出,是直接用FileOutputStream写到文件中,然后XmlUtils就是把这个文件写成xml的形式。其实SharedPreferences是用xml的格式存储数据相信大家都懂,我这里只是通过代码简单过一遍这个流程。


能看出SharedPreferences和MMKV的不同之处,SP是用FileOutputStream把数据写进本的,而MMKV是用了内存映射,MMKV明显会更快,存储数据的格式方面,SP是用了xml的格式,而MMKV用的是protobuf,明显也是MMKV会更小。


虽然SharedPreferences调用起来方便,但同样的也了一些缺点,比较多进程环境下,比如在某些快速读写的环境中使用apply等。那是不是说我就必须使用MMKV去代替SharedPreferences?其实并不是,你的功能没涉及多进程环境,没涉及频繁大量的读写数据,比如存就只存一个状态,或者说我隔一段时间才读写一次数据量不大的数据,那直接使用SharedPreferences也不会有什么问题。没必要大动干戈,杀鸡还要用牛刀?


3. 数据迁移


这才是我想讲的重点,什么是数据迁移,和SharedPreferences还有MMKV又有什么关系,数据迁移是一个解决问题的思路,和SP还有MMKV是没有关系,只不过我用它们两个来举例会比较好说明。


虽然MMKV好用是吧,假如说你有什么场景,用SP确实无法支持你的业务了,改用MMKV,但是你的旧版本中还是用的SP去存数据,直接覆盖升级可是不会删除磁盘数据的,那你得把SP之前存的xml格式的数据迁移到MMKV中,这就是一个本地数据迁移的过程。


如果从SP迁移到MMKV中,那应该挺简单,我相信MMKV中有对应的方法提供给你,我想腾讯开发的,肯定会考虑到这一点,如果没有,你自己写这个迁移的逻辑也不难。而且SP是android原生提供的组件,所以不会涉及到删除组件之类的操作。但是假如,我说假如,字节也出个key-value的组件,比如叫ByteKV,假如他不是用protobuf,是另一种能把数据压缩更小的格式。这时候你用MMKV,你想去替换成ByteKV,你要怎么做。


有的人就说了,那如果有这种情况,它们也会考虑兼容其它的组件,如果没有,那就在手动写迁移的逻辑,这个又不复杂。手写迁移的逻辑是不复杂,但有没有想过一个问题,你需要去删除之前的库,比如说你之前依赖MMKV,你现在换这个ByteKV之后,你需要不再依赖MMKV ,不然你就会每次换一个新的库,你都重新依赖,并且不删除旧的依赖。


比如你的1.0版本依赖MMKV,2.0版本改用ByteKV,在依赖ByteKV的同时,你还要依赖MMKV吗?SP是没有这个问题,因为它是原生的代码。


我帮你们想了一个办法,假如1.0版本依赖MMKV,我2.0版本当一个过渡版本依赖ByteKV和MMKV,我3.0再把MMKV的依赖去掉行不行?当然不行,那有些用户直接从1.0升到3.0不就导致没迁移的数据没了吗


那这要怎么处理,其实说来也简单,MMKV把数据存到本地的哪个文件这个你知道吧,它用protobuf的方式去存你也知道吧,那这事不就完了,你知道文件存哪里并以什么方式存,那你就能把内容读取出来,这和存的过程已经没有任何关系了。 所以你读这个文件的内容,根本就不需要MMKV,你只需要判断在这个文件夹下有这个文件,并且这个文件是某个格式的,就手动做迁移,迁移完之后再把文件删了。如果你不知道你所用的框架会把数据存到哪里,又是以什么格式存的,那也简单,去看它的源码就知道了。


这里是拿了MMKV来举例,数据库也一样,你改不同的数据库框架,无所谓,你知道它存在哪里,怎么存的,那你不用对应的库也能把数据提出来。


这其实就是数据迁移的原理,我管你是用什么库存的,你的库做的只不过是对存的过程的优化和决定数据的格式。


还有一个要注意的点是,数据不是一次性迁移完的,是部分部分迁移的,你先迁移一部分,然后删除旧文件的那部分数据。


总结


这篇文章其实主要是想简单介绍SP和MMKV的不同,了解MMKV是为何被设计出来,并且站在开发者的一个角度去思考,如果是你,你要怎样才能像他们一样,设计出这样的一套思路。


其次就是关于本地数据迁移的问题,如果去透过现象看本质,我们平时会用到很多别人写的库,为什么用,因为别人写得好,我自己从0开始设计没办法像他们一样设计得这么好,所以使用他们得。但我同样需要知道这其中的原理,知道他们是怎样去实现的。


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