1. 项目背景与双部署设计思路
1.1 这一篇到底在解决什么问题
Unity项目的热更新,拆开来看其实是两件事:资源的远程更新,和代码逻辑的远程更新。
绝大多数团队在过了原型阶段之后,都会遇到同一个尴尬——线上版本出了一个配置错误或者一个小的逻辑bug,如果走应用商店发版流程,审核周期从几天到一两周不等,等发完版本玩家早就流失了。于是热更新成了必选项。而资源热更这块,Addressable(下面简称AA)已经是Unity官方体系里相当成熟的方案了,它把AssetBundle的打包、依赖分析、远程加载、资源分组这些事都封装好了;代码热更这块,HybridCLR是目前社区里用得非常多的一套方案,它支持你在纯C#环境里跑热更新逻辑,不需要把业务代码翻译成Lua,维护成本和上手门槛都要低很多。
但问题在于,很多团队在单独用AA、单独用HybridCLR时都没什么大问题,一旦要把两者合在一起用,就掉进各种坑里:比如热更新DLL打包进AA后加载不到、远程Catalog更新失败导致整个启动卡死、AOT元数据没有按正确顺序加载导致运行时报错、首包体积没有真正瘦下去等等。
这篇文章假定你已经看过了这个系列前面的基础篇和原理篇,我们直接进入第三篇——完整实现“本地资源兜底 + 远程资源覆盖”的双部署方案,同时打通代码热更和资源热更的完整链路。如果你正在做项目技术选型,或者已经在改造现有项目,这篇内容应该能帮你少踩很多坑。
1.2 为什么选择AA而不是自己造轮子
我接触过不少团队,早期都自己封装了一套基于AssetBundle的下载管理框架,流程大概是:写一个AssetBundle打包编辑器脚本、维护一份资源依赖关系表、自己写下载队列、自己写版本对比。说实话,这套东西做出来以后用着确实“可控”,但代价是所有功能都要自己维护。AssetBundle的依赖管理是最容易出错的点:一个材质依赖了一个纹理,纹理被打进另一个Bundle,整个加载就可能莫名其妙地黑屏或者资源丢失。
AA把这些基础能力全部接管了。它做的事情本质上就是:自动分析资源依赖、自动分配资源到合适的Bundle、提供一套统一的异步加载接口、支持本地和远程两套构建以及加载地址。而且AA有完善的可视化工具,一个资源属于哪个分组、构建到哪个路径、依赖了哪些资源,全部能看清楚。
这个系列实战篇里,我最终的结论很明确:如果你的项目没有特殊的历史包袱,直接上AA,别自己写。自己写一轮AssetBundle框架的时间,投入产出比不划算。
1.3 双部署到底“双”在哪里
所谓双部署,指的是同一套构建产物,在本地和远程各存在一份,并能在运行时根据资源分组策略选择加载路径。
先说为什么需要“双”:
- 首包瘦身。原生安装包里只放启动必需的最小资源集合,其余资源全部放到远程CDN。玩家安装后第一次进入游戏时按需下载,这样首包体积可以控制在很小的范围。
- 容灾兜底。如果玩家设备处于弱网环境,或者远程CDN临时不可用,至少本地已有的资源能保证游戏能够启动,不至于一进游戏就是白屏。某些关键入口场景完全可以先走本地加载,等网络恢复后再做增量更新。
- 版本回退。远程版本如果出现问题,可以让客户端回退到本地版本,或者回退到上一个可用版本。这在运营环境里是救命的能力。
具体到AA里,双部署体现在构建路径的配置上:一个项目可以同时存在Local Build Path和Remote Build Path两组配置。我们把启动场景、基础UI、全局配置这类资源放在本地组,把游戏内容资源(角色模型、场景、特效、音频)放在远程组。构建周期里,AA会把本地组资源打进安装包,远程组资源另外打包上传到CDN。
代码层面,HybridCLR处理的是“逻辑代码”的更新,AA处理的是“资源代码的载体”。这两者必须配合起来才能完成一次完整的热更新。接下来我就按实际项目里搭建的流程,一步步拆解整个实现。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 工程基础:HybridCLR 集成与程序集划分
2.1 版本选型和环境准备
在做任何代码之前,先把环境定下来。我这里使用的是Unity 2021.3 LTS,AA版本为1.21.x,HybridCLR版本是当前官方GitHub仓库的最新release版。
值得提醒一点:HybridCLR对Unity版本比较敏感,不同Unity版本对应不同分支。安装时不要直接从GitHub拉master,而是先确认你的Unity版本匹配哪个release分支。以Unity 2021为例,通常对应的HybridCLR版本是0.7.x到0.9.x之间,具体以官方文档说明为准。如果你用的是Unity 2022或者Unity 6000系列,分支又不一样。
安装HybridCLR包时,在Package Manager里选择“Add package from git URL”,填入官方仓库地址,然后需要执行菜单栏里的安装步骤,让它生成对应的初始化文件。这一步如果漏了,后面所有编辑器和构建命令都不好使。
2.2 AOT程序集和热更新程序集的划分逻辑
这是一个非常核心的设计问题:哪些程序集要被裁剪进AOT,哪些程序集要作为热更新程序集放到远程。
我的策略是:
- AOT程序集:包括Unity自带的程序集(UnityEngine、UnityEngine.CoreModule等)、第三方SDK程序集、Addressable和HybridCLR自身、以及项目里最稳定、几乎不会改的基础框架代码。
- 热更新程序集:游戏业务逻辑,比如战斗系统、UI面板逻辑、任务系统、养成系统。这些是迭代频率最高的部分,全部打成热更新DLL。
- 共享边界定义:AOT程序集和热更新程序集之间通过接口层解耦。具体操作是,在AOT程序集里定义接口(比如IBattleSystem),在热更新程序集里实现这个接口。运行时通过反射加载热更新程序集,再通过接口调用具体实现。
采用这个划分的原因很简单:热更新程序集体积和数量越少,更新时下载量越小,启动加载越快。把基础框架留在AOT里,可以让热更新程序集专注于业务。另外,当多个热更新程序集之间相互引用时,依赖关系一旦复杂起来,打补丁和Debug的难度会指数增加。所以我建议热更新程序集不要拆分得太碎,按照“模块级”来划分就够了。
2.3 HybridCLR运行时初始化代码
工程项目里,我一般会在启动场景里放一个名为BootStrap的GameObject,挂一个初始化脚本。下面是一个简化版的HybridCLR初始化流程:
csharp复制using System;
using System.Collections.Generic;
using System.Reflection;
using UnityEngine;
using HybridCLR;
public class BootStrap : MonoBehaviour
{
private const string HotUpdateDllName = "GameLogic.dll";
private const string AotMetadataNames = "mscorlib.dll,System.dll,System.Core.dll";
private async void Start()
{
// 1. 先确保Addressable初始化完成
await Addressables.InitializeAsync().Task;
// 2. 加载热更新DLL对应的TextAsset
TextAsset gameLogicDll = await Addressables.LoadAssetAsync<TextAsset>(HotUpdateDllName).Task;
// 3. 加载AOT程序集补充元数据
var aotDlls = AotMetadataNames.Split(',');
foreach (var dllName in aotDlls)
{
TextAsset dllAsset = await Addressables.LoadAssetAsync<TextAsset>(dllName).Task;
RuntimeApi.LoadMetadataForAOTAssembly(dllAsset.bytes, HomologousImageMode.SuperSet);
}
// 4. 通过反射加载热更新程序集
Assembly hotUpdateAssembly = Assembly.Load(gameLogicDll.bytes);
Type entryType = hotUpdateAssembly.GetType("GameLogic.GameEntry");
entryType.GetMethod("StartGame").Invoke(null, null);
}
}
这段代码展示了最基本的流程。但真实项目里不能这么简单,原因是初始化链路是有严格顺序的,顺序错了,后面就报错。
这个顺序是:
- AA初始化
- 加载AOT补充元数据
- 加载热更新DLL
- 反射调用入口
其中第2步必须在第3步之前。因为热更新DLL里可能会引用AOT程序集里的一些类型,如果AOT元数据没有提前补充,反射加载DLL时就会因为找不到元数据而抛出异常。
很多人会忽略的一点是:加载AOT元数据并不要求全部加载,只加载那些“被热更新程序集引用到的、且可能因为在IL2CPP裁剪而缺失元数据”的程序集就够了。全部加载会增加启动耗时。
3. Addressable 分组与远程部署配置
3.1 Addressable分组策略与路径配置
AA的配置界面里,我们可以创建多个Group,每个Group可以设置自己的构建和加载配置。
我常用的分组结构如下:
| 分组名 | 构建路径类型 | 包含内容 | 说明 |
|---|---|---|---|
| StartUp | Local | 启动场景、BootStrap相关资源 | 必须本地 |
| ConfigData | Remote | 配置表、Json、Lua | 远程更新 |
| AssetsCore | Remote | 公共资源、共享材质、通用UI | 远程更新 |
| ModuleBattle | Remote | 战斗场景、角色、特效、音效 | 按需下载 |
| ModuleUI | Remote | UI面板预制体、图集 | 按需下载 |
| HotUpdateDll | Remote | 热更新DLL的TextAsset、AOT元数据 | 强制更新 |
然后逐一去设置每个Group的构建路径。
打开Addressable Groups窗口,选中某个Group,在Inspector面板里能看到“Build Path”和“Load Path”两个设置。这里关键是要创建两套Profile变量:
- Local:
{UnityEngine.AddressableAssets.AddressableAssetSettings.PlayerBuildDataPath}对应本地构建路径 - Remote:
{MyRemoteBuildPath}你可以自定义一个变量,指向本地某个打包输出目录 - RemoteLoad:
{CDNBaseUrl}/AssetBundles对应CDN上存储资源的URL前缀
也就是说,远程构建产物会先输出到本地的打包目录,然后由打包脚本或CI上传到CDN,并且上传的目录结构必须与URL前缀一致。这算是AA远程部署最容易出问题的地方——上传的目录层级不对,加载404。
3.2 资源标签(Label)与加载地址规划
AA的资源加载地址默认是资源的Addressable Name,也可以自定义。我建议按模块前缀命名,例如:Assets/ModuleBattle/Prefabs/BattleScene.prefab 这样的完整路径作为Addressable Name,保证全局唯一,避免同名冲突。
另一个建议是搭配Label使用。比如所有战斗相关的资源都打上Battle标签,需要预下载战斗内容时,就可以按Label批量加载:
csharp复制AsyncOperationHandle<IList<GameObject>> handle = Addressables.LoadAssetsAsync<GameObject>(
new List<string> { "Battle" }, // 标签列表
addressable => { /* 预加载回调 */ },
Addressables.MergeMode.Union
);
这种按功能模块批量加载的方式,通常用来做“整包预下载”或“后台静默下载”,很实用。
3.3 构建与上传流程
在AA的菜单栏里选择“Build > New Build > Default Build Script”,就会执行完整的资源构建流程。构建完成后会生成一个catalog文件和一组.bundle文件。其中:
catalog.hash和catalog.json是资源清单*.bundle是实际资源包*.bundle.hash是各文件哈希
为了让远程部署生效,你需要:
- 构建前勾选“Build Remote Catalog”选项,并指定Remote Catalog的构建路径。
- 构建后把
ServerData目录下所有文件整体上传到CDN,保持目录结构不变。 - 确认CDN的目录层级与Addressable Profile里RemoteLoad的URL前缀一致。
上传这个环节,经常有团队成员只上传了bundle文件漏传了catalog,导致客户端启动时拿不到新的资源清单,从而完全不走更新逻辑。我个人的习惯是把“上传catalog”和“上传bundle”拆成两个CI步骤,每一步都做校验。
4. 双部署热更新链路的完整实现
4.1 客户端启动流程设计
在这一步,我们把上面提到的所有能力都串起来。完整启动流程我分为五个阶段:
阶段一:本地初始化
读取本地持久化版本号。这个版本号一般存在persistentDataPath下,也可以使用PlayerPrefs。同时读取AA的Addressables.InitializeAsync返回的初始资源路径信息。
阶段二:请求远程序版本
向版本服务器发起请求,服务器返回当前最新版本号、强制更新标志、更新日志等。这是独立于AA的一层逻辑,一般走HTTP接口。
阶段三:更新AA Catalog
如果远程序版本号和本地记录版本号不一致,调用AA的更新Catalog接口:
csharp复制await Addressables.CheckForCatalogUpdates();
var updateHandles = await Addressables.UpdateCatalogs();
该接口会比对本地catalog和远程catalog的hash,发现差异后就下载新的catalog文件。更新完成后,AA就知道了远端有哪些资源可以使用。
阶段四:加载热更代码
从“HotUpdateDll”这个Group里通过AA加载热更新DLL和AOT元数据,然后执行HybridCLR的加载逻辑。在这一步,还要验证DLL的完整性,例如检查Assembly是否为空、入口类型是否存在。如果加载失败,执行回滚逻辑。
阶段五:按需下载业务资源
业务层可以根据版本号、玩家进度、活动配置等因素,决定下载哪些模块的资源。比如活动要开了,就通过AA的下载接口预下载对应Label的资源;关卡加载时再同步加载需要的资源。
4.2 版本对比与回滚策略
版本号这一层是最容易被忽视的。很多团队把版本号只埋在自己的服务器接口里,完全没想过“热更新了一半失败怎么办”。
我建议设计成三层版本号:
- 客户端发版版本:比如
1.2.0,对应App Store/各渠道包版本。 - 资源版本:比如
20250415_1,对应AA资源的构建版本。 - 代码版本:比如
code_20250415_1,对应HybridCLR热更DLL的版本。
每次热更时,客户端记录当前生效的资源版本和代码版本。一旦下一次更新失败,或者更新后的代码启动即崩溃,可以回退到上一次成功运行的版本。HybridCLR天然支持同时存在多份DLL文件,只需要在加载时指定加载哪个文件即可。
具体实现上,我会在persistentDataPath下维护一个目录结构:
code复制persistentDataPath/
versions/
20250415_1/
GameLogic.dll
mscorlib.dll
...
20250420_1/
GameLogic.dll
mscorlib.dll
...
current_version.json
每次远程更新前,把新版本DLL先下载到versions/新版本号/目录下,全部下载完成并校验通过后,再更新current_version.json。这样即使新版本有问题,客户端也能快速定位到上一份完整可用的DLL进行回退。
4.3 代码热更与资源热更的耦合原理
代码热更新处理的是C#程序集(DLL),资源热更新处理的是Unity资源(Prefab、Texture、AudioClip等)。两者之间必须有一个约定:热更新代码里的资源加载,全部走AA的地址加载,不能直接用Resources.Load或者硬编码路径。
举个例子,热更新DLL里有一个战斗UI的打开逻辑:
csharp复制public class BattlePanel : MonoBehaviour
{
public void Show()
{
// 错误示范:Resources.Load<GameObject>("UI/BattlePanel")
// 正确做法:通过Addressable加载
Addressables.InstantiateAsync("Assets/UI/Prefabs/BattlePanel.prefab");
}
}
为什么强调这一点?因为你的Prefab可能存在于本地,也可能存在于远程。AA能够通过地址自动解析应该从本地还是从远程加载。而Resources.Load只能加载打进安装包的Resources目录下的资源,完全无法支持远程更新。可以说,AA实际上承担了“热更新代码与资源之间的装配层”这一角色。
4.4 首包瘦身与渐进式资源释放
首包瘦身,核心思路是:保证冷启动所需资源必须在本地,其余全部远程。
启动场景、Logo、登录界面、必要的公共UI、启动配置、热更新框架必须本地。而游戏正文内容、角色、战斗场景、怪物、技能特效,尽量放远程。这里需要特别注意的是,有一些资源虽然看起来很小,但被启动场景的Prefab直接引用,导致AA在构建时为了防止依赖丢失,会把它强制打进本地组。排查这个问题,建议用AA的Analyze工具扫描依赖关系。
渐进式加载方面,配合AA的收入缓存和按需加载,我一般还会做释放策略:当UI面板关闭或场景切换后,超过一段时间没有再次被引用的资源,调用Addressables.Release释放引用计数。如果担心释放后被再次用到又要重新下载,可以加一个“本地LRU缓存”策略:把最近使用且体积不大的资源缓存到磁盘,超出一段时间后自动删除。
5. 常见问题与排查技巧实录
5.1 热更新DLL加载报错排查清单
这是整个方案里问题最多的地方,我遇到的典型错误如下:
错误一:加载DLL时提示FileNotFoundException或TypeLoadException
最常见的原因是AOT元数据没有被正确加载。比如只加载了mscorlib.dll的补充元数据,但漏了System.dll,一旦热更新DLL里用到System.DateTime、System.String的某些方法,就可能报TypeLoadException。
排查方式:看异常信息里是哪个类型加载失败,再回去检查补充元数据清单是否包含该类型的所在程序集。
错误二:调用热更新方法时提示栈错误或崩溃
HybridCLR执行模式分为解释执行和AOT执行,代码如果在热更新DLL里调用了一个方法,而该方法所属程序集是AOT且被裁剪了,就可能导致崩溃。解决思路是在HybridCLR的Linker配置里,把可能被反射调用的AOT方法保留,不要裁剪。
错误三:找不到热更新程序集入口
大概率是程序集命名不一致。打包热更新DLL时,程序集名一定要和Assembly.Load时传入的名称一致。Unity工程里如果你的程序集定义文件叫GameLogic.asmdef,那程序集名就是GameLogic,加载时传入的字符串也必须是GameLogic.dll。
5.2 Catalog更新不生效或加载失败
症状一:远端资源已经更换,但客户端还是走本地
检查“Update a previous build”模式,很多场景下需要勾选Build Remote Catalog,否则不会生成远程catalog。另外检查代码里是否真的调用了Addressables.UpdateCatalogs,如果只是InitializeAsync而没有调用UpdateCatalogs,AA不会主动去拉新的catalog。
症状二:加载远程资源报404
优先检查CDN路径。AA的RemoteLoad Profile变量如果配的是https://cdn.example.com/assetbundles,那么上传到CDN时资源必须放在/assetbundles目录下,且bundle文件目录层级和构建输出时的目录层级一致。
症状三:更新Catalog之后旧的场景资源加载异常
Catalog更新后,AA内部会清理旧的Bundle。如果当前场景里的资源还在被引用,可能因为Bundle被续期而短暂卡顿。这种情况建议在更新Catalog前,先确保当前场景切换到了启动场景,再执行更新。
5.3 iOS上跑HybridCLR的注意事项
iOS审核和运行环境里,HybridCLR用的是一种解释执行的模式,不涉及JIT,理论上合规。但工程上仍然有几个要注意的点:
- 必须使用IL2CPP构建,不能使用Mono。
- 不要在热更新代码里做
DynamicMethod或Emit等动态生成代码的操作,iOS会被拒。 - 尽量把热更新DLL控制在合理体积内,第一次启动下载和加载都会更快。
如果遇到iOS包下载热更新DLL后无法进入游戏,优先排查是不是有AOT元数据漏加载,或者程序集之间引用了AOT中被裁剪的类型。
6. 工具链与打包自动化
6.1 使用CI脚本完成一键构建
手动手动点击编辑器按钮进行构建,只适合开发期。真正上线时,需要做一键打包。我这里的自动化流程是:
- 使用命令行参数调用Unity BatcMode。
- 第一步执行HybridCLR的打包命令,生成AOT程序和热更新DLL。
- 第二步执行AA的构建脚本,生成catalog和bundle。
- 把热更新DLL复制到AA的HotUpdateDll分组对应目录下,并设为Addressable。
- 生成新的版本号和manifest文件。
- 把ServerData目录上传到CDN。
这一步的难点是HybridCLR的构建命令和AA的构建命令需要在同一个编辑器进程里顺序执行。我写的是一个C#编辑器脚本,在MenuItem里暴露一个“一键打包并上传”的入口,CI再调用这个入口。
6.2 校验产物完整性的手段
建议每次上传CDN后,从服务器拉取一份catalog文件与本地构建产物对比hash。如果hash不一致,说明上传过程出了问题。这里我写过一个简单的校验脚本,对ServerData目录下所有文件计算SHA1,生成一份manifest.json,上传后再远程拉取并对比。
这一套路基本能保证:本地构建、远程CDN、客户端实际下载到的三方文件完全一致。要知道,资源更新最怕的就是“构建成功但上传损坏”,到时候线上出现诡异的问题,排查成本极高。
7. 我的个人心得和后续扩展
踩了这么多坑,最想告诉大家的其实是两件事:一是顺序,二是版本管理。
顺序指的是,整个热更新链路里,任何一步的顺序都不能乱。AA初始化、Catalog更新、AOT元数据加载、热更DLL加载、入口函数调用,这个顺序是硬约束。我在项目里专门写了启动状态机,把每个阶段的成功/失败都记录到日志里,方便线上排查问题。与其等出了bug再debug,不如在启动阶段就建立完整的日志埋点。
版本管理指的是,不只是代码有版本,catalog有版本,热更新DLL也有版本。整个系统必须能清晰回答:当前这个玩家设备上,跑的是哪一份代码、哪一套资源。我见过太多项目,上线之后无法确定线上到底跑的是哪个版本,导致bug无法复现、修复也验证不了。
这套方案做完之后,可以继续扩展的方向也有不少。
首先是分模块强更。比如某个活动版本,客户端必须更新到特定资源版本才能进入活动,这时可以按Label做强制下载校验。
其次是AB测试。因为AA的远程资源支持覆盖式加载,理论上我们可以为不同用户分配不同的CDN路径或者不同的catalog,从而实现资源层面的A/B测试。
最后是增量更新。AA默认的更新是整个catalog和整体bundle层面,如果要做更细粒度的增量,需要自己叠加一层差异文件的生成逻辑。目前我还在实践中,等跑通了再单独写一篇。
最后再分享一个小技巧:在开发阶段,我建议把AA的Profile切到“Editor Hosted”,这样可以直接在编辑器里模拟远程加载。但每次切换Profile后,记得清理一次Library/Addressable缓存。这个缓存经常导致更新不生效的假象,开发时浪费了我不少时间。
