注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

Flutter & ChatGPT | 代码生成器

ChatGPT 作为一个自然语言处理工具,已经火了一段时间。对待 ChatGPT 不同人有着不同的看法,新事物的出现必然如此。利益相关者形成 抵制 和 狂热 两极;哗众取宠者蹭蹭热度,问些花活,博人眼球;猎奇者尝尝鲜,起哄者挑挑火;实用派在思考新事物的价值和...
继续阅读 »

ChatGPT 作为一个自然语言处理工具,已经火了一段时间。对待 ChatGPT 不同人有着不同的看法,新事物的出现必然如此。利益相关者形成 抵制狂热 两极;哗众取宠者蹭蹭热度,问些花活,博人眼球;猎奇者尝尝鲜,起哄者挑挑火;实用派在思考新事物的价值和劳动力:


image.png


对于那些拿 ChatGPT 当百科全书来用的,或询问哲学问题的朋友,我只想说:



对于一个问题,用错误的工具去处理得出错误的结果,是一件很正常的事。





1. ChatGPT 的特点和劣势

ChatGPT 最大的特点是基础的语义分析,让计算机对自然语言进行处理并输出。在一段会话中,上下文是有效的,所以可以类似于交流。



问这个问题,它会怎么回答?



这种猎奇的心理,会让一部分人期望尝试;有稀奇古怪或愚蠢的回答,也可以满足人类对人工智障的优越感;分享问答,也让 ChatGPT 拥有一丝的社交属性。蹭热度、猎奇、起哄三者可以用它填充一块内心的空虚,也仅止步于此。




ChatGPT 目前的劣势也很明显,由于数据是几年前的,所以时效性不强;对很多问题回答的精准度并不高,对于盲目相信的人,或判别力较差的朋友并不友好;最后一点,非常重要:对于工具而言,如果对其依赖性太高,脱离工具时,会让人的主观能动性降低。


image.png




2. 代码的生成与规则诱导

如下所示,让它生成一个 Dart 的 User 类:



生成一个 dart 类 User, 字段为 : 可空 int 型 age 、final 非空 String 型 username 默认值为 “unknown”



image.png


虽然代码给出了,但是可以看出,这是空安全之前的代码。可能很多人到这里,觉得数据陈旧没什么用途,就拜拜了您嘞。


image.png




但它是一个有会话上下文的自然语言处理工具,你可以让它理解一些概念。就像一个新员工,上班第一天出了一点小错误,你是立刻开除他,还是告诉他该怎么正确处理。如下所示,给了它一个概念:



Dart 新版本中可空类型定义时,其后需要加 ?



image.png




如下所示,你就可以在当前的会话环境中让它生成更多字段的类型:



用 Dart 新版本生成一个 dart 类 User,字段为: final 非空 int 型 age , final 非空 String 型 username 默认值为 “unknown” , final 非空 int 型 height,可空 String型info,final 非空 int 型 roleId



image.png


如果存在问题,可以继续进行指正。比如 :



用 Dart 新版本,有默认值的字段不需要使用 required 关键字,其他非空字段需要



image.png


所以对于 ChatGPT 而言,我们可以把它看成一个有一些基础知识的,可为我们免费服务的员工,简称:奴隶。当它做错事时,你骂它,责备它,抛弃它是毫无意义的,因为它是机器。我们需要去 诱导 它理解,在当前工作环境中正确的事。


这样在当前会话中,它就可以理解你诉说的规则,当用它创建其他类时,他就不会再犯错。并且不排除它会基于你的规则,去完善自身的 知识储备 ,当众多的人用正确的规则去 诱导 它,这就是一个善意的正反馈。




3. 解决方案的概念

这里从生成的代码 不支持空安全支持空安全,其实只用了几句话。第一句是反馈测试,看看它的 默认知识储备



生成一个 dart 类 User, 字段为 : 可空 int 型 age 、final 非空 String 型 username 默认值为 “unknown”



当它的输出不满足我们的需求时,再进行 诱导



Dart 新版本中可空类型定义时,其后需要加 ?

用 Dart 新版本,有默认值的字段不需要使用 required 关键字,其他非空字段需要



在诱导完成之后,它就可以给出满足需求的输出。这种诱导后提供的会话环境,输出是相对稳定的,完成特定的任务。这就是为不确定的输出,添加规则,使其输出趋近 幂等性 。一旦一项可以处理任务的工具有这种性质,就可以面向任何人使用。可以称这种诱导过程为解决某一问题的一种 解决方案


比如上面的三句话就是:根据类信息生成 Dart 数据类型,并支持空安全。在当前环境下,就可以基于这种方案去处理同类的任务:



用 Dart 新版本生成一个 dart 类 TaskResult,字段为: final 非空 int 型 cost , final 非空 String 型 taskName 默认值为 “unknown” , final 非空 int 型 count,可空 String型taskInfo,final 非空 String型 taskCode



image.png


你拷贝代码后,就是可用的:


image.png




4. Dart 数据类生成器完善

上面生成 Dart 数据类比较简单,下面继续拓展,比如对于数据类型而言 copyWithtoJsonfromJson 的方法自己写起来比较麻烦。如果现在告诉它:



为上面的类提供 copyWith、toJson 、 fromJson 方法



它会进行提供,说明它具有这个 默认知识储备 ,但可以看到 copyWith 方法中的字段不符合空安全:


image.png


此时可以训练它的 类型可空 的意识,让它主动处理类似的问题,也可以直白的告诉它



将上面的 copyWith 方法入参类型后加 ? 号



这样生成的 TaskResult 类就可以使用了:


image.png


class TaskResult {
final int cost;
final String taskName;
final int count;
final String? taskInfo;
final String taskCode;

TaskResult({
required this.cost,
this.taskName = 'unknown',
required this.count,
this.taskInfo,
required this.taskCode,
});

TaskResult copyWith({
int? cost,
String? taskName,
int? count,
String? taskInfo,
String? taskCode,
}) {
return TaskResult(
cost: cost ?? this.cost,
taskName: taskName ?? this.taskName,
count: count ?? this.count,
taskInfo: taskInfo ?? this.taskInfo,
taskCode: taskCode ?? this.taskCode,
);
}

Map toJson() {
return {
'cost': cost,
'taskName': taskName,
'count': count,
'taskInfo': taskInfo,
'taskCode': taskCode,
};
}

static TaskResult fromJson(Map json) {
return TaskResult(
cost: json['cost'] as int,
taskName: json['taskName'] as String,
count: json['count'] as int,
taskInfo: json['taskInfo'] as String,
taskCode: json['taskCode'] as String,
);
}
}



5. 代码生成字符串 与 ChatGPT 生成字符串

对于一些相对固定的代码,可以使用代码逻辑,拼接字符串来生成。如下所示,通过对类结构的抽象化,使用对象进行配置,输出字符串。我们来思考一下,这和 ChatGPT 生成代码的区别。


首先,使用代码生成代码是一种完全的 幂等行为 。也就是说任何人、在任何时间、任何空间下,使用相同的输入,都可以获取到相同的输出,是绝对精准的。其产生代码的行为逻辑是完全可控的,人的内心是期待确定性的。


image.png


而 ChatGPT 对自然语言的理解,你可以用语言去引导它输出一些你的需求,比如 :



以json 格式生成 10 句连续的中文对话,key 为 content包括。另外 time 字段为时间戳 ,type 字段1,2 随机



image.png


其实没有什么孰强孰弱,只是使用场景的不同而已。刀在不同人的手里有不同的用法,人是生产生活的主体,工具只有服务的属性。驾驭工具,让它产生实用的价值,才是工具存在的意义。好了,本文到这里就扯完了,感谢观看 ~


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

动态适配 web 终端的尺寸

web
使Xterminal组件自适应容器 通过 xtermjs 所创建的终端大小是由cols、rows这两个配置项的来决定,虽然你可以通过 CSS 样式来让其产生自适应效果,但是这种情况下字体会变得模糊变形等、会出现一系列的问题,要解决这个问题我们还是需要使用col...
继续阅读 »

使Xterminal组件自适应容器


通过 xtermjs 所创建的终端大小是由cols、rows这两个配置项的来决定,虽然你可以通过 CSS 样式来让其产生自适应效果,但是这种情况下字体会变得模糊变形等、会出现一系列的问题,要解决这个问题我们还是需要使用cols、rows这两个值来动态设置。


image.png


红色部分则是通过colsrows属性控制,我们可以很明显的看到该终端组件并没有继承父元素的宽度以及高度,而实际的宽度则是通过colsrows两个属性控制的。


如何动态设置cols和rows这两个参数。


我们去看官方官方文档的时候,会注意到,官方有提供几个插件供我们使用。


image.png


xterm-addon-fit: 可以帮助我们来让 web 终端实现宽度自适应容器。目前的话行数还不行,暂时没有找到好的替代方案,需要动态的计算出来,关于如何计算可以参数 vscode 官方的实现方案。


image.png


引入xterm-addon-fit,在我们的案例中,加入下面这两行:


image.png


动态计算行数


想要动态计算出行数的话,就需要获取到一个dom元素的高度:


image.png


动态计算尺寸的方法。


const terminalReact: null | HTMLDivElement = terminalRef.current // 100% * 100%
const xtermHelperReact: DOMRect | undefined = terminalReact?.querySelector(".xterm-helper-textarea")!.getBoundingClientRect()
const parentTerminalRect = terminalReact?.getBoundingClientRect()
const rows = Math.floor((parentTerminalRect ? parentTerminalRect.height : 20) / (xtermHelperReact ? xtermHelperReact.height : 1))
const cols = Math.floor((parentTerminalRect ? parentTerminalRect.width : 20) / (xtermHelperReact ? xtermHelperReact.width : 1))
// 调用resize方法,重新设置大小
termRef.current.resize(cols, rows)
复制代码

我们可以考虑封装成一个函数,只要父亲组件的大小发生变化,就动态适配一次。


作者:可视化高级双料技工
链接:https://juejin.cn/post/7160332506015727629
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

Backbone前端框架解读

web
一、 什么是Backbone在前端的发展道路中,前端框架元老之一jQuery对繁琐的DOM操作进行了封装,提供了链式调用、各类选择器,屏蔽了不同浏览器写法的差异性,但是前端开发过程中依然存在作用域污染、代码复用度低、冗余度高、数据和事件绑定烦琐等痛点。5年后,...
继续阅读 »

一、 什么是Backbone

在前端的发展道路中,前端框架元老之一jQuery对繁琐的DOM操作进行了封装,提供了链式调用、各类选择器,屏蔽了不同浏览器写法的差异性,但是前端开发过程中依然存在作用域污染、代码复用度低、冗余度高、数据和事件绑定烦琐等痛点。

5年后,Backbone横空出世,通过与Underscore、Require、Handlebar的整合,提供了一个轻量和友好的前端开发解决方案,其诸多设计思想对于后续的现代化前端框架发展起到了举足轻重的作用,堪称现代前端框架的基石。

通过对Backbone前端框架的学习,让我们领略其独特的设计思想。



二、 核心架构

按照MVC框架的定义,MVC是用来将应用程序分为三个主要逻辑组件的架构模式:模型,视图和控制器。这些组件被用来处理一个面向应用的特定开发。 MVC是最常用的行业标准的Web开发框架,以创建可扩展的项目之一。 Backbone.js为复杂WEB应用程序提供模型(models)、集合(collections)、视图(views)的结构。

◦ 其中模型用于绑定键值数据,并通过RESRful JSON接口连接到应用程序;

◦ 视图用于UI界面渲染,可以声明自定义事件,通过监听模型和集合的变化执行相应的回调(如执行渲染)。





如图所示,当用户与视图层产生交互时,控制层监听变化,负责与数据层进行数据交互,触发数据Change事件,从而通知视图层重新渲染,以实现UI界面更新。更进一步,当数据层发生变化时,由Backbone提供了数据层和服务器数据共享同步的能力。

其设计思想主要包含以下几点:

◦数据绑定(依赖渲染模板引擎)、事件驱动(依赖Events)

◦视图组件化,并且组件有了生命周期的概念

◦前端路由配置化,实现页面局部刷新

这些创新的思想,在现代前端框架中进一步得到了继承和发扬。



三、 部分源码解析

Backbone极度轻量,编译后仅有几kb,贯穿其中的是大量的设计模式:工厂模式、观察者模式、迭代器模式、适配器模式……,代码流畅、实现过程比较优雅。按照功能拆分为了Events、Model、Collection、Router、History、View等若干模块,这里摘取了部分精彩源码进行了解析,相信对我们的日常代码开发也有一定指导作用:

(1)迭代器

EventsApi起到一个迭代器分流的作用,对多个事件进行解析拆分,设计的非常经典,执行时以下用法都是合法的:

◦用法一:传入一个名称和回调函数的对象

modal.on({
   "change": change_callback,
   "remove": remove_callback
})

◦用法二:使用空格分割的多个事件名称绑定到同一个回调函数上

model.on("change remove", common_callback)

实现如下:

var eventsApi = function(iteratee, events, name, callback, opts) {
  var i = 0, names;
  if(name && typeof name === 'object') {
      // 处理第一种用法
      if(callback !== void 0 && 'context' in opts && opts.context === void 0) opts.context = callback;
      for(names = _.keys(names); i < names.length; i++) events = eventsApi(iteratee, events, names[i], name[names[i]], opts);
  } else if(name && eventSplitter.test(name)) {
      // 处理第二种用法
      for(names = name.split(eventSplitter); i < names.length; i++) events = iteratee(events, names[i], callback, opts);
  } else {
      events = iteratee(events, name, callback, opts);
  }
  return events;
}

(2)监听器

用于一个对象监听另外一个对象的事件,例如,在A对象上监听在B对象上发生的事件,并且执行A的回调函数:

A.listenTo(B, "b", callback)

实际上这个功能用B对象来监听也可以实现:

B.on("b", callback, A)

这么做的好处是,方便对A创建、销毁逻辑的代码聚合,并且对B的侵入程度较小。实现如下:

Events.listenTo = function(obj, name, callback) {
   if(!obj) return this;
   var id = obj._listenId || (obj._listenId = _.uniqueId('l'));
   // 当前对象的所有监听对象
   var listeningTo = this._listeningTo || (this._listeningTo = {});
   var listening = listeningTo[id];
   
   if(!listening) {
       // 创建自身监听id
       var thisId = this._listenId || (this._listenId = _.uniqueId('l'));
       listening = listeningTo[id] = {obj: obj, objId: id, id: thisId, listeningTo: listeningTo, count: 0};
  }
   // 执行对象绑定
   internalOn(obj, name, callback, this, listening);
   return this;
}

(3)Model值set

通过option-flags兼容赋值、更新、删除等操作,这么做的好处是融合公共逻辑,简化代码逻辑和对外暴露api。实现如下:

set: function(key, val, options) {
  if(key == null) return this;
  // 支持两种赋值方式: 对象或者 key\value
  var attrs;
  if(typeof key === 'object') {
      attrs = key;
      options = val;
  } else {
      (attrs = {})[key] = val;
  }
  options || (options = {});
  ……
  var unset = options.unset;
  var silent = options.silent;
  var changes = [];
  var changing = this._changing; // 处理嵌套set
  this._changing = true;
   
  if(!changing) {
      // 存储变更前的状态快照
      this._previousAttributes = _.clone(this.attributes);
      this.changed = {};
  }
  var current = this.attributes;
  var changed = this.changed;
  var prev = this._previousAttributes;
   
  for(var attr in attrs) {
      val = attrs[attr];
      if(!_.isEqual(current[attr], val)) changes.push(attr);
      // changed只存储本次变化的key
      if(!_.isEqual(prev[attr], val)) {
          changed[attr] = val;
      } else {
          delete changed[attr]
      }
      unset ? delete current[attr] : (current[attr] = val)
  }
  if(!silent) {
      if(changes.length) this._pending = options;
      for(var i=0; i<changes.length; i++) {
          // 触发 change:attr 事件
          this.trigger('change:' + changes[i], this, current[changes[i]], options);
      }
  }
  if(changing) return this;
  if(!silent) {
      // 处理递归change场景
      while(this._pending) {
          options = this._pending;
          this._pending = false;
          this.trigger('change', this, options);
      }
  }
  this._pending = false;
  this._changing = false;
  return this;
}

四、 不足(对比react、vue)

对比现代前端框架,由于Backbone本身比较轻量,对一些内容细节处理不够细腻,主要体现在:

◦视图和数据的交互关系需要自己分类编写逻辑,需要编写较多的监听器

◦监听器数量较大,需要手动销毁,维护成本较高

◦视图树的二次渲染仅能实现组件整体替换,并非增量更新,存在性能损失

◦路由切换需要自己处理页面更新逻辑



五、为什么选择Backbone

看到这里,你可能有些疑问,既然Backbone存在这些缺陷,那么现在学习Backbone还有什么意义呢?

首先,对于服务端开发人员,Backbone底层依赖underscore/lodash、jQuery/Zepto,目前依然有很多基于Jquery和Velocity的项目需要维护,会jQuery就会Backbone,学习成本低;通过Backbone能够学习用数据去驱动View更新,优化jQuery的写法;Backbone面对对象编程,符合Java开发习惯。

其次,对于前端开发人员,能够学习其模块化封装库类函数,提升编程技艺。Backbone的组件化开发,和现代前端框架有很多共通之处,能够深入理解其演化历史。

作者:京东零售 陈震
来源:juejin.cn/post/7197075558941311035

收起阅读 »

一篇文章带你掌握Flex布局的所有用法

web
Flex 布局目前已经非常流行了,现在几乎已经兼容所有浏览器了。在文章开始之前我们需要思考一个问题:我们为什么要使用 Flex 布局?其实答案很简单,那就是 Flex 布局好用。一个新事物的出现往往是因为旧事物不那么好用了,比如,如果想让你用传统的 css 布...
继续阅读 »

Flex 布局目前已经非常流行了,现在几乎已经兼容所有浏览器了。在文章开始之前我们需要思考一个问题:我们为什么要使用 Flex 布局?

其实答案很简单,那就是 Flex 布局好用。一个新事物的出现往往是因为旧事物不那么好用了,比如,如果想让你用传统的 css 布局来实现一个块元素垂直水平居中你会怎么做?实现水平居中很简单,margin: 0 auto就行,而实现垂直水平居中则可以使用定位实现:

<div class="container">
 <div class="item"></div>
</div>
.container {
position: relative;
width: 300px;
height: 300px;
background: red;
}
.item {
position: absolute;
background: black;
width: 50px;
height: 50px;
margin: auto;
left: 0;
top: 0;
bottom: 0;
right: 0;
}

或者

.item {
 position: absolute;
 background: black;
 width: 50px;
 height: 50px;
 margin: auto;
 left: calc(50% - 25px);
 top: calc(50% - 25px);
}

image.png

但是这样都显得特别繁琐,明明可以一个属性就能解决的事情没必要写这么麻烦。而使用 Flex 则可以使用 place-content 属性简单的实现(place-content 为 justify-content 和 align-content 简写属性)

.container {
 width: 300px;
 height: 300px;
 background: red;
 display: flex;
 place-content: center;
}
.item {
 background: black;
 width: 50px;
 height: 50px;
}

接下来的本篇文章将会带领大家一起来探讨Flex布局

基本概念

我们先写一段代码作为示例(部分属性省略)

html

<div class="container">
 <div class="item">flex项目</div>
 <div class="item">flex项目</div>
 <div class="item">flex项目</div>
 <div class="item">flex项目</div>
</div>
.container {
display: flex;
width: 800px;
gap: 10px;
}
.item {
color: #fff;
}

image.png

flex 容器

我们可以将一个元素的 display 属性设置为 flex,此时这个元素则成为flex 容器比如container元素

flex 项目

flex 容器的子元素称为flex 项目,比如item元素

flex 布局有两个轴,主轴交叉轴,至于哪个是主轴哪个是交叉轴则有flex 容器flex-direction属性决定,默认为:flex-direction:row,既横向为主轴,纵向为交叉轴,

image.png

flex-direction还可以设置其它三个属性,分别为row-reverse,column,column-reverse

  • row-reverse

image.png

  • column

1675390782104.png

  • column-reverse

1675390925377.png

从这里我们可以看出 Flex 轴的方向不是固定不变的,它受到flex-direction的影响

不足空间和剩余空间

当 Flex 项目总宽度小于 Flex 容器宽度时就会出现剩余空间

image.png

当 Flex 项目总宽度大于 Flex 容器宽度时就会出现不足空间

image.png

Flex 项目之间的间距

Flex 项目之间的间距可以直接在 Flex 容器上设置 gap 属性即可,如

<div class="container">
 <div class="item">A</div>
 <div class="item">B</div>
 <div class="item">C</div>
 <div class="item">D</div>
</div>
.container {
display: flex;
width: 500px;
height: 400px;
gap: 10px;
}
.item {
width: 150px;
height: 40px;
}

image.png

Flex 属性

flex属性是flex-grow,flex-shrink,flex-basis三个属性的简写。下面我们来看下它们分别是什么。

  • flex-basis可以设定 Flex 项目的大小,一般主轴为水平方向的话和 width 解析方式相同,但是它不一定是 Flex 项目最终大小,Flex 项目最终大小受到flex-grow,flex-shrink以及剩余空间等影响,后面文章会告诉大家最终大小的计算方式

  • flex-grow为 Flex 项目的扩展系数,当 Flex 项目总和小于 Flex 容器时就会出现剩余空间,而flex-grow的值则可以决定这个 Flex 项目可以分到多少剩余空间

  • flex-shrink为 Flex 项目的收缩系数,同样的,当 Flex 项目总和大于 Flex 容器时就会出现不足空间,flex-shrink的值则可以决定这个 Flex 项目需要减去多少不足空间

既然flex属性是这三个属性的简写,那么flex属性简写方式分别代表什么呢?

flex属性可以为 1 个值,2 个值,3 个值,接下来我们就分别来看看它们代表什么意思

  • 一个值

如果flex属性只有一个值的话,我们可以看这个值是否带单位,带单位那就是flex-basis,不带就是flex-grow

.item {
 flex: 1;

 /* 相当于 */
 flex-grow: 1;
 flex-shrink: 1;
 flex-basis: 0;
}
.item {
 flex: 30px;

 /* 相当于 */
 flex-grow: 1;
 flex-shrink: 1;
 flex-basis: 30px;
}
  • 两个值

flex属性有两个值的话,第一个无单位的值就是flex-grow,第一个无单位的值则是flex-shrink,有单位的就是flex-basis

.item {
 flex: 1 2;

 /* 相当于 */
 flex-grow: 1;
 flex-shrink: 2;
 flex-basis: 0;
}
.item {
 flex: 30px 2;

 /* 相当于 */
 flex-grow: 2;
 flex-shrink: 1;
 flex-basis: 30px;
}
  • 三个值

flex属性有三个值的话,第一个无单位的值就是flex-grow,第一个无单位的值则是flex-shrink,有单位的就是flex-basis

.item {
 flex: 1 2 10px;

 /* 相当于 */
 flex-grow: 1;
 flex-shrink: 2;
 flex-basis: 10px;
}
.item {
 flex: 30px 2 1;

 /* 相当于 */
 flex-grow: 2;
 flex-shrink: 1;
 flex-basis: 30px;
}

.item {
 flex: 2 30px 1;

 /* 相当于 */
 flex-grow: 2;
 flex-shrink: 1;
 flex-basis: 30px;
}

另外,flex 的值还可以为initial,auto,none

  • initial

initial 为默认值,和不设置 flex 属性的时候表现一样,既 Flex 项目不会扩展,但会收缩,Flex 项目大小有本身内容决定

 .item {
flex: initial;

/* 相当于 */
flex-grow: 0;
flex-shrink: 1;
flex-basis: auto;
}
  • auto

当 flex 设置为 auto 时,Flex 项目会根据自身内容确定flex-basis,既会拓展也会收缩

 .item {
flex: auto;

/* 相当于 */
flex-grow: 1;
flex-shrink: 1;
flex-basis: auto;
}
  • none

none 表示 Flex 项目既不收缩,也不会扩展

 .item {
flex: none;

/* 相当于 */
flex-grow: 0;
flex-shrink: 0;
flex-basis: auto;
}

Flex 项目大小的计算

首先看一下 flex-grow 的计算方式

flex-grow

面试中经常问到: 为什么 flex 设置为 1 的时候,Flex 项目就会均分 Flex 容器? 其实 Flex 项目设置为 1 不一定会均分容器(后面会解释),这里我们先看下均分的情况是如何发生的

同样的我们先举个例子

<div>
<div>Xiaoyue</div>
<div>June</div>
<div>Alice</div>
<div>Youhu</div>
<div>Liehuhu</div>
</div>
.container {
display: flex;
width: 800px;
}
.item {
flex: 1;
font-size: 30px;
}

flex 容器总宽度为 800px,flex 项目设置为flex:1,此时页面上显示

image.png

我们可以看到每个项目的宽度为 800/5=160,下面来解释一下为什么会均分:

首先

 .item {
flex: 1;

/* 相当于 */
flex-grow: 1;
flex-shrink: 1;
flex-basis: 0;
}

因为flex-basis为 0,所有 Flex 项目扩展系数都是 1,所以它们分到的剩余空间都是一样的。下面看一下是如何计算出最终项目大小的

这里先给出一个公式:

Flex项目弹性量 = (Flex容器剩余空间/所有flex-grow总和)\*当前Flex项目的flex-grow

其中Flex项目弹性量指的是分配给 Flex 项目多少的剩余空间,所以 Flex 项目的最终宽度为

flex-basis+Flex项目弹性量

根据这个公式,上面的均分也就很好理解了,因为所有的flex-basis为 0,所以剩余空间就是 800px,每个 Flex 项目的弹性量也就是(800/1+1+1+1+1)*1=160,那么最终宽度也就是160+0=160

刚刚说过 flex 设置为 1 时 Flex 项目并不一定会被均分,下面就来介绍一下这种情况,我们修改一下示例中的 html,将第一个 item 中换成一个长单词

<div>
<div>Xiaoyueyueyue</div>
<div>June</div>
<div>Alice</div>
<div>Youhu</div>
<div>Liehu</div>
</div>

此时会发现 Flex 容器并没有被均分

image.png

因为计算出的灵活性 200px 小于第一个 Flex 项目的min-content(217.16px),此时浏览器会采用 Flex 项目的min-content作为最终宽度,而后面的 Flex 项目会在第一个 Flex 项目计算完毕后再进行同样的计算

我们修改一下 flex,给它设置一个 flex-basis,看下它计算之后的情况

.item {
text-align: center;
flex: 1 100px;
}

因为每个项目的flex-basis都是 100px,Flex 容器剩余空间800-500=300px,所以弹性量就是(300/5)*1=60px,最终宽度理论应该为100+60=160px,同样的因为第一个 Flex 项目的min-content为 217.16px,所以第一个 Flex 项目宽度被设置为 217.16px,最终表现和上面一样

image.png

我们再来看一下为什么第 2,3,4,5 个 Flex 项目宽度为什么是 145.71px

1675415296477.png

当浏览器计算完第一个 Flex 项目为 217.16px 后,此时的剩余空间为800-217.16-100*4=182.84,第 2 个 Flex 项目弹性量(182.84/1+1+1+1)*1=45.71,所以最终宽度为100+45.71=145.71px,同样的后面的 Flex 项目计算方式是一样的,但是如果后面再遇到长单词,假如第五个是长单词,那么不足空间将会发生变化,浏览器会将第五个 Flex 项目宽度计算完毕后再回头进行一轮计算,具体情况这里不再展开

所以说想要均分 Flex 容器 flex 设置为 1 并不能用在所有场景中,其实当 Flex 项目中有固定宽度元素也会出现这种情况,比如一张图片等,当然如果你想要解决这个问题其实也很简单,将 Flex 项目的min-width设置为 0 即可

.item {
flex: 1 100px;
min-width: 0;
}

image.png

flex-grow 为小数

flex-grow 的值不仅可以为正整数,还可以为小数,当为小数时也分为两种情况:所有 Flex 项目的 flex-grow 之和小于等于 1 和大于 1,我们先看小于等于 1 的情况,将例子的改成

<div>
<div>Acc</div>
<div>Bc</div>
<div>C</div>
<div>DDD</div>
<div>E</div>
</div>
.item:nth-of-type(1) {
flex-grow: 0.1;
}
.item:nth-of-type(2) {
flex-grow: 0.2;
}
.item:nth-of-type(3) {
flex-grow: 0.2;
}
.item:nth-of-type(4) {
flex-grow: 0.1;
}
.item:nth-of-type(5) {
flex-grow: 0.1;
}

效果如图

image.png

我们可以发现项目并没有占满容器,它的每个项目的弹性量计算方式为

Flex项目弹性量=Flex容器剩余空间*当前Flex项目的flex-grow

相应的每个项目的实际宽度也就是flex-basis+弹性量,首先先不设置 flex-grow,我们可以看到每个项目的 flex-basis 分别为: 51.2 , 33.88 , 20.08 , 68.56 , 16.5

image.png

所以我们可以计算出 Flex 容器的剩余空间为800-51.2 -33.88 - 20.08 - 68.56 - 16.5=609.78,这样我们就可以算出每个项目的实际尺寸为

A: 实际宽度 = 51.2 + 609.78*0.1 = 112.178

B: 实际宽度 = 33.88 + 609.78*0.2 = 155.836

...

下面看下 flex-grow 之和大于 1 的情况,将例子中的 css 改为

.item:nth-of-type(1) {
flex-grow: 0.1;
}
.item:nth-of-type(2) {
flex-grow: 0.2;
}
.item:nth-of-type(3) {
flex-grow: 0.3;
}
.item:nth-of-type(4) {
flex-grow: 0.4;
}
.item:nth-of-type(5) {
flex-grow: 0.5;
}

此时的效果为

image.png

可以看出 Flex 项目是占满容器的,它的计算方式其实和 flex-grow 为正整数时一样

Flex项目弹性量 = (Flex容器剩余空间/所有flex-grow总和)\*当前Flex项目的flex-grow

所以我们可以得出一个结论: Flex 项目的 flex-grow 之和小于 1,Flex 项目不会占满 Flex 容器

flex-shrink

flex-shrink 其实和 flex-grow 基本一样,就是扩展变成了收缩,flex-grow 是项目比例增加容器剩余空间,而 flex-shrink 则是比例减去容器不足空间

修改一下我们的例子:

.item {
flex-basis: 200px;
/* 相当于 */
flex-shrink: 1;
flex-grow: 0;
flex-basis: 200px;
}

此时项目的总宽度200*5=1000px已经大于容器总宽度800px,此时计算第一个项目的不足空间就是800-200*5=-200px,第二个项目的不足空间则是800-第一个项目实际宽度-200*4,依次类推

最终计算公式其实和 flex-grow 计算差不多

Flex项目弹性量 = (Flex容器不足空间/所有flex-shrink总和)*当前Flex项目的flex-shrink

只不过,所以上面例子每个项目可以计算出实际宽度为

第一个 Flex 项目: 200+((800-200x5)/5)*1 = 160px

第二个 Flex 项目: 200+((800-160-200x4)/4)*1 = 160px

第三个 Flex 项目: 200+((800-160-160-200x3)/3)*1 = 160px

第四个 Flex 项目: 200+((800-160-160-160-200x2)/2)*1 = 160px

第五个 Flex 项目: 200+((800-160-160-160-160-200x1)/1)*1 = 160px

如果 Flex 项目的min-content大于flex-basis,那么最终的实际宽度将会取该项目的min-content,比如改一下例子,将第一个 Flex 项目改成长单词

<div>
<div>XiaoyueXiaoyue</div>
<div>June</div>
<div>Alice</div>
<div>Youhu</div>
<div>Liehu</div>
</div>

image.png

可以看出浏览器最终采用的是第一个 Flex 项目的min-content作为实际宽度,相应的后面 Flex 项目的宽度会等前一个 Flex 项目计算完毕后在进行计算

比如第二个 Flex 项目宽度= 200+((800-228.75-200x4)/4)*1 = 142.81px

flex-shrink 为小数

同样的 flex-shrink 也会出现小数的情况,也分为 Flex 项目的 flex-shrink 之和小于等于 1 和大于 1 两种情况,如果大于 1 和上面的计算方式一样,所以我们只看小于 1 的情况,将我们的例子改为

.item {
flex-basis: 200px;
flex-shrink: 0.1;
}

效果为

image.png

此时我们会发现 Flex 项目溢出了容器,所以我们便可以得出一个结论:Flex 项目的 flex-shrink 之和小于 1,Flex 项目会溢出 Flex 容器

下面看一下它的计算公式

Flex项目弹性量=Flex容器不足空间*当前Flex项目的flex-shrink
Flex项目实际宽度=flex-basis + Flex项目弹性量

比如上面例子的每个 Flex 项目计算结果为

第一个 Flex 项目宽度 = 200+(800-200x5)x0.1=180px,但是由于它本身的min-content为 228.75,所以最终宽度为 228.75

第二个 Flex 项目宽度 =200-(800-228.75-200x4)x0.1=117.125

第三个 Flex 项目宽度...

Flex 的对齐方式

Flex 中关于对齐方式的属性有很多,其主要分为两种,一是主轴对齐方式:justify-,二是交叉轴对齐方式:align-

首先改一下我们的例子,将容器设置为宽高为 500x400 的容器(部分属性省略)

<div>
<div>A</div>
<div>B</div>
<div>C</div>
</div>
.container {
display: flex;
width: 500px;
height: 400px;
}
.item {
width: 100px;
height: 40px;
}

image.png

主轴对齐属性

这里以横向为主轴,纵向为交叉轴

justify-content

justify-content的值可以为:

  • flex-start 默认值,主轴起点对齐

image.png

  • flex-end 主轴终点对齐

image.png

  • left 默认情况下和 flex-start 一致

  • right 默认情况下和 flex-end 一致

  • center 主轴居中对齐

image.png

  • space-between 主轴两端对齐,并且 Flex 项目间距相等

image.png

  • space-around 项目左右周围空间相等

image.png

  • space-evenly 任何两个项目之间的间距以及边缘的空间相等

image.png

交叉轴对齐方式

align-content

align-content 属性控制整个 Flex 项目在 Flex 容器中交叉轴的对齐方式

注意设置 align-content 属性时候必须将 flex-wrap 设置成 wrap 或者 wrap-reverse。它可以取得值为

  • stretch 默认值,当我们 Flex 元素不设置高度的时候,默认是拉伸的

比如将 Flex 元素宽度去掉

.item {
width: 100px;
}

image.png

  • flex-start 位于容器开头,这个和 flex-direction:属性有关,默认在顶部

image.png

  • flex-end 位于容器结尾

image.png

  • center 元素居中对齐

image.png

  • space-between 交叉轴上下对齐,并且 Flex 项目上下间距相等

此时我们改下例子中 Flex 项目的宽度使其换行,因为如果 Flex 项目只有一行,那么 space-between 与 flex-start 表现一致

.item {
width: 300px;
}

image.png

  • space-around 项目上下周围空间相等

image.png

  • space-evenly 任何两个项目之间的上下间距以及边缘的空间相等

image.png

align-items

align-items 属性定义 flex 子项在 flex 容器的当前行的交叉轴方向上的对齐方式。它与 align-content 有相似的地方,它的取值有

  • stretch 默认值,当我们 Flex 元素不设置高度的时候,默认是拉伸的

  • center 元素位于容器的中心,每个当前行在图中已经框起来

image.png

  • flex-start 位于容器开头

  • flex-end 位于容器结尾

  • baseline 位于容器的基线上

比如给 A 项目一个 padding-top

.item:nth-of-type(1) {
padding-top: 50px;
}

没设置 baseline 的表现

image.png

设置 baseline 之后

image.png

通过上面的例子我们可以发现,如果想要整个 Flex 项目垂直对齐,在只有一行的情况下,align-items 和 align-content 设置为 center 都可以做到,但是如果出现多行的情况下 align-items 就不再适用了

align-self

上面都是给 Flex 容器设置的属性,但是如果想要控制单个 Flex 项目的对齐方式该怎么办呢?

其实 Flex 布局中已经考虑到了这个问题,于是就有个 align-self 属性来控制单个 Flex 项目在 Flex 容器侧交叉轴的对齐方式。

align-self 和 align-items 属性值几乎是一致的,比如我们将整个 Flex 项目设置为 center,第二个 Flex 项目设置为 flex-start

.container {
display: flex;
width: 500px;
height: 400px;
align-items: center;
}
.item {
width: 100px;
height: 40px;
}
.item:nth-of-type(2) {
align-self: flex-start;
}

image.png

注意,除了以上提到的属性的属性值,还可以设置为 CSS 的关键词如 inherit 、initial 等

交叉轴与主轴简写

place-content

place-content` 为 `justify-content` 和 `align-content` 的简写形式,可以取一个值和两个值,如果设置一个值那么 `justify-content` 和 `align-content` 都为这个值,如果是两个值,第一个值为 `align-content`,第二个则是 `justify-content

到这里关于Flex布局基本已经介绍完了,肯定会有些细枝末节没有考虑到,这可能就需要我们在平时工作和学习中去发现了

作者:东方小月
来源:https://juejin.cn/post/7197229913156796472

收起阅读 »

百万级数据excel导出功能如何实现?

前言最近我做过一个MySQL百万级别数据的excel导出功能,已经正常上线使用了。这个功能挺有意思的,里面需要注意的细节还真不少,现在拿出来跟大家分享一下,希望对你会有所帮助。原始需求:用户在UI界面上点击全部导出按钮,就能导出所有商品数据。咋一看,这个需求挺...
继续阅读 »

前言

最近我做过一个MySQL百万级别数据的excel导出功能,已经正常上线使用了。

这个功能挺有意思的,里面需要注意的细节还真不少,现在拿出来跟大家分享一下,希望对你会有所帮助。

原始需求:用户在UI界面上点击全部导出按钮,就能导出所有商品数据。

咋一看,这个需求挺简单的。

但如果我告诉你,导出的记录条数,可能有一百多万,甚至两百万呢?

这时你可能会倒吸一口气。

因为你可能会面临如下问题:

  1. 如果同步导数据,接口很容易超时。

  2. 如果把所有数据一次性装载到内存,很容易引起OOM。

  3. 数据量太大sql语句必定很慢。

  4. 相同商品编号的数据要放到一起。

  5. 如果走异步,如何通知用户导出结果?

  6. 如果excel文件太大,目标用户打不开怎么办?

我们要如何才能解决这些问题,实现一个百万级别的excel数据快速导出功能呢?


1.异步处理

做一个MySQL百万数据级别的excel导出功能,如果走接口同步导出,该接口肯定会非常容易超时

因此,我们在做系统设计的时候,第一选择应该是接口走异步处理。

说起异步处理,其实有很多种,比如:使用开启一个线程,或者使用线程池,或者使用job,或者使用mq等。

为了防止服务重启时数据的丢失问题,我们大多数情况下,会使用job或者mq来实现异步功能。

1.1 使用job

如果使用job的话,需要增加一张执行任务表,记录每次的导出任务。

用户点击全部导出按钮,会调用一个后端接口,该接口会向表中写入一条记录,该记录的状态为:待执行

有个job,每隔一段时间(比如:5分钟),扫描一次执行任务表,查出所有状态是待执行的记录。

然后遍历这些记录,挨个执行。

需要注意的是:如果用job的话,要避免重复执行的情况。比如job每隔5分钟执行一次,但如果数据导出的功能所花费的时间超过了5分钟,在一个job周期内执行不完,就会被下一个job执行周期执行。

所以使用job时可能会出现重复执行的情况。

为了防止job重复执行的情况,该执行任务需要增加一个执行中的状态。

具体的状态变化如下:

  1. 执行任务被刚记录到执行任务表,是待执行状态。

  2. 当job第一次执行该执行任务时,该记录再数据库中的状态改为:执行中

  3. 当job跑完了,该记录的状态变成:完成失败

这样导出数据的功能,在第一个job周期内执行不完,在第二次job执行时,查询待处理状态,并不会查询出执行中状态的数据,也就是说不会重复执行。

此外,使用job还有一个硬伤即:它不是立马执行的,有一定的延迟。

如果对时间不太敏感的业务场景,可以考虑使用该方案。

1.2 使用mq

用户点击全部导出按钮,会调用一个后端接口,该接口会向mq服务端,发送一条mq消息

有个专门的mq消费者,消费该消息,然后就可以实现excel的数据导出了。

相较于job方案,使用mq方案的话,实时性更好一些。

对于mq消费者处理失败的情况,可以增加补偿机制,自动发起重试

RocketMQ自带了失败重试功能,如果失败次数超过了一定的阀值,则会将该消息自动放入死信队列

2.使用easyexcel

我们知道在Java中解析和生成Excel,比较有名的框架有Apache POIjxl

但它们都存在一个严重的问题就是:非常耗内存,POI有一套SAX模式的API可以一定程度的解决一些内存溢出的问题,但POI还是有一些缺陷,比如07版Excel解压缩以及解压后存储都是在内存中完成的,内存消耗依然很大。

百万级别的excel数据导出功能,如果使用传统的Apache POI框架去处理,可能会消耗很大的内存,容易引发OOM问题。

easyexcel重写了POI对07版Excel的解析,之前一个3M的excel用POI sax解析,需要100M左右内存,如果改用easyexcel可以降低到几M,并且再大的Excel也不会出现内存溢出;03版依赖POI的sax模式,在上层做了模型转换的封装,让使用者更加简单方便。

需要在mavenpom.xml文件中引入easyexcel的jar包:

<dependency>
   <groupId>com.alibaba</groupId>
   <artifactId>easyexcel</artifactId>
   <version>3.0.2</version>
</dependency>
复制代码

之后,使用起来非常方便。

读excel数据非常方便:

@Test
public void simpleRead() {
  String fileName = TestFileUtil.getPath() + "demo" + File.separator + "demo.xlsx";
  // 这里 需要指定读用哪个class去读,然后读取第一个sheet 文件流会自动关闭
  EasyExcel.read(fileName, DemoData.class, new DemoDataListener()).sheet().doRead();
}
复制代码

写excel数据也非常方便:

 @Test
public void simpleWrite() {
   String fileName = TestFileUtil.getPath() + "write" + System.currentTimeMillis() + ".xlsx";
   // 这里 需要指定写用哪个class去读,然后写到第一个sheet,名字为模板 然后文件流会自动关闭
   // 如果这里想使用03 则 传入excelType参数即可
   EasyExcel.write(fileName, DemoData.class).sheet("模板").doWrite(data());
}
复制代码

easyexcel能大大减少占用内存的主要原因是:在解析Excel时没有将文件数据一次性全部加载到内存中,而是从磁盘上一行行读取数据,逐个解析。

3.分页查询

百万级别的数据,从数据库一次性查询出来,是一件非常耗时的工作。

即使我们可以从数据库中一次性查询出所有数据,没出现连接超时问题,这么多的数据全部加载到应用服务的内存中,也有可能会导致应用服务出现OOM问题。

因此,我们从数据库中查询数据时,有必要使用分页查询。比如:每页5000条记录,分为200页查询。

public Page<User> searchUser(SearchModel searchModel) {
  List<User> userList = userMapper.searchUser(searchModel);
  Page<User> pageResponse = Page.create(userList, searchModel);
  pageResponse.setTotal(userMapper.searchUserCount(searchModel));
  return pageResponse;
}
复制代码

每页大小pageSize和页码pageNo,是SearchModel类中的成员变量,在创建searchModel对象时,可以设置设置这两个参数。

然后在Mybatis的sql文件中,通过limit语句实现分页功能:

limit #{pageStart}, #{pageSize}
复制代码

其中的pagetStart参数,是通过pageNo和pageSize动态计算出来的,比如:

pageStart = (pageNo - 1) * pageSize;
复制代码

4.多个sheet

我们知道,excel对一个sheet存放的最大数据量,是有做限制的,一个sheet最多可以保存1048576行数据。否则在保存数据时会直接报错:

invalid row number (1048576) outside allowable range (0..1048575)
复制代码

如果你想导出一百万以上的数据,excel的一个sheet肯定是存放不下的。

因此我们需要把数据保存到多个sheet中。

5.计算limit的起始位置

我之前说过,我们一般是通过limit语句来实现分页查询功能的:

limit #{pageStart}, #{pageSize}
复制代码

其中的pagetStart参数,是通过pageNo和pageSize动态计算出来的,比如:

pageStart = (pageNo - 1) * pageSize;
复制代码

如果只有一个sheet可以这么玩,但如果有多个sheet就会有问题。因此,我们需要重新计算limit的起始位置。

例如:

ExcelWriter excelWriter = EasyExcelFactory.write(out).build();
int totalPage = searchUserTotalPage(searchModel);

if(totalPage > 0) {
  Page<User> page = Page.create(searchModel);
  int sheet = (totalPage % maxSheetCount == 0) ? totalPage / maxSheetCount: (totalPage / maxSheetCount) + 1;
  for(int i=0;i<sheet;i++) {
    WriterSheet writeSheet = buildSheet(i,"sheet"+i);
    int startPageNo = i*(maxSheetCount/pageSize)+1;
    int endPageNo = (i+1)*(maxSheetCount/pageSize);
    while(page.getPageNo()>=startPageNo && page.getPageNo()<=endPageNo) {
      page = searchUser(searchModel);
      if(CollectionUtils.isEmpty(page.getList())) {
          break;
      }
       
      excelWriter.write(page.getList(),writeSheet);
      page.setPageNo(page.getPageNo()+1);
    }
  }
}
复制代码

这样就能实现分页查询,将数据导出到不同的excel的sheet当中。

6.文件上传到OSS

由于现在我们导出excel数据的方案改成了异步,所以没法直接将excel文件,同步返回给用户。

因此我们需要先将excel文件存放到一个地方,当用户有需要时,可以访问到。

这时,我们可以直接将文件上传到OSS文件服务器上。

通过OSS提供的上传接口,将excel上传成功后,会返回文件名称访问路径

我们可以将excel名称和访问路径保存到中,这样的话,后面就可以直接通过浏览器,访问远程excel文件了。

而如果将excel文件保存到应用服务器,可能会占用比较多的磁盘空间

一般建议将应用服务器文件服务器分开,应用服务器需要更多的内存资源或者CPU资源,而文件服务器需要更多的磁盘资源

7.通过WebSocket推送通知

通过上面的功能已经导出了excel文件,并且上传到了OSS文件服务器上。

接下来的任务是要本次excel导出结果,成功还是失败,通知目标用户。

有种做法是在页面上提示:正在导出excel数据,请耐心等待

然后用户可以主动刷新当前页面,获取本地导出excel的结果。

但这种用户交互功能,不太友好。

还有一种方式是通过webSocket建立长连接,进行实时通知推送。

如果你使用了SpringBoot框架,可以直接引入webSocket的相关jar包:

<dependency>
 <groupId>org.springframework.boot</groupId>
 <artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
复制代码

使用起来挺方便的。

我们可以加一张专门的通知表,记录通过webSocket推送的通知的标题、用户、附件地址、阅读状态、类型等信息。

能更好的追溯通知记录。

webSocket给客户端推送一个通知之后,用户的右上角的收件箱上,实时出现了一个小窗口,提示本次导出excel功能是成功还是失败,并且有文件下载链接。

当前通知的阅读状态是未读

用户点击该窗口,可以看到通知的详细内容,然后通知状态变成已读

8.总条数可配置

我们在做导百万级数据这个需求时,是给用户用的,也有可能是给运营同学用的。

其实我们应该站在实际用户的角度出发,去思考一下,这个需求是否合理。

用户拿到这个百万级别的excel文件,到底有什么用途,在他们的电脑上能否打开该excel文件,电脑是否会出现太大的卡顿了,导致文件使用不了。

如果该功能上线之后,真的发生发生这些情况,那么导出excel也没有啥意义了。

因此,非常有必要把记录的总条数,做成可配置的,可以根据用户的实际情况调整这个配置。

比如:用户发现excel中有50万的数据,可以正常访问和操作excel,这时候我们可以将总条数调整成500000,把多余的数据截取掉。

其实,在用户的操作界面,增加更多的查询条件,用户通过修改查询条件,多次导数据,可以实现将所有数据都导出的功能,这样可能更合理一些。

此外,分页查询时,每页的大小,也建议做成可配置的。

通过总条数和每页大小,可以动态调整记录数量和分页查询次数,有助于更好满足用户的需求。

9.order by商品编号

之前的需求是要将相同商品编号的数据放到一起。

例如:

编号商品名称仓库名称价格
1笔记本北京仓7234
1笔记本上海仓7235
1笔记本武汉仓7236
2平板电脑成都仓7236
2平板电脑大连仓3339

但我们做了分页查询的功能,没法将数据一次性查询出来,直接在Java内存中分组或者排序。

因此,我们需要考虑在sql语句中使用order by 商品编号,先把数据排好顺序,再查询出数据,这样就能将相同商品编号,仓库不同的数据放到一起。

此外,还有一种情况需要考虑一下,通过配置的总记录数将全部数据做了截取。

但如果最后一个商品编号在最后一页中没有查询完,可能会导致导出的最后一个商品的数据不完整。

因此,我们需要在程序中处理一下,将最后一个商品删除。

但加了order by关键字进行排序之后,如果查询sql中join了很多张表,可能会导致查询性能变差。

那么,该怎么办呢?

总结

最后用两张图,总结一下excel异步导数据的流程。

如果是使用mq导数据:


如果是使用job导数据:


这两种方式都可以,可以根据实际情况选择使用。

我们按照这套方案的开发了代码,发到了pre环境,原本以为会非常顺利,但后面却还是出现了性能问题。

后来,我们用了两招轻松解决了性能问题。

作者:苏三说技术
来源:juejin.cn/post/7196140566111043643

收起阅读 »

对于单点登录,你不得不了解的CAS

之前我们通过面试的形式,讲了JWT实现单点登录(SSO)的设计思路,并且到最后也留下了疑问,什么是CAS。寒暄开始今天是上班的第一天,刚进公司就见到了上次的面试官,穿着格子衬衫和拖鞋,我们就叫他老余吧。老余看见我就开始勾肩搭背聊起来了,完全就是自来熟的样子,和...
继续阅读 »

之前我们通过面试的形式,讲了JWT实现单点登录(SSO)的设计思路,并且到最后也留下了疑问,什么是CAS

寒暄开始

今天是上班的第一天,刚进公司就见到了上次的面试官,穿着格子衬衫和拖鞋,我们就叫他老余吧。老余看见我就开始勾肩搭背聊起来了,完全就是自来熟的样子,和我最近看的少年歌行里的某人很像。

什么是CAS呢

老余:上次你说到了CAS,你觉得CAS是什么?

我:之前我们面试的时候,我讲了JWT单点登录带来的问题,然后慢慢优化,最后衍变成了中心化单点登录系统,也就是CAS的方案。

CAS(Central Authentication Service),中心认证服务,就是单点登录的某种实现方案。你可以把它理解为它是一个登录中转站,通过SSO站点,既解决了Cookie跨域的问题,同时还通过SSO服务端实现了登录验证的中心化。

这里的SSO指的是:SSO系统

它的设计流程是怎样的

老余:你能不能讲下它的大致实现思路,这说的也太虚头巴脑了,简直是听君一席话,如听一席话。
我:你别急呀,先看下它的官方流程图。


重定向到SSO

首先,用户想要访问系统A的页面1,自然会调用http://www.chezhe1.com的限制接口,(比如说用户信息等接口登录后才能访问)。

接下来 系统A 服务端一般会在拦截器【也可以是过滤器,AOP 啥的】中根据Cookie中的SessionId判断用户是否已登录。如果未登录,则重定向到SSO系统的登录页面,并且带上自己的回调地址,便于用户在SSO系统登录成功后返回。此时回调地址是:http://www.sso.com?url=www.chezhe1.com

这个回调地址大家应该都不会陌生吧,像那种异步接口或者微信授权、支付都会涉及到这块内容。不是很了解的下面会解释~
另外这个回调地址还必须是前端页面地址,主要用于回调后和当前系统建立会话。

此时如下图所示:


用户登录

  1. 在重定向到SSO登录页后,需要在页面加载时调用接口,根据SessionId判断当前用户在SSO系统下是否已登录。【注意这时候已经在 SSO 系统的域名下了,也就意味着此时Cookie中的domain已经变成了sso.com

为什么又要判断是否登录?因为在 CAS 这个方案中,只有在SSO系统中为登录状态才能表明用户已登录。

  1. 如果未登录,展现账号密码框,让用户输入后进行SSO系统的登录。登录成功后,SSO页面和SSO服务端建立起了会话。 此时流程图如下所示:


安全验证

老余:你这里有一个很大的漏洞你发现没有?
我:emm,我当然知道。

对于中心化系统,我们一般会分发对应的AppId,然后要求每个应用设置白名单域名。所以在这里我们还得验证AppId的有效性,白名单域名和回调地址域名是否匹配。否则有些人在回调地址上写个黄色网站那不是凉凉。


获取用户信息登录

  1. 在正常的系统中用户登录后,一般需要跳转到业务界面。但是在SSO系统登录后,需要跳转到原先的系统A,这个系统A地址怎么来?还记得重定向到SSO页面时带的回调地址吗?


通过这个回调地址,我们就能很轻易的在用户登录成功后,返回到原先的业务系统。

  1. 于是用户登录成功后根据回调地址,带上ticket,重定向回系统A,重定向地址为:http://www.chezhe1.com?ticket=123456a

  2. 接着根据ticket,从SSO服务端中获取Token。在此过程中,需要对ticket进行验证。

  3. 根据tokenSSO服务端中获取用户信息。在此过程中,需要对token进行验证。

  4. 获取用户信息后进行登录,至此系统A页面和系统A服务端建立起了会话,登录成功。

此时流程图如下所示:


别以为这么快就结束了哦,我这边提出几个问题,只有把这些想明白了,才算是真的清楚了。

  • 为什么需要 Ticket?

  • 验证 Ticket 需要验证哪些内容?

  • 为什么需要 Token?

  • 验证 Token 需要验证哪些内容?

  • 如果没有Token,我直接通过Ticket 获取用户信息是否可行?

为什么需要 Ticket

我们可以反着想,如果没有Ticket,我们该用哪种方式获取Token或者说用户信息?你又该怎么证明你已经登录成功?用Cookie吗,明显是不行的。

所以说,Ticket是一个凭证,是当前用户登录成功后的产物。没了它,你证明不了你自己。

验证 Ticket 需要验证哪些内容

  1. 签名:对于这种中心化系统,为了安全,绝大数接口请求都会有着验签机制,也就是验证这个数据是否被篡改。至于验签的具体实现,五花八门都有。

  2. 真实性:验签成功后拿到Ticket,需要验证Ticket是否是真实存在的,不能说随便造一个我就给你返回Token吧。

  3. 使用次数:为了安全性,Ticket只能使用一次,否则就报错,因为Ticket很多情况下是拼接在URL上的,肉眼可见。

  4. 有效期:另外则是Ticket的时效,超过一定时间内,这个Ticket会过期。比如微信授权的Code只有5分钟的有效期。

  5. ......

为什么需要 Token?

只有通过Token我们才能从SSO系统中获取用户信息,但是为什么需要Token呢?我直接通过Ticket获取用户信息不行吗?

答案当然是不行的,首先为了保证安全性Ticket只能使用一次,另外Ticket具有时效性。但这与某些系统的业务存在一定冲突。因此通过使用Token增加有效时间,同时保证重复使用。

验证 Token 需要验证哪些内容?

和验证 Ticket类似

  1. 签名 2. 真实性 3. 有效期

如果没有 Token,我直接通过 Ticket 获取用户信息是否可行?

这个内容其实上面已经给出答案了,从实现上是可行的,从设计上不应该,因为TicketToken的职责不一样,Ticket 是登录成功的票据,Token是获取用户信息的票据。

用户登录系统B流程

老余:系统A登录成功后,那系统B的流程呢?
我:那就更简单了。

比如说此时用户想要访问系统B,http://www.chezhe2.com的限制接口,系统B服务端一般会在拦截器【也可以是过滤器,AOP 啥的】中根据Cookie中的SessionId判断用户是否已登录。此时在系统B中该系统肯定未登录,于是重定向到SSO系统的登录页面,并且带上自己的回调地址,便于用户在SSO系统登录成功后返回。回调地址是:http://www.sso.com?url=www.chezhe2.com。

我们知道之前SSO页面已经与SSO服务端建立了会话,并且因为CookieSSO这个域名下是共享的,所以此时SSO系统会判断当前用户已登录。然后就是之前的那一套逻辑了。 此时流程图如下所示:


技术以外的事

老余:不错不错,理解的还可以。你发现这套系统里,做的最多的是什么,有什么技术之外的感悟没。说到这,老余叹了口气。

我:我懂,做的最多的就是验证了,验证真实性、有效性、白名单这些。明明一件很简单的事,最后搞的那么复杂。像现在银行里取钱一样,各种条条框框的限制。我有时候会在想,技术发展、思想变革对于人类文明毋庸置疑是有益的,但是对于我们人类真的是一件好事吗?如果我们人类全是机器人那样的思维是不是会更好点?


老余:我就随便一提,你咋巴拉巴拉了这么多。我只清楚一点,拥有七情六欲的人总是好过没有情感的机器人的。好了,干活去吧。

总结

这一篇内容就到这了,我们聊了下关于单点登录的 CAS 设计思路,其实CAS 往大了讲还能讲很多,可惜我的技术储备还不够,以后有机会补充。如果想理解的更深刻,也可以去看下微信授权流程,应该会有帮助。

最后还顺便提了点技术之外的事,记得有句话叫做:科学的尽头是哲学,我好像开始慢慢理解这句话的意思了。

作者:车辙cz
来源:juejin.cn/post/7196924295310262328

收起阅读 »

咱不吃亏,也不能过度自卫

我们公司人事小刘负责考勤统计。发完考勤表之后,有个员工找到他,说出勤少统计了一天。小刘一听,感觉自己有被指控的风险。他立刻严厉起来:“每天都来公司,不一定就算全勤。没打卡我是不统计的”。最后小刘一查,发现是自己统计错了。小刘反而更加强势了:“这种事情,你应该早...
继续阅读 »

我们公司人事小刘负责考勤统计。发完考勤表之后,有个员工找到他,说出勤少统计了一天。

小刘一听,感觉自己有被指控的风险。

他立刻严厉起来:“每天都来公司,不一定就算全勤。没打卡我是不统计的”。

最后小刘一查,发现是自己统计错了。

小刘反而更加强势了:“这种事情,你应该早点跟我反馈,而且多催着我确认。你自己的事情都不上心,扣个钱啥的只能自己兜着”

这就是明显的不愿意吃亏,即使自己错了,也不愿意让自己置于弱势。

你的反应,决定别人怎么对你。这种连言语的亏都不吃的人,并不会让别人敬畏,反而会让人厌恶,进而影响沟通

我还有一个同事老王。他是一个职场老人,性格嘻嘻哈哈,业务能力也很强。

以前同事小赵和老王合作的时候,小赵宁愿经两层人传话给老王,也不愿意和他直接沟通。

我当时感觉小赵不善于沟通。

后来,当我和老王合作的时候,才体会到小赵的痛苦。

因为,老王是一个什么亏都不吃的人,谁来找他理论,他就怼谁。

你告诉他有疏漏,他会极力掩盖问题,并且怒怼你愚昧无知。

就算你告诉他,说他家着火了。他首先说没有。你一指那不是烧着的吗?他回复,你懂个屁,你知道我几套房吗?我说的是我另一个家没着火。

有不少人,从不吃亏,无论什么情况,都不会让自己处于弱势。

这类人喜欢大呼小叫,你不小心踩他脚了,他会大喊:践踏我的尊严,和你拼了!

心理学讲,愤怒源于恐惧,因为他想逃避当前不利的局面

人总会遇到各种不公的待遇,或误会,或委屈。

遇到争议时,最好需要确认一下,排除自己的问题。

如果自己没错,那么比较好的做法就是:“我认为你说得不合理,首先……其次……最后……”。

不盲目服软,也不得理不饶人,全程平心静气,有理有据。这种人绝对人格魅力爆棚,让人敬佩。

最后,有时候过度强硬也是一种策略,可以很好地过滤和震慑一些不重要的事物。

作者:TF男孩
来源:juejin.cn/post/7196678344573173816

收起阅读 »

我竟然完美地用js实现默认的文本框粘贴事件

web
前言:本文实际是用js移动控制光标的位置!解决了网上没有可靠教程的现状废话连篇默认情况对一个文本框粘贴,应该会有这样的功能:粘贴文本后,光标不会回到所有文本的最后位置,而是在粘贴的文本之后将选中的文字替换成粘贴的文本但是由于需求,我们需要拦截粘贴的事件,对剪贴...
继续阅读 »

前言:本文实际是用js移动控制光标的位置!解决了网上没有可靠教程的现状

废话连篇

默认情况对一个文本框粘贴,应该会有这样的功能:

  1. 粘贴文本后,光标不会回到所有文本的最后位置,而是在粘贴的文本之后

  2. 将选中的文字替换成粘贴的文本

但是由于需求,我们需要拦截粘贴的事件,对剪贴板的文字进行过滤,这时候粘贴的功能都得自己实现了,而一旦自己实现,上面2个功能就不见了,我们就需要还原它。

面对这样的需求,我们肯定要控制移动光标,可是现在的网上环境真的是惨,千篇一律的没用代码...于是我就发表了这篇文章。

先上代码

    <textarea id="text" style="width: 996px; height: 423px;"></textarea>
   <script>
       // 监听输入框粘贴事件
       document.getElementById('text').addEventListener('paste', function (e) {
           e.preventDefault();
           let clipboardData = e.clipboardData.getData('text');
           // 这里写你对剪贴板的私货
           let tc = document.querySelector("#text");
           tc.focus();
           const start = (tc.value.substring(0,tc.selectionStart)+clipboardData).length;
           if(tc.selectionStart != tc.selectionEnd){
               tc.value = tc.value.substring(0,tc.selectionStart)+clipboardData+tc.value.substring(tc.selectionEnd)
          }else{
               tc.value = tc.value.substring(0,tc.selectionStart)+clipboardData+tc.value.substring(tc.selectionStart);
          }
           
           // 重新设置光标位置
           tc.selectionEnd =tc.selectionStart = start
      });
   </script>

怎么理解上述两个功能?
第一个解释:
比如说现在文本框有:

染念真的很生气

如果我们现在在真的后面粘贴不要,变成

染念真的不要很生气|

拦截后的光标是在生气后面,但是我们经常使用发现,光标应该出现在不要的后面吧!
就像这样:

染念真的不要|很生气

第2个解释:

染念真的不要很生气

我们全选真的的同时粘贴求你,拦截后会变成

染念真的求你不要很生气|

但默认应该是:

染念求你|不要很生气

代码分析

针对第2个问题,我们应该先要获取默认的光标位置在何处,tc.selectionStart是获取光标开始位置,tc.selectionEnd是获取光标结束位置。
为什么这里我写了一个判断呢?因为默认时候,我们没有选中一块区域,就是把光标人为移动到某个位置(读到这里,光标在位置后面,现在人为移动到就是前面,这个例子可以理解不?),这个时候两个值是相等的。

233|333
^--^
1--4
tc.selectionEnd=4,tc.selectionStart = 4

如果相等,说明就是简单的定位,tc.value = tc.value.substring(0,tc.selectionStart)+clipboardData+tc.value.substring(tc.selectionStart);,tc.value.substring(0,tc.selectionStart)获取光标前的内容,tc.value.substring(tc.selectionStart)是光标后的内容。
如果不相等,说明我们选中了一个区域(光标选中一块区域说明我们选中了一个区域),代码只需要在最后获取光标后的内容这的索引改成tc.selectionEnd

|233333|
^------^
1------7
tc.selectionEnd=7,tc.selectionStart = 1

在获取光标位置之前,我们应该先使用tc.focus();聚焦,使得光标回到文本框的默认位置(最后),这样才能获得位置。
针对第1个问题,我们就要把光标移动到粘贴的文本之后,我们需要计算位置。
获得这个位置,一定要在tc.value重新赋值之前,因为这样的索引都没有改动。
const start = (tc.value.substring(0,tc.selectionStart)+clipboardData).length;这个代码和上面解释重复,很简单,我就不解释了。
最后处理完了,重新设置光标位置,tc.selectionEnd =tc.selectionStart = start,一定让selectionEnd和selectionStart相同,不然选中一个区域了。

如果我们在value重新赋值之后获取(tc.value.substr(0,tc.selectionStart)+clipboardData).length,大家注意到没,我们操作的是tc.value,value已经变了,这里的重新定位光标开始已经没有任何意义了!

作者:染念
来源:dyedd.cn/943.html

收起阅读 »

闭包用多了会造成内存泄露 ?

web
闭包,是JS中的一大难点;网上有很多关于闭包会造成内存泄露的描述,说闭包会使其中的变量的值始终保持在内存中,一般都不太推荐使用闭包而项目中确实有很多使用闭包的场景,比如函数的节流与防抖那么闭包用多了,会造成内存泄露吗?场景思考以下案例: A 页面引入了一个 d...
继续阅读 »

闭包,是JS中的一大难点;网上有很多关于闭包会造成内存泄露的描述,说闭包会使其中的变量的值始终保持在内存中,一般都不太推荐使用闭包

而项目中确实有很多使用闭包的场景,比如函数的节流与防抖

那么闭包用多了,会造成内存泄露吗?

场景思考

以下案例: A 页面引入了一个 debounce 防抖函数,跳转到 B 页面后,该防抖函数中闭包所占的内存会被 gc 回收吗?

该案例中,通过变异版的防抖函数来演示闭包的内存回收,此函数中引用了一个内存很大的对象 info(42M的内存),便于明显地对比内存的前后变化

注:可以使用 Chrome 的 Memory 工具查看页面的内存大小:


场景步骤:

1) util.js 中定义了 debounce 防抖函数

// util.js`
let info = {
 arr: new Array(10 * 1024 * 1024).fill(1),
 timer: null
};
export const debounce = (fn, time) => {
 return function (...args) {
   info.timer && clearTimeout(info.timer);
   info.timer = setTimeout(() => {
     fn.apply(this, args);
  }, time);
};
};

2) A 页面中引入并使用该防抖函数

import { debounce } from './util';
mounted() {
   this.debounceFn = debounce(() => {
     console.log('1');
  }, 1000)
}
  • 抓取 A 页面内存: 57.1M


3) 从 A 页面跳转到 B 页面,B 页面中没有引入该 debounce 函数

问题: 从 A 跳转到 B 后,该函数所占的内存会被释放掉吗?

  • 此时,抓取 B 页面内存: 58.1M


  • 刷新 B 页面,该页面的原始内存为: 16.1M


结论: 前后对比发现,从 A 跳转到 B 后,B 页面内存增大了 42M,证明该防抖函数所占的内存没有被释放掉,即造成了内存泄露

为什么会这样呢? 按理说跳转 B 页面后,A 页面的组件都被销毁掉了,那么 A 页面所占的内存应该都被释放掉了啊😕

我们继续对比测试

4) 如果把 info 对象放到 debounce 函数内部,从 A 跳转到 B 后,该防抖函数所占的内存会被释放掉吗?

// util.js`
export const debounce = (fn, time) => {
let info = {
 arr: new Array(10 * 1024 * 1024).fill(1),
 timer: null
};
 return function (...args) {
   info.timer && clearTimeout(info.timer);
   info.timer = setTimeout(() => {
     fn.apply(this, args);
  }, time);
};
};

按照步骤 4 的操作,重新从 A 跳转到 B 后,B 页面抓取内存为16.1M,证明该函数所占的内存被释放掉了

为什么只是改变了 info 的位置,会引起内存的前后变化?

要搞懂这个问题,需要理解闭包的内存回收机制

闭包简介

闭包:一个函数内部有外部变量的引用,比如函数嵌套函数时,内层函数引用了外层函数作用域下的变量,就形成了闭包。最常见的场景为:函数作为一个函数的参数,或函数作为一个函数的返回值时

闭包示例:

function fn() {
let num = 1;
return function f1() {
  console.log(num);
};
}
let a = fn();
a();

上面代码中,a 引用了 fn 函数返回的 f1 函数,f1 函数中引入了内部变量 num,导致变量 num 滞留在内存中

打断点调试一下


展开函数 f 的 Scope(作用域的意思)选项,会发现有 Local 局部作用域、Closure 闭包、Global 全局作用域等值,展开 Closure,会发现该闭包被访问的变量是 num,包含 num 的函数为 fn

总结来说,函数 f 的作用域中,访问到了fn 函数中的 num 这个局部变量,从而形成了闭包

所以,如果真正理解好闭包,需要先了解闭包的内存引用,并且要先搞明白这几个知识点:

  • 函数作用域链

  • 执行上下文

  • 变量对象、活动对象

函数的内存表示

先从最简单的代码入手,看下变量是如何在内存中定义的

let a = '小马哥'

这样一段代码,在内存里表示如下


在全局环境 window 下,定义了一个变量 a,并给 a 赋值了一个字符串,箭头表示引用

再定义一个函数

let a = '小马哥'
function fn() {
 let num = 1
}

内存结构如下:


特别注意的是,fn 函数中有一个 [[scopes]] 属性,表示该函数的作用域链,该函数作用域指向全局作用域(浏览器环境就是 window),函数的作用域是理解闭包的关键点之一

请谨记:函数的作用域链是在创建时就确定了,JS 引擎会创建函数时,在该对象上添加一个名叫作用域链的属性,该属性包含着当前函数的作用域以及父作用域,一直到全局作用域

函数在执行时,JS 引擎会创建执行上下文,该执行上下文会包含函数的作用域链(上图中红色的线),其次包含函数内部定义的变量、参数等。在执行时,会首先查找当前作用域下的变量,如果找不到,就会沿着作用域链中查找,一直到全局作用域

垃圾回收机制浅析

现在各大浏览器通常用采用的垃圾回收有两种方法:标记清除、引用计数

这里重点介绍 "引用计数"(reference counting),JS 引擎有一张"引用表",保存了内存里面所有的资源(通常是各种值)的引用次数。如果一个值的引用次数是0,就表示这个值不再用到了,因此可以将这块内存释放


上图中,左下角的两个值,没有任何引用,所以可以释放

如果一个值不再需要了,引用数却不为0,垃圾回收机制无法释放这块内存,从而导致内存泄漏

判断一个对象是否会被垃圾回收的标准: 从全局对象 window 开始,顺着引用表能找到的都不是内存垃圾,不会被回收掉。只有那些找不到的对象才是内存垃圾,才会在适当的时机被 gc 回收

分析内存泄露的原因

回到最开始的场景,当 info 在 debounce 函数外部时,为什么会造成内存泄露?

进行断点调试


展开 debounce 函数的 Scope选项,发现有两个 Closure 闭包对象,第一个 Closure 中包含了 info 对象,第二个 Closure 闭包对象,属于 util.js 这个模块

内存结构如下:


当从 A 页面切换到 B 页面时,A 页面被销毁,只是销毁了 debounce 函数当前的作用域,但是 util.js 这个模块的闭包却没有被销毁,从 window 对象上沿着引用表依然可以查找到 info 对象,最终造成了内存泄露


当 info 在 debounce 函数内部时,进行断点调试


其内存结构如下:


当从 A 页面切换到 B 页面时,A 页面被销毁,同时会销毁 debounce 函数当前的作用域,从 window 对象上沿着引用表查找不到 info 对象,info 对象会被 gc 回收


闭包内存的释放方式

1、手动释放(需要避免的情况)

如果将闭包引用的变量定义在模块中,这种会造成内存泄露,需要手动释放,如下所示,其他模块需要调用 clearInfo 方法,来释放 info 对象

可以说这种闭包的写法是错误的 (不推荐), 因为开发者需要非常小心,否则稍有不慎就会造成内存泄露,我们总是希望可以通过 gc 自动回收,避免人为干涉

let info = {
 arr: new Array(10 * 1024 * 1024).fill(1),
 timer: null
};
export const debounce = (fn, time) => {
 return function (...args) {
   info.timer && clearTimeout(info.timer);
   info.timer = setTimeout(() => {
     fn.apply(this, args);
  }, time);
};
};
export const clearInfo = () => {
 info = null;
};

2、自动释放(大多数的场景)

闭包引用的变量定义在函数中,这样随着外部引用的销毁,该闭包就会被 gc 自动回收 (推荐),无需人工干涉

export const debounce = (fn, time) => {
 let info = {
   arr: new Array(10 * 1024 * 1024).fill(1),
   timer: null
};
 return function (...args) {
   info.timer && clearTimeout(info.timer);
   info.timer = setTimeout(() => {
     fn.apply(this, args);
  }, time);
};
};

结论

综上所述,项目中大量使用闭包,并不会造成内存泄漏,除非是错误的写法

绝大多数情况,只要引用闭包的函数被正常销毁,闭包所占的内存都会被 gc 自动回收。特别是现在 SPA 项目的盛行,用户在切换页面时,老页面的组件会被框架自动清理,所以我们可以放心大胆的使用闭包,无需多虑

理解了闭包的内存回收机制,才算彻底搞懂了闭包。以上关于闭包的理解,如果有误,欢迎指正 🌹

参考链接:
浏览器是怎么看闭包的。
JavaScript 内存泄漏教程
JavaScript闭包(内存泄漏、溢出以及内存回收),超直白解析

作者:海阔_天空
来源:juejin.cn/post/7196636673694285882

收起阅读 »

人保寿险要求全员背诵董事长罗熹金句,央媒痛批其“谄媚”

最近,中国人保寿险公司品牌宣传部门,给人保集团董事长罗熹惹出舆情。去年12月2日,人保寿险官方公众号“中国人保寿险”发布文章《首季峰启动会上,罗熹董事长这些金句值得收藏!》。文中提到,“直达人心,催人奋进 董事长金句来了!”近日,该公司又专门发《通知》,要求公...
继续阅读 »

最近,中国人保寿险公司品牌宣传部门,给人保集团董事长罗熹惹出舆情。

去年12月2日,人保寿险官方公众号“中国人保寿险”发布文章《首季峰启动会上,罗熹董事长这些金句值得收藏!》。文中提到,“直达人心,催人奋进 董事长金句来了!”


近日,该公司又专门发《通知》,要求公司总、省、地市、县支各级机构全体干部员工,“学习、熟读、并背诵董事长在首季峰启动会上传达的金句集锦。”


《通知》称,总公司各部门主要负责人、各级机构一把手要充分发挥示范带动作用,带头讲金句、用金句,通过集中学习、个人自学、背诵打卡等多种方式,确保全体内勤人员将金句内容牢记于心、付诸于行……

《通知》中还提到,要在今年2月10日前,完成全员闭卷通关及考试,并对考试成绩进行汇总。“纸质试卷需妥善保管,以备检查。”

今年1月29日,“中国人保寿险”公众号推送了《以考促学,一套题带你牢记“首季峰”金句》的文章。文内的多道填空题,均是罗董事长的致辞“金句”。


该事件引发关注后,人保寿险删除了上述这两篇公众号文章。

此外,有媒体报道称,人保寿险2月4日深夜发布的一份内部邮件显示,1月30日下发的文件《关于开展“学习罗董金句,激扬奋进力量”学习活动的通知》已被废止。

被卷入“学金句”旋涡的罗熹履新人保集团董事长时间并不长。去年11月21日,银保监会发布消息称,核准了罗熹新职务。

公开资料显示,罗熹出生于1960年12月,毕业于中国人民银行研究生部,经济学硕士学位,高级经济师,1977年8月参加工作以来,曾在多家银行、保险公司工作。

2月6日,有自媒体称,自己因2月4日发布《如此谄媚领导?一央企发文要求全体员工学习、熟读、背诵董事长“金句”》文章,收到人保寿险的撤稿函。


有网友评论称,作为央企的人保寿险公司,发文要求全体员工学习、熟读、背诵董事长罗熹的“金句”,而且还有相应学习活动的测试试题,如此形式主义是否合适?是否有“谄媚领导”之嫌?

中新社旗下的中新经纬2月6日晚间发表评论称,这种“金句学习”的企业文化,更像是一种职场“洗脑”,加深了外界对寿险行业的不良观感。

“强制员工背诵董事长金句,看似是让员工领会管理者的经营思路和企业发展战略,实则是下属谄媚上级之举,容易使企业员工陷入盲目个人崇拜。”评论称,作为一家企业的领导者,更应该时刻保持清醒的头脑,及时制止下属的变相吹捧。

评论指出,对保险公司来说,与其将董事长金句背会,不如将每一张一张保单做好,每一笔业务做到位,这样方能赢得更多客户信任。

作者:一见财经
来源:zhuanlan.zhihu.com/p/604080917

收起阅读 »

一杯咖啡的时间☕️,搞懂 API 和 RESTful API!

☀️ 前言API和RESTful API 是每个程序员都应该了解并掌握的基本知识,我们在开发过程中设计 API 的时候也应该至少要满足一些最基本的要求。如果你还不了解什么是API或你没有了解RESTful API,你可以选择花5分钟时间看下去,我会最通俗易懂的...
继续阅读 »

☀️ 前言

  • APIRESTful API 是每个程序员都应该了解并掌握的基本知识,我们在开发过程中设计 API 的时候也应该至少要满足一些最基本的要求。

  • 如果你还不了解什么是API或你没有了解RESTful API,你可以选择花5分钟时间看下去,我会最通俗易懂的帮你普及这一切。

❓ 什么是 API

  • 举个简单的例子你就会明白:

    • 早在2000年我们还在用小灵通的时代,网上购票已经慢慢兴起,但是绝大部分人出行还是通过电话查询航班来去选择购票,我们首先需要打电话到附近的站台根据时间询问航班或车次,得到结果后再去到对应站台进行购票。


  • 随着时代的飞速发展和智能手机的普及,各种旅游App也映入眼帘,大家也学会了如何在App上进行购票

  • 这时候我们买票就没有以前那么麻烦了,在App输入你的起点终点后,会展现所有符合条件的车次,航班,不仅仅只有时间、座位,还有航空公司、预计时间等等等等详细信息一目了然,你只需要根据你的需求购买即可。


  • 连接是一件很棒的事情,在我们现在的生活中,我们可以很轻松的通过App进行购物、阅读、直播,我们以前所未有的方式和世界与人们相连接。

  • 那这些是怎么做到的?为什么一个App能够这么便利?这些资料为什么会可以从A到达B,为什么我们只需要动动手指就可以达到这一切?

  • 而这个桥梁,这个互联网世界的无名英雄就是APIAPI,全名 Application Programming Interface (应用程式界面),简单来说,是品牌开发的一种接口,让第三方可以额外开发、应用在自身的产品上的系统沟通界面。

  • 简单来说,你可以把它比喻成古人的鸽子,通过飞鸽传书来传达你的需求,而接收方再把回应通过鸽子传达给你。

  • 再说回上面举的例子。

    • 旧时代我们需要知道航班的信息,我们就需要一个信差,而这个电话员就是这个信差,也就是我们说的 API,他传达你的要求到系统,而站台就是这个系统,比如告诉它查询明天飞往广州的飞机,那么他就会得出结果,由电话员传递给你。

    • 而现在我们需要购买机票等,只需要通过购票系统选择日期,城市,舱位等,他会从不同的航空公司网站汇集资料,而汇集资料的手段就是通过API和航空公司互动。

  • 我们现在知道是API让我们使用这些旅游 App,那么这个道理也一样适用于生活中任何应用程序、资料和装置之间的互动,都有各自的API进行连接。

❓ 什么是 RESTful API

  • 在互联网并没有完全流行的初期,移动端也没有那么盛行,页面请求和并发量也不高,那时候人们对接口(API)的要求没那么高。

  • 当初的 web 应用程序主要是在服务器端实现的,因此需要使用复杂的协议来操作和传输数据。然而,随着移动端设备的普及,需要在移动端也能够访问 web 应用程序,而客户端和服务端就需要接口进行通信,但接口的规范性就又成了一个问题。


  • 所以一套简化开发、结构清晰、符合标准、易于理解、易于扩展让大部分人都能够理解接受的接口风格就显得越来越重要,而RESTful风格的接口(RESTful API)刚好有以上特点,就逐渐应运而生。

REST

  • REST,全名 Representational State Transfer(表现层状态转移),他是一种设计风格,一种软件架构风格,而不是标准,只是提供了一组设计原则和约束条件

RESTful

  • RESTful 只是转为形容詞,就像那么 RESTful API 就是满足 REST风格的,以此规范设计的 API

RESTful API

  • 我们常见的 API 一般都长这样子:


  • RESTful 风格的 API 却长这样子:


🔘 六大原则

  • Roy FieldingHTTP 协议的主要设计者之一,他在论文中阐述了 REST 架构的概念并给出了 REST 架构的六个限制条件,也就是六大原则

Uniform Interface(统一接口)
  • 就像我们上面两幅图看到的 API,这是最直观的特征,是 REST 架构的核心,统一的接口对于 RESTful 服务非常重要。客户端只需要关注实现接口就可以,接口的可读性加强,使用人员方便调用

  • RESTful API通过 URL定位资源,并通过

    HTTP方法操作该资源,对资源的操作包括获取、创建、修改和删除,这些操作正好对应 HTTP 协议提供的GETPOSTPUTDELETE方法。

    • GET:获取资源信息。

    • POST:创建一个新资源。

    • PUT:更新已有的资源。

    • DELETE:删除已有的资源。

  • 在一个完全遵循 RESTful 的团队里,后端只需要告诉前端 /users 这个 API,前端就应该知道:

    • 获取所有用户:GET /users

    • 获取特定用户:GET /users/{id}

    • 创建用户:POST /users

    • 更新用户:PUT /users/{id}

    • 删除用户:DELETE /users/{id}

  • API 数量非常多,系统非常复杂时,RESTful 的好处会越来越明显。理解系统时,可以直接围绕一系列资源来理解和记忆。

Client-Server(客户端和服务端分离)
  • 它意味着客户端和服务器是独立的、可以分离的

  • 客户端是负责请求和处理数据的组件,服务器是负责存储数据处理请求的组件。

  • 这两个组件之间通过一组约定来协作,以便客户端能够获取所需的数据。

Statelessness(无状态)
  • 它指的是每个请求都是独立的没有前后关系。服务器不保存客户端的状态信息,并且每个请求都必须包含所有所需的信息。

  • 这样做的好处是可以使每个请求变得简单容易理解处理,并且可以更容易地扩展和维护

  • 例如,假设你在登录一个网站,你需要在登录界面输入用户名和密码通过接口获取到了 token 。接下来的所有请求都需要携带上这个 token 而不是系统在第一次登录成功之后记录了你的状态。

Cacheability(可缓存)
  • 客户端和服务端可以协商缓存内容,通过设置 HTTP 状态码,服务器可以告诉客户端这个数据是否可以被缓存。

  • 例如,一个 HTTP 响应头中包含一个 Cache-Control 字段,用于告诉客户端该数据可以缓存多长时间。这样可以提高数据传输的效率,从而降低网络带宽的开销,加速数据的访问速度。

Layered System(分层)
  • 客户端不应该关心请求经过了多少中间层,只需要关心请求的结果。

  • 架构的系统可以分为多个层次,每一层独立完成自己的任务。这样的架构结构使得系统更容易维护,并且允许独立替换不同层次。

  • 例如,数据库存储层可以独立于其他层,在不影响其他层的情况下进行替换或扩展。

Code on Demand(可选的代码请求)
  • 它提倡服务器可以将客户端代码下载到客户端并执行。这样,客户端可以根据服务器发送的代码来扩展它的功能。

  • 这个限制可以使客户端代码变得更加灵活,并且可以通过服务器提供的代码来解决问题,而不必再等待下一个版本。

  • Code on Demand 是可选的,但它可以使 RESTful API 变得更加灵活和可扩展。

🔥 RESTful API 设计规范

  • 说了这么多的理论,那我们该如何去设计一个最简单 RESTful 风格的 API 呢?

HTTP 方法
  • HTTP 设计了很多动词,来标识不同的操作,不同的HTTP请求方法有各自的含义,就像上面所展示的,RESTful API 应该使用 HTTP 方法(如 GET、POST、PUTDELETE)来描述操作。

版本控制
URL 明确标识资源
  • API 应该使用简洁明了的 URL 来标识资源,并且在同一个资源上使用不同的 HTTP 方法来执行不同的操作。

  • 这样的设计使得客户端在无需任何额外信息的情况下就可以找到所需的资源。

  • 不规范的的 URL,形式千奇百怪,不同的开发者还需要了解文档才能调用。

  • 规范后的 RESTful 风格的 URL,形式固定,可读性强,根据名词和 HTTP 动词就可以操作这些资源。


  • 给大家一个小 tips,如果你遇到了实在想不到的 URL ,你可以参考github开放平台 ,这里面有很多很规范的 URL 设计。

HTTP 状态码
  • HTTP状态码是 RESTful API设计的重要一环,是表示 API请求的状态,用于告知客户端是否成功请求并处理数据。常用的 HTTP状态码有:

    • 200 OK:请求成功,表示获得了请求的数据

    • 201 Created:请求成功,表示创建了一个新的资源

    • 204 No Content:请求成功,表示操作成功,但没有返回数据

    • 400 Bad Request:请求失败,表示请求格式不正确或缺少必要参数

    • 401 Unauthorized:请求失败,表示认证失败或缺少授权

    • 403 Forbidden:请求失败,表示没有访问权限

    • 404 Not Found:请求失败,表示请求的资源不存在

    • 500 Internal Server Error:请求失败,表示服务器内部错误

统一返回数据格式
  • 常用的返回数据格式有 JSONXML

  • JSON 是现在比较流行的数据格式,它是简单、轻量、易于解析,并且它有很好的可读性。

  • XML 也是一种常用的数据格式,它的优点是比较灵活,并且支持各种数据类型。

合格美观的 API 文档
  • 项目开发离不开前后端分离,离不开 API,当然也就离不开 API 文档,但是文档的编写又成为程序员觉得麻烦事之一,甚至我还看到有公司的的 API 文档是用 Word 文档手敲的。

  • 市面上有很多可以管理 API 的软件,每个人都有自己的选择,我给大家推荐一款 API 管理神器 Apifox,可以一键生成 API 文档。

  • 不需要你过多的操作,只需要你在可视化的页面添加你的 API 即可生成,现在也支持了多种导航模式亮暗色模式顶部自定义 Icon 、文案可跳转到你的官网等地址


  • 对于独立开发者和团队来说都是一大利好福音,本文就不做过多介绍,感兴趣的可以去试试~

👋🏻 写在最后

  • 总的来说 RESTful 风格的 API 固然很好很规范,但大多数互联网公司并没有按照或者完全按照其规则来设计,因为 REST 是一种风格,而不是一种约束或规则,过于理想的 RESTful API 会付出太多的成本。

  • 如果您正在考虑使用 RESTful API,请确保它符合您的业务需求。例如,如果您的项目需要实现复杂的数据交互,您可能需要考虑使用其他 API 设计方法。

  • 因此,请确保在选择 API 设计方法时充分考虑您的业务需求。此外,您还需要确保 RESTful API 与您的系统架构和技术栈相兼容。通过这些考虑,您可以确保 RESTful API 的正确使用,并且可以实现更高效和可靠的 API

  • 长期来看,API 设计也不只是后端的工作,而是一个产品团队在产品设计上的协调工作,应该整个团队参与。

  • 这次简单分享了 APIRESTful API,在实际运用中,并不是一定要使用这种规范,但是有 RESTful 标准可以参考,是十分有必要的,希望对大家有帮助。

作者:快跑啊小卢_
来源:juejin.cn/post/7196570893152616506

收起阅读 »

我当面试官的经历总结

背景工作之余,负责过公司前端岗位的一些技术面试,一直在想,能不能对这个经历做一个总结,遂有了这篇文章。文章主要内容如下:我的面试风格面试者——简历格式与内容面试者——简历亮点面试者——准备面试面试官——面试前准备面试官——面试中面试官——面试结果评价总结我的面...
继续阅读 »

背景

工作之余,负责过公司前端岗位的一些技术面试,一直在想,能不能对这个经历做一个总结,遂有了这篇文章。

文章主要内容如下:

  1. 我的面试风格

  2. 面试者——简历格式与内容

  3. 面试者——简历亮点

  4. 面试者——准备面试

  5. 面试官——面试前准备

  6. 面试官——面试中

  7. 面试官——面试结果评价

  8. 总结

我的面试风格

我非常讨厌问一些稀奇古怪的问题,也不喜欢遇到任何面试者,都准备几个相同的技术问题。我的面试风格可以总结为以下几点:

  1. 根据简历内容,提炼和简历深度关联的技术场景

  2. 将提炼的技术场景分解成问题,可以是一个问题,也可以是多个问题,可以困难,也可以容易

  3. 和面试者进行友好交流,感受面试者的各种反馈,尊重面试者

  4. 面试是一个互相学习的过程

以上总结可以用如下思维导图概括:


面试者——简历格式与内容

我们看一张两个简历的对比图,如下所示:


上图中的两个简历,代表了大多数人的简历样子。大家可以自行感觉下,哪一个简历更好些。

我对简历格式与内容,有如下两点看法:

  1. 我更喜欢图中简历 2 的格式,但简历格式不会影响我的面试评价

  2. 简历内容是核心,我会根据简历内容来决定要不要面试和如何面试

所以对于面试者来说,一定要写好简历内容。

面试者——简历亮点

究竟什么样的内容算是亮点呢?对此,我罗列了简历亮点的思维导图,如下图所示:


简洁阐述下简历亮点思维导图:

  1. 技术丰富:有深度,比如你在 node 方面做了 ssr 、微服务和一些底层工具等;有广度,比如你实践过 pch5 、小程序、桌面端、ssrnode 、微前端、低代码等

  2. 项目:比如你深度参与或者主导低代码平台项目建设,该项目非常复杂,在建设过程中,做了很多技术等方面的提升和创新,产生了很好的效果

  3. 博客/开源:比如你写的博客文章质量高,有自己独特和深入的见解;你在开源方面做了很多贡献,提了一些好的 pr ,有自己的开源作品

  4. 公司知名:这个好理解,比如你在头部互联网,独角兽等公司工作过

  5. 其他:学历和工作年限,算是门槛,合适也是亮点

面试者要善于把自己的亮点展示在简历上,这对于应聘心怡公司来说,是非常重要的事情。

面试者——准备面试

面试者在准备面试阶段,应当做好以下 5 点:

  1. 写好简历内容,这个是重中之重

  2. 整理好自我介绍,控制好时间,做到言简意赅,把重点、亮点突出

  3. 确定好回答面试官提问的基本方式,保持统一的回答方式

  4. 根据简历内容,自己对自己做一次面试,或者找朋友模拟面试官,面试自己

  5. 找出不足,进行优化

面试者可以对写好的简历,用思维导图等工具,对内容进行分解,如下图所示:


在分解完成后,我们将相同点进行归纳,然后对多次提及,重复提及,着重提及的归纳进行重点复习和梳理。

这里用上图举 2 个归纳例子说明下:

我的技术栈中提及 pnpm yarn , 其涉及到的知识点,有以下:

  1. 包管理器选型, npm yarn pnpm 三者的区别

  2. monorepo 设计

我的重要功能提及商详页,其涉及到的知识点,有以下:

  1. 性能优化

  2. wap 端的常见问题,如 1px 问题、滚动穿透、响应式、终端适配

做好面试准备,会让你在面试过程中,胸有成竹,运筹帷幄。

面试官——面试前准备

主要有以下四个步骤:

  1. 看简历:作为面试官,在面试前,要认真看面试者的简历,这是对面试者的尊重

  2. 找亮点:这块参考上文提到的面试者亮点

  3. 定场景:根据简历内容和亮点,确定深度关联的技术场景

  4. 提问题:将确定的技术场景分解成问题,可以是一个问题,也可以是多个问题,可以困难,也可以容易

我认为面试前准备是面试官最重要的流程,这个做好了,剩下的就很容易做了。

面试官——面试中

整个过程的主线如下:

  1. 官方开头:比如打招呼、面试者自我介绍

  2. 重点过程:这个过程主要有两个事情:

第一个事情:按照上文 面试前准备 的内容来和面试者进行沟通交流,衡量面试者的回答和所写简历内容两者之间的联系 第二个事情:对于有疑惑的联系,要二次验证,这个举个例子

比如面试者简历上写,自研组件库。我问他按需加载是怎么实现的,他的回答会有下面两种情况

第一种情况:回答的很好,这个时候我会再讨论一个按需加载相关的小问题,如果回答还是很流畅。那很好,这个就是面试亮点

第二种情况:回答的很差,那我会怀疑自研组件库是不是他用心做的事情。因为他有可能是 fork 一个开源组件库,然后改改,然后就没然后了。这个时候,我倾向于直接和他沟通,比如问他在自研组件库上花了多少时间,是不是随便搞的。在回答很差的前置条件下,面试者大都会说实情。这样我就能掌握正确的信息,避免误解。

  1. 官方结尾:上家辞职原因、为什么选择来我司、定居情况、回答面试者提的各种问题

面试官——面试结果评价

结果无非就是失败和成功,绝大多数的面试结果评价都是客观公正的,剩下的少数都是一些特殊情况,遇到这种,那就是运气不好了。

总结

以上是我作为面试官经历的一次总结,虽然面试次数不多,但依然值得我为此写一个总结,这是一份宝贵的面经。

作者:码上有你
来源:juejin.cn/post/7195770700107399228

收起阅读 »

字节前端监控实践

简述Slardar 前端监控自18年底开始建设以来,从仅仅作为 Sentry 的替代,经历了一系列迭代和发展,目前做为一个监控解决方案,已经应用到抖音、西瓜、今日头条等众多业务线。据21年下旬统计,Slardar 前端监控(Web + Hybrd) 工作日晚间...
继续阅读 »

简述

Slardar 前端监控自18年底开始建设以来,从仅仅作为 Sentry 的替代,经历了一系列迭代和发展,目前做为一个监控解决方案,已经应用到抖音、西瓜、今日头条等众多业务线。

据21年下旬统计,Slardar 前端监控(Web + Hybrd) 工作日晚间峰值 qps 300w+,日均处理数据超过千亿条。

image.png 本文,我将针对在这一系列的发展过程中,字节内部监控设计和迭代遇到的落地细节设计问题,管中窥豹,向大家介绍我们团队所思考和使用的解决方案。

他们主要围绕着前端监控体系建设的一些关键问题展开。也许大家对他们的原理早已身经百战见得多了,不过当实际要落地实现的时候,还是有许多细节可以再进一步琢磨的。

image.png

如何做好 JS 异常监控

JS 异常监控本质并不复杂,浏览器早已提供了全局捕获异常的方案。

window.addEventListenr('error', (err) => {
   report(normalize(err))
});

window.addEventListenr('unhandledrejection', (rejection) => {
   report(normalize(rejection))
});
复制代码

但捕获到错误仅仅只是相关工作的第一步。在我看来,JS 异常监控的目标是:

  1. 开发者迅速感知到 JS 异常发生

  2. 通过监控平台迅速定位问题

  3. 开发者能够高效的处理问题,并统计,追踪问题的处理进度

在异常捕获之外,还包括堆栈的反解与聚合,处理人分配和报警这几个方面。

image.png

堆栈反解: Sourcemap

大家都知道 Script 脚本在浏览器中都是以明文传输并执行,现代前端开发方案为了节省体积,减少网络请求数,不暴露业务逻辑,或从另一种语言编译成 JS。都会选择将代码进行混淆和压缩。在优化性能,提升用户体验的同时,也为异常的处理带来了麻烦。

在本地开发时,我们通常可以清楚的看到报错的源代码堆栈,从而快速定位到原始报错位置。而线上的代码经过压缩,可读性已经变得非常糟糕,上报的堆栈很难对应到原始的代码中。 Sourcemap 正是用来解决这个问题的。

简单来说,Sourcemap 维护了混淆后的代码行列到原代码行列的映射关系,我们输入混淆后的行列号,就能够获得对应的原始代码的行列号,结合源代码文件便可定位到真实的报错位置。

Sourcemap 的解析和反解析过程涉及到 VLQ 编码,它是一种将代码映射关系进一步压缩为类base64编码的优化手段。

image.png 在实际应用中,我们可以把它直接当成黑盒,因为业界已经为我们提供了方便的解析工具。下面是一个利用 mozila 的 sourcemap 库进行反解的例子。

image.png

以上代码执行后通常会得到这样的结果,实际上我们的在线反解服务也就是这样实现的

image.png

当然,我们不可能在每次异常发生后,才去生成 sourcemap,在本地或上传到线上进行反解。这样的效率太低,定位问题也太慢。另一个方案是利用 sourcemappingURL 来制定sourcemap 存放的位置,但这样等于将页面逻辑直接暴露给了网站使用者。对于具有一定规模和保密性的项目,这肯定是不能接受的。

 //# sourceMappingURL=http://example.com/path/hello.js.map
复制代码

为了解决这个问题,一个自然的方案便是利用各种打包插件或二进制工具,在构建过程中将生成的 sourcemap 直接上传到后端。Sentry 就提供了类似的工具,而字节内部也是使用相似的方案。

image.png

image.png 通过如上方案,我们能够让用户在发版构建时就可以完成 sourcemap 的上传工作,而异常发生后,错误可以自动完成解析。不需要用户再操心反解相关的工作了。

堆栈聚合策略

当代码被成功反解后,用户已经可以看到这一条错误的线上和原始代码了,但接下来遇到的问题则是,如果我们只是上报一条存一条,并且给用户展示一条错误,那么在平台侧,我们的异常错误列表会被大量的重复上报占满,

对于错误类型进行统计,后续的异常分配操作都无法正常进行。

在这种情况下,我们需要对堆栈进行分组和聚合。也就是,将具有相同特征的错误上报,归类为统一种异常,并且只对用户暴露这种聚合后的异常。

image.png

堆栈怎么聚合效果才好呢?我们首先可以观察我们的JS异常所携带的信息,一个异常通常包括以下部分

img

  • name: 异常的 Type,例如 TypeError, SyntaxError, DOMError

  • Message:异常的相关信息,通常是异常原因,例如 a.b is not defined.

  • Stack (非标准)异常的上下文堆栈信息,通常为字符串

那么聚合的方案自然就出来了,利用某种方式,将 error 相关的信息利用提取为 fingerprint,每一次上报如果能够获得相同的 fingerprint,它们就可以归为一类。那么问题进一步细化为:如何利用 Error 来保证 fingerprint 的区分尽量准确呢?

image.png

如果跟随标准,我们只能利用 name + message 作为聚合依据。但在实践过程中,我们发现这是远远不够的。如上所示,可以看到这两个文件发生的位置是完全不同的,来自于不同的代码段,但由于我们只按照 name + message 聚合。它们被错误聚合到了一起,这样可能造成我们修复了其中一个错误后。误以为相关的所有异常都被解决。

因此,很明显我们需要利用非标准的 error.stack 的信息来帮我们解决问题了。在这里我们参考了 Sentry 的堆栈聚合策略:

image.png 除了常规的 name, message, 我们将反解后的 stacktrace 进一步拆分为一系列的 Frame,每一个 Frame 内我们重点关注其调用函数名,调用文件名以及当前执行的代码行(图中的context_line)。

image.png

Sentry 将每一个拆分出的部分都称为一个 GroupingComponent,当堆栈反解完毕后,我们首先自上而下的递归检测,并自下而上的生成一个个嵌套的 GroupingComponent。最后,在顶层调用 GroupingComponent.getHash() 方法, 得到一个最终的哈希值,这就是我们最终求得的 fingerprint。

image.png 相较于message+name, 利用 stacktrace 能够更细致的提取堆栈特征,规避了不同文件下触发相同 message 的问题。因此获得的聚合效果也更优秀。这个策略目前在字节内部的工作效果良好,基本上能够做到精确的区分各类异常而不会造成混淆和错误聚合。

处理人自动分配策略

异常已经成功定位后,如果我们可以直接将异常分配给这行代码的书写者或提交者,可以进一步提升问题解决的效率,这就是处理人自动分配所关心的,通常来说,分配处理人依赖 git blame 来实现。

image.png

一般的团队或公司都会使用 Gitlab / Github 作为代码的远端仓库。而这些平台都提供了丰富的 open-api 协助用户进行blame,

我们很自然的会联想到,当通过 sourcemap 解出原始堆栈路径后,如果可以结合调用 open-api,获得这段代码所在文件的blame历史, 我们就有机会直接在线上确定某一行的可能的 author / commitor 究竟是谁。从而将这个异常直接分配给他。

思路出来了,那么实际怎么落地呢?

我们需要几个信息

  1. 线上报错的项目对应的源代码仓库名,如 toutiao-fe/slardar

  2. 线上报错的代码发生的版本,以及与此版本关联的 git commit 信息,为什么需要这些信息呢?

默认用来 blame 的文件都是最新版本,但线上跑的不一定是最新版本的代码。不同版本的代码可能发生行的变动,从而影响实际代码的行号。如果我们无法将线上版本和用来 blame 的文件划分在统一范围内,则很有可能自动定位失败。

因此,我们必须找到一种方法,确定当前 blame 的文件和线上报错的文件处于同一版本。并且可以直接通过版本定位到关联的源代码 commit 起止位置。这样的操作在 Sentry 的官方工具 Sentry-Cli 中亦有提供。字节内部同样使用了这种方案。

通过 相关的 二进制工具,在代码发布前的脚本中提供当前将要发布的项目的版本和关联的代码仓库信息。同时在数据采集侧也携带相同的版本,线上异常发生后,我们就可以通过线上报错的版本找到原始文件对应的版本,从而精确定位到需要哪个时期的文件了。

image.png

异常报警

当异常已经成功反解和聚合后,当用户访问监控平台,已经可以观察并处理相关的错误,不过到目前为止,异常的发生还无法触及开发者,问题的解决依然依靠“走查”行为。这样的方案对严重的线上问题依然是不够用,因此我们还需要主动通知用户的手段,这就是异常报警。

在字节内部,报警可以分为宏观报警,即针对错误指标的数量/比率的报警,以及微观报警,即针对新增异常的报警。

image.png

宏观报警

宏观报警是数量/比率报警, 它只是统计某一类指标是否超出了限定的阈值,而不关心它具体是什么。因此默认情况下它并不会告诉你报警的原因。只有通过归因维度或者下文会提到的 微观(新增异常)报警 才能够知晓引发报警的具体原因

关于宏观报警,我们有几个关键概念

  • 第一是样本量,用户数阈值: 在配置比率指标时。如果上报量过低,可能会造成比率的严重波动,例如错误率 > 20%, 的报警下,如果 JS 错误数从 0 涨到 1, 那就是比率上涨到无限大从而造成没有意义的误报。如果不希望被少量波动干扰,我们设置了针对错误上报量和用户数的最低阈值,例如只有当错误影响用户数 > 5 时,才针对错误率变化报警。

image.png

  • 第二是归因维度: 对于数量,比率报警,仅仅获得一个异常指标值是没什么意义的,因为我们无法快速的定位问题是由什么因素引发的,因此我们提供了归因维度配置。例如,通过对 JS 异常报警配置错误信息归因,我们可以在报警时获得引发当前报警的 top3 关键错误和增长最快的 top3 错误信息。

  • 第三是时间窗口,报警运行频率: 如上文所说,报警是数量,比率报警,而数量,比率一定有一个统计范围,这个就是通过 时间窗口 来确定的。而报警并不是时时刻刻盯着我们的业务数据的,可以理解为利用一个定时器来定期检查 时间窗口 内的数据是否超出了我们定义的阈值。而这个定时器的间隔时间,就是 报警运行频率。通过这种方式,我们可以做到类实时的监测异常数据的变化,但又没有带来过大的资源开销。

微观报警(新增异常)

相较于在意宏观数量变化的报警,新增异常在意每一个具体问题,只要此问题是此前没有出现过的,就会主动通知用户。

同时,宏观报警是针对数据的定时查找,存在运行频率和时间窗口的限制,实时性有限。微观报警是主动推送的,具有更高的实时性。

微观报警适用于发版,灰度等对新问题极其关注,并且不方便在此时专门配置相关数量报警的阶段。

如何判断“新增”?

我们在 异常自动分配章节讲到了,我们的业务代码都是可以关联一个版本概念的。实际上版本不仅和源代码有关,也可以关联到某一类错误上。

在这里我们同样也可以基于版本视角判断“新增错误”。

对于新增异常的判断,针对两种不同场景做了区分

  • 对于指定版本、最新版本的新增异常报警,我们会分析该报警的 fingerprint 是否为该版本代码中首次出现。

  • 而对于全体版本,我们则将"首次”的范围增加了时间限制,因为对于某个错误,如果在长期没有出现后又突然出现,他本身还是具有通知的意义的,如果不进行时间限制,这个错误就不会通知到用户,可能会出现信息遗漏的情况。

如何做好性能监控?

如果说异常处理是前端监控体系60分的分界线,那么性能度量则是监控体系能否达到90分的关键。一个响应迟钝,点哪儿卡哪儿的页面,不会比点开到处都是报错的页面更加吸引人。页面的卡顿可能会直接带来用户访问量的下降,进而影响背后承载的服务收入。因此,监控页面性能并提升页面性能也是非常重要的。针对性能监控,我们主要关注指标选取品质度量 、瓶颈定位三个关键问题。

指标选取

指标选取依然不是我们今天文章分享的重点。网上关于 RUM 指标,Navigation 指标的介绍和采集方式已经足够清晰。通常分为两个思路:

  1. RUM (真实用户指标) -> 可以通过 Web Vitals (github.com/GoogleChrom…*

  2. 页面加载指标 -> NavigationTiming (ResourceTiming + DOM Processing + Load) 可以通过 MDN 相关介绍学习。这里都不多赘述。

瓶颈定位

收集到指标只是问题的第一步,接下来的关键问题便是,我们应该如何找出影响性能问题的根因,并且针对性的进行修复呢?

慢会话 + 性能时序分析

如果你对“数据洞察/可观测性”这个概念有所了解,那么你应该对 Kibana 或 Datadog 这类产品有所耳闻。在 kibana 或 Datadog 中都能够针对每一条上传的日志进行详细的追溯。和详细的上下文进行关联,让用户的体验可被观测,通过多种筛选找到需要用户的数据。

在字节前端的内部建设中,我们参考了这类数据洞察平台的消费思路。设计了数据探索能力。通过数据探索,我们可以针对用户上报的任意维度,对一类日志进行过滤,而不只是获得被聚合过的列表信息数据。这样的消费方式有什么好处呢?

  1. 我们可以直接定位到一条具体日志,找到一个现实的 data point 来分析问题

  2. 这种视图的状态是易于保存的,我们可以将找到的数据日志通过链接发送给其他人,其他用户可以直接还原现场。

对于性能瓶颈,在数据探索中,可以轻松通过针对某一类 PV 上报所关联的性能指标进行数值筛选。也可以按照某个固定时段进行筛选,从而直接获得响应的慢会话。这样的优势在于我们不用预先设定一个“慢会话阈值”,需要哪个范围的数据完全由我们自己说了算。例如,通过对 FCP > 3000ms 进行筛选,我们就能够获得一系列 FCP > 3s 的 PV 日志现场。

image.png

在每次 PV 上报后,我们会为数据采集的 SDK 设置一个全局状态,比如 view_id, 只要没有发生新的页面切换,当前的 view_id 就会保持不变。

而后续的一系列请求,异常,静态资源上报就可以通过 view_id 进行后端的时序串联。形成一张资源加载瀑布图。在瀑布图中我们可以观察到各类性能指标和静态资源加载,网络请求的关系。从而检测出是否是因为某些不必要的或者过大的资源,请求导致的页面性能瓶颈。这样的瀑布图都是一个个真实的用户上报形成的,相较于统计值产生的甘特图,更能帮助我们解决实际问题。

image.png

结合Longtask + 用户行为分析

通过指标过滤慢会话,并且结合性能时序瀑布图分析,我们能够判断出当前页面中是否存在由于网络或过大资源因素导致的页面加载迟缓问题

但页面的卡顿不一定全是由网络因素造成的。一个最简单的例子。当我在页面的 head 中插入一段非常耗时的同步脚本(例如 while N 次),则引发页面卡顿的原因就来自于代码执行而非资源加载。

image.png

针对这种情况,浏览器同样提供了 Longtask API 供我们收集这类占据主线程时间过长的任务。
同样的,我们将这类信息一并收集,并通过上文提到的 view_id 串联到一次页面访问中。用户就可以观察到某个性能指标是否受到了繁重的主线程加载的影响。若有,则可利用类似 lighthouse 的合成监控方案集中检查对应页面中是否存在相关逻辑了。

image.png

受限于浏览器所收集到的信息,目前的 longtask 我们仅仅只能获得它的执行时间相关信息。而无法像开发者面板中的 performance 工具一样准确获取这段逻辑是由那段代码引发的。如果我们能够在一定程度上收集到longtask触发的上下文,则可定位到具体的慢操作来源。

此外,页面的卡顿不一定仅仅发生在页面加载阶段,有时页面的卡顿会来自于页面的一次交互,如点击,滚动等等。这类行为造成的卡顿,仅仅依靠 RUM / navigation 指标是无法定位的。如果我们能够通过某种方式(在PPT中已经说明),对操作行为计时。并将操作计时范围内触发的请求,静态资源和longtask上报以同样的瀑布图方式收敛到一起。则可以进一步定位页面的“慢操作”,从而提升页面交互体验。

如下图所示,我们可以检查到,点击 slardar_web 这个按钮 / 标签带来了一系列的请求和 longtask,如果这次交互带来了一定的交互卡顿。我们便可以集中修复触发这个点击事件所涉及的逻辑来提升页面性能表现。

image.png

品质度量

当我们采集到一个性能指标后,针对这样一个数字,我们能做什么?

我们需要结论:好还是不好?

实际上我们通常是以单页面为维度来判定指标的,以整站视角来评判性能的优劣的置信度会受到诸多因素影响,比如一个站点中包含轻量的登陆页和功能丰富的中后台,两者的性能要求和用户的容忍度是不一致的,在实际状况下两者的绝对性能表现也是不一致的。而简单平均只会让我们观察不到重点,页面存在的问题数据也可能被其他的页面拉平。

其次,指标只是冷冰冰的数据,而数据想要发挥作用,一定需要参照系。比如,我仅仅提供 FMP = 4000ms,并不能说明这个页面的性能就一定需要重点关注,对于逻辑较重的PC页面,如数据平台,在线游戏等场景,它可能是符合业务要求的。而一个 FMP = 2000ms的页面则性能也不一定好,对于已经做了 SSR 等优化的回流页。这可能远远达不到我们的预期。

一个放之四海而皆准的指标定义是不现实的。不同的业务场景有不同的性能基准要求。我们可以把他们转化为具体的指标基准线。

通过对于现阶段线上指标的分布,我们可以可以自由定义当前站点场景下针对某个指标,怎样的数据是好的,怎样的数据是差的。

基准线应用后,我们便可以在具体的性能数据产出后,直观的观察到,在什么阶段,某些指标的表现是不佳的,并且可以集中针对这段时间的性能数据日志进行排查。

image.png

一个页面总是有多个性能指标的,现在我们已经知道了单个性能指标的优劣情况,如何整体的判断整个页面,乃至整个站点的性能状况,落实到消费侧则是,我们如何给一个页面的性能指标评分?

如果有关注过 lighthouse 的同学应该对这张图不陌生。

image.png lighthouse 通过 google 采集到的大量线上页面的性能数据,针对每一个性能指标,通过对数正态分布将其指标值转化成 百分制分数。再通过给予每个指标一定的权重(随着 lighthouse 版本更迭), 计算出该页面性能模块的一个“整体分数”。在即将上线的“品质度量”能力中,我们针对 RUM 指标,异常指标,以及资源加载指标均采取了类似的方案。

image.png

我们通常可以给页面的整体性能分数再制定一个基准分数,当上文所述的性能得分超过分数线,才认为该页面的性能水平是“达标”的。而整站整体的达标水平,则可以利用整站达标的子页面数/全站页面数来计算,也就是达标率,通过达标率,我们可以非常直观的迅速找到需要优化的性能页面,让不熟悉相关技术的运营,产品同学也可以定期巡检相关页面的品质状况。

如何做好请求 / 静态资源监控?

除了 JS 异常和页面的性能表现以外,页面能否正常的响应用户的操作,信息能否正确的展示,也和 api 请求,静态资源息息相关。表现为 SLA,接口响应速度等指标。现在主流的监控方案通常是采用手动 hook相关 api 和利用 resource timing 来采集相关信息的。

手动打点通常用于请求耗时兜底以及记录请求状态和请求响应相关信息。

  1. 对于 XHR 请求: 通过 hook XHR 的 open 和 send 方法, 获取请求的参数,在 onreadystatechange 事件触发时打点记录请求耗时。

    1.  // 记录 method
      hookObjectProperty(XMLHttpRequest.prototype, 'open', hookXHROpen);
      // hook onreadystateChange,调用前后打点计算
      hookObjectProperty(XMLHttpRequest.prototype, 'send', hookXHRSend);
      复制代码
  2. 对于fetch请求,则通过 hook Fetch 实现

    1. hookObjectProperty(global, 'fetch', hookFetch)
      复制代码
  • 第二种则是 resourceTiming 采集方案

    1. 静态资源上报:

      1. pageLoad 前:通过 performance.getEntriesByType 获取 resource 信息

      2. pageLoad后:通过 PerformanceObserver 监控 entryType 为 resource 的资源

  • const callback = (val, i, arr, ob) => // ... 略
    const observer = new PerformanceObserver((list, ob) => {
       if (list.getEntries) {
         list.getEntries().forEach((val, i, arr) => callback(val, i, arr, ob))
      } else {
         onFail && onFail()
      }
       // ...
    });

    observer.observe({ type: 'resource', buffered: false })
    复制代码

手动打点的优势在于无关兼容性,采集方便,而 Resource timing 则更精准,并且其记录中可以避开额外的事件队列处理耗时

如何理解和使用 resource timing 数据?

我们现在知道 ResourceTiming 是更能够反映实际资源加载状况的相关指标,而在工作中,我们经常遇到前端请求上报时间极长而后端对应接口日志却表现正常的情况。这通常就可能是由使用单纯的打点方案计算了太多非服务端因素导致的。影响一个请求在前端表现的因素除了服务端耗时以外,还包括网络,前端代码执行排队等因素。我们如何从 ResourceTiming 中分离出这些因素,从而更好的对齐后端口径呢?

第一种是 Chrome 方案(阿里的 ARMS 也采用的是这种方案):

它通过将线上采集的 ResoruceTiming 和 chrome timing 面板的指标进行类比还原出一个近似的各部分耗时值。他的简单计算方式如图所示。
img img
不过 chrome 实际计算 timing 的方式不明,这种近似的方式不一定能够和 chrome 的面板数据对的上,可能会被用户质疑数据不一致。

第二种则是标准方案: 规范划分阶段,这种划分是符合 W3C 规范的格式,其优势便在于其通用性好,且数据一定是符合要求的而不是 chrome 方案那种“近似计算”。不过它的缺陷是阶段划分还是有点太粗了,比如用户无法判断出浏览器排队耗时,也无法完全区分网络下载和下载完成后的资源加载阶段。只是简单的划分成了 Request / Response 阶段,给用户理解和分析带来了一定成本

在字节内部,我们是以标准方案为主,chrome方案为辅的,用户可以针对自己喜好的那种统计方式来对齐指标。通常来说,和服务端对齐耗时阶段可以利用标准方案的request阶段减去severtiming中的cdn,网关部分耗时来确定。

image.png

接下来我们再谈谈采集 SDK 的设计。

SDK 如何降低侵入,减少用户性能损耗?体积控制和灵活使用可以兼得吗?

常需要尽早执行,其资源加载通常也会造成一定的性能影响。更大的资源加载可能会导致更慢的 Load,LCP,TTI 时间,影响用户体验。

image.png

为了进一步优化页面加载性能,我们采用了 JS Snippets 来实现异步加载 + 预收集。

  1. 异步加载主要逻辑

首先,如果通过 JS 代码创建 script 脚本并追加到页面中,新增的 script 脚本默认会携带 async 属性,这意味着这这部分代码将通过async方式延迟加载。下载阶段不会阻塞用户的页面加载逻辑。从而一定程度的提升用户的首屏性能表现。

image.png

  1. 预收集

试想一下我们通过 npm 或者 cdn 的方式直接引入监控代码,script必须置于业务逻辑最前端,这是因为若异常先于监控代码加载发生,当监控代码就位时,是没有办法捕获到历史上曾经发生过的异常的。但将script置于前端将不可避免的对用户页面造成一定阻塞,且用户的页面可能会因此受到我们监控 sdk 服务可用性的影响。

为了解决这个问题,我们可以同步的加载一段精简的代码,在其中启动 addEventListener 来采集先于监控主要逻辑发生的错误。并存储到一个全局队列中,这样,当监控代码就位,我们只需要读取全局队列中的缓存数据并上报,就不会出现漏报的情况了。

image.png

更进一步:事件驱动与插件化

方案1. 2在大部分情况下都已经比较够用了,但对于字节的某些特殊场景却还不够。由于字节存在大量的移动端页面,且这些页面对性能极为敏感。因而对于第三方库的首包体积要求非常苛刻,同时,也不希望第三方代码的执行占据主线程太长时间。

此外,公司内也有部分业务场景特殊,如 node 场景,小程序场景,electron,如果针对每一种场景,都完全重新开发一套新的监控 SDK,有很大的人力重复开发的损耗。

如果我们能够将 SDK 的框架逻辑做成平台无关的,而各个数据监控,收集方案都只是以插件形式存在,那么这个 SDK 完全是可插拔的,类似 Sentry 所使用的 integration 方案。用户甚至可以完全不使用任何官方插件,而是通过自己实现相关采集方案,来做到项目的定制化。

关于框架设计可以参见下图
img img img

  1. 我们把整个监控 SDK 看作一条流水线(Client),接受的是用户配置(config)(通过 ConfigManager),收集和产出的是具体事件(Event, 通过 Plugins)。流水线是平台无关的,它不关心处理的事件是什么,也不关心事件是从哪来的。它其实是将这一系列的组件交互都抽象为 client 上的事件,从而使得数据采集器能够介入数据流转的每个阶段
    Client 通过 builder 包装事件后,转运给 Sender 负责批处理,Sender 最终调用 Transporter 上报。Transporter 是平台强相关的,例如 Web 使用 xhr 或 fetch,node 则使用 request 等。 同时,我们利用生命周期的概念设置了一系列的钩子,可以让用户可以在适当阶段处理流水线上的事件。例如利用 beforeSend 钩子去修改即将被发送的上报内容等。

imgimg

当整体的框架结构设计完后,我们就可以把视角放到插件上了。由于我们将框架设置为平台无关的,它本身只是个数据流,有点像是一个精简版的 Rx.js。而应用在各个平台上,我们只需要根据各个平台的特性设计其对应的采集或数据处理插件。

插件方案某种意义上实现了 IOC,用户不需要关心事件怎么处理,传入的参数是哪里来的,只需要利用传入的参数去获取配置,启动自己的插件等。如下这段JS采集器代码,开发插件时,我们只需要关心插件自身相关的逻辑,并且利用传入 client 约定的相关属性和方法工作就可以了。不需要关心 client 是怎么来的,也不用关心 client 什么时候去执行它。

image.png

当我们写完了插件之后,它要怎么才能被应用在数据采集和处理中呢?为了达成降低首包大小的目标,我们将插件分为同步和异步两种加载方式。

  1. 可以预收集的监控代码都不需要出现在首包中,以异步插件方式接入

  2. 无法做到预收集的监控代码以同步形式和首包打在一起,在源码中将client传入,尽早启动,保证功能稳定。

image.png 3. 异步插件采用约定式加载,用户在使用层面是完全无感的。我们通过主包加载时向在全局初始化注册表和注册方法,在读取用户配置后,拉取远端插件加载并利用全局注册方法获取插件实例,最后传入我们的 client 实现代码执行。

image.png

经过插件化和一系列 SDK 的体积改造后,我们的sdk 首包体积降低到了从63kb 降低到了 34 kb。

image.png

总结

本文主要从 JS 异常监控,性能监控和请求,静态资源监控几个细节点讲述了 Slardar 在前端监控方向所面临关键问题的探索和实践,希望能够对大家在前端监控领域或者将来的工作中产生帮助。其实前端监控还有许多方面可以深挖,例如如何利用拨测,线下实验室数据采集来进一步追溯问题,如何捕获白屏等类崩溃异常,如何结合研发流程来实现用户无感知的接入等等。

作者:字节架构前端
来源:https://juejin.cn/post/7195496297150709821

收起阅读 »

一个炫酷的头像悬停效果

web
本文翻译自 A Fancy Hover Effect For Your Avatar,略有删改,有兴趣可以看看原文。你知道当一个人的头像从一个圆圈或洞里伸出来时的那种效果吗?本文将使用一种很简洁的方式实现该悬停效果,可以用在你的头像交互上面。看到了吗?我们将制...
继续阅读 »

本文翻译自 A Fancy Hover Effect For Your Avatar,略有删改,有兴趣可以看看原文。

你知道当一个人的头像从一个圆圈或洞里伸出来时的那种效果吗?本文将使用一种很简洁的方式实现该悬停效果,可以用在你的头像交互上面。


看到了吗?我们将制作一个缩放动画,其中头像部分似乎从它所在的圆圈中钻出来了。是不是很酷呢?接下来让我们一起一步一步地构建这个动画交互效果。

HTML:只需要一个元素

是的,只需要一个img图片标签即可,本次练习的挑战性部分是使用尽可能少的代码。如果你已经关注我一段时间了,你应该习惯了。我努力寻找能够用最小、最易维护的代码实现的CSS解决方案。

<img src="" alt="">

首先我们需要一个带有透明背景的正方形图像文件,以下是本次案例使用的图像。


在开始CSS之前,让我们先分析一下效果。悬停时图像会变大,所以我们肯定会在这里使用transform:scale。头像后面有一个圆圈,径向渐变应该可以达到这个效果。最后我们需要一种在圆圈底部创建边框的方法,该边框将不受整体放大的影响且是在视觉顶层。

放大效果

放大的效果,增加transform:scale,这个比较简单。

img:hover {
 transform: scale(1.35);
}

上面说过背景是一个径向渐变。我们创建一个径向渐变,但是两个颜色之间不要有过渡效果,这样使得它看起来像我们画了一个有实线边框的圆。

img {
 --b: 5px; /* border width */

 background:
   radial-gradient(
     circle closest-side,
     #ECD078 calc(99% - var(--b)),
     #C02942 calc(100% - var(--b)) 99%,
     #0000
  );
}

注意CSS变量,--b,在这里它表示“边框”的宽度,实际上只是用于定义径向渐变红色部分的位置。


下一步是在悬停时调整渐变大小,随着图像的放大,圆需要保持大小不变。由于我们正在应用scale变换,因此实际上需要减小圆圈的大小,否则它会随着化身的大小而增大。

让我们首先定义一个CSS变量--f,它定义了“比例因子”,并使用它来设置圆的大小。我使用1作为默认值,因为这是图像和圆的初始比例,我们从圆转换。

现在我们必须将背景定位在圆的中心,并确保它占据整个高度。我喜欢把所有东西都直接简写在 background 属性,代码如下:

background: radial-gradient() 50% / calc(100% / var(--f)) 100% no-repeat;

背景放置在中心( 50%),宽度等于calc(100%/var(--f)),高度等于100%。

当 --f 等于 1 时是我们最初的比例。同时,渐变占据容器的整个宽度。当我们增加 --f,元素的大小会增长但是渐变的大小将减小。


越来越接近了!我们在顶部添加了溢出效果,但我们仍然需要隐藏图像的底部,这样它看起来就像是跳出了圆圈,而不是整体浮在圆圈前面。这是整个过程中比较复杂的部分,也是我们接下来要做的。

下边框

第一次尝试使用border-bottom属性,但无法找到一种方法来匹配边框的大小与圆的大小。如图所示,相信你能看出来无法实现我们想要的效果:

实际的解决方案是使用outline属性。不是borderoutline可以让我们创造出很酷的悬停效果。结合 outline-offset 偏移量,我们就可以实现所需要的效果。

其核心是在图像上设置一个outline轮廓并调整其偏移量以创建下边框。偏移量将取决于比例因子,与渐变大小相同。outline-offset 偏移量看起来相对比较复杂,这里对计算方式进行了精简,有兴趣的可以看看原文。

img {
 --s: 280px; /* image size */
 --b: 5px; /* border thickness */
 --c: #C02942; /* border color */
 --f: 1; /* initial scale */
 
 border-radius: 0 0 999px 999px;
 outline: var(--b) solid var(--c);
 outline-offset: calc((1 / var(--f) - 1) * var(--s) / 2 - var(--b));

}

因为我们需要一个圆形的底部边框,所以在底部添加了一个边框圆角,使轮廓与渐变的弯曲程度相匹配。


现在我们需要找到如何从轮廓中删除顶部,也就是上图中挡住头像的那根线。换句话说,我们只需要图像的底部轮廓。首先,在顶部添加空白和填充,以帮助避免顶部头像的重叠,这通过增加padding即可实现:

padding-top: calc(var(--s)/5)

这里还有一个注意点,需要添加 content-box 值添加到 background

background:
 radial-gradient(
   circle closest-side,
   #ECD078 calc(99% - var(--b)),
   var(--c) calc(100% - var(--b)) 99%,
   #0000
) 50%/calc(100%/var(--f)) 100% no-repeat content-box;

这样做是因为我们添加了padding填充,并且我们只希望将背景设置为内容框,因此我们必须显式地定义出来。

CSS mask

到了最后一部分!我们要做的就是藏起一些碎片。为此,我们将依赖于 CSS mask 属性,当然还有渐变。

下面的图说明了我们需要隐藏的内容或需要显示的内容,以便更加准确。左图是我们目前拥有的,右图是我们想要的。绿色部分说明了我们必须应用于原始图像以获得最终结果的遮罩内容。


我们可以识别mask的两个部分:

  • 底部的圆形部分,与我们用来创建化身后面的圆的径向渐变具有相同的维度和曲率

  • 顶部的矩形,覆盖轮廓内部的区域。请注意轮廓是如何位于顶部的绿色区域之外的-这是最重要的部分,因为它允许剪切轮廓,以便只有底部部分可见

最终的完整css如下,对有重复的代码进行抽离,如--g,--o:

img {
 --s: 280px; /* image size */
 --b: 5px; /* border thickness */
 --c: #C02942; /* border color */
 --f: 1; /* initial scale */

 --_g: 50% / calc(100% / var(--f)) 100% no-repeat content-box;
 --_o: calc((1 / var(--f) - 1) * var(--s) / 2 - var(--b));

 width: var(--s);
 aspect-ratio: 1;
 padding-top: calc(var(--s)/5);
 cursor: pointer;
 border-radius: 0 0 999px 999px;
 outline: var(--b) solid var(--c);
 outline-offset: var(--_o);
 background:
   radial-gradient(
     circle closest-side,
     #ECD078 calc(99% - var(--b)),
     var(--c) calc(100% - var(--b)) 99%,
     #0000) var(--_g);
 mask:
   linear-gradient(#000 0 0) no-repeat
   50% calc(-1 * var(--_o)) / calc(100% / var(--f) - 2 * var(--b)) 50%,
   radial-gradient(
     circle closest-side,
     #000 99%,
     #0000) var(--_g);
 transform: scale(var(--f));
 transition: .5s;
}
img:hover {
 --f: 1.35; /* hover scale */
}

下面的一个演示,直观的说明mask的使用区域。中间的框说明了由两个渐变组成的遮罩层。把它想象成左边图像的可见部分,你就会得到右边的最终结果:

最后

搞定!我们不仅完成了一个流畅的悬停动画,而且只用了一个<img>元素和不到20行的CSS技巧!如果我们允许自己使用更多的HTML,我们能简化CSS吗?当然可以。但我们是来学习CSS新技巧的!这是一个很好的练习,可以探索CSS渐变、遮罩、outline属性的行为、转换以及其他许多内容。

在线效果

实例里面是流行的CSS开发人员的照片。有兴趣的同学可以展示一下自己的头像效果。

看完本文如果觉得有用,记得点个赞支持,收藏起来说不定哪天就用上啦~

作者:南城FE
来源:juejin.cn/post/7196747356796518460

收起阅读 »

Android进阶宝典 -- 告别繁琐的AIDL吧,手写IPC通信框架,5行代码实现进程间通信(下)

接:Android进阶宝典 -- 告别繁琐的AIDL吧,手写IPC通信框架,5行代码实现进程间通信(上)2.3 内部通讯协议完善当客户端发起请求,想要执行某个方法的时候,首先服务端会先向Registery中查询注册的服务,从而找到这个要执行的方法,这个流程是在...
继续阅读 »

接:Android进阶宝典 -- 告别繁琐的AIDL吧,手写IPC通信框架,5行代码实现进程间通信(上)

2.3 内部通讯协议完善

当客户端发起请求,想要执行某个方法的时候,首先服务端会先向Registery中查询注册的服务,从而找到这个要执行的方法,这个流程是在内部完成。

override fun send(requestRequest?): Response? {
   //获取服务对象id
   val serviceId = request?.serviceId
   val methodName = request?.methodName
   val params = request?.params
   // 反序列化拿到具体的参数类型
   val neededParams = parseParameters(params)
   val method = Registry.instance.findMethod(serviceIdmethodNameneededParams)
   Log.e("TAG""method $method")
   Log.e("TAG""neededParams $neededParams")
   when (request?.type) {

       REQUEST_TYPE.GET_INSTANCE.ordinal -> {
           //==========执行静态方法
           try {
               var instanceAny? = null
               instance = if (neededParams == null || neededParams.isEmpty()) {
                   method?.invoke(null)
              } else {
                   method?.invoke(nullneededParams)
              }
               if (instance == null) {
                   return Response("instance == null"-101)
              }
               //存储实例对象
               Registry.instance.setServiceInstance(serviceId ?""instance)
               return Response(null200)
          } catch (eException) {
               return Response("${e.message}"-102)
          }
      }
       REQUEST_TYPE.INVOKE_METHOD.ordinal -> {
           //==============执行普通方法
           val instance = Registry.instance.getServiceInstance(serviceId)
           if (instance == null) {
               return Response("instance == null "-103)
          }
           //方法执行返回的结果
           return try {

               val result = if (neededParams == null || neededParams.isEmpty()) {
                   method?.invoke(instance)
              } else {
                   method?.invoke(instanceneededParams)
              }
               Response(gson.toJson(result), 200)
          } catch (eException) {
               Response("${e.message}"-104)
          }

      }
  }

   return null
}

当客户端发起请求时,会将请求的参数封装到Request中,在服务端接收到请求后,就会解析这些参数,变成Method执行时需要传入的参数。

private fun parseParameters(paramsArray<Parameters>?): Array<Any?>? {
   if (params == null || params.isEmpty()) {
       return null
  }
   val objects = arrayOfNulls<Any>(params.size)
   params.forEachIndexed { indexparameters ->
       objects[index=
           gson.fromJson(parameters.valueClass.forName(parameters.className))
  }
   return objects
}

例如用户中心调用setUserInfo方法时,需要传入一个User实体类,如下所示:

UserManager().setUserInfo(User("ming",25))

那么在调用这个方法的时候,首先会把这个实体类转成一个JSON字符串,例如:

{
  "name":"ming",
  "age":25
}

为啥要”多此一举“呢?其实这种处理方式是最快速直接的,转成json字符串之后,能够最大限度地降低数据传输的大小,等到服务端处理这个方法的时候,再把Request中的params反json转成User对象即可。

fun findMethod(serviceIdString?methodNameString?neededParamsArray<Any?>?): Method? {
   //获取服务
   val serviceClazz = serviceMaps[serviceId?return null
   //获取方法集合
   val methods = methodsMap[serviceClazz?return null
   return methods[rebuildParamsFunc(methodNameneededParams)]
}

private fun rebuildParamsFunc(methodNameString?paramsArray<Any?>?): String {

   val stringBuffer = StringBuffer()
   stringBuffer.append(methodName).append("(")

   if (params == null || params.isEmpty()) {
       stringBuffer.append(")")
       return stringBuffer.toString()
  }
   stringBuffer.append(params[0]?.javaClass?.name)
   for (index in 1 until params.size) {
       stringBuffer.append(",").append(params[index]?.javaClass?.name)
  }
   stringBuffer.append(")")
   return stringBuffer.toString()
}

那么在查找注册方法的时候就简单多了,直接抽丝剥茧一层一层取到最终的Method。在拿到Method之后,这里是有2种处理方式,一种是通过静态单例的形式拿到实例对象,并保存在服务端;另一种就是执行普通方法,因为在反射的时候需要拿到类的实例对象才能调用,所以才在GET_INSTANCE的时候存一遍

3 客户端 - connect

在第二节中,我们已经完成了通讯协议的建设,最终一步就是客户端通过绑定服务,向服务端发起通信了。

3.1 bindService

/**
* 绑定服务
*
*/
fun connect(
   contextContext,
   pkgNameString,
   actionString = "",
   serviceClass<out IPCService>
) {
   val intent = Intent()
   if (pkgName.isEmpty()) {
       //同app内的不同进程
       intent.setClass(contextservice)
  } else {
       //不同APP之间进行通信
       intent.setPackage(pkgName)
       intent.setAction(action)
  }
   //绑定服务
   context.bindService(intentIpcServiceConnection(service), Context.BIND_AUTO_CREATE)
}

inner class IpcServiceConnection(val simpleServiceClass<out IPCService>) : ServiceConnection {

   override fun onServiceConnected(nameComponentName?serviceIBinder?) {
       val mService = IIPCServiceInterface.Stub.asInterface(serviceas IIPCServiceInterface
       binders[simpleService= mService
  }

   override fun onServiceDisconnected(nameComponentName?) {
       //断连之后,直接移除即可
       binders.remove(simpleService)
  }
}

对于绑定服务这块,相信伙伴们也很熟悉了,这个需要说一点的就是,在Android 5.0以后,启动服务不能只依赖action启动,还需要指定应用包名,否则就会报错。

在服务连接成功之后,即回调onServiceConnected方法的时候,需要拿到服务端的一个代理对象,即IIPCServiceInterface的实例对象,然后存储在binders集合中,key为绑定的服务类class对象,value就是对应的服务端的代理对象。

fun send(
   typeInt,
   serviceClass<out IPCService>,
   serviceIdString,
   methodNameString,
   paramsArray<Parameters>
): Response? {
   //创建请求
   val request = Request(typeserviceIdmethodNameparams)
   //发起请求
   return try {
       binders[service]?.send(request)
  } catch (eException) {
       null
  }
}

当拿到服务端的代理对象之后,就可以在客户端调用send方法向服务端发送消息。

class Channel {

   //====================================
   /**每个服务对应的Binder对象*/
   private val bindersConcurrentHashMap<Class<out IPCService>IIPCServiceInterface> by lazy {
       ConcurrentHashMap()
  }

   //====================================

   /**
    * 绑定服务
    *
    */
   fun connect(
       contextContext,
       pkgNameString,
       actionString = "",
       serviceClass<out IPCService>
  ) {
       val intent = Intent()
       if (pkgName.isEmpty()) {
           intent.setClass(contextservice)
      } else {
           intent.setPackage(pkgName)
           intent.setAction(action)
           intent.setClass(contextservice)
      }
       //绑定服务
       context.bindService(intentIpcServiceConnection(service), Context.BIND_AUTO_CREATE)
  }

   inner class IpcServiceConnection(val simpleServiceClass<out IPCService>) : ServiceConnection {

       override fun onServiceConnected(nameComponentName?serviceIBinder?) {
           val mService = IIPCServiceInterface.Stub.asInterface(serviceas IIPCServiceInterface
           binders[simpleService= mService
      }

       override fun onServiceDisconnected(nameComponentName?) {
           //断连之后,直接移除即可
           binders.remove(simpleService)
      }
  }


   fun send(
       typeInt,
       serviceClass<out IPCService>,
       serviceIdString,
       methodNameString,
       paramsArray<Parameters>
  ): Response? {
       //创建请求
       val request = Request(typeserviceIdmethodNameparams)
       //发起请求
       return try {
           binders[service]?.send(request)
      } catch (eException) {
           null
      }
  }


   companion object {
       private val instance by lazy {
           Channel()
      }

       /**
        * 获取单例对象
        */
       fun getDefault(): Channel {
           return instance
      }
  }
}

3.2 动态代理获取接口实例

回到1.2小节中,我们定义了一个IUserManager接口,通过前面我们定义的通信协议,只要我们获取了IUserManager的实例对象,那么就能够调用其中的任意普通方法,所以在客户端需要设置一个获取接口实例对象的方法。

fun <T> getInstanceWithName(
   serviceClass<out IPCService>,
   classTypeClass<T>,
   clazzClass<*>,
   methodNameString,
   paramsArray<Parameters>
): T? {
   
   //获取serviceId
   val serviceId = clazz.getAnnotation(ServiceId::class.java)

   val response = Channel.getDefault()
      .send(REQUEST.GET_INSTANCE.ordinalserviceserviceId.namemethodNameparams)
   Log.e("TAG""response $response")
   if (response != null && response.result) {
       //请求成功,返回接口实例对象
       return Proxy.newProxyInstance(
           classType.classLoader,
           arrayOf(classType),
           IPCInvocationHandler()
      ) as T
  }

   return null
}

当我们通过客户端发送一个获取单例的请求后,如果成功了,那么就直接返回这个接口的单例对象,这里直接使用动态代理的方式返回一个接口实例对象,那么后续执行这个接口的方法时,会直接走到IPCInvocationHandler的invoke方法中。

class IPCInvocationHandler(
   val serviceClass<out IPCService>,
   val serviceIdString?
) : InvocationHandler {

   private val gson = Gson()

   override fun invoke(proxyAny?methodMethod?argsArray<out Any>?): Any? {

       //执行客户端发送方法请求
       val response = Channel.getDefault()
          .send(
               REQUEST.INVOKE_METHOD.ordinal,
               service,
               serviceId,
               method?.name ?"",
               args
          )
       //拿到服务端返回的结果
       if (response != null && response.result) {
           //反序列化得到结果
           return gson.fromJson(response.valuemethod?.returnType)
      }


       return null
  }

}

因为服务端在拿到Method的返回结果时,将javabean转换为了json字符串,因此在IPCInvocationHandler中,当调用接口中方法获取结果之后,用Gson将json转换为javabean对象,那么就直接获取到了结果。

3.3 框架使用

服务端:

UserManager2.getDefault().setUserInfo(User("ming"25))
IPC.register(UserManager2::class.java)

同时在服务端需要注册一个IPCService的实例,这里用的是IPCService01

<service
   android:name=".UserService"
   android:enabled="true"
   android:exported="true" />
<service
   android:name="com.lay.ipc.service.IPCService01"
   android:enabled="true"
   android:exported="true">
   <intent-filter>
       <action android:name="android.intent.action.GET_USER_INFO" />
   </intent-filter>
</service>

客户端:

调用connect方法,需要绑定服务端的服务,传入包名和action

IPC.connect(
   this,
   "com.lay.learn.asm",
   "android.intent.action.GET_USER_INFO",
   IPCService01::class.java
)

首先获取IUserManager的实例,注意这里要和服务端注册的UserManager2是同一个ServiceId,而且接口、javabean需要存放在与服务端一样的文件夹下

val userManager = IPC.getInstanceWithName(
   IPCService01::class.java,
   IUserManager::class.java,
   "getDefault",
   null
)
val info = userManager?.getUserInfo()

通过动态代理拿到接口的实例对象,只要调用接口中的方法,就会进入到InvocationHandler中的invoke方法,在这个方法中,通过查找服务端注册的方法名从而找到对应的Method,通过反射调用拿到UserManager中的方法返回值。

这样其实就通过5-6行代码,就完成了进程间通信,是不是比我们在使用AIDL的时候要方便地许多。

4 总结

如果我们面对下面这个类,如果这个类是个私有类,外部没法调用,想通过反射的方式调用其中某个方法。

@ServiceId(name = "UserManagerService")
public class UserManager2 implements IUserManager {

   private static UserManager2 userManager2 = new UserManager2();

   public static UserManager2 getDefault() {
       return userManager2;
  }

   private User user;

   @Nullable
   @Override
   public User getUserInfo() {
       return user;
  }

   @Override
   public void setUserInfo(@NonNull User user) {
       this.user = user;
  }

   @Override
   public int getUserId() {
       return 0;
  }

   @Override
   public void setUserId(int id) {

  }
}

那么我们可以这样做:

val method = UserManager2::class.java.getDeclaredMethod("getUserInfo")
method.isAccessible = true
method.invoke(this,params)

其实这个框架的原理就是上面这几行代码所能够完成的事;通过服务端注册的形式,将UserManager2中所有的方法Method收集起来;当另一个进程,也就是客户端想要调用其中某个方法的时候,通过方法名来获取到对应的Method,调用这个方法得到最终的返回值

作者:layz4android
来源:juejin.cn/post/7192465342159912997

收起阅读 »

Android进阶宝典 -- 告别繁琐的AIDL吧,手写IPC通信框架,5行代码实现进程间通信(上)

如果在Android中想要实现进程间通信,有哪些方式呢?(2)Socket通信:这种属于Linux层面的进程间通信了,除此之外,还包括管道、信号量等,像传统的IPC进程间通信需要数据二次拷贝,这种效率是最低的;那么本篇文章并不是说完全丢弃掉AIDL,它依然不失...
继续阅读 »

对于进程间通信,很多项目中可能根本没有涉及到多进程,很多公司的app可能就一个主进程,但是对于进程间通信,我们也是必须要了解的。

如果在Android中想要实现进程间通信,有哪些方式呢?

(1)发广播(sendBroadcast):e.g. 两个app之间需要通信,那么可以通过发送广播的形式进行通信,如果只想单点通信,可以指定包名。但是这种方式存在的弊端在于发送方无法判断接收方是否接收到了广播,类似于UDP的通信形式,而且存在丢数据的形式;

(2)Socket通信:这种属于Linux层面的进程间通信了,除此之外,还包括管道、信号量等,像传统的IPC进程间通信需要数据二次拷贝,这种效率是最低的;

(3)AIDL通信:这种算是Android当中主流的进程间通信方案,通过Service + Binder的形式进行通信,具备实时性而且能够通过回调得知接收方是否收到数据,弊端在于需要管理维护aidl接口,如果不同业务方需要使用不同的aidl接口,维护的成本会越来越高。

那么本篇文章并不是说完全丢弃掉AIDL,它依然不失为一个很好的进程间通信的手段,只是我会封装一个适用于任意业务场景的IPC进程间通讯框架,这个也是我在自己的项目中使用到的,不需要维护很多的AIDL接口文件。

有需要源码的伙伴,可以去我的github首页获取 FastIPC源码地址分支:feature/v0.0.1-snapshot有帮助的话麻烦给点个star⭐️⭐️⭐️

1 服务端 - register

首先这里先说明一下,就是对于传统的AIDL使用方式,这里就不再过多介绍了,这部分还是比较简单的,有兴趣的伙伴们可以去前面的文章中查看,本文将着重介绍框架层面的逻辑。

那么IPC进程间通信,需要两个端:客户端和服务端。服务端会提供一个注册方法,例如客户端定义的一些服务,通过向服务端注册来做一个备份,当客户端调用服务端某个方法的时候来返回值。

object IPC {

   //==========================================

   /**
    * 服务端暴露的接口,用于注册服务使用
    */
   fun register(serviceClass<*>) {
       Registry.instance.register(service)
  }

}

其实在注册的时候,我们的目的肯定是能够方便地拿到某个服务,并且能够调用这个服务提供的方法,拿到我想要的值;所以在定义服务的时候,需要注意以下两点:

(1)需要定义一个与当前服务一一对应的serviceId,通过serviceId来获取服务的实例;

(2)每个服务当中定义的方法同样需要对应起来,以便拿到服务对象之后,通过反射调用其中的方法。

所以在注册的时候,需要从这两点入手。

1.1 定义服务唯一标识serviceId

@Target(AnnotationTarget.TYPE)
@Retention(AnnotationRetention.RUNTIME)
annotation class ServiceId(
   val nameString
)

一般来说,如果涉及到反射,最常用的就是通过注解给Class做标记,因为通过反射能够拿到类上标记的注解,就能够拿到对应的serviceId。

class Registry {

   //=======================================
   /**用于存储 serviceId 对应的服务 class对象*/
   private val serviceMapsConcurrentHashMap<StringClass<*>> by lazy {
       ConcurrentHashMap()
  }

   /**用于存储 服务中全部的方法*/
   private val methodsMapConcurrentHashMap<Class<*>ConcurrentHashMap<StringMethod>> by lazy {
       ConcurrentHashMap()
  }


   //=======================================

   /**
    * 服务端注册方法
    * @param service 服务class对象
    */
   fun register(serviceClass<*>) {

       // 获取serviceId与服务一一对应
       val serviceIdAnnotation = service.getAnnotation(ServiceId::class.java)
           ?throw IllegalArgumentException("只有标记@ServiceId的服务才能够被注册")
       //获取serviceId
       val name = serviceIdAnnotation.name
       serviceMaps[name= service
       //temp array
       val methodsConcurrentHashMap<StringMethod> = ConcurrentHashMap()
       // 获取服务当中的全部方法
       for (method in service.declaredMethods) {

           //这里需要注意,因为方法中存在重载方法,所以不能把方法名当做key,需要加上参数
           val buffer = StringBuffer()
           buffer.append(method.name).append("(")
           val params = method.parameterTypes
           if (params.size > 0) {
               buffer.append(params[0].name)
          }
           for (index in 1 until params.size) {
               buffer.append(",").append(params[index].name)
          }
           buffer.append(")")
           //保存
           methods[buffer.toString()] = method
      }
       //存入方法表
       methodsMap[service= methods
  }

   companion object {
       val instance by lazy { Registry() }
  }
}

通过上面的register方法,当传入定义的服务class对象的时候,首先获取到服务上标记的@ServiceId注解,注意这里如果要注册必须标记,否则直接抛异常;拿到serviceId之后,存入到serviceMaps中。

然后需要获取服务中的全部方法,因为考虑到重载方法的存在,所以不能单单以方法名作为key,而是需要把参数也加上,因此这里做了一个逻辑就是将方法名与参数名组合一个key,存入到方法表中。

这样注册任务就完成了,其实还是比较简单的,关键在于完成2个表:服务表和方法表的初始化以及数据存储功能

1.2 使用方式

@ServiceId("UserManagerService")
interface IUserManager {

   fun getUserInfo()User?
   fun setUserInfo(userUser)
   fun getUserId()Int
   fun setUserId(idInt)
}

假设项目中有一个用户信息管理的服务,这个服务用于给所有的App提供用户信息查询。

@ServiceId("UserManagerService")
class UserManager : IUserManager {

   private var userUser? = null
   private var userIdInt = 0

   override fun getUserInfo(): User? {
       return user
  }

   override fun setUserInfo(userUser) {
       this.user = user
  }

   override fun getUserId()Int {
       return userId
  }

   override fun setUserId(idInt) {
       this.userId = id
  }

}

用户中心可以注册这个服务,并且调用setUserInfo方法保存用户信息,那么其他App(客户端)连接这个服务之后,就可以调用getUserInfo这个方法,获取用户信息,从而完成进程间通信。

2023-01-23 22:15:54.729 13361-13361/com.lay.learn.asm E/TAG: entrySet key class com.lay.learn.asm.binder.UserManager
2023-01-23 22:15:54.729 13361-13361/com.lay.learn.asm E/TAG: mapValue key setUserInfo(com.lay.learn.asm.binder.User)
2023-01-23 22:15:54.729 13361-13361/com.lay.learn.asm E/TAG: mapValue value public void com.lay.learn.asm.binder.UserManager.setUserInfo(com.lay.learn.asm.binder.User)
2023-01-23 22:15:54.729 13361-13361/com.lay.learn.asm E/TAG: mapValue key getUserInfo()
2023-01-23 22:15:54.729 13361-13361/com.lay.learn.asm E/TAG: mapValue value public com.lay.learn.asm.binder.User com.lay.learn.asm.binder.UserManager.getUserInfo()
2023-01-23 22:15:54.729 13361-13361/com.lay.learn.asm E/TAG: mapValue key getUserId()
2023-01-23 22:15:54.729 13361-13361/com.lay.learn.asm E/TAG: mapValue value public int com.lay.learn.asm.binder.UserManager.getUserId()
2023-01-23 22:15:54.729 13361-13361/com.lay.learn.asm E/TAG: mapValue key setUserId(int)
2023-01-23 22:15:54.729 13361-13361/com.lay.learn.asm E/TAG: mapValue value public void com.lay.learn.asm.binder.UserManager.setUserId(int)

我们看调用register方法之后,每个方法的key值都是跟参数绑定在一起,这样服务端注册就完成了。

2 客户端与服务端的通信协议

对于客户端的连接,其实就是绑定服务,那么这里就会使用到AIDL通信,但是跟传统的相比,我们是将AIDL封装到框架层内部,对于用户来说是无感知的。

2.1 创建IPCService

这个服务就是用来完成进程间通信的,客户端需要与这个服务建立连接,通过服务端分发消息,或者接收客户端发送来的消息。

abstract class IPCService : Service() {
   override fun onBind(intentIntent?)IBinder? {
       return null
  }
}

这里我定义了一个抽象的Service基类,为啥要这么做,前面我们提到过是因为整个项目中不可能只有一个服务,因为业务众多,为了保证单一职责,需要划分不同的类型,所以在框架中会衍生多个实现类,不同业务方可以注册这些服务,当然也可以自定义服务继承IPCService。

class IPCService01 : IPCService() {
}

在IPCService的onBind需要返回一个Binder对象,因此需要创建aidl文件。

2.2 定义通讯协议

像我们在请求接口的时候,通常也是向服务端发起一个请求(Request),然后得到服务端的一个响应(Response),因此在IPC通信的的时候,也可以根据这种方式建立通信协议。

data class Request(
   val type: Int,
   val serviceId: String?,
   val methodName: String?,
   val params: Array<Parameters>?
) : Parcelable {
   //=====================================
   /**请求类型*/
   //获取实例的对象
   val GET_INSTANCE = "getInstance"
   //执行方法
   val INVOKE_METHOD = "invokeMethod"
   
   //=======================================

   constructor(parcel: Parcel) : this(
       parcel.readInt(),
       parcel.readString(),
       parcel.readString(),
       parcel.createTypedArray(Parameters.CREATOR)
  )

   override fun writeToParcel(parcel: Parcel, flags: Int) {
       parcel.writeInt(type)
       parcel.writeString(serviceId)
       parcel.writeString(methodName)
  }

   override fun describeContents(): Int {
       return 0
  }

   override fun equals(other: Any?): Boolean {
       if (this === other) return true
       if (javaClass != other?.javaClass) return false

       other as Request

       if (type != other.type) return false
       if (serviceId != other.serviceId) return false
       if (methodName != other.methodName) return false
       if (params != null) {
           if (other.params == null) return false
           if (!params.contentEquals(other.params)) return false
      } else if (other.params != null) return false

       return true
  }

   override fun hashCode(): Int {
       var result = type
       result = 31 * result + (serviceId?.hashCode() ?: 0)
       result = 31 * result + (methodName?.hashCode() ?: 0)
       result = 31 * result + (params?.contentHashCode() ?: 0)
       return result
  }

   companion object CREATOR : Parcelable.Creator<Request> {
       override fun createFromParcel(parcel: Parcel): Request {
           return Request(parcel)
      }

       override fun newArray(size: Int): Array<Request?> {
           return arrayOfNulls(size)
      }
  }

}

对于客户端来说,致力于发起请求,请求实体类Request参数介绍如下:

type表示请求的类型,包括两种分别是:执行静态方法和执行普通方法(考虑到反射传参);

serviceId表示请求的服务id,要请求哪个服务,便可以获取到这个服务的实例对象,调用服务中提供的方法;

methodName表示要请求的方法名,也是在serviceId服务中定义的方法;

params表示请求的方法参数集合,我们在服务端注册的时候,方法名 + 参数名 作为key,因此需要知道请求的方法参数,以便获取到Method对象。

data class Response(
   val value:String?,
   val result:Boolean
):Parcelable {
   @SuppressLint("NewApi")
   constructor(parcelParcel) : this(
       parcel.readString(),
       parcel.readBoolean()
  )

   override fun writeToParcel(parcelParcelflagsInt) {
       parcel.writeString(value)
       parcel.writeByte(if (result1 else 0)
  }

   override fun describeContents()Int {
       return 0
  }

   companion object CREATOR : Parcelable.Creator<Response> {
       override fun createFromParcel(parcelParcel)Response {
           return Response(parcel)
      }

       override fun newArray(sizeInt)Array<Response?> {
           return arrayOfNulls(size)
      }
  }
}

对于服务端来说,在接收到请求之后,需要针对具体的请求返回相应的结果,Response实体类参数介绍:

result表示请求成功或者失败;

value表示服务端返回的结果,是一个json字符串。

因此定义aidl接口文件如下,输入一个请求之后,返回一个服务端的响应。

interface IIPCServiceInterface {
   Response send(in Request request);
}

这样IPCService就可以将aidl生成的Stub类作为Binder对象返回。

abstract class IPCService : Service() {
   
   override fun onBind(intentIntent?)IBinder? {
       return BINDERS
  }

   companion object BINDERS : IIPCServiceInterface.Stub() {
       override fun send(requestRequest?)Response? {

           when(request?.type){
               
               REQUEST.GET_INSTANCE.ordinal->{
                   
              }
               REQUEST.INVOKE_METHOD.ordinal->{
                   
              }
          }
           
           return null
      }
  }
}

续:Android进阶宝典 -- 告别繁琐的AIDL吧,手写IPC通信框架,5行代码实现进程间通信(下)

作者:layz4android

来源:juejin.cn/post/7192465342159912997

收起阅读 »

高仿B站自定义表情

在之前的文章给你的 Android App 添加自定义表情中我们介绍了自定义表情的原理,没看过的建议看一下。这一篇文章将介绍它的应用,这里以B站的自定义表情面板为例,效果如下:自定义表情的大小当我们写死表情的大小时,文字的 textSize 变大变小时都会有一...
继续阅读 »

在之前的文章给你的 Android App 添加自定义表情中我们介绍了自定义表情的原理,没看过的建议看一下。这一篇文章将介绍它的应用,这里以B站的自定义表情面板为例,效果如下:


自定义表情的大小

当我们写死表情的大小时,文字的 textSize 变大变小时都会有一点问题。

文字大于图片大小时,在多行的情况下,只有表情的行间距明显小于其他行的间距。如图:


为什么会出现这种情况呢?如下图所示,我在top, ascent, baseline, descent, bottom的位置标注了辅助线。


可以很清晰的看到,在只有表情的情况下,top, ascent, descent, bottom的位置有明显的问题。原因是 DynamicDrawableSpangetSize 方法里面对 FontMetricsInt 进行了修改。解决的方式很简单,就是注释掉修改代码就行,代码如下。修改后,效果如下图所示。

@Override
   public int getSize(@NonNull Paint paint, CharSequence text, int start, int end, @Nullable FontMetricsInt fm) {
         Drawable d = getDrawable();
         Rect rect = d.getBounds();
//
//       if (fm != null) {
//           fm.ascent = -rect.bottom;
//           fm.descent = 0;
//
//           fm.top = fm.ascent;
//           fm.bottom = 0;
//       }

       return rect.right;
  }


不知道你还记不记得,我们说过getSize 的返回值是表情的宽度。上面的注释代码其实是设置了表情的高度,如果文本的大小少于表情时,就会显示不全,如下图所示:


那这种情况下,应该怎么办?这里不卖关子了,最终代码如下。解决方式非常简单就是分情况来判断。当文本的高度小于表情的高度时,设置 fmtop, ascent, descent, bottom的值,让行的高度变大的同时让大的 emoji 图片居中。

 @Override
  public int getSize(@NonNull Paint paint, CharSequence text, int start, int end, @Nullable FontMetricsInt fm) {
      Drawable d = getDrawable();
      Rect rect = d.getBounds();

      float drawableHeight = rect.height();
      Paint.FontMetrics paintFm = paint.getFontMetrics();

      if (fm != null) {
          int textHeight = fm.bottom - fm.top;
          if(textHeight <= drawableHeight) {//当文本的高度小于表情的高度时
              //解决文字的大小小于图片大小的情况
              float textCenter = (paintFm.descent + paintFm.ascent) / 2;
              fm.ascent = fm.top = (int) (textCenter - drawableHeight / 2);
              fm.descent = fm.bottom = (int) (textCenter + drawableHeight / 2);
          }
      }
  return rect.right;
}

当然,你可能发现了,B站的 emoji 表情好像不是居中的。如下图所示,B站对 emoji 表情的处理类似基于 baseline 对齐。


上面最难理解的居中已经介绍,对于其他方式比如 baseline 和 bottom 就简单了。完整代码如下:

@Override
  public int getSize(@NonNull Paint paint, CharSequence text, int start, int end, @Nullable FontMetricsInt fm) {
      Drawable d = getDrawable();
      if(d == null) {
          return 48;
      }
      Rect rect = d.getBounds();

      float drawableHeight = rect.height();
      Paint.FontMetrics paintFm = paint.getFontMetrics();

      if (fm != null) {
          if (mVerticalAlignment == ALIGN_BASELINE) {
              fm.ascent = fm.top = (int) (paintFm.bottom - drawableHeight);
              fm.bottom = (int) (paintFm.bottom);
              fm.descent = (int) paintFm.descent;
          } else if(mVerticalAlignment == ALIGN_BOTTOM) {
              fm.ascent = fm.top = (int) (paintFm.bottom - drawableHeight);
              fm.bottom = (int) (paintFm.bottom);
              fm.descent = (int) paintFm.descent;
          } else if (mVerticalAlignment == ALIGN_CENTER) {
              int textHeight = fm.bottom - fm.top;
              if(textHeight <= rect.height()) {
                  float textCenter = (paintFm.descent + paintFm.ascent) / 2;
                  fm.ascent = fm.top = (int) (textCenter - drawableHeight / 2);
                  fm.descent = fm.bottom = (int) (textCenter + drawableHeight / 2);
              }
          }
      }

      return rect.right;
  }

动态表情

动态表情实际上就是 gif 图。我们可以使用 android-gif-drawable 来实现。在 build.gradle 中增加依赖:

dependencies {
  ...
  implementation 'pl.droidsonroids.gif:android-gif-drawable:1.2.25'
}

然后在我们创建自定义 ImageSpan 的时候传入参数就可以了:

val size = 192
val gifFromResource = GifDrawable(getResources(), gifData.drawableResource)
gifFromResource.stop()
gifFromResource.setBounds(0,0, size, size)
val content = mBinding.editContent.text as SpannableStringBuilder
val stringBuilder = SpannableStringBuilder(gifData.text)
stringBuilder.setSpan(BilibiliEmojiSpan(gifFromResource, ALIGN_BASELINE),
  0, stringBuilder.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)

关于 android-gif-drawable 更具体用法可以看 Android加载Gif动画android-gif-drawable的使用

作者:小墙程序员

来源:juejin.cn/post/7196592276159823931

收起阅读 »

android 微信抢红包工具 AccessibilityService

1、目标 使用AccessibilityService的方式,实现微信自动抢红包(吐槽一下,网上找了许多文档,由于各种原因,无法实现对应效果,所以先给自己整理下),关于AccessibilityService的文章,网上有很多(没错,多的都懒得贴链接那种多),...
继续阅读 »

1、目标


使用AccessibilityService的方式,实现微信自动抢红包(吐槽一下,网上找了许多文档,由于各种原因,无法实现对应效果,所以先给自己整理下),关于AccessibilityService的文章,网上有很多(没错,多的都懒得贴链接那种多),可自行查找。


2、实现流程


1、流程分析(这里只分析在桌面的情况)


我们把一个抢红包发的过程拆分来看,可以分为几个步骤:


收到通知 -> 点击通知栏 -> 点击红包 -> 点击开红包 -> 退出红包详情页


以上是一个抢红包的基本流程。


2、实现步骤


1、收到通知 以及 点击通知栏


接收通知栏的消息,介绍两种方式


Ⅰ、AccessibilityService

即通过AccessibilityService的AccessibilityEvent.TYPE_NOTIFICATION_STATE_CHANGED事件来获取到Notification


private fun handleNotification(event: AccessibilityEvent) {
val texts = event.text
if (!texts.isEmpty()) {
for (text in texts) {
val content = text.toString()
//如果微信红包的提示信息,则模拟点击进入相应的聊天窗口
if (content.contains("[微信红包]")) {
if (event.parcelableData != null && event.parcelableData is Notification) {
val notification: Notification? = event.parcelableData as Notification?
val pendingIntent: PendingIntent = notification!!.contentIntent
try {
pendingIntent.send()
} catch (e: CanceledException) {
e.printStackTrace()
}
}
}
}
}

}

Ⅱ、NotificationListenerService

这是监听通知栏的另一种方式,记得要获取权限哦


class MyNotificationListenerService : NotificationListenerService() {

override fun onNotificationPosted(sbn: StatusBarNotification?) {
super.onNotificationPosted(sbn)

val extras = sbn?.notification?.extras
// 获取接收消息APP的包名
val notificationPkg = sbn?.packageName
// 获取接收消息的抬头
val notificationTitle = extras?.getString(Notification.EXTRA_TITLE)
// 获取接收消息的内容
val notificationText = extras?.getString(Notification.EXTRA_TEXT)
if (notificationPkg != null) {
Log.d("收到的消息内容包名:", notificationPkg)
if (notificationPkg == "com.tencent.mm"){
if (notificationText?.contains("[微信红包]") == true){
//收到微信红包了
val intent = sbn.notification.contentIntent
intent.send()
}
}
}
Log.d("收到的消息内容", "Notification posted $notificationTitle & $notificationText")
}

override fun onNotificationRemoved(sbn: StatusBarNotification?) {
super.onNotificationRemoved(sbn)
}
}

2、点击红包


通过上述的跳转,可以进入聊天详情页面,到达详情页之后,接下来就是点击对应的红包卡片,那么问题来了,怎么点?肯定不是手动点。。。


我们来分析一下,一个聊天列表中,我们怎样才能识别到红包卡片,我看网上有通过findAccessibilityNodeInfosByViewId来获取对应的View,这个也可以,只是我们获取id的方式需要借助工具,可以用Android Device Monitor,但是这玩意早就废废弃了,虽然在sdk的目录下存在monitor,奈何本人太菜,点击就是打不开



我本地的jdk是11,我怀疑是不兼容,毕竟Android Device Monitor太老了。换新的layout Inspector,也就看看本地的debug应用,无法查看微信的呀。要么就反编译,这个就先不考虑了,换findAccessibilityNodeInfosByText这个方法试试。


这个方法从字面意思能看出来,是通过text来匹配的,我们可以知道红包卡片上面是有“微信红包”的固定字样的,是不是可以通股票这个来匹配呢,这还有个其他问题,并不是所有的红包都需要点,比如已过期,已领取的是不是要过滤下,咋一看挺好过滤的,一个循环就好,仔细想,这是棵树,不太好剔除,所以换了个思路。


最终方案就是递归一棵树,往一个列表里面塞值,“已过期”和“已领取”的塞一个字符串“#”,匹配到“微信红包”的塞一个AccessibilityNodeInfo,这样如果这个红包不能抢,那肯定一前一后分别是一个字符串和一个AccessibilityNodeInfo,因此,我们读到一个AccessibilityNodeInfo,并且前一个值不是字符串,就可以执行点击事件,代码如下


private fun getPacket() {
val rootNode = rootInActiveWindow
val caches:ArrayList<Any> = ArrayList()
recycle(rootNode,caches)
if(caches.isNotEmpty()){
for(index in 0 until caches.size){
if(caches[index] is AccessibilityNodeInfo && (index == 0 || caches[index-1] !is String )){
val node = caches[index] as AccessibilityNodeInfo
var parent = node.parent
while (parent != null) {
if (parent.isClickable) {
parent.performAction(AccessibilityNodeInfo.ACTION_CLICK)
break
}
parent = parent.parent
}
break
}
}
}

}

private fun recycle(node: AccessibilityNodeInfo,caches:ArrayList<Any>) {
if (node.childCount == 0) {
if (node.text != null) {
if ("已过期" == node.text.toString() || "已被领完" == node.text.toString() || "已领取" == node.text.toString()) {
caches.add("#")
}

if ("微信红包" == node.text.toString()) {
caches.add(node)
}
}
} else {
for (i in 0 until node.childCount) {
if (node.getChild(i) != null) {
recycle(node.getChild(i),caches)
}
}
}
}

以上只点击了第一个能点击的红包卡片,想点击所有的可另行处理。


3、点击开红包


这里思路跟上面类似,开红包页面比较简单,但是奈何开红包是个按钮,在不知道id的前提下,我们也不知道则呢么获取它,所以采用迂回套路,找固定的东西,我这里发现每个开红包的页面都有个“xxx的红包”文案,然后这个页面比较简单,只有个关闭,和开红包,我们通过获取“xxx的红包”对应的View来获取父View,然后递归子View,判断可点击的,执行点击事件不就可以了吗


private fun openPacket() {
val nodeInfo = rootInActiveWindow
if (nodeInfo != null) {
val list = nodeInfo.findAccessibilityNodeInfosByText ("的红包")
for ( i in 0 until list.size) {
val parent = list[i].parent
if (parent != null) {
for ( j in 0 until parent.childCount) {
val child = parent.getChild (j)
if (child != null && child.isClickable) {
child.performAction(AccessibilityNodeInfo.ACTION_CLICK)
}
}

}

}
}

}

4、退出红包详情页


这里回退也是个按钮,我们也不知道id,所以可以跟点开红包一样,迂回套路,获取其他的View,来获取父布局,然后递归子布局,依次执行点击事件,当然关闭事件是在前面的,也就是说关闭会优先执行到


private fun close() {
val nodeInfo = rootInActiveWindow
if (nodeInfo != null) {
val list = nodeInfo.findAccessibilityNodeInfosByText ("的红包")
if (list.isNotEmpty()) {
val parent = list[0].parent.parent.parent
if (parent != null) {
for ( j in 0 until parent.childCount) {
val child = parent.getChild (j)
if (child != null && child.isClickable) {
child.performAction(AccessibilityNodeInfo.ACTION_CLICK)
}
}

}

}
}
}

3、遇到问题


1、AccessibilityService收不到AccessibilityEvent.TYPE_NOTIFICATION_STATE_CHANGED事件


android碎片问题很正常,我这边是使用NotificationListenerService来替代的。


2、需要点击View的定位


简单是就是到页面应该点哪个View,找到相应的规则,来过滤出对应的View,这个规则是随着微信的改变而变化的,findAccessibilityNodeInfosByViewId最直接,但是奈何工具问题,有点麻烦,于是采用取巧的办法,通过找到其他View来定位自身


4、完整代码


MyNotificationListenerService


class MyNotificationListenerService : NotificationListenerService() {

override fun onNotificationPosted(sbn: StatusBarNotification?) {
super.onNotificationPosted(sbn)

val extras = sbn?.notification?.extras
// 获取接收消息APP的包名
val notificationPkg = sbn?.packageName
// 获取接收消息的抬头
val notificationTitle = extras?.getString(Notification.EXTRA_TITLE)
// 获取接收消息的内容
val notificationText = extras?.getString(Notification.EXTRA_TEXT)
if (notificationPkg != null) {
Log.d("收到的消息内容包名:", notificationPkg)
if (notificationPkg == "com.tencent.mm"){
if (notificationText?.contains("[微信红包]") == true){
//收到微信红包了
val intent = sbn.notification.contentIntent
intent.send()
}
}
} Log.d("收到的消息内容", "Notification posted $notificationTitle & $notificationText")
}

override fun onNotificationRemoved(sbn: StatusBarNotification?) {
super.onNotificationRemoved(sbn)
}
}

MyAccessibilityService


class RobService : AccessibilityService() {

override fun onAccessibilityEvent(event: AccessibilityEvent) {
val eventType = event.eventType
when (eventType) {
AccessibilityEvent.TYPE_NOTIFICATION_STATE_CHANGED -> handleNotification(event)
AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED, AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED -> {
val className = event.className.toString()
Log.e("测试无障碍",className)
if (className == "com.tencent.mm.ui.LauncherUI") {
getPacket()
} else if (className == "com.tencent.mm.plugin.luckymoney.ui.LuckyMoneyReceiveUI") {
openPacket()
} else if (className == "com.tencent.mm.plugin.luckymoney.ui.LuckyMoneyNotHookReceiveUI") {
openPacket()
} else if (className == "com.tencent.mm.plugin.luckymoney.ui.LuckyMoneyDetailUI") {
close()
}

}
}
}

/**
* 处理通知栏信息
*
* 如果是微信红包的提示信息,则模拟点击
*
* @param event
*/
private fun handleNotification(event: AccessibilityEvent) {
val texts = event.text
if (!texts.isEmpty()) {
for (text in texts) {
val content = text.toString()
//如果微信红包的提示信息,则模拟点击进入相应的聊天窗口
if (content.contains("[微信红包]")) {
if (event.parcelableData != null && event.parcelableData is Notification) {
val notification: Notification? = event.parcelableData as Notification?
val pendingIntent: PendingIntent = notification!!.contentIntent
try {
pendingIntent.send()
} catch (e: CanceledException) {
e.printStackTrace()
}
}
}
}
}

}

/**
* 关闭红包详情界面,实现自动返回聊天窗口
*/
@TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR2)
private fun close() {
val nodeInfo = rootInActiveWindow
if (nodeInfo != null) {
val list = nodeInfo.findAccessibilityNodeInfosByText ("的红包")
if (list.isNotEmpty()) {
val parent = list[0].parent.parent.parent
if (parent != null) {
for ( j in 0 until parent.childCount) {
val child = parent.getChild (j)
if (child != null && child.isClickable) {
child.performAction(AccessibilityNodeInfo.ACTION_CLICK)
}
}

}

}
}
}

/**
* 模拟点击,拆开红包
*/
@TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR2)
private fun openPacket() {
Log.e("测试无障碍","点击红包")
Thread.sleep(100)
val nodeInfo = rootInActiveWindow
if (nodeInfo != null) {
val list = nodeInfo.findAccessibilityNodeInfosByText ("的红包")
for ( i in 0 until list.size) {
val parent = list[i].parent
if (parent != null) {
for ( j in 0 until parent.childCount) {
val child = parent.getChild (j)
if (child != null && child.isClickable) {
Log.e("测试无障碍","点击红包成功")
child.performAction(AccessibilityNodeInfo.ACTION_CLICK)
}
}

}

}
}

}

/**
* 模拟点击,打开抢红包界面
*/
@TargetApi(Build.VERSION_CODES.JELLY_BEAN)
private fun getPacket() {
Log.e("测试无障碍","获取红包")
val rootNode = rootInActiveWindow
val caches:ArrayList<Any> = ArrayList()
recycle(rootNode,caches)
if(caches.isNotEmpty()){
for(index in 0 until caches.size){
if(caches[index] is AccessibilityNodeInfo && (index == 0 || caches[index-1] !is String )){
val node = caches[index] as AccessibilityNodeInfo
// node.performAction(AccessibilityNodeInfo.ACTION_CLICK)
var parent = node.parent
while (parent != null) {
if (parent.isClickable) {
parent.performAction(AccessibilityNodeInfo.ACTION_CLICK)
Log.e("测试无障碍","获取红包成功")
break
}
parent = parent.parent
}
break
}
}
}

}

/**
* 递归查找当前聊天窗口中的红包信息
*
* 聊天窗口中的红包都存在"微信红包"一词,因此可根据该词查找红包
*
* @param node
*/
private fun recycle(node: AccessibilityNodeInfo,caches:ArrayList<Any>) {
if (node.childCount == 0) {
if (node.text != null) {
if ("已过期" == node.text.toString() || "已被领完" == node.text.toString() || "已领取" == node.text.toString()) {
caches.add("#")
}

if ("微信红包" == node.text.toString()) {
caches.add(node)
}
}
} else {
for (i in 0 until node.childCount) {
if (node.getChild(i) != null) {
recycle(node.getChild(i),caches)
}
}
}
}

override fun onInterrupt() {}
override fun onServiceConnected() {
super.onServiceConnected()
Log.e("测试无障碍id","启动")
val info: AccessibilityServiceInfo = serviceInfo
info.packageNames = arrayOf("com.tencent.mm")
serviceInfo = info
}
}

5、总结


此文是对AccessibilityService的使用的一个梳理,这个功能其实不麻烦,主要是一些细节问题,像自动领取支付宝红包,自动领取QQ红包或者其他功能等也都可以用类似方法实现。


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

APT-单例代码规范检查

前文提到注解按照Retention可以取值可以分为SOURCE,CLASS,RUNTIME三类,在定义注解完成后,可以结合APT进行注解解析,读取到注解相关信息后进行一些检查和设置。 接下来我们实现一个单例注解来修饰单例类,当开发人员写的单例类不符合编码规范时...
继续阅读 »

前文提到注解按照Retention可以取值可以分为SOURCE,CLASS,RUNTIME三类,在定义注解完成后,可以结合APT进行注解解析,读取到注解相关信息后进行一些检查和设置。


接下来我们实现一个单例注解来修饰单例类,当开发人员写的单例类不符合编码规范时,在编译过程中抛出异常。大家都知道,单例类应该具有以下特点:



  • 构造器私有

  • 具有public static修饰的getInstance方法


打开Android Studio,新建SingletonAnnotationDemo工程,随后在该工程中进行注解的定义和APT的开发,一般情况下注解和与之关联的APT都会以单独的Module声明在项目中,下面我们开始实践吧。


singleton-annotation 注解模块


新建singleton-annotation Java模块

打开新建的SingletonAnnotationDemo项目,在右上角切换至Project视图,如下图所示:


1-7-2-1


切换完成后,在项目名称上右键单击,在弹出的菜单中依此选择new->Module,如下图所示:


1-7-2-2


选择Module条目后,弹出如下对话框,依次操作如下图所示:


1-7-2-3


其中标记1表明我们创建的是Java或者Kotlin模块,标记2位置填写模块名称,这里输入singleton-annotation,标记3位置输入打算创建的类名,这里填写Singleton,标记4位置用于选择模块语言类型,这里选择java即可。


至此创建singleton-annotation模块完成,等待Android Studio构建完成即可。


新建Singleton注解

打开新建的singleton-annotation模块,进入Singleton.java文件中将其修改为注解,如上文描述,该注解运行在编译期,故Retention为SOURCE,作用在类上,故其Target取值为TYPE,完整代码如下:


 package com.poseidon.singleton_annotation;
 
 import java.lang.annotation.ElementType;
 import java.lang.annotation.Retention;
 import java.lang.annotation.RetentionPolicy;
 import java.lang.annotation.Target;
 
 @Retention(RetentionPolicy.SOURCE)
 @Target(ElementType.TYPE)
 public @interface Singleton {
 }

依赖singleton-annotation模块

在app模块添加对singleton-annotation模块的依赖,操作方式有两种:




  • 手动添加singleton-annotation依赖


    打开app模块的build.gradle文件,在其内部手动添加依赖,如下所示:


     dependencies {
        ...
         // 添加singleton-annotation模块依赖
         implementation project(path: ':singleton-annotation')
     
     }

    随后重新同步项目即可




  • 使用AS菜单添加singleton-annotation依赖


    在app模块右键选择Open Module Settings,在随后弹出的弹窗中添加singleton-annotation模块,操作指导如下图所示:


    1-7-2-4


    选择Open Module Settings后弹框如下图所示,选择dependencies,代表依赖管理,随后在右侧的Module列表中选择你要操作的模块,这里选择app,最后点击选择app模块后,其右侧依赖列表中的加号,选择Module Dependency,代表添加模块依赖



    Module Dependency:模块依赖,一般用于添加项目中的其他模块作为依赖


    Library Dependency:库依赖,一般用于添加上传到maven,google,jcenter等位置的开源库


    JAR/AAR Dependency:一般用于添加本地已有的jar或aar文件作为依赖时使用



    1-7-2-5


    选择添加模块依赖后,弹出窗体如下图所示:


    1-7-2-6


    在上图中标记1的位置勾选要添加的模块,在2的位置选择依赖方式,随后点击OK等待同步完成即可。




singleton-processor 注解处理模块


与创建singleton-annotation模块一样,以相同的方式创建一个名为singleton-processor的模块,其内部有一个Processor类,创建完成后,项目模块如下图所示(Processor类为创建模块时输入的类名,AS自动生成的):


1-7-2-7


添加注解处理器声明

将Processor.java类作为我们的注解处理器类,为了Android Studio能识别到该类,我们需要对该类进行声明,通常有两种声明方式:




  • 手动声明


    手动声明的主要实现方式是在main目录下创建resources/META-INF/services目录,在该目录下创建javax.annotation.processing.Processor文件,其内容如下所示:


     com.poseidon.singleton_processor.Processor

    可以看到其内部写的是注解处理器类完整路径(包名+类名),当有多个注解处理器类时,可以写多行,每次放置一条注解处理器信息即可




  • 借助AutoService库自动声明


    除了手动声明外,我们可以借助auto-service库进行注解处理器声明,其本身也是依赖注解实现,在singleton-processor模块的build.gradle中添加auto-service库依赖,如下所示:


     dependencies {
         implementation 'com.google.auto.service:auto-service:1.0'
         annotationProcessor 'com.google.auto.service:auto-service:1.0'
     }

    依赖添加完成后,使用@AutoService注解修饰我们的注解处理器类,代码如下:


     @AutoService(Processor.class)
     public class Processor extends AbstractProcessor {
         @Override
         public boolean process(Set<? extends TypeElement> set, RoundEnvironment roundEnvironment) {
             return false;
        }
     }

    随后运行该项目,可以看到在singleton-processor模块的build目录中自动生成了META-INF相关的目录,如下图所示:


    1-7-2-8


    其中javax.annotation.processing.Processor文件内容和我们手动添加时的内容一致。



    当然也可以参考上文在Library Dependency窗口添加auto-service依赖,大家可以自行探索下





依赖singleton-processor模块

与依赖singleton-annotation模块时方法类似,由于singleton-processor模块是注解处理模块,故依赖方式应使用annotationProcessor,在app模块的build.gradle文件的dependencies块中添加代码如下:


 annotationProcessor project(path: ':singleton-processor')

至此我们已经完成了新增模块的依赖以及注解的声明,接下来我们来看看注解处理器的实现。


注解处理器代码实现


在前文中我们已经将singleton-processor模块的Processor类声明为注解处理器,接下来我们来看下如何在注解处理器中处理我们的@Singleton注解,并对使用该注解的单例类完成检查。


自定义注解处理器一般继承自AbstractProcessor,AbstractProcessor是一个抽象类,其父类是Processor,在类编译成.class文件前,遍历整个项目里的所有代码,在获取到对应注解后,回调注解处理器的process方法,以便对注解进行处理。


当继承AbstractProcessor时,我们一般重写下列函数:



























函数名称函数说明
void init(ProcessingEnvironment processingEnv)初始化处理器环境,这里可以缓存处理器环境,在process中发生异常等,可以打断通过缓存的变量打断编译执行
boolean process(Set<? extends TypeElement> set, RoundEnvironment roundEnvironment)处理方法,类或成员等的注释,并返回该处理器的处理结果。 如果返回true ,则表明注解被当前处理器处理,并且不会要求后续处理器继续处理; 如果返回false ,则表示未处理传入的注解,继续传递给后续处理器处理. RoundEnvironment参数用于查找使用了指定注解的元素,这里的元素有多种,方法,成员,类等,和ElementType取值范围一致
Set getSupportedAnnotationTypes()获取注解处理器要处理的注解类型,如果在注解处理器类上使用了@SupportedAnnotationTypes注解修饰,则这里返回的Set应和注解取值一致
SourceVersion getSupportedSourceVersion()注解处理器支持的Java版本,如果在注解处理器类上使用了@SupportedSourceVersion注解修饰,则这里返回的取值应该和注解取值一致

下面我们按照上述描述重写Processor代码如下:


 @AutoService(Processor.class)
 public class Processor extends AbstractProcessor {
     // 注解处理器运行环境
     private ProcessingEnvironment mProcessingEnvironment;
     @Override
     public synchronized void init(ProcessingEnvironment processingEnv) {
         super.init(processingEnv);
         mProcessingEnvironment = processingEnv;
    }
 
     @Override
     public boolean process(Set<? extends TypeElement> set, RoundEnvironment roundEnvironment) {
         return false;
    }
 
     @Override
     public Set<String> getSupportedAnnotationTypes() {
         return super.getSupportedAnnotationTypes();
    }
 
     @Override
     public SourceVersion getSupportedSourceVersion() {
         // 支持到最新的java版本
         return SourceVersion.latestSupported();
    }
 }

由于该处理器主要处理的是@Singleton注解,故getSupportedAnnotationTypes实现如下(singleton-processor模块依赖singleton-annotation模块):


 @Override
 public Set<String> getSupportedAnnotationTypes() {
     HashSet<String> hashSet = new HashSet<>();
     // 添加注解类的完整名称到HashSet中
     hashSet.add(Singleton.class.getCanonicalName());
     return hashSet;
 }

随后我们来看下process函数的实现,process内部逻辑实现一般分为三步:



  1. 获取代码中被使用该注解的所有元素,这里的元素指的是组成程序的元素,可能是程序包,类本身、类的变量、方法等

  2. 筛选符合要求的元素,根据注解的使用场景筛选第一步中得到的所有元素,比如Singleton这个注解作用于类,就从第一步的结果中筛选出所有的类元素

  3. 遍历筛选出的元素,按照预设规则进行检查


按照上述步骤实现的Singleton注解处理器的process函数如下所示:


@Override
public boolean process(Set<? extends TypeElement> set, RoundEnvironment roundEnvironment) {
// 1.通过RoundEnvironment查找所有使用了Singleton注解的Element
// 2.随后通过ElementFilter获取该元素里面的所有类元素
// 3.遍历所有的类元素,针对自己关注的方法字段进行处理
for (TypeElement typeElement: ElementFilter.typesIn(roundEnvironment.getElementsAnnotatedWith(Singleton.class))) {
// 检查构造函数
if (!checkPrivateConstructor(typeElement)) {
return false;
}
// 检查getInstance方法
if (!checkGetInstanceMethod(typeElement)) {
return false;
}
}
return true;
}


ElementFilter.typesIn就是用来筛选查找出来的结果中的类元素,在ElementFilter类内部定义了五个元素组,如下所示:



  • CONSTRUCTOR_KIND:构造器元素组

  • FIELD_KINDS:成员变量元素组

  • METHOD_KIND:方法元素组

  • PACKAGE_KIND:包元素组

  • MODULE_KIND:模块元素组

  • TYPE_KINDS:类元素组


其中类元素组囊括的最多,包括CLASS,ENUM,INTERFACE等



checkPrivateConstructor

public boolean checkPrivateConstructor(TypeElement typeElement) {
// 通过typeElement.getEnclosedElements()获取在此类或接口中直接声明的字段,方法等元素,随后使用ElementFilter.constructorsIn筛选出构造方法
List<ExecutableElement> constructors = ElementFilter.constructorsIn(typeElement.getEnclosedElements());
for (ExecutableElement constructor : constructors) {
// 判断构造方式是否是Private修饰的
if (constructor.getModifiers().isEmpty() || !constructor.getModifiers().contains(Modifier.PRIVATE)) {
mProcessingEnvironment.getMessager().printMessage(Diagnostic.Kind.ERROR, "constructor of a singleton class must be private", constructor);
return false;
}
}
return true;
}

checkPrivateConstructor实现逻辑如上,代码比较简单,不做赘述。


checkGetInstanceMethod

public boolean checkGetInstanceMethod(TypeElement typeElement) {
// 通过ElementFilter.constructorsIn筛选出该类中声明的所有方法
List<ExecutableElement> methods = ElementFilter.methodsIn(typeElement.getEnclosedElements());
for (ExecutableElement method : methods) {
System.out.println(TAG+method.getSimpleName());
// 检查方法名称
if (method.getSimpleName().contentEquals("getInstance")) {
// 检查方法返回类型
if (mProcessingEnvironment.getTypeUtils().isSameType(method.getReturnType(), typeElement.asType())) {
// 检查方法修饰符
if (!method.getModifiers().contains(Modifier.PUBLIC)) {
mProcessingEnvironment.getMessager().printMessage(Diagnostic.Kind.ERROR, "getInstance method should have a public modifier", method);
return false;
}
if (!method.getModifiers().contains(Modifier.STATIC)) {
mProcessingEnvironment.getMessager().printMessage(Diagnostic.Kind.ERROR, "getInstance method should have a static modifier", method);
return false;
}
}
}
}
return true;
}

checkGetInstanceMethod实现逻辑如上,可以看出当不满足我们预设条件时会通过printMessage向外抛出异常,中断编译执行。


使用Singleton注解,查看注解处理器效果


在app模块中添加SingleTest.java并应用注解,代码如下:


@Singleton
public class SingletonTest {
private SingletonTest(){}
private static SingletonTest getInstance(){
return new SingletonTest();
}
}

可以看到该代码存在问题,我们要求getInstance方法要用public static修饰,这里使用的是private,运行程序,看我们的注解处理器是否能发现该问题并打断程序执行,运行结果如下图:


1-7-2-9


可以看到程序确实停止运行,并抛出了编译时异常,至此我们自定义编译时注解的操作就学习完了。


扩展


在注解使用方法一节中,我们提到编译时注解即Retention=RetentionPolicy.SOURCE的注解仅在源码中保留,接下来我们验证一下,反编译前文中通过注解处理器检查正常运行的apk,找到SingletonTest类,可以看到在其字节码文件中确实不存在注解代码,如下图所示:


1-7-2-10


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

Flutter混编工程之异常处理

Flutter App层和Framework层的异常,通常是不会引起Crash的,但是Engine层的异常会造成Crash。而Flutter Engine部分的异常,主要是libfutter.so发生的异常,这部分的异常,在Dart层无法捕获,一般会交给类似B...
继续阅读 »

Flutter App层和Framework层的异常,通常是不会引起Crash的,但是Engine层的异常会造成Crash。而Flutter Engine部分的异常,主要是libfutter.so发生的异常,这部分的异常,在Dart层无法捕获,一般会交给类似Bugly这样的平台来收集。


我们能主动监控的,主要是Dart层的异常,这些异常虽然不会让App crash,但是统计这些异常对于提高我们的用户体验,是非常有必要的。


同步异常与异步异常


对于同步异常来说,直接使用try-catch就可以捕获异常,如果要指定捕获的异常类型,可以使用on关键字。但是,try-catch不能捕获异步异常,就像下面的代码,是无法捕获的。


try {
Future.error("error");
} catch (e){
print(e)
}

这和在Java中,try-catch捕获Thread中的异常类似,对于异步异常来说,只能使用Future的catchError或者是onError来捕获异常,代码如下所示。


Future.delayed(Duration(seconds: 1)).then((value) => print(value), onError: (e) {});

Dart的执行队列是一个单线程模型,所以在事件循环队列中,当某个Task发生异常并没有被捕获时,程序并不会退出,只是当前的Task异常中止,也就是说一个Task发生的异常是不会影响其它Task执行的。


Widget Build异常


Widget在Build过程中如果发生异常,例如在build函数中出错(throw exception),我们会看见一个深红色的异常界面,这个就是Flutter自带的异常处理界面,我们来看下源代码中,Flutter对这类异常的处理方式。在ComponentElement的实现中,我们找到performRebuild函数,这个是函数是build时所调用的,我们在这里,可以找到相关的实现。


如下所示,在执行到build()函数如果出错时,就会被catch,从而创建一个ErrorWidget。
image-20220412151724451.png
再进入_debugReportException中一探究竟,你会发现,应用层的异常被catch之后,都是通过FlutterError.reportError来处理的。
image-20220412152002822.png
在reportError中,会调用onError来处理,默认的处理方式是dumpErrorToConsole,它就是onError的默认实现。
image-20220412153627080.png



在这里我们还能发现如何判断debug模式,看源码是不是很有意思。



通过上面的源码,我们就可以了解到,当Flutter应用层崩溃后,SDK的处理,简而言之,就是会构建一个错误界面,同时回调onError函数。在这里,我们可以通过修改这个静态的回调函数,来创建自己的处理方式。
image-20220414145625129.png
所以,很简单,我们只需要在main()中,执行下面的代码即可。


var defaultError = FlutterError.onError;
FlutterError.onError = (FlutterErrorDetails details) {
defaultError?.call(details);// 根据需要是否要保留default处理
reportException(details);
};

defaultError?.call(details)就是默认将异常日志打印到console的方法,如果不用,这里可以去掉。


重写错误界面


前面我们看到了,在源代码中,Flutter自定义了一个ErrorWidget作为默认的异常界面,在平时的开发中,我们可以自定义ErrorWidget.builder,实现一个更友好的错误界面,例如封装一个统一的异常提示界面。


ErrorWidget.builder = (FlutterErrorDetails details) {
return MaterialApp(
theme: ThemeData(primarySwatch: Colors.red),
home: Scaffold(
appBar: AppBar(
title: const Text('出错了,请稍后再试'),
),
body: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Text(details.toString()), // 后续修改为统一的错误页
)),
),
);
};

如上所示,通过修改ErrorWidget.builder,就可以将任意自定义的界面作为异常界面了。


全局未捕获异常


前面讲到的,都是属于被捕获的异常,而有一些异常,在代码中是没有被捕获的,这就类似Android的UncaughtExceptionHandler,Flutter也提供了一个全局的异常处理钩子函数,所有的未捕获异常,无论是同步异常还是异步异常,都会在这里被监听。


在Dart中,SDK提供了一个Zone的概念,一个Zone就类似一个沙箱,在Zone里面,可以拥有独立的异常处理、print函数等等功能,多个Zone之间是彼此独立的,所以,我们只需要将App运行在一个Zone里面,就可以借助它的handleUncaughtError来处理所有的未捕获异常了。下面是使用Zone的一个简单示例。


void main() {
runZoned(
() => runApp(const MyApp(color: Colors.blue)),
zoneSpecification: ZoneSpecification(
handleUncaughtError: (
Zone self,
ZoneDelegate parent,
Zone zone,
Object error,
StackTrace stackTrace,
) {
reportException(
FlutterErrorDetails(
exception: error,
stack: stackTrace,
),
);
},
),
);
}

根据文档中的提升,可以使用runZonedGuarded来进行简化,代码如下所示。


void main() {
runZonedGuarded(
() => runApp(const MyApp(color: Colors.blue)),
(Object error, StackTrace stack) {
reportException(
FlutterErrorDetails(
exception: error,
stack: stack,
),
);
},
);
}

封装


下面我们将前面的异常处理方式都合并到一起,并针对EngineGroup的多入口处理,封装一个类,代码如下所示。


class SafeApp {
run(Widget app) {
ErrorWidget.builder = (FlutterErrorDetails details) {
return MaterialApp(
theme: ThemeData(primarySwatch: Colors.red),
home: Scaffold(
appBar: AppBar(
title: const Text('出错了,请稍后再试'),
),
body: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Text(details.toString()), // 后续修改为统一的错误页
)),
),
);
};
FlutterError.onError = (FlutterErrorDetails details) {
Zone.current.handleUncaughtError(details.exception, details.stack!);
};
runZonedGuarded(
() => runApp(const MyApp(color: Colors.blue)),
(Object error, StackTrace stack) {
reportException(
FlutterErrorDetails(
exception: error,
stack: stack,
),
);
},
);
}
}

在这里,我们构建了下面这些异常处理的方式:



  • 统一的异常处理界面

  • 将Build异常统一转发到Zone中的异常处理函数来进行处理

  • 将所有的未捕获异常记录


这样的话,我们在使用时,只需要对原始的App进行下调用即可。


void main() => SafeApp().run(const MyApp(color: Colors.blue));

这样就完成了异常处理的封装。


上报


在Flutter侧,我们只是获取了异常的相关信息,如果需要上报,那么我们需要借助Channel,桥接的Native,使用Bugly或其它平台进行上报,我们可以借助Pigeon来进行处理,还不熟悉的朋友可以参考我前面的文章。
Flutter混编工程之高速公路Pigeon
Flutter混编工程之通讯之路
通过Channel,我们可以把异常数据报给Native侧,再让Native侧走自己的上报通道,例如Bugly等。


NativeCommonApi().reportException('------Flutter_Exception------\n${details.exceptionAsString()}\n${details.stack.toString()}');

同时,Flutter提供了exceptionAsString()方法,将异常信息展示的更加友好一点,我们可以借助它来做一些格式化的操作。


3.3版本API的改进


官方的API更新如下:
docs.flutter.dev/testing/err…
PlatformDispatcher.onError在以前的版本中,开发者必须手动配置自定义Zone才能捕获应用程序的所有异常和错误,但是自定义Zone对Dart核心库中的一些优化是有害的,这会减慢应用程序的启动时间。「在此版本中,开发者可以通过设置回调来捕获所有错误和异常,而不是使用自定义。」


所以,3.3之后,我们不用再设置Zone来捕获全局异常了,只用设置PlatformDispatcher.instance.onError即可。


import 'package:flutter/material.dart';
import 'dart:ui';

Future<void> main() async {
await myErrorsHandler.initialize();
FlutterError.onError = (details) {
FlutterError.presentError(details);
myErrorsHandler.onErrorDetails(details);
};
PlatformDispatcher.instance.onError = (error, stack) {
myErrorsHandler.onError(error, stack);
return true;
};
runApp(const MyApp());
}

class MyApp extends StatelessWidget {
const MyApp({super.key});

@override
Widget build(BuildContext context) {
return MaterialApp(
builder: (context, widget) {
Widget error = const Text('...rendering error...');
if (widget is Scaffold || widget is Navigator) {
error = Scaffold(body: Center(child: error));
}
ErrorWidget.builder = (errorDetails) => error;
if (widget != null) return widget;
throw ('widget is null');
},
);
}
}

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

不就是一个空白页,有必要那么讲究吗?

前言 在各类软件应用中,会经常遇到空页面的情况,比如列表无数据、搜不到相应结果、用户数据没有添加等等。这种空页面看似很少出现,但是如果不注意体验的话,会让用户不快甚至是困惑。今天我们就来讲讲针对各类空页面,如何改善用户体验。 列表无数据 页面无数据通常会在列表...
继续阅读 »

前言


在各类软件应用中,会经常遇到空页面的情况,比如列表无数据、搜不到相应结果、用户数据没有添加等等。这种空页面看似很少出现,但是如果不注意体验的话,会让用户不快甚至是困惑。今天我们就来讲讲针对各类空页面,如何改善用户体验。


列表无数据


页面无数据通常会在列表类页面中出现,最为简单的方式就是用一个空图标+文字说明的方式告诉用户查询的结果为空,比如下面这样。


image.png


这是中规中矩的无数据空页面,遇到过奇葩的情况是直接给一个白屏 —— 你这是告诉用户是数据加载不出来呢还是没数据呢?
相比这种静态的空页面,我们可以使用 Lottie 加载一些带动效的无数据指示,会让用户体验好很多。而需要写的代码其实并没有几行。


empty-gif.gif


Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
title: const Text('抱歉'),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Lottie.asset(
'assets/empty.json',
repeat: true,
width: 200.0,
),
Text(
'暂无数据',
style: TextStyle(
color: Colors.grey[400],
fontSize: 14.0,
),
),
],
),
),
);
}

找不到搜索结果


对于搜索,在没有搜到对应的数内容时,相比给一个空页面,给一些用户感兴趣的相似内容可能会更好,一方面可以让用户浏览替代的内容,另一方面可以在一定程度上提高转化率。典型的例子就是在商品搜索的时候,如果没有找到对应的商品,会推荐系统里相关的商品,比如下面是京东的例子。


image.png


用户数据没有添加


这是需要用户主动提交才会有数据的情况。糟糕的体验是只告诉用户没有数据,而没有引导用户去添加数据。比如我们的地址管理,我们来看到下面两种体验,一对比高下立现。


image.png


guide-empty.gif


第一种一个是添加地址的按钮不够显眼,另外就是在需要用户操作的时候,缺乏引导。这会导致首次使用该功能的用户很迷茫,一时不知道从哪里添加收货地址。相比之下,下面的实现方式按钮位置更明显,而且通过动画能够让用户清楚地知道可以通过点击下面的按钮添加收货地址。


第二种方式实现的代码如下所示,这里的引导动画效果使用了 AnimatedPositioned 组件实现(相关文章可以参考:🚀🚀🚀庆祝神舟十三号发射成功,来一个火箭发射动画)。


Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('收货地址'),
),
body: Stack(
children: [
Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Image.asset('assets/common-empty.png', width: 100.0),
Text(
'暂无收货地址',
style: TextStyle(
color: Colors.grey[400],
fontSize: 16.0,
height: 2,
),
),
],
),
),
AnimatedPositioned(
duration: const Duration(milliseconds: 500),
bottom: _bottom,
height: guideIconSize,
left: MediaQuery.of(context).size.width / 2 - guideIconSize / 2,
onEnd: () {
setState(() {
if (_bottom == minBottom) {
_bottom = maxBottom;
} else {
_bottom = minBottom;
}
});
},
child: Icon(Icons.arrow_downward,
color: Theme.of(context).primaryColor, size: guideIconSize),
)
],
),
bottomSheet: Container(
height: 50.0,
width: MediaQuery.of(context).size.width,
color: Theme.of(context).primaryColor,
margin: const EdgeInsets.all(0.0),
child: TextButton(
onPressed: () {
if (kDebugMode) {
print('跳到添加地址!');
}
},
child: const Text('添加收货地址', style: TextStyle(color: Colors.white)),
),
),
);
}

网络连接问题


网络连接偶尔会出现短时间连接断开导致无法加载后端数据的问题,这个时候可不能直接放个网络错误的指示页面就完了,比如下面这样。
network-error.gif
动画确实改善了用户体验,但没有解决根本问题。我们来看一下我们自己网络连接有问题的时候的处理步骤:



  1. 如果手机的网络连接没问题,我们会希望当前页面能够重新加载;

  2. 如果手机网络有问题,我们可能会切换网络(比如切换到4G 网络),然后还是希望能够重新加载。


这个空页面没有提供重新加载的功能,这意味着用户需要返回到上一个页面,找到之前点击的内容,然后再进入这个页面来达到再次刷新的目的。这额外多了两个步骤,而且还需要用户记住之前点击的内容,体验就不怎么好了。这种情况只需要提供一个重新加载的按钮,体验就会好很多了。


image.png


总结


总结一下,如何提高空页面的用户体验,针对我们提到的4种情况有对应的4个原则:



  1. 对于确实无数据的情况,给出有好的提示,比如实用动画+文字的形式。千万不要认为反正后台有数据,不会出现空页面而什么都不做——结果就是让用户看着白屏一脸懵逼!

  2. 用户输入的搜索词可能会非常长(比如复制京东的商品名称去淘宝搜),很可能搜不到结果。如果可能,建议对于搜索词长的情况能够匹配一些标签,通过标签搜相关的内容推荐给用户,这比搜不到给一个空白页面的体验会好很多,而且海还会促进应用的内容或商品消费。

  3. 对于需要用户执行添加动作才会有的数据(比如收货地址、收藏夹、好友等),要给出合理的引导,让用户能够顺利地完成相应的动作,而不是让用户自己摸索。

  4. 对于因为网络、本机授权等导致出现错误无法加载数据的情况,除了给出友好的提示之外,要同时能够提供类似重新加载的功能,便于用户解决本机问题后,能够回来直接重新加载页面内容。


从心理上来说,人对于空白状态都是有点畏惧的。因此,开发出好的体验的空页面就是需要给用户合理的解释和必要的引导,让空页面不那么空!


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

Android 通过MotionLayot实现点赞动画

在之前的文章Android 一种点赞动画的实现中,通过Animation和Animator实现了一种点赞动画效果,有知友评论用MotionLayout是否会比较简单。我之前还没有使用过MotionLayout,刚好通过实现这个点赞动画来学习一下MotionLa...
继续阅读 »

在之前的文章Android 一种点赞动画的实现中,通过AnimationAnimator实现了一种点赞动画效果,有知友评论用MotionLayout是否会比较简单。我之前还没有使用过MotionLayout,刚好通过实现这个点赞动画来学习一下MotionLayout的使用。


MotionLayout


MotionLayoutConstraintLayout的子类,包含在ConstraintLayout库中,在ConstraintLayout的基础上,增加了管理控件动画的功能。


官方文档


添加库


如果之前没有使用ConstraintLayout,那么需要在app module下的build.gradle中添加代码,如下:


dependencies {
// 项目中使用AndroidX
implementation 'androidx.constraintlayout:constraintlayout:2.0.0-beta1'

// 项目中未使用AndroidX
implementation 'com.android.support.constraint:constraint-layout:2.0.0-beta1'
}

点赞效果的实现


尝试使用MotionLayout来实现之前的点赞动画,最终实现了缩放以及发散效果。


布局中添加MotionLayout


<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:motion="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">

<!--根节点改为使用MotionLayout-->
<!--layoutDescription 配置MotionScene配置文件-->
<!--showPaths设置是否显示动画的轨迹-->
<androidx.constraintlayout.motion.widget.MotionLayout
android:id="@+id/motion_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
motion:layoutDescription="@xml/example_motion_scene"
tools:showPaths="true">

<include
android:id="@+id/include_title"
layout="@layout/layout_title" />

<androidx.appcompat.widget.AppCompatImageView
android:id="@+id/iv_thumb_up"
android:layout_width="25dp"
android:layout_height="25dp"
android:layout_marginTop="2dp"
android:src="@drawable/icon_thumb_up"
motion:layout_constraintBottom_toBottomOf="parent"
motion:layout_constraintEnd_toEndOf="parent"
motion:layout_constraintStart_toStartOf="parent"
motion:layout_constraintTop_toTopOf="parent" />

<androidx.appcompat.widget.AppCompatImageView
android:id="@+id/iv_thumb_up1"
android:layout_width="25dp"
android:layout_height="25dp"
android:layout_marginTop="2dp"
android:src="@drawable/icon_thumb_up"
android:visibility="gone"
motion:layout_constraintBottom_toBottomOf="parent"
motion:layout_constraintEnd_toEndOf="parent"
motion:layout_constraintStart_toStartOf="parent"
motion:layout_constraintTop_toTopOf="parent" />

<androidx.appcompat.widget.AppCompatImageView
android:id="@+id/iv_thumb_up2"
android:layout_width="25dp"
android:layout_height="25dp"
android:layout_marginTop="2dp"
android:src="@drawable/icon_thumb_up"
android:visibility="gone"
motion:layout_constraintBottom_toBottomOf="parent"
motion:layout_constraintEnd_toEndOf="parent"
motion:layout_constraintStart_toStartOf="parent"
motion:layout_constraintTop_toTopOf="parent" />

<androidx.appcompat.widget.AppCompatImageView
android:id="@+id/iv_thumb_up3"
android:layout_width="25dp"
android:layout_height="25dp"
android:layout_marginTop="2dp"
android:src="@drawable/icon_thumb_up"
android:visibility="gone"
motion:layout_constraintBottom_toBottomOf="parent"
motion:layout_constraintEnd_toEndOf="parent"
motion:layout_constraintStart_toStartOf="parent"
motion:layout_constraintTop_toTopOf="parent" />

<androidx.appcompat.widget.AppCompatImageView
android:id="@+id/iv_thumb_up4"
android:layout_width="25dp"
android:layout_height="25dp"
android:layout_marginTop="2dp"
android:src="@drawable/icon_thumb_up"
android:visibility="gone"
motion:layout_constraintBottom_toBottomOf="parent"
motion:layout_constraintEnd_toEndOf="parent"
motion:layout_constraintStart_toStartOf="parent"
motion:layout_constraintTop_toTopOf="parent" />

<androidx.appcompat.widget.AppCompatImageView
android:id="@+id/iv_thumb_up5"
android:layout_width="25dp"
android:layout_height="25dp"
android:layout_marginTop="2dp"
android:src="@drawable/icon_thumb_up"
android:visibility="gone"
motion:layout_constraintBottom_toBottomOf="parent"
motion:layout_constraintEnd_toEndOf="parent"
motion:layout_constraintStart_toStartOf="parent"
motion:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.motion.widget.MotionLayout>
</layout>

创建MotionScene配置文件


<?xml version="1.0" encoding="utf-8"?>
<MotionScene xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:motion="http://schemas.android.com/apk/res-auto">

<!--配置动画的属性-->
<!--duration 配置动画的持续时间-->
<!--constraintSetStart 配置动画开始时,控件集的状态-->
<!--constraintSetEnd 配置动画结束时,控件集的状态-->
<!--motionInterpolator 配置动画的插值器,-->
<Transition
android:id="@+id/transition_thumb"
android:duration="1500"
motion:constraintSetEnd="@id/thumb_end"
motion:constraintSetStart="@id/thumb_start"
motion:motionInterpolator="linear">

<!--点击时触发动画-->
<!--targetId 配置触发事件的控件id-->
<!--clickAction 配置点击触发的效果-->
<!--clickAction toggle 当前控件集为开始状态,则播放动画切换至结束状态,反之亦然-->
<!--clickAction transitionToEnd 播放控件集开始到结束的动画-->
<!--clickAction transitionToStart 播放控件集结束到开始的动画-->
<!--clickAction jumpToEnd 不播放动画,控件集直接切换至结束状态-->
<!--clickAction jumpToStart 不播放动画,控件集直接切换至开始状态-->
<OnClick
motion:clickAction="transitionToEnd"
motion:targetId="@id/iv_thumb_up" />

<!--关键帧集合,用于实现缩放效果-->
<KeyFrameSet>

<!--修改属性-->
<!--framePosition 取值范围为0-100-->
<!--motionTarget 设置修改的对象-->
<!--scaleX 设置x轴缩放大小-->
<!--scaleY 设置y轴缩放大小-->
<KeyAttribute
android:scaleX="1.6"
android:scaleY="1.6"
motion:framePosition="25"
motion:motionTarget="@id/iv_thumb_up" />

<KeyAttribute
android:scaleX="1"
android:scaleY="1"
motion:framePosition="50"
motion:motionTarget="@id/iv_thumb_up" />
</KeyFrameSet>
</Transition>

<!--控件集 动画开始时的状态-->
<ConstraintSet android:id="@+id/thumb_start">

<!--与layout文件中的控件对应-->
<!--visibilityMode 如果需要改变控件的可见性,需要将此字段配置为ignore-->
<Constraint
android:id="@+id/iv_thumb_up"
android:layout_width="25dp"
android:layout_height="25dp"
android:layout_marginTop="2dp"
motion:layout_constraintBottom_toBottomOf="parent"
motion:layout_constraintEnd_toEndOf="parent"
motion:layout_constraintStart_toStartOf="parent"
motion:layout_constraintTop_toTopOf="parent"
motion:visibilityMode="ignore" />

<Constraint
android:id="@+id/iv_thumb_up1"
android:layout_width="25dp"
android:layout_height="25dp"
android:layout_marginTop="2dp"
motion:layout_constraintBottom_toBottomOf="parent"
motion:layout_constraintEnd_toEndOf="parent"
motion:layout_constraintStart_toStartOf="parent"
motion:layout_constraintTop_toTopOf="parent"
motion:visibilityMode="ignore">

<!--改变控件的属性-->
<!--attributeName 属性名-->
<!--customFloatValue Float类型属性值-->
<CustomAttribute
motion:attributeName="alpha"
motion:customFloatValue="1" />
</Constraint>

<Constraint
android:id="@+id/iv_thumb_up2"
android:layout_width="25dp"
android:layout_height="25dp"
android:layout_marginTop="2dp"
motion:layout_constraintBottom_toBottomOf="parent"
motion:layout_constraintEnd_toEndOf="parent"
motion:layout_constraintStart_toStartOf="parent"
motion:layout_constraintTop_toTopOf="parent"
motion:visibilityMode="ignore">

<CustomAttribute
motion:attributeName="alpha"
motion:customFloatValue="1" />
</Constraint>

<Constraint
android:id="@+id/iv_thumb_up3"
android:layout_width="25dp"
android:layout_height="25dp"
android:layout_marginTop="2dp"
motion:layout_constraintBottom_toBottomOf="parent"
motion:layout_constraintEnd_toEndOf="parent"
motion:layout_constraintStart_toStartOf="parent"
motion:layout_constraintTop_toTopOf="parent"
motion:visibilityMode="ignore">

<CustomAttribute
motion:attributeName="alpha"
motion:customFloatValue="1" />
</Constraint>

<Constraint
android:id="@+id/iv_thumb_up4"
android:layout_width="25dp"
android:layout_height="25dp"
android:layout_marginTop="2dp"
motion:layout_constraintBottom_toBottomOf="parent"
motion:layout_constraintEnd_toEndOf="parent"
motion:layout_constraintStart_toStartOf="parent"
motion:layout_constraintTop_toTopOf="parent"
motion:visibilityMode="ignore">

<CustomAttribute
motion:attributeName="alpha"
motion:customFloatValue="1" />
</Constraint>

<Constraint
android:id="@+id/iv_thumb_up5"
android:layout_width="25dp"
android:layout_height="25dp"
android:layout_marginTop="2dp"
motion:layout_constraintBottom_toBottomOf="parent"
motion:layout_constraintEnd_toEndOf="parent"
motion:layout_constraintStart_toStartOf="parent"
motion:layout_constraintTop_toTopOf="parent"
motion:visibilityMode="ignore">

<CustomAttribute
motion:attributeName="alpha"
motion:customFloatValue="1" />
</Constraint>
</ConstraintSet>

<!--控件集 动画结束时的状态-->
<ConstraintSet android:id="@+id/thumb_end">

<Constraint
android:id="@+id/iv_thumb_up"
android:layout_width="25dp"
android:layout_height="25dp"
android:layout_marginTop="2dp"
motion:layout_constraintBottom_toBottomOf="parent"
motion:layout_constraintEnd_toEndOf="parent"
motion:layout_constraintStart_toStartOf="parent"
motion:layout_constraintTop_toTopOf="parent"
motion:visibilityMode="ignore" />

<Constraint
android:id="@+id/iv_thumb_up1"
android:layout_width="25dp"
android:layout_height="25dp"
android:layout_marginTop="110dp"
android:layout_marginEnd="90dp"
motion:layout_constraintBottom_toBottomOf="parent"
motion:layout_constraintEnd_toEndOf="parent"
motion:layout_constraintStart_toStartOf="parent"
motion:layout_constraintTop_toTopOf="parent"
motion:visibilityMode="ignore">

<CustomAttribute
motion:attributeName="alpha"
motion:customFloatValue="0.5" />
</Constraint>

<Constraint
android:id="@+id/iv_thumb_up2"
android:layout_width="25dp"
android:layout_height="25dp"
android:layout_marginStart="70dp"
android:layout_marginTop="95dp"
motion:layout_constraintBottom_toBottomOf="parent"
motion:layout_constraintEnd_toEndOf="parent"
motion:layout_constraintStart_toStartOf="parent"
motion:layout_constraintTop_toTopOf="parent"
motion:visibilityMode="ignore">

<CustomAttribute
motion:attributeName="alpha"
motion:customFloatValue="0.4" />
</Constraint>

<Constraint
android:id="@+id/iv_thumb_up3"
android:layout_width="25dp"
android:layout_height="25dp"
android:layout_marginEnd="85dp"
android:layout_marginBottom="140dp"
motion:layout_constraintBottom_toBottomOf="parent"
motion:layout_constraintEnd_toEndOf="parent"
motion:layout_constraintStart_toStartOf="parent"
motion:layout_constraintTop_toTopOf="parent"
motion:visibilityMode="ignore">

<CustomAttribute
motion:attributeName="alpha"
motion:customFloatValue="0.6" />
</Constraint>

<Constraint
android:id="@+id/iv_thumb_up4"
android:layout_width="25dp"
android:layout_height="25dp"
android:layout_marginStart="60dp"
android:layout_marginBottom="120dp"
motion:layout_constraintBottom_toBottomOf="parent"
motion:layout_constraintEnd_toEndOf="parent"
motion:layout_constraintStart_toStartOf="parent"
motion:layout_constraintTop_toTopOf="parent"
motion:visibilityMode="ignore">

<CustomAttribute
motion:attributeName="alpha"
motion:customFloatValue="0.2" />
</Constraint>

<Constraint
android:id="@+id/iv_thumb_up5"
android:layout_width="25dp"
android:layout_height="25dp"
android:layout_marginEnd="20dp"
android:layout_marginBottom="60dp"
motion:layout_constraintBottom_toBottomOf="parent"
motion:layout_constraintEnd_toEndOf="parent"
motion:layout_constraintStart_toStartOf="parent"
motion:layout_constraintTop_toTopOf="parent"
motion:visibilityMode="ignore">

<CustomAttribute
motion:attributeName="alpha"
motion:customFloatValue="0" />
</Constraint>
</ConstraintSet>
</MotionScene>

监听动画状态


class MotionLayoutExampleActivity : AppCompatActivity() {

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val binding: LayoutMotionLayoutExampleActivityBinding = DataBindingUtil.setContentView(this, R.layout.layout_motion_layout_example_activity)
binding.motionLayout.setTransitionListener(object : MotionLayout.TransitionListener {
override fun onTransitionStarted(motionLayout: MotionLayout?, startId: Int, endId: Int) {
// 动画开始
// 把发散的按钮显示出来
binding.ivThumbUp1.visibility = View.VISIBLE
binding.ivThumbUp2.visibility = View.VISIBLE
binding.ivThumbUp3.visibility = View.VISIBLE
binding.ivThumbUp4.visibility = View.VISIBLE
binding.ivThumbUp5.visibility = View.VISIBLE
}

override fun onTransitionChange(motionLayout: MotionLayout?, startId: Int, endId: Int, progress: Float) {
// 动画进行中
}

override fun onTransitionCompleted(motionLayout: MotionLayout?, currentId: Int) {
// 动画完成
// 隐藏发散的按钮,将状态还原
binding.root.postDelayed({
binding.ivThumbUp1.visibility = View.GONE
binding.ivThumbUp2.visibility = View.GONE
binding.ivThumbUp3.visibility = View.GONE
binding.ivThumbUp4.visibility = View.GONE
binding.ivThumbUp5.visibility = View.GONE
binding.motionLayout.progress = 0f
}, 200)
}

override fun onTransitionTrigger(motionLayout: MotionLayout?, triggerId: Int, positive: Boolean, progress: Float) {

}
})
}
}

示例


已整合到demo中。


ExampleDemo github


ExampleDemo gitee


效果如图:


device-2023-02-04-103100.gif

大致还原了之前的动画效果,MotionLayout实现起来确实不复杂,但是目前还没有找到如何设置动画开始前的延时,因此点击完之后按钮的缩放效果与发散效果之间的间隔、发散出去的按钮之间的间隔无法完全复原。


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

Flutter 小技巧之 3.7 性能优化 background isolate

Flutter 3.7 的 background isolate 绝对是一大惊喜,尽管它在 release note 里被一笔带过 ,但是某种程度上它可以说是 3.7 里最实用的存在:因为使用简单,提升又直观。 Background isolate YYDS...
继续阅读 »

Flutter 3.7 的 background isolate 绝对是一大惊喜,尽管它在 release note 里被一笔带过 ,但是某种程度上它可以说是 3.7 里最实用的存在:因为使用简单,提升又直观



Background isolate YYDS



前言


我们知道 Dart 里可以通过新建 isolate 来执行”真“异步任务,而本身我们的 Dart 代码也是运行在一个独立的 isolate 里(简称 root isolate),而 isolate 之间不共享内存,只能通过消息传递在 isolates 之间交换状态。



所以 Dart 里不像 Java 一样需要线程锁。



而在 Dart 2.15 里新增了 isolate groups 的概念,isolate groups 中的 isolate 共享程序里的各种内部数据结构,也就是虽然 isolate groups 还是不允许 isolate 之间共享可变对象,但 groups 可以通过共享堆来实现结构共享,例如:



Dart 2.15 后可以将对象直接从一个 isolate 传递到另一 isolate,而在此之前只支持基础数据类型。



那么如果使用场景来到 Flutter Plugin ,在 Flutter 3.7 之前,我们只能从 root isolate 去调用 Platform Channels ,如果你尝试从其他 isolate 去调用 Platform Channels ,就会收获这样的错误警告:




例如,在 Flutter 3.7 之前,Platform Channels 是和 _DefaultBinaryMessenger 这个全局对象进行通信,但是一但切换了 isolate ,它就会变为 null ,因为 isolate 之间不共享内存。



而从 Flutter 3.7 开始,简单地说,Flutter 会通过新增的 BinaryMessenger 来实现非 root isolate 也可以和 Platform Channels 直接通信,例如:



我们可以在全新的 isolate 里,通过 Platform Channels 获取到平台上的原始图片后,在这个独立的 isolate 进行一些数据处理,然后再把数据返回给 root isolate ,这样数据处理逻辑既可以实现跨平台通用,又不会卡顿 root isolate 的运行。



Background isolate


现在 Flutter 在 Flutter 3.7 里引入了 RootIsolateTokenBackgroundIsolateBinaryMessenger 两个对象,当 background isolate 调用 Platform Channels 时, background isolate 需要和 root isolate 建立关联,所以在 API 使用上大概会是如下代码所示:


RootIsolateToken rootIsolateToken =
RootIsolateToken.instance!;

Isolate.spawn((rootIsolateToken) {
doFind2(rootIsolateToken);
}, rootIsolateToken);

doFind2(RootIsolateToken rootIsolateToken) {
// Register the background isolate with the root isolate.
BackgroundIsolateBinaryMessenger
.ensureInitialized(rootIsolateToken);
//......
}

通过 RootIsolateToken 的单例,我们可以获取到当前 root isolate 的 Token ,然后在调用 Platform Channels 之前通过 ensureInitialized 将 background isolate 需要和 root isolate 建立关联。



大概就是 token 会被注册到 DartPluginRegistrant 里,然后 BinaryMessenger_findBinaryMessenger 时会通过 BackgroundIsolateBinaryMessenger.instance 发送到对应的 listener



完整代码如下所示,逻辑也很简单,就是在 root isolate 里获取 RootIsolateToken ,然后在调用 Platform Channels 之前 ensureInitialized 关联 Token 。


 InkWell(
onTap: () {
///获取 Token
RootIsolateToken rootIsolateToken =
RootIsolateToken.instance!;
Isolate.spawn(doFind, rootIsolateToken);
},

////////////////

doFind(rootIsolateToken) async {
/// 注册 root isolaote
BackgroundIsolateBinaryMessenger.ensureInitialized(rootIsolateToken);

///获取 sharedPreferencesSet 的 isDebug 标识位
final Future<void> sharedPreferencesSet = SharedPreferences.getInstance()
.then((sharedPreferences) => sharedPreferences.setBool('isDebug', true));
/// 获取本地目录
final Future<Directory> tempDirFuture = path_provider.getTemporaryDirectory();

/// 合并执行
var values = await Future.wait([sharedPreferencesSet, tempDirFuture]);

final Directory? tempDir = values[1] as Directory?;
final String dbPath = path.join(tempDir!.path, 'database.db');
File file = File(dbPath);
if (file.existsSync()) {
///读取文件
RandomAccessFile reader = file.openSync();
List<int> buffer = List.filled(256, 0);
while (reader.readIntoSync(buffer) == 256) {
List<int> foo = buffer.takeWhile((value) => value != 0).toList();
///读取结果
String string = utf8.decode(foo);
print("######### $string");
}
reader.closeSync();
}
}


这里之所以可以在 isolate 里直接传递 RootIsolateToken ,就是得益于前面所说的 Dart 2.15 的 isolate groups



其实入下代码所示,上面的实现换成 compute 也可以正常执行,当然,如果是 compute 的话,有一些比较特殊情况需要注意


RootIsolateToken rootIsolateToken =    RootIsolateToken.instance!;
compute(doFind, rootIsolateToken);

如下代码所示, doFind2 方法在 doFind 的基础上,将 Future.waitawait 修改为 .then 去执行,如果这时候你再调用 spawncompute ,你就会发现 spawn 下代码依然可以正常执行,但是 compute 却不再正常执行


onTap: () {
RootIsolateToken rootIsolateToken =
RootIsolateToken.instance!;
compute(doFind2, rootIsolateToken);
},

onTap: () {
RootIsolateToken rootIsolateToken =
RootIsolateToken.instance!;
Isolate.spawn(doFind2, rootIsolateToken);
},


doFind2(rootIsolateToken) async {
/// 注册 root isolaote
BackgroundIsolateBinaryMessenger.ensureInitialized(rootIsolateToken);

///获取 sharedPreferencesSet 的 isDebug 标识位
final Future<void> sharedPreferencesSet = SharedPreferences.getInstance()
.then((sharedPreferences) => sharedPreferences.setBool('isDebug', true));

/// 获取本地目录
final Future<Directory> tempDirFuture = path_provider.getTemporaryDirectory();


///////////////////// Change Here //////////////////
/// 合并执行
Future.wait([sharedPreferencesSet, tempDirFuture]).then((values) {
final Directory? tempDir = values[1] as Directory?;
final String dbPath = path.join(tempDir!.path, 'database.db');
///读取文件
File file = File(dbPath);
if (file.existsSync()) {
RandomAccessFile reader = file.openSync();
List<int> buffer = List.filled(256, 0);
while (reader.readIntoSync(buffer) == 256) {
List<int> foo = buffer.takeWhile((value) => value != 0).toList();
String string = utf8.decode(foo);
print("######### $string");
}
reader.closeSync();
}
}).catchError((e) {
print(e);
});
}

为什么会这样?compute 不就是 Flutter 针对 Isolate.spawn 的简易封装吗?



其实原因就在这个封装上,compute 现在不是直接执行 Isolate.spawn 代码,而是执行 Isolate.run ,而 Isolate.run 针对 Isolate.spawn 做了一些特殊封装。



compute 内部会将执行对象封装成 _RemoteRunner 再交给 Isolate.spawn 执行,而 _RemoteRunner 在执行时,会在最后强制调用 Isolate.exit ,这就会导致前面的 Future.wait 还没执行,而 Isolate 就退出了,从而导致代码无效的原因。




另外在 Flutter 3.7 上 ,如果 background isolate 调用 Platform Channels 没有关联 root isolate,也能看到错误提示你初始化关联,所以这也是为什么我说它使用起来很简单的原因。



除此之外,最近刚好遇到有“机智”的小伙伴说 background isolate 无法正常调用,看了下代码是把 RootIsolateToken.instance!; 写到了 background isolate 执行的方法里。




你猜如果这样有效,为什么官方不直接把这个获取写死在 framewok?



其实这也是 isolates 经常引起歧义的原因,isolates 是隔离,内存不共享数据,所以 root isolate 里的 RootIsolateToken 在 background isolate 里直接获肯定是 null ,所以这也是 isolate 使用时需要格外注意的一些小细节。



另外还有如 #36983 等问题,也推动了前面所说的 compute 相关的更改。



最后,如果需要一个完整 Demo 的话,可以参考官方的 background_isolate_channels ,项目里主要通过 SimpleDatabase_SimpleDatabaseServer 的交互,来模拟展示 root isolate 和 background isolate 的调用实现。


最后


总的来说 background isolate 并不难理解,自从 2018 年在 issue #13937 被提出之后就饱受关注,甚至官方还建议过大家通过 ffi 另辟蹊径去实现,当时的 issue 也被搭上了 P5 的 Tag。



相信大家都知道 P5 意味着什么。



所以 background isolate 能在 Flutter 3.7 看到是相当难得的,当然这也离不开 Dart 的日益成熟的支持,同时 background isolate 也给我们带来了更多的可能性,其中最直观就是性能优化上多了新的可能,代码写起来也变得更顺畅。


期待 Flutter 和 Dart 在后续的版本中还能给我们带来更多的惊喜。


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

“八股文”? ——什么是好的技术面试

ReactJs 核心开发 Dan Abramov 和 Youtube 主播和 Dan 进行 了一场模拟面试,这个面试将近持续了一个小时,但是主要是后面的那个算法题耗费时间,前面几个问题都是很八股的前端面试题(这部分翻译和评价来自@程序员的喵):http://w...
继续阅读 »

不知道什么时候起,市面上开始流行所谓的面经、背题之类的八股文,大到字节、腾讯、阿里、baidu,小到十几人的小微企业都是开始有大量的算法类笔试题。而且在面试时的问题也越来越标准化,网上到处流传着 xxx 公司面经、xxx 公司面试题这种东西,我不禁感叹这种方式的面试和筛选简历的方式真的能招到好的人才吗?

ReactJs 核心开发 Dan Abramov 和 Youtube 主播和 Dan 进行 了一场模拟面试,这个面试将近持续了一个小时,但是主要是后面的那个算法题耗费时间,前面几个问题都是很八股的前端面试题(这部分翻译和评价来自@程序员的喵):

  1. let 和 const 区别

  2. 什么时候使用 redux

  3. dangerouslySetInnerHTML 是什么,该怎么用

  4. 把一个 div 居中

  5. 把一个 binaryTree 镜像翻转

  6. Bonus Q: 一个找兔子的算法题,兔子出现在数组的某个位置,但是每次可以跳向相邻的位置,用最快的办法找到兔子的位置。

http://www.youtube.com/watch?v=XEt…

把 div 居中算是前端中的经典梗了,Dan 花了好一会时间在面试官的提示下才把一个 div 居中。如果对方不是 React 核心开发,手熟的前端可能就会开始鄙视这位“初级前端”了。

最后一个算法题比较新颖,这不算红黑树式的八股算法题,倒像是一个 IQ 测试题目。可以看出 dan 也很少碰这类算法题。他花费了近半个小时在面试官的提示下,按照自己的直觉一步一步推出了答案。但是他最后写的代码是有点小问题的(没有用 2 来递增 index),面试者看他思路是对的也没有指出来了。

即使是非常知名的开源作者在面试这些基础问题和算法题的时候都是很困难的,那普通人岂不是更困难?如果不背题的情况下要做出算法题还是很难的。

我在写这篇文章之前我搜了下,我发现有篇文章写得非常好,基本已经把我想说的都概括进去了。

怎样花两年时间去面试一个人 – 刘未鹏

我就着这篇文章往下说下自己的感受吧。

现在市面上基本无论实习还是社招、校招都大量的流行笔试的本质是因为如何界定好的、优秀的技术人才越来越难。如果在上面文章说的一样:

招聘真的很困难。以至于招聘者每年需要绞尽脑汁出新笔试题,以免往年的笔试题早就被人背熟了。出题很费脑子,要出的不太简单也不太难,能够滤掉绝大多数滥竽充数的但又要保证不因题目不公平而滤掉真正有能力的,要考虑审题人的时间成本就只能大多数用选择题,而选择题又是可以猜答案的(极少有人会在选了答案之后还敢在空白的地方写为什么选某答案的原因的)。更悲催的是,有些题目出的连公司的员工们自己都会做错(真的是员工们做错了吗?还是题目本身就出错了?)

我们没有很好的办法去界定一个人在技术上是否优秀,实践证明是否在大厂工作过、学历是否很好只是提高了优秀人才的概率,但并不能决定一个人是否优秀。现在大部分五年以下工作经验所做的都是纯业务开发,例如 API 开发、所谓的”增删查改“等等。甚至于换不同语言都已经很难了,经常使用 Java 开发的就很难切换到 Python 开发。

即使笔试之后进入面试阶段,我们也很难在很短的时间内去界定一个人他是否是好的、优秀的人才。就如在《社会性动物》里描述的一样:”我们总是寻求保存认知(心理)能量并将复杂事物简单化处理的方法。我们会利用经验法则去走捷径。我们会忽略一些信息以减少认知负担;我们会过度利用一些信息以避免去寻找更多的信息;或者我们只是按照最初的直觉,接受一个不够完美的选择,因为它已经足够好了。人类进化的一个奇怪的特点是它倾向于消极:我们倾向于关注潜在的威胁而不是祝福,这种倾向通常被称为消极偏见。(罗伊·鲍迈斯特(Roy Baumeister)和他的同事发现,消极的事件通常比积极的事件更有力量。)“。

在面试的过程中,无论是对于面试官还是候选人来说,都很难保持完全中立,会不由自主的倾向于寻找对方的缺点,寻找对方不会什么、缺点是什么。甚至如果对方与自己越相似你就会越喜欢他,对方与自己越不相似,自己就越不喜欢他。(如学习经历、成长环境、同个国家留学、上个公司是同个公司等等)。

对于现在的候选人来说,刚一坐下来就要担心需要不需要笔试了,等下笔试有电脑还是手写、有没有现代的 IDE、有没有代码提示等等。

所以说在短短的几个小时(很多时候一小时都不够)中想要发现一个人的闪光点是很难的。雇主在招人时很难选择优秀的人只能通过更加标准的”考试“来选择那些至少更擅长应试的人,或者使用标准的面经类的面试题去扣一个框架的细节、一个工具的细节、Hashmap 原理什么的。在这样的市场环境下候选人也会慢慢习惯这样的环境,随时准备应试。这样的市场环境将工具和解决问题的能力本末倒置,我们不能说一个擅长使用锤子的人更擅长锻造,我们也不能说一个擅长锻造的人一定擅长挥舞锤子。

但实际上对于好的技术开发来说,难道具体的语言和框架不应该只是工具吗? 哪个用得顺手就用哪个么?我们实际应该要做的不是利用数学知识、计算机相关的知识、逻辑思维能力、分析能力在某个场景下用适合的工具去解决遇到的问题吗?

我有一次打车遇到一个司机跟我抱怨说每天派单都很少,但是他本人应该优先级很高才对,那我就问他你是不是每天出门是一样的路线?他说是,我告诉他你其实可以试下每天出门时每遇到一个十字路口就走与上次不一样的方向,然后记录下来哪个条路线单最多最好,以后就按那个路线走。

我们换成计算机领域的话来说,这就是一种类似广度优先搜索算法的算法,我们将每天出门的路线看作是一张图,每个十字路口看作是一个节点。广度搜索算法可以帮我们分析出从 A 节点出发前往 B 节点哪条路径最短,我们可以把路径最短的目标换成哪条路径同等时间获得的收益最大。我们只不过是用人力去模拟这个算法,来实现最优路径。

所以所谓的精通 xxx、熟悉 yyy、掌握 zzz 的本质是,我们能不能用类似这些东西的机制或者利用这些东西解决业务问题,或者我们能不能利用这些算法、原理的思想解决现实生活中遇到的问题。

在互联网这么多年,最重要的方法论就是在高密度的信息下用某个方法论解决某个问题。虽然有时候互联网黑话很好笑,但有时候遇到某个问题的时候就会发现这个黑话还是很好用的,毕竟它代表了某个方法论的简写(手动狗头)。

那么怎么样才能让雇主方更容易找到好的人,也能让候选人更好的表现自己呢?我觉得提供一个自己的博客和 GitHub 之类的开放代码平台能够非常好的表现自己的技术品味、自学习的能力、进步的速度。长期维护一个好的品味的博客、深度的博客是很难的,需要花大量的时间和精力去写作、去思考。

同时我们可以参与开源项目的贡献或者我们可以自己设计一个解决了某个经常遇到的问题的项目、模拟某个场景的项目。自己撰写架构设计文档、技术文档等等然后开发、完善单元测试、不断完善迭代、尝试更加新颖的技术。通过把项目展示在 GitHub 之类的平台上,雇主方可以很好的通过你的项目和代码了解到你的技术品味,也可以看到你的编程习惯是否与自己符合。自己也可以通过长期维护和更新项目不断更新技术栈。

对于雇主方来说,要思考的是自己所需的人到底是更擅长挥舞锤子的人还是更擅长锻造的人。如果我们是希望更擅长锻造的人,我们应该更关注的是候选人本身在什么样的环境下、通过什么样的方法、取得了什么样的成果、吸取了什么教训、下次再解决这个问题是否有更好的方案。通过与候选人共同探讨过去的经历,我们很快可以知道这个人是不是适合与自己合作的人(当然重点是合作了能不能解决问题,需要保持中立去评估)。

关于是否应该选择创业公司的问题,我今天搜索的时候发现有篇当年好像很火的文章《没事别想不开去创业公司》,15 年 16 年那个年代我也是创业潮中的一员,在当时的环境下的确就如同文章一样:

天确实变了,但是这天是不是为你变的,很难说。就像一线城市繁华的夜景,和你有没有关系,很难说。押上自己所有的时间和机会筹码,自己创业或加入创业公司,是不是一步好棋,也很难说。

当现在的环境与当年不一样了,如果说当年是资本+政策+经济兴起的三重推动力的话,现在就是三者都不行的环境了,更恶劣的环境反而容易诞生更加正规和更有潜力的创业公司的。

选择创业公司不能直接想着加入后就能马上 IPO 发家致富,而是应该往最坏的方向打算。创业就像吹一个泡泡,太大就会爆炸,太小又没有任何的意义。如何小心意义的让这个泡泡不爆成为一个飞在空中的泡泡是一个很难的且要求人非常理性、反人性的事情。

其次选择一个创业公司一定要去试试它的产品,看看自己喜不喜欢,如果自己都不喜欢这个产品不会经常用,你如何相信这个产品能发展起来?你如果不相信你为什么要参与创业呢?一只眼睛看着外面商业环境的变化,随时准备调整战略战术适应市场,另一只眼睛盯着内部的团队,随时要调整和救火。在一个高速发展的公司中的确平日和周末的界限没那么明显,但无论是公司还是个人还是应该想着如何更高效而不是如何加班更多,加班多并不代表高效,高效也不一定要加班更多,像 intel 现在 的 CEO 帕特·基尔辛格在自传中写到的——“一个杂耍艺人同时转动三个小茶碟。一个碟子代表上帝,另一个代表家庭,第三个代表工作。我当时的生活就是这样,我得时刻注意让这三个碟子都在空中旋转,根本没机会暂停或休息。如果我稍有分神,碟子就会掉下来,摔到地上。也许我们可以把这称之为有张驰的工作。工作和生活要平衡:工作时要竭尽全力;休息时要完全放松,或在家陪伴家人,或外出度假。

加入创业公司的本质是选一个好的创业公司,与他一起成长,如果他没法长大为何要加入?如果他要野蛮生长,你呢?

回顾招聘的话题,对于我个人而言,评估一个人是不是好的技术人才最简单的办法就是,如果将来互联网衰败,当工程师并不能提供很多收入的时候、甚至你换行了你还会喜欢并跟进新的技术吗?甚至有一天编程将死、程序员职业消失在历史长河中,你会怎么办?

作者:Andy_Qin
来源:juejin.cn/post/7188046122441506853

收起阅读 »

首页弹框太多?Flow帮你“链”起来

很多App一打开,首页都会有各种各样的交互,比如权限授权,版本更新,阅读协议,活动介绍,用户权限变更等,这些交互大多数都是以弹框为主,也会有少数几个是以页面或者别的形式出现,但是无论是弹框还是页面,这些只是表现形式,这种交互难点在于如何去判断它们什么时候出来它...
继续阅读 »

很多App一打开,首页都会有各种各样的交互,比如权限授权,版本更新,阅读协议,活动介绍,用户权限变更等,这些交互大多数都是以弹框为主,也会有少数几个是以页面或者别的形式出现,但是无论是弹框还是页面,这些只是表现形式,这种交互难点在于

  1. 如何去判断它们什么时候出来

  2. 它们出来的先后次序是什么

  3. 中途需求如果增加或者删除一个弹框或者页面,我们应该改动哪些逻辑

常见的做法

可能这种需求刚开始由于弹框少,交互还简单,所以大多数的做法就是直接在首页用if-else去完成了

if(条件1){
  //弹框1
}else if(条件2){
   //弹框2
}

但是当需求慢慢迭代下去,首页弹框越来越多,判断的逻辑也越来越复杂,判断条件之间还存在依赖关系的时候,我们的代码就变得很可怕了

if(条件1 && 条件2 && 条件3){
  //弹框1
}else if(条件1 && (条件2 || 条件3)){
   //弹框2
}else if(条件2 && 条件3){
   //弹框3
}else if(....){
  ....
}

这种情况下,这些代码就变的越来越难维护,长久下来,造成的问题也越来越多,比如

  1. 代码可读性变差,不是熟悉业务的人无法去理解这些逻辑代码

  2. 增加或者减少弹框或者条件需要更改中间的逻辑,容易产生bug

  3. 每个分支的弹框结束后,需要重新从第一个if再执行一遍判断下一个弹框是哪一个,如果条件里面牵扯到io操作,也会产生一定的性能问题

设计思路

能否让每个弹框作为一个单独的任务,生成一条任务链,链上的节点为单个任务,节点维护任务执行的条件以及任务本身逻辑,节点之间无任何依赖关系,具体执行由任务链去管理,这样的话如果增加或者删除某一个任务,我们只需要插拔任务节点就可以


定义任务

首先我们先简单定一个任务,以及需要执行的操作

interface SingleJob {
   fun handle(): Boolean
   fun launch(context: Context, callback: () -> Unit)
}
  • handle():判断任务是否应该执行的条件

  • launch():执行任务,并在任务结束后通过callback通知任务链执行下一条任务

实现任务

定义一个TaskJobOne,让它去实现SingleJob

class TaskJobOne : SingleJob {
   override fun handle(): Boolean {
       println("start handle job one")
       return true
  }
   override fun launch(context: Context, callback: () -> Unit) {
       println("start launch job one")
       AlertDialog.Builder(context).setMessage("这是第一个弹框")
          .setPositiveButton("ok") {x,y->
               callback()
          }.show()
  }
}

这个任务里面,我们先默认handle的执行条件是true,一定会执行,实际开发过程中可以根据需要来定义条件,比如判断登录态等,lanuch里面我们简单的弹一个框,然后在弹框的关闭的时候,callback给任务链,为了调试的时候看的清楚一些,在这两个函数入口分别打印了日志,同样的任务我们再创建一个TaskJobTwo,TaskJobThree,具体实现差不多,就不贴代码了

任务链

首先思考下如何存放任务,由于任务之间需要体现出优先级关系,所以这里决定使用一个LinkedHashMap,K表示优先级,V表示任务

object JobTaskManager {
   val jobMap = linkedMapOf(
       1 to TaskJobOne(),
       2 to TaskJobTwo(),
       3 to TaskJobThree()
  )
}

接着就是思考如何设计整条任务链的执行任务,因为这个是对jobMap里面的任务逐个拿出来执行的过程,所以我们很容易就想到可以用Flow去控制这些任务,但是有两个问题需要去考虑下

  1. 如果直接将jobMap转成Flow去执行,那么出现的问题就是所有任务全部都一次性执行完,显然不符合设计初衷

  2. 我们都知道Flow是由上游发送数据,下游接收并处理数据的一个自上而下的过程,但是这里我们需要一个job执行完以后,通过callback通知任务链去执行下一个任务,任务的发送是由前一个任务控制的,所以必须设计出一个环形的过程

首先我们需要一个变量去保存当前需要执行的任务优先级,我们定义它为curLevel,并设置初始值为1,表示第一个执行的是优先级为1的任务

var curLevel = 1

这个变量将会在任务执行完以后,通过callback回调以后再自增,至于自增之后如何再去执行下一条任务,这个通知的事情我们交给StateFlow

val stateFlow = MutableStateFlow(curLevel)
fun doJob(context: Context, job: SingleJob) {
   if (job.handle()) {
       job.launch(context) {
           curLevel++
           if (curLevel <= jobMap.size)
               stateFlow.value = curLevel
      }
  } else {
       curLevel++
       if (curLevel <= jobMap.size)
           stateFlow.value = curLevel
  }
}

stateFlow初始值是curlevel,当上层开始订阅的时候,不给stateFlow设置value,那么stateFlow初始值1就会发送出去,开始执行优先级为1的任务,在doJob里面,当任务的执行条件不满足或者任务已经执行完成,就自增curLevel,再给stateFlow赋值,从而执行下一个任务,这样一个环形过程就有了,下面是在上层如何执行任务链

MainScope().launch {
   JobTaskManager.apply {
       stateFlow.collect {
           flow {
               emit(jobMap[it])
          }.collect {
               doJob(this@MainActivity, it!!)
          }
      }
  }
}

我们的任务链就完成了,看下效果


通过日志我们可以看到,的确是每次关闭一个弹框,才开始执行下一条任务,这样一来,如果某个任务的条件不满足,或者不想让它执行了,只需要改变对应job的handle条件就可以,比如现在把TaskJobOne的handel设置为false,在看下效果

class TaskJobOne : SingleJob {
   override fun handle(): Boolean {
       println("start handle job one")
       return false
  }
   override fun launch(context: Context, callback: () -> Unit) {
       println("start launch job one")
       AlertDialog.Builder(context).setMessage("这是第一个弹框")
          .setPositiveButton("ok") {x,y->
               callback()
          }.show()
  }
}


可以看到经过第一个task的时候,由于已经把handle条件设置成false了,所以直接跳过,执行下一个任务了

依赖于外界因素

上面只是简单的模拟了一个任务链的工作流程,实际开发过程中,我们有的任务会依赖于其他因素,最常见的就是必须等到某个接口返回数据以后才去执行,所以这个时候,执行你的任务需要判断的东西就更多了

  • 是否优先级已经轮到它了

  • 是否依赖于某个接口

  • 这个接口是否已经成功返回数据了

  • 接口数据是否需要传递给这个任务 鉴于这些,我们就要重新设计我们的任务与任务链,首先要定义几个状态值,分别代表任务的不同状态

const val JOB_NOT_AVAILABLE = 100
const val JOB_AVAILABLE = 101
const val JOB_COMBINED_BY_NOTHING = 102
const val JOB_CANCELED = 103
  • JOB_NOT_AVAILABLE:该任务还没有达到执行条件

  • JOB_AVAILABLE:该任务达到了执行任务的条件

  • JOB_COMBINED_BY_NOTHING:该任务不关联任务条件,可直接执行

  • JOB_CANCELED:该任务不能执行

接着需要去扩展一下SingleJob的功能,让它可以设置状态,获取状态,并且可以传入数据

interface SingleJob {
  ......
   /**
    * 获取执行状态
    */
   fun status():Int

   /**
    * 设置执行状态
    */
   fun setStatus(level:Int)

   /**
    * 设置数据
    */
   fun setBundle(bundle: Bundle)
}

更改一下任务的实现

class TaskJobOne : SingleJob {
   var flag = JOB_NOT_AVAILABLE
   var data: Bundle? = null
   override fun handle(): Boolean {
       println("start handle job one")
       return  flag != JOB_CANCELED
  }
   override fun launch(context: Context, callback: () -> Unit) {
       println("start launch job one")
       val type = data?.getString("dialog_type")
       AlertDialog.Builder(context).setMessage(if(type != null)"这是第一个${type}弹框" else "这是第一个弹框")
          .setPositiveButton("ok") {x,y->
               callback()
          }.show()
  }
   override fun setStatus(level: Int) {
       if(flag != JOB_COMBINED_BY_NOTHING)
           this.flag = level
  }
   override fun status(): Int = flag

   override fun setBundle(bundle: Bundle) {
       this.data = bundle
  }
}

现在的任务执行条件已经变了,变成了状态不是JOB_CANCELED的任务才可以执行,增加了一个变量flag表示这个任务的当前状态,如果是JOB_COMBINED_BY_NOTHING表示不依赖外界因素,外界也不能改变它的状态,其余状态则通过setStatus函数来改变,增加了setBundle函数允许外界向任务传入数据,并且在launch函数里面接收数据并展示在弹框上,我们在任务链里面增加一个函数,用来给对应优先级的任务设置状态与数据

fun setTaskFlag(level: Int, flag: Int, bundle: Bundle = Bundle()) {
       if (level > jobMap.size) {
           return
      }
       jobMap[level]?.apply {
           setStatus(flag)
           setBundle(bundle)
      }
  }

我们现在可以把任务链同接口一起关联起来了,首先我们先创建个viewmodel,在里面创建三个flow,分别模拟三个不同接口,并且在flow里面向下游发送数据

class MainViewModel : ViewModel(){
   val firstApi = flow {
       kotlinx.coroutines.delay(1000)
       emit("元宵节活动")
  }
   val secondApi = flow {
       kotlinx.coroutines.delay(2000)
       emit("端午节活动")
  }
   val thirdApi = flow {
       kotlinx.coroutines.delay(3000)
       emit("中秋节活动")
  }
}

接着我们如果想要去执行任务链,就必须等到所有接口执行完毕才可以,刚好flow里面的zip操作符就可以满足这一点,它可以让异步任务同步执行,等到都执行完任务之后,才将数据传递给下游,代码实现如下

val mainViewModel: MainViewModel by lazy {
   ViewModelProvider(this)[MainViewModel::class.java]
}

MainScope().launch {
   JobTaskManager.apply {
       mainViewModel.firstApi
           .zip(mainViewModel.secondApi) { a, b ->
               setTaskFlag( 1, JOB_AVAILABLE, Bundle().apply {
                   putString("dialog_type", a)
              })
               setTaskFlag( 2, JOB_AVAILABLE, Bundle().apply {
                   putString("dialog_type", b)
              })
          }.zip(mainViewModel.thirdApi) { _, c ->
               setTaskFlag( 3, JOB_AVAILABLE, Bundle().apply {
                   putString("dialog_type", c)
              })
          }.collect {
               stateFlow.collect {
                   flow {
                       emit(jobMap[it])
                  }.collect {
                       doJob(this@MainActivity, it!!)
                  }
              }
          }
  }
}

运行一下,效果如下


我们看到启动后第一个任务并没有立刻执行,而是等了一会才去执行,那是因为zip操作符是等所有flow里面的同步任务都执行完毕以后才发送给下游,flow里面已经执行完毕的会去等待还没有执行完毕的任务,所以才会出现刚刚页面有一段等待的时间,这样的设计一般情况下已经可以满足需求了,毕竟正常情况一个接口的响应时间都是毫秒级别的,但是难防万一出现一些极端情况,某一个接口响应忽然变慢了,就会出现我们的任务链迟迟得不到执行,产品体验方面就大打折扣了,所以需要想个方案解决一下这个问题

优化

首先我们需要当应用启动以后就立马执行任务链,判断当前需要执行任务的优先级与curLevel是否一致,另外,该任务的状态是可执行状态

/**
* 应用启动就执行任务链
*/
fun loadTask(context: Context) {
   judgeJob(context, curLevel)
}

/**
* 判断当前需要执行任务的优先级是否与curLevel一致,并且任务可执行
*/
private fun judgeJob(context: Context, cur: Int) {
   val job = jobMap[cur]
   if(curLevel == cur && job?.status() != JOB_NOT_AVAILABLE){
       MainScope().launch {
           doJob(context, cur)
      }
  }
}

我们更改一下doJob函数,让它成为一个挂起函数,并且在里面执行完任务以后,直接去判断它的下一级任务应不应该执行

private suspend fun doJob(context: Context, index: Int) {
   if (index > jobMap.size) return
   val singleJOb = jobMap[index]
   callbackFlow {
       if (singleJOb?.handle() == true) {
           singleJOb.launch(context) {
               trySend(index + 1)
          }
      } else {
           trySend(index + 1)
      }
       awaitClose { }
  }.collect {
       curLevel = it
       judgeJob(context,it)
  }
}

流程到了这里,如果所有任务都不依赖接口,那么这个任务链就能一直执行下去,如果遇到JOB_NOT_AVAILABLE的任务,需要等到接口响应的,那么任务链停止运行,那什么时候重新开始呢?就在我们接口成功回调之后给任务更改状态的时候,也就是setTaskFlag

fun setTaskFlag(context:Context,level: Int, flag: Int, bundle: Bundle = Bundle()) {
   if (level > jobMap.size) {
       return
  }
   jobMap[level]?.apply {
       setStatus(flag)
       setBundle(bundle)
  }
   judgeJob(context,level)
}

这样子,当任务链走到一个JOB_NOT_AVAILABLE的任务的时候,任务链暂停,当这个任务依赖的接口成功回调完成对这个任务状态的设置之后,再重新通过judgeJob继续走这条任务链,而一些优先级比较低的任务依赖的接口先完成了回调,那也只是完成对这个任务的状态更改,并不会执行它,因为curLevel还没到这个任务的优先级,现在可以试一下效果如何,我们把threeApi这个接口响应时间改的长一点

val thirdApi = flow {
   kotlinx.coroutines.delay(5000)
   emit("中秋节活动")
}

上层执行任务链的地方也改一下

MainScope().launch {
   JobTaskManager.apply {
       loadTask(this@MainActivity)
       mainViewModel.firstApi.collect{
           setTaskFlag(this@MainActivity, 1, JOB_AVAILABLE, Bundle().apply {
               putString("dialog_type", it)
          })
      }
       mainViewModel.secondApi.collect{
           setTaskFlag(this@MainActivity, 2, JOB_AVAILABLE, Bundle().apply {
               putString("dialog_type", it)
          })
      }
       mainViewModel.thirdApi.collect{
           setTaskFlag(this@MainActivity, 3, JOB_AVAILABLE, Bundle().apply {
               putString("dialog_type", it)
          })
      }
  }
}

应用启动就loadTask,然后三个接口已经从同步又变成异步操作了,运行一下看看效果


总结

大致的一个效果算是完成了,这只是一个demo,实际需求当中可能更复杂,弹框,页面,小气泡来回交互的情况都有可能,这里也只是想给一些正在优化项目的的同学提供一个思路,或者接手新需求的时候,鼓励多思考一下有没有更好的设计方案

作者:Coffeeee
来源:juejin.cn/post/7195336320435601467

收起阅读 »

最近突然火爆朋友圈的Damus是个啥?

前言兔年过完刚上班没几天,今天看朋友圈的时候突然发现好多人在发以"npub1"开头的神秘字符串。搞的我差点怀疑是不是微信出bug了?于是稍微搜索一了下才知道这串神秘代码是个公钥,这个公钥是用来加好友的,在哪里加好友呢?在一个去中心化的社交网络客户端上加好友。这...
继续阅读 »

前言

兔年过完刚上班没几天,今天看朋友圈的时候突然发现好多人在发以"npub1"开头的神秘字符串。搞的我差点怀疑是不是微信出bug了?于是稍微搜索一了下才知道这串神秘代码是个公钥,这个公钥是用来加好友的,在哪里加好友呢?在一个去中心化的社交网络客户端上加好友。这个客户端在iOS上叫Damus(音译:大妈们?)。在Android平台上叫Amethyst(我也不知道咋念)。如果你哪个客户端都不想装,人家还有网页端给你用snort.social

(下面是我的公钥,供大家参考,没事的话也可以加一下,如果你也有公钥欢迎留在评论区)

npub1prxq60wh5zm9s2mf8uw3fr6yy44uuf4l67kh5f7am4244svecjrsfm75vt

看起来这玩意除了有个难念的名字和加好友要用一串根本没法记的字符串外,它就还是个社交软件嘛。是不是这样呢?我们接着来体验一下Damus。

Damus咋玩?

首先和普通的社交软件一样得注册,但Damus有个好处是你注册的时候不需要手机号也不需要邮箱,只需要填个用户名就行了。注册的同时会为你自动生成一个公钥和一个私钥。公钥--就是前面说的"npub1"开头的字符串--是用来加好友的,可以发给别人。私钥是用来登录的。一定要自己保管起来,千万别让外人看到。


登录进去以后感觉就是个低配版的Twitter。支持#tag话题 毕竟是刚发布的客户端。好多东西还不完善。目前可以发文字。而且貌似没有140字的限制,但是不支持直接发图片,要发图片需要将图片上传到别的服务器生成链接以后才能使用。

另外一个小细节,点赞图标不光有大拇指,它还有小拇指,就问你六不六?


个人中心除了用户名,头像,个人网址等等常规设置外,还可以设置比特币闪电小费地址,也就是说这玩意想做成像微信那样既可以社交又可以支付?只不过支付应该只支持“数字货币”吧。


从上面的使用体验可以看出Damus还比较基础,很多功能是赶不上现有的社交软件的,例如发帖不支持样式编辑、发帖后不可删除、点赞或转推后不可撤销、图片需要转成链接才能发布,此外,趋势、过滤器等高级需求也亟待添加。那它为啥突然爆火呢?

Damus为啥火爆?

首先是因为有Twitter创始人Jack Dorsey的背书。在Damus终于上线app store之后,Jack Dorsey发推称之为里程碑事件


Jack Dorsey的推动让硅谷科技圈风投圈一下子躁动起来,不到24小时就已经蔓延到我的朋友圈了,可见世界之小,社交网络之强大。

其次就是Damus自身的特点了。从其官网我们可以看到Damus宣称自己的6大优势。


翻译一下就是:

  • 用户做主:建立在开放的互联网协议之上,没有任何平台可以禁止或审查,用户可以控制自己的数据和语音;

  • 加密:端到端加密的消息传递;

  • 无需注册:创建帐户不需要电话号码、电子邮件或姓名;

  • 无需服务器:消息通过去中心化的中继分发,无需运行任何基础设施,也没有单点故障;

  • 可编程:轻松集成机器人,帮助用户实现生活或业务自动化。Damus 也会在你的服务器出现故障时进行通知。

  • 赚钱:支持用比特币和闪电网络打赏朋友的帖子和 stack sats。

Damus能火爆其实主要就是因为其第一个优势,“用户做主”。基于去中心化社交协议 Nostr,那,看到去中心化,那不就是区块链,Web3等等当下正火的概念吗?区块链,Web3一直缺少面向用户的杀手级应用。而Damus貌似被大佬们寄予厚望能成为这样的应用。所以才有里程碑一说吧。

总结

从Damus的突然火爆我们可以看出科技圈最新的风向如何了。我的感受就是之前总在说区块链,Web3等等概念似乎离我们前端圈子比较遥远。还有就是各种币圈新闻让人感觉看不懂,但大受震撼。但现在Damus的出现会不会终于让Web3概念杀到了前端圈的门口呢?我觉得作为前端人需要紧紧跟随观察起来。至于Damus命运如何,是蓬勃发展壮大?还是热闹一阵子之后一地鸡毛?还是过几天就404?谁也不知道。但是抢先体验一下总没有错。

最后呢,再放一下我的Damus公钥

npub1prxq60wh5zm9s2mf8uw3fr6yy44uuf4l67kh5f7am4244svecjrsfm75vt

欢迎大家关注,也请大家把自己的公钥放在评论区方便互关。

作者:ad6623
来源:juejin.cn/post/7195423742709268541

收起阅读 »

Android浅谈Webview的Loading

前言 在开发webview的loading效果的时候会有一些问题,这边记录一些碰到的常见的问题,并且设计出一套Loading的方案来解决相关的问题。 1. loading的选择 开发loading效果的原因在于webview加载页面的时候,有时候会耗时,导致不...
继续阅读 »

前言


在开发webview的loading效果的时候会有一些问题,这边记录一些碰到的常见的问题,并且设计出一套Loading的方案来解决相关的问题。


1. loading的选择


开发loading效果的原因在于webview加载页面的时候,有时候会耗时,导致不显示内容又没有任何提示,效果不太好,所以需要在webview使用的地方加上loading的效果,其实更好的体验是还要加上EmptyView,我这边主要就以loadingView来举例。


那开发这loading基本有两种方式,一种是使用window,也就是Dialog这些弹窗的方式,在加载时弹出弹窗,在加载结束后关闭弹窗,有些人可能会封装好一些loading弹窗,然后在这里复用。

这个方法的好处是如果你封装好了,能直接复用,省去很多代码。缺点也很明显,弹窗弹出的时候是否处于一个不允许交互的情况,如果这个流程有问题,那便一直无法和页面做交互


另一种方法是直接在webview的上层覆盖一个LoadingView,webview是继承FrameLayout,就是也可以直接addView。

这个方法的好处就是不会出现上面的问题,因为我webview所在的页面关闭了,它的loading也会跟着一起消失,而且显示的效果会好一些。缺点就是可能一些特殊的webview你会单独做操作,导致会多写一些代码


没有说哪种方法是实现会比较好,主要看使用的场景和具体的需求。


2. loading显示时机的问题


我们做loading的思路就是加载开始的时候显示,加载完成之后关闭,那选择这个开始的时机和结束的时机就比较重要了。


大多数人都会直接使用WebViewClient的onPageStarted回调作为开始时机,把onPageFinished的回调,觉得直接这样写就行了,无所谓,反正webview会出手。


这个思路确实能在正常的情况下显示正常,但是在弱网情况下呢?复杂的网络环境下呢?有些人可能也会碰到一些这样的情况,loading的show写在onPageStarted中,加载时会先白屏一下,才开始显示loading,但是这个白屏的时间很短,所以觉得无所谓。但有没有想过这在正常网络环境下的白屏一下放到复杂的有问题的网络环境中会被放大成什么样。


这个加载过程其实大体分为两个阶段,从loadurl到WebViewClient的onPageStarted和从WebViewClient的从onPageStarted到onPageFinished


所以我的做法是在loadurl的时候去start loading,而不是WebViewClient的onPageStarted回调的时候。


这个是开始的时机,那结束的时机会不会有问题,还真可能有,有时候你会发现一种现象,加载完之后,你的H5内容和loading会同时显示一段时间,才关闭loading(几年前有碰到过,写这篇文章的时候测试没有复现过,不知道是不是版本更新修复了这个问题)


那如果碰到这个问题该怎么解决呢?碰到这个问题,说明onPageFinished的回调时机在页面加载完之后,所以不可信。我们知道除了这个方法之外,BaseWebChromeClient也有个方法onProgressChanged表示加载的进度,当然这个进度你拿去判断也会有问题,因为它并不会每次都会回调100给你,可能有时候给你96,就没了。

我以前的做法是双重判断,判断是进度先返回>85还是onPageFinished先调用,只要有一个调用,我都会关闭loading


3. 体验优化


当然处理好显示的关闭的时机还不行,想想如果在loadurl中show loading会怎样,没错,就算网速快的情况,页面让loading一闪而过,那这样所造成的体验就很不好,所以我们需要做一个延迟显示,我个人习惯是延迟0.5秒。当然延迟显示也会有延迟显示的问题,比如延迟到0.3秒的时候你关闭页面怎么办,再0.2秒之后我总不不能让它显示吧。


说了显示,再说关闭。无论是onPageFinished方法还是onProgressChanged,你能保证它一定会有回调吗?这些代码都不是可控的,里面会不会出现既没抛异常,也没给回调的情况。也许有人说不会的,我都用了这么多年了,没出现过这种问题,但是既然不是我们可控的代码,加一层保险总没错吧。

其实这也简单,定一个timeout的逻辑就行,我个人是定义10秒超时时间,如果10秒后没有关闭loading,我就手动关闭并显示emptyview的error页面。这个超时时间还是比较实用,最上面说了loading的选择,如果你的loading做成view,那即便没有这个逻辑也影响不大,最多就会菊花一直转,但如果你是window做的,没有超时的处理,又没有回调,那你的window会一直显示卡住页面。


4. loading最终设计效果


基于上面的情况,我写个Demo,首先loading的选择,我选择基于view,所以要写个自定义View


public class WebLoadingView extends RelativeLayout {

private Context mContext;
// 0:正常状态;1:loading状态;2:显示loadingview状态
private AtomicInteger state;
private Handler lazyHandler;
private Handler timeOutHandler;

public BaseWebLoadingView(Context context) {
super(context);
init(context);
}

public BaseWebLoadingView(Context context, AttributeSet attrs) {
super(context, attrs);
init(context);
}

public BaseWebLoadingView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init(context);
}

private void init(Context context) {
this.mContext = context;
state = new AtomicInteger(0);
lazyHandler = new Handler(Looper.getMainLooper());
timeOutHandler = new Handler(Looper.getMainLooper());
initView();
}

private void initView() {
LayoutInflater.from(mContext).inflate(R.layout.demo_loading, this, true);
}

public void show() {
if (state.compareAndSet(0, 1)) {
lazyHandler.postDelayed(new Runnable() {
@Override
public void run() {
if (state.compareAndSet(1, 2)) {
setVisibility(View.VISIBLE);
}
}
}, 500);

timeOutHandler.postDelayed(new Runnable() {
@Override
public void run() {
close();
}
}, 10000);
}
}

public void close() {
state.set(0);
setVisibility(View.GONE);

try {
lazyHandler.removeCallbacksAndMessages(null);
timeOutHandler.removeCallbacksAndMessages(null);
} catch (Exception e) {
e.printStackTrace();
}
}

}

代码应该都比较好理解,就不过多介绍了,然后在自定义webview的loadurl里面展示


@Override
public void loadUrl(String url) {
if (webLoadingView != null && !TextUtils.isEmpty(url) && url.startsWith("http")) {
webLoadingView.show();
}
super.loadUrl(url);
}

写这里主要是有个地方要注意,就是调方法时也会执行这个loadUrl,所以要判断是加载网页的时候才显示loading。


5. 总结


总结几个重点吧,第一个是对第三方的东西(webview这个也类似第三方吧,坑真的很多),我们没办法把控它的流程,或者说没办法把控它的生命周期,所以要封装一套流程逻辑去给调用端方便去使用。

第二个问题是版本的问题,也许会出现不同的版本所体现的效果不同,这个是需要留意的。


如果要完美解决这堆loading相关的问题,最好的方法就是看源码,你知道它里面是怎么实现的,为什么会出现onPageStarted之前还会有一段间隔时间,那就去看loadUrl和onPageStarted回调之间的源码,看它做了什么操作嘛。我个人是没看源码,所以这里只能说是浅谈。


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

首页弹框太多?Flow帮你“链”起来

很多App一打开,首页都会有各种各样的交互,比如权限授权,版本更新,阅读协议,活动介绍,用户权限变更等,这些交互大多数都是以弹框为主,也会有少数几个是以页面或者别的形式出现,但是无论是弹框还是页面,这些只是表现形式,这种交互难点在于 如何去判断它们什么时候出...
继续阅读 »

很多App一打开,首页都会有各种各样的交互,比如权限授权,版本更新,阅读协议,活动介绍,用户权限变更等,这些交互大多数都是以弹框为主,也会有少数几个是以页面或者别的形式出现,但是无论是弹框还是页面,这些只是表现形式,这种交互难点在于



  1. 如何去判断它们什么时候出来

  2. 它们出来的先后次序是什么

  3. 中途需求如果增加或者删除一个弹框或者页面,我们应该改动哪些逻辑


常见的做法


可能这种需求刚开始由于弹框少,交互还简单,所以大多数的做法就是直接在首页用if-else去完成了


if(条件1){
//弹框1
}else if(条件2){
//弹框2
}

但是当需求慢慢迭代下去,首页弹框越来越多,判断的逻辑也越来越复杂,判断条件之间还存在依赖关系的时候,我们的代码就变得很可怕了


if(条件1 && 条件2 && 条件3){
//弹框1
}else if(条件1 && (条件2 || 条件3)){
//弹框2
}else if(条件2 && 条件3){
//弹框3
}else if(....){
....
}

这种情况下,这些代码就变的越来越难维护,长久下来,造成的问题也越来越多,比如



  1. 代码可读性变差,不是熟悉业务的人无法去理解这些逻辑代码

  2. 增加或者减少弹框或者条件需要更改中间的逻辑,容易产生bug

  3. 每个分支的弹框结束后,需要重新从第一个if再执行一遍判断下一个弹框是哪一个,如果条件里面牵扯到io操作,也会产生一定的性能问题


设计思路


能否让每个弹框作为一个单独的任务,生成一条任务链,链上的节点为单个任务,节点维护任务执行的条件以及任务本身逻辑,节点之间无任何依赖关系,具体执行由任务链去管理,这样的话如果增加或者删除某一个任务,我们只需要插拔任务节点就可以


az1.png


定义任务


首先我们先简单定一个任务,以及需要执行的操作


interface SingleJob {
fun handle(): Boolean
fun launch(context: Context, callback: () -> Unit)
}


  • handle():判断任务是否应该执行的条件

  • launch():执行任务,并在任务结束后通过callback通知任务链执行下一条任务


实现任务


定义一个TaskJobOne,让它去实现SingleJob


class TaskJobOne : SingleJob {
override fun handle(): Boolean {
println("start handle job one")
return true
}
override fun launch(context: Context, callback: () -> Unit) {
println("start launch job one")
AlertDialog.Builder(context).setMessage("这是第一个弹框")
.setPositiveButton("ok") {x,y->
callback()
}.show()
}
}

这个任务里面,我们先默认handle的执行条件是true,一定会执行,实际开发过程中可以根据需要来定义条件,比如判断登录态等,lanuch里面我们简单的弹一个框,然后在弹框的关闭的时候,callback给任务链,为了调试的时候看的清楚一些,在这两个函数入口分别打印了日志,同样的任务我们再创建一个TaskJobTwo,TaskJobThree,具体实现差不多,就不贴代码了


任务链


首先思考下如何存放任务,由于任务之间需要体现出优先级关系,所以这里决定使用一个LinkedHashMap,K表示优先级,V表示任务


object JobTaskManager {
val jobMap = linkedMapOf(
1 to TaskJobOne(),
2 to TaskJobTwo(),
3 to TaskJobThree()
)
}

接着就是思考如何设计整条任务链的执行任务,因为这个是对jobMap里面的任务逐个拿出来执行的过程,所以我们很容易就想到可以用Flow去控制这些任务,但是有两个问题需要去考虑下



  1. 如果直接将jobMap转成Flow去执行,那么出现的问题就是所有任务全部都一次性执行完,显然不符合设计初衷

  2. 我们都知道Flow是由上游发送数据,下游接收并处理数据的一个自上而下的过程,但是这里我们需要一个job执行完以后,通过callback通知任务链去执行下一个任务,任务的发送是由前一个任务控制的,所以必须设计出一个环形的过程


首先我们需要一个变量去保存当前需要执行的任务优先级,我们定义它为curLevel,并设置初始值为1,表示第一个执行的是优先级为1的任务


var curLevel = 1

这个变量将会在任务执行完以后,通过callback回调以后再自增,至于自增之后如何再去执行下一条任务,这个通知的事情我们交给StateFlow


val stateFlow = MutableStateFlow(curLevel)
fun doJob(context: Context, job: SingleJob) {
if (job.handle()) {
job.launch(context) {
curLevel++
if (curLevel <= jobMap.size)
stateFlow.value = curLevel
}
} else {
curLevel++
if (curLevel <= jobMap.size)
stateFlow.value = curLevel
}
}

stateFlow初始值是curlevel,当上层开始订阅的时候,不给stateFlow设置value,那么stateFlow初始值1就会发送出去,开始执行优先级为1的任务,在doJob里面,当任务的执行条件不满足或者任务已经执行完成,就自增curLevel,再给stateFlow赋值,从而执行下一个任务,这样一个环形过程就有了,下面是在上层如何执行任务链


MainScope().launch {
JobTaskManager.apply {
stateFlow.collect {
flow {
emit(jobMap[it])
}.collect {
doJob(this@MainActivity, it!!)
}
}
}
}

我们的任务链就完成了,看下效果


a1111.gif


通过日志我们可以看到,的确是每次关闭一个弹框,才开始执行下一条任务,这样一来,如果某个任务的条件不满足,或者不想让它执行了,只需要改变对应job的handle条件就可以,比如现在把TaskJobOne的handel设置为false,在看下效果


class TaskJobOne : SingleJob {
override fun handle(): Boolean {
println("start handle job one")
return false
}
override fun launch(context: Context, callback: () -> Unit) {
println("start launch job one")
AlertDialog.Builder(context).setMessage("这是第一个弹框")
.setPositiveButton("ok") {x,y->
callback()
}.show()
}
}

a2222.gif


可以看到经过第一个task的时候,由于已经把handle条件设置成false了,所以直接跳过,执行下一个任务了


依赖于外界因素


上面只是简单的模拟了一个任务链的工作流程,实际开发过程中,我们有的任务会依赖于其他因素,最常见的就是必须等到某个接口返回数据以后才去执行,所以这个时候,执行你的任务需要判断的东西就更多了



  • 是否优先级已经轮到它了

  • 是否依赖于某个接口

  • 这个接口是否已经成功返回数据了

  • 接口数据是否需要传递给这个任务
    鉴于这些,我们就要重新设计我们的任务与任务链,首先要定义几个状态值,分别代表任务的不同状态


const val JOB_NOT_AVAILABLE = 100
const val JOB_AVAILABLE = 101
const val JOB_COMBINED_BY_NOTHING = 102
const val JOB_CANCELED = 103


  • JOB_NOT_AVAILABLE:该任务还没有达到执行条件

  • JOB_AVAILABLE:该任务达到了执行任务的条件

  • JOB_COMBINED_BY_NOTHING:该任务不关联任务条件,可直接执行

  • JOB_CANCELED:该任务不能执行


接着需要去扩展一下SingleJob的功能,让它可以设置状态,获取状态,并且可以传入数据


interface SingleJob {
......
/**
* 获取执行状态
*/
fun status():Int

/**
* 设置执行状态
*/
fun setStatus(level:Int)

/**
* 设置数据
*/
fun setBundle(bundle: Bundle)
}

更改一下任务的实现


class TaskJobOne : SingleJob {
var flag = JOB_NOT_AVAILABLE
var data: Bundle? = null
override fun handle(): Boolean {
println("start handle job one")
return flag != JOB_CANCELED
}
override fun launch(context: Context, callback: () -> Unit) {
println("start launch job one")
val type = data?.getString("dialog_type")
AlertDialog.Builder(context).setMessage(if(type != null)"这是第一个${type}弹框" else "这是第一个弹框")
.setPositiveButton("ok") {x,y->
callback()
}.show()
}
override fun setStatus(level: Int) {
if(flag != JOB_COMBINED_BY_NOTHING)
this.flag = level
}
override fun status(): Int = flag

override fun setBundle(bundle: Bundle) {
this.data = bundle
}
}

现在的任务执行条件已经变了,变成了状态不是JOB_CANCELED的任务才可以执行,增加了一个变量flag表示这个任务的当前状态,如果是JOB_COMBINED_BY_NOTHING表示不依赖外界因素,外界也不能改变它的状态,其余状态则通过setStatus函数来改变,增加了setBundle函数允许外界向任务传入数据,并且在launch函数里面接收数据并展示在弹框上,我们在任务链里面增加一个函数,用来给对应优先级的任务设置状态与数据


fun setTaskFlag(level: Int, flag: Int, bundle: Bundle = Bundle()) {
if (level > jobMap.size) {
return
}
jobMap[level]?.apply {
setStatus(flag)
setBundle(bundle)
}
}

我们现在可以把任务链同接口一起关联起来了,首先我们先创建个viewmodel,在里面创建三个flow,分别模拟三个不同接口,并且在flow里面向下游发送数据


class MainViewModel : ViewModel(){
val firstApi = flow {
kotlinx.coroutines.delay(1000)
emit("元宵节活动")
}
val secondApi = flow {
kotlinx.coroutines.delay(2000)
emit("端午节活动")
}
val thirdApi = flow {
kotlinx.coroutines.delay(3000)
emit("中秋节活动")
}
}

接着我们如果想要去执行任务链,就必须等到所有接口执行完毕才可以,刚好flow里面的zip操作符就可以满足这一点,它可以让异步任务同步执行,等到都执行完任务之后,才将数据传递给下游,代码实现如下


val mainViewModel: MainViewModel by lazy {
ViewModelProvider(this)[MainViewModel::class.java]
}

MainScope().launch {
JobTaskManager.apply {
mainViewModel.firstApi
.zip(mainViewModel.secondApi) { a, b ->
setTaskFlag( 1, JOB_AVAILABLE, Bundle().apply {
putString("dialog_type", a)
})
setTaskFlag( 2, JOB_AVAILABLE, Bundle().apply {
putString("dialog_type", b)
})
}.zip(mainViewModel.thirdApi) { _, c ->
setTaskFlag( 3, JOB_AVAILABLE, Bundle().apply {
putString("dialog_type", c)
})
}.collect {
stateFlow.collect {
flow {
emit(jobMap[it])
}.collect {
doJob(this@MainActivity, it!!)
}
}
}
}
}

运行一下,效果如下


a3333.gif


我们看到启动后第一个任务并没有立刻执行,而是等了一会才去执行,那是因为zip操作符是等所有flow里面的同步任务都执行完毕以后才发送给下游,flow里面已经执行完毕的会去等待还没有执行完毕的任务,所以才会出现刚刚页面有一段等待的时间,这样的设计一般情况下已经可以满足需求了,毕竟正常情况一个接口的响应时间都是毫秒级别的,但是难防万一出现一些极端情况,某一个接口响应忽然变慢了,就会出现我们的任务链迟迟得不到执行,产品体验方面就大打折扣了,所以需要想个方案解决一下这个问题


优化


首先我们需要当应用启动以后就立马执行任务链,判断当前需要执行任务的优先级与curLevel是否一致,另外,该任务的状态是可执行状态


/**
* 应用启动就执行任务链
*/
fun loadTask(context: Context) {
judgeJob(context, curLevel)
}

/**
* 判断当前需要执行任务的优先级是否与curLevel一致,并且任务可执行
*/
private fun judgeJob(context: Context, cur: Int) {
val job = jobMap[cur]
if(curLevel == cur && job?.status() != JOB_NOT_AVAILABLE){
MainScope().launch {
doJob(context, cur)
}
}
}

我们更改一下doJob函数,让它成为一个挂起函数,并且在里面执行完任务以后,直接去判断它的下一级任务应不应该执行


private suspend fun doJob(context: Context, index: Int) {
if (index > jobMap.size) return
val singleJOb = jobMap[index]
callbackFlow {
if (singleJOb?.handle() == true) {
singleJOb.launch(context) {
trySend(index + 1)
}
} else {
trySend(index + 1)
}
awaitClose { }
}.collect {
curLevel = it
judgeJob(context,it)
}
}

流程到了这里,如果所有任务都不依赖接口,那么这个任务链就能一直执行下去,如果遇到JOB_NOT_AVAILABLE的任务,需要等到接口响应的,那么任务链停止运行,那什么时候重新开始呢?就在我们接口成功回调之后给任务更改状态的时候,也就是setTaskFlag


fun setTaskFlag(context:Context,level: Int, flag: Int, bundle: Bundle = Bundle()) {
if (level > jobMap.size) {
return
}
jobMap[level]?.apply {
setStatus(flag)
setBundle(bundle)
}
judgeJob(context,level)
}

这样子,当任务链走到一个JOB_NOT_AVAILABLE的任务的时候,任务链暂停,当这个任务依赖的接口成功回调完成对这个任务状态的设置之后,再重新通过judgeJob继续走这条任务链,而一些优先级比较低的任务依赖的接口先完成了回调,那也只是完成对这个任务的状态更改,并不会执行它,因为curLevel还没到这个任务的优先级,现在可以试一下效果如何,我们把threeApi这个接口响应时间改的长一点


val thirdApi = flow {
kotlinx.coroutines.delay(5000)
emit("中秋节活动")
}

上层执行任务链的地方也改一下


MainScope().launch {
JobTaskManager.apply {
loadTask(this@MainActivity)
mainViewModel.firstApi.collect{
setTaskFlag(this@MainActivity, 1, JOB_AVAILABLE, Bundle().apply {
putString("dialog_type", it)
})
}
mainViewModel.secondApi.collect{
setTaskFlag(this@MainActivity, 2, JOB_AVAILABLE, Bundle().apply {
putString("dialog_type", it)
})
}
mainViewModel.thirdApi.collect{
setTaskFlag(this@MainActivity, 3, JOB_AVAILABLE, Bundle().apply {
putString("dialog_type", it)
})
}
}
}

应用启动就loadTask,然后三个接口已经从同步又变成异步操作了,运行一下看看效果


a4444.gif


总结


大致的一个效果算是完成了,这只是一个demo,实际需求当中可能更复杂,弹框,页面,小气泡来回交互的情况都有可能,这里也只是想给一些正在优化项目的的同学提供一个思路,或者接手新需求的时候,鼓励多思考一下有没有更好的设计方案


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

Kotlin SharedFlow&StateFlow 热流到底有多热?

1. 冷流与热流区别 2. SharedFlow 使用方式与应用场景 使用方式 流的两端分别是消费者(观察者/订阅者),生产者(被观察者/被订阅者),因此只需要关注两端的行为即可。 1. 生产者先发送数据 fun test1() { ...
继续阅读 »

1. 冷流与热流区别



image.png


2. SharedFlow 使用方式与应用场景


使用方式


流的两端分别是消费者(观察者/订阅者),生产者(被观察者/被订阅者),因此只需要关注两端的行为即可。


1. 生产者先发送数据


    fun test1() {
runBlocking {
//构造热流
val flow = MutableSharedFlow<String>()
//发送数据(生产者)
flow.emit("hello world")

//开启协程
GlobalScope.launch {
//接收数据(消费者)
flow.collect {
println("collect: $it")
}
}
}
}

Q:先猜测一下结果?

A:没有任何打印


我们猜测:生产者先发送了数据,因为此时消费者还没来得及接收,因此数据被丢弃了。


2. 生产者延后发送数据

我们很容易想到变换一下时机,让消费者先注册等待:


    fun test2() {
runBlocking {
//构造热流
val flow = MutableSharedFlow<String>()

//开启协程
GlobalScope.launch {
//接收数据(消费者)
flow.collect {
println("collect: $it")
}
}

//发送数据(生产者)
delay(200)//保证消费者已经注册上
flow.emit("hello world")
}
}

这个时候消费者成功打印数据。


3. 历史数据的保留(重放)

虽然2的方式连通了生产者和消费者,但是你对1的失败耿耿于怀:觉得SharedFlow有点弱啊,限制有点狠,LiveData每次新的观察者到来都能收到当前的数据,而SharedFlow不行。

实际上,SharedFlow对于历史数据的重放比LiveData更强大,LiveData始终只有个值,也就是每次只重放1个值,而SharedFlow可配置重放任意值(当然不能超过Int的范围)。

换一下使用姿势:


    fun test3() {
runBlocking {
//构造热流
val flow = MutableSharedFlow<String>(1)
//发送数据(生产者)
flow.emit("hello world")

//开启协程
GlobalScope.launch {
//接收数据(消费者)
flow.collect {
println("collect: $it")
}
}
}
}

此时达成的效果与2一致,MutableSharedFlow(1)表示设定生产者保留1个值,当有新的消费者来了之后将会获取到这个保留的值。

当然也可以保留更多的值:


    fun test3() {
runBlocking {
//构造热流
val flow = MutableSharedFlow<String>(4)
//发送数据(生产者)
flow.emit("hello world1")
flow.emit("hello world2")
flow.emit("hello world3")
flow.emit("hello world4")

//开启协程
GlobalScope.launch {
//接收数据(消费者)
flow.collect {
println("collect: $it")
}
}
}
}

此时消费者将打印出"hell world1~hello world4",此时也说明了不管有没有消费者,生产者都生产了数据,由此说明:



SharedFlow 是热流



4. collect是挂起函数

在2里,我们开启了协程去执行消费者逻辑:flow.collect,不单独开启协程执行会怎样?


    fun test4() {
runBlocking {
//构造热流
val flow = MutableSharedFlow<String>()

//接收数据(消费者)
flow.collect {
println("collect: $it")
}
println("start emit")//①
flow.emit("hello world")
}
}

最后发现①没打印出来,因为collect是挂起函数,此时由于生产者还没来得及生产数据,消费者调用collect时发现没数据后便挂起协程。



因此生产者和消费者要处在不同的协程里



5. emit是挂起函数

消费者要等待生产者生产数据,所以collect设计为挂起函数,反过来生产者是否要等待消费者消费完数据才进行下一次emit呢?


    fun test5() {
runBlocking {
//构造热流
val flow = MutableSharedFlow<String>()
//开启协程
GlobalScope.launch {
//接收数据(消费者)
flow.collect {
delay(2000)
println("collect: $it")
}
}

//发送数据(生产者)
delay(200)//保证消费者先执行
println("emit 1 ${System.currentTimeMillis()}")
flow.emit("hello world1")
println("emit 2 ${System.currentTimeMillis()}")
flow.emit("hello world2")
println("emit 3 ${System.currentTimeMillis()}")
flow.emit("hello world3")
println("emit 4 ${System.currentTimeMillis()}")
flow.emit("hello world4")
}
}

从打印可以看出,生产者每次emit都需要等待消费者消费完成之后才能进行下次emit。


6. 缓存的设定

在之前分析Flow的时候有说过Flow的背压问题以及使用Buffer来解决它,同样的在SharedFlow里也有缓存的概念。


    fun test6() {
runBlocking {
//构造热流
val flow = MutableSharedFlow<String>(0, 10)
//开启协程
GlobalScope.launch {
//接收数据(消费者)
flow.collect {
delay(2000)
println("collect: $it")
}
}
//发送数据(生产者)
delay(200)//保证消费者先执行
println("emit 1 ${System.currentTimeMillis()}")
flow.emit("hello world1")
println("emit 2 ${System.currentTimeMillis()}")
flow.emit("hello world2")
println("emit 3 ${System.currentTimeMillis()}")
flow.emit("hello world3")
println("emit 4 ${System.currentTimeMillis()}")
flow.emit("hello world4")
}
}

MutableSharedFlow(0, 10) 第2个参数10表示额外的缓存大小为10,生产者通过emit先将数据放到缓存里,此时它并没有被消费者的速度拖累。


7. 重放与额外缓存个数


public fun <T> MutableSharedFlow(
replay: Int = 0,//重放个数
extraBufferCapacity: Int = 0,//额外的缓存个数
onBufferOverflow: BufferOverflow = BufferOverflow.SUSPEND
):

重放主要用来给新进的消费者重放特定个数的历史数据,而额外的缓存个数是为了应付背压问题,总的缓存个数=重放个数+额外的缓存个数。


应用场景


如有以下需求,可用SharedFlow




  1. 需要重放历史数据

  2. 可以配置缓存

  3. 需要重复发射/接收相同的值



3. SharedFlow 原理不一样的角度分析


带着问题找答案


重点关注的无非是emit和collect函数,它俩都是挂起函数,而是否挂起取决于是否满足条件。同时生产者和消费出现的时机也会影响这个条件,因此列举生产者、消费者出现的时机即可。


只有生产者


当只有生产者没有消费者,此时生产者调用emit会挂起协程吗?如果不是,那么什么情况会挂起?

从emit函数源码入手:


    override suspend fun emit(value: T) {
//如果发射成功,则直接退出函数
if (tryEmit(value)) return // fast-path
//否则挂起协程
emitSuspend(value)
}

先看tryEmit(xx):


    override fun tryEmit(value: T): Boolean {
var resumes: Array<Continuation<Unit>?> = EMPTY_RESUMES
val emitted = kotlinx.coroutines.internal.synchronized(this) {
//尝试emit
if (tryEmitLocked(value)) {
//遍历所有消费者,找到需要唤醒的消费者协程
resumes = findSlotsToResumeLocked(resumes)
true
} else {
false
}
}
//恢复消费者协程
for (cont in resumes) cont?.resume(Unit)
//emitted==true表示发射成功
return emitted
}

private fun tryEmitLocked(value: T): Boolean {
//nCollectors 表示消费者个数,若是没有消费者则无论如何都会发射成功
if (nCollectors == 0) return tryEmitNoCollectorsLocked(value) // always returns true
if (bufferSize >= bufferCapacity && minCollectorIndex <= replayIndex) {
//如果缓存已经满并且有消费者没有消费最旧的数据(replayIndex),则进入此处
when (onBufferOverflow) {
//挂起生产者
BufferOverflow.SUSPEND -> return false // will suspend
//直接丢弃最新数据,认为发射成功
BufferOverflow.DROP_LATEST -> return true // just drop incoming
//丢弃最旧的数据
BufferOverflow.DROP_OLDEST -> {} // force enqueue & drop oldest instead
}
}
//将数据加入到缓存队列里
enqueueLocked(value)
//缓存数据队列长度
bufferSize++ // value was added to buffer
// drop oldest from the buffer if it became more than bufferCapacity
if (bufferSize > bufferCapacity) dropOldestLocked()
// keep replaySize not larger that needed
if (replaySize > replay) { // increment replayIndex by one
updateBufferLocked(replayIndex + 1, minCollectorIndex, bufferEndIndex, queueEndIndex)
}
return true
}

private fun tryEmitNoCollectorsLocked(value: T): Boolean {
kotlinx.coroutines.assert { nCollectors == 0 }
//没有设置重放,则直接退出,丢弃发射的值
if (replay == 0) return true // no need to replay, just forget it now
//加入到缓存里
enqueueLocked(value) // enqueue to replayCache
bufferSize++ // value was added to buffer
// drop oldest from the buffer if it became more than replay
//若是超出了重放个数,则丢弃最旧的值
if (bufferSize > replay) dropOldestLocked()
minCollectorIndex = head + bufferSize // a default value (max allowed)
//发射成功
return true
}

再看emitSuspend(value):


    private suspend fun emitSuspend(value: T) = suspendCancellableCoroutine<Unit> sc@{ cont ->
var resumes: Array<Continuation<Unit>?> = EMPTY_RESUMES
val emitter = kotlinx.coroutines.internal.synchronized(this) lock@{
...
//构造为Emitter,加入到buffer里
SharedFlowImpl.Emitter(this, head + totalSize, value, cont).also {
enqueueLocked(it)
//单独记录挂起的emit
queueSize++ // added to queue of waiting emitters
// synchronous shared flow might rendezvous with waiting emitter
if (bufferCapacity == 0) resumes = findSlotsToResumeLocked(resumes)
}
}
}

用图表示整个emit流程:



image.png


现在可以回到上面的问题了。




  1. 如果没有消费者,生产者调用emit函数永远不会挂起

  2. 有消费者注册了并且缓存容量已满并且最旧的数据没有被消费,则生产者emit函数有机会被挂起,如果设定了挂起模式,则会被挂起



最旧的数据下面会分析


只有消费者


当只有消费者时,消费者调用collect会被挂起吗?

从collect函数源码入手。


    override suspend fun collect(collector: FlowCollector<T>) {
//分配slot
val slot = allocateSlot()//①
try {
if (collector is SubscribedFlowCollector) collector.onSubscription()
val collectorJob = currentCoroutineContext()[Job]
while (true) {
//死循环
var newValue: Any?
while (true) {
//尝试获取值 ②
newValue = tryTakeValue(slot) // attempt no-suspend fast path first
if (newValue !== NO_VALUE)
break//拿到值,退出内层循环
//没拿到值,挂起等待 ③
awaitValue(slot) // await signal that the new value is available
}
collectorJob?.ensureActive()
//拿到值,消费数据
collector.emit(newValue as T)
}
} finally {
freeSlot(slot)
}
}

重点看三点:

① allocateSlot()

先看Slot数据结构:


    private class SharedFlowSlot : AbstractSharedFlowSlot<SharedFlowImpl<*>>() {
//消费者当前应该消费的数据在生产者缓存里的索引
var index = -1L // current "to-be-emitted" index, -1 means the slot is free now
//挂起的消费者协程体
var cont: Continuation<Unit>? = null // collector waiting for new value
}

每此调用collect都会为其生成一个AbstractSharedFlowSlot对象,该对象存储在AbstractSharedFlowSlot对象数组:slots里


allocateSlot() 有两个作用:




  1. 给slots数组扩容

  2. 往slots数组里存放AbstractSharedFlowSlot对象



② tryTakeValue(slot)

创建了slot之后就可以去取值了


    private fun tryTakeValue(slot: SharedFlowSlot): Any? {
var resumes: Array<Continuation<Unit>?> = EMPTY_RESUMES
val value = kotlinx.coroutines.internal.synchronized(this) {
//找到slot对应的buffer里的数据索引
val index = tryPeekLocked(slot)
if (index < 0) {
//没找到
NO_VALUE
} else {
//找到
val oldIndex = slot.index
//根据索引,从buffer里获取值
val newValue = getPeekedValueLockedAt(index)
//slot索引增加,指向buffer里的下个数据
slot.index = index + 1 // points to the next index after peeked one
//更新游标等信息,并返回挂起的生产者协程
resumes = updateCollectorIndexLocked(oldIndex)
newValue
}
}
//如果可以,则唤起生产者协程
for (resume in resumes) resume?.resume(Unit)
return value
}

该函数有可能取到值,也可能取不到。


③ awaitValue


    private suspend fun awaitValue(slot: kotlinx.coroutines.flow.SharedFlowSlot): Unit = suspendCancellableCoroutine { cont ->
kotlinx.coroutines.internal.synchronized(this) lock@{
//再次尝试获取
val index = tryPeekLocked(slot) // recheck under this lock
if (index < 0) {
//说明没数据可取,此时记录当前协程,后续恢复时才能找到
slot.cont = cont // Ok -- suspending
} else {
//有数据了,则唤醒
cont.resume(Unit) // has value, no need to suspend
return@lock
}
slot.cont = cont // suspend, waiting
}
}


image.png


对比生产者emit和消费者collect流程,显然collect流程比emit流程简单多了。


现在可以回到上面的问题了。



无论是否有生产者,只要没拿到数据,collect都会被挂起



slot与buffer


以上分别分析了emit和collect流程,我们知道了emit可能被挂起,被挂起后可以通过collect唤醒,同样的collect也可能被挂起,挂起后通过emit唤醒。

重点在于两者是如何交换数据的,也就是slot对象和buffer是怎么关联的?


image.png


如上图,简介其流程:




  1. SharedFlow设定重放个数为4,额外容量为3,总容量为4+3=7

  2. 生产者将数据堆到buffer里,此时消费者还没开始collect

  3. 消费者开始collect,因为设置了重放个数,因此构造Slot对象时,slot.index=0,根据index找到buffer下标为0的元素即为可以消费的元素

  4. 拿到0号数据后,slot.index=1,找到buffer下标为1的元素

  5. index++,重复4的步骤



因为collect消费了数据,因此emit可以继续放新的数据,此时又有新的collect加入进来:


image.png




  1. 新加入的消费者collect时构造Slot对象,因为此时的buffer最旧的值为buffer下标为2,因此Slot初始化Slot.index = 2,取第2个数据

  2. 同样的,继续往后取值



此时有了2个消费者,假设消费者2消费速度很慢,它停留在了index=3,而消费者1消费速度快,变成了如下图:


image.png




  1. 消费者1在取index=4的值(可以继续往后消费数据),消费者2在取index=3的值

  2. 生产者此时已经填充满buffer了,buffer里最旧的值为index=4,为了保证消费者2能够获取到index=4的值,此时它不能再emit新的数据了,于是生产者被挂起

  3. 等到消费者2消费了index=4的值,就会唤醒正在挂起的生产者继续生产数据



由此得出一个结论:



SharedFlow的emit可能会被最慢的collect拖累从而挂起



该现象用代码查看打印比较直观:


    fun test7() {
runBlocking {
//构造热流
val flow = MutableSharedFlow<String>(4, 3)
//开启协程
GlobalScope.launch {
//接收数据(消费者1)
flow.collect {
println("collect1: $it")
}
}
GlobalScope.launch {
//接收数据(消费者2)
flow.collect {
//模拟消费慢
delay(10000)
println("collect2: $it")
}
}
//发送数据(生产者)
delay(200)//保证消费者先执行
var count = 0
while (true) {
flow.emit("emit:${count++}")
}
}
}

4. StateFlow 使用方式与应用场景


使用方式


1. 重放功能

上面花了很大篇幅分析SharedFlow,而StateFlow是SharedFlow的特例,先来看其简单使用。


    fun test8() {
runBlocking {
//构造热流
val flow = MutableStateFlow("")
flow.emit("hello world")
flow.collect {
//消费者
println(it)
}
}
}

我们发现,并没有给Flow设置重放,此时消费者依然能够消费到数据,说明StateFlow默认支持历史数据重放。


2. 重放个数

具体能重放几个值呢?


    fun test10() {
runBlocking {
//构造热流
val flow = MutableStateFlow("")
flow.emit("hello world")
flow.emit("hello world1")
flow.emit("hello world2")
flow.emit("hello world3")
flow.emit("hello world4")
flow.collect {
//消费者
println(it)
}
}
}

最后发现消费者只有1次打印,说明StateFlow只重放1次,并且是最新的值。


3. 防抖


    fun test9() {
runBlocking {
//构造热流
val flow = MutableStateFlow("")
flow.emit("hello world")
GlobalScope.launch {
flow.collect {
//消费者
println(it)
}
}
//再发送
delay(1000)
flow.emit("hello world")
// flow.emit("hello world")
}
}

生产者发送了两次数据,猜猜此时消费者有几次打印?

答案是只有1次,因为StateFlow设计了防抖,当emit时会检测当前的值和上一次的值是否一致,若一致则直接抛弃当前数据不做任何处理,collect当然就收不到值了。若是我们将注释放开,则会有2次打印。


应用场景


StateFlow 和LiveData很像,都是只维护一个值,旧的值过来就会将新值覆盖。

适用于通知状态变化的场景,如下载进度。适用于只关注最新的值的变化。

如果你熟悉LiveData,就可以理解为StateFlow基本可以做到替换LiveData功能。


5. StateFlow 原理一看就会


如果你看懂了SharedFlow原理,那么对StateFlow原理的理解就不在话下了。


emit 过程


    override suspend fun emit(value: T) {
//value 为StateFlow维护的值,每次emit都会修改它
this.value = value
}

public override var value: T
get() = NULL.unbox(_state.value)//从state取出
set(value) { updateState(null, value ?: NULL) }


private fun updateState(expectedState: Any?, newState: Any): Boolean {
var curSequence = 0
var curSlots: Array<StateFlowSlot?>? = this.slots // benign race, we will not use it
kotlinx.coroutines.internal.synchronized(this) {
val oldState = _state.value
if (expectedState != null && oldState != expectedState) return false // CAS support
//新旧值一致,则无需更新
if (oldState == newState) return true // Don't do anything if value is not changing, but CAS -> true
//更新到state里
_state.value = newState
curSequence = sequence
//...
curSlots = slots // read current reference to collectors under lock
}

while (true) {
curSlots?.forEach {
//遍历消费者,修改状态或是将挂起的消费者唤醒
it?.makePending()
}
...
}
}

emit过程就是修改value值的过程,无论是否修改成功,emit函数都会退出,它不会被挂起。


collect 过程


    override suspend fun collect(collector: FlowCollector<T>) {
//分配slot
val slot = allocateSlot()
try {
if (collector is SubscribedFlowCollector) collector.onSubscription()
val collectorJob = currentCoroutineContext()[Job]
var oldState: Any? = null // previously emitted T!! | NULL (null -- nothing emitted yet)
while (true) {
val newState = _state.value
collectorJob?.ensureActive()
//值不相同才调用collect闭包
if (oldState == null || oldState != newState) {
collector.emit(NULL.unbox(newState))
oldState = newState
}
if (!slot.takePending()) { // try fast-path without suspending first
//挂起协程
slot.awaitPending() // only suspend for new values when needed
}
}
} finally {
freeSlot(slot)
}
}

StateFlow 也有slot,叫做StateFlowSlot,它比SharedFlowSlot简单多了,因为始终只需要维护一个值,所以不需要index。里面有个成员变量_state,该值既可以是消费者协程当前的状态,也可以表示协程体。

当表示为协程体时,说明此时消费者被挂起了,等到生产者通过emit唤醒该协程。


image.png


6. StateFlow/SharedFlow/LiveData 区别与应用




  1. StateFlow 是SharedFlow特例

  2. SharedFlow 多用于事件通知,StateFlow/LiveData多用于状态变化

  3. StateFlow 有默认值,LiveData没有,StateFlow.collect闭包可在子线程执行,LiveData.observe需要在主线程监听,StateFlow没有关联生命周期,LiveData关联了生命周期,StateFlow防抖,LiveData不防抖等等。



随着本篇的完结,Kotlin协程系列也告一段落了,接下来将重点放在协程工程架构实践上,敬请期待。


以上为Flow背压和线程切换的全部内容,下篇将分析Flow的热流。

本文基于Kotlin 1.5.3,文中完整Demo请点击


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

我代码就加了一行log日志,结果引发了P1的线上事故

线上事故回顾前段时间新增一个特别简单的功能,晚上上线前review代码时想到公司拼搏进取的价值观临时加一行log日志,觉得就一行简单的日志基本上没啥问题,结果刚上完线后一堆报警,赶紧回滚了代码,找到问题删除了添加日志的代码,重新上线完毕。情景还原定义了一个 C...
继续阅读 »

线上事故回顾

前段时间新增一个特别简单的功能,晚上上线前review代码时想到公司拼搏进取的价值观临时加一行log日志,觉得就一行简单的日志基本上没啥问题,结果刚上完线后一堆报警,赶紧回滚了代码,找到问题删除了添加日志的代码,重新上线完毕。

情景还原

定义了一个 CountryDTO

public class CountryDTO {
   private String country;

   public void setCountry(String country) {
       this.country = country;
  }

   public String getCountry() {
       return this.country;
  }

   public Boolean isChinaName() {
       return this.country.equals("中国");
  }
}
复制代码

定义测试类 FastJonTest

public class FastJonTest {
   @Test
   public void testSerialize() {
       CountryDTO countryDTO = new CountryDTO();
       String str = JSON.toJSONString(countryDTO);
       System.out.println(str);
  }
}

运行时报空指针错误: 通过报错信息可以看出来是 序列化的过程中执行了 isChinaName()方法,这时候this.country变量为空, 那么问题来了:

  • 序列化为什么会执行isChinaName()呢?

  • 引申一下,序列化过程中会执行那些方法呢?

源码分析

通过debug观察调用链路的堆栈信息 调用链中的ASMSerializer_1_CountryDTO.writeFastJson使用asm技术动态生成了一个类ASMSerializer_1_CountryDTO,

asm技术其中一项使用场景就是通过到动态生成类用来代替java反射,从而避免重复执行时的反射开销

JavaBeanSerizlier序列化原理

通过下图看出序列化的过程中,主要是调用JavaBeanSerializer类的write()方法。 JavaBeanSerializer 主要是通过 getObjectWriter()方法获取,通过对getObjectWriter()执行过程的调试,找到比较关键的com.alibaba.fastjson.serializer.SerializeConfig#createJavaBeanSerializer方法,进而找到 com.alibaba.fastjson.util.TypeUtils#computeGetters

public static List<FieldInfo> computeGetters(Class<?> clazz, //
                                                JSONType jsonType, //
                                                Map<String,String> aliasMap, //
                                                Map<String,Field> fieldCacheMap, //
                                                boolean sorted, //
                                                PropertyNamingStrategy propertyNamingStrategy //
  ){
       //省略部分代码....
       Method[] methods = clazz.getMethods();
       for(Method method : methods){
           //省略部分代码...
           if(method.getReturnType().equals(Void.TYPE)){
               continue;
          }
           if(method.getParameterTypes().length != 0){
               continue;
          }
      //省略部分代码...
           JSONField annotation = TypeUtils.getAnnotation(method, JSONField.class);
           //省略部分代码...
           if(annotation != null){
               if(!annotation.serialize()){
                   continue;
              }
               if(annotation.name().length() != 0){
                   //省略部分代码...
              }
          }
           if(methodName.startsWith("get")){
            //省略部分代码...
          }
           if(methodName.startsWith("is")){
            //省略部分代码...
          }
      }
}

从代码中大致分为三种情况:

  • @JSONField(.serialize = false, name = "xxx")注解

  • getXxx() : get开头的方法

  • isXxx():is开头的方法

序列化流程图


示例代码

/**
* case1: @JSONField(serialize = false)
* case2: getXxx()返回值为void
* case3: isXxx()返回值不等于布尔类型
* case4: @JSONType(ignores = "xxx")
*/
@JSONType(ignores = "otherName")
public class CountryDTO {
   private String country;

   public void setCountry(String country) {
       this.country = country;
  }

   public String getCountry() {
       return this.country;
  }

   public static void queryCountryList() {
       System.out.println("queryCountryList()执行!!");
  }

   public Boolean isChinaName() {
       System.out.println("isChinaName()执行!!");
       return true;
  }

   public String getEnglishName() {
       System.out.println("getEnglishName()执行!!");
       return "lucy";
  }

   public String getOtherName() {
       System.out.println("getOtherName()执行!!");
       return "lucy";
  }

   /**
    * case1: @JSONField(serialize = false)
    */
   @JSONField(serialize = false)
   public String getEnglishName2() {
       System.out.println("getEnglishName2()执行!!");
       return "lucy";
  }

   /**
    * case2: getXxx()返回值为void
    */
   public void getEnglishName3() {
       System.out.println("getEnglishName3()执行!!");
  }

   /**
    * case3: isXxx()返回值不等于布尔类型
    */
   public String isChinaName2() {
       System.out.println("isChinaName2()执行!!");
       return "isChinaName2";
  }
}

运行结果为:

isChinaName()执行!!
getEnglishName()执行!!
{"chinaName":true,"englishName":"lucy"}

代码规范

可以看出来序列化的规则还是很多的,比如有时需要关注返回值,有时需要关注参数个数,有时需要关注@JSONType注解,有时需要关注@JSONField注解;当一个事物的判别方式有多种的时候,由于团队人员掌握知识点的程度不一样,这个方差很容易导致代码问题,所以尽量有一种推荐方案。 这里推荐使用@JSONField(serialize = false)来显式的标注方法不参与序列化,下面是使用推荐方案后的代码,是不是一眼就能看出来哪些方法不需要参与序列化了。

public class CountryDTO {
   private String country;

   public void setCountry(String country) {
       this.country = country;
  }

   public String getCountry() {
       return this.country;
  }

   @JSONField(serialize = false)
   public static void queryCountryList() {
       System.out.println("queryCountryList()执行!!");
  }

   public Boolean isChinaName() {
       System.out.println("isChinaName()执行!!");
       return true;
  }

   public String getEnglishName() {
       System.out.println("getEnglishName()执行!!");
       return "lucy";
  }

   @JSONField(serialize = false)
   public String getOtherName() {
       System.out.println("getOtherName()执行!!");
       return "lucy";
  }

   @JSONField(serialize = false)
   public String getEnglishName2() {
       System.out.println("getEnglishName2()执行!!");
       return "lucy";
  }

   @JSONField(serialize = false)
   public void getEnglishName3() {
       System.out.println("getEnglishName3()执行!!");
  }

   @JSONField(serialize = false)
   public String isChinaName2() {
       System.out.println("isChinaName2()执行!!");
       return "isChinaName2";
  }
}

三个频率高的序列化的情况

以上流程基本遵循 发现问题 --> 原理分析 --> 解决问题 --> 升华(编程规范)。

  • 围绕业务上:解决问题 -> 如何选择一种好的额解决方案 -> 好的解决方式如何扩展n个系统应用;

  • 围绕技术上:解决单个问题,顺着单个问题掌握这条线上的原理。

作者:老鹰汤
来源:juejin.cn/post/7156439842958606349

收起阅读 »

React和Vue谁会淘汰谁?

web
在我的技术群里大家经常会聊一些宏观的技术问题,就比如:Vue和React,最终谁会被淘汰?这样的讨论,到最后往往会陷入技术的细枝末节的比较,比如:对比两者响应式的实现原理对比两者的运行时性能很多程序员朋友,会觉得:技术问题,就应该从技术的角度找到答案但实际上,...
继续阅读 »

在我的技术群里大家经常会聊一些宏观的技术问题,就比如:

Vue和React,最终谁会被淘汰?

这样的讨论,到最后往往会陷入技术的细枝末节的比较,比如:

  • 对比两者响应式的实现原理

  • 对比两者的运行时性能

很多程序员朋友,会觉得:

技术问题,就应该从技术的角度找到答案

但实际上,一些大家纠结的技术问题,往往跟技术本身无关。

谁才是框架的最终赢家?

讨论React和Vue谁会淘汰谁?这个问题,就像10年前,一个康师傅信徒和一个统一信徒争论:

哪家泡面企业最终会被淘汰呢?

他们把争论的重点放在口味的对比面饼分量的对比等等,最终谁也无法说服谁。

实际我们最后知道了,外卖App的崛起,对泡面企业形成了降维打击。

回到框架这个问题上,在前端框架流行之前,前端最流行的开发库是jQuery,他是命令式编程的编程范式。

取代jQuery的并不是另一个更优秀的jQuery,而是声明式编程的前端框架。

同样的,取代前端框架的,不会是另一个更优秀的前端框架,而是另一种更适合web开发的编程范式。

那在前端框架这个领域内部,ReactVue最终谁会淘汰谁呢?

我的答案是:

谁也不会淘汰谁。

任何框架最核心的竞争力,不是性能,也不是生态是否繁荣,而是开发者用的是否顺手,也就是开发模式是否合理

React发明了JSX这种开发模式,并持续教育了开发者3年,才让社区接受这种开发模式

这种发明开发模式,再教育开发者的行为,也只有meta这种大公司才办得到。

Vue则直接使用了模版语法这种现成的开发模式。这种模式已经被广大后端工程师验证过是最好上手的web开发模式。

所以像后端工程师或者编程新人会很容易上手Vue

经过多年迭代,他们各自的开发模式已经变成了事实上的前端框架DSL标准。

这会为他们带来两个好处:

  1. 开发模式是个主观偏好,不存在优劣

所以他们谁也无法淘汰谁,只能说React的开发模式受众范围更广而已。

  1. 后来者会永远居于他们的阴影之下

新的框架如果无法在编程范式上突破,那么为了抢占VueReact的市场份额,只能遵循他们的开发模式,因为这样开发者才能无痛迁移。

比如最近两年比较优秀的新框架,svelteVue的开发模式,Solid.jsReact的开发模式

在同样的开发模式下,占市场主导地位的框架可以迅速跟进那些竞争者的优秀特性。

比如Vue就准备开发一个类似Svelte的版本。

一句话总结就是:

你是无法在我的BGM中击败我的

总结

总体来说,在新的web编程范式流行之前,ReactVue还会长期霸占开发者喜欢的前端框架前列。

在此过程中,会出现各种新框架,他们各有各的特点,但是,都很难撼动前者的地位。

作者:魔术师卡颂
来源:juejin.cn/post/7190550643386351653

收起阅读 »

为什么大家都说 SELECT * 效率低?

无论在工作还是面试中,关于SQL中不要用“SELECT *”,都是大家听烂了的问题,虽说听烂了,但普遍理解还是在很浅的层面,并没有多少人去追根究底,探究其原理。 效率低的原因 先看一下最新《阿里java开发手册(泰山版)》中 MySQL 部分描述: 【强制】在...
继续阅读 »

无论在工作还是面试中,关于SQL中不要用“SELECT *”,都是大家听烂了的问题,虽说听烂了,但普遍理解还是在很浅的层面,并没有多少人去追根究底,探究其原理。


效率低的原因


先看一下最新《阿里java开发手册(泰山版)》中 MySQL 部分描述:


【强制】在表查询中,一律不要使用 * 作为查询的字段列表,需要哪些字段必须明确写明。说明:



  • 增加查询分析器解析成本。

  • 增减字段容易与 resultMap 配置不一致。

  • 无用字段增加网络 消耗,尤其是 text 类型的字段。


开发手册中比较概括的提到了几点原因,让我们深入一些看看:


1. 不需要的列会增加数据传输时间和网络开销



  • 用“SELECT * ”数据库需要解析更多的对象、字段、权限、属性等相关内容,在 SQL 语句复杂,硬解析较多的情况下,会对数据库造成沉重的负担。

  • 增大网络开销;* 有时会误带上如log、IconMD5之类的无用且大文本字段,数据传输size会几何增涨。如果DB和应用程序不在同一台机器,这种开销非常明显

  • 即使 mysql 服务器和客户端是在同一台机器上,使用的协议还是 tcp,通信也是需要额外的时间。


2. 对于无用的大字段,如 varchar、blob、text,会增加 io 操作


准确来说,长度超过 728 字节的时候,会先把超出的数据序列化到另外一个地方,因此读取这条记录会增加一次 io 操作。(MySQL InnoDB)


3. 失去MySQL优化器“覆盖索引”策略优化的可能性


SELECT * 杜绝了覆盖索引的可能性,而基于MySQL优化器的“覆盖索引”策略又是速度极快,效率极高,业界极为推荐的查询优化方式。


例如,有一个表为t(a,b,c,d,e,f),其中,a为主键,b列有索引。


那么,在磁盘上有两棵 B+ 树,即聚集索引和辅助索引(包括单列索引、联合索引),分别保存(a,b,c,d,e,f)和(a,b),如果查询条件中where条件可以通过b列的索引过滤掉一部分记录,查询就会先走辅助索引,如果用户只需要a列和b列的数据,直接通过辅助索引就可以知道用户查询的数据。


如果用户使用select *,获取了不需要的数据,则首先通过辅助索引过滤数据,然后再通过聚集索引获取所有的列,这就多了一次b+树查询,速度必然会慢很多。


由于辅助索引的数据比聚集索引少很多,很多情况下,通过辅助索引进行覆盖索引(通过索引就能获取用户需要的所有列),都不需要读磁盘,直接从内存取,而聚集索引很可能数据在磁盘(外存)中(取决于buffer pool的大小和命中率),这种情况下,一个是内存读,一个是磁盘读,速度差异就很显著了,几乎是数量级的差异。


索引知识延申


上面提到了辅助索引,在MySQL中辅助索引包括单列索引、联合索引(多列联合),单列索引就不再赘述了,这里提一下联合索引的作用。


联合索引 (a,b,c)


联合索引 (a,b,c) 实际建立了 (a)、(a,b)、(a,b,c) 三个索引


我们可以将组合索引想成书的一级目录、二级目录、三级目录,如index(a,b,c),相当于a是一级目录,b是一级目录下的二级目录,c是二级目录下的三级目录。要使用某一目录,必须先使用其上级目录,一级目录除外。


image.png


联合索引的优势


1) 减少开销


建一个联合索引 (a,b,c) ,实际相当于建了 (a)、(a,b)、(a,b,c) 三个索引。每多一个索引,都会增加写操作的开销和磁盘空间的开销。对于大量数据的表,使用联合索引会大大的减少开销!


2)覆盖索引


对联合索引 (a,b,c),如果有如下 sql 的,


SELECT a,b,c from table where a='xx' and b = 'xx';

那么 MySQL 可以直接通过遍历索引取得数据,而无需回表,这减少了很多的随机 io 操作。减少 io 操作,特别是随机 io 其实是 DBA 主要的优化策略。所以,在真正的实际应用中,覆盖索引是主要的提升性能的优化手段之一。


3)效率高


索引列多,通过联合索引筛选出的数据越少。比如有 1000W 条数据的表,有如下SQL:


select col1,col2,col3 from table where col1=1 and col2=2 and col3=3;

假设:假设每个条件可以筛选出 10% 的数据。



  • A. 如果只有单列索引,那么通过该索引能筛选出 1000W 10%=100w 条数据,然后再回表从 100w 条数据中找到符合 col2=2 and col3= 3 的数据,然后再排序,再分页,以此类推(递归);

  • B. 如果是(col1,col2,col3)联合索引,通过三列索引筛选出 1000w 10% 10% *10%=1w,效率提升可想而知!


4)索引是建的越多越好吗


答案自然是否定的



  • 数据量小的表不需要建立索引,建立会增加额外的索引开销

  • 不经常引用的列不要建立索引,因为不常用,即使建立了索引也没有多大意义

  • 经常频繁更新的列不要建立索引,因为肯定会影响插入或更新的效率

  • 数据重复且分布平均的字段,因此他建立索引就没有太大的效果(例如性别字段,只有男女,不适合建立索引)

  • 数据变更需要维护索引,意味着索引越多维护成本越高。

  • 更多的索引也需要更多的存储空间

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

Android 关于集成环信点击行为不唤起落地页问题

1、华为推送前台点击行为不唤起落地页问题华为系统级别通知,应用系统特性,华为指定固定跳转页面action(如:刷屏页面、广告页面),可实现前台或后台都唤起落地页{ "async":false, "targets": [ "cs1" ...
继续阅读 »

1、华为推送前台点击行为不唤起落地页问题

华为系统级别通知,应用系统特性,华为指定固定跳转页面action(如:刷屏页面、广告页面),可实现前台或后台都唤起落地页

{
"async":false,
"targets": [
"cs1"
],
"strategy": 2,
"pushMessage": {
"title": "环信",
"content": "你好,欢迎使用环信推送服务",
"sub_title": "环信",
"ext":{
"test":"skip test"
},
"config": {
"clickAction": {
"action": "com.a.b.shot"
}
}
}
}

2、小米vivo离线点击未触发重写方法

vivo 没有点击回调,可在跳转页面获取,vivo指定 activity


注意:小米预定义通知不走onNotificationMessageClicked。指定跳转类型为预定义通知。


不指定,点击回调我们的应用测试正常。可自行重写PushMessageReceiver 验证是否小米sdk 未触发 PushMessageReceiver.onNotificationMessageClicked

指定,小米推送配置指定action,在相应的Activity中可以调用Intent的getSerializableExtra(PushMessageHelper.KEY_MESSAGE)方法得到MiPushMessage对象。


public class MyMipushReceiver extends EMMiMsgReceiver {

@Override
public void onNotificationMessageClicked(Context context, MiPushMessage miPushMessage) {
String extStr = miPushMessage.getContent();
JSONObject extras = new JSONObject(extStr);
if (extras !=null ){
String t = extras.getString("xxxx");
//handle
}
}
}

public class EMMiMsgReceiver extends PushMessageReceiver {
private static final String TAG = "EMMiMsgReceiver";

public void onNotificationMessageClicked(Context context, MiPushMessage message) {
EMLog.i(TAG, "onNotificationMessageClicked is called. " + message.toString());
Intent msgIntent = context.getPackageManager().getLaunchIntentForPackage(context.getPackageName());
msgIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
context.startActivity(msgIntent);
}
}


4、oppo 和魅族的点击能力(是否和华为一样)

经验证行为和华为一样,前台情况下也需要指定跳转页,oppo, 魅族指定 activity

{
"async":false,
"targets": [
"cs1"
],
"strategy": 2,
"pushMessage": {
"title": "公子小白有点黑",
"content": "你好,欢迎使用环信推送服务",
"sub_title": "环信",
"config": {
"clickAction": {
"action": "com.a.b.shot",
"activity": "com.hyphenate.easeim.section.me.activity.AboutHxActivity"
}
}
}
}


5、离线扩展对应问题,设置的内容和获取对应即可。

如设置:"ext":{"test1":"t1", "test2":"t2"},获取如下

public class MainActivity extends BaseActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
Bundle extras = getIntent().getExtras();
if (extras != null) {
String t = extras.getString("test1");
String f = extras.getString("test2");
//TODO
}
}
}


收起阅读 »

通过官网项目来学习——Jetpack之Startup库

nowinandroid项目作为目前google官方来演示MAD(现代Android开发技术)的示例项目,里面大量依赖运用了jetpack包下的各种库。通过分析学习这些库在项中的实际使用可以帮助我们比直接阅读库的文档来更好的理解和学习。希望通过学习后可以帮助到...
继续阅读 »

nowinandroid项目作为目前google官方来演示MAD(现代Android开发技术)的示例项目,里面大量依赖运用了jetpack包下的各种库。通过分析学习这些库在项中的实际使用可以帮助我们比直接阅读库的文档来更好的理解和学习。希望通过学习后可以帮助到我们能熟练地在我们自己的项目中正确高效的使用到jetpack里面的各种强大库。不废话了,下面进入我们今天的正题——Startup


简单认识一下Startup


image.png


App Startup  |  Android Developers 官网的指南有兴趣可以看看


我们今天不讲原理,你只需知道这个库比之前用多个content provider去实现初始化更高效,更精确,更显性,也就是说能合并content provider提升app的启动速度,能准确的控制初始化顺序,能清晰的从代码知道依赖关系。仅仅这些可能jym会说,我们项目不在乎那点启动速度的提升,也没有很多三方库需要走初始化等,根本用不到这个库。是的,我之前也是这么理解的,但是通过nowinandroid项目发现,有些jetpack内的其他库的初始化现在也交给Startup来完成了,这一点就很重要了。意味着我们可以少写很多样板代码,少写也意味着少犯错。所以我觉的还是有必要单独写一篇文章来说说Startup


编写初始化的代码步骤很简单主要就分3步:



  1. 定义实现Initializer接口的实现类

  2. 配置manifest

  3. 自动或手动调用初始化操作


OK了!就这简单3步,下面我们结合项目例子来看


项目代码



  • 先看第一步


object Sync {
// This method is a workaround to manually initialize the sync process instead of relying on
// automatic initialization with Androidx Startup. It is called from the app module's
// Application.onCreate() and should be only done once.
fun initialize(context: Context) {
AppInitializer.getInstance(context)
.initializeComponent(SyncInitializer::class.java)
}
}

internal const val SyncWorkName = "SyncWorkName"

/**
* Registers work to sync the data layer periodically on app startup.
*/
class SyncInitializer : Initializer<Sync> {
override fun create(context: Context): Sync {
WorkManager.getInstance(context).apply {
// Run sync on app startup and ensure only one sync worker runs at any time
enqueueUniqueWork(
SyncWorkName,
ExistingWorkPolicy.KEEP,
SyncWorker.startUpSyncWork(),
)
}

return Sync
}

override fun dependencies(): List<Class<out Initializer<*>>> =
listOf(WorkManagerInitializer::class.java)
}

定一个SyncInitializer类实现了泛型为SyncInitializer接口。需要重写接口定义的两个方法:



  •  create() 方法, 它包含初始化组件所需的所有操作,并返回一个Sync的实例.

  •  dependencies() 方法, 返回当前初始化器需要依赖的其他初始化器集合,我们可以用这个方法来变相的实现各个初始化器的执行顺序。


所以在create方法里面的执行WorkManager.getInstance(context)方法是安全的。我们这篇只关注Startup所以我们只用知道在这个地方WorkManager做了些事情就行,后面会另开一篇单独讲WorkManager。为啥是安全的呢?因为在dependencies方法里面先执行了WorkManagerInitializer::class.java初始化。我们再来看看这个类。


public final class WorkManagerInitializer implements Initializer<WorkManager> {

private static final String TAG = Logger.tagWithPrefix("WrkMgrInitializer");

@NonNull
@Override
public WorkManager create(@NonNull Context context) {
// Initialize WorkManager with the default configuration.
Logger.get().debug(TAG, "Initializing WorkManager with default configuration.");
//这个地方已经完成了单例的构建,后面再调用WorkManager.getInstance(context)获取实例,否则报错
WorkManager.initialize(context, new Configuration.Builder().build());
return WorkManager.getInstance(context);
}

@NonNull
@Override
public List<Class<? extends androidx.startup.Initializer<?>>> dependencies() {
//这里WorkManager的初始化不需要其他初始化构造器,所以返回的是个空集合
return Collections.emptyList();
}
}

以上我们就把第一步走完了,现在再来看第二步



  • 再看第二步


<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">

<application>
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">
<!-- TODO: b/2173216 Disable auto sync startup till it works well with instrumented tests -->
<meta-data
android:name="com.google.samples.apps.nowinandroid.sync.initializers.SyncInitializer"
android:value="androidx.startup"
tools:node="remove" />
</provider>

</application>

</manifest>

这里需要注意的是tools:node="remove",在provider层级用的话是全局取消自动初始化,在meta-data层级用的话是单个组件取消自动初始化。例子展示的是单个组件取消自动初始化。另外注意的一点是被依赖的初始化组件是不需要再另外在manifest里面声明的,这就是为什么WorkManagerInitializer没有声明。



  • 最后一步


@HiltAndroidApp 
class NiaApplication : Application(), ImageLoaderFactory {
override fun onCreate() {
super.onCreate()
// Initialize Sync; the system responsible for keeping data in the app up to date.
Sync.initialize(context = this)
}

/**
* Since we're displaying SVGs in the app, Coil needs an ImageLoader which supports this
* format. During Coil's initialization it will call `applicationContext.newImageLoader()` to
* obtain an ImageLoader.
*
* @see <a href="https://github.com/coil-kt/coil/blob/main/coil-singleton/src/main/java/coil/Coil.kt">Coil</a>
*/
override fun newImageLoader(): ImageLoader {
return ImageLoader.Builder(this)
.components {
add(SvgDecoder.Factory())
}
.build()
}
}

上面的代码是app的Application,我们今天的重点是Startup,所以我们先不管其他的。只用看onCreate下的Sync.initialize(context = this)方法。


object Sync {
// This method is a workaround to manually initialize the sync process instead of relying on
// automatic initialization with Androidx Startup. It is called from the app module's
// Application.onCreate() and should be only done once.
fun initialize(context: Context) {
AppInitializer.getInstance(context)
.initializeComponent(SyncInitializer::class.java)
}
}

AppInitializer.getInstance(context).initializeComponent(SyncInitializer::class.java)传入SyncInitializer类,实现手动初始化完成。



以上就是nowinandroid项目对Startup库的使用,并且上面我们也知道了我们自定义的初始化器在初始化的时候通过WorkManager做了些事情。那么下篇我们还是通过这个例子来看看nowinandroid是怎么使用WorkManager这个库的。



水平有限,写作不易。各位jym高抬贵手点个赞留个言都是对我最大的鼓励


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

悲剧!Google华裔“网红”炫耀公司福利,突然被裁了!

2023年开年,全球科技企业的裁员人数已经超过了9万人。 图源网络 版权属于原作者开年裁员最狠的莫过于亚马逊、谷歌、微软等老牌科技大厂。亚马逊和微软同一天1月18日裁员,人数分别是1.8万和1万,被网友们戏称为“西雅图不眠夜”。谷歌紧随其后,在中国春节前,1月...
继续阅读 »

2023年开年,全球科技企业的裁员人数已经超过了9万人。


图源网络 版权属于原作者

开年裁员最狠的莫过于亚马逊、谷歌、微软等老牌科技大厂。

亚马逊和微软同一天1月18日裁员,人数分别是1.8万和1万,被网友们戏称为“西雅图不眠夜”。

谷歌紧随其后,在中国春节前,1月20日凌晨2点左右宣布裁员1.2万。

这场轰轰烈烈的裁员影响了不少人。特别是春节前,很多华人被裁就像是晴天霹雳。

随着裁员潮,员工无限福利时代似乎也一去不复返了...

科技大厂的各种福利总是令人艳羡。最近几年,越来越多员工开始在社交媒体上分享自己“在科技大厂工作的一天”,这类主题也通常能获得不错的点击量。

于是,一批批大厂“网红”就这样出现了,他们热衷于在网上展现公司的办公环境、福利待遇、工作气氛...

Google华裔女员工晒公司福利,突然被裁

Nicole Tsai 是一位在谷歌洛杉矶办事处工作的TikToker ,她经常使用tik tok分享她作为谷歌员工的生活和工作场景。

她分享的照片中,很多奢华的办公场景着实吸引了不少羡煞的目光,也收获了数万名粉丝!

她曾展示过谷歌的主题会议室、免费的午餐和happy hour的酒吧小酌。


图源于网络 版权属于原作者


图源于网络 版权属于原作者

然而在这次谷歌的12000人裁员大潮中,Nicole Tsai 没能幸免。

她发布了一条视频,标题为“A Day In My Life Getting Laid Off At Google”。

Nicole Tsai 表示,她醒来时看到上级发来的消息,她意识到了事情的不对,她迅速冲下楼,发现无法访问工作中的任何东西。无法登录电子邮件,甚至无法查看她的日历。

她意识到,她被裁员了。


图源于网络 版权属于原作者

Nicole Tsai 表示,她觉得这场浩荡的裁员就像是一场俄罗斯轮盘赌,完全是随机的。

她不知道接下来会发生什么,但她还会选择在社交媒体上和大家分享她裁员之后的生活。

值得一提的是,外国网友似乎并没有对Nicole Tsai表示同情。

“这些几乎没有技能的人不仅有工作,工资还高得离谱。随着利率的上升和印钞机的停止,派对结束了!”


“这些人据称是最聪明的人,但没有意识到在发布的视频中,自己表现的更像是在度假,而不是在工作,这不会有好结果。”


“没有人是无可替代的。”


“他们期望发生什么?我对他们没有同情心。他们中的大多数人在社交媒体上疯狂炫耀福利、高薪和假期。希望他们现在依旧能保持同样的精力,因为财富列车已经结束,他们已经失业了。”


身边遭遇裁员的小伙伴...

被裁员后大家面对的处境也大不相同,有些同学已经有绿卡或者身份问题无忧,找下一份工作的压力会小一些。

但是有一些小伙伴被裁即面临着身份问题,再加上自己的个人情况,要面对的压力也变得巨大。

有个小伙伴发帖分享了自己的情况:


图源一亩三分地 版权属于原作者

楼主身份今年10月就到期了,也没钱再去读书了,加上与家人的关系不好。她感觉自己仿佛一叶孤舟…

在除夕夜,面对这次裁员的楼主显得格外落寞。

在地里很多看了帖子的暖心小伙伴送上了自己的祝福,还帮助楼主分析如何面对未来的方法。

有网友说:我们都会在磨砺中成长变得更强大,请楼主相信塞翁失马焉知非福,您一定会找到走下去的路的!

作者:一亩三分地
来源:mp.weixin.qq.com/s/ZYkGk2c6WIKrUcY96RfDhw

收起阅读 »

记一次浏览器播放实时监控rtsp视频流的解决历程(利用Ffmpeg + node.js + websocket + flv.js实现)

web
背景笔者目前在做一个智慧楼宇的产品(使用react开发的),在交付项目的时候,遇到了需要在浏览器端播放rtsp视频流的场景,而浏览器端是不能直接播放rtsp视频流的,原本打算让客户方提供flv格式的视频流 鉴于项目现场的环境以及种种原因,客户方只能提供rtsp...
继续阅读 »

背景

笔者目前在做一个智慧楼宇的产品(使用react开发的),在交付项目的时候,遇到了需要在浏览器端播放rtsp视频流的场景,而浏览器端是不能直接播放rtsp视频流的,原本打算让客户方提供flv格式的视频流 鉴于项目现场的环境以及种种原因,客户方只能提供rtsp视频流,所以就只能自己解决了。

于是乎,去网上随便一搜就搜到了Ffmpeg + node.js + websocket + flv.js的解决方案,但是真正自己实现下来,遇到了几个棘手的问题,例如:莫名其妙的报错,部分监控视频转换失败,不能转码h265格式的视频等等(本文会介绍自己遇到的问题以及解决方案)。

涉及到的技术点

  • ffmpeg:ffmpeg是一个转码工具,将rtsp视频里转换成flv格式的视频流

  • node.js

  • websocket

  • flv.js

node.js端

点击进入node端的github

用到的关键库

  • @ffmpeg-installer/ffmpeg

自动为当前node服务所在的平台安装适合的ffmpeg,无需自己再去手动下载、安装配置了。通过该库安装的ffmpeg,其路径在node_modules/@ffmpeg-installer/darwin-x64/ffmpeg (我用的是mac,自动安装的是darwin-x64,不同平台不一样)

  • fluent-ffmpeg

该库是对ffmpeg 命令的封装,简化了命令的使用流程,原生ffmpeg的命令是比较复杂难懂的。

点击进入fluent-ffmpeg的github地址

完整可复制直接运行的node代码

const ffmpegPath = require('@ffmpeg-installer/ffmpeg'); // 自动为当前node服务所在的系统安装ffmpeg
const ffmpeg = require('fluent-ffmpeg');
const express = require('express');
const webSocketStream = require('websocket-stream/stream');
const expressWebSocket = require('express-ws');

ffmpeg.setFfmpegPath(ffmpegPath.path);

/**
* 创建一个后端服务
*/
function createServer() {
   const app = express();
   app.use(express.static(__dirname));
   expressWebSocket(app, null, {
       perMessageDeflate: true
  });
   app.ws('/rtsp/', rtspToFlvHandle);

   app.get('/', (req, response) => {
       response.send('当你看到这个页面的时候说明rtsp流媒体服务正常启动中......');
  });

   app.listen(8100, () => {
       console.log('转换rtsp流媒体服务启动了,服务端口号为8100');
  });
}

/**
* rtsp 转换 flv 的处理函数
* @param ws
* @param req
*/
function rtspToFlvHandle(ws, req) {
   const stream = webSocketStream(ws, {
       binary: true,
       browserBufferTimeout: 1000000
  }, {
       browserBufferTimeout: 1000000
  });
   // const url = req.query.url;
   const url = new Buffer(req.query.url, 'base64').toString(); // 前端对rtsp url进行了base64编码,此处进行解码
   console.log('rtsp url:', url);
   try {
       ffmpeg(url)
          .addInputOption(
               '-rtsp_transport', 'tcp',
               '-buffer_size', '102400'
          )
          .on('start', (commandLine) => {
               // commandLine 是完整的ffmpeg命令
               console.log(commandLine, '转码 开始');
          })
          .on('codecData', function (data) {
               console.log(data, '转码中......');
          })
          .on('progress', function (progress) {
               // console.log(progress,'转码进度')
          })
          .on('error', function (err, a, b) {
               console.log(url, '转码 错误: ', err.message);
               console.log('输入错误', a);
               console.log('输出错误', b);
          })
          .on('end', function () {
               console.log(url, '转码 结束!');
          })
          .addOutputOption(
               '-threads', '4',  // 一些降低延迟的配置参数
               '-tune', 'zerolatency',
               '-preset', 'ultrafast'
          )
          .outputFormat('flv') // 转换为flv格式
          .videoCodec('libx264') // ffmpeg无法直接将h265转换为flv的,故需要先将h265转换为h264,然后再转换为flv
          .withSize('50%') // 转换之后的视频分辨率原来的50%, 如果转换出来的视频仍然延迟高,可按照文档上面的描述,自行降低分辨率
          .noAudio() // 去除声音
          .pipe(stream);
  } catch (error) {
       console.log('抛出异常', error);
  }
}

createServer();

react 前端

用到的关键库

  • flv.js

用于前端播放flv格式视频库

完整可直接复制使用的react组件

import React, { useEffect, useRef } from 'react';
import './FlvVideoPlayer.scss';
import flvjs from 'flv.js';
import { Button } from '@alifd/next';

interface FlvVideoPlayerProps {
 url?: string; // rtsp 的url
 isNeedControl?: boolean;
 fullScreenRef?: any; // 方便组件外部调用全屏方法的ref
}

const FlvVideoPlayer = React.forwardRef<any, FlvVideoPlayerProps>(({ isNeedControl, url, fullScreenRef }, ref) => {
 const videoDomRef = useRef<any>();
 const playerRef = useRef<any>(); // 储存player的实例

 React.useImperativeHandle(ref, () => ({
   requestFullscreen,
}));

 useEffect(() => {
   if (videoDomRef.current) {
     if (fullScreenRef) {
       fullScreenRef.current[url] = requestFullscreen;
    }
     // const url = `${videoUrl}/rtsp/video1/?url=${url}`;
     playerRef.current = flvjs.createPlayer({
       type: 'flv',
       isLive: true,
       url,
    });
     playerRef.current.attachMediaElement(videoDomRef.current);
     try {
       playerRef.current.load();
       playerRef.current.play();
    } catch (error) {
       console.log(error);
    }
  }

   return () => {
     destroy();
  };
}, [url]);

 /**
  * 全屏方法
  */
 const requestFullscreen = () => {
   if (videoDomRef.current) {
    (videoDomRef.current.requestFullscreen && videoDomRef.current.requestFullscreen()) ||
      (videoDomRef.current.webkitRequestFullScreen && videoDomRef.current.webkitRequestFullScreen()) ||
      (videoDomRef.current.mozRequestFullScreen && videoDomRef.current.mozRequestFullScreen()) ||
      (videoDomRef.current.msRequestFullscreen && videoDomRef.current.msRequestFullscreen());
  }
};

 /**
  * 销毁flv的实例
  */
 const destroy = () => {
   if (playerRef.current) {
     playerRef.current.pause();
     playerRef.current.unload();
     playerRef.current.detachMediaElement();
     playerRef.current.destroy();
     playerRef.current = null;
  }
};

 return (
   <>
     <Button type="primary" onClick={requestFullscreen}>
       全屏按钮
     </Button>
     <video controls={isNeedControl} ref={videoDomRef} className="FlvVideoPlayer" loop />
   </>
);
});

export default FlvVideoPlayer;

组件用到的url

  • 本地开发时

本地开发时,node服务是启动在自己电脑上,所以node服务的地址就是 ws://127.0.0.1:8100,为了防止在传rtsp地址的过程中出现参数丢失的情况,故采用window.btoa()方法对rtsp进行base64编码一下,又由于node端代码中监听的是/rtsp/,故完整的组件的url是

ws://127.0.0.1:8100/rtsp?url=${window.btoa('rtsp地址')}
  • 部署线上

直接将服务器ip替换掉127.0.0.1即可

  • 提供一个测试的rtsp地址

rtsp://wowzaec2demo.streamlock.net/vod/mp4:BigBuckBunny_115k.mp4

遇到的问题

1. rtsp地址中存在?拼接的参数,传到后端丢失

  • 错误详情

An error occured: ffmpeg exited with code 1: rtsp://... Server returned 404 Not Found
  • 原因

完整的url是ws://127.0.0.1:8100/rtsp?url=${window.btoa('rtsp地址')},如果rtsp地址中再含有?拼接参数的话,那么就会出现两个?,传到node端之后,会被express去除掉rtsp地址中的?

  • 解决方式

在前端对rtsp使用window.btoa方法进行base64编码,在node端使用new Buffer进行解码即可

2. 连接超时

  • 报错截图

  • 原因

部署到客户内网发现的,是两台服务网络不通造成的

  • 解决方式

找运维解决

3. CPU飚到100%,卡顿

  • 错误详情

监控视频采用分页显示,每页8个监控视频,切换到下一页的时候,上一页转换监控视频的ffmpeg进程,仍然存在,没有被kill掉。所以ffmpeg的进程不停地增加,导致CPU占用100%

  • 原因

封装flvjs 的react组件中,在组件卸载的时候,没有把flvjs的实例销毁掉,导致进程不会被自动kill掉

  • 解决方式

组件卸载的时候,将flvjs的实例销毁掉

4. 不能转码h265视频流

  • 错误详情

Video codec hevc not compatible with flv 。Could not write header for output file #0 (incorrect codec parameters ?): Function not implemented
  • 原因

有些监控摄像头的视频格式是 hevc h265, flv不支持,需要先将h265转化至h264格式

  • 解决方式

node端代码中。ffmpeg添加 videoCodec('libx264') 配置即可

优化 ffmpeg 低延迟配置参数

'-threads', '4'
'-tune', 'zerolatency'
'-preset', 'ultrafast'

更新

当我把ffmpeg配置参数中的输出分辨率配置移除后,目前的延时在1~2s左右

作者:huisiyu
来源:juejin.cn/post/7124188097617051685

收起阅读 »

Android通知栏增加快捷开关的技术实现

我们通常可以在通知栏上看到“飞行模式”、“移动数据”、“屏幕录制”等开关按钮,这些按钮都属于通知栏上的快捷开关,点击快捷开关可以轻易调用某种系统能力或打开某个应用程序的特定页面。那是否可以在通知栏上自定义一个快捷开关呢?答案是可以的,具体是通过TileServ...
继续阅读 »


我们通常可以在通知栏上看到“飞行模式”、“移动数据”、“屏幕录制”等开关按钮,这些按钮都属于通知栏上的快捷开关,点击快捷开关可以轻易调用某种系统能力或打开某个应用程序的特定页面。那是否可以在通知栏上自定义一个快捷开关呢?答案是可以的,具体是通过TileService的方案实现。

TileService继承自Service,所以它也是Android的四大组件之一,不过它是一个特殊的组件,开发者不需要手动开启调用,系统可以自动识别并完成调用,系统会通过绑定服务(bindService)的方式调用。

创建使用:

快捷开关是Android 7(target 24)的新能力,因此在使用该能力前必须先判断版本大小(大于等于target 24)。

1、自定义一个TileService类。

class MyQSTileService: TileService() {
 override fun onTileAdded() {    
     super.onTileAdded()  
}
 
 override fun onStartListening() {    
     super.onStartListening()  
}
 
 override fun onStopListening() {    
     super.onStopListening()  
}
 
 override fun onClick() {    
     super.onClick()  
}
 
 override fun onTileRemoved() {    
     super.onTileRemoved()  
}
}

TileService是通过绑定服务(bindService)的方式被调用的,因此,绑定服务生命周期包含的四种典型的回调方法(onCreate()、onBind()、onUnbind()和 onDestroy())都会被调用。但是,TileService也包含了以下特殊的生命周期回调方法:

  • onTileAdded():当用户从编辑栏添加快捷开关到通知栏的快速设置中会调用。

  • onTileRemoved():当用户从通知栏的快速设置移除快捷开关时调用。

  • onClick():当用户点击快捷开关时调用。

  • onStartListening():当用户打开通知栏的快速设置时调用。当快捷开关并没有从编辑栏拖到设置栏中不会调用。在TileAdded添加之后会调用一次。

  • onStopListening():当用户打开通知栏的快速设置时调用。当快捷开关并没有从编辑栏拖到设置栏中不会调用。在TileRemoved移除之前会调用一次。

2、在应用程序的清单文件中声明TileService

<service
    android:name=".MyQSTileService"
    android:label="@string/my_default_tile_label"  
    android:icon="@drawable/my_default_icon_label"
    android:exported="true"
    android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
    <intent-filter>
        <action android:name="android.service.quicksettings.action.QS_TILE" />
    </intent-filter>
</service>
  • name:自定义的TileService的类名。

  • label:快捷开关在通知栏上显示的名称。

  • icon:快捷开关在通知栏上显示的图标。

  • exported:该服务能否被外部应用调用。该属性必须为true。如果为false,那么快捷开关的功能将失效,原因是exported="false"时,TileService将不支持外部应用调起,手机系统自然不能再和该快捷开关交互。必须配置。

  • permission:需要给service配置的权限,BIND_QUICK_SETTINGS_TILE即允许应用程序绑定到第三方快速设置。必须配置。

  • intent-filter:意图过滤器,只有匹配内部的action,才能调起该service。必须配置。

监听模式

TileService的监听模式(或理解为启动模式)有两种,一种是主动模式,另一种是标准模式。

  • 主动模式

在主动模式下,TileService被请求时该服务会被绑定,并且TileService的onStartListening也会被调用。该模式需要在AndroidManifeast清单文件中声明:

<service ...>
  <meta-data android:name="android.service.quicksettings.ACTIVE_TILE"
        android:value="true" />
  ...
</service>

通过TileService.requestListeningState()这一静态方法,就可以实现对TileService的请求,示例如下:

      TileService.requestListeningState(
           applicationContext, ComponentName(
               BuildConfig.APPLICATION_ID,
               MyQSTileService::class.java.name
          )
      )

主动模式下值得注意的是:

  • 用户在通知栏快速设置的地方点击快捷开关时,TileService会自动完成绑定、TileService的onStartListening会被调用。

  • TileService无论是通过点击被绑定还是通过requestListeningState请求被绑定,TileService所在的进程都会被调起。

标准模式

在标准模式下,TileService可见时(即用户下拉通知栏看见快捷开关)该服务会被绑定,并且TileService的onStartListening也会被调用。标准模式不需要在AndroidManifeast清单文件中进行额外的声明,默认就是标准模式。

标准模式下值得注意的是:

  • 和主动模式相同,TileService被绑定时,TileService所在的进程就会被调起。

  • 而和主动模式不同的是,标准模式绑定TileService是通过用户下拉通知栏实现的,这意味着TileService所在的进程会被多次调起。因此为了避免主进程被频繁调起、避免DAU等数据统计受到影响,我们还需要为TileService指定一个特定的子进程,在Androidmanifest清单文件中设置:

      <service
          ......
          android:process="自定义子进程的名称">
          ......
      </service>

更新快捷开关

如果需要对快捷开关的数据进行更新,可以通过getQsTile()获取快捷开关的对象,然后通过setIcon(更新icon)、setLable(更新名称)、setState(更新状态,包括STATE_ACTIVE——表示开启或启用状态、STATE_INACTIVE——表示关闭或暂停状态、STATE_UNAVAILABLE:表示暂时不可用状态,在此状态下,用户无法与您的磁贴交互)等方法设置快捷开关新的数据,最后调用updateTile()方法实现。

  override fun onStartListening() {
  super.onStartListening()
  if (qsTile.state === Tile.STATE_ACTIVE) {
      qsTile.label = "inactive"
      qsTile.icon = Icon.createWithResource(context, R.drawable.inactive)
      qsTile.state = Tile.STATE_INACTIVE
  } else {
      qsTile.label = "active"
      qsTile.icon = Icon.createWithResource(context, R.drawable.active)
      qsTile.state = Tile.STATE_ACTIVE
  }
  qsTile.updateTile()
}

操作快捷开关

  • 如果想要实现点击快捷开关时、关闭通知栏并跳转到某个页面,可以调用以下方法:

startActivityAndCollapse(Intent intent)
  • 如果想要在点击快捷开关时弹出对话框进行交互,可以调用以下方法:

override fun onClick() {
   super.onClick()
   if(!isLocked()) {
       showDialog()
  }
}

因为快捷开关有可能在用户锁屏时出现,所以必须加上isLocked()的判断。只有非锁屏的情况下,对话框才会出现。

  • 如果快捷开关含有敏感信息,需要使用isSecure()进行设备安全性判断,当设备安全时,才能执行快捷开关相关的逻辑(如点击的逻辑)。当设备不安全时(手机处于锁屏状态时),可调用unlockAndRun(Runnable runnable),提示用户解锁屏幕并执行自定义的runnable操作。

以上是通知栏增加快捷开关的全部介绍。欢迎关注公众号度熊君,一起分享交流。

作者:度熊君
来源:juejin.cn/post/7190663063631036473

收起阅读 »

通过官方项目来学习——枚举、密封类、密封接口的区别和使用场景

相信点进来愿意看这篇文章的jym都用过密封类、枚举类甚至也已经用到了密封接口,但是多多少少可能还是有点迷惑。写这篇文章就是希望能跟大家一起梳理这三类的区别,搞清楚在哪种情况用哪种实现最合适。 从java到kotlin,大家肯定对枚举已经比较熟悉了,这里就不专门...
继续阅读 »

相信点进来愿意看这篇文章的jym都用过密封类、枚举类甚至也已经用到了密封接口,但是多多少少可能还是有点迷惑。写这篇文章就是希望能跟大家一起梳理这三类的区别,搞清楚在哪种情况用哪种实现最合适。


从java到kotlin,大家肯定对枚举已经比较熟悉了,这里就不专门说它了。但是密封类和密封接口的概念最先是在kotlin上实现的,java之前是没有的。那么我们可能会想,为什么在已经有枚举的情况下还要新增密封类和密封接口这两个新的概念出来呢?这三点又有什么不同?什么情况我们应该用他们呢?如果你没有一个清晰的答案,那么请带着这三个问题请继续看下去


下面用nowinandroid项目内的代码来当个例子


密封类


sealed class Icon {
data class ImageVectorIcon(val imageVector: ImageVector) : Icon()
data class DrawableResourceIcon(@DrawableRes val id: Int) : Icon()
}

这个密封类很简单。里面就封了两个数据类,两个类的参数类型不同。根据命名不难看出,Icon密封类是提供Icon给ui用的,ImageVectorIcon通过提供ImageVector实现icon显示,DrawableResourceIcon提供资源id来实现icon显示。那么很容易想象出来后面的使用场景无非就是通过一个类型判断,来执行对应条件下的加载就完了。这里大家只需留意一点,就是这个密封类我们是没有做任何初始化操作的。


枚举类


enum class TopLevelDestination(
val selectedIcon: Icon,
val unselectedIcon: Icon,
val iconTextId: Int,
val titleTextId: Int,
) {
FOR_YOU(
selectedIcon = DrawableResourceIcon(NiaIcons.Upcoming),
unselectedIcon = DrawableResourceIcon(NiaIcons.UpcomingBorder),
iconTextId = forYouR.string.for_you,
titleTextId = R.string.app_name,
),
BOOKMARKS(
selectedIcon = DrawableResourceIcon(NiaIcons.Bookmarks),
unselectedIcon = DrawableResourceIcon(NiaIcons.BookmarksBorder),
iconTextId = bookmarksR.string.saved,
titleTextId = bookmarksR.string.saved,
),
INTERESTS(
selectedIcon = ImageVectorIcon(NiaIcons.Grid3x3),
unselectedIcon = ImageVectorIcon(NiaIcons.Grid3x3),
iconTextId = interestsR.string.interests,
titleTextId = interestsR.string.interests,
),
}

现在我们来简单看一眼这个枚举类。4个参数组成了构造参数,前两个参数的类型是上面定义的Icon密封类。然后定义了三个值For_You,BOOKMARKS,INTERESTS。这里需要注意了,这三个值的参数都并必须完成初始化提供实例才行,而前面定义的密封类并不需要。那么我们发现了一个枚举类跟密封类的区别了。这个区别我个人觉得也是枚举类和密封类最大的一个区别-复杂度。这里的枚举类的使用场景是给app的底部导航栏用的,我们知道一般导航栏需要的东西很简单数量也不多,一般都是一个选中时的icon,未选中时的icon,一个标题就完了,所以很简单一点都不复杂。后期我们要添加多少个新的也都是这个模版,很方便我们统一维护。但是这里的Icon密封类就相对要复杂灵活点了,首先可以实现提供显示icon的方法有还很多种,我们不太可能一次把所有的方式都写进去,所以我们可以通过Icon密封类随时扩展,再则这个icon可能是会经常更换的(比方说版本更新,配合活动动态更换等),那么我们这个资源肯定就不能写死了,也就是不推荐用枚举去实现,不然换一次icon就要新建一个新值,维护起来麻烦也不优雅。那么这个时候的密封类就又起到作用了,我们只用替换原有枚举类初始化的资源就行了。



小结一下:简单稳定的用枚举,复杂灵活的用密封



密封接口


密封接口这个概念并不是跟密封类一同出现的。是先有的密封类后面高版本kotlin才出现的密封接口。刚出来的时候,我也不懂为啥还要特意再整个密封接口出来,正好在掘金上看到了大佬写的一篇很好的文章介绍了密封接口和密封类的区别,这里我就不再复述了,建议大家直接点链接去学习 Kotlin 1.5 新特性:密封接口比密封类强在哪? - 掘金 (juejin.cn)。下面还是提供一下nowinandroid项目里面部分用到密封接口地方的代码。


package com.google.samples.apps.nowinandroid.core.result

import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart

sealed interface Result<out T> {
data class Success<T>(val data: T) : Result<T>
data class Error(val exception: Throwable? = null) : Result<Nothing>
object Loading : Result<Nothing>
}


用来封装网络请求返回的几种情况,可读性强于密封类实现



sealed interface NewsFeedUiState {
/**
* The feed is still loading.
*/
object Loading : NewsFeedUiState

/**
* The feed is loaded with the given list of news resources.
*/
data class Success(
/**
* The list of news resources contained in this feed.
*/
val feed: List<UserNewsResource>,
) : NewsFeedUiState
}


封装页面加载的情况,可读性强于密封类实现


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

ARouter ksp注解处理器 实现思路

注解处理器到底承担了什么作用? 考虑清楚这个问题 对我们的ksp实现 会有非常大的帮助, 否则那一坨注解处理器的实现 你是根本不知道为什么要那样实现,自然ksp的实现你是无从下手的。 首先我们来看一个典型的例子, 我们有2个module 一个叫 moduleA...
继续阅读 »

注解处理器到底承担了什么作用?


考虑清楚这个问题 对我们的ksp实现 会有非常大的帮助, 否则那一坨注解处理器的实现 你是根本不知道为什么要那样实现,自然ksp的实现你是无从下手的。


首先我们来看一个典型的例子, 我们有2个module 一个叫 moduleA 一个叫moduleB , moduleA中的类 想跳转到moudleB中的类 要怎么做? 一般都是


startActivity(B.class)


这就会带来一个问题了, moduleA 要引用到moduleB 中的类, 你只能让moduleA依赖moduleB, 那如果反过来呢?moduleB还得依赖moduleA了, 这种相互间的引用 肯定是不行的了,那应该怎么做?


就是参考Arouter的做法就可以了, 例如moduleB中的B.class 我想对外暴露 ,让别的module中可以跳转到我这个activity ,那就我在B.class中 加一个注解


例如:
image.png


这是大家最熟悉的代码了,那么关键的地方就在于, 我的注解处理器 到底要做什么? 要生成什么样的类,来达成我们最终的目的。


本质上来说,我们一个apk,下面肯定有多个module, 不管他们的依赖关系如何,他的编译关系都是确定的,这句话怎么理解?


moduleA 编译以后生成一堆class文件,moduleB 编译以后也生成一堆class文件, 等等。 最终这些class文件
都是在我们的app moudle 编译时汇总的, 思考明白这个问题 那就好理解了,


回到前面的例子中,我们在moduleB中 加了注解,然后我们可以利用注解处理器 来生成一个类,这个类中维护一个map,这个map的key就是 我们注解中的path的字符串之,value 则是本身我们B.class


这样多个module 在app module 中汇总编译的时候 我们就可以拿到一个巨大的map 这个map中key就是path的值,value 就是目标的class


之后跳转的时候只要在navigation中传递一下path的值,然后根据再到map中寻找到对应的class就可以了。


你看,arouter的注解处理器 是干啥的,我们就想清楚了吧,就是生成一堆辅助类,这个辅助类的最终目的就是帮我们生成path 和class的对应关系的


理想和现实中的差别


在上一个小节中,我们基本弄清楚了arouter 注解处理器的作用, 但是仅靠这一节的知识要完全看懂arouter-compiler的代码还是不够, 因为实际上arouter 的map生成要比 我们前面一个小节 所说的要复杂的多。为什么?


你仔细思考一下, 如果是多个module 都使用了route注解,那这些注解的类中的path的值 是不是有可能是重复的?


比如moduleB中 有一个类叫X.class 他的path是 /x1/xc
moduleC中 有一个类叫Y.class 他的 path值也是 /x1/xc


这就会导致一个问题了,在app 编译的时候 同样一个path 会对应着2个class,此时跳转就会出现错误了。


我们来看下,Arouter中 是如何设计来解决这个问题的 他首先引入了一个Group的概念, 比如我们上面的path
x1 就是group, 当然你也可以手动指定group ,但是意思都是一样的


首先生成一个名为


Arouter$$Root$$moduleName

的类,这个类继承的是IRouteRoot这个接口


这里我们要关注的是moduleName ,我们在用annotaionProcessor 或者kapt 或者ksp的 这三种注解处理器的时候 都要传递一个参数的


image.png


image.png


然后再关注下 loadInto 这个方法


这个方法一看就是生成了一个map 对吧, 这个map的key 就是 group的值,而value则是注解处理器生成的一个类 实现了IRouteGroup接口


Arouter$$Group$$group的值

我们来看一下这个类里面干了啥


image.png


这个类也有一个loadInfo 方法


它的key 就是path的值, value 就是RouteMeta对象,注意这个对象中就具体包含了Activity.class了,


所以Arouter 实际上就是把我们的map给分了级,


首先是利用 moduleName 来生成 IRouteRoot的类 ,这样可以规避不同module之间有冲突的现象
其次是利用 group的概念 再次对路由进行分层, 这样一方面是降低冲突几率,另外一方面,利用group的概念,我们还可以做路由的懒加载,毕竟项目大了以后 一次性加载全部路由信息也是有成本的,有了group的概念,


我们就可以按照group的级别来加载了,实际上arouter本身路由加载也是这样做的。


路由利用group分组以后, 默认任何实际路由信息都不会加载, 当每次调用者发起一次路由加载事件时,都会按照group的信息来查找,第一次触发某个group 时,再去加载这个group下面的所有路由信息


ksp的基础实现


首先我们新建一个module ,命名大家随意,注意这个module的build 文件写法即可


apply plugin: 'java'
apply plugin: 'kotlin'

compileJava {
sourceCompatibility = '1.8'
targetCompatibility = '1.8'
}

sourceSets.main {
java.srcDirs("src/main/java")
}

dependencies {
implementation 'com.alibaba:arouter-annotation:1.0.6'
implementation("com.squareup:kotlinpoet:1.11.0")
implementation("com.squareup:kotlinpoet-ksp:1.11.0")
implementation("com.squareup:kotlinpoet-metadata:1.11.0")
implementation 'com.alibaba:fastjson:1.2.69'
implementation 'org.apache.commons:commons-lang3:3.5'
implementation 'org.apache.commons:commons-collections4:4.1'
implementation("com.google.devtools.ksp:symbol-processing-api:1.6.20-1.0.5")

}

apply from: rootProject.file('gradle/publish.gradle')

其次,去meta-inf 下 新建一个文件,文件名是固定的


com.google.devtools.ksp.processing.SymbolProcessorProvider


image.png


里面的内容就简单了,把我们的ksp注解处理器配置进去即可


com.alibaba.android.arouter.compiler.processor.RouteSymbolProcessorProvider
com.alibaba.android.arouter.compiler.processor.InterceptorSymbolProcessorProvider
com.alibaba.android.arouter.compiler.processor.AutowiredSymbolProcessorProvider

这里要注意一下,即使是一个纯java代码的module 也可以使用ksp来生成代码的


注解处理器如何debug?


注解处理器的代码其实还挺晦涩难懂的,全靠日志打印很麻烦,这里还是会debug 比较好


image.png


image.png


稍微配置一下即可, 然后打上断点,按下debug开关,rebuild 工程即可触发注解处理器的调试了


使用ksp 注解处理器来生成辅助类


这里篇幅有限, 我们只做辅助类的生成, 至于辅助类里面的loadInto方法 我们暂不做实现,具体的实现我们留到下一篇文章再说,这一节只做一下 辅助类生成这个操作


首先我们来配置一下 使用ksp的module


ksp {
arg("AROUTER_MODULE_NAME", project.getName())
}

ksp project(':arouter-compiler')

然后要注意的是,即使是纯java代码的module 也可以利用ksp来生成代码的, 唯一要注意的是你需要在这个module下 添加


apply plugin: 'kotlin-android'

现在注解处理器也配置好了, 我们就可以干活了。


先放一个基础类就行


class RouteSymbolProcessorProvider : SymbolProcessorProvider {
override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor {
return RouteSymbolProcessor(environment.options, environment.logger, environment.codeGenerator)
}
}

第一步,我们要取出moduleName,这个东西的作用前面已经介绍过了,


val moduleName = options[KEY_MODULE_NAME]

第二步,我们要取出项目中 使用Route注解的类,拿到这些类的信息


// 取出来 使用route注解的
val symbols = resolver.getSymbolsWithAnnotation(Route::class.qualifiedName!!)

// 先取出来 有哪些 类用了Route注解
val elements = symbols.filterIsInstance<KSClassDeclaration>().toList()

第三步, 也是最关键的一步,我们要取出Route 中的关键信息作一个map,key是group,value是path的list


其实也就是一个group 下面对应的所有path信息


这里有几个关键点, 要把Route中的path和group的值 都提取出来, 如果没有指定group 则 path的第一段作为group的值


另外就是在取的时候 要判断一下 这个element的注解是不是Route注解, 因为一个类可以有多个注解,我们要取特定的Route注解 才能取到我们想要的值


关键代码


val map = mutableMapOf<String, List<String>>()
elements.forEach {
it.annotations.toList().forEach { ks ->
// 防止多个注解的情况
if (ks.shortName.asString() == "Route") {
var path = ""
var group = ""
ks.arguments.forEach { ksValueA ->
if (ksValueA.name?.asString() == "path") {
path = ksValueA.value as String
}
if (ksValueA.name?.asString() == "group") {
group = ksValueA.value as String
}
}

// 如果没有配置group 则去path中取
if (group.isEmpty()) {
group = path.split("/")[1]
}

if (map.contains(group)) {
map[group] = map[group]!!.plus(path)
} else {
map[group] = listOf(path)
}
}
}
}

第四步,我们生成IRouteRoot 辅助类


这里有一个难点 就是 如何写这个方法参数的类型


image.png


看下具体代码 如何来解决这个问题


private fun String.quantifyNameToClassName(): com.squareup.kotlinpoet.ClassName {
val index = lastIndexOf(".")
return com.squareup.kotlinpoet.ClassName(substring(0, index), substring(index + 1, length))
}

// IRouteRoot 这个接口 方法参数的定义 MutableMap<String, Class<out IRouteGroup>>?
val parameterSpec = ParameterSpec.builder(
"routes",
MUTABLE_MAP.parameterizedBy(
String::class.asClassName(),
Class::class.asClassName().parameterizedBy(
WildcardTypeName.producerOf(
Consts.IROUTE_GROUP.quantifyNameToClassName()
)
)
).copy(nullable = true)
).build()

参数的这个问题解决掉以后 就很简单了


直接按照名字规则 生成一下 类即可


val rootClassName = "ARouter$$Root$$$moduleName"

val packageName = "com.alibaba.android.arouter"
val file = FileSpec.builder("$packageName.routes", rootClassName)
.addType(
TypeSpec.classBuilder(rootClassName).addSuperinterface(
com.squareup.kotlinpoet.ClassName(
"com.alibaba.android.arouter.facade.template",
"IRouteRoot"
)
).addFunction(
FunSpec.builder("loadInto").addModifiers(KModifier.OVERRIDE)
.addParameter(parameterSpec)
.addStatement("TODO()").build()
).build()
)
.build()

file.writeTo(codeGen, false)

最后一步, 我们要生成IRrouteGroup的辅助类,里面放入对应path的信息


这里path的信息 我用注释表示下即可,


// 生成group 辅助类
map.forEach { (key, value) ->

val rootClassName = "ARouter$$Group$$$key"

// IRouteGroup 这个接口 方法参数的定义 MutableMap<String,RouteMeta>?
val parameterSpec = ParameterSpec.builder(
"atlas",
MUTABLE_MAP.parameterizedBy(
String::class.asClassName(),
RouteMeta::class.asClassName()
).copy(nullable = true)
).build()

val packageName = "com.alibaba.android.arouter"
// val rootClass = com.squareup.kotlinpoet.ClassName("", rootClassName)
val file = FileSpec.builder("$packageName.routes", rootClassName)
.addType(
TypeSpec.classBuilder(rootClassName).addSuperinterface(
com.squareup.kotlinpoet.ClassName(
"com.alibaba.android.arouter.facade.template",
"IRouteGroup"
)
).addFunction(
FunSpec.builder("loadInto").addModifiers(KModifier.OVERRIDE)
.addParameter(parameterSpec)
.addComment("path: $value")
.addStatement("TODO()").build()
).build()
)
.build()

file.writeTo(codeGen, false)
}

最后看下实现效果:


对应的辅助类 应该是都生成了:


image.png


path的信息:


image.png


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

简单聊聊 compose 的remember

前言 在前面聊mutableStateOf 的时候用了这段代码来讲述了Compose 状态的更新。 code-1 val name = mutableStateOf("hello compose") setContent...
继续阅读 »

前言


在前面聊mutableStateOf 的时候用了这段代码来讲述了Compose 状态的更新。


code-1
val name = mutableStateOf("hello compose")
setContent {
    Text(name.value)
}

lifecycleScope.launch {
    delay(3000)
    name.value = "android"
}

接下来,我们继续通过这段代码来一起聊下 Composeremember


浅聊


我们先对code-1的代码稍微做下修改


code-2    
    setContent {
            var name by mutableStateOf("hello compose")
            Text(name)
            //ps:此处代码仅做演示使用,compose中协程的使用并非如此
            lifecycleScope.launch {
                delay(3000)
                name = "android"
            }
     }

当我们这样进行修改之后,发现3s过后,“hello compose” 并没有如期变成“android”。


这是为什么呢? 是协程没有执行吗?还是没有进行重组刷新?用最简单的方法,我们来加日志看一下执行。


code-3   
      setContent {
            Log.i("TAG", "setContent: ")
            var name by mutableStateOf("hello compose")
            Text(name)
            //ps:此处代码仅做演示使用,compose中协程的使用会另做讲解。
            lifecycleScope.launch {
                delay(3000)
                name = "android"
                Log.i("TAG", "launch: ")
            }
        }


可以看到,协程已经执行了,而且也进行了重组刷新,但是为什么值没有改变呢?


这是因为使用变量的组件会被包起来,当变量改变的时候会随之进行重组刷新,每次刷新的时候,就会重新创建一个MutableState对象,这个对象就会取默认值“hello compose”。所以才会看起来每次都进行了刷新,但是文字却没有任何改变。



刷新重组的范围就叫做重组作用域。



我们想让Text() 进行刷新怎么办?可以进行包一层。我们对code-2的代码稍微做下修改。


code-4           
setContent {
           var name by mutableStateOf("hello compose")
           Button(onClick = {}){
               Text(name)
           }
            //ps:此处代码仅做演示使用,compose中协程的使用会另做讲解。
            lifecycleScope.launch {
                delay(3000)
                name = "android"
            }
        }

我们让Button 对他进行一个包裹,然后来看看效果。





可以看到进行包裹了之后,文字发生了改变。虽然这样满足了我们的需求,但是不能每次有使用变量的组件,每次都进行一个包裹吧,这岂不是会疯掉。


接下来就需要有请我们今天的主角 remember了,它就是为了帮助我们解决这个问题。


它可以在每次重组的时候,去帮我们去拿已经缓存的值,不需要每次都是重新创建。


code-5             
setContent {
            var name by remember { mutableStateOf("hello compose") }
            Text(name)
            //ps:此处代码仅做演示使用,compose中协程的使用会另做讲解。
            lifecycleScope.launch {
                delay(3000)
                name = "android"
            }
}





remember 也是一个Composable 函数,因此只能在Composable 中调用。



现在,我们有个场景,我们需要频繁展示相同的数据,如果使用Text() 直接进行展示,就会每次就会重新计算,但是这些计算没必要的。


为了避免不必要的资源浪费,我们也可以使用remember 来解决。


code-6       
      setContent {
            var name by remember { mutableStateOf("hello compose") }
            ShowCharLenth(name)
            //ps:此处代码仅做演示使用,compose中协程的使用会另做讲解。
            lifecycleScope.launch {
                delay(3000)
                name = "android"
            }
        }

code-7
@Composable
fun ShowCharLenth(value: String) {
    val str = remember { value }
    Text(str)
}

这样使用,就避免了code-7 中的频繁计算重组。


可是这样还会产生一个问题,如果我们展示的数据 如果变了怎么办? 前面的数据进行了缓存,后面的数据即使变了 还会取之前缓存的数据,那直接产生的问题就是数据改变了,但是UI上没有变化。


remember 也早就对这种情况有了解决措施,而且非常简单。


code-8
@Composable
fun ShowCharLenth(value: String) {
    val str = remember(value) { value }
    Text(str)
}

小括号中的value 就是一个key值,根据key值 再去拿remember缓存的数据,这样就完美解决了我们的问题。


至此,我们从这段很短的代码中学到了 remember 的作用 以及使用,感兴趣的同学可以简单上手实践下。


总结


今天的内容就到这里了,非常的简短,主要就是介绍了一下 remember的作用和使用,希望对于新上手Compose的同学有所帮助。


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

30岁转行程序员晚了吗?分享30岁转行的经历

按惯例,先说下我基本情况。我是85年的,计算机专业普通本科毕业。在一个二线城市,毕业后因为自身能力问题、认知水平问题,再加上运气不好,换过多份工作,每份工作都干不长。导致我30多岁时,还一事无成,也几乎没有积累到什么经验技术、行业知识等。甚至还一度去开过网约车...
继续阅读 »

按惯例,先说下我基本情况。我是85年的,计算机专业普通本科毕业。在一个二线城市,毕业后因为自身能力问题、认知水平问题,再加上运气不好,换过多份工作,每份工作都干不长。导致我30多岁时,还一事无成,也几乎没有积累到什么经验技术、行业知识等。甚至还一度去开过网约车,送过外卖。

转行做程序员前,我每个月收入仅三四千元。2017年下定决心,准备转行!我知道自己最大的劣势就是年龄太大了,但考虑再三,因为以下几个原因,我还是决定走这条路:

  1. 我目前的情况已经算是城市里的底层了。我不会做生意,没其它经验和技能,性格偏内向,销售和交际也不太擅长。所以我不怕失去什么,因为我也没有什么可失去的。

  2. 我想学个谋生技能从新开始,而学开发最适合我。因为我并非0基础,毕竟大学时学的这个专业,而且自己也曾经很喜欢编程。

  3. 我对待遇的要求不高,只要工资在5k以上就行。如果能有6、7 那我就太满意了。

  4. 当时我的瞎推断:因为国内出生率特别低,以后可能没那么多年轻人供雇主挑选,所以我们这些中老年人,也有一定的机会。

  5. 受到一些心灵鸡汤的鼓励,比如什么种一颗树最好的时机是十年前,其次,就是现在!

我辞去工作,开始在家自学web开发。主要的学习方式就是在网上看视频教程。那些视频教程,初级的基本上免费。中高级的有些会收费,大概50-300元左右一套。反正“学费”上我没花什么钱,总共不到一千元吧。

先是学了些前端基础。在学后端时,犹豫了下学PHP还是JAVA。在我读书时是学过JAVA的,虽然已记得不多了。于是我就想学个新的吧!还因为PHP比JAVA简单,更适合中小型项目。我这个情况,肯定进不了大厂,做不了大项目了。所以就决定学PHP。(现在有点小后悔了)

学完前后端基础后,我还跟着视频教程,自己做了两三个简单的项目。在我学习过程中,让我比较有自信的是——没遇到什么挫折。当然,肯定遇到过各种问题被卡住,但自己都去百度解决了。

接下来开始准备找工作,第一个难点就是简历。快32岁的人了,如果老实说刚自学出来没任何实际工作经验,肯定是没人要的。没办法,只好去包装简历,说成有两年开发经验。瞎编简历的过程还是很困难的。以前呆的哪家公司,做了什么项目,完全凭想像去编造。

然后海量投简历,艰难地面试,不断地被淘汰......当时我的信念就是,大不了我花一年时间去找工作,找不到就继续学,直到找到为止!最终我花了近两个月时间,可能参加了20次的面试,才勉强找到份工作。

因为没有实际工作经验,造假的简历,是很容易被发现的。只要稍有经验的面试官,多追问你几个细节,就露馅了。因为你并没有做过,不可能编造出全部的细节。所以面试过程很艰难。说几次印象深刻的:

1、一家特别小的公司,还是与人合租的办公室,我当时见到的员工,似乎只有两个人。与技术面试官简单交流了几句后,他当场决定录用我,让我明天来上班,并且说稍后会发短信给我确认。但不知道为什么,后来没再联系我了。

2、另一家小公司,还是在居民小区里办公,不过办公环境还不错,约有七八个人的样子。与面试官交流的地点是在生活阳台上,面试官坐着一个简易塑料板凳,旁边是个洗衣机。和他聊了较长时间,自以为他问的所有问题,我回答得都不错,也很希望拿到这个offer。满心期待的回家等了好几天,结果也没下文了。

3、也是一家在居民楼里办公的小公司,办公环境给人压抑的感觉。去的时候就遇上,老板模样的人,在对开发人员大发脾气,那些开发人员都不敢吱声。然后那老板对待我们这些面试者,态度特别粗鲁,抱怨我们为什么早到了10分钟?笔试的内容甚至包含直接给他们现有项目找bug,和改bug。我心中生气,中途离开了。

4、最后能找到工作,通过面试,主要是运气。是老板直接跟我聊的,没经过技术面试官。老板觉得我有相关行业工作经验(其实没啥帮助),又是统招本科,专业对口,就直接决定录用我了。如果当时他让懂技术的人来面试我下,估计我多半过不了。

那是家小公司,连同老板在内,总共十几个人,给的待遇是6k。这待遇对于开发来说,算是最低水平了。但我当时还是很高兴。说出不来怕丢人,比起以前的工作,6k对我来说已是高薪了。

刚去上班那段时间,还是发现了很多“新奇”的东西。比如,原来还有一个岗位,叫做“产品经 理”。以前我一直以为,只需要有开发人员撸代码就行了。更让我汗颜的是,我都不知道PHP也是可以做APP的。恰好我看的那些视频教程,都没提到这点。讲课的例子,以及做的实战项目练习,全是pc网站!

当我同事滑动着手机告诉我,app中的这些数据,都是接口中获取的。我点头,装作原来如此的样子。其实内心却震惊了:然来PHP还可以做APP啊,真是牛B!没错,我就是这么菜,甚至当时我都搞不明白,啥是api接口。感觉这是一个很难的东西。还把它和OOP中的"接口"搞混了。因为他们都叫“接口”二字。

在那里呆了两三个月后,我就没那么白痴了。给我安排的日常开发任务,都能按时完成。呆了半年后,我渐渐地发现。我后端同事些,水平也不过如此,可以说基础还不如我,我还时不时给他们解答下技术上的问题。当时我还有点飘飘然了,觉得他们只不过对业务更熟悉些。

现在的我回想起来,当时我的看法大致没错。道理很简单:愿意去这家公司,拿6-7k工资(除了工资以外,啥也没有)的程序员,只能是初级水平。

经过一年的时间,我已成长为一个合格的增删改查工程师。这里已学不到什么东西了,现在回想起来,那个公司没有任何技术氛围,在里面呆着,有一种养老的感觉,外部没有什么动力和压力,逼自己继续学习。

经朋友介绍,我跳槽去了另一家公司,很顺利地通过了笔试和面试,工资开的9k。

刚去的第一个月,我经受了很大的压力。部门负责人,看我年龄这么大,就以为我是一个很牛逼的人。就给我安排了些较难的任务,我很费力地完成了一两个后。他就给我安排了个更难的,说:“这个都是架构师搞了的,你好好研究下吧。”我就在焦虑和不安中,研究了一周,还是云里雾里的,只好鼓起勇气跟他摊牌了,说我做不来。还好那负责人也没说啥,安排我做增删改查的业务了。

我第一家公司,还有个大问题是,用的都是些落伍的技术。而我在第二家公司这里也跟上了主流的技术。比如git(上家是svn)、laravel5(上家是tp3)、 docker(上家是phpstudy) 、前后端分离(以前是混起的)、任务进度管理系统(上家没有) 、专业的测试工程师(上家是运营人员兼测试)等等。光是学习和熟悉这些,我都花了大量的时间。

这里开发人员的技术水平,和上家公司完全不是一个档次的。氛围也和第一家公司不一样了,同事们经常聊各种新出来的技术。哪怕聊点8卦,也是以IT新闻为主。他们聊的东西,很多我都听不懂。让我深感自己知识面的狭窄。

这里的学习气氛也浓厚。做过开发的都知道,忙的时候真是喝水都顾不上。但有时没事,又闲得很。上家公司在没事干时,大家就偷偷玩游戏、看视频、小说等。这里没事时,大家就是看文档,学新东西。

两三个月后,我慢慢适应这里了。但还是那个问题,一直让我焦虑——年龄。同事闲聊时,我最怕聊到年龄的话题。哪怕和年龄有一点点关系的话题,我也会警觉。比如同事们聊起用的第一部智能手机。我一般就不接话,因为我用的第一个智能手机还是palm!

但是,怕什么还是来什么了。

有一次聚会,又聊起年龄。每个人就在说说笑笑地报自己的年龄。轮到我时,我强笑着说:“我嘛,永远18岁。”一个正常情商的人,都会明白,这就是不愿意说,识趣的话应该也就别多问了。但是,在坐有个同事,情商真的太低了,他直接跳出来说:“他85年的!”此时,刚走进来一个95后同事,听到“85年”这三个字,顺口就说了句:“谁85年的啊?”当时我感觉整个空气都凝固了,我的大腿控制不住地抖动,背上密密麻麻地出汗,把衬衣都湿透了。我只好举手示意,说:“是我,我是85年的...”当时我的尴尬和羞愧,永远也无法忘记。

目前我工资12k,仍然是一个技术普通的后端开发人员。对于这个收入我是知足和满意的。

我是个脸皮薄,性格敏感的人,2020年又要满35岁了,哎!

其实让我目前感到尴尬和羞愧的并不完全是因为年龄。而是我的年龄和技术能力完全不匹配!公司中也有年龄和我相仿的人,但在我的眼中,他们都是技术大牛了。感觉什么都懂,随便说一个问题,他们都能给你上上课,讲讲底层原理。当我新听到一个技术概念,觉得很新鲜,正准备去了解个大概时。他们不仅熟悉,甚至还知道茴香豆的茴字,有四种写法!

青春逝去,时光不再。比我聪明,比我入行早的人,都还在努力,我现在能做的,只能是继续努力学习,仅仅希望不要那么丢脸。

对于那些一毕业就干开发,目前不到30岁,但经常听说程序员只能做到35岁,并为此焦虑的同学。请你们尽管放心,只要你们做的不是养老的工作,每年在技术上都有明显进步,找到好工作绝对没问题。至少在中型公司当个leader是没问题的。

但在此劝那些30多岁想转行程序员的人,如果你们像我一样,不是一个脸皮厚的人,一定要慎重!

不过如果呆在那种10个人左右的小公司,这种年龄尴尬,要稍好点,但就没什么技术氛围了,成长较慢。

至于有人问我他该不该转行程序员,我想说职业规划是大事,每个人的情况都不一样,这很难回答。何况我也不是个“人生导师”,只是个技术普通的大龄程序员。我个人意见总结起来是这样的,就不再一一单独回复了:

1.你是否有兴趣和能力去做好开发?

  • 有个简单的方法,可以判断自己是否有能力。那就是回顾一下自己中学或大学时的数学成绩!

  • 如果数学成绩好,说明你有天赋,反之就没有。

  • 这并不是说,做项目开发需要多少数学知识——相关性不等于因果性。

  • 只是因为,数学成绩好,代表你比较聪明,抽象思维能力强,这是开发所需要的。

  • 我自己读的是一个普通中学,普通大学。我的数学成绩,一般在班上排名前5。我自己感觉就是学初级、中级的知识较容易。高级点的知识,学起来就特别吃力。

  • 我公司里有位同事,很年轻,技术特别厉害。我就很好奇,问了他一句:“你大学时,是不是数学很好?”结果他回答,他数学一直是全校第一名。

2.你的现状是什么?

  • 如果现在有份收入不错,且能长期干下去的工作,那也没必要去转行。

  • 反之,如果像我当初一样,做着一份毫无前途,月收入仅三、四千元的工作,那可以考虑转行。

3.你的年龄?

  • 如果你还年轻,大概在26岁以下,且前2个条件都满足,那可以去转。

  • 而如果像我一样,当时都30多岁了,要慎重。


2021年1月27日更新

我的近况:半年前,换了家公司,待遇差不多。但要轻松了很多,让我压力和焦虑都小了些。这家公司规模不算小,但并不是互联网公司,研发部门人很少。同事们的平均年龄,也相对大些。所以对我来说,整体气氛比较轻松。也让我有更多时间去学习。我又花了很多时间,重新把前端基础学了一遍,现在的前端技术和我几年前时学的,变化太大了!

2021年6月24日更新

3月初,我一个朋友的朋友,是一个小公司的老板。他想给自己公司做一个内部用的业务系统,联系上了我。真是巧了,正好我才重新学完前端。充分了解了这个项目的需求后,我发现不算难。价格我就报了4万,对方毫不犹豫的答应了。我一个人撸后端和前端代码,前端只有pc端,前前后后,加调试修改,一共花了一个多月时间完成。交付后,那个老板很满意。不过这种私单,我感觉是可遇不可求的。我去网上各种发包接单平台看了下(比如猪八戒),价格都是超低的。

不久后,又因为一些朋友关系。了解到健身房相关行业的软件需求。比如约课,会员管理之类的。有两三个人打算新开健身房的人,都对我说,你只要把这个系统开发现来,我肯定买(付费模式是月付或年付)。我就去仿造其它健身房系统,做了一个类似的多商户SaaS系统,用户端是微信公众号。结果没想到做成后,之前答应说一定买的,却因为各种原因,要么没开成店,要么推迟开。我挂到网上去,也没卖出去。不过我也并没有在意,就当自己学习了,练手了。

能搞这么多事情的前提,是我目前呆的这个公司,比较轻松。有时整整一个月都没啥事。

作者:老去的80后
来源:zhuanlan.zhihu.com/p/99944212

收起阅读 »

vue-video-player 播放m3u8视频流

该问题网上答案较少,翻阅github得到想要的答案,在此记录一下首先,为了减少包体积,在组件中局部引入vue-video-player(在main.j s中引入会增加包体积)播放m3u8需要注意两点:需要引入videojs并绑定到window上安装依赖vide...
继续阅读 »

该问题网上答案较少,翻阅github得到想要的答案,在此记录一下

首先,为了减少包体积,在组件中局部引入vue-video-player(在main.j s中引入会增加包体积)

播放m3u8需要注意两点:

  1. 需要引入videojs并绑定到window上

  2. 安装依赖videojs-contrib-hls( npm i videojs-contrib-hls)并引入

  3. sources要指定type为application/x-mpegURL

代码如下:

<template>
   <section>
       <video-player :options="options"></video-player>
   </section>
</template>

<script>
import { videoPlayer } from 'vue-video-player'

import videojs from 'video.js'
//注意点1:需要引入videojs并绑定到window上
window.videojs = videojs
//注意点2:引入依赖
require('videojs-contrib-hls/dist/videojs-contrib-hls.js')


require('video.js/dist/video-js.css')
require('vue-video-player/src/custom-theme.css')

export default {
   name: 'test-video-player',
   components: {
       videoPlayer
  },
   data() {
       return {
           options: {
               autoplay: false,
               height: '720',
               playbackRates: [0.7, 1.0, 1.25, 1.5, 2.0],
               sources: [
                  {
                       withCredentials: false,
                       type: 'application/x-mpegURL', //注意点3:这里的type需要指定为 'application/x-mpegURL'
                       src:
                           'https://tx-safety-video.acfun.cn/mediacloud/acfun/acfun_video/47252fc26243b079-e992c6c3928c6be2dcb2426c2743ceca-hls_720p_2.m3u8?pkey=ABDuFNTOUnsfYOEZC286rORZhpfh5uaNeFhzffUnwTFoS8-3NBSQEvWcqdKGtIRMgiywklkZvPdU-2avzKUT-I738UJX6urdwxy_ZHp617win7G6ga30Lfvfp2AyAVoUMjhVkiCnKeObrMEPVn4x749wFaigz-mPaWPGAf5uVvR0kbkVIw6x-HZTlgyY6tj-eE_rVnxHvB1XJ01_JhXMVWh70zlJ89EL2wsdPfhrgeLCWQ&safety_id=AAKir561j0mZgTqDfijAYjR6'
                  }
              ],
               hls: true
          }
      }
  },
   computed: {},
   methods: {},
   created() {}
}
</script>

<style lang="" scoped></style>

参考

作者:我只是一个API调用工程师
来源:juejin.cn/post/7080748744592850951

收起阅读 »

项目没发版却出现了bug,原来是chrome春节前下毒

web
前言农历: 腊月二十五阳历: 2023-01-16过年和年兽已经临近过年,公司的迭代版本也已经封版,大家都在一片祥和又掺杂焦虑的气氛中等待春节的到来。 当然,等待的人群里面也有我,吼吼哈嘿。突然企业微信的一声响,我习惯性的抬头瞅了一眼屏幕,嗯? 来至线上bug...
继续阅读 »

前言

  • 农历: 腊月二十五

  • 阳历: 2023-01-16

过年和年兽

已经临近过年,公司的迭代版本也已经封版,大家都在一片祥和又掺杂焦虑的气氛中等待春节的到来。 当然,等待的人群里面也有我,吼吼哈嘿。

突然企业微信的一声响,我习惯性的抬头瞅了一眼屏幕,嗯? 来至线上bug群?

不过因为最近咱前端项目也没有发版,心里多少有点底气的。

于是怀着好奇的心情点开了群消息, 准备看看是什么情况。

结果进群看到是某前端页面元素拖拽功能的位置失效了。晴天霹雳啊,我们有一个类似给运营做自定义活动页面,说是无法拖拽了。然后需要做活动比较紧急,需要尽快修复。

这活脱脱就是跟着春节来的年兽啊。我还没放烟花打年兽,年兽就先朝我冲过来了,那说什么也得较量较量了。


项目背景

我们这个功能是属于一个基础功能,通过npm私有仓库维护版本

这个基础功能呢,很多项目中都在使用。

如果基础功能发了新版本,业务部门不进行升级安装,那么这个业务线的项目也是不会出问题的。所以只要线上出了问题,那么要满足两个条件

1、基础功能进行了发布了npm新版本,且这个版本有问题

2、业务部门进行了升级,使用了这个新版本

排查问题

一般来说:造成问题的可能性有

  1. 有人发过新迭代版本

  2. 是不是存在莫名的缓存

  3. 有人在以前的版本里面下毒了,然后现在发作了(可能性不大)

经过粗略排查

猜测结果
1、发版导致?近期两周,该服务部分未更新,排除
2、缓存导致已经清理,没用,排除
3、下毒了看了相关代码,没什么问题,排除

问题初见端倪

接着发生了两件事情

1、然后我本地跑了一下项目的时候,在操作的时候,存在报错。

2、一个测试兄弟反馈说他那儿可以正常操作

这他么莫不是浏览器兼容问题了吧。

我去他那看了一下,都是chrome浏览器(这个项目我们只支持到chrome就可以)

这时的我感觉可能问题有点大了,莫不是chrome又调整了吧

点开测试兄弟的版本看了下,是108,而且处于重启就会升级的状态。 我赶紧回到我的工位,打开电脑发现是109。


在看了下那个报错, event.path为undefined, 这里先介绍下path是个什么玩意,他是一个数组,里面记录着从当前节点冒泡到顶层window的所有node节点。我们借助这个功能做了一写事情。。。

这直接被chrome釜底抽薪了。(path属于非标准api, 这些非标准api慎用,说不定什么时候就嘎了)

解决问题

1、问题一

既然是event.path没了,那么我们怎么办呢,首先得找到代替path的方法, 上面我们也说了,path里面记录的是从当前节点冒泡到顶层window的所有node节点(我们是拖拽事件)


那么我们可以自己遍历一下当前节点+他的父节点+父节点的父节点+...+window

    let path = [];
   let target = event.target;
   while(target.parentNode !== null){
     path.push(target);
     target = target.parentNode;
  }
   path.push(document, window);
   return path;

在项目里面试了一下,emm,很稳定。

1、问题二

但是我们又遇到了第二个问题,使用到event.path的项目还比较多,这就日了狗了 如果没有更好的方法,那么我只能挨个项目改,然后测试,然后逐个项目发版

这种原始的方法我们肯定是不会采用的,换个思路,既然event下的path被删除了,那么我们在event对象下追加个一个path属性就可以了

当然我们要记得判断下path属性是否存在,因为有部分用户的chrome是老版本的,我们只对升级后的版本做一些兼容就可以了

if (!Event.prototype.hasOwnProperty("path")){
   Object.defineProperties(Event.prototype, {
     path: {
         get: function(){
             var target = this.target;
             console.log('target', target)
             var path = [];
             while(target.parentNode !== null){
                 path.push(target);
                 target = target.parentNode;
            }
             path.push(document, window);
             return path;
        }
    },
     composedPath: {
         value: function(){
             return this.path;
        },
         writable: true
    }
  });
}

这样,我们只需要在每个项目的根html,通过script引入这个js文件就可以了

反思

如题,这个事情怪chrome吗?其实不能怪的。 1、chrome在之前就已经给出了更新通知,只是我们没有去关注这个事情 2、本身event.path不是标准属性,我们却使用了(其实其他浏览器是没有这个属性的,只是chrome提供了path属性, 虽然现在他删除了) 3、总之还是自己不够警惕,同时使用了不标准的属性,以此为戒,共勉

作者:大鱼敢瞪猫
来源:juejin.cn/post/7193520080808837180

收起阅读 »

如何让一套代码完美适配各种屏幕?

一、适配的目的 区别于iOS,android设备有不同的分辨率大小以及不同厂商的系统,目前市场的分辨率可以看下相关统计。 2021市场移动设备分辨率统计 可以看到主流的分辨率有10多种,当不做适配时,一套代码在不同设备上的效果偏大、偏小、截断以及留白严重,那一...
继续阅读 »

一、适配的目的


区别于iOS,android设备有不同的分辨率大小以及不同厂商的系统,目前市场的分辨率可以看下相关统计。


2021市场移动设备分辨率统计


可以看到主流的分辨率有10多种,当不做适配时,一套代码在不同设备上的效果偏大、偏小、截断以及留白严重,那一套代码如何完美的展示在不同的设备上,可以看下面的一些适配方案。


二、UI适配


2.1、常见的适配方式


2.1.1、xml布局控件适配



  1. 避免写死View的宽高,尽量使用warp_content和match_parent;

  2. 父布局为LinearLayout,选择使用android:layout_weight属性,为布局中的每个子View设置权重;

  3. 父布局为RelativeLayout,可以选择使用layout_centerInParent等属性,设置子View的相对位置;

  4. 谷歌官方在之前版本中提供了一个百分比布局方式:support:percent,它支持RelativeLayout和FrameLayout的百分比布局,但是目前官方已经不再维护,而将他取而代之的是新晋布局:ConstraintLayout,ConstraintLayout强大之处不仅在于它能够进行百分比布局,还可以进行相对定位、角度定位、尺寸约束、宽高比、Chainl链布局等,在不同设备间都能处理的游刃有余。


2.1.2、图片适配



  1. .9图

    .9.png图片本质上还是png图片,相对于普通png图来说,.9图可以让图片在指定的位置拉伸和在指定的位置显示内容且不会失真;

  2. 见2.1.4分辨率限定符;


2.1.3、依据产品设计适配


所谓产品设计适配,指的是产品流程在不同设备上有不同的展示方式,例如手机与Pad的区别,在手机设备上,一般来说具体Item列表是一个页面,点击每个Item会跳转至新的详情页;而在宽度>高度的Pad上,为了防止页面空白浪费,一般会要求屏幕左侧为Item列表,右侧即详情页,item与详情页会同时出现在用户的视觉内,如下图


Pad.png


关于这种类型的设计,其实郭霖《第一行代码》给出了一个方案,我在这里抛砖引玉一下,给出基本思路。


这种情况下,适配的核心在于利用android动态加载布局的机制,使得程序能够根据分辨率或者屏幕大小在运行时动态加载不同的布局,而动态加载就需要使用到限定符



  • 限定符
    所谓限定符,指的是给res目录中的子目录加上“-限定符”,可以给不同设备提供不同的资源以及布局,如下图,layout添加-large,-small。


image.png


layout-small:指的是提供给小屏幕设备的资源;

layout-large:指的是提供给大屏幕设备的资源;

layout/layout-normal:指的是提供给中等屏幕设备的资源,也就是默认状态;

layout-xlarge:值得是提供给超大屏幕设备的资源;


在上面所提出的情景下,Pad即指的大屏幕,手机一般可看作为中等屏幕设备,为了在大屏幕下显示双页模式,我们可以在layout-large和layout目录下新建同一个name的布局xml,在layout-large下的xml针对Pad做双页处理,即左半边View+右半边View样式,layout目录下xml还是做普通处理。


在最后项目运行时,会根据不同设备来加载不同目录下的xml资源,即Pad会加载layout-large目录下的xml,普通手机设备会加载layout目录下的xml资源。


从而实现一套代码在不同设备上产品逻辑。


限定符可以大范围的区分设备,但是你还是不知道-large代表是多大的设备,-small代表的是多小的设备,如果需要清楚的区分各个屏幕的大小,那就需要用到最小宽度限定符。



  • 最小宽度限定符(Smallest-width Qualifier),简称SW
    最小宽度限定符指的是,对屏幕的宽度设立一个最小的值(dp),当当前设备屏幕宽度大于这个值就加载一个布局,


image.png


例如在res下新建一个layout-sw720dp的文件夹,当屏幕宽度大于720dp时,项目就会加载layout-sw720dp/***.xml 资源文件。


2.1.4、限定符适配


在2.1.3中提到了限定符的概念,也解决了一部分的设计适配问题,但是还有一些限定符的概念没有涉及到,该目录下将会提到不同的限定符的概念,可以结合2.1.3一起食用。



  • 分辨率限定符
    在Android项目中,会把放置图片资源的文件夹分为drawable-hdpi、xhdpi xxhdpi xxxhdpi等,这些指的就是分辨率限定符。


Andriod系统会根据手机屏幕的大小及屏幕密度去选择不同文件夹下的图片资源,以此来实现在不同大小不同屏幕分辨率下适配的问题。


这里提一点AS对图片资源的匹配规则:


举个例子,当当前的设备密度为xhdpi,此时代码中ImageView需要去引用drawable中的图片,那么根据匹配规则,系统首先会在drawable-xhdpi文件夹中去搜索,如果需要的图片存在,那么直接显示;如果不存在,那么系统将会开始从更高dpi中搜索,例如drawable-xxhdpi,drawable-xxxhdpi,如果在高dpi中搜索不到需要的图片,那么就会去drawable-nodpi中搜索,有则显示,无则继续向低dpi,如drawable-hdpi,drawable-mdpi,drawable-ldpi等文件夹一级一级搜索.


当在比当前设备密度低的文件夹中搜到图片,那么在ImageView(宽高在wrap_content状态下)中显示的图片将会被放大.图片放大也就意味着所占内存也开始增多.这也就是为什么分辨率不高的图片随意放置在drawable中也会出现OOM,而在高密度文件夹中搜到图片,图片在该设备上将会被缩小,内存也就相应减少。


在理想的状态下,不同dpi的文件下应该放置相应dpi的图片资源,以对不同的设备进行适配。




  • 尺寸限定符和最小宽度限定符
    见2.1.3




  • 屏幕方向限定符
    屏幕方向限定符即“-land”、“-port”,分别代表横排和竖屏。




手机会存在横竖屏切换的场景,当设备横屏时,会主动加载layout-land/目录下的资源文件,当设备为竖屏时,则加载layout-port目录下的资源文件。


2.2、今日头条适配方式


在开始今日头条的适配方案之前,需要提及px、dpi、density的概念。


px:即像素,我们常看到的480 * 800 、720 * 1280、1080 * 1920指的就是像素值宽高的意思;


dpi:即densityDpi,每英寸中的像素数;


density:屏幕密度,density = dpi / 160;


scaledDensity:字体的缩放因子,正常情况下和density相等,但是调节系统字体大小后会改变这个值


android中的dp在渲染前会将dp转为px,计算公式:



  • px = density * dp


从dp和px的转换公式 :px = dp * density 可以看出,如果设计图宽为360dp,想要保证在所有设备计算得出的px值都正好是屏幕宽度的话,我们只能修改 density 的值。这就是该方案的核心。


那如何修改系统的density?


可以通过DisplayMetrics获取系统density和scaledDensity值,


val displayMetrics = application.resources.displayMetrics

val density = displayMetrics.density
val scaledDensity = displayMetrics.scaledDensity

设配的目的在于使用一套设计稿,能完好的展示在不同设备上,所以UI需要确定一个固定的尺寸,依据density=px / dp的公式,确定density的值,其中px指的是真实设备的值,
这里我们以设计稿的宽度作为一个纬度进行测算。


举个例子,如设计稿中固定宽度为360dp,当前设备的屏幕宽度为720,那么density = 720 / 360 = 2,其中当前设备的屏幕宽度也可以用DisplayMetrics来获取:


val targetDensity = displayMetrics.widthPixels / 360

整体思路


//0.获取当前app的屏幕显示信息
val displayMetrics = application.resources.displayMetrics
if (appDensity == 0f) {
//1.初始化赋值操作 获取app初始density和scaledDensity
appDensity = displayMetrics.density
appScaleDensity = displayMetrics.scaledDensity
}

/*
2.计算目标值density, scaleDensity, densityDpi
targetDensity为当前设备的宽度/设计稿固定的宽度
targetScaleDensity:目标字体缩放Density,等比例测算
targetDensityDpi:density = dpi / 160 即dpi = density * 160
*/
val targetDensity = displayMetrics.widthPixels / WIDTH
val targetScaleDensity = targetDensity * (appScaleDensity / appDensity)
val targetDensityDpi = (targetDensity * 160).toInt()

//3.替换Activity的density, scaleDensity, densityDpi
val dm = activity.resources.displayMetrics
dm.density = targetDensity
dm.scaledDensity = targetScaleDensity
dm.densityDpi = targetDensityDpi

三、刘海屏适配















image.pngimage.png


  • 有状态栏的界面:刘海区域会显示状态栏,无需适配;

  • 全屏界面:刘海区域可能遮挡内容,需要适配;


针对刘海屏适配,在Android P以上,谷歌官方给出了适配方案,可参考developer.android.google.cn/guide/topic… ,所以在 targetApi >= 28 上可以使用谷歌官方推荐的适配方案进行刘海屏适配。
而在Android O的设备上,如华为、小米、oppo等厂商给出了适配方案。


3.1、Android9.0官方适配


将内容呈现到刘海区域中,则可以使用 WindowInsets.getDisplayCutout() 来检索 DisplayCutout 对象,同时可以使用窗口布局属性 layoutInDisplayCutoutMode 控制内容如何呈现在刘海区域中。


layoutInDisplayCutoutMode



  • LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT :在竖屏模式下,内容会呈现到刘海区域中;但在横屏模式下,内容会显示黑边。

  • LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES:在竖屏模式和横屏模式下,内容都会呈现到刘海区域中。

  • LAYOUT_IN_DISPLAY_CUTOUT_MODE_NEVER:内容从不呈现到刘海区域中。


/**
* @param mode 刘海屏下内容显示模式,针对Android9.0
LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT = 0; //在竖屏模式下,内容会呈现到刘海区域中;但在横屏模式下,内容会显示黑边
LAYOUT_IN_DISPLAY_CUTOUT_MODE_NEVER = 2;//不允许内容延伸进刘海区
LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES = 1;//在竖屏模式和横屏模式下,内容都会呈现到刘海区域中
*/
@RequiresApi(Build.VERSION_CODES.P)
private fun setDisplayCutoutMode(mode: Int) {
window.attributes.apply {
this.layoutInDisplayCutoutMode = mode
window.attributes = this
}

}

判断是否当前设备是否有刘海:


/**
* 判断当前设备是否有刘海
*/
@RequiresApi(Build.VERSION_CODES.P)
private fun hasCutout(): Boolean {
window.decorView.rootWindowInsets?.let {
it.displayCutout?.let {
if (it.boundingRects.size > 0 && it.safeInsetTop > 0) {
return true
}
}
}
return false
}

在activity的
setContentView(R.layout.activity_main)之前设置layoutInDisplayCutoutMode。

















LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULTLAYOUT_IN_DISPLAY_CUTOUT_MODE_NEVERLAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
image.pngimage.pngimage.png

3.2、各大厂商适配方案(华为、小米、oppo等)


除了在AndroidP系统下官方给了适配方案,各大厂商针对自家系统也给出了相应的适配方案,可参考:


oppo

vivo

小米

华为


参考文档

今日头条适配方案

Android9.0官方适配方案


推荐阅读

视频直播小窗口(悬浮窗)展示方案

探究ANR原理-是谁控制了ANR的触发时间


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

Compose太香了,不想再写传统 xml View?教你如何在已有View项目中混合使用Compose

前言 在我的文章 记一次 kotlin 在 MutableList 中使用 remove 引发的问题 中,我提到有一个功能是将多张动图以N宫格的形式拼接,并且每个动图的宽保证一致,但是高不保证一致。 在原本项目中我使用的是传统 view 配合 Recycler...
继续阅读 »

前言


在我的文章 记一次 kotlin 在 MutableList 中使用 remove 引发的问题 中,我提到有一个功能是将多张动图以N宫格的形式拼接,并且每个动图的宽保证一致,但是高不保证一致。


在原本项目中我使用的是传统 view 配合 RecyclerView 和 GridLayout 布局方式进行拼图的预览,但是这会存在一个问题。


实际上是这样排列的:


s1.png


但是预想中应该是这样排列:


s2.png


可以看到,我们的需求应该是完全按照顺序来排列,但是瀑布流布局却是在每一行中,哪一列的高度最小就优先排到哪一列,而不是严格按照给定顺序排列。


显然,这是不符合我们的需求的。


我曾经试图找到其他的替代方式实现这个效果,或者试图找到 GridLayout 的某个参数可以修改为按顺序排列,但是一直无果。


最终,只能用自定义布局来实现我想要的效果了。但是对于原生 View 的自定义布局非常麻烦,我也没有接触过,所以就一直不了了之了。


最近一直在学习 compose ,发现 compose 的自定义布局还挺简单的,所以就萌生了使用 compose 的自定义布局来实现这个需求的想法。


由于这个项目是使用的传统 View ,并且已经上线运行很久了,不可能一蹴而就直接全部改成使用 compose,并且这个项目也还挺复杂的,移植起来也不简单。所以,我决定先只将此处的预览界面改为使用 compose,也就是混合使用 View 与 compose。


开始移植


compose 自定义布局


在开始之前我们需要先使用 compose 编写一个符合我们需求的自定义布局:


@Composable
fun TestLayout(
modifier: Modifier = Modifier,
columns: Int = 2,
content: @Composable ()->Unit
) {
Layout(
modifier = modifier,
content = content,
) { measurables: List<Measurable>, constrains: Constraints ->
val itemWidth = constrains.maxWidth / columns
val itemConstraints = constrains.copy(minWidth = itemWidth, maxWidth = itemWidth)
val placeables = measurables.map { it.measure(itemConstraints) }

val heights = IntArray(columns)
var rowNo = 0
layout(width = constrains.maxWidth, height = constrains.maxHeight){
placeables.forEach { placeable ->
placeable.placeRelative(itemWidth * rowNo, heights[rowNo])
heights[rowNo] += placeable.height

rowNo++
if (rowNo >= columns) rowNo = 0
}
}
}
}

这个自定义布局有三个参数:


modifier Modifier 这个不用过多介绍


columns 表示一行需要放多少个 item


content 放置于其中的 itam


布局的实现也很简单,首先由于每个子 item 的宽度都是一致的,所以我们直接定义 item 宽度为当前布局的最大可用尺寸除以一行的 item 数量: val itemWidth = constrains.maxWidth / columns


然后创建一个 Array 用于存放每一列的当前高度,方便后面摆放时计算位置: val heights = IntArray(columns)


接下来遍历所有子项 placeables.forEach { placeable -> } 。并使用绝对坐标放置子项,且 x 坐标为 宽度乘以当前列, y 坐标为 当前列高度 placeable.placeRelative(itemWidth * rowNo, heights[rowNo])


最后将高度累加 heights[rowNo] += placeable.height 并更新列数到下一列 rowNo++if (rowNo >= columns) rowNo = 0


下面预览一下效果:


@Composable
fun Test() {
Column(
Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
TestLayout {
Rectangle(height = 120, color = Color.Blue, index = "1")
Rectangle(height = 60, color = Color.LightGray, index = "2")
Rectangle(height = 140, color = Color.Yellow, index = "3")
Rectangle(height = 80, color = Color.Cyan, index = "4")
}
}
}


@Composable
fun Rectangle(height: Int, color: Color, index: String) {
Column(
modifier = Modifier
.size(width = 100.dp, height = height.dp)
.background(color),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(text = index, fontWeight = FontWeight.ExtraBold, fontSize = 24.sp)
}
}


@Preview(backgroundColor = 0xFFFFFFFF, showBackground = true)
@Composable
fun PreviewTest() {
Test()
}

效果如下:


s2.png


完美符合我们的需求。


增加修改 gradle 配置


为了给已有项目增加 compose 支持我们需要增加一些依赖以及更新一些参数配置。


检查 AGP 版本


首先,我们需要确保 Android Gradle Plugins(AGP)版本是最新版本。


如果不是的话需要升级到最新版本,确保 compose 的使用,例如我写作时最新稳定版是 7.3.0


点击 Tools - AGP Upgrade Assistant 打开 AGP 升级助手,选择最新版本后升级即可。


检查 kotlin 版本


不同的 Compose Compiler 版本对于 kotlin 版本有要求,具体可以查看 Compose to Kotlin Compatibility Map


例如,我们这里使用 Compose Compiler 版本为 1.3.2 则要求 kotlin 版本为 1.7.20


修改配置信息


首先确保 API 等级大于等于21,然后启用 compose:


buildFeatures {
// Enables Jetpack Compose for this module
compose true
}

配置 Compose Compiler 版本:


composeOptions {
kotlinCompilerExtensionVersion '1.3.2'
}

并且确保使用 JVM 版本为 Java 8 , 需要修改的所有配置信息如下:


android {
defaultConfig {
...
minSdkVersion 21
}

buildFeatures {
// Enables Jetpack Compose for this module
compose true
}
...

// Set both the Java and Kotlin compilers to target Java 8.
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}

composeOptions {
kotlinCompilerExtensionVersion '1.3.2'
}
}

添加依赖


dependencies {
// Integration with activities
implementation 'androidx.activity:activity-compose:1.5.1'
// Compose Material Design
implementation 'androidx.compose.material:material:1.2.1'
// Animations
implementation 'androidx.compose.animation:animation:1.2.1'
// Tooling support (Previews, etc.)
implementation 'androidx.compose.ui:ui-tooling:1.2.1'
// Integration with ViewModels
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.5.1'
// UI Tests
androidTestImplementation 'androidx.compose.ui:ui-test-junit4:1.2.1'
}

自此所有配置修改完成,Sync 一下吧~


将 view 替换为 compose


根据我们的需求,我们需要替换的是用于预览拼图的 RecyclerView:


<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".fragment.gifTools.JointGifPreviewFragment">

<!-- ... -->

<androidx.recyclerview.widget.RecyclerView
android:id="@+id/jointGif_preview_recyclerView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="48dp"
android:layout_marginBottom="8dp"
android:transitionName="shared_element_container_gifImageView"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />

<!-- ... -->

</androidx.constraintlayout.widget.ConstraintLayout>

将其替换为承载 compose 的 ComposeView:


<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".fragment.gifTools.JointGifPreviewFragment">

<!-- ... -->

<androidx.compose.ui.platform.ComposeView
android:id="@+id/jointGif_preview_recyclerView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="48dp"
android:layout_marginBottom="8dp"
android:transitionName="shared_element_container_gifImageView"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />

<!-- ... -->

</androidx.constraintlayout.widget.ConstraintLayout>

在原本初始化 RecyclerView 的地方,将我们上面写好的 composable 设置进去。


将:


override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)

// ...

initRecyclerView()

// ...
}

改为:


override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)

// ...

bind.jointGifPreviewRecyclerView.setContent {
Test()
}

// ...
}

ComposeViewsetContent(content: @Composable () -> Unit) 方法只有一个 content 参数,而这个参数是一个添加了 @Composable 注解的匿名函数,也就是说,在其中我们可以正常的使用 compose 了。


更改完成后看一下运行效果:


s3.png


可以看到,混合使用完全没有问题。


但是这里我们使用的是写死的 item 数据,而不是用户动态选择的图片数据,所以下一步我们需要搞定 compose 和 view 之间的数据交互。


数据交互


首先,因为我们需要显示的动图,所以需要引入一下对动图的支持,这里我们直接使用 coil 。


引入 coil 依赖:


// coil compose
implementation 'io.coil-kt:coil-compose:2.2.2'
// coil gif 解码支持
implementation 'io.coil-kt:coil-gif:2.2.2'

定义一个用于显示 gif 的 composable:


@Composable
fun GifImage(
uri: Uri,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val imageLoader = ImageLoader.Builder(context)
.components {
if (SDK_INT >= 28) {
add(ImageDecoderDecoder.Factory())
} else {
add(GifDecoder.Factory())
}
}
.build()

Image(
painter = rememberAsyncImagePainter(model = uri, imageLoader = imageLoader),
contentDescription = null,
modifier = modifier,
contentScale = ContentScale.FillWidth
)
}

其中,rememberAsyncImagePaintermodel 参数支持多种类型的图片,例如:File Uri String Drawable Bitmap 等,这里因为我们原本项目中使用的是 Uri ,所以我们也定义为使用 Uri。


而 coil 对于不同 API 版本支持两种解码器 ImageDecoderDecoderGifDecoder 按照官方的说法:



Coil includes two separate decoders to support decoding GIFs. GifDecoder supports all API levels, but is slower. ImageDecoderDecoder is powered by Android's ImageDecoder API which is only available on API 28 and above. ImageDecoderDecoder is faster than GifDecoder and supports decoding animated WebP images and animated HEIF image sequences.



简单翻译就是 GifDecoder 支持所有 API 版本,但是速度较慢; ImageDecoderDecoder 仅支持 API >= 28 但是速度较快。


因为我们的需求是宽度一致,等比缩放长度,所以需要给 Image 加上缩放类型 contentScale = ContentScale.FillWidth


之后把我们的自定义 Layout 改一下名字,其他内容不变: SquareLayout


增加一个 JointGifSquare 用作界面入口:


@Composable
fun JointGifSquare(
columns: Int,
uriList: ArrayList<Uri>,
) {
SquareLayout(columns = columns) {
uriList.forEachIndexed { index, uri ->
GifImage(
uri = uri,
)
}
}
}

其中 columns 表示每一行有多少列;uriList 表示需要显示 GIF 动图 Uri 列表。


最后,将 Fragmnet 中原本初始化 RecyclerView 的方法改为:


private fun initRecyclerView() {
val showGifResolutions = arrayListOf()

// 获取用户选择的图片列表,初始化 showGifResolutions

// ...

var lineLength = GifTools.JointGifSquareLineLength[gifUris!!.size]

bind.jointGifPreviewRecyclerView.setContent {
JointGifSquare(
lineLength,
gifUris!!
)
}
}

其中,GifTools.JointGifSquareLineLength 是我定义的一个 HashMap 用来存放所有图片数量与每一行数量的对应关系:


val JointGifSquareLineLength = hashMapOf(4 to 2, 9 to 3, 16 to 4, 25 to 5, 36 to 6, 49 to 7, 64 to 8, 81 to 9, 100 to 10)

从上面可以看出,其实要从 compose 中拿到 View 的数据也很简单,直接传值进去即可。


最终运行效果:


g1.gif


原本使用 view 的运行效果:


g2.gif


可以看到,使用 compose 重构后的排列方式才是符合我们预期的排列方式。


总结


自此,我们就完成了将 View 中的其中一个界面替换为使用 compose 实现,也就是混合使用 view 和 compose 。


其实这个功能还有两个特性没有移植,那就是支持点击预览中的任意图片后可以更换图片和长按图片可以拖拽排序。


这两个功能的界面实现非常简单,难点在于,我怎么把更换图片和重新排序图片后的状态传回给 View。


这个问题我们就留着以后再说吧。


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

探索环信IMuni-app(小程序)在集成中遇到的断联问题,如何保持较为稳定的websocket链接?

BB在前说一下写这个文章的契机吧,目前在自己负责的项目中尤其在移动端H5,以及uni-app开发小程序项目当中较为经常会遇到,登录环信IM之后长连接断开问题,主要出现的场景也较为集中,下面列举一下我复现出现的场景,附带有一些我的解决场景,不能保证百分百的解决大...
继续阅读 »

BB在前
说一下写这个文章的契机吧,目前在自己负责的项目中尤其在移动端H5,以及uni-app开发小程序项目当中较为经常会遇到,登录环信IM之后长连接断开问题,主要出现的场景也较为集中,下面列举一下我复现出现的场景,附带有一些我的解决场景,不能保证百分百的解决大家的问题,不求有功,但求有用。

常见复现的链接断开场景


异常场景一

用户长时间息屏,此类操作较为高频复现,操作步骤就是open环信IM,然后进入聊天界面,发送几条消息后直接熄灭手机屏幕长时间不再进行开屏操作(这个长时间怎么理解?五分钟十分钟甚至三十分钟都可能),然后重新打开评估直接进行消息发送,如果你使用的是4.x SDK 这时候发送消息直接会出现type 39 not login类报错,如果不是是3.x之类的SDK可能会发送不产生success消息回调,无任何反应。【高复现】

异常场景二
用户原来在聊天界面进行消息发送,退出聊天界面到后台进行挂起,然后就手机撂那了,对就是直接撂那了🤔,经过了一段时间等待用户又回到了聊天界面进行聊天,偶现消息发送无反应等待1、2秒自动进行了连接。【偶现】

异常场景三
用户退出聊天界面,开始进行其他应用内的操作,下班了,准备刷会B站,随手点开了微信,在微信里面回了一个小时的工作信息。(艹、血压开始高了),然后回到聊天界面发送消息出现没有反应。【长时间切出高复现】

异常场景四
长时间挂起中间可能出现了网络波动,自动进行了网络切换发现继续消息发送没有反应。【偶现】

异常场景五
用户发现4G信号不太好用,手动连接了一下WIFI,结果发现WIFI信号更差,又切了回来,结果发现这几通操作之后发送消息失败或者不触发消息成功回调。【较高复现】

解决时主要用到的方法&API

环信SDK当中提供的几种判断是否正在连接的API

/* 主动调用类 **/
WebIM.conn.open() //打开IM登录
WebIM.conn.close() //关闭IM连接
WebIM.conn.isOpened() //boolean false未连接 true连接
WebIM.logOut //boolean false在登录 true已退出
/* 被动触发类 **/
//消息监听,两种类型写法一种为3.x 一种为4.x
WebIM.conn.listen({
onOpened: function () {}, //连接成功回调
onClosed: function () {}, //连接关闭回调
})
WebIM.conn.addEventHandler("handlerId", {
onConnected: () => {
console.log("onConnected");
},
onDisconnected: () => {
console.log("onDisconnected"
);
})


2、【以uni-app为例】生命周期钩子函数

<script>
export default {
onShow: function() {

},
onHide: function() {

}
}
</script>

3、【以uni-app为例】网络状态变更监听API

uni.onNetworkStatusChange()


上述场景优化的方式方法


关于场景一、二、三的优化探索:

回顾一下场景一、二、三主要面临的问题是,用户在进入聊天页面操作后息屏了手机可能去忙其他的事情,或者进入到其他应用,后台挂起。
过了很长时间才再次恢复操作手机,进入到IM聊天页面,并且很有可能开屏直接就展示了聊天界面并且进行继续聊天用户用户在整个操作流程中是无感知的并且应该是无感知的。
这里分享一个在探索无感知重连过程中,实际使用并且有效的解决方案,以及代码片段。
1、IM登录后全局保留登录状态,可以存储在globdata或者Vuex之类的全局状态管理方法里。
2、在App.vue的根组件中增加onShow生命周期钩子函数,或者在IM相关页面中增加onShow钩子函数,主要目的想必大家也已经猜到了,每次用户开屏或者进入到页面应用中都会触发onShow钩子函数,在此钩子函数中获取当前应用的登录状态,并且调用SDK内部提供的获取SDK本地连接状态的api去进行判断是否需要手动补偿登录,话不多说上代码。

<script>
export default {
globalData: {
isLoginIM: false
},
onShow() {
console.log('>>>>>this.isLoginIM', this.globalData.isLoginIM)
//判断的逻辑是如果全局已经是登录状态,但是获取当前SDK本地状态却为false未连接那么需要进行手动补偿重新登录。
if (this.globalData.isLoginIM && !uni.WebIM.conn.isOpened()) {
console.log('执行重连逻辑')
uni.WebIM.conn.close && uni.WebIM.conn.close();
//增加延时目的是为了,确保连接完全断开再去执行重新openIM操作
setTimeout(()=>{
this.loginIM()
},500)

} else {
console.log('不需要执行重新登陆')
}
},
}
</script>


关于场景四、五的优化探索:
场景四、五的点其实主要还是在弱网切换或者网络波动的情况下出现的连接层面问题,网络的稳定其实是较为不可控的,我们没有办法保证终端用户时刻保持在一个较为良好稳定的网络环境下,默认情况下,环信SDK是有重连逻辑在里面的,默认重连次数为5次,触发时机也基本为网络切换或者网络完全断开,重连结束之后就没有办法再进一步进行重连了。

下面探讨的优化是如果觉得切换网络后SDK的重连速度不能满足需求(其实在4.1.2后续的SDK版本中切网重连速度进行了优化,基本满足了实际需求,老的版本确实发现有切网连接较慢问题。),没有在第一时间就介入进行重连,所以通过监听网络层面的变化手动将其断开再进行IM连接的形式进行重连。

<script>
exprot default {
onLoad(){
//通过uni提供的网络状态变化监听,主动监听网络进行了变化就进行断开手动进行连接。
uni.onNetworkStatusChange((info) => {
console.log('>>>>>>>>>>>>>网络变化', info);
uni.showToast({
icon: 'none',
title: '网络变化',
});

uni.WebIM.conn.close();
console.log('>>>>>重新连接', this.login);
//加延时是断开是异步操作,有可能还未断开就进行了登录,此时登录是无效的。
setTimeout(() => {
this.login();
}, 500);
});
}
}
</script>


极端补偿连接的手段
这种方案在非常极端的情况下考虑使用,在App.vue中增加onShow,onHide钩子函数(加在App.vue根组件是因为全局退出进入必然会触发这个组件,不用担心在某个页面没加载的时候不触发的情况),应用息屏切出,都必然会触发onHide钩子函数,在这一步直接选择断开与环信的链接,不用担心断开后又收到消息的问题,因为离线后收到的消息是会存储在环信的离线服务器当中的,再次登录后会再次进行投递的,恢复页面后会触发onShow再次进行IM连接这样,就规避调用切出切入连接不稳定的问题,这种方案可以用但是不推荐,因为断开重连太过于频繁属于比较重的操作,并且据观察,不同机型上,可能选择发送图片从系统中选择文件都会触发全局的onHide,这种断开肯定是我们不希望的,而且通常上述方案就已经能够满足使用。

<script>
export default {
globalData:{
isChangeConnect:true //这个是因为在安卓上选择相册也会触发onHide onShow,所以增加个状态在选择发图片的时候更改,让其不触发断开重连。
},
onLaunch: function() {
console.log('App Launch')
this.listenIM()
},
onShow: function() {
console.log('App Show',this.globalData.isChangeConnect)
if (this.globalData.isChangeConnect) {
this.loginIM()
}
},
onHide: function() {
console.log('App Hide',this.globalData.isChangeConnect)
if (this.globalData.isChangeConnect) {
this.closeIM()
}
},
</script>


BB在后
上面是我遇到的一些场景优化手段,为什么是探索呢?因为有些点也是在进行尝试的手段,不一定是最佳实践,也不一定必然能够解决全部场景的问题,我看这个还能重新再编辑,后续如果有更高的形式,或者方案再加进去,或者在评论区与大家共同探讨,如果觉得这篇小破文对你有帮助,留个赞吧。


收起阅读 »

你还在用merge么,了解了解rebase吧

前言 Git作为我们日常开发代码的版本管理,开发分支的管理方面起着很大作用,我们开发过程中分支通常有生产、预发、测试、开发这几个分支,我们会根据项目进行的某个阶段,将代码提交到某个版本上,正常流程是先开发 —>测试 —>预发—>生产,但是通常...
继续阅读 »

前言


Git作为我们日常开发代码的版本管理,开发分支的管理方面起着很大作用,我们开发过程中分支通常有生产、预发、测试、开发这几个分支,我们会根据项目进行的某个阶段,将代码提交到某个版本上,正常流程是先开发 —>测试 —>预发—>生产,但是通常会有很多版本,有先后上线顺序,并且我们的开发人员也会是多个,在各种因素下项目的开发版本远程分支,以及开发人员的本地分支管理就由为的关键。


普通开发流程


正常一个版本需要经过的几个阶段,分别是dev、test、uat、master,我们通过下面流程图这么做是没什么问题的,每个阶段去将从master拉取的版本分支,push到对应的分支上进行发布,正常预发和生产环境的代码应该保持一致,test分支由于会有多个版本并行开发,所以代码和预发和生产比起来会有一些不一样。


B6C4A36C-1922-4B72-954E-414C41A8D7D5.png


多版本并行开发


在多个版本并非开发的时候,对分支的管理就不像上面那么简单了,涉及到多个version,这些版本的上线时间节点也是不同的,意味着上test和uat的时间节点也是不一样的。


这里涉及到多种情况



  1. 在后端开发人员较少的情况下,通常2-3人为例,完全可以从master拉取一个开发分支,分支格式已 服务名+上线时间,例如xxx_20230130这个本地分支,后端开发人员一起在这个分支上进行并行开发,开发阶段将自己的本地分支merge到dev分支,因为只有2-3人所以冲突解决起来还好,有冲突解决冲突。

  2. 后端开发人员较多的情况,通常在5-8人为例,这时候从master分支拉取分支,分支格式就需要已 服务名+姓名缩写+上线时间来命名,尽量每个人在自己命名的分支下进行开发,这样在开发阶段本地测试的时候,可以做到相互不影响,但是在merge到远程分支的时候,解决代码冲突的时候需要认真仔细一些,这种活还是交给心细的人来做吧,测试的时候也需要根据版本上线的优先级进行测试。

  3. 版本比较多的情况,比如一个月会有4-5个版本的开发,那么上线时间也是分4-5个节点,这样就需要每次从先发上线的远程分支,将代码merge到下个版本的本地开发分支上,以此类推。


58C8F39E-4161-4FBA-9861-CF48E436F5AF.png


Git merge


作为git 合并分支的命令,也是在日常开发过程中经常用到的一个命令,通常我们会将拥有最新代码的一个版本merge到较老的一个版本,实现版本同步。


3EE8E0C2-67C1-4136-9703-67726D5B1005.png


大体就是这么一个步骤,从刚开始的公共分支,变为master和feature分支,
通过git merge master 命令将master分支merge到feature分支。
Merge命令会将前面featrue分支所有的commit提交全部合并为一个新的commit提交。
⚠️这里只有会在产生冲突的时候,才能产生新的commit记录。


可以理解为git pull =git fetch +git merge,拉取最新的远程分支,然后将这个分支合并到另一个分支。


在公司开发的时候,通常大家喜欢这个命令,因为简单粗暴,直接将其他分支合并到自己分支,简单好理解。


Git rebase


作为自己的个人喜好,比较喜欢rebase这个命令,核心理念就是“变基”。


3F362A81-B158-4CCA-86A8-FA7715E2FDF7.png



  1. 由上图可看见,通过reabse命令将feature分支延续到了master分支后面。

  2. 在多人开发过程中,如果其他人在master进行commit,这个时候你在feature分支提交了几个commit,这时候你使用rebase命令,会将你的commit提交记录放在master的commit记录的后面,而merge就会将不同分支的commit合并成一个新的commit记录,这就是merge和rebase的不同点。

  3. 本地feature分支和远端的master分支如果是同一条分支的话,可以使用rebase,保证commit的记录的清晰性,这个很关键!


⚠️不要在公共分支使用rebase命令,这样会污染公共分支,这样公共分支就会存在你的commit记录,别人拉取的时候会存在你的最新的commit记录。


总结


在开发中不仅需要代码质量高,在版本管理上也是由为的重要,上线前漏掉代码的事情,相信大家都曾遇到过,但是这种事情是很危险⚠️的,希望此文章能给大家在日常代码版本管理中提交警惕,合理合并分支,最后祝大家在新的一年,少出bug、多多学习、多多进步。


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

谷歌的bug:当 CompileSdk 33 遇上Kotlin

最近项目里compose 要升级到1.3, 要求compile sdk 也要到33版本,大家都知道 一般情况下,我们修改compilesdk 都不会有什么问题,最多就是一些api的适配,编译不过啥的, 但是不会引发线上故障,但是这里要注意了target sd...
继续阅读 »

最近项目里compose 要升级到1.3, 要求compile sdk 也要到33版本,大家都知道 一般情况下,我们修改compilesdk 都不会有什么问题,最多就是一些api的适配,编译不过啥的, 但是不会引发线上故障,但是这里要注意了target sdk 的修改 就要复杂的多了, 这里不多说,只介绍一下 我碰到的一个compilesdk 33的问题


在Compile sdk 33版本中,这个手势监听的接口 代码发生了一些变化:


image.png


在这些接口方法说 参数前面加上了一个NonNull的 注解,这个注解的意思就是 告诉开发者 这个参数不可能为空


image.png


注意了 在<=32的版本中 这个注解是没有的


image.png


对于java的开发者来说,这个影响微乎其微,但是如果你跟我一样是kotlin的开发者就要倒霉了,


因为在<=32的时候 你继承这个接口的时候 会提示你参数要定义成可空的


但是当你升级到33的sdk的时候,你就会发现编译不过了


image.png


为啥?


因为33的sdk 前面说过了,方法前面有了 不可空的注解了


要让他编译过很简单 我们只要把? 去掉即可


image.png


到这里还没结束,最坑的地方来了, 虽然你能编译过,但是在运行时,有可能会发生crash


image.png


为啥? 熟悉kotlin的人就知道了,当你定义一个参数为不可空的类型的时候,你如果传了一个null给这个参数,他就会报这个crash了,这种情况常见于 java代码调用kotlin代码的时候 这是kotlin编译器的魔法,有兴趣的可以自己反编译看一下字节码,实际上,当你定义一个变量为不可空的时候,如果传值给他 他就会校验这个值 是不是为null 为null 则直接抛异常


搞清楚问题所在以后 就得想想怎么解决了,目前的情况就是 如果不改,就编译不过,改了 在运行时会crash


另外:
这里有个链接,可以看下该问题的讨论,目前状态是显示 谷歌承认了该bug,看状态显示fixed,但是不知道为什么
还没有推送最新的33 sdk
issueTracker


实际上解决这个问题的方法有很多,


方法1: 这个接口的实现 我们不用kotlin写,用java写,即可 这个方案最简单,但是不太优雅


方法2: 魔改下android sdk 33版本的jar包,把注解去掉 这个方案也可以,但是有点麻烦


方法3: asm 字节码修改,把那个校验参数为null 就抛异常的代码删了就行了。 杀鸡焉用牛刀


方法4: 写一个delegate 即可,以后都用这个代理类去做监听, 这个方法我认为是最简单的,一劳永逸,而且成本极低



import android.content.Context;
import android.os.Handler;
import android.view.GestureDetector;
import android.view.MotionEvent;

import androidx.annotation.Nullable;

/**
* 在compile sdk 33 中 修复google的一个注解bug,该bug 会导致 要么kotlin代码编译失败
* 要么运行时crash,这里用代理模式 简单的规避此问题即可
*
*/
public class GestureDetectorDelegate extends GestureDetector {
/**
* @param listener
* @param handler
* @deprecated
*/
public GestureDetectorDelegate(OnGestureListener listener, Handler handler) {
super(listener, handler);
}

/**
* @param listener
* @deprecated
*/
public GestureDetectorDelegate(OnGestureListener listener) {
super(listener);
}

public GestureDetectorDelegate(Context context, OnGestureListenerDelegate listener) {
super(context, listener);
}

public GestureDetectorDelegate(Context context, OnGestureListener listener, Handler handler) {
super(context, listener, handler);
}

public GestureDetectorDelegate(Context context, OnGestureListener listener, Handler handler, boolean unused) {
super(context, listener, handler, unused);
}

/**
* 主要修改点就是在这里了,复写这些方法 标记这些参数为可空的即可
*/
public interface OnGestureListenerDelegate extends OnGestureListener {
boolean onDown(@Nullable MotionEvent e);

void onShowPress(@Nullable MotionEvent e);

boolean onSingleTapUp(@Nullable MotionEvent e);

boolean onScroll(@Nullable MotionEvent e1, @Nullable MotionEvent e2, float distanceX, float distanceY);

void onLongPress(@Nullable MotionEvent e);

boolean onFling(@Nullable MotionEvent e1, @Nullable MotionEvent e2, float velocityX, float velocityY);
}
}

方案5: 利用proguard混淆的配置规则


其实所谓的抛异常,就是kotlin在编译的时候 手动帮我们增加了判断是否null 然后抛异常的方法


image.png


那我们实际上最简单的方案就是 利用混淆的规则,在release包构建的时候 把这个代码去掉就可以了


-assumenosideeffects class kotlin.jvm.internal.Intrinsics {
public static void check*(...);
}

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

耗时一周,实现高仿微信渐变模糊效果——纯原生实现

最近一个需求要实现类似微信状态的模糊效果,还要求不能引入库,增加包的大小。网上搜了一圈,只有 Flutter 的实现。没办法只能自己开撸,实现效果如下,上面的图是我的实现效果,下面的是微信的实现效果。 实现原理 首先,我们观察一下下面的微信状态的实现效...
继续阅读 »

最近一个需求要实现类似微信状态的模糊效果,还要求不能引入库,增加包的大小。网上搜了一圈,只有 Flutter 的实现。没办法只能自己开撸,实现效果如下,上面的图是我的实现效果,下面的是微信的实现效果。



Screenshot_2022-11-12-14-46-40-11_2d4809a04714b92ad0ec5f736efb755b.jpg


956ac151237d8da1ad0bc9edebd6744a.jpg



实现原理


首先,我们观察一下下面的微信状态的实现效果。可以看出上部分是截取了头发部分进行了高斯模糊;而下面部分则是对围裙进行高斯模糊。


image.png


拿原图进行对比,我们可以发现,渐变高斯模糊的部分遮住了原图片,同时还有渐变的效果。最后,图片好像加了一层灰色的遮罩,整体偏灰。

接下来,我们要做的事情就清楚了。


第一步:选取原图片的上下两部分分别进行高斯模糊
第二步:自定义 OnDraw 方法,让高斯模糊的部分覆盖原图片的上下两部分
第三步:让高斯模糊的图片实现渐变效果


选取原图片的上下两部分分别进行高斯模糊


在开始高斯模糊前,我们需要先确定上下两部分的高度。需要注意的是,我们不能直接使用图片的高度,因为图片的宽不一定等于屏幕的宽度。因此,我们需要按照比例计算出图片缩放后的高度。代码如下:



//最后要求显示的图片宽度为屏幕宽度
int requireWidth = UIUtils.getScreenWidth(context);
int screenHeight = UIUtils.getScreenHeight(context);
//按照比例,计算出要求显示的图片高度
int requireHeight = requireWidth * source.getHeight() / source.getWidth();
int topOrBottomBlurImageHeight = (int) ((screenHeight - requireHeight) / 2 + requireHeight * 0.25f);

如下图所示,最后一步 (screenHeight - requireHeight) / 2 获取到缩放后的图片居中时的上下两部分的高度。但是,渐变高斯模糊的部分还需要增加 padding 来遮住原图片的部分内容,这里的 padding 取的是 requireHeight * 0.25f


企业微信截图_89ec2f08-1741-4789-9c7c-943be98e3f68.png


计算出高度后,我们还不能对图片直接进行高斯模糊,要先要对图片进行缩放。为什么要先进行压缩呢?有两点原因:



  1. 使用 RenderScript 进行高斯模糊,最大模糊半径是 25,模糊效果不理想

  2. 高斯模糊的半径超过 10 之后就有性能问题


为了解决上面的问题,我们需要先对图片进行缩放,再进行高斯模糊。核心代码如下,为了后面使用协程,这里是用 kotlin 实现的。



private val filter = PorterDuffColorFilter(Color.argb(140, 0, 0, 0), PorterDuff.Mode.SRC_ATOP)

private fun blurBitmap(
source: Bitmap,
radius: Int,
top: Boolean,
topOrBottomBlurImageHeight: Int,
screenHeight: Int,
context: Context?
): Bitmap? {

//第1部分
val cutImageHeight = topOrBottomBlurImageHeight * source.height / screenHeight
val sampling = 30

//第2部分
val outBitmap = Bitmap.createBitmap(source.width / sampling,
cutImageHeight / sampling, Bitmap.Config.ARGB_8888)
val canvas = Canvas(outBitmap)
canvas.scale(1 / sampling.toFloat(), 1 / sampling.toFloat())
val paint = Paint()
paint.flags = Paint.FILTER_BITMAP_FLAG or Paint.ANTI_ALIAS_FLAG
//过滤颜色值
paint.colorFilter = filter
val dstRect = Rect(0, 0, source.width, cutImageHeight)
val srcRect: Rect = if (top) {//截取顶部
Rect(0, 0, source.width, cutImageHeight)
} else {//截取底部
Rect(0, source.height - cutImageHeight, source.width, source.height)
}
canvas.drawBitmap(source, srcRect, dstRect, paint)

//高斯模糊
val result = realBlur(context, outBitmap, radius)

//创建指定大小的新 Bitmap,内部会对传入的原 Bitmap 进行拉伸
val scaled = Bitmap.createScaledBitmap(
result,
(source.width),
(cutImageHeight),
true)
return scaled
}

代码看不懂?没关系,下面会一一来讲解:


第1部分,这里定义了两个本地变量 cutImageHeightsamplingcutImageHeight 是要裁剪图片的高度,sampling 是缩放的比例。你可能会奇怪 cutImageHeight 的计算方式。如下图所示,cutImageHeight 是用 topOrBottomBlurImageHeight 占屏幕高度的比例计算的,目的是让不同的图片裁剪的高度不同,这也是微信状态模糊的效果。如果你想固定裁剪比例,完全可以修改 cutImageHeight 的计算方式。


image.png


第2部分,这里就做了一件事,就是截取原图的部分并压缩。这里比较难理解的就是为什么创建 Bitmap 时,它的宽高已经缩小了,但是还需要调用 canvas.scale。其实,canvas.scale 只会作用于 canvas.drawBitmap 里的原 Bitmap


高斯模糊这里可以采取你项目里之前使用的方式就行,如果之前没做过高斯模糊,可以看Android图像处理 - 高斯模糊的原理及实现。这里使用的是 Google 原生的方式,代码如下:


@Throws(RSRuntimeException::class)
private fun realBlur(context: Context?, bitmap: Bitmap, radius: Int): Bitmap {
var rs: RenderScript? = null
var input: Allocation? = null
var output: Allocation? = null
var blur: ScriptIntrinsicBlur? = null
try {
rs = RenderScript.create(context)
rs.messageHandler = RenderScript.RSMessageHandler()
input = Allocation.createFromBitmap(
rs, bitmap, Allocation.MipmapControl.MIPMAP_NONE,
Allocation.USAGE_SCRIPT
)
output = Allocation.createTyped(rs, input.type)
blur = ScriptIntrinsicBlur.create(rs, Element.U8_4(rs))
blur.setInput(input)
blur.setRadius(radius.toFloat())
blur.forEach(output)
output.copyTo(bitmap)
} finally {
rs?.destroy()
input?.destroy()
output?.destroy()
blur?.destroy()
}
return bitmap
}

还有一点细节,由于我们给高斯模糊的图片加了 filter ,为了保持一致性。我们也需要给原 Bitmap 进行过滤。代码如下:


private fun blurSrc(bitmap: Bitmap): Bitmap? {
if (bitmap.isRecycled) {
return null
}
val outBitmap = Bitmap.createBitmap(bitmap.width, bitmap.height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(outBitmap)
val paint = Paint()
paint.flags = Paint.FILTER_BITMAP_FLAG or Paint.ANTI_ALIAS_FLAG
paint.colorFilter = filter
canvas.drawBitmap(bitmap, 0f, 0f, paint)
return outBitmap
}

最后,我们可以使用协程来获取处理后的 Bitmap ,代码如下


fun wxBlurBitmap(source: Bitmap, topOrBottomBlurImageHeight: Int, screenHeight: Int, context: Context?, imageView: BlurImageView) {
if(source.isRecycled) {
return
}
GlobalScope.launch(Dispatchers.Default) {
val time = measureTimeMillis {
val filterBitmap = async {
blurSrc(source)
}
val topBitmap = async {
blurBitmap(source, 10, true, topOrBottomBlurImageHeight, screenHeight, context)
}
val bottomBitmap = async {
blurBitmap(source, 10, false, topOrBottomBlurImageHeight, screenHeight, context)
}
val src = filterBitmap.await()
val top = topBitmap.await()
val bottom = bottomBitmap.await()
launch(Dispatchers.Main) {
if(top == null || bottom == null) {
imageView.setImageBitmap(source)
} else {
imageView.setBlurBitmap(src, top, bottom, topOrBottomBlurImageHeight)

}

}
}
}
}

自定义 ImageView


上面的操作,我们获得了3个 Bitmap,要把它们正确的摆放就需要我们自定义一个 ImageView。如果对自定义 View 不了解的话,可以看看扔物线大佬的 Hencoder 的自定义View系列 教程。代码如下:


public class BlurImageView extends androidx.appcompat.widget.AppCompatImageView {

private Bitmap mSrcBitmap;
private Bitmap mTopBlurBitmap;
private Bitmap mBottomBlurBitmap;
private Matrix mDrawMatrix;
private Paint mPaint;
private Shader mTopShader;
private Shader mBottomShader;
private PorterDuffXfermode mSrcPorterDuffXfermode;
private PorterDuffXfermode mBlurPorterDuffXfermode;
private int mTopOrBottomBlurImageHeight;

public BlurImageView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
init();
}

/**
* 设置图片
* @param src 原图片的 Bitmap
* @param top 原图片top部分的 Bitmap
* @param bottom 原图片bottom部分的 Bitmap
* @param topOrBottomBlurImageHeight 模糊图片要求的高度
*/
public void setBlurBitmap(Bitmap src, Bitmap top, Bitmap bottom, int topOrBottomBlurImageHeight) {
this.mSrcBitmap = src;
this.mTopBlurBitmap = top;
this.mBottomBlurBitmap = bottom;
this.mTopOrBottomBlurImageHeight = topOrBottomBlurImageHeight;
invalidate();
}

private void init() {
mPaint = new Paint();
mDrawMatrix = new Matrix();
mPaint.setAntiAlias(true);
mSrcPorterDuffXfermode = new PorterDuffXfermode(PorterDuff.Mode.DST_ATOP);
mBlurPorterDuffXfermode = new PorterDuffXfermode(PorterDuff.Mode.DST_ATOP);
}

@Override
protected void onDraw(Canvas canvas) {
if(mSrcBitmap == null || mTopBlurBitmap == null || mBottomBlurBitmap == null) {
super.onDraw(canvas);
return;
}
if(mSrcBitmap.isRecycled() || mTopBlurBitmap.isRecycled() || mBottomBlurBitmap.isRecycled()) {
mSrcBitmap = null;
mTopBlurBitmap = null;
mBottomBlurBitmap = null;
return;
}

int save = canvas.saveLayer(null, null, Canvas.ALL_SAVE_FLAG);

//第1部分
final int srcWidth = mSrcBitmap.getWidth();
final int srcHeight = mSrcBitmap.getHeight();
final int topWidth = mTopBlurBitmap.getWidth();
final int topHeight = mTopBlurBitmap.getHeight();
final int contentWidth = getWidth() - getPaddingLeft() - getPaddingRight();
final int contentHeight = getHeight() - getPaddingTop() - getPaddingBottom();
float scrBitmapScale = (float) contentWidth / (float) srcWidth;
float srcTopOrBottomPadding = (contentHeight - srcHeight * scrBitmapScale) * 0.5f;
int requireBlurHeight = mTopOrBottomBlurImageHeight;
float overSrcPadding = requireBlurHeight - srcTopOrBottomPadding;//要求的模糊图片的高度
float dx = 0;//缩放后的模糊图片的x方向的偏移
float dy = 0;//缩放后的模糊图片的y方向的偏移
float blurScale = 0;//高斯模糊图片的缩放比例
if(requireBlurHeight * topWidth >= topHeight * contentWidth) {
//按照高缩放
blurScale = (float) requireBlurHeight / (float) topHeight;
dx = (contentWidth - topWidth * blurScale) * 0.5f;
} else {
//按照宽缩放,因为按照高缩放时,当前Bitmap无法铺满
blurScale = (float) contentWidth / (float) topWidth;
dy = (requireBlurHeight - topHeight * blurScale) * 0.5f;
}

//第2部分
//绘制上面模糊处理后的图片,注意如果作为RecyclerView的Item,则不能复用mTopShader,
//需要每次 new 一个新的对象
if(mTopShader == null) {
mTopShader = new LinearGradient((float) contentWidth / 2, requireBlurHeight, (float) contentWidth / 2, srcTopOrBottomPadding, new int[]{
0x00FFFFFF,
0xFFFFFFFF
}, null, Shader.TileMode.CLAMP);
}
mPaint.setShader(mTopShader);
mDrawMatrix.setScale(blurScale, blurScale);
mDrawMatrix.postTranslate(Math.round(dx), Math.round(dy));
canvas.drawBitmap(mTopBlurBitmap, mDrawMatrix, null);
mPaint.setXfermode(mBlurPorterDuffXfermode);
canvas.drawRect(0, srcTopOrBottomPadding, contentWidth, requireBlurHeight, mPaint);
//绘制下面模糊处理后的图片
float padding = contentHeight - requireBlurHeight;
mDrawMatrix.setScale(blurScale, blurScale);
mDrawMatrix.postTranslate(Math.round(dx), Math.round(padding + dy));
canvas.drawBitmap(mBottomBlurBitmap, mDrawMatrix, null);
//注意如果作为RecyclerView的Item,则不能复用mBottomShader,
//需要每次 new 一个新的对象
if(mBottomShader == null) {
mBottomShader = new LinearGradient((float) contentWidth/2, padding + overSrcPadding, (float) contentWidth/2, padding, new int[]{
0xFFFFFFFF,
0x00FFFFFF
}, null, Shader.TileMode.CLAMP);
}
mPaint.setShader(null);
mPaint.setShader(mBottomShader);
canvas.drawRect(0, padding + overSrcPadding, contentWidth, padding, mPaint);

//绘制中间的原图
mPaint.setShader(null);
mPaint.setXfermode(mSrcPorterDuffXfermode);
float srcScale = (float) contentWidth / (float) srcWidth;
mDrawMatrix.setScale(srcScale, srcScale);
mDrawMatrix.postTranslate(0, Math.round(srcTopOrBottomPadding));
canvas.drawBitmap(mSrcBitmap, mDrawMatrix, mPaint);
canvas.restoreToCount(save);
}
}

BlurImageView 得核心代码在 onDraw 里面。我们按照上面注释的顺序,一个一个来分析:


第1部分,我们声明了几个变量,用来辅助计算。为了方便理解,我画了如下示意图:


image.png


srcTopOrBottomPadding: 是原图按照比例缩放、居中摆放时空白的高度
overSrcPadding: 是模糊图片遮罩原图片的高度,也就是渐变模糊图片的高度
dx: 按照高度缩放时,缩放后的模糊图片的x方向的偏移
dy: 按照宽缩放时,缩放后的模糊图片的y方向的偏移
blurScale: 图上没有标出,是高斯模糊图片的缩放比例。确保高斯模糊的图片能够铺满


第2部分,这里的作用是绘制上下两部分的模糊图片,并对图片的部分进行渐变处理。以上面部分的图片为例,第一步先绘制已经处理好的 mTopBlurBitmap,这里设置了 Matrix ,在绘制过程中会对图片进行缩放和移动,让图片的位置摆放正确。第二步就是对部分图片进行渐变处理,这里合成模式选择了 DST_ATOP


最后一步绘制中间的原图,就大功告成了,点击启动就能看到渐变模糊效果了。文章最后就求一个免费的赞吧🥺🥺


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

Kotlin系列之听说这个函数的入参也是函数?

整洁是kotlin语法的一大特性,它主要体现在扩展函数,中缀调用,运算符重载,约定,以及对lambda表达式的活用,其实在用java写Android的时候我们已经遇到过lambda了,比如当你在设置一个控件的点击监听事件的时候 我们通常会这样写,然后就发现在...
继续阅读 »

整洁是kotlin语法的一大特性,它主要体现在扩展函数,中缀调用,运算符重载,约定,以及对lambda表达式的活用,其实在用java写Android的时候我们已经遇到过lambda了,比如当你在设置一个控件的点击监听事件的时候


image.png
我们通常会这样写,然后就发现在点击事件的参数部分,代码变灰然后还有一条波浪线,提示说匿名类View.OnClickListener()可以被替换成lambda表达式,所以我们就按照提示将这个点击事件转换成lambda


bindingView.button.setOnClickListener(v -> {});

很简洁的一行代码就生成了,其中v是参数,后面箭头紧跟着一个花括号,花括号里面就是你要写的逻辑代码,相信这个大家都清楚,而在kotlin中,做了进一步简化,它可以将这个lambda表达式放在括号外面,并且可以将参数省略


bindingView.button.setOnClickListener {}

代码更加简洁了,而lambda在kotlin中的表现远远不止这些,还可以将整个lambda作为一个函数的参数,典型的例子就是在使用标准库中的filter,map函数,或者Flow里面的操作符,举个例子,在一个名字的集合中,我们要对这个集合做一个过滤的操作,首字母为s的才可以被输出,代码如下


listOf("shifang","zhaoerzhu","sundashen").filter { it.startsWith("s") }

在这个例子中filter函数就是接收了一个lambda参数,我们将整个lambda表达式显示出来就是这样


listOf("shifang","zhaoerzhu","sundashen").filter { it -> it.startsWith("s") }

所以在kotlin中,将类似于filter这样可以接受lambda或者函数引用作为参数的函数,或者返回值是lambda或者函数引用的函数,称之为高阶函数,这篇文章,会从以下几点慢慢介绍高阶函数



  • 什么是函数类型

  • 如何去调用一个高阶函数

  • 给函数类型设置默认值

  • 返回值为函数类型的高阶函数

  • 内联函数

  • inline,noinline和crossinline修饰符

  • 在lambda中使用return


函数类型


我们刚开始学敲代码的时候,基本都是从数据类型开始学的,什么整数类型,浮点数类型,布尔值类型,都很熟悉了已经,到了kotlin这边,又多出来了一个函数类型,这是啥?我们刚刚说到filter是高阶函数,而入参是函数的才能被叫做是高阶函数,所以我们看看filter这个函数里面长什么样子的


ac1.png
我们看到filter的参数部分,predicate是变量,而冒号后面就是跟的参数类型了,我们终于看到函数类型长啥样了,一个括号,里面跟一个泛型T,其实也就是函数的参数类型,后面一个箭头,箭头后面跟着返回值类型,所以我们声明一个函数类型的变量可以这样做


val findName : (String) -> Boolean
val sum : (Int,Int) -> Int

括号里面就是函数的参数类型跟参数数量,箭头后面是函数的返回值类型,这个时候我们在想一个问题,既然是函数类型,那肯定接受的就是一个函数,我们知道在kotlin中一个函数如果什么也不用返回,那么这个函数的返回值可以用Unit的来表示


fun showMessage():Unit {
println()
}

但通常我们都是省略Unit


fun showMessage() {
println()
}

那是不是函数类型里面,返回值如果是Unit,我们也可以省略呢?这样是不行的,函数类型中就算这个函数什么都不返回,我们也要显示的将返回类型Unit表示出来,同样的,如果函数没有参数,也要指定一个空的括号,表示这个函数无参


val showMessage:() -> Unit

到了这里,我们就已经清楚了为什么在lambda表达式里{x,y -> x+y},或者开头那个例子,filter函数中{ it -> it.startsWith("s"),变量的类型都省略了,那就是因为这些变量类型已经在函数类型的声明中被指定了


当然函数类型也是可以为空的,同其他数据类型一样,当你要声明一个可空的函数类型的时候,我们可以这样做


val sum : (Int,Int) -> Int?

上述代码其实犯了一个错误,它并不能表示一个可空的函数类型,它只能表示这个函数的返回值可以为空,那如何表示一个可空的函数类型呢?我们应该在整个函数类型外面加一个括号,然后在括号后面指定它是可以为空的,像这样


val sum : ((Int,Int) -> Int)?

调用高阶函数


知道了函数类型以后,我们就要开始去手写高阶函数了,比如现在有一个需求,要求编辑框内输入的内容里面只能包含字母以及空格,其他的都要过滤掉,那我们就给String添加一个扩展函数吧,这个函数接受一个函数类型的变量,这个函数类型的参数是一个字符,返回类型是一个布尔值,表示符合条件的字符才可以被输出,我们看下这个函数如何实现


fun String.findLetter(judge:(Char) -> Boolean):String{
val mBuilder = StringBuilder()
for(index in indices){
if(judge(get(index))){
mBuilder.append(get(index))
}
}
return mBuilder.toString()
}

内部实现就是这样,对输入的字符串逐个字符进行遍历,通过调用judge函数来判断每个字符,符合条件的就输出,不符合的就过滤掉,高阶函数有了,我们现在去调用它


println("what 8is4 ko4tli3n".findLetter { it in 'a'..'z' || it == ' ' })
复制代码

整个花括号里面就是一个函数,它作为一个参数传递给findLetter,我们看下运行结果


 I  what is kotlin

完全按照条件输出,这样做的好处就是,如果下次需求变了,要求空格也不能输出,那么我们完全不需要去更改findLetter的代码,只需要更改一下作为函数类型的函数就可以了,就像这样


println("what 8is4 ko4tli3n".findLetter { it in 'a'..'z' })

运行结果就变成了


I  whatiskotlin

我们再换个例子,刚刚是给String定义了一个类似于过滤作用的函数,现在去定义一个映射作用函数,比如给输入的内容每个字符之间都用逗号隔开,我们该怎么做呢


fun String.turn(addSplit: (Char) -> String): String {
val mBuilder = StringBuilder()
for (index in indices){
if(index != indices.last){
mBuilder.append(addSplit(get(index)))
}else{
mBuilder.append(get(index))
}
}
return mBuilder.toString()
}

代码与findLetter相似,稍微做了一点变化,我们看到这个高阶函数的入参类型变成了(Char)->String,表示输入一个字符,返回的是一个字符串,函数类型addSplit在这里就充当着一个字符串的角色,我们看下如何去调用这个高阶函数


println("abcdefg".turn { "${it}," })

我们看见turn后面的花括号里面就一个字符串,这个字符串是每个字符后面追加一个逗号,我们看下运行结果


 I  a,b,c,d,e,f,g

函数类型的默认值


对于映射函数turn,我们再改下需求,某些场景下,我们输入什么就希望输出什么,比如用户设置昵称,基本是没有任何条件限制的,我们改造下turn函数,让它可以接收空的函数类型,那这个我们在刚刚函数类型那部分讲过,只需要在整个函数类型外面加个括号,然后加上可空标识就好了,改造完之后turn函数就变成了这样


fun String.turn(addSplit: ((Char) -> String)?): String {
val mBuilder = StringBuilder()
for (index in indices){
if(index != indices.last){
mBuilder.append(addSplit?.let { it(get(index)) })
}else{
mBuilder.append(get(index))
}
}
return mBuilder.toString()
}

看起来没什么问题,但是当你去调用这个turn函数,不传入任何函数类型参数的时候,我们发现代码提示报错了


ac2.png
理由是addSplit这个参数一定要有个值,也就是说必须得传点啥吗?也不一定,我们知道kotlin函数中,参数是可以设置默认值的,那么函数类型的参数当然也可以设置默认值,就算什么也不传,它默认有一种实现方式,这样不就好了吗,我们再改下turn函数


fun String.turn(addSplit: ((Char) -> String)? = { it.toString() }): String {
val mBuilder = StringBuilder()
for (index in indices) {
if (index != indices.last) {
mBuilder.append(addSplit?.let { it(get(index)) })
} else {
mBuilder.append(get(index))
}
}
return mBuilder.toString()
}

这样就不报错了,默认输入啥就输出啥,我们看下运行结果


 I  abcdefg

函数类型作为返回值


刚刚我们举的例子是作为入参的函数类型,现在我们看下作为返回值的函数类型,这个其实我们平时开发当中也经常遇到,比如在一段代码中由于某个或者某几个条件,决定的不是一个值,而是会走到不同的逻辑代码中,这个时候我们脑补下如果这些代码都写在一起那是不是一个函数就显的比较臃肿了,可读性也变差了,所以我们就像return某一个值一样,将一段逻辑代码也return出去,这样代码逻辑就显的清晰很多,我们新增一个combine函数,返回值是函数类型


fun String.combine(): (String) -> String {
val mBuilder = StringBuilder()
return {
mBuilder.append(it)
for (index in indices) {
if (index != indices.last) {
mBuilder.append("${get(index)},")
} else {
mBuilder.append(get(index))
}
}
mBuilder.toString()
}
}

combine不接收任何参数了,返回值变成了(String) -> String,我们现在尝试着调用combine函数看看会输出什么呢


println("abcdefg".combine())

  I  Function1<java.lang.String, java.lang.String>

我们看到并没有输出期望的结果,这个是为什么呢?我再回到代码中看看


image.png
我们发现在返回值代码的边上,标明的这个返回值是一个lambda,并不是一个String,这个也就是函数类型作为返回值造成的结果,返回的是一个函数,函数你不去执行它,怎么可能会有结果呢,所以执行这个函数的方法就是调用invoke


println("abcdefg".combine().invoke("转换字符串:"))

invoke方法我们还是很熟悉的,在java里面去反射某一个类里面的方法的时候,最终去执行这个method就是用的invoke,而kotlin里面的invoke其实还是一个约定,当lambda要去调用invoke函数去执行lambda本身的函数体时,invoke可以省略,直接在lambda函数体后面加()以及参数,至于约定这里就不展开说了,我会另起一篇文章单独讲,所以上面的代码我们还可以这样写


println("abcdefg".combine()("转换字符串:"))

两种写法的运行结果都一样的,结果都是


 I  转换字符串:a,b,c,d,e,f,g

内联函数


lambda带来的性能开销


我们刚刚看到一个lambda的函数需要调用invoke方法才可以执行,那么这个invoke方法从哪里来的呢?凭什么调用它这个函数就可以执行了呢?我们将之前写的代码转换成java找找原因


public static final Function1 combine(@NotNull final String $this$combine) {
final StringBuilder mBuilder = new StringBuilder();
return (Function1)(new Function1() {
public Object invoke(Object var1) {
return this.invoke((String)var1);
}

@NotNull
public final String invoke(@NotNull String it) {
Intrinsics.checkNotNullParameter(it, "it");
mBuilder.append(it);
int index = 0;
for(int var3 = ((CharSequence)$this$combine).length(); index < var3; ++index) {
if (index != StringsKt.getIndices((CharSequence)$this$combine).getLast()) {
mBuilder.append("" + $this$combine.charAt(index) + ',');
} else {
mBuilder.append($this$combine.charAt(index));
}
}
String var10000 = mBuilder.toString();
return var10000;
}
});
}

通过反编译我们看到,原来这个lambda表达式就是定义了一个回调方法是invoke的匿名类Function1,Function后面跟着的1其实就是参数个数,我们点到Function1里面看看


public interface Function1<in P1, out R> : Function<R> {
/** Invokes the function with the specified argument. */
public operator fun invoke(p1: P1): R
}

现在我们知道刚刚没有调用invoke方法的时候,为什么会输出那一段信息了,其实那个就是把整个接口名称输出打印出来,只有调用了invoke这个回调方法,才会真正的去执行逻辑代码,把真正的结果输出,与此同时,我们注意到在反编译代码中,每一次调用turn函数,都会生成一个Function1的对象,如果被多次调用的话,很容易会造成一定的性能损耗,针对这种情况,我们应该怎么去避免呢


inline


针对lamnda带来的性能开销,kotlin里面会使用inline修饰符去解决,用法也很简单,只要在高阶函数的最前面用inline去修饰就好了,我们新增一个inlineturn函数,与turn函数相似,只是用inline去修饰


fun String.turn(addSplit: (Char) -> String): String {
val mBuilder = StringBuilder()
for (index in indices) {
if (index != indices.last) {
mBuilder.append(addSplit(get(index)))
} else {
mBuilder.append(get(index))
}
}
return mBuilder.toString()
}

inline fun String.inlineturn(addSplit: (Char) -> String): String {
val mBuilder = StringBuilder()
for (index in indices) {
if (index != indices.last) {
mBuilder.append(addSplit(get(index)))
} else {
mBuilder.append(get(index))
}
}
return mBuilder.toString()
}

两个函数的代码基本相似,只是inlineturn函数是用inline修饰的,在kotlin里面,对这种用inline修饰的高阶函数称之为内联函数,我们去调用下这两个函数,然后反编译看看有什么区别吧


kotlin代码
println("abcdefg".turn { "${it}," })
println("abcdefg".inlineturn { "${it}," })

反编译后的java代码
String $this$inlineturn$iv = StringKt.turn("abcdefg", (Function1)null.INSTANCE);
System.out.println($this$inlineturn$iv);

$this$inlineturn$iv = "abcdefg";
int $i$f$inlineturn = false;
StringBuilder mBuilder$iv = new StringBuilder();
int index$iv = 0;
for(int var6 = ((CharSequence)$this$inlineturn$iv).length(); index$iv < var6; ++index$iv) {
if (index$iv != StringsKt.getIndices((CharSequence)$this$inlineturn$iv).getLast()) {
char it = $this$inlineturn$iv.charAt(index$iv);
int var8 = false;
String var10 = "" + it + ',';
mBuilder$iv.append(var10);
} else {
mBuilder$iv.append($this$inlineturn$iv.charAt(index$iv));
}
}
String var10000 = mBuilder$iv.toString();
$this$inlineturn$iv = var10000;
System.out.println($this$inlineturn$iv);

我们看到turn方法不出所料,每次调用都会生成一个Function1的对象,而inlineturn函数反编译后我们发现,这不就是将invoke方法里面的代码复制出来放到外面来执行吗,所以现在我们知道内联函数的工作原理了,就是将函数体复制到调用处去执行,而此时,内联函数inlineturn的函数类型参数addSplit就不再是一个对象,而只是一个函数体了


noinline和crossinline


我们现在已经有了一个概念了,inline修饰符什么时候适合使用



  • 当函数是一个高阶函数

  • 由于编译器需要将内联函数体代码复制到调用处,所以函数体代码量比较小的时候适合用inline修饰


但有些场景下,即使函数是高阶函数,也是不推荐使用inline修饰符的,比如说你的函数类型参数需要当作对象传给其他普通函数


inline fun String.inlineturn(addSplit: (Char)->String): String {
val mBuilder = StringBuilder()
for (index in indices) {
if (index != indices.last) {
mBuilder.append(addSplit(get(index)))
} else {
mBuilder.append(get(index))
}
}
turnAnother(addSplit)//这一行编译报错
return mBuilder.toString()
}

还有一种场景就是当你的函数类型参数是可空的


inline fun String.inlineturn(addSplit: ((Char)->String)?): String {//参数部分编译报错
val mBuilder = StringBuilder()
for (index in indices) {
if (index != indices.last) {
mBuilder.append(addSplit?.let { it(get(index)) })
} else {
mBuilder.append(get(index))
}
}
return mBuilder.toString()
}

这两段代码都会编译报错,而报错的信息也基本一致,信息当中都会有这一句提示



Add 'noinline' modifier to the parameter declaration



到了这里我们遇到了一个新的修饰符noinline,从字面意思上并联系上下文,我们知道了这个noinline的作用,就是在内联函数中,使用noinline修饰的函数类型参数可以不参与内联,它依然是一个对象,反编译的时候它依然会被转成一个匿名类,尽管它是在一个内联函数中。
我们使用noinline修饰符更改一下inlineturn函数,然后再反编译看看java代码中的区别


String $this$inlineturn$iv = StringKt.turn("abcdefg", (Function1)null.INSTANCE);
System.out.println($this$inlineturn$iv);

$this$inlineturn$iv = "abcdefg";
Function1 addSplit$iv = (Function1)null.INSTANCE;
int $i$f$inlineturn = false;
StringBuilder mBuilder$iv = new StringBuilder();
int index$iv = 0;
for(int var7 = ((CharSequence)$this$inlineturn$iv).length(); index$iv < var7; ++index$iv) {
if (index$iv != StringsKt.getIndices((CharSequence)$this$inlineturn$iv).getLast()) { mBuilder$iv.append((String)addSplit$iv.invoke($this$inlineturn$iv.charAt(index$iv)));
} else {
mBuilder$iv.append($this$inlineturn$iv.charAt(index$iv));
}
}
StringKt.turnAnother(addSplit$iv);
String var10000 = mBuilder$iv.toString();
$this$inlineturn$iv = var10000;
System.out.println($this$inlineturn$iv);

我们看到原本是将函数体复制出来的地方,现在变成了生成一个Function1的对象了,说明addSplit对象已经不参与内联了,而这个时候我们注意到了,inlineturn函数前面的inline修饰符有了一个警告,提示说这个修饰符已经不需要了,建议去掉


image.png
对于这种警告我觉得还是不能去忽略的,因为我们已经在反编译的代码中看到了,尽管addSplit不参与内联,但还是会将函数体的代码复制出来,对于编译器来讲还是会有损耗的,所以这种情况下还是把inline和noinline修饰符去掉,让它变成一个普通的高阶函数


现在我们再换个场景,有时候一个函数类型的对象它执行起来比较耗时,我们不能让它在主线程运行,那就必须在将这个对象套在一个线程里面运行


inline fun String.inlineturn(addSplit: (Char)->String): String {
val mBuilder = StringBuilder()
Runnable{
for (index in indices) {
if (index != indices.last) {
mBuilder.append(addSplit(get(index)))//addSplit这边编译报错
} else {
mBuilder.append(get(index))
}
}
}
return mBuilder.toString()
}

我们发现这边又编译报错了,内联函数怎么回事啊?事儿这么多。。。我们看下这次报错提示是什么



Can't inline 'addSplit' here: it may contain non-local returns. Add 'crossinline' modifier to parameter declaration 'addSplit'



意思是不能对addSplit进行内联,原因是调用函数类型addSplit的地方与内联函数inlineturn属于不同的域,或者在inlineturn里面调用addSplit属于间接调用,所以在kotlin里面,如果内联函数中调用的函数类型,与内联函数本身属于间接调用的关系,那么函数类型前面需要加上crossinline修饰符,表示加强内联关系,我们修改一下inlineturn函数,给addSplit加上
crossinline修饰符,代码就变成了


inline fun String.inlineturn(crossinline addSplit: ((Char)->String)): String {
val mBuilder = StringBuilder()
Runnable {
for (index in indices) {
if (index != indices.last) {
mBuilder.append(addSplit(get(index)))
} else {
mBuilder.append(get(index))
}
}
}
return mBuilder.toString()
}

学到这里我相信不少人已经对高阶函数有了一个比较清晰的了解了,其实我们在学习Flow的时候已经接触过这些高阶函数和内联函数了,比如我们看下map操作符里面


image.png
map就是一个内联函数,而它里面的transform参数就是一个被crossinline修饰的函数类型的挂起函数,因为map里面的函数体必需要运行在一个协程域里面,而map又是运行在另一个协程域里面,map与transform之间属于间接调用的关系,这才用crossinline修饰


在lambda中使用return


现在给String再增加一个扩展函数,功能很简单,遍历String里面的每个字符,然后将字符在lambda的参数里面打印出来,同时要求如果遍历到字母,那么就停止打印。


fun String.filterAndPrint(filter:(Char) -> Unit){
for (index in indices) {
filter(get(index))
}
}

private fun test() {
"153a667".filterAndPrint {
if(it in 'a'..'z'){
return
}
println(it)
}
println("outside of foreach")
}

代码大概就是这样去实现,但是我们发现写完代码后编译器在return的那个地方报错了,提示说这里不允许使用return



'return' is not allowed here



这个是什么原因呢,kotlin官方文档中有这么一段描述



要退出一个 lambda 表达式,我们必须使用一个标签,并且在 lambda 表达式内部禁止使用裸 return,因为 lambda 表达式不能使包含它的函数返回



kotlin为什么要这么设计呢?我们结合上面讲到的内联函数就清楚了,因为当我们在filterAndPrint函数里面return,退出的函数完全取决于它是不是内联函数,如果是,我们知道编译器会讲函数复制到外面调用处的位置,那么return的就是test函数,而如果不是内联,那么退出的就是filterAndPrint本身,所以对于这么一种可能会导致冲突的作法,kotlin就限制了在普通lambda表达式里面不能使用return,如果一定要用,必需加上标签,也就是在return后面加上@以及lambda所在的函数名,我们更改一下上面的test函数


private fun test() {
"153a667".filterAndPrint {
if(it in 'a'..'z'){
return@filterAndPrint
}
println(it)
}
println("outside of foreach")
}

加上标签以后编译器不报错了,我们看下运行结果


 I  1
I 5
I 3
I 6
I 6
I 7
I outside of foreach

我们看到reutrn@filterAndPrint的时候并没有跳出test函数,只是跳过了a,继续循环打印后面的字符,这个就很想java里面continue的作法,但我们的需求不是这样描述的,我们希望遇到字母以后就不打印后面的字符了,也就是直接跳出test函数,没错,就是将filterAndPrint变成内联函数就好了


inline fun String.filterAndPrint(filter:(Char) -> Unit){
for (index in indices) {
filter(get(index))
}
}

private fun test() {
"153a667".filterAndPrint {
if(it in 'a'..'z'){
return
}
println(it)
}
println("outside of foreach")
}

当lambda所在函数是内联函数的时候,lambda内部是可以return的,而且可以不用加标签,这个时候退出的函数就是调用内联函数所在的函数,也就是例子中的test(),我们把这种返回称为非局部返回,我们看下现在的运行结果


 I  1
I 5
I 3

现在这个才是我们想要的结果,现在回想一下当初刚开始学kotlin的时候,对没有break和continue关键字还有点不习惯,现在知道kotlin把这俩关键字去掉的原因了,因为完全不需要,一个return加上内联函数就够了,想在哪个地方退出循环就在哪个地方退出。


总结


这篇文章我们逐步从函数类型开始,慢慢的认识了高阶函数,会去写高阶函数,也掌握了inline,noinline,crossinline这些修饰符的作用以及使用场景,如果说之前你对高阶函数还很陌生的话,那么通过这篇文章,应该会对它熟悉一点了


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