Use SwiftUI in UnityPolySpatial(VisionOS): 在Unity PolySpatial中使用SwiftUI

5 minute read

Published:

Introduction

在VisionOS的开发场景中,AppleVisionPro(AVP)自带的SwiftUI有着较为自然的毛玻璃和光影效果,所以我们也可以采用SwiftUI作为Unity-VisionOS的交互UI。

Talk is cheap, show the code!

先看效果

 Photo

那么首先我们应该先理解unity polyspatial的SwiftUI例子。

#if UNITY_VISIONOS && !UNITY_EDITOR
        [DllImport("__Internal")]
        static extern void SetNativeCallback(CallbackDelegate callback);

        [DllImport("__Internal")]
        static extern void OpenSwiftUIWindow(string name);

        [DllImport("__Internal")]
        static extern void CloseSwiftUIWindow(string name);

        [DllImport("__Internal")]
        static extern void SetCubeCount(int count);

        [DllImport("__Internal")]
        static extern void SetSphereCount(int count);

        [DllImport("__Internal")]
        static extern void SetLastObjectInstanceID(int instanceId);

他们使用dll的方式引入swift代码,并且注册了一个回调,用于接收swift发来的消息。 例如,

  void OnEnable()
        {
            m_Button.WasPressed += WasPressed;
            //注册回调
            SetNativeCallback(CallbackFromNative);
        }

  //接收到消息后的回调函数,可以根据command类型区分后续执行的函数。
 delegate void CallbackDelegate(string command, int value);

        // This attribute is required for methods that are going to be called from native code
        // via a function pointer.
        [MonoPInvokeCallback(typeof(CallbackDelegate))]
        static void CallbackFromNative(string command, int value)
        {
            // MonoPInvokeCallback methods will leak exceptions and cause crashes; always use a try/catch in these methods
            try
            {
                Debug.Log($"Callback from native: {command} {value}");

                // This could be stored in a static field or a singleton.
                // If you need to deal with multiple windows and need to distinguish between them,
                // you could add an ID to this callback and use that to distinguish windows.
                var self = FindFirstObjectByType<SwiftUIDriver>();

                if (command == "closed") {
                    self.m_SwiftUIWindowOpen = false;
                    return;
                }

                if (command == "spawn red")
                {
                    self.Spawn(Color.red);
                }
                else if (command == "spawn green")
                {
                    self.Spawn(Color.green);
                }
                else if (command == "spawn blue")
                {
                    self.Spawn(Color.blue);
                }
                else if (command == "recolor")
                {
                    var thing = PolySpatialObjectUtils.GetGameObjectForPolySpatialIdentifier((ulong)value);
                    thing.GetComponent<MeshRenderer>().material.color = Color.magenta;
                }
            }
            catch (Exception exception)
            {
                Debug.LogException(exception);
            }
        }

其对应的swift代码也可以看到,通过定义一些回调函数可以使用在swift给unity发消息

// Declared in C# as: delegate void CallbackDelegate(string command);
typealias CallbackDelegateType = @convention(c) (UnsafePointer<CChar>, Int32) -> Void
var callbackDelegate: CallbackDelegateType? = nil

// Declared in C# as: static extern void SetNativeCallback(CallbackDelegate callback);
@_cdecl("SetNativeCallback")
func setNativeCallback(_ delegate: CallbackDelegateType)
{
    print("############ SET NATIVE CALLBACK")
    callbackDelegate = delegate
}

// This is a function for your own use from the enclosing Unity-VisionOS app, to call the delegate
// from your own windows/views (HelloWorldContentView uses this)
public func CallCSharpCallback(_ str: String, _ value: Int32 = 0)
{
    if (callbackDelegate == nil) {
        return
    }
    //print("####################send " + str)
    str.withCString {
        callbackDelegate!($0, value)
    }
}

而通过unity给swift发消息也能看到有类似的函数,比如打开窗口的消息

  // Declared in C# as: static extern void OpenSwiftUIWindow(string name);
@_cdecl("OpenSwiftUIWindow")
func openSwiftUIWindow(_ cname: UnsafePointer<CChar>)
{
    let openWindow = EnvironmentValues().openWindow

    let name = String(cString: cname)
    print("############ OPEN WINDOW \(name)")
    openWindow(id: name)
}

