1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ネイティブのモバイルアプリに KMP / Flutter / React Native (Expo) を埋め込んだときのパフォーマンスを比較する

1
Last updated at Posted at 2026-09-15

先日 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 が重いと言われるとき、ランタイムの起動時間と普段の画面操作が一緒に語られることがあります。

しかし今回のように分けて測ると、コストがかなり違います。

  1. ランタイムを初期化する
  2. 最初の画面を生成する
  3. その後の画面を表示する

既存のネイティブアプリへ部分的に埋め込む場合、いつ初期化するかは技術選定と同じくらい重要かもしれません。

どれを選ぶか

この結果だけを見ると 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のアプリサイズ増加を許容できるか
  • メモリ制約の厳しい端末をどこまでサポートするか
  • 既存の開発資産・チーム構成をどれだけ活かせるか

まで含めて判断する必要がありそうです。

個人的にはネイティブアプリの一部分だけを別のフレームワークで作るという構成は、思っていたより現実的な選択肢なのかもしれません。

1
0
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?