Dan Tech Academy
After this lesson you will
  • Explain how the Shared framework is built and wired into Xcode, and what its header contains
  • Know the interop limits through Objective-C for suspend, Flow, generics, sealed types, enums, default parameters and exceptions, and when you need SKIE or Swift export
  • Call Apple APIs from iosMain and understand the basics of the Kotlin/Native memory model when working with ARC
Duration18 min
DifficultyBeginner

iOS Host And Swift Interop

How Kotlin becomes an iOS framework, what Swift sees through the Objective-C header, which features are lost crossing the bridge and how Kotlin calls Apple's APIs.

Video in production

Lesson video is on the way

While you wait, read the full text version below: complete theory, code samples and the hands-on checklist.

Recording is in post-production

Lesson Goal

This lesson explains the machinery behind running a KMP app on iOS: how Kotlin becomes a framework, what Swift sees of that framework, which Kotlin features get lost crossing the bridge, and how Kotlin calls Apple's APIs.

After this lesson, you can design the boundary between Kotlin and Swift for a KMP project: what Swift should see, what to hide, and when you need tools like SKIE or Swift export.

The steps in this lesson that need a Mac (inspecting the header, running Xcode) were not run on a Mac when it was written. Every detail about the repo comes from reading the code.

Part 1: Kotlin Becomes A Framework

Open shared-app/build.gradle.kts. For each iOS target, the module declares a framework:

kotlin
iosTarget.binaries.framework {
    baseName = "Shared"
    isStatic = true
}
  • baseName = "Shared" is the name Swift uses in import Shared.
  • isStatic = true produces a static framework. A comment explains why: a dynamic framework costs app launch time and gives nothing back, because nothing else links against it.

Xcode calls Gradle through the embedAndSignAppleFrameworkForXcode build phase. This way of wiring things is called direct integration. Other projects may use CocoaPods, or build an XCFramework and distribute it through Swift Package Manager when the iOS team works in a separate repository. When you meet an unfamiliar project, open Build Phases in Xcode to see which approach it uses.

Docs: iOS integration methods, Build final native binaries.

Part 2: What Swift Sees

Swift doesn't read Kotlin code directly. The Kotlin/Native compiler generates an Objective-C header for the framework, and Swift imports that header. Everything Swift sees has passed through Objective-C.

A few translation rules you will meet right in the repo:

KotlinWhat Swift sees
The top-level function doInitApp() in SharedKoin.ktSharedKoinKt.doInitApp()
The top-level function MainViewController() in MainViewController.ktMainViewControllerKt.MainViewController()
Classes, interfacesClasses, protocols, with a prefix taken from the framework name in Objective-C
objectA class with a shared property
private, internalNot visible

Top-level functions are grouped into a class named after the file plus a Kt suffix. That's why the KDoc of doInitApp points out that the file is named SharedKoin.kt so that the class in the header is SharedKoinKt. Rename the Kotlin file and you have to fix the Swift too.

The function is named doInitApp instead of initApp. In Objective-C, methods that start with init belong to the initializer family and are treated differently from ordinary methods. The KDoc explains that the repo chose to avoid that prefix entirely.

What The Header Contains

According to the docs, the framework by default contains the public API of the module being built. A dependency module's API only goes into the header when you call export(...) in the framework block and declare that dependency with api(...). The docs advise against turning on transitiveExport, because it pulls in indirect dependencies too, which increases build time and binary size.

:shared-app doesn't call export(...) at all. A comment at the top of the file states the intent: only what is really needed in the header gets exported.

A code-reading exercise: find the public top-level declarations in shared-app/src/commonMain and shared-app/src/iosMain. You will see AppShell, appKoinModules, SplashViewModel, IosNotifier... Swift only calls two functions. The rest is still public, so it still goes into the header. To hide a declaration from Swift while still letting other Kotlin modules use it, the docs offer @HiddenFromObjC. If only that module uses it, internal is enough.

Docs: Interoperability with Swift/Objective-C.

Part 3: What Gets Lost Going Through Objective-C

Objective-C is much older than Kotlin and Swift, so many features have no complete translation. This is the table to keep in mind when you design an API for Swift to call:

Kotlin featureWhat Swift gets
suspend funA function with a completion handler. You can call it with async in Swift, but the docs say this is still highly experimental
FlowAn ordinary Kotlin interface that you collect through a callback. The element type is lost
GenericsOnly kept on classes. Generics on interfaces and functions are lost
sealed class/interfaceAn ordinary class tree. A Swift switch needs a default
enum classA class, not a Swift enum
Parameters with default valuesSwift has to pass every parameter
ExceptionsA function without @Throws that throws into Swift stops the app completely
Int?Boxed into an object such as KotlinInt

Exceptions are the most dangerous row. Kotlin has no checked exceptions; Swift does. The docs are explicit: an exception that reaches Swift and isn't in the list declared by @Throws is treated as unhandled and stops the program. For a suspend fun without @Throws, only CancellationException is converted to an NSError.

