Hands-on experience I’ve gained while developing Compose Multiplatform over the past two years.

As an Android Developer, among the various Multiplatform options (Flutter, RN, etc.), Compose Multiplatform seemed to be the best. Since I already know how to write declarative Compose UIs, it may take almost zero effort to build an iOS app as an Android Developer. But the reality was quite different in specific ways 😂.

From the official Compose Multiplatform website

 

The initial stage is fantastic. You write your shared UI, run it on an iOS simulator for the first time, and it just works! However, suddenly you’re faced with questions that the official documentation doesn’t seem to answer. How do we integrate a third-party library like Firebase that doesn’t support KMP? What’s the right way to handle a platform-specific API that’s deeply tied to the Android Context? When is it better to throw away the shared components and use a native SwiftUI view instead?

expect/actual can‘t handle everything

From the official Compose Multiplatform document — Platform-specific APIs

 

We commonly use expect/actual declarations to write platform-specific APIs on each platform.

For example, in iOS, to print log in console, we use platform.Foundation.NSLog function, while in Android, we use android.util.Log . An example will look something like this:

// commonMain
expect object Log {
fun e(tag: String, message: String, throwable: Throwable? = null)
fun d(tag: String, message: String)
fun i(tag: String, message: String)
}

// androidMain
actual object Log {
actual fun e(tag: String, message: String, throwable: Throwable?) {
Log.e(tag, message, throwable)
}
// ...
}

// iosMain
actual object Log {
actual fun e(tag: String, message: String, throwable: Throwable?) {
if (throwable != null) {
NSLog("ERROR: [$tag] $message. Throwable: $throwable CAUSE ${throwable.cause}")
} else {
NSLog("ERROR: [$tag] $message")
}
}
// ...
}

 

This is a top-down approach where the shared code is in control. However, many real-world scenarios require the opposite. Below are the real-world scenarios I’ve encountered.

1. Native UI Events (e.g., Push Notifications)

A push notification is delivered to the device. When the user taps it, the application needs to trigger business logic located in the shared module, such as navigating to a specific screen. The OS notifies the native application, and this event must then flow up to the shared logic.

2. Asynchronous Platform Logic (e.g., Force Update Check)

Your application needs to check the App Store or Play Store to see if a mandatory update is available. This involves an asynchronous network call that is handled differently on each platform. The platform code needs to perform this work in the background and deliver the result to commonMain via a callback or listener once it’s ready. A simple function call from commonMain cannot accommodate this asynchronous, delayed response.

3. Integrating Third-Party Native SDKs (e.g., Firebase Remote Config)

You are using the native Firebase SDK to fetch remote config states. When the state changes, your shared UI must be recomposed to reflect this change. Your code does not request Firebase’s state; instead, the Firebase SDK notifies your code when the state changes. Our shared code must act as a passive listener, not an active caller.

Observer Pattern from refactoring.guru

 

And so on. The solution is above expect/actual, where the shared module defines a listener interface that the platform-specific code can use to send events back to the shared logic.

Case Study 1: Implementing Native UI State Observer

You cannot directly observe a SwiftUI view state from pure Kotlin code. This means that if a Kotlin module has to collect SwiftUI view state, a bridge is required to translate the state from native UI to a Kotlin observable type, such as StateFlow.

Suppose we need to collect the iOS app required version to implement the forced update feature.

First, we create a singleton object SharedViewControllers that manages the state and exposes the UI.

// composeApp/iosMain
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)
}
}

 

  1. StateFlow acts as the single source of truth for the UI state, managed entirely within Kotlin.
  2. mainViewController() is the entry point for Swift, returning a standard UIViewController.
  3. collectAsStateWithLifecycle() bridges the StateFlow to Compose’s state system, ensuring the UI always reflects the latest data.

Connecting this bridge to SwiftUI is simple using UIViewControllerRepresentable which acts as a host for our UIKit controller.

// iosApp/ContentView.swift
import SwiftUI
import ComposeApp // Import your KMP shared module if needed

