🐍

僕が考えた最強のKMMアプリ構成

2022/10/03に公開

はじめに

KMMを使うのは今回が初めてなので間違えや、より良い方法があると思います。
遠慮なく優しく教えてもらえると嬉しいです。
また、Android開発は少しだけ分かりますがiOSは全く分からない人間が書いているので、
その点もご留意ください

API/DBからデータを取得してJetpackComposeで表示する例を紹介します。

アプリ作成

Xcodeの最新版とAndroidStudioをダウンロードして最初の設定を終わらせます。
※特にXcodeは開発できるところまで初期設定ができていないとプロジェクト作成で泣きます。

AndroidStudioでプロジェクト作成

まずはKotlin Multiplatform Mobileプラグインを導入します。

Kotlin MultiPlatform Appで新規プロジェクトを作成します

モジュール分割

これは色んな意見があると思いますが、個人の趣味で小さいアプリでもモジュール分割はしています。
なので、今回もモジュール作成を行いました。

普段のネイティブAndroid開発と違いモジュールもAndroid/iOSで共通のライブラリなのか?
Android/iOSどちらかでのみ使用するものなのか?と言う判断が入るのが新鮮でした。

モジュール構成はざっくりこのような形です。
角丸が共通化されているモジュールで、四角がネイティブです。

Androidモジュール

Androidアプリの一番親です。
manifestなどがここにあります。
ApplicationクラスもここにあるのでKoinの初期化処理なども担当します。

Sharedモジュール

共通部分の親モジュールです。
存在価値あるのか自分の中で色々検討しています。

Koinのモジュールまとめ役をやってもらっています。

Featuresモジュール

図だと分かりにくいかもしれませんが、ネイティブと共通でそれぞれにFeaturesモジュールを置いています。
Androidのネイティブ実装だと機能のまとまり毎にfeatureモジュールを切るのが好きですが、
ViewModelまで共通化できるので、feature毎にモジュールを切る必要性が無いかも?との判断でfeaturesとしてまとめました。

なので、共通のFeaturesにViewModel、ネイティブのFeaturesにUIが入る事になります。
ここでAndroidの人ならlifecycle.ViewModel使えないけど平気?と思った人には
こちらのmoko-mvvmを使うと良い感じにやってくれそうなので、使ってみています。
https://github.com/icerockdev/moko-mvvm

class RandomDogGridViewModel(
    private val getRandomDogUseCase: GetRandomDogUseCase,
    private val deleteInsertDogUseCase: DeleteInsertDogUseCase
) : ViewModel() {
    private val retryTrigger = RetryTrigger()
    val randomDog: StateFlow<Result<RandomDogResponse>> = retryableFlow(retryTrigger) {
        getRandomDogUseCase(LIMIT_DOG_COUNT)
    }.stateIn(viewModelScope, SharingStarted.Eagerly, Result.Loading)
〜略〜
}

Repositoryモジュール

特に面白くないです。
API/DBとの橋渡しをしています。

Networkモジュール

通信にはKtorFitというライブラリを使用しました。
こちらはAndroidの人なら慣れているRetorofitと近い使い方ができるライブラリになります。
https://github.com/Foso/Ktorfit

interface DogApi {
    @GET("breeds/image/random/{limit}")
    fun getRandomDog(@Path("limit") limit: Int): Flow<RandomDogResponse>
}

Localモジュール

DBにはSQLDelightを使用しました。
こちらもFlowでデータの取得結果が取れたり(変更のたびに通知される)するので、
Roomと遜色なく使える気がします。

fun selectDog(): Flow<List<Dog>> = database.dogQueries.selectDog().asFlow().mapToList()

細かいコードの紹介

DI(Koin)

expect fun platformModule(): Module

共通部分にViewodelなどネイティブ実装が必要になるモジュールを取得する処理を書くよ宣言

actual fun platformModule(): Module = module {
    viewModel { RandomDogGridViewModel(get(), get()) }
    viewModel { RandomDogDetailViewModel(get()) }
}

