Unity AssetBundle商业化项目资源方案教程

目录
- 背景与整体架构
- 核心数据结构
- 配置层:定义资源规则
- [编辑器打包管线:从资源到 Bundle](#编辑器打包管线:从资源到 Bundle)
- [4.5 BuildBundlePack ------ 打包 VFS](#4.5 BuildBundlePack —— 打包 VFS)
- [4.5.1 Patch VFS 模式](#4.5.1 Patch VFS 模式)
- 运行时管线:从下载到加载
- [5.3 VersionsRequest ------ 五阶段流程](#5.3 VersionsRequest —— 五阶段流程)
- [5.7 运行时 VFS 读取(多包支持)](#5.7 运行时 VFS 读取(多包支持))
- [5.8 分块并行下载 ChunkedDownloadRequest](#5.8 分块并行下载 ChunkedDownloadRequest)
- 对象池与引用计数
- 面试高频考点
一、背景与整体架构
1.1 为什么要自己写资源方案?
Unity 自带的 AssetBundle 机制有以下痛点:
- 分包策略不灵活:默认的打包方式对大型项目不够友好,容易产生大量碎片 bundle 或超大 bundle
- 没有版本管理:需要自己维护资源版本号、增量更新逻辑
- 没有下载管理:Unity 不提供断点续传、并发下载、重试机制
- 没有依赖优化:公共资源容易被重复打包到多个 bundle 中
- 没有 VFS 支持:大量小文件在网络传输、磁盘 IO 上效率低下
xasset 就是为了解决这些问题而设计的完整资源管理方案。
1.2 整体架构总览
┌─────────────────────────────────────────────────────────┐
│ 编辑器层 (Editor) │
│ ┌──────────┐ ┌──────────┐ ┌───────────────────────┐ │
│ │ Settings │ │ Build │ │ Builder + Tasks │ │
│ │ (配置) │ │ (分组定义) │ │ (打包管线) │ │
│ └──────────┘ └──────────┘ └───────────────────────┘ │
├─────────────────────────────────────────────────────────┤
│ 构建产物 │
│ ┌──────────┐ ┌──────────┐ ┌───────────────────────┐ │
│ │ .bundle │ │bundles. │ │ versions.json │ │
│ │ (Asset │ │pak(VFS) │ │ updateinfo.json │ │
│ │ Bundle) │ │ │ │ playerassets.json │ │
│ └──────────┘ └──────────┘ └───────────────────────┘ │
├─────────────────────────────────────────────────────────┤
│ 运行时层 (Runtime) │
│ ┌──────────┐ ┌──────────┐ ┌───────────────────────┐ │
│ │ Assets │ │Downloader│ │ BundlePack (VFS读取) │ │
│ │ (入口) │ │ (下载) │ │ │ │
│ └──────────┘ └──────────┘ └───────────────────────┘ │
│ ┌──────────┐ ┌──────────┐ ┌───────────────────────┐ │
│ │ Bundle │ │ Asset │ │ Dependencies │ │
│ │Request │ │ Request │ │ (依赖管理) │ │
│ └──────────┘ └──────────┘ └───────────────────────┘ │
└─────────────────────────────────────────────────────────┘
核心流程:
配置资源分组 → 收集资源 → 优化依赖 → 打包 AssetBundle
→ 打包 VFS(bundles.pak) → 生成版本清单 → 上传服务器
→ 客户端初始化 → 检查更新 → 下载资源 → 加载资源
1.3 目录结构
Assets/xasset/
├── Config/ # 配置文件 (ScriptableObject)
│ ├── Settings.asset # 主配置(PlayerConfig + BundleConfig)
│ ├── PackedAssets.asset # 打包缓存
│ └── Builds/
│ └── Arts.asset # 一个 Build 定义
├── Editor/ # 编辑器代码
│ ├── Settings.cs # 配置类定义 + bundle 命名规则
│ ├── MenuItems.cs # 菜单项
│ ├── Build/
│ │ ├── Build.cs # Build ScriptableObject
│ │ ├── Builder.cs # 构建主入口
│ │ ├── BuildCache.cs # 构建缓存
│ │ ├── BuildChanges.cs # 变更记录
│ │ └── Task/
│ │ ├── BuildTask.cs
│ │ ├── CollectAssets.cs # 收集资源
│ │ ├── OptimizeDependencies.cs # 优化依赖
│ │ ├── SaveBuildAssets.cs # 保存构建数据
│ │ ├── LoadBuildAssets.cs # 加载缓存
│ │ ├── BuildBundles.cs # 打包 AssetBundle
│ │ ├── BuildBundlePack.cs # 打包 VFS
│ │ └── BuildVersions.cs # 生成版本清单
│ ├── Pipeline/
│ │ ├── IBuildPipeline.cs
│ │ ├── DefaultBuildPipeline.cs
│ │ ├── IAssetBundleManifest.cs
│ │ └── DefaultAssetBundleManifest.cs
│ ├── Handlers/
│ │ ├── EditorAssetHandler.cs
│ │ └── EditorSceneHandler.cs
│ ├── Processor/
│ │ └── ReferencesPostprocessor.cs
│ └── GUI/Editors/
│ └── SettingsEditor.cs
├── Runtime/ # 运行时代码
│ ├── API/
│ │ ├── Assets.cs # 运行时总入口
│ │ ├── Asset.cs # 资源加载 API
│ │ ├── Scene.cs # 场景加载 API
│ │ └── Requests/
│ │ ├── InitializeRequest.cs # 初始化
│ │ ├── GetUpdateInfoRequest.cs # 更新检查
│ │ ├── GetDownloadSizeRequest.cs # 计算下载大小
│ │ ├── VersionsRequest.cs # 版本下载
│ │ ├── BundleRequest.cs # bundle 加载
│ │ ├── AssetRequest.cs # 资源加载
│ │ ├── SceneRequest.cs # 场景加载
│ │ ├── Dependencies.cs # 依赖管理
│ │ ├── ReferencesCounter.cs # 引用计数
│ │ ├── ActionRequest.cs # 通用操作
│ │ ├── ReloadRequest.cs # 热重载
│ │ ├── RemoveRequest.cs # 移除资源
│ │ └── Handlers/
│ │ ├── RuntimeBundleHandler.cs # bundle 加载策略
│ │ ├── RuntimeAssetHandler.cs # 资源加载策略
│ │ └── RuntimeSceneHandler.cs # 场景加载策略
│ ├── Config/
│ │ ├── Manifest.cs # 资源清单数据结构
│ │ ├── Versions.cs # 版本管理
│ │ ├── PlayerAssets.cs # 安装包资源配置
│ │ ├── Downloadable.cs # 可下载基类
│ │ └── UpdateInfo.cs # 更新信息
│ └── Network/
│ ├── BundlePack.cs # VFS 读取
│ ├── Downloader.cs # 下载管理器
│ ├── DownloadRequest.cs # 下载请求
│ ├── DownloadRequestBase.cs
│ ├── DownloadRequestBatch.cs
│ ├── DownloadHandler.cs / DownloadHandlerUWR.cs
│ ├── DownloadContent.cs # 下载内容描述
│ └── DownloadErrors.cs
└── Samples/ # 示例代码
└── Scritps/
├── Startup.cs
├── CheckForUpdates.cs
├── Download.cs
├── LoadAsset.cs
└── ...
二、核心数据结构
在深入代码之前,先搞清楚几个核心概念。
2.1 Manifest(资源清单)
Manifest 是一个 ScriptableObject,是整个方案的"地图":
csharp
// Assets/xasset/Runtime/Config/Manifest.cs
public class Manifest : ScriptableObject
{
public string[] dirs; // 所有资源目录
public ManifestAsset[] assets; // 所有资源文件
public ManifestBundle[] bundles; // 所有 bundle 元信息
public ManifestGroup[] groups; // 资源分组(用于分包下载)
}
ManifestAsset:描述一个资源文件
csharp
public class ManifestAsset
{
public string name; // 文件名
public int[] deps; // 依赖的其他 asset id
public int bundle; // 所属 bundle 在 bundles[] 中的索引
public int dir; // 所属目录在 dirs[] 中的索引
public AddressMode addressMode; // 寻址模式
public string path; // 完整路径(反序列化时拼装)
public Manifest manifest; // 所属 Manifest 引用
}
ManifestBundle:描述一个 AssetBundle 文件
csharp
public class ManifestBundle : Downloadable
{
public string name; // bundle 逻辑名,如 "shaders_bundle"
public string hash; // 内容 hash
public ulong size; // 文件大小
public string file; // 实际文件名 = hash + ".bundle"
public int[] deps; // 依赖的 bundle id 列表
}
2.2 Version 与 Versions
csharp
// 单个构建单元的版本
public class Version : Downloadable
{
public int ver; // 版本号(递增)
public string name; // 名称,对应 Build 名称
public Manifest manifest; // 运行时加载的 Manifest 实例
public string GetFilename() =>
$"{name.ToLower()}_{hash}.json"; // 文件名 = 名称 + hash
}
// 所有版本的集合
public class Versions : ScriptableObject
{
public long timestamp; // 时间戳,用于比对更新
public List<Version> data; // 所有版本列表
public bool IsNew(Versions v) => timestamp > v.timestamp;
}
2.3 PlayerAssets(安装包资源配置)
csharp
public class PlayerAssets : ScriptableObject
{
public PlayerAssetsSplitMode splitMode; // 分包模式
public bool updatable; // 是否可更新
public string version; // 应用版本
public string updateInfoURL; // 更新信息地址
public string downloadURL; // 下载地址
public List<string> data; // 包内资源 hash 列表
}
2.4 枚举类型一览
| 枚举 | 选项 | 含义 |
|---|---|---|
| BundleMode | PackTogether | 整组合一包 |
| (打包模式) | PackByFile | 每文件一包 |
| PackByFolder | 按目录打包 | |
| PackByFolderTopOnly | 按顶层目录 | |
| PackByRaw | 保留路径打包 | |
| PackByFileWithoutExtension | 去掉扩展名 | |
| PackByCustom | 自定义 | |
| AddressMode | LoadByPath | 完整资源路径加载 |
| (寻址模式) | LoadByName | 文件名加载 |
| LoadByNameWithoutExtension | 文件名(无后缀) | |
| LoadByDependencies | 仅作依赖,不生成地址 | |
| DeliveryMode | InstallTime | 安装包内 |
| (交付模式) | FastFollow | 启动后即下载 |
| OnDemand | 按需下载 | |
| PlayerAssetsSplitMode | IncludeAllAssets | 包内包含所有资源 |
| (安装包分包) | IncludeInstallTimeAssetsOnly | 仅 InstallTime |
| ExcludeAllAssets | 包内不含资源 |
三、配置层:定义资源规则
3.1 Settings ------ 一切配置的起点
Settings 是一个 ScriptableObject,包含两个核心配置块。
PlayerConfig(运行时配置):
csharp
// Assets/xasset/Editor/Settings.cs
public class PlayerConfig
{
public PlayerAssetsSplitMode assetsSplitMode; // 安装包分包模式
public bool updatable; // 是否开启更新
public string updateInfoURL; // 更新信息 URL
public string downloadURL; // 资源下载 URL
public byte maxDownloads = 5; // 最大并发下载
public byte maxRetryTimes = 3; // 最大重试次数
public byte maxRequests = 10; // 单帧最大请求处理
public bool autoslicing = true; // 自动切片(防卡顿)
public float autoslicingTimestep; // 切片时间步长
public float autoreleaseTimestep = 0.7f; // 自动回收间隔
}
BundleConfig(打包配置):
csharp
public class BundleConfig
{
public bool checkReference = true; // 打包前检查引用
public BuildAssetBundleOptions options; // Unity 打包选项
public bool packByFileForAllScenes = true; // 场景按文件打包
public bool packTogetherForAllShaders = true; // Shader 全归一包
public string extension = ".bundle"; // bundle 扩展名
public List<string> shaders; // Shader 后缀列表
public List<string> excludeFiles; // 排除文件列表
}
面试题:为什么 Shader 要单独打包到一起?
因为 Unity 的 Shader 在打包时有"变体收集"机制。如果 Shader 分散在多个 bundle 中,运行时可能出现 Shader 变体丢失导致渲染效果错误。将所有 Shader 归到一个 bundle 可以保证变体被完整收集。
3.2 分包策略 ------ 7 种 BundleMode
Settings.PackAsset() 是分包的核心方法,它接收一个 BuildEntry,返回该资源所属的 bundle 名称:
csharp
public static string PackAsset(BuildEntry entry)
{
var assetPath = entry.asset;
var groupName = entry.owner.name;
var bundle = groupName.ToLower();
switch (entry.bundleMode)
{
case BundleMode.PackTogether:
bundle = groupName; // 整个组打一包
break;
case BundleMode.PackByFolder:
bundle = GetDirectoryName(assetPath); // 按父目录打包
break;
case BundleMode.PackByFile:
bundle = assetPath; // 每个文件一包
break;
case BundleMode.PackByFolderTopOnly:
// 只取顶层目录
bundle = GetTopLevelDir(assetPath, entryPath);
break;
case BundleMode.PackByRaw:
bundle = assetPath; // 保留路径
break;
case BundleMode.PackByFileWithoutExtension:
bundle = assetPath.Replace(ext, ""); // 去掉扩展名
break;
case BundleMode.PackByCustom:
bundle = CustomPacker?.Invoke(entry); // 自定义规则
break;
}
return PackAsset(assetPath, bundle);
}
然后 PackAsset(assetPath, bundle) 会做特殊处理和格式化:
csharp
public static string PackAsset(string assetPath, string bundle)
{
// 1. Shader 特殊处理:全部合入 "shaders" bundle
if (settings.packTogetherForAllShaders && settings.shaders.Exists(assetPath.EndsWith))
bundle = "shaders";
// 2. 场景特殊处理:每个场景单独一包
if (settings.packByFileForAllScenes && assetPath.EndsWith(".unity"))
bundle = assetPath;
// 3. 格式化:去空格、替换特殊字符、转小写
bundle = bundle.Replace(" ", "")
.Replace("/", "_")
.Replace("-", "_")
.Replace(".", "_")
.ToLower();
// 4. 添加扩展名
return $"{bundle}{settings.extension}";
}
面试题:这 7 种分包策略分别适用什么场景?
模式 适用场景 PackTogether 小模块的全部资源,如 UI 界面 PackByFile 大资源、场景,按需加载 PackByFolder 按功能模块分目录管理 PackByFolderTopOnly 只拆一层,子目录仍合并 PackByRaw 保留原始路径结构 PackByFileWithoutExtension 需根据路径無后缀加载 PackByCustom 完全自定义
3.3 Build 资产 ------ 分组的载体
Build ScriptableObject 是打包的"任务单":
csharp
// Assets/xasset/Editor/Build/Build.cs
public class Build : ScriptableObject
{
public int id;
public bool enabled = true; // 是否参与构建
public BuildParameters parameters; // 构建参数
public BuildGroup[] groups; // 资源分组列表
}
public class BuildGroup
{
public string name = "New Group";
public int id;
public bool enabled = true;
public DeliveryMode deliveryMode = DeliveryMode.OnDemand;
public BuildEntry[] assets; // 组内资源
public string desc;
}
public class BuildEntry
{
public string asset; // 资源路径(文件或文件夹)
public BundleMode bundleMode = BundleMode.PackTogether;
public AddressMode addressMode = AddressMode.LoadByPath;
public string filter; // 文件夹资源的搜索 filter
public string bundle; // 计算后的 bundle 名
}
小白注意 :在 Unity 编辑器中,你可以创建多个
.asset文件(如Arts.asset、UI.asset),每个文件就是一个Build,里面可以定义多个Group,每个Group下可以拖入资源文件和文件夹。这相当于你的"资源分包方案"。
四、编辑器打包管线:从资源到 Bundle
4.1 Builder ------ 构建总指挥
Builder.BuildBundles() 是整个打包流程的入口:
csharp
// Assets/xasset/Editor/Build/Builder.cs
public static void BuildBundles(params Build[] builds)
{
BuildBundlesInternal(false, builds);
}
private static void BuildBundlesInternal(bool withCache, params Build[] builds)
{
// 1. 过滤出所有 enabled 的 Build,按 id 排序
// 2. 检查引用关系(checkReference)
// 3. 创建输出目录
// 4. 对每个 Build 执行任务管线
foreach (var asset in assets)
{
var jobs = new List<IBuildStep>();
if (withCache) // 缓存模式:跳过收集和优化
{
jobs.Add(new LoadBuildAssets()); // 从缓存加载
jobs.Add(new BuildBundles()); // 直接打包
jobs.Add(new BuildBundlePack()); // 打包 VFS
jobs.Add(new BuildVersions()); // 生成版本
}
else // 全量模式:完整管线
{
jobs.Add(new CollectAssets()); // 收集资源
if (parameters.optimizeDependencies)
jobs.Add(new OptimizeDependencies()); // 优化依赖
jobs.Add(new SaveBuildAssets()); // 保存构建数据
jobs.Add(new BuildBundles()); // 打包
jobs.Add(new BuildBundlePack()); // 打包 VFS
jobs.Add(new BuildVersions()); // 生成版本
}
var task = BuildTask.StartNew(build, jobs.ToArray());
// ...
}
}
重点:
- 每个
Build独立执行一条任务管线 withCache=true时跳过收集和依赖优化,适合增量构建- 任务按顺序执行,前一个失败则后续不执行
4.2 CollectAssets ------ 资源收集
csharp
// Assets/xasset/Editor/Build/Task/CollectAssets.cs
public class CollectAssets : IBuildStep
{
public void Start(BuildTask task)
{
foreach (var group in task.groups)
{
if (!group.enabled) continue;
foreach (var entry in group.assets)
{
if (Directory.Exists(entry.asset))
{
// 如果是文件夹,遍历子资源
foreach (var child in Settings.GetChildren(entry))
{
child.owner = entry.owner;
child.bundleMode = entry.bundleMode;
child.addressMode = entry.addressMode;
child.parent = entry.asset;
children.Add(child);
}
}
else
{
// 如果是文件,直接添加
var asset = Settings.GetPackedAsset(entry.asset);
asset.owner = entry.owner;
asset.bundleMode = entry.bundleMode;
asset.addressMode = entry.addressMode;
asset.parent = Settings.GetDirectoryName(asset.asset);
if (task.AddAsset(asset))
asset.bundle = Settings.PackAsset(asset);
// ↑ 核心:计算每个资源属于哪个 bundle
}
}
}
// 处理子资源
foreach (var asset in children)
if (task.AddAsset(asset))
asset.bundle = Settings.PackAsset(asset);
}
}
关键行为:
- 遍历所有
Group和BuildEntry - 对文件夹资源:调用
Settings.GetChildren()使用AssetDatabase.FindAssets搜索 - 对每个资源调用
Settings.PackAsset()分配 bundle - 通过
task.AddAsset()去重,避免同一资源出现在多个 bundle
Settings.GetChildren() 的实现:
csharp
public static IEnumerable<string> GetChildren(BuildEntry entry)
{
var path = entry.asset;
var children = new List<string>();
var guilds = AssetDatabase.FindAssets(entry.filter, new[] { path });
var set = new HashSet<string>();
var exclude = GetDefaultSettings().bundle.excludeFiles;
foreach (var guild in guilds)
{
var child = AssetDatabase.GUIDToAssetPath(guild);
// 过滤:排除目录、排除文件 (.cs, .dll 等)、自定义过滤
if (string.IsNullOrEmpty(child) || exclude.Exists(child.EndsWith)
|| Directory.Exists(child) || set.Contains(child))
continue;
set.Add(child);
if (CustomFilter != null && !CustomFilter(child))
continue;
children.Add(child);
}
return children;
}
4.3 OptimizeDependencies ------ 依赖优化
这是防止资源被重复打包的关键步骤:
csharp
// Assets/xasset/Editor/Build/Task/OptimizeDependencies.cs
public class OptimizeDependencies : IBuildStep
{
private readonly Dictionary<string, List<BuildEntry>> references =
new Dictionary<string, List<BuildEntry>>();
public void Start(BuildTask task)
{
// 1. 为每个资源收集依赖
foreach (var entry in task.assets)
CollectDependencies(entry);
// 2. 对被多个 bundle 引用的公共依赖,提取为独立 bundle
foreach (var entry in assets)
{
if (!references.TryGetValue(entry.asset, out var value))
continue;
if (value.Count <= 1) continue; // 只被一处引用,无需提取
// 被多出引用 → 提取为公共 bundle
entry.owner = group;
entry.bundleMode = BundleMode.PackByFile;
entry.addressMode = AddressMode.LoadByDependencies;
entry.bundle = $"auto_{Settings.PackAsset(entry)}";
task.AddAsset(entry);
}
}
private void CollectDependencies(BuildEntry entry)
{
foreach (var dependency in Settings.GetDependencies(entry.asset))
{
if (dependency == entry.asset) continue; // 跳过自身
if (dependency.EndsWith(".unity")) continue; // 场景不参与
if (exclude.Exists(dependency.EndsWith)) continue;
if (entries.ContainsKey(dependency)) continue; // 已在列表中
// 记录引用关系
if (!references.TryGetValue(dependency, out var value))
{
value = new List<BuildEntry>();
references[dependency] = value;
}
value.Add(entry);
}
}
}
核心逻辑:
- 对每个已收集的资源,调用
AssetDatabase.GetDependencies()获取完整依赖树 - 统计每个依赖资源被几个 bundle 引用
- 被引用次数 > 1 的依赖 → 提取为独立的公共 bundle(以
auto_前缀命名) - 这些公共 bundle 的
addressMode设为LoadByDependencies,表示不对外暴露加载地址,仅作为其他 bundle 的内部依赖
面试题:依赖优化的意义是什么?
假设 A 和 B 两个 Prefab 都引用了 C 材质。如果不优化:
- C 材质会被分别打包到 A 和 B 的 bundle 中 → 重复资源
- 运行时加载两个 Prefab 会持有两份材质副本 → 内存浪费
优化后:C 材质被提取到
auto_materials_c独立 bundle,A 和 B 的 bundle 都声明依赖它。运行时只加载一份 C,两个 Prefab 共享。
4.4 BuildBundles ------ 真正的打包
csharp
// Assets/xasset/Editor/Build/Task/BuildBundles.cs
private static void BuildBundledAssets(BuildTask task)
{
// 1. 将所有资源按 bundle 分组
var bundles = GetBundles(task);
// 2. 转换为 AssetBundleBuild[]
var builds = task.bundles.ConvertAll(input =>
new AssetBundleBuild
{
assetNames = input.assets.ToArray(),
assetBundleName = input.group
}).ToArray();
// 3. 调用 Pipeline 打包
var manifest = Builder.Pipeline.BuildAssetBundles(
outputPath, builds, options, buildTarget);
// 4. 处理输出文件
foreach (var assetBundle in assetBundles)
{
var bundle = bundles[assetBundle];
var path = Settings.GetCachePath(assetBundle);
var info = new FileInfo(path);
// ★ 计算 hash
bundle.hash = Utility.ComputeHash(path);
bundle.size = (ulong)info.Length;
// ★ 重命名为 hash + extension
bundle.file = $"{bundle.hash}{Settings.Extension}";
// ★ 复制到数据目录
var newPath = Settings.GetDataPath(bundle.file);
info.CopyTo(newPath, true);
}
}
Hash 重命名机制:
这里有一个非常重要的设计模式:
打包输出 → 计算文件内容的 Hash → 用 Hash 重命名文件
例如:
shaders_bundle.bundle ──→ a1b2c3d4.bundle
ui_main_menu.bundle ──→ e5f6g7h8.bundle
为什么这样做?
- 内容即地址:文件内容变了,Hash 就变了,文件名也跟着变
- 天然避免缓存污染:旧版本文件不会被错误命中
- 支持 CDN 强缓存:CDN 可以设置超长 Cache-Control,因为路径变了就是新文件
- 增量更新:服务端只需要提供新 Hash 的文件,客户端自动下载旧没有的
4.5 BuildBundlePack ------ 打包 VFS 虚拟文件系统
这是 xasset 的特色功能:把所有 bundle 合并到一个文件中。
文件格式:
┌──────────────────────────────┐
│ Magic: "XAPK" (4 bytes) │
│ Version: 1 (4 bytes) │
│ IndexOffset (8 bytes) │ ← 指向索引区起始位置
│ IndexSize (4 bytes) │ ← 索引区大小
├──────────────────────────────┤
│ Bundle 1 的二进制数据 │
│ Bundle 2 的二进制数据 │
│ ... │
├──────────────────────────────┤
│ 索引区 (JSON) │
│ { │
│ entries: [ │
│ {file, offset, size, hash} │
│ ] │
│ } │
└──────────────────────────────┘
写入代码:
csharp
// Assets/xasset/Editor/Build/Task/BuildBundlePack.cs
private static void BuildPack(BuildTask task)
{
var packPath = Path.Combine(dataPath, BundlePack.Filename);
var entries = new List<BundlePackEntry>();
long offset = BundlePack.HeaderSize; // 从 20 字节后开始写入数据
using (var stream = File.Open(packPath, FileMode.Create))
using (var writer = new BinaryWriter(stream, Encoding.UTF8))
{
// 1. 写入头部:Magic + Version + 占位 IndexOffset + 占位 IndexSize
writer.Write(Encoding.ASCII.GetBytes(BundlePack.Magic)); // "XAPK"
writer.Write(BundlePack.Version); // 1
writer.Write((long)0); // IndexOffset 占位
writer.Write((int)0); // IndexSize 占位
// 2. 依次写入每个 bundle 的原始字节
foreach (var bundle in task.bundles)
{
var path = Settings.GetDataPath(bundle.file);
var bytes = File.ReadAllBytes(path);
writer.Write(bytes);
entries.Add(new BundlePackEntry
{
file = bundle.file,
offset = offset,
size = info.Length,
hash = bundle.hash
});
offset += info.Length;
}
// 3. 写入索引 JSON
var indexJson = JsonUtility.ToJson(
new BundlePackIndex { entries = entries.ToArray() });
var indexBytes = Encoding.UTF8.GetBytes(indexJson);
var indexOffset = stream.Position;
writer.Write(indexBytes);
// 4. 回到头部,填入真正的 IndexOffset 和 IndexSize
writer.Seek(8, SeekOrigin.Begin);
writer.Write(indexOffset);
writer.Write(indexBytes.Length);
}
}
面试题:VFS 方案相比散文件有什么优点?
- 减少网络请求:大量小 bundle 需要多次 HTTP 请求,合并后一次搞定
- 提高压缩率:合并后整体压缩比单个小文件压缩更高效
- 避免文件系统碎片:移动端大量小文件会使 IO 变慢
- 简化部署 :只需要同步一个
bundles.pak
4.5.1 Patch VFS 模式
小版本更新时,全量下载 bundles.pak 成本过高。Patch VFS 模式只打包变更的 bundle 为小体积 patch 文件。
构建流程:
BuildParameters.patchMode = true
→ BuildBundlePack.Start() 检测 patchMode
→ BuildPatchPack() 只处理 task.changes 中的变更 bundle
→ 输出 patch_{name}_{timestamp}.pak(仅含变更条目)
→ BuildVersions 记录 PatchInfo 到 version.patches[]
打包代码:
csharp
// Assets/xasset/Editor/Build/Task/BuildBundlePack.cs
public void Start(BuildTask task)
{
if (task.parameters.patchMode)
BuildPatchPack(task); // ★ 增量模式:只打包变更的 bundle
else
BuildPack(task); // 全量模式:打包所有 bundle
}
private static void BuildPatchPack(BuildTask task)
{
var dataPath = Parameters.GetDataPath(task.bundles);
var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss");
task.patchFileName = $"patch_{task.build.name.ToLower()}_{timestamp}.pak";
var packPath = Path.Combine(dataPath, task.patchFileName);
// 只处理 task.changes 中的变更 bundle
foreach (var bundle in task.bundles)
{
if (!task.changes.Contains(bundle.file)) continue; // ★ 跳过未变更的
// 写入 bundle 数据 + 记录 entry
}
// 写入索引 JSON,生成 patch VFS 文件
}
版本记录:
在 BuildVersions 中,为包含 patch 的版本添加 PatchInfo:
csharp
if (!string.IsNullOrEmpty(task.patchFileName))
{
var patchList = new List<PatchInfo>(version.patches);
patchList.Add(new PatchInfo
{
file = task.patchFileName,
hash = patchHash,
size = patchSize,
ver = version.ver
});
version.patches = patchList.ToArray();
}
else
{
version.patches = Array.Empty<PatchInfo>(); // 全量构建:清空 patch 列表
}
PatchInfo 数据结构:
csharp
[Serializable]
public class PatchInfo
{
public string file; // patch 文件名,如 "patch_arts_20250115_143000.pak"
public string hash; // 文件 Hash
public ulong size; // 文件大小
public int ver; // 对应的版本号
}
面试题:Patch VFS 和全量 VFS 如何协同工作?
基础包
bundles.pak包含全量 bundle,patch 包patch_xxx.pak只包含变更的 bundle。运行时通过同一
entries字典查询,patch 条目覆盖基础包同名条目:
BundlePack.entries["shaders_a1b2.bundle"] → 先在 patches 中找 → 找到则读取 patch VFS → 没找到则读取基础包 bundles.pak这样既保证了小版本快速更新,又不需要重复下载未变更的内容。
4.6 BuildVersions ------ 生成版本清单
csharp
// Assets/xasset/Editor/Build/Task/BuildVersions.cs
private static bool UpdateManifest(BuildTask task, Manifest manifest)
{
// 1. 为每个资源创建 ManifestAsset
foreach (var bundle in task.bundles)
{
foreach (var asset in bundle.assets)
{
var entry = task.GetAsset(asset);
if (entry.addressMode == AddressMode.LoadByDependencies) continue;
// 跳过"仅依赖"资源,它们不出现在加载地址表中
var manifestAsset = new ManifestAsset
{
id = assets.Count,
name = Path.GetFileName(asset),
path = asset,
bundle = index, // 所属 bundle 索引
dir = pos, // 所属目录索引
addressMode = entry.addressMode
};
assets.Add(manifestAsset);
}
// 检测变更:hash 或 size 变了就是变更
if (getBundles.TryGetValue(bundle.group, out var value)
&& value.hash == bundle.hash && value.size == bundle.size)
continue;
task.changes.Add(bundle.file);
}
// 2. 构建依赖图
foreach (var asset in assets)
{
var dependencies = Settings.GetDependencies(asset.path);
foreach (var dependency in dependencies)
if (map.TryGetValue(dependency, out var dep))
asset.deps.Add(dep.id);
}
// 3. 构建 Group 信息
BuildGroups(manifest, task);
}
BuildGroups 的作用:
csharp
private static void BuildGroups(Manifest manifest, BuildTask task)
{
foreach (var group in groups)
{
// 找到该 group 下所有资源所属的 bundle id
foreach (var asset in assets)
{
if (!manifest.TryGetAsset(asset, out var result)) continue;
set.Add(result.bundle); // 记录 bundle id
}
// 创建 ManifestGroup
manifestGroups.Add(new ManifestGroup
{
name = group.name,
assets = bundles,
deliveryMode = group.deliveryMode // 交付模式
});
}
}
这步设计的巧妙之处:运行时可以通过 Group 名称,快速定位需要下载哪些 bundle 来完成某个功能模块的加载。
五、运行时管线:从下载到加载
5.1 初始化流程
运行时启动时,Assets.InitializeAsync() 会触发初始化:
csharp
// Assets/xasset/Runtime/API/Assets.cs
[RuntimeInitializeOnLoadMethod]
private static void Initialize()
{
// 创建 xasset GameObject,挂载 Downloader、Recycler、Scheduler
var gameObject = new GameObject("xasset",
typeof(Downloader), typeof(Recycler), typeof(Scheduler));
Object.DontDestroyOnLoad(gameObject);
}
真正的初始化在 InitializeRequest.RuntimeInitializeAsync() 中:
csharp
// Assets/xasset/Runtime/API/Requests/InitializeRequest.cs
public IEnumerator RuntimeInitializeAsync()
{
// Step 1: 从安装包读取 playerassets.json
var www = UnityWebRequest.Get(Assets.GetPlayerDataURl(PlayerAssets.Filename));
yield return www.SendWebRequest();
var settings = Utility.LoadFromJson<PlayerAssets>(json);
Assets.LoadPlayerAssets(settings);
// ↑ 加载下载地址、最大并发数等配置
progress = 0.2f;
// Step 2: 从安装包读取 versions.json
www = UnityWebRequest.Get(Assets.GetPlayerDataURl(Versions.Filename));
yield return www.SendWebRequest();
Assets.Versions = Utility.LoadFromJson<Versions>(json);
// 如果 server 版本文件不在本地,从安装包复制
foreach (var version in Assets.Versions.data)
{
if (Assets.IsDownloaded(version, false)) continue;
var url = Assets.GetPlayerDataURl(version.file);
// ... 从安装包复制到下载目录
}
progress = 0.3f;
// Step 3: 下载目录中可能有更新的 server versions
var serverVersions = Utility.LoadFromFile<Versions>(
Assets.GetDownloadDataPath(Versions.Filename));
if (serverVersions != null && serverVersions.IsNew(Assets.Versions))
Assets.Versions = serverVersions;
// Step 4: 加载每个 Version 的 Manifest
foreach (var version in Assets.Versions.data)
version.Load(Assets.GetDownloadDataPath(version.file));
progress = 1f;
SetResult(Result.Success);
}
小白理解:
- 安装包里会有一份 "出厂版本" 的
versions.json和资源- 下载目录里可能有 "更新版本" 的
versions.json- 更新版本的 timestamp 比出厂新 → 启用更新版本
- 这样可以做到:第一次安装直接用包内资源;有更新时自动切换到下载的新资源
5.2 检查更新
csharp
// Assets/xasset/Runtime/API/Requests/GetUpdateInfoRequest.cs
protected override void OnStart()
{
_request = UnityWebRequest.Get(Assets.UpdateInfoURL);
_request.SendWebRequest();
}
protected override void OnUpdated()
{
if (!_request.isDone) return;
if (string.IsNullOrEmpty(_request.error))
{
info = Utility.LoadFromJson<UpdateInfo>(_request.downloadHandler.text);
Assets.DownloadURL = info.downloadURL; // 更新下载地址
// 对比时间戳
if (info.timestamp <= Assets.Versions.timestamp)
{
SetResult(Result.Failed, "Nothing to update.");
return;
}
SetResult(Result.Success);
}
}
updateinfo.json 内容:
json
{
"timestamp": 1234567890,
"file": "versions_v1_2_3.json",
"hash": "a1b2c3d4",
"size": 1024,
"downloadURL": "http://cdn.example.com/Bundles/Android",
"playerURL": "http://cdn.example.com/Build/xasset.apk",
"version": "1.2.3"
}
面试题:为什么需要单独的 updateinfo.json,而不是直接下载 versions.json?
updateinfo.json非常小(几百字节),目的是让客户端快速判断是否有更新 。如果每次都要下载完整的 versions.json(可能几百 KB)再去比较,浪费流量和时间。这是一种两步式更新检查的设计模式。
5.3 VersionsRequest ------ 版本文件下载(五阶段流程)
这是设计最精巧的模块之一。支持全量更新 和 Patch 差异化更新两种模式:
csharp
// Assets/xasset/Runtime/API/Requests/VersionsRequest.cs
public class VersionsRequest : Request
{
private enum Step
{
HeaderDownloading, // 阶段1: 下载 versions.json
PackDownloading, // 阶段2: 尝试下载 bundles.pak(全量模式)
ContentsDownloading, // 阶段3: 回退到逐个下载 bundle 文件
ContentsLoading, // 阶段4: 加载所有 Version 的 Manifest
PatchPacksLoading // ★ 阶段5: 下载并加载 patch VFS 包
}
}
阶段1 --- HeaderDownloading :下载 versions.json,解析出需要更新的 bundle 列表。根据版本中是否包含 patches 字段决定后续路径:
解析完成后检查 version.patches:
├── 有 patches → Patch 模式 → 跳过 PackDownloading,直接 ContentsDownloading
└── 无 patches → 全量模式 → 走 PackDownloading 尝试下载 bundles.pak
阶段2 --- PackDownloading(全量模式) :优先下载 bundles.pak(一个包搞定所有新 bundle)。成功后从中提取每个 bundle 文件,如果有缺失则回退到 ContentsDownloading。
阶段3 --- ContentsDownloading :逐个下载缺失的 bundle 文件。Patch 模式下只下载版本清单文件(manifest_{hash}.json),而非全量 bundle。
阶段4 --- ContentsLoading:加载所有 Version 的 Manifest,解析资源地址映射。
阶段5 --- PatchPacksLoading(★ Patch 模式核心):
版本清单加载完毕后,检查并下载 patch VFS 包:
csharp
private void UpdateLoadPatchPacks()
{
// 第一轮:收集缺失的 patch 包
foreach (var version in versions.data)
{
if (version.patches == null) continue;
foreach (var patch in version.patches)
{
var patchPath = Assets.GetDownloadDataPath(patch.file);
if (File.Exists(patchPath)) continue;
// ★ 大文件用分块并行下载(≥ 10MB)
if (patch.size >= (ulong)ChunkedDownloadRequest.MinFileSize)
{
var chunked = new ChunkedDownloadRequest
{
content = content,
maxConcurrency = 4
};
chunked.SendRequest();
_chunkedPatches.Add(chunked);
}
else
{
smallPatches.Add(patch); // 小文件用普通 batch 下载
}
}
}
// 全部下载完成后
LoadLocalPatches(); // 逐个调用 BundlePack.LoadPatchPack()
}
为什么需要 Patch 模式?
| 场景 | 全量模式 | Patch 模式 |
|---|---|---|
| 更新规模 | 任意数量 bundle | 少量 bundle 变更 |
| 下载量 | 整个 bundles.pak(可能很大) | 只下载变更的 patch VFS(小) |
| 基础包 | 同时下载新 bundles.pak | 基础包不变,追加小 patch |
| 适用版本 | 大版本(功能新增/重构) | 小版本(bug 修复/资源微调) |
5.4 BundleRequest ------ Bundle 加载的核心
csharp
// Assets/xasset/Runtime/API/Requests/BundleRequest.cs
internal static BundleRequest Load(ManifestBundle bundle)
{
// 1. 复用机制:已加载的直接返回,未加载的从池中取
if (!Loaded.TryGetValue(bundle.name, out var request))
{
request = Unused.Count > 0 ? Unused.Dequeue() : new BundleRequest();
request.Reload(bundle);
Loaded[bundle.name] = request;
}
request.LoadAsync();
return request;
}
重点是 GetHandler() ------ 根据资源位置选择最优加载策略:
csharp
private static IBundleHandler GetHandler(BundleRequest request)
{
var bundle = request.info;
// 优先级 1: 从 VFS (bundles.pak) 读取
if (BundlePack.TryGetEntry(bundle.file, out _))
return new RuntimePackBundleHandler();
// 优先级 2: 已下载到本地
if (Assets.IsDownloaded(bundle, false))
return new RuntimeLocalBundleHandler {
path = Assets.GetDownloadDataPath(bundle.file)
};
// 优先级 3: 在安装包内(出厂资源)
if (Assets.IsPlayerAsset(bundle.hash))
return new RuntimeLocalBundleHandler {
path = Assets.GetPlayerDataPath(bundle.file)
};
// 优先级 4: 需要从网络下载
return new RuntimeDownloadBundleHandler();
}
四种加载策略:
① RuntimePackBundleHandler ------ 从 VFS 读取:
csharp
internal struct RuntimePackBundleHandler : IBundleHandler
{
public void OnStart(BundleRequest request)
{
if (BundlePack.TryGetEntry(request.info.file, out var entry))
{
var bytes = BundlePack.ReadBundle(entry);
request.assetBundle = AssetBundle.LoadFromMemory(bytes);
// ↑ 从内存中加载 AssetBundle
}
}
}
② RuntimeLocalBundleHandler ------ 从本地文件加载:
csharp
internal struct RuntimeLocalBundleHandler : IBundleHandler
{
public string path;
public void OnStart(BundleRequest request)
{
request.LoadAssetBundle(path);
}
}
// BundleRequest 的 LoadAssetBundle:
public void LoadAssetBundle(string filename)
{
ReloadAssetBundle(info.name); // 热重载:卸载旧的同名 bundle
assetBundle = AssetBundle.LoadFromFile(filename);
// ↑ 从文件直接加载,不涉及内存拷贝
}
③ RuntimeDownloadBundleHandler ------ 先下载再加载:
csharp
internal struct RuntimeDownloadBundleHandler : IBundleHandler
{
public void OnStart(BundleRequest request)
{
var bundle = request.info;
var url = Assets.GetDownloadURL(bundle.file);
var savePath = Assets.GetDownloadDataPath(bundle.file);
_downloadAsync = Downloader.DownloadAsync(
DownloadContent.Get(url, savePath, bundle.hash, bundle.size));
}
public void Update(BundleRequest request)
{
request.progress = _downloadAsync.progress;
if (!_downloadAsync.isDone) return;
if (_downloadAsync.result == DownloadRequestBase.Result.Success)
{
request.LoadAssetBundle(_savePath);
return;
}
// 自动重试机制
if (Application.internetReachability != NetworkReachability.NotReachable
&& _retryTimes < Assets.MaxRetryTimes)
{
_downloadAsync.Retry();
_retryTimes++;
return;
}
}
}
面试题:
AssetBundle.LoadFromFile()和AssetBundle.LoadFromMemory()有什么区别?什么时候用哪个?
LoadFromFile:直接从磁盘文件加载,操作系统有缓存机制,内存效率高。优先使用。LoadFromMemory:需要先将文件读入byte[],再从内存加载。多了一次内存拷贝。xasset 的设计是:
- 散文件 bundle →
LoadFromFile(直接读文件)- VFS 中的 bundle →
LoadFromMemory(从 pak 中读取字节后加载)因为 VFS 中没有独立文件,只能先读取字节再加载。
5.5 AssetRequest ------ 资源加载
AssetRequest 负责从已加载的 AssetBundle 中提取具体的资源对象(Texture、Prefab 等):
csharp
// Assets/xasset/Runtime/API/Requests/AssetRequest.cs
internal static AssetRequest Load(string path, Type type, bool isAll = false)
{
// 1. 通过地址映射找到 ManifestAsset
if (!Assets.TryGetAsset(ref path, out var info)) return null;
var key = $"{path}[{type.Name}]";
// 2. 复用已加载的请求
if (!Loaded.TryGetValue(key, out var request))
{
request = Unused.Count > 0 ? Unused.Dequeue() : new AssetRequest();
request.Reset();
request.type = type;
request.info = info;
request.path = path;
Loaded[key] = request;
}
request.LoadAsync();
return request;
}
RuntimeAssetHandler ------ 两阶段加载:
csharp
public struct RuntimeAssetHandler : IAssetHandler
{
private enum Step { LoadDependencies, LoadAsset }
private Dependencies _dependencies;
private AssetBundleRequest _loadAssetAsync;
public void OnStart(AssetRequest request)
{
// 阶段1: 先加载依赖
_dependencies = Dependencies.LoadAsync(request.info);
_step = Step.LoadDependencies;
}
public void Update(AssetRequest request)
{
switch (_step)
{
case Step.LoadDependencies:
_dependencies.Update();
request.progress = _dependencies.progress * 0.5f;
if (!_dependencies.isDone) return;
// 依赖加载完毕 → 阶段2: 从主 bundle 加载资源
LoadAssetAsync(request);
break;
case Step.LoadAsset:
request.progress = 0.5f + _loadAssetAsync.progress * 0.5f;
if (!_loadAssetAsync.isDone) return;
SetResult(request);
break;
}
}
}
加载链路总结:
Asset.LoadAsync("Assets/.../myPrefab.prefab", typeof(GameObject))
→ AssetRequest.Load(path, type)
→ Dependencies.LoadAsync(assetInfo)
→ BundleRequest.Load(mainBundle)
→ RuntimeBundleHandler.GetHandler() 选择加载策略
→ BundlePack / LoadFromFile / Download
→ BundleRequest.Load(depBundle1)
→ BundleRequest.Load(depBundle2)
→ assetBundle.LoadAssetAsync("Assets/.../myPrefab.prefab")
5.6 下载系统
Downloader(下载管理器):
csharp
// Assets/xasset/Runtime/Network/Downloader.cs
public class Downloader : MonoBehaviour
{
internal static readonly Queue<DownloadRequest> Queue = new Queue<DownloadRequest>();
private static readonly List<DownloadRequest> Progressing = new List<DownloadRequest>();
private static readonly Dictionary<string,DownloadRequest> Cache = new Dictionary<string, DownloadRequest>();
private void Update()
{
UpdateAll();
}
private static void UpdateAll()
{
// 从队列取新的请求,最多 MaxDownloads 个并行
while (Queue.Count > 0 && Progressing.Count < Assets.MaxDownloads)
{
var item = Queue.Dequeue();
if (item.status == Status.Wait) item.Start();
Progressing.Add(item);
}
// 更新正在进行的下载
for (int index = 0; index < Progressing.Count; index++)
{
var item = Progressing[index];
if (item.isDone)
{
Progressing.RemoveAt(index);
item.Complete();
continue;
}
item.Update();
}
}
}
DownloadRequest(单个下载请求):
csharp
// 从服务端下载文件的入口
public static DownloadRequest DownloadAsync(DownloadContent content)
{
if (!Cache.TryGetValue(content.url, out var request))
{
request = new DownloadRequest { content = content };
request.SendRequest(); // 入队
request.handler = CreateHandler(request);
Cache[content.url] = request;
}
return request;
}
// 下载校验
internal void VerifyContent()
{
// 1. 检查文件是否存在
if (!file.Exists) { SetResult(Result.Failed); return; }
// 2. 检查文件大小是否匹配
if (content.size != 0 && file.Length != (long)content.size)
{ file.Delete(); SetResult(Result.Failed); return; }
// 3. 计算 Hash 校验
var computeHash = Utility.ComputeHash(savePath);
if (content.hash.Equals(computeHash))
{
SetResult(Result.Success);
return;
}
// Hash 不匹配 → 文件损坏,删除重下
file.Delete();
SetResult(Result.Failed, "Hash mismatch");
}
// 断点续传
public void Start()
{
// 检查本地已有大小
downloadedBytes = content.GetDownloadedBytes();
if (downloadedBytes > 0 && downloadedBytes == content.size)
{
// 已经下载完成,无需重新下载
SetResult(Result.Success, "Nothing to download");
return;
}
// 否则从断点处继续下载
OnStart();
status = Status.Progressing;
}
面试题:断点续传是如何实现的?
- 下载前检查本地已有文件大小(
downloadedBytes)- 如果大小已经等于目标大小,直接完成(秒完成)
- 否则 HTTP 请求中设置
Rangeheader,从已下载的字节处继续下载- 下载完成后校验完整文件 Hash
5.7 运行时 VFS 读取 BundlePack(多包支持)
运行时从 VFS 文件(bundles.pak + 可选 patch VFS)中读取指定 bundle 的数据。
多包架构:
VFS 设计从单包扩展为多包(base + patches),支持小版本差异化更新:
bundles.pak ← 全量基础包(大版本构建)
└── 包含所有 bundle 条目
patch_{name}_{ver}.pak ← 增量 patch 包(小版本构建)
└── 只包含变更的 bundle 条目,同名覆盖基础包
每个 BundlePackEntry 增加 packPath 字段,标记该条目来自哪个包文件:
csharp
public class BundlePackEntry
{
public string file; // bundle 文件名
public long offset; // 在 VFS 中的偏移
public long size; // 大小
public string hash; // 内容 Hash
[NonSerialized] public string packPath; // ★ 所属包文件路径
}
Patch 加载机制 ------ LoadPatchPack:
csharp
public static bool LoadPatchPack(string path)
{
EnsureLoaded(); // 确保基础包已加载
var patchEntries = ReadPackFromFile(path);
if (patchEntries == null) return false;
if (entries == null)
entries = new Dictionary<string, BundlePackEntry>(StringComparer.OrdinalIgnoreCase);
foreach (var entry in patchEntries)
{
if (string.IsNullOrEmpty(entry.file)) continue;
entry.packPath = path; // 标记为来自 patch 包
entries[entry.file] = entry; // ★ 覆盖同名条目
}
return true;
}
查找与读取 ------ 优先使用 entry 所属的包:
csharp
public static byte[] ReadBundle(BundlePackEntry entry)
{
var packPath = entry.packPath ?? GetBasePackPath();
// ↑ 有 packPath 说明是 patch 包中的条目,否则从基础包读取
if (packPath == null) return null;
using var stream = File.OpenRead(packPath);
stream.Seek(entry.offset, SeekOrigin.Begin);
var buffer = new byte[entry.size];
var read = 0;
while (read < buffer.Length)
{
var count = stream.Read(buffer, read, buffer.Length - read);
if (count <= 0) break;
read += count;
}
return read == buffer.Length ? buffer : null;
}
加载时序:
初始化时 (InitializeRequest):
1. 加载 playerassets.json / versions.json
2. 加载各版本 Manifest
3. ★ 扫描已下载的 patch 包,逐个 LoadPatchPack
运行时请求时 (BundleRequest.GetHandler):
1. BundlePack.TryGetEntry(bundle.file) → 查找 entries 字典
2. ★ patch 条目已覆盖基础包同名条目 → 自动走 patch VFS
3. ReadBundle(entry) → 从 entry.packPath 对应的包文件中读取
5.8 分块并行下载 ChunkedDownloadRequest
针对大文件(≥ 10MB)的下载加速方案,将文件拆为多个 chunk,通过 HTTP Range 头并发下载,完成后合并校验。
为什么需要分块下载?
当 patch 包体积较大(如 50MB、100MB),单线程下载速度慢且失败后全量重试成本高。分块并行下载可以:
- 同时发起多个 HTTP Range 请求,充分利用带宽
- 每个 chunk 独立重试,失败只重传小片段
- 支持断点续传,已下载的 chunk 不重复下载
- 下载完成后进行大小 + Hash 双重校验
核心参数:
csharp
public static long ChunkSize { get; set; } = 1024 * 1024; // 1MB 每块
public static long MinFileSize { get; set; } = 10 * 1024 * 1024; // 启用分块的最小文件
public int maxConcurrency { get; set; } = 4; // 最大并发数
工作流程:
文件 → 按 ChunkSize 切分 → 检查磁盘已有临时文件
→ 对每个 chunk 发起 HTTP Range: bytes={start}-{end}
→ 并发下载 (maxConcurrency=4)
→ 全部完成后:流式合并(80KB 缓冲区)
→ 校验大小 → 校验 MD5 Hash → 清理临时目录
断点续传机制:
csharp
private void StartChunk(ChunkInfo chunk)
{
var rangeStart = chunk.start + chunk.existingBytes;
var rangeEnd = chunk.end;
if (rangeStart > rangeEnd) { chunk.done = true; return; }
var uwr = UnityWebRequest.Get(url);
uwr.SetRequestHeader("Range", $"bytes={rangeStart}-{rangeEnd}");
if (chunk.existingBytes > 0 && File.Exists(chunk.tempFile))
uwr.downloadHandler = new DownloadHandlerFile(chunk.tempFile, true);
// ↑ 追加模式:从已有字节继续下载
}
每个 chunk 下载前检查 {tempDir}/part_{i} 临时文件,如果已存在则从断点处继续。已完成的 chunk 跳过。
重试策略:
两层重试:
- Chunk 级重试 :单个 chunk 下载失败(网络超时、大小不匹配),修改
chunk.failed标志,下一帧重新调度 - 整体重试 :累计失败 chunk 数 > 0 时,达到
MaxRetryTimes后彻底失败
csharp
private void TryRetryOrFail()
{
if (canRetry)
{
foreach (var chunk in _chunks)
{
if (!chunk.failed) continue;
chunk.failed = false;
// 重新检查临时文件大小,用于断点续传
chunk.existingBytes = new FileInfo(chunk.tempFile).Length;
}
_retryTimes++;
LaunchChunks();
return;
}
SetResult(Result.Failed);
}
文件合并:
采用流式合并而非全量读入内存,避免大文件 OOM:
csharp
using (var destStream = File.OpenWrite(savePath))
{
for (var i = 0; i < _chunks.Length; i++)
{
using (var srcStream = File.OpenRead(chunk.tempFile))
{
int read;
while ((read = srcStream.Read(buffer, 0, buffer.Length)) > 0)
destStream.Write(buffer, 0, read);
}
}
}
完整性校验:
合并后执行三级校验链:
文件存在?→ 文件大小匹配?→ MD5 Hash 匹配?
任何一级失败 → 删除错误文件 → 返回失败。
在 VersionsRequest 中的集成:
csharp
// 大文件用分块并行下载,小文件用普通 DownloadRequestBatch
if (patch.size >= (ulong)ChunkedDownloadRequest.MinFileSize)
{
var chunked = new ChunkedDownloadRequest
{
content = content,
maxConcurrency = 4
};
chunked.SendRequest();
_chunkedPatches.Add(chunked);
}
else
{
smallPatches.Add(patch);
}
进度计算同时考虑普通 batch 和多个 chunked 请求的综合进度。
六、对象池与引用计数
6.1 Dependencies 依赖管理
Dependencies 负责管理一个资源及其所有依赖 bundle 的生命周期:
csharp
internal class Dependencies : IReloadable
{
internal static readonly Dictionary<string, Dependencies> Loaded = ...;
private static readonly Queue<Dependencies> Unused = ...;
private readonly List<BundleRequest> _bundles = new List<BundleRequest>();
private int _refCount; // 引用计数
public static Dependencies LoadAsync(ManifestAsset asset)
{
if (!Loaded.TryGetValue(asset.path, out var value))
{
value = Unused.Count > 0 ? Unused.Dequeue() : new Dependencies();
value.asset = asset;
Loaded[asset.path] = value;
}
value.LoadAsync(); // 增加引用计数
return value;
}
private void LoadAll()
{
// 加载主 bundle
_bundleRequest = Load(bundles[asset.bundle]);
// 加载所有依赖 bundle
foreach (var dep in bundle.deps)
Load(bundles[dep]);
}
public void Release()
{
_refCount--;
if (_refCount != 0) return; // 还有其他引用,不释放
ClearAll(); // 卸载所有 bundle
Loaded.Remove(asset.path);
Unused.Enqueue(this);
}
}
6.2 BundleRequest 和 AssetRequest 的对象池
两个类都用相同的复用模式:
csharp
// 从池中取或新建
request = Unused.Count > 0 ? Unused.Dequeue() : new BundleRequest();
// 返回池中
private static void Remove(BundleRequest request)
{
Loaded.Remove(request.info.name);
Unused.Enqueue(request);
}
这种模式的好处:
- 减少 GC 分配(对象复用)
- 避免频繁创建/销毁的开销
七、面试高频考点
7.1 一句话概括这个方案
这是一套基于 Unity AssetBundle + SBP 的完整资源管理方案,覆盖了资源分包、依赖优化、增量打包、VFS 合并、版本管理、断点续传下载、运行时加载等全链路。
7.2 高频面试题
Q1:资源包命名为什么用 Hash?
核心答案:实现"内容即地址"(Content-addressable storage)。
- Bundle 文件名为
文件内容的 Hash + 扩展名 - 内容变了 → Hash 变了 → 文件名变了 → 旧文件不会被错误命中
- 天然支持 CDN 强缓存和增量更新
Q2:如何防止资源被重复打包?
核心答案 :OptimizeDependencies 步骤。
- 对所有已收集资源调用
AssetDatabase.GetDependencies()获取依赖树 - 统计每个依赖被多少个 bundle 引用
- 被多次引用的依赖 → 提取为独立 bundle(
auto_前缀) - 运行时通过
Dependencies类管理依赖加载,确保每个依赖 bundle 只加载一次
Q3:加载资源时,有哪几种获取 bundle 的途径?
四层加载策略:
| 优先级 | 方式 | 适用场景 |
|---|---|---|
| 1 | 从 VFS(bundles.pak)读取 | 有合并包时 |
| 2 | 从本地下载目录加载 | 已下载更新的资源 |
| 3 | 从安装包目录加载 | 出厂自带资源 |
| 4 | 从网络下载后加载 | 按需更新的资源 |
Q4:安装包资源(InstallTime)和下载资源如何切换?
通过 Assets.IsPlayerAsset() 和 Assets.IsDownloaded() 联合判断:
csharp
public static bool IsDownloaded(Downloadable item)
{
// 1. 在安装包中?→ 已下载
if (IsPlayerAsset(item.hash)) return true;
// 2. 在 VFS 中?→ 已下载
if (BundlePack.TryGetEntry(item.file, out var entry)) return true;
// 3. 本地有完整文件?→ 已下载
var file = new FileInfo(Assets.GetDownloadDataPath(item.file));
return file.Exists && file.Length == (long)item.size;
}
而 GetHandler() 的优先级自然保证了:VFS > 下载目录 > 安装包目录 > 网络。所以同一个资源,如果既在安装包中又下载了更新版本,会自动使用更新版本(因为下载目录中的 VFS/散文件优先级更高)。
Q5:增量更新如何做到的?
- 打包时 :检测到 bundle 的 Hash 没变 → 不加入
changes列表 → 不上传 - 版本号 :
version.ver++,版本文件名包含版本号(versions_v1_2_3.json) - 运行时 :下载最新
versions.json,对比本地已有资源,只下载缺失或 hash 不匹配的文件 BuildChanges:记录每次构建的变更文件列表和总大小
Q6:VFS(bundles.pak)解决了什么问题?
- 降低网络开销:大量小文件 → 一个 HTTP 请求
- 提高 IO 效率:移动端大量小文件随机 IO 极慢
- 确保文件一致性:一个 pak 包要么完全下载成功,要么完全失败,不存在"部分文件损坏"的问题
Q7:断点续传怎么保证文件完整性?
三步校验链:
文件存在 → 文件大小匹配 → Hash 校验
↓ ↓
快速校验(80%情况) 最终校验(100%可靠)
如果任何一步失败,删除文件,重新下载。
Q8:AddressMode 的四种模式怎么用?
| 模式 | 加载时传参 | 示例 |
|---|---|---|
| LoadByPath | Assets/.../prefab.prefab |
默认,明确 |
| LoadByName | prefab.prefab |
只需文件名 |
| LoadByNameWithoutExtension | prefab |
最简洁 |
| LoadByDependencies | 不可直接加载 | 公共依赖 |
7.3 实战面试话术
面试官:"你们项目用的什么资源管理方案?"
"我们用的是基于 AssetBundle 的自研方案,核心思路是:编辑器端用 SBP 管线打包,通过
Build资产定义分组和分包策略,打包时自动做依赖优化,生成 Hash 命名的 bundle 文件和 VFS 合并包。运行时有一套完整的初始化和更新检查流程,资源加载有四层策略(VFS → 本地 → 包内 → 下载),下载支持断点续传和 Hash 校验。"
面试官:"分包策略怎么设计的?"
"我们提供了 7 种分包模式,从整组一包到每文件一包都有。实际项目中,Shaders 全部合并成
shaders.bundle(保证变体完整),场景按文件单独打包,公共 UI 素材按目录打包,个别特大资源按文件单独打包。配合依赖优化,公共引用自动提取为独立 bundle。"
面试官:"怎么实现热更的?"
"我们用的是版本对比的方案。服务端维护一个
updateinfo.json,客户端启动时先拉取它,比对时间戳判断是否有更新。如果有更新,再下载完整的versions.json,从中知道哪些 bundle 文件变了。下载时优先尝试bundles.pak(一个包含所有新文件的合并包),如果失败就逐个回退下载。下载前会校验文件大小和 Hash,保证完整性。"
附录:关键文件速查表
| 功能模块 | 核心文件 | 关键类/方法 |
|---|---|---|
| 配置 | Editor/Settings.cs |
Settings.PackAsset(), PlayerConfig, BundleConfig |
| 构建入口 | Editor/Build/Builder.cs |
Builder.BuildBundles() |
| 资源收集 | Editor/Build/Task/CollectAssets.cs |
CollectAssets.Start() |
| 依赖优化 | Editor/Build/Task/OptimizeDependencies.cs |
OptimizeDependencies.Start() |
| 打包 | Editor/Build/Task/BuildBundles.cs |
BuildBundles.Start(), Hash 重命名 |
| VFS 打包 | Editor/Build/Task/BuildBundlePack.cs |
BuildBundlePack.Start() |
| 版本清单 | Editor/Build/Task/BuildVersions.cs |
BuildVersions.Start(), BuildGroups() |
| 运行时入口 | Runtime/API/Assets.cs |
Assets.InitializeAsync(), Assets.LoadAsync() |
| 初始化 | Runtime/API/Requests/InitializeRequest.cs |
InitializeRequest.RuntimeInitializeAsync() |
| 更新检查 | Runtime/API/Requests/GetUpdateInfoRequest.cs |
GetUpdateInfoRequest |
| 版本下载 | Runtime/API/Requests/VersionsRequest.cs |
VersionsRequest, 五阶段流程(含 PatchPacksLoading) |
| Bundle 加载 | Runtime/API/Requests/BundleRequest.cs |
BundleRequest.Load(), GetHandler() |
| Bundle 策略 | Runtime/API/Requests/Handlers/RuntimeBundleHandler.cs |
四种 Handler |
| 资源加载 | Runtime/API/Requests/AssetRequest.cs |
AssetRequest.Load() |
| 资源策略 | Runtime/API/Requests/Handlers/RuntimeAssetHandler.cs |
RuntimeAssetHandler, 两阶段 |
| 依赖管理 | Runtime/API/Requests/Dependencies.cs |
Dependencies.LoadAsync(), 引用计数 |
| 下载器 | Runtime/Network/Downloader.cs |
Downloader, 队列+并发 |
| 下载请求 | Runtime/Network/DownloadRequest.cs |
DownloadRequest, 断点续传+Hash |
| VFS 读取 | Runtime/Network/BundlePack.cs |
BundlePack.ReadBundle(), BundlePack.LoadPatchPack(), patch 覆盖机制 |
| 分块并行下载 | Runtime/Network/ChunkedDownloadRequest.cs |
ChunkedDownloadRequest, HTTP Range 并发分块, 断点续传, Hash 校验 |
| 运行时 Manifest | Runtime/Config/Manifest.cs |
Manifest, ManifestAsset, ManifestBundle |
| 运行时版本 | Runtime/Config/Versions.cs |
Versions, Version |
| 安装包配置 | Runtime/Config/PlayerAssets.cs |
PlayerAssets, PlayerAssetsSplitMode |