与嵌入在 SwiftUI iOS APP中的 Unity 游戏建立通信

1_puwbZIt4mMeLwHze20TJ7w

如果你按照我之前写的关于从 SwiftUI APP启动 Unity 游戏的帖子进行操作,那你就能成功地将Unity 游戏集成到SwiftUI 项目中,并通过操控按钮进行下载或卸载。

然而,我们的 Swifty 之旅并不能就此结束,因为如果不能在两个APP之间进行通信,那几乎就等于没用。你可能需要从 iOS 向 Unity 端发送一些游戏或玩家信息以正确初始化游戏,或者,你可能要在完成游戏后将玩家的分数发送回本机APP。

这都能通过 Unity 框架实现,我们只需稍加设置,就可以轻松创建双向通信。让我们往下看吧!

注意: 我们将接着上次的讲解,所以如果你还没有看本系列的第一部分,记得一定要补上,因为第一部分所包含的项目都已经设置完成了。

我们的目标?

为了实现 iOS APP和 Unity 游戏之间的通信,我们需要制作一些比单个按钮APP稍复杂的东西,但操作仍要简单,只有这样,才不会被游戏机制分心。

因此,对于 iOS →Unity 通信 ,我们要采用以下这个简单的思路:我们的 Unity 游戏将包含一个球,这个球可以根据我们从本机端发送的消息而改变颜色。在本地 iOS 端,我们将实现三个用不同颜色名称标记的按钮: 红色绿色蓝色 。每个按钮都将启动同一个Unity 游戏。但是,每个按钮向游戏发送的消息内容将会不同,因此如果用户点击 红色 按钮,球的颜色将为红色,蓝色按钮使球变为蓝色,依此类推。

对于 Unity → iOS 通信, 我们将反其道而行。我们将在 Unity 游戏中添加一个按钮,通过每次按下按钮时从 Unity 向 iOS 发送的消息来跟踪它被按下的次数。

这些例子很简单,虽不代表真正的游戏,但足以让我们清楚建立通信的原理。现在我们知道该怎么做了,那就开始吧!

iOS → Unity 通信

先创建一个新的 Unity 项目。在该项目中,添加一个 Quit Game 按钮并使其只调用 Application.Unload() ,这与我们在上一篇文章中所做的完全相同。如有需要,你可以重新使用之前已经具备退出按钮功能的 Unity 项目。

接下来,添加一个球体游戏对象并将其命名为

我们这个闪亮的球目前并没有什么特别之处,但一旦我们赋予它一些功能,那性质就不一样了。因此,创建一个名为 BallBehavior.cs 的新脚本并将其附加到我们的 Ball 游戏对象中。这个脚本需要有以下代码:

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

public class BallBehavior : MonoBehaviour
{
    void SetBallColor(string color)
    {
        if (color == "red") GetComponent<Renderer>().material.color = Color.red;
        else if (color == "blue") GetComponent<Renderer>().material.color = Color.blue;
        else if (color == "green") GetComponent<Renderer>().material.color = Color.green;
        else GetComponent<Renderer>().material.color = Color.white;
    }
}

不管你信不信,这段代码是 Unity 端建立 iOS → Unity 通信所需的全部代码。本机APP只需使用 UnityFramework SDK来调用 SetBallColor 方法就行。

现在我们需从Unity 导出这个游戏,并将其导出到名为 UnityBallExport 的文件夹中。

注意,每次导出 Unity 游戏时,都必须重复集成步骤:

  • 将导出的项目的 Unity-iPhone.xcodeproj 文件 拖到 主要iOS APP的 XCode 工作区

  • (重新)在 XCode 中导入 UnityFramework.framework 库,

  • 选择 Data 文件夹并选中 UnityFramework 旁边的 Target Membership 框

有关如何执行此操作的更多信息,可参阅上一篇文章将 Unity 与 iOS 连接 的部分。

现在是时候修改我们的 SwiftyUnity 项目了。我们的主要目标是从 iOS APP中调用我们刚刚在 Unity 端执行的 SetBallColor 方法。回想一下,我们从 ContentView.swift 文件中加载 Unity 游戏,我们将在演示完Unity 游戏后立即设置球的颜色。

import SwiftUI

struct ContentView: View {
    var body: some View {
        Button(action: {
            Unity.shared.show()

            // Implement a way to call Unity's SetBallColor() method here

        }) {
            Text("Launch Unity!")
        }
    }
}

需要考虑到的关键一点是,当我们发送消息时,游戏可能尚未初始化,这将导致消息丢失,这就是为什么我们将实施一种缓存机制来存储任意待处理的消息,以便在游戏加载完全后发送。

