在 Unity 开发中,如何高效管理和加载游戏资源,是决定项目性能和可维护性的重要因素。

Unity 这些年来先后推出了三种主要的资源加载方式:

Resources → AssetBundles → Addressables。

每一代系统都在解决上一代的局限性。

下面我们就从它们的历史和特点出发,一步步讲清楚它们的区别与使用场景。

1. Resources

当你度过了基本的新手期,开始编写代码之后,会发现一个问题,如果希望切换游戏对象,或者材质,难道每次都要将它拖入到游戏对象的组件上吗?

比如,暴露一个参数,然后拖入进去,这样就可以在游戏过程中实例化。

public class LoadFromPrefab : MonoBehaviour
{
    public GameObject prefab;

    void Start()
    {
        if (prefab != null)
        {
            GameObject cubeObject = Instantiate(prefab, new Vector3(0, 0, 0), Quaternion.identity);
        }
    }
}

但是每次都要拖来拖去,这肯定是不合适的,尤其是代码复杂之后,游戏对象的选择竟然是通过一个对象上的组件的临时绑定,不符合低耦合高内聚的特点。

在早期 Unity 版本中,Resources 文件夹是最方便的动态加载方式,任何放在名为 Resources 文件夹下的资源,都会自动被打包进游戏主包,无需额外配置。

首先创建一个 Prefab :

Resources Dir Prefab

加载语法直观明了,只需要通过其在 Resources 文件夹下的相对路径来决定:

using UnityEngine;

public class LoadFromResources : MonoBehaviour
{
    void Start()
    {
        GameObject prefab = Resources.Load<GameObject>("Cube");
        GameObject cubeObject = Instantiate(prefab, new Vector3(0, 0, 0), Quaternion.identity);
    }
}

运行之后就可以加载到游戏中:

Resources Cube Prefab

但是它的局限性是非常明显的:

  • 所有资源都会被打进主包,无法单独更新。

  • 不支持热更新或远程加载。

  • 没有依赖追踪与内存控制机制。

最后一点的依赖缺失是指,你加载对象的时候,它的材质贴图都被加载了,但是这些贴图材质的生命周期 Resources 是不会帮你管理的,想卸载只能采用:

Texture2D tex = Resources.Load<Texture2D>("Textures/Explosion");
Resources.UnloadAsset(tex);

// 全局
Resources.UnloadUnusedAssets()

并且如果贴图被其他地方使用了,贴图是不会卸载的。


2. AssetBundle

为了让资源能“分包加载、远程更新”,Unity 推出了 AssetBundle。 它允许你将资源打成独立的包,并在运行时动态加载。

首先需要标记资源,在资源的 Inspector 面板底部找到 AssetBundle,例如前文提到的 Cube 方块下方找到它。

AssetBundle Cube Setting

这里有2个选项可以设置,一个是 Bundle 名称,如果选择 None,资源不会被打包到 AssetBundle

后面的那个是为同一个 AssetBundle 创建不同版本(Variant),比如高清材质和低清材质,又比如中文和英文。

2.1 打包

在设置好这些选项之后,选择一个目录,用下面的代码就可以实现打包所有被设置的资源,最终是要放在 StreamingAssets 下的,因为这样游戏构建后才能在最终的游戏目录中找到:

using UnityEditor;
using UnityEngine;

public class ABBuilder
{
    [MenuItem("Build/Build AssetBundles")]
    static void BuildAllAssetBundles()
    {
        string outputPath = Application.dataPath + "/StreamingAssets/AssetBundles";

        if (Directory.Exists(outputPath))
        {
            Directory.Delete(outputPath, true);
            Debug.Log("清空旧的 AssetBundles 目录");
        }
        Directory.CreateDirectory(outputPath);

        BuildPipeline.BuildAssetBundles(
            outputPath,
            BuildAssetBundleOptions.None, // 可根据需求选择压缩方式
            BuildTarget.StandaloneWindows64  // 根据平台选择
        );

        Debug.Log("AssetBundle 打包完成!路径:" + outputPath);
    }
}

这里提一下,就是打包用代码打包比较麻烦,通常用 AssetBundles Browser 来创建和浏览 AssetBundle。这个工具的底层依然是上面的代码进行打包,所以不在此过多介绍。

