Skip to content

Ktor と SQLDelight を使用してマルチプラットフォームアプリを作成する

このチュートリアルではIntelliJ IDEAを使用しますが、Android Studioでも同様に実行できます。どちらのIDEもコア機能とKotlin Multiplatformサポートを共有しています。

このチュートリアルでは、IntelliJ IDEAを使用して、Kotlin MultiplatformでiOSおよびAndroid向けの高度なモバイルアプリケーションを作成する方法を示します。 このアプリケーションは以下を実行します。

  • Ktor を使用して、公開されている SpaceX API からインターネット経由でデータを取得する
  • SQLDelight を使用して、ローカルデータベースにデータを保存する
  • SpaceX ロケットの打ち上げリストを、打ち上げ日、結果、詳細な説明とともに表示する

アプリケーションには、iOSとAndroidの両方のプラットフォームで共有されるコードを含むモジュールが含まれます。ビジネスロジックとデータアクセスレイヤーは共有モジュールで一度だけ実装され、両方のアプリケーションのUIはネイティブになります。

Emulator and Simulator

プロジェクトでは、以下のマルチプラットフォームライブラリを使用します。

  • Ktor をHTTPクライアントとして使用し、インターネット経由でデータを取得します。
  • kotlinx.serialization を使用して、JSONレスポンスをエンティティクラスのオブジェクトにデシリアライズします。
  • kotlinx.coroutines を使用して、非同期コードを記述します。
  • SQLDelight を使用して、SQLクエリからKotlinコードを生成し、型安全なデータベースAPIを作成します。
  • Koin を使用して、依存性注入(DI)を介してプラットフォーム固有のデータベースドライバーを提供します。

テンプレートプロジェクト最終アプリケーションのソースコードは、当社のGitHubリポジトリで確認できます。

プロジェクトを作成する

  1. クイックスタートで、Kotlin Multiplatform開発のための環境をセットアップするの手順を完了します。

  2. IntelliJ IDEAで、File | New | Projectを選択します。

  3. 左側のパネルで、Kotlin Multiplatformを選択します(Android Studioでは、テンプレートはNew ProjectウィザードのGenericタブにあります)。

  4. New Projectウィンドウで以下のフィールドを指定します。

    • Name: SpaceTutorial
    • Group: com.jetbrains
    • Artifact: spacetutorial

    Create Ktor and SQLDelight Multiplatform project

  5. AndroidおよびiOSターゲットを選択します。

  6. iOSの場合、Do not share UIオプションを選択します。両方のプラットフォームでネイティブUIを実装します。

  7. すべてのフィールドとターゲットを指定したら、Createをクリックします。

Gradleの依存関係を追加する

共有モジュールにマルチプラットフォームライブラリを追加するには、build.gradle.ktsファイルの関連するソースセットのdependencies {}ブロックに依存関係の指示(implementation)を追加する必要があります。

kotlinx.serializationとSQLDelightの両ライブラリには、追加の設定も必要です。

gradle/libs.versions.tomlファイルのバージョンカタログを変更または追加して、必要なすべての依存関係を反映させます。

  1. [versions]ブロックで、AGPバージョンを確認し、残りを追加します。

    [versions]
    agp = "8.7.3"
    ...
    coroutinesVersion = "1.10.2"
    dateTimeVersion = "0.6.2"
    koin = "4.1.0"
    ktor = "3.2.3"
    sqlDelight = "2.1.0"
    lifecycleViewmodelCompose = "2.9.1"
    material3 = "1.3.2"
  2. [libraries]ブロックで、以下のライブラリ参照を追加します。

    [libraries]
    ...
    android-driver = { module = "app.cash.sqldelight:android-driver", version.ref = "sqlDelight" }
    koin-androidx-compose = { module = "io.insert-koin:koin-androidx-compose", version.ref = "koin" }
    koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" }
    kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutinesVersion" }
    kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "dateTimeVersion" }
    ktor-client-android = { module = "io.ktor:ktor-client-android", version.ref = "ktor" }
    ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
    ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
    ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
    ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
    native-driver = { module = "app.cash.sqldelight:native-driver", version.ref = "sqlDelight" }
    runtime = { module = "app.cash.sqldelight:runtime", version.ref = "sqlDelight" }
    androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleViewmodelCompose" }
    androidx-compose-material3 = { module = "androidx.compose.material3:material3", version.ref="material3" }
  3. [plugins]ブロックで、必要なGradleプラグインを指定します。

    [plugins]
    ...
    kotlinxSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
    sqldelight = { id = "app.cash.sqldelight", version.ref = "sqlDelight" }
  4. 依存関係が追加されたら、プロジェクトの再同期を求められます。Sync Gradle ChangesボタンをクリックしてGradleファイルを同期します。 Synchronize Gradle files

  5. shared/build.gradle.ktsファイルの冒頭に、以下の行をplugins {}ブロックに追加します。

    kotlin
    plugins {
        // ...
        alias(libs.plugins.kotlinxSerialization)
        alias(libs.plugins.sqldelight)
    }
  6. 共通ソースセットには、各ライブラリのコアアーティファクトと、ネットワークリクエストおよびレスポンスを処理するためにkotlinx.serializationを使用するKtorのシリアライズ機能が必要です。 iOSおよびAndroidのソースセットには、SQLDelightとKtorのプラットフォームドライバーも必要です。

    同じshared/build.gradle.ktsファイルに、必要なすべての依存関係を追加します。

    kotlin
    kotlin {
        // ...
    
        sourceSets {
            commonMain.dependencies {
                implementation(libs.kotlinx.coroutines.core)
                implementation(libs.ktor.client.core)
                implementation(libs.ktor.client.content.negotiation)
                implementation(libs.ktor.serialization.kotlinx.json)
                implementation(libs.runtime)
                implementation(libs.kotlinx.datetime)
                implementation(libs.koin.core)
            }
            androidMain.dependencies {
                implementation(libs.ktor.client.android)
                implementation(libs.android.driver)
            }
            iosMain.dependencies {
                implementation(libs.ktor.client.darwin)
                implementation(libs.native.driver)
            }
        }
    }
  7. 依存関係を追加したら、再度Sync Gradle ChangesボタンをクリックしてGradleファイルを同期します。

