KMP and Swift Interop
Irbisa · cheatsheetSeptember 13, 2026

KMP and Swift Interop

ObjC bridge, suspend and Flow to Swift, SKIE, XCFramework, SPM, linking, Swift export

Senior Developer16 itemscompressed for a skim
  1. 01

    An iOS engineer switches over your sealed AuthState and Xcode still demands a default branch — why?

    Medium

    Because the framework header is Objective-C, and Objective-C has no closed hierarchies — a sealed type arrives as an ordinary open class with subclasses, so Swift has no way to know the list is complete.

    // commonMain
    sealed interface AuthState {
      data object Anonymous : AuthState
      data class LoggedIn(val token: String) : AuthState
      data class Failed(val reason: String) : AuthState
    }
    …
  2. 02

    Your shared Result<T> reaches Swift with every success value typed Any — what does the Objective-C bridge do to Kotlin generics?

    Medium

    Objective-C has lightweight generics on classes and nothing else, so Kotlin's class-level type parameters survive as compile-time hints and every other use of a generic degrades to Any.

    // commonMain
    
    // ✅ A generic CLASS crosses: Swift sees Page<Post>
    class Page<T>(val items: List<T>, val nextCursor: String?)
    
    // ❌ A generic INTERFACE does not — ObjC protocols have no type parameters,
    …
  3. 03

    Why must the iOS team pass every argument to a Kotlin function that has defaults, and where do Swift names like doCopy() come from?

    Medium

    Objective-C has neither default arguments nor overloading, so the header generator drops the defaults outright and mangles any name that would collide.

    @file:OptIn(ExperimentalObjCName::class, ExperimentalObjCRefinement::class)
    
    // ❌ Defaults evaporate — Swift must write search(query:limit:offset:)
    class SearchApi {
      suspend fun search(query: String, limit: Int = 20, offset: Int = 0): List<Post> = TODO()
    }
    …
  4. 04

    An iOS screen is dismissed and Swift cancels its Task, but your shared suspend fun keeps downloading — why doesn't that cancellation reach Kotlin?

    Medium

    Because the generated bridge is a one-way completion handler: Swift hands Kotlin a block and gets back nothing that could carry a cancel.

    // ❌ Exported directly: Swift gets `try await`, Kotlin never hears the cancel
    class UserApi(private val client: HttpClient) {
      suspend fun fetchUser(id: Long): User = client.get("/users/$id").body()
    }
    // Generated header:
    //   - (void)fetchUserId:(int64_t)id
    …
  5. 05

    Your shared repository exposes Flow<List<Post>> and the iOS team says it is unusable from Swift — what are their options?

    Medium

    A raw Flow crosses as an opaque Objective-C class whose only member is collect(collector:completionHandler:), and FlowCollector is a generic interface — so Swift would have to implement a protocol just to receive Any? values one at a time.

    // commonMain
    
    // ❌ Raw export: Swift sees an opaque Flow with collect(collector:completionHandler:)
    class PostRepo(private val dao: PostDao) {
      fun posts(): Flow<List<Post>> = dao.observePosts()
    }
    …
  6. 06

    The iOS lead does not want another compiler plugin in the build — what does SKIE actually generate, and what does it cost?

    Medium

    SKIE is a Kotlin compiler plugin from Touchlab that generates a Swift layer on top of the Objective-C framework and ships it inside the same framework, so the iOS project installs nothing and imports nothing new.

    // shared/build.gradle.kts
    plugins {
      kotlin("multiplatform")
      alias(libs.plugins.skie)      // co.touchlab.skie — pin it to your Kotlin version
    }
    …
  7. 07

    A network timeout inside a shared call kills the iOS app outright even though Swift wrapped it in do/catch — what is going on?

    Medium

    Only exceptions listed in @Throws are converted to NSError; every other Kotlin exception reaching Objective-C is treated as unhandled and terminates the process, and no Swift catch can intercept it.

    // ❌ Nothing is annotated: a timeout reaching ObjC terminates the process.
    //    Swift's do/catch never runs — there is no NSError to catch.
    class Api(private val client: HttpClient) {
      suspend fun loadFeed(): List<Post> = client.get("/feed").body()
    }
    …
  8. 08

    Swift passed nil where your Kotlin signature promised a non-null parameter — how did that get through, and where does it finally crash?

    Hard

    Because nullability in the generated header is a promise to the Swift compiler, not a runtime check — Kotlin/Native does not re-validate arguments at the boundary, so any nil Swift was not forced to unwrap walks straight into a non-null Kotlin parameter.

    // ❌ Three crashes Swift cannot see coming
    class ProfileVm(private val repo: ProfileRepo) {
      private val cache = mutableMapOf<String, String>()
      lateinit var user: User                        // read before load() -> process kill
      fun avatarUrl(): String = user.avatar!!        // NPE -> process kill, not an error
    …
  9. 09

    Your iOS engineers have their own repo and no JDK on their Macs — how does the shared framework reach them, and what does each option cost?

    Hard

    Either the iOS build compiles Kotlin itself or it consumes a versioned binary, and that choice — not the packaging format — is what the iOS team feels every day.

    import org.jetbrains.kotlin.gradle.plugin.mpp.apple.XCFramework
    
    kotlin {
      val xcf = XCFramework("Shared")              // one artifact, every iOS slice
      listOf(iosArm64(), iosSimulatorArm64()).forEach { target ->
        target.binaries.framework {
    …
  10. 10

    The app ships a widget and a share extension, and cold launch got slower after the shared module landed — static or dynamic framework?

    Hard

    isStatic = true links the Kotlin binary into whatever consumes it at build time, dynamic ships a separate .framework that dyld loads at launch — and with several targets in one app that difference is not only launch time, it is how many Kotlin runtimes you are running.

    kotlin {
      listOf(iosArm64(), iosSimulatorArm64()).forEach { target ->
        target.binaries.framework {
          baseName = "Shared"
    
          // One consumer: link Kotlin straight into the app binary.
    …
  11. 11

    A vendor SDK and your shared module are both Kotlin/Native frameworks and both use kotlinx-datetime — what breaks when they land in one app?

    Hard

    Each Kotlin/Native framework is a self-contained program: it embeds the stdlib, the runtime, the garbage collector and every klib it links, so two frameworks mean two Kotlin runtimes that cannot see each other.

    // umbrella/build.gradle.kts — the single Kotlin binary the iOS app links
    kotlin {
      listOf(iosArm64(), iosSimulatorArm64()).forEach { target ->
        target.binaries.framework {
          baseName = "Shared"
          export(projects.featureFeed)       // every shared module...
    …
  12. 12

    A breakpoint in a .kt file never hits from Xcode and Kotlin values show as opaque objects — what has to be true to debug across the boundary?

    Hard

    LLDB can stop inside Kotlin because the Kotlin/Native compiler emits DWARF, but only for a debug-configuration framework whose recorded source paths still exist on the machine you are debugging from.

    # Xcode > Build Phases > Run Script. The framework variant is what decides
    # whether you can debug at all — the task reads Xcode's CONFIGURATION.
    cd "$SRCROOT/.."
    ./gradlew :shared:embedAndSignAppleFrameworkForXcode
    #   export KOTLIN_FRAMEWORK_BUILD_TYPE=release   <- pinning this in a Debug
    #   scheme is the usual reason breakpoints silently stop working
    …
  13. 13

    The iOS team put [weak self] in every callback and the shared ViewModel still never deallocates — where is its lifetime actually held?

    Hard

    Most "leaks" at this boundary are not ARC cycles at all: a running coroutine is a Kotlin GC root, so a ViewModel with a live collector stays alive no matter what Swift does or does not retain.

    class Cancellable(private val onCancel: () -> Unit) { fun cancel() = onCancel() }
    
    // ❌ A singleton owning the scope: the screen has no way to end its own work
    object FeedHub {
      private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
      private val listeners = mutableListOf<(List<Post>) -> Unit>()
    …
  14. 14

    A StateFlow collected on Dispatchers.Default drives a SwiftUI @Published property and the runtime complains — what are Kotlin/Native's threading rules now?

    Hard

    Kotlin/Native stopped enforcing anything years ago — freezing, freeze(), @SharedImmutable and InvalidMutabilityException are gone and objects are shared across threads as on the JVM — so every rule left at this boundary is Apple's, and placing the main-thread hop is your job.

    class FeedViewModel(private val repo: FeedRepo) {
      private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
    
      private val _state = MutableStateFlow<FeedState>(FeedState.Loading)
      val state: StateFlow<FeedState> = _state
    …
  15. 15

    How do you shape the shared module's public API so the SwiftUI on top of it reads like Swift instead of a translated Kotlin header?

    Hard

    Treat the exported surface as a designed product with exactly one consumer, not as whatever happened to be public in commonMain — because that is precisely what the header generator ships.

    @OptIn(ExperimentalObjCRefinement::class)
    class FeedViewModel(private val repo: FeedRepo) {   // FeedRepo is internal, never exported
    
      private val scope = MainScope()
    
      // The whole screen as one value: Swift renders this and nothing else.
    …
  16. 16

    Swift export takes Objective-C out of the middle of the bridge — what does that actually change, and would you ship a release on it today?

    Hard

    Swift export makes the Kotlin/Native compiler emit Swift bindings directly, so the API no longer has to be expressible in Objective-C first — which is where most of today's interop pain comes from.

    // gradle.properties
    //   kotlin.experimental.swift-export.enabled=true
    
    // shared/build.gradle.kts
    kotlin {
      iosArm64()
    …