はじめに
この記事はNTTテクノクロス Advent Calendar 2025 シリーズ1、13日目の記事です。
こんにちは、NTTテクノクロスの大谷です。
ちょうど一年前はiOS関連の記事を書いたのですが、今年はAndroid開発に携わる機会があったため、今回のメインはAndroidです。
中でも気になっていたものの結局触る機会のなかったKotlin MultiplatformとCompose Multiplatformをテーマとし、Androidネイティブ開発との違い、導入時に躓きそうなところを重点的に見ていきたいと思っています。
Kotlin Multiplatformとは
JetBrainsが開発したオープンソーステクノロジーです。
Kotlinを用いてAndroid、iOS、デスクトップ、Web、サーバーでコードを共有することが可能です。
コードを共有する範囲も柔軟性が高く、ネイティブUIを保ったままビジネスロジックを共有化させたり、Compose Multiplatformを用いれば、UIコードまで共有することも可能です。
公式サイトより
Compose Multiplatformとは
Jetpack Composeを拡張したクロスプラットフォーム用のUIフレームワークです。
一部のAndroid固有のAPIを除き、ほぼJetpack Composeと同じようにプラットフォーム共通のUIを作成することができます。
公式サイトより
今回の作成物
「触ってみる」といってもふわふわしているので今回もテーマを決めてアプリ作成に臨みたいと思います。
ズバリ「宴会会場比較アプリ」です。
今年、部署の忘年会(100人規模)の幹事を同期・後輩の複数人で担当したのですが、それがまぁ~~大変でした。特に会場探し。参加者数が多いのでまず全員が入れる規模の会場を探し、予算や会社からの距離、喫煙所の有無などの条件を洗い出して比較……気づけばブラウザのタブがすごいことになっていました。
今回はこれを簡単に比較・管理できるアプリを目指して作成していきます。
要件はざっと以下の通りです。(Excelでええやん、というのは言わないでください🙃)
- 参加者数・予算など開催する宴会の概要を登録できる
- 上記の宴会に紐づけて会場を複数登録できる
- 登録した会場を一覧で表示し、比較できる
スマホで調べたりメモすることが多かったので、今回の開発対象もAndroid、iOSとします。
開発環境
今回使用した開発環境は以下の通りです。
OS: Windows 11 Pro 24H2
IDE: Android Studio Otter | 2025.2.1 Patch 1
普段使用しているのがWindowsだったため上記を選択しました。
ただ、iOSアプリのビルドにはXcodeをインストールしたMacが必要なため、最初からMacで開発を進めたほうが楽そうです。
また、今回のコード実装にはGeminiに大変お世話になりました。
無料枠なのであまり期待はしていませんでしたが、画面作成時などは思っていた以上にサクッと想定した通りのものが出てきて感動しました。
1. プロジェクトの作成
1.1 下準備
まず、Android StudioでKotlin Multiplatformを利用できるようにします。
Kotlin Multiplatformプラグインをインストールします。

続いて、設定(Languages & Frameworks > Kotlin)でEnable K2 modeにチェックが入っていることを確認します。

これで下準備は完了です。
1.2 プロジェクトを作成する
上記のプラグインがインストールされていると、新規プロジェクトの選択画面にKotlin Multiplatformが出現するのでそれを選択します。

次のプロジェクト概要入力画面はAndroidネイティブアプリ作成時と変わりません。よしなに入力して次に進みます。
次の画面ではKotlin Multiplatformの設定を行います。
今回の対象はAndroid、iOSなので、この2つにチェックを入れます。
UI ImplementationでUIコードを共有するかどうかを選択します。今回はUIも共有して作成したいのでShare UIを選択しました。もしUIコードを共有せずそれぞれネイティブで実装する場合は、Do Not Share UIを選択します。

設定が完了するとプロジェクトが作成されます。初期のプロジェクト構成は画像の通りです。(オレンジ部分はビルドすると出てきます。)
これからは基本的に以下のディレクトリに実装していきます。
commonMain:共有部分のコード
androidMain:Android固有のコード
iosMain:iOS固有のコード

