在 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 文件夹下的相对路径来决定:
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 是不会帮你管理的,想卸载只能采用:
Texture2D tex = Resources.Load<Texture2D>("Textures/Explosion");
Resources.UnloadAsset(tex);
// 全局
Resources.UnloadUnusedAssets()
并且如果贴图被其他地方使用了,贴图是不会卸载的。
2. AssetBundle
为了让资源能“分包加载、远程更新”,Unity 推出了 AssetBundle。 它允许你将资源打成独立的包,并在运行时动态加载。
首先需要标记资源,在资源的 Inspector 面板底部找到 AssetBundle,例如前文提到的 Cube 方块下方找到它。

这里有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 是文件夹的名字,它包含了下面所有打的包的一些信息,比如依赖。
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);
}
}
效果:

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;
}
}
}
}
它是可以自动找到所有依赖的:

2.3 包依赖
给 Cube 上一个材质,分配相同的 Bundle:

在 home.cn.manifest 中是能够看到打包了材质和 Prefab 进去。
- Assets/Resources/CubeMaterial.mat
- Assets/Resources/Cube.prefab
如果分配给不同的 Bundle 会发生什么呢?

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

还记得前面我们从主包中找到所有变体吗?其实除了这个之外,主包中的 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);
}
}
效果:

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。

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

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

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

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

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

这里面最关键的是 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;
}
然后就可以选择类型:

使用需要使用异步加载的方式,然后通过完成时的回调来获取资产,具体资产通过 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);
}
}
}
运行后就可以看到对象:

可以简写:
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 中:

初次将材质设置为绿色:

初次构建 Addressable:

构建之后可以打包整个游戏,此时打开游戏材质就是绿色。
然后,在编辑器中更改材质的颜色:

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

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

直接全部移动到 StreamingAssets\aa 中,注意这里针对的是 Windows 平台,其他平台有一些区别。
最后的效果,替换后打开游戏,就是新的材质的颜色:

3.5 远程更新 和 Catalog
在 Addressable Profiles 中,设置 Remote 地址,这要是一个网址的前缀,比如这里采用的是本地的 8888:http://127.0.0.1:8888。

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

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

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

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

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

然后使用 Update 更新:

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

直接进入 remote 的输出目录,可以选择直接在这个目录启动一个 http 服务来进行测试,比如这里采用 8888 端口:
cd ServerData/StandaloneWindows64
python -m http.server 8888
然后我们启动游戏,会发现此时材质已经变化了:

查看下 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:

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