Gradleの同期後、プロジェクトの設定は完了し、コードを記述し始めることができます。

マルチプラットフォームの依存関係に関する詳細ガイドについては、Kotlin Multiplatformライブラリの依存関係を参照してください。

アプリケーションのデータモデルを作成する

チュートリアルアプリには、ネットワーキングサービスとキャッシュサービスのファサードとして公開されているSpaceXSDKクラスが含まれます。 アプリケーションのデータモデルには、以下の3つのエンティティクラスが含まれます。

  • 打ち上げに関する一般情報
  • ミッションパッチ画像のリンク
  • 打ち上げに関する記事のURL

このデータがすべて最終的にUIに表示されるわけではありません。 シリアライズをデモンストレーションするためにデータモデルを使用しています。 しかし、リンクやパッチをいじって、例をより情報量の多いものに拡張することもできます!

必要なデータクラスを作成します。

  1. shared/src/commonMain/kotlin/com/jetbrains/spacetutorialディレクトリに、entityパッケージを作成し、その中にEntity.ktファイルを作成します。

  2. 基本的なエンティティのすべてのデータクラスを宣言します。

    kotlin

各シリアライズ可能なクラスは、@Serializableアノテーションでマークする必要があります。kotlinx.serializationプラグインは、アノテーション引数にシリアライザへのリンクを明示的に渡さない限り、@Serializableクラスのデフォルトのシリアライザを自動的に生成します。

@SerialNameアノテーションを使用すると、フィールド名を再定義でき、データクラスのプロパティをより読みやすい識別子でアクセスするのに役立ちます。

SQLDelightを設定し、キャッシュロジックを実装する

SQLDelightを設定する

SQLDelightライブラリは、SQLクエリから型安全なKotlinデータベースAPIを生成できます。コンパイル中、ジェネレーターはSQLクエリを検証し、共有モジュールで使用できるKotlinコードに変換します。

SQLDelightの依存関係はすでにプロジェクトに含まれています。ライブラリを設定するには、shared/build.gradle.ktsファイルを開き、最後にsqldelight {}ブロックを追加します。このブロックには、データベースとそのパラメータのリストが含まれています。

kotlin
sqldelight {
    databases {
        create("AppDatabase") {
            packageName.set("com.jetbrains.spacetutorial.cache")
        }
    }
}

packageNameパラメータは、生成されるKotlinソースのパッケージ名を指定します。

プロンプトが表示されたらGradleプロジェクトファイルを同期するか、ダブルを押してSync All Gradle, Swift Package Manager projectsを検索します。

.sqファイルを操作するために、公式のSQLDelightプラグインをインストールすることを検討してください。

データベースAPIを生成する

まず、必要なすべてのSQLクエリを含む.sqファイルを作成します。デフォルトでは、SQLDelightプラグインはソースセットのsqldelightフォルダー内で.sqファイルを検索します。

  1. shared/src/commonMainディレクトリに、新しいsqldelightディレクトリを作成します。

  2. sqldelightディレクトリ内に、パッケージのネストされたディレクトリを作成するためにcom/jetbrains/spacetutorial/cacheという名前の新しいディレクトリを作成します。

  3. cacheディレクトリ内に、AppDatabase.sqファイル(build.gradle.ktsファイルで指定したデータベースと同じ名前)を作成します。 アプリケーションのすべてのSQLクエリはこのファイルに保存されます。

  4. データベースには、打ち上げに関するデータを含むテーブルが含まれます。 テーブルを作成するための以下のコードをAppDatabase.sqファイルに追加します。

    text
    import kotlin.Boolean;
    
    CREATE TABLE Launch (
        flightNumber INTEGER NOT NULL,
        missionName TEXT NOT NULL,
        details TEXT,
        launchSuccess INTEGER AS Boolean DEFAULT NULL,
        launchDateUTC TEXT NOT NULL,
        patchUrlSmall TEXT,
        patchUrlLarge TEXT,
        articleUrl TEXT
    );
  5. テーブルにデータを挿入するためのinsertLaunch関数を追加します。

    text
    insertLaunch:
    INSERT INTO Launch(flightNumber, missionName, details, launchSuccess, launchDateUTC, patchUrlSmall, patchUrlLarge, articleUrl)
    VALUES(?, ?, ?, ?, ?, ?, ?, ?);
  6. テーブルのデータをクリアするためのremoveAllLaunches関数を追加します。

    text
    removeAllLaunches:
    DELETE FROM Launch;
  7. データ取得のためのselectAllLaunchesInfo関数を宣言します。

    text
    selectAllLaunchesInfo:
    SELECT Launch.*
    FROM Launch;
  8. 対応するAppDatabaseインターフェース(後でデータベースドライバーで初期化する)を生成します。 そのためには、ターミナルで以下のコマンドを実行します。

    shell
    ./gradlew generateCommonMainAppDatabaseInterface

    生成されたKotlinコードはshared/build/generated/sqldelightディレクトリに保存されます。

プラットフォーム固有のデータベースドライバーのファクトリを作成する

