系列第 10 篇。排查 Unity UI 资源泄漏时,只检查 GameObject 是否销毁还不够:句柄、预热引用和动态模型可能仍有所有者。下面从失败路径定位释放责任,再对照 FUI 源码和最小测试验证。
英雄皮肤预览页很适合暴露资源所有权问题:页面 Prefab 已经创建,玩家切换皮肤后又异步加载模型、贴图和图标;加载尚未结束时页面被关闭,迟到结果究竟交给谁?如果只 Destroy(GameObject),Addressables Handle、AssetBundle、动态图片订阅或预加载引用仍可能活着。
“加载”和“释放”如果分散在不同类、不同回调里,系统就无法证明一次打开最终一定归还了所有东西。
先给结论
资源框架应把后端差异封装在 Provider,把一次占用封装为 Lease:
Navigator → 资源准备 / Provider.Create → IViewLease
↓
Commit 给活动页/缓存
↓
Close/Evict/失败时 Dispose
Lease 的价值不是包装一个 Dispose,而是把所有权变成可传递、可回滚、可测试的对象。
为什么 Destroy 不等于释放
一个页面实例可能同时持有:
- 实例化后的 GameObject;
- Prefab 加载句柄;
- Addressables 引用计数;
- 页面内动态加载的头像和图标;
- Binding/Presenter 的服务订阅;
- 预加载依赖页面的所有权;
- 对象池或缓存槽位。
Destroy(view.gameObject) 只处理其中一部分。尤其是 Addressables,实例释放与资源句柄释放必须遵循对应 API;AssetBundle 也有自己的依赖与卸载规则。
四种资源接入方案对比
| Navigator 直接调用 Resources | 最简单 | 路径、加载和导航强耦合 | 原型 |
| Navigator 直接调用 Addressables | 功能完整 | 测试困难,后端 API 扩散 | 单一后端项目 |
| Loader 返回 GameObject | 后端隔离 | 没有释放凭证,所有权不闭合 | 过渡方案 |
| Provider 返回 Lease | 加载与释放成对,可替换可测试 | 需要设计幂等和转移规则 | 长期框架 |
Provider 的抽象不应只为“以后也许换资源系统”。更直接的价值是让 Navigator 无论面对 Resources 还是 Addressables,都只处理同一种所有权协议。
FUI 的真实契约:返回的不是裸 View
下面摘录当前 ResourceContracts.cs 的接口,命名空间为 FUI.Resources;RouteDescriptor 来自 FUI.Navigation,IView 来自 FUI.Rendering。省略 using,不是完整独立文件。
public interface IViewProvider
{
IViewLease Create(RouteDescriptor route, IPreloadLease preload = null);
ValueTask<IViewLease> CreateAsync(
RouteDescriptor route,
IPreloadLease preload,
CancellationToken token);
ValueTask<IPreloadLease> PreloadAsync(
RouteDescriptor route,
CancellationToken token);
}
public interface IViewLease : IDisposable
{
IView View { get; }
IAssetScope Assets { get; }
}
这是 FUI 当前的契约:IViewLease.View 是已经创建的 View,而不是 Prefab。Provider 根据 RouteDescriptor.AssetKey 定位资源、创建展示对象及 Scope;它不应引用生成的 Routes 类型,也不负责绑定、Presenter 或导航生命周期。把返回 Prefab 再创建实例作为另一套设计是可以讨论的,但不能直接套到这个接口上。
特别注意:接口存在 CreateAsync,不代表当前 Navigator 的异步打开路径直接调用它。Navigator.OpenQueue.cs 先调用 Provider.PreloadAsync 准备资源;之后在提交阶段调用 Create(route.Descriptor, pendingPreload)。传入预热 Lease 时,Create 只能实例化已经准备好的资源,不能偷偷再发起一次加载。这样慢资源等待与串行导航提交可以分开处理。
所有权转移必须写清楚
先看所有权转移的教学伪代码。BuildEntry、Commit 并非 FUI API,示例假设 Commit 失败时没有接管 Lease;当前 FUI 的异步调度路径以后一段源码说明为准:
IViewLease lease = null;
try
{
lease = await provider.CreateAsync(route, preload: null, token);
var entry = BuildEntry(lease);
Commit(entry);
lease = null; // 所有权已转移给 entry
}
finally
{
lease?.Dispose();
}
如果没有显式转移点,很容易出现两种相反错误:失败时没人释放,成功后局部 finally 又把活动页资源提前释放。
可扩展方案是让事务对象提供一次性的 CommitTo(entry),但 FUI 当前并没有因此自动获得一个同名公开 API。
实际 Navigator.Operations.cs 在 Provider.Create 成功后先将 pendingPreload 置空,把 ViewLease 赋给 NavigationEntry,再清空局部 viewLease;随后才创建逻辑投影并 Enable。也就是说,Lease 转移到 Entry 不等于页面打开事务已全部成功。绑定、依赖页或后续步骤失败时,RollbackOpen 仍须清理 Entry 持有的资源。判断泄漏不能只看局部 finally。
Lease 必须满足哪些语义
5.1 Dispose 幂等
回滚与上层兜底可能同时触发释放:
public void Dispose()
{
if (Interlocked.Exchange(ref disposed, 1) != 0)
return;
ReleaseCore();
}
这是幂等闸门的教学代码,不是完整 Provider。Interlocked 只保证一次进入,不能把 Unity 对象操作变成线程安全;释放应切回后端要求的线程。若 ReleaseCore 中第一项清理抛异常,后续资源仍可能泄漏,而且 disposed 已经置位,第二次调用不会自动补救。生产实现应分别尝试所有子项的清理,再记录或聚合异常。幂等和完整清理是两项不同保证。
5.2 成功返回即拥有完整责任
Provider 不能先返回 View,再异步补充句柄。调用者拿到 Lease 时,它应已能独立释放当前获取产生的全部资源。
5.3 获取失败不返回半成品
如果加载 Prefab 成功但实例化失败,Provider 内部应释放 Prefab Handle 后再抛异常。
5.4 不暴露后端句柄给业务层
否则业务代码会绕过 Lease 调用 Addressables.Release,所有权再次分裂。
Resources、Addressables、AssetBundle 的适配差异
Resources
同步实现简单,但 Resources.UnloadAsset、实例销毁和 UnloadUnusedAssets 的语义不同。Lease 应准确释放实例;是否主动卸载共享资源要由 Provider 策略决定。
Addressables
需要区分 LoadAssetAsync + Instantiate 与 InstantiateAsync 的释放配对。取消等待不一定等于底层操作立即终止,结果回来后仍要检查操作资格并释放。
AssetBundle
Bundle 可能被多个页面共享,还存在依赖 Bundle。Lease 不应直接 Unload(true) 一个共享 Bundle,而应通过引用计数或上层 Bundle 管理器归还所有权。
框架不要假装能用一个 Destroy 覆盖这些差异;Provider 正是放置后端规则的地方。
页面动态资源需要第二层 Scope
皮肤预览页会在打开后按选择加载模型、贴图和头像。它们不属于 View Prefab 本身,却应随页面一起释放。
public interface IAssetScope : IDisposable
{
IAssetLease<T> Load<T>(string key);
ValueTask<IAssetLease<T>> LoadAsync<T>(string key, CancellationToken token);
IAssetLease<T> Instantiate<T>(string key);
ValueTask<IAssetLease<T>> InstantiateAsync<T>(string key, CancellationToken token);
}
这也是当前 ResourceContracts.cs 的真实接口。准确的持有关系是 NavigationEntry → IViewLease → IAssetScope,不是 Entry 自己再创建一套 Scope。契约要求 Scope.Dispose 兜底释放仍存活的子 Lease,但没有实现一个适用于所有后端的通用 Scope,也没有规定必须逆序释放。逆序清理是存在依赖关系时可采用的实现策略。
关闭后进入缓存时 ViewLease 仍存活,因此 Assets 也继续存在。皮肤模型若只应在可见期间保留,应在关闭或换皮肤时主动归还自己的子 Lease,不能等页面级 Scope 兜底。Scope 应允许子 Lease 提前 Dispose,并确保最终不会重复释放底层资源。
当前 Settings Sample 的 AssetScope 对动态加载方法直接抛 NotSupportedException;它只是无需动态资产的样例,不是现成的 Addressables Provider。本文的动态皮肤加载是接入方实现案例,不应让读者误以为安装 FUI 就有完整资源后端。
预加载为什么也需要 Lease
FUI 当前把预加载 Lease 留在 Navigator 内部。业务层只表达“预热这个 Route”:
// 假设项目已生成 Routes.Shop,navigator 已初始化,token 由调用方提供。
await navigator.PreloadAsync(Routes.Shop, token);
在 Provider 层,PreloadAsync(RouteDescriptor, token) 仍返回明确的 IPreloadLease;Navigator 接管它,并在创建页面、替换预热项或 ClearCache() 时处理所有权。这里没有向业务返回可随意释放的预热句柄。Navigator.PreloadAsync 还会捕获调用时的 Provider 与 generation;等待结束后检查取消、Provider 身份、generation 与 shuttingDown,拒绝过期结果,并在 finally 归还尚未转移的 Lease。取消是一个信号,generation 则防止旧结果混入新 Provider 的预热池。
调用方不应自行保存或释放后端预热句柄。若项目需要“多个业务所有者分别预热”的复杂语义,应在 Navigator 之上增加受控协调层,而不是绕过 IPreloadLease 直接操作资源系统。
缓存会改变释放边界
关闭页面时有两条合法路径:
Close → Cache:Lease 转移给 CacheEntry
Close → Release:Lease.Dispose
缓存淘汰时才真正 Dispose。缓存必须有容量、时长或显式清理策略,不能成为“永不释放”的另一个名字。
这不是抽象愿望:当前 TryCacheInstance 会把 Instance 与 ViewLease 一起放入 CachedEntry,清空原 Entry 的引用。旧 Handle 不保留,取回缓存会创建新 Entry 和新 Handle。关闭先执行 DisableAsync,成功后才尝试缓存;释放缓存时先 DestroyProjection,再 Dispose ViewLease。缓存不等于对象全部销毁,也不等于旧句柄恢复有效。
从源码推断,这些限制是在统一管理对象身份与资源寿命,降低业务层绕过框架的机会;它是架构约束,不是安全沙箱。缓存保留多少逻辑状态还要看具体 Instance、重新绑定与业务初始化规则,不能简单宣称只缓存 GameObject。
取消和异常是资源系统的主流程
异步资源代码要按以下场景测试:
- 等待前取消;
- 加载中取消,但后端无法真正停止;
- 加载成功后、实例化前取消;
- 实例化成功后、绑定前异常;
- Transition 期间关闭;
- 场景切换时批量释放。
任何场景都应满足所有权守恒:
获取次数 – 已转移次数 – 已释放次数 = 当前局部拥有数量
可以在测试 Provider 中记录 Create/Dispose 计数,直接验证最后归零。
常见坑点
坑一:Lease 是 struct 且会被复制
值类型复制可能导致多个副本都认为自己拥有释放权。除非使用共享引用状态并充分测试,否则优先用 sealed class。
坑二:Provider 缓存和 Navigator 缓存重复
两层都缓存实例,会让淘汰和引用计数无法推理。Provider 可缓存资源,Navigator 管页面实例;边界要明确。
坑三:释放顺序错误
应先停止命令/解绑/结束 Presenter,再销毁 View,最后释放资源句柄。否则回调可能访问已释放对象。
坑四:预加载失败被静默吞掉
真正打开时会重复失败却缺少上下文。预加载应返回可观察结果,或把失败记录到诊断系统。
坑五:用 Resources.UnloadUnusedAssets 当垃圾回收器
它不能替代正确所有权,只会把问题推迟并带来不可控开销。
可执行验证
- Create 成功后 Dispose 一次,后端释放一次;Dispose 两次仍只释放一次。
- 加载/实例化/绑定/动画任一步失败,计数最终归零。
- 活动页关闭进入缓存时资源不释放,淘汰时释放。
- 同 Route 重复预热不向业务暴露两个独立所有权;若扩展多所有者协调层,再额外测试引用计数和独立退出。
- 页面动态资源随 Scope 最终释放;进入缓存不误判为 Scope 已释放;主动归还子 Lease 后,Scope 清理不二次释放。
- 取消后迟到的 Addressables 结果被立即归还。
- 测试 Provider 可替代真实资源后端,Navigator 测试不依赖 AssetDatabase。
这些测试目前是接入验收清单,不是声称已经在读者的后端或 Unity 项目执行通过。
最容易遗漏的例子:页面已经关闭,模型刚好加载完成
以下是错误的教学伪代码,不是 FUI API。后端能够继续完成,页面关闭只会改变业务是否还需要它:
var model = await backend.LoadSkinAsync(skinId);
if (pageClosed) return; // 不显示了,但刚取得的资源没人归还
preview.Show(model);
再加一个 token 并不能自动补全释放。如果后端不支持真正终止,取消等待与底层取得资源会成为两条时间线。接入层必须观察原始操作的完成,并对未交付结果负责。把整个异步任务丢掉,调用方就连该释放哪个句柄也不知道了。
接下来给一个可以独立放进 NUnit 测试程序集的最小闭环。它只演示“结果到达后如何移交或归还”,不是完整 FUI Provider。为保证可读性,限制为单线程调用;不操作 Unity 对象,不代表 Addressables 适配已完成。所有业务关闭和完成回调需遵循同一执行上下文。
using System;
using System.Threading;
using System.Threading.Tasks;
using NUnit.Framework;
public sealed class SkinResourceOwnershipTests
{
sealed class Lease : IDisposable
{
Action release;
public Lease(Action release) { this.release = release; }
public void Dispose()
{
var callback = release;
release = null;
callback?.Invoke();
}
}
sealed class PreviewSlot : IDisposable
{
bool closed;
int version;
Lease current;
public async Task ChangeAsync(Task<Lease> pending,
CancellationToken token)
{
var mine = ++version;
Lease local = null;
try
{
// 模拟后端不支持取消:必须观察其最终返回的 Lease。
local = await pending;
if (local == null) throw new InvalidOperationException();
token.ThrowIfCancellationRequested();
if (closed || mine != version) return;
// 先摘除旧所有权;清理异常时,新结果仍由 finally 归还。
var old = current;
current = null;
old?.Dispose();
current = local;
local = null; // 只有这一行之后,退出由 Slot 负责
}
finally { local?.Dispose(); }
}
public void Dispose()
{
closed = true;
++version;
var old = current;
current = null;
old?.Dispose();
}
}
[Test]
public async Task CloseBeforeLoadCompletes_ReturnsLateLease()
{
var released = 0;
var source = new TaskCompletionSource<Lease>();
var slot = new PreviewSlot();
var task = slot.ChangeAsync(source.Task, CancellationToken.None);
slot.Dispose();
source.SetResult(new Lease(() => released++));
await task;
slot.Dispose();
Assert.That(released, Is.EqualTo(1));
}
[Test]
public async Task AcceptedLease_LivesUntilOwnerExits()
{
var released = 0;
var slot = new PreviewSlot();
await slot.ChangeAsync(Task.FromResult(
new Lease(() => released++)), CancellationToken.None);
Assert.That(released, Is.EqualTo(0));
slot.Dispose();
slot.Dispose();
Assert.That(released, Is.EqualTo(1));
}
[Test]
public async Task OlderSelection_CannotReplaceNewerSelection()
{
var releasedA = 0;
var releasedB = 0;
var a = new TaskCompletionSource<Lease>();
var slot = new PreviewSlot();
var taskA = slot.ChangeAsync(a.Task, CancellationToken.None);
await slot.ChangeAsync(Task.FromResult(
new Lease(() => releasedB++)), CancellationToken.None);
a.SetResult(new Lease(() => releasedA++));
await taskA;
Assert.That(releasedA, Is.EqualTo(1));
Assert.That(releasedB, Is.EqualTo(0));
slot.Dispose();
Assert.That(releasedB, Is.EqualTo(1));
}
}
预期分别是迟到结果释放一次、当前结果等所有者退出才释放、旧请求不能顶掉新选择。这里没有执行 Unity 测试;它证明的范围也不是加载后端会成功,而是输入指定完成顺序时,Slot 的所有权去向可以被断言。
真实接入还需补充取消后返回、获取失败、释放抛异常、线程切换和 Scope 已 Dispose 后子请求才完成。Scope 不应把迟到 Lease 登记进一个再也不会清理的容器:应原子地判断是否关闭,不接受则立即归还。若同时保留“已关闭”检查与“登记”两步却允许多线程插入,两步之间仍可能竞争。
好用的框架,应该让谁少操心
让新人记住每个后端的释放配对,再要求每个页面都写对异常处理,几乎是在把框架职责交回业务。Provider 统一后端协议,Lease 统一所有权凭证,Navigator 统一页面与缓存状态;业务只表达资源需求和何时不再需要。替换展示层或资源系统时,应改边缘适配,不迫使业务重写导航逻辑。
代价同样真实:实现 Provider 比包一层 Load 更费心;幂等、失败清理、预热转移和线程边界都要测试。它适合资源生命周期开始复杂的项目。短命原型可以先直接加载,但应知道将来要迁移的是释放责任,而不只是函数名。
分层的直接回报是测试可以不依赖场景:Fake Provider 记录资源取得、转移和归还;Navigator 测状态,Provider 集成测试测后端配对,实际 Unity 测试测对象和线程。不要拿一层的通过代替另一层证据。
资料与源码索引
- FUI:Resources 文档
- FUI:Resource Contracts
- FUI:Navigator.OpenQueue.cs
- FUI:Navigator.Operations.cs
- FUI:Navigator.Api.cs
- FUI:Navigator.State.cs
- FUI:SettingsSampleViewProvider.cs
排查的最后一步,不是再调用一次卸载,而是证明每份资源当下由谁持有、在哪个退出路径归还。
下一篇预告:把层级、回退、单例与缓存收进导航策略,分析大厅、弹窗、Loading 和 Toast 的组合。第 11 篇发布后补充同平台跳转。

