使用 Ktor 和 SQLDelight 创建多平台应用——教程

This tutorial demonstrates how to use Android Studio to create a mobile application for iOS and Android using Kotlin Multiplatform Mobile with Ktor and SQLDelight.

The application will include a module with shared code for both the iOS and Android platforms. The business logic and data access layers will be implemented only once in the shared module, while the UI of both applications will be native.

The output will be an app that retrieves data over the internet from the public SpaceX API, saves it in a local database, and displays a list of SpaceX rocket launches together with the launch date, results, and a detailed description of the launch:

Emulator and Simulator

You will use the following multiplatform libraries in the project:

  • Ktor as an HTTP client for retrieving data over the internet.
  • kotlinx.serialization to deserialize JSON responses into objects of entity classes.
  • kotlinx.coroutines to write asynchronous code.
  • SQLDelight to generate Kotlin code from SQL queries and create a type-safe database API.

You can find the template project as well as the source code of the final application on the corresponding GitHub repository.

使用 Ktor 和 SQLDelight 创建多平台应用——教程 - 图2

Before you start

  1. Download and install Android Studio.
  2. Search for the Kotlin Multiplatform Mobile plugin in the Android Studio Marketplace and install it.

    Kotlin Multiplatform Mobile plugin

  3. Download and install Xcode.

For more details, see the Set up the environment section.

Create a Multiplatform project

  1. In Android Studio, select File | New | New Project. In the list of project templates, select Kotlin Multiplatform App and then click Next.

    Kotlin Multiplatform Mobile plugin wizard

  2. Name your application and click Next.

  3. Select Regular framework in the list of iOS framework distribution options.

    Kotlin Multiplatform Mobile plugin wizard. Final step

  4. Keep all other options default. Click Finish.

  5. To view the complete structure of the multiplatform mobile project, switch the view from Android to Project.

    Project view

For more on project features and how to use them, see Understand the project structure.

You can find the configured project on the master branch.

使用 Ktor 和 SQLDelight 创建多平台应用——教程 - 图7

Add dependencies to the multiplatform library

To add a multiplatform library to the shared module, you need to add dependency instructions (implementation) for all libraries to the dependencies block of the relevant source sets in the build.gradle.kts file.

Both the kotlinx.serialization and SQLDelight libraries also require additional configurations.

  1. In the shared directory, specify the dependencies on all the required libraries in the build.gradle.kts file:

    1. val coroutinesVersion = "1.7.1"
    2. val ktorVersion = "2.3.2"
    3. val sqlDelightVersion = "1.5.5"
    4. val dateTimeVersion = "0.4.0"
    5. sourceSets {
    6. targetHierarchy.default()
    7. val commonMain by getting {
    8. dependencies {
    9. implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion")
    10. implementation("io.ktor:ktor-client-core:$ktorVersion")
    11. implementation("io.ktor:ktor-client-content-negotiation:$ktorVersion")
    12. implementation("io.ktor:ktor-serialization-kotlinx-json:$ktorVersion")
    13. implementation("com.squareup.sqldelight:runtime:$sqlDelightVersion")
    14. implementation("org.jetbrains.kotlinx:kotlinx-datetime:$dateTimeVersion")
    15. }
    16. }
    17. val androidMain by getting {
    18. dependencies {
    19. implementation("io.ktor:ktor-client-android:$ktorVersion")
    20. implementation("com.squareup.sqldelight:android-driver:$sqlDelightVersion")
    21. }
    22. }
    23. val iosMain by getting {
    24. // ...
    25. dependencies {
    26. implementation("io.ktor:ktor-client-darwin:$ktorVersion")
    27. implementation("com.squareup.sqldelight:native-driver:$sqlDelightVersion")
    28. }
    29. }
    30. }
    • Each library requires a core artifact in the common source set.
    • Both the SQLDelight and Ktor libraries need platform drivers in the iOS and Android source sets, as well.
    • In addition, Ktor needs the serialization feature to use kotlinx.serialization for processing network requests and responses.
  2. At the very beginning of the build.gradle.kts file in the same shared directory, add the following lines to the plugins block:

    1. plugins {
    2. // ...
    3. kotlin("plugin.serialization") version "1.9.10"
    4. id("com.squareup.sqldelight")
    5. }
  3. Now go to the build.gradle.kts file in the project root directory and specify the classpath for the plugin in the build system dependencies:

    1. buildscript {
    2. dependencies {
    3. // ...
    4. classpath("com.squareup.sqldelight:gradle-plugin:1.5.5")
    5. }
    6. }
  4. Finally, define the SQLDelight version in the gradle.properties file in the project root directory to ensure that the SQLDelight versions of the plugin and the libraries are the same:

    1. sqlDelightVersion=1.5.5
  5. Sync the Gradle project.

