ジョブカン事業部のアドベントカレンダー16日目の記事です!
ジョブカン事業部でモバイルアプリ開発を担当している @kohan と申します。
今回はモバイルアプリのクロスプラットフォーム開発で一番有名といっても過言ではないFlutterと最近勢いを増しているCompose Multiplatform(以降、本記事では CMP と表記します)のネイティブ連携部分に着目して、調査と比較を行ってみました!
比較対象のスコープについて
本記事では、これらフレームワークの主要なユースケースである iOS および Android に焦点を絞って比較します。そのため、WebやDesktopプラットフォームの調査と比較は含まれないです。
FlutterとCMPとは
どちらもオープンソースのフレームワークで以下の特徴があります。
- 単一のコードベースで、Android, iOS, Desktop, Web 向けのアプリを作成できる
- FlutterならDart、CMPならKotlin
- Web開発でも主流の宣言的UIを採用している
今までのモバイルアプリ開発では基本的にAndroidはJava, Kotlin, xmlを使い、iOSはObjective-C, Swiftを使っているため、アプリ二つ分の開発工数がかかっていました。
ですが、上記フレームワークの登場により、少ない工数で同時にAndroid/iOSアプリを開発できるようになったため、採用する企業も増えてきています。
ただOSをまたぐ、クロスプラットフォームな開発には避けて通れない以下の問題があります。
OS固有の機能との連携
FlutterやCMPの強みは、UI(見た目)だけでなく、データの加工やバリデーションといった「ビジネスロジック」まで共通化できる点にあります。
しかし、実際のアプリ開発はそれだけでは完結しません。例えば以下のような機能は、OS(Android/iOS)ごとに全く異なる仕組みで動いているためです。
- カメラ、マイクの制御
- GPS(位置情報)の取得
- Bluetooth通信
- プッシュ通知
- 機器の情報
もし位置情報を利用するアプリを作りたいとなった場合、共通のコード(DartやKotlin共通モジュール)から直接OS固有の機能を呼び出すことはできないため、
- 「位置情報をください」とAndroid OSに頼むJava/Kotlinコード
- 「位置情報をください」とiOSに頼むSwift/Objective-Cコード
が必要になります。
そこで、今回は実際にネイティブ連携のコードを書いてみて、FlutterとCMPの差を比較してみました。
コードの具体例
検証環境
本記事のコードは以下の環境で動作確認を行っています。
- PC: macOS Sequoia 15.6.1
- Android Studio: Otter | 2025.2.1 Patch 1
- Xcode: 16.2
- Kotlin: 2.2.21
- Compose Multiplatform: 1.9.3
- Flutter: 3.38.4
実装について
本記事のコードの一部は、生成AIの支援を受けて記述しています。動作確認は行っていますが、より効率的・モダンな記述方法があるかもしれません。
両者のネイティブ連携実装周りの違いを比較するために、実際にネイティブ連携を行うコードを書きました。
1. OS情報の取得
Flutter
Flutterは何もライブラリを入れずにネイティブ連携する場合はMethodChannelを直接使うことになるのですが、Flutter公式ドキュメントではPigeonの利用が記載されているため、それに倣ってPigeon(型安全にネイティブ連携ができるライブラリ)の利用を前提にコードを書きます。
1. 共通APIの定義
まずは共通API定義のためのファイルを作成します。これを元にPigeonがDartやKotlin, Swiftのコード生成を行うため、出力先のパスとファイル名を設定します。
// pigeons/platform_api.dart
import 'package:pigeon/pigeon.dart';
@ConfigurePigeon(
PigeonOptions(
dartOut: 'lib/platform_api.g.dart',
dartOptions: DartOptions(),
kotlinOut: 'android/app/src/main/kotlin/com/example/native_compare/PlatformApi.g.kt',
kotlinOptions: KotlinOptions(package: 'com.example.native_compare'),
swiftOut: 'ios/Runner/PlatformApi.g.swift',
swiftOptions: SwiftOptions(),
),
)
@HostApi()
abstract class PlatformApi {
String getOsInfo();
}
次に各プラットフォームで実装できるように下記コマンドでコード生成を行います。
以下コードを実行すると上記定義ファイルを元に Android(Kotlin), iOS(Swift), Dart のコードが自動生成されます。
dart run pigeon --input pigeons/platform_api.dart
2. プラットフォームごとの実装
次に生成されたインターフェース(Android)およびプロトコル(iOS)を、各ネイティブ側で実装し、Flutterエンジンに登録します。
// android/app/src/main/kotlin/com/example/native_compare/PlatformApi.g.kt
// ... 自動生成ファイルなので省略 ...
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface PlatformApi {
// 開発者が実装すべきメソッド
fun getOsInfo(): String
companion object {
/** Sets up an instance of `PlatformApi` to handle messages through the `binaryMessenger`. */
fun setUp(binaryMessenger: BinaryMessenger, api: PlatformApi?, messageChannelSuffix: String = "") {
// ... チャンネルの登録処理など ...
}
}
}
// android/app/src/main/kotlin/com/example/native_compare/MainActivity.kt
package com.example.native_compare
import io.flutter.embedding.android.FlutterActivity
import android.os.Build
import io.flutter.embedding.engine.FlutterEngine
class MainActivity : FlutterActivity() {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
// 生成されたセットアップメソッドを呼び出す
PlatformApi.setUp(flutterEngine.dartExecutor.binaryMessenger, PlatformApiImpl())
}
}
private class PlatformApiImpl : PlatformApi {
override fun getOsInfo(): String {
return "Android ${Build.VERSION.RELEASE}"
}
}
// ios/Runner/PlatformApi.g.swift
// ... 自動生成ファイルなので省略 ...
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
protocol PlatformApi {
// 開発者が実装すべきメソッド(throwsが付いている点に注目)
func getOsInfo() throws -> String
}
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
class PlatformApiSetup {
/// Sets up an instance of `PlatformApi` to handle messages through the `binaryMessenger`.
static func setUp(binaryMessenger: FlutterBinaryMessenger, api: PlatformApi?, messageChannelSuffix: String = "") {
// ... チャンネルの登録やエラーハンドリング処理 ...
}
}
// ios/Runner/AppDelegate.swift
import Flutter
import UIKit
@main
@objc class AppDelegate: FlutterAppDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
let controller : FlutterViewController = window?.rootViewController as! FlutterViewController
PlatformApiSetup.setUp(binaryMessenger: controller.binaryMessenger, api: PlatformApiImpl())
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
}
private class PlatformApiImpl: PlatformApi {
func getOsInfo() throws -> String {
let device = UIDevice.current
return "\(device.systemName) \(device.systemVersion)"
}
}
PlatformApi.g.swiftについて
コード生成しただけだとビルドに失敗するので、ビルド前にRunnerへこのファイルを追加しておく必要があります
3. UIからの呼び出し
最後に、getOsInfo()をUIから呼び出します。
コードからわかるようにプラットフォーム固有実装の部分では同期関数だったんですが、Dart側では非同期関数(Future)になっています。
// lib/platform_api.g.dart
class PlatformApi {
// ... (コンストラクタ等は省略)
// 開発者が呼ぶメソッド
// Future (非同期) になっている
Future<String> getOsInfo() async {
// ... (Pigeonが自動生成したメッセージング処理) ...
}
}
// lib/main.dart
import 'package:flutter/material.dart';
import 'platform_api.g.dart'; // Pigeonで生成されたコード
void main() {
runApp(const App());
}
class App extends StatelessWidget {
const App({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
body: Center(
child: FutureBuilder<String>(
future: PlatformApi().getOsInfo(), // 生成されたAPIを呼ぶ
builder: (context, snapshot) {
final text = snapshot.data ?? '取得中...';
// 例えばAndroidならAndroid 16, iOSならiOS 26のように出力される
return Text(text);
},
),
),
),
);
}
}
なぜ非同期(Future)になるのか?
-
スレッドが異なるため
- Dart(UIスレッド)とネイティブ(メインスレッド)は別の場所で動いており、処理を依頼して結果が返ってくるまで待機する必要があります
-
データの変換が必要なため
- メモリを共有できないため、データを一度バイナリに変換(シリアライズ)して送受信するオーバーヘッドがあります
CMP
1. 共通APIの定義
まず、commonMainソースセットに共通APIのインターフェース(ルール)を宣言します。 expectキーワードをつけた関数は「宣言」のみを行い、ここには実装を書けません。
// composeApp/src/commonMain/kotlin/com/example/nativecompare/Platform.kt
package com.example.nativecompare
expect fun getOsInfo(): String
2. プラットフォームごとの実装
次に、定義したexpectに対応するactualを、各プラットフォーム固有のソースセット(androidMain, iosMain)にて実装します。
// composeApp/src/androidMain/kotlin/com/example/nativecompare/Platform.android.kt
package com.example.nativecompare
import android.os.Build
actual fun getOsInfo() = "Android ${Build.VERSION.RELEASE}"
// composeApp/src/iosMain/kotlin/com/example/nativecompare/Platform.ios.kt
package com.example.nativecompare
import platform.UIKit.UIDevice
actual fun getOsInfo() = "${UIDevice.currentDevice.systemName} ${UIDevice.currentDevice.systemVersion}"
コードを見ると以下のライブラリが使用されていることがわかります。
-
androidMainではandroid.os.build -
iosMainではplatform.UIKit.UIDevice
これは、各ソースセットが最終的にそれぞれのネイティブコード(JVMバイトコードやiOSのネイティブバイナリ)にコンパイルされるためです。その結果、Flutterでやったようなブリッジなどの仕組みを介さずに、OS標準のAPIやライブラリへ直接アクセスできます。
このように、プラットフォーム固有のソースセット内では、各OSのネイティブ実装がそのまま書けるようになっています。
Swiftのライブラリは使える?
今回はKotlinからiOSの標準フレームワークを直接呼んでいますが、Swift製のサードパーティライブラリ(SPM)など、Kotlinから直接呼び出すのが難しいケースもあります。 その場合は、Swift側で実装を行い、Kotlin側で定義したインターフェースを通じて呼び出す(DIする)こともできます。
また、actualの実装忘れはIDEがexpectの部分にNo actual for expect declaration in module(s)を出して教えてくれますし、ビルド対象のプラットフォームのactualの実装がない場合にビルドしたら失敗するので、実行時エラーが起きることはないです。
3. UIからの呼び出し
実装が完了したら、UIから呼び出します。 Kotlinコンパイラが、ビルドターゲットに合わせてexpectと actualをマージして一つの宣言を生成してくれるため、通常の関数と同じように呼び出すことができます。
// composeApp/src/commonMain/kotlin/com/example/nativecompare/App.kt
package com.example.nativecompare
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.material3.Text
@Composable
fun App() {
MaterialTheme {
// 共通UIからgetOsInfo()を呼び出し、プラットフォーム毎の実装が解決される
// 同期的に呼べる
val osInfo = remember { getOsInfo() }
// 例えばAndroidならAndroid 16, iOSならiOS 26のように出力される
Text(text = osInfo)
}
}
なぜ同期関数(直列)で書けるのか?
-
コンパイル時に結合されるため
-
expectとactualはビルドの段階で一つのネイティブコードとして統合されます。実行時には単なる「関数呼び出し」となるため、ブリッジ通信の待ち時間が発生しないです
-
-
同じスレッド・メモリ空間で動くため
- ネイティブコードそのものとして実行されるため、スレッドをまたぐ処理や、データのシリアライズ(変換)オーバーヘッドがほぼゼロで、即座に値を返します
両フレームワークの特徴まとめ
| 特徴 | Flutter (MethodChannel / Pigeon) | CMP (expect / actual) |
|---|---|---|
| 連携の仕組み |
メッセージング方式 (手紙のやり取り) |
直接呼び出し (ダイレクトコール) |
| 呼び出しタイプ |
非同期 (Future)返事を待つ必要がある |
同期 その場で結果が返る |
| 結合タイミング | 実行時 (Runtime) | コンパイル時 (Compile time) |
| データの受け渡し |
シリアライズが必要 バイナリに変換して送受信 |
オーバーヘッドなし ネイティブコードとして統合 |
| 開発フロー | 定義ファイル作成 → コード生成 → 実装 |
expect 定義 → actual 実装 |
| 型安全性 | ツール (Pigeon) に依存 | 言語機能 (Kotlin) で保証 |
ここまで見ると、
「FlutterよりもCMPの方が仕組みとして優れているしCMPを使えば良いんじゃ?」
と思われるかもしれないですが、実務レベルになると「そもそもネイティブ連携コードを自分で書かなくて済む」という点が開発スピードにおいて圧倒的な差となります。
Flutterには長年積み上げられたライブラリ(パッケージ)のエコシステムがあるため、大抵の機能はパッケージに頼るだけで完結します。
実際にその違いがわかる現在地の緯度・経度を取得するコードを書いてみます。
2. 現在地の緯度・経度の取得
Flutter
OS情報の取得ではネイティブ連携の違いを示すためにあえて自前で書いていましたが、今回は実務を想定してgeolocatorパッケージを利用します。
このパッケージはFlutter Favoriteに認定されているFlutterチームが認めた高品質なパッケージなので、実務でも十分選択肢に入ってくるパッケージだと思います。
0. パッケージの追加
まずプロジェクトにパッケージを追加するため、以下のコマンドを実行します。
flutter pub add geolocator
1. 共通APIの定義
パッケージがやってくれているので、開発者は何も定義しなくて良いです!
2. プラットフォームごとの実装
こちらもパッケージがプラットフォームごとの実装をやってくれているので、開発者は何も実装しなくて良いです!
3. UIからの呼び出し
geolocatorパッケージが用意してくれた関数をラップするクラスと関数を実装し、UIから呼び出す。
設定ファイルの編集は必要です
ネイティブコード(Kotlin/Swift)を書く必要はありませんが、OSに「位置情報を使います」と宣言するための設定ファイルへの記述は必要です。
-
Android:
AndroidManifest.xmlに<uses-permission ... />を追加 -
iOS:
Info.plistにNSLocationWhenInUseUsageDescriptionなどを追加
// lib/location_service.dart
import 'package:geolocator/geolocator.dart';
class GeoLocation {
final double latitude;
final double longitude;
GeoLocation({
required this.latitude,
required this.longitude,
});
}
class LocationService {
/// 現在地を取得する(権限チェック付き)
/// 失敗した場合は例外(Exception)を投げます
Future<GeoLocation> determinePosition() async {
bool serviceEnabled;
LocationPermission permission;
// 1. 位置情報サービスがONになっているか確認
serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (!serviceEnabled) {
return Future.error('位置情報サービスが無効です。設定からONにしてください。');
}
// 2. 現在の権限状態を確認
permission = await Geolocator.checkPermission();
// 3. 権限がない場合、リクエストする
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
if (permission == LocationPermission.denied) {
return Future.error('位置情報の権限が拒否されました。');
}
}
// 4. 「永久に拒否」されている場合(設定画面に行かないと直せない状態)
if (permission == LocationPermission.deniedForever) {
return Future.error('位置情報の権限が永久に拒否されています。設定画面から許可してください。');
}
// 5. 現在の位置を取得
final position = await Geolocator.getCurrentPosition();
return GeoLocation(latitude: position.latitude, longitude: position.longitude);
}
}
// lib/main.dart
import 'package:flutter/material.dart';
import 'location_service.dart';
void main() {
runApp(const App());
}
class App extends StatelessWidget {
const App({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
body: Center(
child: FutureBuilder<GeoLocation>(
future: LocationService().determinePosition(),
builder: (context, snapshot) {
if (snapshot.hasError) {
return Text('エラー: ${snapshot.error}');
}
if (!snapshot.hasData) {
return const Text('位置情報を取得中...');
}
final geoLocation = snapshot.data!;
// 取得した緯度・経度を表示
return Text('${geoLocation.latitude} / ${geoLocation.longitude}');
},
),
),
),
);
}
}
先ほどと比べるとかなりシンプルに書けていると思います。
CMP
CMPにもmoko-geoやcompassなどのライブラリはありますが、Flutterのgeolocatorに比べると知名度に大きく差があるため、実務での採用には慎重な判断が求められます。
もし、これらのライブラリを使わずフルスクラッチで実装することになった場合、以下の手順と知識が必要になります。
1.共通APIの定義
まず、共通モジュール (commonMain) にインターフェースを定義します。
- 位置情報取得用関数の
expect定義 - 権限リクエスト用関数の
expect定義 - (必要に応じて)コールバックやFlowによるデータ通知の設計
2. プラットフォームごとの実装
各OS固有のソースセット (androidMain/iosMain) でactualを実装します
- Android (androidMain)
-
Contextの注入- 緯度・経度取得に使う
FusedLocationProviderClientはContextが必須なので、KoinなどのDIライブラリを用いて依存注入の設計が必要になります
- 緯度・経度取得に使う
-
Activityとの連携- 権限リクエストの結果を受け取るには
Activity側のコールバック処理が必要になるため、Composable関数とのライフサイクル管理も考慮する必要があります
- 権限リクエストの結果を受け取るには
-
- iOS (iosMain)
- Delegateパターンの実装
-
CLLocationManagerはDelegateパターンで動作するため、クラス継承やプロトコル実装が必要です
-
- Kotlin/Nativeの制約
- Objective-C/Swiftの作法をKotlin上で扱う必要があります
- Delegateパターンの実装
3. UIからの呼び出し
呼び出し自体は関数を叩くだけなのでFlutterと大きな差はありません。
かなりざっくり描きましたが、記述に各OSの作法(ContextやDelegate)への理解が必要な上に、大量のコードを書く必要があります。
実装コードについて
実際にフルスクラッチしてみたコードを書いたんですが、記述量が非常に多いため、記事の可読性を考慮して末尾の 備考 に折りたたんで掲載しています。
CMPでの自前実装の大変さがよく分かると思いますので、覗いてみて下さい。
まとめ
今回、実際にFlutterとCMPでネイティブ連携のコードを書いて比較してみた結果、それぞれのアーキテクチャの違いによる「開発体験の差」や「向き不向き」がわかったので、実際に手を動かして感じた感想を整理しました。
両フレームワークのメリット・デメリット
Flutter
-
メリット
-
エコシステムが圧倒的
- 位置情報などのネイティブ機能を使いたい場合、既存の高品質なパッケージを導入すれば、OSの差異を意識せず数行で実装できる
-
エコシステムが圧倒的
-
デメリット
-
ネイティブ連携の壁
- パッケージが存在しない機能や、高度なパフォーマンスチューニングが必要な場合、Dartとネイティブの間の通信(ブリッジ)やボイラープレートコードが実装の負担になる
-
自動生成コードのノイズ
- Pigeonなどを利用する場合、自動生成されるコードの管理が必要になる
-
ネイティブ連携の壁
Compose Multiplatform (CMP)
-
メリット
-
ネイティブAPIへの直接アクセス
- KotlinからOS標準のAPIを直接叩けるため、通信ブリッジが不要で、ボイラープレートコードも削減できる
-
コンパイル時の安心感
-
expect/actual機構により、プラットフォームごとの実装漏れや型エラーをコンパイル時に検知できる
-
-
ネイティブAPIへの直接アクセス
-
デメリット
-
高い専門知識が必要
- ライブラリがまだ少ないため、実装には各OS固有の作法(Androidの
ContextやiOSのDelegateなど)への深い理解が求められる
- ライブラリがまだ少ないため、実装には各OS固有の作法(Androidの
-
高い専門知識が必要
個人的な選定基準
以上を踏まえると、プロジェクトの特性やチーム構成によって、以下のように使い分けるのが良さそうだと感じました。
Flutter が向いているケース
-
Web開発バックグラウンドのチーム
- DartはJavaScriptと文法が似ているため、学習コストを抑えられる
-
一般的な機能がメインのアプリ
- OS固有の複雑な機能をそれほど使わず、既存のパッケージで要件を満たせる場合
-
新規開発(資産なし)
- Kotlin/Swiftの既存資産がなく、ゼロからクロスプラットフォーム対応したい場合
Compose Multiplatform (CMP)が向いているケース
-
モバイルネイティブ開発者がいるチーム
- 既存のKotlinコードはそのまま流用でき、Swiftコードも呼び出し側を調整すれば資産を活用できる
-
OS機能をフル活用するアプリ
- センサー、Bluetooth、複雑なバックグラウンド処理など、ネイティブAPIとの密な連携が必要な場合
- ブリッジ通信のオーバーヘッドを気にする必要がなく、ネイティブアプリと同等のパフォーマンスを追求できる
- センサー、Bluetooth、複雑なバックグラウンド処理など、ネイティブAPIとの密な連携が必要な場合
さいごに
株式会社DONUTSおよびジョブカン事業部では、新卒・中途を問わず一緒に働くメンバーを募集しています。
もし弊社に興味を持っていただけた方はぜひ応募をご検討ください!
備考
【参考】CMPでの位置情報取得・自前実装コード全文(クリックして展開)
// composeApp/src/commonMain/kotlin/com/example/nativecompare/InitKoin.kt
package com.example.nativecompare
import org.koin.core.context.startKoin
import org.koin.dsl.KoinAppDeclaration
// Koinを起動する関数
// Android用(Contextを受け取るため)
fun initKoin(appDeclaration: KoinAppDeclaration = {}) = startKoin {
appDeclaration()
modules(platformModule) // ← ここでプラットフォームごとのモジュール(LocationServiceなど)を読み込む
}
// ★iOS用(Swiftから引数なしで呼びやすくするためのラッパー)
// これがないとSwift側からデフォ引数が見えないので、呼ぶ時にinitKoin(appDeclaration: { _ in })のようにしないといけなくなる
fun doInitKoin() {
initKoin {}
}
// composeApp/src/commonMain/kotlin/com/example/nativecompare/LocationService.kt
package com.example.nativecompare
import org.koin.core.module.Module
data class GeoLocation(
val latitude: Double,
val longitude: Double
)
interface LocationService {
suspend fun getCurrentLocation(): GeoLocation
}
expect val platformModule: Module
// composeApp/src/commonMain/kotlin/com/example/nativecompare/PermissionController.kt
package com.example.nativecompare
import androidx.compose.runtime.Composable
// パーミッション操作用インターフェース
interface LocationPermissionController {
fun requestPermission()
}
// OSごとの実装を作成するComposable関数 (Factory)
// 引数で「許可された/拒否された」ときの結果を受け取るコールバックを渡します
@Composable
expect fun rememberLocationPermissionController(
onResult: (Boolean) -> Unit
): LocationPermissionController
package com.example.nativecompare
import android.annotation.SuppressLint
import android.content.Context
import com.google.android.gms.location.LocationServices
import com.google.android.gms.location.Priority
import kotlinx.coroutines.tasks.await
import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module
// 実装クラス (privateで隠蔽)
// Contextはコンストラクタでもらうだけ。シングルトンは不要!
private class AndroidLocationService(private val context: Context) : LocationService {
private val fusedLocationClient = LocationServices.getFusedLocationProviderClient(context)
@SuppressLint("MissingPermission")
override suspend fun getCurrentLocation(): GeoLocation {
val location = fusedLocationClient.getCurrentLocation(
Priority.PRIORITY_HIGH_ACCURACY,
null
).await()
return GeoLocation(location.latitude, location.longitude)
}
}
// モジュールの実体
actual val platformModule = module {
single<LocationService> { AndroidLocationService(androidContext()) }
}
// composeApp/src/iosMain/kotlin/com/example/nativecompare/LocationService.ios.kt
package com.example.nativecompare
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.useContents
import org.koin.dsl.module
import platform.CoreLocation.CLLocationManager
import platform.CoreLocation.CLLocationManagerDelegateProtocol
import platform.CoreLocation.kCLLocationAccuracyBest
import platform.Foundation.NSError
import platform.darwin.NSObject
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
private class IosLocationService : LocationService {
private val locationManager = CLLocationManager()
@OptIn(ExperimentalForeignApi::class)
override suspend fun getCurrentLocation(): GeoLocation = suspendCoroutine { continuation ->
val delegate = object : NSObject(), CLLocationManagerDelegateProtocol {
override fun locationManager(manager: CLLocationManager, didUpdateLocations: List<*>) {
val location = didUpdateLocations.lastOrNull() as? platform.CoreLocation.CLLocation
location?.let {
val geo = GeoLocation(
// KotlinはSwiftと違い、直接C言語の構造体を除けないのでuseContentsが必要
latitude = it.coordinate.useContents { latitude },
longitude = it.coordinate.useContents { longitude }
)
continuation.resume(geo)
manager.stopUpdatingLocation()
}
}
override fun locationManager(manager: CLLocationManager, didFailWithError: NSError) {
continuation.resumeWithException(Exception(didFailWithError.localizedDescription))
}
}
locationManager.delegate = delegate
locationManager.desiredAccuracy = kCLLocationAccuracyBest
locationManager.requestWhenInUseAuthorization() // 権限リクエスト
locationManager.startUpdatingLocation()
}
}
// モジュールの実体
actual val platformModule = module {
single<LocationService> { IosLocationService() }
}
// composeApp/src/androidMain/kotlin/com/example/nativecompare/PermissionController.android.kt
package com.example.nativecompare
import android.Manifest
import android.content.pm.PackageManager
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat
class AndroidPermissionController(
private val onLaunch: () -> Unit
) : LocationPermissionController {
override fun requestPermission() {
onLaunch()
}
}
@Composable
actual fun rememberLocationPermissionController(
onResult: (Boolean) -> Unit
): LocationPermissionController {
val context = LocalContext.current
// Android標準の「結果を受け取るランチャー」を作成
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestMultiplePermissions()
) { permissions ->
// FINE または COARSE のどちらかが許可されていればOKとみなす
val isGranted = permissions[Manifest.permission.ACCESS_FINE_LOCATION] == true ||
permissions[Manifest.permission.ACCESS_COARSE_LOCATION] == true
onResult(isGranted)
}
return remember(launcher) {
AndroidPermissionController {
// すでに許可されているかチェック
val hasFine = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED
val hasCoarse = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
if (hasFine || hasCoarse) {
onResult(true)
} else {
// 許可されていなければポップアップを出す
launcher.launch(
arrayOf(
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION
)
)
}
}
}
}
// composeApp/src/iosMain/kotlin/com/example/nativecompare/PermissionController.ios.kt
package com.example.nativecompare
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import platform.CoreLocation.CLLocationManager
import platform.CoreLocation.CLLocationManagerDelegateProtocol
import platform.CoreLocation.kCLAuthorizationStatusAuthorizedAlways
import platform.CoreLocation.kCLAuthorizationStatusAuthorizedWhenInUse
import platform.CoreLocation.kCLAuthorizationStatusNotDetermined
import platform.darwin.NSObject
class IosPermissionController(
private val locationManager: CLLocationManager,
private val onResult: (Boolean) -> Unit
) : LocationPermissionController {
// Delegateを保持する変数(GCで回収されないように)
private val delegate = PermissionDelegate(onResult)
init {
locationManager.delegate = delegate
}
override fun requestPermission() {
val status = locationManager.authorizationStatus
when (status) {
kCLAuthorizationStatusNotDetermined -> {
// まだ決まっていない場合、ポップアップを出す
locationManager.requestWhenInUseAuthorization()
}
kCLAuthorizationStatusAuthorizedAlways,
kCLAuthorizationStatusAuthorizedWhenInUse -> {
// すでに許可済み
onResult(true)
}
else -> {
// 拒否されている
onResult(false)
}
}
}
// Objective-CのDelegateを受け取るクラス
private class PermissionDelegate(
private val onResult: (Boolean) -> Unit
) : NSObject(), CLLocationManagerDelegateProtocol {
override fun locationManagerDidChangeAuthorization(manager: CLLocationManager) {
val status = manager.authorizationStatus
val isGranted = (status == kCLAuthorizationStatusAuthorizedAlways ||
status == kCLAuthorizationStatusAuthorizedWhenInUse)
// "未決定"のときは何もしない(ポップアップが出た直後など)
if (status != kCLAuthorizationStatusNotDetermined) {
onResult(isGranted)
}
}
}
}
@Composable
actual fun rememberLocationPermissionController(
onResult: (Boolean) -> Unit
): LocationPermissionController {
// Composableのライフサイクルに合わせてCLLocationManagerを作成
val locationManager = remember { CLLocationManager() }
return remember(locationManager) {
IosPermissionController(locationManager, onResult)
}
}
// composeApp/src/androidMain/kotlin/com/example/nativecompare/MainApplication.kt
package com.example.nativecompare
import android.app.Application
import org.koin.android.ext.koin.androidContext
class MainApplication : Application() {
override fun onCreate() {
super.onCreate()
// ここでスイッチON!
initKoin {
// Android独自のContext情報をKoinに渡す
androidContext(this@MainApplication)
}
}
}
// iosApp/iosApp/iOSApp.swift
import SwiftUI
import ComposeApp
@main
struct iOSApp: App {
init() {
// Kotlinで定義した "doInitKoin" を呼び出す
// ファイル名 + Kt というクラス経由でアクセスします
InitKoinKt.doInitKoin()
}
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
package com.example.nativecompare
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import org.jetbrains.compose.ui.tooling.preview.Preview
import org.koin.compose.koinInject
import kotlin.time.ExperimentalTime
@OptIn(ExperimentalMaterial3Api::class, ExperimentalTime::class)
@Composable
@Preview
fun App() {
MaterialTheme {
val locationService: LocationService = koinInject()
var isPermissionGranted by remember { mutableStateOf(false) }
val permissionController = rememberLocationPermissionController { granted ->
isPermissionGranted = granted
}
LaunchedEffect(Unit) {
permissionController.requestPermission()
}
var logText by remember { mutableStateOf("") }
val scope = rememberCoroutineScope()
Scaffold(
topBar = {
TopAppBar(
title = { Text("Compose Multiplatform ネイティブ連携") }
)
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.padding(paddingValues)
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// 権限がないときはボタンを押せないようにする(または警告を出す)
if (!isPermissionGranted) {
Text("位置情報の権限が必要です", color = MaterialTheme.colorScheme.error)
}
InfoCard(
title = "緯度・経度",
value = logText
)
Button(
onClick = {
scope.launch {
logText = "取得中..."
try {
val loc = locationService.getCurrentLocation()
logText = "${loc.latitude} / ${loc.longitude}"
} catch (e: Exception) {
logText = "Error: ${e.message}"
}
}
},
enabled = isPermissionGranted
) {
Text("位置情報を取得")
}
}
}
}
}
/**
* 情報カードコンポーネント
*/
@Composable
fun InfoCard(
title: String,
value: String
) {
Card (
modifier = Modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
shape = RoundedCornerShape(12.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Text(
text = title,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = value,
style = MaterialTheme.typography.titleLarge
)
}
}
}