一个 4 个常量的枚举,Kotlin 在字节码里生成了什么

enum class Direction { NORTH, SOUTH, WEST, EAST } 编译出 1698 字节的 Direction.class;同样四个常量,javac --release 8 生成的 DirectionJ.class 是 976 字节。

多出来的约 74% 并非装饰。它对应 Kotlin 编译器为枚举补上的几样东西:$VALUES 数组、私有的 $values()、$ENTRIES 缓存和 getEntries(),以及一份 @Metadata。

本文用一个最小枚举,把这些产物逐个拆开;带参数、带匿名子类、带 when 的例子随后补上。

材料与版本

Kotlin Java(对照)
源码 Direction.kt(1 行) DirectionJ.java(1 行)
编译器 kotlinc 2.4.21(默认 jvmTarget=1.8) javac --release 8
产物 Direction.class,1698 字节 DirectionJ.class,976 字节
class 版本 major 52(Java 8) major 52(Java 8)

两边 class 版本一致,差异只来自编译器。kotlinc 和 kotlin-stdlib.jar 都来自 Kotlin v2.4.21 的编译器发行包,运行时 classpath 里要带上 lib/kotlin-stdlib.jar。

Kotlin 版 ⬇️

kotlin 复制代码
enum class Direction { NORTH, SOUTH, WEST, EAST }

Java 对照版 ⬇️

java 复制代码
public enum DirectionJ { NORTH, SOUTH, WEST, EAST }

编译并查看 ⬇️

bash 复制代码
kotlinc Direction.kt
javap -p -c -v Direction.class

javac --release 8 DirectionJ.java
javap -p -c -v DirectionJ.class

javap 要加 -p,否则看不到 private 和 synthetic 成员;-c 看指令,-v 看常量池、flags 和注解。

下文出现的 #n 是本文编译产物里的常量池下标;换一次编译,编号可能变化,以你自己 javap 的结果为准。

一、枚举首先是一个 java.lang.Enum 子类

javap 的第一行 ⬇️

text 复制代码
public final class Direction extends java.lang.Enum<Direction>
  flags: (0x4031) ACC_PUBLIC, ACC_FINAL, ACC_SUPER, ACC_ENUM

Java 对照版一模一样 ⬇️

text 复制代码
public final class DirectionJ extends java.lang.Enum<DirectionJ>
  flags: (0x4031) ACC_PUBLIC, ACC_FINAL, ACC_SUPER, ACC_ENUM

JVM 没有为枚举发明新的 class 文件类型。它只是在普通类上多认一个 ACC_ENUM 标志;Class.isEnum() 会检查这个标志(以及父类是否为 java.lang.Enum)。所以:

  • Direction 首先是个类,继承 java.lang.Enum<Direction>。
  • 类是 final 的:枚举常量集合在编译期固定,不能通过继承扩展。
  • name / ordinal 不是 Direction 的字段,而是 java.lang.Enum 里的 private final 字段,通过同样在父类里的 name() / ordinal() 方法读取。

四个常量本身是静态字段 ⬇️

text 复制代码
public static final Direction NORTH;
  flags: (0x4019) ACC_PUBLIC, ACC_STATIC, ACC_FINAL, ACC_ENUM

public static final Direction SOUTH;
public static final Direction WEST;
public static final Direction EAST;

Direction.NORTH 在字节码里就是一次 getstatic,没有方法调用。

Direction 一共有 6 个字段:四个常量、$VALUES、$ENTRIES。Java 版只有 5 个:少了 $ENTRIES。

另外,枚举常量是值 ,不是类型。Direction.NORTH 的类型是 Direction,不能写 fun f(d: Direction.NORTH)。这是编译期规则;字节码里它只是一个 Direction 类型的静态字段。

二、常量是 <clinit> 里 new 出来的单例

枚举类的静态初始化块负责把四个常量造出来 ⬇️

text 复制代码
static {};
  Code:
     0: new           #2    // class Direction
     3: dup
     4: ldc           #58   // String NORTH
     6: iconst_0
     7: invokespecial #59   // Method "<init>":(Ljava/lang/String;I)V
    10: putstatic     #47   // Field NORTH:LDirection;

    13: new           #2    // class Direction
    ...
    49: putstatic     #56   // Field EAST:LDirection;

    52: invokestatic  #64   // Method $values:()[LDirection;
    55: putstatic     #22   // Field $VALUES:[LDirection;
    ...

