简介
本文简单讨论 Kotlin/Native 的包体积的优化方法,包括:
- 编译参数优化
- 精细管理导出符号+DCE的优化方法
(暂时未讨论 Kotlin/JVM 等情况)。其中的方法是基于社区讨论,或者基于 Kotlin 源码进行了一些扩展,从而初步达到结果。优化无止境,也会有其他优化手段,也欢迎一起探讨。
背景
在三端一码 和基于 Kotlin Multiplatform 的原生跨端研发范式背景下,我们 app 的 Android 端引入了 Kotlin/JVM,对 iOS 和鸿蒙端引入了 Kotlin/Native(未来也许会有 Kotlin/JS/Wasm),另外由于 KMP 跨端 UI 选型也包括了 Compose Multiplatform,也有一些 lottie(compottie) 等第三方库,所以引入 Kotlin + Compose 等框架后的整体安装包增量还是比较大的,挖掘一下 Kotlin/Native 代码里有哪些包体积有优化的空间,目标是降低用户下载包的大小,以及新的独立 app 接入 KMP/CMP 框架的一次性包体积成本。

优化方案一:
经过 Google 搜索,升级到 Kotlin 2.1 比 Kotlin 2.0 K/N 产物大了几M甚至更多,原因应该是 llvm 使用了比较新的版本,所以我们在 Kotlin 使用 Kotlin 官方 issue 平台 讨论的方案:
原理:主要是增加了Os 以及 globaldce,lto 等优化pass和编译参数
社区讨论:youtrack.jetbrains.com/issue/KT-74...
在"壳工程"里增加 freeCompilerArgs
:
这个编译参数优化手段能减小几MB(10%)的包体积,但是平均来讲,仅把 Kotlin 2.1 拉回到 Kotlin 2.0 同期包大小水平,我们还有没有其他的方法呢?
进一步分析 binary 产物
// 以下用 K/N 代替 Kotlin/Native 这个术语,这也是 Jetbrains 官方的用法。
Kotlin/Native 顾名思义是把 Kotlin 语言编译到机器代码,也就是 iOS 和鸿蒙绝大部分情况是 arm64 代码。
- 鸿蒙上的 K/N 的产物是一个 libxxx.so 文件,会被 DevEco Studio 最终 hap 包编译的时候,strip 掉 DWARF 调试信息,然后直接 copy 到鸿蒙 hap 安装包内
- iOS 的 K/N 最终编译产物是一个 framework 包,里面包含 .h 头文件和 .o 目标文件,.o 中的代码包括了一份 Kotlin 逻辑编译生成的机器码
笔者不是 iOS 出身的开发者,经过和专业同学的讨论,有以下几种方法看 iOS 端/鸿蒙端的Kotlin/Native 产物
- 方法1: iOS 使用 LinkMap 分析包大小: juejin.cn/post/696915...
- 方法2: iOS & 鸿蒙,使用 IDA Pro 逆向看带符号产物,间接推断哪些符号被裁掉了,以及函数长什么样子?
灵感点
一开始,我们入手点是在 iOS 或者鸿蒙 KMP 的 所谓的壳工程里,build.gradle.kts 中,把 kotlin -> native 配置的 export library 配置成 "不 export",会发现体积一下子小了很多(接近一半),但是相对应的产物 framework 或者 so 中的头文件的符号会减小到很少。摸索过程省略。。。
最终我们会看到以鸿蒙为例,so 产物中包括了以下几种函数符号:
- kotlin 函数的机器码:即 "_kfun" 函数,包含了纯 kotlin 逻辑的函数

- 从 C/ObjC 调用到 Kotlin 的桥接代码:即 "",每个函数都包含了:检查 Kotlin runtime 初始化、检查安全点等

- 从 Kotlin 调用到 C/ObjC的 c interop/oc interop 的代码,包含objc_alloc/init/createStableRef的模版代码:


测试方法
首先需要明确测试方法
iOS 测试方法:
- 需要在 xcode 配置编译 release,对应 Gradle
./gradlew linkReleaseFrameworkIosArm64
(注:一定不能用 debug 测试包体积,因为最终我们使用 release,走 opt 配置,会做编译器各种优化pass)
- 另外,需要最终看ipa的体积变化,而不只是看 framework 的,因为后面link一定会有符号裁剪
鸿蒙测试方法:
- DevEco IDE 最新版本(最新SDK),对应 Gradle
./gradlew linkReleaseOhosArm64
- 分别看 hap包之前 unstrip 和 hap包作为zip解压缩后的 libkmp.so 的体积
Kotlin 2.1.0 官方方案
经过翻代码,发现 Kotlin 2.1 比 Kotlin 2.0 多出一个特性:在 Kotlin 2.1 中,新增了一个特性允许通过 objCExportEntryPointsPath
配置文件来控制哪些Kotlin代码会被暴露到ObjC/Swift中。这个改动是由 github.com/JetBrains/k... 这个提交实现的。
这个提交应该可以帮助我们提供DCE+export的精细化的控制思路!
入口点确定:
Kotlin代码中通过 ObjCEntryPoints
接口来控制哪些代码被暴露给 ObjC 。在官方 commit 中新增的功能允许通过配置文件指定精确的导出规则,如果没有指定该配置,则默认使用ObjCEntryPoints.ALL
,即导出所有可见的代码。
DCE处理流程:
在 K/N 编译流程中,DCE是在 kotlin-native/backend.native/compiler/ir/backend.native/src/org/jetbrains/kotlin/backend/konan/driver/phases/TopLevelPhases.kt
文件中的 runCodegen
方法中调用的:
scss
/**
* Compile lowered [module] to object file.
* @return absolute path to object file.
*/
private fun PhaseEngine<NativeGenerationState>.runCodegen(module: IrModuleFragment) {
val optimize = context.shouldOptimize()
module.files.forEach {
runPhase(ReturnsInsertionPhase, it)
}
val moduleDFG = runPhase(BuildDFGPhase, module, disable = !optimize)
val devirtualizationAnalysisResults = runPhase(DevirtualizationAnalysisPhase, DevirtualizationAnalysisInput(module, moduleDFG), disable = !optimize)
// 这里:
val dceResult = runPhase(DCEPhase, DCEInput(module, moduleDFG, devirtualizationAnalysisResults), disable = !optimize)
runPhase(RemoveRedundantCallsToStaticInitializersPhase, RedundantCallsInput(moduleDFG, devirtualizationAnalysisResults, module), disable = !optimize)
runPhase(DevirtualizationPhase, DevirtualizationInput(module, devirtualizationAnalysisResults), disable = !optimize)
// Have to run after link dependencies phase, because fields from dependencies can be changed during lowerings.
// Inline accessors only in optimized builds due to separate compilation and possibility to get broken debug information.
module.files.forEach {
runPhase(PropertyAccessorInlinePhase, it, disable = !optimize)
runPhase(InlineClassPropertyAccessorsPhase, it, disable = !optimize)
runPhase(RedundantCoercionsCleaningPhase, it)
// depends on redundantCoercionsCleaningPhase
runPhase(UnboxInlinePhase, it, disable = !optimize)
}
runPhase(CreateLLVMDeclarationsPhase, module)
runPhase(GHAPhase, module, disable = !optimize)
runPhase(RTTIPhase, RTTIInput(module, dceResult))
val lifetimes = runPhase(EscapeAnalysisPhase, EscapeAnalysisInput(module, moduleDFG, devirtualizationAnalysisResults), disable = !optimize)
runPhase(CodegenPhase, CodegenInput(module, lifetimes))
}
具体执行代码是:
ini
val dceResult = runPhase(DCEPhase, DCEInput(module, moduleDFG, devirtualizationAnalysisResults), disable = !optimize)
DCE 的核心实现在 kotlin-native/backend.native/compiler/ir/backend.native/src/org/jetbrains/kotlin/backend/konan/optimizations/DCE.kt
文件中
- DCE 如何识别未使用的代码:
-
DCE 过程首先构建一个调用图(CallGraph),标记所有从根节点可达的函数
-
根节点包括:
-
所有被ObjC导出的函数(通过ObjCEntryPoints配置决定)
-
程序入口点和其他必要函数
-
所有从这些根节点无法达到的函数被视为"死代码",会被DCE移除
- ObjC导出与DCE的关系:
- 当你通过objcExportEntryPointsPath配置文件限制导出的函数时,那些不在白名单中的函数不会被标记为"根节点"
- 如果这些函数同时也没有被Kotlin代码本身使用(即从其他根节点不可达),它们会被DCE识别为死代码并移除
- 这样可以有效减少最终二进制文件的大小,因为只有真正需要的代码才会被保留
在代码实现上,主要涉及几个关键部分:
- ObjCExportMapper.shouldBeExposed 方法,这个方法决定了一个类或函数是否应该暴露给ObjC,它会检查:
- 是否在entryPoints中被指定(通过entryPoints.shouldBeExposed(descriptor))
- 是否有ObjCName注解(通过hasExportAnnotation(descriptor))
- 还会检查其他条件如可见性、是否被标记为hidden等
- ObjCEntryPoints实现:
- 默认实现ObjCEntryPoints.ALL总是返回true
- 自定义实现会检查给定的描述符是否匹配配置文件中指定的规则
- DCE 实现,实际执行DCE的代码,它会:
- 构建调用图
- 标记所有可达的函数
- 移除不可达的函数
总结:当通过-Xbinary=objcExportEntryPointsPath=export-entrypoints.txt配置了导出白名单后,Kotlin Native编译器会先根据这个白名单确定哪些代码应该导出到ObjC,然后在DCE阶段,只有被导出的或者从根节点可达的代码才会被保留,其他代码都会被DCE移除。这样可以有效减少最终二进制大小。
思路也就是:我们可以对 export 符号(包括全局函数、method类方法、property、callable 等等符号)做export配置,从而给后面的DCE pass做颗粒度更细的控制从而减少很多无用Kotlin函数的体积。
可以用一个图来解释为什么 export 可以协助 DCE来做死代码删除+减小包体积:

