做Unity项目资源管理的人,迟早会撞上这么个需求:项目里已经用Addressable把资源拆成了几十个Group,但到了出包环节,某些特殊产物希望把其中一部分资源组直接从构建结果里拿掉——不是运行时再控制加载,也不是让玩家多下一坨远程资源,而是这个包里干脆就没有这些内容。
我去年在维护一个中大型项目的构建管线时,就遇到了这个场景。审核包要临时剔除活动资源,海外包不要某些国内特供内容,微信小游戏首包被体积卡死,必须砍掉好几个暂时不开放的玩法资源组。改配置、恢复配置、再人工检查,几轮下来团队被折磨得不行,最后我实现了一套基于自定义Addressable构建脚本的“Build时剔除资源组”方案,把整个流程做成了可配置、可校验、可跑CI的自动化步骤。
这篇文章会把完整思路、实现代码、以及我在真实项目里踩过的坑全部记录下来。适合正在做Unity资源管理、构建管线的开发者阅读,也适合那些刚接触Addressable、想知道构建流程究竟怎么被“插手”的读者。文章中会大量使用Unity Addressable的自定义构建扩展点,如果你还没用到这个层面,正好可以借此看明白它的构建链路是怎么回事。
1. 项目背景与需求拆解
1.1 什么场景需要“构建时剔除资源组”
先别急着写代码,我们得想清楚为什么会有这种需求。不是说资源组配好之后就一直不变了吗?实际上在业务足够复杂的项目里,Addressable的Group配置早就不是“一堆散资源的收纳盒”了,它承担着渠道差异化、版本差异化、内容合规等等职责。
典型的场景有这么几类:
第一个是渠道包差异。国内安卓渠道几十个,每个渠道都可能提出一些“特殊要求”:这个商店不让出现某类内容,那个商店要求把某个玩法的资源拆掉,或者说渠道包对包体大小有严格上限。如果每次出包前都人工去调整Addressable配置,光同步各渠道的差异就能把人累死,而且极容易漏。
第二个是审核包和线上包分离。很多游戏为了过审,会临时把某些敏感活动、图片、音频资源隐藏掉,等审核通过后再通过热更新放出来。这里的“隐藏”不是简单不显示UI,而是包内根本不能存在这些资源文件。这时候如果资源还在Bundle里,审核人员用工具一解包就能看到,属于典型的不合规风险。
第三个是首包体积优化。微信小游戏、抖音小游戏这些平台对首包资源的大小控制得很死,动辄几MB的Addressable远端Bundle打进去直接超限。很多项目会专门给首包精简版本配置一套“最小资源集合”,但如果每个玩法资源组都要手动挪来挪去,效率太低。
第四个是大版本内容分批开放。新玩法做完了,但这个版本还没准备放出来,资源可以先不发,等版本开了活动再通过远程Addressable更新下发。这时候“构建时剔除”就是天然的开关。
所以你会发现,这些事情本质上不是资源“怎么加载”的问题,而是资源“能不能被看见”的问题。剔除的目标就是:让某些资源组在本次构建的产物中彻底不存在,同时不破坏编辑器里的原始配置。
1.2 这背后真正要解决的问题是什么
如果只是要“删掉几个Group”,手动作业也能做。但需求背后的核心诉求其实是三个:
- 可重复。每次构建都能稳定复现同样的剔除结果,不能依赖某个同学手动勾选。
- 可配置。剔除哪些内容要能通过配置改,最好是策划、运营也能看明白的规则,而不是硬编码在代码里。
- 可校验。构建完成后能自动确认被剔除的资源确实没有进入Catalog和Bundle,而不是靠肉眼翻日志。
再往深一层说,这个需求本质上是把“Addressable资源配置”和“构建产物”解耦:编辑器里的Group配置是完整的,保留所有内容;但具体某个产物体积、某个平台、某个渠道打出来是什么样,由构建时的规则决定。想明白这一点,方案就清晰了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 方案设计与技术选型
2.1 先排除几个“听起来简单但坑很多”的方案
在做正式方案之前,我把团队里提过的几种土办法都试了一遍,这里先帮大家避个雷。
第一个土办法:构建前人工取消勾选Group里的资源,构建后再恢复。这个方案在项目初期资源少时还行,资源一多就完全失控。Addressable的Group里可能有上百个Entry,手滑漏掉一个就得返工,而且多人同时操作编辑器配置,很容易互相覆盖。
第二个土办法:构建前用脚本把不要的Group从settings.groups里移除,构建完再插回去。这个方案看起来自动化了,实际上风险极高。因为AddressableAssetSettings是持久化资产,脚本操作序列化数据如果遇到异常中断,配置很可能就脏了,整个团队都会被影响。而且多个平台并行构建时,同一份配置被反复改动,极易出问题。
第三个土办法:把所有不想进包的内容全部配置为远程组,运行时按需下载。这个方案看起来“正规”,但并没有解决问题。首包体积该超还是超,用户该下载的还是得下载,如果你的目标是“不让包里有这些内容”,远程包同样需要被Catalog记录,审核人员同样能看到资源地址和加载逻辑。
真正干净的思路,是让Addressable在“构建输入阶段”就把这些资源组过滤掉,而不是构建完了再删文件,也不是运行时再想办法。这就要去改Addressable的构建脚本了。
2.2 为什么选择自定义构建脚本
Unity Addressable系统有一个很关键的设计:你可以给AddressableAssetSettings配置一个“构建脚本”。默认用的是BuildScriptPackedMode,它负责把Group里的资源收集、分析依赖、打包Bundle、生成Catalog。这个构建脚本本身是可以被替换成自定义实现的。
如果我们写一个自定义构建脚本,在它内部执行真正的打包流程之前,先把那些命中剔除规则的Group从“构建输入”里拿掉,后续的Bundle生成和Catalog生成就根本看不到这些资源了。产物是干净的,编辑器配置是完整的,构建过程是自动化的。这是我最终选择这个方向的根本原因。
它还有一个额外的好处:可以无缝接入CI。命令行里跑-executeMethod调用我们的自定义构建入口,同一个逻辑既能本地用,也能在构建机上跑,不会因为“编辑器配置被人工改过”导致构建结果不可复现。
2.3 想改构建脚本,先搞清这几个关键角色
在写代码之前,我们把Addressable构建链路里几个核心对象弄清楚。你不用死记源码,但理解它们的定位后,后面看任何版本SDK都不会慌。
| 对象 | 角色 | 和“剔除资源组”的关系 |
|---|---|---|
| AddressableAssetSettings | 核心配置入口,持有所有Group列表、构建脚本列表、配置文件等 | 我们自定义的构建脚本要替换到它的BuildScript列表里 |
| AddressableAssetGroup | 一个资源组,包含一组AssetEntry | 剔除操作的最小单位就是它 |
| BuildScriptBase | 所有构建脚本的基类,定义了构建生命周期 | 我们要继承它(或继承BuildScriptPackedMode) |
| BuildLayout | 构建输出的中间描述,包含所有要打进Bundle的Group和Asset信息 | 它是过滤的关键战场:BuildLayout里没有的东西,最终产物里就不会有 |
| ContentCatalogData | 生成的Catalog数据,记录资源和Bundle的映射 | 用它可以反查确认剔除是否生效 |
我用一个生活化的类比来理解:Addressable构建就像流水线生产。Group是原材料货架,BuildScript是产线控制器,BuildLayout是生产计划表,Bundle是成品箱,Catalog是装箱清单。我们要做的,就是在“制定生产计划表”的时候,把某些货架的原料从计划里拿掉。货架不动,原料不动,但今天的成品箱里就是没有它们。
3. 核心实现:一个可用的自定义剔除构建脚本
3.1 先做一个规则配置,让策划也能看得懂
我不建议把剔除逻辑写成if(groupName == "xxx")这种硬编码,维护成本太高。正确做法是创建一个ScriptableObject,把剔除规则做成可配数据。这样构建机、本地、不同的渠道包可以各自挂不同的规则配置。
下面是我项目里用的规则类,放在Editor目录下,可以直接在Project窗口右键创建:
csharp复制using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;
using UnityEditor.AddressableAssets.Settings;
using UnityEngine;
[CreateAssetMenu(fileName = "ExcludeRules", menuName = "Addressables/ExcludeRules")]
public class ExcludeRules : ScriptableObject
{
[Tooltip("命中这些Group名字的资源组将被剔除,支持正则表达式")]
public List<string> groupNamePatterns = new List<string>();
[Tooltip("命中这些Asset路径的资源也会被剔除,支持正则表达式")]
public List<string> assetPathPatterns = new List<string>();
[Tooltip("如果为true,表示启用上面的剔除规则;false则相当于跳过")]
public bool enabled = true;
public bool ShouldExcludeGroup(AddressableAssetGroup group)
{
if (!enabled) return false;
if (group == null) return false;
foreach (var pattern in groupNamePatterns)
{
if (string.IsNullOrEmpty(pattern)) continue;
if (Regex.IsMatch(group.Name, pattern))
return true;
}
return false;
}
public bool ShouldExcludeAsset(string assetPath)
{
if (!enabled) return false;
foreach (var pattern in assetPathPatterns)
{
if (string.IsNullOrEmpty(pattern)) continue;
if (Regex.IsMatch(assetPath, pattern))
return true;
}
return false;
}
}
这个规则类很简单,但有个细节值得说:路径匹配也用正则,而不是简单包含。之前我们团队遇到过记错路径前缀的情况,用^Assets/开头的正则可以把范围控制得很精确。另外,Group名字匹配和Asset路径匹配是“或”的关系,只要命中一条就会被剔除。
配置好规则后,你在Project窗口里创建一份ExcludeRules资产,比如叫ExcludeRules_BuildAudit.asset,里面填上要剔除的Group名规则。构建脚本会引用这份配置。
3.2 自定义BuildScript的核心过滤逻辑
接下来是重头戏:写一个继承BuildScriptBase的自定义构建脚本。这里我要先说一个坏消息:不同版本的Unity Addressable包,这个类的方法签名和内部结构一直在微调。所以下面的代码我根据比较通用的版本给你一个“核心框架”,你自己接的时候要以实际引用的SDK为准。但思路是绝对不会变的:在生成BuildLayout之前,把所有命中规则的Group过滤掉。
先看框架:
csharp复制using System.Collections.Generic;
using System.Linq;
using UnityEditor;
using UnityEditor.AddressableAssets;
using UnityEditor.AddressableAssets.Build;
using UnityEditor.AddressableAssets.Build.DataBuilders;
using UnityEditor.AddressableAssets.Settings;
using UnityEngine;
[CreateAssetMenu(fileName = "ExcludeRuleBuildScript.asset", menuName = "Addressables/Build/ExcludeRuleBuildScript")]
public class ExcludeRuleBuildScript : BuildScriptBase
{
[SerializeField] private ExcludeRules excludeRules;
public override bool CanBuildData()
{
return excludeRules != null && base.CanBuildData();
}
// 这个方法是当前Addressables版本中构建的核心入口。
// 不同SDK版本名字可能叫DoBuild、Build、或BuildDataImplementation,
// 你只需要找到“从settings.groups生成BuildLayout”的那一步,在它前面做过滤。
protected override TResult DoBuild<TResult>(AddressableAssetsBuildContext context)
{
if (excludeRules == null)
{
Debug.LogError("[ExcludeRuleBuildScript] 没有配置ExcludeRules,请检查资产引用。");
return default;
}
PrepareForBuild();
try
{
return EvaluateBuild(context, base.DoBuild);
}
finally
{
RestoreAfterBuild();
}
}
private void PrepareForBuild()
{
// 这里可以通过临时状态记录需要保留的Group,但要注意:
// 尽量不要真的增删settings.groups,否则会影响编辑器资产序列化。
// 如果因为版本原因只能通过移除Group实现,务必在finally中恢复。
}
private TResult EvaluateBuild<TResult>(AddressableAssetsBuildContext context, System.Func<TResult> buildAction)
{
// 真正的过滤逻辑在内部执行
return buildAction();
}
private void RestoreAfterBuild()
{
// 恢复任何临时修改
}
}
我知道你看到这个代码可能会觉得空,因为核心过滤逻辑被藏在注释里了。这是因为不同Addressables版本中,“拿到所有Group”的方式不一样:有的在context.Settings.groups,有的在context.BuildLayout中通过BuildLayoutGeneration任务生成。为了让你不卡壳,我换一种更落地的方式:如果你们项目用的Addressables版本比较旧,可以从AddressableAssetSettingsDefaultObject.Settings拿到groups列表,直接用excludeRules.ShouldExcludeGroup过滤,然后把过滤后的组单独传给打包逻辑。
如果你用的是新版Addressables(1.19之后),构建流程里会有一个BuildLayoutGeneration步骤。可以自定义一个IDataBuilderStep,插入到BuildLayoutGeneration之前,先把AddressableAssetSettings.groups里命中的组临时标记,再让后续步骤跳过。但标记Group本身也不是标准做法,更稳妥的是在真正生成BuildLayout时直接对Group列表做排除。
我给一个更实际、更通用的备选方案吧,这个方案兼容性最好,也最容易理解:构建入口脚本临时修改Group列表,构建结束后恢复。虽然我在前面说过它坑多,但只要注意异常恢复和序列化时机,它对大多数项目反而是可落地最快的方案。
3.3 兜底方案:构建入口临时移除Group(附完整代码)
如果你不想折腾自定义BuildScript的版本兼容问题,可以先从下面这个方案跑通业务,再逐步迁移到3.2的自定义脚本方案。这个方案的核心是:在调用AddressableAssetSettings.BuildPlayerContent()之前,把命中的Group从settings里临时拿走,构建完成后在finally里恢复。
csharp复制using System.Collections.Generic;
using System.Linq;
using UnityEditor;
using UnityEditor.AddressableAssets;
using UnityEditor.AddressableAssets.Settings;
using UnityEngine;
public static class AddressableExcludeBuild
{
[MenuItem("Tools/Addressables/Build With Exclude Rules")]
public static void BuildWithExcludeRules()
{
var settings = AddressableAssetSettingsDefaultObject.Settings;
var rules = AssetDatabase.LoadAssetAtPath<ExcludeRules>(
"Assets/Configs/ExcludeRules_BuildAudit.asset"
);
if (settings == null || rules == null)
{
Debug.LogError("缺少AddressableSettings或ExcludeRules配置,构建中止。");
return;
}
// 记录被移除Group的原始索引,用于恢复
var removed = new List<(int index, AddressableAssetGroup group)>();
for (int i = settings.groups.Count - 1; i >= 0; i--)
{
var group = settings.groups[i];
if (rules.ShouldExcludeGroup(group))
{
removed.Add((i, group));
settings.groups.RemoveAt(i);
}
}
if (removed.Count == 0)
{
Debug.Log("[ExcludeRuleBuild] 没有命中任何剔除规则,直接开始常规构建。");
}
else
{
Debug.Log($"[ExcludeRuleBuild] 共剔除 {removed.Count} 个资源组:" +
string.Join(", ", removed.Select(r => r.group.Name)));
}
try
{
// 执行Addressable正式构建
AddressableAssetSettings.BuildPlayerContent();
}
finally
{
// 无论构建成功还是失败,都要把Group恢复回去
foreach (var (index, group) in removed)
{
int insertIndex = Mathf.Clamp(index, 0, settings.groups.Count);
settings.groups.Insert(insertIndex, group);
}
EditorUtility.SetDirty(settings);
AssetDatabase.SaveAssets();
Debug.Log("[ExcludeRuleBuild] 构建流程结束,资源组配置已恢复。");
}
}
}
这里有个很重要的点必须提醒:千万不要在BuildPlayerContent执行过程中取消操作。一旦中途退出,finally里的恢复逻辑依然会执行,但Addressable的临时文件可能没清理干净,下一构建容易出错。如果遇到这种情况,直接删掉Assets/AddressableAssetsData下的Generated目录,重新构建一次就好。
这个方案的优点是不依赖SDK内部版本细节,很稳定;缺点是构建期间动了合法配置的序列化数据。如果你团队有多人同时操作AddressableSettings,构建机又和本地共用同一个仓库,就要小心冲突。这也是我最终还是选择“自定义BuildScript”方向的原因,但作为第一步落地,这个方案完全能用。
3.4 把入口接进CI和命令行
本地开发可以点菜单栏,但真正解放生产力的是接入CI。在构建机上,我们会通过命令行执行类似这样的语句:
bash复制Unity -batchmode -quit -projectPath /path/to/project \
-executeMethod AddressableExcludeBuild.BuildWithExcludeRules \
-logFile /path/to/build.log
如果同一个工程要出多个渠道包、多个剔除规则,可以把规则路径做成命令行参数传进去。Unity命令行传参有一种常见做法:通过-CustomArg:ExcludeRulesPath=...这类格式传递,然后用Environment.GetCommandLineArgs()去解析。
csharp复制private static string GetCustomArg(string key)
{
var args = System.Environment.GetCommandLineArgs();
for (int i = 0; i < args.Length - 1; i++)
{
if (args[i].StartsWith("-CustomArg:" + key + "="))
return args[i].Substring((" -CustomArg:" + key + "=").Length);
}
return null;
}
然后在BuildWithExcludeRules里读取:
csharp复制var customPath = GetCustomArg("ExcludeRulesPath");
if (!string.IsNullOrEmpty(customPath))
{
rules = AssetDatabase.LoadAssetAtPath<ExcludeRules>(customPath);
}
这样就可以做到“一套构建代码,多份规则配置”。CI里不同渠道走不同的规则文件,构建机不用来回修改任何东西。
4. 实操过程与核心环节实现
4.1 构建前做一次依赖完整性检查
在你真的把某些Group剔除掉之前,必须先回答一个问题:这些被剔除的资源,有没有被保留的资源引用?
如果被剔除了某个模型的材质贴图,但另一个保留的Group里有一个Prefab引用了它,构建时Addressable会尝试分析这个依赖。结果有两种可能:要么构建报错,说找不到被依赖的资源;要么构建成功,但运行时加载Prefab时材质丢失,呈现粉红色,这个问题极难排查。
我强烈建议在正式构建前写一个检查脚本,使用AssetDatabase.GetDependencies递归扫描保留资源的依赖项,然后和被剔除的资源集合做交集,有交集就直接报错。
我项目里简化的检查逻辑如下:
csharp复制public static class AddressableExcludeValidator
{
public static List<string> Validate(AddressableAssetSettings settings, ExcludeRules rules)
{
var result = new List<string>();
// 收集被剔除的资源路径
var excludedPaths = new HashSet<string>();
foreach (var group in settings.groups)
{
if (rules.ShouldExcludeGroup(group))
{
foreach (var entry in group.entries)
{
excludedPaths.Add(entry.AssetPath);
}
}
}
// 扫描保留Group的所有资源及其依赖
foreach (var group in settings.groups)
{
if (rules.ShouldExcludeGroup(group)) continue;
foreach (var entry in group.entries)
{
var deps = AssetDatabase.GetDependencies(entry.AssetPath, true);
foreach (var dep in deps)
{
if (excludedPaths.Contains(dep))
{
result.Add($"资源 {entry.AssetPath} 依赖了被剔除的 {dep},请处理引用关系");
}
}
}
}
return result;
}
}
这个检查在项目早期的价值可能看不出来,一旦业务复杂起来,能帮你拦下大量“构建成功但运行爆红”的问题。我的经验是:把它集成到构建入口的第一步,有任何依赖冲突就中断构建,除非你明确知道自己在做什么。
4.2 构建后的产物校验:确认剔除真的生效
构建完成不等于事情结束。我见过太多次“以为剔除了,结果资源还在”的情况。原因可能是:CI传错了规则文件、构建机上的代码没更新、或者Addressable配置里默认构建脚本压根不是我们自定义的。
为了杜绝这种“我以为”的问题,我加了一个校验步骤:构建完成后读取生成的Catalog,反查是否存在被剔除的资源路径。
Addressable构建后会生成一个Catalog文件,常见位置在Library/com.unity.addressables/aa/Windows(不同平台不同)下的catalog.json。可以直接用ContentCatalogData反序列化,然后遍历内部的资源路径列表。
csharp复制using UnityEditor.AddressableAssets.Build.Layout;
using UnityEngine.AddressableAssets.ResourceLocators;
public static void VerifyBuildResult(ExcludeRules rules)
{
// 1. 获取构建产物目录
// 每个平台产物路径不一样,可以用 AddressableAssetSettings.GetGeneratedBuildResult() 或直接定位到平台目录
var buildResult = AddressableAssetSettingsDefaultObject.Settings.GetBuildResult();
if (buildResult == null)
{
Debug.LogError("[ExcludeValidation] 没有找到构建结果,无法校验。");
return;
}
// 2. 从构建结果中取出所有Asset路径,检查是否包含被剔除路径
// 这里的实现依赖具体SDK版本,下面只是思路示意
var allAssetPaths = GetAllAssetPathsFromBuildResult(buildResult);
var excludedPaths = GetExcludedPaths(rules);
var leaked = allAssetPaths.Where(p => excludedPaths.Contains(p)).ToList();
if (leaked.Count > 0)
{
Debug.LogError($"[ExcludeValidation] 发现 {leaked.Count} 条被剔除资源仍然进入了构建产物,请检查规则和构建脚本。");
foreach (var p in leaked.Take(20))
{
Debug.LogError(" 泄漏资源: " + p);
}
}
else
{
Debug.Log("[ExcludeValidation] 校验通过,所有剔除规则均已生效。");
}
}
说实话,GetAllAssetPathsFromBuildResult这个方法在不同版本SDK里差异很大。如果你不想深挖内部结构,有一个更简单但有效的粗暴校验法:构建完成后,到Bundle产物目录里打一个大包,用AssetDatabase.GetDependencies或者直接读Bundle的AssetName列表,检查是否有被剔除的资源路径。用Unity的AssetBundle.LoadFromFile加GetAllAssetNames()也能做到。
这个校验步骤是我个人强烈建议保留的。因为构建系统一旦复杂起来,“规则写错了”和“规则没生效”是两种完全不同的故障,校验逻辑能帮你第一时间分清。
4.3 平台差异和微信小游戏等特殊情况
不同平台的Addressable构建产物路径、打包策略、甚至代码宏定义都不一样,所以剔除规则最好也能按平台区分。
我的做法是在ExcludeRules里加一个维度:平台类型枚举。
csharp复制public enum BuildTargetFilter
{
All,
Standalone,
Android,
iOS,
WebGL,
WeChatMiniGame
}
public BuildTargetFilter targetFilter;
然后在ShouldExcludeGroup里判断:
csharp复制public bool ShouldExcludeGroupForCurrentPlatform(AddressableAssetGroup group)
{
if (!enabled) return false;
#if UNITY_WEBGL
if (targetFilter == BuildTargetFilter.WebGL ||
targetFilter == BuildTargetFilter.WeChatMiniGame ||
targetFilter == BuildTargetFilter.All)
#else
// 这里根据当前构建平台和targetFilter做匹配
#endif
{
return ShouldExcludeGroup(group);
}
return false;
}
这里只是想告诉你一个思路:剔除规则和平台绑定会更贴近实战。特别是微信小游戏这种特殊环境,它的首包大小卡得非常死,而且资源加载方式和原生Addressable不一样。如果你在微信小游戏平台上有单独的Addressable适配逻辑,记得让剔除规则先于适配逻辑执行,否则可能白干。
值得说一句的是,如果你的项目同时有Addressable和YooAsset的对比方案,不要被“工具对比”带偏思路。YooAsset和Addressable本质上都是资源管理框架,但它们的构建脚本扩展方式完全不同,剔除资源组的实现路径也不一样。我们这里讲的“在构建输入阶段过滤资源组”的思路,放到YooAsset里也是成立的,只需要你找到它的BuildPipeline入口改一下输入数据源。工具可以换,思路是一样的。
5. 常见问题与排查技巧实录
5.1 构建没过、或者规则没生效,先查这几种情况
我在这个功能的开发过程中,遇到过不少诡异问题,这里直接列一个速查表,省得你一个个试。这个表是根据我实际项目里的踩坑经历整理的,而不是抄文档。
| 现象 | 常见原因 | 排查方向 |
|---|---|---|
| 构建后Catalog里仍有被剔除资源 | 默认构建脚本没换成自定义脚本 | 打开AddressableAssetSettings,检查BuildScript列表里是否真的启用了自定义脚本 |
| 构建报错:找不到AddressableAssetSettings | 脚本在批处理模式下执行过早 | 在调用构建前,确保AssetDatabase已经刷新,可以先执行AssetDatabase.Refresh() |
| 剔除规则不生效 | 正则在YAML中反斜杠被转义 | 用Unity的Resources.Load或AssetDatabase.LoadAssetAtPath检查规则资产本身是否加载失败 |
| 构建成功但运行时找不到资源 | 被剔除资源被保留组依赖,Catalog里出现悬空引用 | 跑4.1的依赖完整性检查,修掉所有跨组引用 |
| 增量构建出现旧内容反灰 | Addressable增量构建的内容更新判断出错 | 在CI脚本中加上清理指令,删掉Library/com.unity.addressables缓存目录或执行CleanBuild |
| 多人同时编辑AddressableSettings产生冲突 | 临时移除Group时修改了共享资产 | 尽量走自定义BuildScript方案,避免直接改settings.groups |
5.2 被引用资源被误删导致运行期问题
这是我被坑得最惨的一类问题。有次我们想把一个“元宵活动”的资源组剔除,只保留了主界面Prefab,结果这个Prefab里有一个引用指向活动界面的贴图,贴图本身又在被剔除的Group里,依赖检查没拦住。构建一切正常,但真机玩家打开主界面后,那个小组件变成了粉红色方块。
这类问题最难受的地方在于:构建并不报错,因为Addressable并没有把“依赖到被剔除资源”当成一个硬性错误来处理。它只在运行时加载时才暴露出来。所以依赖检查绝对不能省,而且要放到剔除方案的第一道关口。
后来我干脆把检查逻辑升级了一下:不仅检查被剔除Group里的资源,还检查这些资源如果被保留资源引用,直接在构建入口用EditorUtility.DisplayDialog弹窗,让主程决定是强制继续还是直接终止。在CI里则直接中断构建,宁可构建失败也不能出运行时粉红Bug。
5.3 剔除脚本和Unity版本的兼容问题
前面说了,Addressables包在1.18、1.19、1.21、2.x这些版本之间,构建相关API变了不只一次。特别是BuildScriptBase这个类,不同版本里要重写的方法名可能完全不同。
我的建议是:锁定你项目里使用的Addressables版本,然后在本地写一个简单的测试构建,先用默认构建脚本跑通,再替换成自定义脚本,确保自定义脚本本身没有任何报错。接着在CI构建机上也固定用同一版本,不要跟着Unity自动升级。
另外一个容易忽略的点:如果你在项目里同时引入了YooAsset或者其他资源管理插件,它们可能会改写AddressableAssetSettings的构建入口,或者包里有自己的IBuildTask。这种环境下,自定义构建脚本的执行顺序很容易被打乱。我碰到过一次莫名其妙有个步骤先于我们的过滤逻辑跑完,导致某些Group的数据被缓存进了构建临时文件,后来发现是另一个插件在InitializeOnLoadMethod里提前触发了Addressable构建。排查方法很简单:构建日志里搜所有Addressables相关信息,看是谁先动了构建流程。
5.4 增量构建的旧产物残留
Addressable默认支持增量构建,这本来是好事,但在“剔除资源组”的场景下会引入一个新问题:你上一次构建的产物理目录里还有旧Bundle,如果本次构建因为增量判断认为某些资源没变化,它可能直接复用了旧产物,而这些旧产物里恰恰包含了你这次想要剔除的资源。
这个问题在本地可能不出现,因为本地构建前大家习惯手动清理。但在CI里,构建机上往往会保留上一次的Library缓存,一旦命中这种复用逻辑,剔除就名存实亡了。
解决思路有两个:
- 在CI脚本中,每次构建前强制清理
Library/com.unity.addressables目录,或者直接执行AddressableAssetSettings.CleanPlayerContent()。 - 构建脚本里在
PrepareForBuild阶段把所有构建临时目录删干净。
我用的是第二种,因为它在代码层面就能保证可复现,不依赖CI那边的清洗步骤。但代价是每次剔除构建都会丢失增量编译的加速效果,出包时间会长一些。对于“审核包”、“渠道包”这类低频特殊产物,完全值得。
6. 一点个人经验的总结
把这个功能完整落地之后,我最大的体会是:构建时的资源剔除,本质上是构建管线的一种“策略注入”,而工具只是手段。不要上来就想改Addressable源码,先梳理清楚你的规则、校验、恢复这三个流程,方案自然就浮出来了。
另外,规则配置一定要外置成ScriptableObject,让业务同学也能看懂。在我项目里,运营同学后来需要临时出个“去活动资源”的审核包,他们直接复制了一份ExcludeRules资产,改几个正则关键词,跑一次菜单栏构建就完成了,压根不需要找开发改代码。真正好的工具是让不懂技术的人也能安全操作,在这个目标下,自定义构建脚本这个方向选对了。
最后说一个细节小技巧:因为Addressable的构建结果会把catalog.json写进StreamingAssets,在CI校验时可以直接对最终打包出的StreamingAssets目录做一次文件扫描。如果某个Bundle的名字恰好能对应到某个被剔除Group的前缀,还可以再加一个文件名层面的快速校验,作为Catalog校验的兜底。这种多一层校验的土办法,在应急排查时救过我很多次。如果后续业务中你还想让剔除规则支持“按日期生效”或者“按AB测试分组生效”,在这个框架上继续加字段就行,核心过滤链路不用动。
