Kotlin Nativeで共有プロパティとプラットフォーム固有実装を両立する方法

Kotlin Nativeでプラットフォーム固有のロジックを持ちながら、共通プロパティを適切に管理することは、マルチプラットフォーム開発において重要です。
KotlinはJava仮想マシン(JVM)だけでなく、iOSやLinux、WebAssemblyなど様々な環境で動作するマルチプラットフォーム言語として注目されています。
特にKotlin Nativeを使用すると、プラットフォームのネイティブコードを直接生成できるため、iOSやAndroidで同じコードを共有しつつ、特定のプラットフォーム向けに最適化された処理を実装できます。

本記事では、Kotlin Nativeを使ってプロパティを共有しつつ、プラットフォームごとに異なる実装を行う方法について詳しく解説します。
expect/actualキーワードを用いた実装方法や、プロジェクトの構成方法、実践的なサンプルコードなどを通して、効率的にKotlin Nativeを活用する方法を学びます。

これにより、マルチプラットフォームプロジェクトのコード量を削減しつつ、柔軟にプラットフォーム固有の機能を実現できるようになります。

目次

Kotlin Nativeとは


Kotlin Nativeは、Kotlinのコードをネイティブバイナリにコンパイルし、Java仮想マシン(JVM)を必要とせずに実行できる技術です。これにより、iOS、Windows、Linux、WebAssemblyなど、JVMが動作しない環境でもKotlinのコードを直接実行できます。

Kotlin Nativeはマルチプラットフォームプロジェクトの一部として利用されることが多く、AndroidやiOSなど異なるプラットフォーム間でロジックを共有するのに最適です。特に、ビジネスロジックやデータ処理を共通化しつつ、UIなどのプラットフォーム固有部分をネイティブコードで記述することが可能になります。

Kotlin Nativeの主な特長

  • JVM不要:ネイティブバイナリを直接生成するため、JVMのインストールが不要です。
  • プラットフォーム間でコード共有:共通ロジックをKotlinで記述し、プラットフォーム固有の実装部分のみ別途記述可能です。
  • 高速な実行:ネイティブコードにコンパイルされるため、パフォーマンスが向上します。
  • 完全な相互運用性:iOSではSwift/Objective-C、LinuxやWindowsではC/C++などのコードと連携できます。

利用シーン

  • iOSとAndroidの両方に対応したアプリ開発
  • CLIツールやサーバーサイドアプリケーションの構築
  • パフォーマンス重視の処理をKotlinで記述し、CやSwiftと連携

Kotlin Nativeは、クロスプラットフォーム開発を効率化し、保守性の高いアプリケーションを構築する強力なツールとして注目されています。

プロパティ共有の基本概念


Kotlin Nativeでは、プラットフォーム間でコードを共有する際に、プロパティの共有が重要になります。
プロパティを共通化することで、ビジネスロジックやデータモデルなどの非UIロジックを一度記述するだけで、iOSやAndroidなど複数のプラットフォームで同じコードを再利用できます。

プロパティ共有の仕組み


Kotlinではexpectactualというキーワードを使うことで、プロパティのインターフェースを共通化しながら、各プラットフォームごとに異なる具体的な実装を提供できます。

例えば、アプリの設定項目(Configuration)をプラットフォームごとに異なる方法で保存する場合、共通のプロパティ定義は以下のようになります。

// 共通コード (sharedモジュール)
expect class Configuration() {
    var userToken: String
}

このコードは各プラットフォームで異なるuserTokenの取得・保存方法を持たせることが可能です。

プロパティ共有の利点

  • コードの一貫性:プラットフォームごとに異なる実装が必要でも、共通のインターフェースを維持できます。
  • 保守性の向上:共通ロジックを一元管理でき、バグの修正や機能追加が容易になります。
  • 開発効率の向上:重複するコードを削減し、開発速度を向上させます。

プロパティ共有の具体例