AppDatabaseインターフェースを初期化するためには、SqlDriverインスタンスを渡します。 SQLDelightはSQLiteドライバーの複数のプラットフォーム固有の実装を提供します。そのため、各プラットフォームでこれらのインスタンスを個別に作成する必要があります。

expectedおよびactualインターフェースでこれを実現できますが、このプロジェクトではKoinを使用してKotlin Multiplatformで依存性注入を試します。

  1. データベースドライバー用のインターフェースを作成します。そのためには、shared/src/commonMain/kotlin/com/jetbrains/spacetutorial/ディレクトリに、cacheパッケージを作成します。

  2. cacheパッケージ内にDatabaseDriverFactoryインターフェースを作成します。

    kotlin
    package com.jetbrains.spacetutorial.cache
    
    import app.cash.sqldelight.db.SqlDriver
    
    interface DatabaseDriverFactory {
        fun createDriver(): SqlDriver
    }
  3. Android用にこのインターフェースを実装するクラスを作成します。shared/src/androidMain/kotlinディレクトリに、com.jetbrains.spacetutorial.cacheパッケージを作成し、その中にDatabaseDriverFactory.ktファイルを作成します。

  4. Androidでは、SQLiteドライバーはAndroidSqliteDriverクラスによって実装されます。DatabaseDriverFactory.ktファイルで、データベース情報とコンテキストリンクをAndroidSqliteDriverクラスのコンストラクタに渡します。

    kotlin
    package com.jetbrains.spacetutorial.cache
    
    import android.content.Context
    import app.cash.sqldelight.db.SqlDriver
    import app.cash.sqldelight.driver.android.AndroidSqliteDriver
    
    class AndroidDatabaseDriverFactory(private val context: Context) : DatabaseDriverFactory {
        override fun createDriver(): SqlDriver {
            return AndroidSqliteDriver(AppDatabase.Schema, context, "launch.db")
        }
    }
  5. iOSの場合、shared/src/iosMain/kotlin/com/jetbrains/spacetutorial/ディレクトリに、cacheパッケージを作成します。

  6. cacheパッケージ内に、DatabaseDriverFactory.ktファイルを作成し、以下のコードを追加します。

    kotlin
    package com.jetbrains.spacetutorial.cache
    
    import app.cash.sqldelight.db.SqlDriver
    import app.cash.sqldelight.driver.native.NativeSqliteDriver
    
    class IOSDatabaseDriverFactory : DatabaseDriverFactory {
        override fun createDriver(): SqlDriver {
            return NativeSqliteDriver(AppDatabase.Schema, "launch.db")
        }
    }

これらのドライバーのインスタンスは、後でプロジェクトのプラットフォーム固有のコードで実装します。

キャッシュを実装する

これまで、プラットフォームデータベースドライバーのファクトリと、データベース操作を実行するためのAppDatabaseインターフェースを追加しました。 次に、AppDatabaseインターフェースをラップし、キャッシングロジックを含むDatabaseクラスを作成します。

  1. 共通ソースセットshared/src/commonMain/kotlin内、com.jetbrains.spacetutorial.cacheパッケージに、新しいDatabaseクラスを作成します。これには、両プラットフォームに共通のロジックが含まれます。

  2. AppDatabaseのドライバーを提供するために、抽象的なDatabaseDriverFactoryインスタンスをDatabaseクラスのコンストラクタに渡します。

    kotlin
    package com.jetbrains.spacetutorial.cache
    
    internal class Database(databaseDriverFactory: DatabaseDriverFactory) {
        private val database = AppDatabase(databaseDriverFactory.createDriver())
        private val dbQuery = database.appDatabaseQueries
    }

    このクラスの可視性internalに設定されており、これはマルチプラットフォームモジュール内からのみアクセス可能であることを意味します。

  3. Databaseクラス内に、いくつかのデータ処理操作を実装します。 まず、すべてのロケット打ち上げのリストを返すgetAllLaunches関数を作成します。 mapLaunchSelecting関数は、データベースクエリの結果をRocketLaunchオブジェクトにマッピングするために使用されます。

    kotlin
    import com.jetbrains.spacetutorial.entity.Links
    import com.jetbrains.spacetutorial.entity.Patch
    import com.jetbrains.spacetutorial.entity.RocketLaunch
    
    internal class Database(databaseDriverFactory: DatabaseDriverFactory) {
        // ...
    
        internal fun getAllLaunches(): List<RocketLaunch> {
            return dbQuery.selectAllLaunchesInfo(::mapLaunchSelecting).executeAsList()
        }
    
        private fun mapLaunchSelecting(
            flightNumber: Long,
            missionName: String,
            details: String?,
            launchSuccess: Boolean?,
            launchDateUTC: String,
            patchUrlSmall: String?,
            patchUrlLarge: String?,
            articleUrl: String?
        ): RocketLaunch {
            return RocketLaunch(
                flightNumber = flightNumber.toInt(),
                missionName = missionName,
                details = details,
                launchDateUTC = launchDateUTC,
                launchSuccess = launchSuccess,
                links = Links(
                    patch = Patch(
                        small = patchUrlSmall,
                        large = patchUrlLarge
                    ),
                    article = articleUrl
                )
            )
        }
    }
  4. データベースをクリアし、新しいデータを挿入するためのclearAndCreateLaunches関数を追加します。

    kotlin
    internal class Database(databaseDriverFactory: DatabaseDriverFactory) {
        // ...
    
        internal fun clearAndCreateLaunches(launches: List<RocketLaunch>) {
            dbQuery.transaction {
                dbQuery.removeAllLaunches()
                launches.forEach { launch ->
                    dbQuery.insertLaunch(
                        flightNumber = launch.flightNumber.toLong(),
                        missionName = launch.missionName,
                        details = launch.details,
                        launchSuccess = launch.launchSuccess ?: false,
                        launchDateUTC = launch.launchDateUTC,
                        patchUrlSmall = launch.links.patch?.small,
                        patchUrlLarge = launch.links.patch?.large,
                        articleUrl = launch.links.article
                    )
                }
            }
        }
    }

