Expo のブラウンフィールド改修:書き直さずに Expo を既存のネイティブアプリに組み込む方法
既存のネイティブ iOS/Android アプリに Expo を追加する。書き直しは不要。Expo SDK 55 の新しい分離型 brownfield ワークフローを紹介する。
日本語
コピー

すでにリリース済みのネイティブアプリがあるとする。長年にわたるプラットフォーム固有の判断が積み重なった産物で、丸ごと置き換えるつもりも、一度に置き換えるつもりもない。
一方で、そのアプリの中に React Native で新機能を作りたいこともある(たとえばクロスプラットフォームで共有できるロジック)。そのためにアプリの残りを書き直す必要はない。
これが brownfield と呼ばれるもので、既存のネイティブアプリに React Native と Expo を追加する。ただしそのアプリの主入口は React Native ではない(完全な定義はこちら)。
問題は React Native を追加できるかどうかではなく、どう追加するかにある。全か無かのアーキテクチャ判断にせず、インクリメンタルで自己完結し、チームの他のメンバーの邪魔をしない形にできるかどうかだ。
本記事では、Expo の brownfield アプローチがどう進化してきたか、そして今日どこまでできるのかを整理する。SDK 55 では分離型 brownfield ワークフローを導入し、非 React Native のネイティブアプリに Expo の画面やコンポーネントを組み込めるようにした。やり方は、事前ビルド済みの Expo アプリをネイティブ依存として埋め込むもので、新しいフレームワークを持ち込むというより、ライブラリを1つ追加する感覚に近い。
Expo は既存ネイティブアプリのどこに収まるか
既存のネイティブアプリに React Native を導入しようと考える理由はチームによってさまざまだ。
-
段階的な採用が目的なら、コードベース全体を一度に賭けることなく、アプリの一部分から Expo を使い始められる。後から範囲を広げる余地も残る。
-
製品の特定部分だけを React Native で作るという選択もある。自己完結した機能領域やサブアプリ(例:Facebook アプリ内の Marketplace)などで、残りはそのままにする。
-
技術的な理由ではなくビジネス上の理由から、たとえば買収後に既存の React Native アプリをネイティブ製品へ統合する場合もある。
これらのシナリオが求めるものは同じだ。React Native が新しい入口になったり既存の構造を置き換えたりするのではなく、すでにあるアプリに埋め込めること。しかも苦痛な書き換えも、既存アプリ側の不格好な妥協もなしに。
Expo の brownfield アプローチが目指すのは、既存のコードベースとそれを保守するチームになるべく手を触れさせずに React Native を使えるようにすることだ。Expo は明確な API とパターンを提供し、React Native をサポートされた保守可能な形でネイティブアプリに埋め込む。これらの統合方法は Expo のツールチェーン、ライブラリ、SDK アップグレードの流れと併用できる。
これはエンジニアリングチーム全体にとっても価値がある。多くの企業には React を得意とするエンジニアがすでにいるが、ネイティブプラットフォームやネイティブ開発そのものには詳しくない。Expo を埋め込めば境界がはっきりした領域ができ、その経験をそのまま活かせる。ネイティブアプリのビルド方法や帰属を変える必要はない。
ここでの価値はアプリのアーキテクチャを再定義することではなく、Expo を選択的に導入できるようにしつつ、アプリの他の部分とその背後の判断をそのままにしておくことにある。
Expo における brownfield アプローチ
Expo は現在、React Native を既存のネイティブアプリに追加する方法を2つドキュメント化している。
統合型アプローチは React Native と Expo をネイティブアプリに直接インストールする。だが React Native をネイティブアプリに埋め込むのは、普通のライブラリを追加するより複雑だ。2つ目のランタイム、ビルドシステム、開発環境を持ち込むことになり、チーム全体がそれに適応する必要がある(詳しくは「統合型アプローチで Expo をネイティブアプリに追加する方法」を参照)。
代替として SDK 55 で導入したのが分離型アプローチだ。React Native のコードをネイティブライブラリ(Android では AAR、iOS では XCFramework)にパッケージし、他の依存関係と同じようにネイティブアプリに組み込む。ネイティブ開発者は Node.js 環境を用意する必要も、React Native のビルド依存を扱う必要もない。事前ビルド済みの成果物を消費するだけであり、複雑さは React Native チームの内側に閉じ込められ、組織の他のメンバーには見えない。
分離型アプローチの実際の姿
分離型アプローチでは、Expo アプリは事前にビルドされ、ネイティブバイナリ成果物として配布される。Expo 側から見ればこれは普通の Expo プロジェクトだ。通常の Expo と React Native のツールで画面やコンポーネントを開発し、開発中は Expo CLI でプロジェクトを動かし、変更をホストアプリに統合したくなった時点でコンパイル済みの framework または AAR を出力する。
分離型の手法は新しい expo-brownfield パッケージで実現する:
# Install the package
npx expo install expo-brownfield
# Build the iOS XCFramework
npx expo-brownfield build:ios
# Build the Android AAR
npx expo-brownfield build:android
土台にあるのは Continuous Native Generation (CNG) だ。成果物をビルドするためのネイティブ iOS / Android プロジェクトは、アプリ設定と Expo モジュールから生成され、手で保守するものではない。expo-brownfield 設定プラグインがこの流れを拡張し、ネイティブ framework の生成に必要なターゲットを追加する。埋め込まれるアプリ側で Xcode プロジェクトや Gradle ファイルを管理する必要はない。これらはすべてツールチェーンがビルドステップの中で処理する。
ビルドが完了すると、生成される成果物はプラットフォームによって異なります。
iOS の場合、プロジェクト内に artifacts ディレクトリが生成され、その中に app をコンパイルしたネイティブ framework の成果物が格納されます。
├── app/
├── artifacts/
│ ├── hermesvm.xcframework/
│ └── expohelloworldbrownfield.xcframework/
├── assets/
├── ios/
├── node_modules/
├── .gitignore
└── app.json
artifacts ディレクトリには 2 つの .xcframework が含まれます。
-
JavaScript エンジンとランタイム依存関係用
-
コンパイル済みの Expo app 本体用
生成された .xcframework を Xcode プロジェクトにドラッグするだけで完了です。それらは他の依存関係と同様にリンクされ、埋め込まれた Expo app をネイティブコードから初期化してレンダリングできるようになります。

