はじめに
本投稿は「How to Use Swift Packages in Kotlin Multiplatform using Koin」からインスパイアされ、そして同投稿を補完するものです。
KMP 公式ドキュメントには以下のように記載があり、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」を参考に CocoaPods パッケージの導入を試みたが全く上手くいかず諦めました... 途方に暮れていたところに上記記事を見つけ、問題を解決することができたのです。
本記事は KMP の shared コードにおいて Firebase SDK を使って Firebase Storage にアクセスする方法について記載します。すなわち、androidMain
では Firebase Android SDK を使い、iosMain
では Firebase iOS SDK を使って、Firebase Storage にアクセスします。
尚、本記事の読者は Koin および 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」の解析
では、まずオリジナルの記事を読んで見ましょう。(無理して読まなく良いです💦)
記事のコードをシーケンス図にしてみました。難しくないですか? iOS 側と shared 側を行ったり来たり、小生はシーケンス図にするまで何をやってるんか理解できませんでした。
でもシーケンス図にしてもやっぱり難しい。(わかったという人はここから読む必要はもうないですw)
Firebase Storage にアクセスするケースで試す
「How to Use Swift Packages in Kotlin Multiplatform using Koin」は Firebase Analytics を扱っていますが、本記事は Firebase Storage を扱う場合について解説していきます。
本記事で扱うコードのシーケンス図は以下です。オリジナルに存在していたKoinApplication
のコードは小生には冗長に感じたので削除しました。
クラス図は以下です。
何がしたいか
何がしたいかを先におさえておきましょう。そのために最も重要な箇所は👇です。
コードだとこうなります。single ...
という見慣れた Koin のコードですね。
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
に格納され、同オブジェクトを操作できるようになりました 🎉
class NoticeRepository : KoinComponent {
suspend fun fetch(): String {
val dataSource: FirebaseStorageDataSourceContract by inject()
...
ここまで来たら「Swift の FirebaseStorageDataSource
クラスの中で Firebase iOS SDK に依存するコード書けば良い」ということが容易に想像できると思います。そして Firebase iOS SDK は Swift Package Manger でインストールすれば良いということにも🎵
全体のソースコード
シーケンス図に沿ってソースコード記載していきます。
import SwiftUI
import ComposeApp
@main
struct iOSApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
+ init() {
+ CommonModuleKt.doInitIosKoin(
+ onKoinStart: {
+ IosModuleKt.createSwiftLibDependencyModule(
+ factory: SwiftLibDependencyFactory.shared
+ )
+ }
+ )
+ }
}
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(),
)
}
}
interface SwiftLibDependencyFactoryContract {
fun provideFirebaseStorageDataSource(): FirebaseStorageDataSourceContract
}
class SwiftLibDependencyFactory: SwiftLibDependencyFactoryContract {
static var shared = SwiftLibDependencyFactory()
func provideFirebaseStorageDataSource() -> any FirebaseStorageDataSourceContract {
return FirebaseStorageDataSource()
}
}
interface FirebaseStorageDataSourceContract {
fun fetch(callback: (String?, Throwable?) -> Unit)
}
import Foundation
import ComposeApp
import FirebaseStorage
import FirebaseCore
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.")
}
}
}
}
}
fun createSwiftLibDependencyModule(factory: SwiftLibDependencyFactoryContract): Module = module {
single { factory.provideFirebaseStorageDataSource() } bind FirebaseStorageDataSourceContract::class
}
Android 側 (= androidMain)
actual val platformModule = module {
single {
val storage = Firebase.storage()
FirebaseStorageDataSource(storage = storage)
} bind FirebaseStorageDataSourceContract::class
}
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
を使ってコールバックを待ち受けます。
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)
}
}
}
}
}