专栏名称: 郭霖
Android技术分享平台,每天都有优质技术文章推送。你还可以向公众号投稿,将自己总结的技术心得分享给大家。
目录
相关文章推荐
郭霖  ·  Android Studio 中的 ... ·  昨天  
郭霖  ·  一篇文章带你彻底掌握Optional ·  2 天前  
鸿洋  ·  掌握这17张图,掌握RecyclerView ... ·  3 天前  
郭霖  ·  Android - 监听网络状态 ·  1 周前  
51好读  ›  专栏  ›  郭霖

从零实现一个 KMP 插桩框架: EzHook

郭霖  · 公众号  · android  · 2024-12-13 08:00

正文



/   今日科技快讯   /


近日,极越CEO夏一平发布内部信承认,公司目前正遇到困难,需要立即调整。他提出了合并职能重复的部门与岗位、削减短期内无法提升财务表现的项目等一系列举措。据多位极越员工处透露,公司员工11月份的工资已经到账,但是11月、12月的社保需要自行缴纳。目前公司所有业务陷入停滞,内部大群全员禁言。极越计划留下极少数员工维持业务。其余员工有两种方案可以选择,一是离职,等到明年2月底拿N+1补偿;二是选择留下来,但12月开始薪资无法发放,职员将处于“自费上班”的状况。


/   作者简介   /


明天周六啦,提前祝大家周末愉快!


本篇文章来自XDMrWu的投稿,文章主要分享了自己开发的一个 KMP 插桩框架,相信会对大家有所帮助!同时也感谢作者贡献的精彩文章。


XDMrWu的博客地址:

https://juejin.cn/user/395479914649933/posts


/   背景   /


Kotlin Multiplatform近年来逐渐崭露头角,已成为备受关注的跨平台开发方案。它支持代码在 Android、iOS、Web、Destop 等多个平台之间复用,能够大幅提升开发效率与需求的多端一致性。并且随着鸿蒙 Next 的发布,越来越多公司开始着手适配 KMP 以满足未来移动端跨平台开发的需求,由此可见 KMP的发展潜力不可小觑。


然而,在将 KMP 应用到实际项目中时发现缺少一个关键能力——「代码插桩」。这一能力对于修改第三方库、面向切片编程(AOP)以及构建业务框架等场景至关重要。在 Android 平台上,我们可以基于 Transform 实现自定义的字节码转换,并且社区也提供了丰富的 AOP 框架(如 Lancethttps://github.com/eleme/lancet))。但目前还没有一款基于 KMP 的类 Transform 能力与对应的 AOP 框架,因此本文将探索 KMP 平台上类 Transform 能力,并基于该能力开发一款适用于 KMP 的 AOP 框架——EzHook。


本文仅关注 Kotlin/Native、Kotlin/JS两个平台的插桩能力,Android 平台继续使用 Transform

本文重在探讨方案的实现思路,包含较多 Kotlin 编译器源码与流程分析、调试思路等,如果只想了解框架如何使用那么只需关注「如何使用 EzHook」、「如何实现自定义 Transform」两节即可

本文基于 Kotlin 2.0.21 版本分析


/   使用EzHook   /


Github地址:https://github.com/XDMrWu/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 来调用目标方法


@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

1. 拷贝 KotlinJsIrLinkConfig.kt 到你的 Gradle Plugin 模块中,请注意保持包名一致,并且在 classpath 中将该 Gradle Plugin 放在最前面
2. 拷贝 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 Pluginhttps://bnorm.medium.com/writing-your-second-kotlin-compiler-plugin-part-1-project-setup-7b05c7d93f6c)。

/   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  PhaseEngine.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.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.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(
        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  createSimpleNamedCompilerPhase(
    name: String,
    description: String,
    preactions: Set<Action<Input, Context>> = emptySet()
,
    postactions: Set> = emptySet(),
    prerequisite: Set> = emptySet(),
    outputIfNotEnabled: (PhaseConfigurationService, PhaserState, Context, Input) -> Output,
    op: (Context, Input) -> Output
): SimpleNamedCompilerPhase = object : SimpleNamedCompilerPhase(
    name,
    description,
    preactions = preactions,
    postactions = postactions.map { f ->
        fun(actionState: ActionStatedataPair<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: (CommonBackendContextIrModuleFragment) -> 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.kthttps://github.com/XDMrWu/EzHook/blob/main/compiler_plugin/src/main/kotlin/com/wulinpeng/ezhook/compiler/hook/NativeIrLoweringExtension.kt

注入 Hook 逻辑

在实现 Hook 逻辑后,我们还需要选择合适的时机来执行这个 Hook 方法。通过前面的分析,我们知道 KotlinNativeCompile 阶段会调用自定义的编译器插件。同时,KotlinNativeCompile 和 KotlinNativeLink 这两个任务默认在同一进程中运行,并复用相同的 ClassLoader。因此,在自定义编译器插件中执行 Hook 方法是可以对后续的 KotlinNativeLink 任务产生影响,具体代码见EzHookCompilerRegister.kthttps://github.com/XDMrWu/EzHook/blob/main/compiler_plugin/src/main/kotlin/com/wulinpeng/ezhook/compiler/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.canonicalNametrueclassLoader)
        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(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(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(https://github.com/XDMrWu/EzHook/blob/main/compiler_plugin/src/main/kotlin/com/wulinpeng/ezhook/compiler/hook/JsIrLoweringExtension.kt

/   EzHook实现   /

实现逻辑

框架核心需要实现的功能是替换目标方法的实现,并且在 Hook 方法中能够调用原始方法并修改参数, 实现逻辑如下


1. 拷贝目标方法用于后续调用原始方法

class EzHookIrTransformer(val collectInfos: Listval 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)
    }
}

2. 将目标方法实现替换为调用 Hook 方法,并传递当前对象

class EzHookIrTransformer(val collectInfos: Listval 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))
        }
    }
}

3. 为 Hook 方法增加一个参数作为目标对象

class EzHookIrTransformer(val collectInfos: Listval 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)
        }
    }
}

4. 找到 Hook 方法中的 callOrigin 调用,替换为目标对象的 copy 方法调用

class EzHookIrTransformer(val collectInfos: Listval 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()

    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)
        }
    }
}

问题适配

1. 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. (/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 代码出现循环依赖的问题。

推荐阅读:
我的新书,《第一行代码 第3版》已出版!
Android Native内存越用越多,会不会触发GC?
Kotlin异步Web框架,Ktor 3.0 来啦!

欢迎关注我的公众号
学习技术或投稿

长按上图,识别图中二维码即可关注