Unity资源加载进化论:从AssetBundle到Addressables,一文带你吃透手游资源管理

一、为什么我们需要"资源管理"

先抛一个问题:你做一个手游,里面有1000张角色立绘、500个UI图标、200个场景。把它们全部塞进游戏包里行不行?

技术上可以,但你会面临三个致命问题:

  1. 包体爆炸
  2. 更新困难
  3. 内存溢出

资源管理的本质,就是解决包体、热更、内存这三件事。

Unity给出的方案演进路线是:

scss 复制代码
Resources(初代)  →  AssetBundle(进阶)  →  Addressables(现代)
   全量加载         手动管理依赖          按地址按需加载

二、上古时代:Resources文件夹

在讲AssetBundle之前,必须提一下Resources。这是Unity最早提供的资源加载方式,简单到令人发指:

csharp 复制代码
// 把资源放在 Assets/Resources/ 目录下,然后这样加载
GameObject prefab = Resources.Load<GameObject>("Prefabs/Player");
Instantiate(prefab);

// 异步加载
ResourceRequest request = Resources.LoadAsync<GameObject>("Prefabs/Player");
yield return request;
Instantiate(request.asset);

// 释放
Resources.UnloadUnusedAssets();

之所以说Resources是上古时代,是因为它有几个致命缺点:

  • Resources目录下的资源全部打进包体,无论是否用到。
  • 启动时全量序列化,包越大启动越慢。
  • 无法热更新,资源永远跟版本绑定。

所以Resources只适合存放必须用的、体积小的、启动时必须加载的资源,比如:启动时的Logo、首屏Loading、默认配置表等。其他全部应该走AssetBundle或Addressables。


三、AssetBundle登场:把资源拆出去

3.1 AssetBundle是什么

简单说,AssetBundle就是Unity把多个资源打成一个压缩包,你可以把这个包放在服务器上,运行时下载下来再加载。

它解决了Resources的两大问题:

  • 包体可以小,资源走CDN下发
  • 可以热更,发个新bundle就行

3.2 第一个AssetBundle:动手打一个包

我们假设场景:把一个角色预制体打成AssetBundle。

第一步:标记资源

在Project窗口选中你的Prefab(比如Player.prefab),在Inspector最底下能看到AssetBundle标签栏。点击"None"下拉,新建一个叫characters的包,再新建一个变体(variant)叫hd(可选):

makefile 复制代码
AssetBundle: characters    Variant: hd

这一步只是打了个"标签",告诉Unity:"这些资源以后要打到一起去"。

第二步:写一个构建脚本

csharp 复制代码
/// <summary>
/// AssetBundle 构建工具
/// 
/// 【为什么输出到项目根目录而非 Assets/ 内部?】
/// BuildPipeline.BuildAssetBundles 在构建过程中会生成临时文件和最终产物。
/// 如果输出路径位于 Assets/ 目录下,Unity 的 AssetDatabase 会在构建期间
/// 自动检测到新文件并触发资源刷新/导入,导致:
///   1. 构建过程被中断,产物可能被当作普通资源重新导入而损坏
///   2. 部分产物文件被吞掉,最终只剩空文件夹
/// 因此,必须将输出路径设在 Assets/ 之外(项目根目录下),避免 AssetDatabase 干扰。
/// 
/// 【为什么构建后还要拷贝到 StreamingAssets?】
/// StreamingAssets 是 Unity 的特殊目录,其内容在打包时会原封不动地打入最终安装包,
/// 运行时可通过 Application.streamingAssetsPath 访问,是 AssetBundle 热加载的
/// 标准存放位置。如果只放在项目根目录,打包后不会包含这些文件,运行时无法加载。
/// 所以流程为:先在外部安全构建 → 再拷贝到 StreamingAssets 供运行时使用。
/// </summary>
public class AssetBundleBuilder
{
    /// <summary>
    /// 菜单项:Tools > Build AssetBundles - Windows
    /// 点击后执行完整的 AssetBundle 构建流程
    /// </summary>
    [MenuItem("Tools/Build AssetBundles - Windows")]
    public static void BuildAllAssetBundles()
    {
        // 输出路径设在项目根目录(与 Assets 同级),避免 AssetDatabase 在构建时干扰
        string outputPath = "AssetBundles";

        // 确保输出目录存在
        if (!Directory.Exists(outputPath))
        {
            Directory.CreateDirectory(outputPath);
        }

        // 执行 AssetBundle 构建
        // 参数1:输出目录
        // 参数2:LZ4 压缩,解压速度快,适合运行时按需加载(对比 LZMA 体积更小但解压慢)
        // 参数3:目标平台为 Windows 64 位
        BuildPipeline.BuildAssetBundles(
            outputPath,
            BuildAssetBundleOptions.ChunkBasedCompression,
            BuildTarget.StandaloneWindows64
        );

        // ---- 构建完成后,将产物拷贝到 StreamingAssets ----

        // StreamingAssets 下的目标路径,打包后会随安装包一起发布
        string streamingPath = "Assets/StreamingAssets/AssetBundles";

        // 先清空旧文件,避免残留上一次构建的过期产物
        if (Directory.Exists(streamingPath))
        {
            Directory.Delete(streamingPath, true);
        }
        Directory.CreateDirectory(streamingPath);

        // 将构建输出的所有文件拷贝到 StreamingAssets
        // 包括:每个 AssetBundle 文件、对应的 .manifest 文件、总清单文件
        foreach (var file in Directory.GetFiles(outputPath))
        {
            var srcFile = file;
            var dstFile = Path.Combine(streamingPath, Path.GetFileName(file));
            File.Copy(srcFile, dstFile, true);
        }

        // 刷新 AssetDatabase,让 Unity 识别 StreamingAssets 中新增的文件
        AssetDatabase.Refresh();
        Debug.Log("AssetBundle构建完成!已拷贝至 StreamingAssets/AssetBundles");
    }
}

