最近剛好有機會要整合 Unity 到 SwiftUI 開發的 app 裡頭,整個過程不算難,但是蠻繁瑣的。網路上的資料絕大部分都是跟 Objective-C / UIKit 的整合,比較少 Swift / SwiftUi 相關資料,所以趁著記憶猶新的時候把它記錄下來,希望可以幫助到其他人以及未來的自己。

從 Unity 2019.4 的版本開始,它提供了 UaaL (Unity as a Library) 功能,可以把 Unity 專案匯出成 framework 讓其他程式使用,開啟了原生平台與 Unity 互動的更多可能性。你可以只用 Unity 開發遊戲或其他精美畫面的部分,剩下的則是使用原生平台(例如 iOS 或 Android)開發。

我使用的開發環境是 Xcode 12.5 + Unity 2020.3.15f2 LTS,預期達成以下目標:

  1. 在 SwiftUI 顯示 Unity 的畫面
  2. iOS 跟 Unity 可以雙向傳遞訊息

先附上 sample code。這篇文章會有大量程式碼,也會假設你對 Xcode 跟 Unity 的操作有基本的認知,廢話不多說,讓我們開始吧!

基本的整合

首先,利用 Xcode 建立一個 SwiftUI App,然後透過 File -> Save as Workspace 把他轉成 workspace,這樣才方便之後將 Unity project 整合進來。

然後用 Unity Hub 建立一個 Unity 專案,並透過 File -> Build Settings 把專案導出成 Xcode project,為了方便起見,我把導出的結果放在 SwiftUI App 專案根目錄底下的 UnityExport 目錄。

導出成功之後,把 Unity-iPhone.xcodeproj 拉到剛才建立的 workspace 裡頭,並把 Location 設為 Relative to Workspace。

然後選擇 Unity-iPhone project 底下的 Data 目錄,在 Target Membership 的地方勾選 UnityFramework。

接下來回到我們的 SwiftUI project 並選擇我們的 target,在 General -> Frameworks, Libraries, and Embedded Content 加入 UnityFramework.framework

然後去 Build Phases -> Link Binary With LibrariesUnityFramework.framework 移除。

這樣子基本的專案設定就完成了,接下來我們建立一個 UnityManager.swift 來管理 Unity,這裡沒有什麼特別的,就是啟動 Unity 而已。

import Foundation
import UnityFramework

final class UnityManager: UIResponder, UIApplicationDelegate {
    static let shared = UnityManager()

    /// UnityFramework instance
    private var unityFramework: UnityFramework?

    var unityView: UIView? {
        return unityFramework?.appController().rootView
    }

    /// Loads the Unity framework
    func load() {
        let ufw = loadUnityFramework()
        ufw.setDataBundleId("com.unity3d.framework")
        ufw.register(self)
        ufw.runEmbedded(withArgc: CommandLine.argc, argv: CommandLine.unsafeArgv, appLaunchOpts: nil)
        unityFramework = ufw
    }

    /// Unloads the Unity framework
    ///
    /// ## Notes
    /// - Unloading isn't synchronous, and this object will be notified in the `unityDidUnload` method
    func unload() {
        unityFramework?.unloadApplication()
    }
}

extension UnityManager: UnityFrameworkListener {
    /// Triggered by Unity via `UnityFrameworkListener` when the framework unloaded
    func unityDidUnload(_: Notification!) {
        unityFramework?.unregisterFrameworkListener(self)
        unityFramework = nil
    }
}

