Slide 1

Slide 1 text

2025-03-25 Ebisu.mobile #9 STORES 株式会社 榎本健太 Compose Multiplatform における iOS ネイティブ実装のベストプラクティス

Slide 2

Slide 2 text

自己紹介 2 ● 榎本 健太 ● iOS エンジニア ● @enomotok_ ● enomoto

Slide 3

Slide 3 text

CMP における iOS アプリ開発の課題 ● Kotlin の共通実装だけで両 OS の実装が完了しないケースがある ○ ネイティブ API を使いたいケース ■ 例:音を鳴らす ○ Firebase のような mBaaS の利用 3

Slide 4

Slide 4 text

Kotlin の共通実装だけで両 OS の実装が完了しないケースでどうすべきか ● どうするのが良いか 🤔 ○ expect / actual を使う ○ Swift で実装する 4

Slide 5

Slide 5 text

おさらい:expect / actual とは ● 共通コード(common)で定義したインターフェースに対して、各プ ラットフォームごとに具体的な実装(actual)を割り当てるしくみ ● KMP では expect / actual と組み合わせて、 iOS の API を Kotlin で実装するためのクラスが存在する 5

Slide 6

Slide 6 text

おさらい:expect / actual とは 6 expect class AudioPlayer { fun playSound(audio: Audio) } commonMain/AudioPlayer.kt actual class AudioPlayer { actual fun playSound(audio: Audio) { val mediaItem = NSURL.URLWithString(URLString = Res.getUri(audio.path)) ??. } } iosMain/AudioPlayer.ios.kt actual class AudioPlayer( private val context: Context, ) { actual fun playSound(audio: Audio) { val soundPool = requireSoundPool() ??. } } androidMain/AudioPlayer.android.kt

Slide 7

Slide 7 text

Kotlin の共通実装だけで両 OS の実装が完了しないケースでどうすべきか ● どうするのが良いか 🤔 ○ expect / actual を使う ○ Swift で実装する 7

Slide 8

Slide 8 text

チームの意思決定:Swift を積極的に使う ● iOS 専用の実装が必要な箇所は Swift でなるべく書くことに決めた ● 前提:Android エンジニア2名、 iOS エンジニア2名の混成チーム ○ つまり、チームには iOS の専門家がいる ● Swift を使って書けるなら、なるべくそうしたい ● Swift Package Manager を用いて純正のライブラリを利用できる 8

Slide 9

Slide 9 text

Kotlin との連携方法 ● Dependency Injection (Koin) を使う ○ NotificationCenter などの手段も検討したが... ○ コードの秩序を保つために Koin のしくみを生かす 9

Slide 10

Slide 10 text

DI の実装方法と設計 ● Kotlin 側に interface を定義して、Swift 側で実装 ● iOS のエントリーポイントで依存性を注入する 10

Slide 11

Slide 11 text

DI の実装方法と設計 - Kotlin 側に interface を定義、Swift で実装 11 final class KrashlyticsCoreImpl: CoreKrashlyticsCore { private var crashlytics: Crashlytics? init() { crashlytics = Crashlytics.crashlytics() } func log(message: String) { … } … } iosApp/KrashlyticsCoreImpl.swift interface KrashlyticsCore { fun log(message: String) ??. } commonMain/Krashlytics.kt

Slide 12

Slide 12 text

DI の実装方法と設計 - iOS のエントリーポイントで依存性を注入 12 @main struct iOSApp: SwiftUI.App { init() { FirebaseApp.configure() InitKoinKt.doInitKoin(config: nil, iosNativeModule: iosNativeModule) sharedUi.App.companion.initialize( krashlyticsCore: inject(), remoteConfigCore:inject() ) } var body: some Scene { … } } var iosNativeModule: Koin_coreModule = MakeNativeModuleKt.makeNativeModule( krashlyticsCore: { _ in return KrashlyticsCoreImpl() }, remoteConfigCore: { _ in return RemoteConfigCoreImpl() } ) iosApp/iOSApp.swift

Slide 13

Slide 13 text

