注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

环信FAQ

环信FAQ

集成常见问题及答案
RTE开发者社区

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

如何为你的 js 项目添加 ts 支持?

web
前一段时间为公司内的一个 JS 公共库,增加了一些 TypeScript 类型支持。在这里简答记录一下。 安装 TypeScript 依赖 首先安装 TypeScript 依赖,我们要通过 tsc 指令创建声明文件: pnpm ins...
继续阅读 »

前一段时间为公司内的一个 JS 公共库,增加了一些 TypeScript 类型支持。在这里简答记录一下。





安装 TypeScript 依赖


首先安装 TypeScript 依赖,我们要通过 tsc 指令创建声明文件:


pnpm install -D typescript

创建配置文件


接下来创建 TypeScript 配置文件:


npx tsc --init

这一步会在项目的根目录下创建一个 tsconfig.json 文件。我们在原来配置的基础上开放一些配置:


{
  "compilerOptions": {
     "target": "es2016",
     "module": "commonjs",
     "esModuleInterop": true,
     "forceConsistentCasingInFileNames": true,
     "strict": true,
     "noImplicitAny": false,
     "skipLibCheck": true,
+    "allowJs": true,
+    "checkJs": true,
+    "declaration": true,
+    "emitDeclarationOnly": true,
+    "rootDir": "./",
+    "outDir": "./types",
   }
+  "include": [
+    "security/**/*"
+  ]
}

字段说明


对上述字段,我们挑几个重要的说明一下。





  • allowJscheckJs 增加 JS 文件支持



  • declarationemitDeclarationOnly 我们只需要 tsc 帮我们生成类型声明文件即可



  • rootDiroutDir 指定了类型声明文件生成到 types/ 目录



  • include 我们只为 security/ 目录下的代码生成类型声明文件


想详细了解每个配置字段的含义,可以参考 TypeScript 官方说明:https://aka.ms/tsconfig


生成类型文件


项目根目录下创建 index.d.ts 文件


export let security: typeof import("./types/security");

接下里修改 package.json, 增加当前 npm 包的类型声明支持和构建脚本 typecheck


{
    "scripts": {
        // ...
        "typecheck""tsc",
    },
    types: "index.d.ts"   
}

接下来执行脚本:


npm run typecheck

最后就能看到在 types/ 目录下为 security/ 生成的类型声明文件了。


作者:zhangbao
来源:mdnice.com/writing/55c153377fd3436581576b7017c25f3a
收起阅读 »

python复数类型的使用及介绍

在Python中,复数类型是用来表示具有实部和虚部的数值。复数由实部和虚部组成,形式为 a + bj,其中 a 是实部,b 是虚部,j 是虚数单位。 要创建一个复数,可以使用 complex() 函数,并提供实部和虚部作为参数。例如: z =&n...
继续阅读 »

在Python中,复数类型是用来表示具有实部和虚部的数值。复数由实部和虚部组成,形式为 a + bj,其中 a 是实部,b 是虚部,j 是虚数单位。


要创建一个复数,可以使用 complex() 函数,并提供实部和虚部作为参数。例如:


z = complex(23)
print(z)  # 输出:(2+3j)

这里,z 是一个复数,实部为2,虚部为3。


可以通过 .real 属性来访问复数的实部,通过 .imag 属性来访问复数的虚部。例如:


print(z.real)  # 输出:2.0
print(z.imag)  # 输出:3.0

可以使用运算符来对复数进行算术运算,包括加法、减法、乘法和除法。例如:


z1 = complex(23)
z2 = complex(45)

addition = z1 + z2
subtraction = z1 - z2
multiplication = z1 * z2
division = z1 / z2

print(addition)      # 输出:(6+8j)
print(subtraction)   # 输出:(-2-2j)
print(multiplication) # 输出:(-7+22j)
print(division)      # 输出:(0.5609756097560976+0.0487804878048781j)

Python还提供了一些内置函数和方法来处理复数,例如 abs() 函数用于计算复数的绝对值,cmath 模块提供了一些数学函数,如求幅度和相位角。


复数的使用可以在需要处理虚数或使用复数运算的情况下非常有用,例如在工程、物理或数学领域。


作者:小小绘
来源:mdnice.com/writing/2cd491bb2ae749d195b965325ba408f3
收起阅读 »

如何在页面关闭时发送 API 请求

