Unity AssetBundle 极限管理:依赖、异步加载、引用计数、自动卸载到底怎么串起来?

AssetBundle 真正难的从来不是这一句:

bash 复制代码
AssetBundle.LoadFromFile(path);

而是项目规模变大以后随之而来的问题:

资源在哪个 Bundle?依赖包谁先加载?多个地方同时请求怎么办?远端资源没下载怎么办?什么时候才能安全卸载?一个公共依赖又怎么避免被提前卸掉?

MyFramework 的 ResourceManager + AssetBundleLoader + AssetBundleInfo + AssetInfo + ResourceRef,就是围绕这些问题组成了一套完整的 AssetBundle 生命周期管理。

项目地址:

github.com/ZHOURUIH/My...

一、业务层根本不关心资源在哪个 AssetBundle

业务加载资源时只写:

bash 复制代码
ResourceRef<GameObject> prefab =
	mResourceManager.loadGameResource<GameObject>(
		"UI/Login.prefab");

传进去的是 GameResources 下的资源路径,而不是 Bundle 名。

ResourceManager 在编辑器和正式包中还可以使用不同加载源:

bash 复制代码
mLoadSource = isEditor() ?
	GameEntryBase.getInstance().mFrameworkParam.mLoadSource :
	LOAD_SOURCE.ASSET_BUNDLE;

所以编辑器可以直接走 AssetDatabase,打包后则强制进入 AssetBundle。

上层代码完全不用分两套。

二、启动时先建立"资源 → Bundle"索引

AssetBundle 系统初始化时首先读取:

bash 复制代码
STREAMING_ASSET_FILE

这个文件保存了每个 Bundle 的:

bash 复制代码
Bundle名字
包含的所有Asset
依赖的Bundle

解析以后建立:

bash 复制代码
Dictionary<string, AssetBundleInfo> mAssetBundleInfoList;
Dictionary<string, AssetInfo> mAssetToBundleInfo;

因此以后给出:

bash 复制代码
UI/Login.prefab

就可以直接定位到:

bash 复制代码
AssetInfo
↓
AssetBundleInfo
↓
真正的AssetBundle

不需要运行时再扫描 Manifest 或遍历所有 Bundle。

三、依赖关系不是加载时临时查的

每个 AssetBundleInfo 同时保存:

bash 复制代码
Dictionary<string, AssetBundleInfo> mParents;
Dictionary<string, AssetBundleInfo> mChildren;

其中:

bash 复制代码
Parents
当前Bundle依赖了谁

Children
谁依赖了当前Bundle

初始化结束以后统一执行:

bash 复制代码
bundleInfo.findAllDependence();

把依赖名字真正解析成 AssetBundleInfo,同时反向建立 Children。

这样一张依赖图在游戏开始前就已经建立完成。

四、加载自己之前,先把所有依赖拉起来

同步加载时非常直接:

bash 复制代码
foreach (var item in mParents)
{
	item.Value.loadAssetBundle();
}

mAssetBundle =
	AssetBundle.LoadFromFile(
		availableReadPath(mBundleFileName));

异步也是同样原则。

先触发:

bash 复制代码
loadParentAsync();

真正轮到当前 Bundle 加载时,再等待:

bash 复制代码
while (!bundleInfo.isAllParentLoaded())
{
	yield return null;
}

所以:

bash 复制代码
角色Bundle
├─ 材质Bundle
├─ ShaderBundle
└─ 公共贴图Bundle

不会出现角色已经开始加载,依赖包却还没准备好的情况。

五、本地没有 Bundle?加载流程直接接上下载

异步加载时首先查询:

bash 复制代码
string fullPath =
	availableReadPath(bundleFileName);

这个函数会结合前面讲过的资源版本系统,判断 PersistentDataStreamingAssets 中有没有当前可用版本。

如果返回 null

bash 复制代码
本地没有最新版Bundle
↓
从服务器下载
↓
写入PersistentData
↓
更新本地FileList
↓
LoadFromMemoryAsync

有本地文件则直接:

bash 复制代码
AssetBundle.LoadFromFileAsync(fullPath);

所以 AssetBundle 管理和上一篇的资源热更新并不是两套独立系统。

热更新决定文件在哪里,AssetBundleLoader 决定怎么把它加载进内存。

六、十个地方同时请求,不会加载十遍

一个 Bundle 正在异步加载时,再次请求不会重新开始加载,而是加入:

bash 复制代码
mLoadCallbackList

下载也是一样。

如果当前状态已经是:

bash 复制代码
LOAD_STATE.DOWNLOADING

新请求只加入:

bash 复制代码
mDownloadCallbackList

单个 Asset 也维护自己的:

bash 复制代码
List<AssetLoadCallback> mCallback;

所以最终结构其实是:

bash 复制代码
请求A ┐
请求B ├─→ 同一个Bundle加载任务
请求C ┘
              ↓
          加载完成
              ↓
        一次性通知全部回调

这对 UI、角色、特效同时依赖公共资源的场景非常重要。

七、Bundle 加载完成,不代表资源也全部加载

