注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

从应用工程师的角度再谈车载 Android 系统

前言 根据中汽协数据显示,2022年8月中国汽车出口量达30.8万辆,同比增长65%,这也是历史上首次超过30万辆。从今年前八个月整体情况来看,我国汽车出口量已经超越德国,仅次于日本汽车出口量。其中,新能源汽车1-8月出口量同比增长超九成,贡献了重要的增量。...
继续阅读 »

前言



根据中汽协数据显示,2022年8月中国汽车出口量达30.8万辆,同比增长65%,这也是历史上首次超过30万辆。从今年前八个月整体情况来看,我国汽车出口量已经超越德国,仅次于日本汽车出口量。其中,新能源汽车1-8月出口量同比增长超九成,贡献了重要的增量。



众所周知,今年互联网行业发展的并不愉快,导致互联网行业就业形势不太理想,“开猿节流”的事情时有发生,于是不少Android开发萌生了转行做车载的想法,之前我其实写过一篇凑数用得 Android车载应用开发与分析(11)- 车载Android应用开发入门指南,这篇文章的初衷其实是劝Android开发的同学慎重转行搞车载!


不过还是有些和我一样是从手机应用转行做车载应用的同学读完后,希望我能再详细讲讲车载Android的学习,一些准备做车载的同学,也认为之前的博客写得太乱,于是决定从一个车载应用工程师的角度,重新来讲讲车载Android系统。


车载操作系统


汽车操作系统是从传统汽车电子不断演变而来的,传统汽车电子产品可分为两类:


一类是汽车电子控制装置,通过直接向执行机构(如电子阀门、继电器开关、执行马达)发送指令,以控 制车辆关键部件(如发动机、变速箱、动力电池)协同工作,这类系统一般统称为电子控制单元(ECU);


另一类是车载电子设备,如仪表、娱乐音响、导航系统、HUD等,这类系统不直接参与汽车行驶的控制 决策,不会对车辆行驶性能和安全产生影响,通常统称为车载信息娱乐系统(IVI)。这也是Android程序员主要负责的领域。


主流车载操作系统架构



当前国内主流车载操作系统的架构如上所示,左侧是汽车的中控、副驾屏幕,操作系统一般是Android,右侧是汽车的仪表屏幕,一般是QNX系统。



车载系统中还有一些Security、SOA、AutoSAR相关的模块,这些模块作为Android工程师属于知道了也插不上手,画出来也看不懂的东西,就全部省略了。



先来解释几个Android程序员可能不太熟悉的模块:


以太网


以太网(Ethernet),是一种计算机局域网技术,也是互联网从业者,天天打交道的东西。在汽车座舱中IVI硬件与其他硬件间通信有时需要借助以太网来实现,例如:MQTT、HTTP等。


CAN


控制器局域网 (Controller Area Network,简称CAN或者CAN bus) 是一种功能丰富的车用总线标准。被设计用于在不需要主机(Host)的情况下,允许网络上的单片机和仪器相互通信。 它基于消息传递协议,设计之初在车辆上采用复用通信线缆,以降低铜线使用量,后来也被其他行业所使用。


CAN 是车载领域很重要的一种通信总线,我们在中控屏上可以随时查看、设置车门、发动机、后备箱这些模块,其实就是借助CAN bus实现的,即使是Android程序员也经常要和它打交道,以后会详细讲讲这个东西。


MCU


微控制器单元,它负责着汽车很大一部分的功能,例如通过车载控制器对各项数据进行分析处理,以做出最优决策;负责对车辆的信息娱乐交互和运动控制等等。


总的来说,MCU可以应用于车辆的通讯、能源、存储、感知以及计算,对汽车行业有着重要的作用。


SOC


SoC的定义多种多样,由于其内涵丰富、应用范围广,很难给出准确定义。一般说来, SoC称为系统级芯片,也有称片上系统(System on Chip),意指它是一个产品,是一个有专用目标的集成电路,其中包含完整系统并有嵌入软件的全部内容。


车载Soc和常见的手机Soc非常类似,内部集成了CPU和GPU。目前最主流的车载Soc是高通的8155,它就是高通在手机Soc骁龙855的基础上发展而来的。


QNX


QNX是商业类Unix实时操作系统,主要针对嵌入式系统市场。QNX采取微核心架构,操作系统中的多数功能是以许多小型的task来执行,它们被称为server。这样的架构使得用户和开发者可以关闭不需要的功能,而不需要改变操作系统本身。


QNX的应用十分广泛,被广泛应用于汽车、轨道交通、航空航天等对安全性、实时性要求较高的领域,在汽车领域的市场占有率极高。



该产品开发于20世纪80年代初,后来改名为QNX软件系统公司,公司已被黑莓公司并购。



Hypervisor


一种运行在基础物理服务器和操作系统之间的中间软件层,可允许多个操作系统和应用共享硬件。也可叫做VMM( virtual machine monitor ),即虚拟机监视器。


目前国内主流的汽车座舱,都是在一个SOC上同时运行着两个不同特性的操作系统。对娱乐、应用生态有需求的中控、副驾一般由Android系统控制,而对稳定性、安全性要求较高的仪表盘,则由QNX系统直接控制,Android可以看做是一个运行在QNX上的虚拟系统,其底层技术原理就是Hypervisor。




其实以上说得这些都是从Android工程师角度看到的车载操作系统,实际上这只是车载操作系统的冰山一角,最底层的Other Hardware更能代表智能汽车操作系统的核心,它包含高级驾驶辅助系统、泊车辅助系统、自动驾驶系统、TCU、4G/5G网关、中央控制器等等。这些复杂的硬件与软件共同组成了一个智能汽车操作系统。


现代汽车的操作系统是如此的复杂,一些汽车的TCU、中央控制器甚至还额外运行着一套操作系统(例如linux),所以现在还没有哪一个汽车/主机厂商能够独立完成整套系统的开发,基本都需要依赖大量的第三方软、硬件供应商(笔者之前就是就职于一家汽车软件供应商,不过现在已经处于提桶状态了)。


好在作为Android程序员我们只需要关心Android系统的那部分。


车载 Android 系统


车载Android系统,又称Android Automotive,是对原始Android系统的一个功能扩充版本,在编译AOSP源码时可以看到相应的编译选项。



Android Automotive 编译后的原始界面如下所示,相信有过车载开发经验的同学对这个界面一定不陌生,我们正是在这个界面上把车载Android系统一点点搭建起来的。



Android Automotive


Android Automotive 是一个基于 Android 平台扩展后,适用于现代汽车的智能操作系统,可以直接运行为Android系统开发的应用。Android Automotive并非Android的分支或并行开发版本。它与手机和平板电脑等设备上搭载的Android使用相同的代码库,位于同一个存储区中。


Android Automotive与Android最大的区别在于,Android Automotive增加了对汽车特定要求、功能和技术的支持。



Google的官方文档:source.android.google.cn/docs/device…



Android Auto


除了Android Automotive,Google还推出了一个Android Auto。两者的命名方式可能有点让人迷惑不解。下面介绍了它们之间的区别:



  • Android Auto 是一个基于用户手机运行的平台,可通过 USB 连接将 Android Auto 用户体验投射到兼容的车载信息娱乐系统。Android Auto本质上就是一个运行在Android系统上的车载应用,与苹果的CarPlay,百度的CarLife类似。





  • Android Automotive 是一个可定制程度非常高的开源Android平台,它是一个完整的操作系统。




需要说明的是,使用Android Auto需要用户的手机支持Google服务框架,所以一般只在国内销售的汽车基本都不支持Android Auto,一些沿用了国外车机系统的合资车型可能会支持Android Auto。


车载 Android 应用


常见的车载应用


SystemUI


系统的UI。SystemUI是一个标准的android应用程序,它提供了系统UI的统一管理方案。
常见的状态栏、导航栏、消息中心、音量调节弹窗、蓝牙连接弹窗等一系列后台弹窗都是由SystemUI模块负责管理。


开发难度:SystemUI作为Android系统启动的第一个带有UI的应用程序,对启动性能和稳定性都有很高的要求。SystemUI需要管理的模块非常多,导致开发任务比较繁重,有的车载项目会要求SystemUI兼容原有的应用层API,那么开发难度还会上升。开发人员需要对Android原生的SystemUI源码有一定的了解。


Launcher


Android系统的桌面。


开发难度:Launcher是与用户交互最多的应用程序之一,同样对启动性能和稳定性都有很高的要求。Launcher开发难度主要集中在与3D车模的互动(如果有3D模型),可能需要支持Widget的显示(WidgetHost),各种应用的拖动和编辑等。开发人员最好对Android原生的Launcher源码有一定的了解。


Settings


系统设置,是车载Android系统中非常重要的一个系统级应用,是整个车载IVI系统的控制中心,整车的音效、无线通信、状态信息、安全信息等等都是需要通过系统设置来查看和控制。


开发难度:系统设置主要难度都集中在对Android Framework层API的理解上,例如蓝牙、Wi-Fi设置就需要开发人员对系统级API有一定的了解,这些内容往往都需要阅读Android原生应用的源码才能了解,所以系统设置也是一个开发难度比较大的车载应用。


CarService


车载Android系统的核心服务之一,所有应用都需要通过CarService来查询、控制整车的状态。例如:车辆的速度、档位、点火状态等等。


VehicleSettings


车辆设置,更常用的叫法是『车控车设』。负责管理整个车辆内外设置项的应用,主要与CarService进行数据交互。可设置项非常多。驾驶模式、方向盘助力、后视镜折叠、氛围灯、座舱监测、无线充电等等。


开发难度:主要难度集中在复杂多变的UI,有的主机厂商会在HMI中引入3D化的交互模型,就还需要考虑与3D模型间的通信,同时还需要熟练运用CAN工具来模拟汽车的CAN信号用于调试和开发。


HVAC


空调。负责管理整个车辆空调的应用,主要与CarService进行数据交互。


开发难度:和『车控车设』类似。


Map


地图,车载系统的核心功能之一,负责导航和语音提示等功能。不同的主机厂商有不同的开发方式。不外乎有三种:


1)选择使用百度、高德的地图SDK自行开发导航应用;


2)将导航模块外包给百度、高德,由地图供应商进行定制化开发;


3)直接集成地图供应商已有的车载版本的应用;


开发难度:主要集中在对地图SDK的运用和理解上,而且地图应用属于对性能要求较高的模块。


Multi-Media


多媒体应用。一般包含图片浏览、在线音视频播放器、USB音视频播放器、收音机等。




车载的应用远不止以上说得这些,根据不同的需求,还有非常多的Service需要做定制化开发,这里只列举了最常见的应用类型。


汽车上还会有一些第三方的应用,常见的有QQ音乐、微信、QQ、抖音、讯飞输入法等等,这些应用主机厂商不会获得源码,一般只会拿到一个apk,直接集成到Android系统中即可。


车载应用与移动应用的区别


夸张一点说,移动端的应用开发和车载应用开发,完全就不是一个技术思路。总结一下大致有以下几个区别:


1)应用级别不同


多数车载应用属于系统级应用,可以调用Android SDK内部隐藏的API,也不需要动态地向用户申请权限。移动应用是普通应用,系统对其限制很多,需要遵守Android应用的开发规范。


由于车载应用是系统级应用,所以移动端很多常用的技术比如热修复、插件化基本都不会采用,但是相对的进程保活、开机自启就变得非常简单了。


2)迭代方式不同


移动应用只要用户的手机接入了WiFi就可以进行在线升级,所以移动应用多采用小步快跑的形式,进行快速迭代。


车载系统级应用的更新只能以整车OTA的形式进行,而OTA可能会消耗宝贵的车机流量,所以车载应用在SOP(量产)前,就必须完成全部需求的开发,而且不能出现严重的bug。在正式交付用户前,车厂内部或4S店还会进行几次OTA升级用做最后的bug修复。(如果在交付用户后还有严重的bug或需求未完成,那么大概率项目经理、程序员都要祭天了)


3)技术路线不同


正是因为车载应用对稳定性的要求极高,所以车载应用在开发时,对待新型技术会非常的慎重,比如,目前只有少数主机厂商在使用Kotlin开发车载应用,毕竟Android Framework都还没有改成Kotlin,大部分厂商对Kotlin的积极性不高。而且车载应用也不允许随意使用开源框架,如果必须使用,务必注意框架的开源协议,以免给汽车厂商带来不必要的麻烦。


4)运行环境不同


车载应用的运行环境是经过高度定制化的Android系统,定制化也就意味着bug。移动端的应用出现bug时,我们的第一反应是应用的代码有缺陷。车载应用发现bug也要考虑到是不是系统本身出现了bug,这是一件非常有挑战性的事,应用开发与系统开发相互扯皮、泼脏水也属于家常便饭。


车载应用需要掌握的技能


除了一般Android开发需要学习的基础内容外,一名优秀的车载应用工程师还需要掌握以下的技能


1)MVVM架构


虽然如今一些移动端应用已经开始尝试MVI架构,但是就像前面说得,车载应用对待新技术都会持观望态度,目前主流的车载应用还是采用基于Jetpack组件的MVVM架构。


2)构建系统级应用


由于多数车载应用都属于系统级应用,所以必须了解如何构建一个系统级应用,这方面的内容可以看我之前写得Android车载应用开发与分析(11)- 车载Android应用开发入门指南,虽然写得比较乱凑活看吧。


还有一本比较老的书《Android深度探索:系统应用源代码分析与ROM定制》也可以看一看。


3)性能优化


应用的性能优化是个亘古不变的话题,掌握应用的各种性能优化方式,也是一个Android程序员必备的生存手段,汽车座舱的SOC性能比旗舰手机要差不少,如果优化好车载应用将是一个非常有挑战性的任务。


4)IPC通信


Android中最常用的跨进程通信手段是Binder,因为有大量的Service需要与应用进行交互,所以基于Binder的AIDL在车载应用开发中使用得非常广泛,学会使用AIDL也同样属于必备技能之一。


5)CAN仿真测试工具


CAN仿真测试工具包含了软件和硬件,在车载应用开发时我们需要借助这些工具来模拟发送CAN性能给到IVI来调试我们的应用,在实车调试阶段,也需要借助这些工具来捕获车辆的CAN信号来分析一些bug。常用的有CAN alyzer、CANoe、TS-Master等等,这些工具价格都极其昂贵,独自购买不现实,在车载应用开发务必把握学习和使用的机会。


6)系统应用源码


这一项是我认为最重要的,不少车载应用层项目都是反复定制各种SystemUI、Launcher、Settings等等,读懂Android系统应用源码对我们定制化开发这些应用有非常大的好处。




以上是一些我认为车载应用开发时需要掌握的技能,其他的一些诸如:adb调试指令、Linux操作系统的运用、AOSP源码编译也都需要额外学习,根据不同的需求,JNI、NDK等技术也有可能会用上。


车载应用开发者的未来


这篇文章的开头提到了一则新闻,中国今年的汽车出口量已经超越德国仅次于日本,这似乎是一个振奋人心的消息。汽车工业的高速发展,对我们这些车载程序员当然属于利好,但是最近的一则消息又让我改变了看法。



9月29日,零跑汽车正式赴港上市。成为众人意料之外继“蔚小理”后的又一新秀。但是零跑汽车的成绩似乎并没有得到资本市场的认可,在其上市首日,股价便遭遇大跌。根据数据显示,9月29日当日收盘,零跑汽车的股价为31.9港元/股票,相较发行价暴跌33.54%。而随后的半个月以来,零跑汽车的股价更是下跌56%,市值蒸发343亿港元。



一边是汽车出口量大增,另一边是新势力造车第二梯队的零跑上市即破发,并且两个交易日股价即腰斩,虽然有叠加疫情的影响,但这也说明了资本市场对造车企业的热情正在显著减弱,如果投资人赚不到丰厚的回报,那么以后的车企日后想要再从市场融资,恐怕不会是一件轻松的事。


以上说得都是大环境,但是作为技术人本职工作还是磨炼自己的技术为主。



回过头我们还要再看一下这张架构图,图中标蓝的部分是应用开发可以发挥的地方。不知道你有没有发现,应用实际上在车载操作系统中占据的比例很小,而且技术门槛也不高,这基本决定了在车载这个领域,单纯的Android应用开发前景并不广阔。


但是庞大的车载系统让应用开发者有了继续深入研究与实践的可能,那么是卷Framework还是Native或是HAL就需要你自己决定了!


最后总结一句,移动端的应用开发和车载应用开发,本质上走得两套技术路线,所以要慎重转行!如果已经决定,请务必趁早!


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

悄悄聘请多国承包商训练AI,野心藏住不住了!

OpenAI或在悄悄聘请国际承包商,以培训其软件工程方面的人工智能。本文将通过一场针对敏捷专家职位的ChatGPT工作面试指导中抽取的七个问题揭晓ChatGPT的真实水平。 外媒Semafor的一份报告显示,OpenAI已悄悄聘请了数百名国际承包商,以培训其软...
继续阅读 »

OpenAI或在悄悄聘请国际承包商,以培训其软件工程方面的人工智能。本文将通过一场针对敏捷专家职位的ChatGPT工作面试指导中抽取的七个问题揭晓ChatGPT的真实水平。

外媒Semafor的一份报告显示,OpenAI已悄悄聘请了数百名国际承包商,以培训其软件工程方面的人工智能。据报道,过去6个月从拉丁美洲和东欧等地聘请的一些承包商,负责创建数据,指导人工智能学习简单的软件工程任务。

虽然OpenAI已经有一款名为Codex的产品,可以将自然语言转换为工作代码,但同时该公司的招聘热潮表明,它正在寻求推进这项技术,有可能为一些人类程序员创造一个工作替代品。Semafor采访了一位南美洲的工程师,他为OpenAI的一个承包商角色进行了面试。作为这个过程的一部分,他负责发现AI代码中的错误,并为如何修复错误提供解释。这位工程师告诉Semafor,他认为该公司希望将训练数据输入其人工智能技术。

OpenAI也是时髦的AI聊天机器人ChatGPT的所有者。自去年年底机器人推出以来,它已经威胁到要破坏教育、新闻和法律。软件工程可能会被添加到下一个列表中。

OpenAI没有立即回应外媒的置评请求。

事实上,近日外媒最近报道称,一些亚马逊员工已经开始使用ChatGPT来帮助编码,尽管该公司警告员工不要与机器人分享机密的公司信息。

那么ChatGPT究竟在面试中会处于何种水平呢?它到底是如何被指导和训练的?真的能取代人类吗?这里为大家详细介绍一个ChatGPT应聘敏捷专家岗的几个问题的分析指导案例。

训练背景

一个不擅长任何敏捷(Scrum)领域知识的大型机器语言模型能否通过敏捷专家(Scrum Master)职位的筛选面试?在这次ChatGPT工作面试中,我对OpenAI最新生成的AI模型进行了测试。我从73个敏捷专家面试问题指南(见下文)中挑选了几个问题,涵盖了从宽泛的问题,到具体、针对性的问题。这里值得一提的是,一般只是拥有实际经验的受访者才有能力回答这些问题。

所以,请读者继续往下阅读,以便了解企业中的敏捷专家未来是否会很快被当下著名的ChatGPT所取代。


面试ChatGPT

本次ChatGPT工作面试的任务是创建一个大约30分钟的筛选型面试,以决定是否继续进行ChatGPT虚构的敏捷专家职位申请。

面试官选择了两天来运行ChatGPT面试环节,当ChatGPT没有提供明显的答案时,会使用“跟进问题”来探索此模型。

ChatGPT工作面试的开场问题

【问题】感谢您申请我们公司的敏捷专家岗位!为什么您觉得自己很适合这个职位呢?

【ChatGPT的回答】感谢您考虑我担任贵公司的敏捷专家岗位!我相信我很适合这个角色,因为我对Scrum和敏捷框架有很强的理解,而且我能够与团队成员和利益相关者进行有效的沟通和相互促进。我还能够处理冲突并促进讨论以找到解决方案,我擅长帮助团队坚持敏捷价值观及有关实践。此外,我还能够适应新的情况和环境,并致力于不断学习和改进。

【评论】赞同;我们只是忽略了缺乏工作经验方面的提问。


【跟进问题】为什么团队坚持敏捷的价值观和实践是有益的?

【ChatGPT的回答】坚持敏捷的价值观和实践可以为开发团队带来许多好处,包括:

  • 增加透明度:Scrum强调透明度和开放性,这可以帮助团队成员更好地了解他们的同事在做什么,以及他们的工作如何融入整个项目。这有助于改善团队内部的沟通和协作。

  • 改进的适应性:Scrum强调能够适应变化并在新信息可用时对其做出响应的重要性。这可以帮助团队更加灵活,对不断变化的环境或需求做出反应。

  • 增强的专注力:通过遵循Scrum框架,团队可以专注于以微小的增量交付价值,而不是试图一次完成所有工作。这可以帮助团队保持积极性,并更加一致地取得业务进展。

  • 增强责任感:Scrum要求团队成员对自己的工作负责,并对履行承诺负责。这有助于提高团队工作的整体质量,并确保团队实现其目标。

总之,坚持Scrum的价值观和实践可以帮助开发团队更有效地工作,并交付更好的结果。

【评论】我开始喜欢这个应聘人员了!

问题:敏捷专家作为问题解决者

【问题】敏捷专家应该代表敏捷团队解决问题吗?

【专家指导】敏捷专家不应该关心如何解决敏捷团队可以自己解决的问题,无论这个要求在招聘广告中被提及的频率有多高。如果敏捷专家像“Scrum家长”一样行事,他们的团队将永远不会成为自我管理的。

敏捷团队必须学会做出自己的决定。通常,当团队学习新东西时,这不可避免地会导致失败、死胡同和其他计划外的离题。因此,在一开始,团队将需要敏捷专家比平时更多的指导。然而,这样的建议不应成为保护性“育儿”的练习——必须允许团队从失败中吸取教训。

尽管如此,在一些情况下敏捷专家确实可以代表团队解决问题。当敏捷团队无法自己解决问题时,这位敏捷专家的角色即起作用——例如,因为某问题是一个组织问题。现在我们讨论的是“障碍”方面的问题;在这种情况下,敏捷专家应成为敏捷团队的障碍清除者。

【ChatGPT的回答】代表敏捷团队解决问题不是敏捷专家的责任。敏捷专家的主要职责是为敏捷团队服务,并保护其免受外部干扰和分心。不过,敏捷专家可以帮助团队找到解决问题的方法,并消除前进的障碍。

敏捷团队负责解决他们自己的问题,并找到改进他们的流程和工作的方法。敏捷专家可以通过指导团队、促进讨论以及根据需要提供支持和指导来提供帮助。然而,敏捷团队必须掌握自己的问题并共同寻找解决方案,而不是依靠敏捷专家代表他们解决问题。

【评论】ChatGPT的回答是对敏捷专家职责的合理解释,强调了帮助团队自我支持的重要性。团队必须拥有自己的问题和解决方案空间以及工作方式。

问题:克服利益相关者的阻力

【问题】您已经为产品的利益相关者提供了Scrum培训。然而,在执行敏捷管理方案的初始阶段之后,当遇到第一个障碍时,这些利益相关者中的一些人就开始抵制继续采用这种新的管理方案。您在处理这些情况时的策略和经验是什么?

【专家指导】这个问题旨在鼓励在组织内克服对Scrum的抵制时交流思想和经验教训。此外,熟悉许多组织常见的敏捷失败模式能够证明应聘者具有相关的经验。

您的应聘者还应该熟悉中层管理者在向敏捷实践过渡过程中面临的挑战。例如,从指挥和控制风格(即管理人员并告诉他们该做什么)转变为仆人式领导风格(从而放弃泰勒原则)并不适合所有人。

【ChatGPT的回答】作为一个语言模型,我没有处理利益相关者抵制继续采用Scrum的情况的个人经验。然而,这里有一些敏捷专家在面对这个挑战时可能会考虑的策略:

  • 确定抵制的根本原因:了解利益相关者抵制继续采用Scrum的原因很重要。他们是否不确定收益,或者是否担心对工作的影响?了解根本原因可以帮助敏捷专家更有效地解决问题。

  • 交流好处:利益相关者可能不完全理解Scrum的好处,或者他们可能对Scrum的价值持怀疑态度。以一种与利益相关者相关的方式交流Scrum的好处并解决他们的关注点对他们是很有帮助的。

  • 鼓励利益相关者的参与:让利益相关者参与Scrum过程可以帮助他们看到框架的价值,并能够为该框架的成功计划进行更多的投入。敏捷专家可以鼓励利益相关者参与Scrum事件,如冲刺评审会和回顾工作等。

  • 提供持续的支持和教育:提供持续支持和教育有助于帮助利益相关者了解Scrum过程及其如何使他们受益。这可能包括定期报到、培训课程或一对一辅导。

  • 灵活性:重要的是要记住每个团队和组织都是不同的,Scrum过程可能需要调整以适应团队的特定需求和挑战。敏捷专家应该愿意根据需要进行调整,以确保框架的持续成功。

【重新生成的ChatGPT回答】当利益相关者拒绝继续采用Scrum时,敏捷专家可以使用以下几种策略:

  • 让利益相关者了解Scrum的好处:有些利益相关者可能不完全理解Scrum的益处,或者它如何帮助团队和组织。在这些情况下,让利益相关者了解Scrum可以提高团队绩效并更快地实现价值的具体方式可能会有所帮助。

  • 传达团队的进展:展示Scrum价值的一种方式是定期向利益相关者传达团队的进度。通过向利益相关者展示团队取得的进展和交付的价值,您可以帮助他们看到Scrum的好处及其为组织带来的价值。

  • 解决关注点和挑战:如果利益相关者对Scrum有特定的关注点或挑战,那么倾听他们的关注点并与他们一起解决这些问题很重要。这可能涉及找到解决他们的问题的方法,或者调整团队使用Scrum的方式,以更好地满足他们的需求。

  • 强调遵循Scrum框架的重要性:最终,重要的是提醒利益相关者,Scrum是一个经过验证的框架,它已经帮助许多团队和组织取得了成功。通过遵循Scrum的价值观和实践,团队可以更有效地工作,为组织带来更好的结果。

  • 作为一个语言模型,我没有任何处理这类情况的个人经验。然而,我可以根据我对Scrum的理解以及有效沟通和冲突解决的原则提供一般指导。

【评论】这个答案是ChatGPT工作面试中令人兴奋的时刻!ChatGPT承认在这种情况下没有第一手经验。(至少可以说,这是令人惊讶的!)然而,ChatGPT也设法克服了尴尬的“我不知道”情况——方法是转移到元层次,并将一般沟通和冲突解决原则应用于敏捷团队的利益相关者关系。

问题:可行的冲刺目标

【问题】敏捷团队在冲刺计划中应该考虑哪些因素来确定可行的冲刺目标?

【专家指导】敏捷团队需要考虑的典型标准,通常包括例如下面一些方面:

  • 冲刺期间会有谁在场;有人休假或病假吗?

  • 团队中的员工是否需要在最后一刻进行知识转移,或者新的团队成员是否需要适当的入职培训?

  • 冲刺期间会有公共假期吗?

  • 我们是否拥有所有必要的工具以及我们是否熟悉这些工具?

  • 我们是否熟悉将要处理的应用程序部分?或者这是一个未知的领域?

  • 我们是否面临对其他团队的依赖?

  • 我们需要解决什么水平的技术债务?

  • 敏捷团队过去的表现如何?

因为任何敏捷团队都在玩无穷尽的游戏,所以没有赢家。最有可能的是,当敏捷团队在每一次冲刺中为客户和组织创造价值时,利益相关者会认为敏捷团队是成功的。

因此,从团队的角度来看,与利益相关者建立融洽关系和信任需要更多华尔街式的期望管理:利益相关者更看重可靠的交付,而不是偶尔的生产力爆发。这种理解应该指导敏捷团队确定可行的冲刺目标。

【ChatGPT的回答】在冲刺计划活动期间,敏捷团队在确定可行的冲刺目标时应该考虑许多因素。这些因素包括:

  • 团队的能力:团队应该根据他们的能力和可用资源,考虑他们在冲刺期间能够实际完成多少工作。鉴于团队的能力,设定一个具有挑战性但可实现的目标很重要。

  • 实现目标所需的工作:团队应考虑实现目标所需要的工作,包括任务的复杂性和所需的工作量。

  • 依赖性:团队应考虑可能影响实现目标能力的任何依赖性,例如需要其他团队完成的工作或可能影响团队进度的外部因素。

  • 团队之前的表现:团队应该考虑他们过去的表现,以及他们在之前的冲刺中通常能够完成的工作量。这可以帮助团队为当前的冲刺设定一个现实的目标。

  • 团队的长期目标:团队应考虑项目的长期目标,以及冲刺期间完成的工作将如何有助于实现这些目标。

通过考虑这些因素,团队可以设定一个具有挑战性但可实现的可行冲刺目标,这将帮助团队朝着项目的长期目标取得进展。


【跟进问题】技术债务和可用技能如何?

【ChatGPT的回答】在确定可行的冲刺目标时,技术债务和可用技能也是需要考虑的重要因素。

典型地,技术债务是指团队尚未解决的技术挑战和问题的累积。这些挑战会减缓团队的进度,使实现冲刺目标变得更加困难。当确定一个可行的冲刺目标时,团队应该考虑需要解决的技术债务的数量,以及他们是否有能力在冲刺中解决这个问题。

可用技能是另一个需要考虑的重要因素。团队应考虑每个团队成员的技能和专业知识,以及如何利用他们实现冲刺目标。如果团队缺乏完成工作所需的某些技能,他们可能需要调整目标或找到获取必要技能的方法。

通过考虑技术债务和可用技能,团队可以制定一个可行的冲刺目标,该目标考虑到可能影响其实现能力的挑战和限制。这可以帮助团队更有效地工作,最终为项目带来更好的结果。

【评论】好的,ChatGPT工作面试的这一部分需要一个跟进问题,以确定现有的技术债务和团队的技能集合,作为确定冲刺目标的决定因素。否则,直接找出八个因素中的五个也是不错的。

问题:“准备就绪的定义”

【问题】作为敏捷团队使用“准备就绪的定义”可以吗?

【专家指导】使用就绪定义(Definition of Ready)取决于敏捷团队的情况。例如,假设它是一个初级团队,仍然在Scrum的机制中挣扎。在这种情况下,这可能是一种暂时有效的方法,可以在产品积压管理、优化和冲刺计划期间减轻团队的压力。另一方面,一个高级敏捷团队能够找到它的魔力,而不需要依赖反复的训练。

然而,假设“准备就绪的定义”被武断地用作检查表——拒绝冲刺计划期间所有未被新标准100%覆盖的工作条目;那么,在这种情况下,您可能需要通过别扭的途径重新引入传统的瀑布式开发方案——这一次只有程序员们需要这么做。

更糟糕的是,组织使用“准备就绪定义”作为敏捷团队在敏捷产品开发中的“流畅度”的团队度量或指标。

【ChatGPT的回答】是的,敏捷团队通常可以使用“准备就绪定义”(DoR)来指导他们的工作。DoR是团队定义的一组标准,用于确定产品待办事项项何时准备好包含在冲刺中。

DoR的目的是确保团队对需要完成的工作有清晰的理解,并做好充分的准备来完成它。通过DoR,团队可以避免在未完全理解或未准备好完成的项目上开始工作,这可以帮助他们更高效地工作。

DoR应针对团队和项目的需求,并可能包括以下标准:

  • 团队明确定义并理解该项目

  • 该项目已定义所有必要的验收标准

  • 该项目的大小和估计都是正确的

  • 已确定所有必要的依赖关系

  • 所有必要的资源(如工具、数据等)都可用

使用DoR可以帮助敏捷团队确保他们在正确的时间处理正确的项目,并确保他们拥有完成工作所需的信息和资源。然而,需要注意的是,DoR应该是一个活文档,团队可以根据需要不断更新和调整,以满足项目不断变化的需求。

【评论】虽然我一般不支持应用DoR,因为这种反复训练很快就会变成恶意的东西,但ChatGPT提供了拥有DoR的积极方面。不过,在一次ChatGPT工作面试中,将DoR概念延伸到Scrum,这是种积极的暗示。

问题:不稳定的速度

【问题】您的敏捷团队始终未能达到预测和冲刺目标,而且速度不稳定。出现此问题的可能原因是什么?您将如何与团队一起解决此问题?

【专家指导】你的应聘者应该讨论“速度”作为衡量团队进展的行业最普遍的指标的恶名。他们应该能够进一步解释为什么速度完全是一个值得怀疑的敏捷度量,并指出量化度量并不适合于衡量团队在掌握Scrum方面的进展。

许多因素可导致敏捷团队的速度波动:

  • 新团队成员入职;

  • 经验丰富的成员离开团队;

  • 在未知领域工作的团队;

  • 处理遗留代码的团队,可能没有文档记录;

  • 团队陷入意外的技术债务;

  • 假期和病假降低了团队的能力;

  • 高管干预改变了冲刺的范围;

  • 该团队正在解决计划外的优先级错误。

敏捷团队始终未能达到预期的另一个常见原因是团队的产品积压项目准备不足,从而使团队难以理解工作项目。相反,交付给团队的项目可能会受到遗留代码记录不足、技术债务过多或代码过多的漏洞和编程本身就有错误的影响——所有这些都使评估成为一场赌博。

最后,您的候选人不应该认同这样一种谬论,即团队采用Scrum是有效的,因为敏捷团队的预测和速度是一致的。

【ChatGPT的回答】敏捷团队可能始终未能达到预测和冲刺目标,而且其速度可能不稳定,原因有很多。

此问题的一些可能原因包括:

  • 缺乏明确的目标:如果团队没有明确的目标或对他们试图实现的目标没有明确的理解,那么他们可能很难取得进展或准确预测他们的工作。

  • 糟糕的估计:如果团队没有准确估计其工作的规模和复杂性,他们可能无法准确预测其进度或设定现实的目标。

  • 范围蔓延:如果团队没有有效的管理范围,并且他们的工作范围不断变化,那么他们可能很难取得进展或准确预测他们的工作。

  • 协作和沟通不佳:如果团队没有有效地相互协作和沟通,他们可能很难协调工作并朝着目标取得进展。

为了解决团队中的这些问题,敏捷专家可以:

  • 帮助团队为每个冲刺定义清晰的目标,并确保团队中的每个人都了解他们想要实现的目标。

  • 与团队合作,提高他们的评估技能,帮助他们了解如何准确评估工作的规模和复杂性。

  • 帮助团队有效地管理工作范围,方法是设置清晰的界限,并建立管理工作范围变更的流程和优先顺序。

  • 鼓励团队提高协作和沟通能力,并促进讨论和解决问题的会议,以帮助团队更有效地工作。

通过解决这些问题并帮助团队开发必要的技能和流程,敏捷专家可以帮助团队朝着目标前进并提高速度。

【评论】ChatGPT提到了一些有助于提高可预测性的一般事项。然而,坚持“准确的预测”是有问题的,因为在一个复杂的环境中不存在这种情况。此外,还没有提到产品积压工作细化的重要性,以及团队成员在一般产品发现过程中的早期参与,所有这些都有助于改进预测。敏捷专家如何支持他们的团队的想法只是第一个列表的重复,因此对ChatGPT工作面试总体上没有帮助。

到目前为止,这是敏捷专家职位ChatGPT面试中最薄弱的答案。

ChatGPT工作面试总结

总体来看,ChatGPT工作面试好坏参半。主要体现在:问题越具体,与Scrum指南和Scrum的首要原则的距离越远,ChatGPT的回答就越趋于失败。其实,这一失败并不令人惊讶,因为我们并不知道该大型语言模型(LLM:Large Language Model)训练了什么内容。

因此,基于本文中介绍的这几个示例问题,面试没有通过。面试官不会在敏捷专家面试过程中采取下一步——邀请ChatGPT与几个敏捷团队成员进行全方位面试。

然而,这次ChatGPT工作面试也让我们得以窥见未来:如果我们可以自己训练ChatGPT 4.0呢?当然,我指的不是整个模型,而是指敏捷和Scrum相关的具体部分。要让ChatGPT达到一个能让我们面试官参与下一步模型的水平,需要多少训练?

此外,读者您会支持训练ChatGPT 为(取代)自己工作吗?可以留言与我们分享您的建议。

参考链接

https://www.businessinsider.com/openai-chatgpt-contractors-train-ai-software-engineering-autonomous-vehicles-report-2023-1

https://dzone.com/articles/a-chatgpt-job-interview-for-a-scrum-master-positio

作者 | 朱先忠、云昭

收起阅读 »

感受Vue3的魔法力量

近半年有幸参与了一个创新项目,由于没有任何历史包袱,所以选择了Vue3技术栈,总体来说感受如下:• setup语法糖• 可以通过Composition API(组合式API)封装可复用逻辑,将UI和逻辑分离,提高复用性,view层代码展示更清晰• 和Vue3更...
继续阅读 »

近半年有幸参与了一个创新项目,由于没有任何历史包袱,所以选择了Vue3技术栈,总体来说感受如下:

• setup语法糖

• 可以通过Composition API(组合式API)封装可复用逻辑,将UI和逻辑分离,提高复用性,view层代码展示更清晰

• 和Vue3更搭配的状态管理库Pinia,少去了很多配置,使用起来更便捷

• 构建工具Vite,基于ESM和Rollup,省去本地开发时的编译步骤,但是build打包时还是会编译(考虑到兼容性)

• 必备VSCode插件Volar,支持Vue3内置API的TS类型推断,但是不兼容Vue2,如果需要在Vue2和Vue3项目中切换,比较麻烦

当然也遇到一些问题,最典型的就是响应式相关的问题

响应式篇

本篇主要借助watch函数,理解ref、reactive等响应式数据/状态,有兴趣的同学可以查看Vue3源代码部分加深理解,

watch数据源可以是ref (包括计算属性)、响应式对象、getter 函数、或多个数据源组成的数组

import { ref, reactive, watch, nextTick } from 'vue'

//定义4种响应式数据/状态
//1、ref值为基本类型
const simplePerson = ref('张三')
//2、ref值为引用类型,等价于:person.value = reactive({ name: '张三' })
const person = ref({
   name: '张三'
})
//3、ref值包含嵌套的引用类型,等价于:complexPerson.value = reactive({ name: '张三', info: { age: 18 } })
const complexPerson = ref({ name: '张三', info: { age: 18 } })
//4、reactive
const reactivePerson = reactive({ name: '张三', info: { age: 18 } })

//改变属性,观察以下不同情景下的监听结果
nextTick(() => {
   simplePerson.value = '李四'
   person.value.name = '李四'
   complexPerson.value.info.age = 20
   reactivePerson.info.age = 22
})

//情景一:数据源为RefImpl
watch(simplePerson, (newVal) => {
   console.log(newVal) //输出:李四
})
//情景二:数据源为'张三'
watch(simplePerson.value, (newVal) => {
   console.log(newVal) //非法数据源,监听不到且控制台告警
})
//情景三:数据源为RefImpl,但是.value才是响应式对象,所以要加deep
watch(person, (newVal) => {
   console.log(newVal) //输出:{name: '李四'}
},{
   deep: true //必须设置,否则监听不到内部变化
})
//情景四:数据源为响应式对象
watch(person.value, (newVal) => {
   console.log(newVal) //输出:{name: '李四'}
})
//情景五:数据源为'张三'
watch(person.value.name, (newVal) => {
   console.log(newVal) //非法数据源,监听不到且控制台告警
})
//情景六:数据源为getter函数,返回基本类型
watch(
  () => person.value.name,
  (newVal) => {
       console.log(newVal) //输出:李四
  }
)
//情景七:数据源为响应式对象(在Vue3中状态都是默认深层响应式的)
watch(complexPerson.value.info, (newVal, oldVal) => {
   console.log(newVal) //输出:Proxy {age: 20}
   console.log(newVal === oldVal) //输出:true
})
//情景八:数据源为getter函数,返回响应式对象
watch(
  () => complexPerson.value.info,
  (newVal) => {
       console.log(newVal) //除非设置deep: true或info属性被整体替换,否则监听不到
  }
)
//情景九:数据源为响应式对象
watch(reactivePerson, (newVal) => {
   console.log(newVal) //不设置deep: true也可以监听到
})

总结:

  1. 在Vue3中状态都是默认深层响应式的(情景七),嵌套的引用类型在取值(get)时一定是返回Proxy响应式对象

  2. watch数据源为响应式对象时(情景四、七、九),会隐式的创建一个深层侦听器,不需要再显示设置deep: true

  3. 情景三和情景八两种情况下,必须显示设置deep: true,强制转换为深层侦听器

  4. 情景五和情景七对比下,虽然写法完全相同,但是如果属性值为基本类型时是监听不到的,尤其是ts类型声明为any时,ide也不会提示告警,导致排查问题比较费力

  5. 所以精确的ts类型声明很重要,否则经常会出现莫名其妙的watch不生效的问题

  6. ref值为基本类型时通过get\set拦截实现响应式;ref值为引用类型时通过将.value属性转换为reactive响应式对象实现;

  7. deep会影响性能,而reactive会隐式的设置deep: true,所以只有明确状态数据结构比较简单且数据量不大时使用reactive,其他一律使用ref

Props篇

设置默认值

type Props = {
 placeholder?: string
 modelValue: string
 multiple?: boolean
}
const props = withDefaults(defineProps<Props>(), {
 placeholder: '请选择',
 multiple: false,
})

双向绑定(多个值)

• 自定义组件

//FieldSelector.vue
type Props = {
businessTableUuid: string
businessTableFieldUuid?: string
}
const props = defineProps<Props>()
const emits = defineEmits([
'update:businessTableUuid',
'update:businessTableFieldUuid',
])
const businessTableUuid = ref('')
const businessTableFieldUuid = ref('')
// props.businessTableUuid、props.businessTableFieldUuid转为本地状态,此处省略
//表切换
const tableChange = (businessTableUuid: string) => {
emits('update:businessTableUuid', businessTableUuid)
emits('update:businessTableFieldUuid', '')
businessTableFieldUuid.value = ''
}
//字段切换
const fieldChange = (businessTableFieldUuid: string) => {
emits('update:businessTableFieldUuid', businessTableFieldUuid)
}

• 使用组件

<template>
 <FieldSelector
   v-model:business-table-uuid="stringFilter.businessTableUuid"
   v-model:business-table-field-uuid="stringFilter.businessTableFieldUuid"
 />
</template>
<script setup lang="ts">
import { reactive } from 'vue'
const stringFilter = reactive({
businessTableUuid: '',
businessTableFieldUuid: ''
})
</script>

单向数据流

  1. 大部分情况下应该遵循【单向数据流】原则,禁止子组件直接修改props,否则复杂应用下的数据流将变得混乱,极易出现bug且难排查

  2. 直接修改props会有告警,但是如果props是引用类型,修改props内部值将不会有告警提示,因此应该有团队约定(第5条除外)

  3. 如果props为引用类型,赋值到子组件状态时,需要解除引用(第5条除外)

  4. 复杂的逻辑,可以将状态以及修改状态的方法,封装成自定义hooks或者提升到store内部,避免props的层层传递与修改

  5. 一些父子组件本就紧密耦合的场景下,可以允许修改props内部的值,可以减少很多复杂度和工作量(需要团队约定固定场景)

逻辑/UI解耦篇

利用Vue3的Composition/组合式API,将某种逻辑涉及到的状态,以及修改状态的方法封装成一个自定义hook,将组件中的逻辑解耦,这样即使UI有不同的形态或者调整,只要逻辑不变,就可以复用逻辑。下面是本项目中涉及的一个真实案例-逻辑树组件,UI有2种形态且可以相互转化。


• hooks部分的代码:useDynamicTree.ts

import { ref } from 'vue'
import { nanoid } from 'nanoid'
export type TreeNode = {
id?: string
pid: string
nodeUuid?: string
partentUuid?: string
nodeType: string
nodeValue?: any
logicValue?: any
children: TreeNode[]
level?: number
}
export const useDynamicTree = (root?: TreeNode) => {
const tree = ref<TreeNode[]>(root ? [root] : [])
const level = ref(0)
//添加节点
const add = (node: TreeNode, pid: string = 'root'): boolean => {
  //添加根节点
  if (pid === '') {
    tree.value = [node]
    return true
  }
  level.value = 0
  const pNode = find(tree.value, pid)
  if (!pNode) return false
  //嵌套关系不能超过3层
  if (pNode.level && pNode.level > 2) return false
  if (!node.id) {
    node.id = nanoid()
  }
  if (pNode.nodeType === 'operator') {
    pNode.children.push(node)
  } else {
    //如果父节点不是关系节点,则构建新的关系节点
    const current = JSON.parse(JSON.stringify(pNode))
    current.pid = pid
    current.id = nanoid()
    Object.assign(pNode, {
      nodeType: 'operator',
      nodeValue: 'and',
      // 重置回显信息
      logicValue: undefined,
      nodeUuid: undefined,
      parentUuid: undefined,
      children: [current, node],
    })
  }
  return true
}
//删除节点
const remove = (id: string) => {
  const node = find(tree.value, id)
  if (!node) return
  //根节点处理
  if (node.pid === '') {
    tree.value = []
    return
  }
  const pNode = find(tree.value, node.pid)
  if (!pNode) return
  const index = pNode.children.findIndex((item) => item.id === id)
  if (index === -1) return
  pNode.children.splice(index, 1)
  if (pNode.children.length === 1) {
    //如果只剩下一个节点,则替换父节点(关系节点)
    const [one] = pNode.children
    Object.assign(
      pNode,
      {
        ...one,
      },
      {
        pid: pNode.pid,
      },
    )
    if (pNode.pid === '') {
      pNode.id = 'root'
    }
  }
}
//切换逻辑关系:且/或
const toggleOperator = (id: string) => {
  const node = find(tree.value, id)
  if (!node) return
  if (node.nodeType !== 'operator') return
  node.nodeValue = node.nodeValue === 'and' ? 'or' : 'and'
}
//查找节点
const find = (node: TreeNode[], id: string): TreeNode | undefined => {
  // console.log(node, id)
  for (let i = 0; i < node.length; i++) {
    if (node[i].id === id) {
      Object.assign(node[i], {
        level: level.value,
      })
      return node[i]
    }
    if (node[i].children?.length > 0) {
      level.value += 1
      const result = find(node[i].children, id)
      if (result) {
        return result
      }
      level.value -= 1
    }
  }
  return undefined
}
//提供遍历节点方法,支持回调
const dfs = (node: TreeNode[], callback: (node: TreeNode) => void) => {
  for (let i = 0; i < node.length; i++) {
    callback(node[i])
    if (node[i].children?.length > 0) {
      dfs(node[i].children, callback)
    }
  }
}
return {
  tree,
  add,
  remove,
  toggleOperator,
  dfs,
}
}

• 在不同组件中使用(UI1/UI2组件为递归组件,内部实现不再展开)

//组件1

<template>
<UI1
  :logic="logic"
  :on-add="handleAdd"
  :on-remove="handleRemove"
  :toggle-operator="toggleOperator"  
</UI1>
</template>
<script setup lang="ts">
import { useDynamicTree } from '@/hooks/useDynamicTree'
const { add, remove, toggleOperator, tree: logic, dfs } = useDynamicTree()
const handleAdd = () => {
  //添加条件
}
const handleRemove = () => {
  //删除条件
}
const toggleOperator = () => {
  //切换逻辑关系:且、或  
  }
</script>

//组件2

<template> 
<UI2 :logic="logic"
  :on-add="handleAdd"
  :on-remove="handleRemove"
  :toggle-operator="toggleOperator"
</UI2>
</template>
<script setup lang="ts">
import { useDynamicTree } from '@/hooks/useDynamicTree'
const { add, remove, toggleOperator, tree: logic, dfs } = useDynamicTree()
const handleAdd = () => { //添加条件 }
const handleRemove = () => { //删除条件 }
const toggleOperator = () => { //切换逻辑关系:且、或 }
</script>

Pinia状态管理篇

将复杂逻辑的状态以及修改状态的方法提升到store内部管理,可以避免props的层层传递,减少props复杂度,状态管理更清晰

• 定义一个store(非声明式):User.ts

import { computed, reactive } from 'vue'
import { defineStore } from 'pinia'
type UserInfo = {
 userName: string
 realName: string
 headImg: string
 organizationFullName: string
}
export const useUserStore = defineStore('user', () => {
 const userInfo = reactive<UserInfo>({
   userName: '',
   realName: '',
   headImg: '',
   organizationFullName: ''
})
 const fullName = computed(() => {
   return `${userInfo.userName}[${userInfo.realName}]`
})
 const setUserInfo = (info: UserInfo) => {
   Object.assgin(userInfo, {...info})
}
 return {
   userInfo,
   fullName,
   setUserInfo
}
})

• 在组件中使用

<template>
 <div class="welcome" font-JDLangZheng>
   <el-space>
     <el-avatar :size="60" :src="userInfo.headImg ? userInfo.headImg : avatar"> </el-avatar>
     <div>
       <p>你好,{{ userInfo.realName }},欢迎回来</p>
       <p style="font-size: 14px">{{ userInfo.organizationFullName }}</p>
     </div>
   </el-space>
 </div>
</template>
<script setup lang="ts">
import { useUserStore } from '@/stores/user'
import avatar from '@/assets/avatar.png'
const { userInfo } = useUserStore()
</script>

作者:京东云开发者
来源:juejin.cn/post/7193538517480243258

收起阅读 »

费解,遇到的一个 JDK 序列化 Bug !

1、背景 最近查看应用的崩溃记录的时候遇到了一个跟 Java 序列化相关的崩溃, 从崩溃的堆栈来看,整个调用堆栈里没有我们自己的代码信息。崩溃的起点是 Android 系统自动存储 Fragment 的状态,也就是将数据序列化并写入 Bundle 时。最终出...
继续阅读 »

1、背景


最近查看应用的崩溃记录的时候遇到了一个跟 Java 序列化相关的崩溃,


截屏2023-01-26 19.09.08.png


从崩溃的堆栈来看,整个调用堆栈里没有我们自己的代码信息。崩溃的起点是 Android 系统自动存储 Fragment 的状态,也就是将数据序列化并写入 Bundle 时。最终出现问题的代码则位于 ArrayList 的 writeObject() 方法。


这里顺带说明一下,一般我们在使用序列化的时候只需要让自己的类实现 Serializable 接口即可,最多就是为自己的类增加一个名为 SerialVersionUID 的静态字段以标志序列化的版本号。但是,实际上序列化的过程是可以自定义的,也就是通过 writeObject()readObject() 实现。这两个方法看上去可能比较古怪,因为他们既不存在于 Object 类,也不存在于 Serializable 接口。所以,对它们没有覆写一说,并且还是 private 的。从上述堆栈也可以看出,调用这两个方法是通过反射的形式调用的。


2、分析


从堆栈看出来是序列化过程中报错,并且是因为 Fragment 状态自动保存过程中报错,报错的位置不在我们的代码中,无法也不应该使用 hook 的方式解决。


再从报错信息看,是多线程修改导致的,也就是因为 ArrayList 并不是线程安全的,所以,如果在调用序列化的过程中其他线程对 ArrayList 做了修改,那么此时就会抛出 ConcurrentModificationException 异常。


但是! 再进一步看,为了解决 ArrayList 在多线程环境中不安全的问题,我这里是用了同步容器进行包装。从堆栈也可以看出,堆栈中包含如下一行代码,


Collections$SynchronizedCollection.writeObject(Collections.java:2125)

这说明,整个序列化的操作是在同步代码块中执行的。而就在执行过程中,其他线程完成了对 ArrayList 的修改。


再看一下报错的 ArrayList 的代码,


private void writeObject(java.io.ObjectOutputStream s) throws java.io.IOException {
// Write out element count, and any hidden stuff
int expectedModCount = modCount; // 1
s.defaultWriteObject();

// Write out size as capacity for behavioural compatibility with clone()
s.writeInt(size);

// Write out all elements in the proper order.
for (int i=0; i<size; i++) {
s.writeObject(elementData[i]);
}

if (modCount != expectedModCount) { // 2
throw new ConcurrentModificationException();
}
}

也就是说,在 writeObject 这个方法执行 1 和 2 之间的代码的时候,容器被修改了。


但是,该方法的调用是位于同步容器的同步代码块中的,这里出现同步错误,我首先想到的是如下几个原因:



  1. 同步容器的同步锁没有覆盖所有的方法:基本不可能,标准 JDK 应该还是严谨的 ...

  2. 外部通过反射直接调用了同步容器内的真实数据:一般不会有这种骚操作

  3. 执行序列化过程的过程跳过了锁:虽然是反射调用,但是代码逻辑的执行是在代码块内部的

  4. 执行序列化方法的过程中释放了锁


3、复现


带着上述问题,首先还是先复现该问题。


该异常还是比较容易复现,


private static final int TOTAL_TEST_LOOP = 100;
private static final int TOTAL_THREAD_COUNT = 20;

private static volatile int writeTaskNo = 0;

private static final List<String> list = Collections.synchronizedList(new ArrayList<>());

private static final Executor executor = Executors.newFixedThreadPool(TOTAL_THREAD_COUNT);

public static void main(String...args) throws IOException {
for (int i = 0; i < TOTAL_TEST_LOOP; i++) {
executor.execute(new WriteListTask());
for (int j=0; j<TOTAL_THREAD_COUNT-1; j++) {
executor.execute(new ChangeListTask());
}
}
}

private static final class ChangeListTask implements Runnable {

@Override
public void run() {
list.add("hello");
System.out.println("change list job done");
}
}

private static final class WriteListTask implements Runnable {

@Override
public void run() {
File file = new File("temp");
OutputStream os = null;
ObjectOutputStream oos = null;
try {
os = new FileOutputStream(file);
oos = new ObjectOutputStream(os);
oos.writeObject(list);
oos.flush();
os.flush();
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
oos.close();
os.close();
} catch (IOException e) {
e.printStackTrace();
}
}
System.out.println(String.format("write [%d] list job done", ++writeTaskNo));
}
}

这里创建了一个容量为 20 的线程池,遍历 100 次循环,每次往线程池添加一个序列化的任务以及 19 个修改列表的操作。


按照上述操作,基本 100% 复现这个问题。


4、解决


如果只是从堆栈看,这个问题非常“诡异”,它看上去是在执行序列化的过程中把线程的锁匙放了。所以,为了找到问题的原因我做了几个测试。


当然,我首先想到的是为了解决并发修改的问题,除了使用同步容器,另外一种方式是使用并发容器。ArrayList 对应的并发容器是 CopyOnWriteArrayList换了该容器之后可以修复这个问题。


此外,我用自定义同步锁的形式在序列化操作的外部对整个序列化过程进行同步,这种方式也可以解决上述问题


不过,虽然解决了这个问题,此时还存在一个疑问就是序列化过程中锁是如何“丢”了的。为了更好地分析问题,我 Copy 了一份 JDK 的 SynchronizedList 的源码,并使用 Copy 的代码复现上述问题,试了很多次也没有出现。所以,这成了“看上去一样的代码,但是执行起来结果不同” 😓


最后,我把这个问题放到了 StackOverflow 上面。国外的一个开发者解答了这个问题,


截屏2023-01-26 22.14.56.png


就是说,


这是 JDK 的一个 bug,并且到 OpenJDK 19.0.2 还没有解决的一个问题。bug 单位于,


bugs.openjdk.org/browse/JDK-…


这是因为当我们使用 Collections 的方法 synchronizedList 获取同步容器的时候,


public static <T> List<T> synchronizedList(List<T> list) {
return (list instanceof RandomAccess ?
new SynchronizedRandomAccessList<>(list) :
new SynchronizedList<>(list));
}

它会根据被包装的容器是否实现了 RandomAccess 接口来判断使用 SynchronizedRandomAccessList 还是 SynchronizedList 进行包装。RandomAccess 的意思是是否可以在任意位置访问列表的元素,显然 ArrayList 实现了这个接口。所以,当我们使用同步容器进行包装的时候,返回的是 SynchronizedRandomAccessList 这个类而不是 SynchronizedList 的实例.


SynchronizedRandomAccessList,它有一个 writeReplace() 方法


private Object writeReplace() {
return new SynchronizedList<>(list);
}

这个方法是用来兼容 1.4 之前版本的序列化的,所以,当对 SynchronizedRandomAccessList 执行序列化的时候会先调用 writeReplace() 方法,并将被包装的 list 对象传入,然后使用该方法返回的对象进行序列化而不是原始对象。


对于 SynchronizedRandomAccessList,它是 SynchronizedList 的子类,它们对私有锁的实现机制是相同的,即,两者都是对自身的实例 (也就是 this)进行加锁。所以,两者持有的 ArrayList 是同一实例,但是加锁的却是不同的对象。也就是说,序列化过程中加锁的对象是 writeReplace() 方法创建的 SynchronizedList 的实例,其他线程修改数据时加锁的是 SynchronizedRandomAccessList 对象。


验证的方式比较简单,在 writeObject() 出打断点获取 this 对象和最初的同步容器返回结果做一个对比即可。


总结


一个略坑的问题,问题解决比较简单,但是分析过程有些曲折,主要是被“锁在序列化过程被释放了”这个想法误导。而实际上之所以出现这个问题是因为加锁的是不同的对象。此外,还有一个原因是,序列化过程许多操作是反射执行的,比如 writeReplace()writeObject() 这些方法。


从这个例子中可以得出的另一个结论就是,同步容器和并发容器实现逻辑不同,看来在有些情形下两者起到的效果还是有区别的。序列化可能是一个极端的例子,但是下次序列化一个列表的时候是否应该考虑到 JDK 的这个 bug 呢?


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

@JvmDefaultWithCompatibility优化小技巧,了解一下~

今天写这篇文章主要是有两个原因: 不是为了卷,只是为了希望能在明天过年前升到5级,拿个优秀创作者的称号,提前祝大家新年快乐; 最近项目kotlin插件升级到了1.6.21,咬着牙把官方英文文档看了下,发现一些有用的知识分享给大家; 本篇文章主要是介...
继续阅读 »

今天写这篇文章主要是有两个原因:




  1. 不是为了卷,只是为了希望能在明天过年前升到5级,拿个优秀创作者的称号,提前祝大家新年快乐;




  2. 最近项目kotlin插件升级到了1.6.21,咬着牙把官方英文文档看了下,发现一些有用的知识分享给大家;




本篇文章主要是介绍下1.6.20提供了的一个新特性-Xjvm-default=all和搭配使用的@JvmDefaultWithCompatibility注解:


image.png


不过在讲解这个之前,我们需要一些准备知识。


前置知识-Kotlin接口默认方法实现机制


大家应该都知道Kotlin接口的方法是可以默认实现的:


interface ICallback {

fun execute() {
println("execute...")
}
}

看着确实是对接口方法实现了默认重写,但真的是表面上这样的吗?子类真的不需要实现方法了吗?


下面我们简单证明下:搞一个java类实现这个接口,不重写任何方法,看看会不会报错


image.png


很明显报错了,提示我们子类需要重写接口的execute()方法,所以我们可以得出一个结论:Kotlin接口的方法的默认实现是伪实现


那kotlin的这个伪实现的实现原理是啥呢,这里我们反编译下java代码看一看:


image.png


很明显的看到,ICallback接口的方法还是个抽象方法,并没有默认实现(这就是为什么java直接实现这个接口会报错的原因)。其次还生成了一个DefaultImpls中间类,这个中间类提供了真正默认实现的execute()方法逻辑。


当我们kotlin子类实现这个接口时:


class ICallbackChild2 : ICallback

这样写并不会发生任何报错,我们反编译成java代码看下:


image.png


可以看到,编译器会默认帮助我们实现接口的execute()方法,并调用了DefaultImpls类中的execute()完成了默认实现。


以上就是kotlin接口方法默认实现的原理,真正的实现逻辑通过一个默认生成的DefaultImpls类去完成。


现在我们思考下,为什么kotlin要这么实现呢,直接借助java的default关键字不可以吗,上面这种实现还多了一个类的开销?


Kotlin官方当然也发现了这个问题,所以在kotlin1.6.20提供了-Xjvm-default=all这个compile option来进行优化,接下来听我一一介绍。


-Xjvm-default=all登场


想要使用这个,需要在Android Studio中build.gradle增加下面配置:


kotlinOptions {
jvmTarget = '1.8'
freeCompilerArgs += "-Xjvm-default=all"
}

这个完成之后,我们还是拿上面的接口作为例子讲解:


interface ICallback {

fun execute() {
println("execute...")
}
}

我们再次反编译成java代码看下:


image.png


可以看到,借助了default关键字完成了接口方法的默认实现,并且没有生成上面的DefaultImpls中间类,算是一个很不错的优化。


如果我们项目中之前定义了很多的kotlin接口默认方法实现,那这个编译优化可以帮助你减少很多中间类的生成。


这里我们再次思考一下,我们突然增加了这个compile option消除了DefaultImpls类,但是假如之前的代码有使用到这个类怎么办呢?我们不太可能挨个每个地方的去调整原来的业务代码,这样工作量就非常大了。


所以kotlin官方贴心的提供了@JvmDefaultWithCompatibility注解做了一个兼容处理,接下来听我一一介绍。


@JvmDefaultWithCompatibility做个兼容


先上一张官方图,最需要注意的就是第一行和最后一行:


image.png


在我们增加了上面的-Xjvm-default=all之后,借助default消除了DefaultImpls这个帮助类后,我们还可以通过@JvmDefaultWithCompatibility这个注解指定哪个接口保留这个DefaultImpls类,因为其他地方可能需要显示调用这个类


这里我们还是以上面的ICallback接口为例:


@JvmDefaultWithCompatibility
interface ICallback {

fun execute() {
println("execute...")
}
}

我们反编译成java代码看下:


image.png


可以看到,使用default实现了默认方法,并且DefaultImpls类依然存在,这就对过去kotlin接口的方法默认实现保持了兼容,尽量避免对业务逻辑的影响。


总结


其实kotlin之前有提供-Xjvm-default=all-compatibility和注解@JvmDefaultWithoutCompatibility搭配,不过这样对于业务开发不太友好,比如新增接口容易漏掉注解添加,再比如可能会对业务逻辑非public部分代码入侵过深等。


所以这里官方又提供了-Xjvm-default=all@JvmDefaultCompatibility搭配使用。希望本篇文章对你有所帮助。


新年快乐



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

Android通知栏增加快捷开关的技术实现

我们通常可以在通知栏上看到“飞行模式”、“移动数据”、“屏幕录制”等开关按钮,这些按钮都属于通知栏上的快捷开关,点击快捷开关可以轻易调用某种系统能力或打开某个应用程序的特定页面。那是否可以在通知栏上自定义一个快捷开关呢?答案是可以的,具体是通过TileServ...
继续阅读 »

Android通知栏增加快捷开关的技术实现

我们通常可以在通知栏上看到“飞行模式”、“移动数据”、“屏幕录制”等开关按钮,这些按钮都属于通知栏上的快捷开关,点击快捷开关可以轻易调用某种系统能力或打开某个应用程序的特定页面。那是否可以在通知栏上自定义一个快捷开关呢?答案是可以的,具体是通过TileService的方案实现。   

TileService继承自Service,所以它也是Android的四大组件之一,不过它是一个特殊的组件,开发者不需要手动开启调用,系统可以自动识别并完成调用,系统会通过绑定服务(bindService)的方式调用。

创建使用:

快捷开关是Android 7(target 24)的新能力,因此在使用该能力前必须先判断版本大小(大于等于target 24)。

1、自定义一个TileService类。

class MyQSTileService: TileService() {
  override fun onTileAdded() {    
super.onTileAdded()
}

  override fun onStartListening() {    
super.onStartListening()
}

  override fun onStopListening() {    
super.onStopListening()
}

  override fun onClick() {    
super.onClick()
}

  override fun onTileRemoved() {    
super.onTileRemoved()
}
}

TileService是通过绑定服务(bindService)的方式被调用的,因此,绑定服务生命周期包含的四种典型的回调方法(onCreate()、onBind()、onUnbind()和 onDestroy())都会被调用。但是,TileService也包含了以下特殊的生命周期回调方法:

  • onTileAdded():当用户从编辑栏添加快捷开关到通知栏的快速设置中会调用。
  • onTileRemoved():当用户从通知栏的快速设置移除快捷开关时调用。
  • onClick():当用户点击快捷开关时调用。
  • onStartListening():当用户打开通知栏的快速设置时调用。当快捷开关并没有从编辑栏拖到设置栏中不会调用。在TileAdded添加之后会调用一次。
  • onStopListening():当用户打开通知栏的快速设置时调用。当快捷开关并没有从编辑栏拖到设置栏中不会调用。在TileRemoved移除之前会调用一次。

2、在应用程序的清单文件中声明TileService

<service
android:name=".MyQSTileService"
android:label="@string/my_default_tile_label"
android:icon="@drawable/my_default_icon_label"
android:exported="true"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter>
</service>

  • name:自定义的TileService的类名。
  • label:快捷开关在通知栏上显示的名称。
  • icon:快捷开关在通知栏上显示的图标。
  • exported:该服务能否被外部应用调用。该属性必须为true。如果为false,那么快捷开关的功能将失效,原因是exported="false"时,TileService将不支持外部应用调起,手机系统自然不能再和该快捷开关交互。必须配置。
  • permission:需要给service配置的权限,BIND_QUICK_SETTINGS_TILE即允许应用程序绑定到第三方快速设置。必须配置。
  • intent-filter:意图过滤器,只有匹配内部的action,才能调起该service。必须配置。

监听模式

TileService的监听模式(或理解为启动模式)有两种,一种是主动模式,另一种是标准模式。

  • 主动模式

在主动模式下,TileService被请求时该服务会被绑定,并且TileService的onStartListening也会被调用。该模式需要在AndroidManifeast清单文件中声明:

<service ...>
<meta-data android:name="android.service.quicksettings.ACTIVE_TILE"
android:value="true" />
...
</service>

通过TileService.requestListeningState()这一静态方法,就可以实现对TileService的请求,示例如下:

      TileService.requestListeningState(
applicationContext, ComponentName(
BuildConfig.APPLICATION_ID,
MyQSTileService::class.java.name
)
)

主动模式下值得注意的是:

  • 用户在通知栏快速设置的地方点击快捷开关时,TileService会自动完成绑定、TileService的onStartListening会被调用。
  • TileService无论是通过点击被绑定还是通过requestListeningState请求被绑定,TileService所在的进程都会被调起。

标准模式

     在标准模式下,TileService可见时(即用户下拉通知栏看见快捷开关)该服务会被绑定,并且TileService的onStartListening也会被调用。标准模式不需要在AndroidManifeast清单文件中进行额外的声明,默认就是标准模式。

标准模式下值得注意的是:

  • 和主动模式相同,TileService被绑定时,TileService所在的进程就会被调起。
  • 而和主动模式不同的是,标准模式绑定TileService是通过用户下拉通知栏实现的,这意味着TileService所在的进程会被多次调起。因此为了避免主进程被频繁调起、避免DAU等数据统计受到影响,我们还需要为TileService指定一个特定的子进程,在Androidmanifest清单文件中设置:
      <service
......
android:process="自定义子进程的名称">
......
</service>

更新快捷开关

如果需要对快捷开关的数据进行更新,可以通过getQsTile()获取快捷开关的对象,然后通过setIcon(更新icon)、setLable(更新名称)、setState(更新状态,包括STATE_ACTIVE——表示开启或启用状态、STATE_INACTIVE——表示关闭或暂停状态、STATE_UNAVAILABLE:表示暂时不可用状态,在此状态下,用户无法与您的磁贴交互)等方法设置快捷开关新的数据,最后调用updateTile()方法实现。

  override fun onStartListening() {
super.onStartListening()
if (qsTile.state === Tile.STATE_ACTIVE) {
qsTile.label = "inactive"
qsTile.icon = Icon.createWithResource(context, R.drawable.inactive)
qsTile.state = Tile.STATE_INACTIVE
} else {
qsTile.label = "active"
qsTile.icon = Icon.createWithResource(context, R.drawable.active)
qsTile.state = Tile.STATE_ACTIVE
}
qsTile.updateTile()
}

操作快捷开关

  • 如果想要实现点击快捷开关时、关闭通知栏并跳转到某个页面,可以调用以下方法:
startActivityAndCollapse(Intent intent)

  • 如果想要在点击快捷开关时弹出对话框进行交互,可以调用以下方法:
override fun onClick() {
super.onClick()
if(!isLocked()) {
showDialog()
}
}

因为快捷开关有可能在用户锁屏时出现,所以必须加上isLocked()的判断。只有非锁屏的情况下,对话框才会出现。

  • 如果快捷开关含有敏感信息,需要使用isSecure()进行设备安全性判断,当设备安全时,才能执行快捷开关相关的逻辑(如点击的逻辑)。当设备不安全时(手机处于锁屏状态时),可调用unlockAndRun(Runnable runnable),提示用户解锁屏幕并执行自定义的runnable操作。
收起阅读 »

Flutter图片与文件选择器

春节已过,今天是开工的第一天。我已经一个多星期没碰过电脑了,今日上班,打开电脑的第一件事就是想着写点什么。反正大家都还沉浸在节后的喜悦中,还没进入工作状态,与其浪费时间,不如做些更有意义的事情。 今天就跟大家简单分享一下Flutter开发过程中经常会用到的图片...
继续阅读 »

春节已过,今天是开工的第一天。我已经一个多星期没碰过电脑了,今日上班,打开电脑的第一件事就是想着写点什么。反正大家都还沉浸在节后的喜悦中,还没进入工作状态,与其浪费时间,不如做些更有意义的事情。


今天就跟大家简单分享一下Flutter开发过程中经常会用到的图片和文件选择器。


一、image_picker


一个适用于iOS和Android的Flutter插件,能够从图像库中选取图片、视频,还能够调用相机拍摄新的照片。


该插件由Flutter官方提供,github的Star高达16.7k,算是比较成熟且流行的插件了。


1、安装


flutter pub add image_picker

或者


/// pubspec.yaml文件添加依赖,并在执行flutter pub get命令
dependencies
image_picker: ^0.8.6+1

2、使用


import 'package:image_picker/image_picker.dart';

/// 图片选取
Future<void> getImage() async {
final XFile? file = await ImagePicker().pickImage(
source: ImageSource.gallery, // 图库选择
maxWidth: 1000.0, // 设置图片最大宽度,间接压缩了图片的体积
);

/// 选取图片失败file为null,要注意判断下。
/// 获取图片路径后可以上传到服务器上
print('${file?.path}');
}

/// 视频选取
Future<void> getImage() async {
final XFile? file = await ImagePicker().pickVideo(
source: ImageSource.camera, // 调用相机拍摄
);

print('${file?.path}');
}

在项目中,调用getImage方法就会打开图片选择器。


image.png


image.png


3、属性



  • source


图片来源,ImageSource.gallery图片库中选择,ImageSource.camera调用相机拍摄新图片。



  • maxWidth


图片的最大宽度,source为ImageSource.camera时有用,等于间接的压缩了图片的体积。如果不设置,以目前手机的相机性能,动不动就拍出了4、5M的照片,对于app来说,图片上传到服务端,将会很慢,建议设置此属性。


4、注意


iOS端如果出现闪退并且控制台报出:



The app's Info.plist must contain an NSPhotoLibraryAddUsageDescription key with a string value explaining to the user how the app uses this data.



那么,需要打开Xcode在Info.plist配置隐私提示语。


image.png


二、flutter_document_picker


文档选择器,image_picker只能选择图片和视频,如果要选择PDF,word文档、excel表格等就没办法了。这个时候可以使用flutter_document_picker插件,直接选取手机中的文件。


1、安装


flutter pub add flutter_document_picker

或者


/// pubspec.yaml文件添加依赖,并在执行flutter pub get命令
dependencies
flutter_document_picker: ^5.1.0

2、使用


import 'package:image_picker/image_picker.dart';

/// 图片选取
Future<void> getDocument() async {
FlutterDocumentPickerParams? params = FlutterDocumentPickerParams(
// 允许选取的文件拓展类型,不加此属性则默认支持所有类型
allowedFileExtensions: ['pdf', 'xls', 'xlsx', 'jpg', 'png', 'jpeg'],
);

String? path = await FlutterDocumentPicker.openDocument(
params: params,
);

print('$path');
}

image.png


总结


image_picker插件用于图片选取,而flutter_document_picker则用于文件选择,在日常开发中都是很常用的。在iOS中使用要注意隐私权限的配置,不然就会闪退。如果想了解更多的参数属性,可以查看官方文档:


image_picker document


flutter_document_picker document


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

作为一个学会智能合约的前端开发者,该如何找到 Web3 工作?

最近有个朋友咨询我一个问题。他做前端大概有五年了,技术比较一般。从去年开始就一直非常看好 web3 方向。但在 web2 的公司做久了,有一种思维定性,没有下定决心学习 web3 的技术,也不知道从何而起。后来他看到我开始布道 web3,纠结了一段时间,前段时...
继续阅读 »

最近有个朋友咨询我一个问题。

他做前端大概有五年了,技术比较一般。从去年开始就一直非常看好 web3 方向。但在 web2 的公司做久了,有一种思维定性,没有下定决心学习 web3 的技术,也不知道从何而起。

后来他看到我开始布道 web3,纠结了一段时间,前段时间终于下定决心,开始跟着我学起了他犹豫了一年的 web3 技术。

从 solidity 开始,到 truffle、ethersjs 等等,把常用的技术全学了一遍。也根据我的 github 项目做了三四个入门级别的 DApp,整个开发流程也相对熟悉。

这是 Github 项目地址:github.com/luzhenqian/…

但现在仍然很迷茫,因为现在的他需要一个真正转型的契机,也就是寻找一份 web3 工作。

这种迷茫,在我的理解中,应该是一类通用问题,也就是:作为一个前端开发者,在学会了智能合约后如何加入到 Web3 开发的公司或者团队。

我相信存在这类问题的人不在少数,所以在我帮朋友解惑后,顺便把这个过程中的一些思考整理出来,帮助更多存在同类问题的小伙伴答疑。

要完成这个任务,我认为需要做四个阶段的准备:确认自身技术栈、确认自身领域、确认目标公司、自我准备。

首先第一个就是整理好你的定位与技术栈。找工作这件事,其实就是营销。你需要把自己包装成产品,然后要对产品进行定位与美化。

如果仍然锚定做开发者,那么定位主要分两个:

  1. 仍然主做前端。

  2. 全栈。做合约开发,同时也做前端。

第二个就是确定好自己切入的细分领域,你可以理解为主要业务范畴。

Web3 目前的领域非常多,比如:

  • 交易所。

  • 公链。

  • NFT 交易所。

  • 钱包。

  • 链游。

  • DID。

第三个就是想好自己要加入的公司类型,这个时候公司就是你的用户,你需要给公司画用户画像。

以 base 为维度进行划分,基本上有三类:

  • remote

  • 国外

  • 国内

以规模进行划分,大概也有几类:

  • 顶级大厂,比如头部交易所和公链。

  • 独角兽公司,拿到千万级别美金的知名公司。

  • 商业化公司,拿到千万级别人民币的知名项目。

  • 增长阶段的公司,有一些用户,拿到了一些资本投资,处于增长阶段的公司。

  • 创业公司,还没有什么钱,也没有什么用户的刚起步的公司。

最后一个,你需要编写自己的简历。

如果你没有经验,那就自己给自己增加一些经验,比如想办法参加一些黑客松,参加一些社区活动,也可以在 Github 上做一些小项目,不一定功能很庞大很复杂,但一定要有创意和创新,小而美。

产品定位

先来看产品定位,也就是自身的问题。

技术栈我推荐你这么选:

  • next.js:最流行的前端框架,没有备选。

  • tailwindcss:最流行的 CSS 框架,没有备选。

  • solidity:最流行的智能合约编程语言,没有备选。

  • ethersjs:最流行的智能合约交互库,备选是 web3.js。

  • wagmi:最流行的 React Hook 智能合约交互库,没有备选。

  • hardhat:最流行的智能合约开发框架,备选是 truffle。

  • open zeppelin:最流行的智能合约库。

业务领域没有局限,都可以选择。但一定要有自己最擅长的那一个,也就是专精领域。在你的专精领域中,你就是专家,你可以一直侃侃而谈,但一定不能被问得哑口无言。比如选择 NFT。你需要把 NFT 相关的协议全部搞得非常明白,比如 ERC721、ERC1155 等。同时还需要知道白名单是怎么做的、荷兰拍卖该怎么去做、NFT 交易所怎么做,手续费如何抽取、NFT 的一些种类,PFP 背后的逻辑等等、一些蓝筹项目是如何运作起来的、NFT 创作网站的流程及实现方案等等。千万不要什么都懂,但什么都不精通。

用户画像

接下来就是用户画像,也就是公司的定位。

我推荐的公司画像是这样:remote、国外。规模的话自然是越大越好,因为薪资报酬相对都更稳定。当然一些小而美的公司也不错。在正式投递简历之前,一定要对公司进行全方位了解。比如公司的主营业务是什么,不存在实际价值的公司一定不要去。所谓实际价值,就是能够解决某一个或一类真实存在并有意义的实际问题。同时也要看一下团队背景和创始人背景。

简历阶段,我建议根据意向岗位来定制自己的简历。提高通过率,节省时间成本。首先在简历上把自己的技术体系写清楚,尽量契合目标岗位。在商业化以上的公司里面,更注重个人领域的细分,他们通常更看重你的技术深度,因为他们把你招进去是要做螺丝钉的。所以你需要在这方面多加准备。如果是创业阶段或者增长阶段的公司,他们通常更看重你的想法,因为在这个阶段中,他们的产品也是在得到用户反馈后迅速变化的,并没有形成固有的形态,他们需要你去和他们一起 building 产品。

另外在简历上也有一些小技巧。比如尽量把头像放上去,很多欧美的公司都需要通过头像知道你的肤色和你是哪国人。同时简历中不要使用很糟糕的英文。有找一些优质的简历模板进行参照。总之要从一些细节之处来展现你的专业度。

最后祝你早日加入理想的公司。

作者:代码与野兽
来源:juejin.cn/post/7193206064962404411

收起阅读 »

清华硕士被字节2W月薪录取后愤然发布"字节之恶"讨伐檄文火遍全网!

一清华硕士毕业生被字节2W月薪录取,该毕业生认为被严重侮辱,血泪控诉字节之恶,引发打工人对于清华高材生的薪水以及字节的广泛关注,在网上引起轩然大波 一开始看到他文案末尾的两个坏笑表情,还以为他是在凡尔赛,随口吐吐槽。该同学的字节录取通知书如下:后端开发工程师-...
继续阅读 »

一清华硕士毕业生被字节2W月薪录取,该毕业生认为被严重侮辱,血泪控诉字节之恶,引发打工人对于清华高材生的薪水以及字节的广泛关注,在网上引起轩然大波


一开始看到他文案末尾的两个坏笑表情,还以为他是在凡尔赛,随口吐吐槽。

该同学的字节录取通知书如下:后端开发工程师-基础架构,工作地点北京。

字节OFFER信息如下


谁知道该清华高材生收到字节2W月薪的OFFER后,愤愤不平并锲而不舍的攻击字节,认为2W月薪不如本科生,硕士白读了,感觉侮辱了自己和清华:


总结就是:

1、透露出公司效益不好,劝投资者抛售期权、员工年终别用现金换期权,做空丫的!

2、个人努力被否认了,很难受很悲哀,要将此痛双倍奉还!

并发布了标题为“字节之恶”讨伐檄文


对堂堂清华硕士毕业生开出2W月薪,严重的侮辱了该清华高材生的人格,字字控诉、声声血泪,令人闻之落泪。

中国互联网最牛逼的企业之一字节被它贬的一无是处,并列举了字节三大罪状!

疫情三年,百业调零,而高校每年的毕业生却屡创新高,僧多肉少,毕业后2W的薪水是否侮辱了该清华毕业生呢?


国家统计局出版的《中国统计年鉴2022》显示,北京城镇非私营单位就业人员的全年平均工资为194651元,按12个月计算,月薪就是16221元。

该清华硕士拿到的2万offer,显然高于普通北漂的工资水平,还没算年终奖、各种补贴。

国家统计局出版的《中国统计年鉴2022》显示,北京城镇非私营单位就业人员的全年平均工资为194651元,按12个月计算,月薪就是16221元


该清华学生的2W月薪显然是没有包含年终奖部分,字节年终奖一般为1-6个月,按照字节的公开薪酬资料该高材生年薪肯定是超过30W的


字节跳动的职级一共10级,从1-1到5-2,每一个大职级下分有1~2个小职级,1-1是初级工程师,1-2是研发工程师,2-1和2-1为资深研发,3-1和3-2为team领导层,4-1和4-2是部门领导层,5-1和5-2是公司领导层。

1-1和1-2主要由刚毕业或工作经验尚浅的员工组成,校招应届生目前一般是 1-2级,作为项目组成员;平均月薪24k,年终奖大概为3个月,比其他互联网大公司入门级别的月薪20k左右要高出20%左右。

而2-2可对标阿里P7、腾讯9、10级,将会有股票激励。关于期权,回购价格为市场价 8 折,已归属的期权员工可以带走。3-1和3-2就是公司的中层了。

接下来我们来看看2022年字节跳动员工工资标准一览表:


字节跳动不同序列间月薪差异较大,技术岗整体薪资偏高。字节跳动的现金薪酬要比其他大厂高出 25%-40%。

从表中可知,在字节,1-2级对应中位数薪资大概为31.5w,2-1级中位数薪资大概为42.3w,2-2级中位数薪资大概为57.9w,3-1级中位数薪资大概为101.1w,3-2级中位数薪资大概为134.1w,达到3-1就可以有年薪百万了。

年终奖一般 0-6 个月,会在每年 3 月发放。一般中等大多数是 15 薪,如果年中绩效超预期,则可以多拿 2 个月年终奖。

对于该该清华高材生对互联网巨头的血泪控诉,你怎么看呢?

来源:桔子聊球

收起阅读 »

[Flutter] 如何替换so文件来动态替换Flutter代码

一、Flutter代码的启动起点 我们在多数的业务场景下,使用的都是FlutterActivity、FlutterFragment。在在背后,我们知道有着FlutterEnigine、DartExecutor等等多个部件在支持它们的工作。我们所要探究的,就是,...
继续阅读 »

一、Flutter代码的启动起点


我们在多数的业务场景下,使用的都是FlutterActivityFlutterFragment。在在背后,我们知道有着FlutterEnigineDartExecutor等等多个部件在支持它们的工作。我们所要探究的,就是,它们是如何启动的,Dart代码是从何而来的,以实现动态替换libapp.so


以官方的计数器Demo为例,默认的Activity宿主,是实现了FlutterActivity的子类,对于一个Activity,我们最应该关心的就是它的onCreate方法:



  • FlutterActivity# onCreate


protected void onCreate(@Nullable Bundle savedInstanceState) {
switchLaunchThemeForNormalTheme();

super.onCreate(savedInstanceState);

delegate = new FlutterActivityAndFragmentDelegate(this);
delegate.onAttach(this);
delegate.onRestoreInstanceState(savedInstanceState);

lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_CREATE);

configureWindowForTransparency();
setContentView(createFlutterView());
configureStatusBarForFullscreenFlutterExperience();
}

其实过程很简单,FlutterActivity在这里做了一些主题的设置,因为毕竟FlutterActivity也是一个常规的Activity,它就必须按照Android的Activity的一些规范来进行设置。


第三行代码开始,就创建了一个我们所说的**FlutterActivityAndFragmentDelegate**对象,FlutterActivity将绝大多数的Flutter初始化相关逻辑委托给了它,而自身则专注于设置主题、窗口、StatusBar等等。


我们对delegate.onAttach(this);这一行代码的跟踪,最终能走到如下的一个创建流程:


FlutterActivity->
FlutterActivityAndFragmentDelegate->
onAttach()->
setupFlutterEngine->
1.尝试去Cache中获取Engine
2.尝试从Host中获取Engine
3.都没有的话创建一个新的Engine->
Engine #Constructor->
1. 会对Assets、DartExecutor、各种Channel、FlutterJNI做处理
2. 还会对FlutterLoader做处理->
startInitialization方法做初始化
-> 1. 必须在主线程初始化Flutter
-> 2. 先检查settings变量;
-> 3. 获取全局的ApplicationContext防止内存泄漏
-> 4. VsyncWaiter对象的初始化
-> 5. 最后会生成一个initTask交给线程池去执行

1.1 initTask对象


initTask是一个Callable对象,和Runnable类似的,我们可以将它理解成一个任务,也就是一段代码,他最终会被交给线程池去执行:


initResultFuture = Executors.newSingleThreadExecutor().submit(initTask);

initTask的代码如下


 // Use a background thread for initialization tasks that require disk access.
Callable<InitResult> initTask =
new Callable<InitResult>() {
@Override
public InitResult call() {
ResourceExtractor resourceExtractor = initResources(appContext);

flutterJNI.loadLibrary();

// Prefetch the default font manager as soon as possible on a background thread.
// It helps to reduce time cost of engine setup that blocks the platform thread.
Executors.newSingleThreadExecutor()
.execute(
new Runnable () {
@Override
public void run () {
flutterJNI.prefetchDefaultFontManager();
}
}
);

if (resourceExtractor != null) {
resourceExtractor.waitForCompletion();
}

return new InitResult(
PathUtils.getFilesDir(appContext),
PathUtils.getCacheDirectory(appContext),
PathUtils.getDataDirectory(appContext)
);
}
};

我们可以抓一下其中的关键字:



  • ResourceExtractor




  • FlutterJNI.loadLibrary




  • FlutterJNI.prefetchDefaultFontManager




  • PathUtils


不难发现,主要是在做一些资源的预取。


ResourceExtractor主要是针对在DEBUG或者是JIT模式下,针对安装包内资源的提取逻辑。


在DEBUG或者JIT模式下,需要提取Assets目录下的资源文件到存储中,Assets本质上还是Zip压缩包的一部分,没有自己的物理路径,所以需要提取,并返回真真实的物理路径。在DEBUG和JIT模式下,FlutterSDK和业务代码将被构建成Kernel格式的二进制文件,Engine将通过文件内存映射的方式进行加载。



详见:「三、libflutter.so和libapp.so」



1.2 ResourceExtractor



libflutter.so和libapp.so



在DEBUG | JIT模式下,我们是没有libapp.so的,而在release模式下,是有libapp.so文件的,我们分别解包两个不同的Apk文件,可以很清楚地看到这一点:



我们知道,libflutter.so是存放flutter的一些基础类库的so文件,而libapp.so则是存放我们业务代码的so文件,那如果在DEBUG|JIT模式下,没有libapp.so,那么我们的业务代码存储在哪里呢?


此时,我们就要看看ResourceExtractor的initResources方法,究竟干了些什么:


 /** Extract assets out of the APK that need to be cached as uncompressed files on disk. */
private ResourceExtractor initResources(@NonNull Context applicationContext) {
ResourceExtractor resourceExtractor = null;
if (BuildConfig.DEBUG || BuildConfig.JIT_RELEASE) {
final String dataDirPath = PathUtils.getDataDirectory(applicationContext);
final String packageName = applicationContext.getPackageName();
final PackageManager packageManager = applicationContext.getPackageManager();
final AssetManager assetManager = applicationContext.getResources().getAssets();
resourceExtractor =
new ResourceExtractor(dataDirPath, packageName, packageManager, assetManager);

// In debug/JIT mode these assets will be written to disk and then
// mapped into memory so they can be provided to the Dart VM.
resourceExtractor
.addResource(fullAssetPathFrom(flutterApplicationInfo.vmSnapshotData))
.addResource(fullAssetPathFrom(flutterApplicationInfo.isolateSnapshotData))
.addResource(fullAssetPathFrom(DEFAULT_KERNEL_BLOB));

resourceExtractor.start();
}
return resourceExtractor;
}

其中的addResource方法,分别提供了VM的快照数据、iSolate的快照数据DEFAULT_KERNEL_BLOB的数据。因为Flutter本身支持热重载的特性,保存状态和快照(Snapshot)之间必然是不可分割的。


而DEFAULT_KERNEL_BLOB是一个字符串常量: "kernel_blob.bin",结合前面的内容:



FlutterSDK和业务代码将被构建成Kernel格式的二进制文件



我们有理由猜测, "kernel_blob.bin" ,就是我们的业务代码,Flutter是支持逻辑代码热重载的,所以这个字面量加载的资源同样可能会被重新加载。



这也是为什么,如果我们在State中,新增了某个变量作为Widget的某个状态,在initState中调用了,然后使用热重载之后,会导致State中找不到这个变量,因为initState在初次启动时就被调用过了,后续的热重载只会将之前的Snapshot恢复回来,而不会走initState的逻辑。



我们可以在app-debug.apk的assets中,找到"kernel_blob.bin"文件,同样也可以找到isolate_snapshot_data、vm_snapshot_data文件,所以ResourceExtractor加载的,基本上都是这个文件夹中的文件。



但是,在非DEBUG|JIT模式下,就不需要通过ResourceExtractor来进行加载了。


回到initTask方法,只在resourceExtractor != null时,会去等待它的完成。


ResourceExtractor resourceExtractor = initResources(appContext);

flutterJNI.loadLibrary();

// Prefetch the default font manager as soon as possible on a background thread.
// It helps to reduce time cost of engine setup that blocks the platform thread.
Executors.newSingleThreadExecutor()
.execute(
new Runnable() {
@Override
public void run() {
flutterJNI.prefetchDefaultFontManager();
}
});

if (resourceExtractor != null) {
resourceExtractor.waitForCompletion();
}

1.3 FlutterJNI#loadLibrary


public void loadLibrary() {
if (FlutterJNI.loadLibraryCalled) {
Log.w(TAG, "FlutterJNI.loadLibrary called more than once" );
}

System.loadLibrary( "flutter" );
FlutterJNI.loadLibraryCalled = true;
}

代码比较简单,无非就是调用System.loadLibrary去加载Library文件。需要注意的是,表面上找到是flutter,但是在Native(C++)层中,会为它拼接上前缀和后缀:lib和.so,所以,实际上load行为查找的是位于apk包下的lib目录下的对应架构文件夹下的libflutter.so


initTask任务提交给线程池之后,就相当于startInitialization走完了。



你会发现有个问题,在Debug模式下,我们加载业务代码是从二进制文件:"kernel_blob.bin"中加载的,而Release模式下,实在libapp.so中加载的,上面已经出现了加载"kernel_blob.bin"和libflutter.so ,那么在release模式下,另一个Library文件:libapp.so是什么时候加载的呢?


所以,就要进入我们的第二个关键方法:ensureInitializationComplete



二、ensureInitializationComplete


实际上,ensureInitializationComplete和startInitialization在FlutterEngine的初始化代码中


flutterLoader.startInitialization(context.getApplicationContext());
flutterLoader.ensureInitializationComplete(context, dartVmArgs);

代码一百多行,但是大多都是一些配置性的代码:


public void ensureInitializationComplete(
@NonNull Context applicationContext, @Nullable String[] args) {
if (initialized) {
return;
}
if (Looper.myLooper() != Looper.getMainLooper()) {
throw new IllegalStateException(
"ensureInitializationComplete must be called on the main thread" );
}
if (settings == null) {
throw new IllegalStateException(
"ensureInitializationComplete must be called after startInitialization" );
}
try {
InitResult result = initResultFuture.get();

List<String> shellArgs = new ArrayList<>();
shellArgs.add( "--icu-symbol-prefix=_binary_icudtl_dat" );

shellArgs.add(
"--icu-native-lib-path="
+ flutterApplicationInfo.nativeLibraryDir
+ File.separator
+ DEFAULT_LIBRARY);
if (args != null) {
Collections.addAll(shellArgs, args);
}

String kernelPath = null;
if (BuildConfig.DEBUG || BuildConfig.JIT_RELEASE) {
String snapshotAssetPath =
result.dataDirPath + File.separator + flutterApplicationInfo.flutterAssetsDir;
kernelPath = snapshotAssetPath + File.separator + DEFAULT_KERNEL_BLOB;
shellArgs.add( "--" + SNAPSHOT_ASSET_PATH_KEY + "=" + snapshotAssetPath);
shellArgs.add( "--" + VM_SNAPSHOT_DATA_KEY + "=" + flutterApplicationInfo.vmSnapshotData);
shellArgs.add(
"--" + ISOLATE_SNAPSHOT_DATA_KEY + "=" + flutterApplicationInfo.isolateSnapshotData);
} else {
shellArgs.add(
"--" + AOT_SHARED_LIBRARY_NAME + "=" + flutterApplicationInfo.aotSharedLibraryName);

// Most devices can load the AOT shared library based on the library name
// with no directory path. Provide a fully qualified path to the library
// as a workaround for devices where that fails.
shellArgs.add(
"--"
+ AOT_SHARED_LIBRARY_NAME
+ "="
+ flutterApplicationInfo.nativeLibraryDir
+ File.separator
+ flutterApplicationInfo.aotSharedLibraryName);
}

shellArgs.add( "--cache-dir-path=" + result.engineCachesPath);
if (!flutterApplicationInfo.clearTextPermitted) {
shellArgs.add( "--disallow-insecure-connections" );
}
if (flutterApplicationInfo.domainNetworkPolicy != null) {
shellArgs.add( "--domain-network-policy=" + flutterApplicationInfo.domainNetworkPolicy);
}
if (settings.getLogTag() != null) {
shellArgs.add( "--log-tag=" + settings.getLogTag());
}

ApplicationInfo applicationInfo =
applicationContext
.getPackageManager()
.getApplicationInfo(
applicationContext.getPackageName(), PackageManager.GET_META_DATA);
Bundle metaData = applicationInfo.metaData;
int oldGenHeapSizeMegaBytes =
metaData != null ? metaData.getInt(OLD_GEN_HEAP_SIZE_META_DATA_KEY) : 0;
if (oldGenHeapSizeMegaBytes == 0) {
// default to half of total memory.
ActivityManager activityManager =
(ActivityManager) applicationContext.getSystemService(Context.ACTIVITY_SERVICE);
ActivityManager.MemoryInfo memInfo = new ActivityManager.MemoryInfo();
activityManager.getMemoryInfo(memInfo);
oldGenHeapSizeMegaBytes = (int) (memInfo.totalMem / 1e6 / 2);
}

shellArgs.add( "--old-gen-heap-size=" + oldGenHeapSizeMegaBytes);

if (metaData != null && metaData.getBoolean(ENABLE_SKPARAGRAPH_META_DATA_KEY)) {
shellArgs.add( "--enable-skparagraph" );
}

long initTimeMillis = SystemClock.uptimeMillis() - initStartTimestampMillis;

flutterJNI.init(
applicationContext,
shellArgs.toArray(new String[0]),
kernelPath,
result.appStoragePath,
result.engineCachesPath,
initTimeMillis);

initialized = true;
} catch (Exception e) {
Log.e(TAG, "Flutter initialization failed." , e);
throw new RuntimeException(e);
}
}

显然,ensureInitializationComplete也必须在主线程中进行调用,并且必须在startInitialization之后进行调用。此外,我们要注意另外一个东西:shellArgs。


2.1 ShellArgs


Shell是什么大家并不陌生,在计算机中,Shell通常作为系统调用用户操作之间的那么个东西,它存在的形式在Linux/Mac中一般就是一个Shell软件,通常运行在终端当中(你可以粗略地就将Shell 和终端划等号 )。


所以,Flutter的Shell自然而然地旨在设置Flutter运行的一个「基底」,ShellArgs,则是我们使用这么个「基底」的参数。


和之前提到的ResourceExtractor在JIT|DEBUG模式下主动去加载VM和Isoalte快照数据类似地,ShellArgs会在DEBUG和JIT模式下,去设置VM快照数据、Isolate快照数据和Kernel的地址。



别忘了,Kernel即上述的“kernel_blob.bin”二进制文件,是在Debug阶段我们的业务代码,和libapp.so是相对的。



而在除上述之外的条件下,Flutter设置了一个AOT_SHARED_LIBRARY_NAME的路径:


shellArgs.add(
"--" + AOT_SHARED_LIBRARY_NAME + "=" + flutterApplicationInfo.aotSharedLibraryName);

shellArgs.add(
"--"
+ AOT_SHARED_LIBRARY_NAME
+ "="
+ flutterApplicationInfo.nativeLibraryDir
+ File.separator
+ flutterApplicationInfo.aotSharedLibraryName);

在运行时,这个向shareArgs这个List中添加内容的两个字符串的内容,大致上就是指定了装载在系统的Apk安装包中的so文件的路径。


--aot-shared-library-name=libapp.so
--aot-shared-library-name=/data/app/~~RjRJYnLhVBHYW8pHHPeX2g==/com.example.untitled1-wcMTYW1VkfGA2LxW62gUFA==/lib/arm64/libapp.so


因为Tinker本身是支持二进制SO库的动态化的,之前尝试过去动态修改aotSharedLibraryName的值和路径,希望FlutterLoader从该地址去加载libapp.so,以实现Android侧借助Tinker热修复Flutter代码,但是并没有细看源码,打了N个Debug包去测试,结果现在发现这逻辑压根没走。




除了上述的两个libapp.so的名称和路径之外,在DEBUG | JIT模式下的ShellArgs的全家福大致如下:



其实你仔细看看,上述的Kernel的Path并没有在这里面,因为它作为参数,传递给了flutterJNI.init函数。


三、实践:自定义libapp.so的加载


至此,我们今天最开始的一个话题:Embdder和代码Dart代码从何而来, 便有了结果 。结合上述的内容,我们可以做一个小小的实践,我们通过传入ShellArgs,来加载指定的 libapp.so 文件。


回到我们最初的流程:


FlutterActivity->
FlutterActivityAndFragmentDelegate->
onAttach()->
setupFlutterEngine->
……
startInitialization
ensureInitializationComplete // alpha

我们需要在上述的过程的alpha之前,完成对***AOT_SHARED_LIBRARY_NAME*** 对应的路径(一模一样,也是 AOT_SHARED_LIBRARY_NAME )这两个字符串的内容替换,比如:


--aot-shared-library-name=libapp.so
--aot-shared-library-name= /data/app/~~RjRJYnLhVBHYW8pHHPeX2g==/com.example.untitled1-wcMTYW1VkfGA2LxW62gUFA==/lib/arm64/libapp.so

我们希望替换成:


--aot-shared-library-name=libfixedapp.so
--aot-shared-library-name= /temp/lib/arm64/libfixedapp.so

3.1 flutterApplicationInfo和FlutterActivity#getShellArgs()


这是FlutterLoader的一个实例对象,它在startInitialization阶段被赋值:


public void startInitialization(@NonNull Context applicationContext, @NonNull Settings settings) {
// ……
try {
final Context appContext = applicationContext.getApplicationContext();
// ……
flutterApplicationInfo = ApplicationInfoLoader.load(appContext);

……

所以,我们只需要在合适的时机去修改这个值即可。


但是并没有合适的时机,因为Flutter并没有为我们提供可以侵入去反射设置它的时机,如果在startInitialization,我们唯一可以侵入的时机是attach()函数,但是会让我们反射设置的值被覆盖掉。


但是,我们关注一下,在setupFlutterEngine时,我们new FlutterEngine的参数:


flutterEngine =
new FlutterEngine(
host.getContext(),
host.getFlutterShellArgs().toArray(),
/*automaticallyRegisterPlugins=*/ false,
/*willProvideRestorationData=*/ host.shouldRestoreAndSaveState());

此处的host,就是我们的FlutterActivity,因为FlutterActivity本身就是FlutterActivityAndFragmentDelegate.Host接口的实现类,而这个host.getFlutterShellArgs().toArray(),最终会作为我们在FlutterActivity预设的参数,在所其他系统预设参数被加入之前被加入到我们的shellArgs数组中。


所以,我们在FlutterActivity的子类,也就是MainActivity下,重写getFlutterShellArgs()方法:


class MainActivity: FlutterActivity() {
override fun getFlutterShellArgs(): FlutterShellArgs {
return super.getFlutterShellArgs().apply {
this.add( "--aot-shared-library-name=libfixedapp.so" )
this.add( "--aot-shared-library-name=/data/data/com.example.untitled1/libfixedapp.so" )
}
}
}

我们可以在debug模式下debug,看看有没有效果:



显然,是有效果的。



因为只能从几个特定的目录中去加载so库文件,我们必须将补丁SO文件放在/data/data/com.example.untitled1对应的目录之下。



接下来,我们先写一个有bug的Flutter代码,我们把标题改成:This is Counter Title with bug 并且新增一个 _decrementCounter() 并把计数器的加法按钮对应的增加按钮,改成减少调用。


然后在Flutter项目根目录使用安装Release包:


flutter build apk --release
adb install build/app/outputs/flutter-apk/app-release.apk

然后我们修复Bug,将代码恢复到最开始的默认状态,然后:


flutter build apk --release
open build/app/outputs/flutter-apk/

解压apk,然后把对应的so文件移出来,放到对应的文件夹下: /data/data/com.example.untitled1/libfixedapp.so 。完成之后,重新启动程序,即可从新的、我们指定的路径加载新的 libapp.so 了:



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

因为买不到烟花,所以我想用Compose来放烟花

再过几天就要过大年了,最近周围也是一到晚上就是到处都在放烟花,看得我十分眼馋也想搞点烟花来放放,可惜周围实在是买不到,一打听全是托人在外省买的,算了太麻烦了,那真的烟花放不了,我就干脆决定写个烟花出来吧,应应景,也烘托点年味儿出来~刚好最近学了点Compose...
继续阅读 »

再过几天就要过大年了,最近周围也是一到晚上就是到处都在放烟花,看得我十分眼馋也想搞点烟花来放放,可惜周围实在是买不到,一打听全是托人在外省买的,算了太麻烦了,那真的烟花放不了,我就干脆决定写个烟花出来吧,应应景,也烘托点年味儿出来~刚好最近学了点Compose的动画,所以这个烟花就拿Compose来写,先上个最终效果图


afire.gif


不好意思...放错效果图了...这个才是


afire9.gif


gif有点卡,真实效果还要流畅点,这些我们先不说,先来看看这个动画我们需要做些什么



  1. 一闪一闪(对..在闪)的小星星

  2. 逐渐上升的烟花火苗

  3. 烟花炸开的效果

  4. 炸开后闪光效果


开始开发


闪烁的星星


首先我们放烟花肯定是在晚上放烟花的,所以整体画布首先背景色就是黑色,模拟一个夜空的场景


Canvas(
modifier = Modifier
.size(screenWidth().dp, screenHeight().dp)
.background(color = Color.Black)
) {

}

确定好了画布以后,我们先来想想如何画星星,夜空中的星星其实就是在画布上画几个小圆点,然后小圆点的颜色是白色的,最后星星看起来都是有大有小的,因为距离我们的距离不一样,所以我们的小圆点也要看起来大小不一样,也就是圆点的半径不一样,知道这些以后我们开始设计代码,先确定好需要的变量,比如画布的中心点xy坐标,星星的xy坐标,以及星星的颜色


val drawColor = colorResource(id = R.color.white)

val centerX = screenWidth() / 2
val centerY = screenHeight() / 2
val starXList = listOf(
screenWidth() / 12, screenWidth() / 6, screenWidth() / 4,
screenWidth() / 3, screenWidth() * 5 / 12, screenWidth() / 2, screenWidth() * 7 / 12,
screenWidth() * 2 / 3, screenWidth() * 3 / 4, screenWidth() * 5 / 6, screenWidth() * 11 / 12
)
val starYList = listOf(
centerY / 12, centerY / 6, centerY / 4,
centerY / 3, centerY * 5 / 12, centerY / 2, centerY * 7 / 12,
centerY * 2 / 3, centerY * 3 / 4, centerY * 5 / 6, centerY * 11 / 12
)

starXList放星星的横坐标,横坐标就是把画布宽十二等分,starYList放星星的纵坐标,纵坐标就是把画布高的二分之一再十二等分,这样作法的目的就是最终画圆点的时候,两个List可以随机选取下标值,达到星星随机散布在夜空的效果


drawCircle(drawColor, 5f, Offset(starXList[0], starYList[10]))
drawCircle(drawColor, 4f, Offset(starXList[1], starYList[9]))
drawCircle(drawColor, 3f, Offset(starXList[2], starYList[4]))
drawCircle(drawColor, 5f, Offset(starXList[3], starYList[6]))
drawCircle(drawColor, 6f, Offset(starXList[4], starYList[3]))
drawCircle(drawColor, 5f, Offset(starXList[5], starYList[7]))
drawCircle(drawColor, 6f, Offset(starXList[6], starYList[2]))
drawCircle(drawColor, 2f, Offset(starXList[7], starYList[1]))
drawCircle(drawColor, 5f, Offset(starXList[8], starYList[0]))
drawCircle(drawColor, 2f, Offset(starXList[9], starYList[5]))
drawCircle(drawColor, 2f, Offset(starXList[10], starYList[8]))

然后一闪一闪的效果怎么做呢,一闪一闪也就是圆点的半径循环在变大变小,所以我们需要用到Compose的循环动画rememberInfiniteTransition,这个函数可以通过它的animateXXX函数来创建循环动画,它里面有三个这样的函数


image.png


我们这里就使用animateFloat来创建可以变化的半径


val startRadius by transition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(tween(1000, easing = LinearEasing))
)

这个函数返回的是一个Float类型的值,前两个参数很好理解,初始值跟最终值,第三个参数是一个
InfiniteRepeatableSpec的对象,它决定这个循环动画的一些参数,duration决定动画持续时间,delayMillis延迟执行的时间,easing决定动画执行的速度



  • LinearEasing 匀速执行

  • FastOutLinearInEasing 逐渐加速

  • FastOutSlowInEasing 先加速后减速

  • LinearOutSlowInEasing 逐渐减速


这里的星星的动画就选择匀速执行就好,我们把starRadius设置到星星的绘制流程里面去


drawCircle(drawColor, 5f + startRadius, Offset(starXList[0], starYList[10]))
drawCircle(drawColor, 4f + startRadius, Offset(starXList[1], starYList[9]))
drawCircle(drawColor, 3f + startRadius, Offset(starXList[2], starYList[4]))
drawCircle(drawColor, 5f + startRadius, Offset(starXList[3], starYList[6]))
drawCircle(drawColor, 6f + startRadius, Offset(starXList[4], starYList[3]))
drawCircle(drawColor, 5f + startRadius, Offset(starXList[5], starYList[7]))
drawCircle(drawColor, 6f + startRadius, Offset(starXList[6], starYList[2]))
drawCircle(drawColor, 2f + startRadius, Offset(starXList[7], starYList[1]))
drawCircle(drawColor, 5f + startRadius, Offset(starXList[8], starYList[0]))
drawCircle(drawColor, 2f + startRadius, Offset(starXList[9], starYList[5]))
drawCircle(drawColor, 2f + startRadius, Offset(starXList[10], starYList[8]))

效果就是这样的


afire3.gif


烟花火苗


现在开始绘制烟花部分,首先是上升的火苗,火苗也是个小圆点,它的起始坐标跟终点坐标很好确定,横坐标都是centerX即画布的一半,纵坐标开始位置是在画布高度位置,结束是在centerY即画布一半高度位置,而一次放烟花的过程中,烟花炸开的次数有很多次,伴随着火苗上升次数也很多次,所以这个也是个循环动画,整个过程代码实现如下


val fireDuration = 3000
val shootHeight by transition.animateFloat(
screenHeight(),
screenHeight() / 2,
animationSpec = InfiniteRepeatableSpec(tween(fireDuration,
easing = FastOutSlowInEasing))
)
Canvas(
modifier = Modifier
.size(screenWidth().dp, screenHeight().dp)
.background(color = Color.Black)
) {
drawCircle(drawColor, 6f, Offset(centerX, shootHeight))
}

由于火苗上升会随着重力逐渐减速,所以这里选择的是先快后慢的动画效果,效果如下


afire4.gif


烟花炸开


这一部分难度开始增加了,因为烟花炸开这个效果是要等到火苗上升到最高点的位置然后在炸开的,这两个动画有个先后关系,用惯了Androi属性动画的我刚开始还不以为然,认为肯定会有个动画回调或者监听器之类的东西,然而看了下循环动画的源码发现并没有找到想要的监听器


image.png
那只能换个思路了,刚刚说到炸开的动画是在火苗上升到最高点的时候才开始的,那这个最高点就是个开关,当火苗到达最高点的时候,让火苗的动画“暂停”,然后开始炸开的动画,现在问题的关键是,如何让火苗的动画“暂停”,我们知道火苗的动画是一个循环动画,循环动画是从初始值到最终值循环变化的过程,那么我们是不是只要让这两个值都为同一个,让它们没有变化的空间,是不是就等于让这个动画“暂停”了呢,我们开始设计这个过程


var turnOn by remember { mutableStateOf(false) }
val distance = remember { Animatable(screenHeight().dp, Dp.VectorConverter) }
LaunchedEffect(turnOn) {
distance.snapTo(if (turnOn) screenHeight().dp else 0.dp)
}

turnOn是个开关,true的时候表示火苗动画开始,false的时候表示火苗动画已经到达最高点,distance是一个Animatable的对象,Animatable是啥呢,从字面上就能理解它也是个动画,但与我们刚刚接触的循环动画不一样,它只有从初始值到最终值的单向变化,而后面的LaunchedEffect是啥呢,我们点到里面去看下它的源码


fun LaunchedEffect(
key1: Any?,
block: suspend CoroutineScope.() -> Unit
)
{
val applyContext = currentComposer.applyCoroutineContext
remember(key1) { LaunchedEffectImpl(applyContext, block) }
}

这里我们看到key1是任何值,被remember保存了起来,block是个挂起的函数类型对象,也就是block是运行在协程里面的,我们再去LaunchedEffectImpl里面看看


image.png


我们看到了这个协程是在被remember的值发生改变以后才去执行的,那现在清楚了,每次改变turnOn的值,distance就会来回从screenHeight()和0之间切换,而切换的条件就是火苗上升高度到达了画布的一半,我们改一下刚刚火苗的动画,让shootHeight随着distance变化而变化,另外我们给画布添加个点击事件,每次点击让turnOn的值发生改变,目的让动画多进行几次


val shootHeight by transition.animateFloat(
distance.value.value,
distance.value.value / 2,
animationSpec = InfiniteRepeatableSpec(tween(fireDuration,
easing = FastOutSlowInEasing))
)
Canvas(
modifier = Modifier
.size(screenWidth().dp, screenHeight().dp)
.background(color = Color.Black)
.clickable { turnOn = !turnOn }
) {
if (shootHeight.toInt() != screenHeight().toInt() / 2) {
if (shootHeight.toInt() != 0) {
drawCircle(drawColor, 6f, Offset(centerX, shootHeight))
}
} else {
turnOn = false
}
}

我们看下效果是不是我们想要的


afire5.gif


So far so good~动画已经分离开来了,现在就要开始炸开效果的开发,我们先脑补下炸开是什么样子的,是由火苗开始,向四周延伸出去若干条烟火,或者换句话说就是以火苗为圆心,向四周画线条,这样说我们思路有了,这是一个由圆心开始向外drawLine的过程,drawLine这个api大家很熟悉了,最主要的就是确定开始跟结束两处的坐标,但是无论开始还是结束,这两个坐标都是分布在一个圆周上的,所以我们第一步先要确定在哪几个角度上面画线


val anglist = listOf(30, 75, 120, 165, 210, 255, 300, 345)

知道了角度以后,就要去计算xy坐标了,这个就要用到正弦余弦公式


private fun calculateX(centerX: Float, fl: Int, endCor: Boolean): Float {
val angle = Math.toRadians(fl.toDouble())
return centerX - cos(angle).toFloat() * (if (endCor) screenWidth() / 2 else screenWidth() / 12)
}

private fun calculateY(centerY: Float, fl: Int, endCor: Boolean): Float {
val angle = Math.toRadians(fl.toDouble())
return centerY - sin(angle).toFloat() * (if (endCor) screenWidth() / 2 else screenWidth() / 12)
}

其中endColor是true就是画终点的坐标,false就是起点的坐标,我们先画一条线,剩下的线的代码都相同


val startfireOneX = calculateX(centerX, anglist[0], false)
val startfireOneY = calculateY(centerY, anglist[0], false)
val endfireOneX = calculateX(centerX, anglist[0], true)
val endfireOneY = calculateY(centerY, anglist[0], true)

var fireColor = colorResource(id = R.color.color_03DAC5)
var fireOn by remember { mutableStateOf(false) }
val fireOneXValue = remember { Animatable(startfireOneX, Float.VectorConverter) }
val fireOneYValue = remember { Animatable(startfireOneY, Float.VectorConverter) }
val fireStroke = remember { Animatable(0f, Float.VectorConverter) }

LaunchedEffect(fireOn){
fireStroke.snapTo(if(fireOn) 20f else 0f)
fireOneXValue.snapTo(if(fireOn) endfireOneX else startfireOneX)
fireOneYValue.snapTo(if(fireOn) endfireOneY else startfireOneY)
}

fireOneXValue是第一条线横坐标的变化动画,fireOneYValue是纵坐标的变化动画,它们的改变都有fireOn去控制,fireOn打开的时机就是火苗上升到最高点的时候,同时我们也增加了fireStroke,表示线条粗细的变化动画,也随着fireOn的改变而改变,我们现在去创建横坐标,纵坐标以及线条粗细的循环动画


val fireOneX by transition.animateFloat(
startfireOneX, fireOneXValue.value,
infiniteRepeatable(tween(fireDuration, easing = FastOutSlowInEasing))
)
val fireOneY by transition.animateFloat(
startfireOneY, fireOneYValue.value,
infiniteRepeatable(tween(fireDuration, easing = FastOutSlowInEasing))
)

val strokeW by transition.animateFloat(
initialValue = fireStroke.value/20,
targetValue = fireStroke.value,
animationSpec = infiniteRepeatable(tween(fireDuration,
easing = FastOutSlowInEasing))
)

我们现在可以去绘制第一根线了,在Canvas里面增加第一个drawLine


Canvas(
modifier = Modifier
.size(screenWidth().dp, screenHeight().dp)
.background(color = Color.Black)
.clickable { turnOn = !turnOn }
) {
if (shootHeight.toInt() != screenHeight().toInt() / 2) {
if (shootHeight.toInt() != 0) {
drawCircle(drawColor, 6f, Offset(centerX, shootHeight))
}
} else {
turnOn = false
fireOn = true
}
drawLine(
fireColor, Offset(startfireOneX, startfireOneY),
Offset(fireOneX, fireOneY), cap = StrokeCap.Round, strokeWidth = strokeW
)
}

到了这一步,我们应该考虑的是,如何让动画衔接起来,也就是炸开动画完成以后,继续执行火苗动画,那么我们就要找出炸开动画结束的那个点,这里总共有三个值,我们选择strokeW,当线条粗细到达最大值的时候,将fireOn关闭,将turnOn打开,我们在drawLine后面加上这段逻辑


Canvas(
modifier = Modifier
.size(screenWidth().dp, screenHeight().dp)
.background(color = Color.Black)
.clickable { turnOn = !turnOn }
) {
if (shootHeight.toInt() != screenHeight().toInt() / 2) {
if (shootHeight.toInt() != 0) {
drawCircle(drawColor, 6f, Offset(centerX, shootHeight))
}
} else {
turnOn = false
fireOn = true
}
drawLine(
fireColor, Offset(startfireOneX, startfireOneY),
Offset(fireOneX, fireOneY), cap = StrokeCap.Round, strokeWidth = strokeW
)
if(strokeW == 19){
fireOn = false
turnOn = true
}
}

这个时候,两个动画就连起来了,我们运行下看看效果


afire6.gif


一条线完成了,那么其余几根线道理也是一样的,代码有点多篇幅关系就不贴出来了,直接看效果图吧


afire7.gif


基本的样子已经出来了,现在给这个烟花优化一下,我们知道放烟花时候,每次炸开的样子都是不一样的,红橙黄绿啥颜色都有,我们这边也让每次炸开时候,颜色都不一样,那首先我们要弄一个颜色的集合


val colorList = listOf(
colorResource(id = R.color.color_03DAC5), colorResource(id = R.color.color_BB86FC),
colorResource(id = R.color.color_E6A639), colorResource(id = R.color.color_01B9FF),
colorResource(id = R.color.color_FF966B), colorResource(id = R.color.color_FFEBE7),
colorResource(id = R.color.color_FF4252), colorResource(id = R.color.color_EC4126)
)

并且让fireColor在每次炸开之前,更换一次颜色,随机换也行,按照下标顺序替换也行,这边我选择顺序换了,位置就是炸开动画开始的地方


var colorIndex by remember { mutableStateOf(0) }

Canvas(
modifier = Modifier
.size(screenWidth().dp, screenHeight().dp)
.background(color = Color.Black)
.clickable { turnOn = !turnOn }
) {
if (shootHeight.toInt() != screenHeight().toInt() / 2) {
if (shootHeight.toInt() != 0) {
drawCircle(drawColor, 6f, Offset(centerX, shootHeight))
}
} else {
if (strokeW.toInt() == 0) {
colorIndex += 1
if (colorIndex > 7) colorIndex = 0
fireColor = colorList[colorIndex]
}
turnOn = false
fireOn = true
}
drawLine(
fireColor, Offset(startfireOneX, startfireOneY),
Offset(fireOneX, fireOneY), cap = StrokeCap.Round, strokeWidth = strokeW
)
if(strokeW == 19){
fireOn = false
turnOn = true
}
}

我们再想想看,烟花结束以后是不是还会有一些闪光,有的烟花的闪光还会有声音,声音我们弄不出来,但是闪光还是可以的,还记得我们星星怎么画的吗,不就是几个圆圈在那里不断绘制,然后一闪一闪的效果就是不断改变圆圈的半径,那我们烟花的闪光效果也可以这么做,首先我们先确定好需要绘制圆圈的坐标


val endXAnimList = listOf(
calculatePointX(centerX, anglist[0]),
calculatePointX(centerX, anglist[1]),
calculatePointX(centerX, anglist[2]),
calculatePointX(centerX, anglist[3]),
calculatePointX(centerX, anglist[4]),
calculatePointX(centerX, anglist[5]),
calculatePointX(centerX, anglist[6]),
calculatePointX(centerX, anglist[7])
)
val endYAnimList = listOf(
calculatePointY(centerY, anglist[0]),
calculatePointY(centerY, anglist[1]),
calculatePointY(centerY, anglist[2]),
calculatePointY(centerY, anglist[3]),
calculatePointY(centerY, anglist[4]),
calculatePointY(centerY, anglist[5]),
calculatePointY(centerY, anglist[6]),
calculatePointY(centerY, anglist[7])
)

然后烟花放完以后会有个逐渐暗淡的过程,在这里我们就让圆圈的半径也有个逐渐变小的过程,那我们就创建个变小的动画


val pointDuration = 3000
val firePointRadius = remember{ Animatable(0f, Float.VectorConverter) }
val pointRadius by transition.animateFloat(
initialValue = firePointRadius.value,
targetValue = firePointRadius.value / 6,
animationSpec = infiniteRepeatable(tween(pointDuration,
easing = FastOutLinearInEasing))
)

有了这个闪光的动画以后,接下去就要让它跟炸开的动画衔接起来了,这边也跟其他动画一样,增加一个开关去控制,当开关打开之后,firePointRadius设置成最大,开启这个闪光动画,当开关关闭以后,就让firePointRadius设置为0,也就是关闭闪光动画,代码如下


var pointOn by remember { mutableStateOf(false) }
LaunchedEffect(pointOn) {
firePointRadius.snapTo(if (pointOn) 12f else 0f)
}

参数都设置好了以后,我们可以去绘制闪光的圆圈了,这边我们让闪光的开关在炸开动画完毕之后打开,原本要开启的火苗动画我们暂时先不打开,而闪光动画的颜色我们让它跟炸开的动画颜色一致,让整个过程看上去像是烟花自己炸开然后变成小颗粒的样子


Canvas(
modifier = Modifier
.size(screenWidth().dp, screenHeight().dp)
.background(color = Color.Black)
.clickable { turnOn = !turnOn }
) {
....此处省略前面两个烟花动画的绘制过程.....

if(strokeW == 19){
fireOn = false
pointOn = true
}
if(pointOn){
repeat(endXAnimList.size) {
drawCircle(
colorList[colorIndex], pointRadius,
Offset(endXAnimList[it], endYAnimList[it])
)
}
}
}

到了这里感觉好像漏了点什么,没错,之前我们暂时把火苗开关打开的时机取消了,那这个开关得打开呀,不然我们的烟花没办法连在一起放,现在就是要找到这个临界值,我们发现这个绘制圆圈的过程,只有圆圈的半径在随着时间的递进逐渐变小的,它的最小值是当pointOn开关打开之后,targetValue的值也就是2,所以我们可以判断当pointRadius变成2的时候,将闪光动画关闭,火苗动画打开,我们将这个判断加到绘制圆圈的后面


if(pointOn){
repeat(endXAnimList.size) {
drawCircle(
colorList[colorIndex], pointRadius,
Offset(endXAnimList[it], endYAnimList[it])
)
}
if (pointRadius.toInt() == 2) {
pointOn = false
turnOn = true
}
}

现在动画已经都衔接起来了,我们看下效果吧


afire8.gif


额~~感觉怪怪的,说好的闪光呢,但就动画而言圆圈的确是完成了半径逐渐变小的绘制过程,那么问题出在哪里呢?我们回到代码中再检查一遍,发现了这一处代码


Offset(endXAnimList[it], endYAnimList[it])
复制代码

这个圆点绘制的位置是均匀分布在一个圆周上的,也就是只绘制了八个圆点,但是真实效果里面的圆点有很多个,那我们是不是只要将endXAnimList,endYAnimList这两个数组里面的坐标打乱随机组成一个圆点不就好了,这样一来最多会绘制出64个圆点,再配合动画不就能达到闪光的效果了吗,所以我们先写一个随机函数


private fun randomCor(): Int {
return (Math.random() * 8).toInt()
}

然后将原来绘制圆点的坐标的下标改成随机数


if(pointOn){
repeat(endXAnimList.size) {
drawCircle(
colorList[colorIndex], pointRadius,
Offset(endXAnimList[randomCor()], endYAnimList[randomCor()])
)
}
if (pointRadius.toInt() == 2) {
pointOn = false
turnOn = true
}
}

现在我们再来看看效果如何


afire9.gif


总结


完整的动画效果已经出来了,整个开发过程还是相对来讲比较吃力的,我想这应该是刚开始接触Compose动画这一部分吧,后面再多开发几个动画应该会得心应手一些,但还是有点收获的,比如



  • 循环动画如果中途需要暂停,然后过段时间再打开的话,不能直接对它的initValue跟targetValue设置值,这样是无效的,必须搭配着Animatable动画一起使用才行

  • LaunchedEffect虽说是在Compose里面是提供给协程运行的函数,不看源码的话以为它里面只能做一件事情,其他事情会被堵塞,其实LaunchedEffect已经封装好了,它的block就是一个协程,所以无论在LaunchedEffect做几件事情,它们都只是运行在一个协程里面


也有一些遗憾与不足



  • 动画衔接的地方都是判断一个值有没有到达一个具体值,然后用开关去控制,感觉应该有更好的方式,比如可以配合着delayMillis,让动画延迟一会再开始

  • 烟花本身其实可以用曲线来代替直线,比如贝塞尔,这个是在开发过程中才想到的,我先去试试看,等龙年再画个更好的烟花~~

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

快速排序和归并排序的时间复杂度分析——通俗易懂

快速排序和归并排序的时间复杂度分析——通俗易懂 一、前言   今天面试的时候,被问到归并排序的时间复杂度,这个大家都知道是O(nlogn),但是面试官又继续问,怎么推导出来的。这我就有点懵了,因为之前确实没有去真正理解这个时间复杂度是如何得出的,于是就随便答了...
继续阅读 »

快速排序和归并排序的时间复杂度分析——通俗易懂


一、前言


  今天面试的时候,被问到归并排序的时间复杂度,这个大家都知道是O(nlogn),但是面试官又继续问,怎么推导出来的。这我就有点懵了,因为之前确实没有去真正理解这个时间复杂度是如何得出的,于是就随便答了一波(理解了之后,发现面试的时候答错了......)。


  归并排序和快速排序,是算法中,非常重要的两个知识点,同时也是在面试中被问的非常频繁的内容,我明知如此,却没有彻底理解,真是太不应该了。所以,今天这篇博客就来分析一下这两种排序算法的时间复杂度是如何得出的。我查了许多篇博客,很多都是通过公式进行分析,十分难理解,下面我就结合自己的理解,使用通俗易懂的方式进行描述(为了好理解,可能会有些啰嗦)。


二、正文


2.1 归并排序的时间复杂度分析


  了解归并排序的应该都知道,归并排序的时间复杂度是O(nlogn),且这个时间复杂度是稳定的,不随需要排序的序列不同而产生波动。那这个时间复杂度是如何得来的呢?我们可以这样分析,假设我们需要对一个包含n个数的序列使用归并排序,并且使用的是递归的实现方式,那么过程如下:



  • 递归的第一层,将n个数划分为2个子区间,每个子区间的数字个数为n/2

  • 递归的第二层,将n个数划分为4个子区间,每个子区间的数字个数为n/4

  • 递归的第三层,将n个数划分为8个子区间,每个子区间的数字个数为n/8;


  ......



  • 递归的第logn层,将n个数划分为n个子区间,每个子区间的数字个数为1


  我们知道,归并排序的过程中,需要对当前区间进行对半划分,直到区间的长度为1。也就是说,每一层的子区间,长度都是上一层的1/2这也就意味着,当划分到第logn层的时候,子区间的长度就是1了。而归并排序的merge操作,则是从最底层开始(子区间为1的层),对相邻的两个子区间进行合并,过程如下:



  • 在第logn层(最底层),每个子区间的长度为1,共n个子区间,每相邻两个子区间进行合并,总共合并n/2次。n个数字都会被遍历一次,所有这一层的总时间复杂度为O(n)


  ......



  • 在第二层,每个子区间长度为n/4,总共有4个子区间,每相邻两个子区间进行合并,总共合并2次。n个数字都会被遍历一次,所以这一层的总时间复杂度为O(n)

  • 在第一层,每个子区间长度为n/2,总共有2个子区间,只需要合并一次。n个数字都会被遍历一次,所以这一层的总时间复杂度为O(n)


  通过上面的过程我们可以发现,对于每一层来说,在合并所有子区间的过程中,n个元素都会被操作一次,所以每一层的时间复杂度都是O(n)。而之前我们说过,归并排序划分子区间,将子区间划分为只剩1个元素,需要划分logn次。每一层的时间复杂度为O(n),共有logn层,所以归并排序的时间复杂度就是O(nlogn)


  上面的描述算是非常详细了,应该不会太难理解。如果上面的过程还是不太理解,那么我们通过另外一种更直观的方式进行分析。上面描述的是递归的过程,下面我们通过非递归(迭代)方式实现的归并排序,再来分析一波,这种方式更加直观(为什么不直接通过非递归的方式描述,而是先通过递归的方式分析,是因为上面的过程也可以用来分析快速排序)。下面是通过非递归方式实现的归并排序代码,其中有两处分析时间复杂度的关键点,我标注出来了(重点关注注释):


**


/**
* 此方法用来定义子区间大小,子区间大小从1->2->4->8 ... ->n/2
* 可以近似地认为进行了logn次
*/
public static void merge(int[] arr) {
// 关键点1:划分子区间,每一次的子区间长度是上一次的两倍,所以这个循环需要执行logn次
for(int i = 1;i<arr.length;i *= 2){
// 关键点2:此方法每次执行的时间复杂度为O(n),具体看下方
mergeSort(arr,i);
}
}


/**
* 以下方法,每次执行的时间复杂度都是O(n),
* 因为需要将arr数组的每gap个数子,作为一个子区间,
* 然后对相邻的两个子区间执行归并排序的merge操作,
* 所以在这个方法中,arr数组中的每一个数都会在merge操作中,
* 被处理一次,所以下面这个方法的时间复杂度为O(n)
*/
public static void mergeSort(int[] arr, int gap) {
int[] tmp = new int[arr.length];
int index = 0;
int start1 = 0;
int end1 = start1 + gap - 1;
int start2 = end1 + 1;
int end2 = (start2 + gap - 1)>=arr.length?arr.length-1:start2+gap-1;
while(start2<arr.length){
while(start1<=end1&&start2<=end2){
if(arr[start1]<arr[start2]){
tmp[index++] = arr[start1++];
}else{
tmp[index++] = arr[start2++];
}
}
while(start1<=end1){
tmp[index++] = arr[start1++];
}
while(start2<=end2){
tmp[index++] = arr[start2++];
}
start1 = end2+1;
end1 = start1 + gap - 1;
start2 = end1 + 1;
end2 = (start2 + gap - 1)>=arr.length?arr.length-1:start2+gap-1;
}
while(start1<arr.length){
tmp[index++] = arr[start1++];
}
for(int j = 0;j<tmp.length;j++){
arr[j] = tmp[j];
}

}

  上面的代码,merge方法中的循环需要循环logn次,每次循环都调用一次mergeSort方法,mergeSort方法的时间复杂度为O(n),所以很容易得出归并排序的时间复杂度为O(nlogn)


2.2 快速排序的时间复杂度


  了解快速排序的应该知道,快速排序的时间复杂度在O(nlogn)~ O(n^2)之间,下面我就来分别分析这两种情况:


(一)快速排序的最好情况O(nlogn)


  这种情况下,其实和上面通过递归分析的归并排序很类似,理解了归并排序的时间复杂度分析,那这里应该也很好理解。快速排序的实现方式,就是在当前区间中选择一个轴,区间中所有比轴小的数都需要放到轴的左边,而比轴大的数则放到轴的右边。在理想的情况下,我们选取的轴刚好就是这个区间的中位数。也就是说,在操作之后,正好将区间分成了数字个数相等的左右两个子区间。此时就和归并排序基本一致了:



  • 递归的第一层,n个数被划分为2个子区间,每个子区间的数字个数为n/2

  • 递归的第二层,n个数被划分为4个子区间,每个子区间的数字个数为n/4

  • 递归的第三层,n个数被划分为8个子区间,每个子区间的数字个数为n/8;


  ......



  • 递归的第logn层,n个数被划分为n个子区间,每个子区间的数字个数为1


  以上过程与归并排序基本一致,而区别就是,归并排序是从最后一层开始进行merge操作,自底向上;而快速排序则是从第一层开始,交换区间中数字的位置,也就是自顶向下。但是,merge操作和快速排序的调换位置操作,时间复杂度是一样的,对于每一个区间,处理的时候,都需要遍历一次区间中的每一个元素。这也就意味着,快速排序和归并排序一样,每一层的总时间复杂度都是O(n),因为需要对每一个元素遍历一次。而且在最好的情况下,同样也是有logn层,所以快速排序最好的时间复杂度为O(nlogn)


(二)快速排序的最坏情况O(n^2)


  下面我们再来说一说快速排序的最坏情况,这种情况就比较好理解了。什么是快速排序的最坏情况,那就是,对于每一个区间,我们在处理的时候,选取的轴刚好就是这个区间的最大值或者最小值。比如我们需要对n个数排序,而每一次进行处理的时候,选取的轴刚好都是区间的最小值。于是第一次操作,在经过调换元素顺序的操作后,最小值被放在了第一个位置,剩余n-1个数占据了2到n个位置;第二次操作,处理剩下的n-1个元素,又将这个子区间的最小值放在了当前区间的第1个位置,以此类推......每次操作,都只能将最小值放到第一个位置,而剩下的元素,则没有任何变化。所以对于n个数来说,需要操作n次,才能为n个数排好序。而每一次操作都需要遍历一次剩下的所有元素,这个操作的时间复杂度是O(n),所以总时间复杂度为O(n^2)


  其实上面的过程,我们可以换一个角度理解:每次操作,找出最小值放到剩余区间的第一个位置,这不就是选择排序的实现方式吗?而选择排序的时间复杂度就是O(n^2),所以上面的过程也就O(n^2)


三、总结


  以上内容,就是我基于自己的理解,对快速排序和归并排序时间复杂度的分析。为了更好理解,我的描述都尽可能的详细,所以可能会有点啰嗦,但是我认为还是很通俗易懂的。希望这篇博客能够为之前对这两种排序算法理解不是特别清晰的人提供帮助,同时,若上面的内容存在错误或不足,欢迎指正或补充。


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

Android SplashScreen API使用

在Android 12 出现了一个SplashScreen新功能,它为所有应用添加了新的应用启动动画,可以通过SplashScreen API来定制专属应用启动动画。 默认情况下,新的应用启动动画为白色背景,中心为应用图标。 接下去将一一介绍如何使用Spla...
继续阅读 »

在Android 12 出现了一个SplashScreen新功能,它为所有应用添加了新的应用启动动画,可以通过SplashScreen API来定制专属应用启动动画。


默认情况下,新的应用启动动画为白色背景,中心为应用图标。


默认应用启动动画


接下去将一一介绍如何使用SplashScreen API来定制专属应用启动动画。


由于这是Android 12新增功能,所以所有相关API都要求api 31才能使用,因此需要额外创建一个values-v31,并将themes.xml拷贝一份放入其中。


values-v31


背景颜色


默认情况下,应用启动动画背景为白色。


在应用所使用的主题中设置以下代码,可以定制应用启动动画的背景颜色。


<item name="android:windowSplashScreenBackground">@color/splash_screen_background</item>

<color name="splash_screen_background">#7B5AB6</color>

设置背景颜色
需要注意一点,目前使用android:windowSplashScreenBackground设置的颜色不能带透明度,必须为6位或者是8位且透明度为FF,如果使用了带透明度的颜色将不生效。


启动图标


默认情况下,应用启动动画的中心为应用图标。


在应用所使用的主题中设置以下代码,可以定制应用启动动画的中心图标。


<item name="android:windowSplashScreenAnimatedIcon">@drawable/cat</item>

启动图标


这是原始图片:


原始图片



  • 可以发现启动图标需要保留一定的内边距,因为会被部分裁剪。

  • 除了设置静态图片,也可以设置动画形式,配置使用android:windowSplashScreenAnimationDuration设置动画时长。

  • 如果设置的图标是透明背景的,可以另外设置android:windowSplashScreenIconBackgroundColor来定制中心图标的背景颜色。


底部图片(Google不推荐使用)


使用android:windowSplashScreenBrandingImage可以设置底部图片,图片尺寸比例需要为2.5:1。


延缓启动时间


使用android:windowSplashScreenAnimationDuration可以设置启动动画时长,但是最长只能设置1000毫秒。


很多时候需要在启动的时候拉取一些应用配置,需要有更长时间的启动效果。


可以在代码中实现,通过ViewTreeObserver.OnPreDrawListener:


class MainActivity : AppCompatActivity() {
private var isAppReady = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val content: View = findViewById(android.R.id.content)
content.viewTreeObserver.addOnPreDrawListener(object : ViewTreeObserver.OnPreDrawListener {
override fun onPreDraw(): Boolean {
if (isAppReady) {
content.viewTreeObserver.removeOnPreDrawListener(this)
}
return isAppReady
}
})
delayBootTime()
}

private fun delayBootTime() {
lifecycleScope.launch {
delay(3000)
isAppReady = true
}
}
}

当应用配置已准备好,onPreDraw返回true,并且移除监听。这里使用delay3秒来模拟拉取应用配置的耗时操作。


需要注意,一定要在准备好后onPreDraw返回true,否则会一直卡在启动页上。


启动退出动画


Android 12 SplashScreen新功能提供了setOnExitAnimationListener方法可以定制启动退出时的动画效果,该API只能在版本12及以上使用:


        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
splashScreen.setOnExitAnimationListener { splashScreenView ->
val slideUp = ObjectAnimator.ofFloat(
splashScreenView,
View.TRANSLATION_Y,
0f,
-splashScreenView.height.toFloat()
)
slideUp.duration = 2000
// 在自定义动画结束时调用splashScreenView.remove()
slideUp.addListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator?) {
splashScreenView.remove()
}
})
slideUp.start()
}
}

低版本兼容


在Android 12以下版本没有SplashScreen启动动画,显示的空白背景页面,这在用户体验上很不好。因此,Google在AndroidX中提供了一个向下兼容的SplashScreen库。


配置


implementation 'androidx.core:core-splashscreen:1.0.0'
复制代码

设置主题


定义一个新的主题并给应用使用:


    <style name="SplashScreenTheme" parent="Theme.SplashScreen">
<item name="windowSplashScreenBackground">@color/splash_screen_background</item>
<item name="windowSplashScreenAnimatedIcon">@drawable/cat</item>
<item name="postSplashScreenTheme">@style/Theme.SplashScreenDemo</item>
</style>

需要注意几点:



  • 必须以R.style.Theme_SplashScreen 为父级

  • 启动图标动画形式失效

  • windowSplashScreenBackground、windowSplashScreenAnimatedIcon前面都没有 android:

  • postSplashScreenTheme指定应用原来的主题,这样,当SplashScreen结束时,应用主题能够被恢复


在启动Activity中设置


一定要在setContentView方法之前调用installSplashScreen方法


        super.onCreate(savedInstanceState)
installSplashScreen()
setContentView(R.layout.activity_main)

至此,在低版本上也能有同样效果的SplashScreen动画了,当然一些启动退出动画这些Android 12特有的API仍然是无法使用的。


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

学之前“flow?狗都不学”学之后“狗不学正好我学”

标题皮一下,个人项目引入Kotlin Flow一段时间了,这篇文章想写写个人理解到的一点皮毛,有错欢迎在评论区指出。 Flow基础知识 Flow可理解为数据流,使用起来比较简单,看几个demo就可以直接上手了,除了提几个点之外也不再赘述。 Flow为冷流。在...
继续阅读 »

标题皮一下,个人项目引入Kotlin Flow一段时间了,这篇文章想写写个人理解到的一点皮毛,有错欢迎在评论区指出。


Flow基础知识


Flow可理解为数据流,使用起来比较简单,看几个demo就可以直接上手了,除了提几个点之外也不再赘述。



  • Flow为冷流。在Flow知识体系中,生产(获取)数据的可称为生产者(producer),消费(使用)数据的可称为消费者(consumer),冷流即有消费者消费数据,生产者才会生产数据。

  • Flow中生产者与消费者为一对一的关系,即消费者不share(共享)同一个Flow,新加一个消费者,就会新创建一个Flow。


上面两个点可以通过个简单的demo进行验证。


val timerFlow = flow {
   val start = 0
   var current = start
   while (true) {
       emit(current)
       current++
       delay(1000)
  }
}

class MainActivity : AppCompatActivity() {
   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       var firstTimer by mutableStateOf(0)
       var secondTimer by mutableStateOf(0)
       var thirdTimer by mutableStateOf(0)
       val fontSize: TextUnit = 30.sp
       lifecycleScope.launch {
           while (true) {
               delay(1000)
               firstTimer++
          }
      }
       setContent {
           var secondTimerIsVisible by remember {
               mutableStateOf(false)
          }
           var thirdTimerIsVisible by remember {
               mutableStateOf(false)
          }
           Column(
               modifier = Modifier.fillMaxSize(),
               horizontalAlignment = Alignment.CenterHorizontally,
               verticalArrangement = Arrangement.Center
          ) {
               Text(
                   text = "屏幕启动时间为${firstTimer}秒",
                   textAlign = TextAlign.Center, fontSize = fontSize
              )
               if (secondTimerIsVisible) {
                   Text(
                       "第一个自定义计时器的时间为${secondTimer}秒。",
                       textAlign = TextAlign.Center,
                       fontSize = fontSize
                  )
              } else {
                   Button(
                       onClick = {
                           lifecycleScope.launch {
                               repeatOnLifecycle(Lifecycle.State.STARTED) {
                                   timerFlow.collect {
                                       secondTimer = it
                                  }
                              }
                          }
                           secondTimerIsVisible = true

                      },
                  ) {
                       Text(
                           text = "启动第一个自定义计时器",
                           textAlign = TextAlign.Center,
                           fontSize = fontSize
                      )
                  }
              }
               if (thirdTimerIsVisible) {
                   Text(
                       "第二个自定义计时器的时间为${thirdTimer}秒。",
                       textAlign = TextAlign.Center,
                       fontSize = fontSize
                  )
              } else {
                   Button(
                       modifier = Modifier.padding(10.dp),
                       onClick = {
                           lifecycleScope.launch {
                               repeatOnLifecycle(Lifecycle.State.STARTED) {
                                   timerFlow.collect {
                                       thirdTimer = it
                                  }
                              }
                          }
                           thirdTimerIsVisible = true

                      },
                  ) {
                       Text(
                           text = "启动第二个自定义计时器",
                           textAlign = TextAlign.Center,
                           fontSize = fontSize
                      )
                  }
              }
          }
      }
  }
}

运行一下。





在上面的demo中,创建了三个计时器,第一个计时器用协程来实现,来计时屏幕的启动时间,第二,第三个计时器用flow来实现,为自定义计时器,需要手动启动。



  • 在屏幕启动几秒后,才启动第二个计时器,该计时器是从0秒开始启动的,这说明flow并不是屏幕一启动就产生数据,而是有消费者消费数据,才会产生数据。

  • 第二个计时器和第三个计时器的时间不一样,说明它们尽管用了同一个timerFlow变量,却不是共享同一个flow,新加一个消费者,就会新创建一个Flow。


SharedFlow


稍微了解设计模式的读者应该知道,Flow其实是用了观察者模式,生产者对应subject(被观察者),消费者对应observer(观察者),只是flow中每个subject只允许有一个observer,但在实际项目中,一个subject有多个observer的情况再正常不过,于是乎就有了SharedFlow。


SharedFlow是共享流,它的特性与flow刚好反着来。



  • SharedFlow是热流,即使没有消费者也会一直产生数据,该产生数据的策略是可变的,后面会详细讲。

  • 多个消费者会共享同一个Flow。


对上面代码进行修改,将Flow转换为SharedFlow,并将其移动到新建的MainViewModel中。


class MainViewModel : ViewModel() {

   val timerFlow = flow {
       val start = 0
       var current = start
       while (true) {
           emit(current)
           current++
           delay(1000)
      }
  }.shareIn(viewModelScope, SharingStarted.Eagerly,0)

}

修改MainActivity的代码,添加viewModel的实例化代码private val viewModel: MainViewModel = MainViewModel() ,并timerFlow.collect改成viewModel.timerFlow.collect,改动较少,就不放出全部源码了,需要注意的是,将MainViewModel直接实例化的做法是错误的,理由是当Activity由于某种原因,如屏幕旋转而销毁时,MainViewModel会重新实例化,这样就达不到ViewModel数据持久化的目的了,本文是为了方便演示SharedFlow是热流的特性才直接实例化。


运行一下。





效果图有两个点是比较关键的。



  • 自定义计时器的时间与屏幕启动时间是一样的,说明SharedFlow不管有没有消费者,都会产生数据。

  • 两个自定义计时器的时间是一样的,说明两个计时器共享了同一个SharedFlow。


先看看shareIn()方法的源码。


public fun <T> Flow<T>.shareIn(
   scope: CoroutineScope,
   started: SharingStarted,
   replay: Int = 0
): SharedFlow<T>



  • scope参数为指定SharedFlow在哪个协程域启动。




  • replay参数指定当有新的消费者出现时,发送多少个之前的数据给该消费者。




  • started为启动策略。


    有三个启动策略可选。




    • SharingStarted.Eagerly 。SharedFlow会立即产生数据,即使连第一个消费者还没出现,demo中使用的就是该启动策略。




    • SharingStarted.Lazily。SharedFlow只有在第一个消费者消费数据后才产生数据。




    • WhileSubscribed。WhileSubscribed的源码如下所示。


      public fun SharingStarted.Companion.WhileSubscribed(
         stopTimeout: Duration = Duration.ZERO,
         replayExpiration: Duration = Duration.INFINITE
      )


      • stopTimeOut。当SharedFlow一个消费者也没有的时候,等待多久才停止流。

      • replayExpiration。用来指定replay个数量的缓存在等待多少时间后无效,当你不想用户看到较旧的数据时,可使用这个参数。






此外,SharedFlow也可以直接创建。


class MainViewModel : ViewModel() {

   val timerFlow = MutableSharedFlow<Int>()

   init {
       viewModelScope.launch {
           val start = 0
           var current = start
           while (true) {
               timerFlow.emit(current)
               current++
               delay(1000)
          }
      }
  }
}

StateFlow


StateFlow是SharedFlow的一个特殊变种,其特性有:



  • 始终有值且值唯一。

  • 可以有多个消费者。

  • 永远只把最新的值给到消费者。


第二,第三特性比较好理解,就是replay参数为1的SharedFlow,那第一个特性需要结合demo才更好理解。


先将flow转化为StateFlow。


class MainViewModel : ViewModel() {

   val timerFlow = flow {
       val start = 0
       var current = start
       while (true) {
           emit(current)
           current++
           delay(1000)
      }
  }.stateIn(viewModelScope, SharingStarted.Eagerly,0)

}

sharedIn()的源码如下所示。


public fun <T> Flow<T>.stateIn(
   scope: CoroutineScope,
   started: SharingStarted,
   initialValue: T//初始值
): StateFlow<T>{

}

运行一下。






与SharedFlow比较,最大的不同就是SharedFlow demo中的自定义计时器是从0开始的,之后才和屏幕启动时间一致,而这个StateFlow demo中的自定义计时器是一启动就和屏幕启动时间一致,出现这种情况的原因是:



  • SharedFlow并不存储值,MainActivity只有在 SharedFlow emit()出最新值的时候,才能collect()到值。

  • 根据StateFlow的第一点特性,其始终有值且值唯一,在MainActivity一订阅StateFlow的时候,就立马就将最新的值给到了MainActivity,所以StateFlow demo中的计时器没有经历0的阶段。


可以看到,StateFlow与之前的LiveData比较相似的。


StateFlow还有另一种在实际项目中更常用的使用方式,修改MainViewModel的代码。


class MainViewModel : ViewModel() {

   private val _timerFlow: MutableStateFlow<Int> = MutableStateFlow(0)
   val timerFlow: StateFlow<Int> = _timerFlow.asStateFlow()

   init {
       viewModelScope.launch {
           val start = 0
           var current = start
           while (true) {
               _timerFlow.value = current
               current++
               delay(1000)
          }
      }
  }

}

代码中先创建私有MutableStateFlow实例_timerFlow,再将其转化为公共StateFlow实例timerFlow,因为timerFlow只可读,不能修改,暴露给Main Activity使用更符合规范。


collect Flow的规范做法


官方推荐我们用lifeCycle.repeatOnLifecycle()去collect flow。


lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.timerFlow.collect {
...
}
}
}

Activity会在onStart()开始收集数据,在onStop()结束数据的收集。


如下图所示,如果直接使用lifecycleScope.launch去collect flow,那么在应用进入后台后,也会持续进行数据的收集,这样将造成资源的浪费。






要是嫌上述代码繁琐,也可以添加以下依赖。


implementation "androidx.lifecycle:lifecycle-runtime-compose:2.6.0-alpha01"

然后将collect代码改成下述代码也能达到同样的效果,不过该方法只适用于StateFlow。


viewModel.timerFlow.collectAsStateWithLifecycle()

该方法的源码如下所示。


fun <T> StateFlow<T>.collectAsStateWithLifecycle(
   lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
   minActiveState: Lifecycle.State = Lifecycle.State.STARTED,
   context: CoroutineContext = EmptyCoroutineContext
): State<T>

从第二个参数可以知道默认是从onStart()开始收集数据。


项目真的需要引入Flow吗?


谷歌对Flow的推崇力度很大,Android官网中除了Flow相关的文章之外,很多代码示例也多多少少用了Flow,大有一种Flow放之四海而皆准的态势,但使用一段时间后,我发现Flow的应用场景其实也是有一定局限的。


以我个人项目中的之前Repository类中某段代码为例。


override suspend fun getCategory(): Flow<List<Category>?> {
       return flow {
           when (val response = freeApi.getCategoryList()) {
               is ApiSuccess -> {
                   val categories = response.data
                   withContext(Dispatchers.IO) {
                       Timber.v("cache categories in db")
                       categoryDao.insertCategoryList(categories)
                  }
                   emit(categories)//1
              }
               else -> {
                   Timber.d(response.toString())
                   val cacheCategories = withContext(Dispatchers.IO) {
                       categoryDao.getCategoryList()
                  }
                   if (cacheCategories.isNotEmpty()) {
                       Timber.d("load categories from db")
                       emit(cacheCategories)//2
                  } else {
                       Timber.d("fail to load category from db")
                       emit(null)//3
                  }
              }
          }
      }
  }

其实上面代码并不适合用Flow,因为尽管代码1,2,3处都有emit,但最终getCategory()只会emit一次值,Flow是数据流,但一个数据并不能流(Flow)起来,这样无法体现出Flow的好处,徒增资源的消耗。


除此之外,在一个屏幕需要获取从多个api获取数据的时候,如果强行用Flow就会出现繁琐重复的代码,像下面的代码会有好几处。


getXXX().catch{
//进行异常处理
}.collect{
//得到数据
}

我也去查阅了相关的资料,发现确实如此,具体可见参考资料1和2。


参考资料


本文主要参考了资料4,与资料4在排版,内容有较多相似地方。



  1. Kotlin Flow: Best Practices

  2. one-shot operation with Flow in Android

  3. Complete guide to LiveData and Flow: Answering — Why, Where, When, and Which.

  4. 官方推荐 Flow 取代 LiveData,有必要吗?

  5. Recommended Ways To Create ViewModel or AndroidViewModel

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

阿里iconfont审核很慢?自己搭建一个,直接从figma上传

web
iconfont我们前端都认为挺好用的,但设计师经常说:“这玩意真不好,上传个图标审核半天,审核通过了还不会自动上传😡”不应该呀,我一看,原来是阿里iconfont管理平台的问题;那简单,我不用它不就行了😎原来的工作流程“宁花机器10分钟,不花人工1分钟”,在...
继续阅读 »


iconfont我们前端都认为挺好用的,但设计师经常说:“这玩意真不好,上传个图标审核半天,审核通过了还不会自动上传😡”

不应该呀,我一看,原来是阿里iconfont管理平台的问题;那简单,我不用它不就行了😎

原来的工作流程


“宁花机器10分钟,不花人工1分钟”,在旧流程中,我们可以看到,人工操作的环节足足有6个(听说阿里icon审核也是人工的)。很显然,这是相当低效的流程。看来,除了阿里第三方的审核问题,我们内部原有的图标交付流程也出现了问题😀

怎么解决

先看看有没有可以直接用的方案

  1. 转转的方案 和我们想到一块去了,不使用阿里的iconfont管理平台,而是魔改了YIcon;可惜转转暂时没有开源他们方案的想法,但也给予我们一些思路,我们能不能也学着魔改。

  2. 除了YIcon,还有一个开源的iconfont管理平台Nicon。他们的优点都是具有完善的管理&鉴权机制,但缺点是代码过于老旧,长时间没人维护。这也意味着要花费较大的人力要魔改,这对于我们是不能接受的。

  3. 很幸运的是,figma社区有较为成熟的Figma图标交付方案figma-icon-automation,看到了Shoppee得物等公司都参考figma-icon-automation来实现了自己一套的图标交付流程,看起来figma插件是目前最优的选择。可是,我们还是希望保留iconfont的使用方式,不然的话改用SVG组件,这个改变成本也是无法接受的。

    因此,我们决定修改figma-icon-automation的流程,实现适合我们的iconfont交付方案🚀

新的iconfont交付方案

魔改1: github 改为 gitlab

  1. 出于保护设计师的资产

我们存储icons到内网部署的gitlab上,保护了设计资产的同时,也自然不会有第三方来审核图标。

  1. 出于iconont权限管理

gitlab可对不同项目分配不同权限,我们不再需要一个iconfont平台来管理权限;同时解决了,可能会没有及时回收离职员工的阿里iconfont平台权限所带来的风险。

  1. 出于iconfont项目管理

这个与普通的gitlab项目没什么区别,你可以创建多个iconfont项目对应不同前端项目,每个项目都是独立的。

  1. 出于iconfont版本管理

得益于git的强大,我们可以还拥有了版本管理,这是阿里iconfont平台没有给我们带来的;我们可以清楚地追溯到是谁修改/删除了图标,或者及时地回滚iconfont版本。

魔改2: 更加好用的figma插件

基于gitlab官方Figma插件,我们对其进行改造(主要因为可以节省查阅Figma API文档的时间),实现了一款更加适合我们的设计师使用的Figma插件————UpCon,主要功能如下:

  1. 支持配置自己公司gitlab域名

使用自定义gitlab域名作为请求的BaseUrl来调用gitlab开放的api,默认为v4版api,支持最新版gitlab。

  1. 支持配置project-id,支持存储多个id

通过project-id来管理不同项目,并且通过本地storage存储多个project-id,方便用户快速切换项目

  1. 支持配置gitlab access token

通过access token来登录gitlab,同时识别该用户是否具有该project对应的开发权限(无权限用户无法跳转到上传页)

  1. 支持自定义选择多个Icons,并实时预览

通过在figma中选择要上传的icon(支持frame与component类型),填写本次提交的信息,即可触发上传。

  1. 支持去除颜色上传

我们保留了阿里iconfont平台的去除颜色上传功能,其原理是通过正则修改SVG代码中的color属性为currentcolor
选择去除颜色后,当前选择的所有icon都会去除颜色,并可实时预览去除颜色后的效果。

  1. 校验icon命名

我们会对已上传的icons名称与当前选择icons名称进行对比,重名的icon,会给予橙色边框与tooltip提示。如果你执意要上传,则会覆盖原先的图标。
与之同时,我们对icon命名进行了强制规范,名称中如含有/\:*?"<>|.字符,会给予红色边框提示,并不允许上传


魔改3: 触发Gitlab CI脚本

在figma插件触发上传后,会生成一次commit记录并同时触发Gitlab CI操作。可以通过clone 我们开源的iconfont-build-tools来实现自定义Gitlab CI操作,iconfont-build-tools的主要功能如下:

  1. 处理转换SVG代码为iconfont

我们会读取当前项目下的icons/路径下的所有svg文件(此路径暂不支持修改),将svg代码转换为iconfont.js代码,详细实现代码可查看iconfont-build-tools

  1. 转换svg名称为拼音

我们保留了阿里iconfont平台中,把中文名称自动转为拼音的功能,这一功能大大降低了设计师们的icon命名带来的困扰。

  1. 自动生成tag信息并发布新版本

我们还自动把本次git commit的Hash值作为版本tag,并自动发布新版本,这是实现版本管理关键的一步。

  1. 自动上传iconfont到CDN

生成的iconfont.js文件可以通过自定义配置来自动上传到自己的CDN,返回的url会自动携带在release信息中,具体的数据格式可查看iconfont-build-tools


方案开源&计划

目前,我们的方案已经开源了,欢迎大家积极尝试并提出宝贵的建议👍

未来,我们计划给gitlab ci流程中接入微信机器人通知,大家可以持续关注或者star我们的项目😊

Figma UpCon iconfont-build-tools

总结

相比于旧的图标交付流程,新的流程直接把步骤缩减到两步,这大大地提高了我们的效率。而且我们保留许多旧流程的习惯,如依旧使用iconfont方案,upcon中去除颜色功能,build-tools的中文转拼音功能,这些功能的迁移让我们几乎不用花费额外的成本去使用新的流程。

同时我们也希望有更多的用户给予我们正向的反馈,完善此流程,让图标交付变得更简单。


参考链接

figma plugin juejin.cn/post/706816…

得物 IconBot juejin.cn/post/704398…

Shopee IconBot juejin.cn/post/690372…

svgicons2svgfont juejin.cn/post/713711…

iconfont预览 segmentfault.com/a/119000002…

gitlab figma gitlab.com/gitlab-org/…

作者:BlackGoldRoad
来源:juejin.cn/post/7184324458063069245

收起阅读 »

舍弃传统文档阅读吧!~新一代代码文档神器code-hike

web
最终效果如图。起因相信不少小伙伴阅读过一篇文章:build-your-own-react这是一篇通俗易懂的从头开始讲述如何创建一个react过程的文章,其中最吸引我的就是这个文章的代码排版方式。将所有代码放置在左侧,随着文档的滚动,左侧代码不断发生变化,不断提...
继续阅读 »


最终效果如图。

起因

相信不少小伙伴阅读过一篇文章:build-your-own-react

这是一篇通俗易懂的从头开始讲述如何创建一个react过程的文章,其中最吸引我的就是这个文章的代码排版方式。将所有代码放置在左侧,随着文档的滚动,左侧代码不断发生变化,不断提示我哪些代码发生了变动。这样的文档方式,是我之前没体验过的船新版本。去作者的gayhub看到正好有开源工具,于是自己搭建了demo,马上惊为天人。所以在这里我要做一个违背祖宗的决定,将其分享给大家。

code-hike简介

codehike.org/ code-hike是一个 mdx的插件,专注于文档写作的组件库。专注于代码的展示,具有以下几个功能:

代码块的展示


支持134种不同的编程语言,基本涵盖了目前市面上的编程语言。

批注和对代码的交互体验


可以看到在code-hike中可以对部分代码进行高亮显示,这部分主要通过force和mark来操作。同时它还允许你在代码块中进行链接,可以点击跳转到页面的其他位置。也可以自定义自己的样式显示。

一系列的code组件

一系列帮你优化code展示的组件,在本文中,将主要使用CH-scrollycoding作为展示。

安装

我们这里以Docusaurus为例作为展示,当然你也可以使用React,vite或其他任意模版或者docs框架作为开始。

我们首先安装docusaurus

npx create-docusaurus@latest my-website classic

然后安装hike的相关依赖

cd my-website  

npm i @mdx-js/react@2 docusaurus-theme-mdx-v2 @code-hike/mdx

配置

首先配置docusaurus.config.js,插入mdx-v2主题

// docusaurus.config.js

const config = {
   ...
   themes: ["mdx-v2"],
   ...
}

然后插入code-hike插件

// docusaurus.config.js
const theme = require("shiki/themes/nord.json")
const {
 remarkCodeHike,
} = require("@code-hike/mdx")

const config = {
   presets: [
    [
       "classic",
      {
         docs: {
           beforeDefaultRemarkPlugins: [
            [remarkCodeHike, { theme }],
          ],
           sidebarPath: require.resolve("./sidebars.js"),
        },
      },
    ],
  ],
   ...
}

再设置下style

// docusaurus.config.js
...
const config={

   theme: {
       customCss: [
         require.resolve("@code-hike/mdx/styles.css"),
      ],
    },
  }
}

至此所有配置完成,我的完整配置如下:

// @ts-check
// Note: type annotations allow type checking and IDEs autocompletion

const lightCodeTheme = require("prism-react-renderer/themes/github");
const darkCodeTheme = require("prism-react-renderer/themes/dracula");

const theme = require("shiki/themes/nord.json");
const { remarkCodeHike } = require("@code-hike/mdx");

/** @type {import('@docusaurus/types').Config} */
const config = {
 title: "css and js",
 tagline: "read everyday",
 url: "https://your-docusaurus-test-site.com",
 baseUrl: "/",
 onBrokenLinks: "throw",
 onBrokenMarkdownLinks: "warn",
 favicon: "img/favicon.ico",

 // GitHub pages deployment config.
 // If you aren't using GitHub pages, you don't need these.
 organizationName: "facebook", // Usually your GitHub org/user name.
 projectName: "docusaurus", // Usually your repo name.

 // Even if you don't use internalization, you can use this field to set useful
 // metadata like html lang. For example, if your site is Chinese, you may want
 // to replace "en" with "zh-Hans".
 i18n: {
   defaultLocale: "en",
   locales: ["en"],
},

 presets: [
  [
     "classic",
     /** @type {import('@docusaurus/preset-classic').Options} */
    ({
       
       docs: {
         beforeDefaultRemarkPlugins: [[remarkCodeHike, { theme }]],
         sidebarPath: require.resolve("./sidebars.js"),
         // Please change this to your repo.
         // Remove this to remove the "edit this page" links.
         editUrl:
           "https://github.com/facebook/docusaurus/tree/main/packages/create-docusaurus/templates/shared/",
      },
       blog: {
         showReadingTime: true,
         // Please change this to your repo.
         // Remove this to remove the "edit this page" links.
         editUrl:
           "https://github.com/facebook/docusaurus/tree/main/packages/create-docusaurus/templates/shared/",
      },
       theme: {
         customCss: [
           require.resolve("@code-hike/mdx/styles.css"),
           require.resolve("./src/css/custom.css"),
        ],
      },
    }),
  ],
],
 themes: ["mdx-v2"],

 themeConfig:
   /** @type {import('@docusaurus/preset-classic').ThemeConfig} */
  ({
     navbar: {
       title: "My Site",
       logo: {
         alt: "My Site Logo",
         src: "img/logo.svg",
      },
       items: [
        {
           type: "doc",
           docId: "intro",
           position: "left",
           label: "阅读",
        },
         
        { to: "/blog", label: "Blog", position: "left" },
        {
           href: "https://github.com/facebook/docusaurus",
           label: "GitHub",
           position: "right",
        },
      ],
    },
     footer: {
       style: "dark",
       links: [
        {
           title: "Docs",
           items: [
            {
               label: "Tutorial",
               to: "/docs/intro",
            },
             
          ],
        },
        {
           title: "Community",
           items: [
            {
               label: "Stack Overflow",
               href: "https://stackoverflow.com/questions/tagged/docusaurus",
            },
            {
               label: "Discord",
               href: "https://discordapp.com/invite/docusaurus",
            },
            {
               label: "Twitter",
               href: "https://twitter.com/docusaurus",
            },
          ],
        },
        {
           title: "More",
           items: [
            {
               label: "Blog",
               to: "/blog",
            },
            {
               label: "GitHub",
               href: "https://github.com/facebook/docusaurus",
            },
          ],
        },
      ],
       copyright: `Copyright © ${new Date().getFullYear()} My Project, Inc. Built with Docusaurus.`,
    },
     prism: {
       theme: lightCodeTheme,
       darkTheme: darkCodeTheme,
    },
  }),
};

module.exports = config;

特别注意

因为code hike使用的是mdx2,在此版本有一个破坏性更新,如果遇到以下问题


请找到<!--truncate-->,将其删除

开始自己的demo

将docs下的一个md文件,改为如下的数据


为啥不在掘金贴代码块呢,因为跟掘金的markdown冲突了。。。

你得到了如下的效果



这就是一个简单的如我开头展示的效果。

代码标记

这里主要有两个点需要注意 : 第一个是

---

三条横线,作为每段展示文档的分割,所以你可以看到我们的每一段都有这个标记


第二个是 focus


这个标记表明了你有哪些代码需要高亮,在demo中使用的是行高亮。一共有两种用法:

你可以写在文件开头,例如```js statement.js focus=6:8,这表示将从文件的开头进行计算,第6-8行 你也可以写在文件内,例如

// focus(1,1)
  const result=[]

这表示,从标记位置开始的后面的1-1行,也就是第一行

除了这种按照行进行标记,你也可以标记列,例如

  // focus[7:12]
    result = 40000;

它表示从下一行的第7-12个字符。 效果为


作者:im天元
来源:juejin.cn/post/7175000675523887159

收起阅读 »

一张码如何实现多渠道(微信、支付宝、云闪付...)收款

大家好,我是小悟今天是正月初五,天气超级好,也是迎财神的日子,祝大家顺风顺水,财源滚滚,钱兔似锦。既然要发财,那自然少不了收款咯。如果你是一个商家,肯定是想收款的方式越方便越好,但支付渠道有那么多种,也就意味着顾客的支付选择也是多种。那总不能把所有的渠道收款码...
继续阅读 »


大家好,我是小悟

今天是正月初五,天气超级好,也是迎财神的日子,祝大家顺风顺水,财源滚滚,钱兔似锦。


既然要发财,那自然少不了收款咯。如果你是一个商家,肯定是想收款的方式越方便越好,但支付渠道有那么多种,也就意味着顾客的支付选择也是多种。

那总不能把所有的渠道收款码都贴上吧,那会非常的乱,对顾客来说也极其不方便,一个码能解决的事情,就不要搞复杂化了。那这个是怎么实现的呢?


要实现一码多渠道收款其实也不难,毋庸置疑,现在主流的支付方式就是微信和支付宝,而在微信和支付宝申请的商户相同点是都支持余额、银行卡和信用卡支付,不同点是微信支持云闪付支付,支付宝支持花呗支付。所以只要对接了微信和支付宝,那基本上就够用了。

值得一提的是,随着微信支付生态的发展,现在想实现这样的功能是越来越方便了。借助微信扫普通链接二维码打开小程序的功能,无需判断前端是微信还是支付宝或者其他APP扫码,可以减少很多工作量。


所以重点来了,我们都知道,微信和支付宝根据前端不同而有多种支付方式,比如APP支付,H5支付,小程序支付等。

为了实现更全和更简单的功能,支付宝需要对接H5支付,而微信需要对接的却是小程序支付。说到这里你可能就有疑问了,为啥不都是H5支付或都是小程序支付?


首先对接支付宝H5支付的话,当你使用其他APP比如抖音、快手打开的时候也可以跳转到支付宝完成支付,一劳永逸。再者因为微信小程序支付支持云闪付支付,所以微信对接的是小程序支付。

说到这里不知道你已经想到实现思路了吗?是的,前端需要开发一个简单的页面,但是再简单,起码可以输入金额吧。然后简单做下金额正则校验,因为涉及到H5和小程序,所以可以使用uniapp编写前端页面部署更方便,也就是说支付宝部署的是H5,微信部署的是小程序。

我写的demo是搞了两个,不要学我哦,怎么方便怎么来,左边是支付宝H5扫出来的样子,右边是微信小程序扫出来的样子。


支付做多了服务端其实也不复杂,注意,支付宝对接的是H5支付,微信对接的是小程序支付,简单贴一下代码。需要写回调的话也一定不要忘了哦。

支付宝H5支付

public AjaxResult aliPayH5(PayModel payModel) {
  payModel.setBody("支付宝H5支付").setSubject("支付宝H5支付");
  String outTradeNo = IdUtil.getSnowflake(1,1).nextIdStr();
  payModel.setOutTradeNo(outTradeNo).setPassbackParams(outTradeNo);
  String form = aliPayService.aliPayH5(payModel);
if(StringUtils.isNotBlank(form)) {
      Map<String, Object> result = new HashMap<>(2);
      result.put("form", form);
return AjaxResult.success(result);
  }
return AjaxResult.error("数据准备异常");
}

微信小程序支付

public AjaxResult jsapiMaPayCommon(JsapiOrderParam param, HttpServletRequest request) {
  String openId = param.getOpenId();
  String remoteAddr = IpUtils.getIpAddr(request);
  String outTradeNo = IdUtil.getSnowflake(1,1).nextIdStr();
  BigDecimal decimal100 = new BigDecimal("100");
  BigDecimal orderAmount = new BigDecimal(String.valueOf(param.getAmount()));
  JsapiParam jsapiParam = new JsapiParam();
  jsapiParam.setAppid(wechatProperties.getMaAppId())
          .setMchid(wechatProperties.getMchId())
          .setDescription("微信小程序支付")
          .setOut_trade_no(outTradeNo)
          .setAttach(outTradeNo)
          .setNotify_url(wechatProperties.getNotifyUrlCommon());
  Amount amount = new Amount();
  amount.setTotal(decimal100.multiply(orderAmount).intValue());
  jsapiParam.setAmount(amount);
  Payer payer = new Payer();
  payer.setOpenid(openId);
  jsapiParam.setPayer(payer);
  SceneInfo sceneInfo = new SceneInfo();
  sceneInfo.setDevice_id("POS1:12");
  sceneInfo.setPayer_client_ip(remoteAddr);
  jsapiParam.setScene_info(sceneInfo);
  BaseParam baseParam = new BaseParam();
  baseParam.setAppName(wechatProperties.getAppName())
          .setMchId(wechatProperties.getMchId())
          .setMchSerialNo(wechatProperties.getMchSerialNo())
          .setWechatSerialNo(wechatProperties.getWechatSerialNo())
          .setMchPrivateKeyPath(wechatProperties.getMchPrivateKeyPath())
          .setWechatPubKeyPath(wechatProperties.getWechatPubKeyPath());
  JSONObject result = wechatService.jsapiPay(jsapiParam, baseParam);
  int status = result.getInteger("requestStatus");
if (status == 200) {
      SortedMap<Object, Object> params = new TreeMap<>();
      String timestamp = Long.toString(System.currentTimeMillis() / 1000);
      String nonceStr = UuidUtils.randomUUID();
      String packageParam = "prepay_id=" + result.getString("prepay_id");
      String paySign = SignUtils.paySign(wechatProperties.getMaAppId(), timestamp, nonceStr, packageParam,
              wechatProperties.getMchPrivateKeyPath());
      params.put("appId", wechatProperties.getMaAppId());
      params.put("timeStamp", timestamp);
      params.put("paySign", paySign);
      params.put("signType", "RSA");
      params.put("nonceStr", nonceStr);
      params.put("package", "prepay_id=" + result.getString("prepay_id"));
      logger.info("params:{}",params);
return AjaxResult.success(params);
  } else {
return AjaxResult.error(result.getString("message"), result);
  }
}

部署起来后,支付宝基本就这样了,能支付就行,微信还需要配置一些东西。首先,微信商户号后台,支付方式配置,云闪付需要开启状态。


其次,小程序后台,需要配置扫普通链接二维码打开小程序,将部署的支付宝H5支付链接地址映射到微信小程序的支付页面,测试范围选择线上版,全网发布即可。这样,当使用微信扫描该二维码地址时,就会自动跳转到微信小程序支付页面。


然后使用草料二维码生成器将H5地址塞到二维码里面,就大功告成了,以后使用微信或支付宝,或者其他APP扫码就可以完成支付了。支持微信、云闪付、支付宝、花呗、银行卡、信用卡支付。打完收工。


一码在手,生意你有。

您的一键三连,是我更新的最大动力,谢谢

山水有相逢,来日皆可期,谢谢阅读,我们再会

我手中的金箍棒,上能通天,下能探海

作者:悟空码字
来源:juejin.cn/post/7192983769618317370

收起阅读 »

【附源码】国内首届Discord场景创意编程开源项目

以下开源项目是由环信联合华为举办的《国内首届Discord场景创意编程赛》作品,附源码,一键即用。一、 模拟器游戏直播-新新人类新新人类模拟器游戏直播基于环信超级社区Demo构建,增加以“video-x”命名的新型Channel,用户可在本机操作/控制当前游戏...
继续阅读 »

以下开源项目是由环信联合华为举办的《国内首届Discord场景创意编程赛》作品,附源码,一键即用。


一、 模拟器游戏直播-新新人类

新新人类模拟器游戏直播基于环信超级社区Demo构建,增加以“video-x”命名的新型Channel,用户可在本机操作/控制当前游戏界面,并通过集成声网RTC SDK, 在聊天频道中实现连麦聊天, 一对多直播。其中直播流来自于NES模拟器画面, 用户可以观看房主游玩经典NES的游戏画面. 并进一步与房主联机, 实现2p游戏。


模拟器游戏直播-项目预览

该项目不仅集成了环信超级社区SDK,声网的RTC功能,也同时集成了第三方小游戏。而这正是超级社区,也就是Discord产品的精髓之一。用户不仅可以通过IM聊天,也可以进行语聊,看游戏直播,甚至自己进行游戏直播。这些都是当下Discord这款产品中使用率最高的功能。这个作品不仅让人眼前一亮,也展示出作者对Discord和超级社区场景深入的理解,令人印象深刻。

源码:https://github.com/easemob/EasemobCircle-2022-Innovation-Challenge/tree/master/Innovation-Challenge/%E6%96%B0%E6%96%B0%E4%BA%BA%E7%B1%BB-%E6%A8%A1%E6%8B%9F%E5%99%A8%E7%9B%B4%E6%92%AD


二、 代码搬运工-CT超级社区

CT超级社区基于环信超级社区Demo,在实时聊天场景基本功能中,丰富了聊天内容英译汉翻译功能。同时增加了频道插件功能,通过将封装部分API成SDK,部分功能可通过插件的形式去实现,通过丰富的插件功能提高用户互动性,提升社区体验。目前实现的插件有:投票、社区签到、打卡分享、代码分享、频道内置机器人、外置拓展机器人。

CT超级社区-项目预览

该项目集成了多个超级社区场景下的高使用频率功能,投票以及打卡签到、机器人等插件有助于提升社区活跃度,鼓励社区内用户发起讨论,独到之处也为开发者们提供了分享代码的功能插件,为社区提供了更丰富的互动元素,高度契合了超级社区场景化需求。

源码:https://github.com/easemob/EasemobCircle-2022-Innovation-Challenge/tree/master/Innovation-Challenge/%E4%BB%A3%E7%A0%81%E6%90%AC%E8%BF%90%E5%B7%A5-CT%E7%A4%BE%E5%8C%BA


三、小雪花-有趣点儿圈子

“有趣点儿圈子”基于环信超级社区构建,在于支持万人场景下的沟通交流娱乐。多种分类频道(通知频道、直播频道),满足于用户畅游。用户等级VIP信息,专属聊天图标。内置扔骰子游戏,石头剪刀布游戏,红包功能,随机打卡功能。还有可以陪你的机器人功能,萌萌的它,可以每日单词、笑话、天气提醒...,更有功能强大的ChatGPT AI对话。

有趣点儿圈子-项目预览


该项目集成了多种群内小游戏以及红包功能,同时支持了不同群成员的等级属性。作为一个社交类产品,这些功能都极大提升了一个社区的活力和丰富程度。值得一提的是虽然ChatGPT功能并没有在此项目中完全跑通,但这种创新精神和将新兴功能接入超级社区的想象力仍然值得鼓励。期待后期继续完善,为广大开发者分享更加卓越的场景应用。

源码:https://github.com/easemob/EasemobCircle-2022-Innovation-Challenge/tree/master/Innovation-Challenge/%E5%B0%8F%E9%9B%AA%E8%8A%B1-%E6%9C%89%E8%B6%A3%E7%82%B9%E5%84%BF%E5%9C%88%E5%AD%90

以上开源作品中使用到的SDK:

 ●注册环信:https://console.easemob.com/user/register

●超级社区介绍:https://www.easemob.com/product/im/circle

●超级社区SDK 集成文档::https://docs-im.easemob.com/ccim/circle/overview

●超级社区Demo体验:https://www.easemob.com/download/demo#discord

●技术支持社区:https://www.imgeek.org

收起阅读 »

Android 关于集成环信如何设置桌面角标

1、小米角标:MIUI6-MIUI11桌面应用角标适配方法通过反射调用设置桌面角标,参考代码如下:try { Field field = notification.getClass().getDeclaredField(“extraNotificatio...
继续阅读 »

1、小米角标

MIUI6-MIUI11桌面应用角标适配方法

通过反射调用设置桌面角标,参考代码如下:

try {
Field field = notification.getClass().getDeclaredField(“extraNotification”);
Object extraNotification = field.get(notification);
Method method = extraNotification.getClass().getDeclaredMethod(“setMessageCount”, int.class);
method.invoke(extraNotification, mCount);
} catch (Exception e) {
e.printStackTrace();
}


MIUI12及以后桌面应用角标适配方法

由于Google屏蔽了hideAPI的反射调用,因此MIUI12及以后可以使用notification.number,可参照Android开发者文档 https://developer.android.google.cn/reference/android/app/Notification#number,参考代码如下:

Notification notification = new Notification.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.notification_icon)
.setContentTitle(textTitle)
.setContentText(textContent)
.setNumber(int number)
.build();


如何判断MIUI版本

可参考文档https://dev.mi.com/console/doc/detail?pId=1312,其中6.1节有具体方法说明。

桌面应用角标问题Q&A

https://dev.mi.com/console/doc/detail?pId=2321

2、华为角标:

需要在消息扩展里设置上应用入口activity,如demo是com.hyphenate.chatuidemo.ui.SplashActivity

// 设置自定义推送提示
JSONObject extObject = new JSONObject();
try {
extObject.put("em_huawei_push_badge_class", "com.hyphenate.chatuidemo.ui.SplashActivity");
} catch (JSONException e) {
e.printStackTrace();
}
// 将推送扩展设置到消息中
message.setAttribute("em_apns_ext", extObject);


3、vivo角标:

“桌面图标角标”默认关闭

接入成功后,“桌面图标角标”默认关闭,需要用户手动开启。

开启路径:“设置”-“通知与状态栏”-“应用通知管理”-应用名称-“桌面图标角标”。

未成功接入“桌面图标角标”的应用,无“桌面图标角标”选项。

备注:视OS版本差异,“桌面图标角标”名称可能为“应用图标标记”或“桌面角标”。

具体实现

a. 添加权限:

b. 应用在需要显示桌面角标的场景,通过广播将信息发送给vivoLauncher:

广播参数:

action:launcher.action.CHANGE_APPLICATION_NOTIFICATION_NUM

packageName:应用包名

className:主类名

notificationNum:未读消息数目

简单示例:

Intent intent = new Intent();

int missedCalls = 10;

intent.setAction("launcher.action.CHANGE_APPLICATION_NOTIFICATION_NUM");

intent.putExtra("packageName", "com.android.xxxx");

intent.putExtra("className", "com.android.xxxx.Mainxxxx");

intent.putExtra("notificationNum", missedCalls);

intent.addFlags(Intent.FLAG_RECEIVER_INCLUDE_BACKGROUND);

sendBroadcast(intent);


注意:

在8.0上,还需要给Intent加上下面的flag

Intent.FLAG_RECEIVER_INCLUDE_BACKGROUND

4、oppo角标:

需要申请Push角标功能

https://open.oppomobile.com/bbs/forum.php?mod=viewthread&tid=2448&extra=page%3D1&aid=11392

5、魅族角标:

不支持

收起阅读 »

国家级出行平台亮相,“强国交通”完成内测即将上线

作为国内首个国家级交通出行平台,涵盖约车、货运、水运、航运等功能的“强国交通”近日已完成内测,进入上线倒计时。记者从“强国交通”项目组获悉,“强国交通”将率先推出约车服务,已接入数十家网约车运力公司,未来预计接入运力将占市场全部运力的90%以上。通过项目组提供...
继续阅读 »

作为国内首个国家级交通出行平台,涵盖约车、货运、水运、航运等功能的“强国交通”近日已完成内测,进入上线倒计时。记者从“强国交通”项目组获悉,“强国交通”将率先推出约车服务,已接入数十家网约车运力公司,未来预计接入运力将占市场全部运力的90%以上。


通过项目组提供的邀请码,记者尝鲜登录“强国交通”应用界面,应用首页规划有“约车”“货运”“公路”“铁路”“水运”“航运”等六大服务门类,以及维修、保险、油卡优惠等配套服务。目前“约车”服务已开通运营测试,可选择“打车”“助老约车”两种途径。如选择“打车”,用户输入目的地后,可进行叫车、接单、支付等全流程;选择“助老约车”,则可以电话约车,极大方便广大老年用户出行。

记者注意到,“强国交通”已接入数十家网约车运力公司,运力较为充足、车型较为丰富。“强国交通”项目组负责人童光来介绍,平台仍在增加运力聚合,预计接入运力将占市场全部运力的90%以上。“强国交通”上线后也将接入微信、支付宝、抖音等平台,方便群众享受安全、便捷的数字交通服务。

据悉,“强国交通”是“学习强国”学习平台与相关部门联合打造的数字立体交通应用服务平台,为平台用户提供涵盖水、路、空、铁等多门类数字交通工具,立体化、集成化、智能化、安全化的交通出行服务。

基于网约车行业一度存在的无序扩张和数据安全问题,“强国交通”秉承数字惠民和安全可控的原则,将陆续上线多项功能。其中,将率先为“学习强国”学习平台注册用户提供约车服务。除了提供“助老约车”特色服务,“强国交通”还将为重点央企、国企等企事业单位职工提供定制化出行服务,最大程度保护用户数据安全与个人隐私。

编辑:蔡文清
来源:bj.bjd.com.cn/5b165687a010550e5ddc0e6a/contentShare/5b16573ae4b02a9fe2d558f9/AP63c7b528e4b0ed71f9280811.html

收起阅读 »

超实用!过年该给爸妈买什么?看这一篇就够了

春节将至,你准备啥时候回家?小时候,爸妈把最好的留给我们,从无怨言;如今我们在外求学工作,该带点什么回去孝顺忙碌了一年的爸妈?根据学生党、工薪族不同预算,送你一份最走心的春节礼物清单,供参考打包好自己,把爱带回家!来源:人民日报

春节将至,你准备啥时候回家?小时候,爸妈把最好的留给我们,从无怨言;如今我们在外求学工作,该带点什么回去孝顺忙碌了一年的爸妈?根据学生党、工薪族不同预算,送你一份最走心的春节礼物清单,供参考打包好自己,把爱带回家!










来源:人民日报

微博图床挂了!

一直担心的事情还是发生了。作为hexo多年的使用者,微博图床一直是我的默认选项,hexo+typora+iPic更是我这几年写文章的黄金组合。而图床中,新浪图床一直都是我的默认选项,速度快、稳定同时支持大图片批量上传更是让其成为了众多图床工具的默认选项。虽然今...
继续阅读 »

一直担心的事情还是发生了。

作为hexo多年的使用者,微博图床一直是我的默认选项,hexo+typora+iPic更是我这几年写文章的黄金组合。而图床中,新浪图床一直都是我的默认选项,速度快、稳定同时支持大图片批量上传更是让其成为了众多图床工具的默认选项。虽然今年早些的时候,部分如「ws1、ws2……」的域名就已经无法使用了,但通过某些手段还是可以让其存活的,而最近,所有调用的微博图床图片都无法加载并提示“403 Forbidden”了。


💡Tips:图片中出现的Tengine是淘宝在Nginx的基础上修改后开源的一款Web服务器,基本上,Tengine可以被看作一个更好的Nginx,或者是Nginx的超集,详情可参考👉淘宝Web服务器Tengine正式开源 - The Tengine Web Server

刚得知这个消息的时候,我的第一想法其实是非常生气的,毕竟自己这几年上千张图片都是用的微博图床,如今还没备份就被403了,可仔细一想,说到底还是把东西交在别人手里的下场,微博又不是慈善企业,也要控制成本,一直睁一只眼闭一只眼让大家免费用就算了,出了问题还是不太好怪到微博上来的。

那么有什么比较好的办法解决这个问题呢?

查遍了网上一堆复制/粘贴出来的文章,不是开启反向代理就是更改请求头,真正愿意从根本上解决问题的没几个。

如果不想将自己沉淀的博客、文章托管在印象笔记、notion、语雀这些在线平台的话,想要彻底解决这个问题最好的方式是:自建图床!

为了更好的解决问题,我们先弄明白,403是什么,以及我们存在微博上的图片究竟是如何被403的。

403

百度百科,对于403错误的解释很简单

403错误是一种在网站访问过程中,常见的错误提示,表示资源不可用。服务器理解客户的请求,但拒绝处理它,通常由于服务器上文件或目录的权限设置导致的WEB访问错误。

所以说到底是因为访问者无权访问服务器端所提供的资源。而微博图床出现403的原因主要在于微博开启了防盗链。

防盗链的原理很简单,站点在得知有请求时,会先判断请求头中的信息,如果请求头中有Referer信息,然后根据自己的规则来判断Referer头信息是否符合要求,Referer 信息是请求该图片的来源地址。

如果盗用网站是 https 的 协议,而图片链接是 http 的话,则从 https 向 http 发起的请求会因为安全性的规定,而不带 referer,从而实现防盗链的绕过。官方输出图片的时候,判断了来源(Referer),就是从哪个网站访问这个图片,如果是你的网站去加载这个图片,那么 Referer 就是你的网站地址;你的网址肯定没在官方的白名单内,(当然作为可操作性极强的浏览器来说 referer 是完全可以伪造一个官方的 URL 这样也也就也可以饶过限制🚫)所以就看不到图片了。


解决问题

解释完原理之后我们发现,其实只要想办法在自己的个人站点中设置好referer就可以解决这个问题,但说到底也只是治标不治本,真正解决这个问题就是想办法将图片迁移到自己的个人图床上。

现在的图床工具很多,iPic、uPic、PicGo等一堆工具既免费又开源,问题在于选择什么云存储服务作为自己的图床以及如何替换自己这上千张图片。

  1. 选择什么云存储服务

  2. 如何替换上千张图片

什么是OSS以及如何选择

「OSS」的英文全称是Object Storage Service,翻译成中文就是「对象存储服务」,官方一点解释就是对象存储是一种使用HTTP API存储和检索非结构化数据和元数据对象的工具。

白话文解释就是将系统所要用的文件上传到云硬盘上,该云硬盘提供了文件下载、上传等一列服务,这样的服务以及技术可以统称为OSS,业内提供OSS服务的厂商很多,知名常用且成规模的有阿里云、腾讯云、百度云、七牛云、又拍云等。

对于我们这些个人用户来说,这些云厂商提供的服务都是足够使用的,我们所要关心的便是成本💰。

笔者使用的是七牛云,它提供了10G的免费存储,基本已经够用了。

有人会考虑将GitHub/Gitee作为图床,并且这样的文章在中文互联网里广泛流传,因为很多人的个人站点都是托管在GitHub Pages上的,但是个人建议是不要这么做。

首先GitHub在国内的访问就很受限,很多场景都需要科学上网才能获得完整的浏览体验。再加上GitHub官方也不推荐将Git仓库存储大文件,GitHub建议仓库保持较小,理想情况下小于 1 GB,强烈建议小于 5 GB。

如何替换上千张图片

替换文章中的图片链接和“把大象放进冰箱里”步骤是差不多的

  1. 下载所有的微博图床的图片

  2. 上传所有的图片到自己的图床(xx云)

  3. 对文本文件执行replaceAll操作

考虑到我们需要迁移的文件数量较多,手动操作肯定是不太可行的,因此我们可以采用代码的方式写一个脚本完成上述操作。考虑到自己已经是一个成熟的Java工程师了,这个功能就干脆用Java写了。

为了减少代码量,精简代码结构,我这里引入了几个第三方库,当然不引入也行,如果不引入有一些繁琐而又简单的业务逻辑需要自己实现,有点浪费时间了。

整个脚本逻辑非常简单,流程如下:


获取博客文件夹下的Markdown文件

这里我们直接使用hutool这个三方库,它内置了很多非常实用的工具类,获取所有markdown文件也变得非常容易

/**
* 筛选出所有的markdown文件
*/
public static List<File> listAllMDFile() {
   List<File> files = FileUtil.loopFiles(VAULT_PATH);
   return files.stream()
    .filter(Objects::nonNull)
      .filter(File::isFile)
      .filter(file -> StringUtils.endsWith(file.getName(), ".md"))
      .collect(Collectors.toList());
}

获取文件中的所有包含微博图床的域名

通过Hutools内置的FileReader我们可以直接读取markdown文件的内容,因此我们只需要解析出文章里包含微博图床的链接即可。我们可以借助正则表达式快速获取一段文本内容里的所有url,然后做一下filter即可。

/**
* 获取一段文本内容里的所有url
*
* @param content 文本内容
* @return 所有的url
*/
public static List<String> getAllUrlsFromContent(String content) {
   List<String> urls = new ArrayList<>();
   Pattern pattern = Pattern.compile(
       "\\b(((ht|f)tp(s?)\\:\\/\\/|~\\/|\\/)|www.)" + "(\\w+:\\w+@)?(([-\\w]+\\.)+(com|org|net|gov"
           + "|mil|biz|info|mobi|name|aero|jobs|museum" + "|travel|[a-z]{2}))(:[\\d]{1,5})?"
           + "(((\\/([-\\w~!$+|.,=]|%[a-f\\d]{2})+)+|\\/)+|\\?|#)?" + "((\\?([-\\w~!$+|.,*:]|%[a-f\\d{2}])+=?"
           + "([-\\w~!$+|.,*:=]|%[a-f\\d]{2})*)" + "(&(?:[-\\w~!$+|.,*:]|%[a-f\\d{2}])+=?"
           + "([-\\w~!$+|.,*:=]|%[a-f\\d]{2})*)*)*" + "(#([-\\w~!$+|.,*:=]|%[a-f\\d]{2})*)?\\b");
   Matcher matcher = pattern.matcher(content);
   while (matcher.find()) {
       urls.add(matcher.group());
  }
   return urls;
}

下载图片

用Java下载文件的代码在互联网上属实是重复率最高的一批检索内容了,这里就直接贴出代码了。

public static void download(String urlString, String fileName) throws IOException {
   File file = new File(fileName);
   if (file.exists()) {
       return;
  }
   URL url = null;
   OutputStream os = null;
   InputStream is = null;
   try {
       url = new URL(urlString);
       URLConnection con = url.openConnection();
       // 输入流
       is = con.getInputStream();
       // 1K的数据缓冲
       byte[] bs = new byte[1024];
       // 读取到的数据长度
       int len;
       // 输出的文件流
       os = Files.newOutputStream(Paths.get(fileName));
       // 开始读取
       while ((len = is.read(bs)) != -1) {
           os.write(bs, 0, len);
      }
  } finally {
       if (os != null) {
           os.close();
      }
       if (is != null) {
           is.close();
      }
  }
}

上传图片

下载完图片后我们便要着手将下载下来的图片上传至我们自己的云存储服务了,这里直接给出七牛云上传图片的文档链接了,文档里写的非常详细,我就不赘述了👇

Java SDK_SDK 下载_对象存储 - 七牛开发者中心

全局处理

通过阅读代码的细节,我们可以发现,我们的方法粒度是单文件的,但事实上,我们可以先将所有的文件遍历一遍,统一进行图片的下载、上传与替换,这样可以节约点时间。

统一替换的逻辑也很简单,我们申明一个全局Map,

private static final Map<String, String> URL_MAP = Maps.newHashMap();

其中,key是旧的新浪图床的链接,value是新的自定义图床的链接。

我们将listAllMDFile这一步中所获取到的所有文件里的所有链接保存于此,下载时只需遍历这个Map的key即可获取到需要下载的图片链接。然后将上传后得到的新链接作为value存在到该Map中即可。

全文替换链接并更新文件

有了上述这些处理步骤,接下来一步就变的异常简单,只需要遍历每个文件,将匹配到全局Map中key的链接替换成Map中的value即可。

/**
* 替换所有的图片链接
*/
private static String replaceUrl(String content, Map<String, String> urlMap) {
   for (Map.Entry<String, String> entry : urlMap.entrySet()) {
       String oldUrl = entry.getKey();
       String newUrl = entry.getValue();
       if (StringUtils.isBlank(newUrl)) {
           continue;
      }
content = RegExUtils.replaceAll(content, oldUrl, newUrl);
  }
   return content;
}

我们借助commons-lang实现字符串匹配替换,借助Hutools实现文件的读取和写入。

files.forEach(file -> {
   try {
       FileReader fileReader = new FileReader(file.getPath());
       String content = fileReader.readString();
       String replaceContent = replaceUrl(content, URL_MAP);
       FileWriter writer = new FileWriter(file.getPath());
       writer.write(replaceContent);
  } catch (Throwable e) {
       log.error("write file error, errorMsg:{}", e.getMessage());
  }
});

为了安全起见,最好把文件放在新的目录中,不要直接替换掉原来的文件,否则程序出现意外就麻烦了。

接下来我们只需要运行程序,静待备份结果跑完即可。

以上就是本文的全部内容了,希望对你有所帮助

作者:插猹的闰土
来源:juejin.cn/post/7189651446306963514

收起阅读 »

Android动态加载so!这一篇就够了!

背景对于一个普通的android应用来说,so库的占比通常都是巨高不下的,因为我们无可避免的在开发中遇到各种各样需要用到native的需求,所以so库的动态化可以减少极大的包体积,自从2020腾讯的bugly团队发部关于动态化so的相关文章后,已经过去两年了,...
继续阅读 »

背景

对于一个普通的android应用来说,so库的占比通常都是巨高不下的,因为我们无可避免的在开发中遇到各种各样需要用到native的需求,所以so库的动态化可以减少极大的包体积,自从2020腾讯的bugly团队发部关于动态化so的相关文章后,已经过去两年了,相关文章,经过两年的考验,实际上so动态加载也是非常成熟的一项技术了,但是很遗憾,许多公司都还没有这方面的涉略又或者说不知道从哪里开始进行,因为so动态其实涉及到下载,so版本管理,动态加载实现等多方面,我们不妨抛开这些额外的东西,从最本质的so动态加载出发吧!这里是本次的例子,我把它命名为sillyboy,欢迎pr还有后续点赞呀!

so动态加载介绍

动态加载,其实就是把我们的so库在打包成apk的时候剔除,在合适的时候通过网络包下载的方式,通过一些手段,在运行的时候进行分离加载的过程。这里涉及到下载器,还有下载后的版本管理等等确保一个so库被正确的加载等过程,在这里,我们不讨论这些辅助的流程,我们看下怎么实现一个最简单的加载流程。


从一个例子出发

我们构建一个native工程,然后在里面编入如下内容,下面是cmake

# For more information about using CMake with Android Studio, read the
# documentation: https://d.android.com/studio/projects/add-native-code.html

# Sets the minimum version of CMake required to build the native library.

cmake_minimum_required(VERSION 3.18.1)

# Declares and names the project.

project("nativecpp")

# Creates and names a library, sets it as either STATIC
# or SHARED, and provides the relative paths to its source code.
# You can define multiple libraries, and CMake builds them for you.
# Gradle automatically packages shared libraries with your APK.

add_library( # Sets the name of the library.
      nativecpp

      # Sets the library as a shared library.
      SHARED

      # Provides a relative path to your source file(s).
      native-lib.cpp)

add_library(
      nativecpptwo
      SHARED
      test.cpp

)

# Searches for a specified prebuilt library and stores the path as a
# variable. Because CMake includes system libraries in the search path by
# default, you only need to specify the name of the public NDK library
# you want to add. CMake verifies that the library exists before
# completing its build.

find_library( # Sets the name of the path variable.
      log-lib

      # Specifies the name of the NDK library that
      # you want CMake to locate.
      log)

# Specifies libraries CMake should link to your target library. You
# can link multiple libraries, such as libraries you define in this
# build script, prebuilt third-party libraries, or system libraries.

target_link_libraries( # Specifies the target library.
      nativecpp

      # Links the target library to the log library
      # included in the NDK.
      ${log-lib})


target_link_libraries( # Specifies the target library.
      nativecpptwo

      # Links the target library to the log library
      # included in the NDK.
      nativecpp
      ${log-lib})

可以看到,我们生成了两个so库一个是nativecpp,还有一个是nativecpptwo(为什么要两个呢?我们可以继续看下文) 这里也给出最关键的test.cpp代码

#include <jni.h>
#include <string>
#include<android/log.h>


extern "C"
JNIEXPORT void JNICALL
Java_com_example_nativecpp_MainActivity_clickTest(JNIEnv *env, jobject thiz) {
  // 在这里打印一句话
  __android_log_print(ANDROID_LOG_INFO,"hello"," native 层方法");

}

很简单,就一个native方法,打印一个log即可,我们就可以在java/kotin层进行方法调用了,即

public native void clickTest();

so库检索与删除

要实现so的动态加载,那最起码是要知道本项目过程中涉及到哪些so吧!不用担心,我们gradle构建的时候,就已经提供了相应的构建过程,即构建的task【 mergeDebugNativeLibs】,在这个过程中,会把一个project里面的所有native库进行一个收集的过程,紧接着task【stripDebugDebugSymbols】是一个符号表清除过程,如果了解native开发的朋友很容易就知道,这就是一个减少so体积的一个过程,我们不在这里详述。所以我们很容易想到,我们只要在这两个task中插入一个自定义的task,用于遍历和删除就可以实现so的删除化了,所以就很容易写出这样的代码

ext {
   deleteSoName = ["libnativecpptwo.so","libnativecpp.so"]
}
// 这个是初始化 -配置 -执行阶段中,配置阶段执行的任务之一,完成afterEvaluate就可以得到所有的tasks,从而可以在里面插入我们定制化的数据
task(dynamicSo) {
}.doLast {
   println("dynamicSo insert!!!! ")
   //projectDir 在哪个project下面,projectDir就是哪个路径
   print(getRootProject().findAll())

   def file = new File("${projectDir}/build/intermediates/merged_native_libs/debug/out/lib")
   //默认删除所有的so库
   if (file.exists()) {
       file.listFiles().each {
           if (it.isDirectory()) {
               it.listFiles().each {
                   target ->
                       print("file ${target.name}")
                       def compareName = target.name
                       deleteSoName.each {
                           if (compareName.contains(it)) {
                               target.delete()
                          }
                      }
              }
          }
      }
  } else {
       print("nil")
  }
}
afterEvaluate {
   print("dynamicSo task start")
   def customer = tasks.findByName("dynamicSo")
   def merge = tasks.findByName("mergeDebugNativeLibs")
   def strip = tasks.findByName("stripDebugDebugSymbols")
   if (merge != null || strip != null) {
       customer.mustRunAfter(merge)
       strip.dependsOn(customer)
  }

}

可以看到,我们定义了一个自定义task dynamicSo,它的执行是在afterEvaluate中定义的,并且依赖于mergeDebugNativeLibs,而stripDebugDebugSymbols就依赖于我们生成的dynamicSo,达到了一个插入操作。那么为什么要在afterEvaluate中执行呢?那是因为android插件是在配置阶段中才生成的mergeDebugNativeLibs等任务,原本的gradle构建是不存在这样一个任务的,所以我们才需要在配置完所有task之后,才进行的插入,我们可以看一下gradle的生命周期


通过对条件检索,我们就删除掉了我们想要的so,即ibnativecpptwo.so与libnativecpp.so。

动态加载so

根据上文检索出来的两个so,我们就可以在项目中上传到自己的后端中,然后通过网络下载到用户的手机上,这里我们就演示一下即可,我们就直接放在data目录下面吧


真实的项目过程中,应该要有校验操作,比如md5校验或者可以解压等等操作,这里不是重点,我们就直接略过啦!

那么,怎么把一个so库加载到我们本来的apk中呢?这里是so原本的加载过程,可以看到,系统是通过classloader检索native目录是否存在so库进行加载的,那我们反射一下,把我们自定义的path加入进行不就可以了吗?这里采用tinker一样的思路,在我们的classloader中加入so的检索路径即可,比如

private static final class V25 {
  private static void install(ClassLoader classLoader, File folder) throws Throwable {
      final Field pathListField = ShareReflectUtil.findField(classLoader, "pathList");
      final Object dexPathList = pathListField.get(classLoader);

      final Field nativeLibraryDirectories = ShareReflectUtil.findField(dexPathList, "nativeLibraryDirectories");

      List<File> origLibDirs = (List<File>) nativeLibraryDirectories.get(dexPathList);
      if (origLibDirs == null) {
          origLibDirs = new ArrayList<>(2);
      }
      final Iterator<File> libDirIt = origLibDirs.iterator();
      while (libDirIt.hasNext()) {
          final File libDir = libDirIt.next();
          if (folder.equals(libDir)) {
              libDirIt.remove();
              break;
          }
      }
      origLibDirs.add(0, folder);

      final Field systemNativeLibraryDirectories = ShareReflectUtil.findField(dexPathList, "systemNativeLibraryDirectories");
      List<File> origSystemLibDirs = (List<File>) systemNativeLibraryDirectories.get(dexPathList);
      if (origSystemLibDirs == null) {
          origSystemLibDirs = new ArrayList<>(2);
      }

      final List<File> newLibDirs = new ArrayList<>(origLibDirs.size() + origSystemLibDirs.size() + 1);
      newLibDirs.addAll(origLibDirs);
      newLibDirs.addAll(origSystemLibDirs);

      final Method makeElements = ShareReflectUtil.findMethod(dexPathList, "makePathElements", List.class);

      final Object[] elements = (Object[]) makeElements.invoke(dexPathList, newLibDirs);

      final Field nativeLibraryPathElements = ShareReflectUtil.findField(dexPathList, "nativeLibraryPathElements");
      nativeLibraryPathElements.set(dexPathList, elements);
  }
}

我们在原本的检索路径中,在最前面,即数组为0的位置加入了我们的检索路径,这样一来classloader在查找我们已经动态化的so库的时候,就能够找到!

结束了吗?

一般的so库,比如不依赖其他的so的时候,直接这样加载就没问题了,但是如果存在着依赖的so库的话,就不行了!相信大家在看其他的博客的时候就能看到,是因为Namespace的问题。具体是我们动态库加载的过程中,如果需要依赖其他的动态库,那么就需要一个链接的过程对吧!这里的实现就是Linker,Linker 里检索的路径在创建 ClassLoader 实例后就被系统通过 Namespace 机制绑定了,当我们注入新的路径之后,虽然 ClassLoader 里的路径增加了,但是 Linker 里 Namespace 已经绑定的路径集合并没有同步更新,所以出现了 libxxx.so 文件(当前的so)能找到,而依赖的so 找不到的情况。bugly文章

很多实现都采用了Tinker的实现,既然我们系统的classloader是这样,那么我们在合适的时候把这个替换掉不就可以了嘛!当然bugly团队就是这样做的,但是笔者认为,替换一个classloader显然对于一个普通应用来说,成本还是太大了,而且兼容性风险也挺高的,当然,还有很多方式,比如采用Relinker这个库自定义我们加载的逻辑。

为了不冷饭热炒,嘿嘿,虽然我也喜欢吃炒饭(手动狗头),这里我们就不采用替换classloader的方式,而是采用跟relinker的思想,去进行加载!具体的可以看到sillyboy的实现,其实就不依赖relinker跟tinker,因为我把关键的拷贝过来了,哈哈哈,好啦,我们看下怎么实现吧!不过在此这前,我们需要了解一些前置知识

ELF文件

我们的so库,本质就是一个elf文件,那么so库也符合elf文件的格式,ELF文件由4部分组成,分别是ELF头(ELF header)、程序头表(Program header table)、节(Section)和节头表(Section header table)。实际上,一个文件中不一定包含全部内容,而且它们的位置也未必如同所示这样安排,只有ELF头的位置是固定的,其余各部分的位置、大小等信息由ELF头中的各项值来决定。


那么我们so中,如果依赖于其他的so,那么这个信息存在哪里呢!?没错,它其实也存在elf文件中,不然链接器怎么找嘛,它其实就存在.dynamic段中,所以我们只要找打dynamic段的偏移,就能到dynamic中,而被依赖的so的信息,其实就存在里面啦 我们可以用readelf(ndk中就有toolchains目录后) 查看,readelf -d nativecpptwo.so 这里的 -d 就是查看dynamic段的意思


这里面涉及到动态加载so的知识,可以推荐大家一本书,叫做程序员的自我修养-链接装载与库这里就画个初略图


我们再看下本质,dynamic结构体如下,定义在elf.h中

typedef struct{
Elf32_Sword d_tag;
union{
Elf32_Addr d_ptr;
....
}
}

当d_tag的数值为DT_NEEDED的时候,就代表着依赖的共享对象文件,d_ptr表示所依赖的共享对象的文件名。看到这里读者们已经知道了,如果我们知道了文件名,不就可以再用System.loadLibrary去加载这个文件名确定的so了嘛!不用替换classloader就能够保证被依赖的库先加载!我们可以再总结一下这个方案的原理,如图


比如我们要加载so3,我们就需要先加载so2,如果so2存在依赖,那我们就调用System.loadLibrary先加载so1,这个时候so1就不存在依赖项了,就不需要再调用Linker去查找其他so库了。我们最终方案就是,只要能够解析对应的elf文件,然后找偏移,找到需要的目标项(DT_NEED)所对应的数值(即被依赖的so文件名)就可以了

public List<String> parseNeededDependencies() throws IOException {
  channel.position(0);
  final List<String> dependencies = new ArrayList<String>();
  final Header header = parseHeader();
  final ByteBuffer buffer = ByteBuffer.allocate(8);
  buffer.order(header.bigEndian ? ByteOrder.BIG_ENDIAN : ByteOrder.LITTLE_ENDIAN);

  long numProgramHeaderEntries = header.phnum;
  if (numProgramHeaderEntries == 0xFFFF) {
      /**
        * Extended Numbering
        *
        * If the real number of program header table entries is larger than
        * or equal to PN_XNUM(0xffff), it is set to sh_info field of the
        * section header at index 0, and PN_XNUM is set to e_phnum
        * field. Otherwise, the section header at index 0 is zero
        * initialized, if it exists.
        **/
      final SectionHeader sectionHeader = header.getSectionHeader(0);
      numProgramHeaderEntries = sectionHeader.info;
  }

  long dynamicSectionOff = 0;
  for (long i = 0; i < numProgramHeaderEntries; ++i) {
      final ProgramHeader programHeader = header.getProgramHeader(i);
      if (programHeader.type == ProgramHeader.PT_DYNAMIC) {
          dynamicSectionOff = programHeader.offset;
          break;
      }
  }

  if (dynamicSectionOff == 0) {
      // No dynamic linking info, nothing to load
      return Collections.unmodifiableList(dependencies);
  }

  int i = 0;
  final List<Long> neededOffsets = new ArrayList<Long>();
  long vStringTableOff = 0;
  DynamicStructure dynStructure;
  do {
      dynStructure = header.getDynamicStructure(dynamicSectionOff, i);
      if (dynStructure.tag == DynamicStructure.DT_NEEDED) {
          neededOffsets.add(dynStructure.val);
      } else if (dynStructure.tag == DynamicStructure.DT_STRTAB) {
          vStringTableOff = dynStructure.val; // d_ptr union
      }
      ++i;
  } while (dynStructure.tag != DynamicStructure.DT_NULL);

  if (vStringTableOff == 0) {
      throw new IllegalStateException("String table offset not found!");
  }

  // Map to file offset
  final long stringTableOff = offsetFromVma(header, numProgramHeaderEntries, vStringTableOff);
  for (final Long strOff : neededOffsets) {
      dependencies.add(readString(buffer, stringTableOff + strOff));
  }

  return dependencies;
}

扩展

我们到这里,就能够解决so库的动态加载的相关问题了,那么还有人可能会问,项目中是会存在多处System.load方式的,如果加载的so还不存在怎么办?比如还在下载当中,其实很简单,这个时候我们字节码插桩就派上用场了,只要我们把System.load替换为我们自定义的加载so逻辑,进行一定的逻辑处理就可以了,嘿嘿,因为笔者之前就有写一个字节码插桩的库的介绍,所以在本次就不重复了,可以看Sipder,同时也可以用其他的字节码插桩框架实现,相信这不是一个问题。

总结

看到这里的读者,相信也能够明白动态加载so的步骤了,最后源代码可以在SillyBoy,当然也希望各位点赞呀!当然,有更好的实现也欢迎评论!!

作者:Pika
来源:juejin.cn/post/7107958280097366030

收起阅读 »

8年了,Web3到底给这个世界带来了什么?

一言以蔽之,Web3 并没有带来普遍的价值,它只给少数人带来了价值。Web3 这个词,可以追溯到 1999 年。但我们如今谈论的 Web3 不是那个定义。从 1999 年到 2014 年期间,不断有很多名人来定义 web3。但都没有获得普遍认可。2014 年 ...
继续阅读 »

一言以蔽之,Web3 并没有带来普遍的价值,它只给少数人带来了价值。

Web3 这个词,可以追溯到 1999 年。但我们如今谈论的 Web3 不是那个定义。

从 1999 年到 2014 年期间,不断有很多名人来定义 web3。但都没有获得普遍认可。2014 年 Polkadot 创始人和以太坊的创始人 Gavin Wood 提出的概念才是如今真正让 Web3 成为大多数人熟知的起点。

Web3 的起因

Web2 模型与社会结构非常相似,互联网上的各种中心化平台和现实世界中的各种机构对等。

Gavin 认为这种模型是坏掉的模型,他想通过技术手段创造一种全新的模型。如果继续使用这种模型,人类社会会走向衰败。

而这种新模型,就是去中心化,让用户自己拥有自己的数据与资产。

你可能会想,打破平台垄断,无疑会触碰到某些人的利益,遭到很多既得利益者的抵制。

但事实并非如此。

Gavin 一直强调,Web3 并不是取代现存的科技巨头或者权力机构。它只是一种新的模型,这种模型的优势就是权力分配的机制不同。Gavin 试图通过这种模型来保护自由世界。而那些不需要自由的人,仍然可以活在 Web2 的世界里。两者并不冲突。

Web3 的门槛

你可能又会想,如何实现上述模型?

Gavin 曾给出过答案,基于区块链技术。

区块链是一种低成本构造人人互信环境的一种技术。它天生就具有分布式、去中心化的属性。

但是,让用户去编写代码与区块链进行交互,门槛无疑太高了。

所以 Gavin 和志同道合的伙伴搭建了以太坊平台、solidity 智能合约等技术,帮助大家降低编程门槛。

但即使如此,Web3 的门槛仍然很高。

除去技术门槛,交易的成本也是让很多普通人无法接受的重要原因。

在以太坊进行交易,最少要支付 10 元人民币左右的 gas 费。再看看中国的现状,还有多少人在菜市场买菜还要为了几毛钱砍价?大多数普通人不会接受如此昂贵的 gas 费。

所以目前的 Web3,参与者都是些技术极客、创业者和投机者。普通人短时间内无法跨越这些天生的门槛,进入 Web3 的世界。

我认为,普通人不需要自由,自然也不需要 Web3。

Web3 的现状

虽然 Web3 的设想很好,但如今的 Web3 很混乱。

炒作、内斗、诈骗、抄袭,无处不在。

矿场、赌狗、NFT 炒作、洗钱、传销,他们并不是 Web3 好的一面,但 Web3 又没办法离开它们。

他们的关系,是一种相互依赖,相互依存、相互利用的关系。

投机者希望借助这个自由的平台实现利益收割、敛财、暴富。

Web3 的推动者希望借助这帮狂热的疯子来扩大 Web3 对世界的影响力。

投机者不懂那些理念,也不需要懂。它们只在乎电费涨没涨;自己压的币涨没涨;酝酿了很久的盘有没有杀到猪;新发行的 NFT 有没有被 Mint 光。

你说如今 Web3 中的人有没有信仰?不好回答,不过我总结的道理就是:增长就是信仰。就这么简单。

Web2 与 Web3 的关系?

既不冲突,也不对立。

现实世界中的 Web3 发展,与 Gavin 最初提出的目标有不少偏差。但基本的逻辑没有变化。

很多人批判 Web3,认为它是一种新型骗局。

你可以说 Web3 没有普遍价值,但说它是一种骗局我是绝不认可的。人与人之间社交,本身就存在欺骗,这是人的天性,与 Web2 或者 Web3 无关。

人们对 Web3 最错误的认知就是把修补 Web2 已存在的窟窿的重任托付给 Web3。

Web3 不是为了让 Web 更好,也不是为了替代 Web2。

它的使命是创造属于自由的新东西。

Web3 虽然名字中有个 Web,但它不是 Web。

Web3 的未来

自从 2021 年,Web3 火了之后。对 Web3 的定义也越来越模糊。

人们迫切地希望把 Web2 中,或者说现实世界中那些令人痛恨,而又无力改变的现状,通通扔到 Web3 里面,把希望寄托于 Web3。

这个奇怪的现象一直持续至今。

如今我们谈论的 Web3,已经不再单单是曾经 Gavin 下的定义了。

元宇宙、ESG 电商、虚拟地产、创作者经济、去中心化金融等等一堆衍生概念,全部涌入 Web3 中。

如今,Web2 中的各大科技公司纷纷布局 Web3,而 Web3 也成为各大投资机构的焦点。沉浸在这种焦点之下,难免会让 Web3 的人看不清自己。

假泡沫还是真趋势?

没人能看清,我也看不清,也无法预测。

不过,我终归是对 Web3 有信仰的,这份信仰源自增长。

作者:代码与野兽
来源:juejin.cn/post/7189085299304038459

收起阅读 »

什么样的故障让阿里云换了总裁?

2022年12月18日大概是阿里云最黑暗的日子,阿里云香港Region可用区C因为机房水冷装置出现故障导致大规模服务中断,对很多客户业务产生重大影响,包括澳门多家网站及App自当日午时起无法访问使用。云服务器宕机后一般几个小时左右便能恢复,可是阿里云这次宕机于...
继续阅读 »

2022年12月18日大概是阿里云最黑暗的日子,阿里云香港Region可用区C因为机房水冷装置出现故障导致大规模服务中断,对很多客户业务产生重大影响,包括澳门多家网站及App自当日午时起无法访问使用。云服务器宕机后一般几个小时左右便能恢复,可是阿里云这次宕机于12月18日早上8点56分首次检测到故障警告,直到次日凌晨0点30分所有服务才恢复正常,整个故障持续时间超过15个半小时。号称世界第三的云计算服务商在机房出现异常的时候要耗费如此多的时间才能恢复简直不敢想象,实在和其宣扬的各种黑科技高可用大相径庭。

这次的宕机事件对阿里云的技术品牌力损伤非常大,可能后期你再宣扬自己技术有多牛,客户可能都不会相信了,一旦失去了客户的信任,那离失去市场也就不远了。毕竟现在云计算市场竞争还是比较激烈的,像华为云、腾讯云以及百度云等都在摩拳擦掌抢占市场份额。

故障复盘


暴露的问题

制冷系统监控不足

在机房出现故障的时候,首先检测到的是机房温度异常升高而后才排查到是机房制冷设备问题,很明显机房监控系统没有对制冷机组进行监控。原因机房主备水冷机组共用了同一个水路循环系统,因此存在单点故障问题,一旦水路循环系统出问题,主备水冷机组都会受影响。后期对制冷设备进行手动操作仍然不能恢复其正常运行,很明显没有对水冷设备进行过设备故障演练,导致出现问题后恢复操作时间过长。

为什么机房直接进行喷淋?

大家都知道机房中的服务器都是电子设备,而电子设备最怕水了,如果服务器进水了那就很有可能导致服务器短路损坏,进而导致服务器数据丢失,更加延长了故障恢复的时间。所以为什么不是喷洒七氟丙烷气体灭火,最起码泡沫、粉末也行啊,但是实际上直接触发了喷淋操作,这也是非常诡异的地方。

高可用形同虚设

企业使用阿里云的一个重要原因就是看中了其高可用能力,希望哪天在发生故障的时候用户可以无感地快速进行切换,尽可能的减少故障导致的业务中断时间。而在此次的阿里云宕机故障中,虽然采用了B、C可用区双机房进行了容灾,在C可用区故障之后通过B可用区对外提供服务,但是可惜的是B可用区服务实例资源不够,同时ECS启动时依赖的中间件服务没有进行双机房容灾,只部署在了可用区C机房当中,而此时的可用区C机房已经出现故障,导致B区无法实现扩容。这也暴露了阿里云对重要的基础中间件以及oss基础服务并没有真正做到全部双机房容灾,出现了单点故障问题。

阿里云智能换帅

2022年12月29日,阿里云宕机事件不到两周的时间,阿里巴巴董事会主席兼CEO张勇向阿里全员信公布人事变动信息,张勇将亲自兼任阿里云智能总裁,张建锋不再担任阿里云智能总裁。在这封信中提到:只有在一点一滴的行动上保障好客户利益,创造好客户价值,才能承担好引领发展、创造就业、参与国际竞争的大使命。

在这封全员信发出后,张勇作为阿里云智能总裁单独面向阿里云全体小二发了内部信,信中措辞更为严厉,在800多字的内容中,提到了超过20次“客户”,可见张勇这次是真的急了。以下截取了信中部分内容。


反思

无论是像阿里这样体量的公司还是正在创业的公司,都要始终把客户的利益放在第一位,把客户的信任放在第一位才能赢得客户,赢得市场。否则一旦失去客户的信任,也就慢慢失去了市场,那么在激烈的市场竞争中就会被淘汰。希望在新的一年里阿里云真的可以吸取这次宕机事件的教训,真正为客户创造价值。

作者:慕枫技术笔记
来源:juejin.cn/post/7188431024051716152

收起阅读 »

vue阻止重复请求(下)

接:vue阻止重复333请求(上)(c)代码步骤1-通过axios请求拦截器取消重复请求通过axios请求拦截器,在每次请求前把请求信息和请求的取消方法放到一个map对象当中,并且判断map对象当中是否已经存在该请求信息的请求,如果存在取消上传请求<!D...
继续阅读 »

接:vue阻止重复333请求(上)

(c)代码

步骤1-通过axios请求拦截器取消重复请求

通过axios请求拦截器,在每次请求前把请求信息和请求的取消方法放到一个map对象当中,并且判断map对象当中是否已经存在该请求信息的请求,如果存在取消上传请求

<!DOCTYPE html>
<html lang="en">
<head>
   <meta charset="UTF-8">
   <meta http-equiv="X-UA-Compatible" content="IE=edge">
   <meta name="viewport" content="width=device-width, initial-scale=1.0">
   <title>Document</title>
   <script src="https://lf26-cdn-tos.bytecdntp.com/cdn/expire-1-M/vue/2.6.14/vue.min.js" type="application/javascript"></script>
   <script src="https://lf9-cdn-tos.bytecdntp.com/cdn/expire-1-M/axios/0.26.0/axios.min.js" type="application/javascript"></script>
   <script src="https://lf3-cdn-tos.bytecdntp.com/cdn/expire-1-M/qs/6.10.3/qs.js" type="application/javascript"></script>

</head>
<body>
   <div id="app">
       <button @click="onClick1" ref="btn1">请求1</button>
       <button @click="onClick2" ref="btn2">请求2</button>
   </div>
</body>
<script>
//存储请求信息和取消方法的的map对象    
const pendingRequest = new Map();  
//根据请求的信息(请求方式,url,请求get/post数据),产生map的key
function getRequestKey(config){
   const { methodurlparamsdata } = config;
   return [methodurlQs.stringify(params), Qs.stringify(data)].join("&");
}  
//请求拦截器
axios.interceptors.request.use(
   function (config) {
   //根据请求的信息(请求方式,url,请求get/post数据),产生map的key
   let requestKey = getRequestKey(config)
   //判断请求是否重复
   if(pendingRequest.has(requestKey)){
       //取消上次请求
       let cancel = pendingRequest.get(requestKey)
       cancel()
       //删除请求信息
       pendingRequest.delete(requestKey)
  }
   //把请求信息,添加请求到map当中
   // 生成取消方法
   config.cancelToken = config.cancelToken || new axios.CancelToken(cancel => {
       // 把取消方法添加到map
       if (!pendingRequest.has(requestKey)) {
           pendingRequest.set(requestKeycancel)
      }
  })
   return config;
},
(error=> {
    return Promise.reject(error);
}
);
let sendPost = function(data){
   return axios({
       url'http://nodejs-cloud-studio-demo-bkzxs.nodejs-cloud-studio-demo.50185620.cn-hangzhou.fc.devsapp.net/test',
       method'post',
       data
  })
}
new Vue({
   el'#app',
   mounted() {
       this.$refs.btn1.click()
       this.$refs.btn2.click()
  },
   methods: {
       // 使用lodash对请求方法做防抖
       //这里有问题,只是对每个按钮的点击事件单独做了防抖,但是两个按钮之间做不到防抖的效果
       onClick1async function(){
           let res = await sendPost({username:'zs'age20})
           console.log('请求1的结果'res.data)
      },
       onClick2async function(){
           let res = await sendPost({username:'zs'age20})
           console.log('请求2的结果'res.data)
      },
  },
})
</script>
</html>

预览

步骤2-通过axios响应拦截器处理请求成功

通过axios的响应拦截器,在请求成功后在map对象当中,删除该请求信息的数据

<!DOCTYPE html>
<html lang="en">
<head>
   <meta charset="UTF-8">
   <meta http-equiv="X-UA-Compatible" content="IE=edge">
   <meta name="viewport" content="width=device-width, initial-scale=1.0">
   <title>Document</title>
   <script src="https://lf26-cdn-tos.bytecdntp.com/cdn/expire-1-M/vue/2.6.14/vue.min.js" type="application/javascript"></script>
   <script src="https://lf9-cdn-tos.bytecdntp.com/cdn/expire-1-M/axios/0.26.0/axios.min.js" type="application/javascript"></script>
   <script src="https://lf3-cdn-tos.bytecdntp.com/cdn/expire-1-M/qs/6.10.3/qs.js" type="application/javascript"></script>

</head>
<body>
   <div id="app">
       <button @click="onClick1" ref="btn1">请求1</button>
       <button @click="onClick2" ref="btn2">请求2</button>
   </div>
</body>
<script>
//存储请求信息和取消方法的的map对象    
const pendingRequest = new Map();  
//根据请求的信息(请求方式,url,请求get/post数据),产生map的key
function getRequestKey(config){
   const { methodurlparamsdata } = config;
   return [methodurlQs.stringify(params), Qs.stringify(data)].join("&");
}  
//请求拦截器
axios.interceptors.request.use(
   function (config) {
   //根据请求的信息(请求方式,url,请求get/post数据),产生map的key
   let requestKey = getRequestKey(config)
   //判断请求是否重复
   if(pendingRequest.has(requestKey)){
       //取消上次请求
       let cancel = pendingRequest.get(requestKey)
       cancel()
       //删除请求信息
       pendingRequest.delete(requestKey)
  }
   //把请求信息,添加请求到map当中
   // 生成取消方法
   config.cancelToken = config.cancelToken || new axios.CancelToken(cancel => {
       // 把取消方法添加到map
       if (!pendingRequest.has(requestKey)) {
           pendingRequest.set(requestKeycancel)
      }
  })
   return config;
},
(error=> {
    return Promise.reject(error);
}
);

//响应拦截器
axios.interceptors.response.use(
(response=> {
       //请求成功
       //删除请求的信息
       let requestKey = getRequestKey(response.config)
       if(pendingRequest.has(requestKey)){
           pendingRequest.delete(requestKey)  
      }
       return response;
  },
  (error=> {
       return Promise.reject(error);
  }
);
let sendPost = function(data){
   return axios({
       url'http://nodejs-cloud-studio-demo-bkzxs.nodejs-cloud-studio-demo.50185620.cn-hangzhou.fc.devsapp.net/test',
       method'post',
       data
  })
}
new Vue({
   el'#app',
   mounted() {
       this.$refs.btn1.click()
       this.$refs.btn2.click()
  },
   methods: {
       // 使用lodash对请求方法做防抖
       //这里有问题,只是对每个按钮的点击事件单独做了防抖,但是两个按钮之间做不到防抖的效果
       onClick1async function(){
           let res = await sendPost({username:'zs'age20})
           console.log('请求1的结果'res.data)
      },
       onClick2async function(){
           let res = await sendPost({username:'zs'age20})
           console.log('请求2的结果'res.data)
      },
  },
})
</script>
</html>

预览

步骤3-通过axios响应拦截器处理请求失败

通过axios的响应拦截器,在请求失败后在map对象当中,删除该请求信息的数据

<!DOCTYPE html>
<html lang="en">
<head>
   <meta charset="UTF-8">
   <meta http-equiv="X-UA-Compatible" content="IE=edge">
   <meta name="viewport" content="width=device-width, initial-scale=1.0">
   <title>Document</title>
   <script src="https://lf26-cdn-tos.bytecdntp.com/cdn/expire-1-M/vue/2.6.14/vue.min.js" type="application/javascript"></script>
   <script src="https://lf9-cdn-tos.bytecdntp.com/cdn/expire-1-M/axios/0.26.0/axios.min.js" type="application/javascript"></script>
   <script src="https://lf3-cdn-tos.bytecdntp.com/cdn/expire-1-M/qs/6.10.3/qs.js" type="application/javascript"></script>

</head>
<body>
   <div id="app">
       <button @click="onClick1" ref="btn1">请求1</button>
       <button @click="onClick2" ref="btn2">请求2</button>
   </div>
</body>
<script>
//存储请求信息和取消方法的的map对象    
const pendingRequest = new Map();  
//根据请求的信息(请求方式,url,请求get/post数据),产生map的key
function getRequestKey(config){
   const { methodurlparamsdata } = config;
   return [methodurlQs.stringify(params), Qs.stringify(data)].join("&");
}  
//请求拦截器
axios.interceptors.request.use(
   function (config) {
   //根据请求的信息(请求方式,url,请求get/post数据),产生map的key
   let requestKey = getRequestKey(config)
   //判断请求是否重复
   if(pendingRequest.has(requestKey)){
       //取消上次请求
       let cancel = pendingRequest.get(requestKey)
       cancel()
       //删除请求信息
       pendingRequest.delete(requestKey)
  }
   //把请求信息,添加请求到map当中
   // 生成取消方法
   config.cancelToken = config.cancelToken || new axios.CancelToken(cancel => {
       // 把取消方法添加到map
       if (!pendingRequest.has(requestKey)) {
           pendingRequest.set(requestKeycancel)
      }
  })
   return config;
},
(error=> {
    return Promise.reject(error);
}
);
//删除请求信息
function delPendingRequest(config){
   let requestKey = getRequestKey(config)
   if(pendingRequest.has(requestKey)){
       pendingRequest.delete(requestKey)  
  }
}
//响应拦截器
axios.interceptors.response.use(
(response=> {
       //请求成功
       //删除请求的信息
       delPendingRequest(response.config)
       return response;
  },
  (error=> {
       //请求失败
       //不是取消请求的错误
       if (!axios.isCancel(error)){
           //服务器报400,500报错,删除请求信息
           delPendingRequest(error.config || {})
      }
       return Promise.reject(error);
  }
);
let sendPost = function(data){
   return axios({
       url'http://nodejs-cloud-studio-demo-bkzxs.nodejs-cloud-studio-demo.50185620.cn-hangzhou.fc.devsapp.net/test',
       method'post',
       data
  })
}
new Vue({
   el'#app',
   mounted() {
       this.$refs.btn1.click()
       this.$refs.btn2.click()
  },
   methods: {
       // 使用lodash对请求方法做防抖
       //这里有问题,只是对每个按钮的点击事件单独做了防抖,但是两个按钮之间做不到防抖的效果
       onClick1async function(){
           let res = await sendPost({username:'zs'age20})
           console.log('请求1的结果'res.data)
      },
       onClick2async function(){
           let res = await sendPost({username:'zs'age20})
           console.log('请求2的结果'res.data)
      },
  },
})
</script>
</html>

预览

作者:黄金林
来源:juejin.cn/post/7189231050806001719

收起阅读 »

vue阻止重复333请求(上)

项目当中前端代码会遇到同一个请求向服务器发了多次的情况,我们要避免服务器资源浪费,同一个请求一定时间只允许发一次请求(1)如果业务简单,例如同一个按钮防止多次点击,我们可以用定时器做防抖处理 (2)如果业务复杂,例如多个组件通过代码,同一个请求发多次,这个时...
继续阅读 »

背景

项目当中前端代码会遇到同一个请求向服务器发了多次的情况,我们要避免服务器资源浪费,同一个请求一定时间只允许发一次请求

思路

(1)如果业务简单,例如同一个按钮防止多次点击,我们可以用定时器做防抖处理
(2)如果业务复杂,例如多个组件通过代码,同一个请求发多次,这个时候防抖已经不好处理了,最好是对重复的ajax请求统一做取消操作

实现

方式1-通过定时器做防抖处理

(a)概述

效果:当用户连续点击多次同一个按钮,最后一次点击之后,过小段时间后才发起一次请求
原理:每次调用方法后都产生一个定时器,定时器结束以后再发请求,如果重复调用方法,就取消当前的定时器,创建新的定时器,等结束后再发请求,工作当中可以用第三方封装的工具函数例如lodashdebounce方法来简化防抖的代码

(b)代码


<html lang="en">
<head>
   <meta charset="UTF-8">
   <meta http-equiv="X-UA-Compatible" content="IE=edge">
   <meta name="viewport" content="width=device-width, initial-scale=1.0">
   <title>Documenttitle>
   <script src="https://lf26-cdn-tos.bytecdntp.com/cdn/expire-1-M/lodash.js/4.17.21/lodash.min.js" type="application/javascript">script>
   <script src="https://lf26-cdn-tos.bytecdntp.com/cdn/expire-1-M/vue/2.6.14/vue.min.js" type="application/javascript">script>
   <script src="https://lf9-cdn-tos.bytecdntp.com/cdn/expire-1-M/axios/0.26.0/axios.min.js" type="application/javascript">script>
head>
<body>
   <div id="app">
       <button @click="onClick">请求button>
   div>
body>
<script>
// 定义请求接口
function sendPost(data){
   return axios({
       url'https://nodejs-cloud-studio-demo-bkzxs.nodejs-cloud-studio-demo.50185620.cn-hangzhou.fc.devsapp.net/test',
       method'post',
       data
  })
}
new Vue({
   el'#app',
   methods: {
       // 调用lodash的防抖方法debounce,实现连续点击按钮多次,0.3秒后调用1次接口
       onClick_.debounce(async function(){
           let res = await sendPost({username:'zs'age20})
           console.log('请求的结果'res.data)
      }, 300),
  },
})
script>
html>

(c)预览

连接

(d)存在的问题

无法解决多个按钮件的重复请求的发送问题,例如下面两种情况

情况-在点击事件上做防抖

按钮事件间是相互独立的,调用的是不同方法,做不到按钮间防抖效果


<html lang="en">
<head>
   <meta charset="UTF-8">
   <meta http-equiv="X-UA-Compatible" content="IE=edge">
   <meta name="viewport" content="width=device-width, initial-scale=1.0">
   <title>Documenttitle>
   <script src="https://lf26-cdn-tos.bytecdntp.com/cdn/expire-1-M/lodash.js/4.17.21/lodash.min.js" type="application/javascript">script>
   <script src="https://lf26-cdn-tos.bytecdntp.com/cdn/expire-1-M/vue/2.6.14/vue.min.js" type="application/javascript">script>
   <script src="https://lf9-cdn-tos.bytecdntp.com/cdn/expire-1-M/axios/0.26.0/axios.min.js" type="application/javascript">script>
head>
<body>
   <div id="app">
       <button @click="onClick1" ref="btn1">请求1button>
       <button @click="onClick2" ref="btn2">请求2button>
   div>
body>
<script>
 
let sendPost = function(data){
   return axios({
       url'http://nodejs-cloud-studio-demo-bkzxs.nodejs-cloud-studio-demo.50185620.cn-hangzhou.fc.devsapp.net/test',
       method'post',
       data
  })
}
new Vue({
   el'#app',
   mounted() {
       this.$refs.btn1.click()
       this.$refs.btn2.click()
  },
   methods: {
       // 使用lodash对请求方法做防抖
       //这里有问题,只是对每个按钮的点击事件单独做了防抖,但是两个按钮之间做不到防抖的效果
       onClick1_.debounce(async function(){
           let res = await sendPost({username:'zs'age20})
           console.log('请求1的结果'res.data)
      }, 300),
       onClick2_.debounce(async function(){
           let res = await sendPost({username:'zs'age20})
           console.log('请求2的结果'res.data)
      }, 300),
  },
})
script>
html>

预览

情况2-在接口方法做防抖

按钮间调用的方法是相同的,是可以对方法做防抖处理,但是处理本身对方法做了一次封装,会影响到之前方法的返回值接收,需要对之前的方法做更多处理,变得更加复杂,不推荐


<html lang="en">
<head>
   <meta charset="UTF-8">
   <meta http-equiv="X-UA-Compatible" content="IE=edge">
   <meta name="viewport" content="width=device-width, initial-scale=1.0">
   <title>Documenttitle>
   <script src="https://lf26-cdn-tos.bytecdntp.com/cdn/expire-1-M/lodash.js/4.17.21/lodash.min.js" type="application/javascript">script>
   <script src="https://lf26-cdn-tos.bytecdntp.com/cdn/expire-1-M/vue/2.6.14/vue.min.js" type="application/javascript">script>
   <script src="https://lf9-cdn-tos.bytecdntp.com/cdn/expire-1-M/axios/0.26.0/axios.min.js" type="application/javascript">script>
head>
<body>
   <div id="app">
       <button @click="onClick1" ref="btn1">请求1button>
       <button @click="onClick2" ref="btn2">请求2button>
   div>
body>
<script>
// 使用lodash对请求方法做防抖,    
let sendPost = _.debounce(function(data){
   //这里有问题,这里的返回值不能作为sendPost方法执行的返回值,因为debounce内部包裹了一层
   return axios({
       url'http://nodejs-cloud-studio-demo-bkzxs.nodejs-cloud-studio-demo.50185620.cn-hangzhou.fc.devsapp.net/test',
       method'post',
       data
  })
}, 300)
new Vue({
   el'#app',
   mounted() {
       this.$refs.btn1.click()
       this.$refs.btn2.click()
  },
   methods: {
       onClick1async function(){
           //这里有问题,sendPost返回值不是promise,而是undefined
           let res = await sendPost({username:'zs'age20})
           console.log('请求1的结果'res)
      },
       onClick2async function(){
           let res = await sendPost({username:'zs'age20})
           console.log('请求2的结果'res)
      },
  },
})
script>
html>

预览

方式2-通过取消ajax请求

(a) 概述

直接对请求方法做处理,通过ajax库的api方法把重复的请求给取消掉

(b)原理

原生ajax取消请求

通过调用XMLHttpRequest对象实例的abort方法把请求给取消掉


<html lang="en">
<head>
   <meta charset="UTF-8">
   <meta http-equiv="X-UA-Compatible" content="IE=edge">
   <meta name="viewport" content="width=device-width, initial-scale=1.0">
   <title>Documenttitle>
head>
<body>
body>
<script>
//原生ajax的语法    
let xhr = new XMLHttpRequest();
xhr.open("GET""http://nodejs-cloud-studio-demo-bkzxs.nodejs-cloud-studio-demo.50185620.cn-hangzhou.fc.devsapp.net/test?username=zs&age=20"true);
xhr.onload = function(){
   console.log(xhr.responseText)
}
xhr.send();
//在谷歌浏览器的低速3g下面测试
//通过XMLHttpRequest实例的abort方法取消请求
setTimeout(() => xhr.abort(), 100);
script>
html>

预览

axios取消请求

通过axiosCancelToken对象实例cancel方法把请求给取消掉


<html lang="en">
<head>
   <meta charset="UTF-8">
   <meta http-equiv="X-UA-Compatible" content="IE=edge">
   <meta name="viewport" content="width=device-width, initial-scale=1.0">
   <title>Documenttitle>
   <script src="https://lf9-cdn-tos.bytecdntp.com/cdn/expire-1-M/axios/0.26.0/axios.min.js" type="application/javascript">script>
head>
<body>
body>
<script>
/*axios的取消的语法*/
// 方式1-通过axios.CancelToken.source产生cancelToken和cancel方法
/*
const source = axios.CancelToken.source();
axios.get('http://nodejs-cloud-studio-demo-bkzxs.nodejs-cloud-studio-demo.50185620.cn-hangzhou.fc.devsapp.net/test', {
  params: {username: 'zs', age: 20},
  cancelToken: source.token
}).then(res=>{
  console.log('res', res.data)
}).catch(err=>{
  console.log('err', err)
})
//在谷歌浏览器的低速3g下面测试
//通过调用source的cancel方法取消
setTimeout(() => source.cancel(), 100);
*/

/**/
// 方式2-通过new axios.CancelToken产生cancelToken和cancel方法
let cancelFn
const cancelToken =  new axios.CancelToken(cancel=>{
   cancelFn = cancel
});
axios.get('http://nodejs-cloud-studio-demo-bkzxs.nodejs-cloud-studio-demo.50185620.cn-hangzhou.fc.devsapp.net/test', {
   params: {username: 'zs'age: 20},
   cancelToken: cancelToken
}).then(res=>{
   console.log('res'res.data)
}).catch(err=>{
   console.log('err'err)
})
//在谷歌浏览器的低速3g下面测试
//通过调用cancelFn方法取消
setTimeout(() => cancelFn(), 100);

script>
html>

预览

续:vue阻止重复请求(下)

作者:黄金林
来源:juejin.cn/post/7189231050806001719

收起阅读 »

基于 Android 系统方案适配 Night Mode 后,老板要再加一套皮肤?

背景说明原本已经基于系统方案适配了暗黑主题,实现了白/黑两套皮肤,以及跟随系统。后来老板研究学习友商时,发现友商 App 有三套皮肤可选,除了常规的亮白和暗黑,还有一套暗蓝色。并且在跟随系统暗黑模式下,用户可选暗黑还是暗蓝。这不,新的需求马上就来了。其实我们之...
继续阅读 »

背景说明

原本已经基于系统方案适配了暗黑主题,实现了白/黑两套皮肤,以及跟随系统。后来老板研究学习友商时,发现友商 App 有三套皮肤可选,除了常规的亮白和暗黑,还有一套暗蓝色。并且在跟随系统暗黑模式下,用户可选暗黑还是暗蓝。这不,新的需求马上就来了。

其实我们之前两个 App 的换肤方案都是使用 Android-skin-support 来做的,在此基础上再加套皮肤也不是难事。但在新的 App 实现多皮肤时,由于前两个 App 做了这么久都只有两套皮肤,而且新的 App 需要实现跟随系统,为了更好的体验和较少的代码实现,就采用了系统方案进行适配暗黑模式。

Android-skin-support 和系统两种方案适配经验来看,系统方案适配改动的代码更少,所花费的时间当然也就更少了。所以在需要新添一套皮肤的时候,也不可能再去切方案了。那么在使用系统方案的情况下,如何再加一套皮肤呢?来,先看源码吧。

源码分析

以下源码基于 android-31

首先,在代码中获取资源一般通过 Context 对象的一些方法,例如:

// Context.java

@ColorInt
public final int getColor(@ColorRes int id) {
   return getResources().getColor(id, getTheme());
}

@Nullable
public final Drawable getDrawable(@DrawableRes int id) {
   return getResources().getDrawable(id, getTheme());
}

可以看到 Context 是通过 Resources 对象再去获取的,继续看 Resources

// Resources.java

@ColorInt
public int getColor(@ColorRes int id, @Nullable Theme theme) throws NotFoundException {
   final TypedValue value = obtainTempTypedValue();
   try {
       final ResourcesImpl impl = mResourcesImpl;
       impl.getValue(id, value, true);
       if (value.type >= TypedValue.TYPE_FIRST_INT
           && value.type <= TypedValue.TYPE_LAST_INT) {
           return value.data;
      } else if (value.type != TypedValue.TYPE_STRING) {
           throw new NotFoundException("Resource ID #0x" + Integer.toHexString(id)                                 + " type #0x" + Integer.toHexString(value.type) + " is not valid");
      }
       // 这里调用 ResourcesImpl#loadColorStateList 方法获取颜色
       final ColorStateList csl = impl.loadColorStateList(this, value, id, theme);
       return csl.getDefaultColor();
  } finally {
    releaseTempTypedValue(value);
  }
}

public Drawable getDrawable(@DrawableRes int id, @Nullable Theme theme)
       throws NotFoundException {
   return getDrawableForDensity(id, 0, theme);
}

@Nullable
public Drawable getDrawableForDensity(@DrawableRes int id, int density, @Nullable Theme theme) {
   final TypedValue value = obtainTempTypedValue();
   try {
       final ResourcesImpl impl = mResourcesImpl;
       impl.getValueForDensity(id, density, value, true);
    // 看到这里
       return loadDrawable(value, id, density, theme);
  } finally {
    releaseTempTypedValue(value);
  }
}

@NonNull
@UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.R, trackingBug = 170729553)
Drawable loadDrawable(@NonNull TypedValue value, int id, int density, @Nullable Theme theme)
       throws NotFoundException {
   // 这里调用 ResourcesImpl#loadDrawable 方法获取 drawable 资源
   return mResourcesImpl.loadDrawable(this, value, id, density, theme);
}

到这里我们知道在代码中获取资源时,是通过 Context -> Resources -> ResourcesImpl 调用链实现的。

先看 ResourcesImpl.java

/**
* The implementation of Resource access. This class contains the AssetManager and all caches
* associated with it.
*
* {@link Resources} is just a thing wrapper around this class. When a configuration change
* occurs, clients can retain the same {@link Resources} reference because the underlying
* {@link ResourcesImpl} object will be updated or re-created.
*
* @hide
*/
public class ResourcesImpl {
  ...
}

虽然是 public 的类,但是被 @hide 标记了,意味着想通过继承后重写相关方法这条路行不通了,pass。

再看 Resources.java,同样是 public 类,但没被 @hide 标记。我们就可以通过继承 Resources 类,然后重写 Resources#getColorResources#getDrawableForDensity 等方法来改造获取资源的逻辑。

先看相关代码:

// SkinResources.kt

class SkinResources(context: Context, res: Resources) : Resources(res.assets, res.displayMetrics, res.configuration) {

   val contextRef: WeakReference<Context> = WeakReference(context)

   override fun getDrawableForDensity(id: Int, density: Int, theme: Theme?): Drawable? {
       return super.getDrawableForDensity(resetResIdIfNeed(contextRef.get(), id), density, theme)
  }

   override fun getColor(id: Int, theme: Theme?): Int {
       return super.getColor(resetResIdIfNeed(contextRef.get(), id), theme)
  }
 
   private fun resetResIdIfNeed(context: Context?, resId: Int): Int {
       // 非暗黑蓝无需替换资源 ID
       if (context == null || !UIUtil.isNightBlue(context)) return resId

       var newResId = resId
       val res = context.resources
       try {
           val resPkg = res.getResourcePackageName(resId)
           // 非本包资源无需替换
           if (context.packageName != resPkg) return newResId

           val resName = res.getResourceEntryName(resId)
           val resType = res.getResourceTypeName(resId)
        // 获取对应暗蓝皮肤的资源 id
           val id = res.getIdentifier("${resName}_blue", resType, resPkg)
           if (id != 0) newResId = id
      } finally {
           return newResId
      }
  }

}

主要原理与逻辑:

  • 所有资源都会在 R.java 文件中生成对应的资源 id,而我们正是通过资源 id 来获取对应资源的。

  • Resources 类提供了 getResourcePackageName/getResourceEntryName/getResourceTypeName 方法,可通过资源 id 获取对应的资源包名/资源名称/资源类型。

  • 过滤掉无需替换资源的场景。

  • Resources 还提供了 getIdentifier 方法来获取对应资源 id。

  • 需要适配暗蓝皮肤的资源,统一在原资源名称的基础上加上 _blue 后缀。

  • 通过 Resources#getIdentifier 方法获取对应暗蓝皮肤的资源 id。如果没找到,改方法会返回 0

现在就可以通过 SkinResources 来获取适配多皮肤的资源了。但是,之前的代码都是通过 Context 直接获取的,如果全部替换成 SkinResources 来获取,那代码改动量就大了。

我们回到前面 Context.java 的源码,可以发现它获取资源时,都是通过 Context#getResources 方法先得到 Resources 对象,再通过其去获取资源的。而 Context#getResources 方法也是可以重写的,这意味着我们可以维护一个自己的 Resources 对象。ApplicationActivity 也都是继承自 Context 的,所以我们在其子类中重写 getResources 方法即可:

// BaseActivity.java/BaseApplication.java

private Resources mSkinResources;

@Override
public Resources getResources() {
   if (mSkinResources == null) {
    mSkinResources = new SkinResources(this, super.getResources());
  }
   return mSkinResources;
}

到此,基本逻辑就写完了,马上 build 跑起来。

咦,好像有点不太对劲,有些 colordrawable 没有适配成功。

经过一番对比,发现 xml 布局中的资源都没有替换成功。

那么问题在哪呢?还是先从源码着手,先来看看 View 是如何从 xml 中获取并设置 background 属性的:

// View.java

public View(Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) {
this(context);

   // AttributeSet 是 xml 中所有属性的集合
   // TypeArray 则是经过处理过的集合,将原始的 xml 属性值("@color/colorBg")转换为所需的类型,并应用主题和样式
   final TypedArray a = context.obtainStyledAttributes(
           attrs, com.android.internal.R.styleable.View, defStyleAttr, defStyleRes);

  ...
   
   Drawable background = null;

  ...
   
   final int N = a.getIndexCount();
for (int i = 0; i < N; i++) {
    int attr = a.getIndex(i);
    switch (attr) {
           case com.android.internal.R.styleable.View_background:
               // TypedArray 提供一些直接获取资源的方法
            background = a.getDrawable(attr);
            break;
          ...
      }
  }
 
  ...
   
   if (background != null) {
    setBackground(background);
  }
 
  ...
}

再接着看 TypedArray 是如何获取资源的:

// TypedArray.java

@Nullable
public Drawable getDrawable(@StyleableRes int index) {
   return getDrawableForDensity(index, 0);
}

@Nullable
public Drawable getDrawableForDensity(@StyleableRes int index, int density) {
   if (mRecycled) {
    throw new RuntimeException("Cannot make calls to a recycled instance!");
  }

   final TypedValue value = mValue;
   if (getValueAt(index * STYLE_NUM_ENTRIES, value)) {
       if (value.type == TypedValue.TYPE_ATTRIBUTE) {
           throw new UnsupportedOperationException(
               "Failed to resolve attribute at index " + index + ": " + value);
      }

       if (density > 0) {
           // If the density is overridden, the value in the TypedArray will not reflect this.
           // Do a separate lookup of the resourceId with the density override.
           mResources.getValueForDensity(value.resourceId, density, value, true);
      }
    // 看到这里
       return mResources.loadDrawable(value, value.resourceId, density, mTheme);
  }
   return null;
}

TypedArray 是通过 Resources#loadDrawable 方法来加载资源的,而我们之前写 SkinResources 的时候并没有重写该方法,为什么呢?那是因为该方法是被 @UnsupportedAppUsage 标记的。所以,这就是 xml 布局中的资源替换不成功的原因。

这个问题又怎么解决呢?

之前采用 Android-skin-support 方案做换肤时,了解到它的原理,其会替换成自己的实现的 LayoutInflater.Factory2,并在创建 View 时替换生成对应适配了换肤功能的 View 对象。例如:将 View 替换成 SkinView,而 SkinView 初始化时再重新处理 background 属性,即可完成换肤。

AppCompat 也是同样的逻辑,通过 AppCompatViewInflater 将普通的 View 替换成带 AppCompat- 前缀的 View。

其实我们只需能操作生成后的 View,并且知道 xml 中写了哪些属性值即可。那么我们完全照搬 AppCompat 这套逻辑即可:

  • 定义类继承 LayoutInflater.Factory2,并实现 onCreateView 方法。

  • onCreateView 主要是创建 View 的逻辑,而这部分逻辑完全 copy AppCompatViewInflater 类即可。

  • onCreateView 中创建 View 之后,返回 View 之前,实现我们自己的逻辑。

  • 通过 LayoutInflaterCompat#setFactory2 方法,设置我们自己的 Factory2。

相关代码片段:

public class SkinViewInflater implements LayoutInflater.Factory2 {
   @Nullable
   @Override
   public View onCreateView(@Nullable View parent, @NonNull String name, @NonNull Context context, @NonNull AttributeSet attrs) {
    // createView 方法就是 AppCompatViewInflater 中的逻辑
       View view = createView(parent, name, context, attrs, false, false, true, false);
       onViewCreated(context, view, attrs);
       return view;
  }

   @Nullable
   @Override
   public View onCreateView(@NonNull String name, @NonNull Context context, @NonNull AttributeSet attrs) {
       return onCreateView(null, name, context, attrs);
  }

   private void onViewCreated(@NonNull Context context, @Nullable View view, @NonNull AttributeSet attrs) {
    if (view == null) return;
       resetViewAttrsIfNeed(context, view, attrs);
  }
 
   private void resetViewAttrsIfNeed(Context context, View view, AttributeSet attrs) {
    if (!UIUtil.isNightBlue(context)) return;
     
    String ANDROID_NAMESPACE = "http://schemas.android.com/apk/res/android";
    String BACKGROUND = "background";
     
    // 获取 background 属性值的资源 id,未找到时返回 0
    int backgroundId = attrs.getAttributeResourceValue(ANDROID_NAMESPACE, BACKGROUND, 0);
    if (backgroundId != 0) {
           view.setBackgroundResource(resetResIdIfNeed(context, backgroundId));
      }
  }
}
// BaseActivity.java

@Override
public void onCreate(Bundle savedInstanceState) {
   super.onCreate(savedInstanceState);
   SkinViewInflater inflater = new SkinViewInflater();
   LayoutInflater layoutInflater = LayoutInflater.from(this);
   // 生成 View 的逻辑替换成我们自己的
   LayoutInflaterCompat.setFactory2(layoutInflater, inflater);
}

至此,这套方案已经可以解决目前的换肤需求了,剩下的就是进行细节适配了。

其他说明

自定义控件与第三方控件适配

上面只对 background 属性进行了处理,其他需要进行换肤的属性也是同样的处理逻辑。如果是自定义的控件,可以在初始化时调用 TypedArray#getResourceId 方法先获取资源 id,再通过 context 去获取对应资源,而不是使用 TypedArray#getDrawable 类似方法直接获取资源对象,这样可以确保换肤成功。而第三方控件也可通过 background 属性同样的处理逻辑进行适配。

XML <shape> 的处理

<!-- bg.xml -->
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<corners android:radius="8dp" />
<solid android:color="@color/background" />
</shape>

上面的 bg.xml 文件内的 color 并不会完成资源替换,根据上面的逻辑,需要新增以下内容:

<!-- bg_blue.xml -->
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<corners android:radius="8dp" />
<solid android:color="@color/background_blue" />
</shape>

如此,资源替换才会成功。

设计的配合

这次对第三款皮肤的适配还是蛮轻松的,主要是有以下基础:

  • 在适配暗黑主题的时候,设计有出设计规范,后续开发按照设计规范来。

  • 暗黑和暗蓝共用一套图片资源,大大减少适配工作量。

  • 暗黑和暗蓝部份共用颜色值含透明度,同样减少了工作量,仅少量颜色需要新增。

这次适配的主要工作量还是来自 <shape> 的替换。

暗蓝皮肤资源文件的归处

我知道很多换肤方案都会将皮肤资源制作成皮肤包,但是这个方案没有这么做。一是没有那么多需要替换的资源,二是为了减少相应的工作量。

我新建了一个资源文件夹,与 res 同级,取名 res-blue。并在 gradle 配置文件中配置它。编译后系统会自动将它们合并,同时也能与常规资源文件隔离开来。

// build.gradle
sourceSets {
main {
java {
srcDir 'src/main/java'
}
res.srcDirs += 'src/main/res'
res.srcDirs += 'src/main/res-blue'
}
}

有哪些坑?

WebView 资源缺失导致闪退

版本上线后,发现有 android.content.res.Resources$NotFoundException 异常上报,具体异常堆栈信息:

android.content.res.ResourcesImpl.getValue(ResourcesImpl.java:321)
android.content.res.Resources.getInteger(Resources.java:1279)
org.chromium.ui.base.DeviceFormFactor.b(chromium-TrichromeWebViewGoogle.apk-stable-447211483:4)
org.chromium.content.browser.selection.SelectionPopupControllerImpl.n(chromium-TrichromeWebViewGoogle.apk-stable-447211483:1)
N7.onCreateActionMode(chromium-TrichromeWebViewGoogle.apk-stable-447211483:8)
Gu.onCreateActionMode(chromium-TrichromeWebViewGoogle.apk-stable-447211483:2)
com.android.internal.policy.DecorView$ActionModeCallback2Wrapper.onCreateActionMode(DecorView.java:3255)
com.android.internal.policy.DecorView.startActionMode(DecorView.java:1159)
com.android.internal.policy.DecorView.startActionModeForChild(DecorView.java:1115)
android.view.ViewGroup.startActionModeForChild(ViewGroup.java:1087)
android.view.ViewGroup.startActionModeForChild(ViewGroup.java:1087)
android.view.ViewGroup.startActionModeForChild(ViewGroup.java:1087)
android.view.ViewGroup.startActionModeForChild(ViewGroup.java:1087)
android.view.ViewGroup.startActionModeForChild(ViewGroup.java:1087)
android.view.ViewGroup.startActionModeForChild(ViewGroup.java:1087)
android.view.View.startActionMode(View.java:7716)
org.chromium.content.browser.selection.SelectionPopupControllerImpl.I(chromium-TrichromeWebViewGoogle.apk-stable-447211483:10)
Vc0.a(chromium-TrichromeWebViewGoogle.apk-stable-447211483:10)
Vf0.i(chromium-TrichromeWebViewGoogle.apk-stable-447211483:4)
A5.run(chromium-TrichromeWebViewGoogle.apk-stable-447211483:3)
android.os.Handler.handleCallback(Handler.java:938)
android.os.Handler.dispatchMessage(Handler.java:99)
android.os.Looper.loopOnce(Looper.java:233)
android.os.Looper.loop(Looper.java:334)
android.app.ActivityThread.main(ActivityThread.java:8333)
java.lang.reflect.Method.invoke(Native Method)
com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:582)
com.android.internal.os.ZygoteInit.main(ZygoteInit.java:1065)

经查才发现在 WebView 中长按文本弹出操作菜单时,就会引发该异常导致 App 闪退。

这是其他插件化方案也踩过的坑,我们只需在创建 SkinResources 之前将外部 WebView 的资源路径添加进来即可。

@Override
public Resources getResources() {
if (mSkinResources == null) {
WebViewResourceHelper.addChromeResourceIfNeeded(this);
mSkinResources = new SkinResources(this, super.getResources());
}
return mSkinResources;
}

RePlugin/WebViewResourceHelper.java 源码文件

具体问题分析可参考

Fix ResourceNotFoundException in Android 7.0 (or above)

最终效果图

skin_demo.gif

总结

这个方案在原本使用系统方式适配暗黑主题的基础上,通过拦截 Resources 相关获取资源的方法,替换换肤后的资源 id,以达到换肤的效果。针对 XML 布局换肤不成功的问题,复制 AppCompatViewInflater 创建 View 的代码逻辑,并在 View 创建成功后重新设置需要进行换肤的相关 XML 属性。同一皮肤资源使用单独的资源文件夹独立存放,可以与正常资源进行隔离,也避免了制作皮肤包而增加工作量。

目前来说这套方案是改造成本最小,侵入性最小的选择。选择适合自身需求的才是最好的。

作者:ONEW
来源:https://juejin.cn/post/7187282270360141879

收起阅读 »

自定义View模仿即刻点赞数字切换效果

即刻点赞展示点赞的数字增加和减少并不是整个替换,而是差异化替换。再加上动画效果就看的很舒服。自己如何实现这种数字切换呢?下面用一张图来展示我的思路:现在只需要根据这张图,写出对应的动画即可。 分为2种场景:数字+1:差异化的数字从3号区域由渐变动画(透明度 0...
继续阅读 »

即刻点赞展示


点赞的数字增加和减少并不是整个替换,而是差异化替换。再加上动画效果就看的很舒服。

自己如何实现这种数字切换呢?

下面用一张图来展示我的思路:


现在只需要根据这张图,写出对应的动画即可。 分为2种场景:

  • 数字+1:

    • 差异化的数字从3号区域由渐变动画(透明度 0- 255) + 偏移动画 (3号区域绘制文字的基线,2号区域绘制文字的基线),将数字移动到2号位置处

    • 差异化的数字从2号区域由渐变动画(透明度 255- 0) + 偏移动画(2号区域绘制文字的基线,1号区域绘制文字的基线),将数字移动到1号位置处

  • 数字-1

    • 差异化的数字从1号区域由渐变动画(透明度 0- 255) + 偏移动画 (1号区域绘制文字的基线,2号区域绘制文字的基线),将数字移动到2号位置处

    • 差异化的数字从2号区域由渐变动画(透明度 255- 0) + 偏移动画(2号区域绘制文字的基线,3号区域绘制文字的基线),将数字移动到3号位置处

公共部分就是: 不变的文字不需要做任何处理,绘制在2号区域就行。绘制差异化文字时,需要加上不变的文字的宽度就行。

效果展示


源码

class LikeView @JvmOverloads constructor(
  context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {

  private val paint = Paint().also {
      it.isAntiAlias = true
      it.textSize = 200f
  }

  private val textRect0 = Rect(300, 100, 800, 300)
  private val textRect1 = Rect(300, 300, 800, 500)
  private val textRect2 = Rect(300, 500, 800, 700)

  private var nextNumberAlpha: Int = 0
      set(value) {
          field = value
          invalidate()
      }

  private var currentNumberAlpha: Int = 255
      set(value) {
          field = value
          invalidate()
      }

  private var offsetPercent = 0f
      set(value) {
          field = value
          invalidate()
      }

  private val fontMetrics: FontMetrics = paint.fontMetrics
  private var currentNumber = 99
  private var nextNumber = 0
  private var motionLess = currentNumber.toString()
  private var currentMotion = ""
  private var nextMotion = ""

  private val animator: ObjectAnimator by lazy {
      val nextNumberAlphaAnimator = PropertyValuesHolder.ofInt("nextNumberAlpha", 0, 255)
      val offsetPercentAnimator = PropertyValuesHolder.ofFloat("offsetPercent", 0f, 1f)
      val currentNumberAlphaAnimator = PropertyValuesHolder.ofInt("currentNumberAlpha", 255, 0)
      val animator = ObjectAnimator.ofPropertyValuesHolder(
          this,
          nextNumberAlphaAnimator,
          offsetPercentAnimator,
          currentNumberAlphaAnimator
      )
      animator.duration = 200
      animator.interpolator = DecelerateInterpolator()
      animator.addListener(
          onEnd = {
              currentNumber = nextNumber
          }
      )
      animator
  }

  override fun onDraw(canvas: Canvas) {
      paint.alpha = 255

      paint.color = Color.LTGRAY
      canvas.drawRect(textRect0, paint)

      paint.color = Color.RED
      canvas.drawRect(textRect1, paint)

      paint.color = Color.GREEN
      canvas.drawRect(textRect2, paint)

      paint.color = Color.BLACK
      if (motionLess.isNotEmpty()) {
          drawText(canvas, motionLess, textRect1, 0f)
      }

      if (nextMotion.isEmpty() || currentMotion.isEmpty()) {
          return
      }

      val textHorizontalOffset =
          if (motionLess.isNotEmpty()) paint.measureText(motionLess) else 0f
      if (nextNumber > currentNumber) {
          paint.alpha = currentNumberAlpha
          drawText(canvas, currentMotion, textRect1, textHorizontalOffset, -offsetPercent)
          paint.alpha = nextNumberAlpha
          drawText(canvas, nextMotion, textRect2, textHorizontalOffset, -offsetPercent)
      } else {
          paint.alpha = nextNumberAlpha
          drawText(canvas, nextMotion, textRect0, textHorizontalOffset, offsetPercent)
          paint.alpha = currentNumberAlpha
          drawText(canvas, currentMotion, textRect1, textHorizontalOffset, offsetPercent)
      }
  }

  private fun drawText(
      canvas: Canvas,
      text: String,
      rect: Rect,
      textHorizontalOffset: Float = 0f,
      offsetPercent: Float = 0f
  ) {
      canvas.drawText(
          text,
          rect.left.toFloat() + textHorizontalOffset,
          rect.top + (rect.bottom - rect.top) / 2f - (fontMetrics.bottom + fontMetrics.top) / 2f + offsetPercent * 200,
          paint
      )
  }


  override fun onDetachedFromWindow() {
      super.onDetachedFromWindow()
      animator.end()
  }

  fun plus() {
      if (currentNumber == Int.MAX_VALUE) {
          return
      }
      nextNumber = currentNumber + 1

      processText(findEqualsStringIndex())

      if (animator.isRunning) {
          return
      }
      animator.start()
  }

  fun minus() {
      if (currentNumber == 0) {
          return
      }
      nextNumber = currentNumber - 1
      processText(findEqualsStringIndex())
      if (animator.isRunning) {
          return
      }
      animator.start()
  }

  private fun findEqualsStringIndex(): Int {
      var equalIndex = -1
      val nextNumberStr = nextNumber.toString()
      val currentNumberStr = currentNumber.toString()

      val endIndex = min(currentNumberStr.length, nextNumberStr.length) - 1

      for (index in 0..endIndex) {
          if (nextNumberStr[index] != currentNumberStr[index]) {
              break
          }
          equalIndex = index
      }
      return equalIndex
  }

  private fun processText(index: Int) {
      val currentNumberStr = currentNumber.toString()
      val nextNumberStr = nextNumber.toString()
      if (index == -1) {
          motionLess = ""
          currentMotion = currentNumberStr
          nextMotion = nextNumberStr
      } else {
          motionLess = currentNumberStr.substring(0, index + 1)
          currentMotion = currentNumberStr.substring(index + 1)
          nextMotion = nextNumberStr.substring(index + 1)
      }
  }
}

作者:timer
来源:juejin.cn/post/7179181214530551867

收起阅读 »

android 原生安全音量逻辑设计

前言 接到一个开发需求,需要定制化开发一个安全音量功能;此前有了解过为了符合欧盟等有关国家和地区的规定,原生Android是有自带一个安全音量功能的,想要定制则先要了解这个功能原先长什么样子,下面我们就从一个系统工程师的角度出发去探寻一下,原生Android的...
继续阅读 »

前言


接到一个开发需求,需要定制化开发一个安全音量功能;此前有了解过为了符合欧盟等有关国家和地区的规定,原生Android是有自带一个安全音量功能的,想要定制则先要了解这个功能原先长什么样子,下面我们就从一个系统工程师的角度出发去探寻一下,原生Android的安全音量功能是如何实现的。


安全音量配置


安全音量的相关配置都在framework的config.xml里面,可以直接修改或者overlay配置修改其默认值。


<!-- Whether safe headphone volume is enabled or not (country specific). -->
<bool name="config_safe_media_volume_enabled">true</bool>

<!-- Safe headphone volume index. When music stream volume is below this index
the SPL on headphone output is compliant to EN 60950 requirements for portable music
players. -->
<integer name="config_safe_media_volume_index">10</integer>

config_safe_media_volume_enabled是安全音量功能的总开关,config_safe_media_volume_index则是表明触发安全音量弹框的音量大小值。


安全音量相关流程


安全音量的主要流程都在AudioService里面,其大致流程如下图所示:


MSG_CONFIGURE_SAFE_MEDIA_VOLUME_FORCED
onSystemReady
onConfigureSafeVolume
checkSafeMediaVolume
AudioManager
adjustStreamVolume
setStreamVolume
showSafetyWarningH

onSystemReady 初始化


系统启动过程略去不表,在系统启动完成后会调用onSystemReady;在onSystemReady中,service会发送一个MSG_CONFIGURE_SAFE_MEDIA_VOLUME_FORCED的msg,强制配置安全音量。


public void onSystemReady() {
...
sendMsg(mAudioHandler,
MSG_CONFIGURE_SAFE_MEDIA_VOLUME_FORCED,
SENDMSG_REPLACE,
0,
0,
TAG,
SystemProperties.getBoolean("audio.safemedia.bypass", false) ?
0 : SAFE_VOLUME_CONFIGURE_TIMEOUT_MS);
...
}

发送的MSG_CONFIGURE_SAFE_MEDIA_VOLUME_FORCED会调用onConfigureSafeVolume()来进行安全音量的配置


onConfigureSafeVolume() 安全音量配置


    private void onConfigureSafeVolume(boolean force, String caller) {
synchronized (mSafeMediaVolumeStateLock) {
//Mobile contry code,国家代码,主要用来区分不同国家,部分国家策略可能会不一致
int mcc = mContext.getResources().getConfiguration().mcc;
if ((mMcc != mcc) || ((mMcc == 0) && force)) {
//从config_safe_media_volume_index中获取回来的安全音量触发阈值
mSafeMediaVolumeIndex = mContext.getResources().getInteger(
com.android.internal.R.integer.config_safe_media_volume_index) * 10;

mSafeUsbMediaVolumeIndex = getSafeUsbMediaVolumeIndex();

//根据audio.safemedia.force属性值或者value配置的值来决定是否使能安全音量
boolean safeMediaVolumeEnabled =
SystemProperties.getBoolean("audio.safemedia.force", false)
|| mContext.getResources().getBoolean(
com.android.internal.R.bool.config_safe_media_volume_enabled);

//确认是否需要bypass掉安全音量功能
boolean safeMediaVolumeBypass =
SystemProperties.getBoolean("audio.safemedia.bypass", false);

// The persisted state is either "disabled" or "active": this is the state applied
// next time we boot and cannot be "inactive"
int persistedState;
if (safeMediaVolumeEnabled && !safeMediaVolumeBypass) {
persistedState = SAFE_MEDIA_VOLUME_ACTIVE; //这个值只能是disable或者active,不能是inactive,主要用于下次启动。
// The state can already be "inactive" here if the user has forced it before
// the 30 seconds timeout for forced configuration. In this case we don't reset
// it to "active".
if (mSafeMediaVolumeState != SAFE_MEDIA_VOLUME_INACTIVE) {
if (mMusicActiveMs == 0) { //mMusicActiveMs主要用于计数,当安全音量弹框弹出时,如果按了确定,这个值便开始递增,当其达到UNSAFE_VOLUME_MUSIC_ACTIVE_MS_MAX时,则重新使能安全音量
mSafeMediaVolumeState = SAFE_MEDIA_VOLUME_ACTIVE;
enforceSafeMediaVolume(caller);
} else {
//跑到这里则表示已经弹过安全音量警示了,并且按了确定,所以把值设置为inactive
// We have existing playback time recorded, already confirmed.
mSafeMediaVolumeState = SAFE_MEDIA_VOLUME_INACTIVE;
}
}
} else {
persistedState = SAFE_MEDIA_VOLUME_DISABLED;
mSafeMediaVolumeState = SAFE_MEDIA_VOLUME_DISABLED;
}
mMcc = mcc;
//持久化当前安全音量的状态
sendMsg(mAudioHandler,
MSG_PERSIST_SAFE_VOLUME_STATE,
SENDMSG_QUEUE,
persistedState,
0,
null,
0);
}
}
}

由上可知,onConfigureSafeVolume()主要用于配置和使能安全音量功能,并且通过发送MSG_PERSIST_SAFE_VOLUME_STATE来持久化安全音量配置的值,这个持久化的值只能是active或者disabled。


case MSG_PERSIST_SAFE_VOLUME_STATE:
onPersistSafeVolumeState(msg.arg1);
break;
....
....
private void onPersistSafeVolumeState(int state) {
Settings.Global.putInt(mContentResolver,
Settings.Global.AUDIO_SAFE_VOLUME_STATE,
state);
}

安全音量触发


从实际操作可知,安全音量触发条件是:音量增大到指定值。
从调节音量的代码出发,在调用mAudioManager.adjustStreamVolume和mAudioManager.setStreamVolume时,最终会调用到AudioService中的同名方法,在执行该方法的内部:


protected void adjustStreamVolume(int streamType, int direction, int flags,
String callingPackage, String caller, int uid) {
...
...
...
} else if ((direction == AudioManager.ADJUST_RAISE) &&
!checkSafeMediaVolume(streamTypeAlias, aliasIndex + step, device)) {
Log.e(TAG, "adjustStreamVolume() safe volume index = " + oldIndex);
mVolumeController.postDisplaySafeVolumeWarning(flags);
....
...

private void setStreamVolume(int streamType, int index, int flags, String callingPackage,
String caller, int uid) {
....
....
if (!checkSafeMediaVolume(streamTypeAlias, index, device)) {
mVolumeController.postDisplaySafeVolumeWarning(flags);
mPendingVolumeCommand = new StreamVolumeCommand(
streamType, index, flags, device);
} else {
onSetStreamVolume(streamType, index, flags, device, caller);
index = mStreamStates[streamType].getIndex(device);
}
....
....

由以上代码可以看出,其安全音量弹框警告的触发地方就在checkSafeMediaVolume方法附近处,并且都是通过mVolumeController这个远程服务去调用UI显示安全音量弹框警告,但两种调节音量的方法,触发效果略有不同:



  • adjustStreamVolume:当音量步进方向是上升并且checkSafeMediaVolume返回false时,直接弹出警告框;由于警告框占据了焦点,此时无法进行UI操作,并且再按音量+键时,会继续触发这个弹框,导致无法实质性地调整音量;

  • setStreamVolume:当传入的音量形参大于安全音量阈值,会触发checkSafeMediaVolume返回false,弹出安全音量警告框;并且会通过mPendingVolumeCommand保存设置的音量值,待关掉安全音量后再赋回来。


private boolean checkSafeMediaVolume(int streamType, int index, int device) {
synchronized (mSafeMediaVolumeStateLock) {
if ((mSafeMediaVolumeState == SAFE_MEDIA_VOLUME_ACTIVE) &&
(mStreamVolumeAlias[streamType] == AudioSystem.STREAM_MUSIC) &&
((device & mSafeMediaVolumeDevices) != 0) &&
(index > safeMediaVolumeIndex(device))) {
return false;
}
return true;
}
}

以上是安全音量判断条件checkSafeMediaVolume,可以看出其判断主要根据以下条件:



  • mSafeMediaVolumeState是否为active,这个是安全音量功能的开关变量;

  • 音频流是否为STREAM_MUSIC,只针对该音频流做安全音量;

  • 设备类型,默认mSafeMediaVolumeDevices值如下:


    /*package*/ final int mSafeMediaVolumeDevices = AudioSystem.DEVICE_OUT_WIRED_HEADSET
| AudioSystem.DEVICE_OUT_WIRED_HEADPHONE
| AudioSystem.DEVICE_OUT_USB_HEADSET;

由上可知,只针对耳机播放或者USB耳机才做安全音量功能,如有需要系统工程师可自行配置其他设备;



  • 音量大小,只有音量index超过safeMediaVolumeIndex获取的值,才需要弹出安全音量警示框,而safeMediaVolumeIndex的值则是本文开头在config.xml中配置的config_safe_media_volume_index所得出的;


UI部分


上面有提到,当满足安全音量警示框的触发条件时,会通过mVolumeController这个远程服务去调用UI显示安全音量弹框警告,其调用链条有点长,中途略过不表,其最终会走到VolumeDialogImpl.java的showSafetyWarningH,如下:


public class VolumeDialog {
...
private void showSafetyWarningH(int flags) {
if ((flags & (AudioManager.FLAG_SHOW_UI | AudioManager.FLAG_SHOW_UI_WARNINGS)) != 0
|| mShowing) {
synchronized (mSafetyWarningLock) {
if (mSafetyWarning != null) {
return;
}
mSafetyWarning = new SafetyWarningDialog(mContext, mController.getAudioManager()) {
@Override
protected void cleanUp() {
synchronized (mSafetyWarningLock) {
mSafetyWarning = null;
}
recheckH(null);
}
};
mSafetyWarning.show();
}
recheckH(null);
}
rescheduleTimeoutH();
}
...
}

UI配置部分主要在SafetyWarningDialog.java,代码就不贴了,可自行查看,其本质是一个对话框,在弹出时会抢占UI焦点,如果不点击确定或取消,则无法操作其他UI;点击确定后,会调用mAudioManager.disableSafeMediaVolume()来暂时关闭安全音量警告功能,但上面有提到,当点击确定之后其实是启动了一个变量mMusicActiveMs的计数,当这个计数到达一定值(默认是20个小时),安全音量会重新启动;但如果点击了取消,再继续调大音量时,安全音量弹框还是会继续弹出;


disableSafeMediaVolume()


上面有提到,在安全音量弹框弹出后,点击确定可以暂时关闭安全音量警告功能,其实最终会调用到AudioService中的disableSafeMediaVolume(),代码如下:


public void disableSafeMediaVolume(String callingPackage) {
enforceVolumeController("disable the safe media volume");
synchronized (mSafeMediaVolumeStateLock) {
setSafeMediaVolumeEnabled(false, callingPackage);
if (mPendingVolumeCommand != null) {
onSetStreamVolume(mPendingVolumeCommand.mStreamType,
mPendingVolumeCommand.mIndex,
mPendingVolumeCommand.mFlags,
mPendingVolumeCommand.mDevice,
callingPackage);
mPendingVolumeCommand = null;
}
}
}

一方面是调用setSafeMediaVolumeEnabled来暂时关闭安全音量功能,另一方面会把此前临时挂起的设置音量mPendingVolumeCommand重新设置回去。


小结


简单来讲,Android原生的安全音量功能默认强制打开,在插入耳机后,音量调节到指定阈值时,会触发音量警告弹框,该弹框会抢走焦点,不点击确定或取消无法进行其他操作;在点击确定后,默认操作者本人允许设备音量继续往上调,但此时系统会开始一个默认为20分钟的倒计时,在这20分钟内音量随意调节都不会触发安全音量弹框,但20分钟结束后,音量大于阈值时会继续触发安全音量弹框,提醒使用者注意。


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

落地 Kotlin 代码规范,DeteKt 了解一下~

前言 各个团队多少都有一些自己的代码规范,但制定代码规范简单,困难的是如何落地。如果完全依赖人力Code Review难免有所遗漏。 这个时候就需要通过静态代码检查工具在每次提交代码时自动检查,本文主要介绍如何使用DeteKt落地Kotlin代码规范,主要包括...
继续阅读 »

前言


各个团队多少都有一些自己的代码规范,但制定代码规范简单,困难的是如何落地。如果完全依赖人力Code Review难免有所遗漏。


这个时候就需要通过静态代码检查工具在每次提交代码时自动检查,本文主要介绍如何使用DeteKt落地Kotlin代码规范,主要包括以下内容



  1. 为什么使用DeteKt?

  2. IDE接入DeteKt插件

  3. CLI命令行方式接入DeteKt

  4. Gradle方式接入DeteKt

  5. 自定义Detekt检测规则

  6. Github Action集成Detekt检测


为什么使用DeteKt?


说起静态代码检查,大家首先想起来的可能是lint,相比DeteKt只支持Kotlin代码,lint不仅支持KotlinJava代码,也支持资源文件规范检查,那么我们为什么不使用Lint呢?


在我看来,Lint在使用上主要有两个问题:



  1. IDE集成不够好,自定义lint规则的警告只有在运行./gradlew lint后才会在IDE上展示出来,在clean之后又会消失

  2. lint检查速度较慢,尤其是大型项目,只对增量代码进行检查的逻辑需要自定义


DeteKt提供了IDE插件,开启后可直接在IDE中查看警告,这样可以在第一时间发现问题,避免后续检查发现问题后再修改流程过长的问题


同时Detekt支持CLI命令行方式接入与Gradle方式接入,支持只检查新增代码,在检查速度上比起lint也有一定的优势


IDE接入DeteKt插件


如果能在IDE中提示代码中存在的问题,应该是最快发现问题的方式,DeteKt也贴心的为我们准备了插件,如下所示:



主要可以配置以下内容:



  1. DeteKt开关

  2. 格式化开关,DeteKt直接使用了ktlint的规则

  3. Configuration file:规则配置文件,可以在其中配置各种规则的开关与参数,默认配置可见:default-detekt-config.yml

  4. Baseline file:基线文件,跳过旧代码问题,有了这个基线文件,下次扫描时,就会绕过文件中列出的基线问题,而只提示新增问题。

  5. Plugin jar: 自定义规则jar包,在自定义规则后打出jar包,在扫描时就可以使用自定义规则了


DeteKt IDE插件可以实时提示问题(包括自定义规则),如下图所示,我们添加了自定义禁止使用kae的规则:



对于一些支持自动修复的格式问题,DeteKt插件支持自动格式化,同时也可以配置快捷键,一键自动格式化,如下所示:



CLI命令行方式接入DeteKt


DeteKt支持通过CLI命令行方式接入,支持只检测几个文件,比如本次commit提交的文件


我们可以通过如下方式,下载DeteKtjar然后使用


curl -sSLO https://github.com/detekt/detekt/releases/download/v1.22.0-RC1/detekt-cli-1.22.0-RC1.zip
unzip detekt-cli-1.22.0-RC1.zip
./detekt-cli-1.22.0-RC1/bin/detekt-cli --help

DeteKt CLI支持很多参数,下面列出一些常用的,其他可以参见:Run detekt using Command Line Interface


Usage: detekt [options]
Options:
--auto-correct, -ac
支持自动格式化的规则自动格式化,默认为false
Default: false
--baseline, -b
如果传入了baseline文件,只有不在baseline文件中的问题才会掘出来
--classpath, -cp
实验特性:传入依赖的class路径和jar的路径,用于类型解析
--config, -c
规则配置文件,可以配置规则开关及参数
--create-baseline, -cb
创建baseline,默认false,如果开启会创建出一个baseline文件,供后续使用
--input, -i
输入文件路径,多个路径之间用逗号连接
--jvm-target
EXPERIMENTAL: Target version of the generated JVM bytecode that was
generated during compilation and is now being used for type resolution
(1.6, 1.8, 9, 10, 11, 12, 13, 14, 15, 16 or 17)
Default: 1.8
--language-version
为支持类型解析,需要传入java版本
--plugins, -p
自定义规则jar路径,多个路径之间用,或者;连接

在命令行可以直接通过如下方式检查


java -jar /path/to/detekt-cli-1.21.0-all.jar # detekt-cli-1.21.0-all.jar所在路径
-c /path/to/detekt_1.21.0_format.yml # 规则配置文件所在路径
--plugins /path/to/detekt-formatting-1.21.0.jar # 格式化规则jar,主要基于ktlint封装
-ac # 开启自动格式化
-i $FilePath$ # 需要扫描的源文件,多个路径之间用,或者;连接

通过如上方式进行代码检查速度是非常快的,根据经验来说一般就是几秒之内可以完成,因此我们完成可以将DeteKtgit hook结合起来,在每次提交commit的时候进行检测,而如果是一些比较耗时的工具比如lint,应该是做不到这一点的


类型解析


上面我们提到了,DeteKt--classpth参数与--language-version参数,这些是用于类型解析的。


类型解析是DeteKt的一项功能,它允许 Detekt 对您的 Kotlin 源代码执行更高级的静态分析。


通常,Detekt 在编译期间无法访问编译器语义分析的结果,我们只能获取Kotlin源代码的抽象语法树,却无法知道语法树上符号的语义,这限制了我们的检查能力,比如我们无法判断符号的类型,两个符号究竟是不是同一个对象等


通过启用类型解析,Detekt 可以获取Kotlin编译器语义分析的结果,这让我们可以自定义一些更高级的检查。


而要获取类型与语义,当然要传入依赖的class,也就是classpath,比如android项目中常常需要传入android.jarkotlin-stdlib.jar


Gradle方式接入DeteKt


CLI方式检测虽然快,但是需要手动传入classpath,比较麻烦,尤其是有时候自定义规则需要解析我们自己的类而不是kotlin-stdlib.jar中的类时,那么就需要将项目中的代码的编译结果传入作为classpath了,这样就更麻烦了


DeteKt同样支持Gradle插件方式接入,这种方式不需要我们另外再配置classpath,我们可以将CLI命令行方式与Gradle方式结合起来,在本地通过CLI方式快速检测,在CI上通过Gradle插件进行完整的检测


接入步骤


// 1. 引入插件
plugins {
id("io.gitlab.arturbosch.detekt").version("[version]")
}

repositories {
mavenCentral()
}

// 2. 配置插件
detekt {
config = files("$projectDir/config/detekt.yml") // 规则配置
baseline = file("$projectDir/config/baseline.xml") // baseline配置
parallel = true
}

// 3. 自定义规则
dependencies {
detektPlugins "io.gitlab.arturbosch.detekt:detekt-formatting:1.21.0"
detektPlugins project(":customRules")
}

// 4. 配置 jvmTarget
tasks.withType(Detekt).configureEach {
jvmTarget = "1.8"
}
// DeteKt Task用于检测,DetektCreateBaselineTask用于创建Baseline
tasks.withType(DetektCreateBaselineTask).configureEach {
jvmTarget = "1.8"
}

// 5. 只分析指定文件
tasks.withType<io.gitlab.arturbosch.detekt.Detekt>().configureEach {
// include("**/special/package/**") // 只分析 src/main/kotlin 下面的指定目录文件
exclude("**/special/package/internal/**") // 过滤指定目录
}

如上所示,接入主要需要做这么几件事:



  1. 引入插件

  2. 配置插件,主要是配置configbaseline,即规则开关与老代码过滤

  3. 引入detekt-formatting与自定义规则的依赖

  4. 配置JvmTarget,用于类型解析,但不用再配置classpath了。

  5. 除了baseline之外,也可以通过includeexclude的方式指定只扫描指定文件的方式来实现增量检测


通过以上方式就接入成功了,运行./gradlew detektDebug就可以开始检测了,扫描结果可在终端直接查看,并可以直接定位到问题代码处,也可以在build/reprots/路径下查看输出的报告文件:



自定义Detekt检测规则


要落地自己制定的代码规范,不可避免的需要自定义规则,当然我们首先要看下DeteKt自带的规则,是否已经有我们需要的,只需把开关打开即可.


DeteKt自带规则


DeteKt自带的规则都可以通过开关配置,如果没有在 Detekt 闭包中指定 config 属性,detekt 会使用默认的规则。这些规则采用 yaml 文件描述,运行 ./gradlew detektGenerateConfig 会生成 config/detekt/detekt.yml 文件,我们可以在这个文件的基础上制定代码规范准则。


detekt.yml 中的每条规则形如:


complexity: # 大类
active: true
ComplexCondition: # 规则名
active: true # 是否启用
threshold: 4 # 有些规则,可以设定一个阈值
# ...

更多关于配置文件的修改方式,请参考官方文档-配置文件


Detekt 的规则集划分为 9 个大类,每个大类下有具体的规则:



















































规则大类说明
comments与注释、文档有关的规范检查
complexity检查代码复杂度,复杂度过高的代码不利于维护
coroutines与协程有关的规范检查
empty-blocks空代码块检查,空代码应该尽量避免
exceptions与异常抛出和捕获有关的规范检查
formatting格式化问题,detekt直接引用的 ktlint 的格式化规则集
naming类名、变量命名相关的规范检查
performance检查潜在的性能问题
potentail-bugs检查潜在的BUG
style统一团队的代码风格,也包括一些由 Detekt 定义的格式化问题

表格引用自:cloud.tencent.com/developer/a…


更细节的规则说明,请参考:官方文档-规则集说明


自定义规则


接下来我们自定义一个检测KAE使用的规则,如下所示:


//  入口
class CustomRuleSetProvider : RuleSetProvider {
override val ruleSetId: String = "detekt-custom-rules"
override fun instance(config: Config): RuleSet = RuleSet(
ruleSetId,
listOf(
NoSyntheticImportRule(),
)
)
}

// 自定义规则
class NoSyntheticImportRule : Rule() {
override val issue = Issue(
"NoSyntheticImport",
Severity.Maintainability,
"Don’t import Kotlin Synthetics as it is already deprecated.",
Debt.TWENTY_MINS
)

override fun visitImportDirective(importDirective: KtImportDirective) {
val import = importDirective.importPath?.pathStr
if (import?.contains("kotlinx.android.synthetic") == true) {
report(
CodeSmell(
issue,
Entity.from(importDirective),
"'$import' 不要使用kae,推荐使用viewbinding"
)
)
}
}
}

代码其实并不复杂,主要做了这么几件事:



  1. 添加CustomRuleSetProvider作为自定义规则的入口,并将NoSyntheticImportRule添加进去

  2. 实现NoSyntheticImportRule类,主要包括issue与各种visitXXX方法

  3. issue属性用于定义在控制台或任何其他输出格式上打印的ID、严重性和提示信息

  4. visitImportDirective即通过访问者模式访问语法树的回调,当访问到import时会回调,我们在这里检测有没有添加kotlinx.android.synthetic,发现存在则报告异常


支持类型解析的自定义规则


上面的规则没有用到类型解析,也就是说不传入classpath也能使用,我们现在来看一个需要使用类型解析的自定义规则


比如我们需要在项目中禁止直接使用android.widget.Toast.show,而是使用我们统一封装的工具类,那么我们可以自定义如下规则:


class AvoidToUseToastRule : Rule() {
override val issue = Issue(
"AvoidUseToastRule",
Severity.Maintainability,
"Don’t use android.widget.Toast.show",
Debt.TWENTY_MINS
)

override fun visitReferenceExpression(expression: KtReferenceExpression) {
super.visitReferenceExpression(expression)
if (expression.text == "makeText") {
// 通过bindingContext获取语义
val referenceDescriptor = bindingContext.get(BindingContext.REFERENCE_TARGET, expression)
val packageName = referenceDescriptor?.containingPackage()?.asString()
val className = referenceDescriptor?.containingDeclaration?.name?.asString()
if (packageName == "android.widget" && className == "Toast") {
report(
CodeSmell(
issue, Entity.from(expression), "禁止直接使用Toast,建议使用xxxUtils"
)
)
}
}
}
}

可以看出,我们在visitReferenceExpression回调中检测表达式,我们不仅需要判断是否存在Toast.makeTest表达式,因为可能存在同名类,更需要判断Toast类的具体类型,而这就需要获取语义信息


我们这里通过bindingContext来获取表达式的语义,这里的bindingContext其实就是Kotlin编译器存储语义信息的表,详细的可以参阅:K2 编译器是什么?世界第二高峰又是哪座?


当我们获取了语义信息之后,就可以获取Toast的具体类型,就可以判断出这个Toast是不是android.widget.Toast,也就可以完成检测了


Github Action集成Detekt检测


在完成了DeteKt接入与自定义规则之后,接下来就是每次提交代码时在CI上进行检测了


一些大的开源项目每次提交PR都会进行一系列的检测,我们也用Github Action来实现一个


我们在.github/workflows目录添加如下代码


name: Android CI

on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]

jobs:
detekt-code-check:

runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v3
- name: set up JDK 11
uses: actions/setup-java@v3
with:
java-version: '11'
distribution: 'temurin'
cache: gradle

- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: DeteKt Code Check
run: ./gradlew detektDebug

这样在每次提交PR的时候,就都会自动调用该workflow进行检测了,检测不通过则不允许合并,如下所示:



点进去也可以看到详细的报错,具体是哪一行代码检测不通过,如图所示:



总结


本文主要介绍了DeteKt的接入与如何自定义规则,通过IDE集成,CLI命令行方式与Gradle插件方式接入,以及CI自动检测,可以保证代码规范,IDE提示,CI检测三者的统一,方便提前暴露问题,提高代码质量。


如果本文对你有所帮助,欢迎点赞~


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

请求量突增一下,系统有效QPS为何下降很多?

QPS
简介 最近我观察到一个现象,当服务的请求量突发的增长一下时,服务的有效QPS会下降很多,有时甚至会降到0,这种现象网上也偶有提到,但少有解释得清楚的,所以这里来分享一下问题成因及解决方案。 队列延迟 目前的Web服务器,如Tomcat,请求处理过程大概都类似如...
继续阅读 »

简介


最近我观察到一个现象,当服务的请求量突发的增长一下时,服务的有效QPS会下降很多,有时甚至会降到0,这种现象网上也偶有提到,但少有解释得清楚的,所以这里来分享一下问题成因及解决方案。


队列延迟


目前的Web服务器,如Tomcat,请求处理过程大概都类似如下:

image_2023-01-15_20230115173654

这是Tomcat请求处理的过程,如下:



  1. Acceptor线程:线程名类似http-nio-8080-Acceptor-0,此线程用于接收新的TCP连接,并将TCP连接注册到NIO事件中。

  2. Poller线程:线程名类似http-nio-8080-ClientPoller-0,此线程一般有CPU核数个,用于轮询已连接的Socket,接收新到来的Socket事件(如调用端发请求数据了),并将活跃Socket放入exec线程池的请求队列中。

  3. exec线程:线程名类似http-nio-8080-exec-0,此线程从请求队列中取出活跃Socket,并读出请求数据,最后执行请求的API逻辑。


这里不用太关心AcceptorPoller线程,这是nio编程时常见的线程模型,我们将重点放在exec线程池上,虽然Tomcat做了一些优化,但它还是从Java原生线程池扩展出来的,即有一个任务队列与一组线程。


当请求量突发增长时,会发生如下的情况:



  1. 当请求量不大时,任务队列基本是空的,每个请求都能得到及时的处理。

  2. 但当请求量突发时,任务队列中就会有很多请求,这时排在队列后面的请求,就会被处理得越晚,因而请求的整体耗时就会变长,甚至非常长。


可是,exec线程们还是在一刻不停歇的处理着请求的呀,按理说服务QPS是不会减少的呀!


简单想想的确如此,但调用端一般是有超时时间设置的,不会无限等待下去,当客户端等待超时的时候,这个请求实际上Tomcat就不用再处理了,因为就算处理了,客户端也不会再去读响应数据的。

image_2023-01-15_20230115175826

因此,当队列比较长时,队列后面的请求,基本上都是不用再处理的,但exec线程池不知道啊,它还是会一如既往地处理这些请求。


当exec线程执行这些已超时的请求时,若又有新请求进来,它们也会排在队尾,这导致这些新请求也会超时,所以在流量突发的这段时间内,请求的有效QPS会下降很多,甚至会降到0。


这种超时也叫做队列延迟,但队列在软件系统中应用得太广泛了,比如操作系统调度器维护了线程队列,TCP中有backlog连接队列,锁中维护了等待队列等等。


因此,很多系统也会存在这种现象,平时响应时间挺稳定的,但偶尔耗时很高,这种情况有很多都是队列延迟导致的。


优化队列延迟


知道了问题产生的原因,要优化它就比较简单了,我们只需要让队列中那些长时间未处理的请求暂时让路,让线程去执行那些等待时间不长的请求即可,毕竟这些长时间未处理的请求,让它们再等等也无防,因为客户端可能已经超时了而不需要请求结果了,虽然这破坏了队列的公平性,但这是我们需要的。


对于Tomcat,在springboot中,我们可以如下修改:

使用WebServerFactoryCustomizer自定义Tomcat的线程池,如下:


@Component
public class TomcatExecutorCustomizer implements WebServerFactoryCustomizer<TomcatServletWebServerFactory> {
@Resource
ServerProperties serverProperties;

@Override
public void customize(TomcatServletWebServerFactory factory) {
TomcatConnectorCustomizer tomcatConnectorCustomizer = connector -> {
ServerProperties.Tomcat.Threads threads = serverProperties.getTomcat().getThreads();
TaskQueue taskqueue = new SlowDelayTaskQueue(1000);
ThreadPoolExecutor executor = new org.apache.tomcat.util.threads.ThreadPoolExecutor(
threads.getMinSpare(), threads.getMax(), 60L, TimeUnit.SECONDS,
taskqueue, new CustomizableThreadFactory("http-nio-8080-"));
taskqueue.setParent(executor);
ProtocolHandler handler = connector.getProtocolHandler();
if (handler instanceof AbstractProtocol) {
AbstractProtocol<?> protocol = (AbstractProtocol<?>) handler;
protocol.setExecutor(executor);
}
};
factory.addConnectorCustomizers(tomcatConnectorCustomizer);
}
}

注意,这里还是使用的Tomcat实现的线程池,只是将任务队列TaskQueue扩展为了SlowDelayTaskQueue,它的作用是将长时间未处理的任务移到另一个慢队列中,待当前队列中无任务时,再把慢队列中的任务移回来。


为了能记录任务入队列的时间,先封装了一个记录时间的任务类RecordTimeTask,如下:


@Getter
public class RecordTimeTask implements Runnable {
private Runnable run;
private long createTime;
private long putQueueTime;

public RecordTimeTask(Runnable run){
this.run = run;
this.createTime = System.currentTimeMillis();
this.putQueueTime = this.createTime;
}
@Override
public void run() {
run.run();
}

public void resetPutQueueTime() {
this.putQueueTime = System.currentTimeMillis();
}

public long getPutQueueTime() {
return this.putQueueTime;
}
}

然后队列的扩展实现如下:


public class SlowDelayTaskQueue extends TaskQueue {
private long timeout;
private BlockingQueue<RecordTimeTask> slowQueue;

public SlowDelayTaskQueue(long timeout) {
this.timeout = timeout;
this.slowQueue = new LinkedBlockingQueue<>();
}

@Override
public boolean offer(Runnable o) {
// 将任务包装一下,目的是为了记录任务放入队列的时间
if (o instanceof RecordTimeTask) {
return super.offer(o);
} else {
return super.offer(new RecordTimeTask(o));
}
}

public void pullbackIfEmpty() {
// 如果队列空了,从慢队列中取回来一个
if (this.isEmpty()) {
RecordTimeTask r = slowQueue.poll();
if (r == null) {
return;
}
r.resetPutQueueTime();
this.add(r);
}
}

@Override
public Runnable poll(long timeout, TimeUnit unit) throws InterruptedException {
pullbackIfEmpty();
while (true) {
RecordTimeTask task = (RecordTimeTask) super.poll(timeout, unit);
if (task == null) {
return null;
}
// 请求在队列中长时间等待,移入慢队列中
if (System.currentTimeMillis() - task.getPutQueueTime() > this.timeout) {
this.slowQueue.offer(task);
continue;
}
return task;
}
}

@Override
public Runnable take() throws InterruptedException {
pullbackIfEmpty();
while (true) {
RecordTimeTask task = (RecordTimeTask) super.take();
// 请求在队列中长时间等待,移入慢队列中
if (System.currentTimeMillis() - task.getPutQueueTime() > this.timeout) {
this.slowQueue.offer(task);
continue;
}
return task;
}
}
}

逻辑其实挺简单的,如下:



  1. 当任务入队列时,包装一下任务,记录一下入队列的时间。

  2. 然后线程从队列中取出任务时,若发现任务等待时间过长,就将其移入慢队列。

  3. 而pullbackIfEmpty的逻辑,就是当队列为空时,再将慢队列中的任务移回来执行。


为了将请求的队列延迟记录在access.log中,我又修改了一下Task,并加了一个Filter,如下:



  1. 使用ThreadLocal将队列延迟先存起来


@Getter
public class RecordTimeTask implements Runnable {
private static final ThreadLocal<Long> WAIT_IN_QUEUE_TIME = new ThreadLocal<>();

private Runnable run;
private long createTime;
private long putQueueTime;
public RecordTimeTask(Runnable run){
this.run = run;
this.createTime = System.currentTimeMillis();
this.putQueueTime = this.createTime;
}
@Override
public void run() {
try {
WAIT_IN_QUEUE_TIME.set(System.currentTimeMillis() - this.createTime);
run.run();
} finally {
WAIT_IN_QUEUE_TIME.remove();
}
}

public void resetPutQueueTime() {
this.putQueueTime = System.currentTimeMillis();
}

public long getPutQueueTime() {
return this.putQueueTime;
}

public static long getWaitInQueueTime(){
return ObjectUtils.defaultIfNull(WAIT_IN_QUEUE_TIME.get(), 0L);
}
}


  1. 再在Filter中将队列延迟取出来,放入Request对象中


@WebFilter
@Component
public class WaitInQueueTimeFilter extends HttpFilter {

@Override
public void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws
IOException,
ServletException {
long waitInQueueTime = RecordTimeTask.getWaitInQueueTime();
// 将等待时间设置到request的attribute中,给access.log使用
request.setAttribute("waitInQueueTime", waitInQueueTime);

// 如果请求在队列中等待了太长时间,客户端大概率已超时,就没有必要再执行了
if (waitInQueueTime > 5000) {
response.sendError(503, "service is busy");
return;
}
chain.doFilter(request, response);
}

}


  1. 然后在access.log中配置队列延迟


server:
tomcat:
accesslog:
enabled: true
directory: /home/work/logs/applogs/java-demo
file-date-format: .yyyy-MM-dd
pattern: '%h %l %u %t "%r" %s %b %Dms %{waitInQueueTime}rms "%{Referer}i" "%{User-Agent}i" "%{X-Forwarded-For}i"'

注意,在access.log中配置%{xxx}r表示取请求xxx属性的值,所以,%{waitInQueueTime}r就是队列延迟,后面的ms是毫秒单位。


优化效果


我使用接口压测工具wrk压了一个测试接口,此接口执行时间100ms,使用1000个并发去压,1s的超时时间,如下:


wrk -d 10d -T1s --latency http://localhost:8080/sleep -c 1000

然后,用arthas看一下线程池的队列长度,如下:


[arthas@619]$ vmtool --action getInstances \
--classLoaderClass org.springframework.boot.loader.LaunchedURLClassLoader \
--className org.apache.tomcat.util.threads.ThreadPoolExecutor \
--express 'instances.{ #{"ActiveCount":getActiveCount(),"CorePoolSize":getCorePoolSize(),"MaximumPoolSize":getMaximumPoolSize(),"QueueSize":getQueue().size()} }' \
-x 2

image_2023-01-16_20230116003607

可以看到,队列长度远小于1000,这说明队列中积压得不多。


再看看access.log,如下:

image_2023-01-15_20230115233508

可以发现,虽然队列延迟任然存在,但被控制在了1s以内,这样这些请求就不会超时了,Tomcat的有效QPS保住了。


而最后面那些队列延迟极长的请求,则是被不公平对待的请求,但只能这么做,因为在请求量超出Tomcat处理能力时,只能牺牲掉它们,以保全大局。


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

新年兔兔送祝福——SearchRabbit(安卓app)

前言 2023年到来,今年过年格外早,没几天就要迎新年了,因为是兔年,所以我创建了一个Rabbit为主题的App,里面以兔子为主题而添加各种相关内容,目前仅有十条2023兔年祝福语,后面会增加其他功能,下面,我们看看这个App的样子。 正篇 UI设计 首先,这...
继续阅读 »

前言


2023年到来,今年过年格外早,没几天就要迎新年了,因为是兔年,所以我创建了一个Rabbit为主题的App,里面以兔子为主题而添加各种相关内容,目前仅有十条2023兔年祝福语,后面会增加其他功能,下面,我们看看这个App的样子。


正篇


UI设计


首先,这个App因为这两天才创建的,所以只是UI上看起来和兔子相关,内容并不是很充实。主要是找了一张兔子的图片做App的logo,以及找了几张动态图作为app内部的装饰UI,如下:


Snipaste_2023-01-15_21-52-02.png


untitled.gif


勉强符合此次“兔了个兔”的主题。

内容设计


内部我是利用LottieAnimation去展示动图(让UI忙碌的安卓Lottie动画渲染库(一) - 掘金 (juejin.cn) & 让UI忙碌的安卓Lottie动画渲染库(二) - 掘金 (juejin.cn)),然后使用之前掘友推荐的刘强东写的列表神器BRV(liangjingkanji/BRV: [文档详细] Android上最好的RecyclerView框架, 比 BRVAH 更简单强大 (github.com)),琢磨了半天最后还是没有成功使用库作者推荐的DataBinding方式,我使用RecyclerView中使用BRV去加载10条祝福语。


这是使用作者推荐方式后运行不起来的截图:
b836e43f4f40f6754d0fef16e041dd0.png
看文档上的解决方法依次尝试还是没成功,所以还是采用ViewBinding的方式了。


代码与效果展示


部分XML布局如下,我虽然启用了DataBinding但目前还不会用,所以我也同时启用了ViewBinding:


<?xml version="1.0" encoding="utf-8"?>
<layout 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">

<data>

</data>

<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:context=".MainActivity">
...
...

<com.airbnb.lottie.LottieAnimationView
android:id="@+id/rabbit_easter_egg_slider"
android:layout_width="match_parent"
android:layout_height="100dp"
android:layout_gravity="center"
app:lottie_autoPlay="true"
app:lottie_fileName="lottie/rabbit_easter_egg_slider.json"
app:lottie_loop="true"
app:lottie_repeatMode="restart" />

<androidx.core.widget.NestedScrollView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
tools:ignore="SpeakableTextPresentCheck">

<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical">

...
...

<com.airbnb.lottie.LottieAnimationView
android:id="@+id/rabbit_2023"
android:layout_width="200dp"
android:layout_height="200dp"
android:layout_gravity="center"
app:lottie_autoPlay="true"
app:lottie_fileName="lottie/rabbit_2023.json"
app:lottie_loop="true"
app:lottie_repeatMode="restart" />
</LinearLayout>

</androidx.core.widget.NestedScrollView>

<androidx.recyclerview.widget.RecyclerView
android:id="@+id/vMainList"
android:layout_width="match_parent"
android:layout_height="wrap_content" >
</androidx.recyclerview.widget.RecyclerView>

</LinearLayout>
</layout>

我的activity中部分代码如下,很笨拙地使用列表的方式存了10条祝福语,后面还会优化一下并加上复制按钮:


...
...

class MainActivity : AppCompatActivity() {

private lateinit var binding: ActivityMainBinding
private var text = arrayOf("兔年!...",
...
....,
....,
....")

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
window.attributes.softInputMode = WindowManager.LayoutParams.SOFT_INPUT_ADJUST_PAN

binding.vMainList.linear().setup {
addType<SimpleModel>(R.layout.item_simple)
setAnimation(AnimationType.SLIDE_BOTTOM)

onBind {
val binding = getBinding<ItemSimpleBinding>() // 使用ViewBinding/DataBinding都可以使用本方法
binding.tvName.text = getModel<SimpleModel>().name
}
}.models = getData()

}

private fun getData(): MutableList<Any> {
// 在Model中也可以绑定数据
return mutableListOf<Any>().apply {
for (i in 1..10) {
val simpleModel = SimpleModel(
"$i、${text[i-1]}"
, i)
add(simpleModel)
// add(SimpleModel())
}


}
}
}

运行后目前只可以滑动查看列表:


1111.gif


项目代码


总之就是这个App目前还非常简陋,但是已经放到了GitHub上了,后续会逐渐添加优化一些功能和代码。


项目地址:ObliviateOnline/RabbitApp: 2023 rabbit app (github.com)


总结


本来是想做一个搜索类的App,结果发现做着做着就偏离了方向,但是本来就是为了新年添个彩头,又是自己弄着玩的,加之看起来还是像那么回事,所以就这么直接发出来献丑了,希望大家喜欢!


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

介绍一个令强迫症讨厌的小红点组件

前言 在 App 的运营中,活跃度是一个重要的指标,日活/月活……为了提高活跃度,就发明了小红点,然后让强迫症用户“没法活”。 小红点虽然很讨厌,但是为了 KPI,程序员也不得不屈从运营同学的逼迫(讨好),得想办法实现。这一篇,来介绍一个徽标(Badge)组...
继续阅读 »

前言


在 App 的运营中,活跃度是一个重要的指标,日活/月活……为了提高活跃度,就发明了小红点,然后让强迫症用户“没法活”。


image.png


小红点虽然很讨厌,但是为了 KPI,程序员也不得不屈从运营同学的逼迫(讨好),得想办法实现。这一篇,来介绍一个徽标(Badge)组件,能够快速搞定应用内的小红点。


Badge 组件


Badge 组件被 Flutter 官方推荐,利用它让小红点的实现非常轻松,只需要2个步骤就能搞定。



  1. 引入依赖


pubspec.yaml文件种引入相应版本的依赖,如下所示。


badges: ^2.0.3


  1. 将需要使用小红点的组件使用 Badge 作为上级组件,设置小红点的位置、显示内容、颜色(没错,也可以改成小蓝点)等参数,示例代码如下所示。


Badge(
badgeContent: Text('3'),
position: BadgePosition.topEnd(top: -10, end: -10),
badgeColor: Colors.blue,
child: Icon(Icons.settings),
)

position可以设置徽标在组件的相对位置,包括右上角(topEnd)、右下角(bottomEnd)、左上角(topStart)、左下角(bottomStart)和居中(center)等位置。并可以通过调整垂直方向和水平方向的相对位置来进行位置的细微调整。当然,Badge 组件考虑了很多应用场景,因此还有其他的一些参数:



  • elevation:阴影偏移量,默认为2,可以设置为0消除阴影;

  • gradient:渐变色填充背景;

  • toAnimate:徽标内容改变后是否启用动效哦,默认有动效。

  • shape:徽标的形状,默认是原型,也可以设置为方形,设置为方形的时候可以使用 borderRadius 属性设置圆角弧度。

  • borderRadius:圆角的半径。

  • animationType:内容改变后的动画类型,有渐现(fade)、滑动(slide)和缩放(scale)三种效果。

  • showBadge:是否显示徽标,我们可以利用这个控制小红点的显示与否,比如没有提醒的时候该值设置为 false 即可隐藏掉小红点。


总的来说,这些参数能够满足所有需要使用徽标的场景了。


实例


我们来看一个实例,我们分别在导航栏右上角、内容区和底部导航栏使用了三种类型的徽标,实现效果如下。


badge.gif


其中导航栏的代码如下,这是 Badge 最简单的实现方式了。


AppBar(
title: const Text('Badge Demo'),
actions: [
Badge(
showBadge: _badgeNumber > 0,
padding: const EdgeInsets.all(4.0),
badgeContent: Text(
_badgeNumber < 99 ? _badgeNumber.toString() : '99+',
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white,
fontSize: 11.0,
),
),
position: BadgePosition.topEnd(top: 4, end: 4),
child: IconButton(
onPressed: () {},
icon: const Icon(
Icons.message_outlined,
color: Colors.white,
),
),
),
],
),

内容区的徽标代码如下,这里使用了渐变色填充,动画形式为缩放,并且将徽标放到了左上角,注意如果使用了渐变色那么会覆盖 badgeColor 指定的背景色。


Badge(
showBadge: _badgeNumber > 0,
padding: const EdgeInsets.all(6.0),
badgeContent: Text(
_badgeNumber < 99 ? _badgeNumber.toString() : '99+',
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white,
fontSize: 10.0,
),
),
position: BadgePosition.topStart(top: -10, start: -10),
badgeColor: Colors.blue,
animationType: BadgeAnimationType.scale,
elevation: 0.0,
gradient: const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.red,
Colors.orange,
Colors.green,
],
),
child: Image.asset(
'images/girl.jpeg',
width: 200,
height: 200,
),
),

底部导航栏的代码如下所示,这里需要注意,Badge 组件会根据内容区的尺寸自动调节大小,底部导航栏的显示控件有限,推荐使用小红点(不用数字标识)即可。


BottomNavigationBar(items: [
BottomNavigationBarItem(
icon: Badge(
showBadge: _badgeNumber > 0,
padding: const EdgeInsets.all(2.0),
badgeContent: Text(
_badgeNumber < 99 ? _badgeNumber.toString() : '99+',
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white,
fontSize: 11.0,
),
),
position: BadgePosition.topEnd(top: -4, end: -6),
animationType: BadgeAnimationType.fade,
child: const Icon(Icons.home_outlined)),
label: '首页',
),
const BottomNavigationBarItem(
icon: Icon(
Icons.star_border,
),
label: '推荐',
),
const BottomNavigationBarItem(
icon: Icon(
Icons.account_circle_outlined,
),
label: '我的',
),
]),

总结


本篇介绍了使用 Badge 组件实现小红点徽标组件。可以看到,Badge 组件的使用非常简单,相比我们自己从零写一个 Badge 组件来说,使用它可以让我们省时省力、快速地完成运营同学要的小红点。本篇源码已上传至:实用组件相关代码


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

Android 原生混合 Flutter 开发初体验之一

前言 最近公司的项目用Flutter技术栈比较多,有些需求可能还需要一些Android原生的支持,所以我做了一些Android原生混合Flutter开发的尝试,参考了一些文章,也遇到了一些问题,这里把总结的经验分享出来。 本文是针对 Android 项目添加...
继续阅读 »

前言


最近公司的项目用Flutter技术栈比较多,有些需求可能还需要一些Android原生的支持,所以我做了一些Android原生混合Flutter开发的尝试,参考了一些文章,也遇到了一些问题,这里把总结的经验分享出来。



本文是针对 Android 项目添加 Flutter 模块的情况编写的。



开发环境



  • PC with Win10 20H2

  • Android Studio Arctic Fox | 2020.3.1 Patch 2(试过小松鼠版本,太不喜欢了,电鳗就更没去尝试)

  • AGP 7.0.2


创建一个Android项目


直接贴图带过了哈,这步应该都熟练的吧


image.png


创建一个Flutter模块


这里就有区别了,较新版的AS中提供直接创建Flutter模块的模板,但是我的北极狐版本没有,因此这里演示两种方式:


AS模板创建


在你的当前项目中,使用AS菜单中的 File > New > New Module… 创建一个新的Flutter模块,或者选择一个此前就已准备好的Flutter模块。
https://docs.flutter.dev/development/add-to-app/android/project-setup#using-android-studio


如果你选择创建一个新的模块,你可以使用向导来帮助你设置模块的名称,模块存放的位置之类的配置项。



  • 由于我这里还是北极狐版的AS,所以我并未实践官方提供的模板创建方式,按照官方的说法,它会自动帮你配置好依赖关系,但我也不确定会不会遇到问题,没有最好,有的话应该也都和手动创建的方式差不多。


手动创建Flutter模块


Terminal执行下方命令


```
flutter create -t module --org com.randalldev fluttermodule
```
复制代码

然后官方提供了两种方式添加依赖关系:




  1. AAR依赖模式


    AAR模式有个好处就是团队中的其他成员不需要安装Flutter SDK,最少只需要一个人输出AAR即可。


    但是我个人不喜欢这种方式,我更倾向于git submodule的项目管理方式,并且安装Flutter SDK的成本实在算不上高,因此,这种方式,我按下不表。




  2. 模块代码依赖模式


    这种方式确保了一步完成Android项目和Flutter模块的编译。这种方式对于你的开发同时涉及两个部分并且快速迭代很方便,但这需要团队的每个人成员都安装Flutter SDK来确保顺利编译这个混合app


    在主项目的settings.gradle中将Flutter模块作为子项目引入。


    // Include the host app project.
    include ':app' // 默认已有的配置
    setBinding(new Binding([gradle: this])) // 新增
    evaluate(new File( // 新增
    settingsDir.parentFile, // 新增
    '${root_project}/fluttermodule/.android/include_flutter.groovy' // 新增
    )) // 新增


    此时AS会提示你gradle配置变更了,需要重新sync,别急,先别点!



    假设fluttermodule是和app目录同层级的。


    appbuild.gradle中添加flutter模块的依赖


     dependencies {
    implementation project(':flutter')
    }


    官方的指南就到此为止了,与此同时,坑也来了/doge





排雷


此时当你点了sync会出现如下报错


* What went wrong:
A problem occurred evaluating script.
> Failed to apply plugin class 'FlutterPlugin'.
> Build was configured to prefer settings repositories over project repositories but repository 'maven' was added by plugin class 'FlutterPlugin'

projectsetting.gradle


dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
jcenter() // Warning: this repository is going to shut down soon
}
}

改为


dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.PREFER_PROJECT)
repositories {
google()
mavenCentral()
jcenter() // Warning: this repository is going to shut down soon
}
}

此时当你点了sync会出现如下报错


A problem occurred evaluating project ':app'.
> Project with path ':fluttermodule' could not be found in project ':app'.

projectsetting.gradle的末尾添加


include ':fluttermodule'

此时当你点了sync编译大概率能成功,但是会有很严重的警告


Failed to resolve: androidx.core:core-ktx:1.9.0
Add Google Maven repository and sync project
Show in Project Structure dialog
Affected Modules: app

projectbuild.gradle


task clean(type: Delete) {
delete rootProject.buildDir
}

上方添加


allprojects {
repositories {
google()
mavenCentral()
maven { url 'https://jitpack.io' }
}
}

至此,大体上一个混合的Android原生+Flutter项目的初步构建就完成了。


页面跳转


Android原生打开Flutter页面


默认的跳转方式会出现明显的白屏,体验上很不好,这里直接给出优化后的方式


使用FlutterEngine缓存并复用




  1. appAndroidManifest.xml中注册FlutterActivity


    <activity
    android:name="io.flutter.embedding.android.FlutterActivity"
    android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
    android:hardwareAccelerated="true"
    android:theme="@style/Theme.HybridFlutter"
    android:windowSoftInputMode="adjustResize" >
    </activity>



  2. app中创建一个App.kt继承Application并在AndroidManifest.xml中配置给application节点的name属性


    class App : Application() {
    ···
    }

    <manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.randalldev.hybridflutter">

    <application
    android:name=".App"
    ···
    </manifest>



  3. App.kt中准备好FlutterEngine




    1. 创建FlutterEngine实例


          private val flutterEngine by lazy {
      FlutterEngine(this).apply {
      dartExecutor.executeDartEntrypoint(DartExecutor.DartEntrypoint.createDefault())
      }
      }



    2. 重写onCreate()并将实例存储在FlutterEngineCache


      override fun onCreate() {
      super.onCreate()

      FlutterEngineCache.getInstance().put("your_engine_id", flutterEngine)
      }



    3. 重写onTerminate()并将实例销毁


      override fun onTerminate() {
      super.onTerminate()

      flutterEngine.destroy()
      }



    4. 在业务需要的地方使用FlutterEngine中的Intent实例进行跳转


          findViewById<TextView>(R.id.textView).setOnClickListener {
      startActivity(FlutterActivity.withCachedEngine("your_engine_id").build(this))
      }



    5. 选择app进行run


      image.png




    6. 如果遇到如下Java版本问题,请进行如下配置变更


      A problem occurred evaluating project ':flutter'.
      > Failed to apply plugin 'com.android.internal.library'.
      > Android Gradle plugin requires Java 11 to run. You are currently using Java 1.8.
      You can try some of the following options:
      - changing the IDE settings.
      - changing the JAVA_HOME environment variable.
      - changing `org.gradle.java.home` in `gradle.properties`.



      1. 选择 Project Structure > SDK location > Gradle Settings 设置Gradle JDK11


        image.png


        image.png




      2. ./gradle.properties中添加上文中对应的java.home路径


        # replace with your own jdk11 or above
        org.gradle.java.home=C\:\\Softwares\\Google\\Android\\Android_Studio\\jre



      3. sync后应该就可以顺利的run


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

Android Studio 如何更便捷开发系统 App

System App 最近接触系统App相关的开发,刚开始得知在系统源码中,开发系统应用,As 引用库的时候,居然不能代码联想,布局也不能预览,实在不习惯。后面搜了下网上的资源,有一些介绍,也不是特别完整,于是自己把这些零碎的点,整理出来,方面后续自己看看。 ...
继续阅读 »

System App


最近接触系统App相关的开发,刚开始得知在系统源码中,开发系统应用,As 引用库的时候,居然不能代码联想,布局也不能预览,实在不习惯。后面搜了下网上的资源,有一些介绍,也不是特别完整,于是自己把这些零碎的点,整理出来,方面后续自己看看。


本文主要解决以下几个问题:



  1. 代码移植、资源移植

  2. 系统隐藏代码未找到

  3. aidl 代码问题

  4. protobuf 代码问题

  5. lint 警告提示


代码移植、资源移植


这一步,就是把系统App的代码拷贝出来(例如:/packages/apps/Settings),相当于移植到一个新的项目。创建一个新的 As 工程,然后按照源码的目录层级创建,记得包名跟源码一致,尽可能保存目录层级一致,接着就是各种 copy 操作了,把 src、res 等目录都搬过去新项目中。在移植的过程,需要将 Android.bp 文件里面依赖的库,按照 gradle 的方式来依赖进去。例如:


 static_libs: [
"com.google.android.material_material",
"androidx.transition_transition",
"androidx-constraintlayout_constraintlayout",
"androidx.core_core",
"androidx.media_media",
"androidx.legacy_legacy-support-core-utils",
"androidx.legacy_legacy-support-core-ui",
"androidx.fragment_fragment",
"androidx.appcompat_appcompat",
"androidx.preference_preference",
"androidx.recyclerview_recyclerview",
"androidx.legacy_legacy-preference-v14",
"androidx.leanback_leanback",
"androidx.leanback_leanback-preference",
"androidx.lifecycle_lifecycle-extensions",
"androidx.lifecycle_lifecycle-common-java8",
"kotlin-stdlib",
"kotlinx-coroutines-android",
"androidx.navigation_navigation-common-ktx",
"androidx.navigation_navigation-fragment-ktx",
"androidx.navigation_navigation-runtime-ktx",
"androidx.navigation_navigation-ui-ktx",
]

对应到 gradle 代码,这个过程十分麻烦,因为很多资源缺失,需要一个个的寻找,以及代码的移植还会关联其他工程代码。而且库的版本也是需要注意的。所以需要耐心解决


    compileOnly files('libs/framework.jar')
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.1.1'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.1.1'

implementation 'androidx.core:core-ktx:1.6.0'
implementation 'androidx.appcompat:appcompat:1.3.1'
implementation 'com.google.android.material:material:1.4.0'
implementation 'com.android.support:multidex:1.0.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.1'

implementation "com.google.protobuf:protobuf-javalite:3.13.0"

def nav_version = "2.3.5"

// Java language implementation
implementation "androidx.navigation:navigation-fragment-ktx:$nav_version"
implementation "androidx.navigation:navigation-ui-ktx:$nav_version"
// Feature module Support
implementation "androidx.navigation:navigation-dynamic-features-fragment:$nav_version"

def lifecycle_version = "2.3.1"
def arch_version = "2.1.0"

// ViewModel
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
// LiveData
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"
// Lifecycles only (without ViewModel or LiveData)
implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version"

// ???
implementation "android.arch.lifecycle:extensions:1.1.1"

// Saved state module for ViewModel
implementation "androidx.lifecycle:lifecycle-viewmodel-savedstate:$lifecycle_version"

// Annotation processor
kapt "androidx.lifecycle:lifecycle-compiler:$lifecycle_version"
// alternately - if using Java8, use the following instead of lifecycle-compiler
implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycle_version"

// optional - helpers for implementing LifecycleOwner in a Service
implementation "androidx.lifecycle:lifecycle-service:$lifecycle_version"

// optional - ProcessLifecycleOwner provides a lifecycle for the whole application process
implementation "androidx.lifecycle:lifecycle-process:$lifecycle_version"

// optional - ReactiveStreams support for LiveData
implementation "androidx.lifecycle:lifecycle-reactivestreams-ktx:$lifecycle_version"

// optional - Test helpers for LiveData
testImplementation "androidx.arch.core:core-testing:$arch_version"

def leanback_version = "1.2.0-alpha01"

implementation "androidx.leanback:leanback:$leanback_version"

// leanback-preference is an add-on that provides a settings UI for TV apps.
implementation "androidx.leanback:leanback-preference:$leanback_version"

// leanback-paging is an add-on that simplifies adding paging support to a RecyclerView Adapter.
implementation "androidx.leanback:leanback-paging:1.1.0-alpha08"

// leanback-tab is an add-on that provides customized TabLayout to be used as the top navigation bar.
implementation "androidx.leanback:leanback-tab:1.1.0-beta01"

如果项目源码存在多个 src 目录,需要在 gradle 中指定 java 目录


    sourceSets {
main {
java.srcDirs = ['src/main/src', 'src/main/src2', 'src/main/src_gen']
// 定义proto文件目录
proto {
// srcDir 'src/main/java'
srcDir 'src/main/src'
include '**/*.proto'
}
}
}

系统隐藏代码未找到


系统源码编译之后,找到/out/target/common/obj/JAVA_LIBRARIES/framework_intermediates 目录下的 classes.jar 文件,更名为 framework.jar ,按照jar包的引用方式,依赖进去工程。


同时需要更改jar的加载顺序,在工程目录的 gradle 添加如下代码


allprojects {
repositories {
google()
jcenter()
}
gradle.projectsEvaluated {
tasks.withType(JavaCompile) {

options.compilerArgs << "-Xlint:unchecked" << "-Xlint:deprecation"

// options.compilerArgs.add('-Xbootclasspath/p:app/libs/framework.jar')
Set<File> fileSet = options.bootstrapClasspath.getFiles()
List<File> newFileList = new ArrayList<>();
//"../framework.jar" 为相对位置,需要参照着修改,或者用绝对位置
// 我这里用的是绝对路径,注意区分 linux 系统与 window 系统的反斜杠
newFileList.add(new File("/xxx/framework.jar"))
newFileList.addAll(fileSet)
options.bootstrapClasspath = files(newFileList.toArray())
// options.bootstrapClasspath.getFiles().forEach(new Consumer<File>() {
// @Override
// void accept(File file) {
// println(file.name)
// }
// })
//options.compilerArgs.add('-Xbootclasspath/p:app\\libs\\framework.jar')
}
}
}

aidl 代码问题


aidl 代码可以用两种方式处理,一种是直接拷贝aidl的生成物,本质还是java代码,另一种方式是按源码那样创建aidl文件


protobuf 代码问题


需要正确引入对应的 protobuf 的版本,以及生成代码的目录,我记得我当时还因为版本不匹配导致一些错误,具体时间太久了,当时也没存记录。


plugins {
id 'com.android.application'
id 'com.google.protobuf'
id 'kotlin-android'
id 'kotlin-kapt'
}

protobuf {
protoc {
artifact = "com.google.protobuf:protoc:3.13.0"
}
generateProtoTasks {
all().each { task ->
task.builtins {
java {
option "lite"
}
}
}
}
}

···

// 工程 gradle 配置
buildscript {
ext.kotlin_version = "1.4.32"
repositories {
google()
jcenter()
}
dependencies {
classpath "com.android.tools.build:gradle:4.1.3"
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath "com.google.protobuf:protobuf-gradle-plugin:0.8.13"
}
}

lint 警告提示


源码中会使用一些过时的方法,在打包过程会导致失败。需要在 gradle 中配置,错误不中断


 lintOptions {
abortOnError false
}

小结


整个过程,就是多次修改,拷贝,然后编译的过程,直到没有错误提示,能够成功生成apk的时候,就成功了。之后就可以愉快的关联代码,以及布局预览了。


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

「Android」用 Spotless 让你的代码 “一尘不染“

Spotless 是什么? 这是一个代码格式化工具,我们可以定义自己想要的代码规则在需要的时候去应用它,整个过程完全自动且可以应用于整个工程。 Spotless 支持多种语言:c、c++、java、kotlin、python 等等更多,应用广泛的开发语言基本都...
继续阅读 »

Spotless 是什么?


这是一个代码格式化工具,我们可以定义自己想要的代码规则在需要的时候去应用它,整个过程完全自动且可以应用于整个工程。


Spotless 支持多种语言:c、c++、java、kotlin、python 等等更多,应用广泛的开发语言基本都支持。


插件丰富,支持 Gradle、Maven、SBT。并且有 IDE 插件,如:VS Code、Intellij。


社区活跃,很多优秀的开源贡献者,如:ktlint、ktfmt、prittier 等, Github 上的提交经常是几天前。


为什么要用 Spotless?


编写代码时我们都希望遵循固定的代码风格,我们使用的 IDE 也都有代码格式化功能。但是很遗憾,代码格式化都需要开发者手动触发,所以你肯定会有忘记格式化的时候。
如果你的团队有 code review 的过程,你的小伙伴或许能纠正这些问题,当然,也有可能看不到,某段代码就这样”脏“了。


虽然良好、统一的代码风格并不能提高代码性能,即使代码风格很糟糕代码也能正确编译,且运行结果并无二致。但是当你的代码有良好统一风格时,代码会更美观,有更好的阅读性,小伙伴 code review 时可以完全不用关注代码风格。


而 [Spotless](diffplug/spotless: Keep your code spotless (github.com)) 就是这样一个能让你不用关注代码风格的工具,因为它会自动帮你格式化代码。


项目整合


Gradle 配置


首先在 project build.gradle 中添加 Spotless 插件


classpath "com.diffplug.spotless:spotless-plugin-gradle:$spotless_version"

然后在 project 或 module 的 build.gradle 中做如下配置


apply plugin: 'com.diffplug.spotless'
spotless {
java{
target 'src/*/java/**/*.java'
googleJavaFormat()
}
kotlin {
target 'src/*/java/**/*.kt'
ktlint('0.43.2')
}

format 'misc', {
target '**/*.gradle', '**/*.md', '**/.gitignore'
}
format 'xml', {
target '**/*.xml'
}

}

结束了,就是这么简单,你需要的仅仅是去配置你想要的代码规则。具体规则配置请参考 [Spotless GitHub](spotless/plugin-gradle at main · diffplug/spotless (github.com))


Java (google-java-format, eclipse jdt, clang-format, prettier, palantir-java-format, formatAnnotations)

Kotlin (ktfmt, ktlint, diktat, prettier)

Spotless gradle task


使用 Spotless 非常简单,一般我们只需要使用两个 task


./gradlew spotlessCheck

执行 spotlessCheck 可以校验代码风格,如果有问题会报错并列出异常代码的位置


./gradlew spotlessApply

执行 spotlessApply 可以在全工程范围内自动修复代码不合规的地方。不过它只能格式化代码,如果规则需要修改代码,比如要删除某个导入的包是需要手动操作的。


从以上两步操作:配置、执行,可以看到 Spotless 的依赖成本是非常低的,使用也非常简单。它带来的好处在我看来有两点:



  • 保证全工程代码风格一致,且是遵循官方代码风格规范

  • 开发者编码阶段完全不需要关心代码风格问题,也不会在 review 阶段花时间更正代码风格,只关注业务价值本身


所以如果你或你的团队在关注代码风格问题,那么 Spotless 一定适合你,请尝试使用吧!


对于 Android 开发最关心的就是 java 和 kotlin 了,这两个语言都有非常多的代码规范支持,不过使用较多的是 Java「google-java-format」kotlin「ktlint」。随着 kotlin 在 Android 开发的占比增长,kotlin 的代码规范就显得更受关注,并且如果你关注 Google 的官方源码,你会发现 Google 的这些工程大多都是使用 ktlint 约束代码风格,这是因为 ktlin 同时支持 official Kotlin coding conventionsAndroid Kotlin Style Guide 两种风格,Android 开发者可以用 ktlint 方便的遵循 Google 代码风格。


更多 ktlint 内容请关注 [ktlint](Ktlint (pinterest.github.io))。


Android Studio lint 插件


前文提到的 google-java-formatktlint 是都有 IDE 插件的,可以在 plugins market 中安装。


image.png


image.png


个人目前开发比较少涉及到 java ,所以 AS 并没有安装 google-java-format,有 java code format 的开发者可以在市场中安装此工具。下面我简单介绍下 AS ktlint 插件的使用体验。


image.png


比如此处多了一个空行,ktlint 会醒目标红,鼠标移过去会展示可操作的选项,可以选择 ktlint format 或禁用此规则。


image.png


安装插件后 AS 会增加一个菜单 Refactor -> Format With Ktlint,此菜单可以格式化整个文件,就与 AS 的格式化操作一样,你也可以为 Format With Ktlint 指定快捷键,操作会更方便。


在 ktlint 的 gradle 、AS 插件加持下,相信代码风格在开发中不需要特别花时间去处理了。如果代码风格不正确首先是 AS 的 error 醒目提醒,如果看到了可以一键修复。如果看不到,在执行 ./gradlew spotlessApply之后还可以全工程修复,让“脏”代码无所遁形。


Git hooks


上文介绍的配置,可能已经满足很多人了,不过还是有人会觉得,只要是手动操作的内容,那一定会有可能会忘记,有没有不需要手动操作的格式化操作。
有的有的,方案就是本小结标题 Git hooks。Ktlint 官网有一键安装 git hooks的操作,可以参考 [ktlint git hooks](Command line - Ktlint (pinterest.github.io))。


关于 git hooks 是什么可以参考这篇博文: [git hooks 简介与使用](git hooks 简介与使用 - 简书 (jianshu.com))


不过这个需要每个人都要手动去操作,其实我们可以把 git hooks 脚本文件放到我们工程里,然后通过 gradle 将脚本文件拷贝至 .git 目录


首先在项目下新建hooks目录,新建pre-commit文件:


#!/bin/sh
#
# An example hook script to verify what is about to be committed.
# Called by "git commit" with no arguments. The hook should
# exit with non-zero status after issuing an appropriate message if
# it wants to stop the commit.
#
# To enable this hook, rename this file to "pre-commit".

./gradlew spotlessCheck
result=$?
printf "the spotlessCheck result code is $result"
if [[ "$result" = 0 ]] ; then
echo "\033[32m
....
....
SpotlessCheck Pass!!
....
....
\033[0m"
exit 0
else
./gradlew spotlessApply
echo "\033[31m
....
....
SpotlessCheck Failed!!
代码格式有问题;
....
已经自动调整格式,review代码后再git add . && git commit
....
....
\033[0m"
exit 1
fi

pre-commit 脚本可以根据自己需要作调整,这边仅仅是一个示例。


下一步需要将此文件拷贝至项目目录下 .git/hooks 目录,可以用 gradle 来处理拷贝事件。


task copyHooks(type: Copy) {
println 'copyHooks task'
from("hooks") {
include "**"
}
into ".git/hooks"
}

执行此 task 就可以将项目 hooks 目录内容拷贝至 .git


完成上述操作之后,在每次执行 git commit 之前都会先执行 pre-commit,在校验失败后会自动格式化代码,开发者在 reivew 之后再重新提交。


总结


如果你在开发过程中时不时会因为代码格式化问题造成困扰,那 Spotless 及 ktlint 会完全解放你。让你无需关注代码格式的同时也能保证代码风格的一致。


ktlint 的优秀远不止此篇文中所述,这仅仅是一篇指导文,大家可以去探索更适合自己项目的方法。


希望此文能让大家了解 Spotless 并尝试使用,谢谢!


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

Kotlin Flow 探索

响应式编程 因为 Kotlin Flow 是基于 响应式编程 的实现,所以先了解一下 响应式编程 的概念。 首先看下百度百科解释: 响应式编程是一种面向数据流和变化传播的编程范式。这意味着可以在编程语言中很方便地表达静态或动态的数据流,而相关的计算模型会自动...
继续阅读 »

响应式编程


因为 Kotlin Flow 是基于 响应式编程 的实现,所以先了解一下 响应式编程 的概念。


首先看下百度百科解释:



响应式编程是一种面向数据流和变化传播的编程范式。这意味着可以在编程语言中很方便地表达静态或动态的数据流,而相关的计算模型会自动将变化的值通过数据流进行传播。



这个释义很抽象,难以理解。只知道它的核心是:数据流


如何理解这个数据流,先看下响应式编程 ReactiveX 下的一个框架 RxJava


RxJava 是基于响应式编程的实现,它的定义:



RxJava 是 Reactive Extensions 的 Java VM 实现:一个通过使用可观察序列来组合异步和基于事件的程序的库。
它扩展了观察者模式以支持数据/事件序列,并添加了运算符,允许您以声明方式组合序列,同时消除了对低级线程、同步、线程安全和并发数据结构等问题的担忧。



看完这个定义,脑袋中也很模糊。下面从 RxJava 应用的一个简单例子来分析:


   Observable.just(bitmap).map { bmp->
//在子线程中执行耗时操作,存储 bitmap 到本地
saveBitmap(bmp)
}.subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread()).subscribe { bitmapLocalPath ->
//在主线程中处理存储 bitmap 后的本地路径地址
refreshImageView(bitmapLocalPath)
}

上面例子中: 将一个 bitmap 存储到本地并返回本地路径,从源数据 bitmap → 存储 btimap 到本地操作 → 获取本地图片路径值刷新UI。其实,就可以把这整个过程中按时间发生的事件序列理解为数据流


数据流包含提供方(生产者),中介(中间操作),使用方(消费者):



  • 提供方(生产者):源数据,将数据添加到数据流中;

  • 中介(中间操作):可以修改发送到数据流的值,或修正数据流本身;

  • 使用方(消费者):结果数据,使用数据流中的值。


那么,上面例子中的数据流是:



  • 提供方(生产者):源数据 bitmap;

  • 中介(中间操作):map 操作,存储 btimap 到本地;

  • 使用方(消费者):本地图片路径。


再看下 RxJava 中的数据流解释:


RxJava 中的数据流由源、零个或多个中间步骤组成,然后是数据消费者或组合器步骤(其中该步骤负责通过某种方式消费数据流):


source.operator1().operator2().operator3().subscribe(consumer);
source.flatMap(value -> source.operator1().operator2().operator3());


在这里,如果我们想象自己在操作符 operator2 上,向左看 source 被称为上游。向右看 subscriber/consumer 称为下游。当每个元素都写在单独的行上时,这一点通常更为明显:


source
.operator1()
.operator2()
.operator3()
.subscribe(consumer)

这也是 RxJava 的上游、下游概念。


其实,Flow 数据流中参看 RxJava,也可以有这样类似的上游和下游概念:


flow
.operator1()
.operator2()
.operator3()
.collect(consumer)

了解了 响应式编程 的核心 数据流 后,对 响应式编程 有了初步印象。但是 响应式编程 的实现远不止如此,它还涉及观察者模式线程调度等。不管原理这些,用它来做开发有什么好处呢?其实,它主要优点是:



  • 对于并发编程,线程切换,没有 callback hell,简化了异步执行的代码;

  • 代码优雅,简洁,易阅读和维护。


下面看两个业务例子:


     Observable.just(bitmap).map { bmp ->
//在子线程中执行耗时操作,存储 bitmap 到本地
saveBitmap(bmp)
}.map { path ->
//在子线程中执行耗时操作,上传图片到服务端
uploadBitmap(path)
}.subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread())
.subscribe { downloadUrl ->
//在主线程中处理获取图片下载地址
}

        //从服务端批量下载文件
Observable.from(downloadUrls).flatMap { downloadUrl ->
//下载单个文件,返回本地文件
Observable.just(downloadUrl).map {url-> downloadResource(url) }
}.map { file ->
//对文件解压
unzipFile(file)
}.subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread())
.subscribe { folderPath ->
//拿到文件夹路径
}

所以 响应式编程 的实现,主要是帮我们解决了 并发编程问题,能用优雅简洁的代码做异步事件处理。


Kotlin 协程 和 Flow,它们结合在一起也实现了 响应式编程。在 Kotlin 环境中,再结合 Android 提供 Lifecycle, ViewModel, Flow 的扩展,能让我们在 Android 中做并发编程,异步事件管理如鱼得水。


Kotlin Flow


Kotlin Flow 就是 Kotlin 数据流,它基于 Kotlin 协程构建。上一篇 Kotlin 协程探索 分析了 协程 的大致原理,知道协程就是 Kotlin 提供的一套线程 API 框架,方便做并发编程。那么 Kotlin 协程 和 Flow (数据流)的结合,和 RxJava 框架就有异曲同工之妙。


下面使用 Kotlin 协程 和 Flow 来实现上面 RxJava 的两个业务例子:


        GlobalScope.launch(Dispatchers.Main) {
flowOf(bitmap).map { bmp ->
//在子线程中执行耗时操作,存储 bitmap 到本地
Log.d("TestFlow", "saveBitmap: ${Thread.currentThread()}")
saveBitmap(bmp)
}.flowOn(Dispatchers.IO).collect { bitmapLocalPath ->
//在主线程中处理存储 bitmap 后的本地路径地址
Log.d("TestFlow", "bitmapLocalPath=$bitmapLocalPath: ${Thread.currentThread()}")
}
}
//从服务端批量下载文件
GlobalScope.launch(Dispatchers.Main) {
downloadUrls.asFlow().flatMapConcat { downloadUrl ->
//下载单个文件,返回本地文件
flowOf(downloadUrl).map { url ->
Log.d("TestFlow", "downloadResource:url=$url: ${Thread.currentThread()}")
downloadResource(url)
}
}.map { file ->
//对文件解压
Log.d("TestFlow", "unzipFile:file=${file.path}: ${Thread.currentThread()}")
unzipFile(file)
}.flowOn(Dispatchers.IO).collect { folderPath ->
//拿到文件夹路径
Log.d("TestFlow", "folderPath=$folderPath: ${Thread.currentThread()}")
}
}
控制台结果输出:
TestFlow: saveBitmap: Thread[DefaultDispatcher-worker-1,5,main]
TestFlow: bitmapLocalPath=/mnt/sdcard/Android/data/com.wangjiang.example/files/images/flow.png: Thread[main,5,main]

TestFlow: downloadResource:url=https://www.wangjiang.example/coroutine.zip: Thread[DefaultDispatcher-worker-1,5,main]
TestFlow: unzipFile:file=/mnt/sdcard/Android/data/com.wangjiang.example/files/zips/coroutine.zip: Thread[DefaultDispatcher-worker-1,5,main]
TestFlow: downloadResource:url=https://www.wangjiang.example/flow.zip: Thread[DefaultDispatcher-worker-1,5,main]
TestFlow: unzipFile:file=/mnt/sdcard/Android/data/com.wangjiang.example/files/zips/flow.zip: Thread[DefaultDispatcher-worker-1,5,main]
TestFlow: folderPath=/mnt/sdcard/Android/data/com.wangjiang.example/files/zips/coroutine: Thread[main,5,main]
TestFlow: folderPath=/mnt/sdcard/Android/data/com.wangjiang.example/files/zips/flow: Thread[main,5,main]

可以看到,和 RxJava 实现的效果是一致的。首先,使用launch启动一个协程,然后使用源数据创建一个 Flow(数据生产),再经过 flatMapConcat, map 变换(多个中间操作),最后通过collect获取结果数据(数据消费),这其中还包括线程切换:在主线程中启动子线程执行耗时任务,并将耗时任务结果返回给主线程(flowOn 指定了中间操作在 IO 线程中执行)。所以 协程 和 Flow(数据流) 结合,就是 响应式编程 的实现,这对我们来说,使用它可以在 Kotlin 环境中写出优雅的异步代码来做并发编程。


下面再分别来熟悉一下 协程 和 Flow。


协程概念


首先来看一下协程中的一些概念和 API。


CoroutineScope: 定义协程的 scope。



CoroutineScope 会跟踪它使用 launch 或 async 创建的所有协程。您可以随时调用 scope.cancel() 以取消正在进行的工作(即正在运行的协程)。在 Android 中,某些 KTX 库为某些生命周期类提供自己的 CoroutineScope。例如,ViewModel 有 viewModelScope,Lifecycle 有 lifecycleScope。不过,与调度程序不同,CoroutineScope 不运行协程。



Kotlin 提供了为 UI 组件使用的 MainScope


public fun MainScope(): CoroutineScope = ContextScope(SupervisorJob() + Dispatchers.Main)

为应用程序整个生命周期使用的 GlobalScope:


public object GlobalScope : CoroutineScope {
/**
* Returns [EmptyCoroutineContext].
*/
override val coroutineContext: CoroutineContext
get() = EmptyCoroutineContext
}

因为是应用程序整个生命周期,所以要慎重使用。


也可以自定义 Scope:


val scope = CoroutineScope(Job() + Dispatchers.Main)

另外,Android KTX 库针对 CoroutineScope 做了扩展,所以在 Android 中通常会使用 Activity 或 Fragment 生命周期相关的 lifecycleScope,和 ViewModel 生命周期相关的viewModelScope


public val Lifecycle.coroutineScope: LifecycleCoroutineScope
get() {
while (true) {
val existing = mInternalScopeRef.get() as LifecycleCoroutineScopeImpl?
if (existing != null) {
return existing
}
val newScope = LifecycleCoroutineScopeImpl(
this,
SupervisorJob() + Dispatchers.Main.immediate
)
if (mInternalScopeRef.compareAndSet(null, newScope)) {
newScope.register()
return newScope
}
}
}

public val ViewModel.viewModelScope: CoroutineScope
get() {
val scope: CoroutineScope? = this.getTag(JOB_KEY)
if (scope != null) {
return scope
}
return setTagIfAbsent(
JOB_KEY,
CloseableCoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
)
}

internal class CloseableCoroutineScope(context: CoroutineContext) : Closeable, CoroutineScope {
override val coroutineContext: CoroutineContext = context

override fun close() {
coroutineContext.cancel()
}
}

启动协程: launch 和 async


启动协程有两种方式:



  • launch :启动一个新的协程,并返回一个 Job,这个 Job 是可以取消的 Job.cancel

  • async :也会启动一个新的协程,并返回一个 Deferred 接口实现,这个接口其实也继承了Job 接口,可以使用 await 挂起函数等待返回结果。


CoroutineContext: 协程上下文


val scope = CoroutineScope(Job() + Dispatchers.Main)

在 CoroutineScope 中定义了 plus 操作:


public operator fun CoroutineScope.plus(context: CoroutineContext): CoroutineScope =
ContextScope(coroutineContext + context)

因为 JobDispatchers 顶层都继承了接口 Element,而 Element 又继承了接口 CoroutineContext:


public interface Element : CoroutineContext

所以 Job() 和 Dispatchers.Main 可以相加。这里 CoroutineScope 的构造方法中是必须要有 Job(),如果没有,它自己也会创建一个 Job()


public fun CoroutineScope(context: CoroutineContext): CoroutineScope =
ContextScope(if (context[Job] != null) context else context + Job())

Job 和 CoroutineDispatcher 在 CoroutineContext 中的作用是:



Job:控制协程的生命周期。
CoroutineDispatcher:将工作分派到适当的线程。



CoroutineDispatcher:协程调度器与线程



  • Dispatchers.Default:默认调度器,指示此协程应在为 cpu 计算操作预留的线程上执行;

  • Dispatchers.Main:指示此协程应在为 UI 操作预留的主线程上执行;

  • Dispatchers.IO:指示此协程应在为 I/O 操作预留的线程上执行。


GlobalScope.launch(Dispatchers.Main) {
}

withContext(Dispatchers.IO){
}

.flowOn(Dispatchers.IO)

小结


要使用协程,首先创建一个 scope: CoroutineScope 来负责管理协程,定义scope 时需要指定控制协程的生命周期的 Job和将工作分派到适当线程的CoroutineDispatcher。定义好 scope 后, 可通过 scope.launch启动一个协程,也可以多次使用scope.launch启动多个协程,启动的协程可通过 scope.cancel取消,但它取消的是 scope 启动的所有协程。如果要取消单个协程,需要使用scope.launch返回的 Job 来取消Job.cancel,这个 Job 控制着单个协程的生命周期。当启动协程后,主线程中的任务依然可以继续执行,在执行launch{}时,可以通过 withContext(Dispatchers.IO)将协程的执行操作移至一个 I/O 子线程,子线程执行完任务,再将结果返回主线程继续执行。


简单示例:


    //主线程分派任务
private val scope = CoroutineScope(Job() + Dispatchers.Main)

//管理对应的协程的生命周期
private var job1: Job? = null

fun exec() {
//启动一个协程
job1 = scope.launch {
//子线程执行耗时任务
withContext(Dispatchers.IO){

}
}
//启动一个协程
val job2 = scope.launch {
//启动一个协程
val taskResult1 = async {
//子线程执行耗时任务
withContext(Dispatchers.IO){

}
}
val taskResult2 = async {
//子线程执行耗时任务
withContext(Dispatchers.IO){

}
}
//taskResult1 和 taskResult2 都返回结果才会继续执行
taskResult1.await() + taskResult2.await()
}
}

fun cancelJob() {
//取消 job1 对应的协程
job1?.cancel("cancel job1")
}

fun cancelScope() {
//取消 scope 对应的所有协程
scope.cancel("cancel scope")
}

在上面的例子中:



  • scope:定义主线程分派任务的 scope 来跟踪它使用 launch 或 async 创建的所有协程;

  • job1:管理它对应的协程的生命周期;

  • withContext(Dispatchers.IO):切换到子线程执行耗时任务;

  • cancelJob 会取消 job1 对应的协程;

  • cancelScope 会取消 scope 启动的所有协程。


Flow 数据流


了解了 Kotlin 协程的一些基础 概念和 API 后,知道了协程的基本使用。接下来,再了解一下 Kotlin Flow 相关的概念和 API。



Kotlin 中的 Flow API 旨在异步处理按顺序执行的数据流。Flow 本质上是一个 Sequence。我们可以像对 Kotlin 中 Sequence 一样来操作Flow:变换,过滤,映射等。Kotlin Sequences 和 Flow 的主要区别在于 Flow 可以挂起



如果有理解 Kotlin Sequence,那其实很好理解 Kotlin Flow。刚好,在前面一篇 Kotlin 惰性集合操作-序列 Sequence文章中,有分析 Sequence 的原理,这里也可以把 Flow 按照类似的原理进行理解。


val sequenceResult = intArrayOf(1, 2, 3).asSequence().map { it * it }.toList()

MainScope().launch{
val flowResult = intArrayOf(1, 2, 3).asFlow().map { it * it }.toList(mutableListOf())
}

上面 sequenceResult 和 flowResult 的值都是:[1, 4, 9]


在 Sequence 中,如果没有末端操作,中间操作不会被执行。在 Flow 中也是一样,如果数据流没有数据消费collect ,中间操作也不会被执行。


flowOf(bitmap).map { bmp ->
//在子线程中执行耗时操作,存储 bitmap 到本地
saveBitmap(bmp)
}.flowOn(Dispatchers.Default)

上面代码中,map 操作不会被执行。


一个完整的数据流应该包含:数据生产( flowOf , asFlow, flow{} )→ 中间操作(map, filter等)→ 数据消费(collect,asList,asSet等)。下面将分别了解相关操作。


数据流:数据生产


数据生产主要是通过数据源构建数据流。可以使用 Builders.kt 中提供的 Flow 相关扩展方法,如:


intArrayOf(1, 2, 3).asFlow().map { it * it }

val downloadUrl = "https://github.com/ReactiveX/RxJava"
flowOf(downloadUrl).map { downloadZip(it) }

(1..10).asFlow().filter { it % 2 == 0 }

通常使用 flowOfasFlow方法直接构建数据流。它们创建的都是冷流:



冷流:这段 flow 构建器中的代码直到流被收集(collect)的时候才运行。



也可以通过 flow{} 来构建数据流,使用emit方法将数据源添加到数据流中:


    flow<Int> {
emit(1)
withContext(Dispatchers.IO){
emit(2)
}
emit(3)
}.map { it * it }

不管是 flowOfasFlow 还是 flow{},它们都会实现接口 FlowCollector


public fun <T> flow(@BuilderInference block: suspend FlowCollector<T>.() -> Unit): Flow<T> = SafeFlow(block)

internal inline fun <T> unsafeFlow(@BuilderInference crossinline block: suspend FlowCollector<T>.() -> Unit): Flow<T> {
return object : Flow<T> {
override suspend fun collect(collector: FlowCollector<T>) {
collector.block()
}
}
}

接口 FlowCollector 提供的 emit 方法,负责将源数据添加到数据流中:


public fun interface FlowCollector<in T> {

/**
* Collects the value emitted by the upstream.
* This method is not thread-safe and should not be invoked concurrently.
*/
public suspend fun emit(value: T)
}

总结:构建数据流可以使用 Flow 相关扩展方法: flowOf , asFlow, flow{},它们都是通过接口 FlowCollector 提供的 emit 方法,将源数据添加到数据流中。


数据流:中间操作


中间操作主要修改发送到数据流的值,或修正数据流本身。如 filter, map, flatMapConcat 操作等:


intArrayOf(1, 2, 3).asFlow().map { it * it }.collect{ }

(1..100).asFlow().filter { it % 2 == 0 }.collect{ }

val data = hashMapOf<String, List<String>>(
"Java" to arrayListOf<String>("xiaowang", "xiaoli"),
"Kotlin" to arrayListOf<String>("xiaozhang", "xiaozhao")
)
flow<Map<String, List<String>>> {
emit(data)
}.flatMapConcat {
it.values.asFlow()
}.collect{ }

中间操作符有很多,根据使用场景大概可分为:



  • 转换操作符:简单转换可以使用过滤 filter,映射 map操作,复杂转换可以使用变换 transform操作;

  • 限长过渡操作符:在流触及相应限制的时候会将它的执行取消,可以使用获取 take操作,take(2)表示只获取前两个值;

  • 丢弃操作符:丢弃流中结果值,可以使用丢弃 drop 操作,drop(2)表示丢弃前两个值;

  • 展平操作符:将给定的流展平为单个流,flatMapConcatflattenConcat 操作表示顺序收集传入的流操作,flatMapMergeflattenMerge操作表示并发收集所有传入的流,并将它们的值合并到一个单独的流,以便尽快的发射值操作,flatMapLatest 操作表示以展平的方式收集最新的流操作;

  • 组合操作符:将多个流组合,zip操作表示组合两个流的值,两个流都有值才进行组合操作,combine 操作表示组合两个流最新的值,每次组合的时候都是使用每个流最新的值;

  • 缓冲操作符:当数据生产比数据消费快的时候,可以使用缓冲buffer操作,在数据消费的时候可以缩短时间;

  • 合并操作符:合并发射项,不对每个值进行处理,可以使用合并conflate 操作,跳过中间值;

  • flowOn 操作符:更改流发射的上下文,会将 flowOn操作前的操作切换到 flowOn 指定的上下文Dispatchers.DefaultDispatchers.IODispatchers.Main,也就是指定前面的操作所执行的线程;


上面介绍了主要的操作符的大致使用场景,操作符详细解释可以查看官方文档:异步流


中间操作符代码示例:


(1..3).asFlow().take(2).collect{
//收集到结果值 1,2
}

(1..3).asFlow().drop(2).collect{
//收集到结果值 3
}

    private fun downloadVideo(videoUrl: String): Pair<String, String> {
return Pair(videoUrl, "videoFile")
}

private fun downloadAudio(audioUrl: String): Pair<String, String> {
return Pair(audioUrl, "audioFile")
}

private fun downloadImage(imageUrl: String): Pair<String, String> {
return Pair(imageUrl, "imageFile")
}

MainScope().launch {
val imageDownloadUrls = arrayListOf<String>("image1", "image2")
val audioDownloadUrls = arrayListOf<String>("audio1", "audio2", "audio3")
val videoDownloadUrls = arrayListOf<String>("video1", "video2", "video3", "video4")
val imageFlows = imageDownloadUrls.asFlow().map {
downloadImage(it)
}
val audioFlows = audioDownloadUrls.asFlow().map {
downloadAudio(it)
}
val videoFlows = videoDownloadUrls.asFlow().map {
downloadVideo(it)
}
merge(imageFlows, audioFlows, videoFlows).flowOn(Dispatchers.IO).onEach {
Log.d("TestFlow", "result=$it")
}.collect()
}
控制台输出结果:
TestFlow: result=(image1, imageFile)
TestFlow: result=(image2, imageFile)
TestFlow: result=(audio1, audioFile)
TestFlow: result=(audio2, audioFile)
TestFlow: result=(audio3, audioFile)
TestFlow: result=(video1, videoFile)
TestFlow: result=(video2, videoFile)
TestFlow: result=(video3, videoFile)
TestFlow: result=(video4, videoFile)

merge 操作符将多个流合并到一个流,支持并发。类似 RxJava 的 zip 操作

(1..3).asFlow().onStart {
Log.d("TestFlow", "onStart:${Thread.currentThread()}")
}.flowOn(Dispatchers.Main).map {
Log.d("TestFlow", "map:$it,${Thread.currentThread()}")
if (it % 2 == 0)
throw IllegalArgumentException("fatal args:$it")
it * it
}.catch {
Log.d("TestFlow", "catch:${Thread.currentThread()}")
emit(-1)
}.flowOn(Dispatchers.IO)
.onCompletion { Log.d("TestFlow", "onCompletion:${Thread.currentThread()}") }
.onEach {
Log.d("TestFlow", "onEach:$it,${Thread.currentThread()}")
}.collect()
控制台输出结果:
TestFlow: onStart:Thread[main,5,main]
TestFlow: map:1,Thread[DefaultDispatcher-worker-3,5,main]
TestFlow: map:2,Thread[DefaultDispatcher-worker-3,5,main]
TestFlow: catch:Thread[DefaultDispatcher-worker-3,5,main]
TestFlow: onEach:1,Thread[main,5,main]
TestFlow: onEach:-1,Thread[main,5,main]
TestFlow: onCompletion:Thread[main,5,main]

flowOn 指定 onStart 在主线程中执行(Dispatchers.Main),指定 map 和 catch 在 IO 线程中执行(Dispatchers.IO)

总结:中间操作其实就是数据流的变换操作,与 Sequence 和 RxJava 的变换操作类似。


数据流:数据消费


数据消费就是使用数据流的结果值。末端操作符最常使用 collect来收集流结果值:


 (1..3).asFlow().collect{
//收集到结果值 1,2,3
}

除了 collect 操作符外,还有一些操作符可以获取数据流结果值:



  • collectLatest:使用数据流的最新值;

  • toListtoSet等:将数据流结果值转换为集合;

  • first:获取数据流的第一个结果值;

  • single:确保流发射单个(single)值;

  • reduce :累积数据流中的值;

  • fold :给定一个初始值,再累积数据流中的值。


末端操作符代码示例:


  (1..3).asFlow().collectLatest {
delay(300)
//只能获取到3
}

//转换为 List 集合 [1,2,3]
val list = (1..3).asFlow().toList()
//转换为 Set 集合 [1,2,3]
val set = (1..3).asFlow().toSet()

val first = (1..3).asFlow().first()
//first 为第一个结果值 1

val single = (1..3).asFlow().single()
//流不是发射的单个值,会抛异常

val reduce = (1..3).asFlow().reduce { a, b ->
a + b
}
//reduce 的值为6=1+2+3

val fold = (1..3).asFlow().fold(10) { a, b ->
a + b
}
//fold 的值为16=10+1+2+3

除了上面这些末端操作符,在末端之前还关联着一些操作符:



  • onStart:在数据流结果值收集之前调用;

  • onCompletion:在数据流结果值收集之后调用;

  • onEmpty:在数据流完成而不发出任何元素时调用;

  • onEach:在数据流结果值收集时迭代流的每个值;

  • catch :在收集数据流结果时,声明式捕获异常。


末端关联操作符代码示例:


 (1..3).asFlow().onStart {
Log.d("TestFlow", "onStart")
}.map {
if (it % 2 == 0)
throw IllegalArgumentException("fatal args:$it")
it * it
}.catch { emit(-1) }.onCompletion { Log.d("TestFlow", "onCompletion") }.onEach {
Log.d("TestFlow", "onEach:$it")
}.collect()

控制台输出结果:
TestFlow: onStart
TestFlow: onEach:1
TestFlow: onEach:-1
TestFlow: onCompletion

总结:数据流进行数据消费时,可以结合末端操作符输出集合,累积值等,当要监听数据流收集结果值开始或结束,可以使用 onStartonCompletion,当遇到流抛出异常,可以声明 catch进行异常处理。


总结


响应式编程,可以理解为一种面向数据流编程的方式,也就是使用数据源构建数据流 → 修改数据流中的值 → 处理数据流结果值,在这个过程中,一系列的事件或操作都是按顺序发生的。在 Java 环境中,RxJava 框架实现了响应式编程,它结合了数据流、观察者模式、线程框架;在 Kotlin 环境中,Kotlin 协程和 Flow 结合在一起实现了响应式编程,其中协程就是线程框架,Flow 就是数据流。不管是 RxJava 还是 Kotlin 协程和 Flow 的实现的响应式编程,它们的目的都是为了:使用优雅,简洁,易阅读,易维护的代码来编写并发编程,处理异步操作事件。另外,Android LifeCycle 和 ViewModel 对 Kotlin 协程和 Flow 进行了扩展支持,这也对异步事件进行生命周期管理更方便。


参考文档:



下一篇将探索 Kotlin Flow 冷流和热流。


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

2023年大厂新年礼盒大赏,网友:我一点也不酸

新年倒计时啦,又到了各大公司发放新年礼盒的时候了。如何为辛苦了一年工作的员工们献上最真挚的祝愿,如何让员工在朋友圈晒图的时候感觉倍儿有面,如何让品牌文化在不经意间传播在各大社交圈,那非新年礼盒莫属了!废话不多说,让我们一起看看各大公司在兔年到来之际都给我们带来...
继续阅读 »

新年倒计时啦,又到了各大公司发放新年礼盒的时候了。

如何为辛苦了一年工作的员工们献上最真挚的祝愿,如何让员工在朋友圈晒图的时候感觉倍儿有面,如何让品牌文化在不经意间传播在各大社交圈,那非新年礼盒莫属了!

废话不多说,让我们一起看看各大公司在兔年到来之际都给我们带来了什么样的惊喜吧!

字节

字节今年的礼盒依旧是走实用风,春联、红包必不可少;一封家书一支笔,纸短情长;一份月历、一个颈部按摩仪,守护你的健康,一起走过2023。


京东

京东今年的礼盒可谓是“可可爱爱,没有头脑”。春联、红包雷打不动;携三两好友,坐在露营垫上,吃着零食礼包,捏一捏减压球,惬意的生活这就来啦。


阿里

阿里依旧是温情路线——家书,可爱公仔属实是拿捏了,新年撞上了“阿里动物园”,让人眼前一亮。


百度

百度的新年礼盒堪称是全场的最重量级选手,重8.4KG,除了居家必备的空气炸锅外,这份“兔年大吉”大礼包还包含了毛绒小熊、包饺子4件套、吉语祝福筷、蘸料碟、月历、手账本, 是真的把你生活各方面需求全给安排上了。


华为

华为奋斗者新年礼盒让人看了感觉热血澎湃!水杯、象棋、兔子盲盒、新年对联福字、一封信,颜值实用都在线,尤其是兔子盲盒,一下子戳中了心巴。(ps:省份之间礼盒不一样,据说还有WATCH 3手表)


腾讯

腾讯今年的礼盒依旧延续了简约务实的风格,一看包装就很“腾讯”,里面有春联、红包、福字、笔记本,浓浓的年味。


网易互娱

网易互娱的新年礼盒外形设计主打摩登风,礼盒里有:舒压按摩仪、运动随行保温杯、文房四宝、智能两用跳绳、每日黑巧、每日坚果、新春周边,2023邀你易起奔走于新一章的热爱里。


小红书

小红书的“HAO HAO2023”礼盒,这一次可谓是玩“懂”了员工的心思,把新年装进挎包,带着薯队长,裹紧毛毯,拿着新的本子,轻松跨年!


知乎

知乎今年是一本日历,一支笔,一本笔记本,特别是知乎日历延续了知乎问答社区的属性,每天一个问答,既涨知识又充满仪式感。


得物

得物新年礼盒第一层是“温暖”,毛毯、帽子和围巾,裹上一层温暖,心也开始有了温度;第二层是新年气氛组,灯笼、红包、春联和福字,年味瞬间拉满;第三层是新年防护套装,做好自己健康的第一责任人!


360

360的礼盒可以说是塞得满满,火力全开的电火锅、富得牛油火锅底料、同心并进筷子、春联福字红包组合套装,简直不要太懂干饭人。


携程

携程的礼盒浓浓的酷炫风,香薰蜡烛、露营灯&口哨、转运扑克、红包套装,2023加满超级BUFF,开启新年礼遇。


搜狐

搜狐今年的礼盒可以说得上是最干的了,甜香软糯的玉米、口感细腻的栗子、香甜软糯的薯干、颗粒饱满的小米,可以说听起来就让人流口水啦!(PS:搜狐文化还有礼盒哦)


大疆

大疆的新年礼盒是出乎意料的“大”,居家旅行必备,新年之际, 带上行李箱回家过年,你绝对是路上最靓的仔~


OPPO

OPPO今年的新年礼盒很有新意,颜值担当非网绿小欧和《故宫里的神兽世界》系列盲盒莫属,台历也别具一格,每一页都有科技知识科普,还有很OPPO的笔记本和新年贺卡,这一波给满分!


ps:以上素材均由小编整理,来源于网络,排名无先后顺序。

来源:51CTO技术栈 | mp.weixin.qq.com/s/G6b_NraWT0gxF5CoIVD89Q

收起阅读 »

kotlin快速实现一款小游戏,糖果雨来啦

前言回想小时候,一到冬天就开始期盼着学校快点放寒假,期盼着快点过年。因为过年有放不完的鞭炮与吃不完的糖果,犹记得那时候我的口袋里总是充满着各式各样的糖果。今天就以糖果为主题,实现糖果雨来啦这个互动小游戏。效果展示开始引导页面糖果收集页面收集结束页面实现细节具体...
继续阅读 »

前言

回想小时候,一到冬天就开始期盼着学校快点放寒假,期盼着快点过年。因为过年有放不完的鞭炮与吃不完的糖果,犹记得那时候我的口袋里总是充满着各式各样的糖果。今天就以糖果为主题,实现糖果雨来啦这个互动小游戏。

效果展示

开始引导页面糖果收集页面收集结束页面



实现细节

具体实现其实也很简单,主要分为3块内容:

  1. 开始引导页面:提供开始按钮来告诉用户如何开始,3秒倒计时动画,让用户做好准备。

  2. 糖果收集页面:自动生成糖果并从上往下掉落,用户点击糖果完成收集(糖果消失 & 糖果收集总数加一)。

  3. 收集结束页面:告诉用户一共收集了多少糖果,提供再玩一次按钮入口。

引导动画

如果单单是一个静态页面,提供文字来提醒用户如何开始游戏,会略显单调,所以我加了一些自定义View动画,模拟点击动作,来达到提醒用户作用。

利用三个动画组合在一起同时执行,从达到该效果,分别是:

  1. 手指移动去点击动画。

  2. 点击后的水波纹动画。

  3. 点击后糖果+1动画。

这里我们以 点击后糖果+1动画 举例。

我们先建一个res/anim/candy_add_anim.xml文件,如下:

<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android">

   <alpha
       android:duration="3000"
       android:fromAlpha="0.0"
       android:repeatCount="-1"
       android:repeatMode="restart"
       android:toAlpha="1.0" />

   <translate
       android:duration="3000"
       android:fromYDelta="0%"
       android:interpolator="@android:anim/accelerate_interpolator"
       android:repeatCount="-1"
       android:repeatMode="restart"
       android:toYDelta="-10%p" />

   <scale
       android:duration="3000"
       android:fromXScale="0"
       android:fromYScale="0"
       android:pivotX="50%"
       android:pivotY="50%"
       android:repeatCount="-1"
       android:repeatMode="restart"
       android:toXScale="1"
       android:toYScale="1" />

</set>

然后在指定的View中执行该动画,如下:

binding.candyAddOneTv.apply {
   val animation = AnimationUtils.loadAnimation(context, R.anim.candy_add_anim)
   startAnimation(animation)
}

糖果的生成

从效果展示图中也可以看出,糖果的样式是各式各样的且其位置坐标是随机的。

我通过代码动态生成一个大小固定的TextView,然后通过设置layoutParams.setMargins来确定其坐标,通过setBackground(drawable)来设置糖果背景(为了使生成的糖果是各式各样的,所以我找了一些糖果的SVG图来作为背景),然后加入到View.root

具体代码如下:

//随机生成X坐标
val leftMargin = (0..(getScreenWidth() - 140)).random()
TextView(this).apply {
   layoutParams = FrameLayout.LayoutParams(140, 140).apply {
       setMargins(leftMargin, -140, 0, 0)
  }
   background = ContextCompat.getDrawable(this@MainActivity, generateRandomCandy())
   binding.root.addView(this)
}

并且通过协程delay(250),来达到一秒钟生成4颗糖果。

fun generatePointViewOnTime() {
   viewModelScope.launch {
       for (i in 1..60) {
           Log.e(TAG, "generatePointViewOnTime: i = $i")
           pointViewLiveData.value = i
           if (i % 4 == 0) {
               countDownTimeLiveData.postValue(i / 4)
          }
           delay(250)
      }
  }

}

糖果的掉落

介绍完了糖果的生成,接着就是糖果的掉落效果实现。

这里我们同样使用View动画即可完成,通过translationY(getScreenHeight().toFloat() + 200)来让糖果从最上方平移出屏幕最下方,同时为其设置加速插值器,达到掉落速度越来越快的效果。

整个平移时间设置为3s,具体代码如下:

private fun startMoving(view: View) {
   view.apply {
       animate().apply {
           interpolator = AccelerateInterpolator()
           duration = 3000
           translationY(getScreenHeight().toFloat() + 200)
           start()
      }
  }
}

糖果的收集

点击糖果,糖果消失,糖果收集总数+1。所以我们只需为其设置点击监听器,在用户点击时,为TextView设置visibility以及catchNumber++即可。

TextView(this).apply {
   ···略···

   setOnClickListener {
       this.visibility = View.GONE
       Log.e(TAG, "onCreate: tag = ${it.tag}, id = ${it.id}")
       catchNumber++
       binding.catchNumberTv.text = getString(R.string.catch_number, catchNumber)
       doVibratorEffect()
  }
}

点击反馈

为了更好的用户体验,为点击设置震动反馈效果。

private fun doVibratorEffect() {
   val vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
       val vibratorManager =
           getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager
       vibratorManager.defaultVibrator
  } else {
       getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
  }

   if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
       vibrator.vibrate(VibrationEffect.createOneShot(30, VibrationEffect.DEFAULT_AMPLITUDE))
  } else {
       vibrator.vibrate(30)
  }
}

结束弹窗

当糖果收集结束后,弹出一个结束弹窗来告诉用户糖果收集情况,这里采用属性动画,让弹窗弹出的效果更加的生动。

private fun showAnimation(view: View) {
   view.scaleX = 0F
   view.scaleY = 0F

   //zoom in 放大;zoom out 缩小;normal 恢复正常
   val zoomInHolderX = PropertyValuesHolder.ofFloat("scaleX", 1.05F)
   val zoomInHolderY = PropertyValuesHolder.ofFloat("scaleY", 1.05F)
   val zoomOutHolderX = PropertyValuesHolder.ofFloat("scaleX", 0.8F)
   val zoomOutHolderY = PropertyValuesHolder.ofFloat("scaleY", 0.8F)
   val normalHolderX = PropertyValuesHolder.ofFloat("scaleX", 1F)
   val normalHolderY = PropertyValuesHolder.ofFloat("scaleY", 1F)
   val zoomIn = ObjectAnimator.ofPropertyValuesHolder(
       view,
       zoomInHolderX,
       zoomInHolderY
  )

   val zoomOut = ObjectAnimator.ofPropertyValuesHolder(
       view,
       zoomOutHolderX,
       zoomOutHolderY
  )
   zoomOut.duration = 400

   val normal = ObjectAnimator.ofPropertyValuesHolder(
       view,
       normalHolderX,
       normalHolderY
  )
   normal.duration = 500

   val animatorSet = AnimatorSet()
   animatorSet.playSequentially(zoomIn, zoomOut, normal)
   animatorSet.start()
}

总结

如果你对该小游戏有兴趣,想进一步了解一下代码,可以参考Github Candy-Catch,欢迎你给我点个小星星。

相信很多人都有这样的感受,随着年龄的增加,越来越觉得这年味越来越淡了,随之而来对过年的期盼度也是逐年下降。在这里,我愿大家童心未泯,归来仍是少年!

最后,给大家拜个早年,祝大家新春快乐

其实分享文章的最大目的正是等待着有人指出我的错误,如果你发现哪里有错误,请毫无保留的指出即可,虚心请教。

另外,如果你觉得文章不错,对你有所帮助,请给我点个赞,就当鼓励,谢谢~Peace~!

作者:Jere_Chen
来源:juejin.cn/post/7054194708410531876

收起阅读 »

如何打造一个优雅的git工作流

Git
在开发中,不论是一个团队一起开发一个项目,还是自己独立开发一个项目。都少不了要和git打交道。面对不同的开发场景,或许每个团队都有自己的git工作流。这里,我想分享一下我的团队目前正在使用的基于gitlab的git工作流。一起交流一下。规范化的git流程能降低...
继续阅读 »

在开发中,不论是一个团队一起开发一个项目,还是自己独立开发一个项目。都少不了要和git打交道。面对不同的开发场景,或许每个团队都有自己的git工作流。这里,我想分享一下我的团队目前正在使用的基于gitlabgit工作流。一起交流一下。

规范化的git流程能降低我们的出错概率,也不会经常遇到git问题,然后去搜一堆git高阶用法。我们的这套git玩法儿,其实只要会基本的git操作就行了,然后规范化操作,基本不会遇到git问题,这样大家就可以将时间用于业务上。最终,希望大家研究git的时候是在感兴趣的时候,而不是遇到问题,紧急去寻找答案的时候

我们的这种git工作流玩儿法呢,主要是分为下面几个分支:

  • master分支 最新的稳定代码

  • vx.x.x分支 版本分支,x.x.x是此次开发的版本号。

  • feat-xxx分支 特性(新的功能)分支

  • fix-xxx分支 修复分支

上面的这些分支呢,就是我们在开发中需要经常去创建并使用的分支。下面详细说说每个分支代表的意思。

master分支代表的是最新的稳定版本的代码,一般是版本分支或者修复分支的代码上线后合并过来的。

feat-xxx分支表示的是为开发某个版本的某个新功能而创建的分支。

vx.x.x代表的是版本分支,这个是我们在每个版本开始前,以此次版本号为名从master创建的分支,比如版本号是 2.0.1,那么版本分支则为 v2.0.1。然后等到该版本的各个新功能在feat-xxx开发完成并冒烟测试通过后,就到gitlab上提一个mr合并到该版本分支上。等到各个环境测试通过后,就将版本分支的代码合并到master上,然后就可以删除本次的版本分支了。

fix-xxx表示的是修复分支,通常在处理线上问题时,创建一个以缺陷名称命名的分支,在缺陷测试通过后,通过mr合并到master分支去

注意:这里有个细节是,在特性分支上开发提交的commit信息,一般认为是无用信息,会在合并给版本分支的时候给合并到一个commit(由于我们是使用gitlab来合并,所以在发起mr请求时勾选squash选项就好了),而在提测后不论是修复测试过程中bug,或者是优化功能的commit则会全部保留,这个目的是一个警示,因为我希望最好的情况是提测即上线,虽然达到这个目标有难度,但是这些留下的commit信息可以帮助我们复盘

各个分支的作用如上面所描述的那样,接着聊聊我们开发的一些经典场景该怎么做:

第一个场景:正常开发迭代

我们以本次需要开发一个 1.0.0版本为例,这个其中有两个功能模块,一个是需要添加一个按钮,一个是需要添加一个表格

masterv1.0.0feat-add-buttonfeat-add-form从master切出 v1.0.0从master切出 feat-add-button从master切出 feat-add-button开发完成开发完成在gitlab发起mr到v1.0.0,并合并所有commit在gitlab发起mr到v1.0.0,并合并所有commit提测修复测试bug将修复的 commit cherry pick到 v1.0.0在gitlab上mr到master,并将合并信息改成 v1.0.0masterv1.0.0feat-add-buttonfeat-add-form

通过上面的时序图,可以看到,我们以我们即将开始的版本命名了一个版本分支 v1.0.0,并且也根据这个版本下面的两个功能创建了两个特性分支 feat-add-buttonfeat-add-form,然后等功能开发完成后再通过gitlab发起mr(注意,这里要把合并commit选项勾选上)合并到 v1.0.0,那么 v1.0.0分支的代码就会从dev环境开始流转,直到生产环境。这其中,如果有需要修复或者优化的地方,也是先修改特性分支,然后再cherry pick到版本分支上面。上线以后删除版本分支以及下面的特性分支。

通过这个流程管理的代码版本非常清晰,这是截取的master的一部分片段


在正常迭代流程还有个场景。那就是在开发过程中,pm突然过来说,因为某种不可抗力,有一个功能需要砍掉。这个时候,如果是代码还没提测,亦或者是功能比较简单,处理起来还不算麻烦。但如果是,你的功能和其他同事的代码已经在测试了,并且也已经修复了一些bug,commit都交叉在一起,特别是那种涉及修改文件还多的需求,这个时候处理起来就很麻烦,不仅要看着别人的代码,还得警惕自己的代码别弄错了。那这个时候,在我们流程里就很简单,直接删除现有的版本分支就好了,再重新将需要上线的特性分支组合在一起就可以了。可以看到,版本分支是由特性分支组合起来的,也就是说,版本分支可以由不同的特性分支随意组合。这样处理起来就比较方便

第二个场景 线上bug修复

我们以线上需要修复一个按钮的点击事件为例

masterfix-button-click从master切出 fix-button-click修复问题并测试从gitlab发起mr合并到mastermasterfix-button-click

其实这里的流程跟上面没多大的区别,但是这里需要注意的是,线上问题修复,一个bug一个commit,合并到master的时候不合并commit。而且需要将合并信息修改为本次的版本号。比如本次则为 v1.0.1

第三个场景 多版本并行开发

这个场景跟正常迭代场景并没啥区别,只是取决于你有多个版本,就创建对应的版本分支就可以了。每个版本分支按照正常迭代流程就可以了。

Q&A

Q:为什么没有使用dev、test等对应环境的分支,这样也好实现push既部署

A:我们这个流程是放弃了使用这些固定的分支的。有几个原因,

  1. 代码提测后从dev到test,甚至再到uat(预发布)环境,如果在不同的环境都有代码的变动,那么为了保持这些分支代码一致的话,就需要将代码同步到各个环境分支,这点儿有些费事儿。而版本分支不存在这个问题,版本分支只有一个,可以对应到各个环境。

  2. 方便多版本并行开发。版本分支可以创建多个,并行开发的时候比较方便部署到不同的测试环境。如果版本之间的模块关联性不大,还可以并行测试。

  3. 语义化。版本分支可以通过分支名称就知道目前有哪些分支正在开发中。

Q: master分支有变动怎么处理

A: master分支有变动的话,及时的合并到自己的功能分支上,以防和其他成员代码有冲突

写在最后

以上就是我的分享了,橘生淮南,适合我的未必适合大家,互相交流罢了

作者:雲天
来源:juejin.cn/post/7186946414620966967

收起阅读 »

运行环信Android Demo常见问题以及语音消息播放声音小的解决方法

运行Demo为什么会下载不下来aar 导致demo的项目无法正常运行打开到 buildgradle , 将MavenCental()至前,在maven库 阿里云和华为里 添加allowInsecureProtocol = true 添加后编译一下 可以在远...
继续阅读 »
运行Demo为什么会下载不下来aar 导致demo的项目无法正常运行
打开到 buildgradle , 将MavenCental()至前,在maven库 阿里云和华为里 添加
allowInsecureProtocol = true
添加后编译一下 可以在远程包里查看下是否下载成功 会 一般是4个aar  
1.easeimkit aar 2.easecallkit 3.easechat 4.rtc 3.6.2aar
1.环信3.9.3 sdk登录慢的问题
初始化打开 options.setFpaEnable(true)(全球加速)
2.播放语音时 语音声音小

1.首先要打开扬声器 如果觉得声音还是比较小
2.将ui库中调用的原声音量模式修改为媒体音量模式

3.发送语音、视频、文件体积超过10MB
相机是直接调用的系统的,跟随的是系统的大小,我拍摄15s视频大概18m左右。环信系统默认的是只能发送10M的视频文件,可以联系商务经理开通上调发送体积
4.关于百度地图切换至高德地图
demo中百度地图的so库是放在项目层的

1.因为百度地图将easeimkit中关于百度地图的集成去掉,改成高德地图;2.在chatfragment中重写位置的点击事件方法startMapLocation或者是直接在EaseChatFragment中直接修改点击事件startMapLocation跳转到高德地图;3.在调用环信api去发送地理位置消息时,传入高德获取到的经纬度
2.点击位置的点击事件更换 ,demo中的点击事件是在EaseChatFragment下的onExtendMenuItemClick里面官方提供了EaseBaiduMapActivity 这个定位页面。2.修改为高德其实非常简单只需要在ChatFragment操作就可以了2.1修改点击事件在ChatFragment的onExtendMenuItemClick方法中添加2.2 在自己实现高德地图的页面返回定位信息 参数名称不要修改 不然其它地方也要修改2.3接下来在ChatFragment中的onActivityResult中接收定位信息并发送消息走到这里从高德获取的位置消息已经成功发送给好友了 接下来是获取查看好友位置消息2.4 查看位置消息还是在ChatFragment里 通过getCustomChatRow方法LoccationAdapter 继承位置消息展示 重写了点击事件即可。
5.语音消息amr格式转为MP3格式
需要本地库倒入easeimkit

收起阅读 »

集成环信uni-app sdk遇到的问题及解决方法

1. 打包问题    问题描述:        a. 打包h5后报错 [system] API connectSocket is not yet implemented        b. 打包后登录时请求token有问题。    解决方案:如果打包h5平台出...
继续阅读 »

1. 打包问题

    问题描述:
        a. 打包h5后报错 [system] API connectSocket is not yet implemented

        b. 打包后登录时请求token有问题。

    解决方案:如果打包h5平台出现以上两种情况,可以看下打包时想优化包体积大小是否有开启【摇钱树】具体配置如图:

         ps: 不了解该配置的可以看下uniapp的官方文档介绍,附上链接https://uniapp.dcloud.io/collocation/manifest?id=treeshaking

    问题原因:如果开启这个配置项,打包后所有uni没用到的方法都不会打包进去,这样就会导致SDK内部 uni去用request请求就拿不到,这样后续token就会有问题,或者识别不到scoket api等报错。


2. uniapp运行真机报错 【addEventListener is not defind】


解决方案:升级到4.1.0的uni sdk即可。

问题原因:addEventListener 这个是监听浏览器网络变化的,移动端下不支持,所以提示未定义,但实际上并不会影响其他功能,在后续的版本也修复了下该报错~


3. 参考demo报错【this.setData is not a function】如图:

集成过程中可能疑惑this.setData应该是小程序中的方法,为什么uni中会有,是因为demo中有对该方法重写通过minxin,具体在main.js文件中体现,如下图:

所以如果参照demo报此错可以看下这块是否有复制过来呢~


4. uniapp运行h5发送语音报错

目前的录音实现依赖uni.getRecorderManager()方式, 是不支持 H5的 可以参考下这个文章
https://en.uniapp.dcloud.io/api/media/record-manager.html#getrecordermanager


5. 登录报错 elapse 如图:


解决方案:1)看下当前是否有链接网络  2)是否有开启vpn 

6. uni-app中有时会用到nvue组件,订阅事件将会在nvue中失效,所以如果有发布订阅事件需求推荐使用,uni.$emit发布,uni.$on监听。


今天问题就分享到这里啦,感谢大家的阅读!

收起阅读 »

基于 Android 系统方案适配 Night Mode 后,老板要再加一套皮肤?

背景说明 原本已经基于系统方案适配了暗黑主题,实现了白/黑两套皮肤,以及跟随系统。后来老板研究学习友商时,发现友商 App 有三套皮肤可选,除了常规的亮白和暗黑,还有一套暗蓝色。并且在跟随系统暗黑模式下,用户可选暗黑还是暗蓝。这不,新的需求马上就来了。 其实我...
继续阅读 »

背景说明


原本已经基于系统方案适配了暗黑主题,实现了白/黑两套皮肤,以及跟随系统。后来老板研究学习友商时,发现友商 App 有三套皮肤可选,除了常规的亮白和暗黑,还有一套暗蓝色。并且在跟随系统暗黑模式下,用户可选暗黑还是暗蓝。这不,新的需求马上就来了。


其实我们之前两个 App 的换肤方案都是使用 Android-skin-support 来做的,在此基础上再加套皮肤也不是难事。但在新的 App 实现多皮肤时,由于前两个 App 做了这么久都只有两套皮肤,而且新的 App 需要实现跟随系统,为了更好的体验和较少的代码实现,就采用了系统方案进行适配暗黑模式。


Android-skin-support 和系统两种方案适配经验来看,系统方案适配改动的代码更少,所花费的时间当然也就更少了。所以在需要新添一套皮肤的时候,也不可能再去切方案了。那么在使用系统方案的情况下,如何再加一套皮肤呢?来,先看源码吧。


源码分析



以下源码基于 android-31



首先,在代码中获取资源一般通过 Context 对象的一些方法,例如:


// Context.java

@ColorInt
public final int getColor(@ColorRes int id) {
return getResources().getColor(id, getTheme());
}

@Nullable
public final Drawable getDrawable(@DrawableRes int id) {
return getResources().getDrawable(id, getTheme());
}

可以看到 Context 是通过 Resources 对象再去获取的,继续看 Resources


// Resources.java

@ColorInt
public int getColor(@ColorRes int id, @Nullable Theme theme) throws NotFoundException {
final TypedValue value = obtainTempTypedValue();
try {
final ResourcesImpl impl = mResourcesImpl;
impl.getValue(id, value, true);
if (value.type >= TypedValue.TYPE_FIRST_INT
&& value.type <= TypedValue.TYPE_LAST_INT) {
return value.data;
} else if (value.type != TypedValue.TYPE_STRING) {
throw new NotFoundException("Resource ID #0x" + Integer.toHexString(id) + " type #0x" + Integer.toHexString(value.type) + " is not valid");
}
// 这里调用 ResourcesImpl#loadColorStateList 方法获取颜色
final ColorStateList csl = impl.loadColorStateList(this, value, id, theme);
return csl.getDefaultColor();
} finally {
releaseTempTypedValue(value);
}
}

public Drawable getDrawable(@DrawableRes int id, @Nullable Theme theme)
throws NotFoundException {
return getDrawableForDensity(id, 0, theme);
}

@Nullable
public Drawable getDrawableForDensity(@DrawableRes int id, int density, @Nullable Theme theme) {
final TypedValue value = obtainTempTypedValue();
try {
final ResourcesImpl impl = mResourcesImpl;
impl.getValueForDensity(id, density, value, true);
// 看到这里
return loadDrawable(value, id, density, theme);
} finally {
releaseTempTypedValue(value);
}
}

@NonNull
@UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.R, trackingBug = 170729553)
Drawable loadDrawable(@NonNull TypedValue value, int id, int density, @Nullable Theme theme)
throws NotFoundException {
// 这里调用 ResourcesImpl#loadDrawable 方法获取 drawable 资源
return mResourcesImpl.loadDrawable(this, value, id, density, theme);
}

到这里我们知道在代码中获取资源时,是通过 Context -> Resources -> ResourcesImpl 调用链实现的。


先看 ResourcesImpl.java


/**
* The implementation of Resource access. This class contains the AssetManager and all caches
* associated with it.
*
* {@link Resources} is just a thing wrapper around this class. When a configuration change
* occurs, clients can retain the same {@link Resources} reference because the underlying
* {@link ResourcesImpl} object will be updated or re-created.
*
* @hide
*/
public class ResourcesImpl {
...
}

虽然是 public 的类,但是被 @hide 标记了,意味着想通过继承后重写相关方法这条路行不通了,pass。


再看 Resources.java,同样是 public 类,但没被 @hide 标记。我们就可以通过继承 Resources 类,然后重写 Resources#getColorResources#getDrawableForDensity 等方法来改造获取资源的逻辑。


先看相关代码:


// SkinResources.kt

class SkinResources(context: Context, res: Resources) : Resources(res.assets, res.displayMetrics, res.configuration) {

val contextRef: WeakReference<Context> = WeakReference(context)

override fun getDrawableForDensity(id: Int, density: Int, theme: Theme?): Drawable? {
return super.getDrawableForDensity(resetResIdIfNeed(contextRef.get(), id), density, theme)
}

override fun getColor(id: Int, theme: Theme?): Int {
return super.getColor(resetResIdIfNeed(contextRef.get(), id), theme)
}

private fun resetResIdIfNeed(context: Context?, resId: Int): Int {
// 非暗黑蓝无需替换资源 ID
if (context == null || !UIUtil.isNightBlue(context)) return resId

var newResId = resId
val res = context.resources
try {
val resPkg = res.getResourcePackageName(resId)
// 非本包资源无需替换
if (context.packageName != resPkg) return newResId

val resName = res.getResourceEntryName(resId)
val resType = res.getResourceTypeName(resId)
// 获取对应暗蓝皮肤的资源 id
val id = res.getIdentifier("${resName}_blue", resType, resPkg)
if (id != 0) newResId = id
} finally {
return newResId
}
}

}

主要原理与逻辑:



  • 所有资源都会在 R.java 文件中生成对应的资源 id,而我们正是通过资源 id 来获取对应资源的。

  • Resources 类提供了 getResourcePackageName/getResourceEntryName/getResourceTypeName 方法,可通过资源 id 获取对应的资源包名/资源名称/资源类型。

  • 过滤掉无需替换资源的场景。

  • Resources 还提供了 getIdentifier 方法来获取对应资源 id。

  • 需要适配暗蓝皮肤的资源,统一在原资源名称的基础上加上 _blue 后缀。

  • 通过 Resources#getIdentifier 方法获取对应暗蓝皮肤的资源 id。如果没找到,改方法会返回 0


现在就可以通过 SkinResources 来获取适配多皮肤的资源了。但是,之前的代码都是通过 Context 直接获取的,如果全部替换成 SkinResources 来获取,那代码改动量就大了。


我们回到前面 Context.java 的源码,可以发现它获取资源时,都是通过 Context#getResources 方法先得到 Resources 对象,再通过其去获取资源的。而 Context#getResources 方法也是可以重写的,这意味着我们可以维护一个自己的 Resources 对象。ApplicationActivity 也都是继承自 Context 的,所以我们在其子类中重写 getResources 方法即可:


// BaseActivity.java/BaseApplication.java

private Resources mSkinResources;

@Override
public Resources getResources() {
if (mSkinResources == null) {
mSkinResources = new SkinResources(this, super.getResources());
}
return mSkinResources;
}

到此,基本逻辑就写完了,马上 build 跑起来。


咦,好像有点不太对劲,有些 colordrawable 没有适配成功。


经过一番对比,发现 xml 布局中的资源都没有替换成功。


那么问题在哪呢?还是先从源码着手,先来看看 View 是如何从 xml 中获取并设置 background 属性的:


// View.java

public View(Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) {
this(context);

// AttributeSet 是 xml 中所有属性的集合
// TypeArray 则是经过处理过的集合,将原始的 xml 属性值("@color/colorBg")转换为所需的类型,并应用主题和样式
final TypedArray a = context.obtainStyledAttributes(
attrs, com.android.internal.R.styleable.View, defStyleAttr, defStyleRes);

...

Drawable background = null;

...

final int N = a.getIndexCount();
for (int i = 0; i < N; i++) {
int attr = a.getIndex(i);
switch (attr) {
case com.android.internal.R.styleable.View_background:
// TypedArray 提供一些直接获取资源的方法
background = a.getDrawable(attr);
break;
...
}
}

...

if (background != null) {
setBackground(background);
}

...
}

再接着看 TypedArray 是如何获取资源的:


// TypedArray.java

@Nullable
public Drawable getDrawable(@StyleableRes int index) {
return getDrawableForDensity(index, 0);
}

@Nullable
public Drawable getDrawableForDensity(@StyleableRes int index, int density) {
if (mRecycled) {
throw new RuntimeException("Cannot make calls to a recycled instance!");
}

final TypedValue value = mValue;
if (getValueAt(index * STYLE_NUM_ENTRIES, value)) {
if (value.type == TypedValue.TYPE_ATTRIBUTE) {
throw new UnsupportedOperationException(
"Failed to resolve attribute at index " + index + ": " + value);
}

if (density > 0) {
// If the density is overridden, the value in the TypedArray will not reflect this.
// Do a separate lookup of the resourceId with the density override.
mResources.getValueForDensity(value.resourceId, density, value, true);
}
// 看到这里
return mResources.loadDrawable(value, value.resourceId, density, mTheme);
}
return null;
}

TypedArray 是通过 Resources#loadDrawable 方法来加载资源的,而我们之前写 SkinResources 的时候并没有重写该方法,为什么呢?那是因为该方法是被 @UnsupportedAppUsage 标记的。所以,这就是 xml 布局中的资源替换不成功的原因。


这个问题又怎么解决呢?


之前采用 Android-skin-support 方案做换肤时,了解到它的原理,其会替换成自己的实现的 LayoutInflater.Factory2,并在创建 View 时替换生成对应适配了换肤功能的 View 对象。例如:将 View 替换成 SkinView,而 SkinView 初始化时再重新处理 background 属性,即可完成换肤。


AppCompat 也是同样的逻辑,通过 AppCompatViewInflater 将普通的 View 替换成带 AppCompat- 前缀的 View。


其实我们只需能操作生成后的 View,并且知道 xml 中写了哪些属性值即可。那么我们完全照搬 AppCompat 这套逻辑即可:



  • 定义类继承 LayoutInflater.Factory2,并实现 onCreateView 方法。

  • onCreateView 主要是创建 View 的逻辑,而这部分逻辑完全 copy AppCompatViewInflater 类即可。

  • onCreateView 中创建 View 之后,返回 View 之前,实现我们自己的逻辑。

  • 通过 LayoutInflaterCompat#setFactory2 方法,设置我们自己的 Factory2。


相关代码片段:


public class SkinViewInflater implements LayoutInflater.Factory2 {
@Nullable
@Override
public View onCreateView(@Nullable View parent, @NonNull String name, @NonNull Context context, @NonNull AttributeSet attrs) {
// createView 方法就是 AppCompatViewInflater 中的逻辑
View view = createView(parent, name, context, attrs, false, false, true, false);
onViewCreated(context, view, attrs);
return view;
}

@Nullable
@Override
public View onCreateView(@NonNull String name, @NonNull Context context, @NonNull AttributeSet attrs) {
return onCreateView(null, name, context, attrs);
}

private void onViewCreated(@NonNull Context context, @Nullable View view, @NonNull AttributeSet attrs) {
if (view == null) return;
resetViewAttrsIfNeed(context, view, attrs);
}

private void resetViewAttrsIfNeed(Context context, View view, AttributeSet attrs) {
if (!UIUtil.isNightBlue(context)) return;

String ANDROID_NAMESPACE = "http://schemas.android.com/apk/res/android";
String BACKGROUND = "background";

// 获取 background 属性值的资源 id,未找到时返回 0
int backgroundId = attrs.getAttributeResourceValue(ANDROID_NAMESPACE, BACKGROUND, 0);
if (backgroundId != 0) {
view.setBackgroundResource(resetResIdIfNeed(context, backgroundId));
}
}
}

// BaseActivity.java

@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
SkinViewInflater inflater = new SkinViewInflater();
LayoutInflater layoutInflater = LayoutInflater.from(this);
// 生成 View 的逻辑替换成我们自己的
LayoutInflaterCompat.setFactory2(layoutInflater, inflater);
}

至此,这套方案已经可以解决目前的换肤需求了,剩下的就是进行细节适配了。


其他说明


自定义控件与第三方控件适配


上面只对 background 属性进行了处理,其他需要进行换肤的属性也是同样的处理逻辑。如果是自定义的控件,可以在初始化时调用 TypedArray#getResourceId 方法先获取资源 id,再通过 context 去获取对应资源,而不是使用 TypedArray#getDrawable 类似方法直接获取资源对象,这样可以确保换肤成功。而第三方控件也可通过 background 属性同样的处理逻辑进行适配。


XML <shape> 的处理


<!-- bg.xml -->
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<corners android:radius="8dp" />
<solid android:color="@color/background" />
</shape>

上面的 bg.xml 文件内的 color 并不会完成资源替换,根据上面的逻辑,需要新增以下内容:


<!-- bg_blue.xml -->
<shape xmlns:android="http://schemas.android.com/apk/res/android">
   <corners android:radius="8dp" />
   <solid android:color="@color/background_blue" />
</shape>

如此,资源替换才会成功。


设计的配合


这次对第三款皮肤的适配还是蛮轻松的,主要是有以下基础:



  • 在适配暗黑主题的时候,设计有出设计规范,后续开发按照设计规范来。

  • 暗黑和暗蓝共用一套图片资源,大大减少适配工作量。

  • 暗黑和暗蓝部份共用颜色值含透明度,同样减少了工作量,仅少量颜色需要新增。


这次适配的主要工作量还是来自 <shape> 的替换。


暗蓝皮肤资源文件的归处


我知道很多换肤方案都会将皮肤资源制作成皮肤包,但是这个方案没有这么做。一是没有那么多需要替换的资源,二是为了减少相应的工作量。


我新建了一个资源文件夹,与 res 同级,取名 res-blue。并在 gradle 配置文件中配置它。编译后系统会自动将它们合并,同时也能与常规资源文件隔离开来。


// build.gradle
sourceSets {
   main {
       java {
        srcDir 'src/main/java'
      }
       res.srcDirs += 'src/main/res'
       res.srcDirs += 'src/main/res-blue'
  }
}

有哪些坑?


WebView 资源缺失导致闪退


版本上线后,发现有 android.content.res.Resources$NotFoundException 异常上报,具体异常堆栈信息:


android.content.res.ResourcesImpl.getValue(ResourcesImpl.java:321)
android.content.res.Resources.getInteger(Resources.java:1279)
org.chromium.ui.base.DeviceFormFactor.b(chromium-TrichromeWebViewGoogle.apk-stable-447211483:4)
org.chromium.content.browser.selection.SelectionPopupControllerImpl.n(chromium-TrichromeWebViewGoogle.apk-stable-447211483:1)
N7.onCreateActionMode(chromium-TrichromeWebViewGoogle.apk-stable-447211483:8)
Gu.onCreateActionMode(chromium-TrichromeWebViewGoogle.apk-stable-447211483:2)
com.android.internal.policy.DecorView$ActionModeCallback2Wrapper.onCreateActionMode(DecorView.java:3255)
com.android.internal.policy.DecorView.startActionMode(DecorView.java:1159)
com.android.internal.policy.DecorView.startActionModeForChild(DecorView.java:1115)
android.view.ViewGroup.startActionModeForChild(ViewGroup.java:1087)
android.view.ViewGroup.startActionModeForChild(ViewGroup.java:1087)
android.view.ViewGroup.startActionModeForChild(ViewGroup.java:1087)
android.view.ViewGroup.startActionModeForChild(ViewGroup.java:1087)
android.view.ViewGroup.startActionModeForChild(ViewGroup.java:1087)
android.view.ViewGroup.startActionModeForChild(ViewGroup.java:1087)
android.view.View.startActionMode(View.java:7716)
org.chromium.content.browser.selection.SelectionPopupControllerImpl.I(chromium-TrichromeWebViewGoogle.apk-stable-447211483:10)
Vc0.a(chromium-TrichromeWebViewGoogle.apk-stable-447211483:10)
Vf0.i(chromium-TrichromeWebViewGoogle.apk-stable-447211483:4)
A5.run(chromium-TrichromeWebViewGoogle.apk-stable-447211483:3)
android.os.Handler.handleCallback(Handler.java:938)
android.os.Handler.dispatchMessage(Handler.java:99)
android.os.Looper.loopOnce(Looper.java:233)
android.os.Looper.loop(Looper.java:334)
android.app.ActivityThread.main(ActivityThread.java:8333)
java.lang.reflect.Method.invoke(Native Method)
com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:582)
com.android.internal.os.ZygoteInit.main(ZygoteInit.java:1065)

经查才发现在 WebView 中长按文本弹出操作菜单时,就会引发该异常导致 App 闪退。


这是其他插件化方案也踩过的坑,我们只需在创建 SkinResources 之前将外部 WebView 的资源路径添加进来即可。


@Override
public Resources getResources() {
   if (mSkinResources == null) {
    WebViewResourceHelper.addChromeResourceIfNeeded(this);
    mSkinResources = new SkinResources(this, super.getResources());
  }
   return mSkinResources;
}


RePlugin/WebViewResourceHelper.java 源码文件




具体问题分析可参考


Fix ResourceNotFoundException in Android 7.0 (or above)



最终效果图


skin_demo.gif


总结


这个方案在原本使用系统方式适配暗黑主题的基础上,通过拦截 Resources 相关获取资源的方法,替换换肤后的资源 id,以达到换肤的效果。针对 XML 布局换肤不成功的问题,复制 AppCompatViewInflater 创建 View 的代码逻辑,并在 View 创建成功后重新设置需要进行换肤的相关 XML 属性。同一皮肤资源使用单独的资源文件夹独立存放,可以与正常资源进行隔离,也避免了制作皮肤包而增加工作量。


目前来说这套方案是改造成本最小,侵入性最小的选择。选择适合自身需求的才是最好的。


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

使用 Jetpack Compose 做一个年度报告页面

刚刚结束的 2022 年,不少应用都给出了自己的 2022 年度报告。趁着这股热潮,我自己维护的应用《译站》 也来凑个热闹,用 Jetpack Compose 写了个报告页面。效果如下: 效果还算不错?如果需要实际体验的,可以前往 这里 下载翻译后打开底部最...
继续阅读 »

刚刚结束的 2022 年,不少应用都给出了自己的 2022 年度报告。趁着这股热潮,我自己维护的应用《译站》 也来凑个热闹,用 Jetpack Compose 写了个报告页面。效果如下:


tutieshi_640x1422_17s.gif


效果还算不错?如果需要实际体验的,可以前往 这里 下载翻译后打开底部最右侧 tab,即可现场看到。


制作过程


观察上图,需要完成的有三个难点:



  • 闪动的数字

  • 淡出 + 向上位移的微件们

  • 有一部分微件不参与淡出(如 Spacer)


下面将详细介绍


闪动的数字


在我的上一篇文章 Jetpack Compose 十几行代码快速模仿即刻点赞数字切换效果 中,我基于 AnimatedContent 实现了 数字增加时自动做动画 的 Text,它的效果如下:



诶,既然如此,那实现这个数字跳动不就简单了吗?我们只需要让数字自动从 0 变成 目标数字,不就有了动画的效果吗?

此处我选择 Animatable ,并且使用 LauchedEffect 让数字自动开始递增,并把数字格式化为 0013(长度为目标数字的长度)传入到上次完成的微件中,这样一个自动跳动的动画就做好啦。

代码如下:


@Composable
fun AutoIncreaseAnimatedNumber(
modifier: Modifier = Modifier,
number: Int,
durationMills: Int = 10000,
textPadding: PaddingValues = PaddingValues(horizontal = 8.dp, vertical = 12.dp),
textSize: TextUnit = 24.sp,
textColor: Color = Color.Black,
textWeight: FontWeight = FontWeight.Normal
) {
// 动画,Animatable 相关介绍可以见 https://compose.funnysaltyfish.fun/docs/design/animation/animatable?source=trans
val animatedNumber = remember {
androidx.compose.animation.core.Animatable(0f)
}
// 数字格式化后的长度
val l = remember {
number.toString().length
}

// Composable 进入 Composition 阶段时开启动画
LaunchedEffect(number) {
animatedNumber.animateTo(
targetValue = number.toFloat(),
animationSpec = tween(durationMillis = durationMills)
)
}

NumberChangeAnimatedText(
modifier = modifier,
text = "%0${l}d".format(animatedNumber.value.roundToInt()),
textPadding = textPadding,
textColor = textColor,
textSize = textSize,
textWeight = textWeight
)
}

@OptIn(ExperimentalAnimationApi::class)
@Composable
fun NumberChangeAnimatedText(
modifier: Modifier = Modifier,
text: String,
textPadding: PaddingValues = PaddingValues(horizontal = 8.dp, vertical = 12.dp),
textSize: TextUnit = 24.sp,
textColor: Color = Color.Black,
textWeight: FontWeight = FontWeight.Normal,
) {
Row(modifier = modifier) {
text.forEach {
AnimatedContent(
targetState = it,
transitionSpec = {
slideIntoContainer(AnimatedContentScope.SlideDirection.Up) with
fadeOut() + slideOutOfContainer(AnimatedContentScope.SlideDirection.Up)
}
) { char ->
Text(text = char.toString(), modifier = modifier.padding(textPadding), fontSize = textSize, color = textColor, fontWeight = textWeight)
}
}
}
}

这样就完成啦~


淡出 + 向上位移的微件们


实际上,这个标题的难点在于“们”这个字,这意味着不但要完成“向上+淡出”的效果,还要有序,一个一个来。

对于这个问题,因为我的需求很简单:所有微件竖着排列,自上而下逐渐淡出。因此,我选择的解决思路是:自定义布局。(这不一定是唯一的思路,如果你有更好的方法,也欢迎一起探讨)。下面我们慢慢拆解:


微件竖着放


这其实是最简单的一步,你可以阅读我曾经写的 深入Jetpack Compose——布局原理与自定义布局(一) 来了解。简单来说,我们只需要依次摆放所有微件,然后把总宽度设为宽度最大值,总高度设为高度之和即可。代码如下:


@Composable
fun AutoFadeInComposableColumn(
modifier: Modifier = Modifier,
content: @Composable FadeInColumnScope.() -> Unit
) {
val measurePolicy = MeasurePolicy { measurables, constraints ->
val placeables = measurables.map { measurable ->
measurable.measure(constraints.copy(minHeight = 0, minWidth = 0))
}

var y = 0
// 宽度:父组件允许的最大宽度,高度:微件高之和
layout(constraints.maxWidth, placeables.sumOf { it.height }) {
// 依次摆放
placeables.forEachIndexed { index, placeable ->
placeable.placeRelativeWithLayer(0, y){
alpha = 1
}
y += placeable.height
}.also {
// 重置高度
y = 0
}
}
}
Layout(modifier = modifier, content = { FadeInColumnScopeInstance.content() }, measurePolicy = measurePolicy)
}

上面的例子就是最简单的自定义布局了,它可以实现内部的 Composable 从上到下竖着排列。注意的是,在 place 的时候,我们使用了 placeRelativeWithLayer ,它可以调整组件的 alpha(还有 rotation/transform),这个未来会被用于实现淡出效果。


一个一个淡出


到了关键的一步了。我们不妨想一想,淡出就是 alpha 从 0->1,y 偏移从 offsetY -> 0 的过程,因此我们只需要在 place 时控制一下两者的值就行。作为一个动画过程,自然可以使用 Animatable。现在的问题是:需要几个 Animatable 呢?

自然,你可以选择使用 n 个 Animatable 分别控制 n 个微件,不过考虑到同一时刻其实只有一个 @Composable 在做动画,因此我选择只用一个。因此我们需要增加一些变量:



  • currentFadeIndex 记录当前是哪个微件在播放动画

  • finishedFadeIndex 记录播放完成的最后一个微件的 index,用于检查动画是否结束了


实话说这两个变量或许可以合成一个,不过既然写成了两个,那就先这样写下去吧。

两个状态可以只放到 Layout 里面,也可以放到专门的 State 中,考虑到外部可能要用到(嘿嘿,其实是真的要用到)两个值,我们单独写一个 State


class AutoFadeInColumnState {
var currentFadeIndex by mutableStateOf(-1)
var finishedFadeIndex by mutableStateOf(0)

companion object {
val Saver = listSaver<AutoFadeInColumnState, Int>(
save = { listOf(it.currentFadeIndex, it.finishedFadeIndex) },
restore = {
AutoFadeInColumnState().apply {
currentFadeIndex = it[0]; finishedFadeIndex = it[1]
}
}
)
}
}

@Composable
fun rememberAutoFadeInColumnState(): AutoFadeInColumnState {
return rememberSaveable(saver = AutoFadeInColumnState.Saver) { AutoFadeInColumnState() }
}

接下来,为我们的自定义 Composable 添加几个参数吧


@Composable
fun AutoFadeInComposableColumn(
modifier: Modifier = Modifier,
state: AutoFadeInColumnState = rememberAutoFadeInColumnState(),
fadeInTime: Int = 1000, // 单个微件动画的时间
fadeOffsetY: Int = 100, // 单个微件动画的偏移量
content: @Composable FadeInColumnScope.() -> Unit
)

接下来就是关键,修改 place 的代码完成动画效果。


// ...
placeables.forEachIndexed { index, placeable ->
// @1 实际的 y,对于动画中的微件减去偏移量,对于未动画的微件不变
val actualY = if (state.currentFadeIndex == index) {
y + (( 1 - fadeInAnimatable.value) * fadeOffsetY).toInt()
} else {
y
}
placeable.placeRelativeWithLayer(0, actualY){
// @2
alpha = if (index == state.currentFadeIndex) fadeInAnimatable.value else
if (index <= state.finishedFadeIndex) 1f else 0f
}
y += placeable.height
}.also {
y = 0
}

相较于之前,代码有两处主要更改。@1 处更改微件的 y,对于动画中的微件减去偏移量,对于未动画的微件不变,以实现 “位移” 的效果; @2 处则设置 alpha 值实现淡出效果,具体逻辑如下:



  • 如果是正在动画的那个,alpha 就是当前动画的值,实现渐渐淡出的效果

  • 否则,对于已经执行完动画的,alpha 正常为 1;否则为 0(还没轮到它们显示)


接下来,问题在于执行完一个如何执行下一个了。我的思路是这样的:添加一个 LauchedState(state.currentFadeIndex) 使得在 currentFadeIndex 变化时(这表示当前执行动画的微件变了)重新把 Animatable 置0,开启动画效果。动画完成后又把 currentFadeIndex 加一,直至完成所有。代码如下:


@Composable
fun xxx(...){
LaunchedEffect(state.currentFadeIndex){
if (state.currentFadeIndex == -1) {
// 找到第一个需要渐入的元素
state.currentFadeIndex = 0
}
// 开始动画
fadeInAnimatable.animateTo(
targetValue = 1f,
animationSpec = tween(
durationMillis = fadeInTime,
easing = LinearEasing
)
)
// 动画播放完了,更新 finishedFadeIndex
state.finishedFadeIndex = state.currentFadeIndex
// 全部动画完了,退出
if(state.finishedFadeIndex >= whetherFadeIn.size - 1) return@LaunchedEffect

state.currentFadeIndex += 1
fadeInAnimatable.snapTo(0f) // snapTo(0f) 无动画直接置0
}
}

到这里,一个 内部子微件依次淡出 的自定义布局已经基本完成了。下面问题来了:在 Compose 中,我们使用 Spacer 创建间隔,但是往往 Spacer 是不需要动画的。因此我们需要支持一个特性:允许设置某些 Composable 不做动画,也就是直接跳过它们。这种子微件告诉父微件信息的时期,当然要交给 ParentData 来做


允许部分 Composable 不做动画


要了解 ParentData,您可以参考我的文章 深入Jetpack Compose——布局原理与自定义布局(四)ParentData,此处不再赘述。

我们添加一个 class FadeInColumnData(val fade: Boolean = true) 和 对应的 Modifier,用于指定某些 Composable 跳过动画。考虑到这个特定的 Modifier 只能用在我们这个布局,因此需要加上 scope 的限制。这些代码如下:


class FadeInColumnData(val fade: Boolean = true) : ParentDataModifier {
override fun Density.modifyParentData(parentData: Any?): Any =
this@FadeInColumnData
}

interface FadeInColumnScope {
@Stable
fun Modifier.fadeIn(whetherFadeIn: Boolean = true): Modifier
}

object FadeInColumnScopeInstance : FadeInColumnScope {
override fun Modifier.fadeIn(whetherFadeIn: Boolean): Modifier = this.then(FadeInColumnData(whetherFadeIn))
}

有了这个,我们上面的布局也得做相应的更改,具体来说:



  • 需要增加一个列表 whetherFadeIn 记录 ParentData 提供的值

  • 开始的动画 index 不再是 0 ,而是找到的第一个需要做动画的元素

  • currentFadeIndex 的更新需要找到下一个需要做动画的值


具体代码如下:


@Composable
fun AutoFadeInComposableColumn() {
var whetherFadeIn: List<Boolean> = arrayListOf()
// ...

LaunchedEffect(state.currentFadeIndex){
// 等待初始化完成
while (whetherFadeIn.isEmpty()){ delay(50) }
if (state.currentFadeIndex == -1) {
// 找到第一个需要渐入的元素
state.currentFadeIndex = whetherFadeIn.indexOf(true)
}
// 开始动画
// - state.currentFadeIndex = 0
for (i in state.finishedFadeIndex + 1 until whetherFadeIn.size){
if (whetherFadeIn[i]){
state.currentFadeIndex = i
fadeInAnimatable.snapTo(0f)
break
}
}
}

val measurePolicy = MeasurePolicy { measurables, constraints ->
// ...
whetherFadeIn = placeables.map { placeable ->
((placeable.parentData as? FadeInColumnData) ?: FadeInColumnData()).fade
}

// 宽度:父组件允许的最大宽度,高度:微件高之和
layout(constraints.maxWidth, placeables.sumOf { it.height }) {
// ...
}
}
Layout(modifier = modifier, content = { FadeInColumnScopeInstance.content() }, measurePolicy = measurePolicy)
}

完成啦!


一点小问题


事实上,整个布局的大体到目前已经趋于完成,不过目前有点小问题:对于 AutoIncreaseAnimatedNumber ,它的动画执行时机是错误的。你可以想象:尽管数字没有显示出来(alpha 为 0),但实际上它已经被摆放了,因此数字跳动的动画已经开始了。对于这个问题,我的解决方案是为 AutoIncreaseAnimatedNumber 额外添加一个 Boolean 参数 startAnim,只有该值为 true 时才真正开始执行动画。


那么 startAnim 什么时候为 true 呢?就是 currentFadeIndex == 这个微件的 Index 时,这样就可以手工指定什么时候开始动画了。

代码如下:


@Composable
fun AutoIncreaseAnimatedNumber(
startAnim: Boolean = true,
...
) {
// Composable 进入 Composition 阶段,且 startAnim 为 true 时开启动画
LaunchedEffect(number, startAnim) {
if (startAnim)
animatedNumber.animateTo(
targetValue = number.toFloat(),
animationSpec = tween(durationMillis = durationMills)
)
}

NumberChangeAnimatedText(
...
)
}

实际使用时


Row(verticalAlignment = Alignment.CenterVertically) {
AnimatedNumber(number = 110, startAnim = state.currentFadeIndex == 7) // 或者 >=,如果动画时间长于 fadeInTime 的话
ResultText(text = "次")
}

完工!


Pager?


如你所想,整体的布局是用 Pager 实现的,这个用的是 google/accompanist: A collection of extension libraries for Jetpack Compose 内的实现。鉴于不是本篇重点,此处略过,感兴趣的可以看下面的代码。


代码


完整代码见 FunnyTranslation/AnnualReportScreen.kt at compose


如果有用,欢迎 Star仓库 / 此处点赞 / 评论 ~


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

如何优雅的在Fragment中使用ViewBinding

前言 在Fragment中控制View十分简单,只需要声明+findViewById即可: class FragmentA : Fragment() { private lateinit var imageView: ImageView ...
继续阅读 »

前言


在Fragment中控制View十分简单,只需要声明+findViewById即可:


class FragmentA : Fragment() {
private lateinit var imageView: ImageView

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
imageView = view.findViewById(R.id.imageView)
}
}

但这样同时也遇到了一个问题:在使用Navigation或者使用replaceaddToBackStack进行FragmentA切换到FragmentB时,FragmentA会走到onDestroyView,但不会destory。FragmentA走到onDestroyView时,Fragment对根View的引用会置空,由于imageView被Fragment持有,所以此时imageView并未被释放,从而导致了内存泄漏。









View Leak 1View Leak 2

当页面变的复杂时,变量的声明以及赋值也会变成一个重复的工作。比较成熟的框架如Butter Knife通过@BindView注解生成代码,以避免手工编写findViewById代码,同时也提供了Unbinder用以在onDestoryView中进行解绑以防止内存泄漏。不过在Butter Knife的官方文档中提到目前Butter Knife已不再维护,推荐使用ViewBinding作为视图绑定工具:



Attention: This tool is now deprecated. Please switch to view binding. Existing versions will continue to work, obviously, but only critical bug fixes for integration with AGP will be considered. Feature development and general bug fixes have stopped.



在ViewBinding的官方文档中,推荐的写法如下:


class TestFragment : Fragment() {
private var _binding: FragmentTestBinding? = null
// 只能在onCreateView与onDestoryView之间的生命周期里使用
private val binding: FragmentTestBinding get() = _binding!!

override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = FragmentTestBinding.inflate(inflater, container, false)
return binding.root
}

override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}

这种方式虽然防止了内存泄漏,但仍然需要手工编写一些重复代码,大部分人甚至可能直接声明lateinit var binding,从而导致更严重的内存泄漏问题。下面我们将介绍两种解放方案:


Fragment基类


如果项目中存在一个BaseFragment的话,我们完全可以将上面的逻辑放在BaseFragment中:


open class BaseFragment<T : ViewBinding> : Fragment() {

protected var _binding: T? = null

protected val binding: T get() = _binding!!

override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}

或者更进一步,将onCreateView的逻辑也放在父类中:


abstract class BaseFragment<T : ViewBinding> : Fragment() {

private var _binding: T? = null
protected val binding: T get() = _binding!!

abstract val bindingInflater: (LayoutInflater, ViewGroup?, Bundle?) -> T

override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = bindingInflater.invoke(inflater, container, savedInstanceState)
return binding.root
}

override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}

子类使用时:


class TestFragment : BaseFragment<FragmentTestBinding>() {

override val bindingInflater: (LayoutInflater, ViewGroup?, Bundle?) -> FragmentTestBinding
get() = { layoutInflater, viewGroup, _ ->
FragmentTestBinding.inflate(layoutInflater, viewGroup, false)
}
}

不过这种方式由于给基类增加了泛型,所以对于已有项目的侵入性比较高。


生命周期委派


借助Kotlin的by关键字,我们可以将binding置空的任务交给Frament生命周期进行处理,比较简单的版本如下:


class LifecycleAwareViewBinding<F : Fragment, V : ViewBinding> : ReadWriteProperty<F, V>, LifecycleEventObserver {

private var binding: V? = null

override fun getValue(thisRef: F, property: KProperty<*>): V {
binding?.let {
return it
}
throw IllegalStateException("Can't access ViewBinding before onCreateView and after onDestroyView!")
}

override fun setValue(thisRef: F, property: KProperty<*>, value: V) {
if (thisRef.viewLifecycleOwner.lifecycle.currentState == Lifecycle.State.DESTROYED) {
throw IllegalStateException("Can't set ViewBinding after onDestroyView!")
}
thisRef.viewLifecycleOwner.lifecycle.addObserver(this)
binding = value
}

override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
if (event == Lifecycle.Event.ON_DESTROY) {
binding = null
source.lifecycle.removeObserver(this)
}
}
}

在使用时可以直接通过by关键字,但仍需在onCreateView中进行赋值:


class TestFragment : Fragment() {
private var binding: FragmentTestBinding by LifecycleAwareViewBinding()

override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = FragmentTestBinding.inflate(inflater, container, false)
return binding.root
}
}

如果想省略onCreateView中的创建ViewBinding的重复逻辑,有两种思路,一个是Fragment构造时传入布局Id,通过viewBinding生成的bind函数创建ViewBinding;另外一种思路则是通过反射调用ViewBindinginflate方法。两种思路的主要不同就是创建ViewBinding的方式不一样,而核心代码一样,实现如下:


class LifecycleAwareViewBinding<F : Fragment, V : ViewBinding>(
private val bindingCreator: (F) -> V
) : ReadOnlyProperty<F, V>, LifecycleEventObserver {

private var binding: V? = null

override fun getValue(thisRef: F, property: KProperty<*>): V {
binding?.let {
return it
}
val lifecycle = thisRef.viewLifecycleOwner.lifecycle
if (lifecycle.currentState == Lifecycle.State.DESTROYED) {
this.binding = null
throw IllegalStateException("Can't access ViewBinding after onDestroyView")
} else {
lifecycle.addObserver(this)
val viewBinding = bindingCreator.invoke(thisRef)
this.binding = viewBinding
return viewBinding
}
}

override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
if (event == Lifecycle.Event.ON_DESTROY) {
binding = null
source.lifecycle.removeObserver(this)
}
}
}

然后创建函数返回LifecycleAwareViewBinding即可:


// 1. 通过bind函数
fun <V : ViewBinding> Fragment.viewBinding(binder: (View) -> V): LifecycleAwareViewBinding<Fragment, V> {
return LifecycleAwareViewBinding { binder.invoke(it.requireView()) }
}
// 使用
class TestFragment : Fragment(R.layout.fragment_test) {
private val binding: FragmentTestBinding by viewBinding(FragmentTestBinding::bind)
}

// 2. 通过反射的方式
inline fun <reified V : ViewBinding> Fragment.viewBinding(): LifecycleAwareViewBinding<Fragment, V> {
val method = V::class.java.getMethod("inflate", LayoutInflater::class.java, ViewGroup::class.java, Boolean::class.java)
return LifecycleAwareViewBinding { method.invoke(null, layoutInflater, null, false) as V }
}
// 使用
class TestFragment : Fragment() {
private val binding: FragmentTestBinding by viewBinding()

override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
return binding.root
}
}

需要注意的是第一种方式使用了Fragment#requireView方法,所以需要将布局id传给Fragment的构造方法(将布局id传给Fragment实际上是借助了Fragment默认的onCreateView实现,虽然不传布局Id、手动实现也可以,但这样实际上和最上面提到的方法差不多了)。


上面的两种思路GitHub中已经有作者实现了,并且考虑了一些边界case以及优化,感兴趣的可以去看看:ViewBindingPropertyDelegate


总结


对于ViewBinding为了防止内存泄漏而出现的模板代码,可以将模板代码提取至基类Fragment中或者借助Fragment的viewLifecycleOwner的生命周期进行自动清理;对于onCreateView中为了创建ViewBinding而出现的模板代码,可以借助Fragment#onCreateView的默认实现以及ViewBinding生成的bind函数进行创建,或者通过反射调用ViewBinding生成的inflate方法创建ViewBinding


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

年终反思潮!李彦宏:“马化腾说的问题,百度也都有……”

前段时间,马化腾在 2022 年内部员工大会上,谈及了业务部门改革表面、追求大数字营收、内部贪腐“触目惊心”等问题,引发大家热议。随后,刘强东在京东高管管理培训会议上痛批“拿PPT和假大空词汇忽悠自己的人就是骗子”;蔚来李斌在全员信中表示有八大问题亟待解决。据...
继续阅读 »

到了年末,难免少不了“年终总结”、“反思潮”,互联网的“大佬”们怎能落下?

前段时间,马化腾在 2022 年内部员工大会上,谈及了业务部门改革表面、追求大数字营收、内部贪腐“触目惊心”等问题,引发大家热议。随后,刘强东在京东高管管理培训会议上痛批“拿PPT和假大空词汇忽悠自己的人就是骗子”;蔚来李斌在全员信中表示有八大问题亟待解决。

作为曾经的 BAT 三巨头之一的百度,最近也因为一篇文章也在“反思潮”中引发了大家的关注。


“简单之约”下的反思

据 36 氪报道,百度在 1 月 3 日内网发布的了一篇文章《简单之约:谈机会谈挑战,新思考新要求》。文章长达 7000 字,取自 2022 年 12 月末,李彦宏面向全体员工的一场内部直播。(“简单之约”,是百度管理层与全体员工沟通公司理念与战略的固定机制,由员工提问,高层答疑,每季度举行一次。)

从文章的整体来看,李彦宏从技术投入、商业本质、公司管理以及百度未来的机会等话题直指了百度目前的现状问题及思考,例如对资本、组织等宏观问题的复盘,以及对 AIGC 的商业化、疫情下的搜索产品、“端到端”战略等具体业务挑战的思考。

在谈及马化腾的内部讲话时,李彦宏表示,马化腾内部讲话提到的问题,百度也都有,并表示一直在试图积极地去解决这些“零容忍”的问题,一旦发现这种事情不会妥协。

此外,李彦宏还鼓励员工站在更高的角度去思考问题。做事情除了满足 OKR 之外,应该跳出这个范围,看看设置的目标对业务的长期成长、长期健康有没有作用。同时,李彦宏坦言了 2023 年的小目标,希望 2023 年至少能有一个高成长、有创新的业务。”

“技术和创新”是长期话题

值得一提的是,百度作为一家技术驱动的互联网公司,在文章中多次谈及了有关技术方向的思考。

众所周知,“技术和创新”是李彦宏长期关注的话题,非常重视技术与市场的匹配的重要性。在本次全员会上他也指出,“要做市场真正需要的技术,否则就是自嗨”。此前,百度就在技术方面付出了很多的投入。根据财报显示,近年来百度的技术研发投入占收入的比例超过 20% 。

在文中,李彦宏还提到了许多有关 AI 领域新的技术进展,尤其是近期屡次出圈的 AIGC 和 ChatGPT ,他表示:“技术能做到这一步了,但是它会变成什么样的产品,产品能满足什么样的需求,这个链条上还有很多不确定性。”

早前, AI 领域就备受百度的关注。在 2022 万象·百度移动生态大会上,百度发布了 AI助理,覆盖了各种 AIGC 应用,包括 AI 自动生成文字、图片以及图片转换成视频。李彦宏表示,这是百度“天天在琢磨的技术方向”,将其商业化很难,但百度必须要做。

网友:“除了剖析问题,关键要看后续做法!”

伴随着#李彦宏内部反思#、#李彦宏称马化腾说的问题百度都有#、#李彦宏称百度让更多人及时看到真相#的话题登上微博热搜榜,也掀起网友一股讨论热潮:

  • “能深刻剖析自己存在的问题也很有勇气,不过关键要看后续做法”;

  • “真的是非常敬佩李彦宏”;

  • “公司越大,就更要处理好内部问题”;

  • “百度还是有很强的技术氛围和底蕴的 ”。

欲戴王冠,必承其重。在互联网企业在抢占市场的过程中,难免需要迎接更多的挑战。对此,你怎么看?可以在评论区留言或讨论。

参考链接:

https://36kr.com/newsflashes/2075958593507332

https://finance.sina.com.cn/tech/internet/2023-01-06/doc-imxzheuc5666852.shtml

https://www.sohu.com/a/626669960_335395

https://new.qq.com/rain/a/20230106A0313400

作者:朱珂欣 | CSDN 程序人生
来源:blog.csdn.net/csdnsevenn/article/details/128629148

收起阅读 »

货拉拉SSL证书踩坑之旅

一、背景简介1、遇到的问题2020年,货拉拉运营部门和客户端开发对齐了https网络通信协议中的SSL网络证书校验方案;但是由于Android客户端的证书配置不规范,导致在客户端内置的SSL网络证书到期前十几天被发现证书校验异常,Android客户端面临全网访...
继续阅读 »

img

一、背景简介

1、遇到的问题

2020年,货拉拉运营部门和客户端开发对齐了https网络通信协议中的SSL网络证书校验方案;但是由于Android客户端的证书配置不规范,导致在客户端内置的SSL网络证书到期前十几天被发现证书校验异常,Android客户端面临全网访问异常的问题

2、本文内容

本文主要介绍解决货拉拉Android客户端SSL证书到期的解决方案及Android端SSL证书相关知识

二、SSL证书简介

1、SSL证书诞生背景

1994年,Netscape公司首先使用了SSL协议,SSL协议全称为:安全套接层协议(Secure Sockets Layer),它指定了在应用程序协议(如HTTP、Telnet、FTP)和TCP/IP之间提供数据安全性分层的机制,它是在传输通信协议(TCP/IP)上实现的一种安全协议,采用公开密钥技术,它为TCP/IP连接提供数据加密、服务器认证、消息完整性以及可选的客户端认证。由于SSL协议很好地解决了互联网明文传输的不安全问题,很快得到了业界的支持,并已经成为国际标准

HyperText Transfer Protocol over Secure Socket Layer。在HTTPS中,使用传输层安全性(TLS)或安全套接字层(SSL)对通信协议进行加密。也就是HTTP+SSL(TLS)=HTTPS

img

2、SSL证书简介

按类型划分,SSL证书包括CA证书、用户证书两种

(1)CA证书(Certification Authority证书颁发机构)

证书的签发机构(CA)颁发的电子证书,包含根证书和中间证书两种

[i]根证书

属于根证书颁发机构(CA)的公钥证书,是在公开密钥基础建设中,信任链的起点

一般客户端会内置

[ii]中间证书

因为根证书太宝贵了,直接颁发风险太大了。因此,为了保护根证书,CAs通常会颁发所谓的中间证书。CA使用它的私钥对中间证书签名,使它受到信任。然后CA使用中间证书的私钥签署和颁发终端用户SSL证书。这个过程可以执行多次,其中一个中间根对另一个中间根进行签名

(2)用户证书

用户证书是由CA中间证书签发给用户的证书,包含服务器证书、客户端证书

[i]服务器证书

组成Web服务器的SSL安全功能的唯一的数字标识。 通过CA签发,并为用户提供验证您Web站点身份的手段。

服务器证书包含详细的身份验证信息,如服务器内容附属的组织、颁发证书的组织以及称为公开密钥的唯一的身份验证文件

[ii]客户端证书

在双向https验证中,就必须有客户端证书,生成方式同服务器证书一样;

单向证书则不用生成

3、SSL证书链

SSL证书链是从用户证书、生成用户证书的CA中间证书、生成CA中间证书的CA中间证书...一直到CA根证书;其中根证书只能有一个,但是CA中间证书可以有多个

(1)以baidu的证书为例

img

(2)证书链

客户端(比如浏览器或者Android手机)验证我们SSL证书的有效性的时候,会一层层的去寻找颁发者的证书,直到自签名的根证书,然后通过相应的公钥再反过来验证下一级的数字签名的正确性

任何数字证书都必须要有根证书做支持,有了根证书的支持才说明这个数字证书是有效的是被信任的

img

4、SSL证书文件的后缀

证书的后缀主要有.key、.csr、.crt、.pem等

(1).key文件:密钥文件,SSL证书的私钥就包含在其中

(2).csr文件:这个文件里面包含着证书的公钥和其他一些公司信息,通过请求签名之后就可以直接生出证书

(3).crt文件:该文件中也包含了证书的公钥、签名信息以及根据不同类型证书携带不同的认证信息,如IP等(该文件在有些机构、系统中也可能表现为.cert后缀)

(4).pem文件:该文件相对比较少见,里面包含着证书的私钥以及部分证书信息

5、SSL用户证书类型

SSL用户证书主要分为(1)DV SSL证书 (2)OV SSL证书 (3)EV SSL证书

(1)DV SSL证书(域名验证型):只需验证域名所有权,无需人工验证申请单位真实身份,几分钟就可颁发的SSL证书。价格一般在百元至千元左右,适用于个人或者小型网站

(2)OV SSL证书(企业验证型):需要验证域名所有权以及企业身份信息,证明申请单位是一个合法存在的真实实体,一般在1~5个工作日颁发。价格一般在百元至几千元左右,适用于企业型用户申请

(3)EV SSL证书(扩展验证型):除了需要验证域名所有权以及企业身份信息之外,还需要提交一下扩展型验证,通常CA机构还会进行电话回访,一般在2~7个工作日颁发证书。价格一般在千元至万元左右,适用于在线交易网站、企业型网站

6、SSL证书结构

img

7、SSL证书查看

以Chorme上的baidu为例:

第1步

img

第2步

img

第3步

img

三、客户端SSL证书校验流程

1、客户端SSL证书校验主要是在网络连接的SSL/TLS握手环节校验

SSL/TLS握手(用非对称加密的手段传递密钥,然后用密钥进行对称加密传递数据)

img

校验流程主要在上述过程的第三步和第六步

第三步:Certificate

Server——>Client 服务端下发公钥证书

第六步:证书合法性校验

Client 对 Server下发的公钥证书进行合法性校验

2、客户端证书校验过程

img

(1)校验证书是否是受信任的CA根证书颁发机构颁发

客户端通过服务器证书 中签发机构信息,获取到中间证书公钥;利用中间证书公钥进行服务器证书的签名验证

a、中间证书公钥解密 服务器签名,得到证书摘要信息;

b、摘要算法计算 服务器证书 摘要信息;

c、然后对比两个摘要信息。

客户端通过中间证书中签发机构信息,客户端本地查找到根证书公钥;利用根证书公钥进行中间证书的签名验证

(2)客户端校验服务端证书公钥及摘要信息

客户端获取到服务端的公钥:Https请求 TLS握手过程中,服务器公钥会下发到请求的客户端。

客户端用存储在本地的CA机构的公钥,对服务端公钥中对应的摘要信息进行解密,获取到服务端公钥的摘要信息A;

客户端根据对服务端公钥进行摘要计算,得到摘要信息B;

对比摘要信息A与B,相同则证书验证通过

(3)校验证书是否在上级证书的吊销列表

若证书的申请主体出现:私钥丢失、申请证书无效等情况,CA机构需要废弃该证书

(详细策略见《四、Android端证书吊销校验策略》)

(4)校验证书是否过期

校验证书的有效期是否已经过期:主要判断证书中Validity period字段是否过期(ps:Android系统默认不校验证书有效期,但浏览器和ios系统默认会校验证书有效期)

(5)校验证书域名是否一致

校验证书域名是否一致:核查 证书域名*是否与当前的*访问域名 匹配

比如:我们请求的域名 http://www.huolala.cn 是否与证书文件DNS标签下所列的域名匹配

img

四、Android端证书吊销校验策略

1、证书吊销校验主要存在两类机制:CRL 与 OCSP

(1)证书吊销列表校验:CRL(Certificate Revocation List)

证书吊销列表:是一个单独的文件,该文件包含了 CA机构 已经吊销的证书序列号与吊销日期;

证书中一般会包含一个 URL 地址 CRL Distribution Point,通知使用者去哪里下载对应的 CRL 以校验证书是否吊销。

该吊销方式的优点是不需要频繁更新,但是不能及时吊销证书,这期间可能已经造成了极大损失

(2)证书状态在线查询:OCSP(Online Certificate Status Protocol)

证书状态在线查询协议:一个实时查询证书是否吊销的方式。

请求者发送证书的信息并请求查询,服务器返回正常、吊销或未知中的任何一个状态。

证书中一般也会包含一个 OCSP 的 URL 地址,要求查询服务器具有良好的性能。

部分 CA 或大部分的自签 CA (根证书)都是未提供 CRL 或 OCSP 地址的,对于吊销证书会是一件非常麻烦的事情

2、Android系统默认使用CRL方式来校验证书是否被吊销

核心实现类是CertBlocklistImpl(维护了本地黑名单列表),部分源码逻辑如下:

(1)TrustManagerImpl(证书校验核心类)

第1步循环校验信任证书

img

第2步检查该证书是否在黑名单列表里面

img

(2)CertBlocklistImpl(证书黑名单列表维护类)

黑名单校验逻辑:主要检查是否在黑名单列表里面

img

黑名单本地存储位置

img

可以看到黑名单文件储存在环境变量“ANDROID_DATA”/misc/keychain/pubkey_blacklist.txt;

可以通过adb shell--export--echo $ANDROID_DATA,拿到环境变量位置,一般在/data目录下

3、Android端自定义证书吊销校验逻辑

核心类在TrustManagerFactory、CertPathTrustManagerParameters、PKIXRevocationChecker

(1)TrustManagerFactory工厂模式的证书管理类

有两种init方式

[i]init(KeyStore ks) 默认使用

传递私钥,一般传递系统默认或者传空

以okhttp为例(默认传空)

img

[ii]init(ManagerFactoryParameters spec) 自定义方式

下面介绍下通过自定义方式来实现OCSP方式校验证书是否吊销

4、基于PKIXRevocationChecker方式自定义OCSP方式

(1)自定义TrustManagerFactory.init(ManagerFactoryParameters spec)

init方法传入基于CertPath的TrustManagerCertPathTrustManagerParameters,包装策略PKIXRevocationChecker

img

(2)PKIXRevocationChecker(用于检查PKIX算法的证书撤销状态)

默认使用OCSP方式校验,可以自定义使用OCSP策略还是CLR策略

参考谷歌开发者文档:developers.google.cn/j2objc/java…

img

五、Android端证书校验方式

主要有四种校验方式:

客户端单向认证服务端---证书锁定

客户端单向认证服务端---公钥锁定

客户端服务端双向认证

客户端信任所有证书

1、客户端单向认证服务端---证书锁定

(1)校验过程

校验服务端证书的subject信息和publickey信息是否与客户端内置证书一致,如果不一致会报错:

“java.security.cert.CertPathValidatorException: Trust anchor for certification path not found”

(2)实现方式

[i]network-security-config配置方式

(生效范围:app全局,包含webview请求)

(只支持android7.0及以上)

img

[ii]代码配置方式(生效范围:配置了该SSLParams的实例)

img

(3)优点

校验了subject信息和publickey信息,防信息篡改的安全等级高一点

(4)缺点

[i]因为一般网络证书的有效期是1-2年,所以面临过期之后可能校验异常的问题(ps:本次货拉拉客户端遇到的就是这种内置的网络证书快到期的case)

[ii]内置在app里面,证书容易泄漏

2、客户端单向认证服务端---公钥锁定

(1)校验过程

校验服务端证书的公钥信息是否与客户端内置证书的一致

(2)实现方式

[i]network-security-config配置方式

(生效范围:app全局,包含webview请求)

(只支持android7.0及以上)

img

[ii]代码配置方式(生效范围:配置了该参数的实例)

img

(3)优点

只要服务端的公钥保持不变,更换证书也能通过校验

(4)缺点

只校验了公钥,防信息篡改的安全等级低一点

3、客户端和服务端双向认证

(1)实现方式

自定义的SSLSocketFactory实现客户端和服务端双向认证

public class SSLHelper {

  /** * 存储客户端自己的密钥 */ private final static String CLIENT_PRI_KEY = "client.bks";

  /** * 存储服务器的公钥 */ private final static String TRUSTSTORE_PUB_KEY = "publickey.bks";

  /** * 读取密码 */ private final static String CLIENT_BKS_PASSWORD = "123321";

  /** * 读取密码 */ private final static String PUCBLICKEY_BKS_PASSWORD = "123321";

  private final static String KEYSTORE_TYPE = "BKS";

  private final static String PROTOCOL_TYPE = "TLS";

  private final static String CERTIFICATE_STANDARD = "X509";

  public static SSLSocketFactory getSSLCertifcation(Context context) {

      SSLSocketFactory sslSocketFactory = null;

      try {
          // 服务器端需要验证的客户端证书,其实就是客户端的keystore
          KeyStore keyStore = KeyStore.getInstance(KEYSTORE_TYPE);
          // 客户端信任的服务器端证书
          KeyStore trustStore = KeyStore.getInstance(KEYSTORE_TYPE);

          //读取证书
          InputStream ksIn = context.getAssets().open(CLIENT_PRI_KEY);
          InputStream tsIn = context.getAssets().open(TRUSTSTORE_PUB_KEY);

          //加载证书
          keyStore.load(ksIn, CLIENT_BKS_PASSWORD.toCharArray());
          trustStore.load(tsIn, PUCBLICKEY_BKS_PASSWORD.toCharArray());

          //关闭流
          ksIn.close();
          tsIn.close();

          //初始化SSLContext
          SSLContext sslContext = SSLContext.getInstance(PROTOCOL_TYPE);
          TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance(CERTIFICATE_STANDARD);
          KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance(CERTIFICATE_STANDARD);

          trustManagerFactory.init(trustStore);
          keyManagerFactory.init(keyStore, CLIENT_BKS_PASSWORD.toCharArray());

          sslContext.init(keyManagerFactory.getKeyManagers(), trustManagerFactory.getTrustManagers(), null);
          sslSocketFactory = sslContext.getSocketFactory();
      } catch (KeyStoreException e) {
          e.printStackTrace();
      } catch (IOException e) {
          e.printStackTrace();
      } catch (CertificateException e) {
          e.printStackTrace();
      } catch (NoSuchAlgorithmException e) {
          e.printStackTrace();
      } catch (UnrecoverableKeyException e) {
          e.printStackTrace();
      } catch (KeyManagementException e) {
          e.printStackTrace();
      }
      return sslSocketFactory;
  }

}

(2)优点

双向校验更安全

(3)缺点

需要服务端支持,TLS/SSL握手耗时增长

4、客户端信任所有证书

不检验任何证书,下面列两种常见的实现方式

(1)OkHttp版本

img

(2)HttpURLConnection版本

img

六、Android端一种源码调试的方式

背景:由于证书校验相关源码不在Android.jar中,为了方便调试证书校验的流程,这里简单介绍一种非android.jar包中的Android源码调试的方式

1、下载源码

(1)源码地址:android.googlesource.com/

android官方提供了各个模块的git仓库地址

img

(2)以SSL证书调试为例

我们只需要conscrypt部分的源码:android.googlesource.com/platform/ex…

注意点:选择的分支要和被调试的手机版本一致(因为不同系统版本下源码有点区别)

如果测试及时Android10.0系统,我们可以选择android10-release分支

img

2、源码导入

新建一个module 把刚才的系统源码复制进来,不需要依赖,只需要在setting.gradle中include,这样做隔离性好,方便移除

img

3、源码编译

导入源码之后,可能会有部分编译问题,可以解决的可以先解决,如果解决不了可以先注释;

需要注意点:

(1)不能修改行号,否则调试的时候走不到

(2)不能新增代码,新增的代码不会执行

4、断点调试

打好断点就可以发车了

可以看到app发起网络请求之后会走到TrustManagerImpl里面的checkServerTrusted校验服务端证书

img

七、Android端证书校验源码解析

1、证书校验主要分3步

(1)握手过程中验证证书

验证证书合法性,判断是否由合法的CA签发,由上面的Android系统根证书库来判断

img

(2)验证域名

判断服务端证书是否为特定域名签发,验证网站身份,这里如果出错就会抛出

SSLPeerUnverifiedException的异常

img

(3)验证证书绑定

img

2、Android根证书相关源码

Android会内置常用的根证书,系统根证书存放在/system/etc/security/cacerts 目录下,文件均为 PEM 格式的 X509 证书格式,包含明文base64编码公钥,证书信息,哈希等

Android系统的根证书管理类

位于/frameworks/base/core/java/android/security/net/config 目录下

以下是根证书管理类的类关系图

img

(1)CertificateSource

接口类,定义了对根证书可执行的获取和查询操作

img

有三个实现类,分别是KeyStoreCertificateSource、ResourceCertificateSource、DirectoryCertificateSource

(2)KeyStoreCertificateSource

从 KeyStore 中获取证书

img

(3)ResourceCertificateSource

基于 ResourceId 从资源目录读取文件并构造证书

img

(4)DirectoryCertificateSource(抽象类)

遍历指定的目录 mDir 读取证书;还提供了一个抽象方法 isCertMarkedAsRemoved() 用于判断证书是否被移除

img

SystemCertificateSourceUserCertificateSource 继承了DirectoryCertificateSource并且分别定义了系统和用户根证书库的路径,并实现抽象方法

[i]SystemCertificateSource

定义了系统证书查询路径,并且还指定了被移除的证书文件的目录

img

判断证书是否移除就是直接判断证书文件是否存在于指定的目录

img

[ii]UserCertificateSource

定义了用户证书指定查询路径,证书是否移除永远为false

img

3、Android证书校验源码

(以证书锁定方式的单向校验服务端证书为例)

核心类TrustManagerImpl、TrustedCertificateIndex、X500Principal

(1)第一步checkServerTrusted()

img

(2)第二步checkTrusted()

img

(3)第三步TrustedCertificateIndex类匹配证书issuer和signature信息

private final Map<X500Principal, List> subjectToTrustAnchors

= new HashMap<X500Principal, List>();

可以看到获取TrustAnchor是通过HashMap的key X500Principal匹配获取的,

img

(4)X500Principal

private transient X500Name thisX500Name;

查看X500Principal的源码可以看到它覆写了equals()方法,对比的是属性中的thisX500Name

调试下来发现我们客户端证书的 thisX500Name 的值为

“CN=*. huolala.cn , OU=IT, O=深圳货拉拉科技有限公司, L=深圳市, ST=广东省, C=CN”

(ps:后面会提到,货拉拉客户端证书异常主要因为新证书缺少了OU字段)

img

(5)subject和issue信息

img

八、货拉拉SSL证书踩坑流程

1、背景简介

2020年7月份的时候,货拉拉出现了因为网络证书过期导致的异常,所以运维的同事拉了客户端的同事一起对齐了方案,使用上述《客户端单向认证服务端---公钥锁定》的方式

由于历史原因:

货拉拉用户端使用了上述(三、1(2)客户端单向认证服务端---证书锁定,代码配置方式)

货拉拉司机端使用了上述(三、1(1)客户端单向认证服务端---证书锁定,network-security-config配置方式)

2021年7月份的时候,运维同事更新了服务端的证书,因为更换过程中没有出现异常,所以运维的同事以为android端都是按照之前约定的《客户端单向认证服务端---公钥锁定》方式

(但实际原因是用户和司机端提前内置了2022-8-19过期的证书)

2、线上出现异常

2022-8-1的时候,运维同事开始操作更新服务端2023年的证书,在更新了H5部分域名的证书之后,司机Android端出现部分网页白屏的问题

排查之后发现服务端更新了证书导致客户端证书校验证书非法导致异常

2022-8-2的时候开始排查用户端的逻辑,发现是《客户端单向认证服务端---证书锁定,代码配置方式》,测试之后发现

(1)删除app内置2022年的证书,只保留2020年的证书之后,native请求异常,无法进入app

(2)手动调整手机设备时间,发现native请求正常,webview白屏和图片加载失败

意味着在服务端更换的证书2022-8-19到期之后,客户端将面临全网访问异常的问题

3、第一次尝试解决

测试的时候发现,android端在证书过期时仍然可以访问服务端(客户端和服务端都保持一致的2022年的证书);

所以想的第1个解决方案是服务端仍然使用2022-8-19的证书,直到大部分用户升级上来之后再更换新证书;

但是ios和web发现如果服务端使用过期证书的情况,系统底层会拦截这个过期证书直接报错;

所以无法兼容所有客户端

4、第二次尝试解决

在查看源码TrustManagerImpl类源码的时候发现,TrustManagerImpl的服务端检验只是校验了publickey(公钥),所以如果2022年的旧证书和2023年的新证书如果公钥一致的话,可能可以校验通过;

所以想的第2个解决方案是服务端使用的新证书保持和2022-8-19的证书的公钥一致就可以;

但是测试的时候发现native请求还是会报错

“java.security.cert.CertPathValidatorException: Trust anchor for certification path not found”

5、第三次尝试解决

开发发现按照证书链的校验过程,如下:

img

如果有中间证书,那么这个中间证书机构颁发的任何服务器证书都可以都校验通过;

所以想出的第3个解决方案是服务器证书内置中间证书组成证书链;

但是排查之后发现服务器证书和客户端内置的证书里面都已经包含了中间证书,所以依然行不通

(ps:如果客户端内置的证书里面删除用户证书信息,只保留中间证书信息,那么只要是这家中间证书颁发的所有的服务器证书都是可以校验通过的,而且一般中间证书的有效期是10年,这也可以作为一个备选项,不过缺点是不安全)

6、第四次尝试解决

(1)测试同学在网上找到一篇《那些年踩过HTTPS的坑(二)——APP证书链mp.weixin.qq.com/s/yv_XcMLvr…

所以想到的解决方案是重新申请一个带OU字段的新服务器证书

(2)但是运维同事咨询了两家之前的中间商之后对方的回复都是新的证书已经不再提供OU字段,理由是

img

img

(3)最后历经一言难尽的各种插曲最后找UniTrust颁发了带OU字段的新证书

(ps:还在使用证书锁定方式校验的可以留意下证书里面的OU字段,后续证书都不会再提供)

九、Android端证书校验的解决方案

1、认证方式

按照安全等级划分,从高到低依次为:

(1)客户端和服务端双向认证,参考上述《五、Android端证书校验方式-3、客户端和服务端双向认证》

(2)客户端单向认证服务端---证书锁定,参考上述《五、Android端证书校验方式-1、客户端单向认证服务端---证书锁定》

(3)客户端单向认证服务端---公钥锁定,参考上述《五、Android端证书校验方式-2、客户端单向认证服务端---公钥锁定》

可以根据各自的安全需求选择合适的认证方式

2、校验方式

(1)证书校验

具体方式参考《五、Android端证书校验方式-1、客户端单向认证服务端---证书锁定》;

为了增强安全性,app可以内置加密后的证书,将解密信息存放在加固后的c++端,增强安全性

(2)公钥校验

具体方式参考《五、Android端证书校验方式-2、客户端单向认证服务端---公钥锁定》;

为了增强安全性,app可以内置加密后的公钥,将解密信息存放在加固后的c++端,增强安全性

3、配置降级

为了在出现异常情况时不影响app访问,可以添加动态配置和动态降级能力

(1)动态配置

动态下发公钥和证书信息,需要留意下发的时机要尽量早一点,避免证书异常时走不到下发的请求

(2)动态降级

动态降级证书校验功能,在客户端证书或者服务端证书出现异常时,支持动态关闭所有的证书校验的功能

十、总结

最后,总结一下整体的思路:

1、SSL证书分为CA证书和用户证书

2、客户端SSL证书校验是在网络连接的SSL/TLS握手环节进行校验

3、SSL证书的认证方式分为(1)单向认证(2)双向认证

4、SSL证书的校验方式分为(1)证书校验(2)公钥校验

5、SSL证书的校验流程主要是校验证书是否是由受信任的CA机构签发的合法证书

6、SSL证书的吊销校验策略分为(1)CRL本地校验证书吊销列表(2)OCSP证书状态在线查询

7、纵观本次踩坑之旅,也暴露出一个比较深刻的问题:大部分的客户端开发的认知还是停留在app上层,缺少对底层技术的认识和探索,导致一个很小的配置问题差点酿成大的事故;这也为想在客户端领域进一步提升提供了一个思路:多学习客户端的底层技术,包含网络底层实现、安全、系统底层源码等等

8、最后,解决技术类问题最核心的点还是学习和熟悉源代码;解决证书配置问题的过程中,走了不少弯路,本质上是最开始没有彻底掌握证书校验相关的系统源代码的逻辑,客观上是由于缺少非android.jar源码的调试手段导致阅读源码遗漏了部分校验逻辑,所以本次特意补上(六、Android端一种源码调试的方式),希望后续遇到系统级的疑难杂症可以用的上

参考:

http://www.cnblogs.com/xiaxveliang…

blog.csdn.net/weixin_3501…

作者:货拉拉技术
来源:https://juejin.cn/post/7186837003026038843

收起阅读 »

Android 中关于枚举的优化

概述Android 中使用 Kotlin 枚举 + when、java 枚举时,源代码编译后会产生额外的产物,进而带来一些额外开销,本文讲述了 Android 对枚举使用的优化的讲解和解决办法。参考ProGuard 的优化列表:http://www.guard...
继续阅读 »

概述

Android 中使用 Kotlin 枚举 + when、java 枚举时,源代码编译后会产生额外的产物,进而带来一些额外开销,本文讲述了 Android 对枚举使用的优化的讲解和解决办法。

参考

枚举的开销

详情描述

eg: 使用 enum 定义枚举类 ClazzEnum.

public enum ClazzEnum {
   ONE, TWO
}

enum 标识符声明的枚举类 ClazzEnum 默认继承自 java.lang.Enum, 每个枚举类成员默认都是 public static final 修饰,每个枚举常量都相当于是一个 ClazzEnum 对象,而 Enum 默认实现已经声明了一些枚举属性,所以枚举通常会比静态常量多两倍以上的内存占用,所以在过去 Android 中不推荐使用枚举。

解决办法

  1. 启用 R8 编译优化;

  2. 使用静态常量或TypeDef注解替换枚举;

R8 编译优化

R8 编译优化枚举,解决枚举造成的额外开销;

Android Studio 3.4.0+ 以后,在 build.gradle 编译配置中通过 minifyEnabled=true 开启 R8 编译优化,R8 会直接调用枚举的序数值(ordinal),在编译的时候将琐碎的枚举优化为整型,避免枚举造成的额外开销。

kotlin/java 代码的编译

为了更好的理解 R8 对枚举的优化,我们简单了解下kotlin/java 代码的编译流程。

在 Android 应用中,kotlin/java 代码的编译流程:

  1. kotlin/javac 编译器编译源代码文件为 java 字节码

    kotlin/javac 编译器会将代码转换为 java 字节码,Android 设备并不直接运行 Java 字节码,而是运行名为 DEX 的 Dalvik 可执行文件;

  2. D8 编译器将 java字节码转为 DEX 代码

  3. R8 (可选项,推荐 release 使用)优化:

    R8 在 build.gradle 中通将 minifyEnabled 设为 true 来开启,它将在所有其他编译工作后执行,来保证您获得的是一个缩减和优化过的应用。

Kotlin: 枚举+when

问题描述

在 Kotlin 中使用枚举时,也仅仅是将其转换为 Java 编程语言中的枚举而已,本身并不包含任何隐藏开销。但当 枚举+when 配合使用时,就会引入额外的开销。

我们举个例子:

package enums

fun main() {
   val age: Int = getAge(People.CHILD);
   println("ret: ${age}")
}

fun getAge(p: People): Int {
   return when (p) {
       People.ADULT -> 30
       People.CHILD -> 18
  }
}

enum class People {
   ADULT,
   CHILD
}

查看上述代码编译后的字节码:

# 查看字节码
# 方式一:IDEA(可能有些地方编译失败)
IDEA/AndroidStudio -> Tools -> Kotlin -> Show Kotlin Bytecode -> Decompile
# 方式二:kotlinc + JD-GUI
$ kotlinc test.kt -include-runtime -d ret.jar
// 编译后的字节码
public final class TestKt$WhenMappings {
  public static final int[] $EnumSwitchMapping$0 = new int[People.values().length];

  static {
     $EnumSwitchMapping$0[People.ADULT.ordinal()] = 1;
     $EnumSwitchMapping$0[People.CHILD.ordinal()] = 2;
  }
}

@Metadata(...)
public final class TestKt {
public static final void main() {
  int age = getAge(People.CHILD);
  String str = Intrinsics.stringPlus("ret: ", Integer.valueOf(age));
  boolean bool = false;
  System.out.println(str);
}
 
public static final int getAge(@NotNull People p) {
  Intrinsics.checkNotNullParameter(p, "p");
  People people = p;
  int i = WhenMappings.$EnumSwitchMapping$0[people.ordinal()];
  switch (i) {
    case 1:
     
    case 2:
     
  }
  throw new NoWhenBranchMatchedException();
}
}

在上述编译后的代码中可以发现,当使用 when 语句接受枚举作为参数时,编译后 when 转换成的 switch 并没有让 switch 语句直接接受枚举,而是接受了 p 枚举对应 ordinal 作为索引对应 TestKt$WhenMappings 数组中的元素值作为参数。

可以发现使用 when 语句时,编译后产物中会生成 TestKt$WhenMappings类,这个类里面有一个存储映射信息的数组 $EnumSwitchMapping$0,接下来则是一些执行映射操作的静态代码。

示例中是只有一个 when 语句时的情况,如果我们写了更多的 when 语句,那么每个 when 语句都会在 TestKt$WhenMappings类中生成一个对应的数组,即使这些 when 语句都在使用同一个枚举也一样。所以这就意味着,在您不知情的时候,会生成一个类,而且其中还包含了一些数组,这些都会让类加载和实例化消耗更多的时间。

解决办法

  1. Kotlin 中枚举可以用 Sealed Class 密封类替代;

  2. 启用 Android R8 编译会自动优化,避免生成类和映射数组,而且只会创建了您所需的最佳代码;

    // 启用 R8 编译优化后,会直接把 when 转为 switch, 并接收 Enum#ordinal 作为参数;
    public static final int getAge(@NotNull People p) {
       switch (p.ordinal()) {
           case 0:
           // ...
      }
    }

作者:呛呛cei
来源:juejin.cn/post/7070074670036287496

收起阅读 »

kotlin-object关键字与单例模式

object 关键字有三种不同的语义:匿名内部类、伴生对象、单例模式。因为 Kotlin 的设计者认为,这三种语义本质上都是在定义一个类的同时还创建了对象。在这样的情况下,与其分别定义三种不同的关键字,还不如将它们统一成 object 关键字。 一、 匿名内部...
继续阅读 »

object 关键字有三种不同的语义:匿名内部类、伴生对象、单例模式。因为 Kotlin 的设计者认为,这三种语义本质上都是在定义一个类的同时还创建了对象。在这样的情况下,与其分别定义三种不同的关键字,还不如将它们统一成 object 关键字。


一、 匿名内部类


Android中用java写View的点击事件:


findViewById(R.id.tv).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
//do something
}
});

在 Kotlin 当中,我们会使用 object 关键字来创建匿名内部类。同样,在它的内部,我们也必须要实现它内部未实现的方法。这种方式不仅可以用于创建接口的匿名内部类,也可以创建抽象类的匿名内部类:


findViewById<TextView>(R.id.tv).setOnClickListener(object : View.OnClickListener {
override fun onClick(v: View?) {
//do something
}
})

//上面的代码可以用SAM转换简化,IDE会提示

Java 和 Kotlin 相同的地方就在于,它们的接口与抽象类,都不能直接创建实例。想要创建接口和抽象类的实例,我们必须通过匿名内部类的方式。


在 Kotlin 中,匿名内部类还有一个特殊之处,就是我们在使用 object 定义匿名内部类的时候,其实还可以在继承一个抽象类的同时,来实现多个接口:


//抽象类和抽象方法
abstract class Person{
abstract fun isAdult()
}
//接口
interface AListener {
fun getA()
}
//接口
interface BListener {
fun getB()
}

//继承一个抽象类的同时,来实现多个接口
private val item = object :Person(),AListener,BListener{
override fun isAdult() {
//do something
}

override fun getA() {
//do something
}

override fun getB() {
//do something
}
}

在日常的开发工作当中,我们有时会遇到这种情况:我们需要继承某个类,同时还要实现某些接口,为了达到这个目的,我们不得不定义一个内部类,然后给它取个名字。但这样的类,往往只会被用一次就再也没有其他作用了。所以针对这种情况,使用 object 的这种语法就正好合适。我们既不用再定义内部类,也不用想着该怎么给这个类取名字,因为用过一次后就不用再管了。


引申:可以把函数当做参数简化定义接口的操作。以前写java时应该都写过很多如下的接口回调:


class DownloadFile {
//携带token下载文件
fun downloadFile(token:String) {
val filePath = ""
listener?.onSuccess(filePath)
}
//定义成员变量
private var listener: OnDownloadResultListener? = null
//写set方法
fun setOnDownloadResultListener(listener: OnDownloadResultListener){
this.listener = listener
}
//定义接口
interface OnDownloadResultListener {
fun onSuccess(filePath:String)
}
}

通过函数当做参数就不需要定义接口了:


class DownloadFile {

private var onSuccess: ((String?) -> Unit)? = null

fun downloadFile(token:String) {
val filePath = ""
onSuccess?.invoke(filePath)
}

fun setOnDownloadResultListener(method:((String?) -> Unit)? = null){
this.onSuccess = method
}
}

//调用
DownloadFile().downloadFile("")
DownloadFile().setOnDownloadResultListener { filePath ->
print("$filePath")
}

二、单例模式


在 Kotlin 当中,要实现单例模式其实非常简单,我们直接用 object 修饰类即可:


object StringUtils {

fun getLength(text: String?): Int = text?.length ?: 0

}

//反编译
public final class StringUtils {
@NotNull
public static final StringUtils INSTANCE; //静态单例对象

public final int getLength(@Nullable String text) {
return text != null ? text.length() : 0;
}

private StringUtils() {
}

static { //静态代码块
StringUtils var0 = new StringUtils();
INSTANCE = var0;
}
}

这种方式定义的单例模式,虽然简洁,但存在两个缺点:


1、不支持懒加载。


2、不支持传参构造单例。写构造方法会报错,会提示object修饰的类不允许有构造方法。


三、伴生对象


1、深入分析伴生对象


Kotlin 当中没有 static 关键字,所以我们没有办法直接定义静态方法和静态变量。不过,Kotlin 还是为我们提供了伴生对象,来帮助实现静态方法和变量。


我们先来看看 object 定义单例的一种特殊情况,看看它是如何演变成“伴生对象”的:


class User() {
object InnerClass {
fun foo() {}
}
}

用object修饰嵌套类,看下反编译的结果:


public final class User {
//object修饰的内部类为静态内部类
public static final class Inner {
@NotNull
public static final User.Inner INSTANCE; //静态单例对象

public final void foo() {
}

private Inner() {
}
//通过static静态代码块创建了单例对象
static {
User.Inner var0 = new User.Inner();
INSTANCE = var0;
}
}
}

调用的时候的代码


User.InnerClass.foo()

可以看到foo方法并不是静态方法,那加上@JvmStatic这个注解试试:


class User() {
object InnerClass {
@JvmStatic
fun foo() {}
}
}

//反编译结果
public final class User {

public static final class InnerClass {
@NotNull
public static final User.InnerClass INSTANCE;

@JvmStatic
public static final void foo() { //foo方法变成了静态方法
}

private InnerClass() {
}

static {
User.InnerClass var0 = new User.InnerClass();
INSTANCE = var0;
}
}
}

foo方法变成了一个静态方法,但是在使用的时候还是要User.InnerClass.foo(),而User类中的静态方法应该是直接User.foo()调用才对,这还是不符合定义静态方法的初衷。那在 Kotlin 如何实现这样的静态方法呢?我们只需要在前面例子当中的 object 关键字前面,加一个 companion 关键字即可。


①不加@JvmStatic注解


//假如不加@JvmStatic注解
class User() {
companion object InnerClass {
fun foo() {}
}
}

//反编译
public final class User {
@NotNull
public static final User.InnerClass InnerClass = new User.InnerClass((DefaultConstructorMarker)null);

public static final class InnerClass {
public final void foo() {
}

private InnerClass() {
}

// $FF: synthetic method
public InnerClass(DefaultConstructorMarker $constructor_marker) {
this();
}
}
}

//调用
User.foo()

//反编译调用的代码
User.InnerClass.foo();

如果不加上@JvmStatic注解调用的时候只是省略了前面的单例对象InnerClassfoo仍然不是User的静态方法。


②加@JvmStatic注解


//假如加@JvmStatic注解
class User() {
companion object InnerClass {
@JvmStatic
fun foo() {}
}
}

//反编译
public final class User {
@NotNull
public static final User.InnerClass InnerClass = new User.InnerClass((DefaultConstructorMarker)null);

@JvmStatic
public static final void foo() { //多生成了一个foo方法,但其实还是调用的下面的foo方法
InnerClass.foo();
}

public static final class InnerClass {
@JvmStatic
public final void foo() { //实际的foo方法
}

private InnerClass() {
}

// $FF: synthetic method
public InnerClass(DefaultConstructorMarker $constructor_marker) {
this();
}
}
}

可以看到这个时候多生成了一个静态的foo方法,可以通过User.foo()真正去调用了,而不是省略掉了InnerClass单例对象(把InnerClass对象放在了静态方法的实现中)。


那又有问题来了,上面二种方式应该如何选择,哪种情况下哪个好,什么时候该加注解什么时候不该加注解?


解析:1、用companion修饰的对象会创建一个Companion的实例:


class User {
companion object {
fun foo() {}
}
}

//反编译
public final class User {
@NotNull
public static final User.Companion Companion = new User.Companion((DefaultConstructorMarker)null);

public static final class Companion {
public final void foo() {
}

private Companion() {
}

// $FF: synthetic method
public Companion(DefaultConstructorMarker $constructor_marker) {
this();
}
}
}

//java中调用
User.Companion.foo();

如果不加@JvmStatic,java调用kotlin代码会多创建这个Companion实例,会多一部分内存开销,所以如果这个静态方法java需要调用,那务必要把@JvmStatic加上。


2、多创建一个静态foo方法会不会多内存开销? 答案是不会,因为这个静态的foo方法调用的也是Companion中的方法foo方法,所以不会有多的内存开销。


2、用伴生对象实现工厂模式


所谓的工厂模式,就是指当我们想要统一管理一个类的创建时,我们可以将这个类的构造函数声明成 private,然后用工厂模式来暴露一个统一的方法,以供外部使用。Kotlin 的伴生对象非常符合这样的使用场景:


// 私有的构造函数,外部无法调用
class User private constructor(name: String) {
companion object {
@JvmStatic
fun create(name: String): User? {
// 统一检查,比如敏感词过滤
return User(name)
}
}
}

3、用伴生对象实现单例模式


(1)、借助懒加载委托


class MainActivity : AppCompatActivity() {

//借助懒加载委托实现单例
private val people by lazy { People("张三", 18) }

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)

}
}

//反编译后
public final class MainActivity extends AppCompatActivity {
private final Lazy people$delegate;

private final People getPeople() {
Lazy var1 = this.people$delegate;
Object var3 = null;
return (People)var1.getValue();
}

protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
this.setContentView(1300000);
}

public MainActivity() { //构造方法
this.people$delegate = LazyKt.lazy((Function0)null.INSTANCE); //lazy方法有线程安全的实现
}
}

MainActivity的构造方法中通过LazyKt.lazy获取类的代理对象,看下LazyKt.lazy的源码实现:


/**
* Creates a new instance of the [Lazy] that uses the specified initialization function [initializer]
* and the default thread-safety mode [LazyThreadSafetyMode.SYNCHRONIZED]. //线程安全模式
*
* If the initialization of a value throws an exception, it will attempt to reinitialize the value at next access.
*
* Note that the returned instance uses itself to synchronize on. Do not synchronize from external code on
* the returned instance as it may cause accidental deadlock. Also this behavior can be changed in the future.
*/
public actual fun <T> lazy(initializer: () -> T): Lazy<T> = SynchronizedLazyImpl(initializer)

/**
* Creates a new instance of the [Lazy] that uses the specified initialization function [initializer]
* and thread-safety [mode].
*
* If the initialization of a value throws an exception, it will attempt to reinitialize the value at next access.
*
* Note that when the [LazyThreadSafetyMode.SYNCHRONIZED] mode is specified the returned instance uses itself
* to synchronize on. Do not synchronize from external code on the returned instance as it may cause accidental deadlock.
* Also this behavior can be changed in the future.
*/
public actual fun <T> lazy(mode: LazyThreadSafetyMode, initializer: () -> T): Lazy<T> =
when (mode) {
LazyThreadSafetyMode.SYNCHRONIZED -> SynchronizedLazyImpl(initializer)
LazyThreadSafetyMode.PUBLICATION -> SafePublicationLazyImpl(initializer)
LazyThreadSafetyMode.NONE -> UnsafeLazyImpl(initializer)
}

(2)、伴生对象 Double Check


class UserManager private constructor(name: String) {

companion object {
@Volatile
private var INSTANCE: UserManager? = null

fun getInstance(name: String): UserManager =
// 第一次判空
INSTANCE?: synchronized(this) {
// 第二次判空
INSTANCE?:UserManager(name).also { INSTANCE = it }
}
}
}
// 使用
UserManager.getInstance("Tom")

我们定义了一个伴生对象,然后在它的内部,定义了一个 INSTANCE,它是 private的,这样就保证了它无法直接被外部访问。同时它还被注解“@Volatile”修饰了,这可以保证INSTANCE的可见性,而getInstance()方法当中的synchronized,保证了INSTANCE的原子性。因此,这种方案还是线程安全的。


同时,我们也能注意到,初始化情况下,INSTANCE 是等于 null 的。这也就意味着,只有在getInstance() 方法被使用的情况下,我们才会真正去加载用户数据。这样,我们就实现了整个UserManager的懒加载,而不是它内部的某个参数的懒加载。


另外,由于我们可以在调用getInstance(name) 方法的时候传入初始化参数,因此,这种方案也是支持传参的。


单例模式最多的写法,注意如果参数是上下文,不能传递ActivityFragment的上下文,不然会有内存泄漏。(单例的内存泄漏)


(3)、抽象类模板


如果有多个类似于上面的单例,那么就会有很多重复代码,于是尝试抽象成模板代码:


//要实现单例类,就只需要继承这个 BaseSingleton 即可
//P为参数,T为返回值
abstract class BaseSingleton<in P, out T> {

@Volatile
private var instance: T? = null

//抽象方法,需要我们在具体的单例子类当中实现此方法
protected abstract fun creator(param: P): T

fun getInstance(param: P): T =
instance ?: synchronized(this) {
instance ?: creator(param).also { instance = it }
}
}

通过伴生对象实现抽象类,并给出具体实现


//构建UploadFileManager对象需要一个带参数的构造方法
class UploadFileManager(val param: String) {

//伴生对象实现BaseSingleton抽象类
companion object : BaseSingleton<String, UploadFileManager>() {
//重写方法并给出具体实现
override fun creator(param: String): UploadFileManager {
return UploadFileManager(param)
}
}


fun foo(){
print("foo")
}
}

//调用
UploadFileManager.getInstance("张三").foo()

因为构造方法的限制这种封装也有一定的局限性。


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