Unity as a Library を Expo Modules で楽に運用する (Expo 準備編)
この記事はLivetoon Tech Advent Calendar 2025の 14 日目の記事です。
はじめに
こんにちは、株式会社 Livetoon インターンのきたぴーと申します!
AI キャラクターアプリ kaiwa を主に開発していまして、急な訴求にはなりますが、ぜひ下記リンクからアプリを体験してみていただけると幸いです!
今回のアドベントカレンダーでは、LivetoonのAIキャラクターアプリのkaiwaに関わるエンジニアが、アプリの話からLLM・合成音声・インフラ監視・GPU・OSSまで、幅広くアドベントカレンダーとして書いて行く予定です。
是非、publicationをフォローして、記事を追ってみてください。
さて、私が今回取り上げるのは
ユーザー体験向上を目的とした「kaiwa の React Native 化」
になります!私のシリーズは全部で 3 本立てになる予定で、全てを読むかお好みの Coding Agent に突っ込めばそれなりに動くものになることを目指しています。それぞれの記事で基本的なワークフローと私が実装する時に引っかかった落とし穴についてそれぞれ解説していきます。
また、サンプルリポジトリをこちらに公開していますので、適宜参照してください。今後の記事の投稿に合わせてリポジトリも更新されていきます。
想定読者
Expo, React Native, モバイルのネイティブ実装について多少の実装経験があることを仮定しています。
- Expo, React Native に 3D 表現を Unity の資産を利用して導入したい人
- 2025/12 時点で最新の実装方法について知りたい人
- React Native との統合においてなんらかの実装で詰まった人
このような方々の実装の一助になれば幸いです。
Unity as a Library (UaaL) とは?
Unity as a Library (以下 UaaL) は、Unity のランタイム、レンダリング機能をライブラリとして iOS, Android などのネイティブアプリに組み込む仕組みです。通常の Unity アプリ開発では、Unity がアプリ全体のライフサイクルを管理しますが、UaaL を利用すると Unity は 1 つの View / UIView として動作するのでネイティブ UI との統合がとても容易になります。
本記事では、以下のようなコードベースの完成を目的とします。
- expo-router を使ったコードベース上で
- Unity as a Library の View を常駐させ
- Unity ↔︎ React Native 双方向のメッセージングを実装し
- React Native の UI を安全にオーバーレイし
- Android/iOS で動作する
(実際の Unity Player の組み込み, メッセージング実装は次回以降で行います。)
UaaL + Expo を採用するメリット
UI 実装の自由度が非常に高い
UaaL の最大の利点は、 UI を Unity ではなくネイティブ側で実装できることです。Unity の uGUI や UI Toolkit は表現力が高いものの、例えば可変長テキスト向けの UI を実装するような少々手の込んだ実装を行おうとすると途端に難しくなります。
開発メンバーの苦労はこちらから
UaaL を使用すれば、素直な UI 表現ができるネイティブ実装と 3D 表現に強い Unity 実装という形でお互いの強みを活かしながら責務を明確に分離できます。
React Native は Coding Agent と相性が良い
React / TypeScript は
- LLM によるコード補完・修正精度が高い
- 状態管理・UI 更新が宣言的でとても扱いやすい
- scene ファイルの yaml ではなくプログラム言語として構造を保てるので差分修正がしやすい
といった理由から、Coding Agent と非常に相性が良い技術スタックです。
Expo の Custom dev client によってホットリロードが使える
Unity 側の変更が少ないプロダクトであれば、これはアドバンテージになります。UaaL を Android, iOS (Simulator, Device 両方) 向けにビルドするのは割と時間をとります (M3 Pro の Macbook で合計 10 分以上かかるイメージ)。ですが、Expo は Custom Dev Client という仕組みを提供しているので、ネイティブレイヤーの実装変更がない限りは、JS をリロードするだけで UI 変更を反映できます。
デメリット
実装・保守の難易度が高い
Using Unity as a Library in other applications から Unity 公式のコメントを引用します。
Unity as a Library is intended for specialist users who use native platform technologies such as Java/Android, Objective C/iOS, or Windows Win32/UWP, and want to include Unity-powered features in their games or applications.
みていただける通り、UaaL は "specialist" 向けの機能という説明がされています。Swift, Kotlin といった言語の他に Cocoapods の定義や Unity が Export した成果物に対して手動でパッチしなければならない場面も登場するため、保守難易度は決して低いとは言えません。
今回、kaiwa で採用する背景
Biz もコーディングをする時代
Unity アプリ時代では UI 変更に対してかなりの時間を費やしていて、仮説検証のスピードという観点からすると好ましくない状況でした。Expo によるホットリロードや React の Coding Agent との親和性の高さを土台として、Dev 側はもちろん、Biz 側も Coding Agent を利用して PR 作成からリリースまでできる素早い開発をしていこうという狙いです。
React の組織的な親和性
flutter_unity_widget のようなライブラリもありますが、kaiwa の開発メンバーが React を得意としているため、React を採用するメリットが大きかったです。
ちなみに Expo バージョンのプロダクトはいま、鋭意開発中なんです ← !?
(未公開のプロダクト情報をここまでしゃべって良いのかと、ちょっと不安になっています😳)
Expo コードベースを準備する
# tab が実装された状態のテンプレートでコードベースを新規作成する
npx create-expo-app@latest --template tabs
# (optional) src/ ディレクトリに JS のソースコードを移動する
# 付随して assets を参照しているコードや tsconfig の @/* に src ディレクトリを追加するなど
# 対応が必要です
mkdir -p src && mv app components assets constants src/
# GitHub リポジトリを作成した後で、Expo module を作成する
npx create-expo-module ./modules/expo-unity-view
✔ What is the name of the npm package? … expo-unity-view
✔ What is the native module name? … ExpoUnityView
✔ How would you describe the module? … A module to represent unity as a library for expo
✔ What is the Android package name? … [アプリと衝突しないパッケージネーム]
✔ What is the name of the package author? … [自動入力済み、com.authorname.packagenameという感じで自動生成される]
✔ What is the email address of the author? … [自動入力済み]
✔ What is the URL to the author's GitHub profile? … [自動入力済み]
✔ What is the URL for the repository? … [作成した GitHub リポジトリの URL]
React Native インタフェースを定義する
Unity as a library は調べてみてもらえればわかりますが、Expo, React Native において決定版となるような統合ライブラリが存在しません (2025/12 時点)。UaaL と React Native の組み合わせは、アプリごとに要件が異なるので汎用化の難しい領分であることに起因していると思っています。なので、Unity as a library を実装する時は業務的な仕様とよく相談することになります。
今回の場合は、ボタンを押すことによって Unity Player が全画面で描画され、React の UI 要素がオーバーレイできるような構成を目指します。Unity 側の touchableEvents は全て無視し、タッチのインタラクションは全て React Native に任せる戦略を取ります。(Unity 公式の uaal-example では Unity とネイティブ両方のインタラクションができていますが、不具合発生時に切り分けが難しくなるので割り切りました。) kaiwa の画面を利用した完成イメージと、React 側からの呼び出しイメージは以下のような形です。

