包体从 80MB 压到 12MB 的那天下午,我以为热更这件事已经收尾了。结果第一次真机验收,远端资源 404 报了一屏,好不容易把路径修对,又发现改过的贴图在客户端还是老样子,最后内存曲线一路向上爬到 OOM——三个问题,三个方向,全都指向同一套机制:自定义更新目录、下载 AB 包、引用计数规则。这三件事在 Addressable 里是咬合在一起的,单独看任何一块都觉得“不就是几个 API 吗”,放在一起才会明白为什么官方文档翻三遍还是踩坑。
这篇内容讲的是一次完整的远端资源体系搭建:怎么让 Catalog 脱离包体独立更新,怎么把远端路径用 Profile 变量管起来,ServerData目录下哪些文件该传哪些绝对不能传,DownloadDependenciesAsync在真机上到底是什么行为,以及最容易被忽略的引用计数——它决定了你的 AssetBundle 什么时候真正从内存里消失。适合已经跑通过 Addressable 基础流程、准备做远端热更的开发者,也适合那些“功能都跑通了但内存一直涨”的同学对照排查。下面按我当时落地的顺序讲,每一步都会说清楚为什么这么做。
1. Catalog 不在包里之后,谁来决定“该加载哪一份”
先把一个概念摆正:Addressable 的所有加载行为,最终都依赖一份叫 Catalog 的清单文件。这份清单里存的是资源地址(Address)到资源位置(IResourceLocation)的映射,而IResourceLocation里最关键的两个字段是InternalId和Data——InternalId指向具体的 bundle 文件路径或 URL,Data里存的是这个 bundle 的 hash、CRC、依赖关系。换句话说,运行时加载一个资源,Addressable 做的事情是:查 Catalog 拿到 location,再根据 location 里的 URL 去取 bundle。
默认情况下,这份 Catalog 会被打进包体的StreamingAssets/aa目录下,跟着安装包一起发出去。问题就出在这:包体里的 Catalog 是只读的,你发版之后它永远不会变。一旦远端 bundle 换了名字或者新增了资源,本地 Catalog 就找不到它们了——它手里的地图还是上一版的地图。
1.1 Catalog 本质是一张 InternalId 到 Bundle 的映射表
我习惯把 Catalog 理解成一张快递单。Address 是你填的收件地址(比如Assets/Prefabs/Hero.prefab这种逻辑地址),InternalId 是快递公司的内部运单号(指向xxx.bundle的实际路径),Data 里则是这个包裹的重量、体积、易碎标记(hash、CRC、依赖)。你查地址拿到运单号,再拿运单号去取件,中间任何一环对不上都会失败。
远端更新的本质,就是换一张新的快递单。新单子里可能有新的运单号、新的依赖关系,甚至同一个地址指向了不同的包裹。所以“自定义更新目录”这件事,核心不是把 bundle 传到服务器,而是让客户端在启动时能够拿到一张新的 Catalog,并且用它替换掉包里那张旧的。
1.2 Remote Catalog 的两段式加载:InitializeAsync 和 LoadContentCatalogAsync
Addressable 的启动过程是两段的。第一次Addressables.InitializeAsync()时,引擎会去StreamingAssets/aa/settings.json里读配置,然后加载包体内的主 Catalog(如果你的工程勾了BuildRemoteCatalog,主 Catalog 也会被放在远端,但本地仍然保留一份用于兜底)。这一步是隐式的——你调用任何LoadAssetAsync,它都会先等你初始化完。
第二段才是关键:Addressables.LoadContentCatalogAsync(catalogPath),或者更高层的CheckForCatalogUpdates+UpdateCatalogs组合。这一步会在运行时把远端 Catalog 拉下来,解析成一个新的IResourceLocator,然后注册到 ResourceManager 里,覆盖掉同名的旧 locator。注意是覆盖,不是叠加。所以远端 catalog 必须是从同一个工程、同一次构建链路里产出的,版本错乱会直接导致资源错乱。
这里有个很多人忽略的点:InitializeAsync必须彻底完成,才能去做 catalog 检查。如果你在Awake里并发调用InitializeAsync和CheckForCatalogUpdates,后者大概率拿到空列表,因为它依赖初始化阶段建立的 locator 表。我当时的写法是在启动流程里用一个显式的状态机,把“初始化完成”作为一个前置条件卡住后面的所有步骤,这个看起来笨但特别稳。
1.3 什么时候该上“自定义更新目录”,什么时候纯属自找麻烦
不是所有项目都需要远端 Catalog。如果你只是想让资源不占安装包体积,但每次发版都跟着商店走,那单纯把 Group 的 Build & Load Path 设成Remote,把 bundle 传到 CDN 就够了,Catalog 留在包里完全没问题——因为每次发版你都会重新打包,包里自然是最新的 Catalog。
真正需要远端 Catalog 的场景只有两种。第一种是发版后还要新增或替换资源,比如活动、皮肤、关卡,这些内容在打包时还不存在,必须靠新 Catalog 才能被找到。第二种是内容更新(Content Update)流程,你希望在保留玩家已下载内容的基础上,只下发发生变化的 bundle,这时候需要走addressables_content_state.bin的增量比对,而增量比对的结果会体现在新的 Catalog 里。
反过来说,如果你的团队没有 CDN 运维能力、没有版本回滚方案、没有灰度机制,那远端 Catalog 带来的风险是实打实的:一次错误的 Catalog 下发,可以让全量用户的白屏率瞬间拉满。我见过最惨的一次是有人把测试环境的 Catalog 传到了生产目录,玩家进去之后所有资源地址都对不上,进游戏直接黑屏。
2. Profile 变量与远端路径:URL 是在编辑器里拼好、在运行时解析的
远端资源的路径配置,全在 Addressables 的 Profile 里。打开AddressableAssetSettings的 Profiles 面板,你会看到四行:Local.BuildPath、Local.LoadPath、Remote.BuildPath、Remote.LoadPath。这四行看起来简单,但它们决定了 bundle 打包时落在哪个目录、运行时从哪个 URL 去取,是整个远端体系的地基。
最容易出问题的地方在于:BuildPath 和 LoadPath 是两套东西,作用时机完全不同。BuildPath 只在编辑器构建时参与,决定了 bundle 文件物理上生成在哪个文件夹;LoadPath 只在运行时参与,决定了 InternalId 拼接出来的 URL 前缀。很多人改完 BuildPath 发现真机还是 404,就是因为只改了前一半。
2.1 BuildPath 和 LoadPath 的职责边界
默认配置下,Remote.BuildPath是ServerData/[BuildTarget],相对工程根目录,构建产物会落在ServerData/Android、ServerData/iOS这样的子目录里。这个目录里的东西就是你要上传到服务器的全部内容。
Remote.LoadPath默认是http://localhost/[BuildTarget],这个显然不能用于生产。你要把它改成你的实际地址,比如https://your-cdn-domain.com/aa/[BuildTarget]。注意这里是前缀,Addressable 会在运行时把这个前缀和 bundle 的相对路径拼起来,得到最终 URL。
一个非常实用的做法是把两边的目录结构约定成完全一致:BuildPath 生成ServerData/Android/xxx.bundle,LoadPath 就是https://.../aa/Android,这样上传时整个Android文件夹怼上去就行,不用做任何路径改写。我踩过的坑是早期把 BuildPath 设成了ServerData(没有平台子目录),结果 Android 和 iOS 的产物混在一个文件夹里互相覆盖,排查了半天才发现。
2.2 用自定义 Profile 变量把 CDN 地址抽出来
硬编码 URL 在 LoadPath 里是可以工作的,但一旦你要在开发、测试、生产三套环境之间切换,就会非常痛苦。正确做法是定义自定义 Profile 变量。
在 Profile 面板里加一个变量,比如叫CDNRoot,值填https://your-cdn-domain.com/aa。然后Remote.LoadPath写成[CDNRoot]/[BuildTarget]。变量引用用方括号,[BuildTarget]是内置变量,会解析成当前构建目标平台名(Android、iOS、StandaloneWindows64等)。
// Profile 变量的值在运行时可以通过这种方式读取,用于日志和自检 string remoteRoot = Addressables.RuntimePath; // 本地 runtime 路径 // 如果你需要拿到远端根地址做拼接判断,可以在构建脚本里导出配置更进一步,可以再加一个CDNVersion变量做多版本并存,LoadPath 写成[CDNRoot]/[CDNVersion]/[BuildTarget]。这样服务器上可以有v1、v2多个目录,回滚时只要切换客户端的CDNVersion值就行,不用动服务器文件。这个设计在灰度发布场景下非常好用——你可以让一部分玩家读v2目录,出问题立刻切回v1。
2.3 路径拼接的三个高频陷阱
第一个是结尾斜杠。https://host.com/aa后面不带斜杠,Addressable 拼接时会自己补一个,但如果你写成https://host.com/aa/带了斜杠,有些版本会拼出双斜杠//Android/xxx。多数 CDN 能容忍双斜杠,但少数对象存储会直接返回 403 或者 404,非常隐蔽。我现在的习惯是结尾一律不带斜杠。
第二个是大小写。[BuildTarget]解析出来的平台名是首字母大写的(Android),但如果你的 CDN 部署在 Linux 上,文件系统是区分大小写的,服务器上的目录必须和它完全一致。有次运维同学上传的时候手动改成了android,结果所有请求全 404,日志里看 URL 完全正确,就是找不到文件。
第三个是 URL 编码。如果你的 CDN 域名后面带了路径参数或者特殊字符,Addressable 在拼接时不会做 URL 编码,可能导致请求被 CDN 拒掉。这个场景比较少见,但如果你的资源路径里出现了中文或者空格,一定要改成英文加下划线。
3. ServerData 目录逐个文件过一遍:哪些要上传,哪些只能留在本地
构建完成之后,打开ServerData/Android目录,你会看到一堆文件。我第一次看到这个目录的时候是懵的,因为根本分不清哪些必须传、哪些传了会出大事。这里逐个说清楚。
| 文件 | 作用 | 是否上传 | 备注 |
|---|---|---|---|
catalog_<版本号>.json | 资源位置清单 | 必须 | 文件名带版本号 |
catalog_<版本号>.hash | Catalog 的哈希指纹 | 必须 | 与 json 同时更新 |
*.bundle | 实际的资源包 | 必须 | 按 Group 划分 |
*.bundle.manifest | 构建时的依赖信息 | 视情况 | 通常不上传 |
addressables_content_state.bin | 增量构建的基准快照 | 绝对不能 | 仅限本地保存 |
settings.json | 运行时的全局配置 | 通常不上传 | 跟随包体 |
3.1 catalog.json 与 catalog.hash 的配对关系
CheckForCatalogUpdates比较的就是.hash文件。流程是:客户端先请求远端的.hash,拿回来和本地缓存的 hash 做比对,如果不一样,认为 Catalog 有更新,再去下载.json。
这里有个非常隐蔽的坑:如果你只更新了 json 没有更新 hash,客户端会认为没有更新;如果只更新了 hash 没更新 json,客户端会发现有更新,但下载下来的是旧内容。两种情况都会导致“明明改了却看不到变化”的诡异现象。所以上传时必须保证两个文件成对更新,最好用脚本一次性传,别手动拖。
另一个大坑是 CDN 缓存。如果你的 CDN 对.hash做了长时间的缓存,客户端永远拿到的是旧 hash,也就永远检测不到更新。Addressables 默认给 catalog 文件名加了版本号后缀(类似catalog_1.21.19.json)就是为了绕开这个问题——文件名变了,CDN 就会当成新文件重新回源。但.hash文件的名字通常不带版本号,所以一定要在 CDN 后台给.hash配置不缓存或者短缓存(比如 60 秒)。
3.2 bundle 与它旁边的 manifest 文件
*.bundle.manifest是构建过程中产生的依赖描述文件,主要给编辑器分析用,运行时不会去读它。传到 CDN 上除了浪费空间和增加误操作风险之外没有任何好处,我一般直接排除掉。
真正要注意的是 bundle 本身的命名。如果不同 Group 生成了同名的 bundle,后构建的会覆盖先构建的,最后只有一个能存在,这类问题在真机上表现为“某些资源莫名其妙加载失败”。解决办法是在AddressableAssetSettings里勾上Unique Bundle IDs,让每个 bundle 名带上组名和哈希后缀。代价是每次构建即使内容没变,bundle 名也可能变化,导致远端全量重新下载——所以这个开关要结合你的发布频率权衡。我的经验是:内容迭代频繁、组划分细的项目开启;资源结构稳定、追求下载量的项目关闭,改用严格的命名规范来约束。
3.3 addressables_content_state.bin 为什么绝对不能传
这个文件是增量构建的基准快照,记录的是上一次发布时所有资源的地址、依赖和 bundle 分配情况。它只在本地起作用,Content Update流程会读它来比对出“哪些 bundle 变了”。
把它传到服务器上没有任何技术意义,但风险极大。如果某个流程误把它当配置文件下发到客户端(比如有人把它放进了StreamingAssets一起打包),可能导致增量比对逻辑读到错误的基准,产出错乱的新 Catalog。我现在的做法是在构建脚本里加一行,把这个文件复制到一个固定的本地备份目录,然后从ServerData里删掉,从源头上杜绝误传。备份目录按版本号命名,回滚的时候直接切回去就行。
4. 版本检查与 Catalog 切换:Check 和 Update 必须是两步
远端 Catalog 的更新在 Addressable 里是一个明确的两步流程:CheckForCatalogUpdates判断有没有更新,UpdateCatalogs执行下载和替换。很多人图省事直接用UpdateCatalogs(null)让它内部自动检查,这样确实能跑,但会丢掉“有没有更新”这个信息,导致你无法在 UI 上给出“正在检查更新”和“正在下载配置”两个不同的提示。
4.1 CheckForCatalogUpdates 到底比较了什么
这个 API 返回的是AsyncOperationHandle<List<string>>,列表里放的是需要更新的 Catalog 路径。内部逻辑是:对每一个启用了远端加载的 locator,去请求它对应的.hash文件,和本地记录的 hash 做字符串比对,不一致就加进待更新列表。
IEnumerator CheckAndUpdateCatalog() { // 第一步:等待初始化彻底完成,这是所有后续操作的前提 var initHandle = Addressables.InitializeAsync(); yield return initHandle; // 第二步:检查远端 catalog 是否有更新 var checkHandle = Addressables.CheckForCatalogUpdates(false); yield return checkHandle; if (checkHandle.Status != AsyncOperationStatus.Succeeded) { Debug.LogError($"检查 catalog 失败: {checkHandle.OperationException}"); Addressables.Release(checkHandle); yield break; } List<string> catalogsToUpdate = checkHandle.Result; Debug.Log($"待更新的 catalog 数量: {catalogsToUpdate.Count}"); if (catalogsToUpdate.Count > 0) { // 第三步:执行更新,返回新的 locator 列表 var updateHandle = Addressables.UpdateCatalogs(catalogsToUpdate, false); yield return updateHandle; if (updateHandle.Status == AsyncOperationStatus.Succeeded) { var locators = updateHandle.Result; Debug.Log($"catalog 更新完成,新 locator 数量: {locators.Count}"); } Addressables.Release(updateHandle); } Addressables.Release(checkHandle); }注意每个 handle 都要 Release,包括看起来只是“查询”的CheckForCatalogUpdates。这个 handle 内部持有真实的异步操作对象,不释放会一直挂在 ResourceManager 里。
4.2 UpdateCatalogs 之后,旧的 handle 会怎样
这是我认为最需要警惕的一点。UpdateCatalogs完成后,ResourceManager 里的 locator 被替换成了新的,但之前已经通过LoadAssetAsync拿到的 handle 依然指向旧的 location。这意味着在更新 Catalog 之后继续使用旧 handle,可能访问到已经被卸载的 bundle,表现为空引用或者资源丢失。
所以正确的心智模型是:Catalog 更新是一个分界线,分界线之前加载的所有资源都应该被释放,分界线之后重新加载。我实际的做法是在启动流程里把“检查更新”放在最前面,在它完成之前不加载任何业务资源,只加载极少量的启动 UI 资源,并且这些启动资源会被放在一个独立的、不参与远端更新的 Group 里。
还有一种情况是运行中更新——比如玩家在设置界面点了“检查更新”。这时候场景里可能已经在跑一堆资源了。我的建议是不要在运行中做完整 Catalog 更新,老老实实提示玩家重启客户端。强行在运行中切换 Catalog,你需要精确释放所有旧 handle、清理缓存、重新初始化,出问题的概率远高于收益。
4.3 一套可以照着做的启动流程
把上面的东西串起来,启动流程大致是这五步:
- 显示启动画面,加载本地基础资源(来自打包进包体的 Group)
- 等待
InitializeAsync完成 - 依次执行
CheckForCatalogUpdates→UpdateCatalogs - 用关键资源 key 调
GetDownloadSizeAsync算总下载量,弹窗询问玩家是否下载 - 下载完成后进入主流程,此时才允许加载远端资源
第 4 步的下载量提示很重要,尤其是首次进入的玩家。我见过不少项目跳过这步,玩家第一次进去流量跑掉几百兆,投诉就来了。GetDownloadSizeAsync支持传入一个 key 列表或者 Label,把“首次进入必须带的资源”打上同一个 Label,一次性算出来最方便。
5. DownloadDependenciesAsync:进度、缓存与取消的真实行为
DownloadDependenciesAsync是下载远端 bundle 的主力 API。它的作用是:给定一个 key(或一组 key),找到这些资源依赖的所有 bundle,把本地没有的那些下载下来。
听起来很直观,但实际用起来有几个反直觉的地方。
5.1 GetDownloadSizeAsync 的返回值为什么和实际下载量对不上
GetDownloadSizeAsync返回的是“需要从网络下载的字节数”,它已经扣除了本地缓存里存在的 bundle。这本来是个好事,但也带来一个现象:第一次调用算出 50MB,玩家点了取消,第二次再算还是 50MB,可实际下载只跑了 10MB 就完成了——因为第一次下载的部分已经进了缓存,只是缓存的写入是渐进的,GetDownloadSizeAsync的计算也基于当时的缓存快照。
我的做法是不纠结绝对精确,在 UI 上显示“约 XX MB”而不是精确数字,并且把下载过程做成可中断的:玩家点了取消,下次进来提示“上次剩余约 XX MB,是否继续”。这种体验反而比纠结精确值更自然。
5.2 进度回调里的 Percent 不是线性的
下载进度通过handle.GetDownloadStatus()拿,返回一个DownloadStatus结构体,里面有DownloadedBytes、TotalBytes和Percent。这里的坑在于:TotalBytes是当前已经发起的所有请求的总字节数,而不是你预期的总量。Addressable 的下载是按 bundle 并发发起的,早期几个小 bundle 先完成,TotalBytes会不断增长,导致Percent出现“先冲到 80% 再掉回 30%”这种跳变。
IEnumerator DownloadWithProgress(object key) { var sizeHandle = Addressables.GetDownloadSizeAsync(key); yield return sizeHandle; long totalSize = sizeHandle.Result; Addressables.Release(sizeHandle); if (totalSize == 0) { Debug.Log("所有资源已在本地缓存,无需下载"); yield break; } var downloadHandle = Addressables.DownloadDependenciesAsync(key, false); float lastReported = 0f; while (!downloadHandle.IsDone) { if (downloadHandle.Status == AsyncOperationStatus.Failed) { Debug.LogError($"下载失败: {downloadHandle.OperationException}"); break; } DownloadStatus status = downloadHandle.GetDownloadStatus(); // 用已下载字节数除以预估总大小,比 Percent 更稳定 float rawProgress = totalSize > 0 ? (float)status.DownloadedBytes / totalSize : 0f; // 进度条只增不减,避免回退带来的视觉抖动 lastReported = Mathf.Max(lastReported, Mathf.Clamp01(rawProgress)); UpdateProgressBar(lastReported, status.DownloadedBytes / 1024f / 1024f); yield return null; } if (downloadHandle.Status == AsyncOperationStatus.Succeeded) { Debug.Log("下载完成"); } Addressables.Release(downloadHandle); }上面这段用“已下载字节 / 预估总量”来算进度,并且做了单调递增处理,进度条就不会来回跳了。这是我试过几种方案之后觉得最舒服的写法。
5.3 清理缓存的两个层级
清理缓存有两个 API,容易混淆。
ClearDependencyCacheAsync(key)是按 key 清理,把指定资源依赖的 bundle 从本地缓存里删掉。适合做“清理某个活动的资源”这种操作。要注意它清理的是依赖链上的所有 bundle,如果两个活动共享了一个公共 bundle,清掉一个活动的同时会把另一个活动的缓存也干掉,下次进入又要重新下载。
CleanResourceCache()是全局清理,把所有引用计数为 0 的 bundle 从内存和缓存里释放掉。这个更暴力,一般用在切场景、退出登录这种明确的时机。它有一个force参数,传true会连正在使用的也清掉——这个千万别乱用。
还有个更底层的选择是直接调Caching.ClearCache(),但那会把整个 Unity 缓存清空,如果你还有别的系统在用缓存(比如 AssetBundle 之外的网络资源),会被误伤。我一般只在玩家手动点“清理缓存”按钮时才用它。
缓存的过期策略也值得说一句。Unity 的缓存有自己的过期时间设置(Caching.expirationDelay),长时间不访问的缓存可能被自动清理。如果你的项目有“预下载全部资源”的设计,记得把这个值调大,否则玩家下载完放几天再进来,缓存没了又要重新下。
6. 引用计数记在谁头上:Bundle 的生死判定链路
如果你的项目用的是 Addressable,但内存曲线一直缓慢上涨,八成是引用计数没管住。这部分是整篇文章里最需要动脑子理解的地方,因为 Addressable 的引用计数并不直接暴露给你,它藏在 ResourceManager 内部。
6.1 LoadAssetAsync 一次 +1,Release 一次 -1
整个机制可以概括成一句话:每次成功的LoadAssetAsync会给对应的 bundle 引用计数 +1,每次Release会 -1,计数归零时这个 bundle 才有资格被卸载。
关键在于“对应”两个字。你LoadAssetAsync("HeroPrefab")时,Addressable 会顺着 Catalog 找到 HeroPrefab 所在的 bundle,对它 +1。如果这个 bundle 里还有 20 个别的资源,你加载其中一个,也是对同一个 bundle +1。所以计数是针对 bundle 的,不是针对资源的。
这就解释了一个常见疑惑:为什么我加载了一个小图,内存却涨了几十兆?因为那个小图和一个大图恰好被分到同一个 bundle 里,加载这个 bundle 就把整个包都读进内存了。决定内存占用的不是资源大小,是 bundle 大小。所以 Group 划分要按“使用场景”来切,把同时用到的放一起,把不同时用到的拆开。
6.2 InstantiateAsync 和 AssetReference 的双重计数
除了LoadAssetAsync,还有两条常见的加载路径。
一条是Addressables.InstantiateAsync。它内部做了两件事:加载 prefab 资源,然后实例化。所以它会对 prefab 所在的 bundle +1,并且返回的 handle 同时代表“资源引用”和“实例引用”。要释放必须用Addressables.ReleaseInstance(gameObject)或者Addressables.Release(handle),两者都能正确销毁实例并减计数。
千万别直接Destroy(gameObject)。那样实例是没了,但引用计数没减,bundle 永远处于“有人在用”的状态,内存就这么漏掉了。这个问题在 Project 视图里完全看不出来,只有用 Memory Profiler 抓包才能看到一堆 bundle 挂着不释放。
另一条是AssetReference。这是序列化在 Inspector 上的引用,运行时通过assetRef.LoadAssetAsync<T>()加载,通过assetRef.ReleaseAsset()释放,或者assetRef.InstantiateAsync()/assetRef.ReleaseInstance(go)配对。它的计数规则和LoadAssetAsync完全一致,只是入口不同。
6.3 计数归零不等于立刻卸载
这是最反直觉的一点。引用计数归零之后,bundle 只是“可以被卸载”,但ResourceManager不一定会立即释放它,因为它可能还在等一个清理时机。
新版本里有一个 bundle 卸载行为的设置项,可以配置成“计数归零即卸载”或者“计数归零且不再被引用时才卸载”。前者内存回收更积极,但可能导致同一个 bundle 反复加载卸载,产生 IO 抖动;后者更温和,适合资源复用率高的场景。
如果你希望主动触发清理,可以调Addressables.CleanResourceCache(),它会扫描所有引用计数归零的 bundle 并释放。我一般在这几个时机调用:场景切换完成后、退出登录时、玩家手动清理缓存时。
还有一点要强调:Resources.UnloadUnusedAssets()不会卸载 Addressable 管理的 bundle。它只管Resources目录和场景里没有引用的资源。很多人以为调一下它就万事大吉了,实际上对 Addressable 完全无效。
6.4 几个天天有人踩的计数陷阱
第一个是AsyncOperationHandle是结构体。它内部持有一个共享的 operation 对象,所以拷贝不会导致计数错乱,但如果你把 handle 存进 List 又在多处 Release,很容易多减一次。多减的后果是计数变成负数,bundle 被提前卸载,正在使用的资源突然变成紫色或者空引用。我现在的做法是所有 handle 的生命周期都跟一个明确的所有者绑定,谁加载谁释放,绝不跨模块传递。
第二个是 handle 已经 Release 之后再访问handle.Result。这会抛出Attempting to use an invalid operation handle异常。常见于Completed回调里——你注册了回调,但在回调触发前就把 handle 释放了,回调一进去就炸。写法上要么保证回调里先判断handle.IsValid(),要么保证释放时机一定晚于回调。
第三个是场景加载。LoadSceneAsync加载的场景会对场景依赖的 bundle 做计数,必须用UnloadSceneAsync卸载。直接SceneManager.UnloadSceneAsync是不走 Addressable 计数链的,会导致场景相关的 bundle 一直被占着。
7. 三个线上问题的排查复盘
前面讲的都是“应该怎么样”,这一节讲“出事了怎么查”。我挑三个有代表性的问题,把完整的排查过程写出来,遇到类似现象的可以直接对照。
7.1 现象:真机一片 404,日志里的 URL 看起来完全正确
第一个反应是路径配错了。打开日志,把打印出来的 URL 复制到浏览器,能正常访问——说明路径是对的,那问题就在别处。
第一步查的是文件是否真的存在。用命令行去列服务器目录,发现catalog_1.21.19.json在,但catalog_1.21.19.hash不在。原因是打包脚本只上传了 json 后缀的文件,hash 被过滤掉了。CheckForCatalogUpdates请求 hash 拿到 404,整个更新流程静默失败,然后用旧 Catalog 去加载新资源,自然全 404。
这个问题的排查链路说明一件事:看到 404 不要急着改路径,先确认服务器上文件是不是真的齐了。我现在的上传脚本会有一个校验环节,列出本地目录的所有文件,逐个确认服务器上存在且大小一致,任何一个对不上就中断并报错。
7.2 现象:Catalog 更新成功了,但资源内容还是旧的
日志显示UpdateCatalogs成功,新 locator 也注册了,但加载出来的贴图还是老的。这种情况通常是 bundle 名没变但内容变了,客户端拿到的还是缓存里的旧 bundle。
排查的第一步是看 bundle 的 hash 有没有变。在 Catalog 的 json 里能找到每个 bundle 的 hash 值,对比新旧两份,如果 hash 一样,说明构建时没有真正把改动打进去——最常见的原因是改了资源但没重新构建,直接用了上一次的产物。Addressable 的构建有缓存机制,如果它认为 Group 的内容没变,可能跳过重新打包。
第二步看 CDN 缓存。如果 bundle 名不变、hash 变了,理论上客户端会发现 hash 不匹配并重新下载。但如果 CDN 对 bundle 做了强缓存,客户端请求的时候拿到的还是旧文件,hash 校验反而会失败报错。所以 bundle 文件也一样要配置合适的缓存策略——通常是用长缓存加文件名版本号的方式,而不是靠 CDN 自己判断。
第三步看本地缓存。Addressable 下载的 bundle 会存在本地缓存里,如果缓存没被正确失效,加载时可能直接读本地旧文件。清理缓存后重试,如果问题消失了,就确认是缓存失效逻辑的问题。
7.3 现象:内存曲线一路向上,玩十分钟就 OOM
这是最典型也最耗时的排查。我的标准流程分四步。
第一步,确认是不是 Addressable 的问题。用 Memory Profiler 抓两个时间点的快照,对比AssetBundle对象的数量变化。如果 bundle 数量持续增长且从不下降,基本可以确定是引用计数没释放。
第二步,看是哪些 bundle 在涨。按大小排序,找出增长最快的几个,然后回到工程里查这些 bundle 里装了什么资源、被哪些代码加载。这一步的目的是缩小范围,从“全部没释放”缩到“某几个模块没释放”。
第三步,查这些资源的加载代码。重点看三种模式:加载了但从来没调用 Release、在循环里反复加载同一个资源(每次 +1 但只释放一次)、用Destroy代替了ReleaseInstance。第三种最常见,我见过一份代码里几十处Destroy(go),全是 InstantiateAsync 出来的对象。
第四步是修复后的验证。我会写一个压测脚本,模拟反复进出某个界面二十次,观察 bundle 数量是否稳定。稳定在某个值不再增长,才算修复成功。这个验证步骤不能省,因为引用计数的 bug 往往有多个,修好一个还有下一个。
最后一个容易忽略的点:异步加载的竞态。比如你在OnEnable里发起加载,在OnDisable里释放,如果加载还没完成就触发了 Disable,释放的时候 handle 还无效,后续加载完成时又没人管它,就漏了一个。这种问题靠代码审查很难发现,只能靠压力测试 + 内存快照反复对比。
最后分享一个我自己一直在用的小技巧:在开发阶段把 Addressable 的事件回调打开,每次 bundle 加载和卸载都打一条带时间戳和InternalId的日志,输出到一个环形缓冲区里,界面上做个快捷键随时 dump 出来。真机上内存出问题时,直接把这个日志拉出来看最后加载了哪些 bundle、有没有对应的卸载记录,比连 Profiler 快得多。这套东西搭起来花不了半天,但省下的排查时间是以天计的。