包体从 80MB 压到 12MB 的那天下午我以为热更这件事已经收尾了。结果第一次真机验收远端资源 404 报了一屏好不容易把路径修对又发现改过的贴图在客户端还是老样子最后内存曲线一路向上爬到 OOM——三个问题三个方向全都指向同一套机制自定义更新目录、下载 AB 包、引用计数规则。这三件事在 Addressable 里是咬合在一起的单独看任何一块都觉得“不就是几个 API 吗”放在一起才会明白为什么官方文档翻三遍还是踩坑。这篇内容讲的是一次完整的远端资源体系搭建怎么让 Catalog 脱离包体独立更新怎么把远端路径用 Profile 变量管起来ServerData目录下哪些文件该传哪些绝对不能传DownloadDependenciesAsync在真机上到底是什么行为以及最容易被忽略的引用计数——它决定了你的 AssetBundle 什么时候真正从内存里消失。适合已经跑通过 Addressable 基础流程、准备做远端热更的开发者也适合那些“功能都跑通了但内存一直涨”的同学对照排查。下面按我当时落地的顺序讲每一步都会说清楚为什么这么做。1. Catalog 不在包里之后谁来决定“该加载哪一份”先把一个概念摆正Addressable 的所有加载行为最终都依赖一份叫 Catalog 的清单文件。这份清单里存的是资源地址Address到资源位置IResourceLocation的映射而IResourceLocation里最关键的两个字段是InternalId和Data——InternalId指向具体的 bundle 文件路径或 URLData里存的是这个 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 和 LoadContentCatalogAsyncAddressable 的启动过程是两段的。第一次Addressables.InitializeAsync()时引擎会去StreamingAssets/aa/settings.json里读配置然后加载包体内的主 Catalog如果你的工程勾了BuildRemoteCatalog主 Catalog 也会被放在远端但本地仍然保留一份用于兜底。这一步是隐式的——你调用任何LoadAssetAsync它都会先等你初始化完。第二段才是关键Addressables.LoadContentCatalogAsync(catalogPath)或者更高层的CheckForCatalogUpdatesUpdateCatalogs组合。这一步会在运行时把远端 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.bundleLoadPath 就是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_版本号.hashCatalog 的哈希指纹必须与 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 返回的是AsyncOperationHandleListstring列表里放的是需要更新的 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; } Liststring 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 一次 1Release 一次 -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.LoadAssetAsyncT()加载通过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 快得多。这套东西搭起来花不了半天但省下的排查时间是以天计的。