在此刻,我们就发现了,其实swiftui与unity的通信非常简单,在swift这段定义一些函数来处理unity传来的消息,在unity这端引入dll并且注册回调来处理swift传来的消息即可。 接下来我们用一个传递json的实战来熟悉。

//定义好我们json所用的函数,模仿官方的即可
typealias StringCallbackDelegateType = @convention(c) (UnsafePointer<CChar>, UnsafePointer<CChar>) -> Void
var stringCallbackDelegate: StringCallbackDelegateType? = nil

@_cdecl("SetNativeStringCallback")
func setNativeStringCallback(_ delegate: StringCallbackDelegateType) {
    print("[SwiftPlugin] 字符串回调通道已建立")
    stringCallbackDelegate = delegate
}

public func CallCSharpCallbackString(_ command: String, _ jsonPayload: String) {
    guard let delegate = stringCallbackDelegate else {
        print("[SwiftPlugin] Error: Unity 回调未注册")
        return
    }
    command.withCString { cCmd in
        jsonPayload.withCString { cMsg in
            delegate(cCmd, cMsg)
        }
    }
}

//这里就是如果unity发给swift消息的话,swift这边需要使用回调模式来处理
// MARK: - jsonString Bridge (回调模式)

// 1. 定义一个闭包类型:(Command, Payload) -> Void
public typealias UnityMessageCallback = (String, String) -> Void

// 2. 定义一个全局变量来保存这个闭包
var onUnityMessageReceived: UnityMessageCallback? = nil

// 3. 提供给 App 端 (AttachmentManager) 调用的注册函数
public func RegisterUnityMessageCallback(_ callback: @escaping UnityMessageCallback) {
    print("[SwiftPlugin] JSON消息回调已注册")
    onUnityMessageReceived = callback
}

// 4. 供 Unity C# 调用的 C 接口
@_cdecl("SendUnityMessageToSwift")
func sendUnityMessageToSwift(_ cmdPtr: UnsafePointer<CChar>, _ msgPtr: UnsafePointer<CChar>) {
    let command = String(cString: cmdPtr)
    let payload = String(cString: msgPtr)
    
    // 转发给注册的闭包 (通常是 AttachmentManager 提供的)
    DispatchQueue.main.async {
        if let callback = onUnityMessageReceived {
            callback(command, payload)
        } else {
            print("[SwiftPlugin] Warning: 收到 Unity 消息 '\(command)',但在 Swift 端没有接收者 (回调未注册)。")
        }
    }
}

经过以上Swift这一系列操作之后呢,我们只需要考虑unity这边的函数注册,先引入dll,再加入发送和接收消息的处理函数即可,这里可以做一个通用的桥脚本。

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

public class VisionOSBridge : MonoBehaviour
{
    public static VisionOSBridge Instance;

    //定义委托
    public delegate void StringCallbackDelegate(string command, string payload);
    
    //所有的业务监听者:Dictionary<指令, 回调函数>
    private Dictionary<string, Action<string>> messageListeners = new Dictionary<string, Action<string>>();

   
    [DllImport("__Internal")]
    private static extern void SetNativeStringCallback(StringCallbackDelegate callback);

    [DllImport("__Internal")] // 通用发送接口
    private static extern void SendUnityMessageToSwift(string command, string payload);

    void Awake() {
        if (Instance == null) Instance = this;
        else Destroy(gameObject);
        DontDestroyOnLoad(gameObject);
    }

    void OnEnable() {
#if UNITY_VISIONOS && !UNITY_EDITOR
        SetNativeStringCallback(OnMessageReceived);
#endif
    }

    /// <summary>
    /// 发送消息(unity->swift)
    /// </summary>
    /// <param name="command"></param>
    /// <param name="jsonPayload"></param>
    public void SendToSwift(string command, string jsonPayload)
    {
#if UNITY_VISIONOS && !UNITY_EDITOR
        SendUnityMessageToSwift(command, jsonPayload);
#else
        Debug.Log($"[Mock Send] Cmd: {command}, Payload: {jsonPayload}");
#endif
    }

