简介:这份资源聚焦 Unity 3D 内容与 Ionic 混合应用的双向集成,面向需要在 Cordova/Ionic 项目中嵌入 Unity 场景并实现消息通信的移动开发者,尤其适合涉及 AR 场景、已使用或计划接入 Vuforia 插件的中等规模商业项目。包内共 21 个文件,涵盖 png 界面与流程截图、mm 与 m 等 Objective-C++ 原生桥接实现、java 与 h 的 Android 侧代码、cs 通信脚本、js 调用脚本以及 xml、xcconfig、json、jar 等配置与依赖文件,压缩包约 424KB,结构围绕 Android 与 iOS 双端展开。目前已有 228 人学习下载。通过其中的原生桥接代码、Unity 侧通信脚本与两端配置示例,读者可以理解 Unity 与 Ionic 之间消息传递的完整链路,掌握在 Android Studio 与 Xcode 环境下搭建集成工程、排查编译与通信问题的思路,并参考 Vuforia 场景下的实测经验快速迁移到自己的 AR 项目。
1. 把 Unity 塞进 Ionic 壳里:为什么我最后选了 Objective-C++ 桥接而不是 WebView 硬扛
去年接了个 AR 展示类的活儿,客户要求很明确:主 App 用 Ionic 写业务界面,中间嵌一个 Unity 做的 3D 互动场景,Android 和 iOS 都要跑,而且 Unity 那边还要挂 Vuforia 做图像识别。我第一反应是能不能用 WebView 直接加载 Unity WebGL 版本,省得折腾原生桥接。结果真机一测就翻车了——中低端安卓机上帧率掉到个位数,iOS 上 WKWebView 对 WebGL 的支持也是玄学,Vuforia 的摄像头数据根本喂不进去。
后来老老实实走原生集成路线:Unity 导出 Android Library 和 iOS Framework,Ionic 侧通过 Cordova 插件封装调用,两端用 Objective-C++ 和 Java 写桥接层,消息通过 UnitySendMessage 和平台通道来回传。这套方案在 Vuforia 插件环境下实测通过,Android 和 iOS 都能稳定跑起来。如果你也在做 Ionic + Unity 的混合集成,尤其是涉及 AR 识别、3D 模型交互、原生摄像头权限这些场景,这篇笔记应该能帮你省掉至少两周的试错时间。下面我从工程结构、桥接实现、消息协议、避坑几个角度拆开讲。
2. 工程结构拆解:Unity 导出物怎么和 Ionic 工程对齐
2.1 Unity 侧导出配置:Android 出 .aar,iOS 出 .framework
Unity 这边首先要确认导出目标平台。Android 选Export Project或者直接勾选Build App Bundle不行,我们要的是Android Library形式的产物。在 Unity 的 Build Settings 里切到 Android,勾选Export Project,然后 Player Settings 里把Scripting Backend设为 IL2CPP,Target Architectures至少勾 ARM64 和 ARMv7。导出后你会得到一个完整的 Gradle 工程,里面unityLibrary模块就是我们要的。
iOS 这边更直接:Build Settings 切到 iOS,Player Settings里把Scripting Backend设为 IL2CPP,Target SDK选 Device SDK,导出 Xcode 工程。导出后在 Xcode 里找到UnityFrameworktarget,编译出.framework文件。注意 iOS 导出时Camera Usage Description和Microphone Usage Description必须填,否则 Vuforia 初始化直接崩。
提示:Unity 版本和 Vuforia 版本要匹配,我用的 Unity 2021.3 LTS + Vuforia 10.x,导出前先在 Unity 里跑一遍空场景确认 Vuforia 能正常初始化,再导出。
2.2 Ionic 侧 Cordova 插件骨架:plugin.xml 怎么写
Ionic 这边我用的 Cordova 插件机制来封装原生桥接。先建一个插件目录,结构大概是这样:
unity-bridge-plugin/ ├── plugin.xml ├── www/ │ └── unity-bridge.js ├── src/ │ ├── android/ │ │ └── UnityBridge.java │ └── ios/ │ └── UnityBridge.mmplugin.xml是核心,它告诉 Cordova 怎么把原生代码和 JS 接口关联起来。关键配置如下:
<plugin xmlns="http://apache.org/cordova/ns/plugins/1.0" id="cordova-plugin-unity-bridge" version="1.0.0"> <name>UnityBridge</name> <js-module src="www/unity-bridge.js" name="UnityBridge"> <clobbers target="UnityBridge" /> </js-module> <platform name="android"> <config-file target="res/xml/config.xml" parent="/*"> <feature name="UnityBridge"> <param name="android-package" value="com.example.unitybridge.UnityBridge"/> </feature> </config-file> <source-file src="src/android/UnityBridge.java" target-dir="src/com/example/unitybridge" /> <framework src="libs/unityLibrary.aar" custom="true" /> </platform> <platform name="ios"> <config-file target="config.xml" parent="/*"> <feature name="UnityBridge"> <param name="ios-package" value="UnityBridge" /> </feature> </config-file> <source-file src="src/ios/UnityBridge.mm" /> <framework src="libs/UnityFramework.framework" custom="true" /> </platform> </plugin>这里有几个参数要盯紧:android-package必须和 Java 类的包名完全一致,ios-package对应 Objective-C 类名。framework src里的路径是相对于插件根目录的,.aar和.framework要提前拷到libs/下。custom="true"表示这是本地框架,不是远程依赖。
2.3 把插件挂进 Ionic 工程:npm link 和 cordova plugin add
插件写好后,在 Ionic 工程根目录执行:
# 先构建插件(如果有 TypeScript 或构建步骤) npm run build # 用本地路径安装插件 ionic cordova plugin add ../unity-bridge-plugin # 确认插件已注册 ionic cordova plugin list | grep unity如果插件有更新,重新执行ionic cordova plugin remove cordova-plugin-unity-bridge再 add 一次。注意 Ionic 5+ 默认用 Capacitor,但 Cordova 插件在 Capacitor 工程里也能用,需要在capacitor.config.json里确认cordova相关配置没被禁用。
3. 消息通道实现:UnitySendMessage 和平台通道怎么接
3.1 Android 侧:Java 调 Unity,Unity 调 Java
Android 这边 Unity 导出的unityLibrary里有一个UnityPlayer类,Ionic 侧通过 Java 反射调用。先看 Java 怎么发消息给 Unity:
// UnityBridge.java package com.example.unitybridge; import com.unity3d.player.UnityPlayer; import org.apache.cordova.CordovaPlugin; import org.apache.cordova.CallbackContext; import org.json.JSONArray; import org.json.JSONException; public class UnityBridge extends CordovaPlugin { private static final String TAG = "UnityBridge"; private static CallbackContext mCallbackContext; @Override public boolean execute(String action, JSONArray args, CallbackContext callbackContext) throws JSONException { mCallbackContext = callbackContext; if ("sendToUnity".equals(action)) { String gameObject = args.getString(0); String method = args.getString(1); String message = args.getString(2); // 关键调用:UnitySendMessage(游戏对象名, 方法名, 消息内容) UnityPlayer.UnitySendMessage(gameObject, method, message); callbackContext.success("sent"); return true; } if ("initUnity".equals(action)) { // 初始化 Unity 播放器,通常在 Activity 里做 callbackContext.success("init"); return true; } return false; } // Unity 侧调用此静态方法回传消息 public static void onUnityMessage(String message) { if (mCallbackContext != null) { mCallbackContext.success(message); } } }UnitySendMessage三个参数分别是:Unity 场景里的 GameObject 名字、挂在该对象上的脚本方法名、字符串消息。注意方法名不能带参数,Unity 侧接收方法必须是public void MethodName(string msg)形式。onUnityMessage是给 Unity 回调用的静态方法,Unity 侧通过AndroidJavaClass调用。
Unity 侧 C# 代码这样写:
// UnityBridge.cs 挂在场景中名为 "Bridge" 的 GameObject 上 using UnityEngine; public class UnityBridge : MonoBehaviour { // Ionic 调 Unity 的入口方法 public void OnIonicMessage(string message) { Debug.Log("收到 Ionic 消息: " + message); // 解析 JSON 或直接处理 // 处理完后回传 SendToIonic("处理完成: " + message); } void SendToIonic(string msg) { #if UNITY_ANDROID && !UNITY_EDITOR using (AndroidJavaClass jc = new AndroidJavaClass( "com.example.unitybridge.UnityBridge")) { jc.CallStatic("onUnityMessage", msg); } #elif UNITY_IOS && !UNITY_EDITOR // iOS 侧通过 extern "C" 函数回调 UnityBridgeIOS_SendMessage(msg); #endif } #if UNITY_IOS && !UNITY_EDITOR [DllImport("__Internal")] private static extern void UnityBridgeIOS_SendMessage(string msg); #endif }3.2 iOS 侧:Objective-C++ 桥接和 extern "C" 回调
iOS 这边用.mm文件(Objective-C++)来写桥接,因为要同时调 Objective-C 的 Cordova 接口和 C++ 的 Unity 接口。先看桥接类:
// UnityBridge.mm #import <Cordova/CDVPlugin.h> #import <UnityFramework/UnityFramework.h> @interface UnityBridge : CDVPlugin @property (nonatomic, strong) UnityFramework *ufw; @end @implementation UnityBridge - (void)pluginInitialize { [super pluginInitialize]; // 获取 UnityFramework 单例 self.ufw = [UnityFramework getInstance]; // 注册回调 [self.ufw registerFrameworkListener:self]; } // JS 调用的 sendToUnity 方法 - (void)sendToUnity:(CDVInvokedUrlCommand *)command { NSString *gameObject = [command.arguments objectAtIndex:0]; NSString *method = [command.arguments objectAtIndex:1]; NSString *message = [command.arguments objectAtIndex:2]; // 关键调用:UnitySendMessage [self.ufw sendMessageToGOWithName:[gameObject UTF8String] functionName:[method UTF8String] message:[message UTF8String]]; CDVPluginResult *result = [CDVPluginResult resultWithStatus:CDVCommandStatus_OK messageAsString:@"sent"]; [self.commandDelegate sendPluginResult:result callbackId:command.callbackId]; } // Unity 回调入口,C 函数形式暴露给 Unity static UnityBridge *gBridge = nil; extern "C" void UnityBridgeIOS_SendMessage(const char *message) { NSString *msg = [NSString stringWithUTF8String:message]; if (gBridge) { [gBridge onUnityMessage:msg]; } } - (void)onUnityMessage:(NSString *)message { CDVPluginResult *result = [CDVPluginResult resultWithStatus:CDVCommandStatus_OK messageAsString:message]; [result setKeepCallbackAsBool:YES]; [self.commandDelegate sendPluginResult:result callbackId:self.currentCallbackId]; } @end这里有几个关键点:UnityFramework是 Unity iOS 导出的框架类,sendMessageToGOWithName对应 Android 的UnitySendMessage。extern "C"函数是给 Unity C# 侧DllImport调用的,函数名必须和 C# 里声明的一致。gBridge是全局静态变量,在pluginInitialize里赋值。
3.3 JS 侧统一接口:Promise 封装和消息队列
前端这边用 Promise 封装 Cordova 的exec调用,让调用方不用关心回调:
// www/unity-bridge.js var exec = require('cordova/exec'); var UnityBridge = { _messageQueue: [], _isReady: false, init: function() { return new Promise(function(resolve, reject) { exec(resolve, reject, 'UnityBridge', 'initUnity', []); }); }, sendToUnity: function(gameObject, method, message) { return new Promise(function(resolve, reject) { exec(resolve, reject, 'UnityBridge', 'sendToUnity', [gameObject, method, message]); }); }, // Unity 主动发消息时的监听 onMessage: function(callback) { exec(callback, null, 'UnityBridge', 'registerListener', []); } }; module.exports = UnityBridge;exec的五个参数分别是:成功回调、失败回调、插件名、action 名、参数数组。registerListener需要在原生侧实现,用setKeepCallbackAsBool:YES保持回调通道打开,这样 Unity 可以多次发消息回来。
4. 避坑与排查:Vuforia 环境下最容易翻车的五个点
4.1 现象:Unity 场景加载后黑屏,日志无报错
原因:Android 侧unityLibrary的AndroidManifest.xml里 Activity 配置和 Ionic 主 Activity 冲突,或者 Unity 播放器没有正确 attach 到当前 Activity。
解决:在 Ionic 的MainActivity.java里重写onResume和onPause,把生命周期透传给 UnityPlayer:
@Override protected void onResume() { super.onResume(); UnityPlayer.UnitySendMessage("Bridge", "OnResume", ""); } @Override protected void onPause() { super.onPause(); UnityPlayer.UnitySendMessage("Bridge", "OnPause", ""); }同时确认AndroidManifest.xml里 Unity 的 Activity 声明没有覆盖主 Activity。
4.2 现象:iOS 上 Vuforia 摄像头权限弹窗不出现,直接闪退
原因:Info.plist里缺少NSCameraUsageDescription,或者 UnityFramework 初始化时没有传入正确的权限描述。
解决:在 Ionic 工程的config.xml里加:
<platform name="ios"> <edit-config file="*-Info.plist" mode="merge" target="NSCameraUsageDescription"> <string>需要摄像头权限用于 AR 识别</string> </edit-config> </platform>重新ionic cordova prepare ios后确认Info.plist已更新。
4.3 现象:Android 上消息发送成功但 Unity 收不到
原因:UnitySendMessage的第一个参数 GameObject 名字拼写错误,或者该 GameObject 在场景加载时被销毁了。
解决:在 Unity 里把桥接脚本挂在一个常驻对象上,用DontDestroyOnLoad保护:
void Awake() { DontDestroyOnLoad(this.gameObject); }同时确认 GameObject 名字和 Java 侧传入的完全一致,大小写敏感。
4.4 现象:iOS 编译报错 "Undefined symbols for architecture arm64"
原因:UnityFramework.framework没有正确链接,或者extern "C"函数在 C# 侧声明和 Objective-C++ 侧定义不一致。
解决:在 Xcode 的Build Phases → Link Binary With Libraries里手动添加UnityFramework.framework,并在Build Settings → Other Linker Flags里加-all_load。检查 C# 的DllImport("__Internal")函数名和.mm里的extern "C"函数名完全一致。
4.5 现象:Vuforia 识别到目标后 Unity 回调 JS 延迟超过 2 秒
原因:消息通道用了同步阻塞方式,或者 JS 侧回调没有用setKeepCallback导致每次都要重新注册。
解决:确保 iOS 侧CDVPluginResult设置了setKeepCallbackAsBool:YES,Android 侧CallbackContext只保存一次。另外 Unity 侧发消息不要在Update里每帧调用,用事件驱动,识别成功才发一次。
5. 进阶技巧:用 JSON 协议统一两端消息格式,加一个消息 ID 做回执
前面几章的消息都是裸字符串,实际项目里消息类型多了之后很容易乱。我后来改成了 JSON 协议,每条消息带type、payload、msgId三个字段,Unity 侧处理完回传时带上同一个msgId做回执匹配。这样前端可以发多条消息而不用等上一条返回,Promise 也能正确 resolve。
先看 JS 侧的封装:
// 带消息 ID 的发送封装 var _msgIdCounter = 0; var _pendingMessages = {}; function sendWithReceipt(gameObject, method, payload) { var msgId = 'msg_' + (++_msgIdCounter); var message = JSON.stringify({ type: method, payload: payload, msgId: msgId }); return new Promise(function(resolve, reject) { _pendingMessages[msgId] = { resolve: resolve, reject: reject }; UnityBridge.sendToUnity(gameObject, 'OnIonicMessage', message); // 超时保护 setTimeout(function() { if (_pendingMessages[msgId]) { delete _pendingMessages[msgId]; reject(new Error('Unity 响应超时: ' + msgId)); } }, 5000); }); } // Unity 回传时匹配 msgId UnityBridge.onMessage(function(rawMessage) { try { var msg = JSON.parse(rawMessage); var pending = _pendingMessages[msg.msgId]; if (pending) { delete _pendingMessages[msg.msgId]; pending.resolve(msg.payload); } } catch (e) { console.error('消息解析失败', e); } });Unity 侧 C# 对应处理:
[System.Serializable] public class BridgeMessage { public string type; public string payload; public string msgId; } public void OnIonicMessage(string rawJson) { BridgeMessage msg = JsonUtility.FromJson<BridgeMessage>(rawJson); Debug.Log("收到类型: " + msg.type + " ID: " + msg.msgId); // 根据 type 分发处理 string result = HandleMessage(msg.type, msg.payload); // 回传时带上同一个 msgId BridgeMessage reply = new BridgeMessage { type = "reply", payload = result, msgId = msg.msgId }; SendToIonic(JsonUtility.ToJson(reply)); }这套协议跑通后,前端调用就像调普通异步接口一样自然。我一般会在sendWithReceipt外面再包一层业务方法,比如startARScan()、loadModel(modelId),每个方法内部拼对应的type和payload。超时时间设 5 秒是因为 Vuforia 首次初始化摄像头可能就要 2-3 秒,设太短会误判。
还有一个细节:Android 侧UnitySendMessage的字符串长度有限制,超过 64KB 会截断。如果 payload 里有大段 JSON,建议先压缩或者拆成多条发。iOS 侧没有这个限制,但为了两端一致,我统一控制在 32KB 以内。
从那以后我每次做 Unity 和 Ionic 的桥接,都强制先跑一遍「发一条带 msgId 的消息 → Unity 回传 → JS 匹配 resolve」的最小闭环,确认通道没问题再往上堆业务逻辑。这个习惯帮我省掉了至少三次因为消息丢失导致的深夜排查。希望帮到你。
本文还有配套的精品资源,点击获取