AndroidではSharedPreferences、iOSではNSUserDefaultsを使う例を考えます。
これをexpect/actualを用いて以下のように記述します。

// iOS実装 (iosMain)
actual class Configuration {
    actual var userToken: String
        get() = NSUserDefaults.standardUserDefaults.stringForKey("userToken") ?: ""
        set(value) {
            NSUserDefaults.standardUserDefaults.setObject(value, "userToken")
        }
}

// Android実装 (androidMain)
actual class Configuration {
    private val prefs = PreferenceManager.getDefaultSharedPreferences(context)

    actual var userToken: String
        get() = prefs.getString("userToken", "") ?: ""
        set(value) {
            prefs.edit().putString("userToken", value).apply()
        }
}

このように、プロパティの定義を共通化しつつ、実装はプラットフォームに合わせて記述できるため、コードの再利用性が高まり、保守性も向上します。

プラットフォーム固有実装の必要性


マルチプラットフォーム開発では、多くのロジックを共有できますが、UI、デバイスAPI、ストレージなどはプラットフォームごとに異なるため、固有の実装が求められます。
特にKotlin Nativeを使用する際は、ネイティブAPIにアクセスするためにプラットフォーム固有のコードを記述する必要があります。

固有実装が必要な場面

  1. UIの描画
    AndroidではActivityFragmentを使いますが、iOSではUIViewControllerが必要になります。これらは共通化が難しく、固有の実装が不可欠です。
  2. デバイス機能の利用
    カメラ、GPS、Bluetoothなどのデバイス機能は、OSごとにAPIが異なります。例えば、カメラを起動する処理はAndroidではCameraXを使いますが、iOSではAVFoundationが必要です。
  3. データの永続化
    AndroidではSharedPreferences、iOSではNSUserDefaultsが一般的に使われます。これらのストレージAPIは同じ役割を果たしますが、使い方や型が異なります。

共通化と固有実装の使い分け


すべてのコードを共通化するのではなく、ビジネスロジックやネットワーク通信などは共有し、UIやストレージ部分は固有の実装に分けることで、保守性と効率が向上します。

  • 共通化する部分
  • ビジネスロジック
  • データモデル
  • ネットワーク通信(Ktorなど)
  • アルゴリズムや計算処理
  • プラットフォーム固有実装が必要な部分
  • UIレイヤー
  • ネイティブAPIへのアクセス
  • センサーやカメラなどのハードウェア操作

プラットフォーム固有実装の実例


AndroidとiOSで異なる通知機能を実装する例を見てみましょう。
共通部分は以下の通りです。

expect class Notifier {
    fun sendNotification(message: String)
}

AndroidとiOSの固有部分は以下のように実装します。

Androidの実装

actual class Notifier(private val context: Context) {
    actual fun sendNotification(message: String) {
        val builder = NotificationCompat.Builder(context, "default")
            .setContentTitle("通知")
            .setContentText(message)
            .setSmallIcon(R.drawable.ic_notification)
        NotificationManagerCompat.from(context).notify(1, builder.build())
    }
}

iOSの実装

actual class Notifier {
    actual fun sendNotification(message: String) {
        val content = UNMutableNotificationContent().apply {
            title = "通知"
            body = message
        }
        val request = UNNotificationRequest.requestWithIdentifier(
            "default", content, UNTimeIntervalNotificationTrigger.triggerWithTimeInterval(1.0, false)
        )
        UNUserNotificationCenter.currentNotificationCenter().addNotificationRequest(request, null)
    }
}

このように、プラットフォーム固有のコードで機能を実装し、共通のインターフェースで呼び出すことで、マルチプラットフォーム開発が容易になります。

expect/actualの仕組み


Kotlinのexpect/actualキーワードは、プラットフォームごとに異なる実装を提供しつつ、共通のインターフェースを保持する仕組みです。これにより、同じ名前のプロパティや関数を異なるプラットフォームでそれぞれの方式で実装できます。

