前言

之前文章写了关于网络同步客户端的使用处理UE 联机

本篇主要为对于更底层的思考与处理,本篇没有对于底层代码的处理。

RPC与属性同步

原文目录

一.关于Actor与其所属连接

  1. Actor的Role是ROLE_Authority就是服务端么?
    1. Owner是如何在RPC调用中生效的?

二.进一步理解RPC与同步

  1. RPC函数应该在哪个端执行?
  2. 客户端创建的Actor能调用RPC么?
  3. RPC与Actor同步谁先执行?
  4. 多播MultiCast RPC会发送给所有客户端么?
  5. RPC参数与返回值

三.合理使用COND_InitialOnly

四.客户端与服务器一致么?

五.属性同步的基本规则与注意事项

  1. 结构体的属性同步
  2. 属性回调
  3. UObject指针类型的属性同步

六.组件同步

核心要点

核心要点解析

  1. 连接所有权是网络同步的基础
    • 客户端首次连接到服务器时,服务器会同步一个特殊的PlayerController到客户端,这个Controller持有网络连接信息(NetDriver、Connection等)

• 只有拥有连接的Actor才能获得ROLE_AutonomousProxy权限,否则为ROLE_SimulatedProxy

• 连接所有权决定了RPC的目标客户端和属性同步的范围

  1. RPC执行机制的关键限制
    • RPC函数通过Actor的连接信息确定执行目标

• 多播RPC的实际发送范围受网络相关性(NetRelevant)影响,并非一定发送给所有客户端

• RPC参数有限制:仅支持UObject指针和const FString&,不支持返回值

• RPC执行时机与属性同步存在不确定性,可能影响逻辑顺序

  1. 属性同步的精细控制
    • 通过COND_条件宏控制属性同步条件,如COND_InitialOnly表示仅初始同步

• 属性同步由服务器主动触发,客户端通过OnRep回调响应

• 动态数组和结构体支持同步,但结构体内的属性不能单独标记Replicated

  1. 组件同步的两种模式
    • 静态组件:Actor构造函数中创建的组件随Actor一起”同步”(实际是客户端独立创建)

• 动态组件:运行时创建的组件需要显式调用SetIsReplicated(true)才能同步

• 组件同步支持属性复制和RPC,但某些组件类型(如SkeletalMesh)需要特殊处理

重要实践建议

网络代码的端区分:始终注意代码在客户端和服务器上的执行差异,避免因端不同步导致的逻辑错误。

同步时机管理:属性同步和RPC执行可能存在时序问题,特别是对新创建的Actor进行操作时,需要确保同步完成后再执行相关逻辑。

连接有效性验证:在客户端创建的Actor即使设置Owner为PlayerController,也不会获得有效连接,因为连接的核心意义在于服务器到客户端的同步通道。

这篇文章的价值在于将官方文档中抽象的网络概念转化为具体的实现逻辑,并指出了实际开发中容易遇到的陷阱,如RPC与属性同步的时序问题、客户端/服务器代码执行差异等,对深入理解UE4网络同步机制很有帮助。

枚举类

网络模式 ENetMode

  • NM_Standalone:标准的单机,没有服务器
  • NM_DedicatedServer:DS模式下的服务器
  • NM_ListenServer:LS模式,局域网联机下的服务器
  • NM_Client:在有服务器模式下的客户端

网络角色 ENetRole

  1. ROLE_None(不存在):不存在角色、即没有复制的Actor的远程网络角色为不存在,None
  2. ROLE_SimulatedProxy(模拟):客户端上的角色、即实际控制权不在本客户端上,OtherPlayer
  3. ROLE_AutonomousProxy(自治):客户端上的角色、且实际控制权在本客户端上,myself
  4. ROLE_Authority(权威):服务器上的角色一律是权威的,

通信

联网流程

UE4官网

在联网的过程中,每当客户端尝试连接服务器时,都有如下流程:

  1. 客户端发送连接请求
  2. 服务端本地通过 GameMode:PreLogin 验证是否要接受连接
  3. 接受连接时,服务器发送当前地图供客户端加载
  4. 客户端加载成功后,发送 Join 信息到服务器
  5. 服务器接受连接后,客户端创建一个真实的客户端对应的 PlayerController, 并将其复制到对应客户端
  6. 一切顺利的话,服务器调用到 GameMode:PostLogin,此时RPC调用才可以正常进行

在第五步创建具备真实意义的PlayerController时,这个PlayerController才能够正常的拥有网络连接,这是一个对应关系

网络相关性 Relevancy

UE支持的地图很大,足以让有可能一局游戏都没法遇见另一个玩家,这种情况下的话,自然很有可能完全不需要关心对方的更新。

相关性判断

