デコシノニッキ

ホロレンジャーの戦いの記録

Unity as a Libraryと SwiftUI で作るARアプリ (前編)

f:id:haikage1755:20210625234251p:plain

!Image from iOS.jpg

NOTE

記事について

  • この記事は更新される可能性があります。
  • 画像の更新コストが重いので、テキストベースで手順を記載しています。
  • この記事は、ARFoundationとSwiftUIを組み合わせたいなーというモチベーションで書いていますが、ARFoundationは別になくても問題なく成立します。

元ネタについて

  • この記事は、下記のブログポストのコードを分割したり日本語のコメントを入れて整理した内容です。詳しい内容はリンク先をご参照ください。 # Unity 2020 Integration With SwiftUI
  • ブログでは書いていない細かい引っかかりなどは追記しています。

書いている人の知識について

登場要素

  1. Unityプロジェクト
  2. Unityビルドした後に生成されるIL2CPPプロジェクト
  3. Swiftプロジェクト
  4. UnityビルトとSwiftプロジェクトを一緒にまとめるワークスペース

Unity アプリの準備

開発環境: Unity 2020.3 1. MyARApp (なんでもいい) でアプリを作ります 2. Build Settings > Switch Platform で iOS に切り替える

AR利用環境の設定

  1. Package Manager から ARKit XR PluginAR Foundaiton を入れる
  2. Hierarchy からMainCameraを削除し、 ARSessionOriginARSession を作成する
  3. Unity でレンダリングができているかを確認するために、Cube を (0, 0, 3) に作成・配置する
  4. Project Settings > XR Plug-in Management の Plug-in Providers の ARKit にチェックを入れる

(Option) このアプリをUnityアプリとしても使いたい場合は、 Project Settings > Player Settings > Other Settings > Camera Useage Description にfor ARKit (なんでもいい) を入れる。 Camera の許可設定はSwiftアプリの方で設定してあげる必要がある。

Swift との連携部の作成

Swift アプリからは Objective-C 経由でUnityアプリのメソッド等を呼んでもらう

  1. Assets/Plugins/iOS ディレクトリを作る
  2. NativeCallProxy.hディレクトリに追加する

NativeCallProxy.h は後にSwift アプリと情報をやり取りする場所になる。C# 的に言うならば interface みたいなもの。

// 重要: このファイルのターゲットメンバーシップに UnityFrameworkを設定し、パブリックヘッダーの可視性を設定する必要がある

#import <Foundation/Foundation.h>

// NativeCallsProtocol は iOS側からUnityメソッドに登録する型
@protocol NativeCallsProtocol
@required
- (void) onUnityStateChange:(const NSString*) state;
// 上は一例
// このブロックにのメソッドを定義していく.
@end

__attribute__ ((visibility("default")))
@interface FrameworkLibAPI : NSObject
// UnityFrameworkLoadの後に呼び出す
// iOS側はこのメソッドから、デリゲートを登録する
+(void) registerAPIforNativeCalls:(id<NativeCallsProtocol>) aApi;

@end
  1. 同様に、NativeCallProxy.mm を追加する

NativeCallProxy.h がインタフェースならば、NativeCallProxy.mm はその実装部となる。

#import <Foundation/Foundation.h>
#import "NativeCallProxy.h"

@implementation FrameworkLibAPI

id<NativeCallsProtocol> api = NULL;
+(void) registerAPIforNativeCalls:(id<NativeCallsProtocol>) aApi
{
    api = aApi;
}

@end

// Unity側からiOSのメソッドを伝達する部分
// apiに登録されているデリゲートを呼び出している
extern "C" {
  void
  sendUnityStateUpdate(const char* state)
  {
      const NSString* str = @(state);
      [api onUnityStateChange: str];
  }
}
  1. Assets に HostNativeAPI.cs を追加する

Unity から Native Plugin を呼び出せるようにスクリプトを追加する。

using System.Runtime.InteropServices;

