以下の内容はhttps://www.m3tech.blog/entry/kmp-platform-logic-diより取得しました。


Kotlin Multiplatform (KMP) でプラットフォーム固有の実装をcommonMainで扱う2つのアプローチ

記事タイトルから生成したイメージ。ポップな感じでお願いしました。

【マルチデバイスチーム ブログリレー2日目】

マルチデバイスチームでモバイルアプリエンジニアをやっている小林 (@bakobox)です。

マルチデバイスチームでは複数のアプリを開発していますが、一部のアプリではKotlin Multiplatform (以下KMP)を使ってロジックの共通化を行っています。KMPを使ってアプリを開発していると、プラットフォーム固有のコードを扱わなければならない場面が必ず出てきます。プラットフォーム固有の実装をcommonMainからどのように扱えるようにするかは、KMPプロジェクトにおける重要な設計課題の1つです。

本記事では、この課題に対処するための主要なアプローチである、

  1. expect/actualを使った方法
  2. DIフレームワークの管理に乗せる方法

についてご紹介いたします。また2つ目の方法では、iOS側の実装をSwiftで行う方法もご紹介いたします。

1. expect/actualを使った方法

KMPが提供するexpect/actualは、commonMainでプラットフォーム固有のAPIを利用するための基本的な仕組みです。

commonMainでexpectキーワードを付けて関数やプロパティを宣言し、androidMainやiosMainといったプラットフォーム固有のソースセットでactualキーワードを付けてexpect宣言に対する具体的な実装を提供することで、プラットフォーム固有のロジックをcommonMainから扱うことが可能になります。

プロパティ

commonMain

expect val platformName: String

androidMain

actual val platformName: String = "Android"

iosMain

actual val platformName: String = "iOS"

これで、commonMainのソースからはplatformNameというプロパティを参照するだけで、実行されているプラットフォームに応じた値を取得できます。

関数

commonMain

expect fun generateUUID(): String

androidMain

import java.util.UUID

actual fun generateUUID(): String = UUID.randomUUID().toString()

iosMain

import platform.Foundation.NSUUID

actual fun generateUUID(): String = NSUUID().UUIDString()

関数も、プロパティとほぼ同様です。androidMain, iosMainではプラットフォーム固有のAPIが利用できるため、それを用いてUUIDを生成しています。

インタフェース

interfaceとfactoryメソッドを使ったパターンも考えられます。

commonMain

interface Logger {
    fun log(message: String)
}

expect fun createLogger(): Logger

androidMain

import android.util.Log

class AndroidLogger : Logger {
    override fun log(message: String) {
        Log.d("MyKMPApp", message)
    }
}

actual fun createLogger(): Logger = AndroidLogger()

iosMain

import platform.Foundation.NSLog

class IOSLogger : Logger {
    override fun log(message: String) {
        NSLog("MyKMPApp: %@", message)
    }
}

actual fun createLogger(): Logger = IOSLogger()

Loggerインタフェースを定義し、インスタンスを取得するcreateLogger()関数を用意しています。commonMainからは createLogger().log("Hello KMP!") のように呼び出すだけで、各プラットフォームのログ機構が利用されます。

以上が比較的単純なケースです。もう少し複雑なパターンを考えてみましょう。例えば、Androidの固有APIだとContextが必要になることが多々あります。この場合、どこからContextを渡せば良いのでしょうか? 例として、バッテリー残量を取得する機能を持つBatteryInfoを考えてみます。

commonMain

interface BatteryInfo {
    fun getBatteryLevel(): Int
}

expect fun createBatteryInfo(): BatteryInfo

androidMain

object ContextHolder {
    private lateinit var _context: Context
    val context: Context
        get() = _context

    val isInitialized: Boolean
        get() = ::_context.isInitialized

    fun initialize(context: Context) {
        _context = context.applicationContext
    }
}

class AndroidBatteryInfo(private val context: Context): BatteryInfo {
    override fun getBatteryLevel(): Int {
        val batteryStatus: Intent? = IntentFilter(Intent.ACTION_BATTERY_CHANGED).let { ifilter ->
            context.registerReceiver(null, ifilter)
        }
        val level: Int = batteryStatus?.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) ?: -1
        val scale: Int = batteryStatus?.getIntExtra(BatteryManager.EXTRA_SCALE, -1) ?: -1

        return if (level == -1 || scale == -1 || scale == 0) {
            -1
        } else {
            (level.toFloat() / scale.toFloat() * 100.0f).roundToInt()
        }
    }
}

actual fun createBatteryInfo(): BatteryInfo {
    if (!ContextHolder.isInitialized) {
        throw IllegalStateException("ContextHolder not initialized.")
    }
    return AndroidBatteryInfo(context = ContextHolder.context)
}