打开 Unity.swift 文件,这是我们游戏的主要入口点。添加一个名为 UnityMessage 的新结构,它将包含我们向 Unity 发送消息所需的所有数据。

import Foundation
import UnityFramework

class Unity: UIResponder, UIApplicationDelegate {

    private struct UnityMessage {
        let objectName: String?
        let methodName: String?
        let messageBody: String?
    }

    private var cachedMessages = [UnityMessage]()
    
    
    . . .
    
    
    func sendMessage(
        _ objectName: String,
        methodName: String,
        message: String
    ) {
        let msg: UnityMessage = UnityMessage(
            objectName: objectName,
            methodName: methodName,
            messageBody: message
        )

        // Send the message right away if Unity is initialized, else cache it
        if isInitialized {
            ufw?.sendMessageToGO(
                withName: msg.objectName,
                functionName: msg.methodName,
                message: msg.messageBody
            )
        } else {
            cachedMessages.append(msg)
        }
    }
    
}

现在我们有一种方法向Unity发送消息,即使用 sendMessage 调用 Unity 框架 sendMessageToGO 方法(GO 代表游戏对象)。如你所见,该APP将检查 Unity 是否已经初始化,如果已完成初始化,则会立即发送消息,否则,消息将被缓存,以便稍后发送。

我们现在需要处理发送和清理缓存消息的机制。考虑到这一点,你的 Unity.swift 文件应如下所示:

import Foundation
import UnityFramework

class Unity: UIResponder, UIApplicationDelegate {

    // The structure for Unity messages
    private struct UnityMessage {
        let objectName: String?
        let methodName: String?
        let messageBody: String?
    }

    private var cachedMessages = [UnityMessage]() // Array of cached messages

    static let shared = Unity()

    private let dataBundleId: String = "com.unity3d.framework"
    private let frameworkPath: String = "/Frameworks/UnityFramework.framework"

    private var ufw : UnityFramework?
    private var hostMainWindow : UIWindow?

    private var isInitialized: Bool {
        ufw?.appController() != nil
    }

    func show() {
        if isInitialized {
            showWindow()
        } else {
            initWindow()
        }
    }

    func setHostMainWindow(_ hostMainWindow: UIWindow?) {
        self.hostMainWindow = hostMainWindow
    }

    private func initWindow() {
        if isInitialized {
            showWindow()
            return
        }

        guard let ufw = loadUnityFramework() else {
            print("ERROR: Was not able to load Unity")
            return unloadWindow()
        }

        self.ufw = ufw
        ufw.setDataBundleId(dataBundleId)
        ufw.register(self)
        ufw.runEmbedded(
            withArgc: CommandLine.argc,
            argv: CommandLine.unsafeArgv,
            appLaunchOpts: nil
        )

        sendCachedMessages() // Added this line
    }

    private func showWindow() {
        if isInitialized {
            ufw?.showUnityWindow()
            sendCachedMessages() // Added this line
        }
    }

    private func unloadWindow() {
        if isInitialized {
            cachedMessages.removeAll() // Added this line
            ufw?.unloadApplication()
        }
    }

    private func loadUnityFramework() -> UnityFramework? {
        let bundlePath: String = Bundle.main.bundlePath + frameworkPath

        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
    }

    // Main method for sending a message to Unity
    func sendMessage(
        _ objectName: String,
        methodName: String,
        message: String
    ) {
        let msg: UnityMessage = UnityMessage(
            objectName: objectName,
            methodName: methodName,
            messageBody: message
        )

        // Send the message right away if Unity is initialized, else cache it
        if isInitialized {
            ufw?.sendMessageToGO(
                withName: msg.objectName,
                functionName: msg.methodName,
                message: msg.messageBody
            )
        } else {
            cachedMessages.append(msg)
        }
    }

    // Send all previously cached messages, if any
    private func sendCachedMessages() {
        if cachedMessages.count >= 0 && isInitialized {
            for msg in cachedMessages {
                ufw?.sendMessageToGO(
                    withName: msg.objectName,
                    functionName: msg.methodName,
                    message: msg.messageBody
                )
            }

            cachedMessages.removeAll()
        }
    }
}

extension Unity: UnityFrameworkListener {

    func unityDidUnload(_ notification: Notification!) {
        ufw?.unregisterFrameworkListener(self)
        ufw = nil
        hostMainWindow?.makeKeyAndVisible()
    }
}