Learn more about adding dependencies on multiplatform libraries.

You can find this state of the project on the final branch.

使用 Ktor 和 SQLDelight 创建多平台应用——教程 - 图8

Create an application data model

The Kotlin Multiplatform app will contain the public SpaceXSDK class, the facade over networking and cache services. The application data model will have three entity classes with:

  • General information about the launch
  • A URL to external information
  • Information about the rocket

  • In shared/src/commonMain/kotlin, add the com.jetbrains.handson.kmm.shared.entity package.

  • Create the Entity.kt file inside the package.

  • Declare all the data classes for basic entities:

    kotlin {src=”multiplatform-mobile-tutorial/Entity.kt” initial-collapse-state=”collapsed” collapsed-title=”data class RocketLaunch” lines=”3-41” }

Each serializable class must be marked with the @Serializable annotation. The kotlinx.serialization plugin automatically generates a default serializer for @Serializable classes unless you explicitly pass a link to a serializer through the annotation argument.

However, you don’t need to do that in this case. The @SerialName annotation allows you to redefine field names, which helps to declare properties in data classes with more easily readable names.

You can find the state of the project after this section on the final branch.

使用 Ktor 和 SQLDelight 创建多平台应用——教程 - 图9

Configure SQLDelight and implement cache logic

Configure SQLDelight

The SQLDelight library allows you to generate a type-safe Kotlin database API from SQL queries. During compilation, the generator validates the SQL queries and turns them into Kotlin code that can be used in the shared module.

The library is already in the project. To configure it, go to the shared directory and add the sqldelight block to the end of the build.gradle.kts file. The block will contain a list of databases and their parameters:

  1. sqldelight {
  2. database("AppDatabase") {
  3. packageName = "com.jetbrains.handson.kmm.shared.cache"
  4. }
  5. }

The packageName parameter specifies the package name for the generated Kotlin sources.

Consider installing the official SQLite plugin for working with .sq files.

使用 Ktor 和 SQLDelight 创建多平台应用——教程 - 图10

Generate the database API

First, create the .sq file, which will contain all the needed SQL queries. By default, the SQLDelight plugin reads .sq from the sqldelight folder:

  1. In shared/src/commonMain, create a new sqldelight directory and add the com.jetbrains.handson.kmm.shared.cache package.
  2. Inside the package, create an .sq file with the name of the database, AppDatabase.sq. All the SQL queries for the application will be in this file.
  3. The database will contain a table with data about launches. To create the table, add the following code to the AppDatabase.sq file:

    1. CREATE TABLE Launch (
    2. flightNumber INTEGER NOT NULL,
    3. missionName TEXT NOT NULL,
    4. details TEXT,
    5. launchSuccess INTEGER AS Boolean DEFAULT NULL,
    6. launchDateUTC TEXT NOT NULL,
    7. patchUrlSmall TEXT,
    8. patchUrlLarge TEXT,
    9. articleUrl TEXT
    10. );
  4. To insert data into the tables, declare an SQL insert function:

    1. insertLaunch:
    2. INSERT INTO Launch(flightNumber, missionName, details, launchSuccess, launchDateUTC, patchUrlSmall, patchUrlLarge, articleUrl)
    3. VALUES(?, ?, ?, ?, ?, ?, ?, ?);
  5. To clear data in the tables, declare an SQL delete function:

    1. removeAllLaunches:
    2. DELETE FROM Launch;
  6. In the same way, declare a function to retrieve data:

    1. selectAllLaunchesInfo:
    2. SELECT Launch.*
    3. FROM Launch;

After the project is compiled, the generated Kotlin code will be stored in the shared/build/generated/sqldelight directory. The generator will create an interface named AppDatabase, as specified in build.gradle.kts.

Create platform database drivers

