开发者博客 – IT技术 尽在开发者博客

开发者博客 – 科技是第一生产力


  • 首页

  • 归档

  • 搜索

抖音 Android 包体积优化探索:从 Class 字节码

发表于 2022-01-13

前言

众所周知,应用安装包的体积会十分影响用户的应用下载速度和安装速度。据 GooglePlay 平台对外发布相关的包大小对转化率影响的数据,我们可以看到随着包大小的增加,安装转化率总体呈下降的趋势。

图片

因此对于我们的应用来说,为了提升我们用户下载的转化率(即下载安装激活用户与潜在用户的比例),我们对包体积必须给予一定的优化和管控。

我们应用商店中提供给用户下载的安装包,是 Android 定义的 APK 格式,其实质则是一个包含应用所有所需资源的 zip 包,它包含了如下所示的几个组成部分:

图片

这其中最主要的组成部分便是 DEX 文件,它们都是由 Java/Kotlin 代码编译而成。过去的两年中,抖音的 DEX 的个数从 8 个涨到了 21 个,DEX 的总大小从 26M 涨到了 48M,增长十分迅猛。诚然,随着抖音的快速发展,业务复杂度的提高,代码量级一定是在增加的,但如何在业务无感的情况下,对代码进行通用优化,也是我们一个很重要的优化方向。

在介绍具体优化手段之前,我们首先需要了解下针对 DEX 整体上的优化思路。

DEX 通用优化思路

在 AGP 的构建过程中,Java 或 Kotlin 源代码在经过编译之后会生成 Class 字节码文件,在这个阶段 AGP 提供了 Transform 来做字节码的处理,我们非常熟悉的 Proguard 就是在这个阶段工作的,之后 Class 文件经由 dexBuilder 生成一堆较小的 DEX 文件,再经由 mergeDex 合并成最终的 DEX 文件,然后打入 APK 中。具体过程如下图所示:

图片

因此,我们针对 DEX 文件的优化时机可以从分别从三个阶段切入,分别是.kt 或.java 源文件、class 文件、DEX 文件:

  • 在源文件进行处理也就是手动改造代码,这种方式对程序设计本身有侵入,并且有较强的局限性;
  • 在 class 字节码阶段对开发者无感知,而且基本上能完成大多数的优化,但对于像跨 DEX 引用优化这样涉及 DEX 格式本身的优化无法完成;
  • 在 DEX 文件阶段进行优化是最理想的,在这个阶段我们除了能对 DEX 字节码本身进行优化,也可对 DEX 文件格式进行操作。

优化的手段总体上来说也就是冗余去除、内容精简、格式优化等方式。

由于早期抖音 class 字节码修改工具建设比较成熟,我们很多包体积的优化都是通过修改 class 字节码完成的,随着优化的深入,后期也有很多优化是在 DEX 文件阶段处理的。关于 DEX 阶段相关的优化我们后续会有相关文章介绍,这里主要介绍 Class 字节码阶段进行的相关优化,主要分为两大类:

  • 单纯去除无用的代码指令,包括去除冗余赋值,无副作用代码删除等
  • 除了能减少代码指令数量外,同时减少方法和字段的数量,从而有效减少 DEX 的数量。我们知道 DEX 中引用方法数、引用字段数等不能超过 65535,超过之后就需要新开一个 DEX 文件,因此减少 DEX 中方法数、字段数可以减少 DEX 文件数量,像短方法内联、常量字段消除、R 常量内联就属于这类优化。

接下来我们会针对每一项优化的背景、优化思路和收益进行详细介绍。

去除冗余赋值

在我们平时的代码开发中,我们可能会写出以下的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
csharp复制代码class MyClass {
     private boolean aBoolean = false;

     private static boolean aBooleanStatic = false;

     private void boo() {
         if (!aBoolean) {
             System.out.println("in aBoolean false!");
         }

         if (!aBooleanStatic) {
             System.out.println("in aBooleanStatic false!");
         }
     }
}

我们常常为了保证一个 Class 的成员变量的初始满足我们期望的值,手动对其进行一次赋值,如上述代码里的 aBoolean 和 aBooleanStatic。这是一种逻辑上非常安全的做法,但这真是必须的吗?

其实 Java 官方在虚拟机规范(docs.oracle.com/javase/spec… )
中定义了,Class对象在虚拟机中加载时,所有的静态字段(也就是静态成员变量,下面统称为Field)都会首先加载一个默认值。

2.3. Primitive Types and Values

…

The integral types are:

  • byte, whose values are 8-bit signed two’s-complement integers, and whose default value is zero
  • short… whose default value is zero
  • int… whose default value is zero
  • long… whose default value is zero
  • char… whose default value is the null code point ('\u0000')

The floating-point types are:

  • float… whose default value is positive zero
  • double… whose default value is positive zero

2.4. Reference Types and Values

…The null reference initially has no run-time type, but may be cast to any type. The default value of a reference type is null.

总结来说,在 Java 中的基本类型和引用类型的 Field 都会在 Class 被加载的同时赋予一个默认值,byte、short、int、long、float、double类型都会被赋为 0, char 类型会被赋为'\u0000',引用类型会被赋为 null。

我们将开头那段代码通过命令行java -p -v转化为字节码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
csharp复制代码public com.bytedance.android.dexoptimizer.MyClass();
    Code:
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: aload_0
         5: iconst_0
         6: putfield      #2                  // Field aBoolean:Z
         9: return

   static {};
    Code:
         0: iconst_0
         1: putstatic     #6                  // Field aBooleanStatic:Z
         4: return

  private void boo();
    Code:
         0: aload_0
         1: getfield      #2                  // Field aBoolean:Z
         4: ifne          15
         7: getstatic     #4                  // Field java/lang/System.out:Ljava/io/PrintStream;
        10: ldc           #5                  // String in aBoolean false!
        12: invokevirtual #6                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        15: aload_0
        16: getfield      #3                  // Field aBooleanStatic:Z
        19: ifne          30
        22: getstatic     #4                  // Field java/lang/System.out:Ljava/io/PrintStream;
        25: ldc           #7                  // String in aBooleanStatic false!
        27: invokevirtual #6                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        30: return

通过上述字节码发现,虽然 JVM 会在运行时将 aBoolean 赋值为 0,但是我们在字节码中仍然会再赋值一次 0 给到 aBoolean,aBooleanStatic 同理。

1
2
3
4
5
6
7
8
kotlin复制代码public com.bytedance.android.dexoptimizer.MyClass();
    Code:
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: aload_0
         5: iconst_0
         6: putfield      #2                  // Field aBoolean:Z
         9: return

以上标红部分出现了重复赋值,去除了不影响运行时逻辑。因此,我们考虑在 Class 字节码处理阶段,将这种冗余的字节码移除来获取包大小收益。

优化思路

理解了问题产生的原因后,就很容易得到对应的解决方案。首先,能够被优化的 Field 赋值,需要满足这三个条件:

  1. Field 是属于其直接定义的 Class 的,而非在父类定义过的;
  2. Field 赋值是在 Class 的clinit、init方法中,这样做很大程度是为了降低复杂度(因为只在这两个方法中调用的 private 方法也是能做这样的优化,但分析这样的方法复杂度很高);
  3. Field 赋值是默认值,当出现多个赋值时,在非默认赋值后的赋值都无法被优化。

我们结合下面的代码,具体说明一下各种情况是否可以被优化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
ini复制代码Class MyClass {
     // 可以优化,直接定义的,且是默认值
     private boolean aBoolean = false;
     // 不可优化,因为赋值为非默认值
     private boolean bBoolean = true;
     // 可以优化,直接定义的,且是默认值
     private static boolean aBooleanStatic = false;

     static {
         // 可以优化,第一处出现,且是默认值
         aBooleanStatic = false;

         // 其他代码
         ...

         // 可以优化,前面没有非默认值赋值,且是默认值
         aBooleanStatic = false;

         // 其他代码
         ...

         // 不可优化,因为赋值为非默认值
         aBooleanStatic = true;

         // 其他代码
         ...

         // 不可优化,因为之前出现了非默认值的赋值
         aBooleanStatic = false;
     }

     private void boo() {
         // 不可优化,因为函数为非clinit或init
         aBoolean = false;
     }
}

具体实现上,我们的优化思路是这样的:

  • 遍历 Class 所有方法,找到<clinit>和<init>方法,从上往下进行字节码指令遍历
  • 遍历这两种方法的所有字节码指令,找到所有的 putfield 指令,将 putfield 指令的目标 ClassName 和 FieldName 使用-连接,构建一个唯一的 Key,如果
    • putfield 目标 Class 不是当前 Class,跳过
    • putfield 前的 load 指令不为iconst_0,fconst_0,dconst_0,lconst_0,aconst_null,并将该 putfield 所关联的唯一的 Key 放入已经遍历过的 Key 的集合中
    • putfield 前的 load 指令为iconst_0,fconst_0,dconst_0,lconst_0,aconst_null,且该 putfield 所关联的唯一的 Key 没有在遍历过的 Key 的集合出现过,则标记为可清除的字节码指令
  • 遍历完成后,删除所有被标记为可清除的字节码指令

我们用一个简单的例子来说明下我们的思路:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
vbnet复制代码public com.bytedance.android.dexoptimizer.MyClass();  // 1. 判断是<init>方法,进入优化逻辑
    Code: // 2. 从上往下进行代码遍历
         0: aload_0
         1: invokespecial #Method java/lang/Object."<init>":()V
         4: aload_0
         5: iconst_0
         6: putfield      #Field MyClass.aBoolean:Z. // 3.发现是该Class的域,且赋值为iconst_0,标记往上三个指令可以删除
         7: aload_0
         8: iconst_1
         9: putfield      #Field MyClass.aBoolean:Z  // 4.发现是该Class的域,且赋值不为iconst_0,则在遍历过的Key的集合中添加MyClass-aBoolean,继续往下
         10: aload_0
         11: iconst_0
         12: putfield     #Field MyClass.aBoolean:Z  // 5.发现是该Class的域,但在遍历过的Key的集合中发现存在MyClass-aBoolean,继续往下
         15: return

最终发现上述字节码中,标红的部分可以删除,删除对应的字节码指令,优化完成。

使用抖音之前开源的字节码处理框架 ByteX,可以比较方便地获取 Field 的 Class,遍历 Class 的所有方法,以及所有方法的字节码。我们也已经将此方案进行了开源,有兴趣的同学可以前往查看详细代码:

  • github.com/bytedance/B…

删除无副作用代码

冗余赋值是利用了虚拟机在类加载时为字段默认赋值的特性,从而删除多余的的赋值指令,而我们代码中本身也有一些对线上包是没有作用的,最常见的就是日志打印,除了占用包体积之外,还会造成性能问题以及安全风险,因此一般都会将其移除掉,接下来我们以 Log.i 调用为例来介绍如何删除代码中的无用函数调用。比如下面代码中的日志打印语句:

1
2
3
4
5
csharp复制代码
public static void click() {
    clickSelf();
    Log.i("Logger", "click time:" + System.currentTimeMillis());
}

一开始我们尝试了 proguard 的 -assumenosideeffects,这个指令需要我们假定要删除的方法调用没有任何的副作用,并且从程序分析的角度来说这个方法是不会修改堆上某个对象或者栈上方法参数的值。使用如下配置,proguard 就会在 optimize 阶段帮我们删除 Log 相关的方法调用。

1
2
3
4
5
6
7
8
arduino复制代码-assumenosideeffects class android.util.Log {
    public static boolean isLoggable(java.lang.String, int);
    public static int v(...);
    public static int i(...);
    public static int w(...);
    public static int d(...);
    public static int e(...);
}

但是这种删除并不彻底,它只会删除方法调用指令本身,比如上面的代码中删除 Log.i 方法调用之后,会遗留一个 StringBuilder 对象的创建:

1
2
3
4
scss复制代码public static void click() {
    clickSelf();
    new StringBuilder("click time:")).append(System.currentTimeMillis();
}

这个对象的创建我们人为判断的话也是无用的,但是仅从简单的静态程序指令分析的角度并不能判定其是无用的,因此 proguard 并没有将其删除。

既然 assumenosideeffects 删除不干净,我们就自己来实现更加彻底的优化方案。

优化思路

1
2
3
4
5
6
7
8
9
10
11
12
13
14
less复制代码 public static void click();
    Code:
       0: invokestatic  #6                  // Method clickSelf:()V
       3: ldc           #7                  // String Logger
       5: new           #8                  // class java/lang/StringBuilder
       8: dup
       9: invokespecial #9                  // Method java/lang/StringBuilder."<init>":()V
      12: ldc           #10                 // String click time:
      14: invokevirtual #11                 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      17: invokestatic  #12                 // Method java/lang/System.currentTimeMillis:()J
      20: invokevirtual #13                 // Method java/lang/StringBuilder.append:(J)Ljava/lang/StringBuilder;
      23: invokevirtual #14                 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
      26: invokestatic  #2                  // Method android/util/Log.i:(Ljava/lang/String;Ljava/lang/String;)I
      29: pop

如上可以看到一行Log.i("Logger", "click time:" + System.currentTimeMillis());在编译完成之后会生成多条指令(从 ldc 到 pop),除了目标方法 Log.i 调用 invokestatic 指令外,还有很多参数创建和入栈指令。

我们要删除相关方法的调用的话,主要是就是找到这行代码所产生的起始指令和终止指令,然后起始到终止位置之间的指令就是我们要删除的全部指令。
1. 查找终止指令位置

终止指令的查找相对简单,主要就是找到要删除的目标方法调用指令,再根据方法的返回值类型确定是否要包含其后的 pop 或 pop2 指令。

比如上述代码我们通过遍历就能找到目标方法调用invokestatic #2 的位置,因为 Log.i 的返回值类型是 int,终止指令就是下一条的 pop。

注意 pop 指令的作用是主动让 int 类型的值出栈,也就是不会使用该方法的返回值,只有这种情况下我们才能安全删除目标方法,否则不能删除。当然如果方法的返回值类型是 void,就不会有 pop 指令。

2. 查找起始指令位置

起始指令的查找则需要我们对于 java 字码指令设计有基本的认识: java 字节码指令是基于堆栈设计的,每一条字节码指令会对应操作数栈的若干参数的入栈和出栈,并且一个完整独立代码/代码块执行前和执行后操作数栈应该是一样的。

因此我们找到终止指令后,倒序遍历指令,根据指令的作用进行反向的入栈和出栈操作,当我们的栈中 size 减为 0 时,就找到了起始指令的位置。注意在入栈时候要记录参数的类型,并在出栈时候做类型匹配校验。如上面的示例:

  • pop 指令效果是单 slot 参数(像 int,float)出栈 ,那我们就在栈存入一个 slot 类型的参数
  • invokestatic 要看方法的参数和返回值,正常效果是对应方法的参数从右至左依次出栈,方返回值 int 入栈。我们就根据方法返回值出栈一个 int 类型的参数,发现栈顶目前是 slot,类型匹配。然后按照方法参数从左至右依次入栈两个 String 类型的参数。
  • invokevirtual 指令正常方法调用参数依次从右至左依次出栈,然后 this 对象出栈,最后方法返回值 String 入栈。我们弹出栈顶一个参数,发现其和 String 匹配,然后依次入栈 this 对应的类型 StringBuilder,这里调用的是 toString 方法没有参数就不用再入栈。
  • 中间其他的指令类似,直到 ldc 指令,本身是向栈中放入一个 int,float 或 String 常量,我们这里弹出一个参数,发现其是 String 匹配,并且此时栈的大小变为 0,也就找到了起始指令的位置。

方案缺陷

不过上述方案存在两个缺陷:

  1. 因为分析只在单个方法内分析,针对 Log 方法封装的情况,必须需要配置封装方法作为目标方法,才能删除完全删除,比如下面的方法需要配置 AccountLog.d 才能删除其调用处的 StringBuilder 创建。
1
2
3
4
kotlin复制代码object AccountLog {
    @JvmStatic
    fun d(tag: String, msg: String) = Log.d(tag, msg)
}
  1. 可能会误删除一些有用的指令,因为无法认为 Log.i 的两个参数的构建指令都是没有用的,我们只能确定 StringBuilder 的创建是没用的,但是一些其他的方法调用可能会改变一些对象的状态,因此存在一定风险。

Proguard 方案

在我们上述方案在线上运行一年之后,尝试针对上述弊端进行优化,然后发现 proguard 还提供了 assumenoexternalsideeffects 指令,它可以让我们指定没有任何外部副作用的方法。

指定了以后,它只会修改调用这个方法的实例本身,但不会修改其他的对象。通过如下的配置可以删除无用的 StringBuilder 创建。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
arduino复制代码-assumenoexternalsideeffects class java.lang.StringBuilder {
    public java.lang.StringBuilder();
    public java.lang.StringBuilder(int);
    public java.lang.StringBuilder(java.lang.String);
    public java.lang.StringBuilder append(java.lang.Object);
    public java.lang.StringBuilder append(java.lang.String);
    public java.lang.StringBuilder append(java.lang.StringBuffer);
    public java.lang.StringBuilder append(char[]);
    public java.lang.StringBuilder append(char[], int, int);
    public java.lang.StringBuilder append(boolean);
    public java.lang.StringBuilder append(char);
    public java.lang.StringBuilder append(int);
    public java.lang.StringBuilder append(long);
    public java.lang.StringBuilder append(float);
    public java.lang.StringBuilder append(double);
    public java.lang.String toString();
}
-assumenoexternalreturnvalues public final class java.lang.StringBuilder {
    public java.lang.StringBuilder append(java.lang.Object);
    public java.lang.StringBuilder append(java.lang.String);
    public java.lang.StringBuilder append(java.lang.StringBuffer);
    public java.lang.StringBuilder append(char[]);
    public java.lang.StringBuilder append(char[], int, int);
    public java.lang.StringBuilder append(boolean);
    public java.lang.StringBuilder append(char);
    public java.lang.StringBuilder append(int);
    public java.lang.StringBuilder append(long);
    public java.lang.StringBuilder append(float);
    public java.lang.StringBuilder append(double);
}

不过,这个配置只适用于 Log 里只传入 String 的情况。如果是int Log.w (String tag, Throwable tr)这种情况,就无法把Throwable参数也一起去掉。那还是应该采用我们自己实现的插件才能优化干净。

此优化对抖音包体积收益,约为 520KB。

短方法内联

上面介绍的两个优化是从去除无用的指令的角度出发,开篇 DEX 优化思路中我们有讲过,减少定义方法或者字段数从而减少 DEX 数量也是我们常用优化思路之一,短方法内联就是精简代码指令的情况下,同时减少定义方法数。

在和海外竞品的对比过程中,我们发现单个 DEX 文件中的定义方法数远比竞品要多,进一步对 DEX 进行分析,发现抖音的 DEX 中有大量的 access,getter-setter 方法,而竞品中几乎没有。因此我们打算针对短方法做一些内联优化,减少定义方法数。

在介绍优化方案前,先来了解下内联的基础知识,内联作为最常见的代码优化手段,被称为优化之母。一些语言像 C++、Kotlin 提供了 inline 关键字给程序员做函数的内联,而 Java 语言本身并没有给程序员提供控制或建议 inline 的机会,甚至 javac 编译过程中也没有做方法内联。为了便于理解,我们通过一个简单的例子来看内联是如何工作的,如下代码中 callMethod 调用 print 函数:

1
2
3
4
5
6
7
8
9
10
11
csharp复制代码public class InlineTest {

    public static void callMethod(int a) {
        int result = a + 5;
        print(result);

    }
    public static void print(int result) {
        System.out.println(result);
    }
}

在内联之后 inlineMethod 的内容直接被展开到 callMethod 中, 从字节码的角度看变化如下:

内联前:

1
2
3
4
5
6
7
8
9
arduino复制代码public static void callMethod(int);
    Code:
       0: iload_0
       1: iconst_5
       2: iadd
       3: istore_1
       4: iload_1
       5: invokestatic  #2                  // Method print:(I)V
       8: return

内联后:

1
2
3
4
5
6
7
8
9
10
11
12
arduino复制代码public static void callMethod(int);
    Code:
       0: iload_0
       1: iconst_5
       2: iadd
       3: dup
       4: istore_0
       5: istore_0
       6: getstatic     #5                  // Field java/lang/System.out:Ljava/io/PrintStream;
       9: iload_0
      10: invokevirtual #6                  // Method java/io/PrintStream.println:(I)V
      13: return

从执行时间的角度看,减少了一次函数调用,从而提升了执行性能。从空间占用角度看,减少了一处函数声明,从而减少了代码体积。