web
前言 在一些需求背景下,我们需要在页面销毁(关闭/刷新)时将数据同步给后台,比如 记录视频播放进度、页面浏览时长埋点等。 在 window 全局对象上,提供了 beforeunload 事件,会在浏览器窗口关闭或刷新时触发。 要实现这个需求,普遍的做法是在 w...
继续阅读 »

前言


在一些需求背景下,我们需要在页面销毁(关闭/刷新)时将数据同步给后台,比如 记录视频播放进度、页面浏览时长埋点等


window 全局对象上,提供了 beforeunload 事件,会在浏览器窗口关闭或刷新时触发。


要实现这个需求,普遍的做法是在 window.onbeforeunload 监听事件回调中发起 api 请求。


const onBeforeunload = async () => {
// 发起请求
}
window.addEventListener('beforeunload', onBeforeunload);


注意:在移动设备下,一些浏览器并不支持 beforeunload 事件,最可靠的方式是在 visibilitychange 事件中处理。



document.addEventListener('visibilitychange', function logData() {
if (document.visibilityState === 'hidden') {
...
}
});

发起请求的方式有如下几种:



  1. ajax(XMLHttpRequest)

  2. sendBeacon(Navigator.sendBeacon)

  3. fetch(Fetch keepalive)


下面,我们分析对比以上几种方式的优劣及适用性。


一、ajax


早期前后端进行数据交互多数都采用 XMLHttpRequest 方式创建一个 HTTP 请求,默认采用 异步 方式发起请求:


const ajax = (config) => {
const options = Object.assign({
url: '',
method: 'GET',
headers: {},
success: function () { },
error: function () { },
data: null,
timeout: 0,
async: true, // 是否异步发送请求,默认 true 是异步,同步需设置 false。
}, config);
const method = options.method.toUpperCase();

// 1、创建 xhr 对象
const xhr = new XMLHttpRequest();
xhr.timeout = options.timeout; // 设置请求超时时间

// 2、建立连接
xhr.open(method, options.url, options.async); // 第三参数决定是以 异步/同步 方式发起 HTTP 请求

// 设置请求头
Object.keys(options.headers).forEach(key => {
xhr.setRequestHeader(key, options.headers[key]);
});

// 3. 发送数据
xhr.send(['POST', 'PUT'].indexOf(method) > -1 ? JSON.stringify(options.data) : null);

// 4. 接收数据
xhr.onreadystatechange = function () { // 处理响应
if (xhr.readyState === 4) {
if ((xhr.status >= 200 && xhr.status < 300) || xhr.status === 304) {
options.success(xhr.responseText);
} else {
options.error(xhr.status, xhr.statusText);
}
}
};

// 超时处理
xhr.ontimeout = function () { options.error(0, 'timeout') };
// 错误处理
xhr.onerror = function () { options.error(0, 'error') };
// xhr.abort(); // 取消请求
}

对于 ajax 发起异步请求,若在发送过程中 刷新或关闭 浏览器,请求会被自动终止,如下图:


image.png



如果想在控制台查看刷新前页面接口调用情况,可勾选 Preserve log 选项,Network 会保留上个页面的请求记录。



可见,异步方式的 ajax 请求被浏览器自动 cancel 取消,无法将数据正常推送到后台。


一种处理方式是改为 同步 ajax 请求方式,在调用 open 建立连接时,第三参数 async 传递 false 表示以 同步方式 发送请求:


xhr.open(method, options.url, false);

但目前,谷歌浏览器已经不允许在页面关闭期间发起 同步 XHR 请求,建议使用 sendBeacon 或者 fetch keep-alive。我们接着往下看。


二、sendBeacon


navigator.sendBeacon()  方法可用于通过 HTTP POST 将少量数据 异步 传输到 Web 服务器。



官方链接:developer.mozilla.org/zh-CN/docs/…



它的语法如下:


navigator.sendBeacon(url);
navigator.sendBeacon(url, data);


  • url: 指定将要被发送到的网络地址;

  • data: 可选,是将要发送的 ArrayBufferArrayBufferViewBlobDOMStringFormData 或 URLSearchParams 类型的数据。

  • return: 返回值。当用户代理成功把数据加入传输队列时,sendBeacon()  方法将会返回 true,否则返回 false


navigator.sendBeacon 使用示例如下:


// 通过 Blob 方式传递 JSON 数据
const blob = new Blob(
[JSON.stringify({ ... })],
{ type: 'application/json; charset=UTF-8' }
);
// 发送请求
navigator.sendBeacon(url, blob);