点击菜单Tools > Build AssetBundles - Windows,等几秒钟,你会在Assets/StreamingAssets/AssetBundles/目录下看到:

复制代码
characters.hd                    ← 你的资源包
characters.hd.manifest           ← 资源清单(人类可读)
AssetBundles                     ← 总manifest
AssetBundles.manifest            ← 总清单的清单

注意那个没有后缀的AssetBundles文件,它是所有包的总目录,记录了包之间的依赖关系。后面会用到。

3.3 加载AssetBundle:四种姿势

csharp 复制代码
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Runtime.Versioning;
using UnityEngine;
using UnityEngine.Networking;

public class Init : MonoBehaviour
{
    // Start is called before the first frame update
    void Start()
    {
        // 方式1:同步加载(最快,但卡主线程)
        LoadSync();

        // 方式2:异步加载(推荐)
        StartCoroutine(LoadAsync());

        // 方式3:从内存加载(已下载到内存的byte[])
        // StartCoroutine(LoadFromMemory());

        // 方式4:从网络下载并缓存(热更场景)
        // StartCoroutine(LoadFromWeb());
    }

    /// <summary>
    /// 方式3:从内存加载。
    /// 适用场景:Bundle 数据已经在内存里(例如从自定义协议下载、从加密容器解密、从 PersistentData 读出再处理等)。
    /// 这里为了演示,先用 File.ReadAllBytes 把文件读成 byte[],再交给 AssetBundle.LoadFromMemoryAsync。
    /// 真实项目里通常是 UnityWebRequest/Socket 拿到的 byte[] 直接丢进来。
    /// </summary>
    IEnumerator LoadFromMemory()
    {
        string path = Path.Combine(Application.streamingAssetsPath, "AssetBundles/characters.hd");

        // StreamingAssets 在 Android 上是 jar 内的虚拟路径,File.ReadAllBytes 读不到,
        // 需要用 UnityWebRequest;这里是 Windows Standalone,直接读文件即可。
        if (!File.Exists(path))
        {
            Debug.LogError($"AssetBundle 不存在:{path}");
            yield break;
        }

        byte[] bytes = File.ReadAllBytes(path);

        AssetBundleCreateRequest req = AssetBundle.LoadFromMemoryAsync(bytes);
        yield return req;

        AssetBundle ab = req.assetBundle;
        if (ab == null)
        {
            Debug.LogError("从内存加载 AssetBundle 失败");
            yield break;
        }

        AssetBundleRequest assetReq = ab.LoadAssetAsync<GameObject>("Player");
        yield return assetReq;

        GameObject prefab = assetReq.asset as GameObject;
        if (prefab != null)
        {
            Instantiate(prefab);
        }
        else
        {
            Debug.LogError("AssetBundle 里未找到 Player");
        }

        ab.Unload(false); // 卸载 bundle,但保留已实例化出来的对象
    }