这个工具来同时提供了预览功能,帮助你查看一个已经打好的包的内部有什么。

打包完毕后,就有了包体资源:

AssetBundles

其中的 AssetBundles 是文件夹的名字,它包含了下面所有打的包的一些信息,比如依赖。

home 就是具体的二进制资源包。

home.manifest 记录了该 Bundle 的依赖关系和校验信息。

AssetBundles.manifest 记录了所有 Bundle 的依赖关系、变体(Variant)、哈希值、版本号等信息,用于加载和管理依赖。

然后就是加载和使用,因为打出来的包叫 home.cn,所以可以直接用它

using System.IO;
using UnityEngine;

public class LoadFromAssetBundle : MonoBehaviour
{
    private void Start()
    {
        string selectedBundle = "home.cn";

        AssetBundle ab = AssetBundle.LoadFromFile(Path.Combine(Application.streamingAssetsPath, "AssetBundles", selectedBundle));

        GameObject cubePrefab = ab.LoadAsset<GameObject>("Cube");
        Instantiate(cubePrefab);
    }
}

效果:

LoadFromAssetBundle


2.2 变种包

如果我们希望根据前缀来找到变种包,可以通过搜索所有匹配的包的包名来找到,先加载文件夹同名的 AssetBundles,然后加载其中的资产 AssetBundleManifest,调用 GetAllAssetBundlesWithVariant 就可以获取所有变种,然后逐名匹配。

using System.IO;
using UnityEngine;

public class LoadFromAssetBundle : MonoBehaviour
{
    private void Start()
    {
        string manifestPath = Path.Combine(Application.streamingAssetsPath, "AssetBundles/AssetBundles");
        AssetBundle manifestBundle = AssetBundle.LoadFromFile(manifestPath);
        AssetBundleManifest manifest = manifestBundle.LoadAsset<AssetBundleManifest>("AssetBundleManifest");
        
        string[] bundlesWithVariant = manifest.GetAllAssetBundlesWithVariant();
        string targetBundleName = "home";
        string selectedBundle = targetBundleName;
        
        foreach (var bundle in bundlesWithVariant)
        {
            if (bundle.StartsWith(targetBundleName + "."))
            {
                selectedBundle = bundle; // 选中 cn 版本
                Debug.Log("Selected Bundle: " + selectedBundle);
                break;
            }
        }
    }
}

它是可以自动找到所有依赖的:

GetAllAssetBundlesWithVariant


2.3 包依赖

给 Cube 上一个材质,分配相同的 Bundle:

Green Cube

home.cn.manifest 中是能够看到打包了材质和 Prefab 进去。

- Assets/Resources/CubeMaterial.mat
- Assets/Resources/Cube.prefab

如果分配给不同的 Bundle 会发生什么呢?

CubeMaterialBundleSet

会发生材质丢失的效果,表现为紫色:

load fail

还记得前面我们从主包中找到所有变体吗?其实除了这个之外,主包中的 manifest 还包含了所有的依赖,通过读取依赖,就能自动分析包的依赖,实现载入材质了。

using System.IO;
using UnityEngine;

public class LoadFromAssetBundle : MonoBehaviour
{
    private void Start()
    {
        string manifestPath = Path.Combine(Application.streamingAssetsPath, "AssetBundles/AssetBundles");
        AssetBundle manifestBundle = AssetBundle.LoadFromFile(manifestPath);
        AssetBundleManifest manifest = manifestBundle.LoadAsset<AssetBundleManifest>("AssetBundleManifest");

        string targetBundle = "home.cn";
        
        string[] bundlesDependencies = manifest.GetAllDependencies(targetBundle);

        for (int i = 0; i < bundlesDependencies.Length; i++)
        {
            Debug.Log(bundlesDependencies[i]);
            AssetBundle.LoadFromFile(Path.Combine(Application.streamingAssetsPath, "AssetBundles", bundlesDependencies[i]));
        }
        
        AssetBundle ab = AssetBundle.LoadFromFile(Path.Combine(Application.streamingAssetsPath, "AssetBundles", targetBundle));
        
        GameObject cubePrefab = ab.LoadAsset<GameObject>("Cube");
        Instantiate(cubePrefab);
    }
}

