☰
Unity WebGL与H5通信:jslib桥接C#通知前端初始化完成
2026/10/5 8:56:30 网站建设 项目流程

接手WebGL项目时,我最常被问的问题就是:H5页面怎么知道Unity内容已经加载完了?尤其当你要把Unity WebGL嵌入一个商城、活动页或者游戏大厅时,外层网页的DOM元素(比如一个"进入游戏"按钮)必须等Unity内部真正初始化完成后才能启用。如果瞎等或倒计时猜,结果不是按钮早亮、点了没反应,就是整个页面白屏几秒被人直接关掉。

这个需求落到技术实现上,就是标题里这回事:Unity的C#脚本主动调用前端的JavaScript函数,把"Unity WebGL加载并初始化完成"这件事同步给H5页面。我第一次做捕鱼题材的WebGL项目时,光在通信时序上就折腾了不少时间——海洋场景、鱼群模型、动作资源加起来体积不小,首页加载时间本来就长,初始化完成的信号再传不准,后面的引导流程全是乱的。

这篇文章把我踩过的坑、验证过的写法、以及推荐的项目结构全部整理出来。做WebGL的H5壳工程、做Unity渲染嵌入业务前后端联调的朋友,都可以直接参考。

1. 为什么Unity WebGL必须靠.jslib插件桥接C#与JS

1.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把交互指令发给Unity,Unity处理完再通过jslib把结果回调给JS。本文核心是后半条链路,但第五章会带上SendMessage把闭环串起来。

2. 搭桥第一步:创建UnityBridge.jslib并理解它的运行环境

2.1 jslib文件放哪、长什么样、谁在执行它

创建.jslib文件不需要额外安装任何插件,它是纯文本格式,就放在Assets/Plugins/WebGL/目录下即可。只要后缀名是.jslib,Unity的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.jslib:

mergeInto(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面板观察过完整过程,大致是:

  1. 页面加载build.loader.js
  2. 调用createUnityInstance或旧版UnityLoader.instantiate,开始拉取.wasm和.data文件
  3. wasm编译实例化,Unity运行时初始化
  4. 创建游戏主循环,加载第一个场景
  5. 场景中MonoBehaviour的Awake被调用
  6. 紧接着第一个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#封送时走的是int,0为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麻烦,但也有捷径。下面这组是我每次联调都会做的事:

  1. 打开浏览器DevTools的Console面板,保持网络面板开启,先确认.wasm和.data文件正常加载。
  2. 在jslib函数里主动加console.log,这是最直接的观察点。比如在NotifyH5Ready开头把name和payload都打出来,马上能确认C#是否真的调了过来、参数是否是预期的字符串。
  3. Unity的Debug.Log在WebGL下会输出到浏览器Console,所以C#侧的日志也能同窗口看到。
  4. 在Console面板手动输入unityInstance,回车。如果打印出了实例对象,说明运行时已就绪;如果undefined,说明createUnityInstance还没完成。
  5. 给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在收到通知的瞬间去查某个图片资源还没加载出来的尴尬。这一帧的代价几乎为零,但能让整个首屏体验从容不少。如果你也踩过"通知发了但页面表现不完整"的怪问题,不妨试试这个时机微调。

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

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

立即咨询