每个常量都是 new + dup + 常量名 + 序号 + 构造器 + putstatic。名字和序号是编译器塞进去的,源码里看不到。

构造器长这样 ⬇️

text 复制代码
private Direction();
  descriptor: (Ljava/lang/String;I)V
  flags: (0x0002) ACC_PRIVATE
  Code:
     0: aload_0
     1: aload_1
     2: iload_2
     3: invokespecial #10   // Method java/lang/Enum."<init>":(Ljava/lang/String;I)V
     6: return
  Signature: #8            // ()V

注意 javap 把它打印成无参的 Direction(),但 descriptor 明明是 (String,int)V。原因是 Kotlin 给构造器写了一个 Signature: ()V,把编译器注入的两个参数从 Kotlin 签名里藏掉了。对 Kotlin 源码来说,这就是一个私有的无参构造器;对 JVM 来说,它要接收 name 和 ordinal。

这也解释了为什么不能写 Direction():构造器是 private,而且只在 <clinit> 里被调用。枚举常量因此是类初始化时一次性创建的单例 ,第一次访问 Direction.NORTH 会触发整个类初始化,四个常量一起建好。

三、$VALUES、$values() 和 values()

字段列表里还有两个 Kotlin 和 Java 都会生成的成员 ⬇️

text 复制代码
private static final Direction[] $VALUES;
  flags: (0x101a) ACC_PRIVATE, ACC_STATIC, ACC_FINAL, ACC_SYNTHETIC

$VALUES 保存常量数组;$values() 负责构造它;公开的 values() 负责返回一份拷贝 ⬇️

text 复制代码
private static final Direction[] $values();
  Code:
     0: iconst_4
     1: anewarray     #2    // class Direction
     4: astore_0
     5: aload_0
     6: iconst_0
     7: getstatic     #47   // Field NORTH:LDirection;
    10: aastore
    ...
    30: areturn

public static Direction[] values();
  Code:
     0: getstatic     #22   // Field $VALUES:[LDirection;
     3: invokevirtual #28   // Method java/lang/Object.clone:()Ljava/lang/Object;
     6: checkcast     #29   // class "[LDirection;"
     9: areturn

values() 每次都 clone,所以调用方拿到的是新数组,改它不会影响枚举内部。运行时验证 ⬇️

kotlin 复制代码
println(Direction.values() === Direction.values())  // false

这部分 Java 也有,Kotlin 原样保留,主要用于 Java 互操作和反射;Kotlin 自己推荐的入口是 entries。

四、entries 是 Kotlin 额外加的一层

Kotlin 的 class 里多了一个字段和一个方法 ⬇️

text 复制代码
private static final kotlin.enums.EnumEntries $ENTRIES;
  flags: (0x101a) ACC_PRIVATE, ACC_STATIC, ACC_FINAL, ACC_SYNTHETIC

public static kotlin.enums.EnumEntries<Direction> getEntries();
  Code:
     0: getstatic     #43   // Field $ENTRIES:Lkotlin/enums/EnumEntries;
     3: areturn

<clinit> 的结尾把它初始化 ⬇️

text 复制代码
52: invokestatic  #64   // Method $values:()[LDirection;
55: putstatic     #22   // Field $VALUES:[LDirection;
58: getstatic     #22   // Field $VALUES:[LDirection;
61: checkcast     #66   // class "[Ljava/lang/Enum;"
64: invokestatic  #72   // Method kotlin/enums/EnumEntriesKt.enumEntries:([Ljava/lang/Enum;)Lkotlin/enums/EnumEntries;
67: putstatic     #43   // Field $ENTRIES:Lkotlin/enums/EnumEntries;
70: return

entries 不是一个字段。写 Direction.entries 时,编译器生成的是 getEntries() 调用 ⬇️

text 复制代码
0: invokestatic  Direction.getEntries:()Lkotlin/enums/EnumEntries;
3: checkcast     java/util/List

EnumEntries 是一个继承 java.util.List 的接口,实现类是 kotlin.enums.EnumEntriesList。它继承 AbstractList,内部只有一个 T[] entries 字段,构造时直接把 $VALUES 的引用存进去,不再复制。

所以 entries 的读操作都很直接 ⬇️

