属性复制(Property Replication)
「服务器改一个变量,客户端自动同步」——这是 UE 网络最核心的能力,也是面试必挖的点。本篇拆开这句话:怎么声明、引擎怎么知道变了、怎么做差分、怎么省带宽、怎么优化那个 O(属性×Actor) 的轮询代价。
一句话结论
服务器每帧把复制属性和 Shadow State(上次快照)逐个比对,变了的打包发给客户端;Push Model 让你手动标脏、省掉全量轮询。
场景问题
打个比方:属性复制像每天核对库存表并只上报变动。服务器手里有两张表:当前实际库存和上次上报时抄下来的那份副本(Shadow State)。每天(每帧)拿这两张表逐项比对,只把对不上的项报上去——这就是差分:不发全表,只发变的。
但项目一多,逐项比对就成了体力活:1000 个仓库 × 每仓 20 项 = 每天核 2 万次,多数项其实从没动过。Push Model 换个思路:谁动了库存,自己在门口挂个牌(标脏),核对时只看挂牌的——从「全表点一遍」变成「只看挂牌的」。
COND_*复制条件是分发范围:有些数字只报给自己人(COND_OwnerOnly),有些只在开业时报一次(COND_InitialOnly),别群发浪费。RepNotify则是收件方的签收动作:新数字一到就顺手更新门口的显示屏(刷 UI、播特效),而不是让显示屏每秒自己去查表。
「你怎么让一个 Health 变量从服务器同步到所有客户端?」——声明 UPROPERTY(Replicated) + 在 GetLifetimeReplicatedProps 里 DOREPLIFETIME,服务器改值客户端就能收到。追问「引擎怎么知道 Health 变了?」——默认是每帧轮询比较:把当前值和上次发送时的影子拷贝逐字节比,不同就发。再追问「1000 个 Actor 各 20 个属性,每帧比 2 万次不慢吗?」——这就引出 Push Model。整篇就是把这条追问链讲透。
实现方案
1. 声明复制
// .h
UPROPERTY(Replicated)
int32 Health;
// .cpp —— 告诉引擎哪些属性要复制
void AMyActor::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutProps) const {
Super::GetLifetimeReplicatedProps(OutProps);
DOREPLIFETIME(AMyActor, Health); // Health 复制给所有相关客户端
}
外加 Actor 要开 bReplicates = true(SetReplicates(true))。方向永远是服务器 → 客户端单向,客户端改复制属性无效(会被下次复制覆盖)。
2. RepLayout:属性扁平化
引擎为每个可复制类构建一张 RepLayout:遍历 反射生成的 FProperty 链表,把所有 Replicated 属性(含嵌套 struct 展开)拍平成一张按 handle(稳定编号)索引的表,记录每个属性的偏移、类型、条件。之后所有比较、序列化都按这张表的 handle 操作,不再走反射名字查找——快。
3. Shadow State 差分(默认轮询)
Shadow State(影子状态):引擎为每个连接(或每个 Actor)保存「上次发出去的属性值快照」。每次发包前拿当前值和影子比,只发变化的属性 + 它的 handle。客户端按 handle 找到属性写入。
代价:这是 O(连接 × Actor × 属性) 的逐属性比较。属性多、Actor 多、连接多时,光比较就吃满 CPU——哪怕啥都没变也要比。这就是下面 Push Model 要解决的。
4. Push Model(UE5,标脏免轮询)
UE4/UE5 差异
Push Model(Net Push Model)是 UE5 引入的可选优化,UE4 只有默认轮询。开关:net.IsPushModelEnabled 1 + 编译期 UE_WITH_IRIS/Push Model 宏。
思路反过来:不再每帧全量比较,而是属性变化时由你手动标脏,引擎只发被标脏的属性:
// 声明用 Push-based 变体
DOREPLIFETIME_WITH_PARAMS_FAST(AMyActor, Health, Params /*bIsPushBased=true*/);
// 改值时手动标脏
void AMyActor::SetHealth(int32 New) {
Health = New;
MARK_PROPERTY_DIRTY_FROM_NAME(AMyActor, Health, this); // ← 标脏
}
省掉的正是「没变的属性也要比一遍」的开销:把复制的 CPU 成本从 O(所有属性) 降到 O(实际变化的属性)。代价是漏标脏 = 不复制(改了值忘了标,客户端永远收不到),需要纪律或封装 setter。
5. 复制条件 COND_*:省带宽
DOREPLIFETIME_CONDITION 让属性只复制给需要的连接,省带宽:
| 条件 | 复制给谁 | 典型用途 |
|---|---|---|
COND_None | 所有相关连接 | 默认,人人可见 |
COND_InitialOnly | 只在首次复制 | 出生点、创建参数,之后不变 |
COND_OwnerOnly | 只给 owner | 玩家私有数据(弹药明细) |
COND_SkipOwner | 除 owner 外所有人 | owner 已本地预测,不必回发 |
COND_SimulatedOnly | 只给 SimulatedProxy | 只有「别人」需要的表现数据 |
COND_AutonomousOnly | 只给 AutonomousProxy | 只有本人需要的纠正数据 |
COND_SkipOwner + 预测 | — | 移动纠正常用组合 |
COND_Custom | 运行时 DOREPLIFETIME_ACTIVE_OVERRIDE 动态开关 | 条件复杂时代码控制 |
COND_ReplayOnly | 只写进回放 | 回放需要但实时不需要的数据 |
DOREPLIFETIME_CONDITION(AMyPawn, Ammo, COND_OwnerOnly); // 弹药只发给本人
选对条件能大幅削减带宽:一个 100 人房间里,玩家私有数据用 COND_OwnerOnly 后,复制量从「每属性 × 100 连接」降到「每属性 × 1」。
6. RepNotify:值到达时回调
UPROPERTY(ReplicatedUsing = OnRep_Health)
int32 Health;
void AMyActor::OnRep_Health() { // 客户端收到新 Health 时自动调
UpdateHealthBar(); // 更新血条 UI
}
要点(高频坑):
- 服务器改值不会自动触发
OnRep(只有客户端收到复制才触发)。服务器要同样逻辑得手动调。 - 默认值没变化不触发;加
REPNOTIFY_Always可让每次收到都触发(哪怕值没变)。 - 到达顺序不可依赖:多个属性的
OnRep触发顺序不保证,别在OnRep_A里假设B已经是新值。
7. 数据量优化:序列化压缩
带宽正比于「每个属性序列化后的字节数」,压缩手段:
NetSerialize:为 struct 自定义网络序列化,只写需要的位。FVector_NetQuantize系列:位置量化。NetQuantize(整数精度)/NetQuantize10(0.1)/NetQuantize100(0.01)——精度换字节,位置从 3×4 字节 float 压到几字节。- bool 位打包:多个 bool 复制时打进 bitfield,1 bit 一个而非 1 字节。
FFastArraySerializer:数组增量复制。普通TArray复制会整个数组重发;FastArray 只发增删改的元素(靠每元素的ReplicationID/ReplicationKey),大数组(背包、Buff 列表)必备。
struct FVector_NetQuantize100 Location; // 位置压到 cm 精度,省带宽
8. 子对象复制
Actor 身上的 UObject 成员(非组件)默认不复制,要手动:
ReplicateSubobjects(旧法):重写该函数,对每个子对象调Channel->ReplicateSubobject。- Registered Subobject List(UE5 新法):
AddReplicatedSubObject注册,引擎自动处理,省去手写。 - Component 复制:组件设
SetIsReplicated(true)即可,引擎自动建通道。 - 初始复制顺序:一个新 Actor 首次复制的顺序是「创建 Actor Channel → 复制初始属性 → 复制子对象/组件」。所以子对象的
OnRep触发时,Actor 本身的初始属性已就绪。
9. 一句话记住这几组容易混的概念
| 一组 | 差别一句话 | 类比 |
|---|---|---|
| 属性复制 vs RPC | 属性复制同步「当前状态」,晚来的人也能看到;RPC 传「一瞬间的事件」,错过就没了 | 公告板 vs 寄信(详见 RPC) |
| 轮询 vs Push Model | 轮询是「全表逐项比一遍」;Push Model 是「改的人自己挂牌,只看挂牌的」 | 天天盘全仓 vs 谁动谁挂牌 |
COND_* vs Relevancy | COND_* 定「这个属性发给谁」;Relevancy 定「这个Actor整体要不要发给谁」 | 这份报表只给老板看 vs 这个仓库根本不在你辖区 |
RepNotify vs 每帧 Tick 检查 | RepNotify 是「值到了推给你」;Tick 轮询是「你自己一直问变了没」 | 来件通知 vs 每分钟跑去看信箱 |
TArray vs FFastArraySerializer | 普通数组改一格要重发整个数组;FastArray 只发增删改的那几格 | 重印整本目录 vs 只发一张勘误页 |
一条主线串起来:先看这个 Actor 归不归你管(Relevancy)→ 再看哪些属性给你(COND_*)→ 再看有没有变(Shadow State 或 Push Model 标脏)→ 变了就压缩打包发(NetQuantize / FastArray)→ 到了触发 RepNotify 刷表现。
为什么这么做
差分 + Shadow State:绝大多数属性帧间不变,只发变化的能把带宽压到最低。影子快照是「只发 diff」的必要前提。
Push Model 反转轮询:默认轮询在属性/Actor 多时把 CPU 耗在「比较没变的东西」上。手动标脏把成本从 O(全部属性) 降到 O(变化属性),用一点编码纪律换 CPU。
COND_ + NetQuantize + FastArray:三个方向省带宽——范围(只发给需要的人)、精度(够用就好)、增量(只发变化的元素)。大房间下每一项都是数量级的节省。
为什么别的选择不行
- 每帧全量发所有属性:带宽爆炸,且大部分是没变的冗余数据。
- 客户端也能改复制属性:违反服务器权威,且会被下次复制覆盖,等于作弊入口,见 反作弊。
- 用 RPC 传状态代替属性复制:RPC 是「一次性事件」,新加入的客户端收不到历史;属性复制天然给新连接发当前值。原则是「属性传状态、RPC 传事件」,见 RPC。
- 大数组用普通
TArray复制:改一个元素整个数组重发,带宽浪费;应上FFastArraySerializer。
沉淀结论
复制 = 声明(DOREPLIFETIME)+ RepLayout 扁平化 + Shadow State 差分。默认每帧轮询比较 O(属性×Actor),Push Model(UE5)手动标脏省掉全量轮询。省带宽三板斧:COND_* 控范围、NetQuantize 控精度、FFastArraySerializer 控增量。RepNotify 服务器不自动触发、顺序不可依赖。
记忆口诀
流程:DOREPLIFETIME 声明 / RepLayout 扁平化按 handle / Shadow State 差分 / 只发变化
Push Model:UE5 引入 / MARK_PROPERTY_DIRTY 标脏 / 省全量轮询 / 漏标不复制
省带宽:COND_ 控范围 / NetQuantize 控精度 / bool 位打包 / FastArray 控增量
RepNotify:ReplicatedUsing / 服务器不自动触发 / REPNOTIFY_Always / 到达顺序不可依赖
原则:属性传状态 / RPC 传事件 / 服务器→客户端单向
内容来源
综合整理。主要参考:Epic 官方文档 Property Replication、Push Model Networking、Fast TArray Replication、UE 源码 RepLayout.cpp/DataReplication.cpp、社区分析 Alex Forsythe《How Unreal's Networking Works》。数字为引擎默认值或业界数量级估算。
相关专题:底层 Bunch/Channel 见 NetDriver/Channel/Bunch;反射如何驱动 RepLayout 见 UObject/GC/反射;属性 vs RPC 选型见 RPC;哪些 Actor 会被复制(Relevancy)见 Relevancy 与带宽预算。
自测:合上资料能说清楚吗?
引擎默认怎么知道一个复制属性变了?代价是什么?
参考答案
默认每帧轮询:发包前遍历 RepLayout,把每个复制属性的当前值和 Shadow State(上次发送的快照)逐个比较,不同的才打包(handle + 新值)发出并更新影子。代价是 O(连接×Actor×属性) 的逐属性比较——哪怕没变也要比,属性/Actor 多时吃满 CPU。
Push Model 解决什么问题?代价是什么?
参考答案
解决默认轮询「没变的属性也要比一遍」的 CPU 浪费。UE5 引入,改成属性变化时手动 MARK_PROPERTY_DIRTY_FROM_NAME 标脏,引擎只发被标脏的属性,成本从 O(全部属性) 降到 O(变化属性)。代价是漏标脏 = 不复制(改了值忘标客户端永远收不到),需要封装 setter 保证纪律。
COND_OwnerOnly 有什么用?举个省带宽的例子。
参考答案
让属性只复制给拥有该 Actor 的客户端。例如玩家弹药明细用 COND_OwnerOnly,100 人房间里复制量从「每属性 × 100 连接」降到「× 1」,既省带宽又防止别的客户端读到你的私有数据。
RepNotify 有哪些坑?
参考答案
① 服务器改值不会自动触发 OnRep(只有客户端收到复制才触发),服务器要同样逻辑得手动调;② 默认值没变不触发,需要每次都触发得加 REPNOTIFY_Always;③ 多个属性的 OnRep 到达/触发顺序不保证,不能在一个 OnRep 里假设另一个属性已是新值。
大数组(如背包)怎么高效复制?
参考答案
用 FFastArraySerializer。普通 TArray 复制改一个元素会整个数组重发;FastArray 靠每元素的 ReplicationID/ReplicationKey 只发增删改的元素,大数组(背包、Buff 列表)带宽省一个数量级。