效果:

load green cube success


2.4 卸载资源

前面只谈了载入,没有谈如何卸载资源。

简单来说,通过下面命令卸载包资源:

assetBundle.Unload(bool unloadAllLoadedObjects);

其中的 truefalse 的区别在于:

  • false:只卸载 AssetBundle 文件本身(释放磁盘句柄),但不会清除已经加载到内存中的对象。
  • true:不仅卸载 AssetBundle 文件,还会 销毁所有通过该包加载的对象(包括材质、贴图、Mesh 等)。

对于材质和贴图来说,使用 true 卸载会导致这些资源被销毁,从而出现粉色材质(Missing Texture / Shader) 的情况。这是因为场景中的对象仍然引用这些已经被释放的资源。

但是如果是使用 Instantiate 生成的 GameObject,并不会导致这个物体立即消失,因为 Instantiate 实际上创建了一个资源的副本。 只是副本内部引用的材质、贴图等资源已经被卸载,就会出现显示异常或引用丢失。

通常的做法是:

// 使用完后卸载包文件,但保留资源
assetBundle.Unload(false);

// 当确认所有资源都不再使用时再彻底释放
assetBundle.Unload(true);
Resources.UnloadUnusedAssets(); // 可进一步清理未被引用的对象

2.5 异步加载

异步加载 AssetBundle 文件:

AssetBundleCreateRequest request = AssetBundle.LoadFromFileAsync(path);
yield return request;

AssetBundle bundle = request.assetBundle;

异步加载 Bundle 中的资源:

AssetBundleRequest assetRequest = bundle.LoadAssetAsync<GameObject>("Cube");
yield return assetRequest;

GameObject prefab = assetRequest.asset as GameObject;
Instantiate(prefab);

2.6 包管理工具实例

注意每个 bundle 都是只能加载一次的,除非你卸载了。所以通过一个包管理工具来统一管理这些包是有必要的。

按照以下原则来编写:

  • 使用 单例模式 管理唯一实例
  • 使用一个 字典 记录已加载的所有包
  • 提供 加载 / 卸载 / 卸载所有 功能(为简化,本例都是同步版本)
using System.Collections.Generic;
using System.IO;
using UnityEngine;

public class AssetBundleManager
{
    // 单例实例
    private static AssetBundleManager _instance;
    public static AssetBundleManager Instance
    {
        get
        {
            if (_instance == null)
                _instance = new AssetBundleManager();
            return _instance;
        }
    }

    // 已加载的所有 AssetBundle
    private Dictionary<string, AssetBundle> _loadedBundles = new Dictionary<string, AssetBundle>();

    // 基础路径(可以改成 Addressables 或服务器路径)
    private string _basePath = Path.Combine(Application.streamingAssetsPath, "AssetBundles");

    private AssetBundleManager() { } // 禁止外部构造

    /// <summary>
    /// 加载一个 AssetBundle(同步)
    /// </summary>
    public AssetBundle LoadBundle(string bundleName)
    {
        // 已经加载过就直接返回
        if (_loadedBundles.TryGetValue(bundleName, out AssetBundle bundle))
            return bundle;

        string fullPath = Path.Combine(_basePath, bundleName);
        if (!File.Exists(fullPath))
        {
            Debug.LogError($"AssetBundle 不存在: {fullPath}");
            return null;
        }

        bundle = AssetBundle.LoadFromFile(fullPath);
        if (bundle == null)
        {
            Debug.LogError($"加载 AssetBundle 失败: {bundleName}");
            return null;
        }

        _loadedBundles[bundleName] = bundle;
        Debug.Log($"加载 AssetBundle 成功: {bundleName}");
        return bundle;
    }

    /// <summary>
    /// 卸载一个 AssetBundle
    /// </summary>
    public void UnloadBundle(string bundleName, bool unloadAllObjects = false)
    {
        if (_loadedBundles.TryGetValue(bundleName, out AssetBundle bundle))
        {
            bundle.Unload(unloadAllObjects);
            _loadedBundles.Remove(bundleName);
            Debug.Log($"卸载 AssetBundle 成功: {bundleName}");
        }
    }