こちらの手法では、ContextHolderを介してAndroidBatteryInfoにContextを渡しています。AndroidではBatteryInfoのインスタンス生成前(ApplicationのonCreate()とか)にContextHolder.initialize(Context)を呼び、contextをセットしておく必要があります。

iosMain

class IosBatteryInfo: BatteryInfo {
    override fun getBatteryLevel(): Int {
        UIDevice.currentDevice.batteryMonitoringEnabled = true
        val batteryLevel = UIDevice.currentDevice.batteryLevel
        UIDevice.currentDevice.batteryMonitoringEnabled = false

        return (batteryLevel * 100).roundToInt()
    }
}

actual fun createBatteryInfo(): BatteryInfo = IosBatteryInfo()

以上が、Context問題を解決する方法の1つです。

アプローチ1のまとめ

定数値や単純な関数の場合、手軽かつ効率的に実装できるという利点があります。 しかし、最後に示したようにプラットフォーム固有の依存性がある場合、その取り扱いには注意が必要です。applicationContextを使用しているとはいえ、Contextをシングルトンとして保持する設計は一般的に推奨されません。 さらに、expect/actualを多用しすぎるとテストが複雑化したり、コードの見通しが悪くなる恐れがあります。

2. Dependency Injection (DI)フレームワークの管理に乗せる方法

もう一つのアプローチが、DIフレームワークを利用する方法です。

多くのアプリのプロジェクトで、疎結合な設計を構築するためにDIフレームワークを採用していると思います。

DIを導入することで、テスト時と本番時で異なる依存を注入できるようにし、テスタビリティが向上するといったメリットが一般的にはあると思います。

KMPの文脈だと、DIを用いることによってプラットフォーム固有の実装詳細をインタフェースを使って隠蔽し、適切なプラットフォームの固有実装を注入することにも役立ちます。

commonMainでインタフェースを定義する

まず、commonMainにプラットフォーム固有の実装を抽象化するinterfaceを作成します。
先ほどの例と同様に、BatteryInfoを考えてみます。

interface BatteryInfo {
    fun getBatteryLevel(): Int
}

プラットフォーム固有のソースセットでインタフェースを実装する

次に、各プラットフォーム固有のソースセットで、BatteryInfoの具象クラスを実装します。

androidMain

class AndroidBatteryInfo(private val context: Context): BatteryInfo {
    override fun getBatteryLevel(): Int {
        // ...
    }
}

iosMain

class IosBatteryInfo: BatteryInfo {
    override fun getBatteryLevel(): Int {
        // ...
    }
}

DIでプラットフォーム毎に実装を注入する

interfaceとその実装が準備できたら、次はDIでプラットフォーム毎に適切な実装を注入する仕組みを構築していきます。

ここでは、KMPでよく使われるKoinというDIフレームワークを用いて説明していきますが、他のDIフレームワークでも同等のことが実現できると思います。

Koinの基本的な使い方は、moduleDSLを使って依存関係の定義を書き、アプリケーションの起動時にstartKoinを呼び出して定義したモジュールを読み込むという流れです。

expect/actualを使ってプラットフォーム固有依存の定義を行う

KMPでプラットフォーム固有の依存をDIで扱う際の核となる部分は、Koinのモジュール定義自体にexpect/actualを適用することです。

まず、commonMainで、プラットフォーム固有の依存関係を定義するためのKoinモジュールをexpect宣言します。

commonMain

import org.koin.core.module.Module

expect val platformModule: Module

次に、各プラットフォーム固有のソースセットで、platformModuleの中身を実装していきます。

androidMain

actual val platformModule: Module = module {
    single<BatteryInfo> { AndroidBatteryInfo(context = androidContext()) }
}

Koinでは、AndroidのContextはandroidContext()で取得できるので、AndroidBatteryInfoのインスタンス生成時に渡しています。

iosMain

actual val platformModule: Module = module {
    single<BatteryInfo> { IosBatteryInfo() }
}

iOSの場合は追加の依存関係を必要としていないため、単純にインスタンスを生成して登録しています。

Koinの初期化と利用

最後に、定義したKoinモジュールを初期化する処理をアプリに追加します。

初期化のヘルパー関数を用意しておきます

commonMain

fun initKoin(appDeclaration: KoinApplication.() -> Unit) {
    startKoin {
        appDeclaration()
        modules(platformModule)
    }
}

そして、各プラットフォームで初期化していきます。

Android

class MyApplication : Application() {
    override fun onCreate() {
        super.onCreate()
        initKoin {
            androidContext(this@MyApplication) // Android ContextをKoinに設定
        }
    }
}

