元Flutter使い、現iOSプログラマが、Compose Multiplatformに入門してみた

# はじめに

以前Flutterを使っていた経験から、現在はSESとしてiOSプログラマをしている[uehatsu](https://x.com/uehatsu)です。ちょっとCompose Multiplatformをいじる必要が出てきたので、連休を機会にさわり始めました。メモがてらまとめてみたいと思います。

https://www.jetbrains.com/ja-jp/compose-multiplatform/

# 環境構築

公式ホームページ通りにやったところ大きな問題はありませんでした。あえて言うなら以下の点で「あれ？」と思いました。

- Xcode 16.1をインストールした状態ではAndroid Studio上でシミュレータが正しく選べなかったので、Xcode 16.0にしたこと
- kdoctorがAndroid Studioのバージョンを正しく認識してくれなかったこと
- kdoctorが「Android Studio上で`Kotlin Multiplatform Mobile Plugin`をインストールしろ」と言うけど、そんなプラグインはなく`Kotlin Multiplatform Plugin`に改名されている、らしいこと

https://www.jetbrains.com/help/kotlin-multiplatform-dev/multiplatform-setup.html

# Multiplatform対応のプロジェクトを作成する

公式ページには[Kotlin Multiplatform wizard](https://kmp.jetbrains.com/)を利用してプロジェクトを作成しろと書かれていました。以前私が触ったときはGitHubリポジトリをフォークする形だったので、便利になりましたね。当時もWizardがあったはずですが、野良のものだったのか、現在のJetBrains製のものかまでは失念してしまいました。

以下の設定でプロジェクトを作成しました。

- Project Name
    - ComposeMultiplatformTest
- Project ID
    - info.uehatsu.cmptest
- Android
    - チェック
- iOS
    - チェック
    - Share UIを選択（Compose Multiplatformを使うので）
- Desktop, Web, Server
    - 非チェック

最初Serverなどもチェックして作成したのですが、モジュールのインストールでエラーになってしまったので外しました。最終的には直るのでしょうが、初学者殺しですね（苦笑）。

プロジェクトを作成したらダウンロードしたZipファイルを展開し、git init, git add, git commitしておきます。

プロジェクトを開くとAGP Upgradeについて聞かれますが、行ってもGradle Version未検証のワーニングがでてしまうので、そのままにします。

# 今回行う事

順番が前後してしまいましたが、今回行う事を検討してみます。いくつかホームページを見たところ、株式会社Nextatさんのスタッフブログに行き着きました。ちょうど良い分量だったので、ここからいくつかピックアップして試してみたいと思います。

https://nextat.co.jp/staff/archives/category/kotlin

## Compose Multiplatform 入門（環境構築編）

https://nextat.co.jp/staff/archives/360

すでに環境構築は済んでいるのでスキップします。

## Compose Multiplatform 入門（プロジェクト作成編）

https://nextat.co.jp/staff/archives/361

こちらも既に済んでいるのでスキップします。

## Compose Multiplatform入門（画面遷移編）

https://nextat.co.jp/staff/archives/364

ここからやってみたいと思います。

この記事は[Koin](https://insert-koin.io/)と[Voyager](https://voyager.adriel.cafe/)を使った画面遷移についてまとまっています。以下、記事と重複するところもありますが、私がやった手順や調べたことなどと共にまとめます。

### Koinとは？

Kotlin用のDIプラットフォーム。基礎となる`koin-core`とそれ以外（例：`koin-android`, `koin-compose`, `koin-ktor`）などに分かれている。

### Voyagerとは？

マルチプラットフォーム用の画面遷移ライブラリ。単体でも簡単に画面遷移を記述する事ができるが、Koinと合わせることでさらに強力に画面遷移をわかりやすく記述することができる。
Activityを分けず、1つのActivityにViewControllerを集約し、その中で画面遷移を行う事で、簡単に画面の記述・追加などが行える。

### セットアップ

記事には、`libs.versions.toml`に記載する方法が取られていますが、実際に何をしているのかが不明瞭だったので、`composeApp/build.gradle.kts`に記載した上で、Android Studioに修正してもらう方法を取ります。

```kotlin:composeApp/build.gradle.kts 一部抜粋
kotlin {
    sourceSets {
        commonMain.dependencies {
            // 追記
            implementation("io.insert-koin:koin-core:4.0.0")
            implementation("io.insert-koin:koin-compose:4.0.0")
            implementation("cafe.adriel.voyager:voyager-koin:1.1.0-beta03")
            implementation("cafe.adriel.voyager:voyager-navigator:1.1.0-beta03")
            implementation("cafe.adriel.voyager:voyager-screenmodel:1.1.0-beta03")
        }
    }
}
```

追記するとAndroid Studioが波線をつけるので、マウスカーソルを重ねて、`Replace with ...`を選択し`libs.versions.toml`にバージョン情報を移します。tomlファイルはversion.refを書き直しています。

```kotlin:composeApp/build.gradle.kts 一部抜粋
kotlin {
    sourceSets {
        commonMain.dependencies {
            implementation(libs.koin.core)
            implementation(libs.koin.compose)
            implementation(libs.voyager.koin)
            implementation(libs.voyager.navigator)
            implementation(libs.voyager.screenmodel)
        }
    }
}
```

```toml:libs.versions.toml 一部抜粋
[versions]
koin = "4.0.0"
voyager = "1.1.0-beta03"

[libraries]
koin-compose = { module = "io.insert-koin:koin-compose", version.ref = "koin" }
koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" }
voyager-koin = { module = "cafe.adriel.voyager:voyager-koin", version.ref = "voyager" }
voyager-navigator = { module = "cafe.adriel.voyager:voyager-navigator", version.ref = "voyager" }
voyager-screenmodel = { module = "cafe.adriel.voyager:voyager-screenmodel", version.ref = "voyager" }
```

色々と準備が出来たら、`build.gradle.kts`ファイルの右上に出ている`Sync Now`をクリックします。

### ファイルの追加

記事の順番の通りにファイルを追加していくと補完が効かないので、ちょっと順番を変えていきます。また、ファイルの配置場所も若干変えています。

#### AnotherScreenViewModelクラスの追加

```kotlin:composeApp/src/commonMain/kotlin/info/uehatsu/cmptest/viewmodel/AnotherScreenViewModel.kt
package info.uehatsu.cmptest.viewmodel

import cafe.adriel.voyager.core.model.ScreenModel

class AnotherScreenViewModel(val postId: String) : ScreenModel {
}
```

#### AnotherScreenクラスの追加

以下の変更をしています。

- `val ViewModel`
    - `val viewModel: AnotherScreenViewModel` に修正
    - KoinのDI時の型を確定させるため
- `getScreenModel {}`
    - `koinScreenModel {}` に修正
    - Deprecatedのため修正

```kotlin:composeApp/src/commonMain/kotlin/info/uehatsu/cmptest/presentation/screen/another/AnotherScreen.kt
package info.uehatsu.cmptest.presentation.screen.another

import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material.Button
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import cafe.adriel.voyager.core.screen.Screen
import cafe.adriel.voyager.koin.koinScreenModel
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import info.uehatsu.cmptest.viewmodel.AnotherScreenViewModel
import org.koin.core.parameter.parametersOf

class AnotherScreen(private val postId: String) : Screen {

    @Composable
    override fun Content() {
        val viewModel: AnotherScreenViewModel = koinScreenModel { parametersOf(postId) }
        val navigator = LocalNavigator.currentOrThrow

        Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
            Text(text = viewModel.postId)

            Button(onClick = { navigator.pop() }) {
                Text("戻る")
            }
        }
    }

}
```

#### HomeScreenViewModelの追加

```kotlin:composeApp/src/commonMain/kotlin/info/uehatsu/cmptest/viewmodel/HomeScreenViewModel.kt
package info.uehatsu.cmptest.viewmodel

import cafe.adriel.voyager.core.model.ScreenModel
import info.uehatsu.cmptest.Greeting
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update

class HomeScreenViewModel(private val greeting: Greeting) : ScreenModel {
    
    private val _showsContent = MutableStateFlow(false)
    val showContent = _showsContent.asStateFlow()
    
    private val _greet = MutableStateFlow(greeting.greet())
    val greet = _greet.asStateFlow()
    
    fun toggleContent() {
        _showsContent.update { !it }
    }
    
}
```

#### HomeScreenの追加 (WIP)

Screensを作成していないので、一旦ガワだけ作成します。

```kotlin:composeApp/src/commonMain/kotlin/info/uehatsu/cmptest/presentation/screen/home/HomeScreen.kt
package info.uehatsu.cmptest.presentation.screen.home

import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import cafe.adriel.voyager.core.screen.Screen
import cafe.adriel.voyager.koin.koinScreenModel
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import info.uehatsu.cmptest.viewmodel.HomeScreenViewModel

class HomeScreen : Screen {

    @Composable
    override fun Content() {
        val viewModel: HomeScreenViewModel = koinScreenModel()
        val navigator = LocalNavigator.currentOrThrow
        val showsContent by viewModel.showContent.collectAsState()

        Column(
            modifier = Modifier.fillMaxWidth(),
            horizontalAlignment = Alignment.CenterHorizontally,
        ) {
        }
    }

}
```

#### Screensクラスの追加

以下変更しました。

- `register {}`
    - `register<Screens.Home> {}`に変更
        - KoinのDI型を明示的に指定
    - `register<Screens.Another> {}`に変更
        - KoinのDI型を明示的に指定

```kotlin:composeApp/src/commonMain/kotlin/info/uehatsu/cmptest/presentation/screen/Screens.kt
package info.uehatsu.cmptest.presentation.screen

import cafe.adriel.voyager.core.registry.ScreenProvider
import cafe.adriel.voyager.core.registry.screenModule
import info.uehatsu.cmptest.presentation.screen.another.AnotherScreen
import info.uehatsu.cmptest.presentation.screen.home.HomeScreen

sealed class Screens : ScreenProvider {
    data object Home : Screens()
    data class Another(val postId: String) : Screens()
}

val appScreenModule = screenModule {
    register<Screens.Home> {
        HomeScreen()
    }
    register<Screens.Another> { provider ->
        AnotherScreen(provider.postId)
    }
}
```

#### HomeScreenクラスの修正

ガワだけ用意した`HomeScreen`クラスを完成させます。

```kotlin:composeApp/src/commonMain/kotlin/info/uehatsu/cmptest/presentation/screen/home/HomeScreen.kt
package info.uehatsu.cmptest.presentation.screen.home

import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material.Button
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import cafe.adriel.voyager.core.registry.rememberScreen
import cafe.adriel.voyager.core.screen.Screen
import cafe.adriel.voyager.koin.koinScreenModel
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import composemultiplatformtest.composeapp.generated.resources.Res
import composemultiplatformtest.composeapp.generated.resources.compose_multiplatform
import info.uehatsu.cmptest.presentation.screen.Screens
import info.uehatsu.cmptest.viewmodel.HomeScreenViewModel
import org.jetbrains.compose.resources.painterResource

class HomeScreen : Screen {

    @Composable
    override fun Content() {
        val viewModel: HomeScreenViewModel = koinScreenModel()
        val navigator = LocalNavigator.currentOrThrow
        val showsContent by viewModel.showContent.collectAsState()

        Column(
            modifier = Modifier.fillMaxWidth(),
            horizontalAlignment = Alignment.CenterHorizontally,
        ) {
            val anotherScreen = rememberScreen(Screens.Another(postId = "dummy_post_id"))

            Button(onClick = { navigator.push(anotherScreen) }) {
                Text("画面遷移")
            }

            Button(onClick = { viewModel.toggleContent() }) {
                Text("Click me!")
            }

            AnimatedVisibility(showsContent) {
                val greeting by viewModel.greet.collectAsState()
                Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
                    Image(painterResource(Res.drawable.compose_multiplatform), null)
                    Text("Compose: $greeting")
                }
            }
        }
    }
    
}
```

#### ViewModelModulesの追加

```kotlin:composeApp/src/commonMain/kotlin/info/uehatsu/cmptest/module/ViewModelModules.kt
package info.uehatsu.cmptest.module

import info.uehatsu.cmptest.Greeting
import info.uehatsu.cmptest.viewmodel.AnotherScreenViewModel
import info.uehatsu.cmptest.viewmodel.HomeScreenViewModel
import org.koin.dsl.module

val viewModelModule = module {
    factory {
        HomeScreenViewModel(get())
    }
    factory {
        Greeting()
    }
    factory { params ->
        AnotherScreenViewModel(postId = params.get())
    }
}
```

#### AppModulesの追加

以下を変更しています。

- importを削除
- Listを削除

```kotlin:
package info.uehatsu.cmptest.module

fun appModules() = listOf(viewModelModule)
```

#### Appクラスの更新

```kotlin:composeApp/src/commonMain/kotlin/info/uehatsu/cmptest/App.kt
package info.uehatsu.cmptest

import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import cafe.adriel.voyager.core.registry.ScreenRegistry
import cafe.adriel.voyager.core.registry.rememberScreen
import cafe.adriel.voyager.navigator.Navigator
import info.uehatsu.cmptest.module.appModules
import info.uehatsu.cmptest.presentation.screen.Screens
import info.uehatsu.cmptest.presentation.screen.appScreenModule
import org.jetbrains.compose.ui.tooling.preview.Preview
import org.koin.compose.KoinApplication

@Composable
@Preview
fun App() {
    KoinApplication(application = {
        modules(appModules())
    }) {
        MaterialTheme {
            Surface(
                modifier = Modifier.fillMaxSize(),
                color = MaterialTheme.colors.background
            ) {
                ScreenRegistry {
                    appScreenModule()
                }

                val homeScreen = rememberScreen(Screens.Home)
                Navigator(homeScreen)
            }
        }
    }
}
```

### 実行

これでiOS, Androidとも実行が可能になりました。記事には`co.touchlab:stately-common`を入れるように書かれていますが、私の環境では入れなくても特にエラーになりませんでした。修正されたのかも知れませんね。

## Compose Multiplatform 入門（ロギング編）

https://nextat.co.jp/staff/archives/366

### Kermitとは？

Kotlin用のロギングライブラリです。

https://kermit.touchlab.co/

### セットアップ

今回はtomlファイルを直接いじって見たいと思います。修正したら、同期(Sync Now)しておきます。

```toml:gradle/libs.versions.toml 一部抜粋
[versions]
kermit = "2.0.4"

[libraries]
kermit = { module = "co.touchlab:kermit", version.ref = "kermit" }
```

```kotlin:composeApp/build.gradle.kts 一部抜粋
kotlin {
    sourceSets {
        commonMain.dependencies {
            implementation(libs.kermit)
        }
    }
}
```

### AnotherScreen.ktを修正する

先に作成した`AnotherScreen.kt`を修正します。また以下修正を行いました。

- `LifecycleEffect()`
    - `LaunchedEffect(Unit) {}` に書き換え
    - Deplicatedのため

```kotlin:
package info.uehatsu.cmptest.presentation.screen.another

import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material.Button
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import cafe.adriel.voyager.core.screen.Screen
import cafe.adriel.voyager.koin.koinScreenModel
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import co.touchlab.kermit.Logger
import info.uehatsu.cmptest.viewmodel.AnotherScreenViewModel
import org.koin.core.parameter.parametersOf

class AnotherScreen(private val postId: String) : Screen {

    @Composable
    override fun Content() {
        val viewModel: AnotherScreenViewModel = koinScreenModel { parametersOf(postId) }
        val navigator = LocalNavigator.currentOrThrow

        LaunchedEffect(Unit) {
            Logger.i { "Open Another Screen" }
        }

        Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
            Text(text = viewModel.postId)

            Button(onClick = { navigator.pop() }) {
                Text("戻る")
            }
        }
    }

}
```

### 実行

実行し、画面遷移ボタンをタップしてAnotherScreenが表示されると、Androidの場合、以下のようなログが`LogCat`に表示されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/65508/d0e3c584-bea0-128c-8527-3033bd2a9a52.png)

iOSの場合はRunの実行Logに表示されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/65508/47804722-bb43-7a36-d369-314822d184cb.png)

## それ以降の記事

FirebaseLoginや、Ktorを使ったHTTP通信、リソースなどについて記事が続きます。今日はこの後予定があるので、明日改めて続編を書くかも知れません。

# 最後に

先にも書きましたが、参照した記事には続きがありました。明日また書くかも知れません。ひとまず「Compose Multiplatformの最初の一歩」はクリアした感じ。ここからJetpack Composeや、実際のKMP, CMPのコアな部分も触ってみたいと思っています。