那是不是所有的方法都适合内联呢?

显然不是的,对于单次调用的方法说内联能同时取得时间和空间的收益;对于多次调用的的方法则需要考虑方法本身的长短,比如上面的 print 方法展开之后的指令是比 invokestatic 指令本身要长很多的,但是像 access、getter-setter 方法本身比较短就很适合内联。

access 方法内联

1
2
3
4
5
6
7
8
9
10
11
12
13
csharp复制代码public class Foo {
    private int mValue;

    private void doStuff(int value) {
        System.out.println("Value is " + value);
    }

    private class Inner {
        void stuff() {
            Foo.this.doStuff(Foo.this.mValue);
        }
    }
}

如上述代码,大家都知道 Java 可以在内部类 Foo$Inner 中直接访问外部类 Foo 的私有成员,但是 JVM 并没有什么内部类外部类的概念,认为一个类直接访问另一个类的私有成员是非法的。编译器为了能实现这种语法糖,会在编译期生成以下静态方法:

1
2
3
4
5
6
arduino复制代码static int Foo.access$100(Foo foo) {
    return foo.mValue;
}
 static void Foo.access$200(Foo foo, int value) {
    foo.doStuff(value);
}

内部类对象创建时候会传入外部类的引用,这样当内部类需要访问外部类的mValue 或调用doStuff()方法时,会通过调用这些静态方法来实现。这里需要生成静态的方法的原因,是因为被访问的成员是私有的,而私有访问控制更多地是在源码层面去约束,防止破坏程序的设计。在字节码层面只要不破坏语法逻辑,因此我们完全可以将这些私有成员改成 public 的,直接删除掉编译器生成的桥接静态方法。

优化思路

具体的优化分为分为以下几步:

  1. 收集字节码中的 access 方法。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
arduino复制代码static int access$000(com.bytedance.android.demo.inline.Foo);
    descriptor: (Lcom/bytedance/android/demo/inline/Foo;)I
    flags: ACC_STATIC, ACC_SYNTHETIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: getfield      #2                  // Field mValue:I
         4: ireturn


static void access$100(com.bytedance.android.demo.inline.Foo, int);
    descriptor: (Lcom/bytedance/android/demo/inline/Foo;I)V
    flags: ACC_STATIC, ACC_SYNTHETIC
    Code:
      stack=2, locals=2, args_size=2
         0: aload_0
         1: iload_1
         2: invokespecial #1                  // Method doStuff:(I)V
         5: return

如上面的字节码所示,它的特征非常明显,因为是编译生成的方法,它有 synthetic 标记,并且是静态方法,方法名字以”access$”开头,通过这些特征在 ClassVisitor visitMethod 时就很容易匹配到相关方法。

  1. 分析并记录 access 方法调用处要替换的目标指令。

access 桥接的访问只有字段和方法两种,相对应的指令是方法访问指令(invokvirtual, invokspecial 等)和字段访问指令(getfield, putfield 等) ,只需遍历方法找到相应的指令,同时解析出指令访问的字段或方法信息,然后再将对应的 private 成员改为 public。比如 access$000 方法会找到如下指令,访问的字段是类 Foo 的 mValue。

1
less复制代码getfield      #2                  // Field mValue:I
  1. 替换 access 方法调用处的 invokestatic 为对应的目标指令,并删除 access 方法的定义。

遍历查找所有对 access 方法的调用点,如下面的 invokestatic 指令,其调用方法在我们第一步收集的 access 方法中,将它替换为 getfield,然后便可以删除 Foo.access$000 方法本身。

1
css复制代码invokestatic  #3                  // Method com/bytedance/android/demo/inline/Foo.access$000:(Lcom/bytedance/android/demo/inline/Foo;)I

getter-setter 内联

封装是面向对象编程(OOP)的基本特性之一,使用 getter 和 setter 方法是在程序设计中常见的封装方法之一。在日常开发中,我们常常会为一些类写一些 getter-setter 方法,如下代码所示:

1
2
3
4
5
6
7
8
9
csharp复制代码public class People {
    private int age;
    public int getAge() {
        return this.age;
    }
    public void setAge(int age) {
        this.age = age;
    }
}

这些方法完全就是短方法内联的最佳 case。

优化思路

getter-setter 内联整体实现和 access 方法大同小异,整体也分为收集、分析和删除三步。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
arduino复制代码public int getAge();
    descriptor: ()I
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: getfield      #2                  // Field age:I
         4: ireturn

public void setAge(int);
    descriptor: (I)V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=2, args_size=2
         0: aload_0
         1: iload_1
         2: putfield      #2                  // Field age:I
         5: return
  1. 收集代码中要内联的 getter-setter 方法信息。参考上面的字节码指令,主要是找出只有参数入栈(LOAD 类指令)、字段访问(GETFIELD, PUTFIELD)、RETURN 指令 的方法。这里需要注意的是要过滤被 proguard 规则 keep 的方法,这些删除风险很大,因为可能会有插件内调用或者反射调用。
  2. 记录每个方法访问字段的指令以及目标字段,如果字段访问权限是非 public 的话,修改成 public 的。
  3. 针对调用 getter-setter 的方法的地方,直接替换为相应的字段访问指令,并删除 getter-setter 的方法的定义。

为什么不用 Proguard

Proguard 除了混淆、shrink 无用代码之外,也会对代码进行诸多的优化,其中就包括短方法内联,唯一方法内联等。那我们的 App 为什么没有直接使用呢?主要还是因为使用了 robust 热修,auto-patch 对内联层级过高以及像 builder 方法这种情况支持的不好,会导致 Patch 生成失败。但是 access 方法、getter-setter 方法本身很短,至多也就有一层内联层级,不会影响 Patch 的生成,proguard 又无法配置哪些方法内联,因此我们打算自己来实现。

抖音上两个短方法内联减少定义方法数 7 万+,DEX 文件减少一个,包体积收益达到了 1.7M。

常量字段消除

上面短方法内联是将方法内容展开到调用处去,我们代码中的一些常量也类似,可以将常量值替换使用处,从而减少字段的声明,这种优化就是常量字段消除的最简单表现。

我们知道 javac 会做一些 final 类型变量的常量字段消除优化,比如下面的代码:

1
2
3
4
5
6
7
8
9
arduino复制代码public class ConstJava {
    public static final int INTEGER = 1024;
    public static final String STRING = "this is long  str";

    public static void constPropagation() {
        System.out.println("integer:" + INTEGER);
        System.out.println("string:" + STRING);
    }
}

在编译之后 constPropagation 方法就会变成如下内容,常量直接替换成了字面值,这样相应的 final 字段就变成了无用字段,proguard 就可以将其 shrink 掉。

1
2
3
4
csharp复制代码public static void constPropagation() {
    System.out.println("integer:1024");
    System.out.println("string:this is long  str");
}

但是比如下面的一些一些 kotlin 代码,编译之后如下, 并未进行传播优化。当然这里如果添加 const 关键字修改,对应地会进行优化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
kotlin复制代码class ConstKotlin {
    companion object {
        val INTEGER = 1024
        val STRING = "this is long str"
    }

    private val b = 6

    fun constPropagation(){
        println("a:$INTEGER")
        println("s:$STRING")
    }

}

编译后代码:

1
2
3
4
5
6
7
8
9
10
ini复制代码private static final int INTEGER = 1024;
@NotNull
private static final String STRING = "this is long str";

public final void constPropagation() {
   String var1 = "a:" + INTEGER;
   System.out.println(var1);
   var1 = "s:" + STRING;
   System.out.println(var1);
}

因此我们可以针对这种 case 进行优化。

另外我们上面说常量字段消除优化之后,对应的字段声明就可以被 proguard 删除,但是项目中有很多 keep 过度的情况,比如下面的规则会导致常量字段声明被保留,这种情况我们可以将字段删除。

1
kotlin复制代码-keep class com.bytedance.android.demo.ConstJava{*;}

优化思路

  1. 收集 static final 类型的变量,并记录其字面值,这里需要排除一些特殊的字段,然后最终确定能删除的字段。需要排除的字段主要有下面两种:
  • 用来表示序列化对象版本的 serialVersionUID 字段;
  • 有反射使用到的字段,一般来说不太会有反射访问 final 类型变量的情况,但这里还是会尝试分析代码中对字段的反射调用,如果有对应的访问则保留。
  1. 针对代码中 getstatic 指令的访问,分析其访问的字段,如果在第一步收集到的字段中,就把对应的指令改为 l 对应的常量入栈指令,并删除对应的字段。如下为对 INTEGER 的访 getstatic 指令,其在第一步收集到的 final 类型变量中,字面值为 1。
1
less复制代码getstatic     #48                 // Field STRING:Ljava/lang/String;

修改为 ldc 指令:

1
vbnet复制代码ldc           #25                 // String s:this is long str

这里些同学会有疑问,比如一个大的字符串传播到多个类里面不是反而会增大包体积么?

的确存在这种可能,不过由于一个 Dex 中所有的类共用一个常量池,所以传播过去如果两个类在同一个 Dex 文件中的话是不会有负向的,反之则会有负向。

常量字段消除优化总体带来 400KB 左右的包体收益。

R.class 常量内联

常量字段消除优化的是常规的 final static 类型,但在我们的代码中,还有另一种类型的常量也可以内联优化。

在我们 Android 的开发中,常常会用到 R 这个类,它是我们使用资源的最平常的方式。但实际上,R 文件的生成有着许多不合理的地方,对我们的性能和包大小都造成了极大的影响。但是要理解这个问题,首先我们需要再理解一次 R 文件是什么。

我们在平时的代码开发中,常常会写出以下平常的代码:

1
2
3
4
5
6
7
8
9
10
less复制代码public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        // 此处我们使用R中的id来获取MainActivity的layout资源
        setContentView(R.layout.activity_main);
    }
}

我们在该例中使用R.layout.activity_main来获取了 MainActivity 的 layout 资源,那我们将其转化为字节码会是如何呢?这需要分两种情况讨论:

  • 当 MainActivity 在 application module 下时,其字节码为:
1
2
3
4
5
6
7
8
9
arduino复制代码protected void onCreate(android.os.Bundle);
    Code:
         0: aload_0
         1: aload_1
         2: invokespecial #2                  // Method android/support/v7/app/AppCompatActivity.onCreate:(Landroid/os/Bundle;)V
         5: aload_0
         6: ldc           #4                  // int 2131296285
         8: invokevirtual #5                  // Method setContentView:(I)V
        11: return

可以看到使用R.layout.activity_main直接被替换成了常量。

  • 然而,当 MainActivity 在 library module 下时,其字节码为:
1
2
3
4
5
6
7
8
9
arduino复制代码protected void onCreate(android.os.Bundle);
    Code:
         0: aload_0
         1: aload_1
         2: invokespecial #2                  // Method android/support/v7/app/AppCompatActivity.onCreate:(Landroid/os/Bundle;)V
         5: aload_0
         6: getstatic     #3                  // Field com/bytedance/android/R$layout.activity_main:I
         9: invokevirtual #4                  // Method setContentView:(I)V
        12: return

可以看到其从使用 ldc 指令导入常量,变成了使用 getstatic 指令访问 R$layout 的 activity_main 域。

为什么会出现差别

我们知道,library module 在提供给 application module 的时候一般是通过 aar 的形式提供的,因此为了在 library module 打包时,javac 能够编译通过,AGP 默认会给 library module 提供一个临时的 R.java 文件(最终不会打入 library module 的包中),并且为了防止被 javac 内联,会将 R 中 field 的修饰符限定为public static,这样就使得 R 的域都不为常量,最终逃过 javac 内联保留到了 application module 的编译中。

为什么 library module 不内联

在 Android 中,我们每个资源 id 都是唯一的,因此我们在打包的时候需要保证不会出现重复 id 的资源。如果我们在 library module 就已经指定了资源 id,那我们就和容易和其他 library module 出现资源 id 的冲突。因此 AGP 提供了一种方案,在 library module 编译时,使用资源 id 的地方仍然采用访问域的方式,并记录使用的资源在 R.txt 中。在 application module 编译时,收集所有 library module 的 R.txt,加上 application module R 文件输入给 aapt,aapt 在获得全局的输入后,按序给每个资源生成唯一不重复的资源 id,从而避免这种冲突。但此时,library module 已经编译完成,因此只能生成 R.java 文件,来满足 library module 的运行时资源获取。

为什么 ProGuard 没有优化

我们在使用 ProGuard 的时候,Google 官方建议我们带上一些 keep 规则,这也是新建 application 默认会生成的模版代码

1
2
3
4
5
arduino复制代码buildTypes {
    release {
        proguardFiles getDefaultProguardFile('proguard-android-optimize.txt')
    }
}

官方给的 keep 规则(android.googlesource.com/platform/sd…%E4%B8%AD%EF%BC%8C%E4%B8%BA%E4%BA%86%E4%BF%9D%E8%AF%81%E8%BF%90%E8%A1%8C%E6%97%B6%E6%AD%A3%E7%A1%AE%EF%BC%88%E5%A6%82%E9%81%BF%E5%85%8D%E7%A8%8B%E5%BA%8F%E8%BF%90%E8%A1%8C%E6%97%B6%E5%8F%8D%E5%B0%84%E8%8E%B7%E5%8F%96) R class 的字段),所以加了下面这条规则:

1
2
3
arduino复制代码-keepclassmembers class **.R$* {
    public static <fields>;
}

该 keep 规则的作用是,将所有 R 以及 R 内部类的以 public static 修饰的域保留,使其不被优化。因此在我们最终的 APK 中,R.class 仍然存在,这造成了我们包体积的膨胀。

实际上,造成我们包体积膨胀的原因不止 R 的域的定义和赋值,在 Android 中,一个 DEX 可放置的 field 的数量上限固定是 65536,超过这个限制则我们需要将一个 DEX 拆分为两个。多个 DEX 会导致 DEX 中的复用数据变少,从而进一步提升了包体积的膨胀。因此我们对于 R 的优化,在 DEX 层面上也会有很大的收益。

解决方法

了解问题根源后,解决方案也十分简单。既然 R.class 中各个域的值确认后就不再改变,那我们完全可以将通过 R 获取资源 id 的调用处内联,并删除对应的域,来获取收益。

优化思路大概如下:

  1. 遍历所有的方法,定位所有的getstatic指令
  2. 如果该getstatic指令的目标 Class name 的为**.R 或者**.R$* 形式的 Class

a. 如果 getstatic指令的目标 Field 为public static int类型,则使用 ldc指令将getstatic替换,直接将 Field 的实际值导入;

b. 如果getstatic指令的目标 Field 为public static int[]类型,则使用newarray指令将getstatic替换,将<clinit>中 Field 的数组赋值导入。
3. 遍历完成后,判断 R.class 中的是否所有域都被删除,如果全部被删除,则将该 R.class 也移除。

我们使用前文的 case 来说明如下:

1
2
3
4
5
6
7
8
9
10
11
12
less复制代码protected void onCreate(android.os.Bundle);
    Code:
         0: aload_0
         1: aload_1
         2: invokespecial #2                  // Method android/support/v7/app/AppCompatActivity.onCreate:(Landroid/os/Bundle;)V
         5: aload_0
        // 判断是R.class的Field调用,使用ldc替换
         6: getstatic     #3                  // Field com/bytedance/android/R$layout.activity_main:I
         6: ldc           #4                  // int 2131296285

         8: invokevirtual #5                  // Method setContentView:(I)V
        11: return

实际上,我们并不是所有 id 都能内联,如果我们运行时通过反射 R.class 来获取某些指定名字的资源时,如果我们将其内联了,会导致运行时找不到 id 的异常。为了防止这种情况的发生,我们可以在方案中增加一个白名单的概念,在白名单中的域将不会被内联,对应的,方案中的步骤 2,需要修改为

  1. 如果该getstatic指令的目标 Class name 的为**.R 或者**.R$* 形式的 Class

a. 如果getstatic指令的目标 Field 在白名单中,则跳过;

b. 如果getstatic指令的目标 Field 为public static int类型,则使用ldc指令将getstatic替换,直接将 Field 的实际值导入;

c. 如果getstatic指令的目标 Field 为public static int[]类型,则使用newarray指令将getstatic替换,将<clinit>中 Field 的数组赋值导入。

抖音上线此优化后减少包体积约 30.5M。抖音能产生这么大的收益是因为抖音的 R 十分巨大,包含的 field 非常多,同时由于单个 DEX 能定义的 field 最多为 65536 个,如果不做精简则会导致 DEX 数量的剧增,从而出现 DEX 总体积暴涨的情况。

小结

今天我们介绍的这些优化可以大幅减少 DEX 包体积,很大地促进抖音的用户增长,同时也可以优化启动时虚拟机对 DEX 加载耗时。不过这些只是抖音在字节码方面所做冰山一角,本文介绍的所有方案的实现代码,都在我们之前开源的字节码修改工具 ByteX 里:

  • github.com/bytedance/B…

当然,DEX 相关的优化还有很多。比如我们对 Kotlin 的代码生成也进行了优化,在 Kotlin 流行的今天,也拿到了较大的收益;同时对于 DEX 本身格式和内容的优化,在抖音也落地了很多技术含量较高的方案。这里受限于篇幅就不再详述。

在本系列后续的文章中,我们还将继续从 DEX、资源、SO、业务治理几个大方面深入讲解抖音上我们包体积相关的技术探索,尽情期待。

加入我们

抖音Android基础技术团队是一个深度追求极致的团队,我们专注于性能、架构、包大小、稳定性、基础库、编译构建等方向的深耕,保障超大规模团队的研发效率和数亿用户的使用体验。目前北京、上海、杭州、深圳都有大量人才需要,欢迎有志之士与我们共同建设亿级用户全球化APP!

可以点击以下链接,进入字节跳动招聘官网查询「抖音基础技术 Android」相关职位:

【北京、上海】
job.toutiao.com/referral/mo…

【杭州】
job.toutiao.com/s/8V74RjJ

也可以邮件联系:xiaolin.gan@bytedance.com 或者 weifutan@bytedance.com 咨询相关信息或者直接发送简历内推!

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Threejs 实现虎年春节3D创意页面

发表于 2022-01-11

PK创意闹新春,我正在参加「春节创意投稿大赛」,详情请看:春节创意投稿大赛

背景

虎年 🐅 春节将至,本文使用 React + Three.js 技术栈,实现趣味 3D 创意页面。本文包含的知识点主要包括:ShadowMaterial、 MeshPhongMaterial 两种基本材质的使用、使用 LoadingManager 展示模型加载进度、OrbitControls 的缓动动画、TWEEN 简单补间动画效果等。

实现

👀 在线预览,已适配移动端:dragonir.github.io/3d/#/lunar

引入资源

其中 GLTFLoader、FBXLoader 用于加在模型、OrbitControls 用户镜头轨道控制、TWEEN 用于生成补间动画。

1
2
3
4
5
js复制代码import * as THREE from "three";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader";
import { FBXLoader } from "three/examples/jsm/loaders/FBXLoader";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";
import { TWEEN } from "three/examples/jsm/libs/tween.module.min.js";

场景初始化

这部分内容主要用于初始化场景和参数,详细讲解可点击文章末尾链接阅读我之前的文章,本文不再赘述。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
js复制代码container = document.getElementById('container');
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
container.appendChild(renderer.domElement);
// 场景
scene = new THREE.Scene();
scene.background = new THREE.TextureLoader().load(bgTexture);
// 雾化效果
scene.fog = new THREE.Fog(0xdddddd, 100, 120);
// 摄像机
camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(100, 100, 100);
camera.lookAt(new THREE.Vector3(0, 0, 0));
// 平行光
const cube = new THREE.Mesh(new THREE.BoxGeometry(0.001, 0.001, 0.001), new THREE.MeshLambertMaterial({ color: 0xdc161a }));
cube.position.set(0, 0, 0);
light = new THREE.DirectionalLight(0xffffff, 1);
light.position.set(20, 20, 8);
light.target = cube;
scene.add(light);
// 环境光
const ambientLight = new THREE.AmbientLight(0xffffff);
scene.add(ambientLight);
// 聚光灯
const spotLight = new THREE.SpotLight(0xffffff);
spotLight.position.set(-20, 20, -2);
scene.add(spotLight);

💡 Fog 场景雾化

本例中,打开页面时模型由远及近加载,颜色由白色变为彩色的功能就是通过 Fog 实现的。Fog 类定义的是线性雾,雾的密度是随着距离线性增大的,即场景中物体雾化效果随着随距离线性变化。