我在此文件中添加了注释,以便你可以和上一个比较已添加哪些内容。如你所见, sendCachedMessages method 确保在游戏初始化后发送缓存的消息(如果有的话),因此不会丢失任何内容。

现在让我们回到 ContentView.swift 以便我们可以通过创建好的红、蓝和绿色按钮来对这个功能加以使用。我添加了视图修饰符只是为了让按钮看起来更漂亮一些,没有其他意思。

import SwiftUI

private struct ButtonViewModifier: ViewModifier {
    var color: Color

    func body(content: Content) -> some View {
        content.frame(width: 200, height: 50).background(color)
    }
}

private struct TextViewModifier: ViewModifier {
    func body(content: Content) -> some View {
        content.font(.title).foregroundColor(Color.white)
    }
}

struct ContentView: View {
    var body: some View {
        VStack {
            Spacer()

            Button(action: {
                Unity.shared.show()
                Unity.shared.sendMessage(
                    "Ball",
                    methodName: "SetBallColor",
                    message: "red"
                )
            }) {
                Text("Red").modifier(TextViewModifier())
            }
            .modifier(ButtonViewModifier(color: Color.red))

            Spacer()

            Button(action: {
                Unity.shared.show()
                Unity.shared.sendMessage(
                    "Ball",
                    methodName: "SetBallColor",
                    message: "blue"
                )
            }) {
                Text("Blue").modifier(TextViewModifier())
            }
            .modifier(ButtonViewModifier(color: Color.blue))
            
            Spacer()

            Button(action: {
                Unity.shared.show()
                Unity.shared.sendMessage(
                    "Ball",
                    methodName: "SetBallColor",
                    message: "green"
                )
            }) {
                Text("Green").modifier(TextViewModifier())
            }
            .modifier(ButtonViewModifier(color: Color.green))

            Spacer()
        }
    }
}

这些按钮中的每一个都调用了 Unity.shared.sendMessage 方法。第一个参数是我们在 Unity 中的游戏对象的名称,在本例中是 Ball 。第二个参数是附加到游戏对象的脚本中方法的名称,即 SetBallColor 。 第三个参数是我们要发送的消息,也就是颜色名称。记住,消息 必须是字符串类型 ,因此如果你想发送不同的类型,你需要将其封装在一个字符串中并在 Unity 端展开。

让我们通过点击按钮来测试一下:

没毛病!球收到信息并改变颜色,它们分别根据你按下的按钮,变为红色、蓝色或绿色。我们的 iOS → Unity 通信到此结束。

Unity → iOS 通信

以相反的方式发送消息有点棘手,但这影响不大!让我们重新审视我们的目标:我们需要通过在每次按下按钮时向 iOS 发送一条消息来跟踪 Unity 游戏中按钮被按下的次数。

那我们就先创建一个新的 Unity 项目并向场景中添加一个按钮。此外,在 Assets 文件夹内创建一个新文件夹并将其命名为 Plugins .

我们暂时离开 Unity,因为我们需要在 iOS 端做一些配置。在 SwiftyUnity 项目中,添加一个名为 NativeCallProxy 的新 Objective-C 文件。



当 XCode创建一个 Objective-C 桥接标头时,点击 Create Bridging Header 按钮并让 XCode 配置所有内容,以便也可以从项目中访问 Objective-C 代码。

重要提示: XCode 自动创建了一个名为 NativeCallProxy.m 的文件,但我们希望再扩展为 .mm 。因此,将文件重命名为 NativeCallProxy.mm

到目前为止,我们在 iOS 项目中有 2 个重要的新文件: NativeCallProxy.mmSwiftyUnity-Bridging-Header.h

再添加一个文件并将其命名为 NativeCallProxy.h 。编辑这些文件,使其具有以下内容:

//
//  Use this file to import your target's public headers that you would like to expose to Swift.
//

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

@protocol NativeCallsProtocol
@required
- (void) sendMessageToMobileApp:(NSString*)message;
// other methods
@end

__attribute__ ((visibility("default")))
@interface FrameworkLibAPI : NSObject
+(void) registerAPIforNativeCalls:(id<NativeCallsProtocol>) aApi;

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

@implementation FrameworkLibAPI

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

@end

extern "C"
{
    void sendMessageToMobileApp(const char* message)
    {
        return [api sendMessageToMobileApp:[NSString stringWithUTF8String:message]];
    }
}

我们将用 sendMessageToMobileApp 方法建立Unity → iOS 通信。我们将通过按下我们在 Unity 场景中创建的按钮进行调用,并在本机端为该事件放置一个侦 听器。