次に、ネイティブアプリが React Native ホストを明示的に初期化し、React Native ビューを適切な箇所に埋め込んでレンダリングする。
Android では、成果物は .aar としてパッケージされるが、配布方法が少し異なる。ビルドプロセスはファイルをネイティブプロジェクトにコピーせず、成果物をローカルの Maven ディレクトリ(通常は ~/.m2)に公開する。
つまり、ネイティブアプリは成果物の公開先である Maven リポジトリ(mavenLocal() やリモートの Maven リポジトリなど)から依存関係を解決できる必要がある。
// settings.gradle or build.gradle (depending on your setup)
dependencyResolutionManagement {
repositories {
mavenLocal()
google()
mavenCentral()
}
}
設定が済んだら、それをホストするモジュールで埋め込みアプリを通常の依存関係として追加できる:
dependencies {
implementation("com.example.helloworld:brownfield:1.0.0")
}
実務上の注意点がひとつ。ホスト Activity には、React Native が必要とする属性を提供するテーマを使う必要がある。ここでは AppCompat ベースのテーマが無難なデフォルトだ。テスト時は任意の AppCompat テーマ(たとえば Theme.AppCompat.Light.NoActionBar)で問題なく動作する。
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
- android:theme="@style/Theme.Helloworld">
+ android:theme="@style/Theme.AppCompat.Light.NoActionBar">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
ホストアプリをビルドまたは実行するときは、build variant が Expo アプリのパッケージ方法と一致していることを確認する。たとえば --release で .aar をビルドしたなら、ホストアプリを実行するときも release variant を使う(Android Studio の “Active Build Variant” などで)。

