Unity WebGL与H5通信:C#主动通知JavaScript初始化完成

发布时间:2026/10/5 8:56:33
Unity WebGL与H5通信:C#主动通知JavaScript初始化完成
做Unity WebGL开发的兄弟八成都被一个问题磨过C#脚本这边辛辛苦苦把游戏逻辑跑起来了可H5页面那边还在傻傻等loading压根不知道Unity到底啥时候真正初始化完成。更头疼的是想让C#主动给JavaScript递个话还得折腾半天通信。这篇文章就专门掰扯清楚这件事——怎么用C#脚本调用JavaScript函数让H5在Unity WebGL加载并初始化完成的那一刻准时收到通知然后该隐藏加载条隐藏加载条该显示开始按钮显示开始按钮。整个方案适合正在做Unity WebGL打包、或者用H5壳子包游戏、还有在App内嵌H5上跑Unity玩法的朋友参考属于拿来就能用的实战套路不是光讲理论的科普文。1. 整体思路拆解到底谁先准备好谁先通知谁1.1 核心机制Unity WebGL的“桥”是怎么搭起来的要说清楚C#和JavaScript怎么互通先得知道Unity WebGL在浏览器里到底是个什么形态。Unity打包WebGL后浏览器里跑的不再是一个exe或者app而是一堆静态文件核心是.wasmWebAssembly二进制、.data资源包、.framework.js引擎框架这些。浏览器把它们加载下来再由WebAssembly引擎去解释执行本质上Unity游戏变成了一段跑在浏览器沙箱里的WebAssembly程序。这时候C#代码是跑在WebAssembly里的而JavaScript是跑在浏览器主线程里的它们俩原本不是一个世界的中间必须得有个“桥”。Unity官方提供的桥接方案其实是基于Emscripten编译器和WebGL runtime的底层机制C#通过DllImport(__Internal)声明一个外部方法底层在编译的时候会被映射到JS侧同名的全局函数上。这样C#调NotifyToJS()实际等于调用了浏览器环境里的NotifyToJS()函数消息就递过去了。理解这个机制有什么好处好处在于你能判断什么能传、什么不能传。比如字符串参数Unity内部会做编码转换JS侧拿到的是普通字符串但如果传结构体或者引用类型跨语言边界就得小心我一开始做的时候就遇到传int没问题、传string时不时乱码的怪毛病后来才知道字符串编码转换是有开销和坑的需要按规范处理。1.2 方案选型为什么“C#主动上报”比“H5轮询”靠谱很多第一次接触Unity WebGL的人第一反应是让H5页面用setInterval去轮询比如每隔100毫秒查一下Unity是不是初始化好了。这思路不是说完全不行但它有两个硬伤一是浪费Unity初始化还没到那个阶段时你每100毫秒发一次查询请求虽然数据量不大但浏览器主线程和WebAssembly之间来回切换是有成本的二是时序不稳万一轮询间隔太长错过关键节点H5侧可能误判Unity还在加载中导致用户看到游戏画面了loading条还死死顶着。正确的做法就是让C#自己来喊“我准备好了”。Unity引擎在WebGL环境里跑起来后脚本的生命周期是确定的Awake先执行然后是StartStart执行完基本可以认为游戏逻辑已经初始化。抓住这个时机在Start里通过DllImport调用JS函数把“初始化完成”的消息主动发出去。这种事件驱动的单向通知既精准又轻量还能顺带带上一些业务数据比如大厅配置、玩家信息、关卡进度等一条消息全搞定。1.3 真实业务场景H5壳子、App内嵌、小游戏容器通用我经手的项目里这种“Unity上报JS接收”的模式基本覆盖了三类场景。第一种是纯H5页面Unity作为页面里的一个canvas元素页面加载完先展示一张海报或loading动画等Unity初始化完成后进入游戏场景第二种是App内嵌H5比如用WebView加载H5页面H5内部再跑Unity WebGL这时候App原生层和H5之间还有一层桥Unity初始化完成的消息往往要一路传回App层去决定是否隐藏启动页第三种是微信小游戏方向虽然小游戏手动档和WebGL档不一样但NotificationMessage这套思想相通。所以这篇文章说的不只是一个函数调用而是一套消息机制。你学会了C#调JS稍加扩展就能做双向通信再往上就可以设计一套完整的“游戏生命周期事件总线”H5侧监听Unity的各种状态例如加载进度、初始化完成、首帧渲染完成、资源预热完成等整个H5壳子的状态机就变得极其清晰。2. 搭建通信基础C#函数怎么暴露给JavaScript调用2.1 官方标准姿势DllImport extern声明先上代码这是C#侧的声明方式using System.Runtime.InteropServices; using UnityEngine; public class WebGLBridge : MonoBehaviour { [DllImport(__Internal)] private static extern void NotifyToJS(string message); public void SendLoadedMessage() { #if UNITY_WEBGL !UNITY_EDITOR NotifyToJS({\type\:\unity_ready\,\timestamp\:\2024-01-01 12:00:00\}); #endif } }这里有几个关键点得划重点。第一DllImport(__Internal)这个字符串是固定的必须写成__Internal它代表“链接到内部JS环境”而不是加载一个真实的DLL。第二extern方法不能有方法体实现在JS侧你写方法体编译器会报错。第三方法名NotifyToJS必须和JS侧全局函数名完全一致区分大小写。第四强烈建议包一层#if UNITY_WEBGL !UNITY_EDITOR宏因为在Unity编辑器里跑的时候__Internal这个库不存在调用会直接抛异常不包宏连编辑器测试都没法做。注意在编辑器里想测试逻辑可以把NotifyToJS改成空实现或者用Debug.Log代替不然每次都报DllNotFoundException。这也是新手最容易踩的第一道坑。2.2 JavaScript侧的真实接收者全局函数还是unityInstanceJS侧接收的代码其实很直白就是把函数挂到全局作用域下window.NotifyToJS function(message) { console.log([Unity] NotifyToJS -, message); var msg JSON.parse(message); // 这里可以做业务处理 if (msg.type unity_ready) { hideLoadingUI(); showGameUI(); } };但这里有个非常容易出问题的点C#调用JS函数时Unity底层是直接调用那个名字的函数所以这个函数必须挂在window全局上。如果你把它挂在一个只在某个模块作用域里的函数上调用会直接报Uncaught TypeError: NotifyToJS is not a function。还有一个容易忽略的坑当Unity WebGL加载完成后Unity会在页面上创建一个UnityInstance对象这个实例通常保存在createUnityInstance的Promise回调里。C#侧发过来的消息在全局函数里处理没问题但你要是想在JS侧反过来主动调用C#方法你就必须持有这个unityInstance引用。所以创建Unity实例的代码要设计成let unityInstance null; const config { dataUrl: buildConfig.dataUrl, frameworkUrl: buildConfig.frameworkUrl, codeUrl: buildConfig.codeUrl, streamingAssetsUrl: StreamingAssets, companyName: TestCompany, productName: TestGame, productVersion: 1.0.0, }; createUnityInstance(canvas, config, (instance) { unityInstance instance; console.log([H5] Unity instance created); }).catch((error) { console.error([H5] Unity create error:, error); });后续JS想主动调C#就可以unityInstance.SendMessage(BridgeObject, ReceiveMessageFromJS, hello from JS);SendMessage的参数含义第一个是场景里GameObject的名字第二个是挂在它身上的C#组件方法名第三个是参数字符串。注意这里只支持字符串参数其他类型需要自己转换。2.3 创建实例的config到底该怎么配置才能用消息机制上面代码里出现了config这里面的dataUrl、frameworkUrl、codeUrl都是Unity构建产物里的文件名通常对应build.data、build.framework.js、build.wasm。你打开Unity打包生成的index.html里面会有一段自动生成的模板代码createUnityInstance的config就是从那里来的。在实践中我建议你直接基于Unity生成的标准index.html去改而不是从零写。因为index.html模板里其实还有几个回调方法比如onProgress加载进度回调、onModuleLoaded等这些在不改动整体结构的前提下嫁接我们的C#消息机制是最省力的。div idunity-container canvas idunity-canvas width960 height540/canvas div idunity-loading-bar div idunity-progress-bar/div /div /div这些DOM元素是Unity模板默认创建的Loading进度条也是它们负责显示的。我们的方向是用onProgress更新进度条等C#发来“初始化完成”再隐藏整个loading容器而不是等Unity实例创建完就立刻隐藏。因为实例创建完和Unity内部游戏逻辑初始化完成其实是两个概念这一点下文会细说。3. 从“加载中”到“就绪”初始化完成通知的完整实现3.1 到底什么才算真正的“加载并初始化完成”很多人有个误区觉得createUnityInstance的Promise回调执行了就代表Unity加载完成了。其实不对。createUnityInstance执行完只能说Unity引擎运行时在浏览器里启动起来了Canvas上可能还是一片空白C#脚本还没跑游戏场景的Awake和Start都没执行。从用户视角看那一刻跟白屏没任何区别。真正的“加载并初始化完成”我的判断标准是场景中主管理脚本的Start方法已经执行并且第一个逻辑帧已准备好渲染。Unity脚本生命周期里Awake在对象实例化时立即执行Start在下一次Update之前执行。因此挂载在初始化管理对象上的脚本Start()一旦跑起来就意味着场景里的基础组件已经被创建、缓存数据已就绪、UI初始化函数已经被调用过了。基于这个判断我建议把“准备完成”的主动上报放在Start里必要时配合协程延后一帧确保其他对象的Start也都跑完了void Start() { StartCoroutine(SendReadyAfterFrame()); } private IEnumerator SendReadyAfterFrame() { yield return null; yield return null; #if UNITY_WEBGL !UNITY_EDITOR NotifyToJS({\type\:\unity_ready\}); #endif }延后两帧有两个考量让Camera、UI等组件在同一帧初始化完成后更稳定同时给JS侧的全局函数绑定留出更充裕的时间虽然理论上C#在Start发消息时JS侧函数早已就绪但为了杜绝个别浏览器在极端网络情况下造成的时序抖动等两个帧更稳妥。3.2 复杂消息体不能只传一个“我好了”要带上业务数据实际项目里H5收到unity_ready后往往不只是隐藏loading还要做很多事。比如读取玩家是否已登录、请求服务器下发配置、根据设备类型动态调整UI。所有这些信息如果靠H5自己去猜那会很麻烦但C#侧既然已经初始化了顺手把这些状态打包成JSON字符串传过去H5一次性拿到能省掉好几轮后续的“拉数据”操作。我项目中实际使用的消息结构长这样string jsonMsg JsonUtility.ToJson(new UnityReadyMessage { type unity_ready, version Application.version, sceneName SceneManager.GetActiveScene().name, isLoggedIn PlayerPrefs.GetInt(isLoggedIn, 0) 1, playerId PlayerPrefs.GetString(playerId, ) }); NotifyToJS(jsonMsg);对应的C#数据结构[Serializable] public class UnityReadyMessage { public string type; public string version; public string sceneName; public bool isLoggedIn; public string playerId; }JSON字段名和C#字段名会严格对应所以JS侧解析时直接msg.type、msg.version取就行。有一点要记住JsonUtility不支持直接序列化Dictionary如果你要传的是一种“键值对集合”建议用ListKeyValuePair或者直接转成Newtonsoft.Json但引入第三方库会增加打包体积Unity自带的JsonUtility够用就好。3.3 JS侧的业务处理隐藏loading还要做资源预加载JS侧收到消息后的处理远远不止隐藏一个loading。我总结了一个比较标准的处理函数模板你可以直接参考window.NotifyToJS function(message) { let msg; try { msg JSON.parse(message); } catch (e) { console.error([H5] parse message error:, message, e); return; } if (msg.type unity_ready) { // 1. 隐藏Unity加载进度条/遮罩 const loadingBar document.getElementById(unity-loading-bar); if (loadingBar) loadingBar.style.display none; // 2. 显示游戏入口或主界面 const gameEntry document.getElementById(game-entry); if (gameEntry) gameEntry.style.display block; // 3. 初始化游戏所需的其他SDK if (window.GameSDK) window.GameSDK.init(msg.playerId); // 4. 通知App原生层如果是嵌在WebView里 if (window.ReactNativeWebView) { window.ReactNativeWebView.postMessage(JSON.stringify({ type: UNITY_READY, gameVersion: msg.version })); } } };这里面的第4条在App内嵌场景很有用。很多App的H5壳子是通过WebView监听H5发出的消息来决定原生的启动逻辑的。Unity初始化完成意味着游戏内容已经可以展示此时通知App层去隐藏闪屏比App自己定时器等待要准确得多。3.4 边缘情况WebGL的start、首帧、WebGL Context丢失聊到“初始化完成”有三个边缘情况必须提醒。第一Canvas尺寸为0。如果H5页面里Unity的canvas初始化时宽高是0Unity实例也能创建成功但渲染会失败C#侧Start照样执行这样一来消息发了用户看到的是黑屏或空白。所以H5侧在调用createUnityInstance之前务必确认canvas已经拥有有效的CSS宽度和高度最好直接设置style的width、height而不是依赖CSS媒体查询。第二WebGL Context丢失。很长一段时间的页面切换或显卡驱动异常会导致WebGL上下文丢失Unity会尝试恢复但这个过程可能长达几秒。如果你的H5在unity_ready后立刻播放复杂动画恰好撞上Context丢失就会非常尴尬。可以在JS侧监听webglcontextlost事件等Unity自己恢复后重新同步状态。第三低端机初始化极慢。我测试过一款配置比较差的安卓手机Unity WebGL的wasm编译加载花了十几秒但config.onProgress回调能持续给出进度H5如果只傻等unity_ready消息用户会一直盯着一个静止的进度条。建议配合onProgress做一个“最小加载时间”控制比如进度已经100但Unity仍未上报就绪显示“资源校验中”避免用户误以为卡死。4. 踩坑实录我在实际开发中遇到的6个典型问题4.1 C#调JS时提示“function not found”函数明明已经定义了这个问题出现的次数最多。函数明明写在了window.NotifyToJS上C#调用还是报Cant find function NotifyToJS in Module。排查思路分三步第一步确认C#的DllImport声明里方法名和JS函数名是否完全一致包括大小写NotifyToJS和NotifyTojs就是两个不同的函数第二步确认JS的函数是在Unity实例创建之前还是之后声明的如果你的NotifyToJS定义在某个异步模块内部Unity加载完成后才被执行那C#这边调用时函数还不存在把函数挂载放到window上的时间点要早于createUnityInstance第三步确认Unity构建时勾选了合适的Linking选项某些Il2CPP裁剪配置可能把没被引用的外部方法当成无用代码处理掉。实操心得我自己的习惯是把所有的JS桥函数集中放到一个UnityBridge.js文件里在HTML的head里用script标签直接引入早于Unity的构建脚本执行。这样就不会出现“函数未定义”的时序问题。千万别把桥函数塞进某个异步拉取的模块里你会怀疑人生的。4.2 SendMessage调用C#方法没反应方法明明写对了这类问题出在“JS调C#”方向时居多。你先确认一下场景中是否存在对应名字的GameObject再确认挂载的脚本里真的定义了对应方法。特别注意SendMessage调用的方法是public的private方法不行我看到很多人用SendMessage调一个private方法怎么调都没反应。还有一个隐藏点如果C#方法有重载SendMessage解析重载有时会懵建议方法名保持全局唯一别偷懒重载。4.3 DllImport声明在WebGL平台编译报错编辑器测试又好好的这个坑的原理前文提过__Internal只存在于WebGL构建环境。你的代码里如果出现了DllImport(__Internal)那么只能在#if UNITY_WEBGL块里编译。问题是很多人在编辑器里测试也要跑这段逻辑你可以在宏判断之外准备好编辑器调试实现public static void NotifyToJS(string message) { #if UNITY_WEBGL !UNITY_EDITOR UnifiedBridgeLib.NotifyToJS(message); #else Debug.Log([Mock] NotifyToJS: message); #endif }这种方式在编辑器里虽然走的是Mock但至少不会因为DllNotFoundException直接崩掉。等发布到WebGL平台上再走真正的JS桥。4.4 消息重复触发H5收到了两遍初始化完成遇到过一次很隐蔽的情况因为切后台导致Unity实例重新加载C#的Start被触发两次H5侧就收到了两条unity_ready。第一次收到时隐藏了loading第二次收到时可能已经把某个按钮的状态重置了这个bug特别难察觉。解决方案有两个层面C#侧确保Start里的上报逻辑只执行一次用静态标志位控制JS侧做一个幂等处理同一个unity_ready消息一分钟内不再重复触发业务逻辑。推荐两个都做双保险。4.5 字符串带中文或特殊字符传过去乱码跨语言字符串传输涉及编码转换。Unity底层在处理C#字符串传给JS时会按UTF-8编码正常情况下中文完全没问题。但如果你的H5页面没有声明UTF-8字符集或者服务器响应头里Content-Type缺少charsetutf-8就会乱码。解决方式很简单HTML的head里加meta charsetutf-8如果服务器是可以配置的也把响应头改成text/html; charsetutf-8。4.6 WebGL打包后页面一直黑屏但C#逻辑好像在跑黑屏问题往往不是通信问题而是渲染问题。最常见的原因是Unity的Canvas没有获得正确的尺寸或者创建实例时传的canvas元素不在当前视口内浏览器为了省资源暂停了WebGL的渲染。试一下把canvas移到可视区域后再观察如果依然黑屏检查WebGL Context是否已被创建F12打开Console看有没有WebGL相关的error提示。我之前踩过一次原因是CSS里给canvas设置了display:noneUnity实例创建成功后忘记改回display:block导致引擎一直在跑但什么都看不见。5. 进阶玩法基于这套通信还能做哪些扩展5.1 双向通信C#流程到一半主动向H5要个参数场景Unity在做支付确认时需要H5壳子提供一个token。传统做法是C#轮询或等H5端主动传但更优雅的方式是C#先给JS发一个“请求支付参数”的消息然后JS在合适的时机把token通过SendMessage传回C#。这本质上是两个单向桥的组合C#调JSGetTokenFromH5();JS侧收到后异步去后台拉一个好的token拉到了再调SendMessage(BridgeObject, OnTokenReceived, token);将结果传回C#。注意JS侧的异步调用是完全可以的C#这边等待时不能阻塞主线程只能在C#方法里记录一个回调占位等JS回传后再用MainThreadDispatcher或直接更新逻辑。由于SendMessage的回传发生在WebGL的主循环之外C#方法被调用时其实还是在主线程内所以更新状态是安全的。但为了保险起见建议在回调里只设置变量不立刻驱动UI等下一帧Update里再渲染。5.2 统一消息协议别一个方法传一个参数全用JSON结构体当你有了几十个桥方法后最怕的就是C#侧定义了一堆NotifyToJS、SendToH5、CallJsJS侧也一堆全局函数。我后来把通信协议统一成了一个方法PostMessageFromUnity(messageJson)、PostMessageFromJS(messageJson)所有交互都通过JSON里的type字段路由。好处是新增业务时不用新建C#的DllImport方法也不再需要改JS桥的全局函数只需要在消息路由里加一个case分支。这在项目迭代频繁的时期非常省事。{ type: request_token, requestId: cc4f1a2b-91b4-4f3a-9c60-1234567890ab }{ type: response_token, requestId: cc4f1a2b-91b4-4f3a-9c60-1234567890ab, code: 0, data: { token: xxxxx } }协议头用requestId关联请求和响应这样一个方法打天下。5.3 会话状态同步Unity内部状态变化主动推给H5除了初始化完成游戏中的很多状态也是H5需要知道的。比如金币变化、关卡结束、玩家升级。这些都可以通过同一套桥机制推给H5。H5侧可以做一条状态栏或者悬浮按钮实时显示游戏内部的关键数据。这种模式在很多H5游戏活动运营页里很常用。5.4 内存和性能WebGL通信会不会引起卡顿这是很多做H5游戏壳子的开发者会担心的问题。大体的结论是少量字符串消息往来不会对性能有可感知的影响但要避免高频、大批量的通信比如每帧都传几百条数据给H5去渲染图表这种得改用canvas在Unity内部渲染或者降低消息频率合并批量发送。我在实际操作中会把需要推送的一堆状态字段合并成一个JSON用一个0.5秒定时器发送而不是每帧都发。WebGL主线程和JS线程之间的消息传递是有序列化和反序列化开销的频率越高掉帧越明显。实操心得本地实时状态用Unity UI自绘历史数据、跨端日志、分享信息才走JS桥。别为了“看起来灵活”把什么都往H5推性能账算不过来的。5.5 事件总线设计思路给以后的多场景切换留后路项目一旦变大游戏内场景切换频繁如果再在每个场景的Start里都发消息JS侧的管理就会非常乱。建议在项目初始化时创建一个常驻的EventBridgeMono单例它负责统一监听C#侧各个模块发来的事件并用统一的消息协议转发给JS。JavaScript侧也维护一个EventBus对象收到消息后根据type分发到不同的注册回调里。这套事件总线结构虽然初期多写几十行代码但后面加功能时你会感谢当时的决定。6. 常见问题速查表症状大概率原因处理方式C#调JS报function not found函数名大小写不一致或函数声明时机太晚统一桥函数名在Unity实例创建前挂到window上JS调SendMessage没反应GameObject名/方法名错误、方法非public检查场景对象名、方法访问级别、方法名唯一编辑器运行DllNotFoundException__Internal在非WebGL环境不存在使用宏隔离编辑器下用Mock实现消息重复收到Start执行多次或JS重复注册回调C#侧用静态标志位JS侧做幂等处理中文乱码页面字符集未声明为UTF-8在HTML和服务器响应头都加上charsetutf-8Canvas黑屏但逻辑在跑Canvas尺寸为0或display:none给canvas设置有效宽高确保可见WebGL Context丢失显卡驱动或页面切换监听webglcontextlost事件恢复后重新同步状态通信频繁掉帧每帧高频发送大量桥消息合并批量发送避免每帧传大量数据在实际做Unity WebGL和H5协作的过程中我最深的一个体会就是通信机制本身并不复杂真正麻烦的是时序和异常边界。加载完成、初始化完成、首次可交互这三者很容易被混为一谈但只要把“C#主动上报、JS统一接收”这条思路理清楚后续无论是做加载进度管理还是做游戏状态同步都会顺手很多。如果你正在捣鼓Unity WebGL往H5容器里嵌不妨先从最简单的NotifyToJS(ready)跑通一版再把协议丰富起来最后再加状态同步这套路我已经在多个项目里反复验证过稳。