このアプローチは、プラットフォーム固有のAPIを活用する場合や、UIやストレージなど異なるシステムを操作する際に役立ちます。
expectで共通の定義を行い、actualでプラットフォームに応じた具体的な実装を提供する形になります。

expect/actualの基本構造


expectは共通コードに記述し、プラットフォームに依存しないインターフェースを定義します。
actualは各プラットフォームごとのコードに記述し、expectで定義したものを実際に実装します。

例:デバイス名を取得する場合

共通コード (sharedモジュール):

expect class DeviceInfo() {
    fun getDeviceName(): String
}

Android実装 (androidMain):

actual class DeviceInfo {
    actual fun getDeviceName(): String {
        return Build.MODEL
    }
}

iOS実装 (iosMain):

import platform.UIKit.UIDevice

actual class DeviceInfo {
    actual fun getDeviceName(): String {
        return UIDevice.currentDevice.name
    }
}

expect/actualを使うメリット

  • コードの再利用:共通部分は一度書くだけで済み、固有部分は最小限の実装で済みます。
  • 型安全性の維持:インターフェースが統一されているため、型安全性を維持したままプラットフォーム固有処理を記述できます。
  • 保守性の向上:ロジックの大部分を共有できるため、修正が必要な場合も共通コードに対して行うだけで済みます。

expect/actualの使用例


データストレージの例
AndroidではSharedPreferences、iOSではNSUserDefaultsを使い、それぞれでデータを保存します。

共通コード (sharedモジュール):

expect class Settings() {
    fun save(key: String, value: String)
    fun get(key: String): String?
}

Android実装 (androidMain):

actual class Settings(context: Context) {
    private val prefs = PreferenceManager.getDefaultSharedPreferences(context)

    actual fun save(key: String, value: String) {
        prefs.edit().putString(key, value).apply()
    }

    actual fun get(key: String): String? {
        return prefs.getString(key, null)
    }
}

iOS実装 (iosMain):

import platform.Foundation.NSUserDefaults

actual class Settings {
    actual fun save(key: String, value: String) {
        NSUserDefaults.standardUserDefaults.setObject(value, key)
    }

    actual fun get(key: String): String? {
        return NSUserDefaults.standardUserDefaults.stringForKey(key)
    }
}

このように、プラットフォームごとに異なるAPIをactualで記述し、共通ロジックをexpectで記述することで、シームレスなマルチプラットフォーム開発が可能になります。

プロパティ共有と固有実装の例


Kotlin Nativeでは、プラットフォーム間でプロパティを共有しつつ、プラットフォーム固有の実装を柔軟に切り替えることができます。ここでは、アプリケーション設定(Configuration)のプロパティを例に、expect/actualを使った具体的な実装方法を紹介します。

共通プロパティの定義


アプリケーションが保持するユーザートークン(userToken)を、AndroidではSharedPreferences、iOSではNSUserDefaultsで管理するケースを考えます。

共通コード (sharedモジュール):

expect class Configuration() {
    var userToken: String
}


このコードで、userTokenプロパティの存在は共通化されていますが、実際の保存方法はプラットフォームごとに異なります。

Androidでの実装


AndroidではSharedPreferencesを使ってuserTokenを保存します。

Android実装 (androidMain):

import android.content.Context
import android.preference.PreferenceManager

actual class Configuration(context: Context) {
    private val prefs = PreferenceManager.getDefaultSharedPreferences(context)

    actual var userToken: String
        get() = prefs.getString("userToken", "") ?: ""
        set(value) {
            prefs.edit().putString("userToken", value).apply()
        }
}

iOSでの実装


iOSではNSUserDefaultsを利用して同様にuserTokenを保存します。

iOS実装 (iosMain):

import platform.Foundation.NSUserDefaults

actual class Configuration {
    actual var userToken: String
        get() = NSUserDefaults.standardUserDefaults.stringForKey("userToken") ?: ""
        set(value) {
            NSUserDefaults.standardUserDefaults.setObject(value, "userToken")
        }
}