大部分Actor都统一使用一套相关性判断,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// ActorReplication.cpp:322
bool AActor::IsNetRelevantFor(const AActor* RealViewer, const AActor* ViewTarget, const FVector& SrcLocation) const
{
// 如果这个Actor是被标记了始终相关的,或者它的拥有者是视图目标,或者真实查看者是它的拥有者,或者这个Actor本身就是视图目标,或者视图目标是这个Actor的发起者, 则直接相关
if (bAlwaysRelevant || IsOwnedBy(ViewTarget) || IsOwnedBy(RealViewer) || this == ViewTarget || ViewTarget == GetInstigator())
{

return true;
}
// 如果启用了拥有者相关性并且有拥有者,则调用拥有者的IsNetRelevantFor方法,判断拥有者是否相关
else if (bNetUseOwnerRelevancy && Owner)
{
return Owner->IsNetRelevantFor(RealViewer, ViewTarget, SrcLocation);
}
// 如果标记了仅拥有者相关,则不相关
else if (bOnlyRelevantToOwner)
{
return false;
}
// 如果有根组件,并且根组件的父组件存在,且父组件的拥有者存在,则调用父组件拥有者的IsNetRelevantFor方法,判断父组件拥有者是否相关
else if (RootComponent && RootComponent->GetAttachParent() && RootComponent->GetAttachParent()->GetOwner() &&
(Cast<USkeletalMeshComponent>(RootComponent->GetAttachParent()) || (RootComponent->GetAttachParent()->GetOwner() == Owner)))
{
return RootComponent->GetAttachParent()->GetOwner()->IsNetRelevantFor(RealViewer, ViewTarget, SrcLocation);
}
// 如果这个Actor是隐藏的,并且根组件不存在或根组件的碰撞未启用,则不相关
else if(IsHidden() && (!RootComponent || !RootComponent->IsCollisionEnabled()))
{
return false;
}

// 如果没有根组件,则不相关
if (!RootComponent)
{
return false;
}

// 返回是否启用了距离基础相关性,或者是否在网络相关性距离内
return !GetDefault<AGameNetworkManager>()->bUseDistanceBasedRelevancy ||
IsWithinNetRelevancyDistance(SrcLocation);
}

属性同步

  • 蓝图:蓝图通过RepNotify来执行属复制后的回调事件。
  • C++:C++通过ReplicateUsing宏来标注一个变量在同步后调用的函数。

使用思路

  1. 持久性数据都至少应该保留一个需要复制的对应数据和回调,这样能避免重连的情况下、RPC调用不会再次发生导致的数据丢失
  2. 不要调用其他对象上需要复制的数据,其他对象可能并未完成复制
  3. 只能关注一个接近最新的属性、它的变化过程很有可能丢失
  4. TArray在属性同步时很容易出现问题,在需要属性同步的应该一律使用TFastArray来代替

属性复制坑点

  1. 蓝图的回调会在客户端和服务器上调用、而C++的只会在客户端调用,如果需要在服务器做变更后逻辑,得在服务器赋值后手动调用

  2. C++的回调只会在调用后发现属性值不一致,需要覆盖时调用。如果客户端已经修改成相同值,则不会调用。

    但是这个可以通过修改宏DOREPLIFETIME_WITH_PARAMS_FAST和修改 RepNotifyConditionREPNOTIFY_Always 来保证每次服务器复制都会调用修改。

  3. 由于同步需要时间和同步顺序不确定、有可能同步时对象的属性还未能同步,所以最好不要使用其他对象上需要同步的数据

  4. TMapTSet都不支持网络同步,只有TArray能够进行网络同步。但是对于中间的Remove导致的数组频繁删除,会有性能问题

  5. TArray对于普通类型能够直接判断值变化,但是复杂类型如UObject 指针,TArray是基于内存变化来判断是否发生改变的,单纯的修改不能触发网络复制

属性同步的基本规则与注意事项

非休眠状态下的Actor的属性同步:只在服务器属性值发生改变的情况下执行
回调函数执行条件:服务器同步过来的数值与客户端不同
休眠的Actor:不同步

首先要认识到,同步操作触发是由服务器决定的,所以不管客户端是什么值,服务器觉得该同步就会把数据同步到客户端。而回调操作是客户端执行,所以客户端会判断与当前的值是否相同来决定是否产生回调。

然后是属性同步,属性同步的基本原理就是服务器在创建同步通道的时候给每一个Actor对象创建一个属性变化表(这里面涉及到FObjectReplicator,FRepLayout,FRepState,FRepChangedPropertyTracker相关的类,有兴趣可以进一步了解,在另一深入UE网络同步文章里有讲解),里面会记录一个当前默认的Actor属性值。之后,每次属性发生变化的时候,服务器都会判断新的值与当前属性变化表里面的值是否相同,如果不同就把数据同步到客户端并修改属性变化表里的数据。对于一个非休眠且保持连接的Actor,他的属性变化表是一直存在的,所以他的表现出来的同步规则也很简单,只要服务器变化就同步。