    /// <summary>
    /// 卸载所有已加载的 AssetBundle
    /// </summary>
    public void UnloadAll(bool unloadAllObjects = false)
    {
        foreach (var kv in _loadedBundles)
        {
            kv.Value.Unload(unloadAllObjects);
        }

        _loadedBundles.Clear();
        Debug.Log("已卸载所有 AssetBundle");
    }
}

使用示例

public class TestABManager : MonoBehaviour
{
    private void Start()
    {
        // 加载包
        var ab = AssetBundleManager.Instance.LoadBundle("home.cn");
        if (ab != null)
        {
            GameObject cube = ab.LoadAsset<GameObject>("Cube");
            Instantiate(cube);
        }

        // 卸载单个包
        // AssetBundleManager.Instance.UnloadBundle("home.cn");

        // 卸载所有包
        // AssetBundleManager.Instance.UnloadAll();
    }
}

2.7 有什么麻烦的地方?

AssetBundle 是一种底层、手动控制的资源打包与加载机制,功能强大但限制较多:

  • 依赖需手动管理 需要通过 AssetBundleManifest.GetAllDependencies() 手动加载依赖,卸载时也不会自动判断资源是否仍被使用。Addressables 引入了自动引用计数机制来解决这一问题。

  • 缺乏增量与差分更新 资源改动后必须重新打整个 Bundle,没有内置的增量更新或差分机制。

  • 打包流程繁琐 每个资源都需手动设置 AssetBundle 名称,打包容易出错,流程不直观。

  • 无内建版本与更新管理 不支持版本号、校验、或 CDN 缓存控制,需要自行实现更新与校验逻辑。

  • 平台相关性强 不同平台(Windows / Android / iOS)需要分别打包,无法通用。

AssetBundle 功能强大,但开发和维护成本较高,尤其在大型项目中。

但是目前为止,大多数项目还是采用的 AssetBundle,因为这些项目都是老项目,程序员们通过手动编写代码把上面的问题已经处理好了,所以你去找工作看到用 AssetBundle 非常合理。

Unity 官方考虑到重复造轮子的问题,推出了 Addressables 系统。


3. Addressables

Unity 的 Addressables 是基于 AssetBundle 的高层封装。

它继承了 AssetBundle 的灵活性,同时自动化了依赖管理、异步加载、远程更新等繁琐流程。

3.1 安装和创建 Addressables

Package Manager 中搜索 Addressables,选择安装即可获得该功能。

Window 中找到 asset manager 中的 addressables groups,打开即可创建 Addressables

create addressables groups

如果原本有 Asset Bundle,会提示你转换。

Addessables Groups Inspector

实际上就会自动创建一个文件夹,这里面就是各种配置文件,这些配置文件,也是 ScriptableObjects

Addessables Groups Directory

Prefab 或者其它资产上放可以找到一个 Addressable 的按钮。

Inspector addrerssable radio button

点击后这个资产就会添加到 addressables groups 中,第一次点击会自动之上上面的创建 Group 的工作。

Inspector addrerssable radio button clicked

Group 里面可以进行各种选择:

Addressable Group Panel

这里面最关键的是 Addressable Name,可以点击简化按钮简化这个可寻址名,这个 Addressable Name 是唯一的,根据这个就可以确定指定的资产是什么。


3.2 使用 Addressables

每个 Addressables Asset 都是可以通过下面的 AssetReference 来绑定。有泛型类型可以支持各种资产,AssetReference 是通用不区分具体资产,另外还有一些指定类型的写法:

public class LoadFromAddressable : MonoBehaviour
{
    public AssetReference assetReference;
    
    public AssetReferenceGameObject assetReferenceGameObject;

    public AssetReferenceT<Material> assetReferenceMaterial;
}

然后就可以选择类型:

Asset Reference Interact

使用需要使用异步加载的方式,然后通过完成时的回调来获取资产,具体资产通过 handle.Result 获取:

using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.AsyncOperations;

public class LoadFromAddressable : MonoBehaviour
{
    public AssetReference assetReference;

