☰
Unity 嵌入 Ionic 实战:Cordova 双向桥接与 AR 联调
2026/10/26 21:49:45 网站建设 项目流程

简介:面向需要在 Ionic 混合应用中嵌入 Unity 3D 内容的 Android/iOS 开发者,这份集成指南聚焦 Cordova 插件桥接、双向消息通信与 Vuforia 增强现实场景,适合具备移动端和 Unity 基础、希望打通原生层与 Web 层通信的中级开发者参考。压缩包共 21 个文件、约 424KB,涵盖 png 截图、mm/m/h/java/cs 等原生与 Unity 侧源码、xml/json 插件配置、xcconfig 工程设置及 md 说明,并附 jar 依赖和 js 调用脚本,便于理解插件目录组织与两端通信链路。已有 228 人学习,说明该方案在 Unity 与 Ionic 结合场景中具有参考价值。读者可获得 Android Studio 与 Xcode 下的集成思路、Cordova 插件配置范例、Unity 向 Ionic 发送消息及反向调用的实现线索,并借助 Vuforia 测试背景排查原生桥接与 AR 项目落地中的常见问题。

1. 先算一笔账:重写 3D 界面,还是塞进现有 Ionic 壳

某个做设备巡检的团队,手上那套 Ionic 应用已经跑了三年,工单、点检、报表全在上面。新需求是加一段三维装配演示加 AR 识别界面。用 WebGL 硬啃,模型一多帧率就掉到十几帧;推翻重来,业务侧不接受。剩下的路只有一条:把 Unity 导出的原生产物挂到 Ionic 的 Cordova 插件层,让 Web 层和 Unity 场景通过一条消息通道互通。这份资源就是这条链路的可运行工程——Android 的 unityLibrary、iOS 的 UnityFramework、Cordova 插件里的 JS 与 Java、Objective-C++ 三层桥接,还包括在 AR 识别插件(Vuforia)场景下的实测记录。适合已经能跑通ionic cordova build、但卡在“Unity 工程怎么嵌、消息怎么来回传”的移动端开发者。

2. 拆开链路:Unity 导出产物与 Ionic 壳工程怎么对接

2.1 导出的是模块,不是 APK

很多人第一次做集成,卡在第一步:Unity 的 Build 按钮点完,拿到一个 apk,然后就不知道往哪放了。集成场景里不能这么干,必须走 Export Project。打开 File → Build Settings,切到 Android,勾上 Export Project,再点 Export,得到一个 Gradle 工程目录。里面通常有两个模块:launcher是壳 Activity,unityLibrary才是引擎本体——渲染、资源、托管代码全在这一层。

# Unity 导出后的典型目录(模块化工程) unity-export/ ├── launcher/ # 壳 Activity,集成时基本不用 │ └── src/main/AndroidManifest.xml ├── unityLibrary/ # 真正的引擎模块,整个拷走 │ ├── build.gradle # 里面有 unityStreamingAssets 等开关 │ ├── libs/unity-classes.jar │ └── src/main/ │ ├── assets/bin/Data/ # 场景、资源包、globalgamemanagers │ ├── java/com/unity3d/player/UnityPlayerActivity.java │ └── jniLibs/ # arm64-v8a / armeabi-v7a 的 libunity.so、libil2cpp.so └── settings.gradle

我一般只搬unityLibrary,launcher直接丢掉。原因很实际:Ionic 的 Activity 由 Cordova 生成,塞第二个壳 Activity 进去只会让启动路径变成两条,后面处理返回键、推送跳转、深链全要写两份。

放的位置也有讲究。直接把unityLibrary拷进platforms/android/下,cordova platform rm android一条命令就给你清得干干净净,血泪经验。稳妥做法是把模块放在工程根目录的native/unityLibrary,然后在platforms/android/settings.gradle里用projectDir指过去,这样重装平台目录不影响原生模块。这个文件每次cordova prepare会被覆盖,所以得靠钩子脚本或者config.xml的resource-file重新写回,后面第 4 章会给出具体写法。

iOS 侧产物结构不一样,是 Framework 加一堆散文件。同样只看清单,别看目录层级。