text 复制代码
getSize()  → entries.length
get(i)     → entries[i]
contains(e) / indexOf(e) → 用 e.ordinal() 当下标,再比较引用

contains / indexOf 不是线性扫描,而是把 ordinal 当数组下标做一次身份比较;枚举常量是单例,引用相等就等价于常量相等。这也是 EnumEntriesList 可以直接共享 $VALUES 的原因。

运行时行为 ⬇️

text 复制代码
Direction.entries === Direction.entries   → true
Direction.values() === Direction.values() → false
Direction.entries.javaClass.name          → kotlin.enums.EnumEntriesList
Direction.entries                         → [NORTH, SOUTH, WEST, EAST]
Direction.entries[1]                      → SOUTH
Direction.entries.indexOf(EAST)           → 3

entries 是只读视图。用 Java 的 List.add 去改它,会得到 ⬇️

text 复制代码
java.lang.UnsupportedOperationException: Operation is not supported for read-only collection

和 values() 每次复制一份数组相比,entries 只在类初始化时创建一个共享列表。常量多、调用频繁时,这能省掉一批临时数组。

因为 entries 就是 List,first、firstOrNull、sorted 这些集合 API 可以直接用。文档里的 fromName / fromPosition / fromRgb 这类查找函数,也只是写在 companion object 里的普通方法;编译后它们进入 Color$Companion 类,底层仍然是 entries.find 或 entries.getOrNull,没有额外的枚举机制。

valueOf 和泛型辅助函数

按名字查找常量,用的是另一个生成的静态方法 ⬇️

text 复制代码
public static Direction valueOf(java.lang.String);
  Code:
     0: ldc           #2    // class Direction
     2: aload_0
     3: invokestatic  #34   // Method java/lang/Enum.valueOf:(Ljava/lang/Class;Ljava/lang/String;)Ljava/lang/Enum;
     6: checkcast     #2    // class Direction
     9: areturn

它把活转给 java.lang.Enum.valueOf(Class, String),内部用 Class 的枚举常量目录按名字查。名字对不上就抛 IllegalArgumentException ⬇️

text 复制代码
No enum constant Direction.PINK

文档里的 enumEntries<T>()、enumValues<T>() 和 enumValueOf<T>() 都是 inline + reified 的语法糖。类型实参已知时,调用点会被直接替换成对应的静态方法 ⬇️

text 复制代码
enumEntries<Direction>()   → invokestatic Direction.getEntries()
enumValues<Direction>()    → invokestatic Direction.values()
enumValueOf<Direction>(s)  → invokestatic Direction.valueOf(String)

前者返回共享列表,后两者每次调用都会新建数组或走一次名字查找。

Direction.entries.getOrNull(index) 则会展开成 getEntries() → List → CollectionsKt.getOrNull,越界返回 null,不会抛异常。

顺带一个运行时依赖:$ENTRIES 的初始化调用了 kotlin.enums.EnumEntriesKt,所以 Kotlin 枚举在类初始化时必须能加载 kotlin-stdlib。只访问一个常量也一样 ⬇️

text 复制代码
Exception in thread "main" java.lang.NoClassDefFoundError: kotlin/enums/EnumEntriesKt
    at ColorPlain.<clinit>(ColorPlain.kt:4)

这一点和 Hello World 里无参 main() 不同:那个程序不带 stdlib 也能跑,枚举不行。

五、name、ordinal 和比较都来自父类

先定义一个三常量的枚举,后面几节都用它 ⬇️

kotlin 复制代码
enum class Color { RED, GREEN, BLUE }

fun ordinalOf(c: Color): Int = c.ordinal
fun nameOf(c: Color): String = c.name

Color 的字段里没有 name 和 ordinal。它们存在 java.lang.Enum 里,访问时走父类方法 ⬇️

text 复制代码
ordinalOf:
  6: aload_0
  7: invokevirtual #21   // Method Color.ordinal:()I

nameOf:
  6: aload_0
  7: invokevirtual #28   // Method Color.name:()Ljava/lang/String;

片段省略了方法开头的参数空检查和结尾的返回指令。

java.lang.Enum 里 name()、ordinal()、equals()、hashCode()、compareTo() 都是 final,枚举类不能覆盖它们。compareTo 按 ordinal 比较,也就是声明顺序 ⬇️

kotlin 复制代码
println(Color.RED < Color.BLUE)   // true
println(Color.entries.sorted())   // 声明顺序,不是字典序