构造函数:Fog(color, near, far)。

  • color 属性: 表示雾的颜色,比如设置为红色,场景中远处物体为黑色,场景中最近处距离物体是自身颜色,最远和最近之间的物体颜色是物体本身颜色和雾颜色的混合效果。
  • near 属性:表示应用雾化效果的最小距离,距离活动摄像机长度小于 near 的物体将不会被雾所影响。
  • far 属性:表示应用雾化效果的最大距离,距离活动摄像机长度大于 far 的物体将不会被雾所影响。

创建地面

本例中使用了背景图,我需要一个既能呈现透明显示背景、又能产生阴影的材质生成地面,于是使用到 ShadowMaterial 材质。

1
2
3
4
5
6
7
js复制代码var planeGeometry = new THREE.PlaneGeometry(100, 100);
var planeMaterial = new THREE.ShadowMaterial({ opacity: .5 });
var plane = new THREE.Mesh(planeGeometry, planeMaterial);
plane.rotation.x = -0.5 * Math.PI;
plane.position.set(0, -8, 0);
plane.receiveShadow = true;
scene.add(plane);

💡 ShadowMaterial 阴影材质

此材质可以接收阴影,但在其他方面完全透明。

构造函数: ShadowMaterial(parameters: Object)

  • parameters:(可选)用于定义材质外观的对象,具有一个或多个属性。

特殊属性:

  • .isShadowMaterial[Boolean]:用于检查此类或派生类是否为阴影材质。默认值为 true。因为其通常用在内部优化,所以不应该更改该属性值。
  • .transparent[Boolean]:定义此材质是否透明。默认值为 true。

创建魔法阵

在老虎 🐅 底部地面创建一个炫酷的旋转自发光圆形魔法阵。

1
2
3
4
5
6
7
8
js复制代码cycle = new THREE.Mesh(new THREE.PlaneGeometry(40, 40), new THREE.MeshPhongMaterial({
map: new THREE.TextureLoader().load(cycleTexture),
transparent: true
}));
cycle.rotation.x = -0.5 * Math.PI;
cycle.position.set(0, -9, 0);
cycle.receiveShadow = true;
scene.add(cycle);

魔法阵的贴图:

💡 MeshPhongMaterial 网格材质

一种用于具有镜面高光的光泽表面的材质。该材质使用非物理的 Blinn-Phong 模型来计算反射率。

构造函数:MeshPhongMaterial(parameters: Object)

  • parameters:(可选)用于定义材质外观的对象,具有一个或多个属性。

特殊属性:

  • .emissive[Color]:材质的放射(光)颜色,基本上是不受其他光照影响的固有颜色。默认为黑色。
  • .emissiveMap[Texture]:设置放射(发光)贴图。默认值为 null。放射贴图颜色由放射颜色和强度所调节。 如果你有一个放射贴图,请务必将放射颜色设置为黑色以外的其他颜色。
  • .emissiveIntensity[Float]:放射光强度。调节发光颜色。默认为 1。
  • .shininess[Float]:specular 高亮的程度,越高的值越闪亮。默认值为 30。
  • .specular[Color]:材质的高光颜色。默认值为 0x111111 的颜色 Color。这定义了材质的光泽度和光泽的颜色。
  • .specularMap[Texture]:镜面反射贴图值会影响镜面高光以及环境贴图对表面的影响程度。默认值为 null。

与 MeshLambertMaterial 中使用的 Lambertian 模型不同,该材质可以模拟具有镜面高光的光泽表面(例如涂漆木材)。使用 Phong 着色模型计算着色时,会计算每个像素的阴影,与 MeshLambertMaterial 使用的 Gouraud 模型相比,该模型的结果更准确,但代价是牺牲一些性能。
MeshStandardMaterial 和 MeshPhysicalMaterial 也使用这个着色模型。在 MeshStandardMaterial 或 MeshPhysicalMaterial 上使用此材质时,性能通常会更高 ,但会牺牲一些图形精度。

文字模型

使用 FBXLoader 来加载恭喜发财,岁岁平安字样的 3D 文字模型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
js复制代码const fbxLoader = new FBXLoader();
fbxLoader.load(textModel, mesh => {
mesh.traverse(child => {
if (child.isMesh) {
meshes.push(mesh);
child.castShadow = true;
child.receiveShadow = true;
// 调节材质的金属度、粗糙度、颜色等样式
child.material.metalness = .2;
child.material.roughness = .8;
child.material.color = new THREE.Color(0x111111);
}
});
mesh.position.set(4, 6, -8);
mesh.rotation.set(-80, 0, 0);
mesh.scale.set(.32, .32, .32);
group.add(mesh);
});

📹 哔哩哔哩 3D 文字生成教程传送门:iBlender中文版插件 老外教你用汉字中文字体 Font 3D Chinese And Japanese Characters Blender 插件教程

老虎模型

老虎模型是 gltf 格式,在使用 GLTFLoader 加载模型的过程中,发现有 🕷 bug,loader 无法读取到模型体积的 total 值,于是使用通用加载器 LoadingManager 来管理模型加载进度。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
js复制代码const manager = new THREE.LoadingManager();
manager.onStart = (url, loaded, total) => {};
manager.onLoad = () => {};
manager.onProgress = async(url, loaded, total) => {
if (Math.floor(loaded / total * 100) === 100) {
this.setState({ loadingProcess: Math.floor(loaded / total * 100) });
} else {
this.setState({ loadingProcess: Math.floor(loaded / total * 100) });
}
};
const gltfLoader = new GLTFLoader(manager);
gltfLoader.load(tigerModel, mesh => {
mesh.scene.traverse(child => {
if (child.isMesh) {
child.castShadow = true;
child.material.metalness = 0;
child.material.roughness = .8;
child.material.transparent = true;
child.material.side = THREE.DoubleSide;
child.material.color = new THREE.Color(0xffffff);
}
});
mesh.scene.rotation.y = Math.PI * 9 / 8;
mesh.scene.position.set(0, -4, 2);
mesh.scene.scale.set(.75, .75, .75);
// 💡 加载模型自身动画
let meshAnimation = mesh.animations[0];
mixer = new THREE.AnimationMixer(mesh.scene);
let animationClip = meshAnimation;
let clipAction = mixer.clipAction(animationClip).play();
animationClip = clipAction.getClip();
group.add(mesh.scene);
scene.add(group)
});

💡 LoadingManager 加载器管理器

它的功能是处理并跟踪已加载和待处理的数据。如果未手动设置加强管理器,则会为加载器创建和使用默认全局实例加载器管理器。一般来说,默认的加载管理器已足够使用了,但有时候也需要设置单独的加载器,比如,你想为对象和纹理显示单独的加载条时。

构造方法:LoadingManager(onLoad: Function, onProgress: Function, onError: Function)

  • onLoad:可选,所有加载器加载完成后,将调用此函数。
  • onProgress:可选,当每个项目完成后,将调用此函数。
  • onError:可选,当一个加载器遇到错误时,将调用此函数。

属性:

  • .onStart[Function]:加载开始时被调用。参数: url 被加载的项的url;itemsLoaded 目前已加载项的个数;itemsTotal 总共所需要加载项的个数。此方法默认未定义。
  • .onLoad[Function]:所有的项加载完成后将调用此函数。默认情况下,此方法时未定义的,除非在构造函数中进行传递。
  • .onProgress[Function]:此方法加载每一个项,加载完成时进行调用。参数:url 被加载的项的 url;itemsLoaded 目前已加载项的个数;itemsTotal 总共所需要加载项的个数。默认情况下,此方法时未定义的,除非在构造函数中进行传递。
  • .onError[Function]:此方法将在任意项加载错误时调用。参数:url 所加载出错误的项的 url。默认情况下,此方法时未定义的,除非在构造函数中进行传递。

添加镜头移动补间动画

模型加载完成后,通过结合使用 TWEEN.js 实现相机 📷 移动实现漫游,也就是打开页面时看到的模型由远及近逐渐变大的动画效果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
js复制代码const Animations = {
animateCamera: (camera, controls, newP, newT, time = 2000, callBack) => {
var tween = new TWEEN.Tween({
x1: camera.position.x,
y1: camera.position.y,
z1: camera.position.z,
x2: controls.target.x,
y2: controls.target.y,
z2: controls.target.z,
});
tween.to({
x1: newP.x,
y1: newP.y,
z1: newP.z,
x2: newT.x,
y2: newT.y,
z2: newT.z,
}, time);
tween.onUpdate(function (object) {
camera.position.x = object.x1;
camera.position.y = object.y1;
camera.position.z = object.z1;
controls.target.x = object.x2;
controls.target.y = object.y2;
controls.target.z = object.z2;
controls.update();
});
tween.onComplete(function () {
controls.enabled = true;
callBack();
});
tween.easing(TWEEN.Easing.Cubic.InOut);
tween.start();
},
}
export default Animations;

调用示例:

1
js复制代码Animations.animateCamera(camera, controls, { x: 0, y: 5, z: 21 }, { x: 0, y: 0, z: 0 }, 2400, () => {});

💡 TWEEN.js

是一个补间动画库,可以实现很多动画效果。它使一个对象在一定时间内从一个状态缓动变化到另外一个状态。TWEEN.js 本质就是一系列缓动函数算法,结合Canvas、Three.js 很简单就能实现很多效果。

基本使用:

1
2
3
4
5
6
7
js复制代码var tween = new TWEEN.Tween({x: 1})     // position: {x: 1}
.delay(100) // 等待100ms
.to({x: 200}, 1000) // 1s时间,x到200
.onUpdate(render) // 变更期间执行render方法
.onComplete(() => {}) // 动画完成
.onStop(() => {}) // 动画停止
.start(); // 开启动画

📌 要让动画真正动起来,需要在 requestAnimationFrame 中调用 update 方法。

1
js复制代码TWEEN.update()

缓动类型:

TWEEN.js 最强大的地方在于提供了很多常用的缓动动画类型,由 api easing() 指定。如示例中用到的:

1
js复制代码tween.easing(TWEEN.Easing.Cubic.InOut);

链式调用:

TWEEN.js 支持链式调用,如在 动画A 结束后要执行 动画B,可以这样 tweenA.chain(tweenB) 利用链式调用创建往复来回循环的动画:

1
2
3
4
5
js复制代码var tweenA = new TWEEN.Tween(position).to({x: 200}, 1000);
var tweenB = new TWEEN.Tween(position).to({x: 0}, 1000);
tweenA.chain(tweenB);
tweenB.chain(tweenA);
tweenA.start();

控制器缓动移动

controls.enableDamping 设置为 true 可以开启鼠标移动场景时的缓动效果,产生运动惯性,开启后 3D 更具真实感。

1
2
3
4
js复制代码controls = new OrbitControls(camera, renderer.domElement);
controls.target.set(0, 0, 0);
controls.enableDamping = true;
controls.maxDistance = 160;

💡 THREE.OrbitControls 参数控制一览

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
js复制代码//鼠标控制是否可用
controls.enabled = true;
//聚焦坐标
controls.target = new THREE.Vector3();
//最大最小相机移动距离(PerspectiveCamera 景深相机)
controls.minDistance = 0;
controls.maxDistance = Infinity;
//最大最小鼠标缩放大小(OrthographicCamera正交相机)
controls.minZoom = 0;
controls.maxZoom = Infinity;
//最大仰视角和俯视角,范围是0到Math.PI
controls.minPolarAngle = 0;
controls.maxPolarAngle = Math.PI;
//水平方向视角限制,范围[-Math.PI, Math.PI]
controls.minAzimuthAngle = - Infinity;
controls.maxAzimuthAngle = Infinity;
//惯性滑动,滑动大小默认0.25,若开启,那么controls.update()需要加到动画循环函数中
controls.enableDamping = false;
controls.dampingFactor = 0.25;
//滚轮是否可控制zoom,zoom速度默认1
controls.enableZoom = true;
controls.zoomSpeed = 1.0;
//是否可旋转,旋转速度
controls.enableRotate = true;
controls.rotateSpeed = 1.0;
//是否可平移,默认移动速度为7px
controls.enablePan = true;
// 点击箭头键时移动的像素值
controls.keyPanSpeed = 7.0;
//是否自动旋转,自动旋转速度。默认每秒30圈,如果是enabled,那么controls.update()需要加到动画循环函数中
controls.autoRotate = false;
// 当fps为60时每转30s
controls.autoRotateSpeed = 2.0;
//是否能使用键盘
controls.enableKeys = true;
//默认键盘控制上下左右的键
controls.keys = { LEFT: 37, UP: 38, RIGHT: 39, BOTTOM: 40 };
//鼠标点击按钮
controls.mouseButtons = { ORBIT: THREE.MOUSE.LEFT, ZOOM: THREE.MOUSE.MIDDLE, PAN: THREE.MOUSE.RIGHT };

最后不要忘记添加窗口缩放适配方法和 requestAnimationFrame 更新方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
js复制代码function onWindowResize() {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}
function animate() {
requestAnimationFrame(animate);
renderer.render(scene, camera);
let time = clock.getDelta();
// 老虎动画
mixer && mixer.update(time);
// 补间动画
TWEEN && TWEEN.update();
// 控制器
controls && controls.update();
// 魔法阵
cycle && (cycle.rotation.z += .01);
}

Loading 页3D文字样式

3D 文字样式主要通过叠加多层 text-shadow 实现的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
css复制代码.loading {
font-size: 64px;
color: #FFFFFF;
text-shadow: 0 1px 0 hsl(174,5%,80%),
0 2px 0 hsl(174,5%,75%),
0 3px 0 hsl(174,5%,70%),
0 4px 0 hsl(174,5%,66%),
0 5px 0 hsl(174,5%,64%),
0 6px 0 hsl(174,5%,62%),
0 7px 0 hsl(174,5%,61%),
0 8px 0 hsl(174,5%,60%),
0 0 5px rgba(0,0,0,.05),
0 1px 3px rgba(0,0,0,.2),
0 3px 5px rgba(0,0,0,.2),
0 5px 10px rgba(0,0,0,.2),
0 10px 10px rgba(0,0,0,.2),
0 20px 20px rgba(0,0,0,.3);
}

效果

最终实现效果如下图所示,大家感兴趣可在线预览,已适配移动端。被这张加速的 小脑斧🐅 动图笑死。

总结

本文中主要涉及到的知识点包括:

  • Fog 场景雾化
  • ShadowMaterial 阴影材质
  • MeshPhongMaterial 网格材质
  • LoadingManager 加载器管理器
  • TWEEN.js 补间动画
  • THREE.OrbitControls 参数控制
  • CSS 3D 文字样式

附录

想了解场景初始化、光照、阴影及其他网格几何体的相关知识,可阅读我的其他文章。如果觉得文章对你有帮助,不要忘了 一键三连😂。

  • [1]. 使用Three.js实现炫酷的酸性风格3D页面
  • [2]. Three.js 实现脸书元宇宙3D动态Logo
  • [3]. Three.js 实现3D全景侦探小游戏
  • [4]. 模型来源:sketchfab

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

硬核 - Java 随机数相关 API 的演进与思考(上)

发表于 2022-01-10

本系列将 Java 17 之前的随机数 API 以及 Java 17 之后的统一 API 都做了比较详细的说明,并且将随机数的特性以及实现思路也做了一些简单的分析,帮助大家明白为何会有这么多的随机数算法,以及他们的设计思路是什么。

本系列会分为两篇,第一篇讲述 Java 随机数算法的演变思路以及底层原理与考量,之后介绍 Java 17 之前的随机算法 API 以及测试性能,第二篇详细分析 Java 17 之后的随机数生成器算法以及 API 和底层实现类以及他们的属性,性能以及使用场景,如何选择随机算法等等,并对 Java 的随机数对于 Java 的一些未来特性的适用进行展望

这是第一篇。

如何生成随机数

我们一般使用随机数生成器的时候,都认为随机数生成器(Pseudo Random Number Generator, PRNG)是一个黑盒:

image

这个黑盒的产出,一般是一个数字。假设是一个 int 数字。这个结果可以转化成各种我们想要的类型,例如:如果我们想要的的其实是一个 long,那我们可以取两次,其中一次的结果作为高 32 位,另一次结果作为低 32 位,组成一个 long(boolean,byte,short,char 等等同理,取一次,取其中某几位作为结果)。如果我们想要的是一个浮点型数字,那么我们可以根据 IEEE 标准组合多次取随机 int 然后取其中某几位组合成浮点型数字的整数位以及小数位。

如果要限制范围,最简单的方式是将结果取余 + 偏移实现。例如我们想取范围在 1 ~ 100 之间,那么我们就将结果先对 99 取余,然后取绝对值,然后 +1 即可。当然,由于取余操作是一个性能消耗比较高的操作,最简单的优化即检查这个数字 N 与 N-1 取与运算,如果等于 0 即这个书是 2 的 n 次方(2 的 n 次方 2 进制表示一定是 100000 这样的,减去 1 之后 为 011111,取与肯定是 0);对于 2 的 n 次方取余相当于对 2 的 n 次方减一取与运算。这是一个简单的优化, 实际的优化要比这个复杂多。

初始化这个黑盒的时候,一般采用一个 SEED 进行初始化,这个 SEED 的来源可能多种多样,这个我们先按下不表,先来看一些这个黑盒中的一些算法。

image

线性同余算法

首先是最常见的随机数算法:线性同余(Linear Congruential Generator)。即根据当前 Seed 乘以一个系数 A,然后加上一个偏移 B,最后按照 C 进行取余(限制整体在一定范围内,这样才能选择出合适的 A 和 B,为什么要这么做后面会说),得出随机数,然后这个随机数作为下次随机的种子,即:

1
scss复制代码X(n+1) = ( A * X(n) + B ) % C

这种算法的优势在于,实现简单,并且性能算是比较好的。 A,B 取值必须精挑细算,让在 C 范围内的所有数字都是等可能的出现的。例如一个极端的例子就是 A = 2, B = 2, C = 10,那么 1,3,5,7,9 这些奇数在后续都不可能出现。为了能计算出一个合适的 A 和 B,要限制 C 在一个比较可控的范围内。一般为了计算效率,将 C 限制为 2 的 n 次方。这样取余运算就可以优化为取与运算。不过好在,数学大师们已经将这些值(也就是魔法数)找到了,我们直接用就好了。

这种算法生成的随机序列,是确定的,例如 X 下一个是 Y, Y 下一个是 Z,这可以理解成一个确定环(loop)。
image。

这个环的大小,即 Period。由于 Period 足够大,初始 SEED 一般也是每次不一样的,这样近似做到了随机。但是,假设我们需要多个随机数生成器的时候,就比较麻烦了,因为我们虽然能保证每个随机生成器的初始 SEED 不一样,但是在这种算法下,无法保证某个随机数生成器的初始 SEED 就是另一个随机数生成器初始 SEED 的下一个(或者很短步骤内的) SEED。举个例子,假设某个随机数生成器的初始 SEED 是 X,另一个是 Z,虽然 X 和 Z 可能看上去差距很大,但是他们在这个算法的随机序列中仅隔了一个 Y。这样的不同的随机数生成器,效果不好。

那么如何能保证不同的随机数生成器之间间隔比较大呢?也就是,我们能通过简单计算(而不是计算 100w 次从而调到 100w 次之后的随机数)直接使另一个随机数生成器的初始 SEED 与当前这个的初始 SEED,间隔一个比较大的数,这种性质叫做可跳跃性。 基于线性反馈移位寄存器算法的 Xoshiro 算法给我们提供了一种可跳跃的随机数算法。

线性反馈移位寄存器算法

线性反馈移位寄存器(Linear feedback shift register,LFSR)是指给定前一状态的输出,将该输出的线性函数再用作输入的移位寄存器。异或运算是最常见的单比特线性函数:对寄存器的某些位进行异或操作后作为输入,再对寄存器中的每个 bit 进行整体移位。

但是如何选择这些 Bit,是一门学问,目前比较常见的实现是 XorShift 算法以及在此基础上进一步优化的
Xoshiro 的相关算法。Xoshiro 算法是一种比较新的优化随机数算法,计算很简单并且性能优异。同时实现了可跳跃性。

这种算法是可跳跃的。假设我们要生成两个差距比较大的随机数生成器,我们可以使用一个随机初始 SEED 创建一个随机数生成器,然后利用算法的跳跃操作,直接生成一个间隔比较大的 SEED 作为另一个随机数生成器的初始 SEED。

image

