- 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
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.
Lesson video is on the way
While you wait, read the full text version below: complete theory, code samples and the hands-on checklist.
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:
iosTarget.binaries.framework {
baseName = "Shared"
isStatic = true
}
baseName = "Shared"is the name Swift uses inimport Shared.isStatic = trueproduces 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:
| Kotlin | What Swift sees |
|---|---|
The top-level function doInitApp() in SharedKoin.kt | SharedKoinKt.doInitApp() |
The top-level function MainViewController() in MainViewController.kt | MainViewControllerKt.MainViewController() |
| Classes, interfaces | Classes, protocols, with a prefix taken from the framework name in Objective-C |
object | A class with a shared property |
private, internal | Not 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 feature | What Swift gets |
|---|---|
suspend fun | A function with a completion handler. You can call it with async in Swift, but the docs say this is still highly experimental |
Flow | An ordinary Kotlin interface that you collect through a callback. The element type is lost |
| Generics | Only kept on classes. Generics on interfaces and functions are lost |
sealed class/interface | An ordinary class tree. A Swift switch needs a default |
enum class | A class, not a Swift enum |
| Parameters with default values | Swift has to pass every parameter |
| Exceptions | A 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.
| Tool | What it is | What it fills in | Status |
|---|---|---|---|
| SKIE | A Gradle plugin from Touchlab that generates extra Swift code into the framework | Flow as AsyncSequence, suspend with cancellation, exhaustive switch over enums and sealed types, default parameters | A third-party library |
| Swift export | A feature of Kotlin itself that exports straight to Swift without going through Objective-C | suspend as async, Flow as AsyncSequence, sealed types as Swift enums, packages preserved | Alpha 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 repo | Apple API used |
|---|---|
IosDataStoreFileResolver | NSFileManager, NSURL (Foundation) |
SharedKoin.kt | NSBundle, to read values from Info.plist |
IosShareSheet | UIActivityViewController (UIKit) |
IosGoogleSignInLauncher | ASWebAuthenticationSession (AuthenticationServices) |
GoogleOAuthPkce | CC_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
selfinto 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
deiniton 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
| Symptom | Usual cause | Fix |
|---|---|---|
Swift says it can't find SharedKoinKt | The Kotlin file was renamed, so the class in the header changed with it | Rename the file back or fix the call in Swift |
| The iOS app stops suddenly when Swift calls a Kotlin function | The function throws an exception without @Throws | Catch the error in Kotlin, or declare @Throws so Swift can handle it |
| Swift can't see a type from another module | That module isn't export(...)ed | Add export(...) and declare api(...), only for types that are really needed |
A Swift switch over a sealed class demands a default | The sealed class became an ordinary class tree through Objective-C | Accept the default, or use SKIE |
| iOS app memory keeps growing around one screen | A Swift closure holds self inside a Kotlin object | Use [weak self] in the closure |
Checkpoint
You are done with this lesson when you can:
- explain why Swift calls
SharedKoinKt.doInitApp()and notdoInitApp(), - say which module's API the
Sharedframework 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.