To initialize AppDatabase, pass an SqlDriver instance to it. SQLDelight provides multiple platform-specific implementations of the SQLite driver, so you need to create them for each platform separately. You can do this by using expected and actual declarations.

  1. Create an abstract factory for database drivers. To do this, in shared/src/commonMain/kotlin, create the com.jetbrains.handson.kmm.shared.cache package and the DatabaseDriverFactory class inside it:

    1. package com.jetbrains.handson.kmm.shared.cache
    2. import com.squareup.sqldelight.db.SqlDriver
    3. expect class DatabaseDriverFactory {
    4. fun createDriver(): SqlDriver
    5. }

    Now provide actual implementations for this expected class.

  2. On Android, the AndroidSqliteDriver class implements the SQLite driver. Pass the database information and the link to the context to the AndroidSqliteDriver class constructor.

    For this, in the shared/src/androidMain/kotlin directory, create the com.jetbrains.handson.kmm.shared.cache package and a DatabaseDriverFactory class inside it with the actual implementation:

    1. package com.jetbrains.handson.kmm.shared.cache
    2. import android.content.Context
    3. import com.squareup.sqldelight.android.AndroidSqliteDriver
    4. import com.squareup.sqldelight.db.SqlDriver
    5. actual class DatabaseDriverFactory(private val context: Context) {
    6. actual fun createDriver(): SqlDriver {
    7. return AndroidSqliteDriver(AppDatabase.Schema, context, "test.db")
    8. }
    9. }
  3. On iOS, the SQLite driver implementation is the NativeSqliteDriver class. In the shared/src/iosMain/kotlin directory, create a com.jetbrains.handson.kmm.shared.cache package and a DatabaseDriverFactory class inside it with the actual implementation:

    1. package com.jetbrains.handson.kmm.shared.cache
    2. import com.squareup.sqldelight.db.SqlDriver
    3. import com.squareup.sqldelight.drivers.native.NativeSqliteDriver
    4. actual class DatabaseDriverFactory {
    5. actual fun createDriver(): SqlDriver {
    6. return NativeSqliteDriver(AppDatabase.Schema, "test.db")
    7. }
    8. }

Instances of these factories will be created later in the code of your Android and iOS projects.

You can navigate through the expect declarations and actual implementations by clicking the handy gutter icon:

Expect/Actual gutter

Implement cache

So far, you have added platform database drivers and an AppDatabase class to perform database operations. Now create a Database class, which will wrap the AppDatabase class and contain the caching logic.

  1. In the common source set shared/src/commonMain/kotlin, create a new Database class in the com.jetbrains.handson.kmm.shared.cache package. It will be common to both platform logics.

  2. To provide a driver for AppDatabase, pass an abstract DatabaseDriverFactory to the Database class constructor:

    1. package com.jetbrains.handson.kmm.shared.cache
    2. import com.jetbrains.handson.kmm.shared.entity.Links
    3. import com.jetbrains.handson.kmm.shared.entity.Patch
    4. import com.jetbrains.handson.kmm.shared.entity.RocketLaunch
    5. internal class Database(databaseDriverFactory: DatabaseDriverFactory) {
    6. private val database = AppDatabase(databaseDriverFactory.createDriver())
    7. private val dbQuery = database.appDatabaseQueries
    8. }

    This class’s visibility is set to internal, which means it is only accessible from within the multiplatform module.

  3. Inside the Database class, implement some data handling operations. Add a function to clear all the tables in the database in a single SQL transaction:

    1. internal fun clearDatabase() {
    2. dbQuery.transaction {
    3. dbQuery.removeAllLaunches()
    4. }
    5. }
  4. Create a function to get a list of all the rocket launches:

    1. import com.jetbrains.handson.kmm.shared.entity.Links
    2. import com.jetbrains.handson.kmm.shared.entity.Patch
    3. import com.jetbrains.handson.kmm.shared.entity.RocketLaunch
    4. internal fun getAllLaunches(): List<RocketLaunch> {
    5. return dbQuery.selectAllLaunchesInfo(::mapLaunchSelecting).executeAsList()
    6. }
    7. private fun mapLaunchSelecting(
    8. flightNumber: Long,
    9. missionName: String,
    10. details: String?,
    11. launchSuccess: Boolean?,
    12. launchDateUTC: String,
    13. patchUrlSmall: String?,
    14. patchUrlLarge: String?,
    15. articleUrl: String?
    16. ): RocketLaunch {
    17. return RocketLaunch(
    18. flightNumber = flightNumber.toInt(),
    19. missionName = missionName,
    20. details = details,
    21. launchDateUTC = launchDateUTC,
    22. launchSuccess = launchSuccess,
    23. links = Links(
    24. patch = Patch(
    25. small = patchUrlSmall,
    26. large = patchUrlLarge
    27. ),
    28. article = articleUrl
    29. )
    30. )
    31. }

    The argument passed to selectAllLaunchesInfo is a function that maps the database entity class to another type, which in this case is the RocketLaunch data model class.

  5. Add a function to insert data into the database:

    1. internal fun createLaunches(launches: List<RocketLaunch>) {
    2. dbQuery.transaction {
    3. launches.forEach { launch ->
    4. insertLaunch(launch)
    5. }
    6. }
    7. }
    8. private fun insertLaunch(launch: RocketLaunch) {
    9. dbQuery.insertLaunch(
    10. flightNumber = launch.flightNumber.toLong(),
    11. missionName = launch.missionName,
    12. details = launch.details,
    13. launchSuccess = launch.launchSuccess ?: false,
    14. launchDateUTC = launch.launchDateUTC,
    15. patchUrlSmall = launch.links.patch?.small,
    16. patchUrlLarge = launch.links.patch?.large,
    17. articleUrl = launch.links.article
    18. )
    19. }

