Unity项目打包成WebGL之后,页面里其实有两套逻辑在同时跑:一套是Unity编译出来的C#运行时,另一套是浏览器里普普通通的JavaScript。这两套东西怎么互相喊话、传数据、同步状态,是我这些年被问得最多的问题之一,也是做Unity WebGL项目时绕不开的核心环节。这篇东西我尽量把原理讲明白、把代码写完整,再把这些年踩过的坑都摆出来,给Unity开发者和前端同学一个能直接抄作业的参考。
事情的起因很简单。某次做一个带3D场景的Web项目,Unity这边负责渲染模型和物理交互,前端负责整套业务UI和登录流程。两边各写各的,看起来井水不犯河水,结果一联调才发现,前端点按钮要通知Unity切换动画,Unity里某个操作完成后要回传数据给前端更新页面,两边谁也绕不开谁。那时候赶进度,网上东拼西凑找了一堆代码,能用但很乱,后来才慢慢整理出一套稳定的通信方案。今天聊的内容,就是把我整理后的方案和心得完整走一遍。
这篇内容适合两类人:一类是Unity开发,正在做WebGL导出却搞不清和JS怎么对接;另一类是前端工程师,页面里要嵌入Unity模块,需要控制Unity内的对象。下面从原理开始讲。
1. 通信的整体设计思路:Unity WebGL和前端到底是怎么“连线”的
1.1 先理解Unity WebGL的运行真相
很多人一开始会误以为Unity WebGL像Flash那样,是一个独立运行在页面里的插件。实际上Unity WebGL是把C#代码编译成WebAssembly(或者传统浏览器里的asm.js)格式的二进制模块,运行的时候由一个加载器(loader)在页面里启动,然后通过WebGL接口把画面渲染到一块canvas画布上。
也就是说,Unity代码在浏览器里并不是以“一个JS对象”的形式存在,而是以一个独立的运行时(runtime)存在。前端JavaScript和Unity之间没有共享的堆内存,也不存在“直接调用对方函数”这种简单的对应关系。两边要通信,必须通过Unity官方提供的桥接机制,这就像两个国家的人语言不通,必须靠翻译官传递消息。
我习惯用一个生活化的类比来解释:Unity WebGL就像住在一间独立房间里的人,有自己的生活方式和家具;网页里的JavaScript是房间的主人。房间主人想给Unity递个纸条,得通过门缝塞进去;Unity想喊话给主人,也得通过一个特定的传话筒。这个门缝和传话筒,就是后面要讲的SendMessage和jslib插件。
1.2 三条桥梁,两种方向
Unity与前端通信,方向上分两类:前端到Unity、Unity到前端。每一类都有对应的官方机制:
- 前端到Unity:通过Unity实例的SendMessage方法,直接往Unity场景里的某个GameObject发送消息,触发该对象上挂载的公开方法。
- Unity到前端:通过jslib插件。所谓jslib,是Unity专门为WebGL导出提供的一套JavaScript插件文件,放在Assets/Plugins/WebGL目录下。C#代码通过DllImport声明并调用jslib里的函数,jslib函数内部再调用页面上的JavaScript函数或触发事件。
- 双向混合:很多成熟项目会设计一个消息总线,把两边的通信都抽象成“事件”,Unity和前端各自注册监听,互不直接依赖。这个模式在后端逻辑越来越多时会非常省心。
为什么不能直接从Unity的C#里调用一个页面全局变量上的任意函数?因为C#和JavaScript是两种完全不同的运行时,C#代码编译进WebAssembly后,函数地址、调用约定都和JS不一样。Unity官方提供jslib机制,本质上就是在两者之间做一个函数映射:你把JS函数按照约定写在一个插件文件里,编译时Unity会把这个函数暴露给C#侧调用。
理解了这个模型,后面所有代码就都能串起来了。
2. 前端主动调用Unity:SendMessage的完整用法
2.1 前置条件:稳定拿到Unity实例
前端调用Unity的第一件事,是拿到Unity实例(unityInstance)。这个实例是在Unity加载器的启动回调里返回的,不同版本的Unity导出的模板代码略有不同,但核心逻辑是一致的。
以Unity 2021之后的WebGL模板为例,加载代码大致长这样:
<script src="Build/WebGL.loader.js"></script> <script> var myGameInstance = null; function onUnityReady(callback) { if (myGameInstance) { callback(myGameInstance); return; } createUnityInstance(document.querySelector('#canvas-container'), config) .then(function (instance) { myGameInstance = instance; callback(instance); }) .catch(function (error) { console.error('Unity启动失败:', error); }); } </script>注意,早期版本(Unity 2018之前)的模板会把Unity实例挂到window.unityInstance这个全局变量上,所以很多老代码里直接写unityInstance.SendMessage(...)。新版本模板不再强制全局暴露,而由你自己在回调里保存实例。项目里如果既有老模板又有新模板,最好做一个统一封装,比如:
function getUnity() { return window.unityInstance || window.myGameInstance; }这里有个实战细节:Unity实例只有在WebAssembly初始化完成之后才能真正接收SendMessage调用。所以不要一进页面就立刻调用,正确做法是等加载器的Promise resolve了再开始通信。如果页面里需要提前调用,可以把调用缓存起来,等实例就绪后统一执行。
2.2 核心调用:SendMessage的三种参数和限制
SendMessage的用法非常简单,一句话就能概括:
unityInstance.SendMessage(对象名, 方法名, 参数);对象名是Unity场景中某个GameObject的完整路径或名称;方法名是该对象上挂载脚本中的一个public方法;参数是可选的,可以是字符串或数字。
官方限制有三点特别容易踩:
- 目标GameObject必须是当前场景中激活状态的。如果对象在层级面板里被禁用,或者场景还没加载到,SendMessage直接报错。
- 方法必须是public的。private、protected方法在WebGL下不会出现在消息分发里。
- 参数只能是string、number(整数或浮点数),不能直接传数组、对象、布尔值。布尔值在部分版本里可以强转,但稳妥起见我用0/1代替。
实际项目中经常遇到的问题是“前端要传一个包含多个字段的配置对象给Unity”。这时候直接传对象是行不通的,只能先把对象序列化成JSON字符串,再作为参数传过去。
2.3 复杂数据怎么传:JSON序列化与反序列化
前端侧的做法:
var message = JSON.stringify({ action: 'moveTo', position: { x: 1.5, y: 0, z: 3.2 }, speed: 2.0 }); unityInstance.SendMessage('GameManager', 'HandleWebMessage', message);Unity侧的接收方法:
public class GameManager : MonoBehaviour { [System.Serializable] public class WebMessage { public string action; public PositionInfo position; public float speed; } [System.Serializable] public class PositionInfo { public float x; public float y; public float z; } public void HandleWebMessage(string json) { var msg = JsonUtility.FromJson<WebMessage>(json); // 根据msg.action执行对应的逻辑 } }这样传过去之后,C#里拿到的就是一个强类型对象,可以直接用。这里要特别注意JSON字段名和C#字段名的匹配问题:JsonUtility默认要求字段名完全一致,大小写敏感。C#的property或者字段用Pascal命名,前端通常用camelCase命名,非常容易踩坑。
我的建议是:前端序列化时直接按照C#端字段名来,或者用第三方JSON库加字段映射处理。Unity自带的JsonUtility功能偏弱,比如不支持Dictionary类型,数组的表现也很基础。如果项目数据结构复杂,建议在WebGL构建里使用第三方JSON库,但要注意后面会提到的IL2CPP裁剪问题。
2.4 包一层JS API,别让业务代码裸写SendMessage
直接在每个业务模块里写unityInstance.SendMessage('GameManager', 'HandleWebMessage', json)不是不行,但随着页面逻辑越来越多,这种方式会失控:对象名写错一个字符、方法名改动了、传参格式不统一,排查起来非常痛苦。
我习惯在前端做一层统一封装,类似这样:
var UnityBridge = { _instance: null, ready: false, queue: [], init: function (instance) { this._instance = instance; this.ready = true; while (this.queue.length) { this.send(this.queue.shift()); } }, send: function (msg) { if (!msg.action) { throw new Error('消息必须包含action字段'); } if (!this.ready) { this.queue.push(msg); return; } var json = JSON.stringify(msg); this._instance.SendMessage('GameManager', 'HandleWebMessage', json); }, moveTo: function (x, y, z) { this.send({ action: 'moveTo', position: { x: x, y: y, z: z } }); } };业务代码里只需要调用UnityBridge.moveTo(1, 2, 3),不需要关心Unity对象名、方法名、序列化细节。后续Unity侧改名了,只需要改封装这一处。这个习惯放到任何项目里都会让你少踩很多坑。
3. Unity主动调用前端:jslib插件与事件通知
3.1 创建并挂载自己的jslib文件
Unity主动调前端,最规范的方式是写jslib插件。我先说文件位置和基本结构。
在Unity项目里新建一个文件夹:Assets/Plugins/WebGL,在里面创建一个文本文件,命名为MyBridge.jslib。Unity会把这个目录下的所有.jslib文件自动打包进WebGL导出产物。
文件内容的基本结构是:
mergeInto(LibraryManager, { MyBridge_SendToJS: function (message) { var msg = UTF8ToString(message); window.dispatchEvent(new CustomEvent('unityToWeb', { detail: msg })); } });这个写法是Unity多年来的标准写法:把JS函数挂到LibraryManager对象上,Unity编译时会把MyBridge_SendToJS这个函数映射到C#侧,供DllImport调用。
有一个版本兼容小知识:Unity 2021之后,官方推荐使用LibraryManager.library写法的趋势,但mergeInto在多数版本下依然兼容。为了稳妥,我项目里会同时兼容两种环境:
if (typeof mergeInto === 'undefined') { mergeInto = LibraryManager.mergeInto; } mergeInto(LibraryManager, { MyBridge_SendToJS: function (message) { var msg = UTF8ToString(message); window.dispatchEvent(new CustomEvent('unityToWeb', { detail: msg })); } });这么做是以防Unity后续版本移除全局mergeInto,导致老代码失效。实测下来,这条兼容写法在多个大版本里都能正常使用。
3.2 C#侧封装:DllImport与编辑器模拟
jslib写好了,C#侧怎么调用?需要先声明外部函数:
#if UNITY_WEBGL && !UNITY_EDITOR using System.Runtime.InteropServices; #endif public static class WebBridge { #if UNITY_WEBGL && !UNITY_EDITOR [DllImport("__Internal")] private static extern void MyBridge_SendToJS(string message); #endif public static void SendToJS(string message) { #if UNITY_WEBGL && !UNITY_EDITOR MyBridge_SendToJS(message); #else Debug.Log("[Editor模拟调用JS] " + message); #endif } }这里有两个关键点。
第一,DllImport必须写在UNITY_WEBGL && !UNITY_EDITOR的条件编译里。因为在Unity编辑器里运行时,并不存在“__Internal”这个动态库,如果不包条件编译,编辑器直接报DllNotFoundException。
第二,编辑器里无法调用jslib,所以要写一个模拟分支,让业务逻辑在编辑器里也能跑通。比如自己的测试环境没有浏览器,就可以让SendToJS在编辑器里直接打日志,这样不阻塞开发流程。
实际调用时,C#代码只需要:
WebBridge.SendToJS("{\"type\":\"levelComplete\",\"score\":1200}");3.3 在jslib里触发前端事件
jslib里调用前端的方式有很多种,最直接的是调用页面上的全局函数:
MyBridge_SendToJS: function (message) { var msg = UTF8ToString(message); if (window.androidCallback) { window.androidCallback(msg); } }这种方式简单直接,但有一个缺点:全局函数名是硬编码的,页面里如果存在多个Unity模块实例,或者嵌入在第三方页面里时,全局名称冲突就是个大问题。所以我更推荐使用自定义事件(CustomEvent)的方式:
MyBridge_SendToJS: function (message) { var msg = UTF8ToString(message); window.dispatchEvent(new CustomEvent('unityToWeb', { detail: msg })); }前端在任何地方注册监听:
window.addEventListener('unityToWeb', function (event) { var msg = JSON.parse(event.detail); // msg.type 决定业务处理逻辑 console.log('来自Unity的消息:', msg); });事件机制的好处是解耦:Unity侧不需要关心页面是谁在监听,页面里多个模块可以各自监听同一个事件,互不影响。比如监控模块统一收集Unity上报的FPS、内存数据,业务模块同时处理游戏状态变更,两者各听各的,互不干扰。
3.4 高频通信的性能调度
WebAssembly与JavaScript的互相调用是存在边界开销的,不要小看这个开销。我做过一个性能对比测试:一帧里调用SendMessage 50次以上,低端手机浏览器上帧率下降明显;100次以上时卡顿肉眼可见。所以高频通信必须做合并或降频。
常见的做法有三种:
- 数据合并:Unity侧把一帧内的多个事件拼成一个JSON数组,在FixedUpdate或协程的固定间隔里一次性发给前端。
- 事件累加器:用一个计数器记录变化次数,每隔一定时间(例如0.5秒)批量推送一次最新状态,而不是每次变化都推。
- 触发信号+拉取数据:Unity只通知JS“数值变了”,前端主动调用一个查询方法来获取最新数据。适合那种状态变化频繁、但前端UI不需要实时展示所有中间状态的场景。
一定要把“每次数据变化都通知前端”当成设计禁忌。前端UI更新本身就慢于游戏逻辑,很多时候只关心最终结果,不关心中间过程。
4. 数据格式、序列化与性能优化
4.1 消息协议设计:type + payload
通信的代码写多了之后,你会发现自己最需要的不只是“能通”,而是“可维护”。我强烈建议从一开始就约定一种统一的消息格式。目前我固定用的格式是:
{ "type": "事件类型", "payload": { "key": "value" } }Unity侧定义对应的C#类:
[System.Serializable] public class BridgeMessage { public string type; public string payload; // 嵌套JSON字符串 }前端发过来的payload统一为字符串,Unity侧根据type分发时再单独反序列化成对应的数据结构。这样做的好处是:BridgeMessage这个外壳永远不变,业务数据单独维护,协议演进时壳层代码不需要改。
注意Unity自带的JsonUtility不能处理多态和复杂继承结构,payload字段作为字符串传递,恰好绕开了这个限制。
4.2 序列化坑:JsonUtility的限制、IL2CPP裁剪
JsonUtility是Unity官方序列化库,和Editor序列化系统深度绑定,对Dictionary、继承链的支持很差。我遇到过一个真实案例:前端传一个JSON数组,用JsonUtility解析出来的List长度永远为0,后来排查发现是C#类型里的嵌套类没有加[System.Serializable]特性,导致字段被静默丢弃。
所以用JsonUtility时,所有参与序列化的类都必须标记[System.Serializable]。
另一个大坑是IL2CPP裁剪。Unity WebGL构建默认使用IL2CPP,它会做代码裁剪,把“看起来没被直接引用”的代码移除。如果某个JSON解析类只在DllImport回调里被反射式使用,很容易被裁剪掉,运行时解析失败。
解决办法有两个:
- 在Assets目录下创建link.xml,保留需要用反射的类:
<linker> <assembly fullname="MyAssembly" preserve="all"/> </linker>- 在代码里主动用
[Preserve]特性标记这些类型(需要引用UnityEngine.Preserve命名空间)。
我建议显式写link.xml,因为WebGL项目后期引用第三方库越来越多,用特性到处标容易漏。
4.3 大数据与二进制数据的传输
字符串消息适合传配置、事件、玩家操作,但遇到模型数据、纹理数据、音频数据这类大体积内容时,直接走JSON再转Base64会有两个问题:序列化开销大、内存翻倍。
如果必须在大容量场景下传输二进制,社区里比较成熟的做法是:C#侧通过jslib把数据写入WebAssembly的内存缓冲区,然后把指针和长度传给JS侧,JS侧通过HEAPU8直接读取。
jslib侧的处理:
MyBridge_SendBinary: function (ptr, length) { var data = HEAPU8.subarray(ptr, ptr + length); var copy = new Uint8Array(data); // 拷贝一份,避免Unity内存回收后读取失效 var blob = new Blob([copy]); window.dispatchEvent(new CustomEvent('unityBinaryData', { detail: blob })); }C#侧:
[DllImport("__Internal")] private static extern void MyBridge_SendBinary(int ptr, int length); public static void SendBinary(byte[] data) { unsafe { fixed (byte* p = data) { MyBridge_SendBinary((int)p, data.Length); } } }这里有个细节:在Unity的WebGL平台,使用unsafe代码需要开启“Allow unsafe code”编译选项,并且在非WebGL平台上要包条件编译。这条路比JSON直接传快得多,但复杂度也高一些,只有在确实有大批量数据需求时才建议使用。
4.4 避免轮询,让Unity主动推
前端拿到Unity实例后,很容易写出这样的逻辑:用setInterval每隔100毫秒查询一次Unity当前状态,然后更新页面UI。这种轮询方式省事,但会持续产生边界调用,尤其在低端设备上会造成额外GC和性能损耗。
更好的方式是事件驱动:Unity状态变化时主动推给前端,前端只在收到消息时更新UI。如果某些数据确实需要前端按需拉取,应该设计一个“请求-响应”模型:前端发一条“查询”消息,Unity收到后回传一次数据。
这个模式下,前端页面逻辑更自然,Unity侧也不会有平白无故的周期性开销。唯一要注意的是请求响应的超时处理:如果Unity侧异常,长时间没有回包,前端要有兜底逻辑,避免页面状态卡死。
5. 常见问题与排查技巧实录
5.1 问题速查表
多年的实战中,我把高频问题汇总成了一张速查表,遇到问题先按表排查:
| 现象 | 可能原因 | 解决思路 |
|---|---|---|
| SendMessage报“Object xxx not found” | GameObject不存在、未激活、场景未加载 | 检查对象名和激活状态,场景加载完成后重试 |
| 报“Method xxx not found” | 方法不是public、拼写错误、脚本未挂载 | 确认方法签名和脚本挂载状态 |
| Unity收不到前端消息但无报错 | Unity实例尚未就绪 | 等Promise resolve后再发送,或先入队缓冲 |
| 调用jslib函数没反应 | 文件没放在Assets/Plugins/WebGL目录 | 检查文件位置,清理构建缓存并重新导出 |
| JSON解析后字段都是默认值 | 字段名不匹配、类缺少[Serializable]标记 | 逐一核对字段名,补全特性标记 |
| 编辑器运行时报DllNotFoundException | 条件编译没包编辑器模拟分支 | 补上UNITY_EDITOR分支兜底 |
| 页面卡顿严重 | 高频SendMessage、大数据JSON传输 | 合并消息、降频、改用二进制通道 |
| 中文在JS侧显示乱码 | 编码问题或变量在传输中被截断 | 确保UTF-8编码,手动toString测试 |
5.2 三个高频问题的详细排查
“Unity实例没就绪就发消息”是我见过最多的问题。解决办法是做成消息队列机制:前端发送时判断unityInstance.ready,如果没就绪,先把消息缓存,等Unity启动完成后一次性flush。这个队列机制前面代码里已经写过了,直接用就行。
“jslib函数找不到”也很常见。除了文件位置问题,还有可能是构建缓存导致的。Unity WebGL导出时偶尔会缓存旧的jslib,改动后不生效。我的习惯是:每次改完jslib,在Build Settings里勾选Clean Build,或者手动删除项目目录下的Library和Temp缓存再重新导出。还有一点要注意:jslib文件名改动后,旧文件如果还留在目录里,两个文件里的函数签名冲突,也会导致异常。
“Unity偶尔发消息给JS,前端监听不到”这个问题的根源通常在于你用了全局函数调用,但监听器的注册时机晚于Unity的执行时机。CustomEvent方案天然规避了这个问题,因为即使事件触发早于监听注册,数据也可以暂时存到全局变量或LocalStorage里,等监听器挂上之后再补发。最简单的做法:前端初始化时先把事件监听注册好,再初始化Unity,顺序不要反。
5.3 iframe嵌页场景的通信中转
还有一个非常典型的业务场景:Unity页面被嵌在第三方页面的iframe里,外部页面和Unity之间原来那种“同页面互相调用”就不成立了。因为iframe之间是独立的JavaScript环境,外部页面拿不到iframe内部的unityInstance。
此时需要的是iframe内页面做一层中转。方案是这样的:
iframe内部的Unity页面里:
// 为外部页面提供统一入口 window.addEventListener('message', function (event) { var data = event.data; if (data && data.type === 'toUnity') { myUnityInstance.SendMessage('GameManager', 'HandleWebMessage', JSON.stringify(data.payload)); } });外部页面里:
iframeEl.contentWindow.postMessage({ type: 'toUnity', payload: { action: 'pause', reason: 'tabHidden' } }, '*');反过来,Unity要通知外部页面时,iframe内部页面先把Unity事件转发到父级:
window.addEventListener('unityToWeb', function (event) { parent.postMessage({ type: 'fromUnity', payload: JSON.parse(event.detail) }, '*'); });这个方案实质上是把“Unity和JS的通信”翻译成“iframe和父页面的通信”,多了一层中转,但机制完全一样。安全性方面,实际项目中请让父页面指定固定的origin安全校验,不要在线上环境用通配符*。
6. 我的实操经验与几个避坑建议
6.1 统一消息协议,比“能用”更重要
我最早写Unity WebGL通信时,也是哪里需要调哪里,前端一个消息一个格式,Unity侧一个方法一个参数。结果项目越往后越乱,改一次协议要翻好几个文件。后来痛定思痛,花半天时间设计了一套统一协议,从此再没乱过。
协议的核心就一句话:所有跨端消息都走同一个出入口,消息内容用type区分业务语义。前端任何业务消息都通过UnityBridge.send发出,Unity任何消息都通过WebBridge.SendToJS上报。这里不需要复杂的消息框架,只需要一个简单的分发器,就足以让几十个业务功能在几万行代码里保持结构清晰。
6.2 让调试日志跨端可追踪
联调时最痛苦的事,是Unity侧报了个错、前端侧也报了个错,但两个错误到底谁先谁后,往往要看半天。我的做法是统一日志格式:所有跨端消息在发送和接收处都打日志,日志里带上统一的请求序列号。
前端发消息时:
send: function (msg) { msg.seq = (this.seqCounter = this.seqCounter || 0) + 1; console.log('[Bridge→Unity] seq=' + msg.seq, msg); ... }Unity侧接收后打一条对应日志。这样只要用浏览器开发者工具过滤“Bridge”就能串起整条消息链路。排查问题时有这条链路和没有,效率差一倍。
6.3 多Unity实例与场景切换的注意点
同一个页面里同时挂两个以上Unity实例的情况虽然少见,但真遇到时,通信模块必须做好实例隔离。每个Unity实例需要有独立的命名空间、独立的Bridge封装、独立的事件前缀。否则两个实例的SendMessage会互相干扰。我的做法是给每个Unity实例的canvas分配独立ID,所有事件名加上实例前缀,例如unityA_sceneLoaded和unityB_sceneLoaded,各听各的。
Unity场景内切换时,通信接收对象也不能随便挂在会被销毁的场景对象上。我会专门建一个常驻的Bootstrapper对象,标记为DontDestroyOnLoad,所有跨端消息都先路由到它,再由它转发给当前场景里的业务对象。这样切场景时前端不需要知道场景变化,消息始终有人接收。
以上这些都是我做完几个Unity WebGL项目之后,沉淀下来觉得最有价值的部分。通信本身不复杂,但要把通信做好、做稳、做到真正好维护,靠的是对协议的设计、对边界的理解和对坑的预判。希望这份经验能帮你少走一段弯路。