private extension UnityManager {
    /// Loads the UnityFramework from the bundle path
    ///
    /// - Returns: The UnityFramework instance
    func loadUnityFramework() -> UnityFramework {
        let bundlePath: String = Bundle.main.bundlePath + "/Frameworks/UnityFramework.framework"
        guard let bundle = Bundle(path: bundlePath) else {
            fatalError("Fail to find UnityFramework.framework bundle.")
        }

        if !bundle.isLoaded {
            bundle.load()
        }

        guard let ufw = bundle.principalClass?.getInstance() else {
            fatalError("Fail to load UnityFramework.")
        }

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

我們可以建立一個 UnityView.swift 來啟動 Unity project,並且可以在 SwiftUI 裡頭顯示。

import SwiftUI

struct UnityView: UIViewRepresentable {
    typealias UIViewType = UIView

    func makeUIView(context: Context) -> UIView {
        let rootView = UIView()
        UnityManager.shared.load()
        DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
            guard let unityView = UnityManager.shared.unityView else {
                fatalError("Unity rootView is not ready.")
            }
            rootView.addSubview(unityView)
        }
        return rootView
    }

    func updateUIView(_ uiView: UIView, context: Context) {
        // Do nothing.
    }
}

現在我們就可以在實機執行看看了,實際跑起來的時候,會發現以下的錯誤:
Assertion failed: (launchScreen != nil && @"UILaunchStoryboardName key is missing from info.plist") ...

這是因為 Unity 會去找 Launch Screen 的關係,而新版的 Xcode 專案已經不會自動產生 Launch Screen 了,我們只要手動新增一個 Launch Screen 檔就可以解決這個錯誤。

在上頭的 UnityView 你會發現有一個 asyncAfter(),這是因為要等待 Unity 建立畫面所以刻意的一個 delay。可是這樣並不是一個好的作法,如果可以讓 Unity 在建立好畫面之後主動通知我們就好了。

為了完成這樣的溝通,我們需要在 Unity 建立給 iOS 用的 Plugin。

雙向的溝通

Unity 端

Plugin 是 Unity 跟 Native iOS 溝通的橋樑,Unity 透過 C# Script 跟 Plugin 溝通,iOS 當然是用 Swift 跟 Plugin 溝通。

首先在 Unity Project 底下建立 Assets/Plugins/iOS 的目錄結構,並新增 NativeCallProxy.hNativeCallProxy.mm 兩個檔案,它的用途是要建立兩支 API,分別可以收送字串訊息。檔案內容如下:

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

typedef void (*StringCallback)(const char* value);

@protocol NativeCallsProtocol
@required
#pragma mark - Unity to App Callbacks
- (void)unitySendStringToHost:(NSString *)string;

#pragma mark - App to Unity Callbacks
- (void)unitySetStringCallback:(StringCallback)callback;
@end

__attribute__ ((visibility("default")))
@interface FrameworkLibAPI : NSObject
// Call it any time after `UnityFrameworkLoad` to set object implementing NativeCallsProtocol methods.
+ (void)registerAPIforNativeCalls:(id<NativeCallsProtocol>)aApi;
@end

//
// NativeCallProxy.mm
//

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

@implementation FrameworkLibAPI

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

@end

extern "C"
{
    /// Functions listed here are available to Unity. When called,
    /// they forward the call to the `api` delegate.
    ///
    /// We should also perform data transformation here, from
    /// C data struct to Objective-C **if needed**.

    void sendStringToHost(const char* string)
    {
        return [api unitySendStringToHost:@(string)];
    }

    void setStringCallback(StringCallback callback)
    {
        return [api unitySetStringCallback:callback];
    }
}

然後在 Assets/Scripts 底下建立一個 BridgingAPI.cs 檔案,它的目的是作為 Unity 端統一收送訊息的角色,其他物件可以透過它送訊息出去,它也要負責將收到的訊息分派給負責的物件。內容如下:

using System;
using System.Collections;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using UnityEngine.UI;
using UnityEngine;
using AOT;

/// <summary>
/// C-API exposed by the Host, i.e., Unity -> Host API.
/// </summary>
public class HostNativeAPI {
    public delegate void StringCallback(string value);

    [DllImport("__Internal")]
    public static extern void sendStringToHost(string value);

