1. 为什么小游戏项目必须做热更:从一次线上事故说起
去年年底我接手了一个休闲小游戏项目,玩法不复杂,核心逻辑就是合成+关卡推进,团队四个人,工期两个月。上线第一周数据还不错,结果第三关有个道具的数值配错了,导致玩家可以无限刷金币。这个问题如果走传统发版流程,从提审到用户真正更新,安卓渠道快的话一天,iOS加上审核周期至少两三天,等版本铺开的时候,经济系统已经被冲烂了。
那次事故之后我把热更方案彻底重做了一遍,最终落地的组合就是YooAsset 管资源 + HybridCLR 管代码。这套方案在小游戏场景下特别合适,原因很直接:小游戏包体限制严格,首包必须小;玩法迭代快,策划改数值、改配置不能等发版;代码逻辑也经常要修,纯资源热更覆盖不了 C# 逻辑的改动。
这篇文章我会把整套方案从选型、搭建、实操到踩坑完整讲一遍。适合正在做 Unity 小游戏、准备接入热更、或者已经接了但被各种报错折磨的开发者。哪怕你之前没接触过 YooAsset 和 HybridCLR,跟着走也能把双更新跑通。
先说清楚这套方案解决的核心问题:资源(预制体、贴图、配置、音频)和代码(C# 逻辑)都能在不重新发版的前提下更新。资源部分靠 YooAsset 做打包、分发、加载;代码部分靠 HybridCLR 做补充元数据 + 热更程序集加载。两者配合,才能做到真正的"双更新"。
2. 方案选型:为什么是 YooAsset + HybridCLR 而不是别的
2.1 资源热更为什么放弃 Addressable 选 YooAsset
Unity 官方有 Addressable,功能很全,但我实测下来在小游戏场景有几个不舒服的点。第一是包体,Addressable 依赖的库比较重,对首包体积敏感的小游戏不太友好。第二是构建速度,资源多了之后 Build 一次要等很久,迭代节奏被打断。第三是它的 Catalog 机制在弱网环境下加载体验一般,小游戏用户网络波动大,这点很致命。
YooAsset 是国内团队做的,设计目标就是轻量和高效。它的资源清单是二进制格式,加载快;支持多种运行模式(编辑器模拟、单机、联机),开发期不用每次打包;分包策略灵活,可以按标签、按目录、按资源类型分。最关键的是它对小游戏平台的适配做得比较到位,微信小游戏、抖音小游戏这些平台都有现成的处理。
我对比过两者的构建产物,同样一批资源,YooAsset 打出来的包体通常比 Addressable 小 10% 到 20%,这个差距在小游戏首包限制下很关键。
2.2 代码热更为何锁定 HybridCLR
代码热更的方案市面上有几类:Lua 系(xLua、ToLua)、ILRuntime、HybridCLR。Lua 系需要把逻辑用 Lua 重写,团队要学新语言,而且和 C# 交互有性能损耗。ILRuntime 是解释执行 IL,性能比原生差不少,复杂逻辑会卡。HybridCLR 走的是另一条路——它把热更程序集在运行时转成原生代码执行,性能接近 AOT 编译的代码,同时保留 C# 的开发体验。
HybridCLR 的核心原理是补充元数据(Supplementary Metadata)加解释器与 AOT 混合执行。简单说,AOT 主包里没有的泛型实例化、被裁剪的元数据,通过补充元数据的方式在运行时补齐,然后热更 DLL 就能正常加载执行。它不需要你改语言,C# 照写,这对团队来说学习成本几乎为零。
注意:HybridCLR 对 Unity 版本有要求,建议 2020.3.36 以上,2021.3 LTS 和 2022.3 LTS 是验证最充分的。太老的版本可能缺少必要的 API 支持。
2.3 两者组合的协同关系
很多人会误以为资源热更和代码热更是两件独立的事,其实它们必须协同。热更代码里引用的预制体、配置表,都要通过 YooAsset 加载;而 YooAsset 的加载逻辑本身可能也需要热更。所以正确的做法是:主包里放最小可运行的启动逻辑 + YooAsset 运行时 + HybridCLR 运行时,启动后先检查资源版本,再检查代码版本,两者都更新完再进入游戏。
这个顺序不能反。如果先加载热更代码,但代码里依赖的资源还没更新,就会报资源找不到。我踩过这个坑,后面会详细讲。
3. 环境搭建与工程结构设计
3.1 版本与依赖清单
我用的这套组合经过多个项目验证,稳定性不错:
| 组件 | 版本 | 说明 |
|---|---|---|
| Unity | 2022.3.20f1 LTS | 长期支持版,HybridCLR 适配完善 |
| YooAsset | 2.1.x | 2.x 版本 API 更稳定 |
| HybridCLR | 6.x | 与 Unity 2022 匹配 |
| 目标平台 | 微信小游戏 / 抖音小游戏 | 本文以微信小游戏为主 |
安装 HybridCLR 推荐用它的 Installer,在 Package Manager 里添加 Git URL 后,通过菜单HybridCLR/Installer一键安装,它会自动处理 il2cpp 的裁剪和配置。手动装容易漏步骤,尤其是link.xml和裁剪相关的设置。
YooAsset 直接通过 Package Manager 添加即可,注意选 2.x 分支,1.x 和 2.x 的 API 差异较大,网上很多老教程是 1.x 的,照抄会报错。
3.2 工程目录怎么划分
目录结构直接影响打包和热更的清晰度,我习惯这样分:
Assets/ Main/ # 主包内容,不参与热更 Scripts/ Launch/ # 启动逻辑 Runtime/ # YooAsset、HybridCLR 运行时封装 Scenes/ Launch.unity # 启动场景 HotUpdate/ # 热更内容 Scripts/ # 热更代码,编译成 DLL Res/ # 热更资源 Prefabs/ Configs/ Textures/ HybridCLRGenerate/ # HybridCLR 生成的补充元数据关键原则:主包只放启动必需的东西,其余全部丢进 HotUpdate。主包越小,首包体积越可控,热更覆盖范围越大。
3.3 程序集划分的关键操作
HybridCLR 要求热更代码必须放在独立的程序集里,不能和主包代码混在一起。操作步骤:
- 在
HotUpdate/Scripts下创建HotUpdate.asmdef,命名比如Game.HotUpdate。 - 主包代码放在另一个 asmdef,比如
Game.Main。 - 在
Game.Main的 asmdef 里,不要引用Game.HotUpdate,否则会被打进主包。 - 热更程序集可以引用主包程序集,反过来不行。
这一步做错的话,热更 DLL 会被误打进主包,热更就失效了。判断方法:打包后看主包的Assembly-CSharp.dll或对应程序集里有没有热更代码的类。
提示:HybridCLR 的 Settings 面板里要配置
HotUpdateAssemblies,把Game.HotUpdate加进去,这样它才会被识别为热更程序集,不参与 AOT 编译。
4. YooAsset 资源热更的完整实操
4.1 资源打包模式与清单配置
YooAsset 有三种运行模式,开发期和上线期用法不同:
- EditorSimulateMode:编辑器下直接读 AssetDatabase,不用打包,改资源立刻生效,开发期用这个。
- OfflinePlayMode:单机模式,资源全在包内,适合不需要热更的纯单机。
- HostPlayMode:联机模式,从 CDN 或服务器拉资源,热更就用这个。
上线配置里,HostPlayMode需要设置IRemoteServices,也就是资源服务器地址。小游戏平台通常用平台自己的 CDN,微信小游戏可以用云开发的文件存储。
打包时在 YooAsset 的 Build 面板里配置:
- Package Name:包名,比如
DefaultPackage。 - Build Pipeline:选
BuiltinBuildPipeline或ScriptableBuildPipeline,后者更灵活。 - Compress Option:小游戏建议
LZ4,压缩率和解压速度平衡好。 - Output Path:输出目录,注意区分本地和远程。
4.2 资源版本与清单的更新流程
YooAsset 的更新流程分几步,我按实际代码顺序讲:
// 1. 初始化 Package var package = YooAssets.CreatePackage("DefaultPackage"); var initParams = new HostPlayModeParameters { BuildinQueryServices = new GameQueryServices(), RemoteServices = new RemoteServices(defaultHostServer, fallbackHostServer) }; var initOp = package.InitializeAsync(initParams); yield return initOp; // 2. 获取资源版本 var versionOp = package.UpdatePackageVersionAsync(); yield return versionOp; string packageVersion = versionOp.PackageVersion; // 3. 获取资源清单 var manifestOp = package.UpdatePackageManifestAsync(packageVersion); yield return manifestOp; // 4. 创建下载器并下载 var downloader = package.CreateResourceDownloader(10, 3); if (downloader.TotalDownloadCount > 0) { downloader.BeginDownload(); yield return downloader; }这里有几个参数要解释。CreateResourceDownloader(10, 3)里的 10 是并发下载数,3 是失败重试次数。小游戏平台并发太高容易被限流,我一般设 6 到 10。重试次数设 3 比较稳,网络抖动时能自动恢复。
UpdatePackageVersionAsync会去服务器拉一个版本文件,里面记录了当前最新的资源版本号。如果版本号和本地一致,说明没有更新,直接跳过下载。这个机制保证了每次启动不会重复下载。
4.3 资源加载与释放的正确姿势
加载资源用package.LoadAssetAsync<T>(location),location 是资源的地址,可以在打包时配置。我习惯用资源路径作为地址,直观好维护。
var handle = package.LoadAssetAsync<GameObject>("Prefabs/Enemy"); yield return handle; var prefab = handle.AssetObject as GameObject; Instantiate(prefab); // 用完记得释放 handle.Release();释放这块是重灾区。YooAsset 的 handle 必须成对释放,加载了不释放,内存会一直涨。我见过项目因为没释放,玩十分钟内存爆掉。建议封装一层资源管理器,统一管理 handle 的生命周期,场景切换时批量释放。
注意:
LoadAssetAsync返回的 handle 如果被多个地方引用,要确保每个引用方都 Release,或者用引用计数管理。直接 Release 一次就销毁,其他引用方会拿到空对象。
5. HybridCLR 代码热更的核心实现
5.1 补充元数据的生成与加载
HybridCLR 最关键的一步是补充元数据。AOT 主包在编译时,il2cpp 会裁剪掉一些没被直接引用的泛型实例化和元数据。热更代码里如果用到了这些被裁剪的部分,运行时会报ExecutionEngineException或者找不到方法。
解决办法是生成补充元数据 DLL,在运行时加载。操作:
- 菜单
HybridCLR/Generate/All,它会生成补充元数据、裁剪后的 AOT 泛型等。 - 生成的 DLL 放在
HybridCLRGenerate目录,需要打进热更资源里。 - 运行时在加载热更程序集之前,先加载这些补充元数据。
// 加载补充元数据 foreach (var dllName in aotMetaDlls) { var handle = package.LoadRawFileAsync(dllName); yield return handle; byte[] dllBytes = handle.GetRawFileData(); RuntimeApi.LoadMetadataForAOTAssembly(dllBytes, HomologousImageMode.SuperSet); handle.Release(); }HomologousImageMode.SuperSet是推荐模式,兼容性最好。这一步必须在加载热更 DLL 之前完成,顺序错了会直接崩。
5.2 热更程序集的加载与反射调用
补充元数据加载完,就可以加载热更 DLL 了:
var handle = package.LoadRawFileAsync("Game.HotUpdate.dll.bytes"); yield return handle; byte[] dllBytes = handle.GetRawFileData(); var assembly = Assembly.Load(dllBytes); // 反射调用入口 var type = assembly.GetType("Game.HotUpdate.GameEntry"); var method = type.GetMethod("Start"); method.Invoke(null, null);热更 DLL 打包时要注意:Unity 编译出来的 DLL 是Game.HotUpdate.dll,但小游戏平台加载时通常需要加.bytes后缀,避免被平台当成可执行文件拦截。YooAsset 打包时把 DLL 当二进制资源处理,加载用LoadRawFileAsync。
反射调用入口只做一次,进去之后热更代码内部就可以正常互相调用了。入口方法建议做成静态无参的,简单可靠。
5.3 热更代码里如何引用主包类型
热更代码可以引用主包里的类,比如主包的ResourceManager、EventSystem这些。但要注意,主包里的类如果被裁剪了,热更代码调用时会找不到。解决办法是在主包里保留一个link.xml,把需要被热更代码引用的类型和命名空间标记为不裁剪。
<linker> <assembly fullname="Game.Main"> <type fullname="Game.Main.ResourceManager" preserve="all"/> <type fullname="Game.Main.EventDispatcher" preserve="all"/> </assembly> </linker>这个文件放在Assets下任意位置即可,il2cpp 编译时会读取。漏配的话,编辑器里跑得好好的,真机上就报TypeLoadException,这个坑我踩过不止一次。
6. 双更新的启动流程编排
6.1 启动时序的完整设计
把资源和代码的更新串起来,启动流程是这样的:
- 初始化 YooAsset Package。
- 检查资源版本,下载资源更新。
- 加载补充元数据 DLL。
- 加载热更程序集 DLL。
- 反射调用热更入口,进入游戏逻辑。
这个顺序是硬性的。第 2 步必须在第 3、4 步之前,因为补充元数据和热更 DLL 本身也是资源,要通过 YooAsset 下载。第 3 步必须在第 4 步之前,否则热更 DLL 里的泛型会崩。
6.2 版本比对与增量更新策略
每次启动都全量下载肯定不行,要做增量。YooAsset 的清单机制天然支持增量:UpdatePackageManifestAsync会对比本地和远程清单,只下载差异部分。代码这边,我给热更 DLL 加一个版本号,存在配置里,版本变了才重新下载 DLL。
string localCodeVersion = PlayerPrefs.GetString("CodeVersion", "0"); string remoteCodeVersion = GetRemoteCodeVersion(); // 从版本文件读 if (localCodeVersion != remoteCodeVersion) { // 下载新的热更 DLL // 更新本地版本号 PlayerPrefs.SetString("CodeVersion", remoteCodeVersion); }这样大部分启动只更新资源,代码没变就不下载 DLL,节省流量和时间。
6.3 断点续传与失败重试
小游戏网络环境差,下载中断是常态。YooAsset 的下载器支持断点续传,但需要你的RemoteServices正确实现GetDownloadUrl和文件校验。我建议在下载失败时给用户一个明确的提示,并提供重试按钮,而不是静默失败。
重试策略上,我做的是:单文件失败重试 3 次,整体下载失败后允许用户手动重试,重试时从已下载的部分继续。实测下来,弱网环境下这套策略能把下载成功率从 70% 提到 95% 以上。
7. 避坑清单:我踩过的那些坑
7.1 资源与代码更新顺序错乱
最常见的坑:先加载热更代码,代码里引用了新资源,但资源还没下载完,直接报资源找不到。必须严格按"资源→元数据→代码"的顺序。我在启动流程里加了状态机,每个阶段完成才进下一个,杜绝并发导致的顺序问题。
7.2 泛型实例化缺失导致的崩溃
热更代码里用了List<CustomType>这种泛型,如果CustomType在主包里没被任何 AOT 代码引用过,il2cpp 会裁剪掉这个泛型实例化,运行时直接崩。解决办法有两个:一是在主包里写一个"占位"方法,强制引用这些泛型;二是靠补充元数据补齐。我一般两个都做,双保险。
7.3 小游戏平台的 DLL 加载限制
微信小游戏对动态加载 DLL 有安全限制,直接Assembly.Load可能被拦截。HybridCLR 官方提供了针对小游戏平台的适配,需要开启对应的宏和配置。具体是在 HybridCLR Settings 里勾选目标平台,它会生成平台专用的加载代码。没开这个的话,编辑器正常,真机报错。
7.4 裁剪配置遗漏
link.xml漏配是高频问题。除了主包类型,还要注意 Unity 自身的一些类型,比如System.Collections.Generic下的某些泛型。我的做法是先在 Development Build 下跑,看有没有TypeLoadException,有就补进link.xml,反复几轮直到干净。
7.5 内存泄漏与 handle 未释放
前面提过,YooAsset 的 handle 不释放会内存泄漏。我封装了一个AssetHandleManager,所有加载都走它,场景切换时统一释放。另外,热更 DLL 加载后,Assembly对象本身占内存,如果频繁热更要注意旧 Assembly 的释放,不过一般一个版本内不会重复加载,问题不大。
| 坑点 | 现象 | 解决 |
|---|---|---|
| 顺序错乱 | 资源找不到 | 状态机控制顺序 |
| 泛型缺失 | ExecutionEngineException | 占位引用 + 补充元数据 |
| DLL 加载被拦 | 真机崩溃 | 开启平台适配宏 |
| 裁剪遗漏 | TypeLoadException | 补 link.xml |
| handle 未释放 | 内存持续上涨 | 统一管理器释放 |
8. 性能与包体优化的实战经验
8.1 首包体积怎么压到最小
小游戏首包限制通常在 4MB 到 20MB 之间,具体看平台。压缩策略:
- 主包只放启动场景和启动脚本,其余全热更。
- 贴图用 ASTC 或 ETC2 压缩,小游戏平台优先 ASTC。
- 音频用 MP3 或 OGG,不要用 WAV。
- 代码开启 il2cpp 的
Managed Stripping Level为 High,配合link.xml保底。 - 移除未使用的 Unity 模块,比如物理、动画如果不用就裁掉。
我实测过,一个中等复杂度的小游戏,优化前首包 15MB,优化后能压到 6MB 左右。
8.2 热更下载速度优化
下载速度取决于并发数和资源大小。并发数不是越高越好,小游戏平台一般限制单域名并发,设太高反而被限流。我一般设 6 到 8。资源方面,把大文件拆小,比如一张 2048 的图拆成几张 1024,下载时能并行,整体更快。
另外,CDN 的选择很关键。用平台自带的 CDN 通常比自建快,因为节点离用户近。微信小游戏用云开发存储,抖音用它的对象存储,都是优化过的。
8.3 运行时性能监控
热更代码执行性能和 AOT 接近,但反射调用入口那一下有开销,所以入口只调一次。运行时要监控帧率、内存、GC。我习惯在 Development Build 下开 Profiler,重点看热更代码有没有频繁 GC Alloc。HybridCLR 的解释执行部分如果被频繁调用,会有额外开销,热点逻辑尽量放在 AOT 侧或者优化成不触发解释器的形式。
9. 上线后的维护与迭代建议
9.1 灰度发布怎么做
热更最大的优势是可以灰度。我的做法是:资源版本和代码版本都支持按用户 ID 或渠道分流。比如先给 10% 用户推新版本,观察崩溃率和关键指标,没问题再全量。YooAsset 的RemoteServices可以根据用户信息返回不同的资源地址,实现分流。
9.2 回滚机制
热更出问题要能快速回滚。资源方面,保留上一个版本的清单和文件,出问题切回旧版本地址即可。代码方面,热更 DLL 也保留旧版本,版本号回退就加载旧的。关键是版本文件要能动态改,不要写死在包里。
9.3 监控与告警
上线后要监控热更的成功率、下载耗时、崩溃率。我在关键节点埋了点:启动开始、资源更新完成、代码加载完成、进入游戏。任何一个环节失败都上报,后台能看到哪个版本、哪个环节出问题。这套监控帮我提前发现过好几次资源服务器配置错误。
10. 一些零散但重要的实操心得
热更这套东西,文档看一遍觉得简单,真上手全是细节。我最后再补几个零散但很关键的点。
第一,开发期一定要用 EditorSimulateMode,改资源秒生效,不要每次都打包,否则迭代效率极低。打包只在提测和上线前做。
第二,热更 DLL 的编译要独立。我见过把热更代码和主包代码放一个 asmdef 的,结果热更 DLL 里带了一堆主包代码,包体暴涨还容易冲突。asmdef 划分清楚,各管各的。
第三,测试热更流程要在真机跑。编辑器模拟和真机差异很大,尤其是小游戏平台的文件系统和网络环境。我一般准备一个测试环境,专门验证热更,每次改动都跑一遍完整流程。
第四,版本号管理要规范。资源版本、代码版本、配置版本分开管理,不要混在一起。我用一个version.json统一记录,服务器和客户端都读这个文件,避免版本对不上。
第五,补充元数据的 DLL 不要频繁变。它和 AOT 主包强相关,主包不变的情况下,补充元数据一般也不变。如果每次热更都重新生成,可能导致和主包不匹配。我的做法是主包发版时才重新生成,热更期间复用。
这套 YooAsset + HybridCLR 的组合,我从去年用到现在,经历了几个项目的上线和迭代,稳定性是经得起考验的。刚开始接入会有点折腾,尤其是补充元数据和裁剪那块,但一旦跑通,后续的迭代效率提升非常明显。策划改数值、程序修 bug,当天就能推给用户,不用再等发版周期。对于小游戏这种快节奏、重运营的品类,热更能力基本是标配了。