APIサービスを実装する

インターネット経由でデータを取得するため、SpaceX 公開APIと、v5/launchesエンドポイントからすべての打ち上げリストを取得する単一のメソッドを使用します。

アプリケーションをAPIに接続するクラスを作成します。

  1. shared/src/commonMain/kotlin/com/jetbrains/spacetutorial/ディレクトリに、networkパッケージを作成します。

  2. networkディレクトリ内に、SpaceXApiクラスを作成します。

    kotlin
    package com.jetbrains.spacetutorial.network
    
    import io.ktor.client.HttpClient
    import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
    import io.ktor.serialization.kotlinx.json.json
    import kotlinx.serialization.json.Json
    
    class SpaceXApi {
        private val httpClient = HttpClient {
            install(ContentNegotiation) {
                json(Json {
                    ignoreUnknownKeys = true
                    useAlternativeNames = false 
                })
            }
        }
    }

    このクラスは、ネットワークリクエストを実行し、JSONレスポンスをcom.jetbrains.spacetutorial.entityパッケージのエンティティにデシリアライズします。 KtorのHttpClientインスタンスは、httpClientプロパティを初期化し、格納します。

    このコードは、KtorのContentNegotiationプラグインを使用して、GETリクエストの結果をデシリアライズします。このプラグインは、リクエストとレスポンスのペイロードをJSONとして処理し、必要に応じてそれらをシリアライズおよびデシリアライズします。

  3. ロケット打ち上げのリストを返すデータ取得関数を宣言します。

    kotlin
    import com.jetbrains.spacetutorial.entity.RocketLaunch
    import io.ktor.client.request.get
    import io.ktor.client.call.body
    
    class SpaceXApi {
        // ...
        
        suspend fun getAllLaunches(): List<RocketLaunch> {
            return httpClient.get("https://api.spacexdata.com/v5/launches").body()
        }
    }

getAllLaunches関数にはsuspend修飾子が付いています。これは、suspend関数HttpClient.get()の呼び出しが含まれているためです。 get()関数には、インターネット経由でデータを取得する非同期操作が含まれており、コルーチンまたは別のsuspend関数からのみ呼び出すことができます。ネットワークリクエストはHTTPクライアントのスレッドプールで実行されます。

GETリクエストを送信するためのURLは、get()関数への引数として渡されます。

SDKを構築する

iOSおよびAndroidアプリケーションは、共有モジュールを介してSpaceX APIと通信します。このモジュールは、公開クラスSpaceXSDKを提供します。

  1. 共通ソースセットshared/src/commonMain/kotlin内、com.jetbrains.spacetutorialパッケージに、SpaceXSDKクラスを作成します。 このクラスは、DatabaseおよびSpaceXApiクラスのファサードになります。

    Databaseクラスのインスタンスを作成するには、DatabaseDriverFactoryインスタンスを提供します。

    kotlin
    package com.jetbrains.spacetutorial
    
    import com.jetbrains.spacetutorial.cache.Database
    import com.jetbrains.spacetutorial.cache.DatabaseDriverFactory
    import com.jetbrains.spacetutorial.network.SpaceXApi
    
    class SpaceXSDK(databaseDriverFactory: DatabaseDriverFactory, val api: SpaceXApi) { 
        private val database = Database(databaseDriverFactory)
    }

    SpaceXSDKクラスのコンストラクタを介して、プラットフォーム固有のコードで正しいデータベースドライバーを注入します。

  2. 作成されたデータベースとAPIを使用して打ち上げリストを取得するgetLaunches関数を追加します。

    kotlin
    import com.jetbrains.spacetutorial.entity.RocketLaunch
    
    class SpaceXSDK(databaseDriverFactory: DatabaseDriverFactory, val api: SpaceXApi) {
        // ...
    
        @Throws(Exception::class)
        suspend fun getLaunches(forceReload: Boolean): List<RocketLaunch> {
            val cachedLaunches = database.getAllLaunches()
            return if (cachedLaunches.isNotEmpty() && !forceReload) {
                cachedLaunches
            } else {
                api.getAllLaunches().also {
                    database.clearAndCreateLaunches(it)
                }
            }
        }
    }

このクラスには、すべての打ち上げ情報を取得するための関数が1つ含まれています。forceReloadの値に応じて、キャッシュされた値を返すか、インターネットからデータをロードし、その結果でキャッシュを更新します。キャッシュされたデータがない場合、forceReloadフラグの値に関係なく、インターネットからデータをロードします。

SDKのクライアントは、forceReloadフラグを使用して、打ち上げに関する最新情報をロードし、ユーザー向けにプルツーリフレッシュジェスチャーを有効にすることができます。

すべてのKotlin例外はチェックされない(unchecked)例外ですが、Swiftにはチェックされるエラー(checked errors)しかありません(詳細についてはSwift/Objective-Cとの相互運用性を参照)。したがって、Swiftコードが予期される例外を認識できるようにするため、Swiftから呼び出されるKotlin関数は、潜在的な例外クラスのリストを指定して@Throwsアノテーションでマークされるべきです。

Androidアプリケーションを作成する

IntelliJ IDEAは初期のGradle設定を自動的に処理するため、sharedモジュールはAndroidアプリケーションにすでに接続されています。

UIとプレゼンテーションロジックを実装する前に、composeApp/build.gradle.ktsファイルに必要なすべてのUI依存関係を追加します。

