Kotlin MultiPlatform (KMP)

Zahra Heydari
9 min readJun 5, 2024

--

This is a step-by-step guide to creating your first cross-platform application that works on Android and iOS platforms.

Kotlin Multiplatform technology simplifies the development of cross-platform projects and one of its major use cases is sharing the application logic code between platforms.

Note: This article is based on the sample project I implemented and published as a public repository on my GitHub. Please check the link below before starting the implementation.

https://github.com/ZahraHeydari/Kotlin-MultiPlatform-Mobile

What we will learn in this article:

  1. Setting Up The Environment
  2. Creating a basic KMM Project
  3. Understanding the basics of the KMM project structure
  4. ViewModel
  5. Networking
  6. Coroutines in Ios
  7. Dependency Injection

Note: Before starting, you have to set up an environment for cross-platform development. But if you did it before, you can skip this step!

1. Setting up the environment

Install the necessary tools:

To ensure everything works as expected, install and run the KDoctor tool.

brew install kdoctor

Note: KDoctor works on macOS only.

2. Create a basic KMM project

Open Android Studio and then select Kotlin Multiplatform App from the New Project templates. Click the Next button.

On the next screen, choose a name for the application and the location for saving the project, etc. Click the Next button then.

Finally, select the dependency manager for the iOS app, which isRegular framework by default, and then hit the Finish.

Once gradle sync is complete, we can run both iOS and Android apps using the run button from the toolbar.

That will run the app on the Android emulator or iOS simulator.

3. Understanding the basics of the KMM project structure

Each Kotlin Multiplatform project includes three modules shared, androidApp, and iosApp.

  • shared is a Kotlin module that contains the logic common for both Android and iOS applications — the code you share between platforms. It uses Gradle as the build system to help automate your build process.
  • androidApp is a Kotlin module that builds into an Android application. It uses Gradle as the build system. The androidApp module depends on and uses the shared module as a regular Android library.
  • iosApp is an Xcode project that builds into an iOS application It depends on and uses the shared module as an iOS framework. The shared module can be used as a regular framework or as a CocoaPods dependency.

💡 By default, the Kotlin Multiplatform wizard creates projects that use the regular framework dependency.

Expected and actual Keywords

Expected and actual declarations allow you to access platform-specific APIs from Kotlin Multiplatform modules. You can provide platform-agnostic APIs in the common code.

To define expected and actual declarations, follow these rules:

  1. In the common source set, declare a standard Kotlin construct. This can be a function, property, class, interface, enumeration, or annotation.

2. Mark this construct with the expect keyword. This is your expected declaration. These declarations can be used in the common code, but shouldn't include any implementation. Instead, the platform-specific code provides this implementation.

3. In each platform-specific source set, declare the same construct in the same package and mark it with the actual keyword. This is your actual declaration, which typically contains an implementation using platform-specific libraries.

💡 During compilation for a specific target, the compiler tries to match each actual declaration it finds with the corresponding expected declaration in the common code.

The expected declaration in the common code
The actual declarations in Android and iOS platforms

Common code

Common code is the Kotlin code shared among different platforms.

Consider the simple example:

class Greeting {
private val platform: Platform = getPlatform()

fun greet(): String {
return "Hello, ${platform.name}!"
}
}
interface Platform {
val name: String
}

expect fun getPlatform(): Platform

Share code on all platforms

If you have business logic that is common for all platforms, you don’t need to write the same code for each platform — just share it in the common source set.

You often need to create several native targets that could potentially reuse a lot of the common logic and third-party APIs.

Now it’s time to add viewModel to our KMM project!

4. ViewModel in a KMP project

ViewModel is a class that is responsible for preparing and managing the data for an Activity or a Fragment.

In this sample project, EmojiHubViewModel is used to prepare and manage emoji data and expose it to the UI and SharedViewModel is a base for multiple viewmodels.

Both SharedViewModel and EmojiHubViewModel are in the commonMain module.

SharedViewModel and EmojiHubViewModel classes in the commonMain
// shared/commonMain/.../viewModel/SharedViewModel
expect open class SharedViewModel() {

val sharedViewModelScope: CoroutineScope

protected open fun onCleared()
}

In the androidMain and iosMain source sets, mark the same construct with the actual keyword. This actual declaration typically contains an implementation using platform-specific libraries.

Notice that the EmojiHubViewModel declaration is in the shared common code and can be used in both iOS and Android platforms.

class EmojiHubViewModel(private val repository :EmojiHubRepository) : SharedViewModel() {

...

init {
sharedViewModelScope.launch {
...
}
}
}

