Adding Kotlin Multiplatform to an existing Android app: the iOS side
Adding Kotlin Multiplatform to an existing Android app hands iOS a Swift package with an Objective-C-shaped API. Swift export is Alpha; builds need a Mac.
When you add Kotlin Multiplatform to an existing Android app, the Android app barely notices: it gains a Gradle module and keeps its UI. The iOS team gets more change. They receive a compiled framework, either built by their own Xcode project or shipped to them as a Swift package. They call Kotlin through an Objective-C header, which means completion handlers, class-based enums and a few generated names. They need a Mac, and so does any CI job that builds the framework. Swift export, which removes most of that friction, has been Alpha since Kotlin 2.4.0 (3 June 2026). It is worth trying on a branch. Do not build a release on it yet.
This post goes through what changes for iOS, in the order the iOS team runs into it. It is the long version of one answer on the studio's Kotlin Multiplatform client work, "shared modules first, CMP screens where you want them", written so you can judge whether it fits your app before asking anyone. Every status and command was checked against its linked source on 3 October 2026. Many answers in old forum threads from 2020 to 2023 are no longer true. Where one of them matters, the post says what replaced it.
Start with one shared module, not a rewrite
JetBrains' KMP FAQ (modified 1 October 2026) gives two routes for an existing Android app. Make your Android application work on iOS moves business logic into a shared module and keeps a native UI on both sides. Migrating a Jetpack Compose app to Kotlin Multiplatform is the advanced route, and it also moves the UI.
The first route is where most teams should start. In Android Studio it begins at File | New | New Module, with the Kotlin Multiplatform Shared Module template. The FAQ is plain about what happens next: when you move code into the shared module, "the parts that use Android dependencies stop compiling". You then either switch to a multiplatform library or put an interface in common code and implement it once per platform. The migration guide lists the two main obstacles as Java code and Android Views.
The iOS team can stay out of this phase. Nothing reaches them until the module compiles for an iOS target.
What the iOS team receives
JetBrains' integration overview offers two models, and choosing between them is the first decision that affects iOS engineers every day.
- Local, direct integration. An Xcode Run Script phase calls
./gradlew :shared:embedAndSignAppleFrameworkForXcode, so every Xcode build also runs Gradle. The setup page asks you to place the phase before Compile Sources and to turn off User Script Sandboxing. Each iOS machine then needs a JDK as well as Xcode. If Xcode cannot find a JDK installed through SDKMAN, this one-line fix solves it. - Remote integration through Swift Package Manager. The shared code arrives "like a regular third-party dependency". Per the Swift package export guide, you build
Shared.xcframework, zip it, compute its checksum withswift package compute-checksum, upload the zip, and publish aPackage.swiftthat points at it. JetBrains recommends keepingPackage.swiftin a separate Git repository, because SwiftPM versions packages by Git tag and those tags can collide with your project's tags. If you export several modules, combine them into one umbrella module first.
Remote integration is the one that answers "do our iOS developers have to learn Kotlin?" with no. They add a package in Xcode, as they would for any other library. The cost is a release step whenever the shared code changes.
CocoaPods still works, but avoid building anything new on it. The CocoaPods team plans to make trunk read-only on 2 December 2026, noting that "these dates are not set in stone". Existing builds keep working, but trunk will accept no new pod versions. Kotlin 2.4.0 also added Swift package import, which lets Kotlin code use Swift packages such as Firebase directly. That feature is Alpha, and JetBrains has a migration guide away from the CocoaPods plugin.
Calling Kotlin from Swift today
Kotlin/Native reaches Swift indirectly, through Objective-C. The interop reference (modified 12 August 2026) lists what that means for the people writing Swift:
- Suspend functions appear as completion handlers. Calling them as
asyncfrom Swift is described as "highly experimental". - Exceptions. Kotlin exceptions are unchecked. Only functions marked
@Throwssurface as Swiftthrows, and "other Kotlin exceptions reaching Swift/Objective-C are considered unhandled and cause program termination". Agree on@Throwsfor every public function that can fail before the first release. - Enums become classes, so a Swift
switchover one needs adefaultcase. - Names. Top-level functions hang off a class named after their file, which is why JetBrains' own examples call
Main_iosKt.MainViewController().@ObjCNamerenames a declaration for Swift,@HiddenFromObjChides one, and@ShouldRefineInSwiftlets you hand-write a Swift wrapper.
For coroutines, the FAQ points to two libraries. KMP-NativeCoroutines is "the more tried-and-tested solution", and SKIE "can be easier to set up and is less verbose", mapping Flow straight to AsyncSequence. Both release in step with Kotlin. KMP-NativeCoroutines v1.0.6 (7 September 2026) and SKIE 0.10.15 (25 September 2026) both target Kotlin 2.4.20, the latest release according to the releases page.
One more 2.4.0 change reaches Swift directly. Kotlin 2.4.0 raises the default minimum iOS version from 14.0 to 15.0. An app that still supports iOS 14 has to override it in the build file.
What Swift export changes, and why to wait
Swift export (doc modified 28 August 2026) skips the Objective-C header. Suspend functions become async, flows become AsyncSequence through asAsyncSequence(), sealed hierarchies become Swift enums that can be switched exhaustively, and each Kotlin module becomes its own Swift module. It reached Alpha in Kotlin 2.4.0.
Two limits matter more than the feature list. First: "Swift export currently works only in projects that use direct integration". It does not work yet with the Swift package distribution described above. Second: "breaking changes are expected", generic type parameters are erased to their upper bounds, and only final classes are supported. For a production app, keep the Objective-C export and one of the two coroutine libraries. Try Swift export on a branch.
Builds and CI
Release builds of Kotlin/Native take "an order of magnitude more time than debug binaries", according to the compilation-time guide (modified 3 September 2026). Most of its advice comes down to building less:
- During development,
embedAndSignAppleFrameworkForXcodebuilds only for the simulator or device you are running on. Leaveassembleand*XCFrameworktasks to CI. - Drop targets nobody uses. If no one runs the simulator on an Intel Mac, you do not need
iosX64. - Keep
~/.konanbetween CI runs. Otherwise the compiler downloads and rebuilds its toolchain on every run. - Turn on the Gradle build cache and configuration cache, and remove old workarounds such as
kotlin.native.disableCompilerDaemon=truethat may no longer be needed.
Any job that builds the framework needs macOS, and macOS minutes cost more. On 3 October 2026, GitHub's runner pricing lists a standard macOS runner at $0.062 a minute against $0.006 for 2-core Linux. Run shared-code tests and the Android build on Linux, and reserve the Mac runner for the iOS link and the package release. JetBrains publishes a GitHub Actions setup for KMP to start from.
From the studio: This pipeline (the XCFramework build on a macOS runner, the zip and checksum, the tagged
Package.swift) is the kind of release engineering the studio sets up on your CI, with signing keys and store accounts in your name, and it can be quoted on its own through the contact form.
Binary size: measure what users download
In a 2021 thread about a large shared framework, the explanation that eventually arrived was embedded bitcode. That cause is gone. The Xcode 14 release notes say "the App Store no longer accepts bitcode submissions".
The common measuring mistake is still around. An XCFramework holds a device slice and a simulator slice, so its size on disk says little about what ships. Apple's guide to reducing app size goes further: the .app, the .xcarchive and even the IPA you upload are not suitable for measuring. Export with app thinning set to "All compatible device variants" and read App Thinning Size Report.txt, or use the per-variant sizes in App Store Connect. Compare two builds of the same app, with and without the shared module.
If the number is too high, Kotlin's compilation-time guide names two causes. transitiveExport = true "disables dead code elimination in many cases", and "each exported module negatively affects compilation time and binary size". Export only what Swift calls. The smallBinary option, which builds with -Oz, is Experimental since 2.2.20. If you also share UI, JetBrains' own figure is that Compose Multiplatform "adds only ~9 MB" compared with an equivalent SwiftUI app (CMP 1.8.0 post). That figure applies to shared UI, not to an app that shares only logic.
Debugging across the boundary
Kotlin/Native writes DWARF debug information, so LLDB can set breakpoints in Kotlin, step through it and show variables (debugging doc, modified 23 July 2026). There are three ways to use that:
- From the Kotlin IDE. The Kotlin Multiplatform plugin added "cross-language navigation and debugging for Swift and Kotlin" in version 0.9 (19 May 2025). From IntelliJ IDEA 2025.2.2 or Android Studio Otter 2025.2.1, it offers "basic launching and debugging capabilities for iOS apps" (recommended IDEs).
- From Xcode. Touchlab's xcode-kotlin installs with
brew install xcode-kotlinandxcode-kotlin install. You add thecommonMainandiosMainfolders to the Xcode project and set breakpoints in them. Runxcode-kotlin syncafter every Xcode update. Its latest release, 2.2.1, is from April 2025. - From crash reports. The compiler produces
.dSYMfiles for release binaries on Apple platforms by default, and Xcode finds them for symbolication.
The limit to plan around: "expression evaluation in debugger tools is not supported, and currently there are no plans for implementing it." You can inspect a Kotlin variable, but you cannot evaluate a Kotlin expression at a breakpoint.
Adding Compose screens where they earn it
Once the logic is shared, the UI can follow one screen at a time, and the iOS team decides which ones. The SwiftUI interop guide wraps a Kotlin ComposeUIViewController in a UIViewControllerRepresentable. The UIKit guide places it in any container view controller, and UIKitView embeds native views inside Compose. Both guides include a warning that is easy to miss: add CADisableMinimumFrameDurationOnPhone to Info.plist, or "the app will crash at runtime".
The FAQ also states the trade-off. Compose draws on a canvas, so "your UI will stay the same after the OS updates", while any native components you embed will change with the OS. Settings or onboarding screens are a reasonable first test. Leave screens built around system controls native.
Who owns what afterwards
The structure above determines the long-term ownership. The Android team owns the shared module and its public API. The iOS team owns the app, the Package.swift repository it consumes, and the decision about when to move to Swift export. Signing, store accounts and the release workflow stay with whoever owns the app today. Agree on the @Throws convention, the export list and the package versioning scheme before the first release. Those three are the hardest to change once Swift code depends on them.
If you are still deciding whether to start, the dated status table covers what is Stable and what is not. If you have decided, pick one Android module with no UI and no Android dependencies, make it build for iosSimulatorArm64, and have someone on the iOS team call it from a test app before you agree anything else.
Kotlin Multiplatform apps for iOS and Android, Compose Multiplatform for the UI, and the SDK and release work underneath.
Fixed scope or monthly · the code is yours from the first commit