简介:这份资源面向需要在 Ionic 混合应用中嵌入 Unity 3D 内容的移动开发者,重点解决 Android 与 iOS 双端集成及两者间消息通信的问题,并已在带 Vuforia 插件的中等规模 Unity 商业项目中验证,适合具备 Cordova、Objective-C++ 与 C# 基础的进阶开发者。压缩包共 21 个文件,约 424KB,包含 png 界面截图、mm 与 m 原生实现、java 与 h 安卓桥接、cs 通信脚本、xml 插件配置、xcconfig 构建参数及 js 调用示例,覆盖从原生层到前端调用的完整链路。目前已有 228 人学习下载。读者可据此掌握 Unity 视图在 Ionic 页面中的加载方式、双向消息通道的搭建思路,以及 Vuforia AR 场景下的集成要点,并借助截图与配置样例快速定位编译与通信环节的常见问题,减少跨平台联调成本。
1. 从一次真机联调翻车说起:Unity 内容怎么塞进 Ionic 壳里
去年帮一个做工业巡检的团队看问题,他们的 Ionic 应用在 Android 真机上打开 AR 模块直接黑屏,iOS 上更干脆,进程闪退。代码翻出来一看,Unity 导出的 Android 工程被整个塞进platforms/android里当子模块编译,Ionic 侧用window.open去加载一个本地 HTML,两边靠localStorage传参。这套做法在浏览器里跑得通,一上真机就崩——因为 Unity 的UnityPlayerActivity和 Cordova 的CordovaActivity抢同一个 Activity 生命周期,谁先onResume谁后onPause完全失控。
这份资源要解决的就是这类问题:把 Unity 3D 场景(含 Vuforia 这类 AR 插件)作为原生视图嵌进 Ionic/Cordova 应用,并且在 Web 层和 Unity 层之间建立一条稳定的双向消息通道,Android 和 iOS 都要能跑。它适合两类人:一是手里已经有 Ionic 项目、想加 AR 或 3D 展示模块的移动端开发者;二是做 Unity 内容、需要把场景交付给 Hybrid 壳的 3D 同学。核心不是教你写 Unity 脚本,而是讲清楚桥怎么搭、消息怎么发、生命周期怎么对齐。
2. 架构拆解:Cordova 插件层、原生容器层与 Unity 导出层的三明治结构
2.1 三层职责划分
这个集成方案的本质是一个自定义 Cordova 插件,把 Unity 运行时当作原生视图来管理。整体分三层:
- JS 接口层:Ionic 页面调用的
cordova.plugins.UnityIonic,暴露show()、hide()、postMessage()、onMessage()四个方法。 - 原生桥接层:Android 侧是
UnityIonicPlugin.java+ 一个继承CordovaPlugin的入口;iOS 侧是UnityIonicPlugin.m(或 Swift 封装)+ Objective-C++ 桥接文件.mm。 - Unity 导出层:Unity 导出的 Android Library(
unityLibrary模块)或 iOS Framework(UnityFramework.framework),对外只暴露一个 View 和一组消息回调。
三层之间不共享内存,消息通过序列化字符串传递。Android 走UnityPlayer.UnitySendMessage,iOS 走UnityFramework.sendMessageToGO。这个设计的好处是 Unity 侧完全不用关心 Ionic 的存在,它只认一个 GameObject 和几个方法名。
2.2 目录结构长什么样
一个典型的集成后工程目录(以 Android 为例):
ionic-unity-app/ ├── src/ # Ionic 源码 │ ├── app/ │ │ └── ar-view/ # 调用 Unity 的页面 │ └── ... ├── cordova-plugin-unity-ionic/ # 自定义插件 │ ├── src/ │ │ ├── android/ │ │ │ └── UnityIonicPlugin.java │ │ └── ios/ │ │ ├── UnityIonicPlugin.m │ │ └── UnityIonicBridge.mm │ ├── www/ │ │ └── unity-ionic.js # JS 接口 │ └── plugin.xml ├── platforms/ │ └── android/ │ ├── app/ # Cordova 主工程 │ └── unityLibrary/ # Unity 导出模块 └── unity-project/ # Unity 源工程(不参与 Ionic 构建)关键点:unityLibrary是作为独立 module 被app依赖的,不是把 Unity 的assets和libs直接拷进app。这样升级 Unity 内容时只需替换unityLibrary,不会污染 Cordova 主工程。
2.3 消息通道的数据格式约定
两边传消息最容易翻车的地方是格式不统一。我一般会约定一个固定结构:
{ "type": "command", "action": "startScan", "payload": { "targetId": "abc123" }, "callbackId": "cb_001" }type区分是命令还是事件,action是具体动作,callbackId用于异步回执。Unity 侧收到后解析 JSON,执行完通过UnitySendMessage回传一个同callbackId的结果。这样 JS 层可以用 Promise 包一层,避免回调地狱。
提示:不要用
SendMessage传复杂对象,Unity 的UnitySendMessage只支持字符串参数,JSON 序列化是唯一稳妥做法。
3. 跑起来:从 Unity 导出到 Ionic 真机运行的完整链路
3.1 Unity 侧导出配置
先处理 Unity 工程。以 Android 为例,Build Settings里要选Export Project而不是Build,否则拿不到可集成的 Gradle 模块。导出后目录里会有unityLibrary和launcher两个模块,我们只要unityLibrary。
Unity 侧需要挂一个消息接收脚本,挂在一个常驻 GameObject 上:
using UnityEngine; public class UnityIonicBridge : MonoBehaviour { // 这个方法名要和原生层约定一致 public void OnNativeMessage(string json) { var msg = JsonUtility.FromJson<Message>(json); switch (msg.action) { case "startScan": StartScan(msg.payload); break; case "stopScan": StopScan(); break; } } void StartScan(string payload) { // 调用 Vuforia 或自定义 AR 逻辑 Debug.Log("Scan started with: " + payload); // 执行完回传结果 SendToNative("{\"type\":\"event\",\"action\":\"scanStarted\",\"callbackId\":\"cb_001\"}"); } void SendToNative(string json) { // Android 用 UnityPlayer,iOS 用 UnityFramework #if UNITY_ANDROID using (AndroidJavaClass jc = new AndroidJavaClass("com.unity3d.player.UnityPlayer")) { using (AndroidJavaObject jo = jc.GetStatic<AndroidJavaObject>("currentActivity")) { jo.Call("onUnityMessage", json); } } #elif UNITY_IOS // iOS 侧通过 DllImport 调用原生方法 UnityIonicNative_SendMessage(json); #endif } [System.Serializable] class Message { public string type; public string action; public string payload; public string callbackId; } }这段代码的关键是OnNativeMessage方法名必须和原生层UnitySendMessage("Bridge", "OnNativeMessage", json)里的第二个参数完全一致,大小写敏感。SendToNative里 Android 通过currentActivity反射调用 Java 方法,iOS 通过DllImport声明外部函数。
3.2 Cordova 插件注册与 Android 桥接
插件入口UnityIonicPlugin.java负责把 Unity 视图挂到 Cordova 的 WebView 之上:
public class UnityIonicPlugin extends CordovaPlugin { private FrameLayout unityContainer; private UnityPlayer unityPlayer; @Override public void initialize(CordovaInterface cordova, CordovaWebView webView) { super.initialize(cordova, webView); // 创建容器,初始隐藏 unityContainer = new FrameLayout(cordova.getActivity()); unityContainer.setVisibility(View.GONE); // 把 Unity 视图加到 Cordova 根布局 ViewGroup root = (ViewGroup) webView.getView().getParent(); root.addView(unityContainer, new ViewGroup.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); } @Override public boolean execute(String action, JSONArray args, CallbackContext callbackContext) { switch (action) { case "show": cordova.getActivity().runOnUiThread(() -> { unityContainer.setVisibility(View.VISIBLE); if (unityPlayer == null) { unityPlayer = new UnityPlayer(cordova.getActivity()); unityContainer.addView(unityPlayer); } unityPlayer.resume(); }); callbackContext.success(); return true; case "postMessage": String json = args.optString(0); UnityPlayer.UnitySendMessage("Bridge", "OnNativeMessage", json); callbackContext.success(); return true; } return false; } // Unity 侧回调入口 public void onUnityMessage(String json) { // 转发给 JS 层 webView.getEngine().evaluateJavascript( "window.UnityIonic._onNativeMessage(" + JSONObject.quote(json) + ")", null); } }initialize里把 Unity 容器加到 WebView 的父布局,这样 Unity 视图会盖在 WebView 上面。show时懒加载UnityPlayer,避免应用启动就初始化 Unity 导致启动变慢。onUnityMessage是 Unity 侧通过反射调用的,把消息透传给 JS。
3.3 iOS 侧 Objective-C++ 桥接
iOS 不能用 Android 那套反射,需要.mm文件混编 C++ 和 Objective-C:
// UnityIonicBridge.mm #import "UnityIonicPlugin.h" #import <UnityFramework/UnityFramework.h> @implementation UnityIonicPlugin - (void)show:(CDVInvokedUrlCommand*)command { dispatch_async(dispatch_get_main_queue(), ^{ UnityFramework* ufw = [UnityFramework getInstance]; UIView* unityView = [ufw appController].window.rootViewController.view; unityView.frame = self.viewController.view.bounds; [self.viewController.view addSubview:unityView]; [ufw runUI]; }); CDVPluginResult* result = [CDVPluginResult resultWithStatus:CDVCommandStatus_OK]; [self.commandDelegate sendPluginResult:result callbackId:command.callbackId]; } - (void)postMessage:(CDVInvokedUrlCommand*)command { NSString* json = [command.arguments objectAtIndex:0]; UnityFramework* ufw = [UnityFramework getInstance]; [ufw sendMessageToGOWithName:"Bridge" functionName:"OnNativeMessage" message:[json UTF8String]]; CDVPluginResult* result = [CDVPluginResult resultWithStatus:CDVCommandStatus_OK]; [self.commandDelegate sendPluginResult:result callbackId:command.callbackId]; } // Unity 侧回调 extern "C" void UnityIonicNative_SendMessage(const char* message) { NSString* msg = [NSString stringWithUTF8String:message]; // 通过通知或单例转发给插件实例 [[NSNotificationCenter defaultCenter] postNotificationName:@"UnityIonicMessage" object:nil userInfo:@{@"data": msg}]; } @endiOS 侧的核心是UnityFramework单例,sendMessageToGOWithName对应 Android 的UnitySendMessage。UnityIonicNative_SendMessage是 C 函数,供 Unity 的DllImport调用,内部通过通知把消息抛给插件实例。
3.4 Ionic 页面调用
JS 层封装成 Promise 风格:
// unity-ionic.js var UnityIonic = { _callbacks: {}, _cbIndex: 0, show: function() { return new Promise(function(resolve, reject) { cordova.exec(resolve, reject, 'UnityIonic', 'show', []); }); }, postMessage: function(action, payload) { var self = this; return new Promise(function(resolve) { var cbId = 'cb_' + (self._cbIndex++); self._callbacks[cbId] = resolve; var msg = JSON.stringify({ type: 'command', action: action, payload: payload, callbackId: cbId }); cordova.exec(null, null, 'UnityIonic', 'postMessage', [msg]); }); }, _onNativeMessage: function(json) { var msg = JSON.parse(json); if (msg.callbackId && this._callbacks[msg.callbackId]) { this._callbacks[msg.callbackId](msg); delete this._callbacks[msg.callbackId]; } } }; module.exports = UnityIonic;页面里这样用:
import { Component } from '@angular/core'; declare var UnityIonic: any; @Component({ selector: 'app-ar-view', template: '<ion-content></ion-content>' }) export class ArViewPage { async startAR() { await UnityIonic.show(); const result = await UnityIonic.postMessage('startScan', { targetId: 'abc123' }); console.log('扫描已启动', result); } }postMessage返回 Promise,Unity 执行完通过callbackId回执,JS 层 resolve。这样 Ionic 页面写起来和调普通异步接口没区别。
4. 避坑排查:真机联调时最容易翻车的五个点
4.1 Android 黑屏但日志无报错
现象:show()调用后 Unity 容器可见,但画面全黑,Logcat 里只有UnityPlayer初始化日志,没有异常。
原因:Unity 导出的unityLibrary里AndroidManifest.xml声明的UnityPlayerActivity和 Cordova 的CordovaActivity冲突,或者unityLibrary的build.gradle里minSdkVersion低于 Cordova 主工程。
解决:把unityLibrary的minSdkVersion对齐到主工程(一般 24 以上),并且不要用UnityPlayerActivity,改用UnityPlayer直接构造 View 的方式,就是 3.2 里那种写法。
4.2 iOS 上 Unity 视图盖住整个屏幕无法关闭
现象:show()之后 Unity 视图全屏,Ionic 的导航栏和按钮都点不到。
原因:unityView.frame设成了self.viewController.view.bounds,而且加在了最上层。
解决:给 Unity 视图留出安全区域,或者用一个容器 View 控制大小。常见做法是unityView.frame = CGRectMake(0, 88, width, height - 88),顶部留出导航栏高度。关闭时调hide()把视图移除或隐藏。
4.3 消息发送成功但 Unity 收不到
现象:postMessage返回成功,但 Unity 侧OnNativeMessage没执行。
原因:UnitySendMessage的第一个参数是 GameObject 名字,必须和场景里挂UnityIonicBridge脚本的物体名字完全一致。很多人脚本挂在Main Camera上却传"Bridge"。
解决:在 Unity 场景里新建一个空物体命名为Bridge,把脚本挂上去。或者改代码里的名字和实际物体对齐。iOS 侧同理,sendMessageToGOWithName的第一个参数也是物体名。
4.4 Vuforia 相机权限被 Cordova 拦截
现象:Unity 场景加载后 AR 相机不启动,提示无权限。
原因:Cordova 的cordova-plugin-camera或权限插件在onResume时重新申请权限,和 Vuforia 的相机初始化抢资源。
解决:在config.xml里显式声明相机权限,并且在 Unity 视图show()之前确保权限已授予。Android 侧可以在UnityIonicPlugin.show里先检查CAMERA权限,没有就申请,授予后再初始化 UnityPlayer。
4.5 返回 Ionic 页面后 Unity 不暂停导致耗电
现象:从 AR 页面返回后,Unity 场景还在后台跑,手机发热。
原因:hide()只做了setVisibility(GONE),没有调unityPlayer.pause()。
解决:hide里加unityPlayer.pause(),show里加unityPlayer.resume()。iOS 侧对应[ufw pause:YES]和[ufw pause:NO]。这个细节不做,用户手机半天就没电,属于血泪经验。
5. 二次开发:换 AR 引擎、加消息类型与性能调优的实操思路
5.1 把 Vuforia 换成其他 AR 引擎
这份资源虽然标注了 Vuforia 测试通过,但桥接层和 AR 引擎是解耦的。换引擎只需要改 Unity 侧UnityIonicBridge.cs里StartScan的实现,原生层和 JS 层完全不用动。比如换成 AR Foundation,把StartScan里的 Vuforia 初始化换成ARSession启用即可。消息协议不变,action还是startScan,payload里传的参数按新引擎调整。
5.2 扩展消息类型支持二进制数据
当前协议只传 JSON 字符串,传图片或点云数据会很大。我一般会加一个type: "binary"的消息,payload里放 base64,或者更高效的做法是原生层开一个临时文件通道,消息里只传文件路径。Android 用getExternalFilesDir,iOS 用NSTemporaryDirectory,两边约定同一个相对路径规则。这样 Unity 侧读文件、JS 侧读文件,避免大字符串跨桥序列化的开销。
5.3 性能调优的几个参数
| 参数 | 默认值 | 建议值 | 说明 |
|---|---|---|---|
| Unity 渲染分辨率 | 屏幕原生 | 0.8x | 在UnityPlayer初始化前设置,降低 GPU 压力 |
| 消息批量发送间隔 | 无 | 50ms | JS 侧攒一批消息再发,减少跨桥次数 |
| Unity 帧率 | 60 | 30 | AR 场景 30 帧足够,省电 |
| 纹理压缩格式 | 默认 | ASTC | Android 和 iOS 都支持,包体更小 |
这些参数在UnityIonicPlugin初始化时通过UnityPlayer的setFrameRate或 Unity 侧Application.targetFrameRate设置。分辨率调整在 Android 上可以用unityPlayer.setResolution,iOS 上在UnityFramework启动前配置。
5.4 一个我踩过的坑:消息回调 ID 冲突
早期版本我用自增整数做callbackId,结果 Unity 侧异步执行完回传时,JS 层已经发了新消息,ID 对不上。后来改成cb_加时间戳加随机数,冲突概率降到零。从那以后我每次设计跨端消息协议,都强制要求callbackId全局唯一且带前缀,不管两端是不是同一套语言。这个习惯帮我省了很多排查时间。
如果你手里正好有 Ionic 项目要加 3D 或 AR 模块,这份资源里的插件结构和桥接代码可以直接拿去改,Android 和 iOS 的坑基本都标出来了。希望帮到你。
本文还有配套的精品资源,点击获取