The Database class instance will be created later, along with the SDK facade class.

You can find the state of the project after this section on the final branch.

使用 Ktor 和 SQLDelight 创建多平台应用——教程 - 图12

Implement an API service

To retrieve data over the internet, you’ll need the SpaceX public API and a single method to retrieve the list of all launches from the v5/launches endpoint.

Create a class that will connect the application to the API:

  1. In the common source set shared/src/commonMain/kotlin, create the com.jetbrains.handson.kmm.shared.network package and the SpaceXApi class inside it:

    1. package com.jetbrains.handson.kmm.shared.network
    2. import com.jetbrains.handson.kmm.shared.entity.RocketLaunch
    3. import io.ktor.client.*
    4. import io.ktor.client.call.*
    5. import io.ktor.client.plugins.contentnegotiation.*
    6. import io.ktor.client.request.*
    7. import io.ktor.serialization.kotlinx.json.*
    8. import kotlinx.serialization.json.Json
    9. class SpaceXApi {
    10. private val httpClient = HttpClient {
    11. install(ContentNegotiation) {
    12. json(Json {
    13. ignoreUnknownKeys = true
    14. useAlternativeNames = false
    15. })
    16. }
    17. }
    18. }
    • This class executes network requests and deserializes JSON responses into entities from the entity package. The Ktor HttpClient instance initializes and stores the httpClient property.
    • This code uses the Ktor ContentNegotiation plugin to deserialize the GET request result. The plugin processes the request and the response payload as JSON, serializing and deserializing them using a special serializer.
  2. Declare the data retrieval function that will return the list of RocketLaunches:

    1. suspend fun getAllLaunches(): List<RocketLaunch> {
    2. return httpClient.get("https://api.spacexdata.com/v5/launches").body()
    3. }
    • The getAllLaunches function has the suspend modifier because it contains a call of the suspend function get(), which includes an asynchronous operation to retrieve data over the internet and can only be called from a coroutine or another suspend function. The network request will be executed in the HTTP client’s thread pool.
    • The URL is defined inside the get() function to send requests.

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, adding the internet access permission to this module’s manifest makes sense.

In the androidApp/src/main/AndroidManifest.xml file, add the following permission to the manifest:

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

You can find the state of the project after this section on the final branch.

使用 Ktor 和 SQLDelight 创建多平台应用——教程 - 图13

Build an SDK

