池化Actor:局部循环Timer会把上一世带进下一世

自动关联目录:池化Actor:局部循环Timer会把上一世带进下一世

核心判断:池化 Actor 回池后仍是有效 UObject。局部句柄创建的循环 Timer 不会因为隐藏、禁 Tick 或“业务 EndPlay”自动停止;每次借出都会再增加一只属于上一世的周期任务。

一、业务冲突:对象要复用,初始化又需要延迟执行

复杂角色从池中借出后,需要恢复物理、外观、AI、重要度和外部索引。有些初始化不能在借出当帧完成,于是最直接的写法是在借出回调里设一个延迟 Timer。普通 Spawn/Destroy 流程下,对象销毁会让 UObject 绑定失效;对象池恰恰取消了这个前提。

二、实际实现:大部分重置很完整,一只 Timer 破坏了代际

实际回池路径已经显式注销空间索引和重要度系统、释放 AIController、解绑状态事件、清理感知、关闭投射运动并通知外部状态表;借出路径则重新注册这些依赖。这说明实现者已经把池化理解成一套替代 BeginPlay/EndPlay 的业务生命周期,而不只是隐藏 Actor。

但基类在每次借出时创建一个局部 FTimerHandle,以 UObject 成员函数绑定启动循环 Timer,用于延迟恢复物理。句柄离开函数后丢失,回池路径也没有清理该 Timer。

这里不会靠 UObject 失效自愈:Actor 只是进入池中,仍被池强引用。第一次复用留下一个循环,第二次复用再加一个;即使回调本身暂时幂等,调度成本仍随复用次数增长,而且隐藏对象也会持续触碰物理状态。

三、为什么通用回池逻辑没有替它收尾

禁用 ActorTick 或组件 Tick 不影响 FTimerManager;销毁 Physics State 也不会删除 Timer;反射拷回默认属性无法恢复 TimerManager 内部的调度记录。CreateUObject/成员函数绑定只保证对象无效时不调用,而池化对象始终有效。

ClearAllTimersForObject(this) 可以作为停机兜底,但它会清掉该对象全部 UObject 绑定 Timer,容易误伤仍需保留的基础设施任务,也无法表达每只 Timer 属于哪个复用代次。最好的所有权仍然是成员句柄加 Generation。

四、目标方案:Timer 必须进入 Reset 契约

如果任务只需延迟一次,首先应把循环标志改为一次性;如果确实要周期维护,则必须让句柄可撤销:

class FPooledRuntime {
    uint64 ReuseGeneration = 0;
    FTimerHandle DeferredPhysicsHandle;

    void OnAcquire() {
        CancelDeferredPhysics();
        const uint64 MyGeneration = ++ReuseGeneration;

        TimerManager.SetTimer(
            DeferredPhysicsHandle,
            MakeWeakLambda(this, [this, MyGeneration] {
                if (!IsAcquired() || MyGeneration != ReuseGeneration) return;
                RestoreDeferredPhysics();
            }),
            Delay,
            false);
    }

    void OnRelease() {
        ++ReuseGeneration;              // 先让旧回调失去提交权
        CancelDeferredPhysics();
        UnregisterExternalSystems();
        QuiescePhysicsAndAI();
    }
};

“先失效、再清理”很重要:即使某个回调已经进入队列,代际检查也能阻止它提交。EndPlay 和池销毁继续调用同一取消函数,保证真实销毁与逻辑回池不会维护两套规则。

若成百上千个对象都要周期检查,成员 Timer 虽正确,规模上仍不划算。此时应由集中调度器按活跃集合、时间轮或最小堆触发,只为当前借出的对象保存任务票据;回池即撤销票据。

五、验证:复用次数必须与活动任务数解耦

测试应对同一实例连续执行多轮借出/回池,并在每一轮统计该对象的活动 Timer、回调次数、池中回调次数和旧代拒绝次数。稳定条件是:借出时同类任务最多一只,回池后为零,累计复用次数增加不会提高单周期回调频率。

还要覆盖 World 暂停、时间膨胀、借出后立即回池、回池与 Timer 同帧到期、池整体清理以及 DS 无渲染路径。Unreal Insights 中如果同一对象的 Timer Delegate 调用频率随借出次数阶梯上升,问题已经被直接证明。

六、反思

对象池最危险的地方不是忘记显示 Actor,而是 UObject 身份仍在、业务生命已经结束。现有代码对 AI、索引、感知和委托做了大量正确清理,反而让这只局部 Timer 更有代表性:任何拿不到取消令牌的重复任务,都不应该跨进对象池。