プロパティを利用する


共通コードでは、プラットフォームを意識せずにプロパティを利用できます。

共通コード (sharedモジュール):

fun saveToken(token: String) {
    val config = Configuration()
    config.userToken = token
}

fun getToken(): String {
    val config = Configuration()
    return config.userToken
}

コードの流れ

  1. Configurationクラスはexpectで宣言され、各プラットフォームでactualが実装されます。
  2. 共通ロジックはsaveTokengetTokenで記述し、プラットフォームに応じた実際の挙動はactualで決まります。
  3. userTokenプロパティを更新・取得するコードは、共通コード内からシームレスに呼び出されます。

実行結果の例

  • Androidアプリでトークンを保存するとSharedPreferencesに保存されます。
  • iOSアプリで同じコードを実行すると、NSUserDefaultsに保存されます。

このように、プロパティ共有を実現しながら、プラットフォーム固有のロジックを柔軟に切り替えることができます。

プロジェクト構成とビルド設定


Kotlin Multiplatform(KMP)プロジェクトでは、共通コードとプラットフォーム固有コードを効率的に管理するために、プロジェクトの構成とビルド設定が重要です。
Kotlin Nativeを活用する際は、Gradleを使って各プラットフォームのターゲットを設定し、ソースセットを分けて管理します。

プロジェクトの基本構成


Kotlin Multiplatformプロジェクトは、以下のようなディレクトリ構成になります。

/my-kotlin-native-app
├── build.gradle.kts
├── settings.gradle.kts
└── src
    ├── commonMain            // 共通コード
    │   └── kotlin
    │       └── Configuration.kt
    ├── commonTest            // 共通テストコード
    ├── androidMain           // Android固有コード
    │   └── kotlin
    │       └── Configuration.kt
    ├── iosMain               // iOS固有コード
    │   └── kotlin
    │       └── Configuration.kt
    └── iosSimulatorArm64Main // iOSシミュレーター用コード
        └── kotlin

Gradle設定


Kotlin Multiplatformでは、build.gradle.ktsでターゲットプラットフォームを指定します。以下は、AndroidとiOS向けのプロジェクト設定例です。

plugins {
    kotlin("multiplatform")
}

kotlin {
    androidTarget()              // Android向け
    iosArm64()                   // iOS向け(実機)
    iosX64()                     // iOSシミュレーター向け
    iosSimulatorArm64()          // Appleシリコン向けシミュレーター

    sourceSets {
        val commonMain by getting {
            dependencies {
                implementation(kotlin("stdlib"))
            }
        }
        val androidMain by getting
        val iosMain by creating {
            dependsOn(commonMain)
        }
        val iosSimulatorArm64Main by getting {
            dependsOn(iosMain)
        }
    }
}

ポイント解説

  • androidTarget():Android向けのバイナリを生成します。
  • iosArm64() / iosX64():iOSの実機およびシミュレーター向けバイナリを生成します。
  • sourceSetscommonMainで共通コードを定義し、androidMainiosMainで固有の実装を記述します。

iOS用ビルド設定


Kotlin NativeでiOS向けのバイナリを生成する際は、Xcodeと連携する必要があります。

  1. Kotlin Nativeのビルドコマンド
./gradlew linkDebugFrameworkIos


このコマンドでiOS向けのフレームワークが生成され、Xcodeプロジェクトでリンク可能になります。

  1. Xcodeプロジェクトへの組み込み
    build/bin/ios/debugFrameworkに生成されたMyApp.frameworkをXcodeに追加します。

Android用ビルド設定


Android用のビルドは通常のGradleタスクと同様に実行されます。

./gradlew assembleDebug

マルチプラットフォームのビルド


両プラットフォームを同時にビルドする場合は、以下のコマンドでまとめて実行できます。

./gradlew build

