- 本文仅关注 Kotlin/Native、Kotlin/JS两个平台的插桩能力,Android 平台继续使用 Transform
- 本文重在探讨方案的实现思路,包含较多 Kotlin 编译器源码与流程分析、调试思路等,如果只想了解框架如何使用那么只需关注「如何使用 EzHook」、「如何实现自定义 Transform」两节即可
- 本文基于 Kotlin 2.0.21 版本分析
背景
Kotlin Multiplatform近年来逐渐崭露头角,已成为备受关注的跨平台开发方案。它支持代码在 Android、iOS、Web、Destop 等多个平台之间复用,能够大幅提升开发效率与需求的多端一致性。并且随着鸿蒙 Next 的发布,越来越多公司开始着手适配 KMP 以满足未来移动端跨平台开发的需求,由此可见 KMP的发展潜力不可小觑。
然而,在将 KMP 应用到实际项目中时发现缺少一个关键能力——「代码插桩」。这一能力对于修改第三方库、面向切片编程(AOP)以及构建业务框架等场景至关重要。在 Android 平台上,我们可以基于 Transform 实现自定义的字节码转换,并且社区也提供了丰富的 AOP 框架(如 Lancet)。但目前还没有一款基于 KMP 的类 Transform 能力与对应的 AOP 框架,因此本文将探索 KMP 平台上类 Transform 能力,并基于该能力开发一款适用于 KMP 的 AOP 框架——EzHook。
如何使用 EzHook
依赖配置
顶层组件:对于 Kolint/Native 和 Kotlin/JS 来说都会有一个处于上层的组件用于产出对应的 so 或 js 产物,这个组件我们称为顶层组件
EzHook 包含 Gradle 插件、运行时 Library 两部分,Gradle 插件提供了编译期信息采集与 IR 转换的能力,需要在顶层组件引入
// 根目录 build.gradle.kts
buildscript {
dependencies {
classpath("io.github.xdmrwu:ez-hook-gradle-plugin:0.0.2")
}
}
// 顶层组件
plugins {
id("io.github.xdmrwu.ez-hook-gradle-plugin")
}
Library则提供了必要的注解与运行时能力,在使用到 EzHook 的模块依赖即可
kotlin {
sourceSets {
commonMain.dependencies {
implementation("io.github.xdmrwu:ez-hook-library:0.0.2")
}
}
}
为了使 EzHook 生效还需要关闭 Kotlin/Native 的缓存机制
// gradle.properties
kotlin.native.cacheKind=none
使用方式
EzHook是一个类似 Lancet 的 AOP 框架,可以将任意方法在编译期替换为指定方法,只需要提供一个 Hook 方法并添加 @EzHook 注解指定目标方法即可
@HiddenFromObjC
@EzHook("kotlin.time.Duration.toInt")
fun toInt(unit: DurationUnit): Int {
println("Hook to int")
return 10086
}
上述代码则会将Duration类的 toInt 方法替换为当前的 toInt 方法,需要注意的是
@EzHook注解提供的是目标方法的fqName,也支持顶层方法- Hook 方法的方法名可以随意写,但是参数数量、类型与返回值类型需要与目标方法保持一致
- Hook 方法必须是顶层方法
- 如果目标平台包括 iOS,需要添加
@HiddenFromObjC注解
调用原始方法
框架也支持在 Hook 方法中调用目标方法,并修改参数。具体做法为
- 创建一个与参数同名同类型的变量来覆盖原始参数
- 通过
callOrigin<T>来调用目标方法
@HiddenFromObjC
@EzHook("kotlin.time.Duration.toInt")
fun toInt(unit: DurationUnit): Int {
val unit = DurationUnit.HOURS
return callOrigin<Int>()
}
内联 Hook 方法
由于 JS 模块循环依赖时容易出现运行时崩溃,框架支持将 Hook 方法内联到目标方法模块中,以此避免生成的 JS 代码存在模块间的循环依赖。只需要在 @EzHook注解中配置参数 inline=true 即可
@EzHook("kotlin.time.Duration.toInt", true)
fun toInt(unit: DurationUnit): Int {
val unit = DurationUnit.HOURS
return callOrigin<Int>()
}
需要注意的是Hook 方法不能依赖当前模块其他方法或变量,否则还是会出现循环依赖的问题,在实际应用中可以参考下面两条原则
- 如何判断是否需要 inline:当目标平台包括 Kotlin/JS,且 Hook 方法所在模块位于目标方法所在模块上层
- 如何判断 Hook 方法是否可以 inline:Hook 方法所引用的类、方法、变量所在的模块均可以被目标模块依赖
如何实现自定义 Transform
Transform为 EzHook 框架提供了 IR 转换的底座,但是由于 KCP 模块无法引入外部依赖的限制,当前无法提供一个独立的Transform 产物用于接入,只能通过拷贝代码到自己的 GradlePlugin、KCP模块中实现,下面将介绍如何基于这种方式使用 Transform 基座。
接入 Transform
- 拷贝 KotlinJsIrLinkConfig.kt 到你的 Gradle Plugin 模块中,请注意保持包名一致,并且在 classpath 中将该 Gradle Plugin 放在最前面
- 拷贝 hook 目录下的代码到你的 KCP 模块中
注册 IrLoweringHookExtension
在你的CompilerPluginRegistrar中通过调用IrLoweringHookExtension.registerExtension完成 Hook 并注册自定义的 IrLoweringHookExtension,IrLoweringHookExtension定义如下
interface IrLoweringHookExtension {
fun traverse(context: CommonBackendContext, module: IrModuleFragment)
fun transform(context: CommonBackendContext, module: IrModuleFragment)
}
Transform 对项目中的 IR 会进行两轮处理,分别对应 traverse 和 transform 两个方法,建议在 traverse 中采集代码信息,在 transform 中实现具体的 IR 转换逻辑
什么是 KCP
KCP(Kotlin Compiler Plugin)是 Kotlin 提供的一种功能强大的扩展机制,用于在编译阶段对代码进行分析、变换或生成。通过 KCP开发者可以深度定制编译器行为,实现如 AOP(面向切面编程)、代码插桩、静态检查等高级功能。KCP 的核心特点是它直接作用于 Kotlin 编译器的中间表示(IR),具有高度的灵活性和跨平台支持(包括 JVM、JS 和 Native)。
本文不会介绍如何实现 KCP,推荐先阅读 Writing Your Second Kotlin Compiler Plugin。
KMP Transform实现
为了实现类 Android 的 Transform 底座,我们需要实现统一处理应用所有模块 IR 数据的能力。虽然 Kotlin 允许我们自定义编译器插件(KCP)进行 IR 处理,但 KCP 只能处理单一模块的 IR,无法直接访问或修改应用程序中所有依赖模块的 IR 信息。因此仅依赖 KCP 实现全局统一的 IR 处理是不可行的,我们需要探索其他解决思路。
实现思路
Kotlin/Native 和 Kotlin/JS 发布的库产物以 klib 形式存在,而 klib 文件存储了该模块编译后的 Kotlin IR 数据。因此可以合理猜测,当编译顶层模块生成平台可执行产物(如 so 文件或 js 文件)时,编译器会收集整个项目中所有依赖模块的 IR 数据,并在这一阶段将其整合、优化,最终统一编译成目标平台的产物。
如果我们可以在这个阶段注入我们自定义的 IR 转换逻辑,就可以实现全局 IR 的处理能力。下面将分别分析Kotlin /Native 和 Kotlin/ JS 的编译流程,尝试找到这一阶段并实现逻辑注入。
Kotlin/Native
编译流程分析
Kotlin/Native 编译主要会触发 KotlinNativeCompile 和 KotlinNativeLink 两个 Gradle Task,前者负责将当前模块编译为 Library 产物(也就是包含Kotlin IR 的 klib),后者负责将当前项目编译为平台可执行产物。
KotlinNativeCompile
KMP 的编译逻辑并不与 Gradle 耦合,Gradle Task 只是负责整合编译参数、通过 KotlinToolRunner 执行编译命令触发编译,相关代码如下所示
@CacheableTask
abstract class KotlinNativeCompile {
@TaskAction
fun compile() {
val buildMetrics = metrics.get()
addBuildMetricsForTaskAction(
metricsReporter = buildMetrics,
languageVersion = resolveLanguageVersion()
) {
// 构建编译参数
val arguments = createCompilerArguments()
val buildArguments = buildMetrics.measure(GradleBuildTime.OUT_OF_WORKER_TASK_ACTION) {
val output = outputFile.get()
output.parentFile.mkdirs()
buildFusService.orNull?.reportFusMetrics {
NativeCompilerOptionMetrics.collectMetrics(compilerOptions, it)
}
ArgumentUtils.convertArgumentsToStringList(arguments)
}
// 触发编译命令
objectFactory.KotlinNativeCompilerRunner(
settings = runnerSettings,
metricsReporter = buildMetrics
).run(buildArguments)
}
}
}
// KotlinNativeCompilerRunner.run 最终会触发到 KotlinToolRunner#runInProcess
abstract class KotlinToolRunner {
private fun runInProcess(args: List<String>, metricsReporter: BuildMetricsReporter<GradleBuildTime, GradleBuildPerformanceMetric> = DoNothingBuildMetricsReporter) {
metricsReporter.measure(GradleBuildTime.NATIVE_IN_PROCESS) {
// 编译转换参数,在参数前增加 konanc
val transformedArgs = transformArgs(args)
// 获取 ClassLoader 加载 cli class
val isolatedClassLoader = getIsolatedClassLoader()
// ...
try {
val mainClass = isolatedClassLoader.loadClass(mainClass)
val entryPoint = mainClass.methods
.singleOrNull { it.name == daemonEntryPoint } ?: error("Couldn't find daemon entry point '$daemonEntryPoint'")
metricsReporter.measure(GradleBuildTime.RUN_ENTRY_POINT) {
// 通过 cli 执行 konanc 命令完成编译
entryPoint.invoke(null, transformedArgs.toTypedArray())
}
} catch (t: InvocationTargetException) {
throw t.targetException
}
}
}
}
可以看到 KotlinNativeCompile 整合编译参数后通过 cli 触发了 konanc 命令进行编译,完整的命令如下所示
konanc
-g
-enable-assertions
-library
/Users/wulinpeng/.konan/kotlin-native-prebuilt-macos-aarch64-2.0.21/klib/common/stdlib
-library
/Users/wulinpeng/Desktop/kcp_project/EzHook/demo-v2/build/classes/kotlin/macosArm64/main/klib/demo-v2.klib
-library
/Users/wulinpeng/.gradle/caches/modules-2/files-2.1/io.github.xdmrwu/ez-hook-library-macosarm64/0.0.2/3e8f0d77f5add361fdd0332ed43ef732b13ba860/library.klib
-library
/Users/wulinpeng/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlinx/kotlinx-datetime-macosarm64/0.6.1/8f8b97995e611b9f5207cf52b1d98d64100b9e3/kotlinx-datetime.klib
-library
/Users/wulinpeng/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlinx/kotlinx-serialization-core-macosarm64/1.6.2/9b73a3c99322130d2aba72d7a382879546251058/kotlinx-serialization-core.klib
-module-name
EzHook:demo
-no-endorsed-libs
-nostdlib
-output
/Users/wulinpeng/Desktop/kcp_project/EzHook/demo/build/classes/kotlin/macosArm64/main/klib/demo.klib
-produce
library
-Xshort-module-name=demo
-target
macos_arm64
-Xfragment-refines=macosArm64Main:macosMain,macosMain:appleMain,appleMain:nativeMain,nativeMain:commonMain
-Xfragment-sources=commonMain:/Users/wulinpeng/Desktop/kcp_project/EzHook/demo/src/commonMain/kotlin/com/wulinpeng/ezhook/demo/Main.kt,commonMain:/Users/wulinpeng/Desktop/kcp_project/EzHook/demo/src/commonMain/kotlin/com/wulinpeng/ezhook/demo/hook/TestManagerHook.kt
-Xfragments=macosArm64Main,macosMain,appleMain,nativeMain,commonMain
-Xmulti-platform
-Xplugin=/Users/wulinpeng/Desktop/kcp_project/EzHook/local-plugin-repository/io/github/xdmrwu/ez-hook-compiler-plugin/0.0.2/ez-hook-compiler-plugin-0.0.2.jar
/Users/wulinpeng/Desktop/kcp_project/EzHook/demo/src/commonMain/kotlin/com/wulinpeng/ezhook/demo/Main.kt
/Users/wulinpeng/Desktop/kcp_project/EzHook/demo/src/commonMain/kotlin/com/wulinpeng/ezhook/demo/hook/TestManagerHook.kt
核心参数总结如下
- --module-name:当前编译的模块名称
- -produce:产物类型,当前指定编译为 Library
- -Xplugin:如果当前模块配置了自定义的 KCP 插件,那么会通过改参数传递KCP 插件的 jar 包路径
最终 konanc 会触发编译器代码进行编译,入口在 K2Native中,这里首先会解析前面传递的-Xplugin参数,将 KCP 插件通过 ClassLoader 加载到内存中并完成注册
class K2Native : CLICompiler<K2NativeCompilerArguments>() {
override fun doExecute(@NotNull arguments: K2NativeCompilerArguments,
@NotNull configuration: CompilerConfiguration,
@NotNull rootDisposable: Disposable,
@Nullable paths: KotlinPaths?): ExitCode {
// ...
// 加载 KCP 插件并完成注册
val pluginLoadResult =
PluginCliParser.loadPluginsSafe(arguments.pluginClasspaths, arguments.pluginOptions, arguments.pluginConfigurations, configuration)
if (pluginLoadResult != ExitCode.OK) return pluginLoadResult
// ..
try {
// 执行编译逻辑
runKonanDriver(configuration, environment, rootDisposable)
} catch (e: Throwable) {
// ...
throw e
}
return ExitCode.OK
}
}
最终的代码编译逻辑在DynamicCompilerDriver中实现,该类负责 Kotlin/Native 所有类型产物的编译,KotlinNativeCompile 传递的是 library 类型产物,最终编译产物为 klib,具体逻辑如下所示
internal class DynamicCompilerDriver(private val performanceManager: CommonCompilerPerformanceManager?) : CompilerDriver() {
override fun run(config: KonanConfig, environment: KotlinCoreEnvironment) {
usingNativeMemoryAllocator {
usingJvmCInteropCallbacks {
PhaseEngine.startTopLevel(config) { engine ->
if (!config.compileFromBitcode.isNullOrEmpty()) produceBinaryFromBitcode(engine, config, config.compileFromBitcode!!)
else when (config.produce) {
CompilerOutputKind.PROGRAM -> produceBinary(engine, config, environment)
CompilerOutputKind.DYNAMIC -> produceCLibrary(engine, config, environment)
CompilerOutputKind.STATIC -> produceCLibrary(engine, config, environment)
CompilerOutputKind.FRAMEWORK -> produceObjCFramework(engine, config, environment)
// 编译为 library
CompilerOutputKind.LIBRARY -> produceKlib(engine, config, environment)
CompilerOutputKind.BITCODE -> error("Bitcode output kind is obsolete.")
CompilerOutputKind.DYNAMIC_CACHE -> produceBinary(engine, config, environment)
CompilerOutputKind.STATIC_CACHE -> produceBinary(engine, config, environment)
CompilerOutputKind.HEADER_CACHE -> produceBinary(engine, config, environment)
CompilerOutputKind.TEST_BUNDLE -> produceBundle(engine, config, environment)
}
}
}
}
}
private fun produceKlib(engine: PhaseEngine<PhaseContext>, config: KonanConfig, environment: KotlinCoreEnvironment) {
val serializerOutput = if (environment.configuration.getBoolean(CommonConfigurationKeys.USE_FIR))
// 编译为 KotlinIR
serializeKLibK2(engine, config, environment)
else
serializeKlibK1(engine, config, environment)
// 写入 klib
serializerOutput?.let { engine.writeKlib(it) }
}
}
综上,KotlinNativeCompile 整体流程流程如下图所示
KotlinNativeLink
KotlinNativeLink整体流程与 KotlinNativeCompile 类似,最终也是通过 CLI 触发 konanc 编译,但是编译参数有所不同,具体参数如下所示
konanc
-g
-enable-assertions
-Xinclude=/Users/wulinpeng/Desktop/kcp_project/EzHook/demo/build/classes/kotlin/macosArm64/main/klib/demo.klib
-library
/Users/wulinpeng/.konan/kotlin-native-prebuilt-macos-aarch64-2.0.21/klib/common/stdlib
-library
/Users/wulinpeng/Desktop/kcp_project/EzHook/demo-v2/build/classes/kotlin/macosArm64/main/klib/demo-v2.klib
-library
/Users/wulinpeng/.gradle/caches/modules-2/files-2.1/io.github.xdmrwu/ez-hook-library-macosarm64/0.0.2/3e8f0d77f5add361fdd0332ed43ef732b13ba860/library.klib
-library
/Users/wulinpeng/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlinx/kotlinx-datetime-macosarm64/0.6.1/8f8b97995e611b9f5207cf52b1d98d64100b9e3/kotlinx-datetime.klib
-library
/Users/wulinpeng/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlinx/kotlinx-serialization-core-macosarm64/1.6.2/9b73a3c99322130d2aba72d7a382879546251058/kotlinx-serialization-core.klib
-entry
com.wulinpeng.ezhook.demo.main
-no-endorsed-libs
-nostdlib
-output
/Users/wulinpeng/Desktop/kcp_project/EzHook/demo/build/bin/macosArm64/debugExecutable/Shared.kexe
-produce
program
-target
macos_arm64
-Xmulti-platform
-Xexternal-dependencies=/var/folders/dj/fry6r1852kn99cjp70_14dm00000gn/T/kotlin-native-external-dependencies14182981270788071442.deps
这段参数是用于编译 macos 上的可执行文件,核心参数如下
- -entry:指定程序运行的入口方法 main
- - output:指定输出产物为 .kexe
- -produce:指定产物类型为 program,配合--target 可以确定平台
与 KotlinNativeCompile 相比少了-Xplugin 参数,这也可以解释为什么 KCP 只能处理单一模块的 IR。
在编译器侧的逻辑和前面 KotlinNativeCompile 的逻辑一样,区别在于最后调用了produceBinary 生成可执行文件。
/**
* Produce a single binary artifact.
*/
private fun produceBinary(engine: PhaseEngine<PhaseContext>, config: KonanConfig, environment: KotlinCoreEnvironment) {
// ...
performanceManager.trackGeneration {
val backendContext = createBackendContext(config, frontendOutput, psiToIrOutput)
// 执行后端编译逻辑,生成 binary 产物
engine.runBackend(backendContext, psiToIrOutput.irModule)
}
}
// TopLevelPhases.kt
internal fun <C : PhaseContext> PhaseEngine<C>.runBackend(backendContext: Context, irModule: IrModuleFragment) {
// ...
useContext(backendContext) { backendEngine ->
fun createGenerationStateAndRunLowerings(fragment: BackendJobFragment): NativeGenerationState {
// ...
try {
val module = fragment.irModule
newEngine(generationState) { generationStateEngine ->
// ...
rootPerformanceManager.trackIRLowering {
// 对当前模块以及所有依赖模块执行 lower
generationStateEngine.lowerModuleWithDependencies(module)
}
}
return generationState
} catch (t: Throwable) {
generationState.dispose()
throw t
}
}
val fragments = backendEngine.splitIntoFragments(irModule)
val threadsCount = context.config.threadsCount
if (threadsCount == 1) {
fragments.forEach { fragment ->
runAfterLowerings(fragment, createGenerationStateAndRunLowerings(fragment))
}
} else {
// ...
}
}
}
internal fun PhaseEngine<NativeGenerationState>.lowerModuleWithDependencies(module: IrModuleFragment) {
// ...
// 获取所有依赖的 IrModule
val allModulesToLower = listOf(module) + dependenciesToCompile.reversed()
// 执行所有 lower phase
runIrValidationPhase(validateIrBeforeLowering, allModulesToLower)
runLowerings(getLoweringsUpToAndIncludingInlining(), allModulesToLower)
runIrValidationPhase(validateIrAfterInlining, allModulesToLower)
runLowerings(getLoweringsAfterInlining(), allModulesToLower)
runIrValidationPhase(validateIrAfterLowering, allModulesToLower)
mergeDependencies(module, dependenciesToCompile)
}
从源码可以看出 在lowerModuleWithDependencies中会整合当前模块和所有依赖模块的 IR,并进行 lower 处理。然而 Kotlin 编译器为了提升编译效率,会在首次编译时将三方依赖编译为目标平台的binary并缓存,下次编译直接使用缓存参与目标平台产物的编译, 具体实现如下
class KonanDriver(
val project: Project,
val environment: KotlinCoreEnvironment,
val configuration: CompilerConfiguration,
val compilationSpawner: CompilationSpawner
) {
fun run() {
val cacheBuilder = CacheBuilder(konanConfig, compilationSpawner)
if (cacheBuilder.needToBuild()) {
// 为三方依赖库构建缓存
cacheBuilder.build()
konanConfig = KonanConfig(project, configuration) // TODO: Just set freshly built caches.
}
// 执行编译
DynamicCompilerDriver(performanceManager).run(konanConfig, environment)
}
}
cacheBuilder.build 会并发执行所有三方库的缓存构建,触发类型为CompilerOutputKind.STATIC_CACHE,从该类的定义可以看出在 macos 平台上缓存产物为 .a 文件
enum class CompilerOutputKind {
STATIC_CACHE {
override fun suffix(target: KonanTarget?) = ".${target!!.family.staticSuffix}"
override fun prefix(target: KonanTarget?) = target!!.family.staticPrefix
},
}
enum class Family(
val exeSuffix: String,
val dynamicPrefix: String,
val dynamicSuffix: String,
val staticPrefix: String,
val staticSuffix: String
) {
OSX("kexe", "lib", "dylib", "lib", "a"),
IOS("kexe", "lib", "dylib", "lib", "a"),
TVOS("kexe", "lib", "dylib", "lib", "a"),
WATCHOS("kexe", "lib", "dylib", "lib", "a"),
LINUX("kexe", "lib", "so", "lib", "a"),
MINGW("exe", "", "dll", "lib", "a"),
ANDROID("kexe", "lib", "so", "lib", "a");
}
综上,KotlinNativeLink 整体流程流程如下图所示
Transform 逻辑实现
实现 Hook 逻辑
从上面的编译流程分析可以看出 KotlinNativeLink 阶段就是负责将当前项目依赖模块统一处理、编译成可执行产物,也确实在这个阶段对所有 IR 进行了统一的处理(Lower)。但是由于缓存机制的存在,这个阶段只会处理当前项目所有源码模块,而三方依赖模块会直接使用处理后的缓存,这样看来我们仍然无法统一处理整个项目所有的 IR。然而经过一番源码研究后发现这个缓存机制是可配置的,我们可以通过指定 gradle 参数来关闭这个缓存机制
// gradle.properties
kotlin.native.cacheKind=none
关闭之后在KotlinNativeLink阶段会统一处理所有三方库与项目源码的 IR,我们只需要想办法在这个阶段注入我们自定义的 IR 处理逻辑即可达成目标。上面提到最终会调用lowerModuleWithDependencies方法执行 lower,我们来看一下这块代码
internal fun PhaseEngine<NativeGenerationState>.lowerModuleWithDependencies(module: IrModuleFragment) {
// ...
// 获取所有依赖的 IrModule
val allModulesToLower = listOf(module) + dependenciesToCompile.reversed()
// 执行所有 lower phase
runIrValidationPhase(validateIrBeforeLowering, allModulesToLower)
runLowerings(getLoweringsUpToAndIncludingInlining(), allModulesToLower)
runIrValidationPhase(validateIrAfterInlining, allModulesToLower)
runLowerings(getLoweringsAfterInlining(), allModulesToLower)
runIrValidationPhase(validateIrAfterLowering, allModulesToLower)
mergeDependencies(module, dependenciesToCompile)
}
// NativeLoweringPhases.kt
internal val validateIrBeforeLowering = createSimpleNamedCompilerPhase<NativeGenerationState, IrModuleFragment>(
name = "ValidateIrBeforeLowering",
description = "Validate IR before lowering",
op = { context, module -> IrValidationBeforeLoweringPhase(context.context).lower(module) }
)
最终会使用NativeLoweringPhases.kt中定义的各个 LoweringPhase 对 IR 进行处理,由于该文件中定义的所有 LoweringPhase 都是顶层属性,那么我们很容易想到利用 java 反射来 Hook 这些LoweringPhase注入自定义的 IR 处理逻辑。以第一个validateIrBeforeLowering为例,它是通createSimpleNamedCompilerPhase创建的对象,具体 IR 处理逻辑在 op 参数中,createSimpleNamedCompilerPhase实现如下
fun <Context : LoggingContext, Input, Output> createSimpleNamedCompilerPhase(
name: String,
description: String,
preactions: Set<Action<Input, Context>> = emptySet(),
postactions: Set<Action<Output, Context>> = emptySet(),
prerequisite: Set<AbstractNamedCompilerPhase<*, *, *>> = emptySet(),
outputIfNotEnabled: (PhaseConfigurationService, PhaserState<Input>, Context, Input) -> Output,
op: (Context, Input) -> Output
): SimpleNamedCompilerPhase<Context, Input, Output> = object : SimpleNamedCompilerPhase<Context, Input, Output>(
name,
description,
preactions = preactions,
postactions = postactions.map { f ->
fun(actionState: ActionState, data: Pair<Input, Output>, context: Context) = f(actionState, data.second, context)
} .toSet(),
prerequisite = prerequisite,
) {
override fun outputIfNotEnabled(phaseConfig: PhaseConfigurationService, phaserState: PhaserState<Input>, context: Context, input: Input): Output =
outputIfNotEnabled(phaseConfig, phaserState, context, input)
override fun phaseBody(context: Context, input: Input): Output =
op(context, input)
}
本质上创建了一个匿名内部类,该类会捕获外部的 op 参数,在生成的字节码中会有一个参数与之对应$op,我们只需要通过反射替换该属性即可完成自定义逻辑注入,具体实现如下
private fun hookValidateIrBeforeLowering(allModules: MutableList<IrModuleFragment>, transformer: (CommonBackendContext, IrModuleFragment) -> Unit) {
val clazz = Class.forName("org.jetbrains.kotlin.backend.konan.driver.phases.NativeLoweringPhasesKt")
// 获取 validateIrBeforeLowering
val lower = clazz.declaredFields.firstOrNull { it.name == "validateIrBeforeLowering" } ?.apply {
isAccessible = true
} !!.get(null)
// 替换 $op 属性
lower.javaClass.getDeclaredField("$op").apply {
isAccessible = true
} .set(lower, { context: LoggingContext, module: IrModuleFragment ->
val innerContext = context.javaClass.getDeclaredField("context").apply {
isAccessible = true
} .get(context) as CommonBackendContext
// 在这插入自定义逻辑
// ...
// 调用原始逻辑
IrValidationBeforeLoweringPhase(innerContext as CommonBackendContext).lower(module)
})
}
在 Transform 的设计中会有 traverse 和 transform 两个阶段,前者用于信息采集,后者实现 IR 转换,所以我们可以 Hook Lower 阶段前两个LoweringPhase,分别实现这两个阶段,具体代码见NativeIrLoweringExtension.kt
注入 Hook 逻辑
在实现 Hook 逻辑后,我们还需要选择合适的时机来执行这个 Hook 方法。通过前面的分析,我们知道 KotlinNativeCompile 阶段会调用自定义的编译器插件。同时,KotlinNativeCompile 和 KotlinNativeLink 这两个任务默认在同一进程中运行,并复用相同的 ClassLoader。因此,在自定义编译器插件中执行 Hook 方法是可以对后续的 KotlinNativeLink 任务产生影响,具体代码见EzHookCompilerRegister.kt
Kotlin/JS
Kotlin / JS 与 Kotlin/Native 整体流程比较像,在 Gradle 层面也有Kotlin2JsCompile和KotlinJsIrLink两个 Task,Kotlin2JsCompile负责将模块编译为 klib,KotlinJsIrLink 负责将所有 IR 编译为 JS 代码。
如何调试
在分析过程中发现 JS 的编译器后端无端断点调试,排查后发现编译 JS 默认会启动新的进程执行编译,为了能够正常断点调试可以在 gradle.properties 中添加配置开启当前进程编译
kotlin.compiler.execution.strategy=in-process
编译流程分析
Kotlin2JsCompile
与 Kotlin/Native 类似,Kotlin2JsCompile负责整合编译参数、通过 GradleKotlinCompilerRunner 执行编译命令触发编译,相关代码如下所示
@CacheableTask
abstract class Kotlin2JsCompile {
override fun callCompilerAsync(
args: K2JSCompilerArguments,
inputChanges: InputChanges,
taskOutputsBackup: TaskOutputsBackup?,
) {
// 处理参数
processArgsBeforeCompile(args)
// 触发编译
compilerRunner.runJsCompilerAsync(
args,
environment,
taskOutputsBackup
)
compilerRunner.errorsFiles?.let { gradleMessageCollector.flush(it) }
}
}
完整的编译命令如下所示
-Xir-only
-Xir-produce-klib-dir
-libraries
/Users/wulinpeng/Desktop/kcp_project/EzHook/demo-v2/build/libs/demo-v2-js.klib:/Users/wulinpeng/.gradle/caches/modules-2/files-2.1/io.github.xdmrwu/ez-hook-library-js/0.0.2/4503db3c0c0cc648166c944bbbd0cd97f2ef0779/library-js.klib:/Users/wulinpeng/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-dom-api-compat/2.0.21/537dbda0c5c5ee06c81b2c0d1f466df60d810e3f/kotlin-dom-api-compat-2.0.21.klib:/Users/wulinpeng/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-stdlib-js/2.0.21/8047778f196f3cdc817f1e90eb45f08b4837ba9/kotlin-stdlib-js-2.0.21.klib
-main
call
-meta-info
-ir-output-name
demo
-no-stdlib
-ir-output-dir
/Users/wulinpeng/Desktop/kcp_project/EzHook/demo/build/classes/kotlin/js/main
-source-map
-source-map-embed-sources
never
-target
es5
-Xfragment-refines=jsMain:commonMain
-Xfragment-sources=commonMain:/Users/wulinpeng/Desktop/kcp_project/EzHook/demo/src/commonMain/kotlin/com/wulinpeng/ezhook/demo/Main.kt,commonMain:/Users/wulinpeng/Desktop/kcp_project/EzHook/demo/src/commonMain/kotlin/com/wulinpeng/ezhook/demo/hook/TestManagerHook.kt
-Xfragments=jsMain,commonMain
-Xmulti-platform
-Xplugin=/Users/wulinpeng/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-scripting-compiler-embeddable/2.0.21/ac0c290312174e309c3e65711ab29fed1442532a/kotlin-scripting-compiler-embeddable-2.0.21.jar,/Users/wulinpeng/Desktop/kcp_project/EzHook/local-plugin-repository/io/github/xdmrwu/ez-hook-compiler-plugin/0.0.2/ez-hook-compiler-plugin-0.0.2.jar,/Users/wulinpeng/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-scripting-compiler-impl-embeddable/2.0.21/84c79a332f515fd6dd3ea2ab50c227c4a5756c37/kotlin-scripting-compiler-impl-embeddable-2.0.21.jar,/Users/wulinpeng/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-scripting-jvm/2.0.21/939d8b644308f8d97c60317df76ee40299475831/kotlin-scripting-jvm-2.0.21.jar,/Users/wulinpeng/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-scripting-common/2.0.21/2aeb50e2df2ef94f6b90b7ab2c56d5e18d3687c1/kotlin-scripting-common-2.0.21.jar,/Users/wulinpeng/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-stdlib/2.0.21/618b539767b4899b4660a83006e052b63f1db551/kotlin-stdlib-2.0.21.jar,/Users/wulinpeng/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-gradle-plugin-api/2.0.21/2fe6828d39788b439f1a44576c4fd2d98862e77b/kotlin-gradle-plugin-api-2.0.21.jar,/Users/wulinpeng/.gradle/caches/modules-2/files-2.1/org.jetbrains/annotations/13.0/919f0dfe192fb4e063e7dacadee7f8bb9a2672a9/annotations-13.0.jar,/Users/wulinpeng/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-gradle-plugin-annotations/2.0.21/585ea547101485df5165acbffc51b760b0bf2728/kotlin-gradle-plugin-annotations-2.0.21.jar,/Users/wulinpeng/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-native-utils/2.0.21/ccdbc49a5d23f7ab782fbdff20c9426cf074f2d7/kotlin-native-utils-2.0.21.jar,/Users/wulinpeng/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-tooling-core/2.0.21/952179e9b7f114e78274ca73cea6df8fce3c8b3b/kotlin-tooling-core-2.0.21.jar,/Users/wulinpeng/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-script-runtime/2.0.21/c9b044380ad41f89aa89aa896c2d32a8c0b2129d/kotlin-script-runtime-2.0.21.jar,/Users/wulinpeng/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-util-klib/2.0.21/cc042c4a50a1ee46695a8f2f928c196223bdd7dc/kotlin-util-klib-2.0.21.jar,/Users/wulinpeng/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-util-io/2.0.21/4467ad395771e3d12ca864fd9949e1404920f558/kotlin-util-io-2.0.21.jar
-Xir-per-module-output-name=EzHook-demo
-Xir-per-module-output-name=EzHook-demo
-Xir-module-name=EzHook:demo
/Users/wulinpeng/Desktop/kcp_project/EzHook/demo/src/commonMain/kotlin/com/wulinpeng/ezhook/demo/Main.kt
/Users/wulinpeng/Desktop/kcp_project/EzHook/demo/src/commonMain/kotlin/com/wulinpeng/ezhook/demo/hook/TestManagerHook.kt
- -Xir-module-name: 当前编译模块名称
- -Xir-produce-klib-dir:编译 klib
- -Xplugin:如果当前模块配置了自定义的 KCP 插件,那么会通过改参数传递KCP 插件的 jar 包路径
最终会触发编译器代码进行编译,入口在 K2JsIrCompiler中,这里首先会解析前面传递的-Xplugin参数,将 KCP 插件通过 ClassLoader 加载到内存中并完成注册
class K2JsIrCompiler : CLICompiler<K2JSCompilerArguments>() {
override fun doExecute(
arguments: K2JSCompilerArguments,
configuration: CompilerConfiguration,
rootDisposable: Disposable,
paths: KotlinPaths?
): ExitCode {
// ...
// 加载插件
val pluginLoadResult = loadPlugins(paths, arguments, configuration)
if (pluginLoadResult != OK) return pluginLoadResult
// ...
try {
// 执行代码编译
val ir2JsTransformer = Ir2JsTransformer(arguments, module, phaseConfig, messageCollector, mainCallArguments)
val outputs = ir2JsTransformer.compileAndTransformIrNew()
// ...
} catch (e: CompilationException) {
// ...
return INTERNAL_ERROR
}
return OK
}
}
综上,Kotlin2JsCompile 整体流程流程如下图所示
KotlinJsIrLink
KotlinJsIrLink 和 Kotlin2JsCompile 流程相似,区别在于编译参数,具体参数如下
-Xcache-directory=/Users/wulinpeng/Desktop/kcp_project/EzHook/demo/build/klib/cache/js/developmentLibrary
-Xinclude=/Users/wulinpeng/Desktop/kcp_project/EzHook/demo/build/classes/kotlin/js/main
-Xir-only
-Xir-produce-js
-libraries
/Users/wulinpeng/Desktop/kcp_project/EzHook/demo-v2/build/libs/demo-v2-js.klib:/Users/wulinpeng/.gradle/caches/modules-2/files-2.1/io.github.xdmrwu/ez-hook-library-js/0.0.2/4503db3c0c0cc648166c944bbbd0cd97f2ef0779/library-js.klib:/Users/wulinpeng/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlinx/kotlinx-datetime-js/0.6.1/723f6ab9f4c7b370bbee0d79d63c96af9b68055f/kotlinx-datetime-js-0.6.1.klib:/Users/wulinpeng/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlinx/kotlinx-serialization-core-js/1.6.2/27bceec3b89d0eec8f951f8b25f16fffa0df214e/kotlinx-serialization-core-js-1.6.2.klib:/Users/wulinpeng/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-dom-api-compat/2.0.21/537dbda0c5c5ee06c81b2c0d1f466df60d810e3f/kotlin-dom-api-compat-2.0.21.klib:/Users/wulinpeng/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-stdlib-js/2.0.21/8047778f196f3cdc817f1e90eb45f08b4837ba9/kotlin-stdlib-js-2.0.21.klib
-main
call
-meta-info
-no-stdlib
-ir-output-dir
/Users/wulinpeng/Desktop/kcp_project/EzHook/demo/build/compileSync/js/main/developmentLibrary/kotlin
-source-map
-source-map-embed-sources
never
-target
es5
-Xmulti-platform
-Xir-module-name=EzHook:demo
-Xir-per-module
-ir-output-name=EzHook-demo
- -Xir-produce-js:编译成 JS 产物
和 KotlinNativeLink 类似,在这个阶段不会带上 KCP 插件。最终的编译逻辑在JsIrCompilerWithIC 中实现
class JsIrCompilerWithIC {
override fun compile(allModules: Collection<IrModuleFragment>, dirtyFiles: Collection<IrFile>): List<() -> JsIrProgramFragments> {
// ...
// 执行 lower
lowerPreservingTags(allModules, context, phaseConfig, context.irFactory.stageController as WholeWorldStageController)
val transformer = IrModuleToJsTransformer(context, shouldReferMainFunction = mainArguments != null)
return transformer.makeIrFragmentsGenerators(dirtyFiles, allModules)
}
}
Transform 逻辑实现
实现 Hook 逻辑
与 Kotlin/Native 类似,我们只需要 Hook 对应的 LoweringPhase 即可,代码见JsIrLoweringExtension.kt
注入 Hook 逻辑
In-progress
上面介绍过在 Kotlin /Native 下是在 KCP 的执行入口来触发 Hook 逻辑,Compile 与 Link 两个 Task 在一个进程且复用 ClassLoader 是改方案可行的重要前提。然而在 Kotlin/ JS 中,尽管我们设置 in-progress 保证了 Compile 和 Link在一个进程中,但是 GradleKotlinCompilerWork在不同 Task 之间没有复用 ClassLoader ,导致该方案无效。
class GradleKotlinCompilerWork {
private fun compileInProcessImpl(messageCollector: MessageCollector): ExitCode {
val stream = ByteArrayOutputStream()
val out = PrintStream(stream)
// todo: cache classloader?
val classLoader = URLClassLoader(config.compilerFullClasspath.map { it.toURI().toURL() } .toTypedArray())
val servicesClass = Class.forName(Services::class.java.canonicalName, true, classLoader)
val emptyServices = servicesClass.getField("EMPTY").get(servicesClass)
val compiler = Class.forName(config.compilerClassName, true, classLoader)
// ...
}
}
那么是否可以将 KotlinJsIrLink 的编译参数中应用 KCP 插件来实现呢?首先从源码入手看一下为什么KotlinJsIrLink没有应用 KCP,将 KCP 插件路径赋值到编译参数中的逻辑在AbstractKotlinCompileConfig中
internal abstract class AbstractKotlinCompileConfig<TASK : AbstractKotlinCompile<*>>
constructor(compilationInfo: KotlinCompilationInfo) : this(
compilationInfo.project,
compilationInfo.project.topLevelExtension
) {
configureTask { task ->
// ,,,
compilationInfo.tcs.compilation.let { compilation ->
// ...
task.pluginClasspath.from(
compilation.internal.configurations.pluginConfiguration
)
}
// ...
}
}
}
而KotlinJsIrLinkConfig继承该类后主动清空了 pluginClassplugin,因此 KotlinJsIrLink 不会应用 KCP
internal open class KotlinJsIrLinkConfig(
private val binary: JsIrBinary,
) : BaseKotlin2JsCompileConfig<KotlinJsIrLink>(KotlinCompilationInfo(binary.compilation)) {
private val compilation
get() = binary.compilation
init {
configureTask { task ->
// Link tasks are not affected by compiler plugin, so set to empty
task.pluginClasspath.setFrom(objectFactory.fileCollection())
// ...
}
}
}
如果你具备 Gradle Hook 相关经验,可能已经想到一种方法:通过调整 classpath 顺序来覆盖 KGP 的实现类。在这里,我们可以覆盖 KotlinJsIrLinkConfig 类的实现。具体步骤是将 KotlinJsIrLinkConfig 的代码拷贝到自定义的 Gradle Plugin 中,并确保该插件在 classpath 中优先加载,从而取代原始的 KotlinJsIrLinkConfig 实现。以下是具体的实现方式:
internal open class KotlinJsIrLinkConfig(
private val binary: JsIrBinary,
) : BaseKotlin2JsCompileConfig<KotlinJsIrLink>(KotlinCompilationInfo(binary.compilation)) {
private val compilation
get() = binary.compilation
init {
configureTask { task ->
// 注释掉这一行,应用 KCP 到 KotlinJsIrLink
// task.pluginClasspath.setFrom(objectFactory.fileCollection())
// ...
}
}
}
daemon
然而默认情况下 Kotlin/JS 是走 daemon 模式的,改模式下 Compile 与 Link 共用一个 daemon 进程并复用 ClassLoader。由于上面为 KotlinJsIrLink 任务应用了 KCP 会导致相关的类被 Hook 两次(加载 KCP 没有复用 ClassLoader),进而导致运行时异常,因此需要在对应的 Hook 逻辑中判断原始 Lower 是否已经被 Hook,具体逻辑见JsIrLoweringExtension.kt
EzHook实现
实现逻辑
框架核心需要实现的功能是替换目标方法的实现,并且在 Hook 方法中能够调用原始方法并修改参数, 实现逻辑如下
- 拷贝目标方法用于后续调用原始方法
class EzHookIrTransformer(val collectInfos: List<EzHookInfo>, val pluginContext: CommonBackendContext): IrElementTransformerVoidWithContext() {
companion object {
private const val LAST_PARAM_NAME = "ez_hook_origin"
private const val NEW_FUNCTION_SUFFIX = "_ez_hook"
}
override fun visitFunctionNew(function: IrFunction): IrStatement {
// ...
val newFunction = function.copyFunctionToParent("${function.name.asString()}$NEW_FUNCTION_SUFFIX")
}
}
fun IrFunction.copyFunctionToParent(newName: String, newParent: IrDeclarationParent = parent): IrFunction {
return deepCopyWithSymbols(newParent).apply {
name = Name.identifier(newName)
setDeclarationsParent(newParent)
(newParent as IrDeclarationContainer).addChild(this)
}
}
- 将目标方法实现替换为调用 Hook 方法,并传递当前对象
class EzHookIrTransformer(val collectInfos: List<EzHookInfo>, val pluginContext: CommonBackendContext): IrElementTransformerVoidWithContext() {
companion object {
private const val LAST_PARAM_NAME = "ez_hook_origin"
private const val NEW_FUNCTION_SUFFIX = "_ez_hook"
}
override fun visitFunctionNew(function: IrFunction): IrStatement {
// ...
function.body = pluginContext.createIrBuilder(function.symbol).irBlockBody(function) {
val result = createTmpVariable(
irExpression = irCall(hookFunction).apply {
function.valueParameters.forEachIndexed { index, param ->
putValueArgument(index, irGet(param))
}
// put dispatch receiver as the last param if exist
if (function.isClassMember()) {
putValueArgument(function.valueParameters.size, irGet(function.dispatchReceiverParameter!!))
}
} ,
nameHint = "returnValue",
origin = IrDeclarationOrigin.DEFINED
)
+irReturn(irGet(result))
}
}
}
- 为 Hook 方法增加一个参数作为目标对象
class EzHookIrTransformer(val collectInfos: List<EzHookInfo>, val pluginContext: CommonBackendContext): IrElementTransformerVoidWithContext() {
companion object {
private const val LAST_PARAM_NAME = "ez_hook_origin"
private const val NEW_FUNCTION_SUFFIX = "_ez_hook"
}
override fun visitFunctionNew(function: IrFunction): IrStatement {
// ...
if (function.isClassMember()) {
hookFunction.addValueParameter(Name.identifier(LAST_PARAM_NAME), function.parentAsClass.defaultType)
}
}
}
- 找到 Hook 方法中的
callOrigin调用,替换为目标对象的 copy 方法调用
class EzHookIrTransformer(val collectInfos: List<EzHookInfo>, val pluginContext: CommonBackendContext): IrElementTransformerVoidWithContext() {
companion object {
private const val LAST_PARAM_NAME = "ez_hook_origin"
private const val NEW_FUNCTION_SUFFIX = "_ez_hook"
}
override fun visitFunctionNew(function: IrFunction): IrStatement {
// ...
hookFunction.transform(EzHookCallOriginTransformer(hookFunction, newFunction, pluginContext), null)
}
}
class EzHookCallOriginTransformer(val hookFunction: IrFunction, val targetFunction: IrFunction, val context: CommonBackendContext): IrElementTransformerVoidWithContext() {
companion object {
private const val CALL_ORIGIN = "com.wulinpeng.ezhook.runtime.callOrigin"
}
/**
* variables to override this function params
*/
private val overrideParams = mutableListOf<IrVariable>()
override fun visitVariable(declaration: IrVariable): IrStatement {
val name = declaration.name.asString()
val type = declaration.type.getClass()!!.kotlinFqName!!.asString()
if (hookFunction.valueParameters.any {
it.name.asString() == name && it.type.getClass()!!.kotlinFqName!!.asString() == type
}) {
overrideParams.add(declaration)
}
return super.visitVariable(declaration)
}
override fun visitCall(expression: IrCall): IrExpression {
if (expression.symbol.owner.fqNameWhenAvailable?.asString() == CALL_ORIGIN) {
context.createIrBuilder(hookFunction.symbol).apply {
return irCall(targetFunction.symbol).apply {
// for class member function, make the last param as dispatch receiver
if (targetFunction.isClassMember()) {
val lastParam = hookFunction.valueParameters.last()
dispatchReceiver = irGet(lastParam)
}
for (i in 0 until targetFunction.valueParameters.size) {
val valueParam = targetFunction.valueParameters[i]
// override param
val irVariable = overrideParams.find { it.name.asString() == valueParam.name.asString() }
putValueArgument(i, irGet(irVariable ?: valueParam))
}
}
}
} else {
return super.visitCall(expression)
}
}
}
问题适配
-
Invalid record
在 iOS Target 上运行时会出现编译问题,具体信息如下
error: Invalid record (Producer: 'LLVM11.1.0' Reader: 'LLVM 11.1.0')
1 error generated.
at org.jetbrains.kotlin.konan.exec.Command.handleExitCode(ExecuteCommand.kt:129)
at org.jetbrains.kotlin.konan.exec.Command.execute(ExecuteCommand.kt:85)
at org.jetbrains.kotlin.backend.konan.BitcodeCompiler.runTool(BitcodeCompiler.kt:35)
at org.jetbrains.kotlin.backend.konan.BitcodeCompiler.hostLlvmTool(BitcodeCompiler.kt:44)
at org.jetbrains.kotlin.backend.konan.BitcodeCompiler.clang(BitcodeCompiler.kt:72)
at org.jetbrains.kotlin.backend.konan.BitcodeCompiler.makeObjectFile(BitcodeCompiler.kt:87)
at org.jetbrains.kotlin.backend.konan.driver.phases.ObjectFilesKt.ObjectFilesPhase$lambda$0(ObjectFiles.kt:22)
at org.jetbrains.kotlin.backend.common.phaser.PhaseBuildersKt$createSimpleNamedCompilerPhase$3.phaseBody(PhaseBuilders.kt:91)
at org.jetbrains.kotlin.backend.common.phaser.PhaseBuildersKt$createSimpleNamedCompilerPhase$3.phaseBody(PhaseBuilders.kt:79)
at org.jetbrains.kotlin.backend.common.phaser.SimpleNamedCompilerPhase.phaseBody(CompilerPhase.kt:226)
at org.jetbrains.kotlin.backend.common.phaser.AbstractNamedCompilerPhase.invoke(CompilerPhase.kt:113)
at org.jetbrains.kotlin.backend.konan.driver.PhaseEngine.runPhase(Machinery.kt:120)
at org.jetbrains.kotlin.backend.konan.driver.PhaseEngine.runPhase$default(Machinery.kt:111)
at org.jetbrains.kotlin.backend.konan.driver.phases.TopLevelPhasesKt.compileAndLink(TopLevelPhases.kt:295)
at org.jetbrains.kotlin.backend.konan.driver.phases.TopLevelPhasesKt.runBackend$lambda$12$runAfterLowerings(TopLevelPhases.kt:127)
at org.jetbrains.kotlin.backend.konan.driver.phases.TopLevelPhasesKt.runBackend(TopLevelPhases.kt:139)
at org.jetbrains.kotlin.backend.konan.driver.DynamicCompilerDriver.produceObjCFramework(DynamicCompilerDriver.kt:83)
at org.jetbrains.kotlin.backend.konan.driver.DynamicCompilerDriver.run$lambda$2$lambda$1$lambda$0(DynamicCompilerDriver.kt:43)
at org.jetbrains.kotlin.backend.konan.driver.PhaseEngine$Companion$startTopLevel$topLevelPhase$1.phaseBody(Machinery.kt:79)
at org.jetbrains.kotlin.backend.konan.driver.PhaseEngine$Companion$startTopLevel$topLevelPhase$1.phaseBody(Machinery.kt:73)
at org.jetbrains.kotlin.backend.common.phaser.SimpleNamedCompilerPhase.phaseBody(CompilerPhase.kt:226)
at org.jetbrains.kotlin.backend.common.phaser.AbstractNamedCompilerPhase.invoke(CompilerPhase.kt:113)
at org.jetbrains.kotlin.backend.konan.driver.PhaseEngine$Companion.startTopLevel(Machinery.kt:86)
at org.jetbrains.kotlin.backend.konan.driver.DynamicCompilerDriver.run(DynamicCompilerDriver.kt:37)
at org.jetbrains.kotlin.backend.konan.KonanDriver.run(KonanDriver.kt:135)
at org.jetbrains.kotlin.cli.bc.K2Native.runKonanDriver(K2Native.kt:157)
at org.jetbrains.kotlin.cli.bc.K2Native.doExecute(K2Native.kt:65)
at org.jetbrains.kotlin.cli.bc.K2Native.doExecute(K2Native.kt:34)
这是由于在编译期为 Hook 方法添加了参数,导致 Hook 方法签名变更,最终与生成的头文件签名不一致造成编译失败。Compose Compiler 也有类似的问题,它们在编译期会为 @Composable 方法添加额外的参数,解决方案就是为这些方法加上@HiddenFromObjC注解,保证这些方法不会导出,下面是 Compose Compiler 的实现
/**
* AddHiddenFromObjCLowering looks for functions and properties with @Composable types and
* adds the `kotlin.native.HiddenFromObjC` annotation to them.
* [docs](https://kotlinlang.org/api/latest/jvm/stdlib/kotlin.native/-hidden-from-obj-c/)
*/
class AddHiddenFromObjCLowering {
override fun visitFunction(declaration: IrFunction): IrStatement {
val f = super.visitFunction(declaration) as IrFunction
// 非 public 的方法不会被导出,不需要添加 @HiddenFromObjC
if (f.isLocal || f.isSyntheticFun() ||
!(f.visibility == DescriptorVisibilities.PUBLIC ||
f.visibility == DescriptorVisibilities.PROTECTED)
)
return f
// 为 @Composable 方法添加 @HiddenFromObjC 注解
if (f.hasComposableAnnotation() || f.needsComposableRemapping()) {
f.addHiddenFromObjCAnnotation()
hideFromObjCDeclarationsSet?.add(f)
currentShouldAnnotateClass = true
}
return f
}
}
由于我们 Hook 的时机比较靠后,使用这种方式加上@HiddenFromObjC 注解也无法解决该问题,所以就需要使用方主动在 Hook 方法上增加@HiddenFromObjC注解来规避这个问题。
-
Cannot read properties of undefined (reading 'a')
通过执行jsNodeDevelopmentRun运行 js 代码时会出现崩溃,堆栈如下
/Users/wulinpeng/Desktop/kcp_project/EzHook/build/js/packages/EzHook-demo/kotlin/EzHook-demo-v2.js:15
}(function (_, kotlin_kotlin, kotlin_EzHook_demo) {
^
TypeError: Cannot read properties of undefined (reading 'a')
at /Users/wulinpeng/Desktop/kcp_project/EzHook/build/js/packages/EzHook-demo/kotlin/EzHook-demo-v2.js:19:40
at /Users/wulinpeng/Desktop/kcp_project/EzHook/build/js/packages/EzHook-demo/kotlin/EzHook-demo-v2.js:5:5
at Object.<anonymous> (/Users/wulinpeng/Desktop/kcp_project/EzHook/build/js/packages/EzHook-demo/kotlin/EzHook-demo-v2.js:15:2)
at Module._compile (node:internal/modules/cjs/loader:1455:14)
at Object.Module._extensions..js (node:internal/modules/cjs/loader:1534:10)
at Module.load (node:internal/modules/cjs/loader:1265:32)
at Function.Module._load (node:internal/modules/cjs/loader:1081:12)
at Module.require (node:internal/modules/cjs/loader:1290:19)
at require (node:internal/modules/helpers:188:18)
at /Users/wulinpeng/Desktop/kcp_project/EzHook/build/js/packages/EzHook-demo/kotlin/EzHook-demo.js:5:29
这是由于在编译期我们为目标方法添加了对 Hook 方法的依赖(引用),导致模块间存在循环依赖。而在 JS 中模块循环依赖时非常容易出现初始化错误,比如这里 undefined 就是因为 demo 模块和 demo-v2 模块循环依赖导致 demo 模块未完成初始化。
针对这种情况需要将 Hook 方法内联到目标模块中,以此来避免生成 JS 代码出现循环依赖的问题。