I inspected my KMP iOS export header 61% of it was dead weight. Here’s what I found and built

I inspected my KMP iOS export header 61% of it was dead weight. Here’s what I found and built

我检查了我的 KMP iOS 导出头文件,发现 61% 都是冗余代码。以下是我的发现与构建的工具

If you are building an iOS app with Kotlin Multiplatform (KMP) or Compose Multiplatform, you might have opened your generated Shared.h header at some point and wondered why it is 20,000+ lines long. I ran into this recently while optimizing one of my personal KMP apps. I kept seeing Objective-C classes generated for every single theme color, dimension constant, and internal state model, even though my Swift code never touched any of them.

如果你正在使用 Kotlin Multiplatform (KMP) 或 Compose Multiplatform 构建 iOS 应用,你可能在某个时刻打开过生成的 Shared.h 头文件,并纳闷它为什么会有 20,000 多行。最近我在优化个人 KMP 应用时也遇到了这个问题。我发现每一个主题颜色、尺寸常量和内部状态模型都会生成对应的 Objective-C 类,尽管我的 Swift 代码根本没有用到它们。

To get a clear picture of what was actually going on, I built a small Gradle plugin called kmprofiler. It parses the generated Objective-C header, scans your Swift source files, and highlights which exported declarations have zero call sites in Swift. The numbers on my app caught me off guard, but cleaning it up took just a few minutes.

为了弄清楚到底发生了什么,我构建了一个名为 kmprofiler 的小型 Gradle 插件。它会解析生成的 Objective-C 头文件,扫描你的 Swift 源代码文件,并高亮显示哪些导出的声明在 Swift 中没有任何调用点。我应用中的数据让我大吃一惊,但清理它们只花了短短几分钟。

Why does Kotlin/Native export so much? In Kotlin, declarations are public by default. When targeting iOS, the Kotlin/Native compiler looks at every public class, top-level function, and property in your shared module and creates an Objective-C class interface and runtime method trampolines in the framework binary. The compiler cannot dead-strip these automatically because Objective-C relies on dynamic dispatch. It has to assume Swift or Objective-C could call them at runtime. If your UI is built with Compose Multiplatform or your Swift app only interacts with a couple of high-level bridge interfaces, most of those exported Objective-C wrappers end up being dead weight.

为什么 Kotlin/Native 会导出这么多内容?在 Kotlin 中,声明默认是公开的。当针对 iOS 平台时,Kotlin/Native 编译器会查看共享模块中的每一个公开类、顶层函数和属性,并在框架二进制文件中创建 Objective-C 类接口和运行时方法跳转(trampolines)。编译器无法自动剔除这些“死代码”,因为 Objective-C 依赖动态分发,它必须假设 Swift 或 Objective-C 可能在运行时调用它们。如果你的 UI 是用 Compose Multiplatform 构建的,或者你的 Swift 应用只与少数几个高层桥接接口交互,那么大部分导出的 Objective-C 包装器最终都会成为冗余代码。

The Audit: 459 Exports, 282 Unused

审计结果:459 个导出,282 个未使用

When I ran kmprofiler on my app (Framed), it gave me this breakdown: 当我针对我的应用 (Framed) 运行 kmprofiler 时,得到了以下分析结果:

📊 KMP iOS Export Profile

  • Export surface: 459 Kotlin declarations exported to Objective-C.
  • No direct Swift call site found for 282 of them (61.4% uncalled).

📊 KMP iOS 导出概况

  • 导出面: 459 个 Kotlin 声明被导出到 Objective-C。
  • 其中 282 个未发现直接的 Swift 调用点(61.4% 未被调用)。

The unused exports mostly fell into three buckets: 这些未使用的导出主要分为三类:

  1. *File Facades (Kt classes): Top-level properties in files like Dimens.kt (38 spacing constants) or Color.kt generated synthetic Objective-C classes like DimensKt with static getters for every single constant.

  2. Internal UI State and Callbacks: Internal data classes like HomeScreenCallbacks (23 callbacks) and state models were left public, creating full Objective-C class descriptors.

  3. Leaked Library Types: Third-party classes from Compose and Ktor leaked into the bridging header because of exposed function signatures.

  4. *文件外观 (Kt 类):Dimens.kt(38 个间距常量)或 Color.kt 等文件中的顶层属性,为每一个常量生成了类似 DimensKt 的合成 Objective-C 类及静态 getter。

  5. 内部 UI 状态和回调:HomeScreenCallbacks(23 个回调)和状态模型等内部数据类被保留为公开状态,从而创建了完整的 Objective-C 类描述符。

  6. 泄露的库类型: 由于暴露了函数签名,来自 Compose 和 Ktor 的第三方类泄露到了桥接头文件中。

