项目地址:
ResourceRef<T> 解决的是资源引用问题。
但资源引用清空后,AssetBundle 不能马上卸载。
因为 AssetBundle 不是单个资源。
一个 AssetBundle 可能包含多个资源,也可能被其他 AssetBundle 依赖。
所以 MyFramework 在 AssetBundleInfo 中做了独立的卸载判断。
一、问题
资源释放和 AssetBundle 卸载不是一回事。
资源释放只表示:
bash
某个 UnityEngine.Object 不再被业务持有
AssetBundle 能不能卸载,还要看:
bash
包内资源是否都释放了
是否有其他包依赖自己
当前包是否正在加载
是否允许卸载
是否已经过了延迟卸载时间
所以 MyFramework 没有在资源释放时直接卸载 AssetBundle。
它先释放资源,再由 AssetBundleInfo 判断整个包是否可以卸载。
二、依赖结构
AssetBundleInfo 中维护两组依赖:
bash
protected Dictionary<string, AssetBundleInfo> mChildren = new(); // 依赖自己的AssetBundle列表,即引用了自己的AssetBundle
protected Dictionary<string, AssetBundleInfo> mParents = new(); // 依赖的AssetBundle列表,即自己引用的AssetBundle,包含所有的直接和间接的依赖项
含义很明确:
bash
mParents
当前包依赖的包
mChildren
依赖当前包的包
例如:
bash
ui_panel.ab 依赖 common_texture.ab
那么关系是:
bash
ui_panel.ab.mParents
common_texture.ab
common_texture.ab.mChildren
ui_panel.ab
加载时看 mParents。
卸载时看 mChildren。
两个方向都要维护。
三、依赖建立
依赖关系先从配置中读出来。
初始阶段只知道依赖包名字:
bash
public void addParent(string dep)
{
mParents.TryAdd(dep, null);
}
资源清单解析完成后,再把名字转换成 AssetBundleInfo 引用:
bash
public void findAllDependence()
{
using var a = new ListScope<string>(out var tempList);
foreach (string depName in tempList.setRangeKeys(mParents))
{
AssetBundleInfo info = mResourceManager.getAssetBundleInfo(depName);
// 找到自己的父节点
mParents.set(depName, info);
// 并且通知父节点添加自己为子节点
info.addChild(this);
}
}
addChild() 会把当前包加入父包的子依赖列表:
bash
public void addChild(AssetBundleInfo other)
{
mChildren.TryAdd(other.mBundleName, other);
}
这一步很关键。
如果只记录"我依赖谁",加载没有问题。
但卸载时还需要知道"谁依赖我"。
所以 mChildren 是为了卸载保护存在的。
四、加载顺序
同步加载 AssetBundle 时,会先加载所有父依赖:
bash
public void loadAssetBundle()
{
if (isWebGL())
{
logError("webgl无法使用loadAssetBundle");
return;
}
if (mAssetBundle != null)
{
return;
}
if (mLoadState != LOAD_STATE.NONE)
{
logError("资源包正在异步加载,无法开始同步加载." + mBundleFileName);
return;
}
// 先确保所有依赖项已经加载
foreach (var item in mParents)
{
item.Value.loadAssetBundle();
}
mAssetBundle = AssetBundle.LoadFromFile(availableReadPath(mBundleFileName));
if (mAssetBundle == null)
{
logError("can not load asset bundle : " + mBundleFileName);
}
mLoadState = LOAD_STATE.LOADED;
mWillUnloadTime = -1.0f;
}
异步加载也是一样,先请求依赖包加载:
bash
public void loadParentAsync()
{
foreach (var item in mParents)
{
item.Value.loadAssetBundleAsync(null);
}
}
当前包加载时必须保证依赖存在。
当前包卸载时,也必须保证没有子包还在使用自己。
五、资源卸载
单个资源卸载在 unloadAsset() 中处理:
bash
public bool unloadAsset(UObject obj)
{
if (!mObjectToAsset.Remove(obj, out AssetInfo info))
{
logError("object doesn't exist! name:" + obj.name + ", can not unload!");
return false;
}
// 预设类型不真正进行卸载,否则在AssetBundle内存镜像重新加载之前,无法再次从AssetBundle加载此资源
if (obj is GameObject || obj is Component)
{
// UObject.DestroyImmediate(obj, true);
}
// 其他独立资源可以使用此方式卸载,使用Resources.UnloadAsset及时卸载资源
// 可以减少Resources.UnloadUnusedAssets的耗时
else
{
Resources.UnloadAsset(obj);
}
info.clear();
if (canUnload())
{
mWillUnloadTime = UNLOAD_DELAY_TIME;
}
return true;
}
这里没有马上卸载 AssetBundle。
它只是:
bash
移除资源到 AssetInfo 的映射
卸载独立资源
清理 AssetInfo 状态
检查当前包是否可以卸载
如果可以卸载,设置延迟卸载时间
真正的 AssetBundle 卸载放到后面。
六、canUnload
卸载判断集中在 canUnload():
bash
// 尝试卸载AssetBundle,卸载需要满足两个条件
// 当前AssetBundle内的所有资源已经没有正在使用
// 已经没有其他的正在使用的AssetBundle引用了自己
protected bool canUnload()
{
if (mLoadState != LOAD_STATE.LOADED)
{
return false;
}
// 如果资源包的资源已经没有在使用中,则卸载当前资源包
foreach (var item in mAssetList)
{
if (item.Value.getLoadState() != LOAD_STATE.NONE)
{
return false;
}
}
// 如果已经没有资源被引用了,则卸载AssetBundle
// 当前已经没有正在使用的AssetBundle引用了自己时才可以卸载
foreach (var item in mChildren)
{
if (item.Value.getLoadState() != LOAD_STATE.NONE)
{
return false;
}
}
return true;
}
这个函数有三个判断。
七、加载状态
第一层判断:
bash
if (mLoadState != LOAD_STATE.LOADED)
{
return false;
}
只有已经加载完成的包才允许进入卸载流程。
如果包正在等待加载、异步加载中,或者已经是未加载状态,就不能按普通卸载逻辑处理。
AssetBundle 卸载必须基于明确状态。
八、包内资源
第二层判断:
bash
foreach (var item in mAssetList)
{
if (item.Value.getLoadState() != LOAD_STATE.NONE)
{
return false;
}
}
mAssetList 记录当前包内所有资源。
如果其中任意一个 AssetInfo 仍然不是 NONE,说明包内还有资源正在使用或处于加载状态。
这时不能卸载整个包。
这个判断保护的是:
bash
当前包自己的资源
资源引用没有清空,包不能卸载。
九、子包依赖
第三层判断:
bash
foreach (var item in mChildren)
{
if (item.Value.getLoadState() != LOAD_STATE.NONE)
{
return false;
}
}
这一步检查的是依赖当前包的其他 AssetBundle。
如果某个子包还在使用中,当前包不能卸载。
例如:
bash
common_texture.ab
被 ui_panel.ab 依赖
即使 common_texture.ab 自己的资源都没有被业务直接引用,只要 ui_panel.ab 还在加载状态,common_texture.ab 就不能卸载。
否则 ui_panel.ab 中的资源可能会失去依赖。
这个判断保护的是:
bash
其他包对当前包的依赖
十、延迟卸载
AssetBundleInfo 中有一个延迟时间:
bash
protected const float UNLOAD_DELAY_TIME = 5.0f; // 没有引用时延迟5秒卸载
还有一个倒计时变量:
bash
protected float mWillUnloadTime = -1.0f; // 引用计数变为0时的计时,小于0表示还有引用,不会被卸载,大于等于0表示计数为0,即将在一定时间后卸载
当 canUnload() 返回 true 时,不是马上调用 unload(),而是设置倒计时:
bash
mWillUnloadTime = UNLOAD_DELAY_TIME;
update() 中再倒计时:
bash
public void update(float elapsedTime)
{
// 需要再次确认是否有引用
if (tickTimerOnce(ref mWillUnloadTime, elapsedTime) && canUnload())
{
unload();
}
}
倒计时结束后,还会再次调用 canUnload()。
这点很重要。
5 秒内资源可能又被重新加载。
依赖关系也可能发生变化。
所以最终卸载前必须重新检查。
十一、重新使用
资源包重新被加载或使用时,会取消卸载倒计时。
同步加载中:
bash
mWillUnloadTime = -1.0f;
异步加载中:
bash
public void loadAssetBundleAsync(AssetBundleCallback callback)
{
mWillUnloadTime = -1.0f;
...
}
资源加载中:
bash
public T loadAsset<T>(string fileNameWithSuffix) where T : UObject
{
mWillUnloadTime = -1.0f;
...
}
异步资源加载中:
bash
public CustomAsyncOperation loadAssetAsync(string fileNameWithSuffix, AssetLoadCallback callback, string loadPath)
{
mWillUnloadTime = -1.0f;
...
}
含义是:
bash
只要资源包再次被使用
就取消即将卸载状态
这可以避免刚准备卸载,下一帧又重新加载。
十二、子包卸载通知
当前包卸载后,会通知自己的父依赖:
bash
// 通知依赖项,自己被卸载了
mParents.forValue(item => item.notifyChildUnload());
父包收到通知后,会重新检查自己能否卸载:
bash
public void notifyChildUnload()
{
if (canUnload())
{
mWillUnloadTime = UNLOAD_DELAY_TIME;
}
}
这个流程解决的是依赖链卸载。
例如:
bash
A 依赖 B
B 依赖 C
A 卸载后,会通知 B。
B 如果也没人用了,会进入延迟卸载。
B 卸载后,再通知 C。
这样依赖包不是立即被强制卸掉,而是沿依赖关系逐层检查。
十三、真正卸载
真正卸载在 unload() 中:
bash
public void unload()
{
if (mResourceManager.isDontUnloadAssetBundle(mBundleFileName))
{
return;
}
if (mAssetBundle != null)
{
// 为true表示会卸载掉LoadAsset加载的资源,并不影响该资源实例化的物体
// 只支持参数为true,如果是false,则是只卸载AssetBundle镜像,但是加载资源包中时会需要使用内存镜像
// 其他资源包中的资源引用到此资源时,也会自动从此AssetBundle内存镜像中加载需要的资源
// 所以卸载镜像,将会造成这些自动加载失败,仅在当前资源包内已经没有任何资源在使用了,并且
// 其他资源包中的资源实例没有对当前资源包进行引用时才会卸载
#if BYTE_DANCE
mAssetBundle.TTUnload(true);
#else
mAssetBundle.Unload(true);
#endif
mAssetBundle = null;
}
mObjectToAsset.Clear();
mAssetList.forValue(item => item.clear());
mLoadState = LOAD_STATE.NONE;
// 通知依赖项,自己被卸载了
mParents.forValue(item => item.notifyChildUnload());
}
这里使用的是:
bash
mAssetBundle.Unload(true);
true 表示卸载通过 LoadAsset 加载出来的资源。
所以前面的 canUnload() 必须严格。
如果仍然有资源或依赖包在使用当前包,直接 Unload(true) 会破坏资源关系。
十四、禁止卸载
卸载前还有一层判断:
bash
if (mResourceManager.isDontUnloadAssetBundle(mBundleFileName))
{
return;
}
有些 AssetBundle 可以被标记为不卸载。
这适合常驻资源包。
例如:
bash
基础字体
公共材质
常用 Shader
通用 UI 资源
启动阶段常驻资源
这些资源频繁使用,卸载反而会造成重复加载。
十五、异步加载保护
异步加载完成时,也有状态保护:
bash
public void notifyAssetBundleAsyncLoaded(AssetBundle assetBundle)
{
mAssetBundle = assetBundle;
if (mLoadState != LOAD_STATE.NONE)
{
mLoadState = LOAD_STATE.LOADED;
// 异步加载请求的资源
foreach (AssetInfo item in mLoadAsyncList)
{
mAssetList.get(item.getAssetName()).loadAssetAsync();
}
}
// 加载状态为已卸载,表示在异步加载过程中,资源包被卸载掉了
else
{
logWarning("资源包异步加载完成,但是异步加载过程中被卸载");
unload();
}
mLoadAsyncList.Clear();
using var a = new ListScope<AssetBundleCallback>(out var callbacks);
foreach (AssetBundleCallback callback in mLoadCallbackList.moveTo(callbacks))
{
callback(this);
}
}
如果异步加载过程中包已经被卸载,完成后不会继续当作正常包使用。
这里直接走 unload()。
这避免了异步加载和卸载状态交叉时,包重新进入错误状态。
十六、设计重点
这个机制的重点不是"引用计数"。
而是三层保护:
bash
资源保护
包内 AssetInfo 都为空,才允许卸载
依赖保护
没有正在使用的子包依赖自己,才允许卸载
时间保护
满足条件后延迟 5 秒,再次确认后才卸载
这三层缺一不可。
只看资源引用,依赖包可能出错。
只看依赖关系,包内资源可能还在用。
满足条件后马上卸载,又可能造成短时间内频繁加载和卸载。
十七、设计取舍
这套设计的优点:
bash
不会因为单个资源释放就误卸载整个包
不会卸载仍被其他包依赖的公共包
减少频繁 Load / Unload
支持依赖链逐层释放
卸载前再次确认状态
代价也很明确:
bash
AssetBundle 不会在资源释放后立刻消失
内存释放有 5 秒延迟
依赖关系需要在初始化阶段完整建立
卸载逻辑比简单引用计数更复杂
这个取舍适合长期项目。
资源系统更稳定,内存回收不追求立即发生。
总结
MyFramework 中 AssetBundle 卸载流程大致是:
bash
ResourceRef 释放
↓
资源引用清空
↓
ResourceManager 卸载单个资源
↓
AssetInfo 清理状态
↓
AssetBundleInfo.canUnload()
↓
满足条件后设置 5 秒延迟
↓
update 中倒计时
↓
再次 canUnload()
↓
AssetBundle.Unload(true)
↓
通知父依赖重新检查
ResourceRef 判断的是资源是否还被业务持有。
AssetBundleInfo 判断的是整个资源包是否可以安全卸载。
canUnload() 同时检查包内资源和子包依赖。
UNLOAD_DELAY_TIME 避免资源刚释放就马上卸载。
这就是 MyFramework 中 AssetBundle 延迟卸载与依赖保护的主要设计。