sendBeacon 发送请求有以下几个特点:



  1. 通过 HTTP POST 请求方式 异步 发送数据,同时不会延迟页面的卸载或影响下一导航的载入性能;

  2. 支持跨域,但不支持自定义 headers 请求头,这意味着:如果用户信息 Access-Token 是作为请求头信息传递,需要后台接口支持 url querystring 参数传递解析。

  3. 考虑其兼容性。


三、fetch keep-alive


当使用 fetch() 请求时,如果把 RequestInit.keeplive 设置为 true,即便页面被终止请求也会保持连接。


fetch(url, {
method: 'POST',
body: JSON.stringify({ ... }),
headers: {
'Content-Type': 'application/json', // 指定 type
},
keepalive: true,
});

推荐使用 Fetch API 实现「离开网页时,将数据保存到我们的服务器上」。


但它也有一些限制需要注意:



  1. 传输数据大小限制:无法发送兆字节的数数据,我们可以并行执行多个 keepalive 请求,但它们的 body 长度之和不得超过 64KB

  2. 无法处理服务器响应:在网页文档卸载后,尽管设置 keepalive 的 fetch 请求可以成功,但后续的响应处理无法工作。所以在大多数情况下,例如发送统计信息,这不是问题,因为服务器只接收数据,并通常向此类请求发送空的响应。


思考


在框架的生命周期,如 React useEffect 可以实现页面关闭时发送 HTTP 请求记录数据吗?


答案是:不可以


尽管,我们所理解的 useEffect 中的销毁函数会在页面销毁时触发,但有一个前提条件是:程序保活正常运行,即 ReactDOM.render 创建的 FiberRoot 正常运转


试想,浏览器页面进行刷新或关闭,React 所启动的应用会直接中断停止,程序中页面定义的 useEffect 将不会被执行。


参考


Navigator sendBeacon页面关闭也能发送请求方法.

fetch keep

laive.

收起阅读 »

看了我项目中的商品功能设计,同事也开始悄悄模仿了...

商品功能作为电商系统的核心功能,它的设计可谓是非常重要的。就算不是电商系统中,只要是涉及到需要交易物品的项目,商品功能都具有很好的参考价值。今天就以mall项目中的商品功能为例,来聊聊商品功能的设计与实现。 mall项目简介 这里还是简单介绍下mall项目吧...
继续阅读 »

商品功能作为电商系统的核心功能,它的设计可谓是非常重要的。就算不是电商系统中,只要是涉及到需要交易物品的项目,商品功能都具有很好的参考价值。今天就以mall项目中的商品功能为例,来聊聊商品功能的设计与实现。



mall项目简介


这里还是简单介绍下mall项目吧,mall项目是一套基于 SpringBoot + Vue + uni-app 的电商系统,目前在Github已有60K的Star,包括前台商城项目和后台管理系统,能支持完整的订单流程!涵盖商品、订单、购物车、权限、优惠券、会员等功能,功能很强大!



功能设计



首先我们来看下mall项目中商品功能的设计,主要包括商品管理、添加\编辑商品、商品分类、商品类型、品牌管理等功能,这里的功能同时涉及前台商城和后台管理系统。



商品管理


在mall项目的后台管理系统中,后台管理员可以对商品进行管理,比如添加、编辑、删除、上架等操作。



当商品上架完成后,前台会员在mall项目的前台商城的商品列表中就可以看到对应商品了。



添加/编辑商品


后台管理员在添加/编辑商品时,需要填写商品信息、商品促销、商品属性以及选择商品关联。



之后前台会员在前台商城的商品详情页中就可以查看到对应的商品信息了。



商品分类


后台管理员也可以对商品的分类进行添加、编辑、删除、查询等操作。



这样前台会员在前台商城中就可以按商品分类来筛选查看商品了。



商品类型


后台管理员可以对商品的类型属性进行设置,设置好之后在编辑商品时就可以进行商品属性、参数的设置了。



此时前台会员就可以在前台商城中选择对应属性的商品进行购买了。



品牌管理


后台管理员可以对商品的品牌进行添加、编辑、删除、查询等操作。



此时前台会员就可以在前台商城的品牌详情页中查看到品牌信息以及相关的商品了。



功能整理


对于商品模块的功能,我这里整理了一张思维导图方便大家查看,主要是整理了下有哪些功能以及功能需要涉及哪些字段。



数据库设计


根据我们的功能设计和整理好的思维导图,就可以进行数据库设计了,这里是mall项目商品模块的功能设计图。