动态数组TArray在网络中是可以正常同步的,系统会检测到你的数组长度是否发生了变化,并通知客户端改变。

RPC

RPC坑点

  1. 在遇到“游戏状态恢复”的场景,比如网络游戏中的断线重连。然后你就可能会遇到一些对象在重连后状态不对,因为变化时使用的RPC是一次性的。

    当重连后,RPC不会再执行一次,所以客户端重连的状态与服务器其实是不同的。

    这时候需要使用属性同步来解决问题,但是属性回调在断线重连的时候也并不一定想执行,所以要重新审视一下回调函数里面的内容。

  2. 不要大量使用可靠RPC或者Tick中使用rpc,这会导致调用过多堵塞网络

  3. 在RPC调用时,如果传参带有某些依赖属性同步的变量,有可能会导致RPC执行时使用的变量是未复制的,可以通过 DelayUnmappedRPCs 来延迟RPC调用,但是还是最好避免这些戏法

  4. RPC时有可能直接被舍弃,例如一个很远的Actor上调用的RPC有可能不会在某个客户端上被调用,哪怕它是一个多播可靠RPC

  5. beginplay在客户端服务器都会执行,如果在beginplay执行另外一个actor的生成。可能会触发客户端和服务器都生成一遍自己的actor,结果客户端存在了两个Actor(一个自己生成的,一个服务器生产的)。

    之后在调用RPC的时候很可能会出现RPC执行失败,因为没有复制,本地生成的Actor没有任何connection信息。

  6. 不要把随时可能被destroyed的对象传进RPC的参数里面,RPC参数里面没有判断对象是否是合法的。如果传递的过程中对象被destroy掉,后续可能触发序列化找不到NETGUID的相关崩溃

RPC参数与返回值

参数:RPC函数除了UObject类型的指针以及constFString&的字符串外,其他类型的指针或者引用都不可以作为RPC的参数。对于UObject指针类型我们可以在另一端通过GUID识别(后面第五部分有讲解),但是其他类型的指针传过去是什么呢?我们根本就无法还原其地址,所以不允许传输其指针或者引用。

而对于FString,传const原因我认为是为了不想让发送方与接收方两边对字符串进行修改,而传引用只是为了减少复制构造带来的开销。在FString发送与接收的处理细节里面并不在意其是否是const&,他只在意他的类型以及相对Object的偏移。

返回值:一个RPC函数是不能有返回值的,因为其本身的执行就是一次消息的传递。假如一个客户端执行一个Server RPC,如果有返回值的话,那么岂不是服务器执行后还要再发送一个消息给客户端?这个消息怎么处理?再发一次RPC?如果还有返回值那么不就无限循环了?因此RPC函数不可以添加返回值。

客户端与服务器一致么?

我们已经知道UE4的客户端与服务器公用一套代码,那么我们在每次写代码的时候就有必要提醒一下自己。这段代码在哪个端执行,客户端与服务器执行与表现是否一致?

虽然,我很早之前就知道这个问题,但是写代码的时候还是总是忽略这个问题,而且程序功能经常看起来运行的没什么问题。不过看起来正常不代表逻辑正常,有的时候同步机制帮你同步一些东西,有时候会删除一些东西,有时候又会生成一些东西,然而你可能一点都没发现。

举个例子,我在一个ActorBeginPlay的时候给他创建一个粒子Emiter。代码大概如下:

1
2
3
4
5
6
void AGate::BeginPlay()
{
Super::BeginPlay();
//单纯的在当前位置创建粒子发射器
GetWorld()->SpawnActor<AEmitter>(SpawnEmitter,GetActorLocation(), GetActorRotation());
}

代码很简单,不过也值得我们分析一下。

首先,服务器下,当Actor创建的时候就会执行BeginPlay,然后在服务器创建了一个粒子发射器。这一步在服务器(DedicateServer)创建的粒子其实就是不需要的,所以一般来说,这种纯客户端表现的内容我们不需要在专用服务器上创建。

再来看一下客户端,当创建一个Gate的时候,服务器会同步到客户端一个Gate,然后客户端的Gate执行BeginPlay,创建粒子。这时候我们已经发现二者执行BeginPlay的时机不一样了。进一步测试,发现当玩家远离Gate的时候,由于UE的同步机制(只会同步一定范围内的Actor),客户端的Gate会被销毁,而粒子发射器也会销毁。而当玩家再次靠近的时候,Gate又被同步过来了,原来的粒子发射器也被同步过来。而因为客户端再次执行了BeginPlay,又创建了一个新的粒子,这样就会导致不断的创建新的粒子。

属性回调与RPC

问题:属性回调与RPC在使用结果上的差异?

属性回调理论上一定会执行,而RPC函数有可能由于错过执行时机而不再会执行。

