Mirrativ tech blog

株式会社ミラティブの開発者(サーバサイドエンジニア,iOSエンジニア,Androidエンジニア,機械学習エンジニア,インフラエンジニア, etc...)によるブログです

【Unity】MirrativのEmbedding Unityを更新した話: 実践 Unity as a Library

こんにちは皆様いかがお過ごしでしょうか、10ヶ月ぶりくらいのポストになります、よこてです。今日は「Mirrativ の Unity は進化してるんだぞ」という記事を書いていきます。

tech.mirrativ.stream

Mirrativ は Swift/Kotlin によるネイティブアプリですが、3D/アバター部分は Unity で実現しています。いわゆる embedding unity で、 Unity 2018.3 からは Unity as a Library として公式サポートされています。前回記事で触れたように、Unity をネイティブアプリに組み込むこと自体は公式サポート以前にもできて、ミラティブでは Unity 2018.2(2018年8月頃)から使っています。

f:id:n0mimono:20201015194824p:plain

Mirrativ では今 Unity 2019.4 LTS を使っていて、8月から Mirrativ の機能としてリリースした「エモモRUN」(3Dアバター × ゲーム × ライブ配信)もこれを利用しています。公式としてサポートされたといってもハマりどころがあったりするので今日はそのあたりを中心に話をします。

Unity as a Library

Unity as a Library は読んで字のごとく「Unity を(アプリケーションでなく)ライブラリとしてつかう」方法です。Mirrativ がアバター機能を最初にリリースした2018年時点では、ググっても情報量皆無の認知度でしたが、今はそれなりにヒットする感じで1ミリずつ広がりを見せているんじゃないかと思います。

公式の説明から引用すると

Unity では、ランタイムライブラリの読み込み、アクティベーション、アンロードの方法とタイミングをネイティブアプリケーション内で管理するための制御機能を用意しています。その上、モバイルアプリの構築プロセスはほぼ同じです。Unity では iOS Xcode と Android Gradle のプロジェクトを制作できます。

もともと Unity は昔から、エンジン部分をライブラリとしてアプリケーション本体から切り離すような構成をしていました。具体的に Android では、エンジンのエントリーは libmain.so 、ビューとして Surface View (本体はVulkanあるいはGLSL)、ラッパーとしての UnityPlayerextends FrameLayout)があり、これを使うためのアプリケーションとして MainActivity がある、という構成です。

前回記事(Unity 2018.2)時点では、UnityPlayerを Unity が用意する MainActivity から切り離して使いました。ビルドという視点では、もともとアプリ用に準備されたプロジェクトをライブラリ用の設定にして、ライブラリとしてビルドするということをやっています。

  • アプリ用プロジェクト ← これをライブラリ用に書き換えてビルドする

Unity 2018.3 以降の Unity as a Library では、Unity 上で iOS/Android ビルドをした時点で Unity エンジン部分がプロジェクトとして初めから分離しています。Android の場合には、アプリケーションのプロジェクトの中に unityLibrary というサブプロジェクトが出力され、アプリのプロジェクトがこの unityLibrary に依存する構成になっています。このため unityLibrary をそのままビルドすれば他プロジェクトで利用するための .aar が取得できます。

  • アプリ用プロジェクト
    • ライブラリ用プロジェクト ← これをビルドする

出力されたプロジェクトをそのままネイティブ側のプロジェクトに組み込んでもよいですが、Mirrativ では一度ライブラリ(iOS の場合は .framework)としてビルドしています。

フレームワークのビルド(2020版)

そのままビルドすればいいといったものの、そのままビルドできません。。いくつかの hack を入れます。

iOS

Mirrativ では次の3つの処理を行っています。

  1. Xcodeプロジェクトの修正
  2. Info.plistの修正
  3. ネイティブコードの修正

すべてポストプロセスで処理するようにしています。コードの一部を抜粋すると

    public void OnPostprocessBuild(BuildReport report)
    {
        var outputPath = summary.outputPath;
        var overridePath = Application.dataPath + "/../Framework/iOS/build";
        EditProject(outputPath); // 1
        EditPList(outputPath); // 2
        Utility.RSync(overridePath, outputPath); // 3
    }

Utility.RSyncrsync -av overridePath outputPath と同じ処理を行うメソッドで、overridePath 以下にある全ファイルを outputPath 以下のファイルに上書きします。

