🍎

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

に公開

この記事はLivetoon Tech Advent Calendar 2025の 22 日目の記事です。
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

UnityFramework としてエクスポートする

iOS は UnityFramework.framework + Data/ をセットで運用します。ここが Android と一番違うポイントです。この特有の仕様で、以下のような現象に悩まされることがありました。

  • UnityFramework.framework だけでは アセットが見つからず落ちる
  • Data/ が app bundle に入っていないと Data bundle not found で終了

Unity の Build オプションについて設定は特にありません。このまま build を叩いてください。今回ディレクトリは packages/expo-unity-view/ios/UnityFrameworkWrapper/Unity にしました。
Unity Build 画面


CocoaPods でラップする

2025年末時点では、Expo の iOS ネイティブ依存は CocoaPods が公式に想定されたルートであり、SPM は公式ドキュメント上の手順がなく未保証です。なので本記事では CocoaPods 前提で解説します。podspec の実装はこちらをご覧ください。

https://github.com/shogo0x2e/uaal-for-expo-example/blob/main/packages/expo-unity-view/ios/UnityFrameworkWrapper/UnityFrameworkWrapper.podspec


Build Phase を Ruby で注入する

Unity の Data/ を確実に app bundle に入れるため、Xcodeproj を直接編集して build phase を追加します。この Ruby スクリプトで、uaalforexpoexample.xcodeproj の app target に [CP-User] Copy Unity Data to App を追加します。

https://github.com/shogo0x2e/uaal-for-expo-example/blob/main/scripts/ensure-unity-data-phase.rb

Unity を Swift から呼び出す

Unity は iOS でも 同時に 1 インスタンスのみです。そのため、UnityRuntime をシングルトンで管理します。構成は Android とほぼ同じイメージです。異なるのは UnityFrameworkWrapper を使って UnityFramework の呼び出しを Pod に閉じ込めていることです。

メッセージングをする

iOS → Unity

Unity 側には UnitySendMessage があるので、Swift からそのまま呼ぶだけでOKです。今回は、UnityRuntime.swift でラップしているので、もっと簡略化してアクセスできるようにしています。

UnityRuntime.shared.sendMessage(
  objectName: "GameObjectName",
  methodName: "MethodName",
  message: "message payload"
)

Unity → iOS

Unity から iOS へは DllImport("__Internal") を使います。

#if UNITY_IOS && !UNITY_EDITOR
[DllImport("__Internal")]
private static extern void sendMessageToReactNative(string message);
#endif

iOS 側では NativeCallProxy.mm(Unity 側のプラグイン) が sendMessageToReactNative を受け取り、Swift 側の NativeCallsProtocol 実装へ渡します。NativeCallProxy の実装は公式の uaal-example から取得します。詳細は uaal-example の iOS ドキュメントをご覧ください (リンク)。

@objc protocol NativeCallsProtocol {
  func sendMessageToReactNative(_ message: String)
}

final class UnityRuntime: NSObject, NativeCallsProtocol {
  static let shared = UnityRuntime()
  weak var delegate: UnityRuntimeDelegate?

  func sendMessageToReactNative(_ message: String) {
    DispatchQueue.main.async { [weak self] in
      guard let self else { return }
      self.delegate?.unityRuntime(self, didReceiveMessage: message)
    }
  }

  private func registerNativeCallsProxy() {
    guard let cls = NSClassFromString("FrameworkLibAPI") as? NSObjectProtocol else { return }
    let sel = NSSelectorFromString("registerAPIforNativeCalls:")
    _ = (cls as AnyObject).perform(sel, with: self)
  }
}

React 側(イベント受信)

Android 側と同様、ExpoUnityViewModule が unityMessage イベントを emit しているので、JS 側は addUnityMessageListener で購読できます。

addUnityMessageListener((event) => {
  console.log("Unity message:", event.message);
});

動作確認

リポジトリには Makefile を同梱しています。また、今回は Simulator ビルドを統合していないので実機でのみ動作する形になっています。

iOS の bundleIdentifier は変更する必要があるかもしれません。app.json に com.shogo0x2e から始まるサンプル ID を書いていますが、個々人でお好みの ID に変更していただけると幸いです。

その後、下記のコマンドでアプリを起動してみてください。初回起動時には expo cli から Development team for signing the app: と尋ねられるので個人の Apple Developer Account を使用してください。

make ios IOS_ARGS='--device "YOUR_DEVICE_NAME"'

iOS 動作のイメージ

メモ

レイアウトのデバッグには Xcode の Capture View Hierarchy を使う

Xcode で ios/uaalforexpoexample.xcworkspace を開いてアプリを端末で起動させると Debug > View Debugging > Capture View Hierarchy から View の階層が見れます。

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

最後に

ここまでお疲れ様でした!

正直、React Native と Unity as a Library の連携がここまで複雑になるとは思っていませんでした。ただ、いま振り返ると難しさの中心は「Unity を埋め込む」ことではなくて、React Native が普段隠している View 階層・ライフサイクルの現実と真正面から付き合うことだったと思います。

たとえば、最初は “画面ごとに UnityView を出し入れすればいい” と考えていたのですが、実際には UnityPlayer の性質上それが不安定になりやすく、結果として _layout.tsx に常駐させる構成が一番事故らない、という結論になりました。こういう「動くはず」ではなく「壊れない形」を選ぶ意思決定が、UaaL では特に重要でした。

また、Coding Agent もかなり助けになった一方で、React Native のネイティブモジュール領域はハルシネーションが出やすく、最後は Layout Inspector / Capture View Hierarchy を見て判断するのが一番早い、という学びもありました。

3章立ての長編でしたが、同じように「Unity を使いたいけど UI はネイティブで作りたい」「Expo で運用できる形に落とし込みたい」という人の手がかりになれば嬉しいです。最後まで読んでいただき、ありがとうございました!

参考文献

https://techblog.zozo.com/entry/unity-as-a-library-ux

https://speakerdeck.com/pkino/challenge-of-unity-as-a-library-by-a-ios-development-beginner

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

https://github.com/Unity-Technologies/uaal-example/blob/master/docs/ios.md

Discussion