欢迎光临
我们一直在努力

Unity UI 资源释放:Provider、Lease 与缓存所有权的实现和坑点

系列第 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 篇发布后补充同平台跳转。

赞(0)
未经允许不得转载:171主机测评 » Unity UI 资源释放:Provider、Lease 与缓存所有权的实现和坑点
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址