埋め込まれた Expo アプリは、ネイティブの Android コードから明示的にインスタンス化できる。
ネイティブアプリと埋め込みアプリの通信
アイソレーション方式には通信 API が組み込まれており、ネイティブホストと埋め込み Expo アプリの間でメッセージをやり取りできる。
この API は、埋め込みアプリの内部モジュールに直接アクセスすることなく、イベントとデータを交換するための構造化された双方向チャネルを提供する。アイソレーション境界をまたいでナビゲーション、状態の変更、ネイティブ由来のイベントを調整するのは、主にこれが担う。
概念的には、Web の postMessage モデルに近い。内部のオブジェクトやモジュールに直接触るのではなく、メッセージ層を通じてメッセージを渡す。
たとえば、外側のアプリから React Native にメッセージを送るにはこう書く:
// iOS
import ExpoBrownfield
BrownfieldMessaging.sendMessage([
"type": "MyIOSMessage",
"timestamp": Date().timeIntervalSince1970,
"data": [
"platform": "ios"
]
])
React Native 側で外側のアプリからのメッセージを受け取るには:
import * as Brownfield, { type MessageEvent } from 'expo-brownfield';
import { useEffect } from 'react';
function MyComponent() {
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
console.log('Received message:', event);
};
Brownfield.addMessageListener(handleMessage);
return () => {
Brownfield.removeMessageListener(handleMessage);
};
}, []);
}
制約とトレードオフ
Brownfield サポートには実務上の論点が多い。以下、順に見ていく。
アイソレーション方式の制約
-
ネイティブアプリに埋め込めるアプリは 1 つだけ。埋め込みアプリは単一の XCFramework(iOS の場合)または AAR(Android の場合)としてパッケージ化して配布する。この制約があるのは、埋め込みアプリがそれぞれ React Native ランタイムを自前で持つためだ。同じネイティブアプリにこうしたフレームワークを複数入れると、ビルド時にクラス名が衝突する。複数のアイソレーションアプリの埋め込みは計画中だが、現時点では利用できない。
-
複数の論理的な体験は、1 つの埋め込みランタイムを共有しなければならない。埋め込みアプリは複数の JavaScript bundle を読み込めるが、それらはすべて同じパッケージ済み Expo アプリ内で動作し、同一の React Native ランタイムとネイティブ依存を共有する必要がある。
-
埋め込みアプリは意図的に自己完結型に設計されている。埋め込みアプリの外側のコードから Expo モジュールやその他の内部実装の詳細に直接アクセスすることはできない。ネイティブアプリと埋め込みアプリの連携は、埋め込みアプリ自身が公開する明確なインターフェース、たとえばメッセージ API を通さなければならない。
-
ビルド時にも実際のトレードオフがある。framework や AAR のビルドは遅くなりがちで、この場合プリコンパイル済みの React Native バイナリ は再利用できない。debug と release の構成を切り替えるたびに埋め込み成果物を再生成する必要があり、開発中の摩擦になる。
-
生成した XCFramework や AAR は、バイナリ成果物として保存・配布しなければならない。ファイルが大きくなりうるため、通常は Git に直接コミットしない。Git LFS も使えるが、多くのチームは成果物リポジトリ(たとえば Artifactory やその他の Maven 互換 registry)に公開して、バージョン付きで配布している。
ライブラリ互換性の考慮点
brownfield では、一部のライブラリが期待どおりに動かない、あるいはドキュメントが乏しいことがある。
Expo のライブラリにも当てはまる。たとえば expo-updates パッケージは統合方式・アイソレーション方式のどちらの brownfield でも使えるが、現状はネイティブアプリごとに Expo プロジェクトが 1 つ、更新 URL も 1 つという前提になっている。
この制約はどちらの方式にも当てはまる。原因は、Expo プロジェクト ID がネイティブアプリの bundle にどう保存されるかにある。
Expo 製かサードパーティ製かを問わず、多くのコミュニティライブラリも React Native がアプリのエントリポイントであることを前提にしている。React Native のビューがルートウィンドウであることや、React Native の activity がメイン activity であることを期待するライブラリは、React Native を fragment や view controller として埋め込む構成では追加設定が必要になったり、そもそも互換性がなかったりする。
まとめ
Expo の brownfield は現在 2 つの方式からなる。React Native と Expo をネイティブプロジェクトに直接インストールしてアプリと一緒にビルドする統合方式と、プリコンパイル済みの Expo アプリを依存としてネイティブアプリに埋め込むアイソレーション方式だ。
どちらも今のところ異なるニーズに応える。統合方式は柔軟性とネイティブアプリとの深い統合をもたらし、アイソレーション方式は Expo アプリを自己完結型に保つことで導入の摩擦を下げる。
brownfield における Expo のツールの方向性は、こうした統合をますます馴染み深く予測可能なものにし、複雑さの大部分をツール側で処理することにある。
brownfield サポートは、React Native をオール・オア・ナッシングの選択肢として扱うのではなく、既存のネイティブアプリに Expo を段階的に導入できるようにする。小さく始め、適した場所で広げていけばいい。
Expo をチームの既存アプリに組み込みやすくなるにつれ、チームが何を作るのか見るのが楽しみだ。