Cleaning It Up

清理工作

I went through 5 files in my shared module and adjusted their visibility: 我检查了共享模块中的 5 个文件并调整了它们的可见性:

  1. Making theme files internal:

    // Dimens.kt / Color.kt / Type.kt
    package com.framed.app.ui.theme
    // Before: val spacing16 = 16.dp
    internal val spacing16 = 16.dp
    internal val beige = Color(0xFFF1E5D7)
  2. Making UI models internal:

    // Before: data class HomeScreenCallbacks(...)
    internal data class HomeScreenCallbacks(
        val onRefresh: () -> Unit = {},
        // ...
    )

    (If you have classes that need to stay public for Android modules, you can use @OptIn(ExperimentalObjCRefinement::class) @HiddenFromObjC to hide them only from iOS).

  3. 将主题文件设为 internal: (代码示例同左)

  4. 将 UI 模型设为 internal: (代码示例同左) (如果你有类需要为 Android 模块保持公开,可以使用 @OptIn(ExperimentalObjCRefinement::class) @HiddenFromObjC 仅在 iOS 中隐藏它们)。

The Numbers

数据对比

After making those 5 files internal, I recompiled the release framework (./gradlew linkReleaseFrameworkIosArm64) and checked the results: 在将这 5 个文件设为 internal 后,我重新编译了发布版框架 (./gradlew linkReleaseFrameworkIosArm64) 并检查了结果:

MetricBeforeAfter (5 files fixed)Delta
Shared.h Header Lines23,622 lines13,327 lines-10,295 lines (-43.6%)
Header File Size1.30 MB0.71 MB-587.5 KB
Shared.framework.o80.1 MB74.4 MB-5.70 MB saved
Total Framework Archive249.6 MB243.9 MB-5.72 MB saved
Uncalled Exports282 declarations163 declarations119 dead exports removed
指标修改前修改后 (5 个文件)变化
Shared.h 头文件行数23,622 行13,327 行-10,295 行 (-43.6%)
头文件大小1.30 MB0.71 MB-587.5 KB
Shared.framework.o80.1 MB74.4 MB节省 5.70 MB
框架总归档大小249.6 MB243.9 MB节省 5.72 MB
未调用的导出282 个声明163 个声明移除 119 个冗余导出

Cleaning up those 5 files removed over 10,000 lines from the header and shaved 5.7 MB off the compiled object binary. 清理这 5 个文件从头文件中移除了超过 10,000 行代码,并从编译后的对象二进制文件中减少了 5.7 MB 的体积。

Trying It on Your Own Project

在你自己的项目中使用

I published kmprofiler (v0.1.0) to the Gradle Plugin Portal if you want to inspect your own KMP project. 如果你想检查自己的 KMP 项目,我已经将 kmprofiler (v0.1.0) 发布到了 Gradle Plugin Portal。

  1. Apply the plugin in your shared module’s build.gradle.kts:

    plugins { id("io.github.siddhantpanhalkar.kmprofiler") version "0.1.0" }
    kmprofiler {
        headerFile.set(layout.buildDirectory.file("bin/iosArm64/releaseFramework/Shared.framework/Headers/Shared.h"))
        swiftSourceDirs.setFrom(layout.projectDirectory.dir("../iosApp"))
        isStatic.set(true)
    }
  2. Run the task: ./gradlew analyzeKmprofiler It prints a summary to your terminal and saves a report to build/reports/kmprofiler-report.md.

  3. 在共享模块的 build.gradle.kts 中应用插件: (代码示例同左)

  4. 运行任务: ./gradlew analyzeKmprofiler 它会将摘要打印到终端,并将报告保存到 build/reports/kmprofiler-report.md

Next Steps

后续计划

Right now I’m working on v0.2.0 to parse Xcode Link Maps (-Xlinker -map), which will calculate the exact linked byte attribution per Kotlin library in your final iOS binary. After that, I plan to add baseline diffing for CI so you can catch export bloat in pull requests. 目前我正在开发 v0.2.0 版本,旨在解析 Xcode Link Maps (-Xlinker -map),这将计算最终 iOS 二进制文件中每个 Kotlin 库精确的链接字节占用。之后,我计划为 CI 添加基准差异对比功能,以便你在 Pull Request 中就能发现导出膨胀问题。

Repo is here: https://github.com/SiddhantPanhalkar/kmprofiler 仓库地址:https://github.com/SiddhantPanhalkar/kmprofiler

Feel free to try it out and let me know if you run into any issues or have suggestions! 欢迎尝试,如果有任何问题或建议,请随时告诉我!