モデルの作成
宴会の情報と会場の情報を管理するモデルを作成します。
今回データ保存は一旦JSONファイルをローカルに保存するようにします。そのため@Serializableをつけてシリアライズ可能にしておきます。
@Serializable
data class Party(
/**
* 宴会の基本条件を定義する
*/
// 宴会ID
val id: String,
// 宴会名
val name: String,
// 開催日
val date: LocalDate?,
// 開始時刻
val startTime: LocalTime?,
// 終了時刻
val endTime: LocalTime?,
// 予算下限
val lowerBudget: Int?,
// 予算上限
val upperBudget: Int?,
// 参加者数
val participants: Int,
// 条件
val conditions: Set<PartyCondition> = emptySet(),
// 会場
val venues: List<Venue> = emptyList()
)
@Serializable
data class Venue(
/**
* 宴会会場を定義する
*/
// 宴会会場ID
val id: String,
// 宴会ID
val partyId: String,
// 会場名
val name: String,
// 場所
val address: String = "",
// 説明
val description: String = "",
// 画像
val imageUrl: String? = null,
// 会場HP
val url: String? = null,
// 収容人数
val capacity: Int,
// 価格
val price: Int,
// 宴会で指定された条件のうち、この会場が満たしている条件
val satisfiedConditions: Set<PartyCondition> = emptySet()
)
宴会・会場のそれぞれは基本情報のほかに「こだわり条件」を持っています。
それをPartyConditionクラスでEnumとして定義しています。
enum class PartyCondition(val label: String) {
ALL_YOU_CAN_DRINK("飲み放題あり"),
ALL_YOU_CAN_EAT("食べ放題あり"),
COURSE_MENU("コース料理あり"),
SMOKING_ALLOWED("喫煙可"),
PRIVATE_ROOM("個室あり"),
PRIVATE_RENTAL("貸し切り可"),
SOUND_SYSTEM("マイク・音響設備あり"),
VISUAL_EQUIPMENT("プロジェクター・モニターあり"),
LUNCH_AVAILABLE("ランチ営業あり"),
LATE_NIGHT("深夜営業あり"),
CARD_ACCEPTED("カード可"),
PARKING_AVAILABLE("駐車場あり");
companion object {
fun getAll() = entries
}
}
ナビゲーションの作成
まずは画面遷移のルートを作成して適用します。
以下の通り4つの画面を用意して遷移させる想定です。
/**
* アプリケーション内の画面遷移ルート定義
* 各画面に必要な引数もここで定義する想定
*/
@Serializable
sealed class Screen
// 宴会一覧画面 (ホーム)
@Serializable
data object PartyList : Screen()
// 宴会企画(作成)画面
@Serializable
data object PartyCreate : Screen()
// 宴会詳細・比較画面 (宴会IDを引数に取る)
@Serializable
data class PartyDetail(val partyId: String) : Screen()
// 会場登録画面 (宴会IDを引数に取る)
@Serializable
data class VenueCreate(val partyId: String) : Screen()
@Composable
@Preview
fun App() {
val navController = rememberNavController()
val scope = rememberCoroutineScope()
Scaffold(
modifier = Modifier.fillMaxSize()
) { innerPadding ->
NavHost(
navController = navController,
startDestination = PartyList,
modifier = Modifier.padding(innerPadding)
) {
// 宴会一覧画面
composable<PartyList> {
PartyListScreen(
onNavigateToCreate = { navController.navigate(PartyCreate) },
onNavigateToDetail = { partyId -> navController.navigate(PartyDetail(partyId)) }
)
}
// 宴会企画画面
composable<PartyCreate> {
PartyCreateScreen(
onNavigateBack = { navController.popBackStack() },
onPartyCreate = { navController.popBackStack() }
}
)
}
// 宴会詳細画面
composable<PartyDetail> { backStackEntry ->
val partyDetail: PartyDetail = backStackEntry.toRoute()
PartyDetailScreen(
partyId = partyDetail.partyId,
onNavigateBack = { navController.popBackStack() },
onNavigateToVenueCreate = { navController.navigate(VenueCreate(partyDetail.partyId)) }
)
}
// 会場登録画面
composable<VenueCreate> { backStackEntry ->
val venueCreate: VenueCreate = backStackEntry.toRoute()
VenueCreateScreen(
partyId = venueCreate.partyId,
onNavigateBack = { navController.popBackStack() },
onVenueCreate = { avController.popBackStack() }
}
)
}
}
}
}
画面の作成
各画面を作成していきます。ほぼAndroidアプリを作成するときと同じ感覚で書くことができました。長くなるので実装は伏せますが、完成した画面がこちらになります。
特に宴会作成画面のこだわり条件の部分のUIは一発でGeminiさんが出してくれました。そうそうこれこれ!
リソースを使う
ここで注意したいのがリソースの使い方です。
上記画面のアイコンなどの画像(〇、✕、+)は今回Material Symbols & Iconsからxmlでダウンロードしたものを取り込んで使用しています。
これを通常のJetpack Composeで利用しようとする際は以下のようになります。
import <アプリのパス>.R
Icon(
painter = painterResource(id = R.drawable.add_24px),
contentDescription = "宴会を作成"
)
同じようにCompose Multiplatformで画像を利用する場合はまずcompose.components.resourcesライブラリを依存関係に含める必要があります。そのうえでコードは以下のようになります。
import <アプリ名>.composeapp.generated.resources.Res
import <アプリ名>.composeapp.generated.resources.add_24px
Icon(
paintor = paintorResource(resource = Res.drawable.add_24px),
contentDescription = "宴会を作成"
)
ネイティブ実装
これが今回のキモです。Kotlin Multiplatformでは、共通のコードに対してそれぞれのプラットフォーム固有のコードを書くことができます。
今回のアプリでは、共通のコードでデータのテキスト化まで行います。このテキスト化したデータをローカルのファイルに保存する処理は、各プラットフォームごとに分ける必要がありました。
共通コード
expectがついているものが「期待される」宣言です。いったんここでインターフェースだけ宣言しておいて、実際の中身はそれぞれのプラットフォームごとにactualをつけて実装します。
expect/actualの組は同じパッケージ内で実装する必要があります。
package com.example.enkaiboard.data
internal expect suspend fun platformWriteTextToFile(filePath: String, text: String)
internal expect suspend fun platformReadTextFromFile(filePath: String): String?
class FileStorage(private val basePath: String) {
/**
* 共通のファイル保存/読み込み処理
* @param basePath ベースとなるディレクトリパス
*/
suspend fun writeTextToFile(fileName: String, text: String) {
/**
* ファイル書き込み処理
* @param fileName ファイル名
* @param text 書き込むテキスト
*/
val fullPath = "$basePath/$fileName"
platformWriteTextToFile(fullPath, text)
}
suspend fun readTextFromFile(fileName: String): String? {
/**
* ファイル読み込み処理
* @param fileName ファイル名
* @return 読み込んだテキスト、見つからなかった場合はnull
*/
val fullPath = "$basePath/$fileName"
return platformReadTextFromFile(fullPath)
}
}
Android
Android環境ではFileクラスのwriteText/readTextを使ってファイルの読み書きを行います。
package com.example.enkaiboard.data
import java.io.File
// Android環境でファイル操作の実装
internal actual suspend fun platformWriteTextToFile(filePath: String, text: String) {
val file = File(filePath)
file.writeText(text)
}
internal actual suspend fun platformReadTextFromFile(filePath: String): String? {
val file = File(filePath)
return if (file.exists()) file.readText() else null
}
iOS
iOS環境でもKotlin/Nativeで用意された固有APIに関してはKotlinで扱うことが可能です。
NSStringのwriteToFile/stringWithContentsOfFileメソッドでファイルの読み書きを行っています。
NSStringのようなObjective-Cのライブラリを使用する場合は、ExpermentalForeignApiをオプトインする必要があります。
package com.example.enkaiboard.data
import kotlinx.cinterop.ExperimentalForeignApi
import platform.Foundation.NSString
import platform.Foundation.NSUTF8StringEncoding
import platform.Foundation.stringWithContentsOfFile
import platform.Foundation.writeToFile
// iOS環境でファイル操作の実装
@OptIn(ExperimentalForeignApi::class)
internal actual suspend fun platformWriteTextToFile(filePath: String, text: String) {
val nsString = text as NSString
nsString.writeToFile(filePath, true, NSUTF8StringEncoding, null)
}
@OptIn(ExperimentalForeignApi::class)
internal actual suspend fun platformReadTextFromFile(filePath: String): String? {
return NSString.stringWithContentsOfFile(filePath, NSUTF8StringEncoding, null)
}
ビルドしてみる
一通り実装が終わったので早速ビルドしてエミュレーターを使って実行していきます。
Android
Androidの方はいつも通りAndroidStudioのビルドボタンを押せばOKです。構成はcomposeAppを選択します。

