先日 expo-brownfield を使って React Native (Expo) の画面を既存のネイティブアプリへ組み込む方法を書きました。
expo-brownfield を使うと、Expo / React Native のアプリを iOS では XCFramework、Android では AAR として出力し、既存の Swift / Kotlin のアプリへ組み込めます。そのときに作成したサンプルがこちらです。
実際に組み込むと、気になったのはパフォーマンスです。
- ネイティブだけで作る場合と比べて、どれくらいレスポンスが変わるか
- 他のクロスプラットフォームの Flutter と比べるとどうなのか
- Kotlin Multiplatform / Compose Multiplatform ならもっとネイティブに近いのか
そこで、次の4パターンで実装して、ネイティブと埋め込みのパフォーマンスを比較しました。なお、実装と計測は Claude Code がすべて対応しました。
- Native
- Kotlin Multiplatform + Compose Multiplatform
- Flutter(埋め込み)
- React Native / Expo(埋め込み)
検証コードはこちらで公開しています。また、本記事に掲載していない検証パターンも記載しています。本文を読んで、内容にご興味があれば、GitHub の README もご確認ください。
2026/09/16
GitHub に「KMP + ネイティブUI」のパフォーマンス調査を追加しました。
今回作ったもの
題材は前回の sample-expo-brownfield と同じです。「GitHub のリポジトリを検索し、一覧表示したあと、その結果を前画面へ返す」というアプリを作りました。リポジトリ検索がそれぞれの埋め込まれたクロスプラットフォームが実行します。
今回はその画面を4つの方式で実装しています。
benchmark-mobile-app-framework-embedding/
├── native/ # SwiftUI / Jetpack Compose
├── kmp/ # Kotlin Multiplatform + Compose Multiplatform
├── flutter/ # Flutter add-to-app
├── expo/ # expo-brownfield
└── bench/ # モックサーバ・自動計測
それぞれに対して、次のホストアプリを用意しています。これは、既存のネイティブアプリに相当します。
ios-host/
android-host/
iOS 側は SwiftUI、Android 側は Jetpack Compose です。ホストアプリの作りはできるだけ同じにして、埋め込む画面の実装だけを変えるようにしました。
それぞれどう埋め込んだか
今回比較した構成は次のとおりです。
| iOS | Android | ランタイム | ネイティブとの通信 | |
|---|---|---|---|---|
| Native | Swift Package | Android Library | なし | Swift / Kotlin |
| KMP / CMP | static XCFramework | AAR | Compose Multiplatform | Kotlin Object / Objective-C Interop |
| Flutter | XCFramework | AAR | Flutter Engine / Dart | MethodChannel |
| Expo | Swift Package + XCFramework | AAR | React Native / Hermes | expo-brownfield Messaging |
Native
比較の基準です。iOS は SwiftUI、Android は Jetpack Compose だけで画面を実装しています。
他の方式と同じように「別ライブラリを埋め込む」という形に揃えるため、iOS では Swift Package、Android ではライブラリモジュールとして分けています。
Kotlin Multiplatform / Compose Multiplatform
KMP と書いていますが、今回はロジックだけを共有する KMP ではなく、UI も Compose Multiplatform で共有しています。iOS には static XCFramework、Android には AAR として組み込みました。
Android の Compose Multiplatform は Jetpack Compose の上でそのまま動くため、今回の構成では追加のランタイムコストがかなり小さくなります。一方で iOS は ComposeUIViewController を利用し、Compose Multiplatform が Skia / Skiko を使って描画します。
ホストとのデータ受け渡しは同一プロセス内の Kotlin オブジェクトです。iOS では Kotlin/Native が生成する Objective-C Interop を経由して Swift から呼び出します。
Flutter
Flutter は公式の Add-to-app の構成です。iOS では XCFramework、Android では AAR として組み込んでいます。
Flutter の場合、画面とは別に FlutterEngine が必要です。今回は、Flutter Engine を先に起動し、そのインスタンスを使い回します。
そのため、Flutter Engine の初期化コストは 埋め込み画面を開く時間ではなく、アプリのコールドスタート時間に含まれます。
ネイティブとの通信には MethodChannel を利用しました。
React Native / Expo
Expo は前回の記事で作成した sample-expo-brownfield をベースにしています。
Expo SDK 57 / React Native 0.86.2 / Hermes の構成です。iOS は Swift Package と XCFramework 群、Android は AAR としてネイティブアプリへ組み込んでいます。
こちらも Flutter と条件を合わせるため、React Native のランタイムをアプリ起動時に初期化しています。つまり React Native / Hermes の初期化時間も、画面表示ではなくコールドスタート側に含まれています。
ネイティブとの通信には expo-brownfield の Messaging を利用しています。
計測したもの
次の6つの時間を測りました。
| 項目 | 内容 |
|---|---|
| コールドスタート | プロセス開始 → ネイティブのホスト画面表示 |
| 埋め込み画面の表示(初回) | ホストから画面を開く → 埋め込み画面の描画 |
| 埋め込み画面の表示(2回目) | 一度閉じたあと、もう一度開く |
| 検索 → 結果の描画 | 検索開始 → 一覧が描画される |
| 検索 → ホストが結果を受信 | 埋め込み側 → ネイティブへのデータ受け渡し |
| ホスト → キーワード差し替え | ネイティブ → 埋め込み側へのデータ受け渡し |
さらに、
- メモリ
- アプリサイズ
も測っています。
操作は自動化した
手作業でストップウォッチを使っても再現性がないので、操作は agent-device で自動化しました。
1回の計測では次の操作を行います。
アプリをコールド起動
↓
埋め込み画面を開く
↓
検索
↓
ホストから検索キーワードを変更
↓
ネイティブ画面へ戻る
↓
もう一度埋め込み画面を開く
↓
戻る
これを各実装で繰り返します。
時間はアプリ自身に記録させる
UI 自動操作ツールから時間を測ると、タップ処理などのオーバーヘッドも入ってしまいます。そこでアプリ内から、次のように計測マーカーを出力するようにしました。
BENCH|<name>|<epochMs>
例えば、次のようなマーカーを Swift / Kotlin / Dart / JavaScript から出し、その差分を計算しています。これにより、agent-device 自身の操作時間をできるだけ除外しています。
BENCH|hostReady|...
BENCH|embeddedScreenReady|...
BENCH|searchStarted|...
BENCH|resultsRendered|...
API はモックにした
GitHub API を直接利用すると、ネットワーク状態や API の応答時間によって結果が変わります。
そこで bench/mock-server を作り、すべての実装へ同じ20件のレスポンスを返すようにしました。つまり、データやレスポンスのサイズや処理は、全実装で同じです。
計測回数
各実装につき、次のようにしました。
- インストール直後の1回をウォームアップとして除外
- その後5回計測
- 5回の中央値を採用
極端に大きい値や小さい値の影響を受けにくくするため、平均ではなく中央値にしました。
実機環境
今回の記事では、次の環境で計測しました。
| 環境 | |
|---|---|
| Mac | MacBook Air M3 / 16 GB |
| iOS | iPhone XR / A12 Bionic / iOS 18.7.10 |
| Android | Rakuten Hand 5G / Snapdragon 480 5G / Android 11 |
| Flutter | 3.47.4 |
| Kotlin | 2.4.20 |
| Compose Multiplatform | 1.12.0 |
| Expo | SDK 57 |
| React Native | 0.86.2 |
| agent-device | 0.21.1 |
いずれも最新のハイエンド端末ではありません。今回のようなランタイムの起動コストを見る場合、最新端末だけで測るよりも差が分かりやすいところはあります。
iOS の結果
iPhone XR / iOS 18.7 の Release ビルドです。
処理時間
| Native | KMP / CMP | Flutter | Expo | |
|---|---|---|---|---|
| コールドスタート | 289 ms | 299 ms | 806 ms | 440 ms |
| 埋め込み画面の表示(初回) | 83 ms | 134 ms | 109 ms | 224 ms |
| 埋め込み画面の表示(2回目) | 63 ms | 71 ms | 64 ms | 76 ms |
| 検索 → 結果の描画 | 110 ms | 90 ms | 124 ms | 101 ms |
| 検索 → ホストが結果を受信 | 94 ms | 63 ms | 49 ms | 83 ms |
| ホスト → キーワード差し替え | 20 ms | 18 ms | 20 ms | 14 ms |
コールドスタート
まず、コールドスタートで、差が生まれました。
Native 289 ms
KMP 299 ms
Flutter 806 ms
Expo 440 ms
KMP / Compose Multiplatform は Native とほぼ同じでした。Native に対して +10ms 程度です。Expo は約 +150ms。Flutter は約 +520ms で、今回の4方式では最も大きな差になりました。
ただしこれは先ほど書いたとおり、Flutter と React Native をアプリ起動時にランタイムの起動しているためです。ランタイム起動を埋め込み画面を開くタイミングで起動すればコールドスタートは短くできますが、その代わり画面を初めて開くときは遅くなります。これは、ランタイムを起動するかの違いです。
埋め込み画面の初回表示
Native 83 ms
KMP 134 ms
Flutter 109 ms
Expo 224 ms
意外だったのが Flutter です。Engine 自体はすでに起動しているため、Flutter の初回画面表示は 109ms でした。Native の83msにかなり近いです。KMP は134ms。Expo は224msと最も時間がかかりました。
Expo の場合、React Native ランタイム自体は起動済みですが、初めて React Native の Root View を作り、JavaScript 側のコンポーネントを描画する処理がここに入ります。
2回目の表示
さらに面白いのが2回目です。
Native 63 ms
KMP 71 ms
Flutter 64 ms
Expo 76 ms
ほぼ同じです。Flutter は Native と1ms差でした。Expo も76msなので、体感できるような大きな差ではありません。
ランタイムを使うフレームワークでも、一度起動してしまえば画面遷移自体はそれほど重くないことが分かります。
メモリ
iOS では RSS を計測しました。
| Native | KMP / CMP | Flutter | Expo | |
|---|---|---|---|---|
| ホスト画面表示後 | 115.3 MB | 111.3 MB | 113.7 MB | 85.3 MB |
| 埋め込み画面表示後 | 121.7 MB | 135.7 MB | 113.5 MB | 104.1 MB |
| 検索後 | 130.7 MB | 150.7 MB | 119.6 MB | 122.2 MB |
| ホストへ戻った後 | 137.0 MB | 158.5 MB | 125.3 MB | 127.8 MB |
検索後では KMP / Compose Multiplatform が約151MBと最も大きくなりました。
Compose Multiplatform が iOS 上で Skia を使って描画するため、そのぶんのメモリが入っていると考えられます。一方、Flutter と Expo が Native より少ない数字になっている箇所もあります。
なお、RSS にはアプリが触れた共有ライブラリなども含まれるため、フレームワーク自体の占有量と完全には一致しません。iOS のメモリ値は大まかな傾向として見るのがよさそうです。
アプリサイズ
実機向け .app のサイズです。
| Native | KMP / CMP | Flutter | Expo | |
|---|---|---|---|---|
| アプリサイズ | 0.6 MB | 32.4 MB | 14.1 MB | 27.1 MB |
ここはかなり差があります。KMP / Compose Multiplatform は約32MB。Flutter は約14MB。Expo は約27MBでした。
今回の Native は非常に小さいサンプルなので、実際の商用アプリで「32MB増えると何倍になる」といった見方はあまり意味がありません。見るべきなのは、そのフレームワークを追加したことによるデータが存在するという点です。
Android の結果
続いて Rakuten Hand 5G / Android 11 の実機です。こちらも Release ビルドです。
処理時間
| Native | KMP / CMP | Flutter | Expo | |
|---|---|---|---|---|
| コールドスタート | 161 ms | 169 ms | 212 ms | 198 ms |
| 埋め込み画面の表示(初回) | 104 ms | 117 ms | 201 ms | 273 ms |
| 埋め込み画面の表示(2回目) | 103 ms | 108 ms | 95 ms | 71 ms |
| 検索 → 結果の描画 | 104 ms | 144 ms | 85 ms | 230 ms |
| 検索 → ホストが結果を受信 | 33 ms | 85 ms | 32 ms | 136 ms |
| ホスト → キーワード差し替え | 42 ms | 45 ms | 32 ms | 40 ms |
KMP / Compose Multiplatform がかなり Native に近い
Android では KMP / Compose Multiplatform がかなり強い結果になりました。コールドスタートは、わずか8ms差です。
Native 161 ms
KMP 169 ms
初回画面表示も、13ms差です。
Native 104 ms
KMP 117 ms
Android の Compose Multiplatform は Jetpack Compose 上で動くので、この構成では追加ランタイムを立ち上げる必要がありません。
KMP / Compose Multiplatform を既存の Compose アプリに組み込む場合、パフォーマンス面ではかなり相性がよさそうです。
Flutter
Flutter は、Native よりコールドスタートが約50ms増えています。
コールドスタート 212 ms
初回画面表示 201 ms
2回目 95 ms
初回画面表示も約2倍ですが、2回目は Native より少し速い95msです。また、今回の画面では Flutter が最も短くなりました。
検索 → 描画
Native 104 ms
KMP 144 ms
Flutter 85 ms
Expo 230 ms
Flutter はネイティブより必ず描画が遅いというような単純な話ではなさそうです。
Expo
Expo は、初回画面表示は4方式で最も長くなっています。
コールドスタート 198 ms
初回画面表示 273 ms
2回目 71 ms
React Native の Root View / JavaScript コンポーネントを初めて作るところにコストがあり、一度作ったあとにはかなり差が縮まっています。
一方、今回の Rakuten Hand 5G では、データ処理や JavaScript ↔ Native の受け渡し部分の差も大きくなりました。
検索 → 描画 230 ms
検索 → ホスト受信 136 ms
開発機上のエミュレータより CPU が遅い実機では、JavaScript の処理やメッセージ変換のコストが見えやすくなったと考えられます。
メモリ
Android は PSS を計測しています。
| Native | KMP / CMP | Flutter | Expo | |
|---|---|---|---|---|
| ホスト画面表示後 | 36.2 MB | 36.2 MB | 66.1 MB | 45.4 MB |
| 埋め込み画面表示後 | 37.0 MB | 36.5 MB | 88.3 MB | 70.0 MB |
| 検索後 | 46.4 MB | 44.5 MB | 94.5 MB | 93.9 MB |
| ホストへ戻った後 | 40.3 MB | 38.9 MB | 77.8 MB | 86.9 MB |
検索後では、次のようになりました、
Native 46.4 MB
KMP 44.5 MB
Flutter 94.5 MB
Expo 93.9 MB
KMP / Compose Multiplatform は Native とほぼ同じです。Flutter と Expo は約94MBで、Native の約2倍になりました。Flutter Engine / Dart VM や React Native / Hermes といったランタイムを抱えることによる固定コストが見えています。
アプリサイズ
ここでも KMP がかなり Native に近いです。
| Native | KMP / CMP | Flutter | Expo | |
|---|---|---|---|---|
| APK | 1.2 MB | 1.5 MB | 44.4 MB | 56.6 MB |
Native 1.2MB に対して KMP は1.5MBでした。Flutter は44.4MB、Expo は56.6MBです。
ただし、今回の APK は、4 ABI をすべて含んだ Universal APK です。
- arm64-v8a
- armeabi-v7a
- x86
- x86_64
Google Play から Android App Bundle として配信する場合は端末に必要な ABI だけが配信されるので、実際のダウンロードサイズはこの数字より小さくなります。
そのため、44MB / 56MB がそのままユーザーの追加ダウンロード量になるわけではありません。
実機の結果をまとめる
今回の結果から、それぞれ次のような特徴が見えました。
KMP / Compose Multiplatform
今回の条件では 最も Native に近い結果となりました。特に Android では、次のすべてが Native とほぼ同じです。
- コールドスタート
- 初回画面表示
- メモリ
- APK サイズ
既存の Android アプリが Jetpack Compose で作られていて、その一部をマルチプラットフォーム化したい場合はかなり自然な選択肢に見えます。
またアプリサイズとメモリも増えます。それでも、別の VM / JavaScript ランタイムを起動する Flutter / Expo と比べると、コールドスタートへの影響はかなり小さくなりました。
Flutter
Flutter はどこでランタイム起動をするかが重要だと感じました。今回はアプリ起動時に Engine を立ち上げたため、次のような結果です。
- コールドスタートは遅くなる
- その代わり、埋め込み画面は速く開く
特に iOS ではコールドスタートが、大きく違いました。
Native 289 ms
Flutter 806 ms
一方、初回画面表示は109ms、2回目は64msです。つまり、ユーザーが必ず使う画面なら事前に Engine を起動しておく選択もできますし、滅多に使わない機能なら画面を開く直前まで Engine を起動しない設計も考えられます。
性能だけでなく、プロダクト上の導線まで含めて初期化タイミングを決める必要がありそうです。
React Native / Expo
Expo は Flutter と比べてコールドスタートへの影響は小さかった一方、埋め込み画面の初回表示でコストが出る傾向になりました。
iOS:
Native 83 ms
Expo 224 ms
Android:
Native 104 ms
Expo 273 ms
ただし2回目になると、次のように縮まります。
iOS
Native 63 ms
Expo 76 ms
Android
Native 103 ms
Expo 71 ms
ただし Android ではメモリとアプリサイズの固定コストがあり、性能の低い端末では JavaScript ↔ Native の通信コストも見えやすくなります。
「2回目は速い」はかなり重要
今回、一番興味深かったのはこれでした。埋め込み画面を2回目に開いたときは、どの方式もかなり近くなります。
iOS:
Native 63 ms
KMP 71 ms
Flutter 64 ms
Expo 76 ms
Android:
Native 103 ms
KMP 108 ms
Flutter 95 ms
Expo 71 ms
Flutter や React Native が重いと言われるとき、ランタイムの起動時間と普段の画面操作が一緒に語られることがあります。
しかし今回のように分けて測ると、コストがかなり違います。
- ランタイムを初期化する
- 最初の画面を生成する
- その後の画面を表示する
既存のネイティブアプリへ部分的に埋め込む場合、いつ初期化するかは技術選定と同じくらい重要かもしれません。
どれを選ぶか
この結果だけを見ると KMP / Compose Multiplatform がかなり有利です。ただ、実際の技術選定では性能だけでは決められません。
KMP / Compose Multiplatform
- Kotlin を中心に開発したい
- Android との親和性を重視したい
- JavaScript / Dart のランタイムを追加したくない
- iOS でも UI を共有したい
なら有力です。
Flutter
- Flutter の資産や開発チームがすでにある
- UI を大きく共有したい
- Engine の初期化タイミングをアプリ側で制御できる
- ある程度のバイナリサイズ増加を許容できる
場合に向いています。
React Native / Expo
- React / React Native / Web 系の資産を活用したい
- Expo で作られた機能をネイティブアプリへ持ち込みたい
- 機能単位で別チームから配布したい
- XCFramework / AAR のような成果物としてネイティブチームへ渡したい
場合には面白い選択肢です。前回試した expo-brownfield は、まさに最後の「別チームからネイティブアプリへ機能を配布する」という用途と相性がよさそうです。
注意点
今回の検証結果にはいくつか注意点があります。まず、これは「GitHub リポジトリを検索して20件の一覧を表示する」という比較的単純な画面です。複雑なアニメーション、大量の画像、動画、WebView、地図などが入れば結果は変わります。
また、5回の中央値なので、大規模な統計的ベンチマークでもありません。
さらにメモリについては、
- iOS: RSS
- Android: PSS
を使っています。
指標が違うため、iOS と Android の MB 数を直接比較してはいけません。検索処理についても、iPhone 実機は Mac 上のモックサーバへ Wi-Fi 経由でアクセスし、Android は adb reverse を利用しています。
まとめ
前回は expo-brownfield を使い、Expo / React Native で作った画面を既存のネイティブアプリへ組み込めるかを試しました。
今回はそこから一歩進めて、実際に組み込んだら、Native / KMP / Flutter / Expo でどれくらい差が出るのかを比較しました。
今回の条件では、KMP / Compose Multiplatform が最も Native に近い結果となりました。特に Android では、コールドスタート、メモリ、アプリサイズともにほぼ Native と同じでした。
Flutter と Expo はランタイムを持つぶん固定コストがあります。しかし、そのコストは、常に発生するわけではありません。
一度ランタイムを起動したあとの画面表示では、Native とほとんど変わらない値になっています。そのため、Flutter や React Native を埋め込むとアプリ全体が遅くなるという単純な話ではないです。
- ランタイムをいつ初期化するか
- その画面をユーザーがどれくらい頻繁に使うか
- 数十MBのアプリサイズ増加を許容できるか
- メモリ制約の厳しい端末をどこまでサポートするか
- 既存の開発資産・チーム構成をどれだけ活かせるか
まで含めて判断する必要がありそうです。
個人的にはネイティブアプリの一部分だけを別のフレームワークで作るという構成は、思っていたより現実的な選択肢なのかもしれません。