Every Kotlin type is first mapped to its Objective-C counterpart and then imported into Swift, so non-null primitives and String come through cleanly, while nullable primitives and collections of numbers arrive boxed.
Primitives: Int is Int32, Long is Int64, Boolean is Bool, Double stays Double. Swift's Int is 64-bit, so call sites convert: api.page(size: Int32(n)).
Nullable primitives: Int? has no Objective-C scalar form, so it becomes KotlinInt?, an NSNumber subclass. You read it with .int32Value and create it with KotlinInt(int: 5).
Collections: List<T> is [T], Map<K, V> is [K: V], Set<T> is Set<T>. Elements must be objects, so List<Int> turns into [KotlinInt]. ByteArray is KotlinByteArray, not Data.
Nullability: the header marks types nullable or non-null, so Swift checks ordinary call sites. Nothing checks at runtime: a nil that slips through an implicitly unwrapped optional or an Any reaches a non-null Kotlin parameter and crashes deep inside Kotlin.
The practical rule: keep exported signatures to non-null primitives, String, lists and your own data classes, and make a parameter nullable whenever Swift can genuinely pass nil.
AndroidSenior
KMP and Swift Interop
ObjC bridge, suspend and Flow to Swift, SKIE, XCFramework, SPM, linking, Swift export
Questions closed
0 / 15
- 0Review
Questions
Objective-C only has lightweight generics on classes, so a type parameter on a Kotlin class survives as a compile-time hint, while generic interfaces, generic functions and kotlin.Result lose it and Swift sees Any.
Generic classes: class Page<T> becomes Page<Post> in Swift. Lightweight generics are erased at runtime, so this is only a promise to the Swift compiler.
Generic interfaces: an Objective-C protocol cannot take a type parameter, so interface Repository<T> loses it and every method returns Any?. That is why exported KMP APIs often use an abstract class instead of an interface.
Generic functions: a selector has nowhere to keep a type parameter, so fun <T> List<T>.secondOrNull(): T? hands Swift Any?.
Primitives in generic positions: they are boxed: Page<Int>.items is [KotlinInt].
**Result<T>**: a sealed interface Result<T> is exported as a non-generic protocol, so the payload is Any?; the stdlib kotlin.Result<T> is a value class and also arrives as Any?. A generic sealed class keeps its parameter on the base class, but Swift still cannot switch over it exhaustively.
The fix is to export concrete types: FeedResult and UserResult with a typed payload per case. Keep the generic machinery inside commonMain and let only concrete types reach the header.
Four things look un-Swift: the default argument disappears, copy() becomes doCopy(), the second overload gets an underscore and the top-level function hangs off a GreetingKt class, because Objective-C has no default arguments, no overloading and no free functions.
Analytics.shared.track(event: "open", params: [:]): an object is reached through .shared (a companion object through .companion), and params must be passed every time. @JvmOverloads does nothing on Native.
User(id: 1, name: "Ada") works, but user.copy(...) does not exist: copy is a reserved Objective-C method family, so it is exported as doCopy(id:name:). id is Int64 in Swift.
loader.load(id: 42) and loader.load(id_: "ada"): both overloads produce the same selector loadId:, so the compiler appends _ to the later one.
GreetingKt.greet(user: user): top-level functions become static methods of a class named after the file.
To fix the surface: give overloads distinct names, add explicit overloads instead of defaults (SKIE can generate Swift defaults only where you opt in with @DefaultArgumentInterop.Enabled), rename with @ObjCName(swiftName = ...) and hide internals with @HiddenFromObjC.
The framework header is Objective-C, which has no closed hierarchies: a sealed type arrives as an ordinary class or protocol with subclasses, so Swift cannot know the list of cases is complete and demands default.
sealed interface AuthState becomes a protocol (a sealed class becomes a class), and AuthState.LoggedIn becomes a class AuthStateLoggedIn that conforms to it. A data object is a class with a shared instance.
The Swift switch turns into a chain of case let s as AuthStateLoggedIn casts with a mandatory default.
A Kotlin enum class has the same problem: it is a class with static instances, not a Swift enum.
The real cost: add a case in Kotlin and the Android when stops compiling, but the iOS build does not. The new state silently falls into default.
Two ways to restore the guarantee:
SKIE generates a real Swift enum for every sealed hierarchy, and switch onEnum(of: state) is checked by the Swift compiler.
**Export a fold-style function** that takes one lambda per case. Kotlin checks the when, and adding a case adds a parameter, so every Swift call site stops compiling.
It is exported as an Objective-C method with a completion handler, which Swift imports as async throws. Cancelling the Swift Task does not reach Kotlin: the coroutine runs to the end and its result is thrown away.
Signature: suspend fun fetchUser(id: Long): User becomes fetchUserId:completionHandler: in the header and func fetchUser(id: Int64) async throws -> User in Swift. A primitive result is boxed: suspend fun count(): Int returns KotlinInt.
Errors: the function is always throws, but only CancellationException and the types listed in @Throws arrive as an NSError. Any other exception terminates the app.
Cancellation: the bridge starts the coroutine and waits for one callback; there is no channel back. A screen that disappears mid-request keeps the download running.
The fixes: SKIE and KMP-NativeCoroutines connect Swift task cancellation to the Kotlin Job. Without a plugin, export a function that starts the work and returns a handle Swift can cancel.
loadProfile has no @Throws, so the ApiException never becomes an NSError: the Kotlin/Native runtime treats it as unhandled and terminates the app, and the Swift catch never runs. A dropped connection crashes it the same way.
Why it hides: every bridged suspend function is async throws in Swift, so try await compiles and looks safe. Without @Throws the bridge turns only CancellationException into an NSError; any other exception is logged with "Function doesn't have or inherit @Throws annotation" and the program is terminated.
How the fix works: @Throws(Exception::class) makes the generated bridge catch every Exception subclass at the boundary and pass it to the completion handler as an NSError, which Swift then throws into the catch.
Why not a narrow list: @Throws(ApiException::class, CancellationException::class) also compiles (a suspend function's list must cover CancellationException), but a lost connection throws Ktor's IOException, which is not listed, and the app still crashes. List narrowly only when the function catches everything else itself.
Catch it next time: add a Konsist or detekt rule for the exported module: every public suspend fun needs @Throws. Back it with an XCTest that calls the facade against a stub server returning 404.
A raw Flow reaches Swift as an opaque protocol with a single method collect(collector:completionHandler:), so you either wrap it in a callback-plus-handle API yourself, or let KMP-NativeCoroutines or SKIE turn it into an AsyncSequence.
Hand-written wrapper: a Kotlin function that takes (T) -> Unit, calls launchIn on a scope and returns a cancel handle. No dependencies and exact types, but one wrapper per stream and a callback instead of something Swift can await.
KMP-NativeCoroutines: a KSP plugin plus a Swift package. You annotate declarations with @NativeCoroutines (or @NativeCoroutinesState for a StateFlow) and Swift consumes them through asyncSequence(for:), Combine publishers or async functions, with cancellation wired through. Costs an annotation per declaration and a Swift dependency.
SKIE: a compiler plugin that rewrites the framework: Flow<T> becomes an AsyncSequence with typed elements, so for await posts in repo.posts() works with no annotations.
How to choose: SKIE if the team accepts a compiler plugin tied to the Kotlin version, KMP-NativeCoroutines for Combine or explicit opt-in, a hand-written wrapper for a few streams. Whatever you choose, Swift must be able to cancel the collection, or the collector lives forever.
SKIE is a Touchlab compiler plugin that generates a Swift layer on top of the Objective-C framework and ships it inside the same framework, so Swift gets enums, async and AsyncSequence without installing anything on the iOS side.
What it generates
Real Swift enums for sealed hierarchies, typed AsyncSequence for Flow, and async for suspend with cancellation passed through.
Default arguments are opt-in only: off by default, enabled per declaration with @DefaultArgumentInterop.Enabled (it generates extra overloads and the docs call it a temporary design).
What it costs
Build time: linking the framework now also compiles generated Swift. On a wide exported API this is noticeable, and individual features can be turned off per module or per declaration in the skie { features { } } block.
Version coupling: each SKIE release supports specific Kotlin versions, so a Kotlin upgrade waits for a matching SKIE.
Another layer: crash stacks and the debugger show generated Swift frames between Swift and Kotlin.
What it does not change
It is still the Objective-C bridge underneath: generic interfaces still erase, nullability is still unchecked at runtime, and names are still mangled.
It post-processes an Objective-C framework, so it cannot be combined with Swift export.
The honest pitch: SKIE restores the two guarantees the bridge destroys and discipline cannot replace (compiler-checked exhaustiveness and real cancellation). If the exported API is small, a hand-written facade may be cheaper.
Own the Kotlin object from one @MainActor model, make Kotlin deliver every callback on the main thread, and never pass exported Kotlin objects between actors: they are Objective-C classes and are not Sendable.
Where callbacks run
Kotlin/Native has no freezing any more, so the runtime does not stop a background callback from touching UI state.
A callback or Flow collector runs on the dispatcher of the Kotlin scope. With Dispatchers.Default a @Published update triggers SwiftUI's "Publishing changes from background threads is not allowed" warning, and an @Observable model updates the view off the main thread.
An await of a bridged suspend function inside a @MainActor method resumes on the main actor, so assigning its result is safe. A closure callback has no such guarantee.
The Swift 6 trap
A closure written inside a @MainActor class is inferred as main-actor isolated. When Objective-C code calls it from a background thread, Swift 6 checks the isolation at runtime and crashes in dispatch_assert_queue.
So the robust fix is in Kotlin: the ViewModel's scope uses Dispatchers.Main (MainScope() or viewModelScope), and heavy work goes into withContext(Dispatchers.Default).
Passing a Kotlin object into Task.detached or another actor fails with a "risks causing data races" error. @preconcurrency import Shared silences it for the module: a migration crutch, not a fix.
The rule: one thread hop per stream, in one visible place, and it belongs in Kotlin.
guard let self turns the weak capture into a strong one for the whole loop, and the loop never ends because a StateFlow never completes. The task holds the model, the model holds the Kotlin ViewModel, and the Kotlin collector keeps running.
**[weak self] helps only until the guard**: after it succeeds, self is a strong local that lives as long as the for await.
Nobody cancels the task: an unstructured Task started in init is not tied to any view. SKIE cancels the Kotlin collection only when the Swift task is cancelled.
The Kotlin side stays alive too: the running collector is a GC root, so the ViewModel and its scope survive, along with any work it launched.
Instruments shows no leak: there is no reference cycle, just a task that never finishes, so Leaks and the memory graph look clean.
The fix: start the collection from SwiftUI's .task, which cancels it when the view disappears, and make the loop an async method of the model. Closing the Kotlin ViewModel's own scope is a separate step the owner must do.
Treat the exported surface as a designed API with one consumer: everything public in the shared module ends up in the header, so export only screen-level facades with simple, concrete types.
Decide what leaves the module
Make repositories, use cases, mappers and DI wiring internal. They only inflate the header, the binary and the build time.
@HiddenFromObjC removes a declaration that must stay public for Android from the iOS header.
@ObjCName(swiftName = ...) fixes a name that reads badly in Swift; @ShouldRefineInSwift exports it as __name so a Swift extension can wrap it in something idiomatic.
Shape what does leave
One state type per screen, one stream of it, one function per user intent.
Parameters and results: String, numbers, Boolean, lists and your own data classes. Keep generic interfaces, Result<T> and raw Flow out of signatures.
Errors travel as values in the state, not as exceptions across the bridge.
Let Swift finish the job
Exported classes are Objective-C classes, so a Swift extension in the app can make them Identifiable or Hashable without touching Kotlin.
Map Kotlin enums to Swift enums once at the edge, or let SKIE generate them.
The test: if you would not put it in a REST response designed for this one app, do not put it in the header.
Either the iOS build compiles Kotlin itself through Gradle, or it consumes a versioned XCFramework through Swift Package Manager. That choice, not the packaging format, is what the iOS team feels every day.
Direct integration, one repo
A Run Script phase calls ./gradlew :shared:embedAndSignAppleFrameworkForXcode, which builds the framework for the current architecture and configuration.
Every Mac and CI runner needs a JDK and a warm Gradle cache, and the Kotlin build adds to every iOS build.
Xcode 15+ sets ENABLE_USER_SCRIPT_SANDBOXING to YES in new projects, which breaks the script; turn it off for that target.
Published XCFramework, separate repos
The XCFramework() DSL produces assembleSharedXCFramework: one artifact with device and simulator slices.
SPM consumes it as a .binaryTarget, by local path while iterating or by url plus checksum for a release. The iOS build never runs Gradle.
The cost is a release loop: bump, publish, resolve. Touchlab's KMMBridge automates it.
CocoaPods plugin
kotlin("native.cocoapods") generates a podspec, and its pod(...) dependencies are still the easiest way for Kotlin to call a Pod-only SDK.
CocoaPods is in maintenance mode and its trunk becomes read-only in December 2026, so this is a legacy choice for new projects.
Rule of thumb: for one repo and one team, direct integration; for a separate iOS repo or an SDK for other teams, an XCFramework through SPM.
A static framework is linked into every binary that uses it; a dynamic one is a separate image that dyld loads at launch. For a single app target static is simpler and launches slightly faster; with an app plus extensions, static copies Kotlin into every target, so dynamic is often smaller.
Static (isStatic = true)
Kotlin code, runtime and garbage collector are linked into the app executable: no embed-and-sign step and no extra image at launch.
Kotlin symbols end up in the app's own dSYM, which you already upload.
Each target links its own copy: app, widget and share extension mean three copies of the Kotlin runtime in the bundle.
Dynamic
One Shared.framework in the app's Frameworks/ folder, loaded by the app and its extensions: one copy on disk.
One more image for dyld at every launch: milliseconds for one framework, but it adds up as frameworks multiply.
A separate Shared.framework.dSYM; forget to upload it and Kotlin frames arrive in crash reports as hex addresses.
What linking does not change
The app and each extension are separate processes, so a Kotlin object is never shared between them. Shared data needs an App Group container, such as UserDefaults(suiteName:) or a database file in the group folder.
Two static Kotlin frameworks in one executable clash on duplicate symbols; use one umbrella framework.
Rule of thumb: for a single app target, static; for app plus several extensions, measure the bundle and usually go dynamic. Set isStatic deliberately rather than inheriting the template's choice.
LLDB can stop inside Kotlin because the Kotlin/Native compiler emits DWARF debug info, but only in a debug framework whose recorded source paths exist on your machine; readable values additionally need the xcode-kotlin LLDB formatters.
Why the breakpoint does not hit
The framework must be the debug variant. With direct integration the Gradle task follows Xcode's CONFIGURATION, and KOTLIN_FRAMEWORK_BUILD_TYPE=release overrides it, and then there is nothing to stop on.
A prebuilt XCFramework from CI records the build agent's absolute paths. LLDB has the symbols but cannot find your .kt file until you map the path with settings set target.source-map.
Xcode does not know .kt files at all without the plugin, so you cannot even set a breakpoint in one.
Making values readable
Touchlab's xcode-kotlin adds Kotlin syntax highlighting, breakpoints in .kt files and LLDB formatters that show Kotlin objects with their fields.
Often the better tool is the Kotlin IDE: IntelliJ IDEA or Android Studio with the Kotlin Multiplatform plugin runs the iOS app and debugs the Kotlin code; keep Xcode for Swift.
On the seam
An exception escaping Kotlin terminates the process; the top frames belong to the Kotlin runtime, and your code is further down, in kfun: frames.
Keep the framework's dSYM for release builds, or crash reports show hex addresses.
Rule of thumb: Swift bugs in Xcode, Kotlin bugs in the Kotlin IDE, and a mixed session only when the bug sits exactly on the boundary.
Swift export makes the Kotlin compiler generate Swift bindings directly, so the API no longer has to fit Objective-C first. It removes most naming and boxing pain, but it is still pre-stable and does not cover everything, so in 2026 it is for prototypes, not releases.
What it changes
Kotlin classes are no longer NSObject subclasses. Packages map to Swift namespaces, or you collapse them with flattenPackage, and each module can become its own Swift module.
Nullable primitives are plain Swift optionals such as Int32? instead of KotlinInt?, and overloads keep their names without underscores.
The build changes: a swiftExport { } block in Gradle and the embedSwiftExportForXcode task instead of embedAndSignAppleFrameworkForXcode.
What it does not give you yet
Coverage of the type system is still growing and changes between Kotlin versions: coroutines (suspend, Flow), generics and some other declarations may be limited, so check the docs for your version.
SKIE post-processes an Objective-C framework and does not work with Swift export, so turning it on means giving up SKIE's enums, flows and cancellation.
What to do now
Try it on a branch or a spare module and check the release notes of each Kotlin version.
Prepare through API shape, not tooling: a narrow facade of value types, one state stream and one function per intent migrates almost for free.