接口设计


对于mall项目中商品模块的接口设计,大家可以参考项目的Swagger接口文档,以Pms开头的接口就是商品模块对应的接口。



总结


商品模块作为电商系统的核心功能,涉及到商品SKU和SPU的概念,是一个非常好的参考案例。如果你能掌握商品模块的设计,对于开发一些需要交易的系统来说,会有非常大的帮助!


项目源码地址


github.com/macroz

heng/…

收起阅读 »

微前端是怎样炼成的,从思想到实现

web
1 道 “微前端”的概念最早由 Thoughtworks 在2016年提出。 微前端是一种类似于微服务的架构,它将微服务的理念应用于浏览器端,即将单页面前端应用由单一的单体应用转变为多个小型前端应用聚合为一的应用。各个前端应用还可以独立开发、独立部署。 ——...
继续阅读 »

1 道


“微前端”的概念最早由 Thoughtworks 在2016年提出。



微前端是一种类似于微服务的架构,它将微服务的理念应用于浏览器端,即将单页面前端应用由单一的单体应用转变为多个小型前端应用聚合为一的应用。各个前端应用还可以独立开发、独立部署。
—— 黄峰达《前端架构——从入门到微前端》



1.1 独立


独立开发、独立部署、独立运行,是微前端应用组织的关键词。独立带来了很多有价值的特性:



  • 不同微应用可以使用不同的技术栈,从而兼容老应用,微应用也可以独立选型、渐进升级;

  • 微应用有单独的 git 仓库,方便管理;

  • 微应用隔离,单独上线,回归测试无需测试整个系统;

  • 拆分应用,加速加载;


为了实现可靠且灵活的独立,微前端必须面对几个核心问题:



  • 微应用间如何调度、解析、加载?

  • 如何避免运行时互相污染?

  • 微应用间如何进行通信?



1.2 大道至简——微前端的理论基础


微前端能成的理论基础是,底层API的唯一性。


首先无论各家前端框架多么天花乱坠,最后都离不开一个操作 ——「通过 js 在一个DOM容器中插入或更新节点树」。所以你在各家的demo也都看得到这样的 api 描述:


ReactDOM.render(<App />, document.getElementById('root'));	// react
createApp(...).mount('#app'); // vue

所以只要提供容器,就能让任何前端框架正常渲染。再上一层,任何 JS API,都离不开在全局对象 window 上的调用,包括 DOM 操作、事件绑定、页面路由、前端存储等等。所以只要封住 window,就可以隔离微应用的运行时。


1.3 主流微前端方案套娃


微前端是一个概念,历史上各种实现方案层出不穷,到今天阿里 qiankun 的方案成为国内主流。


qiankun 底层基于 single-spa,而业务系统也倾向于再在外面包一层,三层方案各自专注解决不同的问题:




  • single-spa 的官方定位是「一个顶层路由,当路由处于活动状态时,它将下载并执行该路由的相关代码」。放到微前端概念中,它专注解决微应用基于路由的调度。

  • qiankun 是一个「微前端实现库,旨在帮助大家能更简单、无痛的构建一个生产可用微前端架构系统」。在 single-spa 的基础上:所谓「简单」,是降低了接入门槛,增强了资源接入方式,支持 HTML Entry;所谓「无痛」,是尽量降低了微前端带来的副作用,即提供了样式和JS的隔离,并通过资源缓存加速微应用切换性能。

  • 到业务系统这一层,着重解决业务生产环境中的问题。最常见的像提供一个MIS管理后台,灵活配置,动态下发微应用信息,实现动态应用插拔。


2 single-spa


single-spa 做的事很聚焦,核心流程是:1、注册路由对应资源 —> 2、监听路由 —> 3、加载对应资源 —> 4、执行资源提供的状态回调。


2.1 api 概览


为了实现这套流程,single-spa 首先提供了「1、注册路由对应资源」的接口:


singleSpa.registerApplication({ name, appLoader, activeWhen });

然后启动「2、监听路由 —> 3、加载对应资源」机制:


singleSpa.start();

对资源则有「提供状态回调」的改造要求:


// 资源代码
export function bootstrap(props) {}
export function mount(props) {}
export function unmount(props) {}
export function unload(props) {} // 可选

2.2 整体实现原理