struct ComposeView: UIViewControllerRepresentable {
func makeUIViewController(context: Context) -> UIViewController {
// Call the bridge to get our Compose-powered UI
return SharedViewControllers().mainViewController(onFinish: { ... })
}

func updateRequiredAppVersionCode(code: Int64) {
SharedViewControllers().updateRequiredAppVersionCode(code: requiredAppVersionCode)
}
// ...
}

 

Now if updateRequiredAppVersionCode() triggers in SwiftUI, it updates the StateFlow in the Kotlin code, then triggers the UI recomposition.

Case Study 2: Integrating Third-Party Native SDKs (such as Firebase SDK)

Maintaining the mobile application sometimes requires implementing essential third-party libraries or SDKs, such as Firebase, for analytics and crash reporting. However, official KMP support for Firebase SDKs is not yet available.

Suppose we have a Logger interface like the one below, and each Logger must integrate a Firebase instance for more detailed error state logging.

Note: For more information about low-level shared module, please refer to more – https://www.jetbrains.com/help/kotlin-multiplatform-dev/multiplatform-project-configuration.html#module-configurations)

The Problem: Native SDKs and Their Dependencies

Libraries like Firebase Analytics and Crashlytics are not simple functions you can call from commonMain. They are native libraries, written in Swift/Objective-C for iOS and Java/Kotlin for Android. They require platform-specific initialization at the application’s entry point, and should depend on the application’s lifecycle and context, which commonMain do not have access to.

The Android implementation is easy, just reference the firebase.crashlytics.ktx module in androidMain directory.

// shared/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?) {
Log.e(tag, message, throwable)

Firebase.crashlytics.recordException(throwable ?: Exception(message))
}
// ...
}

But to reference the Firebase instance in iosMain, A simple expect/actual declaration is not enough here. We need to inject dependencies. (The following instructions are less complicated with dependency injection tools like Koin)

  1. Define an interface in commonMain or shared module: create a simple Kotlin interface that defines only the functions that the shared code needs.
  2. Implement the Contract Natively: In the androidMain and iosMain source sets (or in native Swift and Kotlin code), create classes that implement this interface by wrapping the actual Firebase SDK in their native code.
  3. Inject the Implementation: At application startup, the native code (e.g., AppDelegate on iOS) creates an instance of its implementation and passes it to a central initializer in the shared module. The shared module then holds a reference to this implementation and uses it throughout the app.

 

// shared/iosMain/FirebaseCrashlyticsDelegate.kt
interface FirebaseCrashlyticsDelegate {
fun recordException(error: NSError)
}

// shared/iosMain/Log.ios.kt
actual object Log {

private var crashlyticsDelegate: FirebaseCrashlyticsDelegate? = null

fun initCrashlyticsDelegate(delegate: FirebaseCrashlyticsDelegate) {
crashlyticsDelegate = delegate
}
// ...
}
// iosApp/DefaultFirebaseCrashlyticsDelegate.swift
class DefaultFirebaseCrashlyticsDelegate: FirebaseCrashlyticsDelegate {

func recordException(error: Error) {
Crashlytics.crashlytics().record(error: error)
}
}

 

// iosApp/iOSApp.swift
class AppDelegate: NSObject, UIApplicationDelegate {
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
FirebaseApp.configure()
Log.shared.doInitCrashlyticsDelegate(delegate: DefaultFirebaseCrashlyticsDelegate())
}
// ...
}

 

Note: The shared keyword above indicates that a Kotlin singleton is represented in Swift/Objective-C as a class with a single instance. For more information, please refer to https://kotlinlang.org/docs/native-objc-interop.html.

The result shows the implementation of IoC, Inversion of Control. The standard module dependencies flow downwards: iosApp depends on composeApp. However, the dependency injection for our services flows in the opposite direction. The iosApp creates the FirebaseAnalyticsLogger and injects it up into the composeApp module.

Separating the low-level shared module and the higher-level composeApp module that contains our UIs and ViewModels, the dependency graph would look something like this:

Implementation of IoC(Inversion of Control)

Struggle point from the low-level shared module

However, the attempt to pass the delegate from iosApp into the shared module and then use it from composeApp, somehow failed.

It seemed that the shared and composeApp modules — both compiled into separate frameworks (.xcframework) for iOS — were operating in different memory areas. While I couldn’t find explicit documentation on this behavior, when I passed the delegate object and logged its hash code, I observed different values at different layers of our application.