Androidしか実装していないので、Androidだけですが、
Koin-AndroidのViewModelで設定しています。

API呼び出し〜画面表示

val ktorfit = Ktorfit
    .Builder()
    .baseUrl("https://dog.ceo/api/")
    .httpClient(
        HttpClient {
            install(ContentNegotiation) {
                json(Json { isLenient = true; ignoreUnknownKeys = true })
                install(Logging)
            }
        }
    )
    .requestConverter(
        FlowRequestConverter()
    )
    .responseConverter()
    .build()
val dogApi = ktorfit.create<DogApi>()

KtorFitを準備します。

interface DogApi {
    @GET("breeds/image/random/{limit}")
    fun getRandomDog(@Path("limit") limit: Int): Flow<RandomDogResponse>
}

通信の定義を用意します。

class DogRepository(
    private val dogApi: DogApi,
    private val database: AppDatabase
) {
    fun getRandomDog(limit: Int): Flow<RandomDogResponse> = dogApi.getRandomDog(limit)
}

Repositoryは面白くなく

class GetRandomDogUseCase(
    private val dogRepository: DogRepository
) {
    operator fun invoke(limit: Int): Flow<sobaya.app.util.Result<RandomDogResponse>> =
        dogRepository.getRandomDog(limit).asResult()
}

UseCaseも面白くありません
asResult()はNow in Androidと同じ作りです。

class RandomDogGridViewModel(
    private val getRandomDogUseCase: GetRandomDogUseCase,
    private val deleteInsertDogUseCase: DeleteInsertDogUseCase
) : ViewModel() {
    private val retryTrigger = RetryTrigger()
    val randomDog: StateFlow<Result<RandomDogResponse>> = retryableFlow(retryTrigger) {
        getRandomDogUseCase(LIMIT_DOG_COUNT)
    }.stateIn(viewModelScope, SharingStarted.Eagerly, Result.Loading)
}

ViewModelも面白くありません
retryableFlowはFlowでデータ受け取りをしたいけど、通信エラー時などに手動リトライをする口を用意するために使っています。

@OptIn(ExperimentalLifecycleComposeApi::class)
@Composable
fun RandomDogScreenRout(
    navController: NavController,
    viewModel: RandomDogViewModel,
    modifier: Modifier = Modifier
) {
    val state by viewModel.randomDog.collectAsStateWithLifecycle()
    RandomDogScreen(
        state = state,
        onClickReload = viewModel::fetchRandomDog,
        modifier
    )
}

DB関連

sqldelight {
    database("AppDatabase") {
        packageName = "sobaya.lib.local"
    }
}

build.gradle.ktsにこのような定義を書きます。
ここで指定したパッケージ配下にsqファイルを置きます。

CREATE TABLE dog (
  message TEXT,
  status TEXT
);

selectDog:
SELECT message, status
FROM dog;

insertDog:
INSERT INTO dog(message, status)
VALUES(?, ?);

deleteDog:
DELETE FROM dog;

sqファイルにはCREATE文とアプリから使用するクエリを書きます。

val databaseModule = module {
    single {
        val driver = AndroidSqliteDriver(AppDatabase.Schema, get(), "sample.db")
        AppDatabase(driver)
    }
}

Androidはこのような形でDatabaseの定義を用意します。

class DogRepository(
    private val dogApi: DogApi,
    private val database: AppDatabase
) {
    fun selectDog(): Flow<List<Dog>> = database.dogQueries.selectDog().asFlow().mapToList()

    fun deleteDog() = database.dogQueries.deleteDog()

    fun insertDog(message: String, status: String) = database.dogQueries.insertDog(message, status)
}

Repositoryはこのような形です。
InsertやDeleteがsuspendじゃないのが気持ち悪いですが、慣れる事にします。

実際のコード

https://github.com/sobaya-0141/Sample202209

主に使用したライブラリ

https://github.com/icerockdev/moko-mvvm
https://github.com/Foso/Ktorfit
https://github.com/cashapp/sqldelight

参考

https://github.com/Kashif-E/KMMNewsAPP

Discussion