iOS
冒頭でも述べましたが、iOSアプリのビルドにはXcodeをインストールしたMacが必要です。
使用した環境は以下の通りです。
MacOS Sonoma
Xcode 16.1
コードを移してきただけではもちろんビルドできないので準備が必要です。
自力で必要なものを揃えてビルドスクリプトを設定して...とすればXcodeだけでも可能かもしれませんが、面倒なので素直にこちらにもAndroidStudioを入れます。手順はWindows版と同じです。
Macの場合はさらに追加の手順が必要です。詳細はこちらの記事が大変参考になりました。
1. KDoctorを使って必要な環境を揃える
Java、Ruby、CocoaPodsあたりのインストールが必要になります。KDoctorに従ってください。最終的に以下の表示になればOKです。
Conclusion:
✓ Your operation system is ready for Kotlin Multiplatform Mobile Development!
2. AndroidStudioのビルド設定
今回JDKを別途入れたのでAndroidStudioで使用するJDKを指定してあげる必要がありました。
設定のBuild, Execution, Deployment > Build Tools > Gradleを開き、Gradle JDKにインストールしたJDKのパスを指定します。

3. Xcodeのビルド設定
Xcode側で署名などの設定が必要です。プロジェクトルートのiosApp配下にiosApp.xcodeprojファイルが生成されているはずなので、それをXcodeで開きます。
Signing & CapabilitiesのTeamの部分が空になっているはずなので設定します。

ついでにBuild Settngs内のiOS Deployment Targetも設定しておきます。

あとはAndroidStudioで構成をiosAppに設定してビルドすれば……

動いた!!
Material3のテーマを利用しているのでiOSらしさはありませんが普通に動きます。
ほぼアプリ1つ分の労力でAndroid/iOS両方に対応できるのは大変オトクな気分になります。
おわりに
今回はKotlin MultiplatformとCompose Multiplatformを利用してAndroid/iOSアプリを作成しました。
単純なアプリだったこともありますが、まさかファイル読み書きの2つのメソッド以外共有できるとは思いませんでした。うまく使えば維持管理の効率が爆上がりしそうな予感がします。
今度はカメラなどもう少し物理デバイスにアクセスするところを見てみたいと思います。
それではまた、何かのご縁がありましたらお会いしましょう😊