很显然,这里面有一套应用的状态机制,以及对应的状态流转流程,在 single-spa 内部是这样的:




  • app 池收集注册进来的微应用信息,包括应用资源、对应路由。app 池中的所有微应用,都会维护一个自身的状态机。

  • 刷新器是整个 single-spa 的发动机,负责流转整个状态流程。一旦刷新器被触发(首次启动或路由更新),就开始调度:

    • 拿着最新路由去池子里分拣 app

    • 根据分拣结果,执行 app 资源暴露的生命周期方法




2.3 app 池的实现


app 池的实现都在 src/applications/apps.js 模块中,首先是一个全局池:


const apps = [];

然后直接实现并导出 registerApplication 方法作为向 app 池添加成员的入口:


export function registerApplication( appNameOrConfig, appOrLoadApp, activeWhen, customProps) {
const registration = sanitizeArguments( appNameOrConfig, appOrLoadApp, activeWhen, customProps);
apps.push(
assign(
{ status: NOT_LOADED },
registration
)
);
if (isInBrowser) {
reroute();
}
}

registerApplication 做了几件事:



  1. 构造 app 对象,整理入参,这个和 single-spa 入参兼容有关系。最终 app 对象将包含app 信息、状态、资源、激活条件等信息。

  2. 加入 app 池。这里可以看到初始状态是 NOT_LOADED

  3. 触发了一次 reroute。


2.4 reroute 触发


前面 registerApplication 调了一次 reroute 方法,这就是执行一次刷新。reroute 会在下列场景执行:



  • registerApplication:微应用注册

  • start:框架启动

  • 路由事件(popstate、hashchange)触发


window.addEventListener("hashchange", urlReroute);
window.addEventListener("popstate", urlReroute);
function urlReroute() {
reroute([], arguments);
}

2.5 reroute 分拣执行


reroute 先判断 app 是否应该激活,逻辑很简单,就是把当前路由带到 app.activeWhen 里计算返回(app.activeWhen(window.location)),这里我们只看当前应该处于什么状态。而且按我们通常用法,只有少数 app 会激活。



接下来看微前端应用的激活过程,是先 load 下载应用资源,再 mount 挂载启动应用。



这样结合「app 是否应该激活」X「app 当前状态」,可以得到「应该对 app 做什么操作」。



  1. 「激活」X「not loaded」:应该去加载微应用资源

  2. 「激活」X「not mounted」:应该去挂载启动微应用

  3. 「激活」X「mounted」:什么都不用动

  4. 「不激活」X「not loaded」:什么都不用动

  5. 「不激活」X「not mounted」:应该去卸掉微应用资源

  6. 「不激活」X「mounted」:应该卸载微应用


这里只有1、2、5、6需要操作,也对应了上图中的四个箭头。于是 app 被进一步分拣为四个组:



代码如下:


switch (app.status) {
case NOT_LOADED:
case LOADING_SOURCE_CODE:
if (appShouldBeActive) {
appsToLoad.push(app);
}
break;
case NOT_BOOTSTRAPPED:
case NOT_MOUNTED:
if (!appShouldBeActive && getAppUnloadInfo(toName(app))) {
appsToUnload.push(app);
} else if (appShouldBeActive) {
appsToMount.push(app);
}
break;
case MOUNTED:
if (!appShouldBeActive) {
appsToUnmount.push(app);
}
break;
// all other statuses are ignored
}

拿到四个组后,需要转为具体操作,于是 reroute 中有这种 map:const unloadPromises = appsToUnload.map(toUnloadPromise);,把 app 转换为操作的 Promise。


需要注意的是,load 后app处于中间状态,并未完成激活,还差一步,反之亦然。所以只到中间态的两个组 appsToUnmount、appsToLoad,还需要继续往前走一步。


const unmountUnloadPromises = appsToUnmount
.map(toUnmountPromise)
.map((unmountPromise) => unmountPromise.then(toUnloadPromise));

至于这些Promise是干嘛的也很容易猜到,无非是执行资源暴露的生命周期回调 + 修改应用状态。toXXXPromise 方法都定义在 src/lifecycles 下,可以找到对应生命周期。


至此 reroute 从分拣到执行生命周期的过程完成,完整图如下:



2.6 小结



  • single-spa 主要实现了微前端微应用调度部分,包含一个 app 池及路由变化时刷新回调 app 生命周期函数的机制

  • app 池维护了 app 的信息、资源和状态,暴露添加方法给 registerApplication api

  • 刷新的过程:确定app是否active —> 结合状态判断要做的操作 —> 调用生命周期回调,改状态


3 qiankun


