笃行
首页
个人 & 心法
互联网/硬件后台
游戏基础架构
UE 引擎
游戏业务
AI / 大模型
数据结构与算法
机器学习数学
通用基础
GitHub
首页
个人 & 心法
互联网/硬件后台
游戏基础架构
UE 引擎
游戏业务
AI / 大模型
数据结构与算法
机器学习数学
通用基础
GitHub
  • 引擎骨架

    • UE 引擎(客户端—服务器交互)
    • 引擎架构与一帧的时序
    • UObject / GC / 反射
    • Gameplay Framework 与端存在性矩阵
  • 复制栈底层

    • NetDriver / Channel / Bunch
    • 属性复制
    • RPC
  • 带宽与规模

    • Relevancy 与带宽预算
    • UE5 Iris 复制系统
  • 局内同步

    • 移动预测与回滚
    • 延迟补偿
    • 网络物理同步
  • 系统与运维

    • GAS 网络模型
    • 专用服务器 · Session · Travel
    • 服务器权威与反作弊

属性复制(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 RelevancyCOND_* 定「这个属性发给谁」;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 列表)带宽省一个数量级。

最近更新: 2026/9/10 11:38
Prev
NetDriver / Channel / Bunch
Next
RPC