因此不能通过重写 compareTo 改变排序;需要别的顺序时,加一个显式属性再 sortedBy,这也是文档给出的建议。

六、带参数的常量:多一个字段和一个构造参数

给每个常量关联一个值,源码只多了一个主构造参数 ⬇️

kotlin 复制代码
enum class ColorPlain(val hex: String) {
    RED("#FF0000"),
    GREEN("#00FF00"),
    BLUE("#0000FF")
}

字节码里多出一个字段,构造器多一个参数 ⬇️

text 复制代码
private final java.lang.String hex;

private ColorPlain(java.lang.String);
  descriptor: (Ljava/lang/String;ILjava/lang/String;)V
  Code:
     0: aload_0
     1: aload_1
     2: iload_2
     3: invokespecial #11   // Method java/lang/Enum."<init>":(Ljava/lang/String;I)V
     6: aload_0
     7: aload_3
     8: putfield      #15   // Field hex:Ljava/lang/String;
    11: return
  Signature: #8            // (Ljava/lang/String;)V

public final java.lang.String getHex();

构造器仍然是 private,String name 和 int ordinal 仍在最前面,hex 排在后面;Kotlin 签名仍然是 (String)V。<clinit> 里每个常量多压一个字符串 ⬇️

text 复制代码
 4: ldc           #62   // String RED
 6: iconst_0
 7: ldc           #64   // String #FF0000
 9: invokespecial #66   // Method "<init>":(Ljava/lang/String;ILjava/lang/String;)V
12: putstatic     #53   // Field RED:LColorPlain;

val hex 不是 public 字段,而是一个 private 字段加一个 getHex()。这一点和普通 Kotlin 类一致。

和 Java 对照,结构几乎一样,差别只在 Kotlin 多出来的那层。Java 版 ⬇️

java 复制代码
public enum ColorJ {
    RED("#FF0000"),
    GREEN("#00FF00"),
    BLUE("#0000FF");

    private final String hex;

    ColorJ(String hex) {
        this.hex = hex;
    }

    public String getHex() {
        return hex;
    }
}

两边的成员数量对比如下 ⬇️

Kotlin ColorPlain Java ColorJ
字段 6(3 常量 + hex + $VALUES + $ENTRIES) 5(3 常量 + hex + $VALUES)
方法 7(含 getEntries()) 6
class 属性 3(多 @Metadata) 2
大小 1979 字节 1165 字节

构造器形状、$VALUES、$values()、values()、valueOf() 两边是同一套设计;Kotlin 加的只有 entries 和元数据。Java 编译器也用 Signature 把 (String,int,String) 在源码层显示成 (String),这部分两边是对称的。

枚举类里共用的函数(比如 describe())就是普通的 final 实例方法,所有常量共用一份字节码;operator fun not() 也只是方法名带 not 的普通方法,调用点变成 invokevirtual。只有每个常量各自 override 时,才会走到下一节的匿名子类。

七、每个常量自己的实现,会变成匿名子类

文档里的 ProtocolState ⬇️

kotlin 复制代码
enum class ProtocolState {
    WAITING {
        override fun signal() = TALKING
    },
    TALKING {
        override fun signal() = WAITING
    };

    abstract fun signal(): ProtocolState
}

主类变成 abstract ⬇️

text 复制代码
public abstract class ProtocolState extends java.lang.Enum<ProtocolState>
  flags: (0x4421) ACC_PUBLIC, ACC_SUPER, ACC_ABSTRACT, ACC_ENUM

因为 signal() 是抽象方法,ProtocolState 自己不能是 final。两个常量各自生成一个类 ⬇️

text 复制代码
ProtocolState.class          2019 字节
ProtocolState$WAITING.class   936 字节
ProtocolState$TALKING.class   936 字节

子类的头 ⬇️

text 复制代码
final class ProtocolState$WAITING extends ProtocolState
  flags: (0x0030) ACC_FINAL, ACC_SUPER
  fields: 0, methods: 2

它是包级可见的 final 类,没有自己的字段,只有一个构造器和一个 signal() ⬇️

text 复制代码
ProtocolState$WAITING();
  descriptor: (Ljava/lang/String;I)V
  Code:
     0: aload_0
     1: aload_1
     2: iload_2
     3: aconst_null
     4: invokespecial #10   // Method ProtocolState."<init>":(Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
     7: return