    void LoadSync()
    {
        string path = Path.Combine(Application.streamingAssetsPath, "AssetBundles/characters.hd");
        AssetBundle ab = AssetBundle.LoadFromFile(path);

        if (ab == null)
        {
            Debug.LogError("加载失败");
            return;
        }

        GameObject prefab = ab.LoadAsset<GameObject>("Player");
        Instantiate(prefab);

        ab.Unload(false); // 卸载bundle但保留已加载的资源
    }

    IEnumerator LoadAsync()
    {
        string path = Path.Combine(Application.streamingAssetsPath, "AssetBundles/characters.hd");
        AssetBundleCreateRequest req = AssetBundle.LoadFromFileAsync(path);
        yield return req;

        AssetBundleRequest assetReq = req.assetBundle.LoadAssetAsync<GameObject>("Player");
        yield return assetReq;

        Instantiate(assetReq.asset as GameObject);
    }

    IEnumerator LoadFromWeb()
    {
        string url = "https://your-cdn.com/AssetBundles/characters.hd";
        UnityWebRequest www = UnityWebRequestAssetBundle.GetAssetBundle(url);
        yield return www.SendWebRequest();

        if (www.result == UnityWebRequest.Result.Success)
        {
            AssetBundle ab = DownloadHandlerAssetBundle.GetContent(www);
            GameObject prefab = ab.LoadAsset<GameObject>("Player");
            Instantiate(prefab);
        }
    }
}

3.4 真正的难点:依赖管理

这是AssetBundle最劝退的地方。

假设你的Player.prefab引用了一个材质Hero.mat,材质又引用了贴图Hero_Diffuse.png。如果你只把Player.prefab打到了characters包,那材质和贴图会被冗余复制一份到这个包里。

如果你又把同一个材质用在了Enemy.prefab上,并打到enemies包,那材质又被复制了一份。这就是经典的资源冗余问题。

正确做法:把共享资源单独打包。

复制代码
characters.ab   ← 包含 Player.prefab、Enemy.prefab
shared_mat.ab   ← 包含 Hero.mat
shared_tex.ab   ← 包含 Hero_Diffuse.png

但加载时也得跟着改,先加载依赖,再加载主包

csharp 复制代码
IEnumerator LoadWithDependencies(string targetBundle)
{
    // 1. 先加载总manifest
    string manifestPath = Path.Combine(Application.streamingAssetsPath, "AssetBundles/AssetBundles");
    AssetBundle manifestBundle = AssetBundle.LoadFromFile(manifestPath);
    AssetBundleManifest manifest = manifestBundle.LoadAsset<AssetBundleManifest>("AssetBundleManifest");
    
    // 2. 查出目标包的所有依赖
    string[] dependencies = manifest.GetAllDependencies(targetBundle);
    
    // 3. 先把依赖全部加载
    foreach (string dep in dependencies)
    {
        string depPath = Path.Combine(Application.streamingAssetsPath, "AssetBundles", dep);
        var req = AssetBundle.LoadFromFileAsync(depPath);
        yield return req;
    }
    
    // 4. 最后加载目标包
    string targetPath = Path.Combine(Application.streamingAssetsPath, "AssetBundles", targetBundle);
    var targetReq = AssetBundle.LoadFromFileAsync(targetPath);
    yield return targetReq;
    
    GameObject prefab = targetReq.assetBundle.LoadAsset<GameObject>("Player");
    Instantiate(prefab);
}
  • 一个包被多次依赖,要做引用计数
  • 加载失败要重试和清理
  • 不同包不能重复加载,要做缓存表
  • 卸载时要按引用计数倒序释放
  • 同步加载和异步加载混用时的死锁

于是几乎所有项目都要写一个AssetBundleManager单例,少则三五百行,多则两三千行。代码复杂、坑多、出bug就是线上事故。

3.5 AssetBundle的痛点总结

痛点 表现
依赖管理复杂 需要手动维护引用计数、加载顺序
资源冗余 共享资源处理不当会重复打包
路径硬编码 代码里写满 "characters/Player" 字符串
名字耦合 Bundle名改了,代码全要改
热更新繁琐 要自己写版本比对、增量下载、断点续传
调试困难 Editor下没有bundle,行为和真机不一致

四、Addressables:新时代的资源管理

4.1 设计理念

Addressables(可寻址资源系统)核心想法只有一句话:

不要关心资源在哪、怎么打包、怎么加载,只要告诉我它的"地址",我给你拿过来。