    /// <summary>
    /// 接收消息(swift->unity)
    /// </summary>
    /// <param name="command"></param>
    /// <param name="payload"></param>
    [MonoPInvokeCallback(typeof(StringCallbackDelegate))]
    private static void OnMessageReceived(string command, string payload)
    {
        // 切换到主线程分发
        UnityMainThreadDispatcher.Instance().Enqueue(() => {
            if (Instance != null) Instance.DispatchMessage(command, payload);
        });
    }

    private void DispatchMessage(string command, string payload)
    {
        if (messageListeners.ContainsKey(command))
        {
            messageListeners[command]?.Invoke(payload);
        }
        else
        {
            Debug.LogWarning($"[Bridge] 收到未注册指令: {command}");
        }
    }

   /// <summary>
   /// 注册(辅助检查/诊断/治疗)
   /// </summary>
   /// <param name="command"></param>
   /// <param name="callback"></param>
    public void RegisterListener(string command, Action<string> callback)
    {
        if (!messageListeners.ContainsKey(command))
            messageListeners[command] = callback;
        else
            messageListeners[command] += callback;
    }

    public void UnregisterListener(string command, Action<string> callback)
    {
        if (messageListeners.ContainsKey(command))
            messageListeners[command] -= callback;
    }
}

/// <summary>
/// json包装
/// </summary>
public static class JsonHelper
{
    public static T[] GetArray<T>(string json)
    {
        // 包装一下,变成 { "array": [...] } 以便 JsonUtility 解析
        string newJson = "{ \"array\": " + json + "}";
        Wrapper<T> wrapper = JsonUtility.FromJson<Wrapper<T>>(newJson);
        return wrapper.array;
    }

    [Serializable]
    private class Wrapper<T>
    {
        public T[] array;
    }
}

最后呢,就是可以在swift这边使用注册好的函数,处理来自unity的消息。

init() {
        //注册回调
        RegisterUnityMessageCallback { [weak self] command, payload in
                    self?.handleUnityMessage(command: command, payload: payload)
                }
    }
//收到消息后的处理,这里具体的json解析就不列出来了
func handleUnityMessage(command: String, payload: String) {
            print("[Router] 收到指令: \(command)")
            
            switch command {
            case "Update":
                //处理json
                parseJsonData(json: payload)
                
            case "LateUpdate":
                print("late")
                
            default:
                print("[Router] 未知指令,忽略")
            }
        }

写在结尾

这里就已经说明了unity与swift直接的双向通信,如果需要使用unity的坐标位姿来控制swiftui的摆放位置的话,只需要定义一个通信函数,参数里包含位置坐标和可见性即可。这里仅展示swift方面的代码了,因为unity那边就是引入一下同名dll函数即可。

// MARK: - Attachment Bridge (回调模式)

//定义一个回调类型 (ID, x, y, z, isVisible)
public typealias AttachmentUpdateCallback = (String, Float, Float, Float, Bool) -> Void

//定义一个变量来保存这个回调
var onAttachmentUpdate: AttachmentUpdateCallback? = nil

//供 .App文件 调用的注册函数,这是给 AttachmentManager 用的,需要改polyspatial源代码
//AttachmentManager 会调用这个函数,把数据发给我
public func RegisterAttachmentCallback(_ callback: @escaping AttachmentUpdateCallback) {
    print("[SwiftPlugin] AttachmentManager 已注册接收数据")
    onAttachmentUpdate = callback
}

//供 Unity 调用的 C 接口,这是给 C# VisionOSAttachment.cs 用的
@_cdecl("UpdateAttachment")
public func UpdateAttachment(_ idPtr: UnsafePointer<CChar>, _ x: Float, _ y: Float, _ z: Float, _ isVisible: Bool) {
    //这里不直接调用 AttachmentManager,而是调用那个回调
    //这样就不需要依赖 MuseTrackerView 了
    let id = String(cString: idPtr)
    
    DispatchQueue.main.async {
        // 如果有人注册了回调,就转发数据;没人注册就丢弃
        onAttachmentUpdate?(id, x, y, z, isVisible)
    }
}