ViewModel in the Android platform

SharedViewModel in the androidMain
// shared/androidMain/.../viewModel/SharedViewModel
actual open class SharedViewModel: ViewModel() {

actual val sharedViewModelScope: CoroutineScope = this.viewModelScope

actual override fun onCleared() {
super.onCleared()
}
}

The snippet code below shows how we use emojiHubViewModel in the EmojiHubScreen.

@Composable
fun EmojiHubScreen() {

val emojiHubViewModel: EmojiHubViewModel = koinViewModel()
val items by emojiHubViewModel.items.collectAsState()

Scaffold(topBar = {
TopAppBar(title = {
Text(text = "EmojiHub")
})
}, content = { paddingValues ->
LazyColumn(modifier = Modifier.padding(paddingValues)) {
items(items.size) { index ->
Item(items[index])
}
}
})
}

I’ll explain about fetching data using ktor and dependency injection using koin in the following in this article! So skip it for now!

ViewModel in the iOS platform

SharedViewModel in the iosMain
// shared/iosMain/.../viewModel/SharedViewModel
actual open class SharedViewModel {

actual val sharedViewModelScope = MainScope()

protected actual open fun onCleared() {}

fun clear() {
onCleared()
}
}

And this is how we use emojiHubViewModel in the ContentView.

struct ContentView: View {

@StateObject
var emojiHubViewModel: EmojiHubViewModel()

var body: some View {
Text(String("EmojiHub"))
List {
ForEach(iOSEmojiHubViewModel.items, id: \.self) { item in
Item(emojiItem: item)
}
}
}
}

5. Networking

Ktor is for anything from microservices to multiplatform HTTP client apps. To use it, add Ktor dependencies to your project in the build.gradle.kts as below.

val koinVersion = "3.4.3"

// shared/build.gradle.kts
sourceSets {

val commonMain by getting {
dependencies {
implementation("io.ktor:ktor-client-core:$ktorVersion")
implementation("io.ktor:ktor-client-logging:$ktorVersion")
implementation("io.ktor:ktor-client-content-negotiation:$ktorVersion")
}
}

val androidMain by getting {
dependsOn(commonMain)
dependencies {
...
implementation("io.ktor:ktor-client-okhttp:$ktorVersion")
}
}

val iosMain by getting {
dependsOn(commonMain)
dependencies {
implementation("io.ktor:ktor-client-darwin:$ktorVersion")
}
}
}

Add the HttpClient file in the commonMain module as below:

expect fun httpClient(config: HttpClientConfig<*>.() -> Unit): HttpClient

Declare httpClient with the actual keyword in the anroidMain:

actual fun httpClient(config: HttpClientConfig<*>.() -> Unit): HttpClient = HttpClient(OkHttp) {
config()
}

And httpClient with the actual keyword in the iosMain:

actual fun httpClient(config: HttpClientConfig<*>.() -> Unit): HttpClient = HttpClient(Darwin) {
config()
}

Now, use httpClient in EmojiHubRepository to fetch emojis from a remote server.

class EmojiHubRepository(private val httpClient: HttpClient) {

suspend fun getEmojis(): List<EmojiItem> {
return try {
httpClient.get(urlString = "/api/all").body()
} catch (e: Exception) {
e.printStackTrace()
emptyList()
}
}
}

And in EmojiHubViewModel, use EmojiHubRepository to get data from remote or local sources.

class EmojiHubViewModel(private val repository :EmojiHubRepository) : SharedViewModel() {

private val _items = MutableStateFlow<List<EmojiItem>>(listOf())

@NativeCoroutinesState
val items = _items.asStateFlow()

init {
sharedViewModelScope.launch {
_items.update {
repository.getEmojis() // get data through repository
}
}
}
}

6. Coroutines in Ios

Add Kotlinx.Serialization to your project.

Kotlin serialization consists of a compiler plugin, that generates visitor code for serializable classes, a runtime library with core serialization API, and support libraries with various serialization formats.

- Supports Kotlin classes marked as @Serializable and standard collections.

- Provides JSON, Protobuf, CBOR, Hocon, and Properties formats.

- Complete multiplatform support: JVM, JS, and Native.

Kotlinx.Serialization is used to serialize and deserialize objects of custom types.

val kotlinxSerializationVersion = "1.5.1"

// shared/build.gradle.kts
sourceSets {

val commonMain by getting {
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlinxSerializationVersion")
...
}
}

val androidMain by getting {
dependsOn(commonMain)
dependencies {
...
}
}

val iosMain by getting {
dependsOn(commonMain)
dependencies {
...
}
}
}