Xcodeプロジェクトの修正は必須の処理になります。

    static void EditProject(string outputPath)
    {
        var projectPath = outputPath + "/Unity-iPhone.xcodeproj/project.pbxproj";

        var pbx = new PBXProject();
        pbx.ReadFromFile(projectPath);

        // Get UnityFramework Target
        var guidTarget = pbx.GetUnityFrameworkTargetGuid();

        // Add Public Header
        var guidHeader = pbx.FindFileGuidByProjectPath("Libraries/Plugins/iOS/UnityPlayerToIOS.h");
        pbx.AddPublicHeaderToBuild(guidTarget, guidHeader);

        // Add Data to Resources Build Phase.
        var guidData = pbx.FindFileGuidByProjectPath("Data");
        var guidResPhase = pbx.GetResourcesBuildPhaseByTarget(guidTarget);
        pbx.AddFileToBuildSection(guidTarget, guidResPhase, guidData);

        // Add BITCODE_GENERATION_MODE
        pbx.SetBuildProperty(guidTarget, "BITCODE_GENERATION_MODE", "bitcode");

        pbx.WriteToFile(projectPath);
    }

Unity が出力する Xcode のプロジェクトにはアプリ用の Target とフレームワーク用の Target が含まれます。フレームワーク用の Target に対して次の3つを行います。

  • iOS 用プラグインをプロジェクトに含める
  • Data(バンドルされるリソース郡)をプロジェクトに含める
  • bitcode を生成するようにする

この中で Data フォルダをリソースに指定するのは必須で、これがないと Unity が正常に動きません。Data フォルダはアプリ用 Target に含まれますが、フレームワーク用の Target には含まれないため、これを追加する処理を行います。プラグインと bitcode の対応は必須ではありませんが、Mirrativ では両方とも使用しているためこれを入れています。

Info.plist の修正は optional な処理になります。

    static void EditPList(string outputPath)
    {
        var plistPath = outputPath + "/UnityFramework/Info.plist";

        var plist = new PlistDocument();
        plist.ReadFromFile(plistPath);

        var root = plist.root;
        root.SetString("CFBundleShortVersionString", Application.version);

        plist.WriteToFile(plistPath);
    }

バージョンをフレームワークに入れています。Unity エディタ上で指定するバージョンはアプリの Info.plist に書かれますが、フレームワークの Info.plist には書かれないためこの処理を入れています。

ネイティブコードの修正は optional な処理になります。 Xcode が出力するコードを適当に書き変えたいときに Utility.RSync を利用して書き換えます。例えば、Mirrativ では UnityFramework.h とその周辺のファイルを書き換えていて

__attribute__ ((visibility("default")))
@interface UnityFramework : NSObject
{
}

...

- (void)setAudioSessionActiveUnsafe:(bool)active;
@end

main.mm で

- (void)setAudioSessionActiveUnsafe:(bool)active
{
    UnitySetAudioSessionActive(active ? 1 : 0);
}

という風にしています(この例だと絶対に必要というわけではありませんが。。)。

Android

ライブラリビルド用にポストプロセスを用意します。コードの一部を抜粋すると

    public void OnPostprocessBuild(BuildReport report)
    {
        var outputPath = summary.outputPath + "/unityLibrary";
        var overridePath = Application.dataPath + "/../Framework/Android/unityLibrary";

        ProcessorUtility.Rsync(overridePath, outputPath);
    }

Mirrativでは次のファイルを書き換えています。

  • unityLibrary
    • build.gradle
    • src
      • main
        • AndroidManifest.xml
        • jniLibs
          • x86
            • libmain.so
        • res
          • values
            • ids.xml
            • strings.xml

Unity には AndroidManifest.xml を上書きする仕組みがありますが、ライブラリ用には動いてくれないためポストプロセスで上書きします。

AndroidManifest.xml から application タグを消します。

<?xml version="1.0" encoding="utf-8"?>
<!-- GENERATED BY UNITY. REMOVE THIS COMMENT TO PREVENT OVERWRITING WHEN EXPORTING AGAIN-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.unity3d.player" xmlns:tools="http://schemas.android.com/tools">
  <uses-feature android:glEsVersion="0x00020000" />
  <uses-permission android:name="android.permission.INTERNET" />
  <uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
  <uses-feature android:name="android.hardware.touchscreen" android:required="false" />
  <uses-feature android:name="android.hardware.touchscreen.multitouch" android:required="false" />
  <uses-feature android:name="android.hardware.touchscreen.multitouch.distinct" android:required="false" />
</manifest>

さらに UnityPlayer

    private static String a(Context var0) {
        return var0.getResources().getString(var0.getResources().getIdentifier("game_view_content_description", "string", var0.getPackageName()));
    }

というようにリソースにアクセスしているため、アプリのプロジェクトに含まれる strings.xml をライブラリに含むようにします。

