MyFramework:AssetBundle 延迟卸载与依赖保护

项目地址:

github.com/ZHOURUIH/My...

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 延迟卸载与依赖保护的主要设计。

相关推荐
_zhourui_h_20 小时前
MyFramework:safe() 扩展函数的空集合设计
unity3d·游戏开发
SmalBox1 天前
【节点】[RoundedPolygon节点]原理解析与实际应用
unity3d·游戏开发·图形学
SmalBox2 天前
【节点】[Rectangle节点]原理解析与实际应用
unity3d·游戏开发·图形学
SmalBox3 天前
【节点】[Polygon节点]原理解析与实际应用
unity3d·游戏开发·图形学
_zhourui_h_3 天前
MyFramework:整体代码结构与热更新分层解析
unity3d·游戏开发
SmalBox4 天前
【节点】[Houndstooth节点]原理解析与实际应用
unity3d·游戏开发·图形学
SmalBox6 天前
【节点】[Herringbone节点]原理解析与实际应用
unity3d·游戏开发·图形学
_zhourui_h_6 天前
MyFramework:ClassPool 对象池与 resetProperty 的实现解析
unity3d
SmalBox7 天前
【节点】[Grid节点]原理解析与实际应用
unity3d·游戏开发·图形学