Unity资源加载优化:Provider与Lease模型解决异步缓存与取消难题
2026/9/19 4:26:11 网站建设 项目流程

1. 从一次资源加载事故说起:为什么需要 Provider 和 Lease

去年我在做一个 Unity 项目时踩过一个很典型的坑。场景切换时,一个异步加载的 UI 资源还没回来,玩家已经退出了当前界面,结果回调触发时引用了已经被销毁的 GameObject,直接报MissingReferenceException。更麻烦的是,同一个资源在短时间内被多个模块同时请求,加载了三遍,内存里躺着三份一模一样的贴图。

这类问题在 Unity 项目里非常普遍。异步加载天然带有三个麻烦:取消(请求发出后不再需要了)、缓存(同一资源被多次请求)、迟到结果(回调回来时上下文已经变了)。很多人第一反应是用CancellationToken加字典缓存硬扛,但写着写着就发现,取消和缓存之间的状态同步极其容易出错——取消了一个请求,缓存里到底该不该留?多个请求共享同一个加载任务时,取消其中一个会不会误伤其他?

我后来把这块逻辑抽成了两个核心概念:ProviderLease。Provider 负责“怎么加载、怎么缓存”,Lease 负责“谁在用、什么时候释放”。这套模型不是 Unity 独有的,C# 异步编程里处理资源生命周期的通用思路都能套用。下面我把这套方案从设计动机到落地细节完整拆一遍,适合有一定 C# 异步基础、正在被资源管理折磨的 Unity 开发者参考。

2. Provider 与 Lease 的职责边界到底怎么划

2.1 Provider 不是简单的加载器

很多人会把 Provider 理解成一个“带缓存的加载函数”,这其实低估了它的职责。Provider 真正要解决的是请求去重生命周期托管两件事。

请求去重指的是:当同一个 key 的加载请求在途时,后续请求不应该再发起一次真实的 IO 或网络加载,而是复用同一个在途任务。这里的关键是缓存的不只是“加载完成的结果”,还包括“正在加载中的 Task”。我见过不少实现只缓存完成态,结果并发请求时依然会重复加载。

生命周期托管指的是:Provider 要清楚自己持有的资源什么时候可以真正释放。如果只是无脑缓存,内存会一直涨。所以 Provider 需要维护一个引用计数或者租约集合,当没有任何 Lease 持有某个资源时,才允许卸载。

public interface IResourceProvider<T> where T : class { Task<Lease<T>> AcquireAsync(string key, CancellationToken ct); void Release(string key, Lease<T> lease); }

这个接口里,AcquireAsync返回的不是T,而是Lease<T>。这个设计选择很关键,后面会展开。

2.2 Lease 是“使用权凭证”,不是资源本身

Lease 的本质是一张有期限的使用凭证。它持有对真实资源的引用,同时告诉 Provider:“我还在用,别卸载。”当 Lease 被 Dispose 时,Provider 的引用计数减一,减到零才考虑回收。

为什么不让调用方直接拿T?因为一旦直接暴露T,调用方就没有任何机制通知 Provider “我用完了”。你可能会说可以用IDisposable包一层,但那其实就是 Lease 的雏形了。把 Lease 显式建模出来,好处是:

  • 释放时机明确,不会因为忘记调用而泄漏
  • 可以在 Lease 上附加元信息,比如获取时间、持有者标识,方便排查
  • 取消和释放可以分离处理,取消是“我不要了”,释放是“我用完了”

提示:Lease 一定要实现IDisposable,并且配合using语句使用。我强烈建议在代码审查时把“Acquire 了但没 Dispose”当成一个必须修复的问题。

2.3 两者协作的完整时序

把一次典型的资源获取拆开看,Provider 和 Lease 的协作是这样的:

  1. 调用方AcquireAsync(key),Provider 检查缓存
  2. 缓存命中且已完成,直接构造一个新 Lease,引用计数加一,立即返回
  3. 缓存命中但在途,返回一个“等待同一个 Task”的 Lease,不重复加载
  4. 缓存未命中,创建加载 Task 放入缓存,返回 Lease
  5. 调用方使用资源
  6. 调用方 Dispose Lease,Provider 引用计数减一
  7. 计数归零,Provider 根据策略决定是否卸载

这个时序里,第 3 步是最容易被忽略的。如果多个模块同时请求同一个资源,它们应该共享同一个加载 Task,而不是各自发起加载。这一点直接决定了缓存设计的成败。

3. 取消请求时,缓存到底该不该留

3.1 取消的语义陷阱

CancellationToken在 .NET 里是协作式取消,它本身不会强制中断任何操作,只是通知“我希望你停”。在资源加载场景里,取消的语义其实很微妙:调用方取消,到底意味着“这个资源我不要了”,还是“我这次请求不要了,但资源本身可能还有别人要”?

这两种语义完全不同。如果是前者,取消后应该考虑从缓存里移除;如果是后者,取消只影响当前这个 Lease,缓存里的加载任务应该继续跑完,因为可能还有其他持有者。

我的做法是:取消只影响当前请求的等待,不影响底层加载任务。也就是说,即使调用方取消了,底层加载 Task 依然会跑完并写入缓存。理由很简单——取消一个请求不代表这个资源以后不会被用到,而且中断一个已经在途的 IO 操作往往得不偿失,还不如让它跑完。

3.2 用 TaskCompletionSource 桥接取消

直接await一个可能被取消的 Task 有个问题:如果底层 Task 不响应取消,await会一直挂着。所以需要一个中间层,用TaskCompletionSource把“底层加载完成”和“调用方取消”两个信号桥接起来。

public async Task<Lease<T>> AcquireAsync(string key, CancellationToken ct) { var entry = GetOrCreateEntry(key); var tcs = new TaskCompletionSource<Lease<T>>(); using (ct.Register(() => tcs.TrySetCanceled(ct))) { _ = LoadAndCompleteAsync(entry, tcs); return await tcs.Task; } }

这里ct.Register注册的回调只做一件事:把tcs标记为取消。底层LoadAndCompleteAsync依然会跑完,把结果写进 entry 的缓存。这样调用方拿到的是OperationCanceledException,但缓存不受影响。

3.3 取消后引用计数的处理

有个细节必须处理:如果调用方在等待期间取消了,它其实还没有真正拿到 Lease,所以不应该增加引用计数。引用计数的增加应该发生在“Lease 真正被构造并返回”的那一刻,而不是请求发起时。

我踩过的坑是:一开始在请求发起时就加计数,结果取消的请求也占了计数,导致资源永远无法释放。后来改成在tcs.TrySetResult之前才加计数,取消路径直接跳过,问题就解决了。

注意:如果你的 Provider 支持“取消即卸载”的语义,那需要额外维护一个“取消后是否还有活跃 Lease”的判断,逻辑会复杂不少。除非有明确的内存压力需求,否则不建议这么做。

4. 缓存策略:在途任务、完成结果与弱引用

4.1 缓存的三层结构

一个成熟的 Provider 缓存通常有三层:

层级存储内容生命周期用途
在途任务层正在加载的 Task加载完成即移除请求去重
强引用层已完成且被 Lease 持有的资源引用计数归零后移除快速复用
弱引用层已完成但无人持有的资源GC 决定二次命中加速

在途任务层是必须的,它解决并发重复加载。强引用层是核心,它保证被使用的资源不会被 GC 回收。弱引用层是优化,它让“刚释放不久又被请求”的资源能快速命中,而不必重新加载。

4.2 为什么弱引用层值得做

Unity 里资源加载往往涉及磁盘 IO 或 AssetBundle 解压,成本不低。如果资源刚被释放,下一秒又被请求,重新加载一遍很浪费。弱引用层的作用就是给这种“抖动式请求”一个缓冲。

实现上,强引用层释放时,把资源同时放进弱引用表。下次请求时先查强引用,再查弱引用,弱引用还活着就直接升级为强引用,省掉一次加载。

private bool TryGetFromWeak(string key, out T resource) { if (_weakCache.TryGetValue(key, out var weakRef) && weakRef.TryGetTarget(out resource)) { _strongCache[key] = resource; _weakCache.Remove(key); return true; } resource = null; return false; }

这里有个坑:弱引用表本身也要清理,否则 key 会越积越多。我一般是在每次访问时顺手清理失效项,或者用一个定时器定期扫。

4.3 缓存 key 的设计

缓存 key 不能只用资源路径。同一个路径在不同变体(比如不同画质、不同语言)下可能是不同资源。我通常把 key 设计成路径 + 变体标识的组合,并且保证 key 的生成是确定性的,避免同一个资源因为 key 不一致而被加载多次。

另外,key 的大小写敏感性要注意。Windows 文件系统不区分大小写,但字典默认区分。如果 key 来自用户输入或配置,最好统一转成小写或大写再入字典。

5. 迟到结果:回调回来时世界已经变了

5.1 迟到结果的三种典型场景

迟到结果指的是:异步操作完成时,发起请求的上下文已经不存在或不再需要这个结果了。在 Unity 里主要有三种:

  • 对象已销毁:请求发起时 GameObject 还在,回调回来时已经被 Destroy
  • 场景已切换:请求属于旧场景,回调回来时已经进入新场景
  • 请求已过期:比如一个搜索请求,用户已经输入了新的关键词

这三种场景的共同点是:结果本身可能是有效的,但接收方已经不该处理它了。

5.2 用 Lease 的持有者标识做校验

我在 Lease 里加了一个Owner字段,记录发起请求的对象标识。回调回来时,先校验 Owner 是否还有效,再决定是否处理结果。

public class Lease<T> : IDisposable { public T Resource { get; } public object Owner { get; } private readonly Action<Lease<T>> _onDispose; private bool _disposed; public void Dispose() { if (_disposed) return; _disposed = true; _onDispose(this); } }

校验 Owner 的方式取决于具体场景。Unity 里可以用UnityEngine.Object的隐式 bool 转换判断对象是否存活,也可以用自定义的版本号或 token。

5.3 取消与迟到结果的关系

取消是主动放弃,迟到结果是被动过期。两者经常一起出现:调用方取消了请求,但底层加载还是跑完了,结果回来时就成了迟到结果。

处理迟到结果的核心原则是:结果可以写入缓存,但不要触发业务回调。也就是说,加载完成这件事本身对缓存是有价值的,但对已经取消的调用方没有意义。所以 Provider 在完成加载后,应该先写缓存,再检查调用方是否还在等待,不在等待就静默丢弃回调。

private void CompleteEntry(Entry entry, T resource) { entry.Resource = resource; entry.IsCompleted = true; _strongCache[entry.Key] = resource; if (!entry.Tcs.Task.IsCompleted) { entry.Tcs.TrySetResult(new Lease<T>(resource, entry.Owner, Release)); } }

这段代码里,TrySetResult只在 TCS 还没完成时才调用。如果调用方已经取消,TCS 已经是 Canceled 状态,TrySetResult会返回 false,回调自然就不会触发。

6. 一套可落地的 Provider 实现骨架

6.1 核心数据结构

把前面的设计整合起来,核心数据结构大概是这样:

public class ResourceProvider<T> : IResourceProvider<T> where T : class { private class Entry { public string Key; public Task<T> LoadTask; public T Resource; public int RefCount; public TaskCompletionSource<Lease<T>> Tcs; public object Owner; } private readonly Dictionary<string, Entry> _entries = new(); private readonly Dictionary<string, WeakReference<T>> _weakCache = new(); private readonly Func<string, CancellationToken, Task<T>> _loader; private readonly object _lock = new(); }

_entries同时承担在途任务和强引用的职责,_weakCache是弱引用层。_loader是实际加载逻辑,由外部注入,这样 Provider 本身不关心资源从哪来。

6.2 AcquireAsync 的完整实现

public async Task<Lease<T>> AcquireAsync(string key, CancellationToken ct) { Entry entry; lock (_lock) { if (_entries.TryGetValue(key, out entry) && entry.Resource != null) { entry.RefCount++; return new Lease<T>(entry.Resource, entry.Owner, l => Release(key, l)); } if (entry == null) { entry = new Entry { Key = key, Tcs = new TaskCompletionSource<Lease<T>>() }; _entries[key] = entry; entry.LoadTask = _loader(key, CancellationToken.None); _ = LoadAndCompleteAsync(entry); } } using (ct.Register(() => entry.Tcs.TrySetCanceled(ct))) { return await entry.Tcs.Task; } }

注意这里_loader传入的是CancellationToken.None,因为底层加载不应该被单个调用方的取消影响。这是前面说的“取消只影响等待,不影响加载”的落地。

6.3 Release 与卸载时机

public void Release(string key, Lease<T> lease) { lock (_lock) { if (!_entries.TryGetValue(key, out var entry)) return; entry.RefCount--; if (entry.RefCount <= 0) { _weakCache[key] = new WeakReference<T>(entry.Resource); _entries.Remove(key); } } }

引用计数归零时,把资源放进弱引用表,然后从强引用表移除。这样资源在没人用时可以被 GC 回收,但如果很快又被请求,弱引用表还能兜住。

6.4 线程安全与锁粒度

Unity 的主线程模型让很多人忽略线程安全,但异步加载的回调可能不在主线程。上面的实现用了lock保护字典操作,粒度是整张表。如果并发量很大,可以考虑分段锁或者ConcurrentDictionary,但要注意ConcurrentDictionary的复合操作不是原子的,引用计数的增减还是得靠锁。

我的经验是:资源加载的并发量通常不会高到需要分段锁,一把大锁足够,而且逻辑简单不容易出错。真正需要优化的是加载本身,而不是字典操作。

7. 实测中遇到的几个坑与应对

7.1 在途任务被取消导致的死锁

有一次我把底层加载 Task 也传了调用方的 CancellationToken,结果调用方取消后,加载 Task 被取消,但 entry 还留在字典里,后续请求拿到的是一个已取消的 Task,永远等不到结果。修复方式就是前面说的,底层加载用CancellationToken.None,取消只作用于等待层。

7.2 弱引用表的 key 泄漏

弱引用表如果不清理,key 会一直累积。我一开始只在写入时清理,结果长时间运行后表越来越大。后来改成每次读取时顺手清理当前 key,再配合一个低频的定期全表扫描,内存才稳定下来。

7.3 Unity 对象销毁后的访问

即使有 Lease 保护,如果 Lease 持有的资源是 Unity 的UnityEngine.Object,对象被外部销毁后访问依然会报错。这种情况需要在 Lease 里加一个有效性检查,或者在业务层保证销毁和释放的顺序。我的做法是在 Lease 上暴露一个IsValid属性,业务代码在使用前检查。

7.4 异步回调的线程切换

Unity 里很多 API 只能在主线程调用。如果加载回调在后台线程,直接操作 GameObject 会报错。我通常用TaskScheduler.FromCurrentSynchronizationContext()或者 Unity 的SynchronizationContext把回调切回主线程。这一步很容易忘,忘了就是随机崩溃。

8. 几个容易被忽略的边界情况

8.1 同一个 key 的加载失败

加载失败时,entry 应该从字典移除,否则后续请求会一直拿到失败的 Task。同时,失败信息要传递给所有等待者。我的做法是在LoadAndCompleteAsync里 catch 异常,TrySetException通知所有等待者,然后移除 entry。

8.2 加载过程中再次请求同一个 key

这种情况前面已经覆盖了:entry 存在但 Resource 为 null,说明还在加载中,直接 await 同一个 TCS。但要注意,此时不应该增加引用计数,因为还没真正拿到 Lease。

8.3 Lease 被重复 Dispose

IDisposable的约定是重复 Dispose 应该安全。我在 Lease 里加了_disposed标志,重复调用直接返回。如果不加这个,引用计数会被多减,导致资源被提前卸载。

8.4 Provider 自身的销毁

游戏退出或场景卸载时,Provider 需要清理所有缓存和未完成的加载。我一般实现一个Dispose方法,取消所有在途任务,清空字典。这一步不做的话,退出时可能有回调触发,访问已销毁的对象。

9. 写在最后的一点个人体会

这套 Provider 和 Lease 的模型我在两个项目里用过,最大的感受是:资源管理的复杂度不在于加载本身,而在于生命周期的边界。什么时候该缓存、什么时候该释放、取消后缓存留不留,这些决策没有标准答案,取决于项目的内存预算和加载成本。

我的建议是先把 Lease 的 Dispose 纪律建立起来,确保没有泄漏,再逐步优化缓存策略。一开始可以只用强引用层,跑稳了再加弱引用层。取消语义也建议先用最简单的“只影响等待”,等确实遇到内存压力再考虑更激进的策略。

另外,这套模型不限于 Unity。任何 C# 异步场景里需要管理共享资源生命周期的,都可以套用。核心思想就一句话:把“加载”和“使用”分开,用凭证管理使用,用引用计数管理加载。想清楚这一点,剩下的都是工程细节。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询