简介:这份资源面向需要在 Ionic 混合应用中嵌入 Unity 3D 内容的移动开发者,重点解决 Android 与 iOS 双端集成及两者间消息通信的问题,并已在带 Vuforia 插件的中等规模 Unity 商业项目中验证,适合具备 Cordova、Objective-C++ 与 C# 基础的进阶开发者参考。压缩包共 21 个文件、约 424KB,包含 png 界面截图、mm 与 m 原生实现、java 与 h 安卓桥接、cs 通信脚本、xml 插件配置、xcconfig 与 json 工程配置及 jar 依赖等,覆盖双端桥接所需的关键模块。目前已有 228 人学习下载。读者可从中获得 Unity 与 Ionic 互调消息的完整实现思路、原生层与 C# 层的对接方式,以及 Vuforia 场景下的集成参考,便于快速搭建 AR 类混合应用骨架并排查跨平台通信问题。
1. 把 Unity 塞进 Ionic 壳里:这套桥接方案到底解决什么问题
如果你做过 AR 项目,大概率遇到过这种拧巴的需求:业务界面用 Ionic 写,跨平台、迭代快、表单和列表都顺手;但一到 3D 渲染、AR 识别、模型交互,又必须交给 Unity。两个世界各自跑得好好的,一旦要拼进同一个 App,问题就来了——WebView 里的 JavaScript 和原生层的 Unity 怎么对话?Android 和 iOS 的桥接方式还不一样,Vuforia 插件又要额外处理生命周期。这套 UnityIonicIntegration 方案,核心就是解决「Ionic 壳 + Unity 3D 内容」在 Android 和 iOS 上的双向消息传递问题,并且已经在 Vuforia 插件场景下验证过。适合谁?适合手里有 Ionic 项目、又必须嵌入 Unity AR/3D 模块的移动端开发者,尤其是做工业巡检、教育演示、展厅导览这类需要「业务界面 + 3D 视图」混合的团队。
2. 桥接原理与工程结构:为什么不能直接 iframe 嵌 Unity
2.1 两种运行时的边界在哪里
Ionic 跑在 WebView 里,本质是一个浏览器环境;Unity 导出 Android/iOS 后是一个原生 Activity 或 ViewController,两者不在同一个渲染管线里。很多人第一反应是用 iframe 或者 WebView 加载 Unity WebGL 构建,这在桌面浏览器上勉强能跑,但在移动端 AR 场景下基本不可行——WebGL 构建体积大、性能差,Vuforia 的摄像头和传感器能力也拿不到。所以正确的做法是:Unity 作为原生模块嵌入,Ionic 通过 Cordova 插件暴露的 JS 接口与原生层通信,原生层再转发给 Unity。
这个结构里有三层:JS 层(Ionic 页面)、Cordova 插件层(Java/Objective-C++)、Unity 层(C#)。消息从 JS 发出,经过插件层序列化,最终由 Unity 的UnitySendMessage或自定义回调接收;反向则从 Unity 调用原生方法,再通过插件回调到 JS。
2.2 工程目录怎么摆
一个典型的混合工程结构如下,Android 和 iOS 的 Unity 导出内容分别放在对应平台目录下:
project-root/ ├── ionic-app/ # Ionic 前端工程 │ ├── src/ │ └── cordova-plugin-unity/ # 自定义 Cordova 插件 │ ├── src/ │ │ ├── android/ │ │ │ └── UnityBridgePlugin.java │ │ └── ios/ │ │ └── UnityBridgePlugin.m │ ├── www/ │ │ └── unity-bridge.js │ └── plugin.xml ├── unity-project/ # Unity 工程 │ ├── Assets/ │ │ └── Scripts/ │ │ └── NativeBridge.cs │ └── Plugins/ │ ├── Android/ │ └── iOS/ └── platforms/ ├── android/ │ └── unityLibrary/ # Unity 导出的 Android 模块 └── ios/ └── UnityFramework/ # Unity 导出的 iOS 框架这里的关键点是:Unity 导出时选择「Export as Android Library」或「Export as iOS Framework」,而不是直接导出 APK 或 Xcode 工程。这样 Ionic 的 Cordova 构建才能把 Unity 模块作为依赖引入。
2.3 Cordova 插件如何暴露接口
插件层是整个方案的核心。以 Android 为例,UnityBridgePlugin.java需要继承CordovaPlugin,并实现execute方法处理 JS 调用:
public class UnityBridgePlugin extends CordovaPlugin { private static final String TAG = "UnityBridge"; private static UnityPlayer unityPlayer; private static CallbackContext pendingCallback; @Override public boolean execute(String action, JSONArray args, CallbackContext callbackContext) { if ("sendToUnity".equals(action)) { String message = args.optString(0); // 通过 UnityPlayer 的 UnitySendMessage 发送到指定 GameObject UnityPlayer.UnitySendMessage("NativeBridge", "OnMessageFromJS", message); callbackContext.success("sent"); return true; } if ("initUnity".equals(action)) { // 初始化 Unity 视图并嵌入当前 Activity cordova.getActivity().runOnUiThread(() -> { unityPlayer = new UnityPlayer(cordova.getActivity()); // 将 UnityPlayer 的 View 添加到布局中 FrameLayout container = cordova.getActivity().findViewById(R.id.unity_container); container.addView(unityPlayer.getView()); }); callbackContext.success("init"); return true; } return false; } // Unity 调用此静态方法回传消息到 JS public static void onUnityMessage(String message) { if (pendingCallback != null) { pendingCallback.success(message); pendingCallback = null; } } }逻辑说明:execute是 Cordova 插件的入口,JS 调用cordova.exec时会路由到这里。UnitySendMessage是 Unity 提供的原生接口,第一个参数是 Unity 场景中的 GameObject 名称,第二个是挂载脚本的方法名,第三个是字符串参数。注意UnitySendMessage只能传字符串,复杂数据结构需要自己序列化成 JSON。
参数说明:action是 JS 侧指定的操作名,args是参数数组,callbackContext用于异步回调。initUnity里的R.id.unity_container需要在 Android 布局文件中预先定义,通常是一个FrameLayout。
2.4 iOS 侧的 Objective-C++ 桥接
iOS 的桥接稍微复杂一些,因为 Unity 导出的是 framework,需要用 Objective-C++ 来调用 Unity 的 C++ 接口:
// UnityBridgePlugin.m #import <Cordova/CDV.h> #import "UnityFramework/UnityFramework.h" @interface UnityBridgePlugin : CDVPlugin @property (nonatomic, strong) UnityFramework *ufw; @end @implementation UnityBridgePlugin - (void)initUnity:(CDVInvokedUrlCommand *)command { // 加载 UnityFramework self.ufw = [UnityFramework getInstance]; [self.ufw setDataBundleId:"com.unity3d.framework"]; [self.ufw runUI]; // 将 Unity 视图添加到当前 ViewController UIView *unityView = [self.ufw appController].window.rootViewController.view; [self.viewController.view addSubview:unityView]; CDVPluginResult *result = [CDVPluginResult resultWithStatus:CDVCommandStatus_OK]; [self.commandDelegate sendPluginResult:result callbackId:command.callbackId]; } - (void)sendToUnity:(CDVInvokedUrlCommand *)command { NSString *message = [command.arguments objectAtIndex:0]; // 调用 Unity 的 SendMessage 接口 [self.ufw sendMessageToGOWithName:"NativeBridge" functionName:"OnMessageFromJS" message:[message UTF8String]]; CDVPluginResult *result = [CDVPluginResult resultWithStatus:CDVCommandStatus_OK]; [self.commandDelegate sendPluginResult:result callbackId:command.callbackId]; } @end逻辑说明:UnityFramework是 Unity 导出 iOS 框架时的入口类,getInstance获取单例,runUI启动 Unity 渲染循环。sendMessageToGOWithName对应 Android 的UnitySendMessage,参数含义一致。注意 iOS 上 Unity 视图的层级管理需要小心,如果直接 addSubview 到根视图,可能会遮挡 Ionic 的 WebView,通常需要控制显示时机。
参数说明:setDataBundleId指定 Unity 资源包的 bundle identifier,必须与 Unity 导出时设置的一致。command.arguments是 JS 传入的参数数组,这里取第一个作为消息内容。
3. 从零跑通一次消息往返:JS 到 Unity 再回来
3.1 Ionic 侧调用封装
在 Ionic 项目里,先封装一个简单的服务来调用 Cordova 插件:
// unity-bridge.service.ts import { Injectable } from '@angular/core'; declare var cordova: any; @Injectable({ providedIn: 'root' }) export class UnityBridgeService { private isReady = false; initUnity(): Promise<string> { return new Promise((resolve, reject) => { cordova.exec( (res) => { this.isReady = true; resolve(res); }, (err) => reject(err), 'UnityBridge', // 插件名,对应 plugin.xml 中的 feature name 'initUnity', // action [] // 参数 ); }); } sendToUnity(message: string): Promise<string> { return new Promise((resolve, reject) => { if (!this.isReady) { reject('Unity not initialized'); return; } cordova.exec(resolve, reject, 'UnityBridge', 'sendToUnity', [message]); }); } }逻辑说明:cordova.exec的五个参数分别是成功回调、失败回调、插件名、action 名、参数数组。插件名必须与plugin.xml里<feature name="UnityBridge">保持一致,否则会报Class not found。
参数说明:message是字符串,如果需要传对象,在调用前JSON.stringify,Unity 侧再解析。
3.2 Unity 侧接收与回传
Unity 工程里需要一个挂载在名为NativeBridge的 GameObject 上的脚本:
// NativeBridge.cs using UnityEngine; using System.Runtime.InteropServices; public class NativeBridge : MonoBehaviour { // iOS 原生方法声明 [DllImport("__Internal")] private static extern void onUnityMessage(string message); // Android 原生方法声明 private AndroidJavaClass unityBridgePlugin; void Start() { if (Application.platform == RuntimePlatform.Android) { unityBridgePlugin = new AndroidJavaClass("com.example.unitybridge.UnityBridgePlugin"); } } // 由原生层调用,接收 JS 消息 public void OnMessageFromJS(string message) { Debug.Log("Received from JS: " + message); // 处理消息后回传 SendToJS("Unity received: " + message); } // 发送消息回 JS public void SendToJS(string message) { if (Application.platform == RuntimePlatform.Android) { unityBridgePlugin.CallStatic("onUnityMessage", message); } else if (Application.platform == RuntimePlatform.IPhonePlayer) { onUnityMessage(message); } } }逻辑说明:OnMessageFromJS是原生层通过UnitySendMessage调用的方法,方法名必须完全匹配。SendToJS里 Android 通过AndroidJavaClass反射调用 Java 静态方法,iOS 通过DllImport调用 Objective-C 的 C 函数。
参数说明:com.example.unitybridge.UnityBridgePlugin是 Java 类的完整包名,需要与插件工程一致。iOS 的onUnityMessage需要在.mm文件里用extern "C"声明。
3.3 完整往返流程验证
在 Ionic 页面里调用:
async testRoundTrip() { await this.unityBridge.initUnity(); const response = await this.unityBridge.sendToUnity('hello from ionic'); console.log('Unity replied:', response); }如果一切正常,控制台会先打印 Unity 的Debug.Log,然后收到Unity received: hello from ionic。这一步跑通,说明桥接链路是通的。
4. 避坑与排查:那些让我熬夜的桥接问题
4.1 Unity 视图黑屏或白屏
现象:Ionic 页面正常,但嵌入的 Unity 区域一片黑或白,没有渲染内容。
原因:最常见的是 Unity 导出时没有勾选正确的 Graphics API,或者 Android 上UnityPlayer的 View 没有正确添加到布局层级里。iOS 上则可能是UnityFramework的runUI调用时机不对,在 ViewController 还没加载完就调用了。
解决:Android 检查build.gradle里的minSdkVersion是否与 Unity 导出设置一致,通常 Unity 2019 以上要求 22+。iOS 确保在viewDidLoad之后再调用runUI,并且 Unity 视图的 frame 要手动设置,不能依赖自动布局。
4.2 消息发送后 Unity 收不到
现象:JS 调用sendToUnity返回成功,但 Unity 的OnMessageFromJS没有触发。
原因:UnitySendMessage的第一个参数是 GameObject 名称,如果场景里没有这个对象,或者对象在消息发送时还没加载,消息就会丢失。另一个常见原因是 GameObject 名称拼写错误,Unity 对大小写敏感。
解决:在 Unity 场景里确保NativeBridge这个 GameObject 在启动时就存在,可以放在DontDestroyOnLoad的根对象上。发送消息前先确认 Unity 已经完成初始化,可以在Start方法里回传一个 ready 信号给 JS。
4.3 iOS 编译报错 Undefined symbols
现象:Xcode 编译时提示Undefined symbols for architecture arm64: "_onUnityMessage"。
原因:DllImport("__Internal")声明的方法在 Objective-C 侧没有用extern "C"导出,C++ 的名称修饰导致链接器找不到符号。
解决:在.mm文件里这样写:
extern "C" void onUnityMessage(const char* message) { // 转发到 Cordova 插件 [[UnityBridgePlugin sharedInstance] sendMessageToJS:[NSString stringWithUTF8String:message]]; }注意文件扩展名必须是.mm而不是.m,否则 C++ 代码无法编译。
4.4 Vuforia 摄像头在 Ionic 页面切换后失效
现象:第一次进入 Unity AR 视图正常,退出再进入后摄像头黑屏或 Vuforia 报错。
原因:Vuforia 的摄像头生命周期与 Unity 的UnityPlayer绑定,如果退出时没有正确暂停 Unity,再次进入时摄像头资源被占用。
解决:在 Ionic 页面ionViewWillLeave里调用插件的pauseUnity方法,Android 上执行unityPlayer.pause(),iOS 上调用[self.ufw pause:YES]。重新进入时再resume。这个坑在 Vuforia 场景下尤其明显,因为摄像头是独占资源。
5. 进阶技巧:让桥接更稳的几个习惯
5.1 消息协议设计:别只传裸字符串
一开始我图省事,JS 和 Unity 之间直接传字符串,结果消息类型一多就乱套。后来改成固定 JSON 格式:
{ "type": "command", "action": "rotateModel", "payload": { "angle": 90 }, "callbackId": "cb_001" }Unity 侧解析type和action做路由,callbackId用于异步回传时匹配请求。这样即使后续加消息类型,也不用改桥接层代码。
5.2 生命周期同步:页面切换时暂停 Unity
Ionic 的页面栈和 Unity 的渲染循环是两套生命周期。如果不做同步,用户切到其他页面时 Unity 还在后台跑,耗电且容易出状态问题。我一般会在 Cordova 插件里暴露pauseUnity和resumeUnity两个方法,在 Ionic 的ionViewWillEnter和ionViewWillLeave里调用。
Android 侧:
public boolean execute(String action, JSONArray args, CallbackContext callbackContext) { if ("pauseUnity".equals(action)) { if (unityPlayer != null) unityPlayer.pause(); callbackContext.success(); return true; } if ("resumeUnity".equals(action)) { if (unityPlayer != null) unityPlayer.resume(); callbackContext.success(); return true; } // ... }iOS 侧对应[self.ufw pause:YES]和[self.ufw pause:NO]。
5.3 验证桥接是否正常的检查清单
每次集成完新版本,我会按这个顺序过一遍:
| 检查项 | 预期结果 | 失败时看哪里 |
|---|---|---|
| 插件注册 | cordova plugin list能看到 UnityBridge | plugin.xml 的 feature name |
| Unity 初始化 | 调用 initUnity 后视图出现 | Android logcat / Xcode console |
| JS 到 Unity | Unity 日志打印收到消息 | UnitySendMessage 的 GameObject 名 |
| Unity 到 JS | Ionic 控制台收到回调 | 静态方法是否被正确调用 |
| 页面切换 | 退出再进入 AR 正常 | pause/resume 是否成对调用 |
这张表我贴在显示器边上,每次发版前跑一遍,能省掉很多「玄学」问题。
5.4 一个容易忽略的细节:线程问题
UnitySendMessage必须在主线程调用,如果你在 Cordova 的异步回调里直接调,可能会崩。Android 上我一般用cordova.getActivity().runOnUiThread包一层,iOS 上确保在主队列 dispatch。这个坑不常出现,但一旦出现就是随机崩溃,很难查。
从那以后我每次集成桥接层,都强制走一遍「初始化 → 发消息 → 收回调 → 切页面 → 再回来」的完整流程,确认没有资源泄漏和线程问题才继续写业务。希望帮到你。
本文还有配套的精品资源,点击获取