架构解析

资源加载系统 · 架构解析

覆盖:Assets\Revolution\Runtime\RevResourceSystem\(运行时)+ Assets\Revolution\Editor\RevResourceSystem\ABTool\(打包工具)

本篇只讲"为什么":为什么这么设计、为什么这么写代码、每个决策换来了什么、代价是什么、边界画在哪。
想学怎么用 → 看同目录的《使用说明》,那里是手把手,本篇不重复。
面向小白:用大白话讲"不这么做会怎样",不复述代码。

源设计起源 · 为什么要做这个模块

一句话 资源不可能一直躺在工程里被直接读:真机上它们被打进包、要按需加载和释放。资源加载系统把"放到哪、怎么分包、怎么加载、什么时候释放"收成一条通道,业务只写一句加载。

本篇只讲为什么;怎么用见同目录《使用说明》。

0三句话速览(完整用法见《使用说明》)

① 资源放到      「资源根目录」下(先在打包窗口里指定,如 Assets/GameRes;目录随便分,嵌套多少级都行)
② 给文件夹设 AB 名(Inspector 右下角)→ 菜单 Revolution.Tools/资源/RevAB 打包工具
                →「检查」页签确认没有"漏标"资源 →「打包」页签点「打包」
③ 加载          Sprite icon = RevResManager.Load<Sprite>(RevResPath.UI_Icon, "Hero_1001", RevResGroup.UI);

就这三步,剩下的都是这三步的细节。

1一行代码加载资源

1.1 同步:一行(编辑器直读 / 已经缓存过)

Sprite icon = RevResManager.Load<Sprite>(RevResPath.UI_Icon, "Hero_1001", RevResGroup.UI);
RevResHandle handle = RevResManager.LoadHandle<Sprite>(RevResPath.UI_Icon, "Hero_1001", RevResGroup.UI);
if (!handle.IsLoaded) Debug.LogError($"加载失败:{handle.ErrorReason}");

1.2 异步:真机首次加载必须用这个

为什么必须异步 真机上资源在 AB 包里,AssetBundle.LoadFromFileAsync 是异步操作,同步 Load 拿不到内容;编辑器里因为直读工程,同步才好使。 "编辑器能跑、真机拿不到"就是这类系统的经典错觉。

系统提供的异步接口用泛型就行,不需要写 typeof:

RevResManager.LoadAsync<Sprite>(RevResPath.UI_Icon, "Hero_1001", sprite =>
{
    if (sprite == null) return;          // 失败:回调收到的就是 null
    image.sprite = sprite;
}, RevResGroup.UI);
泛型填什么,框架内部就自动 as 成什么 —— 业务侧一行类型转换都不用写,也不用 typeof (框架内部只有一句 handle.Content as T)。
需要"失败原因 / 加载中"这类细节时,把返回值接住就行,不必换成别的重载:
RevResHandle h = RevResManager.LoadAsync<Sprite>(RevResPath.UI_Icon, "Hero_1001", s => image.sprite = s, RevResGroup.UI);
if (!h.IsLoaded) Debug.LogError($"失败:{h.ErrorReason}");

1.3 想要"一行"的异步写法?在业务侧包一层

系统自带自研 RevTask(Runtime\RevTask\),包一层就能 await:

/// <summary>业务侧封装:把回调式异步变成 await 式(放在你自己的 ResExt.cs 里)</summary>
public static RevTask<T> LoadAsync<T>(string rootPath, string resName, RevResGroup group = RevResGroup.Unknown)
    where T : UnityEngine.Object
{
    var source = RevTask<T>.CreateSource();
    RevResManager.LoadAsync<T>(rootPath, resName, obj =>
    {
        if (obj != null) { source.SetResult(obj); return; }
        // 失败原因从资源系统缓存里的句柄上取(查询同样是两参数)
        RevResLoadErrorReason reason = RevResManager.Get(rootPath, resName).ErrorReason;
        // 要打日志时把两段拼成完整路径
        source.SetException(new System.Exception(
            $"[Res] {RevResPathUtil.Join(rootPath, resName)} 加载失败:{reason}"));
    }, group);
    return source.Task;
}

// 用起来就是一行:
Sprite icon = await LoadAsync<Sprite>(RevResPath.UI_Icon, "Hero_1001", RevResGroup.UI);
同一份资源被多处并发请求时只会真正加载一次,回调自动合并(RevAsyncLoadPump 负责排队限流,默认最多 4 个并发)。

1.4 更推荐:生成的 RevResPath 目录常量 + 两参数 API

打包工具会扫描「资源根目录」下的所有文件夹(含多级嵌套),生成 Assets/Revolution/Generation/RevResPath.cs, 每个文件夹一条常量(值都带结尾 /):

// ❌ 手写整条逻辑路径:资源名那一段写错,要等运行时才知道"资源找不到"
RevResManager.Load<Sprite>("UI/Icon/Hero_1001", RevResGroup.UI);          // ← 旧写法,现在的 API 参数对不上