public class HostNativeAPI 
{
    [DllImport("__Internal")]
    public static extern void sendUnityStateUpdate(string state);
}
  1. Assets に API.cs を追加する

このスクリプトをHierarychy内の任意のGameObjectにアタッチする。後に出てくる iOS 側のプロジェクトは、Unityの起動タイミングを知る手段が必要となる。そこで、このスクリプト"ready"メッセージを送っている。

using UnityEngine;

public class API : MonoBehaviour
{
    void Start()
    {
#if UNITY_IOS
        if (Application.platform == RuntimePlatform.IPhonePlayer
        {
            HostNativeAPI.sendUnityStateUpdate("ready");
        }
#endif
    }
}

Unity アプリのビルドとビルド後の設定

  1. Build Settings から Build する。 ここでは名前をMyARAppExport とする。
  2. ビルドしたプロジェクトに生成される Unity-iPhone.xcodeproj を開く。
  3. 画面左のプロジェクトの階層から、Dataを選択し、画面右のインスペクタの Target Membership の UnityFramework にチェックを入れる。
  4. 続いてプロジェクトの階層から、Libralies/Plugins/iOS/NativeCallProxy.hを選択し、Target MembershipUnityFrameworkprojectpublicにする。public にすることで、Unity-iPhoneと別のプロジェクトからこのNativeCallProxyを呼び出せるようになる。
  5. 設定したらプロジェクトを閉じる。(開いたままだと、ワークスペースの追加時にエラーになる)

Swiftアプリの準備

  1. XCode から New > Project で App を選択する。名前はMyNativeApp(なんでもいい)とする。インタフェースはSwiftUI、ライフサイクルはSwiftUI Appを選択する。
  2. MyNativeApp に LaunchScreen を追加する。
  3. MyNativeApp/Info.plist の Launch screen interface file base nameLaunchScreen を指定する。 (Unity のロゴが表示できるようにSplash Screenを参照している模様)
  4. ARKit を使う場合は、カメラの利用許可が必要となるため、 MyNativeApp/Info.plist に Privacy - Camera Usage Description を追加し、Value には for ARKit (なんでもいい) を入れる
  5. 作成したら、プロジェクトを閉じる。(開いたままだと、ワークスペースの追加時にエラーになる)

ワークスペースを作成する

xcproj を一箇所にまとめておくと便利なのでワークスペースを作って、そこに作成したUnityビルドとSwiftアプリを統合する。

  1. XCode から New > Workspace で Workspace を作成する。名前はMyWorkspace とする。
  2. File > Add Files... から、MyAppExport/Unity-iPhone.xcodeprojを追加する。MyNativeApp/MyNativeApp.xcodeprojも同様についかする。

SwiftUI と Unity アプリを接続する

フレームワークの追加

Unityのビルド物に含まれるUnityFrameworkを通して、Swiftはコミュニケーションを行う。そのため、下記の手順でSwiftプロジェクトにUnityFrameworkを追加する。

  1. 画面左のプロジェクト一覧の階層からMyNativeAppを選択し、Targets から MyNativeApp を選択する。
  2. General タブに切り替え、Frameworks, Libralies, and Embedded Content+ボタン を押して、Workspace/Unity-iPhone/UnityFramework.frameworkを追加する。

NativeCallProxy のインポート

  1. 利用するライブラリを定義する NativeCallProxy-Bridging-Header.h を追加する。

NativeCallProxy-Bridging-Header

#ifndef APISwiftBridge_h
#define APISwiftBridge_h

// Swiftからobjective-c のAPIを呼べるようにする
#include <UnityFramework/NativeCallProxy.h>

#endif /* APISwiftBridge_h */
  1. 前項目と同様に、MyNativeApp を選択し、Targets から MyNativeApp を選択する。
  2. Build Settings のタブに切り替え、Swift Compiler - GeneralObjective-C Biding HeadersNativeCallProxy-Bridging-Header.h を追加する。

コールバックを定義する

