一、为什么我们需要"资源管理"
先抛一个问题:你做一个手游,里面有1000张角色立绘、500个UI图标、200个场景。把它们全部塞进游戏包里行不行?
技术上可以,但你会面临三个致命问题:
- 包体爆炸。
- 更新困难。
- 内存溢出。
资源管理的本质,就是解决包体、热更、内存这三件事。
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); // 卸载资源
}
}
注意几点:
- 没有
Resources.LoadAsync那种回调地狱 ,直接await - 不用关心依赖,框架自动处理
- 不用关心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.BuildPath,Load 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时代要自己写的几千行更新逻辑,这里几十行搞定。