在Unity开发中,资源加载很简单,但卸载很复杂。5个UI共用同一张图,关闭其中一个时能卸载吗?本文深入剖析YooAsset的Handle引用计数机制、异步加载的防泄漏设计,以及LRU缓存的工作原理。
一、核心问题:谁来记住“还有几个人在用”?
假设你写了这样一段代码:
var sprite = await LoadAsset<Sprite>("头像");
image.sprite = sprite;
加载一张图,显示出来。看起来没任何问题。
但当UI关闭时,这张图怎么卸载?
你可能会说:UI关闭时卸载就完了。但如果5个UI都加载了这张头像图呢?UI_A关闭时,能卸载吗?
不能。因为其他4个还在用。
那谁来记住“还有几个人在用”?这个问题开始变得有意思了。
二、Handle:资源的借书证
2.1 核心概念:图书馆模型
想象图书馆的场景。你借一本书,图书馆给你一张借书证。图书馆根据借书证数量决定书能不能下架:
- 还有人借着 → 不能下架
- 所有借书证都还了 → 可以下架
资源管理里也是这样:
| 概念 | 对应 |
|---|---|
| Asset(贴图、音效) | 书 |
| Bundle(资源包) | 书架 |
| Handle | 借书证 |
2.2 代码实现
// 借书
var handle = YooAsset.LoadAssetAsync<Sprite>("头像");
var sprite = handle.AssetObject;
// 还书
handle.Release(); // RefCount--
每次加载,引用计数+1。每次Release,引用计数-1。只有计数归零,资源才会被真正卸载。
这就是为什么YooAsset不直接给你Asset,而要给你一个Handle——它就是借书证,引用计数的载体。
三、幽灵Asset:Unload(true)与Unload(false)的选择
3.1 两种卸载模式
Unity卸载Bundle有两种模式:
| 模式 | 行为 | 风险 |
|---|---|---|
| Unload(false) | 只卸载Bundle容器,Asset留在内存 | 产生幽灵Asset |
| Unload(true) | Bundle和Asset一起销毁 | 可能导致紫红色Missing |
3.2 幽灵Asset的灵异
Unload(false)看起来“安全”——Asset还在,不会有人用着用着突然变紫红色。
但它会产生幽灵Asset:
- Bundle卸载了,Asset还在内存里飘着
- 下次加载同样的资源,会加载一份新的
- 内存里就有两份相同的Asset
- 时间久了,内存爆炸
3.3 YooAsset的选择
YooAsset选择Unload(true)。
代价是:如果有人还在用这个Asset,就会变紫红色Missing。但因为有Handle的引用计数在,这个风险被消除了——只有RefCount=0时才调用Unload(true),这时候保证没有任何人还在用。
两种模式各有缺陷,但配合引用计数,Unload(true)变得完美安全。
四、异步加载的防泄漏设计:_loadingFlag
4.1 问题场景
用户点开背包。资源开始加载。加载需要200ms。但用户在100ms时就关闭了背包。
200ms后资源加载完成,回调执行,GameObject被创建出来——但已经没有人需要它了。它会变成内存里的孤魂野鬼。
原生的异步加载无法取消。开始加载了就一定会加载完。
4.2 解决方案:标记位检查
项目的做法是用一个_loadingFlag:
开始加载时 → 插入标记
关闭UI时 → 移除标记
加载完成时 → 检查标记
没有标记 = 没人要了 = 直接销毁
异步无法取消,但可以在回调时决定要不要这个结果。
五、加载流程:三阶段设计
当调用self.Instantiate("ItemUI")时,框架执行严密的三阶段流程:
5.1 阶段A:查缓存
在LRU池里找有没有闲置的实例。
- 有 → 直接拿出来,
SetActive(true),立即返回 - 没有 → 进入阶段B
5.2 阶段B:异步加载
- 在
_loadingFlag里插入标记 - 调用底层加载:Bundle → Asset → Instantiate
- 等待异步完成
5.3 阶段C:安全检查
资源加载完毕的瞬间,检查_loadingFlag里还有没有这个标记:
- 有 → 移除标记,返回GameObject
- 没有 → 说明在等待期间UI已经被关闭了,立即销毁刚加载好的资源,返回null
六、卸载流程:两条路径
UI关闭时,资源不一定真的销毁。
6.1 路径A:进池缓存(LRU)
条件:资源属于某个Group(如UI),且该Group开启了LRU缓存。
动作:
SetActive(false)—— 隐藏SetParent(poolRoot)—— 挂到不灭节点下- 放入LRU队列尾部
结果:GameObject活着,内存不降,但下次加载极快。
6.2 路径B:真销毁
条件:资源不属于任何Group,或LRU池已满(满了就挤掉最久没用的)。
动作:
Object.Destroy(go)—— 销毁GameObjecthandle.Release()—— 引用计数-1
结果:GameObject死亡。如果引用计数归零,Bundle也会被卸载。
七、关键区分:LRU缓存的是GameObject,不是Asset
这是一个容易混淆的点。
| Asset | GameObject | |
|---|---|---|
| 是什么 | 原始资源(贴图、Prefab) | 实例化后的对象 |
| 和Bundle的关系 | 绑定,Bundle卸载时一起销毁 | 独立,销毁不影响Bundle |
| 缓存它的影响 | 阻止Bundle卸载,内存不能释放 | 只省Instantiate开销,不影响底层 |
LRU是“复用已实例化的对象”,不是“持有原始资源”。
八、整体架构:三层分工
业务层
↓ self.LoadAsset<T>() / self.Instantiate()
YooAssetComponent(封装层)
↓ groupID分组、_loadingFlag防泄漏、LRU缓存
YooAssetsEx → YooAsset(底层)
↓ Handle引用计数、Bundle管理
每层解决不同的问题:
| 层级 | 职责 |
|---|---|
| 底层 | 引用计数、Bundle生命周期 |
| 封装层 | 异步安全、缓存复用、按UI分组批量释放 |
| 业务层 | 只管调用,不用关心细节 |
九、总结:机制与问题对照
资源管理的核心,是在“快速复用”与“内存安全”之间走钢丝。
| 机制 | 解决什么问题 |
|---|---|
| Handle引用计数 | 多用户共用资源时,谁来决定何时卸载 |
| Unload(true) | 避免幽灵Asset导致内存泄漏 |
| _loadingFlag | 异步加载期间UI关闭,防止孤魂野鬼 |
| LRU缓存 | 高频UI秒开,牺牲内存换速度 |
| ResGroup分组 | 不同类型资源用不同缓存策略 |
一句话概括:Handle是借书证,保证“所有人还书后才下架”;LRU是暂存柜,让常客不用每次都去书库取。
本文基于Unity项目的实际架构总结,涵盖YooAsset底层原理与项目层封装设计,适用于中大型Unity项目的资源管理实践。顺便,warp的试用账号可以免费用4.5opus thinking,苦于claude code价格的有福了。试用账号怎么来可以看看万能的…