在 Unity 项目里,Addressables 最开始通常只是一个资源加载工具:把 prefab、图集、材质放进 group,运行时按 address 加载。
但项目变大以后,真正难的往往不是"怎么加载资源",而是:
- 为什么首包越来越大?
- 为什么一个活动模块会依赖另一个活动模块?
- 为什么公共资源越来越像垃圾桶?
- 为什么某个资源明明拆成远程包,打开首包功能时却仍然触发下载?
- 为什么上线前才发现一堆跨组依赖?
这些问题背后,其实都指向同一件事:资源依赖没有被治理成一套可验证的契约。
背景:资源分组不是文件夹
很多团队使用 Addressables 时,会自然地把 group 当成文件夹:
text
一个功能一个 group
一个目录一个 group
一个活动一个 group
这在早期很好用。但随着模块增多,这种方式会暴露一个问题:group 只表达"资源放在哪里",不表达"它允许被谁依赖"。
所以我们需要把 group 从"资源容器"提升成"资源契约"。
这个契约至少包含三个维度:
text
资源归属:Common / ModuleCommon / Module
交付策略:FirstPack / Silent
场景归属:All / Lobby / Battle / Loading / None
资源归属决定谁能依赖谁。
交付策略决定资源什么时候必须可用。
场景归属决定资源属于哪个运行上下文。
这三个维度必须分开看。
整体架构可以抽象成这样:
#mermaid-svg-2Vuuj4UTQjUFnIdU{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-2Vuuj4UTQjUFnIdU .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-2Vuuj4UTQjUFnIdU .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-2Vuuj4UTQjUFnIdU .error-icon{fill:#552222;}#mermaid-svg-2Vuuj4UTQjUFnIdU .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-2Vuuj4UTQjUFnIdU .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-2Vuuj4UTQjUFnIdU .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-2Vuuj4UTQjUFnIdU .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-2Vuuj4UTQjUFnIdU .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-2Vuuj4UTQjUFnIdU .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-2Vuuj4UTQjUFnIdU .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-2Vuuj4UTQjUFnIdU .marker{fill:#333333;stroke:#333333;}#mermaid-svg-2Vuuj4UTQjUFnIdU .marker.cross{stroke:#333333;}#mermaid-svg-2Vuuj4UTQjUFnIdU svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-2Vuuj4UTQjUFnIdU p{margin:0;}#mermaid-svg-2Vuuj4UTQjUFnIdU .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-2Vuuj4UTQjUFnIdU .cluster-label text{fill:#333;}#mermaid-svg-2Vuuj4UTQjUFnIdU .cluster-label span{color:#333;}#mermaid-svg-2Vuuj4UTQjUFnIdU .cluster-label span p{background-color:transparent;}#mermaid-svg-2Vuuj4UTQjUFnIdU .label text,#mermaid-svg-2Vuuj4UTQjUFnIdU span{fill:#333;color:#333;}#mermaid-svg-2Vuuj4UTQjUFnIdU .node rect,#mermaid-svg-2Vuuj4UTQjUFnIdU .node circle,#mermaid-svg-2Vuuj4UTQjUFnIdU .node ellipse,#mermaid-svg-2Vuuj4UTQjUFnIdU .node polygon,#mermaid-svg-2Vuuj4UTQjUFnIdU .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-2Vuuj4UTQjUFnIdU .rough-node .label text,#mermaid-svg-2Vuuj4UTQjUFnIdU .node .label text,#mermaid-svg-2Vuuj4UTQjUFnIdU .image-shape .label,#mermaid-svg-2Vuuj4UTQjUFnIdU .icon-shape .label{text-anchor:middle;}#mermaid-svg-2Vuuj4UTQjUFnIdU .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-2Vuuj4UTQjUFnIdU .rough-node .label,#mermaid-svg-2Vuuj4UTQjUFnIdU .node .label,#mermaid-svg-2Vuuj4UTQjUFnIdU .image-shape .label,#mermaid-svg-2Vuuj4UTQjUFnIdU .icon-shape .label{text-align:center;}#mermaid-svg-2Vuuj4UTQjUFnIdU .node.clickable{cursor:pointer;}#mermaid-svg-2Vuuj4UTQjUFnIdU .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-2Vuuj4UTQjUFnIdU .arrowheadPath{fill:#333333;}#mermaid-svg-2Vuuj4UTQjUFnIdU .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-2Vuuj4UTQjUFnIdU .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-2Vuuj4UTQjUFnIdU .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-2Vuuj4UTQjUFnIdU .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-2Vuuj4UTQjUFnIdU .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-2Vuuj4UTQjUFnIdU .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-2Vuuj4UTQjUFnIdU .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-2Vuuj4UTQjUFnIdU .cluster text{fill:#333;}#mermaid-svg-2Vuuj4UTQjUFnIdU .cluster span{color:#333;}#mermaid-svg-2Vuuj4UTQjUFnIdU div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-2Vuuj4UTQjUFnIdU .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-2Vuuj4UTQjUFnIdU rect.text{fill:none;stroke-width:0;}#mermaid-svg-2Vuuj4UTQjUFnIdU .icon-shape,#mermaid-svg-2Vuuj4UTQjUFnIdU .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-2Vuuj4UTQjUFnIdU .icon-shape p,#mermaid-svg-2Vuuj4UTQjUFnIdU .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-2Vuuj4UTQjUFnIdU .icon-shape .label rect,#mermaid-svg-2Vuuj4UTQjUFnIdU .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-2Vuuj4UTQjUFnIdU .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-2Vuuj4UTQjUFnIdU .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-2Vuuj4UTQjUFnIdU :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 依赖规则配置
规则评估器
Editor 资源引用信息
轻量依赖扫描
Addressables BuildLayout
完整依赖扫描
结构化问题列表
保存时 Guard
修复建议与执行
CI 构建前检测
定时巡检
复验
这张图里最重要的是:规则只有一套,入口可以有多个。
保存时、手动 Analyze、构建前检测、定时巡检,都应该解释同一条依赖规则。否则工具越多,口径越乱。
三维资源契约
第一维度:资源归属
我们把资源归属分成三层。
Common
Common 是全局公共资源。
它应该只放真正稳定、基础、跨领域复用的资源。比如基础 UI、基础字体、基础材质、全局 fallback 资源。
Common 最大的风险是膨胀。很多项目后期都会出现这种情况:只要有两个模块用到同一张图,就把它放进 Common;只要某个依赖报错,就把被依赖资源移进 Common。短期看问题消失了,长期看所有模块都开始共享一坨没人敢动的资源。
所以 Common 的准入应该非常严格。
ModuleCommon
ModuleCommon 是领域公共资源。
它不是全局公共,而是某一类模块内部共享的资源。比如一组相近玩法共享的图集、动画、材质、特效。
它的价值是防止 Common 过度膨胀。
如果几个模块确实共享资源,但共享范围只在某个业务域里,那它应该进入 ModuleCommon,而不是进入全局 Common。
Module
Module 是具体业务模块资源。
它只服务自己,原则上不应该被其他 Module 直接依赖。
最终依赖方向应该是:
text
Module -> ModuleCommon -> Common
允许:
text
Module -> 自己领域的 ModuleCommon
Module -> Common
ModuleCommon -> Common
不允许:
text
Common -> ModuleCommon
Common -> Module
ModuleCommon -> Module
ModuleA -> ModuleB
ModuleA -> 其他领域的 ModuleCommon
一句话总结:
Common 是全局契约,ModuleCommon 是领域契约,Module 是私有实现。
第二维度:首包与静默包
资源归属解决"谁能依赖谁",但它不解决"什么时候可用"。
这就需要第二个维度:交付策略。
常见策略可以简化成:
text
FirstPack:首包资源,启动或关键流程必须可用
Silent:静默资源,不进首包,进入游戏后后台下载
注意,Common / ModuleCommon / Module 和 FirstPack / Silent 不是同一件事。
一个资源可以是:
text
Common + FirstPack
Common + Silent
ModuleCommon + FirstPack
ModuleCommon + Silent
Module + FirstPack
Module + Silent
资源归属决定依赖方向。
交付策略决定可用时机。
最关键的规则是:
FirstPack 不能依赖 Silent。
这是构建期硬规则,不是运行时告警。
一旦完整检测发现 FirstPack 资源依赖 Silent 资源,就应该直接让构建失败,把问题报告交给打包设备处理。否则首包边界会失效。
例如:
text
FirstPack Module -> Silent ModuleCommon // 不允许
FirstPack ModuleCommon -> Silent Common // 不允许
FirstPack Common -> Silent Module // 不允许
如果没有这道构建拦截,这类问题通常会在运行时表现为:
- 首包功能打开时触发下载;
- 进入关键场景时等待远程资源;
- 静默资源被首包链路提前拉起;
- 本来可延后的模块变成启动链路依赖。
所以依赖治理不能只检查资源层级,还必须同时检查交付策略:
text
资源层级是否合法
交付策略是否反向依赖
None 不是无约束
在规则系统里,还会遇到一种状态:某个 group 还没有配置归属。
直觉上可能会把它当成"先不管"。但这很危险。因为新建 group、临时 group、漏配置 group 一旦绕过检查,就会把非法依赖悄悄带进项目。
更稳的做法是:None 表示未分级,但不表示无约束。
可以把它按普通 Module 处理:
csharp
public enum ResourceLevel
{
Common,
ModuleCommon,
Module,
None
}
public static ResourceLevel Normalize(ResourceLevel level)
{
return level == ResourceLevel.None
? ResourceLevel.Module
: level;
}
这样做的好处是保守。
如果一个未分级 group 被 Common 依赖,它会被当成 Common -> Module,直接暴露问题。
如果一个未分级 group 依赖另一个 Module,也会被当成同级模块依赖,继续提示风险。
规则缺失不能成为绕过治理的理由。
白名单不是后门
现实项目里总会有例外。
比如历史资源暂时无法迁移,或者某个底层适配资源在架构上确实允许被多个模块依赖。这时候完全不允许白名单,会让治理系统变得难以落地。
但白名单必须是"结构化例外",不能是"规则后门"。
一个可控的白名单至少应该包含:
text
源 group
目标 group
规则类型
原因
负责人
创建时间
过期时间或复查时间
它应该尽量描述一条精确边,而不是一句宽泛的"这个模块都放过"。
白名单还有一种常见合法场景:流程型依赖。
有些模块虽然不是公共模块,但产品流程上天然是先打开 A 界面,再从 A 进入 B 模块。此时 A 对 B 的入口资源、过渡资源或少量必要资源存在依赖,是可以被允许的。
但这类白名单必须非常克制。它表达的是"明确流程入口允许依赖",不是"A 模块可以随便依赖 B 模块"。否则流程型白名单很容易被滥用,最后又变成隐性的跨模块蛛网。
伪代码可以这样理解:
csharp
public RuleResult EvaluateDependency(GroupInfo source, GroupInfo target)
{
var ruleResult = strictRuleEvaluator.Evaluate(source, target);
if (ruleResult.IsValid)
{
return ruleResult;
}
var exception = whitelist.Find(source.Name, target.Name, ruleResult.RuleType);
if (exception != null && !exception.IsExpired)
{
return RuleResult.AllowedByWhitelist(exception.Reason);
}
return ruleResult;
}
白名单通过后,也不代表问题消失。
更好的做法是:构建不阻塞,但报告里仍然展示,并且定时巡检会提示即将过期或长期未处理的白名单。这样团队不会因为例外太多,慢慢失去对依赖边界的感知。
第三维度:场景归属
资源归属和交付策略仍然不够。
还需要第三个维度:场景归属。
它解决的不是"这个资源是不是公共资源",也不是"这个资源是不是首包资源",而是"这个资源属于哪个运行上下文"。
例如:
text
All
Lobby
Battle
Loading
None
规则可以这样理解:
All可以被所有场景依赖。- 具体场景只能依赖
All或同场景资源。 - 具体场景之间不能互相依赖。
None可以依赖一个具体场景,但不能同时桥接多个场景。- 一个
Nonegroup 如果同时依赖多个场景域,就说明它职责混合,需要拆分。
这类规则能发现另一种问题:某个"看似通用"的模块,实际上同时依赖多个场景,变成隐性桥接层。
场景维度必须单独存在,是因为跨场景依赖经常会带出传递依赖。
例如一个 Lobby 资源静态依赖了 Battle 的 ModuleCommon。表面上看,只依赖了一个领域公共资源;但这个 Battle ModuleCommon 可能继续依赖 Battle Common、Battle 图集、Battle 材质和 Battle 特效。最终结果是 Lobby 打开时,把 Battle 的一串资源链也带进来了。
text
Lobby Module
-> Battle ModuleCommon
-> Battle Common
-> Battle Atlas / Material / Effect
如果只看资源归属,这条链可能会被误判为"Module 依赖 ModuleCommon,好像合理"。但加上场景维度后,它会变成明确的跨场景依赖:Lobby -> Battle,应该被拦截。
不允许不同场景之间互相依赖,主要是为了避免四类问题。
第一是生命周期问题。
场景 A 的资源如果直接依赖场景 B 的资源,那么场景 A 的加载、卸载、缓存策略都会被场景 B 影响。长期看,场景边界会慢慢失效。
第二是首屏和切场景成本问题。
一个场景打开时,可能被迫拉起另一个场景的 bundle 链路。表面上只是引用了一张图或一个 prefab,实际可能把另一个场景的图集、材质、动画、特效一起带进来。
第三是热更和灰度问题。
不同场景往往有不同的发布节奏。如果它们互相依赖,一个场景的小改动可能让另一个场景的资源版本、下载时机和回滚策略都变复杂。
第四是归属问题。
一条跨场景依赖通常意味着资源归属没有说清楚。它到底是全局公共资源,还是场景 A 的资源,还是场景 B 的资源?如果答案不明确,就应该先把归属拆清楚,而不是放宽规则。
这时候不应该简单把它改成 All。
更好的方式通常是拆成:
text
公共核心
场景 A 适配资源
场景 B 适配资源
否则 All 会变成新的垃圾桶。
多主题资源要自动解耦
长期运营项目里,经常会有多主题资源。
比如同一个界面、同一个活动、同一个展示位,会根据配置切换不同主题。每个主题都有自己的图集、特效、材质或 prefab 片段。
多主题依赖和普通跨模块依赖不太一样。
普通依赖问题可能需要人判断:应该改引用、迁 Common、抽 ModuleCommon,还是拆模块。但多主题资源更适合做成确定性规则:一旦识别出主题变体被静态依赖,就应该由程序自动修复,而不是让开发者逐条选择处理策略。
原因是多主题的边界很明确:主题变体是可替换内容,不应该成为基础界面或其他主题的静态依赖。
常见策略有两种。
第一种是只允许一套基础资源被静态依赖。
基础界面可以依赖默认 fallback、基础图集和基础 prefab;其他主题都通过动态 key 加载。这样首包和基础 bundle 只承担"能正常显示"的最低能力。
第二种更严格:所有主题变体都不允许被静态依赖。
基础界面只保存主题 key、资源 key 或配置引用,运行时根据当前主题动态加载。默认主题也按同样规则加载,只是在没有资源或下载失败时回退到内置 fallback。
无论选择哪一种,单条依赖命中后都不再进入人工策略选择,而是进入固定修复流程。
#mermaid-svg-DbQlYqZhGWR2mE5h{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-DbQlYqZhGWR2mE5h .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-DbQlYqZhGWR2mE5h .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-DbQlYqZhGWR2mE5h .error-icon{fill:#552222;}#mermaid-svg-DbQlYqZhGWR2mE5h .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-DbQlYqZhGWR2mE5h .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-DbQlYqZhGWR2mE5h .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-DbQlYqZhGWR2mE5h .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-DbQlYqZhGWR2mE5h .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-DbQlYqZhGWR2mE5h .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-DbQlYqZhGWR2mE5h .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-DbQlYqZhGWR2mE5h .marker{fill:#333333;stroke:#333333;}#mermaid-svg-DbQlYqZhGWR2mE5h .marker.cross{stroke:#333333;}#mermaid-svg-DbQlYqZhGWR2mE5h svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-DbQlYqZhGWR2mE5h p{margin:0;}#mermaid-svg-DbQlYqZhGWR2mE5h .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-DbQlYqZhGWR2mE5h .cluster-label text{fill:#333;}#mermaid-svg-DbQlYqZhGWR2mE5h .cluster-label span{color:#333;}#mermaid-svg-DbQlYqZhGWR2mE5h .cluster-label span p{background-color:transparent;}#mermaid-svg-DbQlYqZhGWR2mE5h .label text,#mermaid-svg-DbQlYqZhGWR2mE5h span{fill:#333;color:#333;}#mermaid-svg-DbQlYqZhGWR2mE5h .node rect,#mermaid-svg-DbQlYqZhGWR2mE5h .node circle,#mermaid-svg-DbQlYqZhGWR2mE5h .node ellipse,#mermaid-svg-DbQlYqZhGWR2mE5h .node polygon,#mermaid-svg-DbQlYqZhGWR2mE5h .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-DbQlYqZhGWR2mE5h .rough-node .label text,#mermaid-svg-DbQlYqZhGWR2mE5h .node .label text,#mermaid-svg-DbQlYqZhGWR2mE5h .image-shape .label,#mermaid-svg-DbQlYqZhGWR2mE5h .icon-shape .label{text-anchor:middle;}#mermaid-svg-DbQlYqZhGWR2mE5h .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-DbQlYqZhGWR2mE5h .rough-node .label,#mermaid-svg-DbQlYqZhGWR2mE5h .node .label,#mermaid-svg-DbQlYqZhGWR2mE5h .image-shape .label,#mermaid-svg-DbQlYqZhGWR2mE5h .icon-shape .label{text-align:center;}#mermaid-svg-DbQlYqZhGWR2mE5h .node.clickable{cursor:pointer;}#mermaid-svg-DbQlYqZhGWR2mE5h .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-DbQlYqZhGWR2mE5h .arrowheadPath{fill:#333333;}#mermaid-svg-DbQlYqZhGWR2mE5h .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-DbQlYqZhGWR2mE5h .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-DbQlYqZhGWR2mE5h .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-DbQlYqZhGWR2mE5h .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-DbQlYqZhGWR2mE5h .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-DbQlYqZhGWR2mE5h .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-DbQlYqZhGWR2mE5h .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-DbQlYqZhGWR2mE5h .cluster text{fill:#333;}#mermaid-svg-DbQlYqZhGWR2mE5h .cluster span{color:#333;}#mermaid-svg-DbQlYqZhGWR2mE5h div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-DbQlYqZhGWR2mE5h .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-DbQlYqZhGWR2mE5h rect.text{fill:none;stroke-width:0;}#mermaid-svg-DbQlYqZhGWR2mE5h .icon-shape,#mermaid-svg-DbQlYqZhGWR2mE5h .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-DbQlYqZhGWR2mE5h .icon-shape p,#mermaid-svg-DbQlYqZhGWR2mE5h .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-DbQlYqZhGWR2mE5h .icon-shape .label rect,#mermaid-svg-DbQlYqZhGWR2mE5h .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-DbQlYqZhGWR2mE5h .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-DbQlYqZhGWR2mE5h .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-DbQlYqZhGWR2mE5h :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 不允许
不允许
基础界面资源
默认 fallback 资源
主题解析器
主题 A 资源
主题 B 资源
主题 C 资源
自动修复通常做三件事:
- 把基础资源上的主题变体静态引用替换成 fallback 或动态 key。
- 把主题之间的直接引用替换成同主题资源或动态 key。
- 把主题资源从基础依赖链里移出,确保构建后不再被基础 bundle 拉入。
对应到依赖检查里,多主题资源可以被视为一类特殊 Module:它们共享一个主题域,但每个主题仍然是相互隔离的叶子节点。主题变体被静态依赖时,不产出"候选方案",而是产出"自动修复任务"。
伪代码可以这样表达:
csharp
public ThemeFixPlan BuildThemeFixPlan(AssetInfo source, AssetInfo target)
{
if (source.IsBaseAsset && target.IsThemeVariant)
{
return ThemeFixPlan.ReplaceWithFallbackOrKey(source, target);
}
if (source.ThemeId != target.ThemeId && source.IsThemeAsset && target.IsThemeAsset)
{
return ThemeFixPlan.ReplaceWithSameThemeKey(source, target);
}
return ThemeFixPlan.None;
}
这里的重点不是"主题资源一定要远程下载",而是"主题资源不能污染基础依赖链"。下载策略可以变化,但静态依赖边界应该稳定。
至于主题资源进入首包几个、剩余主题如何静默下载、优先级怎么计算,仍然属于另一篇静默加载策略文章。
蛛网依赖要先显性化
所谓蛛网依赖,通常不是某一条边的问题,而是很多条"当时看起来合理"的边慢慢叠在一起。
典型表现是:
text
模块 A 引用模块 B 的图
模块 B 引用模块 C 的材质
模块 C 又引用公共图集里的某个变体
公共图集里混入了模块 A 的资源
最后得到的不是清晰的层级,而是一张互相拉扯的网。
这套治理方案解决蛛网依赖的方式,不是直接"自动整理所有资源",而是分三步。
第一步,把网显性化。
通过 BuildLayout 看到 bundle 之间的真实依赖,再结合资源引用扫描,把 bundle 边还原成资源边。这样团队能看到问题不是"某个 bundle 变大了",而是"哪一个资源把哪一个模块拉进来了"。
第二步,把边分类。
每条边都要被归到某个规则里:跨 Module、跨场景、首包依赖 Silent、主题互依、None 桥接多个场景、Common 反向依赖 Module,等等。
第三步,按类型修复。
错误引用就改引用,领域共享就进 ModuleCommon,全局共享才进 Common,视觉复用就用链接图或副本,模块职责混乱就拆模块。
所以它解决的不是单个报错,而是把一张不可解释的网,逐步治理成有方向、有层级、可追踪的依赖图。
依赖检测与可观测工具
依赖是怎么被看见的
依赖治理不是靠人记忆,也不是只看 group 名字。
我们实际借助了 Unity 和 Addressables 已经提供的信息,把"资源 A 依赖资源 B"还原成可以检查、可以解释、可以修复的数据。
也就是说,治理层不是重新发明一套资源系统,而是在官方资源查询能力和 Addressables 构建报告之上,补上业务规则、问题建模和修复流程。
第一层是保存时轻量检测,用于日常开发时快速反馈。
它通常看的是资源当前序列化字段、直接对象引用、图集引用、材质贴图引用这类能在 Editor 内快速拿到的信息。这个检测不需要完整构建,所以反馈很快,适合用在保存 guard 上。
csharp
public IReadOnlyList<DependencyIssue> ValidateDirectReferences(IEnumerable<string> changedAssets)
{
var issues = new List<DependencyIssue>();
foreach (var sourceAsset in changedAssets)
{
var sourceGroup = groupIndex.FindGroup(sourceAsset);
foreach (var reference in referenceScanner.FindDirectReferences(sourceAsset))
{
var targetGroup = groupIndex.FindGroup(reference.AssetPath);
var rule = ruleEvaluator.Evaluate(sourceGroup, targetGroup);
if (!rule.IsValid)
{
issues.Add(DependencyIssue.FromReference(
sourceAsset,
reference.AssetPath,
reference.PropertyPath,
rule.Reason));
}
}
}
return issues;
}
第二层是完整检测,用于构建前、CI 或完整基线。
这一层会依赖 Addressables 的构建结果,尤其是 BuildLayout。BuildLayout 的价值在于它记录了最终 bundle 的组成和依赖关系:哪个 bundle 里有哪些资源,哪个 bundle 依赖了哪个 bundle。
但是 BuildLayout 本身更偏向 bundle 视角。要回答"为什么 A 依赖 B",还需要再做一次映射:
- 通过 BuildLayout 得到
BundleA -> BundleB。 - 通过 bundle 到 group 的索引得到
GroupA -> GroupB。 - 通过 bundle 内容得到
BundleA里的候选源资源和BundleB里的候选目标资源。 - 结合资源依赖扫描结果,确认具体是哪一个源资源引用了哪一个目标资源。
- 把结果落成结构化的
DependencyIssue。
伪代码大概是这样:
csharp
public IReadOnlyList<DependencyIssue> AnalyzeBuildLayout(BuildLayout layout)
{
var issues = new List<DependencyIssue>();
foreach (var sourceBundle in layout.Bundles)
{
foreach (var targetBundle in sourceBundle.Dependencies)
{
var sourceGroup = groupIndex.FindGroupByBundle(sourceBundle.Name);
var targetGroup = groupIndex.FindGroupByBundle(targetBundle.Name);
var rule = ruleEvaluator.Evaluate(sourceGroup, targetGroup);
if (rule.IsValid)
{
continue;
}
foreach (var sourceAsset in sourceBundle.Assets)
foreach (var targetAsset in targetBundle.Assets)
{
if (!assetDependencyIndex.HasDependency(sourceAsset.Path, targetAsset.Path))
{
continue;
}
issues.Add(DependencyIssue.FromBuildLayout(
sourceAsset.Path,
targetAsset.Path,
sourceBundle.Name,
targetBundle.Name,
rule.Reason));
}
}
}
return issues;
}
这里的关键点是:BuildLayout 先告诉我们 bundle 级别的事实,资源依赖扫描再把事实细化到资源级别。
如果只看资源直接引用,会漏掉构建后才显性的 bundle 依赖。如果只看 bundle 依赖,又很难告诉开发者到底是哪张图、哪个 prefab、哪个材质引入了问题。
所以轻量检测负责"尽早发现",完整检测负责"最终裁决"。二者不是互相替代,而是分工协作。
图集里的图片怎么处理
图片依赖还有一个常见陷阱:被依赖的不是一张独立图片,而是图集里的某个 Sprite。
在业务代码或 prefab 上看,字段里挂的可能是一个 Sprite;但在构建结果里,真正进入 bundle 的可能是 SpriteAtlas、Texture 或者被图集打包后的资源集合。如果检测逻辑只看资源路径,很容易出现两种误判:
- 明明引用了图集里的 Sprite,却只识别到图集本体,丢失了具体是哪一张图。
- 多个图集里有同名 Sprite,只按名字匹配,最后把依赖归到错误资源上。
所以图集依赖需要先做归一化。
text
Sprite 引用
-> Sprite 子资源标识
-> 所属原图或图集
-> 图集所在 group
-> 具体 Sprite 名称和引用字段
一个实用做法是同时建立三张索引:
text
SpriteKey -> AtlasAsset
AtlasAsset -> SourceSprites
SourceAsset -> ReferencedSprites
其中 SpriteKey 不能只用名字,至少要包含资源路径或稳定子资源标识。这样才能区分不同图集里的同名 Sprite。
伪代码可以这样写:
csharp
public SpriteReference ResolveSpriteReference(ObjectReference reference)
{
if (!reference.IsSprite)
{
return SpriteReference.None;
}
var spriteKey = spriteIndex.BuildStableKey(reference);
var atlas = atlasIndex.FindAtlasBySprite(spriteKey);
return new SpriteReference
{
SpriteKey = spriteKey,
SpriteName = reference.DisplayName,
AtlasPath = atlas?.Path,
OwnerGroup = groupIndex.FindGroup(atlas?.Path ?? reference.AssetPath),
PropertyPath = reference.PropertyPath
};
}
检测时,如果一个模块引用了另一个模块图集里的 Sprite,问题不应该只显示成"依赖了某个图集"。更有价值的报告应该告诉开发者:
text
哪个 prefab / material / ui 配置引用了它
引用字段在哪里
具体 Sprite 是哪一个
它来自哪个图集
这个图集属于哪个 group
最终造成了哪条 bundle 依赖边
处理图集依赖时,也不能只有"搬到 Common"这一种答案。
常见处理方式有几种:
| 场景 | 处理方式 |
|---|---|
| 只是字段挂错 | 改成当前模块自己的 Sprite |
| 多个模块稳定共享 | 抽到 Common 或 ModuleCommon 图集 |
| 只有一两张小图被复用 | 创建链接图或副本,避免整张图集被拉入 |
| 图集混入多个业务域 | 拆图集,把公共部分和私有部分分开 |
| 主题图误入基础图集 | 移回主题图集,并给基础界面使用 fallback |
这里有一个很重要的取舍:图集是为了减少 draw call 和管理纹理,但依赖治理看的是模块边界。一个图集如果混入太多业务域,运行时可能更省一次 draw call,资源依赖上却会带来更大的首包和热更成本。
所以图集治理不能只看渲染效率,也要看依赖边界。
Editor 工具要能看入边和出边
依赖治理不能只停留在 CI 报告里。
如果开发者每次都要等构建或看长日志,修复效率会很低。因此我们还需要一组 Editor 工具,让资源依赖在编辑器里可以被直接查看。
最核心的两个视角是:
text
Depends On:我依赖了哪些资源
Used By:哪些资源依赖了我
前者用来回答"我为什么把别的模块拉进来了",后者用来回答"我移动或拆分这个资源,会影响谁"。
工具界面可以抽象成这样:
#mermaid-svg-X2I05Ng3zsc6NNwY{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-X2I05Ng3zsc6NNwY .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-X2I05Ng3zsc6NNwY .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-X2I05Ng3zsc6NNwY .error-icon{fill:#552222;}#mermaid-svg-X2I05Ng3zsc6NNwY .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-X2I05Ng3zsc6NNwY .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-X2I05Ng3zsc6NNwY .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-X2I05Ng3zsc6NNwY .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-X2I05Ng3zsc6NNwY .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-X2I05Ng3zsc6NNwY .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-X2I05Ng3zsc6NNwY .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-X2I05Ng3zsc6NNwY .marker{fill:#333333;stroke:#333333;}#mermaid-svg-X2I05Ng3zsc6NNwY .marker.cross{stroke:#333333;}#mermaid-svg-X2I05Ng3zsc6NNwY svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-X2I05Ng3zsc6NNwY p{margin:0;}#mermaid-svg-X2I05Ng3zsc6NNwY .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-X2I05Ng3zsc6NNwY .cluster-label text{fill:#333;}#mermaid-svg-X2I05Ng3zsc6NNwY .cluster-label span{color:#333;}#mermaid-svg-X2I05Ng3zsc6NNwY .cluster-label span p{background-color:transparent;}#mermaid-svg-X2I05Ng3zsc6NNwY .label text,#mermaid-svg-X2I05Ng3zsc6NNwY span{fill:#333;color:#333;}#mermaid-svg-X2I05Ng3zsc6NNwY .node rect,#mermaid-svg-X2I05Ng3zsc6NNwY .node circle,#mermaid-svg-X2I05Ng3zsc6NNwY .node ellipse,#mermaid-svg-X2I05Ng3zsc6NNwY .node polygon,#mermaid-svg-X2I05Ng3zsc6NNwY .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-X2I05Ng3zsc6NNwY .rough-node .label text,#mermaid-svg-X2I05Ng3zsc6NNwY .node .label text,#mermaid-svg-X2I05Ng3zsc6NNwY .image-shape .label,#mermaid-svg-X2I05Ng3zsc6NNwY .icon-shape .label{text-anchor:middle;}#mermaid-svg-X2I05Ng3zsc6NNwY .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-X2I05Ng3zsc6NNwY .rough-node .label,#mermaid-svg-X2I05Ng3zsc6NNwY .node .label,#mermaid-svg-X2I05Ng3zsc6NNwY .image-shape .label,#mermaid-svg-X2I05Ng3zsc6NNwY .icon-shape .label{text-align:center;}#mermaid-svg-X2I05Ng3zsc6NNwY .node.clickable{cursor:pointer;}#mermaid-svg-X2I05Ng3zsc6NNwY .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-X2I05Ng3zsc6NNwY .arrowheadPath{fill:#333333;}#mermaid-svg-X2I05Ng3zsc6NNwY .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-X2I05Ng3zsc6NNwY .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-X2I05Ng3zsc6NNwY .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-X2I05Ng3zsc6NNwY .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-X2I05Ng3zsc6NNwY .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-X2I05Ng3zsc6NNwY .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-X2I05Ng3zsc6NNwY .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-X2I05Ng3zsc6NNwY .cluster text{fill:#333;}#mermaid-svg-X2I05Ng3zsc6NNwY .cluster span{color:#333;}#mermaid-svg-X2I05Ng3zsc6NNwY div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-X2I05Ng3zsc6NNwY .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-X2I05Ng3zsc6NNwY rect.text{fill:none;stroke-width:0;}#mermaid-svg-X2I05Ng3zsc6NNwY .icon-shape,#mermaid-svg-X2I05Ng3zsc6NNwY .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-X2I05Ng3zsc6NNwY .icon-shape p,#mermaid-svg-X2I05Ng3zsc6NNwY .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-X2I05Ng3zsc6NNwY .icon-shape .label rect,#mermaid-svg-X2I05Ng3zsc6NNwY .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-X2I05Ng3zsc6NNwY .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-X2I05Ng3zsc6NNwY .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-X2I05Ng3zsc6NNwY :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 当前选中资源或 group
Depends On 出边
Used By 入边
规则结果
影响范围
修复建议
迁移安全性判断
一个资源详情页里,通常应该展示:
text
资源路径
所属 group
交付策略
场景归属
直接引用
图集 Sprite 引用
BuildLayout bundle 边
违规规则
白名单命中情况
修复建议
这个工具的价值不只是排查问题,也能帮助做资源迁移。
比如准备把一张图从 Module 迁到 ModuleCommon 时,可以先看它的 Used By。如果只有同领域模块依赖它,迁移通常是合理的;如果多个无关场景都依赖它,就要重新判断它到底是不是全局 Common,或者是不是已经形成了蛛网依赖。
运行时也要看 bundle 存活
Editor 和 CI 能回答"理论上会依赖什么",但运行时还需要回答另一个问题:这些 bundle 真的被加载了吗,加载了多少,存活了多久,什么时候应该释放却没有释放。
这类问题只看 BuildLayout 不够。
例如一个界面理论上只依赖少量资源,但运行时打开后 bundle 数量突然增加;或者离开界面很久以后,某些模块 bundle 仍然存在。这通常意味着引用计数、缓存策略、异步加载流程或对象生命周期出现了问题。
所以资源治理工具最好补一个运行时视角:
text
当前已加载 bundle 数量
每个 bundle 的 group / 场景 / 交付策略
首次加载时间
最后访问时间
已存活时长
引用计数或持有者
加载来源
是否超过预期生命周期
工具界面可以按 group、场景或交付策略聚合:
#mermaid-svg-SWj7wF62lhlVqHGy{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-SWj7wF62lhlVqHGy .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-SWj7wF62lhlVqHGy .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-SWj7wF62lhlVqHGy .error-icon{fill:#552222;}#mermaid-svg-SWj7wF62lhlVqHGy .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-SWj7wF62lhlVqHGy .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-SWj7wF62lhlVqHGy .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-SWj7wF62lhlVqHGy .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-SWj7wF62lhlVqHGy .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-SWj7wF62lhlVqHGy .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-SWj7wF62lhlVqHGy .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-SWj7wF62lhlVqHGy .marker{fill:#333333;stroke:#333333;}#mermaid-svg-SWj7wF62lhlVqHGy .marker.cross{stroke:#333333;}#mermaid-svg-SWj7wF62lhlVqHGy svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-SWj7wF62lhlVqHGy p{margin:0;}#mermaid-svg-SWj7wF62lhlVqHGy .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-SWj7wF62lhlVqHGy .cluster-label text{fill:#333;}#mermaid-svg-SWj7wF62lhlVqHGy .cluster-label span{color:#333;}#mermaid-svg-SWj7wF62lhlVqHGy .cluster-label span p{background-color:transparent;}#mermaid-svg-SWj7wF62lhlVqHGy .label text,#mermaid-svg-SWj7wF62lhlVqHGy span{fill:#333;color:#333;}#mermaid-svg-SWj7wF62lhlVqHGy .node rect,#mermaid-svg-SWj7wF62lhlVqHGy .node circle,#mermaid-svg-SWj7wF62lhlVqHGy .node ellipse,#mermaid-svg-SWj7wF62lhlVqHGy .node polygon,#mermaid-svg-SWj7wF62lhlVqHGy .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-SWj7wF62lhlVqHGy .rough-node .label text,#mermaid-svg-SWj7wF62lhlVqHGy .node .label text,#mermaid-svg-SWj7wF62lhlVqHGy .image-shape .label,#mermaid-svg-SWj7wF62lhlVqHGy .icon-shape .label{text-anchor:middle;}#mermaid-svg-SWj7wF62lhlVqHGy .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-SWj7wF62lhlVqHGy .rough-node .label,#mermaid-svg-SWj7wF62lhlVqHGy .node .label,#mermaid-svg-SWj7wF62lhlVqHGy .image-shape .label,#mermaid-svg-SWj7wF62lhlVqHGy .icon-shape .label{text-align:center;}#mermaid-svg-SWj7wF62lhlVqHGy .node.clickable{cursor:pointer;}#mermaid-svg-SWj7wF62lhlVqHGy .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-SWj7wF62lhlVqHGy .arrowheadPath{fill:#333333;}#mermaid-svg-SWj7wF62lhlVqHGy .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-SWj7wF62lhlVqHGy .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-SWj7wF62lhlVqHGy .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-SWj7wF62lhlVqHGy .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-SWj7wF62lhlVqHGy .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-SWj7wF62lhlVqHGy .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-SWj7wF62lhlVqHGy .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-SWj7wF62lhlVqHGy .cluster text{fill:#333;}#mermaid-svg-SWj7wF62lhlVqHGy .cluster span{color:#333;}#mermaid-svg-SWj7wF62lhlVqHGy div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-SWj7wF62lhlVqHGy .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-SWj7wF62lhlVqHGy rect.text{fill:none;stroke-width:0;}#mermaid-svg-SWj7wF62lhlVqHGy .icon-shape,#mermaid-svg-SWj7wF62lhlVqHGy .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-SWj7wF62lhlVqHGy .icon-shape p,#mermaid-svg-SWj7wF62lhlVqHGy .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-SWj7wF62lhlVqHGy .icon-shape .label rect,#mermaid-svg-SWj7wF62lhlVqHGy .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-SWj7wF62lhlVqHGy .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-SWj7wF62lhlVqHGy .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-SWj7wF62lhlVqHGy :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 运行时加载事件
Bundle 存活追踪
当前存活列表
存活时间统计
持有者追踪
调试面板
伪代码可以这样表达:
csharp
public void OnBundleLoaded(BundleInfo bundle, string owner)
{
liveBundles.Track(bundle.Name, new LiveBundleState
{
Group = bundle.Group,
Scene = bundle.Scene,
Policy = bundle.Policy,
FirstLoadedAt = clock.Now,
LastTouchedAt = clock.Now,
Owner = owner
});
}
public IReadOnlyList<LiveBundleState> QueryLongLivedBundles(TimeSpan maxLifetime)
{
return liveBundles
.Where(x => clock.Now - x.FirstLoadedAt > maxLifetime)
.ToList();
}
这个运行时工具和依赖治理是互补关系。
静态分析告诉我们"谁可能依赖谁",运行时面板告诉我们"谁真的被加载、谁一直没释放"。当两边结合起来,很多问题会更容易定位:是依赖规则错了,是资源被错误拉起了,还是释放链路没有闭合。
Guard、修复与 CI 防线
保存时 Guard:把问题前移
依赖问题如果等到上线前才发现,修复成本会很高。因为这时资源、图集、prefab、材质、配置可能已经形成一串连锁关系。
所以保存时 guard 很重要。
它的目标不是替代完整 Analyze,而是在资源被保存时,尽早拦截新增违规。
不过保存 guard 也不能设计成绝对不可绕过。
制作过程中经常会出现这种情况:开发者或美术已经改了半天资源,保存时才发现依赖规则不通过。如果这时候完全不允许保存,反而可能造成工作丢失。所以工具应该允许强制保存,但强制保存必须留痕,并进入后续报告。
另外,保存时检测依赖的是当前 Editor 能看到的信息。有些资源在制作阶段还没有纳入 Addressables,或者还没有完成 group、label、bundle 的最终归属,这些问题保存时未必能完整识别。
因此 Save Guard 的定位更像是"前置提醒 + 新增问题拦截",而不是最终裁决。
典型流程是:
csharp
public SaveDecision OnWillSaveAssets(string[] paths)
{
var issues = dependencyValidator.ValidateOnSave(paths);
if (issues.Count == 0)
{
return SaveDecision.Allow;
}
var newIssues = historyFilter.FilterKnownIssues(issues);
if (newIssues.Count == 0)
{
return SaveDecision.Allow;
}
ui.ShowDependencyBlockedDialog(newIssues);
return SaveDecision.Block;
}
这里有一个很重要的设计:历史问题过滤。
项目里可能已经存在一些历史债务。如果 guard 一上线就把所有旧问题都拦住,开发会寸步难行。
更实际的方式是:
- 新问题继续拦截;
- 已记录的历史问题可以本地过滤;
- 强制保存必须留痕;
- 已修复的问题自动清理历史记录。
这样既不会放任新问题进入,也不会让旧债务阻断所有日常开发。
结构化问题模型
依赖问题不能只是一行字符串。
如果只输出:
text
GroupA cannot depend on GroupB
开发者仍然不知道:
- 哪个资源引用了它?
- 具体字段在哪里?
- 是直接引用还是图集间接依赖?
- 被依赖资源是否也被其他模块使用?
- 应该改引用、迁资源、创建领域公共组,还是创建图片链接?
所以依赖问题需要结构化。
一个比较实用的问题模型可以是:
csharp
public sealed class DependencyIssue
{
public string IssueId { get; init; }
public string SourceGroup { get; init; }
public string TargetGroup { get; init; }
public string SourceAsset { get; init; }
public string TargetAsset { get; init; }
public string ReferencePath { get; init; }
public string RuleType { get; init; }
public string Severity { get; init; }
public string SourceBundle { get; init; }
public string TargetBundle { get; init; }
public string SpriteName { get; init; }
public string AtlasAsset { get; init; }
public IReadOnlyList<string> ReferenceChain { get; init; }
public IReadOnlyList<string> UsedBy { get; init; }
public IReadOnlyList<FixCandidate> Candidates { get; init; }
}
有了结构化模型,后续才能做:
- 问题过滤;
- 修复建议;
- 批量预览;
- 自动复验;
- 修复日志;
- CI 报告;
- Editor 入边/出边查询;
- AI 辅助分析。
这也是治理系统从"脚本"变成"平台"的关键。
修复不是一键万能
资源依赖问题通常不能无脑自动修。
同样是 ModuleA -> ModuleB,可能有不同原因:
- 字段挂错了,应该直接改引用。
- 被依赖资源确实是公共资源,应该迁到 Common。
- 资源只在一个领域内共享,应该迁到 ModuleCommon。
- 只是图片视觉复用,应该创建链接图或副本。
- 依赖关系本来合理,只是规则需要白名单。
- 源模块职责混乱,需要拆分。
所以修复系统应该先生成候选方案,而不是直接执行。
但这条原则也有例外:如果问题类型本身有确定答案,就不应该交给人逐条选择。
多主题静态依赖就是这种例外。基础资源依赖主题变体、主题 A 依赖主题 B,这类问题的方向是固定的:去掉静态引用,改成 fallback 或动态 key,并把主题资源移出基础依赖链。
比较实用的做法,是把修复方案拆成几类,每一类都有明确适用场景。
| 方案 | 适用情况 | 风险 |
|---|---|---|
| 改引用 | 字段挂错、图标配错、prefab 错连到了其他模块资源 | 低,但必须确认替代资源唯一且语义正确 |
| 迁到 Common | 资源确实是全局公共资源,多个无关模块都稳定依赖 | 中,容易导致 Common 膨胀 |
| 迁到 ModuleCommon | 资源只在某个业务域内共享,不适合进入全局 Common | 中,需要先定义清楚业务域边界 |
| 创建链接图或副本 | 只是视觉复用,不希望运行时产生跨模块依赖 | 中,会增加体积,但依赖边界清晰 |
| 创建隔离组 | 构建系统产生重复依赖,资源语义暂时不属于 Common | 中,需要避免隔离组变成新的垃圾桶 |
| 拆分源模块 | 一个模块承载了多个职责,导致天然跨域依赖 | 高,影响目录、配置、加载入口 |
| 调整规则或白名单 | 架构上确认允许的特殊依赖 | 高,必须可解释、可审计 |
| 保留并记录 | 历史债务、低风险误报、暂时无法迁移 | 低到中,但必须有过期或复查机制 |
这也是为什么不应该把所有东西都搬进 Common。
Common 只能解决"共享归属"的问题,不能解决"资源职责不清"的问题。把错误依赖全部搬到 Common,会让依赖图表面变干净,但首包体积、加载浪费、变更影响面都会变差。
csharp
public IReadOnlyList<FixCandidate> BuildFixCandidates(DependencyIssue issue)
{
var candidates = new List<FixCandidate>();
candidates.AddRange(referenceFixer.TryBuildCandidates(issue));
candidates.AddRange(commonMover.TryBuildCandidates(issue));
candidates.AddRange(domainCommonCreator.TryBuildCandidates(issue));
candidates.AddRange(imageLinkCreator.TryBuildCandidates(issue));
candidates.AddRange(ruleExceptionCreator.TryBuildCandidates(issue));
return candidates
.OrderByDescending(x => x.Confidence)
.ThenBy(x => x.Risk)
.ToList();
}
真正能自动执行的,只应该是高置信度、低风险、可复验的问题。
比如:
- 有唯一合法替代资源的引用替换;
- 用户明确指定目标 common 的资源迁移;
- 官方模式的重复依赖隔离;
- 不涉及 prefab 结构重组、不改规则、不改白名单。
其他问题应该进入交互式 Resolution 窗口,由人确认后执行。
修复动作要事务化
一次修复至少应该包含七步:
text
Plan -> Validate -> Preview -> Execute -> Refresh -> Reanalyze -> CommitResult
对应到伪代码:
csharp
public FixResult ExecuteFix(FixPlan plan)
{
var validation = validator.Validate(plan);
if (!validation.IsValid)
{
return FixResult.Failed(validation.Message);
}
var preview = previewBuilder.Build(plan);
if (!user.Confirm(preview))
{
return FixResult.Cancelled();
}
executor.Execute(plan);
assetDatabase.Refresh();
var afterIssues = analyzer.Reanalyze(plan.AffectedGroups);
var result = resultComparer.Compare(plan.ExpectedResolvedIssues, afterIssues);
fixLogger.Write(plan, result);
return result;
}
这里最重要的是复验。
如果修复后旧问题没消失,或者新增了更严重的问题,这次修复就不应该被视为成功。
Common 膨胀也要量化
公共资源治理不能只靠感觉。
尤其是特效、材质、动画这类资源,很容易因为"多个模块都用了"而不断进入 Common。最后一个 Common bundle 巨大无比,每个场景都被迫加载大量自己并不使用的内容。
更好的方式是给 Common 准入和拆分建立指标:
text
依赖模块数量
资源体积
共同加载概率
浪费率
变更频率
可以用类似这样的规则做建议:
csharp
public bool ShouldMoveToCommon(ResourceUsage usage)
{
return usage.DependentModuleCount >= 2
&& usage.EstimatedSavedBytes >= 512 * 1024
&& usage.CoLoadRate >= 0.7f
&& !usage.IsHighChurn;
}
public bool ShouldSplitCommon(CommonBundleStats stats)
{
return stats.CompressedSize > 20 * 1024 * 1024
|| stats.WasteRate > 0.5f
|| stats.HighChurnRatio > 0.3f;
}
这些阈值不一定一开始就非常准确,但它们能让团队从"凭经验争论"变成"拿数据讨论"。
Common 的目标不是越少越好,也不是越多越好,而是让共享范围、加载概率和变更频率匹配。
首包边界只讲依赖
FirstPack / Silent 在这篇文章里只作为依赖边界讨论:首包功能不能依赖静默包资源。
如果首包功能确实需要一个 Silent 资源,那么只有两个选择:
- 把它提升为 FirstPack。
- 给首包功能提供 fallback,让它不依赖该资源也能运行。
至于哪些 group 进入 Silent、如何计算下载优先级、切换业务上下文时如何重新排序、下载失败怎么重试,这些属于静默加载策略,适合单独写成另一篇文章。
把两件事拆开很重要:依赖治理回答"能不能依赖",静默加载策略回答"什么时候下载、先下载谁"。
CI 上的两道防线
保存 guard 解决的是制作期问题,但它不能替代 CI。
原因很简单:完整依赖关系依赖 Addressables 构建结果。某些 bundle 依赖只有在构建以后才会显性出现,只靠保存时的直接引用扫描不够。
还有两个实际原因。
第一,保存 guard 允许强制保存。它应该保护制作过程不丢工作,但不能因为允许强制保存,就让违规依赖进入最终产物。
第二,有些资源在制作时还没纳入 Addressables,保存阶段不一定能看到完整归属。等到构建前,资源进入 group、label、bundle 后,完整依赖关系才会真正成形。
所以 CI 上需要两道防线。
第一道是构建前全量检测。
打包设备在正式构建前先跑一次完整 Analyze,生成或读取 BuildLayout,再根据统一规则检查依赖。如果检查不通过,就直接让构建失败,并把结构化报告丢给打包设备。这样不会把违规依赖带进产物。
csharp
public BuildDecision RunPreBuildDependencyGuard()
{
var layout = addressablesBuilder.BuildForAnalysis();
var issues = dependencyAnalyzer.Analyze(layout);
if (issues.HasBlockingIssues)
{
reportWriter.Write(issues);
return BuildDecision.Fail("Addressables dependency check failed.");
}
return BuildDecision.Continue;
}
第二道是定时巡检。
定时任务不等到有人打包才检查,而是隔一段时间跑一次完整或准完整检测。它的目标不是阻塞某一次构建,而是提前发现趋势:哪个模块开始依赖外部资源、哪个 Common 变大了、哪些历史问题重新出现了。
csharp
public void RunScheduledDependencyScan()
{
var report = dependencyAnalyzer.RunFullScan();
reportPublisher.Publish(report);
if (report.HasNewBlockingIssues)
{
alertSender.NotifyOwners(report);
}
}
这两道防线的职责不同:构建前检测负责兜底拦截,定时巡检负责提前预警。
设计收益
第一,问题前移。
过去依赖问题可能在构建或上线前才发现。引入保存 guard 后,新增违规能在资源保存时暴露,修复成本低很多。
第二,规则一致。
Analyze、Save Guard、Resolution 窗口、构建校验都使用同一套规则语义。None、白名单、场景规则不会在不同工具里解释不一致。
第三,例外可审计。
白名单不是简单放过,而是带原因、负责人、时间和规则类型的结构化例外。它可以降低治理落地阻力,但不会让规则失去边界。
流程型依赖也可以被表达,但只能描述明确入口链路,不能扩大成模块级互相依赖。
第四,历史债务可控。
历史问题不会无限阻塞本地开发,但也不会被静默吞掉。强制保存有记录,记录可以过滤,也可以在后续清理。
本地强制保存保护的是制作过程,CI 构建前检测保护的是最终产物。
第五,Common 不再失控。
Common / ModuleCommon / Module 让"公共资源"有了层级。再配合体积、共现率、变更频率等指标,可以避免 Common 变成所有资源的终点。
第六,场景边界更稳定。
场景之间不能互相依赖,可以避免一个场景的加载、卸载、热更和回滚被另一个场景绑定。
同时也能避免一个场景只引用了另一个场景的 ModuleCommon,却间接带出整条场景资源链。
第七,首包边界更稳定。
FirstPack / Silent 作为交付策略独立存在,可以明确检查首包是否依赖静默包,避免远程资源反向污染首包链路。
第八,多主题资源不污染基础链路。
基础界面只依赖 fallback 和主题 key,具体主题通过间接加载接入。命中主题静态依赖后可以程序自动修复,不需要开发者在每条依赖上手动选择策略。
第九,修复可复盘。
每次修复都有结构化日志:问题是什么、选择了什么方案、改了哪些资源、复验是否通过。后续无论做人审、CI 报告,还是 AI 辅助建议,都有稳定输入。
第十,排查路径更短。
Editor 工具可以直接查看一个资源依赖了谁、又被谁依赖。尤其是图集 Sprite、材质贴图、bundle 间接依赖这类问题,不再需要开发者从构建日志里手工反推。
第十一,运行时问题可观测。
运行时 bundle 监控可以看到当前加载数量、存活时长、持有者和来源。它能把"理论依赖图"和"真实运行状态"连起来,帮助定位资源被错误拉起或没有释放的问题。
第十二,自动化边界清楚。
AI 或自动修复工具可以给建议,但不能绕过预览、确认、执行器和复验。Unity Editor 仍然是资源修改的受控执行者。
总结
Addressables 解决的是资源定位、构建、加载问题,但它不会自动替我们维护业务模块边界。
当项目进入长期运营阶段,资源治理需要从"把资源放进 group"升级为"管理资源依赖契约"。
这套契约的核心是三个维度:
text
Common / ModuleCommon / Module:谁能依赖谁
FirstPack / Silent:什么时候必须可用
All / Lobby / Battle / Loading / None:属于哪个运行上下文
然后在这个基础上增加几类治理机制:
text
白名单:例外必须结构化、可审计
多主题:主题静态依赖必须自动解耦
然后围绕它建立完整闭环:
text
规则配置
依赖分析
入边/出边查询
运行时 bundle 监控
保存拦截
历史过滤
修复建议
变更预览
安全执行
重新分析
结构化日志
最终目标不是让所有依赖问题都自动消失,而是让每一条依赖都能被解释、被检测、被处理、被追踪。
资源治理做到这个程度后,Addressables 才不只是一个加载系统,而是项目模块边界的一部分。