Share more logic between iOS and Android
Now that you've implemented common logic using external dependencies, you can start adding more complex logic. Network requests and data serialization are the most popular use cases for sharing code using Kotlin Multiplatform. Learn how to implement these in your first application, so that after completing this onboarding journey you can use them in future projects.
The updated app will retrieve data over the internet from the SpaceX API and display the date of the last successful launch of a SpaceX rocket.
Add more dependencies
You'll need to add the following multiplatform libraries in your project:
kotlinx.coroutines
, to use coroutines for asynchronous code, which allows simultaneous operations.kotlinx.serialization
, to deserialize JSON responses into objects of entity classes used to process network operations.Ktor, a framework to create an HTTP client for retrieving data over the internet.
kotlinx.coroutines
To add kotlinx.coroutines
to your project, specify a dependency in the common source set. To do so, add the following line to the build.gradle.kts
file of the shared module:
The Multiplatform Gradle plugin automatically adds a dependency to the platform-specific (iOS and Android) parts of kotlinx.coroutines
.
kotlinx.serialization
To use the kotlinx.serialization
library, set up a corresponding Gradle plugin. To do that, add the following line to the existing plugins {}
block at the very beginning of the build.gradle.kts
file in the shared module:
Ktor
You need to add the core dependency (ktor-client-core
) to the common source set of the shared module. You also need to add supporting dependencies:
Add the
ContentNegotiation
functionality (ktor-client-content-negotiation
), which allows serializing and deserializing the content in a specific format.Add the
ktor-serialization-kotlinx-json
dependency to instruct Ktor to use the JSON format andkotlinx.serialization
as a serialization library. Ktor will expect JSON data and deserialize it into a data class when receiving responses.Provide the platform engines by adding dependencies on the corresponding artifacts in the platform source sets (
ktor-client-android
,ktor-client-darwin
).
Synchronize the Gradle files by clicking Sync Now in the notification.
Create API requests
You'll need the SpaceX API to retrieve data, and you'll use a single method to get the list of all launches from the v4/launches endpoint.
Add a data model
In shared/src/commonMain/kotlin
, create a new RocketLaunch.kt
file and add a data class which stores data from the SpaceX API:
The
RocketLaunch
class is marked with the@Serializable
annotation, so that thekotlinx.serialization
plugin can automatically generate a default serializer for it.The
@SerialName
annotation allows you to redefine field names, making it possible to declare properties in data classes with more readable names.
Connect HTTP client
In
shared/src/commonMain/kotlin
, create a newRocketComponent
class.Add the
httpClient
property to retrieve rocket launch information through an HTTP GET request:import io.ktor.client.* import io.ktor.client.plugins.contentnegotiation.* import io.ktor.serialization.kotlinx.json.* import kotlinx.serialization.json.Json class RocketComponent { private val httpClient = HttpClient { install(ContentNegotiation) { json(Json { prettyPrint = true isLenient = true ignoreUnknownKeys = true }) } } }The ContentNegotiation Ktor plugin and the JSON serializer deserialize the result of the GET request.
The JSON serializer here is configured in a way that it prints JSON in a more readable manner with the
prettyPrint
property. It is more flexible when reading malformed JSON withisLenient
, and it ignores keys that haven't been declared in the rocket launch model withignoreUnknownKeys
.
Add the
getDateOfLastSuccessfulLaunch()
suspending function toRocketComponent
:private suspend fun getDateOfLastSuccessfulLaunch(): String { // ... }Call the
httpClient.get()
function to retrieve information about rocket launches:import io.ktor.client.request.* import io.ktor.client.call.* private suspend fun getDateOfLastSuccessfulLaunch(): String { val rockets: List<RocketLaunch> = httpClient.get("https://api.spacexdata.com/v4/launches").body() }httpClient.get()
is also a suspending function because it needs to retrieve data over the network asynchronously without blocking threads.Suspending functions can only be called from coroutines or other suspending functions. This is why
getDateOfLastSuccessfulLaunch()
was marked with thesuspend
keyword. The network request is executed in the HTTP client's thread pool.
Update the function again to find the last successful launch in the list:
private suspend fun getDateOfLastSuccessfulLaunch(): String { val rockets: List<RocketLaunch> = httpClient.get("https://api.spacexdata.com/v4/launches").body() val lastSuccessLaunch = rockets.last { it.launchSuccess == true } }The list of rocket launches is sorted by date from oldest to newest.
Convert the launch date from UTC to your local date and format the output:
import kotlinx.datetime.Instant import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime private suspend fun getDateOfLastSuccessfulLaunch(): String { val rockets: List<RocketLaunch> = httpClient.get("https://api.spacexdata.com/v4/launches").body() val lastSuccessLaunch = rockets.last { it.launchSuccess == true } val date = Instant.parse(lastSuccessLaunch.launchDateUTC) .toLocalDateTime(TimeZone.currentSystemDefault()) return "${date.month} ${date.dayOfMonth}, ${date.year}" }The date will be in the "MMMM DD, YYYY" format, for example, OCTOBER 5, 2022.
Add another suspending function,
launchPhrase()
, which will create a message using thegetDateOfLastSuccessfulLaunch()
function:suspend fun launchPhrase(): String = try { "The last successful launch was on ${getDateOfLastSuccessfulLaunch()} 🚀" } catch (e: Exception) { println("Exception during getting the date of the last successful launch $e") "Error occurred" }
Create the flow
You can use flows instead of suspending functions. They emit a sequence of values instead of a single value that suspending functions return.
Open the
Greeting.kt
file in theshared/src/commonMain/kotlin
directory.Add a
rocketComponent
property to theGreeting
class. The property will store the message with the last successful launch date:private val rocketComponent = RocketComponent()Change the
greet()
function to return aFlow
:import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import kotlin.time.Duration.Companion.seconds fun greet(): Flow<String> = flow { emit(if (Random.nextBoolean()) "Hi!" else "Hello!") delay(1.seconds) emit("Guess what this is! > ${platform.name.reversed()}") delay(1.seconds) emit(daysPhrase()) emit(rocketComponent.launchPhrase()) }The
Flow
is created here with theflow()
builder function, which wraps all the statements.The
Flow
emits strings with a delay of one second between each emission. The last element is only emitted after the network response returns, so the exact delay depends on your network.
Add internet access permission
To access the internet, the Android application needs the appropriate permission. Since all network requests are made from the shared module, it makes sense to add the internet access permission to its manifest.
Update your composeApp/src/androidMain/AndroidManifest.xml
file with the access permission:
Update native Android and iOS UI
You've already updated the API of the shared module by changing the return type of the greet()
function to Flow
. Now you need to update native (iOS, Android) parts of the project so that they can properly handle the result of calling the greet()
function.
Android app
As both the shared module and the Android application are written in Kotlin, using shared code from Android is straightforward.
Introduce a view model
Now that the application is becoming more complex, it's time to introduce a view model to the Android activity called MainActivity
. It invokes the App()
function that implements the UI. The view model will manage the data from the activity and won't disappear when the activity undergoes a lifecycle change.
Add the following dependencies to your
composeApp/build.gradle.kts
file:androidMain.dependencies { // ... implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.2") implementation("androidx.lifecycle:lifecycle-runtime-compose:2.6.2") implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.6.2") }In
composeApp/src/androidMain/kotlin
, create a newMainViewModel
Kotlin class:import androidx.lifecycle.ViewModel class MainViewModel : ViewModel() { // ... }This class extends Android's
ViewModel
class, which ensures the correct behavior regarding lifecycle and configuration changes.Create a
greetingList
value of the StateFlow type and its backing property:import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow class MainViewModel : ViewModel() { private val _greetingList = MutableStateFlow<List<String>>(listOf()) val greetingList: StateFlow<List<String>> get() = _greetingList }StateFlow
here extends theFlow
interface but has a single value or state.The private backing property
_greetingList
ensures that only clients of this class can access the read-onlygreetingList
property.
In the
init
function of the View Model, collect all the strings from theGreeting().greet()
flow:import androidx.lifecycle.viewModelScope import kotlinx.coroutines.launch class MainViewModel : ViewModel() { private val _greetingList = MutableStateFlow<List<String>>(listOf()) val greetingList: StateFlow<List<String>> get() = _greetingList init { viewModelScope.launch { Greeting().greet().collect { phrase -> //... } } } }Since the
collect()
function is suspended, thelaunch
coroutine is used within the view model's scope. This means that the launch coroutine will run only during the correct phases of the view model's lifecycle.Inside the
collect
trailing lambda, update the value of_greetingList
to append the collectedphrase
to the list of phrases inlist
:import kotlinx.coroutines.flow.update class MainViewModel : ViewModel() { //... init { viewModelScope.launch { Greeting().greet().collect { phrase -> _greetingList.update { list -> list + phrase } } } } }The
update()
function will update the value automatically.
Use the view model's flow
In
composeApp/src/androidMain/kotlin
, locate theApp.kt
file and update it, replacing the previous implementation:import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.runtime.getValue import androidx.lifecycle.viewmodel.compose.viewModel @Composable fun App(mainViewModel: MainViewModel = viewModel()) { MaterialTheme { val greetings by mainViewModel.greetingList.collectAsStateWithLifecycle() Column( modifier = Modifier.padding(all = 20.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { greetings.forEach { greeting -> Text(greeting) Divider() } } } }The
collectAsStateWithLifecycle()
function calls ongreetingList
to collect the value from the view model's flow and represent it as a composable state in a lifecycle-aware manner.When a new flow is created, the compose state will change and display a scrollable
Column
with greeting phrases arranged vertically and separated by dividers.
To see the results, re-run your composeApp configuration in Android Studio:
iOS app
For the iOS part of the project, you'll make use of the Model–view–viewmodel pattern again to connect the UI to the shared module, which contains all the business logic.
The module is already imported in the ContentView.swift
file with the import Shared
declaration.
Introducing a view model
Go back to your iOS app in Xcode.
In
iosApp/iOSApp.swift
, update the entry point for your app:@main struct iOSApp: App { var body: some Scene { WindowGroup { ContentView(viewModel: ContentView.ViewModel()) } } }In
iosApp/ContentView.swift
, create aViewModel
class forContentView
, which will prepare and manage data for it. Call thestartObserving()
function within atask()
call to support concurrency:import SwiftUI import Shared struct ContentView: View { @ObservedObject private(set) var viewModel: ViewModel var body: some View { ListView(phrases: viewModel.greetings) .task { await self.viewModel.startObserving() } } } extension ContentView { @MainActor class ViewModel: ObservableObject { @Published var greetings: Array<String> = [] func startObserving() { // ... } } } struct ListView: View { let phrases: Array<String> var body: some View { List(phrases, id: \.self) { Text($0) } } }ViewModel
is declared as an extension toContentView
, as they are closely connected.ViewModel
has agreetings
property that is an array ofString
phrases. SwiftUI connects the view model (ContentView.ViewModel
) with the view (ContentView
).ContentView.ViewModel
is declared as anObservableObject
.The
@Published
wrapper is used for thegreetings
property.The
@ObservedObject
property wrapper is used to subscribe to the view model.
Now the view model will emit signals whenever this property changes.
Choose a library to consume flows from iOS
In this tutorial, you can choose between the SKIE and KMP-NativeCoroutines libraries to help you work with flows in iOS. Both are open-source solutions that support cancellation and generics with flows, which the Kotlin/Native compiler doesn't yet provide by default.
The SKIE library augments the Objective-C API produced by the Kotlin compiler: SKIE transforms flows into an equivalent of Swift’s AsyncSequence
. SKIE directly supports Swift's async
/await
, without thread restriction, and with automatic bi-directional cancellation (Combine and RxSwift require adapters). SKIE offers other features to produce a Swift-friendly API from Kotlin, including bridging various Kotlin types to Swift equivalents. It also doesn’t require adding additional dependencies in iOS projects.
The KMP-NativeCoroutines library helps you consume suspending functions and flows from iOS by generating necessary wrappers. KMP-NativeCoroutines supports Swift's async
/await
functionality as well as Combine and RxSwift. It has been available longer than SKIE, and thus you may encounter fewer edge cases with it today. Using KMP-NativeCoroutines requires adding a Cocoapod or SPM dependency in iOS projects.
Option 1. Configure SKIE
To set up the library, specify the SKIE plugin in shared/build.gradle.kts
and click the Sync Now button.
Consume the flow using SKIE
Return to Xcode and update the code using the library:
Use a loop and the
await
mechanism to iterate through theGreeting().greet()
flow and update thegreetings
property every time the flow emits a value.Make sure
ViewModel
is marked with the@MainActor
annotation. The annotation ensures that all asynchronous operations withinViewModel
run on the main thread to comply with the Kotlin/Native requirement:// ... extension ContentView { @MainActor class ViewModel: ObservableObject { @Published var greetings: [String] = [] func startObserving() async { for await phrase in Greeting().greet() { self.greetings.append(phrase) } } } }Re-run the iosApp configuration from Android Studio to make sure your app's logic is synced:
Option 2. Configure KMP-NativeCoroutines
Return to Android Studio. In the
build.gradle.kts
file of the whole project, add the KSP (Kotlin Symbol Processor) and KMP-NativeCoroutines plugins to theplugins {}
block:plugins { // ... id("com.google.devtools.ksp").version("2.0.0-1.0.24").apply(false) id("com.rickclephas.kmp.nativecoroutines").version("1.0.0-ALPHA-33").apply(false) }In the shared
build.gradle.kts
file, configure the KMP-NativeCoroutines plugin:plugins { // ... id("com.google.devtools.ksp") id("com.rickclephas.kmp.nativecoroutines") }In the shared
build.gradle.kts
file, opt-in to the experimental@ObjCName
annotation:kotlin { // ... sourceSets{ all { languageSettings.optIn("kotlin.experimental.ExperimentalObjCName") } // ... } }Synchronize the Gradle files by clicking Sync Now in the notification.
Mark the flow with KMP-NativeCoroutines
Open the
Greeting.kt
file in theshared/src/commonMain/kotlin
directory.Add the
@NativeCoroutines
annotation to thegreet()
function. This will ensure that the plugin generates the right code to support correct flow handling on iOS:import com.rickclephas.kmp.nativecoroutines.NativeCoroutines class Greeting { // ... @NativeCoroutines fun greet(): Flow<String> = flow { // ... } }
Import the library using SPM in XCode
In Xcode, right-click the
iosApp
project in the left-hand menu and select Add Package Dependencies.In the search bar, enter the package name:
https://github.com/rickclephas/KMP-NativeCoroutines.gitIn the Dependency Rule dropdown, select the Exact Version item and enter the
1.0.0-ALPHA-33
version in the adjacent field.Click the Add Package button: Xcode will fetch the package from GitHub and open another window to choose package products.
Add "KMPNativeCoroutinesAsync" and "KMPNativeCoroutinesCore" to your app as shown, then click Add Package:
This should install the parts of the KMP-NativeCoroutines package necessary to work with the async/await
mechanism.
Consume the flow using the KMP-NativeCoroutines library
In
iosApp/ContentView.swift
, update thestartObserving()
function to consume the flow using KMP-NativeCoroutine'sasyncSequence()
function for theGreeting().greet()
function:func startObserving() async { do { let sequence = asyncSequence(for: Greeting().greet()) for try await phrase in sequence { self.greetings.append(phrase) } } catch { print("Failed with error: \(error)") } }The loop and the
await
mechanism here are used here to iterate through the flow and update thegreetings
property every time the flow emits a value.Make sure
ViewModel
is marked with the@MainActor
annotation. The annotation ensures that all asynchronous operations withinViewModel
run on the main thread to comply with the Kotlin/Native requirement:// ... import KMPNativeCoroutinesAsync import KMPNativeCoroutinesCore // ... extension ContentView { @MainActor class ViewModel: ObservableObject { @Published var greetings: Array<String> = [] func startObserving() async { do { let sequence = asyncSequence(for: Greeting().greet()) for try await phrase in sequence { self.greetings.append(phrase) } } catch { print("Failed with error: \(error)") } } } }Re-run the iosApp configuration from Android Studio to make sure your app's logic is synced:
Next step
In the final part of the tutorial, you'll wrap up your project and see what steps to take next.
See also
Explore various approaches to composition of suspending functions.
Learn more about the interoperability with Objective-C frameworks and libraries.
Complete this tutorial on networking and data storage.
Get help
Kotlin Slack. Get an invite and join the #multiplatform channel.
Kotlin issue tracker. Report a new issue.