iOS

@main
struct iOSApp: App {
    init() {
        KoinHelperKt.doInitKoin { _ in }
    }

    // ...
}

(注意: SwiftからKotlinのトップレベル関数を呼び出す際の正確な方法は、Kotlinファイル名等によって変わります。上記は一例です。)

Koinの初期化が完了すれば、後は通常通りKoinの機能(by inject()など)を使ってBatteryInfoのインスタンスを取得できるようになります。

Composableで取得する例

@Composable
fun Hoge(
    batteryInfo: BatteryInfo = koinInject(),
) {
    // ...
}

classで取得する例

class Hoge: KoinComponent {
    val batteryInfo: BatteryInfo by inject()
}

実装手順は以上です!

この手法では、expect/actualの使用箇所を最小限に留めています。expect val platformModule: Moduleの宣言しかありません。

応用編: iOS側の実装をSwiftで行い、DIで注入する

先ほどの例だとiOS側の実装であるIosBatteryInfoはKotlinで実装しました。しかし、KotlinからiOSのAPIを触るため少しクセがあったり、Swift製のサードパーティライブラリが利用できないといった制約があります。

それを解決する方法として、iOS側の実装をSwiftで行いDIで注入する方法をご紹介いたします。

ここでは、iOS側のBatteryInfoをSwiftで実装して注入する例をやってみます。

まずは、Kotlinで実装したIosBatteryInfoのDI設定はコメントアウトしておきます。

iosMain

actual val platformModule: Module = module {
    // single<BatteryInfo> { IosBatteryInfo() }
}

iOS用にKoinの初期化処理を新たに作成します。

iosMain

fun initKoin(
    batteryInfo: BatteryInfo,
) {
    initKoin {
        modules(
            module {
                single { batteryInfo }
            }
        )
    }
}

commonMainのinitKoinを呼び出しつつ、iOS(Swift)側から渡されたインスタンスを登録する新しいモジュールを追加します。

次はiOSプロジェクト側の対応です。まずは、BatteryInfoに準拠した実装クラスをSwiftで用意します。

class IosBatteryInfo: BatteryInfo {
    func getBatteryLevel() -> Int32 {
        UIDevice.current.isBatteryMonitoringEnabled = true
        let batteryLebel = UIDevice.current.batteryLevel
        UIDevice.current.isBatteryMonitoringEnabled = false

        return Int32((batteryLebel * 100).rounded())
    }
}

initKoinにSwiftで実装したIosBatteryInfoを渡してあげます。

@main
struct iOSApp: App {
    init() {
        KoinHelper_iosKt.doInitKoin(batteryInfo: IosBatteryInfo())
    }

    // ...
}

(注意: SwiftからKotlinのトップレベル関数を呼び出す際の正確な方法は、Kotlinファイル名等によって変わります。上記は一例です。)

これで、Swiftで実装したBatteryInfoをKMPのcommonMainから扱えるようになりました!

アプローチ2のまとめ

こちらの方法では、expect/actualの使用箇所を1箇所に留め、DIを用いてプラットフォーム固有の実装を注入しています。DIの仕組みをプロジェクトに導入するところから始める必要がありますが、アプローチ1で起きた依存解決の問題を解決し、加えてSwift実装をcommonMainに持ち込むことも可能になりました。また、依存の管理がDIフレームワークに乗ることでテストが行いやすくなったり、全体の見通しも良くなると思います。

まとめ

本記事では、プラットフォーム固有の実装をcommonMainから扱うための主要な2つのアプローチ、expect/actualを使った方法とDIフレームワークの管理に乗せる方法についてご紹介いたしました。

両者はプラットフォーム固有の実装を扱うための手段ですが、それぞれに適した利用シーンや特性があります。

expect/actualのアプローチは、プラットフォーム固有の定数値や、引数を取らない単純な値を返す関数など、単純なAPIを提供する場合には手軽で効果的です。

DIフレームワークのアプローチは、初回の導入コストは多少あるものの、多くの場合で有用です。特に、既にDIフレームワークを使用している場合は、プラットフォーム固有の依存関係の管理もDIフレームワークに任せてしまうのがおすすめです。

We are hiring!

エムスリーでは、スマホアプリエンジニアを大大大絶賛募集しています!

ネイティブアプリが好きな方、KMPが好きな方、Flutterが好きな方お待ちしております!

speakerdeck.com

jobs.m3.com




以上の内容はhttps://www.m3tech.blog/entry/kmp-platform-logic-diより取得しました。
このページはhttp://font.textar.tv/のウェブフォントを使用してます

不具合報告/要望等はこちらへお願いします。
モバイルやる夫Viewer Ver0.14