简介:UnityIonicIntegration 是一份针对 Unity 3D 内容与 Ionic 混合应用双向集成的实战指南,适合需要同时掌握 Android/iOS 端插件封装与原生通信的移动开发者。方案基于 Cordova 插件实现 Unity 与 Ionic 之间双向消息传递,并已在带 Vuforia 插件的商业级 Unity 项目中验证,既有基础接入流程,也覆盖增强现实场景的集成要点。资源压缩包内含 21 个文件、体积仅 424KB,主要涉及 Objective-C++(.mm/.h)、Java、C#(.cs)等原生桥接代码,另含 plugin.xml、iOS 配置 xcconfig、调用脚本 js 与说明文档 md,可直接对照工程结构理解 Android 与 iOS 两端的整合方式。已有 228 人学习,适合正在做 Unity 与混合应用融合、AR 功能嵌入或 Cordova 插件定制的开发者参考。
1. 一个 AR 场景,暴露了 WebView 套壳的极限
在做某个需要 AR 看房功能的 Ionic 应用时,我一开始想得很简单:把 Unity 打包成 WebGL,塞进 WebView。结果加载慢、AR 识别卡、消息传不过去,连摄像头权限都要在 Unity 和浏览器两侧各配置一遍。这份资源给的是另一条路——把 Unity 当成一个原生模块,通过 Cordova 插件的方式嵌入 Ionic,并在两者之间维持一条双向消息通道。它已经在 Vuforia 插件跑过 AR 识别,Android 和 iOS 都覆盖到了。适合手上有 Unity 资源、但主体是 Ionic/Cordova 的团队。
2. 技术选型与消息链路:Unity 变成 Ionic 的一个原生模块
刚接触这套方案时,我第一反应是找现成的 WebAR 方案替换掉 Unity。但项目里已经有大量 Unity 场景和 Vuforia 识别逻辑,重写成本太高。真正的解法不是换掉 Unity,而是把 Unity 当作一个原生视图接入 Ionic。
这套集成的核心思路是:Ionic 页面用 Cordova 插件唤起原生层,原生层再加载 Unity 的导出库。Unity 场景并不直接跑在 WebView 里,而是一个独立的全屏或嵌入式原生视图。这样做的好处是保留 Vuforia 需要的摄像头权限、OpenGL 上下文和 Unity 自己的渲染线程,也不会遇到 WebGL 的兼容性损耗。
2.1 整体拓扑:Ionic 页面、Cordova 插件、Unity 场景三者怎么连
从页面点击到 AR 渲染,消息链路大致是五步:
- Ionic 页面调用
window.UnityBridge.launch(...); - Cordova 的 JS 模块通过
exec通知原生插件; - 原生插件创建 Unity 视图,并把 UnityPlayer 或 UnityFramework 挂到当前窗口;
- Unity 里的 C# 脚本把状态反馈给原生层,原生层再通过 Cordova 的回调把消息回传给 JS;
- Ionic 页面收到消息后更新页面状态。
这个链路里最容易出错的是第 4 步。Unity 里不能直接拿到 Cordova 的 WebView 对象,也不能直接调用 JS 方法,所以原生插件必须扮演“中转站”。Android 端靠UnityPlayer.UnitySendMessage和 Java 静态方法中转,iOS 端靠 Objective-C++ 桥接函数中转。
2.2 目录结构:Cordova 插件如何收纳 Unity 导出产物
一份可复用的 Cordova 插件,大概长这样:
plugins/com.example.unitybridge/ plugin.xml // Cordova 插件元信息,声明原生文件和权限 www/UnityBridge.js // 暴露到 window 的 JS API src/android/UnityBridge.java src/android/unityLibrary/ // Unity 导出的 Android Library src/ios/UnityBridge.mm src/ios/UnityLibrary/ // Unity 导出的 iOS Frameworkplugin.xml负责告诉 Cordova:哪些原生文件要编译,哪些 framework 要静态链接,哪些 JS 接口要挂到window上。www/UnityBridge.js是前端唯一要接触的入口。src/android/unityLibrary和src/ios/UnityLibrary是 Unity 导出后的原生产物,不能随手删,里面包含 Unity 运行时、着色器、场景资源以及 Vuforia 所需的原生库。
常见做法是把 Unity 导出的整个 Library 目录放进插件里,而不是只丢一个.aar或.framework。这样下次 Unity 场景更新时,只需要重新导出同名目录覆盖即可。
2.3 Android 与 iOS 的桥接差异:一个用 Java 接 UnityPlayer,一个用 Objective-C++ 接 UnityFramework
Android 端相对直接。Unity 导出的unityLibrary里有一个UnityPlayer类,插件可以在自己的 Activity 中实例化它,并把它当成一个 View 添加到页面容器。消息发送用UnityPlayer.UnitySendMessage,C# 侧用AndroidJavaClass反向调用 Java 静态方法。
iOS 端要绕一点。Unity 导出的UnityLibrary.framework对外暴露的是 Objective-C++ 接口,也就是UnityFramework类。如果你的 Cordova 插件源文件是.m,编译器按普通 Objective-C 处理,连 Unity 的头文件都过不了。必须把文件后缀改成.mm,让编译器进入 Objective-C++ 模式,才能调用 Unity 的 C++ 运行时接口。这就是标题里出现UnityObjective-C++的原因。
3. 核心模块拆解:一条消息从 C# 发出后如何落到 Ionic 页面
这套集成的核心代码其实不复杂,复杂在两端都容易写错。我拆成 Unity 侧、Android 原生侧、iOS 原生侧三个模块来理解。
3.1 Unity 侧:常驻 GameObject + 静态入口
Unity 场景被原生层加载后,需要一个常驻对象来接收外部消息。最常见做法是创建一个UnityMessageCenter脚本,挂在一个永不销毁的 GameObject 上。
using UnityEngine; public class UnityMessageCenter : MonoBehaviour { public static UnityMessageCenter Instance { get; private set; } private void Awake() { if (Instance != null && Instance != this) { Destroy(gameObject); return; } Instance = this; DontDestroyOnLoad(gameObject); } // 从 Unity 发送消息到 Ionic public void SendToIonic(string message) { #if UNITY_ANDROID && !UNITY_EDITOR using (var bridge = new AndroidJavaClass("com.example.unitybridge.UnityBridge")) { bridge.CallStatic("sendToIonic", message); } #elif UNITY_IOS && !UNITY_EDITOR // iOS 端由原生侧的 Objective-C++ 桥接函数接收 #endif } // 供原生层调用,方法名必须和 UnitySendMessage 里的参数保持一致 public void ReceiveFromIonic(string message) { Debug.Log($"[UnityMessageCenter] msg from Ionic: {message}"); // 在这里把消息路由到 Vuforia 场景或 AR 业务逻辑 } }这段代码有三个关键点。DontDestroyOnLoad保证场景切换时消息中心不会消失;ReceiveFromIonic方法名不能改,因为原生层是用字符串去反射这个方法的;SendToIonic用平台宏区分调用方式,避免在编辑器里跑 AndroidJavaClass 时报错。
如果你在 iOS 上使用extern桥接,C# 侧通常会再写一个[DllImport("__Internal")]的声明,指向原生.mm文件里的 C 函数。这个细节最容易漏,漏了之后 C# 能编译,但运行时不报错也不回调。
3.2 Android 原生:Cordova 插件持有 UnityPlayer,并把消息转发给 WebView
Android 端的主要职责有三个:创建UnityPlayer视图、把 Unity 的消息回传 JS、把 JS 的消息传给 Unity。下面是一个简化版。
package com.example.unitybridge; import android.app.Activity; import com.unity3d.player.UnityPlayer; public class UnityBridge { private static UnityPlayer unityPlayer; private static Activity currentActivity; public static void attach(Activity activity) { currentActivity = activity; if (unityPlayer == null) { unityPlayer = new UnityPlayer(activity); // 把 UnityPlayer 添加进当前 Activity 的根容器 } } // Unity C# 调用 sendToIonic 时会进入这里 public static void sendToIonic(final String message) { currentActivity.runOnUiThread(() -> { // 通过 Cordova 的 webView 执行 JS 回调 // 这里保存 callbackId,然后 JS 端调用 window.unityMessageHandler }); } // Ionic JS 调用 sendToUnity 时会进入这里 public static void sendToUnity(String objectName, String methodName, String message) { UnityPlayer.UnitySendMessage(objectName, methodName, message); } }参数说明:objectName是 Unity 场景里的 GameObject 名字,必须和挂载UnityMessageCenter的对象名一致;methodName是对应脚本里的公开方法名;message是传给这个方法的字符串。很多人卡在“Unity 收不到消息”,一排查往往是objectName写错,Unity 里Find不到这个对象,消息就静默丢掉了。
在真机的 Cordova 插件类里,sendToIonic的回调不一定要直接在 Java 里拼 JS。常见做法是先把消息存入静态变量,然后通过 Cordova 的WebView执行一段 JS 代码,或者直接调用之前保存的callbackId。关键是必须在主线程执行,否则 WebView 更新 UI 时会出现时机问题。
3.3 iOS 原生:用 Objective-C++ 接住 UnityFramework 的消息
iOS 端需要把UnityFramework当成一个单例持有。Cordova 插件每次进入时不能重复创建,否则 Unity 场景会重置。
#import "UnityBridge.h" #import <UnityFramework/UnityFramework.h> static UnityFramework *unityFramework; @implementation UnityBridge + (void)launchUnity { if (unityFramework == nil) { unityFramework = [UnityFramework getInstance]; [unityFramework runEmbeddedWithArgc:1 argv:nil appLaunchOpts:nil]; } } + (void)sendToUnity:(NSString *)message { [unityFramework sendMessageToGameObject:@"UnityMessageCenter" methodName:@"ReceiveFromIonic" message:message]; } + (void)onUnityMessage:(NSString *)message { [[NSNotificationCenter defaultCenter] postNotificationName:@"UnityMessageToIonic" object:message]; } @end这段代码与前端的连接逻辑是:launchUnity启动 Unity 场景;sendToUnity从 JS 发消息给 Unity;onUnityMessage接收来自 Unity 的回调。因为源文件是.mm,所以这里能直接使用UnityFramework的头文件和 C++ 对象,不会出现编译报错。
UnityBridge.js是前端拿到的最外层 API,它把原生能力包装成 Promise 风格:
var exec = require('cordova/exec'); exports.launch = function (success, error) { exec(success, error, 'UnityBridge', 'launch', []); }; exports.sendToUnity = function (message, success, error) { exec(success, error, 'UnityBridge', 'sendToUnity', [message]); }; exports.registerMessageHandler = function (handler) { window.unityMessageHandler = handler; };4. 详细跑通指南:从 Unity 导出到 Cordova 插件接入真机
下面是我验证过的一条落地路径。建议先不管业务,把最简单的 Unity 场景打通,再往里面塞 Vuforia 和 AR 逻辑。
4.1 第一步:Unity 导出成 Library 而不是 APK
在 Unity 的 Build Settings 里选择 Android 或 iOS,然后勾选Export Project,不要选Build。勾选之后,Unity 会导出一整个可被原生工程引用的目录结构,上面说过,Android 输出unityLibrary,iOS 输出UnityLibrary。
导出时,还需要进入 Player Settings 确认两件事。包名必须和 Cordova 插件的 Java package 对得上,建议做成com.example.unitybridge这种独立名称,避免与 Ionic 自身包名冲突。Android 端 API Level 建议 21 以上,iOS 端 Deployment Target 建议和 Unity 运行时的最低要求一致。
之前我没勾选Export Project,直接打出 APK 再拆包找classes.jar,绕了很多弯路,后来发现这个工程导出包本身就是插件依赖的来源。这一步不用改任何业务代码,只要 Unity 项目能在编辑器中跑起来,导出就不会有太大问题。
4.2 第二步:把 Unity 原生产物收进 Cordova 插件
拿到 Unity 导出的目录后,把它放进插件对应的src/android和src/ios目录。然后在plugin.xml里声明这些文件:
<plugin name="com.example.unitybridge" version="1.0.0"> <platform name="android"> <source-file src="src/android/UnityBridge.java" target-dir="src/com/example/unitybridge" /> <framework src="src/android/unityLibrary/unityLibrary.aar" custom="true" /> <config-file target="AndroidManifest.xml" parent="/manifest"> <uses-permission android:name="android.permission.CAMERA" /> </config-file> </platform> <platform name="ios"> <source-file src="src/ios/UnityBridge.mm" /> <framework src="src/ios/UnityLibrary.framework" custom="true" /> <framework src="src/ios/UnityFramework.framework" custom="true" /> </platform> <js-module src="www/UnityBridge.js" name="UnityBridge"> <clobbers target="window.UnityBridge" /> </js-module> </plugin>custom="true"表示 framework 由插件自己提供,Cordova 不会去网络下载。uses-permission加摄像头权限是因为 Vuforia 需要访问相机,即使你在 Ionic 的config.xml里申请过,Android 高版本下原生层仍然要显式声明。
4.3 第三步:Ionic 页面调起 Unity 并监听回调
在 Ionic 的页面里,我们只需要操作window.UnityBridge。注意回调到达时往往不在 Angular 的 zone 内,如果不包一层NgZone.run,页面状态不会自动刷新。
declare const window: any; export class ArPage { constructor(private zone: NgZone) {} launchUnity() { window.UnityBridge.launch( () => this.zone.run(() => {}), (err: any) => console.error(err) ); } sendMessageToUnity(message: string) { window.UnityBridge.sendToUnity(message); } bindUnityMessage() { window.UnityBridge.registerMessageHandler((data: string) => { this.zone.run(() => { this.arStatus = data; }); }); } }这里的arStatus就是 Unity 回传的实时状态。比如 Vuforia 识别到目标后,Unity 发一个{ "type": "tracking", "target": "chair" },Ionic 页面就能立刻更新展示。
4.4 第四步:真机联调
把插件加进 Ionic 项目后,依次执行:
ionic cordova platform add android ionic cordova build android ionic cordova run android --device如果是 iOS,Cordova 会生成 Xcode 工程,但UnityLibrary.framework的链接顺序很敏感。常见做法是先pod install,再打开 Xcode 工程,在 Build Settings 里确认Framework Search Paths包含插件目录。
联调时不要把注意力只放在“能不能看到 Unity 画面”上,还要验证一条完整的消息链路:Ionic -> JS -> 原生 -> Unity -> 原生 -> JS。只要这条链路能走通,后续接任何 AR 业务都是往里面填数据。
5. 避坑排查:Android 白屏、iOS 收不到消息、AR 闪退
这套集成的坑大多不在前端,而在原生层和 Unity 运行时的交互。我把实际踩过的四类问题列出来,每条按现象、原因、解决整理。
5.1 Android 启动后白屏,LogCat 里出现 createContext failed
现象是 Unity 场景确实在运行,声音也正常,但画面全白,控制台报createContext failed。
原因是UnityPlayer被放进了一个持有非标准 Surface 的 ViewGroup,或者与 WebView 叠加时发生 EGL 上下文冲突。Unity 的渲染循环要求自己的 SurfaceView 有独立的 surface,直接加在普通的LinearLayout里往往会失败。
解决方法是把UnityPlayer放在一个全屏的 Activity 中,Activity 只做一层容器,不和 WebView 共用同一个视图层级。如果业务上必须局部展示,需要保证 UnityPlayer 对应的SurfaceView不被 webView 的硬件加速层覆盖,必要的时候调setZOrderMediaOverlay(true)。
5.2 iOS 真机收不到从 Unity 发出的消息
现象是 C# 里调用了SendToIonic,JS 端没有任何反应,Xcode 控制台也没有错误。
原因主要有两个。一个是插件的源文件后缀还是.m,导致 Objective-C++ 桥接函数没有编译进去,消息直接丢失。另一个是UnityFramework对象被提前释放,或者每次调用都新建了一个单例,导致回调地址对不上。
解决方法是把桥接文件改成.mm,并在 Cordova 插件生命周期内持有全局static UnityFramework *。另外,registerFrameworkListener之类的回调一定要在launchUnity之前注册,注册晚了就会错过 Unity 首次回调。
5.3 Vuforia 识别目标时闪退
现象是 Unity 界面正常,摄像头也打开了,但 Vuforia 识别到目标的一瞬间崩溃,崩溃栈指向相机相关函数。
原因是 Activity 生命周期没有转发给 UnityPlayer。UnityPlayer 在后台时没有正确暂停渲染线程,当 Vuforia 尝试重新获取相机时,GPU 上下文已经失效。另一个常见原因是 Android 没有CAMERA和VIBRATE权限。
解决方式是在插件 Java 类里复写onResume和onPause,把生命周期转给UnityPlayer。代码里通常会写成:
@Override protected void onResume() { super.onResume(); if (unityPlayer != null) { unityPlayer.resume(); } } @Override protected void onPause() { super.onPause(); if (unityPlayer != null) { unityPlayer.pause(); } }在真机上尝试这个改动之前,建议先用 Unity 自带的 Vuforia 示例场景直接装到设备上跑一遍,确认不是 Vuforia App Key 或者 License 配置问题,再回到集成环境排查。
5.4 Cordova 运行时报 ClassNotFoundException
现象是点击启动按钮后,Android 报找不到com.example.unitybridge.UnityBridge,但代码明明在插件目录里。
原因是plugin.xml里的source-file target-dir和 Java 文件的 package 不一致。Cordova 按 target-dir 拷贝 Java 文件到生成的工程里,如果你的 package 是com.example.unitybridge,target-dir 就必须是src/com/example/unitybridge,不能写成其他路径。
另一个容易被忽略的点是 Unity 导出的.aar文件如果路径写错,Cordova 不会报构建失败,只会在运行时找不到类。检查plugin.xml里的<framework>路径是否真的指向.aar,不要写成.jar。这类问题在日志里很难追,我是直接把生成的platforms/android解包看 class 文件才发现的。
6. 二次开发:把单例桥接扩展成通用消息队列与带参回调
跑通基础链路之后,你会很快发现一个问题:如果只是传一个字符串,Unity 和 Ionic 之间很难承载多种业务消息。
我建议把消息格式升级成 JSON,拆成type和payload。Unity 侧收到字符串后先JsonUtility解析,再根据type分发到不同模块。JS 侧也从一个全局 handler 改成按消息类型订阅,这样后续加“识别到了哪张桌子”“播放哪个动画”都不需要再改原生桥接。
如果还想解决“多条消息并发时回调混乱”的问题,可以在 JS 端给每条消息加一个msgId,并在原生端原样返回。
var exec = require('cordova/exec'); var pending = {}; exports.sendToUnity = function (type, payload, success, error) { var msgId = 'cb_' + Date.now() + '_' + Math.random().toString(16).slice(2); var message = JSON.stringify({ type: type, payload: payload, msgId: msgId }); pending[msgId] = success; exec(function (reply) { var result = JSON.parse(reply); if (result.msgId && pending[result.msgId]) { pending[result.msgId](result); delete pending[result.msgId]; } }, error, 'UnityBridge', 'sendToUnity', [message]); };这里的关键点是msgId由前端生成,原生层只负责把它塞回返回 JSON,不做解析。这样即使 Unity 连续回了三条消息,前端也能一一对应上。
验证这套改造,我一般会在原生层的sendToIonic里临时打印一条带msgId的 JSON,然后打开 Chrome DevTools 的chrome://inspect,等 Unity 启动后触发一次消息,看 JS 端pending是否被正确消费。只要这一步是对的,后面接业务接口就是在写业务逻辑,不用再碰桥接了。
从那以后,我每次接入 Unity 组件都会强制走一遍“Web 端发起 → 原生收到 → Unity 收到 → Unity 回传 → Web 端回调”的完整链路再写业务,等于给桥接代码装了一杆秤。希望这杆秤也能帮到你。
本文还有配套的精品资源,点击获取