
做 Unity WebGL 的同学应该都有过这个经历游戏画面好不容易在浏览器里跑起来了前端页面却还在那傻站着转圈。问题不是加载慢而是前端压根不知道 Unity 到底什么时候算真正“好了”。Unity3D 用 C# 脚本调用前端 JavaScript 函数让 H5 页面拿到 Unity WebGL 加载并初始化完成的消息——这是个很小的桥接需求但涉及 jslib 插件、运行时生命周期、字符串内存传递一整套链路踩的人不少。这篇文章我把从方案选型到完整实现、再到常见坑位全过一遍适合正在做 H5 游戏、需要和 Unity 互通消息的开发者参考。1. 通信机制与方案选型1.1 Unity WebGL 与浏览器的关系是什么样的Unity WebGL 不是把游戏变成一个普通网页那么简单。它真正的形态是Unity 引擎和你的 C# 逻辑被编译成 WebAssembly简称 WASM再配合一套 JavaScript 运行时跑在浏览器沙箱里。你可以把整个 Unity 产物想象成一个“海外的功能车间”C# 是车间内部的管理系统浏览器页面是“主控室”。问题在于C# 这个管理系统默认没有和主控室直接对话的权限。它既不能直接读window对象也不能直接调用document.getElementById更没法主动告诉页面“我初始化完了”。反过来前端 JavaScript 虽然能通过 Unity 暴露出来的实例调用游戏内方法但想批量、动态地做事情还得靠 Unity 这边提供钩子。所以就会出现一个技术名词jslib 插件。它是 Unity WebGL 官方提供的一种机制允许开发者在.jslib文件里写 Native JavaScript 函数然后通过 C# 的[DllImport(__Internal)]来调用。C# 想调用前端函数唯一正规的路径就是先走到 jslib再由 jslib 去调用浏览器环境里的函数。理解这个链路后面所有代码就好懂了。1.2 几种常见通信方案对比我在不同项目里试过几种做法这里直接对比一下帮你少走弯路。Application.ExternalCall(func, arg)老 APIUnity 5.x 时代还能用新版 WebGL 下已经被标记为过时部分版本直接不生效。不建议在新项目里碰它。jslib 插件 [DllImport(__Internal)]目前最通用、最干净的方式。C# 这边声明外部函数jslib 里实现具体 JS 逻辑可以调用window上任意函数。前端主动SendMessage这是反方向也就是 JS 调 C#。它能解决一部分需求但没法解决“Unity 主动告诉前端我准备好了”这个场景——因为前端必须等 Unity 实例创建完才能发消息而初始化完成的消息恰恰发生在这个阶段。通过 URL 参数、querySelector轮询、定时器检测属于土办法能跑但不可靠性能也不好不建议用在生产项目里。结论很直接如果你想实现“C# 脚本调用前端 JavaScript 函数”就用 jslib 方案如果你还想让前端主动调 C#就再把SendMessage加上。组合起来就是完整的双向通信。1.3 为什么需要“初始化完成”这个消息很多人会问前端不是已经在加载createUnityInstance的 Promise 里了吗Promise resolve 了不就代表加载完了吗这个理解不算错但不完整。createUnityInstanceresolve 的时候Unity 的 WASM 模块已经加载完毕引擎也开始启动可这不等于你游戏里的业务逻辑准备好了。比如场景还没加载完画面还黑着。某个 C# 单例还在初始化配置还没有注入。登录模块、数据层还没准备好此时接收消息必然出错。所以“加载完”和“初始化完成”是两个概念。前端要的是一个业务层面的明确信号Unity 内部所有该准备的都准备好了可以开始接收外部指令了。这个信号最好的来源就是 Unity 自己通过 C# 脚本在合适的时机调用 JS 函数通知出去。2. 前端监听“Unity 加载并初始化完成”的整体设计2.1 WebGL 加载流程与实例生命周期要设计好监听方案得先搞清楚 Unity WebGL 在页面里经历了哪些阶段。第一阶段HTML 加载基础脚本比如Build.loader.js、Build.framework.js和.wasm文件。第二阶段调用createUnityInstance引擎启动创建 WebGL 上下文。第三阶段引擎加载场景数据、解析资源。第四阶段C# 的Awake、OnEnable、Start依次执行。第五阶段渲染第一帧游戏正式进入运行状态。前两个阶段前端能通过 Promise 感知第五个阶段开始游戏内部才算真正“活”了。所以我一般会建议在 C# 的Start里做第一次通知但也别太着急。Start执行的时候虽然脚本已经初始化完但 WebGL 上下文和第一帧渲染可能还没完全稳定。更稳的做法是延后一帧或者直接用协程等到当前帧结束再给前端发消息。2.2 前端监听方案的实现前端监听的核心其实很简单Unity 在完成初始化后调用一个全局的 JavaScript 函数前端提前把这个函数挂到window上即可。如果只做单方向消息jslib 里可以直接这样写mergeInto(LibraryManager.library, { NotifyUnityReady: function () { if (typeof window ! undefined window.__unityReady) { window.__unityReady(); } } });前端页面预先注册window.__unityReady function () { console.log(Unity 初始化完成); document.getElementById(loading).style.display none; };这种方式的特点是直白、可控。Unity 完成初始化后主动调用 jslib 函数jslib 再调用前端全局函数。整个链路非常清晰。如果不想污染全局命名空间也可以用自定义事件mergeInto(LibraryManager.library, { NotifyUnityReady: function () { if (typeof window ! undefined) { window.dispatchEvent(new CustomEvent(unity-ready, { detail: { ts: Date.now() } })); } } });前端用window.addEventListener(unity-ready, handler)监听即可。两种方案我都用过小项目用全局回调函数最省事中大型项目或者可能嵌入多个 Unity 实例时建议用自定义事件隔离性更好。2.3 时序问题如何避免消息丢失这个坑我印象很深。早期做项目时前端在window.onload里注册回调Unity 加载比较快结果等前端注册完消息早就发出去了导致页面永远收不到“初始化完成”的通知。解决办法有两个方向方向一前端尽早注册回调。把回调注册放到页面头部的脚本里不要等到DOMContentLoaded或者window.onload再注册。方向二Unity 这边做“消息重发”或者“状态缓存”。比如 jslib 里检查window.__unityReady是否存在如果不存在就先把标志位window.__unityReadyFired true存起来。前端注册回调时检查这个标志位如果已经置位说明 Unity 早就完成了直接执行回调即可。方向二更保险。我在开头提到“H5 获取 Unity WebGL 加载并初始化完成的消息”实际项目里最怕的就是消息发出去了没人接。用一个标志位缓存状态比单纯的事件监听可靠得多。3. C# 调用 JavaScript 函数的完整实操3.1 搭建 .jslib 插件桥首先在 Unity 工程的Assets/Plugins/WebGL目录下新建一个.jslib文件名字随意比如WebBridge.jslib。目录很关键如果放错位置Unity 不会把它识别成 WebGL 插件构建时不会打包进去。一个基础的.jslib文件长这样mergeInto(LibraryManager.library, { NotifyUnityReady: function () { if (typeof window ! undefined window.__unityReady) { window.__unityReady(); } }, SendMessageToPage: function (jsonString) { var data UTF8ToString(jsonString); if (typeof window ! undefined window.__unityMessages) { window.__unityMessages(data); } } });注意mergeInto是 emscripten 提供的方法用于把自定义函数合并进 Unity 的运行时库。函数名要严格控制因为 C# 那边声明的外部函数名必须和这里的键名完全一致。还需要留意一个细节.jslib里可以使用UTF8ToString这个工具函数把 C# 传来的指针转成 JS 字符串。这是 Unity/emscripten 运行时自带的辅助函数实测可以直接用不需要额外引入。3.2 C# 侧声明与调用C# 这边要做的第一件事是声明外部函数。我一般单独建一个静态类来管理所有桥接方法using System.Runtime.InteropServices; public static class WebBridge { #if UNITY_WEBGL !UNITY_EDITOR [DllImport(__Internal)] public static extern void NotifyUnityReady(); [DllImport(__Internal)] public static extern void SendMessageToPage(string jsonString); #else public static void NotifyUnityReady() { // 编辑器预览或非 WebGL 平台时的空实现 } public static void SendMessageToPage(string jsonString) { // 编辑器预览或非 WebGL 平台时的空实现 } #endif }这里有一个关键点[DllImport(__Internal)]只在 WebGL 平台有效。如果直接在 Unity 编辑器里运行会因为找不到__Internal这个 DLL 而报错。所以必须用#if UNITY_WEBGL !UNITY_EDITOR包一层同时在#else里提供空实现这样编辑器里可以正常测试不会打断开发流程。调用时机也很讲究。我建议在某个全局管理器的Start里做延后调用private IEnumerator Start() { // 先等待这一帧渲染完成确保场景基本就绪 yield return null; WebBridge.NotifyUnityReady(); }如果一个场景里有多个脚本谁都可能在Start里发消息那就应该约定一个统一的入口比如在某个GameBootstrap单例里统一发。分散发消息会让调试变得很痛苦你根本不知道哪条消息先到。3.3 字符串、JSON 与内存管理实际操作中肯定不只是发一个“我准备好了”的空信号更多时候要传一些参数比如初始化结果、配置信息、用户状态等。这时字符串传递就成了躲不开的话题。C# 向 JS 传字符串很简单直接当参数传string initData {\code\:0,\msg\:\ready\}; WebBridge.SendMessageToPage(initData);JS 侧用UTF8ToString接收SendMessageToPage: function (jsonString) { var data UTF8ToString(jsonString); // data 就是 C# 传过来的字符串 }反过来如果 JS 想返回字符串给 C#事情就没那么轻松了。JS 不能直接返回一个普通字符串给 C#它只能返回一个指针。常见做法是GetStartupConfig: function () { var str {sdkToken:xxx,serverUrl:https://example.com}; var buffer _malloc(str.length 1); stringToUTF8(str, buffer, str.length 1); return buffer; }C# 这边的声明也很简单[DllImport(__Internal)] public static extern string GetStartupConfig();但这里有个内存隐患C# 会读取指针并复制字符串但 JS 端_malloc出来的内存没有人释放如果频繁调用内存会持续增长。我一般在 JS 端用一个临时缓冲池或者把返回值设计成void 回调函数的方式避免让 JS 主动返回字符串。能用回调尽量用回调这是我在几个项目里踩出来的经验。双向通信的代码结构也更清晰。3.4 传递复杂参数与通信协议设计当双方的数据量变大之后简单的字符串参数就不够用了。我的建议是统一用 JSON 字符串作为通信协议。C# 侧可以序列化一个对象[Serializable] public class UnityReadyPayload { public int code; public string message; public string version; public string sceneName; }然后这样发送var payload new UnityReadyPayload { code 0, message unity ready, version Application.version, sceneName UnityEngine.SceneManagement.SceneManager.GetActiveScene().name }; string json JsonUtility.ToJson(payload); WebBridge.SendMessageToPage(json);JS 侧收到后直接JSON.parse(data)就能拿到结构化数据。这种方式的好处是后续要加字段、加协议都不需要改 jslib 接口前后端只管解析 JSON 就行扩展性非常好。真实项目里我还见过有人把方法名也编码进 JSON 里做成一套简易的 RPC。比如前端sendToUnity({ method: login, args: {...} })Unity 收到后根据method分发到不同处理函数。这套机制一旦建立起来后续加接口就是加分支的事不用反复改 jslib。4. 常见问题排查与避坑实录4.1 常见问题速查表我把平时在社区里被问得最多的几个问题整理成了表格基本涵盖了 Unity WebGL 桥接通信的典型坑现象可能原因解决方法页面一直收不到 ready 消息前端回调注册太晚消息已发出页头注册回调或 Unity 侧缓存状态标志位jslib 函数找不到.jslib文件没放在Assets/Plugins/WebGL目录检查目录位置重新构建编辑器下调用报 DllNotFound[DllImport(__Internal)]在非 WebGL 平台被调用用#if UNITY_WEBGL !UNITY_EDITOR隔离C# 传中文乱码UTF-8 编码处理不正确确认所有源文件保存为 UTF-8用UTF8ToString解析反复调用 JS 返回字符串后内存上涨JS 端_malloc没有释放改为回调传参或在 JS 侧维护缓冲池多个 Unity 实例互相干扰全局函数名冲突用实例 ID 或命名空间包裹避免挂到window上Application.ExternalCall无效老 API 已被移除改用 jslib DllImport 方案Promise resolve 后画面还是黑屏引擎加载完但业务初始化未完成以前端业务为准等 Unity 主动发 ready表格里每一条我都碰到过尤其是前两条属于“看起来简单排查半天”的典型问题。4.2 几个值得展开的坑先说“前端收不到 ready”这个问题它远比想象中复杂。除了注册时序太晚还有一种情况是 Unity 发了消息但前端因为脚本报错导致window.__unityReady没被正确赋值。这种问题在 DevTools 里看 Network 面板是看不出来的必须直接在 Console 里检查window.__unityReady是否存在。所以我现在的做法是前端注册回调后立即打印一条console.log([bridge] ready handler registered)Unity 侧在发出消息时也在 jslib 里打印一条日志。两边一对比消息在哪个环节丢了一目了然。再说字符串乱码。Unity WebGL 的默认编码是 UTF-8但如果你在 Windows 上用一个不是 UTF-8 编码的源文件字符串内容可能在你没意识到的情况下被转成其他编码。这个问题在本地编辑器里不一定能复现因为编辑器里你会直接看到中文但发布到浏览器后前端就收到乱码了。我的建议是所有涉及跨端通信的源文件统一保存为 UTF-8 with BOM或者干脆把中文内容放到配置表/资源文件里C# 只传 key前端按 key 查文案。后者更彻底还顺便解决了多语言问题。还有一个关于构建模板的坑。Unity 默认生成的index.html里createUnityInstance的调用时机、unityInstance变量的作用域和你自己写的模板可能不一样。如果你改了模板务必保留一个全局的unityInstance引用。因为在某些时候前端需要调用unityInstance.SendMessage(...)往 Unity 发数据如果这个引用丢了整个双向通道就断了。我习惯在模板里加一行window.unityInstance await createUnityInstance(canvas, config, onProgress);这样任何脚本都能拿到实例排查问题也方便。4.3 编辑器预览与真机环境的差异很多人在 Unity 编辑器里测试桥接代码发现一切正常但发布成 WebGL 后却完全没用。原因很简单编辑器环境下[DllImport(__Internal)]根本不会走我上面给的#else空实现让代码“看起来正常”但实际什么都没做。所以我有两个建议第一业务逻辑不要依赖桥接返回值做核心判断。在编辑器里用模拟数据在 WebGL 里用真实桥接数据两者之间做好隔离。第二务必准备一个专门的 WebGL 构建配置独立于编辑器预览。所有通信逻辑以 WebGL 版本的日志为准编辑器里的正常不能说明任何问题。能坚持这两点你在排查通信问题时能省掉一大半时间。5. 从“通知完成”到“双向命令”的扩展思路5.1 完成消息只是第一步“Unity 初始化完成”这个通知本质上是一条最简单的消息Unity 主动调 JSJS 收到后做界面处理。但这条链路完全可以扩展成一套通用的双向通信协议。前端到 Unity 的方向可以用SendMessagewindow.unityInstance.SendMessage(BridgeObject, OnPageCommand, JSON.stringify({type: startGame, level: 3}));Unity 侧在BridgeObject上挂一个脚本写好OnPageCommand方法public void OnPageCommand(string json) { var data JsonUtility.FromJsonPageCommand(json); // 根据 data.type 分发处理 }配合前面 C# 调 JS 的方向一个完整的请求-响应闭环就出来了前端发命令给 UnityUnity 处理完用 jslib 回调前端结果。很多 H5 游戏的登录、支付、分享、排行榜功能本质都是这个模型。5.2 多场景、多实例与卸载清理如果你的页面里可能同时存在多个 Unity 实例或者同一个页面会反复创建和销毁 Unity那就要格外注意消息隔离。我之前做过一个活动页同一页面里能切换多个 Unity 小游戏结果每个实例都把回调挂在window.__unityReady上后创建的实例把前面的回调覆盖了页面上的多个游戏互相串消息。解决办法是给每个实例加独立的命名空间或者在消息里带上实例 IDWebBridge.SendMessageToPage({\instanceId\:\game_001\,\status\:\ready\});前端按实例 ID 分发到对应的处理逻辑。如果用的是自定义事件也可以在CustomEvent的detail里带上实例 ID。总之只要消息里能区分来源多实例就不会乱。销毁实例时也要记得解绑事件。Unity 页面跳转或者实例销毁后前端window上挂的回调如果不清理下次创建新实例时容易触发幽灵消息。我通常会在实例销毁前发一条“destroying”状态前端收到后主动移除监听形成完整的生命周期管理。6. 调试工具与经验心得6.1 浏览器 DevTools 的实战技巧Unity WebGL 的调试不像普通前端那么直接但也没那么玄学。我常用的几个技巧在 jslib 函数里多打console.log这是排查 C# 到 JS 链路最快的方式。在浏览器的 Console 里直接执行window.__unityReady能确认前端回调是否已经注册。用 Performance 面板看线程阻塞情况。WebGL 游戏初始化时通常有大量资源编译和加载这个阶段会明显卡顿如果前端要显示加载进度最好把进度回调从 Unity 侧发出来而不是前端自己猜。在 Network 面板里观察.wasm、.data、loader.js的加载顺序和耗时可以判断问题出在下载阶段还是引擎初始化阶段。这些技巧用熟了之后一个通信问题从出现到定位基本在五分钟内就能完成。6.2 一个失败案例的复盘去年我做过一个海外小游戏项目上线没几天就有用户反馈页面常常停在加载界面进不了游戏。排查了大半天发现是这样一个问题Unity 某个场景在初始化时依赖后端接口返回配置而配置接口偶发超时导致 C# 侧迟迟不执行到Start方法里的 ready 通知前端就一直等。当时我们把 ready 通知安排在Start里但 Start 本身需要等场景中所有依赖初始化完成。覆盖这个场景后我把通知拆分成了两个第一层engineReady引擎启动渲染正常由Start延后一帧发送。第二层bizReady业务数据就绪由业务初始化完成后发送。前端根据业务需要选择监听哪一层。页面加载进度条在engineReady后收起进入核心玩法前再等bizReady。这个改动上线后加载卡住的反馈几乎为零。所以要记住一个原则消息粒度不要拍脑袋定要根据前端的等待场景拆层。不是说发了“ready”就万事大吉关键是“ready”对前端意味着什么。6.3 最后分享一个小经验我在实际项目里每次写桥接代码都会额外加一个“回显测试”函数Unity 调用PingJS 收到后立刻调用window.__unityPong通知 Unity。两边都打日志。这个测试函数看起来多余但它能在开发早期快速确认整条链路是否通省掉大量排查时间。等通信稳定之后不删也没事它不会影响性能。另外建议把 jslib 里所有能调用的函数名写成一个清单文档。项目一复杂今天加一个、明天加一个到最后没人记得哪些函数能用、参数是什么。有个清单后续无论换人还是自己维护都能很快上手。