kotlin
kotlin {
// ...
    sourceSets {
        androidMain.dependencies {
            implementation(libs.androidx.compose.material3)
            implementation(libs.koin.androidx.compose)
            implementation(libs.androidx.lifecycle.viewmodel.compose)
        }
        // ... 
    }
}

プロンプトが表示されたらGradleプロジェクトファイルを同期するか、ダブルを押してSync All Gradle, Swift Package Manager projectsを検索します。

インターネットアクセス許可を追加する

インターネットにアクセスするため、Androidアプリケーションには適切な許可が必要です。 composeApp/src/androidMain/AndroidManifest.xmlファイルに、<uses-permission>タグを追加します。

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

依存性注入を追加する

Koinの依存性注入を使用すると、異なるコンテキストで使用できるモジュール(コンポーネントのセット)を宣言できます。 このプロジェクトでは、2つのモジュールを作成します。1つはAndroidアプリケーション用、もう1つはiOSアプリ用です。 次に、対応するモジュールを使用して各ネイティブUIに対してKoinを開始します。

Androidアプリのコンポーネントを含むKoinモジュールを宣言します。

  1. composeApp/src/androidMain/kotlinディレクトリに、com.jetbrains.spacetutorialパッケージにAppModule.ktファイルを作成します。

    そのファイルで、SpaceXApiクラスとSpaceXSDKクラス用の2つのシングルトンとしてモジュールを宣言します。

    kotlin
    package com.jetbrains.spacetutorial
    
    import com.jetbrains.spacetutorial.cache.AndroidDatabaseDriverFactory
    import com.jetbrains.spacetutorial.network.SpaceXApi
    import org.koin.android.ext.koin.androidContext
    import org.koin.dsl.module
    
    val appModule = module { 
        single<SpaceXApi> { SpaceXApi() }
        single<SpaceXSDK> {
            SpaceXSDK(
                databaseDriverFactory = AndroidDatabaseDriverFactory(
                    androidContext()
                ), api = get()
            )
        }
    }

    SpaceXSDKクラスのコンストラクタには、プラットフォーム固有のAndroidDatabaseDriverFactoryクラスが注入されます。 get()関数はモジュール内の依存関係を解決します。SpaceXSDK()apiパラメータの代わりに、Koinは以前に宣言されたSpaceXApiシングルトンを渡します。

  2. Koinモジュールを開始するカスタムApplicationクラスを作成します。

    AppModule.ktファイルの隣に、Application.ktファイルを以下のコードで作成し、modules()関数呼び出しで宣言したモジュールを指定します。

    kotlin
    package com.jetbrains.spacetutorial
    
    import android.app.Application
    import org.koin.android.ext.koin.androidContext
    import org.koin.core.context.GlobalContext.startKoin
    
    class MainApplication : Application() {
        override fun onCreate() {
            super.onCreate()
    
            startKoin {
                androidContext(this@MainApplication)
                modules(appModule)
            }
        }
    }
  3. 作成したMainApplicationクラスを、AndroidManifest.xmlファイルの<application>タグに指定します。

    xml
    <manifest xmlns:android="http://schemas.android.com/apk/res/android">
        ...
        <application
            ...
            android:name="com.jetbrains.spacetutorial.MainApplication">
            ...
        </application>
    </manifest>

これで、プラットフォーム固有のデータベースドライバーによって提供される情報を使用するUIを実装する準備ができました。

打ち上げリストを含むビューモデルを準備する

Jetpack ComposeとMaterial 3を使用してAndroid UIを実装します。まず、SDKを使用して打ち上げリストを取得するビューモデルを作成します。次に、マテリアルテーマを設定し、最後に、これらすべてをまとめるコンポーザブル関数を記述します。

  1. composeApp/src/androidMainソースセット、com.jetbrains.spacetutorialパッケージに、RocketLaunchViewModel.ktファイルを作成します。

    kotlin
    package com.jetbrains.spacetutorial
    
    import androidx.compose.runtime.State
    import androidx.compose.runtime.mutableStateOf
    import androidx.lifecycle.ViewModel
    import com.jetbrains.spacetutorial.entity.RocketLaunch
    
    class RocketLaunchViewModel(private val sdk: SpaceXSDK) : ViewModel() {
        private val _state = mutableStateOf(RocketLaunchScreenState())
        val state: State<RocketLaunchScreenState> = _state
    
    }
    
    data class RocketLaunchScreenState(
        val isLoading: Boolean = false,
        val launches: List<RocketLaunch> = emptyList()
    )

    RocketLaunchScreenStateインスタンスは、SDKから受信したデータとリクエストの現在の状態を格納します。

  2. このビューモデルのコルーチンスコープでSDKのgetLaunches関数を呼び出すloadLaunches関数を追加します。

    kotlin
    import androidx.lifecycle.viewModelScope
    import kotlinx.coroutines.launch
    
    class RocketLaunchViewModel(private val sdk: SpaceXSDK) : ViewModel() {
        //...
        
        fun loadLaunches() {
            viewModelScope.launch { 
                _state.value = _state.value.copy(isLoading = true, launches = emptyList())
                try {
                    val launches = sdk.getLaunches(forceReload = true)
                    _state.value = _state.value.copy(isLoading = false, launches = launches)
                } catch (e: Exception) {
                    _state.value = _state.value.copy(isLoading = false, launches = emptyList())
                }
            }
        }
    }
  3. 次に、RocketLaunchViewModelオブジェクトが作成されるとすぐにAPIからデータを要求するように、クラスのinit {}ブロックにloadLaunches()呼び出しを追加します。

    kotlin
    class RocketLaunchViewModel(private val sdk: SpaceXSDK) : ViewModel() {
        // ...
    
        init {
            loadLaunches()
        }
    }
  4. 次に、AppModule.ktファイルで、Koinモジュールにビューモデルを指定します。

    kotlin
    import org.koin.core.module.dsl.viewModel
    
    val appModule = module {
        // ...
        viewModel { RocketLaunchViewModel(sdk = get()) }
    }