还有一点比较有意思的是,线性同余算法并不可逆,我们只能通过 X(n) 推出 X(n + 1),而不能根据 X(n + 1) 直接推出 X(n)。这个操作对应的业务例如随机播放歌单,上一首下一首,我们不需要记录整个歌单,而是仅根据当前的随机数就能知道。线性反馈移位寄存器算法能实现可逆。

线性反馈移位寄存器算法在生成不同的随机序列生成器也有局限性,即它们还是来自于同一个环,即使通过跳跃操作让不同的随机数生成器都间隔开了,但是如果压力不够均衡,随着时间的推移,它们还是有可能 SEED,又变成一样的了。那么有没有那种能生成不同随机序列环的随机算法呢?

DotMix 算法

DotMix 算法提供了另一种思路,即给定一个初始 SEED,设置一个固定步长 M,每次随机,将这个 SEED 加上步长 M,经过一个 HASH 函数,将这个值散列映射到一个 HASH 值:

1
scss复制代码X(n+1) = HASH(X(n) + M)

这个算法对于 HASH 算法的要求比较高,重点要求 HASH 算法针对输入的一点改变则造成输出大幅度改变。基于 DotMix 算法的 SplitMix 算法使用的即 MurMurHash3 算法,这个即 Java 8 引入的 SplittableRandom 的底层原理。

这种算法好在,我们很容易能明确两个不同参数的随机生成器他们的生成序列是不同的,例如一个生成的随机序列是 1,4,3,7,… 另一个生成的是 1,5,3,2。这点正是线性同余算法无法做到的,他的序列无论怎么修改 SEED 也是确定的,而我们有不能随意更改算法中的 A、B、C 的值,因为可能会导致无法遍历到所有数字,这点之前已经说过了。Xoshiro 也是同理。而 SplitMix 算法不用担心,我们指定不同的 SEED 以及不同的步长 M 就可以保证生成的序列是不同的。这种可以生成不同序列的性质,称为可拆分性

image

这也是 SplittableRandom 比 Random (Random 基于线性同余)更适合多线程的原因:

  • 假设多线程使用同一个 Random,保证了序列的随机性,但是有 CompareAndSet 新 seed 的性能损失。
  • 假设每个线程使用 SEED 相同的 Random,则每个线程生成的随机序列相同。
  • 假设每个线程使用 SEED 不相同的 Random,但是我们不能保证一个 Random 的 SEED 是否是另一个 Random SEED 的下一个结果(或者是很短步长以内的结果),这种情况下如果线程压力不均匀(线程池在比较闲的时候,其实只有一部分线程在工作,这些线程很可能他们私有的 Random 来到和其他线程同一个 SEED 的位置),某些线程也会有相同的随机序列。

使用 SplittableRandom 只要直接使用接口 split 就能给不同线程分配一个参数不同的 SplittableRandom ,并且参数不同基本就可以保证生成不了相同序列。

思考:我们如何生成 Period 大于生成数字容量的随机序列呢?

最简单的做法,我们将两个 Period 等于容量的序列通过轮询合并在一起,这样就得到了 Period = 容量 + 容量 的序列:

image

我们还可以直接记录两个序列的结果,然后将两个序列的结果用某种运算,例如异或或者散列操作拼到一起。这样,Period = 容量 * 容量。

如果我们想扩展更多,都可以通过以上办法拼接。用一定的操作拼接不同算法的序列,我们可以得到每种算法的随机优势。 Java 17 引入的 LXM 算法就是一个例子。

LXM 算法

这是在 Java 17 中引入的算法 LXM 算法(L 即线性同余,X 即 Xoshiro,M 即 MurMurHash)的实现较为简单,结合线性同余算法和 Xoshiro 算法,之后通过 MurMurHash 散列,例如:

  • L34X64M:即使用一个 32 位的数字保存线性同余的结果,两个 32 位的数字保存 Xoshiro 算法的结果,使用 MurMurHash 散列合并这些结果到一个 64 位数字。
  • L128X256M:即使用两个 64 位的数字保存线性同余的结果,4 个 64 位的数字保存 Xoshiro 算法的结果,使用 MurMurHash 散列合并这些结果到一个 64 位数字。

LXM 算法通过 MurMurhash 实现了分割性,没有保留 Xoshiro 的跳跃性。

SEED 的来源

由于 JDK 中所有的随机算法都是基于上一次输入的,如果我们使用固定 SEED 那么生成的随机序列也一定是一样的。这样在安全敏感的场景,不够合适,官方对于 cryptographically secure 的定义是,要求 SEED 必须是不可预知的,产生非确定性输出。

在 Linux 中,会采集用户输入,系统中断等系统运行数据,生成随机种子放入池中,程序可以读取这个池子获取一个随机数。但是这个池子是采集一定数据后才会生成,大小有限,并且它的随机分布肯定不够好,所以我们不能直接用它来做随机数,而是用它来做我们的随机数生成器的种子。这个池子在 Linux 中被抽象为两个文件,这两个文件他们分别是:/dev/random 和 /dev/urandom。一个是必须采集一定熵的数据才放开从池子里面取否则阻塞,另一个则是不管是否采集够直接返回现有的。

在 Linux 4.8 之前:

image

在 Linux 4.8 之后:

image

在熵池不够用的时候,file:/dev/random会阻塞,file:/dev/urandom不会。对于我们来说,/dev/urandom 一般就够用,所以一般通过-Djava.security.egd=file:/dev/./urandom设置 JVM 启动参数,使用 urandom 来减少阻塞。

我们也可以通过业务中的一些特性,来定时重新设置所有 Random 的 SEED 来进一步增加被破解的难度,例如,每小时用过去一小时的活跃用户数量 * 下单数量作为新的 SEED。

测试随机算法随机性

以上算法实现的都是伪随机,即当前随机数结果与上一次是强相关的关系。事实上目前基本所有快速的随机算法,都是这样的。

并且就算我们让 SEED 足够隐秘,但是如果我们知道算法,还是可以通过当前的随机输出,推测出下一个随机输出。或者算法未知,但是能从几次随机结果反推出算法从而推出之后的结果。

针对这种伪随机算法,需要验证算法生成的随机数满足一些特性,例如:

  • period 尽可能长:a full cycle 或者 period 指的是随机序列将所有可能的随机结果都遍历过一遍,同时结果回到初始 seed 需要的结果个数。这个 period 要尽可能的长一些。
  • 平均分布(equidistribution),生成的随机数的每个可能结果,在一个 Period 内要尽可能保证每种结果的出现次数是相同的。否则,会影响在某些业务的使用,例如抽奖这种业务,我们需要保证概率要准。
  • 复杂度测试:生成的随机序列是否够复杂,不会有那种有规律的数字序列,例如等比数列,等差数列等等。
  • 安全性测试:很难通过比较少的结果反推出这个随机算法。

目前,已经有很多框架工具用来针对某个算法生成的随机序列进行测试,评价随机序列结果,验证算法的随机性,常用的包括:

  • testU01 随机性测试:github.com/umontreal-s…
  • NIST 随机性测试:nvlpubs.nist.gov/nistpubs/le…
  • DieHarder Suite 随机性测试

Java 中内置的随机算法,基本都通过了 testU01 的大部分测试。目前,上面提到过的优化算法都或多或少的暴露出一些随机性问题。目前, Java 17 中的 LXM 算法是随机性测试中表现最好的。注意是随机性表现,而不是性能。

Java 中涉及到的所有随机算法(不包括 SecureRandom)

image

  • Linear Congruential generator: doi.org/10.1093%2Fc…
  • Linear-feedback shift register: www.ams.org/journals/mc…
  • XORShift: doi.org/10.18637%2F…
  • Xoroshiro128+: arxiv.org/abs/1805.01…
  • LXM: dl.packetstormsecurity.net/papers/gene…
  • SplitMix: gee.cs.oswego.edu/dl/papers/o…

为什么我们在实际业务应用中很少考虑随机安全性问题

主要因为,我们一般做了负载均衡多实例部署,还有多线程。一般每个线程使用不同初始 SEED 的 Random 实例(例如 ThreadLocalRandom)。并且一个随机敏感业务,例如抽奖,单个用户一般都会限制次数,所以很难采集够足够的结果反推出算法以及下一个结果,而且你还需要和其他用户一起抽。然后,我们一般会限制随机数范围,而不是使用原始的随机数,这就更大大增加了反解的难度。最后,我们也可以定时使用业务的一些实时指标定时设置我们的 SEED,例如:,每小时用过去一小时的(活跃用户数量 * 下单数量)作为新的 SEED。

所以,一般现实业务中,我们很少会用 SecureRandom。如果我们想初始 SEED 让编写程序的人也不能猜出来(时间戳也能猜出来),可以指定随机类的初始 SEED 源,通过 JVM 参数 -Djava.util.secureRandomSeed=true。这个对于所有 Java 中的随机数生成器都有效(例如,Random,SplittableRandom,ThreadLocalRandom 等等)

对应源码:

1
2
3
4
5
6
7
8
9
10
11
12
ini复制代码static {
String sec = VM.getSavedProperty("java.util.secureRandomSeed");
if (Boolean.parseBoolean(sec)) {
//初始 SEED 从 SecureRandom 中取
// SecureRandom 的 SEED 源,在 Linux 中即我们前面提到的环境变量 java.security.egd 指定的 /dev/random 或者 /dev/urandom
byte[] seedBytes = java.security.SecureRandom.getSeed(8);
long s = (long)seedBytes[0] & 0xffL;
for (int i = 1; i < 8; ++i)
s = (s << 8) | ((long)seedBytes[i] & 0xffL);
seeder.set(s);
}
}

所以,针对我们的业务,我们一般只关心算法的性能以及随机性中的平均性,而通过测试的算法,一般随机性都没啥大问题,所以我们只主要关心性能即可。

针对安全性敏感的业务,像是 SSL 加密,生成加密随机散列这种,则需要考虑更高的安全随机性。这时候才考虑使用 SecureRandom。SecureRandom 的实现中,随机算法更加复杂且涉及了一些加密思想,我们这里就不关注这些 Secure 的 Random 的算法了。

Java 17 之前一般如何生成随机数以及对应的随机算法

首先放出算法与实现类的对应关系:

image

使用 JDK 的 API

1.使用 java.util.Random 和基于它的 API:

1
2
ini复制代码Random random = new Random();
random.nextInt();

Math.random() 底层也是基于 Random

java.lang.Math:

1
2
3
4
5
6
java复制代码public static double random() {
return RandomNumberGeneratorHolder.randomNumberGenerator.nextDouble();
}
private static final class RandomNumberGeneratorHolder {
static final Random randomNumberGenerator = new Random();
}

Random 本身是设计成线程安全的,因为 SEED 是 Atomic 的并且随机只是 CAS 更新这个 SEED:

java.util.Random:

1
2
3
4
5
6
7
8
9
ini复制代码protected int next(int bits) {
long oldseed, nextseed;
AtomicLong seed = this.seed;
do {
oldseed = seed.get();
nextseed = (oldseed * multiplier + addend) & mask;
} while (!seed.compareAndSet(oldseed, nextseed));
return (int)(nextseed >>> (48 - bits));
}

同时也看出,Random 是基于线性同余算法的

2.使用 java.util.SplittableRandom 和基于它的 API

1
2
ini复制代码SplittableRandom splittableRandom = new SplittableRandom();
splittableRandom.nextInt();

前面的分析我们提到了,SplittableRandom 基于 SplitMix 算法实现,即给定一个初始 SEED,设置一个固定步长 M,每次随机,将这个 SEED 加上步长 M,经过一个 HASH 函数(这里是 MurMurHash3),将这个值散列映射到一个 HASH 值。

SplittableRandom 本身不是线程安全的:
java.util.SplittableRandom:

1
2
3
4
5
6
7
csharp复制代码public int nextInt() {
return mix32(nextSeed());
}
private long nextSeed() {
//这里非线程安全
return seed += gamma;
}

ThreadLocalRandom 基于 SplittableRandom 实现,我们在多线程环境下使用 ThreadLocalRandom:

1
scss复制代码ThreadLocalRandom.current().nextInt();

SplittableRandom 可以通过 split 方法返回一个参数全新,随机序列特性差异很大的新的 SplittableRandom,我们可以将他们用于不同的线程生成随机数,这在 parallel Stream 中非常常见:

1
2
3
4
5
less复制代码IntStream.range(0, 1000)
.parallel()
.map(index -> usersService.getUsersByGood(index))
.map(users -> users.get(splittableRandom.split().nextInt(users.size())))
.collect(Collectors.toList());

但是由于没有做对齐性填充以及其他一些多线程性能优化的东西,导致其多线程环境下的性能表现还是比基于 SplittableRandom 的 ThreadLocalRandom 要差。

3. 使用 java.security.SecureRandom 生成安全性更高的随机数

1
2
ini复制代码SecureRandom drbg = SecureRandom.getInstance("DRBG");
drbg.nextInt();

一般这种算法,基于加密算法实现,计算更加复杂,性能也比较差,只有安全性非常敏感的业务才会使用,一般业务(例如抽奖)这些是不会使用的。

测试性能

单线程测试:

1
2
3
4
5
6
7
8
9
bash复制代码Benchmark                                      Mode  Cnt          Score          Error  Units
TestRandom.testDRBGSecureRandomInt thrpt 50 940907.223 ± 11505.342 ops/s
TestRandom.testDRBGSecureRandomIntWithBound thrpt 50 992789.814 ± 71312.127 ops/s
TestRandom.testRandomInt thrpt 50 106491372.544 ± 8881505.674 ops/s
TestRandom.testRandomIntWithBound thrpt 50 99009878.690 ± 9411874.862 ops/s
TestRandom.testSplittableRandomInt thrpt 50 295631145.320 ± 82211818.950 ops/s
TestRandom.testSplittableRandomIntWithBound thrpt 50 190550282.857 ± 17108994.427 ops/s
TestRandom.testThreadLocalRandomInt thrpt 50 264264886.637 ± 67311258.237 ops/s
TestRandom.testThreadLocalRandomIntWithBound thrpt 50 162884175.411 ± 12127863.560 ops/s

多线程测试:

1
2
3
4
5
6
7
8
9
bash复制代码Benchmark                                      Mode  Cnt          Score           Error  Units
TestRandom.testDRBGSecureRandomInt thrpt 50 2492896.096 ± 19410.632 ops/s
TestRandom.testDRBGSecureRandomIntWithBound thrpt 50 2478206.361 ± 111106.563 ops/s
TestRandom.testRandomInt thrpt 50 345345082.968 ± 21717020.450 ops/s
TestRandom.testRandomIntWithBound thrpt 50 300777199.608 ± 17577234.117 ops/s
TestRandom.testSplittableRandomInt thrpt 50 465579146.155 ± 25901118.711 ops/s
TestRandom.testSplittableRandomIntWithBound thrpt 50 344833166.641 ± 30676425.124 ops/s
TestRandom.testThreadLocalRandomInt thrpt 50 647483039.493 ± 120906932.951 ops/s
TestRandom.testThreadLocalRandomIntWithBound thrpt 50 467680021.387 ± 82625535.510 ops/s

结果和我们之前说明的预期基本一致,多线程环境下 ThreadLocalRandom 的性能最好。单线程环境下 SplittableRandom 和 ThreadLocalRandom 基本接近,性能要好于其他的。SecureRandom 和其他的相比性能差了几百倍。

测试代码如下(注意虽然 Random 和 SecureRandom 都是线程安全的,但是为了避免 compareAndSet 带来的性能衰减过多,还是用了 ThreadLocal。):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
java复制代码package prng;

import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.util.Random;
import java.util.SplittableRandom;
import java.util.concurrent.ThreadLocalRandom;

import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.BenchmarkMode;
import org.openjdk.jmh.annotations.Fork;
import org.openjdk.jmh.annotations.Measurement;
import org.openjdk.jmh.annotations.Mode;
import org.openjdk.jmh.annotations.Scope;
import org.openjdk.jmh.annotations.State;
import org.openjdk.jmh.annotations.Threads;
import org.openjdk.jmh.annotations.Warmup;
import org.openjdk.jmh.infra.Blackhole;
import org.openjdk.jmh.runner.Runner;
import org.openjdk.jmh.runner.RunnerException;
import org.openjdk.jmh.runner.options.Options;
import org.openjdk.jmh.runner.options.OptionsBuilder;

//测试指标为吞吐量
@BenchmarkMode(Mode.Throughput)
//需要预热,排除 jit 即时编译以及 JVM 采集各种指标带来的影响,由于我们单次循环很多次,所以预热一次就行
@Warmup(iterations = 1)
//线程个数
@Threads(10)
@Fork(1)
//测试次数,我们测试50次
@Measurement(iterations = 50)
//定义了一个类实例的生命周期,所有测试线程共享一个实例
@State(value = Scope.Benchmark)
public class TestRandom {
ThreadLocal<Random> random = ThreadLocal.withInitial(Random::new);
ThreadLocal<SplittableRandom> splittableRandom = ThreadLocal.withInitial(SplittableRandom::new);
ThreadLocal<SecureRandom> drbg = ThreadLocal.withInitial(() -> {
try {
return SecureRandom.getInstance("DRBG");
}
catch (NoSuchAlgorithmException e) {
throw new IllegalArgumentException(e);
}
});

@Benchmark
public void testRandomInt(Blackhole blackhole) throws Exception {
blackhole.consume(random.get().nextInt());
}

@Benchmark
public void testRandomIntWithBound(Blackhole blackhole) throws Exception {
//注意不取 2^n 这种数字,因为这种数字一般不会作为实际应用的范围,但是底层针对这种数字有优化
blackhole.consume(random.get().nextInt(1, 100));
}

@Benchmark
public void testSplittableRandomInt(Blackhole blackhole) throws Exception {
blackhole.consume(splittableRandom.get().nextInt());
}

@Benchmark
public void testSplittableRandomIntWithBound(Blackhole blackhole) throws Exception {
//注意不取 2^n 这种数字,因为这种数字一般不会作为实际应用的范围,但是底层针对这种数字有优化
blackhole.consume(splittableRandom.get().nextInt(1, 100));
}

@Benchmark
public void testThreadLocalRandomInt(Blackhole blackhole) throws Exception {
blackhole.consume(ThreadLocalRandom.current().nextInt());
}

@Benchmark
public void testThreadLocalRandomIntWithBound(Blackhole blackhole) throws Exception {
//注意不取 2^n 这种数字,因为这种数字一般不会作为实际应用的范围,但是底层针对这种数字有优化
blackhole.consume(ThreadLocalRandom.current().nextInt(1, 100));
}

@Benchmark
public void testDRBGSecureRandomInt(Blackhole blackhole) {
blackhole.consume(drbg.get().nextInt());
}

@Benchmark
public void testDRBGSecureRandomIntWithBound(Blackhole blackhole) {
//注意不取 2^n 这种数字,因为这种数字一般不会作为实际应用的范围,但是底层针对这种数字有优化
blackhole.consume(drbg.get().nextInt(1, 100));
}

public static void main(String[] args) throws RunnerException {
Options opt = new OptionsBuilder().include(TestRandom.class.getSimpleName()).build();
new Runner(opt).run();
}
}

微信搜索“我的编程喵”关注公众号,每日一刷,轻松提升技术,斩获各种offer

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Shell 切换解释器,查看当前解释器

发表于 2021-12-29
  • 可以通过 chsh 修改解释器。
  • 查看当前所安装的解释器列表
1
shell复制代码$ cat /etc/shells
1
2
3
4
5
6
7
8
9
10
11
bash复制代码# List of acceptable shells for chpass(1).
# Ftpd will not allow users to connect who are not using
# one of these shells.

/bin/bash
/bin/csh
/bin/dash
/bin/ksh
/bin/sh
/bin/tcsh
/bin/zsh
  • 查看当前解释器
1
shell复制代码$ echo $SHELL
1
bash复制代码/bin/zsh
  • 切换 bash
1
shell复制代码$ chsh -s /bin/bash
  • 切换 zsh
1
shell复制代码$ chsh -s /bin/zsh

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Kotlin协程之Flow使用(三) 本章前言 kotlin

发表于 2021-12-27

banners_twitter.png

本章前言

这篇文章是kotlin协程系列的时候扩展而来,如果对kotlin协程感兴趣的可以通过下面链接进行阅读、

