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 的运行时性能、稳定性监控等已上线的技术方案,敬请期待。

相关推荐
alexhilton1 小时前
Jetpack Compose的性能优化建议
android·kotlin·android jetpack
天枢破军4 小时前
【KMP】桌面端打包指南
kotlin
_一条咸鱼_4 小时前
深度解析 Android MVI 架构原理
android·面试·kotlin
好学人4 小时前
Android MVVM 架构中的重要概念
kotlin·mvvm
好学人4 小时前
一文弄懂 repeatOnLifecycle
kotlin·mvvm
天枢破军5 小时前
【KMP】解决桌面端打包异常无法运行
kotlin
zhangphil5 小时前
Android ExifInterface rotationDegrees图旋转角度,Kotlin
android·kotlin
好学人6 小时前
一文弄懂Kotlin中的by关键字
kotlin
好学人11 小时前
Kotlin中的作用域关键字
kotlin
QING61815 小时前
详解:Kotlin 类的继承与方法重载
android·kotlin·app