例如:我在服务器上面有一个宝箱,第一个玩家过去后,宝箱会自动开启。如果使用RPC函数,当第一个玩家过去后,箱子执行多播RPC函数触发开箱子操作。但是由于其他的玩家离这个箱子很远,所有这个箱子没有同步给其他玩家,其他玩家收不到这个RPC消息。

当这些玩家之后再过去之后,会发现箱子还是关闭的。如果采用属性回调,但第一个玩家过去后,设置箱子的属性bOpen为true,然后同步到所有客户端,通过属性回调执行开箱子操作。

这时候其他玩家靠近箱子时,箱子会同步到靠近的玩家,然后玩家在客户端上会收到属性bOpen,同时执行属性回调,这时候可以实现所有靠近的玩家都会发现箱子已经被别人开过了。

根据文章中关于RPC的机制,并结合虚幻引擎的通用实践,以下是一个在游戏场景中使用RPC函数的完整示例。

场景描述

假设我们有一个“宝箱”Actor(ABP_Chest)。玩家控制的角色走到宝箱面前,按下交互键(如E键)可以打开它。这个过程涉及网络交互:

  1. 客户端:检测到玩家按下E键,请求服务器打开宝箱。
  2. 服务器:验证请求(例如,宝箱是否已上锁、玩家是否有钥匙),执行核心逻辑(如修改宝箱状态、给予玩家奖励),然后通知所有客户端播放开启动画。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
// 文件: BP_Chest.h
#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "BP_Chest.generated.h"

UCLASS()
class MYPROJECT_API ABP_Chest : public AActor
{
GENERATED_BODY()

public:
ABP_Chest();

// 一个用于客户端调用,在服务器上执行的RPC(必须验证!)
UFUNCTION(Server, Reliable, WithValidation) // Server RPC
void Server_RequestOpenChest(APlayerController* RequestingPlayer);

// 一个在服务器上调用,在所有客户端上执行的RPC
UFUNCTION(NetMulticast, Reliable) // NetMulticast RPC
void Multicast_PlayOpenEffect();

// 一个在服务器上调用,只在宝箱所有者(持有连接的)客户端上执行的RPC
UFUNCTION(Client, Reliable) // Client RPC
void Client_ShowSpecialRewardMessage(const FString& RewardText);

protected:
virtual void BeginPlay() override;
virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;

// 同步的属性:宝箱是否已打开
UPROPERTY(ReplicatedUsing = OnRep_bIsOpened, BlueprintReadOnly, Category = "Chest")
bool bIsOpened;

// 当bIsOpened属性同步到客户端时调用的回调函数
UFUNCTION()
void OnRep_bIsOpened();

private:
// Server RPC 的验证函数
bool Server_RequestOpenChest_Validate(APlayerController* RequestingPlayer);
void Server_RequestOpenChest_Implementation(APlayerController* RequestingPlayer);

// 本地函数:客户端尝试交互
void Local_AttemptToOpen(APlayerController* RequestingPlayerController);
};
// 文件: BP_Chest.cpp
#include "BP_Chest.h"
#include "Net/UnrealNetwork.h"
#include "Engine/World.h"

ABP_Chest::ABP_Chest()
{
// 设置为可复制,这是Actor能进行网络同步的前提
bReplicates = true;
bIsOpened = false;
}

void ABP_Chest::BeginPlay()
{
Super::BeginPlay();
}

void ABP_Chest::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
// 将bIsOpened属性注册为可复制
DOREPLIFETIME(ABP_Chest, bIsOpened);
}

// 客户端交互入口(例如,由玩家角色的交互逻辑调用)
void ABP_Chest::Local_AttemptToOpen(APlayerController* RequestingPlayerController)
{
if (!RequestingPlayerController)
{
return;
}

// 关键步骤1:在客户端进行预测性验证和反馈(如播放按键音效)
if (bIsOpened)
{
// 客户端本地提示“宝箱已打开”
return;
}

// 关键步骤2:调用Server RPC,将请求发送给服务器
Server_RequestOpenChest(RequestingPlayerController);
}

// --- Server RPC 实现 ---
bool ABP_Chest::Server_RequestOpenChest_Validate(APlayerController* RequestingPlayer)
{
// 验证逻辑:防止作弊。例如:
// 1. 请求的玩家控制器是否有效?
// 2. 玩家距离宝箱是否过远?
if (!RequestingPlayer || !RequestingPlayer->GetPawn())
{
return false;
}
float Distance = FVector::Dist(GetActorLocation(), RequestingPlayer->GetPawn()->GetActorLocation());
return Distance < 300.0f; // 假设交互距离为300单位
}

