接到把一款休闲放置类 Demo 发布到京东小游戏的需求时,我第一反应不是“能不能发”,而是“发出去之后会不会白屏、会不会闪退、会不会审核被卡三天”。Unity 面向小游戏生态发布这件事,很多团队其实已经跑通了,但每条渠道都有自己的脾气。京东小游戏本质上也是一个 WebGL 容器,这意味着我们真正要做的事,是把 Unity 工程构建成网页产物、再包一层平台适配和原生能力桥接,最后走渠道的打包、审核、发布流程。
这篇文章我就把整条链路拆开,从平台形态、Unity 导出配置、C# 与 JS 的桥接方式、上传提审,到线上踩坑,按实际开发顺序过一遍。不会讲那种“一键发布”的神话,每一个关键决策我都会说明为什么这么做、不这么做会怎样。适合准备用小体量 Unity 产品走渠道发行的团队参考,尤其是第一次接触京东小游戏的开发者。
1. 京东小游戏是什么形态:先搞清容器机制,再谈适配
1.1 平台上跑的其实是一个 WebGL 页面
很多人以为京东小游戏是类似手游 SDK 那样的原生集成,实际上它跑的是浏览器内核渲染的 WebGL 内容。Unity 端要做的不是导出 Android 或 iOS 工程,而是构建 WebGL 版本,再把构建产物放到小游戏容器指定的位置。容器会加载 index.html、Unity loader、wasm 文件、资源包这些静态产物,由浏览器引擎负责渲染和运行。
这带来一个连锁反应:所有 C# 代码会被 IL2CPP 编译成 wasm 字节码,所有资源要走 WebGL 的内存和纹理格式,所有原本依赖原生插件的功能都无法直接生效。比如你想在 C# 里调用某手机厂商推送 SDK,做不到;想读取本地大文件,受限;想跑一个高精度的物理模拟,性能预算和原生差距明显。换句话说,Unity 游戏发到这个平台,技术栈就被拉到 WebGL 这一档,按这一档的规矩做优化。
我在一个内部 Demo 项目上验证过:同样的玩法,Android 包 200MB 内存跑得很轻松,WebGL 包在低端 Android 机型上光加载就等了十几秒,进入游戏后 GC 一抖动就能卡住操作。不是说不能做,而是你得先接受这个载体的性能边界,后面每一步都是围绕边界找平衡。
1.2 和微信小游戏、独立手游的差异
做京东小游戏,绝对不要照搬微信小游戏的适配经验,也不要照搬独立手游的构建思路。
先说和微信小游戏的差别。微信小游戏生态里,Unity 有被广泛使用的官方适配方案,构建时可以直接打出小游戏目录结构,平台侧的登录、分享、支付等接口也会封装成统一的 Unity SDK。京东小游戏这边更接近“自己动手”的路线:Unity 工程先按 WebGL 构建,然后通过桥接层调用平台提供的 JS 接口。接口的命名习惯、调用时机、返回字段,甚至回调成功和失败的定义方式,都要以渠道最新文档为准。
这听起来麻烦,但本质不复杂。核心就一句话:Unity 是 WebGL 容器,平台侧提供 JS API,中间的桥靠.jslib插件和SendMessage来搭。搞清楚这个三角关系,剩下的都是工程活。
再对比独立手游。独立手游的存储、后台切换、音频播放都有系统级能力,平台很难干涉。小游戏则不同:切后台可能丢 WebGL 上下文,音频必须响应浏览器自动播放策略,存档不能依赖 localStorage 跨端存活,这些限制必须在立项阶段就预判到。我在后面会逐个说具体坑。
1.3 为什么 Unity 侧只能选 WebGL 构建目标
有人会问,能不能把 Unity 项目导出成原生代码,再被小游戏容器调用?目前常见的小游戏容器不认原生可执行文件,它只认网页资源。所以即便你的项目最终运行在手机上,开发阶段也必须走 WebGL 构建。
这也意味着,Unity 工程里某些针对原生平台的设置、SDK、DllImport 到头来都不会生效。最稳妥的做法是,在做架构规划时就给“平台适配层”单独留接口,比如登录、支付、分享、埋点,都抽象成 C# 事件或接口,不同平台实现不同的桥。京东小游戏只是这套适配层的一个实现分支。
2. 工程准备与 Unity 导出配置:每个选项都要知道为什么
2.1 版本与模块选择
Unity 版本我建议用 2021 LTS 或更新的稳定版。WebGL 的 IL2CPP 管线在 2020 之后的版本比较成熟,内存控制、加载速度、异常信息都比老版本好处理。不需要追最新,因为小游戏容器往往不会立刻跟进最新浏览器的特性,稳定优先。
安装时要确保 WebGL Build Support 模块已勾选。一个容易被忽略的点:WebGL 模块和 Android/iOS 模块可以共存,但导出时千万别选错平台。我把一次导出到错误的目录,结果整个构建流程不受影响,但出来一堆.so文件,后台自然不认。每次构建前检查一下 Build Target,这是最廉价也最容易犯的错。
2.2 Player Settings 里我建议逐项确认的地方
这部分不是文档搬运,而是把容易踩坑的配置按优先级排一遍。
压缩格式:别一上来就选 Brotli
WebGL 构建产物是文本化的文件(wasm、js、unityweb),压缩能大幅减少体积。Unity 提供 gzip 和 Brotli 两种压缩选项。Brotli 压缩率更高,但要求服务器或容器能正确返回Content-Encoding: br头,否则浏览器解不了压,产物直接白屏。
在小游戏平台上,文件可能走平台的 CDN 或对象存储,能不能正确识别 Brotli 头,不一定由开发者决定。我就遇到过本地起服务正常、发布到平台后白屏的情况,排查到最后是压缩头问题。建议按优先级测试:先用 gzip 跑通全流程,确认平台侧对压缩文件的处理没问题,再尝试 Brotli 换取体积收益。开发期用 Disabled 或 gzip 都行,但发布包建议压缩。
Scripting Backend:保持 IL2CPP
WebGL 平台默认就是 IL2CPP,这能让代码体积更小、性能更好,也符合容器加载逻辑。不要为了快速调试切回 Mono,发布时必须以 IL2CPP 为准。
Strip Engine Code:建议开启
裁剪掉未使用的引擎代码,能显著减小 wasm 体积。开启后必须做完整冒烟测试,因为反射、序列化、动态加载的类可能被裁剪掉,导致运行时才报MissingMethodException。建议在link.xml里显式保留用反射访问的类型。
Exception Support:从 Full 降到 Explicitly Thrown Exceptions Only
开发期用 Full,异常定位方便;发布期如果还开 Full,会塞入大量异常处理代码,wasm 体积和运行时开销都会增加。我一般发布前切到第二个选项,仅捕获明确抛出的异常。配合后台日志系统,线上问题照样能追踪。
Textures:限制最大尺寸并开 MipMap
WebGL 的纹理内存和原生端不是一个量级。我习惯全局设置 Max Texture Size 不超过 2048,UI 图压到 1024,除非某张图真的有清晰度需求。同时能开 MipMap 的尽量开,因为小游戏里镜头远近变化很常见,没有 MipMap 的小图在缩放时会闪烁或明显毛边。
内存设置:256MB 是起点,不是万能解药
Player Settings 里可以直接写 WebGL 的内存大小。默认值在不同版本里不太一样,但我更建议根据实际项目情况设置。轻量游戏 256MB 够;带大量 UI、粒子、场景切换的项目建议 384MB 或 512MB。不要盲目拉到 1GB,过大的内存占用会让低端机加载更慢、更容易被系统回收。“启动时一次分配”这个机制意味着你给的内存,浏览器会真的尝试预留,给多了不是白嫖,是有代价的。
2.3 屏幕适配和安全区
京东小游戏大多在手机端竖屏运行,但手机有刘海屏、打孔屏、底部手势条,WebGL 的 Canvas 通常是全屏铺开的,Unity 的 Screen.width 拿到的可能和实际可视区域不一样。
我一般会让 Player Settings 的默认分辨率保持 1280x720,然后在 C# 启动时根据Screen.width和Screen.height计算实际宽高比。UI 层用 CanvasScaler 的Match = 0.5兼顾宽高,并给顶部和底部留出安全区变量。这个变量由桥接层从平台的系统信息里获取,比如状态栏高度、导航栏高度,再通过 C# 代码传给 UI 管理器。
public class SafeAreaGuider : MonoBehaviour { [SerializeField] private RectTransform topBar; private void Start() { float topOffset = GetTopSafeOffset(); topBar.offsetMin = new Vector2(0, topOffset); } private float GetTopSafeOffset() { // 实际值来自桥接层读取的系统信息,这里先写默认值 return Mathf.Max(Screen.safeArea.yMax - Screen.height * 0.5f, 0f); } }这只是示意,真正接入时要把平台给的数值透传进来。你甚至可以在启动时做一个“第一次进入加载页”,在这个页面里先拿到安全区和系统信息,再初始化主 UI,避免 UI 跑出屏幕或挡住平台自带返回按钮。
3. C# 和平台侧怎么对话:桥接层是整个链路最容易翻车的地方
3.1 通信的三种方式与选择
Unity WebGL 和浏览器 JS 之间的通信,常用三种方式:
.jslib插件:C# 通过[DllImport("__Internal")]声明外部函数,函数体在 JS 文件里实现。这是最常见的双向通信方式。SendMessage:JS 主动调用 Unity 场景里的对象方法,常用于平台完成登录后把结果回传给 C#。Application.ExternalCall:老版本的方式,新版本已不推荐,能不用就不要用。
.jslib文件是放在Assets/Plugins/WebGL/下的文本文件,Unity 构建 WebGL 时会自动把它编译进去。一个典型的结构长这样:
mergeInto(LibraryManager.library, { JDLogin: function (gameObject, method) { // 调用平台提供的登录接口,成功后回传 platform.login({ success: function (res) { var json = JSON.stringify(res); if (runtimeInstance && runtimeInstance.SendMessage) { runtimeInstance.SendMessage(UTF8ToString(gameObject), UTF8ToString(method), json); } }, fail: function (err) { var json = JSON.stringify({ error: err }); if (runtimeInstance && runtimeInstance.SendMessage) { runtimeInstance.SendMessage(UTF8ToString(gameObject), UTF8ToString(method), json); } } }); } });C# 侧声明:
[DllImport("__Internal")] private static extern void JDLogin(string gameObject, string method);运行时调用:
JDLogin("MainUI", "OnLoginResult");要注意的是,.jslib里的函数如果依赖 DOM API 或平台 JS 变量,必须保证平台脚本已经先注入。所以我建议把桥接 JS 做成初始化后置,比如先由 C# 调用一个InitBridge方法,确保平台 SDK 存在,再进入登录流程。
3.2 登录、支付:密钥永远不要出现在客户端
登录和支付是渠道接入绕不开的两座大山。先说登录。客户端通过桥接层向平台要一个临时凭证,比如 code;这个 code 要交给自己的服务器,由服务器向平台服务端换取真实的用户身份和 session。客户端不该直连平台服务端,也不该在本地保存任何密钥。
这是我反复强调的一件事:渠道接入的客户端代码里,出现任何appSecret、私钥、签名算法,都是重大事故。哪怕审计不查你,一旦逆向分析,整个游戏的账号体系就裸奔了。把签名、换取逻辑全部放在服务端,客户端只负责“拿 code”和“传结果”。
支付的流程也类似。客户端点击充值,你的服务器先创建订单,拿到服务端返回值后,通过桥接层拉起平台的收银台;支付完成后由平台服务端和你自己的服务器做回调校验,客户端收到的支付结果只是一个“通知”。这里最怕的就是客户端根据支付成功回调直接发货,回调数据是可以通过伪造请求骗过的,必须在服务端确认过才能真正发货。我甚至在测试环境见过有人直接改内存让支付回调返回成功,结果在渠道那边被判违规。
3.3 分享、录屏、客服等基础能力
这些能力同样通过 JS 桥接暴露给 C#。我的建议是不要把它们做得太零散,而是统一成一个PlatformBridge单例,按模块分方法。比如:
public class PlatformBridge { public void Share(Action<bool> callback); public void ShowCustomerService(); public void UploadScore(string leaderboardId, int score); public void ReportEvent(string eventName, Dictionary<string, string> props); }统一封装有三个好处:第一,代码里不会到处散落DllImport;第二,换渠道时只需要替换桥的实现;第三,方便在编辑器里做模拟实现,开发期不用反复真机预览。我在项目里就写了一个编辑器分支,调不通平台 SDK 时也能在本地跑 UI 和玩法逻辑。
4. 从构建产物到真机预览:跑通一次最小闭环
4.1 构建产物里都有什么
Unity 构建 WebGL 后,输出目录大致包含:
index.html:入口页面,包含加载逻辑Build/xxx.loader.js:加载器,负责下载并初始化 wasmBuild/xxx.framework.js:Unity 引擎运行时的一部分Build/xxx.wasm:编译后的代码主体Build/xxx.data:场景和资源数据,可能为.unityweb压缩格式
这些文件最终要按平台要求压缩成 zip 或直接上传目录。我习惯先把整个构建目录打包,避免漏文件。漏了 loader 或 framework 不会报编译错误,但真机跑起来就是纯白屏,而且控制台只给一个加载失败,排查起来非常头痛。
4.2 后台导入与开发者工具预览
不同小游戏平台的接入步骤有一定差异,但通常都是登录开发者后台、创建一个游戏应用、拿到 AppID 之类的标识,再通过后台或开发者工具导入构建产物。
我自己的节奏是:先在本地起一个静态服务器验证 WebGL 本身没问题,再接入平台。本地用python -m http.server 8080就好,浏览器访问构建目录里的 index.html。如果本地都白屏,先别怀疑平台,回去查 Unity 构建配置和浏览器控制台。
确认本地正常后,再按平台要求的格式上传或导入。真机预览这一步千万别省。平台的后台或开发者工具往往提供了预览二维码,用真机扫一下,能看出很多 tool 里看不出来的问题:分辨率、内存、加载速度、触摸响应、音频策略,全都以真机表现为准。
4.3 开发期调试的开关
我会在构建配置里保留一份“调试版”:不压缩、开异常、关裁剪、带日志,用于真机联调。发布版再切换成:gzip/Brotli、只保留显式异常、开裁剪、关多余调试输出。用同一个工程维护两份构建配置不太方便,我建议用 Build Profile 或构建脚本按参数切换,避免上线前手忙脚乱改配置。
5. 上线前必查的坑:这些现象我几乎每个项目都遇到过
5.1 白屏和加载进度卡在 99%
白屏的原因很多,但最常见的三个是:压缩配置和服务器不匹配、wasm 文件加载失败、WebGL 上下文不支持。
排查思路很机械:看浏览器 Network 面板,确认 wasm、data、js 文件有没有请求、有没有返回 4xx/5xx、Content-Type 对不对。is 文件必须是application/wasm,data 文件在没有压缩时应是application/octet-stream,压缩过的.unityweb要确认 Content-Encoding 和压缩格式一致。
加载进度卡住又要区分情况:卡在 90% 之前,多半是资源文件问题;卡在 90% 到 99%,通常是 WebGL 上下文创建失败或浏览器不支持某个 WebGL 特性。到这一步,我会先查显卡驱动、浏览器版本、是否启用了硬件加速。小游戏容器内置的浏览器内核相对封闭,调试方式有限,所以更要重视本地预检。
5.2 内存:不是看峰值,是看 GC 和分片
WebGL 的内存是一次性分配的堆,C# 的 GC 在这套环境下会更敏感。我遇到过的典型场景:进入一个新关卡,加载大量纹理和模型,内存直接顶到上限,浏览器崩溃,表现就是整个小游戏白屏后退回桌面。
常见的规避手法有三个。第一,限制纹理尺寸和格式,能合图的 UI 一定合图,不要一张张小图散着加载。第二,场景切换时显式调用Resources.UnloadUnusedAssets()和GC.Collect(),虽然不能频繁调用,但在关卡切换这种关口值得做。第三,大对象不要频繁创建和销毁,对象池不只是性能优化,它也在保护内存堆避免碎片化。
5.3 切后台回来之后的假死
小游戏切到后台再回来,经常出现画面冻住或操作无响应。原因往往不是游戏逻辑卡死,而是浏览器对后台任务的限制:定时器被放缓、requestAnimationFrame 暂停、音频上下文挂起。Unity 的OnApplicationFocus或OnApplicationPause在这里非常关键。
private void OnApplicationFocus(bool focus) { if (focus) { Time.timeScale = 1f; AudioListener.pause = false; // 重新初始化某些需要用户手势才能恢复的 API } else { Time.timeScale = 0f; AudioListener.pause = true; // 自动保存进度 } }切后台时不要只暂停不保存,用户可能再也不回来。我在一个项目里就把存档切到了OnApplicationPause触发,每次切后台都写一次存档,效果比定时存档好很多。
5.4 音频自动播放策略
浏览器默认不允许在用户没有点击页面的情况下自动播放声音,小游戏容器大概率也是这个策略。Unity WebGL 的AudioListener.pause和AudioSource.Play在首次加载时可能被浏览器拒绝。
最常见的解法是,在用户第一次点击“开始游戏”按钮时,统一执行一次音频初始化。你可以把按钮点击事件里先播放一个极短的静音 AudioClip,之后再启动背景音乐。不要尝试绕过策略,发布到平台后会吃大亏。测试环境能响的音效,到了真机容器可能就是不响,这种现象基本都是自动播放策略在作祟。
5.5 存档与本地存储
WebGL 的存档通常落在浏览器的 IndexedDB 或 localStorage 里,但在小游戏容器里,这些存储的生命周期不稳定。我不建议把进度完全依赖在某个浏览器的 localStorage key 上,更稳妥的做法是优先使用平台提供的存档接口。
如果平台没有存档接口,自己实现一个兼容层:默认写 localStorage,异常时降级到内存缓存,并提示用户开启云存档或手动记录。另外存档要加密,别把玩家的金币数直接明文放 localStorage,稍微一改就能做外挂。
6. 提审材料与线上运营:最后再提几个实在的点
提审阶段,平台通常会要求填写游戏名称、简介、图标、截图、隐私说明,还可能要求提供软件著作权或版权证明。这里最容易翻车的不是技术,而是“敏感内容”。小游戏平台的审核尺度会更卷,UGC 内容、随机抽奖、虚假宣传、诱导分享,稍微沾一点都可能被驳回。我的经验是,提审前把游戏内所有文案过一遍,把任何可能引起争议的表述换成中性词,别抱侥幸心理。
上线之后,重点盯三个数据:加载成功率、首帧耗时、崩溃率。这些指标往往可以从平台后台直接看,也可以自己通过桥接层上报。我的推荐是做一个极简的启动埋点:开始加载上报一次,Unity 场景启动完成上报一次,首帧卡顿超过阈值再上报一次。这样线上任何加载类问题,都能快速定位是资源下载、wasm 初始化、还是场景初始化阶段的问题。
最后分享一个小技巧。Unity WebGL 在小游戏平台上,加载速度是生死线。除了必要的压缩和裁剪,还可以做“分阶段下载”:首包只放启动场景和核心界面,玩法资源、后续关卡素材放到游戏中后期再加载。对小体量项目来说,这种方案比一味压体积更有效,因为用户等待窗口缩短了,留存才能体现出来。
接入京东小游戏不是一次技术冒险,更像一次资源管理和性能预算练习。把 WebGL 的特性吃透,把桥接层梳理清楚,把平台文档逐字对读一遍,整个流程能跑通并不难。真正难的是在“能跑”和“跑得好”之间,找到你那款产品该停的位置。