Kotlin协程基础及原理系列

  • 史上最详Android版kotlin协程入门进阶实战(一) -> kotlin协程的基础用法
  • 史上最详Android版kotlin协程入门进阶实战(二) -> kotlin协程的关键知识点初步讲解
  • 史上最详Android版kotlin协程入门进阶实战(三) -> kotlin协程的异常处理
  • 史上最详Android版kotlin协程入门进阶实战(四) -> 使用kotlin协程开发Android的应用
  • 史上最详Android版kotlin协程入门进阶实战(五) -> kotlin协程的网络请求封装
  • 史上最详Android版kotlin协程入门进阶实战(六) -> 深入kotlin协程原理(一)
  • 史上最详Android版kotlin协程入门进阶实战(七) -> 深入kotlin协程原理(二)
  • [史上最详Android版kotlin协程入门进阶实战(八) -> 深入kotlin协程原理(三)]
  • [史上最详Android版kotlin协程入门进阶实战(九) -> 深入kotlin协程原理(四)]

Flow系列

  • Kotlin协程之Flow使用(一)
  • Kotlin协程之Flow使用(二)
  • Kotlin协程之Flow使用(三)

扩展系列

  • 封装DataBinding让你少写万行代码
  • ViewModel的日常使用封装
    笔者也只是一个普普通通的开发者,设计不一定合理,大家可以自行吸收文章精华,去糟粕。

kotlin协程之Flow使用(三)

上一章节我们了解了StatedFlow的相关使用,数据更新基本原理,以及如何避免StatedFlow使用的一些坑。本章节我们主要讲解SharedFlow的使用,以及在实际开发使用过程的选择问题。

SharedFlow的使用

我们在上面使用StateFlow的时候就了解到,StateFlow是继承自SharedFlow,是SharedFlow一个更佳具体实现,同时我们也可以把SharedFlow看作是StateFlow的可配置性极高的泛化数据流。

SharedFlow用来取代BroadcastChannel。SharedFlow不仅使用起来更简单、更快速,而且比BroadcastChannel的功能更丰富。但在需求需要的时候,仍然可以使用Channels API。

SharedFlow也是两种类型: SharedFlow和MutableSharedFlow。与上面功能类似。SharedFlow是只读的,如果需要对值进行修改,则需要使用MutableSharedFlow。

但是与StateFlow不同的是,SharedFlow是无法在创建的时候设置初始默认值的。同时SharedFlow在初始的时候有3个可选配置项。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
kotlin复制代码public fun <T> MutableSharedFlow(
replay: Int = 0,
extraBufferCapacity: Int = 0,
onBufferOverflow: BufferOverflow = BufferOverflow.SUSPEND
): MutableSharedFlow<T> {
require(replay >= 0) { "replay cannot be negative, but was $replay" }
require(extraBufferCapacity >= 0) { "extraBufferCapacity cannot be negative, but was $extraBufferCapacity" }
require(replay > 0 || extraBufferCapacity > 0 || onBufferOverflow == BufferOverflow.SUSPEND) {
"replay or extraBufferCapacity must be positive with non-default onBufferOverflow strategy $onBufferOverflow"
}
val bufferCapacity0 = replay + extraBufferCapacity
val bufferCapacity = if (bufferCapacity0 < 0) Int.MAX_VALUE else bufferCapacity0
return SharedFlowImpl(replay, bufferCapacity, onBufferOverflow)
}
  • replay:重新发射给新的订阅者的值的数量,可以将旧的数据回播给新的订阅者。不能为负数,默认为0。
  • extraBufferCapacity:在replay基础上的缓冲池的数量,当有剩余缓冲区空间时,调用emit发射数据不会被挂起,同样的不能为负数,默认值为0。
  • onBufferOverflow:配置一个emit在缓冲区溢出时的触发操作。默认为BufferOverflow.SUSPEND,缓存溢出时挂起。另外还有BufferOverflow.DROP_OLDEST在溢出时删除缓冲区中最旧的值,将新值添加到缓冲区,不会进行挂起。BufferOverflow.DROP_LATEST在缓冲区溢出时删除当前添加到缓冲区的最新值来保持缓冲区内容不变,不会进行挂起。

通过上面可以看到,MutableSharedFlow创建以后,最终会返回一个SharedFlowImpl对象。我们先用SharedFlow实现上面StateFlow的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
kotlin复制代码class TestActivity : AppCompatActivity() {
private val viewModel: TestFlowViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.test)
lifecycleScope.launch {
viewModel.state.collect {
Log.d("carman", "state : $it")
}
}
viewModel.download()
}
}

class TestFlowViewModel : ViewModel() {
private val _state: MutableSharedFlow<Int> = MutableSharedFlow()
val state: SharedFlow<Int> get() = _state
fun download() {
for (state in 0..5) {
viewModelScope.launch(Dispatchers.IO) {
delay(100L * state)
_state.emit(state)
}
}
}
}
1
2
3
4
5
6
kotlin复制代码 D/carman: state : 0
D/carman: state : 1
D/carman: state : 2
D/carman: state : 3
D/carman: state : 4
D/carman: state : 5

可以看到默认的参数这里使用跟StateFlow没什么区别。

image.png

我们现在修改一下创建MutableSharedFlow时候的参数replay,同时再新增一个收集操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
kotlin复制代码class TestActivity : AppCompatActivity() {
private val viewModel: TestFlowViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.test)
lifecycleScope.launch {
var index = 1
launch {
viewModel.state.collect {
Log.d("carman", "第一个 state : $it ")
}
}
launch {
delay(3000)
viewModel.state.collect {
Log.d("carman", "第二个 state : $it")
}
}
}
viewModel.download()
}
}

class TestFlowViewModel : ViewModel() {
private val _state: MutableSharedFlow<Int> = MutableSharedFlow(2)
val state: SharedFlow<Int> get() = _state
fun download() {
for (state in 0..5) {
viewModelScope.launch(Dispatchers.IO) {
delay(100L * state)
_state.emit(state)
}
}
}
}

我们这里在第二个collect中通过delay延时3秒,来确保第一个接收完成后第二个才开始进行数据收集,这里为了更加清晰,把日志打印时间也一并显示:

1
2
3
4
5
6
7
8
kotlin复制代码03:37:08.412 D/carman: 第一个 state : 0 
03:37:08.487 D/carman: 第一个 state : 1
03:37:08.586 D/carman: 第一个 state : 2
03:37:08.686 D/carman: 第一个 state : 3
03:37:08.786 D/carman: 第一个 state : 4
03:37:08.886 D/carman: 第一个 state : 5
03:37:11.383 D/carman: 第二个 state : 4
03:37:11.383 D/carman: 第二个 state : 5

这时候我们就可以看到,在第一个collect接收完所有变化的数据以后,当我们再次启动一个新的collect时,第二个collect函数里面会接收到两次数据,而且是最新的两次数据4和5。这里是如何实现的呢。

image.png

那么我们就需要继续看MutableSharedFlow最终返回的SharedFlowImpl对象的源码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
kotlin复制代码private class SharedFlowImpl<T>(
private val replay: Int,
private val bufferCapacity: Int,
private val onBufferOverflow: BufferOverflow
) : AbstractSharedFlow<SharedFlowSlot>(), MutableSharedFlow<T>, CancellableFlow<T>, FusibleFlow<T> {

private var buffer: Array<Any?>? = null
private var replayIndex = 0L
private var minCollectorIndex = 0L
private var bufferSize = 0
private var queueSize = 0

private val head: Long get() = minOf(minCollectorIndex, replayIndex)
private val replaySize: Int get() = (head + bufferSize - replayIndex).toInt()
private val totalSize: Int get() = bufferSize + queueSize
private val bufferEndIndex: Long get() = head + bufferSize
private val queueEndIndex: Long get() = head + bufferSize + queueSize

override val replayCache: List<T>
get() = synchronized(this) {
val replaySize = this.replaySize
if (replaySize == 0) return emptyList()
val result = ArrayList<T>(replaySize)
val buffer = buffer!!
@Suppress("UNCHECKED_CAST")
for (i in 0 until replaySize) result += buffer.getBufferAt(replayIndex + i) as T
result
}
//...
}

我们这里先过滤掉一些实现方法,这里的数据更新实现还挺复杂的,我们主要看SharedFlowImpl中定义的一些属性,这里我们要分为三部分来理解:

用于存储状态的部分:

  • buffer:缓冲数组,创建和每次分配的大小总是2的幂。
  • replayIndex: 从新收集器(订阅者)中获取值的最小索引。也就是重新发射给新的订阅者的值的数量的位置索引,会根据更新位置的变化,而变化。
  • minCollectorIndex:活动收集器的最小索引,如果没有则等于replayIndex
  • bufferSize:缓冲的数量
  • queueSize:排队的发射器数量

用于计算状态的部分:

  • head:头部索引,取得是replayIndex和minCollectorIndex中最小的值。
  • replaySize:重新发射给新的订阅者的值的数量大小,由创建的时候replay决定。
  • totalSize:总得数量,bufferSize和queueSize之和。
  • bufferEndIndex:缓冲池的尾部索引
  • queueEndIndex:发射器队列的尾部索引

获取缓存数据部分:

  • replayCache:缓存数据快照,集合的大小由创建的时候replay决定,也就是用于计算部分的replaySize变量。每个新订阅者优先从缓存快照中获取值,然后获得新的触发值。可以通过MutableSharedFlowd的resetReplayCache函数重置。

附加一个官方的缓冲区的逻辑结构解释图:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
doc复制代码             buffered values
/-----------------------\
replayCache queued emitters
/----------/----------------------\
+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+
| | 1 | 2 | 3 | 4 | 5 | 6 | E | E | E | E | E | E | | | |
+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+
^ ^ ^ ^
| | | |
head | head + bufferSize head + totalSize
| | |
index of the slowest | index of the fastest
possible collector | possible collector
| |
| replayIndex == new collector's index
---------------------- /
range of possible minCollectorIndex

image.png

上面的案例中,我们只是观察接收到的数据是看不太大的变化。通过上面的一些变量知道,这个时候我们需要观察SharedFlow的replayCache属性 :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
kotlin复制代码class TestActivity : AppCompatActivity() {
private val viewModel: TestFlowViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.test)
lifecycleScope.launch {
var index = 1
viewModel.state.collect {
Log.d("carman", "第${index++}次变化 state : $it replayCache: ${viewModel.state.replayCache}")
}
}
viewModel.download()
}
}

class TestFlowViewModel : ViewModel() {
private val _state: MutableSharedFlow<Int> = MutableSharedFlow(2)
val state: SharedFlow<Int> get() = _state
fun download() {
for (state in 0..5) {
viewModelScope.launch(Dispatchers.IO) {
delay(200L * state)
_state.emit(state)
}
}
}
}
1
2
3
4
5
6
7
8
kotlin复制代码D/carman: 第一个 state : 0 replayCache: [0]
D/carman: 第一个 state : 1 replayCache: [0, 1]
D/carman: 第一个 state : 2 replayCache: [1, 2]
D/carman: 第一个 state : 3 replayCache: [2, 3]
D/carman: 第一个 state : 4 replayCache: [3, 4]
D/carman: 第一个 state : 5 replayCache: [4, 5]
D/carman: 第二个 state : 4 replayCache: [4, 5]
D/carman: 第二个 state : 5 replayCache: [4, 5]

现在我们就可以明显的看到数据变化的区别,当我们有数据变化的时候,SharedFlow会把新的数据存进buffer当中,每次有新的数据进来都会更新buffer。然后当有新订阅者时,优先从buffer中获取值,然后获得新的触发值。

而我们直接获取的replayCache只是获取的我们限定replay片段大小,通过replayIndex的索引位置获取指定大小的值得集合。

1
2
3
4
5
6
7
8
9
10
kotlin复制代码override val replayCache: List<T>
get() = synchronized(this) {
val replaySize = this.replaySize
if (replaySize == 0) return emptyList()
val result = ArrayList<T>(replaySize)
val buffer = buffer!!
@Suppress("UNCHECKED_CAST")
for (i in 0 until replaySize) result += buffer.getBufferAt(replayIndex + i) as T
result
}

image.png

ShareIn转换

在我们日常使用中,我们都可以通过Flow的扩展方法ShareIn将一个Flow对象转换成SharedFlow,但是如果我们的对象不是Flow类型,我们可以通过asFlow先将它转换成Flow类型,比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
kotlin复制代码class MainActivity : AppCompatActivity() {
private lateinit var flow1: SharedFlow<Int>
private lateinit var flow2: SharedFlow<Int>
private lateinit var flow3: SharedFlow<Int>

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
lifecycleScope.launch {
flow1 = mutableListOf(1, 2, 3, 4).asFlow().shareIn(this, SharingStarted.Eagerly, replay = 4)
flow2 = arrayOf(5, 6, 7, 8).asFlow().shareIn(this, SharingStarted.Eagerly, replay = 4)
flow3 = MutableStateFlow(9).shareIn(this, SharingStarted.Eagerly, replay = 1)

launch {
flow1.collect {
Log.d("carman", "flow1 : $it replayCache: ${flow1.replayCache}")
}
}
launch {
flow2.collect {
Log.d("carman", "flow2 : $it replayCache: ${flow2.replayCache}")
}
}
launch {
flow3.collect {
Log.d("carman", "flow3 : $it replayCache: ${flow3.replayCache}")
}
}
}
}
1
2
3
4
5
6
7
8
9
kotlin复制代码D/carman: flow1 : 1  replayCache: [1, 2, 3, 4]
D/carman: flow1 : 2 replayCache: [1, 2, 3, 4]
D/carman: flow1 : 3 replayCache: [1, 2, 3, 4]
D/carman: flow1 : 4 replayCache: [1, 2, 3, 4]
D/carman: flow2 : 5 replayCache: [5, 6, 7, 8]
D/carman: flow2 : 6 replayCache: [5, 6, 7, 8]
D/carman: flow2 : 7 replayCache: [5, 6, 7, 8]
D/carman: flow2 : 8 replayCache: [5, 6, 7, 8]
D/carman: flow3 : 9 replayCache: [9]

可以看到,在案例中我们通过asFlow将数组,集合转换成Flow,然后再使用ShareIn将它转换成SharedFlow。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
kotlin复制代码@FlowPreview
public fun <T> (() -> T).asFlow(): Flow<T> = flow {
emit(invoke())
}

@FlowPreview
public fun <T> (suspend () -> T).asFlow(): Flow<T> = flow {
emit(invoke())
}

public fun <T> Iterable<T>.asFlow(): Flow<T> = flow {
forEach { value ->
emit(value)
}
}
//...
public fun IntArray.asFlow(): Flow<Int> = flow {
forEach { value ->
emit(value)
}
}

上面展示的是部分asFlow的扩展函数,有兴趣了解全部的扩展函数,可以去源码下kotlinx.coroutines.flow包中去查看。

到此为止,我们关于Flow的文章就结束。

内存泄露问题

StateFlow、SharedFlow与LiveData 具有相似之处。两者都是可观察的数据容器类,并且在应用架构中使用时,两者都遵循相似模式。但请他们与 LiveData 的行为又有所不同

当 View 进入 TOPPED 状态时,LiveData.observe() 会自动取消注册使用方,而从StateFlow或任何其他数据流收集数据的操作并不会自动停止。即使View不可见,这些函数也会处理事件。此s时该行为可能会导致应用崩溃。 为避免这种情况,需要使用repeatOnLifecycle API 。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
kotlin复制代码class TestActivity : AppCompatActivity() {
private val viewModel: TestFlowViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.test)
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.state.collect {
Log.d("carman", "state : $it")
}
}
}
viewModel.download()
}
}

我们从repeatOnLifecycle的源码实现可以看到,他们是通过观察对应组件对应的生命周期来防止内存泄露。

注意:repeatOnLifecycle API 仅在 androidx.lifecycle:lifecycle-runtime-ktx:2.4.0-alpha01 库及更高版本中提供。如果我们不使用此方法,那么我们需要把launch以后的Job对象保存起来,然后在相应的阶段cancel掉就可以了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
kotlin复制代码class TestActivity : AppCompatActivity() {
private val viewModel: TestFlowViewModel by viewModels()
private var job: Job?= null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.test)
job = lifecycleScope.launch {
viewModel.state.collect {
Log.d("carman", "state : $it")
}
}
}

override fun onDestroy() {
super.onDestroy()
job?.cancel()
}
}

原创不易。如果您喜欢这篇文章,您可以动动小手点赞收藏image.png。

关联文章
Kotlin协程基础及原理系列

  • 史上最详Android版kotlin协程入门进阶实战(一) -> kotlin协程的基础用法
  • 史上最详Android版kotlin协程入门进阶实战(二) -> kotlin协程的关键知识点初步讲解
  • 史上最详Android版kotlin协程入门进阶实战(三) -> kotlin协程的异常处理
  • 史上最详Android版kotlin协程入门进阶实战(四) -> 使用kotlin协程开发Android的应用
  • 史上最详Android版kotlin协程入门进阶实战(五) -> kotlin协程的网络请求封装
  • 史上最详Android版kotlin协程入门进阶实战(六) -> 深入kotlin协程原理(一)
  • 史上最详Android版kotlin协程入门进阶实战(七) -> 深入kotlin协程原理(二)
  • [史上最详Android版kotlin协程入门进阶实战(八) -> 深入kotlin协程原理(三)]
  • [史上最详Android版kotlin协程入门进阶实战(九) -> 深入kotlin协程原理(四)]

Flow系列

  • Kotlin协程之Flow使用(一)
  • Kotlin协程之Flow使用(二)
  • Kotlin协程之Flow使用(三)

扩展系列

  • 封装DataBinding让你少写万行代码
  • ViewModel的日常使用封装

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Kotlin协程之Flow使用(二) 本章前言 kotlin

发表于 2021-12-27

banners_twitter.png

本章前言

这篇文章是kotlin协程系列的时候扩展而来,如果对kotlin协程感兴趣的可以通过下面链接进行阅读、

Kotlin协程基础及原理系列

  • 史上最详Android版kotlin协程入门进阶实战(一) -> kotlin协程的基础用法
  • 史上最详Android版kotlin协程入门进阶实战(二) -> kotlin协程的关键知识点初步讲解
  • 史上最详Android版kotlin协程入门进阶实战(三) -> kotlin协程的异常处理
  • 史上最详Android版kotlin协程入门进阶实战(四) -> 使用kotlin协程开发Android的应用
  • 史上最详Android版kotlin协程入门进阶实战(五) -> kotlin协程的网络请求封装
  • 史上最详Android版kotlin协程入门进阶实战(六) -> 深入kotlin协程原理(一)
  • 史上最详Android版kotlin协程入门进阶实战(七) -> 深入kotlin协程原理(二)
  • [史上最详Android版kotlin协程入门进阶实战(八) -> 深入kotlin协程原理(三)]
  • [史上最详Android版kotlin协程入门进阶实战(九) -> 深入kotlin协程原理(四)]

Flow系列

  • Kotlin协程之Flow使用(一)
  • Kotlin协程之Flow使用(二)
  • Kotlin协程之Flow使用(三)

扩展系列

  • 封装DataBinding让你少写万行代码
  • ViewModel的日常使用封装
    笔者也只是一个普普通通的开发者,设计不一定合理,大家可以自行吸收文章精华,去糟粕。

kotlin协程之Flow使用(二)

上一个章节我们对Flow有了一本基本的了解。Flow是一直异步数据流,它按顺序发出值并正常或异常地完成。
同时也对一些常用的操作符,如map、filter、take、zip等使用。

直接使用Flow的局限性

但是有一个问题是,虽然Flow可以将任意的对象转换成流的形式进行收集后计算结果。但是如果我们是直接使用Flow,它一次流的收集是我们已知需要计算的值,而且它每次收集完以后就会立即销毁。我们也不能在后续的使用中,发射新的值到该流中进行计算。

这里我们举个简单的例子,我们将在后续的讲解中详细说明。比如:

1
2
3
4
5
6
7
8
9
kotlin复制代码fun test(){
runBlocking {
var flow1 = (1..3).asFlow()
flow1.collect { value ->
println("$TAG: collect :${value}")
}
flow1 = (4..6).asFlow()
}
}
1
2
3
kotlin复制代码carman: collect :1
carman: collect :2
carman: collect :3

我们在使用collect收集流flow1后,即使我们后续再对flow1进行重新的赋值(4..6),我们无法收集到(4..6),我们必须再次使用collect进行收集流,如:

1
2
3
4
5
6
7
8
9
10
11
12
kotlin复制代码fun test(){
runBlocking {
var flow1 = (1..3).asFlow()
flow1.collect { value ->
println("$TAG: 第一次collect :${value}")
}
flow1 = (4..6).asFlow()
flow1.collect { value ->
println("$TAG: 第二次collect :${value}")
}
}
}
1
2
3
4
5
6
kotlin复制代码carman: 第一次collect :1
carman: 第一次collect :2
carman: 第一次collect :3
carman: 第二次collect :4
carman: 第二次collect :5
carman: 第二次collect :6