  1. API.swift を追加する。(UnityBridgeは後述)

API は Unity側で定義したNativeCallsProtocolの実装。ここで定義したメソッドが、registerAPIforNativeCalls 経由で登録される。

先ほど、起動時にreadyを呼ぶように設定した部分は、ここで呼び出される。

import Foundation
import UnityFramework

class API: NativeCallsProtocol {

    internal weak var bridge: UnityBridge!

    // Unityからのメッセージを受け取るコールバックメソッド
    // UnityBridge内で登録する
    internal func onUnityStateChange(_ state: String) {
        switch (state) {
        case "ready":
            // Unityの画面が呼び出し可能になったことを伝える
            self.bridge.unityGotReady()
        default:
            return
        }
    }
}
  1. UnityBridge.swift を追加する。

UnityBridge は、Unity の起動やアンロードといったライフサイクルの操作。また、上で作ったメソッドをregisterAPIforNativeCallsを通じてswiftで受け取りたいUnityからの情報を取得するメソッドをデリゲートに登録する。

import Foundation
import UnityFramework

/// Unityとネイティブアプリを繋ぐシングルトンクラス
/// Unityフレームワークの初期化・読み込みを行える
class UnityBridge: UIResponder, UIApplicationDelegate, UnityFrameworkListener {

    public internal(set) var isReady: Bool = false

    public var api: API

    public var onReady: () -> Void = {}

    private static var instance: UnityBridge?

    /// UnityFramework instance
    private let ufw: UnityFramework

    /// UnityFramework root view
    public var view: UIView? { ufw.appController()?.rootView }

    public static func getInstance() -> UnityBridge {
        if UnityBridge.instance == nil {
            UnityBridge.instance = UnityBridge()
        }
        return UnityBridge.instance!
    }

    /// bundleパスからUnityFrameworkを読み込む
    ///
    /// - Returns: The UnityFramework instance
    private static func loadUnityFramework() -> UnityFramework? {
        let bundlePath: String = Bundle.main.bundlePath + "/Frameworks/UnityFramework.framework"
        let bundle = Bundle(path: bundlePath)
        if bundle?.isLoaded == false {
            bundle?.load()
        }

        let ufw = bundle?.principalClass?.getInstance()
        if ufw?.appController() == nil {
            let machineHeader = UnsafeMutablePointer<MachHeader>.allocate(capacity: 1)
            machineHeader.pointee = _mh_execute_header
            ufw!.setExecuteHeader(machineHeader)
        }
        return ufw
    }

    internal override init() {
         self.ufw = UnityBridge.loadUnityFramework()!
         self.ufw.setDataBundleId("com.unity3d.framework")
         self.api = API()
         super.init()
         self.api.bridge = self
         self.ufw.register(self)
        NSClassFromString("FrameworkLibAPI")?.registerAPIforNativeCalls(self.api)
        //FrameworkLibAPI.registerAPIforNativeCalls(self.api)
         ufw.runEmbedded(withArgc: CommandLine.argc, argv: CommandLine.unsafeArgv, appLaunchOpts: nil)
     }

     public func show(controller: UIViewController) {
         if self.isReady {
             self.ufw.showUnityWindow()
         }
         if let view = self.view {
             controller.view?.addSubview(view)
         }
     }

     public func unload() {
         self.ufw.unloadApplication()
     }

     internal func unityGotReady() {
         self.isReady = true
         onReady()
     }

    /// フレームワークがアンロードされた時に `UnityFrameworkListener`を通してUnityにトリガーされます。
    internal func unityDidUnload(_: Notification!) {
        ufw.unregisterFrameworkListener(self)
        UnityBridge.instance = nil
    }
}
//#endif

View に Unity を組み込む

  1. MyNativeApp に SwiftUIファイルを追加する。

Unityの画面自体はUIKitのView形式で生成されるので UIViewControllerRepresentable を実装して SwiftUI に変換する。

import UIKit
import SwiftUI

// UIViewControllerRepresentableを実装すると、UIKitのViewをSwiftUIのViewとして返すことができる
struct UnityView: UIViewControllerRepresentable {

