Unity as a Library を Expo Module で楽に運用する (Android 編)
この記事はLivetoon Tech Advent Calendar 2025の 20 日目の記事です。
はじめに
こんにちは、株式会社 Livetoon インターンのきたぴーと申します!
AI キャラクターアプリ kaiwa を主に開発していまして、急な訴求にはなりますが、ぜひ下記リンクからアプリを体験してみていただけると幸いです!
今回のアドベントカレンダーでは、LivetoonのAIキャラクターアプリのkaiwaに関わるエンジニアが、アプリの話からLLM・合成音声・インフラ監視・GPU・OSSまで、幅広くアドベントカレンダーとして書いて行く予定です。
是非、publicationをフォローして、記事を追ってみてください。
さて、私が今回取り上げるのは
ユーザー体験向上を目的とした「kaiwa の React Native 化」
になります!私のシリーズは全部で 3 本立てになる予定で、全てを読むかお好みの Coding Agent に突っ込めばそれなりに動くものになることを目指しています。それぞれの記事で基本的なワークフローと私が実装する時に引っかかった落とし穴についてそれぞれ解説していきます。
また、サンプルリポジトリをこちらに公開していますので、適宜参照してください。今後の記事の投稿に合わせてリポジトリも更新されていきます。
Android project でインポートする
File > Build Profiles を開いて、Export Project を行います。今回は保存先を packages/expo-unity-view/android/unityLibrary にします。

build.gradle の dependencies に参照を追加します。
dependencies {
api project(":unityLibrary")
}
implementation ではなく api を使用するのは、expo-unity-view 内に閉じてしまうと Unity に必要なクラスやリソースが解決されないことがあるためです。明示的に成果物の APK ファイルに同梱されていて欲しいので、api として dependencies を定義します。
unityLibrary にパッチを当てる
今回はこんな感じの差分を作ります。
apply from: '../shared/common.gradle'
// NDK パスを設定する
// Unity export にないので、外部から注入します。
def unityPropsFile = file("${projectDir}/../gradle.properties")
if (unityPropsFile.exists()) {
def unityProps = new Properties()
unityPropsFile.withInputStream { unityProps.load(it) }
unityProps.each { key, value ->
if (!project.hasProperty(key)) {
project.ext.set(key, value)
}
}
}
// unityStreamingAssets のデフォルトパスを用意する
// これがない場合、StreamingAssets を参照しようとすると null チェックで落ちる。
def unityStreamingAssets = project.findProperty("unityStreamingAssets") ?: ""
android { ... }
unityLibrary は Unity の実装を更新するたびに直でいじった変更は消えてしまうので、パッチにして状態を冪等に保ちます。
Unity を Kotlin から呼び出す
Unity の UnityPlayer を起動し、View として React 側に返す形になります。UnityPlayer は前回の記事でも取り上げた通り「同時に単一インスタンスのみ」という制約があるので、Kotlin 側でシングルトン管理にしてしまうのが安全です。ざっくり構成はこんな感じです。コードの方は GitHub をご覧ください。
メッセージングをする
Android → Unity
Android は Unity 側で UnitySendMessage() が用意されているので、かなりシンプルです。Scene の中の GameObject を名前で探し、コンポーネントのメソッドを呼び出す API が提供されています。
UnityPlayer.UnitySendMessage(
"GameObjectName",
"MethodName",
"message payload"
)
React 側からは Expo Module を通して、sendUnityMessage({ objectName, methodName, message }) のような API を作るのが扱いやすいです。
Unity → Android
Unity 側からは AndroidJavaClass を使って static メソッドを呼び出すのが一番簡単です。
今回は UnityToReactBridge.emitMessage() を定義して React Native のイベントへ流します。
// Assets/Scripts/RNMessage.cs
using UnityEngine;
public static class RNMessage
{
public static void Send(string payload)
{
#if UNITY_ANDROID && !UNITY_EDITOR
try
{
using var jc = new AndroidJavaClass("com.shogo0x2e.uaalforexpoexample.unityview.UnityToReactBridge");
jc.CallStatic("emitMessage", payload);
}
catch (System.Exception ex)
{
Debug.unityLogger.LogWarning("RNMessage", $"emitMessage failed (Android): {ex.Message}");
}
#else
Debug.Log($"[RNMessage] (stub) payload={payload}");
#endif
}
}
object UnityToReactBridge {
@Volatile
private var listener: ((Map<String, Any?>) -> Unit)? = null
fun setListener(newListener: (Map<String, Any?>) -> Unit) {
listener = newListener
}
@JvmStatic
fun emitMessage(message: String?) {
val payload = mapOf("message" to message.orEmpty())
listener?.invoke(payload)
}
}
React 側(イベント受信)
ExpoUnityViewModule が unityMessage イベントを emit しているので、JS 側は addUnityMessageListener で購読できます。
addUnityMessageListener((event) => {
console.log("Unity message:", event.message);
});
動作確認
app.json の package に現在 com.shogo0x2e から始まるサンプルの package name を設定していますが、個々人の環境で変更する必要があるかもしれません。
そのあとは、先ほど扱ったパッチなども自動適用する makefile を同梱しているので
make android
するとこんな感じで動くはずです。

注意点
unityPlayer は SurfaceView
なので、View としての制約は SurfaceView に従えば OK。何か想定外の挙動でデバッグをしたい時には SurfaceView を使ってプリミティブにプロトタイプを作ってみると問題の切り分けがしやすいです。
ReactSurfaceView は Android の SurfaceView とは全くの別物
Coding Agent がここをよく勘違いするので注意です。
レイアウトバグに悩まされたら Android の Layout Inspector を使う
Expo 準備編でしれっと使っていましたが、UnityView がどこに配置されているのかを見るために使える便利な Android Studio のツールです。
おつかれさまでした
次回は iOS 編です!
参考文献
Discussion