void ABP_Chest::Server_RequestOpenChest_Implementation(APlayerController* RequestingPlayer)
{
// 关键步骤3:服务器权威验证和逻辑执行
if (bIsOpened) // 再次检查,防止重复打开
{
return;
}

// 执行开箱核心逻辑
bIsOpened = true;
// 给予玩家奖励(这段逻辑只在服务器执行)
// GrantRewardToPlayer(RequestingPlayer);

// 关键步骤4:服务器通知所有客户端
// 通知所有客户端播放开启动画和音效
Multicast_PlayOpenEffect();

// 可以只对交互的玩家显示特殊提示
FString SpecialMessage = FString::Printf(TEXT("你找到了稀有宝藏!"));
Client_ShowSpecialRewardMessage(SpecialMessage);
}

// --- NetMulticast RPC 实现 ---
void ABP_Chest::Multicast_PlayOpenEffect_Implementation()
{
// 这个函数会在服务器和所有已同步此宝箱的客户端上执行
// 注意:根据文章提到的“网络相关性”,离得太远没同步此Actor的客户端不会执行。

// 在这里播放宝箱打开的视觉特效(VFX)、声音特效(SFX)
if (OpenParticleSystem)
{
UGameplayStatics::SpawnEmitterAtLocation(GetWorld(), OpenParticleSystem, GetActorLocation());
}
// 触发蓝图事件,方便设计师调整
OnChestOpened_BP();
}

// --- Client RPC 实现 ---
void ABP_Chest::Client_ShowSpecialRewardMessage_Implementation(const FString& RewardText)
{
// 这个函数只会在拥有这个Actor网络连接的客户端上执行。
// 对于这个宝箱,就是那个请求打开的玩家所在的客户端。

// 在本地客户端的屏幕上显示奖励信息
if (APlayerController* PC = GetWorld()->GetFirstPlayerController())
{
// 假设有一个显示提示的蓝图函数
PC->ClientMessage(RewardText);
}
}

// --- 属性复制回调 ---
void ABP_Chest::OnRep_bIsOpened()
{
// 当bIsOpened从服务器同步到客户端时,此函数在该客户端上调用
// 这里是处理状态驱动变化的安全地方,例如更新材质、碰撞体等
if (bIsOpened)
{
// 例如,将宝箱模型切换为打开状态
if (ChestMeshComponent)
{
ChestMeshComponent->SetMaterial(0, OpenedMaterial);
}
// 禁用碰撞,防止再次交互
SetActorEnableCollision(false);
}
}

关键要点总结(结合文章内容)

  1. RPC类型与调用方向:
    ◦ Server:从客户端调用,在服务器上执行。用于请求权威操作。

    ◦ Client:从服务器调用,在拥有该Actor连接的客户端上执行。用于针对特定玩家的效果。

    ◦ NetMulticast:从服务器调用,在服务器和所有相关客户端上执行。用于广播全局效果。

  2. 连接所有权是基础:文章强调,Client RPC能正确送达,依赖于Actor(本例中的宝箱)必须被一个PlayerController“拥有”或与一个连接关联。对于场景中的可交互物件,通常其连接所有者是首先与其交互的玩家。

  3. 执行顺序与可靠性:
    ◦ 在 Server_ 函数中,先修改同步状态(bIsOpened),再调用 Multicast_ 和 Client RPC。

    ◦ 使用 Reliable 确保关键指令不丢失,但需谨慎用于高频操作。

  4. 属性同步 (Replicated) 与 RPC 的协同:
    ◦ bIsOpened 使用属性同步,确保任何后来才进入区域的玩家也能看到宝箱的正确状态(通过 OnRep_bIsOpened 回调)。

    ◦ RPC(如 Multicast_PlayOpenEffect)用于触发“事件性”的、一次性的表现(动画、音效)。文章指出,对于状态同步,属性回调比RPC更可靠,能保证后续进入的客户端也能获得正确状态。

  5. 验证函数 (WithValidation):Server RPC 必须包含验证函数(_Validate),以防止客户端发送恶意数据。这是服务器权威性的关键保障。

  6. 网络相关性:文章特别指出,NetMulticast RPC 的接收者范围受“网络相关性”影响。如果一个客户端因为距离过远等原因,根本没有在它的世界里同步(生成)这个宝箱Actor,那么它不会执行这个多播函数。这是与“发给所有连接”的常见误解不同的地方。

文档未详述但需注意的实践:在实际项目中,类似于“开宝箱”的交互,通常会在客户端先播放一个本地预测性的动画(如手柄震动、按键提示),然后等待服务器RPC回调再播放正式的打开动画,以提升操作响应感。这被称为“客户端预测”。

RPC:处理瞬时事件。动画、音效这类有持续时间、但结束后不需持续保存的状态,用RPC触发非常合适。它确保了事件在发生时刻对所有相关方是同步的。

