相关图示:工具速查总览
原生交互(Native Bridge)是 Unity 客户端和平台能力之间的接缝。登录、支付、广告、推送、相册、定位、权限、分享、打开第三方 App、小游戏里的微信能力,最终都要落到一次跨语言调用上。这篇把 C# 与 Java/Kotlin、Objective-C/Swift、WebGL 与微信小游戏 JS 的调用方式、回调模型、线程模型、打包配置和常见坑整理成可复用的工程规范。
为什么原生交互是资深必修
桥接层难,不是难在某个 API,而是它一次跨了四道边界:
- 跨运行时:C#(托管/IL2CPP)和 Java(ART)、Objective-C(运行时)、JavaScript(JS 引擎)不是一回事。
- 跨线程:原生的回调经常不在 Unity 主线程,Unity API 又只能在主线程调。
- 跨内存模型:托管堆、Java 堆/全局引用、Objective-C 引用计数、WASM 线性内存各管各的,谁分配谁释放要讲清楚。
- 跨构建系统:Gradle、Xcode、Emscripten/微信适配层,任何一层的配置错了,编辑器里能跑,真机上直接挂。
所以桥接层出问题,通常只有三类症状:
- 调不通:类找不到、方法签名不对、so/aar/framework 没打进包、被裁剪或混淆。
- 回调丢或崩:回调线程不对、对象已销毁、delegate 被 GC、Java 全局引用泄漏。
- 发不出去:权限、隐私清单、Manifest 合并、域名白名单、审核规则。
资深开发的判断标准,是能把这三类问题分层定位,而不是在业务代码里打补丁。
桥接层应该长什么样
不要让业务代码直接 new AndroidJavaClass(...) 或 DllImport。正确做法是加一层平台能力抽象:
业务层(登录 / 支付 / 广告 / 分享)
↓ 只依赖接口,不碰平台 API
平台能力接口(ILoginService / IPayService / IPlatform)
↓ 按平台选择实现
Android 实现 iOS 实现 WebGL/微信 实现 Editor Mock
↓
Java / Kotlin OC / Swift JS / jslib
对应的目录与程序集边界,可以这样拆:
Runtime/Platform/IPlatform.cs 接口定义,纯 C#
Runtime/Platform/PlatformManager.cs 平台选择与初始化
Runtime/Platform/Android/*.cs AndroidJavaObject 实现
Runtime/Platform/iOS/*.cs DllImport + 回调实现
Runtime/Platform/WebGL/*.cs jslib 实现 + 微信封装
Runtime/Platform/Editor/EditorPlatform.cs 编辑器 Mock,保证编辑器能跑通流程
这样做的收益很直接:业务层能写单元测试,编辑器不用真机就能跑通登录/支付的假流程,换渠道 SDK 时只改一个实现,出问题也能一眼看出是业务、桥接层还是原生 SDK 的锅。
跨语言通信的通用规则
不管跨哪种语言,有几条规律是共通的。
调用方向决定模型。
- C# → 原生:同步调用,可以有返回值。异常必须在这一侧被捕获并转换,不能让原生异常穿透回来。
- 原生 → C#:回调或事件,异步、跨线程,必须解决“回到主线程 + 对象可能已销毁 + 回调可能重复/丢失”三个问题。
线程是最高频的崩溃来源。
Unity 绝大多数 API 只能在主线程调用。Java 回调、Objective-C 的 GCD、JS 事件都可能在其他线程或时机触发。统一做法是:桥接层只把数据投递到主线程队列,由主线程统一派发。C# 侧一个通用派发器就够:
public class MainThreadDispatcher : MonoBehaviour
{
static readonly ConcurrentQueue<Action> Queue = new ConcurrentQueue<Action>();
public static void Post(Action action)
{
Queue.Enqueue(action);
}
void Update()
{
while (Queue.TryDequeue(out var action))
{
try { action?.Invoke(); }
catch (Exception e) { Debug.LogException(e); }
}
}
}
字符串统一按 UTF-8 约定。 Android 的 JNI、iOS 的 const char*、WebGL 的 Emscripten 内存,都以 UTF-8 为事实标准。中文、Emoji、超长字符串是重灾区,遇到乱码或截断,先查编码,再查长度和缓冲区。
生命周期成对。 注册回调就要有反注册,Acquire 就要有 Dispose,new AndroidJavaObject 就要考虑释放。桥接层最常见的泄漏不是托管内存,而是 Java 全局引用、Objective-C 的 block 循环引用、C# delegate 被原生长期持有。
异常转成错误码。 原生抛异常时,桥接层要 try/catch 并转成项目统一错误码 + 日志,而不是让异常穿透到业务层甚至直接崩。
性能上不要每帧调。 每次跨语言调用都有固定开销,字符串和数组编组更贵。高频数据(如触摸、传感器)应该批量传,或者直接在 C# 侧实现,不要每帧穿过桥接层。
平台宏隔离。 桥接代码几乎都要包在 #if 里,并用 Editor Mock 兜底:
#if UNITY_ANDROID && !UNITY_EDITOR
// Android 实现
#elif UNITY_IOS && !UNITY_EDITOR
// iOS 实现
#elif UNITY_WEBGL && WEIXINMINIGAME && !UNITY_EDITOR
// 微信小游戏实现
#else
// Editor / 其他平台 Mock
#endif
各平台桥接方式可以先看这张对照表:
| 平台 | C# 调原生 | 原生调 C# | 原生代码位置 | 打包配置 |
|---|---|---|---|---|
| Android | AndroidJavaClass / AndroidJavaObject |
AndroidJavaProxy、UnitySendMessage |
Assets/Plugins/Android 的 AAR/Jar |
Manifest、Gradle、ProGuard |
| iOS | [DllImport("__Internal")] + extern "C" |
函数指针 + MonoPInvokeCallback、UnitySendMessage |
Assets/Plugins/iOS 的 .m/.mm/.framework |
Info.plist、Entitlements、隐私清单 |
| WebGL | [DllImport("__Internal")] + jslib |
SendMessage |
Assets/Plugins/WebGL/*.jslib |
WebGL 模板、压缩、内存 |
| 微信小游戏 | 适配 SDK 的 WX 封装 / 自写 jslib |
SendMessage + 回调 |
Assets/WX-WASM-SDK |
域名白名单、分包、隐私接口 |
| C++ 插件 | [DllImport] + extern "C" |
函数指针 | 各平台原生库 | 符号导出、运行时依赖 |
C# 与 Java / Kotlin(Android)
Android 的桥接本质是 JNI 的封装,Unity 提供了三种高层方式。
用 AndroidJavaClass / AndroidJavaObject 调 Java
// 静态类
using (var buildVersion = new AndroidJavaClass("android.os.Build$VERSION"))
{
int sdkInt = buildVersion.GetStatic<int>("SDK_INT");
Debug.Log($"Android API Level: {sdkInt}");
}
// 拿到当前 Activity,再调它的实例方法
using (var unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer"))
using (var activity = unityPlayer.GetStatic<AndroidJavaObject>("currentActivity"))
using (var context = activity.Call<AndroidJavaObject>("getApplicationContext"))
{
// 示例:调一个渠道 SDK 的静态方法
using (var sdk = new AndroidJavaClass("com.example.sdk.ChannelSdk"))
{
sdk.CallStatic("init", context, "your_app_id");
}
}
几个必须记住的点:
- 内部类用
$连接,比如android.os.Build$VERSION。 Call<T>、Get<T>、GetStatic<T>的T只支持基本类型、string、AndroidJavaObject/AndroidJavaClass以及它们的数组。自定义 C# 类型不能直接传。- 方法有重载、或者参数类型有歧义时,显式传 JNI 签名:
// javap -s -p YourClass 可以打印方法签名
// (Ljava/lang/String;I)V 表示 (String, int) -> void
javaObj.Call("doSomething", "(Ljava/lang/String;I)V", "abc", 1);
AndroidJavaObject持有的是 JNI 全局引用,长期持有会占用资源。用完Dispose(),或者放进using。频繁调用的对象(Activity、SDK 单例)应该在平台层缓存一次,而不是每帧 new。
用 AndroidJavaProxy 接收 Java 回调
Java 侧定义一个接口,C# 侧实现它:
// Java(放在 AAR/Jar 里)
public interface ILoginCallback {
void onSuccess(String token);
void onFail(int code, String message);
}
// C#:接口名必须和 Java 完全一致,方法名和参数类型也必须一致
class LoginCallbackProxy : AndroidJavaProxy
{
readonly Action<string> onSuccess;
readonly Action<int, string> onFail;
public LoginCallbackProxy(Action<string> onSuccess, Action<int, string> onFail)
: base("com.example.sdk.ILoginCallback")
{
this.onSuccess = onSuccess;
this.onFail = onFail;
}
public void onSuccess(string token) => MainThreadDispatcher.Post(() => onSuccess(token));
public void onFail(int code, string message) => MainThreadDispatcher.Post(() => onFail(code, message));
}
// 调用
using (var sdk = new AndroidJavaClass("com.example.sdk.LoginSdk"))
{
sdk.CallStatic("login", new LoginCallbackProxy(
token => Debug.Log($"登录成功: {token}"),
(code, msg) => Debug.LogError($"登录失败: {code} {msg}")));
}
要点:
base("...")里的接口名必须是 Java 全限定名,嵌套接口用$,比如android.content.DialogInterface$OnClickListener。- 这个接口必须能在打包进去的 AAR/Jar 里找到,不能只存在于你本地的 Java 源码里。
- 参数类型映射:Java
int→ C#int,JavaString→ C#string,Javaboolean→ C#bool,其他 Java 对象 →AndroidJavaObject。 - Java 回调线程不保证是主线程,所以在代理方法里立刻
MainThreadDispatcher.Post。
用 UnitySendMessage 从 Java 回 Unity
有时候回调信息很简单,不想写 Proxy,可以在 Java 侧直接发消息给 Unity 的一个 GameObject:
// Java
public void onPayResult(String orderId, int code) {
UnityPlayer.UnitySendMessage("SdkManager", "OnPayResult", orderId + "|" + code);
}
// 如果回调在子线程,要先回主线程
activity.runOnUiThread(() ->
UnityPlayer.UnitySendMessage("SdkManager", "OnPayResult", orderId + "|" + code));
// C#:方法必须是 public void,且只能接收一个 string 参数
void OnPayResult(string payload)
{
var parts = payload.Split('|');
string orderId = parts[0];
int code = int.Parse(parts[1]);
}
UnitySendMessage 的限制要记清楚:目标 GameObject 必须处于激活状态并存在于场景中,方法名和 GameObject 名是硬编码字符串,改名就断,参数只能是一个字符串。复杂数据要自己序列化(JSON 或自定义分隔符)。
自定义 Activity 与生命周期
需要处理 onActivityResult、权限回调、生命周期时,要继承 UnityPlayerActivity:
public class MyActivity extends UnityPlayerActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
}
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data);
UnityPlayer.UnitySendMessage("SdkManager", "OnActivityResult",
requestCode + "|" + resultCode);
}
@Override
public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
// 把结果转成业务能识别的字符串再发给 Unity
}
}
Manifest 放在 Assets/Plugins/Android/AndroidManifest.xml(Custom Main Manifest),把 activity 的 android:name 指向自定义类。注意:
- 自定义 Activity 后,
currentActivity拿到的是它,UnityPlayer.currentActivity也是它。 - 不要随意改动包名和 Unity 默认 Activity 的结构,否则可能启动黑屏。
onCreate、onResume、onPause、onDestroy是 Unity 生命周期和原生 SDK 生命周期的交汇点,SDK 的onResume/onPause漏调会导致广告、统计、推送行为异常。
权限与 Manifest
Android 6.0+ 的权限要运行时申请,Manifest 只是声明:
#if UNITY_ANDROID && !UNITY_EDITOR
using (var unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer"))
using (var activity = unityPlayer.GetStatic<AndroidJavaObject>("currentActivity"))
{
activity.Call("requestPermissions",
new string[] { "android.permission.CAMERA" }, 1001);
}
#endif
Manifest 声明示例:
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
要跟上平台变化:Android 13 的通知权限 POST_NOTIFICATIONS、Android 14 的前台服务类型、targetSdk 提升后的行为变更,都会让“之前好好的”SDK 突然失效。
AAR / Jar 接入与打包配置
- 库文件放
Assets/Plugins/Android/,Unity 会自动打进包。 - 有子依赖或需要
implementation时,启用mainTemplate.gradle(Custom Main Gradle Template)和gradleTemplate.properties,把依赖写进去。 - 需要改 Manifest 时,启用 Custom Main Manifest,而不是改导出后的工程。
targetSdk/minSdk在 Player Settings 里统一,别让不同 AAR 的 Manifest 各写一套。- 要支持 64 位(
ARM64),否则上不了 Google Play。 - Android 的 Debug/Release 包名、签名、渠道参数集中到构建脚本和渠道配置里,不要散落在代码中。
R8 / ProGuard 与混淆
开启 Minify 后,反射和 JNI 调用的类、方法可能被裁掉或改名,典型报错是 ClassNotFoundException、NoSuchMethodError。做法是在 Assets/Plugins/Android/proguard-user.txt 里保留 SDK 和 JNI 相关类:
-keep class com.example.sdk.** { *; }
-keep class com.unity3d.player.** { *; }
-keepclassmembers class * {
native <methods>;
}
-keepclasseswithmembers class * {
@android.webkit.JavascriptInterface <methods>;
}
Android 常见坑
- 在子线程直接改 Unity 对象 → 崩溃或随机异常,务必回主线程。
AndroidJavaObject每次 new 都产生 JNI 引用,高频调用要缓存并Dispose。- 混淆把 JNI 方法名改掉 →
NoSuchMethodError。 AndroidJavaProxy的接口没打进 AAR → 运行时报找不到接口。UnitySendMessage的 GameObject 未激活或被销毁 → 回调静默丢失。- 只测了 Editor 或 32 位包 → 真机 / 64 位 /
arm64才暴露问题。 - 渠道 SDK 的 Application 类冲突 → Manifest 合并失败,需要用
tools:replace处理。
C# 与 Objective-C / Swift(iOS)
iOS 的桥接走的是 C 函数符号,不是像 Android 那样的运行时查找。
基本链路:DllImport + extern “C”
C# 侧:
public static class NativeBridge
{
#if UNITY_IOS && !UNITY_EDITOR
[DllImport("__Internal")]
private static extern void _InitSdk(string appId);
[DllImport("__Internal")]
private static extern int _Pay(string orderId, int amount);
public static void InitSdk(string appId) => _InitSdk(appId);
public static int Pay(string orderId, int amount) => _Pay(orderId, amount);
#else
public static void InitSdk(string appId) { }
public static int Pay(string orderId, int amount) => 0;
#endif
}
原生侧 Assets/Plugins/iOS/MyBridge.mm:
#import <Foundation/Foundation.h>
#import "UnityInterface.h"
extern "C" void _InitSdk(const char* appId) {
NSString* appIdStr = [NSString stringWithUTF8String:appId];
dispatch_async(dispatch_get_main_queue(), ^{
// 初始化渠道 SDK
});
}
extern "C" int _Pay(const char* orderId, int amount) {
return 0;
}
要点:
extern "C"阻止 C++ 名称修饰,否则符号对不上。.m/.mm放Assets/Plugins/iOS,Unity 会自动加入 Xcode 工程并编译。- iOS 下
string默认按 UTF-8 传到const char*(Mono/IL2CPP 在 iOS 上把 ANSI 当作 UTF-8),所以中文能正常处理。 - 原生返回字符串比较麻烦,通常用
UnitySendMessage回传,或者返回char*再用Marshal.PtrToStringAnsi(注意谁分配谁释放)。
回调 C#:函数指针 + MonoPInvokeCallback
原生要回调 C#,用函数指针最直接:
public delegate void NativeCallback(string message);
public static class NativeBridge
{
#if UNITY_IOS && !UNITY_EDITOR
[DllImport("__Internal")]
private static extern void _RegisterCallback(NativeCallback callback);
// 保持强引用,防止被 GC
static NativeCallback callbackRef;
public static void Register(Action<string> onMessage)
{
callbackRef = message => MainThreadDispatcher.Post(() => onMessage(message));
_RegisterCallback(callbackRef);
}
#else
public static void Register(Action<string> onMessage) { }
#endif
}
// MyBridge.mm
#import <Foundation/Foundation.h>
#import "UnityInterface.h"
typedef void (*NativeCallback)(const char* message);
static NativeCallback gCallback = NULL;
extern "C" void _RegisterCallback(NativeCallback callback) {
gCallback = callback;
}
// 原生某处触发回调
- (void)onLoginResult:(NSString*)token {
dispatch_async(dispatch_get_main_queue(), ^{
if (gCallback != NULL) {
gCallback([token UTF8String]);
}
});
}
最关键的坑:IL2CPP 是 AOT 编译,函数指针回调必须在 C# 侧标注:
[AOT.MonoPInvokeCallback(typeof(NativeCallback))]
static void OnNativeCallback(string message) { /* ... */ }
漏了 MonoPInvokeCallback,真机上会报 Attempting to call method ... which was not compiled with MonoPInvokeCallback attribute 然后崩溃。另外 delegate 必须是静态方法或者被静态字段强引用,否则会被 GC 回收,表现为跑一段时间后偶发崩溃。
UnitySendMessage 从原生回调
字符串回传最省事的还是 UnitySendMessage:
#import "UnityInterface.h"
extern "C" void _RequestPay(const char* orderId) {
dispatch_async(dispatch_get_main_queue(), ^{
UnitySendMessage("SdkManager", "OnPayResult",
[[NSString stringWithFormat:@"%s|0", orderId] UTF8String]);
});
}
规则和 Android 一样:GameObject 要激活,方法只有一个 string 参数,非主线程要先 dispatch_async(dispatch_get_main_queue())。
Swift 怎么桥接
Swift 不能直接 DllImport,因为它没有稳定的 C 符号和名称修饰约定。两种做法:
- 用 Objective-C 包装层:Swift 类标
@objc、继承NSObject,方法标@objc,再用一个.mm文件把 C 函数转发给它。最稳,兼容性最好。 - 用
@_cdecl("name")(Swift 5.4+)直接导出 C 符号:
@_cdecl("_SwiftHello")
public func SwiftHello(_ name: UnsafePointer<CChar>?) {
let s = name.map { String(cString: $0) } ?? ""
DispatchQueue.main.async {
UnitySendMessage("SdkManager", "OnHello", s)
}
}
注意 .swift 文件 Unity 不会自动加入 Xcode 编译目标,实际项目更常见的做法是把 Swift 代码打成 .framework/.xcframework 再接入,而不是直接丢源码。
Framework、静态库与依赖管理
- 二进制放
Assets/Plugins/iOS/,.framework、.xcframework、.a都可以。 - 需要
-ObjC、-all_load链接标志时,用构建后处理脚本加,别手改 Xcode。 - Swift 库要处理运行时库(
Always Embed Swift Standard Libraries)和签名。 - 依赖用 EDM4U(External Dependency Manager)管理,iOS 走 CocoaPods、Android 走 Gradle,避免手动维护
Dependencies.xml之外的东西。 - 二进制分发的库,优先要
.xcframework(同时含真机和模拟器切片),旧的.framework在 Apple Silicon 上容易出现架构不匹配。
Info.plist、Entitlements 与隐私
Xcode 工程每次构建都会重新生成,手改一定被覆盖。正确做法是用 IPostprocessBuildWithReport 在 OnPostprocessBuild 里脚本化修改:
using UnityEditor;
using UnityEditor.Build;
using UnityEditor.Build.Reporting;
using UnityEditor.iOS.Xcode;
using System.IO;
public class IOSPostProcess : IPostprocessBuildWithReport
{
public int callbackOrder => 0;
public void OnPostprocessBuild(BuildReport report)
{
if (report.summary.platform != BuildTarget.iOS) return;
string projPath = PBXProject.GetPBXProjectPath(report.summary.outputPath);
var proj = new PBXProject();
proj.ReadFromFile(projPath);
// 1. Info.plist:相机、相册、ATT、跳转白名单
string plistPath = Path.Combine(report.summary.outputPath, "Info.plist");
var plist = new PlistDocument();
plist.ReadFromFile(plistPath);
plist.root.SetString("NSCameraUsageDescription", "用于扫描二维码");
plist.root.SetString("NSUserTrackingUsageDescription", "用于广告归因");
plist.WriteToFile(plistPath);
// 2. 添加系统框架、链接标志、Embed Framework
// 3. 修改 entitlements / capabilities
proj.WriteToFile(projPath);
}
}
常被审核和权限卡住的键:
NSCameraUsageDescription、NSPhotoLibraryUsageDescription、NSMicrophoneUsageDescription。NSUserTrackingUsageDescription+ ATT 弹窗(iOS 14.5+)。LSApplicationQueriesSchemes(微信、支付宝等跳转白名单)。aps-environment(推送)、Associated Domains、Sign in with Apple 等 Entitlements。PrivacyInfo.xcprivacy隐私清单(iOS 17+),声明 Required Reason API 和数据收集类型。- 尽量别为了省事整体关闭 ATS(
NSAllowsArbitraryLoads),审核和安全性都会出问题。
iOS 常见坑
- 忘记
MonoPInvokeCallback→ 真机崩溃。 - delegate 被 GC 回收 → 偶发崩溃,保持静态强引用。
- 非主线程调 Unity API → 崩溃或警告。
- Objective-C block 循环引用、ARC/非 ARC 混编 → 内存泄漏或崩溃。
- 手改 Xcode 工程 → 下次构建被覆盖,必须脚本化。
- 类被 strip 掉(缺
-ObjC/-all_load)→ 运行时找不到类。 - 广告/统计 SDK 用
UIApplication私有行为 → 审核被拒。 - 隐私描述文案和实际行为不一致 → 隐私审核被拒。
C# 与 JavaScript(WebGL / 微信小游戏)
WebGL 和微信小游戏共用一套 jslib 机制,区别在于 JS 侧调用的是浏览器 API 还是微信的 wx.* API。
jslib 机制
JS 侧放 Assets/Plugins/WebGL/MyLib.jslib:
mergeInto(LibraryManager.library, {
JsLog: function (ptr) {
console.log(UTF8ToString(ptr));
},
JsAdd: function (a, b) {
return a + b;
},
JsCallAsync: function (namePtr, valuePtr) {
var name = UTF8ToString(namePtr);
var value = UTF8ToString(valuePtr);
// 异步操作完成后回调 Unity
setTimeout(function () {
SendMessage('SdkManager', 'OnJsResult', name + '|' + value);
}, 100);
}
});
C# 侧调用:
public static class JsBridge
{
#if UNITY_WEBGL && !UNITY_EDITOR
[DllImport("__Internal")] private static extern void JsLog(string msg);
[DllImport("__Internal")] private static extern int JsAdd(int a, int b);
[DllImport("__Internal")] private static extern void JsCallAsync(string name, string value);
public static void Log(string msg) => JsLog(msg);
public static int Add(int a, int b) => JsAdd(a, b);
public static void CallAsync(string name, string value) => JsCallAsync(name, value);
#else
public static void Log(string msg) => Debug.Log(msg);
public static int Add(int a, int b) => a + b;
public static void CallAsync(string name, string value) { }
#endif
}
要点:
.jslib只在 WebGL 生效,Editor 必须有 C# 兜底实现,否则编辑器会报EntryPointNotFoundException。- C#
string传到 JS 是一个指针,JS 侧用UTF8ToString(ptr)读取。JS 里如果_malloc了内存,记得_free。 - JS 调 C# 用全局
SendMessage('GameObject', 'Method', value);旧代码里的gameInstance.SendMessage/unityInstance.SendMessage也能用,但依赖 GameObject 名和方法名这个约定不变。 - WebGL 基本是单线程,jslib 里不要做重计算或同步阻塞,否则整帧卡住。
Application.ExternalCall/Application.ExternalEval已废弃,不要再用。
微信小游戏适配
Unity 项目导出微信小游戏,走的是「Unity WebGL + 微信小游戏适配 SDK(WX-WASM-SDK)」,导出后用微信开发者工具打开。从评估、资源加载、启动性能到上线的完整迁移实践,见 Unity 转微信小游戏指南;这里只讲 JS 桥接本身。注意:
- 平台宏通常是
UNITY_WEBGL && WEIXINMINIGAME,配合!UNITY_EDITOR使用。 - 适配层提供 C# 的
WX封装,覆盖登录、支付、广告、分享、系统信息、生命周期、存储、键盘等能力,底层对应微信的wx.*API。 - 能力对应关系大致是:
| 能力 | C# 侧(适配 SDK 封装) | 底层微信 API |
|---|---|---|
| 登录 | WX.Login / 登录回调 |
wx.login |
| 支付 | WX.RequestMidasPayment |
wx.requestMidasPayment |
| 广告 | WX.CreateRewardedVideoAd / 播放回调 |
wx.createRewardedVideoAd |
| 分享 | WX.ShareAppMessage |
wx.shareAppMessage |
| 系统信息 | WX.GetSystemInfoSync |
wx.getSystemInfoSync |
| 生命周期 | WX.OnShow / WX.OnHide |
wx.onShow / wx.onHide |
| 存储 | WX.StorageSet* / WX.StorageGet* |
wx.setStorageSync 等 |
| 网络 | WX.Request / WX.DownloadFile |
wx.request / wx.downloadFile |
| 键盘 | WX.OnKeyboardInput / WX.ShowKeyboard |
微信原生键盘 |
- 开放数据域(排行榜):它是一个独立的 JS 运行环境,不能直接访问主域对象。主域通过
WX.GetOpenDataContext()拿上下文,用postMessage通信,渲染靠SubContextView组件。开放数据域里只能用微信提供的那套子域 API。 - 包体与资源:微信小游戏对首包/分包大小有硬限制,Unity 资源要做分包和远程加载;首次启动要展示明确的加载进度,不能黑屏。
- 域名白名单:
request、downloadFile、uploadFile、socket都要在微信后台配置合法域名,否则请求直接失败,且报错信息不直观。 - 隐私与合规:微信小游戏有独立的隐私协议和授权接口,涉及用户信息、广告、支付时要按平台要求接。
- 版本匹配:适配 SDK 版本要和 Unity 版本匹配,升级 Unity 时同步升级适配层,否则会出现导出失败或运行时诡异问题。
WebGL / 微信常见坑
- jslib 只在 WebGL 生效,Editor 没兜底 → 编辑器直接报错。
- JS 侧字符串/数组内存没释放 → WASM 内存持续增长。
SendMessage依赖字符串名字,改 GameObject/方法名后回调静默失效。- 微信环境里
localStorage不通用,要用微信存储接口或FileSystemManager。 - 用了 Unity 在 WebGL 不支持的特性(多线程、
System.Net同步阻塞、部分图形 API)→ 打包失败或运行异常。 - 域名白名单没配 → 请求失败但不报“白名单”关键字,容易误判为网络问题。
- 适配层和引擎版本不匹配 → 导出或启动阶段出问题。
其他常见桥接
C# 与 C++ 原生插件
[StructLayout(LayoutKind.Sequential)]
public struct NativeVec3 { public float x, y, z; }
[DllImport("mylib", CallingConvention = CallingConvention.Cdecl)]
private static extern int Compute([In] NativeVec3[] input, int count, IntPtr output);
// mylib.cpp
extern "C" __declspec(dllexport) int Compute(const NativeVec3* input, int count, float* output) {
// ...
return 0;
}
- 库文件按平台放
Assets/Plugins/<平台>/,.dll、.so、.bundle、.a。 - 结构体要
LayoutKind.Sequential,注意对齐和平台差异。 - 数组/结构体编组可用
Marshal.Copy、GCHandle.Alloc(pinned)、Marshal.AllocHGlobal,注意 native 内存谁分配谁释放。 - 回调函数指针同样需要
MonoPInvokeCallback(IL2CPP)。 - 平台库会有依赖库,Windows 要一起拷,Android 要保证 ABI 齐全,iOS 要链接进去。
C# 与 Lua(xLua / tolua)
- 通过
LuaEnv执行脚本,LuaTable读写表,LuaFunction.Call调用函数。 - 用
[CSharpCallLua]/[LuaCallCSharp]配置生成胶水代码,避免运行时反射带来的性能和兼容问题。 - 委托和接口的桥接、
LuaEnv.Tick()、GC 时机、热更边界都要提前约定。 - 这类桥接常见于热更新方案,重点是“热更代码和 C# 接口的版本兼容”,而不是单次调用。
C# 与 WebView
- UniWebView 之类的插件可以打开网页并双向通信:C# 调
EvaluateJavaScript,网页通过 message 回到 Unity 的OnMessageReceived。 - 原生 WebView 盖在 Unity 上时,层级、输入穿透、返回键、关闭时机、透明背景都要单独处理。
- 登录、支付、H5 活动常用这套,安全上要注意别把 token 明文塞进 URL。
把桥接做成工程能力
上面都是单点技术,真正决定项目稳定性的是这几条工程约定:
- 平台能力接口化:业务只依赖接口,用 Editor Mock 在编辑器里跑通全流程。
- 回调统一主线程派发:所有原生回调先进主线程队列,再由业务处理。
- 回调注册表:注册/反注册成对,支持一次性回调,超时自动清理,避免“回调了但对象已销毁”。
- 错误码统一:把 Java 异常、iOS 错误、JS 错误、微信 errCode 转成项目错误码。
- 日志规范化:桥接层统一记录方向、方法、参数长度、耗时、错误码;不要打印 token、订单敏感信息。
- 打包配置集中:AppId、渠道号、环境地址从构建参数注入,代码里不硬编码。
- 空实现兜底:Editor、不支持平台、SDK 初始化失败都要有可运行的降级路径。
- 可测试:平台实现可 mock,核心业务逻辑用纯 C# 单测覆盖。
一个桥接调用应该长这样,而不是直接写进业务:
public interface IPayService
{
void Pay(string orderId, int amount, Action<PayResult> onResult);
}
// 业务层
payService.Pay(orderId, amount, result =>
{
if (result.Success) OnPaySuccess(result.OrderId);
else OnPayFail(result.Code, result.Message);
});
需要掌握的工具
- Android Studio、Gradle、ADB、Logcat:调试 Java/Kotlin、AAR、Manifest 合并、崩溃和 ANR。
jadx、apktool:反查 AAR/Jar 里的类名、接口、方法签名,确认AndroidJavaProxy的接口名和参数类型。javap -s -p:打印 Java 方法签名,用于 JNI 重载调用。- ProGuard/R8 规则:防止 JNI 和反射相关类被裁剪、改名。
- Xcode、Device Console、Instruments:调试
.mm/.m、Framework、签名、崩溃和性能。 nm、otool、file:检查二进制符号、架构切片和导出函数,定位“符号对不上”。- EDM4U、CocoaPods、Swift Package Manager:管理 Android/iOS 的第三方依赖。
- Unity 构建后处理(
IPostprocessBuildWithReport):脚本化修改 Info.plist、Entitlements、Gradle、Manifest。 - 微信开发者工具、vConsole、Chrome DevTools:调试 WebGL 与微信小游戏的 JS 侧。
- WX-WASM-SDK:Unity 导出微信小游戏的适配层。
- Charles / Wireshark:排查 SDK 网络、支付回调和域名白名单问题。
可继续细分方向
- Android 原生桥接:JNI 签名、AAR/Jar、Proxy 回调、自定义 Activity、权限、混淆。
- iOS 原生桥接:
DllImport、MonoPInvokeCallback、Framework/xcframework、Info.plist 与隐私合规。 - WebGL 与微信小游戏桥接:jslib、
wx.*能力、开放数据域、分包与域名白名单。 - C++ / Lua / WebView 桥接:原生插件、热更脚本层、H5 双向通信。
- 桥接工程化:接口抽象、主线程派发、回调注册表、错误码与日志规范。
开发高级/资深判断标准
- 能否画出 C# → 平台实现 → 原生 → 回调的完整链路,并说清每一跳的线程。
- 桥接层是否接口化、可 mock、能在 Editor 跑通流程。
- 回调是否统一回主线程,是否有超时、取消、防重复。
- 注册/反注册是否成对,是否存在 Java 全局引用、delegate、block 的泄漏。
- 打包配置、权限、隐私清单是否脚本化、可追溯,而不是手改导出工程。
- 出现问题时,能否快速判断是 C#、桥接层、原生 SDK 还是打包配置的锅。
评论