简介:面向需要把 Unity 3D 场景嵌入 Ionic 移动应用的开发者,围绕 Cordova 插件封装、Android 与 iOS 双端桥接及 Unity-Ionic 双向消息通信展开,并给出在 Vuforia 增强现实插件下验证过的集成思路。包内共 21 个文件,包含 png 界面与运行截图、mm/m 与 java/h 原生桥接代码、cs 通信脚本、js 调用示例,以及 xml、json、xcconfig 等工程配置,压缩包约 424KB,便于按模块查阅。已有 228 人学习,适合具备移动端和 Unity 基础的中级开发者参考。通过目录中的双端工程文件与通信实现,读者可理解插件声明、消息通道搭建、平台差异处理和 AR 场景联调要点,快速复现 Unity 内容与 Ionic 页面的互调流程,并掌握原生桥接与配置排错的基本路径。
1. Ionic 应用里嵌 Unity AR:一条消息通道要跨过 Java、C# 和 Objective-C++ 三种语言
有人在群里问过一个问题:Ionic 写好的业务 App,想在里面塞一个 Unity 做的 AR 识别页,扫到图之后退回原生页面弹商品卡片,这条路到底能不能走通。能走,而且不用把客户端推倒重来。这套 Unity 与 Ionic 集成方案做的就是这样一件事:把 Unity 导出的 Android 库和 iOS 的 UnityFramework 挂进现有 Ionic 工程,再用一个 cordova-plugin 把两端的消息通道打通,Android 和 iOS 各一套实现,Unity 侧只暴露一个常驻 GameObject 收消息,而且这套链路已经在 Vuforia 插件下跑通过。它适合两类人:手上已经有一个在跑的 Ionic 业务 App、AR 只是一两个页面的团队;以及要把 Vuforia 识别能力嵌进混合应用、又不想维护两套客户端的开发者。下面按我拆这套东西的顺序讲,先对齐两端产物形态,再搭桥,最后讲 Vuforia 和那些让我返工过的坑。
2. 产物形态对齐:unityLibrary 与 UnityFramework 各自要拷贝哪几个目录
2.1 为什么不能直接把 APK 或者 IPA 拼进去
混合应用接 Unity 有两种常见思路。一种是把 Unity 场景编译成独立 App,从 Ionic 里用 scheme 跳转唤起,靠 URL 参数传值;另一种是把 Unity 以库的形式编进宿主工程,两边在同一个进程里通信。前一种落地快,但问题很明显:用户在两个 App 之间来回切,任务栈被打断,AR 扫完图回来时 Ionic 的页面状态大概率已经重建;数据也只能走 URL 或者剪贴板,几十 KB 的识别结果根本没法传。
这套方案走的是后一种。Unity 在 Build Settings 里勾上 Export Project,导出的 Gradle 工程里带一个unityLibrary模块,这个模块才是要的东西,里面是 IL2CPP 编出来的 so、Unity 的 Java 胶水层和资源包;旁边的launcher模块只是一个空壳 Activity,删掉不影响任何功能。iOS 侧对应的是UnityFramework,也就是「Unity as a library」那种形态,导出的 Xcode 工程里有Unity-iPhone和UnityFramework两个 target,宿主只需要后者编出来的 framework 加上Data目录。
| 产物 | 来源 | 要拷贝的东西 | 可以直接丢掉 |
|---|---|---|---|
| Android | Unity 导出工程 | unityLibrary/(含src/main/jniLibs、assets、libs) | launcher/、根build.gradle的插件配置 |
| iOS | Unity 导出工程 | UnityFramework.framework、Data/、Classes/Native编出来的产物 | Unity-iPhonetarget、MainApp里的 storyboard |
| 公共 | Unity 场景 | 桥接用的常驻 GameObject 与 C# 脚本 | 示例场景、Editor 目录下的测试脚本 |
这张表是我踩过坑之后确定的边界:Android 侧曾经有人把整个导出工程塞进android/,结果根build.gradle里的插件版本和 Ionic 工程冲突,Gradle 同步直接失败。只拿unityLibrary,剩下的都不要,能省掉一大半麻烦。
2.2 Android:把 unityLibrary 变成一个 Gradle module
第一步是把 Unity 导出工程里的unityLibrary整个目录拷到 Ionic 工程的android/unityLibrary。注意是拷到android/根下,不是android/app/下,后面 Gradle 里的相对路径都基于这个位置。接着在android/settings.gradle里注册模块:
// android/settings.gradle include ':app' include ':unityLibrary' // 指向刚才拷进来的目录,路径写错的表现是 sync 时报 "project with path ':unityLibrary' could not be found" project(':unityLibrary').projectDir = new File(rootProject.projectDir, 'unityLibrary')这段的作用是把unityLibrary变成和app平级的 Gradle 子项目。projectDir这一行必须写绝对定位,只写include不写projectDir,Gradle 会去android/unityLibrary找,找不到就报 sync 失败。
然后在android/app/build.gradle的dependencies里加依赖:
// android/app/build.gradle dependencies { implementation fileTree(dir: 'libs', include: ['*.jar']) implementation project(':unityLibrary') // 关键:把 Unity 的类和 so 合进主包 implementation "androidx.appcompat:appcompat:1.6.1" } android { // Unity 导出的 so 默认是压缩进 apk 的,宿主必须允许解压,否则运行时 dlopen 失败 packagingOptions { jniLibs { useLegacyPackaging = true } // Vuforia 和部分插件会带同名 so,这里的 pickFirst 是保命的 pickFirst '**/libc++_shared.so' pickFirst '**/libmain.so' } }implementation project(':unityLibrary')是整条链路的根,少了它编译期就找不到com.unity3d.player.UnityPlayerActivity。useLegacyPackaging = true对应的是android:extractNativeLibs,Unity 的加载器默认按「解压后加载」的方式找 so,如果宿主强制extractNativeLibs="false",表现就是启动 Unity 页直接闪退,logcat 里一句dlopen failed: library "libmain.so" not found。pickFirst两个 so 是跟 Vuforia 一起用时必须加的,后面第 5 章会展开。
接下来改 Manifest。unityLibrary自带的AndroidManifest.xml里,UnityPlayerActivity是带MAIN/LAUNCHER的,直接合并进来桌面会多一个图标,点开还直接进 Unity。手动把那两个 intent-filter 删掉:
<!-- android/unityLibrary/src/main/AndroidManifest.xml --> <activity android:name="com.unity3d.player.UnityPlayerActivity" android:theme="@style/UnityThemeSelector" android:screenOrientation="fullSensor" android:exported="false" android:configChanges="mcc|mnc|locale|touchscreen|keyboard|keyboardHidden|navigation|orientation|screenLayout|uiMode|screenSize|smallestScreenSize|fontScale|layoutDirection|density"> <!-- 原来的 <intent-filter> MAIN/LAUNCHER 必须删掉,否则会出现两个启动入口 --> </activity>exported="false"是 Android 12 之后的硬要求,没有 intent-filter 的 Activity 不声明 exported 会直接编译不过。configChanges那一长串不要精简,Unity 靠它接管旋转和键盘事件,少一项就会出现切后台回来画面拉伸。
最后在宿主app的 Manifest 里加一条元数据,告诉 Unity 自己跑在一个子 Activity 里:
<!-- android/app/src/main/AndroidManifest.xml --> <application ...> <meta-data android:name="unityplayer.UnityActivity" android:value="true" /> <uses-feature android:name="android.hardware.camera" android:required="false" /> </application>unityplayer.UnityActivity这一条漏掉的后果很隐蔽:Unity 页能起来,但输入事件和生命周期全乱,按返回键不退出、切后台相机不释放。uses-feature加required="false"是为了不让没有摄像头的设备在应用市场被过滤掉,业务上再自己判断是否降级。
2.3 iOS:UnityFramework 的嵌入与启动时机
iOS 侧没有 Gradle 那种模块机制,操作全在 Xcode 里。先用 Unity 导出 Xcode 工程,然后在导出工程里单独选UnityFrameworkscheme 编一次,产物在Products/UnityFramework.framework。把这个 framework 和导出工程根目录的Data文件夹一起拷到 Capacitor 的ios/App下,再在 Xcode 里做三件事:
第一,UnityFramework.framework加到 target 的Frameworks, Libraries, and Embedded Content,Embed 选Embed & Sign,不选 Embed 的话装到真机上启动就崩,报找不到镜像。
第二,Data以 folder reference(蓝色文件夹)的形式加到Copy Bundle Resources,必须是蓝色文件夹,黄色 group 会把内部目录结构打散,Unity 找不到data.unity3d。
第三,在AppDelegate里让 Unity 的 runloop 挂到宿主进程上,只跑一次:
// ios/App/App/AppDelegate.swift import UIKit import Capacitor import UnityFramework @UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate { var unity: UnityFramework? func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { unity = UnityFramework.getInstance() // argc/argv 直接透传,Unity 内部要靠它解析启动参数 unity?.runUIApplicationMain(withArgc: CommandLine.argc, argv: CommandLine.unsafeArgv) return true } }runUIApplicationMain这一步是整个 iOS 侧的地基。它把 Unity 的 player loop 挂到宿主 App 的 runloop 上,之后sendMessageToGOWithName才有接收方。漏掉这一步的典型现象是:Ionic 页面正常、Unity 视图也能显示(因为 view 是单独加载的),但所有UnitySendMessage都静默失败,Xcode 控制台里连 Unity 的初始化日志都看不到。如果Data不是放在主 bundle 而是放在子 bundle 里,还要额外调一次setDataBundleId,值填你自己那个 Data bundle 的 identifier。
2.4 一张对齐表:哪些参数两边必须一致
| 参数 | Android 侧位置 | iOS 侧位置 | 不一致的后果 |
|---|---|---|---|
| 包名 / bundle id | app/build.gradle的applicationId | Xcode target 的 Bundle Identifier | Vuforia 授权校验失败,AR 直接不识别 |
| 最低系统版本 | variables.gradle的minSdkVersion | Podfile的platform :ios | 低于 Unity 要求时运行时崩在 so 加载阶段 |
| CPU 架构 | ndk.abiFilters | ARCHS | 缺 arm64 时真机装不上或 AR 掉帧 |
| 脚本后端 | Unity 里选 IL2CPP | 同左 | Mono 后端无法导出 iOS,Android 侧体积也翻倍 |
这张表里的第一行值得单独强调。Vuforia 的 license key 是绑包名的,debug 构建如果用了applicationIdSuffix ".debug",实际包名就变了,表现是识别器初始化返回授权错误,但日志级别低,很容易被当成相机问题排查半天。我现在的习惯是 debug 和 release 用同一个applicationId,靠签名区分环境。
3. 消息桥怎么搭:一条 JSON 信封打通 cordova.exec 与 UnitySendMessage
3.1 先定协议:一条 JSON 信封跑两端
跨三种语言的通道,最容易翻车的地方不是 API 调用,而是协议设计。Android 的UnitySendMessage只接受一个字符串参数,iOS 的sendMessageToGOWithName也只接受一个const char *,所以不管业务上要传几个字段,最后都得塞进一个字符串。我一般会定一个固定的信封结构:
{ "type": "ar.start", "payload": "{\"targetId\":\"stone-01\"}", "seq": 12, "ts": 1730000000000 }type用「域.动作」的命名,Unity 侧一张 switch 表就能路由;payload单独序列化成字符串而不是嵌套对象,因为 Unity 的JsonUtility不支持object类型字段,嵌套对象会静默解析成 null;seq用于日志对齐和去重,两端打同一条 seq,出问题直接 grep;ts用来判断消息是否过期,避免 Unity 页还没起来时排队的消息在几分钟后才被消费。
提示:
payload用字符串是这套协议里唯一不能妥协的地方。改成嵌套对象后 Android 侧没事(用的是 Gson 风格解析),iOS 和 Unity 侧会各挂一次,排查成本极高。
3.2 JS 侧:cordova-plugin 的对外接口
JS 层只做两件事:把业务调用包成 Promise,以及维护一条常驻的订阅回调。注意这里是「一发一收」两条独立通道,发送是一次性的,接收是长回调,不要混在一个 action 里。
// www/UnityBridge.js const exec = require('cordova/exec'); const CHANNEL = 'UnityBridge'; let listener = null; let seqNo = 0; function envelope(type, payload) { return JSON.stringify({ type: type, // payload 统一序列化成字符串,对齐 Unity 侧 JsonUtility 的能力边界 payload: typeof payload === 'string' ? payload : JSON.stringify(payload || {}), seq: ++seqNo, ts: Date.now() }); } function call(action, args) { return new Promise((resolve, reject) => exec(resolve, reject, CHANNEL, action, args)); } const UnityBridge = { init() { return call('init', []); }, open() { return call('open', []); }, post(type, payload) { return call('post', [envelope(type, payload)]); }, onMessage(fn) { listener = fn; }, _dispatch(json) { if (!listener) return; try { listener(JSON.parse(json)); } catch (e) { console.warn('[UnityBridge] bad json', e); } } }; // 常驻订阅:Unity 主动推过来的消息全部从这里进 exec((json) => UnityBridge._dispatch(json), () => {}, CHANNEL, 'subscribe', []); module.exports = UnityBridge;subscribe那条exec放在模块加载时执行,回调永远不 resolve,靠原生侧的setKeepCallback(true)维持。post返回 Promise,原生侧处理完立刻回 OK,不等 Unity 的响应——Unity 的响应走subscribe那条通道回来,用seq对上。这种「发送不等回执、回执走订阅」的设计,好处是 JS 侧不会因为 Unity 页没起来而卡住 Promise,坏处是要自己做超时,第 6 章会给验证用例。
3.3 Android 侧:UnitySendMessage 与静态回调
Android 侧拆成两个类:一个CordovaPlugin负责跟 JS 打交道,一个纯静态的Helper负责跟 Unity 打交道。分开的原因是 Unity 侧要通过AndroidJavaClass反射调进来,类必须是 public 且方法必须是 static,混在 Plugin 里容易被 R8 裁掉。
// android/app/src/main/java/com/demo/unitybridge/UnityBridgeHelper.java package com.demo.unitybridge; import android.os.Handler; import android.os.Looper; import com.unity3d.player.UnityPlayer; public final class UnityBridgeHelper { // 这两个常量必须与 Unity 场景里的 GameObject 名、方法名逐字一致 private static final String UNITY_OBJECT = "NativeBridge"; private static final String UNITY_METHOD = "OnNativeMessage"; private static volatile UnityBridgePlugin sPlugin; private static final Handler MAIN = new Handler(Looper.getMainLooper()); private UnityBridgeHelper() {} static void attach(UnityBridgePlugin plugin) { sPlugin = plugin; } // JS -> Unity:UnitySendMessage 必须在主线程、且 Unity player 已初始化之后调用 static void sendToUnity(final String json) { MAIN.post(() -> UnityPlayer.UnitySendMessage(UNITY_OBJECT, UNITY_METHOD, json)); } // Unity -> JS:由 C# 通过 AndroidJavaClass 反射调用 public static void postToJs(final String json) { MAIN.post(() -> { UnityBridgePlugin p = sPlugin; if (p != null) p.flushToJs(json); }); } }MAIN.post这两处是必需的。UnityPlayer.UnitySendMessage内部会去碰 Unity 的线程本地状态,从 Cordova 的线程池直接调会抛Fatal signal 11;而 C# 通过 JNI 调postToJs时,当前线程是 Unity 的渲染线程,直接操作CallbackContext会导致 WebView 侧消息错乱。两边都用主线程 Handler 收口,是最省心的写法。
Plugin 本体:
// android/app/src/main/java/com/demo/unitybridge/UnityBridgePlugin.java package com.demo.unitybridge; import android.content.Intent; import org.apache.cordova.CallbackContext; import org.apache.cordova.CordovaPlugin; import org.apache.cordova.PluginResult; import org.json.JSONArray; import org.json.JSONException; public class UnityBridgePlugin extends CordovaPlugin { private CallbackContext inbox; // 只保留一条常驻回调 @Override protected void pluginInitialize() { UnityBridgeHelper.attach(this); } @Override public boolean execute(String action, JSONArray args, CallbackContext cb) throws JSONException { switch (action) { case "init": cb.success(); return true; case "open": { Intent i = new Intent(cordova.getActivity(), com.unity3d.player.UnityPlayerActivity.class); cordova.getActivity().startActivity(i); cb.success(); return true; } case "post": UnityBridgeHelper.sendToUnity(args.getString(0)); cb.success(); // 发送立即回执,Unity 的响应走 subscribe 通道 return true; case "subscribe": inbox = cb; // 长回调,这里绝对不能 success 掉 return true; default: return false; } } void flushToJs(String json) { if (inbox == null) return; PluginResult r = new PluginResult(PluginResult.Status.OK, json); r.setKeepCallback(true); // 保持通道不被 Cordova 回收 inbox.sendPluginResult(r); } }pluginInitialize里把this注入 Helper,是整条回传链路的入口,漏了它 Unity 发的消息会全部丢弃且不报错。subscribe分支里那个inbox = cb之后什么都不做,也是刻意的:Cordova 的CallbackContext只要不调sendPluginResult就一直是 pending 状态,加setKeepCallback(true)才能保证第一次回消息之后通道还活着。
3.4 iOS 侧:Objective-C++ 把 extern "C" 接到 CDVPlugin
iOS 侧的文件必须用.mm后缀。原因很简单:Unity 的 C# 侧通过DllImport("__Internal")找一个 C 符号,而 Cordova 插件是 Objective-C 类,两者只能靠 Objective-C++ 在同一份文件里缝合。
// ios/App/App/Plugins/UnityBridgePlugin.mm #import <Cordova/CDV.h> #import <UnityFramework/UnityFramework.h> static NSString *const kUnityObject = @"NativeBridge"; static NSString *const kUnityMethod = @"OnNativeMessage"; @interface UnityBridgePlugin : CDVPlugin @property (nonatomic, copy) NSString *subscribeId; @end static UnityBridgePlugin *gPlugin = nil; // 供 extern "C" 函数反查实例 @implementation UnityBridgePlugin - (void)pluginInitialize { gPlugin = self; } - (void)subscribe:(CDVInvokedUrlCommand *)command { self.subscribeId = command.callbackId; // 存下来,不立刻回结果 } - (void)post:(CDVInvokedUrlCommand *)command { NSString *json = command.arguments.firstObject; [[UnityFramework getInstance] sendMessageToGOWithName:[kUnityObject UTF8String] functionName:[kUnityMethod UTF8String] message:[json UTF8String]]; [self.commandDelegate sendPluginResult:[CDVPluginResult resultWithStatus:CDVCommandStatus_OK] callbackId:command.callbackId]; } - (void)pushToJs:(NSString *)json { if (!self.subscribeId) return; CDVPluginResult *r = [CDVPluginResult resultWithStatus:CDVCommandStatus_OK messageAsString:json]; [r setKeepCallbackAsBool:YES]; [self.commandDelegate sendPluginResult:r callbackId:self.subscribeId]; } @end // C# 侧 DllImport("__Internal") 对应的符号 extern "C" void UnityBridgePostToNative(const char *msg) { NSString *json = [NSString stringWithUTF8String:(msg ?: "")]; dispatch_async(dispatch_get_main_queue(), ^{ [gPlugin pushToJs:json]; // 必须回主线程,CDVPluginResult 不是线程安全的 }); }gPlugin用强引用是有意的,插件实例的生命周期跟 WebView 一致,用 weak 会在某些时机变成 nil,表现是 Unity 发的消息偶尔丢。extern "C"修饰的函数名不能改,C# 侧的DllImport写的就是这个名字,改了链接期不会报错,运行时才在调用点崩。kUnityObject和kUnityMethod跟 Android 侧那两个常量、以及 Unity 场景里的 GameObject 名,四处必须完全一致——这是这套方案里最容易出问题的地方。
3.5 Unity 侧:一个常驻 GameObject 收所有消息
Unity 侧只需要一个挂了脚本的 GameObject,名字固定,DontDestroyOnLoad保住。
// Assets/Scripts/NativeBridge.cs using System; using UnityEngine; using UnityEngine.SceneManagement; public class NativeBridge : MonoBehaviour { public static NativeBridge Instance { get; private set; } // JS / 原生 -> Unity 的落点:返回值必须是 void,参数必须只有一个 string public void OnNativeMessage(string json) { var env = JsonUtility.FromJson<Envelope>(json); Debug.Log($"[Bridge] recv seq={env.seq} type={env.type}"); switch (env.type) { case "ar.start": SceneManager.LoadScene("ARScan"); break; case "app.ping": Post("app.pong", "{\"ok\":true}"); break; default: Debug.LogWarning($"[Bridge] unhandled type={env.type}"); break; } } public void Post(string type, string payloadJson) { string env = "{\"type\":\"" + type + "\",\"payload\":" + Quote(payloadJson) + ",\"ts\":" + DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() + "}"; #if UNITY_ANDROID && !UNITY_EDITOR using (var helper = new AndroidJavaClass("com.demo.unitybridge.UnityBridgeHelper")) helper.CallStatic("postToJs", env); #elif UNITY_IOS && !UNITY_EDITOR UnityBridgePostToNative(env); #else Debug.Log("[Bridge] editor: " + env); #endif } private static string Quote(string s) => "\"" + s.Replace("\\", "\\\\").Replace("\"", "\\\"") + "\""; #if UNITY_IOS && !UNITY_EDITOR [System.Runtime.InteropServices.DllImport("__Internal")] private static extern void UnityBridgePostToNative(string msg); #endif [Serializable] private class Envelope { public string type; public string payload; public long ts; public int seq; } void Awake() { <p> <a href="https://download.csdn.net/download/weixin_42140625/15109036" style="color:#ec7500;font-size:14px;"> 本文还有配套的精品资源,点击获取 </a> <img alt="menu-r.4af5f7ec.gif" src="https://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;"> </p>