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