Unity AssetBundle商业化项目资源方案

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

目录

  1. 背景与整体架构
  2. 核心数据结构
  3. 配置层:定义资源规则
  4. [编辑器打包管线:从资源到 Bundle](#编辑器打包管线:从资源到 Bundle)
    • [4.5 BuildBundlePack ------ 打包 VFS](#4.5 BuildBundlePack —— 打包 VFS)
    • [4.5.1 Patch VFS 模式](#4.5.1 Patch VFS 模式)
  5. 运行时管线:从下载到加载
    • [5.3 VersionsRequest ------ 五阶段流程](#5.3 VersionsRequest —— 五阶段流程)
    • [5.7 运行时 VFS 读取(多包支持)](#5.7 运行时 VFS 读取(多包支持))
    • [5.8 分块并行下载 ChunkedDownloadRequest](#5.8 分块并行下载 ChunkedDownloadRequest)
  6. 对象池与引用计数
  7. 面试高频考点

一、背景与整体架构

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.assetUI.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);
    }
}

关键行为

  1. 遍历所有 GroupBuildEntry
  2. 对文件夹资源:调用 Settings.GetChildren() 使用 AssetDatabase.FindAssets 搜索
  3. 对每个资源调用 Settings.PackAsset() 分配 bundle
  4. 通过 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);
        }
    }
}

核心逻辑

  1. 对每个已收集的资源,调用 AssetDatabase.GetDependencies() 获取完整依赖树
  2. 统计每个依赖资源被几个 bundle 引用
  3. 被引用次数 > 1 的依赖 → 提取为独立的公共 bundle(以 auto_ 前缀命名)
  4. 这些公共 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

为什么这样做?

  1. 内容即地址:文件内容变了,Hash 就变了,文件名也跟着变
  2. 天然避免缓存污染:旧版本文件不会被错误命中
  3. 支持 CDN 强缓存:CDN 可以设置超长 Cache-Control,因为路径变了就是新文件
  4. 增量更新:服务端只需要提供新 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 方案相比散文件有什么优点?

  1. 减少网络请求:大量小 bundle 需要多次 HTTP 请求,合并后一次搞定
  2. 提高压缩率:合并后整体压缩比单个小文件压缩更高效
  3. 避免文件系统碎片:移动端大量小文件会使 IO 变慢
  4. 简化部署 :只需要同步一个 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;
}

面试题:断点续传是如何实现的?

  1. 下载前检查本地已有文件大小(downloadedBytes
  2. 如果大小已经等于目标大小,直接完成(秒完成)
  3. 否则 HTTP 请求中设置 Range header,从已下载的字节处继续下载
  4. 下载完成后校验完整文件 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 跳过。

重试策略

两层重试:

  1. Chunk 级重试 :单个 chunk 下载失败(网络超时、大小不匹配),修改 chunk.failed 标志,下一帧重新调度
  2. 整体重试 :累计失败 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 步骤。

  1. 对所有已收集资源调用 AssetDatabase.GetDependencies() 获取依赖树
  2. 统计每个依赖被多少个 bundle 引用
  3. 被多次引用的依赖 → 提取为独立 bundle(auto_ 前缀)
  4. 运行时通过 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:增量更新如何做到的?
  1. 打包时 :检测到 bundle 的 Hash 没变 → 不加入 changes 列表 → 不上传
  2. 版本号version.ver++,版本文件名包含版本号(versions_v1_2_3.json
  3. 运行时 :下载最新 versions.json,对比本地已有资源,只下载缺失或 hash 不匹配的文件
  4. BuildChanges:记录每次构建的变更文件列表和总大小
Q6:VFS(bundles.pak)解决了什么问题?
  1. 降低网络开销:大量小文件 → 一个 HTTP 请求
  2. 提高 IO 效率:移动端大量小文件随机 IO 极慢
  3. 确保文件一致性:一个 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

相关推荐
unityのkiven5 小时前
通过 Unity 实现杀戮尖塔2式卡牌系统与机制
unity·游戏引擎
郝学胜-神的一滴5 小时前
中级OpenGL教程 020:巧用数组与循环实现多点点光源渲染,告别冗余代码重构方案
c++·unity·游戏引擎·godot·图形渲染·unreal
Duo1J6 小时前
【UE】Slate 编辑器工具开发01 - Slate语法 & 常用控件
ue5·编辑器·游戏引擎·ue4
qq_170264756 小时前
unity的锁帧和垂直同步
unity·游戏引擎
承渊政道8 小时前
飞算Java炫技赛:Java×Unity数字孪生园区平台的从零落地记录
java·开发语言·unity·技术分享·vibe coding·飞算javaai·飞算java ai炫技赛
unityのkiven18 小时前
Unity 共享材质修改问题与 MaterialPropertyBlock 解决方案
unity·游戏引擎·材质
丁小未18 小时前
Unity 极致高效的IM设计方案教程
unity·性能优化·游戏引擎·im
unityのkiven19 小时前
Unity UGUI 中 EventTrigger 导致 ScrollRect 鼠标滚轮失效的问题分析与解决
unity·计算机外设·游戏引擎
丁小未1 天前
Unity动态图集方案
unity·游戏引擎