Your iOS and Android applications will communicate with the SpaceX API through the shared module, which will provide a public class.

  1. In the com.jetbrains.handson.kmm.shared package of the common source set, create the SpaceXSDK class:

    1. package com.jetbrains.handson.kmm.shared
    2. import com.jetbrains.handson.kmm.shared.cache.Database
    3. import com.jetbrains.handson.kmm.shared.cache.DatabaseDriverFactory
    4. import com.jetbrains.handson.kmm.shared.network.SpaceXApi
    5. class SpaceXSDK (databaseDriverFactory: DatabaseDriverFactory) {
    6. private val database = Database(databaseDriverFactory)
    7. private val api = SpaceXApi()
    8. }

    This class will be the facade over the Database and SpaceXApi classes.

  2. To create a Database class instance, you’ll need to provide the DatabaseDriverFactory platform instance to it, so you’ll inject it from the platform code through the SpaceXSDK class constructor.

    1. import com.jetbrains.handson.kmm.shared.entity.RocketLaunch
    2. @Throws(Exception::class)
    3. suspend fun getLaunches(forceReload: Boolean): List<RocketLaunch> {
    4. val cachedLaunches = database.getAllLaunches()
    5. return if (cachedLaunches.isNotEmpty() && !forceReload) {
    6. cachedLaunches
    7. } else {
    8. api.getAllLaunches().also {
    9. database.clearDatabase()
    10. database.createLaunches(it)
    11. }
    12. }
    13. }
    • The class contains one function for getting all launch information. Depending on the value of forceReload, it returns cached values or loads the data from the internet and then updates the cache with the results. If there is no cached data, it loads the data from the internet independently of the forceReload flag’s value.
    • Clients of your SDK could use a forceReload flag to load the latest information about the launches, which would allow the user to use the pull-to-refresh gesture.
    • To handle exceptions produced by the Ktor client in Swift, the function is marked with the @Throws annotation.

    All Kotlin exceptions are unchecked, while Swift has only checked errors. Thus, to make your Swift code aware of expected exceptions, Kotlin functions should be marked with the @Throws annotation specifying a list of potential exception classes.

You can find the state of the project after this section on the final branch.

使用 Ktor 和 SQLDelight 创建多平台应用——教程 - 图14

Create the Android application

The Kotlin Multiplatform Mobile plugin for Android Studio has already handled the configuration for you, so the Kotlin Multiplatform shared module is already connected to your Android application.

Before implementing the UI and the presentation logic, add all the required dependencies to the androidApp/build.gradle.kts:

  1. // ...
  2. dependencies {
  3. implementation(project(":shared"))
  4. implementation("com.google.android.material:material:1.9.0")
  5. implementation("androidx.appcompat:appcompat:1.6.1")
  6. implementation("androidx.constraintlayout:constraintlayout:2.1.4")
  7. implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
  8. implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.1")
  9. implementation("androidx.core:core-ktx:1.10.1")
  10. implementation("androidx.recyclerview:recyclerview:1.3.0")
  11. implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
  12. implementation("androidx.cardview:cardview:1.0.0")
  13. }
  14. // ...

Implement the UI: display the list of rocket launches

  1. To implement the UI, create the layout/activity_main.xml file in androidApp/src/main/res.

    The screen is based on the ConstraintLayout with the SwipeRefreshLayout inside it, which contains RecyclerView and FrameLayout with a background with a ProgressBar across its center:

    xml {src=”multiplatform-mobile-tutorial/activity_main.xml” initial-collapse-state=”collapsed” collapsed-title=”androidx.constraintlayout.widget.ConstraintLayout xmlns:android” lines=”1-26”}

  2. In androidApp/src/main/java, replace the implementation of the MainActivity class, adding the properties for the UI elements:

    1. class MainActivity : AppCompatActivity() {
    2. private lateinit var launchesRecyclerView: RecyclerView
    3. private lateinit var progressBarView: FrameLayout
    4. private lateinit var swipeRefreshLayout: SwipeRefreshLayout
    5. override fun onCreate(savedInstanceState: Bundle?) {
    6. super.onCreate(savedInstanceState)
    7. title = "SpaceX Launches"
    8. setContentView(R.layout.activity_main)
    9. launchesRecyclerView = findViewById(R.id.launchesListRv)
    10. progressBarView = findViewById(R.id.progressBar)
    11. swipeRefreshLayout = findViewById(R.id.swipeContainer)
    12. }
    13. }
  3. For the RecyclerView element to work, you need to create an adapter (as a subclass of RecyclerView.Adapter) that will convert raw data into list item views. To do this, create a separate LaunchesRvAdapter class:

    1. class LaunchesRvAdapter(var launches: List<RocketLaunch>) : RecyclerView.Adapter<LaunchesRvAdapter.LaunchViewHolder>() {
    2. override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): LaunchViewHolder {
    3. return LayoutInflater.from(parent.context)
    4. .inflate(R.layout.item_launch, parent, false)
    5. .run(::LaunchViewHolder)
    6. }
    7. override fun getItemCount(): Int = launches.count()
    8. override fun onBindViewHolder(holder: LaunchViewHolder, position: Int) {
    9. holder.bindData(launches[position])
    10. }
    11. inner class LaunchViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
    12. // ...
    13. fun bindData(launch: RocketLaunch) {
    14. // ...
    15. }
    16. }
    17. }
  4. Create an item_launch.xml resource file in androidApp/src/main/res/layout/ with the items view layout:

    xml {src=”multiplatform-mobile-tutorial/item_launch.xml” initial-collapse-state=”collapsed” collapsed-title=”androidx.cardview.widget.CardView xmlns:android” lines=”1-28”}

  5. In androidApp/src/main/res/values/, either create your appearance of the app or copy the following styles:

【colors.xml】

  1. <?xml version="1.0" encoding="utf-8"?>
  2. <resources>
  3. <color name="colorPrimary">#37474f</color>
  4. <color name="colorPrimaryDark">#102027</color>
  5. <color name="colorAccent">#62727b</color>
  6. <color name="colorSuccessful">#4BB543</color>
  7. <color name="colorUnsuccessful">#FC100D</color>
  8. <color name="colorNoData">#615F5F</color>
  9. </resources>

【strings.xml】

  1. <?xml version="1.0" encoding="utf-8"?>
  2. <resources>
  3. <string name="app_name">SpaceLaunches</string>
  4. <string name="successful">Successful</string>
  5. <string name="unsuccessful">Unsuccessful</string>
  6. <string name="no_data">No data</string>
  7. <string name="launch_year_field">Launch year: %s</string>
  8. <string name="mission_name_field">Launch name: %s</string>
  9. <string name="launch_success_field">Launch success: %s</string>
  10. <string name="details_field">Launch details: %s</string>
  11. </resources>

【styles.xml】

  1. <resources>
  2. <!-- Base application theme. -->
  3. <style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
  4. <!-- Customize your theme here. -->
  5. <item name="colorPrimary">@color/colorPrimary</item>
  6. <item name="colorPrimaryDark">@color/colorPrimaryDark</item>
  7. <item name="colorAccent">@color/colorAccent</item>
  8. </style>
  9. </resources>
  1. Complete the implementation of the RecyclerView.Adapter:

    1. class LaunchesRvAdapter(var launches: List<RocketLaunch>) : RecyclerView.Adapter<LaunchesRvAdapter.LaunchViewHolder>() {
    2. // ...
    3. inner class LaunchViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
    4. private val missionNameTextView = itemView.findViewById<TextView>(R.id.missionName)
    5. private val launchYearTextView = itemView.findViewById<TextView>(R.id.launchYear)
    6. private val launchSuccessTextView = itemView.findViewById<TextView>(R.id.launchSuccess)
    7. private val missionDetailsTextView = itemView.findViewById<TextView>(R.id.details)
    8. fun bindData(launch: RocketLaunch) {
    9. val ctx = itemView.context
    10. missionNameTextView.text = ctx.getString(R.string.mission_name_field, launch.missionName)
    11. launchYearTextView.text = ctx.getString(R.string.launch_year_field, launch.launchYear.toString())
    12. missionDetailsTextView.text = ctx.getString(R.string.details_field, launch.details ?: "")
    13. val launchSuccess = launch.launchSuccess
    14. if (launchSuccess != null ) {
    15. if (launchSuccess) {
    16. launchSuccessTextView.text = ctx.getString(R.string.successful)
    17. launchSuccessTextView.setTextColor((ContextCompat.getColor(itemView.context, R.color.colorSuccessful)))
    18. } else {
    19. launchSuccessTextView.text = ctx.getString(R.string.unsuccessful)
    20. launchSuccessTextView.setTextColor((ContextCompat.getColor(itemView.context, R.color.colorUnsuccessful)))
    21. }
    22. } else {
    23. launchSuccessTextView.text = ctx.getString(R.string.no_data)
    24. launchSuccessTextView.setTextColor((ContextCompat.getColor(itemView.context, R.color.colorNoData)))
    25. }
    26. }
    27. }
    28. }
  2. Update the MainActivity class as follows:

    1. class MainActivity : AppCompatActivity() {
    2. // ...
    3. private val launchesRvAdapter = LaunchesRvAdapter(listOf())
    4. override fun onCreate(savedInstanceState: Bundle?) {
    5. // ...
    6. launchesRecyclerView.adapter = launchesRvAdapter
    7. launchesRecyclerView.layoutManager = LinearLayoutManager(this)
    8. swipeRefreshLayout.setOnRefreshListener {
    9. swipeRefreshLayout.isRefreshing = false
    10. displayLaunches(true)
    11. }
    12. displayLaunches(false)
    13. }
    14. private fun displayLaunches(needReload: Boolean) {
    15. // TODO: Presentation logic
    16. }
    17. }

    Here you create an instance of LaunchesRvAdapter, configure the RecyclerView component, and implement all the LaunchesListView interface functions. To catch the screen refresh gesture, you add a listener to the SwipeRefreshLayout.