MyFramework 没有在加载 Bundle 后马上把内部所有资源都实例化出来。

真正访问:

bash 复制代码
UI/Login.prefab

时才调用:

bash 复制代码
LoadAssetWithSubAssets(...)

异步则:

bash 复制代码
LoadAssetWithSubAssetsAsync(...)

如果资源请求到来时 Bundle 还没加载完成,它会先进入:

bash 复制代码
mLoadAsyncList

等 Bundle 完成以后再自动触发这些 Asset 的异步加载。

因此是两层生命周期:

bash 复制代码
AssetBundle
↓
按需加载Asset

而不是"加载一个 Bundle 就把里面所有东西全塞进内存"。

八、资源生命周期不是靠业务猜,而是 ResourceRef

加载成功返回的不是裸 UnityEngine.Object,而是:

bash 复制代码
ResourceRef<T>

设置资源时:

bash 复制代码
mToken =
	mResourceManager.addReference(mResource);

每一个引用对象都会拿到唯一 Token。

复制一份所有权:

bash 复制代码
ResourceRef<T> newRef =
	oldRef.copyRef();

又会产生新的 Token。

释放:

bash 复制代码
mResourceManager.unload(ref prefab);

最终 ResourceRef.destroy() 会:

bash 复制代码
mResourceManager.removeReference(
	mResource,
	ref mToken);

所以真正管理的不是一句简单的:

bash 复制代码
引用计数++
引用计数--

而是每个持有者都有独立引用凭证,还能检测重复释放等异常。

九、资源没引用了,也不会立刻把 Bundle 干掉

ResourceManager 每隔 3 秒检查一次资源引用。

某个资源已经没有任何 Token 时,才真正卸载 Asset。

然后 AssetBundleInfo 再判断整个 Bundle 是否满足:

bash 复制代码
自己的所有Asset都已经不用了
+
没有仍在使用的其他Bundle依赖自己

核心判断就是:

bash 复制代码
foreach (var item in mAssetList)
{
	if (item.Value.getLoadState() != LOAD_STATE.NONE)
	{
		return false;
	}
}

foreach (var item in mChildren)
{
	if (item.Value.getLoadState() != LOAD_STATE.NONE)
	{
		return false;
	}
}

这解决了公共依赖最容易出现的问题:

bash 复制代码
A依赖Common
B也依赖Common

A释放
↓
Common不能卸

因为B还在使用

十、满足卸载条件后,还要再等5秒

即使 canUnload() 已经返回 true,也不会立即:

bash 复制代码
AssetBundle.Unload(true);

而是设置:

bash 复制代码
mWillUnloadTime = 5.0f;

五秒以后再次确认仍然可以卸载,才真正释放。

这是为了避免:

bash 复制代码
打开UI
↓
关闭UI
↓
Bundle立即卸载
↓
1秒后又打开UI
↓
Bundle重新加载

这种高频抖动。

某些常驻资源包还可以直接加入:

bash 复制代码
addDontUnloadAssetBundle(...)

彻底禁止自动卸载。

十一、一套 AssetBundle 管理最终在管理什么?

整个生命周期可以浓缩成:

bash 复制代码
资源路径
↓
Asset索引
↓
找到AssetBundle
↓
递归准备依赖
↓
本地存在?
├─ Yes → LoadFromFileAsync
└─ No  → 下载 → LoadFromMemoryAsync
↓
Bundle加载完成
↓
按需LoadAsset
↓
ResourceRef持有引用
↓
引用全部释放
↓
检查其他Bundle是否仍然依赖
↓
等待5秒
↓
再次确认
↓
AssetBundle.Unload(true)

所以一个真正完整的 AssetBundle 管理器,核心并不是"把 AB 加载出来"。

它真正管理的是:

资源定位、依赖图、并发请求合并、远端下载、Asset 按需加载、引用生命周期以及安全卸载。

只有把这整条链路都管住,AssetBundle 才真正从一个 Unity API,变成项目里可以长期稳定使用的资源系统。

相关推荐
SmalBox6 小时前
01-09-认知篇-对比-方案选型矩阵
unity3d·游戏开发
鑫鑫哥adam1 天前
用一个 struct 给游戏关键数值上锁:ProtectedInt 反作弊实践
unity3d
fujisheng6611 天前
FUI 导航实践:拆开 Layer、History、Coverage 与 Cache 的组合语义
c#·unity3d
SmalBox1 天前
01-08-认知篇-对比-其他资源管理方案
unity3d·游戏开发
SmalBox2 天前
01-07-认知篇-对比-原生AssetBundle工作流
unity3d·游戏开发
SmalBox3 天前
01-06-认知篇-对比-Addressable Assets深度解析
unity3d·游戏开发
fujisheng6613 天前
FUI 绑定生命周期实战:可靠解绑、失败回滚与列表项换绑
c#·unity3d
_zhourui_h_3 天前
Unity 资源热更新极限拆解:几万个文件,怎么做到只下载真正变动的那几个?
unity3d
SmalBox4 天前
01-05-认知篇-基础-Unity资源管理痛点分析
unity3d·游戏开发