    private void Start()
    {
        AsyncOperationHandle<GameObject> handle = assetReference.LoadAssetAsync<GameObject>();
        handle.Completed += HandleOnCompleted;
        
    }

    private void HandleOnCompleted(AsyncOperationHandle<GameObject> handle)
    {
        if (handle.Status == AsyncOperationStatus.Succeeded)
        {
            Instantiate(handle.Result);
        }
    }
}

运行后就可以看到对象:

LoadFromAddressable Result

可以简写:

using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.AsyncOperations;

public class LoadFromAddressable : MonoBehaviour
{
    public AssetReference assetReference;

    private void Start()
    {
        assetReference.LoadAssetAsync<GameObject>().Completed += handle =>
        {
            if (handle.Status == AsyncOperationStatus.Succeeded)
            {
                Instantiate(handle.Result);
            }
        };
    }
}

还有种写法,是直接通过 AssetReference 来判断,IsDone 的情况下可以通过 Asset 直接获取这个资源:

public class LoadFromAddressable : MonoBehaviour
{
    public AssetReference assetReference;

    private void Start()
    {
        assetReference.LoadAssetAsync<GameObject>().Completed += handle =>
        {
            if (assetReference.IsDone)
            {
                Instantiate(assetReference.Asset as GameObject);
            }
        };
    }
}

3.3 用 Name 方式使用资产

上面的需要可视化绑定,通常项目中是直接用 Addressable Name 来获取指定资产:

using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.AsyncOperations;

public class LoadFromAddressableByName : MonoBehaviour
{
    [SerializeField] private string address = "MyPrefab"; // 对应 Addressables 中的地址名

    private void Start()
    {
        // 使用地址字符串加载 GameObject
        Addressables.LoadAssetAsync<GameObject>(address).Completed += OnAssetLoaded;
    }

    private void OnAssetLoaded(AsyncOperationHandle<GameObject> handle)
    {
        if (handle.Status == AsyncOperationStatus.Succeeded)
        {
            Instantiate(handle.Result);
        }
        else
        {
            Debug.LogError($"加载失败: {address}");
        }
    }
}

3.3 Group、Addresssable Name、Label

  • Group 是 Addressables 的打包和加载管理单位。

    • 每个 Group 会在构建时生成一个或多个 AssetBundle 文件。
    • Group 控制资源的打包策略(如本地/远程、压缩方式、构建路径、加载路径等)。
    • 一个资源只能属于一个 Group。
  • Address 是资源的唯一访问名称。

    • Address 在整个 Addressables 项目范围内必须唯一。
    • Group、Label 都只是组织和分类方式,不会影响 Address 的唯一性约束。
  • Label 是一个可选的分组标记,可以给多个资源打相同的标签。

    • 一个资源可以有多个标签。
    • 标签用于批量加载或筛选,比如我就想一口气加载所有的敌人

加载标签和加载 address 的语法有一点区别:

// 加载单个 Address
Addressables.LoadAssetAsync<GameObject>("MyCube").Completed += OnLoaded;

// 按 Label
Addressables.LoadAssetsAsync<GameObject>("Enemy", obj => { Instantiate(obj); });

// 按多个 Address
List<string> addresses = new List<string>{"Goblin", "Orc"};
Addressables.LoadAssetsAsync<GameObject>(addresses, obj => { Instantiate(obj); });

总结来说,Group 只是打包的形式上的分组,Label属于标签分组,真正决定资源的只有 address。

至于 AssetBundle 中的变种概念已经没有了。因为变种完全可以用不同的 bundle 命名或资产命名来代替,实际上用 Name 就可以区分资源了。比如 image1_hd.pngimage1_low.png,不需要刻意变种。


3.4 本地更新

首先,我们将材质和用到这个材质的预制体都放入 Group 中:

Local Update Group

初次将材质设置为绿色:

Local Update First Material

初次构建 Addressable

Local Update First Build

构建之后可以打包整个游戏,此时打开游戏材质就是绿色。

然后,在编辑器中更改材质的颜色:

Local Update Second Material

使用 Update a Previous Build 来增量构建:

Local Update Update Build

更新之后在 Asset Manager Test\Library\com.unity.addressables\aa\Windows 下的文件会发生更改:

addressables build target directory

直接全部移动到 StreamingAssets\aa 中,注意这里针对的是 Windows 平台,其他平台有一些区别。

最后的效果,替换后打开游戏,就是新的材质的颜色:

Local Update Final


3.5 远程更新 和 Catalog

在 Addressable Profiles 中,设置 Remote 地址,这要是一个网址的前缀,比如这里采用的是本地的 8888:http://127.0.0.1:8888

addressable profiles panel

然后要开启 remote 的 Catalog。

Catalog 是 Addressables 的资源索引文件,它告诉程序从哪里加载资源,以及当前资源的版本和依赖关系。可以开启 json 格式,这样方便人为查看。

enable remote catalog

然后第一次编译可以选择先清空 build,然后点击 New Build 来构建。

clear and new build

编译整个游戏,然后打开游戏:

remote game first start

接下来演示更新,首先,对于希望远程更新的 Group,先设置为 Remote 模式:

set group to remote mode

然后设置材质为另一个颜色:

change material remote group

然后使用 Update 更新:

remote update a previous build

现在 remote 文件夹 ServerData/StandaloneWindows64 里面,首先会将原本的 Catalog 更新,并且多出更新的 bundle 包:

ServerData Remote Build Directory

直接进入 remote 的输出目录,可以选择直接在这个目录启动一个 http 服务来进行测试,比如这里采用 8888 端口:

cd ServerData/StandaloneWindows64
python -m http.server 8888

然后我们启动游戏,会发现此时材质已经变化了:

remote update new game

查看下 http server 的访问日志,游戏更新资产的流程是:

  • 对比 catalog 的 hash
  • 如果 catalog 的 hash 有变化,更新 catalog
  • 根据 catalog,对新的资源进行访问,如果是远程资源,就下载
Serving HTTP on :: port 8888 (http://[::]:8888/) ...
::ffff:127.0.0.1 - - [07/Nov/2025 15:25:58] "GET //catalog_0.1.0.hash HTTP/1.1" 200 -
::ffff:127.0.0.1 - - [07/Nov/2025 15:25:58] "GET //catalog_0.1.0.json HTTP/1.1" 200 -
::ffff:127.0.0.1 - - [07/Nov/2025 15:25:58] "GET /r_assets_all_02c27bfe06053568040c18a7d35f8ed6.bundle HTTP/1.1" 200 -

因此,如果有未来更新的需求,使用 Addressable 的话,初次可以先全部本地 Build 打包,只需要先生成 Remote Catalog 就可以预留未来的更新了。

也可以初次就放在远程服务器。注意打包的时候 Remote 的 Group 是不会放进游戏中的,如果你感觉放进去了,很可能是忘记清空缓存了,去下列地址清空缓存:

C:\Users\<用户名>\AppData\LocalLow\<公司名>\<游戏名>\com.unity.addressables


3.6 游戏启动时手动更新 Catalogs

前面的更新是全自动的,在首次访问资源的时候触发,但是也可以手动来主动更新 Catalogs:

only update catalogs manually

Addressables 不会自动检查更新,你必须手动调用 CheckForCatalogUpdates()UpdateCatalogs()

首先判断是否有需要更新的 Catalog,然后可以进行更新。

var checkHandle = Addressables.CheckForCatalogUpdates();
var catalogsToUpdate = await checkHandle.Task;

if (catalogsToUpdate != null && catalogsToUpdate.Count > 0)
{
    Debug.Log($"检测到 {catalogsToUpdate.Count} 个 catalog 有更新。");

    var updateHandle = Addressables.UpdateCatalogs(catalogsToUpdate);
    await updateHandle.Task;

    Debug.Log("Catalog 更新完成。");
}
else
{
    Debug.Log("没有检测到 Catalog 更新。");
}

Addressables.Release(checkHandle);

Catalog 准确来说是一个 bundle 列表,如果不更新就还是会去找之前 Catalog 里面的 bundle 的位置。

在更新 Catalog 之后还需要更新 Bundle,总体上可以设计在载入阶段的 ui 界面启动一个下载流程,更新 Catalog,然后下载所有相关的 Bundle,再最终进入到游戏的流程。