资源热更这块最大的沟通成本不在实现,而在名词。同一个词在不同框架里指不同文件,同一个文件在不同框架里叫不同名字:Addressables 叫 Catalog,YooAsset 里没有 Catalog 这个类却有一份 BuildinCatalog;
Manifest既可能是框架的运行时清单,也可能是 Unity 构建过程产出的一个副产物;而 DLC 干脆不是技术名词,是发行名词。这篇文章不按框架 API 组织,而是按名词组织:每个词先给一句话定义、再给它在文件系统里的落点、最后给"怎么验证它"。目标是下次看到别人文档里的一个词,能立刻判断它说的是哪一层,需要打开哪个文件。
后半篇(分包规则 起)换一个视角:按机制组织——打包规则怎么决定粒度、粒度怎么影响请求 / 内存 / 热更、压缩怎么选、运行时怎么验证、优化怎么用 ROI 排序。每个结论都落到一个可执行的验收指标上。
目录
| 章节 | 内容 |
|---|---|
| 先分清三套版本号 | App 版本、资源版本、代码版本,混在一起就永远对不上 |
| Package:资源包 | 它不是安装包;划分边界决定了后面所有能力的上限 |
| Catalog:清单目录 | 三种语境下同名不同物,以及它的两种粒度 |
| Manifest:清单字段逐项 | AssetList / BundleList 每个字段的用途与坑 |
| Bundle:物理容器 | BundleName 与 FileName 的区别,依赖是双向的 |
| DLC:可下载内容 | 为什么它不是技术概念,以及工程上怎么落地 |
| PlayMode 与资源的三层位置 | 内置 / 沙盒 / 远端,以及各自的清理语义 |
| 代码热更的名词 | 热更 DLL 与 AOT 补充元数据,两个 DLL 分工完全不同 |
| 速查词表 | 中英对照总表,可直接当交接文档用 |
| 分包规则 | 收集器、打包规则、共享包:三层机制与常见配置错误 |
| 粒度与请求数 | “请求"是什么、两个极端的代价、共载聚类 |
| 压缩格式 | Uncompressed / LZMA / LZ4,以及双重压缩为什么无效 |
| 加载与内存 | 加载 Bundle ≠ 资源进内存,卸载与引用计数 |
| 热更代价 | 为什么是整包替换、改一张图要重下多少 |
| 运行时观测 | 四种手段:调试器、Profiler、内存快照、API |
| 报告驱动与 ROI | 指标分诊、ROI 排序、验收对照数 |
| 怎么查:实操路径 | 定位资源在哪个包、确认运行时用的哪个版本 |
| 常见误解 | 一整张"以为是 A 其实是 B"的对照表 |
| 落地清单 | 接入新框架时要确认的问题 |
先分清三套版本号
所有资源热更的排障,第一步都是"你到底在比哪个版本号”。这三套版本号的生命周期、比较方、更新代价都不一样。
| 版本 | 典型字段 / 载体 | 谁负责比较 | 更新手段 | 需要重新出包吗 |
|---|---|---|---|---|
| App 版本 | 原生 versionName / versionCode、强更配置 |
启动时服务端下发 | 应用商店覆盖安装 | 是 |
| 资源版本 | PackageVersion、res_version.txt 里的整数 |
客户端与远端清单比对 | 资源热更 | 否 |
| 代码版本 | 热更程序集文件(如 hlwd.game.bytes) |
随资源版本一起走 | 资源热更 | 否 |
三条必须记住的结论:
- 代码热更不是独立通道。 热更 DLL 就是被打进 Bundle 的一个资源,它走的是和贴图、预制体完全相同的版本比对和下载流程。所以"代码版本"实际是"资源版本"的一个子集,不要把它当第四套版本号去设计流程。
- 资源版本和 App 版本不是包含关系,是两条平行线。 一个 App 版本可以对应任意多个资源版本;但资源版本不能向下兼容到比它更老的 App 版本,除非你主动做了降级逻辑。
- 比较动作只有"相等 / 不等",不要设计成大小比较。 资源版本用来寻址 CDN 目录(换版本换目录),不是用来判断"用户是不是落后了"。想做灰度、回滚,就换一份清单指向,而不是去比数字大小。
为什么先讲这个:后面所有名词——Package、Catalog、Manifest、Bundle——都存在"每个版本一份"的情况。分不清版本号,就会拿 A 版本的清单去解释 B 版本的报错。
Package:资源包
一句话定义:一组资源的集合,拥有独立的清单、独立的版本号、独立的下载器与独立的缓存目录。
最容易搞混的一点:Package 不是安装包。 安装包叫 APK / AAB / IPA,项目里通常叫"包体";Package 是工程内部对资源的逻辑切分,一个安装包里可以有多个 Package。
一个 Package 到底"独立"了什么
| 维度 | 是否独立 | 说明 |
|---|---|---|
| 清单文件 | ✅ | 每个 Package 一套,互不影响 |
| 版本号 | ✅ | 可以单独涨版本,其他包不动 |
| 下载任务 | ✅ | 可以有各自的并发与超时策略 |
| 缓存目录 | ✅ | 可以单独清理、单独校验 |
| 引用计数 | ✅ | 跨包引用需要显式依赖声明,不会自动共享 |
| CDN 目录 | ✅ | 通常按 v{版本}/{包名}/ 分层 |
划分边界的常见口径
| 切分维度 | 适用场景 | 代价 |
|---|---|---|
| 主资源 / 非主资源 | 控制首包体积 | 首启需要一次完整清单比对 |
| 语言 | 多语言发行,按系统语言只下次需要的 | 文案常被其他资源引用,容易跨包 |
| 活动 / 赛季 | 活动内容独立上下线、独立回滚 | 活动数量多时清单数量爆炸 |
| DLC / 付费内容 | 按需购买后下载 | 需要处理授权与卸载 |
| 清晰度 / 画质档 | 高端机下高清资源 | 同一资源存在多份,跨档不能复用 |
| 平台 | 桌面与移动格式不通用(如 BC 与 ASTC) | 这是硬约束,基本必须分 |
划分边界是架构决策,不是"顺手分个组"。 一旦按某个维度切了 Package,跨越这个边界的资源引用就会变成"跨包依赖"——它会带来加载时序、卸载粒度、清单复杂度三方面的额外成本。新项目建议先只切"必须切的"(平台、首包/非首包),把 DLC 这类业务维度留到有明确需求时再加。
Catalog:清单目录
这是本文最容易读错的一个词,因为三种语境下都叫 Catalog,但不是同一个文件。
| 语境 | 名字 | 实际文件 | 回答什么问题 |
|---|---|---|---|
| Addressables | Content Catalog | catalog.json、catalog.hash、catalog_{时间戳}.json |
有哪些资源、地址是什么、在哪个 bundle、依赖谁 |
| YooAsset | 没有 Catalog 类,由两个文件分担 | BuildinCatalog.json、{Package}_{版本}.json |
内置带了哪些 bundle;资源到 bundle 的映射 |
| Unity 原生 | AssetBundleManifest |
OutputCache.manifest(构建副产物) |
bundle 之间的依赖关系 |
通用语义:catalog 是"目录",而且是两级的
抛开框架,catalog 这个词的本义是目录。而在资源框架里它有两个粒度,两者常被混为一谈:
- 清单的清单(对应
BuildinCatalog):包里内置了哪些 Bundle、当前有哪些清单版本可用。它是"找清单"的入口。 - 资源的目录(对应
Manifest/ Addressables Catalog 的主体):某个资源地址 → 它属于哪个 Bundle → 它依赖哪些 Bundle。它是"找资源"的入口。
所以当有人说"catalog 不对",你要追问的是:是入口清单不对,还是资源映射不对? 这两个问题的排查路径完全不同。
一份真实的内置清单结构
{
"FileVersion": "1.0.0",
"PackageName": "DefaultPackage",
"PackageVersion": "12",
"Wrappers": [
{
"BundleGUID": "531607a7cd2af3b8a31565ec3f2d374c",
"FileName": "defaultpackage_assets_gamemain_scenes_531607a7cd2af3b8a31565ec3f2d374c.bundle"
}
]
}
读这份文件的正确姿势是倒着读:先看 PackageVersion(当前内置到第几个版本),再看 Wrappers 里每个 FileName 是否真的存在于磁盘上。出现"清单说有这么个包,但磁盘上没有"的时候,问题就锁定在内置产物不完整,而不是下载逻辑。
hash 文件的作用
清单旁边通常有一个同名 .hash 文件(或清单里带 Hash 字段)。它的唯一用途是:让客户端在不下载整个清单的情况下,判断清单有没有变。
- 只下 hash(极小的文件)→ 和本地比对 → 相同就跳过清单下载 → 直接进下载阶段。
- 不同才下完整清单。
这就是"每次启动都要检查更新,但流量消耗很小"的实现基础。反过来说,如果 hash 的生成逻辑和清单内容不匹配(例如清单被重新序列化但 hash 没重算),会出现永远认为需要更新的死循环。
必须记住的坑:BundleID 是数组下标
清单里代表 Bundle 的字段在很多框架里是整数 ID,它不是稳定标识符,而是同一份清单内 BundleList 数组的下标。
# 给定一个 bundleID,反查是哪个 bundle
$manifest = Get-Content .\DefaultPackage_12.json -Raw | ConvertFrom-Json
$bundleId = 15
$bundle = $manifest.BundleList[$bundleId]
$bundle.BundleName
由此推出两条结论:
- 这个 ID 一旦清单重建就会变,不能持久化、不能跨版本比较、不能写死在配置表里。
- 排查时要拿同一份清单内部的 ID 去查,不要拿 A 版本的报错 ID 去 B 版本清单里找,那样必然对不上。
- 反查"这个资源依赖了谁"的正确方式是读
DependBundleIDs(一组下标),再逐个映射回BundleList。人眼读 JSON 很痛苦,优先用构建报告(见下节)。
Manifest:清单字段逐项
Manifest 是"资源目录"的本体。不同框架字段名不同,但结构高度一致:一张资源表 + 一张容器表,用 ID 互相引用。
以 YooAsset 的 PackageManifest 为例:
AssetList(资源表)
| 字段 | 含义 | 注意事项 |
|---|---|---|
Address |
逻辑地址,业务层加载时用 | 默认用资源路径;配了可寻址规则后可能变成短名 |
AssetPath |
工程内路径 | 与 Address 同时存在时,需明确哪个是"对外契约" |
BundleID |
所属 Bundle | 是下标,见上一节 |
DependBundleIDs |
依赖的 Bundle 集合 | 一组下标;共享资源会在这里指向 share 包 |
Tags |
标签 | 用于按标签批量下载、预加载 |
BundleList(容器表)
| 字段 | 含义 | 注意事项 |
|---|---|---|
BundleName |
逻辑名 | 相对稳定,会体现打包目录结构 |
FileName |
物理名 | BundleName_HashName.bundle,含内容 Hash |
FileHash |
内容 Hash | 判断"这个包的内容变没变"的唯一依据 |
FileCRC |
校验值 | 下载完整性校验 |
FileSize |
字节数 | 用于下载进度与体积预算 |
DependBundleIDs |
依赖的其他 Bundle | 加载一个包会先递归加载它 |
读法建议:别读 JSON,读构建报告
原始 JSON 适合程序读,不适合人读。构建过程通常会额外产出一份可读报告,包含四个部分:
| 报告区块 | 内容 | 什么时候看 |
|---|---|---|
| Summary | 整体统计:资源数、包数、总大小 | 发现包体异常膨胀时 |
| AssetInfos | 每个资源:路径、所属包、依赖包 | 查"这个资源被谁引用" |
| BundleInfos | 每个包:逻辑名、物理名、依赖包、被谁依赖、包含哪些资源 | 查共享包、查环形依赖 |
| IndependAssets | 没有任何包引用、也没被引用的独立资源 | 查无用资源、查漏加入收集器的资源 |
其中 BundleInfos 里的 ReferenceBundles(被谁依赖)是最有价值的一列。原始 JSON 只存了"我依赖谁"(正向),要回答"谁依赖我"必须遍历全表构建反向索引——报告已经帮你算好了。
判断一个共享包是否健康,就看它的 ReferenceBundles 数量:只有 1 个引用者的"共享包",说明拆包规则切错了,它白白多了一次 IO 和一份清单开销。
Bundle:物理容器
Bundle 是磁盘上真实存在的文件,前面所有概念最终都要落到它身上。
两个名字,别混用
| 名字 | 形态 | 稳定性 | 用途 |
|---|---|---|---|
BundleName |
defaultpackage_assets_gamemain_materials |
相对稳定 | 日志、依赖分析、人工排查 |
FileName |
defaultpackage_assets_gamemain_materials_abc123....bundle |
内容一变就变 | 磁盘文件名、CDN 路径、缓存 key |
为什么要带 Hash 后缀:因为要支持"同一路径下新旧内容共存"。如果文件名固定,CDN 缓存会给你旧文件;CM 部署新版本时也无法灰度。带 Hash 后,abc123 和 def456 是两个不同 URL,缓存互不干扰,代价是旧文件需要清理策略。
依赖是双向的
清单里存的是正向依赖(我依赖谁),但排查时经常需要反向信息(谁依赖我):
- 共享资源的判定:一个材质被 10 个预制体引用 → 抽成 share 包 → 这 10 个包都会在
DependBundleIDs里指向它。 - 卸载安全:卸载 A 包前要确认没有其他已加载包依赖它,否则会出现"资源凭空消失"。
- 环形依赖:A 依赖 B、B 又依赖 A,加载时死锁。这类问题在正向清单里很难看出来,看报告的反向列表反而一目了然。
命名规则会泄漏架构
BundleName 通常由"打包目录 + 资源类型"拼出来,例如:
defaultpackage_share_assets_gamemain_materials
^^^^^^^^^^^^^ ^^^^^ ^^^^^ ^^^^^^^^^^^^^^
包名前缀 标记 目录路径 资源类型
所以看一个项目的 BundleName 分布,基本就能推断出它的打包策略:出现大量 share 前缀说明启用了共享包规则;目录层级很深说明按目录打包;同名资源出现在多个包名里说明收集器配置有重叠。
DLC:可下载内容
DLC(Downloadable Content)是发行与商业概念,不是技术概念。
这句话是理解 DLC 的关键。在资源框架里你找不到任何叫 DLC 的 API——因为 DLC 描述的是"这个东西在产品上怎么卖、怎么交付",而技术侧只提供"按需下载一组资源"这个能力。把 DLC 当技术名词去找,就会一直在文档里找不到答案。
DLC 与相邻概念的边界
| 概念 | 属于哪个维度 | 回答的问题 | 典型关注点 |
|---|---|---|---|
| DLC | 发行 / 商业 | 主包之外,用户按需购买或获取的内容单元是什么 | 定价、授权、上下架、评级 |
| 热更(Patch) | 交付机制 | 已发布的内容怎么替换成新版本 | 版本比对、差异下载、回滚 |
| 分包(Split) | 包体结构 | 首包放什么、其余按什么边界拆出去 | 首包体积、下载时机 |
| Package | 技术实现单元 | 实现上述目标时,资源按什么边界分组成独立清单 | 清单数量、跨包依赖 |
一句话概括四者关系:DLC 是一类需求,分包是实现它的包体结构策略,Package 是落地时的技术单元,热更是让它们能被持续更新的机制。
技术侧怎么落地一个 DLC
既然没有 API,落地方式就是组合既有能力,通常需要同时满足下面几条:
- 独立 Package:DLC 内容单独一个 Package(或一组),独立清单、独立版本号——这样才能"用户没买就不下载"。
- 独立下载入口:不进入默认启动下载流程,只在用户触发时(购买成功、点了某个入口)才开始下载。
- 两道门:可达 + 授权。技术上"资源下载完了"和"用户有权访问"是两件独立的事。很多线上事故是资源已经能加载,但授权状态没同步,于是未购买用户通过其他入口意外加载到了内容。不要用"资源没下载"来当权限校验。
- 卸载与回收:DLC 是第一个真正需要"用户主动卸载"的场景,要能清理清单与缓存,且不影响主包。
- 回滚与下架:DLC 下架时,已经买过的用户要能继续用(点对点服务),没买过的不能再看到入口。这要求版本清单支持"指向旧清单"。
- 依赖交叉:DLC 与主包互相引用时,谁先加载?主包更新后 DLC 的清单是否过期?这是 DLC 方案里最容易出问题的一环。
平台差异(同一套技术,不同的约束)
| 平台 | 主要约束 | 对 DLC 的影响 |
|---|---|---|
| Android(AAB) | 有分片机制,但资源分片与资源配置是两套东西 | 原生分片不能替代资源 DLC 方案 |
| iOS | 审核对"下载后可改变功能"的内容敏感 | 内容形态与文案要提前对齐审核口径 |
| PC / 主机商店 | 有正式的 DLC SKU 与授权体系(Entitlement) | 商业侧的 DLC 与技术侧的资源包要建立映射表 |
| 微信小游戏等 | 有明确的分包数量与体积上限 | “DLC” 实际是分包能力的重新组合,不能照搬移动端结论 |
结论:DLC 方案 = 发行策略 + 包体结构 + 资源框架能力三者的组合。 三方任何一方没对齐,都会出现"技术做完了但商业侧不理解"或"商业卖完了但技术下不下去"的情况。
PlayMode 与资源的三层位置
同一份资源在运行时可能来自三个地方,PlayMode 决定它们的优先级和组合方式。
| 模式 | 资源来源 | 典型用途 | 关键差异 |
|---|---|---|---|
| Editor 模拟模式 | 直接读 AssetDatabase | 日常开发 | 不走 Bundle,加载行为与真机不同 |
| 离线模式 | 只读内置(StreamingAssets) | 单机、无网、首包自包含产品 | 完全不请求远端,也无法热更 |
| 联机模式(HostPlay) | 内置 + 沙盒缓存 + 远端 | 绝大多数热更产品 | 三层按优先级查找,沙盒覆盖内置 |
| Web 模式 | 远端 HTTP 直读 | 小游戏、WebGL | 无本地缓存概念,依赖浏览器缓存 |
三层位置与清理语义
| 位置 | 物理路径 | 内容 | 谁会清它 |
|---|---|---|---|
| 内置 | StreamingAssets 下 |
随包发布的清单与 Bundle | 只能靠覆盖安装更新 |
| 沙盒 | persistentDataPath 下 |
热更下载下来的清单与 Bundle、版本记录 | 应用自身策略;覆盖安装/重装会清 |
| 远端 | CDN,通常 v{版本}/{包名}/ |
权威清单与 Bundle | 由 CM 部署流程控制 |
这里面有两个必须理解的行为:
- 沙盒的生效方式是把内置清单"复制"过来再更新。 首启时如果沙盒为空,框架会把内置清单复制进沙盒,然后在沙盒那份上继续做增量更新。这解释了为什么首启离线也能跑——它读的是内置产物,而不是远端。
- 覆盖安装会清沙盒。 用户覆盖安装新 App 版本后,沙盒里的清单与 Bundle 可能被清掉,于是回到"用内置清单 + 重新下载"。如果新 App 版本内置的是老清单,用户就会先回退到老版本资源,再更新上去——这段窗口期是线上 bug 的高发期,尤其是"覆盖安装后必须重启一次才正常"这类问题。
版本记录文件
热更流程通常会在沙盒里留一个极小的版本文件(例如一行整数),记录"上次成功更新到哪个资源版本"。
- 它的作用是下次启动的目标基线,避免每次都从头比对清单。
- 它必须在下载全部成功之后才写入。写早了会出现"版本号是新的,资源还是旧的"这种最难查的状态:清单说资源都在,实际加载的是旧内容。
- 它和远端不一致时,不要直接报错退出,而是重新走一次完整比对,这是最常见的自愈路径。
代码热更的名词
代码热更会额外引入几个词,其中最容易被混为一谈的是:热更 DLL 和 AOT 补充元数据。两者都是 .bytes 文件、都放在资源里、都由同一份清单管理,但职责完全不同。
| 名词 | 是什么 | 数量 | 何时需要更新 |
|---|---|---|---|
| 热更程序集 | 被编译成热更 DLL 的业务代码(Hotfix 层) | 通常 1 个 | 业务逻辑改动时 |
| AOT 补充元数据 | AOT 程序集在热更代码里被泛型实例化时需要的元数据 | 一组 | AOT 层改动、或热更代码新用了 AOT 泛型时 |
| link.xml | 告诉裁剪器"这些类型/方法不要裁掉" | 1 个 | 反射、序列化、泛型被裁剪导致运行时才报错时 |
| AOT 泛型引用收集产物 | 构建时扫出来的"热更代码实际用到的 AOT 泛型组合" | 1 个 | 与 AOT 补充元数据配套 |
关键推论:
- 只要热更代码新用到一个 AOT 泛型组合,就必须更新补充元数据。 这是"代码热更上去后,只有某个功能崩溃,报
ExecutionEngineException/Attempting to call method ... for which no ahead of time code was provided“这类问题的根因。 - AOT 层改动无法热更。 它只存在于 App 版本里。所以架构上要把"AOT 尽可能薄"当作硬约束——AOT 越厚,需要强更的改动就越多。
- 加载顺序必须是先补元数据、后加载热更程序集。顺序反了会在加载阶段就失败。
速查词表
| 名词 | 英文 / 常见写法 | 一句话定义 | 常见落点 |
|---|---|---|---|
| 资源包 | Package | 一组资源的集合,独立清单 + 独立版本号 | 框架名称空间下的 Package 对象 |
| 清单目录 | Catalog | “目录"的总称;两级粒度见正文 | catalog.json / BuildinCatalog.json |
| 内置清单 | BuildinCatalog | 包里内置的 Bundle 与清单版本索引 | StreamingAssets 下 |
| 资源清单 | Manifest / PackageManifest | 资源地址到 Bundle 的映射表 | {Package}_{版本}.json |
| 摘要文件 | .hash |
用来快速判断清单是否变化 | 清单同目录 |
| 资源 | Asset | 最小的加载单位 | 工程内资源路径 |
| 地址 | Address / Location | 业务层加载时用的逻辑名 | 清单的 Address 字段 |
| 容器 | Bundle / AssetBundle | 磁盘上的物理包文件 | *.bundle |
| 逻辑名 | BundleName | 相对稳定的包名 | 清单的 BundleName 字段 |
| 物理名 | FileName | 带内容 Hash 的真实文件名 | CDN 路径、本地缓存 |
| 共享包 | Share Bundle | 被多个包共同依赖的包 | BundleName 含 share 标识 |
| 正向依赖 | DependBundles | 我依赖谁 | 清单的 DependBundleIDs |
| 反向依赖 | ReferenceBundles | 谁依赖我(需构建反向索引) | 构建报告的 BundleInfos |
| 可下载内容 | DLC | 主包之外按需获取的内容单元(发行概念) | 无 API,用独立 Package 落地 |
| 热更 | Patch / HotUpdate | 把已发布内容替换为新版本的机制 | 版本比对 + 下载 |
| 分包 | Split | 首包与其余内容的包体划分策略 | 收集器与打包规则 |
| 首包 | Buildin / Base | 随安装包一起发出的内容 | StreamingAssets |
| 沙盒 | persistentDataPath / Sandbox | 热更内容的本地落地目录 | 可写目录 |
| 播发模式 | PlayMode | 决定资源来源组合的运行模式 | 框架初始化参数 |
| 内容校验 | CRC / Hash | 下载完整性与内容变更判定 | 清单字段 |
| 构建报告 | Report | 人可读的构建产物说明 | *.report |
| 强更 | Force Update | 必须覆盖安装才能继续使用 | App 版本号比对 |
| 授权 | Entitlement | 用户是否有权访问某内容 | 服务端 / 商店侧 |
分包规则:三层机制
一句话定义:分包规则不是一条规则,而是三层机制的叠加——收集器决定"哪些资源进哪个组”,打包规则决定"组内资源怎么变成 bundle 文件”,共享包规则与依赖闭包决定"bundle 之间怎么互相引用"。
理解这三层,才能解释"为什么我只是改了一个目录名,产物结构全变了"。
第一层:收集器(Collector)
收集器回答"这个目录下的资源,归谁管"。它以目录为单位配置,一个收集器绑定一段工程路径。
| 字段 | 作用 | 典型取值 |
|---|---|---|
| 收集路径 | 扫哪个目录 | Assets/GameMain/... |
| 收集类型 | 主资源 / 静态资源 / 依赖资源 | Main / Static / Depend |
| 地址规则 | 业务加载时用什么字符串 | 按文件名 / 按工程路径 / 按 GUID / 按组+文件名 |
| 打包规则 | 组内怎么切 bundle | 见第二层 |
| 筛选规则 | 目录里只收哪类资源 | 全部 / 场景 / 预制体 / 精灵 |
| 忽略规则 | 哪些文件跳过 | 常规忽略(.、~ 开头)/ 关闭忽略 |
| 标签 | 用于按标签批量下载 | 业务自定义(如 preload) |
收集类型是这里最容易用错的一格:它表达的是"这个资源是内容本身,还是别人的依赖"。分错会导致共享资源被重复打进多个包——这类问题在报告里表现为"同一资源出现在多个包中"。
第二层:打包规则(PackRule)
打包规则决定"组内的资源怎么切成文件"。这是粒度控制的唯一旋钮,掌握下面这几档就够用:
| 打包规则 | 切法 | 粒度 | 适用 |
|---|---|---|---|
| 按目录打包 | 一个目录一个 bundle,含子目录 | 中 | 默认;目录结构恰好约等于生命周期时用 |
| 按顶层目录打包 | 只按最上层目录切,子目录合并 | 粗 | 目录层级过深、想减少包数 |
| 按文件单独打包 | 一个资源一个 bundle | 最细 | 需要独立热更 / 独立卸载的大资源 |
| 按收集器打包 | 一个收集器一个 bundle | 粗 | 整块内容要整体上下线 |
| 按组打包 | 一个收集器组一个 bundle | 最粗 | 极小项目或强制整包 |
| 按原始文件打包 | 原始文件各自成包、不改名 | 最细 | 视频、下载器管理的二进制 |
| 按着色器打包 | Shader 集中到一个包 | 特例 | 配合自动收集 Shader |
关键认知:打包规则的粒度 = 热更粒度 = 卸载粒度。 一个 bundle 无法只更新其中一部分,也无法只卸载其中一部分。
第三层:共享包与依赖闭包
前两层只决定"哪些资源在一起",第三层决定"它们怎么互相引用"。
- 共享包规则:当一个资源被多个收集器 / 多个包引用时,把它单独抽成一个
share包,其余包以"依赖"的方式指向它。开启后产物里会出现..._share_assets_...这类包名;关闭则共享资源会被并进第一个引用它的包里。 - 依赖闭包:加载一个包时,框架会递归加载它依赖的所有包。所以"加载一个资源的实际代价" = 该资源所在包 + 其依赖闭包的总体积,而不是那个包本身的大小。
这两条一起解释了报告里最有价值的一列——反向依赖(被谁依赖):它是判断共享是否合理的唯一依据。
从产物反推规则
BundleName 是规则的"打印输出"。看名字基本能还原配置:
defaultpackage_share_assets_gamemain_fonts
^^^^^^^^^^^^ ^^^^^ ^^^^^ ^^^^^^^^^^^^^^
包名 共享 目录 资源类型
- 大量
share前缀 → 开了共享包规则。 - 名字里目录层级很深 → 用的是按目录打包。
- 同名资源出现在多个包名里 → 收集器路径重叠或收集类型分错。
常见配置错误
| 症状 | 根因 | 修法 |
|---|---|---|
| 同一资源被打进多个包 | 收集类型分错(该 Depend 的填了 Main),或收集器路径重叠 | 修正收集类型、去重路径 |
| 只有一个引用者的 share 包 | 打包规则切太细,把独占资源也拆了 | 合并回引用方 |
| 包多得离谱 | 用"按文件单独打包"扫了一个大目录 | 换成按目录 / 按顶层目录 |
| 改一处资产,大量包变 hash | 共享包把无关资源捆在一起 | 按生命周期重切共享包 |
粒度:从一个包到每资源一包
一句话定义:粒度就是"一次打包产出的 bundle 有多粗"。它是所有资源问题的总开关——请求数、峰值内存、卸载能力、热更体积,四个指标全部由它决定,而且方向互相冲突。
先明确"请求(Request)“是什么
一次请求 = 加载一个 bundle 文件 = 一次文件 IO(本地)或一次 HTTP GET(远端)。
它和资源数是两个维度:
| 概念 | 单位 | 一个真实项目的量级 | 决定因素 |
|---|---|---|---|
| 资源数 | 个 | 6852 | 工程内容量 |
| 包数 | 个 | 621 | 打包规则 |
| 首次加载请求数 | 次 | ≤ 621 | 包数(已加载的包不重复请求) |
加载 100 个资源,如果它们都在同一个包里,只产生 1 次请求;如果分在 100 个包里,就是 100 次请求。 请求的代价不在"传输”(本地 IO 几乎免费),而在每次请求的固定开销:打开文件、读索引、建 bundle 对象、走一遍依赖解析。
远端场景下请求更贵,因为每个请求都带 HTTP 往返。所以要分清两类病:
| 指标 | 受什么影响 | 该用什么手段治 |
|---|---|---|
| 下载总量 | 包的实际字节数 | 减资源、换压缩格式、提高压缩率 |
| 请求数 | 包的个数 | 合并小包、减少碎片 |
| 首加载延迟 | 请求数 + 压缩格式 | 合并小包、LZ4 |
| 峰值内存 | 最大单包体积 + 压缩格式 | 拆分大包、LZ4 |
两个极端的完整代价
| 维度 | 全部一个包 | 每资源一个包 |
|---|---|---|
| 请求数 | 1 | ≈ 资源数(上例约 6600+) |
| 重复资源 | 无(天然无重复) | 无(但依赖会被拆散) |
| 固定开销 | 最低(只有一份 header / 索引 / TypeTree) | 最高(每包都有一份) |
| 卸载粒度 | 无(要么全在,要么全没) | 每个资源独立 |
| 热更体积 | 全量(改一个字节重下整包) | 最小(只重下改动的那一个) |
| 构建耗时 | 最短 | 最长 |
| 压缩率 | 最高(大文件上下文长) | 最低(小文件压不动) |
| 加载延迟 | 首包最大,之后最省 | 每次都有请求开销 |
| 失败影响面 | 一个包坏了,全游戏不可用 | 单点故障 |
| 适用场景 | 一次性小内容、原型验证 | 大资源按需下载 |
两个极端都能用,但都只在特定场景下才是对的。绝大多数项目要的是中间态。
中间态:按"共载聚类"切
中间态的原则只有一句:让 bundle 的边界等于资源生命周期的边界。
判据是"这些资源会不会在同一时间被同一功能加载、又在同一时间一起不用了"。会,就放一个包;不会,就切开。
| 分层 | 特征 | 典型资源 | 建议粒度 |
|---|---|---|---|
| 常驻层 | 全程不卸载 | 主界面图集、公共 Shader、字体 | 合并成少数几个包 |
| 场景层 | 进出场景 / 功能时整体装卸 | 一个关卡、一个模块的资源 | 一个功能一个包或几个包 |
| 单体层 | 大且按需 | 高清立绘、BGM、视频、大图集 | 一个资源一个包 |
共载聚类为什么是"最优解":它同时压住四个指标——常驻层包数少(请求少)、单体层拆得开(卸载 / 热更粒度细)、同类资源同包(压缩率不塌)、没有跨生命周期捆绑(热更不会误伤)。
判断粒度是否切错的三个信号
- 一个包里的资源,加载时机各不相同 → 切粗了,应该拆。
- 加载一个资源时,顺带拉起了几 MB 的依赖闭包 → 共享包切错了,或该资源没独立。
- 一个包一年都不会单独热更,却因为别的资源变更被重下 → 切细了,应该合。
压缩:Uncompressed / LZMA / LZ4
一句话定义:压缩选项决定 bundle 在磁盘 / 网络上的体积,以及加载时要不要把整个包解到内存——它是"下载体积"和"峰值内存"之间的一个交换。
先分清两层数据
一个 bundle 里其实有两类数据,可达性不同:
| 层 | 内容 | 是否可随机访问 |
|---|---|---|
| 索引层 | 文件头、资源目录、TypeTree、块索引 | 是(映射后可直接读) |
| 数据层 | 每个资源的实际内容 | 取决于压缩格式 |
只有数据层会被"压缩 / 解压"影响,索引层永远要能被随机读。
三种选项
| 选项 | 压缩机制 | 体积 | 加载行为 | 峰值内存 | 适用 |
|---|---|---|---|---|---|
| 不压缩 | 不压缩 | 最大 | 直接映射,懒读取 | 最低 | 需要极速启动、本地已无冗余的内容 |
| LZMA | 流式全量压缩 | 最小 | 整个包一次性解压到内存 | 最高 | 只下载不常驻的内容、包体极度紧张 |
| LZ4(分块) | 按块压缩(Unity 为 128KB / 块) | 中等 | 分块按需解压 | 低 | 绝大多数默认场景 |
LZ4 的关键:分块 + 随机访问
LZ4 在 AB 里是按固定块压缩的。带来的直接结果是:
- 文件可以被映射,只解压你要读的那几块;其它块在磁盘上原样待着。
- 加载一个小资源,不会把整个包解到内存 → 峰值内存 ≈ 该资源大小,而不是包大小。
- 代价是压缩率略低于 LZMA(块与块之间不能共享上下文)。
这也是为什么运行时默认选 LZ4:它在体积、加载速度、峰值内存三者之间给了最稳的组合。
LZMA 的陷阱
LZMA 压缩率最好,但它是流式的:要读包里任何一个资源,必须先把整个包解压出来。
- 一个 50MB 的 LZMA 包,加载第一个资源就要先付出约 50MB 的峰值内存和解压时间。
- 它适合"下完就写盘、之后用 LZ4 版本"这种离线流程,不适合运行时高频加载。
结论:LZMA 省的是流量,付的是内存和首加载延迟。
双重压缩:为什么"再压一次"没用
LZ4 / LZMA 只能压缩还有冗余的数据。而 AB 里的大头——贴图、音频——在导入时已经是压缩格式:
| 资源类型 | 导入后形态 | 再上 LZ4 / LZMA 的收益 |
|---|---|---|
| 贴图(ASTC / ETC2 / BC) | 块压缩,已高度熵编码 | 极低(通常个位数百分比) |
| 音频(Vorbis / MP3 / AAC) | 有损压缩 | 极低 |
| 文本 / JSON / 代码 / Shader | 原始文本 | 高 |
| 网格 / 动画(未压缩) | 二进制 | 中高 |
所以"压缩选项"对包体的提升,主要体现在文本类资源上;指望靠 LZ4 / LZMA 把贴图包瘦一圈是不现实的。贴图包要瘦,只能靠减小分辨率、换压缩格式、拆分不共用的大图集。
与分包的联动
压缩率和粒度是反向的:包切得越细,每个文件越小,LZ4 能利用的上下文越短,压缩率越低。
- 从"一个大包"拆成"一百个小包",总字节数通常会变大(固定开销 + 压缩率下降)。
- 所以拆包是在用"一点体积"换"热更与卸载的粒度",要算总账,不要只盯压缩率。
补充两点:一是更高压缩率的 LZ4 变体(LZ4HC)只提高压缩率、不改变解压速度,但构建更慢,且主流 AB 构建管线默认不暴露;实际项目里换压缩选项的决策点通常只有不压缩 / LZMA / LZ4 三个。二是如果给 bundle 加了加密,就无法再做映射式随机访问,LZ4 的按需解压优势会被抹掉——加密与"低峰值内存"往往要二选一。
加载与内存:加载 Bundle 不等于资源进内存
一句话定义:打开 bundle 只把它的索引映射进内存;只有真正加载过的资源,才占用内存。把 bundle 文件大小等同于内存占用,是资源预算里最常见的错误。
一次加载的五个阶段
| 阶段 | 做什么 | 内存动作 | 量级 |
|---|---|---|---|
| 1. 打开文件 | 从路径加载 bundle | 映射文件、读 header | KB 级 |
| 2. 建索引 | 解析资源目录、依赖表、TypeTree | 常驻少量结构 | KB ~ 低 MB |
| 3. 取资源 | 加载目标资源对象 | 解压 + 反序列化 | 随资源大小 |
| 4. 上传 | 贴图 / 网格上传 GPU | 显存 / GPU 内存 | 随资源大小 |
| 5. 卸载 | 释放 → 引用计数归零 → 强制卸载 | 释放资源 + 包索引 | 归零 |
关键结论:
- 第 1、2 步的成本与包体积无关(除索引 / TypeTree 大小外),更与"包里有几个资源"无关。
- 第 3 步才是有代价的那一步,而它只处理你实际用到的资源。
- 所以:一个 100MB 的包,如果只加载了里面一个 1MB 的贴图,驻留内存约等于 1MB + 索引开销,不是 100MB。
LZ4 让"部分加载"成立
上面的结论只在 LZ4 / 不压缩时成立:
| 格式 | 打开文件之后 | 加载单个资源 |
|---|---|---|
| 不压缩 | 只映射索引 | 直接读,不解压 |
| LZ4 | 只映射索引 | 只解压相关块 |
| LZMA | 整个包已解到内存 | 从内存里取 |
这是上一节"运行时默认选 LZ4"的另一个理由:只有 LZ4 能让"大包 + 小用量"不付出大内存。
卸载是"引用计数 + 强制清对象"
- 每次加载资源都会增加引用计数;释放时减少计数。
- 计数归零后,框架才真正卸载:YooAsset 的做法是调用
AssetBundle.Unload(true)。 Unload(true)的语义是"卸载这个包,并销毁其中所有已加载的对象";相对地Unload(false)会保留对象但让它们失去所属包,容易造成泄漏与悬空引用,实践中基本不用。- 因此"资源为什么还在内存里"通常只有两个答案:引用计数没归零,或者有别的对象在引用它(用 Memory Profiler 查引用链)。
文件体积 ≠ 驻留内存
三个数字必须分开记:
| 数字 | 含义 | 怎么拿 |
|---|---|---|
| 磁盘 / 下载体积 | bundle 文件大小 | 构建报告 |
| 峰值内存 | 加载瞬间的最高占用 | Profiler 抓峰值 |
| 驻留内存 | 稳定状态下的常驻占用 | Memory Profiler 快照 |
优化内存看后两个,优化流量看第一个。 一个包体积很大,可能驻留内存为零(没用到);一个包体积很小,也可能因为反复加载—卸载造成抖动。
热更代价:改一个资源要重下多少
一句话定义:热更是整个 bundle 文件的替换,不是"资源级增量"。改一个资源,代价是它所在 bundle 的完整体积。
为什么是整包替换
清单里 bundle 的物理名是 BundleName_HashName,Hash 由包内容算出:
defaultpackage_uispriteatlas_a1b2c3d4....bundle
^^^^^^^^ 内容变 → 这里变 → 文件名变
- 文件名含 Hash 是为了让 CDN 上"新旧内容共存"、可灰度、可回滚。
- 但代价是:没有任何二进制差分。只要包内容变了,就是换一个全新的文件,客户端要下完整的新文件。
一个真实的反例
在真实项目里出现过一次典型的误判,值得单独记账:
| 项 | 数字 |
|---|---|
图集包 uispriteatlas 体积 |
18.24 MB,装了 53 张图集 |
| 打包规则 | 按目录打包(53 张图集同属一个目录 → 一个包) |
| 只改其中 1 张图集 | 该包 Hash 变 → 重下 18.24 MB |
| 误判 | 曾按"数据库平均包体积"估成约 2MB |
教训:估热更量必须查目标资源所在包的实际大小,不能用平均值、更不能按资源本身大小估。资源 50KB,不代表热更 50KB。
拆包后的账
把图集包从"按目录打包"改成"按文件单独打包":
| 方案 | 包数 | 单包平均 | 改一张图集的重下量 |
|---|---|---|---|
| 现状(按目录) | 1 | 18.24 MB | 18.24 MB |
| 拆开(按文件) | 53 | 约 350 KB | 约 0.1 ~ 2 MB(按该图集大小) |
| 收益 | — | — | 降低约 90%+ |
这就是"粒度 = 热更粒度"的直接体现:同样的资源,只是换了打包规则,单次热更量差一个数量级。
但不要为了热更无限拆细
拆细的隐性代价必须一起算:
| 代价 | 说明 |
|---|---|
| 体积变大 | 包多了,固定开销与压缩率损失累加 |
| 请求变多 | 每个包一次 IO,首加载更碎 |
| 清单变大 | 包数直接放大清单条目 |
| 构建变慢 | 包数影响构建与缓存命中 |
判据:只有当"这个资源的独立热更频率明显高于周围资源"时,拆开才划算。会一起改的放一起,不会一起改的分开——这是热更粒度与共载聚类之间唯一的冲突点,按发布节奏拍板。
运行时怎么观测 Bundle
静态报告(构建产物)能回答"包里有什么",但回答不了"运行时到底谁被加载了、谁在占内存、谁卸不掉"。这一节给四种手段。
手段一:框架自带的 Bundle 调试器(首选)
YooAsset 在调试构建下会自动挂载一个运行时调试组件(RemoteDebuggerInRuntime),把运行中包的数据回传给编辑器窗口 YooAsset/AssetBundle Debugger。它有三个页签:
| 页签 | 回答什么问题 | 关键字段 |
|---|---|---|
| 资源列表(Asset List) | 这个资源被谁加载、加载多久 | 加载耗时、所属包、被哪些场景 / 面板引用 |
| 包列表(Bundle List) | 这个包在不在内存、被谁引用 | 引用计数、状态(活跃 / 空闲)、被哪些包依赖 |
| 操作列表(Operation List) | 正在进行的加载任务 | 任务状态、耗时 |
用法要点:
- 窗口上有单次采样 / 自动采样:排查用单次,长期观察用自动。
- 看"卸载不掉":在包列表里找引用计数 > 0 但业务上已关闭的包,再顺着"被谁依赖"往上找。
- 看"内存莫名高":在资源列表按加载耗时 / 大小排序,找比预期大的资源。
- 它只统计框架加载的资源,不覆盖
Resources等直接加载的对象——不要拿它当唯一真相。
手段二:Unity Profiler
看引擎层的真实占用:
- Memory 模块 → 详细视图 → Assets 分组,能看到 AssetBundle(容器本身)、SerializedFile(包索引)、Texture2D / Mesh 等具体对象。
- 用来回答:“内存是花在容器上,还是花在资源对象上。” 容器大 = 包多 / 包大;对象大 = 资源本身大。
手段三:Memory Profiler 包
com.unity.memoryprofiler 适合查驻留与泄漏:
- 抓快照后能看到进程里所有已加载的 AB、每个包内的对象,以及引用链(谁在引用导致卸不掉)。
- Profiler 看"有多少",Memory Profiler 看"为什么还在"。
手段四:API 兜底(线上 / 自动化)
需要写日志或在无窗口环境排查时,用代码枚举:
foreach (var bundle in AssetBundle.GetAllLoadedAssetBundles())
{
Debug.Log($"{bundle.name}");
}
配合框架自己的诊断开关,可以把"当前加载了哪些包"打成日志带到线上。
四种手段怎么选
| 手段 | 视角 | 强项 | 局限 |
|---|---|---|---|
| Bundle 调试器 | 业务层 | 引用计数、依赖关系、加载耗时 | 只覆盖框架加载 |
| Profiler | 引擎层 | 容器 vs 对象的真实内存 | 引用关系弱 |
| Memory Profiler | 对象层 | 引用链、泄漏定位 | 需抓快照,较重 |
| API / 日志 | 脚本层 | 可自动化、可线上 | 信息最少 |
推荐顺序:调试器定位"哪个包"→ Profiler 定位"哪类内存"→ Memory Profiler 定位"为什么没释放"。
报告驱动的优化与 ROI
一句话定义:优化不该靠"感觉包大",而该靠构建报告里的几个可量化指标分诊,再用 ROI 决定先做哪个。
先看指标:报告里哪几列值得盯
| 指标 | 健康信号 | 危险信号 | 说明 |
|---|---|---|---|
| 重复资源 | 0 | > 0 | 同一资源进了多个包,白占体积 |
| 单包体积 | 分布平滑 | 存在超大包(如 > 5MB) | 首加载与热更的痛点 |
| 包数 / 碎片 | 无大量极小包 | 大量 < 10KB 的包 | 请求数与固定开销浪费 |
| 反向依赖数 | 共享包被很多包依赖 | 共享包只有 1 个引用者 | 拆包规则切错的信号 |
| 依赖闭包体积 | 加载一个资源拉不动多少 | 加载一个小资源拉起几 MB | 共享包粘得太死 |
| 环形依赖 | 无 | 存在 | 加载死锁风险 |
再分诊:问题出在哪一层
| 现象 | 大概率问题层 | 对应动作 |
|---|---|---|
| 总体积太大 | 内容本身 | 减资源、换压缩格式、去无用资源 |
| 单包特别大 | 打包规则太粗 | 按文件 / 按功能拆 |
| 包数特别多、单包特别小 | 打包规则太细 | 合并、按顶层目录 |
| 重复资源 > 0 | 收集类型 / 路径重叠 | 修正收集器配置 |
| 加载一个小资源很慢 | 共享包粘死 | 重切共享包、拆分闭包 |
| 一个包有一堆引用者却很小 | 正常(公共资源) | 保持,但确认它常驻 |
排序:用 ROI 决定先做谁
ROI(投入产出比)= 收益 ÷ 投入。 资源优化里三项都要量化:
| 项 | 怎么量 |
|---|---|
| 收益 | 省下的字节数 / 请求数 / 峰值内存 / 单次热更量 |
| 投入 | 改动范围(配置 or 代码 or 架构)、验证成本 |
| 风险 | 是否影响加载时序、是否引入新依赖、是否要回归测试 |
把它套进候选优化项,大致能排出优先级:
| 优化项 | 收益 | 投入 | 风险 | 优先级 |
|---|---|---|---|---|
| 关闭 TypeTree 写入 | 每包固定开销下降,聚沙成塔 | 一个开关 | 低(需回归) | 高 |
| 字体 ttf 裁剪子集 | 单个超大字库包显著下降 | 中(要定字符集) | 低 | 高 |
| 图集按文件拆包 | 单次热更 18MB → < 2MB | 配置 | 低 | 高 |
| BGM 按需解绑 | 压低峰值内存与首加载 | 中(改加载时机) | 中 | 中高 |
| 小包合并 | 请求数与固定开销下降 | 中(需核对共载) | 中 | 中 |
| 环形依赖治理 | 消除死锁风险 | 高(改架构) | 高 | 低(但要做) |
排序原则:先做"一个开关换全局收益"的(TypeTree),再做"针对单个巨型包"的(字体、图集),最后才是需要动架构的(依赖治理)。
验收:优化要有对照数
每项优化都应在同一版本对比下给出:
- 总包数、总体积 变化多少
- 超大包 / 碎片包 数量变化
- 单次典型热更量 变化
- 首加载请求数、峰值内存 变化
- 构建耗时 变化(不能让构建时间失控)
没有对照数的优化,不算完成。
怎么查:实操路径
名词认清了,接下来是"遇到问题怎么定位"。这一节给的是从现象到文件的路径。
想知道"某个资源在哪个包里"
- 打开构建报告,在
AssetInfos里搜资源路径 → 拿到所属BundleName。 - 在
BundleInfos里查这个BundleName→ 看它依赖谁、被谁依赖、包含哪些资源。 - 只有拿不到报告时,才回到原始清单里做下标映射。
想知道"这个包为什么这么大"
- 看报告
Summary的体积分布,先确定是"包多"还是"单包大"。 - 单包大 → 在
BundleInfos里看它的BundleContents是否有意外的大资源(图集、音频、视频)。 - 包多 → 检查收集器规则是否有重叠;
IndependAssets是否有本该合并的资源。
想知道"运行时实际用的是哪个版本"
| 查什么 | 怎么查 |
|---|---|
| 当前资源版本 | 读沙盒里的版本记录文件;或框架的 GetPackageVersion() |
| 清单到底加载的是哪一份 | 对比内置清单与沙盒清单的版本号 |
| 资源是从内置还是远端来的 | 看缓存命中日志;或临时禁用远端,观察是否还能加载 |
| Bundle 的真实内容 | 用二进制工具列出 Bundle 内的资源名清单,与报告对照 |
一个通用原则
所有资源问题的证据链都必须落在"文件 + 版本"上。 只看日志会出现"日志说下载成功了,但加载的是旧资源"这种无法收敛的状态;必须同时确认哪一份清单、哪一个版本、哪个物理文件。这也是前面反复强调"分不清版本号就永远对不上"的原因。
常见误解
| 以为是 A | 实际是 B | 后果 |
|---|---|---|
| Catalog 就是 Manifest | Catalog 是"目录"的总称,Manifest 是其中"资源映射"这一级 | 排查时找错文件 |
| Manifest 只有一个 | 每个 Package、每个版本各一份 | 拿错版本的清单去解释报错 |
| BundleID 是稳定 ID | 它是同一份清单内数组的下标 | 持久化后跨版本必然失效 |
| DLC 是技术名词 | 它是发行概念,技术侧只有"按需下载一组资源" | 在框架文档里找不到答案 |
| DLC 下载完了就等于有权访问 | 可达性与授权是两道独立的门 | 权限绕过,未购买用户加载到内容 |
| 热更 DLL 和 AOT 补元数据是一回事 | 分工完全不同,后者只在特定条件下才需要更新 | 热更后局部功能崩溃 |
| 代码热更有独立通道 | 热更 DLL 本身就是资源,走同一条链路 | 设计出多余的第二套版本体系 |
| Bundle 压缩能省内存 | 容器压缩省磁盘与下载,不省运行时内存 | 内存优化方向选错 |
| 覆盖安装后一切从头开始 | 沙盒被清 → 先回退到内置清单再更新 | “覆盖安装后必须重启一次"类问题 |
| 版本号可以比大小 | 它用于寻址目录,只该做相等判断 | 灰度与回滚逻辑写歪 |
| 加载 Bundle 就等于资源进内存 | 只映射索引;被加载过的资源才进内存 | 内存预算严重高估 |
| 资源数 = 请求数 | 请求数由包数决定,已加载的包不重复请求 | 高估或低估 IO |
| 包拆得越细越好 | 每包有固定开销,压缩率随包数下降 | 包数爆炸、加载反而更慢 |
| 热更会下"改动的资源” | 实际是整个 bundle 文件替换(无差分) | 热更量被低估数十倍 |
| 已压缩资源再压一次能大省 | ASTC / 音频已高度压缩,再压收益极低 | 压缩收益误判 |
| 按目录打包就是最优粒度 | 粒度应由生命周期决定,目录只是巧合对齐 | 分包切错 |
| 共享包越多越好 | 只有 1 个引用者的共享包是纯浪费 | 多余 IO 与清单开销 |
| 热更量可以按平均包体积估 | 必须查目标资源所在包的真实大小 | 方案评估失真 |
落地清单
接入或接手一套资源热更框架时,把下面这些问题问清楚,基本就能对齐所有名词:
- 这个项目的资源版本号存在哪个文件?谁负责写入?写入时机是在下载前还是下载后?
- 一共划分了哪些 Package?切分维度是什么?有没有跨包引用?
- Catalog 的入口文件是哪一个?它和资源映射清单分别叫什么名字?
- 清单是否带 hash 摘要?更新检查是先比摘要还是直接下清单?
- BundleID 是下标还是稳定标识?项目里有没有把它当稳定 ID 用错的地方?
- 有没有可读的构建报告?
ReferenceBundles是否用于审查共享包? - 是否存在只有一个引用者的共享包?(拆包规则切错的信号)
- DLC / 活动内容是用独立 Package 落的,还是混在主包里用标签控制?
- DLC 的授权校验在哪里做?有没有"资源可达即视为有权"的隐患?
- 三个资源位置(内置 / 沙盒 / 远端)的清理策略分别是什么?覆盖安装会怎样?
- 热更 DLL 与 AOT 补充元数据是否都进了清单?更新条件是否明确?
- 有没有回滚路径?回滚时是换清单还是换版本号?
- 分包边界按什么维度切?(目录 / 生命周期 / 功能)它和"会不会一起加载、一起卸载"一致吗?
- 是否存在没有任何独立热更 / 卸载需求却单独成包的资源,或跨生命周期被捆在一起的包?
- 压缩选项是逐包有依据,还是全局一刀切?有没有对已压缩资源(贴图 / 音频)抱有不切实际的体积预期?
- 是否开启了共享包规则?有没有只有 1 个引用者的共享包?
- 是否关闭了 TypeTree 写入?关闭后有没有做过反射 / 反序列化的回归?
- “加载一个资源"的依赖闭包体积上限是多少?有没有超标的包?
- 单次典型热更量是多少?有没有超大 bundle 让它变成全量重下?
- 是否存在环形依赖?
- 有没有可用的运行时观测手段(Bundle 调试器 / Profiler / Memory Profiler / 日志)?
- 每项优化的验收对照数(体积、请求数、峰值内存、热更量、构建耗时)是否齐备?
一句话总结
资源热更的名词可以归到四层上:Package 是边界,Catalog / Manifest 是地图,Bundle 是实物,DLC 是需求。
- Catalog 和 Manifest 不是同义词:Catalog 是"目录”,Manifest 是目录里"资源映射"那一级;框架不同名字还不一样,先确认对方说的是哪一层再讨论。
- BundleID 是下标不是 ID,所有"跨版本对不上"的问题多半源于此。
- DLC 不是技术概念,技术侧只有"按需下载一组资源";它真正难的地方在授权、卸载、回滚、依赖交叉,而不是下载。
- 三套版本号必须分开看,代码热更不是第四套通道,它本身就是资源。
- 所有结论都要落到文件 + 版本 + 物理路径三件事上,只凭日志无法收敛。
机制层:粒度是一切的开关
在名词之下,真正决定体验的是粒度与压缩这两个旋钮。 记住这几条:
- 粒度决定四个互相冲突的指标:请求数、峰值内存、卸载粒度、热更体积。最优解不是某个极端,而是共载聚类——让 bundle 边界等于生命周期边界。
- “请求”= 一次 bundle 文件加载,数量由包数决定,不由资源数决定;“请求多"和"包大"要分开治。
- 加载 bundle ≠ 资源进内存:只映射索引,被加载过的资源才驻留;引用计数归零的强制卸载才真正释放。
- 压缩只在"体积 vs 峰值内存"之间选:运行时默认 LZ4(分块、可随机访问、低峰值);LZMA 省流量但抬峰值;已压缩资源再压几乎无效。
- 热更是整包替换,不是资源级增量:估热更量必须查目标包的真实体积;拆包能把单次热更量降一个数量级,但要用请求数与压缩率还账。
- 先量化再排序:报告指标分诊 → ROI 排序 → 每项优化给出对照数;没有对照数的优化不算完成。
相关阅读:Unity 开发高级/资深 03:资源管理与热更新、Unity 图片资源格式与内存、Unity 开发高级/资深 技能总览。
评论