public ProtocolState signal();
  Code:
     0: getstatic     #23   // Field ProtocolState.TALKING:LProtocolState;
     3: areturn

注意构造器最后多压了一个 null,调用的是父类一个带 DefaultConstructorMarker 的构造器 ⬇️

text 复制代码
public ProtocolState(java.lang.String, int, kotlin.jvm.internal.DefaultConstructorMarker);
  flags: (0x1001) ACC_PUBLIC, ACC_SYNTHETIC
  Code:
     0: aload_0
     1: aload_1
     2: iload_2
     3: invokespecial #54   // Method "<init>":(Ljava/lang/String;I)V
     6: return

原因很直接:父类构造器是 private,子类不能直接调。编译器就生成一个 public synthetic 的桥构造器,多一个没人会用的 marker 参数,让 ProtocolState$WAITING 能绕过访问控制。Kotlin 源码看不到这个构造器,反射能看到。

<clinit> 里创建常量的方式也变了 ⬇️

text 复制代码
 0: new           #59   // class ProtocolState$WAITING
 3: dup
 4: ldc           #60   // String WAITING
 6: iconst_0
 7: invokespecial #61   // Method ProtocolState$WAITING."<init>":(Ljava/lang/String;I)V
10: putstatic     #49   // Field WAITING:LProtocolState;

字段类型仍然是 ProtocolState,不是 ProtocolState$WAITING。所以匿名类体里额外声明的成员在外部访问不到------文档里 waitingMessage 报错就是这个原因。

只有需要"每个常量各自实现"的抽象方法或接口方法,才会生成这些子类;如果整个枚举共用一份实现(比如在枚举类体里统一实现一个接口),就不会有 $WAITING 这样的类。

八、when 枚举:tableswitch 和一张映射表

对枚举做穷尽 when,源码不用写 else ⬇️

kotlin 复制代码
fun describe(color: Color): String = when (color) {
    Color.RED -> "warm"
    Color.GREEN -> "natural"
    Color.BLUE -> "cool"
}

字节码先查一张映射表,再 tableswitch ⬇️

text 复制代码
 6: aload_0
 7: getstatic     #21   // Field UseSiteKt$WhenMappings.$EnumSwitchMapping$0:[I
10: swap
11: invokevirtual #27   // Method Color.ordinal:()I
14: iaload
15: tableswitch   { // 1 to 3
               1: 40
               2: 45
               3: 50
         default: 55
    }
...
55: new           #35   // class kotlin/NoWhenBranchMatchedException
58: dup
59: invokespecial #39   // Method kotlin/NoWhenBranchMatchedException."<init>":()V
62: athrow

这张表不在枚举类里,而在使用方生成的一个合成类里 ⬇️

text 复制代码
public final class UseSiteKt$WhenMappings
  flags: (0x1031) ACC_PUBLIC, ACC_FINAL, ACC_SUPER, ACC_SYNTHETIC

public static final int[] $EnumSwitchMapping$0;
  flags: (0x1019) ACC_PUBLIC, ACC_STATIC, ACC_FINAL, ACC_SYNTHETIC

它的 <clinit> 按 values() 的长度建数组,把每个常量的 ordinal 映射成从 1 开始的分支号 ⬇️

text 复制代码
 0: invokestatic  #14   // Method Color.values:()[LColor;
 3: arraylength
 4: newarray       int
 ...
 9: getstatic     #18   // Field Color.RED:LColor;
12: invokevirtual #22   // Method Color.ordinal:()I
15: iconst_1
16: iastore
 ...
22: getstatic     #25   // Field Color.GREEN:LColor;
26: invokevirtual #22   // Method Color.ordinal:()I
29: iconst_2
30: iastore
 ...

RED → 1、GREEN → 2、BLUE → 3;没填到的位置保持 0,落到 tableswitch 的 default,抛 NoWhenBranchMatchedException。

为什么不直接用 ordinal 做 tableswitch?因为分支编号和 ordinal 是两套东西:一个分支可以合并多个常量,编译器也需要一张稳定的"常量 → 分支"表。更重要的是二进制兼容:如果枚举在另一个模块里新增了常量,调用方没重新编译,映射数组仍会按运行时的 values().length 建成,新常量对应的槽位是 0,于是走 default 抛异常,而不是静默落进某个错误分支。穷尽性首先是编译期保证,运行时这张表是兜底。

这里又出现了一条 swap,和 Hello World 里那条是同一个原因:iaload 需要"数组引用在下面、下标在上面",而编译器先压了数组、再算 ordinal(),顺序反了,就用 swap 换回来 ⬇️

text 复制代码
aload_0                        → [Color]
getstatic $EnumSwitchMapping$0 → [Color, int[]]
swap                           → [int[], Color]
invokevirtual ordinal          → [int[], int]
iaload                         → [int]

九、@Metadata

和 Hello World 的文件门面类一样,枚举类也带 @kotlin.Metadata,但 k 不同 ⬇️

text 复制代码
kotlin.Metadata(
  mv=[2,4,0]
  k=1
  d1=[...]
  d2=["LDirection;","","<init>","(Ljava/lang/String;I)V","NORTH","SOUTH","WEST","EAST"]
)

Hello World 的 HelloKt 是 k=2(FILE_FACADE),枚举是真正的类,所以 k=1(CLASS)。d2 的字符串表里能看到构造器签名和四个常量名;entries / getEntries 不在里面------它们是编译器按"这是个枚举"合成的成员,不是普通声明。

这些元数据只给工具链用:IDE 的补全、跳转、重构,kotlin-reflect,以及跨模块编译。JVM 执行时不需要它。

一张对照表

维度 Java enum Kotlin enum
类头 extends java.lang.Enum<T>,ACC_ENUM 相同
类标志 public final 相同
常量字段 public static final + ACC_ENUM 相同
name / ordinal 继承自 java.lang.Enum 相同
构造器 private (String, int, ...) 相同
$VALUES / $values() / values() / valueOf() 有 有
entries / getEntries() 无 有
常量匿名子类 有(常量类体) 有
@Metadata 无 有
4 常量、无属性 976 字节 1698 字节
3 常量 + 一个属性 1165 字节 1979 字节

小结

  1. 枚举不是新的 class 文件类型。 它是继承 java.lang.Enum<T> 的 final 类,只在访问标志上多了 ACC_ENUM。
  2. 常量是 <clinit> 里 new 出来的单例。 name 和 ordinal 由编译器注入构造器,构造器是 private,外部无法再创建常量。
  3. $VALUES 和 values() 是 Java 时代就有的设计。 Kotlin 原样保留:values() 每次返回一份 clone。
  4. entries 是 Kotlin 加的共享只读列表。 它由 $ENTRIES 缓存、getEntries() 返回;EnumEntriesList 直接包住 $VALUES,contains / indexOf 用 ordinal 做 O(1) 查找。
  5. 每个带自己实现的常量会生成一个包级 final 子类。 父类构造器是私有的,编译器为此补一个带 DefaultConstructorMarker 的 public synthetic 构造器。
  6. when 枚举编译成 tableswitch + 一张合成映射表。 默认分支仍会抛 NoWhenBranchMatchedException,用来兜住枚举在运行时新增常量的情况。
  7. @Metadata 只服务工具链。 JVM 不需要它;但 $ENTRIES 的初始化需要 kotlin-stdlib,所以 Kotlin 枚举比 Hello World 更依赖标准库。

一个只有四个常量的枚举,把 Kotlin 对 JVM 枚举模型的复用和扩展都展示出来了:能复用的(java.lang.Enum、$VALUES、values()、valueOf())直接复用,需要补的(entries、匿名子类、when 映射、元数据)在字节码里各占一块。

参考资料

相关推荐
2301_808414382 小时前
Linux线程的同步与互斥
jvm
曲径海盗20 小时前
Try-Except怎么用,3个追问解答在自动化下的应用
java·jvm·oracle
Easyhood2 天前
Android 开发入门实践(十四):Compose 副作用入门与四个常用 API
android·开发语言·kotlin·android jetpack·compose
松仔log2 天前
Kotlin中级——callbackFlow
java·前端·kotlin
zh_xuan2 天前
kotlin常见面试题
开发语言·kotlin·面试题
mmsx2 天前
耳切法切带洞多边形:论文能跑通,真实数据为什么崩
android·kotlin
Kapaseker2 天前
Kotlin 又要加语法糖了:这次轮到 if-else
android·kotlin
雨白3 天前
从原理到实践:使用 Kotlin KSP 仿写 ButterKnifeLite
android·kotlin