はじめに
Compose Multiplatform(CMP)は、KotlinとCompose UIを使ってAndroid/iOS/デスクトップ/WebのUIを共通の宣言的コードで構築できる仕組みです。端的に言えば「Kotlin×Composeで、ほぼ同じ書き味のまま複数OSへ展開」できるのが最大の魅力です。
本記事では、セットアップ→共有ロジック設計→期待/実装(expect/actual)でのネイティブ連携→UI構築と状態管理→テスト/配布 の流れで、学生や若手エンジニアでも迷わず始められるようにコードと意図をセットで解説します。
1. Compose Multiplatformの全体像
1-1. 何が「共通」になり、何が「個別」か
共通化できるもの:UI(Composeの@Composable)、ビジネスロジック、HTTP通信(Ktor)、JSON変換(kotlinx.serialization)、状態管理(State/Flow)など。
個別実装が必要なもの:ファイル/センサー/セキュアストレージ、OS固有のUIコンポーネント、OSの認証・共有シートなど。
OS固有処理はexpect/actualで橋渡しします。これにより「共通コードから、必要なときだけネイティブAPI」を呼べます。
1-2. 対応プラットフォーム
Android(Compose)、iOS(UIKit/SwiftUIホスト内)、Desktop(Compose for Desktop: JVM/Skiko)、Web(Compose HTML)。プロジェクト設定で複数ターゲットを並べ、共通モジュールを中心に構成します。
すべてを同時に始める必要はありません。まずはAndroid+Desktopで始め、後からiOS/Webを追加するのが現実的です。
1-3. どんなときに向いている?
ドメインロジックが厚いアプリ(フォーム/テーブル/計算/業務フロー)や、画面デザインの要件が近い複数OSの製品に向いています。逆に、OS固有のUIや高度な3D/メディアが中心ならネイティブ比率が増えます。
「まず同じ設計思想でプロトタイプ→価値検証→必要に応じて個別最適化」の順で導入しましょう。
2. プロジェクトの作り方と基本設定
2-1. Gradleとターゲットの定義
まずはKotlin MultiplatformとComposeプラグインを導入し、ターゲットを宣言します。Android+Desktop(JVM)から始める例です。
// build.gradle.kts(ルート or :shared)
plugins {
kotlin("multiplatform") version "2.0.0"
id("org.jetbrains.compose") version "1.6.10"
id("com.android.library")
}
kotlin {
androidTarget()
jvm("desktop")
sourceSets {
val commonMain by getting {
dependencies {
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material3)
implementation("io.ktor:ktor-client-core:2.3.7")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
}
}
val androidMain by getting {
dependencies { implementation("io.ktor:ktor-client-okhttp:2.3.7") }
}
val desktopMain by getting {
dependencies { implementation("io.ktor:ktor-client-java:2.3.7") }
}
}
}
android {
namespace = "com.example.cmp"
compileSdk = 34
}
2-2. 最小のUI(@Composable)を共通で作る
共通UIはAndroid/Desktop/iOS/Webでそのまま再利用できます。まずはシンプルなカウンターを作って、動く体験を掴みましょう。
@Composable
fun CounterScreen() {
var count by remember { mutableStateOf(0) }
MaterialTheme {
Column(Modifier.padding(16.dp)) {
Text("Count: $count", style = MaterialTheme.typography.headlineMedium)
Spacer(Modifier.height(12.dp))
Button(onClick = { count++ }) { Text("+1") }
}
}
}
意図:rememberとmutableStateOfで状態を持ち、宣言的にUIを更新。UIロジックは共通なので、OSごとに別のUIを覚える必要がありません。
2-3. デスクトップで即試す(Compose for Desktop)
Desktopターゲットは起動が速く、UIの検証に最適。Androidエミュレーターなしで共通UIを動かせます。
// desktopエントリ(:desktopモジュールのmain関数など)
fun main() = application {
Window(onCloseRequest = ::exitApplication, title = "CMP Demo") {
CounterScreen()
}
}
意図:PCだけで開発ループを回せるため、初期の学習コストを下げられます。あとからAndroid/iOSへ展開すればOK。
3. ネイティブAPIとの橋渡し:expect/actual
3-1. 仕組みの概念
expectで「共通側が欲しい関数の形」を宣言し、各OSのactualで具体実装を与えます。共通コードはOSを意識せずに呼べるのが利点。
// commonMain
expect object PlatformInfo {
fun name(): String
}
// androidMain
actual object PlatformInfo {
actual fun name(): String = "Android ${android.os.Build.VERSION.SDK_INT}"
}
// desktopMain
actual object PlatformInfo {
actual fun name(): String = "Desktop JVM"
}
意図:OS固有ロジックはここに閉じ込め、共通UIから「PlatformInfo.name()を表示」するだけに。依存の向きがシンプルになり、テストもしやすい。
3-2. HTTPやストレージの差し替え
Ktorクライアントのエンジン(OkHttp/Java/NSURLSession)や、セキュアストレージ(Android Keystore/iOS Keychain)はOSごとに変わります。expect/actualで抽象化。
// commonMain
interface SecureStore {
fun put(k: String, v: ByteArray)
fun get(k: String): ByteArray?
}
expect fun secureStore(): SecureStore
意図:共通コードはsecureStore()しか知らず、OSごとの安全な保管方法(暗号化/生体認証)はactual側で担います。将来の差し替えも容易。
3-3. iOS組み込みの注意点
iOSで使う場合は、ComposeView(UIKit)やSwiftUIのホストにComposableを載せます。ナビゲーションなどはアプリの構造に合わせて役割分担を。
CocoaPods/Gradleの連携やXcode設定は最初のハードルになりやすいので、最小構成で通してから段階的に追加してください。
4. 状態管理・非同期・ナビゲーション
4-1. state hoisting(状態を上に持ち上げる)
UIの再利用性を高めるため、Composableは「状態(値)とイベント(関数)を受け取る」形にします。内部にロジックを閉じ込めないのがコツ。
@Composable
fun SearchBox(query: String, onChange: (String) -> Unit) {
OutlinedTextField(value = query, onValueChange = onChange, label = { Text("検索") })
}
意図:テスト・プレビュー・他画面での使い回しが簡単。プラットフォームを跨いでも同じ振る舞いを保てます。
4-2. FlowとLaunchedEffectで非同期を扱う
共通ロジックはFlow/Coroutineで書き、UI側でcollectAsStateやLaunchedEffectで結びつけます。ライフサイクル差は少なくなります。
@Composable
fun UserList(vm: UsersViewModel) {
val users by vm.users.collectAsState(initial = emptyList())
LazyColumn { items(users) { Text(it.name) } }
}
意図:データ更新に追従してUIが再構築。UI=データの関数というCompose思想がそのまま複数OSで活きます。
4-3. ルーティング(ナビゲーション)
AndroidはNavigation-Compose、Desktop/iOS/Webは軽量の独自ルータ/状態遷移で十分なことが多いです。まずは「画面=状態の切り替え」をwhenで表現し、必要に応じてライブラリ化。
5. テスト・ビルド・配布の実務ポイント
5-1. 共通ロジックのテストを厚く
commonTestにユニットテストを集約し、KtorのMockEngineでHTTPを再現。UIは「状態の変換」が中心なので、ロジック部のテスト効果が高いです。
val mock = MockEngine {
respond("""{"name":"Alice"}""", headersOf("Content-Type","application/json"))
}
val client = HttpClient(mock) {
install(ContentNegotiation) { json() }
}
// → Repository(client).user("id") を検証
意図:実機差を受けないレイヤの品質を先に固め、OS固有の問題は個別に切り分け。
5-2. ビルド構成とCI
AndroidはGradle、Desktopはcompose-desktopのタスク(package)で配布物(.dmg/.msi 等)を作成可能。iOSはXcodeビルドの段取りが必要。CIで「共通コードのテスト→各OSのビルド」を並列実行できるように。
5-3. パフォーマンス/最適化の勘所
再コンポーズ回数を抑える(remember/derivedStateOf)、LazyColumnにキーを付ける、画像のサイズ最適化、HTTPのキャッシュ制御——など、Android Composeの知見がそのまま効きます。Desktop/Webは描画バックエンドが異なるため、重いアニメーションは段階導入で検証しましょう。
まとめ
Compose Multiplatformは、1つのKotlin/Composeコードで複数OSへ展開できる強力な選択肢。共通化はUI/ロジック/通信を中心に、OS固有機能はexpect/actualで安全に橋渡し。まずはAndroid+Desktopで価値を検証し、iOS/Webへ段階展開するのが現実的な導入パスです。
本記事のスケルトンをベースに、小さなアプリ(リスト/詳細/検索)を共通UIで組んでみると、学習コストと保守性のバランスの良さを実感できるはずです。