マテリアルテーマを構築する

メインのApp()コンポーザブルは、マテリアルテーマが提供するAppTheme関数をベースに構築します。

  1. Material Theme Builderを使用して、Composeアプリのテーマを生成できます。 色とフォントを選択し、右下隅にあるExport themeをクリックします。

  2. エクスポート画面で、Exportドロップダウンをクリックし、**Jetpack Compose (Theme.kt)**オプションを選択します。

  3. アーカイブを解凍し、themeフォルダーをcomposeApp/src/androidMain/kotlin/com/jetbrains/spacetutorialディレクトリにコピーします。

    theme directory location

  4. themeパッケージ内の各ファイルで、作成したパッケージを参照するようにpackage行を変更します。

    kotlin
    package com.jetbrains.spacetutorial.theme
  5. Color.ktファイルに、成功した打ち上げと失敗した打ち上げに使用する2つの色変数を追加します。

    kotlin
    val app_theme_successful = Color(0xff4BB543)
    val app_theme_unsuccessful = Color(0xffFC100D)

プレゼンテーションロジックを実装する

アプリケーションのメインのApp()コンポーザブルを作成し、ComponentActivityクラスからそれを呼び出します。

  1. com.jetbrains.spacetutorialパッケージにあるthemeディレクトリの隣のApp.ktファイルを開き、デフォルトのApp()コンポーザブル関数を置き換えます。

    kotlin
    package com.jetbrains.spacetutorial
    
    import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
    import androidx.compose.runtime.Composable
    import androidx.compose.runtime.getValue
    import androidx.compose.runtime.mutableStateOf
    import androidx.compose.runtime.remember
    import androidx.compose.runtime.rememberCoroutineScope
    import androidx.compose.runtime.setValue
    import org.jetbrains.compose.ui.tooling.preview.Preview
    import org.koin.androidx.compose.koinViewModel
    import androidx.compose.material3.ExperimentalMaterial3Api
    
    @OptIn(
      ExperimentalMaterial3Api::class
    )
    @Composable
    @Preview
    fun App() {
        val viewModel = koinViewModel<RocketLaunchViewModel>()
        val state by remember { viewModel.state }
        val coroutineScope = rememberCoroutineScope()
        var isRefreshing by remember { mutableStateOf(false) }
        val pullToRefreshState = rememberPullToRefreshState()
    }

    ここでは、Koin ViewModel APIを使用して、Android Koinモジュールで宣言したviewModelを参照しています。

  2. 次に、ローディング画面、打ち上げ結果の列、およびプルツーリフレッシュアクションを実装するUIコードを追加します。

    kotlin
    package com.jetbrains.spacetutorial
    
    import androidx.compose.foundation.layout.Arrangement
    import androidx.compose.foundation.layout.Column
    import androidx.compose.foundation.layout.Spacer
    import androidx.compose.foundation.layout.fillMaxSize
    import androidx.compose.foundation.layout.height
    import androidx.compose.foundation.layout.padding
    import androidx.compose.foundation.lazy.LazyColumn
    import androidx.compose.foundation.lazy.items
    import androidx.compose.material3.HorizontalDivider
    import androidx.compose.material3.MaterialTheme
    import androidx.compose.material3.Scaffold
    import androidx.compose.material3.Text
    import androidx.compose.material3.TopAppBar
    import androidx.compose.material3.pulltorefresh.PullToRefreshBox
    import androidx.compose.ui.Alignment
    import androidx.compose.ui.Modifier
    import androidx.compose.ui.unit.dp
    import com.jetbrains.spacetutorial.entity.RocketLaunch
    import com.jetbrains.spacetutorial.theme.AppTheme
    import com.jetbrains.spacetutorial.theme.app_theme_successful
    import com.jetbrains.spacetutorial.theme.app_theme_unsuccessful
    import kotlinx.coroutines.launch
    ...
    
    @OptIn(
        ExperimentalMaterial3Api::class
    )
    @Composable
    @Preview
    fun App() {
        val viewModel = koinViewModel<RocketLaunchViewModel>()
        val state by remember { viewModel.state }
        val coroutineScope = rememberCoroutineScope()
        var isRefreshing by remember { mutableStateOf(false) }
        val pullToRefreshState = rememberPullToRefreshState()
    
        AppTheme {
            Scaffold(
                topBar = {
                    TopAppBar(
                        title = {
                            Text(
                                "SpaceX Launches",
                                style = MaterialTheme.typography.headlineLarge
                            )
                        }
                    )
                }
            ) { padding ->
                PullToRefreshBox(
                    modifier = Modifier
                        .fillMaxSize()
                        .padding(padding),
                    state = pullToRefreshState,
                    isRefreshing = isRefreshing,
                    onRefresh = {
                        isRefreshing = true
                        coroutineScope.launch {
                            viewModel.loadLaunches()
                            isRefreshing = false
                        }
                    }
                ) {
                    if (state.isLoading && !isRefreshing) {
                        Column(
                            verticalArrangement = Arrangement.Center,
                            horizontalAlignment = Alignment.CenterHorizontally,
                            modifier = Modifier.fillMaxSize()
                        ) {
                            Text("Loading...", style = MaterialTheme.typography.bodyLarge)
                        }
                    } else {
                        LazyColumn {
                            items(state.launches) { launch: RocketLaunch ->
                                Column(modifier = Modifier.padding(16.dp)) {
                                    Text(
                                        text = "${launch.missionName} - ${launch.launchYear}",
                                        style = MaterialTheme.typography.headlineSmall
                                    )
                                    Spacer(Modifier.height(8.dp))
                                    Text(
                                        text = if (launch.launchSuccess == true) "Successful" else "Unsuccessful",
                                        color = if (launch.launchSuccess == true) app_theme_successful else app_theme_unsuccessful
                                    )
                                    Spacer(Modifier.height(8.dp))
                                    val details = launch.details
                                    if (details != null && details.isNotBlank()) {
                                        Text(details)
                                    }
                                }
                                HorizontalDivider()
                            }
                        }
                    }
                }
            }
        }
    }
  3. 最後に、MainActivityクラスをAndroidManifest.xmlファイルの<activity>タグに指定します。

    xml
    <manifest xmlns:android="http://schemas.android.com/apk/res/android">
        ...
        <application
            ...
            <activity
                ...
                android:name="com.jetbrains.spacetutorial.MainActivity">
                ...
            </activity>
        </application>
    </manifest>
  4. Androidアプリを実行します。実行構成メニューからcomposeAppを選択し、エミュレーターを選んで、実行ボタンをクリックします。 アプリはAPIリクエストを自動的に実行し、打ち上げリストを表示します(背景色は、生成したMaterial Themeによって異なります)。

    Android application

