Kotlin/Native 包体积优化 for iOS & 鸿蒙

简介

本文简单讨论 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 文件中

  1. DCE 如何识别未使用的代码:
  • DCE 过程首先构建一个调用图(CallGraph),标记所有从根节点可达的函数

  • 根节点包括:

  • 所有被ObjC导出的函数(通过ObjCEntryPoints配置决定)

  • 程序入口点和其他必要函数

  • 所有从这些根节点无法达到的函数被视为"死代码",会被DCE移除

  1. ObjC导出与DCE的关系:
  • 当你通过objcExportEntryPointsPath配置文件限制导出的函数时,那些不在白名单中的函数不会被标记为"根节点"
  • 如果这些函数同时也没有被Kotlin代码本身使用(即从其他根节点不可达),它们会被DCE识别为死代码并移除
  • 这样可以有效减少最终二进制文件的大小,因为只有真正需要的代码才会被保留

在代码实现上,主要涉及几个关键部分:

  1. ObjCExportMapper.shouldBeExposed 方法,这个方法决定了一个类或函数是否应该暴露给ObjC,它会检查:
  • 是否在entryPoints中被指定(通过entryPoints.shouldBeExposed(descriptor))
  • 是否有ObjCName注解(通过hasExportAnnotation(descriptor))
  • 还会检查其他条件如可见性、是否被标记为hidden等
  1. ObjCEntryPoints实现:
  • 默认实现ObjCEntryPoints.ALL总是返回true
  • 自定义实现会检查给定的描述符是否匹配配置文件中指定的规则
  1. 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 的运行时性能、稳定性监控等已上线的技术方案,敬请期待。

相关推荐
zerofancy9 小时前
在Compose Desktop实现简单消息通知
kotlin·app
alexhilton12 小时前
学会说不!让你彻底学会Kotlin Flow的取消机制
android·kotlin·android jetpack
Monkey-旭2 天前
Android Bitmap 完全指南:从基础到高级优化
android·java·人工智能·计算机视觉·kotlin·位图·bitmap
Monkey-旭3 天前
深入理解 Kotlin Flow:异步数据流处理的艺术
android·开发语言·kotlin·响应式编程·flow
程序员江同学4 天前
Kotlin 技术月报 | 2025 年 7 月
android·kotlin
_frank2224 天前
kotlin使用mybatis plus lambdaQuery报错
开发语言·kotlin·mybatis
Bryce李小白4 天前
Kotlin实现Retrofit风格的网络请求封装
网络·kotlin·retrofit
ZhuYuxi3334 天前
【Kotlin】const 修饰的编译期常量
android·开发语言·kotlin
jzlhll1234 天前
kotlin StateFlow的两个问题和使用场景探讨
kotlin·stateflow
Bryce李小白4 天前
Kotlin 实现 MVVM 架构设计总结
android·开发语言·kotlin