My First Deep Dive into Kotlin Multiplatform & Compose Multiplatform: Understanding the Architecture Instead of Just Running the App
Over the last few weeks, I’ve been thinking about what technology I should invest my time in next.
As a Senior Android Developer, I evaluated several cross-platform options including Flutter, React Native, Ionic and Kotlin Multiplatform.
One thing became clear quickly: learning Kotlin Multiplatform (KMP) and Compose Multiplatform (CMP) gives me the highest return on my existing Android expertise.
Unlike Flutter, I don’t have to learn a new language.
Unlike React Native, I don’t have to switch to the JavaScript ecosystem.
Instead, I can continue leveraging:
- Kotlin
- Compose
- Coroutines
- Flow
- Android Studio
- Gradle
while gradually expanding into cross-platform development.
Today was my first real deep dive into understanding how a Compose Multiplatform project is actually structured.
Instead of immediately building features, I decided to understand the architecture first.
And that turned out to be one of the best decisions.
Step 1: Running the Same App on Android and Desktop
The first thing I did was run the sample Compose Multiplatform application.
I had already managed to run it as a Desktop application earlier.
Today I successfully ran the same application on my Android device.
At this point I had not changed a single line of code.
The same UI was running on:
- Android
- Desktop
without any platform-specific UI implementation from me.
That immediately raised a question:
How is this actually working?
Step 2: Looking at the Project Structure
The project structure looked something like this:
The first thing that caught my attention was:
shared
├── commonMain
├── androidMain
├── iosMain
└── jvmMain
androidApp
iosApp
desktopAppInitially this confused me.
If there is already:
- androidApp
- iosApp
then why do we need:
- androidMain
- iosMain
inside the shared module?
Wouldn’t the platform-specific code belong inside the platform-specific app modules?
That question eventually led me to one of the biggest KMP concepts.
Step 3: Understanding Source Sets
The key realization was that:
androidApp
iosApp
desktopAppare mostly launcher modules.
Their primary job is to start the application.
The actual application code mostly lives inside:
sharedInside shared, Kotlin organizes code using source sets:
commonMain
Contains code shared by all platforms.
Examples:
- Compose UI
- ViewModels
- Business Logic
- Repositories
- Networking
- State Management
androidMain
Contains Android-specific implementations.
Examples:
- SharedPreferences
- DataStore
- Android Notifications
- CameraX
- Android Permissions
iosMain
Contains iOS-specific implementations.
Examples:
- NSUserDefaults
- Keychain
- CoreLocation
- AVFoundation
jvmMain
Contains Desktop-specific implementations.
This was my first major “aha!” moment.
The platform-specific logic still belongs inside the shared module because it is part of the application’s behavior.
The launcher modules should ideally remain thin.
Step 4: Why Can’t We Put Everything in commonMain?
My next question was:
Why do androidMain and iosMain even exist?
Why not put everything inside commonMain?
The answer is that commonMain can only use APIs that exist on every target.
For example:
Text("Hello")works everywhere.
But:
SharedPreferencesexists only on Android.
And:
NSUserDefaultsexists only on iOS.
Therefore platform-specific implementations must live in their respective source sets.
This is where KMP’s expect/actual mechanism becomes important.
A shared API can be declared in commonMain and implemented differently in androidMain and iosMain.
Step 5: How Does Android Studio Suddenly Understand iOS APIs?
This was another interesting question I had.
Normally Android Studio would complain if I typed:
NSUserDefaultsinside a regular Android project.
So what changed?
The answer lies in Kotlin Multiplatform targets.
Inside the Gradle configuration I found:
iosArm64()
iosSimulatorArm64()These targets tell Kotlin:
Get Neeraja Gandla’s stories in your inbox
Join Medium for free to get updates from this writer.
“Generate iOS builds too.”
Kotlin/Native then exposes Apple frameworks to Kotlin.
As a result:
import platform.Foundation.NSUserDefaultsbecomes available inside iosMain.
Another “aha!” moment:
Android Studio itself hasn’t magically learned iOS.
Kotlin Multiplatform has taught the compiler about multiple target platforms.
Step 6: Reading the Gradle Configuration
I spent significant time understanding the shared module’s build.gradle.kts.
One interesting section was:
listOf(
iosArm64(),
iosSimulatorArm64()
)This creates:
- Real iPhone builds
- iOS Simulator builds
I also noticed:
baseName = "Shared"Initially I thought this was related to the shared module name.
The actual purpose is different.
Kotlin generates:
Shared.frameworkfor iOS.
The generated framework can later be imported from Swift as:
import SharedThat was another useful realization.
The framework name is configurable and doesn’t necessarily have to match the module name.
Step 7: Understanding How Compose UI Reaches Each Platform
I then traced the flow of a composable such as:
App()Android
MainActivity
↓
setContent
↓
App()Desktop
main()
↓
Window
↓
App()iOS
This path was more interesting.
iOSApp.swift
↓
ContentView.swift
↓
ComposeView
↓
MainViewControllerKt.MainViewController()
↓
ComposeUIViewController
↓
App()This was one of the biggest architecture discoveries of the day.
Step 8: Why Is App() Called from iosMain Instead of iosApp?
At first I thought this was inconsistent.
Android calls App() from androidApp.
Desktop calls App() from desktopApp.
But iOS seemed to call App() from iosMain.
After tracing the flow, I realized what’s happening.
Swift cannot directly render a Compose Composable.
A bridge is required:
ComposeUIViewController {
App()
}This bridge is iOS-specific.
Therefore it lives inside:
iosMainSwift simply hands control over to Kotlin.
That design keeps most of the implementation in Kotlin instead of Swift.
Another major “aha!” moment.
Step 9: The Mystery of MainViewControllerKt
One detail caught my attention.
Swift was calling:
MainViewControllerKt.MainViewController()instead of:
MainViewController()Why the extra “Kt”?
The answer is surprisingly simple.
Top-level Kotlin functions are compiled into generated classes.
For a file named:
MainViewController.ktKotlin generates something conceptually similar to:
MainViewControllerKtwhich contains the function as a static method.
This is why Swift sees:
MainViewControllerKt.MainViewController()instead of directly calling the function.
Step 10: Why Android Studio Shows “No Usages”
Another interesting observation:
Android Studio showed no usages for:
fun MainViewController()even though Swift was clearly calling it.
The reason is that IDE usage analysis works best for:
Kotlin → Kotlin
Java → Kotlinbut this call path is:
Swift
↓
Generated Framework
↓
KotlinThe function is definitely used at runtime, but the IDE cannot always trace that usage across language boundaries.
Step 11: Comparing KMP with Other Cross-Platform Frameworks
While learning KMP, I also revisited the broader cross-platform landscape.
Cordova / PhoneGap
Web app inside a WebView.
Ionic
Modern web framework inside a WebView.
React Native
JavaScript bridge talking to native UI.
Flutter
Single Dart codebase rendered through Flutter’s own engine.
Kotlin Multiplatform
Originally focused on:
Shared Logic
Native UICompose Multiplatform
Extends that vision to:
Shared Logic
+
Shared UIFor Android developers, KMP has a unique advantage:
Every hour invested in KMP also strengthens Kotlin and Android expertise.
Final Thoughts
The biggest lesson from today was that Kotlin Multiplatform is not just about sharing code.
It’s about carefully deciding:
- What should be shared
- What should remain platform-specific
- How platform boundaries are managed
- How Kotlin integrates with native ecosystems
The project structure that initially looked confusing now makes much more sense.
Today’s goal wasn’t to build features.
It was to understand the architecture.
And that foundation will make future KMP learning significantly easier.










