注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

一文揭秘饿了么跨端技术的演进、实践与落地

web
导读:本文会先带领大家一起简单回顾下跨端技术背景与演进历程与在这一波儿接着一波儿的跨端浪潮中的饿了么跨端现状,以及在这个背景下,相较于业界基于 React/Vue 研发习惯出发的各种跨端方案,饿了么为什么会选择走另外一条路,这个过程中我们的一些思考、遇到及解决...
继续阅读 »

导读:本文会先带领大家一起简单回顾下跨端技术背景与演进历程与在这一波儿接着一波儿的跨端浪潮中的饿了么跨端现状,以及在这个背景下,相较于业界基于 React/Vue 研发习惯出发的各种跨端方案,饿了么为什么会选择走另外一条路,这个过程中我们的一些思考、遇到及解决的问题和取得的一些成果,希望能给大家带来一些跨端方面的新思路。



跨端技术背景与演进历程


跨端,究竟跨的是哪些端?


自 90 年的万维网出现,而后的三十多年,我们依次经历了 PC 时代、移动时代,以及现在的万物互联(的 IoT )时代,繁荣的背后,是越来越多的设备、越来越多的系统以及各种各样的解决方案。


总的来说,按照跨端的场景来划分,主要包含以下 4 类:




  • 跨设备平台,如 PC(电脑)/ Mobile(手机)/ OTT(机顶盒)/ IoT(物联网设备)。不同的设备平台往往意味着不同的硬件能力、传感器、屏幕尺寸与交互方式

  • 跨操作系统,如 Android/iOS/HarmonyOS。不同的操作系统为应用开发通常提供了不同的编程语言、应用框架和 API

  • 跨移动应用,如 微信/支付宝/手淘/抖音/快手等。由于移动平台 CS 架构 及 App 间天然的壁垒,不同 App 间相互隔离,并各自在其封闭体系内遵循一套自有标准进行各类资源的索引、定位及渲染。而同一业务投放至不同 App 端时,就需要分别适配这些不同的规则。

  • 跨渲染容器,如 Webview/React Native/Flutter。前面三类场景催生了针对不同设备平台、不同操作系统、不同 App 间解决方案,因而移动领域的各种 Native 化渲染、自绘渲染与魔改 Webview 的方案也由此被设计出来,在尝试解决跨端问题的同时,也一定程度上提高了跨端的迁移门槛和方案选择难度。


而在当下,移动领域依然是绝对的主角,我们来看一下移动端的跨端技术都经历了哪些阶段。


移动跨端技术演进


随着移动互联网的蓬勃发展,端形态变的多样,除了传统的 Native、H5 之外,以动态化与小程序为代表的新兴模式百花齐放大行其道,世面上的框架/容器/工具也层出不穷,整个业态朝着碎片化方向发展。


对开发者来说,碎片化的直接影响,是带来了包括但不限于,刚才提到的设备平台、操作系统、渲染容器、语法标准等方面的各种不确定性,增加了大量的学习、开发与维护成本。


于是,应运而生的各类跨端技术,核心在于从不确定性中找寻确定性,以保障研发体验与产物一致性为前提,为各端适配到最优解,用最少成本达到最好效果,真正做到 "一次编写,到处运行"。


移动跨端大致经历了如下几个阶段:





  • H5 Wap 阶段:Web 天然跨平台,响应式布局是当时的一个主要手段,但由于早期网络环境原因,页面加载速度无法满足业务预期,加之设备传感器标准缺失、内存占用大、GPU 利用率低等问题,在移动设备量爆发伊始,难堪大任的论调一下子被推上风口浪尖,并在 12 年达到顶峰。




  • Hybrid 阶段:典型代表是 Cordova/ionic。功能上看,Hybrid 解决了历史两大痛点:



    • 1)性能,依靠容器能力,各类离线化、预装包、Prefetch 方案大幅减少加载耗时,配合编码优化在 3/4G 时代使 H5 的体验上了一个台阶;

    • 2)功能,通过 JSBridge 方式规避了与 Native 原生割裂带来的底层能力缺失。




  • 框架+原生阶段:典型代表是 ReactNative/Weex。基于 JSC 或类似的引擎,在语法层与 React/Vue 结合,渲染层使用原生组件绘制,尝试在研发效率与性能体验间寻找更佳的平衡点,各类领域解决方案(受限 DSL + 魔改 web 标准 + Native 渲染能力)开始涌现,拉开了大前端融合渲染方案的序幕。




  • 自绘渲染阶段:典型代表是 Flutter/Qt。这里的 “自绘” 更强调不使用系统原生控件或 Webview 的渲染管线,而是依赖 Skia、Cairo 等跨平台图形库,自底向上自建渲染引擎、研发框架及基础配套的方式,其跨 Android/iOS 的特性迅速点燃了客户端研发领域。




  • 小程序阶段:典型代表是 微信/支付宝小程序。小程序是被创造出来的,其本质是各 APP 厂商出于商业考量构造了相对封闭的生态,在标准与能力上无论与 Web 还是厂商之间均存在差异,能力上是自定义 DSL & API + Hybrid + 同层渲染 + 商业管控的综合体。市面跨端方案策略均是锚定一种研发规约进行各形态编译时与运行时的差异抹平与适配。




回顾了以上跨端技术背景与演进历程,在这股浪潮里面,饿了么的跨端投放情况是什么样的?投了那些端?遇到了哪些问题?又是如何解决的?


饿了么跨端投放诉求、现状与策略



众所周知,饿了么是围绕 O2O 为用户提供线上到线下服务的公司,通过对时、空、人、货 的有机结合,来链接商家与消费者,相比于传统电商,时空人货本身具有区域属性,这意味着我们做的不是一个大卖场生意,更多的是需要围绕区域特性提供精细化的服务,这里面有一系列时空、体验、规模、成本的约束需要考虑与应对


而在这一系列约束背后,其实有一个各方共通的经营诉求:



  • 对于商家来说:为了有更好的经营需要有更多曝光,与客户有更多的触达,以便带来成交

  • 对于平台来说:为了能够让更多消费者享受我们的服务,除了深耕自己的超级APP(饿了么APP)外,还需要在人流量大的地方加大曝光、声量与服务能力来扩大我们的规模


这都导向一个目的:哪里流量多,我们就需要在哪里提供与消费者的连接能力


那么问题来了,流量在哪里?现在的互联网,更多都是在做用户的时间与精力生意,背后拆解下来,其实有几个关键因素可以衡量:用户密度、用户活跃度、市场占有率与用户时间分配,细化来看,其中任意几个条件满足,都可以作为我们流量阵地的候选集。


饿了么经过多年耕耘,对外部关键渠道做了大量布局,业务阵地众多,从效果上看,渠道业务无论是用户流量规模还是订单规模均对大盘贡献度较高,且随着业务的持续精进与外部合作环境的持续改善,增量渠道也在不断的涌现中。



在这么多的业务阵地中,投放在各个端的应用的形态基于:



  • 渠道的运行环境

  • 渠道的流量特性

  • 渠道的业务定位

  • 渠道的管控规则


等的差异和限制,目前形成了 以小程序为主,H5为辅 的承接方式,而这些差异带来了大量的不确定性,主要体现在:



  • 渠道环境的高度不确定性:对接了这么多渠道,每个端的运行环境存在巨大差异,拿小程序能力举例,即使是个别 APP 的小程序方案借鉴了微信的思路,由于其内部商业能力、产品设计思路、能力成熟度与完整度、研发配套(语法、框架、工具等)的不一致也会使研发体感有明显的不同,这对技术同学来说,带来的是渠道环境的高度不确定性;

  • 业务诉求的高度不确定性:同时,我们所投放的 APP 都可划分到某些细分领域,用户特性与用户在该平台上的诉求不一,渠道定位也不一致,随着每个业务域的功能演进越来越多,多个渠道功能是否对齐、要不要对齐、有没有对齐、什么时候对齐成了一个非常现实和麻烦的事情,同时业务域之间可能还存在功能上的关联,这进一步提高了其复杂度,在没有一个好的机制与能力保障下,业务、产品、研发对每个渠道的同步策略、能力范围的感知会有较大偏差,甚至于一个需求的迭代,每个端什么时候能同步都变成了一个无法预期的事情,这对于业、产、研来说,带来的是业务诉求上的高度不确定性。


而我们要做的,就是在这两种不确定性中,找到技术能带来的确定性的事情。如何系统性的解决这些问题,则成为我们在保障渠道业务灵活性的基础上持续提升研发效率和体验的关键。


在差异应对上,业务研发最理想的方式是对底层的变化与不一致无感,安心应对业务诉求,基于这个点出发,我们的主要策略是:围绕 “研发体验一致性提升与复杂应用协作机制改进”来保障业务高效迭代。这需要一套强有力的、贴合业务特性的基础设施来支撑。首先想到的便是如何通过“推动框架统一”和“实现一码多端”,来为业务研发降本增效,然而理想很丰满,现实很骨感:



框架的升级通常情况下,大概率会带来业务重构,综合评估之后,作为外部渠道流量大头的小程序业务,则成为了我们优先要保障的业务,也基于此,为了尽可能降低对业务的影响和接入成本,我们明确了以小程序为第一视角来实现多端。


基于小程序跨端的行业现状和思考


在明确了方向之后,那么问题来了:业界有没有适合我们的开源的框架或解决方案呢?


业界有哪些面向于小程序的研发框架?



市面上,从小程序视角出发,具备类似能力的优秀多端框架有很多,有代表性的如 Taro、uni-app、Rax 等,大多以 React 或者 Vue 作为 DSL


那么这些框架能否解决我们所面临的问题?答案是:并不能。


为什么饿了么选择以小程序 DSL 为基础实现跨端?



综合 饿了么 的渠道业务背景需要考虑以下几点:



  • 改造成本:以支付宝、微信、淘宝为代表的饿了么小程序运营多年,大部分存量业务是以支付宝或微信小程序 DSL 来编写,需关注已有业务逻辑(或组件库)的改造成本,而采纳业界框架基本上会直接引发业务的大量重构,这个改造成本是难以接受的。

  • 性能体验:渠道业务是饿了么非常重要的流量阵地,重视程度与APP无差,在体验和性能上有极致的要求,所以我们期望在推动跨端的同时,尽可能减少运行时引入带来的性能损耗。

  • 业务协同:由于每个渠道都基本相当于一个小型的饿了么APP,复杂度高,涉及到多业务域的协同,包括主线步调一致性考量、多业务线/应用类型集成、全链路功能无缝衔接等,在此之外还需给各业务线最大限度的自控与闭环能力,背后需要的是大型小程序业务的一体化研发支撑。


在做了较多的横向对比与权衡之后,上面的这些框架对于我们而言采纳成本过高,所以我们选择了另外一条相对艰辛但更为契合饿了么多端演进方向的路:以小程序原生 DSL 为基础建设跨端解决方案,最大限度保障各端产物代码贴合小程序原生语法,以此尽可能降低因同构带来的体验损耗和业务多端接入成本。


基于小程序 DSL 的跨端解决方案


确定以小程序 DSL 作为方向建设跨端解决方案之后,首先要解决的就是如果将已有的小程序快速适配到多端。这就需要对各个端的差异做细致的分析并给出解决方案。



如何解决小程序多端编译?


为了能够兼顾性能和研发体验,我们选择了 编译时(重)+运行时(轻) 的解决方案。


静态编译解决了那些问题?



静态编译转换主要用于处理 JSWXS/SJSWXML/AXMLWXSS/ACSSJSON 等源码中约束强且不能动态修改的部分,如:



  • 模块引用:JS/WXS/SJS/WXML/AXML/WXSS/ACSS/JSON 等源码中的模块引用替换和后缀名修改;

  • 模版属性映射或语法兼容: AXML/WXML 中如 a:if → wx:if、 onTap → bind:tap{{`${name}Props`}} →  {{name + 'Props'}} 等;

  • 配置映射:如页面 { "titleBarColor": "#000000" } → { "navigationBarBackgroundColor: "#000000", "navigationBarTextStyle": "white" }


等,原理是通过将源码文件转换为 AST(抽象语法树),并通过操作 AST 的方式来实现将源码转换为目标平台的代码。


但静态编译只能解决一部分的差异,还有一些差异需要通过运行时来抹平。


运行时补偿解决了那些问题?



运行时补偿主要用于处理静态编译无法处理或者处理成本较高的一些运行时动态内容,如:



  • JSAPI:实际业务使用上,不管是 JSAPI 的名字还是 JSAPI 的入参都会存在动态赋值的情况,导致了在 JSAPI 的真实调用上,很难通过 AST 去解析出实际传参;

  • 自定义组件 - Props 属性:比如,支付宝属性使用 props 声明,而微信属性使用 properties 声明,配置方式不同且使用时分别使用 this.props.x 及 this.properties.x 的方式获取,同时可能存在动态取值的情况;

  • 自定义组件 - 生命周期:支付宝小程序中的 didUpdate 生命周期,在触发了 propsdata 更新后都会进入 didUpdate 这个生命周期,且能够在 didUpdate 中访问到prevProps / prevData,而在微信小程序中静态转义出这个生命周期就意味着你需要去动态分析出didUpdate里面要用到的所有属性,然后去动态生成出这些属性的监听函数。这显然可靠程度是极其低的;


等等,类似的场景有很多,这里不再一一列举。


通过静态编译 + 运行时补偿的方式,我们便可以让现有的微信或支付宝小程序快速的迁移到其他小程序平台。


如何解决小程序转 Web?


伴随外卖小程序上线多年之后,各个大的渠道(支付宝、手淘、微信等)已切流为小程序承载,但是还有很多细分渠道或非小程序环境渠道,比如:各个银行金融渠道,客户端的极小包等,还需要依赖 H5 的形态快速投放,但当前饿了么的业务越来越复杂,相关渠道的投入资源有限,历史包袱重、迭代成本大等原因,产品功能和服务能力远远落后于小程序和饿了么App。而业务急需一个可以将小程序的功能快速复制到 h5 端的技术方案,以较低的研发和维护成本,满足业务多渠道能力对齐上线的诉求。


基于这个背景,我们自然而然的可以想到,即然小程序可以转其他小程序,那么是否也可以直接将小程序直接转换为 Web,从而最大程度上提升人效和功能对齐效率。


具体是怎么实现的?主要手段还是通过编译时 + 运行时的有机结合:


Web 转端编译原理



编译部分和小程序转小程序的主要区别和难点是:需要将 JSWXS/SJSWXML/AXML 等文件统一转换并合并为 JS 文件并将 WXML/AXML 文件转换为 JSX 语法,将样式文件统一转换为 CSS 文件,并将小程序的页面和组件都转换为 React 组件。


运行时原理



转 Web 的运行时相较于转换为其他小程序会重很多,为了兼顾性能和体验,运行时的核心在于提供与小程序对等的高效运行环境,这里面包含四个主要模块:



  • 框架:提供了小程序在 Web 中的基础运行时功能,比如:Page 、Component 、App 等全局函数,并且提供完整的生命周期实现,事件的注册、分发等

  • 组件:提供小程序公共组件的支持,比如 viewbuttonscroll-view 等小程序原生提供的组件

  • API:提供了类似小程序中 wx 或者 my 的 一系列 api 的实现

  • 路由:提供了页面路由支持和 getCurrentPages 等方法支持


基于这四个模块,配合编译时的自动注入和代码转换,以及路由映射等,我们就可以把一个小程序转换为一个 Web 的 SPA(单页) 或者 MPA(多页) 应用,也成功的解决了业务的研发效率问题,目前 饿了么的新 M 站就是基于这套方案在运行。


如何解决多端多形态问题?



解决了各端的编译转换问题,是不是就万事大吉,业务接下来只要按部就班的基于这套能力实现一码多端就可以了?


然而并不是,随着饿了么的业务场景和范围快速拓展,诞生了一些新的诉求,比如:



  • 支付宝的独立小程序作为分包接入微信小程序

  • 淘宝 / 支付宝的小程序插件作为分包接入某个现有的微信小程序

  • 支付宝的独立小程序作为插件接入淘宝小程序插件

  • 支付宝小程序插件作为分包接入微信或抖音小程序


等等,大家如果仔细观察这些诉求,就会发现一个共同的点:就是小程序的形态不一样。


虽然我们已经具备了多端的能力,但是形态的差异没有解决掉,而之前相关业务的做法是,尽可能将通用功能沉淀到组件库,并按照多端的方式分端输出产物,然而由于相同业务在不同小程序端形态差异性的问题,业务上难以自行规避,而重构的成本又比较高,所以有一部分业务选择了直接按照不同的端不同的形态(如微信、支付宝、淘宝、抖音)各自维护一套代码,但这样做不仅功能同步迭代周期被拉长,而且 BUG 较多,维护困难,研发过程也是异常痛苦。


小程序形态差异有哪些?


形态差异是指 小程序、小程序分包、小程序插件 三种不同形态的运行方式差异以及转换为其他形态之后产生的差异,具体如下:




  • getApp 差异



    • 小程序: 可通过 getApp() 来获取全局 App 实例及实例上挂载的属性或方法

    • 小程序插件: 无法调用 getApp()

    • 小程序分包: 可通过 getApp() 来获取全局 App 实例及实例上挂载的属性或方法;但当通过小程序转换为分包后,分包自身原本调用的 getApp 将失效,并被替换为宿主小程序的 getApp




  • App 应用生命周期 差异



    • 小程序: 应用会执行 onLaunch、onShow、onHide 等生命周期

    • 小程序插件: 无应用生命周期

    • 小程序分包: 无应用生命周期




  • 全局样式(如:app.wxss 或 app.acss)差异



    • 小程序: 可通过全局样式来声明全局样式

    • 小程序插件: 无全局样式

    • 小程序分包: 无全局样式




  • NPM 使用限制



    • 小程序: 各个小程序平台支持和限制情况不一

    • 小程序插件: 各个小程序平台支持和限制情况不一

    • 小程序分包: 各个小程序平台支持和限制情况不一




  • 接口调用限制





  • 路由差异



    • 小程序: 转换到其他形态后自身路由会发生变化

    • 小程序插件: 转换到其他形态后自身路由会发生变化,跳转插件页面需要包含 plugin:// 或 dynamic-plugin:// 等前缀,小程序或分包则不需要

    • 小程序分包: 转换到其他形态后自身路由会发生变化




  • getCurrentPages 差异



    • 小程序: 无限制

    • 小程序插件: 无法通过 getCurrentPages 获取到小程序的页面堆栈

    • 小程序分包: 无限制




  • 页面或组件样式差异



    • 小程序: 无限制

    • 小程序插件: 基本选择器只支持 ID 与 class 选择器,不支持标签、属性、通配符选择器

    • 小程序分包: 无限制




等等,相关形态差异可结合各个小程序平台查看,这里仅罗列常见的部分。


如何解决这些差异?


这里举几个例子:



通过在编译过程中,自动向产物中注入对 App 和 getApp 的运行时模拟实现,这样就可以解决分包和插件下方法缺失或者是冲突引起的报错问题。



方法也是类似,可以在编译的过程中检测全局样式是否存在,如果存在,则将对应的全局样式引用自动注入到每一个页面和组件中来解决全局样式失效的问题。



而针对各个小程序平台的 NPM 使用规则不同的问题,可以通过依赖解析、动态分组、组件提取打包、引用替换等方式,将 NPM 抽取到特定的地方,并将对应的组件和页面中的引用进行替换,来解决 NPM 的支持问题,这样业务就可以基本无脑使用各类 NPM 而不用关心平台差异。


以此类推,将业务难以自行适配的差异,逐一解决之后,剩余的一些功能差异,则由业务基于条件编译的方式来自行适配,这样便可以大大的降低业务形态转换成本,同时也形成了我们面向多端场景下的形态转换方案。


那么到这里,多端转换的问题才算是基本解决了。


如何治理 “复杂小程序”?


如果说上面讲的内容都是聚焦在如何通过编译的方式来解决多端同构以及形态问题的话,那么接下来要解决的就是针对“复杂小程序”的应用架构与研发协作的问题了。



首先介绍下我们所定义的 “复杂小程序”,即具备跨业务领域的、长周期的、多团队协同的、呈现主链路+多分支业务模式的应用,其之所以“复杂”,主要体现在应用形态多样、诉求多样、关联业务面广等特性上


对于饿了么来说,每个渠道阵地均相当于一个小型饿了么APP,除了在研发上提供便利外,还需一套可靠的应用架构来保证其有序演进。


同时,由于渠道之间定位不同,各域的业务、产品及研发对各渠道重视程度与投入比重均有差异,间接导致渠道间相同业务能力的参差不齐,且不同渠道功能缺失的情况持续出现。


我们以饿了么微信小程序为例:



面临的问题有哪些?



  • 工程复杂导致研发效率低:大量的团队在一个单体小程序应用上研发,带来的直接问题就是小程序巨大化带来的研发体验差和编译效率低,且业务相互依赖,单一模块构建失败会引发整个项目的失败,比如饿了么微信小程序单次编译的时间超过了半个小时,且体积逼近 20m 上限

  • 研发流程不规范导致稳定性差:同时由于不同的业务团队迭代周期不一致,而每次发版都需要所有业务的代码一起发,哪怕是某个业务分包或者插件没有更新,但是对应的底层依赖库发生了变更,也极有可能引入线上 BUG,导致测试回归的成本居高不下,发版质量难以保障


解决方案:线下线上结合的集成研发模式


针对上面两个“复杂小程序”所面临的核心问题,我们针对性的通过 「线下集成研发」和「线上研发协作」来解决。


线下集成研发


重点考虑的是提供什么样的集成研发能力,允许以业务单元维度将多个独立的构建(宿主、小程序、插件、分包等)组成一个可用的小程序,消除业务之间强依赖关系,从而达成业务可独立开发、调试和部署的目的,方面统一业务协作流程、降低多端同构成本,关键策略:



  • 提供统一的集成研发方式和流程

  • 提供标准、可复用的集成产物规范

  • 为复杂小程序提供解耦工具和集成方法

  • 标准化小程序宿主、小程序插件、小程序分包、小程序模块之间的通信及能力注入方式



将小程序宿主和各个业务模块(分包、小程序、插件)通过形态转换、拉包、编译、构建、合并等一系列处理后,合并为一个完整小程序,且根据不同的场景可以支持:



  • 主子分包研发模式:基于不同业务对小程序中的分包进行拆分,以达到各个业务相互解耦,独立迭代的目的;

  • SDK 研发模式:将通用的页面或组件封装置某个 NPM 包中作为提供特定功能的 SDK 交由业务使用;

  • 小程序插件研发模式:集成研发也可以用支持标准的小程序插件研发。


这样我们就可以解决线下研发的问题。


线上研发协作


前面介绍的“线下集成研发”为业务单元提供了无阻塞的开发与调试能力,但对于饿了么业务整体演进来说,重视的是每个版本功能的可用与可控,这里面除了将集成的范围扩展到所有业务域的之外,还需要标准化的流程约束:



具体方式上,在机制层面提供了业务类型定义的能力,开发者可将工程做对应标记(主包、分包、插件、独立小程序),在流程层面定义了开发、集成与发布三个阶段,这和 APP 的研发流程有些类似:



  • 开发:各业务应用自行研发并结合平台部署测试,开发测试通过,等待窗口期开启进入集成测试;

  • 集成:管理员设置集成窗口期,在窗口期,允许业务多次集成研发,确认最终要进集成的稳定版本,期间主包管理员可多次部署体验版用于集成测试。窗口期结束后,不允许随意变更;

  • 发布:集成测试通过,各业务进行代码 CR 并进入发布阶段,等候主包提审通过发布上线,最终由管理员完成本次迭代发布,发布完成后,符合标准的主分包产物会被保存下来,后续的迭代中,如果某个分包未发生变更,则会直接复用产物,极大的降低了业务的发布风险,并提升了整体的构建效率。


再进一步,多端业务的最佳实践


通过线下集成+线上协作的双重能力加持,结合已有的多端编译能力,在成功的支撑了饿了么多端渠道业务的稳定高效研发的同时,我们也在思考,面向于未来的多端研发模式应该是个什么样子?


下图是我们期望同时也是饿了么目前多端应用架构正在演进中的样子:



从图上可以看出,我们将应用架构划分为三层(从下往上看):




  • 基础服务与研发规范:最底部的是基础服务与研发规范,由 多端研发框架、多端研发平台和多端研发规范,来提供统一的研发支撑,保障业务研发的基础能力、体验和效率,并负责将相关的业务统一打包、封装、集成,并部署和投放到不同的渠道;




  • 宿主应用框架:第二层是宿主应用框架(Framework),也可以认为是多端统一解决方案,承接了面向于业务研发并适配了多端差异的基础 API(如 登录、定位、请求、路由、实验、风控、埋点、容器等)、基础组件和最佳实践,通过分渠道的配置化运行、标准化的接入手段和中心化的能力管理,来保障整体框架的轻量化、标准化与持续迭代和升级;




  • 渠道应用主体:最上层是各个业务的应用实体,有一个壳工程 + N个业务工程组成,壳工程承接各个渠道定制化的一些能力,而并将下层应用框架的能力暴露给上层的各个业务,各个业务只需要关心两件事即可:



    • 多端形态:以什么样的形态接入到对应的渠道(即壳工程中)?

    • 业务功能:不同的渠道需要展示那些功能?




基于这种分层协作模式,可以最大程度上消除业务对多端差异的感知,可以将重心放在如何更好的为用户提供服务上。


以上内容为饿了么基于小程序 DSL 的跨端实践和解决方案,下面我们来看一下具体取得的成果。


跨端成果


饿了么各渠道业务效果展示



业务一码多端研发提效数据



  • 研发提效:采用一码多端和集成研发模式的业务平均提效 70%,同构的端越多提效越多

  • 多端占比:饿了么内部 85%+ 的多端业务在基于这套方案实现多渠道业务研发和投放

  • 业务覆盖:涵盖了饿了么全域的各个业务板块


能力沉淀 — 饿了么自研 MorJS 多端研发框架


MorJS 开源



我们将饿了么在跨端多渠道上的多年沉淀和解决方案,融合为 MorJS 多端研发框架,并通过 Github 开源的方式向社区开放。


GitHub 仓库地址:github.com/eleme/morjs


下图为 MorJS 的完整架构图:



MorJS 框架目前支持 :



  • 2 种 DSL:微信小程序 DSL 或 支付宝小程序 DSL

  • 4 种编译形态:小程序、小程序插件、小程序分包、小程序多端组件

  • 9 个目标平台:微信、支付宝、百度、字节、快手、钉钉、手淘、QQ、Web


并支撑了饿了么 C 端大多数业务在各个渠道上的研发和投放。


MorJS 为饿了么解决了大量业务在多端研发上的差异问题,让小程序开发的重心回到产品业务本身,减少使用者对多端差异兼容的投入。通过 MorJS 的开源,我们期望能把其中的实现细节、架构设计和技术思考呈现给大家,为更多有类似多端同构需求的企业和开发者服务。同时,我们也希望能够借此吸引到更多志趣相投的小伙伴参与共建,一起加速小程序一码多端能力的发展。欢迎广大小程序开发者们与我们交流。


MorJS 特性介绍



为了能够帮助社区的用户可以快速上手,我们在易用性、标准化和灵活性方面做了大量的准备:



  • ⭐️ 易用性

    • 💎 DSL 支持:可使用微信小程序 DSL 或 支付宝小程序 DSL 编写小程序,无额外使用成本;

    • 🌴 多端支持:支持将一套小程序转换为各类小程序平台及 Web 应用, 节省双倍人力;

    • 🚀 快速接入:仅需引入两个包,增加一个配置文件,即可简单快速接入到现有小程序项目;



  • 🌟 标准化

    • 📦 开箱即用:内置了脚手架、构建、分析、多端编译等完整研发能力,仅需一个依赖即可上手开发;

    • 🌈 表现一致:通过编译时+运行时抹平多端差异性,让不同平台的小程序获得一致的用户体验;

    • 🖇 形态转换:支持同一个项目的不同的形态,允许小程序、分包、插件不同形态之间的相互转换;



  • ✨ 灵活性

    • 🎉 方便扩展:MorJS 将完备的生命周期和内部功能插件化,使用插件(集)以满足功能和垂直域的分层需求;

    • 📚 类型支持:除小程序标准文件类型外,还支持 ts、less/scss、jsonc/json5 等多种文件类型;

    • 🧰 按需适配:可根据需求选择性接入适配能力,小项目仅需编译功能,中等项目可结合编译和页面注入能力,大型项目推荐使用复杂小程序集成能力;




同时也提供了丰富的文档:mor.eleme.io/ 共大家查阅。


部分使用案例及社区服务


以下为部分基于 MorJS 的案例:



用户原声



MorJS 上线的这几个月里面,我们收到了一些社区用户的正向反馈,也收到了一些诉求和问题,其中用户最担心的问题是:MorJS 是不是 KPI 项目,是否会长期维护?


这里借用一下我在 Github 项目的讨论区(Discussions)的回复:



如果大家对 MorJS 感兴趣,期望有更多了解或者在使用 MorJS 中有遇到任何问题,欢迎加入 MorJS 社区服务钉钉群(群号:29445021084)反馈、交流和学习,也可以🔗 点击链接加入钉钉群


展望未来



未来,在现有的 MorJS 的能力基础上,我们会进一步完善已有的多端能力,提升多端转换可用度,完善对各类社区组件库的兼容,并持续扩展编译目标平台的支持(如 鸿蒙、快应用等),在持续为饿了么自身业务和社区用户提供高质量服务的同时,期望有朝一日 MorJS 可以成为业界小程序多端

作者:lyfeyaj
来源:juejin.cn/post/7262558218169319484
研发的基础设施之一。

收起阅读 »

Android 记录一次因隐私合规引发的权限hook

背景 一天,本该快乐编码flutter的我,突然被集团法务钉了,说在合规扫描排查中发现某xxxApp存在在App静默状态下调用某敏感权限获取用户信息,不合规。通过调用栈排查发现是某第三方推送sdk在静默状态下心跳调用的,本着能动口不动脑的准则,我联系了上了第三...
继续阅读 »

背景


一天,本该快乐编码flutter的我,突然被集团法务钉了,说在合规扫描排查中发现某xxxApp存在在App静默状态下调用某敏感权限获取用户信息,不合规。通过调用栈排查发现是某第三方推送sdk在静默状态下心跳调用的,本着能动口不动脑的准则,我联系了上了第三方的技术,询问是否有静默方面的api,结果一番舌战后,对方告诉我他们隐私政策里有添加说明,之后也没有想要改动的打算,但是集团那边说在隐私里说明也不行。


综上,那只能自己动手。


解决的方法:是通过hook系统权限,添加某个业务逻辑点拦截并处理。


涉及到的知识点:java反射、动态代理、一点点耐心。


本文涉及到的敏感权限:


//wifi
android.net.wifi.WifiManager.getScanResults()
android.net.wifi.WifiManager.getConnectionInfo()
//蓝牙
android.bluetooth.le.BluetoothLeScanner.startScan()
//定位
android.location.LocationManager.getLastKnownLocation()

开始


wifi篇


1.首先寻找切入点,以方法WifiManager.getScanResults()为例查看源码


public List<ScanResult> getScanResults() {
  try {
return mService.getScanResults(mContext.getOpPackageName(),
  mContext.getAttributionTag());
  } catch (RemoteException e) {
  throw e.rethrowFromSystemServer();
  }
}

发现目标方法是由mService对象调用,它的定义


@UnsupportedAppUsage
IWifiManager mService;

查看IWifiManager


interface IWifiManager{
...

List<ScanResult> getScanResults(String callingPackage, String callingFeatureId);

WifiInfo getConnectionInfo(String callingPackage, String callingFeatureId);

...
}

可以看到IWifiManager是一个接口类,包含所需方法,可以当成一个切入点。


若以IWifiManager为切入点,进行hook


方法一

private static void hookWifi(Context context) {
try {
//反射获取相关类、字段对象
Class<?> iWifiManagerClass = HookUtil.getClass("android.net.wifi.IWifiManager");
Field serviceField = HookUtil.getField("android.net.wifi.WifiManager", "mService");

WifiManager wifiManager = (WifiManager) context.getSystemService(Context.WIFI_SERVICE);
//获取原始mService对象
Object realIwm = serviceField.get(wifiManager);

//创建IWifiManager代理
Object proxy = Proxy.newProxyInstance(iWifiManagerClass.getClassLoader(),
new Class[]{iWifiManagerClass}, new WifiManagerProxy(realIwm));

//设置新代理
serviceField.set(wifiManager, proxy);
} catch (Exception e) {
e.printStackTrace();
}
}

其中新代理类实现InvocationHandler


public class WifiManagerProxy implements InvocationHandler {

private final Object mOriginalTarget;

public WifiManagerProxy(Object mOriginalTarget) {
this.mOriginalTarget = mOriginalTarget;
}

@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
String methodName = method.getName();
if (("getScanResults".equals(methodName) || "getConnectionInfo".equals(methodName))){
//todo something
return null;
}
return method.invoke(mOriginalTarget,args);
}
}


2.考虑context问题:


获取原始wifiManager需要用到context上下文,不同context获取到的wifiManager不同。若统一使用application上下文可以基本覆盖所需,但是可能会出现遗漏(比如某处使用的是activity#context)。为了保证hook开关唯一,尝试再往上查找新的切入点。


查看获取wifiManager方法,由context调用.getSystemService()


WifiManager wifiManager = (WifiManager) context.getSystemService(Context.WIFI_SERVICE);

继续查看context的实现contextImpl


@Override
public Object getSystemService(String name) {
...
return SystemServiceRegistry.getSystemService(this, name);
}

查看SystemServiceRegistry.getSystemService静态方法


public static Object getSystemService(ContextImpl ctx, String name) {
if (name == null) {
return null;
}
final ServiceFetcher<?> fetcher = SYSTEM_SERVICE_FETCHERS.get(name);
if (fetcher == null) {
...
return null;
}

final Object ret = fetcher.getService(ctx);
if (sEnableServiceNotFoundWtf && ret == null) {
...
return null;
}
return ret;
}

服务由SYSTEM_SERVICE_FETCHERS获取,它是一个静态的HashMap,它的put方法在registerService


private static <T> void registerService(@NonNull String serviceName,
@NonNull Class<T> serviceClass, @NonNull ServiceFetcher<T> serviceFetcher) {
...
SYSTEM_SERVICE_FETCHERS.put(serviceName, serviceFetcher);
...
}

static{
...
//Android 11及以上
WifiFrameworkInitializer.registerServiceWrappers()
...
}

...

@SystemApi
public static <TServiceClass> void registerContextAwareService(
@NonNull String serviceName, @NonNull Class<TServiceClass> serviceWrapperClass,
@NonNull ContextAwareServiceProducerWithoutBinder<TServiceClass> serviceProducer) {
...
registerService(serviceName, serviceWrapperClass,
new CachedServiceFetcher<TServiceClass>() {
@Override
public TServiceClass createService(ContextImpl ctx)
throws ServiceNotFoundException {
return serviceProducer.createService(
ctx.getOuterContext(),
ServiceManager.getServiceOrThrow(serviceName));
}});

}


public static void registerServiceWrappers() {
...
SystemServiceRegistry.registerContextAwareService(
  Context.WIFI_SERVICE,
  WifiManager.class,
  (context, serviceBinder) -> {
  IWifiManager service = IWifiManager.Stub.asInterface(serviceBinder);
  return new WifiManager(context, service, getInstanceLooper());
  }
  );
}

SYSTEM_SERVICE_FETCHERS静态代码块中通过.registerServiceWrappers()注册WIFI_SERVICE服务。


registerService中new了一个CachedServiceFetcher,它返回一个serviceProducer.createService(...)


TServiceClass createService(@NonNull Context context, @NonNull IBinder serviceBinder);

其中第二个参数是一个IBinder对象,它的创建


ServiceManager.getServiceOrThrow(serviceName)

继续


public static IBinder getServiceOrThrow(String name) throws ServiceNotFoundException {
  final IBinder binder = getService(name);
  if (binder != null) {
  return binder;
  } else {
  throw new ServiceNotFoundException(name);
  }
  }
...
@UnsupportedAppUsage
public static IBinder getService(String name) {
  try {
  IBinder service = sCache.get(name);
  if (service != null) {
  return service;
  } else {
  return Binder.allowBlocking(rawGetService(name));
  }
  } catch (RemoteException e) {
  Log.e(TAG, "error in getService", e);
  }
  return null;
  }

最终在getServiceIBinder缓存在sCache中,它是一个静态变量


@UnsupportedAppUsage
private static Map<String, IBinder> sCache = new ArrayMap<String, IBinder>();

综上,如果可以创建新的IBinder,再替换掉sCache中的原始值就可以实现所需。


若以sCache为一个切入点


方法二

private static void hookWifi2() {
try {
Method getServiceMethod = HookUtil.getMethod("android.os.ServiceManager", "getService", String.class);
Object iBinderObject = getServiceMethod.invoke(null, Context.WIFI_SERVICE);

Field sCacheFiled = HookUtil.getField("android.os.ServiceManager", "sCache");
Object sCacheValue = sCacheFiled.get(null);

//生成代理IBinder,并替换原始值
if (iBinderObject != null && sCacheValue != null) {
IBinder iBinder = (IBinder) iBinderObject;
Map<String, IBinder> sCacheMap = (Map<String, IBinder>) sCacheValue;
Object proxy = Proxy.newProxyInstance(iBinder.getClass().getClassLoader(), new Class[]{IBinder.class}, new WifiBinderProxy(iBinder));
sCacheMap.put(Context.WIFI_SERVICE, (IBinder) proxy);
}
} catch (Exception e) {
e.printStackTrace();
}
}

public class WifiBinderProxy implements InvocationHandler {

private final IBinder originalTarget;

public WifiBinderProxy(IBinder originalTarget) {
this.originalTarget = originalTarget;
}

@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
if ("queryLocalInterface".equals(method.getName())) {
Object hook = hookQueryLocalInterface();
if (hook != null){
return hook;
}
}
return method.invoke(originalTarget, args);
}

private Object hookQueryLocalInterface(){
try {
//获取原始IWifiManager对象
Method asInterfaceMethod = HookUtil.getMethod("android.net.wifi.IWifiManager$Stub", "asInterface", IBinder.class);
Object iwifiManagerObject = asInterfaceMethod.invoke(null, originalTarget);

//生成新IWifiManager代理
Class<?> iwifiManagerClass = HookUtil.getClass("android.net.wifi.IWifiManager");
return Proxy.newProxyInstance(originalTarget.getClass().getClassLoader(),
new Class[]{IBinder.class, IInterface.class, iwifiManagerClass},
new WifiManagerProxy(iLocationManagerObject));
}catch (Exception e){
e.printStackTrace();
}
return null;
}
}

至此完成无需上下文的全局拦截。


蓝牙篇


BluetoothLeScanner.startScan()为例查找切入点,以下省略非必需源码粘贴


private int startScan(List<ScanFilter> filters, ScanSettings settings,
final WorkSource workSource, final ScanCallback callback,
final PendingIntent callbackIntent,
List<List<ResultStorageDescriptor>> resultStorages) {
...
IBluetoothGatt gatt;
try {
gatt = mBluetoothManager.getBluetoothGatt();
} catch (RemoteException e) {
gatt = null;
}
...

private final IBluetoothManager mBluetoothManager;

...
public BluetoothLeScanner(BluetoothAdapter bluetoothAdapter) {
mBluetoothAdapter = Objects.requireNonNull(bluetoothAdapter);
mBluetoothManager = mBluetoothAdapter.getBluetoothManager();
...
}

向上查找IBluetoothManager,它在BluetoothAdapter中;向下代理getBluetoothGatt方法处理IBluetoothGatt


查看BluetoothAdapter的创建


public static BluetoothAdapter createAdapter(AttributionSource attributionSource) {
IBinder binder = ServiceManager.getService(BLUETOOTH_MANAGER_SERVICE);
if (binder != null) {
return new BluetoothAdapter(IBluetoothManager.Stub.asInterface(binder),
attributionSource);
} else {
Log.e(TAG, "Bluetooth binder is null");
return null;
}
}

ok,他也包含由ServiceManager中获取得到IBinder,然后进行后续操作。


若以IBluetoothManager为切入点


private static void hookBluetooth() {
try {
//反射ServiceManager中的getService(BLUETOOTH_MANAGER_SERVICE = 'bluetooth_manager')方法,获取原始IBinder
Method getServiceMethod = HookUtil.getMethod("android.os.ServiceManager", "getService", String.class);
Object iBinderObject = getServiceMethod.invoke(null, "bluetooth_manager");

//获取ServiceManager对象sCache
Field sCacheFiled = HookUtil.getField("android.os.ServiceManager", "sCache");
Object sCacheValue = sCacheFiled.get(null);

//动态代理生成代理iBinder插入sCache
if (iBinderObject != null && sCacheValue != null) {
IBinder iBinder = (IBinder) iBinderObject;
Map<String, IBinder> sCacheMap = (Map<String, IBinder>) sCacheValue;
Object proxy = Proxy.newProxyInstance(iBinder.getClass().getClassLoader(), new Class[]{IBinder.class}, new BluetoothBinderProxy(iBinder));
sCacheMap.put("bluetooth_manager", (IBinder) proxy);
}
} catch (Exception e) {
e.printStackTrace();
}
}

代理IBluetoothManager


public class BluetoothBinderProxy implements InvocationHandler {

private final IBinder mOriginalTarget;

public BluetoothBinderProxy(IBinder originalTarget) {
this.mOriginalTarget = originalTarget;
}

@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
if ("queryLocalInterface".equals(method.getName())) {
//拦截
Object hook = hookQueryLocalInterface();
if (hook != null){
return hook;
}
}
//不拦截
return method.invoke(mOriginalTarget, args);
}

private Object hookQueryLocalInterface(){
try {
//获取原始IBluetoothManager对象
Method asInterfaceMethod = HookUtil.getMethod("android.bluetooth.IBluetoothManager$Stub", "asInterface", IBinder.class);
Object iBluetoothManagerObject = asInterfaceMethod.invoke(null, mOriginalTarget);

//生成代理IBluetoothManager
Class<?> iBluetoothManagerClass = HookUtil.getClass("android.bluetooth.IBluetoothManager");
return Proxy.newProxyInstance(mOriginalTarget.getClass().getClassLoader(),
new Class[]{IBinder.class, IInterface.class, iBluetoothManagerClass},
new BluetoothManagerProxy(iBluetoothManagerObject));
}catch (Exception e){
e.printStackTrace();
}
return null;
}
}

代理IBluetoothGatt


public class BluetoothManagerProxy implements InvocationHandler {

private final Object mOriginalTarget;

public BluetoothManagerProxy(Object mOriginalTarget) {
this.mOriginalTarget = mOriginalTarget;
}

@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
if ("getBluetoothGatt".equals(method.getName())) {
Object object = method.invoke(mOriginalTarget,args);
Object hook = hookGetBluetoothGatt(object);
if (hook != null){
return hook;
}
}
return method.invoke(mOriginalTarget, args);
}

private Object hookGetBluetoothGatt(Object object) {
try {
Class<?> iBluetoothGattClass = HookUtil.getClass("android.bluetooth.IBluetoothGatt");
return Proxy.newProxyInstance(mOriginalTarget.getClass().getClassLoader(),
new Class[]{IBinder.class, IInterface.class, iBluetoothGattClass},
new BluetoothGattProxy(object));
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
}

处理业务逻辑


public class BluetoothGattProxy implements InvocationHandler {

private final Object mOriginalTarget;

public BluetoothGattProxy(Object mOriginalTarget) {
this.mOriginalTarget = mOriginalTarget;
}


@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
if ("startScan".equals(method.getName())){
//todo something
return null;
}
return method.invoke(mOriginalTarget,args);
}
}

定位篇


LocationManager.getLastKnownLocation()为例查找切入点,此处不粘贴源码,直接展示


private static void hookLocation() {
try {
Method getServiceMethod = HookUtil.getMethod("android.os.ServiceManager", "getService", String.class);
Object iBinderObject = getServiceMethod.invoke(null, Context.LOCATION_SERVICE);

Field sCacheFiled = HookUtil.getField("android.os.ServiceManager", "sCache");
Object sCacheValue = sCacheFiled.get(null);

//动态代理生成代理iBinder插入sCache
if (iBinderObject != null && sCacheValue != null) {
IBinder iBinder = (IBinder) iBinderObject;
Map<String, IBinder> sCacheMap = (Map<String, IBinder>) sCacheValue;
Object proxy = Proxy.newProxyInstance(iBinder.getClass().getClassLoader(), new Class[]{IBinder.class}, new LocationBinderProxy(iBinder));
sCacheMap.put(Context.LOCATION_SERVICE, (IBinder) proxy);
}
} catch (Exception e) {
e.printStackTrace();
}
}

public class LocationBinderProxy implements InvocationHandler {

private final IBinder originalTarget;

public LocationBinderProxy(IBinder originalTarget) {
this.originalTarget = originalTarget;
}

@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
if ("queryLocalInterface".equals(method.getName())) {
Object hook = hookQueryLocalInterface();
if (hook != null){
return hook;
}
}
return method.invoke(originalTarget, args);
}

private Object hookQueryLocalInterface(){
try {
//获取原始ILocationManager对象
Method asInterfaceMethod = HookUtil.getMethod("android.location.ILocationManager$Stub", "asInterface", IBinder.class);
Object iLocationManagerObject = asInterfaceMethod.invoke(null, originalTarget);

//生成代理ILocationManager
Class<?> iLocationManagerClass = HookUtil.getClass("android.location.ILocationManager");
return Proxy.newProxyInstance(originalTarget.getClass().getClassLoader(),
new Class[]{IBinder.class, IInterface.class, iLocationManagerClass},
new LocationManagerProxy(iLocationManagerObject));
}catch (Exception e){
e.printStackTrace();
}
return null;
}
}

总结


作为Android进程间通信机制Binder的守护进程,本次所hook的权限都可追溯到ServiceManagerServiceManager中的sCache缓存了权限相关的IBinder,以此为切入点可以进行统一处理,不需要引入context。


在此记录一下因隐私合规引发的hook处理流程,同时也想吐槽一下国内应用市场App上架审核是真滴难,每个市场的合规扫描标准都不一样。


附录


源码查看网站 aospxref.com/


路径:/frameworks/base/core/java/android/os/ServiceManager.j

作者:秋至
来源:juejin.cn/post/7262243685898960955
ava

收起阅读 »

如何选择 Android 唯一标识符

前言 大家好,我是未央歌,一个默默无闻的移动开发搬砖者~ 本文针对 Android 各种标识符做了统一收集,方便大家比对,以供选择适合大家的唯一标识符。 标识符 IMEI 从 Android 6.0 开始获取 IMEI 需要权限,并且从 Android 10...
继续阅读 »

前言


大家好,我是未央歌,一个默默无闻的移动开发搬砖者~


本文针对 Android 各种标识符做了统一收集,方便大家比对,以供选择适合大家的唯一标识符。


标识符


IMEI



  • 从 Android 6.0 开始获取 IMEI 需要权限,并且从 Android 10+ 开始官方取消了获取 IMEI 的 API,无法获取到 IMEI 了


fun getIMEI(context: Context): String {
val telephonyManager = context
.getSystemService(TELEPHONY_SERVICE) as TelephonyManager
return telephonyManager.deviceId
}

Android ID(SSAID)



  • 无需任何权限

  • 卸载安装不会改变,除非刷机或重置系统

  • Android 8.0 之后签名不同的 APP 获取的 Android ID 是不一样的

  • 部分设备由于制造商错误实现,导致多台设备会返回相同的 Android ID

  • 可能为空


fun getAndroidID(context: Context): String {
return Settings.System.getString(context.contentResolver,Settings.Secure.ANDROID_ID)
}

MAC 地址



  • 需要申请权限,Android 12 之后 BluetoothAdapter.getDefaultAdapter().getAddress()需要动态申请 android.permission.BLUETOOTH_CONNECT 权限

  • MAC 地址具有全局唯一性,无法由用户重置,在恢复出厂设置后也不会变化

  • 搭载 Android 10+ 的设备会报告不是设备所有者应用的所有应用的随机化 MAC 地址

  • 在 Android 6.0 到 Android 9 中,本地设备 MAC 地址(如 WLAN 和蓝牙)无法通过第三方 API 使用 会返回 02:00:00:00:00:00,且需要 ACCESS_FINE_LOCATION 或 ACCESS_COARSE_LOCATION 权限


Widevine ID



  • DRM 数字版权管理 ID ,访问此 ID 无需任何权限

  • 对于搭载 Android 8.0 的设备,Widevine 客户端 ID 将为每个应用软件包名称和网络源(对于网络浏览器)返回一个不同的值

  • 可能为空


fun getWidevineID(): String {
try {
val WIDEVINE_UUID = UUID(-0x121074568629b532L, -0x5c37d8232ae2de13L)
val mediaDrm = MediaDrm(WIDEVINE_UUID)
val widevineId = mediaDrm.getPropertyByteArray(MediaDrm.PROPERTY_DEVICE_UNIQUE_ID);
val sb = StringBuilder();
for (byte in widevineId) {
sb.append(String.format("x", byte))
}
return sb.toString();
} catch (e: Exception) {
} catch (e: Error) {
}
return ""
}

AAID



  • 无需任何权限

  • Google 推出的广告 ID ,可由用户重置的标识符,适用于广告用例

  • 系统需要自带 Google Play Services 才支持,且用户可以在系统设置中重置



重置后,在未获得用户明确许可的情况下,新的广告标识符不得与先前的广告标识符或由先前的广告标识符所衍生的数据相关联。




还要注意,Google Play 开发者内容政策要求广告 ID“不得与个人身份信息或任何永久性设备标识符(例如:SSAID、MAC 地址、IMEI 等)相关联。”




在支持多个用户(包括访客用户在内)的 Android 设备上,您的应用可能会在同一设备上获得不同的广告 ID。这些不同的 ID 对应于登录该设备的不同用户。



OAID



  • 无需任何权限

  • 国内移动安全联盟出台的“拯救”国内移动广告的广告跟踪标识符

  • 基本上是国内知名厂商 Android 10+ 才支持,且用户可以在系统设置中重置


UUID



  • 生成之后本地持久化保存

  • 卸载后重新安装、清除应用缓存 会改变


如何选择


同个开发商需要追踪对比旗下应用各用户的行为



  • 可以采用 Android ID(SSAID),并且不同应用需使用同一签名

  • 如果获得的 Android ID(SSAID)为空,可以用 UUID 代替【 OAID / AAID 代替也可,但需要引入第三方库】

  • 在 Android 8.0+ 中, Android ID(SSAID)提供了一个在由同一开发者签名密钥签名的应用之间通用的标识符


希望限制应用内的免费内容(如文章)



  • 可以采用 UUID ,作用域是应用范围,用户要想规避内容限制就必须重新安装应用


用户群体主要是大陆



  • 可以采用 OAID ,低版本配合采用 Android ID(SSAID)/ UUID

  • 可以采用 Android ID(SSAID),空的时候配合采用 UUID 等


用户群体在海外



  • 可以采用 AAID

  • 可以采用 Android ID(SSAID),空的时候配合采用 UUID 等
作者:未央歌
来源:juejin.cn/post/7262558218169008188

收起阅读 »

微信微调助手WeChatTweak for mac(多开和防撤回工具)

WeChatTweak for mac是一款仅限mac平台的微信客户端插件,这款插件拥有防撤回和微信多开的功能。 集成版无需执行终端命令,直接可以右键单击dock栏图标以登录另一个微信帐户。 1、阻止消息撤回 消息列表通知 系统通知 2、客户端无限多开...
继续阅读 »

WeChatTweak for mac是一款仅限mac平台的微信客户端插件,这款插件拥有防撤回和微信多开的功能。


集成版无需执行终端命令,直接可以右键单击dock栏图标以登录另一个微信帐户。


1、阻止消息撤回


消息列表通知


系统通知 2、客户端无限多开


右键单击停靠栏图标以登录另一个微信帐户


或者在终端命令行执行:open -n /Applications/WeChat.app


3、链接类型消息增强 4、支持快捷直接复制链接 5、支持由系统替代浏览器直接打开


WeChatTweak Mac激活下载





作者:多来啊梦要飞
来源:mdnice.com/writing/20c7a437b1fa4ee5bf75657937be2893
收起阅读 »

getClass方法详解

getClass方法详解 在Java中,getClass()是Object类的一个方法,用于返回对象的运行时类(Runtime Class)。它的函数签名如下: public final Class<?> getC...
继续阅读 »

getClass方法详解


在Java中,getClass()是Object类的一个方法,用于返回对象的运行时类(Runtime Class)。它的函数签名如下:


public final Class<?> getClass()

getClass()方法返回一个Class对象,该对象表示调用该方法的对象的运行时类型。换句话说,它返回一个描述对象所属类的元数据的实例。


以下是关于getClass()方法的详解:





  1. 返回值类型:getClass()方法返回一个Class<?>类型的对象,这里的问号表示通配符,表示可以是任何类型的Class对象。





  2. 作用:getClass()方法用于获取对象的类信息,包括类的名称、父类、接口信息等。





  3. 运行时类型:getClass()方法返回的是调用对象的运行时类型,而不是对象的声明类型。也就是说,如果对象的类型发生了变化(向上转型或者子类重写父类方法),getClass()返回的是实际运行时类型。





  4. 示例代码:


    class Animal {
        // ...
    }

    class Dog extends Animal {
        // ...
    }

    public class Main {
        public static void main(String[] args) {
            Animal animal = new Dog();
            Class<?> clazz = animal.getClass();
            System.out.println(clazz.getName()); // 输出: Dog
        }
    }

    在上面的示例中,getClass()方法被调用时,对象animal的运行时类型是Dog,因此返回的Class对象代表Dog类。




需要注意的是,getClass()方法是继承自Object类的,因此可以在任何Java对象上调用。但是,在使用getClass()方法之前,必须确保对象不为null,否则会抛出NullPointerException异常。


getClass()方法与反射密切相关,是反射的基础之一。


在Java中,反射是指在运行时动态地获取类的信息并操作类或对象的能力。它允许程序在运行时检查和修改类、方法、字段等的属性和行为,而不需要在编译时确定这些信息。


通过调用对象的getClass()方法,我们可以获得对象的运行时类型的Class对象。然后,使用Class对象可以进行以下反射操作:





  1. 实例化对象:通过Class.newInstance()方法可以实例化一个类的对象。





  2. 获取类的构造函数:通过Class.getConstructors()方法可以获取类的所有公共构造函数,通过Class.getDeclaredConstructors()方法可以获取所有构造函数(包括私有构造函数),还可以通过参数类型匹配获取指定的构造函数。





  3. 获取类的方法:通过Class.getMethods()方法可以获取类的所有公共方法,通过Class.getDeclaredMethods()方法可以获取所有方法(包括私有方法),还可以通过方法名和参数类型匹配获取指定的方法。





  4. 获取类的字段:通过Class.getFields()方法可以获取类的所有公共字段,通过Class.getDeclaredFields()方法可以获取所有字段(包括私有字段),还可以通过字段名匹配获取指定的字段。





  5. 调用方法和访问字段:通过Method.invoke()方法可以调用方法,通过Field.get()Field.set()方法可以访问字段。




总结来说,getClass()方法提供了从对象到其运行时类型的连接,而反射则利用这个连接来获取和操作类的信息。通过反射,我们可以在运行时动态地使用类的成员,实现灵活的代码编写和执行。


作者:维维
来源:mdnice.com/writing/c1e0400e54e94e4881aacdfc5bb10508
收起阅读 »

Python中列表的惭怍方法

Python中的列表是一种非常常用的数据结构,它可以存储多个元素,并且可以进行各种操作。下面是关于列表操作的一些基本方法:列表的生成:使用方括号 [] 来创建一个空列表:my_list = []使用方括号 [] 并在其中添加元素来创建一个非空列表:my_lis...
继续阅读 »

Python中的列表是一种非常常用的数据结构,它可以存储多个元素,并且可以进行各种操作。下面是关于列表操作的一些基本方法:

  1. 列表的生成:

    • 使用方括号 [] 来创建一个空列表:my_list = []

    • 使用方括号 [] 并在其中添加元素来创建一个非空列表:my_list = [1, 2, 3]

    • 使用列表生成式来生成列表:my_list = [x for x in range(5)]

  2. 列表的增加和删除:

    • 使用 append() 方法在列表末尾添加一个元素:my_list.append(4)

    • 使用 insert() 方法在指定位置插入一个元素:my_list.insert(0, 0)

    • 使用 extend() 方法将另一个列表的元素添加到当前列表末尾:my_list.extend([5, 6, 7])

    • 使用 remove() 方法删除列表中的指定元素:my_list.remove(3)

    • 使用 pop() 方法删除并返回列表中指定位置的元素:my_list.pop(0)

  3. 列表的遍历和循环:

    • 使用 for 循环遍历列表中的每个元素:

      for item in my_list:
          print(item)
    • 使用 enumerate() 函数同时获取元素的索引和值:

      for index, item in enumerate(my_list):
        print(index, item)
    • 使用 while 循环根据条件遍历列表:

      i = 0
      while i < len(my_list):
        print(my_list[i])
        i += 1
    • 使用 range() 函数和 len() 函数结合来遍历列表的索引:

        for i in range(len(my_list)):
            print(my_list[i])

希望这些例子能帮助你更好地理解列表的操作方法。如果有任何问题,请随时提问。

作者:orangewu
来源:mdnice.com/writing/3a3a6e2f2a5c4763a2c8901e205f446c
收起阅读 »

前端异步请求轮询方案

业务背景 在前后端数据交互场景下,使用最多的一种方式是客户端发起 HTTP 请求,等待服务端处理完成后响应给客户端结果。 但在一些场景下,服务端对数据的处理需要较长的时间,比如提交一批数据,对这批数据进行数据分析,将最终分析结果返回给前端。 如果采用一次 HT...
继续阅读 »

业务背景


在前后端数据交互场景下,使用最多的一种方式是客户端发起 HTTP 请求,等待服务端处理完成后响应给客户端结果。


但在一些场景下,服务端对数据的处理需要较长的时间,比如提交一批数据,对这批数据进行数据分析,将最终分析结果返回给前端。


如果采用一次 HTTP 请求,用户会一直处于等待状态,再加上界面不会有进度交互,导致用户不知何时会处理完成;此外,一旦刷新页面或者其他意外情况,用户就无从感知处理结果。


面对这类场景,可以借助 「HTTP 轮询方式」 对交互体验进行优化,具体过程如下:


首先发起一次 HTTP 请求用于提交数据,之后启动轮询在一定间隔时间内查询分析结果,在这期间后台可将分析进度同步到前端来告知用户处理进度;此外即使刷新再次进入页面还可以通过「轮询」实时查询进度结果。


下面,我们来看看代码层面看如何实现这类场景。


JS 实现轮询的方式


在实现代码之前,我们需要先明确 JS 实现轮询的方式有哪些,哪种方式最适合使用。


1. setInterval


作为前端开发人员,提起轮询第一时间能想到的是计时器 setInterval,它会按照指定的时间间隔不间断的轮询执行处理函数。


let index = 1;

setInterval(() => {
console.log('轮询执行: ', index ++);
}, 1000);

回过头来看我们的场景:要轮询的是 异步请求(HTTP),请求响应结果会受限制网络或者服务器处理速度,显然 setInterval 这种固定间隔轮询并不适合这个场景。


2. Promise + setTimeout sleep


setInterval 的不足之处在于 轮询间隔时间 在异步请求场景下无法保证两个请求之间的间隔固定。要解决这个问题,可以使用 sleep 睡眠函数来控制间隔时间。


JS 中没有提供 sleep 相关方法,但可以结合 Promise + setTimeout 来实现。


const sleep = () => {
return new Promise(resolve => {
setTimeout(resolve, 1000);
});
}

sleep 仅控制了轮询间隔,而轮询的执行机制需要我们手动根据异步请求结果来实现,比如下面通过控制 while 循环的条件:


const start = async () => {
let i = 0;
while (i < 5) {
await sleep();
console.log(`第 ${++ i} 次执行`);
}
}

start();


使用轮询的时候可以借助 async/await 同步的方式编写,提高代码阅读质量。



实现异步请求轮询


下面我们通过一个完整示例理解 轮询异步请求 的实现及使用注意事项。


首先我们定义两个变量:index 用于控制何时停止轮询,timer 则用于实现中断轮询。


let index = 1;
let timer = 0;

这里,我们定义 syncPromise 来模拟异步请求,可以看作是一次 HTTP 请求,当进行 5 次异步请求后,会返回 false 表示拿到数据分析结果,停止数据查询轮询:


const syncPromise = () => {
return new Promise(resolve => {
setTimeout(() => {
console.log(`第 ${index} 次请求`);
resolve(index < 5 ? true : false);
index ++;
}, 50);
})
}

现在,我们实现 pollingPromise 作为 sleep 睡眠函数使用,去控制轮询的间隔时间,并在指定时间执行异步请求:


const pollingPromise = () => {
return new Promise(resolve => {
timer = setTimeout(async () => {
const result = await syncPromise();
resolve(result);
}, 1000);
});
}

最后,startPolling 作为开始轮询的入口,包含以下逻辑:



  • 1)在轮询前会清除正在进行的轮询任务,避免出现多次轮询;

  • 2)如果需要,在开始轮询时会立刻调用异步请求查询一次数据结果;

  • 3)最后,通过 while 循环根据异步请求的结果,决定是否继续轮询;


const startPolling = async () => {
// 清除进行中的轮询,重新开启计时轮询
clearTimeout(timer); // !!! 注意:清除计时器后,会导致整个 async/await 链路中断,若计时器的位置下方还存在代码,将不会执行。
index = 1;
// 立刻执行一次异步请求
let needPolling = await syncPromise();
// 根据异步请求结果,判断是否需要开启计时轮询
while (needPolling) {
needPolling = await pollingPromise();
}
console.log('轮询请求处理完成!'); // 若异步请求被 clearTimeout(timer),这里不会被执行打印输出。
}

const start = async () => {
await startPolling();
console.log('若异步请求被 clearTimeout(timer),这里将不会被执行');
}
start();

不过,需要注意的是:一旦清除计时器后,会导致整个 async/await 链路中断,若计时器的位置下方还存在代码,将不会执行。


假设当前执行了两次轮询被 clearTimeout(timer) 后,从 startPollingstart 整个 async/await 链路都会中断,且后面未执行的代码也不会被执行。


基于以上规则,异步轮询的处理逻辑尽量放在 syncPromise 异步请求核心函数中完成,避免在开启轮询

作者:明里人
来源:juejin.cn/post/7262261749105639481
的辅助函数中去实现。

收起阅读 »

一代枭雄曹操也需要借力,何况我们

前言 1、人情世故 如果做得好就会说是情商高,做不好会说是世故,这是冯仑老师一段话,然后怎么做不世故呢,也很难评判。 借着这个聊聊人情世故,在我看来它也是做事规则的一部分,我们发展很长一段历史,从不同的立场、不同的利益分出了派别,又从血缘关系分出了宗族,这些...
继续阅读 »

3e6160aa8d1c936f2ffa5ccb994edcab.jpg


前言




1、人情世故


如果做得好就会说是情商高,做不好会说是世故,这是冯仑老师一段话,然后怎么做不世故呢,也很难评判。


借着这个聊聊人情世故,在我看来它也是做事规则的一部分,我们发展很长一段历史,从不同的立场、不同的利益分出了派别,又从血缘关系分出了宗族,这些都是为了利益最大化的一个产物。


反观博主本人,典型理工男,执着技术研究,所以这块一直是弱项,不太会讲话,但是我人缘一直比较好的。当然有利也有弊,弊端的话比较明显的,当一个人说话很厉害的时候,会给人自信,给人觉得靠谱,当一个人说话不咋样的时候,其实也有好处,就是藏锋,你不说出来个人想法大家是不知道你心里的小九九的,所以保全了你自身。(当一个人份量足的时候,说话会引发很大的影响,所以你可以发现如果一个人在公开场合大发演讲,要么是初出茅庐要么就是有靠山)


2、人生的发展需要平台


王立群老师:人生发展往往需要平台,秦国李斯这么一个故事,他发现仓鼠跟厕鼠待遇很不一样,同样是一个物种,但是一个光明正大的吃着粮食,一个过街老鼠人人喊打,所以他悟到了一个道理,人生好的发展需要借助平台的。


我们今天讲的人物:曹操,我们还是从几个学习角度去看,一个是做事的方法,另一个我们从他的事迹里面看出成事的借力的这么一回事。


曹操




出身


他祖父是一个大太监,伺候皇后还有皇上,古代有三股力量,两股都是因为比较亲近产生的,一个是外戚,另一个太监,还有一股力量是文官,这个是人数最多的。那么他祖父权利很大的,然后收了一个义子也就是曹操的父亲,然后他本身属于夏侯家族,所以他带的资源是曹家还有夏侯家非常有实力。


他并没有说直接躺平,而是想着有所作为,接下来我们再看看他的做事方面


做事手段


1、许劭风评


古代有个一个规则,靠着这些有能力、有品德的人来进行推荐人才,曹操想出来做事,他找到许劭,一开始是不肯的,因为前面讲过三股力量,文官是很鄙视太监的,后面曹操使了点手段最终让许劭给他做了风评,然后他听完大笑而去。


idea:从这件事看做什么事都是有个窍门,这个方式是别人建议曹操这么干,所以做事要恰到好处。另外里面提到曹操使了点手段,哈哈透出了一个狠,有点东西。


2、傍大腿


曹操曾经在袁绍下面干活,然后好几次都把自己的精锐干没了,袁绍作为盟主,慷慨的给予兵马才得以恢复元气。


idea:我们看曹操的出身,这么牛逼的背景,他也需要大腿的支持,更何况普普通通的我们。


3、挟天子以令诸侯


这个是非常著名的历史典故,也是因为这个跟袁绍闹掰了,当汉献帝去了洛阳的时候,他马上去迎接,然后用这个发号施令讨伐别人。


idea:曹操的眼光十分毒辣,他看出潜在的价值,不愧是曹老板。


4、善用人才


像官渡之战,像迎接汉献帝,都是底下这批谋士给的主意,曹操手下文官是人才济济的,另外这个老板是善于听从这些好的计谋,这是非常重要的。


官渡之战,袁绍没有听从谋士的重兵把守粮草,导致给了曹操抓住了机会,乌巢一把火烧光了粮草。


个人看法


a、平台是重要的,借力也是需要的


从曹操的发迹来看,他站在一个大平台上面,不像刘备四处投奔。人并不是说能力很强就能表现出来,需要有平台,有这么伯乐去发现你,然后有这么一股力量在你困难的时候拉你一把,这是重要的。


b、曹操做事狠


这里的狠,不是残暴,而是毒辣,眼光毒辣、做事方式到位,我们从善用人才,许劭风评,挟天子以令诸侯,这些做的都很到位。举个例子,比如说我们要煮开一壶水,需要火柴、木头、可能需要鼓风工具,这都是关键那些点。


这个我们前面也提到了,做事一定要有所研究,事情的关键点是什么,当然有这么一群得力助手也很重要,发现关键突破点。所以古代对英雄标准是:腹有良策,有大气概。


c、驾驭人


司马家起来是在曹操去世后几代的事情,可以说在曹操在的时候,这些有心机的人没有动作的,侧面看出曹操的厉害之处,懂人心。在资治通鉴里面也有一个例子,就是桓温,他也是古代一个权臣,后面几代就不行了压不住这批人。


学历史,学读懂人心




历史里面基本都是那个朝代的精英,他们的事迹,做事方法,当然我们看到很多东西,包括抱负、无奈、遗憾;我们学的不仅仅是做事方法,避开权谋的陷阱,还有就是学习读懂人心、人性。当我们谈到这个,大家第一印象就是坏的人性,其实它是一种自然的表现,就像饿了就要吃饭。


《百家讲坛》里面讲了这么一个故事,曹操的下邳之战生擒了吕布,原本曹操很爱惜人才的,后面刘备的一句话:吕布对以往老板不好,而曹操生性多疑,最终嘎了吕布。王立群老师:人们往往看重结果,以结果说话,而不是问你这么做的原因。


是啊,我们在故事背后,看到整件事情人心的博弈,刘备被人称为仁义之君,但是他在那会落进下石了,因为他之前跟吕布有些矛盾的,吕布把他从原来的根据地赶走了,当然他说的也是事实。所以我们除了学习历史,还需要去洞察人心,往往这

作者:大鸡腿同学
来源:juejin.cn/post/7261231205353242682
些能决定事情的走向。

收起阅读 »

三言两语说透koa的洋葱模型

web
Koa是一个非常轻量化的Node.js web应用框架,其洋葱圈模型是它独特的设计理念和核心实现机制之一。本文将详细介绍Koa的洋葱圈模型背后的设计思想,以及它是如何实现的。 洋葱圈模型设计思想 Koa的洋葱圈模型主要是受函数式编程中的compose思想启发而...
继续阅读 »

Koa是一个非常轻量化的Node.js web应用框架,其洋葱圈模型是它独特的设计理念和核心实现机制之一。本文将详细介绍Koa的洋葱圈模型背后的设计思想,以及它是如何实现的。


洋葱圈模型设计思想


Koa的洋葱圈模型主要是受函数式编程中的compose思想启发而来的。Compose函数可以将需要顺序执行的多个函数复合起来,后一个函数将前一个函数的执行结果作为参数。这种函数嵌套是一种函数式编程模式。


Koa借鉴了这个思想,其中的中间件(middleware)就相当于compose中的函数。请求到来时会经过一个中间件栈,每个中间件会顺序执行,并把执行结果传给下一个中间件。这就像洋葱一样,一层层剥开。


这样的洋葱圈模型设计有以下几点好处:



  • 更好地封装和复用代码逻辑,每个中间件只需要关注自己的功能;

  • 更清晰的程序逻辑,通过中间件的嵌套可以表明代码的执行顺序;

  • 更好的错误处理,每个中间件可以选择捕获错误或将错误传递给外层;

  • 更高的扩展性,可以很容易地在中间件栈中添加或删除中间件。


洋葱圈模型实现机制


Koa的洋葱圈模型主要是通过Generator函数和Koa Context对象来实现的。


Generator函数


Generator是ES6中新增的一种异步编程解决方案。简单来说,Generator函数可以像正常函数那样被调用,但其执行体可以暂停在某个位置,待到外部重新唤起它的时候再继续往后执行。这使其非常适合表示异步操作。


// koa中使用generator函数表示中间件执行链
function *logger(next){
  console.log('outer');
  yield next;
  console.log('inner');
}

function *main(){
  yield logger();
}

var gen = main();
gen.next(); // outer
gen.next(); // inner

Koa使用Generator函数来表示洋葱圈模型中的中间件执行链。外层不断调用next重新执行Generator函数体,Generator函数再按顺序yield内层中间件异步操作。这样就可以很优雅地表示中间件的异步串行执行过程。


Koa Context对象


Koa Context封装了请求上下文,作为所有中间件共享的对象,它保证了中间件之间可以通过Context对象传递信息。具体而言,Context对象在所有中间件间共享以下功能:



  • ctx.request:请求对象

  • ctx.response:响应对象

  • ctx.state:推荐的命名空间,用于中间件间共享数据

  • ctx.throw:手动触发错误

  • ctx.app:应用实例引用


// Context对象示例
ctx = {
  request: {...}, 
  response: {...},
  state: {},
  throwfunction(){...},
  app: {...}
}

// 中间件通过ctx对象传递信息
async function middleware1(ctx){
  ctx.response.body = 'hello';
}

async function middleware2(ctx){
  let body = ctx.response.body
  //...
}

每次请求上下文创建后,这个Context实例会在所有中间件间传递,中间件可以通过它写入响应,传递数据等。


中间件执行流程


当请求到达Koa应用时,会创建一个Context实例,然后按顺序执行中间件栈:



  1. 最内层中间件首先执行,可以操作Context进行一些初始化工作;

  2. 用yield将执行权转交给下一个中间件;

  3. 下一个中间件执行,并再次yield交还执行权;

  4. 当最后一个中间件执行完毕后,倒序执行中间件的剩余逻辑;

  5. 每个中间件都可以读取之前中间件写入Context的状态;

  6. 最外层获得Context并响应请求。


// 示意中间件执行流程
app.use(async function(ctx, next){
  // 最内层执行
  ctx.message = 'hello';

  await next();
  
  // 最内层剩余逻辑  
});

app.use(async function(ctx, next){
  // 第二层执行
  
  await next();

  // 第二层剩余逻辑
  console.log(ctx.message); 
});

// 最外层获得ctx并响应

这就是洋葱圈模型核心流程,通过Generator函数和Context对象实现了优雅的异步中间件机制。


完整解析


Koa中间件是一个Generator函数,可以通过yield关键字来调用下一个中间件。例如:


const Koa = require('koa');
const app = new Koa();

app.use(async (ctx, next) => {
  console.log('中间件1开始');
  
  await next();
  
  console.log('中间件1结束');
});

app.use(async (ctx, next) => {
  console.log('中间件2');

  await next();

  console.log('中间件2结束');  
});

app.use(async ctx => {
  console.log('中间件3')
});

app.listen(3000);

在代码中,可以看到Koa注册中间件是通过app.use实现的。所有中间件的回调函数中,await next()前面的逻辑是按照中间件注册的顺序从上往下执行的,而await next()后面的逻辑是按照中间件注册的顺序从下往上执行的。


执行流程如下:



  1. 收到请求,进入第一个中间件

  2. 第一个中间件打印日志,调用next进入第二个中间件

  3. 第二个中间件打印日志,调用next进入第三个中间件

  4. 第三个中间件打印日志,并结束请求

  5. control返回第二个中间件,打印结束日志

  6. control返回第一个中间件,打印结束日志

  7. 请求结束


这样每个中间件都可以控制请求前和请求后,形成洋葱圈模型。


中间件的实现原理


Koa通过compose函数来组合中间件,实现洋葱圈模型。compose接收一个中间件数组作为参数,执行数组中的中间件,返回一个可以执行所有中间件的函数。


compose函数的实现源码如下:


function compose (middleware{

  return function (context, next{
    // last called middleware #
    let index = -1
    return dispatch(0)
    function dispatch (i{
      if (i <= index) return Promise.reject(new Error('next() called multiple times'))
      index = i
      let fn = middleware[i]
      if (i === middleware.length) fn = next
      if (!fn) return Promise.resolve()
      try {
        return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
      } catch (err) {
        return Promise.reject(err)
      }
    }
  }
}

这里利用了函数递归的机制。dispatch函数接收当前中间件的索引i,如果i大于中间件数组长度,则执行next函数。如果i小于中间件数组长度,则取出对应索引的中间件函数执行。


中间件的执行过程


中间件的执行过程


执行中间件函数的时候,递归调用dispatch,同时将索引+1,表示执行下一个中间件。


这样通过递归不断调用dispatch函数,就可以依次执行每个中间件,实现洋葱圈模型。


所以Koa的洋葱圈模型实现得非常简洁优雅,这也是Koa作为新一代Node框架,相比Express更优秀的设计。


洋葱圈模型的优势


提高中间件的复用性


洋葱模型让每个中间件都可以控制请求前和请求后,这样中间件可以根据需要完成各种额外的功能,不会相互干扰,提高了中间件的复用性。


使代码结构更清晰


洋葱模型层层嵌套,执行流程一目了然,代码阅读性好,结构清晰。不会像其他模型那样回调多层嵌套,代码难以维护。


异步编程更简单


洋葱模型通过async/await,使异步代码可以以同步的方式编写,没有回调函数,代码逻辑更清晰。


错误处理更友好


每个中间件都可以捕获自己的错误,并且不会影响其他中间件的执行,这样对错误处理更加友好。


方便Debug


通过洋葱模型可以清楚看到每个中间件的进入和离开,方便Debug。


便于扩展


可以随意在洋葱圈的任意层增加或删除中间件,结构灵活,便于扩展。


总结


总体来说,洋葱模型使中间件更容易编写、维护和扩展,这也是Koa等新框架选择它的主要原因。它的嵌套结构和异步编程支持,使Koa的中间件机制更优雅和高效。


作者:一码平川哟
来源:juejin.cn/post/7262158134323560508
收起阅读 »

2023.28 forEach 、for ... in 、for ... of有什么区别?

web
大家好,我是wo不是黄蓉,今年学习目标从源码共读开始,希望能跟着若川大佬学习源码的思路学到更多的东西。 forEach 、for ... in 、for ... of有什么区别 forEach 数组提供的方法,只能遍历数组 遍历数组:for...in key返...
继续阅读 »

大家好,我是wo不是黄蓉,今年学习目标从源码共读开始,希望能跟着若川大佬学习源码的思路学到更多的东西。


forEach 、for ... in 、for ... of有什么区别


forEach 数组提供的方法,只能遍历数组


遍历数组:for...in key返回数组下标;for...of key返回值;


1690806838416.png
遍历对象:for...in key返回对象的键;for...of 遍历对象报错,提示没有实现person对象不可迭代;


1690806968808.png


iterable什么是可迭代对象?


简单来说就是可以使用for...of遍历的对象,也就是实现了[Symbol.iterator]


迭代和循环有什么区别?


遍历强调把整个数据依次全部取出来,是访问数据结构的所有元素;


迭代虽然也是一次取出数据,但是并不保证取多少,需要调用next方法才能获取数据,不保证把所有的数据取完,是遍历的一种形式。


有哪些对象是可迭代对象呢?


原生的可迭代对象 set map nodelist arguments 数组 string


迭代器是针对某个对象的,有些对象是自己继承了Symbol.Iterator,也可以实现自己的迭代器,必须要实现一个next方法,返回内容



{value:any,done:boolean}

实现对象的迭代器


如果要实现迭代器,需要实现[Symbol.Iterator]是一个函数,这个函数返回一个迭代器


// let arr = ['a', 'b', 'c']
let person = {
name: 'a',
age: 18,
myIterator: function () {
var nextIndex = 0
return {
next: () => {
const array = Object.values(this)
return nextIndex < array.length
? { value: array[nextIndex++], done: false }
: { value: undefined, done: true }
}
}
}
}

let myIterator = person.myIterator()
console.log(person.myIterator())//{ next: [Function: next] }
console.log(myIterator.next())//{ value: 'a', done: false }
console.log(myIterator.next())//{ value: 18, done: false }
console.log(myIterator.next())//{ value: [Function: myIterator], done: false }
console.log(myIterator.next())//{ value: undefined, done: true }
{ value: undefined, done: true }

按道理实现了迭代器该对象就会变为可迭代对象了,可以使用for..of遍历


但是执行后发现还是会提示Person不是可迭代的,是因为for..of只能遍历实现了[Symbol.iterator]接口的的对象,因此我们写的方法名要使用[Symbol.iterator]


1690873941456.png


修改后:


let person = {
name: 'a',
age: 18,
[Symbol.iterator]: function () {
var nextIndex = 0
return {
next: () => {
const array = Object.values(this)
return nextIndex < array.length
? { value: array[nextIndex++], done: false }
: { value: undefined, done: true }
}
}
}
}

//for..in
for (let key in person) {
console.log(key, person[key])
}

//for...of
for (let key of person) {
console.log(key)
}

//打印结果
name a
age 18
a
18

什么时候会用迭代器?


应用场景:可以参考阮一峰老师列举的例子


js语法:for ... of 展开运算符 yield 解构赋值


创建对象时:new map new set new weakmap new weakset


一些方法的调用:promise.all promise.race array.from


for in 和for of 迭代器、生成器(generator)


迭代器中断:


迭代器中定义return方法在迭代器提前关闭时执行,必须返回一个对象


break return throw 在迭代器的return 方法中可以捕获到



let person = {
name: 'a',
age: 18,
[Symbol.iterator]: function () {
var nextIndex = 0
return {
next: () => {
const array = Object.values(this)
return nextIndex < array.length
? { value: array[nextIndex++], done: false }
: { value: undefined, done: true }
},
return: () => {
console.log('结束迭代')
return { done: true }
}
}
}
}

//for...of
for (let key of person) {
console.log(key)
if (key === 'a') break
}

//打印结果
a
结束迭代



作者:wo不是黄蓉
来源:juejin.cn/post/7262212980346404922
>结束:下节讲生成器

收起阅读 »

😋贪心算法

贪心算法 贪心算法是一种寻找最优解的算法思想,它通过局部最优选择来达到全局最优解。在贪心算法中,每一步都会做出当前状态下的最优选择,并且假设做出这样的选择后,剩余的问题可以被简化为一个更小的子问题。 与动态规划不同,贪心算法不需要保存子问题的解,因此通常需要更...
继续阅读 »

贪心算法


贪心算法是一种寻找最优解的算法思想,它通过局部最优选择来达到全局最优解。在贪心算法中,每一步都会做出当前状态下的最优选择,并且假设做出这样的选择后,剩余的问题可以被简化为一个更小的子问题。


与动态规划不同,贪心算法不需要保存子问题的解,因此通常需要更少的空间和时间。


贪心算法通常采用一种贪心的策略,即在每一步选择当前看起来最优的选择,希望最终得到全局最优解。但是,在某些情况下,局部最优解并不能保证一定能够导致全局最优解。由于贪心算法一旦做出选择就不能更改。贪心算法只是一种近似算法。


贪心算法通常需要满足贪心选择性质和最优子结构性质,否则它可能会导致错误的结果。


在使用贪心算法时,我们需要仔细考虑问题的特点和贪心选择的合理性,并尽可能地证明贪心算法的正确性。如果无法证明贪心算法的正确性,我们需要考虑使用其他算法来解决问题。


贪心算法常见的应用场景包括:



  • 贪心选择性质:在求解最优解的过程中,每一步的选择只与当前状态有关,不受之前选择的影响。

  • 最优子结构性质:问题的最优解可以被分解为若干个子问题的最优解,即子问题的最优解可以推导出原问题的最优解。

  • 无后效性:某个状态以前的过程不会影响以后的状态,只与当前状态有关。


举个反例🌰:279. 完全平方数


给你一个整数 n ,返回 和为 n 的完全平方数的最少数量 。


完全平方数 是一个整数,其值等于另一个整数的平方;换句话说,其值等于一个整数自乘的积。例如,149 和 16 都是完全平方数,而 3 和 11 不是。


 


示例 1:


输入: n = 12
输出: 3
解释: 12 = 4 + 4 + 4

示例 2:


输入: n = 13
输出: 2
解释: 13 = 4 + 9

 


提示:



  • 1<=n<=1041 <= n <= 10^4


错误做法:


class Solution:
def numSquares(self, n: int) -> int:
count = 0
while n != 0:
c = int(n**(1/2))
n -= c**2
count += 1
return count

输入12的时候答案是4,也就是12 = 9 + 1 + 1 + 1


实际上应该是答案为312 = 4 + 4 + 4


这个函数使用的是贪心算法的思想,每次都选择当前能用的最大完全平方数来减去 n,直到 n 减为 0。


在每一步中,选择最大的完全平方数来减去 n,可以确保所需的完全平方数的数量最小,因为如果我们选择了小的完全平方数,那么我们需要更多的完全平方数才能表示 n。


但是它并没有证明贪心策略的正确性,也没有提供正确性的证明。我们已经提供反例,证明这玩意儿是错的了。贪心算法的正确性得不到保证,所以本题不能用贪心算法。


正确答案:


class Solution:
def numSquares(self, n: int) -> int:
dp = [float('inf')]*(n+1)
dp[0] = 0
for i in range(1,n+1):
j = 1
while j*j <= i:
dp[i] = min(dp[i],dp[i-j*j]+1)
j+=1
return dp[-1]

这个代码使用了动态规划来解决完全平方数问题,它的时间复杂度为 O(nn)O(n\sqrt{n}),空间复杂度为 O(n)O(n)




  • i=0 时,不需要任何完全平方数。




  • 对于 i>0 的情况,我们枚举从 1i 中的每个完全平方数 j*j,然后计算 dp[i-j*j]+1 的值,这个值表示在将 i-j*j 分解成完全平方数之和的基础上再加上一个完全平方数 j*j。我们需要使 dp[i-j*j]+1 的值最小,因此我们可以得出状态转移方程:




dp[i]=min(dp[i],dp[ijj]+1)dp[i] = min(dp[i], dp[i-j * j]+1)

最后,dp[n] 的值就是将 n 分解成完全平方数之和所需的最小个数。


该代码正确地解决了完全平方数问题,可以得到全局最优解。


55. 跳跃游戏


给定一个非负整数数组 nums ,你最初位于数组的 第一个下标 。


数组中的每个元素代表你在该位置可以跳跃的最大长度。


判断你是否能够到达最后一个下标。


 


示例 1:


输入: nums = [2,3,1,1,4]
输出: true
解释: 可以先跳 1 步,从下标 0 到达下标 1, 然后再从下标 1 跳 3 步到达最后一个下标。

示例 2:


输入: nums = [3,2,1,0,4]
输出: false
解释: 无论怎样,总会到达下标为 3 的位置。但该下标的最大跳跃长度是 0 , 所以永远不可能到达最后一个下标。

 


提示:



  • 1 <= nums.length <= 3 * 104

  • 0 <= nums[i] <= 105


class Solution:
def canJump(self, nums: List[int]) -> bool:
maxlen = 0
for i,n in enumerate(nums):
if maxlen < i:
return False
maxlen = max(maxlen,i+n)
return maxlen >= len(nums) -1

这段代码实现了一个非常经典的贪心算法,用于判断能否从数组的起点跳到终点。


具体思路是,用 maxlen 记录当前能到达的最远位置,遍历数组中的每个位置,如果当前位置大于 maxlen,说明无法到达该位置,直接返回 False。否则,更新 maxlen 为当前位置能够到达的最远位置。


这个算法的贪心策略是,在每个位置上都选择能够到达的最远位置。由于跳跃的步数只能是整数,所以如果当前位置能到达的最远位置小于当前位置,那么就无法到达该位置。


这个算法的时间复杂度是 O(n)O(n),空间复杂度是 O(1)O(1)


45. 跳跃游戏 II


给定一个长度为 n 的 0 索引整数数组 nums。初始位置为 nums[0]


每个元素 nums[i] 表示从索引 i 向前跳转的最大长度。换句话说,如果你在 nums[i] 处,你可以跳转到任意 nums[i + j] 处:



  • 0 <= j <= nums[i] 

  • i + j < n


返回到达 nums[n - 1] 的最小跳跃次数。生成的测试用例可以到达 nums[n - 1]


 


示例 1:


输入: nums = [2,3,1,1,4]
输出: 2
解释: 跳到最后一个位置的最小跳跃数是 2。
  从下标为 0 跳到下标为 1 的位置,跳 1 步,然后跳 3 步到达数组的最后一个位置。

示例 2:


输入: nums = [2,3,0,1,4]
输出: 2

 


提示:



  • 1 <= nums.length <= 104

  • 0 <= nums[i] <= 1000

  • 题目保证可以到达 nums[n-1]


class Solution:
def jump(self, nums) -> int:
minstep = 0
i = len(nums) - 1
while i > 0:
for j,n in enumerate(nums):
if j+n >= i:
minstep += 1
i = j
break
return minstep

该算法的时间复杂度为 O(n2)O(n^2),其中 nn 为数组的长度。


在最坏情况下,每个元素都需要遍历一遍,以找到它们能够到达的最远距离,这需要 O(n)O(n) 的时间复杂度。同时,每次找到能够到达 ii 的最远距离时,都需要遍历从 00i1i-1 的所有元素,以找到能够到达 ii 的最小步数,这也需要 O(n)O(n) 的时间复杂度。因此,总时间复杂度为 O(n2)O(n^2)


该算法的空间复杂度为 O(1)O(1),因为它只使用了常数级别的额外空间。


优化——从前往后跳:


这个算法是一个基于贪心策略的解法,跟之前的从前往后跳的贪心算法类似,不过稍微做了一些改进,可以将时间复杂度降低到 O(n)O(n)


算法的核心思想是维护一个区间 [0, end],在这个区间内每个位置所能跳到的最远距离都是 i + nums[i],其中 i 是当前位置,nums[i] 是当前位置所能跳的最远距离。维护的时候,我们不断更新能够到达的最远距离 maxlen,当 i 到达区间的末尾 end 时,说明需要跳一步,并将 end 更新为 maxlen


这个算法的时间复杂度为 O(n)O(n),空间复杂度为 O(1)O(1)


class Solution:
def jump(self, nums):
n = len(nums)
maxlen = end = 0
step = 0
for i in range(n - 1):
maxlen = max(maxlen, i + nums[i])
if i == end:
end = maxlen
step += 1
return step
作者:Ann
来源:juejin.cn/post/7262231954191859770

收起阅读 »

忙里偷闲IdleHandler

在Android中,Handler是一个使用的非常频繁的东西,输入事件机制和系统状态,都通过Handler来进行流转,而在Handler中,有一个很少被人提起但是却很有用的东西,那就是IdleHandler,它的源码如下。/** * Callback int...
继续阅读 »

在Android中,Handler是一个使用的非常频繁的东西,输入事件机制和系统状态,都通过Handler来进行流转,而在Handler中,有一个很少被人提起但是却很有用的东西,那就是IdleHandler,它的源码如下。

/**
* Callback interface for discovering when a thread is going to block
* waiting for more messages.
*/
public static interface IdleHandler {
/**
* Called when the message queue has run out of messages and will now
* wait for more. Return true to keep your idle handler active, false
* to have it removed. This may be called if there are still messages
* pending in the queue, but they are all scheduled to be dispatched
* after the current time.
*/
boolean queueIdle();
}

从注释我们就能发现,这是一个IdleHandler的静态接口,可以在消息队列没有消息时或是队列中的消息还没有到执行时间时才会执行的一个回调。

这个功能在某些重要但不紧急的场景下就非常有用了,比如我们要在主页上做一些处理,但是又不想影响原有的初始化逻辑,避免卡顿,那么我们就需要等系统闲下来的时候再来执行我们的操作,这个时候,我们就可以通过IdleHandler来进行回调。

它的使用也非常简单,代码示例如下。

Looper.myQueue().addIdleHandler {
// Do something
false
}

在Handler的消息循环中,一旦队列里面没有需要处理的消息,该接口就会回调,也就是Handler空闲的时候。

这个接口有返回值,代表是否需要持续执行,如果返回true,那么一旦Handler空闲,就会执行IdleHandler中的回调,而如果返回false,那么就只会执行一次。

当返回true时,可以通过removeIdleHandler的方式来移除循环的处理,如果是false,那么在处理完后,它自己会移除。

综上,IdleHandler的使用主要有下面这些场景。

  • 低优先级的任务处理:替换之前为了不在初始化的时候影响性能而使用的Handler.postDelayed方法,通过IdleHandler来自动获取空闲的时机。
  • Idle时循环处理任务:通过控制返回值,在系统空闲时,不断重复某个操作。

但是要注意的是,如果Handler过于繁忙,那么IdleHandler的执行时机是有可能被延迟很久的,所以,要注意一些比较重要的处理逻辑的处理时机。

在很多第三方库里面,都有IdleHandler的使用,例如LeakCanary,它对内存的dump分析过程,就是在IdleHandler中处理的,从而避免对主线程的影响。


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

优化 Android Handler提升性能与稳定性

介绍 HandlerHandler 是一个常见的组件,它在 Android 应用程序开发中被广泛使用。Handler 可以将消息传递给主线程,使开发者能够在子线程中进行长时间的耗时操作,同时也避免了因在主线程中更新 UI 而出现的卡顿和 A...
继续阅读 »

介绍 Handler

Handler 是一个常见的组件,它在 Android 应用程序开发中被广泛使用。Handler 可以将消息传递给主线程,使开发者能够在子线程中进行长时间的耗时操作,同时也避免了因在主线程中更新 UI 而出现的卡顿和 ANR 问题。

Handler 的问题

尽管 Handler 能够帮助处理一些繁琐的任务,然而如果不进行优化,Handler 自身却可能成为你应用程序的问题所在。

以下列出一些常见的 Handler 问题:

内存泄漏

因为 Handler 实例通常会保留对主线程的引用,而主线程通常不会被销毁,所以你在应用程序中使用 Handler时,很有可能会遇到内存泄漏的问题。

ANR

在处理大量消息时,使用 Handler 造成运行过程变慢。此时,当主线程无法在规定时间内完成属于它的操作时,就会发生一种无法响应的情况 ANR。

线程安全问题

如果你没有很好地处理并发问题,Handler 在多个线程中对同一实例的使用,可能会引发线程的安全问题。

优化方法

为了避免以上问题,可以尝试以下优化方法:

使用静态内部类

一个优化处理内存泄漏的方法是将 Handler 实例声明为静态内部类。这样,Handler 将不会保留对外部类的引用,从而避免了内存泄漏。

public class MyActivity extends Activity {

private static class MyHandler extends Handler {
private final WeakReference<MyActivity> mActivity;

public MyHandler(MyActivity activity) {
mActivity = new WeakReference<MyActivity>(activity);
}

@Override
public void handleMessage(Message msg) {
MyActivity activity = mActivity.get();
if (activity != null) {
// do something
}
}
}

private final MyHandler mHandler = new MyHandler(this);
}

移除Handler的回调

为了避免Handler泄露,可以再在Activity或Fragment的生命周期方法中移除Handler的回调。

@Override
protected void onDestroy() {
super.onDestroy();
handler.removeCallbacksAndMessages(null);
}

使用子线程与消息延迟

为避免 Handler 运行缓慢和 ANR 的问题, 可以将耗时任务放在子线程中执行,并在需要更新UI时使用Handler进行线程间通信。 如果消息队列中的消息太多,可以让主线程先处理其他任务,再延迟消息的处理时间。

Handler handler = new Handler(Looper.getMainLooper()) {
@Override
public void handleMessage(Message msg) {
// 在主线程更新UI
}
};

// 在子线程中执行耗时任务
new Thread(new Runnable() {
@Override
public void run() {
// 执行耗时操作

handler.sendMessage(handler.obtainMessage());
}
}).start();
private static final int MAX_HANDLED_MESSAGE_COUNT = 500;

private Handler mHandler = new Handler() {
private int mHandledMessageCount = 0;

@Override
public void handleMessage(Message msg) {
// do something

mHandledMessageCount++;
if (mHandledMessageCount > MAX_HANDLED_MESSAGE_COUNT) {
mHandler.postDelayed(new Runnable() {
@Override
public void run() {
mHandledMessageCount = 0;
}
}, 1000);
}
}
};

使用 HandlerThread

为了避免出现线程安全问题,可以使用 HandlerThread 来创建线程从而处理消息。这样做的好处是不必担心多个线程同时访问同一个 Handler 实例的问题。

public class MyHandlerThread extends HandlerThread {
private static final String TAG = "MyHandlerThread";

private Handler mHandler;

public MyHandlerThread() {
super(TAG);
}

@Override
protected void onLooperPrepared() {
mHandler = new Handler(getLooper()) {
@Override
public void handleMessage(Message msg) {
// do something
}
};
}

public Handler getHandler() {
return mHandler;
}
}

使用 SparseArray

如果你的应用程序中有多个 Handler,可以使用 SparseArray 来管理它们。SparseArray 是一个类似于 HashMap的数据结构,它可以非常高效地管理多个 Handler 实例。

private SparseArray<Handler> mHandlerArray = new SparseArray<>();

private void initHandlers() {
mHandlerArray.put(1, new Handler() {
@Override
public void handleMessage(Message msg) {
// do something
}
});

mHandlerArray.put(2, new Handler() {
@Override
public void handleMessage(Message msg) {
// do something
}
});

// add more handlers
}

private void handleMessages(int handlerId, Message msg) {
Handler handler = mHandlerArray.get(handlerId);
if (handler != null) {
handler.handleMessage(msg);
}
}

使用 MessageQueue.IdleHandler

如果你的应用程序中有长时间运行的任务,可以使用 MessageQueue.IdleHandler 来执行它们。MessageQueue.IdleHandler 是一个回调接口,它可以在没有消息时执行任务。

private void executeLongRunningTask() {
Looper.myQueue().addIdleHandler(new MessageQueue.IdleHandler() {
@Override
public boolean queueIdle() {
// do something
return false; // remove the idle handler
}
});
}

结论

Handler 作为 Android 应用程序中非常重要的一个组件,但如果不进行优化,将可能影响应用程序的性能和稳定性。通过这篇文章,我们可以有效地避免问题的出现,让应用程序更加高效稳定。

推荐

android_startup: 提供一种在应用启动时能够更加简单、高效的方式来初始化组件,优化启动速度。不仅支持Jetpack App Startup的全部功能,还提供额外的同步与异步等待、线程控制与多进程支持等功能。

AwesomeGithub: 基于Github的客户端,纯练习项目,支持组件化开发,支持账户密码与认证登陆。使用Kotlin语言进行开发,项目架构是基于JetPack&DataBinding的MVVM;项目中使用了Arouter、Retrofit、Coroutine、Glide、Dagger与Hilt等流行开源技术。

flutter_github: 基于Flutter的跨平台版本Github客户端,与AwesomeGithub相对应。

android-api-analysis: 结合详细的Demo来全面解析Android相关的知识点, 帮助读者能够更快的掌握与理解所阐述的要点。

daily_algorithm: 每日一算法,由浅入深,欢迎加入一起共勉。


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

一篇文章带你学会Kotlin

都2023年了,新建的项目还是Java项目,或者你还在写Java样式的Kotlin项目,仔细看完这篇文章,带你从Java转到Kotlin,带你学会Koltin,从入坑到脱坑为什么要学习KotlinKotlin是Andorid官方推荐语言最年来Google发布很...
继续阅读 »

都2023年了,新建的项目还是Java项目,或者你还在写Java样式的Kotlin项目,仔细看完这篇文章,带你从Java转到Kotlin,带你学会Koltin,从入坑到脱坑

为什么要学习Kotlin

  1. KotlinAndorid官方推荐语言
  2. 最年来Google发布很多新玩意,都是Kotlin写的,对Kotlin支持比较友好
  3. Compose你不会Kotlin怎么学习
  4. 一些大型开源项目,比如OkhttpRetrofitGlide都改成了Kotlin版本
  5. 使用协程,让你切换线程更加方便,摆脱回调地狱
  6. 让你的代码更简洁

综上所示,笔者认为,Kotlin如今是一名Android开发工程师所必须要掌握的技能,但是偏偏还是有很多人不用,不学,所以就写下来这篇文章,带你快速入门Kotlin,也算是对自己知识的一遍巩固

基础

何为Kotlin,笔者认为是如何快速定义变量,常量,new一个对象,调用一个方法,来看一下Java是怎么做的

int a = 10;
a = 11;
TextView textView = new TextView(context);
textView.setText(a);

嗯,还是比较简洁的,但是还可以更简洁,看一下相同的代码使用Kotlin如何完成

fun Test(context: Context?) {
var a = 10
a = 11
val textView = TextView(context)
textView.text = a.toString()
}

解释一下,Kotlin定义常量是val,变量为,var,什么类型,根本不需要,它会通过后面得内容自动推导出来是什么类型的,但是从本质来说,Kotlin是还是强类型语言,只不过编译器会自动推导出来他真的类型而已,然后是不用写new关键字了,不用写;结尾了,getset方法也不用写,直接等,实际上还是调用了真的getset,原因是通过了属性访问器(accessor)的语法糖形式直接使用等号进行赋值和获取

接下来看一下类,点即创建一个Kotlin类型得File,会出来如下弹框

image.png

  • Class 和JavaClass没什么两样,一般就是一个类
  • File 如果当一个Class中有两个同级别的类,这个时候就会变为File,这个一般在写扩展函数的时候使用,扩展函数后面会讲到
  • Interface 和JavaInterface一样,一个接口
  • Sealed interface 封闭接口,防止不同model之间的互相调用,比如你再B Model中定义 B Sealed interface,那么你只能在B Model中使用这个接口,除此之外,还是使用此接口完成多继承的操作
  • Data class 实体类,和Java实体类有什么不同呢,不用写getset方法和普通Kotlin Class有什么不同呢,首先是必须要有有参构造方法,然后重写了hashCodeequals方法
  • Enum class 枚举类,和Java一样
  • Sealed class 和Sealed interface差不多,都是限制只能在一个Model中使用
  • Annotation 定义一个注解
  • Object 这个笔者最喜欢,常用来定义一个单例类,相当于Java的饿汉式 其中比较常用的有ClassData class,Object总的来说还是比Java的类型多一点

返回值

为什么要单写一个返回值,因为Kotlin的所有方法都有返回值,常规的就不说,看下面代码

val a: Unit = printA()

private fun printA() {
print("A")
}

这里面定义了函数,没有定义任何返回值,但是其实的类型是Unit,我的理解是Unit就代表它是一个函数类型和String一样都是Koltin类型中的一种,明白了这点就可以理解接下来的操作

val a = if ( x > y) x else y

相当于Java的三元换算符,如果x大于y就等于x,否则就等于y,对了Kotlin是没有三元换算符这个概念的,如果要达到相同效果只有使用if...else...,同理when也同样适用于这种操作

还是一种建立在编译器类型推导的基础上一种写法

private fun getA() = {
val a = 0
a
}

这个函数的意义就是返回了一个数字a,为什么没有return 它却拥有返回值返回值,请注意看这个=号,编译器给他推导出来了,并且通过lamaba返回值 还有一个非常特殊的返回值 Nothing 什么意思呢 不是null 就是什么也没有 具体可以看一下这篇文章

NULL安全处理

Kotlin是一个null安全的语言下面详细看一下

//定义一个变量
private var a: String? = null

String后面有个?代表,这个变量是可能为null的那么就需要在使用的时候处理它,不然会报错

if (a != null){
//不为null
}else{
//为null
}
//或者加上!!,代表这个时候肯定不为null,但是一般不建议这样写,玩出现null,就会空指针异常
a!!

当然如果确定使用的时候肯定部位null也可以这样写

private lateinit var a: String

代表定义了一个变量,我不管之前对它做了什么操作,反正我使用的时候,它一定是不为null的,当然与之的对应的还有by lazy延迟初始化,当第一次使用的时候才会初始化,比如这样,当我第一次调用a的时候,by lazy的括号内容即委托就会执行给a赋值,值得注意的是by lazy不是绑定在一起的 也可以只使用by意思也是委托,不过要单独写一个委托的实现

private val a: String by lazy { "123" }

扩展函数

扩展函数可以说是Kotlin比较爽的部分,他的本质其实是一个Kotlin的静态方法然后返回了它原本得了类型,比如这段代码

fun String?.addPlus(number: Int): String? {
if (this == null){
return null
}
return this.toInt().plus(number).toString()
}

扩展了String的方法,在String的基础上添加了一个addPlus方法,然后接受一个number参数,然后强转为int类型之后加上number并返回,这样扩展不用继承也可以在一些类中添加方法,减少了出BUG的可能性

val str = "1"
val str2 = str.addPlus(2)

看一这段代码的本质,可以知道Koltin和Java是可以互相转换的

public static final String addPlus(@Nullable String $this$addPlus, int number) {
return $this$addPlus == null ? null : String.valueOf(Integer.parseInt($this$addPlus) + number);
}

可以看到,扩展函数的本质是一个静态方法,并且多了一个String类型的参数,这个参数其实就是扩展函数类的实体,利用扩展函数可以实现简化很多操作,比如金额计算就可以扩展String,然后接收一个String,又或者是给textView添加一个功能,同样可以使用,我认为它差不多就是简化了静态工具类的使用,让其更方便,更快捷

扩展函数二 let also run apply

还记得上面提到的Kotlin对于null的判断吗,其实拥有更快快捷的方法就是使用扩展函数看这一段代码

fun stringToInt(str: String?): Int {
return str?.let {
it.toIntOrNull() ?: -1
} ?: run {
-1
}
}

一段链式调用,这也是Kotlin的特性之一,后续有时间会讲,链式调用有好处也有坏处,好处是可以更好的代码,坏处是一旦使用了过长的链式调用,后期代码维护就会很麻烦,为什么我会知道,因为我就写过,后期维护的时候痛不欲生,开发一时爽维护两行泪,但是适量使用这种,会让你的代码变得更简洁,维护更方便,主要还是看工程师的把握度。

好了具体看一下代码做了什么工作,首先定义了一个stringToInt的函数,然后接受了一个String参数,这个参数可以为null,然后判断是否等于null,等于null返回-1,不能转化为int返回-1,可以正常转化返回int值 Ok 先解释一下如何利用扩展函数做null判断 ?.let代表如果str这个值不为null,那么就执行it.toIntOrNull() ?: -1,否则这里用 ?:来处理即为null 就走run 然后返回-1

  • let 提供了一个以默认值it的空间,然后返回值的是当前执行控件的最后一行代码
  • also 提供了一个以默认值it的空间,然后返回值是它本身
  • run 提供了一个this的命名空间,然后返回最后一行代码
  • apply 提供了一个this的命名空间,然后返回它本身 这里贴出Kotlin的实现,源码其实很简单
@kotlin.internal.InlineOnly
public inline fun <T, R> T.let(block: (T) -> R): R {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
return block(this)
}

@kotlin.internal.InlineOnly
@SinceKotlin("1.1")
public inline fun <T> T.also(block: (T) -> Unit): T {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
block(this)
return this
}

@kotlin.internal.InlineOnly
public inline fun <T, R> T.run(block: T.() -> R): R {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
return block()
}


@kotlin.internal.InlineOnly
public inline fun <T> T.apply(block: T.() -> Unit): T {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
block()
return this
}

代码其实很简单,有兴趣可以自己随便玩玩,这里就以apply为例分析一下,首先返回一个泛型T,这个T就是调用者本身,然后接口了一个black的函数,这个函数就是实际在代码中apply提供的空间,然后执行完成后,然后调用者本身,这样就和上文对应上了,apply 提供了一个this的命名空间,然后返回它本身,也可以仿照实现一个自己myApply哈哈哈,

结语

这篇文章其实原本想写的非常多,还有很多关键字没有介绍,比如by,inline,受限于篇幅问题,暂时把最基础的写了一写,以后会逐步完善这个系列,在写的过程,也有一些是笔者使用起来相对来说比较少的,就比如Sealed interface这个接口,之前就完全不理解,在写的时候特意去查询了一下资料,然后自己测试了一番,才理解,也算是促进了自己学习,希望可以共同进步


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

接口设计

大家好,我是二营长,日拱一卒无有尽,功不唐捐终入海。这里是Java学习小站,关注我,每天进步一点点!接口的重要性:在日常的开发中,在需求确定之后,后端同学首先要做的就是定义接口,接口定义完成之后,前端的同学就可以看接口文档和后端进行同步开发了。接口文档的作用还...
继续阅读 »

大家好,我是二营长,日拱一卒无有尽,功不唐捐终入海。这里是Java学习小站,关注我,每天进步一点点!


接口的重要性:

在日常的开发中,在需求确定之后,后端同学首先要做的就是定义接口,接口定义完成之后,前端的同学就可以看接口文档和后端进行同步开发了。接口文档的作用还有很多:

  1. 沟通:开发、测试和其他人员之间的沟通渠道;它定义了接口的规范和预期行为,确保所有团队成员对接口的功能和使用方式有共同的理解。
  2. 效率:开发人员可以根据文档准确地了解如何调用接口、传递参数以及处理响应。这减少了开发过程中的试错和猜测,使开发人员能够更加专注于业务逻辑的实现。
  3. 并行开发:当多个开发人员同时工作在一个项目中时,接口文档允许他们独立地开发和测试各自的模块。通过定义清晰的接口规范,团队成员可以并行工作,而无需过多的交流和依赖。
  4. 代码质量:清晰的接口先行的方式,可以促使开发人员编写更健壮和可靠的代码。接口定义之后,整个交互过程就了然于胸了。
  5. 方便集成:当不同的系统或团队之间需要进行集成时,接口文档起到了关键的作用。通过提供准确和详细的接口规范,文档可以帮助团队避免集成过程中的误解和错误,降低集成风险。
  6. 支持第三方开发:如果你的应用程序或服务允许第三方开发者使用你的接口,好的接口文档是必不可少的。它为第三方开发者提供了准确的接口描述和示例代码,促进了他们与你的系统进行集成和开发扩展。

工作中常见的维护接口文档的方式:

使用Swagger、YApi等自动化接口管理平台。

Swagger和YApi等工具提供了自动化生成接口文档的功能,它们可以通过解析代码注释、接口定义或接口调用等方式,自动生成接口文档。这样可以减少手动编写和维护文档的工作量,同时确保文档与实际接口保持同步。

这些自动化管理平台还提供了其他有用的功能,例如接口测试、Mock数据生成、权限管理等。它们通常具备用户友好的界面和交互,可以方便团队成员共同编辑和维护接口文档,提高团队协作效率。

怎么设计好一个接口

我曾经遭遇过面试官,疯狂追问接口使如何设计的,虽然这是日常工作的一部分,但是很遗憾我没有表述清楚。

大部分的互联网项目都选择使用HTTP请求的方式进行交互的。

HTTP请求的组成

HTTP请求通常包括以下几个部分:

  1. 请求行(Request Line):包括请求方法(如GET、POST)、请求的URL路径和协议版本(如HTTP/1.1)。

  2. 请求头部(Request Headers):包括多个键值对,用于传递请求的元信息。常见的请求头部字段包括Host、User-Agent、Content-Type、Authorization等。

  3. 空行(Blank Line):请求头部与请求体之间需要有一个空行,用于分隔请求头部和请求体。

  4. 请求体(Request Body):对于某些请求方法(如POST),可以包含请求的内容,如表单数据、JSON数据等。对于其他请求方法(如GET),请求体通常为空。

HTTP请求报文的方式:

HTTP请求报文的方式主要有以下几种:

  1. GET请求:GET请求通过URL参数传递数据,将请求参数附加在URL的末尾,以?开头,多个参数使用&分隔。GET请求的数据会明文显示在URL中,适合用于请求获取资源,对数据安全性要求较低的情况。

  2. POST请求:POST请求将数据放在请求体中传递,适合用于提交表单、上传文件等操作。POST请求的数据不会显示在URL中,相对于GET请求更加安全,但需要在请求头中指定请求体的内容类型(Content-Type)。

  3. PUT请求:PUT请求用于更新(全量替换)指定资源的信息。PUT请求将数据放在请求体中传递,类似于POST请求,但PUT请求要求对指定的资源进行完全替换,而不是部分修改。

  4. PATCH请求:PATCH请求用于部分更新指定资源的信息。PATCH请求将数据放在请求体中传递,用于对资源进行局部修改,而不是全量替换。PATCH请求可以避免对整个资源进行完全替换的开销。

  5. DELETE请求:DELETE请求用于删除指定的资源。DELETE请求通常不包含请求体,而是通过URL指定要删除的资源的路径。

RESTful API 接口规范

REST(Representational State Transfer)是一种软件架构风格和设计原则,用于构建分布式系统和网络应用程序。RESTful是基于REST原则定义的一组规范和约束,用于设计和开发Web API接口。

在RESTful规范中,可以理解为一切即资源,所有请求都是对资源的操作或查询。

RESTful架构中的几个核心概念:

  1. 资源(Resources):每种资源都有一个唯一的统一资源定位符(URI),用于标识和定位该资源。URI代表资源的地址或唯一识别符。

  2. 表现层(Representation):资源的表现层是指将资源具体呈现出来的形式。URI只表示资源的位置,而资源的具体表现形式可以通过HTTP请求的头信息中的Accept和Content-Type字段来指定。这两个字段描述了资源的表现层。

  3. 状态转化(State Transfer):客户端要操作服务器上的资源,需要通过某种方式触发服务器端的状态转变。这种转变是建立在表现层之上的,因此称为"表现层状态转化"。

在RESTful架构中,客户端使用HTTP协议中的四个表示操作方式的动词(GET、POST、PUT、DELETE)来实现状态转化。这些动词分别对应着四种基本操作:GET用于获取资源,POST用于新建资源(也可用于更新资源),PUT用于更新资源,DELETE用于删除资源。

简要总结:

  • 每个URI代表一种资源。
  • 客户端和服务器之间传递资源的表现层。
  • 客户端通过HTTP动词对服务器端资源进行操作,实现表现层状态转化。

举个例子

API命名规范:面向资源命名

当设计符合RESTful规范的接口时,可以在URL路径中添加版本号或者命名空间,以提供更好的可扩展性和可维护性。

获取所有文章:

请求方法:GET

URL路径:/api/articles

示例请求:GET /api/articles

示例响应:

{
  "articles": [
    {
      "id"1,
      "title""RESTful 接口设计",
      "content""这是一篇关于RESTful接口设计的文章。"
    },
    {
      "id"2,
      "title""RESTful 接口实现",
      "content""这是一篇关于RESTful接口实现的文章。"
    }
  ]
}

获取单个文章:

请求方法:GET

URL路径:/api/articles/{id}

示例请求:GET /api/articles/1

示例响应:

{
  "id"1,
  "title""RESTful 接口设计",
  "content""这是一篇关于RESTful接口设计的文章。"
}

创建文章:

请求方法:POST

URL路径:/api/articles

示例请求:

POST /api/articles
Content-Type: application/json

{
  "title""新的文章",
  "content""这是一个全新的文章。"
}

示例响应:

{
  "id"3,
  "title""新的文章",
  "content""这是一个全新的文章。"
}

更新文章:

请求方法:PUT

URL路径:/api/articles/{id}

示例请求:

PUT /api/articles/1
Content-Type: application/json

{
  "title""更新后的文章",
  "content""这是一篇更新后的文章。"
}

示例响应:

{
  "id"1,
  "title""更新后的文章",
  "content""这是一篇更新后的文章。"
}

删除文章:

请求方法:DELETE

URL路径:/api/articles/{id}

示例请求:DELETE /api/articles/1

示例响应:

{
  "message""文章已成功删除。"
}

通过在URL路径中添加/api前缀,可以更好地组织和管理接口,区分不同的功能模块或者版本。这种方式可以提高接口的可扩展性和可维护性,同时也符合常见的API设计实践。

定义统一的请求或响应参数

请求参数:

在定义请求参数时,可以根据具体的业务需求和安全考虑,包括一些常见的参数类型和参数名称。下面是一些常见的请求参数定义:

  1. 查询参数(Query Parameters):这些参数通常包含在URL中,以键值对的形式出现,用于过滤、排序、分页等操作。例如,对于获取文章列表的接口,可以接受page和limit参数来指定返回的页数和每页的数量。

  2. 路径参数(Path Parameters):这些参数通常嵌入在URL路径中,用于标识资源的唯一标识符或其他信息。例如,对于获取单个文章的接口,可以将文章ID作为路径参数,如/articles/{id}。

  3. 请求体参数(Request Body Parameters):这些参数通常包含在请求的消息体中,以JSON、XML或其他格式进行传输,用于传递复杂或大量的数据。例如,对于创建文章的接口,可以将文章的标题、内容等信息作为请求体参数。

  4. 请求头参数(Request Header Parameters):这些参数包含在HTTP请求的头部中,用于传递与请求相关的元数据或控制信息。例如,可以使用Authorization头部参数传递身份验证信息,如token。

对于特定的安全需求,例如身份验证和授权,常见的请求参数包括:

  • Token:用于身份验证和授权的令牌,通常是一个字符串。可以将Token作为请求头参数(如Authorization),请求体参数或查询参数的一部分,具体取决于API设计的需求和标准。

  • API密钥(API Key):用于标识和验证应用程序的身份,通常是一个长字符串。API密钥可以作为请求头参数、请求体参数或查询参数的一部分,以确保只有授权的应用程序可以访问API。

  • 时间戳(Timestamp):用于防止重放攻击和确保请求的时效性,通常是一个表示当前时间的数字或字符串。时间戳可以作为请求头参数、请求体参数或查询参数的一部分。

这些请求参数的具体定义和使用方式应根据你的应用程序需求和安全策略来确定。确保在设计API时考虑到安全性、一致性和易用性。另外,建议参考相关的API设计规范和最佳实践,如OpenAPI规范或RESTful API设计指南。

响应参数

接口响应实例:

{
  "version""string",
  "msg""string",
  "code"200,
  "error""false",
  "data": {},
  "values": {}
}

这个示例中包含了以下参数:

  • version:表示接口版本的字符串。可以用于标识接口的版本号,方便后续的版本控制和兼容性处理。

  • msg:用于提供接口响应的描述信息的字符串。可以包含有关请求处理结果的额外说明或其他相关信息。

  • code:表示请求的处理结果状态码的整数值。一般情况下,200表示成功,其他状态码用于表示不同的错误或结果。

  • error:表示请求处理是否出错的布尔值。当发生错误时,可以将其设置为true,否则设置为false。

  • data:表示接口响应的具体数据的对象。可以包含接口处理结果的数据,例如获取的用户信息、文章内容等。

  • values:表示其他相关数值或附加信息的对象。可以用于传递一些额外的关键值或辅助信息。


                    END

日拱一卒无有尽,功不唐捐终入海。这里是Java学习小站,关注我,每天进步一点点!

收起阅读 »

Android动态权限申请从未如此简单

作者:dreamgyf juejin.cn/post/72255161761711882851. 前言大家是否还在为动态权限申请感到苦恼呢?传统的动态权限申请需要在 Activity 中重写 onRequestPermissionsResu...
继续阅读 »

作者:dreamgyf 
juejin.cn/post/7225516176171188285

1. 前言

大家是否还在为动态权限申请感到苦恼呢?传统的动态权限申请需要在 Activity 中重写 onRequestPermissionsResult 方法来接收用户权限授予的结果。试想一下,你需要在一个子模块中申请权限,那得从这个模块所在的 Activity 的 onRequestPermissionsResult 中将结果一层层再传回到这个模块中,相当的麻烦,代码也相当冗余和不干净,逼死强迫症。

2. 使用

为了解决这个痛点,我封装出了两个方法,用于随时随地快速的动态申请权限,我们先来看看我们的封装方法是如何调用的:

activity.requestPermission(Manifest.permission.CAMERA, onPermit = {
    //申请权限成功 Do something
}, onDeny = { shouldShowCustomRequest ->
    //申请权限失败 Do something
    if (shouldShowCustomRequest) {
        //用户选择了拒绝并且不在询问,此时应该使用自定义弹窗提醒用户授权(可选)
    }
})

这样是不是非常的简单便捷?申请和结果回调都在一个方法内处理,并且支持随用随调。

3. 方案

那么,这么方便好用的方法是怎么实现的呢?不知道小伙伴们在平时开发中有没有注意到过,当你调用 startActivityForResult 时,AS会提示你该方法已被弃用,点进去看会告诉你应该使用 registerForActivityResult 方法替代。没错,这就是 androidx 给我们提供的 ActivityResult 功能,并且这个功能不仅支持 ActivityResult 回调,还支持打开文档,拍摄照片,选择文件等各种各样的回调,同样也包括我们今天要说的权限申请

其实 Android 在官方文档“请求运行时权限”中就已经将其作为动态权限申请的推荐方法了:https://developer.android.com/training/permissions/requesting

如下示例代码所示:

val requestPermissionLauncher =
    registerForActivityResult(RequestPermission()
    ) { isGranted: Boolean ->
        if (isGranted) {
            // Permission is granted. Continue the action or workflow in your
            // app.
        } else {
            // Explain to the user that the feature is unavailable because the
            // feature requires a permission that the user has denied. At the
            // same time, respect the user's decision. Don't link to system
            // settings in an effort to convince the user to change their
            // decision.
        }
    }

when {
    ContextCompat.checkSelfPermission(
            CONTEXT,
            Manifest.permission.REQUESTED_PERMISSION
            ) == PackageManager.PERMISSION_GRANTED -> {
        // You can use the API that requires the permission.
    }
    shouldShowRequestPermissionRationale(...) -> {
        // In an educational UI, explain to the user why your app requires this
        // permission for a specific feature to behave as expected, and what
        // features are disabled if it's declined. In this UI, include a
        // "cancel" or "no thanks" button that lets the user continue
        // using your app without granting the permission.
        showInContextUI(...)
    }
    else -> {
        // You can directly ask for the permission.
        // The registered ActivityResultCallback gets the result of this request.
        requestPermissionLauncher.launch(
                Manifest.permission.REQUESTED_PERMISSION)
    }
}

说到这里,可能有小伙伴要质疑我了:“官方文档里都写明了的东西,你还特地写一遍,还起了这么个标题,是不是在水文章?!”

莫急,如果你遵照以上方法这么写的话,在实际调用的时候会直接发生崩溃:

java.lang.IllegalStateException: 
LifecycleOwner Activity is attempting to register while current state is RESUMED.
LifecycleOwners must call register before they are STARTED.

这段报错很明显的告诉我们,我们的注册工作必须要在 Activity 声明周期 STARTED 之前进行(也就是 onCreate 时和 onStart 完成前),但这样我们就必须要事先注册好所有可能会用到的权限,没办法做到随时随地有需要时再申请权限了,有办法解决这个问题吗?答案是肯定的。

4. 绕过生命周期检测

想解决这个问题,我们必须要知道问题的成因,让我们带着问题进到源码中一探究竟:

public final <I, O> ActivityResultLauncher<I> registerForActivityResult(
        @NonNull ActivityResultContract<I, O> contract,
        @NonNull ActivityResultCallback<O> callback)
 
{
    return registerForActivityResult(contract, mActivityResultRegistry, callback);
}

public final <I, O> ActivityResultLauncher<I> registerForActivityResult(
        @NonNull final ActivityResultContract<I, O> contract,
        @NonNull final ActivityResultRegistry registry,
        @NonNull final ActivityResultCallback<O> callback)
 
{
    return registry.register(
            "activity_rq#" + mNextLocalRequestCode.getAndIncrement(), this, contract, callback);
}

public final <I, O> ActivityResultLauncher<I> register(
        @NonNull final String key,
        @NonNull final LifecycleOwner lifecycleOwner,
        @NonNull final ActivityResultContract<I, O> contract,
        @NonNull final ActivityResultCallback<O> callback)
 
{

    Lifecycle lifecycle = lifecycleOwner.getLifecycle();

    if (lifecycle.getCurrentState().isAtLeast(Lifecycle.State.STARTED)) {
        throw new IllegalStateException("LifecycleOwner " + lifecycleOwner + " is "
                + "attempting to register while current state is "
                + lifecycle.getCurrentState() + ". LifecycleOwners must call register before "
                + "they are STARTED.");
    }

    registerKey(key);
    LifecycleContainer lifecycleContainer = mKeyToLifecycleContainers.get(key);
    if (lifecycleContainer == null) {
        lifecycleContainer = new LifecycleContainer(lifecycle);
    }
    LifecycleEventObserver observer = new LifecycleEventObserver() { ... };
    lifecycleContainer.addObserver(observer);
    mKeyToLifecycleContainers.put(key, lifecycleContainer);

    return new ActivityResultLauncher<I>() { ... };
}

我们可以发现,registerForActivityResult 实际上就是调用了 ComponentActivity 内部成员变量的 mActivityResultRegistry.register 方法,而在这个方法的一开头就检查了当前 Activity 的生命周期,如果生命周期位于STARTED后则直接抛出异常,那我们该如何绕过这个限制呢?

其实在 register 方法的下面就有一个同名重载方法,这个方法并没有做生命周期的检测:

public final <I, O> ActivityResultLauncher<I> register(
        @NonNull final String key,
        @NonNull final ActivityResultContract<I, O> contract,
        @NonNull final ActivityResultCallback<O> callback)
 
{
    registerKey(key);
    mKeyToCallback.put(key, new CallbackAndContract<>(callback, contract));

    if (mParsedPendingResults.containsKey(key)) {
        @SuppressWarnings("unchecked")
        final O parsedPendingResult = (O) mParsedPendingResults.get(key);
        mParsedPendingResults.remove(key);
        callback.onActivityResult(parsedPendingResult);
    }
    final ActivityResult pendingResult = mPendingResults.getParcelable(key);
    if (pendingResult != null) {
        mPendingResults.remove(key);
        callback.onActivityResult(contract.parseResult(
                pendingResult.getResultCode(),
                pendingResult.getData()));
    }

    return new ActivityResultLauncher<I>() { ... };
}

找到这个方法就简单了,我们将 registerForActivityResult 方法调用替换成 activityResultRegistry.register 调用就可以了

当然,我们还需要注意一些小细节,检查生命周期的 register 方法同时也会注册生命周期回调,当 Activity 被销毁时会将我们注册的 ActivityResult 回调移除,我们也需要给我们封装的方法加上这个逻辑,最终实现就如下所示。

5. 最终实现

private val nextLocalRequestCode = AtomicInteger()

private val nextKey: String
    get() = "activity_rq#${nextLocalRequestCode.getAndIncrement()}"

fun ComponentActivity.requestPermission(
    permission: String,
    onPermit: () -> Unit,
    onDeny: (shouldShowCustomRequestBoolean) -> Unit
)
 {
    if (ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED) {
        onPermit()
        return
    }
    var launcher by Delegates.notNull<ActivityResultLauncher<String>>()
    launcher = activityResultRegistry.register(
        nextKey,
        ActivityResultContracts.RequestPermission()
    ) { result ->
        if (result) {
            onPermit()
        } else {
            onDeny(!ActivityCompat.shouldShowRequestPermissionRationale(this, permission))
        }
        launcher.unregister()
    }
    lifecycle.addObserver(object : LifecycleEventObserver {
        override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
            if (event == Lifecycle.Event.ON_DESTROY) {
                launcher.unregister()
                lifecycle.removeObserver(this)
            }
        }
    })
    launcher.launch(permission)
}

fun ComponentActivity.requestPermissions(
    permissions: Array<String>,
    onPermit: () -> Unit,
    onDeny: (shouldShowCustomRequestBoolean) -> Unit
)
 {
    var hasPermissions = true
    for (permission in permissions) {
        if (ContextCompat.checkSelfPermission(
                this,
                permission
            ) != PackageManager.PERMISSION_GRANTED
        ) {
            hasPermissions = false
            break
        }
    }
    if (hasPermissions) {
        onPermit()
        return
    }
    var launcher by Delegates.notNull<ActivityResultLauncher<Array<String>>>()
    launcher = activityResultRegistry.register(
        nextKey,
        ActivityResultContracts.RequestMultiplePermissions()
    ) { result ->
        var allAllow = true
        for (allow in result.values) {
            if (!allow) {
                allAllow = false
                break
            }
        }
        if (allAllow) {
            onPermit()
        } else {
            var shouldShowCustomRequest = false
            for (permission in permissions) {
                if (!ActivityCompat.shouldShowRequestPermissionRationale(this, permission)) {
                    shouldShowCustomRequest = true
                    break
                }
            }
            onDeny(shouldShowCustomRequest)
        }
        launcher.unregister()
    }
    lifecycle.addObserver(object : LifecycleEventObserver {
        override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
            if (event == Lifecycle.Event.ON_DESTROY) {
                launcher.unregister()
                lifecycle.removeObserver(this)
            }
        }
    })
    launcher.launch(permissions)
}

6. 总结

其实很多实用技巧本质上都是很简单的,但没有接触过就很难想到,我将我的开发经验分享给大家,希望能帮助到大家。

收起阅读 »

Java序列化

Java序列化是一种将对象转换为字节流的过程,使得对象可以在网络传输、持久化存储或跨平台应用中进行传递和重建的技术。它允许将对象以二进制的形式表示,并在需要时重新创建相同的对象。Java序列化使用java.io.Serializable接口来标记可序列化的类。...
继续阅读 »

Java序列化是一种将对象转换为字节流的过程,使得对象可以在网络传输、持久化存储或跨平台应用中进行传递和重建的技术。它允许将对象以二进制的形式表示,并在需要时重新创建相同的对象。

Java序列化使用java.io.Serializable接口来标记可序列化的类。被标记为可序列化的类必须实现该接口,并且不包含非可序列化的成员变量(如果存在非可序列化的成员变量,可以通过关键字transient将其排除在序列化过程之外)。

以下是一个简单的Java序列化示例:

import java.io.*;

class Person implements Serializable {
    private String name;
    private int age;

    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public String getName() {
        return name;
    }

    public int getAge() {
        return age;
    }
}

public class SerializationExample {
    public static void main(String[] args) {
        // 创建一个Person对象
        Person person = new Person("John Doe"30);

        // 将对象序列化到文件
        try (FileOutputStream fileOut = new FileOutputStream("person.ser");
             ObjectOutputStream out = new ObjectOutputStream(fileOut)) {
            out.writeObject(person);
            System.out.println("Serialized data is saved in person.ser");
        } catch (IOException e) {
            e.printStackTrace();
        }

        // 从文件中反序列化对象
        try (FileInputStream fileIn = new FileInputStream("person.ser");
             ObjectInputStream in = new ObjectInputStream(fileIn)) {
            Person deserializedPerson = (Person) in.readObject();
            System.out.println("Deserialized person: " + deserializedPerson.getName() +
                    ", Age: " + deserializedPerson.getAge());
        } catch (IOException | ClassNotFoundException e) {
            e.printStackTrace();
        }
    }
}

在上面的示例中,我们创建了一个名为Person的可序列化类,并在SerializationExample类中进行序列化和反序列化操作。首先,我们将Person对象写入文件person.ser中,然后从该文件中读取并反序列化为新的Person对象。

值得注意的是,被序列化的类必须存在相应的类定义,以便在反序列化时正确重建对象。如果序列化和反序列化使用不同版本的类,可能会导致版本不匹配的错误,因此需要小心处理类的版本控制。

此外,还可以通过实现java.io.Externalizable接口来自定义序列化过程,以更精确地控制序列化和反序列化的行为。

Serializable 接口的工作原理

Serializable 是 Java 中用于实现对象序列化的接口。当一个类实现了 Serializable 接口后,它的对象就可以被序列化为字节流,以便在网络传输或持久化存储中使用。

实现 Serializable 接口的类并不需要显式地定义任何方法,而是作为一个标记接口,表示该类的对象可以被序列化。Java 的序列化机制会根据对象的结构自动将其转换为字节序列。

以下是 Serializable 接口的工作原理:

  1. 序列化过程: 当一个对象被序列化时,Java 将其内部状态(也就是对象的字段)转换为字节流。这个过程称为对象的序列化。序列化过程从对象的根开始,递归地处理对象的所有字段,并将它们转换为字节流。

  2. 对象图: 在序列化过程中,Java 会创建一个对象图,表示对象之间的关系。对象图包括所有需要被序列化的对象及其字段。如果一个对象引用了其他对象,那么被引用的对象也会被序列化,并在对象图中保留其引用关系。

  3. 字段序列化: 对象的每个字段都被独立地序列化。基本类型和字符串直接转换为对应的字节表示形式,而引用类型(如其他对象)则按照相同的序列化过程递归地处理。

  4. transient 关键字: 通过使用 transient 关键字,可以指定某个字段不参与序列化过程。被标记为 transient 的字段在序列化过程中被忽略,不会转换为字节流。

  5. 序列化的结果: 序列化过程完成后,Java 将对象及其字段转换为字节数组,并将其存储到文件、数据库或通过网络传输。

  6. 反序列化过程: 反序列化是序列化的逆过程。在反序列化过程中,Java 会根据字节流恢复对象的状态。它会逐个字段地读取字节流,并创建对应类型的对象。如果字段是引用类型,则会递归地进行反序列化,直至还原整个对象图。

需要注意的是,当一个类实现 Serializable 接口后,它的所有非瞬态(non-transient)字段都会被默认序列化。因此,在序列化类时,需要确保所有的字段都是可序列化的,否则会抛出 NotSerializableException 异常。

Serializable接口对性能影响

在Java中,使用Serializable接口进行对象的序列化和反序列化会对性能产生一定的影响。以下是一些与性能相关的考虑:

  1. 序列化开销:将对象转换为字节序列需要一定的时间和计算资源。这个过程涉及到将对象的状态写入到字节流中,包括对象的字段和其他相关信息。因此,如果需要频繁地序列化大型对象或大量对象,可能会对性能造成一定的影响。

  2. 序列化文件大小:序列化后的字节流通常比对象本身要大。这是因为序列化时会包含一些元数据、字段名称以及其他必要的信息。如果需要存储大量的序列化对象,可能会占用更多的磁盘空间。

  3. 反序列化性能:将字节序列转换回对象的过程也需要一定的时间和计算资源。反序列化涉及将字节流恢复为对象的状态,并创建新的对象实例。如果需要频繁地反序列化大量对象,也可能会对性能产生一定的影响。

  4. 序列化版本控制:在使用Serializable接口进行对象序列化时,需要注意对象的版本控制。如果在序列化和反序列化过程中发生了类的修改,可能会导致版本不匹配的问题。这可能需要额外的处理来确保兼容性,并可能影响性能。

总的来说,对于大多数应用程序而言,使用Serializable接口进行对象序列化并不会对性能产生显著的影响。然而,在某些特定情况下(如需要频繁地序列化大型对象或需要高性能的实时系统),可能需要考虑其他序列化方案或优化策略来满足性能需求。

收起阅读 »

ES6的module语法中export和import的使用

ES6
ES6模块与CommonJS模块的差异ES6 模块与 CommonJS 模块完全不同 它们有三个重大差异CommonJS 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用CommonJS 模块是运行时加载,ES6 模块是编译时输出接口CommonJS 模...
继续阅读 »

ES6模块与CommonJS模块的差异

ES6 模块与 CommonJS 模块完全不同 它们有三个重大差异

  • CommonJS 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用
  • CommonJS 模块是运行时加载,ES6 模块是编译时输出接口
  • CommonJS 模块的 require() 是同步加载模块,ES6 模块的 import 命令是异步加载,有一个独立的模块依赖的解析阶段 第二个差异是 CommonJS 加载的是一个对象(即 module.exports 属性),该对象只有在脚本运行完才会生成,而 ES6 模块不是对象,它的对外接口只是一种静态定义,在代码静态解析阶段就会生成。

CommonJS 模块是 Node.js 专用的,语法上面,与 ES6 模块最明显的差异是,CommonJS 模块使用 require() 和 module.exports ,ES6 模块使用 import 和 export

ES6 中 module 的语法

ES6 模块的设计思想是尽量的静态化,使得编译时就能确定模块的依赖关系,以及输入和输出的变量。 ES6模块不是对象,而是通过 export 命令显式指定输出的代码,再通过 import 命令输入。

export 命令

模块功能主要由两个命令构成:export 和 import。 export 命令用于规定模块的对外接口,import 命令用于输入其他模块提供的功能。 一个模块就是一个独立的文件。该文件内部的所有变量,外部无法获取。如果希望外部能够读取模块内部的某个变量,就必须使用 export 关键字输出该变量。

export 输出变量

// export.js
let firstName = 'Mark'
let lastName = 'Dave'

export { firstName, lastName }

上面代码在 export 命令后面,使用大括号指定所要输出的一组变量。

export 输出函数或类

export 命令除了输出变量,还可以输出函数或类(class)

export function multiply(x, y{
 return x * y
}

上面代码对外输出一个函数 `multiply

export使用as重命名

通常情况下,export 输出的变量是本来的名字,但是可以使用 as 关键字重命名

function fun1({ ... }
function fun2({ ... }

export {
 fun1 as streamFun1,
 fun2 as streamFun2,
 fun2 as streamLatestFun 
}

上面代码使用 as 关键字,重命名了函数 fun1 和 fun2 的对外接口,重命名后,fun2可以用不同的名字输出两次

export 规定的对外接口,必须与模块内部的变量一一对应

export 命令规定的是对外的接口,必须与模块内部的变量建立一一对应关系

// 报错
export 1;

// 报错
let m = 1
export m;

上面两种写法都会报错,因为没有提供对外的接口。第一种写法直接输出1,第二种写法通过变量 m,还是直接输出1,1只是一个值,不是接口。正确的写法如下:

// 写法1
export let m = 1;

// 写法2
let m = 1
export { m }

// 写法3
let n = 2
export { n as m }

上面三种写法都是正确的,规定了对外的接口 m。其他脚本可以通过这个接口,取到值 1.它们的实质是,在接口名和模块内部变量之间,建立了一一对应的关系。

同样,function 和 class 的输出,也必须同样遵守这样的写法

// 报错
function f({}
export f

// 正确
function f({}
export { f }

// 正确
export function f({}

export可以出现在模块的任何位置,只要处于模块顶层就可以

export 命令可以出现在模块的任何位置,只要处于模块顶层就可以。如果处于块级作用域内,就会报错

function foo({
 export default 'bar' // SyntaxError
}
foo()

上面代码中,export 语句放在函数之中,结果报错

export default 命令

使用 import 命令的时候,用户需要知道所要加载的变量名或函数名,否则无法加载。但是,用户肯定希望快速上手,未必愿意阅读文档,去了解模块有哪些属性和方法。 为了给用户提供方便,让它们不用阅读文档就能加载模块,就要用到 export default 命令,为模块指定默认输出。

// export-default.js
export default function({
 console.log('foo')
}

上面代码是一个模块文件 export-default.js ,它默认输出是一个函数。 其他模块加载该模块时,import 命令可以为该匿名函数指定任意名字。

// import-default.js
import customName from './export-default'
customName()

上面代码的 import 命令,可以用任意名称指向 export-default.js 输出的方法,这时就不需要知道原模块输出的函数名。需要注意的是,这时 import 命令后面,不使用大括号。 export default 命令用在非匿名函数前,也是可以的

export default function foo({
 console.log('foo')
}

// 或者写成
function foo({
 console.log('foo')
}
export default foo

上面代码中,foo 函数的函数名 foo ,在模块外部是无效的。加载的时候,视同匿名函数加载。 下面比较一下默认输出和正常输出:

// 第一组
export default function crc32({}

import crc32 from 'crc32'

// 第二组
export function crc32({}

import { crc32 } from 'crc32'

上面两组写法,第一组是使用 export default 时,对应的 import 语句不需要使用大括号;第二组是不使用 export default 时,对应的 import 语句需要使用大括号。

export default 命令用于指定模块的默认输出。显然,一个模块只能有一个默认输出,因此 export default 命令只能使用一次。所以,import 命令后面才不用加大括号,因为只可能唯一对应 export default 命令。 本质上,export default 就是输出一个叫做 default 的变量或方法,然后系统允许你为它取任意名字。所以,下面的写法是有效的。

// module.js
function add(x, y{
 return x + y
}
export { add as default }
// 等同于
export default add

// main.js
import { default as foo } from 'modules'
// 等同于
import foo from 'modules'

正是因为 export default 命令其实只是输出一个叫做 default 的变量,所以它后面不能跟变量声明语句。

// 正确
export let a = 1

// 正确
let a = 1
export default a

// 错误
export default let a = 1

上面代码中,export default a 的含义是将变量 a 的值赋值给变量 default。所以,最后一种写法会报错。 同样地,因为 export default 命令的本质是将后面的值,赋给 default 变量,所以可以直接将一根值写在 rcport default 之后

// 正确
export default 42

// 报错
export 42

如果想在一条 import 语句中,同时输入默认方法和其他接口,可以写成下面这样

import _, { each, forEach } from 'lodash'

export default 也可以用来输出类

// MyClass.js
export default class { ... }

// main.js
import MyClass from 'MyClass'
let o = new MyClass()

import 命令

使用 export 命令定义了模块的对外接口后,其他 js 文件就可以通过 import 命令加载这个模块。

// import.js
import { firstName, lastName } from './export.js'

function setName(element{
 element.textContent = firstName + ' ' + lastName
}

上面代码的 import 命令,用于加载 export.js 文件,并从中输入变量。import 命令接受一对大括号,里面指定要从其他模块导入的变量名。大括号里面的变量名,必须与被导入模块 export.js 对外借款的名称相同

import 使用 as 重命名变量

如果想为输入的变量重新取一个名字,import 命令要使用 as 关键字,将输入的变量重命名。

import { lastName } as surname from './export.js'

import`命令输入的变量都是只读的

import 命令输入的变量都是只读的,,因为它的本质是输入接口。也就是说,不允许在加载模块的脚本里面,改写接口。

import { a } from './xxx.js
a = {} // Syntax Error: '
a' is read-only

上面代码中,脚本加载了变量 a,对其重新赋值就会报错,因为 a 是一个只读的接口,但是,如果 a 是一个对象,改写 a 的属性是允许的

import { a } from './xxx.js'

a.foo = 'hello' // 合法操作

上面代码中,a 的属性可以改写成功,并且其他模块也可以读到改写后的值,不过,这种写法很难查错,建议凡是输入的变量,丢完全当做只读,不要轻易改变它的属性。

import 后面的 from 指定模块文件的位置,可以是相对路径,也可以是绝对路径。如果不带有路径,只是一个模块名,那么必须有配置文件,告诉 javascript 引擎该模块的位置。

import 具有提升效果

import 命令具有提升效果,会提升到整个模块的头部,首先执行

foo()

import { foo } from 'my_module'

上面的代码不会报错,因为 import 的执行早于 foo 的调用。这种行为的本质是,import 命令是编译阶段执行的,在代码运行之前。

import 是静态执行,不能使用表达式和变量

由于 import 是静态执行,所以不能使用表达式和变量,这些只有在运行时才能得到结果的语法结构

// 报错
import { 'f' + 'oo' } from ''my_module

// 报错
let module = 'my_module'
import { foo } from module

// 报错
if (x === 1) {
 import { foo } from 'module1'
else {
 import { foo } from 'module2'
}

上面三种写法都会报错,因为它们用到了表达式、变量和 if 结构。在静态分析阶段,这些语法是没法得到值的。 最后,import 语句会执行所加载的模块,因此可以有下面的写法:

import 'lodash'

上面代码仅仅执行 lodash 模块,但是不输入任何值。 如果多次重复执行同一句 import语句,那么只会执行一次,而不会执行多次。

import 'lodash'
import 'lodash'

上面代码加载了两次 lodash,但是只会执行一次

import { foo } from 'my_module'
import { bar } from 'my_module'

// 等同于
import { foo, bar } from 'my_module'

上面代码中,虽然 foo 和 bar 在两个语句中加载,但是它们对应的是同一个 my_module模块,也就是说,import 语句是singleton 模式。

模块的整体加载

除了指定加载某个输出值,还可以使用整体加载,即用星号(*)指定一个对象,所有输出值都加载在这个对象上。 例如,下面是一个 circle.js 文件,它输出两个方法 area 和 circum

// circle.js
export function area(radius{
 return Math.PI * radius * radius
}
export function circum(radius{
 return @ * Math.PI * radius
}

现在,加载这个模块

// main.js
import {area, circum} from './circle'

console.log(area(4))
console.log(circum(14))

上面写法是逐一指定要加载的方法,整体加载的写法如下。

import * as circle from './circle'

console.log(circle.area(4))
console.log(circle.circum(14))

注意:模块整体加载所在的那个对象(上例是 circle),应该是可以静态分析的,所以不允许运行时改变。下面的写法都是不允许的。

import * as circle from './circle'

// 下面两行都是不允许的
circle.foo = 'hello'
circle.area = function ({}

export 和 import 的复合写法

如果在一根模块之中,先输入后输出同一个模块,import 语句可以与 export 语句写在一起。

export { foo, bar } from 'my_module'

// 可以简单理解为
import { foo, bar } from 'my_module'
export { foo, bar }

上面代码中,export 和 import 语句可以结合在一起,写成一行。但需要注意的是,写成一行以后,foo 和 bar 实际上并没有被导入当前模块,只是相当于对外转发了这两个接口,导致当前模块不能直接使用 foo 和 bar。 模块的接口改名和整体输出,也可以采用这种写法

// 接口改名
export { foo as myFoo } from 'my_module'

// 整体输出
export * from 'my_module'

默认接口的写法如下:

export { default } from 'foo'

具名接口改为默认接口的写法如下

export { es6 as default } from './someModule'

// 等同于
import { es6 } from './someModule'
export default es6

同样的,默认接口也可以改名为具名接口

export { default as es6 } from './someModule'

import()

import 和 export 命令只能在模块的顶层,不能在代码块之中(比如,在 if 代码块之中,或在函数之中) ES2020提案引入 import() 函数,支持动态加载模块

import(specifier)

上面代码中,import 函数的参数 specifier,指定所要加载的模块的位置,import 命令能够接受上面参数,import() 函数就能接受上面参数,两者区别主要是后者为动态加载。 import() 返回一个 Promise 对象。如

const main = document.querySelector('main');

import(`./section-modules/${someVariable}.js`)
  .then(module => {
    module.loadPageInto(main);
  })
  .catch(err => {
    main.textContent = err.message;
  });

import() 函数可以用在任何地方,不仅仅是模块,非模块的脚本也可以使用。它是运行时执行,也就是说,什么时候运行到这一句,就会加载指定的模块。另外,import() 函数与所加载的模块没有静态连接关系,这点也是与 import 语句不相同。import() 类似于 Node 的 require 方法,区别主要是前者是异步加载,后者是同步加载。

适用场合

按需加载

import() 可以在需要的时候,再加载某个模块

button.addEventListener('click', event => {
  import('./dialogBox.js')
  .then(dialogBox => {
    dialogBox.open();
  })
  .catch(error => {
    /* Error handling */
  })
});

上面代码中,import() 方法放在 click 事件的监听函数中,只有用户点击了按钮,才会加载这个模块。

条件加载

import() 可以放在 if 代码块,根据不同的情况,加载不同的模块

if (condition) {
  import('moduleA').then(...);
else {
  import('moduleB').then(...);
}

上面代码中,如果满足条件,就加载模块 A,否则加载模块 B

动态的模块路径

import() 允许模块路径动态生成

import(f())
.then(...)

上面代码中,根据函数 f 的返回结果,加载不同的模块。

注意点

import() 加载模块成功以后,这个模块会作为一个对象,当作 then 方法的参数。因此,可以使用对象结构赋值的语法,获取输出接口

import('./myModule.js')
.then({export1, export2}) => {
 // ...
})

上面代码中,export1 和 export2 都是 myModule.js 的输出接口,可以解构获得 如果想同时加载多个模块,可以

Promise.all([
  import('./module1.js'),
  import('./module2.js'),
  import('./module3.js'),
])
.then(([module1, module2, module3]) => {
   ···
});

import() 也可以用在 async 函数中

async function main({
  const myModule = await import('./myModule.js');
  const {export1, export2} = await import('./myModule.js');
  const [module1, module2, module3] =
    await Promise.all([
      import('./module1.js'),
      import('./module2.js'),
      import('./module3.js'),
    ]);
}
main();



收起阅读 »

Flutter 状态组件 InheritedWidget

前言今天会讲下 inheritedWidget 组件,InheritedWidget 是 Flutter 中非常重要和强大的一种 Widget,它可以使 Widget 树中的祖先 Widget 共享数据给它们的后代 Widget,从而简化了状态管理和数据传递的...
继续阅读 »

前言

今天会讲下 inheritedWidget 组件,InheritedWidget 是 Flutter 中非常重要和强大的一种 Widget,它可以使 Widget 树中的祖先 Widget 共享数据给它们的后代 Widget,从而简化了状态管理和数据传递的复杂性,提高了代码的可读性、可维护性和性能。

Provider 就是对 inheritedWidget 的高度封装

https://github.com/rrousselGit/provider/tree/54af320894e3710b8fad2ae3bb4a6ea0e5aba13e/resources/translations/zh-CN

Flutter_bloc 也是这样

https://github.com/felangel/bloc/blob/cef8418a24b916f439f747e2b0c920ee50b8bd18/docs/zh-cn/faqs.md?plain=1#L133

Flutter_bloc 中确实有 provider 的引用

https://github.com/felangel/bloc/blob/cef8418a24b916f439f747e2b0c920ee50b8bd18/packages/flutter_bloc/pubspec.yaml

如果你只是想简单的状态管理几个全局数据,完全可以轻巧的使用 inheritedWidget 。

今天就来讲下如何使用和要注意的地方。

原文 https://ducafecat.com/blog/flutter-inherited-widget

参考

https://api.flutter.dev/flutter/widgets/InheritedWidget-class.html

状态管理

在 Flutter 中,状态管理是指管理应用程序的数据和状态的方法。在应用程序中,有许多不同的组件和部件,它们可能需要在不同的时间点使用相同的数据。状态管理的目的是使这些数据易于访问和共享,并确保应用程序的不同部分保持同步。

在 Flutter 中,有不同的状态管理方法可供选择,包括:

  1. StatefulWidget 和 State:StatefulWidget 允许你创建有状态的部件,而 State 则允许你管理该部件的状态。这是 Flutter 中最基本和最常用的状态管理方法。
  2. InheritedWidget:InheritedWidget 允许你共享数据和状态,并且可以让子部件自动更新当共享的数据发生变化时。
  3. Provider:Provider 是一个第三方库,它基于 InheritedWidget,可以更方便地管理应用程序中的状态。
  4. Redux:Redux 是一个流行的状态管理库,它基于单一数据源和不可变状态的概念,可以使状态管理更加可预测和易于维护。
  5. BLoC:BLoC 是一个基于流的状态管理库,它将应用程序状态分为输入、输出和转换。它可以使应用程序更清晰和可测试。
  6. GetX: GetX 是一个流行的 Flutter 状态管理和路由导航工具包,它提供了许多功能,包括快速且易于使用的状态管理、依赖注入、路由导航、国际化、主题管理等。是由社区开发和维护的第三方工具包。

步骤

第一步:用户状态 InheritedWidget 类

lib/states/user_profile.dart

// 用户登录信息
class UserProfileState extends InheritedWidget {
  ...
}

参数

  const UserProfileState({
    super.key,
    required this.userName,
    required this.changeUserName,
    required Widget child, // 包含的子节点
  }) : super(child: child);

  /// 用户名
  final String userName;

  /// 修改用户名
  final Function changeUserName;

of 方法查询,依据上下文 context

  static UserProfileState? of(BuildContext context) {
    final userProfile =
        context.dependOnInheritedWidgetOfExactType<UserProfileState>();

    // 安全检查
    assert(userProfile != null'No UserProfileState found in context');

    return userProfile;
  }

需要做一个 userProfile 空安全检查

重写 updateShouldNotify 通知更新规则

  @override
  bool updateShouldNotify(UserProfileState oldWidget) {
    return userName != oldWidget.userName;
  }

如果用户名发生改变进行通知

第二步:头部底部组件 StatelessWidget

lib/widgets/header.dart

class HeaderWidget extends StatelessWidget {
  const HeaderWidget({super.key});

  @override
  Widget build(BuildContext context) {
    String? userName = UserProfileState.of(context)?.userName;

    return Container(
      width: double.infinity,
      decoration: BoxDecoration(
        border: Border.all(color: Colors.blue),
      ),
      child: Text('登录:$userName'),
    );
  }
}

通过 String? userName = UserProfileState.of(context)?.userName; 的方式

读取状态数据 userName

lib/widgets/bottom.dart

class BottomWidget extends StatelessWidget {
  const BottomWidget({super.key});

  @override
  Widget build(BuildContext context) {
    String? userName = UserProfileState.of(context)?.userName;

    return Container(
      width: double.infinity,
      decoration: BoxDecoration(
        border: Border.all(color: Colors.blue),
      ),
      child: Text('登录:$userName'),
    );
  }
}

第三步:用户组件 StatefulWidget

lib/widgets/user_view.dart

class UserView extends StatefulWidget {
  const UserView({super.key});

  @override
  State<UserView> createState() => _UserViewState();
}

class _UserViewState extends State<UserView{
  ...

成员变量

class _UserViewState extends State<UserView{
  String? _userName;

重新 didChangeDependencies 依赖函数更新数据

  @override
  void didChangeDependencies() {
    _userName = UserProfileState.of(context)?.userName;
    super.didChangeDependencies();
  }

通过 UserProfileState.of(context)?.userName; 的方式读取

build 函数

  @override
  Widget build(BuildContext context) {
    return Container(
      width: double.infinity,
      decoration: BoxDecoration(
        border: Border.all(color: Colors.purple),
      ),
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          Text('用户名:$_userName'),
          ElevatedButton(
            onPressed: () {
              // 随机 10 个字母
              String randomString = String.fromCharCodes(
                List.generate(
                  10,
                  (index) => 97 + Random().nextInt(26),
                ),
              );

              // 改变用户名
              UserProfileState.of(context)?.changeUserName(randomString);
            },
            child: const Text('改变名称'),
          ),
        ],
      ),
    );
  }

randomString 是一个随机的 10 个字母

通过 UserProfileState.of(context)?.changeUserName(randomString); 的方式触发函数,进行状态更改。

最后:页面调用 AppPage

lib/page.dart

class AppPage extends StatefulWidget {
  const AppPage({super.key});

  @override
  State<AppPage> createState() => _AppPageState();
}

class _AppPageState extends State<AppPage{
  ...

成员变量

class _AppPageState extends State<AppPage{
  String _userName = '未登录';

给了一个 未登录 的默认值

修改用户名函数

  // 修改用户名
  void _changeUserName(String userName) {
    setState(() {
      _userName = userName;
    });
  }

build 函数

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('InheritedWidget'),
      ),
      body: UserProfileState(
        userName: _userName,
        changeUserName: _changeUserName,
        child: SafeArea(
          child: Column(
            children: const [
              // 头部
              HeaderWidget(),

              // 正文
              Expanded(child: UserView()),

              // 底部
              BottomWidget(),
            ],
          ),
        ),
      ),
    );
  }

可以发现 UserProfileState 被套在了最外层,当然还有 Scaffold 。

包裹的子组件有:HeaderWidget、BottomWidget、UserView

状态过程如下:

  1. UserView 触发 _changeUserName 修改用户名
  2. _userName 改变的数据压入 UserProfileState
  3. UserProfileState 触发 updateShouldNotify
  4. 组件 didChangeDependencies 被触发
  5. 最后子成员组件更新成功

代码

https://github.com/ducafecat/flutter_develop_tips/tree/main/flutter_application_inherited_widget

小结

在 Flutter 中,InheritedWidget 是一种特殊的 Widget,它允许 Widget 树中的祖先 Widget 共享数据给它们的后代 Widget,而无需通过回调或参数传递数据。下面是 InheritedWidget 的一些主要作用和好处:

  1. 共享数据:InheritedWidget 允许祖先 Widget 共享数据给它们的后代 Widget,这使得在 Widget 树中传递数据变得更加容易和高效。这种共享数据的方式避免了回调和参数传递的复杂性,使得代码更加简洁易懂。
  2. 自动更新:当共享的数据发生变化时,InheritedWidget 会自动通知它的后代 Widget 进行更新,这使得状态管理变得更加容易。这种自动更新的方式避免了手动管理状态的复杂性,使得代码更加健壮和易于维护。
  3. 跨 Widget 树:InheritedWidget 可以跨 Widget 树共享数据,这使得在应用程序中不同模块之间传递数据变得更加容易。这种跨 Widget 树的共享方式避免了在不同模块之间传递数据时的复杂性,使得代码更加模块化和易于扩展。
  4. 性能优化:InheritedWidget 可以避免不必要的 Widget 重建,从而提高应用程序的性能。当共享的数据没有发生变化时,InheritedWidget 不会通知后代 Widget 进行更新,这避免了不必要的 Widget 重建,提高了应用程序的性能。



收起阅读 »

未来前端框架会如何卷?

前端框架在过去几年间取得了显著的进步和演进。前端框架也将继续不断地演化,以满足日益复杂的业务需求和用户体验要求。从全球web发展角度看,框架竞争已经从第一阶段的前端框架之争(比如Vue、React、Angular等),过渡到第二阶段的框架之争(比如Next、N...
继续阅读 »

前端框架在过去几年间取得了显著的进步和演进。前端框架也将继续不断地演化,以满足日益复杂的业务需求和用户体验要求。从全球web发展角度看,框架竞争已经从第一阶段的前端框架之争(比如Vue、React、Angular等),过渡到第二阶段的框架之争(比如Next、Nuxt、Remix、小程序等)。

组件化开发的持续推进

前端框架的组件化开发将继续成为主流趋势。Vue、React和Angular等成熟框架早已以其优秀的组件化机制著称。未来,这些框架将不断改进组件系统,使组件之间的交互更加灵活、高效,进一步提高开发效率和应用性能。例如,React框架在最近的更新中引入了Suspense机制,让组件的异步加载更加容易和优雅。而小程序框架也将引入更强大的组件化开发机制,使小程序开发更易维护、易扩展。

案例:一个电商企业正在使用Vue框架开发其前端应用。在该应用中,商品展示、购物车、订单结算等功能都被抽象为可复用的组件。这样一来,开发者可以在不同的页面中重复使用这些组件,大大提高了开发效率。同时,当某个功能需要更新或修复时,只需在对应的组件中进行修改,便可以在整个应用中生效,保持了应用的一致性。

更强调性能优化和打包体积

性能优化和打包体积将成为前端框架发展的重点。优化算法和编译工具的不断改进将帮助开发者减少应用的加载时间,提高用户体验。例如,Next.js框架已经内置了自动代码分割和服务端渲染,有效减少了首屏加载时间,使得用户更快地看到页面内容。

案例:一个新闻媒体网站采用了Nuxt.js框架来优化其前端性能。Nuxt.js的服务端渲染功能允许该网站在服务器端生成静态页面,这大大减少了浏览器渲染的工作量。结果,网站的加载速度得到显著提升,用户可以更快地浏览新闻内容,提高了用户留存率和转化率。

深度集成TypeScript

TypeScript作为一种静态类型语言,已经在前端开发中得到广泛应用。未来前端框架将深度集成TypeScript,提供更完善的类型支持和智能提示,减少潜在的Bug,并提升代码的可维护性。例如,Vue框架已经提供了对TypeScript的原生支持,使得开发者可以使用TypeScript编写Vue组件,并获得更强大的类型检查和代码提示。

案例:一家科技公司决定将其现有的JavaScript项目迁移到TypeScript。在迁移过程中,开发团队发现许多隐藏的类型错误,并通过TypeScript提供的类型检查机制及时修复了这些问题。这使得代码质量得到了大幅提升,并为未来的项目维护奠定了良好的基础。

强调用户体验和可访问性

用户体验和可访问性将继续是前端开发的关键词。框架将注重提供更好的用户体验设计,以及更高的可访问性标准,使得应用能够更好地适应不同用户的需求,包括残障用户。例如,React框架支持ARIA(Accessible Rich Internet Applications)标准,使得开发者可以为特殊用户群体提供更好的使用体验。

案例:一家在线教育平台在开发过程中注重可访问性,确保所有用户都能轻松访问其教育内容。平台使用了语义化的HTML标签、ARIA属性以及键盘导航功能,使得视障用户和键盘操作用户也能流畅使用平台。这使得平台在用户中建立了良好的声誉,吸引了更多的用户参与学习。

跨平台开发的融合

前端框架将更加注重跨平台开发的融合。Vue、React等主流框架将提供更便捷的方法,让开发者可以更轻松地将Web应用扩展到其他平台上。例如,React Native框架允许开发者使用React的语法和组件来构建原生移动应用,这使得前端开发者可以在不学习原生开发语言的情况下,快速构建跨平台的移动应用。

这些轻量化前端开发框架也可以与小程序开发相结合,从而提高小程序的开发效率和性能。

在小程序开发中,通常需要使用一些类似于组件化的开发模式,以便更好地管理页面和数据。这些轻量化前端开发框架中,例如 Vue.js 和 React,已经采用了类似于组件化的开发模式,因此可以更好地适应小程序的开发需求。

除此之外,这些轻量化前端开发框架还提供了许多工具和插件,可以帮助开发人员更快地开发小程序。例如,Vue.js 提供了 Vue-CLI 工具,可以快速创建小程序项目和组件;React 提供了 React Native 工具,可以使用类似于 React 的语法开发原生应用程序。这些工具和插件使得小程序开发更加高效和便捷。

1、使用小程序开发框架

类似于 Vue.js 和 React,这些框架可以通过使用小程序框架的渲染层和逻辑层 API,来提高小程序的性能和开发效率。例如,可以使用微信小程序框架和 Vue.js 一起开发小程序,通过引入 mpvue-loader 库来实现 Vue.js 和小程序的整合。

mpvue基于Vue.js核心,修改了Vue.js的 runtime 和 compiler 实现,使其可以运行在小程序环境中。mpvue 支持使用 Vue.js 的大部分特性,如组件、指令、过滤器、计算属性等,同时也支持使用 npm、webpack 等工具来构建项目。mpvue 还提供了一些扩展 API 和插件机制,以适应小程序的特殊需求。

2、使用跨平台开发工具

跨平台开发工具可以让开发人员使用一套代码来同时开发小程序、Web 应用和原生应用。例如,使用 React Native 可以通过 JavaScript 来开发原生应用程序和小程序,同时提高了开发效率和性能。

3、小程序组件库

一些小程序组件库,例如 WeUI 和 Vant,提供了许多常用的 UI 组件和功能,可以帮助开发人员快速地构建小程序页面。这些组件库还可以与 Vue.js 和 React 等轻量化前端开发框架相结合,提高小程序的开发效率和性能。

进一步提升应用价值

Vue 和小程序本质上是两个不同的技术栈,Vue 是一个前端框架,而小程序基于微信语法和规则。由于两者的编程模型和运行环境有很大的差异,因此不能直接将 Vue 代码打包为小程序的。

但可以通过使用小程序开发框架,例如 Taro、Mpvue 和 uni-app,可以将 Vue.js 和 React 等前端框架的开发方式与小程序相结合。这些框架可以将前端框架的语法和特性转换为小程序的语法和特性,从而使得开发人员可以使用熟悉的开发方式来开发小程序。

这里还要推荐一个深化发挥小程序价值的途径,直接将现有的小程序搬到自有 App 中进行运行,这种实现技术路径叫做小程序容器,例如 FinClip SDK 是通过集成 SDK 的形式让自有的 App 能够像微信一样直接运行小程序。

这样一来不仅可以通过前端框架提升小程序的开发效率,还能让小程序运行在微信以外的 App 中,真正实现了一端开发多端上架,另外由于小程序是通过管理后台上下架,相当于让 App 具备热更新能力,避免 AppStore 频繁审核。

最后

综上所述,未来前端框架的发展将持续聚焦在组件化开发、性能优化和打包体积、跨平台开发、小程序框架的崛起、深度集成TypeScript、用户体验和可访问性、全球化和国际化等方向。通过不断地创新和改进,前端框架将推动Web应用开发的进步,为用户提供更好的使用体验和开发者更高效的开发体验。开发者们应密切关注各个框架的更新和改进,以紧跟技术的脚步,为未来的Web应用开发做好准备。

收起阅读 »

面试必备:Android 常见内存泄漏问题盘点

1. 前言当我们开发安卓应用时,性能优化是非常重要的一个方面。一方面,优化可以提高应用的响应速度、降低卡顿率,从而提升用户体验;另一方面,优化也可以减少应用的资源占用,提高应用的稳定性和安全性,降低应用被杀死的概率,从而提高用户的满意度和留存率。但是,对于许多...
继续阅读 »

1. 前言

当我们开发安卓应用时,性能优化是非常重要的一个方面。一方面,优化可以提高应用的响应速度、降低卡顿率,从而提升用户体验;另一方面,优化也可以减少应用的资源占用,提高应用的稳定性和安全性,降低应用被杀死的概率,从而提高用户的满意度和留存率。

但是,对于许多开发者来说,安卓性能优化往往是一个比较棘手的问题。因为性能优化包罗万象,涉及的知识面也比较多,而内存泄露是最常见的一类性能问题,也是各类面试题中的常客,因此了解内存泄漏是每个安卓开发者应该具备的进阶技能。

本文就带大家盘点常见的内存泄漏问题。

2. 内存泄漏的本质

内存泄漏的本质就是对象引用未释放,当对象被创建时,如果没有被正确释放,那么这些对象就会一直占用内存,直到应用程序退出。例如,当一个Activity被销毁时,如果它还持有其他对象的引用,那么这些对象就无法被垃圾回收器回收,从而导致内存泄漏

当存在内存泄漏时,我们需要通过GCRoot来识别内存泄漏的对象和引用。

GCRoot是垃圾回收机制中的根节点,根节点包括虚拟机栈、本地方法栈、方法区中的类静态属性引用、活动线程等,这些对象被垃圾回收机制视为“活着的对象”,不会被回收。

当垃圾回收机制执行时,它会从GCRoot出发,遍历所有的对象引用,并标记所有活着的对象,未被标记的对象即为垃圾对象,将会被回收。

当存在内存泄漏时,垃圾回收机制无法回收一些已经不再使用的对象,这些对象仍然被引用,形成了一些GCRoot到内存泄漏对象的引用链,这些对象将无法被回收,导致内存泄漏。

通过查找内存泄漏对象和GCRoot之间的引用链,可以定位到内存泄漏的根源,进而解决内存泄漏问题,LeakCancry就是通过这个机制实现的。

一些常见的GCRoot包括:

  • 虚拟机栈(Local Variable)中引用的对象。
  • 方法区中静态属性(Static Variable)引用的对象。
  • JNI 引用的对象。
  • Java 线程(Thread)引用的对象。
  • Java 中的 synchronized 锁持有的对象。

什么情况会造成对象引用未释放呢?简单举几个例子:

  • 匿名内部类造成的内存泄漏:匿名内部类通常会持有外部类的引用,如果外部类的生命周期比匿名内部类长,(更正一下,这里用生命周期不太恰当,当外部类被销毁时,内部类并不会自动销毁,因为内部类并不是外部类的成员变量,它们只是在外部类的作用域内创建的对象,所以内部类的销毁时机和外部类的销毁时机是不同的,所以会不会取决与对应对象是否存在被持有的引用)那么就会导致外部类无法被回收,从而导致内存泄漏。

  • 静态变量持有Activity或Context的引用:如果一个静态变量持有Activity或Context的引用,那么这些Activity或Context就无法被垃圾回收器回收,从而导致内存泄漏。

  • 未关闭的Cursor、Stream或者Bitmap对象:如果程序在使用Cursor、Stream或者Bitmap对象时没有正确关闭这些对象,那么这些对象就会一直占用内存,从而导致内存泄漏。

  • 资源未释放:如果程序在使用系统资源时没有正确释放这些资源,例如未关闭数据库连接、未释放音频资源等,那么这些资源就会一直占用内存,从而导致内存泄漏。

接下来我们通过代码示例看一下各种常见内存泄露以及如何避免相关问题的最佳实践

3. 静态引用导致的内存泄漏

当一个对象被一个静态变量持有时,即使这个对象已经不再使用,也不会被垃圾回收器回收,这就会导致内存泄漏

public class MySingleton {
    private static MySingleton instance;
    private Context context;

    private MySingleton(Context context) {
        this.context = context;
    }

    public static MySingleton getInstance(Context context) {
        if (instance == null) {
            instance = new MySingleton(context);
        }
        return instance;
    }
}

上面的代码中,MySingleton持有了一个Context对象的引用,而MySingleton是一个静态变量,导致即使这个对象已经不再使用,也不会被垃圾回收器回收。

最佳实践:如果需要使用静态变量,请注意在不需要时将其设置为null,以便及时释放内存。

4. 匿名内部类导致的内存泄漏

匿名内部类会隐式地持有外部类的引用,如果这个匿名内部类被持有了,就会导致外部类无法被垃圾回收。

public class MyActivity extends Activity {
    private Button button;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        button = new Button(this);
        button.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                // do something
            }
        });
        setContentView(button);
    }
}

匿名内部类OnClickListener持有了外部类MyActivity的引用,如果MyActivity被销毁之前,button没有被清除,就会导致MyActivity无法被垃圾回收。(此处可以将Button 看作是自己定义的一个对象,一般解法是将button对象置为空)

最佳实践:在Activity销毁时,应该将所有持有Activity引用的对象设置为null。

5. Handler引起的内存泄漏

Handler是在Android应用程序中常用的一种线程通信机制,如果Handler被错误地使用,就会导致内存泄漏。

public class MyActivity extends Activity {
    private static final int MSG_WHAT = 1;
    private Handler mHandler = new Handler() {
        @Override
        public void handleMessage(Message msg) {
            switch (msg.what) {
                case MSG_WHAT:
                    // do something
                    break;
                default:
                    super.handleMessage(msg);
            }
        }
    };

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        mHandler.sendEmptyMessageDelayed(MSG_WHAT, 1000 * 60 * 5);
    }

    @Override
    protected void onDestroy() {
        super.onDestroy();
        // 在Activity销毁时,应该将Handler的消息队列清空,以避免内存泄漏。
        mHandler.removeCallbacksAndMessages(null);
        }
}

Handler持有了Activity的引用,如果Activity被销毁之前,Handler的消息队列中还有未处理的消息,就会导致Activity无法被垃圾回收。

最佳实践:在Activity销毁时,应该将Handler的消息队列清空,以避免内存泄漏。

6. Bitmap对象导致的内存泄漏

当一个Bitmap对象被创建时,它会占用大量内存,如果不及时释放,就会导致内存泄漏。

public class MyActivity extends Activity {
    private Bitmap mBitmap;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        // 加载一张大图
        mBitmap = BitmapFactory.decodeResource(getResources(), R.drawable.big_image);
    }

    @Override
    protected void onDestroy() {
        super.onDestroy();
        // 释放Bitmap对象
        mBitmap.recycle();
        mBitmap = null;
    }
}

当Activity被销毁时,Bitmap对象mBitmap应该被及时释放,否则就会导致内存泄漏。

最佳实践:当使用大量Bitmap对象时,应该及时回收不再使用的对象,避免内存泄漏。另外,可以考虑使用图片加载库来管理Bitmap对象,例如Glide、Picasso等。

7. 资源未关闭导致的内存泄漏

当使用一些系统资源时,例如文件、数据库等,如果不及时关闭,就可能导致内存泄漏。例如:

public void readFile(String filePath) throws IOException {
    FileInputStream fis = null;
    try {
        fis = new FileInputStream(filePath);
        // 读取文件...
    } finally {
        if (fis != null) {
            try {
                fis.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}

上面的代码中,如果在读取文件之后没有及时关闭FileInputStream对象,就可能导致内存泄漏。

最佳实践:在使用一些系统资源时,例如文件、数据库等,要及时关闭相关对象,避免内存泄漏。

避免内存泄漏需要在编写代码时时刻注意,及时清理不再使用的对象,确保内存资源得到及时释放。 ,同时,可以使用一些工具来检测内存泄漏问题,例如Android Profiler、LeakCanary等。

8. WebView 内存泄漏

当使用WebView时,如果不及时释放,就可能导致内存泄漏

public class MyActivity extends Activity {
    private WebView mWebView;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        mWebView = findViewById(R.id.webview);
        mWebView.loadUrl("https://www.example.com");
    }

    @Override
    protected void onDestroy() {
        super.onDestroy();
        // 释放WebView对象
        if (mWebView != null) {
            mWebView.stopLoading();
            mWebView.clearHistory();
            mWebView.clearCache(true);
            mWebView.loadUrl("about:blank");
            mWebView.onPause();
            mWebView.removeAllViews();
            mWebView.destroy();
            mWebView = null;
        }
    }
}

上面的代码中,当Activity销毁时,WebView对象应该被及时释放,否则就可能导致内存泄漏。

最佳实践:在使用WebView时,要及时释放WebView对象,可以在Activity销毁时调用WebView的destroy方法,同时也要清除WebView的历史记录、缓存等内容,以确保释放所有资源。

9. 监测工具

  1. 内存监视工具:Android Studio提供了内存监视工具,可以在开发过程中实时监视应用程序的内存使用情况,帮助开发者及时发现内存泄漏问题。
  2. DDMS:Android SDK中的DDMS工具可以监视Android设备或模拟器的进程和线程,包括内存使用情况、堆栈跟踪等信息,可以用来诊断内存泄漏问题。
  3. MAT:MAT(Memory Analyzer Tool)是一款基于Eclipse的内存分析工具,可以分析应用程序的堆内存使用情况,识别和定位内存泄漏问题。
  4. 腾讯的Matrix,也是非常好的一个开源项目,推荐大家使用

10. 总结

内存泄漏是指程序中的某些对象或资源没有被妥善地释放,从而导致内存占用不断增加,最终可能导致应用程序崩溃或系统运行缓慢等问题。

常见的内存泄漏问题和对应的最佳实践整理如下

问题最佳实践
长时间持有Activity或Fragment对象导致的内存泄漏及时释放Activity或Fragment对象
匿名内部类和非静态内部类导致的内存泄漏避免匿名内部类和非静态内部类
WebView持有Activity对象导致的内存泄漏在使用WebView时,及时调用destroy方法
单例模式持有资源对象导致的内存泄漏在单例模式中避免长时间持有资源对象
资源未关闭导致的内存泄漏及时关闭资源对象
静态变量持有Context对象导致的内存泄漏避免静态变量持有Context对象
Handler持有外部类引用导致的内存泄漏避免Handler持有外部类引用
Bitmap占用大量内存导致的内存泄漏在使用Bitmap时,及时释放内存
单例持有大量数据导致的内存泄漏避免单例持有大量数据
收起阅读 »

Compose 实战经验分享:开发要点&常见错误&面试题

1. 前言从 Compose 还在 alpha 到现在,用 Compose 完整的从零到一写了三个应用:Twidere X Android、Mask-Android,还有一个暂未公开的项目。https://github.com/TwidereProject/T...
继续阅读 »

1. 前言

从 Compose 还在 alpha 到现在,用 Compose 完整的从零到一写了三个应用:Twidere X Android、Mask-Android,还有一个暂未公开的项目。

这三个应用每一个都有不一样的收获,现在将开发过程中的经验集中做一波总结:

2. 要点总结

直接说几个总结出来的要点吧:

  1. Compose UI 最核心的一个思想就是:状态向下,事件向上,Compose UI 组件的状态都应该来自其参数而不是自身,不要在 Compose UI 组件中做任何计算,有非常多的性能问题其实是来自对于这一条核心思想的不理解。
  2. 如果一个组件不得不内部持有一些状态,切记将这些状态所有的变量都用上 remember,因为 Compose 函数是会被非常频繁的执行,不用 remember 的话会导致频繁的赋值和初始化,甚至进行一些计算操作。
  3. Compose UI 组件的参数最好是不可变(immutable)的,否则最好的情况是遇到和预期表现不符,最差的情况就是影响到性能了。
  4. 每个 Compose UI 组件最好都有 Modifier,这样 Compose UI 组件就可以很方便的在不同地方复用。
  5. 为了可维护性,请尽量拆分基础 Compose UI 组件和业务 Compose UI 组件,基础 Compose UI 组件尽量拆分的细一些,业务 Compose UI 组件看情况,最好也要拆分的细一些,你不会想去维护一个上千行的 Compose UI 组件的,同时细分也会提高一定的复用率。

下面总结了一些常见的不正确的用法,其中大部分会导致性能问题,有很多人会说 Compose 性能差,但其实更多的是本身的用法有误。

3. 滥用 remember { mutableStateOf() }

Compose UI 最核心的一个思想就是:状态向下,事件向上。这句话举个例子可能会更好理解。 一般初学者在看完教程之后马上就会写下这样的代码:

@Composable
fun Counter() {
    var count by remember { mutableStateOf(0) }
    Button(
        onClick = {
            count++
        }
    ) {
        Text("count $count")
    }
}

然后当业务逻辑复杂之后,他的代码可能会像这样:

@Composable
fun Counter() {
    var count by remember { mutableStateOf(0) }
    var text by remember { mutableStateOf("") }

    Column {
        Button(
            onClick = {
                count++
            }
        ) {
            Text("count $count")
        }
        TextField(
            value = text,
            onValueChange = {
                text = it
            }
        )
        OtherCounter()
    }
}

@Composable
fun OtherCounter() {
    var text by remember { mutableStateOf("Hello world!") }
    Column {
        Text(text)
        TextField(
            value = text,
            onValueChange = {
                text = it
            }
        )
    }
}

抛开代码的业务逻辑不谈,这里的 Composable 函数是带状态的,这会带来不必要的 recomposition,从而导致写出来的 Compose UI 出现性能问题,按照核心思想状态向下,事件向上,上面的代码应该这样写:

@Composable
fun CounterRoute(
    viewModel: CounterViewModel = viewModel<CounterViewModel>()

) {
    val state by viewModel.state.collectAsState()
    Counter(
        state = state,
        onIncrement = {
            viewModel.onIncrement()
        },
        onTextChange = {
            viewModel.onTextChange(it)
        },
        onOtherTextChange = {
            viewModel.onOtherTextChange(it)
        },
    )
}

@Composable
fun Counter(
    state: CounterState,
    onIncrement: () -> Unit,
    onTextChange: (String) -> Unit,
    onOtherTextChange: (String) -> Unit,
)
 {
    Column {
        Button(
            onClick = {
                onIncrement.invoke()
            }
        ) {
            Text("count ${state.count}")
        }
        TextField(
            value = state.text,
            onValueChange = {
                onTextChange.invoke(it)
            }
        )
        OtherCounter(
            text = state.otherText,
            onTextChange = onOtherTextChange,
        )
    }
}

@Composable
fun OtherCounter(
    text: String,
    onTextChange: (String) -> Unit,
)
 {
    Column {
        Text(text)
        TextField(
            value = text,
            onValueChange = {
                onTextChange.invoke(it)
            }
        )
    }
}

这样的写法吧所有状态都放到顶层,同时事件也交由顶层处理,这样的 Compose UI 组件是没有任何状态的,这样的的 Compose UI 组件会有非常好的性能。

4. 忘记 remember

刚刚说完滥用,现在说忘记。当一个组件不得不内部持有状态的时候,这个时候切记:一定要吧所有的变量都用上 remember。

常见的有这样的错误:

@Composable
fun SomeList() {
    val list = listOf("a""b""c")
    LazyColumn {
        items(list) {
            Text(it)
        }
    }
}

这里的 list 完全没有被 remember,而 Compose 函数会非常频繁的执行,这就导致每次执行到 val list = listOf("a", "b", "c") 的时候都会有一次生成赋值甚至计算的操作,这样的写法是非常影响性能的,正确的写法应该是这样:

@Composable
fun SomeList() {
    val list = remember { listOf("a""b""c") }
    LazyColumn {
        items(list) {
            Text(it)
        }
    }
}

当然最好是把 list 移到参数上:

@Composable
fun SomeList(
    list: List<String>,
)
 {
    LazyColumn {
        items(list) {
            Text(it)
        }
    }
}

5. 参数是可变的

还是接着上一个例子,光是 list 移动到参数还是不够的,因为你可以在 Composable 函数外边更改这个列表,比如执行 list.add("") 的操作,Compose 编译器会认为这个 Composable 函数仍然是带状态的,所以还不是最优化的状态。最好是使用 kotlinx.collections.immutable 里面的 ImmutableList:

@Composable
fun SomeList(
    list: ImmutableList<String>,
)
 {
    LazyColumn {
        items(list) {
            Text(it)
        }
    }
}

除了基础类型之外,其他参数中的自定义 class 最好是标记上 @Immutable,这样 Compose 编译器会优化这个 Composable 函数。当然不要定义一个 data class 然后里面一个 var a: String 然后问为什么 a.a = "b" 没有效果,建议传给 Composable 函数的 data class 全是 val。

6. 没开启 R8

R8 对于 Compose 的提升是非常巨大的,如果是简单 UI 的话没有 R8 可能还可以用,复杂 UI 下非常推荐开启 R8,代码优化之后的性能的 Debug 的性能差距极大。

7. 最后:面试题推荐

其实理解了 Compose UI 的核心思想之后,写出来的 Compose 程序应该不会有什么性能问题,而且在这个核心思想下写出来的 Compose UI 逻辑非常的清晰,因为整个 UI 是无状态的,你只需要关系在什么状态下这个 UI 显示的是什么样的,心智负担非常小。

最后推荐一些 Compose 相关的面试题,大家可以做一个自我测试,如果你能回答的七七八八,那么恭喜你,可能已经击败 95% 的同行了。

  1. Jetpack Compose有了解吗?和传统Android UI有什么不同?
  2. DisposableEffect、SideEffect、LaunchedEffect之间的区别?
  3. pointer事件在各个Composable function之间是如何处理的?
  4. 自定义Layout?
  5. CompositionLocal起什么作用?staticCompositionLocalOf和compositionLocalOf有什么区别?
  6. Composable function的状态是如何持久化的?
  7. LazyColumn是如何做Composable function缓存的?
  8. 如何解决LazyColumn和其他Composable function的滑动冲突?
  9. @Composable的作用是什么?
  10. Jetpack Compose是用什么渲染的?执行流程是怎么样的?与flutter/react那样做diff有什么区别/优劣?
  11. Jetpack Compose多线程执行是如何实现的?
  12. 什么是有状态的 Composable 函数?什么是无状态的 Composable 函数?
  13. Compose 的状态提升如何理解?有什么好处?
  14. 如何理解 MVI 架构?和 MVVM、MVP、MVC 有什么不同的?
  15. 在 Android 上,当一个 Flow 被 collectAsState,应用转入后台时,如果这个 Flow 再进行更新,对应的 State 会不会更新?对应的 Composable 函数会不会更新?
收起阅读 »

认识Base64,看这篇足够了

web
Base64的简介 Base64是常见的用于传输8Bit字节码的编码方式之一,基于64个可打印字符来标识二进制数据点方法。 使用Base64的编码不可读,需要解码。 Base64实现方式 Base64编码要求把3个8位字节(3*8=24)转化为4个6位...
继续阅读 »

Base64的简介


Base64是常见的用于传输8Bit字节码的编码方式之一,基于64个可打印字符来标识二进制数据点方法。


使用Base64的编码不可读,需要解码。


Base64实现方式


Base64编码要求把3个8位字节(3*8=24)转化为4个6位的字节(4*6=24),之后在6位的前面补两个0,形成8位一个字节的形式。如果剩下的字符不足3个字节,则用0填充,输出字符使用=,因此编码后输出的文本末尾可能会出现1个或2个=


Base64就是包括小写字母a-z,大写字母A-Z,数字0-9,符号+/组成的64个字符的字符集,另外包括填充字符=。任何符号都可以转换成这个字符集中的字符,这个转化过程就叫做Base64编码。


为了保证输出的编码位可读字符,Base64指定了一个编码表,以便进行统一转换,编码表的大小为2^6=64,即Base64名称的由来。




























































































































































































索引 对应字符 索引 对应字符 索引 对应字符 索引 对应字符
0 A 17 R 34 i 51 z
1 B 18 S 35 j 52 0
2 C 19 T 36 k 53 1
3 D 20 U 37 l 54 2
4 E 21 V 38 m 55 3
5 F 22 W 39 n 56 4
6 G 23 X 40 o 57 5
7 H 24 Y 41 p 58 6
8 I 25 Z 42 q 59 7
9 J 26 a 43 r 60 8
10 K 27 b 44 s 61 9
11 L 28 c 45 t 62 +
12 M 29 d 46 u 63 /
13 N 30 e 47 v
14 O 31 f 48 w
15 P 32 g 49 x
16 Q 33 h 50 y


十进制转二进制


十进制数转换为二进制数时,由于整数和小数的转换方法不同,所以先将十进制数的整数部分和小数部分分别转换后,再加以合并。


十进制整数转换为二进制整数采用 "除 2 取余,逆序排列" 法。


具体做法是:用 2 整除十进制整数,可以得到一个商和余数;再用 2 去除商,又会得到一个商和余数,如此进行,直到商为小于 1 时为止,然后把先得到的余数作为二进制数的低位有效位,后得到的余数作为二进制数的高位有效位,依次排列起来。


示例:求十进制34对应的二进制数





Base64编码示例


示例1:对字符串 Son 进行 Base64编码

























































ASCII字符 S(大写) o n
十进制 83 111 110
二进制 01010011 01101111 01101110
每6个bit为一组 010100 110110 111101 101110
高位补0 00010100 00110110 00111101 00101110
对应的Base64索引 20 54 61 46
对应的Base64字符 U 2 9 u


Son通过Base64编码转换成了U29u,3个ASCII字符刚好转换成对应的Base64字符。


示例2:对字符串 S 进行 Base64编码

























































ASCII字符 S(大写)
十进制 83
二进制 01010011
每6个bit为一组 010100 110000 000000 000000
高位补0 00010100 00110000 00000000 00000000
对应的Base64索引 20 48
对应的Base64字符 U w = =


字符串S对应1个字节,一共8位,按6位为一组分为4组,每组不足6位的补0,然后在每组的高位补0,找到Base64编码进行转换。


Base64的优缺点


优点:





  • 将二进制数据(如图片)转为可打印字符,在HTTP协议下传输



  • 对数据进行简单加密,肉眼安全



  • 如果是在html或css中处理图片,可以减少http请求


缺点:





  • 内容编码后体积变大,至少1/3



  • 编码和解码需要额外工作量


Base64编码和解码





  • btoaatob方法:js中有两个函数被分别用来处理编码和解码Base64字符串



    • btoa():该函数基于二进制数据字符串创建一个Base64编码的字符串



    • atob():该函数用于解码使用Base64编码的字符串




btoa()示例:


let btoaStr = window.btoa(123456)
console.log(btoaStr) // MTIzNDU2

atob()示例:


let atobStr = window.atob(btoaStr)
console.log(atobStr) // 123456

这两个函数容易混淆,可以这样记下,比如btoa是以b字母开头,编码也是以b字母开头。即btoa编码,升序的atob解码。



Data URI Scheme


Data URI scheme的目的是将一些小的数据,直接嵌入到网页中,从而不用再从外部文件载入,减少请求资源的连接数,缺点是不会被浏览器缓存起来。


Data URI scheme支持的Base64编码的类型比如:





  • data:image/png;base64,base64编码的png图片数据



  • data:image/gif;base64,base64编码的gif图片数据



  • data:text/javascript;base64,base64编码的javascript代码


Base64的应用


使用Base64编码资源文件


比如:在html文档中渲染一张Base64的图片


<img :src="data:image/png;base64,IVBORsssssfjsfsfs==" alt="">img>


注意:如果图片较大,在Base64编码后字符串会非常大,影响页面加载进度,这种情况不适合Base64编码。



在HTTP中传递较长的标识信息


比如使用Base64将一个较长的标识符(128bit的UUID)编码为一个字符串,用作表单和httpGET请求中的参数。


标准的Base64编码后不适合直接放在请求的URL中传输,因为URL编码器会把Base64中的/+变成乱码,即%xxx的形式。我们可以将base64编码改进一下用于URL中,比如Base64中的/+转换成_-等字符,避免URL编码器的转换。


canvas生成图片


canvas提供了toDataURL()toBlob()方法。


HTMLCanvasElement.toDataURL() 方法返回一个包含图片展示的data URI。可以使用 type 参数其类型,默认为PNG格式。图片的分辨率为 96dpi。


canvas.toDataURL(type, encoderOptions);

使用示例:设置jpeg图片的质量,图片将以Base64存储


let quality = canvas.toDataURL("image/jpeg"1.0);
// data:image/jpeg;base64,/9j/4AAQSkZJRgABAQ...9oADAMBAAIRAxEAPwD/AD/6AP/Z"

HTMLCanvasElement.toBlob() 方法创造Blob对象,用以展示 canvas 上的图片;这个图片文件可以被缓存或保存到本地(由用户代理自行决定)。


HTMLCanvasElement.toBlob(callback, type?, quality?)

使用示例:


canvas.toBlob(function(blob){...}, "image/jpeg"0.95); // JPEG at 95% quality

读取文件


FileReader.readAsDataURL()方法会读取指定的BlobFile对象。读取操作完成的时候,readyState会变成已完成DONE,并触发 loadEnd事件,同时result属性将包含一个data:URL 格式的字符串(Base64 编码)以表示所读取文件的内容。


读取文件示例:


代码演示可以点击这里查看哦


<input type="file" onchange="previewFile()" />
<br />
<br />
<img src="" alt="导入图片进行预览" />
<script>
  function previewFile({
    var preview = document.querySelector("img");
    var file = document.querySelector("input[type=file]").files[0];
    var reader = new FileReader();

    reader.addEventListener("load",
      function ({
        preview.src = reader.result;
        console.log(reader.result); // Base64编码的图片格式
      },
      false,
    );

    if (file) {
      reader.readAsDataURL(file);
    }
  }
script>

简单"加密"某些数据


Base64常用作一个简单的“加密”来保护某些数据,比如个人密码我们如果不使用其他加密算法的话,可以使用Base64来简单处理。


Base64是加密算法吗


Base64不是加密算法,只是一种编码方式,可以用Base64来简单的“加密”来保护某些数据。


结语


❤️ 🧡 💛大家喜欢我写的文章的话,欢迎大家点点关注、点赞、收藏和转载!!


作者:前端开心果
来源:mdnice.com/writing/3c8306915f484882a5b720bfdedc6e02
收起阅读 »

Flutter路由跳转参数处理小技巧

需求 我们在开发应用中,经常会出现一个界面跳转到另外一个界面并带有参数传递,在Android中大家都知道使用Intent传递参数,在第二个Activity中onCreate中可以获取到这个参数。 实现 那么在Flutter中,我们经常会使用路由跳转到另外...
继续阅读 »

需求


我们在开发应用中,经常会出现一个界面跳转到另外一个界面并带有参数传递,在Android中大家都知道使用Intent传递参数,在第二个Activity中onCreate中可以获取到这个参数。


实现


那么在Flutter中,我们经常会使用路由跳转到另外一个界面,那么如果这个时候需要传参。 代码如下:


/// 路由跳转并带参数
 Navigator.pushNamed(
            context,
            RouteConst.routeNext,
            arguments: (TestArguments("一笑轮回""江苏省徐州市")),
);       
     
     
/// 测试数据模型
class TestArguments {
  String? name;
  String? address;
  TestArguments(this.name, this.address);
}

没错,直接赋值arguments字段就可以了,那么我们如何获取呢?


在第二个页面中


class TwoPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // 从路由设置中获取传递的参数
    var arguments = ModalRoute.of(context)?.settings.arguments;
    // 其他部分的代码...
  }
}


我们需要通过 ModalRoute.of(context)?.settings.arguments获取数据,那么我们直接在 initState方法中直接通过 ModalRoute.of(context)?.settings.arguments获取,会报错


这里出错原因,可以通过错误并查看源码可知,这里部讲述。


我们有的时候需要在initState方法中获取数据并处理一些事情,我们应该怎么做呢?


下面提供一个小技巧。





  • 路由定义


class RouteConst {
  static const routeNext = "/route_next";
}


class RoutePathConst {
  static var routePaths = <String, Widget Function(BuildContext context)>{
    RouteConst.routeNext: (context) => ArgumentsNextPage(),
  };
}




  • 跳转代码


 Navigator.pushNamed(
            context,
            RouteConst.routeNext,
            arguments: (TestArguments("一笑轮回""江苏省徐州市")),
          );

/// 测试数据模型
class TestArguments {
  String? name;
  String? address;

  TestArguments(this.name, this.address);
}




  • 定义ArgumentsMixin


/// Arguments参数数据
mixin ArgumentsMixin {
  late final Object? arguments;
}

/// 路由拼接的参数数据
mixin RouteQueryMixin {
  final Map<String, String> routeParams = HashMap();
}




  • 重写onGenerateRoute



void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      ...
      onGenerateRoute: (settings) {
        var uri = Uri.parse(settings.name ?? "");
        var route = uri.path;
        var params = uri.queryParameters;
        if (!RoutePathConst.routePaths.containsKey(route)) {
          return null;
        }
        return MaterialPageRoute(
          builder: (context) {
            var widgetBuilder = RoutePathConst.routePaths[route];
            var widget = widgetBuilder!(context);
            if (widget is RouteQueryMixin) {
              (widget as RouteQueryMixin).routeParams.addAll(params);
            }
            if (widget is ArgumentsMixin) {
              (widget as ArgumentsMixin).arguments = settings.arguments;
            }
            return widget;
          },
          settings: settings,
        );
      },
    );
  }
}





  • 创建ArgumentsNextPage



///第二页
class ArgumentsNextPage extends StatefulWidget
    with ArgumentsMixin, RouteQueryMixin {
  ArgumentsNextPage({super.key});

  @override
  State<ArgumentsNextPage> createState() => _ArgumentsNextPageState();
}

class _ArgumentsNextPageState extends State<ArgumentsNextPage> {
  /// 传参数据文本
  String get result {
    // Arguments传参数据
    TestArguments? arguments;
    if (widget.arguments != null && widget.arguments is TestArguments) {
      arguments = widget.arguments as TestArguments;
    }

    // 路由拼接的数据
    var params = widget.routeParams;

    // 拼接结果数据
    return "arguments:name=${arguments?.name ?? ""} address=${arguments?.address ?? ""} \nrouteParams=$params";
  }

  @override
  void initState() {
    super.initState();
    print("result=$result}");
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: XYAppBar(
        title: "第二页",
        onBack: () {
          Navigator.pop(context);
        },
      ),
      body: Center(
        child: Text(result),
      ),
    );
  }
}


这样就OK了,好像没讲啥,直接看代码吧。


详细代码见:github.com/yixiaolunhui/flutter_xy


作者:移动小样
来源:mdnice.com/writing/3d43c6e3544b45c59773b133a135fb01
收起阅读 »

hive宽表窄表互转

hive宽表窄表互转 背景 在工作中经常会遇到高表转宽表,宽表转窄表的场景,在此做一些梳理。 宽表转窄表 传统思路 使用sql代码作分析的时候,几次遇到需要将长格式数据转换成宽格式数据,一般使用left join或者case when实现,代码...
继续阅读 »

hive宽表窄表互转


背景


在工作中经常会遇到高表转宽表,宽表转窄表的场景,在此做一些梳理。


宽表转窄表


传统思路



使用sql代码作分析的时候,几次遇到需要将长格式数据转换成宽格式数据,一般使用left join或者case when实现,代码看起来冗长,探索一下,可以使用更简单的方式实现长格式数据转换成宽格式数据。



select year,
max(case when month=1 then money else 0 endas M1,
max(case when month=2 then money else 0 endas M2,
max(case when month=3 then money else 0 endas M3,
max(case when month=4 then money else 0 endas M4 
from sale group by year;

需求描述


某电商数据库中存在一张客户信息表user_info,记录着客户属性数据和消费数据,需要将左边长格式数据转化成右边宽格式数据。 需求实现



涉及函数: str_to_map, concat_ws, collect_set, sort_array




实现思路: 步骤一:将客户信息转化成map格式的数据。 collect_set形成的集合是无序的,若想得到有序集合,可以使用sort_array对集合元素进行排序。 步骤二:将map格式数据中的key与value提取出来,key就是每一列变量名,value就是变量值



select 
    user_no,
    message1['name'name,
    message1['sex'] sex,
    message1['age'] age,
    message1['education'] education,
    message1['regtime'] regtime,
    message1['first_buytime'] first_buytime
from 
  (select
      user_no,
      str_to_map(concat_ws(',',sort_array(collect_set(concat_ws(':', message, detail))))) message1
      from user_info
      group by user_no
      order by user_no
   ) a


窄表转宽表


长宽格式数据之间相互转换使用到的函数,可以叫做表格生成函数


需求描述


某电商数据库中存在表user_info1,以宽格式数据记录着客户属性数据和消费数据,需要将左边user_info1宽格式数据转化成右边长格式数据。 需求实现



步骤一:将宽格式客户信息转化成map格式的数据。 步骤二:使用explode函数将 map格式数据中的元素拆分成多行显示



select user_no, explode(message1)
    from 
    (select user_no, 
        map('name',name'sex',sex, 'age',age, 'education',education, 'regtime',regtime, 'first_buytime',first_buytime) message1
        from user_info1
    ) a

总结



不管是将长格式数据转换成宽格式数据还是将宽格式数据转换成长格式数据,都是先将数据转换成map格式数据。长格式数据转换成宽格式数据:先将长格式数据转换成map格式数据,然后使用列名['key']得到每一个key的value;宽格式数据转换成长格式数据:先将宽格式数据转换成map格式数据,然后使用explode函数将 map格式数据中的元素拆分成多行显示。顺便说一句,R语言中也是通过类似的方法实现长宽格式之间相互转换的。



作者:大数据启示录
来源:mdnice.com/writing/cfacb28094f643d5970e425fc6130980
收起阅读 »

8月来临,再不给自己定年度目标,年终总结又没得写了!

年度目标有多重要? 试想一下,一只无头苍蝇,即便饿了,也只会漫无目的地飞来飞去,最终还是难逃被饿死的命运。 人也是如此,如果连以年度为时间粒度设置的目标都没有,每天只是浑浑噩噩地混混日子,那么他大概率这辈子也会白白浪费掉。 毕竟习惯具有惯性,除非一个人能...
继续阅读 »

年度目标有多重要?


试想一下,一只无头苍蝇,即便饿了,也只会漫无目的地飞来飞去,最终还是难逃被饿死的命运。


人也是如此,如果连以年度为时间粒度设置的目标都没有,每天只是浑浑噩噩地混混日子,那么他大概率这辈子也会白白浪费掉。


毕竟习惯具有惯性,除非一个人能时不时跳出原有舒适圈。



图片来自网络

图片来自网络


事实上,哪怕KPI没有完成,至少知道自己是有方向的,今年完不成,还有明年,明年完不成,就换个目标。


如果你还想死磕,在时间、经济和健康状况都不错的前提下,你可以这么做。


如果超过两年,这个目标还没完成,你就应该考虑一下这个目标是不是设定得有问题了。


这些问题可能来自以下2个方面:


1.违背当前自然规律


比如你要在2年内找到外星人的踪迹;在两年内证明鬼魂的存在;在两年内让已故的猫咪复活……


2.没有违背当前自然规律,但不切实际


比如纯路人的你要在2年内嫁给某知名男星;没有任何创业经验、对公司理财一无所知的情况下想做一家2年内赶超某宝的电商平台;每天24小时不睡觉才能完成的目标……



图片来自网络

图片来自网络


那么,什么样的目标更合理、在两年内是有可能完成的呢?


答案很多。但在目标合理的前提下,一定要对目标进行量化。


比如:


体重超标的你,计划在两年内减掉多少斤?


从事IT行业的你计划在两年内发布多少篇技术博客?这些技术博客有没有点赞要求?有没有技术难度要求?如果有,具体是多少?


你有没有存钱目标?如果有,计划在两年内拥有多少存款?如果主职收入能达到存款要求,是否需要降低物欲来节流?


如果主职收入达不到存款要求,你有没有能赚取额外收入的副业渠道?如果有,能否满足存款要求?如果不能满足,那么有没有办法拓展客户、加强营销以提高副业收入?


如果没有办法,那有没有可能在一年内摸索出收入更高的副业渠道?(剩下一年来搞副业赚钱)


上面问题的解决方案还可以是有计划、有目的地提升自己的职业技能,跳槽换一份待遇更高的工作。


……


8月来临,还没有定下年度目标的你,现在是否该定几个年度目标了?


现在定目标,明年年终总结时,你就有可以量化的成果了。


想想就很激动。


还等什么呢,赶紧行动起来!在你的备忘录里、日记本里把它们写出来。


作者:美人薇格
来源:mdnice.com/writing/d96e3e9c630a43c7bab96a298661cb54
收起阅读 »

为什么你不应该使用div作为可点击元素

web
按钮是为任何网络应用程序提供交互性的最常见方式。但我们经常倾向于使用其他HTML元素,如divspan等作为clickable元素。但通过这样做,我们错过了许多内置浏览器的功能。 我们缺少什么? 无障碍问题(空格键或回车键无法触发按钮点击) 元素将无法通过按...
继续阅读 »

按钮是为任何网络应用程序提供交互性的最常见方式。但我们经常倾向于使用其他HTML元素,如divspan等作为clickable元素。

但通过这样做,我们错过了许多内置浏览器的功能。


我们缺少什么?



  1. 无障碍问题(空格键或回车键无法触发按钮点击)

  2. 元素将无法通过按Tab键来聚焦


1062174618-64c4718ba0919.webp


权宜之计


我们需要在每次创建可点击的 div 按钮时,以编程方式添加所有这些功能


image.png


更好的解决方案


始终优先使用 button 作为可点击元素,以获取浏览器的所有内置功能,如果你没有使用它,始终将上述列出的可访问性功能添加到你的div中。


虽然,直接使用按钮并不直观。我们必须添加并修改一些默认的CSS和浏览器自带的行为。


使用按钮的注意事项


1. 它自带默认样式


我们可以通过将每个属性值设置为 unset 来取消设置现有的CSS。


我们可以添加 all:unset 一次性移除所有默认样式。


在HTML中,我们有三种类型的按钮。 submit, reset and button.
默认的按钮类型是 submit.


无论何时使用按钮,如果它不在表单内,请始终添加 type='button' ,因为 submit 和 reset 与表格有关。


2.请不要在按钮标签内部放置 divs


我们仍然需要添加 cursor:pointer 以便将光标更改为手形。


image.png


作者:王大冶
来源:juejin.cn/post/7261985825089110076
收起阅读 »

从9G到0.3G,腾讯会议对他们的git库做了什么?

web
导读 过去三年在线会议需求井喷,腾讯会议用户量骤增到3亿。快速迭代的背后,腾讯会议团队发现:业务保留了长达5年的历史数据,大量未进行 lfs 转换,新 clone 仓库本地空间占17.7G+。本地磁盘面临严重告急,强烈影响团队 clone 效率。当务之急是将仓...
继续阅读 »

导读


过去三年在线会议需求井喷,腾讯会议用户量骤增到3亿。快速迭代的背后,腾讯会议团队发现:业务保留了长达5年的历史数据,大量未进行 lfs 转换,新 clone 仓库本地空间占17.7G+。本地磁盘面临严重告急,强烈影响团队 clone 效率。当务之急是将仓库进行瘦身。本栏目特邀腾讯会议的智子研发团队成员李双君,回顾腾讯会议客户端的瘦身历程和经验,欢迎阅读。


目录


1 瘦身成效


2 瘦身前事项


3 瘦身整体方案


4 瘦身具体命令执行


5 新代码库验证


6 解决其它设备本地老分支 push 问题


7 其他平台适配


8 最后的验证


9 瘦身完毕后的知会


10 兜底回滚方案


11 踩坑记录及应对


12 写在最后


*作者所在的腾讯会议智子研发团队是腾讯会议的终端团队,负责腾讯会议 Win、Mac、Linux、Android、iOS、小程序、Web 等全栈开发,致力于打造一流的端产品体验。


01、瘦身成效


腾讯会议瘦身完毕后整体收益:




  • Git 仓库大小,9G 到350M。




  • 新 clone 仓库占用空间,从17.7G 到12.2G。




  • 平常拉代码速度(北京地区测试):macbook m1 pro:提升45%;devcloud win:提升56%。




  • 包构建流水线全量拉代码耗时,从16分钟减少到5分钟以内。





02、瘦身前事项


2.1 环境准备


使用有线网,看看能否通过其他办法给机器的上传和下载速度提速?不建议在家中开代理来瘦身,因为家里网速一般都没有公司快;如果在家操作,提前配置好远程桌面,远程公司电脑来瘦身。


使用性能较好的机器,硬盘空间至少要有 xxxG 剩余 (可以提前演练,看看究竟要多大磁盘空间?会议最起码得要求有600G 空余)。会议本次瘦身使用的设备是 MAC Book M1 Pro(16寸)笔记本电脑。


2.2 周知


工作开发群或者邮件等通知瘦身时间和注意事项:


瘦身时间: 选一个大家基本上都不会提交代码的时间,比如十一国庆或者春节;会议选的是春节期间。


注意事项: (开发重点关注)


瘦身期间会锁库,必须提前推送代码到远端,否则需要手动同步; 锁库期间无法进行 MR,且已创建 MR 会失效; 因删除历史记录,会导致本地仓库与远端冲突,请恢复后重新 clone 代码; 需要查询或处理更老的代码,需要去备份仓库查看。

2.3 代码库锁定


禁止代码库写操作,一般公司的代码管理平台可以提供这个功能,Git 项目的 owner 有权限。


2.4 第三方 Git 平台禁用


如果 Git 项目被第三方 Git 平台使用了,要保证瘦身前仓库的同步任务禁用。


比如,会议使用了 Ugit(UGit 是腾讯内部的一款自研 Git 客户端,主要是为腾讯内部研发环境特点而定制),就要如下禁用项目同步:



03、瘦身整体方案


原仓库继续保留作为备份仓库,另外新建仓库,新仓库沿用原仓库的项目名称、版本库路径和 id,并同步原项目数据。


之所以这么做,是为了保证其他平台无缝对接新的 Git 仓库,不用再更换 Git 地址,另外有些通过 api 调用的系统和工具也不受到影响。


瘦身内容:




  • 历史记录删除,只保留最近半年的历史记录。




  • 将历史大文件以及未加入 lfs 的大文件进行 lfs 处理。




04、瘦身具体命令执行


4.1 clone 项目,拉取所有文件版本到本地


git clone example.com/test.git


为了后面的比对验证,可以拷贝一份 test 文件夹放到和 test 同级目录下面的新建的 copyForCompare 文件夹中。


ulimit -n 9999999 # 解决可能出现的报错too many open files的问题
ulimit -n # 查看改成9999999了没

遍历拉取所有分支的 lfs 最新文件,并追踪远端分支到本地


以下这段 shell 脚本可以直接拷贝到终端运行,也可以创建一个.sh 文件放到根目录执行


cur_index=1
j=1
git branch -r | grep -v '->' |
while read remote
do
echo ”deal $cur_index th branch“
cur_index=$[cur_index+1]
git branch --track "${remote#origin/}" "$remote"
echo "begin to lfs fetch branch $remote"
git lfs fetch origin $remote
if [ $? -eq 0 ]; then
echo "fetch branch $remote success"
else
echo "fetch branch $remote failed"
lfs_fetch_fail_array[$j]=$remote
j=$[j+1]
fi
done
if [ ${#lfs_fetch_fail_array[*]} -gt 0 ]; then
echo "git lfs fetch error branches are: ${lfs_fetch_fail_array[*]}"
else
echo "fetch all branches success. done."
fi

获取所有分支的文件和 lfs 文件版本


git fetch --all
git lfs fetch --all

4.2 使用 git filter-branch 截断历史记录


这次瘦身只保留最近半年的历史记录,2022.6.1之前的提交记录都删除,所以截断的 commit 节点按如下所述来找:


提前用 sourceTree(或者别的 Git 界面工具)找出来需要截断的那个 commit,以主干 master 为例,找到 master 分支上提交的并且只有一个父的提交节点(如果提交节点有多个父,那么所有父节点都要处理),该节点必须是所有分支的父节点,否则需要考虑其他分支特殊处理的情况,该情况后面的【特殊分支处理】会有说明。



可以看到选中的截断 commit id 是 ff75cc5cdbf0423a24b4f5438e52683210813ba0



  • 根据上面的 commit id,带入下面的命令,找出其父


git cat-file -p ff75cc5cdbf0423a24b4f5438e52683210813ba0



可以看到只有一个父,其父是7ffe6782272879056ca9618f1d85a5f9716f8e90 ,所以该提交 id 就是要置为空的。如果有多个父都需要处理。



  • 执行命令


注意:对于普通提交节点,下面命令的 parent 值是"-p parentId";对于合并提交节点,下面命令的 parent 值是"-p parentId1 -p parentId2 -p parentId3 ..."


git filter-branch --force --parent-filter '
read parent
if [ "$parent" = "-p 7ffe6782272879056ca9618f1d85a5f9716f8e90" ]
then
echo
else
echo "$parent"
fi' --tag-name-filter cat -- --all


  • 重点验证:上述命令执行完毕后,一定要用如下命令检查是否修改成功


注意:因为执行完了命令已经修改了历史记录,此时 Git log 命令执行会慢点,大概5分钟可以出结果,另外可以用这个在线时间戳转换工具来转换时间戳。


工具链接:http://www.beijing-time.org/shijianchuo…


如果执行成功会把之前的文件版本取最新的 add 到这个截断的提交节点里面,如下图:


git log --all-match --author="xxxx" --grep="auto update .code.yml by robot" --name-status --before="1654043400" --after="1654012800" --all


4.3 使用 git-filter-repo 清理截断日期前的所有历史记录,并将截断节点的提交信息修改


注意此步骤要谨慎处理,因为这步会真正地删除提交记录。


提前安装好 git-filter-repo,执行下面的 python 代码。


import os
try:
import git_filter_repo as fr
except ImportError:
raise SystemExit("Error: Couldn't find git_filter_repo.py. Did you forget to make a symlink to git-filter-repo named git_filter_repo.py or did you forget to put the latter in your PYTHONPATH?")

k_work_dir = "/Volumes/SolidCompany/S_Shoushen/test"
# 2022.6.1 00:00:00
k_clean_history_deadline = b"1654012800"
# 2022.6.1 07:05:07
k_clean_deadline_commit_date = b"1654038307"
k_clean_deadline_commit_author_name = b"xxxxx"
k_new_root_commit_message = "仓库瘦身历史记录裁剪,截断提交记录后新根结点新增历史文件;如果想查看更多历史记录,请去备份仓库:https://example.com/test_backup.git"

def commitCallBackFun(commit, metadata):
[time_stamp, timezone] = commit.committer_date.split()
if time_stamp == k_clean_deadline_commit_date and commit.author_name == k_clean_deadline_commit_author_name:
commit.message = k_new_root_commit_message.encode("utf-8")
if time_stamp >= k_clean_history_deadline:
return
commit.file_changes = []

def main():
os.chdir(k_work_dir)
print("git work dir is", os.getcwd())
args = fr.FilteringOptions.parse_args(['--force', '--debug'])
filter = fr.RepoFilter(args, commit_callback = commitCallBackFun)
filter.run()

if __name__ == '__main__':
main()

验证下截断提交结点的提交信息更改成功了没?


git log --all-match --author="xxx" --grep="仓库瘦身历史记录裁剪" --name-status --before="1654043400" --after="1654012800"

如下就对了:



以上执行完后做个简单验证:


用 BeyondCompare 工具跟刚开始备份的 copyForCompare 目录下的 test 仓库对比,看看有没有增删改文件,期望应该没有任何变化才对。



  • 特殊分支处理


说明:以上历史记录裁剪并删除历史提交记录执行完后,对于基于截断提交节点前的提交节点创建出来的分支或者其子分支会出现文件被删除或者整个分支被删除的情况。



所以要提前弄清楚有没有在截断节点之前早就创建出来一直在用的分支, 如果有就得特殊处理上面的2和3步骤了:


第2步中截断历史记录的时候,要类似分析 master 分支那样分析其它需要保留的特殊分支,找出各自的截断节点的父提交 id;然后执行的 shell 脚本里面条件判断改成判断所有的父提交 id;类似这样:


git filter-branch --force --parent-filter '
read parent
if [ "$parent" = "-p 85f5ee6314f4f46cc47eb02c6af93bd3020a1053 -p cd207e9b3372f68a6d1ffe06fcf189d952e3bf9f" ] || [ "$parent" = "-p 7ffe6782272879056ca9618f1d85a5f9716f8e90" ]
then
echo
else
echo "$parent"
fi' --tag-name-filter cat -- --all

第3步中删除截断节点前提交记录的 python 脚本里面,按照分支名字和自己分支的截断日期来做比对逻辑进行删除提交记录的操作。类似如下:


#!/usr/bin/env python3
import os
try:
import git_filter_repo as fr
except ImportError:
raise SystemExit("Error: Couldn't find git_filter_repo.py. Did you forget to make a symlink to git-filter-repo named git_filter_repo.py or did you forget to put the latter in your PYTHONPATH?")

k_work_dir = "/Users/jevon/Disk/work/appShoushen/shoushen/test"

# 2022.6.1 07:05:07
k_master_cut_date = b"1654038307"

# 2022.3.25 19:32:00
k_private_new_saas_sdk_master_cut_date = b"1648207920"

k_new_root_commit_message = "仓库瘦身历史记录裁剪,截断提交记录后新根结点新增历史文件;如果想查看更多历史记录,请去备份仓库:https://example.com/test_backup.git"

def commitCallBackFun(commit, metadata):
[time_stamp, timezone] = commit.committer_date.split()
# 每个特殊分支的截断提交点的提交信息修改
if (time_stamp == k_master_cut_date and commit.author_name == b"xxx_author1") or \
(time_stamp == k_private_new_saas_sdk_master_cut_date and commit.author_name == b"xxx_author2"):
commit.message = k_new_root_commit_message.encode("utf-8")

# 每个特殊分支的截断提交点前的提交记录,需要根据各自截止日期来做比对删除日期前的历史记录
strBranch = commit.branch.decode("utf-8")
if strBranch.endswith("refs/heads/master"):
if time_stamp < k_master_cut_date:
commit.file_changes = []
elif strBranch.endswith("refs/heads/private/feature/3.12.1/new-saas-sdk-master"):
if time_stamp < k_private_new_saas_sdk_master_cut_date:
commit.file_changes = []
def main():
os.chdir(k_work_dir)
print("git work dir is", os.getcwd())
args = fr.FilteringOptions.parse_args(['--force', '--debug'])
filter = fr.RepoFilter(args, commit_callback = commitCallBackFun)
filter.run()

if __name__ == '__main__':
main()

以上[特殊分支处理]没有实验过,但是个解决思路,具体实践结果待补充,也欢迎实验过的同学交流。


4.4 进行 lfs 转换


rm -Rf .git/refs/original
rm -Rf .git/logs
git branch | wc -l # 看一下本地分支总数
# 拷贝原来的仓库到新目录下面
git clone file:///Users/jevon/Disk/work/appShoushen/shoushen/test /Users/jevon/Disk/work/appShoushen/shoushen/test_new
cd test_new
git branch -r | grep -v '->' |
while read remote
do
git branch --track "${remote#origin/}" "$remote"
done
git fetch --all git branch | wc -l # 看一下本地分支总数,和拷贝之前是否一样
# 分析仓库中占用空间较大的文件类型(演练的时候可以提前分析出来,节省瘦身时间)
git lfs migrate info --top=100 --everything

命令结果如下,是按照文件所有的历史版本累加统计的,只有未加入 lfs 的才会统计。



git rev-list --objects --all | git cat-file --batch-check='%(objecttype) %(objectname) %(objectsize) %(rest)' | sed -n 's/^blob //p' | sort --numeric-sort --key=2 | cut -c 1-12,41- | $(command -v gnumfmt || echo numfmt) --field=2 --to=iec-i --suffix=B --padding=7 --round=nearest | grep MiB

该命令执行结果如下,是把所有大于 1Mb 的文件版本都列出来了,不进行累加,从小到大排序,已经加入 lfs 的不会统计。



# lfs转换
# --include=之后填入根据实际分析的大文件列表
git lfs migrate import --everything --include="*.jar,tool/ATG/index.js,xxx"
# 上面lfs转换执行完后,看一下根目录的.gitattribute文件里面是不是加入了新的lfs文件了

4.5 新建新仓库,推送所有历史记录修改


新创建目标仓库 test_backup.git ,然后运行下面代码:


git remote remove origin
git remote add origin https://example.com/test_backup.git
git remote set-url origin https://example.com/test_backup.git
git remote -v # 确保设置成功新仓库地址

此时可以用下面的命令看看还有没有大文件了(可选)。


git rev-list --objects --all | git cat-file --batch-check='%(objecttype) %(objectname) %(objectsize) %(rest)' | sed -n 's/^blob //p' | sort --numeric-sort --key=2 | cut -c 1-12,41- | $(command -v gnumfmt || echo numfmt) --field=2 --to=iec-i --suffix=B --padding=7 --round=nearest | grep MiB

用以下命令看看还有没有未转换的大的 lfs 文件了(可选)。


git lfs migrate info --top=100 --everything
# 推送历史记录修改到目标新仓库:
git push origin --no-verify --all
git push origin --no-verify --tags

4.6 回到原来的 test 目录,推送 lfs 文件


cd ../test
git config lfs. https://example.com/test_backup.git /info/lfs.access basic
git lfs env # 看一下改成了basic了吗
# 设置成远端目标仓库test_backup.git
git remote remove origin
git remote add origin https://example.com/test_backup.git
git remote set-url origin https://example.com/test_backup.git
git remote -v # 确保设置成功新仓库地址


将 upload_objects.sh 拷贝到 test 目录,然后执行 sh ./upload_objects.sh


upload_objects.sh 内容如下:


#!/bin/bash

set -e

count=$(find .git/lfs/objects -type f | wc -l)
echo "-- total objects count is $count"

index=0
concurrency=25

find .git/lfs/objects -type f | awk -F '/' '{print $NF}' | while read -r obj; do
echo "-- $(date) -- uploading ($index/$count) $obj"
git lfs push origin --object-id "$obj" &
index=$((index+1))
if [[ $index%$concurrency -eq 0 ]]; then
echo -e "\r\n-- $(date) -- waiting --------------------------\r\n"
wait
fi
done

注意脚本里面的并发值不能设置太高,不然会报错./upload_objects.sh: line 12: echo: write error: Interrupted system call,测试发现设置25是比较合适的。



确保像上图这样,最后一个也上传成功。


05、新代码库验证


git clone https://example.com/test_backup.git

使用 git lfs pull 先拉取主干分支所有的历史文件进行测试,保留瘦身的本地仓库;  后续如果发现有其他分支的 lfs 文件没有上传成功,再单独上传即可。


上传命令:


git lfs push --object-id origin "$objId"

对比新旧代码库主干最新代码是否一致,可使用 beyond compare 工具进行验证。四端编译不同代表性的分支运行验证。


06、解决其它设备本地老分支 push 问题


在公司的代码管理平台上设置瘦身后的 test_backup 仓库单文件大小上限为1.5M。


一般公司自己的代码管理平台都会提供设置单个 git 文件上传大小上限的功能,得管理员才有权限设置;腾讯的代码管理平台是像下图这样设置的:



解释:之后的步骤将会把新老仓库互换,新旧仓库互换后,其它机器本地的老仓库分支还是有 push 上去的风险,这样就会把瘦身前的历史记录又推送到瘦身后的 Git 仓库,造成瘦身白费。


07、其他平台适配


7.1 代码管理平台


找代码管理平台协助完成下面的操作:(需要提前预约沟通好)


会议用的代码管理平台是工蜂:


项目名称、版本库路径互换:test_backup 重命名为 test,test 重命名为 test_backup。 将两个项目项目 id 进行调换:新项目沿用旧项目的项目 id,以此保证通过 api 调用的系统和工具不受到影响。 项目数据同步:同步项目成员和权限相关的数据、保护分支规则组到新仓库。

自己工蜂适配(可以提前进行)。对照老工蜂的所有配置,在新工蜂上手动同步修改。


7.2 第三方 Git 工具


如果使用了第三方 Git 工具平台做过瘦身仓库与其他项目仓库的同步,需要处理下(会议使用了 UGit 第三方工具):


通知 UGit 相关负责人把旧的工作区移除一下,重新 clone test 仓库。 把 Ugit 里面 test 仓库的同步任务恢复(如有需要)。

7.3 出包流水线构建平台


因为执行完瘦身后,Git 的 commit id 都变了,历史记录也变了,而 coding 的构建机如果不清理缓存删掉老仓库的话,会导致构建机本地仓库历史与远端冲突,执行 Git 命令会异常,所以 coding 必须要清理掉老仓库,重新 clone。


08、最后的验证


代码管理平台以及出包构建平台都处理完成后,进行最后的验证。


本地验证:


本地是否能正常 clone 代码。 本地对比新旧仓库主干最新代码是否一致。 本地随机抽取分支对比新旧仓库文件个数以及最新代码是否一致。 本地编译验证,程序启动主流程验证。

出包构建平台验证:


主干分支、发布分支、个人需求分支、个人分支等的构建验证。

代码管理平台验证:


代码库基础、高级配置是否正确 保护分支规则配置是否正确,是否有效 项目成员是否和原仓库一致 MR 是否可正常发起、合并,能否正常调起检测流水线

代码库写权限恢复:


保证瘦身后的 Git 仓库恢复写权限;备份仓库禁用写权限。

09、瘦身完毕后的知会


知会参考模板:


xxx 仓库瘦身完成了!接下来需要开发重点关注:本地旧仓库已经失效,必须删掉重新 clone 代码【最最重要】未提前push到远端的本地新增代码需要手动同步旧的未完成的MR已经失效,需要关闭后重新发起需要查询或处理更老的代码,需要去备份仓库查看(xxxx/xxxx.git)开发过程中有任何疑问,欢迎请随时联系 xxx

10、兜底回滚方案


因为使用了备份仓库,所以不会修改原始仓库,但只有代码管理平台(工蜂)在第七步的时候修改了原始仓库,对于这个工蜂的协助修改,需要提前确认好工蜂那边做好了回滚的方案。


11、踩坑记录及应对


11.1 上传 lfs 的时候报错 User is null or anonymous user



LFS: Git:User is null or anonymous user.


解决:git config lfs.example.com/test_backup… basic


输入 git lfs env 看一下输出结果改成了 basic 了吗?



11.2 git push 的时候报错



把远程链接改成 https 的:


git remote set-url origin https://example.com/test_backup.git
git remote -v

如果~/.gitconfig 中有如下的内容要先注释掉。


url.git@example.com:.insteadof=http://example.com/ url.git@example.com:.insteadof=https://example.com/

最后再 push 即可。


如果上述还不行,那么在命令行中执行:


git config --global https.postbuffer 1572864000git config --global https.lowSpeedLimit 0git config --global https.lowSpeedTime 999999

如仍然无法解决,可能是用户的客户端默认有设默认值限制 git 传输包的大小,可执行指令:


git config --global core.packedGitLimit 2048m
git config --global core.packedGitWindowSize 2048m

11.3 window 如何在 git batch 里面运行 git-filter-repo?


安装 python:打开 cmd 窗口,运行 python -m pip install git-filter-repo,安装 git-filter-repo;


用 everything 查找 git-filter-repo.exe;


cmd 窗口,运行 git --exec-path,得到路径类似:C:\Program Files\Git\mingw64\libexec\git-core;


把上面找到的 git-filter-repo.exe 拷贝到 git-core 文件夹里面;


此时在 git batch 窗口中,输入命令 git filter-repo(注意输入的git后面没有-),会提示 No arguments specified.证明 ok 了。


11.4 如果想让 git-filter-repo 作为一个 python 库来使用,实现更复杂的功能该怎么办?


比如,不想这么用了 git-filter-repo --force --commit-callback "xxxx python code...",因为这么用只能写回调的 python 代码,太弱了。


解决:python3 -m pip install --user git-filter-repo,不行就 python3 -m pip install git-filter-repo,安装这个 git-filter-repo包,然后就可以在 python 代码中作为库使用:import git_filter_repo as fr。


11.5 瘦身后发现 coding 的 win 构建机器在 clone 代码时出问题,怎么办?


卡在 git lfs pull:



卡在 git checkout --force xxxxx 提交 id:



卡在 checking out files:



调查发现,是 lfs 进程卡住,不知道什么样的场景触发的,官方有个类似 issue,以上问题均是因为 git 或者 git lfs 版本过低导致的,升级到高版本即可解决。


据当时出错 case 总结得出结论,以下 git 和 git lfs 的版本号可以保证稳定运行不出问题,如果版本号低于以下所示,最好升级。



11.6 执行 git lfs fetch 的时候报错 too many open files 的问题


解决办法:ulimit -n 9999999


12、写在最后


仓库瘦身是个细致耗时的工作,需要谨慎认真地完成。最后腾讯会议客户端仓库的大小也从 9G 瘦身到 350M ,实现的效果还是不错的。


本次我们分享了仓库瘦身的全历程,把执行命令也公示给各位读者。希望可以帮助到为类似困境而头疼的开发者们。这篇文章对您有帮助的话,欢迎转发分享。


-End-


原创作者|李双君


技术责编|陈从贵、郭浩伟



作者:腾讯云开发者
来源:juejin.cn/post/7261814990843265061
收起阅读 »

浏览器渲染15M文本导致崩溃怎么办

web
最近,我刚刚完成了一个阅读器的txt文件阅读功能,但在处理大文件时,遇到了文本内容过多导致浏览器崩溃的问题。 一般情况下,没有任何样式渲染时不会出现什么问题,15MB的文件大约会有3秒的空白时间。 <div id="content"></di...
继续阅读 »

最近,我刚刚完成了一个阅读器的txt文件阅读功能,但在处理大文件时,遇到了文本内容过多导致浏览器崩溃的问题。


一般情况下,没有任何样式渲染时不会出现什么问题,15MB的文件大约会有3秒的空白时间。


<div id="content"></div>

fetch('./dp.txt').then(resp => resp.text()).then(text => {
document.getElementById('content').innerText = text
})

尽管目前还没有严重的问题,但随着文件继续增大,肯定会超过浏览器内存限制而导致崩溃。


在开发阅读器的过程中,我添加了下面的样式,结果导致浏览器直接崩溃:


* {
margin: 0;
padding: 0;
}

html,
body {
width: 100%;
height: 100%;
overflow: hidden;
}

body {
column-fill: auto;
column-width: 375px;
overflow-x: auto;
}

预期结果应该是像下面这样分段显示:



然而,实际出现了下面的问题:


unnamed.png


因此,文件内容太多会导致浏览器崩溃。即使进行普通的渲染,我们也要考虑这个问题。


如何解决


解决这个问题的方法有点经验的前端开发工程师应该都知道可以使用虚拟滚动,重点是怎么对文本分段分段,最容易的可能就是按照一定数量字符划分,但是这个导致文本衔接不整齐出现文本跳动。如图,橙色和蓝色表示两端文本的衔接,虚拟滚动必然会提前移除橙色那块内容,那么就会导致蓝色文本位置发生改变。



要解决这个问题,我们需要想办法用某个元素替代原来的位置。当前页橙色部分删除并计算位置,问题会变得复杂并且误差比较大,因此这一部分直接保留,把这部分前面的内容移除,然后用相同长度的元素占据,接下来重点就是怎么获取到橙色部分与前面内容的分界点。


获取分界点可以使用document.createRange()document.createRange()是 JavaScript 中用于创建Range对象的方法。Range对象表示一个包含节点与文本节点之间一定范围的文档片段。这个范围可以横跨单个节点、部分节点或者多个节点。


// 创建 Range 对象
const range = document.createRange();

range.setStart(textNode, 0); // 第一个参数可以是文本节点,第二个参数表示偏移量
range.setEnd(textNode, 1);
const rect = range.getBoundingClientRect(); // 获取第一个字符的位置信息

利用Range对象的特性,我们可以从橙色部分的最后一个字符开始计算,直到找到分界点的位置。


阅读器如果仅仅只是从左往右阅读,按照上面的方法已经可以实现,但是阅读器可能会出现页面直接跳转,跳转之后的文本起点你并不知道,并且页面总页码你也无法知道。因此从一开始就要知道每一页的分界点,也就是需要做预渲染。以下是一个简单的示例:


let text = '...'
const step = 300
let end = Math.min(step, value.length) // 获取结束点

while (text.length > 0) {
node.innerText = value.substring(0, end) // 取部分插入节点
const range = document.createRange()
range.selectNodeContents(node)
const rect = range.getBoundingClientRect() // 获取当前内容的位置信息

if (rect.height > boxHeight) {
// 判断当前内容高度是否会超出显示区域的高度
// 如果超出,从 end 最后一个字符开始计算,直到不超出范围
while (bottom > boxHeight) {
// node.childNodes[0] 表示文本节点
range.setStart(node.childNodes[0], end - 1)
range.setEnd(node.childNodes[0], end)
bottom = range.getBoundingClientRect().bottom
end--
}
} else {
// 如果没有超出,end 继续增加
// ...
}
}

上面只是简单的实现原理,可以达到精确区分每一页的字符,但是计算量有点太大,15MB文本大约500多万字,循环次数估计也在几十万到上百万。在本人的电脑上测试大约需要20秒,每个人设备的性能不同,所需时间也会有所不同。很明显,这种实现方式并不太理想。


后来我对这个方案进行了优化,实际上我们不需要计算每一页的分界点,可以计算出多页的分界点,例如10页、20页、50页等。优化后的代码是将step增大,比如设为10000,然后将不能组成一页的尾部内容去掉。优化后,15MB的文本大约需要4秒左右。需要注意的是,step并不是越大越好,太大会导致渲染页面占用时间过长。


这就是我目前用来解决页面渲染大量文本的方法。如果你有更

作者:60岁咯
来源:juejin.cn/post/7261231729523965989
好的方案,欢迎留言。

收起阅读 »

古茗前端到底搞什么飞机

在前几期文章的评论中,我发现不少人有类似“古茗前端到底搞什么飞机”的疑问: 其实在入职古茗前我也有这种观点,不就是做做下单小程序,做做简单的内部管理系统吗,甚至在面试过程中我也问了面试官这个问题,在听完面试官的解答之后,我也同样地忍不住发出了“居然要做这么牛...
继续阅读 »


在前几期文章的评论中,我发现不少人有类似“古茗前端到底搞什么飞机”的疑问:



其实在入职古茗前我也有这种观点,不就是做做下单小程序,做做简单的内部管理系统吗,甚至在面试过程中我也问了面试官这个问题,在听完面试官的解答之后,我也同样地忍不住发出了“居然要做这么牛逼的事情,这还是一个奶茶公司吗”的感慨!


于是我毫不犹豫地选择加入了古茗(有免费奶茶喝!畅饮的那种!)。


到现在已经入职快一年了,是时候跟大家讲讲“古茗前端到底搞什么飞机”了。


做一杯奶茶,总共分几步?


其实古茗的业务真的很多,很多,很多!具体有哪些我不方便透露,只能说光是我们前端团队就服务了4个业务域,18条业务线。那我们是怎么服务这些业务域和业务线的呢?我就拿我所在的“机料”举例吧。


那什么是机料呢?在这里我先卖个关子,相信看完下面的介绍,你就会知道是什么意思了,以后去古茗点奶茶就可以跟别人吹牛了(狗头)


首先问大家一个问题:做一杯奶茶,总共分几步?


就和把大象塞进冰箱一样,第一步:倒上茶,第二步:加上料,第三步:吨吨吨!


是不是很简单?步骤看着是简单,但是衍生出来的问题还是很多的


第一步涉及到的问题:



  1. 泡茶汤时,不同的茶,分别用多少度的水泡?泡多长时间?泡多少量?

  2. 怎么保证全国门店的店员按要求执行了泡茶方法?

  3. 怎么灵活控制不同地区的茶保持相同/不同口感?

  4. ...


再来看看第二步涉及到的问题:



  1. 有些物料是冷冻运输的,什么时候拆封解冻?解冻多久?保质期多久?

  2. 有些物料是原材料,什么时候要制备成半成本?保质期多久?

  3. 怎么保证加料时物料是在最佳赏味期内的?

  4. 怎么保证全国门店的店员遵守食品安全规范?

  5. 怎么提前告知门店高峰期的预估物料种类以及用量?

  6. ...


最后第三步里的问题:



  1. 怎么保证门店能尽可能还原研发室里研发出来的口味?

  2. 怎么保证更快地出杯?

  3. ...


虽然看着很麻烦很多,但是我们稍微捋一下还是能捋明白的:



  • 对于“怎么保证”这类问题,其实是一种功能性问题,我们需要让我们的功能代码在门店运行

  • 对于“多少”、“多久”、“什么时候”这些问题,可以归类为配置性问题,可以通过后台配置并进行下发


那基于这两大类问题,“机料”业务就浮出水面了。


机-机器设备


机,就是机器设备(下文统一叫设备),奶茶店里有各种各样的设备,这些机器都是用来辅助店员去标准化地制作奶茶的,设备更多地在解决一些“功能性”的问题,有了这些功能,我们就可以更好地保证一系列流程的规范性。


解决了什么问题


比如上面提到的问题:泡茶汤时,不同的茶,分别用多少度的水泡?泡多长时间?泡多少量?这些问题都是直接影响茶汤的口感的,茶汤是一杯奶茶的基底,要是口感不佳,那么整杯奶茶就毁了。


虽然古茗有很严格的培训体系,每一个店员都需要来总部进行培训学习和考试,但是哪怕是老虎也有打盹的时候,我们不能完全寄希望于店员时时刻刻严格遵守不同类别的茶的制作流程,人不是机器,对不同茶汤的温度、水量、时长等等因素进行人为控制,这些都是难度极大的。


既然人不是机器,那就直接造!于是乎我们的设备部做了泡茶机、制冷设备等等设备。


有泡茶机前,我们的店员需要记下每一款茶的调制过程,然后人工去泡,这就导致同一个人,同一家店,不同时间,泡出来的茶口感会不一样。


有了泡茶机以后,店员只需要把茶包包装上的茶包码往泡茶机上扫一下,泡茶机就可以检索对应的茶汤配方自动按照标准流程、按照标准参数进行泡茶,这样就解决了人为带来的茶汤口感不稳定的问题。


并且无形中还解决了另一个问题,就是“灵活控制不同地区的茶保持相同/不同口感”,因为刚才有提到,泡茶机会根据对应的茶汤配方进行泡茶流程,那么我们就可以根据不同地区下发不同的配方,来保证泡出预期口感的茶汤。


怎么实现


那这一套我们是怎么做的呢?前端在其中扮演了什么角色呢?考虑到保密的原因,我这里就简单画几个图,详细的技术细节就不透露了。



设备侧的产品经理需求评审之后:



  1. 嵌入式开发:与“网关”开发(一般是前端开发)共同制定数据通信协议,并按这份协议进行嵌入式开发,通过这份协议可以实现硬件设备与“网关”上USB或者蓝牙的数据传输方式

  2. 后端开发:与前端制定接口格式,通过接口可以将设备的数据上传至后端,用做设备信息展示、设备异常告警等用处

  3. 前端开发:根据数据通信协议进行“网关”功能的开发,通过协议解析设备的数据,并按照接口格式上传至后端,在后台大盘上显示数据,或者在“网关”上显示相关设备的异常告警


可以看到前端不光要开发后台的功能,还需要开发“网关”的功能,这对传统前端开发来说是一种新的挑战,因为不光要写前端代码,还要掌握硬件的通信协议,了解客户端的相关开发技能,目前的“网关”其实是运行在搭载了Android系统平板的APP中,协议的实现都需要在这个APP中完成。



用这种“伪网关”的方式存在一个很明显的弊端,就是我们的门店存在多个种类的设备,光是制冷设备就分为平冷、冷冻、冷藏三种类型,每种类型分别会有1-2台设备下店,加上一些研发中的设备,这样一来网关平板的蓝牙连接数量很容易达到上限,就会造成有部分设备无法连接蓝牙的问题,为了解决这个问题,我们出了一个临时方案,一个长久方案。


临时方案就是保证核心设备是保持连接的,但是给一些相对来说不是那么重要的设备做定时断开、轮流连接的处理,这样能一定程度上解决这个问题。


长久方案就是开发一个真正意义上的“边缘网关”,边缘网关能接入更多的设备,且能更聚焦于设备的通信、信息的处理分析等功能。


边缘网关是一种用于连接边缘设备和云平台之间的网络设备。 其主要作用是在边缘设备和云平台之间构建一个灵活、高效和安全的网络,将数据从边缘设备收集并处理后,再传输到云平台上进行存储和分析。 在这个过程中,边缘网关需要具备多种功能,包括数据的采集、处理、分析、存储和传输等。


小结


我经常感叹,我们组不是单纯的前端开发工程师了,因为组里的成员一直在和设备、各种协议打交道,桌上也放着各种各样的电路板,年前甚至把一台要下店的净水器直接放在工位边联调,也写过“220V,勿碰”的牌子放在工位上。


之前和TL聊物料网的应用场景,我们都认为这就是物联网一个很好的落地场景,并且我们的目标远不止于此,业务上还有很多的问题要依赖于设备去解决,很多降本增效的事情也要依赖于设备去实现,我相信在不久的将来,古茗的设备一定会给门店带来更多的收益,古茗的IoT方案会成为茶饮界IoT方案中值得参考的那个。


料-物料


说完“机”,再来看看“料”,就是制作一杯奶茶所需要用到的物料。


餐饮行业最重视的一个问题就是食品安全问题,这首先关系到每个消费者的身体健康,其次涉及门店的经营情况,最后会影响品牌在公众心里的印象,所以食安是每一个餐饮企业的底线。


古茗为了保证食安,搞了自己的种植基地,搭建了自己的供应链系统、智能报货系统,研发室的研发员们针对每一款物料制定了“有效期”...


除了种植基地,其他业务都和我们组有关系,这里我就介绍一下物料“有效期”这件事。


解决了什么问题


在开头的经典三步的第二步,都是物料相关的内容,首先我先科普一下物料有效期相关的几个概念:解冻时间、备料时间、最佳赏味期。


解冻时间:冷冻品需要解冻的时长,比如家里的冻肉,做菜时得先拿出来解冻一下;


备料时间:解冻完成后就需要对原料进行备料加工了,还是拿冻肉举例,冻肉解冻完了就得切成肉片开始炒了;


最佳赏味期:顾名思义,就是在这个期间内食用是最好的,好比你妈妈把肉炒完了,喊你吃饭,结果你一直在玩游戏就错过了最佳赏味期了。



为什么是定这三个时间,而不是其他的四个时间,五个时间呢?


其实也是根据奶茶的制作方式来定的,因为门店要做一杯奶茶是需要用到半成品物料的,半成品物料又需要由原料制备得到,而那些冷冻原料又需要解冻(解冻其实也是制备的一个环节,只不过解冻需要的时间会很长,且不是每个物料都需要解冻,比如茶就不需要解冻),所以结合实际情况,就定了这三个时间。


按我个人理解的话,其实就是为了保证任何一家门店、任何一个店员做出来的奶茶,都是符合食安的、最新鲜的、口感最好的。


怎么实现


那这三个时间需要怎么在门店里直观地展现给店员呢?


聪明的你可能想到了,对,就是利用设备!这台设备的名字就叫“效期机”,我再画个图给大家看看效期机是怎么在门店发挥作用的。



假设店员发现XX冷冻原浆快不够用了,那他就会从冷冻柜中取出XX冷冻原浆,然后去效期机的物料列表上去找XX冷冻原浆这个物料,找到后在使用效期机的打印功能进行打印,这时打印机就会打印XX冷冻原浆的效期贴(记载了物料相关信息的一个小贴纸),店员撕下小贴纸后贴到XX冷冻原浆的容器上,最后放到解冻区进行解冻。



店员做完上面这些步骤之后就可以做自己的事了,等到了解冻时间、备料时间、超过最佳赏味期前2分钟这些节点,效期机就会进行语音播报,提醒店员XX冷冻原浆需要进行XX操作了。


那效期机是怎么知道物料的这三个时间并在对应时间给出语音提醒的呢?


其实这个链路的流程蛮简单的,效期机种记录了每个物料的三种时间,在店员打印效期贴的那一刻,这个物料的生命周期就开始了,物料的生命周期每个阶段的时长是按照物料配置平台配置的时间决定的,同时效期机内部维护了三个有序队列,分别是解冻提醒队列,备料提醒队列,最佳赏味期超期提醒队列,物料的生命周期就在这三个队列之间流转,驱动生命周期的是一个10s一次的轮询,每次轮询都会去判断三个队列的头数据是否达到触发条件,即是否到了提醒时间。


比如XX冷冻原浆的配置是解冻时间30分钟,备料时间10分钟,最佳赏味期60分钟,店员打印效期贴的时间是12:00,那么这个物料的生命周期就是这么流转的:



这里有个小细节:我们会在最佳赏味期前2分钟就进行提醒,这个的目的就是为了更进一步保证食安以及最后奶茶的口感。


小结


其实对于物料的管理,上文提到的内容只是冰山一角,我们已经做的、正在做的、未来计划要做的事还多的很。


在食安问题上,针对私自篡改效期时间的门店,会进行高额的罚款,每天也会定期进行后厨的打扫,在培训时也会针对食安部分进行严格的培训和考试;


在物料提醒的优化上,我们发现目前的提醒交互还是会存在店员理解不到位的情况,这里也在不断地进行优化和迭代;


在保证更快的出杯速度上,我们现在正在做的一件事是基于门店的实际出杯情况去生成预测物料用量,并通过效期机下发给到门店,辅助门店更好准确地进行物料的提前制备,以及提升出杯速度。


感悟


最后聊聊入职古茗后的感悟吧,在这不到一年的时间感觉成长了很多。


首先是技术吧,前面也提到了,我所在的组要和设备打交道,加上我本来是个安卓开发,现在加入古茗前端部门后,也在针对性学习前端的内容、跨端的技术,其实学习一门语言并不是难题,难的是要培养前端的编程思维,这和客户端思维还是有差别的。


其次是业务,古茗应该是我离业务最近的公司了,我给门店打过上百通电话,线下跑过十来家门店,跟产品经理去实地调研......这些经历让我更了解了店员需要什么,我们需要提供什么样的功能给他们。


然后是工程师能力,这是在古茗前端部门经常被提起的一个概念,我们给工程师的定位是能解决一类复杂问题的人,在此基础上我们就应该具备更完善的能力,不仅仅局限于写某一种或几种代码,为了搭建运维平台,我特地去学习了产品知识,按照一个完整的流程进行了原型图的评审,需求文档的评审,并参与到平台的开发中。不仅是我,古茗的前端都在朝着“工程师”而在努力着。


最后的最后,就是人长胖了,古茗真的是无限畅饮,我可真喝不动了......


最后


关注公众号「Goodme前端团队」,获取更多干货

作者:古茗前端团队
来源:juejin.cn/post/7261628991055183930
实践,欢迎交流分享~

收起阅读 »

浅谈软件质量与度量

我正在参加「掘金·启航计划」本文从研发角度探讨下高质量软件应具备哪些特点,以及如何度量软件质量。软件质量的分类软件质量通常可以分为:内部质量和外部质量。内部质量内部质量是指软件的结构和代码质量,以及其是否适合维护、扩展和重构。它关注的是软件本身的特性和属性,包...
继续阅读 »

我正在参加「掘金·启航计划」

本文从研发角度探讨下高质量软件应具备哪些特点,以及如何度量软件质量。

软件质量的分类

软件质量通常可以分为:内部质量和外部质量。

内部质量

内部质量是指软件的结构和代码质量,以及其是否适合维护、扩展和重构。它关注的是软件本身的特性和属性,包括:

  • 可读性:代码易于阅读和理解;
  • 易维护性:代码易于修改和维护;
  • 可测试性:代码易于编写单元测试并进行自动化测试;
  • 可靠性:代码稳定、不容易崩溃或出现错误;
  • 可扩展性:代码能够方便地进行扩展;
  • 可重用性:代码可被复用于其他项目中。

内部质量直接影响软件的可维护性和开发效率。如果软件的内部质量很差,那么开发人员可能需要花费更多的时间修复问题,而不是开发新功能。

外部质量

外部质量是指软件的用户体验和其符合用户需求的程度。它关注的是软件的功能和表现形式,包括:

  • 功能性:软件是否具有所需的功能,并且这些功能是否能够正常工作;
  • 易用性:软件是否易于使用,是否符合用户的期望;
  • 性能:软件是否运行快速并响应迅速;
  • 兼容性:软件是否能够在不同的操作系统和设备上正常工作。

外部质量如果很差,那么用户在使用软件过程中可能会遇到问题,而这些问题可能会影响用户体验,导致用户流失。

为什么内部质量更重要

内部质量高的核心降低了未来变更的成本

可以参考下图的时间-功能累计关系图。

对于内部质量比较差的软件,虽然初期进展迅速,但是随着时间的流逝,添加新功能变得越来越困难。甚至一个小改动也需要程序员理解大量代码。当开发做代码变更时,还可能产生意想不到的缺陷,因此导致测试时间长,需要更高成本来做缺陷修复和验证。

对于内部质量高的软件,则与其相反,可以参考下图的比较。

内部质量高的软件更容易被实现。

内部质量高的软件特点之一就是易读性。 这样利于开发者更快弄清楚应用程序是如何运行的,这样就可以知道如何添加新功能。如果将软件很好地划分为不同的实现模块,则开发者没必要阅读所有代码,只需要快速找到涉及功能变动模块的代码就行。

如何衡量软件质量

Cyclomatic Complexity(圈复杂度)

Cyclomatic Complexity通过计算代码中不同路径的数量来衡量代码的复杂程度。圈复杂度越高,表示代码的控制流程越复杂,可能存在更多的错误和缺陷。

下面举例说明Cyclomatic Complexity如何计算。

public int calculate(x, y) {
if (x >= 20) {
if (y >= 20) {
return y;
}

return x;
}

return x + y;
}

这段代码的流程图如下:

圈复杂度的公式如下:

E - N + 2

其中 E 表示图中的边数(上图中的所有形状),N 表示节点数(上图中的所有箭头)。因此,在我们的例子中,6 - 5 + 2 = 3,的确这段代码包含三条路径。

Maintainability Index(可维护性指数)

Maintainability Index(可维护性指数)是一种用于评估软件代码可维护性的指标。它通常考虑代码的复杂度、长度和注释等因素,并将这些因素整合成一个分数来衡量代码的可读性、可维护性和可重构性。

通常情况下,可维护性指数的分数范围是 [0,100],分数越高表示代码的可维护性越好。可维护性指数可以帮助开发人员识别哪些代码需要改进,以提高代码的可维护性和可读性,从而减少维护成本、降低缺陷率,提高代码的质量。

Dependencies(依赖)

软件的开发过程势必会依赖外部框架和库,这些框架和库自身也会经常更新(维护者会添加和删除功能、修复错误、改进性能,并修补安全漏洞)。

旧版本库和框架通常会对依赖它的软件质量产生负面影响。例如安全漏洞是明显的风险(例如22年8月份的

Apachelog4j漏洞)。

SQALE评估法

SQALE评估法主要关注四个指标:

  1. 技术债:即未来要花费的时间和资源去修复当前存在的问题
  2. 可维护性:即代码的易读性、可理解性和可扩展性,从代码的模块化程度、命名规范、注释等因素,并对这些因素进行打分。
  3. 可靠性:即软件的稳定性和可靠性,评估代码中存在的错误、漏洞和异常处理情况。如果存在较多的问题,他们就需要考虑重新设计代码或增加更多的测试用例。
  4. 性能:即软件的响应速度和处理能力

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

for和range性能大比拼!

能GET到的知识点什么场景使用for和range1. 从一个遍历开始万能的range遍历遍历array/slice/stringsarraypackage main import "fmt" func main() { var ...
继续阅读 »

能GET到的知识点

  • 什么场景使用for和range

1. 从一个遍历开始

万能的range遍历

  1. 遍历array/slice/strings

array

package main  

import "fmt"

func main() {
var UserIDList = [10]int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}

for i, v := range UserIDList {
fmt.Println(i, v)
}
}

0 1
1 2
2 3
3 4
4 5
5 6
6 7
7 8
8 9
9 10

slice

package main  

import "fmt"

func main() {
var UserIDList = [10]int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
var UerSlice = UserIDList[:]

for i, v := range UerSlice {
fmt.Println(i, v)
}
}
0 1
1 2
2 3
3 4
4 5
5 6
6 7
7 8
8 9
9 10

字符串

func main(){
var Username = "斑斑砖abc"
for i, v := range Username {
fmt.Println(i, v)
}
}


0 26001
3 26001
6 30742
9 97
10 98
11 99


range进行对array、slice类型遍历一切都正常,但是到了对字符串进行遍历时这里就出问题了,出问题主要在索引这一块。可以看出索引是每个字节的位置,在go语言中的字符串是UTF-8编码的字节序列。而不是单个的Unicode字符。遇到中文字符时需要使用多个字节表示,英文字符一个字节进行表示,索引0-3表示了一个字符及以此完后。

  1. 遍历map
func ByMap() {  
m := map[string]int{
"one": 1,
"two": 2,
"three": 3,
}
for k, v := range m {
delete(m, "two")
m["four"] = 4
fmt.Printf("%v: %v\n", k, v)
}
}

one: 1
four: 4
three: 3


  • 和切片不同的是,迭代过程中,删除还未迭代到的键值对,则该键值对不会被迭代。
  • 在迭代过程中,如果创建新的键值对,那么新增键值对,可能被迭代,也可能不会被迭代。个人认为应该是hash的无序性问题
  • 针对 nil 字典,迭代次数为 0
  1. 遍历channel
func ByChannel() {  
ch := make(chan string)
go func() {
ch <- "a"
ch <- "b"
ch <- "c"
ch <- "d"
close(ch)
}()
time.Sleep(time.Second)
for n := range ch {
fmt.Println(n)
}
}
  • 针对于range对关闭channel的遍历,会直到把元素都读取完成。
  • 但是在for遍历会造成阻塞,因为for变量读取一个关闭的管道并不会进行退出,而是一直进行等待,但是如果关闭了会返回一个状态值可以根据该状态值判断是否需要操作

2.for和range之间奇怪的问题

2.1 无限遍历现象

for

c := []int{1, 2, 3}  
for i := 0; i < len(c); i++ {
c = append(c, i)
fmt.Println(i)
}

1
2
3
.
.
.
15096
15097
15098
15099
15100
15101
15102
15103
15104

range

c := []int{1, 2, 3}  
for _, v := range c {
c = append(c, v)
fmt.Println(v)
}

1
2
3

可以看出for循环一直在永无止境的进行追加元素。 range循环正常。原因:for循环的i < len(c)-1都会进行重新计算一次,造成了永远都不成立。range循环遍历在开始前只会计算一次,如果在循环进行修改也不会影响正常变量。

2.2 在for和range进行修改操作

for

type UserInfo struct {  
Name string
Age int
}
var UserInfoList = [3]UserInfo{
{Name: "John", Age: 25},
{Name: "Jane", Age: 30},
{Name: "Mike", Age: 28},
}
for i := 0; i < len(UserInfoList); i++ {

UserInfoList[i].Age += i
}
fmt.Println(UserInfoList)

0
1
2
[{John 25} {Jane 31} {Mike 30}]

range

var UserInfoList = [3]UserInfo{  
{Name: "John", Age: 25},
{Name: "Jane", Age: 30},
{Name: "Mike", Age: 28},
}


for i, info := range UserInfoList {
info.Age += i
}
fmt.Println(UserInfoList)

[{John 25} {Jane 30} {Mike 28}]

可以看出for循环进行修改了成功,但是在range循环修改失效,为什么呢?因为range循环返回的是对该值的拷贝,所以修改失效。for循环修相当于进行原地修改了。但如果在for循环里面进行赋值修改操作,那么修改也会进行失效 具体如下

var UserInfoList = [3]UserInfo{  
{Name: "John", Age: 25},
{Name: "Jane", Age: 30},
{Name: "Mike", Age: 28},
}
for i := 0; i < len(UserInfoList); i++ {
fmt.Println(i)
item := UserInfoList[i]
item.Age += i

}


fmt.Println(UserInfoList)
> [{John 25} {Jane 30} {Mike 28}]

3. Benchmark大比拼

主要是针对大类型结构体

type Item struct {  
id int
val [4096]byte
}

for_test.go

func BenchmarkForStruct(b *testing.B) {  
var items [1024]Item
for i := 0; i < b.N; i++ {
length := len(items)
var tmp int
for k := 0; k < length; k++ {
tmp = items[k].id
}
_ = tmp
}
}
func BenchmarkRangeStruct(b *testing.B) {
var items [1024]Item
for i := 0; i < b.N; i++ {
var tmp int
for _, item := range items {
tmp = item.id
}
_ = tmp
}
}
goos: windows
goarch: amd64
pkg: article/02fortest
cpu: AMD Ryzen 5 5600G with Radeon Graphics
BenchmarkForStruct-12 2503378 474.8 ns/op 0 B/op 0 allocs/op
BenchmarkRangeStruct-12 4983 232744 ns/op 0 B/op 0 allocs/op
PASS
ok article/02fortest 3.268s

可以看出 for 的性能大约是 range 的 600 倍。

为什么会产生这么大呢?

上述也说过,range遍历会对迭代的值创建一个拷贝。在占据占用较大的结构时每次都需要进行做一次拷贝,取申请大约4kb的内存,显然是大可不必的。所以在对于占据较大的结构时,应该使用for进行变量操作。

总结

如何选择合适的遍历,在针对与测试场景的情况下,图便捷可以使用range,毕竟for循环需要写一堆的条件,初始值等。但是如果遍历的元素是个占用大个内存的结构的话,避免使用range进行遍历。且如果需要进行修改操作的话只能用for遍历来修改,其实range也可以进行索引遍历的,在本文为写,读者可以去尝试一下。


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

告别StringUtil:使用Java 全新String API优化你的代码

前言  Java 编程语言的每一次重要更新,都引入了许多新功能和改进。 并且在String 类中引入了一些新的方法,能够更好地满足开发的需求,提高编程效率。repeat(int count):返回一个新的字符串,该字符串是由原字符串重复指定次数形成的。isBl...
继续阅读 »

前言

  Java 编程语言的每一次重要更新,都引入了许多新功能和改进。 并且在String 类中引入了一些新的方法,能够更好地满足开发的需求,提高编程效率。

  1. repeat(int count):返回一个新的字符串,该字符串是由原字符串重复指定次数形成的。
  2. isBlank():检查字符串是否为空白字符序列,即长度为 0 或仅包含空格字符的字符串。
  3. lines():返回一个流,该流由字符串按行分隔而成。
  4. strip():返回一个新的字符串,该字符串是原字符串去除前导空格和尾随空格后形成的。
  5. stripLeading():返回一个新的字符串,该字符串是原字符串去除前导空格后形成的。
  6. stripTrailing():返回一个新的字符串,该字符串是原字符串去除尾随空格后形成的。
  7. formatted(Object... args):使用指定的参数格式化字符串,并返回格式化后的字符串。
  8. translateEscapes():将 Java 转义序列转换为相应的字符,并返回转换后的字符串。
  9. transform() 方法:该方法可以将一个函数应用于字符串,并返回函数的结果。

示例

1. repeat(int count)

public class StringRepeatExample {
public static void main(String[] args) {
String str = "abc";
String repeatedStr = str.repeat(3);
System.out.println(repeatedStr);
}
}

输出结果:

abcabcabc

2. isBlank()

public class StringIsBlankExample {
public static void main(String[] args) {
String str1 = "";
String str2 = " ";
String str3 = " \t ";

System.out.println(str1.isBlank());
System.out.println(str2.isBlank());
System.out.println(str3.isBlank());
}
}

输出结果:

true
true
true

3. lines()

import java.util.stream.Stream;

public class StringLinesExample {
public static void main(String[] args) {
String str = "Hello\nWorld\nJava";
Stream<String> lines = str.lines();
lines.forEach(System.out::println);
}
}

输出结果:

Hello
World
Java

4. strip()

public class StringStripExample {
public static void main(String[] args) {
String str1 = " abc ";
String str2 = "\t def \n";
System.out.println(str1.strip());
System.out.println(str2.strip());
}
}

输出结果:

abc
def

5. stripLeading()

public class StringStripLeadingExample {
public static void main(String[] args) {
String str1 = " abc ";
String str2 = "\t def \n";
System.out.println(str1.stripLeading());
System.out.println(str2.stripLeading());
}
}

输出结果:

abc
def

6. stripTrailing()

public class StringStripTrailingExample {
public static void main(String[] args) {
String str1 = " abc ";
String str2 = "\t def \n";
System.out.println(str1.stripTrailing());
System.out.println(str2.stripTrailing());
}
}

输出结果:

abc
def

7. formatted(Object... args)

public class StringFormattedExample {
public static void main(String[] args) {
String str = "My name is %s, I'm %d years old.";
String formattedStr = str.formatted( "John", 25);
System.out.println(formattedStr);
}
}

输出结果:

My name is John, I'm 25 years old.

8. translateEscapes()

public class StringTranslateEscapesExample {
public static void main(String[] args) {
String str = "Hello\\nWorld\\tJava";
String translatedStr = str.translateEscapes();
System.out.println(translatedStr);
}
}

输出结果:

Hello
World Java

9. transform()

public class StringTransformExample {
public static void main(String[] args) {
String str = "hello world";
String result = str.transform(i -> i + "!");
System.out.println(result);
}
}

输出结果:

hello world!

结尾

  如果觉得对你有帮助,可以多多评论,多多点赞哦,也可以到我的主页看看,说不定有你喜欢的文章,也可以随手点个关注哦,谢谢。

  我是不一样的科技宅,每天进步一点点,体验不一样的生活。我们下


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

俺,25岁,踌躇一下?

人比山高大家好,我是寒草,一只工作两年的程序猿。农历七月初五,阳历八月七日是我的生日,小时候总是期盼生日的到来,而且总有一种我过生日的那天我就是山大王的错觉,毕竟可以吃香的,喝辣的。但是我也早就度过了无忧无虑的年纪,而生日这一天的意义也早就有所不同。长大后,每...
继续阅读 »

人比山高

大家好,我是寒草,一只工作两年的程序猿。农历七月初五,阳历八月七日是我的生日,小时候总是期盼生日的到来,而且总有一种我过生日的那天我就是山大王的错觉,毕竟可以吃香的,喝辣的。但是我也早就度过了无忧无虑的年纪,而生日这一天的意义也早就有所不同。

长大后,每当生日临近,总是思绪万千,或是思考过去,或是展望未来,年少时的悠哉不再,而思绪确是一年比一年多,有一位著名工程师曾经说过:

思绪和发量是负相关的。

虽然上面这个定律在我身上还没有印证,但是我不妨借生日这个难得的机会整理一下,收拾行囊,以便整装待发。

脚比路长

写文的当下,我正在思考,去年写文章的时候是什么样的心理:

  • 持续亢奋?
  • 充满希望?
  • 活力四射?

反正在我印象里,去年的我心中是充满希望和活力的,但是现在的我肯定和去年大不相同,可能是因为这一年出现了很多的变化:

  • 停不下来的裁员潮
  • 一座座楼(公司)塌了

听了很多悲伤的事,也经历了很多令人消沉的事,使得我并不会如去年那般纯粹的充满活力,也以更加理性和辩证的去看待问题,世界也不再非黑即白,这大概就是“成长”吧,在此推荐:《少有人走的路

在夜晚一个人走在回家的小路,我会思考很多很多事,但多数像泡沫一样飞散了,但是有一件事是我不会放弃的,也算是我长久以来的梦。

见下文

白山旭日

前一段时间,我开了一个新坑:程序猿之梦!星辰大海的前端建站之路「第一周」,没错,现在的我有一个理想,想创造一个自己的产品,我对她寄托了很多很多情感:

  • 我希望她可以承载更多的美好
  • 我希望她可以创造一股清流在网络社会流淌
  • 我希望她可以为社会提供向上的价值
  • ...

但是这个事情已经停摆了一个月了,毕竟我上个月大概上了 250 个小时的班,工作饱和度也基本来到了 150% ,整个人特别特别疲惫,掘金技术圈的群也是会经常 cue 到我的加班。

不要说我卷,我是很期望刘慈欣摸鱼写《三体》的那种生活的,但是生活所迫。

即使工作比较辛苦让我疲惫至极,我也会想把我的产品搞下去,我总是做不切实际的梦,我总是有一堆幻想,我总希望我从事的事业可以让世界更加美好,我总是焦虑,我总是烦躁,我总是有一种奇奇怪怪的理想主义,还有那么一丝丝的浪漫情怀

所以,无论如何,我欣赏我,我会是我。

黑水金光

前几天农历生日的时候,同事们陪我过了一个生日:

我作为一个东北人,也是第一次吃到铁锅炖大鹅(我怕不是个假东北人),还是很开心的,我从小到大还是第一次这么“正式”的过生日,近几天也陆陆续续的收到一些礼物:

  • 一把工学椅:这样我在家就可以更舒适的创作了
  • 一个木制密码箱:最关键还需要我自己拼(其实我没有什么特别值得放在密码箱里的东西)
  • 特利迦奥特曼:这肯定是很了解我的人才会送的礼物~
  • ...

羁绊越来越多了呢🌟还是要充满阳光的好好生活呀~

旅程

如果大家有耐心读到这里,一定有这样的一个疑问:“你这前四个标题是什么意思?”,其实只是我摘取了我母校校歌中的几句词,同时我也想到很多关于母校的指引我前进的故事:

  • 黄大年教授:振兴中华,乃我辈之责

已故,纪念文:「振兴中华,乃我辈之责」于75周年校庆使用 canvas 纪念黄大年老师

  • 张希校长:群居不倚,独立不惧
  • ...

最后,祝我自己 25 岁生日快乐 🎂

✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨

江山如此多娇,引无数英雄竞折腰。
惜秦皇汉武,略输文采;唐宗宋祖,稍逊风骚。
一代天骄,成吉思汗,只识弯弓射大雕。
俱往矣,数风流人物,还看今朝。

— 《沁园春.雪》

✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨

各位,感谢阅读,一起加油!也欢迎各位加我微信:hancao97 和我交流。

-寒草写于2022.08.06 🔥 To Be Continued-


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

深入理解与运用Android Jetpack ViewModel

在Android开发中,数据与界面的分离一直是一项重要的挑战。为了解决这个问题,Google推出了Android Jetpack组件之一的ViewModel。ViewModel是一种用于管理UI相关数据的架构组件,它能够帮助开发者实现优雅的数据驱动和生命周期管...
继续阅读 »

在Android开发中,数据与界面的分离一直是一项重要的挑战。为了解决这个问题,Google推出了Android Jetpack组件之一的ViewModel。ViewModel是一种用于管理UI相关数据的架构组件,它能够帮助开发者实现优雅的数据驱动和生命周期管理。本文将深入浅出地介绍ViewModel的使用和原理,带你一步步掌握这个强大的组件。

什么是ViewModel

ViewModel是Android Jetpack组件之一,它的主要目的是将UI控制器(如Activity和Fragment)与数据相关的业务逻辑分开,使得UI控制器能够专注于展示数据和响应用户交互,而数据的获取和处理则交由ViewModel来管理。这种分离能够使代码更加清晰、易于测试和维护。

ViewModel的原理

ViewModel的原理其实并不复杂。在设备配置发生变化(如屏幕旋转)导致Activity或Fragment重建时,ViewModel不会被销毁,而是保留在内存中。这样,UI控制器可以在重建后重新获取之前的ViewModel实例,并继续使用其中的数据,从而避免数据丢失和重复加载。

ViewModelStore和ViewModelStoreOwner

ViewModel的原理涉及两个核心概念:ViewModelStore和ViewModelStoreOwner。

ViewModelStore是一个存储ViewModel实例的容器,它的生命周期与UI控制器的生命周期关联。在UI控制器(Activity或Fragment)被销毁时,ViewModelStore会清理其中的ViewModel实例,避免内存泄漏。

ViewModelStoreOwner是拥有ViewModelStore的对象,通常是Activity或Fragment。ViewModelProvider通过ViewModelStoreOwner来获取ViewModelStore,并通过ViewModelStore来管理ViewModel的生命周期。

ViewModelProvider

ViewModelProvider是用于创建和获取ViewModel实例的工具类。它负责将ViewModel与ViewModelStoreOwner关联,并确保ViewModel在合适的时机被销毁。

在Activity中获取ViewModel实例:

viewModel = new ViewModelProvider(this).get(MyViewModel.class);

在Fragment中获取ViewModel实例:

viewModel = new ViewModelProvider(this).get(MyViewModel.class);

使用ViewModel

添加ViewModel依赖

首先,确保你的项目已经使用了AndroidX,并在build.gradle中添加ViewModel依赖:

dependencies {
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.3.1"
}

创建ViewModel

创建ViewModel非常简单,只需继承ViewModel类并在其中定义数据和相关操作。

public class MyViewModel extends ViewModel {
private MutableLiveData<String> data = new MutableLiveData<>();

public LiveData<String> getData() {
return data;
}

public void fetchData() {
// 模拟异步数据获取
new Handler().postDelayed(() -> {
data.setValue("Hello, ViewModel!");
}, 2000);
}
}

在UI控制器中使用ViewModel

在Activity或Fragment中获取ViewModel的实例,并观察数据变化:

viewModel = new ViewModelProvider(this).get(MyViewModel.class);
viewModel.getData().observe(this, data -> {
// 更新UI
textView.setText(data);
});

viewModel.fetchData(); // 触发数据获取操作

ViewModel与跨组件通信

ViewModel不仅仅用于在单个UI控制器内部共享数据,它还可以用于在不同UI控制器之间共享数据,实现跨组件通信。例如,一个Fragment中的数据可以通过ViewModel传递给Activity。

在Activity中共享数据:

sharedViewModel = new ViewModelProvider(this).get(SharedViewModel.class);
sharedViewModel.getData().observe(this, data -> {
// 更新UI
textView.setText(data);
});

在Fragment中共享数据:

sharedViewModel = new ViewModelProvider(requireActivity()).get(SharedViewModel.class);

注意:在跨组件通信时,需要使用同一个ViewModelProvider获取相同类型的ViewModel实例。在Activity中,使用this作为ViewModelProvider的参数,在Fragment中,使用requireActivity()作为参数。

ViewModel与SavedState

有时,我们可能希望在ViewModel中保存一些与UI控制器生命周期无关的数据,以便在重建时恢复状态。ViewModel提供了SavedState功能,它可以让我们在ViewModel中持久化保存数据。

示例代码:

public class MyViewModel extends ViewModel {
private SavedStateHandle savedStateHandle;

public MyViewModel(SavedStateHandle savedStateHandle) {
this.savedStateHandle = savedStateHandle;
}

public LiveData<String> getData() {
return savedStateHandle.getLiveData("data");
}

public void setData(String data) {
savedStateHandle.set("data", data);
}
}

使用SavedStateViewModelFactory创建带有SavedState功能的ViewModel:

public class MyActivity extends AppCompatActivity {
private MyViewModel viewModel;

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);

ViewModelProvider.Factory factory = new SavedStateViewModelFactory(getApplication(), this);
viewModel = new ViewModelProvider(this, factory).get(MyViewModel.class);

viewModel.getData().observe(this, data -> {
// 更新UI
textView.setText(data);
});

if (savedInstanceState == null) {
// 第一次创建时,触发数据获取操作
viewModel.fetchData();
}
}
}

ViewModel使用过程中的注意点

  • 不要在ViewModel中持有Context的引用,避免引发内存泄漏。
  • ViewModel应该只关注数据和业务逻辑,不应处理UI相关的操作。
  • 不要在ViewModel中保存大量数据,避免占用过多内存。
  • 当数据量较大或需要跨进程共享数据时,应该考虑使用其他解决方案,如Room数据库或SharedPreferences。

结论

通过本文的介绍,你已经了解了Android Jetpack ViewModel的使用与原理。ViewModel的出现极大地简化了Android开发中的数据管理和生命周期处理,使得应用更加健壮和高效。在实际开发中,合理使用ViewModel能够帮助你构建优雅、易维护的Android应用。


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

Android TextView中那些冷门好用的用法

介绍TextView 是 Android 开发中最常用的小部件之一。它用于在屏幕上显示文本。但是,TextView 有几个较少为人知的功能,对开发人员非常有用。在本博客文章中,我们将探讨其中的一些功能。自定义字体默认情况下,TextView 使用系统字体显示文...
继续阅读 »

介绍

TextView 是 Android 开发中最常用的小部件之一。它用于在屏幕上显示文本。但是,TextView 有几个较少为人知的功能,对开发人员非常有用。在本博客文章中,我们将探讨其中的一些功能。

自定义字体

默认情况下,TextView 使用系统字体显示文本。但其实我们也可以导入我们自己的字体文件在 TextView 中使用自定义字体。这可以通过将字体文件添加到资源文件夹(res/font 或者 assets)并在 TextView 上以编程方式设置来实现。

要使用自定义字体,我们需要下载字体文件(或者自己生成)并将其添加到资源文件夹中。然后,我们可以使用setTypeface()方法在TextView上以编程方式设置字体。我们还可以在XML中使用android:fontFamily属性设置字体。需要注意的是,fontFamily方式只能使用系统预设的字体并且仅对英文字符有效,如果TextView的文本内容是中文的话这个属性设置后将不会有任何效果。

以下是 Android TextView 自定义字体的代码示例:

  1. 将字体文件添加到 assets 或 res/font 文件夹中。
  2. 通过以下代码设置字体:
// 字体文件放到 assets 文件夹的情况
Typeface tf = Typeface.createFromAsset(getAssets(), "fonts/myfont.ttf");
TextView tv = findViewById(R.id.tv);
tv.setTypeface(tf);
// 字体文件放到 res/font 文件夹的情况, 需注意的是此方式在部分低于 Android 8.0 的设备上可能会存在兼容性问题
val tv = findViewById<TextView>(R.id.tv)
val typeface = ResourcesCompat.getFont(this, R.font.myfont)
tv.typeface = typeface

在上面的示例中,我们首先从 assets 文件夹中创建了一个新的 Typeface 对象。然后,我们使用 setTypeface() 方法将该对象设置为 TextView 的字体。

在上面的示例中,我们将字体文件命名为 “myfont.ttf”。我们可以将其替换为要使用的任何字体文件的名称。

自定义字体是 TextView 的强大功能之一,它可以帮助我们创建具有独特外观和感觉的应用程序。另外,我们也可以通过这种方法实现自定义图标的绘制。

AutoLink

AutoLink 可以自动检测文本中的模式并将其转换为可点击的链接。例如,如果 TextView 包含电子邮件地址或 URL,则 AutoLink 将识别它并使其可点击。此功能使开发人员无需手动创建文本中的可点击链接。

要在 TextView 上启用 AutoLink,您需要将autoLink属性设置为emailphoneweball。您还可以使用Linkify类设置自定义链接模式。

以下是一个Android TextView AutoLink代码使用示例:

<TextView
android:id="@+id/tv3"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:autoLink="web"
android:textColorLink="@android:color/holo_red_dark"
android:text="这是我的个人博客地址: http://www.geektang.cn" />

在上面的示例中,我们将 autoLink 属性设置为 web ,这意味着 TextView 将自动检测文本中的 URL 并将其转换为可点击的链接。我们还将 text 属性将文本设置为 这是我的个人博客地址: http://www.geektang.cn 。当用户单击链接时,它们将被带到 http://www.geektang.cn 网站。另外,我们也可以通过 textColorLink 属性将 Link 颜色为我们喜欢的颜色。

AutoLink是一个非常有用的功能,它可以帮助您更轻松地创建可交互的文本。

对齐模式

对齐模式允许您通过在单词之间添加空格将文本对齐到左右边距,这使得文本更易读且视觉上更具吸引力。您可以将对齐模式属性设置为 inter_word 或 inter_character

要使用对齐模式功能,您需要在 TextView 上设置 justificationMode 属性。但是,此功能仅适用于运行 Android 8.0(API 级别 26)或更高版本的设备。

以下是对齐模式功能的代码示例:

<TextView
android:id="@+id/text_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="This is some sample text that will be justified."
android:justificationMode="inter_word"/>

在上面的示例中,我们将 justificationMode 属性设置为 inter_word 。这意味着 TextView 将在单词之间添加空格,以便将文本对齐到左右边距。

以下是对齐模式功能的显示效果示例:

同样一段文本,上面的设置 justificationMode 为 inter_word ,是不是看起来会比下面的好看一些呢?这个属性一般用于多行英文文本,如果只有一行文本或者文本内容是纯中文字符的话,不会有任何效果。


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

六种常见的排序算法

排序算法数组任意两值交换创建临时变量进行交换private void swap(int[] nums, int idx1, int idx2) { int temp = nums[idx1]; nums[idx1] = nums[idx2]; ...
继续阅读 »

排序算法

数组任意两值交换

创建临时变量进行交换

private void swap(int[] nums, int idx1, int idx2) {
int temp = nums[idx1];
nums[idx1] = nums[idx2];
nums[idx2] = temp;
}

冒泡排序

思路:每次对 [0, j] 进行排序,把该区间中最大的值放到这个区间的最右边

时间复杂度:O(n2)

空间复杂度:O(1)

/**
* 冒泡排序
*
* @param nums 数组
*/
public void bubbleSort(int[] nums) {
for (int i = 0; i < nums.length - 1; i++) {
for (int j = 0; j < nums.length - 1 - i; j++) {
if (nums[j] > nums[j + 1]) {
swap(nums, j, j + 1);
}
}
}
}

选择排序

思路:对于区间 [j, nums.length] (i <= j <= nums.length),每次在这个区间中选择最小的值,插入到 nums[i] 中,即每次选择一个最小的值插入到 nums[i] 中;

时间复杂度:O(n2)

空间复杂度:O(1)

/**
* 插入排序
*
* @param nums 数组
*/
public void insertSort(int[] nums) {
for (int i = 0; i < nums.length; i++) {
int idx = 0;
int min = Integer.MAX_VALUE;
for (int j = i; j < nums.length; j++) {
if (nums[j] < min) {
min = nums[j];
idx = j;
}
}
swap(nums, i, idx);
}
}

插入排序

思路:对于区间 [0, j] ,在 [i, length-1] 的区间中每次使用下标的 i 的数( j <= i ),插入到区间 [0, j] 中,保证 [0, j] 是有序的

时间复杂度:O(n2)

空间复杂度:O(1)

/**
* 插入排序
*
* @param nums 数组
*/
public void insertSort(int[] nums) {
for (int i = 1; i < nums.length; i++) {
int temp = nums[i];
int j = i;
for (; j > 0; j--) {
if (temp < nums[j - 1]) {
nums[j] = nums[j - 1];
} else {
break;
}
}
nums[j] = temp;
}
}

快速排序

思路:

  1. 对于单次的排序 partition() ,定义一个标志 part ,凡是小于该值的都放左边,大于该值的都放右边,最后把该值放到中间,并返回中间的下标 partition ,这里实现的关键是:存在一个指针 j 始终指向左边区间的最靠右的值,若 j + 1,则去到了右区间;
  2. 将数组以 partition 为中点,将数组分成两份,每一份继续进行 partition()

时间复杂度:O(nlogn)

空间复杂度:O(logn)

/**
* 递归函数
*
* @param nums 数组
* @param left 左
* @param right 右
*/
public void quickSort(int[] nums, int left, int right) {

if (left >= right) {
return;
}

int partition = partition(nums, left, right);

quickSort(nums, left, partition - 1);
quickSort(nums, partition + 1, right);
}

/**
* 将小于某个元素的值放到左边,大于某个元素的值放到右边
*
* @param nums 数组
* @param left 左
* @param right 右
* @return 结果
*/
public int partition(int[] nums, int left, int right) {
// 以数组的左边的值作为标记
int part = nums[left];
int i = left + 1;
// j 始终指向左边区间小于或等于 part 的最靠右的值
int j = left;

for (; i < nums.length; i++) {
if (nums[i] < part) {
j++;
swap(nums, i, j);
}
}

swap(nums, j, left);
return j;
}

三向切分快速排序

适用于有重复内容的排序

思路:

  1. 分成三个区间,小于 pivot左区间),等于 pivot中区间),大于 pivot右区间);
  2. 左区间的 lt 指针永远指向该区间的最右的位置,右区间的指针永远指向该区间的最左的位置;
  3. 对于中区间,不断移动游标 i 的位置即可;

时间复杂度:O(nlogn)

空间复杂度:O(logn)

public void threeQuickSort(int[] nums, int left, int right) {
if (left >= right) {
return;
}
int pivot = nums[left];

// [left + 1, lt] 小于 pivot
// [lt + 1, i) 等于 pivot
// [gt, right] 大于 pivot
int lt = left; // 左区间的指针
int gt = right + 1; // 右区间的指针
int i = left + 1;

while (i < gt) {
if (nums[i] < pivot) {
lt++;
swap(nums, i, lt);
i++;
} else if (nums[i] == pivot) {
i++;
} else {
gt++;
swap(nums, gt, i);
}
}
swap(nums, left, lt);
threeQuickSort(nums, left, lt - 1);
threeQuickSort(nums, gt, right);
}

归并排序

思路:

  1. 分隔:先将数组不断分割,直到分割到区间 [left, right] 内只有一个值
  2. 合并:将分隔后的数组不断向上合并,利用临时数组 temp[] 存储 原来 nums 数组 [left,right] 区间的值,然后分别从 temp 数组中 [left, mid] 和 [mid + 1, right] 区间分别取出最小的值,放入 nums 数组对应的位置即可;
  3. 代码的主要难点是 nums 数组 和 temp 数组的下标对应关系
    1. 对应 left,即 [left, mid] 的起点,i 在 temp 数组起始值为 0
    2. 对应 mid + 1,即 [mid + 1, right] 的起点,j 在 temp 数组起始值为 mid - left + 1

时间复杂度:O(nlogn)

空间复杂度:O(n + logn) => O(n):临时的数组和递归时压入栈的数据占用的空间

public void mergeSort(int[] nums, int left, int right) {

if (left >= right) {
return;
}
int mid = left + (right - left) / 2;

mergeSort(nums, left, mid);
mergeSort(nums, mid + 1, right);

merge(nums, left, mid, right);
}

/**
* 合并数组
*
* @param nums 数组
* @param left 左端点
* @param mid 中点
* @param right 右端点
*/
private void merge(int[] nums, int left, int mid, int right) {
int length = right - left + 1;
int[] temp = new int[length];

for (int i = 0; i < length; i++) {
temp[i] = nums[left + i];
}

// i j 为 temp 数组的下标
// 关键是找到 i j 与 原数组 nums 下标的对应关系
int i = 0;
int j = mid - left + 1;
for (int k = 0; k < length; k++) {
if (i == mid - left + 1) {
nums[k + left] = temp[j];
j++;
} else if (j == right - left + 1) {
nums[k + left] = temp[i];
i++;
} else if (temp[i] <= temp[j]) {
nums[k + left] = temp[i];
i++;
} else {
nums[k + left] = temp[j];
j++;
}
}
}

堆排序

思路:

  1. 读者首先搞懂什么是  ,代码示例中介绍的 大顶堆,这里不作过多介绍;
  2. 首先初始化一个大顶堆,每个大顶堆的根节点是最大值;
  3. 不断把根节点的值与数组最后一个值交换,然后长度减 1 再次进行大顶堆的整理操作;

时间复杂度:O(nlogn),每次整理的时间复杂度是 logn,要进行 n 次

空间复杂度:O(1)

/**
* 堆排序
*
* @param nums 数组
*/
public void heapSort(int[] nums) {
initMaxHeap(nums);
int len = nums.length - 1;
while (len > 0) {
swap(nums, 0, len);
len--;
siftDown(nums, 0, len);
}
}

/**
* 初始化为大顶堆
*
* @param nums 数组
*/
public void initMaxHeap(int[] nums) {
int len = nums.length;
for (int i = (len - 1) / 2; i >= 0; i--) {
siftDown(nums, i, len - 1);
}
}

/**
* 向下整理
* @param nums 数组
* @param k 某个节点
* @param len 数组长度
*/
public void siftDown(int[] nums, int k, int len) {

while (k * 2 + 1 <= len) {
int j = k * 2 + 1;
if (j + 1 <= len && nums[j] < nums[j + 1]) {
j++;
}
if (nums[k] > nums[j]) {
break;
}
swap(nums, k, j);
k = j;
}
}

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

Java 理论知识整理

过滤器数据准备DAO 层 UserDao、AccountDao、BookDao、EquipmentDaopublic interface UserDao { public void save(); }@Component("userDao") public ...
继续阅读 »

过滤器

数据准备
  • DAO 层 UserDao、AccountDao、BookDao、EquipmentDao

    public interface UserDao {
    public void save();
    }
    @Component("userDao")
    public class UserDaoImpl implements UserDao {
    public void save() {
    System.out.println("user dao running...");
    }

    }
  • Service 业务层

    public interface UserService {
    public void save();
    }
    @Service("userService")
    public class UserServiceImpl implements UserService {
    @Autowired
    private UserDao userDao;//...........BookDao等

    public void save() {
    System.out.println("user service running...");
    userDao.save();
    }
    }

过滤器

名称:TypeFilter

类型:接口

作用:自定义类型过滤器

示例:

  • config / filter / MyTypeFilter

    public class MyTypeFilter implements TypeFilter {
    @Override
    /**
    * metadataReader:读取到的当前正在扫描的类的信息
    * metadataReaderFactory:可以获取到任何其他类的信息
    */
    //加载的类满足要求,匹配成功
    public boolean match(MetadataReader metadataReader, MetadataReaderFactory metadataReaderFactory) throws IOException {
    //获取当前类注解的信息
    AnnotationMetadata am = metadataReader.getAnnotationMetadata();
    //获取当前正在扫描的类的类信息
    ClassMetadata classMetadata = metadataReader.getClassMetadata();
    //获取当前类资源(类的路径)
    Resource resource = metadataReader.getResource();


    //通过类的元数据获取类的名称
    String className = classMetadata.getClassName();
    //如果加载的类名满足过滤器要求,返回匹配成功
    if(className.equals("service.impl.UserServiceImpl")){
    //返回true表示匹配成功,返回false表示匹配失败。此处仅确认匹配结果,不会确认是排除还是加入,排除/加入由配置项决定,与此处无关
    return true;
    }
    return false;
    }
    }
  • SpringConfig

    @Configuration
    //设置排除bean,排除的规则是自定义规则(FilterType.CUSTOM),具体的规则定义为MyTypeFilter
    @ComponentScan(
    value = {"dao","service"},
    excludeFilters = @ComponentScan.Filter(
    type= FilterType.CUSTOM,
    classes = MyTypeFilter.class
    )
    )
    public class SpringConfig {
    }

导入器

bean 只有通过配置才可以进入 Spring 容器,被 Spring 加载并控制

  • 配置 bean 的方式如下:

    • XML 文件中使用 标签配置
    • 使用 @Component 及衍生注解配置

导入器可以快速高效导入大量 bean,替代 @Import({a.class,b.class}),无需在每个类上添加 @Bean

名称: ImportSelector

类型:接口

作用:自定义bean导入器

  • selector / MyImportSelector

    public class MyImportSelector implements ImportSelector{
    @Override
    public String[] selectImports(AnnotationMetadata importingClassMetadata) {
    // 1.编程形式加载一个类
    // return new String[]{"dao.impl.BookDaoImpl"};

    // 2.加载import.properties文件中的单个类名
    // ResourceBundle bundle = ResourceBundle.getBundle("import");
    // String className = bundle.getString("className");

    // 3.加载import.properties文件中的多个类名
    ResourceBundle bundle = ResourceBundle.getBundle("import");
    String className = bundle.getString("className");
    return className.split(",");
    }
    }
  • import.properties

    #2.加载import.properties文件中的单个类名
    #className=dao.impl.BookDaoImpl

    #3.加载import.properties文件中的多个类名
    #className=dao.impl.BookDaoImpl,dao.impl.AccountDaoImpl

    #4.导入包中的所有类
    path=dao.impl.*
  • SpringConfig

    @Configuration
    @ComponentScan({"dao","service"})
    @Import(MyImportSelector.class)
    public class SpringConfig {
    }

注册器

可以取代 ComponentScan 扫描器

名称:ImportBeanDefinitionRegistrar

类型:接口

作用:自定义 bean 定义注册器

  • registrar / MyImportBeanDefinitionRegistrar

    public class MyImportBeanDefinitionRegistrar implements ImportBeanDefinitionRegistrar {
    /**
    * AnnotationMetadata:当前类的注解信息
    * BeanDefinitionRegistry:BeanDefinition注册类,把所有需要添加到容器中的bean调用registerBeanDefinition手工注册进来
    */
    @Override
    public void registerBeanDefinitions(AnnotationMetadata importingClassMetadata, BeanDefinitionRegistry registry) {
    //自定义注册器
    //1.开启类路径bean定义扫描器,需要参数bean定义注册器BeanDefinitionRegistry,需要制定是否使用默认类型过滤器
    ClassPathBeanDefinitionScanner scanner = new ClassPathBeanDefinitionScanner(registry,false);
    //2.添加包含性加载类型过滤器(可选,也可以设置为排除性加载类型过滤器)
    scanner.addIncludeFilter(new TypeFilter() {
    @Override
    public boolean match(MetadataReader metadataReader, MetadataReaderFactory metadataReaderFactory) throws IOException {
    //所有匹配全部成功,此处应该添加实际的业务判定条件
    return true;
    }
    });
    //设置扫描路径
    scanner.addExcludeFilter(tf);//排除
    scanner.scan("dao","service");
    }
    }
  • SpringConfig

    @Configuration
    @Import(MyImportBeanDefinitionRegistrar.class)
    public class SpringConfig {
    }

处理器

通过创建类继承相应的处理器的接口,重写后置处理的方法,来实现拦截 Bean 的生命周期来实现自己自定义的逻辑

BeanPostProcessor:bean 后置处理器,bean 创建对象初始化前后进行拦截工作的

BeanFactoryPostProcessor:beanFactory 的后置处理器

  •  加载时机:在 BeanFactory 初始化之后调用,来定制和修改 BeanFactory 的内容;所有的 bean 定义已经保存加载到 beanFactory,但是 bean 的实例还未创建
  •   执行流程:
    • ioc 容器创建对象

    • invokeBeanFactoryPostProcessors(beanFactory):执行 BeanFactoryPostProcessor

      • 在 BeanFactory 中找到所有类型是 BeanFactoryPostProcessor 的组件,并执行它们的方法
      • 在初始化创建其他组件前面执行

BeanDefinitionRegistryPostProcessor:

  • 加载时机:在所有 bean 定义信息将要被加载,但是 bean 实例还未创建,优先于 BeanFactoryPostProcessor 执行;利用 BeanDefinitionRegistryPostProcessor 给容器中再额外添加一些组件

  • 执行流程:

    • ioc 容器创建对象

    • refresh() → invokeBeanFactoryPostProcessors(beanFactory)

    • 从容器中获取到所有的 BeanDefinitionRegistryPostProcessor 组件

      • 依次触发所有的 postProcessBeanDefinitionRegistry() 方法
      • 再来触发 postProcessBeanFactory() 方法

监听器

基本概述

ApplicationListener:监听容器中发布的事件,完成事件驱动模型开发

public interface ApplicationListener<E extends ApplicationEvent>

所以监听 ApplicationEvent 及其下面的子事件

应用监听器步骤:

  • 写一个监听器(ApplicationListener实现类)来监听某个事件(ApplicationEvent及其子类)

  • 把监听器加入到容器 @Component

  • 只要容器中有相关事件的发布,就能监听到这个事件;

    •  ContextRefreshedEvent:容器刷新完成(所有 bean 都完全创建)会发布这个事件
    •  ContextClosedEvent:关闭容器会发布这个事件
  • 发布一个事件:applicationContext.publishEvent()

@Component
public class MyApplicationListener implements ApplicationListener<ApplicationEvent> {
//当容器中发布此事件以后,方法触发
@Override
public void onApplicationEvent(ApplicationEvent event) {
System.out.println("收到事件:" + event);
}
}

实现原理

ContextRefreshedEvent 事件:

  • 容器初始化过程中执行 initApplicationEventMulticaster():初始化事件多播器

    • 先去容器中查询 id = applicationEventMulticaster 的组件,有直接返回
    • 没有就执行 this.applicationEventMulticaster = new SimpleApplicationEventMulticaster(beanFactory) 并且加入到容器中
    • 以后在其他组件要派发事件,自动注入这个 applicationEventMulticaster
  • 容器初始化过程执行 registerListeners() 注册监听器

    • 从容器中获取所有监听器:getBeanNamesForType(ApplicationListener.class, true, false)
    • 将 listener 注册到 ApplicationEventMulticaster
  • 容器刷新完成:finishRefresh() → publishEvent(new ContextRefreshedEvent(this))

    发布 ContextRefreshedEvent 事件:

    • 获取事件的多播器(派发器):getApplicationEventMulticaster()

    • multicastEvent 派发事件

      • 获取到所有的 ApplicationListener

      • 遍历 ApplicationListener

        • 如果有 Executor,可以使用 Executor 异步派发 Executor executor = getTaskExecutor()
        • 没有就同步执行 listener 方法 invokeListener(listener, event),拿到 listener 回调 onApplicationEvent

容器关闭会发布 ContextClosedEvent


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

frp内网穿透

frp
Frp是什么简单地说,frp就是一个反向代理软件,它体积轻量但功能很强大,可以使处于内网或防火墙后的设备对外界提供服务,它支持HTTP、TCP、UDP等众多协议。服务端配置SSH连接到VPS之后运行如下命令查看处理器架构,根据架构下载不同版本的frp运行如下命...
继续阅读 »

Frp是什么

简单地说,frp就是一个反向代理软件,它体积轻量但功能很强大,可以使处于内网或防火墙后的设备对外界提供服务,它支持HTTP、TCP、UDP等众多协议。

服务端配置

SSH连接到VPS之后运行如下命令查看处理器架构,根据架构下载不同版本的frp
运行如下命令,根据架构不同,选择相应版本并进行下载

wget https://github.com/fatedier/frp/releases/download/v0.22.0/frp_0.22.0_linux_amd64.tar.gz

然后解压缩

tar -zxvf frp_0.22.0_linux_amd64.tar.gz

服务端的配置我们只需要关注如下几个文件

  • frps
  • frps.ini

这两个文件(s结尾代表server)分别是服务端程序和服务端配置文件
然后修改frps.ini文件

[common]
bind_port = 49273
vhost_http_port = 9001
token = Er3@SGTwHtPl+jMRD0/f3QH/A
  • “bind_port”表示用于客户端和服务端连接的端口,这个端口号我们之后在配置客户端的时候要用到。
  • “vhost_http_port”和“vhost_https_port”用于反向代理HTTP主机时使用。
  • “token”是用于客户端和服务端连接的口令,请自行设置并记录,稍后会用到。

编辑完成后保存(vim保存如果不会请自行搜索)

客户端配置

frp的客户端就是我们想要真正进行访问的那台设备。
同样地,根据客户端设备的情况选择相应的frp程序进行下载,将“frp_0.22.0_windows_amd64.zip”解压
客户端的配置我们只需要关注如下几个文件

  • frpc

  • frpc.ini

    这两个文件(c结尾代表client)分别是客户端程序和客户端配置文件。
    然后修改frpc.ini文件

[common]
server_addr = 52.80.184.170
server_port = 49273
token = Er3@SGTwHtPl+jMRD0/f3QH/A

[sentry]
type = http
local_ip = 10.10.75.137
local_port = 9001
custom_domains = 172.31.20.248

其中common字段下的三项即为服务端的设置。

  • server_addr”为服务端IP地址,填入即可。
  • server_port”为服务器端口,填入你设置的端口号即可。
  • token”是你在服务器上设置的连接口令,原样填入即可。

自定义规则

上面frpc.ini的sentry字段是自己定义的规则,自定义端口对应时格式如下。

  • [xxx]”表示一个规则名称,自己定义,便于查询即可。
  • type”表示转发的协议类型,有TCP和UDP等选项可以选择,如有需要请自行查询frp手册。
  • local_ip”是本地应用的IP地址,按照实际应用工作在本机的IP地址填写即可。
  • local_port”是本地应用的端口号,按照实际应用工作在本机的端口号填写即可。
  • custom_domains”服务端IP地址或域名,可以直接使用服务端ip或者生成一个内网ip。

后台运行脚本

运行服务端

./frpc -c frps.ini

运行客户端

./frpc -c frpc.ini

至此,我们的frp仅运行在前台,如果Ctrl+C停止或者关闭SSH窗口后,frp均会停止运行,因而我们使用 nohup命令将其运行在后台。
服务端创建start.sh脚本文件以及frps.log日志文件 编辑start.sh

nohup ./frps -c frps.ini &> frps.log &

客户端创建start.sh脚本文件以及frpc.log日志文件 编辑start.sh

nohup ./frpc -c frpc.ini &> frpc.log &

客户端和服务端执行start.sh脚本

./stash

查看log日志

tail -f frps.log
tail -f frpc.log

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

一篇文章学会正则表达式(Kotlin举例)

一篇文章学会正则表达式(Kotlin举例)正则表达式是一种用来匹配字符串的工具,它可以在文本中查找特定的模式,从而实现对文本的处理和分析。在很多编程语言中,正则表达式都是非常重要的一部分。了解正则表达式在学习正则表达式之前,我们需要先了解一些基本概念。正则表达...
继续阅读 »

一篇文章学会正则表达式(Kotlin举例)

正则表达式是一种用来匹配字符串的工具,它可以在文本中查找特定的模式,从而实现对文本的处理和分析。在很多编程语言中,正则表达式都是非常重要的一部分。

了解正则表达式

在学习正则表达式之前,我们需要先了解一些基本概念。正则表达式由一系列字符和特殊字符组成,用来匹配字符串中的模式。例如,我们可以使用正则表达式来匹配一个电话号码、一个电子邮件地址或者一个网址。正则表达式中的一些常用特殊字符包括:

  • .:匹配任意一个字符。
  • *:匹配前面的字符零次或多次。
  • +:匹配前面的字符一次或多次。
  • ?:匹配前面的字符零次或一次。
  • |:表示或的关系,匹配两边任意一边的内容。
  • ():表示分组,可以将多个字符组合成一个整体。
  • ^:匹配字符串的开头。
  • $:匹配字符串的结尾。
  • {n}:匹配前面的字符恰好出现 n 次。
  • {n,}:匹配前面的字符

正则表达式的基本语法

正则表达式的基本语法包括两个部分:模式和修饰符。模式是用来匹配字符串的规则,而修饰符则用来控制匹配的方式。

在 Kotlin 中,我们可以使用 Regex 类来表示一个正则表达式。例如,下面的代码定义了一个简单的正则表达式,用来匹配一个由数字组成的字符串:

val pattern = Regex("\\d+")

在这个正则表达式中,\d 表示匹配一个数字,+ 表示匹配前面的字符一次或多次。注意,在 Kotlin 中,我们需要使用 \\ 来表示 \ 字符,因为 \ 在字符串中有特殊的含义。

接下来,我们可以使用 matchEntire 函数来检查一个字符串是否符合这个正则表达式:

val input = "12345"
if (pattern.matchEntire(input) != null) {
println("Match!")
} else {
println("No match.")
}

这个代码会输出 Match!,因为输入的字符串符合正则表达式的规则。

常见的正则表达式的高级用法

除了基本的语法之外,正则表达式还有很多高级用法,可以实现更加复杂的匹配和替换操作。下面是一些常用的高级用法:

1. 捕获组

捕获组是指用 () 包围起来的一部分正则表达式,可以将匹配到的内容单独提取出来。例如,下面的代码定义了一个正则表达式,用来匹配一个由姓和名组成的字符串:

val pattern = Regex("(\\w+)\\s+(\\w+)")
val input = "John Smith"
val matchResult = pattern.matchEntire(input)
if (matchResult != null) {
val firstName = matchResult.groupValues[1]
val lastName = matchResult.groupValues[2]
println("First name: $firstName")
println("Last name: $lastName")
}

在这个代码中,\\w+ 表示匹配一个或多个字母、数字或下划线,\\s+ 表示匹配一个或多个空格。groupValues属性可以返回一个列表,其中包含了所有捕获组的内容。在这个例子中,groupValues[1] 表示第一个捕获组的内容,即姓,groupValues[2] 表示第二个捕获组的内容,即名。

2. 非捕获组

非捕获组是指用 (?:) 包围起来的一部分正则表达式,它和普通的捕获组的区别在于,非捕获组匹配到的内容不会单独提取出来。例如,下面的代码定义了一个正则表达式,用来匹配一个由单词和空格组成的字符串:

val pattern = Regex("(?:\\w+\\s+)+\\w+")
val input = "one two three four"
val matchResult = pattern.matchEntire(input)
if (matchResult != null) {
println("Match!")
} else {
println("No match.")
}

在这个代码中,(?:\\w+\\s+)+ 表示匹配一个或多个单词和空格组成的片段,\\w+ 表示匹配一个或多个字母、数字或下划线,\\s+ 表示匹配一个或多个空格。注意,这个正则表达式并没有使用捕获组,因此 matchResult.groupValues 的结果是一个空列表。

3. 零宽断言

零宽断言是指用 (?=) 或 (?!) 包围起来的一部分正则表达式,它可以在匹配的时候不消耗任何字符。例如,下面的代码定义了一个正则表达式,用来匹配一个以 http 或 https 开头的 URL:

val pattern = Regex("(?=http|https)\\w+")
val input = "https://www.google.com"
val matchResult = pattern.find(input)
if (matchResult != null) {
println("Match: ${matchResult.value}")
} else {
println("No match.")
}

在这个代码中,(?=http|https) 表示匹配一个以 http 或 https 开头的字符串,但是不消耗任何字符。find 函数可以在输入字符串中查找第一个匹配的子串,返回一个 MatchResult? 类型的结果。

总结

本文介绍了正则表达式的基本概念和语法,以及一些常用的高级用法。在实际的编程中,正则表达式是一种非常有用的工具,可以帮助我们快速地处理和分析文本数据。在 Kotlin 中,我们可以使用 Regex 类来表示和操作正则表达式,同时还可以使用一些高级用法来实现更加复杂的匹配和替换操作。


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

ThreadLocal的实现原理,ThreadLocal为什么使用弱引用

前言本文将讲述ThreadLocal的实现原理,还有## ThreadLocal为什么使用弱引用。ThreadLocalThreadLocal 是 Java 中的一个类,用于在多线程环境下为每个线程提供独立的变量副本。它通常用于解决多线程并发访问共享变量时的线...
继续阅读 »

前言

本文将讲述ThreadLocal的实现原理,还有## ThreadLocal为什么使用弱引用。

ThreadLocal

ThreadLocal 是 Java 中的一个类,用于在多线程环境下为每个线程提供独立的变量副本。它通常用于解决多线程并发访问共享变量时的线程安全性问题。

ThreadLocal 的工作原理是每个线程内部维护一个 ThreadLocalMap 对象,该对象用于存储每个线程的变量副本。当通过 ThreadLocal 对象获取变量时,它会首先检查当前线程是否已经创建了该变量的副本,如果有,则直接返回副本;如果没有,则通过初始化方法创建一个新的副本,并将其保存在当前线程的 ThreadLocalMap 中

使用 ThreadLocal 时,每个线程都可以独立地访问和修改自己的变量副本,而不会影响其他线程的副本。这使得在多线程环境中共享变量变得更加安全和可靠。

需要注意的是,使用 ThreadLocal 时要注意及时清理不再使用的变量副本,以避免内存泄漏问题。可以通过调用 remove() 方法来清除当前线程的变量副本。

源码解释

set方法源码

// ThreadLocal的set方法,value是要保存的值
public void set(T value) {
   // 得到当前线程对象
   Thread t = Thread.currentThread();
   // 得到当前线程对象关联的ThreadLocalMap对象
   ThreadLocalMap map = getMap(t);
  // 得到map对象就保存值,键为当前ThreadLocal对象
   // 如果没有map对象就创建一个map对象,保存值
   if (map != null)
       map.set(this, value);
   else
       createMap(t, value);
}
// 得到当前线程关联的ThreadLocalMap对象
ThreadLocalMap getMap(Thread t) {
      return t.threadLocals;
}
// 创建一个ThreadLocalMap对象,赋给当前线程的threadLocals属性,并且存入值
void createMap(Thread t, T firstValue) {
      t.threadLocals = new ThreadLocalMap(this, firstValue);
}
private void set(ThreadLocal<?> key, Object value) {
   Entry[] tab = table;
   int len = tab.length;
   // 通过key计算在tab数组中的槽位i
   int i = key.threadLocalHashCode & (len-1);
// 拿到槽位上的Entry对象,如果不为null,则进入循环,如果为null则表示可以直接加入该槽位
   // e = tab[i = nextIndex(i, len)])取出下一个槽位的Entry实体,如果为null,则表示可以直接添加进该槽位
   for (Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) {
       // 拿到与当前Entry有关联的ThreadLocal对象
       ThreadLocal<?> k = e.get();
   // 如果k与当前要保存值的key相等,则替换掉value,相当于修改key的值
       if (k == key) {
           e.value = value;
           return;
      }
// 检查当前节点的ThreadLocal如果为null,表示ThreadLocal已经被gc回收,则调用 replaceStaleEntry() 方法来替换陈旧的 Entry,将新的 ThreadLocal 和值插入到数组中的索引位置 i 处,并返回。
       if (k == null) {
           replaceStaleEntry(key, value, i);
           return;
      }
  }
// 创建一个Entry对象,加入i槽位
   tab[i] = new Entry(key, value);
   // 记录Entry对象个数
   int sz = ++size;
   // cleanSomeSlots清理陈旧的Entry,清理完后如果大于阈值,则调用rehash扩容数组
   if (!cleanSomeSlots(i, sz) && sz >= threshold)
       rehash();
}

get方法源码

public T get() {
   // 获取当前线程对象
   Thread t = Thread.currentThread();
   // 得到当前线程关联的ThreadLocalMap对象
   ThreadLocalMap map = getMap(t);
   if (map != null) {
       // 通过key获取到Entry对象
       ThreadLocalMap.Entry e = map.getEntry(this);
       // Entry不为空,则直接获取值返回结果
       if (e != null) {
           @SuppressWarnings("unchecked")
           T result = (T)e.value;
           return result;
      }
  }
   // 如果map为null,或者Entry为null,则返回一个初始化值
   return setInitialValue();
}
private T setInitialValue() {
  // 如果是在调用构造器初始化的ThreadLocal对象,该方法直接返回null
  // 如果是调用的静态方法withInitial,则返回你指定的一个初始化则
  // 并且还会把该初始化的值保存进ThreadLocalMap
       T value = initialValue();
       Thread t = Thread.currentThread();
       ThreadLocalMap map = getMap(t);
       if (map != null)
           map.set(this, value);
       else
           createMap(t, value);
       return value;
}
public static <S> ThreadLocal<S> withInitial(Supplier<? extends S> supplier) {
  // SuppliedThreadLocal是ThreadLocal的子类,重写了initialValue方法,通过传入一个Supplier,指定初始化值
       return new SuppliedThreadLocal<>(supplier);
}
private Entry getEntry(ThreadLocal<?> key) {
   // 计算当前key的落脚点
   int i = key.threadLocalHashCode & (table.length - 1);
   // 取出落脚点的Entry对象
   Entry e = table[i];
   // 如果e不为空,并且跟e关联的ThreadLocal对象等于当前的key,则返回当前e对象
   if (e != null && e.get() == key)
       return e;
   // 否则进入getEntryAfterMiss
   else
       return getEntryAfterMiss(key, i, e);
}
private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {
   Entry[] tab = table;
   int len = tab.length;
   // 如果e为null,则直接返回null,表示当前key并没有数据
   while (e != null) {
  // 取出与e关联的ThreadLocal对象
  ThreadLocal<?> k = e.get();
       // 判断k是否等于当前的ThreadLocal对象
       if (k == key)
           return e;
       // 当前k是否等于null,为null表示被gc垃圾回收,就清理旧的Entry对象
       if (k == null)
           expungeStaleEntry(i);
       else
           // 否则k不为null,取出下一个槽位,接着循环
           i = nextIndex(i, len);
       e = tab[i];
  }
   return null;
}

总结

可以看出实际保存线程局部变量的是ThreadLocalMap对象,每个线程都有一个这样的对象,保存的是键值对,键为当前的ThreadLocal对象,ThreadLocal对象一般设置为静态,非静态只会造成对象的冗余,因为ThreadLocalMap的键只能是当前ThreadLocal对象,所以只能保存一个键值对,如果要保存多个键值对,可以定义多个ThreadLocal对象作为不同的键,这样获取到的还是与线程有关联的ThreadLocalMap对象,而ThreadLocalMap的键是当前的ThreadLocal对象,多少个该对象,那就可以保存多少个值

强软弱虚四大引用

在Java中,引用是用于引用对象的一个机制,它允许我们通过引用变量来操作和访问对象。在Java中,主要有以下几种引用类型:

  1. 强引用(Strong Reference):这是最常见的引用类型。当我们使用 new 关键字创建对象时,默认就是使用强引用。如果一个对象具有强引用,即存在一个强引用变量引用它,那么垃圾回收器就不会回收该对象。只有当对象没有任何强引用时,才会被认为是不再需要的,可以被垃圾回收
  2. 软引用(Soft Reference):软引用用于描述还有用但非必需的对象。在内存不足时,垃圾回收器可能会选择回收软引用对象。使用软引用可以实现一些缓存功能,在内存不足时释放缓存中的对象,从而避免 OutOfMemoryError。可以使用 SoftReference 类来创建软引用。
  3. 弱引用(Weak Reference):弱引用的生命周期更短暂,只要垃圾回收器发现一个对象只有弱引用与之关联,就会立即回收该对象。弱引用通常用于实现一些特定的缓存或关联数据结构,当对象的强引用被释放后,关联的弱引用对象也会被自动清除。可以使用 WeakReference 类来创建弱引用。
  4. 虚引用(Phantom Reference):虚引用是最弱的引用类型,几乎没有实际的使用场景。虚引用的主要作用是跟踪对象被垃圾回收的状态。当垃圾回收器决定回收一个对象时,如果该对象有虚引用,将会在对象被回收之前,将虚引用加入到与之关联的引用队列中,供应用程序获取对象回收的状态信息。

在内存管理方面,软引用和弱引用都可以用于解决一些特定的内存问题,例如缓存管理或对象关联。它们对于临时性或可替代性对象的管理非常有用,可以在内存紧张时进行垃圾回收,从而提高系统的性能和可用性。然而,需要注意的是,对于软引用和弱引用对象,程序应该在使用时进行必要的判空和恢复处理,以避免 NullPointerException 和其他相关问题。

ThreadLocal为什么使用弱引用

ThreadLocal 使用弱引用的主要原因是为了避免内存泄漏问题。

当使用强引用持有 ThreadLocal 对象时,只有线程销毁或显式地调用 remove() 方法时,Entry 才会被释放。这可能导致在多线程环境下使用线程池时,即使线程已经使用结束处于空闲状态,对应的 Entry 仍然会存在于 ThreadLocalMap 中,导致无法回收相关资源,从而造成内存泄漏。

使用弱引用作为 ThreadLocal 的键(key),可以解决这个问题。弱引用在垃圾回收时只要发现只有弱引用指向,则会被直接回收。因此,当线程结束且对应的 ThreadLocal 对象只有弱引用存在时,垃圾回收器会自动清理该弱引用,进而清理 ThreadLocalMap 中对应的 Entry。这样可以避免内存泄漏问题。

需要注意的是,尽管 ThreadLocalMap 使用了弱引用来避免内存泄漏问题,但仍然需要在使用 ThreadLocal 后调用 remove() 方法,以确保及时清理 ThreadLocal 对象和对应的值。这是因为弱引用的回收时机不确定,不能完全依赖垃圾回收器的工作。

当我们应该请求进来分配一个线程处理请求,此时ThreadLocal对象就会被创建,并且是一个强引用,当第一次把值存入时ThreadLocal时,就会通过Thread拿到或者创建一个ThreadLocalMap对象,并且存入我们的数据,此时ThreadLocal作为键就会被放入弱引用中,此时就算发送垃圾回收也不会回收ThreadLocal因为有一个强引用指向,但是一旦我们的请求执行完毕返回,线程处于空闲状态时,这个强引用就没了,此时就剩下一个弱引用,这个时候发生垃圾回收就ThreadLocal就会被收回。


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

Kotlin | 高阶函数reduce()、fold()详解

在 Kotlin 中,reduce() 和 fold() 是函数式编程中常用的高阶函数。它们都是对集合中的元素进行聚合操作的函数,将一个集合中的元素缩减成一个单独的值。它们的使用方式非常相似,但是返回值略有不同...
继续阅读 »

在 Kotlin 中,reduce() 和 fold() 是函数式编程中常用的高阶函数。它们都是对集合中的元素进行聚合操作的函数,将一个集合中的元素缩减成一个单独的值。它们的使用方式非常相似,但是返回值略有不同。下面是它们的区别:

  • reduce() 函数是对集合中的所有元素进行聚合处理,并返回最后一个合并处理值。
  • fold() 函数除了合并所有元素之外,还可以接受一个初始值,并将其与聚合结果一起返回。注:如果集合为空的话,只会返回初始值。

reduce示例

1、使用 reduce() 函数计算列表中所有数字的总和:

fun reduceAdd() {
val list = listOf(1, 2, 3, 4, 5)
val sum = list.reduce { acc, i ->
println("acc:$acc, i:$i")
acc + i
}
println("sum is $sum") // 15
}

执行结果:

acc:1, i:2
acc:3, i:3
acc:6, i:4
acc:10, i:5
sum is 15

2、使用 reduce() 函数计算字符串列表中所有字符串的拼接结果:

val strings = listOf("apple", "banana", "orange", "pear")
val result = strings.reduce { acc, s -> "$acc, $s" }
println(result) // apple, banana, orange, pear

执行结果:

apple, banana, orange, pear

fold示例

1、使用 fold() 函数计算列表中所有数字的总和,并在其基础上加上一个初始值:

val numbers = listOf(1, 2, 3, 4, 5)
val sum = numbers.fold(10) { acc, i -> acc + i }
println(sum) // 25

执行结果为:

acc:10, i:1
acc:11, i:2
acc:13, i:3
acc:16, i:4
acc:20, i:5
sum is 25

2、使用 fold() 函数将列表中的所有字符串连接起来,并在其基础上加上一个初始值:

val strings = listOf("apple", "banana", "orange", "pear")
val result = strings.fold("Fruits:") { acc, s -> "$acc $s" }
println(result) // Fruits: apple banana orange pear

执行结果:

Fruits: apple banana orange pear

源码解析

  • reduce() 在Kotlin标准库的实现如下:
public inline fun <S, T : S> Iterable<T>.reduce(operation: (acc: S, T) -> S): S {
val iterator = this.iterator()
if (!iterator.hasNext()) throw UnsupportedOperationException("Empty collection can't be reduced.")
var accumulator: S = iterator.next()
while (iterator.hasNext()) {
accumulator = operation(accumulator, iterator.next())
}
return accumulator
}

从代码中可以看出,reduce函数接收一个operation参数,它是一个lambda表达式,用于聚合计算。reduce函数首先获取集合的迭代器,并判断集合是否为空,若为空则抛出异常。然后通过迭代器对集合中的每个元素进行遍历操作,对元素进行聚合计算,将计算结果作为累加器,传递给下一个元素,直至聚合所有元素。最后返回聚合计算的结果。

  • fold() 在Kotlin标准库的实现如下:
public inline fun <T, R> Iterable<T>.fold(
initial: R,
operation: (acc: R, T) -> R
): R {
var accumulator: R = initial
for (element in this) {
accumulator = operation(accumulator, element)
}
return accumulator
}

从代码中可以看出,fold函数接收两个参数,initial参数是累加器的初始值,operation参数是一个lambda表达式,用于聚合计算。

fold函数首先将初始值赋值给累加器,然后对集合中的每个元素进行遍历操作,对元素进行聚合计算,将计算结果作为累加器,传递给下一个元素,直至聚合所有元素。最后返回聚合计算的结果。

总结

  • reduce()适用于不需要初始值的聚合操作,fold()适用于需要初始值的聚合操作。
  • reduce()操作可以直接返回聚合后的结果,而fold()操作需要通过lambda表达式的返回值来更新累加器的值。

在使用时,需要根据具体场景来选择使用哪个函数。


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

Kotlin 字符串常用的操作符

字符串常用的操作符commonPrefixWith返回两个字符串中最长的相同前缀,如果它们没有共同的前缀,则返回空字符串,可以定义 ignoreCase 为 true忽略大小写val action = "蔡徐坤唱跳rap" val...
继续阅读 »

字符串常用的操作符

commonPrefixWith

返回两个字符串中最长的相同前缀,如果它们没有共同的前缀,则返回空字符串,可以定义 ignoreCase 为 true忽略大小写

val action = "蔡徐坤唱跳rap"
val time = "蔡徐坤两年半"
val introduce = "个人练习生蔡徐坤喜欢唱跳rap"
println(action.commonPrefixWith(time)) // 蔡徐坤
println(action.commonPrefixWith(introduce)) // ""

源码实现

// 通过while获取两个字符串同个索引下的字符是否相等
// 最后通过subSequence切割字符串
public fun CharSequence.commonPrefixWith(other: CharSequence, ignoreCase: Boolean = false): String {
val shortestLength = minOf(this.length, other.length)

var i = 0
while (i < shortestLength && this[i].equals(other[i], ignoreCase = ignoreCase)) {
i++
}
if (this.hasSurrogatePairAt(i - 1) || other.hasSurrogatePairAt(i - 1)) {
i--
}
return subSequence(0, i).toString()
}

commonSuffixWith

返回两个字符串中最长的相同后缀,如果它们没有共同的后缀,则返回空字符串,可以定义 ignoreCase 为 true忽略大小写

val action = "蔡徐坤唱跳rap"
val time = "蔡徐坤两年半"
val introduce = "个人练习生蔡徐坤喜欢唱跳rap"
println(action.commonSuffixWith(time))
println(action.commonSuffixWith(introduce))

源码实现

// 与commonPrefixWith的实现差不多,只是commonSuffixWith是倒序循环
public fun CharSequence.commonSuffixWith(other: CharSequence, ignoreCase: Boolean = false): String {
val thisLength = this.length
val otherLength = other.length
val shortestLength = minOf(thisLength, otherLength)

var i = 0
while (i < shortestLength && this[thisLength - i - 1].equals(other[otherLength - i - 1], ignoreCase = ignoreCase)) {
i++
}
if (this.hasSurrogatePairAt(thisLength - i - 1) || other.hasSurrogatePairAt(otherLength - i - 1)) {
i--
}
return subSequence(thisLength - i, thisLength).toString()
}

contains

判断字符串是否包含某字符或某字符串,可以定义 ignoreCase 为 true 忽略大小写

val introduce = "个人练习生蔡徐坤喜欢唱跳rap"
println(introduce.contains('唱')) // true
println(introduce.contains("蔡徐坤")) // true
println("蔡徐坤" in introduce) // // 同上,contains是重载操作符,可以使用该表达式
println(introduce.contains("Rap", ignoreCase = true)) // true
println("Rap" !in introduce) // !in表示不包含的意思,与!introduce.contains("Rap")是同个意思

源码实现

// 通过indexOf判断字符是否存在
public operator fun CharSequence.contains(char: Char, ignoreCase: Boolean = false): Boolean =
indexOf(char, ignoreCase = ignoreCase) >= 0

// 通过indexOf判断字符串是否存在
public operator fun CharSequence.contains(other: CharSequence, ignoreCase: Boolean = false): Boolean =
if (other is String)
indexOf(other, ignoreCase = ignoreCase) >= 0
else
indexOf(other, 0, length, ignoreCase) >= 0

endsWith

判断字符串是否以某字符或某字符串作为后缀,可以定义 ignoreCase 为 true 忽略大小写

val introduce = "个人练习生蔡徐坤喜欢唱跳rap"
println(introduce.endsWith("蔡徐坤")) // false
println(introduce.endsWith("唱跳rap")) // true

源码实现

// 字符直接判断最末尾的字符
public fun CharSequence.endsWith(char: Char, ignoreCase: Boolean = false): Boolean =
this.length > 0 && this[lastIndex].equals(char, ignoreCase)
// 如果都是String,返回String.endsWith,否则返回regionMatchesImpl
public fun CharSequence.endsWith(suffix: CharSequence, ignoreCase: Boolean = false): Boolean {
if (!ignoreCase && this is String && suffix is String)
return this.endsWith(suffix)
else
return regionMatchesImpl(length - suffix.length, suffix, 0, suffix.length, ignoreCase)
}

// 不忽略大小写,返回java.lang.String.endsWith
public actual fun String.endsWith(suffix: String, ignoreCase: Boolean = false): Boolean {
if (!ignoreCase)
return (this as java.lang.String).endsWith(suffix)
else
return regionMatches(length - suffix.length, suffix, 0, suffix.length, ignoreCase = true)
}

equals

判断两个字符串的值是否相等,可以定义 ignoreCase 为 true 忽略大小写

val introduce = "蔡徐坤rap"
println(introduce.equals("蔡徐坤Rap")) // false
println(introduce == "蔡徐坤Rap") // 同上,因为equals是重载操作符,通常使用 == 表示即可
println(introduce.equals("蔡徐坤Rap", false)) // true

源码实现

// 通过java.lang.String的equals和equalsIgnoreCase判断
public actual fun String?.equals(other: String?, ignoreCase: Boolean = false): Boolean {
if (this === null)
return other === null
return if (!ignoreCase)
(this as java.lang.String).equals(other)
else
(this as java.lang.String).equalsIgnoreCase(other)
}

ifBlank

如果字符串都是空格,将字符串转成默认值。这个操作符非常有用

val whitespace = "    ".ifBlank { "default" }
val introduce = "蔡徐坤rap".ifBlank { "default" }
println(whitespace) // default
println(introduce) // 蔡徐坤rap

源码实现

public inline fun <C, R> C.ifBlank(defaultValue: () -> R): R where C : CharSequence, C : R =
if (isBlank()) defaultValue() else this

ifEmpty

如果字符串都是空字符串,将字符串转成默认值。这个操作符非常有用,省去了你去判断空字符串然后再次赋值的操作

val whitespace = "    ".ifEmpty { "default" }
val empty = "".ifEmpty { "default" }
val introduce = "蔡徐坤rap".ifEmpty { "default" }
println(whitespace) // " "
println(empty) // default
println(introduce) // 蔡徐坤rap

判断空字符串、null 和空格字符串

  • isEmpty 判断空字符串
  • isBlank 判断字符串都是空格
  • isNotBlank 与 isBlank 相反,判断字符串不是空格
  • isNotEmpty 与 isEmpty 相反,判断字符串不是空格
  • isNullOrBlank 判断字符串不是 null 和 空格
  • isNullOrEmpty 判断字符串不是 null 和 空字符串

lines

将字符串以换行符或者回车符进行分割,返回每一个分割的子字符串 List<String>

val article = "大家好我是练习时长两年半的个人练习生\n蔡徐坤\r喜欢唱跳rop"
println(article.lines()) // [大家好我是练习时长两年半的个人练习生, 蔡徐坤, 喜欢唱跳rop]

源码实现

// 大概就是通过Sequence去切割字符串
public fun CharSequence.lines(): List<String> = lineSequence().toList()
public fun CharSequence.lineSequence(): Sequence<String> = splitToSequence("\r\n", "\n", "\r")

public fun <T> Sequence<T>.toList(): List<T> {
return this.toMutableList().optimizeReadOnlyList()
}

lowercase

将字符串都转换成小写

val introduce = "蔡徐坤RaP"
println(introduce.lowercase()) // 蔡徐坤rap

源码实现

// 通过java.lang.String的toLowerCase方法实现,其实很多kotlin的方法都是调用java的啦
public actual inline fun String.lowercase(): String = (this as java.lang.String).toLowerCase(Locale.ROOT)

replace

将字符串内的某一部分替换为新的值,可以定义 ignoreCase 为 true 忽略大小写

val introduce = "蔡徐坤rap"
println(introduce.replace("rap", "RAP"))
println(introduce.replace("raP", "RAP", ignoreCase = true))

源码实现

// 首先通过indexOf判断是否存在要被替换的子字符串
// do while循环添加被替换之后的字符串,因为字符串有可能是有多个地方需要替换,所有通过occurrenceIndex判断是否还有需要被替换的部分
public actual fun String.replace(oldValue: String, newValue: String, ignoreCase: Boolean = false): String {
run {
var occurrenceIndex: Int = indexOf(oldValue, 0, ignoreCase)
// FAST PATH: no match
if (occurrenceIndex < 0) return this

val oldValueLength = oldValue.length
val searchStep = oldValueLength.coerceAtLeast(1)
val newLengthHint = length - oldValueLength + newValue.length
if (newLengthHint < 0) throw OutOfMemoryError()
val stringBuilder = StringBuilder(newLengthHint)

var i = 0
do {
stringBuilder.append(this, i, occurrenceIndex).append(newValue)
i = occurrenceIndex + oldValueLength
if (occurrenceIndex >= length) break
occurrenceIndex = indexOf(oldValue, occurrenceIndex + searchStep, ignoreCase)
} while (occurrenceIndex > 0)
return stringBuilder.append(this, i, length).toString()
}
}

startsWith

判断字符串是否以某字符或某字符串作为前缀,可以定义 ignoreCase 为 true 忽略大小写

val introduce = "rap"
println(introduce.startsWith("Rap"))
println(introduce.startsWith("Rap", ignoreCase = true))

源码实现

// 还是调用的java.lang.String的startsWith
public actual fun String.startsWith(prefix: String, ignoreCase: Boolean = false): Boolean {
if (!ignoreCase)
return (this as java.lang.String).startsWith(prefix)
else
return regionMatches(0, prefix, 0, prefix.length, ignoreCase)
}

substringAfter

获取分割符之后的子字符串,如果不存在该分隔符默认返回原字符串,当然你可以自定义返回

例如在截取 ip:port 格式的时候,分隔符就是 :

val ipAddress = "192.168.1.1:8080"
println(ipAddress.substringAfter(":")) // 8080
println(ipAddress.substringAfter("?")) // 192.168.1.1:8080
println(ipAddress.substringAfter("?", missingDelimiterValue = "没有?这个子字符串")) // 没有?这个子字符串

源码实现

// 还是通过substring来截取字符串的
public fun String.substringAfter(delimiter: String, missingDelimiterValue: String = this): String {
val index = indexOf(delimiter)
return if (index == -1) missingDelimiterValue else substring(index + delimiter.length, length)
}

substringAfterLast

与 substringAfter 是同一个意思,不同的是如果一个字符串中有多个分隔符,substringAfter 是从第一个开始截取字符串,substringAfterLast 是从最后一个分隔符开始截取字符串

val network = "255.255.255.0:192.168.1.1:8080"
println(network.substringAfter(":")) // 192.168.1.1:8080
println(network.substringAfterLast(":")) // 8080

源码实现

// 源码和substringAfter差不多,只是substringAfterLast获取的是最后一个分割符的索引
public fun String.substringAfterLast(delimiter: String, missingDelimiterValue: String = this): String {
val index = lastIndexOf(delimiter)
return if (index == -1) missingDelimiterValue else substring(index + delimiter.length, length)
}

substringBefore

获取分割符之前的子字符串,如果不存在该分隔符默认返回原字符串,当然你可以自定义返回,与 substringAfter 刚好相反

val ipAddress = "192.168.1.1:8080"
println(ipAddress.substringBefore(":")) // 192.168.1.1
println(ipAddress.substringBefore("?")) // 192.168.1.1:8080
println(ipAddress.substringBefore("?", missingDelimiterValue = "没有?这个子字符串")) // 没有?这个子字符串

源码实现

// 还是通过substring来截取字符串的,只是是从索引0开始截取子字符串
public fun String.substringBefore(delimiter: String, missingDelimiterValue: String = this): String {
val index = indexOf(delimiter)
return if (index == -1) missingDelimiterValue else substring(0, index)
}

substringBeforeLast

与 substringBefore 是同一个意思,不同的是如果一个字符串中有多个分隔符,substringBefore 是从第一个开始截取字符串,substringBeforeLast 是从最后一个分隔符开始截取字符串

val network = "255.255.255.0:192.168.1.1:8080"
println(network.substringBefore(":")) // 255.255.255.0
println(network.substringBeforeLast(":")) // 255.255.255.0:192.168.1.1

源码实现

// 源码和substringBefore差不多,只是substringBeforeLast获取的是最后一个分割符的索引
public fun String.substringBeforeLast(delimiter: String, missingDelimiterValue: String = this): String {
val index = lastIndexOf(delimiter)
return if (index == -1) missingDelimiterValue else substring(0, index)
}

trim

去掉字符串首尾的空格符,如果要去掉字符串中间的空格符请用 replace

val introduce = "  个人练习生蔡徐坤  喜欢唱跳rap  "
println(introduce.trim()) // 个人练习生蔡徐坤 喜欢唱跳rap

uppercase

将字符串都转换成大写

源码实现

// java.lang.String.toUpperCase
public actual inline fun String.uppercase(): String = (this as java.lang.String).toUpperCase(Locale.ROOT)

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

简单教你Intent如何传大数据

前言最近想不出什么比较好的内容,但是碰到一个没毕业的小老弟问的问题,那就借机说说这个事。Intent如何传大数据?为什么是简单的说,因为这背后深入的话,有很多底层的细节包括设计思想,我也不敢说完全懂,但我知道当你用Intent传大数据报错的时候应该怎么解决,并...
继续阅读 »

前言

最近想不出什么比较好的内容,但是碰到一个没毕业的小老弟问的问题,那就借机说说这个事。Intent如何传大数据?为什么是简单的说,因为这背后深入的话,有很多底层的细节包括设计思想,我也不敢说完全懂,但我知道当你用Intent传大数据报错的时候应该怎么解决,并且简单聊聊这背后所涉及到的东西。

Intent传大数据

平时可能不会发生这种问题,但比如我之前是做终端设备的,我的设备每秒都会生成一些数据,而长时间的话数据量自然大,这时当我跳到另外一个页面使用intent把数据传过去的时候,就会报错

我们调用

intent.putExtra("key", value) // value超过1M

会报错

android.os.TransactionTooLargeException: data parcel size xxx bytes

这里的xxx就是1M左右,告诉你传输的数据大小不能超过1M,有些话咱也不敢乱说,有点怕误人子弟。我这里是凭印象说的,如果有大佬看到我说错,请狠狠的纠正我。

这个错误描述是这么描述,但真的是限死1M吗,说到这个,就不得不提一样东西,Binder机制,先不要跑,这里不会详细讲Binder,只是提一嘴。

说到Binder那就会联系到mmap内存映射,你可以先简单理解成内存映射是分配一块空间给内核空间和用户空间共用,如果还是不好理解,就简单想成分配一块空间通信用,那在android中mmap分配的空间是多少呢?1M-4K。

那是不是说Intent传输的数据超过1M-4K就会报错,理论上是这样,但实际没到这个值,比如0.8M也可能会报错。所以你不能去走极限操作,比如你的数据到了1M,你觉得只要减少点数据,减到8K,应该就能过了,也许你自己测试是正常的,但是这很危险。

所以能不传大数据就不要传大数据,它的设计初衷也不是为了传大数据用的。如果真要传大数据,也不要走极限操作。

那怎么办,切莫着急,请听我慢慢讲。就这个Binder它是什么玩意,它是Android中独特的进程通信的方式,而Linux中进程通信的方式,在Android中同样也适用。进程间通信有很多方式,Binder、管道、共享内存等。为什么会有这么多种通信方式,因为每种通信方式都有自己的特点,要在不同的场合使用不同的通信方式。

为什么要提这个?因为要看懂这个问题,你需要知道Binder这种通信方式它有什么特点,它适合大量的数据传输吗?那你Binder又与我Intent何干,你抓周树人找我鲁迅干嘛~~所以这时候你就要知道Android四大组件之间是用什么方式通信的。

有点扯远了,现在可以来说说结论了,Binder没办法传大数据,我就1M不到你想怎样?当然它不止1M,只是Android在使用时限制了它只能最多用1M,内核的最大限制是4M。又有点扯远了,你不要想着怎么把限制扩大到4M,不要往这方面想。前面说了,不同的进程通信方式,有自己的特点,适用于某些特定的场景。那Binder不适用于传输大数据,我共享内存行不行?

所以就有了解决办法

bundle.putBinder()

有人可能一看觉得,这有什么不同,这在表面上看差别不大,实则内部大大的不同,bundle.putBinder()用了共享内存,所以能传大数据,那为什么这里会用共享内存,而putExtra不是呢?想搞清楚这个问题,就要看源码了。 这里就不深入去分析了,我怕劝退,不是劝退你们,是劝退我自己。有些东西是这样的,你要自己去看懂,看个大概就差不多,但是你要讲出来,那就要看得细致,而有些细节确实会劝退人。所以想了解为什么的,可以自己去看源码,不想看的,就知道这是怎么一回事就行。

那还有没有其它方式呢?当然有,你不懂共享内存,你写到本地缓存中,再从本地缓存中读取行不行?

办法有很多,如果你不知道这个问题怎么解决,你找不到你觉得可行的解决方案,甚至可以通过逻辑通过流程的方式去绕开这个问题。但是你要知道为什么会出现这样的问题,如果你没接触过进程通信,没接触过Binder,让你看一篇文章就能看懂我觉得不切实际,但是至少得知道是怎么一回事。

比如我只说bundle.putBinder()能解决这个问题,你一试,确实能解决,但是不知道为什么,你又怕会不会有其它问题。虽然这篇文章我一直在打擦边球,没有提任何的原理,但我觉得还是能大概让人知道为什么bundle.putBinder()能解决Intent传大数据,你也就能放心去用了。


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

记一次反编译并重新打包的过程

反编译部分的介绍在文章末尾排查原因根据现象来看,这程序要嘛崩溃了,要嘛该App不适配此款盒子(比如ABI不支持、Target SDK Version等问题)minSdkVersion系统版本不支持?但是同事告诉我这款盒子是Android9,在其他的Androi...
继续阅读 »

反编译部分的介绍在文章末尾

排查原因

根据现象来看,这程序要嘛崩溃了,要嘛该App不适配此款盒子(比如ABI不支持、Target SDK Version等问题)

minSdkVersion系统版本不支持?

但是同事告诉我这款盒子是Android9,在其他的Android9和Android 4.4盒子上都跑过,没问题,排除了minSdkVersion的问题

ABI不支持?

不太可能,先不想这个

ADB才是王道

但凡遇到问题,只要设备能够adb,起码问题就解决了一半,但一问,说这盒子似乎不能Adb,,鹅鹅鹅饿~~ 后来借助adbhelper发现,此款盒子还是能adb,只是常规情况下adb的端口是60001,连上之后执行adb root的话,又会换回默认端口,这个情况我也是活久见。。

说正题,连上adb之后,通过抓日志,发现了如下问题:Permission denial: writing to settings requires:android.permission.WRITE_SECURE_SETTINGS,根据错误堆栈信息,大致是app调用了wifimanager.setWifiEnabled(true)这个方法引起的

解决

权限思路

因为自己对android.permission.WRITE_SECURE_SETTINGS这个权限并不太了解,所以从异常的字面意思理解,我以为是权限不够,所以我尝试让app拥有权限来确保其正常运行。

方法1:adb shell pm grant {包名} {权限内容}

执行命令,赋予该程序权限adb shell pm grant {packagename} android.permission.WRITE_SECURE_SETTINGS,执行命令之后,提示java.lang.SecurityException: Package xxxx has not requested permission android.permission.WRITE_SECURE_SETTINGS意思说该程序不需要这个权限,这个问题的原因是因为app并没有在清单文件中申明这个权限,这就有意思了,这个app操作需要这个权限却没有申请权限,可能主要原因是因为以前的低版本不需要,Android9需要吧,所以我们得先给他增加这个申明,然后再赋予这个权限。

通过apktool反编译,然后修改清单文件添加这个权限,再重新打包(后面再说具体得反编译重打包的步骤),一切妥当之后再次执行上面命令,果然老天是不会让我舒坦的,执行后出现异常:java.lang.SecurityException: Package android does not belong to 10034,触发问题的调用堆栈还是之前那个方法引起的。(⊙﹏⊙),思考半天,看了下它的清单文件,并没有申明targetSdkVersion,这有点怪哦,也是活久见,难道游戏apk就可以这么无视规则?那我要不给他增加上,,,嗯可以一试,还是相同的配方,给清单文件增加如下代码:

<uses-sdk
android:minSdkVersion="17"
android:targetSdkVersion="22" />

然后重新打包,再来,没错,还是同样的味道,同样的问题,我以为修改来低于23,权限能够就自动允许了,现在想起来真是too young to simple。。

东搜搜西搜搜,想尝试下是不是因为这个程序不是系统app,后来将程序放在system/app下作为系统程序,还是同样问题,所以很显然,权限这条路行不通。

所以这个问题的解决办法应该参考如下内容:

这个错误是因为你的应用试图调用setWifiEnabled方法,这个方法在Android 9(API级别28)及以上版本已经被弃用。在这些版本中,只有系统应用才能调用setWifiEnabled方法。  
即使你的应用已经被安装为系统应用,并且已经获得了WRITE_SECURE_SETTINGS权限,它仍然不能调用setWifiEnabled方法。这是因为这个方法现在只能被系统UI调用,其他应用,包括系统应用,都不能调用这个方法。
你可以考虑使用WifiNetworkSuggestion API来提示用户连接到特定的Wi-Fi网络,或者使用Settings.Panel.ACTION_WIFI来引导用户到Wi-Fi设置页面。
以下是如何使用Settings.Panel.ACTION_WIFI的示例:
val intent = Intent(Settings.Panel.ACTION_WIFI)
startActivity(intent)
这段代码会打开Wi-Fi设置页面,让用户自己开启或关闭Wi-Fi。

修改程序源代码

权限的路行不通,那我们只能想办法修复app这段逻辑代码了,但是别人的apk,显然不是那么容易让人想改就改撒,提出是这里的问题,别人也不一定信啊,所以我打算自己改这个apk的编译后的源代码,让他不要触发引起异常的那个逻辑,绕过看程序能不能正常跑起来,这一步就需要懂得起smali,还好这个问题比较简单,定位到代码改了之后,重新打包,这下消停了,程序很好正常的运行。

反编译

反编译这一块涉及很多概念,以前大概接触过,都只是看,没有实际操作,有些时候操作也只是简单的反编译看下源码,但总体工具和涉及的概念主要有ApkTool、dex2jar-2.1、jarsigner、jd-gui,还有些文章提到SignApk.jar、jax-gui等;

说下自己的理解,假如我们需要重新打包一个apk,那么我们肯定要从这个apk得到我们可以编辑的文件进行修改,修改后再重新打包,这是我们需要的核心流程;

反编译APK

ApkTool,具体的作用自己查,大致意思是如果我们想看清单文件内容,资源文件之类的,我们就可以通过这个工具进行反编译,由于前面我需要给源程序在清单文件中新增权限申明,所以我们就需要先得到反编译的工程,然后直接修改清单文件即可(把AndroidManifest拖动到Android Studio或者其他文本编辑工具中直接修改然后保存即可)

  • 反编译apk :先在终端将当前位置定位到ApkTool的目录,然后执行命令apktool d {xxx.apk:你的apk名称},该命令会将apk反编译后保存在apktool所在目录下。也可以使用如下命令指定反编译后工程的存储路径apktool d -o {反编译后的存储目录} {xxx.apk},其实只需要知道反编译apk是使用的apktool d即可,查一下文档了解更详细的用法。

  • 重新编译:修改之后我们需要重新打包成apk,使用命令apk b {反编译后的存储目录},编译成功之后,会保存在指定目录下的dist文件夹中。也可以用-o 指定存储的目录。

重签名

apk反编译修改了,也重新编译成了新的apk,但此时这个apk是没有签名的,直接拿到设备上安装是不行的,所以我们需要签名。 这里就需要用到jarsign,这个应该是jdk内自带的jarsigner -verbose -keystore {签名文件路径:也就是keystore、jks文件} -signedjar {签名后的apk路径} {没有签名的apk路径} {使用的签名文件别名,也就是keyalias}如果没出错,则我们就拥有了已经签名的被修改过的apk了,可以去运行验证了。但是由于我们是用的自己签名文件签名的,是不能覆盖安装原来的apk的,两者签名不一致。

使用系统签名

这块我没尝试过,需要的自行查看搜索查看,附带个链接Android应用程序签名系统的签名(SignApk.jar)_新根的博客-CSDN博客

修改源码

跟直接修改清单文件的原理差不多,都是直接修改,但是由于是smali,就需要做一定的语法了解才能改了。也有工具可以将smali转换成java的,比如使用skylot/jadx: Dex to Java decompiler (github.com)工具,不过这个方法只是将smali转换成java来查阅,如果我们想重新打包,那还是必须修改smali文件才行的。

关于这一节建议参考:Android App 逆向入門之二:修改 smali 程式碼 (cymetrics.io)

额外说的:其实上面整个过程我们没用到dex2jar-2.1、jd-gui之类的,其实作用不同,dex2jar的作用是将apk的后缀改成zip解压出来会得到很多dex文件,我们通过dex2jar可以让这些dex转换成jar文件,jar文件就是常规生成的java class文件了,但是jar文件没法直接打开查看,就需要借助jd-gui之类的工具。。所以的目的是说我们想看看别人程序的代码的时候用的吧。

就到这吧,做个记录。。


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

何时使用Kafka而不是RabbitMQ

Kafka 和 RabbitMQ 都是流行的开源消息系统,它们可以在分布式系统中实现数据的可靠传输和处理。Kafka 和 RabbitMQ 有各自的优势和特点,它们适用于不同的场景和需求。本文将比较 Kafka 和 RabbitMQ 的主要区别,并分析何时使用...
继续阅读 »

Kafka 和 RabbitMQ 都是流行的开源消息系统,它们可以在分布式系统中实现数据的可靠传输和处理。Kafka 和 RabbitMQ 有各自的优势和特点,它们适用于不同的场景和需求。本文将比较 Kafka 和 RabbitMQ 的主要区别,并分析何时使用 Kafka 而不是 RabbitMQ。

推荐博主开源的H5商城项目waynboot-mall,这是一套全部开源的微商城项目,包含一个运营后台、h5商城和后台接口。 实现了一个商城所需的首页展示、商品分类、商品详情、sku详情、商品搜索、加入购物车、结算下单、订单状态流转、商品评论等一系列功能。 技术上基于最新得Springboot3.0、jdk17,整合了Redis、RabbitMQ、ElasticSearch等常用中间件, 贴近生产环境实际经验开发而来不断完善、优化、改进中。

github地址:github.com/wayn111/way…

影响因素

  1. 可扩展性:Kafka 旨在处理大容量、高吞吐量和实时数据流。它每秒能够处理数百万个事件,并且可以处理大量数据。另一方面,RabbitMQ 的设计更加灵活,可以处理广泛的用例,但可能不太适合大容量、实时数据流。
  2. 耐用性:Kafka 通过将所有数据写入磁盘来提供高度的耐用性,这对于任务关键型应用程序非常重要。 RabbitMQ 还提供基于磁盘的持久性,但这可能不如 Kafka 提供的那么强大。
  3. 延迟:RabbitMQ 设计为低延迟,这对于实时数据处理和分析非常重要。Kafka 延迟相比 RabbitMQ 会高一点。
  4. 数据流:Kafka 使用无界的数据流,即数据持续地流入到指定的主题(topic)中,不会被删除或过期,除非达到了预设的保留期限或容量限制。RabbitMQ 使用有界的数据流,即数据被生产者(producer)创建并发送到消费者(consumer),一旦被消费或者达到了过期时间,就会从队列(queue)中删除。
  5. 数据使用:Kafka 支持多个消费者同时订阅同一个主题,并且可以根据自己的进度来消费数据,不会影响其他消费者。这意味着 Kafka 可以支持多种用途和场景,比如实时分析、日志聚合、事件驱动等。RabbitMQ 的消费者从一个队列中消费数据,一旦被消费,就不会再被该队列其他消费者看到。这意味着 RabbitMQ 更适合一对一的通信或任务分发。
  6. 数据顺序:Kafka 保证了同一个分区(partition)内的数据是有序的,即按照生产者发送的顺序来存储和消费。但是不同分区之间的数据是无序的,即不能保证跨分区的数据按照全局顺序来处理。 RabbitMQ 保证了同一个队列内的数据是有序的,即按照先进先出(FIFO)的原则来存储和消费。但是不同队列之间的数据是无序的,即不能保证跨队列的数据按照全局顺序来处理。
  7. 数据可靠性:Kafka 通过副本(replica)机制来保证数据的可靠性,即每个主题可以有多个副本分布在不同的节点(broker)上,如果某个节点发生故障,可以自动切换到其他节点继续提供服务。 RabbitMQ 通过镜像(mirror)机制来保证数据的可靠性,即每个队列可以有多个镜像分布在不同的节点上,如果某个节点发生故障,可以自动切换到其他节点继续提供服务。
  8. 数据持久性:Kafka 将数据持久化到磁盘中,并且支持数据压缩和批量传输,以提高性能和节省空间。Kafka 可以支持TB级别甚至PB级别的数据存储,并且可以快速地重放历史数据。RabbitMQ 将数据缓存在内存中,并且支持消息确认和事务机制,以提高可靠性和一致性。RabbitMQ 也可以将数据持久化到磁盘中,但是会降低性能和吞吐量。RabbitMQ 更适合处理小规模且实时性较高的数据。
  9. 数据扩展性:Kafka 通过分区机制来实现水平扩展,即每个主题可以划分为多个分区,并且可以动态地增加或减少分区数量
  10. 复杂性:与 RabbitMQ 相比,Apache Kafka 具有更复杂的架构,并且可能需要更多的设置和配置,因此它的复杂性也允许更高级的功能和定制。另一方面,RabbitMQ 更容易设置和使用。

应用场景

Kafka 适用场景和需求

  • 跟踪高吞吐量的活动,如网站点击、应用日志、传感器数据等。
  • 事件溯源,Kafka 保存着所有历史消息,可以用于事件回溯和审计。
  • 流式处理,如实时分析、实时推荐、实时报警等。
  • 日志聚合,如收集不同来源的日志并统一存储和分析。

RabbitMQ 适用场景和需求

  • 中小项目,项目消息量小、吞吐量不高、对延时敏感。
  • 遗留应用,如需要与旧系统或第三方系统进行集成或通信。
  • 复杂路由,如需要根据不同的规则或条件来分发或过滤消息。
  • 任务分发,如需要将任务均匀地分配给多个工作进程或消费者。

总结

在公司项目中,一般消息量都不大的情况下,博主推荐大家可以使用 RabbitMQ。消息量起来了可以考虑切换到 Kafka,但是也要根据公司内部对两种 MQ 的熟悉程度来进行选择,避免 MQ 出现问题时无法及时处理。


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

实战:工作中对并发问题的处理

大家好,我是 方圆。最近在接口联调时发生了数据并发修改问题,我想把这个问题讲解一下,并把当时提出的解决方案进行实现,希望它能在大家以后在遇到同样的问题时提供一些借鉴和思考的方向。原文还是收录在我的 Github: enthusiasm&nb...
继续阅读 »

大家好,我是 方圆。最近在接口联调时发生了数据并发修改问题,我想把这个问题讲解一下,并把当时提出的解决方案进行实现,希望它能在大家以后在遇到同样的问题时提供一些借鉴和思考的方向。原文还是收录在我的 Github: enthusiasm 中,欢迎Star和获取原文。

1. 问题背景

问题发生在快递分拣的流程中,我尽可能将业务背景简化,让大家只关注并发问题本身。

分拣业务针对每个快递包裹都会生成一个任务,我们称它为 task。task 中有两个字段需要关注,一个是分拣中发生的 异常(exp_type),另一个是分拣任务的 状态(status)。另外,需要关注 分拣状态上报接口,通过它来记录分拣过程中的异常和状态变更。

一般情况下,分拣机在分拣异常发生时会及时调用接口上报,在分拣完成时调用接口来标记为完成状态,两次接口调用的时间间隔较长,不会发生并发问题。

但是有一种特殊的分拣机,它不会在异常发生时及时上报,而是在分拣完成时将分拣过程中发生的异常和分拣结果一起上报,那么此时分拣状态上报接口在同一时间内就会有两次调用,这时便发生了预期外的并发问题。

我们先看下分拣状态上报接口的执行流程:

  1. 先查询到该分拣任务 task,默认情况下 exp_type 和 status 均为默认值0

  2. 分拣异常修改 task 中的 exp_type,分拣完成修改 status 字段信息

  3. 修改完成将 task 写入

数据库初始值为 1, 0, 0,分拣异常和分拣完成几乎同时上报,它们都读取到该值。分拣异常动作将 exp_type 修改为9,写入数据库,此时数据库值为 1, 9, 0;分拣完成动作将 status 修改为1,写入数据库,使得数据库最终值为 1, 0, 1,它将异常字段的值覆盖掉了。正常情况下,最终值应该为 1, 9, 1,分拣完成动作应该读取到分拣异常完成后的值 1, 9, 0 后再进行修改才对。

2. 解决方案

发生这个问题的原因很容易就能发现:两个事务同时执行 读取-修改-写入 序列,其中一个写操作在没有合并另一个写操作变更的情况下,直接覆盖了另一个写操作的结果,所以导致了数据的丢失。

这种问题是比较典型的 丢失更新 问题,可以通过对数据库读操作加锁或者改变数据库的隔离级别为可串行化使事务串行执行的方式进行避免。下面我会将大家在讨论避免丢失更新问题时提出的方案进行介绍,并尽可能的用代码来表现它们。

2.1 数据库读操作加锁和可串行化隔离级别

我们可以考虑:如果对每条Task数据修改的事务都是在当前事务完成之后才允许后续事务进行修改,使事务串行执行,那么我们就能够避免这种情况。比较直接的实现是通过显式加锁来实现,如下

select exp_type, status
from task
where id = 1
for update;

先查询该行数据的事务会获取到该行数据的 排他锁,后续针对该数据的所有读写请求都会被阻塞,直到先前事务执行完将锁释放。

这样通过加锁的方式实现了事务的串行执行。但是,在为SQL添加加锁语句时,需要确定是不是为该行数据加锁而不是锁住了整个表,如果是后者,那么可能会造成系统性能严重下降,而且还需要关注有哪些业务场景使用到了该SQL,是否存在长时间执行的只读事务使用,如果存在的话可能会出现因加锁导致延迟和系统性能下降,所以需要谨慎的评估。

此外,可串行化的数据库隔离级别也能保证事务的串行执行,不过它针对的是所有事务。一般情况下为了保证性能,我们不会采用这种方案(默认使用MySQL可重复读隔离级别)。

MySQL的InnoDB引擎实现可串行化隔离级别采用的是2PL机制:在第一阶段事务执行时获取锁,第二阶段事务执行完成释放锁。

2.2 针对业务只修改必要字段

如果异常状态请求仅修改 exp_type 字段,分拣完成仅修改 status 字段的话,那么我们可以梳理一下业务逻辑,仅将必要修改的字段写入数据库,这样就不会发生丢失更新的异常,如下代码所示:

// 处理异常状态请求,封装修改数据的对象
Task task = new Task();
tast.setId(id);
task.setExpType(expType);

// 更改数据
taskService.updateById(task);

在执行修改数据前,创建一个新的修改对象,并只为其必要修改字段赋值。但是还需要考虑的是:如果这个业务流程处理已经很复杂了,很可能不清楚该为哪些字段赋值而导致再发生新的异常,所以采用这种方法需要对业务足够熟悉,并且在修改完后进行充分的测试。

2.3 分布式锁

分布式锁的方法与方法一类似,都是通过加锁的方式来保证同时只有一个事务执行,区别是方法一的锁加在了数据库层,而分布式锁是借助Redis来实现。

这种实现方式的好处是锁的粒度小,发生锁争抢仅限于单个包裹,无需像数据库加锁一样去考虑锁的粒度和对相关业务的影响。伪代码如下所示:

// 分布式锁KEY
String distributedKey = String.format(DISTRIBUTED_KEY_PREFIX, packageNo);
try {
// 分布式锁阻塞同一包裹号的修改
lock(distributedKey);
// 处理业务逻辑
handler();
} finally {
// 执行完解锁
redissonDistributedLocker.unlock(distributedKey);
}

需要注意,lock() 加锁方法要保证加锁失败或发生其他异常情况不影响业务逻辑的执行,并设定好锁持有时间和等待锁的阻塞时间,此外解锁方法务必添加到 finally 代码块中保证锁的释放。

2.4 CAS

CAS是乐观的解决方案,它一般通过在数据库中增加时间戳列来记录上次数据更改的时间,当新的事务执行时,需要比对读取时该行数据的时间戳和数据库中保存的时间戳是否一致,以此来判断事务执行期间是否有其他事务修改过该行数据,只有在没有发生改变的情况下才允许更新,否则需要重试这个事务。样例SQL如下所示:

update task 
set exp_type = #{expType}, status = #{status}, ts = #{currentTs}
where id = #{id} and ts = #{readTs}

它的原理不难理解,但是实现起来可能会存在困难,因为需要考虑在执行失败后该如何重试,重试的方式和重试的次数需要根据业务去判断。


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

技术主管是否需要什么段位的技术

今天来跟大家讨论一下技术主管需要什么样段位的技术?首先我要说明的一点,技术主管前提一定是技术出身。对于那些完全不懂技术,但是又身兼技术主管或者总监的同学,我这里就不再赘述,毕竟这个已经超出我目前理解力的范围。比如阿里云的王坚博士,基本上不懂技术细节,但是依然是...
继续阅读 »

今天来跟大家讨论一下技术主管需要什么样段位的技术?

首先我要说明的一点,技术主管前提一定是技术出身。对于那些完全不懂技术,但是又身兼技术主管或者总监的同学,我这里就不再赘述,毕竟这个已经超出我目前理解力的范围。比如阿里云的王坚博士,基本上不懂技术细节,但是依然是阿里云的CTO,一手缔造了阿里云。

那我们这里再详细讨论一下,作为一名技术主管,到底应该有什么样的一个技术的段位?或者换句话来说,你的主管的技术水平需要到达什么样的一个水位?

先说结论,作为一名技术主管,一定是整个团队的技术架构师。像其他的一些大家所讨论的条件我觉得都是次要的,比如说写代码的多少,对于技术深度的钻研多少,带的团队人数多少等等,最核心的是技术主管一定要把控整个团队整个业务技术发展的骨架。

为什么说掌控团队技术架构是最重要的?因为对于一个团队来说无非就两点,第一点就是业务价值,第二点就是技术价值。

对于业务价值来说,有各种各样的同学都可以去负责业务上面的一些导向和推进,比如说产品经理,比如说运营同学。技术主管可以在一定程度上去帮助业务成功,甚至是助力业务成功,但是一定要明白技术同学一定要有自己的主轴,就是你对于整个技术的把握。因为业务上的决策说到底技术主管是只能去影响而非去决策,否则就是你们整体业务同学太过拉胯,无法形成战术合力的目的。

对于一线开发同学来说,你只要完成一个接一个的技术项目即可。但是对于技术主管来说,你就要把握整体的技术发展脉络。要清晰的明白什么样的技术架构是和当前的业务匹配的,同时又具备未来业务发展的可扩展性。

那为什么不能把整个技术架构的设计交给某一个核心的骨干研发同学呢?

所以这里就要明白,对于名技术主管来说,未必一定要深刻的钻研技术本身,一定要把技术在业务上的价值发挥到最大。所以在一定程度上来说,可以让适当的同学参与或者主导整个技术架构的设计,但是作为主管必须要了解到所谓的技术投入的产出比是什么。但是如果不对技术架构有一个彻底的理解,如何能决定ROI?

也就是在技术方案的选型里面一定要有一个平衡,能够用最小的技术投入获取到最大的技术利益,而非深究于技术本身的实习方式。如果一名技术主管不了解技术的框架或者某一些主干流程,那么就根本谈不上怎么样去评估这投入的技术产出比。一旦一名技术主管无法衡量整个技术团队的投入产出比,那就意味着整个团队的管理都是在抓虾和浑水摸鱼的状态,这时候就看你团队同学是否自觉了。

出现了这种情况下的团队,可能换一头猪在主管的位置上,业务依然运行良好。如果在业务发展好的时候,可能一直能够顺利推动,你只要坐享其成就可以了,但是一旦到了要突破困难的时期,或者在业务走下行的时候,这个时候你技术上面的优势就一点就没有了。而且在这种情况下,如果你跳槽到其他公司,作为一名技术主管,对方的公司对你的要求也是非常高的,所以这个时候你如果都说不出来你的技术价值对于业务上面的贡献是什么那想当然,你可能大概率就凉凉了。

那问题又回到了什么样的水平才能到达架构师这个话题,可以出来另一篇文章来描述,但是整体上来说,架构的本质首先一定要明白,为的就是业务的增长。

其次,架构的设计其实就是建造一个软件体系的结构,使得具备清晰度,可维护性和可扩展性。另外要想做好架构,基本的基础知识也必不可少,比如说数据库选型、分布式缓存、分库分表、幂等、分布式锁、消息架构、异步架构等等。所以本身来说做好架构师本身难度就非常大,需要长期的积累,实现厚积而薄发。如何成为一名优秀的架构师可以看我的公众号的其他文章,这里就不再详细的介绍了。

第二点是技术主管需要对于技术细节有敏感度。很多人在问一名主管到底应该具备什么样的综合能力,能不能用一种更加形象的方式来概括,我认为就有一句话就可以概括了。技术主管应该是向战略轰炸机在平常的时候一直遨游在大气的最上层能够掌控整个全局,当到了必须要战斗的时候,可以快速的补充下去,定点打击。

我参加过一次TL培训课程,讲师是阿里云智能交付技术部总经理张瑞,他说他最喜欢的一句管理概括,就是“心有猛虎,细嗅蔷薇”,也就是技术主管在平常的时候会关注于更大的宏观战略或策略,也就是注重思考全局,但是在关键的时候一定要关注和落地实际的细节。

换句更加通俗的话来说,就是管理要像战略轰炸机,平常的时候飞在万丈高空巡视,当发生了战斗的时候,立即能够实现定点轰炸。

所以如果说架构上面的设计就是对于整个团队业务和技术骨架的把握,那么对于细节的敏感度就是对于解决问题的落地能力。

那怎么样能够保证你自己有一个技术细节的敏感度?

我认为必要的代码量是需要的,也就是说对于一个主管来说,不必要写太多低代码,但一定要保证一定的代码量,让自己能够最好的,最快的,最贴近实际的理解实际的业务项目。自己写一些代码,其实好处非常多,一方面能够去巩固和加深自己对技术的理解,另外一方面也能够通过代码去更加理解业务。

当然贴近技术的方式有很多种,不一定要全部靠写代码来完成,比如说做code review的方式来完成,做技术方案的评审来完成,这都是可以的。对我来说,我就会强迫自己在每一个迭代会写上一个需求,需求会涉及到各方各面的业务点。有前端的,有后端的,也有数据库设计的。

自己亲自参与写代码或者code review,会让自己更加贴近同学,能够感知到同学的痛点,而不至于只是在空谈说教。

总结

所以对于一个技术主管来说,我认为首要的就是具备架构设计的能力,其次就是要有代码细节的敏感度,对全局和对细节都要有很强大的把控能力。

当然再总结一下,这一套理论只是适用于基础的管理者,而非高层的CTO等,毕竟不同的层级要求的能力和影响力都是不一样的。


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

前端发展:走进行业迷茫的迷雾中

引言2023年,前端开发作为IT行业中备受关注的领域之一,正在经历着巨大的挑战和变革。然而,在当前行业不景气、失业率居高不下以及裁员潮席卷而来的情况下,许多人开始质疑前端开发的未来前景以及学习它是否依然有意义。本文将探讨这个问题并试图给出一些启示。第一部分:前...
继续阅读 »

引言

2023年,前端开发作为IT行业中备受关注的领域之一,正在经历着巨大的挑战和变革。然而,在当前行业不景气、失业率居高不下以及裁员潮席卷而来的情况下,许多人开始质疑前端开发的未来前景以及学习它是否依然有意义。本文将探讨这个问题并试图给出一些启示。

第一部分:前端的价值

前端开发作为网页和移动应用程序开发的重要组成部分,扮演着连接用户与产品的桥梁。前端技术的发展不仅推动了用户体验的提升,也对整个互联网行业产生了深远的影响。随着移动互联网的普及和技术的进步,前端在用户与产品之间的交互变得越来越重要。

对于企业而言,拥有优秀的前端开发团队意味着能够提供更好的用户体验、增强品牌形象、吸引更多用户和扩大市场份额。因此,前端开发的技能依然是企业争相追求的核心能力之一。

第二部分:行业不景气的背后

然而,正如每个行业都经历高低起伏一样,前端开发也面临着行业不景气带来的挑战。2023年,全球经济增长乏力、市场竞争激烈以及萧条的就业市场等因素,使得许多公司紧缩预算、停止招聘,并导致了失业率的上升和裁员的潮水。

在这种情况下,前端开发者需要重新审视自己的技能和市场需求。他们需要具备综合能力,包括对最新前端技术的深入了解、与其他团队成员的良好沟通合作能力以及持续学习和适应变化的能力。

第三部分:自我调整与进阶

面对市场变化和就业压力,前端开发者需要主动调整自己的发展路径。以下是一些建议:

  1. 多元化技能:学习并精通多种前端框架和库,如React、Vue.js和Angular等。同时,了解后端开发和数据库知识,拥有全栈开发的能力,将会让你在就业市场上更具竞争力。
  2. 学习与实践并重:不仅仅是学习新知识,还要将所学应用于实际项目中。积累项目经验,并在GitHub等平台分享你的作品,以展示自己的能力和潜力。同时,参加行业内的比赛、活动和社区,与他人交流并学习他们的经验。
  3. 持续学习:前端技术发展日新月异,不断学习是必需的。关注行业的最新趋势和技术,参加培训、研讨会或在线课程,保持对新知识的敏感度和学习能力。

第四部分:面对就业市场的挑战

在面对行业不景气和裁员的情况下,重新进入就业市场变得更加具有挑战性。以下是一些建议:

  1. 提升个人竞争力:通过获得认证、实习或自主开发项目等方式,提升自己在简历中的竞争力。扩展自己的专业网络,与其他开发者和雇主建立联系。
  2. 寻找新兴领域:探索新兴的技术领域,如大数据、人工智能和物联网等,这些领域对前端开发者的需求逐渐增加,可能为你提供新的机会。
  3. 转型或深耕细分领域:如果市场需求不断减少,可以考虑转型到与前端相关的领域,如UI设计、交互设计或用户体验设计等。或者在前端领域深耕细分领域,在特定行业或特定技术方向上寻找就业机会。

结论

 虽然当前的行业环境确实严峻,但前端开发作为连接用户与产品的重要纽带,在未来依然有着广阔的发展空间。关键在于前端开发者要不断自我调整与进阶,持续学习并适应市场需求。通过多元化技能、学习实践、提升个人竞争力以及面对市场挑战,前端开发者依然可以在这个变革时代中谋得一席之地。


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

一代枭雄曹操也需要借力,何况我们

前言1、人情世故如果做得好就会说是情商高,做不好会说是世故,这是冯仑老师一段话,然后怎么做不世故呢,也很难评判。借着这个聊聊人情世故,在我看来它也是做事规则的一部分,我们发展很长一段历史,从不同的立场、不同的利益分出了派别,又从血缘关系分出了宗族,这些都是为了...
继续阅读 »

前言


1、人情世故

如果做得好就会说是情商高,做不好会说是世故,这是冯仑老师一段话,然后怎么做不世故呢,也很难评判。

借着这个聊聊人情世故,在我看来它也是做事规则的一部分,我们发展很长一段历史,从不同的立场、不同的利益分出了派别,又从血缘关系分出了宗族,这些都是为了利益最大化的一个产物。

反观博主本人,典型理工男,执着技术研究,所以这块一直是弱项,不太会讲话,但是我人缘一直比较好的。当然有利也有弊,弊端的话比较明显的,当一个人说话很厉害的时候,会给人自信,给人觉得靠谱,当一个人说话不咋样的时候,其实也有好处,就是藏锋,你不说出来个人想法大家是不知道你心里的小九九的,所以保全了你自身。(当一个人份量足的时候,说话会引发很大的影响,所以你可以发现如果一个人在公开场合大发演讲,要么是初出茅庐要么就是有靠山)

2、人生的发展需要平台

王立群老师:人生发展往往需要平台,秦国李斯这么一个故事,他发现仓鼠跟厕鼠待遇很不一样,同样是一个物种,但是一个光明正大的吃着粮食,一个过街老鼠人人喊打,所以他悟到了一个道理,人生好的发展需要借助平台的。

我们今天讲的人物:曹操,我们还是从几个学习角度去看,一个是做事的方法,另一个我们从他的事迹里面看出成事的借力的这么一回事。

曹操


出身

他祖父是一个大太监,伺候皇后还有皇上,古代有三股力量,两股都是因为比较亲近产生的,一个是外戚,另一个太监,还有一股力量是文官,这个是人数最多的。那么他祖父权利很大的,然后收了一个义子也就是曹操的父亲,然后他本身属于夏侯家族,所以他带的资源是曹家还有夏侯家非常有实力。

他并没有说直接躺平,而是想着有所作为,接下来我们再看看他的做事方面

做事手段

1、许劭风评

古代有个一个规则,靠着这些有能力、有品德的人来进行推荐人才,曹操想出来做事,他找到许劭,一开始是不肯的,因为前面讲过三股力量,文官是很鄙视太监的,后面曹操使了点手段最终让许劭给他做了风评,然后他听完大笑而去。

idea:从这件事看做什么事都是有个窍门,这个方式是别人建议曹操这么干,所以做事要恰到好处。另外里面提到曹操使了点手段,哈哈透出了一个狠,有点东西。

2、傍大腿

曹操曾经在袁绍下面干活,然后好几次都把自己的精锐干没了,袁绍作为盟主,慷慨的给予兵马才得以恢复元气。

idea:我们看曹操的出身,这么牛逼的背景,他也需要大腿的支持,更何况普普通通的我们。

3、挟天子以令诸侯

这个是非常著名的历史典故,也是因为这个跟袁绍闹掰了,当汉献帝去了洛阳的时候,他马上去迎接,然后用这个发号施令讨伐别人。

idea:曹操的眼光十分毒辣,他看出潜在的价值,不愧是曹老板。

4、善用人才

像官渡之战,像迎接汉献帝,都是底下这批谋士给的主意,曹操手下文官是人才济济的,另外这个老板是善于听从这些好的计谋,这是非常重要的。

官渡之战,袁绍没有听从谋士的重兵把守粮草,导致给了曹操抓住了机会,乌巢一把火烧光了粮草。

个人看法

a、平台是重要的,借力也是需要的

从曹操的发迹来看,他站在一个大平台上面,不像刘备四处投奔。人并不是说能力很强就能表现出来,需要有平台,有这么伯乐去发现你,然后有这么一股力量在你困难的时候拉你一把,这是重要的。

b、曹操做事狠

这里的狠,不是残暴,而是毒辣,眼光毒辣、做事方式到位,我们从善用人才,许劭风评,挟天子以令诸侯,这些做的都很到位。举个例子,比如说我们要煮开一壶水,需要火柴、木头、可能需要鼓风工具,这都是关键那些点。

这个我们前面也提到了,做事一定要有所研究,事情的关键点是什么,当然有这么一群得力助手也很重要,发现关键突破点。所以古代对英雄标准是:腹有良策,有大气概。

c、驾驭人

司马家起来是在曹操去世后几代的事情,可以说在曹操在的时候,这些有心机的人没有动作的,侧面看出曹操的厉害之处,懂人心。在资治通鉴里面也有一个例子,就是桓温,他也是古代一个权臣,后面几代就不行了压不住这批人。

学历史,学读懂人心


历史里面基本都是那个朝代的精英,他们的事迹,做事方法,当然我们看到很多东西,包括抱负、无奈、遗憾;我们学的不仅仅是做事方法,避开权谋的陷阱,还有就是学习读懂人心、人性。当我们谈到这个,大家第一印象就是坏的人性,其实它是一种自然的表现,就像饿了就要吃饭。

《百家讲坛》里面讲了这么一个故事,曹操的下邳之战生擒了吕布,原本曹操很爱惜人才的,后面刘备的一句话:吕布对以往老板不好,而曹操生性多疑,最终嘎了吕布。王立群老师:人们往往看重结果,以结果说话,而不是问你这么做的原因。

是啊,我们在故事背后,看到整件事情人心的博弈,刘备被人称为仁义之君,但是他在那会落进下石了,因为他之前跟吕布有些矛盾的,吕布把他从原来的根据地赶走了,当然他说的也是事实。所以我们除了学习历史,还需要去洞察人心,往往这些能决定事情的走向。


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