DI の実装方法と設計 - iOS のエントリーポイントで依存性を注入 13 commonMain/InitKoin.kt fun initKoin( config: KoinAppDeclaration? = null, iosNativeModule: Module? = null, ) { val modules = mutableListOf( commonSharedUiModule, commonCoreDomainModule, platformCoreDataModule, platformCoreAudioModule, ) iosNativeModule?.let { modules.add(it) } KoinDispatcher.start( modules = modules, config = config, ) }

Slide 14

Slide 14 text

DI の実装方法と設計 - iOS のエントリーポイントで依存性を注入 14 commonMain/InitKoin.kt fun initKoin( config: KoinAppDeclaration? = null, iosNativeModule: Module? = null, ) { val modules = mutableListOf( commonSharedUiModule, commonCoreDomainModule, platformCoreDataModule, platformCoreAudioModule, ) iosNativeModule?.let { modules.add(it) } KoinDispatcher.start( modules = modules, config = config, ) } @main struct iOSApp: SwiftUI.App { init() { FirebaseApp.configure() InitKoinKt.doInitKoin(config: nil, iosNativeModule: iosNativeModule) sharedUi.App.companion.initialize( krashlyticsCore: KrashlyticsCoreImpl(), remoteConfigCore: RemoteConfigCoreImpl() ) } var body: some Scene { … } } var iosNativeModule: Koin_coreModule = MakeNativeModuleKt.makeNativeModule( krashlyticsCore: { _ in return KrashlyticsCoreImpl() }, remoteConfigCore: { _ in return RemoteConfigCoreImpl() } ) iosApp/iOSApp.swift (再掲)

Slide 15

Slide 15 text

補足: DI の実装方法と設計 - commonMain から Swift オブジェクトを参照 ● ジェネリクスや Swift Type metadataを用いることで、 Kotlin の共 通コード内でも Swift のオブジェクトを参照することが可能 15

Slide 16

Slide 16 text

補足: DI の実装方法と設計 - iOS のエントリーポイントで依存性を注入 16 @main struct iOSApp: SwiftUI.App { init() { FirebaseApp.configure() InitKoinKt.doInitKoin(config: nil, iosNativeModule: iosNativeModule) sharedUi.App.companion.initialize( krashlyticsCore: inject(), remoteConfigCore: inject() ) } var body: some Scene { // 省略 } } iosApp/iOSApp.swift (再掲)

Slide 17

Slide 17 text

補足: DI の実装方法と設計 - commonMain から Swift オブジェクトを参照 17 class SwiftKClass: NSObject, KotlinKClass { func isInstance(value: Any?) -> Bool { value is T } var qualifiedName: String? { String(reflecting: T.self) } var simpleName: String? { String(describing: T.self) } } func KClass(for type: T.Type) -> KotlinKClass { SwiftType(type: type, swiftClazz: SwiftKClass()).getClazz() } extension Koin_coreScope { func get() -> T { get(clazz: KClass(for: T.self), qualifier: nil, parameters: nil) as! T } } func inject( qualifier: Koin_coreQualifier? = nil, parameters: (() -> Koin_coreParametersHolder)? = nil ) -> T { KoinGetKt.koinGet(clazz: KClass(for: T.self), qualifier: qualifier, parameters: parameters) as! T } iosApp/KoinHelpers.swift https://github.com/0x6368656174/kmp-di-example This slide includes source code licensed under the Apache License, Version 2.0. © [Pavel Puchkov] You may obtain a copy of the license at http://www.apache.org/licenses/LICENSE-2.0

Slide 18

Slide 18 text

Swift で実装して良かったと感じている理由 ● 開発効率、責務分離、チーム内での分業のしやすさ ● 無理に KMP, CMP用のサードパーティライブラリを使うのではなく、 公式のライブラリを使用できる ● 既存資産の流用 ● DI 前提になっているので、実装の境界を綺麗に表現できている 18

Slide 19

Slide 19 text

まとめ ● Kotlin で完結することだけが正解ではない ● Swift 実装を前提に設計しても良いでしょう ● チーム事情に合わせて最適な設計を選択していきましょう 19