🤖

Unity as a Library を Expo Module で楽に運用する (Android 編)

に公開

この記事はLivetoon Tech Advent Calendar 2025の 20 日目の記事です。
https://adventar.org/calendars/12157

はじめに

こんにちは、株式会社 Livetoon インターンのきたぴーと申します!
AI キャラクターアプリ kaiwa を主に開発していまして、急な訴求にはなりますが、ぜひ下記リンクからアプリを体験してみていただけると幸いです!
https://kai0.onelink.me/Hogh/AdventCalendar2025

今回のアドベントカレンダーでは、LivetoonのAIキャラクターアプリのkaiwaに関わるエンジニアが、アプリの話からLLM・合成音声・インフラ監視・GPU・OSSまで、幅広くアドベントカレンダーとして書いて行く予定です。
是非、publicationをフォローして、記事を追ってみてください。

さて、私が今回取り上げるのは

ユーザー体験向上を目的とした「kaiwa の React Native 化」

になります!私のシリーズは全部で 3 本立てになる予定で、全てを読むかお好みの Coding Agent に突っ込めばそれなりに動くものになることを目指しています。それぞれの記事で基本的なワークフローと私が実装する時に引っかかった落とし穴についてそれぞれ解説していきます。

  1. Expo 準備編 (リンク)
  2. Android 実装編 (この記事)
  3. iOS 実装編 (リンク)

また、サンプルリポジトリをこちらに公開していますので、適宜参照してください。今後の記事の投稿に合わせてリポジトリも更新されていきます。

https://github.com/shogo0x2e/uaal-for-expo-example/tree/main

Android project でインポートする

File > Build Profiles を開いて、Export Project を行います。今回は保存先を packages/expo-unity-view/android/unityLibrary にします。

Unity のビルド設定

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 の実装を更新するたびに直でいじった変更は消えてしまうので、パッチにして状態を冪等に保ちます。

https://github.com/shogo0x2e/uaal-for-expo-example/blob/main/scripts/patch-unity-library.sh

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

するとこんな感じで動くはずです。

Android 動作映像

注意点

unityPlayer は SurfaceView

なので、View としての制約は SurfaceView に従えば OK。何か想定外の挙動でデバッグをしたい時には SurfaceView を使ってプリミティブにプロトタイプを作ってみると問題の切り分けがしやすいです。

ReactSurfaceView は Android の SurfaceView とは全くの別物

Coding Agent がここをよく勘違いするので注意です。

レイアウトバグに悩まされたら Android の Layout Inspector を使う

Expo 準備編でしれっと使っていましたが、UnityView がどこに配置されているのかを見るために使える便利な Android Studio のツールです。

https://zenn.dev/livetoon/articles/expo-uaal-prep

おつかれさまでした

次回は iOS 編です!

参考文献

https://qiita.com/hayato-c18/items/ee04bb320161e3c09d1d

https://docs.unity3d.com/6000.3/Documentation/Manual/UnityasaLibrary-Android.html

https://github.com/facebook/react-native/blob/main/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/runtime/ReactSurfaceView.kt#L40

https://github.com/facebook/react-native/blob/main/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/ReactRootView.java#L89

Discussion