属性回调:状态持久。即使有玩家在宝箱打开后才进入游戏或走近宝箱,由于 bIsOpened属性是同步的,该玩家一看到宝箱,客户端就会根据同步来的 true值,通过 OnRep回调直接将其显示为“已打开”状态,无需额外逻辑。

Actor同步

基本通信类

原文是这么介绍的

NetDriver

  • NetDriver
    网络驱动,实际上我们创建使用的是他的子类IPNetDriver,里面封装了基本的同步Actor的操作,初始化客户端与服务器的连接,建立属性记录表,处理RPC函数,创建Socket,构建并管理当前Connection信息,接收数据包等等基本操作。NetDriver与World一一对应,在一个游戏世界里面只存在一个NetDriver。UE里面默认的都是基于UDPSocket进行通信的。
  • Connection
    表示一个网络连接。服务器上,一个客户端到一个服务器的一个连接叫一个ClientConnection。在客户端上,一个服务器到一个客户端的连接叫一个ServerConnection。
  • LocalPlayer
    本地玩家,一个客户端的窗口ViewportClient对应一个LocalPlayer,Localplayer在各个地图切换时不会改变。
  • Channel
    数据通道,每一个通道只负责交换某一个特定类型特定实例的数据信息。ControlChannel:客户端服务器之间发送控制信息,主要是发送接收连接与断开的相关消息。在一个Connection中只会在初始化连接的时候创建一个该通道实例。
    VoiceChannel:用于发送接收语音消息。在一个Connection中只会在初始化连接的时候创建一个该通道实例。
    ActorChannel:处理Actor本身相关信息的同步,包括自身的同步以及子组件,属性的同步,RPC调用等。每个Connection连接里的每个同步的Actor都对应着一个ActorChannel实例。
    常见的只有这3种:枚举里面还有FileChannel等类型,不过没有使用。
  • PlayerController
    玩家控制器,对应一个LocalPlayer,代替本地玩家控制游戏角色。同时对应一个Connection,记录了当前的连接信息,这和RPC以及条件属性复制都是密切相关的。另外,PlayerController记录他本身的ViewTarget(就是他控制额Character),通过与ViewTarget的距离(太远的Actor不会同步)来进行其他Actor的同步处理。
  • World
    游戏世界,任何游戏逻辑都是在World里面处理的,Actor的同步也受World控制,World知道哪些Actor应该同步,保存了网络通信的基础设施NetDriver。
  • Actor
    在世界存在的对象,没有坐标。UE4大部分的同步功能都是围绕Actor来实现的。
  • Dormant
    休眠,对于休眠的Actor不会进行网络同步

底层通信类

  • Packet 从Socket读出来/输出的数据,一个Packet里面可能有多个Bunch数据或者Ack数据
  • Bunch 一个Bunch里面主要记录了Channel信息,NGUID。同时包含其他的附属信息如是否是完整的Bunch,是否是可靠等,可以简单理解为一个从逻辑上层分发下来的同步数据包,该数据包的数据可能不完整,Bunch分为属性Bunch以及RPCBunch。继承自FNetBitWriter InBunch:从Channel接收的数据流串 ,UNetConnection::ReceivedPacket的时候创建 OutBunch:从Channel产生的数据流串,UActorChannel::ReplicateActor()的时候创建
  • Ack Ack是与Bunch同级别概念的网络数据串,用于实现UDP的可靠数据传输
  • FBitWriter 字节流书写器,可以临时写入比特数据用于传输,存储等,继承自FArchive
  • FSocket 所有平台Socket的基类。 FSocketBSD:使用winSocket的Socket封装
  • UPackageMap 生成与维护Object与NGUID的映射,负责Object的序列化。每一个Connection对应一个UPackageMap
  • PacketHandler :网络包预处理,比如加密,前向纠错,握手等。里面有一个或多个HandlerComponents来执行特殊的数据处理。目前内置的包括加密组件RSA,AES,以及必备的握手组件StatelessConnectHandlerComponent

(Packet与Bunch的区别:Bunch是Packet子集,Packet里面可能不包含Bunch信息,只包含Ack数据)

属性同步相关:

  • FObjectReplicator
    属性同步的执行器,每个Actorchannel对应一个FObjectReplicator,每一个FObjectReplicator对应一个对象实例。设置ActorChannel通道的时候会创建出来。
  • FRepState
    针对每个连接同步的历史数据,记录同步前用于比较的Object对象信息,存在于FObjectReplicator里面。
  • FRepLayOut
    同步的属性布局表,记录所有当前类需要同步的属性,每个类或者RPC函数有一个。
  • FRepChangedPropertyTracker
    属性变化轨迹记录,一般在同步Actor前创建,Actor销毁的时候删掉。
  • FReplicationChangelistMgr
    存放当前的Object对象,保存属性的变化历史记录

