源设计起源 · 为什么要做这个模块
- 没它会怎样:编辑器会骗人:真机上资源在包里、加载本身是异步的,用同步方式取根本拿不到;而编辑器里因为直读工程,同步反而好使 —— 于是"编辑器能跑、真机拿不到"成了这类系统的经典错觉。
- 绕过它就等于放弃一整套能力:直接去读 StreamingAssets 或工程目录,等于没有缓存、没有引用计数、平台分支要自己写、还没法热更 —— 所以"统一走资源系统"是一条纪律,不是偏好。
- 加载了没人还,内存就悄悄涨:系统把"加载几次就要归还几次"变成纪律,引用归零后才进入待释放。分组没传对的后果也不会崩,而是"切了场景内存没降下来" —— 这类问题最难查。
- 错误必须能被看见:漏标的资源在编辑器里照常能加载,到真机才找不到,所以打包工具在打包前就把它拦下来;运行时加载失败也不抛异常、不打日志,把原因留在句柄上等你去查。
本篇只讲为什么;怎么用见同目录《使用说明》。
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);
- 第一个参数是根目录段(用生成的
RevResPath常量,见 1.4;资源就在资源根目录下时传""); - 第二个参数是资源名(不带扩展名;也可以再带子目录,见第 2 章的多级嵌套);
- 第三个参数是资源分组(决定切场景时会不会被整组卸掉,见第五章);
- 两段会被拼成完整逻辑路径
UI/Icon/Hero_1001—— 拼这一步业务不用管,也不需要自己写/; - 失败返回
null;想拿失败原因就用LoadHandle<T>(同样用泛型,不用写typeof):
RevResHandle handle = RevResManager.LoadHandle<Sprite>(RevResPath.UI_Icon, "Hero_1001", RevResGroup.UI); if (!handle.IsLoaded) Debug.LogError($"加载失败:{handle.ErrorReason}");
1.2 异步:真机首次加载必须用这个
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 三条必须记住的纪律
- 逻辑路径不带扩展名。带扩展名会被当成另一条路径 —— 所以用生成的
RevResPath目录常量 + 资源名的两参数写法最稳(RevResPath.UI_Icon, "Hero_1001")。 - 不要手写
Assets/...开头的路径,那是把"资源怎么组织"泄漏进业务代码。 - 不要直接读 StreamingAssets /
Application.dataPath。绕开资源系统 = 没有缓存、没有引用计数、平台分支自己写、还没法热更。
数据表系统就是靠这条纪律统一走资源系统的。
2.3 关于 Resources 兜底
RevResourcesResPolicy 是策略链的最后一环,接两类请求:业务显式写 "Res/xxx" 前缀的、以及 AB 加载失败后允许兜底的。
Assets/Resources 下的东西会被 Unity 无条件打进包体、且无法按需卸载 —— 别把大资源丢进去。
它的定位是"兜底 + 极小必需资源",不是主通道。
3AB 包怎么划分
3.1 先说结论:按"一起用、一起卸"的粒度分包
分包的唯一目的是控制"下载/更新的粒度"和"加载/卸载的粒度"。所以判断标准只有两条:
- 生命周期一致:会同时加载、同时卸载的资源放一个包(比如进战斗用的英雄模型 + 技能特效)。
- 业务域一致:和
RevResGroup的划分对齐(UI / Battle / Sound / Scene),这样卸载能整组回收。
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 六条分包实践
- 一个包别太大:改一张图要重下整个包,热更成本直接等于包体积。
- 一个包别太碎:单个资源的包会被校验器警告("单资源包"),且 IO 次数多。
- 共享资源单独一个包(如
common):被多个包依赖的图集、Shader、通用字体放进公共包。否则依赖分析会报"共享资源被复制多份,体积膨胀"。 - 不要形成循环依赖:校验器会直接报
循环依赖错误,打包中止。 - 依赖会被自动递归加载:加载
hero时,系统会先按 Manifest 把它依赖的包全部加载好,业务不用管。 - 命名只用
[A-Za-z0-9_-/.]:中文、空格会被校验拦下;文件名建议全小写(Linux/Android 区分大小写,Windows 不区分)。
4打包流程与注意事项
4.1 流程(一键)
菜单 Revolution.Tools/资源/RevAB 打包工具:
六个页签:打包 · 分包 · 依赖 · 体积 · 检查 · 快照(「检查」的标题上直接带问题数,不用点进去就知道现在能不能打包)。「打包」页签从上到下:
- 顶部操作栏 目标平台(默认跟随当前平台,可不切平台直接打别的平台)· 输出目录(「打开」直达)· 「打包」主按钮 ·「仅生成映射」·「仅校验」
- 资源目录 资源根目录(必填,可拖拽)+ 音效目录 / BGM 目录
- 分包方式 使用已有标记(推荐)/ 按目录自动分包(切过去会先确认:它会清空全部手动分包)+ 排除目录 / 后缀 + 漏标检查
- 打包选项 压缩方式(LZ4 / LZMA / 不压缩)· 强制重打 · 打包前清空输出 · 拷到 StreamingAssets · 版本号
- 代码生成 手动生成 / 打开 RevResPath.cs、RevSoundPath.cs(默认折叠:打包和生成映射都会顺带生成)
- 校验结果 红色 = 必须修(阻止打包),黄色 = 建议修;过期会提示「重新校验」;一键跳到「检查」页签
★「漏标」(资源没设 AB 名)也在红色里 —— 它会直接中止打包,见 4.4 - 打包结果 平台 · 包数 · 产物体积 · 用时;「打开输出目录」「复制路径」「看产物清单」;共享资源 / 循环依赖提示
「目标平台」是本机偏好(不写进共享配置、不影响别人);CI 打包(
ABCIBuild)永远用当前平台。
选了没装构建支持的平台,按钮会变灰并提示去 Unity Hub 装模块。「分包」页签(也可以从菜单 Revolution.Tools/资源/RevAB 分包浏览 直接打开;对标 Unity 官方 AssetBundle Browser 的 Configure 页)
左边是包树、右上是资源表、右下是详情,两条分隔条都能拖;列宽、排序、选中、展开都会记住:
⚠️ 它只同步显示、不写任何文件:
ResMap.txt / RevResPath.cs 仍然要显式点按钮生成。
| 操作 | 怎么做 |
|---|---|
| 看现在分了哪些包 | 打开页签即可(只读扫描,不会动你的标记);包名里的 / 显示成层级,每行右侧是资源数 · 体积,有问题的包带警告图标 |
| 把资源加入某个包 | 从 Project 把资源(或文件夹)拖到左侧的包上;或拖到右侧资源表(加进当前选中的包) |
| 拖资源新建包 | 把资源拖到左侧空白处 / 文件夹节点上 = 以资源名新建一个包并放进去 |
| 新建空包 | 工具栏「新建包」/ 右键「新建包」→ 直接原地起名(空包先挂在树里,拖进资源才算真正存在) |
| 重命名 | 选中后 F2 或双击;改包"文件夹" = 连同下面所有包一起改名 |
| 调整层级 | 把包拖到另一个节点上 = 挪进它下面;拖到空白处 = 挪到最外层 |
| 删除 | 选中后 Delete(可多选;删文件夹 = 删它下面所有包);只清标记,不会删资源文件 |
| 资源换包 | 在资源表里选中几行,拖到左侧别的包上;或右键「移到 / 某个包」 |
| 移出资源 | 资源表选中后 Delete / 右键「移出包」;继承自文件夹的会询问"要不要清那个文件夹的标记" |
| 定位 | 资源表双击,或右键「在 Project 里定位」;包右键「在 Project 里选中包内资源」 |
| 看为什么在这个包里 | 资源表「标记来源」一列:自己标记 / 继承自哪个文件夹;右下详情还有它依赖的其他包、会被一起打进来的未分包资源 |
| 清理残留包名 | 工具栏「清理未使用包名」(改名 / 删资源后留下的空包名) |
同理:「删除包」= 清空标记,「重命名」= 批量改标记(文件夹也一起改),否则会出现"删了包、资源却还在包里"这种怪现象。 改名 / 删除 / 换包后会自动清掉变成"没人用"的旧包名;包名一律转小写(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 / Linux | PC |
| Android | Android |
| iOS | iOS |
| WebGL(含微信 / QQ 小游戏) | WebGL |
4.3 打包注意事项(按踩坑概率排序)
excludeExtensions 默认只剩 .cs / .meta —— 数据表 Data/*.txt 会正常进 ResMap。如果哪天你手动把
.txt 加了回去,数据表就会被静默踢出包,运行时只表现为 PathNotMapped(极难往"后缀"上想)。好在校验器会直接告警:
有 N 个资源被 excludeExtensions 排除,不会进包 —— 看到就去排除列表里删掉 .txt。
- 命名非法会直接中止打包:只允许
[A-Za-z0-9_-/.]。中文、空格、括号都不行。 - 逻辑路径冲突会中止:
Hero/1001.prefab和Hero/1001.png都会算成Hero/1001,必须改名。 - 同一包内资源重名会中止:
A/Icon.png与B/Icon.png都在ui包里 →LoadAsset("Icon")取到哪个不确定。改名或拆包。 - 漏标会中止:资源在资源根目录下、却没有任何 AB 标记 → 不进任何包,真机必然
FileNotExist。这是手动分包模式最常犯的错,工具会拦(详见 4.4)。 - 空包会中止:标了 AB 名但一个资源都没有(资源删了/改名了的残留),去把标记清掉。
- 换平台必须重新打包:AB 不跨平台。把 Android 的包放到 PC 的目录里,加载会
BundleLoadFail。 - 压缩方式:默认
LZ4(块压缩,加载快、体积中等,推荐);首包/下载包可换LZMA(更小、首次加载更慢);None只用于调试。 - 增量是自动的:
forceRebuild关闭时,Unity 按内容 Hash 跳过没变的包;只有怀疑产物脏了才勾它。 - 编辑器默认不走 AB:开发期
RevEditorResPolicy直读工程(所有资源一律AssetDatabase读,不打包也能跑)。要验证 AB 链路,用菜单Revolution.Tools/资源/AB 加载模式(编辑器)切换,或直接出真机包验证。
⚠️ "编辑器能跑"不代表"真机能跑",AB 链路一定要在真机上验一次。 copyToStreamingAssets打开后是"整目录覆盖":目标目录会被先删再拷,别往里放手写的东西。- 任何
Resources文件夹里的东西都会被无条件打进包体:框架的ResMap.txt就放在它自己的Assets/Revolution/Resources/ResourceSystem/里(很小)。别把大资源往任何Resources文件夹里放 —— 包括框架这个:它存在的唯一目的就是让Resources.Load能读到这张小表。
4.4 漏标检测:手动分包最该防的一件事
漏标 = 资源在「资源根目录」下,却没有被任何 AB 包收走(自己没设 AB 名,父文件夹也没设)。
它的可怕之处在于隐形:
编辑器里:AssetDatabase 直读,照常能加载 —— 一切正常 真机上: ResMap 里查不到它 → PathNotMapped → 兜底 Resources 也没有 → FileNotExist
所以工具在打包前就把它拦下来:
- 走的是反向扫描:从资源根目录枚举所有资源,逐个查
AssetDatabase.GetImplicitAssetBundleName(用Implicit版本,所以"只给文件夹设了标记、子文件自动继承"的资源不会被误报); .meta、excludeExtensions、excludeFolders里的一律跳过(它们本来就不该进包,报出来只会淹掉真问题);- 发现漏标 → 校验报错、中止打包(CI 打包同样会因此失败退出),并在「检查」页签 ① 列出完整清单,每条都能「定位」,也可以「全部加入一个包…」一步补上。
| 情况 | 处理 |
|---|---|
| 确实是该进包的资源 | 在 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) / 空闲时才动手 / 切场景。
| 参数 | 默认 | 含义 |
|---|---|---|
Enable | true | 总开关 |
CheckInterval | 30s | 定时体检间隔 |
UnusedCooldown | 5s | 归零后至少保留多久(防抖动) |
MaxCachedHandles | 800 | 缓存条目上限(超了按 LRU 淘汰) |
OnlyWhenIdle | true | 只在没有在途加载时动手 |
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/ 拼错? |
PathNotMapped | ResMap 里查不到 | ① 资源忘了打包 ② 被 excludeExtensions 排除了(校验器会告警) ③ 资源漏标(没设 AB 名,打包前就会被拦,见 4.4) |
BundleLoadFail | 包本身没加载出来 | ① 产物没拷到 StreamingAssets/<平台名>/ ② 平台不匹配(Android 包放进了 PC 目录) ③ 主包缺失(<平台名> 文件不存在) |
AssetLoadFail | 包打开了,但里面没这个资源 | 资源名写错;资源被剥到别的包(如共享资源被移走) |
FileNotExist | 文件不存在 | 编辑器直读路径不对;Resources 下确实没有 |
TypeMismatch | 类型对不上 | 按 Texture 加载了一个 Prefab |
Cancelled | 被取消 | 切场景时 RevAsyncLoadPump.CancelAll() 中断了在途加载(正常现象) |
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.cs | AB 加载(多平台路径 / 包引用计数) |
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 |