// ✅ 目录段用常量(编译期保护 + IDE 补全),资源名单独一个参数
RevResManager.Load<Sprite>(RevResPath.UI_Icon, "Hero_1001", RevResGroup.UI);

生成物长这样(节选):

public static class RevResPath
{
    public const string UI                      = "UI/";
    public const string UI_Icon                 = "UI/Icon/";
    public const string UI_UIPanel              = "UI/UIPanel/";
    public const string UI_UIPanel_LobbyPanel    = "UI/UIPanel/LobbyPanel/";
    // ...
}

为什么是"目录常量 + 资源名"两个参数,而不是一条完整路径字符串?

对比项两参数(现在)一条完整路径
新增资源不用重新生成(目录没变)必须重跑生成,否则常量还不存在
生成物大小只跟目录数有关跟资源数成正比,几千行
编译期保护目录这一段 ✔整条路径 ✔
写法RevResPath.UI_Icon, "Hero_1001""UI/Icon/Hero_1001"
性能缓存命中时完全不拼字符串(直接用两段算键)每次都要先拼出字符串再查

代价也很明确:资源名那一段仍然是普通字符串,写错不会编译报错,只会在运行时加载失败(建议从 Project 窗口复制名字)。 换来的是"加资源不用等生成",这个交换在迭代期更划算。

关于最后一行"不拼字符串":RevResPathUtil.ComputeKey(rootPath, resName) 与 ComputeKey(Join(rootPath, resName)) 是严格等价的(工程外断言专门验过,含空根目录、"Data/" 这类边界), 所以缓存命中的绝大多数请求一次字符串分配都没有;只有未命中时才拼一次完整路径(映射表 / 句柄 / 日志都需要它)。

1.5 常用 API 速查

API说明
Load<T>(rootPath, resName, group)同步,返回 T 或 null
LoadHandle<T>(rootPath, resName, group)同步,返回 RevResHandle(带失败原因,泛型版)
LoadAsync<T>(rootPath, resName, onFinished, group, priority)异步(推荐),回调直接拿到 T
LoadAsync(rootPath, resName, type, onFinished, group, priority)异步,回调拿到完整 RevResHandle(进阶 / 框架内部用)
Release(rootPath, resName)归还一次引用(归零后进入待释放队列)
UnloadGroup(group, force)按业务域批量卸载
FlushUnused()真正释放"引用已归零"的资源
UnloadAll()全部清空(回登录界面用)
Contains(rootPath, resName) / GetRefCount(rootPath, resName)查询缓存与引用数
CachedCount / UnusedCount统计(调试窗口用)
OpenScope()开一个资源域,using 结束自动全部归还

优先级(排队顺序):Background(0) 预加载 < Normal(100) 默认 < Urgent(300) 紧急资源。

2资源应该放到哪个路径下面

一句话规则 逻辑路径 = 资源相对「资源根目录」的路径,去掉扩展名。
资源根目录由你在打包窗口里指定(把 Assets 里的文件夹拖进那个槽,或点「选择…」)—— 代码里没有任何默认路径。
没配置之前,逻辑路径压根算不出来,工具会直接提示你去设置(不会偷偷用某个猜出来的目录)。
Assets/GameRes/                     ← 资源根目录(resRoot)
├── UI/
│   ├── Icon/
│   │   └── Hero_1001.png          →  逻辑路径 "UI/Icon/Hero_1001"
│   └── HeroPanel.prefab           →  逻辑路径 "UI/HeroPanel"
├── Hero/
│   └── 1001.prefab                →  逻辑路径 "Hero/1001"
├── Sound/
│   └── Skill_1001.ogg             →  逻辑路径 "Sound/Skill_1001"
└── Config/
    └── Data/
        └── Hero.txt               →  逻辑路径 "Config/Data/Hero"
RevResManager.Load<Sprite>(RevResPath.UI_Icon, "Hero_1001", RevResGroup.UI);
RevResManager.Load<GameObject>(RevResPath.Hero, "1001", RevResGroup.Battle);

多级嵌套随便用,而且"在哪一段切开"是等价的 —— 内部拼出来的完整路径完全一样:

RevResManager.Load<GameObject>(RevResPath.UI_UIPanel_LobbyPanel, "MailPanel", RevResGroup.UI);  // 常量用最深层
RevResManager.Load<GameObject>(RevResPath.UI_UIPanel, "LobbyPanel/MailPanel", RevResGroup.UI);  // 常量到中层
RevResManager.Load<GameObject>(RevResPath.UI, "UIPanel/LobbyPanel/MailPanel", RevResGroup.UI);  // 只用顶层常量
// 三者等价:逻辑路径都是 "UI/UIPanel/LobbyPanel/MailPanel"

所以常量名太长、或者嫌目录太深时,只把稳定的前缀做成常量、剩下的层级写进资源名就行,缓存键不受影响。

2.1 两种边角情况

