0. 开篇:这套组合能解决什么问题
做Unity项目做到中后期,十有八九会撞上两个硬骨头:资源包体太大和线上Bug修复太慢。包体动辄几百MB,用户下载成本高,渠道审核又卡得严;线上出了崩溃或者逻辑错误,走传统发版流程最快也得一周才能覆盖用户。这两个问题叠加起来,就是逼着团队必须上热更新方案。
目前Unity生态里,资源热更的主流方案是Addressable可寻址系统(下文简称AA),代码热更的主流方案是HybridCLR(华佗热更)。AA解决的是AssetBundle的打包、加载、依赖管理、远程下载问题,HybridCLR解决的是IL2CPP模式下C#业务逻辑的热更新问题。两者单独用都已经很成熟,但真正把它们深度整合、做到“本地资源 + 远程资源 + 代码逻辑”三者联动热更的完整实现,网上的资料还是偏零散,很多文章只讲了一半,剩下的坑全靠自己踩。
这篇是实战系列的第三篇,重点不是再重复讲AA和HybridCLR的基础概念(前两篇已经覆盖),而是聚焦在双部署架构的完整落地:本地远程如何分组、Catalog怎么切换、HybridCLR的Assembly如何通过AA加载、版本号怎么管理、首次启动和增量更新分别走什么流程。我会把整个项目的实现链路从头到尾过一遍,包括构建脚本、运行时初始化代码、遇到的典型问题和排查思路,尽量给出一套能直接抄作业的工程方案。
系列前两篇分别是:[第一篇:AA可寻址系统的基础用法与资源分组实践]、[第二篇:HybridCLR环境搭建与热更新程序集划分]。这篇默认你已经跑通了前两篇的环境,知道AA的Group、Profile、Catalog是什么,知道HybridCLR的补充元数据(AOT泛型实例化)是干嘛的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
1. 整体架构设计:为什么是“本地 + 远程”双部署
1.1 单远程部署看似美好,实际坑很多
很多团队做热更新,第一反应是“所有资源都放远程服务器,客户端启动后全部下载”。这种做法的好处是包体可以做到很小,但实际运营后会发现一堆问题:
- 首包体验差:用户打开游戏,先得等几百MB资源下载完才能进主界面。按现在平均宽带水平,5G网络下也要几分钟,弱网环境下直接劝退。
- 服务器压力大:新增用户集中涌入时会打爆CDN和源站,尤其是买量投放阶段,瞬时并发能到几十万。
- 资源缺失兜底难:如果某些核心资源在远程缺失或下载失败,游戏直接卡死在加载页。
所以工程上更稳妥的思路是双部署:核心玩法资源、启动必需的UI资源打进包体(本地组),非核心资源、后续迭代新增的资源放远程(远程组)。代码热更逻辑本身(HybridCLR的HotUpdate程序集DLL)也可以走AA的远程组往下发,这样资源和代码共用一个下载通道,版本管理统一。
1.2 双部署的核心优势
- 本地部署保证首包能跑:用户安装后不联网也能体验核心玩法,骨架资源齐全。
- 远程部署支持持续迭代:活动资源、新副本、Bug修复逻辑全部走热更下发,不用重新发版。
- 加载策略灵活:优先加载本地资源保证流畅度,远程资源可以提前预下载,也可以运行时按需下载。
- 与HybridCLR天然互补:HybridCLR的热更DLL本质上也是“资源”,放到AA远程组里,由AA管理下载和版本,省掉自己写文件下载器的成本。
我实际在项目里采用的分组策略是:
| 分组类别 | 包含内容 | 部署位置 | 更新策略 |
|---|---|---|---|
| Local Default Group | 启动场景、核心UI、基础Shader、全局配置 | 打包打进StreamingAssets | 跟随App发版 |
| Remote Art Group | 美术资源(模型、贴图、特效、音频) | 远程CDN | 按版本/按需下载 |
| Remote Logic Group | HybridCLR热更DLL、AOT补充元数据、配置表二进制 | 远程CDN | 启动时强制检查更新 |
| Remote Scene Group | 后续新增玩法场景 | 远程CDN | 按需下载并缓存 |
1.3 整体流程图(用文字描述版)
这里不用流程图,用文字把你的启动流程理清楚:
- 启动App → 初始化AA(Addressables.InitializeAsync)→ 加载启动场景(本地组)。
- 初始化HybridCLR的RuntimeApi,加载HotUpdate程序集的DLL和AOT补充元数据(这两个文件本身被AA管理,第一步先确保它们是最新的)。
- 检查远程Catalog是否有更新(通过版本号文件对比),如果有更新就下载新Catalog并加载。
- 加载并执行HotUpdate入口(通过反射调用HotUpdate程序集里的入口方法)。
- 热更逻辑接管后,由它决定哪些远程资源需要预下载、哪些按需加载。
这套流程核心就在第3步:Catalog的更新策略直接决定了热更的成败,后面会详细讲。
2. Profile与Group配置:手把手搭建双部署骨架
2.1 Profile路径方案设计
AA的Profile决定了本地和远程资源的根路径。我的推荐方案是:
- Local:
{UnityEngine.AddressableAssets.InitializationOperation.StreamingAssetsPath}/[BuildTarget],也就是StreamingAssets下的平台目录。 - Remote:
https://your-cdn-domain.com/[BuildTarget],或者测试阶段直接配本地服务器地址http://192.168.x.x/[BuildTarget]。 - LoadPath 和 BuildPath 都要按照“本地组走Local、远程组走Remote”的原则分别设置。
关键点:Remote路径必须带平台标记。因为iOS、Android、Windows的AssetBundle不通用,如果CDN路径里不带平台区分,灰度包和正式包混在一起,会跑到别人平台加载不到的Bundle。
2.2 Group分组实操
在Addressables Groups窗口里,我建议建三个远程组(对应上面的表格),每个组的关键设置如下:
Remote Logic Group 的设置:
code复制Content Update Restriction: Can Change Post Release(热更组必须用这个)
Web Update Integration Enabled: 不勾选(纯DLL资源不需要)
Build Path: RemoteBuildPath
Load Path: RemoteLoadPath
重点解释一下Content Update Restriction。如果选Cannot Change Post Release,那么该组构建后,一旦发布就无法再变更内容。远程热更组如果选了这项,后面想更新DLL就非常麻烦,需要走复杂的Content Update Builder流程,还容易把老版本的资源搞坏。所以凡是远程组,都选Can Change Post Release,这样构建时AA会把用到的资源自动拆分出远程组的新资源,配合版本覆盖,一套流程跑下来干净利落。
2.3 本地组与远程组交叉引用的坑
这是双部署架构里最容易被忽视的问题。
假设你的Remote Art Group里有一个Prefab,Prefab上挂的脚本在Remote Logic Group的DLL里。这没问题,因为脚本和Prefab都在远程组,更新时可以一起下发。但如果你有一个本地组的Prefab引用了远程组里的材质,或者远程组里某个Prefab引用了本地组的图集,就会产生跨组依赖。
AA在构建时虽然会自动把跨组的依赖对象一起打出来,但后果是:
- 本地组的资源依赖了远程资源,首次启动时会异步加载远程资源,如果网络不好,本地资源也会加载失败。
- 远程组更新后,本地组引用的远程资源版本如果变了,可能出现引用错乱。
我的建议是严格禁止跨组引用,尤其是本地组引用了远程组。做法很简单:
- 全局共享的资源(基础Shader、通用图集、通用UI材质)放本地组,所有远程资源都引用本地这版,引用关系是单向的。
- 远程组之间也尽量解耦,如果确实需要引用,把被引用的公共资源单独放到一个“远程共享组”,各远程组通过Addressable的AddressName引用,而不是直接拖引用。
这个约束在项目初期就需要通过规范和人肉检查来保证,团队大了建议写Editor脚本扫描所有资源引用关系,发现跨组引用直接报错。
3. HybridCLR与AA的集成:DLL作为AA资源
3.1 热更DLL的加载链路
HybridCLR热更的标准加载流程是:
- 通过
Assembly.Load(byte[])加载DLL。 - 如果是IL2CPP构建,还需要在加载DLL前补充AOT泛型元数据(通过
HybridCLR.RuntimeApi.LoadMetadataForAOTAssembly)。
这里的核心问题变成:DLL字节数组从哪里来?
最简单的做法是把DLL放到StreamingAssets目录,用File.ReadAllBytes读取。但这就回到了我们前面说的“代码热更不能独立于资源热更”的问题——StreamingAssets里的文件没法热更。所以必须把DLL交给AA管理,让AA的远程分组负责下载和更新DLL文件,本地存一份作为兜底。
具体做法:
- 在Unity中把
HotUpdate.dll.bytes(HybridCLR构建生成的DLL,后缀改为.bytes避免Unity当作程序集)和AOT补充元数据文件AotDlls.bytes分别做成AA资源,分别命名为HotUpdateDll和AotMetadata,都放Remote Logic Group。 - 构建时,设置一个初始版本的DLL也打进本地包(Local Logic Group),用于无网环境兜底或首次启动时的快速加载。
- 运行时,优先检查远程是否有新版本DLL(通过Catalog判断),有则下载并加载,没有则直接从本地Addressable加载。
3.2 通过AA读取DLL的代码实现
AA读取DLL的推荐方式是Addressables.LoadAssetAsync<TextAsset>,拿到TextAsset.bytes后传给HybridCLR的加载接口。TextAsset.bytes返回的是byte[],直接可用。
csharp复制public static class HotUpdateLoadHelper
{
/// <summary>
/// 加载热更DLL, 优先远程, 无远程版本时加载本地兜底版本
/// </summary>
public static async Task LoadHotUpdateDlls()
{
// 1. 先确保远程catalog更新完成
await Addressables.UpdateCatalogs();
// 2. 加载热更dll
var dllHandler = Addressables.LoadAssetAsync<TextAsset>("HotUpdateDll");
var dllAsset = await dllHandler.Task;
// 3. 加载AOT补充元数据
var aotHandler = Addressables.LoadAssetAsync<TextAsset>("AotMetadata");
var aotAsset = await aotHandler.Task;
// 4. 补充AOT元数据(必须在加载任何热更程序集之前)
var errCode = HybridCLR.RuntimeApi.LoadMetadataForAOTAssembly(
aotAsset.bytes,
HomologousImageMode.SuperSet
);
Debug.Log($"[HotUpdate] LoadMetadataForAOTAssembly errCode={errCode}");
// 5. 加载程序集
var assembly = Assembly.Load(dllAsset.bytes);
// 6. 反射调用入口方法
var entryType = assembly.GetType("HotUpdate.App");
var entryMethod = entryType.GetMethod("Main");
entryMethod?.Invoke(null, null);
}
}
注意第4步:LoadMetadataForAOTAssembly必须在加载任何热更DLL之前调用,否则热更代码里一旦使用了AOT泛型(比如List<MyEnum>这种常见的泛型实例化),运行时就会直接抛异常崩溃。
3.3 HybridCLR元数据裁剪的预生成
HybridCLR要求把打包后的AOT程序集(Unity引擎的DLL和项目的AOT DLL)裁剪后生成补充元数据。生成时机是在IL2CPP构建后、打AB包之前。这一步需要在构建流程里编排好顺序。
补充元数据文件名建议统一为AotDlls.bytes,把多个DLL合并成一个TextAsset,避免AA里管理多个资源带来的加载顺序问题。我实际项目里是把核心AOT元数据合并成一个大文件,体积一般20-30MB,首次远程下载时也就几秒钟,完全能接受。
4. 构建流程编排:从IL2CPP到AB包一步到位
4.1 构建脚本的关键顺序
HybridCLR + AA双部署的项目,构建顺序一旦错了,后面排错非常痛苦。我的构建流水线顺序是:
- 用HybridCLR生成热更DLL(这一步会产出HotUpdate.dll.bytes等)。
- 切换Target到目标平台,执行IL2CPP构建(产出AOT DLL,也就是引擎自身的程序集)。
- 用HybridCLR的
HybridCLR.Editor.Commands生成AOT补充元数据。 - 把DLL和元数据拷贝到项目指定目录,并标记为Addressable资源。
- 执行Addressable的Build(ContentUpdate或NewBuild)。
- 把Addressable生成的远程组文件上传到CDN,本地组文件留在StreamingAssets。
第2步和第3步的依赖关系是硬性的,因为补充元数据是对IL2CPP生成的AOT DLL做裁剪,必须在IL2CPP编译完成后才能执行。
4.2 通过Addressable构建脚本触发HybridCLR
一个可以拷下来改改的构建脚本示例:
csharp复制public static class BuildPipeline
{
public const string HotUpdateDllOutput = "Assets/HotUpdate/Data/HotUpdate.dll.bytes";
public const string AotMetadataOutput = "Assets/HotUpdate/Data/AotDlls.bytes";
[MenuItem("Tools/构建/全量构建(Android)")]
public static void BuildAndroid()
{
// 第1步: 生成热更DLL
BuildTarget target = BuildTarget.Android;
HybridCLR.Editor.Commands.PrebuildCommand.GenerateAll();
// 第2步: 拷贝DLL到AA资源目录
File.Copy("HybridCLRData/HotUpdateDlls/HotUpdate.dll",
HotUpdateDllOutput, true);
File.Copy("HybridCLRData/AotDlls全部文件合并后",
AotMetadataOutput, true);
AssetDatabase.ImportAsset(HotUpdateDllOutput);
AssetDatabase.ImportAsset(AotMetadataOutput);
// 第3步: 执行IL2CPP构建
BuildPlayerOptions opts = new BuildPlayerOptions
{
scenes = new[] { "Assets/Scenes/Boot.unity" },
locationPathName = "Build/Android/app.apk",
target = target,
options = BuildOptions.None
};
BuildPipeline.BuildPlayer(opts);
// 第4步: 生成AOT补充元数据
HybridCLR.Editor.Commands.PrebuildCommand.GenerateAll();
// 第5步: AA构建
AddressableAssetSettings.BuildPlayerContent();
}
}
这里有个坑我必须说:生成AOT元数据的时机必须是在BuildPlayer之后,因为IL2CPP构建过程本身也会对dll进行裁剪,元数据只能基于裁剪后的成品生成。构建顺序错了,热更运行时极大概率报AOT泛型初始化错误,排查起来非常头痛。
4.3 Build与ContentUpdate的选择策略
AA构建有两条常用路径:
Build New:全量构建,从零生成所有组的内容。适用于首包、大版本更新。Content Update:增量构建,基于旧版本构建结果生成差异内容。适用于热更包。
在做热更新时,很多人会困惑“什么时候用ContentUpdate”,我的经验是:正式发布时,每次都走ContentUpdate流程,除非你要做全新大版本(连App一起发)。
具体操作:
- 先记录当前线上版本在本地的一个备份构建产物(
Library/com.unity.addressables/...下面的内容)。 - 修改资源后,在Addressables Groups窗口点击
Tools -> Check for Content Update Restrictions,它会弹出窗口让你指定一个Content State Data文件,选线上版本的。 - 它会自动把被修改的资源标记到新的远程组,然后执行
Build -> Content Update。 - 构建完成后,只上传新生成的远程文件到CDN。
这个方法能最大程度复用旧版本的资源,用户只下载变化的部分。我见过有些团队用Build New做热更,结果每次用户都要重新下载全部远程资源,几千个Bundle,体感极差。
5. 版本管理方案:Catalog、Version与校验
5.1 AA的Catalog更新机制
AA的Catalog是一个记录所有资源地址和Bundle映射关系的配置文件。远程组资源更新后,客户端的Catalog也要跟着更新,否则不知道去哪找新资源。
Catalog更新的标准方法是Addressables.UpdateCatalogs(),但这里有个前提:AA的Catalog本身并不知道“有没有新版本”,它每次都会去远程拉Catalog文件。如果远程没变化,这一步会很耗流量和时间。
所以要在AA之上自己加一层版本号文件来判断是否需要更新Catalog。常见做法:
- 在CDN上放一个
version.txt,内容为一个整形数字(比如1003)。 - 客户端启动时先请求
version.txt,和服务端返回的最新版本号对比。 - 如果版本号大于当前本地记录的版本,才调用
Addressables.UpdateCatalogs()。 - 更新完成后记录新版本号,后续加载资源走新Catalog。
5.2 版本号管理与校验的完整代码
csharp复制public class VersionManager
{
private static string RemoteVersionUrl = "https://your-cdn-domain.com/Android/version.txt";
private static string LocalVersionKey = "local_res_version";
public static async Task<bool> CheckAndUpdate()
{
int localVersion = PlayerPrefs.GetInt(LocalVersionKey, 0);
int remoteVersion = await GetRemoteVersion();
if (remoteVersion > localVersion)
{
Debug.Log($"[Version] 需要更新: {localVersion} -> {remoteVersion}");
// 更新catalog
var catalogs = await Addressables.UpdateCatalogs();
if (catalogs != null && catalogs.Count > 0)
{
PlayerPrefs.SetInt(LocalVersionKey, remoteVersion);
PlayerPrefs.Save();
return true;
}
else
{
// 线上version比本地大,但catalog更新没成功,可能cdn文件缺失
Debug.LogError("[Version] Catalog更新失败");
return false;
}
}
Debug.Log($"[Version] 已是最新版本: {localVersion}");
return false;
}
private static async Task<int> GetRemoteVersion()
{
using var request = UnityWebRequest.Get(RemoteVersionUrl);
request.timeout = 10;
var op = request.SendWebRequest();
while (!op.isDone) await Task.Yield();
if (request.result != UnityWebRequest.Result.Success)
{
Debug.LogError($"[Version] 拉取版本号失败: {request.error}");
return -1;
}
return int.Parse(request.downloadHandler.text.Trim());
}
}
5.3 版本不一致的灾难场景:Catalog回退
这里要提醒一个很隐蔽的坑:如果客户端已经更新到新Catalog,但某些资源还没下载完,此时若CDN上资源被重新覆盖(比如有人手动回滚了文件),客户端的Catalog和新资源可能不匹配,加载时会报RemoteProviderException或AssetNotFound。
我的规避方案是:CDN文件只允许追加新版本,不允许覆盖旧版本。每次热更生成的文件名都带Hash(AA默认是这样做的,Bundle文件名包含Hash),同一个Hash的文件内容永远不变。如果线上出了问题需要回滚,直接把version.txt改回旧版本号即可,客户端会用旧Catalog去找旧Hash的Bundle,只要CDN上保留了历史文件,就能正常回滚。
实际操作中,为节约CDN容量,可以保留最近3-5个版本的Bundle文件,更早的可以清理。
6. 运行时初始化流程与加载策略
6.1 启动场景的AA预热
双部署架构下,启动场景(Boot)必须放在本地组,并且要保证启动场景依赖的所有资源都在本地组,否则会出现“游戏还没起来就要下载资源”的尴尬局面。
具体做法:Boot场景里的UI、Shader、公共Prefab全部挂到Local Default Group。写一个编辑器脚本,启动时扫描Boot场景里所有资源和引用,凡是Addressable的资源但不在本地组的直接报错。
csharp复制[MenuItem("Tools/检查/Boot场景双部署依赖检查")]
public static void CheckBootSceneDependencies()
{
var scene = UnityEditor.SceneManagement.EditorSceneManager.OpenScene("Assets/Scenes/Boot.unity");
var allObjects = scene.GetRootGameObjects();
var refs = UnityEditor.EditorUtility.CollectDependencies(allObjects);
foreach (var obj in refs)
{
var entry = AddressableAssetSettingsDefaultObject.Settings.FindAssetEntry(
AssetDatabase.AssetPathToGUID(AssetDatabase.GetAssetPath(obj))
);
if (entry != null && entry.parentGroup.Name != "Local Default Group")
{
Debug.LogError($"[依赖检查] {obj.name} 在Boot场景中被引用,但所在分组为{entry.parentGroup.Name},必须改为本地组");
}
}
}
6.2 运行时初始化顺序(时间线)
Addressables.InitializeAsync():必须最先执行,加载AA的初始化配置和启动用Catalog。- 加载Boot场景UI(本地组),显示“检查更新”界面。
- 调用
VersionManager.CheckAndUpdate(),通过version.txt判断是否需要更新Catalog。 - 如果需要更新,调
UpdateCatalogs(),期间可以展示进度条(更新进度的粒度到“已更新catalog”就好,精确到Bundle级别意义不大)。 - 调用
LoadHotUpdateDlls()方法,加载热更DLL和AOT元数据。 - 反射进入
HotUpdate.App.Main(args),游戏逻辑正式开始。 - 后续的热更资源下载(美术资源、活动资源)由热更逻辑通过AA的Addressables API按需加载。
6.3 预下载与按需下载的策略选择
远程资源的加载策略会直接影响玩家体验,我的建议是分级加载:
- 首屏资源(热更DLL、启动弹窗UI、角色立绘)必须在Main入口后立即预下载,可以用
Addressables.GetDownloadSizeAsync判断大小,然后用Addressables.DownloadDependenciesAsync批量下载。 - 核心玩法资源(副本场景、战斗特效)在进入对应玩法前提前一个界面预下载。
- 边缘资源(商城装饰、语音包)只在用户点击时按需加载,失败也不影响主流程。
csharp复制public static async Task DownloadGroupWithProgress(string groupName, Action<float> onProgress)
{
var group = AddressableAssetSettingsDefaultObject.Settings.FindGroup(groupName);
var keys = group.entries.Select(e => (object)e.address).ToList();
long totalSize = await Addressables.GetDownloadSizeAsync(keys).Task;
if (totalSize <= 0) return;
var handle = Addressables.DownloadDependenciesAsync(keys);
while (!handle.IsDone)
{
onProgress?.Invoke(handle.GetDownloadStatus().Percent);
await Task.Yield();
}
if (handle.Status != AsyncOperationStatus.Succeeded)
{
Debug.LogError($"[Download] {groupName} 下载失败");
}
}
提示:
GetDownloadSizeAsync返回0不代表资源一定在本地,也有可能是Catalog里根本没找到资源。所以判断结果时要加一层“结果是否为0但确实需要加载”的兜底逻辑。
6.4 内存管理与资源释放
热更新项目跑久了最怕内存泄漏,AA的引用计数机制如果用不好,内存会被撑爆。双部署架构下尤其要注意:
- 远程资源加载完用完,一定要
Addressables.Release(handle),尤其是通过Key加载的单个资源。 - 场景切换时,用
Addressables.UnloadSceneAsync卸载场景,而不是SceneManager.UnloadSceneAsync。 - 对于频繁使用的UI图集,建议常驻(不Release),因为图集反复加载释放会导致性能抖动。
- 使用
Addressables.CleanBundleCache清理本地缓存的Bundle,但这个操作要在服务器确认资源不会再被用到时做,否则要重新下载,更糟。
7. 常见问题与排查技巧实录
7.1 “找不到资源”却明明在Bundle里
现象:运行时报InvalidKeyException或Failed to load asset with address。
排查步骤:
- 确认资源是否在Addressable Groups窗口里,且Address名正确(Address可以用
[address]规则批量命名)。 - 检查运行时用的Address是否和窗口里显示的一致,注意大小写。
- 在Editor的
Window -> Asset Management -> Addressables -> Analyze里跑一次Check Scene to Addressable Duplicate References,会把资源被多个Group引用的问题列出来。 - 看看是不是Catalog没更新:确认版本号是否成功变化,并用
Addressables.ClearDependencyCacheAsync清除本地缓存后重试。
7.2 HybridCLR热更DLL加载时报“AOT泛型初始化失败”
现象:热更代码里用到List<MyEnum>或Dictionary<string, MyClass>时,IL2CPP运行时抛ExecutionEngineException: Attempting to call method 'List<MyEnum>::Add' for which no ahead of time (AOT) code was generated.
原因:这个泛型实例化在打包时没有生成对应的AOT代码,补充元数据里也没有。
处理方案:
- 在HybridCLR的
AOTGenericReference配置里把常用泛型显式声明一下(比如List<int>、List<string>、List<MyEnum>这类高概率用到的)。 - 重新执行完整构建流程(DLL -> IL2CPP -> AOT元数据 -> AA)。
- 检查AOT元数据文件是否确实包含对应DLL(用HybridCLR的
CheckAssembly命令验证)。
我踩过最深的坑是:HybridCLR的补充元数据生成后,因为AA的跨组引用问题,AotDlls.bytes被意外裁剪成空文件,而运行时没报任何加载错误,只在真正用到泛型方法时崩。排查了好久才发现是构建脚本里拷贝文件时路径配错了,导致AA引用的是旧文件。
7.3 远程下载断点续传失败,弱网环境更新永远卡80%
现象:远程资源下载到80%左右,网络切换(WiFi切4G)后进度回退,反复下载失败。
原因:AA的DownloadDependenciesAsync默认不走UnityWebRequest的断点续传,一旦中断就要重来。同时,某些CDN对Range请求支持不完整。
处理方案:
- 让Unity的
UnityWebRequest走自带缓存,DownloadHandlerAssetBundle会写入本地缓存文件,AA会检测到Partial文件并续传。前提是CDN必须支持Range请求(大部分云厂商CDN都支持)。 - 下载前先判断
GetDownloadSizeAsync,如果剩余要下载的大小和上一次差很多,说明之前的部分下载没被缓存,需要检查CDN的Range配置。 - 自己实现一层断点续传逻辑(实时记录每个Bundle的下载状态,重启后跳过已完成的),工程量不小,但弱网用户体验提升明显。预算有限的情况下,先保证“失败重试3次+提示用户切换网络”也能凑合。
7.4 API Level或设备兼容问题
双部署项目出包后,最容易出现的是高版本Android设备正常,低版本设备或者部分模拟器上黑屏/闪退。
常见原因和解决:
- IL2CPP + 补充元数据体积过大会导致启动加载慢,在低端机上表现为长时间白屏。可以在启动界面加“加载中”反馈,并把AOT元数据拆分(热更必须的和可懒加载的分开)。
StreamingAssets路径在部分Android设备上读取有延迟,AA初始化要等资源准备好再执行,不要一进启动场景就并行加载。- 有些OLED设备在低电量下GPU频率被限制,Shader加载多了会闪退。排查办法:把画质分级和Shader的
QualitySettings配置挂钩,低端机降低Shader加载批次。
7.5 新旧版本混用的灰度问题
现象:线上同时存在1.0和1.1版本客户端,服务端更新了某个活动配置表,1.0客户端拉取后解析报错。
原因:服务端配置表的版本兼容没做好。热更逻辑下,老版本客户端也可能访问到新CDN上的文件。
处理:
- CDN文件按版本号目录隔离:
https://cdn/res_v1003/...,不同版本客户端只拉自己版本目录下的文件。AA的RemoteLoadPath里可以拼上版本号变量。 - 或者服务端在接口返回时带上客户端版本匹配校验,不匹配则拒绝下发。
- 更稳妥的是在AA的Catalog里只保留当前版本对应的资源,老版本客户端的更新请求直接返回“请升级App”。
8. “双部署 + 热更新”的边界与红线
虽然AA + HybridCLR能覆盖很多热更需求,但不是所有内容都适合热更,有些改动必须走App发版:
- AA框架本身的版本升级:AA是Unity包,升级后生成的Catalog格式可能变化,老版本客户端解析不了。
- 引擎Native层改动:接入新SDK(比如登录、支付、推送)、修改Manifest权限,这些都要重新打包。
- IL2CPP AOT代码大规模变更:HybridCLR热更依赖补充元数据,如果热更代码大量使用了新的泛型实例化,但元数据没有覆盖,运行时会崩。虽然可以通过补充元数据解决,但每次都生成新元数据要配合完整构建,和新发版成本接近。
- iOS的AppStore政策限制:iOS的JIT限制决定了HybridCLR在iOS上是解释执行模式(纯解释器模式),性能和启动速度会受影响。如果你的游戏对性能要求高,iOS上要做策略性调整。
红线问题说直白点:热更新是为了救急和轻量迭代,不要本末倒置把所有功能都塞进热更通道。真正稳定的做法是:核心框架和引擎层跟着App走,业务逻辑和内容资源走热更,两边保持清晰边界。
9. 当前方案的效果与上限
把AA双部署和HybridCLR接进项目后,我的实测数据(仅代表我们这个项目):
- Android包体:从845MB降至220MB,首包实现“核心玩法可玩”的目标。
- 热更DLL+元数据:首次约35MB/次,纯增量更新约2-8MB/次。
- 从服务端发布到用户生效:v1.0.1(修复登录崩溃)从上传CDN到全量生效约15分钟,v1.0.2(活动资源)约1小时。
- 资源加载速度:本地组资源加载和原AssetBundle直取几乎无差别,远程组弱网环境下平均加载耗时约1.2秒/10MB,可接受。
上限和瓶颈也比较明显:
- HybridCLR在Android的IL2CPP解释器模式下,逻辑性能约为AOT的60%-80%,重度战斗逻辑和频繁GC场景会掉帧。实测我们的战斗系统(频繁打怪掉落、技能判定)热更后帧率降了约12%,后续把热更代码里高频逻辑尽量下沉到AOT程序集后恢复。
- 资源热更对CDN带宽依赖大,如果买量导入用户暴增,CDN费用会跳涨。建议做资源分包+预下载策略,把冷门资源放到云存储低频访问层。
10. 实战中的几点个人体会
在做这套方案的整个过程中,最深刻的体会是:双部署方案的价值不在技术本身,而在工程管理。
技术上,AA和HybridCLR各自的文档都算齐全,但把它们拼起来后,真正的难点全在“边界”上:哪些资源放本地、哪些走远程、脚本和资源之间的依赖怎么保证不跨组、构建顺序怎么管控、版本回滚怎么办。这些问题没有标准答案,完全取决于你项目的类型(是重度MMO还是休闲游戏)、团队规模(有没有专职客户端构建岗)、以及线上运营的节奏。
如果你团队小、节奏快,建议第一步先只做“AA远程资源 + UI资源热更”,代码热更推迟到核心玩法稳定后再上。如果你项目是重度游戏、开发周期长,那么HybridCLR从第一天就该接入,否则后期几十个大系统全堆在AOT里,想转热更都费劲。
最后一个实用建议:一定要做线上监控。热更方案上线后,把“热更DLL加载失败率”“Catalog更新失败率”“远程资源加载失败率”这三个指标接入监控平台。失败率超过阈值就问CDN和版本号下发链路。没有监控,热更出问题你只会无从下手,有了监控,绝大部分问题能在玩家反馈前就发现。
这套方案在我们的项目里已经稳定跑了六个月,发了十几个热更版本,从“代码Bug修复”到“节日活动资源”都走同一套管线,团队的发布焦虑感明显降低了。希望这篇实战笔记能帮你绕开我们踩过的那些坑,让你的热更方案一次性跑通。