架构特点:

  • 客户端服务器共用一套代码
  • 服务器为游戏逻辑服务器,单个服务器为核心,多个客户端连接
  • 默认通信协议为UDP(应用层实现数据可靠的UDP)
  • 收发UDP数据包都在主线程(GameThread)执行

Actor的同步

Actor的同步可以说是UE4网络里面最大的一个模块了,里面包括属性同步,RPC调用等,这里为了方便我将他们拆成了3个部分来分别叙述。
有了前面的描述,我们已经知道NetDiver负责整个网络的驱动,而ActorChannel就是专门用于Actor同步的通信通道。
这里对Actor同步做一个比较细致的描述:服务器在NetDiver的TickFlush里面,每一帧都会去执行ServerReplicateActors来同步Actor的相关内容。在这里我们需要做以下处理:

  1. 获取到所有连接到服务器的ClientConnections,首先获取引擎每帧可以同步的最大Connection的数量,超过这个限制的忽略。然后对每个Connection几乎都要进行下面所有的操作
  2. 找到要同步的Actor,只有被放到World.NetworkActors里面的Actor才会被考虑,Actor在被Spawn时候就会添加到这个NetworkActors列表里面(新的版本里面已经把需要同的ACtor放到了NetDriver的NetworkObjects列表里面了)
  3. 找到客户端玩家控制的角色ViewTarget(ViewTaget与摄像机绑定在一起),这个角色的位置是决定其他Actor是否同步的关键
  4. 验证Actor,对于要销毁的以及所有权Role为ROLE_NONE的Actor不会同步
  5. 是否到达Actor同步时间,Actor的同步是有一定频率的,Actor身上有一个NetUpdateTime,每次同步前都会通过下面这个公式来计算下一次Actor的同步时间,如果没有到达这个时间就会放弃本次同步Actor->NetUpdateTime = World->TimeSeconds + FMath::SRand() * ServerTickTime + 1.f/Actor->NetUpdateFrequency;
  6. 如果这个Actor设置OnlyRelevantToOwner,那么就会放到一个特殊的列表里面OwnedConsiderList然后只同步给属于他的客户端。否则会把Actor放到ConsiderList里面
  7. 对于休眠状态的Actor不会进行同步,对于要进入休眠状态的Actor也要特殊处理关闭同步通道
  8. 查看当前的Actor是否有通道Channel,如果没有,还要看看Actor是否已经加在了场景,没有加载就跳过同步
  9. 接第8个条件——没有Channel的情况下,还会执行Actor::IsNetRelevantFor判断是否网络相关,对于不可见的或者太远的Actor会返回false,不会同步
  10. Actor的同步数量可能非常大,所以有必要对所有的Actor进行一个优先级的排列
    处理完上面的逻辑后会对优先级表里的所有Actor进行排序
  11. 排序后,如果连接没有加载此 actor 所在的关卡,则关闭通道(如果存在)并继续
    每 1 秒钟调用一次 AActor::IsNetRelevantFor,确定 actor 是否与连接相关,如果不相关的时间达到 5 秒钟,则关闭通道
    如果要同步的Actor没有ActorChannel就给其创建一个并绑定Actor,执行同步并更新NetUpdateTime = Actor->GetWorld()->TimeSeconds + 0.2f * FMath::FRand();
    如果此连接出现饱和剩下的 actor会根据连接相关时间判断是否在下一个时钟更新
  12. 执行UActorChannel::ReplicateActor执行真正的Actor同步以及内部数据的同步,这里会将Actor(PackageMap->SerializeNewActor),Actor子对象以及其属性序列化(ReplicateProperties)封装到OutBunch并发送给客户端
    (备注:我们当前版本下面的逻辑都是写在UNetDriver::ServerReplicateActors里面,4.12以后的UE4已经分别把Connection预处理,获取同步Actor列表,优先级处理等逻辑封装到单独的函数里了,详见ServerReplicateActors_BuildConsiderlist, ServerReplicateActors_PrioritizedActors, ServerReplicateActors_ProsessPrioritizedActors等函数
    优先级排序规则是什么?答案是按照是否有controller,距离以及是否在视野。通过FActorPriority构造代码可以定位到APawn::GetNetPriority,这里面会计算出当前Actor对应的优先级,优先级越高同步越靠前,是否有Controller的权重最大)
    总之,大体上Actor同步的逻辑就是在TickFlush里面去执行ServerReplicateActors,然后进行前面说的那些处理。最后对每个Actor执行ActorChannel::ReplicateActor将Actor本身的信息,子对象的信息,属性信息封装到Bunch并进一步封装到发送缓存中,最后通过Socket发送出去。

同步方法

Actor、Component、Uobject同步方法

根据您提供的链接文章内容,以下是其中总结的 Actor、Component 和 UObject 的同步方法的核心要点:

  1. Actor 的同步

Actor 是网络同步的基本单位,其同步机制最为核心。

• 连接与权限 (Role):