资源位置逻辑路径说明
在 resRoot 下去掉「资源根目录」前缀,再去掉扩展名常规情况
在 Assets 下但不属于 resRoot去掉 Assets/ 前缀,再去掉扩展名第三方库资源也能被打包,如 Assets/ThirdParty/Foo/Bar.png → ThirdParty/Foo/Bar
在 Assets/Resources 下加载时写 "Res/xxx"走 Resources 兜底策略,见下

2.2 三条必须记住的纪律

  1. 逻辑路径不带扩展名。带扩展名会被当成另一条路径 —— 所以用生成的 RevResPath 目录常量 + 资源名的两参数写法最稳(RevResPath.UI_Icon, "Hero_1001")。
  2. 不要手写 Assets/... 开头的路径,那是把"资源怎么组织"泄漏进业务代码。
  3. 不要直接读 StreamingAssets / Application.dataPath。绕开资源系统 = 没有缓存、没有引用计数、平台分支自己写、还没法热更。
    数据表系统就是靠这条纪律统一走资源系统的。

2.3 关于 Resources 兜底

RevResourcesResPolicy 是策略链的最后一环,接两类请求:业务显式写 "Res/xxx" 前缀的、以及 AB 加载失败后允许兜底的。

Assets/Resources 下的东西会被 Unity 无条件打进包体、且无法按需卸载 —— 别把大资源丢进去。 它的定位是"兜底 + 极小必需资源",不是主通道。

3AB 包怎么划分

3.1 先说结论:按"一起用、一起卸"的粒度分包

分包的唯一目的是控制"下载/更新的粒度"和"加载/卸载的粒度"。所以判断标准只有两条:

3.2 怎么设 AB 名:给文件夹设(推荐)

Unity 的规则是"资源自己身上写着属于哪个包",标记存在 .meta 里。最省事的方式是给文件夹设 AB 名,子文件自动继承:

Assets/GameRes/UI/Icon/          ← 选中这个文件夹,Inspector 右下角 AssetBundle 填 "ui"
                                       下面所有图(含以后新增的)自动进 ui 包

Assets/GameRes/Hero/             ← 填 "hero"
Assets/GameRes/Sound/            ← 填 "sound"

好处:新加资源不用手动标记,也不会漏。ResMap 里会长成:

UI/Icon/Hero_1001|ui|Hero_1001
Hero/1001|hero|1001

3.3 两种分包模式

模式行为适用
ScanExisting(默认)只读取你设好的 AB 标记生成映射,绝不改动任何标记想精细控制分包(推荐)
AutoByFolder按资源根目录下的顶层目录名自动分包(UI/xxx → 包名 UI),会先清空所有旧标记再重打快速起步 / 资源目录已按业务域分好
AutoByFolder 会清掉你手设的标记,两者不要混用。

两个排除列表的作用范围(容易记混,这里说清):

配置手动分包(ScanExisting)AutoByFolder
excludeFolders(默认 Editor、Raw) 用于「漏标检测」:这些目录下的资源不会被报"漏标"(放源文件、参考图最合适) 这些目录不打标记(不进包)
excludeExtensions(默认 .cs、.meta) 这些后缀不进映射表 同左

3.4 六条分包实践

  1. 一个包别太大:改一张图要重下整个包,热更成本直接等于包体积。
  2. 一个包别太碎:单个资源的包会被校验器警告("单资源包"),且 IO 次数多。
  3. 共享资源单独一个包(如 common):被多个包依赖的图集、Shader、通用字体放进公共包。否则依赖分析会报"共享资源被复制多份,体积膨胀"。
  4. 不要形成循环依赖:校验器会直接报 循环依赖 错误,打包中止。
  5. 依赖会被自动递归加载:加载 hero 时,系统会先按 Manifest 把它依赖的包全部加载好,业务不用管。
  6. 命名只用 [A-Za-z0-9_-/.]:中文、空格会被校验拦下;文件名建议全小写(Linux/Android 区分大小写,Windows 不区分)。

4打包流程与注意事项

4.1 流程(一键)

菜单 Revolution.Tools/资源/RevAB 打包工具:

六个页签:打包 · 分包 · 依赖 · 体积 · 检查 · 快照(「检查」的标题上直接带问题数,不用点进去就知道现在能不能打包)。「打包」页签从上到下:

⭐ 第一次使用必须先设「资源根目录」:代码里没有任何默认路径。 没设置时「打包」按钮是灰的、顶部红字提示,「生成路径常量」会打警告并跳过 —— 都不会悄悄用猜出来的目录。
「目标平台」是本机偏好(不写进共享配置、不影响别人);CI 打包(ABCIBuild)永远用当前平台。 选了没装构建支持的平台,按钮会变灰并提示去 Unity Hub 装模块。

「分包」页签(也可以从菜单 Revolution.Tools/资源/RevAB 分包浏览 直接打开;对标 Unity 官方 AssetBundle Browser 的 Configure 页)

左边是包树、右上是资源表、右下是详情,两条分隔条都能拖;列宽、排序、选中、展开都会记住:

自动同步(默认开启):Project 里拖拽、Inspector 右下角的 AssetBundle 栏、脚本批量改标记 —— 这个窗口都会自动跟着更新,不必手动点刷新;顶部有「自动同步」开关和"已同步 12:03:45"状态(悬停可看上次同步原因)。 关掉开关后「刷新」按钮依然可用。
⚠️ 它只同步显示、不写任何文件:ResMap.txt / RevResPath.cs 仍然要显式点按钮生成。
操作怎么做
看现在分了哪些包打开页签即可(只读扫描,不会动你的标记);包名里的 / 显示成层级,每行右侧是资源数 · 体积,有问题的包带警告图标
把资源加入某个包从 Project 把资源(或文件夹)拖到左侧的包上;或拖到右侧资源表(加进当前选中的包)
拖资源新建包把资源拖到左侧空白处 / 文件夹节点上 = 以资源名新建一个包并放进去
新建空包工具栏「新建包」/ 右键「新建包」→ 直接原地起名(空包先挂在树里,拖进资源才算真正存在)
重命名选中后 F2 或双击;改包"文件夹" = 连同下面所有包一起改名
调整层级把包拖到另一个节点上 = 挪进它下面;拖到空白处 = 挪到最外层
删除选中后 Delete(可多选;删文件夹 = 删它下面所有包);只清标记,不会删资源文件
资源换包在资源表里选中几行,拖到左侧别的包上;或右键「移到 / 某个包」
移出资源资源表选中后 Delete / 右键「移出包」;继承自文件夹的会询问"要不要清那个文件夹的标记"
定位资源表双击,或右键「在 Project 里定位」;包右键「在 Project 里选中包内资源」
看为什么在这个包里资源表「标记来源」一列:自己标记 / 继承自哪个文件夹;右下详情还有它依赖的其他包、会被一起打进来的未分包资源
清理残留包名工具栏「清理未使用包名」(改名 / 删资源后留下的空包名)
为什么"新建包"要先挂在列表里? Unity 里包不是实体,没有"创建包"这种 API —— 分包就是"资源身上写着属于哪个包"。所以一个没有任何资源的包在 Unity 里根本不存在。
同理:「删除包」= 清空标记,「重命名」= 批量改标记(文件夹也一起改),否则会出现"删了包、资源却还在包里"这种怪现象。 改名 / 删除 / 换包后会自动清掉变成"没人用"的旧包名;包名一律转小写(Unity 本身就会强制小写,提前转界面才对得上)。
⚠️ 如果配置里的分包模式是「按目录自动分包」,手动改的标记会在下次打包时被覆盖 —— 页签顶部会给你警告。

另外四个页签:「依赖」「体积」「检查」「快照」

它们和「分包」共用同一份扫描结果(改完分包会自动失效重算;每个页签也都有「刷新」)。包名都能点:点了跳到「分包」页签并选中它。

页签看什么怎么用它
依赖 ① 包 → 它依赖的包(有循环依赖时红色告警,可按包名过滤)
② 被多个包引用、又没分包的资源
第 ② 项是体积膨胀的元凶:点「全部移进共享包…」输入包名,一步把它们单独打成共享包
体积 每个包自身体积 + 含依赖体积(条形对比,可按体积 / 名字排序)
+ 最大的 20 个资源
一眼看出"哪个包突然变大了";条形深色 = 自身,浅色 = 含依赖
检查 ① 没有 AB 标记的资源(漏标)
② 同一包内资源重名
③ 逻辑路径冲突(1001.prefab vs 1001.png)
④ 空包名
⑤ 内容被复制多份
漏标可「全部加入一个包…」或「在 Project 里全选」;空包「一键清理」;共享资源「全部移进共享包…」;其余每条都能「定位」。长列表先显示 20 条
快照 与「记录的基准」或「上次打包」逐包、逐资源对比:新增 / 删除的包、改名(内容一致、只有包名变了)、资源换包、每个包的资源增删与体积变化 想确认"我这一通改到底动了什么"就看它:先点「记录当前布局」存基准 → 改完再点「与记录的布局对比」;「与上次打包对比」不用手动记录(生成映射 / 打包时会自动留底)
「依赖」和「体积」里的依赖分析是编辑器侧估算(用 AssetDatabase.GetDependencies 反推每个资源引用了谁,再看它归哪个包)。 好处是改完分包立刻能看、不用先打一次包。资源多时这一步要几秒:页签先显示"正在分析",下一帧带可取消的进度条算完,不会卡死窗口。
而「打包」页签里打包结果的依赖提示读的是 Unity 真正算出来的 AssetBundleManifest —— 那份更权威。 两者互补:一个用来提前发现问题,一个用来事后复核。
「快照」报告怎么读:按"重要程度"排 —— ① 概览(包数 / 资源数 / 体积各涨跌多少)② 改名 ③ 新增 / 删除的包 ④ 资源换包 ⑤ 每个包的增删明细(体积动得大的排前面)。
改名单独列出来是有意的:不识别的话它会显示成"删一个包 + 加一个包",看着像资源被大搬家了(改名包里的资源也不会误报成"换包")。
快照存在 Library/Revolution/RevAB/(不进版本库、不污染工程):layout-baseline.json = 手动记录的基准,layout-last-build.json = 每次生成映射 / 打包自动留底的那份(更名前在 Library/Revolution/LiteAB/,第一次用到时自动搬过来)。
⚠️ 快照里的体积取的是源文件体积,不是打包后的 AB 体积 —— 它不用先打一次包就能拿到,适合边改边比。

