References: KMP iOS Integration | Kotlin-Swift Interop
| Kotlin | Swift | Notes |
|---|---|---|
class MyClass |
MyClass |
Direct mapping |
Top-level fun myFun() |
MyClassKt.myFun() |
File name becomes prefix |
Top-level val myVal |
MyClassKt.myVal |
Same rule as functions |
object MySingleton |
MySingleton.shared |
Kotlin objects → .shared |
companion object |
Direct on class | No .companion needed |
suspend fun |
async (with KMP adapters) |
Needs @JvmStatic or wrapper |
sealed class |
Protocol + classes | Requires careful mapping |
// iosMain/MainViewController.kt
fun MainViewController(): UIViewController = ComposeUIViewController { ... }
// ContentView.swift — note the "Kt" suffix for top-level functions
MainViewControllerKt.MainViewController()
Kotlin's null-safety maps directly to Swift optionals:
| Kotlin | Swift |
|---|---|
String (non-null) |
String |
String? (nullable) |
String? |
List<String> |
[String] |
List<String?> |
[String?] |
Important: Kotlin's Unit becomes KotlinUnit in Swift. Avoid exposing functions returning Unit to Swift — use Void via wrapper functions when needed.
// Avoid this in public iOS API
fun doSomething(): Unit { ... }
// Prefer void-like callbacks wrapped in expect/actual or explicit wrappers
Kotlin collections are bridged to Swift, but mutability is lost:
// Kotlin List<String> → Swift [String] (read-only copy)
let items: [String] = kotlinObject.items as! [String]
// Kotlin Map<String, Int> → Swift [String: Int]
let map: [String: Int] = kotlinObject.config as! [String: Int]
Never pass Swift Array or Dictionary directly to Kotlin functions expecting List or Map. Wrap at the boundary:
// iosMain — expose iOS-friendly API
fun processItems(items: Array<String>) {
internalFunction(items.toList())
}
Kotlin suspend functions are not automatically available in Swift as async. Use one of these patterns:
SKIE auto-generates Swift-friendly async wrappers:
# libs.versions.toml
skie = "0.9.5"
[plugins]
skie = { id = "co.touchlab.skie", version.ref = "skie" }
// shared/build.gradle.kts
plugins {
alias(libs.plugins.skie)
}
With SKIE, Kotlin suspend functions become Swift async:
// Without SKIE — callback-based
UserRepositoryKt.getUser(id: "1") { result, error in ... }
// With SKIE — native Swift async/await
let user = try await userRepository.getUser(id: "1")
// iosMain — wrap suspend functions in callbacks for Swift
fun getUser(id: String, onSuccess: (User) -> Unit, onError: (String) -> Unit) {
CoroutineScope(Dispatchers.Main).launch {
when (val result = userRepository.getUser(id)) {
is Resource.Success -> onSuccess(result.data)
is Resource.Error -> onError(result.error.toUserMessage())
is Resource.Loading -> {}
}
}
}
kmp-nativecoroutines = "1.0.0-ALPHA-35"
Exposes Kotlin Flows and suspend functions as Swift async sequences.
Kotlin Flow<T> is not directly usable in Swift. Options:
SKIE converts Flow<T> to AsyncSequence automatically.
for await item in viewModel.uiState {
updateUI(with: item)
}
// iosMain
class HomeViewModelIos(private val viewModel: HomeViewModel) {
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
fun observeState(onChange: (HomeUiState) -> Unit): () -> Unit {
val job = scope.launch {
viewModel.uiState.collect { onChange(it) }
}
return { job.cancel() }
}
fun cancel() {
scope.cancel()
}
}
class HomeViewController: UIViewController {
private var cancelObservation: (() -> Void)?
override func viewDidLoad() {
super.viewDidLoad()
cancelObservation = viewModel.observeState { [weak self] state in
self?.updateUI(state: state)
}
}
deinit {
cancelObservation?()
viewModel.cancel()
}
}
Kotlin/Native enforces strict memory isolation rules. Key rules:
InvalidMutabilityException (pre-1.7) or use the new memory model (1.7+, default)@ThreadLocal for thread-local mutable state in iosMain@SharedImmutable for constants shared across threads (deprecated in new MM — just use val)Main dispatcher — Always use Dispatchers.Main for UI updates on iOS
// iosMain — ensure coroutines run on main thread for UI
actual fun platformModule(): Module = module {
single { Dispatchers.Main }
}
Kotlin sealed classes generate a class hierarchy in Swift, but Swift switch won't enforce exhaustiveness. Use SKIE or document the pattern:
// commonMain
sealed class AuthState {
data object Unauthenticated : AuthState()
data class Authenticated(val userId: String) : AuthState()
data object Loading : AuthState()
}
// Without SKIE — verbose and not exhaustive
if let state = authState as? AuthStateAuthenticated {
print(state.userId)
} else if authState is AuthStateUnauthenticated {
showLogin()
}
// With SKIE — sealed classes become Swift enums (exhaustive)
switch authState {
case .unauthenticated: showLogin()
case .authenticated(let userId): showHome(userId: userId)
case .loading: showSpinner()
}
To use the local KMP build in Xcode without SPM remote package:
Add a Run Script build phase to the iOS target:
cd "$SRCROOT/../.."
./gradlew :shared:assembleDebugXCFramework
Add the XCFramework output as a local framework:
Build Phases → Link Binary With Libraries → Add Other → Add Filesshared/build/XCFrameworks/debug/shared.xcframeworkSet Embed & Sign for the framework in Frameworks, Libraries, and Embedded Content
Or use the local SPM package approach (simpler):
// Package.swift for local development (no binaryTarget)
.target(
name: "MySharedWrapper",
dependencies: ["MySharedBinary"]
// points to local path, no URL/checksum needed
)
.kt files when using embedAndSignAppleFrameworkForXcode Gradle taskEnable dSYM: In build.gradle.kts:
iosArm64 {
binaries.framework {
debuggable = true
isStatic = true
}
}
Kotlin/Native memory leak debugging: Use the kotlin.native.internal.GC.collect() and monitor with Instruments
Use os_log (not NSLog — it is deprecated for structured logging) via expect/actual. os_log integrates with the unified logging system visible in Console.app and Instruments.
// iosMain
import platform.darwin.*
actual fun logDebug(tag: String, message: String) {
os_log_with_type(OS_LOG_DEFAULT, OS_LOG_TYPE_DEBUG, "[$tag] $message")
}
actual fun logInfo(tag: String, message: String) {
os_log_with_type(OS_LOG_DEFAULT, OS_LOG_TYPE_INFO, "[$tag] $message")
}
actual fun logWarn(tag: String, message: String) {
os_log_with_type(OS_LOG_DEFAULT, OS_LOG_TYPE_ERROR, "[$tag] WARN: $message")
}
actual fun logError(tag: String, message: String, throwable: Throwable?) {
os_log_with_type(OS_LOG_DEFAULT, OS_LOG_TYPE_FAULT, "[$tag] ERROR: $message ${throwable?.message ?: ""}")
}
Log level mapping:
| Kotlin Level | os_log Type |
Visible in |
|---|---|---|
| DEBUG | OS_LOG_TYPE_DEBUG |
Instruments / Console (debug builds) |
| INFO | OS_LOG_TYPE_INFO |
Console.app |
| WARN | OS_LOG_TYPE_ERROR |
Console.app + crash reports |
| ERROR | OS_LOG_TYPE_FAULT |
Console.app + crash reports + telemetry |
Never log sensitive data (auth tokens, passwords, PII) — os_log entries can persist on device.
// androidMain
actual fun logDebug(tag: String, message: String) {
Log.d(tag, message)
}
actual fun logError(tag: String, message: String, throwable: Throwable?) {
Log.e(tag, message, throwable)
}
SKIE converts Kotlin sealed classes to Swift enums, but there are important edge cases:
SKIE cannot convert sealed classes with generic type parameters to exhaustive Swift enums. Avoid generics in sealed classes exposed to iOS:
// AVOID — SKIE cannot generate exhaustive Swift enum for this
sealed class Result<T> {
data class Success<T>(val data: T) : Result<T>()
data class Error<T>(val error: String) : Result<T>()
}
// PREFER — use concrete types at the iOS API boundary
sealed class UserResult {
data class Success(val user: User) : UserResult()
data class Error(val message: String) : UserResult()
}
SKIE flattens nested sealed hierarchies. Deeply nested classes like AppError.Network.NoConnection become AppErrorNetworkNoConnection in Swift — document this mapping:
// Kotlin
sealed class AuthState {
data object Loading : AuthState()
data class Authenticated(val token: String) : AuthState()
sealed class Error : AuthState() {
data object InvalidCredentials : Error()
data object NetworkError : Error()
}
}
// With SKIE — switch cases
switch state {
case .loading: showSpinner()
case .authenticated(let token): storeToken(token)
case .errorInvalidCredentials: showLoginError()
case .errorNetworkError: showNetworkError()
// Note: Error subclass prefix is added to distinguish nested cases
}
Opt out of SKIE transformation for specific types using @SealedInterop.Disabled:
@SealedInterop.Disabled
sealed class InternalEvent { // not exposed to Swift — kept as class hierarchy
class ItemAdded(val id: String) : InternalEvent()
}
Kotlin/Native uses the new memory model (default since Kotlin 1.7.20) which removes the strict object freeze requirement. Key implications:
InvalidMutabilityException — objects are no longer frozen automaticallyUse AtomicReference for thread-safe state in iosMain:
// iosMain — thread-safe shared mutable reference
import kotlin.native.concurrent.AtomicReference
class SharedCounter {
private val _count = AtomicReference(0)
fun increment() {
_count.value++
}
fun get(): Int = _count.value
}
Coroutines on iOS — always use Dispatchers.Main for UI updates; background work uses Dispatchers.Default (maps to a background thread pool):
// iosMain — correct dispatcher usage
actual fun platformModule(): Module = module {
single<CoroutineDispatcher> { Dispatchers.Main }
}
GC differences — Kotlin/Native uses a different GC than JVM. Avoid creating large object graphs that reference each other across thread boundaries; prefer passing data by value (data classes) over references.
Objective-C object lifecycle — When bridging to Swift, Kotlin objects retain their reference count. Avoid circular references between Kotlin and Swift objects — use weak on the Swift side:
class HomeViewController: UIViewController {
// Weak reference prevents retain cycle with Kotlin ViewModel
private weak var viewModel: HomeViewModelIos?
}
isStatic = true for frameworks — always use static frameworks for iOS to avoid dynamic linking overhead:
iosArm64 {
binaries.framework {
baseName = "shared"
isStatic = true // required for App Store; avoids dyld loading cost
}
}
Minimize Kotlin↔Swift boundary crossings in hot paths — each call across the boundary incurs overhead (ObjC message dispatch). Batch data instead of iterating Kotlin collections from Swift:
// GOOD — one call, returns all data
fun getItems(): List<Item> = repository.getAllCached()
// BAD in a loop — each call crosses the Kotlin/ObjC boundary
// Swift: for i in 0..<viewModel.itemCount { let item = viewModel.getItem(i) }
Avoid suspending functions that return Unit to Swift — prefer callback-based APIs at iOS boundaries (or use SKIE):
// iosMain — iOS-friendly callback API for Swift that doesn't use SKIE
fun loadData(onResult: (List<Item>) -> Unit, onError: (String) -> Unit) {
CoroutineScope(Dispatchers.Main).launch {
when (val result = repository.getItems()) {
is Resource.Success -> onResult(result.data)
is Resource.Error -> onError(result.error.toUserMessage())
is Resource.Loading -> {}
}
}
}
XCFramework size — enable Bitcode and LLVM optimizations in release builds:
iosArm64 {
binaries.framework {
freeCompilerArgs += listOf("-Xoptimization-passes=2")
optimized = true // enables dead code elimination
}
}
When exposing Kotlin APIs to Swift/iOS:
internal@HiddenFromObjC to exclude Kotlin-internal types from the Swift APIName iOS-facing functions clearly — getUser(id:) not fetchUserById(id:)
// Mark internal Kotlin utilities as hidden from Swift
@HiddenFromObjC
internal fun internalHelper(): String = "not exposed to Swift"