Unity FUI资源管理:Provider与Lease模型解决异步加载与引用计数

发布时间:2026/9/19 4:30:12
Unity FUI资源管理:Provider与Lease模型解决异步加载与引用计数 1. 从一次线上事故说起为什么 FUI 资源接入需要 Provider 和 Lease去年年底我们团队在做一个偏工具向的 Unity 项目界面层用的是 FUIFairGUI 的简称一套在 Unity 生态里比较常见的 UI 框架。项目本身不算大但 UI 资源加载这块踩的坑一个接一个切场景时图片资源被提前释放导致界面白块、异步加载回调回来时界面已经关了、同一个图集被反复加载又反复卸载、玩家快速连点按钮时资源引用计数直接乱掉。最严重的一次是上线后第二天有玩家反馈打开背包界面会闪一下然后整个 UI 卡死查了两天才定位到问题——一个异步加载的图标资源在界面关闭之后才回调回来回调里又去访问了已经被销毁的 GameObject。那次事故之后我把 FUI 的资源接入层整个重写了一遍核心思路就是引入两个概念Provider和Lease。Provider 负责“资源从哪来、怎么加载、怎么缓存”Lease 负责“谁在用、用多久、什么时候还”。这套模型不是什么新发明操作系统里的文件句柄、数据库里的连接池、C# 里的IDisposable模式本质上都是同一套思路。但把它落到 FUI 的资源接入上确实能解决一大批让人头疼的问题。这篇文章就是把这套方案完整拆开讲一遍。适合正在用 FUI 或者类似 UI 框架做 Unity 项目的同学尤其是被异步加载、资源释放、缓存管理折磨过的。如果你只是写写小 Demo可能感受不深但只要项目稍微上点规模UI 界面超过二三十个资源引用关系一复杂这套东西的价值就出来了。我会从设计思路讲到具体代码再讲实操中踩过的坑尽量让你看完能直接抄作业。2. 整体设计思路Provider 管来源Lease 管生命周期2.1 为什么不能直接用 Resources.Load 或者 AssetBundle 裸调先说清楚问题背景。FUI 里一个界面通常对应一个包Package包里有一堆图集、字体、预制体。最朴素的做法是界面打开时Resources.Load或者AssetBundle.LoadAsset界面关闭时Resources.UnloadAsset或者assetBundle.Unload(true)。这套做法在 Demo 里没问题但真实项目里会撞上三堵墙。第一堵墙是异步加载的时序问题。Unity 的LoadAssetAsync是异步的你发起加载请求的时候界面还在等回调回来的时候界面可能已经关了。这时候如果你在回调里直接操作界面对象轻则报空引用重则访问已销毁对象导致崩溃。第二堵墙是共享资源的引用计数。同一个图集可能被背包、商店、任务三个界面同时用A 界面关闭时把图集卸了B 界面还在用直接白块。第三堵墙是缓存策略的混乱。有的资源该常驻有的该用完就卸有的该延迟卸载如果每个界面各写各的最后没人说得清某个资源到底该不该在内存里。Provider 和 Lease 这套模型就是针对这三堵墙设计的。Provider 把“资源从哪来”这件事抽象出来统一管理加载、缓存、卸载Lease 把“谁在用”这件事抽象出来统一管理引用、取消、归还。两者配合异步时序、引用计数、缓存策略三个问题一次性解决。2.2 Provider 的职责边界只负责“给”不负责“用”Provider 的核心接口设计得很简单我实际用的版本大概是这样public interface IResourceProvider { // 同步获取命中缓存直接返回 T GetT(string key) where T : UnityEngine.Object; // 异步获取返回一个 Lease ILeaseT GetAsyncT(string key) where T : UnityEngine.Object; // 预加载不返回 Lease只预热缓存 void Preload(string key); // 主动释放某个 key 的缓存谨慎使用 void Release(string key); }这里有个关键设计决策Provider 不持有业务对象的引用只持有资源本身的引用。也就是说Provider 知道“图集 A 被加载了”但不知道“图集 A 被背包界面用了”。这个信息由 Lease 来承载。这样做的原因是Provider 如果去管业务引用就会和业务层耦合最后变成一个什么都管的上帝类。Provider 保持纯粹只做资源层面的加载和缓存业务层面的引用关系交给 Lease。另一个决策是缓存 key 的设计。我一开始用资源路径当 key后来发现不行因为同一个资源可能从不同路径加载比如编辑器下走 Assets 路径打包后走 Bundle 路径。后来改成用“逻辑名 类型”当 key逻辑名由业务层定义Provider 内部维护逻辑名到实际路径的映射。这样业务层不用关心资源到底在哪只关心“我要背包图标”这件事。2.3 Lease 的核心语义引用即承诺归还即释放Lease 这个词翻译过来是“租约”我觉得比“句柄”更贴切因为它强调的是一种有期限的占用关系。你从 Provider 那里租了一个资源租期内你可以随便用租期结束你要还回去。还回去之后如果没人再租Provider 就可以考虑卸载了。Lease 的接口大概是这样public interface ILeaseT : IDisposable where T : UnityEngine.Object { T Asset { get; } bool IsValid { get; } bool IsDone { get; } void Cancel(); event ActionILeaseT OnComplete; }几个关键点。Asset属性在加载完成前是 null加载完成后才有值。IsValid表示这个 Lease 是否还有效——如果被 Cancel 了或者 Provider 被销毁了就变成 false。IsDone表示加载是否完成不管成功还是失败。Cancel是主动取消取消后即使加载完成了回调也不会触发资源也会被标记为可释放。OnComplete是加载完成回调注意这个回调可能在 Cancel 之后才触发所以回调里第一件事就是检查IsValid。这里有个容易踩的坑Cancel 不等于立即释放。Cancel 只是告诉 Provider“我不要了”但资源可能还在加载中强行中断加载在 Unity 里是做不到的LoadAssetAsync没有取消接口。所以 Cancel 的语义是“加载完成后直接归还不通知调用方”。这一点在设计时要和团队说清楚不然有人会以为 Cancel 能省流量。2.4 两者如何配合一张图讲清调用链整个调用链是这样的业务层调用provider.GetAsyncSprite(icon_bag)Provider 先查缓存命中就直接返回一个已完成的 Lease没命中就发起异步加载创建一个 Pending 状态的 Lease 返回给业务层。业务层拿到 Lease 后可以注册OnComplete回调也可以每帧轮询IsDone。加载完成后Provider 把资源塞进 Lease触发回调。业务层用完之后调用lease.Dispose()Provider 收到归还通知把引用计数减一减到零就标记为可卸载。如果业务层在加载完成前就关闭了界面调用lease.Cancel()Provider 把 Lease 标记为无效加载完成后直接归还不触发回调。如果业务层忘了 DisposeProvider 可以在场景切换时做一次强制清理把所有未归还的 Lease 标记为泄漏并记录日志方便排查。这套配合的关键在于Provider 和 Lease 之间有一个内部协议Lease 的 Dispose 和 Cancel 都会通知 ProviderProvider 根据通知更新引用计数。这个协议对业务层是透明的业务层只需要知道“用完 Dispose不要了 Cancel”就够了。3. 核心细节拆解缓存、取消、迟到结果三件事怎么落地3.1 缓存策略LRU 加引用计数双保险缓存这块我试过好几种方案最后落地的是LRU 引用计数的组合。引用计数决定“能不能卸”LRU 决定“先卸谁”。引用计数的逻辑很直接每个资源维护一个refCountLease 创建时加一Dispose 或 Cancel 时减一。refCount 0的资源绝对不能卸这是硬约束。但光有引用计数不够因为有些资源可能长期refCount为零但又被频繁访问比如主界面的背景图每次回主界面都要加载卸了又加载很浪费。这时候就需要 LRU 来决定哪些零引用的资源该保留、哪些该卸。我的做法是维护一个LinkedList作为 LRU 队列每次资源被访问不管是命中缓存还是新加载就移到队首。当缓存总大小超过阈值时从队尾开始扫描找到第一个refCount 0的资源卸载直到大小降到阈值以下。如果扫到队首都没找到可卸的说明当前所有资源都在使用中那就放弃这次清理等下次触发。这里有个参数需要调缓存大小阈值。我一开始设的是 200MB后来发现移动端上太大了低端机容易 OOM。后来改成按平台区分PC 端 300MB移动端 120MB主机端 500MB。这个值不是拍脑袋定的是拿 Profiler 测出来的——移动端上 UI 资源峰值大概 80MB 左右留 40MB 余量给突发加载。你可以根据自己的项目测一下方法是打开所有界面看 Profiler 里 UI 相关的 Texture 和 Mesh 内存峰值然后乘个 1.5 倍作为阈值。注意LRU 队列的更新是有开销的每次访问都要移动节点。如果某个资源每帧都被访问比如动画用的图集频繁移动节点反而浪费 CPU。我的做法是加一个“访问计数”只有访问计数超过一定阈值比如 10 次才更新 LRU 位置否则只加计数不动队列。这样既保留了 LRU 的语义又避免了热点资源的频繁移动。3.2 取消机制Cancel 之后资源去哪了取消这块是最容易出 bug 的地方我踩过的坑能写一页纸。先说结论Cancel 的语义是“放弃回调但资源照常加载并归还”。为什么不做真正的加载中断因为 Unity 的LoadAssetAsync没有取消接口你没法中断一个已经发起的加载请求。强行中断只能靠AssetBundle.Unload但那会影响其他正在加载的资源得不偿失。所以 Cancel 的实现是这样的Lease 内部有一个cancelled标志Cancel 时置为 true同时通知 Provider 减引用计数。加载完成后Provider 检查 Lease 的cancelled标志如果为 true就不触发OnComplete回调直接把资源归还。业务层那边因为回调没触发所以不会去访问已经关闭的界面避免了空引用。但这里有个细节如果多个 Lease 共享同一个加载请求怎么办。比如背包和商店同时请求同一个图集Provider 不应该发起两次加载而是应该复用同一个加载任务。我的做法是维护一个pendingRequests字典key 是资源逻辑名value 是一个TaskCompletionSource或者自定义的加载任务对象。第一个请求发起加载后续请求直接挂到这个任务上。加载完成后所有挂着的 Lease 都收到通知。如果其中一个 Lease 被 Cancel 了只影响它自己不影响其他 Lease。这个设计有个好处取消是廉价的。Cancel 一个 Lease 不需要中断任何加载只是少一个回调而已。加载本身照常进行资源照常进缓存下次有人要用直接命中。这样既避免了中断加载的复杂性又保证了取消的语义正确。3.3 迟到结果处理回调回来时界面已经关了迟到结果是异步加载的经典问题。你发起加载的时候界面还在回调回来的时候界面已经关了这时候如果回调里直接操作界面对象就会出问题。处理这个问题有三层防护。第一层是Lease 的 IsValid 检查。回调里第一件事就是if (!lease.IsValid) return;如果 Lease 已经被 Cancel 或者 Provider 已经销毁直接返回。这一层能挡住大部分问题。第二层是业务层的生命周期检查。即使 Lease 有效界面本身可能已经关闭了。所以回调里还要检查界面对象是否还活着比如if (this null || !gameObject.activeInHierarchy) return;。这一层是业务层的责任Provider 管不了。第三层是Provider 的延迟归还。如果加载完成时发现所有 Lease 都被 Cancel 了Provider 不会立即卸载资源而是把它标记为“可卸载”等下一次 LRU 清理时再处理。这样如果界面很快又打开了还能命中缓存避免反复加载。这三层防护配合下来迟到结果基本不会出问题。我实测过一个极端场景快速连点按钮打开关闭界面 50 次每次都在加载完成前关闭结果没有任何崩溃资源也没有泄漏。Profiler 里看内存曲线是平稳的说明缓存和归还逻辑都正常工作。实操心得调试迟到结果问题的时候可以在 Provider 里加一个日志开关把所有 Cancel 和 Dispose 的调用都打出来带上时间戳和资源名。这样出问题的时候能快速定位是哪个 Lease 没处理好。我一般会在开发期打开这个日志发布期关掉性能影响可以忽略。3.4 引用计数的边界情况重复 Dispose 和跨场景泄漏引用计数有两个边界情况必须处理。第一个是重复 Dispose。业务层可能不小心对一个 Lease 调了两次 Dispose如果不做防护引用计数会减两次导致资源被提前卸载。我的做法是在 Lease 内部加一个disposed标志Dispose 时先检查已经 Dispose 过就直接返回不重复减计数。第二个是跨场景泄漏。Unity 切场景时如果业务层忘了 Dispose 某些 Lease这些 Lease 会一直挂着引用计数永远不归零资源永远不卸载。我的做法是在 Provider 里维护一个“当前场景所有活跃 Lease”的列表切场景时遍历这个列表把还没归还的 Lease 强制标记为泄漏记录日志并强制归还。这样虽然不能完全避免泄漏但至少能保证资源不会永久占用同时日志能帮助定位是哪个界面忘了 Dispose。这里有个经验泄漏日志一定要带堆栈。我一开始只打资源名结果发现同一个资源被多个界面用根本不知道是哪个界面泄漏的。后来改成打Environment.StackTrace虽然开销大一点但只在泄漏时打平时不影响性能。有了堆栈定位泄漏就是分分钟的事。4. 实操过程从零接入 Provider 和 Lease4.1 环境准备与依赖梳理先说环境。我用的是 Unity 2022.3 LTSFUI 版本是 3.xC# 用的是 .NET Standard 2.1。如果你用的是更老的 Unity 版本比如 2019 或 2020大部分代码也能用但TaskCompletionSource和async/await的支持可能有点差异需要自己适配一下。依赖这块Provider 和 Lease 本身不依赖任何第三方库纯 C# 实现。但如果你要用async/await风格需要确保 Unity 的SynchronizationContext正常工作。Unity 默认是有的但如果你在非主线程调用需要自己Post回主线程。我的做法是 Provider 内部维护一个主线程的SynchronizationContext引用所有回调都通过它 Post保证回调在主线程执行。资源加载这块我用的是 Unity 的AssetBundle加Addressables混合方案。Addressables 负责资源定位和依赖管理AssetBundle 负责实际加载。如果你项目里用的是纯 Resources 或者纯 AssetBundle也能用只需要把 Provider 的加载实现换一下就行。Provider 的接口设计是加载方式无关的这是它最大的好处。4.2 Provider 核心实现加载、缓存、卸载三段式Provider 的实现分三段加载、缓存、卸载。加载段负责把资源从磁盘或 Bundle 里读出来缓存段负责管理内存中的资源卸载段负责释放不再使用的资源。加载段的核心是LoadAsync方法大概逻辑是这样private async TaskUnityEngine.Object LoadInternal(string key, Type type) { // 查缓存 if (_cache.TryGetValue(key, out var entry) entry.Asset ! null) { entry.Touch(); return entry.Asset; } // 查 pending if (_pending.TryGetValue(key, out var task)) { return await task; } // 发起新加载 var tcs new TaskCompletionSourceUnityEngine.Object(); _pending[key] tcs.Task; try { var handle Addressables.LoadAssetAsyncUnityEngine.Object(key); await handle.Task; var asset handle.Result; // 进缓存 _cache[key] new CacheEntry(asset, type); tcs.SetResult(asset); return asset; } catch (Exception e) { tcs.SetException(e); throw; } finally { _pending.Remove(key); } }这段代码有几个关键点。第一先查缓存再查 pending缓存命中直接返回pending 命中就 await 已有的任务避免重复加载。第二pending 字典在 finally 里移除保证不管成功失败都不会残留。第三缓存 entry 带 Touch 方法用于更新 LRU 位置。缓存段的核心是CacheEntry类维护资源引用、引用计数、LRU 节点、最后访问时间。卸载段的核心是Trim方法按 LRU 顺序扫描卸载refCount 0的资源。这两段逻辑不复杂但细节多比如卸载时要先Addressables.Release再置空引用否则会内存泄漏。4.3 Lease 核心实现状态机与回调调度Lease 的实现本质上是一个状态机状态有四个Pending加载中、Completed加载完成、Cancelled已取消、Disposed已归还。状态转换规则是Pending 可以转 Completed 或 CancelledCompleted 可以转 DisposedCancelled 可以转 DisposedDisposed 是终态。回调调度这块我用的是Action加主线程 Post。OnComplete事件在加载完成时触发但触发前会检查状态只有 Pending 状态才触发。如果已经 Cancelled就不触发。触发时通过SynchronizationContext.Post保证在主线程执行避免多线程问题。这里有个细节回调只触发一次。我见过有的实现回调会触发多次导致业务层重复初始化。我的做法是在触发前把OnComplete置空保证只触发一次。如果业务层在回调里又注册了新的回调那是另一回事不影响本次触发。4.4 业务层接入示例一个背包界面的完整流程拿背包界面举例完整流程是这样的。界面打开时OnOpen里调用provider.GetAsyncSprite(icon_bag)拿到 Lease注册OnComplete回调回调里把 Sprite 赋给 Image 组件。界面关闭时OnClose里遍历所有 Lease调用Dispose。如果界面在加载完成前就关闭了OnClose里调用Cancel而不是Dispose。代码大概是这样public class BagPanel : FUI Panel { private ListILeaseobject _leases new ListILeaseobject(); protected override void OnOpen() { LoadIcon(icon_bag, iconBagImage); LoadIcon(icon_coin, iconCoinImage); } private void LoadIcon(string key, Image target) { var lease _provider.GetAsyncSprite(key); _leases.Add(lease); lease.OnComplete l { if (!l.IsValid || this null) return; target.sprite l.Asset; }; } protected override void OnClose() { foreach (var lease in _leases) { if (lease.IsDone) lease.Dispose(); else lease.Cancel(); } _leases.Clear(); } }这段代码看起来简单但包含了所有关键点Lease 收集、回调检查、关闭时区分 Dispose 和 Cancel。实际项目里我会把这套逻辑封装成一个基类所有界面继承避免每个界面都写一遍。4.5 参数调优缓存阈值、超时时间、日志级别参数调优这块我列几个实际用到的值和调整依据。缓存阈值前面说了PC 300MB、移动 120MB、主机 500MB。超时时间我设的是 10 秒超过 10 秒还没加载完就认为失败触发错误回调。这个值不是随便定的是拿慢速网络和低端机测出来的正常加载一般 1 到 3 秒10 秒足够覆盖极端情况。日志级别分三档Error 只打错误Warning 打错误加泄漏Info 打所有加载和卸载。开发期用 Info测试期用 Warning发布期用 Error。日志输出用Debug.Log加条件编译发布期直接编译掉零开销。注意超时时间不要设太短否则低端机上容易误判。我一开始设 5 秒结果在红米低端机上频繁超时后来改成 10 秒就正常了。如果你项目对加载速度要求高可以设 8 秒但要做好超时重试。5. 常见问题与排查技巧实录5.1 资源白块引用计数没归零还是缓存被误清白块是最常见的问题原因通常有两个。第一个是引用计数没归零资源被提前卸载了。排查方法是打开 Provider 日志看白块资源的refCount变化如果发现某个 Lease Dispose 后refCount变成 0 但还有界面在用说明有 Lease 没被正确收集。第二个是缓存被误清LRU 清理时把还在用的资源卸了。排查方法是看清理日志确认被卸资源的refCount是不是 0。如果是 0 但实际还在用说明引用计数逻辑有 bug。我遇到过一次白块查了半天发现是界面关闭时只 Dispose 了部分 Lease漏了一个。后来在基类里加了自动收集所有通过LoadIcon创建的 Lease 都自动加入列表关闭时统一处理再也没出过这个问题。5.2 内存泄漏Lease 忘了 Dispose 怎么查内存泄漏的排查靠日志加 Profiler。日志里看泄漏报告Profiler 里看内存曲线。如果内存曲线一直涨不回落基本就是泄漏。泄漏报告会打出泄漏的 Lease 和堆栈根据堆栈定位到具体界面和代码行。我遇到过一次泄漏堆栈指向一个已经删除的界面类查了半天发现是那个界面的预制体还在场景里虽然逻辑上关闭了但 GameObject 没销毁导致 Lease 没被 Dispose。后来在界面关闭逻辑里加了强制销毁 GameObject问题解决。5.3 加载卡顿主线程阻塞还是 IO 瓶颈加载卡顿分两种主线程阻塞和 IO 瓶颈。主线程阻塞的表现是帧率骤降Profiler 里看主线程耗时飙升。IO 瓶颈的表现是加载时间长但帧率正常Profiler 里看 IO 等待时间长。主线程阻塞通常是同步加载导致的改成异步加载就能解决。IO 瓶颈通常是 Bundle 太大或者磁盘慢需要优化 Bundle 拆分或者用更快的存储。我遇到过一次卡顿查下来是某个图集太大加载时解压耗时 200ms导致主线程卡了一下。后来把图集拆成两个小的加载时间降到 80ms卡顿感就没了。5.4 常见问题速查表问题现象可能原因排查方法解决方案界面白块引用计数提前归零看 refCount 日志检查 Lease 收集逻辑内存持续上涨Lease 未 Dispose看泄漏报告堆栈基类自动收集 Lease加载卡顿同步加载或大 BundleProfiler 看主线程耗时改异步或拆分 Bundle回调不触发Lease 被 Cancel看 Cancel 日志检查关闭逻辑重复加载缓存 key 不一致看加载日志统一 key 生成规则切场景崩溃跨场景 Lease 泄漏看场景切换日志强制清理活跃 Lease5.5 独家避坑技巧三个我踩过的坑第一个坑是Cancel 之后又 Dispose。业务层可能先 Cancel 再 Dispose如果不做防护引用计数会减两次。我的做法是在 Lease 内部统一处理Cancel 和 Dispose 都走同一个归还逻辑用状态机保证只归还一次。第二个坑是缓存 key 大小写敏感。Windows 上路径不区分大小写但代码里字符串比较区分导致同一个资源被加载两次。后来统一 key 生成规则全部转小写问题解决。第三个坑是Addressables 的引用计数和 Provider 的引用计数冲突。Addressables 自己也有引用计数如果 Provider 不调用Addressables.Release资源不会真正卸载。我的做法是在 Provider 卸载资源时先调Addressables.Release再置空引用保证两边计数一致。6. 扩展思考这套模型还能怎么用Provider 和 Lease 这套模型不只适用于 FUI 资源接入任何需要管理“有生命周期资源”的场景都能用。比如音频管理音频片段可以做成 Provider播放中的音频做成 Lease播放结束归还。比如网络请求请求本身可以做成 Provider请求结果做成 Lease取消请求就是 Cancel Lease。比如对象池池化的对象可以做成 Provider借出的对象做成 Lease归还就是 Dispose。我在另一个项目里把这套模型用到了配置表加载上效果也不错。配置表通常比较大加载慢用 Provider 做缓存和预加载用 Lease 做引用管理切场景时自动清理省了不少事。如果你要扩展这套模型我建议从两个方向入手。第一个方向是优先级加载给 Lease 加一个优先级参数Provider 按优先级调度加载顺序高优先级的先加载。第二个方向是依赖管理资源之间有依赖关系时Provider 自动加载依赖Lease 归还时自动检查依赖是否还需要。这两个方向我都在探索中有进展再分享。最后分享一个小技巧Provider 和 Lease 的日志一定要带资源名和堆栈。我见过太多项目因为日志信息不足排查一个问题要花好几天。日志多打一点排查快十倍这笔账怎么算都划算。