只有这样我们才能收集flow1流中到新的值。但是这样操作非常的麻烦,我们不仅需要重新对flow1进行赋值后,还需要在每次赋值以后,再次使用collect收集流。

通过上一章节我们知道Flow是冷数据流,那么想要实现上面的需求,那么我就需要使用热数据流。这个时候我们需要使用到,Flow的进一步实现StateFlow和 SharedFlow。但是在讲解他们之前,我们需要了解一个kotlin中另一个概念Channel(通道),因为在后续讲解StateFlow和 SharedFlow会涉及Channel(通道)的相关知识。

image.png

Channel的基本知识

Channel是一个非阻塞的原始发送者之间的对话沟通。从概念上讲,Channel通道类似于Java的阻塞队列BlockingQueue,但它是已经暂停操作而不是阻塞操作,并且可以通过close进行关闭。Channel也是一个热数据流。

一个对话的沟通的过程必定是存在双方,我们看看Channel的定义:

1
2
3
4
5
6
7
8
9
10
11
kotlin复制代码public fun <E> Channel(
capacity: Int = RENDEZVOUS,
onBufferOverflow: BufferOverflow = BufferOverflow.SUSPEND,
onUndeliveredElement: ((E) -> Unit)? = null
): Channel<E>{
//...
}

public interface Channel<E> : SendChannel<E>, ReceiveChannel<E> {
//...
}

Channel在实现上继承了一个发送方SendChannel和一个接收方ReceiveChannel,通过它们进行通信。

  • capacity 是表示整个通道的容量。
  • onBufferOverflow 处理缓冲区溢出的操作,默认创建。
  • onUndeliveredElement 在元素被发送但未接收时给使用者时调用。

我们继续看SendChannel的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
kotlin复制代码public interface SendChannel<in E> {
@ExperimentalCoroutinesApi
public val isClosedForSend: Boolean

public suspend fun send(element: E)

public val onSend: SelectClause2<E, SendChannel<E>>

public fun trySend(element: E): ChannelResult<Unit>

public fun close(cause: Throwable? = null): Boolean

@ExperimentalCoroutinesApi
public fun invokeOnClose(handler: (cause: Throwable?) -> Unit)

//...
}

做为一个发送方,必定会有发送send和关闭close函数,trySend是send的同步变体,它立即将指定的元素添加到该通道,如果这没有违反其容量限制,并返回成功的结果。否则返回失败或关闭的结果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
kotlin复制代码public interface ReceiveChannel<out E> {
@ExperimentalCoroutinesApi
public val isClosedForReceive: Boolean

@ExperimentalCoroutinesApi
public val isEmpty: Boolean

public suspend fun receive(): E

public val onReceive: SelectClause1<E>

public suspend fun receiveCatching(): ChannelResult<E>

public val onReceiveCatching: SelectClause1<ChannelResult<E>>

public fun tryReceive(): ChannelResult<E>

public operator fun iterator(): ChannelIterator<E>

public fun cancel(cause: CancellationException? = null)
//...
}

同样做为一个接收方,必定会有发送receive和取消cancel函数,tryReceive与trySend类似,如果通道不为空,则从通道中检索并删除元素,返回成功的结果,如果通道为空,返回失败的结果,如果通道关闭,则返回关闭的结果。

接下来我们看个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
kotlin复制代码fun test() {
runBlocking {
val channel = Channel<Int>()
launch {
for (x in 1..5) channel.send(x)
}
launch {
delay(1000)
channel.send(6666)
channel.send(9999)
}
repeat(Int.MAX_VALUE) {
println("receive :${channel.receive()}")
}
println("done")
}
}
1
2
3
4
5
6
7
kotlin复制代码receive :1
receive :2
receive :3
receive :4
receive :5
receive :6666
receive :9999

Channel通道提供了一种在流中传输值的方法。使得我们可以在延期发射值时,可以便捷的使单个值在多个协程之间进行相互传输。可以看到我们在使用Channel的时候,发送和接收运行不同的协程。同时我们后续再次使channel发送数据时,同样也会被接收。

但是这里有一个问题,最后的done并没有输出,说明我们整个父协程并没有执行结束。这是因为我们使用while (true)会一直循环执行。这里我们先记录一下,后面我们在处理这个问题。

继续往下看,这个时候如果我们在第一次launch的末尾使用close关闭Channel时:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
kotlin复制代码fun test() {
runBlocking {
val channel = Channel<Int>()
launch {
for (x in 1..5) channel.send(x)
channel.close()
}
launch {
delay(1000)
channel.send(6666)
channel.send(9999)
}
while (true) {
println("receive :${channel.receive()}")
}
println("done")
}
}
1
2
3
4
5
6
7
8
kotlin复制代码receive :1
receive :2
receive :3
receive :4
receive :5

Channel was closed
kotlinx.coroutines.channels.ClosedReceiveChannelException: Channel was closed

这个时候我们可以看到Channel已经被关闭,同时因为Channel已经被关闭,但是我们继续调用了receive函数导致协程异常结束。同样的在Channel已经被关闭后继续调用send一样也会触发异常结束。这个时候使用Channel的isClosedForSend属性来判断。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
kotlin复制代码fun test() {
runBlocking {
val channel = Channel<Int>()
launch {
if (!channel.isClosedForSend) {
for (x in 1..5) channel.send(x)
channel.close()
}
}
launch {
delay(1000)
if (!channel.isClosedForSend) {
channel.send(6666)
channel.send(9999)
}
}
while (true) {
if (!channel.isClosedForSend) {
println("receive :${channel.receive()}")
}else{
break
}
}
println("done")
}
}
1
2
3
4
5
6
kotlin复制代码receive :1
receive :2
receive :3
receive :4
receive :5
done

可以看到我们通过使用isClosedForSend来判断channel是否已经关闭来控制send和receive,同时我们也在判断isClosedForSend为真时,跳出while (true)的死循环来完成整个协程的执行。

通过上面的简单使用,我们可以看到这其实是生产者——消费者 模式的一部分,并且我们经常能在并发的代码中看到它。我们可以认为SendChannel就是生产者,而ReceiveChannel就是消费者。这可以将生产者抽象成一个函数,并且使通道作为它的参数,但这与必须从函数中返回结果的常识相违悖。

image.png

使用produce创建Channel

这个时候我们就需要使用produce的便捷的CoroutineScope协程构建器,它可以很容易在生产者端正确工作, 并且我们使用扩展函数consumeEach在消费者端替代循环。例如:

1
2
3
4
5
6
7
8
9
10
11
kotlin复制代码fun test() {
runBlocking {
val squares = produceTest()
squares.consumeEach { println("receive :$it") }
println("Done!")
}
}

private fun CoroutineScope.produceTest(): ReceiveChannel<Int> = produce {
for (x in 1..5) send(x)
}
1
2
3
4
5
6
kotlin复制代码receive :1
receive :2
receive :3
receive :4
receive :5
Done!

可以看到我们通过produce很容易的就创建了类似的案例,但是它又是如何生产的呢。我们看看produce的源码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
kotlin复制代码public fun <E> CoroutineScope.produce(
context: CoroutineContext = EmptyCoroutineContext,
capacity: Int = 0,
@BuilderInference block: suspend ProducerScope<E>.() -> Unit
): ReceiveChannel<E> =
produce(context, capacity, BufferOverflow.SUSPEND, CoroutineStart.DEFAULT, onCompletion = null, block = block)

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> {
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是CoroutineScope的扩展方法。通过类似协程launch的创建方式。创建了一个
ReceiveChannel对象。不过它额外多了capacity和onBufferOverflow、onCompletion三个属性。

那他又是如何发送数据出去的呢。这里我们需要注意一下第三个参数block,它是ProducerScope扩展,这一点是与launch函数中是不一样的。

1
2
3
4
kotlin复制代码public interface ProducerScope<in E> : CoroutineScope, SendChannel<E> {

public val channel: SendChannel<E>
}

ProducerScope继承自CoroutineScope同时,继承了SendChannel。这也进一步解释了为什么在produce函数中可以通过send发送数据。

1057988094337c15fad4a26a4877e20.jpg

StateFlow和ShareFlow的使用

为什么要使用StateFlow和ShareFlow

Flow是一套方便的API,但它不提供部分场景所必需的状态管理。上面我们提到Flow的局限性就是基于此原因。

例如,一个流程可能具有多个中间状态和一个终止状态,尤其是我们常见的文件下载就是这类流程的一个示例。例如:

准备->开始->下载中->成功/失败->完成

我们希望状态的变动都能通知到会有所动作的观察者。虽然我可以通过ChannelConflatedBroadcastChannel通道来实现,但是实现来说有点太复杂了。另外,使用通道进行状态管理时会出现一些逻辑上的不一致。例如,可以关闭或取消通道。但由于无法取消状态,因此在状态管理中无法正常使用。

这时候我们需要使用StateFlow和SharedFlow来取代Channel。StateFlow和ShareFlow也是Flow API的一部分,它们允许数据流以最优方式,发出状态更新并向多个使用方发出值。

image.png

StateFlow的使用

StateFlow是一个状态容器式可观察数据流,可以向其收集器发出当前状态更新和新状态更新,任何对值的更新都会反馈新值到所有流的接收器中。还可通过其value属性读取当前状态值。

StateFlow可以完全取代ConflatedBroadcastChannel。StateFlow比ConflatedBroadcastChannel更简单、更高效。它也有更好的区分可变性和不可变性的MutableStateFlow和StateFlow。

StateFlow有两种类型: StateFlow和MutableStateFlow。负责更新MutableStateFlow的类是提供方,从StateFlow收集的所有类都是使用方。与使用flow构建器构建的冷数据流不同,StateFlow是热数据流。

1
2
3
4
5
6
7
8
9
kotlin复制代码public interface StateFlow<out T> : SharedFlow<T> {
public val value: T
}

public interface MutableStateFlow<T> : StateFlow<T>, MutableSharedFlow<T> {
public override var value: T

public fun compareAndSet(expect: T, update: T): Boolean
}

从此类数据流收集数据不会触发任何提供方代码。StateFlow始终处于活跃状态并存于内存中,而且只有在垃圾回收根中未涉及对它的其他引用时,它才符合垃圾回收条件。当新使用方开始从数据流中收集数据时,它将接收信息流中的最近一个状态及任何后续状态。这个变化LiveData类似。

我们可以看到StateFlow是继承自SharedFlow,我们可以把StateFlow看作为SharedFlow一个更佳具体实现。所以为了方便讲解,笔者选择从StateFlow开始讲解。现在我们来实现一下上面的流程案例,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
kotlin复制代码class TestActivity : AppCompatActivity() {
private val viewModel: TestFlowViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.test)
lifecycleScope.launch {
viewModel.state.collect {
Log.d("carman","state : $it")
}
}
viewModel.download()
}
}

class TestFlowViewModel : ViewModel() {
private val _state: MutableStateFlow<Int> = MutableStateFlow(0)
val state: StateFlow<Int> get() = _state

fun download() {
for (state in 0..5) {
viewModelScope.launch(Dispatchers.IO) {
delay(200L * state)
_state.value = state
}
}
}
}
1
2
3
4
5
6
kotlin复制代码D/carman: state : 0
D/carman: state : 1
D/carman: state : 2
D/carman: state : 3
D/carman: state : 4
D/carman: state : 5

可以看到我们通过StateFlow很容易的就实现了多状态变化的收集。这里需要注意的是StateFlow是只读的,如果需要对值进行修改,则需要使用MutableStateFlow。

同时这里面有一个细节是get() 。为什么使用get()而不是直接=。假设我们增加一个state2通过=赋值:

1
2
3
4
5
6
kotlin复制代码class TestFlowViewModel : ViewModel() {
private val _state: MutableStateFlow<Int> = MutableStateFlow(0)
val state: StateFlow<Int> get() = _state
val state2: StateFlow<Int> = _state
//....
}

我们看到编译后的java代码将会是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
kotlin复制代码public final class TestFlowViewModel extends ViewModel {
private final MutableStateFlow _state = StateFlowKt.MutableStateFlow(0);
@NotNull
private final StateFlow state2;

@NotNull
public final StateFlow getState() {
return (StateFlow)this._state;
}

@NotNull
public final StateFlow getState2() {
return this.state2;
}

public TestFlowViewModel() {
this.state2 = (StateFlow)this._state;
}

//**
}

//**

这是因为使用get()只是增加一个getState函数来获取指定类型的返回值。而使用=将会额外创建一个StateFlow类型的变量,来持有同一个_state的对象引用。

image.png

StateFlow的骚操作

通过上面的学习我们知道,在一个协程中我们只能对第一个StateFlow数据进行collect。假设现在有一个需求在带第一个StateFlow的状态达到某一个临界值时,终止这个StateFlow的数据收集,执行下一个StateFlow的数据收集。那么我们可以这样实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
kotlin复制代码class TestActivity : AppCompatActivity() {
private val viewModel: TestFlowViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.test)
lifecycleScope.launch {
try {
viewModel.state.collect {
Log.d("carman","state : $it")
if (it == 3){
throw NullPointerException("终止第一个StateFlow的数据收集")
}
}
} catch (e: Exception) {
Log.d("carman","e : $e")
}
viewModel.name.collect {
Log.d("carman","name : $it")
}
}
viewModel.download()
}
}

class TestFlowViewModel : ViewModel() {
private val _state: MutableStateFlow<Int> = MutableStateFlow(0)
val state: StateFlow<Int> get() = _state

private val _name: MutableStateFlow<String> = MutableStateFlow("第二个StateFlow")
val name: StateFlow<String> get() = _name
fun download() {
for (state in 0..5) {
viewModelScope.launch(Dispatchers.IO) {
delay(200L * state)
_state.value = state
}
}
}
}

我们在_state的collect函数中通过条件判断,抛出一个异常结束第一个StateFlow的数据收集。这个时候我们
第一个StateFlow就可以进入数据收集。

1
2
3
4
5
6
kotlin复制代码D/carman: state : 0
D/carman: state : 1
D/carman: state : 2
D/carman: state : 3
D/carman: e : java.lang.NullPointerException: 终止第一个StateFlow的数据收集
D/carman: name : 第二个StateFlow

到此为止,关于StateFlow的使用就基本结束。因为篇幅字数的限制,真的不是我要拖稿,为了追求质量,我都把写好的推到重来了。ShareFlow的使用我们将在下一篇文章中讲解。

原创不易。如果您喜欢这篇文章,您可以动动小手点赞收藏image.png。

技术交流群,有兴趣的可以私聊加入。

关联文章
Kotlin协程基础及原理系列

  • 史上最详Android版kotlin协程入门进阶实战(一) -> kotlin协程的基础用法
  • 史上最详Android版kotlin协程入门进阶实战(二) -> kotlin协程的关键知识点初步讲解
  • 史上最详Android版kotlin协程入门进阶实战(三) -> kotlin协程的异常处理
  • 史上最详Android版kotlin协程入门进阶实战(四) -> 使用kotlin协程开发Android的应用
  • 史上最详Android版kotlin协程入门进阶实战(五) -> kotlin协程的网络请求封装
  • 史上最详Android版kotlin协程入门进阶实战(六) -> 深入kotlin协程原理(一)
  • 史上最详Android版kotlin协程入门进阶实战(七) -> 深入kotlin协程原理(二)
  • [史上最详Android版kotlin协程入门进阶实战(八) -> 深入kotlin协程原理(三)]
  • [史上最详Android版kotlin协程入门进阶实战(九) -> 深入kotlin协程原理(四)]

Flow系列

  • Kotlin协程之Flow使用(一)
  • Kotlin协程之Flow使用(二)
  • Kotlin协程之Flow使用(三)

扩展系列

  • 封装DataBinding让你少写万行代码
  • ViewModel的日常使用封装

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

面试官:项目中常用的 env 文件原理是什么?如何实现?

发表于 2021-12-24
  1. 前言

大家好,我是若川。为了能帮助到更多对源码感兴趣、想学会看源码、提升自己前端技术能力的同学。我倾力组织了每周大家一起学习200行左右的源码共读活动,感兴趣的可以点此扫码加我微信 ruochuan02 参与。

想学源码,极力推荐关注我写的专栏(目前2K人关注)《学习源码整体架构系列》 包含jQuery、underscore、lodash、vuex、sentry、axios、redux、koa、vue-devtools、vuex4、koa-compose、vue 3.2 发布、vue-this、create-vue、玩具vite等20余篇源码文章。

本文仓库 https://github.com/lxchuan12/dotenv-analysis.git,求个star^_^

源码共读活动 每周一期,已进行到18期。于是搜寻各种值得我们学习,且代码行数不多的源码。dotenv 主文件仅118行,非常值得我们学习。

阅读本文,你将学到:

1
2
3
bash复制代码1. 学会 dotenv 原理和实现
2. 学会使用 fs模块 获取文件并解析
3. 等等
  1. 环境准备

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
bash复制代码# 推荐克隆我的项目,保证与文章同步
git clone https://github.com/lxchuan12/dotenv-analysis.git
# npm i -g yarn
cd dotenv-analysis/dotenv && yarn i
# VSCode 直接打开当前项目
# code .
# 我写的例子都在 examples 这个文件夹中,可以启动服务本地查看调试
# 在 dotenv-analysis 目录下
node examples/index.js

# 或者克隆官方项目
git clone https://github.com/motdotla/dotenv.git
# npm i -g yarn
cd dotenv && yarn i
# VSCode 直接打开当前项目
# code .

如果需要对源码进行调试,可以看我的这篇文章:新手向:前端程序员必学基本技能——调试JS代码,这里就不再赘述了。

  1. dotenv 的作用

dotenv

Dotenv 是一个零依赖模块,可将 .env 文件中的环境变量加载到 process.env 中。

如果需要使用变量,则配合如下扩展包使用。

dotenv-expand

众所周知,.env 文件在我们项目中非常常见,在 vue-cli 和 create-react-app 中都有使用。

vue-cli .env

create-react-app .env

  1. .env 文件使用

我们项目中经常会用到.env 文件写法:

1
2
3
4
5
6
bash复制代码NAME=若川
AGE=18
BLOG=https://lxchuan12.gitee.io
MP_WEIXIN='若川视野'
ACTIVITY=每周一起学200行左右的源码共读活动
WEIXIN=加我微信 ruochuan12 参与

单从这个文件来看,我们可以知道有如下功能需要实现:

  1. 读取 .env 文件
  2. 解析 .env 文件拆成键值对的对象形式
  3. 赋值到 process.env 上
  4. 最后返回解析后得到的对象
  1. 简单实现

根据分析问题,我们最终可以简单把代码实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
js复制代码const fs = require('fs');
const path = require('path');

const parse = function parse(src){
const obj = {};
// 用换行符 分割
// 比如
/**
* NAME=若川
* AGE=18
* MP_WEIXIN=若川视野
* BLOG=https://lxchuan12.gitee.io
* ACTIVITY=每周一起学200行左右的源码共读活动
* WEIXIN=加我微信 ruochuan12 参与
*/
src.toString().split('\n').forEach(function(line, index){
// 用等号分割
const keyValueArr = line.split('=');
// NAME
key = keyValueArr[0];
// 若川
val = keyValueArr[1] || '';
obj[key] = val;
});
// { NAME: '若川', ... }
return obj;
}

const config = function(){
// 读取 node 执行的当前路径下的 .env 文件
let dotenvPath = path.resolve(process.cwd(), '.env');
// 按 utf-8 解析文件,得到对象
// { NAME: '若川', ... }
const parsed = parse(fs.readFileSync(dotenvPath, 'utf-8'));

// 键值对形式赋值到 process.env 变量上,原先存在的不赋值
Object.keys(parsed).forEach(function(key){
if(!Object.prototype.hasOwnProperty.call(process.env, key)){
process.env[key] = parsed[key];
}
});

// 返回对象
return parsed;
};

console.log(config());
console.log(process.env);

// 导出 config parse 函数
module.exports.config = config;
module.exports.parse = parse;
  1. 继续完善 config 函数

简版的 config 函数还缺失挺多功能,比如:

1
2
3
4
bash复制代码可由用户自定义路径
可由用户自定义解析编码规则
添加 debug 模式
完善报错输出,用户写的 env 文件自由度比较大,所以需要容错机制。

根据功能,我们很容易实现以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
js复制代码function resolveHome (envPath) {
return envPath[0] === '~' ? path.join(os.homedir(), envPath.slice(1)) : envPath
}

