2.5 复制顺序、初始化与常见隐蔽坑

把属性复制单独再拆一节,因为初始化阶段的顺序问题是「上线后偶发 BUG」的高发区——开发时网络好、节奏慢,很难复现。

Actor 首次同步的顺序

客户端第一次收到一个复制 Actor 时,框架做这些事(顺序很重要):

  1. 在客户端 Spawn 这个 Actor(带服务器上的初始 Transform)
  2. 按复制声明设置属性初值(Subobject 引用、复制属性在此时赋上服务器 spawn 时的值)
  3. 触发一次「初始化」性质的 OnRep 回调(UE5 对首次同步的属性也会发 RepNotify)
  4. 之后进入常规的增量复制

因此「Actor 刚生成的第一帧,所有复制属性是否已就绪?」——是(第 2 步已完成),这是 BeginPlay 里就能读复制属性的原因。但反过来也说明:构造函数里读复制属性是错的(还没赋值,都是默认值),所有依赖复制状态的初始化逻辑放 BeginPlay 或 OnRep。

隐蔽坑一:BeginPlay 时其他 Actor 未就绪

你的角色 BeginPlay 里要读 TeamCaptain->GetTeamId()(另一个复制 Actor 的复制属性)。首次同步时,两个 Actor 的到达顺序不保证——队长可能还没生成,或刚生成但本次回包的属性还在路上。表现就是「偶发空指针/读到默认值」,本地测试极难复现。

解法:

  • 去依赖化:不直接读对方,改用 RPC 请求所需数据。
  • 延迟查询:客户端用一个 Timer / Tick 延迟重试,数据就绪后再初始化。
  • 聚合数据:把对方的关键数据冗余复制在自己身上(如队长 ID 直接复制在队伍组件里)。

隐蔽坑二:复制顺序与「半组装状态」

单次网络回包里,同一帧可能更新了「当前播放的技能 ID」但「技能目标 Actor 引用」还没到。OnRep 逻辑若依赖「目标引用有效」,此时就会失效。处理:OnRep 里对可能未就绪的引用做判空 + 延迟重试,或重设计为先同步对象引用再同步状态。

隐蔽坑三:客户端的 InitialOnly 陷阱

COND_InitialOnly 只在首次同步时发送。如果某个属性你在服务器 spawn 之后才赋值(例如 spawn 后过 0.5 秒才设置「归属队伍」),InitialOnly 只记住了 spawn 时(NULL/默认)的状态,这个属性永远同步不过去。InitialOnly 适合 spawn 前就定死的属性,动态属性别用。

隐蔽坑四:ListenServer 上 OnRep 不触发

监听服务器自己有个本地客户端,本地的复制属性变化来自同一进程的服务器逻辑——服务器上直接改属性不会触发 OnRep(OnRep 只在远端客户端触发)。主机既要逻辑又要表现时,显式拆开:

void AMyCharacter::ApplyDamage(...)
{
    Health -= Damage;                      // 服务器改权威值
    // 服务器自己的表现直接调,不等 OnRep:
    if (GetNetMode() == NM_ListenServer) PlayHitReact(Damage);
}

隐蔽坑五:RelevantOnly 属性与「自己的玩家」

COND_OwnerOnly 的属性只发给拥有者。配合 bOnlyRelevantToOwner 的 Actor(如 PlayerState 上只给自己看的部件)是天作之合;但注意:同一 Actor 的不同客户端实例,各自收到各自的内容——远程客户端上这个属性的值是默认/初始值,别在远程客户端读它做逻辑。

检查清单

  • [ ] 所有依赖复制状态的初始化在 BeginPlay/OnRep,而非构造函数
  • [ ] 跨 Actor 依赖在网络初始化阶段有判空和重试
  • [ ] InitialOnly 仅用于 spawn 前定死的属性
  • [ ] ListenServer 上需要表现的地方显式触发,不依赖 OnRep
  • [ ] 客户端从不写权威复制属性

心得:网络初始化阶段的 bug 有个统一特征——「本地测试一切正常,上线后小概率出现空指针/默认值」。养成习惯:所有跨 Actor 的读操作在网络环境下都假设「对方可能还没准备好」,代码会稳健很多。