← 返回首页

ARTICLE

Unity资源管理-为什么你需要一张借书证

在Unity开发中,资源加载很简单,但卸载很复杂。5个UI共用同一张图,关闭其中一个时能卸载吗?本文深入剖析YooAsset的Handle引用计数机制、异步加载的防泄漏设计,以及LRU缓存的工作原理。 一、核心问题:谁来记住“还有几个人在用”? 假设你写了这样一段代码: var sprite = a

在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:

  1. Bundle卸载了,Asset还在内存里飘着
  2. 下次加载同样的资源,会加载一份新的
  3. 内存里就有两份相同的Asset
  4. 时间久了,内存爆炸

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:异步加载

  1. 在_loadingFlag里插入标记
  2. 调用底层加载:Bundle → Asset → Instantiate
  3. 等待异步完成

5.3 阶段C:安全检查

资源加载完毕的瞬间,检查_loadingFlag里还有没有这个标记:

  • 有 → 移除标记,返回GameObject
  • 没有 → 说明在等待期间UI已经被关闭了,立即销毁刚加载好的资源,返回null

六、卸载流程:两条路径

UI关闭时,资源不一定真的销毁。

6.1 路径A:进池缓存(LRU)

条件:资源属于某个Group(如UI),且该Group开启了LRU缓存。

动作:

  1. SetActive(false) —— 隐藏
  2. SetParent(poolRoot) —— 挂到不灭节点下
  3. 放入LRU队列尾部

结果:GameObject活着,内存不降,但下次加载极快。

6.2 路径B:真销毁

条件:资源不属于任何Group,或LRU池已满(满了就挤掉最久没用的)。

动作:

  1. Object.Destroy(go) —— 销毁GameObject
  2. handle.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价格的有福了。试用账号怎么来可以看看万能的…