| name | kmp-binary-size |
| description | Shrinking the Kotlin/Native iOS framework and Android AAR produced by a KMP shared module — exported API trimming, dead-code elimination, static vs dynamic frameworks, and dependency auditing. Use when the iOS app binary is too big. |
KMP Binary Size
Instructions
The iOS framework for a non-trivial KMP module typically starts around 8-15 MB and can grow quickly. The main levers are: what you export, how you link, and which dependencies you pull in.
1. Measure first
./gradlew :shared:assembleReleaseFrameworkIosArm64
du -sh shared/build/bin/iosArm64/releaseFramework/Shared.framework/Shared
Break down what's inside:
size -m shared/build/bin/iosArm64/releaseFramework/Shared.framework/Shared | head
bloaty shared/build/bin/iosArm64/releaseFramework/Shared.framework/Shared --demangle=rust
Run ./gradlew :shared:iosArm64MainKlibraries and inspect .klib sizes to see which dependency dominates.
2. Static vs dynamic framework
Static (isStatic = true) is smaller overall when the framework is linked into a single app: the app-side linker strips unreferenced symbols.
iosArm64().binaries.framework { baseName = "Shared"; isStatic = true }
Use dynamic only if multiple app extensions (widget, share sheet) each link the framework — then dynamic avoids duplicating the payload.
3. Trim the exported API
Every public declaration in commonMain ends up in the generated Objective-C header and is retained by the linker.
- Mark helpers
internal.
- Put platform-only implementation details in
iosMain as internal.
- For aggregator modules, explicitly
export:
iosArm64().binaries.framework {
baseName = "Shared"
isStatic = true
export(projects.featureAuth)
export(projects.featureFeed)
}
Consumers should call into the exported facades; internals are still linked but not visible.
4. Enable release-mode opts
- Kotlin/Native link step runs LLVM passes in release configuration — build with
-Pkotlin.native.cacheKind=none disabled (it is enabled by default; don't turn it off for shipping builds).
- Ensure
buildType is RELEASE for the Framework you ship: assembleReleaseFramework*.
5. Dependency audit
Common bloat sources:
- Kermit with multiple logger implementations — keep one.
- Ktor logging plugin + full ContentNegotiation serializers — remove in release, or use
ProGuard-style feature flags.
- SQLDelight runtime extensions you don't use (coroutines-extensions is fine; paging3 extensions are large).
- kotlinx.datetime's timezone database (
kotlinx-datetime-zoneinfo artifact) — ~200 KB. Include only if you need timezone math beyond the system one.
Prefer implementation(...) over api(...) so consumers don't transitively pull in deps.
6. Android-side considerations
The .aar that androidTarget() produces is usually small, but R8/ProGuard still needs keep rules if you use reflection through kotlinx.serialization:
# consumer-rules.pro
-keepattributes *Annotation*, InnerClasses
-dontnote kotlinx.serialization.AnnotationsKt
-keep,includedescriptorclasses class **$$serializer { *; }
-keepclassmembers class ** { *** Companion; }
-keepclasseswithmembers class ** { kotlinx.serialization.KSerializer serializer(...); }
Ship consumer-rules.pro from :shared so downstream :app doesn't need to duplicate them.
7. Kotlin/Native linking flags
kotlin {
iosArm64 {
binaries.framework {
isStatic = true
linkerOpts += "-dead_strip"
}
}
}
Combined with static linking, -dead_strip removes sections referenced only by unexported symbols.
8. Swift Package Manager and CocoaPods
When distributing via SPM or CocoaPods, ship the release XCFramework with slices for iosArm64 (device) and iosSimulatorArm64+iosX64 (simulator merged). Don't ship the debug framework — it's 2-3× larger.
Checklist