Implement the presentation logic

  1. Create an instance of the SpaceXSDK class from the shared module and inject an instance of DatabaseDriverFactory in it:

    1. class MainActivity : AppCompatActivity() {
    2. // ...
    3. private val sdk = SpaceXSDK(DatabaseDriverFactory(this))
    4. }
  2. Implement the private function displayLaunches(needReload: Boolean). It runs the getLaunches() function inside the coroutine launched in the main CoroutineScope, handles exceptions, and displays the error text in the toast message:

    1. class MainActivity : AppCompatActivity() {
    2. private val mainScope = MainScope()
    3. // ...
    4. override fun onDestroy() {
    5. super.onDestroy()
    6. mainScope.cancel()
    7. }
    8. // ...
    9. private fun displayLaunches(needReload: Boolean) {
    10. progressBarView.isVisible = true
    11. mainScope.launch {
    12. kotlin.runCatching {
    13. sdk.getLaunches(needReload)
    14. }.onSuccess {
    15. launchesRvAdapter.launches = it
    16. launchesRvAdapter.notifyDataSetChanged()
    17. }.onFailure {
    18. Toast.makeText(this@MainActivity, it.localizedMessage, Toast.LENGTH_SHORT).show()
    19. }
    20. progressBarView.isVisible = false
    21. }
    22. }
    23. }
  3. Select androidApp from the run configurations menu, choose an emulator, and click the run button:

Android application

You’ve just created an Android application that has its business logic implemented in the Kotlin Multiplatform Mobile module.

You can find the state of the project after this section on the final branch.

使用 Ktor 和 SQLDelight 创建多平台应用——教程 - 图16

Create the iOS application

For the iOS part of the project, you’ll make use of SwiftUI to build the user interface and the “Model View View-Model” pattern to connect the UI to the shared module, which contains all the business logic.

The shared module is already connected to the iOS project because the Android Studio plugin wizard has done all the configuration. You can import it the same way you would regular iOS dependencies: import shared.

Implement the UI