现在是有趣的部分:我们需要将 NativeCallProxy.hNativeCallProxy.mm 移动到 Unity 项目中,因为我们将从 UnityFramework 调用它们!不过,我们也将在 XCode 中保留 SwiftyUnity-Bridging-Header.h

因此,让我们将 NativeCallProxy.hNativeCallProxy.mm 移到我们预先在 Unity 项目中创建的 Plugins 文件夹中。我们在 iOS 项目中不需要这些,因为无论如何它们都会被打包到导出的 Unity 项目中,所以可以随意从 SwiftyUnity 项目中删除它们。

是时候开始连接了。让我们创建一个名为 ButtonBehavior.cs 的 C# 脚本并用以下代码对其进行填充:

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

public class NativeAPI {
    [DllImport("__Internal")]
    public static extern void sendMessageToMobileApp(string message);
}

public class ButtonBehavior : MonoBehaviour
{
    private int pressCount;

    void Start () {
        pressCount = 0;
    }

    public void ButtonPressed()
    {
        pressCount++;
        NativeAPI.sendMessageToMobileApp("The button has been tapped " + pressCount.ToString() + " times!");
    }
}

“魔法”就发生在 ButtonPressed() 方法内部。它用将在本机 iOS 端接收到的消息调用 NativeAPI.sendMessageToMobileApp() 方法。

现在 Unity 端要做的就是将此脚本与按钮本身连接起来,所以我们要将 ButtonBehavior.cs 脚本添加到按钮并连接 ButtonPressed 侦听器。

就是这样!下面让我们建立这个游戏。我们将其命名为 UnityButtonExport 并将其集成到我们的 XCode 工作区中,就像我们之前所做的一样。注意,每次将 Unity 游戏导出为 iOS 项目时,你必须:

  • 将导出项目的 Unity-iPhone.xcodeproj 文件 拖到 主 iOS APP的 XCode 工作区,
  • (重新)在 XCode 中导入 UnityFramework.framework 库,
  • 选择 Data 文件夹并选中 UnityFramework 旁边的 Target Membership 框

重要提示: 这次除了常用的配置外,我们还需要一点其他配置。还记得Unity 游戏 Plugins 文件夹中的 NativeCallProxy 文件吗?现在你将会在导出的 Unity-iPhone 项目中看到它们。

你需要选择 Unity-iPhone项目 Libraries/Plugins 文件夹里面的 NativeCallProxy.h 改变UnityFramework从 ProjectPublic 的目标成员。不要忘记这一步!

最后一步是将APP中的侦听器连接到 sendMessageToMobileApp 方法。让我们创建一个示例视图模型类,它将为我们的 Unity 消息注册一个侦听器。创建一个新的 Swift 文件,将其命名为 ViewModel.swift ,并使用以下代码对其进行填充:

import Foundation

class ViewModel: NSObject, ObservableObject, NativeCallsProtocol {

    override init() {
        super.init()

        NSClassFromString("FrameworkLibAPI")?.registerAPIforNativeCalls(self)
    }

    func sendMessage(toMobileApp message: String) {
        print(message)
    }
}

我们还将简化我们的 ContentView.swift ,因此它只启动游戏:

import SwiftUI
import UnityFramework

struct ContentView: View {

    let viewModel = ViewModel()

    var body: some View {
        VStack {
            Button(action: {
                Unity.shared.show()
            }) {
                Text("Launch Unity")
            }
        }
    }
}

做了这么多工作,终于到了测试通信的时候了。运行 iOS 项目并点击“按我!” 按钮。来自 Unity 的消息应该打印到你的 XCode 日志中。

注意,你必须使用 物理 iPhone 设备 (我在下面的示例 gif 中也使用了一个,我只是将其流式传输到屏幕上)。


演示网址

呼!这项工程真是不小。但你却可以将此设置应用于任何你所需要类型的通信! 这些例子虽然非常简单,但是有了这个基础,你可以进行更复杂的逻辑,例如在每一帧上发送或接收消息,甚至为游戏使用自定义蓝牙控制器,这些将在 iOS 端处理并传递给Unity。

希望你会喜欢我们的内容。愿你在 Unity 冒险中玩得开心!:nerd_face:

原文作者 Dino Trnka
原文链接 https://medium.com/mop-developers/communicate-with-a-unity-game-embedded-in-a-swiftui-ios-app-1cefb38ff439

推荐阅读
相关专栏
开发者实践
182 文章
本专栏仅用于分享音视频相关的技术文章,与其他开发者和声网 研发团队交流、分享行业前沿技术、资讯。发帖前,请参考「社区发帖指南」,方便您更好的展示所发表的文章和内容。