<string name="game_view_content_description">Game view</string>

また必須の処理ではありませんが、x86 用の libmain.so を用意しています。UnityPlayer は static initializer で libmain.so を読み込みますが、Unity は x86 の端末(たとえばエミュレータ)をサポートしないため(libmain.soがないため)クラッシュを起こします。

    static {
        (new m()).a();

        try {
            System.loadLibrary("main");
        } catch (UnsatisfiedLinkError var1) {
            com.unity3d.player.g.Log(6, "Failed to load 'libmain.so', the application will terminate.");
            throw var1;
        }
    }

動作する x86 ビルドは用意できないしする必要もないので読み込めるだけのダミーの libmain.so を用意します。

フレームワークの利用

iOS

基本的には公式のサンプルコードを Swift で書き直すだけです。実際に使っているコードから一部抜粋します。

import UnityFramework

extension Unity {
    final class Framework {
        #if arch(i386) || arch(x86_64)
        public func load(argc: Int32, argv: UnsafeMutablePointer<UnsafeMutablePointer<Int8>?>!, launchOptions: [UIApplication.LaunchOptionsKey: Any]?) {
        }
        #else
        public func load(argc: Int32, argv: UnsafeMutablePointer<UnsafeMutablePointer<Int8>?>!, launchOptions: [UIApplication.LaunchOptionsKey: Any]?) {
            func unityFramewokLoad() -> UnityFramework? {
                let bundlePath = "\(Bundle.main.bundlePath)/Frameworks/UnityFramework.framework"
                let bundle = Bundle(path: bundlePath)
                if let bundle = bundle, !bundle.isLoaded {
                    bundle.load()
                }

                let ufw = bundle?.principalClass?.getInstance()
                if ufw?.appController() == nil {
                    var header = _mh_execute_header
                    ufw?.setExecuteHeader(&header)
                }
                return ufw
            }

            let ufw = unityFramewokLoad()
            ufw?.setDataBundleId("com.unity3d.framework")
            ufw?.runEmbedded(withArgc: argc, argv: argv, appLaunchOpts: launchOptions)
        }
        #endif
    }
}

シミュレータ用にダミーの関数を用意しています。

Android

こちらも同じく公式にサンプルがありますが、Mirrativ では OverrideUnityActivity を使わずに UnityPlayer を直接使っています。

class AnyUnityViewFragment : Fragment() {
    private val unityPlayer: UnityPlayer by inject()
}

UnityPlayer は子に SurfaceView をもつ FrameLayout ですが かなり問題児で 適当に扱うと割とクラッシュします。SurfaceView のサイズがなんらかの理由で変更されたときにフレームバッファを作り直す処理が走るため、Unity の処理がハングするのと、さらに処理が終わる前にサイズをさらに変更すると容易にクラッシュします。処理の完了を上手く拾えなかったため( onSurfaceChanged もあまり当てにならず、、)、アプリの方にサイズ変更を連発させないような処理を入れています。

おわりに

Unity as a Library を使うと Unity とネイティブのいいとこどりができるという側面もある一方制約も増えます。例えば、開発中のイテレーションを考えると、Unity ビルド → Xcode ビルド(Unity フレームワーク) → Xcode ビルド(iOS ビルド)となり単純にビルド時間が伸びます。また、アプリから利用する場合はアセットバンドルのビルドも考慮する必要が出てきます。

このような事情のため、Mirrativ の開発方針としては可能な限り Unity/iOS/Android が各々独立して動作可能になるように設計、運用しています。アセットフローという視点では、Unity 側では CI によってフレームワークをビルドし、アウトプット先として GitHub のリポジトリにフレームワークを push、iOS/Android 側のプロジェクトでは git submodule として扱う、という形で運用しています。

未解決の問題はそれなりにあって、例えばネイティブ/Unity間の通信と設計というトピックがあります。現状はネイティブ(iOS/Android)から Unity に情報を伝えるとき SendMessage を使って request を投げるような構成になっているのですが、iOS/Android のアプリ側はFluxなアーキテクチャになっているため、ネイティブ側としては state を Unity にわたして、ネイティブからは Unity が状態を持っていないように見える、、というのが良さげな設計かな、と考えています。今年もあと3ヶ月程度ですが、このあたりは年末までにやっつけていきたいですね。

We are hiring!

ミラティブでは ゲーム×ライブ配信のアプリを一緒に作ってくれる iOS/Android エンジニアを募集中です!meetup も開催しているため気軽にご参加ください!

www.mirrativ.co.jp

speakerdeck.com