プロダクト画面から見る React 画面と Unity 画面の使い方
// src/app/_layout.tsx
// (中略)
function RootLayoutNav() {
// (中略)
return (
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
{/*
* UnityView を背景として表示して、React Native の UI 要素は children として渡して
* オーバーレイできるようにする
*/}
<ExpoUnityView>
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="modal" options={{ presentation: 'modal' }} />
<Stack.Screen name="unity-screen" options={{
// ExpoUnityView は Stack の背景として描画されるので
// UnityView を見えるようにするには透明化が必須!
contentStyle: {
backgroundColor: 'transparent',
},
headerShown: false
}}
/>
</Stack>
</ExpoUnityView>
</ThemeProvider>
);
}
基本的に、一度生成した Unity Player は破棄しないほうがいいので _layout.tsx に配置する方式にします。この都合で、unity-screen ではちょっと強引な解決方法を取ります。_layout.tsx に <ExpoUnityView /> を配置することで、ReactSurfaceView の中にとどまり続けるようにします。
UnityPlayer を背景に、React 要素を透明にする構成です。Android の Layout Inspector をみてもらえるとイメージしやすいかなと思います。下記の画像では、ScreenStack に expo-router の Stack コンポーネントが配置されています。

Android の Layout Inspector で見る unitySurfaceView の配置
Unity が描画に利用する View は Android では SurfaceView, iOS では UIView + CAMetalLayer を使用するので今回はこれらの View/UIView で動作をモックすることにします。
これによって、以下の 2 つが確認できます
- expo-router を利用してネイティブ view を配置した時に View 階層が破綻しないか
- 背景にネイティブ view を配置したまま、React UI を安全にオーバーレイできるか
モックコードについては解説を省略します。タグ mock_implementation からコードをご覧ください。
失敗例
- Stack.Screen 配下に配置するパターン
<ExpoUnityView />を Stack.Screen が呼び出すページに配置すると、Stack の最適化によってコンポーネントがアンマウントされます。その状態で Android で UnityPlayer に対してdestroy()を呼び出すと、条件によってはアプリ自体が kill される 挙動に遭遇しました。 - コンポーネントのアンマウント、マウントに対応して UnityPlayer の親 View に対して removeView(), addView() するパターン
Android において、アンマウント時に親 View から安全にremoveView()できないケースがあり、動作が不安定になってしまいました。(原因は未特定。) - FrameLayout 直下に UnityPlayer の View を配置するパターン
ReactSurfaceView を透過させる方法が存在しない という問題に直面しました。
ios, android で動かす
上記の mock_implementation タグの実装を開いて下記のコマンドを叩けば Android, iOS のシミュレータでアプリが立ち上がるはずです。
# prebuild で ios, android の成果物を生成する
make prebuild
# AVD をセットアップしていればシミュレータが立ち上がります
make android
# XCode がインストール済みであれば iOS シミュレータが立ち上がります
make ios
Android, iOS 共にこんな感じの挙動になれば成功です。unity-view に移る を押して、SurfaceView もしくは CAMetalLayer が写っている緑色の画面になります。戻るボタンを押すことで Tabs のあるホーム画面に戻ります。ボタンが動作して、オーバーレイしている React Native の UI 要素が動いていることがわかります。

