Unity Addressables 资源依赖治理:规则约束、自动修复与全链路防护

在 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 / ModuleFirstPack / 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 可以依赖一个具体场景,但不能同时桥接多个场景。
  • 一个 None group 如果同时依赖多个场景域,就说明它职责混合,需要拆分。

这类规则能发现另一种问题:某个"看似通用"的模块,实际上同时依赖多个场景,变成隐性桥接层。

场景维度必须单独存在,是因为跨场景依赖经常会带出传递依赖。

例如一个 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 资源

自动修复通常做三件事:

  1. 把基础资源上的主题变体静态引用替换成 fallback 或动态 key。
  2. 把主题之间的直接引用替换成同主题资源或动态 key。
  3. 把主题资源从基础依赖链里移出,确保构建后不再被基础 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",还需要再做一次映射:

  1. 通过 BuildLayout 得到 BundleA -> BundleB
  2. 通过 bundle 到 group 的索引得到 GroupA -> GroupB
  3. 通过 bundle 内容得到 BundleA 里的候选源资源和 BundleB 里的候选目标资源。
  4. 结合资源依赖扫描结果,确认具体是哪一个源资源引用了哪一个目标资源。
  5. 把结果落成结构化的 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 或者被图集打包后的资源集合。如果检测逻辑只看资源路径,很容易出现两种误判:

  1. 明明引用了图集里的 Sprite,却只识别到图集本体,丢失了具体是哪一张图。
  2. 多个图集里有同名 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,可能有不同原因:

  1. 字段挂错了,应该直接改引用。
  2. 被依赖资源确实是公共资源,应该迁到 Common。
  3. 资源只在一个领域内共享,应该迁到 ModuleCommon。
  4. 只是图片视觉复用,应该创建链接图或副本。
  5. 依赖关系本来合理,只是规则需要白名单。
  6. 源模块职责混乱,需要拆分。

所以修复系统应该先生成候选方案,而不是直接执行。

但这条原则也有例外:如果问题类型本身有确定答案,就不应该交给人逐条选择。

多主题静态依赖就是这种例外。基础资源依赖主题变体、主题 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 资源,那么只有两个选择:

  1. 把它提升为 FirstPack。
  2. 给首包功能提供 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 才不只是一个加载系统,而是项目模块边界的一部分。

相关推荐
spider_xcxc1 小时前
Jenkins 遇见 Argo CD:构建可靠 GitOps 流水线的实战指南
java·开发语言·servlet
AI人工智能+电脑小能手1 小时前
【大白话说Java面试题 第214题】【10_网络协议篇】第5题:说一下 TCP 协议的三次握手和四次挥手
java·网络协议·tcp·三次握手·四次挥手
我叫唧唧波1 小时前
【Java】Java 基础系统学习笔记
java·笔记·学习
好好沉淀2 小时前
Spring @Validated和Validation注解 校验机制完全指南
java·数据库·后端
唐青枫2 小时前
Java RxJava 实战指南:从 Observable、Flowable 到线程切换和背压处理
java
凤山老林2 小时前
SpringBoot + Configuration2 实现配置的实时双向更新
java·spring boot·后端
2601_963869954 小时前
【计算机毕业设计】基于 Spring Boot+Vue的手工体验馆管理系统的设计与实现
java·spring boot·后端
曹牧4 小时前
Java:BeanListHandler
java·数据库·oracle
白鸽(二般)11 小时前
MinIO Java Client API
java·开发语言