在 iOS 与 Android 间共享更多逻辑

This is the fifth part of the Getting started with Kotlin Multiplatform for mobile tutorial. Before proceeding, make sure you’ve completed previous steps.

First step Set up an environment
Second step Create your first cross-platform app
Third step Update the user interface
Fourth step Add dependencies
Fifth step Share more logic
Sixth step Wrap up your project

You’ve already implemented common logic using external dependencies. Now you can add more complex logic. Network requests and data serialization are the most popular cases to share with 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 a SpaceX API and display the date of the last successful launch of a SpaceX rocket.

Add more dependencies

You’ll need the following multiplatform libraries in your project:

  • kotlinx.coroutines, for using coroutines to write asynchronous code, which allows simultaneous operations.
  • kotlinx.serialization, for deserializing JSON responses into objects of entity classes used to process network operations.
  • Ktor, a framework as 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:

  1. sourceSets {
  2. val commonMain by getting {
  3. dependencies {
  4. // ...
  5. implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.1")
  6. }
  7. }
  8. }

The Multiplatform Gradle plugin automatically adds a dependency to the platform-specific (iOS and Android) parts of kotlinx.coroutines.

If you use Kotlin prior to version 1.7.20

If you use Kotlin 1.7.20 and later, you already have the new Kotlin/Native memory manager enabled by default. If it’s not the case, add the following to the end of the build.gradle.kts file:

  1. kotlin.targets.withType(org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget::class.java) {
  2. binaries.all {
  3. binaryOptions["memoryModel"] = "experimental"
  4. }
  5. }

kotlinx.serialization

For kotlinx.serialization, you need the plugin required by the build system. The Kotlin serialization plugin is shipped with the Kotlin compiler distribution, and the IntelliJ IDEA plugin is bundled into the Kotlin plugin.

You can set up the serialization plugin with the Kotlin plugin using the Gradle plugins DSL by adding this line to the existing plugins block at the very beginning of the build.gradle.kts file in the shared module:

  1. plugins {
  2. // ...
  3. kotlin("plugin.serialization") version "1.9.10"
  4. }

Ktor

You can add Ktor in the same way you’ve added the kotlinx.coroutines library. In addition to specifying the core dependency (ktor-client-core) in the common source set, you also need to:

  • Add the ContentNegotiation functionality (ktor-client-content-negotiation), responsible for serializing/deserializing the content in a specific format.
  • Add the ktor-serialization-kotlinx-json dependency to instruct Ktor to use the JSON format and kotlinx.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).
  1. val ktorVersion = "2.3.2"
  2. sourceSets {
  3. val commonMain by getting {
  4. dependencies {
  5. // ...
  6. implementation("io.ktor:ktor-client-core:$ktorVersion")
  7. implementation("io.ktor:ktor-client-content-negotiation:$ktorVersion")
  8. implementation("io.ktor:ktor-serialization-kotlinx-json:$ktorVersion")
  9. }
  10. }
  11. val androidMain by getting {
  12. dependencies {
  13. implementation("io.ktor:ktor-client-android:$ktorVersion")
  14. }
  15. }
  16. val iosMain by getting {
  17. // ...
  18. dependencies {
  19. implementation("io.ktor:ktor-client-darwin:$ktorVersion")
  20. }
  21. }
  22. }

Synchronize the Gradle files by clicking Sync Now in the notification.

Create API requests

You’ll need the SpaceX API to retrieve data and a single method to get the list of all launches from the v4/launches endpoint.

Add data model

In shared/src/commonMain/kotlin, create a new RocketLaunch.kt file in the project folder and add a data class which stores data from the SpaceX API:

  1. import kotlinx.serialization.SerialName
  2. import kotlinx.serialization.Serializable
  3. @Serializable
  4. data class RocketLaunch (
  5. @SerialName("flight_number")
  6. val flightNumber: Int,
  7. @SerialName("name")
  8. val missionName: String,
  9. @SerialName("date_utc")
  10. val launchDateUTC: String,
  11. @SerialName("success")
  12. val launchSuccess: Boolean?,
  13. )
  • The RocketLaunch class is marked with the @Serializable annotation, so that the kotlinx.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

  1. In Greeting.kt, create a Ktor HTTPClient instance to execute network requests and parse the resulting JSON:

    1. import io.ktor.client.*
    2. import io.ktor.client.plugins.contentnegotiation.*
    3. import io.ktor.serialization.kotlinx.json.*
    4. import kotlinx.serialization.json.Json
    5. class Greeting {
    6. private val platform: Platform = getPlatform()
    7. private val httpClient = HttpClient {
    8. install(ContentNegotiation) {
    9. json(Json {
    10. prettyPrint = true
    11. isLenient = true
    12. ignoreUnknownKeys = true
    13. })
    14. }
    15. }
    16. }

    To deserialize the result of the GET request, the ContentNegotiation Ktor plugin and the JSON serializer are used.

  2. In the greet() function, retrieve the information about rocket launches by calling the httpClient.get() method and find the latest launch:

    1. import io.ktor.client.call.*
    2. import io.ktor.client.request.*
    3. class Greeting {
    4. // ...
    5. @Throws(Exception::class)
    6. suspend fun greet(): List<String> = buildList {
    7. val rockets: List<RocketLaunch> =
    8. httpClient.get("https://api.spacexdata.com/v4/launches").body()
    9. val lastSuccessLaunch = rockets.last { it.launchSuccess == true }
    10. add(if (Random.nextBoolean()) "Hi!" else "Hello!")
    11. add("Guess what it is! > ${platform.name.reversed()}!")
    12. add("\nThere are only ${daysUntilNewYear()} days left until New Year! 🎆")
    13. add("\nThe last successful launch was ${lastSuccessLaunch.launchDateUTC} 🚀")
    14. }
    15. }

    The suspend modifier in the greet() function is necessary because it now contains a call to get(). It’s a suspend function that has an asynchronous operation to retrieve data over the internet and can only be called from within a coroutine or another suspend function. The network request will be executed in the HTTP client’s thread pool.