The repo sidesteps this whole table by design: Swift only calls two functions with no parameters, one that returns nothing and one that returns a UIViewController. The whole UI is written in Compose Multiplatform in Kotlin, so no ViewModel, Flow or sealed class has to cross the bridge. The thinner the Swift shell, the less this table matters.

If you take over a project whose iOS UI is written in SwiftUI and calls Kotlin ViewModels, this table touches every screen. That's when you need Part 4.

Docs: the Kotlin-Swift interopedia, maintained by JetBrains, has an example for every row in the table.

Part 4: SKIE And Swift Export

Two tools address the table in Part 3. The repo uses neither, but you will meet them in other projects.

ToolWhat it isWhat it fills inStatus
SKIEA Gradle plugin from Touchlab that generates extra Swift code into the frameworkFlow as AsyncSequence, suspend with cancellation, exhaustive switch over enums and sealed types, default parametersA third-party library
Swift exportA feature of Kotlin itself that exports straight to Swift without going through Objective-Csuspend as async, Flow as AsyncSequence, sealed types as Swift enums, packages preservedAlpha according to the Kotlin docs

Two notes if you consider Swift export: the docs say it only works with direct integration, and generics are erased to their upper bound when exported. Since it's in Alpha, don't put a project that handles real money on it without thorough testing.

Docs: Swift export, SKIE.

Part 5: Kotlin Calling Apple APIs

The other direction is much easier. In iosMain, Apple's system frameworks are available under the platform.* packages with no extra configuration.

File in the repoApple API used
IosDataStoreFileResolverNSFileManager, NSURL (Foundation)
SharedKoin.ktNSBundle, to read values from Info.plist
IosShareSheetUIActivityViewController (UIKit)
IosGoogleSignInLauncherASWebAuthenticationSession (AuthenticationServices)
GoogleOAuthPkceCC_SHA256 (CoreCrypto), SecRandomCopyBytes (Security)

Some C-style APIs require @OptIn(ExperimentalForeignApi::class), as you can see on IosDataStoreFileResolver and in GoogleOAuthPkce.

An important limit: Kotlin can only call Swift when that API is exposed to Objective-C with @objc. Pure Swift modules aren't supported yet. The KDoc of IosGoogleSignInLauncher shows the consequence: instead of pulling in the Google Sign-In SDK, the repo writes its own OAuth flow with PKCE using Apple's APIs, so it doesn't have to add a seam written in Swift.

Docs: Platform libraries.

Part 6: Memory Model Basics

Kotlin/Native uses a tracing garbage collector, like the JVM: objects live on a shared heap and can be accessed from any thread. Swift and Objective-C use ARC, reference counting. The two mostly cooperate smoothly, but there are two points you need to know:

  • Retain cycles. Kotlin's GC can reclaim reference cycles between Kotlin objects. ARC can't. According to the docs, if even one Objective-C or Swift object is in the cycle, the whole cycle isn't reclaimed. When Swift passes a closure that captures self into Kotlin, use [weak self] just as you would in ordinary Swift code.
  • Deinit. A Swift object passed into Kotlin on the main thread runs its deinit on the main thread, after a GC cycle rather than immediately.

If older docs or articles mention freeze() or not being allowed to share objects between threads, that's the old memory manager. Current code doesn't need any of that.

Docs: Kotlin/Native memory management, Integration with Swift/Objective-C ARC.

Part 7: Compose In The iOS App

MainViewController wraps AppShell() in a ComposeUIViewController, which returns an ordinary UIViewController. ContentView.swift wraps it so SwiftUI can display it. This is the standard pattern the Compose Multiplatform docs describe for embedding Compose in a SwiftUI app.

Config such as the Supabase URL or the RevenueCat key goes from Config.xcconfig into Info.plist, and Kotlin reads it with NSBundle in iosAppBuildConfig.

Docs: Integration with SwiftUI.

Troubleshooting Table

SymptomUsual causeFix
Swift says it can't find SharedKoinKtThe Kotlin file was renamed, so the class in the header changed with itRename the file back or fix the call in Swift
The iOS app stops suddenly when Swift calls a Kotlin functionThe function throws an exception without @ThrowsCatch the error in Kotlin, or declare @Throws so Swift can handle it
Swift can't see a type from another moduleThat module isn't export(...)edAdd export(...) and declare api(...), only for types that are really needed
A Swift switch over a sealed class demands a defaultThe sealed class became an ordinary class tree through Objective-CAccept the default, or use SKIE
iOS app memory keeps growing around one screenA Swift closure holds self inside a Kotlin objectUse [weak self] in the closure

Checkpoint

You are done with this lesson when you can:

  • explain why Swift calls SharedKoinKt.doInitApp() and not doInitApp(),
  • say which module's API the Shared framework contains, and what you would have to do for Swift to see a type from :domain,
  • pick the three rows in the Part 3 table that would cause the most trouble if the iOS UI were written in SwiftUI,
  • explain why the repo doesn't need SKIE or Swift export,
  • name two Apple APIs the repo calls directly from Kotlin.
Sign in