const config = function(options){
// 读取 node 执行的当前路径下的 .env 文件
let dotenvPath = path.resolve(process.cwd(), '.env');
// utf8
let encoding = 'utf8';
// debug 模式,输出提示等信息
let debug = false;
// 对象
if (options) {
if (options.path != null) {
// 解析路径
dotenvPath = resolveHome(options.path)
}
// 使用配置的编码方式
if (options.encoding != null) {
encoding = options.encoding
}
// 有配置就设置为 true
if (options.debug != null) {
debug = true
}
}

try {
// 按 utf-8 解析文件,得到对象
// { NAME: '若川', ... }
// debug 传递给 parse 函数 便于
const parsed = parse(fs.readFileSync(dotenvPath, { encoding }), { debug });

// 键值对形式赋值到 process.env 变量上,原先存在的不赋值
Object.keys(parsed).forEach(function(key){
if(!Object.prototype.hasOwnProperty.call(process.env, key)){
process.env[key] = parsed[key];
} else if (debug) {
console.log(`"${key}" is already defined in \`process.env\` and will not be overwritten`);
}
});

// 返回对象
return parsed;
}
catch (e) {
return { error: e };
}
};

dotenv 源码中,parse 函数主要是一些正则和单双引号、跨平台等细致处理。这里就暂时不阐述,读者朋友可以查看dotenv 源码。

  1. 总结

鉴于文章不宜过长,文章只比较深入的分析了 config 函数。parse 函数目前没有深入分析。

一句话总结 dotenv 库的原理。用 fs.readFileSync 读取 .env 文件,并解析文件为键值对形式的对象,将最终结果对象遍历赋值到 process.env 上。

我们也可以不看 dotenv 源码,根据 api 倒推,自己来实现这样的功能。最终看看和 dotenv 源码本身有什么差别。这样也许更能锻炼自己。或者用 ts 重构它。

本文同时也给我们启发:围绕工作常用的技术包和库值得深入学习,做到知其然,知其所以然。

值得一提的是:dotenv 源码使用的是 flow 类型。vue2 源码也是用的 flow。vue3 源码改用 ts了。


最后可以持续关注我@若川。我倾力持续组织了一年每周大家一起学习200行左右的源码共读活动,感兴趣的可以点此扫码加我微信 ruochuan02 参与。

另外,想学源码,极力推荐关注我写的专栏《学习源码整体架构系列》,目前是掘金关注人数(4.2k+人)第一的专栏,写有20余篇源码文章。包含jQuery、underscore、lodash、vuex、sentry、axios、redux、koa、vue-devtools、vuex4、koa-compose、vue 3.2 发布、vue-this、create-vue、玩具vite、create-vite 等20余篇源码文章。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

阅读axios源码,发现了这些实用的基础工具函数

发表于 2021-12-17

1.前言

歌德说过:读一本好书,就是在和高尚的人谈话。

同理,读优秀的开源项目的源码,就是在和牛逼的大佬交流。

之前总觉得阅读源码是一件了不起的事情,是只有大佬才会去做的事。其实源码也不是想象的那么难,至少有很多看得懂。 比如源码中的工具函数,就算是初级的前端开发也是能够看懂的。重要的是,要迈出这一步,阅读源码没什么的。

阅读本文,你将学到:

1
2
3
4
复制代码1、javascript、nodejs调试技巧及调试工具;
2、如何学习调试axios源码;
3、如何学习优秀开源项目的代码,应用到自己的项目;
4、axios源码中实用的工具函数;

2.环境准备

2.1 读开源项目的贡献指南

打开 axios , 你会惊奇的发现,这不是在浏览器中打开了一个vscode吗?你没有看错,确实是在浏览器中打开了vscode,而且还打开了axios的源码。如果你仔细看了浏览器地址栏里的url, 你会发现github后多了1s,顾名思义,就是1s打开github上的项目。一个小扩展:在每一个github项目中的url里直接加上1s,就能在网页版vscode中查看源码了(不过貌似现在只能查看,不能调试,调试的话还是要把源码clone到本地)。

开源项目一般能在根目录下的README.md文件或CONTRIBUTING.md中找到贡献指南。贡献指南中说明了参与贡献代码的一些注意事项,比如:代码风格、代码提交注释格式、开发、调试等。

打开CONTRIBUTING.md,可以看到在54行的内容:

1
2
3
4
5
sql复制代码Running sandbox in browser

