Unity 开发高级/资深 10-01:原生交互与桥接(Java / Objective-C / 微信 JS)

Unity 开发高级/资深 SDK 跨平台

返回 10:构建发布、CI/CD 与 SDK

返回总览

相关图示:工具速查总览

原生交互(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 就要有 Disposenew 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 AndroidJavaProxyUnitySendMessage Assets/Plugins/Android 的 AAR/Jar Manifest、Gradle、ProGuard
iOS [DllImport("__Internal")] + extern "C" 函数指针 + MonoPInvokeCallbackUnitySendMessage 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 只支持基本类型、stringAndroidJavaObject/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,Java String → C# string,Java boolean → 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 的结构,否则可能启动黑屏。
  • onCreateonResumeonPauseonDestroy 是 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 调用的类、方法可能被裁掉或改名,典型报错是 ClassNotFoundExceptionNoSuchMethodError。做法是在 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/.mmAssets/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 工程每次构建都会重新生成,手改一定被覆盖。正确做法是用 IPostprocessBuildWithReportOnPostprocessBuild 里脚本化修改:

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);
    }
}

常被审核和权限卡住的键:

  • NSCameraUsageDescriptionNSPhotoLibraryUsageDescriptionNSMicrophoneUsageDescription
  • 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 资源要做分包和远程加载;首次启动要展示明确的加载进度,不能黑屏。
  • 域名白名单:requestdownloadFileuploadFilesocket 都要在微信后台配置合法域名,否则请求直接失败,且报错信息不直观。
  • 隐私与合规:微信小游戏有独立的隐私协议和授权接口,涉及用户信息、广告、支付时要按平台要求接。
  • 版本匹配:适配 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.CopyGCHandle.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。
  • jadxapktool:反查 AAR/Jar 里的类名、接口、方法签名,确认 AndroidJavaProxy 的接口名和参数类型。
  • javap -s -p:打印 Java 方法签名,用于 JNI 重载调用。
  • ProGuard/R8 规则:防止 JNI 和反射相关类被裁剪、改名。
  • Xcode、Device Console、Instruments:调试 .mm/.m、Framework、签名、崩溃和性能。
  • nmotoolfile:检查二进制符号、架构切片和导出函数,定位“符号对不上”。
  • 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 原生桥接:DllImportMonoPInvokeCallback、Framework/xcframework、Info.plist 与隐私合规。
  • WebGL 与微信小游戏桥接:jslib、wx.* 能力、开放数据域、分包与域名白名单。
  • C++ / Lua / WebView 桥接:原生插件、热更脚本层、H5 双向通信。
  • 桥接工程化:接口抽象、主线程派发、回调注册表、错误码与日志规范。

开发高级/资深判断标准

  • 能否画出 C# → 平台实现 → 原生 → 回调的完整链路,并说清每一跳的线程。
  • 桥接层是否接口化、可 mock、能在 Editor 跑通流程。
  • 回调是否统一回主线程,是否有超时、取消、防重复。
  • 注册/反注册是否成对,是否存在 Java 全局引用、delegate、block 的泄漏。
  • 打包配置、权限、隐私清单是否脚本化、可追溯,而不是手改导出工程。
  • 出现问题时,能否快速判断是 C#、桥接层、原生 SDK 还是打包配置的锅。

评论