属性复制条件与 RepNotify

自动关联目录:属性复制条件与 RepNotify

"该同步什么"靠注册,DOREPLIFETIME 决定发给谁,OnRep 决定收到后做什么。这三层里任意一层配错,表现都是"客户端数值不对",但原因完全不同。

一句话定位:属性复制是"变化才发",OnRep 只在客户端触发。记住后半句能省掉一半的调试时间。

最小可用写法

UPROPERTY(ReplicatedUsing = OnRep_Health)
float Health;

void AMyActor::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutProps) const
{
    Super::GetLifetimeReplicatedProps(OutProps);
    DOREPLIFETIME(AMyActor, Health);
}

void AMyActor::OnRep_Health()
{
    // 只有客户端会跑
}

漏掉 GetLifetimeReplicatedProps 里的注册,属性就完全不复制——这是最常见也最容易漏的一步(没有编译错误,纯运行时失效)。

COND_ 条件

条件发给谁
COND_None(默认)所有相关连接
COND_OwnerOnly只发给拥有者
COND_SkipOwner发给所有人除了拥有者(配合本地预测:自己的动作本地已演算,不用回传)
COND_InitialOnly只在初始复制时发一次(名字、配置)
COND_SimulatedOnly只发给模拟端(非自主端)
COND_AutonomousOnly只发给自主端(拥有者)
COND_ReplayOrOwner回放或拥有者
COND_SkipReplay回放时跳过
COND_Custom用 SetCustomIsActiveOverride 自己判断
void AMyActor::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutProps) const
{
    Super::GetLifetimeReplicatedProps(OutProps);

    DOREPLIFETIME_CONDITION(AMyActor, AmmoInMag, COND_OwnerOnly);   // 弹匣只有自己需要知道
    DOREPLIFETIME_CONDITION(AMyActor, DisplayName, COND_InitialOnly);
    DOREPLIFETIME_CONDITION(AMyActor, ThirdPersonAnimState, COND_SkipOwner);
}

// 动态条件
void AMyActor::PreReplication(IRepChangedPropertyTracker& ChangedPropertyTracker)
{
    Super::PreReplication(ChangedPropertyTracker);
    DOREPLIFETIME_ACTIVE_OVERRIDE(AMyActor, SecretData, bHasIntel);
}

COND_SkipOwner + 本地预测是标配:自己开枪的表现本地已经播了,服务端再回传一次会造成重复播放。

OnRep 的三种模式

void AMyActor::OnRep_Health(float OldHealth)
{
    // UE 5.x 支持带旧值参数,便于做差值表现
}
模式说明
REPNOTIFY_OnChanged(默认)值真的变了才触发
REPNOTIFY_Always每次收到都触发,即使值相同
REPNOTIFY_InitialOnly只在初始那次触发
UPROPERTY(ReplicatedUsing = OnRep_Health)
float Health;

void AMyActor::OnRep_Health()
{
    // GAS 的写法,见 [UAttributeSet](/76cb5781.html)
}

什么时候用 REPNOTIFY_Always:当客户端做过钳制(PreAttributeChange 改了值)导致与服务端值不一致时,需要强制再触发一次回调来刷新表现。

服务端不会触发 OnRep

这是属性复制最大的认知陷阱:OnRep 是"收到网络包后的回调",服务端没有"收到"这一步。

void AMyActor::SetHealth(float NewValue)
{
    Health = FMath::Clamp(NewValue, 0.f, MaxHealth);   // 服务端改
    if (HasAuthority())
    {
        OnRep_Health();    // 必须手动调一次,否则服务端没有表现逻辑
    }
}

HasAuthority() 是判断服务端的正确方式(不是 IsServer(),也不是 Role 枚举)。

数组复制:Fast Array

普通 TArray 复制是整体重发:改一个元素,整个数组序列化一遍。

// 增量复制:只发变化的项
USTRUCT()
struct FMyItem : public FFastArraySerializerItem
{
    GENERATED_BODY()
    UPROPERTY() int32 Id;
    UPROPERTY() int32 Count;
};

USTRUCT()
struct FMyItemList : public FFastArraySerializer
{
    GENERATED_BODY()
    UPROPERTY() TArray<FMyItem> Items;

    bool NetDeltaSerialize(FNetDeltaSerializeInfo& DeltaParms)
    {
        return FFastArraySerializer::FastDeltaSerialize<FMyItem, FMyItemList>(Items, DeltaParms, *this);
    }
};

template<>
struct TStructOpsTypeTraits<FMyItemList> : public TStructOpsTypeTraitsBase2<FMyItemList>
{
    enum { WithNetDeltaSerializer = true };
};
场景用哪个
少量元素、变化不频繁普通 TArray
背包、Buff 列表、排行榜FastArray

FastArray 的代价:实现复杂、序列化顺序敏感、删除项要标 bIsValid 或走 MarkItemDirty。只有列表真的大/真的频繁才值得上。

Push Model(按需标记脏)

默认引擎每帧比较所有复制属性。Push Model 让你显式标记,省掉比较开销。

// 开启(DefaultEngine.ini 或命令行)
net.IsPushModelEnabled=1

// 代码
#include "Net/Core/PushModel/PushModel.h"
MARK_PROPERTY_DIRTY_FROM_NAME(AMyActor, Health, this);

收益与项目规模成正比:几百个复制属性的 Actor 才有明显效果,小项目不值得引入这个复杂度。

能复制什么

能说明
标量、FString、FName直接支持
USTRUCT成员要 UPROPERTY
TArray / TMap / TSet整体重发
UObject*必须是可网络寻址的(Stably Named)
TSubclassOf支持
FVector_NetQuantize量化后更省
不能说明
裸指针(非 UObject)无网络地址
委托 / TFunction不可序列化
未标 UPROPERTY 的字段对引擎不存在(见 反射)

带宽控制

手段说明
NetUpdateFrequency每秒复制几次,默认 100 很高
MinNetUpdateFrequency无变化时的低频
bOnlyRelevantToOwner只发给拥有者
NetCullDistanceSquared距离剔除
量化类型FVector_NetQuantize*
COND_*见上文
ForceNetUpdate()有重要变化立刻发(不要滥用)

把 NetUpdateFrequency 从默认 100 降到 10–20,通常是收益最大的一次优化(见 Optimization)。

常见坑

坑说明
忘了 DOREPLIFETIME 注册静默不复制
以为服务端也会触发 OnRep不会,要手动调
客户端改复制属性会被服务端覆盖,产生抖动
用 RPC 同步持续状态应该用属性复制
大数组用普通 TArray整体重发,带宽爆炸
复制了不需要的字段降不下来的带宽
OnRep 里做权威判定只有客户端跑
复制属性忘了 UPROPERTY编译可能过,但不复制
FastArray 改了元素不 MarkItemDirty变化不发