改完分包记得回「打包」页签点一次「仅生成映射」:映射表(ResMap.txt)和 RevResPath 才会跟着更新,同时会自动留一份「上次打包」快照给「快照」页签做对比。

「打包」按钮实际做了六步:

收集 AB 标记 → 校验(有 error 就中止)→ BuildPipeline.BuildAssetBundles
→ 依赖分析 → 写 ResMap.txt + BuildManifest.json → 生成路径常量 →(可选)拷到 StreamingAssets

4.2 产物都在哪

AssetBundles/PC/                  ← 打包输出(OutputRoot/平台名)
├── PC                            ← ★ 主包(名字 = 输出目录名,Unity 的规则)
├── PC.manifest                   ← 依赖清单(运行时靠它查依赖)
├── ui / ui.manifest              ← 你分的包
├── hero / hero.manifest
└── BuildManifest.json            ← 给人/CI 看:版本、平台、时间、每个包大小

Assets/Revolution/Resources/ResourceSystem/ResMap.txt  ← 逻辑名|包名|资源名(框架自己的 Resources)
                                                          运行时 Resources.Load("ResourceSystem/ResMap") 读它
Assets/Revolution/Generation/RevResPath.cs     ← 自动生成的目录前缀常量(扫描资源根目录下的文件夹)
Assets/StreamingAssets/PC/                  ← 拷进来的运行时产物

运行时的查找路径是 StreamingAssets/<平台名>/<包名>,平台名 = 产物目录名 = 主包名:

平台目录名 = 主包名
Windows / macOS / LinuxPC
AndroidAndroid
iOSiOS
WebGL(含微信 / QQ 小游戏)WebGL

4.3 打包注意事项(按踩坑概率排序)

