注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

Twitter 上有趣的代码

全文分为 视频版 和 文字版, 文字版: 文字侧重细节和深度,有些知识点,视频不好表达,文字描述的更加准确 视频版: 视频以动画的形式会更加的直观,看完文字版,在看视频,知识点会更加清楚,Twitter 上有趣的代码_哔哩哔哩_bilibili 这是海外一...
继续阅读 »

全文分为 视频版文字版



  • 文字版: 文字侧重细节和深度,有些知识点,视频不好表达,文字描述的更加准确

  • 视频版: 视频以动画的形式会更加的直观,看完文字版,在看视频,知识点会更加清楚,Twitter 上有趣的代码_哔哩哔哩_bilibili


这是海外一位 Kotlin GDE 大佬,在 Twitter 上分享的一段代码,我觉得非常的有意思,代码如下所示,我们花 10s 思考一下,输出结果是什么。


fun printE() = { println("E") }

fun main() {
if (true) println("A")
if (true) { println("B") }
if (true) {
{ println("C") }
}

{ println("D") }

printE()

when {
true -> { println("F") }
}
}

在 Twitter 评论区中也能看到很多不同的答案。


pic02


实际上最后输出结果如下所示。


A
B
F

不知道你第一次看到这么多混乱的花括是什么感觉,当我第一次看到这段代码的时候,我觉得非常的有意思。


如果在实际项目中有小伙伴这么嵌套花括号,我相信肯定会被拉出去暴晒。但是细心观察这段代码,我们能学习到很多 Kotlin 相关的知识点,我们先来说一下为什么最后输出的结果是 A B F


下面图中红色标注部分,if 表达式、 when ... case 表达,如果表达式内只有一行代码的话,花括号是可以省略的,程序执行到代码位置会输出对应的结果, 即 A B F



那为什么 C D E 没有打印,因为图中绿色部分是 lambda 表达式,在 Kotlin 中 lambda 表达式非常的自由,它可以出现在很多地方比如方法内、 if 表达式内、循环语句内、甚至赋值给一个变量、或者当做方法参数进行传递等等。


lambda 表达式用花括号包裹起来,用箭头把实参列表和 lambda 函数体分离开来,如下所示。


{ x: Int -> println("lambda 函数体") }

如果没有参数,上面的代码可以简写成下面这样。


{ println("lambda 函数体") }

C D E 的输出语句在 lambda 函数体内, lambda 表达式我们可以理解为高阶函数,在上面的代码中只是声明了这个函数,但是并没有调用它,因此不会执行,自然也就不会有任何输出。现在我将上面的代码做一点点修改,在花 10s 思考一下输出结果是什么。


fun printE() = { println("E") }

fun main() {
if (true) println("A")
if (true) { println("B") }
if (true) {
{ println("C") }()
}

{ println("D") }()

printE()()

when {
true -> { println("F") }
}
}

最后的输出结果是:


A
B
C
D
E
F

应该有小伙伴发现了我做了那些修改,我只是在 lambda 表达式后面加了一个 (),表示执行当前的 lambda 表达式,所以我们能看到对应的输出结果。如下图所示,



lambda 表达式最终会编译成 FunctionN 函数,如下图所示。



如果没有参数会编译成 Function0,一个参数编译成 Function1,以此类推。FunctionN 重载了操作符 invoke。如下图所示。



因此我们可以调用 invoke 方法来执行 lambda 表达式。


{ println("lambda 函数体") }.invoke()
复制代码

当然 Kotlin 也提供了更加简洁的方式,我们可以使用 () 来代替 invoke(),最后的代码如下所示。


{ println("lambda 函数体") }()
复制代码

到这里我相信小伙伴已经明白了上面代码输出的结果,但是这里隐藏了一个有性能损耗的风险点,分享一段我在实际项目中见到的代码,示例中的代码,我做了简化。


fun main() {

(1..10).forEach { value ->
calculate(value) { result ->
println(result)
}
}

}


fun calculate(x: Int, lambda: (result: Int) -> Unit) {
lambda(x + 10)
}

上面的代码其实存在一个比较严重的性能问题,我们看一下反编译后的代码。



每次在循环中都会创建一个 FunctionN 的对象,那么如何避免这个问题,我们可以将 lambda 表达式放在循环之外,这样就能保证只会创建一个 FunctionN 对象,我们来看一下修改后的代码。


fun main() {
val lambda: (result: Int) -> Unit = { result ->
println(result)
}

(1..10).forEach { value ->
calculate(value, lambda)
}

}


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

代码中被植入了恶意删除操作,太狠了!

背景在交接的代码中做手脚进行删库等操作,之前只是网上听说的段子,没想到上周还真遇到了,并且亲自参与帮忙解决。事情是这样的,一老板接手了一套系统,可能因为双方在交接时出现了什么不愉快的事情,对方不提供源代码,只是把生产环境的服务器打了一个镜像给到对方。对方拿到镜...
继续阅读 »

背景

在交接的代码中做手脚进行删库等操作,之前只是网上听说的段子,没想到上周还真遇到了,并且亲自参与帮忙解决。

事情是这样的,一老板接手了一套系统,可能因为双方在交接时出现了什么不愉快的事情,对方不提供源代码,只是把生产环境的服务器打了一个镜像给到对方。

对方拿到镜像恢复之后,系统起来怎么也无法正常处理业务,于是就找到我帮忙看是什么原因。经过排查,原来交接的人在镜像中做了多处手脚,多处删除核心数据及jar包操作。下面来给大家细细分析排查过程。

排查过程

由于只提供了镜像文件,导致到底启动哪些服务都是问题。好在是Linux操作系统,镜像恢复之后,通过history命令可以查看曾经执行了哪些命令,能够找到都需要启动哪些服务。但服务启动之后,业务无法正常处理,很多业务都处于中间态。

原本系统是可以正常跑业务的,打个镜像之后再恢复就不可以了?这就奇怪了。于是对项目(jar包或war)文件进行排查,查看它们的修改时间。

在文件的修改时间上还真找到了一些问题,发现在打镜像的两个小时前,项目中一个多个项目底层依赖的jar包被修改过,另外还有两个class文件被修改过。

于是,就对它们进行了重点排查。首先反编译了那两个被修改过的class文件,在代码中找到了可疑的地方。


在两个被修改的类中都有上述代码。最开始没太留意这段代码,但直觉告诉我不太对,一个查询业务里面怎么可能出现删除操作呢?这太不符合常理了。

于是仔细阅读上述代码,发现上述红框中的代码无论何时执行最终的结果都是id=1。你是否看出来了?问题就出在三目表达式上,无论id是否为null,id被赋的值都是1。看到这里,也感慨对方是用心了。为了隐藏这个目的,前面写了那么多无用的代码。

但只有这个还不是什么问题,毕竟如果只是删除id为1的值,也只是删除了一条记录,影响范围应该有限。

紧接着反编译了被修改的jar包,依次去找上述删除方法的底层实现,看到如下代码:


原来前面传递的id=1是为了配合where条件语句啊,当id=1被传递进来之后,就形成了where 1=1的条件语句。这个大家在mybatis中拼接多条件语句时经常用到。结果就是一旦执行了上述业务逻辑,就会触发删除T_QUART_DATA全表数据的操作。

T_QUART_DATA表中是用于存储触发定时任务的表达式,到这里也就明白了,为啥前面的业务跑不起来,全部是中间态了。因为一旦在业务逻辑中触发开关,把定时任务的cron表达式全部删除,十多个定时任务全部歇菜,业务也就跑步起来了。

找到了问题的根源,解决起来就不是啥事了,由于没有源代码,稍微费劲的是只能把原项目整个反编译出来,然后将改修改地方进行了修改。

又起波折

本以为到此问题已经解决完毕了,没想到第二天又出现问题了,项目又跑不起来了。经过多方排查和定位,感觉还有定时任务再进行暗箱操作。

于是通过Linux的crontab命令查看是否有定时任务在执行,执行crontab -ecrontab -l,还真看到有三个定时任务在执行。跟踪到定时任务执行的脚本中,而且明目张胆的起名deleteXXX:


而在具体的脚本中,有如下执行操作:


这下找到为什么项目中第二天为啥跑不起来了,原来Linux的定时任务将核心依赖包删除了,并且还会去重启服务。

为了搞破坏,真是煞费苦心啊。还好的是这个jar包在前一天已经反编译出来了,也算有了备份。

小结

原本以为程序员在代码中进行删库操作或做一些其他小手脚只是网络上的段子,大多数人出于职业操守或个人品质是不会做的。没想到这还真遇到了,而且对方为了隐藏删除操作,还做了一些小伪装,真的是煞费苦心啊。如果有这样的能力和心思,用在写出更优秀的代码或系统上或许更好。

当然,不知道他们在交接的过程中到底发生了什么,竟然用这样的方式对待昔日合作的伙伴。之所以写这篇文章,是想让大家学习如何排查代码问题的过程,毕竟用到了不少知识点和技能,但这并不是教大家如何去做手脚。无论怎样,最起码的职业操守还是要有的,这点不接受反驳。


作者:程序新视界
来源:juejin.cn/post/7140066341469290532

收起阅读 »

深入理解MMAP原理,大厂爱不释手的技术手段

为什么大厂爱不释手如微信的MMKV 组件、美团的Logan组件,还有微信的日志模块xlog,为什么大厂偏爱它呢?他到底有什么魔力么?我认为主要原因如下:跨平台,C++编写,可以支持多平台跨进程,通过文件共享可以实现多个进程内存共享,实现进程通信高性能,实现用户...
继续阅读 »

为什么大厂爱不释手

如微信的MMKV 组件、美团的Logan组件,还有微信的日志模块xlog,为什么大厂偏爱它呢?他到底有什么魔力么?我认为主要原因如下:

  • 跨平台,C++编写,可以支持多平台

  • 跨进程,通过文件共享可以实现多个进程内存共享,实现进程通信

  • 高性能,实现用户空间和内核空间的零拷贝,速度快且节约内存等

  • 高稳定,页中断保护神,由操作系统实现的,稳定性可想而知

函数介绍

void* mmap(void* addr, size_t length, int prot, int flags, int fd, off_t offset);
复制代码
  • addr 代表映射的虚拟内存起始地址;

  • length 代表该映射长度;

  • prot 描述了这块新的内存区域的访问权限;

  • flags 描述了该映射的类型;

  • fd 代表文件描述符;

  • offset 代表文件内的偏移值。

mmap的强大之处在于,它可以根据参数配置,用于创建共享内存,从而提高文件映射区域的IO效率,实现IO零拷贝,后面讲下零拷贝的技术,对比下,决定这些功能的主要就是三个参数,下面一一解释

prot

四种情况如下:

  • PROT_EXEC,代表该内存映射有可执行权限,可以看成是代码段,通常存储CPU可执行机器码

  • PROT_READ,代表该内存映射可读

  • PROT_WRITE,代表该内存映射可写

  • PROT_NONE,代表该内存映射不能被访问

flags

比较有代表性的如下:

  • MAP_SHARED,创建一个共享映射区域

  • MAP_PRIVATE,创建一个私有映射区域

  • MAP_ANONYMOUS,创建一个匿名映射区域,该情况只需要传入-1即可

  • MAP_FIXED,当操作系统以addr为起始地址进行内存映射时,如果发现不能满足长度或者权限要求时,将映射失败,如果非MAP_FIXED,则系统就会再找其他合适的区域进行映射

fd

当参数fd不等于0时,内存映射将与文件进行关联,如果等于0,就会变成匿名映射,此时flags必为MAP_ANONYMOUS

应用场景


一个mmap竟有如此丰富的功能,从申请分配内存到加载动态库,再到进程间通信,真的是无所不能,强大到让人五体投地。下面就着四种情况,拿一个我最关心的父子进程通信来举例看下,实现一个简单的父子进程通信逻辑,毕竟我们学习的目的就是为了应用,光有理论怎么能称之为合格的博客呢?

父子进程共享内存

#include <iostream>
#include <sys/wait.h>
#include <unistd.h>
#include <sys/mman.h>

int main() {
  pid_t c_pid = fork();

  char* shm = (char*)mmap(nullptr, 4096, PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0);

  if (c_pid == -1) {
      perror("fork");
      exit(EXIT_FAILURE);
  } else if (c_pid > 0) {
      printf("parent process pid: %d\n", getpid());
      sprintf(shm, "%s", "hello, my child");
      printf("parent process got a message: %s\n", shm);
      wait(nullptr);
  } else {
      printf("child process pid: %d\n", getpid());
      sprintf(shm, "%s", "hello, father.");
      printf("child process got a message: %s\n", shm);
      exit(EXIT_SUCCESS);
  }

  return EXIT_SUCCESS;
}

运行后打印如下

parent process pid: 87799
parent process got a message: hello, my child
child process pid: 87800
child process got a message: hello, father.

Process finished with exit code 0

用mmap创建了一块匿名共享内存区域,fd传入-1MAP_ANONYMOUS配置实现匿名映射,使用MAP_SHARED创建共享区域,使用fork函数创建子进程,这样来实现子进程通信,通过sprintf将格式化后的数据写入到共享内存中。

通过简单的几行代码就实现了跨进程通信,如此简单,这么强大的东西,背后有什么支撑么?带着问题我们接着一探究竟。

MMAP背后的保护神

说到MMAP的保护神,首页了解下内存页:在页式虚拟存储器中,会在虚拟存储空间和物理主存空间都分割为一个个固定大小的页,为线程分配内存是也是以页为单位。比如:页的大小为 4K,那么 4GB 存储空间就需要4GB/4KB=1M 条记录,即有 100 多万个 4KB 的页,内存页中,当用户发生文件读写时,内核会申请一个内存页与文件进行读写操作,如图


这时如果内存页中没有数据,就会发生一种中断机制,它就叫缺页中断,此中断就是MMAP的保护神,为什么这么说呢?我们知道mmap函数调用后,在分配时只是建立了进程虚拟地址空间,并没有分配虚拟内存对应的物理内存,当访问这些没有建立映射关系的虚拟内存时,CPU加载指令发现代码段是缺失的,就触发了缺页中断,中断后,内核通过检查虚拟地址的所在区域,发现存在内存映射,就可以通过虚拟内存地址计算文件偏移,定位到内存所缺的页对应的文件的页,由内核启动磁盘IO,将对应的页从磁盘加载到内存中。最终保护mmap能顺利进行,无私奉献。了解完缺页中断,我们再来细聊下mmap四种场景下的内存分配原理

四种场景分配原理


上面是一个简单的原理总结,并没有详细的展开,感兴趣可以自己查查资料哈。

总结

本次分享,主要介绍了mmap的四种应用场景,通过一个实例验证了父子进程间的通信,并深入mmap找到它的保护神,且深入了解到mmap在四种场景下,操作系统是如何组织分配,通过对这些的了解,在你之后的mmap实战应用有了更好的理论基础,可以根据不同的需求,不同的性能要求等,选择最合适的实现。


作者:i校长
来源:juejin.cn/post/7119116943256190990

收起阅读 »

以往项目中的压缩apk经验

过往的开发中,由于项目中使用的图片、音乐文件、特殊字体文件,以及导入的第三包等导致了最后生成的apk往往体积过大。过大的apk对于用户来说体验会非常的差,下载慢、耗费流量多等。所以开发者需要适当的压缩自己的apk。1.无需国际化时,去除额外的语言配置在项目ap...
继续阅读 »

过往的开发中,由于项目中使用的图片、音乐文件、特殊字体文件,以及导入的第三包等导致了最后生成的apk往往体积过大。过大的apk对于用户来说体验会非常的差,下载慢、耗费流量多等。所以开发者需要适当的压缩自己的apk。

1.无需国际化时,去除额外的语言配置

在项目app module的build.gradle中的defaultConfig中配置 resConfigs,仅配置需要的语言选项。


2.去除不需要的so架构

在项目app module的build.gradle中的defautlConfig中配置 ndk,仅配置需要的so库。 armeabi-v7a,arm64-v8a基本满足需求,如果需要用虚拟机测试可以加上x86


3.使用webg替代png或jpg

webp格式是谷歌推出的一种有损压缩格式,这种图片格式相比png或jpg格式的图片损失的质量几乎可以忽略不计,但是压缩后的图片体积却比png或jpg要小很多。

4.混淆配置

分为代码混淆和资源混淆

4.1代码混淆

proguard可以混淆以及优化代码,减小dex文件的大小,开启后需要需要配置proguard-rules.pro文件来保留不需要混淆的类,以及第三方包的类。 在项目app module的buildType中的release中设置minifyEnable为true,即可开启混淆, proguardFiles 是你制定的混淆规则文件。


4.3资源混淆

关于资源混淆,我使用的是微信的AndResGuard,它会将资源路径变短。 项目github地址:github.com/shwenzhang/…

配置过程: 1.在项目级的build.gradle添加


2.在app module中的build.gralde中添加插件


3.在app module中的build.gradle中添加andResGuard,需要注意的是whiteList,该项目的github中有列了一些三方库需要添加的白名单,还有特别要注意的是项目中如果有使用getIdentifier来查找drawable资源或者mipmap资源,需要将资源名也加入到白名单。



作者:ChenYhong
来源:juejin.cn/post/7027480502193881101

收起阅读 »

Android登录拦截的场景-面向切面基于AOP实现

AOP
前言 场景如下:用户第一次下载App,点击进入首页列表,点击个人页面,需要校验登录,然后跳转到登录页面,注册/登录完成跳转到个人页面。 非常常见的场景,正常我们开发就只能判断是否已经登录,如果未登录就跳转到登录,然后登录完成之后怎么继续执行?如何封装?有哪些方...
继续阅读 »

前言


场景如下:用户第一次下载App,点击进入首页列表,点击个人页面,需要校验登录,然后跳转到登录页面,注册/登录完成跳转到个人页面。


非常常见的场景,正常我们开发就只能判断是否已经登录,如果未登录就跳转到登录,然后登录完成之后怎么继续执行?如何封装?有哪些方式?其实很多人并不清楚。


这里做一个系列总结一下,看看公共有多少种方式实现,你们使用的是哪一种方案,或者说你们觉得哪一种方案最好用。


这一次分享的是全网最多的方案 ,面向切面 AOP 的方式。你去某度一搜,Android拦截登录 最多的结果就是AOP实现登录拦截的功能,既然大家都推荐,我们就来看看它到底如何?


一、了解面向切面AOP


我们学习Java的开始,我们一直就知道 OOP 面向对象,其实 AOP 面向切面,是对OOP的一个补充,AOP采取横向收取机制,取代了传统纵向继承体系重复性代码,把某一类问题集中在一个地方进行处理,比如处理程序中的点击事件、打印日志等。


AOP是编程思想就是把业务逻辑和横切问题进行分离,从而达到解耦的目的,提高代码的重用性和开发效率。OOP的精髓是把功能或问题模块化,每个模块处理自己的家务事。但在现实世界中,并不是所有功能都能完美得划分到模块中。AOP的目标是把这些功能集中起来,放到一个统一的地方来控制和管理。


我记得我最开始接触 AOP 还是在JavaEE的框架SSH的学习中,AspectJ框架,开始流行于后端,现在在Android开发的应用中也越来越广泛了,Android中使用AspectJ框架的应用也有很多,比如点击事件防抖,埋点,权限申请等等不一而足,这里不展开说明,毕竟我们这一期不是专门讲AspectJ的应用的。


简单的说一下AOP的重点概念(摘抄):




  • 前置通知(Before):在目标方法被调用之前调用通知功能。




  • 后置通知(After):在目标方法完成之后调用通知,此时不会关心方法的输出是什么。




  • 返回通知(After-returning):在目标方法成功执行之后调用通知。




  • 异常通知(After-throwing):在目标方法抛出异常后调用通知。




  • 环绕通知(Around):通知包裹了被通知的方法,在被通知的方法调用之前和调用之后执行自定义的行为。




  • 连接点:是在应用执行过程中能够插入切面的一个点。




  • 切点: 切点定义了切面在何处要织入的一个或者多个连接点。




  • 切面:是通知和切点的结合。通知和切点共同定义了切面的全部内容。




  • 引入:引入允许我们向现有类添加新方法或属性。




  • 织入:是把切面应用到目标对象,并创建新的代理对象的过程。切面在指定的连接点被织入到目标对象中。在目标对象的生命周期中有多个点可以进行织入:




  • 编译期: 在目标类编译时,切面被织入。这种方式需要特殊的编译器。AspectJ的织入编译器就是以这种方式织入切面的。




  • 类加载期:切面在目标加载到JVM时被织入。这种方式需要特殊的类加载器(class loader)它可以在目标类被引入应用之前增强该目标类的字节码。




  • 运行期: 切面在应用运行到某个时刻时被织入。一般情况下,在织入切面时,AOP容器会为目标对象动态地创建一个代理对象。SpringAOP就是以这种方式织入切面的。




简单理解就是把一个方法拿出来,在这个方法执行前,执行后,做一些特别的操作。关于AOP的基本使用推荐大家看看大佬的教程:


深入理解Android之AOP


不多BB,我们直接看看Android中如何使用AspectJ实现AOP逻辑,实现拦截登录的功能。


二、集成AOP框架


Java项目集成


buildscript {
repositories {
mavenCentral()
}
dependencies {
classpath 'org.aspectj:aspectjtools:1.8.9'
classpath 'org.aspectj:aspectjweaver:1.8.9'
}
}

组件build.gradle


dependencies {
implementation 'org.aspectj:aspectjrt:1.9.6'
}


import org.aspectj.bridge.IMessage
import org.aspectj.bridge.MessageHandler
import org.aspectj.tools.ajc.Main

// 获取log打印工具和构建配置
final def log = project.logger
final def variants = project.android.applicationVariants
variants.all { variant ->
if (!variant.buildType.isDebuggable()) {
// 判断是否debug,如果打release把return去掉就可以
log.debug("Skipping non-debuggable build type '${variant.buildType.name}'.")
// return;
}
// 使aspectj配置生效
JavaCompile javaCompile = variant.javaCompile
javaCompile.doLast {
String[] args = ["-showWeaveInfo",
"-1.8",
"-inpath", javaCompile.destinationDir.toString(),
"-aspectpath", javaCompile.classpath.asPath,
"-d", javaCompile.destinationDir.toString(),
"-classpath", javaCompile.classpath.asPath,
"-bootclasspath", project.android.bootClasspath.join(File.pathSeparator)]
log.debug "ajc args: " + Arrays.toString(args)

MessageHandler handler = new MessageHandler(true);
new Main().run(args, handler);
//在编译时打印信息如警告、error等等
for (IMessage message : handler.getMessages(null, true)) {
switch (message.getKind()) {
case IMessage.ABORT:
case IMessage.ERROR:
case IMessage.FAIL:
log.error message.message, message.thrown
break;
case IMessage.WARNING:
log.warn message.message, message.thrown
break;
case IMessage.INFO:
log.info message.message, message.thrown
break;
case IMessage.DEBUG:
log.debug message.message, message.thrown
break;
}
}
}
}

Kotlin项目集成


dependencies {
classpath 'com.android.tools.build:gradle:3.6.1'

classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"

classpath 'com.hujiang.aspectjx:gradle-android-plugin-aspectjx:2.0.10'

项目build.gradle


apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt'

apply plugin: 'android-aspectjx'

android {
...

// AOP 配置
aspectjx {
// 排除一些第三方库的包名(Gson、 LeakCanary 和 AOP 有冲突)
exclude 'androidx', 'com.google', 'com.squareup', 'com.alipay', 'com.taobao',
'org.apache',
'org.jetbrains.kotlin',
"module-info", 'versions.9'
}

}

ependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation 'org.aspectj:aspectjrt:1.9.5'
}

集成AOP踩坑:
zip file is empty



和第三方包有冲突,比如Gson,OkHttp等,需要配置排除一下第三方包,



gradle版本兼容问题



AGP版本4.0以上不支持 推荐使用3.6.1



kotlin兼容问题 :



基本都是推荐使用 com.hujiang.aspectjx



编译版本兼容问题:



4.0以上使用KT编译版本为Java11需要改为Java8



组件化兼容问题:



如果在library的moudle中自定义的注解, 想要通过AspectJ来拦截织入, 那么这个@Aspect类必须和自定义的注解在同一moudle中, 否则是没有效果的



等等...


难点就在集成,如何在指定版本的Gradle,Kotlin项目中集成成功。只要集成成功了,使用到是简单了。


三、定义注解实现功能


定义标记的注解


//不需要回调的处理
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Login {
}

定义处理类


@Aspect
public class LoginAspect {

@Pointcut("@annotation(com.guadou.kt_demo.demo.demo3_bottomtabbar_fragment.aop.Login)")
public void Login() {
}

//不带回调的注解处理
@Around("Login()")
public void loginJoinPoint(ProceedingJoinPoint joinPoint) throws Throwable {
YYLogUtils.w("走进AOP方法-Login()");
Signature signature = joinPoint.getSignature();

if (!(signature instanceof MethodSignature)){
throw new RuntimeException("该注解只能用于方法上");
}

Login login = ((MethodSignature) signature).getMethod().getAnnotation(Login.class);
if (login == null) return;

//判断当前是否已经登录
if (LoginManager.isLogin()) {
joinPoint.proceed();
} else {
//如果未登录,去登录页面
LoginManager.gotoLoginPage();
}


object LoginManager {

@JvmStatic
fun isLogin(): Boolean {
val token = SP().getString(Constants.KEY_TOKEN, "")
YYLogUtils.w("LoginManager-token:$token")
val checkEmpty = token.checkEmpty()
return !checkEmpty
}

@JvmStatic
fun gotoLoginPage() {
commContext().gotoActivity<LoginDemoActivity>()
}
}

其实逻辑很简单,就是判断是否登录,看是放行还是跳转到登录页面


使用的逻辑也是很简单,把需要处理的逻辑使用方法抽取,并标记注解即可


    override fun init() {

mBtnCleanToken.click {
SP().remove(Constants.KEY_TOKEN)
toast("清除成功")
}

mBtnProfile.click {

//不带回调的登录方式
gotoProfilePage2()
}

}

@Login
private fun gotoProfilePage2() {
gotoActivity<ProfileDemoActivity>()
}

效果:



这..这和我使用Token自己手动判断有什么区别,完成登录之后还得我再点一次按钮,当然了这只是登录拦截,我想要的是登录成功之后继续之前的操作,怎么办?


其实使用AOP的方式的话,我们可以使用消息通知的方式,比如LiveBus FlowBus之类的间接实现这个效果。


我们先单独的定义一个注解


//需要回调的处理用来触发用户登录成功后的后续操作
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface LoginCallback {
}

修改定义的切面类


@Aspect
public class LoginAspect {

@Pointcut("@annotation(com.guadou.kt_demo.demo.demo3_bottomtabbar_fragment.aop.Login)")
public void Login() {
}

@Pointcut("@annotation(com.guadou.kt_demo.demo.demo3_bottomtabbar_fragment.aop.LoginCallback)")
public void LoginCallback() {
}

//带回调的注解处理
@Around("LoginCallback()")
public void loginCallbackJoinPoint(ProceedingJoinPoint joinPoint) throws Throwable {
YYLogUtils.w("走进AOP方法-LoginCallback()");
Signature signature = joinPoint.getSignature();

if (!(signature instanceof MethodSignature)){
throw new RuntimeException("该注解只能用于方法上");
}

LoginCallback loginCallback = ((MethodSignature) signature).getMethod().getAnnotation(LoginCallback.class);
if (loginCallback == null) return;

//判断当前是否已经登录
if (LoginManager.isLogin()) {
joinPoint.proceed();

} else {
LifecycleOwner lifecycleOwner = (LifecycleOwner) joinPoint.getTarget();

LiveEventBus.get("login").observe(lifecycleOwner, new Observer<Object>() {
@Override
public void onChanged(Object integer) {
try {
joinPoint.proceed();
LiveEventBus.get("login").removeObserver(this);

} catch (Throwable throwable) {
throwable.printStackTrace();
LiveEventBus.get("login").removeObserver(this);
}
}
});

LoginManager.gotoLoginPage();
}
}

//不带回调的注解处理
@Around("Login()")
public void loginJoinPoint(ProceedingJoinPoint joinPoint) throws Throwable {
YYLogUtils.w("走进AOP方法-Login()");
Signature signature = joinPoint.getSignature();

if (!(signature instanceof MethodSignature)){
throw new RuntimeException("该注解只能用于方法上");
}

Login login = ((MethodSignature) signature).getMethod().getAnnotation(Login.class);
if (login == null) return;

//判断当前是否已经登录
if (LoginManager.isLogin()) {
joinPoint.proceed();
} else {
//如果未登录,去登录页面
LoginManager.gotoLoginPage();
}


}
}

在去登录页面之前注册一个LiveEventBus事件,当登录完成之后发出通知,这里就直接放行调用注解的方法。即可完成继续执行的操作。


使用:


    override fun init() {

mBtnCleanToken.click {
SP().remove(Constants.KEY_TOKEN)
toast("清除成功")
}

mBtnProfile.click {

//不带回调的登录方式
gotoProfilePage()
}

}

@LoginCallback
private fun gotoProfilePage() {
gotoActivity<ProfileDemoActivity>()
}

效果:



总结


从上面的代码我们就基于AOP思想实现了登录拦截功能,以后我们对于需要用户登录之后才能使用的功能只需要在对应的方法上添加指定的注解即可完成逻辑,彻底摆脱传统耗时耗力的开发方式。


需要注意的是AOP框架虽然使用起来很方便,能帮我们轻松完成函数插桩功能,但是它也有自己的缺点。AspectJ 在实现时会包装自己的一些类,不仅会影响切点方法的性能,还会导致安装包体积的增大。最关键的是对Kotlin不友好,对高版本AGP不友好,所以大家在使用时需要仔细权衡是否适合自己的项目。如有需求可以运行源码查看效果。源码在此


由于篇幅原因,后期会出单独出一些其他方式实现的登录拦截的实现,如果觉得这种方式不喜欢,大家可以对比一下哪一种方式比较你胃口。大家可以点下关注看看最新更新。


题外话:
我发现大家看我的文章都喜欢看一些总结性的,实战性的,直接告诉你怎么用的那种。所以我尽量不涉及到集成原理与基本使用,直接开箱即用。当然关于原理和基本的使用,我也不是什么大佬,我想大家也看不上我讲的。不过我也会给出推荐的文章。


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

定位都得集成第三方?Android原生定位服务LocationManager不行吗?

前言 现在的应用,几乎每一个 App 都存在定位的逻辑,方便更好的推荐产品或服务,获取当前设备的经纬度是必备的功能了。有些 App 还是以LBS(基于位置服务)为基础来实现的,比如美团,饿了吗,不获取到位置都无法使用的。 有些同学觉得不就是获取到经纬度么,An...
继续阅读 »

前言


现在的应用,几乎每一个 App 都存在定位的逻辑,方便更好的推荐产品或服务,获取当前设备的经纬度是必备的功能了。有些 App 还是以LBS(基于位置服务)为基础来实现的,比如美团,饿了吗,不获取到位置都无法使用的。


有些同学觉得不就是获取到经纬度么,Android 自带的就有位置服务 LocationManager ,我们无需引入第三方服务,就可以很方便的实现定位逻辑。


确实 LocationManager 的使用很简单,获取经纬度很方便,我们就无需第三方的服务了吗? 或者说 LocationManager 有没有坑呢?兼容性问题怎么样?获取不到位置有没有什么兜底策略?


一、LocationManager的使用


由于是Android的系统服务,直接 getSystemService 可以获取到


LocationManager locationManager = (LocationManager) context.getSystemService(Context.LOCATION_SERVICE);
复制代码

一般获取位置有两种方式 NetWork 与 GPS 。我们可以指定方式,也可以让系统自动提供最好的方式。


// 获取所有可用的位置提供器
List<String> providerList = locationManager.getProviders(true);
// 可以指定优先GPS,再次网络定位
if (providerList.contains(LocationManager.GPS_PROVIDER)) {
provider = LocationManager.GPS_PROVIDER;
} else if (providerList.contains(LocationManager.NETWORK_PROVIDER)) {
provider = LocationManager.NETWORK_PROVIDER;
} else {
// 当没有可用的位置提供器时,弹出Toast提示用户
return;
}
复制代码

当然我更推荐由系统提供,当我的设备在室内的时候就会以网络的定位提供,当设备在室外的时候就可以提供GPS定位。


 String provider = locationManager.getBestProvider(criteria, true);
复制代码

我们可以实现一个定位的Service实现这个逻辑


/**
* 获取定位服务
*/
public class LocationService extends Service {

private LocationManager lm;
private MyLocationListener listener;

@Override
public IBinder onBind(Intent intent) {
return null;
}

@SuppressLint("MissingPermission")
@Override
public void onCreate() {
super.onCreate();

lm = (LocationManager) getSystemService(LOCATION_SERVICE);
listener = new MyLocationListener();

Criteria criteria = new Criteria();
criteria.setAccuracy(Criteria.ACCURACY_COARSE);
criteria.setAltitudeRequired(false);//不要求海拔
criteria.setBearingRequired(false);//不要求方位
criteria.setCostAllowed(true);//允许有花费
criteria.setPowerRequirement(Criteria.POWER_LOW);//低功耗

String provider = lm.getBestProvider(criteria, true);

YYLogUtils.w("定位的provider:" + provider);

Location location = lm.getLastKnownLocation(provider);

YYLogUtils.w("" + location);

if (location != null) {
//不为空,显示地理位置经纬度
String longitude = "Longitude:" + location.getLongitude();
String latitude = "Latitude:" + location.getLatitude();

YYLogUtils.w("getLastKnownLocation:" + longitude + "-" + latitude);

stopSelf();

}

//第二个参数是间隔时间 第三个参数是间隔多少距离,这里我试过了不同的各种组合,能获取到位置就是能,不能获取就是不能
lm.requestLocationUpdates(provider, 3000, 10, listener);
}

class MyLocationListener implements LocationListener {
// 位置改变时获取经纬度
@Override
public void onLocationChanged(Location location) {

String longitude = "Longitude:" + location.getLongitude();
String latitude = "Latitude:" + location.getLatitude();

YYLogUtils.w("onLocationChanged:" + longitude + "-" + latitude);


stopSelf(); // 获取到经纬度以后,停止该service
}

// 状态改变时
@Override
public void onStatusChanged(String provider, int status, Bundle extras) {
YYLogUtils.w("onStatusChanged - provider:"+provider +" status:"+status);
}

// 提供者可以使用时
@Override
public void onProviderEnabled(String provider) {
YYLogUtils.w("GPS开启了");
}

// 提供者不可以使用时
@Override
public void onProviderDisabled(String provider) {
YYLogUtils.w("GPS关闭了");
}

}

@Override
public void onDestroy() {
super.onDestroy();
lm.removeUpdates(listener); // 停止所有的定位服务
}

}
复制代码

使用:定义并动态申请权限之后即可开启服务



fun testLocation() {

extRequestPermission(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION) {

startService(Intent(mActivity, LocationService::class.java))

}

}

复制代码

这样我们启动这个服务就可以获取到当前的经纬度,只是获取一次,大家如果想再后台持续定位,那么实现的方式就不同了,我们服务要设置为前台服务,并且需要额外申请后台定位权限。


话说回来,这么使用就一定能获取到经纬度吗?有没有兼容性问题


Android 5.0 Oppo



Android 6.0 Oppo海外版



Android 7.0 华为



Android 11 三星海外版



Android 12 vivo



目前测试不多,也能发现问题,特别是一些低版本,老系统的手机就可能无法获取位置,应该是系统的问题,这种服务跟网络没关系,开不开代理都是一样的。


并且随着测试系统的变高,越来越完善,提供的最好定位方式还出现混合定位 fused 的选项。


那是不是6.0的Oppo手机太老了,不支持定位了?并不是,百度定位可以获取到位置的。



既然只使用 LocationManager 有风险,有可能无法获取到位置,那怎么办?


二、混合定位


其实目前百度,高度的定位Api的服务SDK也不算大,相比地图导航等比较重的功能,定位的SDK很小了,并且目前都支持海外的定位服务。并且定位服务是免费的哦。


既然 LocationManager 有可能获取不到位置,那我们就加入第三方定位服务,比如百度定位。我们同时使用 LocationManager 和百度定位,哪个先成功就用哪一个。(如果LocationManager可用的话,它的定位比百度定位更快的)


完整代码如下:


@SuppressLint("MissingPermission")
public class LocationService extends Service {

private LocationManager lm;
private MyLocationListener listener;
private LocationClient mBDLocationClient = null;
private MyBDLocationListener mBDLocationListener;

@Override
public IBinder onBind(Intent intent) {
return null;
}

@Override
public void onCreate() {
super.onCreate();

createNativeLocation();

createBDLocation();
}

/**
* 第三方百度定位服务
*/
private void createBDLocation() {
mBDLocationClient = new LocationClient(UIUtils.getContext());
mBDLocationListener = new MyBDLocationListener();
//声明LocationClient类
mBDLocationClient.registerLocationListener(mBDLocationListener);
//配置百度定位的选项
LocationClientOption option = new LocationClientOption();
option.setLocationMode(LocationClientOption.LocationMode.Battery_Saving);
option.setCoorType("WGS84");
option.setScanSpan(10000);
option.setIsNeedAddress(true);
option.setOpenGps(true);
option.SetIgnoreCacheException(false);
option.setWifiCacheTimeOut(5 * 60 * 1000);
option.setEnableSimulateGps(false);
mBDLocationClient.setLocOption(option);
//开启百度定位
mBDLocationClient.start();
}

/**
* 原生的定位服务
*/
private void createNativeLocation() {

lm = (LocationManager) getSystemService(LOCATION_SERVICE);
listener = new MyLocationListener();

Criteria criteria = new Criteria();
criteria.setAccuracy(Criteria.ACCURACY_COARSE);
criteria.setAltitudeRequired(false);//不要求海拔
criteria.setBearingRequired(false);//不要求方位
criteria.setCostAllowed(true);//允许有花费
criteria.setPowerRequirement(Criteria.POWER_LOW);//低功耗

String provider = lm.getBestProvider(criteria, true);

YYLogUtils.w("定位的provider:" + provider);

Location location = lm.getLastKnownLocation(provider);

YYLogUtils.w("" + location);

if (location != null) {
//不为空,显示地理位置经纬度
String longitude = "Longitude:" + location.getLongitude();
String latitude = "Latitude:" + location.getLatitude();

YYLogUtils.w("getLastKnownLocation:" + longitude + "-" + latitude);

stopSelf();

}

lm.requestLocationUpdates(provider, 3000, 10, listener);
}

class MyLocationListener implements LocationListener {
// 位置改变时获取经纬度
@Override
public void onLocationChanged(Location location) {

String longitude = "Longitude:" + location.getLongitude();
String latitude = "Latitude:" + location.getLatitude();

YYLogUtils.w("onLocationChanged:" + longitude + "-" + latitude);


stopSelf(); // 获取到经纬度以后,停止该service
}

// 状态改变时
@Override
public void onStatusChanged(String provider, int status, Bundle extras) {
YYLogUtils.w("onStatusChanged - provider:" + provider + " status:" + status);
}

// 提供者可以使用时
@Override
public void onProviderEnabled(String provider) {
YYLogUtils.w("GPS开启了");
}

// 提供者不可以使用时
@Override
public void onProviderDisabled(String provider) {
YYLogUtils.w("GPS关闭了");
}

}


/**
* 百度定位的监听
*/
class MyBDLocationListener extends BDAbstractLocationListener {

@Override
public void onReceiveLocation(BDLocation location) {

double latitude = location.getLatitude(); //获取纬度信息
double longitude = location.getLongitude(); //获取经度信息


YYLogUtils.w("百度的监听 latitude:" + latitude);
YYLogUtils.w("百度的监听 longitude:" + longitude);

YYLogUtils.w("onBaiduLocationChanged:" + longitude + "-" + latitude);

stopSelf(); // 获取到经纬度以后,停止该service
}
}

@Override
public void onDestroy() {
super.onDestroy();
// 停止所有的定位服务
lm.removeUpdates(listener);

mBDLocationClient.stop();
mBDLocationClient.unregisterLocationListener(mBDLocationListener);
}

}
复制代码

其实逻辑都是很简单的,并且省略了不少回调通信的逻辑,这里只涉及到定位的逻辑,别的逻辑我就尽量不涉及到。


百度定位服务的API申请与初始化请自行完善,这里只是简单的使用。并且坐标系统一为国际坐标,如果需要转gcj02的坐标系,可以网上找个工具类,或者看我之前的文章


获取到位置之后,如何Service与Activity通信,就由大家自由发挥了,有兴趣的可以看我之前的文章


总结


所以说Android原生定位服务 LocationManager 还是有问题啊,低版本的设备可能不行,高版本的Android系统又很行,兼容性有问题!让人又爱又恨。


很羡慕iOS的定位服务,真的好用,我们 Android 的定位服务真是拉跨,居然还有兼容性问题。


我们使用第三方定位服务和自己的 LocationManager 并发获取位置,这样可以增加容错率。是比较好用的,为什么要加上 LocationManager 呢?我直接单独用第三方的定位服务不香吗?可以是可以,但是如果设备支持 LocationManager 的话,它会更快一点,体验更好。


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

【Flutter 异步编程 - 壹】 | 单线程下的异步模型

本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究! 一、 本专栏图示概念规范 本专栏是对 异步编程 的系统探索,会通过各个方面去认知、思考 异步编程 的概念。期间会用到一些图片进行表达与示意,在一开始先对图中的元素和 ...
继续阅读 »

本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!



一、 本专栏图示概念规范


本专栏是对 异步编程 的系统探索,会通过各个方面去认知、思考 异步编程 的概念。期间会用到一些图片进行表达与示意,在一开始先对图中的元素基本概念 进行规范和说明。




1. 任务概念规范


任务 : 完成一项需求的基本单位。

分发任务: 触发任务开始的动作。

任务结束: 任务完成的标识。

任务生命期: 任务从开始到完成的时间跨度。



如下所示,方块 表示任务;当 箭头指向一个任务时,表示对该任务进行分发;任何被分发的任务都会结束。在任务分发和结束之间,有一条虚线进行连接,表示 任务生命期





2. 任务的状态


未完成 : Uncompleted

成功完成 : Completed with Success

异常结束 : Completed with Error



一个任务生命期间有三种状态,如下通过三种颜色表示。在 任务结束 之前,该任务都是 未完成 态,通过 浅蓝色 表示;任何被分发的任务都是为了完成某项需求,任何任务都会结束,在结束时刻根据是否完成需求,可分为 成功完成异常结束 两种状态,如下分别用 绿色红色 表示。





3. 时刻与时间线


机体 : 任务分发者或处理者。

时刻: 机体运行中的某一瞬间。

时间线: 所有时刻构成的连续有向轴线。



在一个机体运行的过程中,时间线是绝对的,通过 紫色有向线段 表示时间的流逝的方向。时刻 是时间线上任意一点 ,通过 黑点 表示。





4.同步与异步


同步 : 机体在时间线上,将任务按顺序依次分发。



同步执行任务时,前一个任务完成后,才会分发下一任务。意思是说: 任意时刻只有一个任务在生命期中。




异步: 机体在时间线上,在一个任务未完成时,分发另一任务。



也就是说通过异步编程,允许某时刻有两个及以上的任务在生命期中。如下所示,在 任务1 完成后,分发 任务2; 在 任务2 未结束的情况下,可以分发 任务 3 。此时对于任务 3 来说,任务 2 就是异步执行的。


image.png




二、理解单线程中的异步任务


上面对基本概念进行了规范,看起来可能比较抽象,下面我们通过一个小场景来理解一下。妈妈早上出门散步,临走前嘱咐:



小捷,别睡了。快起床,把被子晒一下,地扫一下。还有,没开水了,记得烧。



当前场景下只有小捷 一个机体,需要完成的任务有四个:起床晒被拖地烧水


image.png




1. 任务的分配

当机体有多个任务需要分发时,需要对任务进行分配。认识任务之间的关系,是任务分配的第一步。只有理清关系,才能合理分配任务。分配过程中需要注意:


[1] 任务之间可能存在明确的先后顺序,比如起床 需要在 晒被 之前。

[2] 任务之间先后顺序也可能无所谓,比如先扫地还是先晒被,并没有太大区别。

[3] 某类任务只需要机体来分发,生命期中不需要机体处理,并且和后续的任务没有什么关联性,比如烧水任务。


image.png


像烧水这种任务,即耗时,又不需要机体在任务生命期中做什么事。如果这类任务使用同步处理,那么任务期间机体能做的事只有 等待 。对于一个机体来说,这种等待就会意味着阻塞,不能处理任何事。


结合日常生活,我们知道当前场景之中,想要发挥机体最大的效力,最好的方式是起床之后,先分发 烧水任务,不需要等待烧水任务完成,就去执行晒被、扫地任务。这样的任务分配就是将 烧水 作为一个异步任务来执行的。


但在如果在分配时,将烧水作为最后一个任务,那么异步执行的价值就会消失。所以对任务的合理分配,对机体的处理效率是非常重要的。




2.异步任务特点

从上面可以看出,异步任务 有很明显的特征,并不是任何任务都有必要异步执行。特别是对于单一机体来说,任务生命期间需要机体亲自参与,是无法异步处理的。 比如一个人不能一边晒被 ,一边 扫地 。所以对于单线程来说,像一些只需要 分发任务,任务的具体执行逻辑由其他机体完成的任务,适合使用 异步 处理,来避免不必要的等待。


这种任务,在应用程序中最常见的是网络 io磁盘 io 的任务。比如,从一个网络接口中获取数据,对于机体来说,只需要分发任务来发送请求,就像烧水时只需要装水按下启动键一样。而服务器如何根据请求,查询数据库来返回响应信息,数据如何在网络中传输的,和分发任务的机体没有关系。磁盘的访问也是一样,分发读写文件任务后,真正干活的是操作系统。


像这类任务通过异步处理,可以避免在分发任务后,机体因等待任务的结束而阻塞。在等待其他机体处理的过程中,去分发其他任务,可以更好地分配时间。比如下面所示,网络数据获取 的任务分发后,需要通过网络把请求传输给服务器,服务器进行处理,给出响应结果。



整个任务处理的过程,并不需要机体参与,所以分发 网络数据获取 任务后,无需等待任务完成,接着分发 构建加载中界面 的任务,来展示加载中的界面。从而给出用户交互的反馈,而不是阻塞在那里等待网络任务完成,这就是一个非常典型的异步任务使用场景。




3. 异步任务完成与回调

前面的介绍中可以看出,异步任务在分发之后,并不会等待任务完成,在任务生命期中,可以继续分发其他任务。但任何任务都会结束,很多时候我们需要知道异步任务何时完成,以及任务的完成情况、任务返回的结果,以便该任务后续的处理。比如,在烧水完成之后,我们需要处理 冲水 的任务。


image.png


这就要涉及到一个对异步而言非常重要的概念:



回调: 任务在生命期间向机体提供通知的方式。



比如 烧水 任务完成后,烧水壶 “叮” 的一声通知任务完成;或者烧水期间发生故障,发出报警提示。这种在任务生命期间向机体发送通知的方式称为回调 。在编程中,回调一般是通过 函数参数 来实现的,所以习惯称 回调函数 。 另外,函数可以传递数据,所以通过回调函数不仅可以知道任务结束的契机,还可以通过回调参数将任务的内部数据暴露给机体。


比如在实际开发中,分发 网络数据获取 的任务,其目的是为了通过网络接口获取数据。就像烧开水任务完成之后,需要把 开水 倒入瓶中一样。我们也需要知道 网络数据获取 的任务完成的时机,将获取的数据 "倒入" 界面中进行显示。



从发送异步任务,到异步任务结束的回调触发,就是一个异步任务完整的 生命期




三、 Dart 语言中的异步


上面只是介绍了 异步模型 中的概念,这些概念是共通的,无论什么编程语言都一样适用。就像现实中,无论使用哪国的语言表述,四则运算的概念都不会有任何区别。只是在表述过程中,表现形式会在语言的语法上有所差异。




1.编程语言中与异步模型的对应关系

每种语言的描述,都是对概念模型的具象化实现。这里既然是对 Flutter 中异步编程的介绍,自然要说一下 Dart 语言对异步模型的描述。


对于 任务 概念来说,在编程中和 函数 有着千丝万缕的联系:函数体 可以实现 任务处理的具体逻辑,也可以触发 任务分发的动作 。但我并不认为两者是等价的, 任务 有着明确的 目的性 ,而 函数 是实现这种 目的 的手段。在编程活动中,函数 作为 任务 在代码中的逻辑体现,任务 应先于 函数 存在。


如下代码所示,在 main 函数中,触发 calculate 任务,计算 0 ~ count 累加值和计算耗时,并返回。其中 calculate 函数就是对该任务的代码实现:


void main(){
TaskResult result = calculate();
}


TaskResult calculate({int count = 10000000}){
int startTime = DateTime.now().millisecondsSinceEpoch;
int result = loopAdd(count);
int cost = DateTime.now().millisecondsSinceEpoch-startTime;
return TaskResult(
cost:cost,
data:result,
taskName: "calculate"
);
}

int loopAdd(int count) {
int sum = 0;
for (int i = 0; i <= count; i++) {
sum+=i;
}
return sum;
}

这里 TaskResult 类用于记录任务完成的信息:


class TaskResult {
final int cost;
final String taskName;
final dynamic data;

TaskResult({
required this.cost,
required this.data,
required this.taskName,
});

Map<String,dynamic> toJson()=>{
"taskName":taskName,
"cost":cost,
"data": data
};
}



2.Dart 编程中的异步任务

如下在计算之后,还有两个任务:saveToFile 任务,将运算结果保存到文件中;以及 render 任务将运算结果渲染到界面上。


void main() {
TaskResult result = cacaulate();
saveToFile(result);
render(result);
}

这里 render 任务暂时通过在控制台打印显示作为渲染,逻辑如下:


void render(TaskResult result) {
print("结果渲染: ${result.toJson()}");
}

下面是将结果写入文件的任务实现逻辑。其中 File 对象的 writeAsString 是一个异步方法,可以将内容写入到文件中。通过 then 方法设置回调,监听任务完成的时机。



void saveToFile(TaskResult result) {
String filePath = path.join(Directory.current.path, "out.json");
File file = File(filePath);
String content = json.encode(result);
file.writeAsString(content).then((File value){
print("写入文件成功:!${value.path}");
});
}



3.当前任务分析

如下是这三个任务的执行示意,在 saveToFile 中使用 writeAsString 方法将异步处理写入逻辑。



这样就像在烧水任务分发后,可以执行晒被一样。saveToFile 任务分发之后,不需要等待文件写入完成,可以继续执行 render 方法。日志输出如下:渲染任务的执行并不会因写入文件任务而阻塞,这就是异步处理的价值。


image.png




四、异步模型的延伸


1. 单线程异步模型的局限性

本文主要介绍 异步模型 的概念,认识异步的作用,以及 Dart 编程语言中异步方法的基本使用。至于代码中更具体的异步使用方式,将在后期文章中结合详细介绍。另外,一般情况下,Dart 是以 单线程 运行的,所以本文中强调的是 单线程 下的异步模型。


仔细思考一下,可以看出,单线程中实现异步是有局限性的。比如说需要解析一个很大的 json ,或者进行复杂的逻辑运算等 耗时任务,这种必须由 本机体 处理的逻辑,而不是 等待结果 的场景,是无法在单线程中异步处理的。


就像是 扫地晒被 任务,对于单一机体来说,不可能同时参与到两个任务之中。在实际开发中这两个任务可类比为 解析超大 json显示解析中界面 两个任务。如果前者耗时三秒,由于单线程 中同步方法的阻塞,界面就会卡住三秒,这就是单线程异步模型的 局限性




2. 多线程与异步的关系

上面问题的本质矛盾是:一个机体无法 同时 参与到两件任务 具体执行过程中。解决方案也非常简单,一个人搞不定,就摇人呗。多个机体参与任务分配的场景,就是 多线程
很多人都会讨论 异步多线程 的关系,其实很简单:两个机体,一个 扫地,一个 晒被,同一时刻,存在两个及以上的任务在生命期中,一定是异步的。毫无疑问,多线程异步模型 的一种实现方式。





3. Dart 中如何解决单线程异步模型的局限性

C++Java 这些语言有 多线程 的支持,通过 “摇人” 可以充分调度 CPU 核心,来处理一些计算密集型的任务,实现任务在时间上的最合理分配。


绝大多数人可能觉得 Dart 是一个单线程的编程语言,其实不然。可能是很多人并没有在 Flutter 端做过计算密集型的任务,没有对多线程迫切的需要。毕竟 移动/桌面客户端 大多是网络、数据库访问等 io 密集型 的任务,人手一个终端,没有什么高并发的场景。不像后端那样需要保证一个终端被百万人同时访问。


或者计算密集型的任务都有由平台机体进行处理,将结果通知给 Flutter 端。这导致 Dart 看起来更像是一个 任务分发者,发号施令的人,绝大多数时候并不需要亲自参与任务的执行过程中。而这正是单线程下的异步模型所擅长的:借他人之力,监听回调信息


其实我们在日常开发中,使用的平台相关的插件,其中的方法基本上都是异步的,本质上就是这个原因。平台 是个烧水壶,烧水任务只需要分发监听回调。至于水怎么烧开,是 平台 需要关心的,这和 网络 io磁盘 io 是很类似的,都是 请求响应 的模式。这种任务,由单线程的异步模型进行处理,是最有效的,毕竟 “摇人” 还是要管饭的。


那如果非要在 Dart 中处理计算密集型的任务,该如何是好呢?不用担心,Dartisolate 机制可以完成这项需求。关于这点,在后面会进行详述。认识 异步 是什么,是本文的核心,那本文就到这里,谢谢观看 ~


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

栈都知道,单调栈有了解吗?

前言 大家好,我是小彭。 今天分享到一种栈的衍生数据结构 —— 单调栈(Monotonic Stack)。栈(Stack)是一种满足后进先出(LIFO)逻辑的数据结构,而单调栈实际上就是在栈的基础上增加单调的性质(单调递增或单调递减)。那么,单调栈是用来解决什...
继续阅读 »

前言


大家好,我是小彭。


今天分享到一种栈的衍生数据结构 —— 单调栈(Monotonic Stack)。栈(Stack)是一种满足后进先出(LIFO)逻辑的数据结构,而单调栈实际上就是在栈的基础上增加单调的性质(单调递增或单调递减)。那么,单调栈是用来解决什么问题的呢?




学习路线图:





1. 单调栈的典型问题


单调栈是一种特别适合解决 “下一个更大元素” 问题的数据结构。


举个例子,给定一个整数数组,要求输出数组中元素 ii 后面下一个比它更大的元素,这就是下一个更大元素问题。这个问题也可以形象化地思考:站在墙上向后看,问视线范围内所能看到的下一个更高的墙。例如,站在墙 [3] 上看,下一个更高的墙就是墙 [4]


形象化思考



这个问题的暴力解法很容易想到:就是遍历元素 ii 后面的所有元素,直到找到下一个比 ii 更大的元素为止,时间复杂度是 O(n)O(n),空间复杂度是 O(1)O(1)。单次查询确实没有优化空间了,那多次查询呢?如果要求输出数组中每个元素的下一个更大元素,那么暴力解法需要的时间复杂度是 O(n2)O(n^2) 。有没有更高效的算法呢?




2. 解题思路


我们先转变一下思路:


在暴力解法中,我们每处理一个元素就要去求它的 “下一个更大元素”。现在我们不这么做,我们每处理一个元素时,由于不清楚它的解,所以先将它缓存到某种数据结构中。后续如果能确定它的解,再将其从缓存中取出来。 这个思路可以作为 “以空间换时间” 优化时间复杂度的通用思路。


回到这个例子上:



  • 在处理元素 [3] 时,由于不清楚它的解,只能先将 [3] 放到缓存中,继续处理下一个元素;

  • 在处理元素 [1] 时,我们观察缓存发现它比缓存中所有元素都小,只能先将它放到缓存中,继续处理下一个元素;

  • 在处理元素 [2] 时,我们观察缓存中的 [1] 比当前元素小,说明当前元素就是 [1] 的解。此时我们可以把 [1] 从缓存中弹出,记录结果。再将 [2] 放到缓存中,继续处理下一个元素;

  • 在处理元素 [1] 时,我们观察缓存发现它比缓存中所有元素都小,只能先将它放到缓存中,继续处理下一个元素;

  • 在处理元素 [4] 时,我们观察缓存中的 [3] [2] [1] 都比当前元素小,说明当前元素就是它们的解。此时我们可以把它们从缓存中弹出,记录结果。再将 [4] 放到缓存中,继续处理下一个元素;

  • 在处理元素 [1] 时,我们观察缓存发现它比缓存中所有元素都小,只能先将它放到缓存中,继续处理下一个元素;

  • 遍历结束,从缓存中弹出过的元素都是有解的,保留在缓存中的元素都是无解的。


分析到这里,我们发现问题已经发生转变,问题变成了:“如何寻找在缓存中小于当前元素的数”。 现在,我们把注意力集中在这个缓存上,思考一下用什么数据结构、用什么算法可以更高效地解决问题。由于这个缓存是我们额外增加的,所以我们有足够的操作空间。


先说结论:



  • 方法 1 - 暴力: 遍历整个缓存中所有元素,最坏情况(递减序列)下所有数据都进入缓存中,单次操作的时间复杂度是 O(N)O(N),整体时间复杂度是 O(N2)O(N^2)

  • 方法 2 - 二叉堆: 不需要遍历整个缓存,只需要对比缓存的最小值,直到缓存的最小值都大于当前元素。最坏情况(递减序列)下所有数据都进入堆中,单次操作的时间复杂度是 O(lgN)O(lgN),整体时间复杂度是 O(NlgN)O(N·lgN)

  • 方法 3 - 单调栈: 我们发现元素进入缓存的顺序正好是有序的,且后进入缓存的元素会先弹出做对比,符合 “后进先出” 逻辑,所以这个缓存数据结构用栈就可以实现。因为每个元素最多只会入栈和出栈一次,所以整体的计算规模还是与数据规模成正比的,整体时间复杂度是 O(n)O(n)


下面,我们先从优先队列说起。




3. 优先队列解法


寻找最值的问题第一反应要想到二叉堆。


我们可以维护一个小顶堆,每处理一个元素时,先观察堆顶的元素:



  • 如果堆顶元素小于当前元素,则说明已经确定了堆顶元素的解,我们将其弹出并记录结果;

  • 如果堆顶元素不小于当前元素,则说明小顶堆内所有元素都是不小于当前元素的,停止观察。


观察结束后,将当前元素加入小顶堆,堆会自动进行堆排序,堆顶就是整个缓存的最小值。此时,继续在后续元素上重复这个过程。


题解


fun nextGreaterElements(nums: IntArray): IntArray {
// 结果数组
val result = IntArray(nums.size) { -1 }
// 小顶堆
val heap = PriorityQueue<Int> { first, second ->
nums[first] - nums[second]
}
// 从前往后查询
for (index in 0 until nums.size) {
// while:当前元素比堆顶元素大,说明找到下一个更大元素
while (!heap.isEmpty() && nums[index] > nums[heap.peek()]) {
result[heap.poll()] = nums[index]
}
// 当前元素入堆
heap.offer(index)
}
return result
}

我们来分析优先队列解法的复杂度:



  • 时间复杂度: 最坏情况下(递减序列),所有元素都被添加到优先队列里,优先队列的单次操作时间复杂度是 O(lgN)O(lgN),所以整体时间复杂度是 O(NlgN)O(N·lgN)

  • 空间复杂度: 使用了额外的优先队列,所以整体的空间复杂度是 O(N)O(N)


优先队列解法的时间复杂度从 O(N2)O(N^2) 优化到 O(NlgN)O(N·lgN),还不错,那还有优化空间吗?




4. 单调栈解法


我们继续分析发现,元素进入缓存的顺序正好是逆序的,最后加入缓存的元素正好就是缓存的最小值。此时,我们不需要用二叉堆来寻找最小值,只需要获取最后一个进入缓存的元素就能轻松获得最小值。这符合 “后进先出” 逻辑,所以这个缓存数据结构用栈就可以实现。


这个问题也可以形象化地思考:把数字想象成有 “重量” 的杠铃片,每增加一个杠铃片,会把中间小的杠铃片压扁,当前的大杠铃片就是这些被压扁杠铃片的 “下一个更大元素”。


形象化思考



解题模板


// 从前往后遍历
fun nextGreaterElements(nums: IntArray): IntArray {
// 结果数组
val result = IntArray(nums.size) { -1 }
// 单调栈
val stack = ArrayDeque<Int>()
// 从前往后遍历
for (index in 0 until nums.size) {
// while:当前元素比栈顶元素大,说明找到下一个更大元素
while (!stack.isEmpty() && nums[index] > nums[stack.peek()]) {
result[stack.pop()] = nums[index]
}
// 当前元素入队
stack.push(index)
}
return result
}

理解了单点栈的解题模板后,我们来分析它的复杂度:



  • 时间复杂度: 虽然代码中有嵌套循环,但它的时间复杂度并不是 O(N2)O(N^2),而是 O(N)O(N)。因为每个元素最多只会入栈和出栈一次,所以整体的计算规模还是与数据规模成正比的,整体时间复杂度是 O(N)O(N)

  • 空间复杂度: 最坏情况下(递减序列)所有元素被添加到栈中,所以空间复杂度是 O(N)O(N)


这道题也可以用从后往前遍历的写法,也是参考资料中提到的解法。 但是,我觉得正向思维更容易理解,也更符合人脑的思考方式,所以还是比较推荐小彭的模板(王婆卖瓜)。


解题模板(从后往前遍历)


// 从后往前遍历
fun nextGreaterElement(nums: IntArray): IntArray {
// 结果数组
val result = IntArray(nums.size) { -1 }
// 单调栈
val stack = ArrayDeque<Int>()
// 从后往前查询
for (index in nums.size - 1 downTo 0) {
// while:栈顶元素比当前元素小,说明栈顶元素不再是下一个更大元素,后续不再考虑它
while (!stack.isEmpty() && stack.peek() <= nums[index]) {
stack.pop()
}
// 输出到结果数组
result[index] = stack.peek() ?: -1
// 当前元素入队
stack.push(nums[index])
}
return result
}



5. 典型例题 · 下一个更大元素 I


理解以上概念后,就已经具备解决单调栈常见问题的必要知识了。我们来看一道 LeetCode 上的典型例题:LeetCode 496.


LeetCode 例题



第一节的示例是求 “在当前数组中寻找下一个更大元素” ,而这道题里是求 “数组 1 元素在数组 2 中相同元素的下一个更大元素” ,还是同一个问题吗?其实啊,这是题目抛出的烟雾弹。注意看细节信息:



  • 两个没有重复元素的数组 nums1和 nums2

  • nums1nums2 的子集。


那么,我们完全可以先计算出 nums2 中每个元素的下一个更大元素,并把结果记录到一个散列表中,再让 nums1 中的每个元素去散列表查询结果即可。


题解


class Solution {
fun nextGreaterElement(nums1: IntArray, nums2: IntArray): IntArray {
// 临时记录
val map = HashMap<Int, Int>()
// 单调栈
val stack = ArrayDeque<Int>()
// 从前往后查询
for (index in 0 until nums2.size) {
// while:当前元素比栈顶元素大,说明找到下一个更大元素
while (!stack.isEmpty() && nums2[index] > stack.peek()) {
// 输出到临时记录中
map[stack.pop()] = nums2[index]
}
// 当前元素入队
stack.push(nums2[index])
}

return IntArray(nums1.size) {
map[nums1[it]] ?: -1
}
}
}



6. 典型例题 · 下一个更大元素 II(环形数组)


第一节的示例还有一道变型题,对应于 LeetCode 上的另一道典型题目:503. 下一个更大元素 II


LeetCode 例题



两道题的核心考点都是 “下一个更大元素”,区别只在于把 “普通数组” 变为 “环形数组 / 循环数组”,当元素遍历到数组末位后依然找不到目标元素,则会循环到数组首位继续寻找。这样的话,除了所有数据中最大的元素,其它每个元素都必然存在下一个更大元素。


其实,计算机中并不存在物理上的循环数组,在遇到类似的问题时都可以用假数据长度和取余的思路处理。如果你是前端工程师,那么你应该有印象:我们在实现无限循环轮播的控件时,有一个小技巧就是给控件 设置一个非常大的数据长度 ,长到永远不可能轮播结束,例如 Integer.MAX_VALUE。每次轮播后索引会加一,但在取数据时会对数据长度取余,这样就实现了循环轮播了。


无限轮播伪代码


class LooperView {

private val data = listOf("1", "2", "3")

// 假数据长度
fun getSize() = Integer.MAX_VALUE

// 使用取余转化为 data 上的下标
fun getItem(index : Int) = data[index % data.size]
}

回到这道题,我们的思路也更清晰了。我们不需要无限查询,所以自然不需要设置 Integer.MAX_VALUE 这么大的假数据,只需要 设置 2 倍的数据长度 ,就能实现循环查询(3 倍、4倍也可以,但没必要),例如:


题解


class Solution {
fun nextGreaterElements(nums: IntArray): IntArray {
// 结果数组
val result = IntArray(nums.size) { -1 }
// 单调栈
val stack = ArrayDeque<Int>()
// 数组长度
val size = nums.size
// 从前往后遍历
for (index in 0 until nums.size * 2) {
// while:当前元素比栈顶元素大,说明找到下一个更大元素
while (!stack.isEmpty() && nums[index % size] > nums[stack.peek() % size]) {
result[stack.pop() % size] = nums[index % size]
}
// 当前元素入队
stack.push(index)
}
return result
}
}



7. 总结


到这里,相信你已经掌握了 “下一个更大元素” 问题的解题模板了。除了典型例题之外,大部分题目会将 “下一个更大元素” 的语义隐藏在题目细节中,需要找出题目的抽象模型或转变思路才能找到,这是难的地方。


小彭在 20 年的文章里说过单调栈是一个相对冷门的数据结构,包括参考资料和网上的其他资料也普遍持有这个观点。 单调栈不能覆盖太大的问题域,应用价值不及其他数据结构。 —— 2 年前的文章


2 年后重新思考,我不再持有此观点。我现在认为:单调栈的关键是 “单调性”,而栈只是为了配合问题对操作顺序的要求而搭配的数据结构。 我们学习单调栈,应该当作学习单调性的思想在栈这种数据结构上的应用,而不是学习一种新的数据结构。对此,你怎么看?


下一篇文章,我们来学习单调性的思想在队列上数据结构上的应用 —— 单调队列。


更多同类型题目:

























































单调栈难度题解
496. 下一个更大元素 IEasy【题解】
1475. 商品折扣后的最终价格Easy【题解】
503. 下一个更大元素 IIMedium【题解】
739. 每日温度Medium【题解】
901. 股票价格跨度Medium【题解】
1019. 链表中的下一个更大节点Medium【题解】
402. 移掉 K 位数字Medium【题解】
42. 接雨水Hard【题解】
84. 柱状图中最大的矩形Hard【题解】

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

不用架构会怎么样?—— 在项目实战中探索架构演进(一)

复杂度 软件的首要技术使命是“管理复杂度” —— 《代码大全》 因为低复杂度才能降低理解成本和沟通难度,提升应对变更的灵活性,减少重复劳动,最终提高代码质量。 架构的目的在于“将复杂度分层” 复杂度为什么要被分层? 若不分层,复杂度会在同一层次展开,这...
继续阅读 »

复杂度



软件的首要技术使命是“管理复杂度” —— 《代码大全》



因为低复杂度才能降低理解成本和沟通难度,提升应对变更的灵活性,减少重复劳动,最终提高代码质量。



架构的目的在于“将复杂度分层”



复杂度为什么要被分层?


若不分层,复杂度会在同一层次展开,这样就太 ... 复杂了。


举一个复杂度不分层的例子:


小李:“你会做什么菜?”


小明:“我会做用土鸡生的土鸡蛋配上切片的番茄,放点油盐,开火翻炒的番茄炒蛋。”


听了小明的回答,你还会和他做朋友吗?


小明把不同层次的复杂度以不恰当的方式揉搓在一起,让人感觉是一种由“没有必要的具体”导致的“难以理解的复杂”。


小李其实并不关心土鸡蛋的来源、番茄的切法、添加的佐料、以及烹饪方式。


这样的回答除了难以理解之外,局限性也很大。因为它太具体了!只要把土鸡蛋换成洋鸡蛋、或是番茄片换成块、或是加点糖、或是换成电磁炉,其中任一因素发生变化,小明就不会做番茄炒蛋了。


再举个正面的例子,TCP/IP 协议分层模型自下到上定义了五层:



  1. 物理层

  2. 数据链路成

  3. 网络层

  4. 传输层

  5. 应用层


其中每一层的功能都独立且明确,这样设计的好处是缩小影响面,即单层的变动不会影响其他层。


这样设计的另一个好处是当专注于一层协议时,其余层的技术细节可以不予关注,同一时间只需要关注有限的复杂度,比如传输层不需要知道自己传输的是 HTTP 还是 FTP,传输层只需要专注于端到端的传输方式,是建立连接,还是无连接。


有限复杂度的另一面是“下层的可重用性”。当应用层的协议从 HTTP 换成 FTP 时,其下层的内容不需要做任何更改。


引子


为了降低客户端领域开发的复杂度,架构也在不断地演进。从 MVC 到 MVP,再到 MVVM,目前已经发展到 MVI。


MVVM 仍然是当下最常用的 Android 端架构,曾经的榜一大哥 MVP 已日落西山。


下图是 Google Trends 关于 “android mvvm” 和 “android mvp”的对比图,剪刀差发生在2018年:


微信图片_20220904192016.png


2018 年到底发生了什么使得架构改朝换代?


MVI 在架构设计上又做了哪些新的尝试?它是否能在将来取代 MVVM?


被如此多新名词弄得头晕脑胀的我,不由得倔强反问:“不是用架构又会怎么样?”


该系列以实战项目中的搜索场景为剧本,演绎了如何运用不同架构进行重构的过程,并逐个给出上述问题自己的理解。


搜索是 App 中常见的业务场景,该功能示意图如下:


1662106805162.gif


业务流程如下:在搜索条中输入关键词并同步展示联想词,点联想词跳转搜索结果页,若无匹配结果则展示推荐流,返回时搜索历史以标签形式横向铺开。点击历史直接发起搜索跳转到结果页。


技术选型


将搜索业务场景做了如下设计:


微信截图_20220902171024.png


搜索页用Activity来承载,它被分成两个部分,头部是常驻在 Activity 的搜索条。下面的“搜索体”用Fragment承载,它可能出现三种状态 1.搜索历史页 2.搜索联想页 3.搜索结果页。


Fragment 之间的切换采用 Jetpack 的Navigation


Navigation 封装了切换 Fragment 的细节,让开发者更轻松地实现界面切换。


它包含三个关键概念:



  1. Navigation graph:一个带标签的 xml 文件,用于配置页面及其包含的动作。

  2. NavHost:一个页面容器。

  3. NavController:页面跳转控制器,用于发起动作。


关于 Navigation 更详细的介绍可以点击Navigation 组件使用入门  |  Android 开发者  |  Android Developers


界面框架


class TemplateSearchActivity : AppCompatActivity() {
companion object {
const val NAV_HOST_ID = "searchFragmentContainer"
}
private lateinit var etSearch: EditText
private lateinit var tvSearch: TextView
private lateinit var ivClear: ImageView
private lateinit var ivBack: ImageView
private lateinit var vInputBg: View
private val contentView by lazy(LazyThreadSafetyMode.NONE) {
LinearLayout {
layout_width = match_parent
layout_height = match_parent
orientation = vertical
background_color = "#0C0D14"
fitsSystemWindows = true

// 搜索条
ConstraintLayout {
layout_width = match_parent
layout_height = wrap_content
// 返回按钮
ivBack = ImageView {
layout_id = "ivSearchBack"
layout_width = 7
layout_height = 14
scaleType = scale_fit_xy
start_toStartOf = parent_id
top_toTopOf = parent_id
margin_start = 22
margin_top = 11
src = R.drawable.search_back
onClick = { finish() }
}
// 搜索框背景
vInputBg = View {
layout_id = "vSearchBarBg"
layout_width = 0
layout_height = 36
start_toEndOf = "ivSearchBack"
align_vertical_to = "ivSearchBack"
end_toStartOf = ID_SEARCH
margin_start = 19.76
margin_end = 16
// 轻松定义圆角背景,省去新增一个 xml
shape = shape {
corner_radius = 54
solid_color = "#1AB8BCF1"
}
}
// 搜索框放大镜icon
ImageView {
layout_id = "ivSearchIcon"
layout_width = 16
layout_height = 16
scaleType = scale_fit_xy
start_toStartOf = "vSearchBarBg"
align_vertical_to = "vSearchBarBg"
margin_start = 16
src = R.drawable.template_search_icon
}
// 搜索框
etSearch = EditText {
layout_id = "etSearch"
layout_width = 0
layout_height = wrap_content
start_toEndOf = "ivSearchIcon"
end_toStartOf = ID_CLEAR_SEARCH
align_vertical_to = "vSearchBarBg"
margin_start = 7
margin_end = 12
textSize = 14f
textColor = "#F2F4FF"
imeOptions = EditorInfo.IME_ACTION_SEARCH
hint = "输入您想搜索的模板"
hint_color = "#686A72"
background = null
maxLines = 1
inputType = InputType.TYPE_CLASS_TEXT
}
// 搜索框尾部清空按钮
ivClear = ImageView {
layout_id = "ivClearSearch"
layout_width = 20
layout_height = 20
scaleType = scale_fit_xy
align_vertical_to = "vSearchBarBg"
end_toEndOf = "vSearchBarBg"
margin_end = 12
src = R.drawable.template_search_clear
// 搜索按钮
tvSearch = TextView {
layout_id = "tvSearch"
layout_width = wrap_content
layout_height = wrap_content
textSize = 14f
textColor = "#686A72"
text = "搜索"
gravity = gravity_center
align_vertical_to = "ivSearchBack"
end_toEndOf = parent_id
margin_end = 16
}
}
// 搜索体
FragmentContainerView {
layout_id = NAV_HOST_ID
layout_width = match_parent
layout_height = match_parent
NavHostFragment.create(R.navigation.search_navigation).also {
supportFragmentManager.beginTransaction()
.replace(NAV_HOST_ID.toLayoutId(), it)
.setPrimaryNavigationFragment(it)
.commit()
}
}
}
}

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(contentView)
initView()
}

private fun initView() {
// 绘制界面初始状态
tvSearch?.apply {
isEnabled = false
textColor = "#484951"
}
ivClear?.visibility = gone
KeyboardUtils.showSoftInputWithDelay(etSearch, 300)
}
}

搜索页是一个 Activity,它的根布局是一个纵向的 LinearLayout,其中上部是一个搜索条,下部是搜索体。上述代码使用了 Kotlin 的 DSL 使得可以用声明式的语法动态的构建视图,避免了 XML 的解析并加载到内存,以及 findViewById() 遍历查找时间复杂度,性能略好,但缺点是无法预览。


关于 运用 Kotlin DSL 动态构建布局的详细讲解可以点击Android性能优化 | 把构建布局用时缩短 20 倍(下)


这套构建布局的 DSL 源码可以在这里找到wisdomtl/Layout_DSL: Build Android layout dynamically with kotlin, get rid of xml file, which has poor performance (github.com)


其中 FragmentContainerView 作为 Fragment 的容器,将其和 NavHostFragment 以及一个 navigation 资源文件绑定:


<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/search_navigation"
app:startDestination="@id/SearchHistoryFragment">
<!--联想页-->
<fragment
android:id="@+id/SearchHintFragment"
android:name="com.bilibili.studio.search.template.fragment.SearchHintFragment"
android:label="search_hint_fragment">
<!--跳转结果页-->
<action
android:id="@+id/action_to_result"
app:destination="@id/SearchResultFragment" />
<!--跳转历史页-->
<action
android:id="@+id/action_to_history"
app:popUpTo="@id/SearchHistoryFragment" />
</fragment>
<!--历史页-->
<fragment
android:id="@+id/SearchHistoryFragment"
android:name="com.bilibili.studio.search.template.fragment.SearchHistoryFragment"
android:label="search_history_fragment">
<!--跳转结果页-->
<action
android:id="@+id/action_to_result"
app:destination="@id/SearchResultFragment" />
<!--跳转联想页-->
<action
android:id="@+id/action_to_hint"
app:destination="@id/SearchHintFragment" />
</fragment>
<!--结果页-->
<fragment
android:id="@+id/SearchResultFragment"
android:name="com.bilibili.studio.search.template.fragment.SearchResultFragment"
android:label="search_result_fragment">
<!--跳转历史页-->
<action
android:id="@+id/action_to_history"
app:popUpTo="@id/SearchHistoryFragment" />
<!--跳转联想页-->
<action
android:id="@+id/action_to_hint"
app:destination="@id/SearchHintFragment" />
</fragment>
</navigation>

navigation 文件定义了 Fragment 实体以及对应的 跳转行为 action。然后就能用NavController方便地进行 Fragment 的切换:


findNavController(NAV_HOST_ID.toLayoutId()).navigate(
R.id.action_history_to_result, // 预定义在 xml 中的 action
bundleOf("keywords" to event.keyword) // 携带跳转参数
)

支离破碎的刷新


了解了整个界面框架和技术选型之后,在不使用任何架构的情况下实现第一个业务界面——搜索条,看看会遇到哪些意想不到的坑。


看上去简单的搜索框,其实包含不少交互逻辑。


交互逻辑:进入搜索页时,搜索按钮置灰,隐藏清空按钮,搜索框获取焦点并自动弹出输入法:


飞书20220903-130310.jpg

用代码表达如下:


class TemplateSearchActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(contentView)
initView()
}

private fun initView() {
// 绘制界面初始状态
tvSearch.apply {
isEnabled = false
textColor = "#484951"
}
ivClear.visibility = gone
KeyboardUtils.showSoftInputWithDelay(etSearch, 300)
}
}

交互逻辑:当输入关键词时,显示清空按钮,并高亮搜索按钮:


飞书20220903-130555.gif


通过addTextChangedListener()监听输入框内容变化并做出视图调整:


class TemplateSearchActivity : AppCompatActivity() {
private fun initView() {
// 初始化时立刻被执行
tvSearch.apply {
isEnabled = false
textColor = "#484951"
}
ivClear.visibility = gone
// 监听输入框字符变化
etSearch.addTextChangedListener(object :TextWatcher{
override fun beforeTextChanged(s: CharSequence?,s: Int,c: Int,after: Int){}
// 将来的某个时间点被执行的逻辑
override fun onTextChanged(char: CharSequence?, s: Int, b: Int, c: Int) {
val input = char?.toString() ?: ""
// 显示 X,并高亮搜索
if(input.isNotEmpty()) {
ivClear.visibility = visible
tvSearch.apply {
textColor = "#F2F4FF"
isEnabled = true
}
}
// 隐藏 X,并置灰搜索
else {
ivClear.visibility = gone
tvSearch.apply {
textColor = "#484951"
isEnabled = false
}
}
}

override fun afterTextChanged(s: Editable?) {}
})

KeyboardUtils.showSoftInputWithDelay(etSearch, 300)
}
}

为了让语义更加明确,抽象出initView()来表示初始化视图。其中包含了的确在初始化会被立刻执行的逻辑,以及在将来某个时间点会执行的逻辑。


如果按照这个趋势发展下去,界面越来越复杂时,initView() 会越来越长。


项目中,超 1000 行的initView()initConfig()就是这样练成的。如此庞大的初始化方法中,很难找到你想要的东西。


将来的逻辑和现在的逻辑最好不要待在一起,特别是当将来的逻辑很复杂时(嵌套回调)。


上述代码还有一个问题,更新搜索按钮tvSearch的逻辑有两个分身,分别处于现在和将来。这增加了理解界面状态的难度。当刷新同一控件的逻辑分处在各种各样的回调中时,如何轻松地回答“控件在某一时刻应该长什么样?”这个问题。当发生界面状态不一致的 Bug 时,又该从哪个地方下手排查问题?


名不副实的子程序


交互逻辑:点击键盘上的搜索或搜索条右侧的搜索进入结果页时,搜索框拉长并覆盖搜索按钮:


飞书20220903-130619.gif


用代码表达如下:


class TemplateSearchActivity : AppCompatActivity() {
private fun initView() {
tvSearch.apply {
isEnabled = false
textColor = "#484951"
}
ivClear.visibility = gone
etSearch.addTextChangedListener(object :TextWatcher{
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
}

override fun onTextChanged(char: CharSequence?, start: Int, before: Int, count: Int) {
val input = char?.toString() ?: ""
if(input.isNotEmpty()) {
ivClear.visibility = visible
tvSearch.apply {
textColor = "#F2F4FF"
isEnabled = true
}
}else {
ivClear.visibility = gone
tvSearch.apply {
textColor = "#484951"
isEnabled = false
}
}
}

override fun afterTextChanged(s: Editable?) {
}
})
// 监听键盘搜索按钮
etSearch.setOnEditorActionListener { v, actionId, event ->
if (actionId == EditorInfo.IME_ACTION_SEARCH) {
val input = etSearch.text.toString() ?: ""
if(input.isNotEmpty()) { searchAndHideKeyboard() }
true
} else false
}
// 监听搜索条搜索按钮
tvSearch.setOnClickListener {
searchAndHideKeyboard()
}
KeyboardUtils.showSoftInputWithDelay(etSearch, 300)
}
// 跳转到搜索页 + 拉长搜索条 + 隐藏搜索按钮 + 隐藏键盘
private fun searchAndHideKeyboard() {
vInputBg.end_toEndOf = parent_id // 拉长搜索框(与父亲右边对齐)
runCatching {
findNavController(NAV_HOST_ID.toLayoutId()).navigate(
R.id.action_history_to_result,
bundleOf("keywords" to etSearch?.text.toString())
)
}
tvSearch.visibility = gone
KeyboardUtils.hideSoftInput(etSearch)
}
}

因为跳转到结果页有两个入口,为了复用代码,不得不抽象出一个方法叫searchAndHideKeyboard()


这个命名是糟糕的,因为它没有表达出方法内做的所有事情,或者说这个子程序的抽象是糟糕的,因为它包含了多个目的,不够单纯。



子程序应该有单一且明确的目的。—— 《代码大全》



单纯的子程序最大的好处是能提高复用度。


当需求稍加改动后,searchAndHideKeyboard()就无法被复用,比如另一个搜索场景中,点击搜索时不需要拉长搜索框,也不隐藏搜索按钮,而是将搜索按钮名称改为取消。


之所以该方法难以被复用,因为它的视角错了,它以当前业务为视角,抽象出当前业务下会发生的界面变化,遂该方法也只能被用于当前业务。


若以界面变化为视角,当搜索行为发生时,界面会发生三个维度的变化:1. 搜索框绘制效果变化 2. 输入法的显示状态变化 3. Fragment 的切换。以这样的视角做抽象就能提高代码的复用度,详细的实现细节会在后续篇章展开。


剪不断理还乱的耦合


交互逻辑:当从结果页返回时(系统返回键/搜索条清空键/点击搜索框),搜索条缩回原始长度,并展示搜索按钮:


飞书20220903-135702.gif


class TemplateSearchActivity : AppCompatActivity() {
private fun initView() {
tvSearch?.apply {
isEnabled = false
textColor = "#484951"
}
ivClear?.visibility = gone
etSearch?.addTextChangedListener(object :TextWatcher{
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
}

override fun onTextChanged(char: CharSequence?, start: Int, before: Int, count: Int) {
val input = char?.toString() ?: ""
if(input.isNotEmpty()) {
ivClear?.visibility = visible
tvSearch?.apply {
textColor = "#F2F4FF"
isEnabled = true
}
}else {
ivClear?.visibility = gone
tvSearch?.apply {
textColor = "#484951"
isEnabled = false
}
}
}

override fun afterTextChanged(s: Editable?) {
}
})
etSearch?.setOnEditorActionListener { v, actionId, event ->
if (actionId == EditorInfo.IME_ACTION_SEARCH) {
val input = etSearch?.text?.toString() ?: ""
if(input.isNotEmpty()) {
searchAndHideKeyboard()
}
true
} else false
}
tvSearch?.setOnClickListener {
searchAndHideKeyboard()
}
// 监听清空按钮
ivClear?.setOnClickListener {
etSearch?.text = null
etSearch?.requestFocus()
// 弹键盘
KeyboardUtils.showSoftInputWithDelay(etSearch, 300)
// 回到历史页
backToHistory()
}
// 监听搜索框触摸事件并回到历史页
etSearch?.setOnTouchListener { v, event ->
if (event.action == MotionEvent.ACTION_DOWN) {
backToHistory()
}
false
}

KeyboardUtils.showSoftInputWithDelay(etSearch, 300)
}
// 监听系统返回并回退到历史页
override fun onBackPressed() {
super.onBackPressed()
backToHistory()
}

private fun backToHistory() {
runCatching {
findNavController(NAV_HOST_ID.toLayoutId()).currentDestination?.takeIf { it.id == R.id.SearchResultFragment }?.let {
// 还原搜索框和搜索按钮
tvSearch?.visibility = visible
vInputBg?.end_toStartOf = "tvSearch"
}
// 弹出结果页回到历史页
findNavController(NAV_HOST_ID.toLayoutId()).navigate(R.id.action_to_history)
}
}
}

这段代码和上一小节有着同样的问题,即在 Activity 中面向业务抽象出各种名不副实且难以复用的子程序。


backToHistory()还加重了这个问题,因为它和searchAndHideKeyboard()是耦合在一起的,分别表示进入搜索结果页和从结果页返回时搜索条的界面交互逻辑。


交互逻辑是易变的,当它发生变化时,就得修改两个地方,漏掉一处,就会产生 Bug。


修改一个子程序后,另一个子程序出 Bug 了。你说恐怖不恐怖?


总结


整个过程没有 ViewModel 的影子,也没有 Model 的影子。


Model,模型(名词)。Trygve Reenskaug,MVC 概念的发明者,在 1979 年就对 MVC 中的 M 下过这样的结论:



The View observes the Model for changes. —— Trygve Reenskaug



上面的代码没有抽象出一个模型用来表达界面的状态变化,界面也没有发送任何指令给 ViewModel,而是在 Activity 中独自消化了所有的业务逻辑。


这是很多 MVVM 在项目中的现状:只要不牵涉到网络请求,则业务逻辑都写在 View 层。这样的代码好写,不好懂,也不好改。


显而易见的坏处是,Activity 变得复杂(复杂度在一个类中被铺开),代码量增多,随之而来的是理解成本增加。


除了业务逻辑和界面展示混合在一起的复杂度之外,上述代码还有另一个复杂度:


代码中对搜索按钮tvSearch的引用有将近10处,且散落在代码的各个角落,有些在初始化initView()中、有些在输入框回调onTextChange()中、有些在业务方法searchAndHideKeyboard()中、还有些在系统返回回调onBackPress()中。在不同的地方对同一个控件做出“是否显示”、“字体颜色”、“是否可点击”等状态的修改。



如果有人在阅读你的代码时不得不搜索整个应用程序以便找到所需的信息,那么就应该重新组织你的代码了。——《代码大全》



这样的写法无法简明扼要地回答“搜索按钮应该长什么样?”这个问题。你不得不搜索整段代码中所有对它的引用,才能拼凑出问题答案。这样是吃力的!


关于“界面该长什么样”这个问题的答案应该内聚在一个点,Flutter 以及 Compose 就是这样降低复杂度的,即 View 和 Model 强绑定,且内建了 View 随 Model 变化而变化的刷新机制。(也可以使用 MVI 实现类似的效果)


除此之外,这样写还有一个坏处是“容易改出 bug”。当需求变更,假设换了一种搜索按钮高亮颜色,可能会发生“没改全”的 bug,因为决定按钮颜色的代码不止一处。


用一张图来表达所有的复杂度在 Activity 层铺开:


微信截图_20220903170226.png


本篇用无架构的方式完成了搜索条的实现。搜索历史及结果的无架构实现会在后续篇章中展开。


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

奶奶个腿!来了个40岁的项目经理,给人整自闭了

我开发的第一个正式的系统,是我做得最难受的一个系统,难受的地方,来自项目经理。第一个项目第一个正式的开发任务,是在我正式参加工作的第二周接到的,公司准备做一个内部的考勤管理系统。在这之前,我只做过毕业设计之类的小项目,开发模式基本是:自己提需求,自己完成。考勤...
继续阅读 »

我开发的第一个正式的系统,是我做得最难受的一个系统,难受的地方,来自项目经理。

第一个项目

第一个正式的开发任务,是在我正式参加工作的第二周接到的,公司准备做一个内部的考勤管理系统。

在这之前,我只做过毕业设计之类的小项目,开发模式基本是:自己提需求,自己完成。

考勤系统,算是第一个正式的、有用户使用的系统。

功能大致包括考勤、请假、工资结算、各种内部的报表功能...

项目的人员配备:1个项目经理、2个中级开发、2个实习生,我就是其中的实习生之一。

由于是第一个项目,当时经验还十分欠缺,连“业务”二字是什么意思都搞不明白,项目用到的技术栈也不熟悉(即便在这之前给了一周的时间给新人做培训)。

并且没有前端开发人员,所以前端代码也需要自己写,一时间貌似要学很多的东西,压力还是挺大的。

最开始是一名中级开发带着我做,相当于找了个“师傅”带带,他会给我分配一些简单的开发任务,很多不明白的地方他都会给我讲,所以虽然感觉到比较艰难,但也不是扛不住。

但是情况在一个月后发生了变化,随着工期越来越紧,大家工作量都开始加大。

很多业务和设计上的问题,这位中级开发也不清楚,就让我直接去对接项目经理,直接和负责人沟通。

由于不是核心项目,公司在人员配比上没有安排产品经理,所以系统界面风格、操作流程,全都是项目经理一个人负责把控。

当实习生直面项目经理的时候,问题就出现了... ...

我听不懂他在说啥

项目经理大概有四十岁左右,看上去比较严肃,据说以前是做C++的,后来才转的Java,从事开发很多年了。

他给我分配任务的方式,和之前带我的“师傅”,就有些不一样。

每次我听他给我讲完之后,我基本不知道该怎么动手。

在这之前,“师傅”会给我讲,这个功能需要做哪几个页面,可参考哪里的功能、需要有增删改、还需要加几个xx功能的弹框,大概需要用到哪些表。

听完这些,我就能自己鼓捣出一个大概的功能雏形,完成之后再找“师傅”看一看,不对的地方再修改,完全可以正常推进工作。

而项目经理就讲得粗糙一些,他会给我讲:这个功能未来谁会使用、需求是谁提出来的、最后要达到什么效果,基本上到这里就结束了。

用到的表,我需要自己根据文档、数据库、相关功能,去连蒙带猜自己找,界面功能应该是怎样的操作流程也不清楚,只能根据系统的整体风格,按照自己的想法来做。

通常一个功能,需要前前后后调整很多次,开发起来非常难受。

我印象非常深刻的一次是,当时遇到一个不清楚的问题:界面上某个内容输入到底是用下拉框还是输入框?如果是下拉框,相关参数我应该从哪里获取?

我就去询问项目经理,这位老大哥听完我的问题之后,就开始给我讲这个需求是谁提的,这个功能是做什么用的,相关联的功能大概有哪些,最终要方便使用人员达到什么目的...

一通讲述下来,二十分钟过去了,我回到工位上的时候,脑子都是蒙的:他给我讲的啥?咋没听懂?xxx又是一个什么新的概念?

本来我已经知道那个功能应该怎么做了,听他一番话之后,我突然不明白他希望我做些什么。

他给我讲那些内容,对于我的开发工作有帮助吗?

而且,我只想问一下,界面上如果用下拉框,那么下拉框的数据从哪里获取。

他完全没有回答我,我去问了个寂寞。

是不是我的理解能力有些问题?我忍不住开始怀疑自己的理解能力。

不止我听不懂

这时候,我把困惑给另一个实习生讲了一下,他就坐在我旁边,我俩交流很多。

“头疼,我之前也问了xx,也听不懂他在说什么,给我扯一大堆。”他这样回答我。

我当时松了一口气,貌似... 不是我的问题呀。

后来这个系统缝缝补补、修修改改,折腾了好几个月。

最后连带我的“师傅”,也开始抱怨难搞呀。

那段时间,几乎就是靠熬,硬撑过去的。

大家在一个没有做到把控全局的项目经理的带领下,都感觉到了艰难,系统应该怎么做,似乎他也不太清楚。

需求没有理清楚、操作流程没有想清楚、给下面的成员讲不清楚...

这就导致了开发时间的大量浪费,返工很多。

这就是我印象里,做的第一个系统,可以说非常难受了。

其实这位老大哥只是不怎么会项目管理,他自己干活还是非常多的,一点都不偷懒,但就是没有把这个小项目给统筹好。

这件事给我的启发是:除了提升硬实力,软技能也不容忽视,别只知道埋头工作,也要抽时间补一补自己的短板呀。

好啦,今天就唠到这里,咱们下期见~

来源:了不起的程序员(ID:great_developer)

收起阅读 »

由点汇聚成字的动效炫极了

前言在引入 fl_chart 绘制图表的时候,看到插件有下面这样的动效,随机散乱的圆点最后组合成了 Flutter 的 Logo,挺酷炫的。本篇我们来探讨类似的效果怎么实现。点阵在讲解代码实现之前,我们先科普一个知识,即点阵。点阵在日常生活中很常见,比如广告屏...
继续阅读 »

由点汇聚成字的动效炫极了

前言

在引入 fl_chart 绘制图表的时候,看到插件有下面这样的动效,随机散乱的圆点最后组合成了 Flutter 的 Logo,挺酷炫的。本篇我们来探讨类似的效果怎么实现。

logo 动画.gif

点阵

在讲解代码实现之前,我们先科普一个知识,即点阵。点阵在日常生活中很常见,比如广告屏,停车系统的显示,行业内称之为 LED 显示屏。

image.png

LED 显示屏实际上就是由很多 LED 灯组合成的一个显示面板,然后通过显示驱动某些灯亮,某些灯灭就可以实现文字、图形的显示。LED 显示屏的点距足够小时,色彩足够丰富时其实就形成了我们日常的显示屏,比如 OLED 显示屏其实原理也是类似的。之前报道过的大学宿舍楼通过控制每个房间的灯亮灯灭来形成文字的原理也是一样的。

image.png

现在来看看 LED显示文字是怎么回事,比如我们要 显示岛上码农的“岛”字,在16x16的点阵上,通过排布得到的就是下面的结果(不同字体的排布会有些差别)。

因为每一行是16个点,我们可以对应为16位二进制数,把黑色的标记为1,灰色的标记为0,每一行就可以得到一个二进制数。比如上面的第一行第8列为1,其他都是0,对应的二进制数就是0000000100000000,对应的16进制数就是0x0100。把其他行也按这种方式计算出来,最终得到的“岛”字对应的是16个16进制数,如下所示。

 [
0x0100, 0x0200, 0x1FF0, 0x1010,
0x1210, 0x1150, 0x1020, 0x1000,
0x1FFC, 0x0204, 0x2224, 0x2224,
0x3FE4, 0x0004, 0x0028, 0x0010
];
复制代码

又了这个基础,我们就可以用 Flutter 绘制点阵图形。

点阵图形绘制

首先我们绘制一个“LED 面板”,也就是绘制一个有若干个点构成的矩阵,这个比较简单,保持相同的间距,逐行绘制相同的圆即可,比如我们绘制一个16x16的点阵,实现代码如下所示。

var paint = Paint()..color = Colors.grey;
final dotCount = 16;
final fontSize = 100.0;
var radius = fontSize / dotCount;
var startPos =
Offset(size.width / 2 - fontSize, size.height / 2 - 2 * fontSize);
for (int i = 0; i < dotCount; ++i) {
var position = startPos + Offset(0.0, radius * i * 2);
for (int j = 0; j < dotCount; ++j) {
var dotPosition = startPos + Offset(radius * 2 * j, position.dy);
canvas.drawCircle(dotPosition, radius, paint);
}
}
复制代码

绘制出来的效果如下:

image.png

接下来是点亮对应的位置来绘制文字了。上面我们讲过了,每一行是一个16进制数,那么我们只需要判断每一行的16进制数的第几个 bit是1就可以了,如果是1就点亮,否则不点亮。点亮的效果用不同的颜色就可以了。 怎么判断16进制数的第几个 bit 是不是1呢,这个就要用到位运算技巧了。实际上,我们可以用一个第 N 个 bit 是1,其他 bit 都是0的数与要判断的数进行“位与”运算,如果结果不为0,说明要判断的数的第 N 个 bit 是1,否则就是0。听着有点绕,看个例子,我们以0x0100为例,按从第0位到第15位逐个判断第0位和第15位是不是1,代码如下:

for (i = 0 ; i < 16; ++i) {
if ((0x0100 & (1 << i)) > 0) {
// 第 i 位为1
}
}
复制代码

这里有两个位操作,1 << i是将1左移 i 位,为什么是这样呢,因为这样可以构成0x0001,0x0002,0x0004,...,0x8000等数字,这些数字依次从第0位,第1位,第2位,...,第15位为1,其他位都是0。然后我们用这样的数与另外一个数做位与运算时,就可以依次判断这个数的第0位,第1位,第2位,...,第15位是否为1了,下面是一个计算示例,第11位为1,其他位都是0,从而可以 判断另一个数的第11位是不是0。

位与运算

通过这样的逻辑我们就可以判断一行的 LED 中第几列应该点亮,然后实现文字的“显示”了,实现代码如下。wordHex是对应字的16个16进制数的数组。dotCount的值是16,用于控制绘制16x16大小的点阵。每隔一行我们向下移动一段直径距离,每隔一列,我们向右移动一段直径距离。然后如果当前绘制位置的数值对应的 bit位为1,就用蓝色绘制,否则就用灰色绘制。这里说一下为什么左移的时候要用dotCount - j - 1,这是因为绘制是从左到右的,而16进制数的左边是高位,而数字j是从小到大递增的,因此要通过这种方式保证判断的顺序是从高位(第15位)到低位(第0位),和绘制的顺序保持一致。

 for (int i = 0; i < dotCount; ++i) {
var position = startPos + Offset(0.0, radius * i * 2);
for (int j = 0; j < dotCount; ++j) {
var dotPosition = startPos + Offset(radius * 2 * j, position.dy);

if ((wordHex[i] & ((1 << dotCount - j - 1))) != 0) {
paint.color = Colors.blue[600]!;
canvas.drawCircle(dotPosition, radius, paint);
} else {
paint.color = Colors.grey;
canvas.drawCircle(dotPosition, radius, paint);
}
}
}
复制代码

绘制的结果如下所示。

image.png

由点聚集成字的动画实现

接下来我们来考虑如何实现开篇说的类似的动画效果。实际上方法也很简单,就是先按照文字应该“点亮”的 LED 的数量,先在随机的位置绘制这么多数量的 LED,然后通过动画控制这些 LED 移动到目标位置——也就是文字本该绘制的位置。这个移动的计算公式如下,其中 t 是动画值,取值范围为0-1.

移动公式

需要注意的是,随机点不能在绘图过程生成,那样会导致每次绘制产生新的随机位置,也就是初始位置会变化,导致上面的公式实际不成立,就达不到预期的效果。另外,也不能在 build 方法中生成,因为每次刷新 build 方法就会被调用,同样会导致初始位置发生变化。所以,生成随机位置应该在 initState方法完成。但是又遇到一个新问题,那就是 initState方法里没有 context,拿不到屏幕宽高,所以不能直接生成位置,我们只需要生成一个0-1的随机系数就可以了,然后在绘制的时候在乘以屏幕宽高就得到实际的初始位置了。初始位置系数生成代码如下:

@override
void initState() {
super.initState();
var wordBitCount = 0;
for (var hex in dao) {
wordBitCount += _countBitOne(hex);
}
startPositions = List.generate(wordBitCount, (index) {
return Offset(
Random().nextDouble(),
Random().nextDouble(),
);
});
...
}
复制代码

wordBitCount是计算一个字中有多少 bit 是1的,以便知道要绘制的 “LED” 数量。接下来是绘制代码了,我们这次对于不亮的直接不绘制,然后要点亮的位置通过上面的位置计算公式计算,这样保证了一开始绘制的是随机位置,随着动画的过程,逐步移动到目标位置,最终汇聚成一个字,就实现了预期的动画效果,代码如下。

void paint(Canvas canvas, Size size) {
final dotCount = 16;
final fontSize = 100.0;
var radius = fontSize / dotCount;
var startPos =
Offset(size.width / 2 - fontSize, size.height / 2 - fontSize);
var paint = Paint()..color = Colors.blue[600]!;

var paintIndex = 0;
for (int i = 0; i < dotCount; ++i) {
var position = startPos + Offset(0.0, radius * i * 2);
for (int j = 0; j < dotCount; ++j) {
// 判断第 i 行第几位不为0,不为0则绘制,否则不绘制
if ((wordHex[i] & ((1 << dotCount - j))) != 0) {
var startX = startPositions[paintIndex].dx * size.width;
var startY = startPositions[paintIndex].dy * size.height;
var endX = startPos.dx + radius * j * 2;
var endY = position.dy;
var animationPos = Offset(startX + (endX - startX) * animationValue,
startY + (endY - startY) * animationValue);
canvas.drawCircle(animationPos, radius, paint);
paintIndex++;
}
}
}
}
复制代码

来看看实现效果吧,是不是很酷炫?完整源码已提交至:绘图相关源码,文件名为:dot_font.dart

点阵汇聚文字动画.gif

总结

本篇介绍了点阵的概念,以及基于点阵如何绘制文字、图形,最后通过先绘制随机点,再汇聚成文字的动画效果。可以看到,化整为零,再聚零为整的动画效果还是蛮酷炫的。实际上,基于这种方式,可以构建更多有趣的动画效果。

作者:岛上码农

来源:juejin.cn/post/7120233450627891237

收起阅读 »

国内及出海企业如何抓住Discord社交红利

一、活动背景2010 年,中国企业吹响了出海远航的号角。10 余年间热门赛道几经嬗变,当工具产品热潮褪去,跨境电商、游戏、社交文娱等轮番登场。到 20 年代,出海对于中国企业来说,不是选择,而成为了必须。2021 年度,全球移动游戏市场收入达 907 亿美元,...
继续阅读 »

一、活动背景
2010 年,中国企业吹响了出海远航的号角。10 余年间热门赛道几经嬗变,当工具产品热潮褪去,跨境电商、游戏、社交文娱等轮番登场。到 20 年代,出海对于中国企业来说,不是选择,而成为了必须。2021 年度,全球移动游戏市场收入达 907 亿美元,中国以 313.7 亿美元稳居全球首位。国内游戏企业在海外大杀特杀,类Discord社区也正逐渐成为各大游戏厂商的运营标配。
据数据显示,2017~2022年,中国社交网络的增长速度不会低于10%,到2022年该行业的市场规模将接近500亿元。增量空间虽然明显,但存量空间早已是一片红海,因此,如何挖掘、探索新兴垂直细分领域及相关市场,如何通过一站式的技术能力提升用户体验,便成为企业生存与发展的关键所在。与此同时,微信已发布10年有余,十年一次系统性的机会,趁着海外Discord的强劲势头,国内也到了诞生新社交巨头的窗口期。
在此背景下,环信新晋的 IM 明星产品【环信超级社区】2.0 版本正式出道与大家见面了~,旨在一站式帮助客户快速开发和构建稳定超大规模用户即时通讯的"类Discord超级社区",作为构建实时交互社区的第一选择,环信超级社区自发布以来很好地满足了类 Discord 实时社区业务场景的客户需求,并支持开发者结合业务需要灵活自定义产品形态,目前已经广泛服务于国内头部出海企业以及海外东南亚和印度企业。
此次公开课以《国内及出海企业如何抓住 Discord 社交红利》为主题,重点聚焦国内及出海企业关注「类 Discord」的热门场景、赛道选择、运营创新玩法、出海安全合规,以及环信超级社区的技术优势和业务面临的挑战等问题,助力企业在市场上抢占先机,在众多竞争者中找到一片属于自己的蓝海。

01.活动流程


19:00-19:40   主题演讲

19:40-19:45   QA环节

19:45-20:00   直播间抽奖

 20:00            直播结束 


02. 演讲大纲

  • 互联网社交沟通模型的演变

  • 环信超级社区是什么?功能点/技术优势/落地场景有哪些

  • 如何做好一款类discord产品(赛道的选择)

  • 类Discord产品运营的最佳姿势(运营是难点)

  • 国内及企业出海做超级社区,环信可以提供什么?

  • 超级社区业务面临的一些挑战(技术角度讲解:群成员数量、消息分发量的急增、业务承载挑战、单条消息的处理(普通群和超级群的对比)

  • 环信在出海安全合规和技术服务方面的最佳实践


03. 听众收益

  • 了解超级社区模型的应用场景,为产品增长注入新的动力

  • 了解超级社区的赛道选择和运营玩法

  • 了解超大规模社区群组的技术实现原理

  • 出海企业如何利用超级社区实现商业成功

  • 海外社交产品的安全合规实战经验


04. 福利活动




收起阅读 »

五子棋AI进阶:极大极小值搜索

AI
前言 上篇文章,介绍了一下五子棋 AI 的入门实现,学完之后能用,就是 AI 还太年轻,只能思考一步棋。 本文将介绍一种提高 AI 思考能力的算法:极大极小值算法。 Minimax算法 又名极小化极大算法,是一种找出失败的最大可能性中的最小值的算法(即最小...
继续阅读 »

前言


上篇文章,介绍了一下五子棋 AI 的入门实现,学完之后能用,就是 AI 还太年轻,只能思考一步棋。


image.png


本文将介绍一种提高 AI 思考能力的算法:极大极小值算法



Minimax算法 又名极小化极大算法,是一种找出失败的最大可能性中的最小值的算法(即最小化对手的最大得益)。通常以递归形式来实现。
Minimax算法常用于棋类等由两方较量的游戏和程序。该算法是一个零总和算法,即一方要在可选的选项中选择将其优势最大化的选择,另一方则选择令对手优势最小化的一个,其输赢的总和为0(有点像能量守恒,就像本身两个玩家都有1点,最后输家要将他的1点给赢家,但整体上还是总共有2点)。 —— 百度百科



极大极小值搜索算法


算法实现原理


对于五子棋游戏来说,如果 AI 执黑子先下,那么第一步 AI 共有 225 种落子方式,AI 落子到一个点后,表示 AI 回合结束,换到对手(白子)落子,这时对手共有 224 种落子方式。我们可以将 AI 和对手交替落子形成的所有情况穷举出来,这样就形成了一棵树,叫做 博弈树


但是,穷举出所有情况太不现实了,这颗 博弈树 最后一层节点数就有 225! ,这个数字是特别庞大的,数字10后边要加432个0!!!这程序运行起来,电脑还要不要了?


image.png


所以,我们只考虑2步棋或4步棋的情况。


image.png


如图所示,我只列举出了走4步棋所形成的部分情况。A0 是起点,AI 将在这个点中选择出最佳的落子点位。A0 下面有两个分支(实际有225个分支,这里放不下,就只演示2个)A1A2,这两个分支表示的就是 AI 第一步落子的两种情况。


A1 如果落子到 (0,0),则当前局面就如下图所示


image.png


A2 如果落子到 (0,1),则当前局面就如下图所示


image.png


AI 落子完后,就轮到对方落子了。在 A1 分支中,对方有 B1B2 两种落子情况(实际有224种)


B1 情况如图所示


image.png


B2 情况如图所示


image.png


一直到第4步落子完时,B5 的局面就会像下图这样


image.png


要知道,这颗 博弈树 是以 AI 的角度建立的,AI 为了赢,它需要从 A1A2 分支中,选择一个对自己最有利的落子点,而 A1A2 分支的好坏需要它们下面的 B1B2B3B4 决定,所以说,下层分支的局面会影响上层分支的选择。


要确定 A1A2 分支哪个好,我们必须从这个分支的最深层看起。


image.png


B5 ~ B12 节点的局面是由对方造成的,我们就假设对方很聪明,他一定能选择一个最有利于他自己的落子点。怎么知道哪个落子点好?还是和之前一样,用评估函数评估一下,分高的就好呗,但有一点不同的是,之前评估的是一个点,现在需要评估一个局面,怎么评估本文后面会提到。


假设 B5 ~ B12 中 各个节点的得分如下图最底部所示


image.png


A3 节点得分为 0A4 节点得分为 1A5 节点得分为 3A6 节点得分为 2。这就很奇怪了,不是说让选得分最大的吗?这怎么都选的最小的得分???


这其实还是要从评估函数说起,因为我们现在的评估函数都是从 AI 角度出发的,评估的得分越高,只会对 AI 有利,对对方来说是不利的。所以,当是对方的分支的时候,我们要选得分最低的节点,因为 AI 要站在对方的角度去做选择,换位思考。这里如果还是没有搞懂的话,我们可以这么理解:



假如张三遇到了抢劫犯,他认为他身上值钱的东西有:《Java从入门到入土》、1000元现金、某厂月薪3.5K包吃包住的Offer。现在抢劫犯要抢劫他身上的一样东西,如果站在张三的角度思考的话,那肯定是让抢《Java从入门到入土》这本破书了,但是站在抢劫犯的角度思考,1000元现金比什么都强!



image.png


这就是思考角度的问题,对方如果很聪明,那他肯定是选择让 AI 利益最低的一个节点,现在我们就认为对方是一个绝顶聪明的人,所以在对方选择的分支里都选择了分值最低的,好让 AI 的利益受损。


再接下去就是 AI 选择分支了,不用说,AI 肯定选分高的。AI 要从对方给的那些低分分支里选择分最高的,也就是差的里面选好的。所以 B1 得分为 1B2 得分为 3


image.png


后面也是一样的流程,又轮到对方选择了,对方肯定选择 B1 分支,B1 分支是得分最低的节点,所以到最后,A1 分支的最终得分为 1


image.png


我们对 A2 分支也做如上操作:AI 选高分,对方选低分。最后可以得出如下图所示的结果


image.png


现在我们知道 A1 最终得分为 1A2 最终得分为 2,因为 AI 会选择最大得分的分支 A2,所以最终 A0 得分为 2,也就是说,AI 下一步的最佳落子点为 (0,1)


image.png


image.png


AI 选择的分支一定是选最高分值的叫做 Max 分支,对方选择的分支一定是选最低分值的叫做 Min 分支,然后由低到高,倒推着求出起点的得分,这就是 极大极小值搜索 的实现原理。


image.png


代码实现


我们接着上次的代码来,在 ZhiZhangAIService 类中定义一个全局变量 bestPoint 用于存放 AI 当前最佳下棋点位,再定义一个全局变量 attack 用于设置 AI 的进攻能力。


    /**
* AI最佳下棋点位
*/
private Point bestPoint;
/**
* 进攻系数
*/
private int attack;

新增 minimax 方法,编写 极大极小值搜索 算法的实现代码。这里是使用递归的方式,深度优先遍历 博弈树,生成树和选择节点是同时进行的。type 表示当前走棋方,刚开始时,因为要从根节点开始生成树,所以要传入 0 ,并且 AI 最后选择高分节点的时候也是在根节点进行的。depth 表示搜索的深度,也就是 AI 思考的步数
,我这边传入的是 2,也就是只思考两步棋,思考4步或6步都行,只要你电脑吃得消(计算量很大的哦)。



/**
* 极大极小值搜索
*
* @param type 当前走棋方 0.根节点表示AI走棋 1.AI 2.玩家
* @param depth 搜索深度
* @return
*/
private int minimax(int type, int depth) {
// 是否是根节点
boolean isRoot = type == 0;
if (isRoot) {
// 根节点是AI走棋
type = this.ai;
}

// 当前是否是AI走棋
boolean isAI = type == this.ai;
// 当前分值,
int score;
if (isAI) {
// AI因为要选择最高分,所以初始化一个难以到达的低分
score = -INFINITY;
} else {
// 对手要选择最低分,所以初始化一个难以到达的高分
score = INFINITY;
}

// 到达叶子结点
if (depth == 0) {
/**
* 评估每棵博弈树的叶子结点的局势
* 比如:depth=2时,表示从AI开始走两步棋之后的局势评估,AI(走第一步) -> 玩家(走第二步),然后对局势进行评估
* 注意:局势评估是以AI角度进行的,分值越大对AI越有利,对玩家越不利
*/
return evaluateAll();
}

for (int i = 0; i < this.cols; i++) {
for (int j = 0; j < this.rows; j++) {
if (this.chessData[i][j] != 0) {
// 该处已有棋子,跳过
continue;
}

/* 模拟 AI -> 玩家 交替落子 */
Point p = new Point(i, j, type);
// 落子
putChess(p);
// 递归生成博弈树,并评估叶子结点的局势获取分值
int curScore = minimax(3 - type, depth - 1);
// 撤销落子
revokeChess(p);

if (isAI) {
// AI要选对自己最有利的节点(分最高的)
if (curScore > score) {
// 最高值被刷新
score = curScore;
if (isRoot) {
// 根节点处更新AI最好的棋位
this.bestPoint = p;
}
}
} else {
// 对手要选对AI最不利的节点(分最低的)
if (curScore < score) {
// 最低值被刷新
score = curScore;
}
}
}
}

return score;
}

新增模拟落子 putChess 和撤销落子 revokeChess 等方法。



/**
* 下棋子
*
* @param point 棋子
*/
private void putChess(Point point) {
this.chessData[point.x][point.y] = point.type;
}

/**
* 撤销下的棋子
*
* @param point 棋子
*/
private void revokeChess(Point point) {
this.chessData[point.x][point.y] = 0;
}

新增一个评估函数 evaluateAll ,用于评估一个局面。这个评估函数实现原理为:搜索棋盘上现在所有的已落子的点位,然后调用之前的评估函数 evaluate 对这个点进行评分,如果这个位置上是 AI 的棋子,则加上评估的分值,是对方的棋子就减去评估的分值。注意这里有个进攻系数 attack,这个值我现在设定的是 2,如果这个值太低或太高都会影响 AI 的判断,我这边经过测试,觉得设置为 2 会比较好点。最后就是将 AI 所有棋子的总得分乘以进攻系数,再减去对手所有棋子的总得分,作为本局面的得分。



/**
* 以AI角度对当前局势进行评估,分数越大对AI越有利
*
* @return
*/
private int evaluateAll() {
// AI得分
int aiScore = 0;
// 对手得分
int foeScore = 0;

for (int i = 0; i < this.cols; i++) {
for (int j = 0; j < this.rows; j++) {
int type = this.chessData[i][j];
if (type == 0) {
// 该点没有棋子,跳过
continue;
}

// 评估该棋位分值
int val = evaluate(new Point(i, j, type));
if (type == this.ai) {
// 累积AI得分
aiScore += val;
} else {
// 累积对手得分
foeScore += val;
}
}
}

// 该局AI最终得分 = AI得分 * 进攻系数 - 对手得分
return aiScore * this.attack - foeScore;
}

调整 AI 入口方法 getPoint,现在使用 minimax 方法获取 AI 的最佳落子点位。


    @Override
public Point getPoint(int[][] chessData, Point point, boolean started) {
initChessData(chessData);
this.ai = 3 - point.type;
this.bestPoint = null;
this.attack = 2;

if (started) {
// AI先下,首子天元
int centerX = this.cols / 2;
int centerY = this.rows / 2;
return new Point(centerX, centerY, this.ai);
}

// 基于极大极小值搜索获取最佳棋位
minimax(0, 2);

return this.bestPoint;
}

测试一下,因为现在的 AI 可以思考两步棋了,所以比之前厉害了许多。


image.png


但是,又因为要搜索很多个节点,所以响应耗时也变长了很多,思考两步的情况下,平均响应时间在 3s 左右。


image.png


再去和大佬的 AI 下一把(gobang.light7.cn/#/),思考两步棋的 AI 执黑子先下,已经可以很轻松的打败大佬的普通级别的 AI 了。


image.png


AI 执白后下的话,连萌新级别的都打不赢,这个应该是评估模型的问题,后续需要对评估模型做进一步的优化。


现在写的搜索算法,如果要让 AI 思考4步棋的话,我这普通电脑还是吃不消的,后续对搜索算法还有更多的优化空间。


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

教你写一个入门级别的五子棋AI

AI
前言 本文只是介绍五子棋AI的实现,最终的成品只是一个 AI 接口,并不包括 GUI,且不依赖 GUI。 五子棋 AI 的实现并不难,只需要解决一个问题就行: 怎么确定AI的最佳落子位置? 一般情况下,五子棋棋盘是由15条横线和15条纵线组合而成的,15...
继续阅读 »

前言



本文只是介绍五子棋AI的实现,最终的成品只是一个 AI 接口,并不包括 GUI,且不依赖 GUI



五子棋 AI 的实现并不难,只需要解决一个问题就行:


怎么确定AI的最佳落子位置?


image.png


一般情况下,五子棋棋盘是由15条横线和15条纵线组合而成的,15x15 的棋盘共有 225 个交叉点,也就是说共有 225 个落子点。


假如说,AI 是黑棋,先行落子,所以 AI 总共有 225 个落子点可以选择,我们可以对每个落子点进行评估打分,哪个分高下哪里,这样我们就能确定最佳落子点了。


但这样又引出了一个新的问题:


怎么对落子点进行评估打分呢?


这就是本文的重点了,请看后文!


image.png


实现过程


抽象



注:部分基础代码依赖于 lombok,请自行引入,或手写基础代码。



落子位置实体类,这里我们定义棋子类型字段:type1表示黑子,2表示白子。


/**
* 棋子点位
*
* @author anlingyi
* @date 2021/11/10
*/

@AllArgsConstructor
@NoArgsConstructor
@ToString
public class Point {
/**
* 横坐标
*/

int x;
/**
* 纵坐标
*/

int y;
/**
* 棋子类型 1.黑 2.白
*/

int type;
}

AI 对外提供的接口,不会依赖任何 GUI 代码,方便其他程序调用。


/**
* 五子棋AI接口
*
* @author anlingyi
* @date 2021/11/10
*/

public interface AIService {

/**
* 获取AI棋位
*
* @param chessData 已下棋子数据
* @param point 对手棋位
* @param started 是否刚开局
* @return
*/

Point getPoint(int[][] chessData, Point point, boolean started);

}

这个接口需要知道我们现在的棋盘落子数据 chessData,还有对手上一步的落子位置 pointstarted 参数表示是否是刚开局,后续可能对刚开局情况做单独的处理。


实现AI接口


我们创建一个类 ZhiZhangAIService,这个类实现 AIService 接口,来写我们的实现逻辑。


/**
*
* 五子棋AI实现
*
* @author anlingyi
* @date 2021/11/10
*/

public class ZhiZhangAIService implements AIService {

/**
* 已下棋子数据
*/

private int[][] chessData;
/**
* 棋盘行数
*/

private int rows;
/**
* 棋盘列数
*/

private int cols;
/**
* AI棋子类型
*/

private int ai;

/**
* 声明一个最大值
*/

private static final int INFINITY = 999999999;

@Override
public Point getPoint(int[][] chessData, Point point, boolean started) {
// 初始化棋盘数据
initChessData(chessData);
// 计算AI的棋子类型
this.ai = 3 - point.type;

if (started) {
// AI先下,首子天元
int centerX = this.cols / 2;
int centerY = this.rows / 2;
return new Point(centerX, centerY, this.ai);
}

// 获取最佳下棋点位
return getBestPoint();
}

/**
* 初始化棋盘数据
*
* @param chessData 当前棋盘数据
*/

private void initChessData(int[][] chessData) {
// 获取棋盘行数
this.rows = chessData.length;
// 获取棋盘列数
this.cols = chessData[0].length;
// 初始化棋盘数据
this.chessData = new int[this.cols][this.rows];
// 深拷贝
for (int i = 0; i < cols; i++) {
for (int j = 0; j < rows; j++) {
this.chessData[i][j] = chessData[i][j];
}
}
}

/**
* 获取最佳下棋点位
*
* @return
*/

private Point getBestPoint() {
Point best = null;
// 初始分值为最小
int score = -INFINITY;

/* 遍历所有能下棋的点位,评估各个点位的分值,选择分值最大的点位 */
for (int i = 0; i < this.cols; i++) {
for (int j = 0; j < this.rows; j++) {
if (this.chessData[i][j] != 0) {
// 该点已有棋子,跳过
continue;
}

Point p = new Point(i, j, this.ai);
// 评估该点AI得分
int val = evaluate(p);
// 选择得分最高的点位
if (val > score) {
// 最高分被刷新
score = val;
// 更新最佳点位
best = p;
}
}
}

return best;
}

/**
* 对当前棋位进行评估
*
* @param point 当前棋位
* @return
*/

private int evaluate(Point point) {
// 核心
}

}

首先看 getPoint 方法,这个是 AI 的出入口方法,我们要对传入的棋盘数据做一个初始化,调用 initChessData 方法,计算出当前游戏的棋盘行数、列数,并且拷贝了一份棋子数据到本地(深拷贝还是浅拷贝视情况而定)。


this.ai = 3 - point.type;

这行代码可以计算出AI是执黑子还是执白子,应该很好理解。


if (started) {
// AI先下,首子天元
int centerX = this.cols / 2;
int centerY = this.rows / 2;
return new Point(centerX, centerY, this.ai);
}

这段代码是处理刚开局时 AI 先行落子的情况,我们这边是简单的将落子点确定为棋盘中心位置(天元)。开局情况的落子我们可以自己定义,并不是固定的,只是说天元的位置比较好而已。


    private Point getBestPoint() {
Point best = null;
// 初始分值为最小
int score = -INFINITY;

/* 遍历所有能下棋的点位,评估各个点位的分值,选择分值最大的点位 */
for (int i = 0; i < this.cols; i++) {
for (int j = 0; j < this.rows; j++) {
if (this.chessData[i][j] != 0) {
// 该点已有棋子,跳过
continue;
}

Point p = new Point(i, j, this.ai);
// 评估该点AI得分
int val = evaluate(p);
// 选择得分最高的点位
if (val > score) {
// 最高分被刷新
score = val;
// 更新最佳点位
best = p;
}
}
}

return best;
}

然后就到了我们最主要的方法了 getBestPoint,这个方法用于选择出 AI 的最佳落子位置。这个方法的思路就是遍历棋盘上所有能下棋的点,然后对这个点进行评分,如果这个点的评分比之前点的评分高,就更新当前最佳落子点位,并更新最高分,所有的落子点都评估完成之后,我们就能确定最好的点位在哪了。


   /**
* 对当前棋位进行评估
*
* @param point 当前棋位
* @return
*/

private int evaluate(Point point) {
// 核心
}

最后就是评估函数的实现了。


评估函数


在写评估函数之前,我们要先了解一下五子棋的几种棋型。(还不熟的朋友,五子棋入门了解一下:和那威学五子棋)


在这里,我把五子棋棋型大致分为:连五活四冲四活三眠三活二眠二眠一 等共8种棋型。


0:空位 1:黑子 2:白子

连五:11111
活四:011110
冲四:21111
活三:001110
眠三:211100
活二:001100
眠二:001120
眠一:001200

冲四活三 如果形成,赢的可能性很大,活四 如果形成,棋局胜负基本确定,连五 形成就已经赢了。所以说,如果 AI 落的点能够形成这几种胜率很高的棋型的话,我们要给这个点评一个高分,这样对 AI 最有利。


我这边定义好了各个棋型的分数情况











































棋型分数
连五10000000
活四1000000
活三10000
冲四8000
眠三1000
活二800
眠二50
眠一10

评估模型的抽象


我们创建一个枚举内部类,然后定义这几种棋型和它的分数。


    @AllArgsConstructor
private enum ChessModel {
/**
* 连五
*/

LIANWU(10000000, new String[]{"11111"}),
/**
* 活四
*/

HUOSI(1000000, new String[]{"011110"}),
/**
* 活三
*/

HUOSAN(10000, new String[]{"001110", "011100", "010110", "011010"}),
/**
* 冲四
*/

CHONGSI(8000, new String[]{"11110", "01111", "10111", "11011", "11101"}),
/**
* 眠三
*/

MIANSAN(1000, new String[]{"001112", "010112", "011012", "211100", "211010"}),
/**
* 活二
*/

HUOER(800, new String[]{"001100", "011000", "000110"}),
/**
* 眠二
*/

MIANER(50, new String[]{"011200", "001120", "002110", "021100", "001010", "010100"}),
/**
* 眠一
*/

MIANYI(10, new String[]{"001200", "002100", "020100", "000210", "000120"});

/**
* 分数
*/

int score;
/**
* 局势数组
*/

String[] values;
}

为了评估方便,我们可以把所有定义好的棋型以及棋型对应的分数存入 Hash 表。


创建一个 LinkedHashMap 类型的类变量 SCORE,然后在静态代码块内进行初始化。


    /**
* 棋型分数表
*/

private static final Map SCORE = new LinkedHashMap<>();

static {
// 初始化棋型分数表
for (ChessModel chessScore : ChessModel.values()) {
for (String value : chessScore.values) {
SCORE.put(value, chessScore.score);
}
}
}

判断落子点位的棋型


棋型和分数都定义好了,现在我们要知道一个点位它的棋型的情况,这样才能评估这个点位的分数。


我们以落子点位为中心,分横、纵、左斜、右斜等4个大方向,分别取出各方向的9个点位的棋子,每个方向的9个棋子都组合成一个字符串,然后匹配现有的棋型数据,累积分值,这样就计算出了这个点位的分数了。


image.png


以上图为例,对横、纵、左斜、右斜做如上操作,可以得出:


横:000111000 -> 活三 +10000
纵:000210000 -> 眠一 +10
左斜:000210000 -> 眠一 +10
右斜:000010000 -> 未匹配到棋型 +0

所以这个点位总得分为:


10000 + 10 + 10 + 0 = 10020

代码实现:


    /**
* 获取局势分数
*
* @param situation 局势
* @return
*/

private int getScore(String situation) {
for (String key : SCORE.keySet()) {
if (situation.contains(key)) {
return SCORE.get(key);
}
}
return 0;
}

/**
* 获取棋位局势
*
* @param point 当前棋位
* @param direction 大方向 1.横 2.纵 3.左斜 4.右斜
* @return
*/

private String getSituation(Point point, int direction) {
// 下面用到了relativePoint函数,根据传入的四个大方向做转换
direction = direction * 2 - 1;
// 以下是将各个方向的棋子拼接成字符串返回
StringBuilder sb = new StringBuilder();
appendChess(sb, point, direction, 4);
appendChess(sb, point, direction, 3);
appendChess(sb, point, direction, 2);
appendChess(sb, point, direction, 1);
sb.append(1); // 当前棋子统一标记为1(黑)
appendChess(sb, point, direction + 1, 1);
appendChess(sb, point, direction + 1, 2);
appendChess(sb, point, direction + 1, 3);
appendChess(sb, point, direction + 1, 4);
return sb.toString();
}

/**
* 拼接各个方向的棋子
*


* 由于现有评估模型是对黑棋进行评估
* 所以,为了方便对局势进行评估,如果当前是白棋方,需要将扫描到的白棋转换为黑棋,黑棋转换为白棋
* 如:point(x=0,y=0,type=2) 即当前为白棋方
* 扫描到的某个方向局势为:20212 -> 转换后 -> 10121
*
* @param sb 字符串容器
* @param point 当前棋子
* @param direction 方向 1.左横 2.右横 3.上纵 4.下纵 5.左斜上 6.左斜下 7.右斜上 8.右斜下
* @param offset 偏移量
*/

private void appendChess(StringBuilder sb, Point point, int direction, int offset) {
int chess = relativePoint(point, direction, offset);
if (chess > -1) {
if (point.type == 2) {
// 对白棋进行转换
if (chess > 0) {
// 对棋子颜色进行转换,2->1,1->2
chess = 3 - chess;
}
}
sb.append(chess);
}
}

/**
* 获取相对点位棋子
*
* @param point 当前棋位
* @param direction 方向 1.左横 2.右横 3.上纵 4.下纵 5.左斜上 6.左斜下 7.右斜上 8.右斜下
* @param offset 偏移量
* @return -1:越界 0:空位 1:黑棋 2:白棋
*/

private int relativePoint(Point point, int direction, int offset) {
int x = point.x, y = point.y;
switch (direction) {
case 1:
x -= offset;
break;
case 2:
x += offset;
break;
case 3:
y -= offset;
break;
case 4:
y += offset;
break;
case 5:
x += offset;
y -= offset;
break;
case 6:
x -= offset;
y += offset;
break;
case 7:
x -= offset;
y -= offset;
break;
case 8:
x += offset;
y += offset;
break;
}

if (x < 0 || y < 0 || x >= this.cols || y >= this.rows) {
// 越界
return -1;
}

// 返回该位置的棋子
return this.chessData[x][y];
}


评估函数的实现


到这一步,我们已经能知道某个落子点位的各个方向的局势,又能通过局势获取到对应的分值,这样一来,评估函数就很好写了,评估函数要做的就是累积4个方向的分值,然后返回就行。


    /**
* 对当前棋位进行评估
*
* @param point 当前棋位
* @return
*/

private int evaluate(Point point) {
// 分值
int score = 0;

for (int i = 1; i < 5; i++) {
// 获取该方向的局势
String situation = getSituation(point, i);
// 下此步的得分
score += getScore(situation);
}

return score;
}

现在,已经可以将我们写的 AI 接入GUI 程序做测试了。如果还没有 GUI,也可以自己写个测试方法,只要按照方法的入参信息传入就行,方法输出的就是 AI 下一步的落子位置。


    /**
* 获取AI棋位
*
* @param chessData 已下棋子数据
* @param point 对手棋位
* @param started 是否刚开局
* @return
*/

Point getPoint(int[][] chessData, Point point, boolean started);

image.png


测试了一下,现在的 AI 只知道进攻,不知道防守,所以我们需要对 getBestPoint 方法进行优化。之前只对 AI 落子进行了评估,现在我们也要对敌方落子进行评估,然后累积分值,这样可以提高 AI 的防守力度。


    private Point getBestPoint() {
Point best = null;
// 初始分值为最小
int score = -INFINITY;

/* 遍历所有能下棋的点位,评估各个点位的分值,选择分值最大的点位 */
for (int i = 0; i < this.cols; i++) {
for (int j = 0; j < this.rows; j++) {
if (this.chessData[i][j] != 0) {
// 该点已有棋子,跳过
continue;
}

Point p = new Point(i, j, this.ai);
// 该点得分 = AI落子得分 + 对手落子得分
int val = evaluate(p) + evaluate(new Point(i, j, 3 - this.ai));
// 选择得分最高的点位
if (val > score) {
// 最高分被刷新
score = val;
// 更新最佳点位
best = p;
}
}
}

return best;
}

只有这行代码进行了改动,现在加上了对手落子到该点的得分。


// 该点得分 = AI落子得分 + 对手落子得分
int val = evaluate(p) + evaluate(new Point(i, j, 3 - this.ai));

再次测试,现在 AI 棋力还是太一般,防守能力是提高了,但还是输给了我这个“臭棋篓子”。


image.png


有一些局势的评分需要提高,例如:



  • 活三又活二

  • 冲四又活二

  • 两个或两个以上的活三

  • 冲四又活三


上面这些情况都得加一些分数,如果分数太普通,AI 棋力就会很普通甚至更弱,可以说目前的 AI 只能算是一个刚入门五子棋的新手。


我这边对这些情况的处理是这样的:



  • 活三又活二:总分x2

  • 冲四又活二:总分x4

  • 两个或两个以上的活三:总分x6

  • 冲四又活三:总分x8


新增一个方法,用于判断当前局势是属于什么棋型


    /**
* 检查当前局势是否处于某个局势
*
* @param situation 当前局势
* @param chessModel 检查的局势
* @return
*/

private boolean checkSituation(String situation, ChessModel chessModel) {
for (String value : chessModel.values) {
if (situation.contains(value)) {
return true;
}
}
return false;
}

修改评估方法 evaluate,对各种棋型做一个统计,最后按照我上面给出的处理规则进行加分处理。


    /**
* 对当前棋位进行评估
*
* @param point 当前棋位
* @return
*/

private int evaluate(Point point) {
// 分值
int score = 0;
// 活三数
int huosanTotal = 0;
// 冲四数
int chongsiTotal = 0;
// 活二数
int huoerTotal = 0;

for (int i = 1; i < 5; i++) {
String situation = getSituation(point, i);
if (checkSituation(situation, ChessModel.HUOSAN)) {
// 活三+1
huosanTotal++;
} else if (checkSituation(situation, ChessModel.CHONGSI)) {
// 冲四+1
chongsiTotal++;
} else if (checkSituation(situation, ChessModel.HUOER)) {
// 活二+1
huoerTotal++;
}

// 下此步的得分
score += getScore(situation);
}

if (huosanTotal > 0 && huoerTotal > 0) {
// 活三又活二
score *= 2;
}
if (chongsiTotal > 0 && huoerTotal > 0) {
// 冲四又活二
score *= 4;
}
if (huosanTotal > 1) {
// 活三数大于1
score *= 6;
}
if (chongsiTotal > 0 && huosanTotal > 0) {
// 冲四又活三
score *= 8;
}

return score;
}

再次进行测试,AI 棋力已经可以打败我这个菜鸡了,但由于我棋艺不精,打败我不具代表性。


image.png


在网上找了一个大佬写的五子棋 AIgobang.light7.cn/#/), 我用我写的 AI 去和大佬的 AI 下棋,我的 AI 执黑,只能打败大佬的萌新级别执白的 AI


AI 执黑的情况,赢


image.png


AI 执白的情况,输


image.png


由于目前的 AI 只能思考一步棋,所以棋力不强,对方稍微套路一下可能就输了,后续还有很大的优化空间。


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

灯泡开关 Ⅱ : 分情况讨论

题目描述 这是 LeetCode 上的 672. 灯泡开关 Ⅱ ,难度为 中等。 Tag : 「脑筋急转弯」、「找规律」 房间中有 n 只已经打开的灯泡,编号从 1 到 n 。墙上挂着 4 个开关 。 这 4 个开关各自都具有不同的功能,其中: 开...
继续阅读 »

题目描述


这是 LeetCode 上的 672. 灯泡开关 Ⅱ ,难度为 中等


Tag : 「脑筋急转弯」、「找规律」


房间中有 n 只已经打开的灯泡,编号从 1n 。墙上挂着 4 个开关 。


4 个开关各自都具有不同的功能,其中:



  • 开关 1 :反转当前所有灯的状态(即开变为关,关变为开)

  • 开关 2 :反转编号为偶数的灯的状态(即 2, 4, ...

  • 开关 3 :反转编号为奇数的灯的状态(即 1, 3, ...

  • 开关 4 :反转编号为 j = 3k + 1 的灯的状态,其中 k = 0, 1, 2, ...(即 1, 4, 7, 10, ...


你必须 恰好 按压开关 presses 次。每次按压,你都需要从 4 个开关中选出一个来执行按压操作。


给你两个整数 npresses,执行完所有按压之后,返回 不同可能状态 的数量。


示例 1:


输入:n = 1, presses = 1

输出:2

解释:状态可以是:
- 按压开关 1 ,[关]
- 按压开关 2 ,[开]

示例 2:


输入:n = 2, presses = 1

输出:3

解释:状态可以是:
- 按压开关 1 ,[关, 关]
- 按压开关 2 ,[开, 关]
- 按压开关 3 ,[关, 开]

示例 3:


输入:n = 3, presses = 1

输出:4

解释:状态可以是:
- 按压开关 1 ,[关, 关, 关]
- 按压开关 2 ,[关, 开, 关]
- 按压开关 3 ,[开, 关, 开]
- 按压开关 4 ,[关, 开, 开]

提示:



  • 1<=n<=10001 <= n <= 1000

  • 0<=presses<=10000 <= presses <= 1000


分情况讨论


记灯泡数量为 nn(至少为 11),翻转次数为 kk(至少为 00),使用 1 代表灯亮,使用 0 代表灯灭。


我们根据 nnkk 的数值分情况讨论:



  • k=0k = 0 时,无论 nn 为何值,都只有起始(全 1)一种状态;

  • k>0k > 0 时,根据 nn 进一步分情况讨论:

    • n=1n = 1 时,若 kk 为满足「k>0k > 0」的最小值 11 时,能够取满「1/0」两种情况,而其余更大 kk 值情况能够使用操作无效化(不影响灯的状态);

    • n=2n = 2 时,若 k=1k = 1,能够取得「11/10/01」三种状态,当 k=2k = 2 时,能够取满「11/10/01/00」四种状态,其余更大 kk 可以通过前 k1k - 1 步归结到任一状态,再通过最后一次的操作 11 归结到任意状态;

    • n=3n = 3 时,若 k=1k = 1 时,对应 44 种操作可取得 44 种方案;当 k=2k = 2 时,可取得 77 种状态;而当 k=3k = 3 时可取满 23=82^3 = 8 种状态,更大的 kk 值可通过同样的方式归结到取满的 88 种状态。

    • n>3n > 3 时,根据四类操作可知,灯泡每 66 组一循环(对应序列 k + 12k + 22k + 13k + 1),即只需考虑 n<=6n <= 6 的情况,而 n=4n = 4n=5n = 5n=6n = 6 时,后引入的灯泡状态均不会产生新的组合(即新引入的灯泡状态由前三个灯泡的状态所唯一确定),因此均可归纳到 n=3n = 3 的情况。




Java 代码:


class Solution {
public int flipLights(int n, int k) {
if (k == 0) return 1;
if (n == 1) return 2;
else if (n == 2) return k == 1 ? 3 : 4;
else return k == 1 ? 4 : k == 2 ? 7 : 8;
}
}

TypeScript 代码:


function flipLights(n: number, k: number): number {
if (k == 0) return 1
if (n == 1) return 2
else if (n == 2) return k == 1 ? 3 : 4;
else return k == 1 ? 4 : k == 2 ? 7 : 8;
};


  • 时间复杂度:O(1)O(1)

  • 空间复杂度:O(1)O(1)


最后


这是我们「刷穿 LeetCode」系列文章的第 No.672 篇,系列开始于 2021/01/01,截止于起始日 LeetCode 上共有 1916 道题目,部分是有锁题,我们将先把所有不带锁的题目刷完。


在这个系列文章里面,除了讲解解题思路以外,还会尽可能给出最为简洁的代码。如果涉及通解还会相应的代码模板。


为了方便各位同学能够电脑上进行调试和提交代码,我建立了相关的仓库:github.com/SharingSour…


在仓库地址里,你可以看到系列文章的题解链接、系列文章的相应代码、LeetCode 原题链接和其他优选题解。


作者:宫水三叶的刷题日记
链接:https://juejin.cn/post/7143438427050999838
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

Android 闪屏页适配

遇到的坑 按官方文档设置完之后,debug运行,或者直接点击Run运行,闪屏页的logo不显示,清掉后台,从桌面点击启动logo才显示,不过设置的windowBackgroud 都是显示正常的,这个问题我调了一天,,,AndroidStudio版本4.2.2...
继续阅读 »

遇到的坑



按官方文档设置完之后,debug运行,或者直接点击Run运行,闪屏页的logo不显示,清掉后台,从桌面点击启动logo才显示,不过设置的windowBackgroud 都是显示正常的,这个问题我调了一天,,,AndroidStudio版本4.2.2




内容来自官方文档 文档地址:点我



如果您之前在 Android 11 或更低版本中实现了自定义初始屏幕,则需要将您的应用迁移到 SplashScreenAPI 以确保它在 Android 12 及更高版本中正确显示。


从 Android 12 开始,系统始终在所有应用的 启动和 热启动时应用新的Android 系统默认启动画面。默认情况下,此系统默认启动画面是使用您的应用程序的启动器图标元素和 您的主题(如果它是单色)构建的。windowBackground


如果您不迁移您的应用,您在 Android 12 及更高版本上的应用启动体验将会降级或可能出现意外结果:



  • 如果您现有的初始屏幕是使用覆盖 的自定义主题android:windowBackground实现的,则系统会将您的自定义初始屏幕替换为 Android 12 及更高版本上的默认 Android 系统初始屏幕(这可能不是您应用的预期体验)。

  • 如果您现有的初始屏幕是使用专用的 实现的,则Activity在运行 Android 12 或更高版本的设备上启动您的应用会导致重复的初始屏幕:显示新的系统初始屏幕 ,然后是您现有的初始屏幕活动。


您可以通过完成本指南中描述的迁移过程来防止这些降级或意外体验。迁移后,新 API 会缩短启动时间,让您完全控制初始屏幕体验,并确保与平台上其他应用程序的启动体验更加一致。


SplashScreen 兼容库


您可以SplashScreen直接使用 API,但我们强烈建议使用 AndroidxSplashScreen兼容库 。compat 库使用SplashScreenAPI,支持向后兼容,并为所有 Android 版本的初始屏幕显示创建一致的外观。本指南是使用 compat 库编写的。


如果您选择直接使用 SplashScreen API 进行迁移,在 Android 11 上并降低您的初始屏幕看起来与以前完全相同;从 Android 12 开始,初始屏幕将具有新的 Android 12 外观。


迁移您的启动画面实施


完成以下步骤,将您现有的初始屏幕实施迁移到适用于 Android 12 及更高版本的新体验。


此过程适用于您从中迁移的任何类型的实现。如果您是从专用迁移Activity,您还应该遵循本文档中描述的最佳实践来调整您的自定义启动屏幕Activity。新的SplashScreenAPI 还减少了由专用启动屏幕活动引入的启动延迟。


使用SplashScreencompat 库迁移后,系统会在所有版本的 Android 上显示相同的初始屏幕。


要迁移初始屏幕:




  1. build.gradle文件中,更改您的 compileSdkVersion并将 SplashScreencompat 库包含在依赖项中。


    build.gradle

    android {
       compileSdkVersion 31
       ...
    }
    dependencies {
       ...
       implementation 'androidx.core:core-splashscreen:1.0.0-beta02'
    }



  2. 使用 的父项创建一个主题Theme.SplashScreen,并将 的值设置为 应该使用 postSplashScreenTheme的主题以及可绘制或动画可绘制的主题。其他属性是可选的。Activity``windowSplashScreenAnimatedIcon


    <style name="Theme.App.Starting" parent="Theme.SplashScreen">
       <!-- Set the splash screen background, animated icon, and animation duration. -->
       <item name="windowSplashScreenBackground">@color/...</item>

       <!-- Use windowSplashScreenAnimatedIcon to add either a drawable or an
            animated drawable. One of these is required. -->
       <item name="windowSplashScreenAnimatedIcon">@drawable/...</item>
       <!-- Required for animated icons -->
       <item name="windowSplashScreenAnimationDuration">200</item>

       <!-- Set the theme of the Activity that directly follows your splash screen. -->
       <!-- Required -->
       <item name="postSplashScreenTheme">@style/Theme.App</item>
    </style>

    如果要在图标下方添加背景颜色,可以使用 Theme.SplashScreen.IconBackground主题并设置 windowSplashScreenIconBackground属性。




  3. 在清单中,将启动活动的主题替换为您在上一步中创建的主题。


    <manifest>
       <application android:theme="@style/Theme.App.Starting">
        <!-- or -->
            <activity android:theme="@style/Theme.App.Starting">
    ...



  4. installSplashScreen在调用之前调用启动 活动super.onCreate()


    class MainActivity : Activity() {

       override fun onCreate(savedInstanceState: Bundle?) {
           // Handle the splash screen transition.
           val splashScreen = installSplashScreen()

           super.onCreate(savedInstanceState)
           setContentView(R.layout.main_activity)
    ...



installSplashScreen返回初始屏幕对象,您可以选择使用它来自定义动画或将初始屏幕保持在屏幕上更长的时间。有关自定义动画的更多详细信息,请参阅 让初始屏幕在屏幕上停留更长时间 和自定义动画以关闭初始屏幕


使您的自定义启动屏幕活动适应新的启动屏幕体验


在您迁移到适用于 Android 12 及更高版本的新初始屏幕体验后,您的自定义初始屏幕Activity仍然存在,因此您需要选择如何处理它。您有以下选择:



  • 保留自定义活动,但阻止其显示

  • 出于品牌原因保留自定义活动

  • 删除自定义活动,并根据需要调整您的应用程序


阻止自定义 Activity 显示


如果您现有的初始屏幕Activity主要用于路由,请考虑删除它的方法;例如,您可以直接链接到实际活动或移动到带有子组件的单个活动。如果这不可行,您可以使用SplashScreen#setKeepOnScreenCondition 将路由活动保持在原位,但停止渲染。这样做会将初始屏幕转移到下一个活动,并允许平滑过渡。


  class RoutingActivity : Activity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        val splashScreen = installSplashScreen()
        super.onCreate(savedInstanceState)

        // Keep the splash screen visible for this Activity
        splashScreen.setKeepOnScreenCondition { true }
        startSomeNextActivity()
        finish()
     }
   ...
 

保留品牌化的自定义活动


如果您想使用后续启动画面Activity来获得品牌体验,您可以Activity通过自定义关闭启动画面的动画,从系统启动画面过渡到您的自定义启动画面。但是,如果可能的话,最好避免这种情况,并使用新的 SplashScreenAPI 来标记您的启动画面。


移除自定义闪屏Activity


一般来说,我们建议您Activity 完全删除您自定义的启动画面,以避免重复启动画面无法迁移,提高效率并减少启动画面加载时间。您可以使用不同的技术来避免显示多余的闪屏活动。




  • 延迟加载组件、模块或库:避免加载或初始化应用程序启动时不需要的组件或库,并在应用程序需要时加载它们。


    如果您的应用确实需要某个组件才能正常工作,请仅在真正需要时而不是在启动时加载它,或者在应用启动后使用后台线程加载它。尽量保持你Application onCreate()的轻盈。


    您还可以受益于使用App Startup 库在应用程序启动时初始化组件。这样做时,请确保仍然加载启动活动所需的所有模块,并且不要在延迟加载的模块变得可用时引入卡顿。




  • 在本地加载少量数据时创建占位符:使用推荐的主题化方法并保留渲染,直到应用程序准备好。要实现向后兼容的初始屏幕,请按照使初始屏幕在屏幕上停留更长时间中概述的步骤。




  • 显示占位符:对于持续时间不确定的基于网络的加载,关闭初始屏幕并显示占位符以进行异步加载。考虑将微妙的动画应用于反映加载状态的内容区域。确保加载的内容结构 尽可能匹配骨架结构,以便在加载内容后实现平滑过渡。




  • 使用缓存:当用户第一次打开您的应用程序时,您可以显示某些 UI 元素的加载指示符(如下例所示)。下次用户返回您的应用时,您可以在加载更新的内容时显示此缓存内容。


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

Android将倒计时做到极致

前言 倒计时的实现有很多方式,我觉得分享这个技术的关键在于有些官方的,甚至第三方的,也许能帮我实现99%的效果,但是当你从99%优化到100%,哪怕这1%微不足道,但你能从这个过程中得到的东西远远比你想象中的要多。 已有倒计时方案存在的问题 在开发倒计时功能时...
继续阅读 »

前言


倒计时的实现有很多方式,我觉得分享这个技术的关键在于有些官方的,甚至第三方的,也许能帮我实现99%的效果,但是当你从99%优化到100%,哪怕这1%微不足道,但你能从这个过程中得到的东西远远比你想象中的要多。


已有倒计时方案存在的问题


在开发倒计时功能时往往我们会为了方便直接使用CountDownTimer或者使用Handler做延时来实现,当然CountDownTimer内部封装也是使用的Handler。


如果只是做次数很少的倒计时或者不需要精确的倒计时逻辑那倒没关系,比如说我只要倒计时10秒,或者我大概5分钟请求某个接口


但是如果是需要做精确的倒计时操作,比如说手机发送验证码60秒,那使用现有的倒计时方案就会存在问题。可能有些朋友没有注意到这一点,下面我们就来简单分析一下现有倒计时的问题。


1. CountDownTimer


这个可能是用得最多的,因为方便嘛。但其实倒计时每一轮倒计时完之后都是存在误差的,如果看过CountDownTimer的源码你就会知道,他的内部是有做校准操作的。(源码很简单这里就不分析了)


但是如果你认真的测试过CountDownTimer,你就会发现,即便它内部有做校准操作,他的每一轮都是有偏差,只是他最后一次倒计时完之后的总共时间和开始倒计时的时间相比没偏差。

什么意思呢,意思就是1秒,2.050秒,3.1秒......,这样的每轮偏差,导致他会出现10.95秒,下一次12秒的情况,那它的回调中如果你直接做取整就会出现少一秒的情况,但实际是没少的。

这只是其中的一个问题,你可以不根据它的回调做展示,自己用一个整形累加做展示也能解决。但是他还有个问题,有概率直接出现跳秒,就是比如3秒,下次直接5秒,这是实际的跳秒,是少了一次回调的那种。


跳秒导致你如果直接使用它可能会大问题,你可能自测的时候没发现,到时一上线应用在用户那概率跳秒,那就蛋疼了。


2. Handler


不搞这么多花里胡哨的,直接使用Handler来实现,会有什么问题。

因为直接使用handler来实现,没有校准操作,每次循环会出现几毫秒的误差,虽然比CountDownTimer的十几毫秒的误差要好,但是在基数大的倒计时情况下误差会累计,导致最终结果和现实时间差几秒误差,时间越久,误差越大


3. Timer


直接使用Timer也一样,只不过他每轮的误差更小,几轮才有1毫秒的误差,但是没有校准还是会出现误差累计,时间越久误差越大。


自己封装倒计时


既然无法直接使用原生的,那我们就自己做一个。

我们基于Handler进行封装,从上面可以看出主要为了解决两个问题,时间校准和跳秒。自己写一个CountDownTimer


public class CountDownTimer {

private int mTimes;
private int allTimes;
private final long mCountDownInterval;
private final Handler mHandler;
private OnTimerCallBack mCallBack;
private boolean isStart;
private long startTime;

public CountDownTimer(int times, long countDownInterval){
this.mTimes = times;
this.mCountDownInterval = countDownInterval;
mHandler = new Handler();
}

public synchronized void start(OnTimerCallBack callBack){
this.mCallBack = callBack;
if (isStart || mCountDownInterval <= 0){
return;
}

isStart = true;
if (callBack != null){
callBack.onStart();
}
startTime = SystemClock.elapsedRealtime();

if (mTimes <= 0){
finishCountDown();
return;
}
allTimes = mTimes;

mHandler.postDelayed(runnable, mCountDownInterval);
}

private final Runnable runnable = new Runnable() {
@Override
public void run() {
mTimes--;
if (mTimes > 0){
if (mCallBack != null){
mCallBack.onTick(mTimes);
}

long nowTime = SystemClock.elapsedRealtime();
long delay = (nowTime - startTime) - (allTimes - mTimes) * mCountDownInterval;
// 处理跳秒
while (delay > mCountDownInterval){
mTimes --;
if (mCallBack != null){
mCallBack.onTick(mTimes);
}

delay -= mCountDownInterval;
if (mTimes <= 0){
finishCountDown();
return;
}
}

mHandler.postDelayed(this, 1000 - delay);
}else {
finishCountDown();
}
}
};

private void finishCountDown(){
if (mCallBack != null){
mCallBack.onFinish();
}
isStart = false;
}

public void cancel(){
mHandler.removeCallbacksAndMessages(null);
isStart = false;
}

public interface OnTimerCallBack{

void onStart();

void onTick(int times);

void onFinish();

}

}

思路就是在倒计时开始前获取一次SystemClock.elapsedRealtime(),每轮倒计时再获取一次SystemClock.elapsedRealtime()相减得到误差,根据delay校准。然后使用while循环来处理跳秒的操作,与原生的CountDownTimer不同,这里如果跳了多少秒,就会返回多少次回调。


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

白话ThreadLocal原理

ThreadLocal作用 对于Android程序员来说,很多人都是在学习消息机制时候了解到ThreadLocal这个东西的。那它有什么作用呢?官方文档大致是这么描述的: ThreadLocal提供了线程局部变量 每个线程都拥有自己的变量副本,可以通过Thr...
继续阅读 »

ThreadLocal作用


对于Android程序员来说,很多人都是在学习消息机制时候了解到ThreadLocal这个东西的。那它有什么作用呢?官方文档大致是这么描述的:



  • ThreadLocal提供了线程局部变量

  • 每个线程都拥有自己的变量副本,可以通过ThreadLocal的set或者get方法去设置或者获取当前线程的变量,变量的初始化也是线程独立的(需要实现initialValue方法)

  • 一般而言ThreadLocal实例在类中被private static修饰

  • 当线程活着并且ThreadLocal实例能够访问到时,每个线程都会持有一个到它的变量的引用

  • 当一个线程死亡后,所有ThreadLocal实例给它提供的变量都会被gc回收(除非有其它的引用指向这些变量)
    上述中“变量”是指ThreadLocal的get方法获取的值


简单例子


先来看一个简单的使用例子吧:


public class ThreadId {

private static final AtomicInteger nextId = new AtomicInteger(0);

private static final ThreadLocal<Integer> threadId = new ThreadLocal<Integer>() {
@Override
protected Integer initialValue() {
return nextId.get();
}
};

public static int get() {
return threadId.get();
}
}

这也是官方文档上的例子,非常简单,就是通过在不同线程调用ThredId.get()可以获取唯一的线程Id。如果在调用ThreadLocal的get方法之前没有主动调用过set方法设置值的话,就会返回initialValue方法的返回值,并把这个值存储为当前线程的变量。


ThreadLocal到底是用来解决什么问题,适用什么场景呢,例子是看懂了,但好像还是没什么体会?ThreadLocal既然是提供变量的,我们不妨把我们见过的变量类型拿出来,做个对比


局部变量、成员变量 、 ThreadLocal、静态变量










































变量类型作用域生命周期线程共享性作用
局部变量方法(代码块)内部,其他方法(代码块)不能访问方法(代码块)开始到结束只存在于每个线程的工作内存,不能在线程中共享解决变量在方法(代码块)内部的代码行之间的共享
成员变量实例内和实例相同可在线程间共享解决变量在实例方法之间的共享,否则方法之间只能靠参数传递变量
静态变量类内部和类的生命周期相同可在多个线程间共享解决变量在多个实例之间的共享
ThreadLocal存储的变量整个线程一般而言与线程的生命周期相同不再多线程间共享解决变量在单个线程中的共享问题,线程中处处可访问

ThreadLocal存储的变量本质上间接算是Thread的成员变量,ThreadLocal只是提供了一种对开发者透明的可以为每个线程存储同一维度成员变量的方式。


共享 or 隔离


网上有很多人持有如下的看法:
ThreadLocal为解决多线程程序的并发问题提供了一种新思路或者ThreadLocal是为了解决多线程访问资源时的共享问题。
个人认为这些都是错误的,ThreadLocal保存的变量是线程隔离的,与资源共享没有任何关系,也没有解决什么并发问题,这一点看了ThreadLocal的原理就会更加清楚。就好比上面的例子,每个线程应该有一个线程Id,这并不是什么并发问题啊。


同时他们会拿ThreadLocal与sychronized做对比,我们要清楚它们根本不是为了解决同一类问题设计的。sychronized是在牵涉到共享变量时候,要做到线程间的同步,保证并发中的原子性与内存可见性,典型的特征是多个线程会访问相同的变量。而ThreadLocal根本不是解决线程同步问题的,它的场景是A线程保存的变量只有A线程需要访问,而其它的线程并不需要访问,其他线程也只访问自己保存的变量。


原理


我们来一个开放性的问题,假如现在要给每个线程增加一个线程Id,并且Java的Thread类你能随便修改,你要怎么操作?非常简单吧,代码大概是这样


public class Thread{
private int id;

public void setId(int id){
this.id=id;
}
}

那好,现在题目变了,我们现在还得为每个线程保存一个Looper对象,那怎么办呢?再加一个Looper的字段不就好了,显然这种做法肯定是不具有扩展性的。那我们用一个容器类不就好了,很自然地就会想到Map,像下面这样


public class Thread{

private Map<String,Object> map;

public Map<String,Object> getMap(){
if(map==null)
map=new HashMap<>();
return map;
}

}

然后我们在代码里就可以通过如下代码来给Thread设置“成员变量”了


   Thread.currentThread().getMap().put("id",id);
Thread.currentThread().getMap().put("looper",looper);

然后可以在该线程执行的任意地方,这样访问:


  Looper looper=(Looper) Thread.currentThread().getMap().get("looper");

看上去还不错,但是还是有些问题:



  • 保存和获取变量都要用到字符换key

  • 因为map中要保存各种值,因此泛型只得用Object,这样获取时候就需要强制转换(可用泛型方法解)

  • 当该变量没有作用时候,此时线程还没有执行完,需要手动设置该变量为空,否则会造成内存泄漏


为了不通过字符串访问,同时省去强制转换,我们封装一个类,就叫ThreadLocal吧,伪代码如下:


  public class ThreadLocal<T> {

public void set(T value) {
Thread t = Thread.currentThread();
Map map = t.getMap();
if (map != null)
//以自己为键
map.put(this, value);
else
createMap(t, value);
}


public T get() {
Thread t = Thread.currentThread();
Map<ThreadLocal<?>,T> map = t.getMap();
if (map != null) {
T e = map.get(this);
return e;
}
return setInitialValue();
}
}

没错,以上基本上就是ThreadLocal的整体设计了,只是线程中存储数据的Map是特意实现的ThreadLocal.ThreadLocalMap。


ThreadLocal与线程的关系如下:
ThreadLocal与线程的关系.png


如上图如所示,ThredLocal本身并不存储变量,只是向每个线程的threadLocals中存储键值对。ThreadLocal横跨线程,提供一种类似切面的概念,这种切面是作用在线程上的。


我们对ThreadLocal已经有一个整体的认识了,接下来我们大致看一下源码


源码分析


TheadLocal


   public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
}

set方法通过Thread.currentThread方法获取当前线程,然后调用getMap方法获取线程的threadLocals字段,并往ThreadLocalMap中放入键值对,其中键为ThreadLocal实例自己。


 ThreadLocalMap getMap(Thread t) {
return t.threadLocals;
}

接着看get方法:


public T get() {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
return setInitialValue();
}

很清晰,其中值得注意的是最后一行的setInitialValue方法,这个方法在我们没有调用过set方法时候调用。


private T setInitialValue() {
T value = initialValue();
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
return value;
}

setInitialValue方法会获取initialValue的返回值并把它放进当前线程的threadLocals中。默认情况下initialValue返回null,我们可以实现这个方法来对变量进行初始化,就像上面TheadId的例子一样。


remove方法,从当前线程的ThreadLocalMap中移除元素。


public void remove() {
ThreadLocalMap m = getMap(Thread.currentThread());
if (m != null)
m.remove(this);
}

TheadLocalMap


看ThreadLocalMap的代码我们主要是关注以下两个方面:



  1. 散列表的一般设计问题。包括散列函数,散列冲突问题解决,负载因子,再散列等。

  2. 内存泄漏的相关处理。一般而言ThreadLocal 引用使用private static修饰,但是假设某种情况下我们真的不再需要使用它了,手动把引用置空。上面我们知道TreadLocal本身作为键存储在TheadLocalMap中,而ThreadLocalMap又被Thread引用,那线程没结束的情况下ThreadLocal能被回收吗?


散列函数
先来理一下散列函数吧,我们在之后的代码中会看到ThreadLocalMap通过 int i = key.threadLocalHashCode & (len-1);决定元素的位置,其中表大小len为2的幂,因此这里的&操作相当于取模。另外我们关注的是threadLocalHashCode的取值。


  private final int threadLocalHashCode = nextHashCode();
private static int nextHashCode() {
return nextHashCode.getAndAdd(HASH_INCREMENT);
}
private static AtomicInteger nextHashCode =
new AtomicInteger();
private static final int HASH_INCREMENT = 0x61c88647;

这里很有意思,每个ThreadLocal实例的threadLocalHashCode是在之前ThreadLocal实例的threadLocalHashCode上加 0x61c88647,为什么偏偏要加这么个数呢?
这个魔数的选取与斐波那契散列有关以及黄金分割法有关,具体不是很清楚。它的作用是这样产生的值与2的幂取模后能在散列表中均匀分布,即便扩容也是如此。看下面一段代码:


  public class MagicHashCode {
//ThreadLocal中定义的魔数
private static final int HASH_INCREMENT = 0x61c88647;

public static void main(String[] args) {
hashCode(16);//初始化16
hashCode(32);//2倍扩容
hashCode(64);
}

private static void hashCode(int length){
int hashCode = 0;
for(int i=0;i<length;i++){
hashCode = i*HASH_INCREMENT+HASH_INCREMENT;
System.out.print(hashCode & (length-1));//求取模后的下标
System.out.print(" ");
}
System.out.println();
}
}

输出结果为:


7 14 5 12 3 10 1 8 15 6 13 4 11 2 9 0   //容量为16时
7 14 21 28 3 10 17 24 31 6 13 20 27 2 9 16 23 30 5 12 19 26 1 8 15 22 29 4 11 18 25 0 //容量为32时
7 14 21 28 35 42 49 56 63 6 13 20 27 34 41 48 55 62 5 12 19 26 33 40 47 54 61 4 11 18 25 32 39 46 53 60 3 10 17 24 31 38 45 52 59 2 9 16 23 30 37 44 51 58 1 8 15 22 29 36 43 50 57 0 //容量为64时

因为ThreadLocalMap使用线性探测法解决冲突(下文会看到),均匀分布的好处在于发生了冲突也能很快找到空的slot,提高效率。


瞄一眼成员变量:


       /**
* 初始容量,必须是2的幂。这样的话,方便把取模运算转化为与运算,
* 效率高
*/
private static final int INITIAL_CAPACITY = 16;

/**
* 容纳Entry元素,长度必须是2的幂
*/
private Entry[] table;

/**
* table中的元素个数.
*/
private int size = 0;

/**
* table里的元素达到这个值就需要扩容了
* 其实是有个装载因子的概念的
*/
private int threshold; // Default to 0

构造函数:


  ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
table = new Entry[INITIAL_CAPACITY];
int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
table[i] = new Entry(firstKey, firstValue);
size = 1;
setThreshold(INITIAL_CAPACITY);
}

firstKey和firstValue就是Map存放的第一个键值对喽。其中firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1)很关键,就是当容量为2的幂时候,这相当于一个取模操作。然后把Entry存储到数组的第i个位置,设置扩容的阈值。


private void setThreshold(int len) {
threshold = len * 2 / 3;
}

这说明当数组里的元素容量达到2/3时候就要扩容,也就是装载因子是2/3。
接下来我们来看下Entry


 static class Entry extends WeakReference<ThreadLocal<?>> {
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}

就这么点东西,这个Entry只是与HashMap不同,只是个普通的键值对,没有链表结构相关的东西。另外Entry只持有对键,也就是ThreadLocal的弱引用,那么我们上面的第二个问题算是有答案了。当没有其他强引用指向ThreadLocal的时候,它其实是会被回收的。但是这有引出了另外一个问题,那Entry呢?当键都为空的时候这个Entry也是没有什么作用啊,也应该被回收啊。不慌,我们接着往下看。


set方法:


 private void set(ThreadLocal<?> key, Object value) {
Entry[] tab = table;
int len = tab.length;
int i = key.threadLocalHashCode & (len-1);
//如果冲突的话,进入该循环,向后探测
for (Entry e = tab[i];
e != null;
e = tab[i = nextIndex(i, len)]) {
ThreadLocal<?> k = e.get();
//判断键是否相等,相等的话只要更新值就好了
if (k == key) {
e.value = value;
return;
}

if (k == null) {
//该Entry对应的ThreadLocal已经被回收,执行replaceStaleEntry并返回
replaceStaleEntry(key, value, i);
return;
}
}
tab[i] = new Entry(key, value);
int sz = ++size;
//进行启发式清理,如果没有清理任何元素并且表的大小超过了阈值,需要扩容并重哈希
if (!cleanSomeSlots(i, sz) && sz >= threshold)
rehash();
}

我们发现如果发生冲突的话,整体逻辑会一直调用nextIndex方法去探测下一个位置,直到找到没有元素的位置,逻辑上整个表是一个环形。下面是nextIndex的代码,就是加1而已。


private static int nextIndex(int i, int len) {
return ((i + 1 < len) ? i + 1 : 0);
}

线性探测的过程中,有一种情况是需要清理对应Entry的,也就是Entry的key为null,我们上面讨论过这种情况下的Entry是无意义的。因此调用
replaceStaleEntry(key, value, i);在看replaceStaleEntry(key, value, i)我们先明确几个问题。采用线性探测发解决冲突,在插入过程中产生冲突的元素之前一定是没有空的slot的。这样在也确保在查找过程,查找到空的slot就可以停止啦。但是假如我们删除了一个元素,就会破坏这种情况,这时需要对表中删除的元素后面的元素进行再散列,以便填上空隙。


空slot:即该位置没有元素
无效slot:该位置有元素,但key为null


replaceStaleEntry除了将value放入合适的位置之外,还会在前后连个空的slot之间做一次清理expungeStaleEntry,清理掉无效slot。


private void replaceStaleEntry(ThreadLocal<?> key, Object value,
int staleSlot) {
Entry[] tab = table;
int len = tab.length;
Entry e;

// 向前扫描到一个空的slot为止,找到离这个空slot最近的无效slot,记录为slotToExpunge
int slotToExpunge = staleSlot;
for (int i = prevIndex(staleSlot, len);
(e = tab[i]) != null;
i = prevIndex(i, len)) {
if (e.get() == null) {
slotToExpunge = i;
}
}

// 向后遍历table
for (int i = nextIndex(staleSlot, len);
(e = tab[i]) != null;
i = nextIndex(i, len)) {
ThreadLocal<?> k = e.get();

// 找到了key,将其与无效slot交换
if (k == key) {
// 更新对应slot的value值
e.value = value;
tab[i] = tab[staleSlot];
tab[staleSlot] = e;
//如果之前还没有探测到过其他无效的slot
if (slotToExpunge == staleSlot) {
slotToExpunge = i;
}
// 从slotToExpunge开始做一次连续段的清理,再做一次启发式清理
cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
return;
}

// 如果当前的slot已经无效,并且向前扫描过程中没有无效slot,则更新slotToExpunge为当前位置
if (k == null && slotToExpunge == staleSlot) {
slotToExpunge = i;
}
}

// 如果key之前在table中不存在,则放在staleSlot位置
tab[staleSlot].value = null;
tab[staleSlot] = new Entry(key, value);

// 在探测过程中如果发现任何其他无效slot,连续段清理后做启发式清理
if (slotToExpunge != staleSlot) {
cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
}
}

expungeStaleEntry主要是清除连续段之前无效的slot,然后对元素进行再散列。返回下一个空的slot位置。


 private int expungeStaleEntry(int staleSlot) {
Entry[] tab = table;
int len = tab.length;

// 删除 staleSlot
tab[staleSlot].value = null;
tab[staleSlot] = null;
size--;

Entry e;
int i;
for (i = nextIndex(staleSlot, len);
(e = tab[i]) != null;
i = nextIndex(i, len)) {
ThreadLocal<?> k = e.get();
if (k == null) {
e.value = null;
tab[i] = null;
size--;
} else {
//对元素进行再散列
int h = k.threadLocalHashCode & (len - 1);
if (h != i) {
tab[i] = null;
while (tab[h] != null)
h = nextIndex(h, len);
tab[h] = e;
}
}
}
return i;
}

启发式地清理:
i对应是非无效slot(slot为空或者有效)
n是用于控制控制扫描次数
正常情况下如果log n次扫描没有发现无效slot,函数就结束了。
但是如果发现了无效的slot,将n置为table的长度len,做一次连续段的清理,再从下一个空的slot开始继续扫描。
这个函数有两处地方会被调用,一处是插入的时候可能会被调用,另外个是在替换无效slot的时候可能会被调用, 区别是前者传入的n为实际元素个数,后者为table的总容量。


private boolean cleanSomeSlots(int i, int n) {
boolean removed = false;
Entry[] tab = table;
int len = tab.length;
do {
// i在任何情况下自己都不会是一个无效slot,所以从下一个开始判断
i = nextIndex(i, len);
Entry e = tab[i];
if (e != null && e.get() == null) {
// 扩大扫描控制因子
n = len;
removed = true;
// 清理一个连续段
i = expungeStaleEntry(i);
}
} while ((n >>>= 1) != 0);
return removed;
}

接着看set函数,如果循环过程中没有返回,找到合适的位置,插入元素,表的size增加1。这个时候会做一次启发式清理,如果启发式清理没有清理掉任何无效元素,判断清理前表的大小大于阈值threshold的话,正常就要进行扩容了,但是表中可能存在无效元素,先把它们清除掉,然后再判断。


private void rehash() {
// 全量清理
expungeStaleEntries();
//因为做了一次清理,所以size可能会变小,这里的实现是调低阈值来判断是否需要扩容。 threshold默认为len*2/3,所以这里的threshold - threshold / 4相当于len/2。
if (size >= threshold - threshold / 4) {
resize();
}
}

作用即清除所有无效slot


private void expungeStaleEntries() {
Entry[] tab = table;
int len = tab.length;
for (int j = 0; j < len; j++) {
Entry e = tab[j];
if (e != null && e.get() == null) {
expungeStaleEntry(j);
}
}
}

保证table的容量len为2的幂,扩容时候要扩大2倍


private void resize() {
Entry[] oldTab = table;
int oldLen = oldTab.length;
int newLen = oldLen * 2;
Entry[] newTab = new Entry[newLen];
int count = 0;
for (int j = 0; j < oldLen; ++j) {
Entry e = oldTab[j];
if (e != null) {
ThreadLocal<?> k = e.get();
if (k == null) {
e.value = null;
} else {
// 扩容后要重新放置元素
int h = k.threadLocalHashCode & (newLen - 1);
while (newTab[h] != null) {
h = nextIndex(h, newLen);
}
newTab[h] = e;
count++;
}
}
}

setThreshold(newLen);
size = count;
table = newTab;
}

get方法:


private Entry getEntry(ThreadLocal<?> key) {
int i = key.threadLocalHashCode & (table.length - 1);
Entry e = table[i];
// 对应的entry存在且key未被回收
if (e != null && e.get() == key) {
return e;
} else {
// 继续往后查找
return getEntryAfterMiss(key, i, e);
}
}

private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {
Entry[] tab = table;
int len = tab.length;
// 不断向后探测直到遇到空entry
while (e != null) {
ThreadLocal<?> k = e.get();
// 找到
if (k == key) {
return e;
}
if (k == null) {
// 该entry对应的ThreadLocal实例已经被回收,调用expungeStaleEntry来清理无效的entry
expungeStaleEntry(i);
} else {
// 下一个位置
i = nextIndex(i, len);
}
e = tab[i];
}
return null;
}

remove方法,比较简单,在table中找key,如果找到了断开弱引用,做一次连续段清理。


private void remove(ThreadLocal<?> key) {
Entry[] tab = table;
int len = tab.length;
int i = key.threadLocalHashCode & (len - 1);
for (Entry e = tab[i];
e != null;
e = tab[i = nextIndex(i, len)]) {
if (e.get() == key) {
//断开弱引用
e.clear();
// 连续段清理
expungeStaleEntry(i);
return;
}
}
}

ThreadLocal与内存泄漏


从上文我们知道当调用ThreadLocalMap的set或者getEntry方法时候,有很大概率会去自动清除掉key为null的Entry,这样就可以断开value的强引用,使对象被回收。但是如果如果我们之后再也没有在该线程操作过任何ThreadLocal实例的set或者get方法,那么就只能等线程死亡才能回收无效value。因此当我们不需要用ThreadLocal的变量时候,显示调用ThreadLocal的remove方法是一种好的习惯。


小结



  • ThredLocal为每个线程保存一个自己的变量,但其实ThreadLocal本身并不存储变量,变量存储在线程自己的实例变量ThreadLocal.ThreadLocalMap threadLocals

  • ThreadLocal的设计并不是为了解决并发问题,而是解决一个变量在线程内部的共享问题,在线程内部处处可以访问

  • 因为每个线程都只会访问自己ThreadLocalMap 保存的变量,所以不存在线程安全问题

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

一类有趣的无限缓存OOM现象

OOM
首先想必大家都知道OOM是啥吧,我就不扯花里胡哨的了,直接进入正题。先说一个背景故事,我司app扫码框架用的zxing,在很长一段时间以前,做过一系列的扫码优化,稍微列一下跟今天主题相关的改动:串行处理改成并发处理,zxing的原生处理流程是通过CameraM...
继续阅读 »

首先

想必大家都知道OOM是啥吧,我就不扯花里胡哨的了,直接进入正题。先说一个背景故事,我司app扫码框架用的zxing,在很长一段时间以前,做过一系列的扫码优化,稍微列一下跟今天主题相关的改动:

  1. 串行处理改成并发处理,zxing的原生处理流程是通过CameraManager获取到一帧的数据之后,通过DecodeHandler去处理,处理完成之后再去获取下一帧,我们给改成了线程池去调度:
  • 单帧decode任务入队列之后立即获取下一帧数据
  • 二维码识别成功则停止其他解析任务
  1. 为了有更大的识别区域,选择对整张拍摄图片进行解码,保证中心框框没对准二维码也能识别到

现象

当时测试反馈,手上一个很古老的 Android 5.0 的机器,打开扫一扫必崩,一看错误栈,是个OOM

机器找不到了,我就不贴现象的堆栈了(埋在时光里了,懒得挖了)。

排查OOM三板斧

板斧一、 通过一定手段,抓取崩溃时的或者崩溃前的内存快照

咦,一年前的hprof文件还在?确实被我找到了。。。

从图中我们能获得哪些信息?

  1. 用户OOM时,byte数组的 java 堆占用是爆炸的

  2. 用户OOM时,byte数组里,有大量的 3M 的byte数组

  3. 3Mbyte 数组是被 zxing 的 DecodeHandler$2 引用的

板斧二、从内存对照出发,大胆猜测找到坏死根源

我们既然知道了 大对象 是被 DecodeHandler$2 引用的,那么 DecodeHandler$2 是个啥呀?

mDecodeExecutor.execute(new Runnable() {
@Override
public void run() {
for (Reader reader : mReaders) {
decodeInternal(data, width, height, reader, fullScreenFrame);
}
}
});

所以稍微转动一下脑瓜子就能知道,必然是堆积了太多的 Runnable,每个Runnable 持有了一个 data 大对象才导致了这个OOM问题。

但是为啥会堆积太多 Runnable 呢?结合一下只有 Android 5.0 机器会OOM,我们大胆猜测一下,就是因为这个机器消费(或者说解码)单张 Bitmap 太慢,同时像上面所说的,我们单帧decode任务入队列之后立即获取下一帧数据并入队下一帧decode 任务,这就导致大对象堆积在了LinkedBlockingDeque中。

OK,到这里原因也清楚了,改掉就完事了。

板斧三、 吃个口香糖舒缓一下心情

呵呵...

解决方案

解决方案其实很简单,从问题出发即可,问题是啥?我生产面包速度是一天10个,一个一斤,但是一天只能吃三斤,那岂不就一天就会多7斤囤货,假如囤货到了100斤地球会毁灭,怎么解决呢?

  1. 吃快点,一天吃10斤
  2. 少生产点,要么生产个数减少,要么生产单个重量减少,要么二者一起
  3. 生产前检查一下吃完没,吃完再生产都来得及,实在不行定个阈值觉得不够吃了再生产嘛。

那么自然而然的就大概知道有哪几种解决办法了:

  1. 生产的小点 - 隔几帧插一张全屏帧即可(如果要保留不在框框内也能解码的特性的话)
  2. 生产前检查一下吃完没 - 线程池的线程空闲时,才去 enqueue decode 任务
  3. 生产单个重量减少 - 限制队列大小
  4. blalala

总结

装模作样的总结一下。

这个例子是一年前遇到的,今天想水篇文章又突然想到了这个事就拿来写写,我总结为:线程池调度 + 进阻塞队列单任务数据过大 + 处理任务过慢

线程池调度任务是啥场景?

  • 有个 Queue,来了任务,先入队
  • 有个 ThreadPool ,空闲了,从 Queue 取任务。

那么,当入队的数据结构占内存太大,且 ThreadPool 处理速度小于 入队速度呢?就会造成 Queue 中数据越来越多,直到 OOM

扫一扫完美的满足了上面条件

  • 入队频率足够高

  • 入队对象足够大

  • 处理速度足够慢。

在这个例子中,做的不足的地方:

  1. 追求并发未考虑机器性能

  2. 大对象处理不够谨慎

当然,总结是为了避免未来同样的惨案发生,大家可以想想还会有什么类似的场景吧,转动一下聪明的小脑袋瓜~

未来展望

装模作样展望一下,未来展望就是,以后有空多水水贴子吧(不是多水水贴吧)。


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

收起阅读 »

2022 年 App 上架审核问题集锦,全面踩坑上线不迷路

相信这几年负责过上架应用市场的 App 开发,或多或少都躺过上线审核的坑,经历过的各种问题也是千奇百怪,今天就给大家做个汇总,希望可以帮助大家少走弯路,争取做一个“优雅”的客户端开发。 首先,近年来为了 “净化” App 环境、保护用户隐私和优化用户体验,各部...
继续阅读 »

相信这几年负责过上架应用市场的 App 开发,或多或少都躺过上线审核的坑,经历过的各种问题也是千奇百怪,今天就给大家做个汇总,希望可以帮助大家少走弯路,争取做一个“优雅”的客户端开发。


首先,近年来为了 “净化” App 环境、保护用户隐私和优化用户体验,各部委大致出台过如下所示的相关法规:

内容时间
《教育移动互联网应用程序备案管理办法》2019 年 11 月 13 日
《App违法违规收集使用个人信息行为认定方法》2019 年 12 月 30 日
《常见类型移动互联网应用程序必要个人信息范围规定》2021 年 03 月 22 日
《个人信息保护法》2021 年 11 月 1 日
《移动互联网应用程序(App)个人信息保护治理白皮书》2021 年 11 月 22 日
《互联网用户账号信息管理规定》2022 年 1 月 1 日
《数据出境安全评估办法》2022 年 9 月 1 日
《互联网弹窗信息推送服务管理规定》2022 年 9 月 30 日

可能还有一些我不知道的遗漏,那不知道这些法规你是否都听说过,这里举一些常见例子:




  • 《互联网用户账号信息管理规定》 的第十二条就是在 App 展示用户 IP 的要求相关条款

  • 《常见类型移动互联网应用程序必要个人信息范围规定》就规定了 App 类目所能获取的权限范围和个人信息索取范围,例如新闻资讯类、浏览器类、安全管理类、应用商店类等无须个人信息,即可使用基本功能服务。


针对上面这个无需权限和个人信息也要提供基本功能服务,如下动图所示,今日头条、知乎和懂车帝就是很好的参考例子,在不同意个人隐私协议的情况下,会有仅浏览的模式,在这个情况下依然可以阅读内容而不是退出 App












所以严格意义上讲,现在 App 按照类目的规定,如果你的 App 在某些类目就只能获取对应权限,多了就是违规,而且一些类目必须用户在没有提供权限和同意协议的情况下,也必须提供服务




  • 《互联网弹窗信息推送服务管理规定》里就有: 弹窗推送广告显著标明“广告”,一键关闭,提供取消渠道等


如下图所示,从意见稿开始之后,基本大部分 App 的启动广告就限制了有效点击范围,产品经理也不能拍着脑袋让你加各种奇奇怪怪的跳转。





首先用户必须同意了你才能收集,不同意是不能收集,所以 App 里各式各样的弹出框就来了,这也是目前最常见的“合规方式”。


而导出个人信息的功能普遍是通过邮箱发送实现,事实上目前还有不少 App 没提供类似支持,还有 App 必须提供用户注销功能,这也是现在 App 开发的必选项,另外 App 还需要提供个性化推荐的开关能力,不然也有审核风险,当时有时候只是需要你放个按键。









image-20220909162615563

另外,在《个保法》的提案里也提及了不能以用户不提供个人信息为由不提供服务,当时实际执行往往还是要看应用类目。



而在用户个人信息认定里,设备id (Android ID) 绝对是重灾区 ,因为几乎是个 App 就会使用到设备 ID,特别是接入的各类第三方 SDK 服务里普遍都会获取。




而处理方法也是普通粗旷,用户不同意隐私协议,就不初始化各类 SDK ,当然,有时候你可能还是会遇到某些奇葩的审核,明明你已经做了处理,平台还认定你违规,这时候可能你就需要学会申诉,不要傻傻自己一直摸索哪里还不对。




总的来说上架问题一般是和个人信息隐私相关的问题最多,而常见的问题有:



  • 未经用户允许手机个人信息

  • 所需信息和服务无关,过度收集

  • 未提供导出和删除个人信息的功能服务

  • 存在个人信息泄漏风险

  • 未明确公布个人信息收集的目的和使用范围


最后这一条也是经常出现问题的点,例如现在会要求你提供哪些 SDK 使用了哪些权限和信息,收集规则是什么用于做什么 ,这也就需要 App 里提供更详细和丰富的隐私政策内容,当然 SDK 提供方也要。



而一般情况下最常见也是最容易触发整改的,就是设备ID,MAC 地址等相关内容,或者说你的 App 其实根本不需要这些也能提供服务,就如前面 《常见类型移动互联网应用程序必要个人信息范围规定》里的要求一样。



这里还有个关键,那就是用户在同意隐私条款时,你不能默认勾选,也就是有需要用户同意☑️的 UI 时,默认时不能选中,需要用户手动勾选同意。



当然,随着审核颗粒度的细化,越来越多奇奇怪怪的问题出现了,例如 Apk 里的资源文件存在安全泄漏问题 ,而解决该问题的有效方法就是:混淆和加固



加固和混淆也适用于以下相关问题的解决,当然,加固的话建议选用第三方付费服务,免费加固的坑实在太多了





















  • 《数据出境安全评估办法》 里针对数据出境也做了要求,其中最直观的例子就是:高德 SDK 无法在以外地区范围服务



当然,不只是相关法规,平台有时候也有自己的规定和理解,比如有几位群友,先后在小米因为 App 里提供 UI 和商店截图一致被打回,理由是应用截图与应用实际功能不符 ,相信遇到这类问题的兄弟是相当郁闷,因为不一致这个认定其实很主观










另外小米等平台还有以没通过Monkey 自动化测试为理由拒绝上架 ,一般这种情况推荐自己上传 testit.miui.com ,通过小米自动化测试后在上传审核时把你通过截图作为附加,这样可以解决审核时的扯皮问题。


有时候一些平台也会有安全扫描,例如华为就会扫描同名的包名,然后附上 git 链接告诉你风险










另外,华为审核时可能会对你的产品逻辑提出他们的想法,比如空白页面,添加引导,没有客服返回渠道等等。










还有另外一个高风险点就是自启动,相信我,如果你要上架平台,2022 年了就不要再想做什么保活相关的逻辑了



除此之外,如果平台说你存在问题,尽量想办法要到检测报告,因为有时候一些平台委托的第三方可能会不是很“靠谱“,然后需要你自己出钱区做”二次付费检测“。










除了上面的问题之后,如果你还遇到如下图类似问题,都可以通过一些官方平台的检测如 open.oppomobile.com/opdp/privac… 帮助查找问题,这样也许就可以帮老板省下一笔开销,当然有一些第三方开源平台如 Hegui3.0PrivacySentry 等项目,也可以帮助你解决一些实际问题



最后,如果关于什么上架审核或者安全合规等问题,欢迎留言评论,也许以后本篇可以作为一个更新集合,继续帮助到更多需要的可怜 App 开发


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

iOS的CoreData技术笔记

前言最近因为新项目想用到数据持久化,本来这是很简单的事情,复杂数据一般直接SQLite就可以解决了。但是一直以来使用SQLite确实存在要自己设计数据库,处理逻辑编码,还有调试方面的种种繁琐问题。所以考虑使用iOS的Core Data方案。上网查了一堆资料后,...
继续阅读 »

前言

最近因为新项目想用到数据持久化,本来这是很简单的事情,复杂数据一般直接SQLite就可以解决了。
但是一直以来使用SQLite确实存在要自己设计数据库,处理逻辑编码,还有调试方面的种种繁琐问题。所以考虑使用iOS的Core Data方案。
上网查了一堆资料后,发现很多代码都已经是陈旧的了。甚至苹果官方文档提供的代码样例都未必是最新的Swift版本。于是萌生了自己写一篇文章来整理一遍思路的想法。尽可能让新人快速的上手,不但要知道其然,还要知道其设计的所以然,这样用起来才更得心应手。

什么是Core Data

我们写app肯定要用到数据持久化,说白了,就是把数据保存起来,app不删除的话可以继续读写。
iOS提供数据持久化的方案有很多,各自有其特定用途。
比如很多人熟知的UserDefaults,大部分时候是用来保存简单的应用配置信息;而NSKeyedArchiver可以把代码中的对象保存为文件,方便后来重新读取。
另外还有个常用的保存方式就是自己创建文件,直接在磁盘文件中进行读写。
而对于稍微复杂的业务数据,比如收藏夹,用户填写的多项表格等,SQLite就是更合适的方案了。关于数据库的知识,我这里就不赘述了,稍微有点技术基础的童鞋都懂。
Core DataSQLite做了更进一步的封装,SQLite提供了数据的存储模型,并提供了一系列API,你可以通过API读写数据库,去处理想要处理的数据。但是SQLite存储的数据和你编写代码中的数据(比如一个类的对象)并没有内置的联系,必须你自己编写代码去一一对应。
Core Data却可以解决一个数据在持久化层和代码层的一一对应关系。也就是说,你处理一个对象的数据后,通过保存接口,它可以自动同步到持久化层里,而不需要你去实现额外的代码。
这种 对象→持久化 方案叫 对象→关系映射(英文简称ORM)。
除了这个最重要的特性,Core Data还提供了很多有用的特性,比如回滚机制,数据校验等。


图1: Core Data与应用,磁盘存储的关系

数据模型文件 - Data Model

当我们用Core Data时,我们需要一个用来存放数据模型的地方,数据模型文件就是我们要创建的文件类型。它的后缀是.xcdatamodeld。只要在项目中选 新建文件→Data Model 即可创建。
默认系统提供的命名为 Model.xcdatamodeld 。下面我依然以 Model.xcdatamodeld 作为举例的文件名。
这个文件就相当于数据库中的“库”。通过编辑这个文件,就可以去添加定义自己想要处理的数据类型。

数据模型中的表格 - Entity

当在xcode中点击Model.xcdatamodeld时,会看到苹果提供的编辑视图,其中有个醒目的按钮Add Entity
什么是Entity呢?中文翻译叫“实体”,但是我这里就不打算用各种翻译名词来提高理解难度了。
如果把数据模型文件比作数据库中的“库”,那么Entity就相当于库里的“表格”。这么理解就简单了。Entity就是让你定义数据表格类型的名词。
假设我这个数据模型是用来存放图书馆信息的,那么很自然的,我会想建立一个叫BookEntity

属性 - Attributes

当建立一个名为BookEntity时,会看到视图中有栏写着Attributes,我们知道,当我们定义一本书时,自然要定义书名,书的编码等信息。这部分信息叫Attributes,即书的属性。
Book的Entity
属性名类型
nameString
isbmString
pageInteger32
其中,类型部分大部分是大家熟知的元数据类型,可以自行查阅。
同理,也可以再添加一个读者:Reader的Entity描述。
Reader的Entity
属性名类型
nameString
idCardString


图2: 在项目中创建数据模型文件

关系 - Relationship

在我们使用Entity编辑时,除了看到了Attributes一栏,还看到下面有Relationships一栏,这栏是做什么的?
回到例子中来,当定义图书馆信息时,刚书籍和读者的信息,但这两个信息彼此是孤立的,而事实上他们存在着联系。
比如一本书,它被某个读者借走了,这样的数据该怎么存储?
直观的做法是再定义一张表格来处理这类关系。但是Core Data提供了更有效的办法 - Relationship
Relationship的思路来思考,当一本书A被某个读者B借走,我们可以理解为这本书A当前的“借阅者”是该读者B,而读者B的“持有书”是A。
从以上描述可以看出,Relationship所描述的关系是双向的,即A和B互相以某种方式形成了联系,而这个方式是我们来定义的。
ReaderRelationship下点击+号键。然后在Relationship栏的名字上填borrow,表示读者和书的关系是“借阅”,在Destination栏选择Book,这样,读者和书籍的关系就确立了。
对于第三栏,Inverse,却没有东西可以填,这是为什么?
因为我们现在定义了读者和书的关系,却没有定义书和读者的关系。记住,关系是双向的。
就好比你定义了A是B的父亲,那也要同时去定义B是A的儿子一个道理。计算机不会帮我们打理另一边的联系。
理解了这点,我们开始选择Book的一栏,在Relationship下添加新的borrowByDestinationReader,这时候点击Inverse一栏,会发现弹出了borrow,直接点上。
这是因为我们在定义BookRelationship之前,我们已经定义了ReaderRelationship了,所以电脑已经知道了读者和书籍的关系,可以直接选上。而一旦选好了,那么在ReaderRelationship中,我们会发现Inverse一栏会自动补齐为borrowBy。因为电脑这时候已经完全理解了双方的关系,自动做了补齐。


一对一和一对多 - to one和to many


我们建立ReaderBook之间的联系的时候,发现他们的联系逻辑之间还漏了一个环节。
假设一本书被一个读者借走了,它就不能被另一个读者借走,而当一个读者借书时,却可以借很多本书。
也就是说,一本书只能对应一个读者,而一个读者却可以对应多本书。
这就是 一对一→to one 和 一对多→to many 。
Core Data允许我们配置这种联系,具体做法就是在RelationShip栏点击对应的关系栏,它将会出现在右侧的栏目中。(栏目如果没出现可以在xcode右上角的按钮调出,如果点击后栏目没出现Relationship配置项,可以多点击几下,这是xcode的小bug)。
Relationship的配置项里,有一项项名为Type,点击后有两个选项,一个是To One(默认值),另一个就是To Many了。


图3: 数据模型的关系配置


Core Data框架的主仓库 - NSPersistentContainer


当我们配置完Core Data的数据类型信息后,我们并没有产生任何数据,就好比图书馆已经制定了图书的规范 - 一本书应该有名字、isbm、页数等信息,规范虽然制定了,却没有真的引进书进来。
那么怎么才能产生和处理数据呢,这就需要通过代码真刀真枪的和Core Data打交道了。
由于Core Data的功能较为强大,必须分成多个类来处理各种逻辑,一次性学习多个类是不容易的,还容易混淆,所以后续我会分别一一列出。
要和这些各司其职的类打交道,我们不得不提第一个要介绍的类,叫NSPersistentContainer,因为它就是存放这多个类成员的“仓库类”。
这个NSPersistentContainer,就是我们通过代码和Core Data打交道的第一个目标。它存放着几种让我们和Core Data进行业务处理的工具,当我们拿到这些工具之后,就可以自由的访问数据了。所以它的名字 - Container 蕴含着的意思,就是 仓库、容器、集装箱。
进入正式的代码编写的第一步,我们先要在使用Core Data框架的swift文件开头引入这个框架:

import CoreData

早期,在iOS 10之前,还没有NSPersistentContainer这个类,所以Core Data提供的几种各司其职的工具,我们都要写代码一一获得,写出来的代码较为繁琐,所以NSPersistentContainer并不是一开始就有的,而是苹果框架设计者逐步优化出来的较优设计。


图4: NSPersistentContainer和其他成员的关系


NSPersistentContainer的初始化


在新建的UIKIT项目中,找到我们的AppDelegate类,写一个成员函数(即方法,后面我直接用函数这个术语替代):

private func createPersistentContainer() {
let container = NSPersistentContainer(name: "Model")
}

这样,NSPersistentContainer类的建立就完成了,其中"Model"字符串就是我们建立的Model.xcdatamodeld文件。但是输入参数的时候,我们不需要(也不应该)输入.xcdatamodeld后缀。
当我们创建了NSPersistentContainer对象时,仅仅完成了基础的初始化,而对于一些性能开销较大的初始化,比如本地持久化资源的加载等,都还没有完成,我们必须调用NSPersistentContainer的成员函数loadPersistentStores来完成它。

private func createPersistentContainer() {
let container = NSPersistentContainer(name: "Model")
container.loadPersistentStores { (description, error) in
if let error = error {
fatalError("Error: \(error)")
}
print("Load stores success")
}
}

从代码设计的角度看,为什么NSPersistentContainer不直接在构造函数里完成数据库的加载?这就涉及到一个面向对象的开发原则,即构造函数的初始化应该是(原则上)倾向于原子级别,即简单的、低开销内存操作,而对于性能开销大的,内存之外的存储空间处理(比如磁盘,网络),应尽量单独提供成员函数来完成。这样做是为了避免在构造函数中出错时错误难以捕捉的问题。


表格属性信息的提供者 - NSManagedObjectModel


现在我们已经持有并成功初始化了Core Data的仓库管理者NSPersistentContainer了,接下去我们可以使用向这个管理者索取信息了,我们已经在模型文件里存放了读者和书籍这两个Entity了,如何获取这两个Entity的信息?
这就需要用到NSPersistentContainer的成员,即managedObjectModel,该成员就是标题所说的NSManagedObjectModel类型。
为了讲解NSManagedObjectModel能提供什么,我通过以下函数来提供说明:

private func parseEntities(container: NSPersistentContainer) {
let entities = container.managedObjectModel.entities
print("Entity count = \(entities.count)\n")
for entity in entities {
print("Entity: \(entity.name!)")
for property in entity.properties {
print("Property: \(property.name)")
}
print("")
}
}

为了执行上面这个函数,需要修改createPersistentContainer,在里面调用parseEntities

private func createPersistentContainer() {
let container = NSPersistentContainer(name: "Model")
container.loadPersistentStores { (description, error) in
if let error = error {
fatalError("Error: \(error)")
}

self.parseEntities(container: container)
}
}

在这个函数里,我们通过NSPersistentContainer获得了NSManagedObjectModel类型的成员managedObjectModel,并通过它获得了文件Model.xcdatamodeld中我们配置好的Entity信息,即图书和读者。
由于我们配置了两个Entity信息,所以运行正确的话,打印出来的第一行应该是Entity count = 2
container的成员managedObjectModel有一个成员叫entities,它是一个数组,这个数组成员的类型叫NSEntityDescription,这个类名一看就知道是专门用来处理Entity相关操作的,这里就没必要多赘述了。
示例代码里,获得了entity数组后,打印entity的数量,然后遍历数组,逐个获得entity实例,接着遍历entity实例的properties数组,该数组成员是由类型NSPropertyDescription的对象组成。
关于名词Property,不得不单独说明下,学习一门技术最烦人的事情之一就是理解各种名词,毕竟不同技术之间名词往往不一定统一,所以要单独理解一下。
Core Data的术语环境下,一个Entity由若干信息部分组成,之前已经提过的EntityRelationship就是了。而这些信息用术语统称为propertyNSPropertyDescription看名字就能知道,就是处理property用的。
只要将这一些知识点梳理清楚了,接下去打印的内容就不难懂了:

Entity count = 2

Entity: Book
Property: isbm
Property: name
Property: page
Property: borrowedBy

Entity: Reader
Property: idCard
Property: name
Property: borrow

我们看到,打印出来我们配置的图书有4个property,最后一个是borrowedBy,明显这是个Relationship,而前面三个都是Attribute,这和我刚刚对property的说明是一致的。

Entity对应的类

开篇我们就讲过,Core Data是一个 对象-关系映射 持久化方案,现在我们在Model.xcdatamodeld已经建立了两个Entity,那么如果在代码里要操作他们,是不是会有对应的类?
答案是确实如此,而且你还不需要自己去定义这个类。
如果你点击Model.xcdatamodeld编辑窗口中的Book这个Entity,打开右侧的属性面板,属性面板会给出允许你编辑的关于这个Entity的信息,其中Entity部分的Name就是我们起的名字Book,而下方还有一个Class栏,这一栏就是跟Entity绑定的类信息,栏目中的Name就是我们要定义的类名,默认它和Entity的名字相同,也就是说,类名也是Book。所以改与不改,看个人思路以及团队的规范。
所有Entity对应的类,都继承自NSManagedObject
为了检验这一点,我们可以在代码中编写这一行作为测试:

var book: Book! // 纯测验代码,无业务价值

如果写下这一行编译通过了,那说明开发环境已经给我们生成了Book这个类,不然它就不可能编译通过。
测试结果,完美编译通过。说明不需要我们自己编写,就可以直接使用这个类了。
关于类名,官方教程里一般会把类名更改为Entity名 + MO,比如我们这个Entity名为Book,那么如果是按照官方教程的做法,可以在面板中编辑Class的名字为BookMO,这里MO大概就是Model Object的简称吧。
但是我这里为简洁起见,就不做任何更改了,Entity名为Book,那么类名也一样为Book
另外,你也可以自己去定义Entity对应的类,这样有个好处是可以给类添加一些额外的功能支持,这部分Core Data提供了编写的规范,但是大部分时候这个做法反而会增加代码量,不属于常规操作。


数据业务的操作员 - NSManagedObjectContext


接下来我们要隆重介绍NSPersistentContainer麾下的一名工作任务最繁重的大将,成员viewContext,接下去我们和实际数据打交道,处理增删查改这四大操作,都要通过这个成员才能进行。
viewContext成员的类型是NSManagedObjectContext
NSManagedObjectContext,顾名思义,它的任务就是管理对象的上下文。从创建数据,对修改后数据的保存,删除数据,修改,五一不是以它为入口。
从介绍这个成员开始,我们就正式从 定义数据 的阶段,正式进入到 产生和操作数据 的阶段。


数据的插入 - NSentityDescription.insertNewObject


梳理完前面的知识,就可以正式踏入数据创建的学习了。
这里,我们先尝试创建一本图书,用一个createBook函数来进行。示例代码如下:

private func createBook(container: NSPersistentContainer,
name: String, isbm: String, pageCount: Int) {
let context = container.viewContext
let book = NSEntityDescription.insertNewObject(forEntityName: "Book",
into: context) as! Book
book.name = name
book.isbm = isbm
book.page = Int32(pageCount)
if context.hasChanges {
do {
try context.save()
print("Insert new book(\(name)) successful.")
} catch {
print("\(error)")
}
}
}

在这个代码里,最值得关注的部分就是NSEntityDescription的静态成员函数insertNewObject了,我们就是通过这个函数来进行所要插入数据的创建工作。
insertNewObject对应的参数forEntityName就是我们要输入的Entity名,这个名字当然必须是我们之前创建好的Entity有的名字才行,否则就出错了。因为我们要创建的是书,所以输入的名字就是Book
into参数就是我们的处理增删查改的大将NSManagedObjectContext类型。
insertNewObject返回的类型是NSManagedObject,如前所述,这是所有Entity对应类的父类。因为我们要创建的EntityBook,我们已经知道对应的类名是Book了,所以我们可以放心大胆的把它转换为Book类型。
接下来我们就可以对Book实例进行成员赋值,我们可以惊喜的发现Book类的成员都是我们在Entity表格中编辑好的,真是方便极了。
那么问题来了,当我们把Book编辑完成后,是不是这个数据就完成了持久化了,其实不是的。
这里要提一下Core Data的设计理念:懒原则。Core Data框架之下,任何原则操作都是内存级的操作,不会自动同步到磁盘或者其他媒介里,只有开发者主动发出存储命令,才会做出存储操作。这么做自然不是因为真的很懒,而是出于性能考虑。
为了真的把数据保存起来,首先我们通过context(即NSManagedObjectContext成员)的hasChanges成员询问是否数据有改动,如果有改动,就执行contextsave函数。(该函数是个会抛异常的函数,所以用do→catch包裹起来)。
至此,添加书本的操作代码就写完了。接下来我们把它放到合适的地方运行。
我们对createPersistentContainer稍作修改:

private func createPersistentContainer() {
let container = NSPersistentContainer(name: "Model")
container.loadPersistentStores { (description, error) in
if let error = error {
fatalError("Error: \(error)")
}

//self.parseEntities(container: container)
self.createBook(container: container,
name: "算法(第4版)",
isbm: "9787115293800",
pageCount: 636)
}
}

运行项目,会看到如下打印输出:

Insert new book(算法(第4版)) successful.

至此,书本的插入工作顺利完成!

因为这个示例没有去重判定,如果程序运行两次,那么将会插入两条书名都为"算法(第4版)"的book记录。

数据的获取

有了前面基础知识的铺垫,接下去的例子只要 记函数 就成了,读取的示例代码:

private func readBooks(container: NSPersistentContainer) {
let context = container.viewContext
let fetchBooks = NSFetchRequest<Book>(entityName: "Book")
do {
let books = try context.fetch(fetchBooks)
print("Books count = \(books.count)")
for book in books {
print("Book name = \(book.name!)")
}
} catch {

}
}

处理数据处理依然是我们的数据操作主力context,而处理读取请求配置细节则是交给一个专门的类,NSFetchRequest来完成,因为我们处理读取数据有各种各样的类型,所以Core Data设计了一个泛型模式,你只要对NSFetchRequest传入对应的类型,比如Book,它就知道应该传回什么类型的对应数组,其结果是,我们可以通过Entity名为Book的请求直接拿到Book类型的数组,真是很方便。

打印结果:

Books count = 1
Book name = 算法(第4版)


数据获取的条件筛选 - NSPredicate


通过NSFetchRequest我们可以获取所有的数据,但是我们很多时候需要的是获得我们想要的特定的数据,通过条件筛选功能,可以实现获取出我们想要的数据,这时候需要用到NSFetchRequest的成员predicate来完成筛选,如下所示,我们要找书名叫 算法(第4版) 的书。
在新的代码示例里,我们在之前实现的readBooks函数代码里略作修改:

private func readBooks(container: NSPersistentContainer) {
let context = container.viewContext
let fetchBooks = NSFetchRequest<Book>(entityName: "Book")
fetchBooks.predicate = NSPredicate(format: "name = \"算法(第4版)\"")
do {
let books = try context.fetch(fetchBooks)
print("Books count = \(books.count)")
for book in books {
print("Book name = \(book.name!)")
}
} catch {
print("\(error)")
}
}

通过代码:

fetchBooks.predicate = NSPredicate(format: "name = \"算法(第4版)\"")

我们从书籍中筛选出书名为 算法(第4版) 的书,因为我们之前已经保存过这本书,所以可以正确筛选出来。
筛选方案还支持大小对比,如

fetchBooks.predicate = NSPredicate(format: "page > 100")

这样将筛选出page数量大于100的书籍。

数据的修改

当我们要修改数据时,比如说我们要把 isbm = "9787115293800" 这本书书名修改为 算法(第5版) ,可以按照如下代码示例:

let context = container.viewContext
let fetchBooks = NSFetchRequest<Book>(entityName: "Book")
fetchBooks.predicate = NSPredicate(format: "isbm = \"9787115293800\"")
do {
let books = try context.fetch(fetchBooks)
if !books.isEmpty {
books[0].name = "算法(第5版)"
if context.hasChanges {
try context.save()
print("Update success.")
}
}
} catch {
print("\(error)")
}

在这个例子里,我们遵循了 读取→修改→保存 的思路,先拿到筛选的书本,然后修改书本的名字,当名字被修改后,context将会知道数据被修改了,这时候判断数据是否被修改(实际上不需要判断我们也知道被修改了,只是出于编码规范加入了这个判断),如果被修改,就保存数据,通过这个方式,成功更改了书名。

数据的删除

数据的删除依然遵循 读取→修改→保存 的思路,找到我们想要的思路,并且删除它。删除的方法是通过contextdelete函数。
以下例子中,我们删除了所有 isbm="9787115293800" 的书籍:

let context = container.viewContext
let fetchBooks = NSFetchRequest<Book>(entityName: "Book")
fetchBooks.predicate = NSPredicate(format: "isbm = \"9787115293800\"")
do {
let books = try context.fetch(fetchBooks)
for book in books {
context.delete(books[0])
}
if context.hasChanges {
try context.save()
}
} catch {
print("\(error)")
}

扩展和进阶主题的介绍

如果跟我一步步走到这里,那么关于Core Data的基础知识可以说已经掌握的差不多了。当然了,这部分基础对于日常开发已经基本够用了。
关于Core Data开发的进阶部分,我在这里简单列举一下:
  1. Relationship部分的开发,事实上通过之前的知识可以独立完成。
  2. 回滚操作,相关类:UndoManager
  3. EntityFetched Property属性。
  4. 多个context一起操作数据的冲突问题。
  5. 持久化层的管理,包括迁移文件地址,设置多个存储源等。
以上诸个主题都可以自己进一步探索,不在这篇文章的讲解范围。不过后续不排除会单独出文探索。

结语

Core Data在圈内是比较出了名的“不好用”的框架,主要是因为其抽象的功能和机制较为不容易理解。本文已经以最大限度的努力试图从设计的角度去阐述该框架,希望对你有所帮助。

收起阅读 »

程序员的坏习惯

前言每位开发人员在自己的职业生涯、学习经历中,都会出一些坏习惯,本文将列举开发人员常犯的坏习惯。希望大家能够意识和改变这些坏习惯。不遵循项目规范每个公司都会定义一套代码规范、代码格式规范、提交规范等,但是有些开发人员就是不遵循相关的 规范,命名不规范、魔鬼数字...
继续阅读 »

前言

每位开发人员在自己的职业生涯、学习经历中,都会出一些坏习惯,本文将列举开发人员常犯的坏习惯。希望大家能够意识和改变这些坏习惯。


不遵循项目规范

每个公司都会定义一套代码规范、代码格式规范、提交规范等,但是有些开发人员就是不遵循相关的 规范,命名不规范、魔鬼数字、提交代码覆盖他人代码等问题经常发生,如果大家能够遵循相关规范,这些问题都可以避免。

用复杂SQL语句来解决问题

程序员在开发功能时,总想着是否能用一条SQL语句来完成这个功能,于是实现的SQL语句写的非常复杂,包含各种子查询嵌套,函数转换等。这样的SQL语句一旦出现了性能问题,很难进行相关优化。

缺少全局把控思维,只关注某一块业务

新增新功能只关注某一小块业务,不考虑系统整体的扩展性,其他模块已经有相关的实现了,却又重复实现,导致重复代码严重。修改功能不考虑对其他模块的影响。

函数复杂冗长,逻辑混乱

一个函数几百行,复杂函数不做拆分,导致代码变得越来月臃肿,最后谁也不敢动。函数还是要遵循设计模式的单一职责,一个函数只做一件事情。如果函数逻辑确实复杂,需要进行拆分,保证逻辑清晰。

缺乏主动思考,拿来主义

实现相关功能,先网上百度一下,拷贝相关的代码,能够运行成功认为万事大吉。到了生产却出现了各种各样的问题,因为网上的demo程序和实际项目的在场景使用上有区别,尤其是相关的参数配置,一定要弄清楚具体的含义,不同场景下,设置参数的值不同。

核心业务逻辑,缺少相关日志和注释

很多核心的业务逻辑实现,整个方法几乎没看到相关注释和日志打印,除了自己能看懂代码逻辑,其他人根本看不懂。一旦生产出了问题,找不到有效的日志输出,问题根本无法定位。

修改代码,缺少必要测试

很多人都会存在侥幸心里,认为只是改了一个变量或者只修改一行代码,不用自测了应该没有问题,殊不知就是因为改一行代码导致了严重的bug。所以修改代码一定要进行自测。

需求没理清,直接写代码

很多程序员在接到需求后,不怎么思考就开始写代码,写着写着发现自己的理解与实际的需求有偏差,造成无意义返工。所以需要多花些时间梳理需求,整理相关思路,能规避很多不合理的问题。

讨论问题,表达没有逻辑、没有重点

讨论问题不交代背景,上来就说自己的方案,别人听得云里雾里,让你从头描述你又讲不明。需要学会沟通和表达,才能进行有效的沟通和合作。

不能从错误中吸取教训

作为一位开发人员,你会犯很多错误,这不可避免也没什么大不了的。但如果你总是犯同样的错误,不能从中吸取教训,那态度就出现问题了。

总结

关于这些坏习惯,你是否中招了,大家应该尽早规避这些坏习惯,成为一名优秀的程序员。


作者:剑圣无痕
来源:juejin.cn/post/7136455796979662862

收起阅读 »

移动端页面秒开优化总结

前言  App优化,是一个工作、面试或KPI都绕不开的话题,如何让用户使用流畅呢?今天谨以此篇文章总结一下过去两个月我在工作中的优化事项到底有那些,优化方面还算小白,有不对的地方还望指出海涵, 该文章主要通过讲述Native跳转到Flutter界面秒开率提升。...
继续阅读 »

前言

  App优化,是一个工作、面试或KPI都绕不开的话题,如何让用户使用流畅呢?今天谨以此篇文章总结一下过去两个月我在工作中的优化事项到底有那些,优化方面还算小白,有不对的地方还望指出海涵, 该文章主要通过讲述Native跳转到Flutter界面秒开率提升

问题分析

  当你拿到反馈App页面渲染时间长的工单的时候,第一步想到的不应该是有那些那些方法可以降低耗时,我们应该根据自己的真实业务触发,第一步 验证 通过打点或者工具去验证这个问题,了解 一个页面打开耗时的统计方式分析一个打开耗时是由那些方面组成,通过那些技术手段去解决80%的问题,抓大放小去处理问题。

  通过工具分析启动链路耗时,发现部分必要接口RT时间较长,Flutter引擎冷启耗时较长和View渲染耗时为主要耗时项。接下来就围绕着三个大方面去做一些优化。

网络优化

   以Android 界面跳转链路来说 ,具体链路看下图(模拟数据 主要明白思想)


   看到串行,就知道这里肯定可以有文章做


  可以看到在网络请求可以提前到 Router环节去解析并进行预加载,并行的话可以优化 必要接口RT的时长,节省的时间在页面秒开链路中占比最多。

  在这里需要兼容网络返回较慢的情况,我们可以引入骨架图,提升上屏率。

数据预请求

Router和请求

  通过拦截路由地址,判断路径是否属于预请求白名单。如果匹配,进入预请求逻辑,发起网络拼接和请求,在获取到结果进行本地缓存,供消费界面去消费。因为考虑到网络返回如果慢与界面,可以提供回调,消费界面进来进行绑定。

端侧通讯

   由于Native 跳转到 Flutter ,所以这里需要借助 Channel来进行管道传递,这里我们没有使用MethodChannel 而是选择 可以Native主动通知Flutter 的EventChannel来接收消息。

public class EventChannelManager implements IFlutterProphetPlugin {
   private static Map<String, EventChannel.EventSink> cachedEventSinkMap = new HashMap<>();
   private static LinkedList<Object> dataList = new LinkedList<>();

   public final static String CHANNEL_REQUEST_PRE = "event_channel";

   private static EventChannelManager instance;

   public static EventChannelManager getInstance() {
       if (null == instance) {
           instance = new EventChannelManager();
      }
       return instance;
  }

   @Creator
   public static IFlutterProphetPlugin create() {
       return new EventChannelManager();
  }

   //初始化
   @Override
   public void initChannel(FlutterEngine engine) {
       try {
           EventChannel eventChannel_pre = new EventChannel(engine.getDartExecutor(), CHANNEL_REQUEST_PRE);
           eventChannel_pre.setStreamHandler(new ProphetStreamHandler(CHANNEL_REQUEST_PRE));
      } catch (Exception ex) {
           Log.e(TAG, "init channel err :" + ex.getMessage());
      }
  }

   //发送消息
   @Override
   public void sendEventToStream(String eventChannel, Object data) {
       synchronized (this) {
           try {
               EventChannel.EventSink eventSink = cachedEventSinkMap.get(eventChannel);
               if (null != eventSink) {
                   eventSink.success(data);
              } else {
                   dataList.add(data);
              }
          } catch (Exception ex) {
          }
      }
  }

   //关闭
   public void cancel(String eventChannel) {
       EventChannel.EventSink eventSink = cachedEventSinkMap.get(eventChannel);
       if (null != eventSink) {
           eventSink.endOfStream();
      }
  }

   public static class ProphetStreamHandler implements EventChannel.StreamHandler {
       private String eventChannel;

       public ProphetStreamHandler(String eventChannel) {
           this.eventChannel = eventChannel;
      }

       @Override
       public void onListen(Object arguments, EventChannel.EventSink events) {
           cachedEventSinkMap.put(eventChannel, events);
           if (dataList.size() != 0) {
               for (Object obj : dataList) {
                   events.success(obj);
              }
               dataList.clear();
          }
      }

       @Override
       public void onCancel(Object arguments) {
           cachedEventSinkMap.remove(eventChannel);
      }
  }

}

上述代码为通用EventChannel创建和发送消息工具类,接口不贴了....

缓存

  预请求模块中,如果网络请求结果成功,可以将结果写入缓存SDK中(可以根据缓存SDK策略,内存和磁盘缓存都做好处理)。结合缓存策略,再次进入界面即可先读取缓存数据上屏,通过顶部Load状态提醒用户 预请求的数据正在加载中,来缩短秒开时间。

端智能

  通过大数据和算法对用户习惯性的使用链路进行分析,判断用户下一个节点将会进入哪个界面,匹配到预请求白名单,也可以更早的进行预请求逻辑 (没有集团SDK支撑的话可以不列为主要优化方式)。


数据后带

  以自己维护的App来说,首屏商品列表会返回很多数据包括但不限于:商品Url、商品名称、价格等核心信息,在进入商品详情中,我们通常会把商品id发送到详情界面,并再次进行商品详情接口的请求,那么我们可以通过数据后带的方式,先让详情页核心数据显示出来,然后通过局部骨架图来等待详情信息的返回,感官上缩短界面等待时长。

数据延后

  首屏中还会有很多二级弹窗列表数据接口的请求,其实这里的接口可以通过延后的方式来加载并渲染出来,减少首屏刚开始的CPU使用,为核心View渲染让步,减少CPU竞争。

业务逻辑优化

  部分不重要接口除了可以延后处理外,还可以通过推动后端合理缩小数据结构,减少不必要的网络消耗产生。对于部分小量接口,可以通过搭车的方式 进行接口合并 一块返回,部分数据可能不需要实时更新的,可以减少不必要请求来进行优化。

布局优化

异步加载

  假设场景是搜索结果列表,我们可以在数据请求前置的同时,去异步 inflate 一些 recyclerview 的 itemview,渲染阶段就可以节约 createViewHolder 的时间。(这里只是进行一个场景举例,更多的使用方法和业务强耦合,需要自行分析和合理设计避免负向优化)

递进加载

  顾名思义,其实递进加载和数据延后请求原理相似,每个界面可能都会有重要View,以商品列表为例,我可能更希望商品列表数据先返回回来,其他的接口可以延后,提升界面渲染速度。

作者:小肥羊冲冲冲Android
来源:juejin.cn/post/7121636526596816933

收起阅读 »

如何让一套代码完美适配各种屏幕?

一、适配的目的区别于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与详情页会同时出现在用户的视觉内,如下图


关于这种类型的设计,其实郭霖《第一行代码》给出了一个方案,我在这里抛砖引玉一下,给出基本思路。

这种情况下,适配的核心在于利用android动态加载布局的机制,使得程序能够根据分辨率或者屏幕大小在运行时动态加载不同的布局,而动态加载就需要使用到限定符

  • 限定符 所谓限定符,指的是给res目录中的子目录加上“-限定符”,可以给不同设备提供不同的资源以及布局,如下图,layout添加-large,-small。


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),当当前设备屏幕宽度大于这个值就加载一个布局,


例如在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
复制代码

三、刘海屏适配



  • 有状态栏的界面:刘海区域会显示状态栏,无需适配;

  • 全屏界面:刘海区域可能遮挡内容,需要适配;

针对刘海屏适配,在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



3.2、各大厂商适配方案(华为、小米、oppo等)

除了在AndroidP系统下官方给了适配方案,各大厂商针对自家系统也给出了相应的适配方案,可参考:

oppo
vivo
小米
华为

参考文档
今日头条适配方案
Android9.0官方适配方案

作者:付十一

来源:juejin.cn/post/7117630529595244558

收起阅读 »

前端按钮/组件权限管理

最近项目中遇到了按钮权限管理的需求,整理了一下目前的方案,有不对的地方望大家指出~方案1:数组+自定义指令把权限放到数组中,通过vue的自定义指令来判断是否拥有该权限,有则显示,反之则不显示我们可以把这个按钮需要的权限放到组件上<el-button v...
继续阅读 »

最近项目中遇到了按钮权限管理的需求,整理了一下目前的方案,有不对的地方望大家指出~

方案1:数组+自定义指令

把权限放到数组中,通过vue的自定义指令来判断是否拥有该权限,有则显示,反之则不显示

我们可以把这个按钮需要的权限放到组件上

<el-button
v-hasPermi="['home:advertising:update']"
>新建</el-button>

自定义指令:

逻辑就是我们在登陆后会获取该用户的权限,并存储到localStorage中,当一个按钮展示时会判断localStorage存储的权限列表中是否存在该按钮所需的权限。

/**
* 权限处理
*/

export default {
 inserted(el, binding, vnode) {
   const { value } = binding;
   const SuperPermission = "superAdmin"; // 超级用户,用于开发和测试
   const permissions = localStorage.getItem('userPermissions')&& localStorage.getItem('userPermissions').split(',');
// 判断传入的组件权限是否符合要求
   if (value && value instanceof Array && value.length > 0) {
     const permissionFlag = value;
     const hasPermissions = permissions && permissions.some(permission => all_permission === permission || permissionFlag.includes(permission));
// 判断是否有权限是否要展示
     if (!hasPermissions) {
       el.parentNode && el.parentNode.removeChild(el);
    }
  } else {
     throw new Error(`请设置操作权限标签值`);
  }
},
};

注册权限

import Vue from 'vue';
import Vpermission from "./permission";

// 按钮权限 自定义指令
Vue.directive('permission', Vpermission);

关于路由权限

数组的方案也可以用到菜单权限上,可以在路由的meta中携带该路由所需的权限,例如:

const router = [{
 path: 'needPermissionPage',
 name: 'NeedPermissionPage',
 meta: {
   role: ['permissionA', 'permissionB'],
},
}]

这个时候就需要在渲染权限的时候动态渲染了,该方案可以看一下其他的文章或成熟的项目,写的非常好

方案2: 二进制

通过二进制来控制权限:

假设我们有增删改查四个基本权限:

const UPDATE = 0b000001;
const DELETE = 0b000010;
const ADD = 0b000100;
const SEARCH = 0b001000;

每一位代表是否有该权限,有该权限则是1,反之是0

表达权限:

我们可以使用或运算来表达一个权限结果,或运算:两个任何一个为1,结果就为1

const reslut = UPDATE | DELETE | SEARCH;
console.log(reslut);  // 11

变成了十进制,我们可以通过toString方法变为二进制结果

const reslut = UPDATE | DELETE | SEARCH;
console.log(reslut.toString(2));  // 1011

result 这个结果就代表我们既拥有更新权限,同时也拥有删除和查询的权限

那么我们可以将十进制的reslut当作该用户的权限,把这个结果给后台,下次用户登陆后只需要返回这个结果就可以了。

权限判断

我们了解了如何表达一个权限,那如何做权限的判断呢?

可以通过且运算,且运算:两位都为1,这一位的结果才是1。

还是用上面的结果,当我们从接口中拿到了reslut,判断他是否有 DELETE 权限:

console.log((reslut & DELETE) === DELETE);  // true

是否有新增的权限

console.log((result & ADD) === ADD); // false

判断和使用

/**
* 接受该组件所需的权限,返回用户权限列表是否有该权限
* @param {String} permission
* @returns {Boolean}
*/
function hasPermission(permission) {
 const permissionList = {
   UPDATE: 0b000001,
   DELETE: 0b000010,
   CREATE: 0b000100,
   SEARCH: 0b001000
}
 let btnPermission = permissionList[permission] ? permissionList[permission] : -1;
 if (btnPermission === -1) return false;
 const userPermission = localStorage.getItem('userPermissions');
// 将本地十进制的值转换为二进制
 const userPermissionBinary = userPermission.toString(2);
// 对比组件所需权限和本地存储的权限
 return (userPermissionBinary & btnPermission) === btnPermission;
}

直接在组件中通过v-show/v-if来控制是否展示

<el-button v-show="hasPermission('UPDATE')">更新</el-button>

小结

我理解来说,对于方案1来说,方案2的优势在于更简洁,后台仅需要存储一个十进制的值,但如果后期新增需求更新了新的权限,可能需要调整二进制的位数来满足业务需求。方案1的优势在于更加易懂,新增权限时仅需要更新组件自定义指令的数组。

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





收起阅读 »

毕业5年了还不知道热修复?

前言 热修复到现在2022年已经不是一个新名词,但是作为Android开发核心技术栈的一部分,我这里还得来一次冷饭热炒。 随着移动端业务复杂程度的增加,传统的版本更新流程显然无法满足业务和开发者的需求, 热修复技术的推出在很大程度上改善了这一局面。国内大部分成...
继续阅读 »

前言


热修复到现在2022年已经不是一个新名词,但是作为Android开发核心技术栈的一部分,我这里还得来一次冷饭热炒。


随着移动端业务复杂程度的增加,传统的版本更新流程显然无法满足业务和开发者的需求,
热修复技术的推出在很大程度上改善了这一局面。国内大部分成熟的主流 App都拥有自己的热更新技术,像手淘、支付宝、微信、QQ、饿了么、美团等。


可以说,一个好的热修复技术,将为你的 App助力百倍。对于每一个想在 Android 开发领域有所造诣的开发者,掌握热修复技术更是必备的素质


热修复是 Android 大厂面试中高频面试知识点,也是我们必须要掌握的知识点。热修复技术,可以看作 Android平台发展成熟至一定阶段的必然产物。
Android热修复了解吗?修复哪些东西?
常见热修复框架对比以及各原理分析?


1.什么是热修复


热修复说白了就是不再使用传统的应用商店更新或者自更新方式,使用补丁包推送的方式在用户无感知的情况下,修复应用bug或者推送新的需求


传统更新热更新过程对比如下:


热修复过程图.jpg


热修复优缺点:



  • 优点:

    • 1.只需要打补丁包,不需要重新发版本。

    • 2.用户无感知,不需要重新下载最新应用

    • 3.修复成功率高



  • 缺点

    • 补丁包滥用,容易导致应用版本不可控,需要开发一套完整的补丁包更新机制,会增加一定的成本




2.热修复方案


首先我们得知道热修复修复哪些东西



  • 1.代码修复

  • 2.资源修复

  • 3.动态库修复


2.1:代码修复方案


从技术角度来说,我们的目的是非常明确的:把错误的代码替换成正确的代码。
注意这里的替换,并不是直接擦写dx文件,而是提供一份新的正确代码,让应用运行时绕过错误代码,执行新的正确代码。


热修复方法过程.png


想法简单直接,但实现起来并不容易。目前主要有三类技术方案:


2.1.1.类加载方案


之前分析类加载机制有说过:
加载流程先是遵循双亲委派原则,如果委派原则没有找到此前加载过此类,
则会调用CLassLoader的findClass方法,再去BaseDexClassLoader下面的dexElements数组中查找,如果没有找到,最终调用defineClassNative方法加载


代码修复就是基于这点:
将新的做了修复的dex文件,通过反射注入到BaseDexClassLoader的dexElements数组的第一个位置上dexElements[0],下次重新启动应用加载类的时候,会优先加载做了修复的dex文件,这样就达到了修复代码的目的。原理很简单


代码如下:


public class Hotfix {

public static void patch(Context context, String patchDexFile, String patchClassName)
throws ClassNotFoundException, NoSuchFieldException, IllegalAccessException, InstantiationException, NoSuchMethodException, InvocationTargetException {
//获取系统PathClassLoader的"dexElements"属性值
PathClassLoader pathClassLoader = (PathClassLoader) context.getClassLoader();
Object origDexElements = getDexElements(pathClassLoader);

//新建DexClassLoader并获取“dexElements”属性值
String otpDir = context.getDir("dex", 0).getAbsolutePath();
Log.i("hotfix", "otpdir=" + otpDir);
DexClassLoader nDexClassLoader = new DexClassLoader(patchDexFile, otpDir, patchDexFile, context.getClassLoader());
Object patchDexElements = getDexElements(nDexClassLoader);

//将patchDexElements插入原origDexElements前面
Object allDexElements = combineArray(origDexElements, patchDexElements);

//将新的allDexElements重新设置回pathClassLoader
setDexElements(pathClassLoader, allDexElements);

//重新加载类
pathClassLoader.loadClass(patchClassName);
}
private static Object getDexElements(ClassLoader classLoader) throws ClassNotFoundException, NoSuchFieldException, IllegalAccessException {
//首先获取ClassLoader的“pathList”实例
Field pathListField = Class.forName("dalvik.system.BaseDexClassLoader").getDeclaredField("pathList");
pathListField.setAccessible(true);//设置为可访问
Object pathList = pathListField.get(classLoader);

//然后获取“pathList”实例的“dexElements”属性
Field dexElementField = pathList.getClass().getDeclaredField("dexElements");
dexElementField.setAccessible(true);

//读取"dexElements"的值
Object elements = dexElementField.get(pathList);
return elements;
}
//合拼dexElements
private static Object combineArray(Object obj, Object obj2) {
Class componentType = obj2.getClass().getComponentType();
//读取obj长度
int length = Array.getLength(obj);
//读取obj2长度
int length2 = Array.getLength(obj2);
Log.i("hotfix", "length=" + length + ",length2=" + length2);
//创建一个新Array实例,长度为ojb和obj2之和
Object newInstance = Array.newInstance(componentType, length + length2);
for (int i = 0; i < length + length2; i++) {
//把obj2元素插入前面
if (i < length2) {
Array.set(newInstance, i, Array.get(obj2, i));
} else {
//把obj元素依次放在后面
Array.set(newInstance, i, Array.get(obj, i - length2));
}
}
//返回新的Array实例
return newInstance;
}
private static void setDexElements(ClassLoader classLoader, Object dexElements) throws ClassNotFoundException, NoSuchFieldException, IllegalAccessException {
//首先获取ClassLoader的“pathList”实例
Field pathListField = Class.forName("dalvik.system.BaseDexClassLoader").getDeclaredField("pathList");
pathListField.setAccessible(true);//设置为可访问
Object pathList = pathListField.get(classLoader);

//然后获取“pathList”实例的“dexElements”属性
Field declaredField = pathList.getClass().getDeclaredField("dexElements");
declaredField.setAccessible(true);

//设置"dexElements"的值
declaredField.set(pathList, dexElements);
}
}

类加载过程如下:


findclass.png
微信Tinker,QQ 空间的超级补丁、手 QQ 的QFix 、饿了 么的 AmigoNuwa 等都是使用这个方式


缺点:因为类加载后无法卸载,所以类加载方案必须重启App,让bug类重新加载后才能生效。


2.1.2:底层替换方案


底层替换方案不会再次加载新类,而是直接在 Native 层 修改原有类
这里我们需要提到Art虚拟机中ArtMethod
每一个Java方法在Art虚拟机中都对应着一个 ArtMethodArtMethod记录了这个Java方法的所有信息,包括所属类、访问权限、代码执行地址等


结构如下:


// art/runtime/art_method.h
class ArtMethod FINAL {
...
protected:
GcRoot<mirror::Class> declaring_class_;
GcRoot<mirror::PointerArray> dex_cache_resolved_methods_;
GcRoot<mirror::ObjectArray<mirror::Class>> dex_cache_resolved_types_;
uint32_t access_flags_;
uint32_t dex_code_item_offset_;
uint32_t dex_method_index_;
uint32_t method_index_;

struct PACKED(4) PtrSizedFields {
void* entry_point_from_interpreter_; // 1
void* entry_point_from_jni_;
void* entry_point_from_quick_compiled_code_; //2
} ptr_sized_fields_;
...
}

在 ArtMethod结构体中,最重要的就是 注释1和注释2标注的内容,从名字可以看出来,他们就是方法的执行入口。
我们知道,Java代码在Android中会被编译为 Dex Code


Art虚拟机中可以采用解释模式或者 AOT机器码模式执行 Dex Code



  • 解释模式:
    就是去除Dex Code,逐条解释执行。
    如果方法的调用者是以解释模式运行的,在调用这个方法时,就会获取这个方法的 entry_point_from_interpreter_,然后跳转执行。

  • AOT模式:
    就会预先编译好 Dex Code对应的机器码,然后在运行期直接执行机器码,不需要逐条解释执行Dex Code。
    如果方法的调用者是以AOT机器码方式执行的,在调用这个方法时,就是跳转到 entry_point_from_quick_compiled_code_中执行。



那是不是只需要替换这个几个 entry_point_* 入口地址就能够实现方法替换了呢?
并没有那么简单,因为不论是解释模式还是AOT模式,在运行期间还会需要调用ArtMethod中的其他成员字段



AndFix采用的是改变指针指向


// AndFix/jni/art/art_method_replace_6_0.cpp
void replace_6_0(JNIEnv* env, jobject src, jobject dest) {
art::mirror::ArtMethod* smeth =
(art::mirror::ArtMethod*) env->FromReflectedMethod(src); // 1

art::mirror::ArtMethod* dmeth =
(art::mirror::ArtMethod*) env->FromReflectedMethod(dest); // 2
...
// 3
smeth->declaring_class_ = dmeth->declaring_class_;
smeth->dex_cache_resolved_methods_ = dmeth->dex_cache_resolved_methods_;
smeth->dex_cache_resolved_types_ = dmeth->dex_cache_resolved_types_;
smeth->access_flags_ = dmeth->access_flags_ | 0x0001;
smeth->dex_code_item_offset_ = dmeth->dex_code_item_offset_;
smeth->dex_method_index_ = dmeth->dex_method_index_;
smeth->method_index_ = dmeth->method_index_;

smeth->ptr_sized_fields_.entry_point_from_interpreter_ =
dmeth->ptr_sized_fields_.entry_point_from_interpreter_;

smeth->ptr_sized_fields_.entry_point_from_jni_ =
dmeth->ptr_sized_fields_.entry_point_from_jni_;
smeth->ptr_sized_fields_.entry_point_from_quick_compiled_code_ =
dmeth->ptr_sized_fields_.entry_point_from_quick_compiled_code_;

LOGD("replace_6_0: %d , %d",
smeth->ptr_sized_fields_.entry_point_from_quick_compiled_code_,
dmeth->ptr_sized_fields_.entry_point_from_quick_compiled_code_);
}

缺点:存在一些兼容性问题,由于ArtMethod结构体是Android开源的一部分,所以每个手机厂商都可能会去更改这部分的内容,这就可能导致ArtMethod替换方案在某些机型上面出现未知错误。


Sophix为了规避上面的AndFix的风险,采用直接替换整个结构体。这样不管手机厂商如何更改系统,我们都可以正确定位到方法地址


2.4.3:install run方案


Instant Run 方案的核心思想是——插桩在编译时通过插桩在每一个方法中插入代码,修改代码逻辑,在需要时绕过错误方法,调用patch类的正确方法。


首先,在编译时Instant Run为每个类插入IncrementalChange变量


IncrementalChange  $change;

为每一个方法添加类似如下代码:


public void onCreate(Bundle savedInstanceState) {
IncrementalChange var2 = $change;
//$change不为null,表示该类有修改,需要重定向
if(var2 != null) {
//通过access$dispatch方法跳转到patch类的正确方法
var2.access$dispatch("onCreate.(Landroid/os/Bundle;)V", new Object[]{this, savedInstanceState});
} else {
super.onCreate(savedInstanceState);
this.setContentView(2130968601);
this.tv = (TextView)this.findViewById(2131492944);
}
}

如上代码,当一个类被修改后,Instant Run会为这个类新建一个类,命名为xxx&override,且实现IncrementalChange接口,并且赋值给原类的$change变量。


public class MainActivity$override implements IncrementalChange {


此时,在运行时原类中每个方法的var2 != null,通过accessdispatch(参数是方法名和原参数)定位到patch类MainActivitydispatch(参数是方法名和原参数)定位到patch类MainActivityoverride中修改后的方法。


Instant Run是google在AS2.0时用来实现“热部署”的,同时也为“热修复”提供了一个绝佳的思路。美团的Robust就是基于此


2.2:资源修复方案


这里我们来看看install run的原理即可,市面上的常见修复方案大部分都是基于此方法。


public static void monkeyPatchExistingResources(Context context,
String externalResourceFile, Collection<Activity> activities) {
if (externalResourceFile == null) {
return;
}
try {
// 创建一个新的AssetManager
AssetManager newAssetManager = (AssetManager) AssetManager.class
.getConstructor(new Class[0]).newInstance(new Object[0]); // ... 1
Method mAddAssetPath = AssetManager.class.getDeclaredMethod(
"addAssetPath", new Class[] { String.class }); // ... 2
mAddAssetPath.setAccessible(true);
// 通过反射调用addAssetPath方法加载外部的资源(SD卡资源)
if (((Integer) mAddAssetPath.invoke(newAssetManager,
new Object[] { externalResourceFile })).intValue() == 0) { // ... 3
throw new IllegalStateException(
"Could not create new AssetManager");
}
Method mEnsureStringBlocks = AssetManager.class.getDeclaredMethod(
"ensureStringBlocks", new Class[0]);
mEnsureStringBlocks.setAccessible(true);
mEnsureStringBlocks.invoke(newAssetManager, new Object[0]);
if (activities != null) {
for (Activity activity : activities) {
Resources resources = activity.getResources(); // ... 4
try {
// 反射得到Resources的AssetManager类型的mAssets字段
Field mAssets = Resources.class
.getDeclaredField("mAssets"); // ... 5
mAssets.setAccessible(true);
// 将mAssets字段的引用替换为新创建的newAssetManager
mAssets.set(resources, newAssetManager); // ... 6
} catch (Throwable ignore) {
...
}

// 得到Activity的Resources.Theme
Resources.Theme theme = activity.getTheme();
try {
try {
// 反射得到Resources.Theme的mAssets字段
Field ma = Resources.Theme.class
.getDeclaredField("mAssets");
ma.setAccessible(true);
// 将Resources.Theme的mAssets字段的引用替换为新创建的newAssetManager
ma.set(theme, newAssetManager); // ... 7
} catch (NoSuchFieldException ignore) {
...
}
...
} catch (Throwable e) {
Log.e("InstantRun",
"Failed to update existing theme for activity "
+ activity, e);
}
pruneResourceCaches(resources);
}
}
/**
* 根据SDK版本的不同,用不同的方式得到Resources 的弱引用集合
*/
Collection<WeakReference<Resources>> references;
if (Build.VERSION.SDK_INT >= 19) {
Class<?> resourcesManagerClass = Class
.forName("android.app.ResourcesManager");
Method mGetInstance = resourcesManagerClass.getDeclaredMethod(
"getInstance", new Class[0]);
mGetInstance.setAccessible(true);
Object resourcesManager = mGetInstance.invoke(null,
new Object[0]);
try {
Field fMActiveResources = resourcesManagerClass
.getDeclaredField("mActiveResources");
fMActiveResources.setAccessible(true);

ArrayMap<?, WeakReference<Resources>> arrayMap = (ArrayMap) fMActiveResources
.get(resourcesManager);
references = arrayMap.values();
} catch (NoSuchFieldException ignore) {
Field mResourceReferences = resourcesManagerClass
.getDeclaredField("mResourceReferences");
mResourceReferences.setAccessible(true);

references = (Collection) mResourceReferences
.get(resourcesManager);
}
} else {
Class<?> activityThread = Class
.forName("android.app.ActivityThread");
Field fMActiveResources = activityThread
.getDeclaredField("mActiveResources");
fMActiveResources.setAccessible(true);
Object thread = getActivityThread(context, activityThread);

HashMap<?, WeakReference<Resources>> map = (HashMap) fMActiveResources
.get(thread);

references = map.values();
}
//遍历并得到弱引用集合中的 Resources ,将 Resources mAssets 字段引用替换成新的 AssetManager
for (WeakReference<Resources> wr : references) {
Resources resources = (Resources) wr.get();
if (resources != null) {
try {
Field mAssets = Resources.class
.getDeclaredField("mAssets");
mAssets.setAccessible(true);
mAssets.set(resources, newAssetManager);
} catch (Throwable ignore) {
...
}
resources.updateConfiguration(resources.getConfiguration(),
resources.getDisplayMetrics());
}
}
} catch (Throwable e) {
throw new IllegalStateException(e);
}
}


  • 注释1处创建一个新的 AssetManager ,

  • 注释2注释3 处通过反射调用 addAssetPath 方法加载外部( SD 卡)的资源。

  • 注释4 处遍历 Activity 列表,得到每个 Activity 的 Resources ,

  • 注释5 处通过反射得到 Resources 的 AssetManager 类型的 rnAssets 字段 ,

  • 注释6处改写 mAssets 字段的引用为新的 AssetManager 。


采用同样的方式



  • 注释7处将 Resources. Theme 的 m Assets 字段 的引用替换为新创建的 AssetManager 。

  • 紧接着 根据 SDK 版本的不同,用不同的方式得到 Resources 的弱引用集合,

  • 再遍历这个弱引用集合, 将弱引用集合中的 Resources 的 mAssets 字段引用都替换成新创建的 AssetManager 。


资源修复原理




  • 1.创建新的AssetManager,通过反射调用addAssetPath方法,加载外部资源,这样新创建的AssetManager就含有了外部资源

  • 2.将AssetManager类型的mAsset字段全部用新创建的AssetManager对象替换。这样下次加载资源文件的时候就可以找到包含外部资源文件的AssetManager。



2.3:动态链接库so的修复


1.接口调用替换方案:


sdk提供接口替换System默认加载so库接口


SOPatchManager.loadLibrary(String libName) -> System.loadLibrary(String libName)

SOPatchManager.loadLibrary接口加载 so库的时候优先尝试去加载sdk 指定目录下的补丁so


加载策略如下:


如果存在则加载补丁 so库而不会去加载安装apk安装目录下的so库
如果不存在补丁so,那么调用System.loadLibrary去加载安装apk目录下的 so库。


加载so库.jpg
我们可以很清楚的看到这个方案的优缺点:
优点:不需要对不同 sdk 版本进行兼容,因为所有的 sdk 版本都有 System.loadLibrary 这个接口。
缺点:调用方需要替换掉 System 默认加载 so 库接口为 sdk提供的接口, 如果是已经编译混淆好的三方库的so 库需要 patch,那么是很难做到接口的替换


虽然这种方案实现简单,同时不需要对不同 sdk版本区分处理,但是有一定的局限性没法修复三方包的so库同时需要强制侵入接入方接口调用,接着我们来看下反射注入方案。


2、反射注入方案


前面介绍过 System. loadLibrary ( "native-lib"); 加载 so库的原理,其实native-lib 这个 so 库最终传给 native 方法执行的参数是 so库在磁盘中的完整路径,比如:/data/app-lib/com.taobao.jni-2/libnative-lib.so, so库会在 DexPathList.nativeLibraryDirectories/nativeLibraryPathElements 变量所表示的目录下去遍历搜索


sdk<23 DexPathList.findLibrary 实现如下


小余23.jpg


可以发现会遍历 nativeLibraryDirectories数组,如果找到了 loUtils.canOpenReadOnly (path)返回为 true, 那么就直接返回该 path, loUtils.canOpenReadOnly (path)返回为 true 的前提肯定是需要 path 表示的 so文件存 在的。那么我们可以采取类似类修复反射注入方式,只要把我们的补丁so库的路径插入到nativeLibraryDirectories数组的最前面就能够达到加载so库的时候是补丁 库而不是原来so库的目录,从而达到修复的目的。


sdk>=23 DexPathList.findLibrary 实现如下


大于23.jpg
sdk23 以上 findLibrary 实现已经发生了变化,如上所示,那么我们只需要把补丁so库的完整路径作为参数构建一个Element对象,然后再插入到nativeLibraryPathElements 数组的最前面就好了。



  • 优点:可以修复三方库的so库。同时接入方不需要像方案1 —样强制侵入用 户接口调用

  • 缺点:需要不断的对 sdk 进行适配,如上 sdk23 为分界线,findLibrary接口实现已经发生了变化。


对于 so库的修复方案目前更多采取的是接口调用替换方式,需要强制侵入用户 接口调用。
目前我们的so文件修复方案采取的是反射注入的方案,重启生效。具有更好的普遍性。
如果有so文件修复实时生效的需求,也是可以做到的,只是有些限制情况。


常见热修复框架?










































































































特性DexposedAndFixTinker/AmigoQQ ZoneRobust/AcesoSophix
技术原理native底层替换native底层替换类加载类加载Instant Run混合
所属阿里阿里微信/饿了么QQ空间美团/蘑菇街阿里
即时生效YES   YES NONO YES混合
方法替换YES  YESYES YES   YES YES
类替换NO NOYESYES   YES  YES 
类结构修改NO  NOYES NO  NOYES 
资源替换NO NOYES YES NO YES 
so替换NO NO YES NO NO YES 
支持gradleNO NO YES YES YESYES 
支持ARTNO YES YES YES YES YES 

可以看出,阿里系多采用native底层方案,腾讯系多采用类加载机制。其中,Sophix是商业化方案;Tinker/Amigo支持特性较多,同时也更复杂,如果需要修复资源和so,可以选择;如果仅需要方法替换,且需要即时生效,Robust是不错的选择。


总结:


尽管热修复(或热更新)相对于迭代更新有诸多优势,市面上也有很多开源方案可供选择,但目前热修复依然无法替代迭代更新模式。有如下原因:
热修复框架多多少少会增加性能开销,或增加APK大小
热修复技术本身存在局限,比如有些方案无法替换so或资源文件
热修复方案的兼容性,有些方案无法同时兼顾Dalvik和ART,有些深度定制系统也无法正常工作
监管风险,比如苹果系统严格限制热修复


所以,对于功能迭代和常规bug修复,版本迭代更新依然是主流。一般的代码修复,使用Robust可以解决,如果还需要修复资源或so库,可以考虑Tinker


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

LinkedList源码解析

LinkedList源码解析 目标 理解LinkedList底层数据结构 深入源码掌握LinkedList查询慢,新增快的原因 1.简介 List 接口的链接列表实现。实现所有可选的列表操作,并且允许所有元素(包括 null )。除了实现 List 接口外...
继续阅读 »

LinkedList源码解析


目标



  • 理解LinkedList底层数据结构

  • 深入源码掌握LinkedList查询慢,新增快的原因


1.简介


List 接口的链接列表实现。实现所有可选的列表操作,并且允许所有元素(包括 null )。除了实现 List 接口外, LinkedList 类还为在列表的开头及结尾 get 、 remove 和 insert 元素提供了统一 的命名方法。这些操作允许将链接列表用作堆栈、队列或双端队列。


特点 :



  • 有序性 : 存入和取出的顺序是一致的

  • 元素可以重复

  • 含有带索引的方法

  • 独有特点 : 数据结构是链表,可以作为栈、队列或者双端队列!


image.png


2.LinkedList原理分析



双向链表



底层数据结构源码


public class LinkedList<E> {
   transient int size = 0;
   //双向链表的头结点
   transient Node<E> first;
   //双向链表的最后一个节点
   transient Node<E> last;

   //节点类【内部类】
   private static class Node<E> {
       E item;//数据元素
       Node<E> next;//下一个节点
       Node<E> prev;//上一个节点

       //节点的构造方法
       Node(Node<E> prev, E element, Node<E> next) {
           this.item = element;
           this.next = next;
           this.prev = prev;
      }
  } /
           /...
}

2.1 LinkedList的数据结构


LinkedList是双向链表,在代码中是一个Node类。内部并没有数组的结构。双向链表肯定存在一个头节 点和一个尾部节点。node节点类,是以内部类的形式存在于LinkedList中的。Node类都有两个成员变 量:



  • prev : 当前节点上一个节点,头节点的上一个节点是null

  • next : 当前节点下一个节点,尾结点的下一个节点是null


链表数据结构的特点 : 查询慢,增删快!



  • 链表数据结构基本构成,是一个node类

  • 每个node类中,有上一个节点【prev】和下一个节点【next】

  • 链表一定存在至少两个节点,first和last节点

  • 如果LinkedList没有数据,first和last都是为null


2.2 LinkedList默认容量&最大容量


image.png


没有默认容量,也没有最大容量


2.3 LinkedList扩容机制


无需扩容机制,只要你的内存足够大,可以无限制扩容下去。前提是不考虑查询的效率。


2.4 为什么LinkedList查询慢,增删快?


LinkedList的数据结构的特点,链表的数据结构就是这样的特点!



  • 链表是一种查询慢的结构【相对于数组来说】

  • 链表是一种增删快的结构【相对于数组来说】


2.5 LinkedList源码剖析-为什么增删快?


新增add


//想LinkedList添加一个元素
public boolean add(E e){
//连接到链表的末尾
       linkLast(e);
       return true;
      }/
       /连接到最后一个节点上去
       void linkLast(E e){
//将全局末尾节点赋值给l
final Node<E> l=last;
//创建一个新节点 : (上一个节点, 当前插入元素, null)
final Node<E> newNode=new Node<>(l,e,null);
//将当前节点作为末尾节点
       last=newNode;
//判断l节点是否为null
       if(l==null)
//既是尾结点也是头节点
       first=newNode;
       else
//之前的末尾节点,下一个节点时末尾节点!
       l.next=newNode;
       size++;//当前集合的元素数量+1
       modCount++;//操作集合数+1。modCount属性是修改技术器
      }/
       /------------------------------------------------------------------
//向链表中部添加
//参数1,添加的索引位置,添加元素
public void add(int index,E element){
//检查索引位是否符合要求
       checkPositionIndex(index);
//判断当前所有是否是存储元素个数
       if(index==size)//true,最后一个元素
       linkLast(element);
       else
//连接到指定节点的后面【链表中部插入】
       linkBefore(element,node(index));
      }/
       /根据索引查询链表中节点!
       Node<E> node(int index){
// 判断索引是否小于 已经存储元素个数的1/2
       if(index< (size>>1)){//二分法查找 : 提高查找节点效率
       Node<E> x=first;
       for(int i=0;i<index; i++)
       x=x.next;
       return x;
      }else{
       Node<E> x=last;
       for(int i=size-1;i>index;i--)
       x=x.prev;
       return x;
      }
      }/
       /将当前元素添加到指定节点之前
       void linkBefore(E e,Node<E> succ){
       // 取出当前节点的前一个节点
       final Node<E> pred=succ.prev;
       //创建当前元素的节点 : 上一个节点,当前元素,下一个节点
       final Node<E> newNode=new Node<>(pred,e,succ);
       //为指定节点上一个节点重新值
       succ.prev=newNode;
//判断当前节点的上一个节点是否为null
       if(pred==null)
       first=newNode;//当前节点作为头部节点
       else
       pred.next=newNode;//将新插入节点作为上一个节点的下个节点
       size++;//新增元素+1
       modCount++;//操作次数+1
      }

remove删除指定索引元素


//删除指定索引位置元素
public E remove(int index){
//检查元素索引
       checkElementIndex(index);
//删除元素节点,
//node(index) 根据索引查到要删除的节点
//unlink()删除节点
       return unlink(node(index));
      }//根据索引查询链表中节点!
       Node<E> node(int index){
// 判断索引是否小于 已经存储元素个数的1/2
       if(index< (size>>1)){//二分法查找 : 提高查找节点效率
       Node<E> x=first;
       for(int i=0;i<index; i++)
       x=x.next;
       return x;
      }else{
       Node<E> x=last;
       for(int i=size-1;i>index;i--)
       x=x.prev;
       return x;
      }
      }/
       /删除一个指定节点
       E unlink(Node<E> x){
//获取当前节点中的元素
final E element=x.item;
//获取当前节点的上一个节点
final Node<E> next=x.next;
//获取当前节点的下一个节点
final Node<E> prev=x.prev;
//判断上一个节点是否为null
       if(prev==null){
//如果为null,说明当前节点为头部节点
       first=next;
      }else{
//上一个节点,的下一个节点改为下下节点
       prev.next=next;
//将当前节点的上一个节点置空
       x.prev=null;
      }/
       /判断下一个节点是否为null
       if(next==null){
//如果为null,说明当前节点为尾部节点
       last=prev;
      }else{
//下一个节点的上节点,改为上上节点
       next.prev=prev;
//当前节点的上节点置空
       x.next=null;
      }/
       /删除当前节点内的元素
       x.item=null;
       size--;//集合中的元素个数-1
       modCount++;//当前集合操作数+1。modCount计数器,记录当前集合操作次数
       return element;//返回删除的元素
      }

2.6 LinkedList源码剖析-为什么查询慢?


查询快和慢是一个相对概念!相对于数组来说


//根据索引查询一个元素
public E get(int index){
//检查索引是否存在
       checkElementIndex(index);
// node(index)获取索引对应节点,获取节点中的数据item
       return node(index).item;
      }/
       /根据索引获取对应节点对象
       Node<E> node(int index){
//二分法查找索引对应的元素
       if(index< (size>>1)){
       Node<E> x=first;
//前半部分查找【遍历节点】
       for(int i=0;i<index; i++)
       x=x.next;
       return x;
      }else{
       Node<E> x=last;
//后半部分查找【遍历】
       for(int i=size-1;i>index;i--)
       x=x.prev;
       return x;
      }
      }/
       /查看ArrayList里的数组获取元素的方式
public E get(int index){
       rangeCheck(index);//检查范围
       return elementData(index);//获取元素
      }E
       elementData(int index){
       return(E)elementData[index];//一次性操作
      }

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

最安全的加密算法 Bcrypt,再也不用担心数据泄密了~

这是《Spring Security 进阶》专栏的第三篇文章,给大家介绍一下Spring Security 中内置的加密算法BCrypt,号称最安全的加密算法,究竟有着什么魔力能让黑客闻风丧胆 哈希(Hash)与加密(Encrypt) 哈希(Hash)是将目标...
继续阅读 »

这是《Spring Security 进阶》专栏的第三篇文章,给大家介绍一下Spring Security 中内置的加密算法BCrypt,号称最安全的加密算法,究竟有着什么魔力能让黑客闻风丧胆


哈希(Hash)与加密(Encrypt)


哈希(Hash)是将目标文本转换成具有相同长度的、不可逆的杂凑字符串(或叫做消息摘要),而加密(Encrypt)是将目标文本转换成具有不同长度的、可逆的密文。



  • 哈希算法往往被设计成生成具有相同长度的文本,而加密算法生成的文本长度与明文本身的长度有关。

  • 哈希算法是不可逆的,而加密算法是可逆的。


HASH 算法是一种消息摘要算法,不是一种加密算法,但由于其单向运算,具有一定的不可逆性,成为加密算法中的一个构成部分。


JDK的String的Hash算法。代码如下:


public int hashCode() {
int h = hash;
if (h == 0 && value.length > 0) {
char val[] = value;
for (int i = 0; i < value.length; i++) {
h = 31 * h + val[i];
}
hash = h;
}
return h;
}

从JDK的API可以看出,它的算法等式就是s[0]*31^(n-1) + s[1]*31^(n-2) + ... + s[n-1],其中s[i]就是索引为i的字符,n为字符串的长度。


HashMap的hash计算时先计算hashCode(),然后进行二次hash。代码如下:


// 计算二次Hash    
int hash = hash(key.hashCode());

static int hash(int h) {
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}

可以发现,虽然算法不同,但经过这些移位操作后,对于同一个值使用同一个算法,计算出来的hash值一定是相同的。


那么,hash为什么是不可逆的呢?


假如有两个密码3和4,我的加密算法很简单就是3+4,结果是7,但是通过7我不可能确定那两个密码是3和4,有很多种组合,这就是最简单的不可逆,所以只能通过暴力破解一个一个的试。


在计算过程中原文的部分信息是丢失了。一个MD5理论上是可以对应多个原文的,因为MD5是有限多个而原文是无限多个的。


不可逆的MD5为什么是不安全的?


因为hash算法是固定的,所以同一个字符串计算出来的hash串是固定的,所以,可以采用如下的方式进行破解。



  1. 暴力枚举法:简单粗暴地枚举出所有原文,并计算出它们的哈希值,看看哪个哈希值和给定的信息摘要一致。

  2. 字典法:黑客利用一个巨大的字典,存储尽可能多的原文和对应的哈希值。每次用给定的信息摘要查找字典,即可快速找到碰撞的结果。

  3. 彩虹表(rainbow)法:在字典法的基础上改进,以时间换空间。是现在破解哈希常用的办法。


对于单机来说,暴力枚举法的时间成本很高(以14位字母和数字的组合密码为例,共有1.24×10^25种可能,即使电脑每秒钟能进行10亿次运算,也需要4亿年才能破解),字典法的空间成本很高(仍以14位字母和数字的组合密码为例,生成的密码32位哈希串的对照表将占用5.7×10^14 TB的存储空间)。但是利用分布式计算和分布式存储,仍然可以有效破解MD5算法。因此这两种方法同样被黑客们广泛使用。


如何防御彩虹表的破解?


虽然彩虹表有着如此惊人的破解效率,但网站的安全人员仍然有办法防御彩虹表。最有效的方法就是“加盐”,即在密码的特定位置插入特定的字符串,这个特定字符串就是“盐(Salt)”,加盐后的密码经过哈希加密得到的哈希串与加盐前的哈希串完全不同,黑客用彩虹表得到的密码根本就不是真正的密码。即使黑客知道了“盐”的内容、加盐的位置,还需要对H函数和R函数进行修改,彩虹表也需要重新生成,因此加盐能大大增加利用彩虹表攻击的难度。


一个网站,如果加密算法和盐都泄露了,那针对性攻击依然是非常不安全的。因为同一个加密算法同一个盐加密后的字符串仍然还是一毛一样滴!


一个更难破解的加密算法Bcrypt


BCrypt是由Niels Provos和David Mazières设计的密码哈希函数,他是基于Blowfish密码而来的,并于1999年在USENIX上提出。


除了加盐来抵御rainbow table 攻击之外,bcrypt的一个非常重要的特征就是自适应性,可以保证加密的速度在一个特定的范围内,即使计算机的运算能力非常高,可以通过增加迭代次数的方式,使得加密速度变慢,从而可以抵御暴力搜索攻击。


Bcrypt可以简单理解为它内部自己实现了随机加盐处理。使用Bcrypt,每次加密后的密文是不一样的。


对一个密码,Bcrypt每次生成的hash都不一样,那么它是如何进行校验的?



  1. 虽然对同一个密码,每次生成的hash不一样,但是hash中包含了salt(hash产生过程:先随机生成salt,salt跟password进行hash);

  2. 在下次校验时,从hash中取出salt,salt跟password进行hash;得到的结果跟保存在DB中的hash进行比对。


在Spring Security 中 内置了Bcrypt加密算法,构建也很简单,代码如下:


@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}

生成的加密字符串格式如下:


$2b$[cost]$[22 character salt][31 character hash]

比如:


$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy
\__/\/ \____________________/\_____________________________/
Alg Cost Salt Hash

上面例子中,$2a$ 表示的hash算法的唯一标志。这里表示的是Bcrypt算法。


10 表示的是代价因子,这里是2的10次方,也就是1024轮。


N9qo8uLOickgx2ZMRZoMye 是16个字节(128bits)的salt经过base64编码得到的22长度的字符。


最后的IjZAgcfl7p92ldGxad68LJZdL17lhWy是24个字节(192bits)的hash,经过bash64的编码得到的31长度的字符。


PasswordEncoder 接口


这个接口是Spring Security 内置的,如下:


public interface PasswordEncoder {
String encode(CharSequence rawPassword);

boolean matches(CharSequence rawPassword, String encodedPassword);

default boolean upgradeEncoding(String encodedPassword) {
return false;
}
}

这个接口有三个方法:



  • encode方法接受的参数是原始密码字符串,返回值是经过加密之后的hash值,hash值是不能被逆向解密的。这个方法通常在为系统添加用户,或者用户注册的时候使用。

  • matches方法是用来校验用户输入密码rawPassword,和加密后的hash值encodedPassword是否匹配。如果能够匹配返回true,表示用户输入的密码rawPassword是正确的,反之返回fasle。也就是说虽然这个hash值不能被逆向解密,但是可以判断是否和原始密码匹配。这个方法通常在用户登录的时候进行用户输入密码的正确性校验。

  • upgradeEncoding设计的用意是,判断当前的密码是否需要升级。也就是是否需要重新加密?需要的话返回true,不需要的话返回fasle。默认实现是返回false。


例如,我们可以通过如下示例代码在进行用户注册的时候加密存储用户密码


//将User保存到数据库表,该表包含password列
user.setPassword(passwordEncoder.encode(user.getPassword()));

BCryptPasswordEncoder 是Spring Security推荐使用的PasswordEncoder接口实现类


public class PasswordEncoderTest {
@Test
void bCryptPasswordTest(){
PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
String rawPassword = "123456"; //原始密码
String encodedPassword = passwordEncoder.encode(rawPassword); //加密后的密码

System.out.println("原始密码" + rawPassword);
System.out.println("加密之后的hash密码:" + encodedPassword);

System.out.println(rawPassword + "是否匹配" + encodedPassword + ":" //密码校验:true
+ passwordEncoder.matches(rawPassword, encodedPassword));

System.out.println("654321是否匹配" + encodedPassword + ":" //定义一个错误的密码进行校验:false
+ passwordEncoder.matches("654321", encodedPassword));
}
}

上面的测试用例执行的结果是下面这样的。(注意:对于同一个原始密码,每次加密之后的hash密码都是不一样的,这正是BCryptPasswordEncoder的强大之处,它不仅不能被破解,想通过常用密码对照表进行大海捞针你都无从下手),输出如下:


原始密码123456
加密之后的hash密码:$2a$10$zt6dUMTjNSyzINTGyiAgluna3mPm7qdgl26vj4tFpsFO6WlK5lXNm
123456是否匹配$2a$10$zt6dUMTjNSyzINTGyiAgluna3mPm7qdgl26vj4tFpsFO6WlK5lXNm:true
654321是否匹配$2a$10$zt6dUMTjNSyzINTGyiAgluna3mPm7qdgl26vj4tFpsFO6WlK5lXNm:false

BCrypt 产生随机盐(盐的作用就是每次做出来的菜味道都不一样)。这一点很重要,因为这意味着每次encode将产生不同的结果。


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

Android DIY你的菜单栏

前言个人打算开发个视频编辑的APP,然后把一些用上的技术总结一下,这次主要是APP的底部菜单栏用到了一个自定义View去绘制实现的,所以这次主要想讲讲自定义View的一些用到的点和自己如何去DIY一个不一样的自定义布局。实现的效果和思路可以先看看实现的效果两个...
继续阅读 »

前言

个人打算开发个视频编辑的APP,然后把一些用上的技术总结一下,这次主要是APP的底部菜单栏用到了一个自定义View去绘制实现的,所以这次主要想讲讲自定义View的一些用到的点和自己如何去DIY一个不一样的自定义布局。

实现的效果和思路

可以先看看实现的效果

sp1gif.gif

两个页面的内容还没做,当前就是一个Demo,可以看到底部的菜单栏是一个绘制出来的不规则的一个布局,那要如何实现呢。可以先来看看它的一个绘制区域:

a94f6a185c3ebee1cc62a9731b2a1be.jpg

就是一个底部的布局和3个子view,底部的区域当然也是个规则的区域,只不过我们是在这块区域上去进行绘制。

可以把整个过程分为几个步骤:

1. 绘制底部布局
(1) 绘制矩形区域
(2) 绘制外圆形区域
(3) 绘制内圆形区域
2. 添加子view进行布局
3. 处理事件分发的区域 (底部菜单上边的白色区域不触发菜单的事件)
4. 写个动画意思意思

1. 绘制底部布局

这里做的话就没必要手动去添加view这些了,直接全部手动绘制就行。

companion object{

const val DIMENS_64 = 64.0
const val DIMENS_96 = 96.0
const val DIMENS_50 = 50.0
const val DIMENS_48 = 48.0

interface OnChildClickListener{
fun onClick(index : Int)
}

}

private var paint : Paint ?= null // 绘制蓝色区域的画笔
private var paint2 : Paint ?= null // 绘制白色内圆的画笔
private var allHeight : Int = 0 // 总高度,就是绘制的范围
private var bgHeight : Int = 0 // 背景的高度,就是蓝色矩阵的范围
private var mRadius : Int = 0 // 外圆的高度
private var mChildSize : Int = 0
private var mChildCenterSize : Int = 0

private var mWidthZone1 : Int = 0
private var mWidthZone2 : Int = 0
private var mChildCentre : Int = 0

private var childViews : MutableList<View> = mutableListOf()
private var objectAnimation : ObjectAnimator ?= null
var onChildClickListener : OnChildClickListener ?= null

init {
initView()
}

private fun initView(){
val lp = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,
DimensionUtils.dp2px(context, DIMENS_64).toInt())
layoutParams = lp

allHeight = DimensionUtils.dp2px(context, DIMENS_96).toInt()
bgHeight = DimensionUtils.dp2px(context, DIMENS_64).toInt()
mRadius = DimensionUtils.dp2px(context, DIMENS_50).toInt()
mChildSize = DimensionUtils.dp2px(context, DIMENS_48).toInt()
mChildCenterSize = DimensionUtils.dp2px(context, DIMENS_64).toInt()
setWillNotDraw(false)

initPaint()
}

private fun initPaint(){
paint = Paint()
paint?.isAntiAlias = true
paint?.color = context.resources.getColor(R.color.kylin_main_color)

paint2 = Paint()
paint2?.isAntiAlias = true
paint2?.color = context.resources.getColor(R.color.kylin_third_color)
}

上边是先把一些尺寸给定义好(我这边是没有设计图,自己去直接调整的,所以可能有些视觉效果不太好,如果有设计师帮忙的话效果肯定会好些),绘制流程就是绘制3个形状,然后代码里也加了些注释哪个变量有什么用,这步应该不难,没什么可以多解释的。

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
val wSize = MeasureSpec.getSize(widthMeasureSpec)
// 拿到子view做操作的,和这步无关,可以先不看
if (childViews.size <= 0) {
for (i in 0 until childCount) {
val cView = getChildAt(i)
initChildView(cView, i)
childViews.add(cView)
if (i == childCount/2){
val ms: Int = MeasureSpec.makeMeasureSpec(mRadius, MeasureSpec.AT_MOST)
measureChild(cView, ms, ms)
}else {
val ms: Int = MeasureSpec.makeMeasureSpec(mChildSize, MeasureSpec.AT_MOST)
measureChild(cView, ms, ms)
}
}
}

setMeasuredDimension(wSize, allHeight)
}

这步其实也很简单,就是说给当前自定义view设置高度为allHeight

override fun onDraw(canvas: Canvas?) {
super.onDraw(canvas)
// 绘制长方形区域
canvas?.drawRect(left.toFloat(), ((allHeight - bgHeight).toFloat()),
right.toFloat(), bottom.toFloat(), paint!!)

// 绘制圆形区域
paint?.let {
canvas?.drawCircle(
(width/2).toFloat(), mRadius.toFloat(),
mRadius.toFloat(),
it
)
}

// 绘制内圆区域
paint2?.let {
canvas?.drawCircle(
(width/2).toFloat(), mRadius.toFloat(),
(mRadius - 28).toFloat(),
it
)
}
}

最后进行绘制, 就是上面说的绘制3个图形,代码里的注释也说得很清楚。

2. 添加子view

我这里是外面布局去加子view的,想弄得灵活点(但感觉也不太好,后面还是想改成里面定义一套规范来弄会好些,如果自由度太高的话去做自定义就很麻烦,而且实际开发中这种需求也没必要把扩展性做到这种地步,基本就是整个APP只有一个地方使用)

但是这边也只是一个Demo先做个演示。

<com.kylin.libkcommons.widget.BottomMenuBar
android:id="@+id/bv_content"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_alignParentBottom="true"
>

<ImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@drawable/home"
/>

<ImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@drawable/video"
/>

<ImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@drawable/more"
/>

</com.kylin.libkcommons.widget.BottomMenuBar>
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
val wSize = MeasureSpec.getSize(widthMeasureSpec)

if (childViews.size <= 0) {
for (i in 0 until childCount) {
val cView = getChildAt(i)
initChildView(cView, i)
childViews.add(cView)
if (i == childCount/2){
val ms: Int = MeasureSpec.makeMeasureSpec(mRadius, MeasureSpec.AT_MOST)
measureChild(cView, ms, ms)
}else {
val ms: Int = MeasureSpec.makeMeasureSpec(mChildSize, MeasureSpec.AT_MOST)
measureChild(cView, ms, ms)
}
}
}

setMeasuredDimension(wSize, allHeight)
}

拿到子view进行一个管理,做一些初始化的操作,主要是设点击事件这些,这里不是很重要。

override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
if (mChildCentre == 0){
mChildCentre = width / 6
}

// 辅助事件分发区域
if (mWidthZone1 == 0 || mWidthZone2 == 0) {
mWidthZone1 = width / 2 - mRadius / 2
mWidthZone2 = width / 2 + mRadius / 2
}

// 设置每个子view的显示区域
for (i in 0 until childViews.size) {
if (i == childCount/2){
childViews[i].layout(mChildCentre*(2*i+1) - mChildCenterSize/2 ,
allHeight/2 - mChildCenterSize/2,
mChildCentre*(2*i+1) + mChildCenterSize/2 ,
allHeight/2 + mChildCenterSize/2)
}else {
childViews[i].layout(mChildCentre*(2*i+1) - mChildSize/2 ,
allHeight - bgHeight/2 - mChildSize/2,
mChildCentre*(2*i+1) + mChildSize/2 ,
allHeight - bgHeight/2 + mChildSize/2)
}
}

}

进行布局,这里比较重要,因为能看出,中间的图标会更大一些,所以要做一些适配。其实这里就是把宽度分为6块,然后3个view分别在1,3,5这三个左边点,y的话就是除中间那个,其它两个都是bgHeight绘制高度的的一半,中间那个是allHeight总高度的一半,这样3个view的x和y坐标都能拿到了,再根据宽高就能算出l,t,r,b四个点,然后布局。

3. 处理事件分发

可以看出我们的区域是一个不规则的区域,按照我们用抽象的角度去思考,我们希望这个菜单栏的区域只是显示蓝色的那个区域,所以蓝色区域上面的白色区域就算是我们自定义view的范围,他触发的事件也应该是后面的view的事件(Demo中后面的View是一个ViewPager),而不是菜单栏。

// 辅助事件分发区域
if (mWidthZone1 == 0 || mWidthZone2 == 0) {
mWidthZone1 = width / 2 - mRadius / 2
mWidthZone2 = width / 2 + mRadius / 2
}

这两块是圆外的x的区域。

/**
* 判断点击事件是否在点击区域中
*/
private fun isShowZone(x : Float, y : Float) : Boolean{
if (y >= allHeight - bgHeight){
return true
}
if (x >= mWidthZone1 && x <= mWidthZone2){
// 在圆内
val relativeX = abs(x - width/2)
val squareYZone = mRadius.toDouble().pow(2.0) - relativeX.toDouble().pow(2.0)
return y >= mRadius - sqrt(squareYZone)
}
return false
}

先判断y如果在背景的矩阵中(上面说了自定义view分成矩阵,外圆,内圆),那肯定是菜单的区域。如果不在,那就要判断y在不在圆内,这里就必须用勾股定理去判断。

override fun onTouchEvent(event: MotionEvent?): Boolean {
// 点击区域进行拦截
if (event?.action == MotionEvent.ACTION_DOWN && isShowZone(event.x, event.y)){
return true
}
return super.onTouchEvent(event)
}

最后做一个事件分发的拦截。除了计算区域那可能需要去想想,其它地方我觉得都挺好理解的吧。

4. 做个动画

给子view设点击事件让外部处理,然后给中间的按钮做个动画效果。

private fun initChildView(cView : View?, index : Int) {
cView?.setOnClickListener {
if (index == childViews.size/2) {
startAnim(cView)
}else {
onChildClickListener?.onClick(index)
}
}
}
private fun startAnim(view : View){
if (objectAnimation == null) {
objectAnimation = ObjectAnimator.ofFloat(view,
"rotation", 0f, -15f, 180f, 0f)
objectAnimation?.addListener(object : Animator.AnimatorListener {

override fun onAnimationStart(p0: Animator) {
}

override fun onAnimationEnd(p0: Animator) {
onChildClickListener?.onClick(childViews.size / 2)
}

override fun onAnimationCancel(p0: Animator) {
onChildClickListener?.onClick(childViews.size / 2)
}

override fun onAnimationRepeat(p0: Animator) {
}

})
objectAnimation?.duration = 1000
objectAnimation?.interpolator = AccelerateDecelerateInterpolator()
}
objectAnimation?.start()
}

注意做释放操作。

fun onDestroy(){
try {
objectAnimation?.cancel()
objectAnimation?.removeAllListeners()
}catch (e : Exception){
e.printStackTrace()
}finally {
objectAnimation = null
}
}

5. 小结

其实代码都挺简单的,关键是你要去想出一个方法来实现这个场景,然后感觉这个自定义viewgroup也是比较经典的,涉及到measure、layout、draw,涉及到动画,涉及到点击冲突。

这个Demo表示你要实现怎样的效果都可以,只要是draw能画出来的,你都能实现,我这个是中间凸出来,你可以实现凹进去,你可以实现波浪的样子,可以实现复杂的曲线,都行,你用各种基础图形去做拼接,或者画贝塞尔等等,其实都不难,主要是要有个计算和调试的过程。但是你的形状要和点击区域关联起来,你设计的图案越复杂,你要适配的点击区域计算量就越大。

甚至我还能做得效果更屌的是,那3个子view的图标,我都能画出来,就不用ImagerView,直接手动画出来,这样做的好处是什么呢?我对子view的图标能做各种炫酷的属性动画,我在切换viewpager时对图标做属性动画,那不得逼格再上一层。 为什么我没做呢,因为没有设计,我自己做的话要花大量的时间去调,要是有设计的话他告诉我尺寸啊位置啊这些信息,做起来就很快。我的APP主要是打算实现视频的编辑为主,所以这些支线就没打算花太多时间去处理。


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

收起阅读 »

Kotlin中 Flow、SharedFlow与StateFlow区别

一、简介 了解过协程Flow 的同学知道是典型的冷数据流,而SharedFlow与StateFlow则是热数据流。 冷流:只有当订阅者发起订阅时,事件的发送者才会开始发送事件。 热流:不管订阅者是否存在,只要发送了事件就会被消费,意思是不管接受方是...
继续阅读 »

一、简介


了解过协程Flow 的同学知道是典型的冷数据流,而SharedFlowStateFlow则是热数据流。



  • 冷流:只有当订阅者发起订阅时,事件的发送者才会开始发送事件。

  • 热流:不管订阅者是否存在,只要发送了事件就会被消费,意思是不管接受方是否能够接收到,在这一点上有点像我们Android的LiveData


解释:LiveData新的订阅者不会接收到之前发送的事件,只会收到之前发送的最后一条数据,这个特性和SharedFlow的参数replay设置为1相似


二、使用分析


最好的分析是从使用时入手冷流flow热流SharedFlow和StateFlow热流的具体的实现类分别是MutableSharedFlow和MutableStateFlow


用一个简单的例子来说明什么是冷流,什么是热流。



  • 冷流flow:


private fun testFlow() {
val flow = flow<Int> {
(1..5).forEach {
delay(1000)
emit(it)
}
}
mBind.btCollect.setOnClickListener {
lifecycleScope.launch {
flow.collect {
Log.d(TAG, "testFlow 第一个收集器: 我是冷流:$it")
}
}
lifecycleScope.launch {
delay(5000)
flow.collect {
Log.d(TAG, "testFlow:第二个收集器 我是冷流:$it")
}
}
}

}

我点击收集按钮响应事件后,打印结果如下图:
image.png
这就是冷流,需要去触发收集,才能接收到结果。


从上图时间可知flow每次重新订阅收集都会将所有事件重新发送一次



  • 热流MutableSharedFlow和


private fun testSharedFlow() {

val sharedFlow = MutableSharedFlow<Int>(
replay = 0,//相当于粘性数据
extraBufferCapacity = 0,//接受的慢时候,发送的入栈
onBufferOverflow = BufferOverflow.SUSPEND
)
lifecycleScope.launch {
launch {

sharedFlow.collect {
println("collect1 received ago shared flow $it")

}
}
launch {
(1..5).forEach {
println("emit1 send ago flow $it")
sharedFlow.emit(it)
println("emit1 send after flow $it")
}
}
// wait a 100
delay(100)
launch {
sharedFlow.collect {
println("collect2 received shared flow $it")
}
}
}

}

image.png


第二个流收集被延迟,晚了100毫秒后就收不到了,想当于不管是否订阅,流都会发送,只管发,而collect1能够收集到是因为他在发送之前进行了订阅收集。


三、分析MutableSharedFlow中参数的具体含义


以上面testSharedFlow()方法中对象为例,上面的配置就是,当前对象的默认配置
源码如下图:


image.png


val sharedFlow = MutableSharedFlow<Int>(
replay = 0,//相当于粘性数据
extraBufferCapacity = 0,//接受的慢时候,发送的入栈
onBufferOverflow = BufferOverflow.SUSPEND //产生背压现象后的,执行策略
)

3.1、 reply:事件粘滞数


reply:事件粘滞数以testSharedFlow方法为例如果设置了数目的话,那么其他订阅者不管什么时候订阅都能够收到replay数目的最新的事件,reply=1的话有点类似Android中使用的livedata。


eg:和testSharedFlow方法区别在于 replay = 2


private fun testSharedFlowReplay() {

val sharedFlow = MutableSharedFlow<Int>(
replay = 2,//相当于粘性数据
extraBufferCapacity = 0,//接受的慢时候,发送的入栈
onBufferOverflow = BufferOverflow.SUSPEND
)
lifecycleScope.launch {
launch {

sharedFlow.collect {
println("collect1 received ago shared flow $it")

}
}
launch {
(1..5).forEach {
println("emit1 send ago flow $it")
sharedFlow.emit(it)
println("emit1 send after flow $it")
}
}
// wait a minute
delay(100)
launch {
sharedFlow.collect {
println("collect2 received shared flow $it")
}
}
}

}

按照上面的解释collect2会收集到最新的4,5两个事件如下图:


image.png


3.2 extraBufferCapacity:缓存容量


extraBufferCapacity:缓存容量,就是先发送几个事件,不管已经订阅的消费者是否接收,这种只管发不管消费者消费能力的情况就会出现背压,参数onBufferOverflow就是用于处理背压问题


eg:和testSharedFlow方法区别在于 extraBufferCapacity = 2


private fun testSharedFlowCapacity() {

val sharedFlow = MutableSharedFlow<Int>(
replay = 0,//相当于粘性数据
extraBufferCapacity = 2,//接受的慢时候,发送的入栈
onBufferOverflow = BufferOverflow.SUSPEND
)
lifecycleScope.launch {
launch {

sharedFlow.collect {
println("collect1 received ago shared flow $it")

}
}
launch {
(1..5).forEach {
println("emit1 send ago flow $it")
sharedFlow.emit(it)
println("emit1 send after flow $it")
}
}
// wait a minute
delay(100)
launch {
sharedFlow.collect {
println("collect2 received shared flow $it")
}
}
}

}

结果如下图:


优先发送将其缓存起来,testSharedFlow测试中发送与接收在没有干扰(延时之类的干扰)的情况下 是一条顺序链,而设置了extraBufferCapacity优先发送两条,不管消费情况,不设置的话(extraBufferCapacity = 0)这时如果在collect1里面设置延时delay(100),send会被阻塞(因为默认是 onBufferOverflow = BufferOverflow.SUSPEND的策略)
image.png


3.3、onBufferOverflow


onBufferOverflow:由背压就有处理策略,sharedflow默认为BufferOverflow.SUSPEND
,也即是如果当事件数量超过缓存,发送就会被挂起,上面提到了一句,DROP_OLDEST销毁最旧的值,DROP_LATEST销毁最新的值


三种参数含义


public enum class BufferOverflow {
/**
* 在缓冲区溢出时挂起。
*/
SUSPEND,

/**
* 在缓冲区溢出时删除** *旧的**值,添加新的值到缓冲区,不挂起。
*/
DROP_OLDEST,

/**
* 在缓冲区溢出时,删除当前添加到缓冲区的最新的**值\
*(使缓冲区内容保持不变),不要挂起。
*/
DROP_LATEST
}

eg:和testSharedFlowCapacity方法区别在于 多了个delay(100)



  • SUSPEND模式


private fun testSharedFlow2() {

val sharedFlow = MutableSharedFlow<Int>(
replay = 0,//相当于粘性数据
extraBufferCapacity = 2,//接受的慢时候,发送的入栈
onBufferOverflow = BufferOverflow.SUSPEND
)
lifecycleScope.launch {
launch {

sharedFlow.collect {
println("collect1 received ago shared flow $it")
delay(100)
}
}
launch {
(1..5).forEach {
println("emit1 send ago flow $it")
sharedFlow.emit(it)
println("emit1 send after flow $it")
}
}
// wait a minute
delay(100)
launch {
sharedFlow.collect {
println("collect2 received shared flow $it")
}
}
}

}

image.png


image.png


SUSPEND情况下从第一张图知道collect1都收集了,第二张图发现collect2也打印了两次,为什么只有两次呢?


因为 extraBufferCapacity = 2,等于2,错过了两次的事件发送的接收,不信的话可以试一下extraBufferCapacity = 0,这时候肯定打印了4次,可能有人问为什么是4次呢,因为collect2的订阅者延时了100毫秒才开始订阅,



  • DROP_LATEST模式


private fun testSharedFlow2() {

val sharedFlow = MutableSharedFlow<Int>(
replay = 0,//相当于粘性数据
extraBufferCapacity = 2,//接受的慢时候,发送的入栈
onBufferOverflow = BufferOverflow.DROP_LATEST

)
lifecycleScope.launch {
launch {

sharedFlow.collect {
println("collect1 received ago shared flow $it")
delay(100)
}
}
launch {
(1..5).forEach {
println("emit1 send ago flow $it")
sharedFlow.emit(it)
println("emit1 send after flow $it")
}
}
// wait a minute
delay(100)
launch {
sharedFlow.collect {
println("collect2 received shared flow $it")
}
}
}

}

发送过快的话,销毁最新的,只保留最老的两条事件,我们可以知道1,2,肯定保留其他丢失


image.png


要想不丢是怎么办呢,很简单不要产生背压现象就行,在emit中延时delay(200),比收集耗时长就行。



  • DROP_OLDEST模式
    该模式同理DROP_LATEST模式,保留最新的extraBufferCapacity = 2(多少)的数据就行


四、StateFlow


初始化


val stateFlow = MutableStateFlow<Int>(value = -1)

image.png


image.png


由上图的继承关系可知stateFlow其实就是一种特殊的SharedFlow,它多了个初始值value


image.png
由上图可知:每次更新数据都会和旧数据做一次比较,只有不同时候才会更新数值。


SharedFlow和StateFlow的侧重点



  • StateFlow就是一个replaySize=1的sharedFlow,同时它必须有一个初始值,此外,每次更新数据都会和旧数据做一次比较,只有不同时候才会更新数值。

  • StateFlow重点在状态,ui永远有状态,所以StateFlow必须有初始值,同时对ui而言,过期的状态毫无意义,所以stateFLow永远更新最新的数据(和liveData相似),所以必须有粘滞度=1的粘滞事件,让ui状态保持到最新。另外在一个时间内发送多个事件,不会管中间事件有没有消费完成都会执行最新的一条.(中间值会丢失)

  • SharedFlow侧重在事件,当某个事件触发,发送到队列之中,按照挂起或者非挂起、缓存策略等将事件发送到接受方,在具体使用时,SharedFlow更适合通知ui界面的一些事件,比如toast等,也适合作为viewModel和repository之间的桥梁用作数据的传输。


eg测试如下中间值丢失:


    private fun testSharedFlow2() {
val stateFlow = MutableStateFlow<Int>(value = -1)

lifecycleScope.launch {
launch {

stateFlow.collect {
println("collect1 received ago shared flow $it")
}
}
launch {
(1..5).forEach {
println("emit1 send ago flow $it")
stateFlow.emit(it)
println("emit1 send after flow $it")
}
}
// wait a minute
delay(100)
launch {
stateFlow.collect {
println("collect2 received shared flow $it")
}
}
}

}

由下图可知,中间值丢失,collect2结果可知永远有状态
image.png
好了到这里文章就结束了,源码分析后续再写。


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

为什么B站的弹幕可以不挡人物

那天在B站看视频的时候偶然发现当字幕遇到人物的时候就被裁切了,不会挡住人物,觉得很神奇,于是决定一探究竟。高端的效果,往往只需要采用最朴素的实现方式,忙碌了两个小时,陈师傅打开了F12,豁然开朗。一张图片+一个属性,直接搞定。为了印证我的想法,我决定自己写一个...
继续阅读 »


那天在B站看视频的时候偶然发现当字幕遇到人物的时候就被裁切了,不会挡住人物,觉得很神奇,于是决定一探究竟。

高端的效果,往往只需要采用最朴素的实现方式,忙碌了两个小时,陈师傅打开了F12,豁然开朗。一张图片+一个属性,直接搞定。


为了印证我的想法,我决定自己写一个demo

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<style>
.video {
width: 668px;
height: 376px;
position: relative;
-webkit-mask-image: url("mask.svg");
-webkit-mask-size: 668px 376px;
}
.bullet {
position: absolute;
font-size: 20px;
}
</style>
</head>
<body>
<div class="video">
<div class="bullet" style="left: 100px; top: 0;">元芳,你怎么看</div>
<div class="bullet" style="left: 200px; top: 20px;">你难道就是传说中的奶灵</div>
<div class="bullet" style="left: 300px; top: 40px;">你好,我是胖灵</div>
<div class="bullet" style="left: 400px; top: 60px;">这是第一集,还没有舔灵</div>
</div>
</body>
</html>

效果是这样的


加一个红背景,看的清楚一些


至此我们就实现了B站同款的不遮挡人物的弹幕。至于这张图片是怎么来的,肯定是AI识别出来然后生成的,一张图片也就一两K,一次加载很多张也不会造成很大的负担。

最后来看看这个神奇的css属性吧

所以在开发需求的时候可以把它当成一个亮点使用,但是不能强依赖于这个属性做需求。

原文链接:https://juejin.cn/post/7141012605535010823

收起阅读 »

项目中第三方库并不是必须的

前言有时候集成一个特定的库(比如 PayPal)是必须的,有时候是避免去开发一些非常复杂的功能,有时候仅仅只是避免重复造轮子。虽然这些都是合理的考量,但使用第三方库的风险和相关成本往往被忽视或误解。在某些情况下,风险是值得的,但是在决定冒险之前,首先...
继续阅读 »

前言

有时候集成一个特定的库(比如 PayPal)是必须的,有时候是避免去开发一些非常复杂的功能,有时候仅仅只是避免重复造轮子。

虽然这些都是合理的考量,但使用第三方库的风险和相关成本往往被忽视或误解。在某些情况下,风险是值得的,但是在决定冒险之前,首先要能够明确的定义风险。为了使风险评估更加的透明和一致,我们制定了一个流程来衡量我们将其集成到app有多大的风险。


风险

大多数大型组织,包括我们,都有某种形式的代码审查,作为开发实践的一部分。对这些团队来说,添加一个第三方库就相当于添加了一堆由不属于团队成员开发,未经审查的代码。这破坏了团队一直坚持的代码审查原则,交付了质量未知的代码。这给app的运行方式以及长期开发带来了风险,对于大型团队而言,更是对整体业务带来了风险。

运行时风险

库代码通常来说,对于系统资源,和app拥有相同级别的访问权限,但它们不一定应用团队为管理这些资源而制定的最佳实践。这意味着它们可以在没有限制的情况下访问磁盘,网络,内存,CPU等等,因此,它们可以(过度)将文件写入磁盘,使用未优化的代码占用内存或CPU,导致死锁或主线程延迟,下载(和上传!)大量数据等等。更糟糕的是他们会导致崩溃,甚至崩溃循环。两次。

其中许多情况直到 app 已经上架才被发现,在这种情况下,修复它需要创建一个新版本,并通过审核,这通常需要大量时间和成本。这种风险可以通过一个变量控制是否调用来进行一定程度的控制,但是这种方法也并非万无一失(看下文)。

开发风险

引用一个同事的话:“每一行代码都是一种负担”,对不是你自己写的代码而言,这句话更甚。库在适配新技术或API时可能很慢,这阻碍了代码开发,或者太快,导致开发的版本过高。

库在采用新技术或API时可能很慢,阻碍了代码库,或者太快,导致部署目标太高。每当 Apple 和 Google 每年发布一个新 OS 版本时,他们通常要求开发人员根据SDK的变化更新代码,库开发人员也必须这样做。这需要协调一致的努力、优先事项的一致性以及及时完成工作的能力。

随着移动平台的不断变化,以及团队(成员)也不是一成不变,这将会成为一个持续不断的风险。当被集成的库不存在了,而库又需要更新时,会花很多时间来决定谁来做。事实证明一旦一个库存在,就很少也很难被移除,因此我们将其视为长期维护成本。

商业风险

如同我上面所说,现代的操作系统并没有对 app 代码和库代码进行区分,因此除了系统资源之外,它们还可以访问用户信息。作为 app 的开发者,我们负责恰当的使用这部分信息,也需要为任何第三方库负责。

如果用户给了 Lyft app 地理位置授权,任何第三方库也将自动得获得授权。他们可以将那些(地理位置)数据上传到自己服务器,竞对服务器,或者谁知道还有什么地方。当一个库需要我们没有的权限时,那问题就更大了。

同样,一个系统的安全取决于其最薄弱的环节,但如果其中包含未经审核的代码,那么你就不知道它到底有多安全。你精心设计的安全编码实践可能会被一个行为不当的库所破坏。苹果和谷歌实施的任何政策都是如此,例如“你不得对用户追踪”。


减少风险

当对一个库(是否)进行使用评估时,我们首先要问几个问题,以了解对库的需求。

我们内部能做么?

有时候我们只需要简单的粘贴复制真正需要的部分。在更复杂的场景中,库与自定义后端通信,我们对该API进行了逆向,并自己构建了一个迷你SDK(同样,只构建了我们需要的部分)。在90%的情况下,这是首选,但在与非常特定的供应商或需求集成时并不总是可行。

有多少用户从该库中受益?

在一种情况下,我们正在考虑添加一个风险很大的库(根据下面的标准),旨在为一小部分用户提供服务,同时将我们的所有用户都暴露在该库中。对于我们认为会从中受益的一小部分客户,我们冒了为我们所有用户带来问题的风险。

这个库有什么传递依赖?

我们还需要评估库的所有依赖项的以下标准。

退出标准是什么?

如果集成成功,是否有办法将其转移到内部?如果不成功,是否有办法删除?


评价标准

如果此时团队仍然希望集成库,我们要求他们根据一组标准对库进行“评分”。下面的列表并不全面,但应该能很好地说明我们希望看到的。

阻断标准

这些标准将阻止我们从技术上或者公司政策上集成此库,在进行下一步之前,我们必须解决:

过高的 deployment target/target SDKs。 我们支持过去4年主流的操作系统(版本),所以第三方库至少也需要支持一样多。

许可证不正确/缺失。 我们将许可文件与应用捆绑在一起,以确保我们可以合法使用代码并将其归属于许可持有人。

没有冲突的传递依赖关系。 一个库不能有一个我们已经包含但版本不同的传递依赖项。

不显示它自己的 UI 。 我们非常小心地使我们的产品看起来尽可能统一,定制用户界面对此不利。

它不使用私有 API 。 我们不愿意冒 app 因使用私有 API 而被拒绝的风险。

主要关注点

闭源。 访问源代码意味着我们可以选择我们想要包含的库的哪些部分,以及如何将该源代码与应用程序的其余部分捆绑在一起。对于我们来说,一个封闭源代码的二进制发行版更难集成。

编译时有警告。 我们启用了“警告视为错误”,具有编译警告的库是库整体质量(下降)的良好指示。

糟糕的文档。 我们希望有高质量的内联文档,外部”如何使用“文档,以及有意义的更新日志。

二进制体积。 这个库有多大?一些库提供了很多功能,而我们只需要其中的一小部分。尤其是在没有访问源码权限的情况下,这通常是一个全有或全无的情况。

外部的网络流量。 与我们无法控制的上游服务器/端点通信的库可能会在服务器关闭、错误数据被发回等时关闭整个应用程序。这也与我上面提到的隐私问题相同。

技术支持。 当事情不能正常工作时,我们需要能够报告/上报问题,并在合理的时间内解决问题。开源项目通常由志愿者维护,也很难有一个时间线,但至少我们可以自己进行修改。这在闭源项目是不可能的。

无法禁用。 虽然大多数库特别要求我们初始化它,但有些库在实例化时更“主动”,并且在我们不调用它的情况下可以自己执行工作。这意味着当库导致问题时,我们无法通过功能变量或其他机制将其关闭。

我们为所有这些(和其他一些)标准分配了点数,并要求工程师为他们想要集成的库汇总这些点数。虽然默认情况下,低分数并不难被拒绝,但我们通常会要求更多的理由来继续前进。


最后

虽然这个过程看起来非常严格,在许多情况下,潜在风险是假设的,但我们有我在这篇博文中描述的每个场景的实际例子。将评估记录下来并公开,也有助于将相对风险传达给不熟悉移动平台工作方式的人,并证明我们没有随意评估风险。

收起阅读 »

Kotlin 协程 Select:看我如何多路复用

前言协程通信三剑客:Channel、Select、Flow,上篇已经分析了Channel的深水区,本篇将会重点分析Select的使用及原理。通过本篇文章,你将了解到:Select 的引入Select 的使用Invoke函数 的妙用Select 的原理Selec...
继续阅读 »

前言

协程通信三剑客:Channel、Select、Flow,上篇已经分析了Channel的深水区,本篇将会重点分析Select的使用及原理。
通过本篇文章,你将了解到:

  1. Select 的引入
  2. Select 的使用
  3. Invoke函数 的妙用
  4. Select 的原理
  5. Select 注意事项

1. Select 的引入

多路数据的选择

串行执行

如今的二维码识别应用场景越来越广了,早期应用比较广泛的识别SDK如zxing、zbar,它们各有各的特点,也存在识别不出来的情况,为了将两者优势结合起来,我们想到的方法是同一份二维码图片分别给两者进行识别。
如下:

    //从zxing 获取二维码信息
suspend fun getQrcodeInfoFromZxing(bitmap: Bitmap?): String {
//模拟耗时
delay(2000)
return "I'm fish"
}

//从zbar 获取二维码信息
suspend fun getQrcodeInfoFromZbar(bitmap: Bitmap?): String {
delay(1000)
return "I'm fish"
}

fun testSelect() {
runBlocking {
var bitmap = null
var starTime = System.currentTimeMillis()
var qrcoe1 = getQrcodeInfoFromZxing(bitmap)
var qrcode2 = getQrcodeInfoFromZbar(bitmap)
println("qrcode1=$qrcoe1 qrcode2=$qrcode2 useTime:${System.currentTimeMillis() - starTime} ms")
}
}

查看打印,最后花费的时间:

qrcode1=I'm fish qrcode2=I'm fish useTime:3013 ms

当然这是串行的方式效率比较低,我们想到了用协程来优化它。

协程并行执行

如下:

    fun testSelect1() {
var bitmap = null;
var starTime = System.currentTimeMillis()
var deferredZxing = GlobalScope.async {
getQrcodeInfoFromZxing(bitmap)
}

var deferredZbar = GlobalScope.async {
getQrcodeInfoFromZbar(bitmap)
}

runBlocking {
//挂起等待识别结果
var qrcoe1 = deferredZxing.await()
//挂起等待识别结果
var qrcode2 = deferredZbar.await()
println("qrcode1=$qrcoe1 qrcode2=$qrcode2 useTime:${System.currentTimeMillis() - starTime} ms")
}
}

查看打印,最后花费的时间:

qrcode1=I'm fish qrcode2=I'm fish useTime:2084 ms

可以看出,花费时间明显变少了。
与上个Demo 相比,虽然识别过程是放在协程里并行执行的,但是在等待识别结果却是串行的。我们引入两个识别库的初衷是哪个识别快就用哪个的结果,为了达成这个目的,传统的方式是:

同时监听并记录识别结果的返回。

同时监听多路结果

如下:

    fun testSelect2() {
var bitmap = null;
var starTime = System.currentTimeMillis()
var deferredZxing = GlobalScope.async {
getQrcodeInfoFromZxing(bitmap)
}

var deferredZbar = GlobalScope.async {
getQrcodeInfoFromZbar(bitmap)
}

var isEnd = false
var result: String? = null
GlobalScope.launch {
if (!isEnd) {
//没有结束,则继续识别
var resultTmp = deferredZxing.await()
if (!isEnd) {
//识别没有结束,说明自己是第一个返回结果的
result = resultTmp
println("zxing recognize ok useTime:${System.currentTimeMillis() - starTime} ms")
//标记识别结束
isEnd = true
}
}
}

GlobalScope.launch {
if (!isEnd) {
var resultTmp = deferredZbar.await()
if (!isEnd) {
//识别没有结束,说明自己是第一个返回结果的
result = resultTmp
println("zbar recognize ok useTime:${System.currentTimeMillis() - starTime} ms")
isEnd = true
}
}
}

//检测是否有结果返回
runBlocking {
while (!isEnd) {
delay(1)
}
println("recognize result:$result")
}
}

通过检测isEnd 标记来判断是否有某个模块返回结果。
结果如下:

  • zbar recognize ok useTime:1070 ms
  • recognize result:I'm fish

由于模拟设定的zbar 解析速度快,因此每次都是采纳的是zbar的结果,所花费的时间大幅减少了,该结果符合预期。

Select 闪亮登场

虽说上个Demo结果符合预期,但是多了很多额外的代码、多引入了其它协程,并且需要子模块对标记进行赋值(对"isEnd"进行赋值),没有达到解耦的目的。我们希望子模块的任务是单一且闭环的,如果能在一个函数里统一检测结果的返回就好了。
Select 就是为了解决多路数据的选择而生的。
来看看它是怎么解决该问题的:

    fun testSelect3() {
var bitmap = null;
var starTime = System.currentTimeMillis()
var deferredZxing = GlobalScope.async {
getQrcodeInfoFromZxing(bitmap)
}
var deferredZbar = GlobalScope.async {
getQrcodeInfoFromZbar(bitmap)
}
runBlocking {
//通过select 监听zxing、zbar 结果返回
var result = select<String> {
//监听zxing
deferredZxing.onAwait {value->
//value 为deferredZxing 识别的结果
"zxing result $value"
}

//监听zbar
deferredZbar.onAwait { value->
"zbar result $value"
}
}

//运行到此,说明已经有结果返回
println("result from $result useTime:${System.currentTimeMillis() - starTime}")
}
}

结果如下:

result from zbar result I'm fish useTime:1079

符合预期,同时可以看出:相比上个Demo,这样写简洁了许多。

2. Select 的使用

除了可以监听async的结果,Select 还可以监听Channel的发送方/接收方 数据,我们以监听接收方数据为例:

    fun testSelect4() {
runBlocking {
var bitmap = null;
var starTime = System.currentTimeMillis()
var receiveChannelZxing = produce {
//生产数据
var result = getQrcodeInfoFromZxing(bitmap)
//发送数据
send(result)
}

var receiveChannelZbar = produce {
var result = getQrcodeInfoFromZbar(bitmap)
send(result)
}

var result = select<String> {
//监听是否有数据发送过来
receiveChannelZxing.onReceive {
value->"zxing result $value"
}

receiveChannelZbar.onReceive {
value->"zbar result $value"
}
}

println("result from $result useTime:${System.currentTimeMillis() - starTime}")
}
}

结果如下:

result from zbar result I'm fish useTime:1028

不论是async还是Channel,Select 都可以监听它们的数据,从而形成多路复用的效果。

image.png

在监听协程里调用select 表达式,表达式{}内声明需要监听的协程的数据,对于select 来说有两种场景:

  1. 没有数据,则select 挂起协程并等待直到其它协程数据准备完成后再次恢复select 所在的协程。
  2. 有数据,则select 正常执行并返回获取的数据。

3. Invoke函数 的妙用

在分析Select 原理之前,需要弄明白invoke函数的原理。
对于Kotlin 类来说,都可以重写其invoke函数。

    operator fun invoke():String {
return "I'm fish"
}

如上,重写了SelectDemo里的invoke函数,和普通成员函数一样,我们可以通过对象调用它。

fun main(args: Array<String>) {
var selectDemo = SelectDemo()
var result = selectDemo.invoke()
println("result:$result")
}

当然,可以进一步简化:

fun main(args: Array<String>) {
var selectDemo = SelectDemo()
var result = selectDemo()
println("result:$result")
}

这里涉及到了kotlin的语法糖:对象居然可以像函数一样调用。
作为函数,invoke 当然也可以接收高阶函数作为参数:

    operator fun invoke(block: (Int) -> String): String {
return block(3)
}

fun main(args: Array<String>) {
var selectDemo = SelectDemo()
var result = selectDemo { age ->
when (age) {
3 -> "I'm fish3"
4 -> "I'm fish4"
else -> "error"
}
}
println("result:$result")
}

因此,当看到对象作为函数调用时,实际上调用的是invoke函数,具体的逻辑需要查看其invoke函数的实现。

4. Select 的原理

上篇分析过Channel,因此本篇趁热打铁,通过Select 监听Channel数据的变化来分析其原理,为方便讲解,我们先以监听一个Channel的为例。
先从select 表达式本身入手。

    fun testSelect5() {
runBlocking {
var starTime = System.currentTimeMillis()
var receiveChannelZxing = produce {
//发送数据
send("I'm fish")
}

//确保channel 数据已经send
delay(1000)
var result = select<String> {
//监听是否有数据发送过来
receiveChannelZxing.onReceive { value ->
"zxing result $value"
}
}
println("result from $result useTime:${System.currentTimeMillis() - starTime}")
}
}

select 是挂起函数,因此协程运行到此有可能被挂起。

#Select.kt
public suspend inline fun <R> select(crossinline builder: SelectBuilder<R>.() -> Unit): R {
//...
return suspendCoroutineUninterceptedOrReturn { uCont ->
//传入父协程体
val scope = SelectBuilderImpl(uCont)
try {
//执行builder
builder(scope)
} catch (e: Throwable) {
scope.handleBuilderException(e)
}
//通过返回值判断是否需要挂起协程
scope.getResult()
}
}

重点看builder(scope),builder 是高阶函数,实际上就是执行了select花括号里的内容,而它里面就是监听数据是否返回。

receiveChannelZxing.onReceive
刚开始看的时候势必以为onReceive是个函数,然而它是ReceiveChannel 里的成员变量:

#Channel.kt
public val onReceive: SelectClause1<E>

通过上一节的分析可知,关键是要找到SelectClause1 的invoke的实现。

#Select.kt
public interface SelectBuilder<in R> {
//block 有个入参
//声明了SelectClause1的扩展函数invoke
public operator fun <Q> SelectClause1<Q>.invoke(block: suspend (Q) -> R)
}

override fun <Q> SelectClause1<Q>.invoke(block: suspend (Q) -> R) {
//SelectBuilderImpl 实现了 SelectClause1 的invoke函数
registerSelectClause1(this@SelectBuilderImpl, block)
}

再看onReceive 的赋值:

#AbstractChannel.kt
final override val onReceive: SelectClause1<E>
get() = object : SelectClause1<E> {
@Suppress("UNCHECKED_CAST")
override fun <R> registerSelectClause1(select: SelectInstance<R>, block: suspend (E) -> R) {
registerSelectReceiveMode(select, RECEIVE_THROWS_ON_CLOSE, block as suspend (Any?) -> R)
}
}

因此,简单总结调用栈如下:

当调用receiveChannelZxing.onReceive{},实际上调用了SelectClause1.invoke(),而它里面又调用了SelectClause1.registerSelectClause1(),最终调用了AbstractChannel.registerSelectReceiveMode。

AbstractChannel. registerSelectReceiveMode

#AbstractChannel.kt
private fun <R> registerSelectReceiveMode(select: SelectInstance<R>, receiveMode: Int, block: suspend (Any?) -> R) {
while (true) {
//如果已经有结果了,则直接返回------->①
if (select.isSelected) return
if (isEmptyImpl) {
//没有发送者在等待,则入队等待,并返回 ------->②
if (enqueueReceiveSelect(select, block, receiveMode)) return
} else {
//直接取出值------->③
val pollResult = pollSelectInternal(select)
when {
pollResult === ALREADY_SELECTED -> return
pollResult === POLL_FAILED -> {} // retry
pollResult === RETRY_ATOMIC -> {} // retry
//调用block------->④
else -> block.tryStartBlockUnintercepted(select, receiveMode, pollResult)
}
}
}
}

分为4个点,接着来一一分析。

select 同时监听多个值,若是有1个符合要求的数据返回了,那么该isSelected 标记为true,当检测到该标记为true时直接退出。
结合之前的Demo,zbar 已经识别出结果了,当select 检测zxing的结果时直接返回。

#AbstractChannel.kt
private fun <R> enqueueReceiveSelect(
select: SelectInstance<R>,
block: suspend (Any?) -> R,
receiveMode: Int
): Boolean {
//构造为Node元素
val node = AbstractChannel.ReceiveSelect(this, select, block, receiveMode)
//添加到Channel队列里
val result = enqueueReceive(node)
if (result) select.disposeOnSelect(node)
return result

当select 时,发现Channel里没有数据,说明Channel还没有开始send,因此构造了Node(ReceiveSelect)加入到Channel queue里。当send数据时,会查找queue里是否有接收者等待,若有则调用Node(ReceiveSelect.completeResumeReceive):

#AbstractChannel.kt
override fun completeResumeReceive(value: E) {
block.startCoroutineCancellable(
if (receiveMode == RECEIVE_RESULT) ChannelResult.success(value) else value,
select.completion,
resumeOnCancellationFun(value)
)
}

block 被调度执行,最后会恢复select 协程的执行。


取出数据,并尝试恢复send协程。


在③的基础上,拿到数据后,直接执行block(此时并没有切换线程进行调度)。

小结一下select 原理:

image.png

可以看出:

select 本身执行并不耗时,若最终没有数据返回则挂起等待,若是有数据返回则不会挂起协程。

我们从头再捋一下select 配合Channel 的原理:

image.png

虽然以Channel为例讲解了select 原理,实际上async等结合select 原理大致差不多,重点都是利用了协程的挂起/恢复做文章。

5. Select 注意事项

如果select有多个数据同时到达,select 默认会选择第一个数据,若想要随机选择数据,可做如下处理:

            var result = selectUnbiased<String> {
//监听是否有数据发送过来
receiveChannelZxing.onReceive { value ->
"zxing result $value"
}
}

想要知道select 还可以监听哪些数据,可查看该数据是否实现了SelectClauseX(X 表示0、1、2)。

以上即为Select 的原理及其使用,下篇将会进入协程的精华部分:Flow的运用,该部分内容较多,可能会分几篇分析,敬请期待。

本文基于Kotlin 1.5.3,文中完整Demo请点击


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

收起阅读 »

Kotlin协程:flowOn与线程切换

    本文分析示例代码如下: launch(Dispatchers.Main) { flow { emit(1) emit(2) }.flowOn(Dispatchers.IO).collect { del...
继续阅读 »

    本文分析示例代码如下:


launch(Dispatchers.Main) {
flow {
emit(1)
emit(2)
}.flowOn(Dispatchers.IO).collect {
delay(1000)

withContext(Dispatchers.IO) {
Log.d("liduo", "$it")
}

Log.d("liduo", "$it")
}
}

一.flowOn方法


    flowOn方法用于将上游的流切换到指定协程上下文的调度器中执行,同时不会把协程上下文暴露给下游的流,即flowOn方法中协程上下文的调度器不会对下游的流生效。如下面这段代码所示:


launch(Dispatchers.Main) {
flow {
emit(2) // 执行在IO线程池
}.flowOn(Dispatchers.IO).map {
it + 1 // 执行在Default线程池
}.flowOn(Dispatchers.Default).collect {
Log.d("liduo", "$it") //执行在主线程
}
}

    接下来,分析一下flowOn方法,代码如下:


public fun <T> Flow<T>.flowOn(context: CoroutineContext): Flow<T> {
// 检查当前协程没有执行结束
checkFlowContext(context)
return when {
// 为空,则返回自身
context == EmptyCoroutineContext -> this
// 如果是可融合的Flow,则尝试融合操作,获取新的流
this is FusibleFlow -> fuse(context = context)
// 其他情况,包装成可融合的Flow
else -> ChannelFlowOperatorImpl(this, context = context)
}
}

// 确保Job不为空
private fun checkFlowContext(context: CoroutineContext) {
require(context[Job] == null) {
"Flow context cannot contain job in it. Had $context"
}
}

    在flowOn方法中,首先会检查方法所在的协程是否执行结束。如果没有结束,则会执行判断语句,这里flowOn方法传入的上下文不是空上下文,且通过flow方法构建出的Flow对象也不是FusibleFlow类型的对象,因此这里会走到else分支,将上游flow方法创建的Flow对象和上下文包装成ChannelFlowOperatorImpl类型的对象。


1.ChannelFlowOperatorImpl类


    ChannelFlowOperatorImpl类继承自ChannelFlowOperator类,用于将上游的流包装成一个ChannelFlow对象,它的继承关系如下图所示:

b5e51102-a741-4122-8ba1-29c331ffbf5a.png


    通过上图可以知道,ChannelFlowOperatorImpl类最终继承了ChannelFlow类,代码如下:


internal class ChannelFlowOperatorImpl<T>(
flow: Flow<T>,
context: CoroutineContext = EmptyCoroutineContext,
capacity: Int = Channel.OPTIONAL_CHANNEL,
onBufferOverflow: BufferOverflow = BufferOverflow.SUSPEND
) : ChannelFlowOperator<T, T>(flow, context, capacity, onBufferOverflow) {
// 用于流融合时创建新的流
override fun create(context: CoroutineContext, capacity: Int, onBufferOverflow: BufferOverflow): ChannelFlow<T> =
ChannelFlowOperatorImpl(flow, context, capacity, onBufferOverflow)

// 若当前的流不需要通过Channel即可实现正常工作时,会调用此方法
override fun dropChannelOperators(): Flow<T>? = flow

// 触发对下一级流进行收集
override suspend fun flowCollect(collector: FlowCollector<T>) =
flow.collect(collector)
}

二.collect方法


    在Kotlin协程:Flow基础原理中讲到,当执行collect方法时,内部会调用最后产生的Flow对象的collect方法,代码如下:


public suspend inline fun <T> Flow<T>.collect(crossinline action: suspend (value: T) -> Unit): Unit =
collect(object : FlowCollector<T> {
override suspend fun emit(value: T) = action(value)
})

    这个最后产生的Flow对象就是ChannelFlowOperatorImpl类对象。


1.ChannelFlowOperator类的collect方法


    ChannelFlowOperatorImpl类没有重写collect方法,因此调用的是它的父类ChannelFlowOperator类的collect方法,代码如下:


override suspend fun collect(collector: FlowCollector<T>) {
// OPTIONAL_CHANNEL为默认值,这里满足条件,之后会详细讲解
if (capacity == Channel.OPTIONAL_CHANNEL) {
// 获取当前协程的上下文
val collectContext = coroutineContext
// 计算新的上下文
val newContext = collectContext + context
// 如果前后上下文没有发生变化
if (newContext == collectContext)
// 直接触发对下一级流的收集
return flowCollect(collector)
// 如果上下文发生变化,但不需要切换线程
if (newContext[ContinuationInterceptor] == collectContext[ContinuationInterceptor])
// 切换协程上下文,调用flowCollect方法触发下一级流的收集
return collectWithContextUndispatched(collector, newContext)
}
// 调用父类的collect方法
super.collect(collector)
}

// 获取当前协程的上下文,该方法会被编译器处理
@SinceKotlin("1.3")
@Suppress("WRONG_MODIFIER_TARGET")
@InlineOnly
public suspend inline val coroutineContext: CoroutineContext
get() {
throw NotImplementedError("Implemented as intrinsic")
}

    ChannelFlowOperator类的collect方法在设计上与协程的withContext方法设计思路是一致的:在方法内根据上下文的不同情况进行判断,在必要时才会切换线程去执行任务。


    通过flowOn方法创建的ChannelFlowOperatorImpl类对象,参数capacity为默认值OPTIONAL_CHANNEL。因此代码在执行时会进入到判断中,但因为我们指定了上下文为Dispatchers.IO,因此上下文发生了变化,同时拦截器也发生了变化,所以最后会调用ChannelFlowOperator类的父类的collect方法,也就是ChannelFlow类的collect方法。


2.ChannelFlow类的collect方法


    ChannelFlow类的代码如下:


override suspend fun collect(collector: FlowCollector<T>): Unit =
coroutineScope {
collector.emitAll(produceImpl(this))
}

    在ChannelFlow类的collect方法中,首先通过coroutineScope方法创建了一个作用域协程,接着调用了produceImpl方法,代码如下:


public open fun produceImpl(scope: CoroutineScope): ReceiveChannel<T> =
scope.produce(context, produceCapacity, onBufferOverflow, start = CoroutineStart.ATOMIC, block = collectToFun)

    produceImpl方法内部调用了produce方法,并且传入了待执行的任务collectToFun。


    produce方法在Kotlin协程:协程的基础与使用中曾提到过,它是官方提供的启动协程的四个方法之一,另外三个方法为launch方法、async方法、actor方法。代码如下:


internal fun <E> CoroutineScope.produce(
context: CoroutineContext = EmptyCoroutineContext,
capacity: Int = 0,
onBufferOverflow: BufferOverflow = BufferOverflow.SUSPEND,
start: CoroutineStart = CoroutineStart.DEFAULT,
onCompletion: CompletionHandler? = null,
@BuilderInference block: suspend ProducerScope<E>.() -> Unit
): ReceiveChannel<E> {
// 根据容量与溢出策略创建Channel对象
val channel = Channel<E>(capacity, onBufferOverflow)
// 计算新的上下文
val newContext = newCoroutineContext(context)
// 创建协程
val coroutine = ProducerCoroutine(newContext, channel)
// 监听完成事件
if (onCompletion != null) coroutine.invokeOnCompletion(handler = onCompletion)
// 启动协程
coroutine.start(start, coroutine, block)
return coroutine
}

    在produce方法内部,首先创建了一个Channel类型的对象,接着创建了类型为ProducerCoroutine的协程,并且传入Channel对象作为参数。最后,produce方法返回了一个ReceiveChannel接口指向的对象,当协程执行完毕后,会通过Channel对象将结果通过send方法发送出来。


    至此,可以知道flowOn方法的实现实际上是利用了协程拦截器的拦截功能。


    在这里之后,代码逻辑分成了两部分,一部分是block在ProducerCoroutine协程中的执行,另一部分是通过ReceiveChannel对象获取执行的结果。


3.flow方法中代码的执行


    在produceImpl方法中,调用了produce方法,并且传入了collectToFun对象,这个对象将会在produce方法创建的协程中执行,代码如下:


internal val collectToFun: suspend (ProducerScope<T>) -> Unit
get() = { collectTo(it) }

    当调用collectToFun对象的invoke方法时,会触发collectTo方法的执行,该方法在ChannelFlowOperator类中被重写,代码如下:


protected override suspend fun collectTo(scope: ProducerScope<T>) =
flowCollect(SendingCollector(scope))

    在collectTo方法中,首先将参数scope封装成SendingCollector类型的对象,接着调用了flowCollect方法,该方法在ChannelFlowOperatorImpl类中被重写,代码如下:


override suspend fun flowCollect(collector: FlowCollector<T>) =
flow.collect(collector)

    ChannelFlowOperatorImpl类的flowCollect方法内部调用了flow对象的collect方法,这个flow对象就是最初通过flow方法构建的对象。根据Kotlin协程:Flow基础原理的分析,这个flow对象类型为SafeFlow,最后会通过collectSafely方法,触发flow方法中的block执行。代码如下:


private class SafeFlow<T>(private val block: suspend FlowCollector<T>.() -> Unit) : AbstractFlow<T>() {
override suspend fun collectSafely(collector: FlowCollector<T>) {
// 触发执行
collector.block()
}
}

    当flow方法在执行过程中需要向下游发出值时,会调用emit方法。根据上面flowCollect方法和collectTo方法可以知道,collectSafely方法的collector对象就是collectTo方法中创建的SendingCollector类型的对象,代码如下:


@InternalCoroutinesApi
public class SendingCollector<T>(
private val channel: SendChannel<T>
) : FlowCollector<T> {
// 通过Channel类对象发送值
override suspend fun emit(value: T): Unit = channel.send(value)
}

    当调用SendingCollector类型的对象的emit方法时,会通过调用类型为Channel的对象的send方法,将值发送出去。


    接下来,将分析下游如何接收上游发出的值。


4.接收flow方法发出的值


    回到ChannelFlow类的collect方法,之前提到collect方法中调用produceImpl方法,开启了一个新的协程去执行任务,并且返回了一个ReceiveChannel接口指向的对象。代码如下:


override suspend fun collect(collector: FlowCollector<T>): Unit =
coroutineScope {
collector.emitAll(produceImpl(this))
}

    在调用完produceImpl方法后,接着调用了emitAll方法,将ReceiveChannel接口指向的对象作为emitAll方法的参数,代码如下:


public suspend fun <T> FlowCollector<T>.emitAll(channel: ReceiveChannel<T>): Unit =
emitAllImpl(channel, consume = true)

    emitAll方法是FlowCollector接口的扩展方法,内部调用了emitAllImpl方法对参数channel进行封装,代码如下:


private suspend fun <T> FlowCollector<T>.emitAllImpl(channel: ReceiveChannel<T>, consume: Boolean) {
// 用于保存异常
var cause: Throwable? = null
try {
// 死循环
while (true) {
// 挂起,等待接收Channel结果或Channel关闭
val result = run { channel.receiveOrClosed() }
// 如果Channel关闭了
if (result.isClosed) {
// 如果有异常,则抛出
result.closeCause?.let { throw it }
// 没有异常,则跳出循环
break
}
// 获取并发送值
emit(result.value)
}
} catch (e: Throwable) {
// 捕获到异常时抛出
cause = e
throw e
} finally {
// 执行结束关闭Channel
if (consume) channel.cancelConsumed(cause)
}
}

    emitAllImpl方法是FlowCollector接口的扩展方法,而这里的FlowCollector接口指向的对象,就是collect方法中创建的匿名对象,代码如下:


public suspend inline fun <T> Flow<T>.collect(crossinline action: suspend (value: T) -> Unit): Unit =
collect(object : FlowCollector<T> {
override suspend fun emit(value: T) = action(value)
})

    在emitAllImpl方法中,当通过receiveOrClosed方法获取到上游发出的值时,会调用emit方法通知下游,这时就会触发collect方法中block的执行,最终实现值从流的上游传递到了下游。


三.flowOn方法与流的融合


    假设对一个流连续调用两次flowOn方法,那么流最终会在哪个flowOn方法指定的调度器中执行呢?代码如下:


launch(Dispatchers.Main) {
flow {
emit(2)
// emit方法是在IO线程执行还是在主线程执行呢?
}.flowOn(Dispatchers.IO).flowOn(Dispatchers.Main).collect {
Log.d("liduo", "$it")
}
}

    答案是在IO线程执行,为什么呢?


    根据本篇上面的分析,当第一次调用flowOn方法时,上游的流会被包裹成ChannelFlowOperatorImpl对象,代码如下:


public fun <T> Flow<T>.flowOn(context: CoroutineContext): Flow<T> {
// 检查当前协程没有执行结束
checkFlowContext(context)
return when {
// 为空,则返回自身
context == EmptyCoroutineContext -> this
// 如果是可融合的Flow,则尝试融合操作,获取新的流
this is FusibleFlow -> fuse(context = context)
// 其他情况,包装成可融合的Flow
else -> ChannelFlowOperatorImpl(this, context = context)
}
}

    而当第二次调用flowOn方法时,由于此时上游的流——ChannelFlowOperatorImpl类型的对象,实现了FusibleFlow接口,因此,这里会触发流的融合,直接调用上游的流的fuse方法,并传入新的上下文。这里容量和溢出策略均为默认值。


    根据Kotlin协程:Flow的融合、Channel容量、溢出策略的分析,这里会调用ChannelFlow类的fuse方法。相关代码如下:


public override fun fuse(context: CoroutineContext, capacity: Int, onBufferOverflow: BufferOverflow): Flow<T> {
...

// 计算融合后流的上下文
// context为下游的上下文,this.context为上游的上下文
val newContext = context + this.context

...
}

    再根据之前在Kotlin协程:协程上下文与上下文元素中的分析,当两个上下文进行相加时,后一个上下文中的拦截器会覆盖前一个上下文中的拦截器。在上面的代码中,后一个上下文为上游的流的上下文,因此会优先使用上游的拦截器。代码如下:


public operator fun plus(other: CoroutineDispatcher): CoroutineDispatcher = other

四.总结


c85c5ea0-e850-4398-aa98-b007e2e78124.png
    粉线为使用时代码编写顺序,绿线为下游触发上游的调用顺序,红线为上游向下游发送值的调用顺序,蓝线为线程切换的位置。


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

Koltin协程:Flow的触发与消费

    本文分析示例代码如下: launch(Dispatchers.Main) { val task = flow { emit(2) emit(3) }.onEach { ...
继续阅读 »

    本文分析示例代码如下:


launch(Dispatchers.Main) {
val task = flow {
emit(2)
emit(3)
}.onEach {
Log.d("liduo", "$it")
}

task.collect()
}

一.Flow的触发与消费


    在Kotlin协程:Flow基础原理的分析中,流的触发与消费都是同时进行的。每当调用collect方法时,会触发流的执行,并同时在collect方法中对流发出的值进行消费。


    而在协程中,其实还提供了分离流的触发与消费的操作——onEach方法。通过使用onEach方法,可以将原本在collect方法中的消费过程的移动到onEach方法中。这样在构建好一个Flow对象后,不会立刻去执行onEach方法,只有当调用collect方法时,才会真正的去触发流的执行。这样就实现了流的触发与消费的分离。


    接下来,将对onEach方法进行分析。


1.onEach方法


    onEach方法用于预先构建流的消费过程,只有在触发流的执行后,才会对流进行消费,代码如下:


public fun <T> Flow<T>.onEach(action: suspend (T) -> Unit): Flow<T> = transform { value ->
action(value)
return@transform emit(value)
}

    onEach方法是一个Flow接口的扩展方法,返回一个类型为Flow的对象。Flow方法内部通过transform方法实现。


2.transform方法


    transform方法是onEach方法的核心实现,代码如下:


public inline fun <T, R> Flow<T>.transform(
@BuilderInference crossinline transform: suspend FlowCollector<R>.(value: T) -> Unit
): Flow<R> = flow { // 创建Flow对象
collect { value -> // 触发collect
return@collect transform(value)
}
}

    transform方法也是Flow接口的扩展方法,同样会返回一个类型为Flow的对象。并且在transform方法内部,首先构建了一个类型为Flow的对象,并且在这个Flow对象的执行体内,调用上游的流的collect来触发消费过程,并通过调用参数transform来实现消费。这个collect方法是一个扩展方法,在Kotlin协程:Flow基础原理分析过,因此不再赘述。


    这就是onEach方法实现触发与消费分离的核心,它将对上游的流的消费过程包裹在了一个新的流内,只有当这个新的流或其下游的流被触发时,才会触发这个新的流自身的执行,从而实现对上游的流的消费。


    接下来分析一下流的消费过程。


3.collect方法


    collect方法用于触发流的消费,我们这里调用的collect方法,是一个无参数的方法,代码如下:


public suspend fun Flow<*>.collect(): Unit = collect(NopCollector)

    这里的无参数collect方法是Flow接口的扩展方法。在无参数collect方法中,调用了另一个有参数的collect方法,这个有参数的collect方法在Kotlin协程:Flow基础原理中提到过,就是Flow接口中定义的方法,并且传入了NopCollecor对象,代码如下:


internal object NopCollector : FlowCollector<Any?> {
override suspend fun emit(value: Any?) {
// 什么都不做
}
}

    NopCollecor是一个单例类,它实现了FlowCollector接口,但是emit方法为空实现。


    因此,这里会调用onEach方法返回的Flow对象的collect方法,这部分在Kotlin协程:Flow基础原理进行过分析,最后会触发flow方法中的block参数的执行。而这个Flow对象就是transform方法返回的Flow对象。代码如下:


public inline fun <T, R> Flow<T>.transform(
@BuilderInference crossinline transform: suspend FlowCollector<R>.(value: T) -> Unit
): Flow<R> = flow { // 创建Flow对象
collect { value -> // 触发collect
return@collect transform(value)
}
}

    通过上面的transform方法可以知道,在触发flow方法中的block参数执行后,会调用collect方法。上面提到transform方法是Flow接口的扩展方法,因此这里有会继续调用上游Flow对象的collect方法。这个过程与刚才分析的类似,这里调用的上游的Flow对象,就是我们在示例代码中通过flow方法构建的Flow对象。


    此时,会触发上游flow方法中block参数的执行,并在执行过程中,通过emit方法将值发送到下游。


    接下来,在transform方法中,collect方法的block参数会被会被回调执行,处理上游发送的值。这里又会继续调用transform方法中参数的执行,这部分逻辑在onEach方法中,代码如下:


public fun <T> Flow<T>.onEach(action: suspend (T) -> Unit): Flow<T> = transform { value ->
action(value)
return@transform emit(value)
}

    这里会调用参数action的执行,流在这里最终被消费。同时,onEach方法会继续调用emit方法,将上游返回的值再原封不动的传递到下游,交由下游的流处理。


二.多消费过程的执行


    首先看下面这段代码:


launch(Dispatchers.Main) {
val task = flow {
emit(2)
emit(3)
}.onEach {
Log.d("liduo1", "$it")
}.onEach {
Log.d("liduo2", "$it")
}

task.collect()
}

    根据上面的分析,两个onEach方法会按顺序依次执行,打印出liduo1:2、liduo2:2、liduo1:3、liduo2:3。就是因为onEach方法会将上游的值继续向下游发送。


    同样的,还有下面这段代码:


launch(Dispatchers.Main) {
val task = flow {
emit(2)
emit(3)
}.onEach {
Log.d("liduo1", "$it")
}

task.collect {
Log.d("liduo2", "$it")
}
}

    这段代码也会打印出liduo1:2、liduo2:2、liduo1:3、liduo2:3。虽然使用了onEach方法,但也可以调用有参数的collect方法来对上游发送的数据进行最终的处理。


三.总结


2c00437e-9499-4ce8-9036-e94a9021dc89.png
    粉线为代码编写顺序,绿线为下游触发上游的调用顺序,红线为上游向下游发送值的调用顺序,蓝线为onEach方法实现的核心。


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

错的不是世界,是我

楔子阳春三月,摸鱼的好季节,我拿起水杯小抿了一口,还是那个甘甜的味道。怡宝,永远的神。这个公司虽然没人陪我说话,工作量也不饱和,但是只要它一天不换怡宝,我便一直誓死效忠这个公司。我的水杯是个小杯,这样每次便能迅速喝完水走去装水,提高装水频率,极大提升摸鱼时长,...
继续阅读 »

本人是95前端菜鸟一枚,目前在广州打工混口饭吃。刚好换了工作,感觉生活节奏变得慢了下来,打了这么多年工总觉得想纪录些什么,怕以后自己老了忘记自己还有这么一些风流往事。书接上回。

楔子

"咕咚,咕咚,咕咚",随着桶装水一个个气泡涌上来,我的水杯虽已装满,但摸鱼太久的我却似木头般木讷,溢出的水从杯口流了下来,弄湿了我的new balance。问:此处描写代表了作者什么心情(5分)

阳春三月,摸鱼的好季节,我拿起水杯小抿了一口,还是那个甘甜的味道。怡宝,永远的神。这个公司虽然没人陪我说话,工作量也不饱和,但是只要它一天不换怡宝,我便一直誓死效忠这个公司。我的水杯是个小杯,这样每次便能迅速喝完水走去装水,提高装水频率,极大提升摸鱼时长,我不自暗叹我真是一个大聪明。装水回到座位还没坐下,leader便带来一个新来的前端给每位组员介绍,我刚入职三个月,便又来了一位新人。leader瞥了我一眼,跟新人介绍说我也是刚入职的前端开发做题家,我看了新人一眼,脑海闪过几句诗词——眼明正似琉璃瓶,心荡秋水横波清面如凝脂,眼如点漆,她呆呆的看着我,我向她点头示意,说了声,你好。她的名字,叫作小薇小月。

天眼

"小饿,过来天若有情找我一下",钉钉弹出来一条消息,正是HRBP红姐发来的,我的心里咯噔了一下,我正在做核酸,跟她同步后她让我做完核酸找她。其实下楼做核酸的时候我看到跟我负责同个项目的队友被红姐拉去谈话,公司找我聊天,除了四年前技术leader莫名奇妙帮我加薪有兴趣可看往期文章,其他都没有发生过好事。我的心里其实已经隐隐约约知道了什么事情,一边做着核酸一边想着对策,一边惶恐一边又有几分惊喜,心里想着不会又要拿大礼包了吧,靠着拿大礼包发家致富不是梦啊

来到天若有情会议室,我收拾了一下心情,走了进去,"坐吧"。红姐冷冷的说了一声,我腿一软便坐了下来。"知道我找你是什么原因吗?"红姐率先发问,"公司是要裁员吗?"我直球回击。红姐有点出乎意料笑了一下,"啪"的一声,很快,我大意了,没有闪。一堆文件直接拍到了桌面,就犹如拍在我的脸上。"这是你上个月的离开工作位置时长,你自己核对一下,签个名"。

我震惊。没想到对面一上来就放大。我自己的情况我是知道的,早上拉个屎,下午喝杯茶,悠然混一日。加上嘘嘘偶尔做做核酸每天离开工作岗位大约2个小时左右。"为什么呀,你入职的时候表现不是这样子的呀,为什么会变成这样呢?是被带坏了吗?没有需求吗?还是个人原因?"。既然你诚心诚意发问,那我就大发慈悲告诉你吧。

为什么呢?

从入职新公司后,我的心感觉就不属于这里,公司指派的任务都有尽心尽责完成,但是来了公司大半年,做了一个项目上线后没落地便夭折,另外一个项目做了一半被公司业务投诉也立刻中断,我没有产出,公司当我太子一样供着。自己从上家公司拿了大礼包后,机缘巧合又能快速进入新的公司,其实自己是有点膨胀的,到了新公司完成任务空闲时便会到掘金写写小说,晚上回家杀杀狼人。有时动一下腰椎,也会传来噼里啪啦的声响,似乎提醒我该去走走了。不过不学无术,游手好闲,的确是我自己的问题。每天摸两小时,资本家看了也会流泪。当然,这些都是马后炮自己事后总结的

"是我个人原因"。虽说极大部分归于没有需求做,但是没有需求做也不代表着能去摸鱼,而且更不能害了leader,我心里明白,这次是我错了。太子被废了。我在"犯罪记录"上面签了字,问了句如何处理,回复我说看上面安排。我出来后发现有两个同事也来询问我情况,我也一五一十说了,发现大家都是相同问题。我默默上百度查了下摸鱼被裁的话题,发现之前tx也有过一次案例,虽然前两次都是败诉,最后又胜诉了。

我晚上回去躺在床上翻来覆去睡不着觉,心中似乎知道结局,避无可避,但错在我身,这次的事件就当做是一个教训,我能接受,挨打立正。闲来无事打开BOSS刷了又刷,岗位寥寥无几,打开脉脉,第一条便是广州找工作怎么这么难啊,下面跟着一大群脉友互相抱团取暖,互相安慰,在寒冬下,大家都知道不容易,大家都互相鼓励,互相给出希望,希望就像一道道暖风,吹走压在骆驼身上的稻草,让我们在时间的流逝下找到花明

第二天,红姐让我去江湖再见会议室。"其实是个坏消息啦,X总容忍不了,这是离职协议,签一下吧"。我看了厚厚的离职协议,默不作声,"签个人原因离职后背调也可以来找我,我这边来协助安排"。弦外之音声声割心,但其实我心里也明白,我也没有底气,不如利索点出来后看看能不能尽快找个工作。

晚宴

leader知道我们几个明天last day后,拉个小群请我们吃饭。也是在这次宴席中,leader透露出他也会跟着我们一起走,我大为吃惊,随后leader便娓娓道来,我知道了很多不为人知的秘密。这次总共走了四个人,都是前端,其中涉及了帮派的斗争,而我们也成为斗争中的牺牲品。我一边听着leader诉说公司的前尘往事,一边给各位小伙伴倒茶,心里也明白,就算内斗,如果自己本身没有犯错,没有被抓到把柄,其实也不会惹祸上身。leader也跟我说因为他个人原因太忙没有分配给我适合的工作量,导致我的确太闲,也让我给简历给他帮忙内推各种大厂,我心里十分感激。

期间有位小伙伴拍着我肩膀说,"我知道你是个很好的写手,但是这些东西最好不要写出来"。我一愣,他接着说,之前有位前端老员工识别到是你的文章,发出来了。凉了,怪不得我变成砧板的鱼肉,原来我的太子爽文都有可能传到老板手里了。我突然心里一惊,问了一句不会是因为的Best 30 年中总结征文大赛才导致大家今晚这场盛宴吧?leader罢了罢手,说我想多了。我也万万想不到,我的杰作被流传出去,可能点赞的人里面都藏着CEO。就怕太子爽文帮我拿到了电热锅,却把我饭碗给弄丢了。不过我相信,上帝为你关上一扇门,会为你打开一扇窗。

不过掘金的奖牌着实漂亮,谢谢大家的点赞,基层程序员一个,写的文章让大家有所动容,有所共鸣,实乃吾之大幸。


天窗

自愿离职后的我开始准备简历,准备复习资料,同时老东家也传来裁员消息。心里不禁感叹,老东家这两次裁员名单,都有我的名字。我刷了下boss,投了一份简历,便准备面试题去了,因为我觉得我的简历很能打,但是面试的机会不多,每一次面试都是一个黄金机会,不能再像上次一样错过。当天一整天都很down,朋友约出来玩,我也拒绝了,但是朋友边邀请边骂边安慰我,我想了一下就当放松一下了,于是便出去浪了一天。第二天睡醒发现两个未接来电,回拨过去后是我投递简历的公司打来的,虽然我没有看什么面试题,但是好在狼人杀玩的够多,面对着几位面试官夸夸其谈,聊东南西北,最终也成功拿下offer。虽然offer一般,但在这个行情下,我一心求稳,便同意入职,所以也相当于无缝衔接。对这位朋友也心怀感激,上次也是他的鼓励,让我走出心中的灰暗,这次也是让我在沮丧中不迷失自我。那天我玩的很开心,让我明白工作没了可以再找,错误犯了可以改回来,但人一旦没了信心迷失方向,便容易坠入深渊。

THE END

其实很多人都跟我说,互联网公司只要结果,这次其实我没犯啥毛病,大家都会去摸鱼。我经过几天思考我也明白,不过,有时候真要从自己身上找下原因,知道问题根本所在,避免日后无论是在工作还是生活中,都能避免在同一个地方再次跌倒。其实大多时候,错的不是世界,而是我

过了几天,leader请了前端组吃一顿他的散伙饭,因为他交接比较多,所以他走的比较晚。菜式十分丰富,其中有道羊排深得我心,肥而不腻,口有余香,难以言喻。小月坐在我的隔壁,在一块羊排上用海南青金桔压榨滴了几滴,拍了拍我的肩膀,让我试一下。我将这块羊排放入口中,金桔的微酸带苦,孜然的点点辛辣,羊排本身浓郁的甜味,原来,这就是人生啊。仔细品尝后,我对小月点了点头,说了声谢谢。


作者:很饿的男朋友
来源:juejin.cn/post/7138117808516235300

收起阅读 »

uniapp使用canvas实现二维码分享

实现使用canvas在小程序H5页面进行二维码分享 如下图效果 可以保存并扫码总体思路:使用canvas进行绘制,为了节省时间固定部分采用背景图绘制 只有二维码以及展示图片及标题绘制,绘制完成后调用uni.canvasToTempFilePath将其转为图片展...
继续阅读 »

实现使用canvas在小程序H5页面进行二维码分享 如下图效果 可以保存并扫码


总体思路:使用canvas进行绘制,为了节省时间固定部分采用背景图绘制 只有二维码以及展示图片及标题绘制,绘制完成后调用uni.canvasToTempFilePath将其转为图片展示

1.组件调用,使用ref调用组件内部相应的canvas绘制方法,传入相关参数 包括名称 路由 展示图片等。

 <SharePoster v-if='showposter' ref='poster' @close='close'/>

<script>
 import SharePoster from "@/components/share/shareposter.vue"
 export default {
   components: {
      SharePoster,
  },
  methods:{
      handleShare(item){
         this.showposter=true
         if(this.showvote){
           this.showvote=false
        }
         this.$nextTick(() => {
        this.$refs.poster.drawposter(item.name, `/pagesMore/voluntary/video/player?schoolId=${item.id}`,item.cover)
        })
      },
  }
</script>

2.组件模板放置canvas容器并赋予id以及宽度高度等,使用iscomplete控制是显示canvas还是显示最后调用uni.canvasToTempFilePath生成的图片

<div class="poster-wrapper" @click="closePoster($event)">
     <div class='poster-content'>
         <canvas canvas-id="qrcode"
           v-if="qrShow"
          :style="{opacity: 0, position: 'absolute', top: '-1000px'}"
         ></canvas>
         <canvas
           canvas-id="poster"
          :style="{ width: cansWidth + 'px', height: cansHeight + 'px' ,opacity: 0, }"
           v-if='!iscomplete'
         ></canvas>
         <image
           v-if="iscomplete"
          :style="{ width: cansWidth + 'px', height: cansHeight + 'px' }"
          :src="tempFilePath"
           @longpress="longpress"
         ></image>
     </div>
 </div>

3.data内放置相应配置参数

 data() {
     return {
         bgImg:'https://cdn.img.up678.com/ueditor/upload/image/20211130/1638258070231028289.png', //画布背景图片
         cansWidth:288, // 画布宽度
         cansHeight:410, // 画布高度
         projectImgWidth:223, // 中间展示图片宽度
         projectImgHeight:167, // 中间展示图片高度
         qrShow:true, // 二维码canvas
         qrData: null, // 二维码数据
         tempFilePath:'',// 生成图路径
         iscomplete:false, // 是否生成图片
    }
  },

4.在created生命周期内调用uni.createCanvasContext创建canvas实例 传入模板内canvas容器id

created(){
     this.ctx = uni.createCanvasContext('poster',this)
  },

5.调用对应方法,绘制分享作品

   // 绘制分享作品
     async drawposter(name='重庆最美高校景象',url,projectImg){
          uni.showLoading({
            title: "加载中...",
            mask: true
          })
          // 生成二维码
         await this.createQrcode(url)
         // 背景
         await this.drawWebImg({
           url: this.bgImg,
           x: 0, y: 0, width: this.cansWidth, height: this.cansHeight
        })
         // 展示图
         await this.drawWebImg({
           url: projectImg,
           x: 33, y: 90, width: this.projectImgWidth, height: this.projectImgHeight
        })
         await this.drawText({
           text: name,
           x: 15, y: 285, color: '#241D4A', size: 15, bold: true, center: true,
           shadowObj: {x: '0', y: '4', z: '4', color: 'rgba(173,77,0,0.22)'}
        })
         // 绘制二维码
         await this.drawQrcode()
         //转为图片
         this.tempFilePath = await this.saveCans()
         this.iscomplete = true
         uni.hideLoading()
    },

6.绘制图片方法,注意 this.ctx.drawImage方法第一个参数不能放网络图片 必须执行下载后绘制

  drawWebImg(conf) {
       return new Promise((resolve, reject) => {
         uni.downloadFile({
           url: conf.url,
           success: (res) => {
             this.ctx.drawImage(res.tempFilePath, conf.x, conf.y, conf.width?conf.width:"", conf.height?conf.height:"")
             this.ctx.draw(true, () => {
               resolve()
            })
          },
           fail: err => {
             reject(err)
          }
        })
      })
    },

7.绘制文本标题

 drawText(conf) {
       return new Promise((resolve, reject) => {
         this.ctx.restore()
         this.ctx.setFillStyle(conf.color)
         if(conf.bold) this.ctx.font = `normal bold ${conf.size}px sans-serif`
         this.ctx.setFontSize(conf.size)
         if(conf.shadowObj) {
           // this.ctx.shadowOffsetX = conf.shadowObj.x
           // this.ctx.shadowOffsetY = conf.shadowObj.y
           // this.ctx.shadowOffsetZ = conf.shadowObj.z
           // this.ctx.shadowColor = conf.shadowObj.color
        }
         let x = conf.x
         conf.text=this.fittingString(this.ctx,conf.text,280)
         if(conf.center) {
           let len = this.ctx.measureText(conf.text)
           x = this.cansWidth / 2 - len.width / 2 + 2
        }

         this.ctx.fillText(conf.text, x, conf.y)
         this.ctx.draw(true, () => {
           this.ctx.save()
           resolve()
        })
      })
    },
// 文本标题溢出隐藏处理
fittingString(_ctx, str, maxWidth) {
           let strWidth = _ctx.measureText(str).width;
           const ellipsis = '…';
           const ellipsisWidth = _ctx.measureText(ellipsis).width;
           if (strWidth <= maxWidth || maxWidth <= ellipsisWidth) {
             return str;
          } else {
             var len = str.length;
             while (strWidth >= maxWidth - ellipsisWidth && len-- > 0) {
               str = str.slice(0, len);
               strWidth = _ctx.measureText(str).width;
            }
             return str + ellipsis;
          }
        },

8.生成二维码

      createQrcode(qrcodeUrl) {
       // console.log(window.location.origin)
       const config={host:window.location.origin}
       return new Promise((resolve, reject) => {
         let url = `${config.host}${qrcodeUrl}`
         // if(url.indexOf('?') === -1) url = url + '?sh=1'
         // else url = url + '&sh=1'
         try{
           new qrCode({
             canvasId: 'qrcode',
             usingComponents: true,
             context: this,
             // correctLevel: 3,
             text: url,
             size: 130,
             cbResult: (res) => {
               this.qrShow = false
               this.qrData = res
               resolve()
            }
          })
        } catch (err) {
           reject(err)
        }
      })
    },

9.画二维码,this.qrData为生成的二维码资源

  drawQrcode(conf = { x: 185, y: 335, width: 100, height: 50}) {
return new Promise((resolve, reject) => {
this.ctx.drawImage(this.qrData, conf.x, conf.y, conf.width, conf.height)
this.ctx.draw(true, () => {
resolve()
})
})
},

10.将canvas绘制内容转为图片并显示,在H5平台下,tempFilePath 为 base64

// canvs => images
saveCans() {
return new Promise((resolve, reject) => {
uni.canvasToTempFilePath({
x:0,
y:0,
canvasId: 'poster',
success: (res) => {
resolve(res.tempFilePath)
},
fail: (err) => {
uni.hideLoading()
reject(err)
}
}, this)
})
},

11.组件全部代码


作者:ArvinC
来源:juejin.cn/post/7041087990222815246

收起阅读 »

人生中的第一次被辞退

2022年8月26日下午5点半得到的通知,有10天的缓冲但没有补偿,理由是没有没有过试用期,离试用期还有10天。 一、咋进的公司? 公司与甲方签的一个单子快到时间了公司没人写,没怎么面试问了我以前写的项目就让我通过了,工资是不打折的。 二、进公司干了啥? 目前...
继续阅读 »

2022年8月26日下午5点半得到的通知,有10天的缓冲但没有补偿,理由是没有没有过试用期,离试用期还有10天。

一、咋进的公司?

公司与甲方签的一个单子快到时间了公司没人写,没怎么面试问了我以前写的项目就让我通过了,工资是不打折的。

二、进公司干了啥?

目前是80天,30天开发后台管理(81张设计稿,60个接口,vue写的),10天修改后台管理第二版,后面40天就是噩梦了,维护前后端不分离的和前后端分离的jq。(时间只是大概,具体不记得了)


三、辞退原因

公司给的原因:维护开发效率太低。

个人认为的原因:

1、之前没接触jq(进来前没说用jq和要维护前后端不分离的项目)。之前那哥们是一毕业就在这家公司写了两年半jq工资没加第二年还降了,与之相比我这之前没接触jq项目的,我开的工资比他还高但我维护的效率比他低太多了。

2、状态不好。我加了上个前端的微信,他当时走了2个月但他现在还没找到工作在家学习vue,我离职了他都没找到工作,这jq我越写越焦虑,我怕有一天忘记vue、react、uniapp就只会jq,简历上全是jq项目我下份工作怎么找。在这种焦虑中工作不在状态想离职但又怕找不到工作,有点摆烂。

四、感受

1、失落。居然被这样一份工作辞退,开始怀疑自己能不能干这行业,自己怕不是个垃圾(虽然确实是菜狗...)。

2、担忧。目前了解到的找工作的前端,一个找了5个月在家学vue的(这公司上个前端),一个找了6个月的,一个找了2个月但找了比较好的工作,我丫的不会也找几个月吧(看来要练习一下捡瓶子,防止饿死)。

3、解脱、丫的,终于10天后不用维护这些垃圾代码了,焯!!!爽!!!

五、有什么打算?

1、先到杭州见一下老朋友,当然也可能约不出来(尴尬),顺便去面试。
2、回老家一趟,两年没回去想家了。
3、去深圳那个唯一叫我靓仔的地方,之后可能就饿死在那。


作者:张二河
链接:https://juejin.cn/post/7136214855777779749

收起阅读 »

是时候改变了,日本政府决定将停止使用软盘和光盘

什么?日本要向软盘宣战了?该国数字大臣河野太郎在推特上公开表示:日本政府有太多业务都需要人们通过软盘、CD等老设备来提交表格和申请了,数量高达1900个!现在,他们要更改规定,弃用软盘,让大家进行在线提交!我没看错吧?2022年了,软盘这种东西早就成为了时代的...
继续阅读 »

什么?日本要向软盘宣战了?

该国数字大臣河野太郎在推特上公开表示:

日本政府有太多业务都需要人们通过软盘、CD等老设备来提交表格和申请了,数量高达1900个!

现在,他们要更改规定,弃用软盘,让大家进行在线提交!


我没看错吧?

2022年了,软盘这种东西早就成为了时代的眼泪。

怎么日本——

一个堂堂的发达国家,以电子产业、机器人技术乃至赛博文化等标签闻名,还在用这东西?


软盘居然还在日本活着

是的,你没看错。

在日本政府,目前还有需要各方商业伙伴用软盘、CD等老式存储介质来传输数据。

最近日本也确实发生了几起软盘丢失的事件,侧面证明此事一点不假。

比如去年12月27日,日本警视厅对外承认:他们丢失了38位公民的个人数据。

这些公民申请了东京都下辖目黑区的公共住房,政府需要与警方确认申请人中是否与犯罪集团有关联。

在调查中,他们就是靠软盘传送申请人数据。

谁知软盘不慎丢失,申请人的个人信息也没了。

此事一出,全球网友都看傻了,有人甚至怀疑这是假新闻。

当然一些日本网友也表示很震惊,没想到自己国家的政务机构还在用这种老古董。


除了政府,软盘也被银行体系大量使用。

日经新闻去年的一篇消息就指出,仅山形银行,一个月内就有1000多家客户在使用软盘传输职工的工资数据。


当然,这些客户中,还是以政府和中小企业居多,尤其是政府。

而在几天前,日本的一个组织对300名15至29岁的人群做了一个小调查——

结果发现还是有近20%的年轻人用过软盘,没错,真的在「用」,相比下,中国00后们认识软盘的都不多…


事实上,日本人与软盘还有些渊源。

该产品1971年就诞生了。当时它还有足足32寸,因携带不方便,被IBM改到了8寸。

真正将其发扬光大的正是日本名企,索尼。

1981年,索尼首次推出了经典款3.5英寸盘,后被广泛生产使用,1984年苹果那款知名的MAC,就带有3.5英寸软盘的驱动。


到90年代,软盘盛极一时,在1996年就有50亿张软盘被使用。

但很快,内存只有1.44MB、容易损坏的软盘被更大容量、更可靠的产品(比如U盘等)迅速取代。

2011年,索尼就已停止生产这种风靡一时的产品。

如今,十一年过去,日本社会对软盘依赖还这么高,或许索尼高层也没想到(手动狗头)。

可是,为什么软盘内存小、效率低,还是不摒弃?

究其原因,有当地网友认为,是因为软盘更安全。

它的存储空间很小,大多病毒都不足以容身,也不用担心网络攻击。


当然,也有更清奇的思路表示——

正因为它被用得少,即便被捡到了,对方也难以找到专门的读取设备。那么最终只能落回政府和机构手里。

但更为重要的也许是,很多使用者们自己不愿改变习惯。

日本官僚群体在软盘使用上一直比较坚持。某负责公共资金管理的政府工作者就曾对日经新闻一再强调软盘的可靠性,称它「几乎从未损坏和丢失数据」。

不光政府官僚。也有金融行业从事技术的工程师指出,他们20年前就在劝说客户改换存储媒介,但怎么说都劝不动。

对于银行、政府等服务机构来说,若有客户坚持认为邮寄软盘比网络传输更安全,作为服务方,他们也只能「向下兼容」,保留相应设备。

由此,也就造成软盘在日本“苟活”到今天。


其实……也早想抛弃

当然,还是有人考虑到现实因素,提出弃用软盘。

比如软盘读取设备停产导致无法处理数据的问题。

已有银行觉得专门的读取和归还软盘的费用很高,实在不想忍了,决定在承接软盘相关业务时收取每月5万日元 (约合2477元人民币)费用。

有些银行则已经开始摒弃软盘业务,将数据转移到其他在线存储格式。

也有一些政府机构也开始这种数字化转型,不过它们可能要到2026年才全面停止使用。

这回,雷厉风行的数字化大臣河野太郎上任后,就一直在公开场合敦促同僚们「走进发达社会」——

终于在这两天在社交网站正式用「宣战」一词,开启全面的抛弃软盘行动。

根据已披露的信息,数字厅将推动更多行政程序以在线方式完成,而非用邮寄软盘、CD、U盘等方式传数据。此外,他们还会敦促各部门机构自我审查,计划在年底发布更具体的政策。


那么,对河野太郎的宣战,各方反馈如何?

拍手称快的日本网友不少,还有一位在日本工作的印度人表示:

顺便也管管日本银行的工作效率吧。

比如你们那个瑞穗(日本三大行之一),每次办丁点儿业务都要去线下分行操作。在我们印度都不这样了,线上搞定一切!


另一方面,“唱衰”的声音仍然存在。

就比如有位朋友就说了:

谢谢您老的建议。

2022:软盘战 ;2052:网络安全打击战

你就等着瞧吧~


再去日网上一看,这种声音还并非个例。


关于河野太郎为什么会遇到较大阻力,身在中国的我们可能难以理解——

一位留学日本,并对该国文化有长期观察的媒体朋友分享了她的看法:

日本是全球第一个进入超高龄社会的国家,老年人又是最积极的投票群体,因此从对选票负责的角度来讲,日本政府需要尽可能地维持老年人所熟悉的社会,而不是迅速改变它。

并且,日本政府系统身居高位的人,绝大多数都是老年人。很多大臣连电脑都用不明白,更别说意识到数字化的重要性了。

翻盖手机等老古董同样流行

其实不止软盘,日本在IT方面总体都比较「怀旧」,或者说保守。

比如一些中小企业和政府机关还在用传真而不是E-mail发文件。

有人进行电商发货时,还要在快递里的清单上盖上每个负责人的印章。

由此,也诞生了一个名为“昭和三大遗物”的戏称,指的就是这俩玩意以及前文所说的软盘。

(嗯,昭和时代可是1926年-1989年了。)

此外,显示器习惯用古老的VGA接口,笔记本坚持用网线接口上网……这种情况在日本也不算稀罕。


还有,一些国际公司的网站一到日本就会改成上个世纪的设计。

比如当地重要门户雅虎,完全是00年代风:


B站日本兄弟站niconico,也还是有种多年前味道:


除了上面这些,还不得不提另一个在日本仍然焕发生命的老古董——

翻盖手机。

在咱们国家,大部分老年人基本都不用这种产品,而在该国,市场上每年都还有好几款新翻盖机上市,比如夏普、日本电信公司KDDI这些公司就在出。

(不过日本的翻盖机在智能化上确实做的很好。

和国内的老年机/功能机不同,它们在2003年刚普及的时候,就被陆续附上了wifi、扫码、移动支付、视频等功能。

现在更是早就普及了安卓系统,一些常用App都可以安装。)


曾在日留学的95后同事也分享了她的奇葩见闻——

在其就读的学校,40岁以上的日本老师都不用智能手机,都是翻盖手机挂脖子上,他们自己还觉得挺方便。

此外,学校办公方面也相对「原始」,当时都2017年了,还没有电子选课系统,而是纸质申请选课。

她还特别强调了支付方式方面,「他们一直在用硬币这种东西…就知道他们有多不怕麻烦了…」

……

看完上述现象,你是不是能理解日本人为什么不愿放弃软盘了……

最后,你觉得日本向软盘「宣战」能成功么?

来源:丰色 詹士 发自 凹非寺

收起阅读 »

这一次,放下axios,使用基于rxjs的响应式HTTP客户端

web
众所周知,在浏览器端和 Node.js 端使用最广泛的 HTTP 客户端为 axios 。想必大家都对它很熟悉,它是一个用于浏览器和 Node.js 的、基于 Promise 的 HTTP 客户端,但这次的主角不是它。起源axios 的前身其实是 Angula...
继续阅读 »

众所周知,在浏览器端和 Node.js 端使用最广泛的 HTTP 客户端为 axios 。想必大家都对它很熟悉,它是一个用于浏览器和 Node.js 的、基于 Promise 的 HTTP 客户端,但这次的主角不是它。

起源

axios 的前身其实是 AngularJS$http 服务。

为了避免混淆,这里需要澄清一下:AngularJS 并不等于 AngularAngularJS 是特指 angular.js v1.x 版本,而 Angular 特指 angular v2+ (没有 .js)和其包含的一系列工具链。

这样说可能不太严谨,但 axios 深受 AngularJS 中提供的$http 服务的启发。归根结底,axios 是为了提供一个类似独立的服务,以便在 AngularJS 之外使用。

发展

但在 Angular 中,却没有继续沿用之前的 $http 服务,而是选择与 rxjs 深度结合,设计出了一个比 $http 服务更先进的、现代化的,响应式的 HTTP 客户端。 在这个响应式的 HTTP Client 中,发送请求后接收到的不再是一个 Promise ,而是来自 rxjsObservable,我们可以订阅它,从而侦听到请求的响应:

const observable = http.get('url');
observable.subscribe(o => console.log(o));

有关它的基本形态及详细用法,请参考官方文档

正文

@ngify/http 是一个形如 Angular HttpClient 的响应式 HTTP 客户端。@ngify/http的目标与 axios 相似:提供一个类似独立的服务,以便在 Angular 之外使用。

@ngify/http 提供了以下主要功能:

先决条件

在使用 @ngify/http 之前,您应该对以下内容有基本的了解:

  • JavaScript / TypeScript 编程。

  • HTTP 协议的用法。

  • RxJS Observable 相关技术和操作符。请参阅 Observables 指南。

API

有关完整的 API 定义,请访问 ngify.github.io/ngify.

可靠性

@ngify/http 使用且通过了 Angular HttpClient 的单元测试(测试代码根据 API 的细微差异做出了相应的更改)。

安装

npm i @ngify/http

基本用法

import { HttpClientHttpContextHttpContextTokenHttpHeadersHttpParams } from '@ngify/http';
import { filter } from 'rxjs';

const http = new HttpClient();

http.get<code: number, data: any, msg: string }>('url''k=v').pipe(
 filter(({ code }) => code === 0)
).subscribe(res => console.log(res));

http.post('url', { k'v' }).subscribe(res => console.log(res));

const HTTP_CACHE_TOKEN = new HttpContextToken(() => 1800000);

http.put('url'null, {
 contextnew HttpContext().set(HTTP_CACHE_TOKEN)
}).subscribe(res => console.log(res));

http.patch('url'null, {
 params: { k'v' }
}).subscribe(res => console.log(res));

http.delete('url'new HttpParams('k=v'), {
 headersnew HttpHeaders({ Authorization'token' })
}).subscribe(res => console.log(res));

拦截请求和响应

借助拦截机制,你可以声明一些拦截器,它们可以检查并转换从应用中发给服务器的 HTTP 请求。这些拦截器还可以在返回应用的途中检查和转换来自服务器的响应。多个拦截器构成了请求/响应处理器的双向链表。

@ngify/http 会按照您提供拦截器的顺序应用它们。

import { HttpClientHttpHandlerHttpRequestHttpEventHttpInterceptorHttpEventType } from '@ngify/http';
import { Observabletap } from 'rxjs';

const http = new HttpClient([
 new class implements HttpInterceptor {
   intercept(requestHttpRequest<unknown>nextHttpHandler): Observable<HttpEvent<unknown>> {
     // 克隆请求以修改请求参数
     request = request.clone({
       headersrequest.headers.set('Authorization''token')
    });

     return next.handle(request);
  }
},
{
   intercept(requestHttpRequest<unknown>nextHttpHandler) {
     request = request.clone({
       paramsrequest.params.set('k''v')
    });

     console.log('拦截后的请求'request);

     return next.handle(request).pipe(
       tap(response => {
         if (response.type === HttpEventType.Response) {
           console.log('拦截后的响应'response);
        }
      })
    );
  }
}
]);

虽然拦截器有能力改变请求和响应,但 HttpRequestHttpResponse 实例的属性是只读的,因此让它们基本上是不可变的。

有充足的理由把它们做成不可变对象:应用可能会重试发送很多次请求之后才能成功,这就意味着这个拦截器链表可能会多次重复处理同一个请求。 如果拦截器可以修改原始的请求对象,那么重试阶段的操作就会从修改过的请求开始,而不是原始请求。 而这种不可变性,可以确保这些拦截器在每次重试时看到的都是同样的原始请求。

如果你需要修改一个请求,请先将它克隆一份,修改这个克隆体后再把它传递给 next.handle()

替换 HTTP 请求类

@ngify/http 内置了以下 HTTP 请求类:

HTTP 请求类描述
HttpXhrBackend使用 XMLHttpRequest 进行 HTTP 请求
HttpFetchBackend使用 Fetch API 进行 HTTP 请求
HttpWxBackend微信小程序 中进行 HTTP 请求

默认使用 HttpXhrBackend,可以通过修改配置切换到其他的 HTTP 请求类:

import { HttpFetchBackendHttpWxBackendsetupConfig } from '@ngify/http';

setupConfig({
 backendnew HttpFetchBackend()
});

你还可使用自定义的 HttpBackend 实现类:

import { HttpBackendHttpClientHttpRequestHttpEventsetupConfig } from '@ngify/http';
import { Observable } from 'rxjs';

// 需要实现 HttpBackend 接口
class CustomHttpBackend implements HttpBackend {
 handle(request: HttpRequest<any>): Observable<HttpEvent<any>> {
   // ...
}
}

setupConfig({
 backendnew CustomHttpBackend()
});

如果需要为某个 HttpClient 单独配置 HttpBackend,可以在 HttpClient 构造方法中传入:

const http = new HttpClient(new CustomHttpBackend());

// 或者

const http = new HttpClient({
 interceptors: [/* 一些拦截器 */],
 backendnew CustomHttpBackend()
});

在 Node.js 中使用

@ngify/http 默认使用浏览器实现的 XMLHttpRequestFetch API。要在 Node.js 中使用,您需要进行以下步骤:

XMLHttpRequest

如果需要在 Node.js 环境下使用 XMLHttpRequest,可以使用 xhr2,它在 Node.js API 上实现了 W3C XMLHttpRequest 规范。
要使用 xhr2 ,您需要创建一个返回 XMLHttpRequest 实例的工厂函数,并将其作为参数传递给 HttpXhrBackend 构造函数:

import { HttpXhrBackendsetupConfig } from '@ngify/http';
import * as xhr2 from 'xhr2';

setupConfig({
 backendnew HttpXhrBackend(() => new xhr2.XMLHttpRequest())
});

Fetch API

如果需要在 Node.js 环境下使用 Fetch API,可以使用 node-fetchabort-controller
要应用它们,您需要分别将它们添加到 Node.jsglobal

import fetch from 'node-fetch';
import AbortController from 'abort-controller';
import { HttpFetchBackend, HttpWxBackend, setupConfig } from '@ngify/http';

global.fetch = fetch;
global.AbortController = AbortController;

setupConfig({
backend: new HttpFetchBackend()
});

传递额外参数

为保持 API 的统一,需要借助 HttpContext 来传递一些额外参数。

Fetch API 额外参数

import { HttpContext, FETCH_TOKEN } from '@ngify/http';

// ...

// Fetch API 允许跨域请求
http.get('url', null, {
context: new HttpContext().set(FETCH_TOKEN, {
mode: 'cors',
// ...
})
});

微信小程序额外参数

import { HttpContextWX_UPLOAD_FILE_TOKENWX_DOWNLOAD_FILE_TOKENWX_REQUSET_TOKEN } from '@ngify/http';

// ...

// 微信小程序开启 HTTP2
http.get('url'null, {
 contextnew HttpContext().set(WX_REQUSET_TOKEN, {
   enableHttp2true,
})
});

// 微信小程序文件上传
http.post('url'null, {
 contextnew HttpContext().set(WX_UPLOAD_FILE_TOKEN, {
   filePath'filePath',
   fileName'fileName'
})
});

// 微信小程序文件下载
http.get('url'null, {
 contextnew HttpContext().set(WX_DOWNLOAD_FILE_TOKEN, {
   filePath'filePath'
})
});

更多

有关更多用法,请访问 angular.cn

作者:Sisyphus
来源:juejin.cn/post/7079724273929027597

收起阅读 »

第一波元宇宙公司发不出工资了

又要给元宇宙泼冷水了。影创科技公司大群也被创始人兼董事长孙立强制解散,连公司HR也加入讨薪队伍。讨薪员工纷纷申请仲裁。影创科技在VR圈内有一定的影响力和知名度。公开信息显示,影创曾获6轮融资,最近一轮融资是在2020年9月份。就在今年3月份,影创官网一篇报道中...
继续阅读 »

元宇宙已经成为了近几年的热门概念,但是投注这个概念之后,企业真的可以“一本万利”吗?答案显然是否定的。近期,便有元宇宙概念相关公司被曝存在拖欠工资等情况。所以就目前形势来看,元宇宙也许是一个不错的先进概念,但企业也需要谨慎投入,做好相应的发展规划。

又要给元宇宙泼冷水了。

近日,一家号称要成为“元宇宙时代的微软”的元宇宙公司影创科技被曝欠薪200多人,时间最长达半年,人均被拖欠10万元,社保公积金也断交。

影创科技公司大群也被创始人兼董事长孙立强制解散,连公司HR也加入讨薪队伍。讨薪员工纷纷申请仲裁。



在某职场社交平台上,有不少人反映影创科技拖欠工资,时间最早追溯到今年6月份。



影创科技在VR圈内有一定的影响力和知名度。

世界VR产业大会是中国以及全球虚拟现实产业专业展会,大会每年都会发布“中国VR50强”榜单。而在过去的三年影创都在榜,19年第13名,20年第9名,21年第27名。

公开信息显示,影创曾获6轮融资,最近一轮融资是在2020年9月份。就在今年3月份,影创官网一篇报道中还写道“无论是技术还是出货量,我们基本都可以排到全球第二,第一是Meta”,影创还豪言要成为元宇宙的“微软”。

外面看着发展还不错,怎么却被曝大规模、长时间欠薪,员工大量离职?

一、今年3月份开始停发工资,讨薪群近200人,而在职员工不到50人

“影创在正常情况下是每月15日发薪,但在去年10月份开始出现拖欠情况,只发基本工资,期间偶尔会在月末补齐”,一位已离职影创员工对三言财经表示。

这名员工离职前是影创的开发工程师,他称到今年3月份,工资就不发了,同时社保、公积金也停了。

所以从今年4月份开始,陆续有人离职。据上述员工介绍,公司最多的时候大约有480人,2022年3月已不到300人,目前仍在职的应该不到50人。

大部分员工都已经离职,但离职并不能结算工资。该员工称自己本来在年前就拿到了两个offer,但因为老板进行了一系列“稳定军心”的动作,自己感觉公司还有希望就留了下来。

但是没想到后续事情的发展并未能如他所想。这名员工提到一个小细节,当时劝他留下来的HR比他还早离开了,“挺讽刺的”。后来HR也加入到了讨薪行列。

该员工还透露,自己离职时只是有一张离职证明。而后来离职员工还签了一份离职协议。协议的主要内容就是公司承诺将于8月、9月份陆续付清所欠薪资。


不过该员工指出,即使签了上述协议,但还是有很多人未能获得薪资。

“约200名员工,欠薪2—6个月,平均欠薪10w左右不等。很多年前离职的也没有结清工资”,他指出为了讨薪这些离职员工自发组织了一个群,用来讨论仲裁以及讨薪方式,群成员近200人。

据上述员工透露,200人讨薪队伍里,已有近100人申请了劳动仲裁。仲裁时间为8月份、9月中旬不等,最快的据说已经走到法院程序。


三言财经拨打了影创官网的400热线及工商预留电话,均无法接通。笔者也试图拨打了影创创始人孙立的电话,但是无人接听。

种种迹象表明,影创似乎正经历着巨大危机。

二、影创为什么走到今天这步?元宇宙不行了?

据公开报道,影创科技创始人孙立曾在游戏行业创业多年,2014年以1.5亿卖掉游戏公司,转到虚拟现实VR行业。

从影创的官网我们能大概看出它的业务范围。其中影创产品为VR眼镜硬件产品;软件服务下的点云平台还在开发中;解决方案具体指混合现实(MR)在具体领域的软件方案;开放平台则是影创的VR操作系统。

在今年4月份的一个关于孙立的专访中,对于影创的商业模式有这样的描述:一是MR智能眼镜硬件和软件的整体解决方案,应用在教育、工业、医疗等多个领域,这部分占整体业务的20%-30%。

二是以操作系统的服务和授权费用为主,这部分占营收的70%。

在专访中,孙立还介绍,影创科技近三年的营收基本上实现了年复合增长率达到260%。2022年预期收入能达到5亿元左右,目前公司盈利整体上还在处于亏损的状态,但亏损的金额在逐年递减,大约在2024年实现正向盈利。

在今年的多篇报道中都强调,影创要做元宇宙的“微软”。大意就是将影创VR操作系统授权给厂商使用,自己充当类似手机中安卓,或者PC中Windows的角色。

单纯只看上述报道,影创可谓前路光明,想象无限。但在员工眼里,却是另一番光景。

上述爆料员工表示,公司一开始主要做AR眼镜,号称国内版的HoloLens。但是缺乏应用场景,也没有完整的配套系统,C端用户不买账,C端市场销量几乎为零。

三言财经在京东搜索“影创”,排名靠前的几款影创智能眼镜基本都没有任何评价。


而长期的研发投入都是AR,但是产出跟投资不成正比,不断推出的新品AR眼镜打不开市场,公司决定转型。

该员工还透露,在2020年之后,趁着元宇宙的热潮,公司往VR方向转型。因为没有产品积淀,所以先做了B端,为其他公司定制软硬件的开发。

具体来说就是为第三方公司定制全套VR设备的开发,包括系统和VR设备。

该员工表示合作的公司大概3个,而且有一个项目原计划金额在上千万,但是项目开发到一半却被放弃了,干脆把源代码都交给了客户,交付前连测试都没有,所以最终公司只收到了400万。

在这名员工看来,公司迟迟融不到资,市场上没有走出困境,入不敷出,暴雷是迟早的事情,只是走的很不体面。

提到难有融资,这位员工称可能与一位投资人有关。他表示这位投资人因为某些原因和公司打官司,闹得很不愉快,受此影响,融资变得更加困难。

三言财经查询发现,2021年影创确实与公司的一名股东有多起诉讼和仲裁案件,涉及财产保全、股东知情权纠纷、公司决议纠纷、民间借贷纠纷。

其中股东知情权纠纷的法律文书中提到,该股东曾从影创财务负责人处得知,影创经营业绩存在严重虚报的情况。


在民间借贷纠纷中,曾提到该股东是此前是影创的第二大股东,同时担任公司的董事、总裁。2018年下半年,公司出现资金困难,该股东将数十万资金借给公司发放员工工资。

三、一边是元宇宙裁员潮,一边是高薪招人,其实并不矛盾

事实上,自从Facebook改名Meta押注元宇宙后,元宇宙瞬间成了风口,这也难怪如此多的公司都爱蹭点元宇宙的热点。

而像影创这样公司,转型元宇宙更是顺理成章。在元宇宙的世界里,VR正是其中一个大的切入点。

据央视财经报道,自去年开始,VR虚拟现实行业进入了发展快车道。2021年,VR头戴式显示器的全球出货量达1095万台,突破年出货一千万台的行业拐点,今年一季度,VR头显保持热销,全球出货量同比增长了241.6%。

而在国内,VR行业的热度也正在逐渐提升。数据显示,2022年上半年,中国VR市场零售额突破8亿元,同比增长81%,

风口之下,这两年元宇宙人才吃香,受到追捧,人才流动也加快。在招聘平台,元宇宙研发总监的月薪甚至达到10万。


但今年元宇宙的老大哥Meta则开始缩减招聘、计划裁员。

今年5月,Meta曾宣布暂停某些部门的招聘。7月初,又有报道称Meta取消了硅谷总部的后勤服务外包合同,导致数百名工人下岗。

扎克伯格也表示,Meta下调了2022年工程师目标数量,从原先的10000名缩减到6000-7000,砍掉了超3000人的招聘计划。

此外还有报道称,Meta预计今年将最多裁员10%。

一边是裁员,一边是高薪招人,看似矛盾,其实不然。风口效应仍Meta这类的企业想要快速占领行业高地,但大环境不再允许这样的冒进方式。

像影创这样追逐风口的中小公司更是数不胜数,他们没有大公司的雄厚实力,有时候断臂求生看起来也像是生死大劫。

风口之下,大家都想冲一波,但是死掉的是绝大多数。大量曾因元宇宙股价暴涨的概念股迎来暴跌;NFT热潮退去,不少巨头退出;元宇宙炒房泡沫破裂……

在消费市场,线下VR店运营情况不容乐观,不少网友反映冲了会员,店却突然倒闭了。



目前,元宇宙还是在初级阶段,没有现象级的爆发应用,也不够普及,还是小众消费。

不过在风口中,人才却可能是最大的受益者。

上文中的那位爆料员工在离职前就找好了下家,他表示自己收到了圈内多家offer,最终选择一家相对稳定的。

对于元宇宙,他这样看:

元宇宙,最重要的是应用场景和需求。一个技术脱离了实际场景,不管噱头多高级,都是空中楼阁。

正面例子有很多,VR类游戏,这抓住了高端游戏玩家的需求。比如半条命alyx VR这款游戏的诞生,吸引了很多玩家,带动了VR头戴设备的销量。绝大多数购买VR头盔的都是因为这个游戏。

还有VR观影,这也是一部分需求。

类似的,爱奇艺的奇遇VR,做的挺好。PICO主打串联,可以畅玩SteamVR,这也是很好的方向。

但是不管怎么说,这些都是高端玩家的需求市场。对于普通用户来说,不管元宇宙怎么发展都跟他们没关系,除非元宇宙切实的解决了他的一些需求。毕竟在虚拟世界里拥有一个房,远比不上现实环境的一片瓦。

最后他透露终于发现了影创创始人、董事长孙立的踪迹。

原来孙立将于8月26日参加AWE Asia 世界XR产业博览会。

影创是参展商,且排在第一位。孙立也是此次博览会的重要嘉宾,博览会首日的活动中,孙立也是也第一演讲的厂商,排在他前面的是三位活动主办方的高管。


在讨薪群里,离职员工商量着直播弹幕讨薪。

有员工说道,“以前看农民工讨薪觉得很遥远,怎想自己也有那么一天”。

作者:丰收;来源公众号:三言财经(ID:sycaijing)

收起阅读 »

前端怎么样限制用户截图?

web
先了解初始需求是什么?是内容数据过于敏感,严禁泄漏。还是内容泄漏后,需要溯源追责。不同的需求需要的方案也不同。来看看就限制用户截图,有哪些脑洞?v站和某乎上的大佬给出了不少脑洞,我又加了点思路。这个方案是最基础,当前可只能阻拦一些小白用户。如果是浏览器,分分钟...
继续阅读 »

做后台系统,或者版权比较重视的项目时,产品经常会提出这样的需求:能不能禁止用户截图?有经验的开发不会直接拒绝产品,而是进行引导。

先了解初始需求是什么?是内容数据过于敏感,严禁泄漏。还是内容泄漏后,需要溯源追责。不同的需求需要的方案也不同。来看看就限制用户截图,有哪些脑洞?

有哪些脑洞

v站和某乎上的大佬给出了不少脑洞,我又加了点思路。

1.基础方案,阻止右键保存和拖拽。

这个方案是最基础,当前可只能阻拦一些小白用户。如果是浏览器,分分钟调出控制台,直接找到图片url。还可以直接ctrl+p,进入打印模式,直接保存下来再裁减。

2.失焦后加遮罩层

这个方案有点意思,看敏感信息时,必须鼠标点在某个按钮上,照片才完整显示。如果失去焦点图片显示不完整或者直接遮罩盖住。

3.高速动态马赛克

这个方案是可行的,并且在一些网站已经得到了应用,在视频或者图片上随机插像素点,动态跑来跑去,对客户来说,每一时刻屏幕上显示的都是完整的图像,靠用户的视觉残留看图或者视频。即时手机拍照也拍不完全。实际应用需要优化的点还是挺多的。比如用手机录像就可以看到完整内容,只是增加了截图成本。

下面是一个知乎上的方案效果。(原地址):


正经需求vs方案

其实限制用户截图这个方案本身就不合理,除非整个设备都是定制的,在软件上阉割截图功能。为了这个需求添加更复杂的功能对于一些安全性没那么高的需求来说,有点本末倒置了。

下面聊聊正经方案:

1.对于后台系统敏感数据或者图片,主要是担心泄漏出去,可以采用斜45度七彩水印,想要完全去掉几乎不可能,就是观感比较差。

2.对于图片版权,可以使用现在主流的盲水印,之前看过腾讯云提供的服务,当然成本比较高,如果版权需求较大,使用起来效果比较好。

3.视频方案,tiktok下载下来的时候会有一个水印跑来跑去,当然这个是经过处理过的视频,非原画,画质损耗也比较高。Netflix等视频网站采用的是服务端权限控制,走的视频流,每次播放下载加密视频,同时获得短期许可,得到许可后在本地解密并播放,一旦停止播放后许可失效。

总之,除了类似于Android提供的截图API等底层功能,其他的功能实现都不完美。即使是底层控制了,一样可以拍照录像,没有完美的方案。不过还是可以做的相对安全。


作者:正经程序员
来源:juejin.cn/post/7127829348689674253

收起阅读 »

大厂B端登录页,让我打开新思路了

web
登录页这个东西,因为感觉很简单,所以经常不被重视。但是登录页作为一个产品的门面,直接影响用户第一印象,又是非常重要的存在。最近研究了一下我电脑上那一堆桌面端的登录页,还真发现了一些之前没想清楚的门道来。\0. 不登录很多产品会提供部分功能给未登录账号使用。比较...
继续阅读 »


登录页这个东西,因为感觉很简单,所以经常不被重视。

但是登录页作为一个产品的门面,直接影响用户第一印象,又是非常重要的存在。

最近研究了一下我电脑上那一堆桌面端的登录页,还真发现了一些之前没想清楚的门道来。

\0. 不登录

很多产品会提供部分功能给未登录账号使用。

比较谨慎的,Zoom 会给一个直接加入会议的按钮:


极端一些的,会像 WPS 这样打开后直接进入,不需要登录页:


给未登录用户太多功能会影响注册用户占比,强制登录又会把使用门槛拉得太高,这个主要看产品定位吧。

接下来,咱们主要针对必须登录的情况来讲吧。

\1. 填写项

这有什么好说的,登录填写项不就是用户名/邮箱/手机号+密码吗?

没错,最典型的却是如此。例如百度网盘和钉钉:



但是我发现,有的产品会故意分两步让你填,这样就可以把注册和登录合并到一个步骤了(输入后看看注册过没,没有就走注册流程,有就走登录流程)。例如飞书和 Google:



还有的,甚至不把填写项放出来,非要你点击入口才行。例如微云和 CCtalk:



我个人是比较喜欢一打开就是填写项,一次填完的,不知道大家怎么看?

\2. 二维码

我发现把二维码放到右上角的方式蛮常见的。

例如钉钉就做得很好看:


飞书用高亮色做有点生硬,但也还行:


微云这个感觉中间突然被切了一角,有点奇怪:


\3. 登录方式

如果登录方式只有 2 种,tab 是最常用的切换方式。例如微云:


如果比较多,用图标在底部列出来是最常用的方式。例如腾讯会议和 Zoom:



但也有一些产品,可能比较纠结,两种方式混合一下。比如飞书:


但是记住一定要在图标下加文字说明,否则就会像 CCtalk 一样看不懂第一个图标是什么(悬停提示也没有):


\4. 注册与忘记密码

这两个按钮几乎所有登录页都需要,但又不是特别重要的信息。

一般两种布局最常见,一是将这两个按钮都放在输入框下面。例如微云和钉钉:



二是把忘记密码放在密码框里面,然后注册就放在右下角某个地方。例如 Zoom、腾讯会议:



也如果把输入邮箱/手机号和密码分成两步,就可以省略一个这两个入口,不过登录就得多一步操作了。例如飞书:


\5. 勾选项

登录页一般有两个勾选项,一个是自动登录、一个是同意协议条款的,大多默认不勾选。

一般都放到登录按钮的下面,虽然不符合操作顺序(先勾选了才能确定),但是排版好看些。例如飞书:


其实像微云这样把勾选项放到登录按钮上其实更加符合操作顺序,因为这是在登录之前要确认的内容:


Zoom 在底部写上登录即代表同意政策和条款,就省略一个勾选项了:


但谁都比不上百度网盘,它们干脆一个勾选项都没有,至今还不是好好的?


\6. 登录按钮

基本上登录页都少不了登录按钮,除非是像钉钉这样登录方式有限的:


有的产品会让登录按钮置灰,直至用户填写完成为止。例如飞书和 Zoom:



\7. 设置项

很多产品会在用户登录之前就提供设置项目,主要是网络设置和语言设计。

例如飞书就两个都给了(左下角),做得挺到位的:


Zoom 就没有提供,跟着我的系统语言用中文,这个思路页也能理解:



腾讯会议比较实诚,把整个设置面板的入口都放到登录页了,包括语言选项在内:



\8. Logo

大部分产品的登录页都会放上 logo,这个感觉是常识。例如腾讯会议、百度网盘:



但其实也有不少只写名字不放 logo 的。例如微云、飞书:



钉钉就比较奇特,既没有 logo 也没有名字,不去状态栏查看一下都不知道这是什么软件:


总结一下

登录页表面看上去简单,经常不受重视,但仔细这么对比下来,发现可变因素还真是挺多的。

不知道大家对于这个页面有什么困惑的地方,可以在评论区讨论一下。

作者:设计师ZoeYZ

来源:juejin.cn/post/7138631923068305422

收起阅读 »

实现一个简易的 npm install

现在写代码我们一般不会全部自己实现,更多是基于第三方的包来进行开发,这体现在目录上就是 src 和 node_modules 目录。src 和 node_modules(第三方包) 的比例不同项目不一样。运行时查找第三方包的方式也不一样:在 node 环境里面...
继续阅读 »

现在写代码我们一般不会全部自己实现,更多是基于第三方的包来进行开发,这体现在目录上就是 src 和 node_modules 目录。


src 和 node_modules(第三方包) 的比例不同项目不一样。

运行时查找第三方包的方式也不一样:

在 node 环境里面,运行时就支持 node_modules 的查找。所以只需要部署 src 部分,然后安装相关的依赖。


在浏览器环境里面不支持 node_modules,需要把它们打包成浏览器支持的形式。


跨端环境下,它是上面哪一种呢?

都不是,不同跨端引擎的实现会有不同,跨端引擎会实现 require,可以运行时查找模块(内置的和第三方的),但是不是 node 的查找方式,是自己的一套。


和 node 环境下的模块查找类似,但是目录结构不一样,所以需要自己实现 xxx install。

思路分析

npm 是有自己的 registry server 来支持 release 的包的下载,下载时是从 registry server 上下载。我们自己实现的话没必要实现这一套,直接用 git clone 从 gitlab 上下载源码即可。

依赖分析

要实现下载就要先确定哪些要下载,确定依赖的方式和打包工具不同:

打包工具通过 AST 分析文件内容确定依赖关系,进行打包

依赖安装工具通过用户声明的依赖文件 (package.json / bundle.json)来确定依赖关系,进行安装

这里我们把包的描述文件叫做 bundle.json,其中声明依赖的包

{
"name": "xxx",
"dependencies": {
"yyyy": "aaaa/bbbb#release/1111"
}
}

通过分析项目根目录的 bundle.json 作为入口,下载每一个依赖,分析 bundle.json,然后继续下载每一个依赖项,递归这个过程。这就是依赖分析的过程。


这样依赖分析的过程中进行包的下载,依赖分析结束,包的下载也就结束了。这是一种可行的思路。


但是这种思路存在问题,比如:版本冲突怎么办?循环依赖怎么办?


解决版本冲突


版本冲突是多个包依赖了同一个包,但是依赖的版本不同,这时候就要选择一个版本来安装,我们可以简单的把规则定为使用高版本的那个。


解决循环依赖


包之间是可能有循环依赖的(这也是为什么叫做依赖图,而不是依赖树),这种问题的解决方式就是记录下处理过的包,如果同个版本的包被分析过,那么久不再进行分析,直接拿缓存。


这种思路是解决循环依赖问题的通用思路。


我们解决了版本冲突和循环依赖的问题,还有没有别的问题?


版本冲突时会下载版本最高的包,但是这时候之前的低版本的包已经下载过了,那么就多了没必要的下载,能不能把这部分冗余下载去掉。


依赖分析和下载分离


多下载了一些低版本的包的原因是我们在依赖分析的过程中进行了下载,那么能不能依赖分析的时候只下载 bundle.json 来做分析,分析完确定了依赖图之后再去批量下载依赖?


从 gitlab 上只下载 bundle.json 这一个文件需要通过 ssh 协议来下载,略微复杂,我们可以用一种更简单的思路来实现:

git clone --depth=1 --branch=bb xxx

加上 --depth 以后 git clone 只会下载单个 commit,速度会很快,虽然比不上只下载 bundle.json,但是也是可用的(我试过下载全部 commit 要 20s 的时候,下载单个 commit 只要 1s)。


这样我们在依赖分析的时候只下载一个 commit 到临时目录,分析依赖、解决冲突,确定了依赖图之后,再去批量下载,这时候用 git clone 下载全部的 commit。最后要把临时目录删除。


这样,通过分离依赖分析和下载,我们去掉了没必要的一些低版本包的下载。下载速度会得到一些提升。


全局缓存


当本地有多个项目的时候,每个项目都是独立下载自己的依赖包的,这样对于一些公用的包会存在重复下载,解决方式是全局缓存。


分析完依赖进行下载每一个依赖包的时候,首先查找全局有没有这个包,如果有的话,直接复制过来,拉取下最新代码。如果没有的话,先下载到全局,然后复制到本地目录。


通过多了一层全局缓存,我们实现了跨项目的依赖包复用。


代码实现

为了思路更清晰,下面会写伪代码

依赖分析

依赖分析会递归处理 bundle.json,分析依赖并下载到临时目录,记录分析出的依赖。会解决版本冲突、循环依赖问题。

const allDeps = {};
function installDeps(projectDir) {
const bundleJsonPath = path.resolve(projectDir, 'bundle.json');
const bundleInfo = JSON.parse(fs.readFileSync(bundleJsonPath));

const bundleDeps = bundleInfo.dependencies;
for (let depName in bundleDeps) {
if(allDeps[depName]) {
if (allDeps[depName] 和 bundleDeps[depName] 分支和版本一样) {
continue;// 跳过安装
}
if (allDeps[depName] 和 bundleDeps[depName] 分支和版本不一样){
if (bundleDeps[depName] 版本 < allDeps[depName] 版本 ) {
continue;
} else {
// 记录下版本冲突
allDeps[depName].conflit = true;
}

}
}
childProcess.exec(`git clone --depth=1 ${临时目录/depName}`);
allDeps[depName] = {
name: depName
url: xxx
branch: xxx
version: xxx
}
installDeps(`${临时目录/depName}`);
}
}

下载

下载会基于上面分析出的 allDeps 批量下载依赖,首先下载到全局缓存目录,然后复制到本地。

function batchInstall(allDeps) {
allDeps.forEach(dep => {
const 全局目录 = path.resolve(os.homedir(), '.xxx');
if (全局目录/dep.name 存在) {
// 复制到本地
childProcess.exec(`cp 全局目录/dep.name 本地目录/dep.name`);
} else {
// 下载到全局
childProcess.exec(`git clone --depth=1 ${全局目录/dep.name}`);
// 复制到本地
childProcess.exec(`cp 全局目录/dep.name 本地目录/dep.name`);
}
});
}

这样,我们就完成了依赖的分析和下载,实现了全局缓存。


总结


我们首先梳理了不同环境(浏览器、node、跨端引擎)对于第三方包的处理方式不同,浏览器需要打包,node 是运行时查找,跨端引擎也是运行时查找,但是用自己实现的一套机制。


然后明确了打包工具确定依赖的方式是 AST 分析,而依赖下载工具则是基于包描述文件 bundl.json(package.json) 来分析。然后我们实现了递归的依赖分析,解决了版本冲突、循环依赖问题。


为了减少没必要的下载,我们做了依赖分析和下载的分离,依赖分析阶段只下载单个 commit,后续批量下载的时候才全部下载。下载方式没有实现 registry 的那套,而是直接从 gitlab 来 git clone。


为了避免多个项目的公共依赖的重复下载,我们实现了全局缓存,先下载到全局目录,然后再复制到本地。



作者:zxg_神说要有光
链接:https://juejin.cn/post/6963855043174858759


收起阅读 »

如果你一层一层一层地剥开洋葱模型,你会明白

关于洋葱模型你知道多少?经过短时间接触NodeJS,浅浅地了解了NodeJS的相关知识,很多不太理解,但是对于洋葱模型,个人觉得挺有意思的,不仅是出于对名字的熟悉。刚接触NodeJS不久,今天就浅浅谈谈koa里的洋葱模型吧。koa是一个精简的Node框架,被认...
继续阅读 »

关于洋葱模型你知道多少?经过短时间接触NodeJS,浅浅地了解了NodeJS的相关知识,很多不太理解,但是对于洋葱模型,个人觉得挺有意思的,不仅是出于对名字的熟悉。刚接触NodeJS不久,今天就浅浅谈谈koa里的洋葱模型吧。

koa是一个精简的Node框架,被认为是第二代Node框架,其最大的特点就是`独特的中间件`流程控制,是一个典型的`洋葱模型`,
它的核心工作包括下面两个方面:

(1) 将Node原生的request和response封装成为一个context对象。
(2) 基于async/await的中间件洋葱模型机制。
中间件是一种独立的系统软件或服务程序,分布式应用软件借助这种软件在不同的技术之间共享资源。
中间件位于客户机/ 服务器的操作系统之上,管理计算机资源和网络通讯。(晦涩难懂了)

重点:
//这是一个中间件(app.use(fun)里的fun),有两个参数,ctx和next
app.use(async (ctx,next)=>{
console.log('<<one');
await next();
console.log('one>>');
})

中间件和路由处理器的参数中都有回调函数,这个函数有2,3,4个参数

如果有两个参数就是req和res;

如果有三个参数就是request,response和next

如果有四个参数就是error,request,response,next

1、koa写接口

为了更好地引入洋葱模型,我们先从使用koa为切入口。且看下面代码:

// 写接口
const Koa = require('koa')//说明安装koa
const app = new Koa()

const main = (ctx) => {
   //   console.log(ctx.request);
   if(ctx.request.url=='/home'){//localhost:3000/home访问
       ctx.response.body={data:1}

  }else if(ctx.request.url=='/user'){//localhost:3000/user访问
       ctx.response.body={name:'fieemiracle'}

  }else{//localhost:3000访问
       ctx.response.body='texts'

  }
}
app.use(main)
app.listen(3000)

以上代码,当我们在后端(终端)启动这个项目,可以通过localhost:3000 || localhost:3000/home || localhost:3000/user访问,页面展示的内容不一样,分别对应分支里的内容。


模拟创建接口,虽然通过if分支让代码跟直观易懂,但是不够优雅,当需要创建多个不同接口时,代码冗长且不优雅,需要改进,我们这采用路由(router):

// 优化5.js
const Koa = require('koa')
const app = new Koa()
const fs=require('fs') ;
// 路由
const router=require('koa-route')//安装koa-router

// 中间件:所有被app.use()掉的函数
const main = (ctx) => {
 ctx.response.body = 'hello'
}
// 中间件:所有被app.use()掉的函数
const about=(ctx)=>{
   ctx.response.type='html';
   ctx.response.body='<a href="https://koa.bootcss.com/">About</a>'
   // ctx.response.body='<a href="/">About</a>'
}
// 中间件:所有被app.use()掉的函数
const other=(ctx)=>{
   ctx.response.type='json';
   ctx.response.body=fs.createReadStream('./6.json')
}

app.use(router.get('/',main));
app.use(router.get('/about',about));
app.use(router.get('/other',other));
// 路由内部有中间件,不需要第二个参数next

app.listen(3000);
注意app.use()语句,被app.use()过的,就是中间件。通过传入路由的方式,当我们使用localhost:3000 || localhost:3000/home || localhost:3000/user访问时候,会对应地执行app.use()。这样就更优雅了。接下来我们看看洋葱模型,跟路由这种方式的区别:
const Koa = require('koa');
const app=new Koa();

// 洋葱模型(koa中间件的执行顺序)
const one=(ctx,next)=>{
   console.log('<<one');
   next();//执行two()
   console.log('one>>');
}
const two=(ctx,next)=>{
   console.log('<<two');
   next();//执行three()
   console.log('two>>');
}
const three=(ctx,next)=>{
   console.log('<<three');
   next();//没有下一个函数,执行下一个打印
   console.log('three>>');
}
app.use(one)
app.use(two)
app.use(three)

app.listen(3000,function(){
   console.log('start');
})

上面代码的执行顺序是什么?

<<one
<<two
<<three
three>>
two>>
one>>

这就是koa的洋葱模型的执行过程:先走近最外层(one),打印'<<one'-->next(),走进第二层(two),打印'<<two'-->next(),走进第三层,打印'<<three'-->next(),没有下一个中间件,打印'three>>'-->第三层执行完毕,走出第三层,打印'two>>'-->第二层执行完毕,走出第二层,打印'one>>'。如图:


这个轮廓是不是就很像洋葱的亚子。简而言之,洋葱模型的执行过程就是:从外面一层一层的进去,再一层一层的从里面出来。


洋葱模型与路由的区别在于:路由内部有内置中间件,不需要第二个参数next


洋葱模型执行原理

上面提到过,中间件:所有被app.use()掉的函数。也就是说,没有被app.use()掉,就不算是中间件。

//新建一个数组,存放中间件
cosnt middleware=[];

当我们使用中间件的时候,首先是使用use方法,use方法会将传入的中间件回调函数存储到middleware中间件数组中。所以我们可以通过app.use()添加中间件,例如:

app.use(function){
middleware.push(function);
}

监听,当执行app.listen去监听端口的时候,其实其内部调用了http模块的createServer方法,然后传入内置的callback方法,这个callback方法就会将use方法存储的middleware中间件数组传给compose函数(后期补充该内容)。


那么我们将上面的洋葱模型,利用其原理改造一下吧:

const Koa = require('koa');
const app=new Koa();

// 添加三个中间件
app.use(async (ctx,next)=>{
   console.log('<<one');
   await next();
   console.log('one>>');
})
app.use(async (ctx,next)=>{
   console.log('<<two');
   await next();
   console.log('two>>');
})
app.use(async (ctx,next)=>{
   console.log('<<three');
   await next();
   console.log('three>>');
})

app.listen(3000,function(){
   console.log('start');
})

//<<one
//<<two
//<<three
//three>>
//two>>
//one>>

看!打印结果一样。async和洋葱模型的结合可谓是yyds了,其实,不用async也是一样的。这下明白什么是洋葱模型了吧。

compose方法是洋葱模型的核心,compose方法中有一个dispatch方法,第一次调用的时候,执行的是第一个中间件函数,中间件函数执行的时候就是再次调用dispatch函数,也就说形成了一个递归,这就是next函数执行的时候会执行下一个中间件的原因。
因此形成了一个洋葱模型。
function compose (middleware) {
 return function (context, next) {
   let index = -1
   // 一开始的时候传入为 0,后续递增
   return dispatch(0)

//compose方法中的dispatch方法
   function dispatch (i) {
     // 假如没有递增,则说明执行了多次
     if (i <= index) return Promise.reject(new Error('next() called multiple times'))
     index = i;

     // 拿到当前的中间件
     let fn = middleware[i];

     if (i === middleware.length) fn = next

     // 当 fn 为空的时候,就会开始执行 next() 后面部分的代码
     if (!fn) return Promise.resolve()
     try {
       // 执行 next() 的时候就是调用 dispatch 函数的时候
       return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));

    } catch (err) {
       return Promise.reject(err)
    }
  }
}
}
洋葱模型存在意义

当在一个app里面有很多个中间件,有些中间件需要依赖其他中间件的结果时,洋葱模型可以保证执行的顺序,如果没有洋葱模型,执行顺序可能出乎我们的预期。


结尾

看到第一个koa写接口的例子,我们知道上下文context(简写ctx)有两个属性,一个是request,另一个是response,洋葱模型就是以函数第二个参数next()为切割点,由外到内执行request逻辑,再由内到外执行response逻辑,这样中间件的交流就更加简单。专业一点说就是:

Koa的洋葱模型是以next()函数为分割点,先由外到内执行Request的逻辑,然后再由内到外执行Response的逻辑,这里的request的
逻辑,我们可以理解为是next之前的内容,response的逻辑是next函数之后的内容,也可以说每一个中间件都有两次处理时机。洋葱
模型的核心原理主要是借助compose方法。


作者:来碗盐焗星球
链接:https://juejin.cn/post/7124601052153774093
来源:稀土掘金
收起阅读 »

没有二十年功力,写不出Thread.sleep(0)这一行“看似无用”的代码!

你好呀,我是喜提七天居家隔离的歪歪。这篇文章要从一个奇怪的注释说起,就是下面这张图:我们可以不用管具体的代码逻辑,只是单单看这个 for 循环。在循环里面,专门有个变量 j,来记录当前循环次数。第一次循环以及往后每 1000 次循环之后,进入一个 if 逻辑。...
继续阅读 »


你好呀,我是喜提七天居家隔离的歪歪。

这篇文章要从一个奇怪的注释说起,就是下面这张图:


我们可以不用管具体的代码逻辑,只是单单看这个 for 循环。

在循环里面,专门有个变量 j,来记录当前循环次数。

第一次循环以及往后每 1000 次循环之后,进入一个 if 逻辑。

在这个 if 逻辑之上,标注了一个注释:prevent gc.

prevent,这个单词如果不认识的同学记一下,考试肯定要考的:


这个注释翻译一下就是:防止 GC 线程进行垃圾回收。

具体的实现逻辑是这样的:


核心逻辑其实就是这样一行代码:

Thread.sleep(0);

这样就能实现 prevent gc 了?


懵逼吗?

懵逼就对了,懵逼就说明值得把玩把玩。

这个代码片段,其实是出自 RocketMQ 的源码:

org.apache.rocketmq.store.logfile.DefaultMappedFile#warmMappedFile


事先需要说明的是,我并没有找到写这个代码的人问他的意图是什么,所以我只有基于自己的理解去推测他的意图。如果推测的不对,还请多多指教。

虽然这是 RocketMQ 的源码,但是基于我的理解,这个小技巧和 RocketMQ 框架没有任何关系,完全可以脱离于框架存在。

我给出的修改意见是这样的:


把 int 修改为 long,然后就可以直接把 for 循环里面的 if 逻辑删除掉了。


这样一看是不是更加懵逼了?

不要慌,接下来,我给你抽丝剥个茧。

另外,在“剥茧”之前,我先说一下结论:

  • 提出这个修改方案的理论立足点是 Java 的安全点相关的知识,也就是 safepoint。

  • 官方最后没有采纳这个修改方案。

  • 官方采没采纳不重要,重要的是我高低得给你“剥个茧”。


探索

当我知道这个代码片段是属于 RocketMQ 的时候,我想到的第一个点就是从代码提交记录中寻找答案。

看提交者是否在提交代码的时候说明了自己的意图。

于是我把代码拉了下来,一看提交记录是这样的:


我就知道这里不会有答案了。

因为这个类第一次提交的时候就已经包含了这个逻辑,而且对应这次提交的代码也非常多,并没有特别说明对应的功能。

从提交记录上没有获得什么有用的信息。

于是我把目光转向了 github 的 issue,拿着关键词 prevent gc 搜索了一番。

除了第一个链接之外,没有找到什么有用的信息:


而第一个链接对应的 issues 是这个:

github.com/apache/rock…

这个 issues 其实就是我们在讨论这个问题的过程中提出来的,也就是前面出现的修改方案:


也就是说,我想通过源码或者 github 找到这个问题权威的回答,是找不到了。

于是我又去了这个神奇的网站,在里面找到了这个 2018 年提出的问题:

stackoverflow.com/questions/5…


问题和我们的问题一模一样,但是这个问题下面就这一个回答:


这个回答并不好,因为我觉得没答到点上,但是没关系,我刚好可以把这个回答作为抓手,把差的这一点拉通对齐一下,给它赋能。

先看这个回答的第一句话:It does not(它没有)。

问题就来了:“它”是谁?“没有”什么?

“它”,指的就是我们前面出现的代码。

“没有”,是说没有防止 GC 线程进行垃圾回收。

这个的回答说:通过调用 Thread.sleep(0) 的目的是为了让 GC 线程有机会被操作系统选中,从而进行垃圾清理的工作。它的副作用是,可能会更频繁地运行 GC,毕竟你每 1000 次迭代就有一次运行 GC 的机会,但是好处是可以防止长时间的垃圾收集。

换句话说,这个代码是想要“触发”GC,而不是“避免”GC,或者说是“避免”时间很长的 GC。从这个角度来说,程序里面的注释其实是在撒谎或者没写完整。

不是 prevent gc,而是对 gc 采取了“打散运行,削峰填谷”的思想,从而 prevent long time gc。

但是你想想,我们自己编程的时候,正常情况下从来也没冒出过“这个地方应该触发一下 GC”这样想法吧?

因为我们知道,Java 程序员来说,虚拟机有自己的 GC 机制,我们不需要像写 C 或者 C++ 那样得自己管理内存,只要关注于业务代码即可,并没有特别注意 GC 机制。

那么本文中最关键的一个问题就来了:为什么这里要在代码里面特别注意 GC,想要尝试“触发”GC 呢?


先说答案:safepoint,安全点。

关于安全点的描述,我们可以看看《深入理解JVM虚拟机(第三版)》的 3.4.2 小节:


注意书里面的描述:

有了安全点的设定,也就决定了用户程序执行时并非在代码指令流的任意位置都能够停顿下来开始垃圾收集,而是强制要求必须执行到达安全点后才能够暂停。

换言之:没有到安全点,是不能 STW,从而进行 GC 的。

如果在你的认知里面 GC 线程是随时都可以运行的。那么就需要刷新一下认知了。

接着,让我们把目光放到书的 5.2.8 小节:由安全点导致长时间停顿。

里面有这样一段话:


我把划线的部分单独拿出来,你仔细读一遍:

是HotSpot虚拟机为了避免安全点过多带来过重的负担,对循环还有一项优化措施,认为循环次数较少的话,执行时间应该也不会太长,所以使用int类型或范围更小的数据类型作为索引值的循环默认是不会被放置安全点的。这种循环被称为可数循环(Counted Loop),相对应地,使用long或者范围更大的数据类型作为索引值的循环就被称为不可数循环(Uncounted Loop),将会被放置安全点。

意思就是在可数循环(Counted Loop)的情况下,HotSpot 虚拟机搞了一个优化,就是等循环结束之后,线程才会进入安全点。

反过来说就是:循环如果没有结束,线程不会进入安全点,GC 线程就得等着当前的线程循环结束,进入安全点,才能开始工作。

什么是可数循环(Counted Loop)?

书里面的这个案例来自于这个链接:

juejin.cn/post/684490… HBase实战:记一次Safepoint导致长时间STW的踩坑之旅

如果你有时间,我建议你把这个案例完整的看一下,我只截取问题解决的部分:


截图中的 while(i < end) 就是一个可数循环,由于执行这个循环的线程需要在循环结束后才进入 Safepoint,所以先进入 Safepoint 的线程需要等待它。从而影响到 GC 线程的运行。

所以,修改方案就是把 int 修改为 long。

原理就是让其变为不可数循环(Uncounted Loop),从而不用等循环结束,在循环期间就能进入 Safepoint。

接着我们再把目光拉回到这里:


这个循环也是一个可数循环。

Thread.sleep(0) 这个代码看起来莫名其妙,但是我是不是可以大胆的猜测一下:故意写这个代码的人,是不是为了在这里放置一个 Safepoint 呢,以达到避免 GC 线程长时间等待,从而加长 stop the world 的时间的目的?

所以,我接下来只需要找到 sleep 会进入 Safepoint 的证据,就能证明我的猜想。

你猜怎么着?

本来是想去看一下源码,结果啪的一下,在源码的注释里面,直接找到了:

hg.openjdk.java.net/jdk8u/jdk8u…


注释里面说,在程序进入 Safepoint 的时候, Java 线程可能正处于框起来的五种不同的状态,针对不同的状态有不同的处理方案。

本来我想一个个的翻译的,但是信息量太大,我消化起来有点费劲儿,所以就不乱说了。

主要聚焦于和本文相关的第二点:Running in native code。

When returning from the native code, a Java thread must check the safepoint _state to see if we must block.

第一句话,就是答案,意思就是一个线程在运行 native 方法后,返回到 Java 线程后,必须进行一次 safepoint 的检测。

同时我在知乎看到了 R 大的这个回答,里面有这样一句,也印证了这个点:

http://www.zhihu.com/question/29…


那么接下来,就是见证奇迹的时刻了:


根据 R 大的说法:正在执行 native 函数的线程看作“已经进入了safepoint”,或者把这种情况叫做“在safe-region里”。

sleep 方法就是一个 native 方法,你说巧不巧?

所以,到这里我们可以确定的是:调用 sleep 方法的线程会进入 Safepoint。

另外,我还找到了一个 2013 年的 R 大关于类似问题讨论的帖子:

hllvm-group.iteye.com/group/topic…


这里就直接点名道姓的指出了:Thread.sleep(0).

这让我想起以前有个面试题问:Thread.sleep(0) 有什么用。

当时我就想:这题真难(S)啊(B)。现在发现原来是我道行不够,小丑竟是我自己。

还真的是有用。

实践

前面其实说的都是理论。

这一部分我们来拿代码实践跑上一把,就拿我之前分享过的《真是绝了!这段被JVM动了手脚的代码!》文章里面的案例。

public class MainTest {

  public static AtomicInteger num = new AtomicInteger(0);

  public static void main(String[] args) throws InterruptedException {
      Runnable runnable=()->{
          for (int i = 0; i < 1000000000; i++) {
              num.getAndAdd(1);
          }
          System.out.println(Thread.currentThread().getName()+"执行结束!");
      };

      Thread t1 = new Thread(runnable);
      Thread t2 = new Thread(runnable);
      t1.start();
      t2.start();
      Thread.sleep(1000);
      System.out.println("num = " + num);
  }
}

这个代码,你直接粘到你的 idea 里面去就能跑。

按照代码来看,主线程休眠 1000ms 后就会输出结果,但是实际情况却是主线程一直在等待 t1,t2 执行结束才继续执行。


这个循环就属于前面说的可数循环(Counted Loop)。

这个程序发生了什么事情呢?

  • 1.启动了两个长的、不间断的循环(内部没有安全点检查)。

  • 2.主线程进入睡眠状态 1 秒钟。

  • 3.在1000 ms之后,JVM尝试在Safepoint停止,以便Java线程进行定期清理,但是直到可数循环完成后才能执行此操作。

  • 4.主线程的 Thread.sleep 方法从 native 返回,发现安全点操作正在进行中,于是把自己挂起,直到操作结束。

所以,当我们把 int 修改为 long 后,程序就表现正常了:


受到 RocketMQ 源码的启示,我们还可以直接把它的代码拿过来:


这样,即使 for 循环的对象是 int 类型,也可以按照预期执行。因为我们相当于在循环体中插入了 Safepoint。

另外,我通过不严谨的方式测试了一下两个方案的耗时:


在我的机器上运行了几次,时间上都差距不大。

但是要论逼格的话,还得是右边的 prevent gc 的写法。没有二十年功力,写不出这一行“看似无用”的代码!

额外提一句

再说一个也是由前面的 RocketMQ 的源码引起的一个思考:


这个方法是在干啥?

预热文件,按照 4K 的大小往 byteBuffer 放 0,对文件进行预热。

byteBuffer.put(i, (byte) 0);

为什么我会对这个 4k 的预热比较敏感呢?

去年的天池大赛有这样的一个赛道:

tianchi.aliyun.com/competition…


其中有两个参赛选大佬都提到了“文件预热”的思路。

我把链接放在下面了,有兴趣的可以去细读一下:

tianchi.aliyun.com/forum/postD…



tianchi.aliyun.com/forum/postD…


最后,谢谢你“点赞”、“评论”我的文章,给我满满的正反馈。谢谢!

来源:juejin.cn/post/7139741080597037063

收起阅读 »

敢在我工位装摄像头?吃我一套JS ➕ CSS组合拳!!👊🏻

web
前言大家好,我是HoMeTown不知道大家最近有没有看到过封面上的这张图,某公司在个人工位安装监控,首先我个人认为,第一每个行业有每个行业的规定,如果公司和员工提前做好沟通,并签过合同协议的话,问题不大,比如银行职员这种岗位。第二是私人企业和员工如果签订了补偿...
继续阅读 »


前言

大家好,我是HoMeTown

不知道大家最近有没有看到过封面上的这张图,某公司在个人工位安装监控,首先我个人认为,第一每个行业有每个行业的规定,如果公司和员工提前做好沟通,并签过合同协议的话,问题不大,比如银行职员这种岗位。第二是私人企业和员工如果签订了补偿协议?协议里明确说明工资翻3倍?4倍?5倍?或者其他的对员工有利的条件?(如果一个探头能翻3倍工资,那我觉得我可以装满)

但是如果是公司在没有和员工沟通的前提下,未经员工同意强制在工位上安装这个破玩意,那我觉得这公司有点太不人道了,违不违法这个咱确实不懂,也不做评论。

类似这样的操作,我本着好奇的心态,又搜了搜,发现这种情况好像不在少数,比如这样:


再或者这样:


作为一个程序员,这点探头能难得到我?我能因为你这点儿探头止步不前了?

话不多说,是时候给你秀秀肌肉💪🏻了,开干!


组合拳拳谱

封装函数lick作为主函数直接 export,让广大的友友们开箱即用!

lick函数内置: init初始化方法、move移动方法、setupEvent事件注册方法以及setupStyle等关键函数,实现事件上的可控制移动。

lick!重卷出击!

export function lick(lickdogWords) {
 setupStyle();
 // 偏移值
 let left = 0;
 //声明定时器
 let timer = null;
 // 文字
 let lickWord = "";
 
 const out = document.querySelector("#lickdog-out_wrap");
 out.innerHTML = `
   <div id="lickdog-inner_wrap">
       <div id="text-before">${lickWord}</div>
       <div id="text-after">${lickWord}</div>
   </div>
 `;

 const inner = document.querySelector("#lickdog-inner_wrap");
 const textBefore = document.querySelector("#text-before");

 init();
 setupEvent();
 
   // 初始化
 function init() {
   // 开启定时器之前最好先清除一下定时器
   clearInterval(timer);
   //开始定时器
   timer = setInterval(move, speed);
}
 
 function setupStyle() {
   const styleTag = document.createElement("style");
   styleTag.type = "text/css";
   styleTag.innerHTML = `
   #lickdog-out_wrap{
       width: 100%;
       height: 100px;
       position: fixed;
       overflow: hidden;
       text-overflow: ellipsis;
       /* 颜色一定要鲜艳 */
       background-color: #ff0000;
       border-radius: 8px;
       /* 阴影也一定要够醒目 */
       box-shadow: rgba(255, 0, 0, 0.4) 5px 5px, rgba(255, 0, 0, 0.3) 10px 10px, rgba(255, 0, 0, 0.2) 15px 15px, rgba(255, 0, 0, 0.1) 20px 20px, rgba(255, 0, 0, 0.05) 25px 25px;
   }
   #lickdog-inner_wrap {
       // padding: 0 12px;
       width: 100%;
       height: 100%;
       display: flex;
       align-items: center;
       position: absolute;
       left: 0;
       top: 0;
   }
   .text{
       white-space:nowrap;
       box-sizing: border-box;
       color: #fff;
       font-size: 48px;
       font-weight:bold;
       /* 文字一定要立体 */
       text-shadow:0px 0px 0 rgb(230,230,230),1px 1px 0 rgb(215,215,215),2px 2px 0 rgb(199,199,199),3px 3px 0 rgb(184,184,184),4px 4px 0 rgb(169,169,169), 5px 5px 0 rgb(154,154,154),6px 6px 5px rgba(0,0,0,1),6px 6px 1px rgba(0,0,0,0.5),0px 0px 5px rgba(0,0,0,.2);
   }
   `;
   document.head.appendChild(styleTag)
}
 
   //封装移动函数
 function move() {
   if (left >= textBefore.offsetWidth) {
     left = 0;
  } else {
     left++;
  }
   inner.style.left = `${-left}px`;
}
 
 function setupStyle() { ... }
}

通过简单的代码,我们基本实现了我们的这一套组合拳,可能说到这,有的朋友还不知道这段代码到底有什么作用,意义在哪,有什么实际的用途...

接下来建一个html进行才艺展示!:

<!DOCTYPE html>
<html lang="en">
 <head>
   <meta charset="UTF-8" />
   <meta http-equiv="X-UA-Compatible" content="IE=edge" />
   <meta name="viewport" content="width=device-width, initial-scale=1.0" />
   <title>Document</title>
   <style>
      html, body {
          margin: 0;
          padding: 0;
      }
   </style>
 </head>
 <body>
   <div id="lickdog-out_wrap"><div>
   <script>
      (async function() {
          const lickdog = await import('./lickdog.js')
          lickdog.lick(
              // 重点!
              [
                  "问题到我为止,改变从我开始",
                  "人在一起叫聚会,心在一起叫团队",
                  "工作创造价值,奉献带来快乐,态度决定一切",
                  "怠惰是贫穷的制造厂",
                  "一个优秀的员工必须培养自己对工作的兴趣,使工作成为愉快的旅程",
                  "一朵鲜花打扮不出美丽的春天,一个人先进总是单枪匹马,众人先进才能移山填海",
                  "抓住今日,尽可能少的依赖明天",
                  "行动是成功的开始,等待是失败的源头",
                  "强化竞争意识,营造团队精神",
                  "迅速响应,马上行动",
                  "去超越那个比你牛逼,更比你努力的人",
                  "不为失败找理由,只为成功找方法",
                  "含泪播种的人一定能含笑收获",
                  "不经历风雨,怎么见彩虹",
                  "路,要一步一步足踏实地地往前走,才能获得成功",
              ]
          )
      })()
   </script>
 </body>
</html>

Duang!

Duang!

Duang!

效果来辽!


嗯,按照上面的代码,你可以通过最简单、最快的方式,立即在你的网页中获得一个置顶的!可以无限轮播公司标语的跑马灯!

而且色彩足够鲜艳,监控器一眼就能看到!!!

咱一整个就是说,这玩意儿往上面一放,老板看到不得夸你两句?给你提提薪资?给你放俩天假?


不够满意?

如果你觉的上面的功能还不够完美,我们可以添加一个空格事件,当你发现你觉得不错的标语(你想让老板给你涨薪的标语)时,仅仅只需要动动你的大拇指敲下空格键,呐,如你所愿,暂停⏸了!该标语会一直停留在展示区域,让老板仔细观看!(你品,你细品!)

  function setupEvent() {
   // 如果遇到自己喜欢的句子,不妨空格⏸,让老板多看看
   document.onkeydown = function (e) {
     var keyNum = window.event ? e.keyCode : e.which; //获取被按下的键值
     if (keyNum == 32) {
       if (timer) {
         clearInterval(timer);
         timer = null;
      } else {
         timer = setInterval(move, speed);
      }
    }
  };
}

效果如下:


还不够满意?

如果你觉得太慢,你甚至可以完全自定义设置滚动速度,让标语滚动更快或者更慢,像这样:

...
const speed = config?.speed ?? 10;
...
//开始定时器
timer = setInterval(move, speed);


觉得自己的句子不够斗志昂扬?不够有激情?没问题,开启beautify,自动为你添加

lickdog.lick({
  [ ... ],
  {
       speed: 1,
       enableBeautify: true,
  }
})

不想用?没问题!使用beautifyText!去自定义吧,自定义你想表达的情绪;自定义不被自定义的自定义:

lickdog.lick({
  [ ... ],
  {
       speed: 1,
       enableBeautify: true,
       beautifyText: '!***、'
  }
})


完结

以玩笑的方式跟大家分享一个了知识点:文字的横向滚动轮播

最后呢,关于这个话题,如果有朋友不幸遇到了,自己决定提不提桶就好。

愿好㊗️。

挣钱嘛,生意,不寒碜 --《让子弹飞》

来源:juejin.cn/post/7135994466006990856

收起阅读 »

中秋~

中秋,是一个令我们耳熟能详的词,中秋博饼,相信大家也并不陌生。当然了,今年中秋我也有博饼。就让我来跟大家讲讲吧!今年中秋,我随着爸爸妈妈来到好清香饭店,嘿嘿,这个饭店我可熟悉了,过年、生日、中秋……我都会和爸爸妈妈来这与他的好友及妻儿一起共聚。今年也不例外。大...
继续阅读 »

中秋,是一个令我们耳熟能详的词,中秋博饼,相信大家也并不陌生。当然了,今年中秋我也有博饼。就让我来跟大家讲讲吧!

今年中秋,我随着爸爸妈妈来到好清香饭店,嘿嘿,这个饭店我可熟悉了,过年、生日、中秋……我都会和爸爸妈妈来这与他的好友及妻儿一起共聚。今年也不例外。大家吃完了一桌丰盛的美食后,面带微笑,准备开始接下来的博饼。我帮着妈妈把礼品都放到桌面上,开始博饼!我们从晨阳哥哥轮起,因为他创造了一个奇迹!从学校中的倒数几名,变成了一中全校唯有的一名可以到北京人民大学文科的学生。报纸上、电视上,都有报道呢!希望它能为我们大家开一个好头。

结果不太理想,噢,什么都没有。接下去大家一直都没有什么好起色。过了不久,再次轮到了我,我闭紧眼睛,双手抓起骰子用力放入碗中,骰子在碗中欢快地跳跃,眼看就要跳出碗了,我的目光顿时灰暗下来,移开视线,心想:前面博了一个二举,一个一秀,这次更惨,什么都没有,一会儿会不会还是这样?过了一会,四周顿时寂静无声,不知是谁大喊了一声:“对堂!”我不可置信地转过头来,“一、二、三、四、五、六!”真的是对堂,我高兴的欢呼起来。就这样,我为我们家博了一瓶酒。接下去就是我妈妈博了,大家呐喊着:“状元!状元!”只听几声骰子与碗碰撞出的清脆的响声,又一个对堂出现在大家眼前。大家“啧啧”地赞叹着。

眼见着桌上的东西越博越少,只剩下两个四进与状元了,大家目不转睛地盯着,仿佛下了天大的决心要博过来。一个四进被我博了,又一个四进被叶伯伯博了,开始博状元了!几轮无果的博饼后,叶伯伯说了句:“唉,今年的状元架子还真大呢!”大家都笑了起来。轮到我了,我满怀希望的投下去,额,四个一两个四,真是的,一和四都反了!,到我妈妈了,妈妈含笑着扔下去,三个四和五、一都已经停住了脚步,还有一个骰子欢快的舞蹈,只见那个骰子精疲力尽了,慢慢停住了脚步。“啊!状元带六”我大声欢呼着,是啊,按照规矩,可以再轮一圈抢状元,如果都没有,状元就归我们家了呢……哈哈,果然不出我所料,状元是我们家的了!妈妈不好意思的笑着说:“呵呵,都是我买来又都被我们家给博走了。”大家都笑着说没事。

随着骰子在碗里跳跃的声音越来越疏远,我知道,中秋拖着她的长裙慢慢走远了……

收起阅读 »

【中秋随手拍 | imgeek专属活动鼠标耳机免费送】我的中秋我做主,快来分享你的中秋趣事吧!

一年一度中秋时,合家齐聚把月赏。中秋节是中华民族的传统节日,每年农历八月十五,亲朋好友们都会合家团聚,围坐于桌前食月饼和赏月。而中秋节的月亮也是一年之中最圆的,似乎也寓意着合家美满、幸福团圆的意思。现诚邀广大环友一起来参与中秋随手拍线上活动,和家人团聚的时候也...
继续阅读 »

一年一度中秋时,合家齐聚把月赏。中秋节是中华民族的传统节日,每年农历八月十五,亲朋好友们都会合家团聚,围坐于桌前食月饼和赏月。而中秋节的月亮也是一年之中最圆的,似乎也寓意着合家美满、幸福团圆的意思。现诚邀广大环友一起来参与中秋随手拍线上活动,和家人团聚的时候也别忘了和大家分享一下你的中秋趣事和计划哦!!!!


本次活动福利多多,只要发布符合条件的话题,就可领取各种丰厚大奖,得奖概率超级大!



#中秋随手拍#参与方式


我的中秋我做主,回帖分享自己的中秋趣事或中秋计划、中秋福利等参与活动(主题不限,和中秋节有关即可)。



活动时间:9月6日—9月13日 17:59


活动奖励


最充实中秋奖 1人  JBL无线蓝牙耳机 奖励精彩评论



幸运参与奖 1人 罗技 无线蓝牙鼠标 回帖中随机抽取一名



幸运陪跑奖 5名 游戏鼠标垫 奖励活动群内5波红包手气王



抽奖须知:

1、所有回复须为原创,不得转载网络图文。否则将不参与评选。

2、所有参与回帖用户请扫码进群,群内开奖时间: 9月13日 18点

3、最充实中秋奖评选标准:认真回复,内容丰富,感情真挚,表述清晰,阅读性高。。

4、群红包中奖但是没有参与话题活动,获奖无效,奖励将顺延给后面参加活动的群友


欢迎广大用户加入中秋随手拍活动群


   



收起阅读 »

前端四年,迷茫、无激情、躺平的人生,路在何方?

前途一片迷茫,路在何方? 今天我来分享一下我的职业囧途,借此告诫新人,少走弯路,多想出路。我是2018年普通本科软件工程毕业,算上实习已经工作五年了。大学期间教的课程都是后台语言,C语言、C++、Java、ASP.NET等。但是教程安排的不太合理,本来大学期间...
继续阅读 »

前途一片迷茫,路在何方? 今天我来分享一下我的职业囧途,借此告诫新人,少走弯路,多想出路。

我是2018年普通本科软件工程毕业,算上实习已经工作五年了。大学期间教的课程都是后台语言,C语言、C++、Java、ASP.NET等。但是教程安排的不太合理,本来大学期间很喜欢Java(大二第一学期),后面一整年时间都没Java相关的课程,教的是ASP、MFC和安卓等。以至于我到大四出来实习还没选好就业方向,胡乱海投简历,面试过Java、ASP.NET和前端岗位。最后选了某家居企业担任前端开发一职(月薪5-7k),虽然仅在此工作一年,但我还是很怀念那里,在那里学到挺多知识(基础差,学得多),认识很多小伙伴(现在还天天联系)。PS:刚出来工作打基础阶段找前端岗位多的平台,可以相互学习,共同进步。一定要多学多问,多做事。

2019年年初裸辞,回家结婚,瞬间感觉肩上的负担重了很多。那时候心想一定要努力学习,好好工作争取拿到高薪。3月中出广州找工作,那时候比较容易找吧!都是企业HR找上门,约面试。自己投的中大厂毫无音信,简历石沉大海。然后去面了两家自己找上门的公司,一家是港资(7-8k),一家就是目前工作这家(8k),也是家居行业的。港资那家其实我比较喜欢的(香港李某某儿子的公司),但是技术栈是Jquery+Node,我学的和做的大多数是Vue相关的,而且还要去香港出差。后面拒绝了(过了几天就后悔了,起码是中大厂背景呀!)。刚开始入职时候只有我一个前端,问组长会不会继续招前端,他说计划再招一个(忽悠的)。一个人硬着头皮干吧!开始遇到很多没做过的项目,VR 720°全景漫游,做了整个试用期才成功上线。头半年虽然是一个人在拼搏,没人教,没共同语言(他们都是后台),不懂就百度,慢慢摸索,工期不急,收获了挺多干货。PS:找工作有条件优先选择规模大背景好的企业。换好工作涨薪更容易。

2022年跑了一半了,我还是在原来的那家公司(3.5年),还是我一个前端,因为公司的项目更多的偏向于后端,前端工作我一个人就能处理完。工资相比入职那时涨了75%,我都不敢看掘金/抖音/小红书大佬们评论的工资了,感觉你们年薪最低都二三十万。我承认,严重拖后腿了。这三年多项目做得挺多挺杂的,有APP(HBuilder打包的)、微信小程序、公众号、网站、桌面应用(Electron套壳)、VR全景图(Three.js)、看板(Echart.js)、PDA/扫码枪相关的项目,还有最最最烦的ERP系统,因为它是开发了好几年的老旧系统,用Jquery开发,当时是后台做的,他们没有模块化的概念,公共组件、公共函数、公共样式啥都没有。MVC的开发模式,自我感觉,对自身职业发展毛用都没。我颓了,由于很多项目的前端都是我独自完成的,没有团队的意识,为了追求速度,没用Eslint代码规范,没用git建分支,没有代码冲突的体验。我目前的技术栈主要是Vue相关的(可以看我发的文章),React/Koa/TypeScript有学习但是工作上没用到,隔几个月就忘记了。平时逛掘金经常看到某某某一两年经验面试心得,那些面试题我看得一脸懵逼,很多题都是表面上知道,但是都不会作答的。我沉思了,心里想我现在都比不上刚出社会的实习生了吗?我跳槽出去还能找到工作吗?不如待在原公司做到退休吧(30+)??? 由于疫情影响,今年很多以前的同事都说公司裁员,大环境不好,有工作就不错了,铺天盖地的消极论。我上有老,下有小,老婆在家带娃,真的不敢跳出舒适圈。今年公司跟我签了第二次合同(5年),这家没年终奖,工作基本能胜任,绰绰有余那种,好处就是每年加一次薪(最少1k)。而我上家公司的前端同事,经过自身不断地努力都找到比较好的平台。一位去了某办公软件,听说年薪 有三四十W,真心羡慕呀! PS:如果你年轻,还没结婚,建议您尽快跳出舒适圈,找一个更好更大的平台深造。年轻是资本,错过了没有回头路,且行且珍惜

我现在好迷茫,没有了刚出社会那种冲劲,一直求稳,没太多的学习热情,很容易分心。每天闲余时间都会刷刷文章,但是没认真思考,没实操,没做笔记,过一段时间又忘了。这就是我毕业到现在的职业生涯。希望看到的新人能以此为鉴,避坑,少走弯路。

前段时间带老婆小孩去海陵岛玩了几天,心情愉悦了很多。最近老婆说她去上班赚钱减轻我的负担,其实我不太想她出去上班的,等小孩读幼儿园再去也不迟。这段时间思考了许久,下定决心,下半年要恶补一下技术,待大环境好点,找份高薪能学到更多技术的平台。希望掘金平台的大佬们能指引一下学习方向,我会努力向你们学习。

前路漫漫,道阻且长,行则将至,做则必成!愿未来无忧,心之所向,身之所往,终至所归!



作者:陌上花開等雨來
来源:juejin.cn/post/7115699180571459620

收起阅读 »