    [DllImport("__Internal")]
    public static extern void setStringCallback(StringCallback cb);
}

/// <summary>
/// C-API exposed by Unity, i.e., Host -> Unity API.
/// </summary>
public class UnityNativeAPI {
    [MonoPInvokeCallback(typeof(HostNativeAPI.StringCallback))]
    public static void stringCallback(string value) {
        Debug.Log("This static function has been called from iOS!");
        Debug.Log(value);
    }
}

以上就完成 Unity 端訊息收送的設定了,那要怎麼使用呢?舉例來說,如果我們希望 Unity 載入完成之後可以送出一個通知的話,我們可以這樣做。首先新增一個 DummyBehaviourScript.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class DummyBehaviourScript : MonoBehaviour
{
    // Start is called before the first frame update
    void Start()
    {
#if UNITY_IOS
        HostNativeAPI.setStringCallback(UnityNativeAPI.stringCallback);
        HostNativeAPI.sendStringToHost("ready");
#endif
    }

    // Update is called once per frame
    void Update()
    {
    }
}

然後在 Unity 當前的 Scene 建立一個 Empty GameObject,然後把剛剛建立的 DummyBehaviourScript.cs 設為它的 component。如此一來這個 Empty GameObject 載入完成之後就會送出一道 ready 字串。

做完以上設定記得存檔,再一次我們用 File -> Build Settings 把 Unity 匯出成 iOS Project,因為我們一開始就有匯出過,所以它會詢問我們要怎麼做,我們選擇 Append。

Xcode 端

現在回到 Xcode,展開 Unity-iPhone project,在 Libraries/Plugins/iOS 底下點選 NativeCallProxy.h,並且把它的 Target Membership 加上 UnityFramework 並設為 Public。

然後在 SwiftUI Project 新增一個 Bridging-Header.h,並加入以下這行,就可以讓 Swift 看到 NativeCallProxy 了:

#import <UnityFramework/NativeCallProxy.h>

現在回到先前建立的 UnityManager.swift,加入以下的程式碼,讓它可以收到 Unity 送來的訊息,也可以發送訊息給 Unity:

final class UnityManager: UIResponder, UIApplicationDelegate {
...
    private var unityStringCallback: StringCallback?
    var onReceiveMessage: ((String) -> ())?
...
    /// Loads the Unity framework
    func load() {
        ...
        FrameworkLibAPI.registerAPIforNativeCalls(self)
    }
...
    func sendString(_ value: String) {
        unityStringCallback?(value)
    }
}

extension UnityManager: NativeCallsProtocol {
    /**
     Internal methods are called by Unity
     */
    func unitySendString(toHost string: String!) {
        onReceiveMessage?(string)
    }

    func unitySetStringCallback(_ callback: StringCallback!) {
        unityStringCallback = callback
    }
}

extension UnityManager: UnityFrameworkListener {
    /// Triggered by Unity via `UnityFrameworkListener` when the framework unloaded
    func unityDidUnload(_: Notification!) {
        FrameworkLibAPI.registerAPIforNativeCalls(nil)
        ...
    }
}

現在我們可以建立一個 UnityViewModel.swift 來使用 UnityManager

final class UnityViewModel: ObservableObject {
    @Published var isUnityLoaded = false

    init() {
        UnityManager.shared.onReceiveMessage = handleUnityMessage(message:)
    }

    func loadUnity() {
        UnityManager.shared.load()
    }

    private func handleUnityMessage(message: String) {
        switch message {
        case "ready":
            isUnityLoaded = true
        default:
            break
        }
    }
}

走到這一步,我們可以知道 Unity 何時載入畫面了,所以稍早建立的 UnityView.swift 就可以改寫成這樣,把刻意的 delay 拿掉,也不必在這邊要求 UnityManager 載入:

...
    func makeUIView(context: Context) -> UIView {
        guard let unityView = UnityManager.shared.unityView else {
            fatalError("Unity rootView is not ready.")
        }
        return unityView
    }
...

如此一來,我們的 SwiftUI View 就可以搭配使用 UnityViewModel,得知何時可以顯示 UnityView 了:

struct ContentView: View {
    @StateObject private var viewModel = UnityViewModel()
    var body: some View {
        ZStack {
            if viewModel.isUnityLoaded {
                UnityView()
                    .ignoresSafeArea()
            }
        }
        .onAppear {
            viewModel.loadUnity()
        }
    }
}

加入 Git 版本控制

一般來說 Unity-iPhone project 是 Unity 產生出來的,所以它可以不用 commit 到 Git(因為只要有 Unity project 就可以再產生一次),但某些情況我們還是需要將它 commit。舉例來說,如果有在跑 CI/CD 就會需要,不然少了這些檔案就無法成功 compile 了。

而這些產生出來的檔案,有些 .a 檔非常的肥大,因此我會建議把這些肥大的 .a 檔加到 Git-LFS,比較不會佔用空間與頻寬。

你也可以來這裡看看我的 sample code

已知問題

  • Unity 只能實機或模擬器擇一,無法匯出同時在實機跟模擬器都能跑的檔案。
  • SwiftUI Preview 功能會失效。

參考資料

Unity Official Documents

Blog Posts