概要
既に本番稼働しているネイティブ iOS/Android アプリに対して、全面的なリライトを行わずに React Native(および Expo)で新しい機能を追加する方法を説明します。これは「ブラウンフィールド(brownfield)」と呼ばれるアプローチで、メインのエントリーポイントが React Native ではない既存ネイティブアプリに対して、段階的かつ自己完結的に Expo を導入することを目的としています。
SDK 55 では、既存のネイティブアプリに Expo の画面やコンポーネントを組み込める「isolated brownfield(分離型ブラウンフィールド)」ワークフローが導入されました。これにより、プリコンパイルされた Expo アプリをネイティブ依存として埋め込むことで、ライブラリを追加する感覚に近い形で Expo を導入できます。
なぜ既存ネイティブアプリに Expo を入れるのか
チームが既存ネイティブアプリに React Native を導入する理由はさまざまです。例として:
- 段階的な導入を行いたい:アプリ全体を一度に切り替えるのではなく、一部から Expo を使い始めて徐々に拡大する可能性を残す。
- 特定の機能領域だけを React Native で構築する:サブアプリや機能領域(例:Facebook の Marketplace のような部分)を React Native で作る。
- ビジネス上の要請:買収などで既存の React Native アプリをネイティブプロダクトに組み込む必要がある場合。
共通する要件は、既存アプリを痛めず(痛みを伴うリライトなしで)、既存の構造やエントリーポイントを置き換えずに React Native を馴染ませられることです。Expo のブラウンフィールドはこの要件に応えることを目指しており、組み込み用の明確な API とパターンを提供して、サポート可能で保守しやすい形で React Native をネイティブアプリに埋め込めるようにしています。
また、多くの組織では React に精通したエンジニアがいる一方でネイティブ開発に詳しくないケースがあり、Expo を埋め込むことでその知見を明確なサーフェスに適用でき、アプリ全体のビルドや所有権の変更を伴わずに機能を追加できます。
Expo におけるブラウンフィールドのアプローチ
Expo は既存ネイティブアプリに React Native を追加するために、主に2つの方法をドキュメント化しています。
-
統合(integrated)アプローチ:React Native と Expo をネイティブアプリ内に直接インストールする方法です。ただし、React Native をネイティブアプリに埋め込む場合、二次的なランタイムやビルドシステム、開発環境が導入されるため、チーム全体でその複雑さに対応する必要があります(詳細は「How to add Expo to a native app using the integrated approach」を参照)。
-
分離(isolated)アプローチ:SDK 55 で導入された方法で、React Native のコードをネイティブライブラリ(Android の AAR、iOS の XCFramework)としてパッケージ化し、通常の依存関係としてネイティブアプリへ組み込めるようにします。これにより、ネイティブ側の開発者は Node.js 環境や React Native のビルド依存を設定する必要がなく、プリビルドされた成果物を消費するだけで済むようになります。
分離アプローチ(isolated)の実際
分離アプローチでは、Expo アプリを事前にビルドしてネイティブバイナリアーティファクトとして配布します。Expo 側では通常どおり Expo プロジェクトとして画面やコンポーネントを開発し、開発中は Expo CLI で実行、ネイティブアプリに組み込むタイミングでフレームワークや AAR を生成します。
このアプローチは新しい expo-brownfield パッケージで実装されています。例:
npx expo install expo-brownfield
npx expo-brownfield build:ios
npx expo-brownfield build:android
内部的には Continuous Native Generation (CNG) に依存しています。アーティファクトをビルドするためのネイティブ iOS/Android プロジェクトは手作業で維持するのではなく、アプリ設定と Expo モジュールから生成されます。expo-brownfield の config plugin は、ネイティブフレームワークを生成するための必要なターゲットを追加してこのプロセスを拡張します。そのため、埋め込みアプリ用の Xcode プロジェクトや Gradle ファイルを手で管理する必要はありません。
ビルドが完了すると、出力はプラットフォームごとに異なります。
-
iOS では、プロジェクト内に artifacts ディレクトリが作られ、コンパイル済みのネイティブフレームワークが含まれます。例:
├── app/
├── artifacts/
│ ├── hermesvm.xcframework/
│ └── expohelloworldbrownfield.xcframework/
├── assets/
├── ios/
├── node_modules/
├── .gitignore
└── app.json
artifacts ディレクトリには 2 つの .xcframework が含まれます:JavaScript エンジンとランタイム依存のもの、そしてコンパイル済みの Expo アプリ本体です。生成された .xcframework を Xcode プロジェクトにドラッグすると、他の依存と同様に表示されリンクされ、ネイティブコード側から埋め込み Expo アプリを初期化して描画できるようになります。
-
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 ベースのテーマが安全なデフォルトです。テスト用には Theme.AppCompat.Light.NoActionBar のような任意の AppCompat テーマが有効です。例:
<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>
また、ホストアプリをビルド/実行する際は、Expo アプリをどのようにパッケージしたか(例:--release)に合わせてビルドバリアントを一致させる必要があります。たとえば .aar を --release で生成した場合、ホストアプリも release バリアントで動かす(Android Studio の “Active Build Variant” など)必要があります。埋め込み Expo アプリはネイティブ側から明示的にインスタンス化できます。
ネイティブと埋め込みアプリ間の通信
分離アプローチには、ネイティブホストと埋め込み Expo アプリ間でメッセージをやり取りするための組み込み通信 API が含まれています。この 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);
};
}, []);
}
制限とトレードオフ
ブラウンフィールド対応には実務上の考慮事項がいくつかあります。主な制限は以下のとおりです。
分離アプローチの制限
-
ネイティブアプリに含められる埋め込みアプリは単一のみです。埋め込みアプリは iOS の XCFramework あるいは Android の AAR としてパッケージされます。これは各埋め込みアプリが独自の React Native ランタイムのコピーを含むためで、同一のネイティブアプリ内に複数のそうしたフレームワークを含めるとビルド時にクラス名衝突が発生します。複数の isolated アプリをサポートする計画はありますが、現時点では利用できません。
-
複数の論理的な体験(複数の JS バンドル)はロード可能ですが、いずれも同じパッケージ化された Expo アプリ内で動作し、同一の React Native ランタイムとネイティブ依存を共有する必要があります。
-
埋め込みアプリは意図的に自己完結型です。埋め込みアプリ外部のコードは、Expo モジュールや内部実装の詳細に直接アクセスできません。ネイティブと埋め込みアプリの相互作用は、埋め込みアプリ自体が公開する明示的なインターフェース(メッセージング API 等)を通じて行う必要があります。
-
ビルド時の実務的なトレードオフがあります。フレームワークや AAR のビルドは時間がかかることがあり、プリコンパイル済みの React Native バイナリを再利用できない場合があります。デバッグとリリースの切り替えは埋め込みアーティファクトの再生成を必要とするため、開発時のフリクションが増えます。
-
生成された XCFramework や AAR はバイナリアーティファクトとして保存・配布する必要があります。これらは大きくなりがちなので Git に直接コミットすることは通常避けられ、Git LFS を用いるか、Artifactory や Maven 互換のレジストリなどアーティファクトリポジトリに公開してバージョン管理するのが一般的です。
ライブラリの互換性に関する注意点
一部のライブラリはブラウンフィールド構成で期待どおり動作しなかったり、ドキュメントが限られている場合があります。これは一部の Expo ライブラリも含みます。例えば expo-updates パッケージは統合型・分離型の両方で利用可能ですが、現時点ではネイティブアプリごとに単一の Expo プロジェクト、単一の update URL を前提としています。この制限は Expo プロジェクト ID の扱いに起因しています。
まとめ
Expo SDK 55 の isolated brownfield ワークフローにより、既存ネイティブアプリに対してライブラリを追加する感覚で Expo/React Native を導入できる選択肢が提供されました。これにより、段階的な導入や特定機能の React Native 化を、既存コードベースやチーム構成を大きく変えずに行いやすくなります。一方で、単一埋め込みアプリ制約、ビルド時間、アーティファクト配布、ライブラリ互換性など実務上のトレードオフも存在するため、導入前にこれらを考慮することが重要です。