Share
Embed
I didn’t know my Android native app could run on iOS - Compose Multiplatform in action!
What if you, as an Android developer, could suddenly develop for iOS without learning a new language or framework? This isn't a fantasy; it's the reality made possible by Compose Multiplatform.
Even libraries designed exclusively for Android, such as androidx.lifecycle, now work on iOS. Often right out of the box!
During this talk, we will explore the basics of Kotlin Multiplatform (KMP) and Compose Multiplatform (CMP). Then, we will take a real native Android application, built entirely with Kotlin and Jetpack Compose, and methodically migrate it to run on iOS, changing only a few lines of code.
Fabio Catinella
November 02, 2025
Transcript
98 slides
-
Fabio Catinella Senior Android Developer @ I didn’t know my
Android native app could run on iOS Compose Multipla tf orm in action! -
Agenda • Kotlin beyond Android • What is a CMP?
• CMP App Requirements • Let’s migrate our app • Conclusions -
Disclaimer I’m not an iOS Developer so, I might say
something incorrect about it. Please take my words with a grain of salt. -
Kotlin beyond Android Source: h tt ps://kotlinlang.org/ When speaking about
Kotlin, people usually think to Mobile development, in pa rt icular to Android Development. -
Kotlin beyond Android Source: h tt ps://kotlinlang.org/ But Kotlin doesn’t
limit to Android. It can be used to develop many other products like, websites or backends. It can also used to call native like it is usually done with C or C++ -
Kotlin beyond Android Source: h tt ps://www.jetbrains.com/kotlin-multipla tf orm/ Thanks
to its interoperability, though. Kotlin made a step fu rt her with the technology called Kotlin Multi Pla tf orm (KMP) that allows code sharing between di ff erent pla tf orms. -
Kotlin beyond Android What makes KMP so special it is
the freedom it leaves to developers to decide how much code to shared maintaining it native. Source: h tt ps://www.jetbrains.com/kotlin-multipla tf orm/ UI Logic -
Kotlin beyond Android You can share an isolated and critical
pa rt of the app. Source: h tt ps://www.jetbrains.com/kotlin-multipla tf orm/ UI Logic -
Kotlin beyond Android Or you can share the entire Business
logic of your app while keeping the UI Native. Source: h tt ps://www.jetbrains.com/kotlin-multipla tf orm/ UI Logic -
Kotlin beyond Android Source: h tt ps://android-developers.googleblog.com/2024/05/android-suppo rt -for-kotlin-multipla tf
orm-to-share-business-logic-across-mobile- web-server-desktop.html -
Kotlin beyond Android Source: h tt ps://developer.android.com/kotlin/multipla tf orm A
lot of Jetpack libraries have already been migrated to take advantage of KMP. • Lifecycle • Datastore • Room • SavedState • SQLite • And many others… -
Compose Multiplatform An open-source, declarative framework for sharing stunning UIs
across multiple pla tf orms. -
Compose Multiplatform on Android On Android, CMP relies on Jetpack
Compose, so it is suppo rt ed out of the box without (almost) any changes by the developer. -
Compose Multiplatform on iOS On iOS instead, Compose Multipla tf
orm uses a canvas-based rendering without compromising native feeling. -
Compose Multiplatform on iOS • Scrolling behavior that matches native
iOS physics. • Text editing with native selection and RTL suppo rt • Drag-and-drop integration with the system • Adaptive UI that respects system se tt ing like font size and contrast • Navigation gestures that feel natural to iOS users. Source: h tt ps://blog.jetbrains.com/kotlin/2025/05/compose-multipla tf orm-1-8-0-released-compose-multipla tf orm-for-ios-is-stable-and-production-ready/ -
Compose Multiplatform on iOS Source: h tt ps://blog.jetbrains.com/kotlin/2025/05/compose-multipla tf orm-1-8-0-released-compose-multipla
tf orm-for-ios-is-stable-and-production-ready/ Compose Multipla tf orm is designed for Host Interoperability: It can be embedded as a single View (UIView/Swi ft UI) within a pre-existing native application. -
Compose Multiplatform on iOS Source: h tt ps://blog.jetbrains.com/kotlin/2025/05/compose-multipla tf orm-1-8-0-released-compose-multipla
tf orm-for-ios-is-stable-and-production-ready/ -
Liquid Glass Source: h tt ps://medium.com/mateedevs/liquid-glass-components-in-compose-multipla tf orm-71b7a9 ff c56d
Due to the way the components in liquid glass are measured by CMP. They are not quite usable at the moment. -
TheGameDB App It’s just a simple app that uses the
IGDB APIs to display game info. -
Compose Multiplatform App Ingredients • Using Compose for the UI
• App fully wri tt en in Kotlin (libraries included) ✅ -
Kotlin only libraries Dagger / Hilt If you are an
Android developer, it is very likely that you use these libraries: Retro fi t -
Kotlin only libraries You can use these alternative libraries 100%
Kotlin inse rt -koin.io ktor.io Ktor -
Compose Multiplatform App Ingredients • Using Compose for the UI
• App fully wri tt en in Kotlin (libraries included) ✅ ✅ -
Start migration To begin migrating a native application, the fi
rst step is to create a new Compose Multipla tf orm (CMP) project, which can be easily and e ff ectively done using the KMP wizard. Source: h tt ps://kmp.jetbrains.com/ -
Start migration Thanks to the new KMP Plugin included in
Android Studio (2025.1.1) Narwal, it is possible to create a KMP app directly also from the integrated wizard. Source: h tt ps://blog.jetbrains.com/kotlin/2025/05/kotlin-multipla tf orm-tooling-now-in-intellij-idea-and-android-studio/ -
Source Set “A Kotlin source set is a set of
source fi les with its own targets, dependencies, and compiler options. It's the main way to share code in multipla tf orm projects.” Source: h tt ps://www.jetbrains.com/help/kotlin-multipla tf orm-dev/multipla tf orm-discover-project.html -
Source Sets Source: h tt ps://www.jetbrains.com/help/kotlin-multipla tf orm-dev/multipla tf orm-discover-project.html#source-sets
Android androidMain iOS iosMain ? CommonMain -
Source Sets Source: h tt ps://www.jetbrains.com/help/kotlin-multipla tf orm-dev/multipla tf orm-discover-project.html#source-sets
iOS iosMain iOS can run on devices with di ff erent CPU architectures. For example: iPhones and iPads use a Arm64 CPUs, while old Macs* still use X86(_64) CPUs. *Macs with Apple Silicon use the same Arm64 architecture as iPhones and iPads, so it could be possible avoiding to have the iosX86 target. -
Source Sets Source: h tt ps://www.jetbrains.com/help/kotlin-multipla tf orm-dev/multipla tf orm-discover-project.html#source-sets
iosArm64 iosX86 iosMain iOS can run on devices with di ff erent CPU architectures. For example: iPhones and iPads use a Arm64 CPUs, while old Macs* still use X86(_64) CPUs. *Macs with Apple Silicon use the same Arm64 architecture as iPhones and iPads, so it could be possible avoiding to have the iosX86 target. -
Intermediate Source Sets Intermediate source sets act as a parent
for a group of pla tf orm-speci fi c source sets. For example, if you're targeting Android, iOS, and desktop, you might want to share code between just the iOS targets ( iosArm64 and iosX86). Instead of duplicating this code, you create an intermediate source set, typically named iosMain. Source: h tt ps://www.jetbrains.com/help/kotlin-multipla tf orm-dev/multipla tf orm-discover-project.html#source-sets iosArm64 iosX86 iosMain -
CommonMain CommonMain is a source set that contains all the
code shared across all the di ff erent pla tf orms you're targeting. This can include core business logic, data models, and any other code that doesn't depend on a speci fi c pla tf orm's APIs Source: h tt ps://www.jetbrains.com/help/kotlin-multipla tf orm-dev/multipla tf orm-discover-project.html#source-sets iosArm64 iosX86 Android -
Migration goal To successfully migrate our app, the goal would
be to move as much code as possible from the old Android project to the new source set common main Android Project commonMain -
App Scheme These are the main folders, and main layers
of my app. data domain ui -
Why this error? OKh tt p exists only on one
of the two targets of our project. (Android) -
Actual e Expected function Source: h tt ps://www.jetbrains.com/help/kotlin-multipla tf orm-dev/multipla
tf orm-expect-actual.html “Expected and actual function allow you to access pla tf orm-speci fi c APIs from Kotlin Multipla tf orm modules. You can provide pla tf orm-agnostic APIs in the common code.” expect fun getHttpClientEngine(): HttpClientEngine -
Actual e Expected function Source: h tt ps://www.jetbrains.com/help/kotlin-multipla tf orm-dev/multipla
tf orm-expect-actual.html “Expected and actual function allow you to access pla tf orm-speci fi c APIs from Kotlin Multipla tf orm modules. You can provide pla tf orm-agnostic APIs in the common code.” actual fun getHttpClientEngine() = OkHttp.create() -
Dependencies and source sets Source: h tt ps://www.jetbrains.com/help/kotlin-multipla tf orm-dev/multipla
tf orm-add-dependencies.html Every source set can have its own exclusive dependencies. sourceSets { iosMain.dependencies { ... } androidMain.dependencies { ... } commonMain.dependencies { ... } commonTest.dependencies { ... } } -
DarwinClient Apple devices use Darwin as H tt pClient. Ktor
o ff ers a Kotlin/Native client engine for them. To use it, we just need to add its dependency in the right source set sourceSets { iosMain.dependencies { implementation(libs.ktor.client.darwin) } ... } -
Source: h tt ps://www.jetbrains.com/help/kotlin-multipla tf orm-dev/multipla tf orm-expect-actual.html actual
fun getHttpClientEngine() = Darwin.create() -
fun buildSimpleClient(): HttpClient { return HttpClient(getHttpClientEngine()).config { install(ContentNegotiation) { json(
Json { isLenient = true ignoreUnknownKeys = true } ) } install(RateLimitPlugin) } } expect fun getHttpClientEngine(): HttpClientEngine -
Second error -> TokenManager TokenManager implementation is dependent on LocalStorage.
interface TokenManager { fun getAccessToken(): String? fun saveAccessToken(value: String) } class TokenManagerImpl( private val localStorage: LocalStorage ) : TokenManager { override fun getAccessToken(): String? = localStorage.getString(ACCESS_TOKEN_KEY) override fun saveAccessToken(value: String) = localStorage.putString(key = ACCESS_TOKEN_KEY, value = value) } -
Second error -> TokenManager Diving in inside the LocalStorage implementation
we found out it is dependent on SharedPreferences which is an Android-only concept. class LocalStorageImpl( context: Context, private val cipher: SimpleCipher, ) : LocalStorage { ... private val sharedPreferences = context.getSharedPreferences(LOCAL_STORAGE, Context.MODE_PRIVATE) ... } -
KMP gives us access to Native API in the iOS
Module This is how we can implement an iOS speci fi c LocalStorage implementation import platform.Foundation.NSUserDefaults class iOSLocalStorage() : LocalStorage { override fun getString(key: String): String? = NSUserDefaults.standardUserDefaults.stringForKey(key) override fun putString(key: String, value: String) = NSUserDefaults.standardUserDefaults.setObject(value = value, forKey = key) override fun delete(key: String) = NSUserDefaults.standardUserDefaults.removeObjectForKey(key) } -
Resources in CMP Source: h tt ps://www.jetbrains.com/help/kotlin-multipla tf orm-dev/compose-multipla tf
orm-resources.html Compose Multipla tf orm provides a special compose-multiplatform- resources library and Gradle plugin suppo rt for accessing resources in common code across all suppo rt ed pla tf orms. -
Resources in CMP Source: h tt ps://www.jetbrains.com/help/kotlin-multipla tf orm-dev/compose-multipla tf
orm-resources.html sourceSets { ... commonMain.dependencies { implementation(compose.components.resources) } ... } -
Resources in CMP Source: h tt ps://www.jetbrains.com/help/kotlin-multipla tf orm-dev/compose-multipla tf
orm-resources.html Create a new directory composeResources in the source set directory you want to add the resources to and use it like you would on Android. -
Resources in CMP Source: h tt ps://www.jetbrains.com/help/kotlin-multipla tf orm-dev/compose-multipla tf
orm-resources.html In Android, resources are identi fi ed by the generated R class, and each resource corresponds to an integer ID. In CMP, resources have a similar but slightly di ff erent approach. The Res class is used, but instead of indexing an integer, it indexes a Resources object. -
Components @Composable fun GameCover( game: Game, modifier: Modifier = Modifier,
contentScale: ContentScale = ContentScale.FillHeight ) { GameDbImage( model = game.coverUrl ?: "", contentScale = contentScale, modifier = modifier .clip( RoundedCornerShape(4.dp) ), isCrossFadeEnable = false, previewResourceId = R.drawable.preview_cover_image ) } (Jetpack Compose) -
Components @Composable fun GameCover( game: Game, modifier: Modifier = Modifier,
contentScale: ContentScale = ContentScale.FillHeight ) { GameDbImage( model = game.coverUrl ?: "", contentScale = contentScale, modifier = modifier .clip( RoundedCornerShape(4.dp) ), isCrossFadeEnable = false, previewResourceId = Res.drawable.preview_cover_image ) } (Compose Multiplatform) -
Previews (Compose Multiplatform) @Preview(showBackground = true) @Composable private fun ActionButtonPreview()
{ TheGameDbTheme { ActionButton(text = "Test", onClick = {}) } } -
Navigation To handle navigation in my native app I chose
an handy library called Compose Destination by Rafael Costa which relies a lot on KSP. Unfo rt unately (although it may be in the future) it’s not KMP ready. I had to go back to standard navigation using the Jetpack Compose Navigation library -
Navigation org.jetbrains.androidx.navigation:navigation-compose NavHost(navController = navController, startDestination = Routes.Home) { composable<Routes.Home>
{ HomeRoute( navigator = navController ) } composable<Routes.Detail>( deepLinks = listOf( navDeepLink { uriPattern = "app://gamedb.fabiocati.it/{gameId}" }, ) ) { val route: Routes.Detail = it.toRoute() GameDetailsRoute( gameId = route.gameId, navigator = navController ) } } -
Navigation org.jetbrains.androidx.navigation:navigation-compose object Routes { @Serializable data object Home @Serializable
data class Detail(val gameId: String) } -
Navigation org.jetbrains.androidx.navigation:navigation-compose NavHost(navController = navController, startDestination = Routes.Home) { composable<Routes.Home>
{ HomeRoute( navigator = navController ) } composable<Routes.Detail>( deepLinks = listOf( navDeepLink { uriPattern = "app://gamedb.fabiocati.it/{gameId}" }, ) ) { val route: Routes.Detail = it.toRoute() GameDetailsRoute( gameId = route.gameId, navigator = navController ) } } -
Navigation org.jetbrains.androidx.navigation:navigation-compose NavHost(navController = navController, startDestination = Routes.Home) { composable<Routes.Home>
{ HomeRoute( navigator = navController ) } composable<Routes.Detail>( deepLinks = listOf( navDeepLink<Routes.Detail>( basePath = "app://gamedb.fabiocati.it" ) ) ) { val route: Routes.Detail = it.toRoute() GameDetailsRoute( gameId = route.gameId, navigator = navController ) } } -
Deeplinking <intent-filter> <action android:name="android.intent.action.VIEW" /> <category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.BROWSABLE"
/> <data android:host="gamedb.fabiocati.it" android:scheme="app" /> </intent-filter> -
Deeplinking On Android, the deep link URIs sent to the
app are available as a pa rt of the Intent that triggered the deep link. A cross-pla tf orm implementation needs a universal way to listen for deep links. This can be achieved by using a Singleton declared in the commonMain source set. When the ComposeApp sta rt s it will check the data saved inside that singleton and will navigate to the right page. Source: h tt ps://www.jetbrains.com/help/kotlin-multipla tf orm-dev/compose-navigation-deep-links.html -
Deeplinking object ExternalUriHandler { private var cached: String? = null
var listener: ((uri: String) -> Unit)? = null set(value) { field = value if (value != null) { cached?.let { value.invoke(it) } cached = null } } fun onNewUri(uri: String) { cached = uri listener?.let { it.invoke(uri) cached = null } } } Source: h tt ps://www.jetbrains.com/help/kotlin-multipla tf orm-dev/compose-navigation-deep-links.html -
Deeplinking val navController = rememberNavController() DisposableEffect(Unit) { ExternalUriHandler.listener = {
uri -> navController.navigate(NavUri(uri)) } onDispose { ExternalUriHandler.listener = null } } TheGameDbTheme { NavHost(navController = navController, startDestination = Routes.Home{ ... } } Source: h tt ps://www.jetbrains.com/help/kotlin-multipla tf orm-dev/compose-navigation-deep-links.html -
Deeplinking struct ContentView: View { var body: some View {
ComposeView() .ignoresSafeArea() // Compose has own keyboard handler .onOpenURL(perform: { incomingURL in handleIncomingURL(incomingURL) }) } } private func handleIncomingURL( _ uri: URL){ ExternalUriHandler.shared.onNewUri(uri: uri.absoluteString) } Source: h tt ps://www.jetbrains.com/help/kotlin-multipla tf orm-dev/compose-navigation-deep-links.html -
Conclusions • Kotlin is a Polyglot: It is a fi
rst-class language for Android, Backend, Desktop, and Web. • KMP's Power: You control the sharing—from logic to the entire UI. • Actual and Expected functions permit a inte rf ace-like approach for speci fi c pla tf orm code. • Resources in Compose Multi Pla tf orm work almost like in Android. • A full Compose Android app is probably multipla tf orm-ready. • Handling DeepLinking on iOS can be tricky.