在 single-app 微应用调度的基础上,qiankun 要带来的是「更简单、无痛的」生产应用。这些特性包括:



  • 💪 HTML Entry 接入方式,让你接入微应用像使用 iframe 一样简单。

  • 🛡 样式隔离,确保微应用之间样式互相不干扰。

  • 🧳 JS 沙箱,确保微应用之间 全局变量/事件 不冲突。

  • ⚡️ 资源预加载,在浏览器空闲时间预加载未打开的微应用资源,加速微应用打开速度。


我们看它的实现思路。


3.1 qiankun 加载应用的过程


qiankun 的特性和它的应用加载方式密不可分,我们先从一个叫 loadApp 的方法入手,看看子应用加载的全过程。


1、入口解析


qiankun 从入参中拿到子应用的 name 和 entry,过一个import-html-entry库,这个库也是 qiankun 自己的,有俩主要用途:从 html 解析静态资源(HTML Entry 的基础),并使其在特定上下文下运行(JS 隔离的基础)。


解析后可以得到子应用对应的可执行 JS(execScripts 方法)、静态资源(assetPublicPath)、html 模版(template)。


2、创建应用容器


随后 qiankun 需要构造一个给子应用的容器(createElement),这个容器是一个子应用独有的 div,标记了从子应用信息挖出来的 id、name、version、config 等信息。容器的形态取决于几个因素:



  • 子应用是否有 html 模版,有的话需要装进去才能让子应用找到渲染 DOM

  • 子应用是否需要样式隔离,有的话可能要加一层 shadow DOM


然后要确保在子应用挂载前,这个容器被渲染并挂到页面上。


3、沙箱构造


接着 qiankun 会构造一个沙箱(createSandboxContainer),然后依赖 execScripts 方法把 window 代理到沙箱上,并在恰当的时候开关拦截。


4、构造传给下游 single-app 的生命周期


这里先从子应用脚本中解析出生命周期(bootstrap, mount, unmount, update),然后补充一些逻辑:



  • mount 时,补充容器获取和绑定、容器挂载、沙箱开启

  • unmount 时,补充沙箱关闭、容器卸载


3.2 HTML Entry 接入


html 解析能力来自import-html-entry库。


它加载完 html 资源,就按 string 继续解析(processTpl),主要方法是通过正则匹配出里面的字符串,比如异步 script:


// 异步 script
if (matchedScriptSrc) {
var asyncScript = !!scriptTag.match(SCRIPT_ASYNC_REGEX);
scripts.push(asyncScript ? {
async: true,
src: matchedScriptSrc
} : matchedScriptSrc);
return genScriptReplaceSymbol(matchedScriptSrc, asyncScript);
}

然后把这些资源打包返回。


3.3 样式隔离


样式隔离是避免子应用之间、子应用-父应用之间出现 class 名的相互污染。


处理样式隔离一般只有两个方法:一是为所有 class name 增加唯一的 scope 标记;二是利用 shadow dom 的天然隔离。


自己加 scope


参考 qiankun 文档:常见问题 - qiankun,可以通过干预编译、利用 antd 等框架的能力来做。


scope 的qiankun实现


如果懒得自己 scope,可以通过 qiankun 配置直接生成 scope:


sandbox: { experimentalStyleIsolation: true }

这个参数会给所有的 class name 外层增加一个子应用独有的标识:


div[data-qiankun-react16] .app-main {
font-size: 14px;
}

通过遍历所有 style 节点,增加前缀:


const styleNodes = appElement.querySelectorAll('style') || [];
forEach(styleNodes, (stylesheetElement: HTMLStyleElement) => {
css.process(appElement!, stylesheetElement, appInstanceId);
});
// css.process
const prefix = `${tag}[${QiankunCSSRewriteAttr}="${appName}"]`;
processor.process(stylesheetElement, prefix);

shadow dom 的乾坤实现


qiankun 通过配置也可以实现 shadow dom 隔离:


sandbox: { strictStyleIsolation: true }

其实是在容器和内容间增加了一层 shadow:


// createElement
const { innerHTML } = appElement;
appElement.innerHTML = '';
let shadow: ShadowRoot;
if (appElement.attachShadow) {
shadow = appElement.attachShadow({ mode: 'open' });
} else {
shadow = (appElement as any).createShadowRoot();
}
shadow.innerHTML = innerHTML;

当然后面获取容器的时候也会兼容这点:


// getAppWrapperGetter
if (strictStyleIsolation && supportShadowDOM) {
return element!.shadowRoot!;
}
return element!;

