React Native(以降、RN)はネイティブアプリに埋め込むことができます。
しかし、公式サイトや技術記事などを見る限り、その実装コストが大きすぎて、採用は難しいと判断できます。
その煩わしさを解決するためのパッケージが公開されています。
- https://github.com/callstack/react-native-brownfield
- https://docs.expo.dev/versions/v55.0.0/sdk/brownfield/
本記事はその中の1つの expo-brownfield を利用して、RN をネイティブアプリに組み込みます。これは RN アプリを XCFramework / AAR として書き出すので、簡単にネイティブアプリに組み込むことができます。
この記事では、使い方の流れ、実装で考えたこと、そして実際に踏んだ問題を書きます。作成したサンプルアプリは GitHub で公開しています。
何を作ったか
サンプルアプリは次のようになります。最初の画面はネイティブで、遷移後の画面は RN です。検索ワードをネイティブから RN に渡して、戻ってきたら RN の検索結果をネイティブに渡しています。
RN はネイティブコンポーネントを利用するので、ネイティブと RN を跨ってもコンポーネントの見た目の差がないのでいいですね。
expo-brownfield の使い方
expo の利用方法などは公式サイトにお任せとして、ざっくりと説明します。
1. プラグインを設定する
expo-brownfield を入れて、app.json にプラグインを追加します。
[
"expo-brownfield",
{
"ios": {
"targetName": "RepoSearchKit",
"bundleIdentifier": "com.example.sample.expo.brownfield.reposearchkit"
},
"android": {
"libraryName": "reposearchkit",
"package": "com.example.sample.expo.brownfield.reposearchkit",
"group": "com.example.sample.expo.brownfield",
"version": "1.0.1"
}
}
]
targetName / libraryName が、あとで配布する成果物の名前になります。
2. prebuild でネイティブターゲットを生成する
npx expo prebuild --platform ios
通常の Expo のプロジェクトはネイティブを含みません。ネイティブのプロジェクトを経由せずに、アプリを生成します。
brownfield ではフレームワークをビルドするために、一時的にネイティブプロジェクトを利用します。この一時的なプロジェクトから、フレームワークが生成されます。
3. フレームワークを生成する
# iOS: Swift Package 形式(xcframework 一式 + Package.swift)
npx expo-brownfield build:ios --release --package RepoSearchKitPackage
# iOS: 素の xcframework だけ
npx expo-brownfield build:ios --release
# Android: AAR を Maven リポジトリに publish
npx expo-brownfield build:android --release
iOS は artifacts/ に、Android は設定した publish 先に出力されます。Release 構成なら JS バンドルは成果物に同梱されるので、Metro を立てずに動きます。
4. ネイティブアプリから利用する
iOS は Swift Package を Add Local で追加し、import RepoSearchKit するだけです。
@main
struct HostApp: App {
init() {
ReactNativeHostManager.shared.initialize() // RN の起動は一度だけ
}
var body: some Scene { WindowGroup { ContentView() } }
}
// SwiftUI から
NavigationLink("RN 画面を開く") {
ReactNativeView(moduleName: "main")
}
// UIKit から
navigationController?.pushViewController(
ReactNativeViewController(moduleName: "main"), animated: true
)
Android は AAR を依存に追加し、BrownfieldActivity を継承した Activity を作ります。
class RepoSearchActivity : BrownfieldActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
showReactNativeFragment()
}
}
初回起動用の関数呼び出しがありますが、基本的に従来のフレームワークと同様に組み込むことができます。ネイティブアプリのエンジニアは RN であることを意識する必要はありません。
ネイティブと React Native のやりとり
埋め込まれた RN アプリは表示するだけでなく、データの入出力ができます。
ネイティブ → RN
サンプルアプリの検索ワードは、画面生成時にネイティブから RN に渡しました。
ReactNativeView(moduleName: "main", initialProps: ["keyword": keyword])
その渡した値は RN でルートコンポーネントの props としてそのまま届きます。
export default function App({ keyword }: RootProps) {
return <RepoSearchScreen keyword={keyword ?? DEFAULT_KEYWORD} />
}
Android の showReactNativeFragment() は launch options を受け取れないので、
ReactNativeViewFactory.createFrameLayout() を直接使って Bundle を渡しています。
追記 2026/08/22
ネイティブから RN に渡しで initialProps を書きましたが、次節同様にメッセージを送ることができます。GitHub のサンプルの方を更新したので、詳細はそちらをご確認ください。
RN → ネイティブ
メッセージ sendMessage / addListener で双方向にやりとりできます。delegate やクロージャで受け取りができます。
sendMessage({ type: 'searchSucceeded', keyword, repositories })
BrownfieldMessaging.addListener { message in /* [String: Any?] が届く */ }
受け取るデータ型は [String: Any?] / Map<String, Any?>、つまり生の JSON です。ネイティブで扱うのは厳しいです。
そこで私は、その型変換や受け取りを行うブリッジ処理をフレームワークに埋め込みました。ネイティブアプリのエンジニアは、いつもの変数型を扱えます。
しかし、問題があります。フレームワークの素になる ios/ android/ は prebuild で毎回作り直されるので、そのブリッジ処理のファイルは消えます。そこで、そのファイルを別ディレクトリ native/ に置き、prebuild のたびに生成プロジェクトから参照するよう設定しました。
[
"./plugins/withRepoSearchBridge",
{
"ios": { "targetName": "RepoSearchKit", "sources": ["native/ios/RepoSearchBridge.swift"] },
"android": { "libraryName": "reposearchkit", "sourceDirs": ["native/android"] }
}
]
- iOS: pbxproj のファイル参照を
SOURCE_ROOT基準の相対パスでnative/ios/に向ける - Android: ライブラリモジュールのソースセットに
java.srcDir("../../native/android")を足す
メモリは増え続けるのか
brownfield で気になるのは、RN を埋め込むことでメモリが増え続けないかです。検索を 10 回繰り返して、実際に測りました。
Android(dumpsys meminfo)
| 回数 | PSS | Native Heap |
|---|---|---|
| 0(検索前) | 139 MB | 17.4 MB |
| 1 | 172 MB | 23.4 MB |
| 5 | 173 MB | 23.9 MB |
| 10 | 173 MB | 23.8 MB |
初回に +33MB 増えたあと、9 回で +1MB 未満です。「開く → 検索 → 戻る」を 6 サイクル繰り返しても 178MB で横ばいで、Activities / Views / AppContexts の数が一定でした。Activity や View がリークしていれば、ここが毎回増えます。
iOS(シミュレータプロセスの RSS)
| 時点 | RSS |
|---|---|
| 検索前 | 321 MB |
| 1 回後 | 356 MB |
| 4 回後 | 381 MB |
| 8 回後 | 382 MB |
| 20 秒放置後 | 285 MB |
4 回目から 8 回目でほぼ増えず、放置したら検索前より下がりました。連打中に増えて見えるのは回収が追いついていないだけで、保持されているわけではありません。
なお iOS は Debug ビルドをシミュレータで測った値なので、実アプリより多めに出ます。正確に見るなら Release ビルドを実機で Instruments を使うべきですね。
RN インスタンスを解放したい場合
ReactNativeHostManager には cleanupPreviousInstance() があり、RN インスタンスごと破棄してメモリを戻せます。まお、今回のサンプルでは実行せずに、インスタンスを保持したままにしています。
これを実行すると、次に RN 画面を開くときの起動コストが再び発生します。今回のサンプルでは呼んでいません。インスタンスを保持したままにしています。RN の起動速度とメモリ使用量をうまく管理しないといけないです。
踏んだ問題
サンプルを作る過程で引っかかったものを挙げます。同じところで止まる人がいるかもしれません。
expo-modules-jsi がコンパイルできない
Expo SDK 57 (expo-modules-jsi@57.0.5) の RuntimeScheduler.h は、コンストラクタに SWIFT_RETURNS_RETAINED を付けています。これが Swift の C++ interop で弾かれ、ExpoModulesJSI.xcframework のビルドが失敗します。
error: 'RuntimeScheduler' cannot be annotated with either SWIFT_RETURNS_RETAINED
or SWIFT_RETURNS_UNRETAINED because it is not returning a SWIFT_SHARED_REFERENCE type
手元にある Xcode 16.2 / 16.4 / 26.2 / 26.3 すべてで再現しました。SDK 58 系の canary では該当行が削除されているので、上流で修正済みの問題です。サンプルでは patch-package で当該行を除いています。
sendMessage に入れ子の null を渡せない(Android)
これは iOS では通るのに Android だけ落ちるタイプで、実機で動かすまで気づきませんでした。
Cannot convert '[object Object]' to a Kotlin type. Value is null, expected an Object
description や language が null のリポジトリが原因でした。ネイティブが使うフィールドだけに絞り、null になりうる値は空文字で送ってネイティブ側で null に戻しています。
JS の数値はすべて浮動小数点で届く
id や stars を as? Int / as? Int 相当で読もうとすると、常に失敗します。Swift なら Double、Kotlin なら Number 経由で受ける必要があります。これも実際に「0 件受信」という形で表面化しました。
キャッシュに 2 回刺された
同じバージョンで成果物を作り直したときのキャッシュです。
iOS
ローカル Swift Package のバイナリターゲットは DerivedData にキャッシュされます。消さないと古い API のままビルドされ、cannot find type ... in scope が出ます。
Android
Gradle は依存の解決結果とは別に、AAR を展開した結果を~/.gradle/caches/*/transforms/ にキャッシュします。--refresh-dependencies はこれを無効化しません。AAR の中には確かにメソッドがあるのに Unresolved reference が出続けました。バージョンを上げるのが確実です。
addSourceFile() がアプリ側ターゲットにファイルを追加する
config plugin で Xcode プロジェクトにファイルを登録するとき、xcode ライブラリの addSourceFile() に target を渡しても効きませんでした。
原因は、フレームワークターゲットのコンパイルフェーズが Sources ではなくターゲット名でコメント登録されていることでした。addSourceFile() はこれを見つけられず、エラーも出さずに、アプリ側ターゲットに追加します。ビルドは通るのにフレームワークにシンボルが入らないという分かりにくい失敗になります。
テストと CI
型変換のような「間違えやすくて失敗が見えにくい」ところにテストを置きました。
| 対象 | 場所 |
|---|---|
src/ の実装 |
expo-app(Jest) |
| bridge・組み込み | ios-host(XCTest)/ android-host(JUnit + Robolectric) |
bridge のテストをホストアプリ側に置いたのは、成果物を利用する側から公開 API を検証できるからです。上に挙げた「フレームワークにシンボルが入っていない」類の失敗は、ソース側でテストしても構造的に検出できません。
CI
CI は Pull Request で expo、iOS, Android の 3 プロジェクトをビルドしてテストします。ホストアプリのテストは成果物に依存するので、ジョブ内で先に xcframework / AAR を作ってから実行しています。これで「ビルドが通る」と「組み込んだ状態で動く」の両方を確認できます。
リリース
タグを打つと、成果物を GitHub Release に添付するワークフローも用意しました。実際にアプリに開発するときは、この GitHub Releases からフレームワークをダウンロードして、ネイティブアプリのプロジェクトに取り込む仕組みの構築になるでしょう。
まとめ
expo-brownfield は、Expo アプリを XCFramework / AAR として切り出して既存アプリに組み込むための道具立てが一通り揃っています。initialProps と BrownfieldMessaging があれば、ネイティブとの往復も素直に書けます。
一方で、生成されるプロジェクトとの付き合い方(config plugin)や、プラットフォーム間の型の差、キャッシュの挙動など、実際に動かさないと分からないところがそれなりにありました。
メモリについては、少なくとも今回の範囲ではリークは確認できませんでした。必要なら cleanupPreviousInstance() で明示的に解放できます。
しかしながら、ネイティブアプリのエンジニアは RN を触ることなく、RN を組み込めるメリットは大きいです。RN はネイティブコンポーネントを利用するので、ユーザーは切り替わったのに気付かないでしょう。
サンプルは公開しているので、試すときの参考にしてもらえればと思います。
動作確認した構成
- Expo SDK 57.0.16 / React Native 0.86.2 / expo-brownfield 57.0.14
- Xcode 26.3 / iPhone 17 シミュレータ
- Android Studio(SDK 36 / NDK 30 / JDK 17)/ AGP 8.12.0 / Kotlin 2.1.20 / Pixel 10 エミュレータ