我先讲一个特别具体的场景:你在维护一个 Unity 项目,Addressable 资源一共 30 个组,其中有一组叫 DevPanel,专门放开发期用的调试 UI、Profiler 场景;另一组叫 PreviewHD,放还没做完的高精度模型。平时打包问题不大,但发评测版那天你必须让它们不进首包。这时候你翻 Addressable 的设置面板,会发现它根本没有一个现成的开关叫“构建时排除这个组”。
这就是这篇博文要解决的事:在 Addressable 构建流程里,按资源组自定义剔除,让某些组在生成 Bundle 之前就被过滤掉。它和“运行时不加载”完全是两码事——运行时控制不会减小包体,构建期剔除了才是真没了。适合正在管一个以上 Addressable 资源组、需要出渠道包/体验版/开发版的 Unity 开发者参考。
1. 动手之前:Addressable 的组、标签和构建流程里“剔除”的位置
1.1 组到底管什么:从目录映射到 Bundle
Addressable 里的 Group(资源组)并不仅仅是一个文件夹分类,它决定了三件很关键的事:Bundle 怎么分、用什么压缩方式、走本地还是远程加载。你可以把组理解成“打包规划的容器”,一个组里的条目通常会聚合到一个或几个 AssetBundle 里,组名、所属 Schema、上边挂的标签,都会直接影响最终产物。
所以“剔除资源组”本质上是干预构建规划:在 Addressable 收集资产并生成 Bundle 之前,把某些组从构建列表里拿掉。只要你拿掉得够早,后面的依赖图、Catalog、BuildLayout 里都不会有这些资源。这就是构建期剔除和运行时屏蔽的差别所在——运行时你顶多是“不让它被加载”,但包体已经在那里了;构建期剔除是让这些资源压根不进入产物。
1.2 默认构建流程里没有“剔组”这个动作
Addressable 默认的 BuildScriptPackedMode 在做的事很简单:遍历 AddressableAssetSettings 里所有非空的组,把每个有可寻址地址的条目收进依赖图,再按组划分 Bundle,最后生成 Catalog。这整个流程里,开发者能控制的只有“哪些组是远程组、哪些组是本地组”“是否允许内容更新”之类的开关,没有一个参数叫“哪些组不参与本轮构建”。
有人会想到在打包前手动把不想要的组拖到不可见状态,或者在组上把 Include 勾掉。实测下来这两种都不彻底:隐藏组仍然会被构建脚本当作有效组处理;把勾选取消更不可靠,组里条目一多容易漏,而且每次打包都要人工检查。最离谱的是,有些项目是靠“先删组再打包,打完再恢复”这种方式做的,一旦构建中间报了异常,组结构恢复不回来,版本库直接脏掉。
所以小结是:Addressable 把“过滤组”这件事设计成了开放接口,允许你通过自定义构建脚本介入,但默认没有给你现成按钮。你需要自己写一个 BuildScript。
1.3 什么样的业务真的需要构建期剔除
我在真实项目里见过四种典型场景需要这个能力:
- 渠道定制包:同一个游戏要出多个渠道包,某些渠道不能带完整的语言包,或者不能带竞品联动资源。整组剔除比逐条清理直观得多。
- 开发版/评测版:只给测试人员用的调试面板、日志回放、Profiler 场景,绝不该出现在面向外部用户的包里。
- DLC 分阶段上线:资源已经打进主工程和主资源组,但运营计划分阶段开放,这时候可以在特定版本构建里临时剔除未上线内容。
- 大世界 / 数字孪生项目:地图、楼栋、设备模型是分组管理的,不同交付版本对资源范围要求不同,用构建期剔除做“瘦身版”特别方便。
另外说一句和热词相关的:如果你考虑过 YooAsset,它本身有“资源分包”和“构建时分组”的设计;Addressable 则更适合通过自定义 BuildScript 实现同样效果。两者在“构建期决定包体内容”这个目标上是一致的,只是 Addressable 需要你多写一点胶水代码。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心实现:用自定义 BuildScript 让资源组被“提前隔离”
2.1 方案对比:构建期剔除 vs 运行时控制 vs 拆工程
很多同学第一反应不是写自定义 BuildScript,而是想着“我跑一个后处理删 Bundle”,或者“我做一个启动参数,运行时判断要不要显示这组资源”。我做了个横向对比,你一看就清楚为什么我不推荐那些做法。
| 方案 | 控制粒度 | 对包体影响 | 维护成本 | 主要风险 |
|---|---|---|---|---|
| 运行时条件判断 | 条目级或 UI 级 | 几乎无 | 低 | 包体不减,浪费下载流量 |
| 打包后删除 Bundle | 文件级 | 有,但不完整 | 中 | 删掉后 Catalog 仍引用,加载即报错 |
| 拆成多个项目/工程 | 项目级 | 彻底 | 高 | 代码重复、维护地狱 |
| 构建期自定义剔除 | 组级、标签级 | 彻底 | 中 | 需要处理依赖链,但可控 |
打包后删 Bundle 是最容易踩坑的:Addressable 在生成阶段已经把 AssetBundle 写到输出目录了,Catalog 里也有对应记录,你后处理把文件删了,脱离 Catalog 的资源一加载就 Missing。除非你同时修改 Catalog,但那等于自己再造一套 Addressable 管线,得不偿失。
2.2 要覆盖的 API 和执行时机
以 Addressable 1.18+ 的 BuildScriptPackedMode 为例,它的基类 BuildScriptPackedBase 内部有这样一个可重写的方法:
csharp复制protected virtual bool PrepareGroup(AddressableAssetGroup group, List<AssetBundleBuild> assetBundleBuilds, ScriptableObject buildContext)
这个方法会在构建流程收集资源组时逐个调用。你返回 true,这个组继续往下走;返回 false,这个组就会从构建地图里被移除,不再参与 Bundle 生成和依赖图构建。这就是我们做剔除的核心切入点。
需要说明的是,版本差异在这里存在。老版本的 BuildScriptPackedBase 可能没有 PrepareGroup 这个虚方法,或者签名不一样。如果你用的 Addressable 版本里找不到它,可以参考方案 B:临时操作 AddressableAssetSettings.groups。我后面会说具体做法。
2.3 第一版剔除脚本:按标签黑名单过滤
先说一个原则:剔除条件不要写在组名上。组名是给人看的,重构一两次就变;标签才是稳定的业务语义。比如给 DevPanel 组打上 "DevOnly" 标签,给 PreviewHD 组打上 "NotRelease" 标签。然后脚本里按标签黑名单做过滤。
以下是完整的自定义构建脚本,放在 Editor 文件夹下:
csharp复制using System.Collections.Generic;
using System.Linq;
using UnityEditor;
using UnityEditor.AddressableAssets.Build.DataBuilders;
using UnityEditor.AddressableAssets.Settings;
[CreateAssetMenu(fileName = "CustomFilterBuildScript.asset", menuName = "Addressables/Content Builders/Custom Filter Build Script")]
public class CustomFilterBuildScript : BuildScriptPackedMode
{
protected override string BuildScriptName => "CustomFilterBuildScript";
// 构建流程收集组时逐组调用
protected override bool PrepareGroup(AddressableAssetGroup group, List<AssetBundleBuild> assetBundleBuilds, ScriptableObject buildContext)
{
// 先检查组级标签
if (!IsAllowed(group.labels))
{
LogSkippedGroup(group);
return false;
}
// 再检查条目级标签:有时标签打在条目上而不是组上
foreach (var entry in group.entries)
{
if (entry.labels.Any(label => IsExcluded(label)))
{
LogSkippedGroup(group);
return false;
}
}
return base.PrepareGroup(group, assetBundleBuilds, buildContext);
}
private bool IsAllowed(HashSet<string> labels)
{
if (labels == null || labels.Count == 0) return true;
return !labels.Any(IsExcluded);
}
private bool IsExcluded(string label)
{
string[] excluded = GetExcludedLabels();
return excluded.Contains(label);
}
private string[] GetExcludedLabels()
{
string raw = EditorPrefs.GetString("CustomAddressableBuild.ExcludeLabels", "");
if (string.IsNullOrEmpty(raw)) return new string[0];
return raw.Split(',').Select(s => s.Trim()).Where(s => !string.IsNullOrEmpty(s)).ToArray();
}
private void LogSkippedGroup(AddressableAssetGroup group)
{
UnityEngine.Debug.Log($"[BuildFilter] 剔除资源组: {group.Name}, 条目数: {group.entries.Count}");
}
}
这里最关键的细节是:我们直接调用 base.PrepareGroup 之前先返回 false,而不是构建完后处理。这么做的好处是干净的,整个组不进入构建地图,后续所有环节都不会看到这组资源。
光有构建脚本还不够,还需要一个入口方法:
csharp复制[MenuItem("Tools/Addressables/Build With Custom Filter")]
public static void BuildWithFilter()
{
var settings = UnityEditor.AddressableAssets.AddressableAssetSettingsDefaultObject.Settings;
var customScript = settings.GetDataBuilderByType<CustomFilterBuildScript>();
if (customScript == null)
{
customScript = ScriptableObject.CreateInstance<CustomFilterBuildScript>();
settings.AddDataBuilder(customScript);
}
settings.ActivePlayerDataBuilderIndex = settings.DataBuilders.IndexOf(customScript);
UnityEditor.AddressableAssets.AddressableAssetSettings.BuildPlayerContent();
}
菜单点击后就是一次完整打包,走的是自定义过滤逻辑。构建日志里你可以看到被剔除的组名。
3. 让剔除条件不写死:标签配置、CI 传参和可复用入口
3.1 为什么用标签而不是硬编码组名
硬编码组名的第一版脚本我写出来十分钟就后悔了。美术同事把 PreviewHD 改成了 PreviewHD_v2,我的打包脚本就凉了。标签则不同,标签是整套命名体系里更稳定的一层:它表达的是“业务属性”,而不是“资源名字”。
建议每位同学在项目初期就给组打好标签,像 "DevOnly"、"Preview"、"CJKOnly"、"DLC1" 这类语义化标签。标签可以一个组打多个,也可以打在组里的单个条目上。我的脚本两种都做了兼容:优先检查组标签,再遍历条目标签。
3.2 把剔除名单做成可视化配置
EditorPrefs 记字符串虽然能用,但让人记 “CustomAddressableBuild.ExcludeLabels” 这个 key 是在为难同事。我后来做了一个简单的 EditorWindow,把所有标签列出来打勾,勾上的就是本轮构建要剔除的。
实现思路不复杂:
csharp复制using System.Collections.Generic;
using System.Linq;
using UnityEditor;
using UnityEditor.AddressableAssets;
using UnityEditor.AddressableAssets.Settings;
using UnityEngine;
public class AddressableBuildFilterWindow : EditorWindow
{
private HashSet<string> selectedLabels = new HashSet<string>();
[MenuItem("Tools/Addressables/Build Filter Settings")]
public static void Open()
{
GetWindow<AddressableBuildFilterWindow>("构建剔除设置");
}
private void OnEnable()
{
string raw = EditorPrefs.GetString("CustomAddressableBuild.ExcludeLabels", "");
selectedLabels = new HashSet<string>(raw.Split(',').Where(s => !string.IsNullOrEmpty(s)));
}
private void OnGUI()
{
var settings = AddressableAssetSettingsDefaultObject.Settings;
if (settings == null) return;
List<string> allLabels = settings.GetLabels();
if (allLabels.Count == 0) return;
foreach (string label in allLabels)
{
bool isOn = selectedLabels.Contains(label);
bool newValue = EditorGUILayout.ToggleLeft(label, isOn);
if (newValue) selectedLabels.Add(label);
else selectedLabels.Remove(label);
}
if (GUILayout.Button("保存选择"))
{
EditorPrefs.SetString("CustomAddressableBuild.ExcludeLabels", string.Join(",", selectedLabels));
AssetDatabase.SaveAssets();
}
if (GUILayout.Button("开始构建(按当前选择剔除)"))
{
EditorPrefs.SetString("CustomAddressableBuild.ExcludeLabels", string.Join(",", selectedLabels));
AddressableBuildFilterWindow.BuildWithFilter();
}
}
public static void BuildWithFilter()
{
var s = AddressableAssetSettingsDefaultObject.Settings;
var customScript = s.GetDataBuilderByType<CustomFilterBuildScript>();
if (customScript == null)
{
customScript = ScriptableObject.CreateInstance<CustomFilterBuildScript>();
s.AddDataBuilder(customScript);
}
s.ActivePlayerDataBuilderIndex = s.DataBuilders.IndexOf(customScript);
AddressableAssetSettings.BuildPlayerContent();
}
}
这个窗口承担两件事:编辑剔除名单 + 一键构建。你甚至可以把它发给不懂代码的制作人同学,让他们自己勾选哪些标签不进包,我在项目里就是这么落地的。
3.3 CI 命令行怎么传剔除参数
打包机不可能有人去开编辑器窗口,所以命令行入口要支持参数传入。实际做法是用 Environment.GetCommandLineArgs 解析自定义参数,比如 -excludeLabels DevOnly,CJKOnly:
csharp复制public static void BuildFromCommandLine()
{
string[] args = System.Environment.GetCommandLineArgs();
int idx = System.Array.IndexOf(args, "-excludeLabels");
if (idx >= 0 && idx + 1 < args.Length)
{
string value = args[idx + 1];
EditorPrefs.SetString("CustomAddressableBuild.ExcludeLabels", value);
}
BuildWithFilter();
// 可选:构建结束后退出
EditorApplication.Exit(0);
}
命令行调用方执行:
bash复制Unity -batchmode -nographics -quit -projectPath /path/to/project -executeMethod AddressableBuildFilterWindow.BuildFromCommandLine -excludeLabels DevOnly,Preview
需要提醒的是,-quit 和 EditorApplication.Exit(0) 二选一即可,不要在 BatchMode 里同时用,容易让编辑器在构建还没写完就提前退出。我遇到过几次这个问题,最后统一用 -executeMethod 方法内部的 EditorApplication.Exit(0),命令行参数去掉 -quit,稳定很多。
4. 实操中的依赖大坑和分析排查实录
4.1 剔除组的资源被其他组引用怎么办
这是构建期剔除最容易翻车的地方。一个组的资源被剔除,不等于其他组不引用它。Addressable 文档里的说法是“被引用的资源会重新被收录”,但在分组被打断的情况下,行为会更复杂,有时构建直接报缺失,有时 Catalog 生成后引用悬空。
我在脚本里加了一轮构建前校验:遍历所有保留组条目,收集它们的依赖路径,再和被剔除组的资产路径取交集。有交集就打印警告,提示开发者去处理引用。
csharp复制private static void ValidateExcludedDependencies(AddressableAssetSettings settings)
{
string[] excluded = GetExcludedLabels();
if (excluded.Length == 0) return;
var excludedGroups = settings.groups
.Where(g => g != null && (g.labels.Any(l => excluded.Contains(l)) || g.entries.Any(e => e.labels.Any(l => excluded.Contains(l)))))
.ToList();
if (excludedGroups.Count == 0) return;
var excludedPaths = new HashSet<string>();
foreach (var g in excludedGroups)
{
foreach (var entry in g.entries)
{
string p = AssetDatabase.GetAssetPath(entry.MainAsset);
if (!string.IsNullOrEmpty(p)) excludedPaths.Add(p);
}
}
var includedGroups = settings.groups.Except(excludedGroups);
foreach (var g in includedGroups)
{
if (g == null) continue;
foreach (var entry in g.entries)
{
string assetPath = AssetDatabase.GetAssetPath(entry.MainAsset);
if (string.IsNullOrEmpty(assetPath)) continue;
string[] deps = AssetDatabase.GetDependencies(assetPath, true);
foreach (var dep in deps)
{
if (excludedPaths.Contains(dep))
{
Debug.LogWarning($"[BuildFilter] 保留组 {g.Name} 的条目 {assetPath} 引用了被剔除资源 {dep}");
}
}
}
}
}
这段代码建议挂到 BuildWithFilter 的开头,打包前先扫一遍。如果警告很多,说明你的资源边界没理清,需要先把被引用的资源转移到保留组,否则即使构建成功,运行时也会因为地址失效导致加载异常。
4.2 场景与脚本残留问题
Addressable 场景被剔除时,会有一个隐藏问题:如果主场景里有对象引用了被剔除场景里的组件、预制体或 ScriptableObject,Unity 在序列化时不会报错,但在运行时反序列化会丢引用。最典型的情况是“场景引用场景”,比如你有一个全局 UI 场景,里面引用了一个调试工具场景中定义的全局变量。
解决办法是场景层面的引用梳理。我一般会在剔除配置里单独给场景组加一条约定:任何被剔除组不得被保留组强引用,如果是弱引用(比如字符串、Address 名),也要在运行时逻辑里做防御式判断。你在校验脚本里可以对后辍为 .unity 的依赖路径单独打 Error 级别日志,因为场景强引用基本是必炸。
4.3 清理缓存与增量更新不一致
第一次接自定义剔除时,我犯过一个很低级的错误:本地改了剔除配置,打包机上没有清理旧的 BuildCache,结果产物比预期多出来一个几百 MB 的旧 Bundle。
Addressable 的缓存位于 Library/com.unity.addressable 下,确实会按内容哈希做增量缓存。但当你改了“哪组参与构建”之后,旧的构建地图、旧的 Bundle 文件并不会全部自动失效。稳妥的做法是在自定义构建脚本里增加一个强制清理选项:
csharp复制if (EditorPrefs.GetBool("CustomAddressableBuild.ForceClearCache", true))
{
UnityEditor.AddressableAssets.Build.BuildScriptPackedMode.ClearCachedData();
}
如果走远程组更新,剔除后会有更复杂的版本对齐问题:线上已有的 Catalog 里包含被剔除组,而你新包的 Catalog 里没有了,老客户端要做整包替换而不是增量更新。我在实际项目里的处理方式是:让运营平台根据构建版本号判断“本次是整包替换”,不要走资源热更,避免 Remote Catalog 和本地 Catalog 不一致导致黑屏。
4.4 常见问题速查表
| 现象 | 原因 | 处理方式 |
|---|---|---|
| 构建时 AssetBundle 仍包含待剔除组 | PrepareGroup 未被重写,或 BuildScript 没有设为 Active | 检查 ActivePlayerDataBuilderIndex 指向自定义脚本 |
| 被剔除组资源仍出现在输出目录 | 构建缓存未清理 | 构建前强制 ClearCachedData |
| Catalog 报 Address 缺失 | 保留组引用了被剔除组 | 运行依赖校验脚本,处理交叉引用 |
| 场景加载后 Missing | 场景间强引用被切断 | 将被引用场景移入保留组或拆分引用关系 |
| CI 构建结果和本地不一致 | 命令行参数未正确写入 EditorPrefs | 检查参数解析逻辑,确认剔除名单传入 |
5. 再往前走一步:从剔除到自动资源流转
自定义剔除做到后面,你会不满足于“谁先勾谁被剔除”,而是想要一种更自动的方式:比如构建脚本自动判断当前分支或构建目标,决定哪些组参与构建。这个可以做成一个简单的映射表:
csharp复制Dictionary<string, string[]> buildTargetLabelRules = new Dictionary<string, string[]>()
{
{ "dev", new[] { "DevOnly" } },
{ "preview", new[] { "DevOnly", "PreviewHD" } },
{ "release", new[] { "NotRelease" } },
};
构建脚本在入口处读取构建规则名(dev/preview/release),自动把对应标签写入剔除名单。这样打包机上只需要维护“这次要出什么类型”,不需要人肉决定资源范围。
再进一步,还可以把被剔除组“暂存”而不是彻底丢进垃圾桶。实际操作中有些团队希望保留被剔除组的内容用于内测工具,做法是在 PrepareGroup 返回 false 之前,把组内的 AssetReference 列表写到一个 JSON 文件里,存到构建输出目录旁边,供内部工具读取。这个需求不复杂,但能明显提升协作效率。
我这里多说一句经验:做这类构建期定制,最重要的是把“剔除决策”变成一个显式、可回看的过程。我见过有人在构建脚本里写死组名,结果某次改版后组名过期,没人发现,正式包白白多了几个 GB。所以你会看到我一直在强调“标签配置化 + 日志输出 + 依赖校验”。这三件套,能让你在 CI 日志里一眼看出:这一版构建到底剔了哪些组、为什么剔、有没有残留引用。打包这事,最怕的就是不可解释的产物;有了这三件套,任何产物差异都能追踪到源头。
