在 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);

其中的 true 和 false 的区别在于:

  • 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.png 和 image1_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,再最终进入到游戏的流程。