1. .txt 数据表:默认已支持,但别把它加回排除列表 excludeExtensions 默认只剩 .cs / .meta —— 数据表 Data/*.txt 会正常进 ResMap。
如果哪天你手动把 .txt 加了回去,数据表就会被静默踢出包,运行时只表现为 PathNotMapped(极难往"后缀"上想)。
好在校验器会直接告警:有 N 个资源被 excludeExtensions 排除,不会进包 —— 看到就去排除列表里删掉 .txt。
  1. 命名非法会直接中止打包:只允许 [A-Za-z0-9_-/.]。中文、空格、括号都不行。
  2. 逻辑路径冲突会中止:Hero/1001.prefab 和 Hero/1001.png 都会算成 Hero/1001,必须改名。
  3. 同一包内资源重名会中止:A/Icon.png 与 B/Icon.png 都在 ui 包里 → LoadAsset("Icon") 取到哪个不确定。改名或拆包。
  4. 漏标会中止:资源在资源根目录下、却没有任何 AB 标记 → 不进任何包,真机必然 FileNotExist。这是手动分包模式最常犯的错,工具会拦(详见 4.4)。
  5. 空包会中止:标了 AB 名但一个资源都没有(资源删了/改名了的残留),去把标记清掉。
  6. 换平台必须重新打包:AB 不跨平台。把 Android 的包放到 PC 的目录里,加载会 BundleLoadFail。
  7. 压缩方式:默认 LZ4(块压缩,加载快、体积中等,推荐);首包/下载包可换 LZMA(更小、首次加载更慢);None 只用于调试。
  8. 增量是自动的:forceRebuild 关闭时,Unity 按内容 Hash 跳过没变的包;只有怀疑产物脏了才勾它。
  9. 编辑器默认不走 AB:开发期 RevEditorResPolicy 直读工程(所有资源一律 AssetDatabase 读,不打包也能跑)。要验证 AB 链路,用菜单 Revolution.Tools/资源/AB 加载模式(编辑器) 切换,或直接出真机包验证。
    ⚠️ "编辑器能跑"不代表"真机能跑",AB 链路一定要在真机上验一次。
  10. copyToStreamingAssets 打开后是"整目录覆盖":目标目录会被先删再拷,别往里放手写的东西。
  11. 任何 Resources 文件夹里的东西都会被无条件打进包体:框架的 ResMap.txt 就放在它自己的 Assets/Revolution/Resources/ResourceSystem/ 里(很小)。别把大资源往任何 Resources 文件夹里放 —— 包括框架这个:它存在的唯一目的就是让 Resources.Load 能读到这张小表。

4.4 漏标检测:手动分包最该防的一件事

漏标 = 资源在「资源根目录」下,却没有被任何 AB 包收走(自己没设 AB 名,父文件夹也没设)。

它的可怕之处在于隐形:

编辑器里:AssetDatabase 直读,照常能加载 —— 一切正常
真机上:  ResMap 里查不到它 → PathNotMapped → 兜底 Resources 也没有 → FileNotExist

所以工具在打包前就把它拦下来:

情况处理
确实是该进包的资源在 Project 里给它(或它所在的文件夹)设 AssetBundle 名
故意不进包(源文件、参考图、文档)把它的目录加进 excludeFolders,或后缀加进 excludeExtensions
干脆不要这个检查关掉配置项 checkUnmarkedAssets(「检查」页签仍会列出来,方便你排查)

5运行时生命周期(别让资源泄漏)

5.1 引用计数

Load / LoadAsync        → RefCount +1(缓存命中也 +1)
Release(rootPath,resName) → RefCount -1
RefCount 归零           → 进入"待释放表"(不会立刻卸,防抖动)
FlushUnused / 自动卸载  → 冷却期过后真正卸载 + Resources.UnloadUnusedAssets
业务纪律:Load 了几次就要 Release 几次。

5.2 分组:切界面 / 切场景整组回收

RevResManager.Load<Sprite>(RevResPath.UI_Icon, "Hero_1001", RevResGroup.UI);

// 关界面时:连同"还在被引用"的一起清账(force = true)
RevResBootstrap.Instance.Shutdown(RevResGroup.UI);

// 只想清引用已归零的(更安全)
RevResBootstrap.Instance.Shutdown(RevResGroup.UI, force: false);
分组典型内容
Battle战斗:技能特效、子弹、英雄模型
UI界面:窗体、图标、字体
Sound音效:BGM、技能音、语音
Config配置表(建议改用 Resident 标志,全程要用)
Scene场景:地图、光照贴图
Unknown默认值:不会被任何分组连带卸载

跨域共享的资源(公共图集被 UI 和 Battle 同时用)要打 RevResInstanceFlag.Resident 标志 —— 它永不参与分组卸载,连 force 也不会误伤。

分组三连问:可选吗 / 要传吗 / 能自定义吗

① 是可选参数吗? 是。签名是 group = RevResGroup.Unknown,不传就是 Unknown。

② 到底有没有必要传? 取决于"这个资源将来要不要被整组回收":

场景建议原因
界面 / 战斗 / 场景资源一定要传关界面、退战斗时一句 Shutdown(group) 整组回收,不用逐个 Release
长期持有、全程要用的(通用图集、常驻特效)不传,或打 Resident 标志它本来就不该被任何分组连带卸掉
一次性用完立刻 Release 的可以不传引用归零后自动卸载就会回收它,分组帮不上忙
随手 Load、从不 Release不传反而"更安全"不会被 Shutdown 误伤 —— 但内存泄漏会悄悄发生
关键区别:传了 = 能被整组回收;不传 = 只能靠"引用归零 + 自动卸载"。
所以"忘了传"的后果不是崩溃,而是"切了场景内存没降下来" —— 这类问题最难查,建议默认都传。

⚠️ 另外记住:归属"首次确定,之后不再变更"。同一个资源被 A 域先加载、B 域后加载,它算 A 域的。跨域共享的资源请打 RevResInstanceFlag.Resident。

③ 能自定义分组吗? 能,两条路:静态分组加枚举值、动态分组用 RevResGroupUtil.Custom(n)。 (框架故意不做"字符串注册接口",原因见本段末尾。)

路线 1:加枚举值 —— 静态分组(推荐)

适用:分组数量固定、编译期就知道(背包 / 商店 / 邮件…)。在 RevResDefine.cs 的 RevResGroup 里往后追加:

public enum RevResGroup
{
    Unknown,    // 0
    Battle,     // 1
    UI,         // 2
    Sound,      // 3
    Config,     // 4
    Scene,      // 5
    Bag,        // 6  ← 新加的,只能追加在末尾
    Shop,       // 7
}

业务侧立刻可用:

RevResManager.LoadAsync<Sprite>(bagIconPath, s => { }, RevResGroup.Bag);
RevResBootstrap.Instance.Shutdown(RevResGroup.Bag);       // 关背包 → 该组资源整组回收

为什么加值是零风险的(已核对全工程):

检查项结果
有没有 switch (group) 穷举分支0 处 —— 加值不会漏 case
有没有序列化的 RevResGroup 字段(MonoBehaviour / ScriptableObject)0 处 —— 编号变化不会读错旧数据
现有用法全是"默认参数值 / 赋值 / 相等比较"(AssignGroup、UnloadGroup、CollectPreloaded…)
⚠️ 只能追加到末尾,不能插在中间:RevResGroup 用的是默认编号(Unknown=0、Battle=1…),插值会让已有编号整体错位。

路线 2:RevResGroupUtil.Custom(n) —— 动态分组

适用:分组运行时才知道(活动 ID、副本 ID、房间号…),代码里没法写死一个枚举名。

RevResGroup g = RevResGroupUtil.Custom(activityId);      // 映射到 CustomBase(1000) 之后的区间,不和内置值撞车

RevResManager.LoadAsync<Sprite>(iconPath, s => { }, g);
RevResBootstrap.Instance.Shutdown(g);                 // 活动结束 → 该活动的资源整组卸掉

RevResGroupUtil 的三个成员(都在 RevResDefine.cs):

成员作用
CustomBase = 1000自定义编号起点,和内置的 0~5 留足间隔,永不撞车
Custom(int index)取第 index 个自定义分组(建议直接用业务 ID)
IsCustom(RevResGroup)是否自定义分组(打日志 / 排查用)

两条路怎么选

场景用哪个理由
分组固定、编译期就知道(背包 / 商店 / 邮件)加枚举值编译期检查 + IDE 补全,写错编译不过
分组运行时才知道(活动 ID、副本 ID)Custom(n)没法枚举,只能靠数值映射
不想碰框架文件Custom(n) + 自己集中声明见下

不想碰框架文件时,把自定义分组集中声明在一处,别让 Custom(1) / Custom(2) 散落各地(回头记不住几号是几号):

// 业务侧:自定义分组集中声明
public static class MyGroups
{
    public static readonly RevResGroup Bag  = RevResGroupUtil.Custom(1);
    public static readonly RevResGroup Shop = RevResGroupUtil.Custom(2);
    public static readonly RevResGroup Mail = RevResGroupUtil.Custom(3);
}

为什么不做成"字符串分组 / 注册接口"?

字符串分组要分配、要比内容,而且拼错一个字母不会报错 —— 只会在卸载时"点名不到",等于把错误从编译期推迟到运行时,还是静默的。数值 ID 零成本、可断言、可比较,所以扩展方式就只有"编译期加枚举值 + 运行期数值映射"这两条。

真需要"名字 → 分组"时(比如策划表里写 "Bag"),应该在业务层做一次映射(读表时把字符串转成 RevResGroup),而不是把字符串语义下沉进资源系统 —— 这样资源系统保持零成本,字符串的容错与报错留在它该在的地方。

5.3 资源域:using 自动回收(最省心)

using (var scope = RevResManager.OpenScope())
{
    var prefab = scope.Load<GameObject>(RevResPath.UI_MainForm, "MainForm", RevResGroup.UI);
    var icon   = scope.Load<Sprite>(RevResPath.UI_Icon, "Hero_1001", RevResGroup.UI);
}   // ← 出大括号自动 Release,中途 return 也保证执行

5.4 自动卸载门卫(不用管,但要会调)

RevResBootstrap.Init() 时会启动 RevResAutoUnloader,5 个触发源:定时检查 / 内存告急 / 缓存超阈值(LRU) / 空闲时才动手 / 切场景。

参数默认含义
Enabletrue总开关
CheckInterval30s定时体检间隔
UnusedCooldown5s归零后至少保留多久(防抖动)
MaxCachedHandles800缓存条目上限(超了按 LRU 淘汰)
OnlyWhenIdletrue只在没有在途加载时动手
RunOnce(force)—手工触发一次(读条结束时想瘦身)

5.5 预加载:进战斗前把资源铺好

RevResPreloader.Preload(
    new[] { (RevResPath.Hero, "1001"), (RevResPath.Skill, "1001_Effect") },  // 一批可跨多个目录
    group: RevResGroup.Battle,
    priority: RevResLoadPriority.Background,          // 不抢前台
    onProgress: task => bar.value = task.Progress, // 0~1 进度
    onCompleted: task => Debug.Log($"预加载完成:{task.Finished}/{task.Total},失败 {task.Failed}"));

// 同一目录下批量(简写):
RevResPreloader.Preload(RevResPath.UI_Icon, new[] { "Hero_1001", "Hero_1002" }, RevResGroup.UI);

// 只预加载一个:
RevResPreloader.Preload(RevResPath.Hero, "1001", RevResGroup.Battle);

// 归还预加载持有的引用(不影响业务自己持有的引用)
RevResPreloader.Release(RevResGroup.Battle);

预加载会保留一份引用 —— 否则加载完没人引用,下一次自动卸载就把它清掉了,等于白做。

RevResPreloadTask 上还有 IsDone / IsCancelled / Cancel(),以及 OnProgress / OnCompleted 两个事件。

6加载失败排查表

失败一律不抛异常、不打日志,原因记在 handle.ErrorReason 上:

原因含义先查什么
PolicyNotFound没有任何策略能处理这个路径逻辑路径写错?前缀 Res/ 拼错?
PathNotMappedResMap 里查不到① 资源忘了打包 ② 被 excludeExtensions 排除了(校验器会告警) ③ 资源漏标(没设 AB 名,打包前就会被拦,见 4.4)
BundleLoadFail包本身没加载出来① 产物没拷到 StreamingAssets/<平台名>/ ② 平台不匹配(Android 包放进了 PC 目录) ③ 主包缺失(<平台名> 文件不存在)
AssetLoadFail包打开了,但里面没这个资源资源名写错;资源被剥到别的包(如共享资源被移走)
FileNotExist文件不存在编辑器直读路径不对;Resources 下确实没有
TypeMismatch类型对不上按 Texture 加载了一个 Prefab
Cancelled被取消切场景时 RevAsyncLoadPump.CancelAll() 中断了在途加载(正常现象)
最省事的排查姿势:先到「检查」页签扫一眼(漏标 / 重名 / 逻辑路径冲突一次看全), 再把编辑器切到 AB 模式(Revolution.Tools/资源/AB 加载模式(编辑器))—— 这样"打包 → 映射 → 加载"整条链在编辑器里就能复现,不用每次出真机包。

7完整示例:一个界面从打开到关闭

public class HeroPanel : MonoBehaviour
{
    [SerializeField] private Image _icon;

    private RevResScope _scope;

    private void OnEnable()
    {
        // ① 开一个资源域:这次界面加载的所有资源都挂在它身上
        _scope = RevResManager.OpenScope();

        // ② 加载(编辑器直读;真机走 AB,第一次加载请用异步版)
        _icon.sprite = _scope.Load<Sprite>(RevResPath.UI_Icon, "Hero_1001", RevResGroup.UI);

        // ③ 预加载下一个界面要用的资源(后台,不抢前台)
        RevResPreloader.Preload(RevResPath.UI_Icon, new[] { "Skin_1001" }, RevResGroup.UI);
    }

    private void OnDisable()
    {
        // ④ 出域即回收:该界面独占的资源引用全部归还
        _scope?.Dispose();
        _scope = null;
    }
}

切场景时统一收口:

RevResBootstrap.Instance.Shutdown(RevResGroup.UI);   // 归还预加载引用 + 按组卸载 + 真正释放
RevResBootstrap.Instance.ShutdownAll();           // 回登录界面:全清 + 释放所有 AB 包

8附:关键文件索引

文件职责
Runtime\RevResourceSystem\Core\RevResManager.cs统一门面(业务唯一入口:加载 / 引用计数 / 卸载)
Runtime\RevResourceSystem\Core\RevResPathUtil.cs两参数路径的拼接与键计算(Join / ComputeKey:不拼字符串也能算出键)
Runtime\RevResourceSystem\Core\RevResHandle.cs资源句柄(状态机 + 标志位 + 引用计数)
Runtime\RevResourceSystem\Core\RevResDefine.cs枚举(分组 / 标志 / 优先级 / 失败原因)
Runtime\RevResourceSystem\Support\RevResBootstrap.cs启动装配 + 切场景清理
Runtime\RevResourceSystem\Support\RevAsyncLoadPump.cs异步泵(排队限流 + 回调合并)
Runtime\RevResourceSystem\Support\RevResPreloader.cs预加载(持有引用,可批量归还)
Runtime\RevResourceSystem\Support\RevResAutoUnloader.cs自动卸载门卫
Runtime\RevResourceSystem\Support\RevResScope.cs资源域(using 自动回收)
Runtime\RevResourceSystem\Implementation\Policies\*三条策略:编辑器直读 / AB / Resources 兜底
Runtime\RevResourceSystem\Implementation\Loaders\RevABLoader.csAB 加载(多平台路径 / 包引用计数)
Editor\RevResourceSystem\ABTool\Pipeline\ABCollector.cs扫描 AB 标记 → 生成"逻辑名 → 包名+资源名"映射(含只读的漏标检测)
Editor\RevResourceSystem\ABTool\Pipeline\ABValidator.cs打包前校验(拦截坏包 + 漏标)
Editor\RevResourceSystem\ABTool\Pipeline\ABBuilderCore.cs打包 + 拷到 StreamingAssets
Editor\RevResourceSystem\ABTool\CodeGen\ABResPathGenerator.cs生成 RevResPath.cs 目录常量(扫描资源根目录下的文件夹)
Editor\RevResourceSystem\ABTool\Pipeline\ABCIBuild.cs无头打包入口(CI / 批处理:-executeMethod Revolution.Editor.ABCIBuild.Build)
Editor\RevResourceSystem\ABTool\Window\ABBuildWindow.cs可视化窗口(Revolution.Tools/资源/RevAB 打包工具)

RevAB 打包工具的目录划分(Editor\RevResourceSystem\ABTool\,原名 LiteAB):

子目录放什么
Core\配置资产 ABBuildConfig、技术路径与本机偏好 ABBuildSetting、日志出口 RevABLog
Pipeline\打包流水线:收集 / 校验 / 打包 / 依赖分析 / 写清单 / CI 入口;窗口共用缓存 ABCollectCache;改标记的全部操作 ABBundleEditor
CodeGen\RevResPath / RevSoundPath 生成器与纯逻辑的命名规则 ResPathNaming
Snapshot\布局快照与差异算法(ABLayoutSnapshot 是纯 C#,可工程外断言)
Integration\与 Unity 编辑器的接缝:标记变更监视(自动同步)、Project 窗口包名角标
Window\界面:窗口 ABBuildWindow、包树 ABBundleTreeView、资源表 ABAssetTreeView、分包页 ABBundleBrowserView、依赖 / 体积 / 检查 / 快照页、公共小件 ABGUI