Reconfiguring httpClient

// shared/commonMain/.../EmojiHubRepository
class EmojiHubRepository {
private val httpClient = httpClient {
...
install(ContentNegotiation) {
json(
Json {
ignoreUnknownKeys = true
}
)
}
}
}

Get data from the server and then run the Android app.

suspend fun getEmojis(): List<EmojiItem> {
return try {
httpClient.get(urlString = "/api/all").body()
} catch (e: Exception) {
e.printStackTrace()
emptyList()
}
}
class EmojiHubViewModel(private val repository :EmojiHubRepository) : SharedViewModel() {

private val _items = MutableStateFlow<List<EmojiItem>>(listOf())

@NativeCoroutinesState
val items = _items.asStateFlow()

init {
sharedViewModelScope.launch {
_items.update {
repository.getEmojis()
}
}
}
}

KMP-NativeCoroutines

// commonMain/viewModel/EmojiHubViewModel
class EmojiHubViewModel(private val repository :EmojiHubRepository) : SharedViewModel() {

private val _items = MutableStateFlow<List<EmojiItem>>(listOf())

@NativeCoroutinesState
val items = _items.asStateFlow()

...
}

IOSEmojiHubViewModel is in the iosApp module.

In IOSEmojiHubViewModel, get data using KMPNativeCoroutinesAsync.

@MainActor
class IOSEmojiHubViewModel: ObservableObject {

private let githubViewModel = GithubViewModel()

@Published
var items = Array<EmojiItem>()

var task: Task<(), Never>? = nil

init() {
task = Task {
do {
let asyncItems = asyncSequence(for: emojiHubViewModel.itemsFlow)
for try await asyncItem in asyncItems {
items = asyncItem
}
} catch {
print("Failed with error: \(error)")
}
}
}

func clear() {
task?.cancel()
}
}
struct ContentView: View {

@StateObject
var iOSEmojiHubViewModel = IOSEmojiHubViewModel()

var body: some View {
Text(String("EmojiHub"))
List {
ForEach(iOSEmojiHubViewModel.items, id: \.self) { item in
Item(emojiItem: item)
}
}
}
}

7. Dependency Injection

Add Koin to your project for implementing a dependency injection pattern as below.

💡Koin is a framework to help you build any kind of Kotlin & Kotlin Multiplatform application, from Android mobile, and Multiplatform apps to backend Ktor server applications.

val koinVersion = "3.4.3"

// shared/build.gradle.kts
sourceSets {

val commonMain by getting {
dependencies {
implementation("io.insert-koin:koin-core:$koinVersion")
...
}
}

val androidMain by getting {
dependsOn(commonMain)
dependencies {
implementation("io.insert-koin:koin-android:$koinVersion")
....
}
}

val iosMain by getting {
dependsOn(commonMain)
dependencies {
...
}
}
}

In EmojiHubRepository, inject HttpClient.

class EmojiHubRepository(private val httpClient: HttpClient) {

...
}

And in EmojiHubViewModel, inject EmojiHubRepository.

// commonMain/viewModel/EmojiHubViewModel
class EmojiHubViewModel(private val repository :EmojiHubRepository) : SharedViewModel() {

...
}

Declare a module to define dependencies as below:

val appModule = module {
single {
httpClient {
...
}
}

single {
EmojiHubRepository(get())
}

sharedViewModel {
EmojiHubViewModel(get())
}
}

Add start Koin in your project application class.

//androidApp/MainApplication
class MainApplication : Application() {

override fun onCreate() {
super.onCreate()

startKoin {
androidContext(this@MainApplication)
modules(appModule) // app modules
}
}
}

And start Koin in the iosMain to use it.

// shared/iosMain/.../KoinStarter.kt
fun startKoin() {
startKoin { modules(appModule) }
}
iOSApp.swift in iosApp module

Invoke startKoin in the iOSApp.

@main
struct iOSApp: App {

init() {
KoinStarterKt.startKoin()
}

var body: some Scene {
WindowGroup {
ContentView()
}
}
}

And GithubViewModelHelper is used to inject GithubViewModel in iOS.

class EmojiHubViewModelHelper: KoinComponent {

private val emojiHubViewModel: EmojiHubViewModel = get()

@NativeCoroutinesState
val items = emojiHubViewModel.items
}
IOSEmojiHubViewModel in iosApp module

Now run the androidApp and iOSApp.

Thank you for reading this story. Hope you find it helpful.

I’d really appreciate any feedback, please leave your comments below.

You can find me on Twitter 🤓

Happy coding!

--

--