Unity WebGL与H5通信:jslib桥接C#通知前端初始化完成
接手WebGL项目时我最常被问的问题就是H5页面怎么知道Unity内容已经加载完了尤其当你要把Unity WebGL嵌入一个商城、活动页或者游戏大厅时外层网页的DOM元素比如一个进入游戏按钮必须等Unity内部真正初始化完成后才能启用。如果瞎等或倒计时猜结果不是按钮早亮、点了没反应就是整个页面白屏几秒被人直接关掉。这个需求落到技术实现上就是标题里这回事Unity的C#脚本主动调用前端的JavaScript函数把Unity WebGL加载并初始化完成这件事同步给H5页面。我第一次做捕鱼题材的WebGL项目时光在通信时序上就折腾了不少时间——海洋场景、鱼群模型、动作资源加起来体积不小首页加载时间本来就长初始化完成的信号再传不准后面的引导流程全是乱的。这篇文章把我踩过的坑、验证过的写法、以及推荐的项目结构全部整理出来。做WebGL的H5壳工程、做Unity渲染嵌入业务前后端联调的朋友都可以直接参考。1. 为什么Unity WebGL必须靠.jslib插件桥接C#与JS1.1 编译链路决定了一切C#早已不是浏览器本地居民很多人第一次做Unity WebGL时会有个错觉C#代码既然能编译成能在浏览器里跑的产物那是不是可以直接用window.alert、操作DOM之类的东西我一开始也这么试结果当然是不行。Unity WebGL的编译流程是这样的C#代码先被编译成IL再转成C最后通过Emscripten编译成WebAssembly.wasm加上配套的JavaScript文件。wasm本身运行在浏览器的沙箱环境里没有直接访问浏览器API的权限它不知道window是什么也不知道document长什么样。Unity引擎在打包时之所以能显示画面是因为它内部把OpenGL/WebGL的绘制接口、文件读取、音频播放等操作都封装了一遍通过Emscripten生成的胶水层跟浏览器打交道。所以从C#侧往JS侧发消息必须顺着Unity提供的桥接机制走不能凭空调用。当年Unity提供过Application.ExternalCall后来标记过时在较新的2021.3上正式弃用、后续版本彻底移除原因就是它在封送字符串参数时性能差、行为不可控且调用过程脱离了Unity的编译期检查。1.2 三种回传方案的取舍ExternalCall、jslib、自定义Loader我梳理一下目前C#主动给JS发通知的可行路径大家直接按结论选型方案现状推荐度Application.ExternalCall老接口高版本已移除不推荐自建.jslib插件C#通过[DllImport(__Internal)]声明调用Unity WebGL官方推荐机制编译期集成类型封送可控强烈推荐通过URL参数传递状态只能单向、一次性无法动态通信基本不用很多网上老教程还在写Application.ExternalCall(JsFunName, param)这个写法今天在新版Unity里直接编译报错。纵使你用的版本还兼容也可能遇到字符串编码错乱、调用时机被延迟的问题。官方铺好的路就是.jslib文件它会被Unity自动编译进WebGL产物的运行时里与C#侧的DllImport声明形成一一对应的外部函数关系。补充一点容易混淆的unityInstance.SendMessage是JS调用C#用的方向正好反过来解决不了Unity主动通知H5的问题。实际项目里通常是两条腿走路——JS先通过SendMessage把交互指令发给UnityUnity处理完再通过jslib把结果回调给JS。本文核心是后半条链路但第五章会带上SendMessage把闭环串起来。2. 搭桥第一步创建UnityBridge.jslib并理解它的运行环境2.1 jslib文件放哪、长什么样、谁在执行它创建.jslib文件不需要额外安装任何插件它是纯文本格式就放在Assets/Plugins/WebGL/目录下即可。只要后缀名是.jslibUnity的WebGL编译器就会自动识别并把它并入最终生成的那个build.framework.js运行时文件里。文件基础结构长这样mergeInto(LibraryManager.library, { // 这里写要暴露给C#的函数 });mergeInto(LibraryManager.library, ...)是Emscripten提供的库合并接口。LibraryManager.library是运行时内部维护的一个函数表所有用mergeInto注入进来的函数都会被注册到wasm模块的外部环境里。C#侧的[DllImport(__Internal)]声明的函数名最终会去这个函数表里找对应的实现。理解这一步很重要它不是把JS函数复制到C#里而是在wasm模块外部挂载一个可调用环境。C#调用这个函数时实际发生的是从wasm侧发起一次外呼参数会从wasm的内存空间传递到这个JavaScript函数里。2.2 参数传递的底层规则字符串必须用UTF8ToString解码第一个容易踩的坑就在这里。C#里你写的是普通string参数但传递到jslib函数里的并不是现成的JavaScript字符串而是一个指向wasm内存中UTF-8编码字节的指针。举个例子[DllImport(__Internal)] private static extern void NotifyH5(string message);对应的jslib函数里如果直接console.log(message)打出来的会是一串数字比如164831232。必须用UTF8ToString(message)做转换mergeInto(LibraryManager.library, { NotifyH5: function (message) { var str UTF8ToString(message); console.log([Unity-H5], str); } });UTF8ToString是Emscripten运行时提供的内存读取工具。它会从指针位置开始按UTF-8编码逐字节读取直到遇到\0终止符还原成JavaScript字符串。除了UTF8ToString还有_malloc、HEAP8、HEAP32等内存操作工具但在基础通信场景里字符串用UTF8ToString就够用了。2.3 完整插件示例一个带事件分发的NotifyH5Ready函数下面这段是我实际项目中一直沿用的事件分发写法。它不把JS侧函数名写死而是靠事件名参数让Unity侧可以灵活地向H5发送各种不同的事件。文件放在Assets/Plugins/WebGL/UnityBridge.jslibmergeInto(LibraryManager.library, { NotifyH5Ready: function (eventName, jsonPayload) { var name UTF8ToString(eventName); var payload UTF8ToString(jsonPayload); try { var observer window.unityBridgeObserver || null; if (observer typeof observer.dispatchEvent function) { observer.dispatchEvent(new CustomEvent(name, { detail: payload })); } if (typeof window[name] function) { window[name](payload); } } catch (e) { console.warn([UnityBridge] NotifyH5Ready error:, e); } }, NotifyH5: function (eventName) { var name UTF8ToString(eventName); if (typeof window[name] function) { window[name](); } } });这里做了两层兼容第一层走window.unityBridgeObserver这个全局观察者它适合多模块订阅同一个事件第二层兼容最简单的全局函数回调。具体用哪层看业务复杂度但有个原则jslib里的函数尽量只做转发不要堆业务逻辑。它毕竟是桥两边的数据结构在它这里做一次转换就够了真正的业务处理交给H5侧的监听器。3. C#侧声明与DllImport避坑编辑器、宏保护与符号命名3.1 [DllImport(__Internal)]为什么在编辑器里必炸C#侧写法看起来跟调用普通DLL一样using System.Runtime.InteropServices; public class GameBridge : MonoBehaviour { [DllImport(__Internal)] private static extern void NotifyH5Ready(string eventName, string jsonPayload); [DllImport(__Internal)] private static extern void NotifyH5(string eventName); }但这里有一个非常容易踩的坑如果你直接在Unity编辑器里点Play跑这个场景会在调用时抛DllNotFoundException: __Internal。原因在于__Internal不是操作系统层面真实存在的动态库它是Unity WebGL编译期特设的一个逻辑命名空间——代码最终会被直接合并进wasm模块并不存在一个运行时需要加载的DLL文件。编辑器不是WebGL环境没有这个命名空间的概念自然找不到库。3.2 条件编译的规范写法与双模式运行技巧正确的做法是加上条件编译宏让这段C#代码只在WebGL打包产物里执行public class GameBridge : MonoBehaviour { [DllImport(__Internal)] private static extern void NotifyH5Ready(string eventName, string jsonPayload); void Start() { #if UNITY_WEBGL !UNITY_EDITOR NotifyH5Ready( OnUnityReady, {\scene\:\OceanScene\,\ver\:\1.0.0\} ); #else // Editor里没有__Internal用模拟逻辑代替 Debug.Log([Editor] Simulate NotifyH5Ready); #endif } }UNITY_WEBGL是WebGL打包平台的宏!UNITY_EDITOR确保在编辑器模拟WebGL平台时也不执行。事实上即便你在编辑器里把Build Target切到WebGL依然没有__Internal这个库所以双保险都写上。还有一个小技巧为了让编辑器下也能联调UI逻辑我习惯在#else分支里把同一条消息通过Unity的SendMessage或事件系统转发给本地调试面板这样引擎内外看到的行为是一致的只是消息源不同。3.3 函数名冲突、压缩混淆和调用时机的隐性坑函数名冲突jslib里声明的函数最终会存在于运行时环境如果和Unity模板代码里的某个顶层变量或函数重名打包后可能被覆盖或报重复声明错误。我给所有桥接函数统一加了项目前缀例如NotifyH5、Unity_避免跟第三方SDK或页面脚本撞名。压缩混淆WebGL设置里的Code Optimization、Compression Format这些选项不会影响通过[DllImport(__Internal)]暴露的jslib函数名编译器会保证C#声明和jslib实现之间的符号一致。但jslib文件里你自己定义的内部全局变量仍可能被压缩工具更改所以局部变量、辅助函数尽量写在mergeInto块内或使用闭包隔离。调用时机Start里调用jslib函数没有大问题但如果你在Awake里调用JS侧可能还没准备好对应的监听器。不用急第四章专门聊时序。4. 让H5准确收到加载并初始化完成的通知时序设计4.1 从浏览器视角看Unity WebGL的加载生命线要设计对的通知时机先搞清楚Unity WebGL在浏览器里到底经历了哪几个阶段。我在控制台Network面板观察过完整过程大致是页面加载build.loader.js调用createUnityInstance或旧版UnityLoader.instantiate开始拉取.wasm和.data文件wasm编译实例化Unity运行时初始化创建游戏主循环加载第一个场景场景中MonoBehaviour的Awake被调用紧接着第一个Start被调用js侧的createUnityInstance(...).then()回调触发的时候对应的是第3步结束运行时刚建立C#脚本还没跑起来。所以如果你在then回调里立刻通过unityInstance.SendMessage调用Unity里的某个业务方法极大概率会报SendMessage target not found——目标对象还没被创建。这也是为什么需要一个来自Unity内部的我准备好了通知。4.2 Start不是终点业务资源就绪后再发通知更可靠很多教程直接教你放在Start里对一个只加载单个场景的演示项目来说没错。但捕鱼场景、大模型展示这类真实项目场景挂载完成 ≠ 业务初始化完成。场景里可能有大量异步加载的模型资源、Addressables资源包、服务端配置拉取这些东西没就绪H5按钮一亮用户点进去就是黑屏或加载转圈。我这里的做法是把通知拆成两级事件。OnUnityRuntimeReady在第一个场景的Start里发表示引擎层、场景对象已创建可以做轻量交互。OnUnityBusinessReady在所有业务初始化完成后再发例如模型加载完毕、鱼群位置数据就绪、UI配置拉取成功。H5页面按需监听如果业务强依赖某个资源就等第二个事件如果只是想显隐一个按钮第一个事件就够了。实战里我一般默认等第二个事件因为用户点击进入场景按钮后紧接着就要看到实际内容时差最好控制在100毫秒内。4.3 JS侧占位Observer先定义好监听再接收Unity事件消息发出去了H5那边如果监听器还没挂好消息就丢了。所以推荐在加载Unity之前先在页面上初始化一个占位Observer。这样不管Unity什么时候发通知只要有这个全局容器在事件就不会丢(function () { if (window.unityBridgeObserver) return; var handlers {}; window.unityBridgeObserver { on: function (event, callback) { if (!handlers[event]) handlers[event] []; handlers[event].push(callback); }, emit: function (event, data) { if (!handlers[event]) return; handlers[event].forEach(function (cb) { try { cb(data); } catch (e) { console.error(e); } }); }, dispatchEvent: function (event) { this.emit(event.type, event.detail); } }; })(); window.unityBridgeObserver.on(OnUnityBusinessReady, function (payload) { var info JSON.parse(payload); console.log([H5] Unity场景已就绪场景名:, info.scene); document.getElementById(enterBtn).disabled false; });这段代码必须在Unity的loader脚本之前引入。因为Unity加载耗时很长页面脚本基本都会提前就绪保险起见我还是习惯在脚本顶部做一次初始化。即使Unity意外地在监听注册前就发来了事件也可以再扩展一个缓存字段把最近一次事件存进window.lastUnityEvent供后注册的监听器读取从根上规避竞争条件。4.4 防止重复通知与消息丢失的队列处理Start在正常的Unity生命周期里只执行一次一般不会重复通知。但如果你把通知函数放到了某些可能被反复触发的回调里比如资源加载完成回调、按钮事件就需要加一层保护。C#侧我习惯用一个布尔标志private bool _readyNotified false; private void NotifyBusinessReady() { if (_readyNotified) return; _readyNotified true; #if UNITY_WEBGL !UNITY_EDITOR NotifyH5Ready(OnUnityBusinessReady, {\scene\:\OceanScene\}); #endif }JS侧也可以做幂等处理同一个事件只执行业务逻辑一次防止H5页面因为监听器重复挂载而触发两遍初始化。消息丢失还有一个常见场景Unity通知发出时H5页面正在处理其它同步任务导致监听器代码还没跑到。要根治这个问题推荐把这个事件作为数据缓存起来而不是只作为触发信号var unityReadyCache window.unityReadyCache || null; window.unityBridgeObserver.on(OnUnityBusinessReady, function (payload) { window.unityReadyCache payload; doBusinessInit(payload); }); // 后注册的监听器也可以直接消费缓存 function ensureUnityReady(callback) { if (window.unityReadyCache) { callback(window.unityReadyCache); } else { window.unityBridgeObserver.on(OnUnityBusinessReady, callback); } }这样不管H5的业务脚本在Unity通知前还是后注册最终都能正确拿到Unity加载并初始化完成这件事。5. 进阶双向通信闭环与返回值的正确姿势5.1 从纯通知到带JSON参数的业务消息前面的事件源都是Unity主动上报实际业务里往往还需要带上更多上下文。比如场景加载完成后把版本号、场景名、资源形态一并带给H5。把jsonPayload从C#侧以字符串形式传过来再解析是WebGL通信里最省心、最不容易出错的做法。C#侧构建JSON字符串时不要手动拼字符串体容易转义出错。我一般配合JsonUtility或Newtonsoft.Json来序列化[System.Serializable] public class UnityReadyPayload { public string scene; public string ver; public int fishCount; public bool audioReady; } var payload new UnityReadyPayload { scene OceanScene, ver 1.0.0, fishCount 1024, audioReady true }; var json JsonUtility.ToJson(payload); NotifyH5Ready(OnUnityBusinessReady, json);JS侧解析时直接JSON.parse数据结构一目了然。凡是跨这道桥的复杂数据一律用JSON字符串这个惯例能帮你省掉90%的参数封送问题。5.2 JS返值给C#int/bool可以爽快用字符串建议换思路jslib函数除了作为发通知的火炮还能给C#返回计算结果。返回逻辑简单类型非常直接。比如C#需要一个标志位判断H5页面是否已经准备好接收消息mergeInto(LibraryManager.library, { IsH5Ready: function () { return window.h5Ready true ? 1 : 0; } });[DllImport(__Internal)] private static extern int IsH5Ready(); void Update() { #if UNITY_WEBGL !UNITY_EDITOR if (IsH5Ready() 1) { // 安全执行外部交互 } #endif }bool在C#封送时走的是int0为false非0为true。而float则按Emscripten的数字传值。字符串返回不建议直接搞因为C#那边拿到的是一个指针还得配合内存释放逻辑非常容易内存泄漏。真要从JS侧回传一大段文本更好的方案还是反过来——JS用SendMessage把字符串作为参数传回给C#的公开方法由Unity侧接收。这就是我开头说的双向闭环。先看JS侧// 当用户点击H5页面某个按钮通知Unity处理业务 window.unityInstance.SendMessage( GameBridge, // GameObject名称 EchoFromJs, // 方法名 Hello from H5 );再看C#侧对应的公开方法public class GameBridge : MonoBehaviour { public void EchoFromJs(string message) { Debug.Log([FromJS] message); // 处理完业务后再通过jslib回调结果给H5 #if UNITY_WEBGL !UNITY_EDITOR NotifyH5Ready(OnJsCommandResult, {\ok\:true,\msg\:\ message \}); #endif } }这样就形成一个完整的通信闭环H5按钮点击 →SendMessage进入C# → 业务处理 → jslib回调通知H5 → H5更新界面。实际项目里支付回调、登录校验、游戏胜负上报基本都是这个套路。5.3 调试实战浏览器控制台里观察C#调用JS的全过程WebGL项目的调试验证比普通App麻烦但也有捷径。下面这组是我每次联调都会做的事打开浏览器DevTools的Console面板保持网络面板开启先确认.wasm和.data文件正常加载。在jslib函数里主动加console.log这是最直接的观察点。比如在NotifyH5Ready开头把name和payload都打出来马上能确认C#是否真的调了过来、参数是否是预期的字符串。Unity的Debug.Log在WebGL下会输出到浏览器Console所以C#侧的日志也能同窗口看到。在Console面板手动输入unityInstance回车。如果打印出了实例对象说明运行时已就绪如果undefined说明createUnityInstance还没完成。给jslib函数设置断点在Sources面板里搜索build.framework.js找到NotifyH5Ready函数体下断点后触发Unity侧动作可以看到函数的入参指针再通过作用域面板查看UTF8ToString(ptr)的结果排查参数封送问题。调试过程中遇到NotifyH5Ready is not a function这类报错优先检查三处jslib文件名是否在Assets/Plugins/WebGL下mergeInto函数是否拼写正确C#侧函数名和jslib函数名是否完全一致。我遇到过一次因为C#函数名多了个空格导致运行时找不到符号排查了半小时才发现。结尾留个经验每次做都会用到的时机控制小技巧过去几个项目做下来我慢慢养成了一个习惯所有和H5通信的入口都收敛到同一个GameBridge组件里不散落在各个业务脚本中。这个组件只干三件事——接收JS侧的SendMessage消息、发送jslib事件给JS、维护去重和时序。后续增加支付回调、分享回调、场景切换通知都只要在这个组件里加方法不用到处找调用点。另一个小细节第一次发OnUnityBusinessReady通知前我在C#里会先yield return null等一帧确保所有可见UI元素完成首帧布局避免H5在收到通知的瞬间去查某个图片资源还没加载出来的尴尬。这一帧的代价几乎为零但能让整个首屏体验从容不少。如果你也踩过通知发了但页面表现不完整的怪问题不妨试试这个时机微调。