这种"地址→资源"的解耦带来了一系列好处:

  • 资源放本地还是远程,代码不用改
  • bundle怎么打、打多大,代码不用改
  • 资源依赖、引用计数全自动
  • 同一个地址,运行时可以指向不同的资源(A/B测试)

4.2 核心概念速通

概念 解释
Address(地址) 资源的唯一标识,可以是任意字符串
Group(分组) 一组打包配置相同的资源,对应bundle
Label(标签) 资源的多对多分类标签,用于批量加载
AssetReference 在Inspector里拖拽的资源引用,代替地址字符串
Catalog(目录) 地址到资源的映射表,远程时支持热更

4.3 安装

打开Window > Package Manager,搜索Addressables安装。装好后会多出一个菜单Window > Asset Management > Addressables > Groups,这就是主面板。

第一次打开会让你点Create Addressables Settings,点一下就完成初始化了。


五、Addressables实战:从0到1

5.1 标记资源

有两种方式:

方式一:Inspector勾选

选中Prefab,在Inspector最上面有个Addressable复选框,勾上就行。地址默认是资源路径,你可以改成更易读的,比如Player

方式二:拖进Groups面板

打开Addressables Groups窗口,直接把资源拖进去。

标记完了之后,Addressables Groups窗口长这样:

scss 复制代码
Default Local Group
  ├── Player          (Assets/Prefabs/Player.prefab)
  ├── Enemy           (Assets/Prefabs/Enemy.prefab)
  └── MainMenuUI      (Assets/UI/MainMenuUI.prefab)

5.2 加载资源:告别字符串拼接

csharp 复制代码
using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.AsyncOperations;
using System.Threading.Tasks;

public class AddressablesDemo : MonoBehaviour
{
    async void Start()
    {
        // 直接通过地址加载
        AsyncOperationHandle<GameObject> handle = 
            Addressables.LoadAssetAsync<GameObject>("Player");
        
        await handle.Task;
        
        if (handle.Status == AsyncOperationStatus.Succeeded)
        {
            Instantiate(handle.Result);
        }
        
        // 用完一定要释放!
        // Addressables.Release(handle);  // 卸载资源
    }
}

注意几点:

  1. 没有 Resources.LoadAsync 那种回调地狱 ,直接 await
  2. 不用关心依赖,框架自动处理
  3. 不用关心bundle在哪,本地/远程切换零修改

5.3 实例化与释放:自动引用计数

csharp 复制代码
public class SpawnExample : MonoBehaviour
{
    private GameObject playerInstance;
    
    public async void Spawn()
    {
        // 一步到位的实例化
        AsyncOperationHandle<GameObject> handle = 
            Addressables.InstantiateAsync("Player", transform);
        
        await handle.Task;
        playerInstance = handle.Result;
    }
    
    public void Despawn()
    {
        if (playerInstance != null)
        {
            // 释放实例,框架自动减引用计数
            // 引用计数归零时,bundle自动卸载
            Addressables.ReleaseInstance(playerInstance);
            playerInstance = null;
        }
    }
}

这是Addressables最让人感动的地方:引用计数自动管理。AssetBundle时代你要手写几百行的UnloadManager,这里一行解决。

5.4 AssetReference:拒绝字符串硬编码

"Player"这种字符串还是很危险------改名就找不到了。Addressables提供了AssetReference

csharp 复制代码
using UnityEngine;
using UnityEngine.AddressableAssets;

public class ReferenceExample : MonoBehaviour
{
    // 在Inspector里拖拽资源
    public AssetReferenceGameObject playerPrefab;
    public AssetReferenceT<Texture2D> iconTexture;
    public AssetReferenceSprite avatarSprite;
    
    async void Start()
    {
        // 通过引用加载,编译期就能发现错误
        var handle = playerPrefab.InstantiateAsync();
        await handle.Task;
        
        // 加载贴图
        var texHandle = iconTexture.LoadAssetAsync();
        await texHandle.Task;
        // 用 texHandle.Result
    }
    
    void OnDestroy()
    {
        playerPrefab.ReleaseInstance(null);
    }
}

在Inspector里,AssetReference字段是一个支持拖拽的下拉框,只能选已经标记为Addressable的资源。资源改名、移动都不影响引用,比字符串安全一万倍。

5.5 标签Label:批量加载的利器

每个资源可以打多个标签,比如:

ini 复制代码
Player.prefab    标签: [character, hero]
Enemy.prefab     标签: [character, enemy]
Boss.prefab      标签: [character, enemy, boss]

加载时可以按标签批量取:

csharp 复制代码
async void LoadAllCharacters()
{
    var handle = Addressables.LoadAssetsAsync<GameObject>(
        "character",                   // 按标签
        asset => Debug.Log($"加载: {asset.name}")  // 单个完成回调
    );
    
    await handle.Task;
    
    // handle.Result 是 IList<GameObject>
    foreach (var prefab in handle.Result)
    {
        // 处理每个角色
    }
}

// 多标签组合(AND)
async void LoadBossEnemies()
{
    var handle = Addressables.LoadAssetsAsync<GameObject>(
        new List<string> { "enemy", "boss" },
        null,
        Addressables.MergeMode.Intersection  // 交集
    );
    await handle.Task;
}

这套机制特别适合:

  • 预加载某个关卡的全部角色
  • 一次性加载某个UI模块的所有图集
  • 按战斗类型加载所有同类型技能特效

5.6 热更新:服务器拉资源

服务器拉资源,客户端不用改代码,这个仅仅是简单的热更,不涉及到资源的增删改,以及只是关键的操作,具体操作请参考Addressables官方文档

把Group的Build Path改成Remote.BuildPathLoad Path改成Remote.LoadPath

sql 复制代码
Addressables Groups → 选中Group → Inspector
  Build & Load Paths: Remote

Addressables Profile里配置远程地址:

ini 复制代码
Remote.LoadPath = https://your-cdn.com/[BuildTarget]

构建时勾选Build Remote Catalog,把生成的远程文件上传到CDN。然后客户端代码不用任何改动!

热更检测代码:

csharp 复制代码
public async Task<bool> CheckAndUpdate()
{
    // 1. 初始化
    await Addressables.InitializeAsync().Task;
    
    // 2. 检查catalog是否有更新
    var checkHandle = Addressables.CheckForCatalogUpdates(false);
    await checkHandle.Task;
    
    List<string> catalogsToUpdate = checkHandle.Result;
    Addressables.Release(checkHandle);
    
    if (catalogsToUpdate.Count == 0)
    {
        Debug.Log("无需更新");
        return false;
    }
    
    // 3. 更新catalog
    var updateHandle = Addressables.UpdateCatalogs(catalogsToUpdate, false);
    await updateHandle.Task;
    Addressables.Release(updateHandle);
    
    // 4. 检查下载体积
    var sizeHandle = Addressables.GetDownloadSizeAsync("preload");
    await sizeHandle.Task;
    long totalBytes = sizeHandle.Result;
    Addressables.Release(sizeHandle);
    
    if (totalBytes > 0)
    {
        Debug.Log($"需要下载: {totalBytes / 1024f / 1024f:F2} MB");
        
        // 5. 下载资源
        var downloadHandle = Addressables.DownloadDependenciesAsync("preload", false);
        
        while (!downloadHandle.IsDone)
        {
            float percent = downloadHandle.PercentComplete;
            DownloadStatus status = downloadHandle.GetDownloadStatus();
            Debug.Log($"进度: {percent * 100:F1}% ({status.DownloadedBytes}/{status.TotalBytes})");
            await Task.Yield();
        }
        
        Addressables.Release(downloadHandle);
    }
    
    return true;
}

整个流程:检查更新 → 更新目录 → 计算体积 → 弹窗确认 → 下载 → 完成。对比AssetBundle时代要自己写的几千行更新逻辑,这里几十行搞定。


相关推荐
TO_WebNow1 小时前
使用thinkPHP8.x 访问接口提示跨域
前端·php
掘金一周1 小时前
回家的时候用车,不回家感觉又没啥用,这车还要不要买 | 沸点周刊 5.14
前端
梦想的颜色1 小时前
前端UI宝藏SKILL——UI/UX Pro Max
前端·ui·ux
無名路人2 小时前
uniApp 小程序 vue3 app.vue静默登录其他页面等待登录完成方式二
前端·微信小程序·ai编程
CoCo的编程之路2 小时前
2026 前端效能飞跃:深度解析智能助手的页面构建最大化方案
前端·人工智能·ai编程·智能编程助手·文心快码baiducomate
JavaAgent架构师2 小时前
前端AI工程化(一):AI通信协议深度解析
前端·人工智能
林恒smileZAZ2 小时前
前端如何让图片、视频、pdf等文件在浏览器直接下载而非预览
前端·pdf
孙6903422 小时前
electron播放本地任意格式的视频
前端·javascript
小小小小宇3 小时前
设计稿转代码:如何将生成代码与内部组件库关联
前端