注意点

  • iOSシミュレーターと実機で異なるターゲットを用意する必要があります。
  • iosMainiosX64iosArm64の両方で使えるようにすることで、コードの重複を防げます。
  • AndroidとiOSで異なるネイティブライブラリを利用する場合は、ソースセットで分岐します。

このように、Kotlin Nativeプロジェクトは、共通コードを最大限に活用しつつ、プラットフォーム固有のコードを適切に管理する構成が求められます。

トラブルシューティングとデバッグ


Kotlin Nativeでの開発では、プラットフォーム固有の問題コンパイルエラーが発生することがあります。これらの問題を迅速に特定し、解決するためには、トラブルシューティングの基本を理解しておく必要があります。ここでは、共通のエラーとその対処法を解説します。

1. expect/actualの不一致エラー


エラー例:

Expected class 'Configuration' has no corresponding actual declaration in module 'androidMain'


原因:
expectで宣言されたクラスに対して、actualの実装が不足している、または不一致が発生しています。

対処法:

  1. 各プラットフォームのsrcフォルダを確認し、actualが正しく実装されているか確認します。
  2. 実装が不足している場合は、新たにactualクラスを追加します。
  3. クラス名や関数のシグネチャが一致しているかを確認します。

例:

// 共通コード (sharedMain)
expect class Configuration() {
    var userToken: String
}

// Android固有実装
actual class Configuration(context: Context) {
    private val prefs = PreferenceManager.getDefaultSharedPreferences(context)

    actual var userToken: String
        get() = prefs.getString("userToken", "") ?: ""
        set(value) {
            prefs.edit().putString("userToken", value).apply()
        }
}

2. プラットフォームAPIの呼び出しエラー


エラー例:

Unresolved reference: platform.UIKit


原因:
iOS固有のAPIをAndroidコードで参照している場合に発生します。

対処法:

  • プラットフォーム固有のAPIは、actualクラス内でのみ参照します。共通コード(commonMain)には記述しません。
  • expectで定義した関数の実装は、iosMainandroidMainに分けて記述します。

例:

// iOS固有コード
actual class Configuration {
    actual var userToken: String
        get() = NSUserDefaults.standardUserDefaults.stringForKey("userToken") ?: ""
        set(value) {
            NSUserDefaults.standardUserDefaults.setObject(value, "userToken")
        }
}

3. iOSでのビルドエラー


エラー例:

Undefined symbols for architecture x86_64


原因:
iOSシミュレーター向けのビルド設定が不足している可能性があります。iosX64iosSimulatorArm64ターゲットが正しく設定されているか確認します。

対処法:
build.gradle.ktsに以下のようにターゲットを追加します。

kotlin {
    iosX64()  // iOSシミュレーター用
    iosArm64()  // iOS実機用
    iosSimulatorArm64()  // Appleシリコン用
}

4. 実行時のクラッシュ


エラー例:

IllegalStateException: Uninitialized property


原因:
lateinitプロパティが初期化されていない状態でアクセスされています。

対処法:

  1. プロパティの初期化をinitブロックやby lazyを使って安全に行います。
  2. 実行前に必ずプロパティが初期化されていることを確認します。

例:

actual class Configuration(context: Context) {
    private val prefs by lazy {
        PreferenceManager.getDefaultSharedPreferences(context)
    }

    actual var userToken: String
        get() = prefs.getString("userToken", "") ?: ""
        set(value) {
            prefs.edit().putString("userToken", value).apply()
        }
}

5. デバッグ方法

  • Androidデバッグ:
    通常のAndroid Studioのデバッガを使ってデバッグします。Log.dなどのログ出力も活用できます。
  • iOSデバッグ:
    Xcodeのデバッガを使用してlldbでブレークポイントを設定します。printlnでログを出力することも可能です。
println("User Token: ${Configuration().userToken}")

このように、エラーの種類に応じたトラブルシューティングを行うことで、Kotlin Nativeでの開発がスムーズになります。

実際の応用例と演習


