软类预热:加载句柄取消不等于初始化已经失效
自动关联目录:软类预热:加载句柄取消不等于初始化已经失效
核心判断:
FStreamableHandle能管理一次资源加载,却不能替业务定义 Loading、Ready、Failed 和 Closed。池是否初始化、类是否驻留、模板是否构建、对象是否可借是四个不同事实。
一、业务冲突:对象池要避免首次卡顿,又不能阻塞等类加载
池化对象由软类配置。进入玩法时希望异步加载 Class、预创建容量并建立 Reset 基线;第一次借用又可能早于预热完成。退出玩法、切 World 或进入低内存模式时,还要取消预热并释放驻留。
这不是简单的“Load 完回调一次”,而是一个有需求、有关闭竞争、有失败结果的资源状态机。
二、实际实现:资源句柄被正确持有,业务状态仍只有布尔值
实际池在 Init 时保存配置并设置 bInit,然后选择异步请求软类或走强制同步路径。异步请求返回的 Streamable Handle 被成员持有;完成回调扩容并创建隐藏模板。自动清退在加载进行中不会删除池;Clear 会依次 Cancel、Release 并 Reset Handle。
这些做法解决了三个常见问题:加载句柄不会成为匿名请求;清退不会明知资源仍在加载却立即拆池;关闭路径显式表达了取消和释放。
剩余问题在业务状态:IsAccessible 只检查已 Init 且未 PendingRemove,但此时 Class 可能尚未 Ready;Spawn 发现软类无效时只能返回空。若加载服务不可用、路径无效或加载失败,池没有 Failed 状态和失败原因,会停在“看似可访问、实际借不到”的中间态。完成回调也不检查当前状态、目标路径或初始化代次,正确性完全依赖取消句柄与 UObject 委托的外部保证。
三、UE 句柄语义不能替代业务语义
CancelHandle 表达“不再等待这次加载继续完成”,不等于撤销已经完成的资源工作;ReleaseHandle 表达释放当前句柄对资源管理的贡献,也不自动说明模板 Actor、池内实例和其他强引用已经消失。CreateUObject 可以避免向已销毁 UObject 调用,却无法阻止一个仍存活但已经 Closed 的池接收旧结果。
同样,强制同步只是另一种执行策略,不应绕过同一套 Ready/Failed 提交条件。把同步与异步都收敛到一条完成状态机,才不会形成两套初始化语义。
四、目标方案:LoadTicket + 显式状态机
enum class EPoolState { Dormant, Loading, Ready, Closing, Closed, Failed };
struct FLoadTicket {
uint64 Generation;
FSoftObjectPath ClassPath;
TSharedPtr<FStreamableHandle> Handle;
};
void BeginWarmup(FSoftObjectPath Path) {
CloseCurrent(EEndReason::Superseded);
State = EPoolState::Loading;
Ticket = { ++Generation, Path, RequestLoad(Path) };
if (!Ticket.Handle) Fail(Generation, ELoadError::ServiceUnavailable);
}
void OnLoaded(uint64 Gen, FSoftObjectPath Path) {
if (State != EPoolState::Loading || Gen != Generation || Path != Ticket.ClassPath) {
ReleaseLateResult(Gen);
return;
}
UClass* LoadedClass = ResolveLoadedClass(Path);
if (!LoadedClass || !ValidatePoolContract(LoadedClass)) {
Fail(Gen, ELoadError::InvalidClass);
return;
}
if (!BuildTemplateAndCapacity(LoadedClass)) {
Fail(Gen, ELoadError::WarmupFailed);
return;
}
State = EPoolState::Ready;
}
FAcquireResult TryAcquire() {
if (State == EPoolState::Loading) return FAcquireResult::Loading();
if (State == EPoolState::Failed) return FAcquireResult::Failed(LastError);
if (State != EPoolState::Ready) return FAcquireResult::Closed();
return AcquireReadyObject();
}Closing 先增加 Generation、拒绝新借用,再取消句柄、处理 Resetting/Ready 队列、销毁模板并释放驻留,最后进入 Closed。调用方因此能区分“稍后重试”“配置错误”“玩法已关闭”,而不是把所有情况压成 nullptr。
五、取舍与验证
状态机让接口更严格:上层必须处理 Loading 和 Failed,不能假设 Init 后立即可借。但它也消除了同步兜底的诱惑。若业务必须在预热未完成时继续,可以显式选择 SpawnFallback;该路径要单独计量主线程耗时,不能藏在池内部突然 LoadSynchronous。
验证至少覆盖:加载服务缺失、路径无效、加载中关闭、完成与关闭同帧、重复 Init 不同类、模板构建失败、低内存切换和 World 销毁。指标应区分 WarmupLatency、ReadyPoolCount、AcquireWhileLoading、WarmupFailureByReason、LateCompletionRejected、ResidentHandleCount。只有 Ready 能借出,Closed 后完成回调不能重新扩容,才算生命周期闭合。
六、反思
现有实现已经知道要保存、取消和释放 Streamable Handle,这是正确的资源所有权起点。问题在于资源请求的状态被误投影成了池的一个布尔值。句柄回答“这次加载怎样结束”,状态机回答“这个业务对象现在允许做什么”;两者缺一不可。