【KMP】Koin を使用して Kotlin Multiplatform で Swift パッケージを使用する方法

## はじめに

本投稿は「[How to Use Swift Packages in Kotlin Multiplatform using Koin](https://proandroiddev.com/how-to-use-swift-packages-in-kotlin-multiplatform-using-koin-c7d24fdbbbd7)」からインスパイアされ、そして同投稿を補完するものです。

https://proandroiddev.com/how-to-use-swift-packages-in-kotlin-multiplatform-using-koin-c7d24fdbbbd7 

[KMP 公式ドキュメント](https://kotlinlang.org/docs/multiplatform-ios-dependencies.html#with-cinterop)には以下のように記載があり、Kotlin Multiplatform は 現時点(2025/03/17)で Swift Package Manager には対応していないことが窺えます。

> To handle iOS dependencies in Kotlin Multiplatform projects, you can manage them with the cinterop tool or use the CocoaPods dependency manager (pure Swift pods are not supported).

iOS エンジニアなら新しいパッケージを追加するのに CocoaPods パッケージはできれば使いたくないでしょう。また、小生は Fiebase iOS SDK を使うため、公式ドキュメント「[Add dependencies on a Pod library](https://kotlinlang.org/docs/native-cocoapods-libraries.html)」を参考に CocoaPods パッケージの導入を試みたが全く上手くいかず諦めました... 途方に暮れていたところに上記記事を見つけ、問題を解決することができたのです。

本記事は KMP の shared コードにおいて Firebase SDK を使って Firebase Storage にアクセスする方法について記載します。すなわち、`androidMain` では Firebase Android SDK を使い、`iosMain` では Firebase iOS SDK を使って、Firebase Storage にアクセスします。

尚、本記事の読者は [Koin](https://insert-koin.io/) および Swift Package Manager についてある程度理解できる人を対象にしており、 Koin と Swift Package Manager について詳しく解説しませんのでご了承ください。

> 本記事では、Koin 4.0.0、Kotlin 2.1.0 を使用しています。


## 「[How to Use Swift Packages in Kotlin Multiplatform using Koin](https://proandroiddev.com/how-to-use-swift-packages-in-kotlin-multiplatform-using-koin-c7d24fdbbbd7)」の解析

では、まずオリジナルの記事を読んで見ましょう。(無理して読まなく良いです💦)
記事のコードをシーケンス図にしてみました。難しくないですか？ iOS 側と shared 側を行ったり来たり、小生はシーケンス図にするまで何をやってるんか理解できませんでした。
![KMP_inject_SPM-ページ1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3494458/029d52ab-4798-48ac-a582-67447ab7eae6.png)

でもシーケンス図にしてもやっぱり難しい。(わかったという人はここから読む必要はもうないですw)

## Firebase Storage にアクセスするケースで試す

「[How to Use Swift Packages in Kotlin Multiplatform using Koin](https://proandroiddev.com/how-to-use-swift-packages-in-kotlin-multiplatform-using-koin-c7d24fdbbbd7)」は Firebase Analytics を扱っていますが、本記事は Firebase Storage を扱う場合について解説していきます。
本記事で扱うコードのシーケンス図は以下です。オリジナルに存在していた`KoinApplication` のコードは小生には冗長に感じたので削除しました。

![KMP_inject_SPM-ページ2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3494458/01c1d865-fd6f-466a-9c75-5e6299e94ffc.png)

クラス図は以下です。

![KMP_inject_SPM-ページ3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3494458/7870dc77-a80f-4e0e-9db3-f2dc4c0950ad.png)

## 何がしたいか

何がしたいかを先におさえておきましょう。そのために最も重要な箇所は👇です。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3494458/d79f19c9-c939-4d25-a6db-b3bca7bbfc24.png" alt="" width="300">

コードだとこうなります。`single ...` という見慣れた Koin のコードですね。

```IosModule.kt
import org.koin.core.module.Module
import org.koin.dsl.bind
import org.koin.dsl.module

fun createSwiftLibDependencyModule(factory: SwiftLibDependencyFactoryContract): Module = module {
    single { factory.provideFirebaseStorageDataSource() } bind FirebaseStorageDataSourceContract::class
}
```

何をやっているのかというと、Kotlin 側から Swift の `provideFirebaseStorageDataSource()` を呼び出し、 `FirebaseStorageDataSource` インスタンスを受け取ってそれを最後に `single()` で KoinDefinition へ変換しています。これで Swift のオブジェクトを Koin でいつでもどこにでも inject できるようになりました！
つまりやりたいことは Kotlin 側で Swift のオブジェクトをターゲットにして `single()` か `factory()` を実行することなのです。

以下のコードをみてください。inject の準備ができたらいつもの `by inject()` を呼んでみます。 すると Swift のオブジェクトが `dataSource` に格納され、同オブジェクトを操作できるようになりました 🎉

```NoticeRepository.kt
class NoticeRepository : KoinComponent {
    suspend fun fetch(): String {
        val dataSource: FirebaseStorageDataSourceContract by inject()
        ...
```        

ここまで来たら「Swift の `FirebaseStorageDataSource` クラスの中で Firebase iOS SDK に依存するコード書けば良い」ということが容易に想像できると思います。そして Firebase iOS SDK は Swift Package Manger でインストールすれば良いということにも🎵

## 全体のソースコード

シーケンス図に沿ってソースコード記載していきます。

### iOS アプリ

```iOSApp.swift
import SwiftUI
import ComposeApp // 共有コードの iOS 向けのバイナリ名

@main
struct iOSApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }

+   init() {
+       CommonModuleKt.doInitIosKoin(
+           onKoinStart: {
+               IosModuleKt.createSwiftLibDependencyModule(
+                   factory: SwiftLibDependencyFactory.shared
+               )
+           }
+       )
+   }
}
```

```SwiftLibDependencyFactory.swift
import ComposeApp // 共有コードの iOS 向けのバイナリ名

class SwiftLibDependencyFactory: SwiftLibDependencyFactoryContract {
    static var shared = SwiftLibDependencyFactory()

    func provideFirebaseStorageDataSource() -> any FirebaseStorageDataSourceContract {
        return FirebaseStorageDataSource()
    }
}
```

```FirebaseStorageDataSource.swift
import Foundation
import ComposeApp // 共有コードの iOS 向けのバイナリ名
import FirebaseStorage // SPM の Firebase Storage ライブラリ
import FirebaseCore // SPM の Firebase Core ライブラリ

class FirebaseStorageDataSource: ComposeApp.FirebaseStorageDataSourceContract {

    func fetch(callback: @escaping (String?, KotlinThrowable?) -> Void) {        
        let storage = Storage.storage()
        var noticeRef = storage.reference().child("notice.txt")
        
        noticeRef.getData(maxSize: 1 * 1024 * 1024) { data, error in
            if let error = error {
                print("Error downloading notice.txt: \(error)")
                let kotlinError = KotlinThrowable(message: error.localizedDescription)
                callback(nil, kotlinError)
            } else {
                if let textData = data, let text = String(data: textData, encoding: .utf8) {
                    print("notice.txt content: \(text)")
                    callback(text, nil)
                } else {
                    print("Error: Could not convert data to string.")
                }
            }
        }
    }
}
```

### commonMain

```CommonModule.kt
import org.koin.core.context.startKoin
import org.koin.core.module.Module
import org.koin.core.module.dsl.viewModel
import org.koin.dsl.KoinAppDeclaration
import org.koin.dsl.module

val useCaseModule = module {
    ...
}

val repositoryModule = module {
    ...
}

val viewModelModule = module {
    ...
}

// NOTE: iOS 側の actual platformModule は実際には使わないので空実装で良い。
expect val platformModule: Module

// for Android
fun initAndroidKoin(appDeclaration: KoinAppDeclaration) = startKoin {
    appDeclaration()
    modules(
        viewModelModule,
        useCaseModule,
        repositoryModule,
        platformModule,
    )
}

// for iOS
// CommonModuleKt.doInitIosKoin() として Swift から呼び出す。
-fun initIosKoin() {
+fun initIosKoin(onKoinStart: () -> Module) {
     startKoin {
         modules(
             viewModelModule,
             useCaseModule,
             repositoryModule,
+            onKoinStart(),
         )
     }
 }
```

```FirebaseStorageDataSourceContract.kt
interface FirebaseStorageDataSourceContract {
    // NOTE: Swift 側で suspend や Flow を使用できないため、コールバックで fetch 結果を呼び出し元に返す
    fun fetch(callback: (String?, Throwable?) -> Unit)
}
```

### iosMain

```SwiftLibDependencyFactoryContract.kt
interface SwiftLibDependencyFactoryContract {
    fun provideFirebaseStorageDataSource(): FirebaseStorageDataSourceContract
}
```

```IosModule.kt
actual val platformModule = module {
    // NOTE: FirebaseStorageDataSource はここでは登録せず、initKoin の
    //       コールバックを介して swiftLibDependenciesModule() をコールすることで登録する。
}

fun createSwiftLibDependencyModule(factory: SwiftLibDependencyFactoryContract): Module = module {
    single { factory.provideFirebaseStorageDataSource() } bind FirebaseStorageDataSourceContract::class
}
```

### androidMain

```AndroidModule.kt
actual val platformModule = module {
    single {
        val storage = Firebase.storage()
        FirebaseStorageDataSource(storage = storage)
    } bind FirebaseStorageDataSourceContract::class
}
```

```FirebaseStorageDataSource.kt
import com.google.firebase.storage.FirebaseStorage

const val ONE_MEGABYTE: Long = 1024 * 1024

class FirebaseStorageDataSource(
    private val storage: FirebaseStorage
) : FirebaseStorageDataSourceContract {

    override fun fetch(callback: (String?, Throwable?) -> Unit) {
        val noticeRef = storage.reference.child("notice.txt")
        noticeRef.getBytes(ONE_MEGABYTE).addOnSuccessListener { bytes ->
            val text = String(bytes)
            println("notice.txtの内容: $text")
            callback(text, null)
        }.addOnFailureListener { exception ->
            // エラー処理
            println("テキストデータのダウンロードに失敗しました: ${exception.message}")
            callback(null, exception)
        }
    }
}
```

## 最後に

単体の iOS アプリを作るときみたく Xcode で Swift パッケージをインストールしてそのラッパークラスを Swift で作っておき、それを Koin を使って shared モジュール側へ inject できるようにする手順を本記事で紹介しました。

この Tips を使うと Swift コードを shared モジュールへ inject できるようになります。なので Swift パッケージを shared モジュール側で使うというのは実はその一ケースでしかないということに気づきます。これで我々は shared モジュールからコールバックを使わずともその場で Swift コードが呼び出せるようになりました！

## おまけ

`FirebaseStorageDataSourceContract` の `fetch` メソッドが `suspend` ではなくコールバックになっていることに気づいたでしょうか。これは、`suspend` メソッドは Swift でオーバーライドできないので、`suspend` の代替としてコールバックにしたからです。しかしコールバックを上位層に影響させたくはなく、かつ Repository 層は `suspend` を使いたいというのが人情なので以下のように `suspendCoroutine` を使ってコールバックを待ち受けます。

```NoticeRepository.kt
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine

class NoticeRepository : KoinComponent {
    suspend fun fetch(): String {
        val dataSource: FirebaseStorageDataSourceContract by inject()
        return suspendCoroutine { continuation ->
            dataSource.fetch { notice, error ->
                // TODO: error に対処する
                notice?.let {
                    continuation.resume(it)
                }
            }
        }
    }
}
```