iOS での動き
Layout Inspector をみれば、View 階層も狙い通り ScreenStack の背面に ExpoUnityView が配置されていることがわかります。

下部、ScreenStack の背面に ExpoUnityView がある

左側、RNScreenStackView の背面に ExpoUnityView がある
Unity プロジェクトを準備する
Unity から React Native 側へのメッセージングを実装するために RNMessage, NativeCallProxy を準備します。RNMessage は自前実装です。iOS Plugin の NativeCallProxy は Unity 公式の uaal-example リポジトリから持ってきました。

Unity Project を再生すると出てくる画面
上記のような感じで動きます。次回以降、Messaging によって以下の機能を実装します。
- React Native → Unity のメッセージングのデモとして Cube の色を任意の色に更新する処理
- Unity → React Native のメッセージングのデモとして経過秒数を React Native に通知する処理
おつかれさまでした
次回の Android 実装編では
- unityLibrary の Android 向け Export 方法
- UnityPlayer をシングルトンで安全に保持する Kotlin 実装
あたりを解説していきますので、Expo modules で実装したい人も純粋に Android で UaaL を呼び出したい人も次回の記事を楽しみにしていただければと思います!最後までご覧いただきありがとうございました!
Coding Agent Tips
途中で発生したエラーの修正には mobile-mcp を使いました。大変お世話になりました。これを組み込むだけで Coding Agent の自走力が上がって、その間他のタスクを並行してこなせる体験が良かったです。ぜひ使用してみてください。
参考文献
Discussion