// A function in our `shared` module to receive the delegate
fun initCrashlyticsDelegate(delegate: FirebaseCrashlyticsDelegate) {
crashlyticsDelegate = delegate
// When called from iosApp, this prints one hash code
NSLog("Delegate hash in 'shared': ${delegate.hashCode()}")
}

// Later, when accessing this delegate from a ViewModel in `composeApp`...
// This would print a DIFFERENT hash code, implying it's not the same instance.
NSLog("Delegate hash in 'composeApp': ${crashlyticsDelegate.hashCode()}")

 

The delegate instance we were initializing in shared was not the same instance our composeApp module was seeing. So the Log interface was moved from the shared module to the composeApp module. The iosApp now provides its delegate implementation directly to composeApp. While this means our UI, ViewModel logic is now more coupled to the delegation logic.

So be mindful of singleton objects when passing delegates across multiple KMP framework boundaries on iOS. If you have another good approach for the architecture, feel free to comment and discuss!

So, What is the Best Solution for Firebase KMP?

What we really need is official Kotlin Multiplatform Mobile (KMP) support directly from the Firebase team. This is a feature the community is eagerly requesting, as can be seen in the official Firebase poll.

Suppose you don’t want to wait, why not try firebase-kotlin-sdk from GitLiveApp. This library provides a standard Kotlin API for Firebase. It’s a popular choice for KMP projects, though I couldn’t use it due to the lack of some Firebase features.

Insights from KotlinConf 2025, Talking with JetBrains developers

Selfie with Sebastian Sellmair and Manuel Vivo!

This May, I attended KotlinConf with a clear goal — to get direct feedback on my KMP architecture. Given the lack of established best practices and in-depth architectural discussions within the Compose Multiplatform community, my primary goal was to gather direct feedback from the developers who build it — the developers at JetBrains.

So I visited the JetBrains booth, and my first conversation with a JetBrains developer was very helpful. He gave me a link to official documentation for handling third-party C libraries, explaining the standard C-interop approach.

While we were discussing, some other JetBrains developers joined us and explained that the Firebase SDK could not follow that approach. Then they lightly reviewed my current architecture — defining an interface in commonMain and injecting a native, platform-specific implementation. To my relief, they confirmed it was the best practical solution available today. “I can’t think of a better way to do it right now,” someone said. Then they concluded with a laugh, telling me that my next step should be to visit the Google booth and “pester the Firebase team to support KMP officially” (haha 🤣).

This experience was incredibly fun and reassuring. We are still in a phase where the community is developing best practices.

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

,

When sharing isn’t caring: Using platform-specific code in Kotlin Multiplatform

Sharing code across platforms is a wonderful superpower. But sometimes, sharing 100% of your codebase isn’t the goal. Maybe you’re migrating existing apps to multiplatform, maybe you have platform-specific libraries or APIs you want to…
Watch Video

When sharing isn’t caring: Using platform-specific code in Kotlin Multiplatform

Russell Wolf
Kotlin Multiplatform Developer

When sharing isn’t caring: Using platform-specific code in Kotlin Multiplatform

Russell Wolf
Kotlin Multiplatform ...

When sharing isn’t caring: Using platform-specific code in Kotlin Multiplatform

Russell Wolf
Kotlin Multiplatform Deve ...

Jobs

Conclusion

Kotlin Multiplatform provides APIs for interoperability and a shared codebase, but as we’ve seen through these case studies, sometimes we need to write native code on multiplatform apps.

Here are the main subjects we followed:

  • While expect/actual is excellent for simple APIs, defining an interface in commonMain and injecting a native implementation is needed for complex features and third-party SDKs.
  • For iOS, your iosMain source set could be used for bridging shared state between.
  • The KMP community is still growing, and developers could easily share opinions and discuss with others.

While libraries like Firebase still await official KMP support, the tools and patterns available today are still capable of building production-ready applications. I truly believe that the ability to share complex business logic, state management, and UI across both Android and iOS from a single codebase is a superpower.

References

Special thanks to the organizers from the JetBrains booth! 😺

This article was previously published on proandroiddev.com

Menu