これで、ビジネスロジックがKotlin Multiplatformモジュールで実装され、UIがネイティブのJetpack Composeを使用して作成されたAndroidアプリケーションが完成しました。

iOSアプリケーションを作成する

プロジェクトのiOS部分では、ユーザーインターフェースを構築するためにSwiftUIと、Model View View-Model(MVVM)パターンを利用します。

IntelliJ IDEAは、共有モジュールにすでに接続されているiOSプロジェクトを生成します。Kotlinモジュールは、shared/build.gradle.ktsファイルで指定された名前(baseName = "Shared")でエクスポートされ、通常のimport Sharedステートメントを使用してインポートされます。

SQLDelight用の動的リンクフラグを追加する

デフォルトでは、IntelliJ IDEAはiOSフレームワークの静的リンク用に設定されたプロジェクトを生成します。

iOSでネイティブのSQLDelightドライバーを使用するためには、Xcodeツールがシステム提供のSQLiteバイナリを見つけられるようにする動的リンカーフラグを追加します。

  1. IntelliJ IDEAで、File | Open Project in Xcodeオプションを選択してXcodeでプロジェクトを開きます。
  2. Xcodeで、プロジェクト名をダブルクリックして設定を開きます。
  3. Build Settingsタブに切り替え、Other Linker Flagsフィールドを検索します。
  4. フィールドの値をダブルクリックし、**+**をクリックして-lsqlite3文字列を追加します。

iOS依存性注入用のKoinクラスを準備する

SwiftコードでKoinのクラスと関数を使用するためには、特別なKoinComponentクラスを作成し、iOS用のKoinモジュールを宣言します。

  1. shared/src/iosMain/kotlin/ソースセットに、cacheフォルダーの隣にcom/jetbrains/spacetutorial/KoinHelper.ktという名前のファイルを作成します。

  2. SpaceXSDKクラスを怠惰なKoinインジェクションでラップするKoinHelperクラスを追加します。

    kotlin
    package com.jetbrains.spacetutorial
    
    import org.koin.core.component.KoinComponent
    import com.jetbrains.spacetutorial.entity.RocketLaunch
    import org.koin.core.component.inject
    
    class KoinHelper : KoinComponent {
        private val sdk: SpaceXSDK by inject<SpaceXSDK>()
    
        suspend fun getLaunches(forceReload: Boolean): List<RocketLaunch> {
            return sdk.getLaunches(forceReload = forceReload)
        }
    }
  3. KoinHelperクラスの後ろに、SwiftでiOS Koinモジュールを初期化および開始するために使用するinitKoin関数を追加します。

    kotlin
    import com.jetbrains.spacetutorial.cache.IOSDatabaseDriverFactory
    import com.jetbrains.spacetutorial.network.SpaceXApi
    import org.koin.core.context.startKoin
    import org.koin.dsl.module
    
    fun initKoin() {
        startKoin {
            modules(module {
                single<SpaceXApi> { SpaceXApi() }
                single<SpaceXSDK> {
                    SpaceXSDK(
                        databaseDriverFactory = IOSDatabaseDriverFactory(), api = get()
                    )
                }
            })
        }
    }

これで、iOSアプリでKoinモジュールを開始し、共通のSpaceXSDKクラスでネイティブデータベースドライバーを使用できます。

UIを実装する