```bash
$ npm start
# Open 127.0.0.1:3000

这里就是告诉我们在如何在浏览器中运行项目的。

2.2 克隆项目并运行

这里使用axios的版本是v0.24.0;

1
2
3
4
5
6
7
bash复制代码git clone https://github.com/axios/axios.git

cd axios

npm start

打开 http://localhost:3000/

这时候可以看到这么一个页面:

image.png

打开浏览器的控制台,选中source选项,然后在axios目录中可以找到源码,如下图:

image.png

这个axios.js就是入口文件,这时候就可以随意打断点进行调试了。

其实,阅读所有源码的流程都类似,之所以说的这么详细,是为了能够让没有阅读过源码的同学也能够跟着一步一步的阅读起来。当你读完之后,肯定会有不少的收获,把这个过程和收获记录下来,慢慢的提升自己,早晚会成为大佬。

  1. 工具函数

今天的主角是utils.js文件, 以下列出了文件中的工具函数:

3.1 isArray 判断数组

1
2
3
4
5
6
7
javascript复制代码var toString = Object.prototype.toString;

// 可以通过 `toString()` 来获取每个对象的类型
// 一般返回值是 Boolean 类型的函数,命名都以 is 开头
function isArray(val) {
return toString.call(val) === '[object Array]';
}

3.2 isUndefined 判断Undefined

1
2
3
4
5
ini复制代码// 直接用`typeof`判断
// 注意 typeof null === 'object'
function isUndefined(val) {
return typeof val === 'undefined';
}

3.3 isBuffer 判断 buffer

1
2
3
4
5
6
7
8
9
10
11
12
kotlin复制代码// 先判断不是 `undefined`和`null`
// 再判断 `val`存在构造函数,因为`Buffer`本身是一个类
// 最后通过自身的`isBuffer`方法判断

function isBuffer(val) {
return val !== null
&& !isUndefined(val)
&& val.constructor !== null
&& !isUndefined(val.constructor)
&& typeof val.constructor.isBuffer === 'function'
&& val.constructor.isBuffer(val);
}

什么是Buffer?

JavaScript 语言自身只有字符串数据类型,没有二进制数据类型。

但在处理像TCP流或文件流时,必须使用到二进制数据。因此在 Node.js中,定义了一个Buffer 类,该类用来创建一个专门存放二进制数据的缓存区。详细可以看 官方文档 或 更通俗易懂的解释。

因为axios可以运行在浏览器和node环境中,所以内部会用到nodejs相关的知识。

3.4 isFormData 判断FormData

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
javascript复制代码// `instanceof` 运算符用于检测构造函数的 `prototype` 属性是否出现在某个实例对象的原型链上

function isFormData(val) {
return (typeof FormData !== 'undefined') && (val instanceof FormData);
}


// instanceof 用法

function C() {}
function D() {}

const c = new C()

c instanceof C // output: true 因为 Object.getPrototypeOf(c) === C.prototype

c instanceof Object // output: true 因为 Object.prototype.isPrototypeOf(c)

c instanceof D // output: false 因为 D.prototype 不在 c 的原型链上

3.5 isObject 判断对象

1
2
3
4
kotlin复制代码// 排除 `null`的情况
function isObject(val) {
return val !== null && typeof val === 'object';
}

3.6 isPlainObject 判断 纯对象

纯对象: 用{}或new Object()创建的对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
vbnet复制代码function isPlainObject(val) {
if (Object.prototype.toString.call(val) !== '[object Object]') {
return false;
}

var prototype = Object.getPrototypeOf(val);
return prototype === null || prototype === Object.prototype;
}


// 例子1
const o = {name: 'jay}
isPlainObject(o) // true

// 例子2
const o = new Object()
o.name = 'jay'
isPlainObject(o) // true

// 例子3
function C() {}
const c = new C()
isPlainObject(c); // false

// 其实就是判断目标对象的原型是不是`null` 或 `Object.prototype`

3.7 isDate 判断Date

1
2
3
javascript复制代码function isDate(val) {
return Object.prototype.toString.call(val) === '[object Date]';
}

3.8 isFile 判断文件类型

1
2
3
javascript复制代码function isFile(val) {
return Object.prototype.toString.call(val) === '[object File]';
}

3.9 isBlob 判断Blob

1
2
3
javascript复制代码function isBlob(val) {
return Object.prototype.toString.call(val) === '[object Blob]';
}

Blob 对象表示一个不可变、原始数据的类文件对象。它的数据可以按文本或二进制的格式进行读取。

3.10 isFunction 判断函数

1
2
3
javascript复制代码function isFunction(val) {
return Object.prototype.toString.call(val) === '[object Function]';
}

3.11 isStream 判断是否是流

1
2
3
4
scss复制代码// 这里`isObject`、`isFunction`为上文提到的方法
function isStream(val) {
return isObject(val) && isFunction(val.pipe);
}

3.12 isURLSearchParams 判断URLSearchParams

1
2
3
4
5
6
7
8
9
javascript复制代码function isURLSearchParams(val) {
return typeof URLSearchParams !== 'undefined' && val instanceof URLSearchParams;
}


// 例子
const paramsString = "q=URLUtils.searchParams&topic=api"
const searchParams = new URLSearchParams(paramsString);
isURLSearchParams(searchParams) // true

URLSearchParams 接口定义了一些实用的方法来处理 URL 的查询字符串,详情可看 MDN:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
csharp复制代码var paramsString = "q=URLUtils.searchParams&topic=api"
var searchParams = new URLSearchParams(paramsString);

for (let p of searchParams) {
console.log(p);
}

// 输出
[ 'q', 'URLUtils.searchParams' ]
[ 'topic', 'api' ]

searchParams.has("topic") === true; // true
searchParams.get("topic") === "api"; // true
searchParams.getAll("topic"); // ["api"]
searchParams.get("foo") === null; // true
searchParams.append("topic", "webdev");
searchParams.toString(); // "q=URLUtils.searchParams&topic=api&topic=webdev"
searchParams.set("topic", "More webdev");
searchParams.toString(); // "q=URLUtils.searchParams&topic=More+webdev"
searchParams.delete("topic");
searchParams.toString(); // "q=URLUtils.searchParams"

3.13 trim 去除首尾空格

1
2
3
4
javascript复制代码// `trim`方法不存在的话,用正则
function trim(str) {
return str.trim ? str.trim() : str.replace(/^\s+|\s+$/g, '');
}

3.14 isStandardBrowserEnv 判断标准浏览器环境

1
2
3
4
5
6
7
8
9
10
11
javascript复制代码function isStandardBrowserEnv() {
if (typeof navigator !== 'undefined' && (navigator.product === 'ReactNative' ||
navigator.product === 'NativeScript' ||
navigator.product === 'NS')) {
return false;
}
return (
typeof window !== 'undefined' &&
typeof document !== 'undefined'
);
}

但是官方已经不推荐使用这个属性navigator.product。

image.png

3.15 forEach 遍历对象或数组

保留了英文注释,提升大家的英文阅读能力。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
javascript复制代码/**
* Iterate over an Array or an Object invoking a function for each item.
* 用一个函数去迭代数组或对象
*
* If `obj` is an Array callback will be called passing
* the value, index, and complete array for each item.
* 如果是数组,回调将会调用value, index, 和整个数组
*
* If 'obj' is an Object callback will be called passing
* the value, key, and complete object for each property.
* 如果是对象,回调将会调用value, key, 和整个对象
*
* @param {Object|Array} obj The object to iterate
* @param {Function} fn The callback to invoke for each item
*/

function forEach(obj, fn) {
// Don't bother if no value provided
// 如果值不存在,无需处理
if (obj === null || typeof obj === 'undefined') {
return;
}

// Force an array if not already something iterable
// 如果不是对象类型,强制转成数组类型
if (typeof obj !== 'object') {
obj = [obj];
}

if (isArray(obj)) {
// Iterate over array values
// 是数组,for循环执行回调fn
for (var i = 0, l = obj.length; i < l; i++) {
fn.call(null, obj[i], i, obj);
}
} else {
// Iterate over object keys
// 是对象,for循环执行回调fn
for (var key in obj) {
// 只遍历可枚举属性
if (Object.prototype.hasOwnProperty.call(obj, key)) {
fn.call(null, obj[key], key, obj);
}
}
}
}

所以,源码为什么不用forEach和for...in...呢???????

3.16 stripBOM删除UTF-8编码中BOM

1
2
3
4
5
6
7
8
9
10
11
12
13
scss复制代码/**
* Remove byte order marker. This catches EF BB BF (the UTF-8 BOM)
*
* @param {string} content with BOM
* @return {string} content value without BOM
*/

function stripBOM(content) {
if (content.charCodeAt(0) === 0xFEFF) {
content = content.slice(1);
}
return content;
}

所谓 BOM,全称是Byte Order Mark,它是一个Unicode字符,通常出现在文本的开头,用来标识字节序。UTF-8主要的优点是可以兼容ASCII,但如果使用BOM的话,这个好处就荡然无存了。

4.总结

本文主要介绍了axios源码的调试过程,以及介绍了一些utils.js中的非常实用的工具函数;相信通过阅读源码,日积月累,并把这些代码或思想应用的自己项目中去,相信能够很好的提升自己的编码能力。

come on! worker!

同时也推荐一些好用的工具:

浏览器中运行vscode, 查看源码

代码沙盒,能运行多种语言,且可以添加依赖

vs code 的 code Runner插件

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

面试官:请手写一个带取消功能的延迟函数,axios 取消功能

发表于 2021-12-17
  1. 前言

大家好,我是若川。为了能帮助到更多对源码感兴趣、想学会看源码、提升自己前端技术能力的同学。我倾力组织了每周大家一起学习200行左右的源码共读活动,感兴趣的可以点此扫码加我微信 ruochuan02 参与。

想学源码,极力推荐关注我写的专栏(目前1.9K人关注)《学习源码整体架构系列》 包含jQuery、underscore、lodash、vuex、sentry、axios、redux、koa、vue-devtools、vuex4、koa-compose、vue 3.2 发布、vue-this、create-vue、玩具vite等20余篇源码文章。

本文仓库 https://github.com/lxchuan12/delay-analysis.git,求个star^_^

源码共读活动 每周一期,已进行到17期。于是搜寻各种值得我们学习,且代码行数不多的源码。delay 主文件仅70多行,非常值得我们学习。

阅读本文,你将学到:

1
2
3
4
bash复制代码1. 学会如何实现一个比较完善的 delay 函数
2. 学会使用 AbortController 实现取消功能
3. 学会面试常考 axios 取消功能实现
4. 等等
  1. 环境准备

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
bash复制代码# 推荐克隆我的项目,保证与文章同步
git clone https://github.com/lxchuan12/delay-analysis.git
# npm i -g yarn
cd delay-analysis/delay && yarn i
# VSCode 直接打开当前项目
# code .
# 我写的例子都在 examples 这个文件夹中,可以启动服务本地查看调试
# 在 delay-analysis 目录下
npx http-server examples
# 打开 http://localhost:8080

# 或者克隆官方项目
git clone https://github.com/sindresorhus/delay.git
# npm i -g yarn
cd delay && yarn i
# VSCode 直接打开当前项目
# code .
  1. delay

我们从零开始来实现一个比较完善的 delay 函数。

3.1 第一版 简版延迟

要完成这样一个延迟函数。

3.1.1 使用

1
2
3
4
js复制代码(async() => {
await delay1(1000);
console.log('输出这句');
})();

3.1.2 实现

用 Promise 和 setTimeout 结合实现,我们都很容易实现以下代码。

1
2
3
4
5
6
7
js复制代码const delay1 = (ms) => {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve();
}, ms);
});
}

我们要传递结果。

3.2 第二版 传递 value 参数作为结果

3.2.1 使用

1
2
3
4
js复制代码(async() => {
const result = await delay2(1000, { value: '我是若川' });
console.log('输出结果', result);
})();

我们也很容易实现如下代码。传递 value 最后作为结果返回。

3.2.2 实现

因此我们实现也很容易实现如下第二版。

1
2
3
4
5
6
7
js复制代码const delay2 = (ms, { value } = {}) => {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve(value);
}, ms);
});
}

这样写,Promise 永远是成功。我们也需要失败。这时我们定义个参数 willResolve 来定义。

3.3 第三版 willResolve 参数决定成功还是失败。

3.3.1 使用

1
2
3
4
5
6
7
8
9
js复制代码(async() => {
try{
const result = await delay3(1000, { value: '我是若川', willResolve: false });
console.log('永远不会输出这句');
}
catch(err){
console.log('输出结果', err);
}
})();

3.3.2 实现

加个 willResolve 参数决定成功还是失败。于是我们有了如下实现。

1
2
3
4
5
6
7
8
9
10
11
12
js复制代码const delay3 = (ms, {value, willResolve} = {}) => {
return new Promise((resolve, reject) => {
setTimeout(() => {
if(willResolve){
resolve(value);
}
else{
reject(value);
}
}, ms);
});
}

3.4 第四版 一定时间范围内随机获得结果

延时器的毫秒数是写死的。我们希望能够在一定时间范围内随机获取到结果。

3.4.1 使用

1
2
3
4
5
6
7
8
9
10
11
12
js复制代码(async() => {
try{
const result = await delay4.reject(1000, { value: '我是若川', willResolve: false });
console.log('永远不会输出这句');
}
catch(err){
console.log('输出结果', err);
}

const result2 = await delay4.range(10, 20000, { value: '我是若川,range' });
console.log('输出结果', result2);
})();

3.4.2 实现

我们把成功 delay 和失败 reject 封装成一个函数,随机 range 单独封装成一个函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
js复制代码const randomInteger = (minimum, maximum) => Math.floor((Math.random() * (maximum - minimum + 1)) + minimum);

const createDelay = ({willResolve}) => (ms, {value} = {}) => {
return new Promise((relove, reject) => {
setTimeout(() => {
if(willResolve){
relove(value);
}
else{
reject(value);
}
}, ms);
});
}

const createWithTimers = () => {
const delay = createDelay({willResolve: true});
delay.reject = createDelay({willResolve: false});
delay.range = (minimum, maximum, options) => delay(randomInteger(minimum, maximum), options);
return delay;
}
const delay4 = createWithTimers();

实现到这里,相对比较完善了。但我们可能有需要提前结束。

3.5 第五版 提前清除

3.5.1 使用

1
2
3
4
5
6
7
8
9
10
11
js复制代码(async () => {
const delayedPromise = delay5(1000, {value: '我是若川'});

setTimeout(() => {
delayedPromise.clear();
}, 300);

// 300 milliseconds later
console.log(await delayedPromise);
//=> '我是若川'
})();

3.5.2 实现

声明 settle变量,封装 settle 函数,在调用 delayPromise.clear 时清除定时器。于是我们可以得到如下第五版的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
js复制代码const randomInteger = (minimum, maximum) => Math.floor((Math.random() * (maximum - minimum + 1)) + minimum);

const createDelay = ({willResolve}) => (ms, {value} = {}) => {
let timeoutId;
let settle;
const delayPromise = new Promise((resolve, reject) => {
settle = () => {
if(willResolve){
resolve(value);
}
else{
reject(value);
}
}
timeoutId = setTimeout(settle, ms);
});

delayPromise.clear = () => {
clearTimeout(timeoutId);
timeoutId = null;
settle();
};

return delayPromise;
}

const createWithTimers = () => {
const delay = createDelay({willResolve: true});
delay.reject = createDelay({willResolve: false});
delay.range = (minimum, maximum, options) => delay(randomInteger(minimum, maximum), options);
return delay;
}
const delay5 = createWithTimers();

3.6 第六版 取消功能

我们查阅资料可以知道有 AbortController 可以实现取消功能。

caniuse AbortController

npm abort-controller

mdn AbortController

fetch-abort

fetch#aborting-requests

yet-another-abortcontroller-polyfill

3.6.1 使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
js复制代码(async () => {
const abortController = new AbortController();

setTimeout(() => {
abortController.abort();
}, 500);

try {
await delay6(1000, {signal: abortController.signal});
} catch (error) {
// 500 milliseconds later
console.log(error.name)
//=> 'AbortError'
}
})();

3.6.2 实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
js复制代码const randomInteger = (minimum, maximum) => Math.floor((Math.random() * (maximum - minimum + 1)) + minimum);

const createAbortError = () => {
const error = new Error('Delay aborted');
error.name = 'AbortError';
return error;
};

const createDelay = ({willResolve}) => (ms, {value, signal} = {}) => {
if (signal && signal.aborted) {
return Promise.reject(createAbortError());
}

let timeoutId;
let settle;
let rejectFn;
const signalListener = () => {
clearTimeout(timeoutId);
rejectFn(createAbortError());
}
const cleanup = () => {
if (signal) {
signal.removeEventListener('abort', signalListener);
}
};
const delayPromise = new Promise((resolve, reject) => {
settle = () => {
cleanup();
if (willResolve) {
resolve(value);
} else {
reject(value);
}
};

rejectFn = reject;
timeoutId = setTimeout(settle, ms);
});

if (signal) {
signal.addEventListener('abort', signalListener, {once: true});
}

delayPromise.clear = () => {
clearTimeout(timeoutId);
timeoutId = null;
settle();
};

return delayPromise;
}

const createWithTimers = () => {
const delay = createDelay({willResolve: true});
delay.reject = createDelay({willResolve: false});
delay.range = (minimum, maximum, options) => delay(randomInteger(minimum, maximum), options);
return delay;
}
const delay6 = createWithTimers();

3.7 第七版 自定义 clearTimeout 和 setTimeout 函数

3.7.1 使用

1
2
3
4
5
6
7
8
9
js复制代码const customDelay = delay7.createWithTimers({clearTimeout, setTimeout});

(async() => {
const result = await customDelay(100, {value: '我是若川'});

// Executed after 100 milliseconds
console.log(result);
//=> '我是若川'
})();

3.7.2 实现

传递 clearTimeout, setTimeout 两个参数替代上一版本的clearTimeout,setTimeout。于是有了第七版。这也就是delay的最终实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
js复制代码const randomInteger = (minimum, maximum) => Math.floor((Math.random() * (maximum - minimum + 1)) + minimum);

const createAbortError = () => {
const error = new Error('Delay aborted');
error.name = 'AbortError';
return error;
};

const createDelay = ({clearTimeout: defaultClear, setTimeout: set, willResolve}) => (ms, {value, signal} = {}) => {
if (signal && signal.aborted) {
return Promise.reject(createAbortError());
}

let timeoutId;
let settle;
let rejectFn;
const clear = defaultClear || clearTimeout;

const signalListener = () => {
clear(timeoutId);
rejectFn(createAbortError());
}
const cleanup = () => {
if (signal) {
signal.removeEventListener('abort', signalListener);
}
};
const delayPromise = new Promise((resolve, reject) => {
settle = () => {
cleanup();
if (willResolve) {
resolve(value);
} else {
reject(value);
}
};

rejectFn = reject;
timeoutId = (set || setTimeout)(settle, ms);
});

if (signal) {
signal.addEventListener('abort', signalListener, {once: true});
}

delayPromise.clear = () => {
clear(timeoutId);
timeoutId = null;
settle();
};

return delayPromise;
}

const createWithTimers = clearAndSet => {
const delay = createDelay({...clearAndSet, willResolve: true});
delay.reject = createDelay({...clearAndSet, willResolve: false});
delay.range = (minimum, maximum, options) => delay(randomInteger(minimum, maximum), options);
return delay;
}
const delay7 = createWithTimers();
delay7.createWithTimers = createWithTimers;
  1. axios 取消请求

axios取消原理是:通过传递 config 配置 cancelToken 的形式,来取消的。判断有传cancelToken,在 promise 链式调用的 dispatchRequest 抛出错误,在 adapter 中 request.abort() 取消请求,使 promise 走向 rejected,被用户捕获取消信息。

更多查看我的 axios 源码文章取消模块 学习 axios 源码整体架构,取消模块

  1. 总结

我们从零开始实现了一个带取消功能比较完善的延迟函数。也就是 delay 70多行源码的实现。

包含支持随机时间结束、提前清除、取消、自定义 clearTimeout、setTimeout等功能。

取消使用了 mdn AbortController ,由于兼容性不太好,社区也有了相应的 npm abort-controller 实现 polyfill。

yet-another-abortcontroller-polyfill

建议克隆项目启动服务调试例子,印象会更加深刻。

1
2
3
4
5
6
bash复制代码# 推荐克隆我的项目,保证与文章同步
git clone https://github.com/lxchuan12/delay-analysis.git
cd delay-analysis
# 我写的例子都在 examples 这个文件夹中,可以启动服务本地查看调试
npx http-server examples
# 打开 http://localhost:8080

最后可以持续关注我@若川。我倾力持续组织了一年每周大家一起学习200行左右的源码共读活动,感兴趣的可以点此扫码加我微信 ruochuan02 参与。

另外,想学源码,极力推荐关注我写的专栏《学习源码整体架构系列》,目前是掘金关注人数(4.2k+人)第一的专栏,写有20余篇源码文章。包含jQuery、underscore、lodash、vuex、sentry、axios、redux、koa、vue-devtools、vuex4、koa-compose、vue 3.2 发布、vue-this、create-vue、玩具vite、create-vite 等20余篇源码文章。


关于 && 源码共读交流群

作者:常以若川为名混迹于江湖。欢迎加我微信ruochuan02。前端路上 | 所知甚少,唯善学。

关注公众号若川视野,每周一起学源码,学会看源码,进阶高级前端。

若川的博客

segmentfault若川视野专栏,开通了若川视野专栏,欢迎关注~

掘金专栏,欢迎关注~

知乎若川视野专栏,开通了若川视野专栏,欢迎关注~

github blog,求个star^_^~

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Threejs 实现3D全景侦探小游戏🕵️

发表于 2021-12-16

背景

你是嘿嘿嘿侦探社实习侦探🕵️,接到上级指派任务,到甄开心小镇🏠调查市民甄不戳👨宝石💎失窃案,根据线人流浪汉老石👨‍🎤提供的线索,小偷就躲在小镇,快把他找出来,帮甄不戳寻回失窃的宝石吧!

本文使用 Three.js SphereGeometry 创建 3D 全景图预览功能,并在全景图中添加二维 SpriteMaterial、Canvas、三维 GLTF 等交互点,实现具备场景切换、点击交互的侦探小游戏。

实现效果

左右滑动屏幕,找到 3D 全景场景中的 交互点 并点击,找出嫌疑人真正躲藏的位置。

已适配移动端,可以在手机上打开访问。

💡 在线预览:dragonir.github.io/3d-panorami…

代码实现

初始化场景

创建场景,添加摄像机、光源、渲染。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
js复制代码// 透视摄像机
camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 1, 1100);
camera.target = new THREE.Vector3(0, 0, 0);
scene = new THREE.Scene();
// 添加环境光
light = new THREE.HemisphereLight(0xffffff);
light.position.set(0, 40, 0);
scene.add(light);
// 添加平行光
light = new THREE.DirectionalLight(0xffffff);
light.position.set(0, 40, -10);
scene.add(light);
// 渲染
renderer = new THREE.WebGLRenderer();
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
container.appendChild(renderer.domElement);

使用球体实现全景功能

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
js复制代码// 创建全景场景
geometry = new THREE.SphereGeometry(500, 60, 60);
// 按z轴翻转
geometry.scale(1, 1, -1);
// 添加室外低画质贴图
outside_low = new THREE.MeshBasicMaterial({
map: new THREE.TextureLoader().load('./assets/images/outside_low.jpg')
});
// 添加室内低画质贴图
inside_low = new THREE.MeshBasicMaterial({
map: new THREE.TextureLoader().load('./assets/images/inside_low.jpg')
});
mesh = new THREE.Mesh(geometry, outside_low);
// 异步加载高清纹理图
new THREE.TextureLoader().load('./assets/images/outside.jpg', texture => {
outside = new THREE.MeshBasicMaterial({
map: texture
});
mesh.material = outside;
});
// 添加到场景中
scene.add(mesh);

📌 全景贴图如上图所示,图片来源于 Bing。

💡 球体 SphereGeometry

构造函数:

1
js复制代码THREE.SphereGeometry(radius, segmentsWidth, segmentsHeight, phiStart, phiLength, thetaStart, thetaLength)
  • radius:半径;
  • segmentsWidth:经度上的分段数;
  • segmentsHeight:纬度上的分段数;
  • phiStart:经度开始的弧度;
  • phiLength:经度跨过的弧度;
  • thetaStart:纬度开始的弧度;
  • thetaLength:纬度跨过的弧度。

💡 基础网格材质 MeshBasicMaterial

球体的材质使用的是 MeshBasicMaterial, 是一种简单的材质,这种材质不受场景中光照的影响。使用这种材质的网格会被渲染成简单的平面多边形,而且也可以显示几何体的线框。

构造函数:

1
js复制代码MeshBasicMaterial(parameters: Object)

parameters :(可选)用于定义材质外观的对象,具有一个或多个属性。

属性:

  • .alphaMap[Texture]:alpha 贴图是一张灰度纹理,用于控制整个表面的不透明度。(黑色:完全透明;白色:完全不透明)。默认值为 null。
  • .aoMap[Texture]:该纹理的红色通道用作环境遮挡贴图。默认值为 null。
  • .aoMapIntensity[Float]:环境遮挡效果的强度。默认值为 1。零是不遮挡效果。
  • .color[Color]:材质的颜色,默认值为白色 0xffffff。
  • .combine[Integer]:如何将表面颜色的结果与环境贴图(如果有)结合起来。选项为THREE.Multiply(默认值),THREE.MixOperation,THREE.AddOperation。如果选择多个,则使用 .reflectivity 在两种颜色之间进行混合。
  • .envMap[Texture]:环境贴图。默认值为 null。
  • .lightMap[Texture]:光照贴图。默认值为 null。
  • .lightMapIntensity[Float]:烘焙光的强度。默认值为 1。
  • .map[Texture]:纹理贴图。默认为 null。
  • .morphTargets[Boolean]:材质是否使用 morphTargets。默认值为 false。
  • .reflectivity[Float]:环境贴图对表面的影响程度,默认值为 1,有效范围介于 0(无反射)和 1(完全反射)之间。
  • .refractionRatio[Float]:折射率不应超过 1。默认值为 0.98。
  • .specularMap[Texture]:材质使用的高光贴图。默认值为 null。
  • .wireframe[Boolean]:将几何体渲染为线框。默认值为 false(即渲染为平面多边形)。
  • .wireframeLinecap[String]:定义线两端的外观。可选值为 butt,round 和 square。默认为 round。
  • .wireframeLinejoin[String]:定义线连接节点的样式。可选值为 round, bevel 和 miter。默认值为 round。
  • .wireframeLinewidth[Float]:控制线框宽度。默认值为 1。

💡 TextureLoader

TextureLoader 从给定的URL开始加载并将完全加载的 texture 传递给 onLoad。该方法还返回一个新的纹理对象,该纹理对象可以直接用于材质创建,加载材质的一个类,内部使用 ImageLoader 来加载文件。

构造函数:

1
js复制代码TextureLoader(manager: LoadingManager)
  • manager:加载器使用的 loadingManager,默认值为 THREE.DefaultLoadingManager。

方法:

1
js复制代码.load(url: String, onLoad: Function, onProgress: Function, onError: Function) : Texture
  • url:文件的 URL 或者路径,也可以为 Data URI。
  • onLoad:加载完成时将调用。回调参数为将要加载的 texture。
  • onProgress:将在加载过程中进行调用。参数为 XMLHttpRequest 实例,实例包含 total 和 loaded 参数。
  • onError:在加载错误时被调用。

添加交互点

新建交互点数组,包含每个交互点的名称、缩放比例、空间坐标。

1
2
3
4
5
6
7
js复制代码var interactPoints = [
{ name: 'point_0_outside_house', scale: 2, x: 0, y: 1.5, z: 24 },
{ name: 'point_1_outside_car', scale: 3, x: 40, y: 1, z: -20 },
{ name: 'point_2_outside_people', scale: 3, x: -20, y: 1, z: -30 },
{ name: 'point_3_inside_eating_room', scale: 2, x: -30, y: 1, z: 20 },
{ name: 'point_4_inside_bed_room', scale: 3, x: 48, y: 0, z: -20 }
];

添加二维静态图片交互点

1
2
3
4
5
6
7
8
9
10
js复制代码let pointMaterial = new THREE.SpriteMaterial({
map: new THREE.TextureLoader().load('./assets/images/point.png')
});
interactPoints.map(item => {
let point = new THREE.Sprite(pointMaterial);
point.name = item.name;
point.scale.set(item.scale * 1.2, item.scale * 1.2, item.scale * 1.2);
point.position.set(item.x, item.y, item.z);
scene.add(point);
});

💡 精灵材质 SpriteMaterial

构造函数:

1
js复制代码SpriteMaterial(parameters : Object)
  • parameters:可选,用于定义材质外观的对象,具有一个或多个属性。材质的任何属性都可以从此处传入(包括从 Material 和 ShaderMaterial 继承的任何属性)。
  • SpriteMaterials 不会被 Material.clippingPlanes 裁剪。

属性:

.alphaMap[Texture]:alpha 贴图是一张灰度纹理,用于控制整个表面的不透明度。默认值为 null。
.color[Color]:材质的颜色,默认值为白色 0xffffff。 .map 会和 color 相乘。
.map[Texture]:颜色贴图。默认为 null。
.rotation[Radians]:sprite 的转动,以弧度为单位。默认值为 0。
.sizeAttenuation[Boolean]:精灵的大小是否会被相机深度衰减。(仅限透视摄像头。)默认为 true。

使用同样的方法,加载嫌疑人二维图片添加到场景中。

1
2
3
4
5
6
7
8
9
10
js复制代码function loadMurderer() {
let material = new THREE.SpriteMaterial({
map: new THREE.TextureLoader().load('./assets/models/murderer.png')
});
murderer = new THREE.Sprite(material);
murderer.name = 'murderer';
murderer.scale.set(12, 12, 12);
murderer.position.set(43, -3, -20);
scene.add(murderer);
}

添加三维动态模型锚点

通过加载地标锚点形状的 gltf 模型来实现三维动态锚点,加载 gltf 需要单独引入 GLTFLoader.js,地标模型使用 Blender 构建。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
js复制代码var loader = new THREE.GLTFLoader();
loader.load('./assets/models/anchor.gltf', object => {
object.scene.traverse(child => {
if (child.isMesh) {
// 修改材质样式
child.material.metalness = .4;
child.name.includes('黄') && (child.material.color = new THREE.Color(0xfffc00))
}
});
object.scene.rotation.y = Math.PI / 2;
interactPoints.map(item => {
let anchor = object.scene.clone();
anchor.position.set(item.x, item.y + 3, item.z);
anchor.name = item.name;
anchor.scale.set(item.scale * 3, item.scale * 3, item.scale * 3);
scene.add(anchor);
})
});

需要在 requestAnimationFrame 中通过修改模型的 rotation 来实现自传动画效果。

1
2
3
4
5
6
js复制代码function animate() {
requestAnimationFrame(animate);
anchorMeshes.map(item => {
item.rotation.y += 0.02;
});
}

添加二维文字提示

可以使用 Canvas 创建文字提示添加到场景中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
js复制代码function makeTextSprite(message, parameters) {
if (parameters === undefined) parameters = {};
var fontface = parameters.hasOwnProperty("fontface") ? parameters["fontface"] : "Arial";
var fontsize = parameters.hasOwnProperty("fontsize") ? parameters["fontsize"] : 32;
var borderThickness = parameters.hasOwnProperty("borderThickness") ? parameters["borderThickness"] : 4;
var borderColor = parameters.hasOwnProperty("borderColor") ? parameters["borderColor"] : { r: 0, g: 0, b: 0, a: 1.0 };
var canvas = document.createElement('canvas');
var context = canvas.getContext('2d');
context.font = fontsize + "px " + fontface;
var metrics = context.measureText(message);
var textWidth = metrics.width;
context.strokeStyle = "rgba(" + borderColor.r + "," + borderColor.g + "," + borderColor.b + "," + borderColor.a + ")";
context.lineWidth = borderThickness;
context.fillStyle = "#fffc00";
context.fillText(message, borderThickness, fontsize + borderThickness);
context.font = 48 + "px " + fontface;
var texture = new THREE.Texture(canvas);
texture.needsUpdate = true;
var spriteMaterial = new THREE.SpriteMaterial({ map: texture });
var sprite = new THREE.Sprite(spriteMaterial);
return sprite;
}

使用方法:

1
2
3
4
js复制代码outsideTextTip = makeTextSprite('进入室内查找');
outsideTextTip.scale.set(2.2, 2.2, 2)
outsideTextTip.position.set(-0.35, -1, 10);
scene.add(outsideTextTip);
  • 💡 Canvas 画布可以作为 Three.js 纹理贴图 CanvasTexture。Canvas 画布可以通过 2D API 绘制各种各样的几何形状,可以通过 Canvas 绘制一个轮廓后然后作为 Three.js 网格模型、精灵模型等模型对象的纹理贴图。
  • 💡 measureText()方法返回一个对象,该对象包含以像素计的指定字体宽度。如果您需要在文本向画布输出之前,就了解文本的宽度,那么请使用该方法。measureText 语法:context.measureText(text).width。

添加三维文字提示

由于时间有限,三维文字 本示例中并未用到,但是在页面中使用 3D 文字会实现更好的视觉效果,想了解具体实现细节,可以阅读我的另一篇文章,后续的鼠标捕获等内容也在该文中有详细讲解。

🔗 传送门:使用three.js实现炫酷的酸性风格3D页面

鼠标捕获

使用 Raycaster 获取点击选中网格对象,并添加点击交互。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
js复制代码function onDocumentMouseDown(event) {
raycaster.setFromCamera(mouse, camera);
var intersects = raycaster.intersectObjects(interactMeshes);
if (intersects.length > 0) {
let name = intersects[0].object.name;
if (name === 'point_0_outside_house') {
camera_time = 1;
} else if (name === 'point_4_inside_bed_room') {
Toast('小偷就在这里', 2000);
loadMurderer();
} else {
Toast(`小偷不在${name.includes('car') ? '车里' : name.includes('people') ? '人群' : name.includes('eating') ? '餐厅' : '这里'}`, 2000);
}
}
onPointerDownPointerX = event.clientX;
onPointerDownPointerY = event.clientY;
onPointerDownLon = lon;
onPointerDownLat = lat;
}

场景切换

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
js复制代码function update() {
lat = Math.max(-85, Math.min(85, lat));
phi = THREE.Math.degToRad(90 - lat);
theta = THREE.Math.degToRad(lon);
camera.target.x = 500 * Math.sin(phi) * Math.cos(theta);
camera.target.y = 500 * Math.cos(phi);
camera.target.z = 500 * Math.sin(phi) * Math.sin(theta);
camera.lookAt(camera.target);
if (camera_time > 0 && camera_time < 50) {
camera.target.x = 0;
camera.target.y = 1;
camera.target.z = 24;
camera.lookAt(camera.target);
camera.fov -= 1;
camera.updateProjectionMatrix();
camera_time++;
outsideTextTip.visible = false;
} else if (camera_time === 50) {
lat = -2;
lon = 182;
camera_time = 0;
camera.fov = 75;
camera.updateProjectionMatrix();
mesh.material = inside_low;
// 加载新的全景图场景
new THREE.TextureLoader().load('./assets/images/inside.jpg', function (texture) {
inside = new THREE.MeshBasicMaterial({
map: texture
});
mesh.material = inside;
});
loadMarker('inside');
}
renderer.render(scene, camera);
}
  • 💡 透视相机的属性创建完成后我们可以根据个人需求随意修改,但是相机的属性修改后,需要调用 updateProjectionMatrix() 方法来更新。
  • 💡 THREE.Math.degToRad:将度转化弧度。

到这里,3D 全景功能全部实现。

🔗 完整代码:github.com/dragonir/3d…

总结

本案例主要涉及到的知识点包括:

  • 球体 SphereGeometry
  • 基础网格材质 MeshBasicMaterial
  • 精灵材质 SpriteMaterial
  • 材质加载 TextureLoader
  • 文字纹理 Canvas
  • 鼠标捕获 Raycaster

参考资料

  • [1]. 在React中用Three.js实现Web VR全景看房
  • [2]. 使用three.js实现炫酷的酸性风格3D页面

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

1…100101102…956

开发者博客

9558 日志
1953 标签
RSS
© 2025 开发者博客
本站总访问量次
由 Hexo 强力驱动
|
主题 — NexT.Muse v5.1.4
0%