注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

如何接"地气"的接入微前端

前言微前端,这个概念已经在国内不止一次的登上各大热门话题,它所解决的问题也很明显,这几个微前端所提到的痛点在我们团队所维护的项目中也是非常凸显。但我始终认为,一个新的技术、浪潮,每每被讨论最热门的一定是他背后所代表的杰出思考。“微前端就是…xx 框架,xx 技...
继续阅读 »



前言

微前端,这个概念已经在国内不止一次的登上各大热门话题,它所解决的问题也很明显,这几个微前端所提到的痛点在我们团队所维护的项目中也是非常凸显。

但我始终认为,一个新的技术、浪潮,每每被讨论最热门的一定是他背后所代表的杰出思考。

“微前端就是…xx 框架,xx 技术”

这种话就有点把这种杰出的思路说的局限了,我只能认为他是外行人,来蹭这个词的热度。

在我所负责的项目和团队中,已经有非常大的存量技术栈和页面已经在线上运行,任何迭代升级都必须要保证小心翼翼,万无一失。

可以说,从一定程度来讲,微前端所带来的这些好处是从用户体验和技术维护方面的,对业务的价值并不能量化体现,落地这项技术秉着既要也要还要的指导方针。

我们对存量技术栈一定需要保持敬畏,隔离,影响范围可控的几个基本要素,然后再考虑落地实施微前端方案。

所以,在这个基本要素和指导方针下。要落地这项新的技术时,一定充分充分了解,当前改造站点所存在的技术方案、占比 以及 当前成熟微前端框架已提供的能力差异,切勿生搬硬套。

背景

我所在团队维护的项目都是些 PC 操作后台(Workstation),这些工作台会存在不同的国家,不同时区,不同合作方等等问题。

如果需要开发一个新的页面需求,很可能投入进来的开发人员都来自不同团队,此时我们要在完成现有需求的同时还需要保证多个管理页面的风格统一,设计规范统一,组件统一,交互行为统一这非常困难。

当该业务需要迁移到另外一个工作台时,虽然需要保持逻辑一致,但导航栏、主题等却不同。

当前存量的方案都是采用 Java 直接进行 Template 渲染出 HTML,经过前面几代前辈的迭代,不同系统中已经存在几种不同技术栈产出的页面。

虽然都是 React 来实现的,但是前辈们都非常能折腾,没有一个是按照常规 React 组件形式开发出来的。

比如:

  1. 大部分页面是通过一份 JSON 配置,消费组件生成的页面。

  2. 部分页面是通过另外一个团队定义的 JSON 配置消费组件生成的,与上面 JSON 完全不一样。

  3. 还有一部分页面,是通过一套页面发布平台提供的 JS Bundle 加载出来的。

面对这样的技术背景下,除了微笑的喊 MMP,含泪说着自己听不懂的话(存在即合理,不难要你干吗?),还得接地气出这样一个落地方案。

方案 & 流程图

首先,需要明确的分析出站点所有页面,所需要加载的通用特性:

上述是精简过后的一些通用功能特性,这里简单做下介绍:

  • Layout Loader 用于加载不同工作台的导航

  • DADA Loader 用于加载 JSON 配置的页面

  • Source Code Loader 用于加载 JS Bundle

  • Micro Loader 用于处理微前端加载

  • Log Report 用于日志埋点

  • Time Zone 用于切换时区

  • i18n 用于切换多语言

  • Guider 用于统一管控用户引导

除此以外可能还会存在以下这些页面扩展能力:

  • 安全监控

  • 流量管控

  • 弹窗管控

  • 问卷调查

  • 答疑机器人

粗略统一归类后来看,页面的大体加载流程应该是这样:

实现细则

基于上述一个加载思路,首先需要做的是页面加载路径收口,需要保证所有页面的加载入口是在一个统一的 Loader 下,然后才可以较为系统的处理所有页面的加载生命周期。

在收敛的同时,同样需要保持开放,对核心加载路径要保持插件化开放,随时支持不同的扩展能力,渲染技术栈接入。

插件机制

所以,在主路径上,通过 Loader 加载配置进行处理,这份配置在主路径中提供上下文,然后交由插件进行消费,如图所示:

举个例子,拿一个独立的 JS Bundle 类型的子应用来说:

<div id="root"></div>
<script src="https://cdn.address/schema-resolver/index.js"></script>
<script src="https://cdn.address/schema-resolver/plugin/layout.js"></script>
<script src="https://cdn.address/schema-resolver/plugin/source-code.js"></script>
<script src="https://cdn.address/schema-resolver/plugin/micro-loader.js"></script>
<script src="https://cdn.address/schema-resolver/plugin/i18n.js"></script>

<script>
 SchemaResolver.render(
  {
     micro: true,
     host: "dev.address",
     hfType: "layout1",
     externals: ["//{HOST}/theme1/index.css"],
     // host is cdn prefix, the resource maybe in different env & country
     resource: {
       js: "/index.js",
       css: "/index.css",
    },
  },
  { container: document.querySelector("#root") }
);
</script>

通过上述的 Plugin 引入,即可开启和消费不同的配置。

这里引入了 Layout Plugin,该插件会消费 hfType 字段然后去加载对于的 Layout 资源提供 Container 交给下一个环节。

按照配置,当前页面开启了微前端,那么 Micro Loader 将会消费提供下来的 Container,然后建立沙箱(这里基于 qiankun),再提供 Container 出来。

最后交由 SourceCode Plugin 进行 Bundle 加载运行和渲染。如果这里是另外一种渲染协议或者技术栈,则可以根据不同配置交由不同插件消费 Container。

这个过程中,每个环节的插件是不依赖的,可插拔的

比如:

如果不加载 Layout Plugin 将不会消费 hfType 字段,也就不会将Layout插件逻辑注入到getContainer方法中,那么将直接得到由最外层下穿的Container进行渲染,也就不会有菜单相关透出。

如果不加载 Micro Plugin 同样不会有微前端的逻辑注入,也就不会建立沙箱,那么页面渲染流程将会按照常规模式继续运行。

当前SchemaResolver已经支持的插件有以下几种,详情参考 SchemaResolver

  • MicroLoader – Base on qiankun using this plug-in can make your content loaded through a micro application so that your content can use all the features of the Micro-Front-End.

  • DadaLoader – Use this plugin can make your app render content by Dada.

  • SourceCodeLoader – Use this plugin can load your js\css bundle to render content, our bundle standard is same as qiankun. You can quick start developing your own page through our toolkit lzd-toolkit-asc.

  • LayoutLoader – Use this plugin can make your page load layout(menu), you can use different hfType configuration to switch different layouts.

  • i18n – Use this plugin can make your page have multi-lang. schema.locale will be the mapping of multilingual keys in MCMS. The plugin will inject and register the language automatically.

  • APlus – Use this plugin can make your page have the feature of APlus .Statistics page interaction events, such as pv\uv. With DadaLoader you can even see every module data(click pv, exposed pv) in pages.

  • WalkThrough – Use this plugin can make your page have the feature of Walk Through. One-stop page features guide.

SchemaResolver的插件能力采用plugin-decorator,如要了解更多插件设计思路可以参考:为你的JavaScript库提供插件能力

SchemaResolver plugin feature is base on plugin-decorator. It’s very easy to develop a new plugin.

More information about plugin can read this article Provide plugin capabilities for your JavaScript library

安全迁移

对于我所在团队负责的项目来说,万万做不得一刀切的方案,所以针对现有存量页面,需要完整分析当前存量技术栈:

针对上述存量页面来说,需要从左到右分批进行页面级别控制上线部署,对于左侧部分页面甚至需要做些项目改造后才可部署接入上线。

这类迁移测试需要处理出一套 自动化e2e测试 流程,通过分批迁移同时梳理出 微前端注册表

有了这两项流程保证及范围控制,当前方案所上线内容完全可控,剩下要处理的大部分就是较为重复的体力活了,覆盖率也可量化。

微前端形态

按照上述方案迁移,那么预期的微前端形态将会是:

  1. 每个开启微前端的页面都可成为主应用

  2. 微前端是插件可选项,如果因为微前端导致的业务异常可随时关闭

  3. 同为微前端的页面路由相互之间切换可实现局部刷新形态,而跳转至非微前端注册表中的页面则会直接页面跳转。随着微前端页面覆盖率提高,局部刷新的覆盖率也会逐渐提高

  4. 可通过不同扩展插件,加载不同技术栈类型的存量页面,转换为对应子应用

在SchemaResolver中的注册和调用路径如下:

总结

透过技术看本质,微前端所代表的杰出思维,才是真正解决具体问题关键所在,只有解决了具体的业务问题,这项技术才有价值转换。

不要为了微前端做微前端,不要为了小程序做小程序。

当前,通过 SchemaResolver,可以针对不同角色提供不同的开放能力:

  • 针对平台管理员,提供插件能力开放全局扩展能力

  • 针对页面开发者,提供标准化接入方案路径,提供多种技术栈接入能力,并无感知提供微前端,多语言,埋点,菜单,主题加载等能力。解耦了不同系统公共能力,同时,这种方式可以让页面开发者快速将具体业务逻辑迁移到其他平台。

  • 针对第三方接入者,不需要关心了解系统菜单、主题接入方式,提供统一的接入口径,通过微前端隔离技术栈、隔离子应用样式。最后通过统一的页面系统管控,轻松入住对应平台,同时可以全局看到站点页面情况。

作者:Yee Wang
来源:https://yeee.wang/posts/3469.html

收起阅读 »

为你的JavaScript库提供插件能力

前言最近在做一个中台框架的设计开发,在做了主框架的基础能力后,思考在框架落实真实业务需求过程中,需要对主框架功能有非常多的定制化内容存在。如果在主体框架中做了哪怕一点业务改动,都可能会对后面的拓展性及灵活性有所限制。所以为了让主体框架做的更加灵活、扩展性更搞,...
继续阅读 »



前言

最近在做一个中台框架的设计开发,在做了主框架的基础能力后,思考在框架落实真实业务需求过程中,需要对主框架功能有非常多的定制化内容存在。如果在主体框架中做了哪怕一点业务改动,都可能会对后面的拓展性及灵活性有所限制。

所以为了让主体框架做的更加灵活、扩展性更搞,在主框架有了基础能力后,就不再对主框架做任何非主框架能力的业务功能开发。

要为主框架不断的”开槽”

其实在很多前端库中都有类似的设计,才能够让更多的开发者参与进来,完成各种各样的社区驱动开发。比如:WebpackBabelHexoVuePress等。

那么如何为自己的项目开槽,做插件呢?

调研

在了解了很多插件的项目源码后,发现实现大多大同小异,主要分为以下几个步骤:

  1. 为框架开发安装插件能力,插件容器

  2. 暴露主要生命运行周期节点方法(开槽)

  3. 编写注入业务插件代码

这些框架都是在自己实现一套这样的插件工具,几乎和业务强相关,不好拆离。或者做了一个改写方法的工具类。总体比较离散,不好直接拿来即用。

另外在实现方式上,大部分的插件实现是直接改写某方法,为他在运行时多加一个Wrap,来依次运行外部插件的逻辑代码。

// main.js
const main = {
 loadData:()=>{},
 render:()=>{}
}

// plugin1.js
const plugin1 = {
 render:()=>{}
}

// install.js
const install = (main, plugin) => {
 main.render = ()=>{
   plugin1.render()
   main.render()
}
}

在上述代码中的插件有几个明显的问题:

  • plugin1 无法控制 render() 的顺序

  • main 中无法得知什么函数可能会被插件改写,什么函数不会被插件改写

  • 如果按照模块文件拆分,团队成员中根本不知道 main.js 中的函数修改会存在风险,因为压根看不到 install.js 中的代码

那么后来,为了解决这些问题,可能会变成这样:

const component = {
 hooks:{
   componentWillMounted(){},
   componentDidMounted(){}
},
 mounte(){
   this.hooks.componentWillMounted();
   //... main code
   this.hooks.componentDidMounted();
}
}

const plugin = {
 componentWillMounted(){
   //...
},
 componentDidMounted(){
   //...
}
}

// install.js
const install = (main, plugin) => {
 // 忽略实现细节。
 main.hooks.componentWillMounted = ()=>{
   plugin1.componentWillMounted()
   main.hook.componentWillMounted()
}
 main.hooks.componentDidMounted = ()=>{
   plugin1.componentDidMounted()
   main.hook.componentDidMounted()
}
}

另外,还有一种解法,会给插件中给 next 方法,如下:

// main.js
const main = {
 loadData:()=>{},
 render:()=>{}
}

// plugin1.js
const plugin1 = {
 render:next=>{
   // run some thing before
   next();
   // run some thing after
}
}

// install.js
const install = (main, plugin) => {
 main.render = ()=>{
   plugin1.render(main.render)
}
}

如上,从调研结构来看,虽然都实现了对应功能,但是从实现过程来看,有几个比较明显的问题:

  • 对原函数侵入性修改过多

  • 对方法rewrite操作过多,太hack

  • 对TypeScript不友好

  • 多成员协作不友好

  • 对原函数操作不够灵活,不能修改原函数的入参出参

开搞

在调研了很多框架的实现方案的后,我希望以后我自己的插件库可以使用一个装饰器完成开槽,在插件类中通过一个装饰器完成注入,可以像这样使用和开发:

import { Hook, Inject, PluginTarget, Plugin } from 'plugin-decorator';

class DemoTarget extends PluginTarget {
 @Hook
 public method1() {
   console.log('origin method');
}
}

class DemoPlugin extends Plugin {
 @Inject
 public method1(next) {
   next();
   console.log('plugin method');
}
}

const demoTarget = new DemoTarget();
demoTarget.install(new DemoPlugin());
demoTarget.method1();

// => origin method
// => plugin method

Decorator

并且可以支持对原函数的入参出参做装饰修改,如下:

import { Hook, Inject, PluginTarget, Plugin } from 'plugin-decorator';

class DemoTarget extends PluginTarget {
 @Hook
 public method1(name:string) {
   return `origin method ${name}`;
}
}

class DemoPlugin extends Plugin {
 @Inject
 public method1(next, name) {
   return `plugin ${next(name)}`;
}
}

const demoTarget = new DemoTarget();
demoTarget.install(new DemoPlugin());

console.log(demoTarget.method1('cool'));

// => plugin origin method cool

Promise

当然,如果原函数是一个Promise的函数,那插件也应该支持Promise了!如下:

import { Hook, Inject, PluginTarget, Plugin } from 'plugin-decorator';

class DemoTarget extends PluginTarget {
 @Hook
 public methodPromise() {
   return new Promise(resolve => {
     setTimeout(() => resolve('origin method'), 1000);
  });
}
}

class DemoPlugin extends Plugin {
 @Inject
 public async methodPromise(next) {
   return `plugin ${await next()}`;
}
}

const demoTarget = new DemoTarget();
demoTarget.install(new DemoPlugin());

demoTarget.methodPromise().then(console.log);

// => Promise<plugin origin method>

Duang!

最终,我完成了这个库的开发:plugin-decorator

GitHub: 地址

没错,我就知道你会点Star,毕竟你这么帅气、高大、威猛、酷炫、大佬!

总结

在该项目中,另外值得提的一点是,该项目是我在开发自己的一套中台框架中临时抽出来的一个工具库。

在工具库中采用了:

  • TypeScript

  • Ava Unit Test

  • Nyc

  • Typedoc

整体开发过程是先写测试用例,然后再按照测试用例进行开发,也就是传说中的 TDD(Test Drive Development)。

感觉这种方式,至少在我做库的抽离过程中,非常棒,整体开发流程非常高效,目的清晰。

在库的编译搭建中使用了 typescript-starter 这个库,为我节省了不少搭建项目的时间!

作者:Yee Wang
来源:https://yeee.wang/posts/dfa4.html

收起阅读 »

轻松生成小程序分享海报

小程序海报组件github.com/jasondu/wxa…需求小程序分享到朋友圈只能使用小程序码海报来实现,生成小程序码的方式有两种,一种是使用后端方式,一种是使用小程序自带的canvas生成;后端的方式开发难度大,由于生成图片耗用内存比较大对服务端也是不小...
继续阅读 »



小程序海报组件

github.com/jasondu/wxa…

需求

小程序分享到朋友圈只能使用小程序码海报来实现,生成小程序码的方式有两种,一种是使用后端方式,一种是使用小程序自带的canvas生成;后端的方式开发难度大,由于生成图片耗用内存比较大对服务端也是不小的压力;所以使用小程序的canvas是一个不错的选择,但由于canvas水比较深,坑比较多,还有不同海报需要重现写渲染流程,导致代码冗余难以维护,加上不同设备版本的情况不一样,因此小程序海报生成组件的需求十分迫切。

在实际开发中,我发现海报中的元素无非一下几种,只要实现这几种,就可以通过一份配置文件生成各种各样的海报了。

海报中的元素分类

要解决的问题

  • 单位问题

  • canvas隐藏问题

  • 圆角矩形、圆角图片

  • 多段文字

  • 超长文字和多行文字缩略问题

  • 矩形包含文字

  • 多个元素间的层级问题

  • 图片尺寸和渲染尺寸不一致问题

  • canvas转图片

  • IOS 6.6.7 clip问题

  • 关于获取canvas实例

单位问题

canvas绘制使用的是px单位,但不同设备的px是需要换算的,所以在组件中统一使用rpx单位,这里就涉及到单位怎么换算问题。

通过wx.getSystemInfoSync获取设备屏幕尺寸,从而得到比例,进而做转换,代码如下:

const sysInfo = wx.getSystemInfoSync();
const screenWidth = sysInfo.screenWidth;
this.factor = screenWidth / 750; // 获取比例
function toPx(rpx) { // rpx转px
return rpx * this.factor;
}
function toRpx(px) { // px转rpx
return px / this.factor;
},

canvas隐藏问题

在绘制海报过程时,我们不想让用户看到canvas,所以我们必须把canvas隐藏起来,一开始想到的是使用display:none; 但这样在转化成图片时会空白,所以这个是行不通的,所以只能控制canvas的绝对定位,将其移出可视界面,代码如下:

.canvas.pro {
  position: absolute;
  bottom: 0;
  left: -9999rpx;
}

圆角矩形、圆角图片

由于canvas没有提供现成的圆角api,所以我们只能手工画啦,实际上圆角矩形就是由4条线(黄色)和4个圆弧(红色)组成的,如下:

圆弧可以使用canvasContext.arcTo这个api实现,这个api的入参由两个控制点一个半径组成,对应上图的示例

canvasContext.arcTo(x1, y1, x2, y2, r)

接下来我们就可以非常轻松的写出生成圆角矩形的函数啦

/**
* 画圆角矩形
*/
_drawRadiusRect(x, y, w, h, r) {
  const br = r / 2;
  this.ctx.beginPath();
  this.ctx.moveTo(this.toPx(x + br), this.toPx(y));   // 移动到左上角的点
  this.ctx.lineTo(this.toPx(x + w - br), this.toPx(y)); // 画上边的线
  this.ctx.arcTo(this.toPx(x + w), this.toPx(y), this.toPx(x + w), this.toPx(y + br), this.toPx(br)); // 画右上角的弧
  this.ctx.lineTo(this.toPx(x + w), this.toPx(y + h - br)); // 画右边的线
  this.ctx.arcTo(this.toPx(x + w), this.toPx(y + h), this.toPx(x + w - br), this.toPx(y + h), this.toPx(br)); // 画右下角的弧
  this.ctx.lineTo(this.toPx(x + br), this.toPx(y + h)); // 画下边的线
  this.ctx.arcTo(this.toPx(x), this.toPx(y + h), this.toPx(x), this.toPx(y + h - br), this.toPx(br)); // 画左下角的弧
  this.ctx.lineTo(this.toPx(x), this.toPx(y + br)); // 画左边的线
  this.ctx.arcTo(this.toPx(x), this.toPx(y), this.toPx(x + br), this.toPx(y), this.toPx(br)); // 画左上角的弧
}

如果是画线框就使用this.ctx.stroke();

如果是画色块就使用this.ctx.fill();

如果是圆角图片就使用

this.ctx.clip();
this.ctx.drawImage(***);

clip() 方法从原始画布中剪切任意形状和尺寸。一旦剪切了某个区域,则所有之后的绘图都会被限制在被剪切的区域内(不能访问画布上的其他区域)。可以在使用 clip() 方法前通过使用 save() 方法对当前画布区域进行保存,并在以后的任意时间对其进行恢复(通过 restore() 方法)。

多段文字

如果是连续多段不同格式的文字,如果让用户每段文字都指定坐标是不现实的,因为上一段文字的长度是不固定的,这里的解决方案是使用ctx.measureText (基础库 1.9.90 开始支持)Api来计算一段文字的宽度,记住这里返回宽度的单位是px(),从而知道下一段文字的坐标。

超长文字和多行文字缩略问题

设置文字的宽度,通过ctx.measureText知道文字的宽度,如果超出设定的宽度,超出部分使用“...”代替;对于多行文字,经测试发现字体的高度大约等于字体大小,并提供lineHeight参数让用户可以自定义行高,这样我们就可以知道下一行的y轴坐标了。

矩形包含文字

这个同样使用ctx.measureText接口,从而控制矩形的宽度,当然这里用户还可以设置paddingLeft和paddingRight字段;

文字的垂直居中问题可以设置文字的基线对齐方式为middle(this.ctx.setTextBaseline('middle');),设置文字的坐标为矩形的中线就可以了;水平居中this.ctx.setTextAlign('center');;

多个元素间的层级问题

由于canvas没有Api可以设置绘制元素的层级,只能是根据后绘制层级高于前面绘制的方式,所以需要用户传入zIndex字段,利用数组排序(Array.prototype.sort)后再根据顺序绘制。

图片尺寸和渲染尺寸不一致问题

绘制图片我们使用ctx.drawImage()API;

如果使用drawImage(dx, dy, dWidth, dHeight),图片会压缩尺寸以适应绘制的尺寸,图片会变形,如下图:

在基础库1.9.0起支持drawImage(sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight),sx和sy是源图像的矩形选择框左上角的坐标,sWidth和sHeight是源图像的矩形选择框的宽度和高度,如下图:

如果绘制尺寸比源图尺寸宽,那么绘制尺寸的宽度就等于源图宽度;反之,绘制尺寸比源图尺寸高,那么绘制尺寸的高度等于源图高度;

我们可以通过wx.getImageInfoApi获取源图的尺寸;

canvas转图片

在canvas绘制完成后调用wx.canvasToTempFilePathApi将canvas转为图片输出,这样需要注意,wx.canvasToTempFilePath需要写在this.ctx.draw的回调中,并且在组件中使用这个接口需要在第二个入参传入this(),如下

this.ctx.draw(false, () => {
  wx.canvasToTempFilePath({
      canvasId: 'canvasid',
      success: (res) => {
          wx.hideLoading();
          this.triggerEvent('success', res.tempFilePath);
      },
      fail: (err) => {
          wx.hideLoading();
          this.triggerEvent('fail', err);
      }
  }, this);
});

IOS 6.6.7 clip问题

在IOS 6.6.7版本中clip方法连续裁剪图片时,只有第一张有效,这是微信的bug,官方也证实了(developers.weixin.qq.com/community/d…

关于获取canvas实例

我们可以使用wx.createCanvasContext获取小程序实例,但在组件中使用切记第二个参数需要带上this,如下

this.ctx = wx.createCanvasContext('canvasid', this);

如何使用组件

github.com/jasondu/wxa…

作者:jasondu41833
来源:https://juejin.cn/post/6844903663840788493

收起阅读 »

利用好 git bisect 这把利器,帮助你快速定位疑难 bug

Git
使用git bisect二分法定位问题的基本步骤:git bisect start [最近的出错的commitid] [较远的正确的commitid]测试相应的功能git bisect good 标记正确直到出现问题则 标记错误 git bisect bad提...
继续阅读 »

使用git bisect二分法定位问题的基本步骤:

  1. git bisect start [最近的出错的commitid] [较远的正确的commitid]

  2. 测试相应的功能

  3. git bisect good 标记正确

  4. 直到出现问题则 标记错误 git bisect bad

  5. 提示的commitid就是导致问题的那次提交

问题描述

我们以Vue DevUI组件库的一个bug举例子🌰

5d14c34b这一次commit,执行yarn build报错,报错信息如下:

✓ building client + server bundles...
✖ rendering pages...
build error:
ReferenceError: document is not defined

我可以确定的是上一次发版本(d577ce4)是可以build成功的。

git bisect 简介

git bisect命令使用二分搜索算法来查找提交历史中的哪一次提交引入了错误。它几乎能让你闭着眼睛快速定位任何源码导致的问题,非常实用。

你只需要告诉这个命令一个包含该bug的坏commit ID和一个引入该bug之前的好commit ID,这个命令会用二分法在这两个提交之间选择一个中间的commit ID,切换到那个commit ID的代码,然后询问你这是好的commit ID还是坏的commit ID,你告诉它是好还是坏,然后它会不断缩小范围,直到找到那次引入bug的凶手commit ID

这样我们就只需要分析那一次提交的代码,就能快速定位和解决这个bug(具体定位的时间取决于该次提交的代码量和你的经验),所以我们提交代码时一定要养成小批量提交的习惯,每次只提交一个小的独立功能,这样出问题了,定位起来会非常快。

接下来我就以Vue DevUI之前出现过的一个bug为例,详细介绍下如何使用git bisect这把利器。

定位过程

git bisect start 5d14c34b d577ce4
or
git bisect start HEAD d577ce4

其中5d14c34b这次是最近出现的有bug的提交,d577ce4这个是上一次发版本没问题的提交。

执行完启动bisect之后,马上就切到中间的一次提交啦,以下是打印结果:

kagol:vue-devui kagol$ git bisect start 5d14c34b d577ce4
Bisecting: 11 revisions left to test after this (roughly 4 steps)
[1cfafaaa58e03850e0c9ddc4246ae40d18b03d71] fix: read-tip icon样式泄露 (#54)

可以看到已经切到以下提交:

[1cfafaaa] fix: read-tip icon样式泄露 (#54)

执行命令:

yarn build

构建成功,所以标记下good

git bisect good
kagol:vue-devui kagol$ git bisect good
Bisecting: 5 revisions left to test after this (roughly 3 steps)
[c0c4cc1a25c5c6967b85100ee8ac636d90eff4b0] feat(drawer): add service model (#27)

标记万good,马上又通过二分法,切到了一次新的提交:

[c0c4cc1a] feat(drawer): add service model (#27)

再次执行build命令:

yarn build

build失败了,出现了我们最早遇到的报错:

✓ building client + server bundles...
✖ rendering pages...
build error:
ReferenceError: document is not defined

标记下bad,再一次切到中间的提交:

kagol:vue-devui kagol$ git bisect bad
Bisecting: 2 revisions left to test after this (roughly 2 steps)
[86634fd8efd2b808811835e7cb7ca80bc2904795] feat: add scss preprocessor in docs && fix:(Toast) single lifeMode bug in Toast

以此类推,不断地验证、标记、验证、标记...最终会提示我们那一次提交导致了这次的bug,提交者、提交时间、提交message等信息。

kagol:vue-devui kagol$ git bisect good
c0c4cc1a25c5c6967b85100ee8ac636d90eff4b0 is the first bad commit
commit c0c4cc1a25c5c6967b85100ee8ac636d90eff4b0
Author: nif <lnzhangsong@163.com>
Date: Sun Dec 26 21:37:05 2021 +0800

feat(drawer): add service model (#27)

* feat(drawer): add service model

* docs(drawer): add service model demo

* fix(drawer): remove 'console.log()'

packages/devui-vue/devui/drawer/index.ts | 7 +++--
.../devui-vue/devui/drawer/src/drawer-service.ts | 33 ++++++++++++++++++++++
packages/devui-vue/devui/drawer/src/drawer.tsx | 3 ++
packages/devui-vue/docs/components/drawer/index.md | 29 +++++++++++++++++++
4 files changed, 69 insertions(+), 3 deletions(-)
create mode 100644 packages/devui-vue/devui/drawer/src/drawer-service.ts

最终定位到出问题的commit:

c0c4cc1a is the first bad commit

github.com/DevCloudFE/…

整个定位过程几乎是机械的操作,不需要了解项目源码,不需要了解最近谁提交了什么内容,只需要无脑地:验证、标记、验证、标记,最后git会告诉我们那一次提交出错。

这么香的工具,赶紧来试试吧!

问题分析

直到哪个commit出问题了,定位起来范围就小了很多。

如果平时提交代码又能很好地遵循小颗粒提交的话,bug呼之欲出。

这里必须表扬下我们DevUI的田主(Contributor)们,他们都养成了小颗粒提交的习惯,这次导致bug的提交c0c4cc1a,只提交了4个文件,涉及70多行代码。

我们在其中搜索下document关键字,发现了两处,都在drawer-service.ts整个文件中:

一处是12行的:

static $body: HTMLElement | null = document.body

另一处是17行的:

this.$div = document.createElement('div')

最终发现罪魁祸首就是12行的代码!

破案!

作者:DevUI团队
来源:https://juejin.cn/post/7046409685561245733

收起阅读 »

前端开发的积木理论——像搭积木一样做前端开发

1 概述用户界面是由一系列组件组合而成,组件将数据和交互封装在内,仅保留必要的接口与其他组件进行通信。在前端开发中,组件就像一个一个的小积木块,我们用这些积木块拼出一个一个页面,这些页面组成了一个完整的为用户提供价值的业务。相信大部分前端工程师都使用过组件库,...
继续阅读 »



1 概述

用户界面是由一系列组件组合而成,组件将数据和交互封装在内,仅保留必要的接口与其他组件进行通信。

在前端开发中,组件就像一个一个的小积木块,我们用这些积木块拼出一个一个页面,这些页面组成了一个完整的为用户提供价值的业务。

相信大部分前端工程师都使用过组件库,比如Ant DesignElementUI,都是我们非常熟悉的组件库,以及我们团队做的DevUI组件库。

组件库就像一个工具箱,里面包含了各式各样奇形怪状、功能各异的组件,我们直接拿这些组件小积木来拼页面,非常方便。

2 界面的基本元素

从抽象的角度来看,任何用户界面都是由组件数据交互组成的。

2.1 组件

组件是一个具有一定功能的独立单元,不同的组件可以组合在一起,构成功能更强大的组件.

组件是网页的器官。

组件的概念要和HTML标签的概念区分开来,HTML标签是网页的基本单元,而组件是基于HTML标签的、包含特定功能的独立单元,可以简单理解为组件是HTML标签的一个超集。

组件内部封装的是数据和交互,对外暴露必要的接口,以与其他组件进行通信。

2.2 数据

用户界面中包含很多数据,有不变的静态数据,也有随时间和交互改变的动态数据,它们大多来自于后台数据库。

组件内部包含数据,组件之间传递的也是数据。

数据是网页的核心。

在前端开发中,数据主要以JSON格式进行存储和传递。

2.3 交互

交互是用户的操作,主要通过鼠标和键盘等计算机外设触发,点击一次按钮、在文本框中输入一些字符、按下回车键等都是交互。

在网页中,所有的交互都是通过事件的方式进行响应的。

交互是网页的灵魂,不能进行交互的网页就像干涸的河流,了无生气。

3 组件的特性

一个设计良好的组件应该包含以下特性:

  • 复用性(Reuseability)

  • 组合性(Composability)

  • 扩展性(Scalability)

3.1 复用性

组件作为一个独立的单元,除了自身的特定功能之外,不应该包含任何业务相关的数据。

组件应该能够复用到任何业务中,只要该业务需要用到组件所包含的功能。

组件应该是资源独立的,以增强组件的复用能力。

3.2 组合性

组件与组件之间可以像积木一样进行组合,组合之后的组件拥有子组件的所有功能,变得更强大。

组合的方式可以是包裹别的组件,也可以是作为参数传入别的组件中。

3.3 扩展性

可以基于现有的组件进行扩展,开发功能更加定制化的组件,以满足业务需求。

组件的可扩展能力依赖于接口的设计,接口要尽可能的灵活,以应对不断变化的需求。

4 组件间通信

4.1 从外向内通信

通过props将数据传递到组件内部,以供组件自身或其子组件使用。

props是不可变的:

  • 这意味着我们无法在组件内部修改props的原始值

  • 也意味着只有外部传入了props,才能在组件内部获取和使用它

4.2 从内向外通信

可以通过两种方式将组件内部的数据传递到组件外:

  • 通过ref属性(组件实例的引用),通过组件的ref属性,可以获取到组件实例的引用,进而获取组件内部的数据和方法

  • 通过事件回调(比如:click),通过事件回调的方式,可以通过props将一个回调函数传递到组件内部,并将组件内部的数据通过回调传递到外部

4.3 双向通信

可以通过全局context的方式进行双向通信。

父组件声明context对象,那么其下所有的子组件,不管嵌套多深,都可以使用该对象的数据,并且可以通过回调函数的方式将子组件的数据传递出来供父组件使用。

4.4 多端通信

通过事件订阅的方式可以实现多个组件之间互相通信。

通过自定义事件(Custom Event),任何组件都可以与其他组件进行通讯,采用的是发布/订阅模式,通过向事件对象上添加监听和触发事件来实现组件间通信。

5 案例

接下来通过广告详情页面的开发来演示如何用积木理论来构建网页。

设计图如下:

5.1 第一步:拆积木

将设计图拆分成有层次的积木结构。

5.1.1 顶层积木

最顶层拆分成四个大积木:

  • Header(头部组件)

  • ChartBlock(图表块组件)

  • DetailBlock(详情块组件)

  • TableBlock(表格块组件)

5.1.2 中间层积木

  • 每个大积木又可以拆分成若干小积木

  • 中间层有些是不可拆分的原子积木,比如:ButtonCheckbox

  • 有些是由原子积木组合而成的复合积木,比如:DateRangePickerTable

层次结构如下:

  • Header

    • Breadcrumb

    • Button

    • DateRangePicker

  • ChartBlock

    • Tabs

    • LineChart

    • BarChart

  • DetailBlock

    • Button

    • List

  • TableBlock

    • Checkbox

    • Select

    • Button

    • Table

5.1.3 底层积木

最底层的积木都是不可拆分的原子积木。

5.2 第二步:造积木

已经将积木的层次结构设计出来,接下来就要考虑每个层次的积木怎么制造的问题。

这一块后面会专门写一个系列文章给大家分享如何自己制造组件。

5.3 第三步:搭积木

5.3.1 顶层积木

对应的代码:

<div class="ad-detail-container">
   <Header />
   <div class="content">
       <div class="chart-detail-block">
           <ChartBlock />
           <DetailBlock />
       </div>
       <TableBlock />
   </div>
</div>

其中的<div>标签是为了布局方便加入的。

5.3.2 中间层积木

Header对应的代码:

<div class="header">
 <div class="breadcrumb-area">
   <div class="breadcrumb-current">gary audio</div>
     <div class="breadcrumb-from">
      From:
       <d-breadcrumb class="breadcrumb" separator="">
         <d-breadcrumb-item href="http://www.qq.com">Campaign List</d-breadcrumb-item>
         <span class="breadcrumb-seprator">> </span>
         <d-breadcrumb-item href="http://www.qq.com">gary audio test</d-breadcrumb-item>
       </d-breadcrumb>
     </div>
   </div>
 </div>
 <div class="operation-area">
   <d-button icon="mail" class="flat" (click)="sendReportEmail()">Send Report Email</d-button>
   <d-date-range-picker (change)="changeDate()" />
 </div>
</div>

需要注意的是:为了方便阐述积木理论的核心思想,这里的原子组件大多都是已经造好的(使用DevUI组件库),也可以选择自己制造,后面会专门写一个系列文章给大家分享如何自己制造组件。

ChartBlock对应的代码:

<div class="chart-block">
   <d-tabs [defaultActiveKey]="1">
       <d-tab-item tab="Ad performance" [key]="1">
           <d-line-chart></d-line-chart>
       </d-tab-item>
       <d-tab-item tab="Audience" [key]="2">
           <d-bar-chart></d-bar-chart>
       </d-tab-item>
   </d-tabs>
</div>

DetailBlock对应的代码:

<div class="detail-block">
   <div class="detail-header">
       <div class="detail-title">Ad detail</div>
       <div class="detail-operation">
           <d-button icon="edit" class="flat" (click)="edit()">Edit</d-button>
           <d-button icon="delete" class="flat" (click)="delete()">Delete</d-button>
           <d-button icon="eye" class="flat" (click)="preview">Preview</d-button>
       </div>
   </div>
   <d-list [data]="adDetail" [config]="detailConfig"></d-list>
</div>

TableBlock对应的代码:

<div class="table-block">
   <div class="table-operation-bar">
       <d-checkbox (change)="changeDelivery()">Has delivery</Checkbox>
       <d-select class="select-table-column" [defaultValue]="1"
           (change)="select()">
           <d-select-option value="1">Performance</d-select-option>
           <d-select-option value="2">Customize</d-select-option>
       </Select>
       <d-button icon="export" (click)="exportData()">Export Data</d-button>
   </div>
   <d-table [dataSource]="adsList" [columns]="columns"></d-table>
</div>

由于篇幅原因,这个案例并没有包含交互的部分,不过基本能够阐述清楚积木理论的核心思想。

作者:DevUI团队
来源:https://juejin.cn/post/7047503485054484516

收起阅读 »

深入理解 redux 数据流和异步过程管理

前端框架的数据流前端框架实现了数据驱动视图变化的功能,我们用 template 或者 jsx 描述好了数据和视图的绑定关系,然后就只需要关心数据的管理了。数据在组件和组件之间、组件和全局 store 之间传递,叫做前端框架的数据流。一般来说,除了某部分状态数据...
继续阅读 »


前端框架的数据流

前端框架实现了数据驱动视图变化的功能,我们用 template 或者 jsx 描述好了数据和视图的绑定关系,然后就只需要关心数据的管理了。

数据在组件和组件之间、组件和全局 store 之间传递,叫做前端框架的数据流。

一般来说,除了某部分状态数据是只有某个组件关心的,我们会把状态数据放在组件内以外,业务数据、多个组件关心的状态数据都会放在 store 里面。组件从 store 中取数据,当交互的时候去通知 store 改变对应的数据。

这个 store 不一定是 redux、mobox 这些第三方库,其实 react 内置的 context 也可以作为 store。但是 context 做为 store 有一个问题,任何组件都能从 context 中取出数据来修改,那么当排查问题的时候就特别困难,因为并不知道是哪个组件把数据改坏的,也就是数据流不清晰。

正是因为这个原因,我们几乎见不到用 context 作为 store,基本都是搭配一个 redux。

所以为什么 redux 好呢?第一个原因就是数据流清晰,改变数据有统一的入口。

组件里都是通过 dispatch 一个 action 来触发 store 的修改,而且修改的逻辑都是在 reducer 里面,组件再监听 store 的数据变化,从中取出最新的数据。

这样数据流动是单向的,清晰的,很容易管理。

这就像为什么我们在公司里想要什么权限都要走审批流,而不是直接找某人,一样的道理。集中管理流程比较清晰,而且还可以追溯。

异步过程的管理

很多情况下改变 store 数据都是一个异步的过程,比如等待网络请求返回数据、定时改变数据、等待某个事件来改变数据等,那这些异步过程的代码放在哪里呢?

组件?

放在组件里是可以,但是异步过程怎么跨组件复用?多个异步过程之间怎么做串行、并行等控制?

所以当异步过程比较多,而且异步过程与异步过程之间也不独立,有串行、并行、甚至更复杂的关系的时候,直接把异步逻辑放组件内不行。

不放组件内,那放哪呢?

redux 提供的中间件机制是不是可以用来放这些异步过程呢?

redux 中间件

先看下什么是 redux 中间件:

redux 的流程很简单,就是 dispatch 一个 action 到 store, reducer 来处理 action。那么如果想在到达 store 之前多做一些处理呢?在哪里加?

改造 dispatch!中间件的原理就是层层包装 dispatch。

下面是 applyMiddleware 的源码,可以看到 applyMiddleware 就是对 store.dispatch 做了层层包装,最后返回修改了 dispatch 之后的 store。

function applyMiddleware(middlewares) {
let dispatch = store.dispatch
middlewares.forEach(middleware =>
dispatch = middleware(store)(dispatch)
)
return { ...store, dispatch}
}

所以说中间件最终返回的函数就是处理 action 的 dispatch:

function middlewareXxx(store) {
return function (next) {
return function (action) {
// xx
};
};
};
}

中间件会包装 dispatch,而 dispatch 就是把 action 传给 store 的,所以中间件自然可以拿到 action、拿到 store,还有被包装的 dispatch,也就是 next。

比如 redux-thunk 中间件的实现:

function createThunkMiddleware(extraArgument) {
return ({ dispatch, getState }) => next => action => {
if (typeof action === 'function') {
return action(dispatch, getState, extraArgument);
}

return next(action);
};
}

const thunk = createThunkMiddleware();

它判断了如果 action 是一个函数,就执行该函数,并且把 store.dispath 和 store.getState 传进去,否则传给内层的 dispatch。

通过 redux-thunk 中间件,我们可以把异步过程通过函数的形式放在 dispatch 的参数里:

const login = (userName) => (dispatch) => {
dispatch({ type: 'loginStart' })
request.post('/api/login', { data: userName }, () => {
dispatch({ type: 'loginSuccess', payload: userName })
})
}
store.dispatch(login('guang'))

但是这样解决了组件里的异步过程不好复用、多个异步过程之间不好做并行、串行等控制的问题了么?

没有,这段逻辑依然是在组件里写,只不过移到了 dispatch 里,也没有提供多个异步过程的管理机制。

解决这个问题,需要用 redux-saga 或 redux-observable 中间件。

redux-saga

redux-saga 并没有改变 action,它会把 action 透传给 store,只是多加了一条异步过程的处理。

redux-saga 中间件是这样启用的:

import { createStore, applyMiddleware } from 'redux'
import createSagaMiddleware from 'redux-saga'
import rootReducer from './reducer'
import rootSaga from './sagas'

const sagaMiddleware = createSagaMiddleware()
const store = createStore(rootReducer, {}, applyMiddleware(sagaMiddleware))
sagaMiddleware.run(rootSaga)

要调用 run 把 saga 的 watcher saga 跑起来:

watcher saga 里面监听了一些 action,然后调用 worker saga 来处理:

import { all, takeLatest } from 'redux-saga/effects'

function* rootSaga() {
yield all([
takeLatest('login', login),
takeLatest('logout', logout)
])
}
export default rootSaga

redux-saga 会先把 action 透传给 store,然后判断下该 action 是否是被 taker 监听的:

function sagaMiddleware({ getState, dispatch }) {
return function (next) {
return function (action) {
const result = next(action);// 把 action 透传给 store

channel.put(action); //触发 saga 的 action 监听流程

return result;
}
}
}

当发现该 action 是被监听的,那么就执行相应的 taker,调用 worker saga 来处理:

function* login(action) {
try {
const loginInfo = yield call(loginService, action.account)
yield put({ type: 'loginSuccess', loginInfo })
} catch (error) {
yield put({ type: 'loginError', error })
}
}

function* logout() {
yield put({ type: 'logoutSuccess'})
}

比如 login 和 logout 会有不同的 worker saga。

login 会请求 login 接口,然后触发 loginSuccess 或者 loginError 的 action。

logout 会触发 logoutSuccess 的 action。

redux saga 的异步过程管理就是这样的:先把 action 透传给 store,然后判断 action 是否是被 taker 监听的,如果是,则调用对应的 worker saga 进行处理。

redux saga 在 redux 的 action 流程之外,加了一条监听 action 的异步处理的流程。

其实整个流程还是比较容易理解的。理解成本高一点的就是 generator 的写法了:

比如下面这段代码:

function* xxxSaga() {
while(true) {
yield take('xxx_action');
//...
}
}

它就是对每一个监听到的 xxx_action 做同样的处理的意思,相当于 takeEvery:

function* xxxSaga() {
yield takeEvery('xxx_action');
//...
}

但是因为有一个 while(true),很多同学就不理解了,这不是死循环了么?

不是的。generator 执行后返回的是一个 iterator,需要另外一个程序调用 next 方法才会继续执行。所以怎么执行、是否继续执行都是由另一个程序控制的。

在 redux-saga 里面,控制 worker saga 执行的程序叫做 task。worker saga 只是告诉了 task 应该做什么处理,通过 call、fork、put 这些命令(这些命令叫做 effect)。

然后 task 会调用不同的实现函数来执行该 worker saga。

为什么要这样设计呢?直接执行不就行了,为啥要拆成 worker saga 和 task 两部分,这样理解成本不就高了么?

确实,设计成 generator 的形式会增加理解成本,但是换来的是可测试性。因为各种副作用,比如网络请求、dispatch action 到 store 等等,都变成了 call、put 等 effect,由 task 部分控制执行。那么具体怎么执行的就可以随意的切换了,这样测试的时候只需要模拟传入对应的数据,就可以测试 worker saga 了。

redux saga 设计成 generator 的形式是一种学习成本和可测试性的权衡。

还记得 redux-thunk 有啥问题么?多个异步过程之间的并行、串行的复杂关系没法处理。那 redux-saga 是怎么解决的呢?

redux-saga 提供了 all、race、takeEvery、takeLatest 等 effect 来指定多个异步过程的关系:

比如 takeEvery 会对多个 action 的每一个做同样的处理,takeLatest 会对多个 action 的最后一个做处理,race 会只返回最快的那个异步过程的结果,等等。

这些控制多个异步过程之间关系的 effect 正是 redux-thunk 所没有的,也是复杂异步过程的管理必不可少的部分。

所以 redux-saga 可以做复杂异步过程的管理,而且具有很好的可测试性。

其实异步过程的管理,最出名的是 rxjs,而 redux-observable 就是基于 rxjs 实现的,它也是一种复杂异步过程管理的方案。

redux-observable

redux-observable 用起来和 redux-saga 特别像,比如启用插件的部分:

const epicMiddleware = createEpicMiddleware();

const store = createStore(
rootReducer,
applyMiddleware(epicMiddleware)
);

epicMiddleware.run(rootEpic);

和 redux saga 的启动流程是一样的,只是不叫 saga 而叫 epic。

但是对异步过程的处理,redux saga 是自己提供了一些 effect,而 redux-observable 是利用了 rxjs 的 operator:

import { ajax } from 'rxjs/ajax';

const fetchUserEpic = (action$, state$) => action$.pipe(
ofType('FETCH_USER'),
mergeMap(({ payload }) => ajax.getJSON(`/api/users/${payload}`).pipe(
map(response => ({
type: 'FETCH_USER_FULFILLED',
payload: response
}))
)
);

通过 ofType 来指定监听的 action,处理结束返回 action 传递给 store。

相比 redux-saga 来说,redux-observable 支持的异步过程的处理更丰富,直接对接了 operator 的生态,是开放的,而 redux-saga 则只是提供了内置的几个 effect 来处理。

所以做特别复杂的异步流程处理的时候,redux-observable 能够利用 rxjs 的操作符的优势会更明显。

但是 redux-saga 的优点还有基于 generator 的良好的可测试性,而且大多数场景下,redux-saga 提供的异步过程的处理能力就足够了,所以相对来说,redux-saga 用的更多一些。

总结

前端框架实现了数据到视图的绑定,我们只需要关心数据流就可以了。

相比 context 的混乱的数据流,redux 的 view -> action -> store -> view 的单向数据流更清晰且容易管理。

前端代码中有很多异步过程,这些异步过程之间可能有串行、并行甚至更复杂的关系,放在组件里并不好管理,可以放在 redux 的中间件里。

redux 的中间件就是对 dispatch 的层层包装,比如 redux-thunk 就是判断了下 action 是 function 就执行下,否则就是继续 dispatch。

redux-thunk 并没有提供多个异步过程管理的机制,复杂异步过程的管理还是得用 redux-saga 或者 redux-observable。

redux-saga 透传了 action 到 store,并且监听 action 执行相应的异步过程。异步过程的描述使用 generator 的形式,好处是可测试性。比如通过 take、takeEvery、takeLatest 来监听 action,然后执行 worker saga。worker saga 可以用 put、call、fork 等 effect 来描述不同的副作用,由 task 负责执行。

redux-observable 同样监听了 action 执行相应的异步过程,但是是基于 rxjs 的 operator,相比 saga 来说,异步过程的管理功能更强大。

不管是 redux-saga 通过 generator 来组织异步过程,通过内置 effect 来处理多个异步过程之间的关系,还是 redux-observable 通过 rxjs 的 operator 来组织异步过程和多个异步过程之间的关系。它们都解决了复杂异步过程的处理的问题,可以根据场景的复杂度灵活选用。

原文:https://juejin.cn/post/7011835078594527263


收起阅读 »

重构B端 ? 表单篇

随着业务的庞大。B端的业务越来越重,导致后面的需求越来越难满足,人在工位坐,锅从天上来,一个小前端就地开启了重构之旅 1. 梳理待重构的B端 上面是待重构 B端 的结构图,由 PHP 编写,利用约定的字段上传 JSON 文件,让 Controller 读取文...
继续阅读 »

随着业务的庞大。B端的业务越来越重,导致后面的需求越来越难满足,人在工位坐,锅从天上来,一个小前端就地开启了重构之旅


1. 梳理待重构的B端



上面是待重构 B端 的结构图,由 PHP 编写,利用约定的字段上传 JSON 文件,让 Controller 读取文件配置 在 DB 生成一个表,再由 Controller 直出到 View 层;在应对那些比较简单的逻辑或者功能性单一的业务时可以起到非常大的作用。但是需求只会越来越多。越来越复杂,尤其在处理复杂业务的时,整个 Controller 作为数据中枢,如果夹杂了太多的冗余逻辑,渐渐的就跟蜘蛛网一样难以维护。


2. 设计重构方案


了解完整个大概的数据走向以及业务背景之后,接下来就是设计整个重构方案了。



  • 继承之前的业务逻辑、通过 JSON 文件 渲染整个页面

  • 整体 UI 升级 ,因为用 React 重构,UI 这里选择 Antd

  • 前后端分离,本地开发先 Mock ,后期用 node 去代理或者用其他更灵活的方式交互数据


3. 配置文件改造



脚手架搭建,这里用 Antd Pro 脚手架 ( umi.js + antd ) , Router 配置 以及 React-Redux 等工具 umi 都帮我们内置了 ,基础搭建就不展开描述了。



{
"字段名称": {
"name_cfg": {
"label": "label",
"type": "select",
"tips": "tips",
"required": "required",
"max_length": 0,
"val_arr": {
"-1": "请选择",
"1": "字段 A",
"2": "字段 B",
"default": "-1"
},
"tabhide": {
"1": "A 字段, B 字段",
"2": "B 字段, C 字段",
"3": "C 字段, D 字段"
}
"relation_select": {
"url": "其他业务的接口请求",
"value": "Option id",
"name": "Option name",
"relation_field": "relation_field1, relation_field2"
}
},
"sql_cfg": {
"type": "int(11)",
"length": 11,
"primary": 0,
"default": -1,
"auto_increment": 0
}
},
...
}

这个 JSON 文件共有几十个字段,这里拿了一个比较有代表性的字段。这是一个 Select 类型的表单,val_arr 是这个 Select 的值,relation_select 请求其他业务的接口填进去这个 val_arr 供给给 Select , tabhide 表示的是,当值为 Key 时 隐藏表单的 Value 字段,多字段时以逗号分割。


此时需要一个过度文件将这个 JSON 文件处理成咋们方便处理的格式


// transfrom.js 
let Arr = [];
let Data = json.field_cfg;
for (let i in Data) {
let obj = {};
let val_Array = [];
let tab_hide = Data[i]['name_cfg']['tabhide']
for (let index in Data[i]['name_cfg']['val_arr']) {
let obj = {};
if (index !== 'default') {
obj['value'] = index;
obj['label'] = Data[i]['name_cfg']['val_arr'][index];
if(tab_hide && tab_hide[index]){
obj['tab_hide'] = tab_hide[index].split(',');
}
val_Array.push(obj);
} else {
val_Array.map((item) => {
if (item.value === Data[i]['name_cfg']['val_arr'][index]) {
item['default'] = true;
}
});
}
}
obj['id'] = i;
obj['name'] = Data[i]['name_cfg']['label'];
obj['type'] = Data[i]['name_cfg']['type'];
obj['required'] = Data[i]['name_cfg']['required'];
obj['tips'] = Data[i]['name_cfg']['tips'];
obj['multiple'] = Data[i]['name_cfg']['multiple'];
obj['val_arr'] = val_Array;
obj['sql_cfg_type'] = Data[i]['sql_cfg']['type'];
obj['sql_default'] = Data[i]['sql_cfg']['default'];
Arr.push(obj);
}

// config.js
const config = [
{
id: '字段名称',
name: 'label -> Name',
type: 'select',
required: 'required',
tips: 'tips',
val_arr: [
{ value: '-1', label: '请选择', default: true }
{ value: '1', label: 'Option A', tab_hide: ['字段A', '字段B'] }
{ value: '2', label: 'Option B', tab_hide: ['字段B', '字段C'] }
],
sql_cfg_type: 'int(11)',
sql_default: -1,
},
...
]

4. 表单渲染


Antd 的表单组件的功能非常丰富。拿到 Config 按照文档一把唆就完事了。

React 的 函数组件 和 Hooks 让代码更加简洁了,可太棒了!


  const renderForm: () => (JSX.Element | undefined)[] = () => {
// renderSelect(); renderText(); renderNumber(); renderDatePicker();
}
const renderSelect: (configItem: ConfigListData) => JSX.Element = (configItem) => {...}

const renderText: (configItem: ConfigListData) => JSX.Element = (configItem) => {...}

const renderNumber: (configItem: ConfigListData) => JSX.Element = (configItem) => {...}

const renderDatePicker: (configItem: ConfigListData) => JSX.Element = (configItem) => {...}

5. 默认字段 & 隐藏字段


componentDidMount 的时候处理这些字段,用 Hooks 可以这么表达 React.useEffect(()=>{...},[])

默认字段 : 给 Form 表单 设置上配置表的 sql_default 即可。

隐藏字段 : 这里涉及到字段会重叠,之前用 JQuery 单纯操作 DOM 节点去 show() 和 hide()。在操作 Dom 这方面,JQ 确实是有他的优势。

现在的解决方案如下图所示:


  const [hideListMap, setHideListMap] = useState<any>(new Map());
configObj.forEach((item: { val_arr: { tab_hide: any; value: any; }[]; sql_default: any; id: any; }) => {
item.val_arr.forEach((val_arr_item: { tab_hide: any; value: any; }) => {
if (val_arr_item.tab_hide && val_arr_item.value === item.sql_default) {
hideListMap.set(item.id, val_arr_item.tab_hide);
}
});
});
setHideListMap(hideListMap)

new Map() 用字段名作为 key ,value 的值为 tab_hide 数组, 之前用 Array 创建一个动态的 key 、value ,发现操作起来没有 Map 好使。


  const [hideList, setHideList] = useState<string[]>();

React.useEffect(() => {
let arr: string[] = []
hideListMap.forEach((item: any, key: any) => {
arr.push(...item);
});
setHideList(arr);
}, [hideListMap])

const selctChange = React.useCallback((value: SelectValue, configItem: ConfigListData) => {
hideListMap.forEach((item: any, key: string) => {
if (key === configItem.id) {
configItem.val_arr.forEach((val_arr_item: { value: SelectValue; tab_hide: any; }) => {
if (val_arr_item.value === value) {
hideListMap.set(configItem.id, val_arr_item.tab_hide);
setHideListMap(new Map(hideListMap))
}
})
}
});
}, [hideListMap]);

React.useEffect 不仅可以当 componentDidMount ,还可以当 componentDidUpdate 使用,只需要在第二个参数加上监听的值 React.useEffect(()=>{...},[n]), 这里监听了 hideListMap 如果 Select 框触发了 改变了 hideListMap 会自动帮我个更新 hideList,只要拿这个 hideList 作为条件判断是否渲染。就满足了多字段隐藏的需求了。


  const renderForm: () => (JSX.Element | undefined)[] = () => {
return configObj.map((item: ConfigListData) => {
if (hideList && hideList.includes(item.id)) {
return undefined
}
if (item.type === 'text') {
if (item.sql_cfg_type.indexOf('int') !== -1) {
return renderNumber(item)
} else {
return renderText(item)
}
}
if (item.type === 'select') {
return renderSelect(item)
}
if (item.type === 'datetime') {
return renderDatePicker(item);
}
return undefined;
})
}

6. Select 动态渲染值


// transfrom.js
let service = [];
let Data = json.field_cfg;
for (let i in Data) {
let relation_select = Data[i]['name_cfg']['relation_select'];
if (relation_select && relation_select['relation_field']) {
relation_select['relation_field'] = relation_select['relation_field'].split(',');
service.push(relation_select);
}
}

// config.ts
const SERVICE = [
{
url: "其他业务的接口请求",
value: "Option id",
name: "Option name",
relation_field: ["relation_field1", "relation_field2" ],
method: 'get'
}
...
]
// utils.ts
import request from 'umi-request'; // 请求库
export const getSelectApi = async (url: string, method: string) => {
return request(url, {
method,
})
}

之前接口请求与 Config 是耦合在一起的,Config 的字段如果增加到一定的数量时就会变得难以维护。最后决定把 表单Config 和 Service 层解耦,目的是为了更为直观的区别 Config 和 Service,方便以后维护。



数据接口参数格式以及字段都要有所约束。这里需要起一个 node 层,主要是处理第三方接口跨域处理,以及参数统一等。



import { CONFIG_OBJ, SERVICE } from './config';
const Form: React.FC = () => {
const [configObj, setConfigObj] = React.useState(CONFIG_OBJ);
React.useEffect(() => {
(async () => {
for (let item of SERVICE) {
for (let fieldItem of item.relation_field) { // 多字段支持
insertObj[fieldItem] = await getSelectApi(item.url, item.method)
}
}
for (let key in insertObj) {
if (key === item.id) {
item.val_arr.push(...insertObj[key].list)
}
}
setConfigObj([...configObj]);
})()
})
...
}
export default Form

将解耦出来的接口配置融合在 form 表单。useState 检测到数据指向变动就会重新渲染。

到这里 Form 表单组件基本搭建完成了。


6. 最后


再进一步思考,这里还有些优化的点,我们可以把这些配置文件也融进去这个B端里,将 Config 和 Service 都写进 DB,脱离用文件上传这种方式。


B端产品的服务群体是企业内人员。B端的用户体验也一样重要,一个优秀的B端也是提高效率、节省成本的途径之一。



本文到此结束,希望对各位看官有帮助 (‾◡◝)


作者:Coffee_C
链接:https://juejin.cn/post/6922286595290693646

收起阅读 »

聊下与 hash 有关的加密

聊下与 hash 有关的加密hash 的简单概述奥.自己百度去吧 百度比我说的详细.咱们在这介绍一下 hash 的特点注意看啊 重点来了1.算法是公开的(怎么算的不重要,腻友不是数学家)2.对相同数据运算,得到的结果是一样的3.对不同数据运算,如MD5得到的结...
继续阅读 »

聊下与 hash 有关的加密

hash 的简单概述

奥.自己百度去吧 百度比我说的详细.

咱们在这介绍一下 hash 的特点

注意看啊 重点来了

1.算法是公开的(怎么算的不重要,腻友不是数学家)

2.对相同数据运算,得到的结果是一样的

3.对不同数据运算,如MD5得到的结果默认是128位,32个字符(16进制标识)

4.逆运算的可能约等于0

5.可称之为信息指纹,是用来做数据识别

hash (MD5)加密的用途

1.用户密码的加密

比如客户端用户密码设置为 123456  ,通过 MD5加密的结果是e10adc3949ba59abbe56e057f20f883e

然后把这个e10adc3949ba59abbe56e057f20f883e发送给服务器,服务器储存的就是e10adc3949ba59abbe56e057f20f883e .  这个时候就算服务器被攻击,数据库泄露,别人拿到的也是一段加密后的结果.是不是这样就安全了

觉得安全了的同学罚站半小时. 看特点 ,上述第二条, 对相同数据运算,得到的结果是一样的!

这个意思就是说.  通过穷举字符组合的方式,创建了明文密文对应查询数据库,创建的记录约90万亿条,占用硬盘超过500TB! 这些都是 MD5加密前和加密后的数据!



看吧.  一些常用或者常规通过 MD5加密已经可以通过第三方直接查询的到了

既然 MD5这么不安全,为什么这种加密还如此风靡 . 咱们接着往下看

这个时候有经验的同学应该知道怎么办了.有一种 md5的保护.叫加盐

什么是加盐呢 .举个栗子   用户的密码是123456吧. 这在程序里是个字符串吧.字符串拼接大家都会吧 比如用户密码后边都拼接一个 handsomezuyu. 哎? 试试看

123456handsomezuyu  md5 后的结果是9a9a83b884f51deb88944461075dc538

咱们在用这个结果去解密试试



这个handsomezuyu就是盐了. 当然盐也可以是coolzuyu  也可以是excellentzuyu 也可以是 goodzuyu. 都可以都可以. 

查不到了吧. 厉害了吧. 可以了吧.这招还行吧. 既简单又实用.都学会了吧.

(注意一下这个方法的缺点.参与这个项目的人都知道这个盐的值吧.那么如果有一天某位参与这个项目开发的同事因为在聚餐的时候放了个屁被开除了.额.... 是吧.那他如果想搞事... 好了.当然也有避免这种情况发生的措施,咱们这篇闲聊中先不讲.)


2.搜索引擎

嗯?这个时候可能有些小可爱就要问了.搜索引擎?哈希?

咱们就简单说一下搜索引擎与哈希的关系

比如你百度搜 "帅逼 zuyu" 和" zuyu 帅逼" 这两个关键词

出现的结果如果没有"帅逼 zuyu"直接关联的内容.那么搜索过后呈现的内容是一样的

因为搜索时引擎会拆词 ,关键字会被拆成" 帅逼 "和"zuyu"两个词 .然后得到两个词的哈希值.然后在对位相加.   无论顺序是怎么样的.对位相加后的结果都一样吧!所以就会出现了我上边说的内容一样的现象. 当然这只是一小部分关系,里边还有很多更复杂的算法和逻辑.这儿咱们就不一一标出了(呵呵,我也不知道)

3.版权/云盘

唉?版权?有点懵?版权还能和hash有关系?

咱们就先说说版权.比如腾讯视频上. 你拍了一段视频然后上传  ,紧接着上传成功 .这个时候腾讯视频就记录下了这个视频的 hash值.并且认定这个版权是你的. 其他人在上传相同的视频之后就会提示该视频已存在等类似的提示信息(如果你通过下载或者修改格式等各方便因素影响.即使视频内容完全一样, hash 值也有可能不同)

那云盘呢.云盘和 hash 有啥 关系呢.

举个栗子奥.百度云大家应该都用过对吧,而且你不仅用过,你用来干嘛我也知道.嗯,看懂这句话的都是同道中人. 好.咱们说回原题. 你们上传一些学习资料的时候有没有遇到过一种现象呢?

不是和谐啊! 不是和谐! 不是和谐!!!

秒传! 对吧  .一个挺大的文件  就秒传上去了.这就是和 hash的关系了.   百度云盘在你上传某国学习资料的时候,会先去拿到你这个文件对应的 hash 值.  如果云盘服务器就会做一个 hash 值的对比 .如果 hash 只能匹配的到,那就是服务器有相同的数据.  然后服务器只不过就是在你的账号里加了一条数据而已   .并不是真的把这个学习资料传上去.    当你下载一些学习资料的时候或者上传一些学习资料的时候  . 也遇到过提示你这个学习资料因为某种原因不可以下载了吧.  是吧.也是这个道理.

所以阿.这个时候知道原理了.那是不是就是可以避免一些不开心的事情了(压缩会改变 hash 值.修改文件名字不会改变 hash 值.翻录或者剪辑视频会改变 hash 值) 好 ,刹车.

来,回想一下, 我上边说特点. 第五条  可称之为信息指纹,是用来做数据识别. 理解了吧

至于特点的第一条. 算法是公开的 .咱们了解就好

至于特点的第三条.对不同数据运算,如MD5得到的结果默认是128位,32个字符(16进制标识)到更像是一种规则

至于特点的第四条.逆运算的可能约等于0 .简单解释一下就是说目前人们所熟知的知识范畴内 hash 就是不可逆运算



收起阅读 »

Taro的http封装

 当我们使用Taro的时候,经常会用到http请求,那么又怎么封装呢?serve.ts import { request, getStorageSync } from '@tarojs/taro' class Server { protecte...
继续阅读 »

当我们使用Taro的时候,经常会用到http请求,那么又怎么封装呢?

serve.ts


import { request, getStorageSync } from '@tarojs/taro'

class Server {
protected ajax({
url,
data,
method = 'GET',
...restParams
}: Taro.RequestParams
) {
// 用户token
const Authorization: string = getStorageSync('token') || ''
// 判断请求类型
let contentType: string
// GET请求
if (method === 'GET') {
contentType = 'application/json'
// POST 请求
} else if (method === 'POST') {
contentType = 'application/x-www-form-urlencoded'
}
return new Promise<Taro.request.SuccessCallbackResult>(
(resolve, reject) => {
request({
url,
data,
method,
header: {
'content-type': contentType,
Authorization,
},
...restParams,
// 成功回调
success(res: Taro.request.SuccessCallbackResult): void {
resolve(res)
},
// 失败回调
fail(err: Taro.General.CallbackResult): void {
reject(err)
},
})
}
)
}
}

export default Server

引用时


import Server from './serve'

let BASEURL: string
if (process.env.TARO_ENV === 'h5') {
BASEURL = '/api'
} else {
BASEURL = 'http://localhost:8000/api'
}

type Result = Taro.request.SuccessCallbackResult<any> | undefined

interface Err {
code: number
message: string
}

interface APILayout<T> {
status: number,
code: number
data: T
}
interface APIMessage<T> {
code: number
message: T
}

// 异常处理
function errMessage(error: Error | any, result: Result): Err | null {
console.log(error);

// H5
if (H5 && !error) {
return null
} else if (result?.statusCode === 200) {
return null
}
const code = error?.status || result?.statusCode
if (code === 401) {
// 清空token
}
const errInfo = {
401: '请先登录',
404: '服务器未响应',
500: '服务器繁忙',
}
return {
code,
message: errInfo[code] ? errInfo[code] : error.info,
}
}
export async function testAPI() {
let [error, result] = await to(
this.ajax({
url: BASEURL + '/test.php'
})
)
const err = errMessage(error, result)
const res: APILayout<Ip> = result?.data

return { err, res }
}

ok 直接用就行了













收起阅读 »

Kotlin invoke约定,让Kotlin代码更简洁

前言 最近看到DSL这个东西,不由的觉得里面可以利用Kotlin的一些特性能简化代码,所以具体来看看它是如何实现的。 正文 首先一上来就说原理或许对于不熟悉Kotlin的来说会感觉有点突兀,所以我准备从头梳理一下。 约定 Kotlin的约定我们在平时开发中肯定...
继续阅读 »

前言


最近看到DSL这个东西,不由的觉得里面可以利用Kotlin的一些特性能简化代码,所以具体来看看它是如何实现的。


正文


首先一上来就说原理或许对于不熟悉Kotlin的来说会感觉有点突兀,所以我准备从头梳理一下。


约定


Kotlin的约定我们在平时开发中肯定用到过,不过我们没有仔细去注意这个名词而已。约定的概念就是:使用与常规方法调用语法不同的、更简洁的符号,调用着有着特殊命名的函数。


这里提取2个关键点,一个是更简洁的符号调用,一个是特殊命名的函数。说白了就是让函数调用更加简洁。


比如我们最熟悉的集和调用 [index] 来 替代 get(index),我们自己也来定义个类,来实现一下这个约定:


data class TestBean(val name: String,val age: Int){
//定义非常简单 使用operator重载运算符get方法
operator fun get(index : Int): Any{
return when(index) {
0 -> name
1 -> age
else -> name
}
}

}

然后我们在使用时:


//这里就可以使用 [] 来替换 get来简化调用方法了
val testBean = TestBean("zyh",20)
testBean.get(0)
testBean[0]

invoke约定


和上面的get约定一样,[] 就是调用 get 方法的更简洁的方式,这里有个invoke约定,它的作用就是让对象像函数一样调用方法,下面直接来个例子:



data class TestBean(val name: String,val age: Int){

//重载定义invoke方法
operator fun invoke() : String{
return "$name - $age"
}

}

定义完上面代码后,我们来进行使用:


val testBean = TestBean("zyh",20)
//正常调用
testBean.invoke()
//约定后的简化调用
testBean()

这里会发现testBean对象可以调用invoke方法是正常调用,但是也可以testBean()直接来调用invoke方法,这就是invoke约定的作用,让调用invoke方法更简单。


invoke约定和函数式类型


既然了解了invoke约定,我们来和lambda结合起来。


对于lambda有点疑惑的可以查看文章:


# Kotlin lambda,有你想了解的一切


我们知道函数类型其实就是实现了FunctionN接口的类,然后当函数类型是函数类型时,这时传递给它一个lambda,lambda就会被编译成FunctionN的匿名内部类(当然是非内联的),然后调用lambda就变成了一次FunctionN接口的invoke调用。


还是看个例子代码:


//定义代码
class TestInvoke {
//高阶函数类型变量
private var mSingleListener: ((Int) -> Unit)? = null
//设置变量
public fun setSingleListener(listener:((Int) -> Unit)?){
this.mSingleListener = listener
}
//
fun testRun() {
//调用invoke函数
mSingleListener?.invoke(100)
//使用invoke约定,省去invoke
if (mSingleListener != null){
mSingleListener!!(100)
}
}

}

定义完上面回调变量后,我们来使用这个回调,由于我们知道高阶函数其实是实现了FunctionN接口的类,也就是实现了:


//注意,这里接口的方法就是invoke
public interface Function1<in P1, out R> : Function<R> {
/** Invokes the function with the specified argument. */
public operator fun invoke(p1: P1): R
}

那我也就可以直接使用下面代码来传递参数:


val function1 = object: Function1<Int,Unit> {
override fun invoke(p1: Int) {
Logger.d("$p1")
}
}
testInvoke.setSingleListener(function1)

这里看起来合情合理,因为在testRun函数中我们调用了invoke函数,把100当做参数,然后这个100会被回调到function1中,但是我们传递lambda时呢:


val testInvoke  = TestInvoke()
testInvoke.setSingleListener { returnInt ->
Logger.d("$returnInt")
}

上面代码传递lambda和传递一个类的实例效果是一样的,只不过这里只是一段代码块,没有显示的调用invoke啥的,所以这就是一个特性,当lambda被用作参数被函数调用时,也就可以看成是一次invoke的自动调用


invoke在DSL中的实践:Gradle依赖


这里我们为什么要说这个invoke依赖呢,很大的原因就是它在一些DSL中有很好的用法,这里我们就来看个Gradle依赖的使用。


我们很常见下面代码:


dependencies {

implementation 'androidx.core:core-ktx:1.6.0'
implementation 'androidx.appcompat:appcompat:1.3.1'
//...
}

这里我们都很习以为常,感觉这里很像配置项,而不像是代码,其实这个也是一段代码,只不过是这种风格。那这种风格如何实现呢,我们来简单实现一下:


class DependencyHandler{
//编译库
fun compile(libString: String){
Logger.d("add $libString")
}
//定义invoke方法
operator fun invoke(body: DependencyHandler.() -> Unit){
body()
}
}

上面代码写完后,我们便可以有下面3种调用方式:


val dependency = DependencyHandler()
//调用invoke
dependency.invoke {
compile("androidx.core:core-ktx:1.6.0")
}
//直接调用
dependency.compile("androidx.core:core-ktx:1.6.0")
//带接受者lambda方式
dependency{
compile("androidx.core:core-ktx:1.6.0")
}

由此可见,上面代码第三种方式便是我们在Gradle配置文件中常见的一种,这里其实就2个关键点,一个是定义invoke函数,一个是定义带接受者的lambda,调用时省去this即可。


总结


其实关于invoke约定和带接受者lambda的写法现在越来越流行了,比如之前的anko库,现在的compose库都是这种声明式的写法,看完原理后,就会发现其实还是很方便的。


后续开始研究compose的时候,再来补充一波。


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

android充电架构的分析

前言 目前android设备越来越多,对于快速充电和长时间待机的需求就不言而喻。对应于此的就是各大手机厂商不断突破大功率充电新闻频繁的出现。在个人目前遇到的快充方案中,基本上在大的架构下属于同一种类型。故分析记录下来。 基本原理 充电简单粗暴点来说就是把电流灌...
继续阅读 »

前言


目前android设备越来越多,对于快速充电和长时间待机的需求就不言而喻。对应于此的就是各大手机厂商不断突破大功率充电新闻频繁的出现。在个人目前遇到的快充方案中,基本上在大的架构下属于同一种类型。故分析记录下来。


基本原理


充电简单粗暴点来说就是把电流灌到电池里面去。那么最简单的方法就是直接拿一个电源接在电池的正负极。只要电源电压高于电池电压就可以把电流灌进去。就如同直接打开水龙头开关接水一样。


但是这样会存在很多问题。例如:电池此时的电压很小,电源电压很高,一怼上电池上的电流就会变得非常大,很可能烧坏电池。所以需要根据电池的电压来调节输入电源的电压。这样又会出现充一会后就调一下电压,太麻烦了。因此可以使用计算机来完成这些动作,通过一颗锂电池充放电芯片来管理充放电的过程。


使用锂电池充电芯片,整个充电过程大致分成了三个阶段:分别是预充、恒流、恒压。

image

上图是各个阶段电池电流,电压的状态。

但是该方案存在转化效率较低的问题,特别是在大功率的情况下,损耗太大。因此在手机里不常采用这个方案,不过无论怎样,基本的充电曲线还是和上图保持一致的。


基本硬件架构


现在手机充电的基本架构如下图所示

image


电源输入


首先是电源的输入。目前手机上输入电源普遍支持有线和无线两种方式。在该架构下并不是简单让电源输入一个固定电压完成充电。而在由ap在不同阶段调节输入电源的功率。因此电源的提供需要支持调压的过程。现在在无线中常使用qi协议,而有线中常使用pd协议。


充电模块


充电模块现在主要由main charger(充电芯片)和charger pump(电荷泵芯片)构成,因为charger pump在大电流的情况下效率很高。整个充电过程依旧和上面的充电曲线基本一致。只不过此时的cc阶段和前半段的cv阶段由charger pump来完成,其余的由main charger来完成


main charger芯片在自身集成了数字逻辑,所以可以自动的切换各个阶段。而charger pump则不是这样,它只是一个模拟器件,我们可以把它理解成一个开关(经过它以后电流升一倍,电压降一半。譬如输入10v,5a输出就是5v,10a


故在使用过程中通过ap的程序来模拟充电阶段。比如我们规定在cc阶段下的电流是6a,而此时ap采集到的电流是4a。那我们就通过协议去增大电源的输入;采集到的电流大于6a则降低电源的输入。


电量计模块


电量计模块主要是获取电池的电量信息。然后上层可以获得电池剩余电量百分比等。


软件架构


android手机内核采用的是linux内核,android有很多层。单单对于充电功能来说可以把它分为2层,一层是kernel里的驱动和逻辑实现,另一层是上层。android界面上显示和充电相关的信息就需要从kernel这一层拿。


在内核中一切皆文件,那对于充电这部分来说也不例外。上层获取的信息都是从/sys/class/power supply路径获取的。这个路径是由代码决定的(源码路径:/system/core/healthd/BatteryMonitor.cpp)

image

在充电信息发生改变的时候kernel就会调用uevent,上层就会接收到,然后执行下面的函数更新状态。


bool BatteryMonitor::update(void) {
bool logthis;

initBatteryProperties(&props);

if (!mHealthdConfig->batteryPresentPath.isEmpty())
props.batteryPresent = getBooleanField(mHealthdConfig->batteryPresentPath);
else
props.batteryPresent = mBatteryDevicePresent;

props.batteryLevel = mBatteryFixedCapacity ?
mBatteryFixedCapacity :
getIntField(mHealthdConfig->batteryCapacityPath);
props.batteryVoltage = getIntField(mHealthdConfig->batteryVoltagePath) / 1000;

if (!mHealthdConfig->batteryCurrentNowPath.isEmpty())
props.batteryCurrent = getIntField(mHealthdConfig->batteryCurrentNowPath) / 1000;

if (!mHealthdConfig->batteryFullChargePath.isEmpty())
props.batteryFullCharge = getIntField(mHealthdConfig->batteryFullChargePath);

if (!mHealthdConfig->batteryCycleCountPath.isEmpty())
props.batteryCycleCount = getIntField(mHealthdConfig->batteryCycleCountPath);

if (!mHealthdConfig->batteryChargeCounterPath.isEmpty())
props.batteryChargeCounter = getIntField(mHealthdConfig->batteryChargeCounterPath);

props.batteryTemperature = mBatteryFixedTemperature ?
mBatteryFixedTemperature :
getIntField(mHealthdConfig->batteryTemperaturePath);

std::string buf;

if (readFromFile(mHealthdConfig->batteryStatusPath, &buf) > 0)
props.batteryStatus = getBatteryStatus(buf.c_str());

if (readFromFile(mHealthdConfig->batteryHealthPath, &buf) > 0)
props.batteryHealth = getBatteryHealth(buf.c_str());

if (readFromFile(mHealthdConfig->batteryTechnologyPath, &buf) > 0)
props.batteryTechnology = String8(buf.c_str());

unsigned int i;
double MaxPower = 0;

for (i = 0; i < mChargerNames.size(); i++) {
String8 path;
path.appendFormat("%s/%s/online", POWER_SUPPLY_SYSFS_PATH,
mChargerNames[i].string());
if (getIntField(path)) {
path.clear();
path.appendFormat("%s/%s/type", POWER_SUPPLY_SYSFS_PATH,
mChargerNames[i].string());
switch(readPowerSupplyType(path)) {
case ANDROID_POWER_SUPPLY_TYPE_AC:
props.chargerAcOnline = true;
break;
case ANDROID_POWER_SUPPLY_TYPE_USB:
props.chargerUsbOnline = true;
break;
case ANDROID_POWER_SUPPLY_TYPE_WIRELESS:
props.chargerWirelessOnline = true;
break;
default:
KLOG_WARNING(LOG_TAG, "%s: Unknown power supply type\n",
mChargerNames[i].string());
}
path.clear();
path.appendFormat("%s/%s/current_max", POWER_SUPPLY_SYSFS_PATH,
mChargerNames[i].string());
int ChargingCurrent =
(access(path.string(), R_OK) == 0) ? getIntField(path) : 0;

path.clear();
path.appendFormat("%s/%s/voltage_max", POWER_SUPPLY_SYSFS_PATH,
mChargerNames[i].string());

int ChargingVoltage =
(access(path.string(), R_OK) == 0) ? getIntField(path) :
DEFAULT_VBUS_VOLTAGE;

double power = ((double)ChargingCurrent / MILLION) *
((double)ChargingVoltage / MILLION);
if (MaxPower < power) {
props.maxChargingCurrent = ChargingCurrent;
props.maxChargingVoltage = ChargingVoltage;
MaxPower = power;
}
}
}

logthis = !healthd_board_battery_update(&props);

if (logthis) {
char dmesgline[256];
size_t len;
if (props.batteryPresent) {
snprintf(dmesgline, sizeof(dmesgline),
"battery l=%d v=%d t=%s%d.%d h=%d st=%d",
props.batteryLevel, props.batteryVoltage,
props.batteryTemperature < 0 ? "-" : "",
abs(props.batteryTemperature / 10),
abs(props.batteryTemperature % 10), props.batteryHealth,
props.batteryStatus);

len = strlen(dmesgline);
if (!mHealthdConfig->batteryCurrentNowPath.isEmpty()) {
len += snprintf(dmesgline + len, sizeof(dmesgline) - len,
" c=%d", props.batteryCurrent);
}

if (!mHealthdConfig->batteryFullChargePath.isEmpty()) {
len += snprintf(dmesgline + len, sizeof(dmesgline) - len,
" fc=%d", props.batteryFullCharge);
}

if (!mHealthdConfig->batteryCycleCountPath.isEmpty()) {
len += snprintf(dmesgline + len, sizeof(dmesgline) - len,
" cc=%d", props.batteryCycleCount);
}
} else {
len = snprintf(dmesgline, sizeof(dmesgline),
"battery none");
}

snprintf(dmesgline + len, sizeof(dmesgline) - len, " chg=%s%s%s",
props.chargerAcOnline ? "a" : "",
props.chargerUsbOnline ? "u" : "",
props.chargerWirelessOnline ? "w" : "");

KLOG_WARNING(LOG_TAG, "%s\n", dmesgline);
}

healthd_mode_ops->battery_update(&props);
return props.chargerAcOnline | props.chargerUsbOnline |
props.chargerWirelessOnline;
}


很显然可以从code看出,通过该路径下文件夹里的type文件的值,来获得不同的信息。
例如
image

此时我进入到test_usb节点下,type为usb。然后online节点则表示usb连接是否在线,此时为1,则表示usb连接,充电图标会亮起来。

image

然后通过命令echo 0 > online ,强制将该值写为0。充电图标消失。

image


故在手机端充电相关的开发基本围绕着power supply架构展开的。下面依次从kernel到上层应用进行分析。



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

手把手带你配置MySQL主备环境

为了保障生产数据的安全性,我们往往需要对数据库做备份,而通过MySQL主备配置,则是一种MySQL数据库备份的好的实现方案。本文将一步步带你搭建MySQL主备环境。MySQL主备搭建现有两台虚拟机,192.168.56.11(主)和192.168.56.12(...
继续阅读 »



为了保障生产数据的安全性,我们往往需要对数据库做备份,而通过MySQL主备配置,则是一种MySQL数据库备份的好的实现方案。本文将一步步带你搭建MySQL主备环境。

MySQL主备搭建

现有两台虚拟机,192.168.56.11(主)和192.168.56.12(备)

1 MySQL安装

离线安装解压版MySQL

1 上传压缩包
  • 以root用户上传mysql-5.7.29-linux-glibc2.12-x86_64.tar.gz到/root/Downloads/下

2 解压
  • cd /root/Downloads

  • tar -zxvf mysql-5.7.29-linux-glibc2.12-x86_64.tar.gz

  • mv mysql-5.7.29-linux-glibc2.12-x86_64 mysql5.7

  • mkdir /usr/database

  • mv mysql5.7 /usr/database

3 创建系统用户组和用户
  • groupadd mysql

  • useradd -r -g mysql mysql

  • id mysql

4 创建mysql data目录
  • cd /usr/database/mysql5.7

  • mkdir data

4 设置data目录权限
  • chown -R mysql:mysql /usr/database/mysql5.7/

  • ll /usr/database

5 修改my.cnf文件

删除并重新创建/etc/my.cnf:

rm -rf /etc/my.cnf
vim /etc/my.cnf
[client]
port = 3306
socket = /tmp/mysql.sock

[mysqld]
init-connect='SET NAMES utf8'
basedir=/usr/database/mysql5.7     #根据自己的安装目录填写
datadir=/usr/database/mysql5.7/data #根据自己的mysql数据目录填写
socket=/tmp/mysql.sock
max_connections=200                 # 允许最大连接数
character-set-server=utf8           # 服务端使用的字符集默认为8比特编码的latin1字符集
default-storage-engine=INNODB       # 创建新表时将使用的默认存储引擎

最大连接数

  • max_connections<=16384

  • 管理员(SUPER)登录的连接,不计其中

  • mysql会为每个连接提供连接缓冲区,连接越多内存开销越大

  • 查询:show variables like '%_connections';show status like '%_connections';

  • max_used_connections / max_connections * 100% (理想值≈ 85%)

  • 数值过小会经常出现ERROR 1040: Too many connections错误

  • 修改方法:

    • 永久:在配置文件my.cnf中设置max_connections的值

    • 临时:以root登录mysql:set GLOBAL max_connections=xxx;->flush privileges;

mysql存储引擎

  • InnoDB:5.5及之后版本的默认引擎

    • 支持事务

    • 聚集索引,文件存放在主键索引的叶子节点上,必须要有主键。通过主键索引效率很高,但是辅助索引需要两次查询,先查询到主键,然后再通过主键查询到数据。因此,主键不应该过大,因为主键太大,其他索引也都会很大。

    • 不支持全文类型索引

    • 支持外键

    • 不保存表的具体行数,执行 select count(*) from table 时需要全表扫描。

    • 最小锁粒度是行锁

    • 适用场景:支持事物、较多写操作、系统崩溃后相对易恢复

  • MyISAM:5.5版本之前的默认引擎

    • 不支持事务

    • 非聚集索引,数据文件是分离的,索引保存的是数据文件的指针,主键索引和辅助索引是独立的。

    • 保存了整个表的行数,执行select count(*) 时只需要读出该变量即可,速度很快;

    • 支持全文类型索引

    • 不支持外键

    • 最小锁粒度是表锁,一个更新语句会锁住整张表,导致其他查询和更新都被阻塞,因此并发访问受限

    • 表以文件形式保存,跨平台使用较方便

    • 适用场景:非事物型、读操作、小型应用

6 mysql初始化
  • /usr/database/mysql5.7/bin/mysqld --initialize-insecure --user=mysql --basedir=/usr/database/mysql5.7 --datadir=/usr/database/mysql5.7/data

#注意:mysqld --initialize-insecure初始化后的mysql是没有密码的

  • chown -R root:root /usr/database/mysql5.7/ #把安装目录的目录的权限所有者改为root

  • chown -R mysql:mysql /usr/database/mysql5.7/data/ #把data目录的权限所有者改为mysql

7 启动mysql
/usr/database/mysql5.7/bin/mysqld_safe --user=mysql &
8 修改root密码
  • cd /usr/database/mysql5.7/bin

  • ./mysql -u root -p # 默认没有密码,直接回车就行

  • use mysql;

  • update user set authentication_string=password('``rootpasswd``') where user='root';

  • flush privileges;

  • exit;

9 登录测试
  • /usr/database/mysql5.7/bin/mysql mysql -u root -p

  • (输入密码)

  • show databases;

  • exit;

10 启动设置
  • cp /usr/database/mysql5.7/support-files/mysql.server /etc/init.d/mysql

  • chkconfig --add mysql # 添加服务

  • chkconfig --list # 查看服务列表

  • chkconfig --level 345 mysql on # 设置开机启动

11 测试服务命令是否可用
  • systemctl status mysql

  • systemctl start mysql

  • systemctl stop mysql

12 设置远程访问
  • 登录数据库:mysql -uroot -p[password]

  • use mysql;

  • select host,user from user;

  • update user set host='%' where user='root';

  • flush privileges;

  • 如果还是无法访问,检查防火墙

13 创建新用户
  • 创建用户app:create user 'app'@'%' identified by 'password';

  • 用户赋权(具有数据库appdb的所有权限,并可远程不限ip访问):grant all on appdb.* to 'app'@'%';

  • flush privilegesl;

14 其他问题
  • -bash: mysql: command not found

    • 临时方案,重新登录后失效:alias mysql=/usr/database/mysql5.7/bin/mysql

    • 永久方案,将命令路径添加到PATH中:

      • vim /etc/profile

      • PATH="$PATH:/usr/database/mysql5.7/bin"

      • source /etc/profile

2 主备配置

1 生产环境为什么需要MySQL集群
  • 高可用性,在主节点失效时自动切换,不需要技术人员紧急处理

  • 高吞吐,可以多个节点同时提供读取数据服务,降低主节点负载,实现高吞吐

  • 可扩展性强,支持在线扩容

  • 无影响备份,在备节点进行备份操作不会对业务产生影响

MySQL集群的缺点:

  • 架构复杂,在部署、管理方面对技术人员要求高

  • 备节点拉取主节点日志时会对主节点服务器性能有一定影响

  • 如果配置了半同步复制,会对事物提交有一定影响

2 修改my.cnf
  • 主备服务器均创建日志文件路径:mkdir /data/mysql_log

  • 修改master下的/etc/my.cnf:

[client]
port = 3306
default-character-set=utf8mb4
socket = /usr/database/mysql5.7/mysql.sock

[mysqld]
basedir = /usr/database/mysql5.7
datadir = /usr/database/mysql5.7/data
tmpdir = /tmp
socket = /tmp/mysql.sock
# pid-file = /data/mysql_db/mysql_seg_3306/mysql.pid
skip-external-locking = 1
skip-name-resolve = 1
port = 3306
server-id = 113306

default-storage-engine = InnoDB
character-set-server = utf8mb4
default_password_lifetime=0

#### log ####
log_timestamps=system
log_bin = /data/mysql_log/mysql-bin
log_bin_index = /data/mysql_log/mysql-bin.index
binlog_format = row
relay_log_recovery=ON
relay_log=/data/mysql_log/mysql-relay-bin
relay_log_index=/data/mysql_log/mysql-relay-bin.index
log_error = /data/mysql_log/mysql-error.log

#### replication ####
replicate_wild_ignore_table = information_schema.%,performance_schema.%,sys.%

#### semi sync replication settings #####
plugin_dir=/usr/database/mysql5.7/lib/plugin
plugin_load = "rpl_semi_sync_master=semisync_master.so;rpl_semi_sync_slave=semisync_slave.so"
loose_rpl_semi_sync_master_enabled = 1
loose_rpl_semi_sync_slave_enabled = 1
loose_rpl_semi_sync_master_timeout = 5000
  • 修改slave下的/etc/my.cnf:

[client]
port = 3306
default-character-set=utf8mb4
socket = /usr/database/mysql5.7/mysql.sock

[mysqld]
basedir = /usr/database/mysql5.7
datadir = /usr/database/mysql5.7/data
tmpdir = /tmp
socket = /tmp/mysql.sock
# pid-file = /data/mysql_db/mysql_seg_3306/mysql.pid
skip-external-locking = 1
skip-name-resolve = 1
port = 3306
server-id = 123306
read-only=1

default-storage-engine = InnoDB
character-set-server = utf8mb4
default_password_lifetime=0

#### log ####
log_timestamps=system
log_bin = /data/mysql_log/mysql-bin
log_bin_index = /data/mysql_log/mysql-bin.index
binlog_format = row
relay_log_recovery=ON
relay_log=/data/mysql_log/mysql-relay-bin
relay_log_index=/data/mysql_log/mysql-relay-bin.index
log_error = /data/mysql_log/mysql-error.log

#### replication ####
replicate_wild_ignore_table = information_schema.%,performance_schema.%,sys.%

#### semi sync replication settings #####
plugin_dir=/usr/database/mysql5.7/lib/plugin
plugin_load = "rpl_semi_sync_master=semisync_master.so;rpl_semi_sync_slave=semisync_slave.so"
loose_rpl_semi_sync_master_enabled = 1
loose_rpl_semi_sync_slave_enabled = 1
loose_rpl_semi_sync_master_timeout = 5000
  • utf8:最大unicode字符是0xffff,仅支持Unicode 中的基本多文种平面(BMP),任何不在基本多文本平面的 Unicode字符,都无法使用 Mysql 的 utf8 字符集存储。

  • utf8mb4(most bytes 4)专门用来兼容四字节的unicode,是utf8的超集,包含很多不常用的汉字、Emoji表情,以及任何新增的 Unicode 字符等等

3 master上创建同步用户
  • mysql -uroot -proot

  • use mysql;

  • create user 'repl'@'%' identified by 'repl';

  • grant replication slave on . to 'repl'@'%';

  • flush privileges;

4 备份master数据库
/usr/database/mysql5.7/bin/mysqldump -S /tmp/mysql.sock -F --opt -R --single-transaction --master-data=2 --default-character-set=utf8 -A > mysql_backup_full.sql

上述命令报错,1045,增加登录信息:

/usr/database/mysql5.7/bin/mysqldump -uroot -proot -S /tmp/mysql.sock -F --opt -R --single-transaction --master-data=2 --default-character-set=utf8 -A > mysql_backup_full.sql

导出指定数据库(仅表结构):

/usr/database/mysql5.7/bin/mysqldump -uroot -proot -S /tmp/mysql.sock -F --opt -R --single-transaction --master-data=2 --default-character-set=utf8 -d testdb1 testdb2 > mysql_backup_test.sql

导出指定数据库(表结构+数据):

**/usr/database/mysql5.7/bin/mysqldump -uroot -proot -S /tmp/mysql.sock -F --opt -R --single-transaction --master-data=2 --default-character-set=utf8 testdb1 testdb2 > mysql_backup_testdatas.sql**
5 slave上恢复master数据库

master推送sql:rsync -avzP mysql_backup_test.sql 192.168.56.12:/root/Downloads/

slave导入sql:/usr/database/mysql5.7/bin/mysqldump -S /tmp/mysql.sock < mysql_backup_test.sql

上述命令不成功,需先创建数据库,改为下述操作:

**mysql -uroot -proot**
**use test;**
**source /root/Downloads/**``**mysql_backup_test.sql**
6 开启同步
  • mysql命令行中查看master status中的File和Position参数:show master status;

    • 查看进程:show processlist\G

  • slave的mysql命令行执行:

mysql> CHANGE MASTER TO
   -> MASTER_HOST='192.168.41.83',
   -> MASTER_PORT=3306,
   -> MASTER_USER='repl',
   -> MASTER_PASSWORD='repl',
   -> MASTER_LOG_FILE='mysql-bin.000004',
   -> MASTER_LOG_POS=154;
mysql> start slave;
mysql> show slave status\G

状态中注意这几项:

Slave_IO_Running:取 Master 日志的线程, Yes 为正在运行

Slave_SQL_Running:从日志恢复数据的线程, Yes 为正在运行

Seconds_Behind_Master:当前数据库相对于主库的数据延迟, 这个值是根据二进制日志的时间戳计算得到的(秒)

7 同步测试

master数据库插入一条数据,可用看到slave同步更新了

主备有了,要是能够故障自动切换就完美了,这正是下一篇内容。

引用请注明出处!

收起阅读 »

动态代理是如何实现的?JDK Proxy 和 CGLib 有什么区别?

90% 的程序员直接或者间接的使用过动态代理,无论是日志框架或 Spring 框架,它们都包含了动态代理的实现代码。动态代理是程序在运行期间动态构建代理对象和动态调用代理方法的一种机制。今天的面试题是:如何实现动态代理?JDK Proxy 和 CGLib 有什...
继续阅读 »



90% 的程序员直接或者间接的使用过动态代理,无论是日志框架或 Spring 框架,它们都包含了动态代理的实现代码。动态代理是程序在运行期间动态构建代理对象和动态调用代理方法的一种机制。

今天的面试题是:如何实现动态代理?JDK Proxy 和 CGLib 有什么区别?

典型回答

动态代理的常用实现方式是反射。反射机制 是指程序在运行期间可以访问、检测和修改其本身状态或行为的一种能力,使用反射我们可以调用任意一个类对象,以及类对象中包含的属性及方法。

但动态代理不止有反射一种实现方式,例如,动态代理可以通过 CGLib 来实现,而 CGLib 是基于 ASM(一个 Java 字节码操作框架)而非反射实现的。简单来说,动态代理是一种行为方式,而反射或 ASM 只是它的一种实现手段而已。

JDK Proxy 和 CGLib 的区别主要体现在以下几个方面:

  • JDK Proxy 是 Java 语言自带的功能,无需通过加载第三方类实现;

  • Java 对 JDK Proxy 提供了稳定的支持,并且会持续的升级和更新 JDK Proxy,例如 Java 8 版本中的 JDK Proxy 性能相比于之前版本提升了很多;

  • JDK Proxy 是通过拦截器加反射的方式实现的;

  • JDK Proxy 只能代理继承接口的类;

  • JDK Proxy 实现和调用起来比较简单;

  • CGLib 是第三方提供的工具,基于 ASM 实现的,性能比较高;

  • CGLib 无需通过接口来实现,它是通过实现子类的方式来完成调用的。

考点分析

此面试题考察的是你对反射、动态代理及 CGLib 的了解,很多人经常会把反射和动态代理划为等号,但从严格意义上来说,这种想法是不正确的,真正能搞懂它们之间的关系,也体现了你扎实 Java 的基本功。和这个问题相关的知识点,还有以下几个:

  • 你对 JDK Proxy 和 CGLib 的掌握程度。

  • Lombok 是通过反射实现的吗?

  • 动态代理和静态代理有什么区别?

  • 动态代理的使用场景有哪些?

  • Spring 中的动态代理是通过什么方式实现的?

知识扩展

1.JDK Proxy 和 CGLib 的使用及代码分析

JDK Proxy 动态代理实现

JDK Proxy 动态代理的实现无需引用第三方类,只需要实现 InvocationHandler 接口,重写 invoke() 方法即可,整个实现代码如下所示:

import java.lang.reflect.InvocationHandler;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;

/**
* JDK Proxy 相关示例
*/
public class ProxyExample {
  static interface Car {
      void running();
  }

  static class Bus implements Car {
      @Override
      public void running() {
          System.out.println("The bus is running.");
      }
  }

  static class Taxi implements Car {
      @Override
      public void running() {
          System.out.println("The taxi is running.");
      }
  }

  /**
    * JDK Proxy
    */
  static class JDKProxy implements InvocationHandler {
      private Object target; // 代理对象

      // 获取到代理对象
      public Object getInstance(Object target) {
          this.target = target;
          // 取得代理对象
          return Proxy.newProxyInstance(target.getClass().getClassLoader(),
                  target.getClass().getInterfaces(), this);
      }

      /**
        * 执行代理方法
        * @param proxy 代理对象
        * @param method 代理方法
        * @param args   方法的参数
        * @return
        * @throws InvocationTargetException
        * @throws IllegalAccessException
        */
      @Override
      public Object invoke(Object proxy, Method method, Object[] args)
              throws InvocationTargetException, IllegalAccessException {
          System.out.println("动态代理之前的业务处理.");
          Object result = method.invoke(target, args); // 执行调用方法(此方法执行前后,可以进行相关业务处理)
          return result;
      }
  }

  public static void main(String[] args) {
      // 执行 JDK Proxy
      JDKProxy jdkProxy = new JDKProxy();
      Car carInstance = (Car) jdkProxy.getInstance(new Taxi());
      carInstance.running();
}

以上程序的执行结果是:

动态代理之前的业务处理.
The taxi is running.

可以看出 JDK Proxy 实现动态代理的核心是实现 Invocation 接口,我们查看 Invocation 的源码,会发现里面其实只有一个 invoke() 方法,源码如下:

public interface InvocationHandler {
public Object invoke(Object proxy, Method method, Object[] args)
        throws Throwable;
}

这是因为在动态代理中有一个重要的角色也就是代理器,它用于统一管理被代理的对象,显然 InvocationHandler 就是这个代理器,而 invoke() 方法则是触发代理的执行方法,我们通过实现 Invocation 接口来拥有动态代理的能力。

CGLib 的实现

在使用 CGLib 之前,我们要先在项目中引入 CGLib 框架,在 pom.xml 中添加如下配置:

<!-- https://mvnrepository.com/artifact/cglib/cglib -->
<dependency>
  <groupId>cglib</groupId>
  <artifactId>cglib</artifactId>
  <version>3.3.0</version>
</dependency>

CGLib 实现代码如下:

package com.lagou.interview;

import net.sf.cglib.proxy.Enhancer;
import net.sf.cglib.proxy.MethodInterceptor;
import net.sf.cglib.proxy.MethodProxy;

import java.lang.reflect.Method;

public class CGLibExample {

  static class Car {
      public void running() {
          System.out.println("The car is running.");
      }
  }

  /**
    * CGLib 代理类
    */
  static class CGLibProxy implements MethodInterceptor {
      private Object target; // 代理对象

      public Object getInstance(Object target) {
          this.target = target;
          Enhancer enhancer = new Enhancer();
          // 设置父类为实例类
          enhancer.setSuperclass(this.target.getClass());
          // 回调方法
          enhancer.setCallback(this);
          // 创建代理对象
          return enhancer.create();
      }

      @Override
      public Object intercept(Object o, Method method,
                              Object[] objects, MethodProxy methodProxy) throws Throwable {
          System.out.println("方法调用前业务处理.");
          Object result = methodProxy.invokeSuper(o, objects); // 执行方法调用
          return result;
      }
  }

  // 执行 CGLib 的方法调用
  public static void main(String[] args) {
      // 创建 CGLib 代理类
      CGLibProxy proxy = new CGLibProxy();
      // 初始化代理对象
      Car car = (Car) proxy.getInstance(new Car());
      // 执行方法
      car.running();
}

以上程序的执行结果是:

方法调用前业务处理.
The car is running.

可以看出 CGLib 和 JDK Proxy 的实现代码比较类似,都是通过实现代理器的接口,再调用某一个方法完成动态代理的,唯一不同的是,CGLib 在初始化被代理类时,是通过 Enhancer 对象把代理对象设置为被代理类的子类来实现动态代理的。因此被代理类不能被关键字 final 修饰,如果被 final 修饰,再使用 Enhancer 设置父类时会报错,动态代理的构建会失败。

2.Lombok 原理分析

在开始讲 Lombok 的原理之前,我们先来简单地介绍一下 Lombok,它属于 Java 的一个热门工具类,使用它可以有效的解决代码工程中那些繁琐又重复的代码,如 Setter、Getter、toString、equals 和 hashCode 等等,向这种方法都可以使用 Lombok 注解来完成。

例如,我们使用比较多的 Setter 和 Getter 方法,在没有使用 Lombok 之前,代码是这样的:

public class Person {
  private Integer id;
  private String name;
  public Integer getId() {
      return id;
  }
  public void setId(Integer id) {
      this.id = id;
  }
  public String getName() {
      return name;
  }
  public void setName(String name) {
      this.name = name;
  }
}

在使用 Lombok 之后,代码是这样的:

@Data
public class Person {
  private Integer id;
  private String name;
}

可以看出 Lombok 让代码简单和优雅了很多。

小贴士:如果在项目中使用了 Lombok 的 Getter 和 Setter 注解,那么想要在编码阶段成功调用对象的 set 或 get 方法,我们需要在 IDE 中安装 Lombok 插件才行,比如 Idea 的插件如下图所示:

接下来讲讲 Lombok 的原理。

Lombok 的实现和反射没有任何关系,前面我们说了反射是程序在运行期的一种自省(introspect)能力,而 Lombok 的实现是在编译期就完成了,为什么这么说呢?

回到我们刚才 Setter/Getter 的方法,当我们打开 Person 的编译类就会发现,使用了 Lombok 的 @Data 注解后的源码竟然是这样的:

可以看出 Lombok 是在编译期就为我们生成了对应的字节码。

其实 Lombok 是基于 Java 1.6 实现的 JSR 269: Pluggable Annotation Processing API 来实现的,也就是通过编译期自定义注解处理器来实现的,它的执行步骤如下:

从流程图中可以看出,在编译期阶段,当 Java 源码被抽象成语法树(AST)之后,Lombok 会根据自己的注解处理器动态修改 AST,增加新的代码(节点),在这一切执行之后就生成了最终的字节码(.class)文件,这就是 Lombok 的执行原理。

3.动态代理知识点扩充

当面试官问动态代理的时候,经常会问到它和静态代理的区别?静态代理其实就是事先写好代理类,可以手工编写也可以使用工具生成,但它的缺点是每个业务类都要对应一个代理类,特别不灵活也不方便,于是就有了动态代理。

动态代理的常见使用场景有 RPC 框架的封装、AOP(面向切面编程)的实现、JDBC 的连接等。

Spring 框架中同时使用了两种动态代理 JDK Proxy 和 CGLib,当 Bean 实现了接口时,Spring 就会使用 JDK Proxy,在没有实现接口时就会使用 CGLib,我们也可以在配置中指定强制使用 CGLib,只需要在 Spring 配置中添加 <aop:aspectj-autoproxy proxy-target-/> 即可。

小结

今天我们介绍了 JDK Proxy 和 CGLib 的区别,JDK Proxy 是 Java 语言内置的动态代理,必须要通过实现接口的方式来代理相关的类,而 CGLib 是第三方提供的基于 ASM 的高效动态代理类,它通过实现被代理类的子类来实现动态代理的功能,因此被代理的类不能使用 final 修饰。

除了 JDK Proxy 和 CGLib 之外,我们还讲了 Java 中常用的工具类 Lombok 的实现原理,它其实和反射是没有任何关系的;最后讲了动态代理的使用场景以及 Spring 中动态代理的实现方式,希望本文可以帮助到你。

作者:Java面试真题解析
来源:https://mp.weixin.qq.com/s/UIMeWRerV9FhtEotV8CF9w

收起阅读 »

面试官:this和super有什么区别?this能调用到父类吗?

this 和 super 都是 Java 中常见的关键字,虽然二者在很多情况下都可以被省略,但它们在 Java 中所起的作用是不可磨灭的。它们都是用来起指代作用的,每个类在实例化的时候之所以能调用到 Object 类(Object 类是所有类的父类),全是二者...
继续阅读 »

this 和 super 都是 Java 中常见的关键字,虽然二者在很多情况下都可以被省略,但它们在 Java 中所起的作用是不可磨灭的。它们都是用来起指代作用的,每个类在实例化的时候之所以能调用到 Object 类(Object 类是所有类的父类),全是二者的“功劳”。

1.super 关键字

super 是用来访问父类实例属性和方法的。

1.1 super 方法使用

每个实例类如果没有显示的指定构造方法,那么它会生成一个隐藏的无参构造方法。对于 super() 方法也是类似,如果没有显示指定 super() 方法,那么子类会生成一个隐藏的 super() 方法,用来调用父类的无参构造方法,这就是咱们开篇所说的“每个类在实例化的时候之所以能调用到 Object 类,就是默认 super 方法起作用了”,接下来我们通过实例来验证一下这个说法。

PS:所谓的“显示”,是指在程序中主动的调用,也就是在程序中添加相应的执行代码。

public class SuperExample {
   // 测试方法
   public static void main(String[] args) {
       Son son = new Son();
  }
}

/**
* 父类
*/
class Father {
   public Father() {
       System.out.println("执行父类的构造方法");
  }
}

/**
* 子类
*/
class Son extends Father {
}

在以上代码中,子类 Son 并没有显示指定 super() 方法,我们运行以上程序,执行的结果如下: 从上述的打印结果可以看出,子类 Son 在没有显示指定 super() 方法的情况下,竟然调用了父类的无参构造方法,这样从侧面验证了,如果子类没有显示指定 super() 方法,那么它也会生成一个隐藏的 super() 方法。这一点我们也可以从此类生成的字节码文件中得到证实,如下图所示:

super 方法注意事项

如果显示使用 super() 方法,那么 super() 方法必须放在构造方法的首行,否则编译器会报错,如下代码所示: 如上图看到的那样,如果 super() 方法没有放在首行,那么编译器就会报错:提示 super() 方法必须放到构造方法的首行。 为什么要把 super() 方法放在首行呢? 这是因为,只要将 super() 方法放在首行,那么在实例化子类时才能确保父类已经被先初始化了。

1.2 super 属性使用

使用 super 还可以调用父类的属性,比如以下代码可以通过子类 Son 调用父类中的 age 属性,实现代码如下:

public class SuperExample {
   // 测试方法
   public static void main(String[] args) {
       Son son = new Son();
  }
}

/**
* 父类
*/
class Father {
   // 定义一个 age 属性
   public int age = 30;

   public Father() {
       super();
       System.out.println("执行父类的构造方法");
  }
}

/**
* 子类
*/
class Son extends Father {
   public Son() {
       System.out.println("父类 age:" + super.age);
  }
}

以上程序的执行结果如下图所示,在子类中成功地获取到了父类中的 age 属性:

2.this 关键字

this 是用来访问本类实例属性和方法的,它会先从本类中找,如果本类中找不到则在父类中找。

2.1 this 属性使用

this 最常见的用法是用来赋值本类属性的,比如常见的 setter 方法,如下代码所示: 上述代码中 this.name 表示 Person 类的 name 属性,此处的 this 关键字不能省略,如果省略就相当于给当前的局部变量 name 赋值 name,自己给自己赋值了。我们可以尝试一下,将 this 关键字取消掉,实现代码如下:

class Person {
   private String name;
   public void setName(String name) {
       this.name = name;
  }
   public String getName() {
       return name;
  }
}
public class ThisExample {
   public static void main(String[] args) {
       Person p = new Person();
       p.setName("磊哥");
       System.out.println(p.getName());
  }
}

以上程序的执行结果如下图所示: 从上述结果可以看出,将 this 关键字去掉之后,赋值失败,Person 对象中的 name 属性就为 null 了。

2.2 this 方法使用

我们可以使用 this() 方法来调用本类中的构造方法,具体实现代码如下:

public class ThisExample {
   // 测试方法
   public static void main(String[] args) {
       Son p = new Son("Java");
  }
}

/**
* 父类
*/
class Father {
   public Father() {
       System.out.println("执行父类的构造方法");
  }
}

/**
* 子类
*/
class Son extends Father {
   public Son() {
       System.out.println("子类中的无参构造方法");
  }
   public Son(String name) {
       // 使用 this 调用本类中无参的构造方法
       this();
       System.out.println("子类有参构造方法,name:" + name);
  }
}

以上程序的执行结果如下图所示: 从上述结果中可以看出,通过 this() 方法成功调用到了本类中的无参构造方法。

注意:this() 方法和 super() 方法的使用规则一样,如果显示的调用,只能放在方法的首行。

2.3 this 访问父类方法

接下来,我们尝试使用 this 访问父类方法,具体实现代码如下:

public class ThisExample {
   public static void main(String[] args) {
       Son son = new Son();
       son.sm();
  }
}

/**
* 父类
*/
class Father {
   public void fm() {
       System.out.println("调用了父类中的 fm() 方法");
  }
}

/**
* 子类
*/
class Son extends Father {
   public void sm() {
       System.out.println("调用子类的 sm() 方法访问父类方法");
       // 调用父类中的方法
       this.fm();
  }
}

以上程序的执行结果如下: 从上述结果可以看出,使用 this 是可以访问到父类中的方法的,this 会先从本类中找,如果找不到则会去父类中找。

3.this 和 super 的区别

1.指代的对象不同

super 指代的是父类,是用来访问父类的;而 this 指代的是当前类。

2.查找范围不同

super 只能查找父类,而 this 会先从本类中找,如果找不到则会去父类中找。

3.本类属性赋值不同

this 可以用来为本类的实例属性赋值,而 super 则不能实现此功能。

4.this 可用于 synchronized

因为 this 表示当前对象,所以this 可用于 synchronized(this){....} 加锁,而 super 则不能实现此功能。

总结

this 和 super 都是 Java 中的关键字,都起指代作用,当显示使用它们时,都需要将它们放在方法的首行(否则编译器会报错)。this 表示当前对象,super 用来指代父类对象,它们有四点不同:指代对象、查找访问、本类属性赋值和 synchronized 的使用不同。

作者:Java中文社群
来源:https://juejin.cn/post/7046994591253266440

收起阅读 »

觉得前端不需要懂算法?那来看下这个真实的例子

算法是问题的解决步骤,同一个问题可以有多种解决思路,也就会有多种算法,但是算法之间是有好坏之分的,区分标志就是复杂度。通过复杂度可以估算出耗时/内存占用等性能的好坏,所以我们用复杂度来评价算法。(不了解复杂度可以看这篇:性能分析不一定得用 Profiler,复...
继续阅读 »

算法是问题的解决步骤,同一个问题可以有多种解决思路,也就会有多种算法,但是算法之间是有好坏之分的,区分标志就是复杂度。

通过复杂度可以估算出耗时/内存占用等性能的好坏,所以我们用复杂度来评价算法。

(不了解复杂度可以看这篇:性能分析不一定得用 Profiler,复杂度分析也行

开发的时候,大多数场景下我们用最朴素的思路,也就是复杂度比较高的算法也没啥问题,好像也用不到各种高大上的算法,算法这个东西似乎可学可不学。

其实不是的,那是因为你没有遇到一些数据量大的场景。

下面我给你举一个我之前公司的具体场景的例子:

体现算法威力的例子

这是我前公司高德真实的例子。

我们会做全源码的依赖分析,会有几万个模块,一个模块依赖另一个模块叫做正向依赖,一个模块被另一个模块依赖叫做反向依赖。我们会先分析一遍正向依赖,然后再分析一遍反向依赖。

分析反向依赖的时候,之前的思路是这样的,对于每一个依赖,都遍历一边所有的模块,找到依赖它的模块,这就是它的反向依赖。

这个思路是很朴素的,容易想到的思路,但是这个思路有没有问题呢?

这个算法的复杂度是 O(n^2),如果 n 达到了十几万,那性能会很差的,从复杂度我们就可以估算出来。

事实上也确实是这样,后来我们跑一遍全源码依赖需要用 10 几个小时,甚至一晚上都跑不出来。

如果让你去优化,你会怎么优化性能呢?

有的同学可能会说,能不能拆成多进程/多个工作线程,把依赖分析的任务拆成几部分来做,这样能得到几倍的性能提升。

是,几倍的提升很大了。

但是如果说我们后来做了一个改动,性能直接提升了几万倍你信么?

我们的改动方式是这样的:

之前是在分析反向依赖的时候每一个依赖都要遍历一遍所有的正向依赖。但其实正向依赖反过来不就是反向依赖么?

所以我们直接改成了分析正向依赖的时候同时记录反向依赖。

这样根本就不需要单独分析反向依赖了,算法复杂度从 O(n^2)降到了 O(n)。

O(n^2) 到 O(n) 的变化在有几万个模块的时候,就相当于几万倍的性能提升。

这体现在时间上就是我们之前要跑一个晚上的代码,现在十几分钟就跑完了。这优化力度,你觉得光靠多线程/进程来跑能做到么?

这就是算法的威力,当你想到了一个复杂度更低的算法,那就意味着性能有了大幅的提升。

为什么我们整天说 diff 算法,因为它把 O(n^2) 的朴素算法复杂度降低到了 O(n),这就意味着 dom 节点有几千个的时候,就会有几千倍的性能提升。

所以,感受到算法的威力了么?

总结

多线程、缓存等手段最多提升几倍的性能,而算法的优化是直接提升数量级的性能,当数据量大了以后,就是几千几万倍的性能提升。

那为什么我们平时觉得算法没用呢?那是因为你处理的数据量太小了,处理几百个数据,你用 O(n^2) O(n^3) 和 O(n) 的算法,都差不了多少。

你处理的场景数据量越大,那算法的重要性越高,因为好的算法和差的算法的差别不是几倍几十倍那么简单,可能是几万倍的差别。

所以,你会见到各大公司都在考算法,没用么?不是的,是太过重要了,直接决定着写出的代码的性能。

原文:https://juejin.cn/post/7023024399376711694

收起阅读 »

前端监控系统设计

前言: 创建一个可随意插拔的插件式前端监控系统 一、数据采集 1.异常数据 1.1 静态资源异常 使用window.addEventListener('error',cb) 由于这个方法会捕获到很多error,所以我们要从中筛选出静态资源文件加载错误情况,这里...
继续阅读 »

前言: 创建一个可随意插拔的插件式前端监控系统


一、数据采集


1.异常数据


1.1 静态资源异常


使用window.addEventListener('error',cb)


由于这个方法会捕获到很多error,所以我们要从中筛选出静态资源文件加载错误情况,这里只监控了js、css、img


// 捕获静态资源加载失败错误 js css img
window.addEventListener('error', e => {
const target = e.targetl
if (!target) return
const typeName = e.target.localName;
let sourceUrl = "";
if (typeName === "link") {
sourceUrl = e.target.href;
} else if (typeName === "script" || typeName === "img") {
sourceUrl = e.target.src;
}

if (sourceUrl) {
lazyReportCache({
url: sourceUrl,
type: 'error',
subType: 'resource',
startTime: e.timeStamp,
html: target.outerHTML,
resourceType: target.tagName,
paths: e.path.map(item => item.tagName).filter(Boolean),
pageURL: getPageURL(),
})
}
}, true)


1.2 js错误


通过 window.onerror 获取错误发生时的行、列号,以及错误堆栈


生产环境需要上传打包后生成的map文件,利用source-map 对压缩后的代码文件和行列号得出未压缩前的报错行列数和源码文件


// parseErrorMsg.js
const fs = require('fs');
const path = require('path');
const sourceMap = require('source-map');

export default async function parseErrorMsg(error) {
const mapObj = JSON.parse(getMapFileContent(error.url))
const consumer = await new sourceMap.SourceMapConsumer(mapObj)
// 将 webpack://source-map-demo/./src/index.js 文件中的 ./ 去掉
const sources = mapObj.sources.map(item => format(item))
// 根据压缩后的报错信息得出未压缩前的报错行列数和源码文件
const originalInfo = consumer.originalPositionFor({ line: error.line, column: error.column })
// sourcesContent 中包含了各个文件的未压缩前的源码,根据文件名找出对应的源码
const originalFileContent = mapObj.sourcesContent[sources.indexOf(originalInfo.source)]
return {
file: originalInfo.source,
content: originalFileContent,
line: originalInfo.line,
column: originalInfo.column,
msg: error.msg,
error: error.error
}
}

function format(item) {
return item.replace(/(\.\/)*/g, '')
}

function getMapFileContent(url) {
return fs.readFileSync(path.resolve(__dirname, `./dist/${url.split('/').pop()}.map`), 'utf-8')
}

1.3 自定义异常


通过console.error打印出来的,我们将其认为是自定义错误


使用 window.console.error 上报自定义异常信息


1.4 接口异常



  1. 当状态码异常时,上报异常

  2. 重写 onloadend 方法,当其 response 对象中 code 值不为 '000000' 时上报异常 

  3. 重写 onerror 方法,当网络中断时无法触发 onload(end) 事件,会触发 onerror, 此时上报异常


1.5 监听未处理的promise错误


当Promise 被reject 且没有reject 处理器的时候,就会触发 unhandledrejection 事件


使用 window.addEventListener('unhandledrejection',cb)


2.性能数据


2.1 FP/FCP/LCP/CLS


chrome 开发团队提出了一系列用于检测网页性能的指标:



  • FP(first-paint),从页面加载开始到第一个像素绘制到屏幕上的时间

  • FCP(first-contentful-paint),从页面加载开始到页面内容的任何部分在屏幕上完成渲染的时间

  • LCP(largest-contentful-paint),从页面加载开始到最大文本块或图像元素在屏幕上完成渲染的时间

  • CLS(layout-shift),从页面加载开始和其生命周期状态变为隐藏期间发生的所有意外布局偏移的累积分数




其中,前三个性能指标都可以直接通过 PerformanceObserver (PerformanceObserver 是一个性能监测对象,用于监测性能度量事件 )来获取。而CLS 则需要通过一些计算。


在了解一下计算方式之前,我们先了解一下会话窗口的概念:一个或多个布局偏移间,它们之间有少于1秒的时间间隔,并且第一个和最后一个布局偏移时间间隔上限为5秒,超过5秒的布局偏移将被划分到新的会话窗口。


Chrome 速度指标团队在完成大规模分析后,将所有会话窗口中的偏移累加最大值用来反映页面布局最差的情况(即CLS)。


如下图:会话窗口2只有一个微小的布局偏移,则会话窗口2会被忽略,CLS只计算会话窗口1中布局偏移的总和。


拉低平均值的小布局偏移示例


2.2 DOMContentLoaded事件 和  onload 事件



  • DOMContentLoaded: HTML文档被加载和解析完成。在文档中没有脚本的情况下,浏览器解析完文档便能触发DOMContentLoaded;当文档中有脚本时,脚本会阻塞文档的解析,而脚本需要等位于脚本前面的css加载完才能执行。但是在任何情况下,DOMContentLoaded 都不需要等图片等其他资源的解析。

  • onload: 需要等页面中图片、视频、音频等其他所有资源都加载后才会触发。


为什么我们在开发时强调把css放在头部,js放在尾部?


image.png


首先文件放置顺序决定下载的优先级,而浏览器为了避免样式变化导致页面重排or重绘,会阻塞内容的呈现,等所有css加载并解析完成后才一次性呈现页面内容,在此期间就会出现“白屏”。


而现代浏览器为了优化用户体验,无需等到所有HTML文档都解析完成才开始构建布局渲染树,也就是说浏览器能够渲染不完整的DOM tree和cssom,尽快减少白屏时间。


假设我们把js放在头部,js会阻塞解析dom,导致FP(First Paint)延后,所以我们将js放在尾部,以减少FP的时间,但不会减少 DOMContentLoaded 被触发的时间。


2.3 资源加载耗时及是否命中缓存情况


通过 PerformanceObserver 收集,当浏览器不支持 PerformanceObserver,还可以通过 performance.getEntriesByType(entryType) 来进行降级处理,其中:



  • Navigation Timing 收集了HTML文档的性能指标

  • Resource Timing 收集了文档依赖的资源的性能指标,如:css,js,图片等等


这里不统计以下资源类型:



  • beacon: 用于上报数据,不统计

  • xmlhttprequest:单独统计


我们能够获取到资源对象的如下信息:


image.png


使用performance.now()精确计算程序执行时间:



  • performance.now()  与 Date.now()  不同的是,返回了以微秒(百万分之一秒)为单位的时间,更加精准。并且与 Date.now()  会受系统程序执行阻塞的影响不同,performance.now()  的时间是以恒定速率递增的,不受系统时间的影响(系统时间可被人为或软件调整)。

  • Date.now()  输出的是 UNIX 时间,即距离 1970 的时间,而 performance.now()  输出的是相对于 performance.timing.navigationStart(页面初始化) 的时间。

  • 使用 Date.now()  的差值并非绝对精确,因为计算时间时受系统限制(可能阻塞)。但使用 performance.now()  的差值,并不影响我们计算程序执行的精确时间。


判断该资源是否命中缓存:

在这些资源对象中有一个 transferSize 字段,它表示获取资源的大小,包括响应头字段和响应数据的大小。如果这个值为 0,说明是从缓存中直接读取的(强制缓存)。如果这个值不为 0,但是 encodedBodySize 字段为 0,说明它走的是协商缓存(encodedBodySize 表示请求响应数据 body 的大小)。不符合以上条件的,说明未命中缓存。


2.4 接口请求耗时以及接口调用成败情况


对XMLHttpRequest 原型链上的send 以及open方法进行改写


import { originalOpen, originalSend, originalProto } from '../utils/xhr'
import { lazyReportCache } from '../utils/report'

function overwriteOpenAndSend() {
originalProto.open = function newOpen(...args) {
this.url = args[1]
this.method = args[0]
originalOpen.apply(this, args)
}

originalProto.send = function newSend(...args) {
this.startTime = Date.now()

const onLoadend = () => {
this.endTime = Date.now()
this.duration = this.endTime - this.startTime

const { duration, startTime, endTime, url, method } = this
const { readyState, status, statusText, response, responseUrl, responseText } = this
console.log(this)
const reportData = {
status,
duration,
startTime,
endTime,
url,
method: (method || 'GET').toUpperCase(),
success: status >= 200 && status < 300,
subType: 'xhr',
type: 'performance',
}

lazyReportCache(reportData)

this.removeEventListener('loadend', onLoadend, true)
}

this.addEventListener('loadend', onLoadend, true)
originalSend.apply(this, args)
}
}

export default function xhr() {
overwriteOpenAndSend()
}

二、数据上报


1. 上报方法


采用sendBeacon 和 XMLHttpRequest 相结合的方式


为什么要使用sendBeacon?



统计和诊断代码通常要在 unload 或者 beforeunload (en-US) 事件处理器中发起一个同步 XMLHttpRequest 来发送数据。同步的 XMLHttpRequest 迫使用户代理延迟卸载文档,并使得下一个导航出现的更晚。下一个页面对于这种较差的载入表现无能为力。

navigator.sendBeacon()  方法可用于通过HTTP将少量数据异步传输到Web服务器,同时不会延迟页面的卸载或影响下一导航的载入性能。这就解决了提交分析数据时的所有的问题:数据可靠,传输异步并且不会影响下一页面的加载。



2. 上报时机



  1. 先缓存上报数据,缓存到一定数量后,利用 requestIdleCallback/setTimeout 延时上报。

  2. 在即将离开当前页面(刷新或关闭)时上报 (onBeforeUnload )/ 在页面不可见时上报(onVisibilitychange,判断document.visibilityState/ document.hidden 状态)

作者:spider集控团队
链接:https://juejin.cn/post/7046697922255126558

收起阅读 »

拒绝!封装el-table,请别再用JSON数组来配置列了

阅读本文📖你将:明白通过JSON 来配置el-table的列可能并不是那么美。(作者主观意见)学会一点关于VNode操作的实例。(一点点)辩证地思考一下当我们在团队内对组件进行二次封装时,哪些东西是我们需要取舍的。前言大家好,我是春哥。我热爱&nbs...
继续阅读 »

阅读本文📖

你将:

  1. 明白通过JSON 来配置el-table的列可能并不是那么美。(作者主观意见)
  2. 学会一点关于VNode操作的实例。(一点点)
  3. 辩证地思考一下当我们在团队内对组件进行二次封装时,哪些东西是我们需要取舍的。

前言

大家好,我是春哥
我热爱 vue.js , ElementUI , Element Plus 相关技术栈,我的目标是给大家分享最实用、最有用的知识点,希望大家都可以早早下班,并可以飞速完成工作,淡定摸鱼🐟。

相信使用 vue 的同学大部分都用过 Element 系列框架,并且绝大部分都用过其中的 el-table 组件。并且几乎所有人都会把表格和分页进行一层封装。

不过,很多人在封装时,总是习惯性地把 el-table 官方推荐的 "插槽写法" 改成 "JSON 数组" 写法。

就像这样:

<template>
<my-el-table :columns="columns" :data="tableData">
</my-el-table>
</template>
<script setup>
const columns = [
{
prop: 'date',
label: 'Date',
width: '180'
},
{
prop: 'name',
label: 'Name'
}
]
// ...其他略
</script>

但经过我多年踩坑的惨痛经历,我必须要大声说出那句话:
快住手!有更好的封装技巧

尔康式拒绝

JSON 式封装哪些缺点?

缺点一:学习成本增高

以下两种场景,如果是你今天刚刚入职,你更愿意在业务代码里看到哪种组件呢?

你更愿意使用哪种组件?

我反正是更偏向于 1 和 4
第 1 种意味着它有丰富的社区支持,有准确而清晰的文档和 demo 可以借鉴。
第 4 种意味着你依然可以靠官方文档横行,并且可以使用一些同事根据业务进行的"增强能力"。

那么 2 和 3 呢?
也许我的同事真的可以做出很好的封装,但如果你在小厂、在初创公司、甚至在外包公司,更大的可能是你的同事并不靠谱。
他的某些封装只是为了满足单一的业务场景, 但你为了了解他的功能,却不得不去面对全新的 api,甚至是通过看他的源码才能了解具体有什么 api 和能力。

在这种场景下,我选择面对熟悉的,官方的 api。 

缺点二:自定义内容需要写 h 函数

不会真的有人喜欢在业务里写 h 函数吧?

当简单的 JSON 配置无法满足产品经理那天马行空的想象力时,你可能需要对 el-table-column 里的内容进行更多的自定义。
此时,也许你就会怀念"插槽式"的便捷了。
假设你的产品经理要求你写一个 带色彩的状态列 。 h和插槽

以上两种写法你会选择哪种呢?
而且,当业务变得更加复杂的时候,h 函数写法的可读性是指数式下跌的,你怕不怕?

当然,用JSX写法来简化 h 函数写法是个不错的思路。

JSON 式封装有哪些优点?

优点一:能简化写法?(并不)

有人说:“ JSON 式封装,能够简化代码写法。”
听到这样的话,我的内心其实是充满困惑的:它究竟能简化什么?
写法上的对比

看出来了吗?
这种常见的所谓 封装,只不过是做了做简单形式化的转换:你并没有少写哪怕一个属性,只不过把它们从这里挪到了那里。

甚至于极端场景,你还需要多写代码:

// 从:
<el-table-column show-overflow-tooltip />
// 变成:
{
'show-overflow-tooltip': true
}

优点二:只有 JSON 化才能实现动态列?(并不)

我在《我对 Element 的 Table 做了封装》 里讨论时, JackLiR8 同学提出了一个疑问: 
简化一下就是:

怎样封装才能在保持插槽写法的情况下,实现 动态列呢 ?

其实这个问题并不难,前提是需要你理解 vNode 是什么以及怎么操作它们。

我做了一个简单的例子,核心代码如下:

// vue3 函数式组件写法
const FunctionalTable = (props, context) => {
// 获得插槽里的 vNodes
const vNodes = context.slots.default()
// 过滤 vNodes
const filteredVNodes = props.visibleKeys == null ? vNodes : vNodes.filter(node => props.visibleKeys.includes(node?.props?.prop))
// 把属性透传给el-table
return <el-table {...context.attrs}>
{ filteredVNodes }
</el-table>
}
// vue3 函数式组件定义 props
FunctionalTable.props = {
visibleKeys: {
type: Array,
default: () => null
}
}

这就能实现 动态列 了?
是的。
下面正是使用时的代码:

<template>
<el-button @click="onClick">给我变!</el-button>
<FunctionalTable :data="tableData" :visibleKeys="visibleKeys">
<el-table-column prop="date" label="Date" width="180" />
<el-table-column prop="name" label="Name" />
<el-table-column prop="address" label="Address" />
</FunctionalTable>
</template>
<script setup>
// 其他略
const visibleKeys = ref(['date', 'name'])
const onClick = () => {
visibleKeys.value = ['date', 'name', 'address']
}
// 其他略
// ...

效果如下: 插槽写法的动态列

毫无疑问,当遇到复杂场景,以及列里需要渲染各种奇形怪状的东西如 tagpopover 或者你需要进行更加复杂的定义的时候,插槽写法是更为优秀的。
这是上述demo的源代码 => github源码

优点三:JSON 配置能存数据库?(我劝你慎重)

"如果我把列的 JSON 配置存到数据库里,那我就不用写代码了!"

好家伙,我直呼好家伙!

除非你已经封装了非常成熟的可视化配置方案,否则! 当业务上需要新增一列时,不还是你写?服务端和运维可不会帮你写代码。

只不过你存储代码的地点,从 git 变成了 数据库

碰上懒一点的服务端,你还需要安装数据库链接软件,增加一项写 sql update 语句的活儿。

更让人感到害怕的是,你丢失了对代码版本跟踪的能力。

"为啥生产库/测试库/开发库存的数据不一样,到底应该用谁,我也不知道这字段是哪个版本、因为什么被谁合入的呀...."

那一刻,你可能会无比怀念 git commit-msg 那。

我期望的封装是什么样的?

如果你想设计一款基于"element UI"或"element Plus",能解决一些迫在眉睫的问题,能优化一些写法,能规范一些格式,能让团队小伙伴们乐于使用到组件库。
我想,你可能得充分考虑以下内容:

  • 它的API是否简单易学(甚至大部分就和 element 一模一样 )?
  • 它是否确确实实简化了业务上的写法?
    比如把 表格 和 分页器 合并,比如提供 请求方法 作为 prop 等都是能极大降低业务复杂性的封装。
  • 它是否扩展性强,易维护?api 设计是否和项目保持风格上的一致?
    在同一个项目里,render/jsx/template 混用很可能会让一些新人感到吃力。
  • 它是否是增强和渐进的?
    我可不希望当我试图使用 elementUI 某个特性时,我猛然发现我同事封装的组件居然不支持!

震惊!

免喷声明

以上所有配图和文本都是我的个人观点。

如果你认为它们是错的,那它们就是错的吧。

关于我反对把 Element 表格列 JSON化 最初的初心,我确实厌倦了在不同团队不同公司总是要一遍又一遍去看前同事们蹩脚的封装,去理解他们做了哪些东西,拼写有没有改编,有没有丢失特性,去再学习一遍完全没有学习价值的API

希望大家写代码时,都能获得良好的体验。

封装组件时,都能封装出"能用","好用", "大家愿意用"的组件!


原文:https://juejin.cn/post/7043578962026430471

收起阅读 »

Python服务端快速调用环信MQTT REST接口下发消息

本文介绍Python服务端通过调用环信MQTT REST API接口快速实现消息下发,使用时可参阅REST发送消息接口介绍1. 前提条件1.1 获取服务器信息调用环信MQTT REST API接口前,需要获取四个环信MQTT服务器信息,包括:应用clientI...
继续阅读 »


本文介绍Python服务端通过调用环信MQTT REST API接口快速实现消息下发,使用时可参阅
REST发送消息接口介绍


1. 前提条件

1.1 获取服务器信息
调用环信MQTT REST API接口前,需要获取四个环信MQTT服务器信息,包括:应用clientID应用clientSecretREST API地址及应用ID
1、应用clientID:从环信console【应用概览】->【应用详情】->【开发者ID】下 "client ID"获取;
2、应用clientSecret:从环信console【应用概览】->【应用详情】->【开发者ID】下"clientSecret"获取; 
3、RSET API地址:从环信console【MQTT】->【服务概览】->【服务配置】下"REST API地址"获取; 
4、应用ID:从环信console【MQTT】->【服务概览】->【服务配置】下"AppId"获取;



2. 实现流程 
注:本代码对消息体内容进行GBK转码,可支持语音播报(适用于扬声器播放中文内容),如不需要此场景使用,可根据需求设置转码格式。

import requests
import time
import json
import base64


# 填写服务参数
# 1、app_client_id:应用clientID
# 2、app_client_secret:应用clientSecret
# 3、api_url_base:RSET API地址
# 4、app_id:应用ID
app_client_id = ' XXXXX'

app_client_secret = 'XXXX'

api_url_base = 'XXXXX'

app_id = 'XXXXXX'


# 播报文字
speak_text = '欢迎使用环信mqtt'


# 获取应用token
api_url_app_token = api_url_base + '/openapi/rm/app/token'
def get_app_token():
data = {
'appClientId':app_client_id,
'appClientSecret':app_client_secret
}

header = {'Content-Type': 'application/json'}

re = requests.post(api_url_app_token, headers=header, data=json.dumps(data))
return (json.loads(re.text)['body']['access_token'])


# 发送mqtt消息
api_url_publish = api_url_base + '/openapi/v1/rm/chat/publish'
def send_msg(app_token, txt):

# 智能音箱的 msgid 每次都不一样才会播报声音
# 这里用毫秒时间戳当作 msgid
time_millis = int(round(time.time() * 1000))

dat ={
'type':'tts_dynamic',
'msgid': time_millis,
'txt':txt ,
}

json_text = json.dumps(dat, ensure_ascii=False)
json_h = json_text.encode(encoding="gbk")
base64_bytes = base64.b64encode(json_h)
base64_utf8 = str(base64_bytes,'utf-8')

#topics,要发送的主题
#clientid,当前客户端ID,格式为“xxxx@appid”
data = {
'topics':['861714050059769'],
'clientid':'12@ff6sc0',
'payload':base64_utf8,
"encoding":'base64',
'qos':1,
'retain':0,
'expire':86400
}

header = {
'Content-Type': 'application/json',
'Authorization': app_token
}

re = requests.post(api_url_publish, headers=header, data=json.dumps(data))
return (json.loads(re.text))


print('正在获取应用token...')
app_token = get_app_token()
print('获取应用token成功')

print(send_msg(app_token, speak_text))
print('发送消息成功')



 三、更多信息

* 如果您在使用MQTT服务中,有任何疑问和建议,欢迎您联系我们

收起阅读 »

PHP服务端快速调用环信MQTT REST接口下发消息

本文介绍PHP服务端通过调用环信MQTT REST API接口快速实现消息下发,使用时可参阅REST发送消息接口介绍1. 前提条件1.1 获取服务器信息调用环信MQTT REST API接口前,需要获取四个环信MQTT服务器信息,包括:应用clientID、应...
继续阅读 »


本文介绍PHP服务端通过调用环信MQTT REST API接口快速实现消息下发,使用时可参阅
REST发送消息接口介绍


1. 前提条件

1.1 获取服务器信息
调用环信MQTT REST API接口前,需要获取四个环信MQTT服务器信息,包括:应用clientID应用clientSecretREST API地址及应用ID
1、应用clientID:从环信console【应用概览】->【应用详情】->【开发者ID】下 "client ID"获取;
2、应用clientSecret:从环信console【应用概览】->【应用详情】->【开发者ID】下"clientSecret"获取; 
3、RSET API地址:从环信console【MQTT】->【服务概览】->【服务配置】下"REST API地址"获取; 
4、应用ID:从环信console【MQTT】->【服务概览】->【服务配置】下"AppId"获取;



2. 实现流程 
注:本代码对消息体内容进行GBK转码,可支持语音播报(适用于扬声器播放中文内容),如不需要此场景使用,可根据需求设置转码格式。

// 填写服务参数
// 1、client_id:应用clientID
// 2、client_secret:应用clientSecret
// 3、rest_uri:RSET API地址
// 4、app_id:应用ID
$config = [
'rest_uri' => 'XXXXXX',
'client_id' => 'XXXXXX',
'client_secret' => 'XXXXXX',
'app_id' => 'XXXXXX',
];

// 实时 token
$accessToken = get_access_token();

// 固定值,有有效期
//$accessToken = 'YWMtiftbBF7sEeyeASnTGg_ZZCGtXR4YNTAxtZpP1MjdlZbv64ppqWZOEI663pDy48tKAgMAAAF9xoOlvAWP1ADm__IWx_b4TLJvCb9axcY6cNImjMXJcx1ty7UK-Ked2w';

// 发送消息
$message = [
'type' => 'tts_dynamic',
'msgid' => 'c1b5d5f46092d4c01a5f422ae2b9ad41188',
'txt' => '测试测试'
];
var_dump(send(['861714050059769'], $message));

/**
* @description: 获取 Token
* @return {String}
*/
function get_access_token()
{
global $config;
$uri = $config['rest_uri'] . '/openapi/rm/app/token';
$body = [
'appClientId' => $config['client_id'],
'appClientSecret' => $config['client_secret'],
];
$headers = [
'Content-Type' => 'application/json',
];
$ret = json_decode(curl_request($uri, $body, $headers), true);
return isset($ret['code']) && $ret['code'] == 200 ? $ret['body']['access_token'] : $ret;
}

/**
* @description: 发送消息
* @param {array} $topics 要发消息的主题数组
* @param {mixed} $message 要发送的消息内容
* @param {String} $deviceID deviceID 用户自定义
* @return {array}
*/
function send($topics, $message, $deviceID = '12')
{
global $config, $accessToken;
$uri = $config['rest_uri'] . '/openapi/v1/rm/chat/publish';
$body = [
'topics' => $topics,
'clientid' => "{$deviceID}@{$config['app_id']}",
'payload' => base64_encode(iconv("UTF-8", "GBK", json_encode($message, JSON_UNESCAPED_UNICODE))),
'encoding' => 'base64',
];

$headers = [
'Content-Type' => 'application/json',
'Authorization' => $accessToken,
];
$ret = json_decode(curl_request($uri, $body, $headers), true);
return $ret;
}

/**
* @description: 查看消息
* @param {String} $messageId 指定的消息ID
* @return {array}
*/
function show($messageId)
{
global $config, $accessToken;
$uri = $config['rest_uri'] . '/openapi/rm/message/message?messageId=' . $messageId;
$headers = [
'Content-Type' => 'application/json',
'Authorization' => $accessToken,
];
$ret = json_decode(curl_request($uri, null, $headers), true);
if (isset($ret['code']) && $ret['code'] == 200) {
$ret['body']['message'] = json_decode(iconv('GBK', 'UTF-8', base64_decode($ret['body']['message'])), true);
return $ret['body'];
}
return $ret;
}

function curl_request($url, $data = null, $headers = null)
{
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, 0);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 0);
// CURLOPT_HEADER => true, // 将头文件的信息作为数据流输出
// CURLOPT_NOBODY => false, // true 时将不输出 BODY 部分。同时 Mehtod 变成了 HEAD。修改为 false 时不会变成 GET。
// CURLOPT_CUSTOMREQUEST => $request->method, // 请求方法
if(!empty($data)){
curl_setopt($ch, CURLOPT_POST, 1);
if (is_array($data)) {
$data = json_encode($data);
}
curl_setopt($ch, CURLOPT_POSTFIELDS, $data);
}
if(!empty($headers)){
curl_setopt($ch, CURLOPT_HTTPHEADER, buildHeaders($headers));
}
$output = curl_exec($ch);
// $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
// $header_size = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
curl_close($ch);
return $output;
}

function buildHeaders($headers)
{{{:playground:message:微信交流群.jpeg?200|}}
$headersArr = array();
foreach ($headers as $key => $value) {
array_push($headersArr, "{$key}: {$value}");
}
return $headersArr;
}


 三、更多信息

* 如果您在使用MQTT服务中,有任何疑问和建议,欢迎您联系我们

收起阅读 »

Android编译插桩操作字节码

1. 概念 什么是编译插桩 顾名思义,所谓的编译插桩就是在代码编译期间修改已有的代码或者生成新代码。我们项目中的 Dagger、ButterKnife或者kotlin都用到了编译插桩技术。 要理解编译插桩,我们要先知道在Android中.java 文件是怎么编...
继续阅读 »

1. 概念


什么是编译插桩


顾名思义,所谓的编译插桩就是在代码编译期间修改已有的代码或者生成新代码。我们项目中的 Dagger、ButterKnife或者kotlin都用到了编译插桩技术。


要理解编译插桩,我们要先知道在Android中.java 文件是怎么编译的。


WechatIMG106.png


如上图所示,demo.java通过javac命令编译成demo.class文件,然后通过字节码文件编译器将class文件打包成.dex。


我们今天要说的插桩,就是在class文件转为.dex之前修改或者添加代码。


2. 场景


我们什么时候会用到它呢?



  • 日志埋点

  • 性能监控

  • 权限控制

  • 代码替换

  • 代码调试

  • 等等...


3. 插桩工具介绍




  • AspectJ




AspectJ 是老牌 AOP(Aspect-Oriented Programming)框架。其主要优势是成熟稳定,使用者也不需要对字节码文件有深入的理解。




  • ASM




ASM 最初起源于一个博士的研究项目,在 2002 年开源,并从 5.x 版本便开始支持 Java 8。并且,ASM 是诸多 JVM 语言钦定的字节码生成库,它在效率与性能方面的优势要远超其它的字节码操作库如 javassist、AspectJ。其主要优势是内存占用很小,运行速度快,操作灵活。但是上手难度大,需要对 Java 字节码有比较充分的了解。


本文使用 ASM 来实现简单的编译插桩效果,接下来我们是想一个小需求,


4. 实践


1. 创建AsmDemo项目,其中只有一个MainActivity


QQ20211224-135648@2x.png


2.创建自定义gradle插件


QQ20211224-135825@2x.png
删除module中main文件夹下所有目录,新建groovy跟java目录。


2222.png
gradle插件是用groovy编写的,所以groovy文件存放.groovy文件,java目录中存放asm相关类。
清空build.gradle文件内容,改为如下内容:


plugins {
id 'groovy'
id 'maven'
}
dependencies {
implementation gradleApi()
implementation localGroovy()
implementation 'com.android.tools.build:gradle:3.5.4'
}
group = "demo.asm.plugin"
version = "1.0.0"

uploadArchives {
repositories {
mavenDeployer {
repository(url: uri("../asm_lifecycle_repo"))
}
}
}

3.创建LifeCyclePlugin文件


package demo.asm.plugin

import org.gradle.api.Plugin
import org.gradle.api.Project

public class LifeCyclePlugin implements Plugin {

@Override
void apply(Project target) {
println("hello this is my plugin")
}
}

LifeCyclePlugin实现了Plugin接口,但我们在app中使用此插件的时候,LifeCyclePlugin的apply插件会被调用。


接着创建properties文件:
首先在main下面创建resources/META-INF/gradle-plugins目录,然后在gradle-plugins中创建demo.asm.lifecycle.properties,并填入如下内容:


implementation-class=demo.asm.plugin.LifeCyclePlugin

其中文件名demo.asm.lifecycle就是我们插件的名称,后续我们需要在app的build.gradle文件中引用此插件。
好了,现在我们的插件已经写完了,我们把他部署到本地仓库中来测试一下。发布地址在上述build.grale文件中repository属性配置。我将其配置在asm_lifecycle_repo目录中。


我们在 Android Studio 的右边栏找到 Gradle 中点击 uploadArchives,执行 plugin 的部署任务,构建成功后,本地会出现一个repo目录,就是我们自定义的插件。


333.png


我们测试一下demo.asm.lifecycle。


首先在项目根目录的build.gradle文件中添加


buildscript {
ext.kotlin_version = '1.4.32'
repositories {
google()
mavenCentral()
maven { url 'asm_lifecycle_repo' } //需要添加的内容
}
dependencies {
classpath "com.android.tools.build:gradle:3.5.4"
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.4.32"
classpath 'demo.asm.plugin:asm_lifecycle_plugin:1.0.0' //需要添加的内容

}
}

然后在app的build.gradle中添加


id 'demo.asm.lifecycle'

然后我们执行命令./gradlew clean assembleDebug,可以看到hello this is my plugin 正确输出,说明我们自定义的gradle插件可以使用。


444.png


然后我们来自定义transform,来遍历.class文件
这部分功能主要依赖 Transform API。


4.自定义transform


什么是 Transform ?


Transform 可以被看作是 Gradle 在编译项目时的一个 task,在 .class 文件转换成 .dex 的流程中会执行这些 task,对所有的 .class 文件(可包括第三方库的 .class)进行转换,转换的逻辑定义在 Transform 的 transform 方法中。实际上平时我们在 build.gradle 中常用的功能都是通过 Transform 实现的,比如混淆(proguard)、分包(multi-dex)、jar 包合并(jarMerge)。
创建LifeCycleTransfrom文件,内容如下:


package demo.asm.plugin

import com.android.build.api.transform.QualifiedContent
import com.android.build.api.transform.Transform
import com.android.build.api.transform.TransformException
import com.android.build.api.transform.TransformInput
import com.android.build.api.transform.TransformInvocation
import com.android.build.gradle.internal.pipeline.TransformManager
import joptsimple.internal.Classes

/**
* Transform 主要作用是检索项目编译过程中的所有文件。通过这几个方法,我们可以对自定义 Transform 设置一些遍历规则,
*/

public class LifeCycleTransform extends Transform {

/**
* 设置我们自定义的 Transform 对应的 Task 名称。Gradle 在编译的时候,会将这个名称显示在控制台上。
* 比如:Task :app:transformClassesWithXXXForDebug。
* @return
*/

@Override
String getName() {
return "LifeCycleTransform"
}
/**
* 在项目中会有各种各样格式的文件,通过 getInputType 可以设置 LifeCycleTransform 接收的文件类型,
* 此方法返回的类型是 Set
集合。
* 此方法有俩种取值
* 1.CLASSES:代表只检索 .class 文件;
* 2.RESOURCES:代表检索 java 标准资源文件。
* @return
*/

@Override
Set getInputTypes() {
return TransformManager.CONTENT_CLASS
}
/**
* 这个方法规定自定义 Transform 检索的范围,具体有以下几种取值:
* EXTERNAL LIBRARIES 只有外部库
* PROJECT 只有项目内容
* PROJECT LOCAL DEPS 只有项目的本地依赖(本地jar )
* PROVIDED ONLY 只提供本地或远程依赖项
* SUB PROJECTS 只有子项目。
* SUB PROJECTS LOCAL DEPS 只有子项目的本地依赖项(本地jar)。
* TESTED CODE 由当前变量(包括依赖项)测试的代码
* @return
*/

@Override
Set getScopes() {
return TransformManager.PROJECT_ONLY
}
/**
* isIncremental() 表示当前 Transform 是否支持增量编译,我们不需要增量编译,所以直接返回 false 即可。
* @return
*/

@Override
boolean isIncremental() {
return false
}
/**
* 最重要的方法,在这个方法中,可以获取到俩个数据的流向
* inputs:inputs 中是传过来的输入流,其中有两种格式,一种是 jar 包格式,一种是 directory(目录格式)。
* outputProvider:outputProvider 获取到输出目录,最后将修改的文件复制到输出目录,这一步必须做,否则编译会报错。
*
* @param transformInvocation
* @throws TransformException* @throws InterruptedException* @throws IOException
*/

@Override
void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
Collection tis = transformInvocation.inputs
tis.forEach(ti -> {
ti.directoryInputs.each {
File file = it.file
if (file)
{
file.traverse {
println("find class:" + it.name)
}
}
}
})

}
}

然后将我们将自定义的transform注册到我们定义好的plugin中,LifeCyclePlugin代码修改如下:


package demo.asm.plugin

import com.android.build.gradle.AppExtension
import org.gradle.api.Plugin
import org.gradle.api.Project

public class LifeCyclePlugin implements Plugin {

@Override
void apply(Project target) {
println("hello this is my plugin")
def android = target.extensions.getByType(AppExtension)
println "======register transform ========"
LifeCycleTransform transform = new LifeCycleTransform()
android.registerTransform(transform)

}
}

然后再次执行./gradlew clean assembleDebug,可以看到项目中所有的.class文件都被输出了


555.png


5.使用 ASM,插入字节码到 Activity 文件


ASM 是一套开源框架,其中几个常用的 API 如下:


ClassReader:负责解析 .class 文件中的字节码,并将所有字节码传递给 ClassWriter。


ClassVisitor:负责访问 .class 文件中各个元素,还记得上一课时我们介绍的 .class 文件结构吗?ClassVisitor 就是用来解析这些文件结构的,当解析到某些特定结构时(比如类变量、方法),它会自动调用内部相应的 FieldVisitor 或者 MethodVisitor 的方法,进一步解析或者修改 .class 文件内容。


ClassWriter:继承自 ClassVisitor,它是生成字节码的工具类,负责将修改后的字节码输出为 byte 数组。


添加 ASM 依赖
在asm_demo_plugin的build.gradle中添加asm依赖


dependencies {
implementation gradleApi()
implementation localGroovy()
implementation 'com.android.tools.build:gradle:3.5.4'
implementation 'org.ow2.asm:asm:8.0.1'//需要添加的依赖
implementation 'org.ow2.asm:asm-commons:8.0.1'//需要添加的依赖
}

在main/java下面创建包 demo/asm/asm目录并添加如下代码:


import org.objectweb.asm.ClassVisitor;
import org.objectweb.asm.MethodVisitor;
import org.objectweb.asm.Opcodes;


/**
* Created by zhangzhenrui
*/


public class LifeCycleClassVisitor extends ClassVisitor {

private String className = "";
private String superName = "";

public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) {
super.visit(version, access, name, signature, superName, interfaces);
this.className = name;
this.superName = superName;
}


public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) {
System.out.println("classVisitor methodName" + name + ",supername" + superName);
MethodVisitor mv = cv.visitMethod(access, name, descriptor, signature, exceptions);
if (superName.equals("android/support/v7/app/AppCompatActivity")) {
if (name.equals("onCreate")) {
return new LifeCycleMethodVisitor(className, name, mv);
}
}
return mv;
}


public void visitEnd() {
super.visitEnd();
}

public LifeCycleClassVisitor(ClassVisitor classVisitor) {
super(Opcodes.ASM5, classVisitor);
}
}


import org.objectweb.asm.MethodVisitor;
import org.objectweb.asm.Opcodes;

/**
* Created by zhangzhenrui
*/


class LifeCycleMethodVisitor extends MethodVisitor {
private String className;
private String methodName;

public LifeCycleMethodVisitor(String className, String methodName, MethodVisitor methodVisitor) {
super(Opcodes.ASM5, methodVisitor);
this.className = className;
this.methodName = methodName;
}

public void visitCode() {
super.visitCode();
System.out.println("methodVistor visitorCode");
mv.visitLdcInsn("TAG");
mv.visitLdcInsn(className + "------>" + methodName);
mv.visitMethodInsn(Opcodes.INVOKESTATIC, "android/util/Log", "e", "(Ljava/lang/String;Ljava/lang/String;)I", false);
mv.visitInsn(Opcodes.POP);

}

}

然后修改LifeCycleTransformtransform函数如下:


@Override
void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
Collection transformInputs = transformInvocation.inputs
TransformOutputProvider outputProvider = transformInvocation.outputProvider
transformInputs.each { TransformInput transformInput ->
transformInput.directoryInputs.each { DirectoryInput directoryInput ->
File file = directoryInput.file
if (file)
{
file.traverse(type: FileType.FILES, namefilter: ~/.*.class/) { File item ->
ClassReader classReader = new ClassReader(item.bytes)
if (classReader.itemCount != 0) {
System.out.println("find class:" + item.name + "classReader.length:" + classReader.getItemCount())
ClassWriter classWriter = new ClassWriter(classReader, ClassWriter.COMPUTE_MAXS)
ClassVisitor classVisitor = new LifeCycleClassVisitor(classWriter)
classReader.accept(classVisitor, ClassReader.EXPAND_FRAMES)
byte[] bytes = classWriter.toByteArray()
FileOutputStream outputStream = new FileOutputStream(item.path)
outputStream.write(bytes)
outputStream.close()
}
}
}
def dest = outputProvider.getContentLocation(directoryInput.name, directoryInput.contentTypes, directoryInput.scopes, Format.DIRECTORY)
FileUtils.copyDirectory(directoryInput.file, dest)
}

}
}

重新部署我们的插件后,重新运行主项目,可以看到:


MainActivity------>onCreate

但是我们没有在MainActivity中写一行代码,这样就实现了动态注入日志的功能


5.总结


本篇文章主要讲述了在Android中使用asm动态操作字节码的流程,其中涉及到的技术点有



  • 自定义gradle插件

  • transform的使用

  • asm的使用

收起阅读 »

swift 苹果登录

iOS
- 苹果登录的前期工作: - 1.开发者账号中增加苹果登录的选项- 2.xcode中配置苹果登录 //swift版本的代码逻辑 //头文件 import AuthenticationServices //按钮加载 苹果登录 对于按钮有一定的要求,具体查看...
继续阅读 »
苹果登录
项目中继承第三方登录时,需增加上苹果登录即可上架
苹果登录需要iOS系统 13以上支持
详细的内容阅读苹果官方的网址
url:https://developer.apple.com/documentation/authenticationservices/implementing_user_authentication_with_sign_in_with_apple
- 苹果登录的前期工作:
- 1.开发者账号中增加苹果登录的选项


1.1  可能会造成证书无法使用,重新编辑一下保存下载即可!


- 2.xcode中配置苹果登录


前期的配置基本上完成
剩下的就是代码逻辑
- 3.代码中增加苹果登录的逻辑
//swift版本的代码逻辑
//头文件
import AuthenticationServices

//按钮加载 苹果登录 对于按钮有一定的要求,具体查看上方的连接
// 此处使用了一个临时的
if #available(iOS 13.0, *) {
let authorizationButton = ASAuthorizationAppleIDButton()
authorizationButton.frame = CGRect(x: (KScreenWidth - 300) / 2, y: kScreenHeight - 50, width: 300, height: 30)
authorizationButton.addTarget(self, action: #selector(handleAuthorizationAppleIDButtonPress), for: .touchUpInside)
self.view.addSubview(authorizationButton)
} else {
// Fallback on earlier versions
}


//MARK: 点击苹果登陆按钮
@objc
func handleAuthorizationAppleIDButtonPress() {

if #available(iOS 13.0, *) {
/**
- 点击 苹果登录的按钮跳出苹果登录的界面
- 跳转出系统界面
*/

let appleIDProvider = ASAuthorizationAppleIDProvider()
let request = appleIDProvider.createRequest()
request.requestedScopes = [.fullName, .email]

let authorizationController = ASAuthorizationController(authorizationRequests: [request])
authorizationController.delegate = self
authorizationController.presentationContextProvider = self as? ASAuthorizationControllerPresentationContextProviding
authorizationController.performRequests()

} else {
// Fallback on earlier versions
}

}

//MARK: - 授权成功
@available(iOS 13.0, *)
func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) {
if #available(iOS 13.0, *) {
switch authorization.credential {
case let appleIDCredential as ASAuthorizationAppleIDCredential:
/**
- 首次注册 能够那去到的参数分别是:
1. user
2.state
3.authorizedScopes
4.authorizationCode
5.identityToken
6.email
7.fullName
8.realUserStatus
*/

// Create an account in your system.
let userIdentifier = appleIDCredential.user
let fullName = appleIDCredential.fullName
let email = appleIDCredential.email
let code = appleIDCredential.authorizationCode
// For the purpose of this demo app, store the `userIdentifier` in the keychain.
self.saveUserInKeychain(userIdentifier)

// For the purpose of this demo app, show the Apple ID credential information in the `ResultViewController`.
self.showResultViewController(userIdentifier: userIdentifier, fullName: fullName, email: email)
BPLog.lmhInfo("userID:\(userIdentifier),fullName:\(fullName),userEmail:\(email),code:\(code)")
case let passwordCredential as ASPasswordCredential:

// Sign in using an existing iCloud Keychain credential.
let username = passwordCredential.user
let password = passwordCredential.password

// For the purpose of this demo app, show the password credential as an alert.
DispatchQueue.main.async {
self.showPasswordCredentialAlert(username: username, password: password)
}

default:
break
}
} else {
// Fallback on earlier versions
}
}
收起阅读 »

快速掌握 Performance 性能分析:一个真实的优化案例

这么强大的工具肯定是要好好掌握的,今天我们就来做一个性能优化的案例来快速上手 Performance 吧。首先,我们准备这样一段代码:<html lang="en"><head>    <meta charse...
继续阅读 »

Chrome Devtools 的 Performance 工具是性能分析和优化的利器,因为它可以记录每一段代码的耗时,进而分析出性能瓶颈,然后做针对性的优化。

这么强大的工具肯定是要好好掌握的,今天我们就来做一个性能优化的案例来快速上手 Performance 吧。

性能分析

首先,我们准备这样一段代码:


<html lang="en">
<head>
   <meta charset="UTF-8">
   <title>worker performance optimizationtitle>
head>
<body>
   <script>
       function a() {
          b();
      }
       function b() {
           let total = 0;
           for(let i = 0; i< 10*10000*10000; i++) {
               total += i;
          }
           console.log('b:', total);
      }

       a();
   script>
   <script>
       function c() {
           d();
      }
       function d() {
           let total = 0;
           for(let i = 0; i< 1*10000*10000; i++) {
               total += i;
          }
           console.log('c:', total);
      }
       c();
   script>
body>
html>

很明显,两个 script 标签是两个宏任务,第一个宏任务的调用栈是 a、b,第二个宏任务的调用栈是 c、d。

我们用 Performance 来看一下是不是这样:

首先用无痕模式打开 chrome,无痕模式下没有插件,分析性能不会受插件影响。

打开 chrome devtools 的 Performance 面板,点击 reload 按钮,会重新加载页面并开始记录耗时:

过几秒点击结束。

这时候界面就会展示出记录的信息:

图中标出的 Main 就是主线程。

主线程是不断执行 Event Loop 的,可以看到有两个 Task(宏任务),调用栈分别是 a、b 和 c、d,和我们分析的对上了。(当然,还有一些浏览器内部的函数,比如 parseHtml、evaluateScript 等,这些可以忽略)

Performance 工具最重要的是分析主线程的 Event Loop,分析每个 Task 的耗时、调用栈等信息。

当你点击某个宏任务的时候,在下面的面板会显示调用栈的详情(选择 bottom-up 是列表展示, call tree 是树形展示)

每个函数的耗时也都显示在左侧,右侧有源码地址,点击就可以跳到 Sources 对应的代码。

直接展示了每行代码的耗时,太方便了!

工具介绍完了,我们来分析下代码哪里有性能问题。

很明显, b 和 d 两个函数的循环累加耗时太高了。

在 Performance 中也可以看到 Task 被标红了,下面的 summary 面板也显示了 long task 的警告。

有同学可能会问:为什么要优化 long task 呢?

因为渲染和 JS 执行都在主线程,在一个 Event Loop 中,会相互阻塞,如果 JS 有长时间执行的 Task,就会阻塞渲染,导致页面卡顿。所以,性能分析主要的目的是找到 long task,之后消除它。

可能很多同学都不知道,其实网页的渲染也是一个宏任务,所以才会和 JS 执行互相阻塞。关于这一点的证明可以看我前面一篇文章:

通过 Performance 证明,网页的渲染是一个宏任务

找到了要优化的代码,也知道了优化的目标(消除 long task),那么就开始优化吧。

性能优化

我们优化的目标是把两个 long task 中的耗时逻辑(循环累加)给去掉或者拆分成多个 task。

关于拆分 task 这点,可以参考 React 从递归渲染 vdom 转为链表的可打断的渲染 vdom 的优化,也就是 fiber 的架构,它的目的也是为了拆分 long task。

但明显我们这里的逻辑没啥好拆分的,它就是一个大循环。

那么能不能不放在主线程跑,放到其他线程跑呢?浏览器的 web worker 好像就是做耗时计算的性能优化的。

我们来试一下:

封装这样一个函数,传入 url 和数字,函数会创建一个 worker 线程,通过 postMessage 传递 num 过去,并且监听 message 事件来接收返回的数据。

function runWorker(url, num) {
   return new Promise((resolve, reject) => {
       const worker = new Worker(url);
       worker.postMessage(num);
       worker.addEventListener('message', function (evt) {
           resolve(evt.data);
      });
       worker.onerror = reject;
  });
};

然后 b 和 c 函数就可以改成这样了:

function b() {
   runWorker('./worker.js', 10*10000*10000).then(res => {
       console.log('b:', res);
  });
}

耗时逻辑移到了 worker 线程:

addEventListener('message', function(evt) {
   let total = 0;
   let num = evt.data;
   for(let i = 0; i< num; i++) {
       total += i;
  }
   postMessage(total);
});

完美。我们再跑一下试试:

哇,long task 一个都没有了!

然后你还会发现 Main 线程下面多了两个 Worker 线程:

虽然 Worker 还有 long task,但是不重要,毕竟计算量在那,只要主线程没有 long task 就行。

这样,我们通过把计算量拆分到 worker 线程,充分利用了多核 cpu 的能力,解决了主线程的 long task 问题,界面交互会很流畅。

我们再看下 Sources 面板:

对比下之前的:

这优化力度,肉眼可见!

就这样,我们一起完成了一次网页的性能优化,通过 Peformance 分析出 long task,定位到耗时代码,然后通过 worker 拆分计算量进行优化,成功消除了主线程的 long task。

代码传到了 github,感兴趣的可以拉下来用 Performance 工具分析下:

github.com/QuarkGluonP…

总结

Chrome Devtools 的 Performance 工具是网页性能分析的利器,它可以记录一段时间内的代码执行情况,比如 Main 线程的 Event Loop、每个 Event loop 的 Task,每个 Task 的调用栈,每个函数的耗时等,还可以定位到 Sources 中的源码位置。

性能优化的目标就是找到 Task 中的 long task,然后消除它。因为网页的渲染是一个宏任务,和 JS 的宏任务在同一个 Event Loop 中,是相互阻塞的。

我们做了一个真实的优化案例,通过 Performance 分析出了代码中的耗时部分,发现是计算量大导致的,所以我们把计算逻辑拆分到了 worker 线程以充分利用多核 cpu 的并行处理能力,消除了主线程的 long task。

做完这个性能优化的案例之后,是不是觉得 Peformance 工具用起来也不难呢?

其实会分析主线程的 Event Loop,会分析 Task 和 Task 的调用栈,找出 long task,并能定位到耗时的代码,Performance 工具就算是掌握了大部分了,常用的功能也就是这些。


作者:zxg_神说要有光
来源:https://juejin.cn/post/7046805217668497445

收起阅读 »

从0到1带你深入理解log4j2漏洞

0x01前言从Apache Log4j2 漏洞影响面查询的统计来看,影响多达60644个开源软件,涉及相关版本软件包更是达到了321094个。而本次漏洞的触发方式简单,利用成本极低,可以说是一场java生态的‘浩劫’。本文将从零到一带你深入了解log4j2漏洞...
继续阅读 »



0x01前言

最近IT圈被爆出的log4j2漏洞闹的沸沸扬扬,log4j2作为一个优秀的java程序日志监控组件,被应用在了各种各样的衍生框架中,同时也是作为目前java全生态中的基础组件之一,这类组件一旦崩塌将造成不可估量的影响。

Apache Log4j2 漏洞影响面查询的统计来看,影响多达60644个开源软件,涉及相关版本软件包更是达到了321094个。而本次漏洞的触发方式简单,利用成本极低,可以说是一场java生态的‘浩劫’。本文将从零到一带你深入了解log4j2漏洞。知其所以然,方可深刻理解、有的放矢。

0x02 Java日志体系

要了解认识log4j2,就不得讲讲java的日志体系,在最早的2001年之前,java是不存在日志库的,打印日志均通过System.outSystem.err来进行,缺点也显而易见,列举如下:

大量IO操作;

无法合理控制输出,并且输出内容不能保存,需要盯守;

无法定制日志格式,不能细粒度显示;

在2001年,软件开发者Ceki Gulcu设计出了一套日志库也就是log4j(注意这里没有2)。后来log4j成为了Apache的项目,作者也加入了Apache组织。这里有一个小插曲,Apache组织建议过sun公司在标准库中引入log4j,但是sun公司可能有自己的小心思,所以就拒绝了建议并在JDK1.4中推出了自己的借鉴版本JUL(Java Util Logging)。不过功能还是不如Log4j强大。使用范围也很小。

由于出现了两个日志库,为了方便开发者进行选择使用,Apache推出了日志门面JCL(Jakarta Commons Logging)。它提供了一个日志抽象层,在运行时动态的绑定日志实现组件来工作(如log4j、java.util.logging)。导入哪个就绑定哪个,不需要再修改配置。当然如果没导入的话他自己内部有一个Simple logger的简单实现,但是功能很弱,直接忽略。架构如下图:

pic_746f57fc.png

在2006年,log4j的作者Ceki Gulcu离开了Apache组织后觉得JCL不好用,于是自己开发了一版和其功能相似的Slf4j(Simple Logging Facade for Java)。Slf4j需要使用桥接包来和日志实现组件建立关系。由于Slf4j每次使用都需要配合桥接包,作者又写出了Logback日志标准库作为Slf4j接口的默认实现。其实根本原因还是在于log4j此时无法满足要求了。以下是桥接架构图:

pic_a29fa3d8.png

到了2012年,Apache可能看不要下去要被反超了,于是就推出了新项目Log4j2并且不兼容Log4j,全面借鉴Slf4j+Logback。此次借鉴比较成功。

Log4j2不仅仅具有Logback的所有特性,还做了分离设计,分为log4j-api和log4j-core,log4j-api是日志接口,log4j-core是日志标准库,并且Apache也为Log4j2提供了各种桥接包

到目前为止Java日志体系被划分为两大阵营,分别是Apache阵营和Ceki阵营。

pic_b911ab38.png

0x03 Log4j2源码浅析

Log4j2是Apache的一个开源项目,通过使用Log4j2,我们可以控制日志信息输送的目的地是控制台、文件、GUI组件,甚至是套接口服务器、NT的事件记录器、UNIX Syslog守护进程等;我们也可以控制每一条日志的输出格式;通过定义每一条日志信息的级别,我们能够更加细致地控制日志的生成过程。最令人感兴趣的就是,这些可以通过一个配置文件来灵活地进行配置,而不需要修改应用的代码。

从上面的解释中我们可以看到Log4j2的功能十分强大,这里会简单分析其与漏洞相关联部分的源码实现,来更熟悉Log4j2的漏洞产生原因。

我们使用maven来引入相关组件的2.14.0版本,在工程的pom.xml下添加如下配置,他会导入两个jar包



  org.apache.logging.log4j
  log4j-core
  2.14.0

pic_e9a036b2.png

在工程目录resources下创建log4j2.xml配置文件






 

     
 


 
     
 

log4j2中包含两个关键组件LogManagerLoggerContextLogManager是Log4J2启动的入口,可以初始化对应的LoggerContextLoggerContext会对配置文件进行解析等其它操作。

在不使用slf4j的情况下常见的Log4J用法是从LogManager中获取Logger接口的一个实例,并调用该接口上的方法。运行下列代码查看打印结果

import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;

public class log4j2Rce2 {
private static final Logger logger = LogManager.getLogger(log4j2Rce2.class);
public static void main(String[] args) {
  String a="${java:os}";
  logger.error(a);
}
}

pic_aafbf0e7.png

属性占位符之Interpolator(插值器)

log4j2中环境变量键值对被封装为了StrLookup对象。这些变量的值可以通过属性占位符来引用,格式为:${prefix:key}。在Interpolator(插值器)内部以Map的方式则封装了多个StrLookup对象,如下图显示:

pic_d1985627.png

详细信息可以查看官方文档。这些实现类存在于org.apache.logging.log4j.core.lookup包下。

当参数占位符${prefix:key}带有prefix前缀时,Interpolator会从指定prefix对应的StrLookup实例中进行key查询。当参数占位符${key}没有prefix时,Interpolator则会从默认查找器中进行查询。如使用${jndi:key}时,将会调用JndiLookuplookup方法使用jndi(javax.naming)获取value。如下图演示。

pic_cb5dc772.png

模式布局

log4j2支持通过配置Layout打印格式化的指定形式日志,可以在Appenders的后面附加Layouts来完成这个功能。常用之一有PatternLayout,也就是我们在配置文件中PatternLayout字段所指定的属性pattern的值%d{yyyy-MM-dd HH:mm:ss.SSS} [%t] %level %logger{36} - %msg%n%msg表示所输出的消息,其它格式化字符所表示的意义可以查看官方文档

pic_d98d5967.png

PatternLayout模式布局会通过PatternProcessor模式解析器,对模式字符串进行解析,得到一个List转换器列表和List格式信息列表。

在配置文件PatternLayout标签的pattern属性中我们可以看到类似%d的写法,d代表一个转换器名称,log4j2会通过PluginManager收集所有类别为Converter的插件,同时分析插件类上的@ConverterKeys注解,获取转换器名称,并建立名称到插件实例的映射关系,当PatternParser识别到转换器名称的时候,会查找映射。相关转换器名称注解和加载的插件实例如下图所示:

pic_9b3ba45d.png

pic_d17431d4.png

本次漏洞关键在于转换器名称msg对应的插件实例MessagePatternConverter对于日志中的消息内容处理存在问题,在大多数场景下这部分是攻击者可控的。MessagePatternConverter会将日志中的消息内容为${prefix:key}格式的字符串进行解析转换,读取环境变量。此时为jndi的方式的话,就存在漏洞。

日志级别

log4j2支持多种日志级别,通过日志级别我们可以将日志信息进行分类,在合适的地方输出对应的日志。哪些信息需要输出,哪些信息不需要输出,只需在一个日志输出控制文件中稍加修改即可。级别由高到低共分为6个:fatal(致命的), error, warn, info, debug, trace(堆栈)。log4j2还定义了一个内置的标准级别intLevel,由数值表示,级别越高数值越小。

当日志级别(调用)大于等于系统设置的intLevel的时候,log4j2才会启用日志打印。在存在配置文件的时候 ,会读取配置文件中值设置intLevel。当然我们也可以通过Configurator.setLevel("当前类名", Level.INFO);来手动设置。如果没有配置文件也没有指定则会默认使用Error级别,也就是200,如下图中的处理:

pic_a35d88a0.png

0x04 漏洞原理

首先先来看一下网络上流传最多的payload

${jndi:ldap://2lnhn2.ceye.io}

而触发漏洞的方法,大家都是以Logger.error()方法来进行演示,那这里我们也采用同样的方式来讲解,具体漏洞环境代码如下所示

import org.apache.logging.log4j.Level;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.apache.logging.log4j.core.config.Configurator;

public class Log4jTEst {

public static void main(String[] args) {

  Logger logger = LogManager.getLogger(Log4jTEst.class);

  logger.error("${jndi:ldap://2lnhn2.ceye.io}");

}
}

直击漏洞本源,将断点断在org/apache/logging/log4j/core/appender/AbstractOutputStreamAppender.java中的directEncodeEvent方法上,该方法的第一行代码将返回当前使用的布局,并调用 对应布局处理器的encode方法。log4j2默认缺省布局使用的是PatternLayout,如下图所示:

pic_cd90f5ec.png

继续跟进在encode中会调用toText方法,根据注释该方法的作用为创建指定日志事件的文本表示形式,并将其写入指定的StringBuilder中。

pic_bf1e021d.png

pic_3dec5458.png

接下来会调用serializer.toSerializable,并在这个方法中调用不同的Converter来处理传入的数据,如下图所示,

pic_5556a236.png

这里整理了一下调用的Converter

org.apache.logging.log4j.core.pattern.DatePatternConverter
org.apache.logging.log4j.core.pattern.LiteralPatternConverter
org.apache.logging.log4j.core.pattern.ThreadNamePatternConverter
org.apache.logging.log4j.core.pattern.LevelPatternConverter
org.apache.logging.log4j.core.pattern.LoggerPatternConverter
org.apache.logging.log4j.core.pattern.MessagePatternConverter
org.apache.logging.log4j.core.pattern.LineSeparatorPatternConverter
org.apache.logging.log4j.core.pattern.ExtendedThrowablePatternConverter

这么多Converter都将一个个通过上图中的for循环对日志事件进行处理,当调用到MessagePatternConverter时,我们跟入MessagePatternConverter.format()方法中一探究竟

pic_da4aa8d9.png

在MessagePatternConverter.format()方法中对日志消息进行格式化,其中很明显的看到有针对字符"KaTeX parse error: Expected ‘}’, got ‘EOF’ at end of input: …连着判断,等同于判断是否存在"{",这三行代码中关键点在于最后一行

pic_8703a6a5.png

这里我圈了几个重点,有助于理解Log4j2 为什么会用JndiLookup,它究竟想要做什么。此时的workingBuilder是一个StringBuilder对象,该对象存放的字符串如下所示

09:54:48.329 [main] ERROR com.Test.log4j.Log4jTEst - ${jndi:ldap://2lnhn2.ceye.io}

本来这段字符串的长度是82,但是却给它改成了53,为什么呢?因为第五十三的位置就是$符号,也就是说${jndi:ldap://2lnhn2.ceye.io}这段不要了,从第53位开始append。而append的内容是什么呢?

可以看到传入的参数是config.getStrSubstitutor().replace(event, value)的执行结果,其中的value就是${jndi:ldap://2lnhn2.ceye.io}这段字符串。replace的作用简单来说就是想要进行一个替换,我们继续跟进

pic_b85b66f9.png

经过一段的嵌套调用,来到Interpolator.lookup,这里会通过var.indexOf(PREFIX_SEPARATOR)判断":"的位置,其后截取之前的字符。截取到jndi然后就会获取针对jndi的Strlookup对象并调用Strlookup的lookup方法,如下图所示

pic_e0add034.png

那么总共有多少Strlookup的子类对象可供选择呢,可供调用的Strlookup都存放在当前Interpolator类的strLookupMap属性中,如下所示

pic_9cb4d7c7.png

然后程序的继续执行就会来到JndiLookup的lookup方法中,并调用jndiManager.lookup方法,如下图所示

pic_11229b48.png

说到这里,我们已经详细了解了logger.error()造成RCE的原理,那么问题就来了,logger有很多方法,除了error以外还别方法可以触发漏洞么?这里就要提到Log4j2的日志优先级问题,每个优先级对应一个数值intLevel记录在StandardLevel这个枚举类型中,数值越小优先级越高。如下图所示:

pic_7621e8c9.png

当我们执行Logger.error的时候,会调用Logger.logIfEnabled方法进行一个判断,而判断的依据就是这个日志优先级的数值大小

pic_9bb0024c.png

pic_618bede7.png

跟进isEnabled方法发现,只有当前日志优先级数值小于Log4j2的200的时候,程序才会继续往下走,如下所示

pic_a1749651.png

而这里日志优先级数值小于等于200的就只有"error"、“fatal”,这两个,所以logger.fatal()方法也可触发漏洞。但是"warn"、"info"大于200的就触发不了了。

但是这里也说了是默认情况下,日志优先级是以error为准,Log4j2的缺省配置文件如下所示。





 




 


所以只需要做一点简单的修改,将中的error改成一个优先级比较低的,例如"info"这样,只要日志优先级高于或者等于info的就可以触发漏洞,修改过后如下所示



 
     
         
     
 
 
     
         
     
 

关于Jndi部分的远程类加载利用可以参考实验室往常的文章:Java反序列化过程中 RMI JRMP 以及JNDI多种利用方式详解JAVA JNDI注入知识详解

0x05 敏感数据带外

当目标服务器本身受到防护设备流量监控等原因,无法反弹shell的时候,Log4j2还可以通过修改payload,来外带一些敏感信息到dnslog服务器上,这里简单举一个例子,根据Apache Log4j2官方提供的信息,获取环境变量信息除了jndi之外还有很多的选择可供使用,具体可查看前文给出的链接。根据文档中所述,我们可以用下面的方式来记录当前登录的用户名,如下所示



  %d %p %c{1.} [%t] $${env:USER} %m%n

获取java运行时版本,jvm版本,和操作系统版本,如下所示



  %d %m%n

类似的操作还有很多,感兴趣的同学可以去阅读下官方文档。

那么问题来了,如何将这些信息外带出去,这个时候就还要利用我们的dnsLog了,就像在sql注入中通过dnslog外带信息一样,payload改成以下形式

"${jndi:ldap://${java:os}.2lnhn2.ceye.io}"

从表上看这个payload执行原理也不难,肯定是log4j2 递归解析了呗,为了严谨一下,就再废话一下log4j2解析这个payload的执行流程

首先还是来到MessagePatternConverter.format方法,然后是调用StrSubstitutor.replace方法进行字符串处理,如下图所示

pic_7f40016f.png

只不过这次迭代处理先处理了"${java:os}",如下图所示

pic_5e142fc5.png

如此一来,就来到了JavaLookup.lookup方法中,并根据传入的参数来获取指定的值

pic_827aa514.png

解析完成后然后log4j2才会去解析外层的${jndi:ldap://2lnhn2.ceye.io},最后请求的dnslog地址如下

pic_da9f36b2.png

此时就实现了将敏感信息回显到dnslog上,利用的就是log4j2的递归解析,来dnslog上查看一下回显效果,如下所示

pic_3a438ebd.png

但是这种回显的数据是有限制的,例如下面这种情况,使用如下payload

${jndi:ldap://${java:os}.2lnhn2.ceye.io}

执行完成后请求的地址如下

pic_af42e0db.png

最后会报如下错误,并且无法回显

pic_dfbae9c6.png

0x06 2.15.0 rc1绕过详解

在Apache log4j2漏洞大肆传播的当天,log4j2官方发布的rc1补丁就传出的被绕过的消息,于是第一时间也跟着研究究竟是怎么绕过的,分析完后发现,这个“绕过”属实是一言难尽,下面就针对这个绕过来解释一下为何一言难尽。

首先最重要的一点,就是需要修改配置,默认配置下是不能触发JNDI远程加载的,单就这个条件来说我觉得就很勉强了,但是确实更改了配置后就可以触发漏洞,所以这究竟算不算绕过,还要看各位同学自己的看法了。

首先在这次补丁中MessagePatternConverter类进行了大改,可以看下修改前后MessagePatternConverter这个类的结构对比

修改前

pic_8dc15d3c.png

修改后

pic_ede62086.png

可以很清楚的看到 增加了三个静态内部类,每个内部类都继承自MessagePatternConverter,且都实现了自己的format方法。之前执行链上的MessagePatternConverter.format()方法则变成了下面这样

pic_fe3e7fff.png

在rc1这个版本中Log4j2在初始化的时候创建的Converter也变了,

pic_81292a90.png

整理一下,可以看的更清晰一些

DatePatternConverter
SimpleLiteralPatternConverter$StringValue
ThreadNamePatternConverter
LevelPatternConverter$SimpleLevelPatternConverter
LoggerPatternConverter
MessagePatternConverter$SimpleMessagePatternConverter
LineSeparatorPatternConverter
ExtendedThrowablePatternConverter

之前的MessagePatternConverter,变成了现在的MessagePatternConverter$SimpleMessagePatternConverter,那么这个SimpleMessagePatternConverter的方法究竟是怎么实现的,如下所示

pic_f57ea3ad.png

可以看到并没有对传入的数据的“KaTeX parse error: Expected ‘}’, got ‘EOF’ at end of input: …的点就没有了么?当然不是,对“{}”的处理,开发者将其转移到了LookupMessagePatternConverter.format()方法中,如下所示

pic_e424c988.png

问题来了,如何才能让log4j2在初始化的时候就实例化LookupMessagePatternConverter从而能让程序在后续的执行过程中调用它的format方法呢?

其实很简单,但这也是我说这个绕过“一言难尽”的一个点,就是要修改配置文件,修改成如下所示在“%msg”的后面添加一个“{lookups}”,我相信一般情况下应该没有那个开发者会这么改配置文件玩,除非他真的需要log4j2提供的jndi lookup功能,修改后的配置文件如下所示



 
     
         
     
 
 
     
         
     
 

这样一来就可以触发LookupMessagePatternConverter.format()方法了,但是单单只改配置,还是不行,因为JndiManager.lookup方法也进行了修改,增加了白名单校验,这就意味着我们还要修改payload来绕过这么一个校验,校验点代码如下所示

pic_aa23e755.png

当判断以ldap开头的时候,就回去判断请求的host,也就是请求的地址,白名单内容如下所示

pic_d138eeeb.png

可以看到白名单里要么是本机地址,要么是内网地址,fe80开头的ipv6地址也是内网地址,看似想要绕过有些困难,因为都是内网地址,没法请求放在公网的ldap服务,不过不用着急,继续往下看。

使用marshalsec开启ldap服务后,先将payload修改成下面这样

${jndi:ldap://127.0.0.1:8088/ExportObject}

如此一来就可以绕过第一道校验,过了这个host校验后,还有一个校验,在JndiManager.lookup方法中,会将请求ldap服务后 ldap返回的信息以map的形式存储,如下所示

pic_b9989af6.png

这里要求javaFactory为空,否则就会返回"Referenceable class is not allowed for xxxxxx"的错误,想要绕过这一点其实也很简单,在JndiManager.lookup方法中有一个非常非常离谱的错误,就是在捕获异常后没有进行返回,甚至没有进行任何操作,我看不懂,但我大为震撼。这样导致了程序还会继续向下执行,从而走到最后的this.context.lookup()这一步 ,如下所示

pic_83144ad8.png

也就是说只要让lookup方法在执行的时候抛个异常就可以了,将payload修改成以下的形式

${jndi:ldap://xxx.xxx.xxx.xxx:xxxx/ ExportObject}

在url中“/”后加上一个空格,就会导致lookup方法中一开始实例化URI对象的时候报错,这样不仅可以绕过第二道校验,连第一个针对host的校验也可以绕过,从而再次造成RCE。在rc2中,catch错误之后,return null,也就走不到lookup方法里了。

0x07 修复&临时建议

在最新的修复https://github.com/apache/logging-log4j2/commit/44569090f1cf1e92c711fb96dfd18cd7dccc72ea中,在初始化插值器时新增了检查jndi协议是否启用的判断,并且默认禁用了jndi协议的使用。

pic_696b7067.png

pic_fa5ec99a.png

修复建议:

升级Apache Log4j2所有相关应用到最新版。

升级JDK版本,建议JDK使用11.0.1、8u191、7u201、6u211及以上的高版本。但仍有绕过Java本身对Jndi远程加载类安全限制的风险。

临时建议:

jvm中添加参数 -Dlog4j2.formatMsgNoLookups=true (版本>=2.10.0)

新建log4j2.component.properties文件,其中加上配置log4j2.formatMsgNoLookups=true (版本>=2.10.0)

设置系统环境变量:LOG4J_FORMAT_MSG_NO_LOOKUPS=true (版本>=2.10.0)

对于log4j2 < 2.10以下的版本,可以通过移除JndiLookup类的方式。

0x08 时间线

2021年11月24日:阿里云安全团队向Apache 官方提交ApacheLog4j2远程代码执行漏洞(CVE-2021-44228)

2021年12月8日:Apache Log4j2官方发布安全更新log4j2-2.15.0-rc1,

2021年12月9日:天融信阿尔法实验室晚间监测到poc大量传播并被利用攻击

2021年12月10日:天融信阿尔法实验室于10日凌晨发布Apache Log4j2 远程代码执行漏洞预警,并于当日发布Apache Log4j2 漏洞处置方案

2021年12月10日:同一天内,网络传出log4j2-2.15.0-rc1安全更新被绕过,天融信阿尔法实验室第一时间进行验证,发现绕过存在,并将处置方案内的升级方案修改为log4j2-2.15.0-rc2

2021年12月15日:天融信阿尔法实验室对该漏洞进行了深入分析并更新修复建议。

0x09 总结

log4j2这次漏洞的影响是核弹级的,堪称web漏洞届的永恒之蓝,因为作为一个日志系统,有太多的开发者使用,也有太多的开源项目将其作为默认日志系统。所以可以见到,在未来的几年内,Apache log4j2 很可能会接替Shiro的位置,作为护网的主要突破点。

该漏洞的原理并不复杂,甚至如果认真读了官方文档可能就可以发现这个漏洞,因为这次的漏洞究其原理就是log4j2所提供的正常功能,但是不管是log4j2的开发者也好,还是使用log4j2进行开发的开发者也好,他们都犯了一个致命的错误,就是相信了用户的输入。

永远不要相信用户的输入,想必这是每一个开发人员都听过的一句话,可惜,真正能做到的人太少了。对于开源软件的生态安全,也需要相关企业和组织加以关注和共同建设,安全之路任重而道远。
作者:kali_Ma
来源:https://blog.csdn.net/kali_Ma/article/details/122178627

收起阅读 »

vue+elementui项目中,页面实现自适应,缩小放大页面排版基本保持不变

问题描述:vue+elementui项目中,页面实现自适应,缩小放大页面排版基本保持不变# 解决方案:第一步:最外层div样式 :fixed(固定定位):生成绝对定位的元素,相对于浏览器窗口进行定位。元素的位置通过 “left”, “top”, “right”...
继续阅读 »

问题描述:
vue+elementui项目中,页面实现自适应,缩小放大页面排版基本保持不变

# 解决方案:
第一步:最外层div样式 :
fixed(固定定位):生成绝对定位的元素,相对于浏览器窗口进行定位。元素的位置通过 “left”, “top”, “right” 以及 “bottom” 属性进行规定
display:flex 是一种布局方式。它即可以应用于容器中,也可以应用于行内元素。是W3C提出的一种新的方案,可以简便、完整、响应式地实现各种页面布局。目前,它已经得到了所有浏览器的支持。
width: 100%,height: 100%:实现页面宽高在不同窗口下都能占满整个屏幕。

.websit{undefined
position: fixed;
display: flex;
width: 100%;
height: 100%;
}


第二步:整体页面样式分三部分,分别是页面头部的:header-two,内容部分:main,页面底部的footer,
其中,头部和底部高度是不变的,中间内容部分的高度=页面高度-头部高度-底部高度,如下
给页面最外层div设置高度:自动获取当前浏览器高度,页面初始化的时候自动获取:

header-two {undefined
padding: 0;(内边距为0)
width: 100%;(宽度自动占满)
text-align: center;(内容居中显示)
height: 80px !important;(设置固定高度)
}
.footer {undefined
padding: 0;
width: 100%;
text-align: center;
height: 126px !important;
}
:style="{minHeight :minHeight +‘px’}"
mounted() {undefined
this.minHeight = document.documentElement.clientHeight - 0;
this.marginLeft = (document.documentElement.clientWidth - 1920) / 2;
const that = this;
window.onresize = function () {undefined
that.minHeight = document.documentElement.clientHeight - 0;
that.marginLeft = (document.documentElement.clientWidth - 1920) / 2
};
}


第三步:
这里header-two 下面还要加一个div,header-div,并为其设置项目要求的最小宽度,和最大宽度,这里设置为1920,保证缩放时的样式正常,同理,底部也要加上一个div,footer-div。
header-div,footer-div{undefined
margin: auto;
text-align: center;
min-width: 1920px !important;
max-width: 1920px !important;
}
第四步:为header-div和footer-div,设置向左偏移:style="{marginLeft:marginLeft + ‘px’ }"
第五步:中间内容过多时,会产生滚动弄条,我们想让滚动条产生在最外层,也就是,中间元素被撑开,因此设置属性
.main {undefined
overflow: visible;
}
A元素具有 overflow: visible 的属性,内层内容比较多时,分两种情况讨论
A元素高度auto:无作用,A元素撑开,正常滚动
A元素具有固定高度:虽然A限制的高度,但内层内容并不会隐藏,而是完全显示在屏幕上,参与布局,甚至撑开外层dom高度
第六步:涉及背景是图片,图片实现自适应,如下

header-first {undefined
padding: 0;
width: 100%;
text-align: center;
background-repeat: no-repeat;
height: 292px !important;
background-image: url(’…/aa.png’);
background-size: cover; /* 使图片平铺满整个浏览器(从宽和高的最大需求方面来满足,会使某些部分无法显示在区域中) */
代码如下:
export default {undefined
name: ‘ContainerMoudle’,
components: {Footer, WebsitHeaderTwo},
data() {undefined
return {undefined
minHeight: 0,
marginLeft: 0
}
},
mounted() {undefined
this.minHeight = document.documentElement.clientHeight - 0;
this.marginLeft = (document.documentElement.clientWidth - 1920) / 2;
const that = this;
window.onresize = function () {undefined
that.minHeight = document.documentElement.clientHeight - 0;
that.marginLeft = (document.documentElement.clientWidth - 1920) / 2
};
},
methods: {}
}


————————————————
原文链接:https://blog.csdn.net/weixin_44039043/article/details/109393574

收起阅读 »

node-sass的坑

国内做前端的,我感觉大多被这个坑过,所有的依赖都装的上,唯有这个依赖怎么都装不上。 首先第一个需要面对的问题,其实这个依赖装不上最大的原因是他在编译安装时需要下载一个安装包,这个安装包是在github上的,由于不可说的原因,国内连github的资源服务器raw...
继续阅读 »

国内做前端的,我感觉大多被这个坑过,所有的依赖都装的上,唯有这个依赖怎么都装不上。


首先第一个需要面对的问题,其实这个依赖装不上最大的原因是他在编译安装时需要下载一个安装包,这个安装包是在github上的,由于不可说的原因,国内连github的资源服务器rawgithubusercontent是很难连接的,这也直接导致了依赖无法安装。


解法1:
淘宝镜像,也是最直接的解法,淘宝镜像中的node-sass中的安装包地址指向已经被改成了淘宝镜像中的安装包地址,安装会很顺利。
这也是官方给出的对于网络问题的解法。


npm install -g mirror-config-china --registry=http://registry.npm.taobao.org
npm install node-sass

同样的--registry或者cnpm都适合用这个方法解。


解法2:
有些人可能因为团队使用package-lock.json来规范统一团队使用的依赖,可能就没法直接通过镜像的方式来下载淘宝镜像里的包了,但是这样也是有解决办法的,手动指定node-sass使用的安装包地址。
通过npmrc


npm config set sass_binary_site "https://npm.taobao.org/mirrors/node-sass/"

也可以设置环境变量:


set SASS_BINARY_SITE=https://npm.taobao.org/mirrors/node-sass/

这样直接通过npm install时候也可以单独使用淘宝的镜像来下载安装包。


解法3:
可以指定下载路径,那自然也可以先把安装包下载下来,再指定安装包进行安装。
先去https://github.com/sass/node-sass/releases/tag/{version}或者https://npm.taobao.org/mirrors/node-sass/下下载对应的安装包:{os}-{module-version}_bingding.node,具体的version根据你的nodejs版本查询下表可得。



接下来的步骤和解法2类似,这里给出npmrc的改法


npm config set sass_binary_path [path]

解法4:
既然主要原因是功夫网,那就只能翻过去了。
先设置系统代理,然后配置npm的代理。


npm config set proxy [system proxy]

完成下载以后问题只解决了一半,下载下来的安装包还需要编译,node-sass需要一些编译环境来确保编译完成,这里有个简单的方案:


npm install -g node-gyp
npm install --global --production windows-build-tools

这个会帮你安装对应的编译环境,问题大部分都能解决,这里面包含了vs的build工具以及python。


这里有个隐藏的小坑也是关于node-gyp的,如果你的node-gyp不是全局安装,而是一个在package-lock.json中的依赖,最好检查一下node-gyp的版本,就比如说我,node-gyp被限制在了一个老的版本上,那么最大的问题就是node-gyp本身去调用的build工具的版本是由他决定的,一些较老版本的node-gyp肯定并不支持更高版本的build工具,对应的支持可以在node-gyp的MSVSVersion.py中的version_map中找到,就拿我的3.8.0举例:


  version_map = {
'auto': ('14.0', '12.0', '10.0', '9.0', '8.0', '11.0'),
'2005': ('8.0',),
'2005e': ('8.0',),
'2008': ('9.0',),
'2008e': ('9.0',),
'2010': ('10.0',),
'2010e': ('10.0',),
'2012': ('11.0',),
'2012e': ('11.0',),
'2013': ('12.0',),
'2013e': ('12.0',),
'2015': ('14.0',),
}

可以看到这里最高仅仅支持到vs2015,所以我自己手动安装了vs2015的build工具最后才能成功编译。
这里也可以手动指定msvs_version来build工具的版本,不过大部分情况下auto就够用了。


npm config set msvs_version [version]

做到这里,执行npm install大概率就没有问题了,如果有问题的话,欢迎将输出的error log分享出来看看还有没有其他隐藏的坑在里面。


作者:Sczlog
链接:https://juejin.cn/post/6914193505061453838

收起阅读 »

SwiftUI版通知栏应用开发(4) ——多语言本地化适配

iOS
开发多语言版本的 APP,估计是大家希望的,尤其对于 iOS/Mac APP 的开发,上线 App Store 多希望在其它地区也能使用,所以今天主要想学习怎么基于 SwiftUI 做一些文本和字符串文字多语言化。相信市面上不少这样的文章可供参考Project...
继续阅读 »

开发多语言版本的 APP,估计是大家希望的,尤其对于 iOS/Mac APP 的开发,上线 App Store 多希望在其它地区也能使用,所以今天主要想学习怎么基于 SwiftUI 做一些文本和字符串文字多语言化。

相信市面上不少这样的文章可供参考

Project 配置

首先,在 Project Info 选项中,选择 Localization 增加一个「中文」本地化配置,如果你需要其他语言,可以相对应的添加:

接下来,新建对应的 Group 文件夹,如本文的两个 Groupen.lprojzh-Hans.lproj

在这两个 Group 里,同时创建同名的 Strings 文件:Localizable

代码编写

创建完成之后,我们分别创建一个 Preferences demo:

在我们的主 View 上引入变量 locale

popOver.contentViewController?.view = NSHostingView(rootView: MainView().environment(\.locale, .init(identifier: "en")))

然后创建一个 Text

Text("Preferences")
.font(.customf(22))
.padding(.bottom, 10.0)

刚开始我们定义的是 en,执行看看效果:

如果我们改为 zh-Hans

MainView().environment(\.locale, .init(identifier: "zh-Hans"))

结果也就不一样了:

Locale 变化功能

基本功能实现了,接下来就是设置一个开关来变化 Locale 了。

首先,创建一个 Picker

Section(header: Text("localization")) {
Picker("", selection: $localeViewModel.localeString) {
ForEach(LocaleStrings.allCases) { localeString in
Text(localeString.rawValue)
.tag(localeString.suggestedLocalication)
}
}
.pickerStyle(SegmentedPickerStyle())
}

其中,我定义两个 enum 来做选择的类型:

enum Localication: String, CaseIterable, Identifiable {
case zh_Hans = "zh-Hans"
case en = "en"
var id: String { self.rawValue }
}

enum LocaleStrings: String, CaseIterable, Identifiable {
case zh_Hans = "中文"
case en = "English"
var id: String { self.rawValue }
}

extension LocaleStrings {
var suggestedLocalication: Localication {
switch self {
case .zh_Hans: return .zh_Hans
case .en: return .en
}
}
}

这个好理解,因为显示的 String 和提供给 locale 的字符串不一致,如显示的是「中文」,提供给 locale 的是 zh-Hans,这里我借助 suggestedLocalication 做桥梁转换。

最后,我们创建一个 Combine ViewModel 变量 localeString,以供实时变化改变本地化字符串内容。

class LocaleViewModel: ObservableObject {
@Published var localeString: Localication
}

最后,只需要在具体的 View 里加入 ViewModel 订阅变量 localeString 的更新:

// ContentView.swift

import SwiftUI

struct ContentView: View {
@ObservedObject private var timerViewModel: TimerViewModel
@ObservedObject private var localeViewModel: LocaleViewModel
init(timerViewModel: TimerViewModel, localeViewModel: LocaleViewModel) {
self.timerViewModel = timerViewModel
self.localeViewModel = localeViewModel
}

var body: some View {
Text("localization")
.font(.customf(14))
.padding()
.environment(\.locale, .init(identifier: localeViewModel.localeString.rawValue))
}
}

好了,代码编写完毕,我们运行看效果:

localechange2

小结

基本跑通本地化多语言适配流程,接下来就是不断增加新的语言和翻译工作。

未完待续

收起阅读 »

[译] SwiftUI 2 应用生命周期的终极指导

原文地址:The Ultimate Guide to the SwiftUI 2 Application Life Cycle原文作者:Peter Friese译文出自:掘金翻译计划本文永久链接:github.com/xitu/gold-m…译者:zhuzil...
继续阅读 »

在很长一段时间里,iOS 开发者们都是使用 AppDelegate 作为应用的主要入口。随着 SwiftUI 2 在 WWDC 2020 上发布,苹果公司引入了一个新的应用生命周期。新的生命周期几乎(几乎)完全与 AppDelegate 无关,为类 DSL 方法铺平了道路。

在本文中,我会讨论引入新的生命周期的原因,以及你该如何在已有的应用或新的应用中使用它。

指定应用入口

我们的第一个问题是,该如何告诉编译器哪里是应用的入口呢?SE-0281 详述了**基于类型的程序入口(Type-Based Program Entry Points)**的工作方式:

Swift 编译器将识别标注了 @main 属性的类型为程序的入口。标有 @main 的类型有一个隐式要求:类型内部需要声明一个静态 main() 方法。

创建新的 SwiftUI 应用时,应用的主类(main class)如下所示:

import SwiftUI

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

那么 SE-0281 提到的静态 main() 函数在哪儿呢?

实际上,框架可以(并且应该)为用户提供方便的默认实现。你会从上面的代码片段注意到 SwiftUIAppLifeCycleApp 遵循 App 协议。对于 App 协议,苹果提供了如下协议扩展:

@available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *)
extension App {

/// 初始化并运行应用。
///
/// 如果你在你的 ``SwiftUI/App`` 的实现类(conformer)的声明前加上了
/// [@main](https://docs.swift.org/swift-book/ReferenceManual/Attributes.html#ID626)
/// 属性,系统会调用这个实现类的 `main()` 方法来启动应用。
/// SwiftUI 提供了该方法的默认实现,从而能以适合平台的方式处理应用启动流程。
public static func main()
}

这下你就懂了吧 —— 这个协议扩展提供了处理应用启动的默认的实现。

由于 SwiftUI 框架不是开源的,所以我们看不到苹果是如何实现此功能的,但是 Swift Argument Parser 是开源的,并且也用了这个办法。查看 ParsableCommand 的源码,就能了解它是如何用协议扩展来提供静态 main 函数的默认实现,并将其用作程序入口的:

extension ParsableCommand {
...
public static func main(_ arguments: [String]?) {
do {
var command = try parseAsRoot(arguments)
try command.run()
} catch {
exit(withError: error)
}
}

public static func main() {
self.main(nil)
}
}

如果上述这些听起来有点复杂,好消息是实际上在创建新的 SwiftUI 应用程序时你不必关心它:只需确保在 Life Cycle 下拉菜单中选择 SwiftUI App 来创建你的应用程序就行了:

创建一个新的 SwiftUI 项目

让我们来看一些常见的情况。

初始化资源 / 你最喜欢的 SDK 或框架

大多数应用程序需要在启动时执行这些步骤:获取一些配置值,连接数据库或者初始化框架或第三方 SDK。

通常,您可以在 ApplicationDelegate 的 application(_:didFinishLaunchingWithOptions:) 方法中进行这些操作。由于已经没有应用委托了,我们需要找到其他方法来初始化我们的应用程序。根据您的特定需求,有以下策略:

  • 为你的主类实现一个构造函数(initializer)(详见文档
  • 为存储属性设置初始值(详见文档
  • 用闭包设置属性的默认值(详见文档
@main
struct ColorsApp: App {
init() {
print("Colors application is starting up. App initialiser.")
}

var body: some Scene {
WindowGroup {
ContentView()
}
}

如果上述几种策略都无法满足你的需求,你可能还是需要一个 AppDelegate。后文会介绍如果能在应用中加入一个 AppDelegate。

处理你的应用的生命周期

了解你的应用程序处于哪种状态有时很有用。例如,你可能希望应用处于活动状态时立即获取新数据,或者在应用程序变为非活动状态并转换到后台后清除所有缓存。

通常,您可以在你的 ApplicationDelegate 上实现 applicationDidBecomeActiveapplicationWillResignActive 或 applicationDidEnterBackground

从 iOS 14.0 起,苹果提供了新的 API,该 API 允许以更优雅,更易维护的方式跟踪应用程序状态:[ScenePhase](https://developer.apple.com/documentation/swiftui/scenephase)。你的项目可以有多个场景(scene),不过有时只有一个场景。这些场景将由 [WindowGroup](https://developer.apple.com/documentation/swiftui/windowgroup) 展示。

SwiftUI 追踪环境中场景的状态,你可以使用 @Environment 属性包装器来获取 scenePhase 的值,然后使用 onChange(of:) modifier 来监听该值的变化:

@main
struct SwiftUIAppLifeCycleApp: App {
@Environment(\.scenePhase) var scenePhase

var body: some Scene {
WindowGroup {
ContentView()
}
.onChange(of: scenePhase) { newScenePhase in
switch newScenePhase {
case .active:
print("App is active")
case .inactive:
print("App is inactive")
case .background:
print("App is in background")
@unknown default:
print("Oh - interesting: I received an unexpected new value.")
}
}
}
}

值得注意的是,你可以从应用中的其他位置读取该值。当在应用的顶层读取该值时(如上面的代码片段所示),你将获得应用程序中所有阶段(phase)的汇总。.inactive 表示你应用中的所有场景均未激活。当在视图中读取 scenePhase 时,你将收到包含该视图的阶段值。请记住,你的应用程序在在同一时刻可能包含在不同阶段的多个场景。想了解有关场景阶段的更多详细信息,请阅读苹果的[文档](developer.apple.com/documentati…

处理深层链接(Deeplink)

之前,在处理深层链接时,你需要实现 application(_:open:options:),并将传入的 URL 转给最合适的处理程序。

新的应用生命周期模型可以更容易地处理深层链接。在最顶层的场景上添加 onOpenURL 就可以处理传入的 URL 了:

@main
struct SwiftUIAppLifeCycleApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.onOpenURL { url in
print("Received URL: \(url)")
}
}
}
}

真正酷的是:你可以在整个应用程序中装上多个 URL 处理程序 —— 让进行深层链接变得很轻松,因为你可以在最合适的位置处理传入的链接。

可能的话,你应该使用 universal links(或者 Firebase Dynamic Links,它使用了 universal links for iOS apps),因为它们使用了关联域(associated domain)来创建网站和你的应用之间的链接 —— 这会让你可以安全地共享数据。

不过,你仍可以使用自定义 URL scheme 来链接应用内部的内容。

无论哪种方式,触发应用中的深层链接的一种简单方法是在开发计算机上使用以下命令:

xcrun simctl openurl booted <your url>

Demo: Opening deep links and continuing user activities

继续用户 activity

如果你的应用使用 NSUserActivity 来集成 Siri、Handoff 或 Spotlight,你需要处理用户继续进行的 activity。

同样,新的应用生命周期模型通过提供两个 modifier 使你更容易实现这一点。这些 modifier 使你可以声明 activity 并让用户可以继续进行它们。

下面是一个展现如何声明 activity 的代码片段。在一个具体的视图里:

struct ColorDetailsView: View {
var color: String

var body: some View {
Image(color)
// ...
.userActivity("showColor") { activity in
activity.title = color
activity.isEligibleForSearch = true
activity.isEligibleForPrediction = true
// ...
}
}
}

为了允许继续进行这个 activity,你可以在最顶层的导航视图中注册 onContinueUserActivity 闭包,如下所示:

import SwiftUI

struct ContentView: View {
var colors = ["Red", "Green", "Yellow", "Blue", "Pink", "Purple"]

@State var selectedColor: String? = nil

var body: some View {
NavigationView {
ScrollView {
LazyVGrid(columns: columns) {
ForEach(colors, id: \.self) { color in
NavigationLink(destination: ColorDetailsView(color: color),
tag: color,
selection: $selectedColor) {
Image(color)
}
}
}
.onContinueUserActivity("showColor") { userActivity in
if let color = userActivity.userInfo?["colorName"] as? String {
selectedColor = color
}
}
}
}
}
}

请帮帮我 —— 上述的那些对我都不管用!

新的应用声明周期(截止当前)并非支持 AppDelegate 的所有回调函数。如果上述这些都不满足你的需求,你可能还是需要一个 AppDelegate

另一个需要 AppDelegate 的原因是你使用的第三方 SDK 会使用 method swizzling 来把它们注入应用生命周期。Firebase 就是一个典型的例子

为了帮助上述情况中的你摆脱困境,Swift 提供了一种将 AppDelegate 的一个实现类与你的 App 实现相连接的方法:@UIApplicationDelegateAdaptor。使用方法如下:

class AppDelegate: NSObject, UIApplicationDelegate {
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
print("Colors application is starting up. ApplicationDelegate didFinishLaunchingWithOptions.")
return true
}
}

@main
struct ColorsApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) var delegate

var body: some Scene {
WindowGroup {
ContentView()
}
}
}

如果你是在复制现有的 AppDelegate 实现,不要忘记删除 @main 属性 —— 不然,编译器该向你抱怨存在多个应用入口了。

总结

至此,让我们讨论一下苹果为什么要进行这些改变。我觉得有以下的几个原因:

SE-0281 explicitly states that one of the design goals was “to offer a more general purpose and lightweight mechanism for delegating a program’s entry point to a designated type.”

苹果选择的基于 DSL 来处理应用生命周期的方法和 SwiftUI 的声明式 UI 搭建方法相契合。两者采用相同的概念可以更方便新加入的开发者们理解。

声明式方法的主要好处是:框架/平台将替代开发者承受实现特定功能的负担。如果需要进行任何更改,这种模式可以在不破坏许多开发人员的应用的情况下进行发布,这也使发布更改变得更容易 —— 理想情况下,开发人员无需更改其实现,因为框架将把一切都搞定。

总体而言,新的应用生命周期模型使实现应用程序的启动更加简单。你的代码将变得更加简洁,更易于维护 —— 要我说,这总是一件好事。

我希望本文能帮你了解新的应用生命周期的来龙去脉。如果你有关于本文的任何疑问或评论,欢迎在 Twitter 上关注并私信我,或者在 GitHub 上的样例项目中提 issue。

感谢你的阅读!

扩展阅读

想了解更多,请查看下面的这些资料:

如果发现译文存在错误或其他需要改进的地方,欢迎到 掘金翻译计划 对译文进行修改并 PR,也可获得相应奖励积分。文章开头的 本文永久链接 即为本文在 GitHub 上的 MarkDown 链接。


掘金翻译计划 是一个翻译优质互联网技术文章的社区,文章来源为 掘金 上的英文分享文章。内容覆盖 AndroidiOS前端后端区块链产品设计人工智能等领域,想要查看更多优质译文请持续关注 掘金翻译计划官方微博知乎专栏

收起阅读 »

SwiftUI 实现侧滑菜单 Side Menu

SwiftUI 实现侧滑菜单 Side Menu 效果 代码 代码里都有相关注释 源码 github 链接:gist.github.com/RandyWei/05… // // ContentView.swift // SiderMenuDemo01 ...
继续阅读 »

SwiftUI 实现侧滑菜单 Side Menu


效果


iShot2021-09-08 09.43.45.gif


代码


代码里都有相关注释


源码 github 链接:gist.github.com/RandyWei/05…



//
// ContentView.swift
// SiderMenuDemo01
//
// Created by RandyWei on 2021/9/7.
//

import SwiftUI

struct ContentView: View {

//划动偏移量
@GestureState var offset:CGFloat = 0

//滑动应该停留在某个点
//停留点: 屏幕宽度的3/5
let maxOffset:CGFloat = UIScreen.main.bounds.width * 3 / 5

//滑动展开之后的 offset
@State var expandOffset:CGFloat = 0

//回弹点:最大停留点/2
private var springOffset:CGFloat{
maxOffset / 2
}
//缩放比例,默认是1
@State private var scaleRatio:CGFloat = 1

//最小 可缩放值
let minScale:CGFloat = 0.9


private var dragGesture: some Gesture {
DragGesture()
.updating($offset, body: { value, out, _ in
//判断是否反向滑动,如果是展开状态需要反向滑动
if value.translation.width >= 0 || expandOffset != 0 {
out = value.translation.width
}
})
.onChanged { value in
//为了顺畅给缩放增加过渡
if value.translation.width >= 0 {
//对缩放比例进行计算:缩放值 = 划动比例 * 可缩放值(1-minScale)
//因为是往小了缩,所以是1-缩放值
scaleRatio = 1 - (value.translation.width / maxOffset) * (1 - minScale)
} else {
//反向value.translation.width是负数 ,所以+maxOffset变为正值
scaleRatio = 1 - ((maxOffset + value.translation.width) / maxOffset) * (1 - minScale)
}
}
.onEnded { value in
//需要判断滑动是否超过某个点来决定是重置还是停留
if value.translation.width >= springOffset {
expandOffset = maxOffset
//停止后,缩小 到0.9
scaleRatio = minScale
} else {
expandOffset = 0
scaleRatio = 1
}
}
}

var body: some View {

ZStack{

//侧边菜单层
SideMenuView()

//功能区域
FeatureView()
.offset(x: offset + expandOffset)
.scaleEffect(scaleRatio)
.animation(.easeInOut(duration: 0.05))
.gesture(dragGesture)


}

}
}

struct FeatureView:View {

var body: some View{

GeometryReader{proxy in
VStack{
HStack{
Image(systemName: "list.dash")
.resizable()
.frame(width: 20, height: 20, alignment: .center)

Text("功能区域")
.font(.title)

Spacer()
}

ScrollView(.vertical, showsIndicators: false, content: {

VStack{

ForEach(0..<50){_ in

HStack{

Image(systemName: "person")
.resizable()
.frame(width: 80, height: 80, alignment: .center)

VStack(alignment: .leading){
Text("titletitletitletitletitle")
.font(.title)

Spacer()

Text("bodybodybodybodybodybody")
.font(.body)
}

}

}.redacted(reason: .placeholder)
}

})
}
.padding(.horizontal)
.padding(.top, 8 + proxy.safeAreaInsets.top)
.frame(maxWidth:.infinity,maxHeight: .infinity,alignment: .topLeading)
.background(Color.white)
.cornerRadius(30)
.shadow(radius: 10)
.ignoresSafeArea()
}

}
}

struct SideMenuView:View {
var body: some View{

GeometryReader{proxy in
VStack(alignment:.leading){
//祖传头像
Image("avatar")
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 100, height: 100, alignment: .center)
.clipShape(Circle())

Text("韦爵爷")
.font(.title)

Text("这个人很懒,什么都没留下")

//菜单

HStack{
Image(systemName: "archivebox")
Text("菜单一")
}
.padding(.top)

HStack{
Image(systemName: "note.text")
Text("菜单二")
}
.padding(.top)


HStack{
Image(systemName: "gearshape")
Text("个人设置")
}
.padding(.top)

Spacer()

HStack{
Image(systemName: "signature")
Text("退出登录")
}
.padding(.top)

}
.foregroundColor(.white)
.padding(.horizontal)
.padding(.top, 8 + proxy.safeAreaInsets.top)
.padding(.bottom, 8 + proxy.safeAreaInsets.bottom)
.frame(maxWidth:.infinity,maxHeight: .infinity,alignment: .topLeading)
.background(Color.orange)
.ignoresSafeArea()
}

}
}

struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}

相关视频

Swift UI侧滑菜单Side Menu-哔哩哔哩


作者:RandyWei
链接:https://juejin.cn/post/7005374220360220702

收起阅读 »

聊聊 Combine 和 async/await 之间的合作

iOS
在 Xcode 13.2 中,苹果完成了 async/await 的向前部署(Back-deploying)工作,将最低的系统要求降低到了 iOS 13(macOS Catalina),这一举动鼓舞了越来越多的人开始尝试使用 async/await 进行开发。...
继续阅读 »

在 Xcode 13.2 中,苹果完成了 async/await 的向前部署(Back-deploying)工作,将最低的系统要求降低到了 iOS 13(macOS Catalina),这一举动鼓舞了越来越多的人开始尝试使用 async/await 进行开发。当大家在接触了异步序列(AsyncSequence)后,会发现它同 Combine 的表现有些接近,尤其结合近两年 Combine 框架几乎没有什么变化,不少人都提出了疑问:苹果是否打算使用 AsyncSequence 和 AsyncStream 替代 Combine。

恰巧我在最近的开发中碰到了一个可能需要结合 Combine 和 async/await 的使用场景,通过本文来聊聊 Combine 和 async/await 它们之间各自的优势、是否可以合作以及如何合作等问题。

原文发表在我的博客 wwww.fatbobman.com

欢迎订阅我的公共号:【肘子的Swift记事本】

需要解决的问题

在最近的开发中,我碰到了这样一个需求:

  • 在 app 的生命周期中,会不定期的产生一系列事件,事件的发生频率不定、产生的途径不定
  • 对每个事件的处理都需要消耗不小的系统资源,且需要调用系统提供的 async/await 版本的 API
  • app 对事件的处理结果时效性要求不高
  • 需要限制事件处理的系统消耗,避免同时处理多个事件
  • 不考虑使用 GCD 或 OperationQueue

对上述的需求稍加分析,很快就可以确立解决问题的方向:

  • Combine 在观察和接收事件方面表现的非常出色,应该是解决需求第一点的不二人选
  • 在解决方案中必然会使用到 async/await 的编程模式

需要解决的问题就只剩下两个:

  • 如何将事件处理串行化(必须处理完一个事件后才能处理下一个事件)
  • 如何将 Combine 和 async/await 结合使用

Combine 和 AsyncSequence 之间的比较

由于 Combine 同 AsyncSequence 之间存在不少相似之处,有不少开发者会认为 AsyncSequence 可能取代 Combine,例如:

  • 两者都允许通过异步的方式处理未来的值
  • 两者都允许开发者使用例如 map、flatMap 等函数对值进行操作
  • 当发生错误时,两者都会结束数据流

但事实上,它们之间还是有相当的区别。

事件的观察与接收

Combine 是为响应式编程而生的工具,从名称上就可以看出,它非常擅长将不同的事件流进行变形和合并,生成新的事件流。Combine 关注于对变化的响应。当一个属性发生变化,一个用户点击了按钮,或者通过 NotificationCenter 发送了一个通知,开发者都可以通过 Combine 提供了的内置工具做出及时处理。

通过 Combine 提供的 Subject(PassthroughSubject、CurrentValueSubject),开发者可以非常方便的向数据流中注入值,当你的代码是以命令式风格编写的时候,Subject 就尤为显得有价值。

在 async/await 中,通过 AsyncSequence,我们可以观察并接收网络流、文件、Notification 等方面的数据,但相较于 Combine,仍缺乏数据绑定以及类似 Subject 的数据注入能力。

在对事件的观察与接收方面,Combine 占有较大优势。

关于数据处理、变形的能力

仅从用于数据处理、变形的方法数量上来看,AsyncSequence 相较 Combine 还是有不小的差距。但 AsyncSequence 也提供了一些 Combine 尚未提供,且非常实用的方法和变量,例如:characters、lines 等。

由于侧重点不同,即使随着时间的推移两者增加了更多的内置方法,在数据处理和变形方面也不会趋于一致,更大的可能性是不断地在各自擅长的领域进行扩展。

错误处理方式

在 Combine 中,明确地规定了错误值 Failure 的类型,在数据处理链条中,除了要求 Output 数据值类型一致外,还要求错误值的类型也要相互匹配。为了实现这一目标,Combine 提供了大量的用于处理错误类型的操作方法,例如:mapError、setFailureType、retry 等。

使用上述方法处理错误,可以获得编译器级别的保证优势,但在另一方面,对于一个逻辑复杂的数据处理链,上述的错误处理方式也将导致代码的可读性显著下降,对开发者在错误处理方面的掌握要求也比较高。

async/await 则采用了开发者最为熟悉的 throw-catch 方式来进行错误处理。基本没有学习难度,代码也更符合大多数人的阅读习惯。

两者在错误处理上功能没有太大区别,主要体现在处理风格不同。

生命周期的管理

在 Combine 中,从订阅开始,到取消订阅,开发者通过代码可以对数据链的生命周期做清晰的定义。当使用 AsyncSequence 时,异步序列生命周期的表述则没有那么的明确。

调度与组织

在 Combine 中,开发者不仅可以通过指定调度器(scheduler),显式地组织异步事件的行为和地点,而且 Combine 还提供了控制管道数量、调整处理频率等多维度的处理手段。

AsyncSequence 则缺乏对于数据流的处理地点、频率、并发数量等控制能力。

下文中,我们将尝试解决前文中提出的需求,每个解决方案均采用了 Combine + async/await 融合的方式。

方案一

在 Combine 中,可以使用两种手段来限制数据的并发处理能力,一种是通过设定 flatMap 的 maxPublishers,另一种则是通过自定义 Subscriber。本方案中,我们将采用 flatMap 的方式来将事件的处理串行化。

在 Combine 中调用异步 API,目前官方提供的方法是将上游数据包装成 Future Publisher,并通过 flatMap 进行切换。

在方案一中,通过将 flatMap、Deferred(确保只有在订阅后 Future 才执行)、Future 结合到一起,创建一个新的 Operator,以实现我们的需求。

public extension Publisher {
func task<T>(maxPublishers: Subscribers.Demand = .unlimited,
_ transform: @escaping (Output) async -> T) -> Publishers.FlatMap<Deferred<Future<T, Never>>, Self> {
flatMap(maxPublishers: maxPublishers) { value in
Deferred {
Future { promise in
Task {
let output = await transform(value)
promise(.success(output))
}
}
}
}
}
}

public extension Publisher where Self.Failure == Never {
func emptySink() -> AnyCancellable {
sink(receiveValue: { _ in })
}
}

鉴于篇幅,完整的代码(支持 Error、SetFailureType)版本,请访问 Gist,本方案的代码参考了 Sundell 的 文章

使用方法如下:

var cancellables = Set<AnyCancellable>()

func asyncPrint(value: String) async {
print("hello \(value)")
try? await Task.sleep(nanoseconds: 1000000000)
}

["abc","sdg","353"].publisher
.task(maxPublishers:.max(1)){ value in
await asyncPrint(value:value)
}
.emptySink()
.store(in: &cancellables)
// Output
// hello abc
// 等待 1 秒
// hello sdg
// 等待 1 秒
// hello 353

假如将将上述代码中的["abc","sdg","353"].publisher更换成 PassthoughSubject 或 Notification ,会出现数据遗漏的情况。这个状况是因为我们限制了数据的并行处理数量,从而导致数据的消耗时间超过了数据的生成时间。需要在 Publisher 的后面添加 buffer,对数据进行缓冲。

let publisher = PassthroughSubject<String, Never>()
publisher
.buffer(size: 10, prefetch: .keepFull, whenFull: .dropOldest) // 缓存数量和策略根据业务的具体情况确定
.task(maxPublishers: .max(1)) { value in
await asyncPrint(value:value)
}
.emptySink()
.store(in: &cancellables)

publisher.send("fat")
publisher.send("bob")
publisher.send("man")

方案二

在方案二中,我们将采用的自定义 Subscriber 的方式来限制并行处理的数量,并尝试在 Subscriber 中调用 async/await 方法。

创建自定义 Subscriber:

extension Subscribers {
public class OneByOneSink<Input, Failure: Error>: Subscriber, Cancellable {
let receiveValue: (Input) -> Void
let receiveCompletion: (Subscribers.Completion<Failure>) -> Void

var subscription: Subscription?

public init(receiveCompletion: @escaping (Subscribers.Completion<Failure>) -> Void,
receiveValue: @escaping (Input) -> Void) {
self.receiveCompletion = receiveCompletion
self.receiveValue = receiveValue
}

public func receive(subscription: Subscription) {
self.subscription = subscription
subscription.request(.max(1)) // 订阅时申请数据量
}

public func receive(_ input: Input) -> Subscribers.Demand {
receiveValue(input)
return .max(1) // 数据处理结束后,再此申请的数据量
}

public func receive(completion: Subscribers.Completion<Failure>) {
receiveCompletion(completion)
}

public func cancel() {
subscription?.cancel()
subscription = nil
}
}
}

receive(subscription: Subscription)中,使用subscription.request(.max(1))设定了订阅者订阅时请求的数据量,在receive(_ input: Input)中,使用return .max(1)设定了每次执行完receiveValue方法后请求的数据量。通过上述方式,我们创建了一个每次申请一个值,逐个处理的订阅者。

但当我们在receiveValue方法中使用 Task 调用 async/await 代码时会发现,由于没有提供回调机制,订阅者将无视异步代码执行完成与否,调用后直接会申请下一个值,这与我们的需求不符。

在 Subscriber 中可以通过多种方式来实现回调机制,例如回调方法、Notification、@Published 等。下面的代码中我们使用 Notification 进行回调通知。

public extension Subscribers {
class OneByOneSink<Input, Failure: Error>: Subscriber, Cancellable {
let receiveValue: (Input) -> Void
let receiveCompletion: (Subscribers.Completion<Failure>) -> Void

var subscription: Subscription?
var cancellable: AnyCancellable?

public init(notificationName: Notification.Name,
receiveCompletion: @escaping (Subscribers.Completion<Failure>) -> Void,
receiveValue: @escaping (Input) -> Void) {
self.receiveCompletion = receiveCompletion
self.receiveValue = receiveValue
cancellable = NotificationCenter.default.publisher(for: notificationName, object: nil)
.sink(receiveValue: { [weak self] _ in self?.resume() })
// 在收到回调通知后,继续向 Publisher 申请新值
}

public func receive(subscription: Subscription) {
self.subscription = subscription
subscription.request(.max(1))
}

public func receive(_ input: Input) -> Subscribers.Demand {
receiveValue(input)
return .none // 调用函数后不继续申请新值
}

public func receive(completion: Subscribers.Completion<Failure>) {
receiveCompletion(completion)
}

public func cancel() {
subscription?.cancel()
subscription = nil
}

private func resume() {
subscription?.request(.max(1))
}
}
}

public extension Publisher {
func oneByOneSink(
_ notificationName: Notification.Name,
receiveCompletion: @escaping (Subscribers.Completion<Failure>) -> Void,
receiveValue: @escaping (Output) -> Void
) -> Cancellable {
let sink = Subscribers.OneByOneSink<Output, Failure>(
notificationName: notificationName,
receiveCompletion: receiveCompletion,
receiveValue: receiveValue
)
self.subscribe(sink)
return sink
}
}

public extension Publisher where Failure == Never {
func oneByOneSink(
_ notificationName: Notification.Name,
receiveValue: @escaping (Output) -> Void
) -> Cancellable where Failure == Never {
let sink = Subscribers.OneByOneSink<Output, Failure>(
notificationName: notificationName,
receiveCompletion: { _ in },
receiveValue: receiveValue
)
self.subscribe(sink)
return sink
}
}

调用:

let resumeNotification = Notification.Name("resume")

publisher
.buffer(size: 10, prefetch: .keepFull, whenFull: .dropOldest)
.oneByOneSink(
resumeNotification,
receiveValue: { value in
Task {
await asyncPrint(value: value)
NotificationCenter.default.post(name: resumeNotification, object: nil)
}
}
)
.store(in: &cancellables)

由于需要回调才能完成整个处理逻辑,针对本文需求,方案一相较方案二明显更优雅。

方案二中,数据处理链是可暂停的,很适合用于需要触发某种条件才可继续执行的场景。

方案三

在前文中提到过,苹果已经为 Notification 提供了 AsyncSequence 的支持。如果我们只通过 NotificationCenter 来发送事件,下面的代码就直接可以满足我们的需求:

let n = Notification.Name("event")
Task {
for await value in NotificationCenter.default.notifications(named: n, object: nil) {
if let str = value.object as? String {
await asyncPrint(value: str)
}
}
}

NotificationCenter.default.post(name: n, object: "event1")
NotificationCenter.default.post(name: n, object: "event2")
NotificationCenter.default.post(name: n, object: "event3")

简单的难以想象是吗?

遗憾的是,Combine 的 Subject 和其他的 Publishe 并没有直接遵循 AsyncSequence 协议。

但今年的 Combine 为 Publisher 增加了一个非常小但非常重要的功能——values。

values 的类型为 AsyncPublisher,其符合 AsyncSequence 协议。设计的目的就是将 Publisher 转换成 AsyncSequence。使用下面的代码便可以满足各种 Publisher 类型的需求:

let publisher = PassthroughSubject<String, Never>()
let p = publisher
.buffer(size: 10, prefetch: .keepFull, whenFull: .dropOldest)
Task {
for await value in p.values {
await asyncPrint(value: value)
}
}

因为 AsyncSequence 只能对数据逐个处理,因此我们无需再考虑数据的串行问题。

将 Publisher 转换成 AsyncSequence 的原理并不复杂,创建一个符合 AsyncSequence 的结构,将从 Publihser 中获取的数据通过 AsyncStream 转送出去,并将迭代器指向 AsyncStream 的迭代器即可。

我们可以用代码自己实现上面的 values 功能。下面我们创建了一个 sequence,功能表现同 values 类似。

public struct CombineAsyncPublsiher<P>: AsyncSequence, AsyncIteratorProtocol where P: Publisher, P.Failure == Never {
public typealias Element = P.Output
public typealias AsyncIterator = CombineAsyncPublsiher<P>

public func makeAsyncIterator() -> Self {
return self
}

private let stream: AsyncStream<P.Output>
private var iterator: AsyncStream<P.Output>.Iterator
private var cancellable: AnyCancellable?

public init(_ upstream: P, bufferingPolicy limit: AsyncStream<Element>.Continuation.BufferingPolicy = .unbounded) {
var subscription: AnyCancellable?
stream = AsyncStream<P.Output>(P.Output.self, bufferingPolicy: limit) { continuation in
subscription = upstream
.sink(receiveValue: { value in
continuation.yield(value)
})
}
cancellable = subscription
iterator = stream.makeAsyncIterator()
}

public mutating func next() async -> P.Output? {
await iterator.next()
}
}

public extension Publisher where Self.Failure == Never {
var sequence: CombineAsyncPublsiher<Self> {
CombineAsyncPublsiher(self)
}
}

完整代码,请参阅 Gist,本例的代码参考了 Marin Todorov 的 文章

sequence 在实现上和 values 还是有微小的不同的,如果感兴趣的朋友可以使用下面的代码,分析一下它们的不同点。

let p = publisher
.print() // 观察订阅器的请求情况。 values 的实现同方案二一样。
// sequence 使用了 AsyncStream 的 buffer,因此无需再设定 buffer

for await value in p.sequence {
await asyncPrint(value: value)
}

总结

在可以预见的未来,苹果一定会为 Combine 和 async/await 提供更多的预置融合手段。或许明后年,前两种方案就可以直接使用官方提供的 API 了。

希望本文能够对你有所帮助。

原文发表在我的博客 wwww.fatbobman.com

欢迎订阅我的公共号:【肘子的Swift记事本】

收起阅读 »

Android技能树点亮计划--Java反射与动态代理

简介 Java的反射是指程序在运行期可以拿到一个对象的所有信息 使用 反射主要分为以下几个步骤 1. 获取Class对象 JVM在加载类的时候,会为每个类生成一个独一无二的Class对象 获取方式有以下几种 //name = Test.class.getDe...
继续阅读 »

简介


Java的反射是指程序在运行期可以拿到一个对象的所有信息


使用


反射主要分为以下几个步骤


1. 获取Class对象


JVM在加载类的时候,会为每个类生成一个独一无二的Class对象


获取方式有以下几种 
//name = Test.class.getDeclaredField("name");
//name = test.getClass().getDeclaredField("name");
name = Class.forName("com.example.app.MainActivity$Test").getDeclaredField("name");

2. 操作fileds


Test test = new Test("xxx");
Field name;
try {
//name = Test.class.getDeclaredField("name");
//name = test.getClass().getDeclaredField("name");
name = Class.forName("com.example.app.MainActivity$Test").getDeclaredField("name");
name.setAccessible(true);
Log.d("test", (String)name.get(test));
} catch (NoSuchFieldException | IllegalAccessException | ClassNotFoundException e) {
e.printStackTrace();
}

class Test {
private String name;

public Test(String name) {
this.name = name;
}
public String getName() {
return name;
}

public void setName(String name) {
this.name = name;
}
}



  • getDeclaredField :获取本类的任何field




  • getField:获取本类和基类的public field




  • 获取基类非public值,只能通过基类class的getDeclaredField




3. 调用method


// 无参数的方法
Method getName = Class.forName("com.example.app.MainActivity$Test").getMethod("getName");
Log.d("test", (String)getName.invoke(test));

// 有参数的方法
Method setName = Class.forName("com.example.app.MainActivity$Test").getMethod("setName", String.class);
setName.invoke(test, "sdaasda");
Log.d("test", test.getName());

动态代理


在程序运行期动态创建某个interface的实例,通过动态代理可以实现一个方法/类的hook


比如hook点击事件


public class HookOnClickListenerHelper {
public static View.OnClickListener hook(Context context, final View v) {//
return (OnClickListener)Proxy.newProxyInstance(v.getClass().getClassLoader(),
new Class[] {OnClickListener.class},
new ProxyHandler(new ProxyOnClickListener()));
}

static class ProxyHandler implements InvocationHandler {

private View.OnClickListener listener;

public ProxyHandler(OnClickListener listener) {
this.listener = listener;
}

@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
return method.invoke(listener, args);
}
}

static class ProxyOnClickListener implements View.OnClickListener {
@Override
public void onClick(View v) {
Log.d("HookSetOnClickListener", "点击事件被hook到了");
}
}
}

findViewById(R.id.service).setOnClickListener(HookOnClickListenerHelper.hook(this, findViewById(R.id.service)))

实践


目标:动态代理应用版本号的返回


分析:


动态代理的实现相对来说是简单的,困难的部分在于通过读源码了解到功能是如何实现的,通过代理哪个类可以修改目标代码的返回



  1. Android是如何获取应用版本号的?


通过getPackageManager()的getPackageInfo()


PackageManager pm = getPackageManager();
PackageInfo pi = pm.getPackageInfo(getPackageName(), 0);
versionName = pi.versionName;
versioncode = pi.versionCode;


  1. getPackageManager()如何获取 ?


getPackageManager在Context中实现,Context是一个abstract Class,所有的实现都在 ContextImpl中,通过ContextImpl我们发现getPackageManager()是从 ActivityThread.getPackageManager()拿到的


// ContextImp.java
@Override
public PackageManager getPackageManager() {
if (mPackageManager != null) {
return mPackageManager;
}

final IPackageManager pm = ActivityThread.getPackageManager();
if (pm != null) {
// Doesn't matter if we make more than one instance.
return (mPackageManager = new ApplicationPackageManager(this, pm));
}

return null;
}


  1. ActivityThread如何获取?


ActivityThread内部有静态方法currentActivityThread()来获取


// ActivityThread.java
public static ActivityThread currentActivityThread() {
return sCurrentActivityThread;
}


  1. ActivityThread中的packageManager怎么获取?


在ActivityThread中定义了sPackageManager,通过它我们就能拿到sPackageManager


 public static IPackageManager getPackageManager() {
if (sPackageManager != null) {
//Slog.v("PackageManager", "returning cur default = " + sPackageManager);
return sPackageManager;
}
IBinder b = ServiceManager.getService("package");
//Slog.v("PackageManager", "default service binder = " + b);
sPackageManager = IPackageManager.Stub.asInterface(b);
//Slog.v("PackageManager", "default service = " + sPackageManager);
return sPackageManager;
}

代理:




  1. 获取ActivityThread


    // 获取ActivityThread
    activityThreadClz = Class.forName("android.app.ActivityThread");
    Method currentActivityThread = activityThreadClz.getDeclaredMethod("currentActivityThread");
    currentActivityThread.setAccessible(true);
    Object activityThread = currentActivityThread.invoke(null);




  2. 获取packageManager


    // 获取packageManager
    Field packageManagerField = activityThreadClz.getDeclaredField("sPackageManager");
    packageManagerField.setAccessible(true);
    final Object packageManager = packageManagerField.get(activityThread);




  3. 动态代理,处理getPackageInfo方法


    // 动态代理处理数据
    Class<?> packageManagerClazz = Class.forName("android.content.pm.IPackageManager", false, getClassLoader());
    Object proxy = Proxy.newProxyInstance(getClassLoader(), new Class[] {packageManagerClazz},
    new InvocationHandler() {
    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
    Object result = method.invoke(packageManager, args);
    if ("getPackageInfo".equals(method.getName())) {
    PackageInfo packageInfo = (PackageInfo)result;
    packageInfo.versionName = "sdsds";
    }
    return result;
    }
    });




  4. 给packManger设置hook的对象


    //hook sPackageManager
    packageManagerField.set(activityThread, proxy);




测试:


//越早 hook 越好,推荐在 attachBaseContext 调用
PackageManager pm = getPackageManager();
try {
PackageInfo pi = pm.getPackageInfo(getPackageName(), 0);
Log.d(TAG, pi.versionName);
} catch (NameNotFoundException e) {
e.printStackTrace();
}


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

微前端拆分实践

“这篇文章是我一次活动分享的讲稿”最近项目上机缘巧合用微前端解决了一些团队问题,借此机会跟大家分享一下。微前端作为近两年兴起的一种解决方案,也不是什么新东西了,既然是解决方案,那么微前端帮我们解决了什么问题呢?这里我以我们项目组为例子讲讲:我们为什么需要微前端...
继续阅读 »

这篇文章是我一次活动分享的讲稿

最近项目上机缘巧合用微前端解决了一些团队问题,借此机会跟大家分享一下。

微前端作为近两年兴起的一种解决方案,也不是什么新东西了,既然是解决方案,那么微前端帮我们解决了什么问题呢?这里我以我们项目组为例子讲讲:

我们为什么需要微前端?

我们的项目整体来看算得上一个比较大型的项目,整个项目规划完成后有 17 条业务线。但是在刚起项目的时候由于种种原因并没有考虑周全,将项目当成一个普通的前端项目来解决,在第一期项目结束,第一条业务上线后,我们紧接着开始了第二和第三条业务线的开发,紧接着我们就遇到了一些问题:

代码冲突

一期项目上线后交由维护团队维护,交付团队继续后面项目的开发。由于所有代码在同一个 repo 中作为一个大型单体被共同维护,两个团队的代码修改常常有冲突,需要小心 merge。同时还需要理解对方的业务,看自己的业务会不会破坏对方的业务。

部署冲突

由于所有的基础设施包括 CI/CD 等都是公用的,任何一个团队想要部署自己的代码,势必会对另外一个团队造成影响,不管是 feature toggle 还是 chunk base 的开发方式都将增大开发人员的心智负担。

技术栈冲突

由于项目比较大,未来团队的数量不确定,我们不能将技术栈限制死,否则就有可能有的团队要使用自己完全不熟练的技术栈,更别说未来还有第三方团队加入的可能性,我们不希望将整个项目绑定在某一个技术栈上。

基于这样的背景,我们发现微前端这套解决方案很好地解决了我们的问题。说白了在我们的项目背景下,我们最希望得到的东西是 -- 团队自治

我们希望各个业务线的团队能够自由修改自己的代码,不用担心与别的团队产生冲突。她们可以自由选择自己熟悉的技术栈,不必有过多限制。同时任何团队的部署都不会影响其他团队,这也就意味着某一个团队负责的部分如果挂掉了,网站上其他团队维护的部分也是可用的。

最重要的,这样的架构可以让各个团队聚焦在自己的技术和业务上,减少各个团队不必要的无效沟通,提升各个团队的开发效率。

拆分时机

对于微前端的拆分来说,这是一项工作量较大的技术改进,而且它不同于别的技术改进,它没有模版,没有办法按部就班的从网上找个东西过来照抄,必须要结合自己的项目来进行。

另一方面,我们需要达成共识的是,在我们的日常开发中,大多数情况下项目上不可能给开发人员足够的时间来做技术改进,这就意味着大多数技术改进需要同业务开发一同进行。那么找准一个改进的时机就很重要了。

那么这样的时机通常是什么时候呢?

业务有较大的改变或演进

这种情况我想大多数同学都经历过,在开发最初说的好好的需求,由于种种原因需要做一次大的改变。面对这种大的需求变更,通常我们的代码也需要做对应的改变,而这种改变也需要重写一些代码,这个重写的过程就是一个很好的进行拆分的好时机。

在这个期间我们有足够的理由说服项目干系人给我们时间去重新组织项目代码去更好地支持业务的发展。

业务稳定不再有大的改进

此时业务的发展趋于稳定,但目前的架构如果也的确给开发造成了阻碍。那么就可以在这个稳定架构上进行改进。当然此时的业务还在发展,我们可以采取两种策略:

  • 一种是以拆分任务为高优先级,新的业务开发基于新的架构
  • 一种是先在旧的架构上持续开发,在拆分的过程中由负责拆分的同学将业务和技术一起迁移过去

拆分原则

我们在拆分微前端的时候一定是带有某种目的的,有可能是想对技术栈进行渐进式升级,也有可能像我们一样想提升各个独立团队的自治力,在不同的目的下我们可能会秉持不同的原则,这也是另一个为什么微前端的拆分没办法简单抄作业的原因。

就我们项目来说,我们追求各个团队的最高自治力,那么我们就希望各个独立app尽量减少彼此的通信和依赖,每个app能够尽量独立处理自己的业务。

在这样的大前提下,我们可以按照业务为主模块为辅的方式指导拆分,基于此,我们定义了一些拆分时候的原则:

  • 保证业务独立,一条业务线应该由一个独立的app来支撑,使得该业务团队拥有这个app的完全控制权
  • 跨业务的页面不应该各个业务各自持有,也应该拆分为一个独立的app
  • 通用方法库和通用组件库由大家共同维护以支撑各自的业务

拆分前的准备

前置概念

single-spa

Single-spa 是一个微前端框架,它不限制每一个 app 具体使用怎样的技术栈,主要通过控制 route 的方式在页面上渲染不同的 app。

在开始微前端的拆分前我们进行了一些调研后选择了它作为我们微前端的框架,说是调研其实当时我们并没有过多的了解每一个框架,比如国内比较有名的 qiankun。

这里其实有一个小插曲,我们第一个了解的框架就是 single-spa,当时有一个小需求 single-spa 实现不了,于是我按照官网的文档去 slack 询问,第二天一大早我就收到了回复,算上时差他们一看到我的问题就给了我答复,这个反馈速度加上对国内开源社区的不乐观,我们直接就选择了 single-spa。

In-broswer module vs build time module

在开始实践前,我可能需要给大家介绍两个概念以帮助大家更好地理解接下来的架构设计,第一个概念是 in-broswer module,或者叫做es6 modules,与之对应的是现在用途最广的 build time module,这两个module有什么区别呢?我们先来看一个图:

module-build-result

module-build-result

这个图里两个 js 文件互相引用后最后打包的结果就是 build time module。在写代码的时候虽然你觉得这两个文件是分离的,但是其实在最终打包的时候这两个文件里的内容会被合并,最终变成一个 js 文件,然后这个 js 文件被 html 文件引用。

in-broswer module 则不同,这种模块是浏览器根据你提供的 url 从网络中请求回来的,你的每一个 import 都代表了一次网络请求,各个文件真的变成了独立的模块,通过网络请求相互依赖。

但是这样的模块有一个缺点,就是它没有办法像我们日常开发一样直接给一个名字就能直接引用到对应的模块:

import singleSpa from "single-spa";

由于需要在网络中定位到这个模块在哪里病发送对应的请求,它需要一个完整的url:

import singleSpa from "https://cdn.jsdelivr.net/npm/single-spa/esm/single-spa.min.js";

Import-map

这个特性使得大多数程序员都不喜欢它,毕竟大多数人都不想写一串长长的 url 来引用一个模块。为了解决这个问题,WICG 起草了一个新的浏览器规范,这个规范叫做 import map

<script type="importmap">
 {
  "imports": {
   "single-spa""https://cdn.jsdelivr.net/npm/single-spa/esm/single-spa.min.js"
  }
 }
</script>

import map 是一段特殊的 js,它的 type 为 importmap,在这个 script 标签里面的是一个 json object。这个 json object 的 key 就是某一个模块的名字,而它对应的 value 就是这个模块的 url 地址。

当然,既然 import-map 是一个 script 标签,那么理所应当它也可以加上 src 属性,成为一段外部 script:

<script type="importmap" src="https://some.url.to.your.importmap"></script>

在一些情况下,可能你的项目中引用了某一个包的不同版本,这时候可以用 import-map 的 scopes 功能来限制某一个文件的引用:

<script type="importmap">
 {
  "imports": {
   "lodash""https://unpkg.com/lodash@3"
  },
  "scopes": {
   "/module-a/": {
    "lodash""https://unpkg.com/lodash@4"
   }
  }
 }
</script>

这里的 scopes 代表了如果某一个 module 以 module-a 开头那么里面如果有引用 lodash 的 import,这个 import 将会引用 v4 版本,其他的 import 则都是引用的 v3 版本。

于是根据这个 import-map,我们就能够在代码里像使用正常模块那样使用 in-broswer module 了:

import singleSpa from "single-spa";

Systemjs

然后接下来就是前端传统节目,很显然,这么新的规范大部分浏览器目前都是不支持的,更别提永远也不可能支持的 IE 了,所以我们需要 polyfill - systemjs,它怎么工作的这里为了不扯远就不再赘述了,感兴趣的同学可以通过链接去 github 里面看文档,总的来说这是一个专门为了 es-module 而生的 polyfill。

我们从一个简单的 demo 来看它是怎么让 import-map 工作的:

es6-module-syntax

es6-module-syntax

这是一个很简单的 demo,HTML 页面中留有一段 template,然后导入一份 es-module,这份 module 也很简单,只做了一件事就是导入 vue 然后把 template 里面的 name 换成我们想要的东西。

但是这里有一个细节,我们在导入 vue 的时候必须用一段 url 来导入,如果我们把这段 url 换成我们平时开发时的字符串会发生什么呢?

import-without-url

import-without-url

这里会发生这样的错误是因为我们在 script 标签上标记了这个 script 是一个 es-module,于是里面的 import 关键字是浏览器在运行时执行的,但是因为后面的字符串没办法告诉浏览器 Vue 这个资源到底在哪,浏览器当然也就找不到对应的资源,于是就报错了。

如果我们想要将 url 替换为我们平时开发时候的字符串,就得依赖于 import-map,但是大部分浏览器现在都还不支持这一特性,于是我们需要引入 systemjs:

how-to-use-systemjs

how-to-use-systemjs

由于我们使用了 systemjs,为了按照它的规矩来行事,我们需要在原本的规范上修改一些代码:

  • 首先是我们需要在开始引入 systemjs
  • 然后将 import-map 的 type 从 importmap 改为 systemjs-importmap
  • 接着把 es-module 的 type 从 module 改为 systemjs-module
  • 最后是改动最大的地方,在 es-module 中我们不再使用 import 和 export 来导入导出模块,转而使用 systemjs 的语法,不过不用担心, webpack 和 rollup 等打包工具现在都支持将代码打包成 systemjs 风格,所以我们在写代码的时候还是可以按照正常规范来写

架构设计

到这里我们的前置概念就介绍完了,可以准备开始正式的拆分工作了,不过在拆分开始前,我们需要提前设计好我们的基础设施架构和代码组织方式。

基础设施架构

基于 single-spa 加上 import-map,我们最后计划好的基础设施架构大概长这个样子:

arch-of-micro-fe

arch-of-micro-fe

  1. 首先我们前端的所有静态资源都会分别部署在 AWS 的 S3 服务中,其中唯一的一份 HTML 文件存放在 root 容器的 S3 中。
  2. 当用户访问我们的网站时,流量会从 client 端到达 root 容器的 AWS S3,这个时候用户的浏览器会先加载根路径下的 HTML 页面,而 HTML 页面的 head 标签中有一份 import-map 的 script。
  3. 这时候 client 会再发送一次请求到我们的 import-map 所在的 S3 拿到 import-map。
  4. 然后我们在 body 标签中用 systemjs 引入 root 容器,整个 APP 开始运转,之后根据不同的路径去不同的 S3 拿对应的静态文件

部署策略

为了能够达到各个团队独立自治的目的,部署是必不可缺的一环,我们的最终目的是不同的团队部署不会影响其他团队的业务。一个团队的线上代码出了问题,其他团队的业务仍可正常运行,对于一个 to B 的项目来说,这样的规划是有意义的。

delpoy-plan

delpoy-plan

基于这个目的,每一个团队自己维护自己的 app 的 CI/CD pipeline。需要特别注意的是,在每一次部署后需要更新 import-map 自己团队对应的 app 地址,这样还可以达到版本管理的目的。只要 S3 中一直存放着某一个版本的静态资源,仅仅更新 import-map 的对应地址即可达到快速部署和回滚的目的。

pipeline-stage

pipeline-stage

本地开发策略

在本地开发时有两种策略,一种是直接在本地启动一个 root 容器,然后将本地的 APP 注册到 root 容器中。

但是这样的开发方式需要解决依赖问题,比如 APP 依赖的通用方法库、通用组件库。解决这些依赖问题也有两个办法,一个是直接将对应的依赖打包,在本地进行配置,本地开发时直接引用打包好的依赖;第二个方式是将这些依赖作为一个共享 APP 直接在本地作为一个类似于 server 一样运行,然后通过 import-map 来共享,在开发时直接引用导出的方法和组件,而 single-spa 也提供了这样的方式,感兴趣的读者可以通过这个链接详细了解。

第二种方式则要简单许多,并且开发体验也会好很多。通常我们都有开发环境。我们可以直接在线上开发环境的 import-map 开一个口,利用 import-map-overrides 这个工具把线上的 import-map 对应的那个 APP 地址覆盖成本地地址。这时线上通过 import-map 去寻找这个 APP 的时候就会直接请求你的本地某个地址,然后线上运行的代码其实就已经是你本地的代码了,可以无缝与各种依赖开发。

你可能会觉得有安全问题,但其实这个工具可以做一些配置,比如只在本地和某一个域名下才打开这个口子,在别的地方都不开放这个后门。

实际拆分

problem

problem

讲了这么多,终于开始上手了,但是这个世界上有一句名话叫做理想很丰满,现实很骨感。当你兴致勃勃准备好了一切计划,现实一般都不会让你如愿。我们这些看起来都还不错的计划有一部分被金主爸爸暂时搁置了,有一部分由于设计不妥开发体验不佳也被改造了。

太贵了

成本永远是和金主爸爸谈判绕不开的话题,我们新的架构设计在单体前端的基础上增加了许多东西:

  • 多 repo(当然这个不算钱,也就没啥阻碍,但是最终也没有用多 repo 的方案,这个后面再聊
  • 多 pipeline
  • 多部署资源(每一个 APP 使用单独的 S3
  • 多出来的 import map service

以前 10 块钱就能干完的活,你这么一搞我得出 100 块了吧,你这么玩我的钱包很难办啊

金主爸爸如是说。这种情况下我们就需要和金主爸爸谈判,为什么这些东西是必要的,为什么我们需要加这么多资源。但项目的问题在于,我们没时间谈判了,所以决定采取“架构降级”:

  • 先暂时用一条 pipeline 来 build 我们的 app,在下一期项目有足够证据的时候切分 pipeline

    • 这一决定在后来验证是完全错误的,设想一下一个内存只有 1G 的 agent,需要 build 一个有 5 个 APP 的前端项目
    • 同时由于金主爸爸的钱包问题,我们项目只有一个 agent,请想象一下我们的日常开发hhhhh
  • 先暂时将所有 app 部署到同一个地方,以文件夹分隔,如果一段时间后发现能满足需求,就先保持原状

  • 每次 build 生成一份 import map,不单独维护 import map 资源,当团队相互影响时再寻求拆分时机

repo 拆分问题

我们一开始的设想是一个单独的 APP 拆分为一个单独的 repo,真正上手的时候仔细一想,有必要吗?

这让我回想起了一期项目时后端的微服务 repo,由于是一期项目,不同微服务之间的调用需要 setup,所以大多数时候本地都打开了三个以上的 Intellij,加上乱七八糟的其他应用,不得不说对 16G 内存的 Macbook 是一个考验。

回到前端这边,极有可能我们在日常的开发过程中会频繁抽取/更改公用代码库,也就意味着我们需要频繁提交更改,更新版本,然后才能使用,想想都不想做了。

再者,目前两个团队的体量其实还不必如此细致的拆分

有必要吗 - 繁琐的开发流程 - 多个本地 idea

公用代码难以维护 - 不同repo 不同更改 - ts类型引用问题

跨业务页面拆分问题

最初的设想是一条业务线是一个单独的 APP,一些跨业务的页面(也就是每一个业务都会有的页面,比如 User Account Management)也会被单独抽取一个 APP。

我们也真的这么做了,然后小伙伴们就戴上了痛苦面具:

  • “BA 说这个页面是统一的,这个业务的改动,那个业务也要改。” “抽!”
  • “BA 说这个新的页面要独立,所有新功能要在所有业务中生效。” “抽!”
  • ......

“这个公共页面的逻辑跟那边的逻辑是一样的,我们是 copy 一份?” “......”

这样的策略导致我们的项目中存在大量 APP,而这些 APP 仔细一想好像没必要啊。增加 build 成本的同时也增加了我们自己的开发和维护成本,这拆的本末倒置了,于是我们做了一个改进 - 将所有公共页面塞进了一个 APP 中。

这个方案咋一听怪怪的,但是真的这么做了以后发现真香。所有的改动都会在所有的业务生效,不同的业务用不同的权限限制,大家维护同意份代码。等一下,你刚刚不是说不想大家维护同一份代码怕冲突吗?

这里的情况恰恰相反,所有的改动和需求都需要在所有地方生效,这样的方式我们就不用维护多份代码,而且也不会造成冲突 - 因为需求方的需求是单向的,如果有冲突,那就是需求冲突了,需要金主爸爸自己内部去掰头了。

可能有的小伙伴会说,怎么不试试后端拆分方式,使用 DDD 来指导拆分呢?巧了么不是,一开始我们就是按照后端 DDD 的方式来指导拆分的,然后就发生了这些问题,至少在我们的实践过程中,微服务的拆分方式不能照搬到前端来。

CSS 冲突问题

这是我们遇到的另一个比较严重的问题。我们在项目中使用了 Material UI,其中的 CSS 使用的是 CSS-in-JS 的方式,又因为有一套自己的 class name 生成规则,在没有控制好 scope 的情况下,多个 APP 的样式名冲突了,导致了严重的互相影响。

这虽然不是 single-spa 的问题,但是 single-spa 也提供了一些解决方案,包括 JS lib 和 CSS 的隔离问题,这些方案可以轻易地在官网或者 github issue 里面搜索到,这里就不过多解释了。解决的关键在于使用不同的 JS 或者 CSS 方案要做好相应的隔离。

写在最后

以上大概就是我们在拆分微前端过程中遇到的还记得住的事情了,从这次拆分中给我最大的益处其实不是技术上的提升,而是让我明白了做项目的两个关键点:

  • 所有事情不会原封不动按照你的计划执行,越大的事情越是这样,及时考虑突发事件,灵活应变,不要拘泥于设计,基于现实改变计划才是可行之策。
  • 架构的演进应该逐步推进,稳步前行,没有必要在一次架构演进中考虑好未来的所有情况,先不说你能不能考虑周全,谁又能说未来的情况不会发生改变呢,不要以现在的情况去揣度未来的情景,过好当下,灵活设计,提前预防未来可能发生的状况,准备好plan B即可。
原文:https://juejin.cn/post/7007774421502935054
收起阅读 »

配置一个好看的PowerShell

工作学习生活中不免要经常用到 PowerShell ,但是那深蓝色的背景实在让人想吐槽几句。今天我们就来美化一下它,几十种花里胡哨的主题任你选择~准备首先我们要下载 Windows Terminal,打开微软商店搜索或者在Gith...
继续阅读 »

工作学习生活中不免要经常用到 PowerShell ,但是那深蓝色的背景实在让人想吐槽几句。

今天我们就来美化一下它,几十种花里胡哨的主题任你选择~

image-20211017111042177

准备

  1. 首先我们要下载 Windows Terminal,打开微软商店搜索或者在Github搜索下载即可:

    image-20211017111722102

  2. Win11后,WSL又迎来了质的飞跃,你甚至可以直接在文件管理中看到它:

    image-20211017111555327

  3. 想修改 Windows Terminal 透明亚克力背景以及字体样式颜色也可以看我的上篇文章。

插一句,可以尝试一下新的 PowerShell 是跨平台的,挺好用

安装及修改

先贴出Oh My Posh官方文档

  1. 首先在命令行分别输入以下命令,中途询问输入Y确认即可:

    Install-Module oh-my-posh -Scope CurrentUser -SkipPublisherCheck
    Install-Module posh-git -Scope CurrentUser

  2. 直接来修改配置文件:

    notepad $PROFILE

  3. 会提示你新建一个文件,直接复制粘贴,然后保存退出重新启动即可。

    Import-Module posh-git
    Import-Module oh-my-posh
    Set-PoshPrompt -Theme agnosterplus

  4. 你可以通过 Get-PoshThemes 来查看所有可用主题,再次修改配置文件中第三行内容即可:

    image-20211017112633530

一些提示

  1. 你打开后可能会有些图标显示不出来,那是因为你的字体不支持,你可以下载Nerd Fonts ,或者下载官方推荐的Meslo LGM NF,使用字体需要修改Windows Terminal的设置文件,具体可以看我的上篇文章,我这里使用的是"MesloLGS NF"。

  2. 选择主题也可以直接输入 Set-PoshPrompt -Theme 主题名

  3. 这个主题在VScode中也可以适配显示,在设置中搜索 Integrated:font,修改字体即可:

    image.png

  4. 显示出来是这样的:

    image.png

  5. 暂时想不出来更多了,有问题可以评论然后我再补充~

原文:https://juejin.cn/post/7019878578703564807
收起阅读 »

不会 Android 性能优化?你还差一个开源库!

简介开源库的地址是:幸苦各位能给个小小的 star 鼓励下。UI 线程 block 检测。App 的 FPS 检测。线程的创建和启动监控以及线程池的创建监控。IPC (进程间通讯)监控。实时通过 logcat 打印检测到的问题。保存检测到的信息到文件。提供上报...
继续阅读 »

简介

由于本人工作需要,需要解决一些性能问题,虽然有 ProfilerSystrace 等工具,但是无法实时监控,多少有些不方便,于是计划写一个能实时监控性能的小工具。经过学习大佬们的文章,最终完成了这个开源的性能实时检测库。初步能达到预期效果,这里做个记录,算是小结了。

开源库的地址是:

github.com/XanderWang/…

幸苦各位能给个小小的 star 鼓励下。

这个性能检测库,可以检测以下问题:

  • UI 线程 block 检测。

  • App 的 FPS 检测。

  • 线程的创建和启动监控以及线程池的创建监控。

  • IPC (进程间通讯)监控。

同时还实现了以下功能:

  • 实时通过 logcat 打印检测到的问题。

  • 保存检测到的信息到文件。

  • 提供上报信息文件接口。

接入指南

1 在 APP 工程目录下面的 build.gradle 添加如下内容。

dependencies {
 // 基础依赖,必须添加
 debugImplementation 'io.github.xanderwang:performance:0.3.1'
 releaseImplementation 'io.github.xanderwang:performance-noop:0.3.1'

 // hook 方案封装,必须添加
 debugImplementation 'io.github.xanderwang:hook:0.3.1'

 // 以下是 hook 方案选择一个就好了。如果运行报错,就换另外一个,如果还是报错,就提个 issue
 // SandHook 方案,推荐添加。如果运行报错,可以替换为 epic 库。
 debugImplementation 'io.github.xanderwang:hook-sandhook:0.3.1'

 // epic 方法。如果运行报错,可以替换为 SandHook。
 // debugImplementation 'io.github.xanderwang:hook-epic:0.3.1'
}

2 APP 工程的 Application 类新增类似如下初始化代码。

Java 初始化示例

  private void initPERF(final Context context) {
   final PERF.LogFileUploader logFileUploader = new PERF.LogFileUploader() {
     @Override
     public boolean upload(File logFile) {
       return false;
    }
  };
   PERF.init(new PERF.Builder()
      .checkUI(true, 100) // 检查 ui lock
      .checkIPC(true) // 检查 ipc 调用
      .checkFps(true, 1000) // 检查 fps
      .checkThread(true) // 检查线程和线程池
      .globalTag("test_perf") // 全局 logcat tag ,方便过滤
      .cacheDirSupplier(new PERF.IssueSupplier<File>() {
         @Override
         public File get() {
           // issue 文件保存目录
           return context.getCacheDir();
        }
      })
      .maxCacheSizeSupplier(new PERF.IssueSupplier<Integer>() {
         @Override
         public Integer get() {
           // issue 文件最大占用存储空间
           return 10 * 1024 * 1024;
        }
      })
      .uploaderSupplier(new PERF.IssueSupplier<PERF.LogFileUploader>() {
         @Override
         public PERF.LogFileUploader get() {
           // issue 文件上传接口
           return logFileUploader;
        }
      })
      .build());
}

kotlin 示例

  private fun doUpload(log: File): Boolean {
   return false
}

 private fun initPERF(context: Context) {
   PERF.init(PERF.Builder()
      .checkUI(true, 100)// 检查 ui lock
      .checkIPC(true) // 检查 ipc 调用
      .checkFps(true, 1000) // 检查 fps
      .checkThread(true)// 检查线程和线程池
      .globalTag("test_perf")// 全局 logcat tag ,方便过滤
      .cacheDirSupplier { context.cacheDir } // issue 文件保存目录
      .maxCacheSizeSupplier { 10 * 1024 * 1024 } // issue 文件最大占用存储空间
      .uploaderSupplier { // issue 文件的上传接口实现
         PERF.LogFileUploader { logFile -> doUpload(logFile) }
      }
      .build()
  )
}

主要更新记录

  • 0.3.1 新增给 ImageView 设置比实际控件尺寸大的图片检测

  • 0.3.0 修改依赖库发布方式为 MavenCentral

  • 0.2.0 线程耗时的监控,同时可以监控线程优先级(setPriority)的改变。

  • 0.1.12 线程创建的监控,加入 thread name 信息收集。同时接入 startup 库做必要的初始化,以及调整 multi dex 的时候,配置文件找不到的问题。

  • 0.1.11 优化 hook 方案的封装,通过 SandHook 开源库,可以按照 IPC 的耗时时间长短来检测。

  • 0.1.10 FPS 的检测时间间隔从默认 2s 调整为 1s,同时支持自定义时间间隔。

  • 0.1.9 优化线程池创建的监控。

  • 0.1.8 初版发布,完成基本的功能。

不建议直接在线上使用这个库,在编写这个库,测试 hook 的时候,在不同的机器和 rom 上,会有不同的问题,这里建议先只在线下自测使用这个检测库。

原理介绍

UI 线程 block 检测原理

主要参考了 AndroidPerformanceMonitor 库的思路,对 UI 线程的 Looper 里面处理 Message 的过程进行监控。

具体做法是,在 Looper 开始处理 Message 前,在异步线程开启一个延时任务,用于后续收集信息。如果这个 Message 在指定的时间段内完成了处理,那么在这个 Message 被处理完后,就取消之前的延时任务,说明 UI 线程没有 block 。如果在指定的时间段内没有完成任务,说明 UI 线程有 block 。此时,异步线程可以执行刚才的延时任务。如果我们在这个延时任务里面打印 UI 线程的方法调用栈,就可以知道 UI 线程在做什么了。这个就是 UI 线程 block 检测的基本原理。

但是这个方案有一个缺点,就是无法处理 InputManager 的输入事件,比如 TV 端的遥控按键事件。通过对按键事件的调用方法链进行分析,发现最终每个按键事件都调用了 DecorView 类的 dispatchKeyEvent 方法,而非 Looper 的处理 Message 流程。所以 AndroidPerformanceMonitor 库是无法准确监控 TV 端应用 UI block 的情况。针对 TV 端应用按键处理,需要找到一个新的切入点,这个切入点就是刚刚的 DecorView 类的 dispatchKeyEvent 方法。

那如何介入 DecorView 类的 dispatchKeyEvent 方法呢?我们可以通过 epic 库来 hook 这个方法的调用。hook 成功后,我们可以在 DecorView 类的 dispatchKeyEvent 方法调用前后都接收到一个回调方法,在 dispatchKeyEvent 方法调用前我们可以在异步线程执行一个延时任务,在 dispatchKeyEvent 方法调用后,取消这个延时任务。如果 dispatchKeyEvent 方法耗时时间小于指定的时间阈值,延时任务在执行前被取消,可以认为没有 block ,此时移除了延时任务。如果 dispatchKeyEvent 方法耗时时间大于指定的时间阈值说明此时 UI 线程是有 block 的。此时,异步线程可以执行这个延时任务来收集必要的信息。

以上就是修改后的 UI 线程 block 的检测原理了,目前做的还比较粗糙,后续计划考虑参考 AndroidPerformanceMonitor 打印 CPU 、内存等更多的信息。

最终终端 log 打印效果如下:

com.xander.performace.demo W/demo_Issue: =================================================
  type: UI BLOCK
  msg: UI BLOCK
  create time: 2021-01-13 11:24:41
  trace:
  java.lang.Thread.sleep(Thread.java:-2)
  java.lang.Thread.sleep(Thread.java:442)
  java.lang.Thread.sleep(Thread.java:358)
  com.xander.performance.demo.MainActivity.testANR(MainActivity.kt:49)
  java.lang.reflect.Method.invoke(Method.java:-2)
  androidx.appcompat.app.AppCompatViewInflater$DeclaredOnClickListener.onClick(AppCompatViewInflater.java:397)
  android.view.View.performClick(View.java:7496)
  android.view.View.performClickInternal(View.java:7473)
  android.view.View.access$3600(View.java:831)
  android.view.View$PerformClick.run(View.java:28641)
  android.os.Handler.handleCallback(Handler.java:938)
  android.os.Handler.dispatchMessage(Handler.java:99)
  android.os.Looper.loop(Looper.java:236)
  android.app.ActivityThread.main(ActivityThread.java:7876)
  java.lang.reflect.Method.invoke(Method.java:-2)
  com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:656)
  com.android.internal.os.ZygoteInit.main(ZygoteInit.java:967)

FPS 检测的原理

FPS 检测的原理,利用了 Android 的屏幕绘制原理。这里简单说下 Android 的屏幕绘制原理。

系统每隔 16 ms 就会发送一个 VSync 信号。 如果应用注册了这个 VSync 信号,就会在 VSync 信号到来的时候,收到回调,从而开始准备绘制。如果准备顺利,也就是 CPU 准备数据、GPU 栅格化等,如果这些任务在 16 ms 之内完成,那么下一个 VSync 信号到来前就可以绘制这一帧界面了。就没有掉帧,界面很流畅。如果在 16 ms 内没准备好,可能就需要更多的时间这个画面才能显示出来,在这种情况下就发生了丢帧,如果丢帧很多就卡顿了。

检测 FPS 的原理其实挺简单的,就是通过一段时间内,比如 1s,统计绘制了多少个画面,就可以计算出 FPS 了。那如何知道应用 1s 内绘制了多少个界面呢?这个就要靠 VSync 信号监听了。

在开始准备绘制前,往 UI 线程的 MessageQueue 里面放一个同步屏障,这样 UI 线程就只会处理异步消息,直到同步屏障被移除。刷新前,应用会注册一个 VSync 信号监听,当 VSync 信号到达的时候,系统会通知应用,让应用会给 UI 线程的 MessageQueue 里面放一个异步 Message *。由于之前 MessageQueue 里有了一个*同步屏障,所以后续 UI 线程会优先处理这个异步 Message 。这个异步 Message 做的事情就是从 ViewRootImpl 开始我们熟悉的 measurelayoutdraw

我们可以通过 Choreographer 注册 VSync 信号监听。16ms 后,我们收到了 VSync 的信号,给 MessageQueue 里面放一个同步消息,我们不做特别处理,只是做一个计数,然后监听下一次的 VSync 信号,这样,我们就可以知道 1s 内我们监听到了多少个 VSync 信号,就可以得出帧率。

为什么监听到的 VSync 信号数量就是帧率呢?

由于 Looper 处理 Message 是串行的,就是一次只处理一个 Message ,处理完了这个 Message 才会处理下一个 Message 。而绘制的时候,绘制任务 Message 是异步消息,会优先执行,绘制任务 Message 执行完成后,就会执行上面说的 VSync 信号计数的任务。如果忽略计数任务的耗时,那么最后统计到的 VSync 信号数量可以粗略认为是某段时间内绘制的帧数。然后就可以通过这段时间的长度和 VSync 信号数量来计算帧率了。

最终终端 log 打印效果如下:

com.xander.performace.demo W/demo_FPSTool: APP FPS is: 54 Hz
com.xander.performace.demo W/demo_FPSTool: APP FPS is: 60 Hz
com.xander.performace.demo W/demo_FPSTool: APP FPS is: 60 Hz

线程的创建和启动监控以及线程池的创建监控

线程和线程池的监控,主要是监控线程和线程池在哪里创建和执行的,如果我们可以知道这些信息,我们就可以比较清楚线程和线程池的创建和启动时机是否合理。从而得出优化方案。

一个比较容易想到的方法就是,应用代码里面的所有线程和线程池继承同一个线程基类和线程池基类。然后在构造函数和启动函数里面打印方法调用栈,这样我们就知道哪里创建和执行了线程或者线程池。

让应用所有的线程和线程池继承同一个基类,可以通过编译插件来实现,定制一个特殊的 Transform ,通过 ASM 编辑生成的字节码来改变继承关系。但是,这个方法有一定的上手难度,不太适合新手。

除了这个方法,我们还有另外一种方法,就是 hook 。通过 hook 线程或者线程池的构造方法和启动方法,我们就可以在线程或者线程池的构造方法和启动方法的前后做一些切片处理,比如打印当前方法调用栈等。这个也就是线程和线程池监控的基本原理。

线程池的监控没有太大难度,一般都是 ThreadPoolExecutor 的子类,所以我们 hook 一下 ThreadPoolExecutor 的构造方法就可以监控线程池的创建了。线程池的执行主要就是 hookThreadPoolExecutor 类的 execute 方法。

线程的创建和执行的监控方法就稍微要费些脑筋了,因为线程池里面会创建线程,所以这个线程的创建和执行应该和线程池绑定的。需要找到线程和线程池的联系,之前看到一个库,好像是通过线程和线程池的 ThreadGroup 来建立关联的,本来我也计划按照这个关系来写代码的,但是我发现,我们有的小伙伴写的线程池的 ThreadFactory 里面创建线程并没有传入ThreadGroup ,这个就尴尬了,就建立不了联系了。经过查阅相关源码发现了一个关键的类,ThreadPoolExecutor 的内部类Worker ,由于这个类是内部类,所以这个类实际的构造方法里面会传入一个外部类的实例,也就是 ThreadPoolExecutor 实例。同时, Worker 这个类还是一个 Runnable 实现,在 Worker 类通过 ThreadFactory 创建线程的时候,会把自己作为一个 Runnable 传给 Thread 所以,我们通过这个关系,就可以知道 WorkerThread 的关联了。这样,我们通过 ThreadPoolExecutorWorker 的关联,以及 WorkerThread 的关联,就可以得到 ThreadPoolExecutor 和它创建的 Thread 的关联了。这个也就是线程和线程池的监控原理了。

最终终端 log 打印效果如下:

com.xander.performace.demo W/demo_Issue: =================================================
  type: THREAD
  msg: THREAD POOL CREATE
  create time: 2021-01-13 11:23:47
  create trace:
  com.xander.performance.StackTraceUtils.list(StackTraceUtils.java:39)
  com.xander.performance.ThreadTool$ThreadPoolExecutorConstructorHook.afterHookedMethod(ThreadTool.java:158)
  de.robv.android.xposed.DexposedBridge.handleHookedArtMethod(DexposedBridge.java:265)
  me.weishu.epic.art.entry.Entry64.onHookObject(Entry64.java:64)
  me.weishu.epic.art.entry.Entry64.referenceBridge(Entry64.java:239)
  java.util.concurrent.Executors.newSingleThreadExecutor(Executors.java:179)
  com.xander.performance.demo.MainActivity.testThreadPool(MainActivity.kt:38)
  java.lang.reflect.Method.invoke(Method.java:-2)
  androidx.appcompat.app.AppCompatViewInflater$DeclaredOnClickListener.onClick(AppCompatViewInflater.java:397)
  android.view.View.performClick(View.java:7496)
  android.view.View.performClickInternal(View.java:7473)
  android.view.View.access$3600(View.java:831)
  android.view.View$PerformClick.run(View.java:28641)
  android.os.Handler.handleCallback(Handler.java:938)
  android.os.Handler.dispatchMessage(Handler.java:99)
  android.os.Looper.loop(Looper.java:236)
  android.app.ActivityThread.main(ActivityThread.java:7876)
  java.lang.reflect.Method.invoke(Method.java:-2)
  com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:656)
  com.android.internal.os.ZygoteInit.main(ZygoteInit.java:967)

IPC(进程间通讯)监控的原理

进程间通讯的具体原理,也就是 Binder 机制,这里不做详细的说明,也不是这个框架库的原理。

检测进程间通讯的方法和前面检测线程的方法类似,就是找到所有的进程间通讯的方法的共同点,然后对共同点做一些修改或者说切片,让应用在进行进程间通讯的时候,打印一下调用栈,然后继续做原来的事情。就达到了 IPC 监控的目的。

那如何找到共同点,或者说切片,就是本节的重点。

进程间通讯离不开 Binder ,需要从 Binder 入手。

写一个 AIDL demo 后发现,自动生成的代码里面,接口 A 继承自 IInterface 接口,然后接口里面有个内部抽象类 Stub 类,继承自 Binder ,同时实现了接口 A 。这个 Stub 类里面还有一个内部类 Proxy ,实现了接口 A ,并持有一个 IBinder 实例。

我们在使用 AIDL 的时候,会用到 Stub 类的 asInterFace 的方法,这个方法会新建一个 Proxy 实例,并给这个 Proxy 实例传入 IBinder , 或者如果传入的 IBinder 实例如果是接口 A 的话,就强制转化为接口 A 实例。一般而言,这个 IBinder 实例是 ServiceConnection 的回调方法里面的实例,是 BinderProxy 的实例。所以 Stub 类的 asInterFace 一般会创建一个 Proxy 实例,查看这个 Proxy 接口的实现方法,发现最终都会调用 BinderProxytransact 方法,所以 BinderProxytransact 方法是一个很好的切入点。

本来我也是计划通过 hookBinderProxy 类的 transact 方法来做 IPC 的检测的。但是 epic 库在 hook 含有 Parcel 类型参数的方法的时候,不稳定,会有异常。由于暂时还没能力解决这个异常,只能重新找切入点。最后发现 AIDL demo 生成的代码里面,除了调用了 调用 BinderProxytransact 方法外,还调用了 ParcelreadException 方法,于是决定 hook 这个方法来切入 IPC 调用流程,从而达到 IPC 监控的目的。

最终终端 log 打印效果如下:

com.xander.performace.demo W/demo_Issue: =================================================
  type: IPC
  msg: IPC
  create time: 2021-01-13 11:25:04
  trace:
  com.xander.performance.StackTraceUtils.list(StackTraceUtils.java:39)
  com.xander.performance.IPCTool$ParcelReadExceptionHook.beforeHookedMethod(IPCTool.java:96)
  de.robv.android.xposed.DexposedBridge.handleHookedArtMethod(DexposedBridge.java:229)
  me.weishu.epic.art.entry.Entry64.onHookVoid(Entry64.java:68)
  me.weishu.epic.art.entry.Entry64.referenceBridge(Entry64.java:220)
  me.weishu.epic.art.entry.Entry64.voidBridge(Entry64.java:82)
  android.app.IActivityManager$Stub$Proxy.getRunningAppProcesses(IActivityManager.java:7285)
  android.app.ActivityManager.getRunningAppProcesses(ActivityManager.java:3684)
  com.xander.performance.demo.MainActivity.testIPC(MainActivity.kt:55)
  java.lang.reflect.Method.invoke(Method.java:-2)
  androidx.appcompat.app.AppCompatViewInflater$DeclaredOnClickListener.onClick(AppCompatViewInflater.java:397)
  android.view.View.performClick(View.java:7496)
  android.view.View.performClickInternal(View.java:7473)
  android.view.View.access$3600(View.java:831)
  android.view.View$PerformClick.run(View.java:28641)
  android.os.Handler.handleCallback(Handler.java:938)
  android.os.Handler.dispatchMessage(Handler.java:99)
  android.os.Looper.loop(Looper.java:236)
  android.app.ActivityThread.main(ActivityThread.java:7876)
  java.lang.reflect.Method.invoke(Method.java:-2)
  com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:656)
  com.android.internal.os.ZygoteInit.main(ZygoteInit.java:967)

作者:xanderwang
来源:https://juejin.cn/post/6916531888576266254

收起阅读 »

丢掉丑陋的 toast,会动的 toast 更有趣!

前言我们通常会用 toast(也叫吐司)来显示提示信息,例如网络请求错误,校验错误等等。大多数 App的 toast 都很简单,简单的半透明黑底加上白色文字草草了事,比如下面这种. 说实话,这种toast 的体验很糟糕。假设是新手用户,他们并不知道 toas...
继续阅读 »



前言

我们通常会用 toast(也叫吐司)来显示提示信息,例如网络请求错误,校验错误等等。大多数 App的 toast 都很简单,简单的半透明黑底加上白色文字草草了事,比如下面这种.

说实话,这种toast 的体验很糟糕。假设是新手用户,他们并不知道 toast 从哪里出来,等出现错误的时候,闪现出来的时候,可能还没抓住内容的重点就消失了(尤其是想截屏抓错误的时候,更抓狂)。这是因为一个是这种 toast 一般比较小,而是动效非常简单,用来提醒其实并不是特别好。怎么破?本篇来给大家介绍一个非常有趣的 toast 组件 —— motion_toast

motion_toast 介绍

从名字就知道,motion_toast 是支持动效的,除此之外,它的颜值还很高,下面是它的一个示例动图,仔细看那个小闹钟图标,是在跳动的哦。这种提醒效果比起常用的 toast 来说醒目多了,也更有趣味性。 下面我们看看 motion_toast 的特性:

  • 可以通过动画图标实现动效;

  • 内置了成功、警告、错误、提醒和删除类型;

  • 支持自定义;

  • 支持不同的主题色;

  • 支持 null safety;

  • 心跳动画效果;

  • 完全自定义的文本内容;

  • 内置动画效果;

  • 支持自定义布局(LTR 和 RTL);

  • 自定义持续时长;

  • 自定义展现位置(居中,底部或顶部);

  • 支持长文本显示;

  • 自定义背景样式;

  • 自定义消失形式。

可以看到,除了能够开箱即用之外,我们还可以通过自定义来丰富 toast 的样式,使之更有趣。

示例

介绍完了,我们来一些典型的示例吧,首先在 pubspec.yaml 中添加依赖motion_toast: ^2.0.0(最低Dart版本需要2.12)。

最简单用法

只需要一行代码搞定!其他参数在 success 的命名构造方法中默认了,因此使用非常简单。

MotionToast.success(description: '操作成功!').show(context);

其他内置的提醒

内置的提醒也支持我们修改默认参数进行样式调整,如标题、位置、宽度、显示位置、动画曲线等等。

// 错误提示
MotionToast.error(
 description: '发生错误!',
 width: 300,
 position: MOTION_TOAST_POSITION.center,
).show(context);

//删除提示
MotionToast.delete(
 description: '已成功删除',
 position: MOTION_TOAST_POSITION.bottom,
 animationType: ANIMATION.fromLeft,
 animationCurve: Curves.bounceIn,
).show(context);

// 信息提醒(带标题)
MotionToast.info(
 description: '这是一条提醒,可能会有很多行。toast 会自动调整高度显示',
 title: '提醒',
 titleStyle: TextStyle(fontWeight: FontWeight.bold),
 position: MOTION_TOAST_POSITION.bottom,
 animationType: ANIMATION.fromBottom,
 animationCurve: Curves.linear,
 dismissable: true,
).show(context);

不过需要注意的是,一个是 dismissable 参数只对显示位置在底部的有用,当在底部且dismissabletrue 时,点击空白处可以让 toast 提前消失。另外就是显示位置 positionanimationType 是存在某些互斥关系的。从源码可以看到底部显示的时候,animationType不能是 fromTop,顶部显示的时候 animationType 不能是 fromBottom

void _assertValidValues() {
 assert(
  (position == MOTION_TOAST_POSITION.bottom &&
           animationType != ANIMATION.fromTop) ||
      (position == MOTION_TOAST_POSITION.top &&
           animationType != ANIMATION.fromBottom) ||
      (position == MOTION_TOAST_POSITION.center),
);
}

自定义 toast

自定义其实就是使用 MotionToast 构建一个实例,其中,descriptioniconprimaryColor参数是必传的。自定义的参数很多,使用的时候建议看一下源码注释。

MotionToast(
 description: '这是自定义 toast',
 icon: Icons.flag,
 primaryColor: Colors.blue,
 secondaryColor: Colors.green[300],
 descriptionStyle: TextStyle(
   color: Colors.white,
),
 position: MOTION_TOAST_POSITION.center,
 animationType: ANIMATION.fromRight,
 animationCurve: Curves.easeIn,
).show(context);

下面对自定义的一些参数做一下解释:

  • icon:图标,IconData 类,可以使用系统字体图标;

  • primaryColor:主颜色,也就是大的背景底色;

  • secondaryColor:辅助色,也就是图标和旁边的竖条的颜色;

  • descriptionStyle:toast 文字的字体样式;

  • title:标题文字;

  • titleStyle:标题文字样式;

  • toastDuration:显示时长;

  • backgroundType:背景类型,枚举值,共三个可选值,transparentsolidlighter,默认是 lighterlighter其实就是加了一层白色底色,然后再将原先的背景色(主色调)加上一定的透明度叠加到上面,所以看起来会泛白。

  • onClose:关闭时回调,可以用于出现多个错误时依次展示,或者是关闭后触发某些动作,如返回上一页。

总结

看完之后,是不是觉得以前的 toast 太丑了?用 motion_toast来一个更有趣的吧。另外,整个 motion_toast 的源码并不多,有兴趣的可以读读源码,了解一下toast 的实现也是不错的。

作者:岛上码农
来源:https://juejin.cn/post/7042301322376265742

收起阅读 »

web错误处理/错误捕获方案

前言花了一些时间整理完善项目的错误处理/错误捕获能力,借此进行一次总结。为了方便阅读,先概括下大概的思路:// 错误处理,避免报错导致程序无法继续执行1、自行对重要步骤进行容灾和try...catch...finally等处理;  2、通过打包工具(...
继续阅读 »



前言

花了一些时间整理完善项目的错误处理/错误捕获能力,借此进行一次总结。为了方便阅读,先概括下大概的思路:

// 错误处理,避免报错导致程序无法继续执行
1、自行对重要步骤进行容灾和try...catch...finally等处理;  
2、通过打包工具(我用的是vite,自己实现的是rollup的plugin)将绝大部分语句包裹try...catch语句,并补全错误信息(文件名,方法名);  

// 错误捕获
1、onerror 事件捕获全局错误;  
2、unhandledrejection 捕获异步错误;  
3、框架层面错误监听(e.g. Vue.config.errorHandler, react ErrorBoundary);  
3、axios等tcp/ip错误处理;  
4、静态资源加载异常捕获(window.addEventListener('error',(event)=>{});  

// 错误分析,补全错误信息
1、通过sourcemap解析错误信息,定位到具体错误代码;

错误处理

我实现的插件是rollup-plugin-trycatch,这个方案不算成熟:

涉及到业务代码的translate,单元测试覆盖面可能不足(目前仅处理几种类型函数,欢迎pr);

如果项目比较稳定,测试覆盖率较高,不建议使用此方案。

rollup-plugin-trycatch

跟webpack有些区别,rollup不分plugin和loader,只通过plugin来实现插件。主要流程如下:
1、创建rollup插件;
2、通过rollup的acorn插件将code转为ast语法树;
3、通过stack-utils插件将当前文件/文件名行数等信息添加到err里;
3、通过estree-walker遍历语法树,将相关的语句(函数)增加wrap节点;
4、通过escodegen插件将语法树转为code;
功能:
1、给所有函数(FunctionDeclaration, FunctionExpression, ArrowFunctionExpression, ObjectMethod)包一层try...catch捕获异常;
2、try...catch错误补全(增加报错信息,原文件名,方法名等错误分析,但仍不够精确,未能定位到具体报错的语句);
具体内容和使用方式可以查看源码,里面有vite的使用demo;

错误捕获

错误捕获的难点在于两个方面:
1、如何全面的捕获到错误;
2、如果通过错误分析到具体问题(一般来说线上代码都是打包压缩过的,如何通过打包压缩后的代码定位到问题);

全面(尽可能)的错误捕获

在我们尝试错误捕获之前,需要先了解有哪些错误,分三类(这里其实有很多分类的方式,我尝试用我自己的分类方式来解读):

1、远程资源加载错误;

window.addEventListener('error', (err) => {
let _url = ''

// 远程资源加载异常
if(err.target instanceof HTMLElement) {
  if(err.target instanceof HTMLAnchorElement) {
    _url = err.target.href
  } else {
    // maybe other htmlelement has src property
    _url = (err.target as HTMLImageElement).src
  }
  // 这里进行上报
  console.log(err, _url)
}
}, true) // 注意有三个参数,第三个参数表捕获阶段传播到该EventTarget时触发

2、同步代码错误 && 异步代码(setTimeout/setInterval,等);

window.onerror = (message, source, lineno, colno, error) => {
// 这里进行错误上报操作
console.log(message, source, lineno, colno, error)
}

3、异步代码错误;

// promise
window.addEventListener("unhandledrejection", (e) => {
// 这里进行上报
console.log(e.reason)
e.preventDefault() // 阻止错误冒泡
}, true)

4、框架提供的错误处理,e.g.

// vue3
app.config.errorHandler = (err, vm, info) => {
// 这里进行错误上报
console.log(err, vm, info)
}

5、xhr, fetch等异步请求方式;

// xhr
function xhr() {
const oReq = new XMLHttpRequest();
const url = "http://www.example.org/example.txt"
 
oReq.addEventListener("error", (err) => {
  console.log(err, url)
});
oReq.open("GET", url);
oReq.send();
}
// fetch
function fetchMethod() {
const url = 'http://www.example.org/example.txt'

fetch(url,
  {
    method: 'GET',
    mode: 'cors',
    cache: 'default'
  }
).then(data => {
  console.log(data)
}).catch(err => {
  // 错误处理
  console.log(url, err)
})
}

具体代码可参考 demo;

错误位置

我们的代码一般是打包压缩后的代码,错误提示的位置有时候很难定位到具体的内容,特别是很难重现的错误内容,我们常常需要更精准的错误信息进行定位。

对于以上方案以及对应的处理方式如下

1、rollup-plugin-trycatch插件wrap一层try...catch;  
在插件中已对错误记录路径,方法名等信息;  
2、远程静态资源;  
错误已记录路径信息;  
3、promise异步代码错误;  
建议自行全部加上catch处理错误;  
4、框架内部的错误处理;  
框架已提供错误定位信息(vue3, `info` is a Vue-specific error info, e.g. which lifecycle hook);  
5、xhr, fetch等;  
建议自行记录处理;  
5、同步代码错误 && 异步代码(setTimeout/setInterval,等);  
其实这个错误是主要错误之一,目前的方案是通过提前打包sourcemap来进行解析

sourcemap解析错误

这里主要介绍下如何实现解析功能(有些服务,e.g. sentry已提供sourcemap的服务,但我们是自己搭建的,所以需要自己来实现这个功能):
1、打包时候将最新的sourcemap覆盖上传到解析服务上(如果有不同版本的查询问题的需求,可以考虑多版本,我暂时没做);

现在大部分公司都是通过自动化工具(jenkins, gitlab等)在打包机进行打包编译,在打包成功后将sourcemap文件上传到解析的服务目录上即可(可以运维通过ssh上传,也可以自行搭建文件上传服务); 

2、通过source-map解析文件,返回具体错误位置;

// 需要传入参数,source, lineno, colno

解析sourcemap源码

问题记录

1、Error.prototype.stack是实验性功能,在不同浏览器,不同版本有不同的处理方式(包括try...catch, unhandledrejection等error都是Error的实例)。
可以参考stackoverflow兼容主流浏览器(未测试);
另外一种就是像我的plugin中自动化wrap try...catch方法时记录下行信息;对于promise,建议尽量全部通过catch处理(或变成同步代码async await);

2、rollup或者acorn并没有提供ast->code的方法,如何进行转换?
在修改ast后需要通过另外的插件来实现ast->code,较多人在issue里推荐的是escodegen。

3、estree-walker在jest调用时候出现module not found的问题;
用2.0.2版本是ok的, 有issue跟进

4、有一些浏览器支持但rollup尚未支持的实验属性需要慎用。

1、class 私有属性 相关issue: https://github.com/rollup/rollup/issues/4292,可以通过插件@rollup/plugin-typescript转化后使用;

参考文档

1、 React,优雅的捕获异常: juejin.cn/post/697438…
2、Allow plugin transforms to only return AST: github.com/rollup/roll…
3、source-map-demo: github.com/Joeoeoe/sou…

作者:vb
来源:https://juejin.cn/post/7046320743973388295

收起阅读 »

关于MobX,知无不言,言无不尽~

MobX 实践指南一、概览篇简介MobX 是一个专注于状态管理的库,在 React 世界的流行程度仅次于拥有官方背景的 Redux。但 MobX 有自己独特的优势,它通过运用透明的函数式响应编程使状态管理变得简单、高效、自由。MobX哲学任何源自应用状态的东西...
继续阅读 »

MobX 实践指南

一、概览篇

简介

MobX 是一个专注于状态管理的库,在 React 世界的流行程度仅次于拥有官方背景的 Redux。但 MobX 有自己独特的优势,它通过运用透明的函数式响应编程使状态管理变得简单、高效、自由。

MobX哲学

任何源自应用状态的东西都应该自动地获得。

核心原理

利用defineProperty(<=v5)或Proxy(v6)拦截对象属性的变化,实现数据的Observable,在 get 中依赖收集,set 中触发依赖绑定的监听函数。
假如你之前关注过 Vue.js、Knockout 等的一些 MVVM 框架的响应式原理,那么你应该会感到非常熟悉。是的,它们的原理如出一辙。

核心概念

不仅是原理,基础概念、顶层的 Api 设计也十分相似。Vue.js 中 data、computed、watch,几乎可以与 Mobx 中的observable-statecomputedreaction等概念一一对应。最大的不同是,MobX 通过 actions 约束对 state 的更新方式,实现了对状态的管理这一重要步骤。整体运行流程如下图所示。

alt 运行流程

安装

mobx 这个包,提供了 MobX 所有的与具体框架平台无关的基础 Api。比如(observable、makeObservable、action等)。

npm i mobx

如果在 react 中使用,需要添加针对 react 开发的包 mobx-react

npm i mobx mobx-react

如果你在 react 开发中,只使用函数式组件,没有使用类组件,那么可以将 mobx-react 替换为一个更轻量的包 mobx-react-lite

npm i mobx mobx-react-lite

相比mobx-react这个全量包,
1. 去掉了对class components的支持,
2. 并且移除了provider、inject
(原因:这两个HOC在React官方已经提供了React.createContext之后变得不是那么必要了)

二、实践篇

1. 声明Store

相比直接使用普通对象,MobX 更推荐使用的方式去创建 Store,主要原因是 class 对 TS 的类型系统更友好,更容易被索引实现自动补全等功能。

三种声明方式

方式一、直接使用普通对象的方式 (不推荐)

import { observable,action } from 'mobx'

const userStore = observable({
roleType:1
})

export const changeRoleType = action((val)=>{
userStore.roleType = val
})

export default userStore

方式二、使用类 + 装饰器 (V6 版本之前的推荐方式)

import { observable } from 'mobx'

class UserStore{
@observable roleType=1
@action changeRoleType(val){
this.data = val
}
}

export default UserStore

方式三、使用类 + makeObservable (V6 版本的推荐方式)(不再推荐装饰器的原因可以在Q&A章节找到)

import { makeObservable,observable,computed,action } from 'mobx'

class UserStore{
constructor(){
makeObservable(this,{
roleType:observable,
roleName:computed,
changeRoleType:action
})
}
roleType = 1
get roleName(){
return roleMap[roleType]
}
changeRoleType(val){
this.roleType = val
}
}
export default UserStore

//or

import { makeAutoObservable } from 'mobx'

class UserStore{
constructor(){
makeAutoObservable(this)
/* 无需显示的声明,会自动应用合适的MobX-Api去修饰。比如
(1)值字段会被推断为observable、
(2)get 修饰的方法,会推断为computed、
(3)普通方法,会自动应用action
(4)如果你有自定义调整某些字段的需求,请参考此方法的[其他入参](https://zh.mobx.js.org/observable-state.html#makeautoobservable)
*/
}
roleType = 1
get roleName(){
return roleMap[roleType]
}
changeRoleType(val){
this.roleType = val
}
}
export default UserStore

实现 store 间通信

例子:在一个角色管理的模块,因为自己拥有管理员权限,权力大到甚至能够更改自己的角色类型,那么果真这样操作时,就需要将RoleStore的修改同步到UserStore,这时就涉及到多个store间通信。
思路:创建一个公共的上级 rootStore,实现多个Store间的状态读取,方法调用。

// 用户信息Store
class UserStore{
constructor(rootStore){
this.rootStore = rootStore
makeAutoObservable(this)
}
uid = 'zyd123'
roleType = 1
changeRoleType(val){
this.roleType = val
}
}
// 角色管理Store
class RoleStore{
constructor(rootStore){
this.rootStore = rootStore
makeAutoObservable(this)
}
changeUserRoleType(uid,type){
const {userStore} = this.rootStore
//更改自己的角色类型
if(uid === userStore.uid){
//*** 同步UserStore ***
userStore.changeRoleType(type)
...
}else{
//更改别人的角色类型
...
}
}
}

// 新建一个上层rootStore,方便Stores间沟通
class RootStore {
constructor() {
this.userStore = new UserStore(this)
this.roleStore = new RoleStore(this)
}
}

const rootStore = new RootStore()
export default rootStore

2. 在React组件中使用

2.1 observer

作用:自动订阅在react组件渲染期间被使用到的可观察对象属性,当他们变化发生时,组件就会自动进行重新渲染。 前边在概览篇提到过MobX的核心能力就是能够将数据get中收集到的所有依赖,在set中一次性发布出去。在react场景中,就是要将状态与组件渲染建立联系,一旦状态变化,所有使用到此状态的组件都需要重新渲染,而这一切的关键就是observer。
用法如下:(demo:实现一个更改全局角色的功能,RoleManage组件负责更改,UserInfo组件负责展示)
src/demos/UserInfo.jsx

import { observer } from "mobx-react";
// 导入rootStore
import rootStore from './../store';
// 拿到对应的子Store
const { userStore } = rootStore;

class UserInfo extends Component {
render() {
//(1) 触发get,收集依赖(ps:当前组件已加入MobX的购物车)
const { roleName } = userStore;
return (
<Row justify="space-between">
<Col></Col>
<Col span={5} className='border'>
<Space align='center'>
<span>当前角色类型:</span>
<h2>{roleName}</h2>
</Space>
</Col>
</Row>
);
}
}
// (关键)observer HOC包裹住组件,将MobX强大的响应式更新能力赋予react组件。
export default observer(UserInfo)

src/demos/RoleManage.jsx

import rootStore from './../store'

const { userStore } = rootStore;

class RoleManage extends Component {
handleUpdateRoleType = ()=>{
//(2) 使用一个action去触发数据set,在set中发布依赖(触发组件更新,ps:Mobx要清空购物车啦)
userStore.changeRoleType(2)
}
render() {
return <Button onClick={this.handleUpdateRoleType}>更改角色</Button>
}
}
export default RoleManage;

2.2 Provider、inject

作用:刚才的例子中,大家可以看到全局Store的引入方式是文件的方式引入的。

import rootStore from './../store'

const { userStore } = rootStore;

这种方式繁琐且不利于维护,假如store文件重新组织,引入的地方需要处处更改与check。所以,有没有方式,在项目开发中Store只需一次注入,就可以在所有组件内非常便捷的引用呢?
答案就是使用 Provider、inject。
让我们重构上边的例子: src/index.jsx

import App from "./App";

import { Provider } from 'mobx-react'
import store from './store'
//利用Provider将Store注入全局
ReactDOM.render(
<Provider {...store}>
<App/>
</Provider>,
document.getElementById("root")
);

src/demos/UserInfo.jsx

class UserInfo extends Component {
render() {
//通过props的方式在render函数中引用
const { roleName } = this.props.userStore;

return (
<Row justify="space-between">
<Col></Col>
<Col span={5} className='border'>
<Space align='center'>
<span>当前角色类型:</span>
<h2>{roleName}</h2>
</Space>
</Col>
</Row>
);
}
}

// inject是高阶函数,所以inject('store')返回值还是个函数,最终入参是组件
export default inject('userStore')(observer(UserInfo))

Provider及inject看上去与react官方推出的context Api用法非常相似,要解决的问题也基本一致。
事实上,最新版的mobx-react,前者就是基于后者去做的封装,这也从侧面说明,这俩Api现在来看,并不是开发react应用的必需品。所以MobX官方在推出针对React平台的轻量包(mobx-react-lite)时,首先就把这俩api排除在外了。
但笔者认为,你如果使用的是class组件,Provider及inject依然建议使用,因为class组件内使用contextApi并不十分方便,但如果你用的hooks,则大可不必再使用Provider及inject了,得益于useContext的方便简洁,大大降低了使用他们的必要性(具体用法,后边会讲到)。

2.3 MobX + Hooks

函数组件+hooks是目前开发React应用的首选方式。MobX顺应趋势,推出了新的hook Api,这已经成为使用MobX的主流方式。

2.3.1 使用全局Store

自定义useStore替换Provider、inject 下边示例笔者会统一采用mobx-react-lite这个轻量包来编写。前边提到这个包并不提供Provider、inject,但是没有关系,有React官方提供的createContext及useContext就足够了。 下边我们自己动手封装一个好用的useStore-hook。
src/store/index.js

...

//创建rootStore的Context
export const rootStoreContext = React.createContext(rootStore)

/**
* @description 提供hook方式,方便组件内部获取Store
* @param {*} storeName 组件名字。作用类似inject(storeName),不传默认返回rootStore
*/

export const useStore = (storeName) => {
const rootStore = React.useContext(rootStoreContext)
if (storeName) {
const childStore = rootStore[storeName]
if (!childStore) {
throw new Error('根据传入storeName,找不到对应的子store')
}
return childStore
}
return rootStore
}

src/index.jsx

- import { Provider } from 'mobx-react'

+ import rootStore, {rootStoreContext} from './store'
+ const { Provider } = rootStoreContext

ReactDOM.render(
<Provider value={rootStore}>
<App/>
</Provider>,
document.getElementById("root")

src/demos/UserInfo.jsx

//换用更轻量的lite包
- import { observer } from "mobx-react";
+ import { observer } from "mobx-react-lite";
import { Row, Col, Space } from "antd";

+ import { useStore } from '../store';

// 函数式组件
const UserInfo = ()=> {
//使用自定义useStore获取全局store
const { roleName } = useStore('userStore')

return (
<Row justify="space-between">
<Col></Col>
<Col span={5} className='border'>
<Space align='center'>
<span>当前角色类型:</span>
<h2>{roleName}</h2>
</Space>
</Col>
</Row>
)
}
export default observer(UserInfo)

假如日常项目中,只希望MobX负责全局的状态管理,以上内容就完全够用了。下边我会介绍MobX+hook在局部状态管理方面的强大能力。
全局状态管理:store在组件外定义,经常放在全局一个单独的store文件夹。适合管理一些公共或者相对某模块是公共的状态。
局部状态管理:store常常定义在组件内部,适用于复杂的组件设计场景,用来解决组件多层嵌套下的状态层层传递、组件状态多且更新复杂等问题。

2.3.2 创建一个局部的Store

先介绍两个hook

useLocalObservable

作用:通过hook的方式声明一个组件内的Store,返回传入普通对象的响应式版本,并在函数组件之后的每一次渲染中保持对这个响应式对象的唯一引用(这点与useState是一致的)(useLocalStore是这个api的前身,但是将要废弃,这里不做介绍)。

useObserver

作用:前边讲的observer是HOC的方式,只能在外部通过包裹整个组件的方式去使用。想要在组件内部实现局部状态管理,在类组件中必须通过内置的Observer组件以renderProps的方式去解决,但在函数式中,hook一定是解决问题的首选,所以可以理解为useObserver是Observer的hook版实现。 示例:useLocalObservable + useObserver实现一个局部的状态管理 src/demos/UserInfoScopeStore.jsx

import { useLocalObservable, useObserver } from "mobx-react-lite";
import { Row, Col, Space,Button } from "antd";

const UserInfo = ()=> {
//定义组件内的响应式Store
const store = useLocalObservable(()=>({
name:'xxx',
changeName(text){
this.name = text
}
}))
// 对比以下两种组件内局部状态视图更新方式。
// useObserver
return useObserver(()=> <Row justify="space-between">
<Col></Col>
<Col span={5} className='border'>
<Space align='center'>
<span>当前用户:</span>
<h2>{store.name}</h2>
<Button onClick={()=>store.changeName('小米')}>修改</Button>
</Space>
</Col>
</Row>)
// or Observer
return <Observer>
{() => <Row justify="space-between">
<Col></Col>
<Col span={5} className='border'>
<Space align='center'>
<span>当前用户:</span>
<h2>{store.name}</h2>
<Button onClick={() => store.changeName('小米')}>修改</Button>
</Space>
</Col>
</Row>}
</Observer>
}
export default UserInfo

简单总结:(1)observer HOC的方式适合组件的整体更新场景(2)useObserver or Observer 都可用来处理局部的组件内更新场景,区别前者是hook的方式,只支持函数式组件,后者使用renderProps的方式,类与函数组件都兼容。

3. 开发者工具

chrome插件

三、Q&A

  1. IE项目能不能用?

V4版本默认可用,V5及以上如果需要兼容不支持Proxy的IE / React Native,请在应用初始化修改全局配置useProxies

import { configure } from "mobx"
// 如果需要兼容ie或rn,请通过全局配置,禁止使用代理
configure({ useProxies: "never" })

  1. 为什么MobX新的V6版本,不再推荐类的装饰器语法,而是建议用makeObservable的方式去修饰Store?

不再推荐装饰器的理由:因为装饰器语法尚未定案,纳入 ES 标准的时间遥遥无期,且未来制定的标准可能与当前的装饰器实现方案有所不同。所以出于兼容性,MobX 6中不推荐使用装饰器,并建议使用 makeObservable / makeAutoObservable 代替。但项目中如果使用的是 TS,笔者认为可以基本忽略影响,毕竟装饰器确实使用起来更简洁一些。

  1. 为什么我的组件并没有随着Store数据的更新而更新?

(1)忘记了observer,useObserver的包裹(大部分原因都是这个)。 (2)defineProperty的响应式方案会有一些针对数组和对象的限制,需要格外注意,必要时候需要使用mobx提供的set方法来解决。 (3)只要你始终传递响应式对象的引用,observer就可以很好的工作,如果只是传递属性值,就造成了响应式丢失,常发生在使用ES6解构的场景,或只传个响应式对象的属性进去。如果读者了解vue3,那么其中的toRefs就是为了解决类似的问题,但是Mobx中你可以通过下边的例子避免这种情况。

   //错误 ❌
const TimerView = observer(({ secondsPassed }) => <span>Seconds passed: {secondsPassed}</span>)

React.render(<TimerViewer secondPassed={myTimer.secondsPassed} />, document.body)

// 正确 🙆
const TimerView = observer(({ myTimer }) => <span>Seconds passed: {myTimer.secondsPassed}</span>)

React.render(<TimerViewer secondPassed={myTimer} />, document.body)
```

4. **必须要通过action去更新Store?**
原理上不必要,原则上必要。你直接**mutable**的方式直接更改Store也是能够触发响应式更新,但是mobx强烈不建议你这样做,因为你会丢失以下好处:
(1) 能够清晰表达出一个函数修改状态的意图,有**利于项目维护**
(2) action结合开发者工具,提供了非常**有用的调试**信息
当启用**严格模式**时,修改store状态需要强制使用action,参见全局配置enforceActions。MobX并不像redux那样,从原理上就限制了state的更新方式,只能靠这种约定的方式去限制。所以**强烈建议开启此选项**。

5. **频繁使用observer,会不会出现性能问题?**
当组件相关的 observable 发生变化时,组件将自动重新渲染,反之,它能够确保在没有相关更改时组件不会重新渲染。真正做到了组件的按需渲染,在实践中,这使得 MobX 应用程序开箱即用地进行了很好的优化,它们通常不需要任何额外的代码来防止过度渲染。

6. **MobX相比Redux最大的优势是什么?**
具体来说:MobX的开箱即用,简洁灵活,对现有项目侵入小,这都是相比Redux的优势方面。
抽象来讲:MobX相比Redux,它天然对实体模型是友好的,它在内部巧妙的借助拦截代理把数据做了observable转换,让你依然在使用层面感知到的是实体模型,但是它却拥有了响应式能力,这就是mobx最厉害的地方,它适合抽象**领域模型**!
## 结尾
以上所有例子都可在这个[github仓库](https://github.com/FEyudong/mobx-study.git)找到。
# END THANKS~

原文:


https://juejin.cn/post/6979095356302688286

收起阅读 »

js 实现双指缩放

前言随着智能手机、平板电脑等触控设备的普及,交互方式也发生了改变。相对于使用鼠标和键盘进行交互的电脑,触控设备可以直接使用手指进行交互,而且基本上都支持多点触控。多点触控最常见的操作莫过于双指缩放了。比如双指缩放网页大小、朋友圈双指缩放图片进行查看。那么如此常...
继续阅读 »

前言

随着智能手机、平板电脑等触控设备的普及,交互方式也发生了改变。相对于使用鼠标和键盘进行交互的电脑,触控设备可以直接使用手指进行交互,而且基本上都支持多点触控。多点触控最常见的操作莫过于双指缩放了。比如双指缩放网页大小、朋友圈双指缩放图片进行查看。那么如此常见的手势操作,你有没有想过它是如何实现的呢?下面跟着我一探究竟吧!

缩放原理

原理其实很简单,双指向外扩张表示放大,向内收缩表示缩小,缩放比例是通过计算双指当前的距离 / 双指上一次的距离获得的。详见下图:

p.jpg

计算出缩放比例后再通过下面两种方式实现缩放。

  1. 通过transform进行缩放
  2. 通过修改宽高来实现缩放

主流的方法都是采用transform来实现,因为性能更好。本篇文章两种方式都会介绍,任你选择。不过在讲之前,还是要先搞懂两个数学公式以及PointerEvent指针事件。因为接下来会用到。如果对PointerEvent指针事件不太熟悉的小伙伴,也可以看看这篇文章js PointerEvent指针事件简单介绍

两点间距离公式

设两个点A、B以及坐标分别为A(x1, y1)、B(x2, y2),则A和B两点之间的距离为:

e693d73856f43706273b0197b3cc42bf.svg

/**
* 获取两点间距离
* @param {object} a 第一个点坐标
* @param {object} b 第二个点坐标
* @returns
*/

function getDistance(a, b) {
const x = a.x - b.x;
const y = a.y - b.y;
return Math.hypot(x, y); // Math.sqrt(x * x + y * y);
}

中点坐标公式

设两个点A、B以及坐标分别为A(x1, y1)、B(x2, y2),则A和B两点的中点P的坐标为:

4a36acaf2edda3cce013415d11e93901203f92dc.png

/**
* 获取中点坐标
* @param {object} a 第一个点坐标
* @param {object} b 第二个点坐标
* @returns
*/

function getCenter(a, b) {
const x = (a.x + b.x) / 2;
const y = (a.y + b.y) / 2;
return { x: x, y: y };
}

获取图片缩放尺寸

<img id="image" alt="">
const image = document.getElementById('image');

let result, // 图片缩放宽高
x, // x轴偏移量
y, // y轴偏移量
scale = 1, // 缩放比例
maxScale,
minScale = 0.5;

// 由于图片是异步加载,需要在load方法里获取naturalWidth,naturalHeight
image.addEventListener('load', function () {
result = getImgSize(image.naturalWidth, image.naturalHeight, window.innerWidth, window.innerHeight);
maxScale = Math.max(Math.round(image.naturalWidth / result.width), 3);
// 图片宽高
image.style.width = result.width + 'px';
image.style.height = result.height + 'px';
// 垂直水平居中显示
x = (window.innerWidth - result.width) * 0.5;
y = (window.innerHeight - result.height) * 0.5;
image.style.transform = 'translate3d(' + x + 'px, ' + y + 'px, 0) scale(1)';
});

// 图片赋值需放在load回调之后,因为图片缓存后读取很快,有可能不执行load回调
image.src='../images/xxx.jpg';

/**
* 获取图片缩放尺寸
* @param {number} naturalWidth
* @param {number} naturalHeight
* @param {number} maxWidth
* @param {number} maxHeight
* @returns
*/

function getImgSize(naturalWidth, naturalHeight, maxWidth, maxHeight) {
const imgRatio = naturalWidth / naturalHeight;
const maxRatio = maxWidth / maxHeight;
let width, height;
// 如果图片实际宽高比例 >= 显示宽高比例
if (imgRatio >= maxRatio) {
if (naturalWidth > maxWidth) {
width = maxWidth;
height = maxWidth / naturalWidth * naturalHeight;
} else {
width = naturalWidth;
height = naturalHeight;
}
} else {
if (naturalHeight > maxHeight) {
width = maxHeight / naturalHeight * naturalWidth;
height = maxHeight;
} else {
width = naturalWidth;
height = naturalHeight;
}
}
return { width: width, height: height }
}

双指缩放逻辑

// 全局变量
let isPointerdown = false, // 按下标识
pointers = [], // 触摸点数组
point1 = { x: 0, y: 0 }, // 第一个点坐标
point2 = { x: 0, y: 0 }, // 第二个点坐标
diff = { x: 0, y: 0 }, // 相对于上一次pointermove移动差值
lastPointermove = { x: 0, y: 0 }, // 用于计算diff
lastPoint1 = { x: 0, y: 0 }, // 上一次第一个触摸点坐标
lastPoint2 = { x: 0, y: 0 }, // 上一次第二个触摸点坐标
lastCenter; // 上一次中心点坐标

// 绑定 pointerdown
image.addEventListener('pointerdown', function (e) {
pointers.push(e);
point1 = { x: pointers[0].clientX, y: pointers[0].clientY };
if (pointers.length === 1) {
isPointerdown = true;
image.setPointerCapture(e.pointerId);
lastPointermove = { x: pointers[0].clientX, y: pointers[0].clientY };
} else if (pointers.length === 2) {
point2 = { x: pointers[1].clientX, y: pointers[1].clientY };
lastPoint2 = { x: pointers[1].clientX, y: pointers[1].clientY };
lastCenter = getCenter(point1, point2);
}
lastPoint1 = { x: pointers[0].clientX, y: pointers[0].clientY };
});

// 绑定 pointermove
image.addEventListener('pointermove', function (e) {
if (isPointerdown) {
handlePointers(e, 'update');
const current1 = { x: pointers[0].clientX, y: pointers[0].clientY };
if (pointers.length === 1) {
// 单指拖动查看图片
diff.x = current1.x - lastPointermove.x;
diff.y = current1.y - lastPointermove.y;
lastPointermove = { x: current1.x, y: current1.y };
x += diff.x;
y += diff.y;
image.style.transform = 'translate3d(' + x + 'px, ' + y + 'px, 0) scale(' + scale + ')';
} else if (pointers.length === 2) {
const current2 = { x: pointers[1].clientX, y: pointers[1].clientY };
// 计算相对于上一次移动距离比例 ratio > 1放大,ratio < 1缩小
let ratio = getDistance(current1, current2) / getDistance(lastPoint1, lastPoint2);
// 缩放比例
const _scale = scale * ratio;
if (_scale > maxScale) {
scale = maxScale;
ratio = maxScale / scale;
} else if (_scale < minScale) {
scale = minScale;
ratio = minScale / scale;
} else {
scale = _scale;
}
// 计算当前双指中心点坐标
const center = getCenter(current1, current2);
// 计算图片中心偏移量,默认transform-origin: 50% 50%
// 如果transform-origin: 30% 40%,那origin.x = (ratio - 1) * result.width * 0.3
// origin.y = (ratio - 1) * result.height * 0.4
// 如果通过修改宽高或使用transform缩放,但将transform-origin设置为左上角时。
// 可以不用计算origin,因为(ratio - 1) * result.width * 0 = 0
const origin = {
x: (ratio - 1) * result.width * 0.5,
y: (ratio - 1) * result.height * 0.5
};
// 计算偏移量,认真思考一下为什么要这样计算(带入特定的值计算一下)
x -= (ratio - 1) * (center.x - x) - origin.x - (center.x - lastCenter.x);
y -= (ratio - 1) * (center.y - y) - origin.y - (center.y - lastCenter.y);
image.style.transform = 'translate3d(' + x + 'px, ' + y + 'px, 0) scale(' + scale + ')';
lastCenter = { x: center.x, y: center.y };
lastPoint1 = { x: current1.x, y: current1.y };
lastPoint2 = { x: current2.x, y: current2.y };
}
}
e.preventDefault();
});

// 绑定 pointerup
image.addEventListener('pointerup', function (e) {
if (isPointerdown) {
handlePointers(e, 'delete');
if (pointers.length === 0) {
isPointerdown = false;
} else if (pointers.length === 1) {
point1 = { x: pointers[0].clientX, y: pointers[0].clientY };
lastPointermove = { x: pointers[0].clientX, y: pointers[0].clientY };
}
}
});

// 绑定 pointercancel
image.addEventListener('pointercancel', function (e) {
if (isPointerdown) {
isPointerdown = false;
pointers.length = 0;
}
});

/**
* 更新或删除指针
* @param {PointerEvent} e
* @param {string} type
*/

function handlePointers(e, type) {
for (let i = 0; i < pointers.length; i++) {
if (pointers[i].pointerId === e.pointerId) {
if (type === 'update') {
pointers[i] = e;
} else if (type === 'delete') {
pointers.splice(i, 1);
}
}
}
}

注意事项

由于transform书写顺序并不满足交换律,换句话说transform: translateX(300px) scale(2);和transform: scale(2) translateX(300px);是不相等的。开发时请根据相应的书写顺序做处理。详见下图:

微信图片_20210802192116.png


原文:https://juejin.cn/post/7020243158529212423

收起阅读 »

为什么祖传代码会被称为屎山

有一天,有几条虫子,干扰了老板赚钱,老板希望你能抓住它们。 你带着年轻的锐气,青春的活力,学艺多年积累的程序设计艺术,打开了公司的代码仓库。 远看,似乎一个运转的机器,巨大的代码堆积在一起形成了大致的轮廓,蠕动着前进。 凑近了一看,在不净的框架中,乱码般的语句...
继续阅读 »

有一天,有几条虫子,干扰了老板赚钱,老板希望你能抓住它们。


你带着年轻的锐气,青春的活力,学艺多年积累的程序设计艺术,打开了公司的代码仓库。


远看,似乎一个运转的机器,巨大的代码堆积在一起形成了大致的轮廓,蠕动着前进。


凑近了一看,在不净的框架中,乱码般的语句在运转,像生了麻风病的蛞蝓一样在喷吐,粘稠的水在流动,而穿着格子衫的人群则在焰柱旁围成了一个半圆,这就是码农的仪式。他们环绕着那不可名状植物,不断的伸手进去拨弄,又不断的掏出一些东西填上去,使他堆积的更高,为了防止到他,又掏出黏糊糊的糊糊,用力的涂抹,试图把它们黏在一起。


这是一个前人留下的屎堆起来的一个克苏鲁缝合怪,看起来摇摇欲坠,有无数的虫子爬来爬去。但勉强堆起了山一样的形体,蠕动着为老板赚钱。



你满心热血,要对这座山进行清理,使它成为一个鲁棒的钢铁巨兽,可以随时更换最新的部件,奔腾如飞,坚固异常,带着兄弟们走向人生巅峰。


你经过缜密的分析,顺着虫子留下的痕迹,终于找到了问题的源头,发现一坨很多年前某码农因为时代局限或者水平有限拉的陈年旧屎,你觉得只要对它改良一下,梳理清楚结构,加强判断与容错,就可以变化成一个钢铁部件,让这坨怪物离巨兽更近一步。


你用力的挖掘其中的信息,却发现,事情没有那么简单,这一坨实际上不是孤立的一坨,而是和整个山体融合在一起。或者说,这座山实际上是一坨坨粘稠滑腻的克苏鲁,通过无数的触角和粘液连接在了一起,这些克苏鲁伸出无数的触角,伸进这座山体中未知的角落。


有看起来结构相同,但是出现了几十上百次的重复逻辑。有无数道不知道伸向何处的判断分支。有七零八落到处都是又无法解释的神秘数字。有从表面直接伸向最底层的神秘调用。还有猜不出,看不懂,无法预计什么时候会触发,什么时候会爆发的无数定时器。还有无数神秘的线程在独立的挂在那里,猜不出哪个什么时候会忽然启动,什么时候会忽然挂起,什么时候会忽然互相抢资源而死锁,哪些资源会莫名其妙的被改动。神秘的链接,神秘的任务队列,神秘的池,神秘的环形缓存,神秘的堆栈。


他们耦合在一起,互相支撑,构成了一坨更大的克苏鲁屎怪,缓慢的蠕动。


你极其困难的清理和修改了其中的一点点内容,让这一点点的内容脱离出耦合态,看起来清晰一点。结果,忽然屎山对面十万八千行外,你永远意想不到的一块功能,忽然挂了。一个你完全在工作上没接触过的同事,通过他的盘查,发现是他维护的一个函数/方法、类、线程、内存块,池,和你改动的部分是深度耦合的,你的解耦导致了难以理解的错误使他们的部分产生了错误。于是你被骂了,你只能再退一步,在一个更小的范围内进行调整,但是发现,虫子不止是由这一块构成的,于是你追踪者虫子的足迹,去改良一个一个的模块。


在经历了一轮又一轮的批评,几乎结识了全公司所有模块的负责人之后,你终于抓住了一条虫子。但是在这个漫长的过程中,你早已忘却初心。在无数次的赶工加班熬夜的迷糊中,被同事老板挨骂后的愤懑中,表白失败/和女朋友吵架/发现自己头顶有点绿的低落中;无数次当做临时代码写下,计划单元测试完成后就重写却忘记的过程中,因为偷懒或者不舍得打断思路而而懒得抽出轮子而产生的超大代码块中。


留下了无数看起来结构相同,但是出现了几十上百次的重复逻辑。无数道不知道伸向何处的判断分支。大量的无法解释的神秘数字。从表面直接伸向最底层的神秘调用。猜不出,看不懂,无法预计什么时候会触发,什么时候会爆发的无数定时器。无数猜不出哪个什么时候会忽然启动,什么时候会忽然挂起,什么时候会忽然互相抢资源而死锁,莫名其妙改动资源的神秘线程。神秘的链接,神秘的任务队列,神秘的池,神秘的环形缓存,神秘的堆栈。


你要抓的哪条虫子确实抓出来了。然而,在你没看到的地方,随着运转,更多的新的虫子正在茁壮的成长。


这时,你突然发现你的脚抽不出来了,几条触手顺着你的腿向上攀延,你的手被深深地吸入泥沼一样的屎山,你使尽全力想要抽出胳膊,但越是挣扎,陷得越深,仿佛屎山中心有一个冰冷的黑洞,要将所有接近的物体吞噬殆尽。你的精气在一点点流失,一种极度的疲惫,但是又释然的感觉涌了上来。此刻,你觉得舒适又满足,渐渐地闭上了双眼,你甘愿奉献头发与生命,将自己化作一块补丁,维系着系统的苟延残喘。它再也没法离开你了,你和你的头发,成了它的一部分。


不知道过了多久。终于又有一条虫子在运行中暴露,干扰了老板赚钱。


老板又安排了一个年轻人来抓住这条虫子。这个年轻人带着锐气,青春和活力来到这座山前。


看到这摇摇欲坠的克苏鲁大山,不仅倒吸一口冷气。


“oh shit ! shit mountain !”



作者:码农出击666
链接:https://juejin.cn/post/7045924498461163533

收起阅读 »

[翻译]你不可错过的 10 个 Xcode 技巧和快捷键

iOS
原文地址:10 Tips and Shortcuts You Should Be Using Right Now in Xcode 原文作者:Mike Pesate 译文出自:掘金翻译计划 本文永久链接:github.com/xitu/gold-m… 译者:F...
继续阅读 »


你不可错过的 10 个 Xcode 技巧和快捷键


Image source: Author


在我作为 iOS 开发人员的职业生涯中,养成了一些使得工作变得更加轻松快捷的 Xcode 习惯。很多好用的快捷键一直都存在,只是我们没有发现而已。


所以我收集了一些我最喜欢的,在这里和大家分享。


我们开始吧!


1. 快速自动缩进


当你的代码没有对齐时,这个快捷键非常有用。



control + i / ⌃ + i



它会自动缩进光标所在的行。如果你选中了一些代码,甚至整个文件,这个快捷键就会调整选中部分的缩进。


Demo of ⌃ + i


这对及时保持代码整洁非常有帮助。


2. 在所有作用域中修改


假设你发现某个方法或变量名有错误,你想要修复它。当然你不会一个个去修改,因为你知道有重构(Refactor)功能可以批量重命名,但有时候 Xcode 的重构功能可能不太靠谱。


此时你可以使用以下快捷键,选中当前文件中所有用到该变量的位置。



command + control + e / ⌘ + ⌃ + e



这将选中所有用到这个变量的位置,让你可以非常方便地更改变量名。


Demo of ⌘ + ⌃ + e


3. 查找下一个


现在,假设你不想在所有作用域中修改变量名称,而只想找到下一处;或者只想在一个函数中重命名,而不是整个类中,或者其他类似情况。有一个(和上面)非常相似的快捷键。



option + control + e / ⌥ + ⌃ + e



Demo of ⌥ + ⌃ + e


当你选中某个字符串,按下这个快捷键,Xcode 将选中下一个出现该字符串的位置。但这意味着,如果某些变量和函数同名,则下一个选中的,也许和你预期的不一样。(译注:这里指的是,并不判断是否真的是同一个变量,只是单纯的字符串匹配)。


4. 查找上一个


上面我们介绍了“查找下一个”,再多按一个键,则变成了“查找上一个”。



shift + option + control + e / ⇧ + ⌥ + ⌃ + e



Demo of ⇧ + ⌥ + ⌃ + e


5. 整行向上或向下移动


我们可能会对代码进行一些顺序调整,当然可以用经典的“剪切粘贴”,但如果我们只想将代码向上移动一行或向下移动一行,那么以下快捷键肯定会对你有所帮助。


向上移动:



option + command + [ / ⌥ + ⌘ + [



向下移动:



option + command + ] / ⌥ + ⌘ + ]



Demo of ⌥ + ⌘ + [ and ⌥ + ⌘ + ]


额外提示!你可以移动多行


如果选中多行之后再使用前面的快捷键,那么这些行将作为一个整体进行移动。


Demo of previous shortcut moving several lines as block


6. 多行光标(使用鼠标)


有时你需要在文件的不同部分中写入相同的内容,你很烦恼,因为你必须编写一次并复制粘贴几次。好吧,别再烦了。你可以使用一个快捷键同时写入多行。



shift + control + click / ⇧ + ⌃ + click



Demo of ⇧ + ⌃ + click


7. 多行光标(使用键盘)


此快捷键与上一个基本相同,但是我们不是使用鼠标来选择光标的位置,而是使用箭头向上或向下来移动光标。



shift + control + up or down /⇧ + ⌃ + ↑ or ↓




8. 快速创建带有多个参数的初始化(init)函数


上面的快捷键,我最喜欢用法之一,就是快速创建一个初始化函数,比之前的任何方法都快。



通过使用多行光标,配合其他一些快捷键,例如复制粘贴或选中整行,我们可以快速创建初始化函数。这只是这个按键的几种用途之一。


8.1 另一种方式


还有一个编辑功能,可以让你轻松地生成 “成员初始化器”(Memberwise Initializer)。你可以将光标放在类的名称上,然后找到 Editor > Refactor > Generate Memberwise Initializer。


但是,由于本文介绍快捷键,所以这里给一个小提示:可以进入 Preferences > Key Bindings,再查找对应命令,并添加快捷键。


这是操作示例:


How to add a key binding


9. 返回光标之前所在的位置


有时候你需要处理很大的文件,向上滚动查看某些内容之后,可能很难找到原来位置。有了这个快捷键,只要我们没有将光标移开,我们就可以快速跳回之前的位置。



option + command + L / ⌥ + ⌘ + L



Demo of ⌥ + ⌘ + L


10. 跳到某一行


和上一条相关,如果我们知道要跳转的那一行的行号,那么使用此快捷键,我们可以直接跳到该行。



command + L / ⌘ + L



Demo of ⌘ + L


最后的想法


这些就是我每天用来高效使用 Xcode 的十个快捷键和技巧。他们经常会派上用场。


我希望他们对你也一样有用。


如果你已经知道了这些快捷键,或者还不知道,都可以与我交流,我会很高兴。也欢迎和我分享你用到的其他有用的快捷键。


小贴士


理想情况下,你可以使用同样的快捷键,来实现前面提到的所有技巧。但是也可能取决于你的操作系统语言设置,其中一些可能略有不同。


你可以在 Xcode > Preferences… > Key Bindings 中查看特定快捷键的按键组合。


额外提示! 快速打开偏好设置(Preferences)



command + , / ⌘ + ,




如果发现译文存在错误或其他需要改进的地方,欢迎到 掘金翻译计划 对译文进行修改并 PR,也可获得相应奖励积分。文章开头的 本文永久链接 即为本文在 GitHub 上的 MarkDown 链接。







作者:FranzWang
收起阅读 »

Xcode 13 更新了哪些内容

iOS
直接进入主题。外观对比 Xcode 12,风格和显示都发生了变化:去掉了文件拓展名图标也可以识别文件类型自动调整了导航栏布局重新进行了分布和调整右下角增加了光标所在行列数文件拓展名设置:打开 设置 - 通用 选择 Fil...
继续阅读 »

直接进入主题。

外观

005XGWPvly1gun3djnsn4j627y1cku0x02

对比 Xcode 12,风格和显示都发生了变化:

  • 去掉了文件拓展名
  • 图标也可以识别文件类型自动调整了
  • 导航栏布局重新进行了分布和调整
  • 右下角增加了光标所在行列数
文件拓展名设置:

打开 设置 - 通用 选择 File Extensions

image-20210920152905766

文件拓展名的显示隐藏控制,选项有三种:

image-20210920153816172

  • Hide All:隐藏全部拓展名

  • Show All:显示全部拓展名

  • Show Only:自定义显示拓展名 ↓↓↓↓

    QQ20210921-024658-HD

    问题提醒设置:

    在 设置 - 通用 里还多了一个 Xcode 12没有的选项:Issues,对应的子选项为:Show InlineShow Minimized

    Show InlineShow Minimized
    image-20210920155859979image-20210920155918641

    对比 Show InlineShow Minimized 把问题提醒最小化到了右侧,当开发者点击对应的问题时,会显示出来。

    优点一目了然,界面整洁,没有一堆提示文字和红蓝色。

    缺点则是无法直接的查看问题原因,即使是点击出来,也没有像前者那样直接的把问题精准的定位具体代码中。

    image-20210920160709412

    不过这个也不能够算是缺点,只是说提示的没有那么的明显,这点根据个人喜好选择就行。

    info.plist

    info.plist 文件内容减少,甚至使用 SwiftUI 创建项目,已经移除了 info.plist 文件,真是把简洁做到了极致

    Storyboard 创建SwiftUI 创建
    image-20210921172421609image-20210921173002487

    当然,只是当前 info.plist 文件没有显示之前的内容,在 Project - Target - Info 下,对应的信息还是存在的,且如果你在 info.plist 文件内新增了的话,依旧会在 Project - Target - Info 下显示出来的。

    至于 SwiftUI 下没有 info.plist 文件,开发者可以自行创建,具体方式可以看这里

    我不能接受

    有一个地方的改变我不能接受,那就是:编译成功失败的提醒框没有啦

    Xcode 12Xcode13
    005XGWPvly1gun4lqd89kg60ig0b80vi02005XGWPvly1gun4lsjs0jg60ig0b841m02

    Xcode 13 中,不管是编译还是运行,都没有了最后的提示框。在设置中也没有找到对应的选项。

    对于我这种经常写 Bug 的人来说,看不到弹出来的 Build Succeeeded,简直是要命。苹果你赶紧给我改回来...

更新:

评论区小伙伴给出解决方案:通知栏会提示编译成功或者失败的提示。

感谢指出,Xcode 13 版本之前也有这个提示, 我一直都忽略了这个地方,平时都把大多数应用的通知都给关了。

让我意外的是:我自己的笔记本 设置 - 通知 里面竟然没有找到 Xcode 这个应用。。。我又不会玩了~

自动补全

import

在开发过程中,经常会出现没有导入头文件就开始直接调用文件,这个时候就会比较尴尬,特别是当代码行数比较多的时候,要先回到顶部导入头文件,再回来继续写,有时候甚至都找不到刚才的位置在哪了...

Xcode 13 解决了这个问题,当你使用一个没有导入头文件的库时,会智能帮助你导入对应的头文件,非常 nice

QQ20210920-163257-HD

switch

Xcode 13 以前,使用 switch 调用枚举的时候,如果想快速调出全部的 case,就只能输入代码后等着 Xcode 给你提示 Switch must be exhaustive 然后 Fix 加载全部的 case

QQ20210920-172130-HD

Xcode 13 中,你是需要正常输入代码,就会自动的显示出来了

QQ20210920-171833-HD

摸鱼的时间又增加了

不过并不是所有的情况都支持,在使用接口请求的时候,回调的Result 类型目前就无法自动补全,只能手动输入。不知道是苹果故意为之还是。。。。

QQ20210920-172656-HD

if / guard let

Xcode 13 中,使用 if 、guard 判断一个 Optional 参数的时候,也会同名自动补全。就很舒服

QQ20210920-173543-HD

for

使用 for...in 循环语句遍历一个数组的时候,Xcode 13 会根据数组名自动生成子元素名自动补全循环

QQ20210920-175028-HD

当然,即使你输入的数组名不是那么的标准,Xcode 也还是会根据它自动识别的进行补全,比如:如果你的数组名是 number 而不是 numbers 的时候,Xcode 的自动补全依旧是 for number in number。所以,还是尽量保证代码命名的正确性吧。

列断点

Swift 链式语法在开发过程中会使代码变得非常美观和整洁,与之带来的部分问题也会出现,就是无法直观的看到每块代码的具体值,每次想查看的时候只能通过声明一个新的变量来赋值查看,这很不Swift

Xcode 13 可以使用给每行代码的任意位置设置断点,通过打印日志来查看详细内容。

可能对于这个 列断点 描述的不是太清楚。可以通过具体操作来了解。

首先创建 列断点:再所选代码位置右键 - Show Code Actions - Create Column Breakpoint

QQ20210920-182540-HD

列断点 跟之前的 行断点 一样,可以 单击、双击、和拖拽。对应的功能也一样。

运行代码,在断点位置处,通过打印日志查看:

QQ20210920-183129-HD

这个功能增加的蛮不错的。

vim

现在你可以从 Xcode 13 中使用 vim 模式来编写代码了。

beta 5 版本中,通过 Editor - Vim Mode 来开启和关闭 vim 模式了。

开启后,Xcode 底部会有对应的快捷键提示。非常友好

image-20210921022842159

其他

除了以上这些之外,Xcode 13 还增加和完善了很多的功能,比如:优化了版本控制功能、新增了 Xcode Cloud 和可以直接在 Xcode 中构件展示官方文档了等等..

image-20210921024217111

更多更详细的内容就需要各位开发者自己亲自去研究和探索了。

总结

相对于之前的版本来说,Xcode 13 看起来让人感觉更加的舒服了,不管是文件风格还是展示形式都显得干净简洁。

当然安装包也还是那么大、还是那么的吃内存。无解~

目前使用起来还是比较顺手的,就是赶紧把编译提醒框退回来,不然每次 command + B 后都要网上看,多别扭。

收起阅读 »

升级到xcode13碰到的问题

iOS
经过了半个月的时间, xcode 没有暴露出来大的 BUG , 可以安心的升级了 然后问题来了, 各种适配问题, 开始撸起来 问题 : The Legacy Build System will be removed in a future release...
继续阅读 »

经过了半个月的时间, xcode 没有暴露出来大的 BUG , 可以安心的升级了


Xcode版本


然后问题来了, 各种适配问题, 开始撸起来



  1. 问题


: The Legacy Build System will be removed in a future release. You can configure the selected build system and this deprecation message in File > Workspace Settings.


错误详情


解决方案:



菜单栏 File->Workspace Settings-> BuildSystem

选择使用 New Buile System(Default)


错误详情



  1. 问题, 文件引入问题(Xcode12之前没有问题)



Multiple commands produce '/Users/imac/Library/Developer/Xcode/DerivedData/dudu-awglbcqfapsmfngrajrwibtvgoph/Build/Products/Debug-iphoneos/dudu.app/PEDat_wb.bundle':

1) Target 'dudu' (project 'dudu') has copy command from '/Users/imac/Desktop/dudu_dev/dudu/WBCloudReflectionFaceVerify_framework/Resources/PEDat_wb.bundle' to '/Users/imac/Library/Developer/Xcode/DerivedData/dudu-awglbcqfapsmfngrajrwibtvgoph/Build/Products/Debug-iphoneos/dudu.app/PEDat_wb.bundle'

2) That command depends on command in Target 'dudu' (project 'dudu'): script phase “[CP] Copy Pods Resources”

Multiple commands produce '/Users/imac/Library/Developer/Xcode/DerivedData/dudu-awglbcqfapsmfngrajrwibtvgoph/Build/Products/Debug-iphoneos/dudu.app/WBCloudReflectionFaceVerify.bundle':

1) Target 'dudu' (project 'dudu') has copy command from '/Users/imac/Desktop/dudu_dev/dudu/WBCloudReflectionFaceVerify_framework/Resources/WBCloudReflectionFaceVerify.bundle' to '/Users/imac/Library/Developer/Xcode/DerivedData/dudu-awglbcqfapsmfngrajrwibtvgoph/Build/Products/Debug-iphoneos/dudu.app/WBCloudReflectionFaceVerify.bundle'

2) That command depends on command in Target 'dudu' (project 'dudu'): script phase “[CP] Copy Pods Resources”

Multiple commands produce '/Users/imac/Library/Developer/Xcode/DerivedData/dudu-awglbcqfapsmfngrajrwibtvgoph/Build/Products/Debug-iphoneos/dudu.app/detector_wb.bundle':

1) Target 'dudu' (project 'dudu') has copy command from '/Users/imac/Desktop/dudu_dev/dudu/WBCloudReflectionFaceVerify_framework/Resources/detector_wb.bundle' to '/Users/imac/Library/Developer/Xcode/DerivedData/dudu-awglbcqfapsmfngrajrwibtvgoph/Build/Products/Debug-iphoneos/dudu.app/detector_wb.bundle'

2) That command depends on command in Target 'dudu' (project 'dudu'): script phase “[CP] Copy Pods Resources”

Multiple commands produce '/Users/imac/Library/Developer/Xcode/DerivedData/dudu-awglbcqfapsmfngrajrwibtvgoph/Build/Products/Debug-iphoneos/dudu.app/ufa_wb.bundle':

1) Target 'dudu' (project 'dudu') has copy command from '/Users/imac/Desktop/dudu_dev/dudu/WBCloudReflectionFaceVerify_framework/Resources/ufa_wb.bundle' to '/Users/imac/Library/Developer/Xcode/DerivedData/dudu-awglbcqfapsmfngrajrwibtvgoph/Build/Products/Debug-iphoneos/dudu.app/ufa_wb.bundle'

2) That command depends on command in Target 'dudu' (project 'dudu'): script phase “[CP] Copy Pods Resources”



解决方案:



target->Build Phases

具体如图, 报错的文件就行了



错误详情



  1. 问题: info.plist 文件问题, 由于项目中使用的库手动拉进来的,



Multiple commands produce '/Users/imac/Library/Developer/Xcode/DerivedData/dudu-awglbcqfapsmfngrajrwibtvgoph/Build/Products/Debug-iphoneos/dudu.app/Info.plist':

1) Target 'dudu' (project 'dudu') has copy command from '/Users/imac/Desktop/dudu_dev/dudu/dudu/Libs/AliyunOSSiOS/Info.plist' to '/Users/imac/Library/Developer/Xcode/DerivedData/dudu-awglbcqfapsmfngrajrwibtvgoph/Build/Products/Debug-iphoneos/dudu.app/Info.plist'

2) Target 'dudu' (project 'dudu') has copy command from '/Users/imac/Desktop/dudu_dev/dudu/dudu/SupportingFiles/Info.plist' to '/Users/imac/Library/Developer/Xcode/DerivedData/dudu-awglbcqfapsmfngrajrwibtvgoph/Build/Products/Debug-iphoneos/dudu.app/Info.plist'

3) Target 'dudu' (project 'dudu') has process command with output '/Users/imac/Library/Developer/Xcode/DerivedData/dudu-awglbcqfapsmfngrajrwibtvgoph/Build/Products/Debug-iphoneos/dudu.app/Info.plist'



解决方案:



直接删除手动拉进来的库里面的 `info.plist` 文件




  1. 项目里面有个 VERSION 的文件, 想不明白这个为啥



Multiple commands produce '/Users/imac/Library/Developer/Xcode/DerivedData/dudu-awglbcqfapsmfngrajrwibtvgoph/Build/Products/Debug-iphoneos/dudu.app/VERSION':

1) Target 'dudu' (project 'dudu') has copy command from '/Users/imac/Desktop/dudu_dev/dudu/dudu/Libs/ST_Mobile/SenseArSourceService/VERSION' to '/Users/imac/Library/Developer/Xcode/DerivedData/dudu-awglbcqfapsmfngrajrwibtvgoph/Build/Products/Debug-iphoneos/dudu.app/VERSION'

2) Target 'dudu' (project 'dudu') has copy command from '/Users/imac/Desktop/dudu_dev/dudu/dudu/Libs/ST_Mobile/VERSION' to '/Users/imac/Library/Developer/Xcode/DerivedData/dudu-awglbcqfapsmfngrajrwibtvgoph/Build/Products/Debug-iphoneos/dudu.app/VERSION'



解决方案:



修改一下 `VERSION` 的文件名称: 或者删除文件




  1. 处理了上面的问题, 依旧还是有问题, 接着修改



Multiple commands produce '/Users/imac/Library/Developer/Xcode/DerivedData/dudu-awglbcqfapsmfngrajrwibtvgoph/Build/Products/Debug-iphoneos/dudu.app/Info.plist':

1) Target 'dudu' (project 'dudu') has copy command from '/Users/imac/Desktop/dudu_dev/dudu/dudu/SupportingFiles/Info.plist' to '/Users/imac/Library/Developer/Xcode/DerivedData/dudu-awglbcqfapsmfngrajrwibtvgoph/Build/Products/Debug-iphoneos/dudu.app/Info.plist'

2) Target 'dudu' (project 'dudu') has process command with output '/Users/imac/Library/Developer/Xcode/DerivedData/dudu-awglbcqfapsmfngrajrwibtvgoph/Build/Products/Debug-iphoneos/dudu.app/Info.plist'



解决方案:



1. 找到 Products 文件夹下的 项目, 邮件 show in finder, 然后向上找, 找到 DerivedData 文件下, 删除对用的文件

到这里这个问题应该已经修复了, 如果还有问题, 继续

2. Build Settings 里面 找到 info.plist 文件夹的位置, (先复制一下路径) 删除, 编译一下, 然后再添加上路径

如果还是不行:

3 Build Phases 里面 Copy Bundle Resources 删除 info.plist 文件



错误详情


好了, 到现在, 我的项目已经能正常运行了,


在翻阅的时候发现 JWAutumn 同学的文章 Xcode 13 更新了哪些内容 这个文章已经更新的比较全了, 可以参考一下


不知道有没有碰到其他问题的, 可以私信我一下, 给加在上面, 供大家参考




  1. Xcode 编译结果的提示, 中间的 Build Successed 和 小锤子不出来了, 没找到在哪里能修改的, 只能在软件上面提示, 感觉习惯了小锤子的提示, 这个修改感觉很不友好




  2. XcodeSnippets (就是自定义的代码块) 不自动提示了, 需要将自定义的名称打全才出来提示 , 比如: 设置的 mark 只有全部打出来才提示代码块, 不然不提示, 也是不友好, 目前没发现在哪里可以提示的




  3. 以前修改的文件, 当前文件的名字变灰, 知道编译后有哪些文件修改过了, 现在直接没了, 没找到哪里设置的 (这个修改很不友好)







作者:Keya
链接:https://juejin.cn/post/7016195057266999304

收起阅读 »

Xcode调试技巧总结

iOS
前言 本来觉得调试是一件很简单的事情,但是看了很多介绍调试方法的文章,发现有些技巧并不知道,有必要对常用的Xcode调试技巧做一个总结,提高工作效率。 一、调试面板 上方:断点开关、继续执行、单步执行、单步步入、单步步过等命令; 左边:watch窗口,负责变...
继续阅读 »

前言


本来觉得调试是一件很简单的事情,但是看了很多介绍调试方法的文章,发现有些技巧并不知道,有必要对常用的Xcode调试技巧做一个总结,提高工作效率。


一、调试面板


image.png


上方:断点开关、继续执行、单步执行、单步步入、单步步过等命令;

左边:watch窗口,负责变量信息显示,如果想查看寄存器的内容,可以将左下角的Auto切换为All

右边:日志窗口,接受和显示程序日志,左下角可以选择All/Debugger/Target output


二、断点


1- 普通断点


找到下断点的代码行,可以通过下面3种方式下断点:

(1)导航栏:Debug->Breakpoint->Add Breakpoint at Current Line

(2)快捷键:command +

(3)鼠标:直接在编辑区域左边行号的地方左键


大部分情况普通断点就满足需求了,但是对于一些特殊的调试情况,还需要掌握一些其他类型的断点。


2- 条件断点


适用场景

(1)一个函数重复多次被调用,但是只需要调试其中某一次的情况时;

(2)对于一些因为异常数据导致的bug调试也很实用;

下断点: 右键普通断点 -> Edit Breakpoint,条件断点和普通断点相比只是多了一个条件判断而已,和我们手动在断点代码加一个if条件判断效果一样,只有满足条件的情况才会断下来;


image.png


3- 符号断点


符号断点: 其实就是对一个特定的函数名下断点,这里的方法可以是OC方法或者C++函数名;

适用场景: 调试一些没有源码的模块时比较有用,比如调试一个第三方提供的Lib库,或者系统模块,可以给相应函数下断点,调试程序的运行流程,查看一些参数信息;

下断点 :断点Tab页 -> 点击下面+号 -> Symbolic Breakpoint


image.png


image.png


设置符号断点可以输入类名+函数名,也可以只输入函数名,xcode会自动匹配不同类中同名的方法进行断点,如下DJTPerson和DJTAnimal都有-(void)djt_run方法,会自动生成两个断点,一旦被调用就会命中断点:


image.png


4- 异常断点


适用场景: 异常断点用来调试程序抛出异常而导致退出,下个异常断点很快就能定位运行到那行代码出了问题;

下断点: 断点Tab -> 左下角+号 -> Exception Breakpoint

Exception Breakpoint也是可以编辑的,可以选择Exception类型,也可以选择在抛出异常或者捕获异常的时候断点等;


image.png


注:有的程序会使用异常来组织程序逻辑,比如微信扫一扫,所以如果Exception选了All,那么异常断点会频繁触发,所以这种情况可以只选择Objective-C异常。


下面是一个异常断点,在DJTPerson类中只有djt_run方法的声明没有实现,触发断点:


image.png


5- watch断点


顾名思义:watch断点就是当某个变量发生改变时触发的断点;

适用场景: watch断点对于要跟踪某个变量或者某个状态的变化时非常有用的,可以方便的跟踪到哪些地方改变了变量的值。

下断点: 在xcode的watch窗口 -> 右键需要watch的变量 -> watch "Xxx":


image.png


如例子中,当_name变量发生变化时调试器会自动断下来,同时输出变化信息:
image.png


6- 线程断点


线程断点: 线程断点适用在调试多线程代码的时候,一段代码可能会被多个线程同时执行,如果下普通断点,那么你会在不同线程之间切来切去,最后自己都迷糊了,这个时候可以使用多线程断点。

下断点: 调试区域右边控制台输出 -> breakpoint set –f 文件名 –l 行号 –t 线程id


下面例子在28行设置普通断点,就可以在控制台打印 thread-id,控制台输入:


thread info

获取当前线程id,在控制台通过命令行给32行设置线程断点:


breakpoint set -f ViewController.m -l 32 -t 0x331854

image.png


7- 断点后的Action


断点后的Action: 当断点被触发可以执行的一些操作;

下断点: 右键断点 -> Edit Breakpoint -> Add action


action类型很多,有调试命令、apple script、shell script等:

image.png


下边是在运行到断点后po一下person.name,直接打印了name的值:
image.png


如果觉得仅仅输出对象信息不够,还想加一些自己指定的内容,可以使用Log Message。


image.png


三、常用命令


1- p命令


p命令:查看基本数据类型的值

po命令:查看oc对象

简单查看一个变量或者OC对象的值在watch窗口完全可以满足,但是如果需要查看一个oc对象的属性,或者一个oc对象方法的返回值怎么办呢?p和po命令后面都可以接相应的表达式,如:


image.png


2- expr命令


expr命令:全称expression,可以在调试时动态修改变量的值,同时打印出结果。使用expr命令动态修改变量的值,可以在调试的时候覆盖一些异常路径,对调试异常处理的代码很有用。


image.png


3- call命令


call命令用来动态调用函数,可以在不增加代码不重新编译的情况下动态调用一个方法。下例动态将view1从父view移除:


image.png


4- image命令


image命令可以列出当前app中的所有模块,可以查找一个地址对应的代码位置,在调试越狱插件时,可以用image list命令查看越狱插件是否注入自己的App。当遇到crash时,查看线程栈只能看到栈帧的地址,使用image lookup -address 地址命令可以方便的定位这个地址对应的代码行。


5- bt命令


bt命令可以查看线程的堆栈信息,该信息也可以在导航区的Debug Navigator看到;

bt:打印当前线程栈
bt all:打印所有线程栈


image.png


分割线:上边介绍了基本的调试技巧,下面是一些不同场景下的调试经验


四、多线程


场景:在调试的时候bug不出现,一旦关闭调试直接运行bug就出现:这种问题大部分是因为多线程bug,而调试影响了多线程的执行顺序。

技巧:这种问题可以在关键点输出log,之前介绍的断点action中的Log Message就派上用场,这样的好处是不需要在代码中添加冗余的log即可调试;在调试多线程问题时,合理使用线程断点和条件断点也是很有帮助的;


五、UI调试


1-控件信息


查看控件信息除了使用p和po命令,还可以使用expr命令修改控件属性,如内容、坐标、大小等,这样可以不重启程序看到界面的变化;


2-界面结构


查看界面结构:po [view recursiveDescription],该命令可以打印出view的所有子view的结构关系,对于调试界面层级关系很有用;


3-快速预览


xcode支持在调试时对变量进行快速预览,调试时将鼠标放在变量上,然后点击快速预览按钮即可看到控件的显示。


image.png


4-符号断点跟踪UI变化


对于一些系统控件的信息,如果发现最终显示和自己设置的不一样,可以使用符号断点,在一些设置函数下断点,这样就可以很清晰的看到是从哪里改变了这个属性的值。比如一个UIButton的title在显示的时候和设置的不一样,只需要符号断点设置setTitle就可以跟踪哪里改了值;



作者:D___
链接:https://juejin.cn/post/6950852311346315271

收起阅读 »

我做了一款vuepress的音乐可视化播放插件

体验地址:博客,github,npm前言博客上的音乐播放器,大多都长一个样,小小的,塞在页面的一个角落里,在别人阅读文章的同时可以听音乐,增加某些体验的满意指数。而我,做了一件不太一样的事情:博客不就是让人看文章的么?再播放音乐甚至有可能会降低阅读的质量,那听...
继续阅读 »



体验地址:博客githubnpm

前言

博客上的音乐播放器,大多都长一个样,小小的,塞在页面的一个角落里,在别人阅读文章的同时可以听音乐,增加某些体验的满意指数。而我,做了一件不太一样的事情:

博客不就是让人看文章的么?再播放音乐甚至有可能会降低阅读的质量,那听歌就好好听歌不好么?既然要体验,那就沉浸体验到爽不好么?

某天,偶然打开了豆瓣FM网页版,很符合豆瓣的感觉,干净简洁,当然网上类似的音乐播放有很多,这里为我后面做的事情埋下了伏笔。

我博客是用 vuepress 搭建的,主题是 vuepress-reco,最开始想找一个播放音乐的插件,于是去找了 awesome-vuepress,搜到唯一和音乐相关的插件,只有一个叫:vuepress-plugin-music-bar 的插件.....还是个bar....有点失落。于是,没人做?那...我做个试试?最终的效果图就是上面看到的四张图了:亮/暗系歌词,亮暗系可视化解码。在看完 vuepress 官网的插件api,就开始搞了!

开搞

不管怎么画页面,初衷是沉浸式体验,找了很多播放器的大体结构,还是觉得网易云的播放界面算比较舒服的,自己也有尝试画过脑海里的播放界面,但是最终还是选择用网易云的效果(拿来吧你):左侧黑胶唱片滚动,右侧歌词滚动, 目前不需要上一曲下一曲,就有播放和分享按钮,也就是长这个样子:

一天半时间,匆匆忙忙做完之后,npm link 调试成功就发了一版npm包。好用?不好说。能不能用?能!

优化

做到这里之后,沉浸式有那么点感觉了,体验?照搬过来就是好的体验么?不,还是要加点东西,比如可视化

这里特别感谢网易云大前端团队的一篇文章:Web Audio在音频可视化中的应用,基本上照着看下来,里面的文献也看一下,就可以做出来上面的效果。说实话,文献是真头大....波长,正余弦,频域时域,奈奎斯特定理,还有什么快速傅里叶变换,头发在偷偷的掉...顺便附上一张某个文献的截图:

不过不看这些也可以做出来!

基本上的思路就是:

  1. 创建 AudioContext,关联音频输入,进行解码、控制音频播放和暂停

  2. 创建 analyser ,获取音频的频率数据(FrequencyData)和时域数据(TimeDomainData)

  3. 设置快速傅里叶变换值,信号样本的窗口大小,区间为32-32768,默认2048

  4. 创建音频源,音频源关联到分析器,分析器关联到输出设备(耳机、扬声器等)

  5. 获取频率数组,转格式,然后用 requestAnimationFrame 通过 canvas 画出来

这些东西上面的文章里讲的很详细,我这种门外汉就不多说啥了。

遇到的问题

npm link

之前使用 npm link 的时候,依赖包没有三方/四方的依赖,所以没注意到,如果开发的npm包带有别的依赖,那么调试的时候要在主项目里的 package.json 先加上这些包,就不会报错说 resolve 失败什么的了,调试结束记得 npm unlink 断开。

接口

本来想用的是 网易云音乐 NodeJS 版 API,但是有些东西不好找,比如我需要歌曲id,封面和歌词,但是文档里没有歌曲id反查专辑id的(封面在专辑id里),只有一个歌曲详情的,但是这个接口,还需要认证跳转....对于使用者来说,我没必要让使用者多这么一步操作,而且很容易出错。于是就换了一个api:保罗API,这个API可以解析的网易云歌曲不是那么的多,不过一般的够了,唯一的缺点就是,多频次刷新会一直 pending,应该是后端设置了ip频次。

既然都有问题,不使用接口行么?尝试找一种 mp3 文件解析出来歌词和封面呢?找到一个 jsmediatags 的仓库,可以解析ID3v2,MP4,FLAC等字段,但是.....这不就是给用户添加麻烦么?需要找专辑,歌词,歌曲,艺人信息全部合一的音源文件....如果我是用户,我不会用它。

翻来覆去,最终还是决定,歌曲用户传进来,然后再传一个歌曲id,封面和歌词走接口,歌曲就是传进来的音源链接,使用方法如下:

<MusicPlayer musicId="xxx" musicSrc="xxx.mp3" style="margin:0 auto">

音源我个人建议要么放vuepress的静态资源,要么就搞成类似图床一样的音源仓库,这样也好维护。

后期想办法优化吧。

主题色

亮系和暗系是适配 vuepress-reco 的主题切换做的适配。

结尾

灵感来自 豆瓣FM,结构参考了昊神的 音乐播放器,可视化播放参考了 Web Audio在音频可视化中的应用,接口感谢 保罗API,这么一说我好像也没做什么事.....

该插件已发npm包,awesome-vuepress 仓库也已收录,可能多少还会有点体验上的小问题,会慢慢修复的。大家也可以提建议,能听进去算我输!

项目写的匆匆忙忙,希望可以做一点更有深度的东西吧——致自己。


作者:道道里
来源:https://juejin.cn/post/7045944008190722079

收起阅读 »

微信小程序反编译获取源码

文章目录 前言一、前置条件二、操作步骤1.进入adb shell2.提取源码编译文件3.反编译前言 对微信小程序进行源码反编译,一般目的为:获取js签名算法,过数据包的防篡改策略获取接口的判断逻辑,一般用于修改返回包来达到未授权的效果,在尝试无法找到争取的返回...
继续阅读 »



文章目录

前言

对微信小程序进行源码反编译,一般目的为:

  • 获取js签名算法,过数据包的防篡改策略

  • 获取接口的判断逻辑,一般用于修改返回包来达到未授权的效果,在尝试无法找到争取的返回值的时候,需要从源码来进行构造

本文旨在记录如何对一个微信小程序进行编译获取其源码,后续分析不做分享,因目的不同,分析的方式也会不同

一、前置条件

需要一台root了的安卓测试手机,root的方式请自行查找。如果是红米手机,可以参考我的root方式,博客链接

本文演示的步骤,基于macbook m1进行,其他设备操作基本也差不多

二、操作步骤

1.进入adb shell

命令如下(示例):

(base)   ~ adb shell
davinci:/ $ whoami
shell
# 提权
davinci:/ $ su root
davinci:/ # whoami
root

2.提取源码编译文件

代码如下(示例):

davinci:/ # cd /data/data/com.tencent.mm                                                                                                               
davinci:/data/data/com.tencent.mm # ls
982178cdd5589cb042c4efb99be0333c  WebNetFile                        ipcallCountryCodeConfig.cfg  recovery        version_history.cfg
CheckResUpdate                    appbrand                          last_avatar_dir              regioncode      webcompt
ClickFlow                         autoauth.cfg                      luckymoney                   snsreport.cfg   webservice
CompatibleInfo.cfg                channel_history.cfg               media_export.proto           staytime.cfg    webview_tmpl
CronetCache                       configlist                        mmslot                       systemInfo.cfg
NowRev.ini                        deviceconfig.cfg                  mobileinfo.ini               textstatus
ProcessDetector                   ee1da3ae2100e09165c2e52382cfe79f  newmsgringtone               tmp
WebCanvasPkg                      heavy_user_id_mapping.dat         patch_ver_history.bin        trace

重点关注一个很长的用户随机码,比如ee1da3ae2100e09165c2e52382cfe79f和982178cdd5589cb042c4efb99be0333c,分别访问判断即可

davinci:/data/data/com.tencent.mm/MicroMsg # cd ./982178cdd5589cb042c4efb99be0333c/                                                        
davinci:/data/data/com.tencent.mm/MicroMsg/982178cdd5589cb042c4efb99be0333c # cd appbrand/                                                          
davinci:/data/data/com.tencent.mm/MicroMsg/982178cdd5589cb042c4efb99be0333c/appbrand # ls
pagesidx  pkg  web_renderingcache
davinci:/data/data/com.tencent.mm/MicroMsg/982178cdd5589cb042c4efb99be0333c/appbrand # cd pkg/  
davinci:/data/data/com.tencent.mm/MicroMsg/982178cdd5589cb042c4efb99be0333c/appbrand/pkg # ls
_-1223314631_166.wxapkg  _-1991183043_171.wxapkg  _-86252332_166.wxapkg   _1233860900_205.wxapkg  _1233860900_230.wxapkg  _2106768478_166.wxapkg
_-1223314631_167.wxapkg  _-289032338_166.wxapkg   _-86252332_167.wxapkg   _1233860900_206.wxapkg  _1233860900_231.wxapkg  _2106768478_171.wxapkg
_-1223314631_168.wxapkg  _-289032338_167.wxapkg   _-86252332_168.wxapkg   _1233860900_207.wxapkg  _1233860900_232.wxapkg  _288413523_8.wxapkg

这些wxapkg即编译后的小程序源码,为了准确找到目标小程序对应的wxapkg文件,可以重新访问目标小程序,之后对这些包进行排序,找到最新的

davinci:/data/data/com.tencent.mm/MicroMsg/982178cdd5589cb042c4efb99be0333c/appbrand/pkg # ls -lt                                                    
total 238092
-rw------- 1 u0_a239 u0_a239   907997 2021-12-26 15:25 _255193015_171.wxapkg
-rw------- 1 u0_a239 u0_a239   427489 2021-12-25 09:37 _1245338104_171.wxapkg
-rw------- 1 u0_a239 u0_a239   258272 2021-12-25 09:35 _2106768478_171.wxapkg
-rw------- 1 u0_a239 u0_a239   745490 2021-12-25 09:35 _927440678_171.wxapkg

找到了目标文件之后,需要将其挪到电脑的目录下。mac环境下,可以下载一个Android文件传输工具,之后通过mv命令,将该文件移动到可访问的目录,即可拖到电脑目录下

mv /data/data/com.tencent.mm/MicroMsg/982178cdd5589cb042c4efb99be0333c/appbrand/pkg/_255193015_171.wxapkg  /mnt/sdcard/Download

注意:有些情况下是分包,需要删除pkg目录下所有文件,重新访问该小程序,之后将所有的wxapkg都移动出来

davinci:/data/data/com.tencent.mm/MicroMsg/982178cdd5589cb042c4efb99be0333c/appbrand # mv /data/data/com.tencent.mm/MicroMsg/982178cdd5589cb042c4efb99be0333c/appbrand/pkg/*  /mnt/sdcard/Download/wxpkg           

davinci:/mnt/sdcard/Download/wxpkg # ls
_-1223314631_171.wxapkg _-1820590985_171.wxapkg _-372062782_171.wxapkg _1123949441_612.wxapkg _255193015_171.wxapkg
_-1325581962_171.wxapkg _-1991183043_171.wxapkg _-86252332_171.wxapkg   _2041131240_171.wxapkg _453111957_171.wxapkg
_-1536422934_171.wxapkg _-289032338_171.wxapkg   _-942297262_171.wxapkg _2106768478_171.wxapkg _927440678_171.wxapkg

3.反编译

使用wxappUnpacker

# 安装依赖(具体参考下官方github)
npm install

node wuWxapkg.js /Users/spark/tools/安卓武器库/_255193015_171.wxapkg

忽略分包爆错,直接进去格式化一下js,Ctrl+F搜索接口

作者:Sp4rkW
来源:https://blog.csdn.net/wy_97/article/details/122155518

收起阅读 »

解决小程序里面的图片之间有空隙的问题

1、将图片转换为块级对象  即,设置img为:  display:block;  在本例中添加一组CSS代码:  #sub img {display:block;}2、设置图片的垂直对齐方式  即设置图片的vertical-align属性为“top,text-...
继续阅读 »

1、将图片转换为块级对象

  即,设置img为:

  display:block;

  在本例中添加一组CSS代码:

  #sub img {display:block;}

2、设置图片的垂直对齐方式

  即设置图片的vertical-align属性为“top,text-top,bottom,text-bottom”也可以解决。如本例中增加一组CSS代码:

  #sub img {vertical-align:top;}

3、设置父对象的文字大小为0px

  即,在#sub中添加一行:

  font-size:0;

  可以解决问题。但这也引发了新的问题,在父对象中的文字都无法显示。就算文字部分被子对象括起来,设置子对象文字大小依然可以显示,但在CSS效验的时候会提示文字过小的错误。

4、改变父对象的属性

  如果父对象的宽、高固定,图片大小随父对象而定,那么可以设置:

  overflow:hidden;

  来解决。如本例中可以向#sub中添加以下代码:

  width:88px;height:31px;overflow:hidden;

5、设置图片的浮动属性

  即在本例中增加一行CSS代码:

  #sub img {float:left;}

  如果要实现图文混排,这种方法是很好的选择。

6、取消图片标签和其父对象的最后一个结束标签之间的空格。

原文:https://blog.csdn.net/Function_JX_/article/details/79588578

收起阅读 »

【科幻】为何宇宙中有智能生命,基于量子退火的一种解释:“必然论”

原文链接: zhuanlan.zhihu.com之前在知乎写过一个宇宙层面的推理链条,类似"黑暗森林"的那种"纯靠推理猜测宇宙真相",也许能对人类和宇宙的目的给出一种解释。在这里略作整理,欢迎大家讨论。【公设1】宇宙遵循量子力学。例如,宇宙有 Lag...
继续阅读 »
原文链接: zhuanlan.zhihu.com

之前在知乎写过一个宇宙层面的推理链条,类似"黑暗森林"的那种"纯靠推理猜测宇宙真相",也许能对人类和宇宙的目的给出一种解释。在这里略作整理,欢迎大家讨论。

【公设1】宇宙遵循量子力学。

例如,宇宙有 Lagrangian,遵循路径积分。或者简化些,宇宙有波函数(忽略 t 在宇宙尺度上的微妙性),有 Hamiltonian,有"能量"。

通俗地说,路径积分的意思是:给定初态和末态,宇宙会找到概率大的演化路径(实际更类似找相位稳的路径,实际还更复杂,这里忽略细节),实现末态(不妨称为宇宙的目标)。

更通俗地说,就像量子退火,无论目标有多么遥远,宇宙都会逐渐找到办法降低自己的"能量"(实际更复杂,这里忽略细节),而且,宇宙找到的方案,一定非常高效,因为量子退火是一个强力的优化方法。

其实,训练深度神经网络的过程,已经告诉我们,在参数空间足够大时,梯度下降是很强的方法。而量子退火,比经典的梯度下降更强得多,宇宙的相空间更是非常巨大,所以宇宙尺度的量子退火,会非常非常强。

【公设2】宇宙的 Hamiltonian / Lagrangian,不仅包括现在的"标准模型"的微观的项目(各种微观粒子之间的耦合),还包括宇宙的大尺度的特性。

例如,维数(其实"标准模型"的项目,已经与维数有关)。

也可能包括更科幻的项目,例如"增大自己的尺寸"(吞并其它宇宙,等等),"分化出更多宇宙","尽快自我毁灭","完成某种循环","展现可能性","改变熵",等等。

具体是什么,不知道,不过,如果这里的理论是对的,那么,迟早有生命会知道,也许宇宙某处已经有生命知道了。

【公设3】智能生命有能力改造宇宙的大尺度的特性,就像降维升维之类。而且,由于智能生命有意识,所以,改造宇宙的速度,会相当快,比无生命的宇宙自己演化的速度更快。

可以这么比喻,无生命的宇宙完成目标,需要穿越很强的壁垒,而智能生命是催化剂,可以让宇宙快速完成目标。

【公设4】智能生命的进化和发展,也受量子力学的控制。

例如,某个基因应该如何变异,遵循量子规律,用拟人的语言说,宇宙对此有很强的"选择偏向"。

当然,量子的特点,是有误差,只要最终能达到类似的目标,宇宙不在意细节。偶然的倒退也不要紧。

【结论】

我们得到了一个有趣的结论:

宇宙会发现,在自己之中形成智能生命,然后让智能生命改造自己,是实现自己的目标的最佳方法。

换而言之,智能生命的出现和发展,不是偶然,是必然。

由于量子退火的高效,我们会发现,智能生命的形成和发展,会像是被看不见的手引导着一般,几乎每一次进化,每一个事件,都会走在接近正确的道路上,宛如神迹。宇宙的规律,就是看不见的手。

我一直感觉,虽然地球有46亿年,虽然宇宙有亿亿亿颗星,但人类还是出现得太快了。

如果只是以进化论的"尽可能传播自己"为目标,完全没必要进化出人类。用 AI 的语言说,这个"目标函数"太弱。

但是,如果宇宙的目标,需要科技极其发达的智能生命,那就不一样,这个目标函数非常非常强。

我的感觉是,即使在人类出现之后,人类的发展,有时看上去也有些"逢凶化吉",包括从前几乎走不出非洲,包括两次世界大战对于现在的和平发展做出了不可磨灭的贡献,包括冷战的过程。人类曾多次在毁灭的边缘,但都逃了过去,危险变成了历史,留给人类无数宝贵的经验和教训。当然,这些都可以用生存者偏差来解释,但仍然是很令人深思的。

【尾声】

这个理论,不妨称为"必然论"。它有很多有趣的推论,这就不用多说了,大家讨论比较有意思。

可能大家会问,这么看来,宇宙中是否应该到处都是生命?我觉得不一定。因为如果一种生命就足够完成目标,是没有必要进化出多种生命,毕竟,进化生命的过程,是"逆天"的过程(减熵)。

当然,也可能由于大家可以想象的原因,必须进化出多种生命。也可能,人类只是这个过程的一个阶段。人类不一定是"天选之族"。宇宙的目标也可能是很奇异的,而且现在我们也无法知道是否生存在模拟之中。总而言之,可以有很多有趣的推论。

收起阅读 »

漫话:如何给女朋友解释灭霸的指响并不是真随机"消灭"半数宇宙人口的?

周末,陪女朋友去电影院看了《复仇者联盟4:终局之战》,作为一个漫威粉三个小时看的是意犹未尽。出来之后,准备和女朋友聊一聊漫威这十年。在《复仇者联盟》电影中,灭霸毕生都有一个目标,那就是通过抹除一半的生命来维持宇宙的平衡。并且,灭霸还说,这个抹除过程是:随机性的...
继续阅读 »


周末,陪女朋友去电影院看了《复仇者联盟4:终局之战》,作为一个漫威粉三个小时看的是意犹未尽。出来之后,准备和女朋友聊一聊漫威这十年。

在《复仇者联盟》电影中,灭霸毕生都有一个目标,那就是通过抹除一半的生命来维持宇宙的平衡。

并且,灭霸还说,这个抹除过程是:随机性的、不夹私情、绝对公平、无论贵贱。

那么,到底什么是随机?他所谓的随机真的如他所说是不夹私情、绝对公平以及无论贵贱的吗?

随机性

随机性这个词是用来表达目的、动机、规则或一些非科学用法的可预测性的缺失。一个随机的过程是一个不定因子不断产生的重复过程。

提到随机性,不得不提的就是随机数,随机数在计算机应用中使用的比较广泛,最为熟知的便是在通信安全和现代密码学等领域中的应用。

随机数分为真随机数和伪随机数,我们程序中使用的基本都是伪随机数。

  • 真随机数,通过物理实验得出,比如掷钱币、骰子、转轮、使用电子元件的噪音、核裂变等。需要满足随机性、不可预测性、不可重现性。

  • 伪随机数,通过一定算法和种子得出。软件实现的是伪随机数。

只要这个随机数是由确定算法生成的,那就是伪随机。只能通过不断算法优化,使你的随机数更接近随机。

有限状态机不能产生真正的随机数的。所以,现代计算机中,无法通过一个纯算法来生成真正的随机数。无论是哪种语言,单纯的算法生成的数字都是伪随机数,都是由可确定的函数通过一个种子,产生的伪随机数。

为啥灭霸并不公平?

前面我们提到过,真随机数要满足随机性、不可预测性、不可重现性。

我们按照这三个性质逐一分析下,看看灭霸到底是不是公平的。

随机性

随机性,指的是不存在统计学偏差,是完全杂乱的数列。

复联3中,灭霸打了指响之后,复仇者联盟中存活和死亡的名单其实并不是随机的。其中很多对CP都是杀1留1的。如钢铁侠——蜘蛛侠、美队——冬兵、火箭浣熊——格鲁特、蚁人——黄蜂女等。

而且,还有一点就是,如果真的是随机性的话,那么灭霸自己也是有一定的概率会被抹除的,但是,他早就知道自己不会被抹除,并且已经制定好了退休计划。

并且,在复联3中,奇异博士用时间宝石和灭霸换了钢铁侠的生命,说明灭霸其实是选择性的进行抹除的。

可见,灭霸的指响抹除过程并不是随机的。

不可预测性

不可预测性,指的是不能从过去的数列推测出下一个出现的数。

这一点了解电影的朋友应该都知道,奇异博士曾经利用时间宝石穿越了时空,预测了未来,并看到了14000605种可能。

可见,灭霸的指响抹除过程并不是不可预测的。

不可重现性

不可重现性,除非将数列本身保存下来,否则不能重现相同的数列。

在复联3中,钢铁侠问奇异博士,14000605种可能中,胜利的有多少种。奇异博士回答:1种。

在复联4中,最后奇异博士对钢铁侠比了下面这样一个手势。说明,他看到的那唯一一种胜利的可能要复现了。

可见,灭霸的指响抹除过程并不是不可复现的。

综上,灭霸的指响抹除过程不符合随机性、不可预测性以及不可复现性。所以,灭霸的指响抹除过程并不是真正的随机的。

通过现象来看,灭霸的抹除操作很可能只是通过简单的分层抽样实现的。简单操作过程如下:

  • 1、把需要特殊处理,不做抹除的人的DNA单独从所有物种的DNA库中识别出来,并保存到缓存中。

  • 2、根据不同的条件把DNA库中的所有生命体划分成若干区块,如地球人、阿斯加德人等。把他们的DNA信息保存到不同的数据库中。在遍历的过程中,如果遇到缓存中已有的数据,则跳过。

  • 3、再根据物种多样性,如性别、年龄段、职业等把同一个分库中的数据分别划分到不同的表中,保证每一张分表中都包含了完整的物种多样性。

  • 4、遍历所有数据库,按顺序的删除每个数据库中一半的分表。如地球人的数据库中共有1024张表,只保留512张即可。

  • 5、再把缓存中的数据同步到数据库中。

这样,在后面需要复活这些人的时候,只需要找到数据库的Binlog,把数据重新写入数据库就行了。

真随机数生成器

真正的随机数是使用物理现象产生而不是计算机程序产生的。生成随机数的设备我们称之为真随机数生成器。

这样的设备通常是基于一些能生成低等级、统计学随机的“噪声”信号的微观现象,如热力学噪声、光电效应和量子现象。

从某种程度上来说,基于经典热噪声的随机数芯片读取当前物理环境中的噪声,并据此获得随机数。这类装置相对于基于软件算法的实现,由于环境中的变量更多,因此更难预测。

然而在牛顿力学的框架下,即使影响随机数产生的变量非常多,但在每个变量的初始状态确定后,整个系统的运行状态及输出在原理上是可以预测的,因此这一类装置也是基于确定性的过程,只是某种更难预测的伪随机数。

但是,量子力学的发现从根本上改变了这一局面,因为其基本物理过程具有经典物理中所不具有的内禀随机性,从而可以制造出真正的随机数产生器。

据美国国家标准与技术研究院(NIST)官网消息,该机构研究人员在2018年4月出版的《自然》杂志上撰文指出,他们开发出一种新方法,可生成由量子力学保证的随机数字。新技术超越了此前获得随机数字的所有方法,得到了“真正的随机数字”,有助增强密码系统的安全性。(原文地址:https://www.nature.com/articles/s41586-018-0019-0.epdf )

NIST数学家彼特·比尔霍斯特进一步解释说:“诸如翻转硬币之类的情况似乎是随机的,但如果能看到硬币确切的下落路径,最终结果也是可以预测的。因此,很难保证给定经典来源真正不可预测。量子力学在产生随机性方面表现更好,量子随机是真正的随机,因为对处于‘叠加’状态的量子粒子进行测量,得到的结果基本上是不可预测的。”

在复联4中,也有很多和量子物理有关的知识,甚至最终可以扭转乾坤也是依靠的量子领域。漫威电影的宗旨可以高度概括成以下四句话:遇事不决,量子力学。 解释不通,穿越时空。 篇幅不够,平行宇宙。 定律不足,高维人族。

Java中的随机数生成器

Java中生成随机数还是比较简单的,Java提供了很多种API可以供开发者使用。

通过时间获取

在Java中,可以通过System.currentTimeMillis()来获取当前时间毫秒数:

final long l = System.currentTimeMillis();

若要获取指定范围的数字,只需要对数字进行取模就行了,如下方法可以获得0-99的随机数:

final long l = System.currentTimeMillis();
final int i = (int)( l % 100 );

Math.random()

通过Math.random()可以返回0(包含)到1(不包含)之间的double值。使用方法如下:

final double d = Math.random();

若要获取int类型的整数,只需要将上面的结果转行成int类型即可。比如,获取[0, 100)之间的int整数。方法如下:

final double d = Math.random();
final int i = (int)(d*100);

Random类

Java提供的伪随机数发生器有java.util.Random类和java.util.concurrent.ThreadLocalRandom类。

Random类采用AtomicLong实现,保证多线程的线程安全性,但正如该类注释上说明的,多线程并发获取随机数时性能较差。

多线程环境中可以使用ThreadLocalRandom作为随机数发生器,ThreadLocalRandom采用了线程局部变量来改善性能,这样就可以使用long而不是AtomicLong,此外,ThreadLocalRandom还进行了字节填充,以避免伪共享。

如使用Random获取[0, 100)之间的int整数,方法如下:

Random random = new Random();
int i2 = random.nextInt(100);

强随机数发生器

强随机数发生器依赖于操作系统底层提供的随机事件。强随机数生成器的初始化速度和生成速度都较慢,而且由于需要一定的熵累积才能生成足够强度的随机数,所以可能会造成阻塞。熵累积通常来源于多个随机事件源,如敲击键盘的时间间隔,移动鼠标的距离与间隔,特定中断的时间间隔等。所以,只有在需要生成加密性强的随机数据的时候才用它。

Java提供的强随机数发生器是java.security.SecureRandom类,该类也是一个线程安全类,使用synchronize方法保证线程安全,但jdk并没有做出承诺在将来改变SecureRandom的线程安全性。因此,同Random一样,在高并发的多线程环境中可能会有性能问题。

这个锅,研发人员不背!!!

根据我的猜想。对于无限手套这个产品,产品经理最初的需求可能只是满足使用者的一个愿望而已,而几颗宝石就像是七龙珠一样,集齐之后打个指响就可以实现愿望。

开发者只是提供了一个可以满足愿望的API接口,参数是一个Callback,具体做什么事情,完全是使用者传进来的想法而已。就像灭霸要抹除一半的生命、绿巨人想要把被抹掉的人救回来、而钢铁侠只是想把坏人抹掉而已。

最后,Tony, Love You 3000 Times.

收起阅读 »

黑科技- iOS静态cell和动态cell结合使用

iOS
1. 什么是静态Cell。 静态cell,可以直接布局cell样式的、group、insert group等直接拖@IBOutlet 布局简单,实用,比如我们同一类型的登陆、密码、设置、WIFI等页面 2. 怎么使用静态Cell。 必须使用StoryBo...
继续阅读 »

1. 什么是静态Cell。



  • 静态cell,可以直接布局cell样式的、group、insert group等直接拖@IBOutlet

  • 布局简单,实用,比如我们同一类型的登陆、密码、设置、WIFI等页面


2. 怎么使用静态Cell。



  • 必须使用StoryBoard来创建UITableViewController

  • image-20210823133103767.png

  • 然后你就可以直接使用cell的布局,运行出来就是StoryBoard的布局

  • image-20210823133226364.png

  • 运行以后的效果

  • image-20210823133337424.png


3. 和动态Cell结合。



  • 如例子:Wi-Fi的截图,我的网络和其他网络可以用动态cell来创建、其他的都可以直接用静态cell来创建


image-20210823132756571.png


使用步骤:




  1. 先在StoryBoard创建静态的cell,需要复用的cell留出一个位置即可

  2. 复用的cell必须单独创建(或者使用单独的xib文件)

  3. 这使用的时候,必须注册cell

  4. 动态的cell必须实现UITableViewDelegate的indentationLevelForRowAt这个方法

  5. 在这个方法里indentationLevelForRowAt返回第一个这StoryBoard留出位置的cell的indexPath


详情请看下列代码实现;



image-20210823133226364.png


第二个section 留白的就是给动态cell实现的


image-20210823134208316.png


创建一个xib的动态cell实现(可复用)


// 注册Cell
tableView.register(UINib(nibName: "CostomTableViewCell", bundle: nil), forCellReuseIdentifier: "CostomTableViewCell")


override func tableView(_ tableView: UITableView, indentationLevelForRowAt indexPath: IndexPath) -> Int {
   if indexPath.section == 1 {
       return super.tableView(tableView, indentationLevelForRowAt: IndexPath(row: 0, section: 1))
   }
   return super.tableView(tableView, indentationLevelForRowAt: indexPath)
}


实现indentationLevelForRowAt方法,返回IndexPath(row: 0, section: 1)第一section 的第一个row,其他不需要复用的自己返回父类即可


override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
   if indexPath.section == 1 {
       let cell = tableView.dequeueReusableCell(withIdentifier: "CostomTableViewCell", for: indexPath) as! CostomTableViewCell
       cell.dynamic.text = "dynamicRow:(dynamicRowArray[indexPath.row])"
       return cell
   }
   return  super.tableView(tableView, cellForRowAt: indexPath)
}

在cellForRowAt复用里写已经要实现的复用的cell,其他静态cell直接返回父类即可


最终实现的效果


image-20210823134654515.png


4. Row、Section使用的技巧,以及常出现的问题。



  • 如果复用的是row,直接实现indentationLevelForRowAt这个方法和cellForRowAt方法即可 必须实现,不然会崩溃

  • 但是如果是复用的section,就必须实现UITableViewDataSource的和数据源相关的方法以下的几个方法


// 自定义动态section 的时候,以下方法必须实现,否则会崩溃
override func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
   nil
}

override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
   nil
}

override func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? {
   nil
}

override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? {
   nil
}

override func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
   5
}

override func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat {
   .leastNonzeroMagnitude
}



  • 默认情况下会调用父类的数据,静态cell是不实现这些方法是越界的


注意事项:



  1. indentationLevelForRowAt 这个方法是动态和静态结合必须实现的方法

  2. Row、Section所需要实现的方法有差别,当是Section的时候需要实现与section相关的代理和数据源,例如sectionHeaderView、Footer等

  3. 动态cell一定要在storyboard里留白,自定义需要复用的cell必须使用xib、或者自定义,不能在原有的storyboard里创建

  4. 如果遇到崩溃,大多数是因为数据越界,数据源的问题,如果以上都实现,基本是没有问题的


Demo地址


作者:芭菲猫
链接:https://juejin.cn/post/6999504422065831943

收起阅读 »

std::out_of_range异常

iOS
使用C++容器类访问成员时由于使用问题可能会遇到"terminate called after throwing an instance of 'std::out_of_range'"或者"Abort message: 'terminating with un...
继续阅读 »

使用C++容器类访问成员时由于使用问题可能会遇到"terminate called after throwing an instance of 'std::out_of_range'"或者"Abort message: 'terminating with uncaught exception of type std::out_of_range"。问题的大概意思是:访问越界了。没有捕获std::out_of_range类型的异常终止。

通常在使用vector、map这样的C++容器类型时会遇到,这里我们以map类型为例,加以说明。

std::out_of_range异常的描述

假设我们定义了一个map类型的变量g_mapIsDestroyedRefCount,要访问容器中的数据项有多种方式。例如,获取g_mapIsDestroyedRefCount中key值为cameraId的值,可以这样:

  1. g_mapIsDestroyedRefCount[cameraId]
  2. g_mapIsDestroyedRefCount.at(cameraId)

两种写法都可以获取key为cameraId的value,一般效果看不出来差别,但是当g_mapIsDestroyedRefCount中不存在key为cameraId的<key, value>时就会出现“std::out_of_range”访问越界问题。

导致std::out_of_range的原因

容器类型访问方法使用有问题

对于std::map::at官方声明:

  mapped_type& at (const key_type& k);
const mapped_type& at (const key_type& k) const;

对于std::map::at使用有如下说明: Access element        访问元素

Returns a reference to the mapped value of the element identified with key k.      返回元素键为k的映射值的引用,即Key为k的元素的对应value值。
If k does not match the key of any element in the container, the function throws an out_of_range exception.   如果容器中没有匹配的k键,该函数将抛出一个out_of_range异常

 

std::map::at的使用

  • 正确使用
  • 错误使用

1.std::map::at的正确使用

 

#include <iostream>
#include <string>
#include <map>

std
::map<int, int> g_mapIsDestroyedRefCount;

int main()
{
int cameraId = 1;
cout
<< "Let's try"<< endl;

//向map中添加测试数据
g_mapIsDestroyedRefCount
.insert(std::pair<int, int>(0, 2))'
cout << "cameraId:"<< cameraId<< "count:";
try {
cout<< g_mapIsDestroyedRefCount.at(cameraId) <<endl;
} catch (const std::out_of_range& oor) {
std::cerr << "\nOut of range error:" << oor.what()<< endl;
}
cout << "try done"<< endl;
return 0;
}

 

运行结果:

 

2.std::map::at错误使用

#include <iostream>
#include <string>
#include <map>
using namespace std;

std
::map<int, int> g_mapIsDestroyedRefCount;

int main()
{
int cameraId = 2;

cout
<< "Let's try"<< endl;
g_mapIsDestroyedRefCount
.insert(std::pair<int, int>(0, 2));
cout
<< "cameraId:"<< cameraId<< "count:";

//介绍中说的方法一,可以访问
cout
<< g_mapIsDestroyedRefCount[cameraId]<< endl;
//方法二,异常
cameraId = 2;
count
<< g_mapIsDestroyedRefCount.at(cameraId)<< endl;
cout<< "try done"<< endl;
}

运行结果:(程序异常退出)


收起阅读 »

傻傻分不清之 Cookie、Session、Token、JWT

什么是认证(Authentication)通俗地讲就是验证当前用户的身份,证明“你是你自己”(比如:你每天上下班打卡,都需要通过指纹打卡,当你的指纹和系统里录入的指纹相匹配时,就打卡成功)互联网中的认证:用户名密码登录邮箱发送登录链接手机号接收验证码只要你能收...
继续阅读 »



什么是认证(Authentication)

  • 通俗地讲就是验证当前用户的身份,证明“你是你自己”(比如:你每天上下班打卡,都需要通过指纹打卡,当你的指纹和系统里录入的指纹相匹配时,就打卡成功)

  • 互联网中的认证:

    • 用户名密码登录

    • 邮箱发送登录链接

    • 手机号接收验证码

    • 只要你能收到邮箱/验证码,就默认你是账号的主人

什么是授权(Authorization)

  • 用户授予第三方应用访问该用户某些资源的权限

    • 你在安装手机应用的时候,APP 会询问是否允许授予权限(访问相册、地理位置等权限)

    • 你在访问微信小程序时,当登录时,小程序会询问是否允许授予权限(获取昵称、头像、地区、性别等个人信息)

  • 实现授权的方式有:cookie、session、token、OAuth

什么是凭证(Credentials)

  • 实现认证和授权的前提

    是需要一种

    媒介(证书)

    来标记访问者的身份

    • 在战国时期,商鞅变法,发明了照身帖。照身帖由官府发放,是一块打磨光滑细密的竹板,上面刻有持有人的头像和籍贯信息。国人必须持有,如若没有就被认为是黑户,或者间谍之类的。

    • 在现实生活中,每个人都会有一张专属的居民身份证,是用于证明持有人身份的一种法定证件。通过身份证,我们可以办理手机卡/银行卡/个人贷款/交通出行等等,这就是认证的凭证。

    • 在互联网应用中,一般网站(如掘金)会有两种模式,游客模式和登录模式。游客模式下,可以正常浏览网站上面的文章,一旦想要点赞/收藏/分享文章,就需要登录或者注册账号。当用户登录成功后,服务器会给该用户使用的浏览器颁发一个令牌(token),这个令牌用来表明你的身份,每次浏览器发送请求时会带上这个令牌,就可以使用游客模式下无法使用的功能。

什么是 Cookie

  • HTTP 是无状态的协议(对于事务处理没有记忆能力,每次客户端和服务端会话完成时,服务端不会保存任何会话信息):每个请求都是完全独立的,服务端无法确认当前访问者的身份信息,无法分辨上一次的请求发送者和这一次的发送者是不是同一个人。所以服务器与浏览器为了进行会话跟踪(知道是谁在访问我),就必须主动的去维护一个状态,这个状态用于告知服务端前后两个请求是否来自同一浏览器。而这个状态需要通过 cookie 或者 session 去实现。

  • cookie 存储在客户端: cookie 是服务器发送到用户浏览器并保存在本地的一小块数据,它会在浏览器下次向同一服务器再发起请求时被携带并发送到服务器上。

  • cookie 是不可跨域的: 每个 cookie 都会绑定单一的域名,无法在别的域名下获取使用,一级域名和二级域名之间是允许共享使用的靠的是 domain)

cookie 重要的属性

属性说明
name=value键值对,设置 Cookie 的名称及相对应的值,都必须是字符串类型 - 如果值为 Unicode 字符,需要为字符编码。 - 如果值为二进制数据,则需要使用 BASE64 编码。
domain指定 cookie 所属域名,默认是当前域名
path指定 cookie 在哪个路径(路由)下生效,默认是 '/'。 如果设置为 /abc,则只有 /abc 下的路由可以访问到该 cookie,如:/abc/read
maxAgecookie 失效的时间,单位秒。如果为整数,则该 cookie 在 maxAge 秒后失效。如果为负数,该 cookie 为临时 cookie ,关闭浏览器即失效,浏览器也不会以任何形式保存该 cookie 。如果为 0,表示删除该 cookie 。默认为 -1。 - 比 expires 好用
expires过期时间,在设置的某个时间点后该 cookie 就会失效。 一般浏览器的 cookie 都是默认储存的,当关闭浏览器结束这个会话的时候,这个 cookie 也就会被删除
secure该 cookie 是否仅被使用安全协议传输。安全协议有 HTTPS,SSL等,在网络上传输数据之前先将数据加密。默认为false。 当 secure 值为 true 时,cookie 在 HTTP 中是无效,在 HTTPS 中才有效。
httpOnly如果给某个 cookie 设置了 httpOnly 属性,则无法通过 JS 脚本 读取到该 cookie 的信息,但还是能通过 Application 中手动修改 cookie,所以只是在一定程度上可以防止 XSS 攻击,不是绝对的安全


什么是 Session

  • session 是另一种记录服务器和客户端会话状态的机制

  • session 是基于 cookie 实现的,session 存储在服务器端,sessionId 会被存储到客户端的cookie 中

session.png

  • session 认证流程:

    • 用户第一次请求服务器的时候,服务器根据用户提交的相关信息,创建对应的 Session

    • 请求返回时将此 Session 的唯一标识信息 SessionID 返回给浏览器

    • 浏览器接收到服务器返回的 SessionID 信息后,会将此信息存入到 Cookie 中,同时 Cookie 记录此 SessionID 属于哪个域名

    • 当用户第二次访问服务器的时候,请求会自动判断此域名下是否存在 Cookie 信息,如果存在自动将 Cookie 信息也发送给服务端,服务端会从 Cookie 中获取 SessionID,再根据 SessionID 查找对应的 Session 信息,如果没有找到说明用户没有登录或者登录失效,如果找到 Session 证明用户已经登录可执行后面操作。

根据以上流程可知,SessionID 是连接 Cookie 和 Session 的一道桥梁,大部分系统也是根据此原理来验证用户登录状态。

Cookie 和 Session 的区别

  • 安全性: Session 比 Cookie 安全,Session 是存储在服务器端的,Cookie 是存储在客户端的。

  • 存取值的类型不同:Cookie 只支持存字符串数据,想要设置其他类型的数据,需要将其转换成字符串,Session 可以存任意数据类型。

  • 有效期不同: Cookie 可设置为长时间保持,比如我们经常使用的默认登录功能,Session 一般失效时间较短,客户端关闭(默认情况下)或者 Session 超时都会失效。

  • 存储大小不同: 单个 Cookie 保存的数据不能超过 4K,Session 可存储数据远高于 Cookie,但是当访问量过多,会占用过多的服务器资源。

什么是 Token(令牌)

Acesss Token

  • 访问资源接口(API)时所需要的资源凭证

  • 简单 token 的组成: uid(用户唯一的身份标识)、time(当前时间的时间戳)、sign(签名,token 的前几位以哈希算法压缩成的一定长度的十六进制字符串)

  • 特点:

    • 服务端无状态化、可扩展性好

    • 支持移动端设备

    • 安全

    • 支持跨程序调用

  • token 的身份验证流程:

img

  1. 客户端使用用户名跟密码请求登录

  2. 服务端收到请求,去验证用户名与密码

  3. 验证成功后,服务端会签发一个 token 并把这个 token 发送给客户端

  4. 客户端收到 token 以后,会把它存储起来,比如放在 cookie 里或者 localStorage 里

  5. 客户端每次向服务端请求资源的时候需要带着服务端签发的 token

  6. 服务端收到请求,然后去验证客户端请求里面带着的 token ,如果验证成功,就向客户端返回请求的数据

  • 每一次请求都需要携带 token,需要把 token 放到 HTTP 的 Header 里

  • 基于 token 的用户认证是一种服务端无状态的认证方式,服务端不用存放 token 数据。用解析 token 的计算时间换取 session 的存储空间,从而减轻服务器的压力,减少频繁的查询数据库

  • token 完全由应用管理,所以它可以避开同源策略

Refresh Token

  • 另外一种 token——refresh token

  • refresh token 是专用于刷新 access token 的 token。如果没有 refresh token,也可以刷新 access token,但每次刷新都要用户输入登录用户名与密码,会很麻烦。有了 refresh token,可以减少这个麻烦,客户端直接用 refresh token 去更新 access token,无需用户进行额外的操作。

img

  • Access Token 的有效期比较短,当 Acesss Token 由于过期而失效时,使用 Refresh Token 就可以获取到新的 Token,如果 Refresh Token 也失效了,用户就只能重新登录了。

  • Refresh Token 及过期时间是存储在服务器的数据库中,只有在申请新的 Acesss Token 时才会验证,不会对业务接口响应时间造成影响,也不需要向 Session 一样一直保持在内存中以应对大量的请求。

Token 和 Session 的区别

  • Session 是一种记录服务器和客户端会话状态的机制,使服务端有状态化,可以记录会话信息。而 Token 是令牌访问资源接口(API)时所需要的资源凭证。Token 使服务端无状态化,不会存储会话信息。

  • Session 和 Token 并不矛盾,作为身份认证 Token 安全性比 Session 好,因为每一个请求都有签名还能防止监听以及重放攻击,而 Session 就必须依赖链路层来保障通讯安全了。如果你需要实现有状态的会话,仍然可以增加 Session 来在服务器端保存一些状态。

  • 所谓 Session 认证只是简单的把 User 信息存储到 Session 里,因为 SessionID 的不可预测性,暂且认为是安全的。而 Token ,如果指的是 OAuth Token 或类似的机制的话,提供的是 认证 和 授权 ,认证是针对用户,授权是针对 App 。其目的是让某 App 有权利访问某用户的信息。这里的 Token 是唯一的。不可以转移到其它 App上,也不可以转到其它用户上。Session 只提供一种简单的认证,即只要有此 SessionID ,即认为有此 User 的全部权利。是需要严格保密的,这个数据应该只保存在站方,不应该共享给其它网站或者第三方 App。所以简单来说:如果你的用户数据可能需要和第三方共享,或者允许第三方调用 API 接口,用 Token 。如果永远只是自己的网站,自己的 App,用什么就无所谓了。

什么是 JWT

  • JSON Web Token(简称 JWT)是目前最流行的跨域认证解决方案。

  • 是一种认证授权机制

  • JWT 是为了在网络应用环境间传递声明而执行的一种基于 JSON 的开放标准(RFC 7519)。JWT 的声明一般被用来在身份提供者和服务提供者间传递被认证的用户身份信息,以便于从资源服务器获取资源。比如用在用户登录上。

  • 可以使用 HMAC 算法或者是 RSA 的公/私秘钥对 JWT 进行签名。因为数字签名的存在,这些传递的信息是可信的。

  • 阮一峰老师的 JSON Web Token 入门教程 讲的非常通俗易懂,这里就不再班门弄斧了

生成 JWT

jwt.io/
http://www.jsonwebtoken.io/

JWT 的原理

img

  • JWT 认证流程:

    • 用户输入用户名/密码登录,服务端认证成功后,会返回给客户端一个 JWT

    • 客户端将 token 保存到本地(通常使用 localstorage,也可以使用 cookie)

    • 当用户希望访问一个受保护的路由或者资源的时候,需要请求头的 Authorization 字段中使用Bearer 模式添加 JWT,其内容看起来是下面这样

Authorization: Bearer <token>
复制代码
  • 服务端的保护路由将会检查请求头 Authorization 中的 JWT 信息,如果合法,则允许用户的行为

  • 因为 JWT 是自包含的(内部包含了一些会话信息),因此减少了需要查询数据库的需要

  • 因为 JWT 并不使用 Cookie 的,所以你可以使用任何域名提供你的 API 服务而不需要担心跨域资源共享问题(CORS)

  • 因为用户的状态不再存储在服务端的内存中,所以这是一种无状态的认证机制

JWT 的使用方式

  • 客户端收到服务器返回的 JWT,可以储存在 Cookie 里面,也可以储存在 localStorage。

方式一

  • 当用户希望访问一个受保护的路由或者资源的时候,可以把它放在 Cookie 里面自动发送,但是这样不能跨域,所以更好的做法是放在 HTTP 请求头信息的 Authorization 字段里,使用 Bearer 模式添加 JWT。

    GET /calendar/v1/events
    Host: api.example.com
    Authorization: Bearer <token>
    复制代码
    • 用户的状态不会存储在服务端的内存中,这是一种 无状态的认证机制

    • 服务端的保护路由将会检查请求头 Authorization 中的 JWT 信息,如果合法,则允许用户的行为。

    • 由于 JWT 是自包含的,因此减少了需要查询数据库的需要

    • JWT 的这些特性使得我们可以完全依赖其无状态的特性提供数据 API 服务,甚至是创建一个下载流服务。

    • 因为 JWT 并不使用 Cookie ,所以你可以使用任何域名提供你的 API 服务而不需要担心跨域资源共享问题(CORS)

方式二

  • 跨域的时候,可以把 JWT 放在 POST 请求的数据体里。

方式三

  • 通过 URL 传输

http://www.example.com/user?token=xxx
复制代码

项目中使用 JWT

项目地址

Token 和 JWT 的区别

相同:

  • 都是访问资源的令牌

  • 都可以记录用户的信息

  • 都是使服务端无状态化

  • 都是只有验证成功后,客户端才能访问服务端上受保护的资源

区别:

  • Token:服务端验证客户端发送过来的 Token 时,还需要查询数据库获取用户信息,然后验证 Token 是否有效。

  • JWT: 将 Token 和 Payload 加密后存储于客户端,服务端只需要使用密钥解密进行校验(校验也是 JWT 自己实现的)即可,不需要查询或者减少查询数据库,因为 JWT 自包含了用户信息和加密的数据。

常见的前后端鉴权方式

  1. Session-Cookie

  2. Token 验证(包括 JWT,SSO)

  3. OAuth2.0(开放授权)

常见的加密算法

image.png

  • 哈希算法(Hash Algorithm)又称散列算法、散列函数、哈希函数,是一种从任何一种数据中创建小的数字“指纹”的方法。哈希算法将数据重新打乱混合,重新创建一个哈希值。

  • 哈希算法主要用来保障数据真实性(即完整性),即发信人将原始消息和哈希值一起发送,收信人通过相同的哈希函数来校验原始数据是否真实。

  • 哈希算法通常有以下几个特点:

    • 正像快速:原始数据可以快速计算出哈希值

    • 逆向困难:通过哈希值基本不可能推导出原始数据

    • 输入敏感:原始数据只要有一点变动,得到的哈希值差别很大

    • 冲突避免:很难找到不同的原始数据得到相同的哈希值,宇宙中原子数大约在 10 的 60 次方到 80 次方之间,所以 2 的 256 次方有足够的空间容纳所有的可能,算法好的情况下冲突碰撞的概率很低:

      • 2 的 128 次方为 340282366920938463463374607431768211456,也就是 10 的 39 次方级别

      • 2 的 160 次方为 1.4615016373309029182036848327163e+48,也就是 10 的 48 次方级别

      • 2 的 256 次方为 1.1579208923731619542357098500869 × 10 的 77 次方,也就是 10 的 77 次方

注意:

  1. 以上不能保证数据被恶意篡改,原始数据和哈希值都可能被恶意篡改,要保证不被篡改,可以使用RSA 公钥私钥方案,再配合哈希值。

  2. 哈希算法主要用来防止计算机传输过程中的错误,早期计算机通过前 7 位数据第 8 位奇偶校验码来保障(12.5% 的浪费效率低),对于一段数据或文件,通过哈希算法生成 128bit 或者 256bit 的哈希值,如果校验有问题就要求重传。

常见问题

使用 cookie 时需要考虑的问题

  • 因为存储在客户端,容易被客户端篡改,使用前需要验证合法性

  • 不要存储敏感数据,比如用户密码,账户余额

  • 使用 httpOnly 在一定程度上提高安全性

  • 尽量减少 cookie 的体积,能存储的数据量不能超过 4kb

  • 设置正确的 domain 和 path,减少数据传输

  • cookie 无法跨域

  • 一个浏览器针对一个网站最多存 20 个Cookie,浏览器一般只允许存放 300 个Cookie

  • 移动端对 cookie 的支持不是很好,而 session 需要基于 cookie 实现,所以移动端常用的是 token

使用 session 时需要考虑的问题

  • 将 session 存储在服务器里面,当用户同时在线量比较多时,这些 session 会占据较多的内存,需要在服务端定期的去清理过期的 session

  • 当网站采用集群部署的时候,会遇到多台 web 服务器之间如何做 session 共享的问题。因为 session 是由单个服务器创建的,但是处理用户请求的服务器不一定是那个创建 session 的服务器,那么该服务器就无法拿到之前已经放入到 session 中的登录凭证之类的信息了。

  • 当多个应用要共享 session 时,除了以上问题,还会遇到跨域问题,因为不同的应用可能部署的主机不一样,需要在各个应用做好 cookie 跨域的处理。

  • sessionId 是存储在 cookie 中的,假如浏览器禁止 cookie 或不支持 cookie 怎么办? 一般会把 sessionId 跟在 url 参数后面即重写 url,所以 session 不一定非得需要靠 cookie 实现

  • 移动端对 cookie 的支持不是很好,而 session 需要基于 cookie 实现,所以移动端常用的是 token

使用 token 时需要考虑的问题

  • 如果你认为用数据库来存储 token 会导致查询时间太长,可以选择放在内存当中。比如 redis 很适合你对 token 查询的需求。

  • token 完全由应用管理,所以它可以避开同源策略

  • token 可以避免 CSRF 攻击(因为不需要 cookie 了)

  • 移动端对 cookie 的支持不是很好,而 session 需要基于 cookie 实现,所以移动端常用的是 token

使用 JWT 时需要考虑的问题

  • 因为 JWT 并不依赖 Cookie 的,所以你可以使用任何域名提供你的 API 服务而不需要担心跨域资源共享问题(CORS)

  • JWT 默认是不加密,但也是可以加密的。生成原始 Token 以后,可以用密钥再加密一次。

  • JWT 不加密的情况下,不能将秘密数据写入 JWT。

  • JWT 不仅可以用于认证,也可以用于交换信息。有效使用 JWT,可以降低服务器查询数据库的次数。

  • JWT 最大的优势是服务器不再需要存储 Session,使得服务器认证鉴权业务可以方便扩展。但这也是 JWT 最大的缺点:由于服务器不需要存储 Session 状态,因此使用过程中无法废弃某个 Token 或者更改 Token 的权限。也就是说一旦 JWT 签发了,到期之前就会始终有效,除非服务器部署额外的逻辑。

  • JWT 本身包含了认证信息,一旦泄露,任何人都可以获得该令牌的所有权限。为了减少盗用,JWT的有效期应该设置得比较短。对于一些比较重要的权限,使用时应该再次对用户进行认证。

  • JWT 适合一次性的命令认证,颁发一个有效期极短的 JWT,即使暴露了危险也很小,由于每次操作都会生成新的 JWT,因此也没必要保存 JWT,真正实现无状态。

  • 为了减少盗用,JWT 不应该使用 HTTP 协议明码传输,要使用 HTTPS 协议传输。

使用加密算法时需要考虑的问题

  • 绝不要以明文存储密码

  • 永远使用 哈希算法 来处理密码,绝不要使用 Base64 或其他编码方式来存储密码,这和以明文存储密码是一样的,使用哈希,而不要使用编码。编码以及加密,都是双向的过程,而密码是保密的,应该只被它的所有者知道, 这个过程必须是单向的。哈希正是用于做这个的,从来没有解哈希这种说法, 但是编码就存在解码,加密就存在解密。

  • 绝不要使用弱哈希或已被破解的哈希算法,像 MD5 或 SHA1 ,只使用强密码哈希算法。

  • 绝不要以明文形式显示或发送密码,即使是对密码的所有者也应该这样。如果你需要 “忘记密码” 的功能,可以随机生成一个新的 一次性的(这点很重要)密码,然后把这个密码发送给用户。

分布式架构下 session 共享方案

1. session 复制

  • 任何一个服务器上的 session 发生改变(增删改),该节点会把这个 session 的所有内容序列化,然后广播给所有其它节点,不管其他服务器需不需要 session ,以此来保证 session 同步

优点: 可容错,各个服务器间 session 能够实时响应。
缺点: 会对网络负荷造成一定压力,如果 session 量大的话可能会造成网络堵塞,拖慢服务器性能。

2. 粘性 session /IP 绑定策略

  • 采用 Ngnix 中的 ip_hash 机制,将某个 ip的所有请求都定向到同一台服务器上,即将用户与服务器绑定。 用户第一次请求时,负载均衡器将用户的请求转发到了 A 服务器上,如果负载均衡器设置了粘性 session 的话,那么用户以后的每次请求都会转发到 A 服务器上,相当于把用户和 A 服务器粘到了一块,这就是粘性 session 机制。

优点: 简单,不需要对 session 做任何处理。
缺点: 缺乏容错性,如果当前访问的服务器发生故障,用户被转移到第二个服务器上时,他的 session 信息都将失效。
适用场景: 发生故障对客户产生的影响较小;服务器发生故障是低概率事件 。
实现方式: 以 Nginx 为例,在 upstream 模块配置 ip_hash 属性即可实现粘性 session。

3. session 共享(常用)

  • 使用分布式缓存方案比如 Memcached 、Redis 来缓存 session,但是要求 Memcached 或 Redis 必须是集群

  • 把 session 放到 Redis 中存储,虽然架构上变得复杂,并且需要多访问一次 Redis ,但是这种方案带来的好处也是很大的:

    • 实现了 session 共享;

    • 可以水平扩展(增加 Redis 服务器);

    • 服务器重启 session 不丢失(不过也要注意 session 在 Redis 中的刷新/失效机制);

    • 不仅可以跨服务器 session 共享,甚至可以跨平台(例如网页端和 APP 端)

img

4. session 持久化

  • 将 session 存储到数据库中,保证 session 的持久化

优点: 服务器出现问题,session 不会丢失
缺点: 如果网站的访问量很大,把 session 存储到数据库中,会对数据库造成很大压力,还需要增加额外的开销维护数据库。

只要关闭浏览器 ,session 真的就消失了?

不对。对 session 来说,除非程序通知服务器删除一个 session,否则服务器会一直保留,程序一般都是在用户做 log off 的时候发个指令去删除 session。
然而浏览器从来不会主动在关闭之前通知服务器它将要关闭,因此服务器根本不会有机会知道浏览器已经关闭,之所以会有这种错觉,是大部分 session 机制都使用会话 cookie 来保存 session id,而关闭浏览器后这个 session id 就消失了,再次连接服务器时也就无法找到原来的 session。如果服务器设置的 cookie 被保存在硬盘上,或者使用某种手段改写浏览器发出的 HTTP 请求头,把原来的 session id 发送给服务器,则再次打开浏览器仍然能够打开原来的 session。
恰恰是由于关闭浏览器不会导致 session 被删除,迫使服务器为 session 设置了一个失效时间,当距离客户端上一次使用 session 的时间超过这个失效时间时,服务器就认为客户端已经停止了活动,才会把 session 删除以节省存储空间。

项目地址

在项目中使用 JWT

后语

  • 本文只是基于自己的理解讲了理论知识,因为对后端/算法知识不是很熟,如有谬误,还请告知,万分感谢

  • 如果本文对你有所帮助,还请点个赞~~

参考

百度百科-cookie

百度百科-session

详解 Cookie,Session,Token

一文彻底搞懂Cookie、Session、Token到底是什么

3种web会话管理的方式!!!

Token ,Cookie和Session的区别!!!

彻底理解 cookie、session、token!!!

前端鉴权

SHA-1

SHA-2

SHA-3

不要再使用MD5和SHA1加密密码了!

廖雪峰 Node 教程之 crypto


作者:秋天不落叶
来源:https://juejin.cn/post/6844904034181070861

收起阅读 »