先日、ネイティブアプリに Flutter、React Native (Expo)、Kotlin Multiplatform などで作った画面を埋め込んだ場合のパフォーマンスを比較しました。
この検証では、既存の iOS / Android アプリにフレームワークとしてクロスプラットフォームを追加しました。そこで気になったのが、そもそも最初からアプリ全体を Flutter や React Native で作った場合はどうなるのかという場合です。昨今では 宗教戦争 技術選定にもなり得る危険な検証です。
Flutter や React Native は、Flutter Engine や Hermes といったランタイムをアプリに含めます。ネイティブアプリの埋め込みでは、ランタイムをネイティブアプリで起動する、ネイティブ間とのデータやりとりのブリッジ処理など、主目的である機能や画面以外のコスト(負荷)が発生します。
ここで、アプリ全体が Flutter / React Native であれば、それぞれのランタイム初期起動後から、画面制御やデータのやりとりなどは、クロスプラットフォームが内部で制御します。そこで、実際に比較してみました。
- 本当にネイティブより遅いのか
- 画面遷移はどうか
- API 通信後の描画はどうか
- メモリ使用量はどの程度増えるのか
- アプリサイズはどのくらい変わるのか
- 比較的スペックの低い実機でも同じ傾向になるのか
検証コードはこちらで公開しています。この記事では書いていない内容なども書いているので、興味あれば参照してください。
今回比較するもの
今回は同じアプリを、次の 3 パターンで作っています。
| 実装 | iOS | Android |
|---|---|---|
| native | SwiftUI | Jetpack Compose |
| Flutter | Flutter | Flutter |
| Expo | React Native + Expo | React Native + Expo |
前回の検証とは異なり、Flutter や Expo をネイティブアプリの一部に埋め込むのではありません。アプリ全体をそのフレームワークで実装しています。
検証するアプリ
前回の検証時と同じ機能アプリを作成しました。
- GitHub リポジトリを検索する
- その結果を一覧表示する
- 結果内容を前画面に反映する
検証では、次の操作を行います。
- アプリをコールドスタートする
- ホーム画面から検索画面を開く
- GitHub リポジトリを検索する
- 検索結果を一覧表示する
- ホーム画面から検索キーワードを差し替える
- 検索画面を閉じて、結果をホーム画面へ返す
- 再度検索画面を開く
実際の GitHub API を使うとネットワーク状況による差が大きくなるため、計測時はモックサーバから固定の 20 件を返しています。
実装方法
Flutter は Skia / Impeller を使って UI を描画します。一方 React Native は、UIKit / Android View を利用します。Expo UI を採用すれば、SwiftUI になるのでパフォーマンスは変わる場合もあります。
| native | Flutter | Expo | |
|---|---|---|---|
| iOS UI | SwiftUI | Flutter | React Native |
| Android UI | Jetpack Compose | Flutter | React Native |
| 画面遷移 | NavigationStack / Activity | Navigator | React Navigation |
| HTTP | URLSession / HttpURLConnection | package:http | fetch |
| ランタイム | なし | Flutter Engine + Dart | Hermes + React Native |
フレームワークは次のバージョンを使用しました。
- Flutter 3.47.4
- Expo SDK 57
- React Native 0.86.3
計測環境
今回の実機は次の 2 台です。古い端末を使うことで、CPU の性能が低い環境でクロスプラットフォームのオーバーヘッドがどの程度見えてくるかも確認できます。
| iOS | Android | |
|---|---|---|
| 端末 | iPhone XR | Rakuten Hand 5G |
| SoC | A12 Bionic | Snapdragon 480 5G |
| OS | iOS 18.7 | Android 11 |
計測方法
操作は agent-device を使って自動化しています。それぞれ 5 回計測し、インストール直後の 1 回をウォームアップとして除外したうえで、残りの中央値を採用しています。
時間については操作ツール側の時間を含めないよう、アプリ内に次のようなマーカーを出力し、その差分を使っています。
BENCH|<name>|<epochMs>
描画完了を取得する API はフレームワークごとに異なります。そのため、完全には同一タイミングを取ることはできず、1 フレーム程度の差はあります。60 Hz の場合は約 16 ms です。
そのため、10〜20 ms 程度の差については「こちらのフレームワークの方が速い」とは判断しないようにしています。ただし、ユーザーはその差もレスポンスと受け取るので、実際はアプリ全体のパフォーマンスとして考慮しましょう。
iOS / iPhone XR で比較する
まずは iPhone XR から検証していきます。最新 iOS のサポートは切れましたが、検証するにはちょうどいいスペックの検証機として、現役で使っています。
処理時間
| native | Flutter | Expo | |
|---|---|---|---|
| コールドスタート | 281 ms | 265 ms | 234 ms |
| 検索画面の表示(初回) | 55 ms | 45 ms | 22 ms |
| 検索画面の表示(2回目) | 39 ms | 30 ms | 13 ms |
| 検索 → 結果の描画 | 208 ms | 118 ms | 126 ms |
| 検索 → ホーム画面が結果を受信 | 176 ms | 73 ms | 101 ms |
| キーワードの差し替え | 28 ms | 8 ms | 14 ms |
まず意外だったのが、コールドスタートにほとんど差がなかったことです。
native 281 ms
Flutter 265 ms
Expo 234 ms
3 実装とも 234〜281 ms の範囲です。計測タイミングに最大 1 フレーム程度の違いがあり、OS のプロセス起動時間なども含まれるため、ほとんどの差はないと判断しました。
Flutter / React Native でアプリ全体を作ったからといって、コールドスタートに影響はありませんでした。
画面遷移
初回表示は、次のような時間でした。
native 55 ms
Flutter 45 ms
Expo 22 ms
2 回目は、さらに短くなりました。
native 39 ms
Flutter 30 ms
Expo 13 ms
少なくとも今回のような単純な画面では、クロスプラットフォームだから画面遷移が遅い、という結果にはなっていません。むしろ Expo が短くなっています。
ただし、22 ms や 13 ms という値は 1〜2 フレーム程度です。このあたりまで来ると、ユーザーが体感できる性能差として比較するより、どの実装でも十分短いと見る方が適切だと思います。
iOS のメモリ使用量
検索後のメモリ使用量は次のようになりました。
| native | Flutter | Expo | |
|---|---|---|---|
| ホーム画面表示後 | 87.2 MB | 104.9 MB | 92.6 MB |
| 検索画面表示後 | 92.4 MB | 107.6 MB | 95.0 MB |
| 検索後 | 101.7 MB | 115.7 MB | 114.3 MB |
| ホーム画面へ戻った後 | 102.7 MB | 117.5 MB | 116.0 MB |
Flutter / Expo ともにランタイムを持つため、native よりメモリ使用量は大きいですが、問題になるような差ではないでしょう。
iOS のアプリサイズ
アプリサイズはかなり差が出ました。
native 0.5 MB
Flutter 15.3 MB
Expo 28.8 MB
.app のサイズなので App Store から実際にダウンロードする容量とは異なりますが、ランタイムを持つ影響は明確です。
起動時間や画面遷移では大きな差が出なかった一方、バイナリサイズには Flutter / React Native を使うコストがそのまま現れています。
Android / Rakuten Hand 5G
続いて Android です。Rakuten Hand 5G は Snapdragon 480 5G を搭載しています。最近のハイエンド端末と比べると CPU 性能は低く、こういった端末でどうなるかも今回確認したかったポイントです。
処理時間
| native | Flutter | Expo | |
|---|---|---|---|
| コールドスタート | 172 ms | 143 ms | 455 ms |
| 検索画面の表示(初回) | 93 ms | 20 ms | 60 ms |
| 検索画面の表示(2回目) | 86 ms | 12 ms | 35 ms |
| 検索 → 結果の描画 | 85 ms | 49 ms | 216 ms |
| 検索 → ホーム画面が結果を受信 | 29 ms | 15 ms | 101 ms |
| キーワードの差し替え | 22 ms | 9 ms | 24 ms |
iOS とは少し違う結果になりました。
Android では Expo のコールドスタートが目立つ
コールドスタートは、次のようになりました。
native 172 ms
Flutter 143 ms
Expo 455 ms
native と Flutter の 29 ms 差は、計測方法を考えると大きな意味を持たせない方がよいでしょう。
一方、Expo は 455 ms です。native の約 2.6 倍となっており、こちらは 1 フレーム程度の誤差では説明できません。ここで、Pixel 9 エミュレータで計測すると、Expo は 135 ms でした。つまり、スペックの影響が大きいです。
Android の検索 → 描画
検索結果 20 件を受け取り、一覧を描画するまでの時間です。
native 85 ms
Flutter 49 ms
Expo 216 ms
Flutter は native と同程度以上の速度で描画できています。
一方、Expo は 216 ms まで伸びました。Pixel 9 エミュレータでは Expo は 60 ms だったため、スペックが影響しています。
Pixel 9 Emulator 60 ms
Rakuten Hand 5G 216 ms
高速な開発機では見えにくかった JS 実行のコストが、CPU 性能の低い実機になると相対的に大きくなったと考えられます。これは今回の検証で特に興味深かった点です。
Android のメモリ使用量
メモリは、次のようになりました。
| native | Flutter | Expo | |
|---|---|---|---|
| ホーム画面表示後 | 39.1 MB | 79.2 MB | 77.7 MB |
| 検索画面表示後 | 40.2 MB | 88.1 MB | 89.3 MB |
| 検索後 | 50.7 MB | 91.0 MB | 105.8 MB |
| ホーム画面へ戻った後 | 44.3 MB | 91.9 MB | 106.0 MB |
Flutter は native の約 1.8 倍、Expo は約 2.1 倍です。iOS よりも差がはっきり出ました。Flutter Engine や Hermes / React Native といったランタイムを持つ以上、ここは避けにくいコストです。
前回の「既存アプリへの埋め込み」でも見えた傾向ですが、アプリ全体をクロスプラットフォームで作ったとしても、メモリのコスト自体がなくなるわけではありません。
Android のアプリサイズ
APK サイズは次の結果でした。
native 1.2 MB
Flutter 46.5 MB
Expo 73.9 MB
かなり大きな差です。ただし今回の APK は複数 ABI を含んだ APK です。
実際にストアから配信する際は ABI ごとに分割されるため、このままユーザーのダウンロードサイズになるわけではありません。
それでも、Flutter Engine や React Native / Hermes などを含む以上、native よりバイナリサイズが大きくなる傾向自体は変わりません。
前回の「埋め込み」と今回の「アプリ全体」を比較してみる
前回の検証では、クロスプラットフォームのランタイムを既存のネイティブアプリに追加すると、その初期化コストが発生するという結果が出ました。
特に iOS の Flutter は分かりやすかったです。
前回:Flutter を埋め込み
コールドスタート 約 810 ms
今回:Flutter でアプリ全体を実装
コールドスタート 265 ms
Flutter 自体が突然高速になったわけではありません。アプリ構造と「どこからどこまでを起動時間として測っているか」が違います。
Brownfield では、次のような形になります。
Native App
├─ Native Runtime
└─ Flutter Runtime
一方、Flutter アプリなら、こうです。
Flutter Runtime
└─ App
アプリ全体を Flutter で作るなら、Flutter Engine は余分に追加されたランタイムではなく、アプリそのものを動かすランタイムになります。同じことは React Native にも言えます。
そのため、クロスプラットフォームはランタイムがあるから起動が遅いと単純には言えません。既存アプリへ追加するのか、最初から全面採用するのかで、コストの見え方が大きく変わります。
メモリとアプリのサイズ
起動時間の差は思ったより小さくなりました。しかし、消えなかったものがあります。メモリとアプリサイズです。
Android 実機の検索後では、
native 50.7 MB
Flutter 91.0 MB
Expo 105.8 MB
APK は、
native 1.2 MB
Flutter 46.5 MB
Expo 73.9 MB
でした。
つまり、ランタイム初期化の時間はアプリ全体の起動処理に吸収できても、ランタイムそのもののメモリとバイナリサイズは消えないということになります。
これはクロスプラットフォームを採用するときに、パフォーマンス上もっとも分かりやすいトレードオフかもしれません。
Flutter はかなり安定していた
今回の範囲では、Flutter はかなり安定した結果になりました。特に Android の Rakuten Hand 5G では次のようなりました。
| 項目 | native | Flutter |
|---|---|---|
| コールドスタート | 172 ms | 143 ms |
| 初回画面表示 | 93 ms | 20 ms |
| 検索 → 描画 | 85 ms | 49 ms |
| 結果の受け渡し | 29 ms | 15 ms |
もちろん、この数字から「Flutter の方が native より高速」と一般化することはできません。画面遷移の実装方法なども異なるからです。
それでも少なくとも今回のような、
- HTTP 通信
- リスト表示
- 2 画面程度のナビゲーション
- 画面間の値渡し
を行う一般的なアプリでは、Flutter だから処理性能で大きな制限を受ける、という結果にはなりませんでした。
Expo は端末性能の影響を受けやすかった
一方で、今回の Expo は端末による差が大きく出ました。
Android のコールドスタートは、
Pixel 9 Emulator 135 ms
Rakuten Hand 5G 455 ms
検索 → 描画も、
Pixel 9 Emulator 60 ms
Rakuten Hand 5G 216 ms
です。
エミュレータだけを見ていると、native / Flutter / Expo で大差ないようにも見えます。ところが CPU の遅い端末に持っていくと、差が一気に広がりました。
React Native は Hermes 上で JavaScript を実行します。そのため、JavaScript の処理量が多い画面では、端末の CPU 性能による影響を Flutter より受けやすいケースがありそうです。
逆に iPhone XR では Expo もかなり良好な結果なので、React Native は遅いと一括りにするのも違います。
どの程度の端末までサポートするのかは、React Native / Expo を採用するときのベンチマーク条件として重要そうです。
考察
少なくとも今回のアプリでは、「クロスプラットフォームはネイティブより極端に遅くなる」という単純な図にはなりませんでした。
起動
アプリ全体を Flutter / Expo で作った場合、iOS では native とほぼ同じ範囲でした。Android でも Flutter は native と同程度です。Expo は CPU の遅い Android 実機で差が出ました。
画面遷移
Flutter / Expo が native より短いケースもありました。特に Android では Single Activity 内のナビゲーションと Activity の起動というアーキテクチャの違いが効いています。
描画
Flutter は今回のリスト描画ではかなり安定しています。Expo は高性能環境では問題ありませんが、低速 CPU の端末で処理時間が伸びました。
メモリ
クロスプラットフォームのランタイム分は増えます。特に Android では違いが分かりやすく出ました。
アプリサイズ
ここは native が圧倒的に小さくなります。Flutter / Expo ではランタイムを同梱するため、どうしても数十 MB 単位の差になります。
まとめ
今回、同じアプリを次のパターンで作り、実機で比較しました。
- SwiftUI / Jetpack Compose
- Flutter
- Expo / React Native
一番意外だったのは、アプリ全体をクロスプラットフォームで作った場合、起動時間は必ずしも native より大幅に遅くならなかったことです。
特に iPhone XR では、ほぼ同じ範囲でした。
native 281 ms
Flutter 265 ms
Expo 234 ms
前回の「ネイティブアプリへの埋め込み」では Flutter Engine や React Native の初期化コストが目立ちましたが、アプリ全体をそのフレームワークで作る場合、その初期化も含めてアプリの起動処理になります。
一方で、メモリとアプリのサイズについてはランタイムを持つコストが残りました。
また、CPU 性能の低い Android 実機では Expo の JavaScript 実行時間が目立ち、開発機や高速なエミュレータだけでは分からない差も確認できました。
今回の検証から感じたのは、「ネイティブかクロスプラットフォーム」だけで性能を判断するのは難しいということです。
どのフレームワークを使うかだけでなく、用途や目的によって結果はかなり変わります。
- アプリ全体で使うのか
- 既存アプリへ埋め込むのか
- どのような画面遷移を採用するのか
- JavaScript / Dart / ネイティブのどこで処理するのか
- どの程度古い端末までサポートするのか
前回の Brownfield の比較と今回の結果を合わせて見ると、クロスプラットフォームを全面採用する場合と、既存ネイティブアプリへ部分的に導入する場合では、同じフレームワークでも性能特性が異なるという点が特に面白い結果になりました。
検証コードと、シミュレータ / エミュレータを含むすべての計測結果は GitHub に公開しています。
最後に、今回選んだ検証機のiPhone XR が無難な結果を出してる一方で、Rakuten hand 5Gが低スペックゆえに悪い結果を出しています。これはパフォーマンスを目的とした検証機として、いい仕事をしてるのが分かりますね。
One more thing...
先日、RN のパッケージである react-native-nitro-zxing の QR コード解析が、標準フレームワークを利用したい場合より早いと話題になりました。ちょうど、パフォーマンス比較をしてるなら、やるしかない。
検証内容
アプリに同梱した QR 画像 500 枚を順にデコードする時間を比べます。カメラは人手が要るので使わず、画像をアプリの書き込み領域に展開してファイルパスからデコードします。
| 実装 | ライブラリ | エンジン |
|---|---|---|
| native / iOS | Vision | Apple Vision |
| native / Android | ML Kit(bundled) | ML Kit |
| Flutter | mobile_scanner |
iOS: Vision / Android: ML Kit |
| Expo | react-native-nitro-zxing |
zxing-cpp |
結果
500 枚のデコード処理を 1 回として、5 回の中央値(ウォームアップ 1 回を除く)を計測しました。
| native | Flutter | Expo | |
|---|---|---|---|
| iPhone XR(1 枚あたり) | 26.44 ms | 30.26 ms | 3.57 ms |
| Rakuten Hand 5G(1 枚あたり) | 32.80 ms | 34.71 ms | 9.70 ms |
計測時の様子を撮影しました。
| native(ML Kit) | Flutter(mobile_scanner) | Expo(nitro-zxing) |
|---|---|---|
![]() |
![]() |
![]() |
react-native-nitro-zxing、速すぎる。iPhone XR に至っては 10 倍早いって、相当ですよ。今回の検証でスペックゆえに遅さが露呈した Rakuten Hand 5G でも、十分に早い結果を出しています。
考察
react-native-nitro-zxing が大幅に速かった理由は、React Native 自体が速いというより、内部で使用している zxing-cpp と今回の入力条件の相性が大きいと考えられます。
zxing-cpp は C++ で実装されたバーコード専用の画像処理・デコードライブラリで、今回のような真正面・高コントラスト・ぼけのない合成 QR では、回転探索や tryHarder といった重い処理を行う前の軽量な探索でデコードできます。
一方、Android の ML Kit や iOS の Vision は実際のカメラ画像からコードを検出することも想定した、より汎用的な認識パイプラインです。そのため今回のような理想的な静止画では、zxing-cpp が特に有利な結果になったと考えられます。
なお Nitro Modules による JSI/C++ 間の低オーバーヘッドも寄与しますが、Flutter と native の差が 6〜14%程度であることから、3〜7倍という差の主因は bridge ではなくデコードエンジンの違いと考えるのが自然です。
まとめ
この記事では、ちょいちょいクロスプラットフォームが勝ることありますが、一般的に「ネイティブ > (越えられない壁)> クロスプラットフォーム」です。たまにネイティブを超えるパフォーマンスが現れますが、まさにそれが今回の react-native-nitro-zxing です。
特定のシーンや機能を絞れば、クロスプラットフォームが有利になることがあります。クロスプラットフォームはネイティブには絶対に勝てないという意識をお持ちであれば、改めましょう。
まあ、QRコードのデコードに関しては、ネイティブも汎用的にせず、zxing を組み込めばいい話なんですがね。