3.4 JS 沙箱


子应用加载过程中,qiankun 构造 JS 沙箱:


// loadApp
if (sandbox) {
sandboxContainer = createSandboxContainer( appName, /* 其他参数 */ );
global = sandboxContainer.instance.proxy as typeof window;
mountSandbox = sandboxContainer.mount;
unmountSandbox = sandboxContainer.unmount;
}

沙箱创建后,会去包裹子应用脚本的执行上下文。沙箱实例被传到 import-html-entry包里,最终用在对 script 标签的包装执行上:


const code = `;(function(window, self, globalThis){;${scriptText}\n${sourceUrl}}).bind(window.proxy)(window.proxy, window.proxy, window.proxy);`
eval(code);

这样子应用的所有「模块和全局变量」声明,就都挂到了代理上。脚本导出的生命周期,也都执行在代理上。


JS 沙箱的目的是,任何一个微应用,在活跃期间能正常使用和修改 window,但卸载后能把「初始」window 还回去供其他微应用正常使用,且当微应用再次活跃时,能找回之前修改过的 window。这就必然需要一个和 app 一一对应的代理对象来管理和记录「app 对 window 的修改」,并提供重置和恢复 window 的能力。



qiankun 为我们准备了三套沙箱方案:



  • ProxySandbox:代理沙箱,在支持 Proxy 时使用

  • LegacySandbox:继承沙箱,在支持 Proxy 且用户 useLooseSandbox 时使用

  • SnapshotSandbox:快照沙箱,在不支持 Proxy 时使用


ProxySandbox


当我们有 Proxy 时,这件事很好办。我们可以让 window 处于「只读模式」,所有对 window 的修改,都将属性挂到代理对象上,使用时先找代理对象,再找真 window。



class ProxySandbox {
proxyWindow
isRunning = false
active() {
this.isRunning = true
}
inactive() {
this.isRunning = false
}
constructor() {
const fakeWindow = Object.create(null)
this.proxyWindow = new Proxy(fakeWindow, {
set: (target, prop, value, receiver) => {
if(this.isRunning) target[prop] = value
},
get: (target, prop, receiver) => {
return prop in target ?target[prop]:window[prop];
}
})
}
}

ProxySandbox 的好处是实现简单,在设计上非常严谨,完全不会影响原生 window,所以卸载时也不需要做任何处理。


LegacySandbox


Proxy 的另一种用法是,放 app 去修改 window,只做被修改属性的「键-初始值」、「键-修改值」记录,在卸载后把初始值挨个重置,在再次挂载后把修改值挨个恢复。



LegacySandbox 保证了真实 window 的属性和当前 app 用到的 window 属性完全一致。如果你需要全局监控当前应用的真实环境,这点就很重要。


SnapshotSandbox


如果环境不支持 Proxy,就没法在「活跃时」做监听,只能尝试在挂载卸载的时候想办法。



  • 对 window 来说,我们只要在挂载时备份一份「快照」存起来,卸载时再把快照覆盖回去。

  • 反过来对 app 环境来说,我们需要在卸载时 diff 出一份「被修改过」的快照,挂载时把快照覆盖回去。



拦截其他副作用


三种沙箱都实现了对 window 属性增删改查的拦截和记录,但子应用还可能对 window 做其他有副作用的操作,比如:定时器、事件监听、DOM节点API操作。


这就是沙箱实例暴露 mount、unmount 方法的原因。当子应用 mount 时,实现对其他副作用的拦截和记录,unmount 时再清除掉。这些副作用包括:


patchInterval									劫持定时器
patchWindowListener 劫持window事件监听
patchHistoryListener 劫持history事件监听(umi专用)
patchDocumentCreateElement 劫持DOM节点创建
patchHTMLDynamicAppendPrototypeFunctions 劫持DOM节点添加方法

副作用的拦截方法都采用同样的接口实现:


function patchXXX(global) {
// 给 mount 调用,在 global 上拦截方法
return function free() {
// 给 unmount 调用,清除副作用
}
}

实现思路都是维护一个「池」,把 mount 后注册的定时器、事件、DOM等记录下来,在 unmount 时清除。比如定时器:


function patchInterval(global) {
// 给 mount 调用,在 global 上拦截方法
let intervals: number[] = [];
global.clearInterval = (intervalId: number) => {
intervals = intervals.filter((id) => id !== intervalId);
return rawWindowClearInterval.call(window, intervalId as any);
};
global.setInterval = (handler: CallableFunction, timeout?: number) => {
const intervalId = rawWindowInterval(handler, timeout);
intervals = [...intervals, intervalId];
return intervalId;
};
return function free() {
// 给 unmount 调用,清除副作用
intervals.forEach((id) => global.clearInterval(id));
}
}

