最近又有同事跑过来问我:我就点了一次Build,怎么工程里同时冒出来两个catalog?是不是Addressables哪里配错了,重复构建了一份?这个问题我早几年刚接触Addressables时也撞到过,当时还反复Clean Build了好几次,结果重建完照样是两个,一度怀疑人生。后来把构建流程顺着源码捋了一遍才算彻底想明白:不是重复了,这是Addressables设计上就需要两份catalog,一份给编辑器自己用,一份给运行时真正加载用。这篇文章就把这个问题的来龙去脉和AA设置Build面板里的关键选项一次讲清楚,正在被catalog困扰的Unity开发者可以少走点弯路。
先给结论:如果你用的是默认配置,一次Build Player Content之后,至少会在两个目录下看到catalog文件——一个在Library或Assets下的编辑器数据区,另一个在StreamingAssets/aa/{平台名}/下。再多配一个远程目录,还会出现第三份。每份的职责不同,缺一不可。下面我按"catalog是什么 -> 为什么有两个 -> Build面板怎么配 -> 实操复现 -> 常见问题"的顺序展开。
1. 先把catalog到底是什么说清楚
1.1 Catalog是一张"资源地图"
很多新手容易把catalog和AssetBundle搞混。AssetBundle是资源本体,一个Prefab、一堆贴图、一个场景,经过打包后变成的二进制文件;而catalog是描述这些bundle的"元数据索引",记录的是"哪个Addressable地址对应哪个bundle、这个bundle里包含哪些资源、依赖哪些兄弟bundle、该用什么Provider去加载"。
你可以把它想象成图书馆的检索卡片:书(AssetBundle)整整齐齐码在书架上,你要找某本书(通过Addressable地址加载资源)时,不可能一本本翻,而是先查检索卡(catalog)拿到"第几排第几层"的定位信息,再过去取书。运行时加载Addressable资源的完整链路就是:
- 初始化时加载catalog。
- 根据传入的Address(比如
"Assets/Prefabs/Enemy.prefab"或一个自定义短名)在catalog中查找到对应的ResourceLocation。 - 根据location里的信息(bundle名、依赖、Provider类型)加载对应AssetBundle。
- 从bundle中实例化出真正的资源返回给你。
所以catalog一旦缺失或损坏,哪怕bundle文件全都在,运行时也找不到任何资源,这就是为什么很多报错会直接提示Failed to load catalog。
1.2 一次Build到底产出了什么
Addressables的构建不是"把资源打成一个包"这么简单,而是走了一条完整的流水线。以官方默认的Default Build Script为例,执行一次Build后,输出目录里会出现这几类东西:
- 若干
.bundle文件:按分组(Group)和构建模式生成的AssetBundle,这是资源真实载体。 - 一个
catalog_{hash}.json:上述的索引文件。 - 一个
catalog_{hash}.hash:catalog内容的短哈希值,用于版本对比。 link.xml、addressables_link.png等辅助文件:帮助IL2CPP裁剪时保留必要类型,以及供编辑器识别构建信息。
构建脚本的逻辑大致是:先打包bundle,再根据打包结果生成Location列表,最后把Location列表序列化成catalog。也就是说,catalog是构建流程的最后一步,它"事后"地汇总了整个构建产物。任何资源的增删改、分组变更、Profile路径变化、Unity版本或Addressables版本升级,都可能影响catalog的hash值,让它看起来每次构建都不一样。
2. 一次构建为何生成两个catalog:核心原因拆解
2.1 两份catalog各自的身份
我们需要先明确一个概念:catalog文件不是某个单一实例,而是"同一份索引数据,在不同生命周期阶段写入不同位置"。
以默认配置为例,在你执行一次完整的Build Player Content后,常见产物路径如下表:
| 文件路径 | 身份定位 | 谁在使用 |
|---|---|---|
Assets/AddressableAssetsData/Generated/ContentCatalogData.asset | 编辑器侧catalog(以ScriptableObject形式存在) | 编辑器内Use Existing Build模式、Groups窗口信息展示 |
Library/com.unity.addressableassets/aa/{平台}/catalog_{hash}.json | 构建缓存目录中的catalog | 编辑器内验证构建结果,也是后续复制到StreamingAssets的来源 |
Assets/StreamingAssets/aa/{平台}/catalog_{hash}.json | 运行时catalog | 真机/打包后运行时加载 |
RemoteBuildPath/catalog_{hash}.json(勾选Build Remote Catalog时) | 远程catalog | 远程更新场景下,客户端从服务器拉取 |
大部分人说"一次构建生成两个catalog",看到的主要是第二项和第三项,或者在Assets/AddressableAssetsData/Generated下的.asset文件和StreamingAssets下的.json文件。它们内容同源,但服务对象不同:编辑器模式需要一份能被AssetDatabase管理的资产型catalog,运行时则需要一份能被文件系统读取的序列化catalog。
2.2 为什么不能只保留一份
可能有人会问:既然内容一样,为什么不只生成一份,运行时直接从项目数据里读?原因很直接:
第一,编辑器环境有AssetDatabase,可以方便地创建、查找、修改.asset文件;但打包后的Player运行环境完全没有AssetDatabase这个概念,它只能通过Application.streamingAssetsPath这类文件路径来读取数据。
第二,编辑器里跑Use Existing Build模式时,Addressables需要按"真实bundle加载"的方式模拟运行,此时必须有一个catalog告诉它bundle在哪、依赖是什么;这个catalog如果放在Library缓存目录,编辑器倒也能读,但放在Assets下以.asset形式存在,用起来更稳妥,还能在Groups面板里直接展示当前构建的catalog信息。
第三,StreamingAssets下的catalog必须跟随Player一起打包。你在编辑器中构建的资源,如果不同步到StreamingAssets,打出的安装包里就什么都没有。所以Build Player Content这步会把构建产物主动复制/写入StreamingAssets/aa/{平台}/。
一句话总结:不是构建逻辑重复了,是"编辑器"和"运行时"两个消费场景各自需要一份catalog。
2.3 你看到的"另一个catalog"也有可能是旧hash残留
除了上面说的两份跨界catalog,还有一个特别常见的现象:目录里躺着好几个catalog_{不同hash}.json。原因是catalog文件名带hash,只要内容有任何变化(哪怕只是某个Addressable分组里一个资源的导入设置变了),新构建生成的catalog hash就会变,而旧文件默认不会立刻被删掉。
这看起来就更像"生成了一堆catalog"了。其实旧的属于历史残留,确实可以手动清理,或者用Clean Build清一次构建缓存。判断"哪个才是当前真正生效的catalog",可以看同目录下catalog_{hash}.hash文件里记录的hash值,或者直接看构建日志末尾输出的catalog路径。
提示:如果你改了Addressable设置,怀疑构建产物是旧的,不要手动去删文件,用
Build > Clean Build > All配合Build Player Content重来一次,比手工清理靠谱得多。
3. AA设置Build面板逐项拆解
3.1 Build面板入口和整体布局
打开Window > Asset Management > Addressables > Groups,在Groups窗口左上角有一排下拉菜单,其中标着Build的就是Build面板入口;选中Assets/AddressableAssetsData/AddressableAssetSettings.asset,在Inspector里也能看到Build相关的配置区。不同Addressables版本UI位置略有差异,但核心选项基本一致。
Build相关设置主要分成三块:Build and Play Mode Scripts、Build Player Content/Clean Build、Catalog设置区,以及跟路径相关的Profiles配置。下面逐个过。
3.2 Build and Play Mode Scripts怎么选
这个区域通常是个列表,显示当前工程可用的构建脚本和播放模式脚本。
Default Build Script:默认构建脚本,也就是上文中提到的打包AssetBundle、生成catalog的完整流程。一般不需要换,除非你写了自己的IBuildScript。- Play Mode Scripts有三个选项:
Use Asset Database (fastest):编辑器下直接引用源资源,不加载bundle。迭代最好用,改完Prefab马上能看到效果,但它绕过了真正的bundle构建链路,不能用于验证打包结果。Use Existing Build (requires built groups):按上一次构建出的bundle和catalog来加载。你在编辑器里模拟"真机加载"体验时选这个。Simulate Groups (advanced):用Addressables内置的模拟系统分析依赖,主要用于调试资源重复、依赖加载顺序等问题,不太常用。
实际开发中我的习惯是:日常写逻辑用Use Asset Database,要排查"打包后表现不对"就切到Use Existing Build复现。很多人测试正常但真机出错,就是因为在Use Asset Database模式下自嗨了半天,压根没验证过真实bundle链路。
3.3 Build Player Content与Clean Build的区别
Build Player Content:一键完成"构建Addressables内容 + 构建Unity Player"。它会先走一遍构建脚本生成bundle和catalog,再调用BuildPipeline.BuildPlayer打安装包。如果你只是想在编辑器里验证资源加载,不需要每次都点这个,用New Build > Default Build Script就够了。New Build > Default Build Script:只构建Addressables内容,不打包Player。日常调试时用这个更快。Update a Previous Build:做内容更新(热更)时用的差量构建。它依赖上一次构建的ContentState.bin,只构建有变化的部分,适合已有正式包之后只发新资源的场景。Clean Build > All / Content Update / Bundles:清理构建缓存。Clean Build会把相关中间产物(bundle、cache等)清掉,下次构建强制全量重来。
这里建议所有遇到"构建结果莫名其妙"的问题,先执行一次Clean Build > All,再重新构建。很多Addressables的诡异表现都源于旧缓存残留,尤其在你改了Group、改了Profile路径、升级了Unity版本之后。
3.4 Catalog设置区逐项说明
在AddressableAssetSettings的Inspector里有一个Catalog折叠区,里面几个选项直接影响catalog生成行为:
| 设置项 | 作用 | 实操建议 |
|---|---|---|
| Player Version Override | 手动指定catalog版本号。留空时用构建时间等自动生成;填了之后catalog文件名会固定带上这个版本信息,便于远程更新对比 | 有多人协作或CI打包时建议设成明确版本号(如1.3.2),否则不同机器构建的hash差异会让你很难排查 |
| Compress Catalog | 压缩catalog的json内容,减小磁盘占用和首包体积,代价是运行时加载catalog需要解压,有一点点性能消耗 | 包体敏感的项目建议开;本机调试可以关,日志方便看 |
| Optimize Catalog Size | 用字符串表压缩重复字段,进一步减小catalog体积 | 建议开启,对运行时透明,能显著降低远程catalog下载量 |
| Build Remote Catalog | 把catalog同时生成到远程构建目录,支持远程更新catalog | 要做热更就开启,后面配合Remote Load Path使用 |
Player Version Override这个选项特别容易被忽略。默认catalog文件名里的hash,是由构建内容计算得出的,内容一变hash就变。如果你在CI里每天构建,产物hash每天都不同,甚至同一个仓库同一份代码,不同开发者电脑构建出来的hash也可能不一样,排查远程更新问题时会非常痛苦。手动指定版本号后,文件名会稳定很多,至少你一眼能看出哪个catalog属于哪个版本。
3.5 Profiles与路径配置
catalog生成在哪、运行时从哪里读,最终都由Profiles里的路径变量决定。打开Groups窗口顶部的Profile下拉菜单,点Manage Profiles能看到默认配置。关键变量就四个:
| 变量名 | 默认值 | 含义 |
|---|---|---|
| Local Build Path | {UnityEngine.AddressableAssets.Addressables.BuildPath}(实际指向Library/com.unity.addressableassets/aa/{平台}/) | 本地bundle和本地catalog的构建输出目录 |
| Local Load Path | {UnityEngine.AddressableAssets.Addressables.RuntimePath}(实际指向StreamingAssets/aa/{平台}/) | 运行时从本地读取bundle/catalog的目录 |
| Remote Build Path | 默认留空或自定义(如ServerData/aa/{平台}/) | 远程bundle和远程catalog的构建输出目录,构建完要手动上传服务器 |
| Remote Load Path | 默认留空,一般填URL(如https://yourcdn.example.com/aa/{平台}/) | 运行时从远程下载bundle/catalog的URL前缀 |
这里有几个踩过无数次的坑。
第一个:把Local Build Path改成Assets下某个目录,比如Assets/BuildBundles/。这样确实方便你在工程里直接翻bundle文件,但每次构建产物都会被Unity当作工程资源导入一遍,轻则让工程体积膨胀,重则触发资源导入循环,甚至把bundle文件误打进包体。默认的Library目录不受AssetDatabase管理,这就是它被设计成默认构建路径的原因。
第二个:把Local Load Path改成绝对路径或非StreamingAssets路径。编辑器下测着没问题,因为编辑器有完整文件系统权限,但真机上你根本写不到那个路径。移动端打包后唯一稳定可读的本地目录就是StreamingAssets,别瞎改。
第三个:Remote相关路径没做平台分区。多个平台共用同一个远程目录会导致catalog互相覆盖,格式务必带上{PlatformName}占位符。
注意:Profiles里的变量支持自定义,但别乱删默认变量,某些代码会引用
{UnityEngine.AddressableAssets.Addressables.BuildPath}这个内置变量,删了之后构建脚本直接报错。
4. 实操:亲手复现"两个catalog"的完整过程
4.1 准备一个最小复现工程
为了做实验,新建一个空Unity工程,然后:
- 通过
Window > Package Manager安装Addressables。 - 在场景里创建一个Cube,转成Prefab,保存到
Assets/Prefabs/Cube.prefab。 - 选中Cube.prefab,在Inspector顶部勾选
Addressable,给个Address名字Cube,让它进入默认分组。 - 打开
Window > Asset Management > Addressables > Groups,确认Default Local Group里能看到这个条目。
这个工程足够简单,构建速度快,产物路径清晰。
4.2 执行构建并观察产物
在Groups窗口点Build > New Build > Default Build Script,只构建内容,不打包Player。构建完成后,打开以下两个目录对比:
- 打开
Library/com.unity.addressableassets/aa/,里面会多出平台目录(如StandaloneWindows或StandaloneOSX),目录内有catalog_{hash}.json和对应的.hash文件,以及若干.bundle文件。 - 打开
Assets/StreamingAssets/aa/,如果之前没生成过,这里通常是空的——因为New Build只会把产物写到构建路径,不会同步到StreamingAssets。
接着执行Build > Build Player Content。这步会把构建产物同步一份到StreamingAssets/aa/{平台}/,你会看到catalog_{hash}.json和.hash出现在这里。
再回到Assets/AddressableAssetsData/Generated/目录,能看到ContentCatalogData.asset这个编辑器侧catalog文件。到这一步,"两个catalog"的现象就完整复现了:.asset一个,StreamingAssets下一个,本质上都是同一次构建生成的索引数据。
如果刚才执行New Build时选的是Use Existing Build播放模式,编辑器运行时加载的catalog来自Library或Assets侧;真机运行时加载的则是StreamingAssets侧。两边路径对不上,加载行为就会有差异。
4.3 用代码验证当前catalog是谁在加载
写个简单的调试脚本,挂到一个空GameObject上:
using UnityEngine; using UnityEngine.AddressableAssets; public class CatalogDebugger : MonoBehaviour { private void Start() { foreach (var locator in Addressables.ResourceLocators) { Debug.Log($"LocatorId: {locator.LocatorId}"); } Addressables.LoadAssetAsync<GameObject>("Cube").Completed += handle => { if (handle.Status == UnityEngine.ResourceManagement.AsyncOperations.AsyncOperationStatus.Succeeded) { Instantiate(handle.Result); Debug.Log("Addressable resource loaded successfully."); } else { Debug.LogError("Failed to load Addressable resource."); } }; } }Addressables.ResourceLocators里会列出当前初始化完成的catalog定位器。Play Mode切到Use Existing Build时,LocatorId通常会指向编辑器catalog对应的路径;打出的Player运行时,则指向StreamingAssets/aa/{平台}/catalog_{hash}.json。从日志里就能看到运行时实际加载的是哪一份。
4.4 打开Remote Catalog之后会变成几份
在AddressableAssetSettings勾选Build Remote Catalog,并给Remote Build Path设一个目录,比如ServerData/aa/{PlatformName}/。再执行一次完整构建,你会发现:
- 远程构建目录下多出
catalog_{hash}.json和catalog_{hash}.hash,这是准备上传CDN的远程catalog。 - 本地构建路径和
StreamingAssets下的catalog仍然存在。
也就是说,只需一次构建,catalog就会出现在三个位置:编辑器侧.asset、本地运行时目录、远程发布目录。这就是为什么远程更新项目里,catalog相关的问题会更多——你得时刻分清"这份catalog是给谁用的"。
5. 常见问题与排查技巧实录
5.1 为什么catalog的hash每次构建都变
这个是正常现象,catalog的hash由构建内容决定。只要存在一处细微差异,例如某个Prefab的GUID变了、某个资源的导入配置变了、Profile路径变了,甚至Unity版本升级导致bundle构建参数变化,hash就会变。如果你需要让hash保持稳定,唯一可控的办法是设置Player Version Override,但要注意这是"版本标识"层面的稳定,不代表内容没变;远程更新恰恰依赖hash变化来判断"有新版catalog发布"。
5.2 为什么编辑器测试正常,打包到真机却加载失败
先确认当前Play Mode Script是Use Asset Database还是Use Existing Build。前者只走源资源路径,不加载任何bundle,所以它测不出"bundle或catalog缺失"类问题。排查这类问题第一件事:切到Use Existing Build,把编辑器当成"准真机"跑一遍。如果仍然失败,继续查Local Load Path是否指向了StreamingAssets,以及在Build Player Content之后StreamingAssets/aa/{平台}/下到底有没有生成文件。
5.3StreamingAssets下根本没有catalog文件夹
常见原因是只执行了New Build > Default Build Script,没有执行Build Player Content。前者只写入构建路径,后者才会把产物同步到StreamingAssets。另外,如果之前用Clean Build把缓存清了但没重新构建,StreamingAssets里也不会有东西。记住这个顺序:New Build生成产物 ->Build Player Content同步并打Player。
5.4 远程catalog一直没生成
先看Build Remote Catalog有没有勾选;再看Remote Build Path是否留空或指向了一个无效目录。有时候你以为设置了,但Profiles里改的是默认Profile,而当前使用的不是这个Profile。所有路径配置都要以当前选中的Profile为准。
5.5 能不能手动删除Assets/AddressableAssetsData/Generated下的catalog
能删,但删完编辑器内Use Existing Build模式下PlayMode读取catalog会失效,Groups窗口的一些展示也会异常。它属于构建产物,你大可在确定不影响需求的情况下删,但下次构建会重新生成。与其手动删,不如用Clean Build管好整个构建链路。
5.6 打出的Player包体里既有bundle又有catalog,但单独拷出这些文件放到别的机器不行
因为StreamingAssets/aa/{平台}/里的catalog文件名和内部hash是对应当前构建的,你手动拷贝catalog和bundle到另一台设备,如果平台不同或安装包内其他内容不一致,运行时还是加载不了。Addressables资源分发应当通过正规的内容更新流程,而不是手工搬运文件。
6. 过来人的几个实操建议
以我个人的项目经验来说,关于catalog和Build这套东西,有几个习惯值得坚持。
第一,CI或多人协作时,Player Version Override一定不要留空。手工指定版本号之后,catalog文件名可控,出问题能用文件名直接定位到构建版本。我见过因为不设版本号,同一个功能在A机器构建正常、B机器构建后远程加载失败,查了两天最后发现是hash不同导致新旧catalog混用的。
第二,每次要发布版本,至少执行一次Clean Build > All再Build Player Content。Addressables的增量构建省时间,但也会把历史遗留问题带进新产物。干净构建多花几分钟,能省下后面几小时的排查时间。
第三,不要折腾默认的Local Build Path。它就老老实实放Library里,别为了"看得见产物"挪到Assets下,这个坑我栽过,代价是整个工程导入了一堆bundle资源,Unity卡到怀疑人生。
第四,真要多平台加远程更新,强烈建议先读一遍BuildScriptPackedMode.cs源码,不需要全懂,只要看它怎么调GenerateCatalog、怎么复制到StreamingAssets、怎么决定BuildPath和LoadPath,你对"两个catalog"的困惑就会彻底消失。源码比任何文档都诚实。
Addressables这套系统本身不算复杂,但它的间接层很容易让新手在路径、目录、副本之间绕晕。搞清楚catalog的生成与消费机制,后面配远程更新、做资源分包、优化首包大小都会顺畅很多。