Upgrade to Pro — share decks privately, control downloads, hide ads and more …

KotlinConf 2026 - What Nobody Told Us About Shi...

Sponsored · SiteGround - Reliable hosting with speed, security, and support you can count on.

KotlinConf 2026 - What Nobody Told Us About Shipping Kotlin to iOS

Avatar for Suhyeon Kim

Suhyeon Kim

May 22, 2026

More Decks by Suhyeon Kim

Other Decks in Programming

Transcript

  1. About Me • Android Dev & Technical Advocate at Woowa

    Bros Delivery Hero) • 7 years of Experience with Android and Kotlin Multiplatform • Developer Community Organizer GDG Korea Android) LinkedIn Gmail linkedin.com/in/wisemuji [email protected]
  2. Before We Start - An Android developer's perspective (iOS beginner)

    - This talk is for those already using, or about to use KMP on iOS. - Weʼll skip the docs and focus on what broke and how we fixed it. - Better ideas? Let's discuss!
  3. The Starting Point Building an in-house trainee management app Team

    󰱹󰞦󰟲 Dev A Android) Dev B Android) Me Android) Ship in 2 months No iOS developer on team Backend also Kotlin stack
  4. 1 Year in Production Internal app, actively maintained on both

    platforms iOS Stable since Compose Multiplatform 1.8.0 All-Kotlin project (server + client)
  5. What We'll Cover Native State Observation Bridging platform events to

    Compose UI Integrating Non-KMP SDKs IoC for Firebase + Kotlin/Native issue on iOS Dependency Management SwiftPM Export: Fix for Multi-Module iOS Builds + Interop pitfalls cheat sheet Insights from JetBrains at KotlinConf 2025
  6. A push notification arrives. Your shared Compose UI needs to

    react. How would you do that in CMP100% Compose UI?
  7. When expect/actual Falls Short - Native UI Events Push notifications

    trigger shared logic, but originate in native code. - Async Platform Logic Returns asynchronously, commonMain can't handle delayed callbacks. e.g. Firebase Remote Config - Native System APIs Platform-specific lifecycle, no KMP equivalent.
  8. Real iOS APIs that hit this wall iOS API Why

    it can't go in commonMain UNUserNotificationCenter Delegate callbacks, not flows CLLocationManager Background-thread callbacks RemoteConfig Firebase) Async with no Kotlin SDK AppDelegate lifecycle No KMP equivalent CBCentralManager Bluetooth) Stateful native session AVAudioSession Singleton, app-wide config
  9. Case Study 1 Native State Observation Requirements: • Develop a

    forced update feature. • Users cannot launch the app if their version is lower than the recommended version.
  10. Case Study 1 Native State Observation Can events from the

    iOS module be received in the Kotlin module?
  11. Kotlin composeApp/iosMain/MainViewController.kt fun MainViewController() = ComposeUIViewController { App() } Swift

    iosApp/ContentView.swift struct ComposeView: UIViewControllerRepresentable { } func makeUIViewController(context: Context) -> UIViewController { MainViewControllerKt.MainViewController() ... Default KMP Project)
  12. Kotlin composeApp/iosMain/MainViewController.kt object SharedViewControllers { private data class ViewState(val requiredAppVersionCode:

    Long = 0) private val state = MutableStateFlow(ViewState()) fun mainViewController(onFinish: () -> Unit): UIViewController { return ComposeUIViewController { // This subscribes to the StateFlow state. val viewState by state.collectAsStateWithLifecycle() if (viewState.requiredAppVersionCode > currentAppVersionCode) { ... } App() } } } // This is called from SwiftUI. fun updateRequiredAppVersionCode(code: Long) { state.value = state.value.copy(requiredAppVersionCode = code) }
  13. Kotlin composeApp/iosMain/MainViewController.kt object SharedViewControllers { private data class ViewState(val requiredAppVersionCode:

    Long = 0) private val state = MutableStateFlow(ViewState()) fun mainViewController(onFinish: () -> Unit): UIViewController { return ComposeUIViewController { // This subscribes to the StateFlow state. val viewState by state.collectAsStateWithLifecycle() if (viewState.requiredAppVersionCode > currentAppVersionCode) { ... } App() } } } // This is called from SwiftUI. fun updateRequiredAppVersionCode(code: Long) { state.value = state.value.copy(requiredAppVersionCode = code) }
  14. Kotlin composeApp/iosMain/MainViewController.kt object SharedViewControllers { private data class ViewState(val requiredAppVersionCode:

    Long = 0) private val state = MutableStateFlow(ViewState()) fun mainViewController(onFinish: () -> Unit): UIViewController { return ComposeUIViewController { // This subscribes to the StateFlow state. val viewState by state.collectAsStateWithLifecycle() if (viewState.requiredAppVersionCode > currentAppVersionCode) { ... } App() } } } // This is called from SwiftUI. fun updateRequiredAppVersionCode(code: Long) { state.value = state.value.copy(requiredAppVersionCode = code) }
  15. Swift iosApp/ContentView.swift import SwiftUI import ComposeApp struct ComposeView: UIViewControllerRepresentable {

    func makeUIViewController(context: Context) -> UIViewController { return SharedViewControllers.shared .mainViewController(onFinish: { ... }) } func updateRequiredAppVersionCode(code: Int64) { SharedViewControllers() .updateRequiredAppVersionCode(code: code) } func fetchRemoteConfig() { let remoteConfig = RemoteConfig.remoteConfig() } } remoteConfig.fetchAndActivate { status, error in // ... }
  16. Swift iosApp/ContentView.swift import SwiftUI import ComposeApp struct ComposeView: UIViewControllerRepresentable {

    func makeUIViewController(context: Context) -> UIViewController { return SharedViewControllers.shared .mainViewController(onFinish: { ... }) } func updateRequiredAppVersionCode(code: Int64) { SharedViewControllers() .updateRequiredAppVersionCode(code: code) } func fetchRemoteConfig() { let remoteConfig = RemoteConfig.remoteConfig() } } remoteConfig.fetchAndActivate { status, error in // ... }
  17. Kotlin logger/commonMain/Log.kt expect object Log { fun e(tag: String, message:

    String, throwable: Throwable? = null) fun d(tag: String, message: String) fun i(tag: String, message: String) } Kotlin logger/androidMain/Log.android.kt actual object Log { actual fun e(tag: String, message: String, throwable: Throwable?) { Log.e(tag, message, throwable) } // ... } Kotlin logger/iosMain/Log.ios.kt actual object Log { actual fun e(tag: String, message: String, throwable: Throwable?) { NSLog("ERROR: [$tag] $message. Throwable: $throwable") } // ... }
  18. But... Firebase Has No KMP SDK ✅ Android ❌ iOS

    Firebase Crashlytics SDK is a regular Gradle dependency. Direct import in androidMain. Firebase iOS SDK is Swift/ObjC. Kotlin/Native cannot import it in iosMain.
  19. Kotlin logger/iosMain/FirebaseCrashlyticsDelegate.kt interface FirebaseCrashlyticsDelegate { fun recordException(error: NSError) } Kotlin

    logger/iosMain/Log.ios.kt actual object Log { private var crashlyticsDelegate: FirebaseCrashlyticsDelegate? = null } fun initCrashlyticsDelegate(delegate: FirebaseCrashlyticsDelegate) { crashlyticsDelegate = delegate } // ...
  20. Kotlin logger/iosMain/FirebaseCrashlyticsDelegate.kt interface FirebaseCrashlyticsDelegate { fun recordException(error: NSError) } Kotlin

    logger/iosMain/Log.ios.kt actual object Log { private var crashlyticsDelegate: FirebaseCrashlyticsDelegate? = null } fun initCrashlyticsDelegate(delegate: FirebaseCrashlyticsDelegate) { crashlyticsDelegate = delegate } // ...
  21. Swift iosApp/DefaultFirebaseCrashlyticsDelegate.swift class DefaultFirebaseCrashlyticsDelegate: FirebaseCrashlyticsDelegate { func recordException(error: any Error)

    { Crashlytics.crashlytics().record(error: error) } } Swift iosApp/iOSApp.swift class AppDelegate: NSObject, UIApplicationDelegate { func application(_ application: UIApplication,...) -> Bool { FirebaseApp.configure() Log.shared.doInitCrashlyticsDelegate(delegate: Default…Delegate()) } // ... }
  22. Swift iosApp/DefaultFirebaseCrashlyticsDelegate.swift class DefaultFirebaseCrashlyticsDelegate: FirebaseCrashlyticsDelegate { func recordException(error: any Error)

    { Crashlytics.crashlytics().record(error: error) } } Swift iosApp/iOSApp.swift class AppDelegate: NSObject, UIApplicationDelegate { func application(_ application: UIApplication,...) -> Bool { FirebaseApp.configure() Log.shared.doInitCrashlyticsDelegate(delegate: Default…Delegate()) } // ... }
  23. Swift iosApp/DefaultFirebaseCrashlyticsDelegate.swift class DefaultFirebaseCrashlyticsDelegate: FirebaseCrashlyticsDelegate { func recordException(error: any Error)

    { Crashlytics.crashlytics().record(error: error) } } Swift iosApp/iOSApp.swift class AppDelegate: NSObject, UIApplicationDelegate { func application(_ application: UIApplication,...) -> Bool { FirebaseApp.configure() Log.shared.doInitCrashlyticsDelegate(delegate: Default…Delegate()) } // ... }
  24. Swift iosApp/DefaultFirebaseCrashlyticsDelegate.swift class DefaultFirebaseCrashlyticsDelegate: FirebaseCrashlyticsDelegate { func recordException(error: any Error)

    { Crashlytics.crashlytics().record(error: error) } } Swift iosApp/iOSApp.swift class AppDelegate: NSObject, UIApplicationDelegate { func application(_ application: UIApplication,...) -> Bool { FirebaseApp.configure() Log.shared.doInitCrashlyticsDelegate(delegate: Default…Delegate()) } // ... }
  25. But it didnʼt work as expected... Expected Actual ViewModel calls

    Log delegate → crash recorded Log delegate is null → crash reporting silently fails Delegate was injected & the function was called, But at the call site delegate was always null 🤔
  26. The Misdiagnoses Threading issue? Added DispatchQueue.main.async Initialization order? Added delays,

    checked lifecycle Still null ��💫 Weak reference ARC? Made it a strong property We were debugging the wrong dimension entirely. Still null Still null
  27. What really was the problem: Two different Log instances in

    the same iOS process. The delegate was set on one, read from another. Kotlin's object is a singleton per compiled binary, not per iOS process.
  28. Quick Fix: Merge Into Single Module 🤔 Pros: Simple, works

    immediately Cons: Lost modularity, slower builds, Need to change the whole architecture
  29. Proper Fix: Framework export Kotlin DSL composeApp/build.gradle.kts listOf( iosX64(), iosArm64(),

    iosSimulatorArm64() ).forEach { iosTarget -> iosTarget.binaries.framework { baseName = "ComposeApp" isStatic = true transitiveExport = true export(projects.logger) } } ✅ Modules stay separate. Keep your :logger, :core, ... ✅ One framework, No split singletons
  30. Alternatives - Speak up directly to the Firebase team to

    request KMP support: https://firebase.uservoice.com/forums/948424-general/suggestions/46591717-support -kotlin-multiplatform-kmp-in-the-sdks - Use the unofficial Firebase Kotlin SDK https://github.com/GitLiveApp/firebase-kotlin-sdk - Choose another logging SDK that supports KMP (e.g., Sentry): https://docs.sentry.io/platforms/kotlin/guides/kotlin-multiplatform
  31. Recap - Firebase in androidMain Kotlin logger/androidMain/Log.android.kt import com.google.firebase.crashlytics.ktx.crashlytics import

    com.google.firebase.ktx.Firebase actual object Log { actual fun e(tag: String, message: String, throwable: Throwable?) { // ... Firebase.crashlytics.recordException(throwable ?: Exception(message)) } }
  32. Firebase in iosMain with CocoaPods Kotlin logger/iosMain/Log.ios.kt import cocoapods.FirebaseCrashlytics.FIRCrashlytics import

    platform.Foundation.NSError import platform.Foundation.NSLocalizedDescriptionKey actual fun recordException(t: Throwable) { // ... FIRCrashlytics.crashlytics().recordError(nsError) }
  33. Kotlin 2.4.0RC · as of May 2026 SwiftPM import -

    Gradle Config Kotlin DSL composeApp/build.gradle.kts kotlin { swiftPMDependencies { iosMinimumDeploymentTarget.set("16.0") } } swiftPackage( url = "https://github.com/firebase/firebase-ios-sdk.git", version = "12.5.0", products = listOf( "FirebaseCrashlytics", "FirebaseRemoteConfig" ), importedClangModules = listOf( "FirebaseCrashlytics", "FirebaseRemoteConfig" ), )
  34. Kotlin 2.4.0RC · as of May 2026 SwiftPM import -

    use Firebase from Kotlin/Native Kotlin logger/iosMain/Log.ios.kt import swiftPMImport.logger.FIRCrashlytics actual fun recordException(t: Throwable) { FIRCrashlytics.crashlytics() .recordError(t.toNSError()) }
  35. Kotlin 2.4.0RC · as of May 2026 But it's still

    Experimental ⚠ Experimental status JetBrains is collecting feedback in KotlinLang #kmp-swift-package-manager Slack channel. Not ready for critical production paths yet. ⚙ isStatic = true recommended Dynamic Kotlin/Native frameworks may hit linker errors or runtime symbol collisions with SwiftPM imports. 🚧 Round-trip not supported yet If your KMP module uses SwiftPM import, you can't yet re-export that module as a Swift package. Ref: kotlinlang.org/docs/multiplatform/multiplatform-spm-import.html
  36. Pitfall #1 Main Thread Guarantee Firebase callbacks → main thread

    (safe). But: CLLocationManager · CBCentralManager · URLSession · and more → background threads! Swift Kotlin DispatchQueue.main.async { fun updateFromBackground(code: Long) { SharedViewControllers.shared CoroutineScope(Dispatchers.Main).launch { .updateRequiredAppVersionCode (code: code) state.update { } it.copy(requiredAppVersionCode = code) } } }
  37. Pitfall #2 Type Mapping Traps Swift // Remember from Case

    Study 1: // Swift Int != Kotlin Long func updateRequiredAppVersionCode(code: Int64) { SharedViewControllers() .updateRequiredAppVersionCode(code: code) } Kotlin Swift Trap Long Int64 Swift Int ≠ Kotlin Long Int Int32 Swift Int is 64-bit! Long? NSNumber? Boxed, loses type info Throwable KotlinThrowable Not NSError!
  38. Interop Pitfalls Cheat Sheet Pitfall Symptom Fix BG thread →

    StateFlow UI warning, race condition DispatchQueue.main.async Int vs Int32/Int64 Compiler error / truncation Explicit Int32 / Int64 Long?  NSNumber? Wrong value, type confusion use .int64Value, not .intValue Throwable ≠ NSError Crash or unhandled exception Manual .toNSError() converter NSError → any Error Protocol stub mismatch Use Xcode auto-generated stub ARC deallocates bridge Crash, no stack trace StateObject or strong ref 🔗 Full reference: github.com/kotlin-hands-on/kotlin-swift-interopedia
  39. Wrap-up: What we learned in 1 year What we covered

    1. Native State Observation StateFlow + ComposeUIViewController to bridge native events into shared Compose UI 2. Integrating Non-KMP SDKs One framework, one binary, one singleton IoC, framework export) 3. Dependency Management CocoaPods today, SwiftPM import tomorrow Kotlin 2.4.0 One Year In 100% Compose UI · 98.2% Kotlin KMP on iOS works in production. 🎉