まず、リストから項目を表示するためのRocketLaunchRow SwiftUIビューを作成します。これはHStackビューとVStackビューに基づいています。データを表示するのに役立つヘルパーを備えたRocketLaunchRow構造体に対する拡張機能があります。

  1. IntelliJ IDEAで、Projectビューにいることを確認します。

  2. iosAppフォルダーに、ContentView.swiftの隣に新しいSwiftファイルを作成し、RocketLaunchRowという名前を付けます。

  3. RocketLaunchRow.swiftファイルを以下のコードで更新します。

    Swift
    import SwiftUI
    import Shared
    
    struct RocketLaunchRow: View {
        var rocketLaunch: RocketLaunch
    
        var body: some View {
            HStack() {
                VStack(alignment: .leading, spacing: 10.0) {
                    Text("\(rocketLaunch.missionName) - \(String(rocketLaunch.launchYear))").font(.system(size: 18)).bold()
                    Text(launchText).foregroundColor(launchColor)
                    Text("Launch year: \(String(rocketLaunch.launchYear))")
                    Text("\(rocketLaunch.details ?? "")")
                }
                Spacer()
            }
        }
    }
    
    extension RocketLaunchRow {
        private var launchText: String {
            if let isSuccess = rocketLaunch.launchSuccess {
                return isSuccess.boolValue ? "Successful" : "Unsuccessful"
            } else {
                return "No data"
            }
        }
    
        private var launchColor: Color {
            if let isSuccess = rocketLaunch.launchSuccess {
                return isSuccess.boolValue ? Color.green : Color.red
            } else {
                return Color.gray
            }
        }
    }

    打ち上げリストはContentViewビューに表示されます。これはすでにプロジェクトに含まれています。

  4. データを準備および管理するViewModelクラスを含むContentViewクラスへの拡張を作成します。 ContentView.swiftファイルに以下のコードを追加します。

    Swift
    extension ContentView {
        enum LoadableLaunches {
            case loading
            case result([RocketLaunch])
            case error(String)
        }
        
        @MainActor
        class ViewModel: ObservableObject {
            @Published var launches = LoadableLaunches.loading
        }
    }

    ビューモデル(ContentView.ViewModel)は、Combineフレームワークを介してビュー(ContentView)と接続します。

    • ContentView.ViewModelクラスはObservableObjectとして宣言されます。
    • launchesプロパティには@Published属性が使用されているため、このプロパティが変更されるたびにビューモデルがシグナルを発行します。
  5. ContentView_Previews構造体を削除します。ビューモデルと互換性のあるプレビューを実装する必要はありません。

  6. ContentViewクラスのボディを更新して、打ち上げリストを表示し、リロード機能を追加します。

    • これはUIの基礎です。チュートリアルの次のフェーズでloadLaunches関数を実装します。
    • viewModelプロパティは、ビューモデルを購読するために@ObservedObject属性でマークされています。
    swift
    struct ContentView: View {
        @ObservedObject private(set) var viewModel: ViewModel
    
        var body: some View {
            NavigationView {
                listView()
                .navigationBarTitle("SpaceX Launches")
                .navigationBarItems(trailing:
                    Button("Reload") {
                        self.viewModel.loadLaunches(forceReload: true)
                })
            }
        }
    
        private func listView() -> AnyView {
            switch viewModel.launches {
            case .loading:
                return AnyView(Text("Loading...").multilineTextAlignment(.center))
            case .result(let launches):
                return AnyView(List(launches) { launch in
                    RocketLaunchRow(rocketLaunch: launch)
                })
            case .error(let description):
                return AnyView(Text(description).multilineTextAlignment(.center))
            }
        }
    }
  7. RocketLaunchクラスはListビューを初期化するためのパラメータとして使用されるため、[Identifiable`プロトコルに準拠](https://developer.apple.com/documentation/swift/identifiable)する必要があります。 クラスにはすでに`id`という名前のプロパティがあるため、`ContentView.swift`の最後に拡張機能を追加するだけで済みます。

    Swift
    extension RocketLaunch: Identifiable { }

データをロードする

ビューモデルでロケット打ち上げに関するデータを取得するためには、MultiplatformライブラリのKoinHelperクラスのインスタンスが必要です。 これにより、正しいデータベースドライバーでSDK関数を呼び出すことができます。

  1. ContentView.swiftファイルで、ViewModelクラスを拡張して、KoinHelperオブジェクトとloadLaunches関数を含めます。

    Swift
    extension ContentView {
        // ...
        @MainActor
        class ViewModel: ObservableObject {
            // ...
            let helper: KoinHelper = KoinHelper()
    
            init() {
                self.loadLaunches(forceReload: false)
            }
    
            func loadLaunches(forceReload: Bool) {
                // TODO: retrieve data
            }
        }
    }
  2. KoinHelper.getLaunches()関数(SpaceXSDKクラスへの呼び出しをプロキシする)を呼び出し、結果をlaunchesプロパティに保存します。

    Swift
    func loadLaunches(forceReload: Bool) {
        Task {
            do {
                self.launches = .loading
                let launches = try await helper.getLaunches(forceReload: forceReload)
                self.launches = .result(launches)
            } catch {
                self.launches = .error(error.localizedDescription)
            }
        }
    }

    KotlinモジュールをAppleフレームワークにコンパイルすると、サスペンド関数はSwiftのasync/awaitメカニズムを使用して呼び出すことができます。

    getLaunches関数はKotlinで@Throws(Exception::class)アノテーションでマークされているため、Exceptionクラスまたはそのサブクラスのインスタンスである例外はすべて、NSErrorとしてSwiftに伝播されます。 したがって、そのような例外はすべてloadLaunches()関数で捕捉できます。

  3. アプリのエントリーポイントであるiOSApp.swiftファイルに移動し、Koinモジュール、ビュー、およびビューモデルを初期化します。

    Swift
    import SwiftUI
    import Shared
    
    @main
    struct iOSApp: App {
        init() {
            KoinHelperKt.doInitKoin()
        }
        
        var body: some Scene {
            WindowGroup {
                ContentView(viewModel: .init())
            }
        }
    }
  4. IntelliJ IDEAで、iosApp構成に切り替え、エミュレーターを選択し、実行して結果を確認します。

iOS Application

プロジェクトの最終バージョンは、finalブランチで確認できます。

次は何をしますか?

このチュートリアルでは、メインスレッドでのJSON解析やデータベースへのリクエスト作成など、リソースを大量に消費する可能性のある操作が含まれています。並行コードの書き方やアプリの最適化について学ぶには、コルーチンガイドを参照してください。

さらに、以下の学習資料も確認できます。