9.4 Kotlin 元数据 JVM 库

原文链接: https://kotlinlang.org/docs/metadata-jvm.html

9.4 Kotlin 元数据 JVM 库

高级

kotlin-metadata-jvm 库提供了各种工具,用于读取、修改和生成已为 JVM 编译的 Kotlin 类的元数据。这些元数据存储在 .class 文件的 @Metadata 注解中,供 kotlin-reflect 等库和工具在运行时检查属性、函数和类等 Kotlin 特有构造。

警告: kotlin-reflect 库依赖元数据来在运行时获取 Kotlin 特有的类细节。元数据与实际 .class 文件之间的任何不一致都可能导致反射行为不正确。

你还可以使用 Kotlin 元数据 JVM 库来检查各种声明属性,例如可见性或修饰性,或者生成元数据并将其嵌入 .class 文件。

把该库添加到你的项目中

要把 Kotlin 元数据 JVM 库包含到项目中,请根据你的构建工具添加相应的依赖配置。

注意: Kotlin 元数据 JVM 库与 Kotlin 编译器和标准库采用相同的版本号。请确保你使用的版本与项目的 Kotlin 版本一致。

Gradle

在 build.gradle(.kts) 文件中添加以下依赖:

Kotlin

1
2
3
4
5
6
7
8
// build.gradle.kts
repositories {
    mavenCentral()
}

dependencies {
    implementation("org.jetbrains.kotlin:kotlin-metadata-jvm:2.4.20")
}

Groovy

1
2
3
4
5
6
7
8
// build.gradle
repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.jetbrains.kotlin:kotlin-metadata-jvm:2.4.20'
}

Maven

在 pom.xml 文件中添加以下依赖。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<project>
    <dependencies>
        <dependency>
            <groupId>org.jetbrains.kotlin</groupId>
            <artifactId>kotlin-metadata-jvm</artifactId>
            <version>2.4.20</version>
        </dependency>
    </dependencies>
    ...
</project>

读取和解析元数据

kotlin-metadata-jvm 库从已编译的 Kotlin .class 文件中提取结构化信息,例如类名、可见性和签名。你可以在需要分析已编译 Kotlin 声明的项目中使用它。例如,二进制兼容性验证器(BCV)就依赖 kotlin-metadata-jvm 来输出公共 API 声明。

你可以先通过反射从已编译的类中获取 @Metadata 注解,开始探索 Kotlin 类的元数据:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
fun main() {
    // 指定类的完全限定名
    val clazz = Class.forName("org.example.SampleClass")

    // 获取 @Metadata 注解
    val metadata = clazz.getAnnotation(Metadata::class.java)

    // 检查元数据是否存在
    if (metadata != null) {
        println("This is a Kotlin class with metadata.")
    } else {
        println("This is not a Kotlin class.")
    }
}

获取 @Metadata 注解之后,请使用 KotlinClassMetadata API 中的 readLenient() 或 readStrict() 函数来解析它。这两个函数都会提取有关类或文件的详细信息,但满足不同的兼容性要求:

  • readLenient():使用该函数可以读取元数据,包括由更新版本 Kotlin 编译器生成的元数据。该函数不支持修改或写入元数据。
  • readStrict():当你需要修改和写入元数据时使用该函数。readStrict() 函数只适用于由你的项目完全支持的 Kotlin 编译器版本所生成的元数据。

注意: readStrict() 函数支持的元数据格式最多比 JvmMetadataVersion.LATEST_STABLE_SUPPORTED 高一个版本,后者对应项目中使用的 Kotlin 最新版本。例如,如果你的项目依赖 kotlin-metadata-jvm:2.1.0,那么 readStrict() 可以处理到 Kotlin 2.2.x 的元数据;否则它会抛出错误,以防错误处理未知格式。更多信息请参阅 Kotlin Metadata GitHub 仓库。

解析元数据时,KotlinClassMetadata 实例会提供有关类或文件级声明的结构化信息。对于类,请使用 kmClass 属性来分析详细的类级元数据,例如类名、函数、属性以及可见性等属性。对于文件级声明,元数据由 kmPackage 属性表示,它包含 Kotlin 编译器生成的文件外观(file facade)中的顶层函数和属性。

下面的代码示例演示了如何使用 readLenient() 解析元数据、用 kmClass 分析类级细节,并用 kmPackage 获取文件级声明:

 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
// 导入必要的库
import kotlin.metadata.jvm.*
import kotlin.metadata.*

fun main() {
    // 指定完全限定类名
    val className = "org.example.SampleClass"

    try {
        // 获取指定名称对应的类对象
        val clazz = Class.forName(className)

        // 获取 @Metadata 注解
        val metadataAnnotation = clazz.getAnnotation(Metadata::class.java)
        if (metadataAnnotation != null) {
            println("Kotlin Metadata found for class: $className")

            // 使用 readLenient() 函数解析元数据
            val metadata = KotlinClassMetadata.readLenient(metadataAnnotation)
            when (metadata) {
                is KotlinClassMetadata.Class -> {
                    val kmClass = metadata.kmClass
                    println("Class name: ${kmClass.name}")

                    // 遍历函数并检查可见性
                    kmClass.functions.forEach { function ->
                        val visibility = function.visibility
                        println("Function: ${function.name}, Visibility: $visibility")
                    }
                }
                is KotlinClassMetadata.FileFacade -> {
                    val kmPackage = metadata.kmPackage

                    // 遍历函数并检查可见性
                    kmPackage.functions.forEach { function ->
                        val visibility = function.visibility
                        println("Function: ${function.name}, Visibility: $visibility")
                    }
                }
                else -> {
                    println("Unsupported metadata type: $metadata")
                }
            }
        } else {
            println("No Kotlin Metadata found for class: $className")
        }
    } catch (e: ClassNotFoundException) {
        println("Class not found: $className")
    } catch (e: Exception) {
        println("Error processing metadata: ${e.message}")
        e.printStackTrace()
    }
}

在元数据中写入和读取注解

Kotlin 会把注解同时存储在字节码和 Kotlin 元数据中。如果你使用 kotlin-metadata-jvm 库读取或写入注解,那么你操作的是它们的元数据表示。

注意: 从 Kotlin 2.4.0 开始,Kotlin 会把注解存储在 Kotlin 元数据中。如果你检查用更早版本编译的类文件,这些注解不会出现在元数据中。

当你修改元数据中的注解时,请确保它们与存储在字节码中的注解保持一致。如果二者不同步,依赖反射或字节码分析的工具可能会报告与读取 Kotlin 元数据的工具不同的结果。

kotlin-metadata-jvm 库提供了以下用于访问注解的 API:

  • KmClass.annotations
  • KmFunction.annotations
  • KmProperty.annotations
  • KmConstructor.annotations
  • KmPropertyAccessorAttributes.annotations
  • KmValueParameter.annotations
  • KmFunction.extensionReceiverAnnotations
  • KmProperty.extensionReceiverAnnotations
  • KmProperty.backingFieldAnnotations
  • KmProperty.delegateFieldAnnotations
  • KmEnumEntry.annotations

下面是一个从 Kotlin 元数据中读取注解的示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
import kotlin.metadata.ExperimentalAnnotationsInMetadata
import kotlin.metadata.jvm.KotlinClassMetadata

annotation class Label(val value: String)

@Label("Message class")
class Message

fun main() {
    val metadata = Message::class.java.getAnnotation(Metadata::class.java)
    val kmClass = (KotlinClassMetadata.readStrict(metadata) as KotlinClassMetadata.Class).kmClass
    println(kmClass.annotations)
    // [@Label(value = StringValue("Message class"))]
}

从字节码中提取元数据

除了使用反射获取元数据之外,另一种做法是使用 ASM 等字节码操作框架从字节码中提取元数据。

你可以按以下步骤进行:

  1. 使用 ASM 库的 ClassReader 类读取 .class 文件的字节码。该类会处理已编译的文件并填充一个表示类结构的 ClassNode 对象。
  2. 从 ClassNode 对象中提取 @Metadata。下面的示例为此使用了自定义扩展函数 findAnnotation()。
  3. 使用 KotlinClassMetadata.readLenient() 函数解析提取出的元数据。
  4. 用 kmClass 和 kmPackage 属性检查解析后的元数据。

下面是一个示例:

 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
// 导入必要的库
import kotlin.metadata.jvm.*
import kotlin.metadata.*
import org.objectweb.asm.*
import org.objectweb.asm.tree.*
import java.io.File

// 检查某个注解是否指向特定名称
fun AnnotationNode.refersToName(name: String) =
    desc.startsWith('L') && desc.endsWith(';') && desc.regionMatches(1, name, 0, name.length)

// 按键获取注解值
private fun List<Any>.annotationValue(key: String): Any? {
    for (index in (0 until size / 2)) {
        if (this[index * 2] == key) {
            return this[index * 2 + 1]
        }
    }
    return null
}

// 定义一个自定义扩展函数,用于按名称在 ClassNode 中定位注解
fun ClassNode.findAnnotation(annotationName: String, includeInvisible: Boolean = false): AnnotationNode? {
    val visible = visibleAnnotations?.firstOrNull { it.refersToName(annotationName) }
    if (!includeInvisible) return visible
    return visible ?: invisibleAnnotations?.firstOrNull { it.refersToName(annotationName) }
}

// 用于简化获取注解值的运算符
operator fun AnnotationNode.get(key: String): Any? = values.annotationValue(key)

// 从 ClassNode 中提取 Kotlin 元数据
fun ClassNode.readMetadataLenient(): KotlinClassMetadata? {
    val metadataAnnotation = findAnnotation("kotlin/Metadata", false) ?: return null
    @Suppress("UNCHECKED_CAST")
    val metadata = Metadata(
        kind = metadataAnnotation["k"] as Int?,
        metadataVersion = (metadataAnnotation["mv"] as List<Int>?)?.toIntArray(),
        data1 = (metadataAnnotation["d1"] as List<String>?)?.toTypedArray(),
        data2 = (metadataAnnotation["d2"] as List<String>?)?.toTypedArray(),
        extraString = metadataAnnotation["xs"] as String?,
        packageName = metadataAnnotation["pn"] as String?,
        extraInt = metadataAnnotation["xi"] as Int?
    )
    return KotlinClassMetadata.readLenient(metadata)
}

// 把文件转换为 ClassNode 以便检查字节码
fun File.toClassNode(): ClassNode {
    val node = ClassNode()
    this.inputStream().use { ClassReader(it).accept(node, ClassReader.SKIP_CODE) }
    return node
}

fun main() {
    val classFilePath = "build/classes/kotlin/main/org/example/SampleClass.class"
    val classFile = File(classFilePath)

    // 读取字节码并处理为 ClassNode 对象
    val classNode = classFile.toClassNode()

    // 定位 @Metadata 注解并以宽松方式读取它
    val metadata = classNode.readMetadataLenient()
    if (metadata != null && metadata is KotlinClassMetadata.Class) {
        // 检查解析后的元数据
        val kmClass = metadata.kmClass

        // 打印类的详细信息
        println("Class name: ${kmClass.name}")
        println("Functions:")
        kmClass.functions.forEach { function ->
            println("- ${function.name}, Visibility: ${function.visibility}")
        }
    }
}

修改元数据

当使用 ProGuard 等工具来缩减和优化字节码时,某些声明可能会从 .class 文件中被移除。ProGuard 会自动更新元数据,使其与修改后的字节码保持一致。

不过,如果你正在开发以类似方式修改 Kotlin 字节码的自定义工具,就需要确保元数据也相应地调整。借助 kotlin-metadata-jvm 库,你可以更新声明、调整属性并移除特定元素。

例如,如果你使用某个 JVM 工具删除 Java 类文件中的私有方法,那么你也必须从 Kotlin 元数据中删除私有函数,以保持一致:

  1. 使用 readStrict() 函数解析元数据,把 @Metadata 注解加载到结构化的 KotlinClassMetadata 对象中。
  2. 直接在 kmClass 或其他元数据结构中调整元数据来应用修改,例如过滤函数或更改属性。
  3. 使用 write() 函数把修改后的元数据编码为新的 @Metadata 注解。

下面是一个从类的元数据中删除私有函数的示例:

 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
// 导入必要的库
import kotlin.metadata.jvm.*
import kotlin.metadata.*

fun main() {
    // 指定完全限定类名
    val className = "org.example.SampleClass"

    try {
        // 获取指定名称对应的类对象
        val clazz = Class.forName(className)

        // 获取 @Metadata 注解
        val metadataAnnotation = clazz.getAnnotation(Metadata::class.java)
        if (metadataAnnotation != null) {
            println("Kotlin Metadata found for class: $className")

            // 使用 readStrict() 函数解析元数据
            val metadata = KotlinClassMetadata.readStrict(metadataAnnotation)
            if (metadata is KotlinClassMetadata.Class) {
                val kmClass = metadata.kmClass

                // 从类元数据中删除私有函数
                kmClass.functions.removeIf { it.visibility == Visibility.PRIVATE }
                println("Removed private functions. Remaining functions: ${kmClass.functions.map { it.name }}")

                // 把修改后的元数据序列化回去
                val newMetadata = metadata.write()
                // 修改元数据之后,你需要把它写入类文件
                // 为此,你可以使用 ASM 等字节码操作框架

                println("Modified metadata: ${newMetadata}")
            } else {
                println("The metadata is not a class.")
            }
        } else {
            println("No Kotlin Metadata found for class: $className")
        }
    } catch (e: ClassNotFoundException) {
        println("Class not found: $className")
    } catch (e: Exception) {
        println("Error processing metadata: ${e.message}")
        e.printStackTrace()
    }
}

提示: 你可以使用 transform() 函数,而不必分别调用 readStrict() 和 write()。该函数会解析元数据、通过 lambda 应用转换,并自动写入修改后的元数据。

从零创建元数据

要使用 Kotlin 元数据 JVM 库为 Kotlin 类文件从零创建元数据:

  1. 根据你想生成的元数据类型,创建 KmClass、KmPackage 或 KmLambda 的实例。
  2. 为该实例添加属性,例如类名、可见性、构造器和函数签名。

提示: 在设置属性时,你可以使用 apply() 作用域函数来减少样板代码。

  1. 使用该实例创建 KotlinClassMetadata 对象,它可以生成 @Metadata 注解。
  2. 指定元数据版本,例如 JvmMetadataVersion.LATEST_STABLE_SUPPORTED,并设置标志(0 表示无标志,如有必要也可以从现有文件复制标志)。
  3. 使用 ASM 的 ClassWriter 类把 kind、data1 和 data2 等元数据字段嵌入 .class 文件。

下面的示例演示了如何为一个简单的 Kotlin 类创建元数据:

 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
// 导入必要的库
import kotlin.metadata.*
import kotlin.metadata.jvm.*
import org.objectweb.asm.*

fun main() {
    // 创建一个 KmClass 实例
    val klass = KmClass().apply {
        name = "Hello"
        visibility = Visibility.PUBLIC
        constructors += KmConstructor().apply {
            visibility = Visibility.PUBLIC
            signature = JvmMethodSignature("<init>", "()V")
        }
        functions += KmFunction("hello").apply {
            visibility = Visibility.PUBLIC
            returnType = KmType().apply {
                classifier = KmClassifier.Class("kotlin/String")
            }
            signature = JvmMethodSignature("hello", "()Ljava/lang/String;")
        }
    }

    // 把 KotlinClassMetadata.Class 实例(包括版本和标志)序列化为 @kotlin.Metadata 注解
    val annotationData = KotlinClassMetadata.Class(
        klass, JvmMetadataVersion.LATEST_STABLE_SUPPORTED, 0
    ).write()

    // 使用 ASM 生成 .class 文件
    val classBytes = ClassWriter(0).apply {
        visit(Opcodes.V1_6, Opcodes.ACC_PUBLIC, "Hello", null, "java/lang/Object", null)
        // 把 @kotlin.Metadata 实例写入 .class 文件
        visitAnnotation("Lkotlin/Metadata;", true).apply {
            visit("mv", annotationData.metadataVersion)
            visit("k", annotationData.kind)
            visitArray("d1").apply {
                annotationData.data1.forEach { visit(null, it) }
                visitEnd()
            }
            visitArray("d2").apply {
                annotationData.data2.forEach { visit(null, it) }
                visitEnd()
            }
            visitEnd()
        }
        visitEnd()
    }.toByteArray()

    // 把生成的 .class 文件写入磁盘
    java.io.File("Hello.class").writeBytes(classBytes)

    println("Metadata and .class file created successfully.")
}

提示: 更详细的示例请参阅 Kotlin Metadata JVM GitHub 仓库。

接下来做什么?