平台核心产物关键文件集成时的用途
AndroidunityLibrary 模块libs/unity-classes.jar、jniLibs/*/libunity.soGradle 依赖,编译期和运行期都要
Androidassets 目录assets/bin/Data/*场景与资源,构建时打进 APK
iOSUnityFramework.frameworkHeaders/UnityFramework.h引擎入口,单例获取
iOSUnityAppController.h/.mm生命周期与视图的接管点
iOSData 目录Data/globalgamemanagers等需要作为 bundle 资源嵌入
双端场景配置GameObject 名称、方法名消息寻址的唯一定位依据

2.2 Android 侧:Activity 还是 View,这是第一个分叉

Android 上集成 Unity 有两条完全不同的路,选错了后面很难回头。

方案 A 是把 Unity 的UnityPlayerActivity当作独立 Activity,Ionic 页面通过startActivity拉起,看完按返回键回到 Web 页面。优点是隔离干净,Unity 崩了不拖累主流程;缺点是每次切换都要重建引擎上下文,冷启动肉眼可见地慢,而且两个界面之间没法做叠加效果,透明 WebView 盖在 3D 场景上这种事想都别想。

方案 B 是把UnityPlayer的 View 直接挂到当前 Activity 的视图树里,和 WebView 共存。代价是生命周期、焦点、层级都得自己管,好处是消息通道短、切换没有黑屏、能做半透明覆盖层。做巡检、拆装演示这类需要“边看 3D 边点表单”的场景,我一般选 B。

// 在 Cordova 插件里把 Unity 的视图挂到 Activity 的内容根上 private UnityPlayer player; private void attachUnity() { Activity activity = cordova.getActivity(); if (player != null) return; // 幂等,重复挂会拿到两个引擎实例 player = new UnityPlayer(activity); // 构造即启动引擎,耗时几百毫秒到数秒 FrameLayout.LayoutParams lp = new FrameLayout.LayoutParams( FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT); activity.addContentView(player.getView(), lp); // 挂到 android.R.id.content player.requestFocus(); // 不请求焦点,Unity 收不到触摸 }

这段代码有三个参数值得盯住。UnityPlayer的构造参数必须是Activity而不是Context,传 Application Context 会在创建 Surface 的时候抛异常,日志里看不出来,只看到黑屏。addContentView把视图挂到内容根上,意味着它盖在整个 WebView 之上,所以显示隐藏要靠setVisibility,不是removeView——后者会销毁 Surface,再挂回去要重新初始化。requestFocus是最容易漏的一句,漏了以后现象特别迷惑:场景正常渲染、动画在跑,就是点不动,很多人会跑去查 Unity 的 Input 设置,其实问题在 Android 焦点。

生命周期必须自己转发,Cordova 插件里重写这几个回调就行:

@Override public void onPause(boolean multitasking) { if (player != null) player.pause(); // 不转发:音频不停、渲染线程空转耗电 } @Override public void onResume(boolean multitasking) { if (player != null) player.resume(); } @Override public void onDestroy() { if (player != null) { player.quit(); player = null; } // quit 而不是 pause,彻底释放 }

quit()会真正销毁引擎,再次进入页面要重新new UnityPlayer,冷启动时间会回来。如果页面进出很频繁,把quit换成pause加视图隐藏,用内存换时间,具体看设备档位。

2.3 iOS 侧:Framework 只能加载一次

iOS 的坑比 Android 更硬。Unity 导出的是UnityFramework.framework,它是个单例封装,[UnityFramework getInstance]拿到的实例全局唯一,第二次调用直接崩;unload之后再load也一样崩。所以“退出页面就卸载、进来再加载”这个思路在 iOS 上不可行,必须做常驻。

// UnityBridgePlugin.mm 里初始化 Unity 运行时 - (void)pluginInitialize { self.ufw = [UnityFramework getInstance]; // 多 Unity 工程共存时必须区分数据目录,单工程也建议显式指定 [self.ufw setDataBundleId:"com.demo.unitybridge.unity"]; // runUIApplicationMainWithArgc 全生命周期只能调一次,重复调用必崩 [self.ufw runUIApplicationMainWithArgc:0 argv:NULL]; self.ufw.appController.delegate = self; // 接管生命周期回调 }

setDataBundleId传的是 Unity 数据所在的 bundle 标识,对应你拖进工程的 Data 目录。这里有个细节:如果不设置,Unity 会去主 bundle 里找Data,多工程场景下就撞车了。argv可以传启动参数,一般给NULL。

视图挂载比 Android 稍微绕一点,因为 Cordova 的self.view层级里还套着 WebView:

UIView *unityView = [[[UnityFramework getInstance] appController] rootView]; unityView.frame = self.webView.superview.bounds; unityView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; [self.webView.superview insertSubview:unityView belowSubview:self.webView]; // 关键:插在 WebView 下面

用insertSubview:belowSubview:而不是addSubview:,是因为后面要做透明 WebView 叠加;直接 add 会把 WebView 盖死,表现是页面白屏但引擎在跑。autoresizingMask不设置的话,横竖屏切换后 Unity 视图尺寸不对,画面被拉伸成一条。

3. 双向消息通道:Cordova 插件三层桥接怎么写

3.1 plugin.xml 与 JS 层:先把接口收口成一个对象

桥接最容易写乱的地方是接口。Android 一套方法名、iOS 一套、JS 又包一层,最后调用方要在业务代码里判断平台。正确做法是 JS 层只暴露一个对象,平台差异全部烂在原生层。

<!-- plugin.xml:声明 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/unitybridge.js" name="UnityBridge"> <clobbers target="UnityBridge" /> <!-- 挂到 window.UnityBridge --> </js-module> <platform name="android"> <config-file target="res/xml/config.xml" parent="/*"> <feature name="UnityBridge"> <param name="android-package" value="com.demo.unitybridge.UnityBridgePlugin"/> </feature> </config-file> <source-file src="src/android/UnityBridgePlugin.java" target-dir="src/com/demo/unitybridge"/> </platform> <platform name="ios"> <config-file target="config.xml" parent="/*"> <feature name="UnityBridge"> <param name="ios-package" value="UnityBridgePlugin"/> </feature> </config-file> <source-file src="src/ios/UnityBridgePlugin.mm"/> <!-- 必须是 .mm --> <framework src="src/ios/UnityFramework.framework" embed="true" custom="true"/> </platform> </plugin>

三个参数必须对齐,否则插件装上了但调用时报Class not found:feature name要和 JS 里exec的 service 名一致(这里是UnityBridge),android-package要是完整包名,ios-package只写类名不带模块前缀。.mm后缀不能写成.m,因为桥接文件里要引 UnityFramework 的 C++ 符号,.m在链接阶段会报一堆 undefined symbol,这个错误信息很难看出根因。

JS 层用 Promise 包一层,同时留一个事件回调口:

// www/unitybridge.js var exec = require('cordova/exec'); var SERVICE = 'UnityBridge'; var handlers = []; // Unity 主动推上来的消息 function call(action, payload) { return new Promise(function (resolve, reject) { exec(resolve, reject, SERVICE, action, [payload || {}]); }); } module.exports = { // 启动引擎并挂载视图,幂等 init: function (options) { return call('init', options); }, // 向内发消息,type 由业务定义 post: function (type, data) { return call('post', { type: type, data: data || {} }); }, // 订阅 Unity 回调,返回取消函数 on: function (handler) { handlers.push(handler); return function () { var i = handlers.indexOf(handler); if (i > -1) handlers.splice(i, 1); }; }, // 只给原生层调用 _emit: function (json) { var msg = typeof json === 'string' ? JSON.parse(json) : json; handlers.slice().forEach(function (h) { h(msg); }); } };

init做成幂等是有原因的:Ionic 的页面栈里,同一个页面可能被 push 两次,ionViewDidEnter触发两次,如果init每次都创建引擎,第二次就会拿到第二个实例,内存直接翻倍。

3.2 Android 侧:静态通道与线程切回

Android 侧要解决两个方向。JS 到 Unity 方向,用UnityPlayer.UnitySendMessage,它有硬约束:必须在主线程调用。Unity 到 JS 方向,得给 Unity 的 C# 一个静态方法入口,因为 C# 那边只能通过AndroidJavaClass反射调静态方法。

package com.demo.unitybridge; import android.app.Activity; import android.os.Handler; import android.os.Looper; import com.unity3d.player.UnityPlayer; import org.apache.cordova.*; import org.json.*; import android.util.Log; public class UnityBridgePlugin extends CordovaPlugin { private static final String TAG = "UnityBridge"; private static final String GO_NAME = "Bridge"; // 与 Unity 场景里的 GameObject 同名 private static CallbackContext eventContext; // 长连接,用于原生 → JS 推送 private final Handler main = new Handler(Looper.getMainLooper()); private UnityPlayer player; @Override public boolean execute(String action, JSONArray args, CallbackContext cb) { if ("init".equals(action)) { if (eventContext == null) { // 第一次调用才建立事件通道 eventContext = cb; PluginResult keep = new PluginResult(PluginResult.Status.NO_RESULT); keep.setKeepCallback(true); // 不设这个,回调只能触发一次 cb.sendPluginResult(keep); } attachUnity(); // 见 2.2 的视图挂载 PluginResult ok = new PluginResult(PluginResult.Status.OK, "ready"); ok.setKeepCallback(true); cb.sendPluginResult(ok); return true; } if ("post".equals(action)) { final String json = args.optJSONObject(0).toString(); main.post(new Runnable() { // 必须切主线程 @Override public void run() { UnityPlayer.UnitySendMessage(GO_NAME, "OnNativeMessage", json); } }); cb.success("queued"); return true; } return false; } // 供 Unity 的 C# 通过 AndroidJavaClass 反射调用 public static void postToJs(final String json) { new Handler(Looper.getMainLooper()).post(new Runnable() { @Override public void run() { if (eventContext == null) return; PluginResult r = new PluginResult(PluginResult.Status.OK, json); r.setKeepCallback(true); // 保持通道,下次还能推 eventContext.sendPluginResult(r); } }); } }

几个参数要盯死。GO_NAME是 Unity 场景里挂接收脚本的 GameObject 名字,两边必须逐字一致,大小写敏感,名字错了UnitySendMessage不报错,只是静默丢弃,这是最坑的一种失败模式。postToJs里又包了一层Handler,是因为 Unity 的 C# 调用发生在渲染线程,直接操作 Cordova 回调会抛CalledFromWrongThreadException。setKeepCallback(true)出现两次不是冗余:一次保持 JS 侧的事件订阅,一次保持每次推送后通道存活,少了任何一个,第二条消息就收不到了。

3.3 iOS 侧:Objective-C++ 里的两个出口

iOS 方向反过来:Unity 到 JS 走sendMessageToGOWithName,JS 到 Unity 用一个extern "C"函数给 C# 调。C# 的DllImport只认 C 符号,所以必须extern "C"并且关掉名字修饰(__Internal就够)。

// UnityBridgePlugin.mm #import <Cordova/CDV.h> #import <UnityFramework/UnityFramework.h> @interface UnityBridgePlugin : CDVPlugin @property (nonatomic, strong) NSString *eventCallbackId; @end @implementation UnityBridgePlugin - (void)init:(CDVInvokedUrlCommand *)command { self.eventCallbackId = command.callbackId; // 存下来,后面多次回调 CDVPluginResult *r = [CDVPluginResult resultWithStatus:CDVCommandStatus_NO_RESULT]; [r setKeepCallbackAsBool:YES]; [self.commandDelegate sendPluginResult:r callbackId:self.eventCallbackId]; } - (void)post:(CDVInvokedUrlCommand *)command { NSDictionary *payload = [command.arguments objectAtIndex:0]; NSData *data = [NSJSONSerialization dataWithJSONObject:payload options:0 error:nil]; NSString *json = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; // 主线程发,与 Android 侧约束一致 dispatch_async(dispatch_get_main_queue(), ^{ [[UnityFramework getInstance] sendMessageToGOWithName:"Bridge" functionName:"OnNativeMessage" message:[json UTF8String]]; }); CDVPluginResult *ok = [CDVPluginResult resultWithStatus:CDVCommandStatus_OK]; [self.commandDelegate sendPluginResult:ok callbackId:command.callbackId]; } @end // 给 C# 的 DllImport("__Internal") 用的 C 符号,名字必须与 C# 端完全一致 extern "C" void UnityBridge_postToJs(const char *json) { NSString *payload = [NSString stringWithUTF8String:json]; UnityBridgePlugin *plugin = [UnityBridgePlugin sharedInstance]; // 需自行实现单例持有 dispatch_async(dispatch_get_main_queue(), ^{ CDVPluginResult *r = [CDVPluginResult resultWithStatus:CDVCommandStatus_OK messageAsString:payload]; [r setKeepCallbackAsBool:YES]; [plugin.commandDelegate sendPluginResult:r callbackId:plugin.eventCallbackId]; }); }

DllImport("__Internal")意味着符号在可执行文件里解析,所以.mm文件必须参与 App target 的编译,只加到 Framework target 里会链接不上。extern "C"不加的话 C# 侧会报EntryPointNotFoundException,报错信息只告诉你找不到入口,不告诉你是名字修饰问题,很多人会去怀疑 Framework 没嵌入,方向就跑偏了。

Unity 侧接收和发送就十来行:

using UnityEngine; using System.Runtime.InteropServices; public class UnityBridgeReceiver : MonoBehaviour { // iOS 侧导入 C 符号,Android 侧走 AndroidJavaClass #if UNITY_IOS && !UNITY_EDITOR [DllImport("__Internal")] private static extern void UnityBridge_postToJs(string json); #endif void Awake() { DontDestroyOnLoad(gameObject); // 场景切换时不销毁,否则消息断链 } // 原生调用过来的入口,方法名必须与原生侧一致 public void OnNativeMessage(string json) { Debug.Log("[Unity] recv: " + json); } // Unity 主动发消息给 Web 层 public void SendToJs(string json) { #if UNITY_ANDROID && !UNITY_EDITOR using (var cls = new AndroidJavaClass("com.demo.unitybridge.UnityBridgePlugin")) { cls.CallStatic("postToJs", json); } #elif UNITY_IOS && !UNITY_EDITOR UnityBridge_postToJs(json); #endif } }

DontDestroyOnLoad这行容易被忽略。场景一换,挂接收脚本的 GameObject 被销毁,原生再调UnitySendMessage就没人接了,现象是“从第二个场景开始消息全丢”。OnNativeMessage里的字符串是UnitySendMessage的第三参数,长度有上限,超过大约 8KB 在部分机型上会被截断,大数据量走文件或者分片协议。

3.4 消息协议:先定信封,再写业务

两边各写一套字段是最常见的返工来源。定一个固定信封,type表示业务动作,data是载荷,seq用于请求响应配对,err只在失败时出现。

{ "type": "scene.load", "seq": 17, "data": { "sceneId": "pump-a01", "autoPlay": true }, "err": null }

type用点号分域(scene.、ar.、ui.),后面做路由分发时可以直接按前缀拆模块。seq是递增整数,JS 侧存一个 Promise 表,收到同seq的响应就 resolve,超时统一 reject,避免业务层自己写计时器。载荷里只放可序列化数据,不要放二进制,贴图和大模型走本地文件路径传参。

4. 构建与联调:从 cordova build 到真机首帧

4.1 Gradle 合并:模块路径与依赖顺序

platforms/android是生成目录,手改会被覆盖。我一般把原生模块放在工程根的native/下,然后靠钩子脚本在after_prepare阶段写回两个文件。

// hooks/after_prepare/inject_unity.js var fs = require('fs'); var path = require('path'); module.exports = function (ctx) { var root = ctx.opts.projectRoot; var androidDir = path.join(root, 'platforms', 'android'); // 1. settings.gradle 里注册模块,projectDir 指向工程根的 native 目录 var settingsPath = path.join(androidDir, 'settings.gradle'); var settings = fs.readFileSync(settingsPath, 'utf8'); if (settings.indexOf('unityLibrary') === -1) { settings += "\ninclude ':unityLibrary'\n" + "project(':unityLibrary').projectDir = new File('" + path.join(root, 'native', 'unityLibrary').replace(/\\/g, '/') + "')\n"; fs.writeFileSync(settingsPath, settings); } // 2. app 的 build.gradle 加依赖 var appGradle = path.join(androidDir, 'app', 'build.gradle'); var gradle = fs.readFileSync(appGradle, 'utf8'); if (gradle.indexOf("project(':unityLibrary')") === -1) { gradle = gradle.replace(/dependencies\s*\{/, "dependencies {\n implementation project(':unityLibrary')\n"); fs.writeFileSync(appGradle, gradle); } };

脚本里两个indexOf判断是幂等保护,cordova prepare会反复触发钩子,不去重就会写出十几行重复依赖,Gradle 报错信息极其难读。projectDir的路径要转成反斜杠无关的写法,Windows 上不转的话路径里的\被当成转义,模块加载失败。

ABI 过滤要单独提一句。Unity 2022 之后默认只出arm64-v8a,如果你的 Ionic 工程里还有别的插件塞了armeabi-v7a的.so,打包会报APK 32-bit 与 64-bit 混合或者干脆安装后崩溃。最省事的做法是在 app 的build.gradle里显式声明:

android { defaultConfig { ndk { abiFilters 'arm64-v8a' } // 只保留 64 位,和 Unity 导出的 ABI 对齐 } }

abiFilters填的必须是 Unity 导出时jniLibs下真实存在的目录名。填了个不存在的,Gradle 不报错,APK 里就是没有.so,装上去秒崩。

4.2 iOS 工程:Embed and Sign 与架构裁剪

iOS 侧要处理的不是依赖,是嵌入方式。UnityFramework.framework必须设置成Embed & Sign,设成Do Not Embed的话编译能过,运行到getInstance时直接dyld: Library not loaded。

用 Cordova 的plugin.xml声明时,<framework>标签上的embed="true"就是对位这个设置,custom="true"告诉 Cordova 这是工程内路径而不是系统框架。两个属性少一个,Xcode 工程里的配置就跟你手工拖进去的效果不一样。

另一个常见故障是模拟器切片。Unity 导出的 Framework 里有时候带着x86_64,真机构建能过,上传校验时被拒。检查用lipo:

# 查看 Framework 里包含哪些架构 lipo -info native/UnityFramework.framework/UnityFramework # 真机发布前剔除模拟器切片,构建脚本里跑一次 lipo -remove x86_64 native/UnityFramework.framework/UnityFramework \ -output native/UnityFramework.framework/UnityFramework

lipo -remove是原地覆写,跑之前先备份原始 Framework,不然下次要出模拟器包还得重新从 Unity 导一次。-output写成同一个路径是允许的,但有些版本的lipo会警告,稳妥做法是先输出到临时文件再mv回去。

4.3 联调三步验证法

装到真机上第一件事不是看画面,是先确认消息通没通,否则画面正常、消息不通,你会以为是插件没注册。

第一步,只验证 JS 到原生。在init之后立刻post('ping', {}),原生侧只打日志不发给 Unity,看到日志说明插件注册和exec链路对了。

第二步,验证原生到 Unity。在post里直接调UnitySendMessage,Unity 侧OnNativeMessage打日志,真机连adb logcat -s Unity或者 Xcode 控制台看。这一步过了,说明 GameObject 名字和方法名对上了。

第三步,才验证回程。给 Unity 场景加一个按钮触发SendToJs,看 JS 的on回调有没有收到。三步拆开跑,出问题能立刻定位是在哪一段断的,比一次性全接上再排查快得多。

5. 避坑与排查:五条血泪记录

5.1 构建与冷启动阶段的三个坑

现象一:装上去白屏,日志里没有任何 Unity 相关输出。原因多半是 ABI 不匹配。Ionic 工程里其他插件带了 32 位.so,abiFilters又没限制,Gradle 打出来的 APK 里 Unity 的libunity.so被挤掉了,或者设备是 64 位但只装了 32 位切片。解决方法是先unzip -l app-debug.apk | grep libunity看.so到底在不在包里,在的话再adb shell getprop ro.product.cpu.abi看设备架构,两头对不上就回到 4.1 改abiFilters。

现象二:iOS 编译通过,运行到插件初始化就闪退,堆栈停在getInstance。原因是 Framework 嵌入方式不对,或者被加载了两次。先查 Xcode 的 General 页,UnityFramework.framework必须是Embed & Sign;再全局搜[UnityFramework getInstance]和runUIApplicationMainWithArgc,确认这两处都只在一个地方调用。页面切换时重复调用pluginInitialize是个隐蔽触发点,用dispatch_once包一层。

现象三:Unity 视图出来了,但场景里点不动,UI 完全没响应。原因就是requestFocus()没调。addContentView之后焦点还在 WebView 上,Unity 的 Surface 收不到触摸事件。补上player.requestFocus(),如果是从 WebView 切回 Unity 视图,还要在切回时再调一次。另外检查有没有把 Unity 视图加到belowSubview的位置却忘了把 WebView 的userInteractionEnabled在需要时打开。

5.2 消息通道里的两个坑

现象四:第一条消息能收到,第二条就没了。原因是回调通道被关闭。Cordova 的PluginResult默认回调完就释放上下文,不调setKeepCallback(true)就只能收到一次。Android 侧要在每次sendPluginResult前都设置,包括init阶段那条NO_RESULT。iOS 侧同理,setKeepCallbackAsBool:YES每次都要加,只在init里加一次是不够的。

现象五:Unity 收到消息但解析崩溃,或者字段全是 null。原因是 JSON 被截断。UnitySendMessage的字符串参数走的是 JNI 字符串传递,长度超过约 8KB 在部分机型上会被静默截断,表现就是 JSON 少了个右花括号,JsonUtility.FromJson抛异常或者返回空对象。解决是给协议加长度约束,超过 2KB 的载荷改走文件:JS 把内容写到应用沙箱,只把路径通过消息传过去,Unity 侧读文件。另外JsonUtility不支持顶层数组和字典,载荷顶层必须是对象,这一点在定协议的时候就该写进约束。

6. 进阶:把桥接层收成一条可测的消息总线

写到第五个业务消息的时候,if (type === 'xxx')会开始蔓延到三个平台。我的习惯是在 Unity 侧先立一张路由表,把type前缀映射到处理类,原生层只负责搬运。

using System; using System.Collections.Generic; using UnityEngine; public class MessageBus : MonoBehaviour { // type 前缀 → 处理器,新增业务只注册不改分发逻辑 private readonly Dictionary<string, Action<string, string>> routes = new Dictionary<string, Action<string, string>>(); void Awake() { DontDestroyOnLoad(gameObject); Register("scene.", (type, data) => Debug.Log("scene handler: " + data)); Register("ar.", (type, data) => Debug.Log("ar handler: " + data)); } public void Register(string prefix, Action<string, string> handler) { routes[prefix] = handler; } // 原生入口,方法名与原生侧一致 public void OnNativeMessage(string json) { var envelope = JsonUtility.FromJson<Envelope>(json); foreach (var kv in routes) { if (envelope.type.StartsWith(kv.Key)) { kv.Value(envelope.type, envelope.data); return; // 前缀最长匹配需要的话,改成先排序再遍历 } } Debug.LogWarning("[Bus] 未注册的 type: " + envelope.type); } [Serializable] public class Envelope { public string type; public int seq; public string data; public string err; } }

路由表的收益是可测:给Register传一个假处理器,就能在编辑器里跑完整的消息回环,不需要真机。data字段这里用string存原始 JSON,到了具体处理器再二次反序列化,避免每个消息都定义一个 envelope 子类。前缀匹配的顺序在多级前缀共存时要小心,ar.和ar.marker.同时注册会命中先遍历到的那个,所以我在注册时按长度倒序排一次,保证最长匹配优先。

JS 侧对应地把seq做成请求响应配对,业务代码只写await UnityBridge.post('scene.load', {...}),底层是 Promise 表加超时。测试的时候给post打个桩,直接返回固定信封,页面逻辑就能脱离引擎单跑。

这套结构我是被一次线上问题逼出来的:当时一个type拼错了字母,原生照发、Unity 照收、路由没命中、日志里什么都没有,排查花了整整一个下午。从那以后我每次新增消息类型,都强制先跑一遍编辑器里的回环用例,确认路由表命中了才接真机。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询