    func makeUIViewController(context _: Context) -> UIViewController {
        let vc = UIViewController()
        let unity = UnityBridge.getInstance()

        // Unityが呼び出し可能になったらUnity画面を表示する
        UnityBridge.getInstance().onReady = {
            UnityBridge.getInstance().show(controller: vc)
        }
        return vc
    }

    func updateUIViewController(_: UIViewController, context _: Context) {
      // Empty.
    }
}
  1. UnityView を ContentView に組み込む
import SwiftUI

struct ContentView: View {
    var body: some View {

        // UnityのViewにSwiftUIのテキストを重畳している
        UnityView()
        Text("Hello, world!")
            .padding()
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

ビルド

あとはビルドしたらアプリが開始されます!

(追記) Native-Plugin もSwiftで書く場合

headerファイルのプロトコル定義やFrameworkLibAPIの記述をswiftで置き換えることもできます。

Unity側の変更

  1. NativeCallsProxy.h を swift で置き換える

Assets/Plugins/iOS/NativeCallsProxy.h を 下記のswiftで置き換えます。

(参考) [# Swift class with only class methods and a delegate?]

NativeCallsProxy.swift

import Foundation

@objc public protocol NativeCallsProtocol {
    // Unityからのステートの変更をSwiftUIへ通知する.
    func onUnityStateChange(_ state:String)
}

public class FrameworkLibAPI: NSObject{

    @objc public static weak var api: NativeCallsProtocol? = nil

    @objc public static func registerAPIforNativeCalls(_ aAPI: NativeCallsProtocol?){
        api = aAPI
    }
}
  1. NativeCallsProxy.mm を一部変更する

Swiftの方でクラスを実装しているため、Objective-C++で実装していた部分を除去しています。

#import <UnityFramework/UnityFramework-Swift.h>

extern "C" {

    void sendUnityStateUpdate(const char* state) {
      NSString * const str = @(state);
      [FrameworkLibAPI.api onUnityStateChange:str];
    }
}
  1. Assets/Editorの下にXcodePostProcessにSwiftを利用するためにビルド後の設定を行ってくれるファイルを追加する

(参考) 【Unity】iOSネイティブプラグインをSwiftで実装する際には、2019.3前後で設定方法が変わる

using System.IO;
using UnityEditor;
using UnityEditor.Callbacks;
using UnityEditor.iOS.Xcode;

sealed class XcodePostProcess
{
    [PostProcessBuild]
    static void OnPostProcessBuild(BuildTarget target, string path)
    {
        if (target != BuildTarget.iOS) return;

        var projectPath = PBXProject.GetPBXProjectPath(path);
        var project = new PBXProject();
        project.ReadFromString(File.ReadAllText(projectPath));

        var targetGuid = project.GetUnityFrameworkTargetGuid();
        
        project.AddBuildProperty(targetGuid, "SWIFT_VERSION", "5.0");
        File.WriteAllText(projectPath, project.WriteToString());
    }
}

SwiftUI側の変更

  1. API.swift をプロトコルの型に合わせて変更する

Protocolの定義が変わったので、NSStringからStringに変更します。

API.swift

    internal func onUnityStateChange(state: String) {
    ・・・
}
  1. NativeCallsProxy-Bridging-Header.h から不要な部分を削除する
#ifndef APISwiftBridge_h
#define APISwiftBridge_h

// NativeCallProxy.hはもうないので、下記の部分を削除数する
// #include <UnityFramework/NativeCallProxy.h>

#endif /* APISwiftBridge_h */

あとはビルドするだけです。

次について

今回は、最低限重畳するところまででしたが次回はSwiftのUIからUnityのオブジェクトを操作するところを書こうかなと考えています。(いつ書き終わるかは未定)

[デコシノニッキ]は、Amazon.co.jpを宣伝しリンクすることによってサイトが紹介料を獲得できる手段を提供することを目的に設定されたアフィリエイト宣伝プログラムである、Amazonアソシエイト・プログラムの参加者です。」