最终优化方案二:
-
增强官方 -Xbinary=objcExportEntryPointsPath 配置能力:支持 class 导出
-
引入一个新的 annotation,叫做 @ObjCExport 和 @CExport,和官方的 @CName和@ObjCName 类似,并在编译器走到
ObjCExportMapper.shouldBeExposed
方法里,增加一下判断 descriptor 或者它的"父亲" 是否挂着 @ObjCExport 注解的逻辑,如果有注解,那么给这些符号做导出到头文件,否则不做导出
上面的优化能实现 iOS framework 裁剪的非常恨:大于 50% 🤣;鸿蒙减小包体积 15% 以上。
代码在内部仓库,文章里具体代码省略。不过这个逻辑比较类似于 @HiddenFromObjC 的注解的反向逻辑。(当时@HiddenFromObjC 一般用于 @Composable函数做完compose编译器的语法糖以后,结构变化很大造成无法做 export 导出的那个官方方案,我们鸿蒙也做了类似方案)
iOS 和 鸿蒙优化后效果:
iOS 二进制产物 28MB-> 15MB,整包减小约3MB
OHOS KMP strip 后的动态库减小 8MB
使用 IDA Pro 分析鸿蒙的 compottie 包的体积:
- 裁剪前:

- 开 export 裁剪后:

可以看到没有被调用的 kotlin 符号,在 objcexport/cexport 也不需要导出的时候,这些函数也可以被DCE裁掉。
总结
本文是 Kotlin/Native 在减少代码编译产物包体积的一些尝试,另外我们还探索了一些 Kotlin/Native 的运行时性能、稳定性监控等已上线的技术方案,敬请期待。