Add internet access permission

To access the internet, the Android application needs 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 androidApp/src/main/AndroidManifest.xml file with the access permission:

  1. <?xml version="1.0" encoding="utf-8"?>
  2. <manifest xmlns:android="http://schemas.android.com/apk/res/android"
  3. package="com.jetbrains.simplelogin.kotlinmultiplatformsandbox" >
  4. <uses-permission android:name="android.permission.INTERNET"/>
  5. </manifest>

Update Android and iOS apps

You’ve already updated the API of the shared module by adding the suspend modifier to the greet() function. Now you need to update native (iOS, Android) parts of the project, so 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:

In androidApp/src/main/java, locate the MainActivity.kt file and update the following class replacing previous implementation:

  1. import androidx.compose.runtime.*
  2. class MainActivity : ComponentActivity() {
  3. override fun onCreate(savedInstanceState: Bundle?) {
  4. super.onCreate(savedInstanceState)
  5. setContent {
  6. MyApplicationTheme {
  7. Surface(
  8. modifier = Modifier.fillMaxSize(),
  9. color = MaterialTheme.colors.background
  10. ) {
  11. var phrases by remember { mutableStateOf(listOf("Loading")) }
  12. LaunchedEffect(true) {
  13. phrases = try {
  14. Greeting().greet()
  15. } catch (e: Exception) {
  16. listOf(e.localizedMessage ?: "error")
  17. }
  18. }
  19. GreetingView(phrases)
  20. }
  21. }
  22. }
  23. }
  24. }

The greet() function is now called inside LaunchedEffect to avoid recalling it on each recomposition.

iOS app

For the iOS part of the project, you’ll make use of the Model–view–viewmodel pattern to connect the UI to the shared module, which contains all the business logic.

The module is already connected to the iOS project — the Android Studio plugin wizard did all the configuration. The module is already imported and used in ContentView.swift with import shared.

If you see errors in Xcode regarding the shared module or when updating your code, run the iosApp from Android Studio.

5. 共享更多逻辑 - 图7

  1. Get back to your iOS app in Xcode.
  2. In iosApp/iOSApp.swift, update the entry point for your app:

    1. @main
    2. struct iOSApp: App {
    3. var body: some Scene {
    4. WindowGroup {
    5. ContentView(viewModel: ContentView.ViewModel())
    6. }
    7. }
    8. }
  3. In iosApp/ContentView.swift, create a ViewModel class for ContentView, which will prepare and manage data for it:

    1. import SwiftUI
    2. import shared
    3. struct ContentView: View {
    4. @ObservedObject private(set) var viewModel: ViewModel
    5. var body: some View {
    6. List(viewModel.phrases, id: \.self) { phrase in
    7. Text(phrase)
    8. }
    9. }
    10. }
    11. extension ContentView {
    12. class ViewModel: ObservableObject {
    13. @Published var phrases: [String] = ["Loading..."]
    14. init() {
    15. // Data will be loaded here
    16. }
    17. }
    18. }
    • ViewModel is declared as an extension to ContentView, as they are closely connected.
    • The Combine framework connects the view model (ContentView.ViewModel) with the view (ContentView).
    • ContentView.ViewModel is declared as an ObservableObject.
    • The @Published wrapper is used for the text property.
    • The @ObservedObject property wrapper is used to subscribe to the view model.

    Now the view model will emit signals whenever this property changes.

  4. Call the greet() function, which now also loads data from the SpaceX API, and save the result in the phrases property:

    1. class ViewModel: ObservableObject {
    2. @Published var phrases: [String] = ["Loading..."]
    3. init() {
    4. Greeting().greet { greeting, error in
    5. DispatchQueue.main.async {
    6. if let greeting = greeting {
    7. self.phrases = greeting
    8. } else {
    9. self.phrases = [error?.localizedDescription ?? "error"]
    10. }
    11. }
    12. }
    13. }
    14. }
    • Kotlin/Native provides bidirectional interoperability with Objective-C, thus Kotlin concepts, including suspend functions, are mapped to the corresponding Swift/Objective-C concepts and vice versa. When you compile a Kotlin module into an Apple framework, suspending functions are available in it as functions with callbacks (completionHandler).
    • The greet() function was marked with the @Throws(Exception::class) annotation. So any exceptions that are instances of the Exception class or its subclass will be propagated as NSError, so you can handle them in the completionHandler.
    • When calling Kotlin suspend functions from Swift, completion handlers might be called on threads other than main, see the iOS integration in the Kotlin/Native memory manager. That’s why DispatchQueue.main.async is used to update phrases property.
  5. In ContentView_Previews, ensure that the view model is properly initialized:

    1. struct ContentView_Previews: PreviewProvider {
    2. static var previews: some View {
    3. ContentView(viewModel: ContentView.ViewModel())
    4. }
    5. }
  6. Re-run both androidApp and iosApp configurations from Android Studio to make sure your app’s logic is synced:

    Final results

You can find this state of the project in our GitHub repository.

5. 共享更多逻辑 - 图9

Next step

In the final part of the tutorial, you’ll wrap up your project and see what steps to take next.

Proceed to the next part

See also

Get help