First, you’ll create a RocketLaunchRow SwiftUI view for displaying an item from the list. It will be based on the HStack and VStack views. There will be extensions on the RocketLaunchRow structure with useful helpers for displaying the data.

  1. Launch your Xcode app and select Open a project or file.
  2. Navigate to your project and select the iosApp folder. Click Open.
  3. In your Xcode project, create a new Swift file with the type SwiftUI View, name it RocketLaunchRow, and update it with the following code:

    1. import SwiftUI
    2. import shared
    3. struct RocketLaunchRow: View {
    4. var rocketLaunch: RocketLaunch
    5. var body: some View {
    6. HStack() {
    7. VStack(alignment: .leading, spacing: 10.0) {
    8. Text("Launch name: \(rocketLaunch.missionName)")
    9. Text(launchText).foregroundColor(launchColor)
    10. Text("Launch year: \(String(rocketLaunch.launchYear))")
    11. Text("Launch details: \(rocketLaunch.details ?? "")")
    12. }
    13. Spacer()
    14. }
    15. }
    16. }
    17. extension RocketLaunchRow {
    18. private var launchText: String {
    19. if let isSuccess = rocketLaunch.launchSuccess {
    20. return isSuccess.boolValue ? "Successful" : "Unsuccessful"
    21. } else {
    22. return "No data"
    23. }
    24. }
    25. private var launchColor: Color {
    26. if let isSuccess = rocketLaunch.launchSuccess {
    27. return isSuccess.boolValue ? Color.green : Color.red
    28. } else {
    29. return Color.gray
    30. }
    31. }
    32. }

    The list of launches will be displayed in the ContentView, which the project wizard has already created.

  4. Create a ViewModel class for the ContentView, which will prepare and manage the data. Declare it as an extension to the ContentView, as they are closely connected, and then add the following code to ContentView.swift:

    1. // ...
    2. extension ContentView {
    3. enum LoadableLaunches {
    4. case loading
    5. case result([RocketLaunch])
    6. case error(String)
    7. }
    8. @MainActor
    9. class ViewModel: ObservableObject {
    10. @Published var launches = LoadableLaunches.loading
    11. }
    12. }
    • The Combine framework connects the view model (ContentView.ViewModel) with the view (ContentView).
    • ContentView.ViewModel is declared as an ObservableObject and @Published wrapper is used for the launches property, so the view model will emit signals whenever this property changes.
  5. Implement the body of the ContentView file and display the list of launches:

    1. struct ContentView: View {
    2. @ObservedObject private(set) var viewModel: ViewModel
    3. var body: some View {
    4. NavigationView {
    5. listView()
    6. .navigationBarTitle("SpaceX Launches")
    7. .navigationBarItems(trailing:
    8. Button("Reload") {
    9. self.viewModel.loadLaunches(forceReload: true)
    10. })
    11. }
    12. }
    13. private func listView() -> AnyView {
    14. switch viewModel.launches {
    15. case .loading:
    16. return AnyView(Text("Loading...").multilineTextAlignment(.center))
    17. case .result(let launches):
    18. return AnyView(List(launches) { launch in
    19. RocketLaunchRow(rocketLaunch: launch)
    20. })
    21. case .error(let description):
    22. return AnyView(Text(description).multilineTextAlignment(.center))
    23. }
    24. }
    25. }

    The @ObservedObject property wrapper is used to subscribe to the view model.

  6. To make it compile, the RocketLaunch class needs to confirm the Identifiable protocol, as it is used as a parameter for initializing the List Swift UIView. The RocketLaunch class already has a property named id, so add the following to the bottom of ContentView.swift:

    1. extension RocketLaunch: Identifiable { }

Load the data

To retrieve the data about the rocket launches in the view model, you’ll need an instance of SpaceXSDK from the Multiplatform library.

  1. In ContentView.swift, pass it in through the constructor:

    1. extension ContentView {
    2. // ...
    3. @MainActor
    4. class ViewModel: ObservableObject {
    5. let sdk: SpaceXSDK
    6. @Published var launches = LoadableLaunches.loading
    7. init(sdk: SpaceXSDK) {
    8. self.sdk = sdk
    9. self.loadLaunches(forceReload: false)
    10. }
    11. func loadLaunches(forceReload: Bool) {
    12. // TODO: retrieve data
    13. }
    14. }
    15. }
  2. Call the getLaunches() function from the SpaceXSDK class and save the result in the launches property:

    1. func loadLaunches(forceReload: Bool) {
    2. Task {
    3. do {
    4. self.launches = .loading
    5. let launches = try await sdk.getLaunches(forceReload: forceReload)
    6. self.launches = .result(launches)
    7. } catch {
    8. self.launches = .error(error.localizedDescription)
    9. }
    10. }
    11. }
    • When you compile a Kotlin module into an Apple framework, suspending functions are available in it as functions with callbacks (completionHandler).
    • Since the getLaunches function is marked with the @Throws(Exception::class) annotation, any exceptions that are instances of the Exception class or its subclass will be propagated as NSError. Therefore, all such errors can be caught by the loadLaunches() function.
  3. Go to the entry point of the app, iOSApp.swift, and initialize the SDK, view, and view model:

    1. import SwiftUI
    2. import shared
    3. @main
    4. struct iOSApp: App {
    5. let sdk = SpaceXSDK(databaseDriverFactory: DatabaseDriverFactory())
    6. var body: some Scene {
    7. WindowGroup {
    8. ContentView(viewModel: .init(sdk: sdk))
    9. }
    10. }
    11. }
  4. In Android Studio, switch to the iosApp configuration, choose an emulator, and run it to see the result:

iOS Application

You can find the final version of the project on the final branch.

使用 Ktor 和 SQLDelight 创建多平台应用——教程 - 图18

下一步做什么?

This tutorial features some potentially resource-heavy operations, like parsing JSON and making requests to the database in the main thread. To learn about how to write concurrent code and optimize your app, see How to work with concurrency.

You can also check out these additional learning materials: