延迟Spawn:FinishSpawning之前不能发布半成品Actor

自动关联目录:延迟Spawn:FinishSpawning之前不能发布半成品Actor

核心判断:Deferred Spawn 的价值是在 Construction Script 和 BeginPlay 前注入构造输入。FinishSpawning 之前的 Actor 只是草稿,只能被当前 Spawn 操作持有;把它写入 PlayerInfo、注册表或事件,相当于发布一份尚未完成不变量的半成品。

一、业务冲突:构造前要写身份,其他系统又想尽快拿到 Actor

玩家信息、Controller 和 Pawn 在创建时需要 UID、Owner、组件配置、初始 Transform 与本地控制属性。普通 SpawnActor 会立即运行 Construction Script、注册组件并进入 BeginPlay,来不及注入这些输入;Deferred Spawn 因此是正确工具。

但 Actor 指针一旦返回,很容易被误认为已经可用。业务会提前建立双向引用、写注册表、开启复制或广播事件,导致 Construction Script 和外部消费者同时观察一份尚未收口的状态。

二、实际实现:一条链路接近事务,另一条提前发布

实际代码有几条很有价值的对照:

  • 创建玩家信息时,先 Deferred Spawn,再设置 Owner、玩家身份和组件创建配置;Finish 后才绑定玩法、加入状态表并广播新增事件。大部分外部发布都在提交边界之后;
  • 创建 Controller 时,先设置本地控制属性并执行 PreFinish 初始化,再 Finish,随后才把结果返回调用方;
  • 创建 Pawn 时,同样先延迟 Spawn 并写入玩家初始化数据,但在 Finish 前就把 Pawn 指针写回玩家信息,且后续初始化没有先证明 Spawn 返回非空。

前两条说明实现者已经理解“构造输入必须在 Finish 前,业务登记应在 Finish 后”。第三条则暴露了最容易遗漏的边界:建立外部反向引用本身就是发布,不只是广播事件才算发布。

三、FinishSpawning 是强副作用边界,但不是数据库事务

Finish 会运行 Construction Script、完成组件初始化,并可能触发 BeginPlay。任何一个阶段都可能访问前置字段、注册委托或产生 World 副作用。因此:

  • 会影响 Construction/BeginPlay 的身份、配置、Owner、Instigator 与初始 Transform 必须在 Finish 前冻结;
  • Registry、PlayerInfo 反向引用、复制关系、玩法事件和对外可查询状态必须在 Finish 成功并验证后发布;
  • 若 SetReplicates 放在 Finish 后,Construction/BeginPlay 看到的就不是最终复制配置。是否允许必须成为显式约束,而不是偶然顺序;
  • Finish 失败时可以销毁 Actor,但无法自动撤销 Construction Script 已经写出的全局副作用,所以构造期仍应禁止外部注册。

四、目标方案:Reserve、Prepare、Finalize、Publish

class FDeferredSpawnGuard {
    TWeakObjectPtr<AActor> Draft;
    bool bPublished = false;
public:
    ~FDeferredSpawnGuard() {
        if (!bPublished && Draft.IsValid()) Draft->Destroy();
    }
    void PublishSucceeded() { bPublished = true; }
};

TResult<AActor*> SpawnPlayerEntity(const FSpawnSpec& Spec) {
    AActor* Draft = World.SpawnActorDeferred(Spec.Class, Spec.Transform,
                                              Spec.Owner, Spec.Instigator);
    if (!IsValid(Draft)) return Error(SpawnFailed);
    FDeferredSpawnGuard Guard(Draft);

    // Prepare: 只写 Draft 自身的构造输入。
    if (!ApplyIdentity(Draft, Spec.Identity)
        || !ApplyComponentPlan(Draft, Spec.Components)
        || !ApplyReplicationPlan(Draft, Spec.Replication)) {
        return Error(PrepareFailed);
    }

    Draft->FinishSpawning(Spec.Transform);
    if (!ValidateFinalActor(Draft, Spec)) return Error(FinalizeFailed);

    // Publish: 到这里外部系统才第一次看见 Actor。
    PlayerRecord.SetActor(Draft);
    RuntimeRegistry.Add(Spec.Identity, Draft);
    BroadcastSpawned(Spec.Identity, Draft);
    Guard.PublishSucceeded();
    return Draft;
}

Prepare 阶段不调用可能把指针存到外部对象的 API。若必须建立循环引用,可先保存稳定 ID,等 Publish 再解析指针。Publish 自身也要有补偿顺序:先写可撤销 Registry,最后广播不可收回的事件;中途失败按逆序移除。

五、取舍与验证

Spawn Guard 只能保证草稿最终被销毁,不能回滚任意蓝图副作用;真正的约束仍是 Construction Script 只构建自身,不登记全局事实。对于无需预注入数据的简单 Actor,普通 Spawn 更合适,不要为了统一接口滥用 Deferred。

测试应故意使用空 Class、Spawn 返回空、Prepare 校验失败、Construction Script 主动销毁 Actor、组件创建失败、Finish 后不满足不变量,以及消费者在 Finish 前查询。网络测试还要确认首个复制帧看到完整身份和组件集合,客户端不会先收到 Actor 再补关键构造字段。

可观测字段包括 SpawnOperationId、Stage、DraftValid、ConstructionCompleted、Published、CompensationResult。完成条件是任何失败 Stage 都不会在 PlayerInfo、Registry、复制路由或事件日志中留下可消费的 Actor。

六、反思

实际代码中“先配身份和组件计划、Finish 后再加入状态表”的顺序非常值得保留。需要批判的不是使用 Deferred Spawn,而是把指针返回误认为对象已经提交。FinishSpawning 之前可以准备对象,不能承诺对象;对外发布必须是最后一步。