Kotlin Nativeを使ったマルチプラットフォーム開発では、実際のアプリケーション開発で多くの利点があります。ここでは、共通プロパティとプラットフォーム固有実装を組み合わせた簡単な設定管理アプリを例に、応用例と演習を紹介します。

アプリの概要


このアプリは、ユーザーがテーマ(ライト/ダーク)を選択し、その設定をデバイスごとに保存します。AndroidではSharedPreferences、iOSではNSUserDefaultsを使って設定を保持します。

要件

  • 共通コードでテーマ選択ロジックを記述する。
  • プラットフォームごとに異なるストレージ方法を採用する。
  • 設定は「ライト」「ダーク」の2つのテーマを保持する。

1. 共通コードの実装


まず、ThemeConfigというクラスをexpectで定義します。

共通コード (commonMain):

expect class ThemeConfig() {
    var selectedTheme: String
}

2. Androidの実装


AndroidではSharedPreferencesを使います。

Android実装 (androidMain):

import android.content.Context
import android.preference.PreferenceManager

actual class ThemeConfig(context: Context) {
    private val prefs = PreferenceManager.getDefaultSharedPreferences(context)

    actual var selectedTheme: String
        get() = prefs.getString("theme", "light") ?: "light"
        set(value) {
            prefs.edit().putString("theme", value).apply()
        }
}

3. iOSの実装


iOSではNSUserDefaultsを使用します。

iOS実装 (iosMain):

import platform.Foundation.NSUserDefaults

actual class ThemeConfig {
    actual var selectedTheme: String
        get() = NSUserDefaults.standardUserDefaults.stringForKey("theme") ?: "light"
        set(value) {
            NSUserDefaults.standardUserDefaults.setObject(value, "theme")
        }
}

4. テーマの切り替えロジック


共通コードでテーマを切り替える関数を作成します。

共通コード (commonMain):

fun toggleTheme(config: ThemeConfig) {
    config.selectedTheme = if (config.selectedTheme == "light") "dark" else "light"
    println("Theme switched to: ${config.selectedTheme}")
}

5. アプリの実行例


各プラットフォームでテーマを切り替えてみましょう。

Android

val config = ThemeConfig(context)
toggleTheme(config)

iOS

val config = ThemeConfig()
toggleTheme(config)

演習問題

  1. 通知音の設定を保持するクラスNotificationConfigを作成し、AndroidではRingtoneManager、iOSではAVFoundationを使って音を設定する仕組みを実装してください。
  2. 設定値が保存されているかどうかをユニットテストで検証するコードを記述してみましょう。
  3. プラットフォームごとに異なるAPI(例:AndroidのToast、iOSのUIAlert)を使って、テーマ変更時にユーザーに通知を表示する処理を追加してください。

まとめ


この演習を通して、プラットフォーム間でロジックを共有しつつ、異なるストレージ方法を使い分ける方法が理解できたと思います。Kotlin Nativeを活用することで、コードの重複を避けつつ、プラットフォーム固有の処理を柔軟に実装できます。

まとめ


本記事では、Kotlin Nativeを活用してプロパティを共有しつつ、プラットフォーム固有の実装を行う方法について解説しました。
expect/actualを利用することで、共通ロジックを維持しながら、各プラットフォームのAPIに適した具体的な処理を実装できることが理解できたかと思います。

特に、テーマ設定の切り替えや、ストレージへのデータ保存など、実際のアプリケーション開発に役立つサンプルを通じて、Kotlin Nativeの強力な柔軟性を確認できました。

Kotlin Multiplatformを使うことで、

  • 開発効率の向上
  • コードの再利用性の向上
  • プラットフォーム間の一貫性の確保
    が可能となり、モバイルアプリ開発がさらに加速します。

今後は、さらに複雑なプラットフォーム固有の処理や、ネットワーク通信、データベース操作などを共通化しつつ、Kotlin Nativeの可能性を広げてみてください。

コメント

コメントする

目次