How to Shrink Kotlin/Native Binary Size for Mobile Apps: Practical Optimizations
This article details the Alipay Tech team's systematic approach to reducing Kotlin/Native binary size for Android, iOS, and HarmonyOS by tuning compiler parameters, leveraging LLVM optimization passes, and applying fine‑grained export and dead‑code‑elimination strategies, complete with test methods and measurable results.
Author Li Haohua from Alipay Technology shares practical methods for optimizing Kotlin/Native binary size, focusing on compiler parameter tuning and fine‑grained export symbol control combined with dead‑code‑elimination (DCE).
Background
Alipay's client adopts a "three‑platform one code" strategy (Android, iOS, HarmonyOS) using Kotlin Multiplatform (KMP) and Compose Multiplatform (CMP). While Android uses Kotlin/JVM, iOS and HarmonyOS rely on Kotlin/Native (with possible future Kotlin/JS or Kotlin/Wasm). Introducing these frameworks increased the overall app package size, prompting a deep analysis of Kotlin/Native output for further compression.
Optimization Plan
After upgrading from Kotlin 2.0 to 2.1, binary size grew noticeably, likely due to a newer LLVM toolchain. The team filed an issue (https://youtrack.jetbrains.com/issue/KT-74981) and proposed adding LLVM optimization flags and passes such as -Os, globaldce, and lto to reduce size.
In the "shell project" the freeCompilerArgs are extended as follows:
@OptIn(org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi::class)
kotlin {
compilerOptions {
freeCompilerArgs.add("-Xllvm-module-passes=default<Os>")
freeCompilerArgs.add("-Xllvm-lto-passes=internalize,globaldce,lto<Os>")
freeCompilerArgs.add("-Xoverride-konan-properties=clangOptFlags.ios_arm64=-Os")
}
// or
targets.withType<org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget> {
compilations.all {
kotlinOptions.freeCompilerArgs += "-Xllvm-module-passes=default<Os>"
kotlinOptions.freeCompilerArgs += "-Xllvm-lto-passes=internalize,globaldce,lto<Os>"
kotlinOptions.freeCompilerArgs += "-Xoverride-konan-properties=clangOptFlags.ios_arm64=-Os"
}
}
// other configurations
}This approach can shrink the binary by roughly 10% (a few megabytes), but additional methods are explored.
Binary Analysis
Kotlin/Native compiles Kotlin to native machine code (arm64 for iOS and HarmonyOS). The resulting artifacts are:
HarmonyOS: a libxxx.so file that is stripped of DWARF info before being packaged.
iOS: a framework containing header files and .o objects with compiled Kotlin code.
Two inspection methods are used:
iOS LinkMap analysis.
IDA Pro reverse‑engineering of symbols on both iOS and HarmonyOS.
Key Insight
Setting the export library to "no export" in build.gradle.kts dramatically reduces binary size (up to 50% for iOS frameworks) but also removes many exported symbols. The team identified the essential symbols that must remain and refined the export configuration.
Official Kotlin 2.1 Feature
Kotlin 2.1 introduces objCExportEntryPointsPath, allowing precise control over which Kotlin symbols are exported to Objective‑C/Swift. This enables DCE to keep only the code reachable from the export whitelist, discarding the rest.
Export Point Determination
Kotlin code uses ObjCEntryPoints to decide exposure. By default ObjCEntryPoints.ALL exports everything; a custom file can list specific entry points.
DCE Process
The DCE phase builds a call graph, marks all functions reachable from root nodes (exported symbols, program entry points, etc.), and removes unreachable functions. The relevant source files are TopLevelPhases.kt and DCE.kt.
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)
// ... other phases ...
runPhase(CodegenPhase, CodegenInput(module, lifetimes))
}Testing Methods
iOS
Build the release framework with: ./gradlew linkReleaseFrameworkIosArm64 Never use Debug mode; Release mode enables -opt which triggers LLVM passes affecting size. Measure the final IPA size, not just the intermediate framework.
HarmonyOS
Build with DevEco IDE using: ./gradlew linkReleaseOhosArm64 Analyze both the un‑stripped libkmp.so inside the hap package and the stripped version after packaging.
Results
iOS framework size reduced from 28 MB to 15 MB (≈ 50% reduction).
Overall iOS IPA decreased by ~4.4 MB.
HarmonyOS KMP dynamic library shrank by ~8 MB after strip, a ~15% reduction.
Final Optimizations
Enhanced -Xbinary=objcExportEntryPointsPath to control class‑level exports.
Introduced @ObjCExport and @CExport annotations to replace @CName / @ObjCName for finer export control.
These annotations are processed in ObjCExportMapper.shouldBeExposed, allowing selective exposure of symbols and enabling DCE to eliminate unused Kotlin code.
Conclusion
By configuring an export whitelist and leveraging DCE, the team achieved substantial binary size reductions for both iOS and HarmonyOS platforms. The approach demonstrates how precise export control combined with LLVM optimization passes can make Kotlin/Native binaries much more lightweight for mobile deployments.
Alipay Experience Technology
Exploring ultimate user experience and best engineering practices
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