​ ◦ 一个 Actor 是否“拥有连接”决定了其网络控制权限 (Role)。

​ ◦ ROLE_AutonomousProxy:表示该 Actor 被一个特定的客户端“拥有”和控制(通常指玩家控制的 Pawn)。它能调用指向其所属客户端的 RPC。

​ ◦ ROLE_SimulatedProxy:表示该 Actor 在客户端上只是服务器状态的模拟和复制品(如 NPC、其他玩家),没有专属的网络连接。

• 同步方式:

1. 属性同步 (Replicated Properties):
    ▪ 在 Actor 的 GetLifetimeReplicatedProps 函数中,使用 DOREPLIFETIME 宏声明需要同步的变量。
    
    ▪ 可以添加条件宏(如 COND_InitialOnly)来控制同步时机。
    
    ▪ 当属性在服务器上发生变化时,会自动同步到相关客户端,并触发客户端的 OnRep 回调函数。
    
    2. RPC (远程过程调用):
       ▪ 在函数前使用 UFUNCTION 宏声明为 Client、Server 或 NetMulticast。

    ▪ 用于执行特定的、非状态同步的逻辑。其执行依赖于 Actor 所拥有的网络连接来确定目标。

• 重要原则:

​ ◦ Actor 必须由服务器生成并设置 bReplicates = true,才能被正常同步到客户端。

​ ◦ 在客户端创建的 Actor 无法获得有效的网络连接,其 Role 为 ROLE_Authority,RemoteRole 为 ROLE_None,仅存在于本地。

  1. Component 的同步

组件的同步依附于其所属的 Actor,分为静态和动态两种情况。

• 静态组件:

​ ◦ 在 Actor 的构造函数(C++)或组件面板(蓝图)中创建的默认组件。

​ ◦ 当 Actor 被同步到客户端时,这些组件会在客户端被重新构造出来,而非通过网络同步状态。这个过程与组件是否设置 Replicates 属性无关。

• 动态组件:

​ ◦ 在游戏运行时(如 BeginPlay 中)通过代码创建或附加的组件。

​ ◦ 必须显式调用 AActorComponent::SetIsReplicated(true),该组件及其属性才能被网络同步。

​ ◦ 同步后,动态组件支持独立的属性同步和 RPC,需要在组件的 GetLifetimeReplicatedProps 中声明需同步的属性。

• 注意事项:

​ ◦ 某些复杂组件(如带有 Skeletal Mesh 的组件)的完整状态可能无法直接同步,通常需要同步一个资源路径(如 TSoftObjectPtr),在客户端的 OnRep 回调中再异步加载。

  1. UObject 的同步(作为引用/指针传递)

UObject 本身不能像 Actor 一样作为独立的网络实体进行同步。它的“同步”主要体现在作为属性或 RPC 参数进行传递。

• 传递机制:

​ ◦ 当在网络上传递一个 UObject* 指针时,引擎传递的是一个 FNetworkGUID。

​ ◦ 客户端根据这个 GUID 在本地查找对应的对象实例。这意味着,只有在客户端也能找到的对应对象,指针传递才有意义。

• 可传递的条件(基于文章和引擎原理):

1. 标记为可复制的 Actor:最常见的情况。
    2. 静态关卡对象:从关卡数据中加载的对象(如地图中放置的静态物体),服务器和客户端拥有相同的实例。
    3. 通过 ReplicatedSubobject 系统注册的对象。
    4. 其他在两端通过确定方式能匹配上的对象。

• 重要限制:

​ ◦ 结构体(UStruct)内的属性不能单独标记 Replicated,其内部所有属性会作为一个整体被同步。若不想同步其中某个属性,需对该属性标记 NotReplicated。

总结对比

对象类型 同步方式
Actor 属性复制 (Replicated 属性 + OnRep)
RPC 调用
(注:由服务器生成,bReplicates=true
连接所有权决定 RPC 目标)
Component 随所属 Actor 静态创建
动态组件需 SetIsReplicated(true) 并独立声明复制属性 依附于一个可同步的 Actor
UObject (指针) 作为属性或 RPC 参数传递,实际传递 FNetworkGUID 目标对象必须在客户端存在且能被 GUID 查找到

文档未详述但需注意的点:在实际开发中,除了上述机制,还需特别注意网络同步的时序问题(如 Actor 创建、属性同步、RPC 执行的先后顺序)和网络相关性(NetRelevant),它们会直接影响同步的有效范围和时机。

参考

Exploring in UE4关于网络同步的理解与思考概念理解 - 知乎

UE网络与同步的个人笔记 | 漫宿

UE多人联机射击游戏中的延迟补偿技术的代码实现 - 知乎

Exploring in UE4网络同步原理深入(上)原理分析 - 知乎

Exploring in UE4网络同步原理深入(下) 原理分析 - 知乎