3.4 预加载


qiankun 可以通过配置或手动调用发起 prefetch:


start({ prefetch: true });
// or
prefetchApps([...]);

发起预加载的时机无非两种:立即预加载(prefetchImmediately)、首个应用挂载后预加载其他应用(prefetchAfterFirstMounted)。但这只是时机差别,预加载的逻辑是一致的。


qiankun 说了,在浏览器空闲时预加载,那肯定要用 requestIdleCallback:


requestIdleCallback(async () => {
// 第一次空闲时解析入口资源
const { getExternalScripts, getExternalStyleSheets } = await importEntry(entry, opts);
// 后面空闲时下载资源
requestIdleCallback(getExternalStyleSheets);
requestIdleCallback(getExternalScripts);
});

3.5 小结



  • 乾坤的特性离不开在子应用加载上下的功夫(loadApp),这个过程包含入口解析、容器创建、沙箱构造、生命周期补充,最后调用 single-app

  • HTML Entry 特性由 import-html-entry 库实现,通过对 html 字符串进行正则匹配,得到资源信息。

  • 样式隔离主要有 class name scope 和 shadow DOM 两种方式,qiankun 都做了支持。前者靠遍历 stylesheet 更改 class name,后者靠容器构建时增加 shadow DOM 层。

  • JS 沙箱是用一个代理对象拦截对 window 的操作。qiankun 提供了Snapshot、Proxy、Legacy三种沙箱,区别在于对属性增删改的拦截方式,效果是一样的。一些直接调用全局 api 的副作用(定时器、DOM操作、事件等)则需要额外拦截和恢复,通常靠维护一个「属于当前子应用的副作用池」。

  • 预加载用的是 requestIdleCallback。


4 业务系统


4.1 配置的数据模型


为了实现动态部署,业务平台要回答一个问题:每次启动时,这个微前端要注册哪些微应用?也就是「平台 - 系统 - 子应用 - 资源」之间关系的维护和下发。


好在这个关系并不复杂:



这是一套最简单的微前端管理模型,在此之上,可根据自己需求选择性加上用户、角色、权限、模版、菜单、导航等。


4.2 用户请求流程


当用户来访问业务平台上的系统时,基本会经过以下流程:




  1. 用户通过域名,经DNS解析,访问到平台的前端服务器。平台作为基建,会承载多个业务系统,每个业务系统又有各自的域名,这里会要求每个接入的业务域名都在DNS配置解析到平台统一的前端服务器IP。

  2. 匹配接入配置,锁定一个系统。一方面系统配置会作为“准入”的nginx配置挂在前端服务器上。另一方面,根据请求携带的域名等信息,可以匹配到具体请求来自哪个系统。

  3. 根据系统获取系统配置。这些配置包含整个系统ID关联的子应用、资源、权限、导航等等配置,通过接口可以一次性返回给客户端,也可以先返回系统ID,客户端再按需请求。客户端的微前端框架现在知道要注册哪些应用了。

  4. 客户端加载静态资源。在配置中会关联应用框架和子应用用到的所有静态资源的CDN地址,按微前端的逻辑,这些资源会在微应用 load 的时候异步加载。


Z 总结



  • 微前端将单体应用拆分为若干个微应用,它们独立开发、独立部署、独立运行。其理论基础是不同框架下相同的底层API。目前主流是在 single-spa、qiankun 的技术方案基础上,做业务系统的封装。

  • single-spa 根据路由变化调度微应用的资源加载和运行,并定义了一套微应用生命周期。实现上依赖内部的一套“应用池”+“刷新器”+路由监听。

  • qiankun 在 single-spa 基础上增加了 js 和 css 隔离、html entry、预加载等开箱即用的工程友好特性。其基础是自己实现了import-html-entry库来控制资源加载和运行时,实现一层容器以隔离样式,并借助 Proxy 等api 实现沙箱来劫持 window 操作。

  • 在业务系统实际应用中,还要在 qiankun 基础上构建数据模型和服务,实现「平台-系统-微应用-资源」各级配置的下发来启动和动态注册微
    作者:几木_Henry
    来源:juejin.cn/post/7262158134322806844
    前端。

收起阅读 »

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

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
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »