注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

环信FAQ

环信FAQ

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

优雅读取Activity的Intent、Fragment的Argument

属性委托实现方式有两种,这里直接通过实现接口的形式实现: var修饰的属性实现属性委托需要实现ReadWriteProperty接口 val修饰的属性实现属性委托需要实现ReadOnlyProperty接口 这里由于我们只需要读取值,所以直接实现ReadO...
继续阅读 »

属性委托实现方式有两种,这里直接通过实现接口的形式实现:



  • var修饰的属性实现属性委托需要实现ReadWriteProperty接口

  • val修饰的属性实现属性委托需要实现ReadOnlyProperty接口


这里由于我们只需要读取值,所以直接实现ReadOnlyProperty接口即可。下面直接上Activity的Intent委托读取代码 :


class IntentWrapper<T>(private val default: T) : ReadOnlyProperty<AppCompatActivity, T?> {
override fun getValue(thisRef: AppCompatActivity, property: KProperty<*>): T? {
return when(default) {
is Int -> thisRef.intent.getIntExtra(property.name, default)
is String -> thisRef.intent.getStringExtra(property.name)
else -> throw Exception()
} as? T
}
}

需要注意,在这里读取Activity的Intent使用的key默认为属性名称:property.name,这也就意味着通过Intent存储值的时候key也要使用属性名称。


如果需要读写Intent的话key不想要使用属性名称,那就得对这个属性委托类IntentWrapper稍微改造下,构造方法中支持从外部传入key键值


上面的属性委托类IntentWrapper中,只是简单处理了StringInt类型,其他的类型BooleanFloat等类型需要请自行添加。


看下使用:


private val data by IntentWrapper(56)

//读
printlin(data)

上面使用起来还是有一丢丢不优雅,每次都得手动创建IntentWrapper并传入默认值,我们可以封装几个常用类型的方法,实现起来更加方便:


fun intIntent(default: Int = 0) = IntentWrapper(default)

fun stringIntent(default: String = "") = IntentWrapper(default)

intIntent()方法给了一个默认值为0,外部可以选择性传入的默认值,其他的类型也是一样处理。


然后就可以这样使用:


private val data by intIntent()

上面主要展示的是读取Activity的Intent,Fragment的Argument处理类似:


class ArgumentWrapper<T>(private val default: T) : ReadOnlyProperty<Fragment, T?> {
override fun getValue(thisRef: Fragment, property: KProperty<*>): T? {
return when(default) {
is Int -> thisRef.arguments?.getInt(property.name, default)
is String -> thisRef.arguments?.getString(property.name)
else -> throw Exception()
} as? T
}
}

使用起来也和Activity类似,这里就不做展示了。当然了,这里可以定义几个常用类型的方法创建ArgumentWrapper,参考上面Activity的处理即可。


后续准备出一篇文章,从类委托的角度考虑封装下Activity的Intent、Fragment的Argument的读取。


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

解析 InheritedWidget

概览 打开源码可以看到对 InheritedWidget的解释 Base class for widgets that efficiently propagate information down the tree --在UI树上能够有效传递信息的一个基本组件...
继续阅读 »

概览


打开源码可以看到对 InheritedWidget的解释


Base class for widgets that efficiently propagate information down the tree
--在UI树上能够有效传递信息的一个基本组件。

我们可以使用这个类来定制一个组件来传递我们需要在整个界面的状态传递。可以在官方的介绍上看到一个案例:


   ###### 创建一个继承InheritedWidget的组件

其中我们会使用一个惯例的写法,创建一个 static 的方法 of 去获得他对应的InheritedWidget。此方法会间接的调用 BuildContext的


dependOnInheritedWidgetOfExactType方法,范型传入我们当前的 FrogColor组件。


///创建一个InheritedWidget的继承类,用于管理 color 信息的传递
class FrogColor extends InheritedWidget {
 const FrogColor({Key key, @required this.color, @required Widget child})
    : assert(color != null),
       assert(child != null),
       super(key: key, child: child);
 final Color color;
 ///一般的写法,官方推荐
 static FrogColor of(BuildContext context) {
   ///这个方法做了什么事情,待会会进行分析解释!
   return context.dependOnInheritedWidgetOfExactType<FrogColor>();
}
 @override
 bool updateShouldNotify(covariant FrogColor oldWidget) {
   return color != oldWidget.color;
}
}

当然我们有时候也可能会直接使用一个Wiget去包裹一个InheritedWidget去传递组件的状态,典型的一个就是Theme组件,参考Theme的源码,我们知道他内部包裹了 _InheritedTheme 和 CupertinoTheme 并且 在 他的字组件中传递了 ThemeData 。所以我们能够很方便的全局的去修改我们的主题。


使用这个InheritedWidgt进行信息的传递

这里我们使用了一个Builder来创建需要使用FrogColor配置信息的子组件。其实我们使用的这个context就是一个Element,这个Element属于FrogColor的Element中的子Element。这一点很重要(关于context的知识需要参考下 Builder的源码)。


class MyPage extends StatelessWidget{
 @override
 Widget build(BuildContext context) {
      return Scaffold(
          appBar: AppBar(),
          body: FrogColor(
            color: Colors.green,
            child: Builder(builder: (BuildContext context){
              //这里我门打印了一下context 。 Builder(dirty, dependencies: [FrogColor])
              //这里打印的是 Widget的一些信息,其实最终调用的是Element的 toString 方法
              /// @override
///     String toStringShort() {
///     return widget != null ? widget.toStringShort() : '[${objectRuntimeType(this, 'Element')}]';
/// }
              print(context);
              print(context as Element) ///并不会报错
              return Text("This is InnerText" ,style: TextStyle(
                color: FrogColor.of(context).color
              ));
            }),
          ),
      );
}
}

为什么我们需要将需要FrogColor信息的组件外面包裹一层Builder,因为 FrogColor.of(context) 这个context 必须是 FrogColor的子 context


(其实是Element),若是使用 MyPage的context , 这个context 位于FrogColor的父级,那么我们需要的效果肯定会达不到的。


为什么会出现这样的效果,我们应该从 我们 惯例定义的 static 方法 中说起。


dependOnInheritedWidgetOfExactType


这个方法我们调用的是 BuildContext.dependOnInheritedWidgetOfExactType , 其中唯一实现这个方法的类就是 Element,我们看 Element


中的这个方法的实现:



  1. 去 _inheritedWidgets 中找输入范型对应的 InheritedElement,对应 FrogColor中的Element。在FrogColorElement创建的时候会将其加入到 _inheritedWidgets 中。

  2. 在当前 Builder对应的Element中的_dependencies加入 找到的 FrogColorElement ,这样两者就建立了相关联系。

  3. ancestor.updateDependencies(this, aspect) 在 FrogColorElement更新依赖关系。



///from Eelement
@override
T dependOnInheritedWidgetOfExactType<T extends InheritedWidget>({Object aspect}) {
 assert(_debugCheckStateIsActiveForAncestorLookup());

 //还记得我么给 这个 方法传入了一 FrogColor的范型。这里 _inheritedWidgets 将类型作为Key 查找 对应的 Element;
 /// _inheritedWidgets 这个 变量 会在Element 的 mount 方法 和 active 方法中调用,就是:如果当前的Elemnt属于InheritedElement便将此Element放入 _inheritedWidgets 中,不是的话向上追溯加入。
 final InheritedElement ancestor = _inheritedWidgets == null ? null : _inheritedWidgets[T];
 ///查到的情况下
 if (ancestor != null) {
   assert(ancestor is InheritedElement);
   /// 关键点 : 添加依赖关系
   return dependOnInheritedElement(ancestor, aspect: aspect) as T;
}
 _hadUnsatisfiedDependencies = true;
 return null;
}

/// from Element 添加依赖关系
@override
 InheritedWidget dependOnInheritedElement(InheritedElement ancestor, { Object aspect }) {
   assert(ancestor != null);
   _dependencies ??= HashSet<InheritedElement>();
   ///在本Element的依赖链中添加,一切的操作都在本Element中操作而非 FrogColor对应的Element中,
   ///这也是为什么我们在前面的打印中看到了 : Builder(dirty, dependencies: [FrogColor])
   _dependencies.add(ancestor);
   ///刷新依赖关系(这个依赖关系刷新的是 FrogColor中的依赖关(这个一会再讲)
   ancestor.updateDependencies(this, aspect);
   return ancestor.widget;
}

分析到这一步,其实我们便可以使用 InheritedWidget提供的一些属性信息,但是当我们更新数据的时候(比如:FrogColor 动态更新 color属性),若是子组件的Widget属性没有发生变化(参考 setState()原理)的时候,子组件是不会发生变化的。也就是说只能在第一次构建的时候生效,那么我们全局修改主题,全局修改 ForgColor 的属性怎么办。我们需要解析 ancestor.updateDependencies(this, aspect) 内在都干了什么,怎么运行的。


InHeritedWidget 动态更新


经过以上的分析,我们知道,在 添加依赖关系的时候,会分别在自己的 _dependencies 中添加父级,然后在父级中的 _dependencies 添加 调用的Element。为什么要添加这种依赖关系?我们应分析当 Element更新的时候对这种依赖关系操作了什么。


熟悉Element更新原理的情况下(不知道顺着思路走)我们知道:属性更改后会调用update方法。而 InheritedElement 继承在 ProxyElement ,我们来分析 ProxyElement(这个类里面包含了通知客户端更新的定义方法) 中的update 做了什么。


/// from ProxyElement
@override
void update(ProxyWidget newWidget) {
 final ProxyWidget oldWidget = widget;
 assert(widget != null);
 assert(widget != newWidget);
 super.update(newWidget);
 assert(widget == newWidget);
 ///调用 updated 方法
 updated(oldWidget);
 _dirty = true;
 rebuild();
}
@protected
void updated(covariant ProxyWidget oldWidget) {
  /// 通知Clients ;   这个方法在ProxyElement中是空的
   notifyClients(oldWidget);
}

ProxyElement最终会调用 notifyClients方法,这个方法在 InhertedElement中进行了实现。



/// from InhertedElement
 /// Notifies all dependent elements that this inherited widget has changed, by
 /// calling [Element.didChangeDependencies].
@override
 void notifyClients(InheritedWidget oldWidget) {
   assert(_debugCheckOwnerBuildTargetExists('notifyClients'));
   //查找所有的依赖关系
   for (final Element dependent in _dependents.keys) {
     ///断言==一些判断。
     assert(() {
       // check that it really is our descendant
       Element ancestor = dependent._parent;
       while (ancestor != this && ancestor != null)
         ancestor = ancestor._parent;
       return ancestor == this;
    }());
     // check that it really depends on us
     assert(dependent._dependencies.contains(this));
     ///通知依赖
     notifyDependent(oldWidget, dependent);
  }
}
}

/// from InhertedElement
 @protected
 void notifyDependent(covariant InheritedWidget oldWidget, Element dependent) {
   ///调用 element的 didChangeDependencies 方法;
   dependent.didChangeDependencies();
}

 ///from Element
 @mustCallSuper
 void didChangeDependencies() {
   assert(_active); // otherwise markNeedsBuild is a no-op
   assert(_debugCheckOwnerBuildTargetExists('didChangeDependencies'));
   /// 标记需要重新进行绘制;
   markNeedsBuild();
}


总结一句话:重新构建的时候,InheritedElement将_dependencies中的ELement全部标记为 重新构建。这样,当 InheritedWidget的属性发生变化的时候,便会很快的有目标的将使用到它属性的Widget进行更新。


这也是为什么很多基于组件基于InheritedWidget,这样的效率很高,而且使用起来很方便。一些状态管理框架也是基于InheritedWIdget进行了一定的封装。


PS:_inheritedWidgets的注册和继承


上面有一点我们是一句话带过的 :在FrogColorElement创建的时候会将其加入到 _inheritedWidgets 中。 这里我们需要分析 Element的mount方法中执行了什么操作;


/// from Element 
@mustCallSuper
void mount(Element parent, dynamic newSlot) {
  ////---省略
 ///最终调用
 _updateInheritance();
 assert(() {
   _debugLifecycleState = _ElementLifecycle.active;
   return true;
}());
}

 /// from Element 若不是InheritedElement的情况下,会查找父级的 _inheritedWidgets
 void _updateInheritance() {
   assert(_active);
   _inheritedWidgets = _parent?._inheritedWidgets;
}

/// from InheritedElement 若是InheritedElement的情况下
@override
 void _updateInheritance() {
   assert(_active);
   /// 将父级的拿过来
   final Map<Type, InheritedElement> incomingWidgets = _parent?._inheritedWidgets;
   if (incomingWidgets != null)
     _inheritedWidgets = HashMap<Type, InheritedElement>.from(incomingWidgets);
   else
     _inheritedWidgets = HashMap<Type, InheritedElement>();
   /// 加入自己
   _inheritedWidgets[widget.runtimeType] = this;
}

总结:通过 _updateInheritance 查找上级的 _inheritedWidgets 若本Element是InheritedElement那么将自己加入进去。


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

对话MySQL之父:中国程序员都想转型做管理,这是最大错误

MySQL之父:代码应该一次写成而不是后面再改在开源数据库领域中,Michael “Monty” Widenius(通常称为Monty)绝对是不得不提的代表人物。有着四十多年编程经验的Monty是MySQL和MariaDB的作者,也是开源软件运动的著名倡导者,...
继续阅读 »

编者按:MySQL之父Monty有着四十多年的编程经验,从儿时的兴趣到长大后的深耕,他在编程领域不断钻研,最终成为编程大师。《新程序员004》带你走进Monty的程序人生,谈谈他在编程方面的最新感悟以及对未来的预测。

MySQL之父:代码应该一次写成而不是后面再改

如今,我们正处于数据爆炸的时代,软件崛起的背后是数据的支持。而随着开源技术的发展,越来越多的数据库选择创建开源社区,让更多开发者参与到数据库的建设中来。

在开源数据库领域中,Michael “Monty” Widenius(通常称为Monty)绝对是不得不提的代表人物。有着四十多年编程经验的Monty是MySQL和MariaDB的作者,也是开源软件运动的著名倡导者,即便是现在他也在坚持写代码。作为影响了几代技术人的数据库,MySQL所取得的成就无需多言。而最初作为MySQL分支立项的MariaDB也在迅速成长,同样在数据库中赢得了一席之地。

作为在技术界游历半生的资深“程序员”,Monty对编程的理解也有许多独到之处,他认为只有学习编程20年以上,才能像读懂音乐一样,看出编程之美。除此之外,他还表示:“写代码时要尽量将代码一次性写成,而不是写完后再没完没了的修改。”只有做到这一点,才能称得上是一名优秀的程序员。而这也是他长久以来所遵循的“编程法则”。

近期,《新程序员》有机会邀请Monty分享他的程序人生,谈谈他对于技术的感悟,以及对于数据库发展的看法与心得。

“我在编程方面有一定的天赋”

1962年,Monty出生在芬兰首都赫尔辛基,小时候的他便对计算机有着浓厚的兴趣。1978年,年仅16岁的Monty用他一整个暑假打工攒的钱买了人生中的第一台电脑,并且用BASIC语言写下了第一行代码REM,从此以后他便与编程结下了不解之缘。三年后,Monty被北欧著名高校赫尔辛基理工大学录取,但由于自己的学习理念与学校不同,他感到在学校学不到什么东西,因此没过多久就辍学了。1981年。离开了校园的Monty开始在荷兰的一家叫做Tapio Laakso Oy的公司当程序员。在近十年之后,34岁的Monty开发出了历史上最流行的开源数据库之一——MySQL。


Monty能开发出MySQL并非偶然,他在编程上投入了大量的时间。根据早期的资料显示,就连别人去参加聚会时,他也在家里写代码。在他看来,好的代码不需要一次又一次地重写,而是在开始写之前,就抱有一次写成的心态。正因为如此,直到多年后的今天,Monty仍然直言“自己在编程方面具有一定的天赋。”

除了Monty,MySQL的诞生还离不开David Axmark和Allan Larsson。早在1980年,17岁的Monty打算将自己的计算机内存从8KB提高到16KB。机缘巧合之下,他去往瑞典Allan Larsson的电脑店寻求帮助,在那里认识了同样也是写代码的David Axmark,之后三人就成为了亲密的合作伙伴,经常一起写代码,解决编程过程中遇到的问题。1995年,三人创立了MySQL AB,MySQL AB就是MySQL的雏形。这其中Monty负责了大部分的开发工作。最终,在1996年10月,MySQL首个版本发布,从此掀开了数据库历史的重要一章。

到了1999年,MySQL的迅速发展已经引起了许多人的注意, Oracle表示要以5000万美元的价格收购MySQL。然而Monty三人并不想止步于此,也不想失去对MySQL的控制,因此拒绝了这次收购。

随着时间的推移,MySQL迅速发展,但同时市场上也出现了包括PostgreSQL在内的竞争对手数据库。为了在竞争中脱颖而出,MySQL开始接受融资,以获得更大的发展机会。到了2003年,MySQL实现了高达400万的安装次数,较两年前翻了一番,成为了当时全世界最受欢迎的开源数据库。

2008年1月16日,Sun Microsystems以高达10亿美元的价格收购MySQL(然而次年Sun又被Oracle收购)。当时Monty担心MySQL可能会受到Oracle的控制而变得商业化,并且如果Oracle一家独大的话,可能会引发数据库领域的不良竞争。于是他发起了一场拯救MySQL的请愿活动,并在MySQL闭源前将其分化,以其小女儿Maria的名字命名创建了MariaDB。

MariaDB开源数据库可以看做是MySQL的一个分支,主要由开源社区维护,目的是要完全兼容MySQL,甚至包括API和命令行。MariDB推出后,不少MySQL的员工都转而投向MariaDB,甚至是原先使用MySQL的各大公司也将数据库迁移到MariaDB上,其中就包括谷歌和维基百科。Monty表示:“与MySQL相比,MariaDB更加成熟,拥有更大的研发优势,并且在安全性修复方面也更加出色。”直到现在,Monty依旧亲自参与MariaDB的开发维护,可以说他的工作重心都在MariaDB上。

MariaDB,坚持开源的背后

《新程序员》:你在创建MariaDB时,曾提到要把它打造成第二个MySQL,并且确保它是开源的。那么对于数据库而言,为什么开源这么重要呢?

Monty:对于任何大型项目来说,开源都是非常重要的。既然要和巨头竞争,你就要有和他们一样的工具。在我看来,开源很适合用于软件开发,尤其是当公司规模还不大的时候。这个时候你很难兼顾公司和用户的需求,因此需要听取别人的想法。而开源就意味着可以获得社区的帮助,能够了解其他人的观点。有了开源,你可以开发出更好的产品,同时产品也能够获得更大的影响力。

《新程序员》:不过开源的一大弊端就是声音太多,需求不一,这种情况下该如何保证数据库能满足大多数人的需求呢?

Monty:要解决这个问题,就需要确保数据库足够灵活,这样才能满足大多数人的需求。在这一点上,MySQL和MariaDB的做法是建立各种性能不一的存储引擎,人们可以针对具体需求开发自己的存储引擎 。

事实上,对于那些有需求的人来说,MariaDB依旧是一个优秀的工具。而对于要求数据库体量较小且运行较快的人来说,MariaDB同样是一个不错的选择。在开发MariaDB时,我们考虑到了各种可能性,使它能够保持良好的性能。

《新程序员》:AI技术的发展让人们对数据库的期待发生了转变,今天数据库是否能够与AI技术结合,从而拥有数据决策能力?

Monty:对于数据库来说,最重要的是要处理AI需要的不同结构。因此我们添加了对JSON的支持,用于在MariaDB中支持动态列。这样人们就可以储存并检索数据,同时保留自己想要的格式。通常AI并不是要创造内容,更多的是实现文件自动化,这就是我们对于MariaDB所抱的期望。因此这两者完全是不同的工具集。

除此之外,我们还需要一个良好的环境,其中每一个部分都是可替代的,要确保自己不被束缚。一旦有了束缚的存在,那么你的应用程序就需要与静态系统相结合,这会大大降低灵活性。我认为对于数据库来说,要注意的一点就是,要确保数据库容易上手,而这恰恰意味着更多的AI技术能够整合到数据库中。

仍然每天坚持写代码

《新程序员》:在IT行业中,在中国有这样一种现象,认为程序员过了35岁就要转型,进入管理层或是其他领域。对此你怎么看?

Monty:这在很多地方都很常见。这个现象的主要原因在于程序员在管理岗位上的工资要比单纯做编程高。因为很少有公司会重视优秀的程序员,这就导致了收入的差异。我认为,如今程序员没有晋升的空间。与其让他们被迫转型,不如建立一个能提升他们收入的新环境。要想做到这一点,公司就得让他们承担更多的责任。要程序员担任管理岗位也行,但前提是仍然要保证他们每天写代码的时间。毕竟好的经理人到处都是,好的程序员却千里挑一。

《新程序员》:据我所知,你仍然每天在坚持写代码,但同时也要负责MariaDB的运营和管理。那么,你如何平衡这两个身份呢?

Monty:我认为在写代码这方面,我还是有一点天分的,所以我想坚持下去。我会雇用经理人为我工作,这样我就可以做我最擅长的事情。我会参与代码审查、社区运营以及MariaDB的相关决策。但同时我也会花很多时间维系客户,与不同国家的开发者交流,其中有许多中国的开发者。我认为,除了写代码之外,这是我做的最重要的事。总而言之,我会雇佣经理人来做一部分管理,让我有足够的时间在真正重要的事情上。

《新程序员》:听闻你从20世纪80年代就开始在家办公,如今这一办公方式也开始流行起来,对于远程办公你有什么看法?

Monty:事实上我认为远程办公是非常灵活的工作方式,自1981年开始我就在家办公(MySQL和MariaDB团队都是在家办公)。我们招人之前可能从来没见过他们,甚至都不知道对面是个人还是团队。但是我们的效率一直都在线。能做到这一点的前提,是要对跟自己联系密切的同事有足够的了解。至少熟悉他们的样貌。

我认为对于八成的开发者而言,在家办公是一个不错的选择。可能有一小部分开发者,他们的工作负担比较重,在家提不起精神来。这就需要他们出去走走,见见朋友或是接触新事物。我刚开始在家办公的时候,也会担心这样是不是会被孤立。所以后来我会定期在家里举行派对,我也会亲自下厨。我们团队每年也会在一起待上一段时间。

一个好的程序员能抵五个一般的程序员

《新程序员》:对于你来说,在过去几年数据库领域发生了哪些大的变化?

Monty:在过去的五年或七年间,学习SQL(结构化查询语言)开始成为一种趋势。但是人们发现SQL过于复杂,因此还需要学习其他语言。于是许多公司开始创新,采用NoSQL(非关系型数据库) 进行开发。但在过去的几年里,人们逐渐意识到NoSQL并不是万金油。但选择关系型数据库是否能够涵盖NoSQL提供的功能?很明显,有的可以 ,有的不行。因此我认为,在当下的环境中,对于数据库的要求在于要保证云端以及本地部署。

我们不能被一个数据库束缚。云端提供的是灵活性,你能在数据库中运行软件,即使是有成百上千个软件,而且本地部署的价格更低,控制权限更高,这一点是云端无法提供的。但我依然认为云端有它的优势,我们要在两者之间找到平衡。

《新程序员》:30年前我从大学毕业时,人们提到数据库一般是指去银行办业务。现在看来,人们有了更多的选择,我们能够借助数据库实现许多功能。但提到数据库开发时,人们往往指的是“后端”。那么,对于一个开发者或是毕业生想要进入数据库领域的人来说,你会给他们怎样的职业建议?

Monty:在我看来,从开源数据库开始入门更简单。现在开源数据库很多,如果你的确想成为专家级别的人,想要得到一份很好的工作,你可以找一个合适的数据库,并学习如何进行优化。但同时你也需要了解人们的需求,你可以和从事这一行的同学交流,并且学习解决数据库中的实际问题。

《新程序员》:除了多参与开源项目之外,对于中国开发者你还有哪些想说的?

Monty:我和来自中国的开发者有过非常多的互动,他们非常棒,在编程上表现得非常优秀。不过我在感到惊喜得同时,也感到非常惋惜,因为他们都想转型做管理。我认为这是最大的错误。他们需要让老板给自己派更多的任务,当然也可以做管理,但前提是能让自己写代码。还是那句话:找到一个好经理很容易,但找到一个好的程序员很难。一个非常出色的程序员可以抵五个一般的程序员,关键是你想当一个好的程序员还是一个平庸的经理。对于所有中国开发者,我只想说,请坚持你的工作,你已经做得非常好了,一定不要停止写代码。

【参考资料】
https://zh.wikipedia.org/wiki/%E7%B1%B3%E5%8D%A1%E5%9F%83%E7%88%BE%C2%B7%E7%B6%AD%E5%BE%B7%E7%B4%90%E6%96%AF
https://blog.openocean.vc/founder-stories-a-hackers-hacker-6d5054c90564
https://huskyintelligence.com/leverage-open-source-code/
http://monty-says.blogspot.com/2009/12/help-saving-mysql.html
https://www.geeksforgeeks.org/introduction-of-mariadb/
http://www.josetteorama.com/from-mysql-to-mariadb-michael-%e2%80%9cmonty%e2%80%9d-widenius-talks-about-databases-and-his-projects/
https://dri.es/the-history-of-mysql-ab
https://mariadb.org/wp-content/uploads/2019/11/MySQL-MariaDB-story.pdf

来源:blog.csdn.net/programmer_editor/article/details/124173842

收起阅读 »

十年积累,5.4 万 GitHub Star 一朝清零:开源史上最大意外损失

我们找 GitHub CEO 求助,但为时已晚。2022 年 2 月 15 日,GitHub 通过推特平台广播了一则消息:「我们的朋友 HTTPie 最近不小心将自己设为了私密,丢掉了所有的 Star。如果你仍然爱它,就给它一颗 Star 作为情人节礼物。」1...
继续阅读 »

我们找 GitHub CEO 求助,但为时已晚。


2022 年 2 月 15 日,GitHub 通过推特平台广播了一则消息:「我们的朋友 HTTPie 最近不小心将自己设为了私密,丢掉了所有的 Star。如果你仍然爱它,就给它一颗 Star 作为情人节礼物。」


10 年攒下的 Star 突然清零?这是怎么回事?

昨天,项目作者 Jakub Roztočil 在博客中正式回应了这一事件。

十年获得 5.4W Star 的开源项目

HTTPie 项目的第一次提交还是在十年之前。

可能一些人对这个项目不够熟悉,这是一个开源 CLI HTTP 客户端。团队从头开始构建了它,以使终端的 API 交互尽可能人性化。

HTTPie(发音为 aitch-tee-tee-pie)可用于测试、调试以及通常与 API 和 HTTP 服务器交互。http&https 命令允许创建和发送任意 HTTP 请求。它们使用简单自然的语法,并提供格式化和彩色输出。

从 2012 年 2 月 25 日在哥本哈根的第一次公开提交之后,项目作者 Jakub Roztočil 就一直在 GitHub 平台上托管该项目。他自己也是 GitHub 平台的忠实粉丝。

这些年来,HTTPie 逐渐成长为平台上最受欢迎的 API 工具,收获了 5.4W 个 Star 和 1k 的关注。

HTTPie 项目受益于 GitHub 的「social coding」功能,同时,GitHub 也受益于自身平台上托管的这个受欢迎的项目。在过去十年中,可能有数百万开发人员访问了 HTTPie 项目的 GitHub 页面。

但在几周前,HTTPie 项目积累的 5.4W Star 一夜清零。

在这篇博客中,项目作者 Jakub Roztočil 详细介绍了事情经过:

发生了什么?

我不小心将项目的 repo 设为了私有,GitHub 级联删除了我们花费 10 年时间建立的社区。

这意味着什么?

如果你是一位下游维护者,或者曾经关注过 HTTPie 以获取通知,你可能需要重新关注一下 repo。

Star 也是一样,如果过去十年里你曾为该项目加注 Star,那现在 HTTPie 应该已不再是你的 Star 项目列表中的一员。

为什么要将 repo 设为私有?

将 repo 设为私有会永久删除所有关注者和 Star,这是 GitHub 的一个特性。我知道这一点,而且我显然无意 httpie/httpie 隐藏。

最直接的原因是我认为我在另一个 repo 中——一个没有内容且 0 Star 的项目。我真正打算做的是隐藏 HTTPie 组织的配置文件 README,这是我在一周前创建但没有机会填充的。

让我走上错误道路的是一个完全不相关的操作:我刚刚在我的个人资料上做了同样的事情(即隐藏了一个空的 README),将其设为 jakubroztocil/jakubroztocil 私有。

在配置文件和存储库方面,GitHub 的概念模型会将用户和组织视为非常相似的实体。在这种情况下,由于我只是想在我们组织的个人资料上重复相同的操作,我的大脑切换到了「自动驾驶」模式。

目前我没有意识到这个包含配置文件 README 的特殊 repo 的命名存在不一致问题,并且它对于用户和组织有所不同:name/name 与 name/.github.

这就是为什么我一开始要隐藏 httpie/httpie,而不是 httpie/.github,并且没有意识到我的错误。

但是,还有一个确认流程?

确实有一个确认框,旨在阻止像我这样的情况下的用户做一些愚蠢的事情。它会告诉你「你将永远失去这个存储库的所有 Star 和关注者」。

问题在于,对于没有提交和任何 Star 的 repo ,它的提示框和具有 10 年历史及 55k Star 与关注者的 repo 是完全一样的。它说的是:「警告:这是一个潜在的破坏性行动。」

套用一句话:一个盒子告诉你「你要拆房子!如果里面有人,他们都会死。」但如果你混淆了地址并认为你正准备拆的是一个空房子,那后果将不堪设想。

实际上,此处的对话应该更加结合上下文,并且再次解释一下情况,比如说「你即将杀死 55000 人。」那肯定会让我停下来。

一番操作之后

当我回到组织页面时,你可以想象我的困惑,我不仅仍然可以看到空的 README,同时我们最受欢迎的 repo 找不到了。片刻之后,我意识到发生了什么事。所以我回到 repo 的设置来翻转开关。但 GitHub 不允许我这样做——整整半个小时。

为什么这么久呢?因为这是 GitHub 级联删除我们 10 年来的 Star 和关注者所花费的时间。而且我没有办法阻止这个过程。我所能做的就是开始发消息给 GitHub 寻求支持,刷新页面并等待 Star 数量达到零,然后才能再次将其公开。

为什么 GitHub 不给我们恢复呢?

GitHub 显然有备份,并且有恢复 repo 的方法。GitHub 团队曾经自己不小心将 GitHub 桌面应用程序 repo 设为私有,然后他们在几个小时内就恢复了一切,当时前 GitHub CEO 给出的解释是:

然而,在我们的事件中,他们拒绝这样做,理由是操作具有不良影响,并且会消耗资源成本。我们甚至提出为所需的任何资源提供经济补偿,但遗憾的是,他们还是拒绝了。相对于在 GitHub 上恢复最受欢迎的社区项目之一以外,他们还有其他优先事项。

因此,GitHub 恢复存储库的前提是他们自己的项目,而不是社区项目。

经验教训

这次危机让我们得到了很多教训,这里主要分享 3 点:

教训 1:UI/UX 设计

弹出的对话框要清晰明了,减少抽象的文字说明。以一种不需要用户思索的方式设计确认对话框。当用户要删除或损坏某些文件时,不要用抽象的语言描述,以免让用户难以了解即将发生的状况,特别是会造成级联删除的行为。例如,以下是我们在 HTTPie for Desktop 中的处理方式:

对话框需要反映操作影响的严重性。当完全没有额外影响时,对话框应该尽量简单,否则会浪费用户有限的注意力,从而降低用户的敏感度:

教训 2:数据库设计

使用软删除(soft-delete)。人非圣贤,孰能无过。人们在删除操作上可能会犯错误,因此应该尽可能使用软删除,延迟硬删除(hard-delete)操作。

教训 3:与 GitHub 的关系

这是我们的人为错误,GitHub 明确表示他们没有法律义务帮助我们。我们长达十年的互惠互利关系是根据 GitHub 的服务条款确定的,除此之外,再无其他。

毕竟,GitHub 有过有争议的行为,这些行为违背了开源社区的精神。微软(已收购 GitHub)尽管拥有一定的开源精神,但并不总是有很好的声誉。

我们希望 GitHub / 微软 有朝一日能够改变他们的态度,并恢复我们的项目社区,他们拥有实现这一点的所有数据和方法。我们也希望他们改进 UI 和数据库设计,以防止这种情况未来发生在其他团队身上。

最后,尽管我们的 GitHub star 量化为虚无,但 HTTPie 现在发展得非常好,从最初作为一个副项目到现在变成了一家公司,我们的团队正在将 HTTPie 发展成一个 API 开发平台。用于 Web 和桌面的 HTTPie 私有测试版收到了很好的反馈,我们迫不及待地想在接下来的几周内公开发布它。

参考链接:
https://httpie.io/blog/stardust

来源:https://mp.weixin.qq.com/s/wbXgE9t0YSI2UKyj070V_g

收起阅读 »

华为员工利用公司系统 Bug 越权访问机密数据,引发网友热议

网友评论堪称“人间清醒”对此案件,不少网友纷纷表达了自己的看法,其中不乏科技大厂里设计技术层面的员工及程序员群体。我们看到有网友在热搜话题下方评论称:“感觉就是幸亏这个人收受的贿赂金额没有太过。如果收受的金额太大,估计华为也不会出具谅解书了,而且判刑也会更重。...
继续阅读 »

4 月 12 日,一条#华为员工越权访问机密文件被判刑#的话题冲上了微博热搜榜头条,由此引发了广大网友关于该事件的激烈讨论。





网友评论堪称“人间清醒”


对此案件,不少网友纷纷表达了自己的看法,其中不乏科技大厂里设计技术层面的员工及程序员群体。


我们看到有网友在热搜话题下方评论称:


“感觉就是幸亏这个人收受的贿赂金额没有太过。如果收受的金额太大,估计华为也不会出具谅解书了,而且判刑也会更重。


“不管是在哪个行业,建议各位多注意一下保密的问题,平常的一些小问题可能不会有人追究,但是像这种直接把公司的机密直接透露给供应商就有点太离谱了,最骚的是这人用的还是公司邮箱?”


“去窃取公司机密数据,还天真的认为公司不知道吗?实在是得不偿失!”


“虽然华为公司谅解了,但是触犯了刑法,还是会按照法律程序进行审判。”


“既然已经调离岗位了,就直接离开就行...但这位员工则是未清理 ERP 登陆信息,然后利用bug 越权访问,并将华为多个供应商的信息发送给第三方,最后被判处有期徒刑一年。”


“先不说越权访问机密数据这件事,咱就是说就为了 1.6 万元,就把自己公司的信息出卖给别的公司,这也是违法的啊。”


“作为华为的员工,你说一年挣多少钱,你差这 1.6 万块钱吗?不理解。又不是几十万或者几百万,就获利 1.6 万元,真的是因小失大。”


“越权访问公司机密数据?如果只是单纯的越权访问公司数据,那也就是违反公司规定,顶多被辞退......关键是,你利用越权的数据进行违法交易,这就是不可容忍的了。”


......



案件回顾:非法牟利16437.6元,真“得不偿失”


据中国裁判文书网披露,该“华为员工利用公司系统 Bug 越权访问机密数据”的案件显示:


2010 年 12 月,Yi(音)某从华为公司线缆物控部调任后,未按华为公司的要求将 ERP 账户线缆类编码物料价格的查询权限清理。直至 2017 年底,Yi 某违反规定多次通过越权查询、借用同事账号登录的方式在 ERP 系统内获取线缆物料的价格信息。


2016 年 12 月 27 日- 2018 年 2 月 28 日期间,Yi 某又多次通过公司邮箱将华为多个供应商共 1183 个(剔除重复部分共 918 个)线缆类编码物料的采购价格发送给金信诺公司。


在 2012 年- 2017 年 6 月 30 日期间,Yi 某收受金信诺公司购物卡共计 7000 元、篮球鞋 5 双(价值共计人民币 16437.6 元)。


在案发后,华为公司出具了谅解书,表示对Yi某侵害华为公司的行为予以谅解。


而作为被卷入其中的华为供应商、上市公司,金信诺证券部人士则透露称,早在 2018 年就已对内部涉事员工的违纪行为进行了处理,且“这仅是个人行为。”


对此案件,最终法院以“非法获取计算机信息系统数据罪”将 Yi 某判处有期徒刑一年,并处罚金2万元,同时依法追缴其违法所得 23437.6 元上缴国库。



华为:每年都要处理员工受贿问题


一直以来,企业员工受贿的现象一直备受关注,且屡禁不止。据了解,#华为员工越权访问机密文件被判刑#此次事件的情况貌似也并非个例。


据相关报道,华为每年都会处理许多类似“员工受贿”的情况。


早在 2021 年 5 月份的“华为2021中国生态大会”上,时任华为轮值董事长徐志军就曾谈到“华为每年都要处理很多员工受贿案件,有些人甚至会被判拘留或监禁”。而当这些员工的亲属向公司求饶并希望华为原谅他们时,作为东家华为也非常尴尬。


因此,华为在每次的公开场合都会向合作伙伴强调“华为应该与合作伙伴共同营造一个健康、诚信的合作环境。与此同时,华为也在加大内部监管的力度,改进工作流程和机制,尽可能减少此类事情的发生概率。


任正非曾表示,“华为维持生存的根本就是不能腐败。”不能让腐败阻碍公司的发展,更不因为发展而宽容腐败,这需要每个岗位每个人的监督。


据了解,就在去年那次大会举办之前,华为就已经通过了相关决议,严格要求华为所有员工,尤其是高级管理人员和退休高级管理人员,不能利用现在和过去的职位之便,来帮助他们的亲友成为华为的合作伙伴、代理商或供应商。



专家建议:尽快建立严格监管制度


众所周知,世界从信息时代迈进智能时代的过程中,数据是开启新时代大门的关键“资产”。特别是对于华为这样在全球领域都极具影响力的科技企业来说,数据更是安全红线。




当然,此次案件的发生也暴露了一些问题,由于其中涉及到侵犯商业秘密罪,因此也引发了不少法律界专家学者的关注。有专家建议,需要尽快建立更严格的监管和安全制度,以杜绝此类问题再发生。


一直以来,华为都非常重视数据安全。对于任何一位合格的科技巨头的员工,都必然该知道什么内容可以访问,什么内容不能访问。否则,一旦越线,就会面临严重后果,甚至面临“牢狱之灾”。

来源:https://mp.weixin.qq.com/s/M55FUYp6r7o65X_joYXvDQ

收起阅读 »

用compose撸一个雷达图

介绍项目中需要使用雷达图来展示各个属性的不同比例,文字根据控件大小自动换行。效果图如何实现1、绘制背景的三个圆形从外圆向内圆绘制,这样内圆的颜色正确覆盖在外圆上,style = Stroke(2f)用来绘制圆形的border。val CIRCLE_TURN =...
继续阅读 »

介绍

项目中需要使用雷达图来展示各个属性的不同比例,文字根据控件大小自动换行。

效果图

untitled.gif

如何实现

1、绘制背景的三个圆形

从外圆向内圆绘制,这样内圆的颜色正确覆盖在外圆上,style = Stroke(2f)用来绘制圆形的border。

val CIRCLE_TURN = 3
val center = Offset(size.width / 2, size.height / 2)
val textNeedRadius = 25.dp.toPx() // 文本绘制范围
val radarRadius = center.x - textNeedRadius
val turnRadius = radarRadius / CIRCLE_TURN

for (turn in 0 until CIRCLE_TURN) {
drawCircle(colors[turn], radius = turnRadius * (CIRCLE_TURN - turn))
drawCircle(colors[3], radius = turnRadius * (CIRCLE_TURN - turn), style = Stroke(2f))
}

2、绘制圆环内的虚线

使用360/data.size算出每个区块需要的角度。

我们知道,竖直向上为-90度,当区块数量为奇数时,第一条虚线应当在竖直方向上,即起始绘制角度为-90度;当区块数量为偶数时,虚线绘制应当左右对称,所以将初始角度设置为-90 - itemAngle / 2inCircleOffset()是用来获取在圆形中的xy位置,点击kotlin.math.cos()/sin()查看方法的描述Computes the cosine of the angle x given in radians可知,我们需要传入一个弧度,角度换算弧度的推导如下。

image.png

val itemAngle = 360 / data.size
val startAngle = if (data.size % 2 == 0) {
-90 - itemAngle / 2
} else {
-90
}

for (index in data.indices) {
// 绘制虚线
val currentAngle = startAngle + itemAngle * index
val xy = inCircleOffset(center, progress * radarRadius, currentAngle)
drawLine(colors[4], center, xy, pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 10f)))
}

/**
* 根据圆心,半径以及角度获取圆形中的xy坐标
*/
fun DrawScope.inCircleOffset(center: Offset, radius: Float, angle: Int): Offset {
return Offset((center.x + radius * cos(angle * PI / 180)).toFloat(), (center.y + radius * sin(angle * PI / 180)).toFloat())
}

3、绘制雷达范围

在最大值为100的情况下,根据bean的value换算出应当绘制点的radius。并算出对应的xy的位置,将其记录到path中方便连成闭合区间绘制。

data class RadarBean(
val text: String,
val value: Float
)
for (index in data.indices) {
val pointData = data[index]
val pointRadius = radarRadius * pointData.value / 100
val fixPoint = inCircleOffset(center, pointRadius, currentAngle)
if (index == 0) {
path.moveTo(fixPoint.x, fixPoint.y)
} else {
path.lineTo(fixPoint.x, fixPoint.y)
}
}
drawPath(path, colors[5]) // 绘制闭合区间
drawPath(path, colors[6], style = Stroke(5f)) // 绘制区间的深色描边

4、绘制文字位置

接下来就是绘制最重要的文字的位置啦,首先我们先了解什么是StaticLayout,这里面有1.4小节介绍StaticLayout是如何使用的。 image.png 观察效果图,我们先分析出位置的绘制规律:

  1. 垂直方向的文字x轴在文字宽度的正中间,y轴在文字的底部
  2. 水平方向的文字x轴与y轴皆在文字的正中间
  3. 左上角的文字x轴在文字的最右边,y轴在最后一行文字的中间
  4. 右上角的文字x轴在文字的最左边,y轴在最后一行文字的中间
  5. 左下角的文字x轴在文字的最右边,y轴在第一行文字的中间
  6. 右下角的文字x轴在文字的最左边,y轴在第一行文字的中间

根据以上规律,需要对文字绘制区域进行区分:

private fun quadrant(angle: Int): Int {
return if (angle == -90 || angle == 90) {
0 // 垂直
} else if (angle == 0) {
-1 // 水平右边
} else if (angle == 180) {
-2 // 水平左边
} else if (angle > -90 && angle < 0) {
1 // 右上角
} else if (angle > 0 && angle < 90) {
2 // 右下角
} else if (angle > 90 && angle < 180) {
3 // 左下角
} else {
4 // 左上角
}
}

设置文本的最大宽度:绿色虚线为左半边的文字最大宽度,蓝色虚线为右半边的文字最大宽度。通过quadrant(currentAngle)获取文字需要绘制的区域,垂直区域的文字最大宽度设置为雷达控件的一半,绿色虚线的文字最大宽度为offset.x,蓝色虚线的文字最大宽度为size.width - offset.x

fun DrawScope.wrapText(
text: String, // 绘制的文本
textPaint: TextPaint, // 文字画笔
width: Float, // 雷达控件的宽度
offset: Offset, // 未调整前的文字绘制的xy位置
currentAngle: Int, // 当前文字绘制所在的角度
chineseWrapWidth: Float? = null // 用来处理UI需求中文每两个字符换行
) {
val quadrant = quadrant(currentAngle)
var textMaxWidth = width
when (quadrant) {
0 -> {
textMaxWidth = width / 2
}
-1, 1, 2 -> {
textMaxWidth = size.width - offset.x
}
-2, 3, 4 -> {
textMaxWidth = offset.x
}
}
}

创建StaticLayout,传入文本绘制的最大宽度textMaxWidth,该控件会根据设置的最大宽度对文本自动换行。

val staticLayout = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
StaticLayout.Builder.obtain(text, 0, text.length, textPaint, textMaxWidth.toInt()).apply {
this.setAlignment(Layout.Alignment.ALIGN_NORMAL)
}.build()
} else {
StaticLayout(text, textPaint, textMaxWidth.toInt(), Layout.Alignment.ALIGN_NORMAL, 1.0f, 0f, false)
}

通过staticLayout获取文本的高度,文本的行数。这里不能使用staticLayout.width来获取文本的宽度,因为假设设置的textMaxWidth=100,而文本绘制后的宽度只有50,通过staticLayout.width获取的宽度为100,这不是我们想要的。所以通过lines>1来判断文本是否换行,如果未换行,直接通过textPaint.measureText获取文本的真实宽度;如果换行,则staticLayout.getLineWidth(0)用来获取文本第一行的宽度就是文本的真实宽度。

val textHeight = staticLayout.height
val lines = staticLayout.lineCount
val isWrap = lines > 1
val textTrueWidth = if (isWrap) staticLayout.getLineWidth(0) else textPaint.measureText(text)

使用canvas绘制文本,这里的save() translate() staticLayout.draw(canvas) restore()是使用StaticLayout绘制的四步曲。

// 绘制文字
val textPointRadius = progress * radarRadius + 10f
val offset = inCircleOffset(center, textPointRadius, currentAngle)
val text = data[index].text
wrapText(
text,
textPaint,
size.width,
offset,
currentAngle,
if (specialHandle) textPaint.textSize * 2 else null
)

drawContext.canvas.nativeCanvas.save()
when (quadrant) {
0 -> { // 规律1
drawContext.canvas.nativeCanvas.translate(offset.x - textTrueWidth / 2, offset.y - textHeight)
}
-1 -> { // 规律2
drawContext.canvas.nativeCanvas.translate(offset.x, offset.y - textHeight / 2)
}
-2 -> { // 规律2
drawContext.canvas.nativeCanvas.translate(offset.x - textTrueWidth, offset.y - textHeight / 2)
}
1 -> { // 规律4
drawContext.canvas.nativeCanvas.translate(
offset.x,
if (!isWrap) offset.y - textHeight / 2 else offset.y - (textHeight - textHeight / lines / 2)
)
}
2 -> { // 规律6
drawContext.canvas.nativeCanvas.translate(offset.x, if (!isWrap) offset.y - textHeight / 2 else offset.y - textHeight / lines / 2)
}
3 -> { // 规律5
drawContext.canvas.nativeCanvas.translate(
offset.x - textTrueWidth,
if (!isWrap) offset.y - textHeight / 2 else offset.y - textHeight / lines / 2
)
}
4 -> { // 规律3
drawContext.canvas.nativeCanvas.translate(
offset.x - textTrueWidth,
if (!isWrap) offset.y - textHeight / 2 else offset.y - (textHeight - textHeight / lines / 2)
)
}
}
staticLayout.draw(drawContext.canvas.nativeCanvas)
drawContext.canvas.nativeCanvas.restore()

这样就画好了,但是产品看完效果图后不喜欢换行的效果,希望每两个字就换行,于是新增如下判断。

image.png

// 需要特殊处理换行&&包含中文字符&&文本绘制一行的宽度>文本最大宽度
if (chineseWrapWidth != null && isContainChinese(text) && textPaint.measureText(text) > textMaxWidth) {
textMaxWidth = chineseWrapWidth
}
private fun isContainChinese(str: String): Boolean {
val p = Pattern.compile("[\u4e00-\u9fa5]")
val m = p.matcher(str)
return m.find()
}

5、增加个小动画

当雷达图从屏幕中出现的时候,做一个绘制值从0到实际值的动画

var enable by remember {
mutableStateOf(false)
}
val progress by animateFloatAsState(if (enable) 1f else 0f, animationSpec = tween(2000))

Modifier.onGloballyPositioned {
enable = it.boundsInRoot().top >= 0 && it.boundsInRoot().right > 0
}

如何使用

private val list = listOf(
RadarBean("基本财务", 43f),
RadarBean("基本财务财务", 90f),
RadarBean("基", 90f),
RadarBean("基本财务财务", 90f),
RadarBean("基本财务", 83f),
RadarBean("技术择时择时", 50f),
RadarBean("景气行业行业", 83f)
)
ComposeRadarView(
modifier = Modifier
.padding(horizontal = 4.dp)
.size(180.dp),
list
)

项目地址

最后贴上项目的地址:ComposeRadar

如果觉得对您有帮助就点个👍吧~


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

收起阅读 »

Android自定义View- 画一幅实时心电测量图

概述 这次来讲讲心电图的绘制,这也是项目当中用到过的。心电图继承自View,概括一下主要有以下内容要实现:**实时显示动态心电测量数据、心电波形左右滑动、惯性滑动及波形 X轴和 Y轴方向双指滑动缩放。**下面我们来看看效果图,图片上传大小有限制,所以分两张: ...
继续阅读 »
概述

这次来讲讲心电图的绘制,这也是项目当中用到过的。心电图继承自View,概括一下主要有以下内容要实现:**实时显示动态心电测量数据、心电波形左右滑动、惯性滑动及波形 X轴和 Y轴方向双指滑动缩放。**下面我们来看看效果图,图片上传大小有限制,所以分两张:



Screenrecorder-2021-08-09-18-44-54-1282021891847387.gif



ECG_2.gif


下面我们将功能拆解,分步实现:



  • 画背景绿色网格线

  • 绘制实时动态心电曲线

  • 实现单指曲线左右平移

  • 实现曲线惯性滑动

  • 实现 X轴及 Y轴方向上曲线的双指滑动缩放(多点触控改变曲线增益)

  • 左上角显示当前增益


1、画网格线

这个就比较简单了。首先确定每一小格的边长,然后获取控件宽高。这样就能分别计算出水平方向及竖直方向有多少小格,也就是可以确定横线和竖线一共要画多少条。然后就可以用循环画出所有的线条,其中每隔5条进行线条加粗,而且画实线,这样就形成了实线大格。下面先看实现:


// 画 Bitmap
protected Bitmap gridBitmap;
// 画 Canvas
protected Canvas bitmapCanvas;
// 控件宽高
protected int viewWidth, viewHeight;
@Override
protected void onSizeChange() {
// 获取控件宽高
viewWidth = mBaseChart.getWidth();
viewHeight = mBaseChart.getHeight();
// 初始化网格 Bitmap
gridBitmap = Bitmap.createBitmap(viewWidth, viewHeight, Bitmap.Config.ARGB_8888);
bitmapCanvas = new Canvas(gridBitmap);
Log.d(TAG, "onSizeChange - " + "-- width = " +
mBaseChart.getWidth() + "-- height = " + mBaseChart.getHeight());
}

/**
* 准备好画网格的 Bitmap
*/
private void initBitmap(){
// 计算横线和竖线条数
hLineCount = (int) (viewHeight / gridSpace) + 2;
vLineCount = (int) (viewWidth / gridSpace) + 2;
// 画横线
for (int h = 0; h < hLineCount; h ++){
float startX = 0f;
float startY = gridSpace * h;
float stopX = viewWidth;
float stopY = gridSpace * h;
// 每个 5根画一条粗实线
if (h % 5 != 0){
linePaint.setPathEffect(pathEffect);
linePaint.setStrokeWidth(1.5f);
}else {
linePaint.setPathEffect(null);
linePaint.setStrokeWidth(3f);
}
// 画线
bitmapCanvas.drawLine(startX, startY, stopX,stopY, linePaint);
}
// 画竖线
for (int v = 0; v < vLineCount; v ++){
float startX = gridSpace * v;
float startY = 0f;
float stopX = gridSpace * v;
float stopY = viewHeight;
// 每隔 5根画一条粗实线
if (v % 5 != 0){
linePaint.setPathEffect(pathEffect);
linePaint.setStrokeWidth(1.5f);
}else {
linePaint.setPathEffect(null);
linePaint.setStrokeWidth(3f);
Log.d(TAG, "v = " + v);
}
// 画线
bitmapCanvas.drawLine(startX, startY, stopX,stopY, linePaint);
}
}

@Override
protected void onDraw(Canvas canvas) {
// 注释 1,Bitmap左边缘位置为getScrollX(),防止网格滑动
canvas.drawBitmap(gridBitmap, mBaseChart.getScrollX(), 0, null);
}

这里想提一下的是,这里网格线并不是直接画在控件 onDraw方法的 Canvas上的。而是在控件初始化时,事先将网格所有线条画在一张 Bitmap上,然后绘制时直接绘制 Bitmap。这样搞就不用每次绘制时都计算一遍线条的位置了。


还有就是上面注释 1处,绘制网格 Bitmap的左边缘的位置是 getScrollX()。因为后面要实现曲线左右滑动,但网格要固定不动。


2、绘制动态实时心电曲线

这就是心电图最主要的实现了。心电在测量的时候会实时传递电压值,我们需要把电压值实时存进数组里。然后把电压值换算成 Y坐标值,再根据事先确定好的 X轴方向两个数据点的距离来确定每个电压值在 X轴方向的坐标。然后从左到右确定曲线的路径Path,再将Path绘制到Canvas上就可以了。


我们观察上面效果图会发现,这里的实现是最后一个到达的数据的显示不会超过控件右边缘。也就是当曲线 X方向的长度不超过控件宽度时,曲线第一个点的横坐标 x = 0。当曲线 X方向长度大于控件宽度时,曲线 Path的第一个点的横坐标就向左移,也就是 x为负的了。这样就实现上面效果中,测量实时心电时,曲线会向左移。这样新来的数据就显示在控件可见范围内,早来的数据逐步向左移出控件可见范围。下面画个草图吧,草图大概就这么个意思:



心电.png


下面看一下实现:


    /**
* 创建曲线
*/
private boolean createPath() {
// 曲线长度超过控件宽度,曲线起点往左移
// 根据控件宽度和数组长度以及 X增益算出数组第一个数的 X坐标
float startX = (this.data.size() * dataSpaceX > viewWidth) ?
(viewWidth - (this.data.size() * dataSpaceX)) : 0f;
// 曲线复位
dataPath.reset();
for (int i = 0; i < this.data.size(); i++) {
// 确定 X轴坐标
float x = startX + i * this.dataSpaceX;
// 确定 Y轴坐标
float y = getVisibleY(this.data.get(i));
// 绘制曲线
if (i == 0) {
dataPath.moveTo(x, y);
} else {
dataPath.lineTo(x, y);
}
}
return true;
}
/**
* 电压 mv(毫伏)在 Y轴方向的换算
* 屏幕向上往下是 Y 轴正方向,所以电压值要乘以 -1进行翻转
* 目前默认每一大格代表 1000 mv,而真正一大格的宽度只有 150,所以 data要以两数换算
* Y == 0,是在 View的上边缘,所以要向下偏移将波形显示在中间
*
* @param data
* @return
*/
// 注释 2
private float getVisibleY(int data) {
// 电压值换算成 Y值
float visibleY = -smallGridSpace * 5 / mvPerLargeGrid * data;
// 向下偏移
visibleY = visibleY + smallGridSpace * 5 * offset;
return visibleY;
}

@Override
protected void onDraw(Canvas canvas) {
// 绘制心电曲线
canvas.drawPath(dataPath, linePaint);
}

上面有一点需要注意的,就是我们的 Y值的换算。我们知道Android屏幕自上而下是 Y轴正方向,所以我们如果直接把电压值画在屏幕上它是倒挂的。另外,这里默认的一大格代表1000mv电压值(可设),而真正一大格的边长是150。所以我们需要将电压值换算成屏幕像素。具体看上面注释 2的getVisibleY方法上面注释。


3、实现曲线左右平移

当心电测量完之后,我们需要实现曲线随手指滑动平移。这样才能看到心电图的全部内容。这个实现原理也简单,也就是监听onTouch事件,根据手指位移使用View的scrollBy方法来实现内容平移就可以了:


 /**
* @param event 单指事件
*/
private void singlePoint(MotionEvent event) {
mVelocityTracker.addMovement(event);
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
lastX = event.getX();
break;
case MotionEvent.ACTION_MOVE:
float deltaX = event.getX() - lastX;
delWithActionMove(deltaX);
lastX = event.getX();
break;
case MotionEvent.ACTION_UP:
// 计算滑动速度
computeVelocity();
break;
}
}

/**
* @param deltaX 处理 MOVE事件
*/
private void delWithActionMove(float deltaX) {
if (this.data.size() * dataSpaceX <= viewWidth) return;
int leftBorder = getLeftBorder(); // 左边界
int rightBorder = getRightBorder(); // 右边界
int scrollX = mBaseChart.getScrollX(); // X轴滑动偏移量

if ((scrollX <= leftBorder) && (deltaX > 0)) {
mBaseChart.scrollTo((int) (viewWidth - this.data.size() * dataSpaceX), 0);
} else if ((scrollX >= rightBorder) && (deltaX < 0)) {
mBaseChart.scrollTo(0, 0);
} else {
// 内容平移
mBaseChart.scrollBy((int) -deltaX, 0);
}
}

注意上面左右边界的设定,别让曲线划出屏幕了。


4、惯性滑动

惯性滑动的实现,这里使用的套路是 VelocityTracker。先追踪手指滑动速度,然后使用 Scroller并结合 View的 computeScroll()方法和 scrollTo方法,实现手指离开屏幕后的惯性滑动。这部分内容在我上一篇文章画一个FM调频收音机刻度表

有讲,这里不再重复。


5、实现双指滑动,在横纵坐标方向缩放曲线

在实现双指滑动曲线缩放功能之前,我们先讲讲一小部分 MotionEvent的基础知识。为什么说只讲一小部分呢?因为 MotionEvent这个事件体系还蛮大。我们只讲一下这次用到的部分。



onTou.png



onTouch2.png


好吧,还是直接画表格吧。这样也直观一点,不用解释那么多。上面红色圈圈圈出来的几个哥们是我们这次要用到的。




  • event.getActionMasked() :上面也有解释,这个方法和 getAction()类似。只不过我们这次要处理多点触控,所以一定要用 getActionMasked() 来获取事件类型。




  • event.getPointerCount() :上面也有解释,获取屏幕上手指个数。因为我们这次要处理双指滑动,所以要用 (getPointerCount() == 2)进行判断。两根手指以外的事件我们不做缩放处理。




  • ACTION_POINTER_DOWN :上面又有解释,第一根手指之后,按下的其他手指。如果结合 (getPointerCount() == 2)这个前提条件,那么我们可以认为这次ACTION_POINTER_DOWN 就是第二根手指按下所触发的事件。




  • event.getX(int pointerIndex):上面也有介绍,获取某个手指当前的 X坐标。我们在获取到两个手指当前的 X坐标之后,就可以算出两指当前在 X轴方向的距离。然后再结合 ACTION_POINTER_DOWN 时所记录的坐标值,就可以计算出两个手指在 X方向上是靠近了还是疏远了(收缩了还是放大了)。getY(int pointerIndex) 方法同理,不做解释了。




  • ACTION_MOVE :两指滑动当然也要用到 MOVE事件,只不过这里 ACTION_MOVE 和单指的使用方法一样,就不做解释了。




好了,我们再看看 X轴方向缩放具体实现吧:


  /**
* 处理onTouch事件
*
* @param event 事件
* @return 拦截
*/
@Override
protected boolean onTouchEvent(MotionEvent event) {
Log.d(TAG, "pointerCount = " + event.getPointerCount());
if (event.getPointerCount() == 1) { // 单指平滑
singlePoint(event);
}
if (event.getPointerCount() == 2) { // 双指缩放
doublePoint(event);
}
return true;
}

/**
* @param event 双指事件
*/
private void doublePoint(MotionEvent event) {
if (pointOne == null) pointOne = new PointF();
if (pointTwo == null) pointTwo = new PointF();

switch (event.getActionMasked()) {
case MotionEvent.ACTION_POINTER_DOWN: // 第二根手指按下
Log.d(TAG, "ACTION_POINTER_DOWN");
// 记录第二根手指按下时,两指的坐标点
saveLastPoint(event);
numbersPerLargeGridOnThisTime = getDataNumbersPerGrid();
mvPerLargeGridOnThisTime = getMvPerLargeGrid();
break;
case MotionEvent.ACTION_MOVE: // 双指拉伸
Log.d(TAG, "ACTION_MOVE");
// 计算 X方向缩放量
getScaleX(event);
// 计算 Y轴方向所放量
getScaleY(event);
break;
case MotionEvent.ACTION_POINTER_UP: // 先离开的手指
Log.d(TAG, "ACTION_POINTER_UP");
break;
}
}

/**
* 处理 X方向的缩放
*
* @param event 事件
* @return 拉伸量
*/
private float getScaleX(MotionEvent event) {
float pointOneX = event.getX(0);
float pointTwoX = event.getX(1);
// 算出 X轴方向的拉伸量
float deltaScaleX = Math.abs(pointOneX - pointTwoX) - Math.abs(pointOne.x - pointTwo.x);
// 设置拉伸敏感度
int inDevi = mBaseChart.getWidth() / 54;
// 计算拉伸时增益偏移量
int inDe = (int) deltaScaleX / inDevi;
// 算出最终增益
int perNumber = numbersPerLargeGridOnThisTime - inDe;
// 设置增益
setDataNumbersPerGrid(perNumber);
return deltaScaleX;


好了,该解释的原理上面都做了解释。上面代码要解释的无非就是缩放敏感度调节的问题,代码里做了解释。缩放量计算出来之后,我们就可以改变心电曲线的增益了。比如说 X方向两点数据之间的距离做了调整、Y方向心电数值计算因子做了调整,然后重新算出曲线 Path再重绘,也就可以了。


6、左上角显示当前增益

最后我们要把当前增益显示出来,比如说 X轴方向一大格绘制了多少点数据、Y轴方向一大格代表多少毫伏。这两个参数都是在上一步双指缩放时动态改变的,所以要留一个对外接口让外界获取到这两个参数。


 /**
* 获取每大格显示的数据个数,再结合医疗版的采样率,就可以算出一格显示了多长时间的数据
*
* @return
*/
public int getDataNumbersPerGrid() {
return this.dataNumbersPerGrid;
}
/**
* @return 获取每大格代表多少毫伏
*/
public float getMvPerLargeGrid() {
return this.mvPerLargeGrid;
}


因为这次心电图的绘制比以往的文章都涉及到更多的细节,所以之前文章里讲过的一些实现细节这里就没重复讲。另外,这次自定义 View使用了 Base模板设计模式,用好几个类来实现了这幅心电图,所以没把完整代码贴在这里。代码还是直接放Github吧 :心电图


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

Compose的UI刷新机制是啥?和Flutter一样么?

前言 我去年发现Android新出了个UI框架Compose,看了Demo后发现,纳尼!和Flutter太像了吧,无论是编程逻辑、控件名,都有很多相似的地方。谷歌自己抄自己?一直想去尝试,感受一下,直到今年才有时间去写了小项目。 思考 我在写代码的时候和Flu...
继续阅读 »

前言


我去年发现Android新出了个UI框架Compose,看了Demo后发现,纳尼!和Flutter太像了吧,无论是编程逻辑、控件名,都有很多相似的地方。谷歌自己抄自己?一直想去尝试,感受一下,直到今年才有时间去写了小项目。


思考


我在写代码的时候和Flutter去做对比,就想到它俩的UI刷新机制是否有相似点,如果不一样,那Compose的刷新机制是什么?


在看Compose的刷新机制前,我回忆了一下Flutter的刷新机制。



简单讲Flutter是通过调用StatefulWidgetsetState方法,重新走一遍build方法中的代码。那原理就是setState调用时会自己添加到BuildOwnerdirtyElements脏链表中,然后调用window.scheduleFrame来注册Vsync回调,当下一次vsync信号的到来时会重新绘制UI。



所以我觉得Flutter更像是一个屏幕,调用setState方法不断重新构建UI页面,一帧一帧的。那Compose是不是也是这样呢?


尝试


Demo如下,在页面上显示一个Text控件,和一个按钮,每一次点击,Text显示的数字自增。


@Composable
fun demo() {
// 关键代码
var versionCode by remember { mutableStateOf(0) }

Column {
Button(
onClick = { versionCode++ }) {
Text("Add +")
}
Text( versionCode.toString() )
}
}

UI发生变化的关键代码就是 by remember { mutableStateOf(0) } ,这行代码删除后,怎么按UI都不会变化。我发每次versionCode变化的时候会重新走一遍demo()内的代码,这时该类中有其他方法,其他方法代码并不会重走。如果把Text中的versionCode引用删除,写一个固定值,这个再点击按钮,也不会重新走一遍代码。



问题



  1. Compose 是如何进行更新的?

  2. 如何做到更新时只有引用的方法内刷新?

  3. 不引用为何不刷新方法?



分析


带着以上三个问题,点进mutableStateOf中去,看一看源码。
1.png
进入 createSnapshotMutableState
2.png
进入 ParcelableSnapshotMutableState
3.png
进入SnapshotMutableStateImpl,下面这个类中,红框标记的是关键代码,这个方法的注解是



A single value holder whose reads and writes are observed by Compose.Additionally, writes to it are transacted as part of the [Snapshot] system.



我理解的意思是,对value这个值做了监听,只要是 Compose的UI 引用了value,当其发生变化时就能自动更新


4.png


这里的 value 就是给 versionCode 赋的值,这里的 get() 方法中会调用readable() ,把当前 state 保存起来(我认为这里的 state 可以理解为 Flutter 的 state)。set() 方法内会进行对比,在 equivalent() 方法中比对的是对象,当两个对象的地址不一致时,会触发监听通知,重写用的Compose方法,



这也就解释了之前说的三个问题。



  1. Compose的刷新本质是对 value 的监听通知;

  2. 为了避免过度刷新,将刷新范围固定到最小的标记@Compose的注解的方法内,包括方法内的其他方法(和Flutter的StatefulWidget的刷新范围一样),前提是其他Compose方法有参数传入(我理解是有新的参数传入,会生成新的对象),否则除了第一次不会再进行刷新;为提高性能,会把频繁刷新的View(Flutter中的widget)封装为单独的Compose方法(Stful);

  3. 对value的引用,调用了get内的注册方法,不引用,也就不会引起刷新。



以上就是我对Compose的刷新机制的理解,


应用


了解了Compose的刷新机制后,怎么才能高效的使用这中逻辑编程呢?我在踩了一堆坑后,有以下几个写代码的注意点,仅供参考。


一、


如果在方法内使用mutableStateOf,需要包一层remember函数,它的作用是运行完里面的代码后,就会存在缓存里,再次执行这行代码,不会再次初始化,会从缓存中拿出 State的对象,防止多次初始化。 如果你偏不,可以试试看有什么神奇的现象🙃。’
(PS :在remember函数中有个熟悉的参数 Key,熟悉Flutter的估计都明白干啥的了,这就不再啰嗦了)


// 成员变量可以不用套,因为本身就初始化一次
var versionCode by mutableStateOf(0)

@Composable
fun demo() {
// 方法内这么写
var versionCode by remember { mutableStateOf(0) }

Column {
Button(
onClick = { versionCode++ }) {
Text("Add +")
}
Text( versionCode.toString() )
}
}

二、


value的刷新是设置新的值,是对比对象本身,如果只改变了对象内的值,是不会放生刷新的。如代码中A对象并没有改变。


data class A(var a: Int = 0)
@Composable
fun demo() {
val versionCode by remember { mutableStateOf(A(0)) }
Column {
Button(
onClick = { versionCode.a++ }) {
Text("Add +")
}
Text( versionCode.toString() )
}
}

遇到这种情况需要刷新有两种办法



  1. 复制对象,改变对象地址


        Button(
onClick = { versionCode.copy(a = versionCode.a++) }) {
Text("Add +")
}


  1. 给对象内的变量实现mutableStateOf


data class A(var a: MutableState<Int> = mutableStateOf(0)) {
// 或写在下面用 by 代理的方式
var a by mutableStateOf(0)
}


三、


单个对象好复制,好改,遇到列表,数组类的对象刷新,该怎么做呢,换列表对象?不合适。好在官方已经替咱们想到了这一点。神器 mutableStateListOf,当列表内的数据发生变化时,会刷新UI


val data = mutableStateListOf(1, 2, 3)
@Composable
fun demo() {
Column {
Button(onClick = {
data.add(data.last() + 1)
}) {
Text(text = "onclick")
}
for (d in data) {
Text(text = "data:${d}")
}
}
}

我大概遇到的UI刷新上的坑,可以归为以上三种,还有其他的欢迎交流补充。


总结


Compose的刷新机制简单来讲是有范围,有组织的用观察者模式进行刷新。
分析了个大概,更深层次的原理,以后慢慢啃。
Compose的编程模式和Flutter很像,会flutter很容易上手,还挺有意思的。


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

尴尬又暖心!学生知乎上提问导师人品如何,没想到导师亲自回答了...

在研究生阶段,学生选择导师一定是慎之又慎,除了学术造诣之外,导师的人品也非常重要。因此很多学生会在选择导师之前进行多方打听,以求对老师进行全面的了解,免得做出让自己后悔的决定。藏龙卧虎的知乎平台自然是很多学子的首选之地。就这两天,有学生在知乎上提问北航的程林老...
继续阅读 »

在研究生阶段,学生选择导师一定是慎之又慎,除了学术造诣之外,导师的人品也非常重要。因此很多学生会在选择导师之前进行多方打听,以求对老师进行全面的了解,免得做出让自己后悔的决定。

藏龙卧虎的知乎平台自然是很多学子的首选之地。就这两天,有学生在知乎上提问北航的程林老师的情况,想要向广大网友提问一下课题组的环境,以及导师的人品如何。

万万没想到,这一问结果引来了老师的亲自回答,这个互动真的太暖心了吧!

得到了老师的亲自回应后,提问者也表明了自己的身份,生怕别人以为是老师自问自答,故意宣传自己。

在评论区,我们可以看到的评论都是对程老师对褒奖:

在程林老师的个人主页上,包含关于招生和团队建设方面的内容,从中也可以看到他的独特。

基本原则:与学生交朋友、以学生成长为目的,打造积极努力、乐观融洽的学术型科研团队~

学生培养方面:与学生交朋友,保持沟通畅达;以学术方向发展为导向,与学生一道确立个人成长路线;积极辅助构建/完善知识体系、学术跟踪和解决能力、碎片化学习能力、任务管理能力,与学生一道成长~

程林教授主页:

关于push不push的问题,整体原则是:我尊重学生的选择。但私下来讲,我自己努力,当然也希望自己的学生们也能够努力,这对学生自己和课题组都有好处。具体科研和项目进展不顺利,我也会着急,甚至责怪两句,但是我都是尽可能的动之以情、晓之以理,毕竟老师是弱势群体,我也没啥大棒子可以威胁。人品好不好,我也不知道,自认为性格开朗,朋友蛮多,家庭和美。

最后一点,能不能学到真本事,我就真的不能保证了,研究生区别于高中和本科,独立科研的比重特别大,导师更多的是一个旁观者和引路人,师傅领进门,修行靠个人。我唯一可以承诺的就是,在关键的时候、在你需要的时候,我会拼尽全力的帮你。

最最后一点,我会强迫你建立自己的知识体系,因为我觉得这是我能教你最重要的东西,希望未来的你能够理解。

版权声明

本文来源:知乎、谷粉学术等

收起阅读 »

如何优雅地处理重复请求(并发请求)

对于一些用户请求,在某些情况下是可能重复发送的,如果是查询类操作并无大碍,但其中有些是涉及写入操作的,一旦重复了,可能会导致很严重的后果,例如交易的接口如果重复请求可能会重复下单。重复的场景有可能是:黑客拦截了请求,重放前端/客户端因为某些原因请求重复发送了,...
继续阅读 »

对于一些用户请求,在某些情况下是可能重复发送的,如果是查询类操作并无大碍,但其中有些是涉及写入操作的,一旦重复了,可能会导致很严重的后果,例如交易的接口如果重复请求可能会重复下单。

重复的场景有可能是:

  1. 黑客拦截了请求,重放

  2. 前端/客户端因为某些原因请求重复发送了,或者用户在很短的时间内重复点击了

  3. 网关重发

  4. ….

本文讨论的是如何在服务端优雅地统一处理这种情况,如何禁止用户重复点击等客户端操作不在本文的讨论范畴。

利用唯一请求编号去重

可能会想到的是,只要请求有唯一的请求编号,那么就能借用Redis做这个去重——只要这个唯一请求编号在redis存在,证明处理过,那么就认为是重复的

代码大概如下:

    String KEY = "REQ12343456788";//请求唯一编号
  long expireTime = 1000;// 1000毫秒过期,1000ms内的重复请求会认为重复
  long expireAt = System.currentTimeMillis() + expireTime;
  String val = "expireAt@" + expireAt;

  //redis key还存在的话要就认为请求是重复的
  Boolean firstSet = stringRedisTemplate.execute((RedisCallback<Boolean>) connection -> connection.set(KEY.getBytes(), val.getBytes(), Expiration.milliseconds(expireTime), RedisStringCommands.SetOption.SET_IF_ABSENT));

  final boolean isConsiderDup;
  if (firstSet != null && firstSet) {// 第一次访问
      isConsiderDup = false;
  } else {// redis值已存在,认为是重复了
      isConsiderDup = true;
  }

业务参数去重

上面的方案能解决具备唯一请求编号的场景,例如每次写请求之前都是服务端返回一个唯一编号给客户端,客户端带着这个请求号做请求,服务端即可完成去重拦截。

但是,很多的场景下,请求并不会带这样的唯一编号!那么我们能否针对请求的参数作为一个请求的标识呢?

先考虑简单的场景,假设请求参数只有一个字段reqParam,我们可以利用以下标识去判断这个请求是否重复。用户ID:接口名:请求参数

String KEY = "dedup:U="+userId + "M=" + method + "P=" + reqParam;

那么当同一个用户访问同一个接口,带着同样的reqParam过来,我们就能定位到他是重复的了。

但是问题是,我们的接口通常不是这么简单,以目前的主流,我们的参数通常是一个JSON。那么针对这种场景,我们怎么去重呢?

计算请求参数的摘要作为参数标识

假设我们把请求参数(JSON)按KEY做升序排序,排序后拼成一个字符串,作为KEY值呢?但这可能非常的长,所以我们可以考虑对这个字符串求一个MD5作为参数的摘要,以这个摘要去取代reqParam的位置。

String KEY = "dedup:U="+userId + "M=" + method + "P=" + reqParamMD5;

这样,请求的唯一标识就打上了!

注:MD5理论上可能会重复,但是去重通常是短时间窗口内的去重(例如一秒),一个短时间内同一个用户同样的接口能拼出不同的参数导致一样的MD5几乎是不可能的。

继续优化,考虑剔除部分时间因子

上面的问题其实已经是一个很不错的解决方案了,但是实际投入使用的时候可能发现有些问题:某些请求用户短时间内重复的点击了(例如1000毫秒发送了三次请求),但绕过了上面的去重判断(不同的KEY值)。

原因是这些请求参数的字段里面,是带时间字段的,这个字段标记用户请求的时间,服务端可以借此丢弃掉一些老的请求(例如5秒前)。如下面的例子,请求的其他参数是一样的,除了请求时间相差了一秒:

    //两个请求一样,但是请求时间差一秒
  String req = "{\n" +
          "\"requestTime\" :\"20190101120001\",\n" +
          "\"requestValue\" :\"1000\",\n" +
          "\"requestKey\" :\"key\"\n" +
          "}";

  String req2 = "{\n" +
          "\"requestTime\" :\"20190101120002\",\n" +
          "\"requestValue\" :\"1000\",\n" +
          "\"requestKey\" :\"key\"\n" +
          "}";

这种请求,我们也很可能需要挡住后面的重复请求。所以求业务参数摘要之前,需要剔除这类时间字段。还有类似的字段可能是GPS的经纬度字段(重复请求间可能有极小的差别)。

请求去重工具类,Java实现

public class ReqDedupHelper {

  /**
    *
    * @param reqJSON 请求的参数,这里通常是JSON
    * @param excludeKeys 请求参数里面要去除哪些字段再求摘要
    * @return 去除参数的MD5摘要
    */
  public String dedupParamMD5(final String reqJSON, String... excludeKeys) {
      String decreptParam = reqJSON;

      TreeMap paramTreeMap = JSON.parseObject(decreptParam, TreeMap.class);
      if (excludeKeys!=null) {
          List<String> dedupExcludeKeys = Arrays.asList(excludeKeys);
          if (!dedupExcludeKeys.isEmpty()) {
              for (String dedupExcludeKey : dedupExcludeKeys) {
                  paramTreeMap.remove(dedupExcludeKey);
              }
          }
      }

      String paramTreeMapJSON = JSON.toJSONString(paramTreeMap);
      String md5deDupParam = jdkMD5(paramTreeMapJSON);
      log.debug("md5deDupParam = {}, excludeKeys = {} {}", md5deDupParam, Arrays.deepToString(excludeKeys), paramTreeMapJSON);
      return md5deDupParam;
  }

  private static String jdkMD5(String src) {
      String res = null;
      try {
          MessageDigest messageDigest = MessageDigest.getInstance("MD5");
          byte[] mdBytes = messageDigest.digest(src.getBytes());
          res = DatatypeConverter.printHexBinary(mdBytes);
      } catch (Exception e) {
          log.error("",e);
      }
      return res;
  }
}

下面是一些测试日志:

public static void main(String[] args) {
  //两个请求一样,但是请求时间差一秒
  String req = "{\n" +
          "\"requestTime\" :\"20190101120001\",\n" +
          "\"requestValue\" :\"1000\",\n" +
          "\"requestKey\" :\"key\"\n" +
          "}";

  String req2 = "{\n" +
          "\"requestTime\" :\"20190101120002\",\n" +
          "\"requestValue\" :\"1000\",\n" +
          "\"requestKey\" :\"key\"\n" +
          "}";

  //全参数比对,所以两个参数MD5不同
  String dedupMD5 = new ReqDedupHelper().dedupParamMD5(req);
  String dedupMD52 = new ReqDedupHelper().dedupParamMD5(req2);
  System.out.println("req1MD5 = "+ dedupMD5+" , req2MD5="+dedupMD52);

  //去除时间参数比对,MD5相同
  String dedupMD53 = new ReqDedupHelper().dedupParamMD5(req,"requestTime");
  String dedupMD54 = new ReqDedupHelper().dedupParamMD5(req2,"requestTime");
  System.out.println("req1MD5 = "+ dedupMD53+" , req2MD5="+dedupMD54);

}

日志输出:

req1MD5 = 9E054D36439EBDD0604C5E65EB5C8267 , req2MD5=A2D20BAC78551C4CA09BEF97FE468A3F
req1MD5 = C2A36FED15128E9E878583CAAAFEFDE9 , req2MD5=C2A36FED15128E9E878583CAAAFEFDE9

日志说明:

  • 一开始两个参数由于requestTime是不同的,所以求去重参数摘要的时候可以发现两个值是不一样的

  • 第二次调用的时候,去除了requestTime再求摘要(第二个参数中传入了”requestTime”),则发现两个摘要是一样的,符合预期。

总结

至此,我们可以得到完整的去重解决方案,如下:

String userId= "12345678";//用户
String method = "pay";//接口名
String dedupMD5 = new ReqDedupHelper().dedupParamMD5(req,"requestTime");//计算请求参数摘要,其中剔除里面请求时间的干扰
String KEY = "dedup:U=" + userId + "M=" + method + "P=" + dedupMD5;

long expireTime = 1000;// 1000毫秒过期,1000ms内的重复请求会认为重复
long expireAt = System.currentTimeMillis() + expireTime;
String val = "expireAt@" + expireAt;

// NOTE:直接SETNX不支持带过期时间,所以设置+过期不是原子操作,极端情况下可能设置了就不过期了,后面相同请求可能会误以为需要去重,所以这里使用底层API,保证SETNX+过期时间是原子操作
Boolean firstSet = stringRedisTemplate.execute((RedisCallback<Boolean>) connection -> connection.set(KEY.getBytes(), val.getBytes(), Expiration.milliseconds(expireTime),
      RedisStringCommands.SetOption.SET_IF_ABSENT));

final boolean isConsiderDup;
if (firstSet != null && firstSet) {
  isConsiderDup = false;
} else {
  isConsiderDup = true;
}

转自:薛定谔的风口猪

链接:https://jaskey.github.io/blog/2020/05/19/handle-duplicate-request/

收起阅读 »

Kotlin flow实践总结

背景 最近学了下Kotlin Flow,顺便在项目中进行了实践,做一下总结。 Flow是什么 按顺序发出多个值的数据流。 本质就是一个生产者消费者模型,生产者发送数据给消费者进行消费。 冷流:当执行collect的时候(也就是有消费者的时候),生产者才开...
继续阅读 »

背景


最近学了下Kotlin Flow,顺便在项目中进行了实践,做一下总结。


image.png


Flow是什么


按顺序发出多个值的数据流。

本质就是一个生产者消费者模型,生产者发送数据给消费者进行消费。


Image.png



  • 冷流:当执行collect的时候(也就是有消费者的时候),生产者才开始发射数据流。

    生产者与消费者是一对一的关系。当生产者发送数据的时候,对应的消费者才可以收到数据。

  • 热流:不管有没有执行collect(也就是不管有没有消费者),生产者都会发射数据流到内存中。

    生产者与消费者是一对多的关系。当生产者发送数据的时候,多个消费者都可以收到数据


实践场景


场景一:简单列表数据的加载状态


简单的列表显示场景,可以使用onStart,onEmpty,catch,onCompletion等回调操作符,监听数据流的状态,显示相应的加载状态UI。



  • onStart:在数据发射之前触发,onStart所在的线程,是数据产生的线程

  • onCompletion:在数据流结束时触发,onCompletion所在的线程,是数据产生的线程

  • onEmpty:当数据流结束了,缺没有发出任何元素的时候触发。

  • catch:数据流发生错误的时候触发

  • flowOn:指定上游数据流的CoroutineContext,下游数据流不会受到影响


private fun coldFlowDemo() {
//创建一个冷流,在3秒后发射一个数据
val coldFlow = flow<Int> {
delay(3000)
emit(1)
}
lifecycleScope.launch(Dispatchers.IO) {
coldFlow.onStart {
Log.d(TAG, "coldFlow onStart, thread:${Thread.currentThread().name}")
mBinding.progressBar.isVisible = true
mBinding.tvLoadingStatus.text = "加载中"
}.onEmpty {
Log.d(TAG, "coldFlow onEmpty, thread:${Thread.currentThread().name}")
mBinding.progressBar.isVisible = false
mBinding.tvLoadingStatus.text = "数据加载为空"
}.catch {
Log.d(TAG, "coldFlow catch, thread:${Thread.currentThread().name}")
mBinding.progressBar.isVisible = false
mBinding.tvLoadingStatus.text = "数据加载错误:$it"
}.onCompletion {
Log.d(TAG, "coldFlow onCompletion, thread:${Thread.currentThread().name}")
mBinding.progressBar.isVisible = false
mBinding.tvLoadingStatus.text = "加载完成"
}
//指定上游数据流的CoroutineContext,下游数据流不会受到影响
.flowOn(Dispatchers.Main)
.collect {
Log.d(TAG, "coldFlow collect:$it, thread:${Thread.currentThread().name}")
}
}
}

比如上面的例子。
使用flow构建起函数,创建一个冷流,3秒后发送一个值到数据流中。
使用onStart,onEmpty,catch,onCompletion操作符,监听数据流的状态。


日志输出:


coldFlow onStart, thread:main
coldFlow onCompletion, thread:main
coldFlow collect:1, thread:DefaultDispatcher-worker-1

场景二:同一种数据,需要加载本地数据和网络数据


在实际的开发场景中,经常会将一些网络数据保存到本地,下次加载数据的时候,优先使用本地数据,再使用网络数据。

但是本地数据和网络数据的加载完成时机不一样,所以可能会有下面几种场景。



  1. 本地数据比网络数据先加载完成:那先使用本地数据,再使用网络数据

  2. 网络数据比本地数据先加载完成:



  • 网络数据加载成功,那只使用网络数据即可,不需要再使用本地数据了。

  • 网络数据加载失败,可以继续尝试使用本地数据进行兜底。



  1. 本地数据和网络数据都加载失败:通知上层数据加载失败


实现CacheRepositity


将上面的逻辑进行简单封装成一个基类,CacheRepositity。

相应的子类,只需要实现两个方法即可。



  • CResult:代表加载结果,Success 或者 Error。

  • fetchDataFromLocal(),实现本地数据读取的逻辑

  • fetchDataFromNetWork(),实现网络数据获取的逻辑


abstract class CacheRepositity<T> {
private val TAG = "CacheRepositity"

fun getData() = channelFlow<CResult<T>> {
supervisorScope {
val dataFromLocalDeffer = async {
fetchDataFromLocal().also {
Log.d(TAG,"fetchDataFromLocal result:$it , thread:${Thread.currentThread().name}")
//本地数据加载成功
if (it is CResult.Success) {
send(it)
}
}
}

val dataFromNetDeffer = async {
fetchDataFromNetWork().also {
Log.d(TAG,"fetchDataFromNetWork result:$it , thread:${Thread.currentThread().name}")
//网络数据加载成功
if (it is CResult.Success) {
send(it)
//如果网络数据已加载,可以直接取消任务,就不需要处理本地数据了
dataFromLocalDeffer.cancel()
}
}
}

//本地数据和网络数据,都加载失败的情况
val localData = dataFromLocalDeffer.await()
val networkData = dataFromNetDeffer.await()
if (localData is CResult.Error && networkData is CResult.Error) {
send(CResult.Error(Throwable("load data error")))
}
}
}

protected abstract suspend fun fetchDataFromLocal(): CResult<T>

protected abstract suspend fun fetchDataFromNetWork(): CResult<T>

}

sealed class CResult<out R> {
data class Success<out T>(val data: T) : CResult<T>()
data class Error(val throwable: Throwable) : CResult<Nothing>()
}

测试验证


写个TestRepositity,实现CacheRepositity的抽象方法。

通过delay延迟耗时来模拟各种场景,观察日志的输出顺序。


private fun cacheRepositityDemo(){
val repositity=TestRepositity()
lifecycleScope.launch {
repositity.getData().onStart {
Log.d(TAG, "TestRepositity: onStart")
}.onCompletion {
Log.d(TAG, "TestRepositity: onCompletion")
}.collect {
Log.d(TAG, "collect: $it")
}
}
}

本地数据比网络数据加载快


class TestRepositity : CacheRepositity<String>() {
override suspend fun fetchDataFromLocal(): CResult<String> {
delay(1000)
return CResult.Success("data from fetchDataFromLocal")
}

override suspend fun fetchDataFromNetWork(): CResult<String> {
delay(2000)
return CResult.Success("data from fetchDataFromNetWork")
}
}

模拟数据:本地加载delay1秒,网络加载delay2秒

日志输出:collect 执行两次,先收到本地数据,再收到网络数据。


onStart
fetchDataFromLocal result:Success(data=data from fetchDataFromLocal) , thread:main
collect: Success(data=data from fetchDataFromLocal)
fetchDataFromNetWork result:Success(data=data from fetchDataFromNetWork) , thread:main
collect: Success(data=data from fetchDataFromNetWork)
onCompletion

网络数据比本地数据加载快


class TestRepositity : CacheRepositity<String>() {
override suspend fun fetchDataFromLocal(): CResult<String> {
delay(2000)
return CResult.Success("data from fetchDataFromLocal")
}

override suspend fun fetchDataFromNetWork(): CResult<String> {
delay(1000)
return CResult.Success("data from fetchDataFromNetWork")
}
}

模拟数据:本地加载delay 2秒,网络加载delay 1秒

日志输出:collect 只执行1次,只收到网络数据。


onStart
fetchDataFromNetWork result:Success(data=data from fetchDataFromNetWork) , thread:main
collect: Success(data=data from fetchDataFromNetWork)
onCompletion

网络数据加载失败,使用本地数据


class TestRepositity : CacheRepositity<String>() {
override suspend fun fetchDataFromLocal(): CResult<String> {
delay(2000)
return CResult.Success("data from fetchDataFromLocal")
}

override suspend fun fetchDataFromNetWork(): CResult<String> {
delay(1000)
return CResult.Error(Throwable("fetchDataFromNetWork Error"))
}
}

模拟数据:本地加载delay 2秒,网络数据加载失败

日志输出:collect 只执行1次,只收到本地数据。


onStart
fetchDataFromNetWork result:Error(throwable=java.lang.Throwable: fetchDataFromNetWork Error) , thread:main
fetchDataFromLocal result:Success(data=data from fetchDataFromLocal) , thread:main
collect: Success(data=data from fetchDataFromLocal)
onCompletion

网络数据和本地数据都加载失败


class TestRepositity : CacheRepositity<String>() {
override suspend fun fetchDataFromLocal(): CResult<String> {
delay(2000)
return CResult.Error(Throwable("fetchDataFromLocal Error"))
}

override suspend fun fetchDataFromNetWork(): CResult<String> {
delay(1000)
return CResult.Error(Throwable("fetchDataFromNetWork Error"))
}
}

模拟数据:本地数据加载失败,网络数据加载失败

日志输出: collect 只执行1次,结果是CResult.Error,代表加载数据失败。


onStart
fetchDataFromNetWork result:Error(throwable=java.lang.Throwable: fetchDataFromNetWork Error) , thread:main
fetchDataFromLocal result:Error(throwable=java.lang.Throwable: fetchDataFromLocal Error) , thread:main
collect: Error(throwable=java.lang.Throwable: load data error)
onCompletion

场景三:多种数据源,按照顺序合并进行展示


Image.png


在实际的开发场景中,经常一个页面的数据,是需要发起多个网络请求之后,组合数据之后再进行显示。
比如类似这种页面,3种数据,需要由3个网络请求获取得到,然后再进行相应的显示。


实现目标:



  1. 接口间不需要互相等待,哪些数据先回来,就先展示哪部分

  2. 控制数据的显示顺序


flow combine操作符


可以合并多个不同的 Flow 数据流,生成一个新的流。
只要其中某个子 Flow 数据流有产生新数据的时候,就会触发 combine 操作,进行重新计算,生成一个新的数据。


例子


class HomeViewModel : ViewModel() {

//暴露给View层的列表数据
val list = MutableLiveData<List<String?>>()

//多个子Flow,这里简单都返回String,实际场景根据需要,返回相应的数据类型即可
private val bannerFlow = MutableStateFlow<String?>(null)
private val channelFlow = MutableStateFlow<String?>(null)
private val listFlow = MutableStateFlow<String?>(null)


init {
//使用combine操作符
viewModelScope.launch {
combine(bannerFlow, channelFlow, listFlow) { bannerData, channelData, listData ->
Log.d("HomeViewModel", "combine bannerData:$bannerData,channelData:$channelData,listData:$listData")
//只要子flow里面的数据不为空,就放到resultList里面
val resultList = mutableListOf<String?>()
if (bannerData != null) {
resultList.add(bannerData)
}
if (channelData != null) {
resultList.add(channelData)
}
if (listData != null) {
resultList.add(listData)
}
resultList
}.collect {
//收集combine之后的数据,修改liveData的值,通知UI层刷新列表
Log.d("HomeViewModel", "collect: ${it.size}")
list.postValue(it)
}
}
}

fun loadData() {
viewModelScope.launch(Dispatchers.IO) {
//模拟耗时操作
async {
delay(1000)
Log.d("HomeViewModel", "getBannerData success")
bannerFlow.emit("Banner")
}
async {
delay(2000)
Log.d("HomeViewModel", "getChannelData success")
channelFlow.emit("Channel")
}
async {
delay(3000)
Log.d("HomeViewModel", "getListData success")
listFlow.emit("List")
}
}
}
}

HomeViewModel



  1. 提供一个 LiveData 的列表数据给View层使用

  2. 内部有3个子 flow ,分别负责相应数据的生产。(这里简单都返回String,实际场景根据需要,返回相应的数据类型即可)。

  3. 通过 combine 操作符,组合这3个子flow的数据。

  4. collect 接收生成的新数据,并修改liveData的数据,通知刷新UI


View层使用


private fun flowCombineDemo() {
val homeViewModel by viewModels<HomeViewModel>()
homeViewModel.list.observe(this) {
Log.d("HomeViewModel", "observe size:${it.size}")
}
homeViewModel.loadData()
}

简单的创建一个 ViewModel ,observe 列表数据对应的 LiveData。

通过输出的日志发现,触发数据加载之后,每次子 Flow 流生产数据的时候,都会触发一次 combine 操作,生成新的数据。


日志输出:
combine bannerData:null,channelData:null,listData:null
collect: 0
observe size:0

getBannerData success
combine bannerData:Banner,channelData:null,listData:null
collect: 1
observe size:1

getChannelData success
combine bannerData:Banner,channelData:Channel,listData:null
collect: 2
observe size:2

getListData success
combine bannerData:Banner,channelData:Channel,listData:List
collect: 3
observe size:3

总结


具体场景,具体分析。刚好这几个场景,配合Flow进行使用,整体实现也相对简单了一些。


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

Kotlin的 :: 符号是个啥?

前言 在阅读Kotlin的代码时,经常有看到 :: 这个符号,这个符号专业术语叫做成员引用,在代码中使用可以简化代码,那到底怎么使用呢以及使用的范围,这篇文章就来好好捋一下。 正文 这里虽然很熟悉,但是我们还是从简单说起,需要了解为什么这样设计。 传递函数优化...
继续阅读 »

前言


在阅读Kotlin的代码时,经常有看到 :: 这个符号,这个符号专业术语叫做成员引用,在代码中使用可以简化代码,那到底怎么使用呢以及使用的范围,这篇文章就来好好捋一下。


正文


这里虽然很熟悉,但是我们还是从简单说起,需要了解为什么这样设计。


传递函数优化


这里我们举个栗子,就看这个熟悉的sortBy排序函数,先定义People类:


//测试代码
data class People(val name: String,val age: Int){
//自定义的排序条件
fun getMax() : Int{
return age * 10 + name.length
}
}

然后我们来进行排序:


val people = People("zyh",10)
val people1 = People("zyh1",100)
val peopleList = arrayListOf(people,people1)
//给sortBy传入lambda
peopleList.sortBy { people -> people.getMax() }

这里我们给sortBy函数传递一个lambda,由于sortBy函数是内联的,所以传递给它的lambda会被内联,但是假如现在有个问题,就是这些lambda已经被定义成了函数变量,比如我定义了一个顶层函数:


//定义了一个顶层函数
fun getMaxSort(people: People): Int{
return (people.age) * 10 + people.name.length
}

或者排序条件已经定义成了一个变量值:


//排序条件
val condition = { people: People -> people.getMax() }

那这时如果我想再进行排序必须要这么写了:


//调用一遍函数
peopleList.sortBy { getMaxSort(it) }
//传递参数
peopleList.sortBy(condition)

然后这里我们可以利用成员引用 :: 符号来优化一下:


//直接就会调用顶层函数getMaxSort
peopleList.sortBy(::getMaxSort)
//直接就会调用People类的getMax函数
peopleList.sortBy(People::getMax)

这里看起来就是语法糖,可以简化代码。


成员引用 ::


你有没有想过这里是为什么,这里使用了 :: 符号其实就是把函数转换成了一个值,首先我们使用


val condition = { people: People -> people.getMax() }

这种时,其实condition就是一个函数类型的变量,这个我们之前文章说过,Kotlin支持完整的函数类型,而使用高阶函数可以用lambda,但是getMaxSort()函数它就是一个函数了,它不是一个值,除非你再外面给它包裹一层构成lambda,所以这里调用condition传递进的是sortBy()中,而getMaxSort(it)是以lambda的形式又包裹了一层。


但是使用 :: 符号后,也就是把函数转换成了一个值,比如 People::getMax 这就是一个值,它代表的就是People内的getMax函数。


而 ::getMaxSort 也是一个值,它表示getMaxSort函数。


使用范围


前面2个例子其实也就表明了这种成员引用的使用范围,一个是类的函数或者属性,还有就是顶层函数,它没有类名,可以省略。


绑定引用


这里再额外说一个知识点,前面说成员引用都是 类名:属性名 这种格式,比如 People::getMax ,但是它在后面KT版本变化后进行了优化,可以看下面代码:


//定义一个people实例
val people = People("zyh",10)
//利用成员引用,把函数转换成值
val ageFun = People::age
val age = ageFun(people)
//直接在对象实例上使用 ::
val ageValue = people::age

从上面我们发现,ageValue的值可以从实例上通过成员引用调用得到,不过这里区别就大了,ageFun是一个函数类型,而ageValue则是一个int值。


总结


总结一下,其实成员引用 :: 很简单,它就是把函数给转成了值,而这个值可以看成是函数类型,这样说就十分好理解了。


不过这个真实原理可不是这么简单,并不是利用lambda又把函数包裹了一层,这里应该是反射的相关知识,我们后续再具体来说其原理,刚好后续有反射相关的文章,大家可以点赞、关注一波。


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

一文扫清DDD核心概念理解障碍

引言 在前面的几篇文章中分别从DDD概念、核心思想以及代码落地等层面阐述了DDD的落地实践的过程,但是很多同学表示对于DDD的某些概念还是觉得不太好理解,影响了对于DDD的学习以及实际应用。因此本文针对大家反馈的问题进行详细的说明,希望可以用大白话的方式把这些...
继续阅读 »

引言


在前面的几篇文章中分别从DDD概念、核心思想以及代码落地等层面阐述了DDD的落地实践的过程,但是很多同学表示对于DDD的某些概念还是觉得不太好理解,影响了对于DDD的学习以及实际应用。因此本文针对大家反馈的问题进行详细的说明,希望可以用大白话的方式把这些看似复杂的概念形象化、简单化。


领域、子域、核心域等这么多域到底怎么理解?


在DDD的众多概念中,首先需要搞清楚的就是到底什么是领域。因为DDD是领域驱动设计,所以领域是DDD的核心基础概念。那么到底什么是领域呢?领域是指某个业务范围以及在范围内进行的活动。根据这个定义,我们知道领域最重要的一个核心点就是范围,只有限定了问题研究的范围,才能针对具体范围内的问题进行研究分析,在后期 进行微服务拆分的的时候也是根据范围来进行的。


我们开发的软件平台是为了解决用户问题,既然我们需要研究问题并解决问题,那就得先确定问题的范围到底是什么。如果我们做的是电商平台,那么我们研究的是电商这个领域或者说电商这个范围的问题,实体店内的销售情况就不是我们研究的问题范围了。因此我们可以把领域理解为我们需要解决指定业务范围内的问题域。再举个生活中的例子,派出所实际都是有自己的片区的也就是业务范围,户籍管理、治安等都是归片区的派出所负责的。这里的片区实际就是领域,派出所专注解决自己片区内的各种事项。


既然我们研究的领域确定了,或者说研究的问题域以及范围确定了,那么接下来就需要对领域进行进一步的划分和切割。实际上这和我们研究事物的一般方法手段是一致的,一旦某个问题太大无从下手的时候,都会将问题进行一步步的拆解,再逐个进行分析和解决。那么放到DDD中,我们在进行分析领域的时候,如果领域对应的业务范围过大,那么就需要对领域进行拆解划分,形成对应的子域或者说更小的问题域,所以说子域对应的是相对于领域来说,更小的业务范围以及问题域。


回到我们刚才所说的电商领域,它就是一个非常大的领域,因为电商实际还包含了商品、用户、营销、支付、订单、物流等等各种复杂的业务。因此支付域、物流域等就是相对于电商来说更小的业务范围或者更小的问题域,那么这部分领域就是对于电商这个领域的子域,相当于对电商这个业务范围的进一步的划分。


image.png


搞清楚了领域和子域的区别之后,那么怎么理解核心域、通用域以及支撑域这么多其他的域呢(域太多了,我脑袋开始嗡嗡响了)?领域和子域是按照范围大小进行区分的,那么核心域、通用域等实际就是按照功能属性进行划分的。


核心域:平台的核心竞争力、最重要的业务,比如对于阿里来说,电商就是核心域。


通用域:其他子域沉淀的通用能力,没有定制化的能力,比如公司的数据中台。


支撑域:不包含核心业务能力也不是各个子域的通用能力沉淀。


那么为什么划分了子域之后,还要分什么核心域、通用域呢?实际上这样划分的目的是根据子域的属性,确定公司对于不同域的资源投入。将优势的资源投入到具备核心竞争力的域上,也是为了让产品更加具备竞争力,就是所谓的钱要花到刀刃上。


限界上下文?限界是什么?上下文又是什么?


限界上下文我觉得是DDD中一个不太好理解的概念,光看这个不明觉厉的名字,甚至有点不知道它到底想表达什么样的意思。我们先来看下限界上下文的原文---Bounded Context,通过原文我们可以看得出来,实际上限界上下文这个翻译增加了我们的理解成本。而反观Bounded Context这个原文实际更好理解一点,即为有边界的上下文。这里给大家一个小建议,如果技术上某个概念不好理解,那么不妨去看看它的原文是什么,大部分情况下原文会比翻译过来的更好理解,更能反映设计者想要表达的真实含义。


image.png


大家都知道我们的语言是有上下文环境的,有的时候同样一句话在不同的语言环境或者说语言上下文中,所代表的意思是不一样的。打个比方假如你有一个女朋友,你们约好晚上一起去吃饭,你在去晚上吃饭地方的路上,这个时候你收到一条来自女朋友的语音:“我已经到龙翔桥了,你出来后往苹果店走。如果你到了,我还没到,你就等着吧。如果我到了,你还没到,你就等着吧。”这里的你就等着吧,在不同的语境下包含的意思是不同的,一个是陈述事实,一个让你瑟瑟发抖。


因此,既然语言本身就有上下文,那么用通用语言描述的业务肯定也是有边界的。DDD中的限界上下文就是用来圈定业务范围的,目的是为了确保业务语言在限界上下文内的表达唯一,不会产生额外的歧义。这个时候大家会不会有另外一个问题,那么这个限界上下文到底是一个逻辑概念还是代码层面会有一个实实在在的边界呢?


按照我自己的理解,限界上下文既是概念上的业务边界,也是代码层面的逻辑逻辑边界。为什么这么说呢?我们在进行业务划分的时候,领域划分为子域集合,子域再划分为子子域集合,那么子子域的业务边界有时候就会和限界上下文的边界重合,也就是说子子域本身就是限界上下文,那么此时限界上下文就是业务边界。在代码落地的过程中,用户服务涉及到用户的创建、用户信息的修改等操作。肯定不会到订单服务中去做这些事情。因为他们属于不同的业务域,也就是说订单相关的操作已经超越了用户的边界上下文,因此它应该在订单的边界上下文中进行。


域和边界上下文的关系是一对一或者一对多的关系,实际上我认为域和限界上下文本质上一致的,应该是为什么这么说呢,比如我们做的微服务当中用户服务,比如,肯定不会到订单服务中去做这些事情。因为他们属于不同的业务域,也就是说订单相关的操作已经超越了用户的边界上下文,因此它应该在订单的限界上下文中进行。限界上下文最主要的作用就是限定哪些业务面描述以及业务动作在这个限界当中。


image.png


总结
DDD在实际落地实践过程中会遇到各种各样的问题,首当其冲的就是一些核心概念晦涩难懂,阻碍了技术人员对DDD的理解和掌握。本文对DDD比较难理解的核心概念进行了详细的描述,相信通过本文大家对于这些核心概念的理解能够更加深入。


创作不易,如果各位同学觉得文章还不错的话,麻烦点赞+收藏+评论交流哦。老样子,文末和大家分享一首诗词。


西江月▪中秋和子由


世事一场大梦,人生几度秋凉?夜来风叶已鸣廊,看取眉头鬓上。


酒贱常愁客少,月明多被云妨。中秋谁与共孤光,把盏凄然北望。


作者:慕枫技术笔记
来源:https://juejin.cn/post/7084074668147081230 收起阅读 »

Redis 缓存穿透与缓存击穿

一、🐕缓存穿透(查不到数据) 比如 用户想要查询一个数据,发现redis内存数据库没有,也就是缓存没有命中,于是先持久层数据库查询,发现也没有,于是本次查询失败,当用户很多的时候,缓存都没有命中时(一般为秒杀活动),于是都会去请求持久层数据库,这就会导致 给持...
继续阅读 »

一、🐕缓存穿透(查不到数据)


比如 用户想要查询一个数据,发现redis内存数据库没有,也就是缓存没有命中,于是先持久层数据库查询,发现也没有,于是本次查询失败,当用户很多的时候,缓存都没有命中时(一般为秒杀活动),于是都会去请求持久层数据库,这就会导致 给持久层数据库造成很大的压力,这时候就相当于出现了缓存穿透



解决方案




布隆过滤器


布隆过滤器是一种数据结构,对所有可能查询的参数以hash形式存储,在控制层先进行校验,不符合则丢弃,从而避免了对底层存储系统的查询压力
在这里插入图片描述




缓存空对象


当存储层不命中后,即使返回的空对象也将其缓存起来,同时会设置一个过期时间,之后再访问这个数据将会从缓存中获取,保护了持久层数据库
在这里插入图片描述


但是这种方法会存在两个问题


1、如果空值能够被缓存起来,这就意味着缓存需要更多的空间存储更多的键,并且当中还有还能多空值的键


2、即使对空值设置了过期时间,还是会存在缓存层和存储层的数据会有一段时间窗口的不一致,这对于需要保持一致性的业务会有影响



二、🐂缓存击穿(量太大,缓存过期)


这里需要注意和缓存击穿的区别,缓存击穿,是指一个key非常热点,在不停的杠大并发,大并发集中对这个点进行访问,当这个key在失效的瞬间,持续的大并发就穿破缓存,直接请求数据库,就像在一堵墙上戳穿了一个洞
当某个key 在过期的瞬间,有大量的请求并发访问,这类数据一般是热点数据,由于缓存过期,会同时访问数据库来查询最新数据,并且写回缓存,会导致数据库瞬间压力过大



解决方案



设置热点数据永不过期



从缓存层面上看,没有设置过期时间,所以不会出现热点key过期后产生的问题



加互斥锁



分布式锁,保证对于每个key同时只有一个线程去查询后端服务,其他线程没有获取分布式锁的权限,因此只需要等待即可,这种方式将高并发的压力转移到了分布式锁,因此对分布式锁的考验很大



三、🐅缓存雪崩


缓存雪崩,是指在某一个时间段,缓存集中过期失效,Redis宕机


产生雪崩的原因之一,比如在双十一零点,有一波商品比较热门,所以这波商品会放在缓存,假设缓存一个小时,那么到了1点钟时,缓存即将过期,此时如果再对此波商品进行访问查询,压力最终就会落到数据库上,对于数据库而言,就会产生周期性的压力波峰,于是所有的请求都会到达存储层,存储层的调用量会暴增,造成存储层也会挂掉的情况。


在这里插入图片描述


其实集中过期,倒不是非常致命,比较致命的缓存雪崩,是缓存服务器某个节点宕机或断网。因为自然形成的缓存雪崩,一定是在某个时间段集中创建缓存,此时,数据库也是可以顶住压力的。无非就是对数据库产生周期性的压力而已。而缓存服务节点的宕机,对数据库服务器造成的压力是不可预知的,很有可能瞬间就把数据库压垮。



解决方案


1、Redis 高可用


既然 redis 有可能挂掉,那我就设多几台 redis ,其实就是搭建集群


2、限流降级


在缓存失效后,通过加锁或者 队列来控制读数据库写缓存的线程数量,比如对某个key 只允许一个线程查询数据和写缓存,其他线程等待


3、数据预热


数据加热的含义就是在正式部署之前,我先把可能的数据先预先访问一遍,这样部分可能大量访问的数据就会加载到缓存中,在即将发生大并发访问前手动触发加载缓存不同的key,设置不同的过期时间,让缓存失效的时间点尽量均匀



作者:CSDNCoder
来源:https://juejin.cn/post/7084820115505545247
收起阅读 »

Python 中的万能之王 Lambda 函数

Python 提供了非常多的库和内置函数。有不同的方法可以执行相同的任务,而在 Python 中,有个万能之王函数:lambda 函数,它可以以不同的方式在任何地方使用。今天将和大家一起研究下这个万能之王!Lambda 函数简介Lambda函数也被称为匿名(没...
继续阅读 »

Python 提供了非常多的库和内置函数。有不同的方法可以执行相同的任务,而在 Python 中,有个万能之王函数:lambda 函数,它可以以不同的方式在任何地方使用。今天将和大家一起研究下这个万能之王!

Lambda 函数简介

Lambda函数也被称为匿名(没有名称)函数,它直接接受参数的数量以及使用该参数执行的条件或操作,该参数以冒号分隔,并返回最终结果。为了在大型代码库上编写代码时执行一项小任务,或者在函数中执行一项小任务,便在正常过程中使用lambda函数。
lambda argument_list:expersion
argument_list是参数列表,它的结构与Python中函数(function)的参数列表是一样的

a,b
a=1,b=2
*args
**kwargs
a,b=1,*args

....

expression是一个关于参数的表达式,表达式中出现的参数需要在argument_list中有定义,并且表达式只能是单行的。

1
None
a+b
sum(a)
1 if a >10 else 0
[i for i in range(10)]
...

普通函数和Lambda函数的区别

  1. 没有名称
    Lambda函数没有名称,而普通操作有一个合适的名称。

  2. Lambda函数没有返回值
    使用def关键字构建的普通函数返回值或序列数据类型,但在Lambda函数中返回一个完整的过程。假设我们想要检查数字是偶数还是奇数,使用lambda函数语法类似于下面的代码片段。

    b = lambda x: "Even" if x%2==0 else "Odd" b(9)

  3. 函数只在一行中
    Lambda函数只在一行中编写和创建,而在普通函数的中使用缩进

  4. 不用于代码重用
    Lambda函数不能用于代码重用,或者不能在任何其他文件中导入这个函数。相反,普通函数用于代码重用,可以在外部文件中使用。

为什么要使用Lambda函数?
一般情况下,我们不使用Lambda函数,而是将其与高阶函数一起使用。高阶函数是一种需要多个函数来完成任务的函数,或者当一个函数返回任何另一个函数时,可以选择使用Lambda函数。

什么是高阶函数?
通过一个例子来理解高阶函数。假设有一个整数列表,必须返回三个输出。

  • 一个列表中所有偶数的和

  • 一个列表中所有奇数的和

  • 一个所有能被三整除的数的和
    首先假设用普通函数来处理这个问题。在这种情况下,将声明三个不同的变量来存储各个任务,并使用一个for循环处理并返回结果三个变量。该方法常规可正常运行。

现在使用Lambda函数来解决这个问题,那么可以用三个不同的Lambda函数来检查一个待检验数是否是偶数,奇数,还是能被三整除,然后在结果中加上一个数。

def return_sum(func, lst):
result = 0
for i in lst:
  #if val satisfies func
  if func(i):
    result = result + i
return result
lst = [11,14,21,56,78,45,29,28]
x = lambda a: a%2 == 0
y = lambda a: a%2 != 0
z = lambda a: a%3 == 0
print(return_sum(x, lst))
print(return_sum(y, lst))
print(return_sum(z, lst))

这里创建了一个高阶函数,其中将Lambda函数作为一个部分传递给普通函数。其实这种类型的代码在互联网上随处可见。然而很多人在使用Python时都会忽略这个函数,或者只是偶尔使用它,但其实这些函数真的非常方便,同时也可以节省更多的代码行。接下来我们一起看看这些高阶函数。

Python内置高阶函数

Map函数

map() 会根据提供的函数对指定序列做映射。

Map函数是一个接受两个参数的函数。第一个参数 function 以参数序列中的每一个元素调用 function 函数,第二个是任何可迭代的序列数据类型。返回包含每次 function 函数返回值的新列表。

map(function, iterable, ...)
Map函数将定义在迭代器对象中的某种类型的操作。假设我们要将数组元素进行平方运算,即将一个数组的每个元素的平方映射到另一个产生所需结果的数组。

arr = [2,4,6,8] 
arr = list(map(lambda x: x*x, arr))
print(arr)

我们可以以不同的方式使用Map函数。假设有一个包含名称、地址等详细信息的字典列表,目标是生成一个包含所有名称的新列表。

students = [
          {"name": "John Doe",
            "father name": "Robert Doe",
            "Address": "123 Hall street"
            },
          {
            "name": "Rahul Garg",
            "father name": "Kamal Garg",
            "Address": "3-Upper-Street corner"
          },
          {
            "name": "Angela Steven",
            "father name": "Jabob steven",
            "Address": "Unknown"
          }
]
print(list(map(lambda student: student['name'], students)))
>>> ['John Doe', 'Rahul Garg', 'Angela Steven']

上述操作通常出现在从数据库或网络抓取获取数据等场景中。

Filter函数

Filter函数根据给定的特定条件过滤掉数据。即在函数中设定过滤条件,迭代元素,保留返回值为True 的元素。Map 函数对每个元素进行操作,而 filter 函数仅输出满足特定要求的元素。

假设有一个水果名称列表,任务是只输出那些名称中包含字符“g”的名称。

fruits = ['mango', 'apple', 'orange', 'cherry', 'grapes'] 
print(list(filter(lambda fruit: 'g' in fruit, fruits)))

filter(function or None, iterable) --> filter object

返回一个迭代器,为那些函数或项为真的可迭代项。如果函数为None,则返回为真的项。

Reduce函数

这个函数比较特别,不是 Python 的内置函数,需要通过from functools import reduce 导入。Reduce 从序列数据结构返回单个输出值,它通过应用一个给定的函数来减少元素。

reduce(function, sequence[, initial]) -> value

将包含两个参数的函数(function)累计应用于序列(sequence)的项,从左到右,从而将序列reduce至单个值。

如果存在initial,则将其放在项目之前的序列,并作为默认值时序列是空的。

假设有一个整数列表,并求得所有元素的总和。且使用reduce函数而不是使用for循环来处理此问题。

from functools import reduce
lst = [2,4,6,8,10]
print(reduce(lambda x, y: x+y, lst))
>>> 30

还可以使用 reduce 函数而不是for循环从列表中找到最大或最小的元素。

lst = [2,4,6,8]
# 找到最大元素
print(reduce(lambda x, y: x if x>y else y, lst))
# 找到最小元素
print(reduce(lambda x, y: x if x<y else y, lst))

高阶函数的替代方法

列表推导式

其实列表推导式只是一个for循环,用于添加新列表中的每一项,以从现有索引或一组元素创建一个新列表。之前使用map、filter和reduce完成的工作也可以使用列表推导式完成。然而,相比于使用Map和filter函数,很多人更喜欢使用列表推导式,也许是因为它更容易应用和记忆。

同样使用列表推导式将数组中每个元素进行平方运算,水果的例子也可以使用列表推导式来解决。

arr = [2,4,6,8]
arr = [i**2 for i in arr]
print(arr)
fruit_result = [fruit for fruit in fruits if 'g' in fruit]
print(fruit_result)

字典推导式

与列表推导式一样,使用字典推导式从现有的字典创建一个新字典。还可以从列表创建字典。

假设有一个整数列表,需要创建一个字典,其中键是列表中的每个元素,值是列表中的每个元素的平方。

lst = [2,4,6,8]
D1 = {item:item**2 for item in lst}
print(D1)
>>> {2: 4, 4: 16, 6: 36, 8: 64}
# 创建一个只包含奇数元素的字典
arr = [1,2,3,4,5,6,7,8]
D2 = {item: item**2 for item in arr if item %2 != 0}
print(D2)
>>> {1: 1, 3: 9, 5: 25, 7: 49}

一个简单应用

如何快速找到多个字典的公共键

方法一

dl = [d1, d2, d3] # d1, d2, d3为字典,目标找到所有字典的公共键
[k for k in dl[0] if all(map(lambda d: k in d, dl[1:]))]

dl = [{1:'life', 2: 'is'}, 
{1:'short', 3: 'i'},
{1: 'use', 4: 'python'}]
[k for k in dl[0] if all(map(lambda d: k in d, dl[1:]))]
# 1

解析

# 列表表达式遍历dl中第一个字典中的键
[k for k in dl[0]]
# [1, 2]

# lambda 匿名函数判断字典中的键,即k值是否在其余字典中
list(map(lambda d: 1 in d, dl[1:]))
# [True, True]
list(map(lambda d: 2 in d, dl[1:]))
#[False, False]

# 列表表达式条件为上述结果([True, True])全为True,则输出对应的k值
#1

方法二

# 利用集合(set)的交集操作
from functools import reduce
# reduce(lambda a, b: a*b, range(1,11)) # 10!
reduce(lambda a, b: a & b, map(dict.keys, dl))

写在最后
目前已经学习了Lambda函数是什么,以及Lambda函数的一些使用方法。随后又一起学习了Python中的高阶函数,以及如何在高阶函数中使用lambda函数。除此之外,还学习了高阶函数的替代方法:在列表推导式和字典推导式中执行之前操作。虽然这些方法看似简单,或者说你之前已经见到过这类方法,但你很可能很少使用它们。你可以尝试在其他更加复杂的函数中使用它们,以便使代码更加简洁。

作者:编程学习网
来源:https://juejin.cn/post/7084062324981497870

收起阅读 »

Flutter bottomSheet 高度自适应及溢出处理

最近在创建 bottomSheet的时候遇到一个问题:弹窗的高度无法根据其内容自适应 先放上显示弹窗的代码,如下: Future<T?> showSheet<T>( BuildContext context, Widg...
继续阅读 »

最近在创建 bottomSheet的时候遇到一个问题:弹窗的高度无法根据其内容自适应



先放上显示弹窗的代码,如下:


Future<T?> showSheet<T>(
BuildContext context,
Widget body, {
bool scrollControlled = false,
Color bodyColor = Colors.white,
EdgeInsets? bodyPadding,
BorderRadius? borderRadius,
}) {
const radius = Radius.circular(16);
borderRadius ??= const BorderRadius.only(topLeft: radius, topRight: radius);
bodyPadding ??= const EdgeInsets.all(20);
return showModalBottomSheet(
context: context,
elevation: 0,
backgroundColor: bodyColor,
shape: RoundedRectangleBorder(borderRadius: borderRadius),
barrierColor: Colors.black.withOpacity(0.25),
// A处
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height - MediaQuery.of(context).viewPadding.top),
isScrollControlled: scrollControlled,
builder: (ctx) => Padding(
padding: EdgeInsets.only(
left: bodyPadding!.left,
top: bodyPadding.top,
right: bodyPadding.right,
// B处
bottom: bodyPadding.bottom + MediaQuery.of(ctx).viewPadding.bottom,
),
child: body,
));
}


其中,A处、B处的作用就是,让弹窗的内容始终显示在安全区域内



高度自适应问题


首先,我们在弹窗中显示点内容:


showSheet(context, Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: const [
Text('这是第一行'),
],
));

效果如下图所示:




此时,我们只需要将显示内容的代码改为如下:


showSheet(context, Column(
mainAxisSize: MainAxisSize.min, // 这一行是关键所在
crossAxisAlignment: CrossAxisAlignment.stretch,
children: const [
Text('这是第一行'),
],
));

现在的效果图如下:




现在我们可以看到,弹窗的高度已经根据内容自适应了。


内容溢出问题


前面的解决方式,仅在内容高度小于默认高度时有效。当内容过多,高度大于默认高度时,就会出现溢出警告,如下图所示:




此时,我们该怎么办呢?


答案是:运用 showModalBottomSheet 的 isScrollControlled 参数,将其设置为true即可,代码如下:


showSheet(context, Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: const [
Text('这是第一行'),
Text('这是很长长..(此处省略若干字)..的一段话,')
],
), scrollControlled: true); // 这一行用于告诉系统,弹窗的内容完全由我们自己管理

此时,效果图如下:



showSheet 补充说明


对前面showSheet代码中,A处、B处的进一步说明:


A处:如果不对内容的高度进行限制,则内容会显示在状态栏之后,而引起用户交互问题。如下图所示:



B处:如果不加 MediaQuery.of(ctx).viewPadding.bottom 这一句,则内容有可能会显示在底部横条的下方,此时也不利于交互


最终版本图样


内容较少(高度跟随内容自适应):



内容很多(顶部、底部均显示在安全区域内):



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

2022了,来体验下 flutter web

前言 flutter从 17年 推出,18年12月 开始发布 1.0 版本,2021年3月 发布 2.0 增加了对桌面和 web 应用的支持。 最大特点是基于skia实现自绘引擎,使用dart语言开发,既支持JIT(just in time: 即时编译)又支持...
继续阅读 »

前言


flutter从 17年 推出,18年12月 开始发布 1.0 版本,2021年3月 发布 2.0 增加了对桌面和 web 应用的支持。
最大特点是基于skia实现自绘引擎,使用dart语言开发,既支持JIT(just in time: 即时编译)又支持AOT(ahead of time: 提前编译),开发阶段使用JIT模式提高时效性,同时在发布阶段使用AOT模式提高编译性能。
作为前端的话,还是更关注flutter web的支持情况。为了体验flutter web,特意用flutter写了个小游戏来看编译后的代码在web上运行如何。


开始之前


早在3年前的 19年初 1.0 出来没多久的时候就尝试用flutter来写一些常见的菜单注册登录等页面demo来,那时候flutter的生态还在发展中,除了官方提供的一些解决方案,三方的一些包很多都不成体系,应用范围较小,由于当时是抱着前端的固有思路来尝鲜flutter,flutter 刚发展起来,轮子远没有那么多,发现写起来远没有Vue、React 这类生态成熟的框架写起来舒服,除了 widget 组件多,写起UI来可以直接看文档写完很方便外,网络请求,路由管理、状态管理(这些像vue有axios/vue-router/vuex)用官方的方法写起来相当麻烦(也可能是我不会用,对新手不友好),维护起来就更麻烦了。


过去3年了,再看flutter,2.0版本发布也快一年了,当再次想用flutter写个demo的时候,发现了社区已经出现了一些经过几年发展的provider、getx之类的状态管理框架,能帮助新手快速入门,用了 getx 感觉是个脚手架,又不仅仅是脚手架,简直是大而全的轮子,状态管理、路由管理一应俱全,生成的目录结构清晰,你只需要去填充 UI 和处理数据。用法也很简单,对新手很友好。


flutter + getx 写一个小游戏


既然选好了那就用 getx 生成项目目录,开始开发,选用了一个很常见的小游戏:数字华容道,功能也简单。 项目地址


项目可以打包成原生应用,也可以打包成 web 应用


数字华容道web版


flutter web 渲染模式


不同的渲染器在不同场景下各有优势,因此 Flutter 同时支持以下两种渲染模式:


HTML 渲染器: 结合了 HTML 元素、CSS、Canvas 和 SVG。该渲染模式的下载文件体积较小。
CanvasKit 渲染器: 渲染效果与 Flutter 移动和桌面端完全一致,性能更好,widget 密度更高,但增加了约 2MB 的下载文件体积。
为了针对每个设备的特性优化您的 Flutter web 应用,渲染模式默认设置为自动。这意味着您的应用将在移动浏览器上使用 HTML 渲染器运行,在桌面浏览器上使用 CanvasKit 渲染器运行。官方文档




使用 HTML 渲染


flutter run -d chrome --web-renderer html
复制代码

使用 HTML,CSS,Canvas 和 SVG 元素来渲染,应用的大小相对较小,元素数量多,请求都是http2


元素如下



请求如下



使用 CanvasKit 渲染


CanvasKit 是以 WASM 为编译目标的Web平台图形绘制接口,其目标是将 Skia 的图形 API 导出到 Web 平台。


flutter run -d chrome --web-renderer canvaskit
复制代码

默认 CanvasKit 渲染,元素数量比html少很多,就是需要请求 canvaskit.wasm,该文件大小7MB左右、默认在 unpkg.com 国内加载速度慢,可以将文件放到国内 cdn 以提升请求效率



元素如下



请求如下,部分还使用了http3



小结


flutter web 通过编译成浏览器可运行的代码,经实践来看,性能还是有些问题,不过如果是单单想要写SPA,那恐怕还是js首选。目前来说flutter的生态经过几年的发展已经有了很多开源轮子,但要说稳定性还无法击败js,要不要用 flutter web 就要根据实际需求来决定了。


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

Flutter 项目复盘 纯纯的实战经验

一. 项目开始 1. 新建flutter项目时首先明确native端用的语言 java还是kotlin , objectC 还是swift ,否则选错了后期换挺麻烦的 2. 选择自己的路由管理和状态管理包,决定项目架构 以我而言 第一个项目用的 fluro 和...
继续阅读 »

一. 项目开始


1. 新建flutter项目时首先明确native端用的语言 java还是kotlin , objectC 还是swift ,否则选错了后期换挺麻烦的


2. 选择自己的路由管理和状态管理包,决定项目架构
以我而言 第一个项目用的 fluro 和 provider 一个路由管理一个状态管理,项目目录新建store和route文件夹,分别存放provider的model文件和fluro的配置文件,到了第二个项目,发现了Getx,一个集合了依赖注入,路由管理,状态管理的包,用起来! 项目目录结构有了很大的变化,整体条理整洁


第一个 image.png


第二个 image.png


3. 常用包配置,比如 Getx 需要把外层MaterialApp换成GetMaterialApp, flutter_screenutil 需要初始化设计图比例,provider全局导入,Dio 封装,拦截器,网络提示等等


二. 全局配置


1. 复用样式


1. 由于flutter 某些小widget复用性很高,而App 需要统一样式 ,样式颜色之类的预设文件放在command文件夹内


colours.dart ,可以预设静态class,存储常用主题色
image.png


styles.dart,可以预设 字体样式 分割线样式 各种固定值间隔


image.png


2. 建议全局管理后端接口,整洁还便于维护,舒服


3. models 文件夹


models 文件夹,可能在web端并不常用,但是在dart里我觉得很需要,后端返回的Json 字符串,一定要通过model类 格式化为一个类,可以极大地减少拼写错误或者类型错误, . 语法也比 [''] 用起来舒服的多推荐一个网站 quickType 输入json对象,一键输出model类!


4. 是否强制横竖屏?


需要在main.dart里配置好


// 强制横屏
SystemChrome.setPreferredOrientations(
[DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight]);

5. 是否需要修改顶部 底部状态栏布局以及样式?


SystemUiOverlayStyleSystemChrome.setSystemUIOverlayStyle(systemUiOverlayStyle); 来配置


6. 设置字体不跟随系统


参考地址


class MyApp extends StatefulWidget {
@override
_MyAppState createState() => _MyAppState();
}

class _MyAppState extends State with WidgetsBindingObserver {
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
home: Container(color: Colors.white),
builder: (context, widget) {
return MediaQuery(
//设置文字大小不随系统设置改变
data: MediaQuery.of(context).copyWith(textScaleFactor: 1.0),
child: widget,
);
},
);
}
}

7. 国际化配置


使用部分widget会显示英文,比如IOS风格的dialog,显示中文这需要设置一下了,
首先需要一个包支持,


  flutter_localizations:
sdk: flutter

引入包,然后在main.dart MetrialApp 的配置项中加入


   // 设置本地化,部分原生内容显示中文,例如长按选中文本显示复制、剪切
localizationsDelegates: [
GlobalMaterialLocalizations.delegate, //国际化
GlobalWidgetsLocalizations.delegate,//国际化
const FallbackCupertinoLocalisationsDelegate() // 这里是为了解决一个报错 看第 8 条
],
//国际化
supportedLocales: [
const Locale('zh', 'CH'),
const Locale('en', 'US'),
],

8. 使用CupertinoAlertDialog报错:The getter 'alertDialogLabel' was called on null


解决方法:
在main.dart中 加入如下类,然后在MetrialApp 的 localizationsDelegates 中实例化 见第 7 条


class FallbackCupertinoLocalisationsDelegate
extends LocalizationsDelegate
{
const FallbackCupertinoLocalisationsDelegate();

@override
bool isSupported(Locale locale)
=> true;

@override
Future
load(Locale locale) =>
DefaultCupertinoLocalizations.load(locale);

@override
bool shouldReload(FallbackCupertinoLocalisationsDelegate old)
=> false;
}

9. ImageCache


最近版本的flutter更新,限制了catchedImage的上限, 100张 1000mb ,而业务需求却需要缓存更多,设置一下了这需要


    class ChangeCatchImage extends WidgetsFlutterBinding {
@override
createImageCache() *{*
Global.myImageCatche = ImageCache()
..maximumSize = 1000
..maximumSizeBytes = 1000 << 20; // 1000MB
return Global.myImageCatche;
}
}

然后在main.dart runApp那里实例化一下ChangeCatchImage() 就可以了


三. 业务模块


常见的业务模块代码分析,比如登录页,闪屏页,首页,退出登录等

1. 首先安利一下Getx


一个文件夹就是一个业务模块,独自管理数据,通过依赖注入数据共享,
舒服


image.png


包括 logic 逻辑控制层 state 数据管理层 view 视图组件层 ,当前业务的复用widget写在文件夹下


2. 登录模块


作为app的入口门户,炫酷美观是少不了的,这就需要关注性能优化,而输入的地方,验证的逻辑要有安全设计



  • 首先关于动画性能优化,最关键的一点是精准的更新需要变化的组件,我们可以通过devtool的工具查看更新范围


image.png



  • 其次时安全设计,简单的来看,限制登录次数,禁止简易密码,加密传输,验证token等,进阶版的比如,防止参数注入,过滤敏感字符等

  • 登录之前的账户验证,密码验证,必填项等,然后登录请求,需要加loading,按钮禁用,就不需要防抖了

  • 登录之后保存到本地用户基本信息(可能存在安全问题,暂未深究),然后下次登陆默认检测是否存在基本信息,并验证过期时间,和token,之后隐式登录到首页


3. splash闪屏模块


app登陆首页的准备页面,可以嵌入广告,或者定制软件宣传动画,提示三秒后跳过
如何优雅的加入app闪屏页?
其实就是在main.dart里把初始化页面设置为splash页面,之后通过跳转逻辑
判断去首页还是登录注册页面
比如这里我用了Getx 就简单配置一下


image.png


4. 操作引导模块


第一次使用app,或者重大更新之后往往会有操作引导
我的项目里用到了两种类型的操作引导


成果图
第一种


1.jpg


第二种


image.png
image.png


二者都是基于overlayEntry()Overlay.of(context).insert(overlayEntry)实现的
第二种用了一个包 操作引导 flutter_intro: ^2.2.1,绑定Widget的GlobalKey,来获取Element信息,拿到位置大小,确保框选的位置正确,外层遮罩与第一种一样都是用overlayEntry()创建的


k.png


创建之后,展示出来
Overlay.of(context).insert(your_overlayEntry)
在某个按钮处切换下一个 比如点击我知道了,下一页之类的


     onPressed: () {
// 执行 remove 方法销毁 第一个overlayEntry 实例
overlayEntryTap.remove();
// 第二个
Overlay.of(context).insert(overlayEntryScale);
},

关于第二个实现涉及的flutter_intro包,粘一下我的代码,详细的可以参照pub食用


final intro = Intro(
// 一共有几步,这里就会创建2个GlobalKey,一会用到
stepCount: 2,
// 点击遮罩下一个
maskClosable: true,
// 高亮区域与 widget 的内边距
padding: EdgeInsets.all(0),
// 高亮区域的圆角半径
borderRadius: BorderRadius.all(Radius.circular(4)),
// use defaultTheme
widgetBuilder: StepWidgetBuilder.useDefaultTheme(
texts: ["点击添加收藏", "下拉添加书签"],
buttonTextBuilder: (currPage, totalPage) {
return currPage < totalPage - 1
? '我知道了 ${currPage + 3}/${totalPage + 2}'
: '完成 ${currPage + 3}/${totalPage + 2}';
},
),
);

......
// 这里用到key来绑定任意Widget
Positioned(
key: intro.keys[1],
top: 0,
right: 20,
...
)
......

5. CustomPaint 绘图画板模块


成果图


image.png


当初选择flutter就是因为,有大量的绘制需求,看中了自带skia,绘制效率高且流畅而且具备平台一致性
结果坑也不少


首先来讲一下 猪脚 CustomPaint


顾名思义,这是一个个性化绘制组件,他的工作就是给你创建一个画布,你想怎么画怎么画,我们直接看怎么用
首先格式化写法



  • 首先 需要写在widget 树里吧
    Container( 
    child: CustomPaint(
    painter: myPainter(),
    ),

    看一下参数列表,发现painter 接收一个CustomePainter对象,这里可以注意一下child参数,很奇怪明明绘制界面都放在painter里了,留一个child干啥用??? 其实有大用,这里面放是他的子widget,但是不参与绘制更新的,通俗一点就是我绘制一片流动的云彩,但是有个静止的太阳,云彩的位置是实时repaint的,这时就可以把太阳widget放在child中,优化性能


image.png



  • 接下来我们创建myPainter()


    class myPainter extends CustomPainter { 
@override
void paint(Canvas canvas, Size size) {
// 创建画笔
final Paint paint = Paint();
// 绘制一个圆
canvas.drawCircle(Offset(50, 50), 5, paint);
}
@override
bool shouldRepaint(CustomPainter oldDelegate) => false;



  • 到这里 我们需要实现两个重要的函数(如上代码)


第一个paint()函数,自带了画布对象 canvas,和画布尺寸 size,这样我们就可以使用Canvas的内置绘制函数了!
而绘制函数,都需要接收一个Paint 画笔对象


image.png


这个画笔对象,就是用来设置画笔颜色,粗细,样式,接头样式等等


Paint paint = Paint(); 
//设置画笔
paint ..style = PaintingStyle.stroke
..color = Colors.red
..strokeWidth = 10;


第二个函数shouldRepaint() 顾名思义判断是否需要重绘,如果返回false就是不需要重绘,只执行一次paine(),返回true就是总是重绘,依据实际需求设置
如果需要绘制类似于 根据数值不断变高的柱状图动画


代码如下(搬走就能用哦)


class BarChartPainter extends CustomPainter {
final List datas;
final List datasrc;
final List xAxis;
final double max;
final Animation animation;

BarChartPainter(
{@required this.xAxis,
@required this.datas,
this.max,
this.datasrc,
this.animation})
: super(repaint: animation);

@override
void paint(Canvas canvas, Size size) {
_darwBars(canvas, size);
_drawAxis(canvas, size);
}

@override
bool shouldRepaint(BarChartPainter oldDelegate) => true;

// 绘制坐标轴
void _drawAxis(Canvas canvas, Size size) {
final double sw = size.width;
final double sh = size.height;

// 使用 Paint 定义路径的样式
final Paint paint = Paint()
..color = Colors.grey
..style = PaintingStyle.stroke
..strokeWidth = 1
..strokeCap = StrokeCap.round;

// 使用 Path 定义绘制的路径,从画布的左上角到左下角在到右下角
final Path path = Path()
..moveTo(40, sh)
..lineTo(sw - 20, sh);

// 使用 drawPath 方法绘制路径
canvas.drawPath(path, paint);
}

// 绘制柱形
void _darwBars(Canvas canvas, Size size) {
final sh = size.height;
final paint = Paint()..style = PaintingStyle.fill;
final double _barWidth = size.width / 20;
final double _barGap = size.width / 25 * 2 + 18;
final double textFontSize = 14.0;

for (int i = 0; i < datas.length; i++) {
final double data = datas[i] * ((size.height - 15) / max);
final top = sh - data;
// 矩形的左边缘为当前索引值乘以矩形宽度加上矩形之间的间距
final double left = i * _barWidth + (i * _barGap) + _barGap;
// 使用 Rect.fromLTWH 方法创建要绘制的矩形
final rect = RRect.fromLTRBAndCorners(
left, top, left + _barWidth, top + data,
topLeft: Radius.circular(5), topRight: Radius.circular(3));
// 使用 drawRect 方法绘制矩形

final offset = Offset(
left + _barWidth / 2 - textFontSize / 2 - 8,
top - textFontSize - 5,
);
paint.color = Color(0xFF59C8FD);

//绘制bar
canvas.drawRRect(rect, paint);

// 使用 TextPainter 绘制矩形上放的数值
TextPainter(
text: TextSpan(
text: datas[i] == 0.0 ? '' : datas[i].toStringAsFixed(0) + " %",
style: TextStyle(
fontSize: textFontSize,
color: paint.color,
// color: Colours.gray_33,
),
),
textAlign: TextAlign.center,
textDirection: TextDirection.ltr,
)
..layout(
minWidth: 0,
maxWidth: textFontSize * data.toString().length,
)
..paint(canvas, offset);

final xData = xAxis[i];
final xOffset = Offset(left, sh + 6);
// 绘制横轴标识
TextPainter(
textAlign: TextAlign.center,
text: TextSpan(
text: '$xData' != ''
? '$xData'.substring(0, 4) + '-' + '$xData'.substring(4, 6)
: '',
style: TextStyle(
fontSize: 12,
color: Colors.black,
),
),
textDirection: TextDirection.ltr,
)
..layout(
minWidth: 0,
maxWidth: size.width,
)
..paint(canvas, xOffset);
}
}
}

好了,customPainter,大体就这么用,下面回归话题,绘制画板
其实整体任务相当复杂,这里刨析一处,其他的融会贯通


拿最经典的铅笔画图来说
其实单纯的实现铅笔画图,甚至带笔锋,类似于签名,都很简单,网上教程一堆


大体思路就是 加一个GestureDetector ,主要用 onPanUpdate事件实时触发绘制动作,用canvas绘制出来
绘制简单,但是性能优化复杂


这里直接给出我测试的最优解
先把新的坐标点与之前的点连成线,可以一次多连接几个,也就是类似于节流的处理手法,
比如等panUpate触发了五次回调,先都把这五个点连接成线,第六次再统一绘制一条线(要是还有啥好办法,希望不吝赐教!)
详细的以后单独整理出来一个项目


6. websocket 即时通讯模块


成果图


Inked2_LI.jpg
只做了最基本的文字 图片 文件功能


简单把各项功能实现说一下,以后会详细整理,并加入音视频



  • 关于websocket


    首先肯定是连接websocket,用到一个包 web_socket_channel


    然后初始化websocket


    // 初始化websocket
    initWebsocket(){
    Global.channel = IOWebSocketChannel.connect(
    WebsocketUrl, // websocket地址
    //这个参数注意一下, 这里是每隔10000毫秒发送ping,如果间隔10000ms没收到pong,就默认断开连接
    //所以收网速等影响,这个参数如果太小,比如100ms就会,出现过一阵子自己断开连接的问题,参考实际设置
    pingInterval: Duration(milliseconds: 10000),
    );
    // 监听服务端消息
    Global.channel.stream.listen(
    (mes) => onMessage(mes),// 处理消息
    onError: (error) => {onError(error)}, // 连接错误
    onDone: () => {onDone()}, // 断开连接
    cancelOnError: true //设置错误时取消订阅
    );
    }



  • 处理消息


    进入页面加载聊天消息,长列表还是得用ListView.build(),消息多的时候体验好很多


    每次监听到新消息,加入到数组中,并更新视图,这一步不同的状态管理方法不同.


    加入消息这里就有难点了


    首先分四种情况 a. 自己发的并且在ListView底部,b. 自己发的但是不在ListView底部, c. 别人发的消息并且在底部,d. 别人发的不在底部.


    a 和 b,c: 只要是自己发得就滚动到底部,在底部时就滚动的慢点,有种消息上拉的感觉


    // 这里要确保在LIstView中已经加入并渲染完成新消息
    // 我的处理就是加了一个延迟,再滚动
    // 直接滚动到ListView底部
    scrollController.jumpTo(scrollController.position.maxScrollExtent);
    // 滚动到某个确定的元素
    Scrollable.ensureVisible(
    // 给每一条消息对象加GlobalKey,获取到当前上下文
    state.messageList[index].key.currentContext,
    duration: Duration(milliseconds: 100),
    curve: Curves.easeInOut,
    // 控制对齐方式
    alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtEnd);

    d : 这种情况,做了个类似于微信的提示




image.png


但是点击定位到消息有坑了,因为用的listView.build,当你在翻阅上边的消息,下面的消息并没有加载,因此获取不到currentContext,因为元素并没有渲染,也就定位错乱了,目前最理想的解决办法就是,往上翻的时候,之下的记录全部渲染,往下滑时再依次清.




  • 文件和图片


    用到了几个包 file_picker, open_file, path_provider


    file_picker ,用来选择文件和图片,可以配置单选多选,需要在安卓的配置文件里加权限


    open_file , 类似于微信点击文件,先下载,然后调用本地默认程序打开文件


    path_provider,提供系统可用路径,用于创建文件目录


    具体使用如下


     // 访问不到app私有目录 导致我卡了很久...
    // Directory dirloc = await getTemporaryDirectory();
    // 访问外置存储目录
    final dirPath = await getExternalStorageDirectory();
    Directory file = Directory(dirPath.path + "/" + "temFile");
    // 不存在就创建目录
    try {
    bool exists = await file.exists();
    if (!exists) {
    await file.create(); // 创建了temFile 目录 用于缓存文件
    }
    } catch (e) {
    print(e);
    }
    // 下边就很关键了 可能不同的后端数据不同实现
    // 请求存储权限 需要一个包 permission_handler: ^6.1.1
    Permission.storage.request().then((value) async {
    //如果许可
    if (value.isGranted) {
    // 判断文件是否存在 wjmc 就是一个变量存储着文件名
    File _tempFile = File(file.path + '/' + wjmc);
    if (!await _tempFile.exists()) {
    try {
    //1、创建文件地址 带扩展 我用了getx cstate
    // final ChatState cState = Get.find().state;
    // 这是一个通用组件 不管理数据 从chatState里注入
    cState.path = file.path + '/' + wjmc;
    //2、下载文件到本地
    cState.downloading.value = nbbh;
    var response = await dio.get(fileUrl);
    Stream resp = response.data.stream;
    //4. 转为uint8类型
    final Uint8List bytes =
    await consolidateHttpClientResponseBytes(resp);
    //5. 转为List 并写入文件
    final List _filelist = List.from(bytes);
    final filePath = File(cState.path);
    await filePath.writeAsBytes(_filelist,
    mode: FileMode.append, flush: true);
    } catch (e) {
    print(e);
    }
    }
    cState.downloading.value = '';
    // 6.这里可以记录位置,保存path到一个数组里,退出软件之后清除缓存 我没做
    open(cState.path);
    }
    });
    // 读取Stream 文件流 处理为Uint8List
    Future consolidateHttpClientResponseBytes(Stream response) {
    final Completer completer = Completer.sync();
    final List> chunks = >[];
    int contentLength = 0;
    response.listen((chunk) {
    chunks.add(chunk);
    contentLength += chunk.length;
    }, onDone: () {
    final Uint8List bytes = Uint8List(contentLength);
    int offset = 0;
    for (List chunk in chunks) {
    bytes.setRange(offset, offset + chunk.length, chunk);
    offset += chunk.length;
    }
    completer.complete(bytes);
    }, onError: completer.completeError, cancelOnError: true);

    return completer.future;
    }
    void open(path) {
    // 下载完成 准备打开文件
    showCupertinoDialog(
    context: Get.context,// 舒服
    builder: (context) {
    return Material(
    color: Colors.transparent,
    child: CupertinoAlertDialog(
    title: Padding(
    padding: EdgeInsets.only(bottom: 10),
    child: Text("提示"),
    ),
    content: Padding(
    padding: EdgeInsets.only(left: 5),
    child: Text("是否打开文件?"),
    ),
    actions: [
    CupertinoButton(
    child: Text(
    "取消",
    style: TextStyle(color: Colours.gray_88),
    ),
    onPressed: () {
    Get.back();
    },
    ),
    CupertinoButton(
    child: Text("确定"),
    onPressed: () async {
    Get.back();
    // 直接调用就能打开,会通过系统默认程序打开 比如.doc 默认用office等.
    await OpenFile.open(
    cState.path,
    );
    }),
    ]),
    );
    },
    );
    }

    音视频用的小鱼易连,但是木有Flutter SDK ,只能基于安卓的去封装,以后有机会再讲讲.


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

系统模块划分设计的思考

系统模块划分设计的思考前言首先明确一下,这里所说的系统模块划分,是针对client,service,common这样的技术划分,而不是针对具体业务的模块划分。避免由于歧义,造成你的时间浪费。直接原因公司内部某技术团队,在引用我们系统的client包时,启动失败...
继续阅读 »

系统模块划分设计的思考

前言

首先明确一下,这里所说的系统模块划分,是针对client,service,common这样的技术划分,而不是针对具体业务的模块划分。避免由于歧义,造成你的时间浪费。

直接原因

公司内部某技术团队,在引用我们系统的client包时,启动失败。
失败原因是由于client下有一个cache相关的依赖,其注入失败导致的。

然后,就发出了这样一个疑问:我只是希望使用一个hsf接口,为什么还要引入诸如缓存,web处理工具等不相关的东西。

这也就自然地引出了前辈对我的一句教导:对外的client需要尽可能地轻便。

很明显,我们原有的client太重了,包含了对外的RPC接口,相关模型(如xxxDTO),工具包等等。

可能有人就要提出,直接RPC+模型一个包,其它内容一个包不就OK了嘛?

问题真的就这么简单嘛?

根本原因

其实出现上述问题,是因为在系统设计之初,并没有深入思考client包的定位,以及日后可能遇到的情况。

这也就导致了今天这样的局面,所幸目前的外部引用并不多,更多是内部引用。及时调整,推广新的依赖,与相关规范为时不晚。

常见模块拆分

先说说我以前的模块拆分。最早的拆分是每个业务模块主要拆分为:

  • xxx-service:具体业务实现模块。

  • xxx-client:对外提供的RPC接口模块。

  • xxx-common:对外的工具,以及模型。

这种拆分方式,是我早期从一份微服务教程中看到的。优点是简单明了,调用方可选择性地选择需要的模块引入。

至于一些通用的组件,如统一返回格式(如ServerResponse,RtObject),则放在了最早的核心(功能核心,但内容很少)模块上。

后来,认为这样并不合适,不应该将通用组件放在一个业务模块上。所以建立了一个base模块,用来将通用的组件,如工具,统一返回格式等都放入其中。

另外,将每个服务都有的xxx-common模块给取消了。将其中的模型,放入了xxx-client,毕竟是外部调用需要的。将其中的工具,根据需要进行拆分:

  • base:多个服务都会使用的。

  • xxx-service:只有这个服务本身使用

  • xxx-client:有限的服务使用,并且往往是服务提供方和服务调用方都要使用。但是往往这种情况,大多是由于接口设计存在问题导致的。所以多为过渡方案。

上述这个方案,也就是我在负责某物联网项目时采用的最终模块划分方式。

在当时的业务下,该方案的优点是模块清晰,较为简洁,并且尽可能满足了迪米特原则(可以参考《阿里Java开发手册相关实践》)。缺点则是需要一定的技术水平,对组件的功能域认识清晰。并且需要有一定的设计思考与能力(如上述工具拆分的第三点-xxx-client,明白为什么这是设计缺陷导致,并能够解决)。

新的问题

那么,既然上述的方案挺不错的,为什么不复用到现在的项目呢?

因为业务变了,导致应用场景变了。而这也带来了新的问题,新的考虑角度。

原先的物联网业务规模并不大,所以依赖也较为简单,也并不需要进行依赖的封装等,所以针对主要是client的内/外这一维度考虑的。

但是现有的业务场景,由于规模较大,模块依赖层级较多,导致上层模块引入过多的依赖。如有一个缓存模块,依赖tair-starter(一个封装的key-value的存储),然后日志模块依赖该缓存模块(进行性能优化),紧接着日志模块作为一个通用模块,被放入了common模块中。依赖链路如下:

调用方 -> common模块 -> 日志模块 -> 缓存模块 -> tair-starter依赖

但是有的调用方表示,根本就不需要日志模块,却引入了tair-starter这一重依赖(starter作为封装,都比较重),甚至由于tair-starter的内部依赖与自身原有依赖冲突,得去排查依赖,进行exclude。
但是同时,也有的调用方,系统通过rich客户端,达到性能优化等目标。

所以,现有的业务场景除了需要考虑client的内/外这一维度,还需要考虑client的pool/rich这一维度。

可能有的小伙伴,看到这里有点晕乎乎的,这两个维度考量的核心在哪里?

内/外,考虑的是按照内外这条线,尽量将client设计得简洁,避免给调用方引入无用依赖。

而pool/rich,考虑的是性能,用户的使用成本(是否开箱即用)等。

最终解决方案

最终的解决方案是对外提供3+n

  • xxx-client(1个):所有外部系统引用都需要的内容,如统一返回格式等。

  • xxx-yyy-client(n个):对具体业务依赖的引用,进行了二次拆分。如xxx-order-client(这里是用订单提花那你一下,大家理解意思就OK)。

  • xxx-pool-client(1个):系统引用所需要的基本依赖,如Lindorm的依赖等。

  • xxx-rich-client(1个):系统引用所需要的依赖与对应starter,如一些自定义的自动装载starter(不需要用户进行配置)。

这个方案,换个思路,理解也简单。
我们提供相关的能力,具体如何选择,交给调用方决定。

其实,讨论中还提到了BOM方案(通过DependentManagement进行jar包版本管理)。不过分析后,我们认为BOM方案更适合那些依赖集比较稳定的client,如一些中间件。而我们目前的业务系统,还在快速发展,所以并不适用。

总结

简单来说,直接从用户需求考虑(这里的用户就是调用方):

  • 外部依赖:

    • 额外引入的依赖尽可能地少,最好只引入二方依赖(我们提供的jar),不引入第三方依赖。

    • 引入的二方依赖不“夹带”私货(如二方jar引入了一堆大第三方依赖)。

  • 自动配置:

    • 可以傻瓜式使用。如引入对应的starter依赖,就可以自动装配对应默认配置。

    • 也可以自定义配置。用户可以在自定义配置,并不用引入无效的配置(因为starter经常引入不需要的依赖)。

  • 性能:

    • 可以通过starter,提供一定的封装,保证一定的性能(如接口缓存,请求合并等)。

    • 可以自定义实现基础功能。因为有些人并不放心功能封装(虽然只是少数,但是稳定性前辈提出的)。

补充

这里补充一点,我对讨论中一个问题的回答,这里提一下。

有人提到工具类,应该如何划分。因为有的工具类,是不依赖于服务状态的,如CookieUtil进行Cookie处理。有的工具类,是依赖于服务状态的,如RedisUtil包含RedisPool状态,直连Redis,处理Redis请求与响应。

其实这里有两个细节:

  • 工具应该按照上面的方式进行划分为两种。单一模块系统,不依赖服务状态的工具往往置于util包,依赖服务状态的工具往往置于common包中。这里还有一个明显的区分点:前者的方法可以设为static,而后者并不能(因为依赖于new出来的状态)。

  • 依赖于状态的工具类,其实是一种拆分不完全的体现。如RedisUtil,可以拆分为连接状态管理的RedisPool与请求响应处理的RedisUitl。两者的组合方式有很多,取决于使用者的需要,以后有机会写一篇相关的博客。不过,我希望大家记住 面向接口编程的原则。

愿与诸君共进步。

原文链接:https://blog.csdn.net/cureking/article/details/105663951

收起阅读 »

从零到阿里的三年

一、背景三年的时间,可以做些什么呢?又或者说,可以做成什么呢?每个人都有各自的机遇、背负、努力。所以这永远没有一个标准答案,有的只是每个人自己的答案。而我能做的,就说出自己的故事,给大家一份参考。如果可以帮助到大家,那就是极好的了。关键词:真实、履历、思考、效...
继续阅读 »

一、背景

三年的时间,可以做些什么呢?又或者说,可以做成什么呢?
每个人都有各自的机遇、背负、努力。所以这永远没有一个标准答案,有的只是每个人自己的答案。
而我能做的,就说出自己的故事,给大家一份参考。如果可以帮助到大家,那就是极好的了。

关键词:真实、履历、思考、效率、执行、不足

履历

先说一下履历吧。毕竟没有履历,可能被人认为是贩卖焦虑的软文。

  • 18.5~18.12:杭州某在线教育公司

    • 岗位:产品+项目+管理+前端+运营
    • 职责:属于啥都干,哪里需要,填哪里。技术方面只做过H5课件,未涉及后端。
    • 薪资:6k x 12
    • 成长:职场
    • 学习:书、证书考试驱动的学习(培训)、慕课网与网易云课堂的各小课程、各类技术白皮书&实时讯息(区块链项目需要)。
    • 证书:计算机技术与软件专业技术资格考试(即”软考“)-系统架构设计师(高级)
  • 19.2~19.12:杭州某物联网公司

    • 岗位:架构师&技术负责
    • 职责:带领团队,0-1搭建工业物联网。真正后端技术的起点。
    • 薪资:11k x 12
    • 成长:技术
    • 学习:书、证书考试驱动的学习(培训)、慕课网各小课、网易云课堂微专业大课_高级开发工程师
    • 证书:计算机技术与软件专业技术资格考试(即”软考“)-系统分析师(高级)
  • 19.12~20.3:某三方外包公司(参与阿里某核心中间件团队开发)

    • 岗位:高级开发工程师~技术专家(外包公司评级)
    • 职责:参与中间件平台非核心系统开发
    • 薪资:20k x 12
    • 成长:三个月试用期都没有,成长有限。
  • 20.3~21.10:某二方外包公司(参与阿里巴巴-盒马开发)

    • 岗位:高级开发工程师
    • 职责:主要负责盒马门店域数字化作业系统开发(主要是智能决策&终端作业)
    • 薪资:(25+2)k x (13 + 3)
    • 成长:业务、产研认识
    • 学习:书、证书考试驱动的学习(培训)、慕课网各小课、慕课网体系大课_Java架构师-十项全能、网易云课堂微专业大课_大数据工程师、九章算法大课_算法班&系统设计课
    • 证书:PMP
  • 21.12~现在:阿里巴巴集团

    • 岗位:保密
    • 职责:参与核心团队开发
    • 薪资:保密

PS:这里简单介绍一下我经历的所谓大小课、培训。

小结

就职业生涯而言,我的开局并不糟糕,起码也算是本科毕业了。这已经超过了不少人了。
但相对很多大佬而言,真的很一般的开局。既不是985、211,也不是研究生。相对团队里一个个清华研究生、东南大学研究生,那这学历属实有点寒碜了。
至于真材实料方面,大学平均成绩也很一般,偏科严重。
不过,大学生涯还是给我留了点东东的。一方面感兴趣的专业课真的学得好,学院前列。另一方面读的书挺多的,整个大学生涯借了图书馆几百本书吧。不敢说每本书都深入看过,但起码每本书都翻过,不少书还做过笔记啥的。

毕业季的时候,想去阿里(当时的杭州大学生都比较向往)。然而学校不怎么出名,阿里根本就没宣讲,当时迷迷糊糊就错过了。现在想来,当时就算有机会,就我那学历和成绩,也够呛。所以,没有赶上校招的快车道(但塞翁失马焉知非福)。

三年多时间(只算后端技术,还不到三年)的学习后,经过“超乎常人”的选拔(足足九轮面试,还不是因为自身原因加餐。。。放心,后面会有面经滴),加入到了阿里的核心部门。

总的来说,打分7/10吧。就结果而言,虽然略低于预期计划,但结果可以接受。就过程而言,由于缺乏经验&指导,以及自身由于各种原因,浪费了不少时间,导致整个过程的效率没有达到预期。这造成,我现在还得补前面三年欠的沉淀债务。

二、剖析

简单剖析一下这三年,离不开三个东西:驱动、思考、执行。
首先得有上层驱动力,才可以推动整个事物前进。其次,要把握好方向盘,确定正确方向。最后,将方向落地到执行层面
简单来说,念头->方向->执行

1.驱动

从某种意义上来说,我也算是职场经验丰富了。毕业三年多,已经经历了五家公司(算上那个三个月不到的三方外包的话)。
从小公司,到大公司,接触了许多人。他们给我最大的印象区别,就是自我驱动力。这份自我驱动力,我有时称之为野心。自我驱动力不同的人,最终表现出来的差十分之大。
近些年比较火的舒适圈理论、还有公司画饼,其实都与自我驱动力相关。

这时候,我就要举例子了。我曾经有一位同事的技术态度就是:可以用(他)现有技术栈解决的,坚决不学新技术。不可以用现有技术栈解决的,就反馈这个问题无解。
另外,我刚进一家公司的时候,那位老板给我画了个饼:希望公司的小伙伴,以后都可以在杭州买房。。。只能说,真是“好大”一个饼。当时,我就泄了一半的气。

简单来说,做人要有梦想,万一真的实现了呢。
如果真的很佛系,可以去看一下励志演讲,喝喝鸡汤。如果嫌鸡汤不好喝,去B站看看美国那边千万刀的豪宅(国内的房子就算了,那鸡汤就不香了),欧洲那边的广袤牧场。
不要老想着鸡汤有毒。鸡汤有毒也许只是一个扭曲的骗局呢?只能说按照博弈的角度来说,创业有成者一定表示,创业者是困难无比,而且一分靠努力,九分靠天命。看懂,掌声。

有人说,当所有人忘记你,便是你的死亡。我和我朋友开玩笑说,当我彻底不蹦哒了,就是我的第一次死亡。

2.思考

思考的三大输出体现:

  • 对过去的总结(复盘等)。
  • 对现状的辨识(厘清组织结构、人际关系等)。
  • 对未来的规划(新年梦想、执行清单等)。

技术上有这么一个准则:
一个需求下来了,如果某人立马开写,要么他做过一样需求,要么他是个憨批。
无论需求多小、多简单,只要么做过,我们就需要进行分析,再进行设计,最后编码、测试、上线。
从架构来说,编码之前需要分析&设计(概要设计&详细设计)。

**人生没有彩排,但可以提前规划。**每个人对自己的职业都应该有短期、中期、长期的规划。短期规划精准,满足Smart原则,中期规划富有弹性,可按照需要进行有效转变。长期规划,则必须follow自己的长期目标(目标质量很重要)。
短期目标,可以借助清单进行执行。中期目标可以和亲友进行探讨,多倾听不同意见(比如不同行业的)。长期目标,就得问自己了,自我价值实现在哪里。
很多人可能并没有自己为之奋斗终生的自我价值,甚至可以说大部分人进了棺材都没有。那可以选择和我一样,令自己拥有更多的选择权,直到找到自我价值的那天。

思考是一种习惯,当习惯了它,就经常会有文思泉涌的感觉。

3.执行

执行方面,我比较认同PDCA,但不需要过于形式化。对自己而言,更多是形成这样的处理模式。

a.计划

前面有提到,通过在脑海里的推演,找到一个切实可行的高效路线。
事前就多思考,执行就蒙头干。

b.执行

执行没啥说的。照做就多了。
中途坚持不下来了,就喝喝鸡汤,听听激昂的音乐(个人方法),就又有干劲了。
当然,如果真的感觉身体很累,那就去锻炼健身吧。我曾经有过半年多的专业健身,效果还是很不错的。

如果真的有一件事无法执行,或者说需要调整计划,那就问问自己,究竟有没有偷懒的成分,如果没有,那就去改变计划吧。

c.复盘&调整

对完成的事情,进行复盘总结,找出优点与不足。
比如,我每年都会写年度总结。平日里,也经常在白板/平板上,对之前做的事情进行分析。
但不可否认,过去的三年里,相对于大量的输入,复盘还是不足。准确说,其实是知识的内化&沉淀不足。

4.小结

总结一下,就一句话。
驱动决定可能,思考决定上限,执行决定下限。

三、方法

前面提到的方向到执行,跨度过大。比如我想学技术、学XXX技术。具体怎么学呢?
所以,这就涉及到方案层面了,或者说流程方法层面了。
举个不恰当的例子,一位p8表示这个财年要做平台商业化,下面一群p6嗷嗷叫咋做。这就需要几位p7,配合p8拆分目标。最后几位p7有了交互逻辑解耦、领域模型抽象等工作,它们就可以将它们细化成具体系统,交给下面的p6。
(准确来说,中间都是在做拆解,并不是一定只有7才有方案/流程方法的考量,粒度不同而已)

俗语说得好,磨刀不误砍柴工。

1.工具

这里的工具只针对狭义工具。更多是集中于软件。广义的工具,是包涵达成目标的多种手段。后面会有一片文章与之相关。

这个时代,不会还有死读书的吧。
合理利用工具,可以大幅提升学习、工作等效率。就像Mybatis取代JDBC、Spring取代EJB、SpringBoot取代Spring一般。

我这里简单提及一些我使用的工具。如果大家感兴趣,我后续会写一篇详细文档,进行阐述的。
Idea Ultimate、Sublime、MarginNote3、XMind、ProcessOn、GoodNotes5、Notability、印象笔记、印象笔记-剪切插件等app
设备方面,随着今年MacBook max的到手,苹果全家桶齐全了。

这里,我提一下我对工具花费的看法。我这边所有软件都是正版的,设备则是按需购买。这些加起来花费不少。但是效率的提升,真的完全赚回来了。

虽然我有marginNote3看pdf文档,但是很多时候还是喜欢看实体书,所以我买了很多实体书。后来甚至为了能够方便看实体ppt等,我直接买了个支持airprint的打印机。。。不过,确实方便了很多。

按照工具的必要程度、效率提升、价格,再根据腰包进行购买。但,你得先知道有这么个东东。囧

我认为,思考与工具(广义上的工具),就是最大的效率提升工具。后面,我会有一篇专门的文档进行这方面的阐述。毕竟也有多个小伙伴问到我效率方面的问题。诸如你这么多书,看得完嘛?你学那么多课,学得过来嘛?项目比较大时,你怎么同时进行项目管理、方案设计,以及核心开发。简而言之,如何实现有效提升效率。

PS:有关破解的问题,个人认为如果可以,还是付费支持一下的说。大家都经历过从破解到付费的时代。开发最常见的,就是jetbrains产品了。早两年,我也是破解,不过后面买得起了,就买了全家桶支持。

2.流程

如果只是单一的工具,那么整体效率终究有限。尤其单一工具带来的局限性,真的令强迫症发狂。
所以,你需要流程,甚至多个流程组成的生态(比如学习生态)。
就学习而言,我目前有两个纵向闭环流程。一个是依据marginNote3的阅读-学习-复习模式构建,这是marginNote官方流程,大家可以了解一下。另一个是基于印象笔记-剪切插件、印象笔记构建,但是复习效果还存在不足。

后续也有想过利用Notion建立流程,或者利用对开发很友好的语雀建立流程。具体整个流程体系,我还在优化迭代中,欢迎大家给出意见。

PS:其实,之前出于定制化的需求,我都想自己写一个知识库工具了。

3.内化

内化,就是把知识变成自己的。
知识看了、收藏了、下载了,都不一定属于你的。甚至做了笔记,这个知识可能还是不属于你的。所以,我们需要有意识地进行知识的内化。

内化的方式有很多。前面说的看、做笔记都是,只是效率低而已。更高一级,是去做实践。比如手写AQS,再比对比对源码,做做思考笔记,你就可以吊打大部分面试官(实测)。再高一级,就是去教别人。比如写博客、线上/线下教课等。效果还是很不错的(实测)。

我自己,就会做笔记(印象笔记),写博客(博客园、CSDN),技术分享(群组、团队、公司等)

5.小结

总结一句话,方案决定整体效率。
需要时常反思自己的流程&工具构建的方案效率,是否可以再提高。平时多留意一些效率方案的文章等。

四、警醒

1.不足之处

那么回首这三年多,是否有不足呢?那当然是肯定的。
自身的不足,体现在三个方面:收集有余,沉淀不足;时间浪费;缺乏锻炼,身体素质下降

a.收集有余,沉淀不足

最近三年虽说没有成为收集癖,大多数信息也是个人相关的。
但是从课程(大课五个、培训三个、小课几十),到文章、再到各类书籍,信息收集得太多了。好在大课整理进度85+%,部分小课被大课覆盖内容,就只是随便看看了。但是后续内化程度不足,水平也是参差不齐。
最近趁着有时间,推进了进度,后续还需要持续推进内化进度。

b.时间浪费

时常回去啥也不想做,就想发呆&看视频/直播,晃荡一两个小时,才回过神,开始做事情。个人觉得一个很重要的原因是白天注意力比较集中,刚回来的时候心思比较活跃,所以注意力难以集中。再就是整体精力不足。

解决无非开源节流:

  • 开源:增强身体锻炼,提高个人精力上线。
  • 节流:需要合理安排时间&精力,提高时间“质量”(详见精英控系列,后续详述)。

PS:我属于白天一干活,就可以开心坐一天的那种,除非特殊情况,否则就真的感觉像心流那样度过一天。但晚上就真的不怎么想加班,尤其实际没啥事情。

c.健身时间变少导致身体素质下降

虽然整体来看,还不错。但是相较于大学时候的身体素质,那确实有所下降。后面需要安排上的说。

2.客观认识

过去三年多,有很多运气的成分,这不得不承认。
比如被第一家公司老板挖到,是因为我那段时间对区块链技术比较关注,被他发现了。。。
比如被第二家公司老板挖到,是因为我在第一家公司考虑软考-系统架构设计师,所以在我对后端技术只学习了一两个月的情况下,被挖过去当技术负责。。。
比如被第三家公司上司挖到,是因为我在第二家公司时,虽然通过了阿里一面,但被我拒绝了后续流程。然后,我的简历就一直在阿里人才库,并且评价还不错。。。
第四家公司原因同上。。。

再比如我遇到上司与同事,大多都挺不错的,简直是职场最大lucky。

如果刨除这些运气,今天的我又会是什么样呢?

五、总结

如果工作的信念只剩一个,我希望是自我驱动。
如果人生的核心只剩一个,我希望是思考。

至于为什么要做这样的分享呢?大学的时候,我学到一句话,我很有感触。这个社会让你知道的,是它想让你知道的。
博弈使得每一位成功者成功后,都会选择包装自己,使得自己的成功更为顺理成章。而我能做的,只有从一开始就展示自己的一点一滴,没有包装的真实。那么如果最终我可以获得一些成就,说明我的道路是正确可行、可持续的。
过去中国几十年的阶级越迁已经越来越难见,那么我们看到的道路真的还是正确的吗?结果与过程真的相匹配嘛?这里无法如数学那样可以明确推理的答案,所以需要我们这些人去探寻。而我能做的,就是用自己的经历去验证自己的想法。再将这份经历真实地展现出来,供大家参考

欢迎大家就文章中的一些问题,如职场生存、职业规划、效率提升、面试经验等,与我进行交流。

最后,愿与诸君共进步。

这次总结更像是对过去三年多的一种粗粒度总结。不过,后续会有面经、工具、工作经验等方面的详细文章。

原文链接:https://blog.csdn.net/cureking/article/details/122179291

收起阅读 »

请不要“妖魔化”外包岗位!——一文带你正确认识外包

一、背景一转眼,又到了金三银四的跳槽&求职季。在IT行业,跳槽就离不开一个词,那就是外包。可以说,每一位IT人都接触过外包,甚至参与其中。而多数IT职场萌新,都面临着大厂外包,还是小公司的绝望抉择。虽然很多人虽然抵制外包,但他们往往对外包只有比较直观、...
继续阅读 »

一、背景

一转眼,又到了金三银四的跳槽&求职季。
在IT行业,跳槽就离不开一个词,那就是外包。可以说,每一位IT人都接触过外包,甚至参与其中。而多数IT职场萌新,都面临着大厂外包,还是小公司的绝望抉择。虽然很多人虽然抵制外包,但他们往往对外包只有比较直观、碎片的认识。
网上针对IT外包的资料,很少很少,而且大多比较零碎。我恰巧对外包算比较有经验(经历详见我之前的 从零到阿里的三年)。
所以我想谈一谈外包。希望能给需要的小伙伴,一些参考与帮助。

二、分析

1.什么是外包

为了更好地分析,我们需要了解什么是外包。
外包是一种将目标,委托给其他组织的管理模型。
外包有很多种,如项目外包、产品外包、工程外包等等。而我们最为关心的,则是人力资源外包。
这样说比较抽象,我来举个例子。

  • 项目外包:为了完成某个项目,出于进度、成本,甚至是风险转移的考量,将项目拆分一部分(如非核心部分)交给其他个人/组织。比如猪八戒网上的一些项目,就是这样的项目。
  • 产品外包:多数出于成本考量,将部分产品外包给其他个人/组织。比如战神5将部分场景、人物模型外包给外部团队完成。
  • 工程外包:多数出于成本、风险、进度等考量,将工程交给其他组织。比如包工头承诺完成大楼的墙壁粉刷等。
  • 人力外包:多数出于成本(也有是对上层政策的对策)的考量,将员工合同签署到其他人力资源公司等。比如国内IT行业的中软国际,员工与中软国际签合同,但却在阿里、大搜车等公司驻场工作(也很多有与目标公司分开的情况)。

2.二方外包VS三方外包

我们有时候会听到招聘人员说自己是二方外包,或者直接说自己不是外包,只是合同签署公司有所差别,和正式员工没有什么区别。抛开那种没底线的欺骗行为,到底什么是二方外包呢?它与三方外包的差别是什么?

最直接的区别,三方外包的合同都是与独立的第三方人力资源公司签署,二方外包的合同是与目标公司的关联公司(多为控股子公司)签署。
这里直接举个例子吧。我是一个即将成为盒马外包的开发人员。

  • 三方外包:我和一家与盒马不入股的中软国际签署合同。
  • 二方外包:我和一家由盒马控股的上嘉签署合同。

透过现象看本质。这两种合同的签署方式,直接决定了你和目标公司(如盒马)的关系。
盒马无法直接管理三方外包,甚至说两者解除合作关系后,你就无法在盒马工作了,所以盒马对三方外包员工的信任会比较低。体现到实际表现中,就是三方外包员工的权限总是很有限。另外,三方外包员工即使无法在盒马工作,也可以被三方外包公司派遣到支付宝等其他公司。所以,盒马与三方外包公司基本都是把三方外包员工视为商品,人力资源商品。
但是盒马有权管理二方外包,所以在工作上会更加信任二方外包员工。而且这个二方外包只会服务于盒马,所以在盒马会在一定程度上把二方外包员工当自己的正式员工看待。

搞清楚了外包员工与目标公司的关系(合同关系),自然就清楚了同样是外包,为什么二方比三方有着更好的待遇&机会。

3.外包的优点

虽然很多开发人员都抵制外包,但实际情况则是依旧有大量开发,选择加入到外包这个圈子。
这说明,外包一定是有好处的。所以,我简单归类了三点好处。

a.面试门槛

外包的面试门槛,相对大厂要低很多。尤其一些初级岗位,真的是有手就行。
原因很简单,有三点:

  • 对于三方外包公司而言,外包员工都是商品,商品越多,公司越赚钱。所以三方外包公司一定会极力帮助你通过面试,包括但不限于给面试资料、透露考题等。
  • 目标公司的面试官大多也不会太重视。而且面试内容,相较于正式员工,更多集中于实用技能,不会出于潜力考虑,询问诸如项目管理、业务思考深度等问题。
  • 即使一家公司翻车了,三方外包公司还会推荐别的公司。概率学上来说,总会通过的。囧

所以,在穷途末路时可以考虑先去外包混一混,别把自己饿死。

b.薪资水平

可能很多人并不知道目标公司给外包公司的合同价。一般来说,你和三方外包公司谈到的最高价,再提升30%~50%,便是目标公司给外包公司的合同价。之所以这么高,是因为正式员工的福利待遇比外包好太多了,比如十六薪、旅游、商业保险等。另外,目标公司政策上会卡住正式员工HC的。
三方外包员工的薪资上限是由级别确定的。而这个级别是面试过程中,目标公司面试官确定的。
你看懂了嘛?看出来什么了嘛?机智的小伙伴已经看到薪资大幅提升的方法了。
是的。只要你确定了你的级别,那么无论你之前薪资多低,你都可以和三方外包公司要这个级别的最高薪资。
因为对于三方外包公司而言,当你级别确定后,目标公司就会给出一个确定的合同价格,为你付钱给三方外包公司。所以,只要你的要价没超出三方外包公司对这个级别设定的薪资上限,他们就一定会和你签合同。毕竟多一个合同,就多赚一份钱。
而在正式员工中,多数情况下,HRG都会以应聘者上一份工作涨薪30%左右为上限。因为再高的价格就得走审批了,流程会比较麻烦。所以除非你非常优秀,否则薪资上限就在那里摆着。

这里说一下我当初的情况,我当初的薪资就是从11k x 12,直接跳到20k x 12。

其实从从零到阿里的三年 的相关经历就可以看出这三个月不到的三方外包经历,帮我实现了短短三个月从11k x 12,到20k x 12,再到24.5k x 16近三倍多工资的巨大跳跃。

当时入职时的级别是资深开发工程师。但是入职一个月后,又被调整为技术专家。所以,我也不确定20k x 12是针对资深开发工程师级别,还是技术专家级别。另外,外包公司的技术专家,大家看着开心就行了。

所以,外包是可以实现薪资的大幅提升的一种方式。

c.学习机会

很多人知道外包的种种不好,但还是选择去外包,这是为什么呢?因为很多人,包括我在内,都相信外包有接触大佬,接触复杂系统、接触大型项目的学习机会。
有一说一,外包虽然没有招聘人员提的那么好学习机会,但却是有一定的学习机会。你可以接触到大佬的代码、架构图,甚至负责项目。
不过,所谓的学习机会完全取决于目标团队。目标团队给你多少文档权限、给你多少代码权限,以及你与目标团队的协作方式,都极大影响了这个学习机会。不得不说,二方和三方的学习机会相差是非常大的。

说一下我在二方,也就是在盒马的情况。首先,我要感谢我所在的团队,尤其是我的一二级主管对我非常照顾,给了我很多机会。非常感谢。由于团队与二级主管(P8)的开明,作为二方的我几乎享有正式员工的所有权限。只要能开的权限,都对我放开审批通过权限。而由于一级主管(P7)的信任与支持,我甚至拥有超出一般员工的项目机会、业务沟通、管理提升。不过,随着二方员工的权限抵达边界、上升渠道卡死,以及最重要的一二级主管离去,我也在近两年的工作后后选择离开。

所以,外包是可以有学习机会的,但取决于所在的团队

4.外包的缺点

说完优点,接下来说说缺点。
虽然大部分人都抵制外包,但是很多人,尤其是萌新,并不清楚外包的主要缺点。
我这里简单归纳一下。

a.工作碎片化

外包的工作内容,大多十分碎片化,甚至是机械化。
因为如果这个工作内容真的很完整、成块儿,那正式工就做掉了。正式工做掉的理由有两个:

  • 完整工作内容有利于他,去构建业务认知。
  • 完整内容拆分出来外包,需要进行进行大量的沟通与团队协作,不利于整体效率。

那么有没有办法避免碎片化呢?答案是有的。一方面可以表现出自身能力,获取正式团队信任,从而获取更完整工作内容。另一方面,从碎片内容中找到联系,构建自身认知体系,从而让碎片化的内容,不再碎片。这个是职业通用技能,实操是有一定困难的,有机会可以聊聊。

工作内容的碎片化,就带来了两个最直观的后果:

  • 提升困难:工作内容的碎片化,导致自身在技术和业务上难以提升,进而影响晋升、转正等,乃至后续可能的面试。
  • 缺乏重量级面试项目:由于工作内容很零碎,面试时无法进行整合,从而获得一个较为完整&复杂的项目。进而导致无法在面试中很好地表现自身实力,影响后续面试结果。

b.缺乏上升通道

这里辟谣一波,许多外包都说有转正机会。实际情况是几乎等于零。 其中,三方外包更是可以直接和零划等号。
三方外包的转正,往往就是给个内推机会,然后和面试官会熟悉一些。然而这些都没什么价值。内推的机会,简直不要太好找的说。现在的大厂,大部分人才招聘,都是技术部员工直接内推的。至于面试官的熟悉,只能说大多数情况下那只会让你的面试更加困难。
至于二方外包的转正,大多也会对绩效、贡献等诸多方面有要求,还需要一二级主管进行推荐。另外,还需要经历审批,以及转正答辩。

靠谱的晋升通道,可能也只有外包公司自己的晋升通道了。具体,我也不太清楚。毕竟我在两家外包公司都是技术最高级了。不过,外包公司的外包员工技术级别,我见过最高的是技术专家,公司宣称对标阿里p6+。

那么到底有没有外包转正式?答案是有的。我见过盒马测试团队的二方外包晋升正式成功了。然而,人家根本没接受,直接跑到支付宝某部门了。

所以,现实是能从外包晋升正式员工的人,水平早超出那条线了,完全可以走正常社招流程。当时作为外包的我,当然也很关注这方面啦。我当时咨询了很多人,得出的结论是,同一个人,晋升的得到的薪资待遇,会比社招得到的待遇低很多。结论就是,能外包晋升正式工,也别走晋升途径,直接去走社招途径。 当然,如果和这个团队离不开什么,与团队有了较深的羁绊,那就没办法了。

所以,外包终究不是归宿晋升通道窄,且晋升性价比很低。

c.温水煮青蛙

很多人都知道外包工作不是最终归宿,为什么还有那么多人一条路走到黑,最后黯然离开?答案很简单,就是 温水煮青蛙 。
一方面,外包的工作往往两极分化,要么一堆碎片化事情,要么无所事事。这对于有一定能力的小伙伴,摸鱼不要太容易。外包的工作考核很是简单,尤其数量最多的三方外包。另一方面,外包的薪资还是说得过去的。属于那种虽然买房买车会有点吃力,但是日常生活还是可以过得比较潇洒的。总结一下,活少钱多压力小,就像温水一般,将外包员工们慢慢麻醉,最终死去等到公司抛弃了自己,才发现自己已经失去了市场竞争力了。

d.心理压力

多数外包,都会承受着低人一等的压力。
这个压力,往往不是来自周边的小伙伴,而是来自周边的环境,甚至是来自外包员工自己。

压力来源于:

  • 外部:
    • 狗牌:狗牌是心理压力的最大来源。阿里的狗牌是可以很容易区分正式员工和外包员工的,每次进门刷卡,就会感到压力,那种与众不同的压力。我就见过很多外包的朋友,从正式员工那里获得正式狗牌壳,替换自己的外包员工狗牌壳。说实话,我当时也感觉一些压力。但我一直都没有这样做,因为我坚信我并不比那些正式员工差,我只是欠缺一个自己给自己的机会。不过平时开会,我也只是把狗牌放兜里。囧
    • 福利待遇:大厂的福利待遇不错,但其中许多福利待遇是外包享受不到的。比如旅游,三方外包基本无法享受免费跟团旅游的。结果就是大家在谈论旅游,你只能默默敲电脑。还有文化衫,很多时候公司的文化衫,是不会算着外包的文化衫的。看着周边都穿上了统一样式的文化衫,只有你格格不入地穿着自己的衣服,心里压力陡增。
    • 权限:正式员工的权限与外包员工的权限存在很大差异。当主管在群里发了一篇内部技术论坛的帖子,表示需要大家观摩学习。结果你发现自己没有权限访问&申请,心态简直裂开。
  • 自我:人或多或少,都会有自我中心的倾向。比如有一天你平地摔了一跤,你周边的小伙伴一周不到就忘了。结果你为此纠结了好几个月,认为很是丢人。很多敏感的小伙伴,甚至会把一些正常的行为动作,解读出别的含义。比如主管看到你来了,切换了一下屏幕,你就认为主管在向你隐瞒什么。

这种心理压力,虽然有可能使当事人寻求突破。但更多人是被这种压力,压得喘不过来气。
一方面需要团队与主管的关心&照顾(这里再次感谢我的一二级主管),另一方面需要靠自我。靠自我,主要依据自己对现状的清醒认识(自己在外包的目的等,详见下),以及自己的职业规划(明确的职业规划,可以大幅减少自我焦虑)。

5.如何选择外包

虽然外包听着不好听,但是依然有大量的人进入外包,那么该如何选择外包岗位呢?
多数人选择外包,无非三类:

  • 作为临时的工作
  • 作为跳薪的踏板
  • 作为提升的一步

这里就这三类主要目的,谈一下该如何选择外包岗位。

a.临时工作

如果是临时工作,建议直接在三个月内离开
一方面脱离试用期,离开手续会很麻烦。更重要的是心理上也会有感情,存在“要不就这里”的心理。另一方面一旦超过三个月,在大厂背调环节,则会被列入考察。对公司&团队而言,早点离开,总比半年后离开,更容易接受(无论是培养、情感,抑或是损失)。
临时工作,那就选择试用期在三个月及以上。在此基础上,还需要考虑:

  • 试用期薪资高:不仅仅是工资,而且是试用期工资。因为一些外包offer,试用期薪资打八折,而有的外包offer则不然。
  • 试用期个人时间多:这点就比较油滑了。首先临时工作,意味着后面需要跳槽,那么自然需要不少时间去准备面试。其次,有的工作试用期工作量会比较少。最后,有的工作虽然工作量不少,但是缺乏对外包员工的管控,完全可以上班准备面试。囧

b.跳薪踏板

如果是跳薪踏板,建议早日落实,并且选择月薪高的。
绝大多数情况下,外包薪资涨薪高,仅适用于月薪25k以下。因为正常开发外包,是有月薪25k的一条线的。阿里这边几个BU的二方外包,封顶25k月薪,但一般可以13 + 3。所以,如果想拿外包做跳薪踏板,那就要趁早。
之所以选择月薪高的offer,是因为二方外包,以及大厂关注的是月薪,默认给16个月薪资。这时候,你的月薪很高,很容易就在第二次跳槽时直接月薪&年薪月份数双成长。操作得当,年薪可以快速上升数倍。不过薪资高,有时候也是一种负担,有机会会谈谈这个问题。话说后面,要不写一个用来快速涨薪的文章?囧
薪资的话题,比较敏感,我就先说这么多。剩下的,自行领悟哈。只能告诉你们,现在知道&执行这个操作的人,不多。/doge

c.自我提升

如果是自我提升,建议和正式员工在一起办公,并且积极主动
如果只是为了见识见识复杂系统,那完全可以考虑项目外包,不需要考虑人力资源外包。所以所谓的自我提升,更多是为了学习大佬们在面对复杂系统、技术难题、项目管理等问题时的处理手段、方法论、思想等。而当你的工作地点并没有和正式员工在一起,这一切都是泡影。也许可以摸鱼摸得很开心,但是对自我提升,毫无作用。

分开这种事儿并不是我们能决定的。那么有两种选择,一个是转换主次目的,随时准备跳槽。另一个是让主管想办法,把你捞过去。之前我们这边主管为了让同团队的三方外包可以和大团队一起工作,直接每周申请外部访客。最后是打了一个到P10的申请,将这位三方外包员工,留在了园区工作。

在一起工作,只是提供了学习&成长的可能,更多的是需要你积极主动地参与其中。前面说过外包的温水煮青蛙,重要的一点是外包很容易摸鱼,表现出来的,就是不积极主动。很多人成为外包员工后,就想少做一些工作,感觉做多了就亏了。。。其实主动争取工作,一方面有机会获取到更多更有价值的工作内容。另一方面可以展现自身主观能动性,进而让主管给你更多机会。另外,面对外包员工,多数主管并不会太关心其成长。所以这就需要外包员工自己去努力获取更多资源、更多信息、更多成长。其实无论是否为外包员工,都需要积极主动,才可以获得更多成长。只是外包员工更需要这个,因为几乎没有人去推你
具体如何在工作中更好地成长,我后面会写一些相关的文章,就不在这里展开了。

三、总结

这篇文章,先介绍了外包的概念,包括二方外包与三方外包的区别。进而分析了外包工作的优缺点。最后进一步分析外包工作的选择,如何选择,如何面对等。
希望这一篇文章,能够帮助到那些接触到外包工作机会,甚至已经是外包员工的小伙伴。

来源:cnblogs.com/Tiancheng-Duan/p/16002433.html

收起阅读 »

2021年 IT 圈吃瓜指南

来源:InfoQ













来源:InfoQ

再谈如何写好技术文档

— 1 —搞清楚主谓宾— 2 —不滥用代词、过渡词和标点符号— 3 —多用强势动词,少用形容词和副词— 5 —正确使用段落— 6 —适当使用列表和表格—&nbs...
继续阅读 »

参加工作时间久一点的工程师应该有这样一个体会:自己平时代码写得再多再好,可一旦要用文档去描述或者表达某一个事情或者问题时,都感觉非常困难,无从下手,不知道自己该写什么不该写什么;或者费了九牛二虎之力写出来的东西没法满足要求,需要再三去修改调整。这其中的主要原因我归纳有两点:

  1. 思维方式固化。大部分人平时代码写得太多,文字类型的表述又写得太少。而代码和文字明显是两种不同的思维方式,在代码里陷得太深,不容易跳出来;

  2. 本身文字表达能力有限。这个跟写代码一样,有人代码质量高、bug少;有人水平低、bug自然就多。

以上两点其实都可以通过平时多练、多写、多梳理的方式去弥补,比如周期性的博客总结和记录。但是,如果你能刻意系统性地去补充一些关于“技术型写作”的理论知识,一定能够事半功倍。这就像我们刚学编程时,一顿学、一顿模仿,但是总感觉缺了点什么,自己再努力发现深度还是不够。

这时候,我们需要做的是看一本高质量的经典书籍,书籍能帮我们梳理知识点、总结各种碰到过的问题,从理论上解答我们心中各种疑惑,将之前的野路子“正规化”。

下面我根据平时的一些积累,将技术型写作的理论知识归纳成10个要点。

  1. 搞清楚主谓宾

  2. 不滥用代词、过渡词和标点符号

  3. 多用强势动词,少用形容词和副词

  4. 正确使用术语

  5. 正确使用段落

  6. 适当使用列表和表格

  7. 一图胜千言

  8. 统一样式和风格

  9. 把握好整体文档结构

  10. 明确文档的目标群体


 
1 
搞清楚主谓宾


文档主要由段落组成,段落由句子组成,而大部分句子又由“主谓宾”组成(可能有些场合省略了,但是读者可以通过上下文轻松get到)。主谓宾是主干骨架,其他内容可以看作是句子的修饰,主干骨架是决定句子是否垮掉的主要原因。

现在很多人可能已经忘记了句子的基本构成,毕竟以汉语为母语的人,大概率是不太会关心这些“细节”,就像说英语的国家可能不太关心am、is、are一样,你说哪个人家都理解。

但是,文档中的一句话读起来是否别扭,大多数时候是由句子构成决定的。在不考虑文档上下文的情况下,如果一个句子能包含正确的主语、谓语和宾语(可选),那么它读起来至少是很顺口的。下面举一个明显搞不清主谓宾的例子:

传统图像处理算法,通过计算烟火颜色特征,极易受烟火周围环境相近颜色干扰而造成误检。

尽管你能读懂作者想要表达的意思,但是这句话读起来还是太别扭。“传统图像处理算法”应该算是主语,后面的“通过……”这句不完整,“极易受……干扰”这句还可以,“……造成误检”算是谓语宾语,但是这里用错了动词,为什么是“算法造成误检”,难道不是“周围环境相近颜色干扰造成误检”吗?

这句话的主干内容是:算法极易受……影响而……。正确的表述应该类似下面这样:

因为传统图像处理算法通过计算烟火颜色特征去识别烟火,所以它极易受烟火周围环境相近颜色干扰而出现误检。

我们用过渡词(因为……所以……)将原来的句子拆成了前后两个部分,前面部分的主语是“传统图像处理算法”,谓宾是“识别烟火”;后半部分的主语是“它”,谓宾是“出现误检”。经过调整后,前后两个部分的主语是同一个:传统图像处理算法。下面再直观看一下修改之后的句子主干骨架:

<因为><传统图像处理算法>通过计算烟火颜色特征去<识别烟火>, <所以><它>极易受烟火周围环境相近颜色干扰而<出现误检>。

如果你觉得用“因为……所以……”不太好,那么可以再换一种表述:

传统图像处理算法通过计算烟火颜色特征去识别烟火,烟火周围环境相近颜色的干扰极易造成误检。

第一句还是跟之前一样,主语是“传统图像处理算法”,第二句主语变成了“干扰”,谓宾是“造成误检”。下面我们直观地看一下修改之后的句子主干骨架:

<传统图像处理算法>通过计算烟火颜色特征去<识别烟火>, 烟火周围环境相近颜色的<干扰>极易<造成误检>。

最后再举一个错误的例子:

由于误报率与漏报率很高,因此不管是否有真实事件发生都会去留意,也会有规定的日程定点巡查视频任务。

上面这个句子的作者完全没搞懂谁是主语,谁是谓语。感兴趣的童鞋可以试着修改一下,改成你认为正确的表述。


 
2 
不滥用代词、过渡词和标点符号


不滥用代词和过渡词

中文文档中的代词主要有:你、我、他、她、它、其、前者、后者、这样、那样、如此等等,过渡词主要有:因为/所以、不但/而且、首先/然后等等。下面这张表格列举了一些常见的代词和过渡词及其常用场合:

序号
类型
名称
常用场合举例
1
代词

C语言中引入了“指针”的概念,作用是为了能够提升内存访问速度。
2代词
后者
C语言发明于1970年代,C++语言发明于1980年代,后者主要引入了面向对象思想。
3代词

指针能够提升程序访问内存的速度,但特点仍存在一些缺陷。
4代词

C语言的一大特性是指针,这就像C++语言和的面向对象思想一样。
5
过渡词
因为/所以
因为神经网络可以自动提取数据特征,所以基于神经网络的深度学习技术中不再有传统意义上的“特征工程”这一概念。
6
过渡词
首先/然后
首先我们要保证有足够多的训练数据,然后我们再选择一个适合该问题的神经网络模型。

表2-1 代词和过渡词举例

代词和过渡词就像标点符号一样,容易被滥用。代词滥用主要体现在作者在使用它们的时候并没有搞清楚它们代表的究竟是谁,是前一句的主语、还是前一句的宾语或者干脆是前一整句话?

过渡词滥用主要体现在作者在使用它们的时候并没有搞清楚前后两句话的逻辑关系,是递进还是转折或者是因果?(过渡词滥用频率要低很多,毕竟搞清楚前后句子逻辑的难度要小)接下来举几个滥用代词和过渡词的例子:

C++语言发明于1980年代,它支持“指针”和“面向对象(Object-Oriented)”两个特性,其价值在计算机编程语言历史上数一数二。

上面这个句子中出现了两个代词“它”和“其”,抛开句子内容本身对错不论,第二个代词指向的对象其实并不明确,“其”指的是“指针”、“面向对象”还是“C++语言”?或者是指“C++语言同时支持……两个特性”这个陈述?像这种有歧义的场合,我们应该少用代词,尽量用具体的主语去代替:

C++语言发明于1980年代,它支持“指针”和“面向对象(Object-Oriented)”两个特性,C++的价值在计算机编程语言历史上数一数二。

如果你一定要用代词,那么调整一下可能更好:

C++语言发明于1980年代,它同时支持“指针”和“面向对象(Object-Oriented)”两个特性,这个价值在计算机编程语言历史上数一数二。

再读一读,你是不是没有感觉到歧义了?我们在“支持”前面增加了一个“同时”,然后将代词换成了“这个”,现在这个代词指的是“C++语言同时支持...两个特性”这个陈述,修改后整个句子的意思更明确。

我们再来看另外一个滥用代词的例子:

该模块主要负责对视频进行解码,输出单张YUV格式的图片,并对输出的图片进行压缩和裁剪,前者基于Resize方法来完成,后者基于Crop()方法完成。

对于大部分人来讲,上面这段没什么问题。代词“前者”指的是压缩、“后者”指的是裁剪,原因很简单,因为单词Resize对应的是压缩、单词Crop对应的是裁剪。

但是这段话如果拿给没有任何知识背景的人去读(大概率可能是找不到这种人),恐怕会存在歧义,主要原因是代词前面提到了很多东西,“前者”和“后者”指向不明确,到底是指“解码”、“输出单张图片”还是后面的“压缩”和“裁剪”?下面这样调整后,整段话的意思更加明确:

该模块主要负责对视频进行解码,输出单张YUV格式的图片,并对输出的图片进行压缩和裁剪,压缩基于Resize方法来完成,裁剪基于Crop()方法完成。

我们去掉了代词,直接用具体的主语来代替,句子意思非常明确。如果你一定要使用代词,那么也可以这样调整:

该模块主要负责对视频进行解码,输出单张YUV格式的图片。同时,它还对输出的图片进行压缩和裁剪,前者基于Resize()方法完成,后者基于Crop()方法完成。

上面这段话还是使用了代词“前者”/“后者”,但是我们修改了标点符号,并且增加了一个过渡词“同时……”,这样做的目的是让读者知道虽然整段话说的是同一个东西,但是前后的句子已经分开了,为我们后面使用代词做好准备。

好的,现在我们来总结一下在技术型文档编写过程中使用代词时的一些有价值经验:

  1. 代词可以指它前面出现过的名词、短语甚至整个句子,但是一定是前面出现过的;

  2. 代词的位置和它要指向的目标最好不要隔得太远,1~3句话之内,超过就不要用了;

  3. 代词的作用是减少小范围内某些词汇或句子重复出现的频率,要用到恰到好处;

  4. 代词前面出现的混淆目标如果太多,一定要重新调整句子,确保代词指向无歧义。

不滥用标点符号

接下来我们再看另一个,标点符号的滥用要普遍很多,其主要原因是:标点符号的使用并没有非常明确的对错之分。至少对大部分人而言,使用句号还是逗号其实并没有什么严格的评判标准,只要不出现“一逗到底”的极端情况,其余大概率都OK。下面这张表格是我根据以往经验,总结出来的应用于技术型写作时中文标点符号使用规则:

序号
符号
写法
使用场合
1逗号

前后两句话关联性比较大,阅读时停顿时间短。
2句号

前后两句话关联性比较小,阅读时停顿时间稍长。
3
分号

前后两句话地位相对平等,句子的内容和格式基本保持一致。比如列表中,如果每项是一个句子或者短语,那么第1至第N-1项结尾使用分号,第N项结尾使用句号。
4
冒号

技术型文档中,冒号一般用在需要引入重要内容的场合。比如当你需要插入一张表格或者一张图片时,需要提前做一个提醒(下表列举了常见的代词和过渡词:),提醒结束时补充一个冒号。
5
括号
()、【】
()一般用于解释性的场合,负责对名词或者句子的补充解释。【】用得比较少,我一般用于需要增加醒目标记的名词或短语中。
6
顿号

一般可以用在枚举名词或者短语的场合。
7
问号
不用多解释。
8
引号
“”、‘’
一般用于标记特殊名词、专用名词、短语,或需要重点突出的名词或短语。
9
分隔号
/
一般用于成对出现的名词(举例:因为/所以、首先/然后等等都是过渡词),或者根据文档上下文来判断地位差不多的相近词(举例:算法的好坏直接影响最终报表中误报/误报率那一栏)。
10
破折号
——
用得不多。
11
省略号
……
不用多解释。
12
感叹号

技术型文档不是写小说,用得不多。
13
书名号
《》、<>
不用多解释。

表2-2 常用标点符号

上面这张表格基本涵盖了常用的中文标点符号,其中有一小部分在技术型文档中不太常见,比如感叹号、破折号,这些符号多多少少带有某种感情色彩,不太适合用于技术型文档编写。前面已经简单概括了一下各个符号的使用场合,下面挑几个容易出错的再一一详细说明:

C++语言发明于1980年代,它衍生自C语言,主要引入了“面向对象(Object-Oriented)”思想,面向对象思想强调对数据的封装和对功能的复用,此特性有利于开发者对代码的维护和扩展,目前,大部分计算机编程语言已经支持了面向对象特性。

上面这段话属于典型的“一逗到底”的例子。作者从C++语言说到了面向对象思想,最后总结大部分计算机编程语言都支持面向对象。我们如果将整段话拆开来看,其实它想表述的是3个内容,每个内容之间最好使用句号,停顿时间稍长一些。我们调整之后的效果是:

C++语言发明于1980年代,它衍生自C语言,主要引入了“面向对象(Object-Oriented)”思想。面向对象思想强调对数据的封装和对功能的复用,此特性有利于开发者对代码的维护和扩展。目前,大部分计算机编程语言已经支持了面向对象特性。

接下来我们再看看分号的使用。根据我个人经验,分号常用在列表场合,下面举一个例子说明:

下面是“将大象装入冰箱”的具体步骤:

  1. 打开冰箱门;

  2. 将大象装进冰箱;

  3. 关上冰箱门。

上面是一个有序列表,列表中的各项内容是一个短语。当列表中各项内容是短语或者句子的时候,除最后一项之外其余项目结尾一般都使用分号(注意,同一个列表中各项的格式最好都保持一致,要么都是短语,要么都是单个的名词,这个后面专门讲列表的时候会提到)。如果列表中各项内容只是一个名词时,那么结尾就可以不用标点符号:

下面是“可以被装进冰箱”的动物:

  • 狗子

  • 大象

  • 猴子

  • 鹦鹉

上面是一个无序列表,列表中的各项内容是一个名词,这时候名词结尾处不需要添加任何标点符号。

我们最后再来看一下小括号的使用场合。在技术型文档中,小括号主要用于对前面的名词、短语或者句子进行补充说明,比如当文档中出现缩写词汇时,我们会在它的后面增加一个小括号,在括号里面注明该缩写词汇的全称。下面举一个使用小括号对缩写词汇解释说明的例子:

API(Application Program Interface)是系统对外提供的访问接口,使用者可以按照API文档中的接口定义去访问系统中的数据,并与它做一些交互。

上面这段话主要讲API是什么、可以干什么。它是Application Program Interface三个单词的简称,为了让读者更清楚该术语的定义,作者可以选择在第一个“API”出现的位置增加一个小括号,并将术语全称补充进来,之后的整个文档无需再重复该操作(后面会单独提到术语全称和简称的运用规则)。

除了能对缩写词汇进行解释说明之外,小括号还可以用于对前面整个句子进行补充说明,再看下面这个例子:

它是Application Program Interface三个单词的简称,为了让读者更清楚该术语的定义,作者可以选择在第一个“API”出现的位置增加一个小括号,并将术语全称补充进来,之后的整个文档无需再重复该操作(后面会单独提到术语全称和简称的运用规则)。

上面这段话其实前面已经出现过,最后小括号里面的内容主要是为了对它前面一句话进行补充。如果补充性说明内容太长,比如要好几句话才能起到补充的作用,那么这个时候我们就不应该再使用小括号了,可以考虑调整句子结构,然后将补充性的内容当作段落主体的一部分。

关于代词、过渡词以及标点符号滥用的内容就讲到这里,其中有一些内容是我个人的写作喜好,其实并没有非常明确的对错之分,比如前面讲到列表中分号的使用,很多人这时候可能选择使用句号。

大家可以根据自己的判断去处理这种模棱两可的场景,当然一些比较确定的规则,比如当列表项只有名词的时候,列表项结尾不要使用任何标点符号,这一点还是比较确定的。


 
3 
多用强势动词,少用形容词和副词


强势动词和主动语句

很多人可能第一次听到“强势动词”这个说法,陌生还难以理解。如果将它翻译成英文,对应的单词应该是“Strong Verbs”,意思是强有力的动词,你可以理解为:听起来动作幅度大、冲击力强的那一类动词。打个比方,假如“走”是弱势动词,那么“跳”就是强势动词;假如拿刀“切”是弱势动词,那么拿刀“砍”就是强势动词。下面这张表格列举了一些强势/弱势动词的例子:

序号
弱势动词
(可考虑)强势动词
1
走过去
跳过去
2
切肉
砍肉
3
出现异常
抛出异常
4
程序退出
程序崩溃
5内存增长
内存泄漏
6找不到日志文件
日志文件丢失
7客户提出质疑
客户投诉
8
任务未完成
任务延期
9角色权限是由管理员设置的
管理员控制角色权限
10
系统无法正常使用API返回的结果
系统无法正常解析API返回的结果

表3-1 强势/弱势动词对比

上面列出了10对强势/弱势动词,我们观察可以发现:弱势动词一般无法正确表达问题/事情的真实情况。在技术型文档编写过程中,虽然我们不能借助词汇使用、句子构成以及标点符号等手段去传递感情倾向,但是也不能掩盖真实准确的内容表达。

在提到强势动词时,我们还要注意“主动语句”和“被动语句”的区别。在技术型文档编写过程中,应该尽量少使用被动语句。下面这张表格列举了一些主动/被动语句的例子:

序号
被动语句
(可考虑)主动语句
1
角色权限是由管理员控制的
管理员控制角色权限
2
API结果无法被系统正常解析
系统无法正常解析API结果
3
图像特征是通过CNN逐步降维的方式提取的
CNN通过逐步降维的方式提取图像特征
4
这种检测效果无法被客户接受
客户无法接受这种检测效果
5
经过研发排查发现,这个现象是正常的(*)
经过研发排查发现,这个属于正常现象

表3-2 主动/被动语句对比

上面表中第5项(带*号)严格来讲不算被动语句,但是在技术型写作过程中,我们应该避免使用“……是……的。”这种句式,该句式太过口语化。尽量少用被动语句的原因有以下三个:

  1. 读起来麻烦。读者读到被动语句时,需要先在脑子里将其转换一下再去理解;

  2. 难以理解。读者有时候很难分清被动语句中的真实主语(甚至可能省略了主语);

  3. 字数多。被动语句一般更长、字数更多。

那么被动语句是不是完全不让用了呢?当然不是。仔细的读者可能已经观察到了前面在举例的时候我们有这样一段话:

C++语言<发明于>1980年代,它支持“指针”和“面向对象(Object-Oriented)”两个特性,C++的价值在计算机编程语言历史上数一数二。

上面第一句中的“……于”其实就是被动语句,像“C++语言发明于……”、“该文档编辑于……”这些都算被动语句,由于宾语(这里是C++语言)更重要,所以默认省略了真实主语(某某发明C++语言,可是某某在这里不太重要)。这类句子结构有一个特点就是:宾语比真实主语重要,所以放到句子的开头位置。

少用形容词和副词

技术型文档讲究的是一个“准”字,它不像小说、散文之类的文学作品带有很强的感情色彩,也不同于网络博客可以掺杂一些非正式词汇,更不能跟Marketing Speech(营销话语)一样常常夸大其词。为了做好前面说的“准”,技术型文档应该尽量少用形容词和副词,因为这些词语大部分都属于“主观”表达。下面举几个使用形容词和副词的例子:

为了保证系统运行更高效,他们尝试尽可能压缩图片尺寸,事实证明这个尝试非常成功。这样的工作看似简单,却蕴含着高技术含量。

上面这段话使用了好几个副词和形容词,比如“尽可能”、“非常”、“高”。如果是技术型文档,这段话建议调整为:

为了提高系统运行效率,他们将图片尺寸压缩到原来的1/3,系统响应速度提升2倍。

我们用具体的数值替换了原来的形容词和副词,并且直接删掉了最后一句话,最后一句话在技术型文档中起不到任何作用。下面这张表格列举了部分形容词和副词使用不恰当的场合:

序号
形容词/副词
(可考虑)调整为
1
经过优化,接口响应速度提升明显
经过优化,接口响应速度提升2倍
2
很多人反应现场误报很多
数据统计发现,现场误报率为11%
3
大部分客户投诉说系统很不好用
最近一个月有超过50个客户投诉说系统不好用
4
升级依赖库后,该函数运行很快
将依赖库升级到2.3.1版本后,该函数执行时间缩短到100ms以内
5
研发同事很辛苦,每天加班很晚
研发同事很辛苦,每天23:00之后才下班

表3-3 形容词/副词使用不恰当举例

最后,我们来总结一下:

  1. 优先使用方便读者阅读理解的动词和句式(强势动词和主动语句);

  2. 尽量少用形容词和副词,用具体数值代替、或者调整句子表述。


 
4 
正确使用术语


这里提到的术语分两种:一种是计算机领域通用的专业术语,像SDK、面向对象、TCP/IP、微服务等等这些名词,它们基本已经被大众接受和理解,我们在编写文档的时候不能随意再重新去命名、调整或者改变拼写(将“TCP/IP”写成“Tcp/ip”)。

另外一种是当前文档需要定义的术语,这种术语只有在当前文档上下文中才有效。我们在编写技术型文档时,通过自己的判断,如果认为文档读者缺乏对相关术语(不管是前面哪一种)的理解,我们都应该在文档靠前位置给出对术语的解释说明,也就是我们平时文档中常见的“名词解释”。

序号名词说明
1SDKSoftware Development Kit,软件开发包,开发者基于该工具包开发更丰富的高层应用。
2内存泄漏通过new/malloc等方法申请的内存在使用完后未被及时释放,程序运行内存占用越来越高。
3面向对象强调对数据和功能的封装,提升代码的可复用性、可扩展性以及灵活性。
4FVM(*)Front Video Manager,前端视频管理服务,负责视频接入、分发等业务。
5视频大数据标签服务(*)对视频进行结构化处理,生成结构化标签,并对外提供标签检索等功能。

表4-1 名词解释举例(*为自定义术语)

有些文档可能篇幅比较短,并不是传统意义上的需求设计类文档,比如对某个线上问题分析的结果汇报、对某个模型检测效果的验证报告、或者研发阶段性的工作总结。这些文档由于本身内容就不多,大部分可能直接进入主题,这时候如果还要在文档中专门增加一块名词解释的版块(并且总共也就一两个术语),就显得比较突兀。

另外一种对术语进行解释说明的方式是用我们前面提到的小括号,我们可以在术语后面增加一个小括号,然后在括号里添加补充说明。这种方式很便捷,但是只适合简单的场景,比如在小括号里面补充术语的全称或者简称,或者只做简单的解释说明。如果对一个术语的解释内容很长,就不太适合用这个方法,下面举一个错误的例子:

当视频离线时,FVM(Front Video Manager,前端视频管理服务,负责视频接入、分发等业务。)会产生一条告警记录,并存入节点数据库。

上面这个术语解释内容太长,不太适合使用小括号的方式,这种情况要么在文档正文中专门对FVM进行解释,要么在小括号中只给出FVM的英文全称即可:

当视频离线时,FVM(Front Video Manager)会产生一条告警记录,并存入节点数据库。

使用小括号去做术语解释还需要注意一点的是:只需要在术语第一次出现的时候做一次解释即可,不需要重复多次。下面举一个重复的错误例子:

当视频离线时,FVM(Front Video Manager)会产生一条告警记录,并存入节点数据库。之后节点数据库会将该条告警记录同步到平台数据库,平台FVM(Front Video Manager)检测到有新的告警记录时,会通过消息中间件通知业务系统,业务系统随后将告警信息以短信(或钉钉)的方式通知到用户。

上面对术语FVM的解释重复了两次,这种做法是错误的,第二次我们可以直接去掉。

有些术语存在全称和简称,我们熟悉的SDK全称是“Software Development Kit”,但是现在基本没有人再去使用它的全称。像这种简称已经被大众熟知的术语,我们就不能再标新立异的去用它的全称。

另外一些在文档中自定义的术语,文档作者为了便于阅读可能也会提供一个简写的版本,在这种情况下,文档前后应该保持一致,即:要么整篇文档都用全称,要么都用简称,尽量做到一致。下面举一个全称简称使用不一致的例子:

IVA(Intelligent Video Analytics,智能视频分析)服务主要负责视频解码、模型推理、目标跟踪以及目标行为分析,该服务是整个系统中最复杂的一个模块。智能视频分析服务由张三团队开发完成,一共耗时6个月,人力成本开销120万。

上面这段话中,前半部分作者使用“IVA”简称(小括号中做了全称说明),但是在后面一句话中作者又使用了全称“智能视频分析”,这种做法没有遵循统一原则。不仅同一段落应该保持统一,整篇文档也应该做到统一,术语在文档中第一次出现时是简称,那么整篇文档都应该用简称,反之亦然。

最后我们来总结一下,在技术型文档中使用术语时需要注意的一些事项:

  1. 文档读者不熟悉的术语(包括通用术语和文档自定义术语)都应该有解释说明;

  2. 小括号只适合简短的术语解释场合,括号里的内容不能太长(一两句短语之内);

  3. 任何方式的术语解释只需要有一次即可(术语第一次出现时),不要解释多次;

  4. 术语的全称和简称要保持使用一致,要么整篇文档都用全称、要么都用简称;

  5. 对于计算机领域的通用专业术语,需要沿用主流用法,不要随意再去调整。


 
5 
正确使用段落


单一职责

与面向对象编程中“类的单一职责原则”一样,文档中的句子(特指以句号结尾的一句话)、段落也应该遵循“单一职责原则”。前面讲标点符号的时候已经提到过,同一段话中前后关联性不大的两句话之间用句号,这样可以保证每句话想要表达的是相对独立的内容。

段落也一样,一个段落只陈述一个主题,可以保证段落的句子不会太多、内容不会太长,便于读者阅读和理解。下面举一个段落使用错误的例子:

Excel提供一个组织数据的高效方法。我们可以将Excel想象成一个有行和列的二维表格,每一行代表一个独立的实体,每一列代表该实体的不同属性。Excel还具备数学功能,比如计算平均值和方差等数学操作。如果你想使用Excel来记录图书信息,那么每一行代表不同的书本,每一列代表书本的属性,比如书的名称、价格以及出版社等等信息。

上面这段话的第一句已经明确了段落主题:Excel能高效地组织数据。可是,这段话中间却穿插了一个不相干的句子,说Excel具备数学功能,能够做一些数学操作,这句话显然跟本段主题不一致,我们需要将其去掉:

Excel提供一个组织数据的高效方法。我们可以将Excel想象成一个有行和列的二维表格,每一行代表一个独立的实体,每一列代表该实体的不同属性。如果你想使用Excel来记录图书信息,那么每一行代表不同的书本,每一列代表书本的属性,比如书的名称、价格以及出版社等等信息。

好的开头语

除了要保证段落的“单一职责”之外,我们还需要给每个段落一句“好的”开头语。那么什么是好的开头语呢?好的开头语要能让读者读完之后就能猜到文档作者在本段中想要陈述的主题,其实就是概括性的句子。

还是以上面那段话为例子,它的第一句话“Excel提供一个组织数据的高效方法”其实就是很好的开头语,它提示本段内容主要讲Excel如何高效地组织数据。如果我们将上面那段话的开头调整一下,那么效果明显就差了很多:

Excel由许许多多的单元格组成,每个单元格可以包含不同的内容。我们可以将Excel想象成一个有行和列的二维表格,每一行代表一个独立的实体,每一列代表该实体的不同属性。如果你想使用Excel来记录图书信息,那么每一行代表不同的书本,每一列代表书本的属性,比如书的名称、价格以及出版社等等信息。

读者读完上面第一句话后,可能还是很懵,需要读完整段话才能明白文档作者在本段中想要表达的意思。段落的开头语可以通过提炼段落内容得到,我们可以在段落写完之后回过头提炼一句话作为本段的开头语,下面这段话描述代码中循环语句的作用:

目前几乎所有的计算机编程语言都支持循环语句,例如,我们可以编写代码来判断一个用户命令行输入是否等于“quit”(退出命令),如果需要判断100万次,那就创建一个循环,让判断逻辑代码运行100万次。

上面的这段话本身没什么问题,主要介绍循环语句的功能和应用场合。但是如果我们提炼一下,在段落开头增加一个更好的开头语,效果可能会提升很多:

循环语句会多次运行同一个代码块,直到不再满足循环条件为止。目前几乎所有的计算机编程语言都支持循环语句,例如,我们可以编写代码来判断一个用户命令行输入是否等于“quit”(退出命令),如果需要判断100万次,那就创建一个循环,让判断逻辑代码运行100万次。

上面开头第一句话就说清楚了循环结构的特点,读者读完第一句话基本就知道整段内容要讲什么。一个好的开头语能够节省读者的时间,因为并不是每个读者都有兴趣去阅读整段的内容,开头语可以给读者“是否继续读下去”一个参考。

控制段落长度

控制段落长度并没有一个明确的标准,它只是一个非常主观的说法。如果文档中某个段落内容太长(比如那种一段话就占半页Word),作者自己应该反复阅读几次再对段落做一些精简,这样既可以节省读者的时间,大概率也能提升意思表达的准确性。

同样,也不太建议文档频繁出现小段落,比如整段内容只有一两句话那种,这个时候可以考虑段落合并或者稍微扩充一下内容。

最后我们来总结一下,在技术型文档中如何正确使用段落:

  1. 一个段落只负责讲一个内容,两个不同的主题应该拆分成两个段落去陈述;

  2. 尽量为每个段落增加一个“好的”开头语,能够清晰表达(或暗示)本段的主题;

  3. 要控制好段落内容长短,“不长不短”根据自己经验(比如不超5~7个句子)。


 
6 
适当使用列表和表格


文字相对来讲其实是一种效率比较低的表达方式。如果你想让人快速地去理解你要表达的意思,图片应该是最好的一种方式,但是图片有一个缺点就是:有时候它只能从宏观上去表达,无法体现其中细节。

当我们想要尽可能直观地去陈述内容,又想尽可能多的包含细节时,我们可以考虑使用列表或者表格。有些读者非常抵触大段大段的文字(尤其在技术型文档中),一种改进方法是前面提到的“控制段落长度”,尽量让段落内容精简、单一;再一个就是看看段落内容是否能以列表或者表格的方式去呈现,这种方式可以给人“严谨、清晰”的感觉。

使用列表

列表简单来讲就是将你原来用段落方式呈现的内容改用项目(Item)的方式去呈现,一般它主要用于枚举、过程描述或者要点归纳等场合。列表中的各项可以是名词、短语,甚至是句子,各项目之间有严格顺序要求的列表叫“有序列表”,相反并没有严格顺序要求的列表叫“无序列表”。下面是以段落的方式陈述小张今天所做的事情:

白天在公司上班期间,小张一共修复了7个bug,做了3个代码合并(评审),并和项目经理讨论了前天提的新需求。晚上回到家后,小张先做饭,然后给儿子洗澡,23:30上床睡觉。

上面这段话本身没什么问题,用了合理的标点符号和过渡词,读起来清晰明了。但是,如果在技术型文档编写中,能将这段话改用列表的方式呈现,起到的效果会更好:

张白天在公司:

  • 修复了7个bug;

  • 做了3个代码合并(评审);

  • 和项目经理讨论前天提的新需求。

晚上回到家后:

  1. 做晚饭;

  2. 给儿子洗澡;

  3. 23:30上床睡觉。

我们将原来的一段话拆成了两个列表,并在每个列表前面做了一个“引入说明”(以冒号结束),介绍了接下来列表的背景上下文。第一个列表是无序列表,因为原文并没有突出强调小张白天在公司每项工作之间的前后关系(无顺序要求),只是一个归纳统计;第二个列表是一个有序列表,原文很明显强调了小张晚上回家之后做事的先后顺序(最后一项还给出了具体时间)。

在技术型文档中,合理地运用列表这种方式去呈现内容可以给人一种“逻辑严谨、思路清晰”的感觉,让读者更相信你讲的内容。

在使用列表时,我们应该确保列表中各项内容结构一致,即:要么都是名词,要么都是短语,要么都是句子。这个原则既能保证你使用列表的初衷(逻辑严谨、思路清晰),也能让读者读起来更舒服。下面是一个错误使用列表的示范:

影响系统检测准确性的因素有:

  • 模型;

  • 产品开通过程中,工程师对算法参数校准程度;

  • 应用现场是否有灯光照明。

上面列表一共包含3项,每项的内容结构各不相同,第一项是一个名词,第二项是一个句子,第三项是一个短语。我们将结构统一后,可以调整为下面这样:

影响系统检测准确性的因素有:

  • 模型的复杂性;

  • 部署时对算法参数校准的程度;

  • 应用现场是否有灯光照明。

上面是将列表中各项内容修改为短语,我们还可以换另外一种方式:

影响系统检测准确性的因素有:

  • 模型类型

  • 校准程度

  • 环境亮度

上面是将列表中各项内容修改为名词,由于是名词,每项结尾处不使用任何标点符号(参见前面专门讲标点符号的章节)。下面是对列表运用的总结:

  1. 列表一般用于枚举、过程描述、要点归纳等场合;

  2. 需要强调顺序的时候应该使用有序列表,其余视情况而定;

  3. 列表中各项内容结构应保持一致,都是名词、短语或者句子;

  4. 每个列表前面尽量添加一个明确的“引入说明”,以冒号结束。

使用表格

表格其实跟面向对象有一定联系,大部分时候表格中的一行相当于一个对象,表格中的列相当于对象的属性(字段),表格和面向对象组织数据的方式本质上是一致的。技术型文档中表格一般用来组织与数字有关的内容,当然也有例外,就像前面章节中用到的表格,纯粹是为了组织文本内容。

下面是在技术型文档中,使用表格时可以参考的一些经验:

  1. 组织数字相关内容时,一定要用表格(大部分人可能已经有这个意识);

  2. 组织结构化类型的文本内容时,尽量用表格;

  3. 每个表格都应该配一个表格标题,简要说明表格内容;

  4. 文档中的表格应具备一致的样式和风格,比如标题字体、背景填充等。

在技术型文档中使用表格组织文本内容时,需要控制每个单元格的文本长度。一般情况下建议单元格中只使用短语,如果必须要用段落,也应该控制段落中句子数量(一般建议不超过2~3句)。下面是错误使用表格来组织文本内容的示范:

序号
语言
介绍
1
C
C语言由贝尔实验室发明于1969至1973年,是一种编译型计算机编程语言。它运行速度快、效率高、使用灵活,常被用于计算机底层驱动以及各种语言编译器的开发。C语言是一种面向过程的编程语言,同时它的语法相对来讲较复杂,新人入门门槛比较高。
2
C++
C++语言发明于1979年,是一种编译型计算机编程语言。它衍生自C语言,继承了C语言的一些特性,比如使用指针直接访问内存,同时它也支持面向对象编程,提升了代码的可复用性、可扩展性以及灵活性。由于C++继承了C的大部分语法,再加上本身具备复杂的类型系统以及泛型编程等语言特性,新人入门门槛也比较高。
3
Python
Python语言发明于1991年,是一种解释型计算机编程语言,因此运行速度相对要慢。Python除了支持面向对象编程之外,还支持函数式编程,它语法简单,更贴近人类自然语言,新人入门门槛较低。Python是目前人工智能领域最热门的语言,对应的工具库非常丰富。

表6-1 三种编程语言介绍

上面是以表格的形式来介绍C、C++以及Python三种编程语言,但是在“介绍”那一列中的文本内容太长,我们可以换一种表达方式:

C
C++
Python
由AT&T 贝尔实验室发明于1969至1973年
由BJarne Struistrup发明于1979年
由Guido van Rossum发明于1991年
语法比较复杂,新人入门门槛高
语法比较复杂,新人入门门槛较高
语法简单,贴近人类自然语言,新人入门门槛低
编译型语言
编译型语言
解释型语言
支持面向过程编程
支持面向过程、面向对象编程
支持面向过程、面向对象、函数式编程
偏底层、运行速度快、使用灵活
继承了C语言的一些特性,在其基础之上还支持面向对象等特性
语法简单,学习难度低
一般用于驱动、编译器、嵌入式或者其他偏向硬件层面的开发
一般用于游戏前后端、PC客户端的开发
一般用于数据科学、人工智能相关开发

表6-2 C vs C++ vs Python

上面表格一共还是3列,但是现在每列代表一种编程语言,列中的每个单元格是对该语言的描述,描述内容都比较精简。如果你想继续补充内容,可以对应地增加行即可。

表格的组织方式有多种多样,行可以变成列、列可以变成行,并没有严格的限制。我们只需要找一个适合自己的方式,比如上面这种每列代表一种语言,是因为该场景需要介绍的编程语言只有三种,如果数量再多点(或者数量不确定,后期会继续增加),那么表格宽度就不太够、这种组织方式就不再合适。


 
7 
一图胜千言


人类在发明文字媒介之前,用的是图形符号。图像(或图形、图片)是所有内容表达方式中最直观的一种,同时也能提升读者的阅读兴趣。有人专门做过研究:在文档中增加图像能提升读者对文档的喜爱程度,不管这个图像跟文档内容本身是否有关系(https://reurl.cc/RjkrK6)。

也就是说,哪怕在文档中插入无关紧要的图像,读者也更愿意去尝试阅读文档中其他的内容。我们平时看别人演示PPT时,如果发现整页都是文字描述,大概率就不会有认真去听的欲望。下面是一段对双向链表的文字描述:

双向链表也叫双链表,是链表的一种。它的每个数据节点中都有两个指针,分别指向直接后继节点和直接前驱节点。所以,从双向链表中的任意一个节点开始,我们都可以很方便地访问它的前驱节点和后继节点。在应用双向链表时,我们一般构造双向循环链表,链表首尾相连。

上面这段描述双向链表的文字本身已经非常清晰,对数据结构有一定基础的人看完文字基本就能理解双向链表的结构和应用场合(基于它的特点)。但是,如果是一个零基础的小白来看这段话,可能效果就不会太好(尤其如果这段话是作为PPT中的内容,大概不会再有更多的内容补充)。如果我们在这段话后面增加一个插图,来直观告诉读者双向链表长什么样:

双向链表也叫双链表,是链表的一种。它的每个数据节点中都有两个指针,分别指向直接后继节点和直接前驱节点。所以,从双向链表中的任意一个节点开始,我们都可以很方便地访问它的前驱节点和后继节点。在应用双向链表时,我们一般构造双向循环链表,链表首尾相连。下图是双向链表结构示意图:


图1 双向链表结构

上面的文本配合图片,能让读者更加直观的理解双向链表的结构特点。当文档中的文本和图片同时出现时,读者大概率会先看图片,然后再结合文字去理解,加快文档阅读速度。

可抽象也可具体

技术型文档中的插图不一定都得是流程图、架构图、或者结构设计图这种非常具体的技术相关图片,还可以是抽象的、能形象表达文档主题的图片。下面是在技术型文档中使用卡通和漫画图片的示例:

示例1:

Gitlab中有Label和Tag两个概念。


为了便于区分,这里将Label翻译成“标签”,将Tag翻译成“标记”(在有些地方这两个单词翻译并没有严格的差异)。Gitlab中标签的作用是为了分类、快速地检索和过滤,用户能通过标签来直观的管理Issues,比如to-do、bug等等。

标记的主要作用是为了归档,给Commit取一个形象的别名,后期快速定位和查找。GitLab中创建标记可以理解为“做记号”,建立索引。一般推荐为标记定义一个有意义的名称,比如以版本号为名,当我们要发布1.0版本,对应的标记名称可以是“v1.0”,如果我们要发布2.0预览版,那么对应的标记名称可以是“2.0-pre”。

示例2:

源码版本控制系统(Source Code Version Control System)主要负责对源代码版本进行管理,涉及到代码提交、撤销、比对、分支管理、代码合并等功能。源码管理是软件开发过程中非常重要的一个环节,它能有效保证软件代码质量。


图1 团队协作

源码管理并不是软件开发周期的全部,整个软件开发周期涉及到多个流程、多个团队(多人)协作完成,包括立项/结项、进度/任务管理、需求/设计、bug管理、测试、集成上线等环节。

突出图中重点

当我们想为文档添加图片时,单张图片包含的内容不宜太过复杂,图片应该能准确地表达意思。如果一张图太过复杂、或者包含了一些可能引起歧义的部分,我们可以尝试以下两种改进方式:

  1. 将复杂的图拆开,一张图对应一个局部细节;

  2. 在图片中将重点区域标记出来,让读者可以一眼就发现重点。

在技术型文档中插入复杂的系统架构图很常见,这种时候建议遵循“先宏观,再具体”的原则,循序渐进。我们不要一上来就放一张大图,还想将所有的细节都包含进去,这种想法不太现实,这不仅对你画图的技能要求很高,读者看完也容易一脸懵。下面这张图太过复杂:

整个视频分析系统由3大服务组成,分别是Intelligent Video Analytics、Front Video Service以及Distribute Load Balance,这3大服务一共包含15个子模块。下面是视频分析系统结构:


图1 视频分析系统结构

上面这个例子中插入的这张图既想描述3大服务之间的交互关系、又想描述各个服务内部子模块之间的交互关系(上面只是示意图,实际情况可能比这个更复杂)。文档读者碰到这种情况可能会产生两个感觉:一是图太复杂了,很难看懂,有些地方迫于空间原因字号还小;二是我需要重点关注的点在哪里?如果遵循前面提到的“先宏观,再具体”的原则,上面这个例子可以调整为:

整个视频分析系统由3大服务组成,分别是Intelligent Video Analytics、Front Video Service以及Distribute Load Balance。下面是视频分析系统中各服务之间的关系:


图1 视频分析系统服务交互

其中,Intelligent Video Analytics服务主要负责对视频解码、推理以及行为分析等结构化操作。该服务内部一共包含9个子模块,模块之间的关系见下图:


图2 Intelligent Video Analytics服务子模块交互

Front Video Service服务主要负责视频接入、分发、配置管理等功能。该服务内部一共包含3个子模块……

另外一种情况,插入的图片中包含了不相干内容,文档作者又没有给出醒目的标记,读者看完不清楚关注重点在哪里。下面是错误的示例:

GitLab中的Release功能主要用来对仓库中的代码以及其他一些相关资料文件进行归档,通常用于版本发布。当有新版本发布时,用户可以基于对应的Commit创建一个Tag标记,给它一个合理的名字,比如“v1.0-pre”(代表发布1.0预览版),然后再基于该Tag发布版本。

后期,其他人可以通过Release菜单快速浏览、检索项目版本发布记录以及对应时间点的相关代码和资料。用户可以在GitLab主界面的左侧菜单中找到Release功能入口:


图1 Gitlab中Release菜单

上面图片在介绍Release功能时给出的图片中包含的菜单项太多,为了让读者更直观看懂图片关注点,可以将图片调整如下(左右两种都可以):

GitLab中的Release功能主要用来对仓库中的代码以及其他一些相关资料文件进行归档,通常用于版本发布。当有新版本发布时,用户可以基于对应的Commit创建一个Tag标记,给它一个合理的名字,比如“v1.0-pre”(代表发布1.0预览版),然后再基于该Tag发布版本。

后期,其他人可以通过Release菜单快速浏览、检索项目版本发布记录以及对应时间点的相关代码和资料。用户可以在Gitlab主界面的左侧菜单中找到Release功能入口:


图1 Gitlab中Release菜单

有准确的图标题

图片是为了读者能够更直观地理解文档内容,但是图片毕竟不是文字,不同的人对同一张图片理解可能存在差异,尤其对于那种不包含任何文字的图片。因此,在文档中插入任何图片时,我们应该为它定义一个合适、贴切的标题。图标题一般是一个名词或者短语,作用跟前面讲到的表格标题一样,协助读者理解图片所要表达的含义。


 
8 
统一样式和风格


文档的样式和风格其实跟我们写代码一样,写代码要遵守统一的代码风格(变量命名、换行规则等等),写文档也应该遵守统一的文档风格。公司或者组织一般都有自己的文档风格规范,规范会定义好正文/标题字体字号、页眉页脚、页边距、行间距、段前段后间距等等,按照规范写出来的文档风格基本就能保持一致。

对于没有规范可用的场合,文档作者可以根据自己的偏好执行即可,保证整篇文档的内容遵守相同的风格,比如文档开头和文档结尾的段落间距、列表样式、对齐方式都应该保持一致。本篇文档的主要规范定义如下:

  1. 页边距上下左右2cm;

  2. 标题18号华文仿宋,正文12号宋体,正文中表格/图标题12号华文仿宋;

  3. 段前/段后间距0.5,段落行间距1.5倍,段落首行对齐不空格;

  4. 表格、图片居中对齐,图标题在图片下方、表格标题在表格上方。

还有另外一些比较重要的样式定义,比如列表样式(本篇文档中每个列表外面套了一个表格,表格无左右边框),还比如本篇文档涉及到了很多举例和示范,所有的举例示范都在表格中,并且表格有自己的样式(字体字号、背景颜色等等)。


 
9 
把握好整体文档结构


把握好整体文档结构是一件非常困难的事情,这个其实跟前面讲到的文档内容本身没什么关系。文档作者在动笔之前需要有一个宏观的构思,需要在脑子里先将文档大纲梳理一遍,一级标题可以是什么、二级标题又可以是什么,然后考虑将合适的内容放到对应的章节中去。

优秀的作者在正式动手之前,可能已经有了很长一段时间的思考准备,尤其对于那种非常复杂的文档。但是这种方式对一些人来讲可能不太现实,难度太大。那么这时候就只能考虑另外一种方式,动手之前先在白纸上打草稿,列出来文档大纲,然后不断修改和调整,直到满意为止。

其实不管上面哪种方式,文档结构考验的是作者组织内容的思维能力。对于一些需求、设计类型的“主流”技术型文档,考验的是作者对软件需求、系统架构的理解深度,该写什么不该写什么,写到什么程度,这些都需要作者考虑清楚,这类型的文档一般有标准的模板可以参考,大家平时写得/见得也比较多。

对于另外一些“非主流”类型的技术型文档,比如对某个线上问题的分析报告、技术/原型调研类文档,这些文档一般规模比较小、也没什么参考标准,全靠作者自己去组织。

下面就以“对某个用户需求做技术性反馈”为例,抛砖引玉,简单描述一下技术型文档结构应该如何去组织:

场景说明:

视频分析系统中,客户要求在事件录像文件中对涉事车辆目标(或区域)进行高亮标框显示,视频录像在播放时会有一个醒目的多边形提醒用户具体事件发生位置。客户懂一些技术相关知识,要求公司技术研发团队针对该需求给出合理的需求反馈,如果需求可实现,评估工作难度;如果需求不可实现,说明具体原因。

根据上面场景说明,该需求并非硬性要求。甲方提出了一个想法,并且非常贴心地考虑到了乙方是否具备条件实现,希望给出一个实质性的答复。公司技术团队在写反馈说明文档之前,应该考虑以下两个问题:

  1. 如果正常响应该需求,具体的方案是什么、难点/风险点各是什么;

  2. 如果不能正常响应该需求,具体原因是什么,是否有可替代方案、替代方案是什么。

也就是说,不管最终团队是否响应该需求,我们在文档中都要有非常实质性的内容,不应该是空话、套话。下面就以“不响应”为例,描述文档应该包含哪些内容:

序号
节标题名称
主要内容
1
背景说明
用自己的话将客户的需求完整描述一遍,不要有任何偏差,表明我方已认真理解过原始需求。
2
已有录像逻辑
详细描述系统中目前已有的事件录像逻辑。因为我们本次是不响应该需求,所以对后面不响应有利的内容一定要着重强调(要突出已有录像逻辑的优势)。
3
录像标框逻辑
详细描述在事件录像文件中对涉事目标(或区域)进行高亮标框的逻辑。注意这里按照理想逻辑去描述,不用考虑任何外在限制。
4
录像标框难点
结合第3点,重点归纳、整理出在录像文件中标框的难点,比如需要对每一路进行解码再去叠加图形、视频画面不能压缩否则影响分辨率等等,这些对设备性能要求非常高,会增加硬件成本。
5
解决方案一 (不计代价去响应)
按照理想逻辑去响应,但是要提出前提条件或者代价,比如单台设备分析路数降低到原来的一半,硬件成本是原来的2本。(其实就是要排除这个方案)
6
解决方案二 (可替代方案)
提出一种可替代的方案,可以满足客户最开始提出的“有醒目标记提醒用户”。比如当视频录像播放时,可以在播放器上面叠加一个高亮方框,能够大概标记涉事车辆目标(或区域)。同时,强调该方案的优势(比如工作周期短、对成本无影响)。
7
结论
其实根据前面的描述,只要认真读完文档的人基本都能知道结论是什么、应该选哪个方案。但是这里还是要书面写上,根据前面的描述,解决方案二有更大的优势,建议采用方案二。

需要注意的是,“响应”或者“不响应”的决定很多时候不在技术团队或者写这个文档的人手里。虽然文档中的内容应该为最终的结论服务,但是总体上不应该有偏差。


 
10 
明确文档的目标群体


文档的目标群体是谁?这个其实应该是写文档最开始就需要明确的东西,面对不同的群体,我们文档的内容、结构包括内容描述程度都会不同。尽早确定读者有助于在构思阶段就明确文档内容边界,哪些该写、哪些不该写,该写的又应该如何去写,这些都是编写文档的大方向。

作者:周智,前微软(中国)Windows工程院员工,目前从事于深度学习计算机视觉相关工作,交通安防领域的视频目标检测、跟踪和行为分析。

收起阅读 »

不幸言中,“核酸码”打不开.....那就聊聊为什么我觉得要挂的原因吧!

周四晚上的时候,看到消息说4月9日起要采用新的核酸检查系统,要推出一个新的码,叫:核酸码。当晚就有很多网友发现随申办上已经有入口了,但点进去是报错的:但是因为还没投入真正使用,所以也没啥大的反馈,大家就瞎讨论了技术栈和这个错误可能的原因啥的。我也顺带瞎扯了一句...
继续阅读 »

周四晚上的时候,看到消息说4月9日起要采用新的核酸检查系统,要推出一个新的码,叫:核酸码

当晚就有很多网友发现随申办上已经有入口了,但点进去是报错的:

但是因为还没投入真正使用,所以也没啥大的反馈,大家就瞎讨论了技术栈和这个错误可能的原因啥的。

我也顺带瞎扯了一句:可能会出性能问题(因为我一直觉得国内擅长Hibernate的开发者比较少)


谁想到,今天在获取核酸码的时候真的碰到各种困难,在获取核酸码的时候,就一直刷不出来,有时候显示人多,有时候504错误:

上面我是12点尝试的,后来16、17点还看到很多朋友圈反应各种卡住,刷不出来。


可能这个系统确实太赶了,所以没做好?不过这个谁知道呢?作为一名技术博主就不瞎猜了。

顺手分享一下为什么我觉得用spring data jpa,很可能会挂?

先说说常规国内用的比较多的技术MyBatis,因为大家都是用直接写SQL的方式来实现数据读写的,这个时候团队里DBA、数据库专家、或者实力强点的开发,往往自己已经能够把SQL执行优化到比较好的地步了。这个是否能做好,与我们对SQL、Java这些知识的掌握程度有关

而当我们用Spring Data JPA这样的框架时候,开发者在框架的帮助下,好多SQL都被隐藏了,喜欢些Java代码来替代SQL的开发过程是挺爽的,但也因为这个原因,他可能并不知道最终自己写的代码真正会执行的SQL具体是怎么样的。

这的时候对于优化就带了很大的难度,对于专业DBA来说,他一般都是不具备Spring Data JPA代码到SQL转化的认识,他是很难帮你做静态分析的。而开发者一侧也有这个问题,如果不是很熟悉Hibernate的话,就很容易写出低性能的代码(不代表框架实现的低性能,核心还是使用姿势的问题)。

所以,我一直建议在高并发系统中对数据访问框架的选型一定要慎重,不是说Spring Data JPA不行,而是需要有熟悉的人来把握(特别提这点的原因是国好多是半调子)。不然就比较容易出现性能问题,但是MyBatis的话,对于国内开发者来说,因为直接写SQL,所以还是相对还是更容易理解和把控一些。

好了,借今天核酸码的现象,跟大家聊聊这两个框架的想法,不知道你是否认同?欢迎留言区说说你的观点。

来源:https://mp.weixin.qq.com/s/43bE8juIRQbQLO3vBTUKWA

收起阅读 »

面试官:知道 Flutter 生命周期?下周来入职!

作为一名移动端开发工程师,刚接触 Flutter 的时候,一定会有这样的疑问:Flutter 的生命周期是怎么样的?是如何处理生命周期的?我的 onCreate()[Android] 在哪里?viewDidLoad()[iOS] 呢? 我的业务逻辑应该放在哪里...
继续阅读 »

作为一名移动端开发工程师,刚接触 Flutter 的时候,一定会有这样的疑问:Flutter 的生命周期是怎么样的?是如何处理生命周期的?我的 onCreate()[Android] 在哪里?viewDidLoad()[iOS] 呢? 我的业务逻辑应该放在哪里处理?初始化数据呢?希望看了这篇文章后,可以对你有一点小小的帮助。


安卓


如果你是一名安卓开发工程师,那么对于 Activity 生命周期肯定不陌生



  • onCreate

  • onStart

  • onResume

  • onPause

  • onStop

  • onDestroy


android_life_cycle


iOS


如果你是一名 iOS 开发工程师,那么 UIViewController 的生命周期肯定也已经很了解了。



  • viewDidLoad

  • viewWillAppear

  • viewDidAppear

  • viewWillDisappear

  • viewDidDisappear

  • viewDidUnload


ios_life_cycle


Flutter


知道了 Android 和 iOS 的生命周期,那么 Flutter 呢?有和移动端对应的生命周期函数么?如果之前你对 Flutter 有一点点了解的话,你会发现 Flutter 中有两个主要的 Widget:StatelessWidget(无状态)StatefulWidget(有状态)。本篇文章我们主要来介绍下 StatefulWidget,因为它有着和 Android 和 iOS 相似的生命周期。


StatelessWidget


无状态组件是不可变的,这意味着它们的属性不能变化,所有的值都是最终的。可以理解为将外部传入的数据转化为界面展示的内容,只会渲染一次。
对于无状态组件生命周期只有 build 这个过程。无状态组件的构建方法通常只在三种情况下会被调用:小组件第一次被插入树中,小组件的父组件改变其配置,以及它所依赖的 InheritedWidget 发生变化时。


StatefulWidget


有状态组件持有的状态可能在 Widget 生命周期中发生变化,是定义交互逻辑和业务逻辑。可以理解为具有动态可交互的内容界面,会根据数据的变化进行多次渲染。实现一个 StatefulWidget 至少需要两个类:



  • 一个是 StatefulWidget 类。

  • 另一个是 Sate 类。StatefulWidget 类本身是不可变的,但是 State 类在 Widget 生命周期中始终存在。StatefulWidget 将其可变的状态存储在由 createState 方法创建的 State 对象中,或者存储在该 State 订阅的对象中。


StatefulWidget 生命周期



  • createState:该函数为 StatefulWidget 中创建 State 的方法,当 StatefulWidget 被创建时会立即执行 createState。createState 函数执行完毕后表示当前组件已经在 Widget 树中,此时有一个非常重要的属性 mounted 被置为 true。

  • initState:该函数为 State 初始化调用,只会被调用一次,因此,通常会在该回调中做一些一次性的操作,如执行 State 各变量的初始赋值、订阅子树的事件通知、与服务端交互,获取服务端数据后调用 setState 来设置 State。

  • didChangeDependencies:该函数是在该组件依赖的 State 发生变化时会被调用。这里说的 State 为全局 State,例如系统语言 Locale 或者应用主题等,Flutter 框架会通知 widget 调用此回调。类似于前端 Redux 存储的 State。该方法调用后,组件的状态变为 dirty,立即调用 build 方法。

  • build:主要是返回需要渲染的 Widget,由于 build 会被调用多次,因此在该函数中只能做返回 Widget 相关逻辑,避免因为执行多次而导致状态异常。

  • reassemble:主要在开发阶段使用,在 debug 模式下,每次热重载都会调用该函数,因此在 debug 阶段可以在此期间增加一些 debug 代码,来检查代码问题。此回调在 release 模式下永远不会被调用。

  • didUpdateWidget:该函数主要是在组件重新构建,比如说热重载,父组件发生 build 的情况下,子组件该方法才会被调用,其次该方法调用之后一定会再调用本组件中的 build 方法。

  • deactivate:在组件被移除节点后会被调用,如果该组件被移除节点,然后未被插入到其他节点时,则会继续调用 dispose 永久移除。

  • dispose:永久移除组件,并释放组件资源。调用完 dispose 后,mounted 属性被设置为 false,也代表组件生命周期的结束。


不是生命周期但是却非常重要的几个概念


下面这些并不是生命周期的一部分,但是在生命周期中起到了很重要的作用。



  • mounted:是 State 中的一个重要属性,相当于一个标识,用来表示当前组件是否在树中。在 createState 后 initState 前,mounted 会被置为 true,表示当前组件已经在树中。调用 dispose 时,mounted 被置为 false,表示当前组件不在树中。

  • dirty:表示当前组件为脏状态,下一帧时将会执行 build 函数,调用 setState 方法或者执行 didUpdateWidget 方法后,组件的状态为 dirty。

  • clean:与 dirty 相对应,clean 表示组件当前的状态为干净状态,clean 状态下组件不会执行 build 函数。


stateful_widget_lifecycle 生命周期流程图


上图为 flutter 生命周期流程图


大致分为四个阶段



  1. 初始化阶段,包括两个生命周期函数 createState 和 initState;

  2. 组件创建阶段,包括 didChangeDependencies 和 build;

  3. 触发组件多次 build ,这个阶段有可能是因为 didChangeDependencies、 setState 或者 didUpdateWidget 而引发的组件重新 build ,在组件运行过程中会多次触发,这也是优化过程中需要着重注意的点;

  4. 最后是组件销毁阶段,deactivate 和 dispose。


组件首次加载执行过程


首先我们来实现下面这段代码(类似于 flutter 自己的计数器项目),康康组件首次创建是否按照上述流程图中的顺序来执行的。



  1. 创建一个 flutter 项目;

  2. 创建 count_widget.dart 中添加以下代码;


import 'package:flutter/material.dart';

class CountWidget extends StatefulWidget {
CountWidget({Key key}) : super(key: key);

@override
_CountWidgetState createState() {
print('count createState');
return _CountWidgetState();
}
}

class _CountWidgetState extends State<CountWidget> {
int _count = 0;
void _incrementCounter() {
setState(() {
print('count setState');
_count++;
});
}

@override
void initState() {
print('count initState');
super.initState();
}

@override
void didChangeDependencies() {
print('count didChangeDependencies');
super.didChangeDependencies();
}

@override
void didUpdateWidget(CountWidget oldWidget) {
print('count didUpdateWidget');
super.didUpdateWidget(oldWidget);
}

@override
void deactivate() {
print('count deactivate');
super.deactivate();
}

@override
void dispose() {
print('count dispose');
super.dispose();
}

@override
void reassemble() {
print('count reassemble');
super.reassemble();
}

@override
Widget build(BuildContext context) {
print('count build');
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
'$_count',
style: Theme.of(context).textTheme.headline4,
),
Padding(
padding: EdgeInsets.only(top: 100),
child: IconButton(
icon: Icon(
Icons.add,
size: 30,
),
onPressed: _incrementCounter,
),
),
],
),
);
}
}

上述代码把 StatefulWidget 的一些生命周期都进行了重写,并且在执行中都打印了标识,方便看到函数的执行顺序。



  1. 在 main.dart 中加载该组件。代码如下:


import 'package:flutter/material.dart';

import './pages/count_widget.dart';

class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
visualDensity: VisualDensity.adaptivePlatformDensity,
),
home: MyHomePage(title: 'Flutter Demo Home Page'),
);
}
}

class MyHomePage extends StatefulWidget {
MyHomePage({Key key, this.title}) : super(key: key);

final String title;

@override
_MyHomePageState createState() {
return _MyHomePageState();
}
}

class _MyHomePageState extends State<MyHomePage> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: CountWidget(),
);
}
}

这时 CountWidget 作为 MyHomePage 的子组件。我们打开模拟器,开始运行。在控制台可以看到如下日志,可以看出 StatefulWidget 在第一次被创建的时候是调用下面四个函数。


flutter: count createState
flutter: count initState
flutter: count didChangeDependencies
flutter: count build

点击屏幕上的 ➕ 按钮,_count 增加 1,模拟器上的数字由 0 变为 1,日志如下。也就是说在状态发生变化的时候,会调用 setStatebuild 两个函数。


flutter: count setState
flutter: count build

command + s 热重载后,日志如下:


flutter: count reassemble
flutter: count didUpdateWidget
flutter: count build

注释掉 main.dart 中的 CountWidget,command + s 热重载后,这时 CountWidget 消失在模拟器上,日志如下:


class _MyHomePageState extends State<MyHomePage> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
// body: CountWidget(),
);
}
}

flutter: count reassemble
flutter: count deactivate
flutter: count dispose

经过上述一系列操作之后,通过日志打印并结合生命周期流程图,我们可以很清晰的看出各生命周期函数的作用以及理解生命周期的几个阶段。
相信很多细心的同学已经发现了一个细节,那就是 build 方法在不同的操作中都被调用了,下面我们来介绍什么情况下会触发组件再次 build。


触发组件再次 build


触发组件再次 build 的方式有三种,分别是 setStatedidChangeDependenciesdidUpdateWidget


1.setState 很好理解,只要组件状态发生变化时,就会触发组件 build。在上述的操作过程中,点击 ➕ 按钮,_count 会加 1,结果如下图:


set_state


2.didChangeDependencies,组件依赖的全局 state 发生了变化时,也会调用 build。例如系统语言等、主题色等。


3.didUpdateWidget,我们以下方代码为例。在 main.dart 中,同样的重写生命周期函数,并打印。在 CountWidget 外包一层 Column ,并创建同级的 RaisedButton 做为父 Widget 中的计数器。


class MyHomePage extends StatefulWidget {
MyHomePage({Key key, this.title}) : super(key: key);

final String title;

@override
_MyHomePageState createState() {
print('main createState');
return _MyHomePageState();
}
}

class _MyHomePageState extends State<MyHomePage> {
int mainCount = 0;

void _changeMainCount() {
setState(() {
print('main setState');
mainCount++;
});
}

@override
void initState() {
print('main initState');
super.initState();
}

@override
void didChangeDependencies() {
print('main didChangeDependencies');
super.didChangeDependencies();
}

@override
void didUpdateWidget(MyHomePage oldWidget) {
print('main didUpdateWidget');
super.didUpdateWidget(oldWidget);
}

@override
void deactivate() {
print('main deactivate');
super.deactivate();
}

@override
void dispose() {
print('main dispose');
super.dispose();
}

@override
void reassemble() {
print('main reassemble');
super.reassemble();
}

@override
Widget build(BuildContext context) {
print('main build');
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Column(
children: <Widget>[
RaisedButton(
onPressed: () => _changeMainCount(),
child: Text('mainCount = $mainCount'),
),
CountWidget(),
],
),
);
}
}

重新加载 app,可以看到打印日志如下:


father_widget_create_state


flutter: main createState
flutter: main initState
flutter: main didChangeDependencies
flutter: main build
flutter: count createState
flutter: count initState
flutter: count didChangeDependencies
flutter: count build

可以发现:



  • 父组件也经历了 createStateinitStatedidChangeDependenciesbuild 这四个过程。

  • 并且父组件要在 build 之后才会创建子组件。


点击 MyHomePage(父组件)的 mainCount 按钮 ,打印如下:


flutter: main setState
flutter: main build
flutter: count didUpdateWidget
flutter: count build

点击 CountWidget 的 ➕ 按钮,打印如下:


flutter: count setState
flutter: count build

可以说明父组件的 State 变化会引起子组件的 didUpdateWidget 和 build,子组件自己的状态变化不会引起父组件的状态改变


组件销毁


我们重复上面的操作,为 CountWidget 添加一个子组件 CountSubWidget,并用 count sub 前缀打印日志。重新加载 app。


注释掉 CountWidget 中的 CountSubWidget,打印日志如下:


flutter: main reassemble
flutter: count reassemble
flutter: count sub reassemble
flutter: main didUpdateWidget
flutter: main build
flutter: count didUpdateWidget
flutter: count build
flutter: count sub deactivate
flutter: count sub dispose

恢复到注释前,注释掉 MyHomePage 中的 CountWidget,打印如下:


flutter: main reassemble
flutter: count reassemble
flutter: count sub reassemble
flutter: main didUpdateWidget
flutter: main build
flutter: count deactivate
flutter: count sub deactivate
flutter: count sub dispose
flutter: count dispose

因为是热重载,所以会调用 reassembledidUpdateWidgetbuild,我们可以忽略带有这几个函数的打印日志。可以得出结论:
父组件移除,会先移除节点,然后子组件移除节点,子组件被永久移除,最后是父组件被永久移除。


Flutter App Lifecycle


上面我们介绍的生命周期主要是 StatefulWidget 组件的生命周期,下面我们来简单介绍一下和 app 平台相关的生命周期,比如退出到后台。


我们创建 app_lifecycle_state.dart 文件并创建 AppLifecycle,他是一个 StatefulWidget,但是他要继承 WidgetsBindingObserver。


import 'package:flutter/material.dart';

class AppLifecycle extends StatefulWidget {
AppLifecycle({Key key}) : super(key: key);

@override
_AppLifecycleState createState() {
print('sub createState');
return _AppLifecycleState();
}
}

class _AppLifecycleState extends State<AppLifecycle>
with WidgetsBindingObserver {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
print('sub initState');
}

@override
void didChangeAppLifecycleState(AppLifecycleState state) {
// TODO: implement didChangeAppLifecycleState
super.didChangeAppLifecycleState(state);
print('didChangeAppLifecycleState');
if (state == AppLifecycleState.resumed) {
print('resumed:');
} else if (state == AppLifecycleState.inactive) {
print('inactive');
} else if (state == AppLifecycleState.paused) {
print('paused');
} else if (state == AppLifecycleState.detached) {
print('detached');
}
}

@override
Widget build(BuildContext context) {
print('sub build');
return Container(
child: Text('data'),
);
}
}

didChangeAppLifecycleState 方法是重点,AppLifecycleState 中的状态包括:resumedinactivepauseddetached 四种。


didChangeAppLifecycleState 方法的依赖于系统的通知(notifications),正常情况下,App是可以接收到这些通知,但有个别情况下是无法接收到通知的,比如用户关机等。它的四种生命周期状态枚举源码中有详细的介绍和说明,下面附上源码以及简单的翻译说明。


app_life_cycle_state



  • resumed:该应用程序是可见的,并对用户的输入作出反应。也就是应用程序进入前台。

  • inactive:应用程序处于非活动状态,没有接收用户的输入。在 iOS 上,这种状态对应的是应用程序或 Flutter 主机视图在前台非活动状态下运行。当处于电话呼叫、响应 TouchID 请求、进入应用切换器或控制中心时,或者当 UIViewController 托管的 Flutter 应用程序正在过渡。在 Android 上,这相当于应用程序或 Flutter 主机视图在前台非活动状态下运行。当另一个活动被关注时,如分屏应用、电话呼叫、画中画应用、系统对话框或其他窗口,应用会过渡到这种状态。也就是应用进入后台。

  • pause:该应用程序目前对用户不可见,对用户的输入没有反应,并且在后台运行。当应用程序处于这种状态时,引擎将不会调用。也就是说应用进入非活动状态。

  • detached:应用程序仍然被托管在flutter引擎上,但与任何主机视图分离。处于此状态的时机:引擎首次加载到附加到一个平台 View 的过程中,或者由于执行 Navigator pop,view 被销毁。


除了 app 生命周期的方法,Flutter 还有一些其他不属于生命周期,但是也会在一些特殊时机被观察到的方法,如 didChangeAccessibilityFeatures(当前系统改变了一些访问性活动的回调)didHaveMemoryPressure(低内存回调)didChangeLocales(用户本地设置变化时调用,如系统语言改变)didChangeTextScaleFactor(文字系数变化) 等,如果有兴趣的话,可以去试一试。


总结


本篇文章主要介绍了 Widget 中的 StatefulWidget 的生命周期,以及 Flutter App 相关的生命周期。但是要切记,StatefulWidget 虽好,但也不要无脑的所有 Widget 全都用它,能使用 StatelessWidget 还是要尽量去使用 StatelessWidget(仔细想一下,这是为什么呢?)。好啦,看完本篇文章,你就是 Flutter 初级开发工程师了,可以去面试了(狗头保命)。


最后


真正坚持到最后的人,往往靠的不是短暂的激情,而是恰到好处的喜欢和投入。你还那么年轻,完全可以成为任何你想要成为的样子!


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

Flutter 蒙层控件

功能说明 新手引导高亮蒙层 图片进度条 使用说明 Import the packages: import 'package:flutter_mask_view/flutter_mask_view.dart'; show height-light mask ...
继续阅读 »

功能说明



  • 新手引导高亮蒙层

  • 图片进度条


使用说明


Import the packages:


import 'package:flutter_mask_view/flutter_mask_view.dart';

show height-light mask for newer:


 Scaffold(
body: Stack(
children: [
//only display background for demo
Image.asset(ImagesRes.BG_HOME),

//config
HeightLightMaskView(
//控件大小
maskViewSize: Size(720, 1080),
//蒙层颜色
backgroundColor: Colors.blue.withOpacity(0.6),
//高亮区域颜色
color: Colors.transparent,
//设置高亮区域形状,如果width = height = radius 为圆形,否则矩形
rRect: RRect.fromRectAndRadius(
Rect.fromLTWH(100, 100, 50, 50),
Radius.circular(50),
),
)
],
),
)

more:


          HeightLightMaskView(
maskViewSize: Size(720, 1080),
backgroundColor: Colors.blue.withOpacity(0.6),
color: Colors.transparent,
//自定义蒙层区域形状
pathBuilder: (Size size) {
return Path()
..moveTo(100, 100)
..lineTo(50, 150)
..lineTo(150, 150);
},
//在蒙层上自定义绘制内容
drawAfter: (Canvas canvas, Size size) {
Paint paint = Paint()
..color = Colors.red
..strokeWidth = 15
..style = PaintingStyle.stroke;
canvas.drawCircle(Offset(150, 150), 50, paint);
},
//是否重绘,默认return false, 如果使用动画,此返回true
rePaintDelegate: (CustomPainter oldDelegate){
return false;
},
)

Display



create image progress bar:


      ImageProgressMaskView(
size: Size(360, 840),
//进度图片
backgroundRes: 'images/bg.png',
//当前进度
progress: 0.5,
//蒙层形状,内置以下两种蒙层:
//矩形蒙层:PathProviders.sRecPathProvider
//水波蒙层(可配置水波高度和密度):PathProviders.createWaveProvider

//自定义进度蒙层
pathProvider: PathProviders.createWaveProvider(60, 100),
),
)

PathProviders.sRecPathProvider:



PathProviders.createWaveProvider:



与动画联动:


class _MaskTestAppState extends State<MaskTestApp>
with SingleTickerProviderStateMixin {
late AnimationController _controller;

@override
void initState() {
_controller =
AnimationController(duration: Duration(seconds: 5), vsync: this);
_controller.forward();
super.initState();
}

@override
void dispose() {
_controller.dispose();
super.dispose();
}

@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: Center(
child: AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Stack(
alignment: Alignment.center,
children: [
ImageProgressMaskView(
size: Size(300, 300),
backgroundRes: ImagesRes.IMG,
progress: _controller.value,
pathProvider: PathProviders.createWaveProvider(60, 40),
rePaintDelegate: (_) => true,
),
Text(
'${(_controller.value * 100).toInt()} %',
style: TextStyle(
color: Colors.red,
fontWeight: FontWeight.bold,
fontSize: 30,
),
)
],
);
},
),
),
);
}
}

Result:


case 1:



case 2: (png)



仓库地址


PUB


Github


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

你会写注释吗?

前言有一本书叫《代码整洁之道》,不知你看过没?初次听闻此书,并未激发我的阅读欲。再次听闻,不免心想:代码竟还整洁之道?我倒要瞧瞧,怎么个整洁法。我是怀着试探地心看了这本书,结果收获了满脑子糟糕的代码。天呐!这代码我貌似一句也看不懂,幸好还有文字,尚可宽慰我这颗...
继续阅读 »

前言

有一本书叫《代码整洁之道》,不知你看过没?

初次听闻此书,并未激发我的阅读欲。再次听闻,不免心想:代码竟还整洁之道?我倒要瞧瞧,怎么个整洁法。

我是怀着试探地心看了这本书,结果收获了满脑子糟糕的代码。天呐!这代码我貌似一句也看不懂,幸好还有文字,尚可宽慰我这颗被代码撞乱的心,于是咬咬牙读了下去。

这本书里面讲了很多代码整洁之道,关于有意义的命名、函数、注释、格式、错误处理、边界等共十七大篇章。如果你感兴趣,可以去看看。我只是粗略地看了一下,因为有些我也看不大明白。特别是当某些代码脱离了计算机而存在的时候,我好像不认识它们了,它们变得异常陌生。恕我我孤陋寡闻了,哎。

尽管如此,此书第四章中,关于“注释”的代码整洁之道,却给我留下了异常深刻的印象。Why? 因为里面关于注释的观点刷新了我的认知,与我的思想产生了一点点灵魂的碰撞,并且说服了我,还驱动我写下了这篇文章。

一、被注释吸引

下面是“注释”篇章的开头两段,特意贴了上来,因为我就是被这样的开头吸引了。希望它能带给你一点点启发。


不知你读完以上两段,作何感想?

我的感想是:如果你的代码写得足够优秀,是不需要过多注释的。注释最多可以证明糟糕的代码。

额,此刻我很想找一个捂脸的表情。与此同时,我在脑海里迅速地回忆了一遍注释之于我的心历路程:从最初知道“注释”这么个神奇玩意儿时的欣喜,到步步沦陷“注释”的魔爪,以致如今看着满屏的代码,不写点儿注释都感觉空落落的......

收回来,继续品。 作者开篇的观点约莫如下:

  • 注释的恰当用法是弥补我们在用代码表达意图时遭遇的失败

  • 如果你发现自己需要写注释,再想想是否有办法翻盘,用代码来表达

  • 注释会撒谎,代码在变动演化,但注释不能总是跟着走

  • 只有代码是唯一准确的信息来源

注意,作者用来了“失败”一词。你无法找到表达自我的恰当方法,所以就要用注释,这并不值得庆祝。当然,这并不意味作者就完全否定了注释的价值,程序员应当负责将代码保持在可维护、有关联、精确的高度。只不过作者更倾向于把力气用在写清楚代码上,直接保证无须编写注释,或者花心思减少注释量。

二、好的注释

有些注释是必须的,作者列举了一些值得写的注释。

  • 公司代码规范要求编写与法律有关的注释

  • 提供基本信息的注释

  • 对意图的解释

  • 阐释:把晦涩难明的参数或返回值的意义翻译为某种可读形式

  • 警示:用于警告其他程序员会出现某种后果的注释

  • TODO 注释:是一种程序员认为应该做,但由于某些原因目前还没做的工作

  • 放大:放大某种看起来不合理之物的重要性的注释

  • 公共 API 中的 Javadoc

尽管如此,作者一再强调:唯一真正好的注释是你想办法不去写的注释。足见作者对注释之深恶痛疾,对糟糕代码之嫌弃,对代码整洁要求之高。你可以细品。

三、坏的注释

果然是有代码洁癖的人,作者用了更多的篇幅来描述坏的注释。

  • 喃喃自语:因为过程需要就添加注释,就是无谓之举

  • 多余的注释:并不比代码本身提供更多的信息,甚至比读代码所花时间长

  • 误导性注释:写出不够精确的注释误导读者

  • 循规式注释:每个函数都要有 Javadoc 或每个变量都要有注释的规则愚不可及

  • 日志式注释:每次编辑代码时,在模块开始处添加一条注释,应当全部删除

  • 废话注释:喋喋不休,废话连篇的注释,一旦代码修改,将变成一堆谎言

  • 能用函数或变量时就别用注释:建议重构代码,删掉注释

  • 位置标记:在源代码中标记某个特别位置,多数实属无理又鸡零狗碎

  • 括号后面的注释:如果你发现自己想标记右括号,其实应该做的是缩短函数

  • 归属与署名:源代码控制系统是这类信息最好的归属地

  • 注释掉的代码:注释掉的代码堆积在一起,就像酒瓶底的渣滓一般

  • HTML 注释:源代码注释中的 HTML 标记是一种厌物

  • 非本地信息:假如你一定要写注释,请确保它描述了离它最近的代码

  • 信息过多:别在注释中添加有趣的历史性话题或无关的细节描述

  • 不明显的关系:注释及其描述的代码之间的联系应该显而易见

  • 函数头:短函数不需要太多的描述,选个好的函数名胜于写函数头注释

一言以蔽之:用整理代码的决心替代创造废话的冲动吧。 你会发现自己成为更优秀、更快乐的程序员。

小结

作者把“注释”拎出来,说了这么多,最终还是回归到了代码本身。

那如何才能写出整洁的代码呢?如果你不明白整洁对代码的意义,尝试去写整洁代码就毫无意义。如果你明白糟糕的代码给你带来的代价,你就会明白,花时间保持代码整洁不但有关效率,还有关生存。争取让营地比你来时更干净吧!

最后,贴上书中震撼我的一隅,希望它能指引你逐渐走向代码整洁之道,与君共勉!



作者:linwanxia
来源:https://juejin.cn/post/7083029096615116837

收起阅读 »

你确定(a == 1 && a == 2 && a == 3)不能为true?

前言最近遇到一个非常有意思的面试题: JavaScript中有没有可能让(a== 1 && a ==2 && a==3)返回true?讲真刚看到这题的时候,我是用这种眼神看面试官的:你TM逗我呢? 尊重一下我可行?没10年脑血栓...
继续阅读 »

前言

最近遇到一个非常有意思的面试题: JavaScript中有没有可能让(a== 1 && a ==2 && a==3)返回true?

讲真刚看到这题的时候,我是用这种眼神看面试官的:你TM逗我呢? 尊重一下我可行?没10年脑血栓问不出这玩意,


但看他一脸"贱笑",一副你一定答不出来的感觉,我觉得此事定不简单...


障眼法我TM给跪了

咱们先不管面试官的意图是什么,具体考察的是什么知识,先来看看几种奇特的解法。

解法1:隐藏字符 + if

const if = () => !0
const a = 9

if(a == 1 && a == 2 && a == 3)
{
 console.log('前端胖头鱼') // 前端胖头鱼
}

眼见为虚


我觉得此时你和我一样,在严重怀疑自己怕是个假前端if也能被改写?a明明是9却可以等于1、2、3


别急,这其实是一个障眼法,只是取巧蒙蔽了我们的双眼,请看下图


真相大白if的后面有个隐藏字符,本质上是声明了一个无论输入啥都返回true函数,而下面的代码块,更是和这个函数没半毛钱关系,怎么样都会执行!!!

{
 console.log('前端胖头鱼') // 前端胖头鱼
}

所以通过构造一个看似重写了if的代码块,仿佛真的实现了题目,实在是太骚了!!!

解法2:隐藏字符 + a变量

有了上面的经验,接下来的解法,你也不会感到奇怪了。

const aᅠ = 1
const a = 2
const ᅠa = 3

if (aᅠ == 1 && a == 2 && ᅠa == 3) {
 console.log('前端胖头鱼') // 前端胖头鱼
}


解法3:隐藏字符 + 数字变量

既然可以伪造三个a变量,那也可以伪造三个123变量嘛

const a = 1
const ᅠ1 = a
const ᅠ2 = a
const ᅠ3 = a

if (a == ᅠ1 && a == ᅠ2 && a == ᅠ3) {
 console.log('前端胖头鱼') // 前端胖头鱼
}

大千世界,果然眼见为虚啊!!!


再来一种奇特的解法

上面几种解法本质上都没有使 a == 1 && a == 2 && a == 3true,不过是障眼法,大家笑笑就好啦!接下来我要认真起来了...

解法4:“with”

MDN上映入眼帘的是一个警告,仿佛他的存在就是个错误,我也从来没有在实际工作中用过他,但他却可以用来解决这个题目。


let i = 1

with ({
 get a() {
   return i++
}
}) {
 if (a == 1 && a == 2 && a == 3) {
   console.log('前端胖头鱼')
}
}

聪明的你甚至都不用我解释代码啥意思了。

隐式转换成解题的关键

上面给出的4种解法多少有点歪门邪道的意思,为了让面试官死心,接下来的才是正解之道,而JS中的隐式转换规则大概也是出这道题的初衷。

隐式转换部分规则

JS中使用==对两个值进行比较时,会进行如下操作:

  1. 将两个被比较的值转换为相同的类型。

  2. 转换后(等式的一边或两边都可能被转换)再进行值的比较。

比较的规则如下表(mdn


从表中可以得到几点信息为了让(a == 1),a只有这几种:

  1. a类型为String,并且可转换为数字1('1' == 1 => true

  2. a类型为Boolean,并且可转换为数字1 (true == 1 => true)

  3. a类型为Object,通过转换机制后,可转换为数字1 (请看下文

对象转原始类型的"转换机制"

规则1和2没有什么特殊的地方,我们来看看3:

对象转原始类型,会调用内置的[ToPrimitive]函数,逻辑大致如下:

  1. 如果有Symbol.toPrimitive方法,优先调用再返回,否则进行2。

  2. 调用valueOf,如果可以转换为原始类型,则返回,否则进行3。

  3. 调用toString,如果可以转换为原始类型,则返回,否则进行4。

  4. 如果都没有返回原始类型,会报错。

const obj = {
 value: 1,
 valueOf() {
   return 2
},
 toString() {
   return '3'
},
[Symbol.toPrimitive]() {
   return 4
}
}

obj == 4 // true
// 您可以将Symbol.toPrimitive、toString、valueOf分别注释掉验证转换规则

解法5: Symbol.toPrimitive

我们可以利用隐式转换规则3完成题目(看完答案你就知道为什么啦!

const a = {
 i: 1,
[Symbol.toPrimitive]() {
   return this.i++
}
}
// 每次进行a == xxx时都会先经过Symbol.toPrimitive函数,自然也就可以实现a依次递增的效果
if (a == 1 && a == 2 && a == 3) {
 console.log('前端胖头鱼') // 前端胖头鱼
}

解法6: valueOf vs toString

当然也可以利用valueOftoString

let a = {
 i: 1,
 // valueOf替换成toString效果是一样的
 // toString
 valueOf() {
   return this.i++
}
}

if (a == 1 && a == 2 && a == 3) {
 console.log('前端胖头鱼') // 前端胖头鱼
}

解法7:Array && join

数组对象在进行隐式转换时,同样符合规则3,只是在toString时还会调用join方法。所以也可以从这里下手

let a = [1, 2, 3]

a.join = a.shift

if (a == 1 && a == 2 && a == 3) {
 console.log('前端胖头鱼') // 前端胖头鱼
}

数据劫持亦是一条出路

通过隐式转换我们做出了3种让a == 1 && a == 2 && a == 3返回true的方案,聪明的你一定想到另一种思路,数据劫持,伟大的Vue就曾使用数据劫持赢得了千万开发者的芳心,我们也试试用它来解决这道面试题

解法8:Object.defineProperty

通过劫持window对象,每次读取a属性时,都给_a 增加1

let _a = 1
Object.defineProperty(window, 'a', {
 get() {
   return _a++
}
})

if (a == 1 && a == 2 && a == 3) {
 console.log('前端胖头鱼') // 前端胖头鱼
}

解法9:Proxy

当然还有另一种劫持数据的方式,Vue3也是将响应式原理中的数据劫持Object.defineProperty换成了Proxy

let a = new Proxy({ i: 1 }, {
get(target) {
return () => target.i++
}
})

if (a == 1 && a == 2 && a == 3) {
console.log('前端胖头鱼') // 前端胖头鱼
}

最后

希望能一直给大家分享实用、基础、进阶的知识点,一起早早下班,快乐摸鱼。

作者:前端胖头鱼
来源:https://juejin.cn/post/7079936779914051615

收起阅读 »

作为一名前端,该如何理解Nginx?

大家好,我叫小杜杜,作为一名小前端,只需要好好写代码,至于部署相关的操作,我们通常接触不到,正所谓专业的人干专业的事,我们在工作中并不需要去配置,但这并不代表不需要了解,相信大家都多多少少听过nginx,所以今天就聊聊,还请大家多多支持~Nginx是什么?Ng...
继续阅读 »

大家好,我叫小杜杜,作为一名小前端,只需要好好写代码,至于部署相关的操作,我们通常接触不到,正所谓专业的人干专业的事,我们在工作中并不需要去配置,但这并不代表不需要了解,相信大家都多多少少听过nginx,所以今天就聊聊,还请大家多多支持~


Nginx是什么?

Nginx (engine x) 是一个轻量级、高性能的HTTP反向代理服务器,同时也是一个通用代理服务器(TCP/UDP/IMAP/POP3/SMTP),最初由俄罗斯人Igor Sysoev编写。

简单的说:

  • Nginx是一个拥有高性能HTTP和反向代理服务器,其特点是占用内存少并发能力强,并且在现实中,nginx的并发能力要比在同类型的网页服务器中表现要好

  • Nginx专为性能优化而开发,最重要的要求便是性能,且十分注重效率,有报告nginx能支持高达50000个并发连接数

正向代理和反向代理

Nginx 是一个反向代理服务器,那么反向代理是什么呢?我们先看看什么叫做正向代理

正向代理:局域网中的电脑用户想要直接访问网络是不可行的,只能通过代理服务器(Server)来访问,这种代理服务就被称为正向代理。

就好比我们俩在一块,直接对话即可,但如果我和你分隔两地,我们要想对话,必须借助一个通讯设备(如:电话)来沟通,那么这个通讯设备就是"代理服务器",这种行为称为“正向代理”

那么反向代理是什么呢?

反向代理:客户端无法感知代理,因为客户端访问网络不需要配置,只要把请求发送到反向代理服务器,由反向代理服务器去选择目标服务器获取数据,然后再返回到客户端,此时反向代理服务器和目标服务器对外就是一个服务器,暴露的是代理服务器地址,隐藏了真实服务器IP地址。

在正向代理中,我向你打电话,你能看到向你打电话的电话号码,由电话号码知道是我给你打的,那么此时我用虚拟电话给你打过去,你看到的不再是我的手机号,而是虚拟号码,你便不知道是我给你打的,这种行为变叫做"反向代理"。

在以上述的例子简单的说下:

  • 正向代理:我通过我的手机(proxy Server)去给你打电话,相当于我和我的手机是一个整体,与你的手机(Server)是分开的

  • 反向代理:我通过我的手机(proxy Server)通过软件转化为虚拟号码去给你打电话,此时相当于我的手机和你的手机是一个整体,和我是分开的

负载均衡

负载均衡:是高可用网络基础架构的关键组件,通常用于将工作负载分布到多个服务器来提高网站、应用、数据库或其他服务的性能和可靠性。

如果没有负载均衡,客户端与服务端的操作通常是:客户端请求服务端,然后服务端去数据库查询数据,将返回的数据带给客户端


但随着客户端越来越多,数据,访问量飞速增长,这种情况显然无法满足,我们从上图发现,客户端的请求和相应都是通过服务端的,那么我们加大服务端的量,让多个服务端分担,是不是就能解决这个问题了呢?

但此时对于客户端而言,他去访问这个地址就是固定的,才不会去管那个服务端有时间,你只要给我返回出数据就OK了,所以我们就需要一个“管理者“,将这些服务端找个老大过来,客户端直接找老大,再由老大分配谁处理谁的数据,从而减轻服务端的压力,而这个”老大“就是反向代理服务器,而端口号就是这些服务端的工号。


向这样,当有15个请求时,反向代理服务器会平均分配给服务端,也就是各处理5个,这个过程就称之为:负载均衡

动静分离

当客户端发起请求时,正常的情况是这样的:


就好比你去找客服,一般先是先说一大堆官方的话,你问什么,他都会这么说,那么这个就叫静态资源(可以理解为是html,css)

而回答具体的问题时,每个回答都是不同的,而这些不同的就叫做动态资源(会改变,可以理解为是变量)

在未分离的时候,可以理解为每个客服都要先说出官方的话,在打出具体的回答,这无异加大了客服的工作量,所以为了更好的有效利用客服的时间,我们把这些官方的话分离出来,找个机器人,让他代替客服去说,这样就减轻了客服的工作量。

也就是说,我们将动态资源和静态资源分离出来,交给不同的服务器去解析,这样就加快了解析的速度,从而降低由单个服务器的压力


安装 Nginx

关于 nginx 如何安装,这里就不做过多的介绍了,感兴趣的小伙伴看看这篇文章:【Linux】中如何安装nginx

这里让我们看看一些常用的命令:

  • 查看版本:./nginx -v

  • 启动:./nginx

  • 关闭:./nginx -s stop(推荐) 或 ./nginx -s quit

  • 重新加载nginx配置:./nginx -s reload

Nginx 的配置文件

配置文件分为三个模块:

  • 全局块:从配置文件开始到events块之间,主要是设置一些影响nginx服务器整体运行的配置指令。(按道理说:并发处理服务的配置时,值越大,可支持的并发处理量越多,但此时会受到硬件、软件等设备等的制约)

  • events块:影响nginx服务器与用户的网络连接,常用的设置包括是否开启对多workprocess下的网络连接进行序列化,是否允许同时接收多个网络连接等等

  • http块:如反向代理和负载均衡都在此配置

location 的匹配规则

共有四种方式:

    location[ = | ~ | ~* | ^~ ] url {
   
  }
复制代码
  • =精确匹配,用于不含正则表达式的url前,要求字符串与url严格匹配,完全相等时,才能停止向下搜索并处理请求

  • ^~:用于不含正则表达式的url前,要求ngin服务器找到表示url和字符串匹配度最高的location后,立即使用此location处理请求,而不再匹配

  • ~最佳匹配,用于表示url包含正则表达式,并且区分大小写。

  • ~*:与~一样,只是不区分大小写

注意:

  • 如果 url 包含正则表达式,则不需要~ 作为开头表示

  • nginx的匹配具有优先顺序,一旦匹配上就会立马退出,不再进行向下匹配

End

关于具体的配置可以参考:写给前端的nginx教程

致此,有关Nginx相关的知识就已经完成了,相信对于前段而言已经足够了,喜欢的点个赞👍🏻支持下吧(● ̄(エ) ̄●)


作者:小杜杜
来源:https://juejin.cn/post/7082655545491980301

收起阅读 »

我用 nodejs 爬了一万多张小姐姐壁纸

前言哈喽,大家好,我是小马,为什么要下载这么多图片呢? 前几天使用 uni-app + uniCloud 免费部署了一个壁纸小程序,那么接下来就需要一些资源,给小程序填充内容。爬取图片首先初始化项目,并且安装 axios 和 ch...
继续阅读 »

前言

哈喽,大家好,我是小马,为什么要下载这么多图片呢? 前几天使用 uni-app + uniCloud 免费部署了一个壁纸小程序,那么接下来就需要一些资源,给小程序填充内容。

爬取图片

首先初始化项目,并且安装 axios 和 cheerio

npm init -y && npm i axios cheerio

axios 用于爬取网页内容,cheerio 是服务端的 jquery api, 我们用它来获取 dom 中的图片地址;const axios = require('axios')

const cheerio = require('cheerio')

function getImageUrl(target_url, containerEelment) {
let result_list = []
const res = await axios.get(target_url)
const html = res.data
const $ = cheerio.load(html)
const result_list = []
$(containerEelment).each((element) => {
result_list.push($(element).find('img').attr('src'))
})
return result_list
}

这样就可以获取到页面中的图片 url 了。接下来需要根据 url 下载图片。

如何使用 nodejs 下载文件

方式一:使用内置模块 ‘https’ 和 ‘fs’

使用 node js 下载文件可以使用内置包或第三方库完成。

GET 方法用于 HTTPS 来获取要下载的文件。 createWriteStream() 是一个用于创建可写流的方法,它只接收一个参数,即文件保存的位置。Pipe()是从可读流中读取数据并将其写入可写流的方法。const fs = require('fs')

const https = require('https')

// URL of the image
const url = 'GFG.jpeg'

https.get(url, (res) => {
// Image will be stored at this path
const path = `${__dirname}/files/img.jpeg`
const filePath = fs.createWriteStream(path)
res.pipe(filePath)
filePath.on('finish', () => {
filePath.close()
console.log('Download Completed')
})
})

方式二:DownloadHelper
npm install node-downloader-helper

下面是从网站下载图片的代码。一个对象 dl 是由类 DownloadHelper 创建的,它接收两个参数:

  1. 将要下载的图像。
  2. 下载后必须保存图像的路径。

File 变量包含将要下载的图像的 URL,filePath 变量包含将要保存文件的路径。const { DownloaderHelper } = require('node-downloader-helper')


// URL of the image
const file = 'GFG.jpeg'
// Path at which image will be downloaded
const filePath = `${__dirname}/files`

const dl = new DownloaderHelper(file, filePath)

dl.on('end', () => console.log('Download Completed'))
dl.start()

方法三: 使用 download

是 npm 大神 sindresorhus 写的,非常好用

npm install download

下面是从网站下载图片的代码。下载函数接收文件和文件路径。const download = require('download')


// Url of the image
const file = 'GFG.jpeg'
// Path at which image will get downloaded
const filePath = `${__dirname}/files`

download(file, filePath).then(() => {
console.log('Download Completed')
})

最终代码

本来想去爬百度壁纸,但是清晰度不太够,而且还有水印等,后来, 群里有个小伙伴找到了一个 api,估计是某个手机 APP 上的高清壁纸,可以直接获得下载的 url,我就直接用了。

下面是完整代码

const download = require('download')
const axios = require('axios')

let headers = {
'User-Agent':
'Mozilla/5.0 (Macintosh; Intel Mac OS X 11_1_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36',
}

function sleep(time) {
return new Promise((reslove) => setTimeout(reslove, time))
}

async function load(skip = 0) {
const data = await axios
.get(
'http://service.picasso.adesk.com/v1/vertical/category/4e4d610cdf714d2966000000/vertical',
{
headers,
params: {
limit: 30, // 每页固定返回30条
skip: skip,
first: 0,
order: 'hot',
},
}
)
.then((res) => {
return res.data.res.vertical
})
.catch((err) => {
console.log(err)
})
await downloadFile(data)
await sleep(3000)
if (skip < 1000) {
load(skip + 30)
} else {
console.log('下载完成')
}
}

async function downloadFile(data) {
for (let index = 0; index < data.length; index++) {
const item = data[index]

// Path at which image will get downloaded
const filePath = `${__dirname}/美女`

await download(item.wp, filePath, {
filename: item.id + '.jpeg',
headers,
}).then(() => {
console.log(`Download ${item.id} Completed`)
return
})
}
}

load()

上面代码中先要设置 User-Agent 并且设置 3s 延迟, 这样可以防止服务端阻止爬虫,直接返回 403。

直接 node index.js 就会自动下载图片了。

爬取运行中

来源:https://juejin.cn/post/7078206989402112037

收起阅读 »

React18正式版发布,未来发展趋势是?

2022年3月29号,React18正式版发布。从v16开始,React团队就在普及并发的概念。在v18的迭代过程中(alpha、Beta、RC),也一直在科普并发特性,所以正式版发布时,已经没有什么新鲜特性。本文主要讲解v18发布日志中透露的一些未来发展趋势...
继续阅读 »

2022年3月29号,React18正式版发布。

v16开始,React团队就在普及并发的概念。在v18的迭代过程中(alpha、Beta、RC),也一直在科普并发特性,所以正式版发布时,已经没有什么新鲜特性。

本文主要讲解v18发布日志中透露的一些未来发展趋势。

欢迎加入人类高质量前端框架研究群,带飞

开发者可能并不会接触到并发特性

React对增加API是很慎重的。从13年诞生至今,触发更新的方式都是this.setState

而引入并发概念后,光是与并发相关的API就有好几个,比如:

  • useTransition

  • useDeferredValue

甚至出现了为并发兜底的API(即并发情况下,不使用这些API可能会出bug),比如:

  • useSyncExternalStore

  • useInsertionEffect

一下多出这么多API,还不是像useState这种不使用不行的API,况且,并发这一特性对于多数前端开发者都有些陌生。

你可以代入自己的业务想想,让开发者上手使用并发特性有多难。

所以,在未来用v18开发的应用,开发者可能并不会接触到并发特性。这些特性更可能是由各种库封装好的。

比如:startTransition可以让用户在不同视图间切换的同时,不阻塞用户输入。

这一API很可能会由各种Router实现,再作为一个配置项开放给开发者。

万物皆可Suspense

对于React来说,有两类瓶颈需要解决:

  • CPU的瓶颈,如大计算量的操作导致页面卡顿

  • IO的瓶颈,如请求服务端数据时的等待时间

其中CPU的瓶颈通过并发特性的优先级中断机制解决。

IO的瓶颈则交给Suspense解决。

所以,未来一切与IO相关的操作,都会收敛到Suspense这一解决方案内。

从最初的React.lazy到如今仍在开发中的Server Components,最终万物皆可Suspense

这其中有些逻辑是很复杂的,比如:

  • Server Components

  • 新的服务端渲染方案

所以,这些操作不大可能是直接面向开发者的。

这又回到了上一条,这些操作会交由各种库实现。如果复杂度更高,则会交由基于React封装的框架实现,比如Next.jsRemix

这也是为什么React团队核心人物Sebastian会加入Next.js

可以说,React未来的定位是:一个前端底层操作系统,足够复杂,一般开发者慎用。

而开发者使用的是基于该操作系统实现的各种上层应用

总结

如果说v16之前各种React Like库还能靠体积、性能优势分走React部分蛋糕,那未来两者走的完全是两条赛道,因为两者的生态不再兼容。

未来不再会有React全家桶的概念,桶里的各个部件最终会沦为更大的框架中的一个小模块。

当前你们业务里是直接使用React呢,还是使用各种框架(比如Next.js)?

作者:魔术师卡颂
来源:https://juejin.cn/post/7080719159645962271 收起阅读 »

高并发之伪共享和缓存行填充(缓存行对齐)(@Contended)

1.使用缓存行(Cache Line)填充前后对比伪共享和缓存行填充,我们先看一个例子,让大家感受一下了解底层知识后,你的代码可以快到起飞的感jio: 在类中定义看似无用的成员属性,速度有质的提升。 如下是未使用缓存行(Cache Line)填充方法运行的结果...
继续阅读 »

1.使用缓存行(Cache Line)填充前后对比

伪共享和缓存行填充,我们先看一个例子,让大家感受一下了解底层知识后,你的代码可以快到起飞的感jio: 在类中定义看似无用的成员属性,速度有质的提升。 如下是未使用缓存行(Cache Line)填充方法运行的结果,可以看到耗时是3579毫秒:

而在其变量x的前后加上7个long类型到变量(在变量x前56Byte,后面也是56Byte,这就是缓存行填充,下面章节会详细介绍),当然这个14个变量是不会在代码中被用到的,但是为什么速度会提升将近2倍呢,如下图所示,可以看到耗时为1280毫秒:


ps:上面两个截图中的完整代码见
章节5,大家也可以直接跳转到章节去看下完整的代码。

为什么会这么神奇,这里为先提前说下结论,具体的大家可以往后看。

  • 缓存一致性是根据缓存行(Cache line)为单元来进行同步的,即缓存中的传输单元为缓存行,一个缓存行大小通常为64Byte;

  • 缓存行的内容一发生变化,就需要进行缓存同步;

  • 所以虽然用到的不是同一个数据,但是他们(数据X和数据Y)在同一个缓存行中,缓存行的内容一发生变化,就需要进行缓存同步,这个同步是需要时间的。

2.内存、缓存与寄存器之间如何传输数据

为什么会这样呢?前面我们提到过缓存一致性的问题,见笔者该篇博文:“了解高并发底层原理”,面试官:讲一下MESI(缓存一致性协议)吧,点击文字即可跳转。 其中内存、缓存与寄存器之间的关系图大致如下:


硬盘中的可执行文件加载到寄存器中进行运算的过程如下:

  1. 硬盘中的可执行文件(底层存储还是二进制的)加载到内存中,操作系统为其分配资源,变成了一个进程A,此时还没有跑起来;

  2. 过了一段时间之后,CPU0的时间片分配给了进程A,此时CPU0进行线程的装载,然后把需要用到的数据先从内存中读取到缓存中,读取的单元为一个缓存行,其大小现在通常为64字节(记住这个缓存行大小为64字节,这个非常重要,在后面会多次用到这个数值)。

  3. 然后数据再从缓存中读取到寄存器中,目前缓存一般为三级缓存,这里不具体画出。

  4. 寄存器得到了数据之后送去ALU(arithmetic and logic unit)做计算。

这里说一下为什么要设计三级缓存:

  • 电脑通过使用时钟来同步指令的执行。时钟脉冲在一个固定的频率(称为时钟频率)。当你买了一台1.5GHz的电脑,1.5GHz就是时钟频率,即每秒15亿次的时钟脉冲,一次完整的时钟脉冲称为一个周期(cycle),时钟并不记录分和秒。它以不变的速率简单跳动。

  • 其主要原因还是因为CPU方法内存消耗的时间太长了,CPU从各级缓存和内存中读取数据所需时间如下:

CPU访问大约需要的周期(cycle)大约需要的时间
寄存器1 cycle0ns
L1 Cache3—4 cycle1ns
L2 Cache10—20 cycle3ns
L3 Cache40—45 cycle15ns
内存60—90ns

3.缓存中数据共享问题(真实共享和伪共享)

3.1 真实共享(不同CPU的寄存器中都到了同一个变量X)

首先我们先说数据的真实共享,如下图,我们在CPU0和CPU1中都用到了数据X,现在不考虑数据Y。


如果不考虑缓存一致性,会出现如下问题: 在多线程情况下,此时由两个cpu同时开始读取了long X =0,然后同时执行如下语句,会出现如下情况:

int X = 0;
X++;

刚开始,X初始化为0,假设有两个线程A,B,

  1. A线程在CPU0上进行执行,从主存加载X变量的数值到缓存,然后从缓存中加载到寄存器中,在寄存器中执行X+1操作,得到X的值为1,此时得到X等于1的值还存放在CPU0的缓存中;

  2. 由于线程A计算X等于1的值还存放在缓存中,还没有刷新会内存,此时线程B执行在CPU1上,从内存中加载i的值,此时X的值还是0,然后进行X+1操作,得到X的值为1,存到CPU1的缓存中,

  3. A,B线程得到的值都是1,在一定的时间周期之后刷新回内存

  4. 写回内存后,两次X++操作之后,其值还是1;

可以看到虽然我们做了两次++X操作,但是只进行了一次加1操作,这就是缓存不一致带来的后果。

如何解决该问题:

  • 具体的我们可以通过MESI协议(详情见笔者该篇博文:blog.csdn.net/MrYushiwen/…)来保证缓存的一致性,如上图最中间的红字所示,在不同寄存器的缓存中,需要考虑数据的一致性问题,这个需要花费一定的时间来同步数据,从而达到缓存一致性的作用。

3.2伪共享(不同CPU的寄存器中用到了不同的变量,一个用到的是X,一个用到的是Y,并且XY在同一个缓存行中)

  • 缓存一致性是根据缓存行(Cache line)为单元来进行同步的,即缓存中的传输单元为缓存行,一个缓存行大小通常为64Byte;

  • 缓存行的内容一发生变化,就需要进行缓存同步;

  • 在3.1中,我们在寄存器用到的数据是同一个X,他们肯定是在同一个缓存行中的,这个是真实的共享数据的,共享的数据为X。

  • 而在3.2中,不同CPU的寄存器中用到了不同的变量,一个用到的是X,一个用到的是Y,但是变量X、Y在同一个缓存行中(一次读取64Byte,见3.1中的图),缓存一致性是根据缓存行为单元来进行同步的,所以虽然用到的不是同一个数据,但是他们(数据X和数据Y)在同一个缓存行中,他们的缓存同步也需要时间。


4.伪共享解决办法(缓存行填充或者使用@Contended注解)

4.1.缓存行填充

如章节一所示,我们可以在x变量前后进行缓存行的填充,:

public volatile long A,B,C,D,E,F,G;
public volatile long x = 1L;
public volatile long a,b,c,d,e,f,g;

添加后,3.2章节中的截图将会变成如下样子:


不论如何进行缓存行的划分,包括x在内的连续64Byte,也就是一个缓存行不可能存在变量Y,同样变量Y所在的缓存行不可能存在x,这样就不存在伪共享的情况,他们之间就不需要考虑缓存一致性问题了,也就节省了这一部分时间。

4.2.Contended注解

在Java 8中,提供了@sun.misc.Contended注解来避免伪共享,原理是在使用此注解的对象或字段的前后各增加128字节大小的padding,使用2倍于大多数硬件缓存行的大小来避免相邻扇区预取导致的伪共享冲突。我们目前的缓存行大小一般为64Byte,这里Contended注解为我们前后加上了128字节绰绰有余。 注意:如果想要@Contended注解起作用,需要在启动时添加JVM参数-XX:-RestrictContended 参数后 @sun.misc.Contended 注解才有。

然而在java11中@Contended注解被归类到模块java.base中的包jdk.internal.vm.annotation中,其中定义了Contended注解类型。笔者用的是java12,其注解如下:


加上该注解,如下,也能达到缓存行填充的效果


5.完整代码(利用缓存行填充和没用缓存行填充)

大家自己也可以跑一下如下代码,看利用缓存行填充后的神奇效果。

5.1没用缓存行填充代码如下:

package mesi;

import java.util.concurrent.CountDownLatch;

/**
* @Author: YuShiwen
* @Date: 2022/2/27 2:52 PM
* @Version: 1.0
*/

public class NoCacheLineFill {

   public volatile long x = 1L;
}

class MainDemo {

   public static void main(String[] args) throws InterruptedException {
       // CountDownLatch是在java1.5被引入的,它是通过一个计数器来实现的,计数器的初始值为线程的数量。
       // 每当一个线程完成了自己的任务后,调用countDown方法,计数器的值就会减1。
       // 当计数器值到达0时,它表示所有的线程已经完成了任务,然后调用await的线程就可以恢复执行任务了。
       CountDownLatch countDownLatch = new CountDownLatch(2);

       NoCacheLineFill[] arr = new NoCacheLineFill[2];
       arr[0] = new NoCacheLineFill();
       arr[1] = new NoCacheLineFill();

       Thread threadA = new Thread(() -> {
           for (long i = 0; i < 1_000_000_000L; i++) {
               arr[0].x = i;
          }
           countDownLatch.countDown();
      }, "ThreadA");

       Thread threadB = new Thread(() -> {
           for (long i = 0; i < 100_000_000L; i++) {
               arr[1].x = i;
          }
           countDownLatch.countDown();
      }, "ThreadB");

       final long start = System.nanoTime();
       threadA.start();
       threadB.start();
       //等待线程A、B执行完毕
       countDownLatch.await();
       final long end = System.nanoTime();
       System.out.println("耗时:" + (end - start) / 1_000_000 + "毫秒");

  }
}

5.2利用缓存行填充代码如下:

package mesi;

import java.util.concurrent.CountDownLatch;

/**
* @Author: YuShiwen
* @Date: 2022/2/27 3:45 PM
* @Version: 1.0
*/

public class UseCacheLineFill {

   public volatile long A, B, C, D, E, F, G;
   public volatile long x = 1L;
   public volatile long a, b, c, d, e, f, g;
}

class MainDemo01 {

   public static void main(String[] args) throws InterruptedException {
       // CountDownLatch是在java1.5被引入的,它是通过一个计数器来实现的,计数器的初始值为线程的数量。
       // 每当一个线程完成了自己的任务后,调用countDown方法,计数器的值就会减1。
       // 当计数器值到达0时,它表示所有的线程已经完成了任务,然后调用await的线程就可以恢复执行任务了。
       CountDownLatch countDownLatch = new CountDownLatch(2);

       UseCacheLineFill[] arr = new UseCacheLineFill[2];
       arr[0] = new UseCacheLineFill();
       arr[1] = new UseCacheLineFill();

       Thread threadA = new Thread(() -> {
           for (long i = 0; i < 1_000_000_000L; i++) {
               arr[0].x = i;
          }
           countDownLatch.countDown();
      }, "ThreadA");

       Thread threadB = new Thread(() -> {
           for (long i = 0; i < 1_000_000_000L; i++) {
               arr[1].x = i;
          }
           countDownLatch.countDown();
      }, "ThreadB");

       final long start = System.nanoTime();
       threadA.start();
       threadB.start();
       //等待线程A、B执行完毕
       countDownLatch.await();
       final long end = System.nanoTime();
       System.out.println("耗时:" + (end - start) / 1_000_000 + "毫秒");

  }
}

作者:YuShiwen
来源:https://juejin.cn/post/7083030159304949767

收起阅读 »

人人为我,我为人人——环信开发者“𠈌”计划邀你加入!

各位亲爱的环友们~环信技术社区及官方支持群自组建以来涌现了不少不分昼夜,互帮互助,无私帮助他人解决问题的热心网友,看似不经意的“顺手答一下”“刚好遇到过”,于被帮助的人都是雪中炭,暗室灯,绝渡舟的存在~~为鼓励这些默默发光的环友同时壮大帮帮团队伍,环信推出“𠈌...
继续阅读 »

各位亲爱的环友们~

环信技术社区及官方支持群自组建以来涌现了不少不分昼夜,互帮互助,无私帮助他人解决问题的热心网友,看似不经意的“顺手答一下”“刚好遇到过”,于被帮助的人都是雪中炭,暗室灯,绝渡舟的存在~~为鼓励这些默默发光的环友同时壮大帮帮团队伍,环信推出
“𠈌”计划。

“𠈌”计划以传递人人为我,我为人人的开发者互助精神为目标,将程序员自由开放和共享精神发扬光大。赠人玫瑰,手有余香,帮助他人沉淀自己的技术力量,现诚邀广大开发者积极加入!

包括但不限于在技术社区和官方支持群里解答IM集成及以外的所有开发问题。


从四月开始,每月底在本社区技术支持群(环信官方技术支持群1、2、3)分别选出3~5名当月积极帮助他人解决问题的网友(非环信员工)给予一定的福利奖励。
同时把Ta送上环信月度优秀群友墙~!颁发优秀环友徽章一枚,自本月起,累计上墙次数超过3次的环友们将拥有神秘的年度大奖~



 福利奖励标准 

IMGeek论坛:积极回复每月新发问题(集成问题,开发问题,bug解决等),帮助坛友解决问题——回帖总条数top5


技术支持群:受企业微信群统计功能限制,每月由以下各群内的环信支持小哥哥小姐姐们提名。



*以上暂为每月评选标准,评选方式后期会慢慢优化,以更客观数据为依据,贯彻公平公正的原则,坚持获选者0争议0质疑的宗旨,众望所归。
*环信员工不参与以上排名或提名。


 福利大礼包 

环信礼包:含环信定制周边、月优秀环友徽章、京东卡、其他随机盲盒




“每月优秀环友”在次月10日前揭晓并发放奖励。


如果您特别了解环信IM集成及相关问题解答,欢迎加入答疑方队。

想加入官方技术支持群的朋友,请联系环信冬冬通过审核后进群。



收起阅读 »

跟我学flutter:我们来举个例子通俗易懂讲解异步(二)ioslate循环机制

前言跟我学flutter系列:跟我学flutter:我们来举个例子通俗易懂讲解dart 中的 mixin跟我学flutter:我们来举个例子通俗易懂讲解异步(一)ioslate跟我学flutter:我们来举个例子通俗易懂讲解异步(二)ioslate循环机制企业...
继续阅读 »
前言
跟我学flutter系列:
跟我学flutter:我们来举个例子通俗易懂讲解dart 中的 mixin
跟我学flutter:我们来举个例子通俗易懂讲解异步(一)ioslate
跟我学flutter:我们来举个例子通俗易懂讲解异步(二)ioslate循环机制
企业级篇目:
跟我学企业级flutter项目:用bloc手把手教你搭建用户认证系统
跟我学企业级flutter项目:dio网络框架增加公共请求参数&header
跟我学企业级flutter项目:如何用dio封装一套企业级可扩展高效的网络层
跟我学企业级flutter项目:如何封装一套易用,可扩展的Hybrid混合开发webview
跟我学企业级flutter项目:手把手教你制作一款低耦合空页面widget

我们在开发flutter应用的时候编写代码,要么是同步代码,要么是异步代码。那么什么是同步什么是异步呢?

  • 同步代码就是正常编写的代码块
  • 异步代码就是Future,async等关键字修饰的代码块

一、时机不同

他们区别于运行时机不同,同步代码先执行,异步代码后执行,即使你的同步代码写在最后,那也是你的同步代码执行,之后运行你的异步代码。

二、机制不同

异步代码运行在 event loop中,类似于Android里的Looper机制,是一个死循环,event loop不断的从事件队列里取事件然后运行。

event loop循环机制

如图所示,事件存放于队列中,loop循环执行 运行图 Dart的事件循环如下图所示。循环中有两个队列。一个是微任务队列(MicroTask queue),一个是事件队列(Event queue)。 在这里插入图片描述 事件队列包含外部事件,例如I/O, Timer,绘制事件等等。 微任务队列则包含有Dart内部的微任务,主要是通过scheduleMicrotask来调度。

  1. 首先处理所有微任务队列里的微任务。
  2. 处理完所有微任务以后。从事件队列里取1个事件进行处理。
  3. 回到微任务队列继续循环。

Dart要先把所有的微任务处理完,再处理一个事件,处理完之后再看看微任务队列。如此循环。

例子:

8个微任务
2个事件

Dart-->执行完8个微任务
Dart-->执行完1个事件
Dart-->查看微任务队列
Dart-->再执行完1个事件
done

异步执行

那么在Dart中如何让你的代码异步执行呢?很简单,把要异步执行的代码放在微任务队列或者事件队列里就行了。

可以调用scheduleMicrotask来让代码以微任务的方式异步执行

    scheduleMicrotask((){
print('a microtask');
});

可以调用Timer.run来让代码以Event的方式异步执行

   Timer.run((){
print('a event');
});

Future异步执行

创建一个立刻在事件队列里运行的Future:

Future(() => print('立刻在Event queue中运行的Future'));

创建一个延时1秒在事件队列里运行的Future:

Future.delayed(const Duration(seconds:1), () => print('1秒后在Event queue中运行的Future'));

创建一个在微任务队列里运行的Future:

Future.microtask(() => print('在Microtask queue里运行的Future'));

创建一个同步运行的Future:

Future.sync(() => print('同步运行的Future'));

这里要注意一下,这个同步运行指的是构造Future的时候传入的函数是同步运行的,这个Future通过then串进来的回调函数是调度到微任务队列异步执行的。

有了Future之后, 通过调用then来把回调函数串起来,这样就解决了"回调地狱"的问题。

Future(()=> print('task'))
.then((_)=> print('callback1'))
.then((_)=> print('callback2'));

在task打印完毕以后,通过then串起来的回调函数会按照链接的顺序依次执行。 如果task执行出错怎么办?你可以通过catchError来链上一个错误处理函数:

 Future(()=> throw 'we have a problem')
.then((_)=> print('callback1'))
.then((_)=> print('callback2'))
.catchError((error)=>print('$error'));

上面这个Future执行时直接抛出一个异常,这个异常会被catchError捕捉到。类似于Java中的try/catch机制的catch代码块。运行后只会执行catchError里的代码。两个then中的代码都不会被执行。

既然有了类似Java的try/catch,那么Java中的finally也应该有吧。有的,那就是whenComplete:


Future(()=> throw 'we have a problem')
.then((_)=> print('callback1'))
.then((_)=> print('callback2'))
.catchError((error)=>print('$error'))
.whenComplete(()=> print('whenComplete'));

无论这个Future是正常执行完毕还是抛出异常,whenComplete都一定会被执行。

结果执行

把如上的代码在dart中运行看看输出

 print('1');
var fu1 = Future(() => print('立刻在Event queue中运行的Future'));
Future future2 = new Future((){
print("future2 初始化任务");
});
print('2');
Future.delayed(const Duration(seconds:1), () => print('1秒后在Event queue中运行的Future'));
print('3');
var fu2 = Future.microtask(() => print('在Microtask queue里运行的Future'));
print('4');
Future.sync(() => print('同步运行的Future')).then((value) => print('then同步运行的Future'));
print('5');
fu1.then((value) => print('then 立刻在Event queue中运行的Future'));
print('6');
fu2.then((value) => print('then 在Microtask queue里运行的Future'));
print('7');
Future(()=> throw 'we have a problem')
.then((_)=> print('callback1'))
.then((_)=> print('callback2'))
.catchError((error)=>print('$error'));
print('8');
Future(()=> throw 'we have a problem')
.then((_)=> print('callback1'))
.then((_)=> print('callback2'))
.catchError((error)=>print('$error'))
.whenComplete(()=> print('whenComplete'));
print('9');
Future future4 = Future.value("立即执行").then((value){
print("future4 执行then");
}).whenComplete((){
print("future4 执行whenComplete");
});
print('10');


future2.then((_) {
print("future2 执行then");
future4.then((_){
print("future4 执行then2");
});

});

输出

I/flutter (29040): 1
I/flutter (29040): 2
I/flutter (29040): 3
I/flutter (29040): 4
I/flutter (29040): 同步运行的Future
I/flutter (29040): 5
I/flutter (29040): 6
I/flutter (29040): 7
I/flutter (29040): 8
I/flutter (29040): 9
I/flutter (29040): 10
I/flutter (29040): 在Microtask queue里运行的Future
I/flutter (29040): thenMicrotask queue里运行的Future
I/flutter (29040): then同步运行的Future
I/flutter (29040): future4 执行then
I/flutter (29040): future4 执行whenComplete
I/flutter (29040): 立刻在Event queue中运行的Future
I/flutter (29040): then 立刻在Event queue中运行的Future
I/flutter (29040): future2 初始化任务
I/flutter (29040): future2 执行then
I/flutter (29040): future4 执行then2
I/flutter (29040): we have a problem
I/flutter (29040): we have a problem
I/flutter (29040): whenComplete
I/flutter (29040): 1秒后在Event queue中运行的Future

输出说明:

  • 先输出同步代码,再输出异步代码
  • 通过then串联起的任务会在主要任务执行完立即执行
  • Future.sync是同步执行,then执行在微任务队列中
  • 通过Future.value()函数创建的任务是立即执行的
  • 如果是在whenComplete之后注册的then,那么这个then的任务将放在microtask执行

Completer

Completer允许你做某个异步事情的时候,调用c.complete(value)方法来传入最后要返回的值。最后通过c.future的返回值来得到结果,(注意:宣告完成的complete和completeError方法只能调用一次,不然会报错)。 例子:

test() async {
Completer c = new Completer();
for (var i = 0; i < 1000; i++) {
if (i == 900 && c.isCompleted == false) {
c.completeError('error in $i');
}
if (i == 800 && c.isCompleted == false) {
c.complete('complete in $i');
}
}

try {
String res = await c.future;
print(res); //得到complete传入的返回值 'complete in 800'
} catch (e) {
print(e);//捕获completeError返回的错误
}
}


收起阅读 »

跟我学flutter:我们来举个例子通俗易懂讲解异步(一)ioslate

前言 跟我学flutter系列:跟我学flutter:我们来举个例子通俗易懂讲解dart 中的 mixin跟我学flutter:我们来举个例子通俗易懂讲解异步(一)ioslate跟我学flutter:我们来举个例子通俗易懂讲解异步(二)ioslate循环机制企...
继续阅读 »

前言


跟我学flutter系列:
跟我学flutter:我们来举个例子通俗易懂讲解dart 中的 mixin
跟我学flutter:我们来举个例子通俗易懂讲解异步(一)ioslate
跟我学flutter:我们来举个例子通俗易懂讲解异步(二)ioslate循环机制
企业级篇目:
跟我学企业级flutter项目:用bloc手把手教你搭建用户认证系统
跟我学企业级flutter项目:dio网络框架增加公共请求参数&header
跟我学企业级flutter项目:如何用dio封装一套企业级可扩展高效的网络层
跟我学企业级flutter项目:如何封装一套易用,可扩展的Hybrid混合开发webview
跟我学企业级flutter项目:手把手教你制作一款低耦合空页面widget

Dart是单线程的,Dart提供了Isolate,isolate提供了多线程的能力。但作为多线程能力的,却内存不能共享。但同样的内存不能共享,那么就不存在锁竞争问题。


举个例子来展示作用


如果一段代码执行事件很长,flutter如何开发。
基本页面代码(一段代码)


ElevatedButton(
child: Text("登录"),
onPressed: () {
执行运行代码();
}

延时代码块


String work(int value){
print("work start");
sleep(Duration(seconds:value));
print("work end");
return "work complete:$value";
}

第一种:直接执行运行代码(延时5秒)


  执行运行代码() {
work(5);
}

结果:
5秒卡的死死的


第二种:async执行运行代码(延时5秒)


  执行运行代码() async{
work(5);
}

结果:
5秒依旧卡的死死的


------------------------------------------------我是分割线--------------------------------------------------



why?在dart中,async不是异步计算么?(循环机制下篇讲)因为我们仍旧是在同一个UI线程中做运算,异步只是说我可以先运行其他的,等我这边有结果再返回,但是,我们的计算仍旧是在这个UI线程,仍会阻塞UI的刷新,异步只是在同一个线程的并发操作。



第三种:ioslate执行运行代码(延时5秒)



但是由于dart中的Isolate比较重量级,UI线程和Isolate中的数据的传输比较复杂,因此flutter为了简化用户代码,在foundation库中封装了一个轻量级compute操作。



  执行运行代码() async{
var result = await compute(work, 5);
print(result);
}

结果:
居然不卡顿了


使用说明



compute的使用还是有些限制,它没有办法多次返回结果,也没有办法持续性的传值计算,每次调用,相当于新建一个隔离,如果调用过多的话反而会适得其反。我们需要根据不同的业务选择用compute和isolate




Future work(int value) async{
//接收消息管道
ReceivePort rp = new ReceivePort();
//发送消息管道
SendPort port = rp.sendPort;
Isolate isolate = await Isolate.spawn(workEvent, port);
//发送消息管道2
final sendPort2 = await rp.first;
//返回应答数据
final answer = ReceivePort();
sendPort2.send([answer.sendPort, value]);
return answer.first;
}

void workEvent(SendPort port) {
//接收消息管道2
final rPort = ReceivePort();
SendPort port2 = rPort.sendPort;
// 将新isolate中创建的SendPort发送到主isolate中用于通信
port.send(port2);

rPort.listen((message) {
final send = message[0] as SendPort;
send.send(work(5));
});
}

基本方法


    //恢复 isolate 的使用
isolate.resume(isolate.pauseCapability);

//暂停 isolate 的使用
isolate.pause(isolate.pauseCapability);

//结束 isolate 的使用
isolate.kill(priority: Isolate.immediate);

//赋值为空 便于内存及时回收
isolate = null;


两个进程都双向绑定了消息通信的通道,即使新的Isolate中的任务完成了,它的进程也不会立刻退出,因此,当使用完自己创建的Isolate后,最好调用isolate.kill(priority: Isolate.immediate);将Isolate立即杀死。



用Future还是isolate?


future使用场景:



  • 代码段可以独立运行而不会影响应用程序的流畅性


isolate使用场景:



  • 繁重的处理可能要花一些时间才能完成

  • 网络加载大图

  • 图片处理
收起阅读 »

跟我学flutter:我们来举个例子通俗易懂讲解dart 中的 mixin

前言跟我学flutter系列:跟我学flutter:我们来举个例子通俗易懂讲解dart 中的 mixin跟我学flutter:我们来举个例子通俗易懂讲解异步(一)ioslate跟我学flutter:我们来举个例子通俗易懂讲解异步(二)ioslate循环机制企业...
继续阅读 »

前言

跟我学flutter系列:
跟我学flutter:我们来举个例子通俗易懂讲解dart 中的 mixin
跟我学flutter:我们来举个例子通俗易懂讲解异步(一)ioslate
跟我学flutter:我们来举个例子通俗易懂讲解异步(二)ioslate循环机制
企业级篇目:
跟我学企业级flutter项目:用bloc手把手教你搭建用户认证系统
跟我学企业级flutter项目:dio网络框架增加公共请求参数&header
跟我学企业级flutter项目:如何用dio封装一套企业级可扩展高效的网络层
跟我学企业级flutter项目:如何封装一套易用,可扩展的Hybrid混合开发webview
跟我学企业级flutter项目:手把手教你制作一款低耦合空页面widget

与java&kotlin不同的是,dart中有一个特殊的关键字mixin(mix-in),用这个关键字的类被其他类(包含)的时候,其他类就拥有了该类的方法。这样代码不通过继承(extend)就可以重用。


场景来展示mixin如何使用


由于在java&kotlin中经常性的用extent & implements 并不知道mixin是如何使用,那么我举几个特殊的例子来帮助大家理解


场景用例


在这里插入图片描述
如上uml图所示
鸟作为父类,鸟必备的技能为(下蛋和走路),而作为其子类的大雁和麻雀可以飞行,企鹅却不能飞行。
那么飞行却成为个别鸟类的技能,如果在父类中定义实现飞,那在企鹅中就多了个空实现。如果定义一个接口实现飞,那么在能飞的鸟类中就必须都要重新编写飞的代码。如何让这一切变得容易呢。
那么我们用混入(with)来实现如下代码:


abstract class Bird{

void walk() { print('我会走路'); }
void xiadan() { print('我会下蛋'); }
}

abstract class Fly{
void fly() { print('我会飞'); }
}

//大雁

class Dayan extends Bird with Fly {}

//企鹅

class Qier extends Bird {}

如果 Fly 类 不希望作为常规类被使用,使用关键字 mixin 替换 class 。


mixin Fly{
void fly() { print('我会飞'); }
}

如果 Fly 类 只希望限定于鸟类去使用,那么需要加入如下关键字


mixin Fly on Bird{
void fly() { print('我会飞'); }
}

mixin特点



  1. mixin 没有构造函数,不能被实例化

  2. 可以当做接口使用,class 混入之后需要实现

  3. 可以使用on 指定混入的类类型,如果不是报错。

  4. 如果with后的多个类中有相同的方法,如果当前使用类重写了该方法,就会调用当前类中的方法。如果当前使用类没有重写了该方法,则会调用距离with关键字最远类中的方法。


调用顺序展示


简单顺序调用


如果with后的多个类中有相同的方法,如果当前使用类重写了该方法,就会调用当前类中的方法。如果当前使用类没有重写了该方法,则会调用距离with关键字最远类中的方法。


abstract class First {
void doPrint() {
print('First');
}
}

abstract class Second {
void doPrint() {
print('Second');
}
}

class Father {
void doPrint() {
print('Father');
}
}

class Son extends Father with First,Second {

}

调用:


	Son son = Son();
son.doPrint();

打印:


Second

重写后调用


class Son extends Father with First,Second {
void doPrint() {
print('Son');
}
}

调用:


	Son son = Son();
son.doPrint();

打印:


Son

带有父类方法调用


class Father {
void init() {
print('Father init');
}
}
mixin FirstMixin on Father {
void init() {
print('FirstMixin init start');
super.init();
print('FirstMixin init end');
}
}

mixin SecondMixin on Father {
void init() {
print('SecondMixin init start');
super.init();
print('SecondMixin init end');
}
}


class Son extends Father with FirstMixin, SecondMixin {

@override
void init() {
print('Son init start');
super.init();
print('Son init end');
}
}

调用:


  Son().init();

打印:


flutter: Son init start
flutter: SecondMixin init start
flutter: FirstMixin init start
flutter: Father init
flutter: FirstMixin init end
flutter: SecondMixin init end
flutter: Son init end

说明






















方式类型说明
withmixin混入该类内容
with onmixin混入该类内容,但必须是特点的类型

特别注意


mixin 可以on多个类,但with时候之前的类必须已经有相关的实现


mixin Mix on Mix1,Mix2{ }
收起阅读 »

Java好用的时间类,别在用Date了

前言假设你想获取当前时间,那么你肯定看过这样的代码public static void main(String[] args) { Date date = new Date(System.currentTimeMillis()); Syste...
继续阅读 »

前言

假设你想获取当前时间,那么你肯定看过这样的代码

public static void main(String[] args) {

Date date = new Date(System.currentTimeMillis());
System.out.println(date.getYear());
System.out.println(date.getMonth());
System.out.println(date.getDate());
}

获取年份,获取月份,获取..日期?
运行一下

121
9
27

怎么回事?获取年份,日期怎么都不对,点开源码发现

/**
* Returns a value that is the result of subtracting 1900 from the
* year that contains or begins with the instant in time represented
* by this <code>Date</code> object, as interpreted in the local
* time zone.
*
* @return the year represented by this date, minus 1900.
* @see java.util.Calendar
* @deprecated As of JDK version 1.1,
* replaced by <code>Calendar.get(Calendar.YEAR) - 1900</code>.
*/
@Deprecated
public int getYear() {
return normalize().getYear() - 1900;
}

原来是某个对象值 减去了 1900,注释也表示,返回值减去了1900,难道我们每次获取年份需要在 加上1900?注释也说明了让我们 用Calendar.get()替换,并且该方法已经被废弃了。点开getMonth()也是一样,返回了一个0到11的值。getDate()获取日期?不应该是getDay()吗?老外的day都是sunday、monday,getDate()才是获取日期。再注意到这些api都是在1.1的时候被废弃了,私以为是为了消除getYear减去1900等这些歧义。收~

Calendar 日历类

public static void main(String[] args) {

Calendar calendar = Calendar.getInstance();
int year = calendar.get(Calendar.YEAR);
int month = calendar.get(Calendar.MONTH);
int dom = calendar.get(Calendar.DAY_OF_MONTH);
int doy = calendar.get(Calendar.DAY_OF_YEAR);
int dow = calendar.get(Calendar.DAY_OF_WEEK);
int dowim = calendar.get(Calendar.DAY_OF_WEEK_IN_MONTH);
System.out.println(year+"年"+ month+"月");
System.out.println(dom+"日");
System.out.println(doy+"日");
System.out.println(dow+"日");
System.out.println(dowim);
}

打印(运行时间2021年10月27日 星期三 晴)

2021年9月
27日
300日
4日
4

问:月份怎么是上个月的?
答:是为了计算方便,月是0到11之间的值。
问:计算方便?
答:比如月份从1月开始,增加一个月,12月+1=13,没有13月。假设取余,(12+1)=1 正好为1月,那11月增加一个月,(11+1)=0,这就有问题了。所以为了计算方便1月,返回了0值。date.getMonth()也是一个道理。 问:那下面的DAY_OF_XXX 又是什么意思?
答:猜!根据结果猜。
Calendar.DAY_OF_MONTH 在这个月 的这一天
Calendar.DAY_OF_YEAR 在这一年 的这一天
Calendar.DAY_OF_WEEK 在这一周 的这一天
Calendar.DAY_OF_WEEK_IN_MONTH 在这一个月 这一天在 第几周
到这里 Calendar.DAY_OF_WEEK 为什么是 4 ,你肯定也猜到了
Calendar.HOUR
Calendar.HOUR_OF_DAY
Calendar.SECOND
...其他的 你肯定也会用了

LocalDate 本地日期类

LocalDate localDate = LocalDate.now();
System.out.println("当前日期:"+localDate.getYear()+" 年 "+localDate.getMonthValue()+" 月 "+localDate.getDayOfMonth()+"日" );

//结果
当前日期:2021 年 10 月 27日

也可以通过 LocalDate.of(年,月,日)去构造

LocalDate pluslocalDate = localDate.plusDays(1);//增加一天
LocalDate pluslocalDate = localDate.plusYears(1);//增加一年

其他api

LocalDate.isBefore(LocalDate);
LocalDate.isAfter();
LocalDate.isEqual();

也就是对两个日期的判断,是在前、在后、或者相等。

LocalTime 本地时间类

LocalTime localTime = LocalTime.now();
System.out.println("当前时间:"+localTime.getHour()+"h "+localTime.getSecond()+"m "+localTime.getMinute()+"s" );

LocalDate和LocalTime 都有类似作用的api
LocalDate.plusDays(1) 增加一天
LocalTime.plusHours(1) 增加一小时 等等~
其他api

LocalTime.isBefore(LocalTime);
LocalTime.isAfter();

对两个时间的判断。肯定碰到过一个需求,今天离活动开始时间还剩多少天。

LocalDateTime 本地日期时间类

public final class LocalDateTime ...{

private final LocalDate date;

private final LocalTime time;
}

LocalDateTime = LocalDate + LocalTime 懂的都懂

Instant 类

Instant 是瞬间,某一时刻的意思

Instant.ofEpochMilli(System.currentTimeMillis())
Instant.now()

通过Instant可以创建一个 “瞬间” 对象,ofEpochMilli()可以接受某一个“瞬间”,比如当前时间,或者是过去、将来的一个时间。
比如,通过一个“瞬间”创建一个LocalDateTime对象

LocalDateTime now = LocalDateTime.ofInstant(
Instant.ofEpochMilli(System.currentTimeMillis()),ZoneId.systemDefault());

System.out.println("当前日期:"+now.getYear()+" 年 "+now.getMonthValue()+" 月 "+now.getDayOfMonth()+"日" )

Period 类

Period 是 时期,一段时间 的意思
Period有个between方法专门比较两个 日期 的

LocalDate startDate = LocalDateTime.ofInstant(
Instant.ofEpochMilli(1601175465000L), ZoneId.systemDefault()).toLocalDate();//1601175465000是2020-9-27 10:57:45
Period p = Period.between(startDate, LocalDate.now());

System.out.println("目标日期距离今天的时间差:"+p.getYears()+" 年 "+p.getMonths()+" 个月 "+p.getDays()+" 天" );

//目标日期距离今天的时间差:1 年 1 个月 1 天

看一眼源码

public static Period between(LocalDate startDateInclusive, LocalDate endDateExclusive) {
return startDateInclusive.until(endDateExclusive);
}

public Period until(ChronoLocalDate endDateExclusive) {
LocalDate end = LocalDate.from(endDateExclusive);
long totalMonths = end.getProlepticMonth() - this.getProlepticMonth(); // safe
int days = end.day - this.day;
if (totalMonths > 0 && days < 0) {
totalMonths--;
LocalDate calcDate = this.plusMonths(totalMonths);
days = (int) (end.toEpochDay() - calcDate.toEpochDay()); // safe
} else if (totalMonths < 0 && days > 0) {
totalMonths++;
days -= end.lengthOfMonth();
}
long years = totalMonths / 12; // safe
int months = (int) (totalMonths % 12); // safe
return Period.of(Math.toIntExact(years), months, days);
}

他只接受两个LocalDate对象,对时间的计算,算好之后返回Period对象

Duration 类

Duration 是 期间 持续时间 的意思 上代码

LocalDateTime end = LocalDateTime.ofInstant(Instant.ofEpochMilli(System.currentTimeMillis()), ZoneId.systemDefault());
LocalDateTime start = LocalDateTime.ofInstant(Instant.ofEpochMilli(1601175465000L), ZoneId.systemDefault());
Duration duration = Duration.between(start, end);

System.out.println("开始时间到结束时间,持续了"+duration.toDays()+"天");
System.out.println("开始时间到结束时间,持续了"+duration.toHours()+"小时");
System.out.println("开始时间到结束时间,持续了"+duration.toMillis()/1000+"秒");

可以看到between也接受两个参数,LocalDateTime对象,源码是对两个时间的计算,并返回对象。

对象转换

再贴点api

//long -> LocalDateTime
LocalDateTime.ofInstant(Instant.ofEpochMilli(timestamp), ZoneId.systemDefault())

//String -> LocalDateTime
DateTimeFormatter dateTimeFormatter1 = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
LocalDateTime.parse("2021-10-28 00:00:00", dateTimeFormatter1);

//LocalDateTime -> long
LocalDateTime对象.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();

//LocalDateTime -> String
DateTimeFormatter dateTimeFormatter1 = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
LocalDateTime对象.format(dateTimeFormatter1)

对象转换几乎都涵盖了,里面有个时区对象,这个一般用默认时区。

总结

用LocalDate、LocalTime、LocalDateTime代替了Date类。Date管日期,Time管时间
LocalDateTime = LocalDate + LocalTime
Period 只能用LocalDate
Duration 持续时间,所以LocalDate、LocalTime、LocalDateTime 都能处理
至于Calendar 日历类,这里面的api,都是针对日历的,比如这个月的第一天是星期几。
总体来说,都是api的使用,非常清晰,废弃date.getMonth()等,使用localDate.getMonthValue()来获取几月,更易理解,更易贴合使用。代码都贴在了github上了


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

收起阅读 »

飞机上一般是什么操作系统?安全不 ?

首先,请大家为“3·21”东航MU5735坠机事故,默哀1分钟,再接着看本文 !来正文 。。。科普文 !航空软件其实并不神秘,从计算机架构上来说,同普通的计算机系统并无太大区别,都是由处理器、总线、I/O设备、存储设备、网络设备、通讯设备、操作系统和应用软件所...
继续阅读 »

首先,请大家为“3·21”东航MU5735坠机事故,默哀1分钟,再接着看本文 !


来正文 。。。科普文 !

航空软件其实并不神秘,从计算机架构上来说,同普通的计算机系统并无太大区别,都是由处理器、总线、I/O设备、存储设备、网络设备、通讯设备、操作系统和应用软件所构成的。仅仅是为了满足很高指标的可靠性、健壮性和实时性,而采用了另一套东西而已。

1、波音-787、AH-64用的操作系统是VxWorks

VxWorks官网http://www.windriver.com/products/vxworks/

2、B-2、F-16、F-22、F-35、空客-380使用的操作系统均是Integrity-178B


Integrity-178B官网https://www.ghs.com/products/safety_critical/integrity-do-178b.html

类似波音-787、空客-380、空客-350内部设备之间是使用以太网的一种变体来互联的,叫AFDX,在应用软件这一层,同普通的以太网程序没有任何区别。扩展:10个关键词,了解MU5735搜寻最新进展

3、过去这些设备经常使用ADA语言来编写,现在为了降低成本,在F-35项目上已经改为使用C++了


F-35项目的C++编程规范http://www.stroustrup.com/JSF-AV-rules.pdf

F-35的微处理器是PowerPC指令集的,为了保证可靠性,采用的编译器也是同普通的编译器不太一样。编译器也是有可能出现bug的,为了保障源代码同编译出来的目标代码完全一致,避免编译器的bug造成问题,在JSF项目内部的软件开发中,经常使用CompCert编译器。这个编译器只能编译C99,但是可靠性极高。扩展:远程控制系统

要知道,近几年全球范围内飞机失事发生的次数不少。据不完全统计,每年全球大约有4000万次的飞机起落,而我国的飞机失事率一直处于非常低的水平。此前中国已经连续12年没有发生过重大民航事故了,而上一次坠机事故还是发生在2010年8月24日,河南航空的伊春空难,当时坠毁的机型为ERJ-190。另外,搜索公众号Java架构师技术后台回复“Spring”,获取一份惊喜礼包。

截至目前,东航坠机已经过去24小时了。总体来说,无论大家讨论什么因素导致的,都不具有肯定性的说法,包括为什么急速骤降,最后垂直坠落,飞机本身有没有问题,是不是操作系统出了故障,有没有遭遇极端天气影响等等,这一切都是属于未知数。

任何空难发生都是悲剧的,事故真实原因还需要等待官方调查结论、依靠黑匣子等来解开谜团。


参考来源:

1. VxWorks官方网站

http://www.windriver.com/products/vxworks/

2. Integrity-178B的官方网站

https://www.ghs.com/products/safety_critical/integrity-do-178b.html

3. 《F-35项目的C++编程规范》PDF

http://www.stroustrup.com/JSF-AV-rules.pdf

来源:科技曼

收起阅读 »

Flutter 与原生通信的三种方式

Flutter 与原生之间的通信依赖灵活的消息传递方式 应用的Flutter部分通过平台通道(platform channel)将消息发送到其应用程序的所在的宿主(iOS或Android)应用(原生应用) 宿主监听平台通道,并接收该消息。然后它会调用该...
继续阅读 »

Flutter 与原生之间的通信依赖灵活的消息传递方式




  • 应用的Flutter部分通过平台通道(platform channel)将消息发送到其应用程序的所在的宿主(iOS或Android)应用(原生应用)




  • 宿主监听平台通道,并接收该消息。然后它会调用该平台的 API,并将响应发送回客户端,即应用程序的 Flutter 部分




Flutter 与原生存在三种交互方式




  • MethodChannel:用于传递方法调用(method invocation)通常用来调用 native 中某个方法




  • BasicMessageChannel:用于传递字符串和半结构化的信息,这个用的比较少




  • EventChannel:用于数据流(event streams)的通信。有监听功能,比如电量变化之后直接推送数据给flutter端




三种 Channel 之间互相独立,各有用途,但它们在设计上却非常相近。每种 Channel 均有三个重要成员变量:




  • name: String类型,代表 Channel 的名字,也是其唯一标识符




  • messager:BinaryMessenger 类型,代表消息信使,是消息的发送与接收的工具




  • codec: MessageCodec 类型或 MethodCodec 类型,代表消息的编解码器




具体使用



  • 首先分别创建 Native 工程和 Flutter Module。我这里是以 iOS 端和 Flutter 通信为例,创建完 iOS 工程后,需要通过 CocoaPods 管理 Flutter Module。


截屏2021-11-27 下午3.09.28.png



  • 然后在 iOS 工程里面创建 Podfile ,然后引入 Flutter Module ,具体代码如下:


platform :ios,'11.0'
inhibit_all_warnings!

#flutter module 文件路径
flutter_application_path = '../flutter_module'
load File.join(flutter_application_path, '.ios', 'Flutter', 'podhelper.rb')

target 'Native_iOS' do

install_all_flutter_pods(flutter_application_path)

end

注意: flutter_application_path 这个是 Flutter 工程的路径,我是原生项目和 Flutter在一个目录下



  • 最后在终端 pod install 一下,看是否能正常引入 Flutter Module。这样就可以在iOS工程里面导入#import <Flutter/Flutter.h>

一、MethodChannel的使用


这里写的代码实现了以下功能


1.实现了点击原生页面的按钮跳转到 Flutter 页面,在 Flutter 点击返回按钮能正常返回原生页面


2.实现在Flutter页面点击当前电量,从原生界面传值到 Flutter 页面


原生端代码


@property (nonatomic, strong)FlutterEngine *flutterEngine;

@property (nonatomic, strong)FlutterViewController *flutterVC;

@property (nonatomic, strong)FlutterMethodChannel *methodChannel;

- (void)viewDidLoad {
    [super viewDidLoad];

   //隐藏了原生的导航栏
    self.navigationController.navigationBarHidden = YES;

    UIButton *btn = [[UIButton alloc] initWithFrame:CGRectMake(100, 100, 80, 80)];
    btn.backgroundColor = [UIColor redColor];
    [btn addTarget:self action: @selector(onBtnClick) forControlEvents:UIControlEventTouchUpInside];
    [self.view addSubview:btn];

    self.flutterVC = [[FlutterViewController alloc] initWithEngine:self.flutterEngine nibName:nil bundle:nil];
//创建channel
    self.methodChannel = [FlutterMethodChannel methodChannelWithName:@"methodChannel" binaryMessenger:self.flutterVC.binaryMessenger];

}

- (void)onBtnClick {

    //告诉Flutter对应的页面
//Method--方法名称,arguments--参数
    [self.methodChannel invokeMethod:@"EnterFlutter" arguments:@""];

//push进入Flutter页面
    [self.navigationController pushViewController:self.flutterVC animated:YES];

    __weak __typeof(self) weakSelf = self;
//监听Flutter发来的事件
    [self.methodChannel setMethodCallHandler:^(FlutterMethodCall * _Nonnull call, FlutterResult  _Nonnull result) {
//响应从Flutter页面发送来的方法
        if ([call.method isEqualToString:@"exit"]) {
            [weakSelf.flutterVC.navigationController popViewControllerAnimated:YES];
        } else if ([call.method isEqualToString:@"getBatteryLevel"]) {
//传值回Flutter页面
            [weakSelf.methodChannel invokeMethod:@"BatteryLevel" arguments:@"60%"];
        }
    }];
}

//创建引擎,真正在项目中,引擎可以定义为一个单例。这样处理防止在原生里面存在多引擎,是非常占有内存的
- (FlutterEngine *)flutterEngine {
    if (!_flutterEngine) {
        FlutterEngine * engine = [[FlutterEngine alloc] initWithName:@"flutterEngin"];
        if (engine.run) {
            _flutterEngine = engine;
        }
    }
    return _flutterEngine;
}

Flutter 端代码


class _MyHomePageState extends State<MyHomePage> {

String batteryLevel = '0%';
//定义通道
final MethodChannel _methodhannel =
const MethodChannel('com.pages.your/native_get');

@override
void initState() {
super.initState();

//Flutter端监听发送过来的数据
_methodhannel.setMethodCallHandler((call) {
if (call.method == 'EnterFlutter') {
print(call.arguments);
} else if (call.method == 'BatteryLevel') {
batteryLevel = call.arguments;
}
setState(() {});
return Future(() {});
});
}

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Center(
child: Column(
children: [
ElevatedButton(
onPressed: () {
//发送消息给原生
_methodhannel.invokeListMethod('exit');
},
child: Text('返回'),
),
ElevatedButton(
onPressed: () {
//发送消息给原生
_oneChannel.invokeListMethod('getBatteryLevel');
},
child: Text('当前电量${batteryLevel}'),
),
],
),
),
);
}
}

二、BasicMessageChannel的使用


它是可以双端通信的,Flutter 端可以给 iOS 发送消息,iOS 也可以给 Flutter 发送消息。这段代码实现了在 Flutter 中的 TextField 输入文字,在 iOS 端能及时输出。


原生端代码


需要在上面代码的基础上增加 MessageChannel ,并接收消息和发送消息


@property (nonatomic, strong) FlutterBasicMessageChannel *messageChannel;

self.messageChannel = [FlutterBasicMessageChannel messageChannelWithName:@"messgaeChannel" binaryMessenger:self.flutterVC.binaryMessenger];

[self.messageChannel setMessageHandler:^(id _Nullable message, FlutterReply  _Nonnull callback) {

        NSLog(@"收到Flutter的:%@",message);
    }];

Flutter 端代码


//需要创建和iOS端相同名称的通道
final messageChannel =
const BasicMessageChannel("messgaeChannel", StandardMessageCodec());

监听消息


messageChannel.setMessageHandler((message) {
print('收到来自iOS的$message');
return Future(() {});
});

发送消息


messageChannel.send(str);

三、EventChannel的使用


只能是原生发送消息给 Flutter 端,例如监听手机电量变化,网络变化,传感器等。


我这里在原生端实现了一个定时器,每隔一秒发送一个消息给 Flutter 端,模仿这个功能。


原生端代码


记得所在的类要实现这个协议 FlutterStreamHandler


//定义属性
//通道
@property (nonatomic, strong) FlutterEventChannel *eventChannel;
//事件回调
@property (nonatomic, copy) FlutterEventSink events;
//用于计数
@property (nonatomic, assign) NSInteger count;

//初始化通道
self.eventChannel = [FlutterEventChannel eventChannelWithName:@"eventChannel" binaryMessenger:self.flutterVC.binaryMessenger];

[self.eventChannel setStreamHandler:self];

//调用创建定时器
[self createTimer];

//创建定时器
- (void)createTimer {

    NSTimer *timer = [NSTimer scheduledTimerWithTimeInterval:1 target:self selector: @selector(timeStart) userInfo:nil repeats:YES];
}

//发送消息
- (void)timeStart{

    self.count += 1;
    NSDictionary *dic = [NSDictionary dictionaryWithObject:@(self.count) forKey:@"count"];
    if (self.events != nil) {
        self.events(dic);
    }
}

//代表通道已经建好,原生端可以发送数据了
- (FlutterError* _Nullable)onListenWithArguments:(id _Nullable)arguments eventSink:(FlutterEventSink)eventSink {

    self.events = eventSink;
    return nil;
}

//代表Flutter端不再接收
- (FlutterError* _Nullable)onCancelWithArguments:(id _Nullable)arguments {

    self.events = nil;
    return nil;
}

Flutter 端代码


//创建通道
final EventChannel eventChannel = const EventChannel('eventChannel');

//开始监听数据
eventChannel.receiveBroadcastStream().listen((event) {
print(event.toString());
});

以上就是iOS原生和Flutter通信的三种方式,消息传递是异步的,这确保了用户界面在消息传递时不会被挂起。


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

在外企工作真的爽吗?

从参加工作至今,我觉得,我做过最幸运的事情包括下面三个:在IT圈混的人都知道,做程序员,在一家公司能干够5年,绝对算的上老员工。如果还不跳槽,很多人会觉得你要么缺乏实力,要么没有奋斗精神。因此,在我加入外企的第5个年头,我也动了换工作的心思。而在这之后,照顾家...
继续阅读 »
最近互联网行业裁员的消息,把大家搞的忧心忡忡。对于在换工作或找工作的小伙伴,犹豫是否要到互联网公司了。剩下就是外企、国企和传统行业了,最近外企因为work life balance的口碑比较好,很多小伙伴都挺憧憬的,那么外企到底怎么样呢?下面给大家精选了一下网友在外企的体验文章,给大家了解一下过来人的体会。
我是一名程序员,从30岁加入外企,至今已经11年。
从参加工作至今,我觉得,我做过最幸运的事情包括下面三个:
  1. 娶了现在的老婆。
  2. 房价暴涨之前买了房。
  3. 30岁之后进入外企。
在IT圈混的人都知道,做程序员,在一家公司能干够5年,绝对算的上老员工。如果还不跳槽,很多人会觉得你要么缺乏实力,要么没有奋斗精神。因此,在我加入外企的第5个年头,我也动了换工作的心思。
面试一圈下来,也收到了几个不错的offer。于是也下定了离开的念头,连离职报告都写好了,就差推开领导的门递上去了。而这时,家里突然有人生病,需要我照顾,也打断了我换工作的计划。
而在这之后,照顾家人的这段时间。它彻底改变我对工作,对人生的看法。
因为家人生病的急,没有在第一时间来的及请假。事情处理后,才在短信里给领导请了假,但心里是忐忑的。因为,觉得领导可能会为难,毕竟公司也不是他家开的。但没想到他很快就回复了我:
“照顾好家人,工作的事你就不要担心了。另外,注意休息,保重身体!”
当时读了,我一个大男人,心里暖暖的,鼻子甚至有点酸楚。
通过这件事,使我真切的感受到这是一个充满温情的公司。这种温情,在人生的特殊时刻,至关重要。
如果我在一家制度严苛,领导不近人情的公司。家人生病的这段时间,估计我很可能要被迫离职了。这样,在我人生最灰暗,经济压力最大的时间点上。我反而丢了工作,没了收入。也许,生活一下子就把我彻底击倒了。
而从那之后的5年。随着时间的推移,我身体的变化,我愈加的觉出,我当时没离开这家公司是多么正确的一个决定。
人生特别荒谬的一点是,当你30岁精力旺盛的时候。你既无法想象,也无法理解一个40岁油腻中年男,对家庭和健康的感受。于是,你一边鄙视着别人稳定的生活,一边高呼着奋斗的口号要去外边闯荡,去赢取别人眼中的财务自由。
但眨眼间,等你真到40岁,期待的财务自由并没有到来。但精力已大不如前,健康问题此起彼伏的时候,就会刻骨的体会到什么叫做人生的无力感。但是,你却已经没了退路。
人到40,当你再也加不动班,却也找不到一个温情公司收留的时候,你怎么办?这个时候,你终于认识到了公务员、国企、外企的好处,但你却没有了机会。
于是,在人生最艰难的节骨点,你被吊在了哪里,没有了出路。
但这能怪谁呢?人总不能在所有的时候,把所有的好处都占了吧,这并不公平!
今年我41岁,按部就班的在公司上班, 也常被人嘲笑没出息,缺乏“奋斗”的激情。但这世界上,是谁规定“奋斗”的人生就更高尚呢?
对我来说,理想生活就是现在这样:
  1. 没有996。
  2. 一年20天年假。
  3. 家庭和生活完美平衡。
  4. 工资虽然没那么高,但也够有尊严的生活。
  5. 同事之间和和气气,互相尊重。
  6. ...
至于好不好,不同的年龄,不同的经历,自会有不同的感受。
“如人饮水,冷暖自知”。
作者:铅刀一割、沈世钧
来源:https://www.zhihu.com/question/299766610
该死的知乎,不知道为啥非要给我推荐这个帖子“在外企工作爽吗?”,下面这个回答看起来更刺激。

爽。
上周刚刚从前东家离职。
离职原因不是我跳槽,而是集团我们的业务条线退出中国,被迫离职。
这是我进的第一家外企,也坚定了我之后(大概率)只在外企找活的信念。
首先上一个我的工作台位镇楼。
200多平的办公室,只有5个员工。

我司隶属一家欧洲集团,我所在的子公司从事非常小众的环境权益交易。
这在欧美已经是成熟产业,但在国内还处于起步阶段。
加之持有这些资产的多为能源国企(涉及国有资产流失等口径问题),所以总部也是保持“试水”的态度设立了我司。
公司地处使馆区,与集团另外一个规模较大的兄弟公司在同一层。
楼下一众网红咖啡馆、餐厅,交通十分便利。
每天上下班不打卡,大家基本是10点左右陆续到,6点左右陆续走。
但你有事,三点走也没人管你。
进公司就是15天年假(社会工龄十年以上则是20天),每多呆一年,增加一天年假,封顶25天。
如果生病了,和老板说一下就好,所以我从来没有关心过自己有几天病假……
我有个同事妈妈得了癌症,有一阵子天天3点左右就走了,大家也都十分理解(当然他不久之后陪家里人出国治病了,也就直接离职了)。
另外一个同事自己生病,整整一年时间没有来上班(名义上是在家办公,其实她的活都是亚太同事分摊了),领着全额工资,住的单人病房也是公司的商业保险全包的。
薪水一般。
和我bat的同学比……人活着何必给自己找不痛快?
但站在性价比角度来看还行。
来源:https://mp.weixin.qq.com/s/fAW1V9ZJNBHWX0M4vONC 4A
收起阅读 »

优秀的后端应该有哪些开发习惯?

前言毕业快三年了,前后也待过几家公司,碰到各种各样的同事。见识过各种各样的代码,优秀的、垃圾的、不堪入目的、看了想跑路的等等,所以这篇文章记录一下一个优秀的后端 Java 开发应该有哪些好的开发习惯。拆分合理的目录结构受传统的 MVC 模式影响,传统做法大多是...
继续阅读 »

前言

毕业快三年了,前后也待过几家公司,碰到各种各样的同事。见识过各种各样的代码,优秀的、垃圾的、不堪入目的、看了想跑路的等等,所以这篇文章记录一下一个优秀的后端 Java 开发应该有哪些好的开发习惯。

拆分合理的目录结构

受传统的 MVC 模式影响,传统做法大多是几个固定的文件夹 controller、service、mapper、entity,然后无限制添加,到最后你就会发现一个 service 文件夹下面有几十上百个 Service 类,根本没法分清业务模块。正确的做法是在写 service 上层新建一个 modules 文件夹,在 moudles 文件夹下根据不同业务建立不同的包,在这些包下面写具体的 service、controller、entity、enums 包或者继续拆分。



等以后开发版本迭代,如果某个包可以继续拆领域就继续往下拆,可以很清楚的一览项目业务模块。后续拆微服务也简单。

封装方法形参

当你的方法形参过多时请封装一个对象出来...... 下面是一个反面教材,谁特么教你这样写代码的!

public void updateCustomerDeviceAndInstallInfo(long customerId, String channelKey,
                  String androidId, String imei, String gaId,
                  String gcmPushToken, String instanceId) {}

写个对象出来

public class CustomerDeviceRequest {
  private Long customerId;
  //省略属性......
}

为什么要这么写?比如你这方法是用来查询的,万一以后加个查询条件是不是要修改方法?每次加每次都要改方法参数列表。封装个对象,以后无论加多少查询条件都只需要在对象里面加字段就行。而且关键是看起来代码也很舒服啊!

封装业务逻辑

如果你看过“屎山”你就会有深刻的感触,这特么一个方法能写几千行代码,还无任何规则可言......往往负责的人会说,这个业务太复杂,没有办法改善,实际上这都是懒的借口。不管业务再复杂,我们都能够用合理的设计、封装去提升代码可读性。下面贴两段高级开发(假装自己是高级开发)写的代码

@Transactional
public ChildOrder submit(Long orderId, OrderSubmitRequest.Shop shop) {
  ChildOrder childOrder = this.generateOrder(shop);
  childOrder.setOrderId(orderId);
  //订单来源 APP/微信小程序
  childOrder.setSource(userService.getOrderSource());
  // 校验优惠券
  orderAdjustmentService.validate(shop.getOrderAdjustments());
  // 订单商品
  orderProductService.add(childOrder, shop);
  // 订单附件
  orderAnnexService.add(childOrder.getId(), shop.getOrderAnnexes());
  // 处理订单地址信息
  processAddress(childOrder, shop);
  // 最后插入订单
  childOrderMapper.insert(childOrder);
  this.updateSkuInventory(shop, childOrder);
  // 发送订单创建事件
  applicationEventPublisher.publishEvent(new ChildOrderCreatedEvent(this, shop, childOrder));
  return childOrder;
}
@Transactional
public void clearBills(Long customerId) {
  // 获取清算需要的账单、deposit等信息
  ClearContext context = getClearContext(customerId);
  // 校验金额合法
  checkAmount(context);
  // 判断是否可用优惠券,返回可抵扣金额
  CouponDeductibleResponse deductibleResponse = couponDeducted(context);
  // 清算所有账单
  DepositClearResponse response = clearBills(context);
  // 更新 l_pay_deposit
  lPayDepositService.clear(context.getDeposit(), response);
  // 发送还款对账消息
  repaymentService.sendVerifyBillMessage(customerId, context.getDeposit(), EventName.DEPOSIT_SUCCEED_FLOW_REMINDER);
  // 更新账户余额
  accountService.clear(context, response);
  // 处理清算的优惠券,被用掉或者解绑
  couponService.clear(deductibleResponse);
  // 保存券抵扣记录
  clearCouponDeductService.add(context, deductibleResponse);
}

这段两代码里面其实业务很复杂,内部估计保守干了五万件事情,但是不同水平的人写出来就完全不同,不得不赞一下这个注释,这个业务的拆分和方法的封装。一个大业务里面有多个小业务,不同的业务调用不同的 service 方法即可,后续接手的人即使没有流程图等相关文档也能快速理解这里的业务,而很多初级开发写出来的业务方法就是上一行代码是 A 业务的,下一行代码是 B业务的,在下面一行代码又是 A 业务的,业务调用之间还嵌套这一堆单元逻辑,显得非常混乱,代码还多。

判断集合类型不为空的正确方式

很多人喜欢写这样的代码去判断集合

if (list == null || list.size() == 0) {
return null;
}

当然你硬要这么写也没什么问题......但是不觉得难受么,现在框架中随便一个 jar 包都有集合工具类,比如 org.springframework.util.CollectionUtilscom.baomidou.mybatisplus.core.toolkit.CollectionUtils 。 以后请这么写

if (CollectionUtils.isEmpty(list) || CollectionUtils.isNotEmpty(list)) {
return null;
}

集合类型返回值不要 return null

当你的业务方法返回值是集合类型时,请不要返回 null,正确的操作是返回一个空集合。你看 mybatis 的列表查询,如果没查询到元素返回的就是一个空集合,而不是 null。否则调用方得去做 NULL 判断,多数场景下对于对象也是如此。

映射数据库的属性尽量不要用基本类型

我们都知道 int/long 等基本数据类型作为成员变量默认值是 0。现在流行使用 mybatisplus 、mybatis 等 ORM 框架,在进行插入或者更新的时候很容易会带着默认值插入更新到数据库。我特么真想砍了之前的开发,重构的项目里面实体类里面全都是基本数据类型。当场裂开......

封装判断条件

public void method(LoanAppEntity loanAppEntity, long operatorId) {
if (LoanAppEntity.LoanAppStatus.OVERDUE != loanAppEntity.getStatus()
        && LoanAppEntity.LoanAppStatus.CURRENT != loanAppEntity.getStatus()
        && LoanAppEntity.LoanAppStatus.GRACE_PERIOD != loanAppEntity.getStatus()) {
  //...
  return;
}

这段代码的可读性很差,这 if 里面谁知道干啥的?我们用面向对象的思想去给 loanApp 这个对象里面封装个方法不就行了么?

public void method(LoanAppEntity loan, long operatorId) {
if (!loan.finished()) {
  //...
  return;
}

LoanApp 这个类中封装一个方法,简单来说就是这个逻辑判断细节不该出现在业务方法中。

/**
* 贷款单是否完成
*/
public boolean finished() {
return LoanAppEntity.LoanAppStatus.OVERDUE != this.getStatus()
        && LoanAppEntity.LoanAppStatus.CURRENT != this.getStatus()
        && LoanAppEntity.LoanAppStatus.GRACE_PERIOD != this.getStatus();
}

控制方法复杂度

推荐一款 IDEA 插件 CodeMetrics ,它能显示出方法的复杂度,它是对方法中的表达式进行计算,布尔表达式,if/else 分支,循环等。


点击可以查看哪些代码增加了方法的复杂度,可以适当进行参考,毕竟我们通常写的是业务代码,在保证正常工作的前提下最重要的是要让别人能够快速看懂。当你的方法复杂度超过 10 就要考虑是否可以优化了。

使用 @ConfigurationProperties 代替 @Value

之前居然还看到有文章推荐使用 @Value 比 @ConfigurationProperties 好用的,吐了,别误人子弟。列举一下 @ConfigurationProperties 的好处。

  • 在项目 application.yml 配置文件中按住 ctrl + 鼠标左键点击配置属性可以快速导航到配置类。写配置时也能自动补全、联想到注释。需要额外引入一个依赖 org.springframework.boot:spring-boot-configuration-processor


  • @ConfigurationProperties 支持 NACOS 配置自动刷新,使用 @Value 需要在 BEAN 上面使用 @RefreshScope 注解才能实现自动刷新

  • @ConfigurationProperties 可以结合 Validation 校验,@NotNull、@Length 等注解,如果配置校验没通过程序将启动不起来,及早的发现生产丢失配置等问题。

  • @ConfigurationProperties 可以注入多个属性,@Value 只能一个一个写

  • @ConfigurationProperties 可以支持复杂类型,无论嵌套多少层,都可以正确映射成对象

相比之下我不明白为什么那么多人不愿意接受新的东西,裂开......你可以看下所有的 springboot-starter 里面用的都是 @ConfigurationProperties 来接配置属性。

推荐使用 lombok

当然这是一个有争议的问题,我的习惯是使用它省去 getter、setter、toString 等等。

不要在 AService 调用 BMapper

我们一定要遵循从 AService -> BService -> BMapper,如果每个 Service 都能直接调用其他的 Mapper,那特么还要其他 Service 干嘛?老项目还有从 controller 调用 mapper 的,把控制器当 service 来处理了。。。

尽量少写工具类

为什么说要少写工具类,因为你写的大部分工具类,在你无形中引入的 jar 包里面就有,String 的,Assert 断言的,IO 上传文件,拷贝流的,Bigdecimal 的等等。自己写容易错还要加载多余的类。

不要包裹 OpenFeign 接口返回值

搞不懂为什么那么多人喜欢把接口的返回值用 Response 包装起来......加个 code、message、success 字段,然后每次调用方就变成这样

CouponCommonResult bindResult = couponApi.useCoupon(request.getCustomerId(), order.getLoanId(), coupon.getCode());
if (Objects.isNull(bindResult) || !bindResult.getResult()) {
throw new AppException(CouponErrorCode.ERR_REC_COUPON_USED_FAILED);
}

这样就相当于

  1. 在 coupon-api 抛出异常

  2. 在 coupon-api 拦截异常,修改 Response.code

  3. 在调用方判断 response.code 如果是 FAIELD 再把异常抛出去......

你直接在服务提供方抛异常不就行了么。。。而且这样一包装 HTTP 请求永远都是 200,没法做重试和监控。当然这个问题涉及到接口响应体该如何设计,目前网上大多是三种流派

  • 接口响应状态一律 200

  • 接口响应状态遵从HTTP真实状态

  • 佛系开发,领导怎么说就怎么做

不接受反驳,我推荐使用 HTTP 标准状态。特定场景包括参数校验失败等一律使用 400 给前端弹 toast。下篇文章会阐述一律 200 的坏处。

写有意义的方法注释

这种注释你写出来是怕后面接手的人瞎么......

/**
* 请求电话验证
*
* @param credentialNum
* @param callback
* @param param
* @return PhoneVerifyResult
*/

要么就别写,要么就在后面加上描述......写这样的注释被 IDEA 报一堆警告看着蛋疼

和前端交互的 DTO 对象命名

什么 VO、BO、DTO、PO 我倒真是觉得没有那么大必要分那么详细,至少我们在和前端交互的时候类名要起的合适,不要直接用映射数据库的类返回给前端,这会返回很多不必要的信息,如果有敏感信息还要特殊处理。

推荐的做法是接受前端请求的类定义为 XxxRequest,响应的定义为 XxxResponse。以订单为例:接受保存更新订单信息的实体类可以定义为 OrderRequest,订单查询响应定义为 OrderResponse,订单的查询条件请求定义为 OrderQueryRequest

尽量别让 IDEA 报警

我是很反感看到 IDEA 代码窗口一串警告的,非常难受。因为有警告就代表代码还可以优化,或者说存在问题。 前几天捕捉了一个团队内部的小bug,其实本来和我没有关系,但是同事都在一头雾水的看外面的业务判断为什么走的分支不对,我一眼就扫到了问题。

因为 java 中整数字面量都是 int 类型,到集合中就变成了 Integer,然后 stepId 点上去一看是 long 类型,在集合中就是 Long,那这个 contains 妥妥的返回 false,都不是一个类型。

你看如果注重到警告,鼠标移过去看一眼提示就清楚了,少了一个生产 bug。

尽可能使用新技术组件

我觉得这是一个程序员应该具备的素养......反正我是喜欢用新的技术组件,因为新的技术组件出现必定是解决旧技术组件的不足,而且作为一个技术人员我们应该要与时俱进~~ 当然前提是要做好准备工作,不能无脑升级。举个最简单的例子,Java 17 都出来了,新项目现在还有人用 Date 来处理日期时间...... 都什么年代了你还在用 Date

结语

本篇文章简单介绍我日常开发的习惯,当然仅是作者自己的见解。暂时只想到这几点,以后发现其他的会更新。

作者:暮色妖娆丶
来源:https://juejin.cn/post/7072252275002966030

收起阅读 »

纯后端如何写前端?我用了低代码平台

我是3y,一年CRUD经验用十年的markdown程序员👨🏻‍💻常年被誉为优质八股文选手花了几天搭了个后台管理页面,今天分享下我的搭建过程,全文非技术向,就当跟大家吹吹水吧。1、我的前端技术老读者可能知道我是上了大学以后,才了解什么是编程。在这之前,我对编程一...
继续阅读 »

我是3y,一年CRUD经验用十年的markdown程序员👨🏻‍💻常年被誉为优质八股文选手

花了几天搭了个后台管理页面,今天分享下我的搭建过程,全文非技术向,就当跟大家吹吹水吧。


1、我的前端技术

老读者可能知道我是上了大学以后,才了解什么是编程。在这之前,我对编程一无所知,甚至报考了计算机专业之后也未曾了解过它是做什么的。

在大一的第一个学期,我印象中只开了一门C++的编程课(其他的全是数学)。嗯,理所当然,我是听不懂的,也不知道用来干什么。


刚进大学的时候,我对一切充满了未知,在那时候顺其自然地就想要进几个社团玩玩。但在众多社团里都找不到我擅长的领域,等快到截止时间了。我又不想大学期间什么社团都没有参加,最后报了两个:乒乓球社团和计算机协会

这个计算机协会绝大多数的人员都来自于计算机专业,再后来才发现这个协会的主要工作就是给人「重装系统」,不过这是后话啦。

当时加入计算机协会还需要满足一定的条件:师兄给了一个「网站」我们这群人,让我们上去学习,等到国庆回来后看下我们的学习进度再来决定是否有资格加入。

那个网站其实就是对HTML/CSS/JavaScript入门教程,是一个国外的网站,具体的地址我肯定是忘了。不过那时候,我国庆闲着也没事干,于是就开始学起来了。我当时的进度应该是学到CSS,能简单的页面布局和展示图片啥的

刚开始的时候,觉得蛮有趣的:我改下这个代码,字体的颜色就变了,图片就能展示出来了。原来我平时上网的网站是这样弄出来的啊! (比什么C++有趣多了)

国庆后回来发现:考核啥的并不重要,只要报名了就都通过了。


有了基本的认知后,我对这个也并不太上心,没有持续地学下去。再后来,我实在是太无聊,就开始想以后毕业找工作的事了,自己也得在大学充实下自己,于是我开始在知乎搜各种答案「如何入门编程」。

在知乎搜了各种路线并浪费了大量时间以后,我终于开始看视频入门。我熬完了JavaSE基础之后,我记得我是看方立勋老师入门的JavaWeb,到前端的课程以后,我觉得前端HTML/CSS/JavaScript啥的都要补补,于是又去找资源学习(那时候信奉着技多不压身)。

印象中是看韩顺平老师的HTML/CSS/JavaScript,那时候还手打代码的阶段,把我看得一愣一愣的(IDE都不需要的)。随着学习,发现好像还得学AJAX/jQuery,于是我又去找资源了,不过我已经忘了看哪个老师的AJAXjQuery课程。

在这个学习的过程中,我曾经用纯HTML/CSS/JavaScript跟着视频仿照过某某网站,在jQuery的学习时候做过各种的轮播图动画。还理解了marginpadding的区别。临近毕业的时候,也会点BootStrap来写个简单的页面(丑就完事了)


等我进公司了以后,技术架构前后端是分离的,虽然我拉了前端的代码,但我看不懂,期间我也没学。以至于我两年多是没碰过前端的,我对前端充满着敬畏(刚毕业那段时间,前端在飞速发展

2、AUSTIN前端选型

从我筹划要写austin项目的时候,我就知道我肯定要写一个「后台管理页面」,但我迟迟没下手。一方面是我认为「后端」才是我的赛道,另一方面我「前端」确实菜,不想动手。

我有想过要不找个小伙伴帮我写,但是很快就被我自己否定了:还得给小伙伴提需求,算了


当我要面临前端的时,我第一时间就想到:肯定是有什么框架能够快速搭建出一个管理页面的。我自己不知道,但是,我的朋友圈肯定是有人知道的啊。于是,我果断求助:


我被安利了很多框架,简单列举下出场率比较高的。

:大多数我只是粗略看了下,没有仔细研究。若有错误可以在评论区留言,轻喷

2.1 renren-fast

官网文档:http://www.renren.io/guide#getdo…


它这个框架是前后端分离的,后端还可以生成对应的CRUD代码,前端基于vueelement-ui开发。

当时其实我有点想选它的,但考虑到我要再部署个后端,还得学点vue,我就搁置了

2.2 RuoYi

官方文档:doc.ruoyi.vip/ruoyi/


RuoYi给我安利的也很多,这个貌似最近非常火?感觉我被推荐了以后,到处都能看到它的身影。

我简单刷了下文档,感觉他做的事比renren-fast要多,文档也很齐全,但是没找到我想要的东西:我打开一个文档,我希望能看到它的系统架构,系统之间的交互或者架构层面上的东西,但我没快速找到。

项目齐全和复杂对我来说或许并不是一件好事,很可能意味着我的学习成本可能会更大。于是,我也搁置着。

2.3 Vue相关

vue-element-admin

官方文档:panjiachen.github.io/vue-element…


Vue Antd Admin

官方文档:iczer.gitee.io/vue-antd-ad…


Ant Design Pro

官方文档:pro.antdv.com/docs/gettin…


这几个项目被推荐率也是极高的,从第一行介绍我基本就知道需要去学Vue的语法,奈何我太懒了,搁置着。

2.4 layui

有好几小伙伴们听说我会jQuery,于是给我推荐了layui。我以前印象中好像听过这个框架,但一直没了解过他。但是,当我搜到它的时候,它已经不维护了


GitHub地址:github.com/sentsin/lay…

我简单浏览下文档,其实它也有对应的一套”语法“,需要一定的学习成本,但不高。


第一感觉有点类似我以前写过的BootStrap,我对这不太感冒,感觉如果要接入可能还是需要自己写比较多的代码。

2.5 其他

还有些小伙伴推荐或者我看到的文章推荐:x-admin/D2admin/smartchart/JEECG-BOOT/Dcat-admin/iview-admin等等等,在这里面还有些依赖着PHP/Python

总的来说,我还是觉得这些框架有一定的学习成本(我真的是懒出天际了)。可能需要我去部署后端,也可能需要我学习前端的框架语法,也可能让我学Vue

看到这里,可能你们很好奇我最后选了什么作为austin的前端,都已经被我筛了这么多了。在公布之前,我想说的是:如果想要页面好看灵活性高还是得学习Vue。从上面我被推荐的框架中,好多都是在Vue的基础上改动的,并且我敢肯定:还有很多基于Vue且好用的后台是我不知道的。

:我这里指代跟我一样不懂前端的(如果本身就已经懂前端,你说啥都对)


3、AMIS框架

我最后选择了amis作为austin的前端。这个框架在我朋友圈只有一个小伙伴推荐,我第一次打开文档的时候,确实惊艳到我了


文档地址:baidu.gitee.io/amis/zh-CN/…

它是一个低代码前端框架:amis 的渲染过程是将 json 转成对应的 React 组件

我花了半天粗略地刷了下文档,大概知道了JSON的结构(说实话,他这个文档写得挺可以的),然后我去GitHub找了一份模板,就直接开始动手了,readme十分简短。


GitHub:github.com/aisuda/amis…

这个前端低代码工具还有个好处就是可以通过可视化编辑器拖拉生成JSON代码,将生成好的代码直接往自己本地一贴,就完事了,确实挺方便的。


可视化编辑器的地址:aisuda.github.io/amis-editor…

4、使用感受

其实没什么好讲的,无非就是在页面上拖拉得到一个页面,然后调用API的时候看下文档的姿势。

在这个过程中我也去看了下这个框架的评价,发现百度内部很多系统就用的这个框架来搭建页面的,也看到Bigo也有在线上使用这个框架来搭建后台。有一线/二线公司都在线上使用该框架了,我就认为问题不大了。

总的来说,我这次搭建austin后台实际编码时间没多少,都在改JSON配置和查文档。我周六下午2点到的图书馆,新建了GitHub仓库,在6点闭馆前就已经搭出个大概页面了,然后在周日空闲时间里再完善了几下,感觉可以用了

austin-amis仓库地址:github.com/ZhongFuChen…

在搭建的过程中,amis低代码框架还是有地方可吐槽的,就是它的灵活性太低。我们的接口返回值需要迎合它的主体结构,当我们如果有嵌套JSON这种就变得异常难处理,表单无法用表达式进行回显等等。

它并不完美,很可能需要我用些奇怪的姿势妥协,不要问我接口返回的时候为啥转了一层Map


不管怎么说,这不妨碍我花了极短的时间就能搭出一个能看的后台管理页面(CRUD已齐全)


5、总结

目前搭好的前端能用,也只能用一点点,后面会逐渐完善它的配置和功能的。我后面有链路追踪的功能,肯定要在后台这把清洗后的数据提供给后台进行查询,但也不会花比较长的篇幅再来聊前端这事了。

我一直定位是在后端的代码上,至于前端我能学,但我又不想学。怎么说呢,利益最大化吧。我把学前端的时间花在学后端上,或许可能对我有更大的受益。现在基本前后端分离了,在公司我也没什么机会写前端。

下一篇很有可能是聊分布式定时任务框架上,我发现我的进度可以的,这个季度拿个4.0应该问题不大了。

都看到这里了,点个赞一点都不过分吧?我是3y,下期见。


austin项目源码Gitee链接:gitee.com/austin

austin项目源码GitHub链接:github.com/austin


作者:Java3y
来源:https://juejin.cn/post/7076231399669235725

收起阅读 »

你最少用几行代码实现深拷贝?

前言深度克隆(深拷贝)一直都是初、中级前端面试中经常被问到的题目,网上介绍的实现方式也都各有千秋,大体可以概括为三种方式:JSON.stringify+JSON.pars e, 这个很好理解;全量判断类型,根据类型做不同的处理2的变型,简化类型判断过程前两种比...
继续阅读 »

前言

深度克隆(深拷贝)一直都是初、中级前端面试中经常被问到的题目,网上介绍的实现方式也都各有千秋,大体可以概括为三种方式:

  1. JSON.stringify+JSON.pars e, 这个很好理解;

  2. 全量判断类型,根据类型做不同的处理

  3. 2的变型,简化类型判断过程

前两种比较常见也比较基础,所以我们今天主要讨论的是第三种。

阅读全文你将学习到:

  1. 更简洁的深度克隆方式

  2. Object.getOwnPropertyDescriptors()api

  3. 类型判断的通用方法

问题分析

深拷贝 自然是 相对 浅拷贝 而言的。 我们都知道 引用数据类型 变量存储的是数据的引用,就是一个指向内存空间的指针, 所以如果我们像赋值简单数据类型那样的方式赋值的话,其实只能复制一个指针引用,并没有实现真正的数据克隆。

通过这个例子很容易就能理解:

const obj1 = {
   name: 'superman'
}
const obj2 = obj1;
obj1.name = '前端切图仔';
console.log(obj2.name); // 前端切图仔

所以深度克隆就是为了解决引用数据类型不能被通过赋值的方式 复制 的问题。

引用数据类型

我们不妨来罗列一下引用数据类型都有哪些:

  • ES6之前: Object, Array, Date, RegExp, Error,

  • ES6之后: Map, Set, WeakMap, WeakSet,

所以,我们要深度克隆,就需要对数据进行遍历并根据类型采取相应的克隆方式。 当然因为数据会存在多层嵌套的情况,采用递归是不错的选择。

简单粗暴版本

function deepClone(obj) {
   let res = {};
   // 类型判断的通用方法
   function getType(obj) {
       return Object.prototype.toString.call(obj).replaceAll(new RegE xp(/\[|\]|object /g), "");
  }
   const type = getType(obj);
   const reference = ["Set", "WeakSet", "Map", "WeakMap", "RegExp", "Date", "Error"];
   if (type === "Object") {
       for (const key in obj) {
           if (Object.hasOwnProperty.call(obj, key)) {
               res[key] = deepClone(obj[key]);
          }
      }
  } else if (type === "Array") {
       console.log('array obj', obj);
       obj.forEach((e, i) => {
           res[i] = deepClone(e);
      });
  }
   else if (type === "Date") {
       res = new Date(obj);
  } else if (type === "RegExp") {
       res = new RegExp(obj);
  } else if (type === "Map") {
       res = new Map(obj);
  } else if (type === "Set") {
       res = new Set(obj);
  } else if (type === "WeakMap") {
       res = new WeakMap(obj);
  } else if (type === "WeakSet") {
       res = new WeakSet(obj);
  }else if (type === "Error") {
       res = new Error(obj);
  }
    else {
       res = obj;
  }
   return res;
}

其实这就是我们最前面提到的第二种方式,很傻对不对,明眼人一眼就能看出来有很多冗余代码可以合并。

我们先进行最基本的优化:

合并冗余代码

将一眼就能看出来冗余的代码合并下。

function deepClone(obj) {
   let res = null;
   // 类型判断的通用方法
   function getType(obj) {
       return Object.prototype.toString.call(obj).replaceAll(new RegExp(/\[|\]|object /g), "");
  }
   const type = getType(obj);
   const reference = ["Set", "WeakSet", "Map", "WeakMap", "RegExp", "Date", "Error"];
   if (type === "Object") {
       res = {};
       for (const key in obj) {
           if (Object.hasOwnProperty.call(obj, key)) {
               res[key] = deepClone(obj[key]);
          }
      }
  } else if (type === "Array") {
       console.log('array obj', obj);
       res = [];
       obj.forEach((e, i) => {
           res[i] = deepClone(e);
      });
  }
   // 优化此部分冗余判断
   // else if (type === "Date") {
   //     res = new Date(obj);
   // } else if (type === "RegExp") {
   //     res = new RegExp(obj);
   // } else if (type === "Map") {
   //     res = new Map(obj);
   // } else if (type === "Set") {
   //     res = new Set(obj);
   // } else if (type === "WeakMap") {
   //     res = new WeakMap(obj);
   // } else if (type === "WeakSet") {
   //     res = new WeakSet(obj);
   // }else if (type === "Error") {
   //   res = new Error(obj);
   //}
   else if (reference.includes(type)) {
       res = new obj.constructor(obj);
  } else {
       res = obj;
  }
   return res;
}

为了验证代码的正确性,我们用下面这个数据验证下:

const map = new Map();
map.set("key", "value");
map.set("ConardLi", "coder");

const set = new Set();
set.add("ConardLi");
set.add("coder");

const target = {
   field1: 1,
   field2: undefined,
   field3: {
       child: "child",
  },
   field4: [2, 4, 8],
   empty: null,
   map,
   set,
   bool: new Boolean(true),
   num: new Number(2),
   str: new String(2),
   symbol: Object(Symbol(1)),
   date: new Date(),
   reg: /\d+/,
   error: new Error(),
   func1: () => {
       let t = 0;
       console.log("coder", t++);
  },
   func2: function (a, b) {
       return a + b;
  },
};
//测试代码
const test1 = deepClone(target);
target.field4.push(9);
console.log('test1: ', test1);

执行结果:


还有进一步优化的空间吗?

答案当然是肯定的。

// 判断类型的方法移到外部,避免递归过程中多次执行
const judgeType = origin => {
   return Object.prototype.toString.call(origin).replaceAll(new RegExp(/\[|\]|object /g), "");
};
const reference = ["Set", "WeakSet", "Map", "WeakMap", "RegExp", "Date", "Error"];
function deepClone(obj) {
   // 定义新的对象,最后返回
    //通过 obj 的原型创建对象
   const cloneObj = Object.create(Object.getPrototypeOf(obj), Object.getOwnPropertyDescriptors(obj));

   // 遍历对象,克隆属性
   for (let key of Reflect.ownKeys(obj)) {
       const val = obj[key];
       const type = judgeType(val);
       if (reference.includes(type)) {
           newObj[key] = new val.constructor(val);
      } else if (typeof val === "object" && val !== null) {
           // 递归克隆
           newObj[key] = deepClone(val);
      } else {
           // 基本数据类型和function
           newObj[key] = val;
      }
  }
   return newObj;
}

执行结果如下:


  • Object.getOwnPropertyDescriptors() 方法用来获取一个对象的所有自身属性的描述符。

  • 返回所指定对象的所有自身属性的描述符,如果没有任何自身属性,则返回空对象。

具体解释和内容见MDN

这样做的好处就是能够提前定义好最后返回的数据类型。

这个实现参考了网上一位大佬的实现方式,个人觉得理解成本有点高,而且对数组类型的处理也不是特别优雅, 返回类数组。

我在我上面代码的基础上进行了改造,改造后的代码如下:

function deepClone(obj) {
   let res = null;
   const reference = [Date, RegExp, Set, WeakSet, Map, WeakMap, Error];
   if (reference.includes(obj?.constructor)) {
       res = new obj.constructor(obj);
  } else if (Array.isArray(obj)) {
       res = [];
       obj.forEach((e, i) => {
           res[i] = deepClone(e);
      });
  } else if (typeof obj === "Object" && obj !== null) {
       res = {};
       for (const key in obj) {
           if (Object.hasOwnProperty.call(obj, key)) {
               res[key] = deepClone(obj[key]);
          }
      }
  } else {
       res = obj;
  }
   return res;
}

虽然代码量上没有什么优势,但是整体的理解成本和你清晰度上我觉得会更好一点。那么你觉得呢?

最后,还有循环引用问题,避免出现无线循环的问题。

我们用hash来存储已经加载过的对象,如果已经存在的对象,就直接返回。

function deepClone(obj, hash = new WeakMap()) {
   if (hash.has(obj)) {
       return obj;
  }
   let res = null;
   const reference = [Date, RegExp, Set, WeakSet, Map, WeakMap, Error];

   if (reference.includes(obj?.constructor)) {
       res = new obj.constructor(obj);
  } else if (Array.isArray(obj)) {
       res = [];
       obj.forEach((e, i) => {
           res[i] = deepClone(e);
      });
  } else if (typeof obj === "Object" && obj !== null) {
       res = {};
       for (const key in obj) {
           if (Object.hasOwnProperty.call(obj, key)) {
               res[key] = deepClone(obj[key]);
          }
      }
  } else {
       res = obj;
  }
   hash.set(obj, res);
   return res;
}

总结

对于深拷贝的实现,可能存在很多不同的实现方式,关键在于理解其原理,并能够记住一种最容易理解和实现的方式,面对类似的问题才能做到 临危不乱,泰然自若。 上面的实现你觉得哪个更好呢?欢迎大佬们在评论区交流~


作者:前端superman
来源:https://juejin.cn/post/7075351322014253064

收起阅读 »

为了快乐的摸鱼,专门写了个网站!

这是鄙人做的网站,目的呢原本是为了摸鱼,把产品那边整的页面快速构建出来,咱们公司用的是比较老的vue2版本,组件库是ant-design-vue,做的系统是一些中规中矩的企业用的办公系统,所以页面都是千篇一律。作为卑微的996社畜,不想被肆无忌惮的压榨,于是有...
继续阅读 »

直接进入主题: demo

这是鄙人做的网站,目的呢原本是为了摸鱼,把产品那边整的页面快速构建出来,咱们公司用的是比较老的vue2版本,组件库是ant-design-vue,做的系统是一些中规中矩的企业用的办公系统,所以页面都是千篇一律。作为卑微的996社畜,不想被肆无忌惮的压榨,于是有一天,我就琢磨着通过拖拉拽的方式把组件模块组合起来,能快速的响应产品那边朝令夕改的无理要求。

经过将近一个月的鼓捣,小破站也在命运多舛中慢慢走向成熟。

先简单介绍吧,显而易见的操作界面:传统的页眉,低调却不失风采;左侧的手风琴列表,简约而不简单;中控是一个设计器,有了它你可以写出一个出色的网页,而不需要写一行代码(少量代码还是必要的)!





本小破站还做了国际化、自适应,能基本满足常规的企业系统界面需求,比如传统的ERP/HR/SDM等后台管理系统,页面的顶部有一个下拉框,里面有默认的几个示例,都是通过这种拖拽方式做出来的。

有一个地方需要特别说明,就是组件提供的事件回调函数提供w,w,w,vm这两个全局参数。w表示当前window全局对象,w表示当前window全局对象,w表示当前window全局对象,vm则代表全局vm对象,也就是this。通过这两个参数,是可以简单的写出组件间调用的方法的 (可以看看test#table这个例子)。当然,涉及更复杂一点的业务逻辑,则需要做更多的代码复用,以及watch监听等等,这部分功能的话暂时还没有想好怎么实现。

组件有基本的antd组件、echarts组件,还有vue-3d-model组件,为了更方便的编辑属性和代码,用了bin-ace-editor,有了这些大佬们的轮子,转起来确实快乐。

功能还在逐步完善中,最近也没很多时间去写,总之有时间就去补充,日积月累的完善吧。

PS:

小破站带宽是乞丐版的1Mb,鄙人已经尽力做了cdn加速,希望不卡

第一次打开会自动生成3个示例,放在localStorage里面

感谢阅读 ^_^


作者:AllenThomas
来源:https://juejin.cn/post/7077743139934437406

收起阅读 »

95年女程序员内心的感受

作为一个95年女程序员,2022也是我从事前端的第5个年头了,期间换过2个公司,想和大家分享下真实感受。坐标: 东北 故事开始 95年出生的女生,大专学历,大学专业为“电子商务”, 因为接触到网页设计与制作以及PS等课程,从而对通过代码写过网页产生了兴趣,东...
继续阅读 »

作为一个95年女程序员,2022也是我从事前端的第5个年头了,期间换过2个公司,想和大家分享下真实感受。坐标: 东北



故事开始


95年出生的女生,大专学历,大学专业为“电子商务”, 因为接触到网页设计与制作以及PS等课程,从而对通过代码写过网页产生了兴趣,东西做出来觉得很有成就感,于是实习期间抱着期待的心情投了很多关于网页设计和网页制作相关的工作,但是更多等来的是培训机构的电话,被告知不通过培训时找不到工作的,学习的东西太少不够用于工作,于是阴差阳错的去了培训机构学习了前端开发...


第一个公司


第一家公司是我2017年3月初入职的,是一个外包公司,由于是mini型公司,没有前端,所以是一个UI小姐姐面试的我,刚好我懂一点设计,聊的比较来,并没有问我太多技术问题就让我去上班了,就这样开启了我的第一份工作...


感受:公司很小,人员很nice,成长很快,初创型企业真的很锻炼人,当只有我一个前端面临问题时,真的很无助,但也因此锻炼我发现问题解决问题的能力,不会就学,不懂就百度,慢慢的也开始能够独当一面,虽然经常加班,但是我很感谢这一份经历,虽然工资低也很累但是也收获了巨大成长


离职原因:后来公司被一个大公司收购成为了一个小部门,因为拖欠工资没办法维持现在的生活就离开了...


第二个公司


第二家公司是2018年入职,是一个自研产品的公司,研发人员上百个,当然前端人员自然也很多啦,想要重点说说感受


感受:公司很大,制度很多,流程相对规范,比如git提交的规范、自动构建、内部脚手架等,不过我发现明显的问题,就是分工不均匀,有的项目组天天加班,持续半年加班,但是有的项目组,每天除了摸鱼还是摸鱼,比如我们组,很多项目都流产了,就很多时候都没有事情,最多的就是逛逛B站,偶尔自学,看看新闻时事....


离职:目前尚未离职,在纠结中。


焦虑


在这个公司快4年了,技术一直停滞,生活的很安逸,但是有点温水煮青蛙的感觉,刚开始会自学,慢慢的也就不太想自学了,好像整个人都堕落了,没有刚开始工作的冲劲了... 算一算我也27岁了,虽说未来的事情说不准,但是确实30岁以后的的程序员确实找工作相对会难一点,尤其是女程序员会考虑到结婚生子的问题,年龄大了确实也会受限,所以现在在纠结到底从事什么工作,是转行还是重新努力学习前端知识,从事前端开发工作,会发现很迷茫,换行or继续前端....


很后悔浪费了这4年的光阴,希望后面的程序员弟弟妹妹要珍惜自己现在的时光,程序员本身就业时间就相对其他行业短暂一点,对于我们来说每一天都很重要,4年了足够我们做很多很多事情了,如果对开发不是很感兴趣也可以提前根据自己的兴趣来规划职业


作者:M77星球
来源:https://juejin.cn/post/7076377003057741838
收起阅读 »

前端无痛刷新Token

前端无痛刷新Token这个需求场景很常见,几乎很多项目都会用上,之前项目也实现过,最近刚好有个项目要实现,重新梳理一番。需求对于需要前端实现无痛刷新Token,无非就两种:请求前判断Token是否过期,过期则刷新请求后根据返回状态判断是否过期,过期则刷新处理逻...
继续阅读 »

前端无痛刷新Token

这个需求场景很常见,几乎很多项目都会用上,之前项目也实现过,最近刚好有个项目要实现,重新梳理一番。

需求

对于需要前端实现无痛刷新Token,无非就两种:

  1. 请求前判断Token是否过期,过期则刷新

  2. 请求后根据返回状态判断是否过期,过期则刷新

处理逻辑

实现起来也没多大差别,只是判断的位置不一样,核心原理都一样:

  1. 判断Token是否过期

    1. 没过期则正常处理

    2. 过期则发起刷新Token的请求

      1. 拿到新的Token保存

      2. 重新发送Token过期这段时间内发起的请求

重点:

  • 保持Token过期这段时间发起请求状态(不能进入失败回调)

  • 把刷新Token后重新发送请求的响应数据返回到对应的调用者

实现

  1. 创建一个flag isRefreshing 来判断是否刷新中

  2. 创建一个数组队列retryRequests来保存需要重新发起的请求

  3. 判断到Token过期

    1. isRefreshing = false的情况下 发起刷新Token的请求

      1. 刷新Token后遍历执行队列retryRequests

    2. isRefreshing = true 表示正在刷新Token,返回一个Pending状态的Promise,并把请求信息保存到队列retryRequests

import axios from "axios";
import Store from "@/store";
import Router from "@/router";
import { Message } from "element-ui";
import UserUtil from "@/utils/user";

// 创建实例
const Instance = axios.create();
Instance.defaults.baseURL = "/api";
Instance.defaults.headers.post["Content-Type"] = "application/json";
Instance.defaults.headers.post["Accept"] = "application/json";

// 定义一个flag 判断是否刷新Token中
let isRefreshing = false;
// 保存需要重新发起请求的队列
let retryRequests = [];

// 请求拦截
Instance.interceptors.request.use(async function(config) {
 Store.commit("startLoading");
 const userInfo = UserUtil.getLocalInfo();
 if (userInfo) {
   //业务需要把Token信息放在 params 里面,一般来说都是放在 headers里面
   config.params = Object.assign(config.params ? config.params : {}, {
     appkey: userInfo.AppKey,
     token: userInfo.Token
  });
}
 return config;
});

// 响应拦截
Instance.interceptors.response.use(
 async function(response) {
   Store.commit("finishLoading");
   const res = response.data;
   if (res.errcode == 0) {
     return Promise.resolve(res);
  } else if (
     res.errcode == 30001 ||
     res.errcode == 40001 ||
     res.errcode == 42001 ||
     res.errcode == 40014
  ) {
   // 需要刷新Token 的状态 30001 40001 42001 40014
   // 拿到本次请求的配置
     let config = response.config;
   //   进入登录页面的不做刷新Token 处理
     if (Router.currentRoute.path !== "/login") {
       if (!isRefreshing) {
           // 改变flag状态,表示正在刷新Token中
         isRefreshing = true;
       //   刷新Token
         return Store.dispatch("user/relogin")
          .then(res => {
           // 设置刷新后的Token
             config.params.token = res.Token;
             config.params.appkey = res.AppKey;
           //   遍历执行需要重新发起请求的队列
             retryRequests.forEach(cb => cb(res));
           //   清空队列
             retryRequests = [];
             return Instance.request(config);
          })
          .catch(() => {
             retryRequests = [];
             Message.error("自动登录失败,请重新登录");
               const code = Store.state.user.info.CustomerCode || "";
               // 刷新Token 失败 清空缓存的用户信息 并调整到登录页面
               Store.dispatch("user/logout");
               Router.replace({
                 path: "/login",
                 query: { redirect: Router.currentRoute.fullPath, code: code }
              });
          })
          .finally(() => {
               // 请求完成后重置flag
             isRefreshing = false;
          });
      } else {
         // 正在刷新token,返回一个未执行resolve的promise
         // 把promise 的resolve 保存到队列的回调里面,等待刷新Token后调用
         // 原调用者会处于等待状态直到 队列重新发起请求,再把响应返回,以达到用户无感知的目的(无痛刷新)
         return new Promise(resolve => {
           // 将resolve放进队列,用一个函数形式来保存,等token刷新后直接执行
           retryRequests.push(info => {
               // 将新的Token重新赋值
             config.params.token = info.Token;
             config.params.appkey = info.AppKey;
             resolve(Instance.request(config));
          });
        });
      }
    }
     return new Promise(() => {});
  } else {
     return Promise.reject(res);
  }
},
 function(error) {
   let err = {};
   if (error.response) {
     err.errcode = error.response.status;
     err.errmsg = error.response.statusText;
  } else {
     err.errcode = -1;
     err.errmsg = error.message;
  }
   Store.commit("finishLoading");
   return Promise.reject(err);
}
);

export default Instance;


作者:沐夕花开
来源:https://juejin.cn/post/7075348765162340383

收起阅读 »

基于JDK的动态代理原理分析

基于JDK的动态代理原理分析 这篇文章解决三个问题: What 动态代理是什么 How 动态代理怎么用 Why 动态代理的原理 动态代理是什么? 动态代理是代理模式的一种具体实现,是指在程序运行期间,动态的生成目标对象的代理类(直接加载在内存中的字节码文件...
继续阅读 »

基于JDK的动态代理原理分析


这篇文章解决三个问题:



  1. What 动态代理是什么

  2. How 动态代理怎么用

  3. Why 动态代理的原理


动态代理是什么?


动态代理是代理模式的一种具体实现,是指在程序运行期间,动态的生成目标对象的代理类(直接加载在内存中的字节码文件),实现对目标对象所有方法的增强。通过这种方式,我们可以在不改变(或无法改变)目标对象源码的情况下,对目标对象的方法执行前后进行干预。


动态代理怎么用?


首先,准备好我们需要代理的类和接口,因为JDK的动态代理是基于接口实现的,所以被代理的对象必须要有接口


/**
* SaySomething接口
*/

public interface SaySomething {

   public void sayHello();

   public void sayBye();
}

/**
* SaySomething的实现类
*/

public class SaySomethingImpl implements SaySomething {
   @Override
   public void sayHello() {
       System.out.println("Hello World");
  }

   @Override
   public void sayBye() {
       System.out.println("Bye Bye");
  }
}

按照动态代理的用法,需要自定义一个处理器,用来编写自定义逻辑,实现对被代理对象的增强。


自定义的处理器需要满足以下要求:



  • 需要实现InvocationHandler,重写invoke方法,在invoke方法中通过加入自定义逻辑,实现对目标对象的增强。

  • 需要持有一个成员变量,成员变量的是被代理对象的实例,通过构造参数传入。(用来支持反射调用被代理对象的方法)

  • 需要提供一个参数为被代理对象接口类的有参构造。(用来支持反射调用被代理对象的方法)


/**
* 自定义的处理器,用来编写自定义逻辑,实现对被代理对象的增强
*/

public class CustomHandler implements InvocationHandler {

   //需要有一个成员变量,成员变量为被代理对象,通过构造参数传入,用来支持方法的反射调用。
   private SaySomething obj;
   
   //需要有一个有参构造,通过构造函数将被代理对象的实例传入,用来支持方法的反射调用
   public CustomHandler(SaySomething obj) {
       this.obj = obj;
  }

   /**
    * proxy:动态生成的代理类对象com.sun.proxy.$Proxy0
    * method:被代理对象的真实的方法的Method对象
    * args:调用方法时的入参
    */

   @Override
   public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
       //目标方法执行前的自定义逻辑处理
       System.out.println("-----before------");

       //执行目标对象的方法,使用反射来执行方法,反射需要传入目标对象,此时用到了成员变量obj。
       Object result = method.invoke(obj, args);

       //目标方法执行后的自定义逻辑处理
       System.out.println("-----after------");
       return result;
  }
}

这样我们就完成了自定义处理器的编写,同时在invoke方法中实现对了代理对象方法的增强,被代理类的所有方法的执行都会执行我们自定义的逻辑。


接下来,需要通过Proxy,newProxyInstance()方法来生成代理对象的实例,并进行方法调用测试。


public class JdkProxyTest {
   public static void main(String[] args) {
       //将生成的代理对象的字节码文件 保存到硬盘
       System.getProperties().setProperty("sun.misc.ProxyGenerator.saveGeneratedFiles", "true");

       //被代理对象的实例
       SaySomething obj = new SaySomethingImpl();
       //通过构造函数,传入被代理对象的实例,生成处理器的实例
       InvocationHandler handler = new CustomHandler(obj);
       //通过Proxy.newProxyInstance方法,传入被代理对象Class对象、处理器实例,生成代理对象实例
       SaySomething proxyInstance = (SaySomething) Proxy.newProxyInstance(obj.getClass().getClassLoader(),
                                                                          new Class[]{SaySomething.class}, handler);
       //调用生成的代理对象的sayHello方法
       proxyInstance.sayHello();
       System.out.println("===================分割线==================");
       //调用生成的代理对象的sayBye方法
       proxyInstance.sayBye();
  }
}

image.png
运行main方法,查看控制台,大功告成。至此,我们已经完整的完成了一次动态代理的使用。


动态代理的原理


生成的proxyInstance对象到底是什么,为什么调用它的sayHello方法会执行CustomerHandler的invoke方法呢?


直接贴上proxyInstance的字节码文件,我们就会恍然大悟了...


//$Proxy0是SaySomething的实现类,重写了sayHello和sayBye方法
public final class $Proxy0 extends Proxy implements SaySomething {
   private static Method m1;
   private static Method m3;
   private static Method m2;
   private static Method m4;
   private static Method m0;

   public $Proxy0(InvocationHandler var1) throws {
       super(var1);
  }

   static {
       try {
           m1 = Class.forName("java.lang.Object").getMethod("equals", Class.forName("java.lang.Object"));
           m3 = Class.forName("com.example.demo.hanmc.proxy.jdk.SaySomething").getMethod("sayHello");
           m2 = Class.forName("java.lang.Object").getMethod("toString");
           m4 = Class.forName("com.example.demo.hanmc.proxy.jdk.SaySomething").getMethod("sayBye");
           m0 = Class.forName("java.lang.Object").getMethod("hashCode");
      } catch (NoSuchMethodException var2) {
           throw new NoSuchMethodError(var2.getMessage());
      } catch (ClassNotFoundException var3) {
           throw new NoClassDefFoundError(var3.getMessage());
      }
  }
 
   //实现了接口的sayHello方法,在方法内部调用了CustomerHandler的invoke方法,同时传入了Method对象,
   //所以在CustomerHandler对象中可以通过mathod.invovke方法调用SyaSomthing的sayHello方法
   public final void sayHello() throws {
       try {
           //h是父类Proxy中的InvocationHandler对象,其实就是我们自定义的CustomHandler对象
           super.h.invoke(this, m3, (Object[])null);
      } catch (RuntimeException | Error var2) {
           throw var2;
      } catch (Throwable var3) {
           throw new UndeclaredThrowableException(var3);
      }
  }

   public final void sayBye() throws {
       try {
           super.h.invoke(this, m4, (Object[])null);
      } catch (RuntimeException | Error var2) {
           throw var2;
      } catch (Throwable var3) {
           throw new UndeclaredThrowableException(var3);
      }
  }
   public final int hashCode() throws {
      //忽略内容
  }
   public final boolean equals(Object var1) throws {
      //忽略内容
  }
   public final String toString() throws {
      //忽略内容
  }
}

看到了生成的代理对象的字节码文件,是不是一切都明白你了,原理竟然如此简单^_^


作者:82年咖啡
来源:https://juejin.cn/post/7079720742899843080
收起阅读 »

你已经是个成熟的前端了,应该学会破解防盗链了

今天一早打开微信,就看到国产github——gitee崩了。 Issue列表里面全是反馈图片显示异常,仔细一看,原来是图床的防盗链。 场景复现 之前没用过gitee,火速去建了一个账号试验一下。 我在我的gitee中上传一张图片,在gitee本站里面显示是正...
继续阅读 »

今天一早打开微信,就看到国产github——gitee崩了。



Issue列表里面全是反馈图片显示异常,仔细一看,原来是图床的防盗链。


场景复现


之前没用过gitee,火速去建了一个账号试验一下。


我在我的gitee中上传一张图片,在gitee本站里面显示是正常的。


1-1.png


右键复制这张图片的地址,放到一个第三方的在线编辑器中,发现图片变成gitee的logo了



什么是防盗链


防盗链不是一根链条,正确的停顿应该是防·盗链——防止其他网站盗用我的链接。


我把图片上传到gitee的服务器,得到了图片的链接,然后拿着这个链接在第三方编辑器中使用,这就是在“盗用”——因为这张图片占用了gitee的服务器资源,却为第三方编辑器工作,gitee得不到好处,还得多花钱。


如何实现防盗链


要实现防盗链,就需要知道图片的请求是从哪里发出的。可以实现这一功能的有请求头中的originrefererorigin只有在XHR请求中才会带上,所以图片资源只能借助referer。其实gitee也确实是这么做的。


通过判断请求的referer,如果请求来源不是本站就返回302,重定向到gitee的logo上,最后在第三方网站引用存在gitee的资源就全变成它的logo了。


可以在开发者工具中看到第三方网站请求gitee图片的流程:



  1. 首先请求正常的图片,但是没有返回200,而是302重定向,其中响应头中的location就是要重定向去向的地址;

  2. 接着浏览器会自动请求这个location,并用这个返回结果代替第一次请求的返回内容;


最后,我们的图片在第三方网站就变成gitee的logo了。


如何破解防盗链


想让gitee不知道我在盗用,就不能让他发现请求的来源是第三方,只要把referer藏起来就好,可以在终端尝试这段代码:


curl 'https://images.gitee.com/uploads/images/2022/0326/155444_dc9923a4_10659337.jpeg' \
-o noReferer.jpg

这段👆代码的意思是请求这张jpg图片资源,把返回结果以noReferer.jpg这个名称保存在当前目录下,并且没有带上referer,测试结果是图片正常保存下来了。


就像加上了gitee本站的referer一样可以正常请求👇:


curl 'https://images.gitee.com/uploads/images/2022/0326/155444_dc9923a4_10659337.jpeg' \
-H 'referer: https://gitee.com' \
-o fromGitee.jpg

而在第三方网站请求的效果就像这段👇代码


curl 'https://images.gitee.com/uploads/images/2022/0326/155444_dc9923a4_10659337.jpeg' \
-H 'referer: https://editor.mdnice.com/' \
-o otherReferer.png

带上了第三方网站的标识https://editor.mdnice.com最终无法正常下载。


gitee做的不够完善吗


测试完上面的三段代码,不知道你会不会疑惑,gitee为什么不把“请求来源不能是第三方网站”的策略改成“请求来源必须是本站点”呢?换句话说,控制referer不能为空,只要是空就重定向。


因为在浏览器的地址栏中直接输入这个图片的url,然后回车,发起的请求是没有referer字段的,在这种场景下如果还是返回gitee的logo,就显得不太合理了。



图片的url:https://images.gitee.com/uploads/images/2022/0326/155444_dc9923a4_10659337.jpeg



图片看不到了,现在怎么办


如果你的个人搭建的博客里面用了很多存在gitee的图片,你可以在html的head部分加上这样一行


<meta name="referrer" content="no-referrer" />


或者


<img referrer="no-referrer|origin|unsafe-url" src="{item.src}"/>


来阻止请求因带上站点来源而被重定向成gitee的logo。


如果你是博客的访问者,可以借助一个chrome小插件ModHeader,把referer给“擦掉”



这样第三方站点就可以正常访问啦~


1-2.png


结语


上面提到的解决方式只是开个玩笑,临时恢复使用可以。但还是要慢慢把图片迁移到自己的服务器才最可靠。


作者:前端私教年年
来源:https://juejin.cn/post/7079705713781506079 收起阅读 »

【集成攻略】手把手教你环信对接离线推送,再搞不定把你头打掉

前提条件1.macOS系统,安装了xcode,并且配置好了cocoapods第三方管理工具2.有苹果开发者账号3.有环信开发者账号(注册地址:https://console.easemob.com/user/register)在苹果开发者中心创建项目,注册推送...
继续阅读 »

前提条件

1.macOS系统,安装了xcode,并且配置好了cocoapods第三方管理工具

2.有苹果开发者账号

3.有环信开发者账号

(注册地址:https://console.easemob.com/user/register


在苹果开发者中心创建项目,注册推送证书.

1.登录苹果开发者中心.

https://developer.apple.com/

(请用自己的苹果开发者账号)





2.苹果开发者中心创建 - Identifiers.

(name - empushdemo )

(identifier - com.yyytp.empushdemo )











3.钥匙串 - 从证书颁发机构请求证书

(本机证书)
















4.针对刚创建的bundle id开通并注册Certificates push 证书

(注册 可以在开发和生产双环境下使用的推送证书)













5.安装证书到本机,并导出 push - p12 

(这里需要格外注意操作步骤!不能展开!!!要闭合的状态导出!!!)

(因为申领的证书是双环境的,所以导出的p12文件直接复制成双份即可)

(开发证书名称 : yyytp_empush_apns_dev)

(生产证书名称 : yyytp_empush_apns_pro)

(密码 : 不告诉你)




==========
提示:解决证书不受信任的问题

如果在《钥匙串访问》中出现"证书不受信任"的警告时,可以去苹果官方网站下载G4证书,并双击打开即可

“证书不受信任”的图片样例


苹果官方网站链接:

https://www.apple.com/certificateauthority/

苹果官方网站需要下载的G4证书截图说明:


苹果官方解释:
苹果全球开发者关系中级证书的版本将于2023年2月7日到期,续订版本将于2030年2月20日到期。更新后的版本用于签署2021 1月28日之后颁发的新软件签名证书。剩余服务证书将于2022年1月27日更新。
为苹果平台开发的苹果开发者计划和苹果开发者企业计划的成员需要下载更新的证书,并遵循以下说明。
苹果开发者企业计划的成员需要在所有使用2020年9月1日之后生成的企业iOS分发证书进行代码签名的机器上安装续订的证书。
对于2021 1月28日之后生成的所有软件签名证书,由在Xcode中拥有个人帐户的开发人员和iOS大学开发人员计划成员提供的,也需要进行此更新。
新的中间证书由Xcode 11.4.1或更高版本自动下载,可在证书颁发机构页面上下载。通过验证过期日期设置为2030,确认安装了正确的中间证书。

注:本解决方案来自简书平台的博主AndyLiYL
原文链接:
https://www.jianshu.com/p/2697ed4f6e41

==========


后续补充:导出时必须使用[英文+数字+下划线]字符集内字符.不能使用中文和其他特殊符号







以上是在苹果开发者中心做了两件事

1.创建了bundleid为com.yyytp.empushdemo的app

2.创建推送证书 apns-2.cer 并导出了 (.p12) 证书,复制为2份,分别命名为 yyytp_empush_apns_dev yyytp_empush_apns_pro 密码是 123456

其中两份p12证书内容是完全一样的,只是命名不同,都适用于开发和生产环境,不过为了后期导入环信后台时方便辨识


===========分割线===========================


在环信console后台创建一个appkey,顺带创建一个测试username


1.登录环信console后台:https://console.easemob.com/user/login




2.创建appkey

(app_name : showpushdemo )





3.创建一个测试账号

(测试账号 : emtest 密码 1 )







========分割线=======================


在环信console后台中上传推送证书(.p12文件)

注意!!!是在刚才创建的appkey(1168171101115760#showpushdemo)下创建证书!!!

(这里需要注意的细节是:证书名不能有中文和其他特殊符号!!! 建议 字母 + 下划线)

(所以我会采用 yyytp_empush_apns_dev / yyytp_empush_apns_pro 这样的命名方式)





最终效果




=========分割线=======================


在代码中实现

1.创建项目

em_push_showdemo

2.集成环信SDK

pod 'HyphenateChat' , '3.9.0'




3.配置项目





4.代码部分如下:





下面代码是AppDelegate.m的所有代码,可直接复制粘贴

下面代码是AppDelegate.m的所有代码,可直接复制粘贴

下面代码是AppDelegate.m的所有代码,可直接复制粘贴


//
// AppDelegate.m
// em_push_showdemo
//
// Created by flower on 2022/3/14.
//

#import "AppDelegate.h"
#import
#import

@interface AppDelegate ()

@end

@implementation AppDelegate


- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
// Override point for customization after application launch.
/*
1.注册环信SDK
2.注册推送
注册成功之后 绑定至环信SDK
3.登录账号
*/

[self _registerEMCHAT];
[self _registerSysPush];
[self _loginEMCHAT];
return YES;
}

- (void)_registerEMCHAT{
EMOptions *options = [EMOptions optionsWithAppkey:@"1168171101115760#showpushdemo"];
options.apnsCertName = @"yyytp_empush_apns_dev";
options.isAutoLogin = false;
options.usingHttpsOnly = true;
[EMClient.sharedClient initializeSDKWithOptions:options];
}

- (void)_registerSysPush{
[UNUserNotificationCenter.currentNotificationCenter
requestAuthorizationWithOptions:
UNAuthorizationOptionBadge|
UNAuthorizationOptionSound|
UNAuthorizationOptionAlert
completionHandler:^(BOOL granted, NSError * _Nullable error) {
if (granted) {
dispatch_async(dispatch_get_main_queue(), ^{
[UIApplication.sharedApplication registerForRemoteNotifications];
});
}
}];
}

- (void)_loginEMCHAT{
[EMClient.sharedClient loginWithUsername:@"emtest" password:@"1" completion:^(NSString *aUsername, EMError *aError) {
if (aError) {
NSLog(@"登录失败");
}else{
NSLog(@"登录成功");
//下面这个updatePushDisplayStyle是设置显示效果,有两种显示效果可以设置.详情可查看枚举值(EMPushDisplayStyleSimpleBanner)的定义
[EMClient.sharedClient.pushManager updatePushDisplayStyle:EMPushDisplayStyleSimpleBanner completion:^(EMError * _Nonnull aError) {
}];
}
}];
}

- (void)application:(UIApplication *)application didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken{
NSLog(@"绑定成功");
dispatch_async(dispatch_get_main_queue(), ^{
[EMClient.sharedClient bindDeviceToken:deviceToken];
});
}

- (void)application:(UIApplication *)application didFailToRegisterForRemoteNotificationsWithError:(NSError *)error{
}



@end


4.运行至手机,运行完成后,退出APP,发送消息测试推送.

收起阅读 »

跟我学企业级flutter项目:手把手教你制作一款低耦合空页面widget

前言跟我学flutter系列:跟我学flutter:我们来举个例子通俗易懂讲解dart 中的 mixin跟我学flutter:我们来举个例子通俗易懂讲解异步(一)ioslate跟我学flutter:我们来举个例子通俗易懂讲解异步(二)ioslate循环机制企业...
继续阅读 »

前言

跟我学flutter系列:
跟我学flutter:我们来举个例子通俗易懂讲解dart 中的 mixin
跟我学flutter:我们来举个例子通俗易懂讲解异步(一)ioslate
跟我学flutter:我们来举个例子通俗易懂讲解异步(二)ioslate循环机制
企业级篇目:
跟我学企业级flutter项目:用bloc手把手教你搭建用户认证系统
跟我学企业级flutter项目:dio网络框架增加公共请求参数&header
跟我学企业级flutter项目:如何用dio封装一套企业级可扩展高效的网络层
跟我学企业级flutter项目:如何封装一套易用,可扩展的Hybrid混合开发webview
跟我学企业级flutter项目:手把手教你制作一款低耦合空页面widget

如何开发一款易用的,并且可以扩展的空页面呢?那么今天我将带领大家手把手开发一款可扩展的空页面。

开发前注意事项

1、定义好空页面状态 2、可扩展思想(用抽象或基类替代实体) 3、抽离出空页面的结构

空页面展示

在这里插入图片描述

开始搭建

一、页面分析

空页面需要元素有:

  1. 展示图片
  2. 展示文案
  3. 展示刷新按钮

页面功能点:

  1. 文案可自定义
  2. 图片可自定义
  3. 按钮可隐藏

wiget作用范围:

  1. 可包裹其他widget
  2. 不包裹其他widget

二、定义状态

2.1 几种状态

enum EmptyStatus {
fail, //失败视图
loading, //加载视图
nodata, //没有数据视图
none //没有状态
}

没有状态该空页面就隐藏掉

2.2 空页面刷新回调

abstract class IEmptyRefresh{

void pressedReload();

}

2.3 定义copy类(复用做准备)&定义空接口(抽离要扩展的方法)

abstract class Copyable {
T copy();
}
abstract class IEmpty implements Copyable{
IEmptyRefresh? iEmptyRefresh;
Widget? diyImage; // 自定义图片替换
Widget? diyText;// 自定义文案替换
Widget? image();

Widget? text();

Widget? refresh();
}

2.4 空页面实现类

默认加载中页面

class DefaultLodingPage extends IEmpty{

@override
Widget? text() {
return diyText??Text(
LibEmptyManager.instance.libEmptyPageLoding,
style: TextStyle(fontSize: LibEmptyManager.instance.textSize, color: AppTheme.instance.textColor()),
);
}

@override
Widget? image() {
return null;
}

@override
Widget? refresh() => null;

@override
IEmpty copy() {
return DefaultLodingPage()
..diyImage = diyImage
..diyText = diyText
..iEmptyRefresh=iEmptyRefresh;
}


}
默认空页面

class DefaultEmptyPage extends IEmpty{

@override
Widget? text() {
return diyText??Text(
LibEmptyManager.instance.libEmptyPageNoData,
style: TextStyle(fontSize: LibEmptyManager.instance.textSize, color: AppTheme.instance.textColor()),
);
}

@override
Widget? image() {
return Padding(
padding: const EdgeInsets.only(bottom: 20),
child: diyImage??Icon(LibEmptyManager.instance.imageNoData,color: AppTheme.instance.imageColor(),size: LibEmptyManager.instance.imageSize,),
);
}

@override
Widget? refresh() => null;

@override
IEmpty copy() {
return DefaultEmptyPage()
..diyImage = diyImage
..diyText = diyText
..iEmptyRefresh=iEmptyRefresh;;
}


}
默认网络失效页

class DefaultNetWorkError extends IEmpty {
@override
Widget? text() {
return diyText??Text(
LibEmptyManager.instance.libEmptyPageNetError,
style: TextStyle(fontSize: LibEmptyManager.instance.textSize, color: AppTheme.instance.textColor()),
);
}

@override
Widget? image() {
return Padding(
padding: const EdgeInsets.only(bottom: 20),
child: diyImage??Icon(LibEmptyManager.instance.imageNetWork,color: AppTheme.instance.imageColor(),size: LibEmptyManager.instance.imageSize,),
);
}

@override
Widget? refresh() {
return Padding(
padding: const EdgeInsets.only(top: 20),
child: Padding(
padding: const EdgeInsets.only(left: 20,right: 20),
child: ElevatedButton(onPressed: () => iEmptyRefresh?.pressedReload(),
style: ButtonStyle(
backgroundColor: MaterialStateProperty.all(AppTheme.instance.btnBackColor()),
shape: MaterialStateProperty.all(const StadiumBorder()),
)
, child: Text(LibEmptyManager.instance.libRefresh,style: TextStyle(fontSize: LibEmptyManager.instance.libRefreshSize,color: AppTheme.instance.btnTextColor())),),
),
);
}

@override
IEmpty copy() {
return DefaultNetWorkError()
..diyImage = diyImage
..diyText = diyText
..iEmptyRefresh=iEmptyRefresh;;
}
}

2.5 空页面管理类

可进行外部配置


class LibEmptyManager{
IEmpty emptyPage = DefaultEmptyPage();
IEmpty loadingPage = DefaultLodingPage();
IEmpty netWorkError = DefaultNetWorkError();

late LibEmptyConfig libEmptyConfig;

LibEmptyManager._();

static final LibEmptyManager _instance = LibEmptyManager._();

static LibEmptyManager get instance {
return _instance;
}

2.6 核心逻辑

判断状态,并进行类型拷贝,并增加自定义参数

switch(widget.layoutType){
case EmptyStatus.none:
visable = true;
break;
// return widget.child;
case EmptyStatus.fail:
iEmpty = LibEmptyManager.instance.netWorkError.copy()
..diyText = widget.networkText
..diyImage = widget.networkImage
;
break;
case EmptyStatus.nodata:
iEmpty = LibEmptyManager.instance.emptyPage.copy()
..diyText = widget.emptyText
..diyImage = widget.emptyImage
;
break;
case EmptyStatus.loading:
iEmpty = LibEmptyManager.instance.loadingPage;
break;
default:
iEmpty = LibEmptyManager.instance.emptyPage.copy()
..diyText = widget.emptyText
..diyImage = widget.emptyImage
;
}

如果是包裹类型需要stack进行包装

return Stack(
children: [
Offstage(
offstage: !visable,
child: widget.child,
),
Offstage(
offstage: visable,
child: Container(
width: double.infinity,
color: AppTheme.instance.backgroundColor(),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: _listEmpty(iEmpty),
),
),),
],
);

判断是否有网,有网的话,就刷新,没网的话,就提示


@override
void pressedReload() async
{
bool isConnectNetWork = await isConnected();
if(isConnectNetWork){
widget.refresh.call();
}else{
TipToast.instance.tip(LibLocalizations.getLibString().libNetWorkNoConnect!,tipType: TipType.error);
}
}

// 是否有网
Future isConnected() async {
var connectivityResult = await (Connectivity().checkConnectivity());
return connectivityResult != ConnectivityResult.none;
}

组装empty


List _listEmpty(IEmpty? iEmpty) {
List tempEmpty = [];
if(iEmpty!=null){
Widget? image = iEmpty.image();
Widget? text = iEmpty.text();
Widget? refresh = iEmpty.refresh();
if(image!=null){
tempEmpty.add(image);
}
if(text!=null){
tempEmpty.add(text);
}
if(refresh!=null){
tempEmpty.add(refresh);
}

}
return tempEmpty;
}

三、空页面widget实现完整代码

class LibEmptyView extends StatefulWidget{
Widget? child;
EmptyStatus layoutType;
VoidCallback refresh;


Widget? networkImage;Widget? networkText;
Widget? emptyImage;Widget? emptyText;

LibEmptyView({Key? key, this.child,required this.refresh,required this.layoutType,this.networkImage,this.networkText, this.emptyImage,this.emptyText}) : super(key: key);

@override
State createState() => _LibEmptyViewState();

}

class _LibEmptyViewState extends State implements IEmptyRefresh{
//控制器

@override
Widget build(BuildContext context) {
IEmpty? iEmpty;
bool visable = false;
switch(widget.layoutType){
case EmptyStatus.none:
visable = true;
break;
case EmptyStatus.fail:
iEmpty = LibEmptyManager.instance.netWorkError.copy()
..diyText = widget.networkText
..diyImage = widget.networkImage
;
break;
case EmptyStatus.nodata:
iEmpty = LibEmptyManager.instance.emptyPage.copy()
..diyText = widget.emptyText
..diyImage = widget.emptyImage
;
break;
case EmptyStatus.loading:
iEmpty = LibEmptyManager.instance.loadingPage;
break;
default:
iEmpty = LibEmptyManager.instance.emptyPage.copy()
..diyText = widget.emptyText
..diyImage = widget.emptyImage
;
}
iEmpty?.iEmptyRefresh = this;



return Stack(
children: [
Offstage(
offstage: !visable,
child: widget.child,
),
Offstage(
offstage: visable,
child: Container(
width: double.infinity,
color: AppTheme.instance.backgroundColor(),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: _listEmpty(iEmpty),
),
),),
],
);
}

@override
void pressedReload() async{
bool isConnectNetWork = await isConnected();
if(isConnectNetWork){
widget.refresh.call();
}else{
TipToast.instance.tip(LibLocalizations.getLibString().libNetWorkNoConnect!,tipType: TipType.error);
}
}


// 是否有网
Future isConnected() async {
var connectivityResult = await (Connectivity().checkConnectivity());
return connectivityResult != ConnectivityResult.none;
}
}

List _listEmpty(IEmpty? iEmpty) {
List tempEmpty = [];
if(iEmpty!=null){
Widget? image = iEmpty.image();
Widget? text = iEmpty.text();
Widget? refresh = iEmpty.refresh();
if(image!=null){
tempEmpty.add(image);
}
if(text!=null){
tempEmpty.add(text);
}
if(refresh!=null){
tempEmpty.add(refresh);
}

}
return tempEmpty;
}

四、空页面widget使用代码

包裹使用 (代码中的webview封装参见:跟我学企业级flutter项目:如何封装一套易用,可扩展的Hybrid混合开发webview

LibEmptyView(
layoutType: status,
refresh: () {

status = EmptyStatus.none;
_innerWebPageController.reload();

},

child: InnerWebPage(widget.url,titleCallBack: (title){
setState(() {
urlTitle = title;
});
},javascriptChannels: widget._javascriptChannels,urlIntercept: widget._urlIntercept,onInnerWebPageCreated: (innerWebPageController){
_innerWebPageController = innerWebPageController;
widget._javascriptChannels?.webPageCallBack = webPageCallBack;
widget._urlIntercept?.webPageCallBack = webPageCallBack;
},onWebResourceError: (error){
setState(() {
status = EmptyStatus.fail;
});
},),
),
));

非包裹使用

if(_status == EmptyStatus.none){
return _listViewUi.call(_allReportItems);
}else{
var empty = LibEmptyView(
layoutType: _status,
refresh: () {
_status = EmptyStatus.loading;
LibLoading.show();
_refreshCenter.refreshData();
},networkImage: networkImage,networkText: networkText,emptyImage: emptyImage,emptyText: emptyText,);
if(builder!=null){
return builder.call(context,empty);
}else{
return empty;
}
}

感谢大家阅读我的文章

收起阅读 »