(转载)UE4网络同步(二)——深入同步细节

前言

UE同步是一块比较复杂而庞大的模块,里面设计到了很多设计思想,技巧,技术。我这里主要是从同步的流程分析,以同步的机制为讲解核心,给大家描述里面是怎么同步的,会大量涉及UE同步模块的底层代码,稍微涉及一点计算机网络底层(Socket相关)相关的知识。 
PS:如果只是想知道怎么使用同步,不建议阅读这篇文章,不过可以参考我另外一篇博客 UE4网络同步(一)——理解同步规则 
另外,博主参考的源码版本比较旧,有些细节略有差异,大家可以作为参考。有时间我会对此进行更新

目录

一.基本概念

二.通信的基本流程

三.连接的建立 
- 1. 服务器网络模块初始化流程 
- 2. 客户端网络模块初始化流程 
- 3. 服务器与客户端建立连接流程

四.Actor的同步细节

五.属性同步细节 
- 1. 属性同步概述 
- 2. 重要数据的初始化流程 
- 3. 发送同步数据流程分析 
- 4. 属性回调函数执行 
- 5. 关于动态数组与结构体的同步

六.RPC执行细节

七.其他网络特性 
- 1. Reliable与可靠数据传输


一. 基本概念

UE网络是一个相当复杂的模块,这篇文档主要是针对Actor同步,属性同步,RPC等大致的阐述一些流程以及关键的一些类。这里我尽可能将我的理解写下来。 
在UE里面有一些和同步相关的概念与类,这里逐个列举一下并做解释:

底层通信:

  • Bunch 
    一个Bunch里面主要记录了Channel信息,NGUID。同时包含其他的附属信息如是否是完整的Bunch,是否是可靠的等等,可以简单理解为一个数据包,该数据包的数据可能不完整,继承自FNetBitWriter 
    InBunch:从Channel接收的数据流串 
    OutBunch:从Channel产生的数据流串
  • FBitWriter 
    字节流书写器,可以临时写入比特数据用于传输,存储等,继承自FArchive
  • FSocket 
    所有平台Socket的基类。 
    FSocketBSD:使用winSocket的Socket封装
  • Packet 
    从Socket读出来/输出的数据
  • UPackageMap 
    生成与维护Object与NGUID的映射,负责Object的序列化。每一个Connection对应一个UPackageMap 
    (Packet与Bunch的区别:Packet里面可能不包含Bunch信息)

基本网络通信:

  • 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不会进行网络同步

属性同步相关:

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

二. 通信的基本流程

如果我们接触过网络通信,应该了解只要知道对方的IP地址以及端口号,服务器A上进程M_1_Server可以通过套接字向客户端B上的进程M_1_Client发送消息,大致的效果如下: 
(转载)UE4网络同步(二)——深入同步细节_第1张图片

  • 图2-1 远程进程通信图

而对于UE4进程内部服务器Server与客户端Client1的通信,与上面的模型基本相似: 
(转载)UE4网络同步(二)——深入同步细节_第2张图片

  • 图2-2 UE4远程进程通信图

那这个里面的Channel是什么意思呢?简单理解起来就是一个通信轨道。为了实现规范与通信效率,我们的一个服务器针对某个对象定义了Channel通道,这个通道只与客户端对应的Channel通道互相发送与接收消息。这个过程抽象起来与TCP/UDP套接字的传输过程很像,套接字是在消息发送到进程前就进行处理,来控制客户端进程A只会接收到服务器对应进程A的消息,而这里是在UnrealEditor.exe进程里面处理,让通道1只接收到另一端通道1发送的消息。 
上面的只是针对一个服务器到客户端的传输流程,那么如果是多个客户端呢? 
(转载)UE4网络同步(二)——深入同步细节_第3张图片

  • 图2-3 Channel通信图

每一个客户端叫做一个Connection,如图,就是一个server连接到两个客户端的效果。对于每一个客户端,都会建立起一个Connection。在服务器上这个Connection叫做ClientConnection,对于客户端这个Connection叫做ServerConnection。每一个Channel都会归属于一个Connection,这样这个Channel才知道他对应的是哪个客户端上的对象。 
接下来我们继续细化,图中的Channel只标记了1,2,3,那么实际上都有哪些Channel?这些Channel对应的都是什么对象?其实,在第一部分的概念里我已经列举了常见的3中Channel,分别是ControlChannel,ActorChannel,以及VoiceChannel。一般来说,ControlChannel与VoiceChannel在游戏中只存在一个,而ActorChannel则对应每一个需要同步的Actor,所以我们再次细化上面的示意图: 
(转载)UE4网络同步(二)——深入同步细节_第4张图片

  • 图2-4 Connection下的Channel通信图

到这里我们基本上就了解了UE4的基本通信架构了,下面我们进一步分析网络传输数据的流程。首先我们要知道,UE4的数据通信是建立在UDP-Socket的基础上的,与其他的通信程序一样,我们需要对Socket的信息进行封装发送以及接收解析。这里面主要涉及到Bunch,RawBunch,Packet等概念,建议参考第一部分的基本概念去理解,很多注释已经加在了流程图里面。如图所示: 
(转载)UE4网络同步(二)——深入同步细节_第5张图片

  • 图2-5 发送同步信息流程图

(转载)UE4网络同步(二)——深入同步细节_第6张图片

  • 图2-6 接收同步信息流程图


三. 连接的建立

前面的内容已经提到过,UE的网通通信是基于Channel的,而ControlChannel就是负责 
控制客户端与服务器建立连接的通道,所以客户端与服务器的连接信息都是通过UControlChannel执行NotifyControlMessage函数处理的。下面首先从服务器与客户端的网络模块初始化说起,然后描述二者连接建立的详细流程:


1.服务器网络模块初始化流程

从创建GameInstance开始,首先创建NetDriver来驱动网络初始化,进而根据平台创建对应的Socket,之后在World里面监听客户端的消息。 
(转载)UE4网络同步(二)——深入同步细节_第7张图片

  • 图3-1 服务器网络模块初始化流程图


2.客户端网络模块初始化流程

客户端前面的初始化流程与服务器很相似,也是首先构建NetDriver,然后根据平台创建对应的Socket,同时他还会创建一个到服务器的ServerConnection。由于客户端没有World信息,所以要使用一个新的类来检测并处理连接信息,这个类就是UpendingNetGame。 
(转载)UE4网络同步(二)——深入同步细节_第8张图片

  • 图3-2 客户端网络模块初始化流程图

3.服务器与客户端建立连接流程

二者都完成初始化后,客户端就会开始发送一个Hello类型的ControlChannel消息给服务器(上面客户端初始化最后一步)。服务器接收到消息之后开始处理,然后会根据条件再给客户端发送对应的消息,如此来回处理几个回合,完成连接的建立,详细流程参考下图: 
(该流程是本地局域网的连接流程,与在线查找服务器列表并加入有差异) 
(转载)UE4网络同步(二)——深入同步细节_第9张图片

  • 图3-3 客户端服务器连接建立流程图

四. 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列表里面
  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的发送Bunch堆栈:(与UE默认的有些不同) 
(转载)UE4网络同步(二)——深入同步细节_第10张图片

  • 图4-1 服务器同步Actor堆栈图

下面描述客户端是如何接收到服务器同步过来的Actor的。首先客户端TickDispatch检测服务器的消息,收到消息后通过Connection以及Channel进行解析(第二部分已经讲解),最后一步解析出完整数据的操作在UActorChannel::ProcessBunch执行,在这个函数里面:

  1. 如果发现当前的ActorChannel对应的Actor为NULL,就对当前的Bunch进行反序列化Connection->PackageMap->SerializeNewActor(Bunch, this, NewChannelActor);解析出Actor的内容并执行PostInitializeComponents。如果Actor不为NULL,跳过这一步(参考下面图一堆栈)
  2. 随后根据Bunch信息找到同步过来的属性值并对当前Actor对应的属性进行赋值
  3. 最后执行PostNetInit调用Actor的BeginPlay。(参考下面图二堆栈)

下面截取了客户端接收到同步Actor并初始化的调用堆栈: 
(转载)UE4网络同步(二)——深入同步细节_第11张图片

  • 图4-2 客户端接收并序列化同步的Actor堆栈图

(转载)UE4网络同步(二)——深入同步细节_第12张图片

  • 图4-3 客户端初始化同步过来Actor堆栈图

从上面的描述来看,基本上我们可以很容易的分析出当前的Actor是否被同步,比如在UActorChannel::ReceivedBunch里面打个断点,看看当前通道里有没有你要的Actor就可以了。 
至于里面更详细的内容,就建议大家去代码里面调试吧。 


五. 属性同步细节

1.属性同步概述

属性同步是一个很复杂的模块,我在另一个关于UE4网络的思考文档里面讲解了属性同步相关的使用逻辑以及注意事项。这里我尽可能的分析一下属性同步的实现原理。 
有一点需要先提前说明一下,服务器同步的核心操作就是比较当前的同步属性是否发生变化,如果发生就将这个数据通过到客户端。如果是普通逻辑处理,我们完全可以保存当前对象的一个拷贝对象,然后每帧去比较这个拷贝与真实的对象是否发生变化。不过,由于同步数据量巨大,我们不可能给每个需要同步的对象都创建一个新的拷贝,而且这个逻辑如果暴露到逻辑层的话会使代码异常复杂难懂,所以这个操作要统一在底层处理。那么,UE4的基本思路就是获取当前同步对象的空间大小,然后保存到一个buffer里面,然后根据属性的OffSet给每个需要同步的属性初始化。这样,就保存了一份简单的“拷贝”用于之后的比较。当然,我们能这么做的前提是存在UE的Object对象反射系统。 
下面开始进一步描述属性同步的基本思路:我们给一个Actor类的同步属性A做上标记Replicates(先不考虑其他的宏),然后UClass会将所有需要同步的属性保存到ClassReps列表里面,这样我们就可以通过这个Actor的UClass获取这个Actor上所有需要同步的属性,当这个Actor实例化一个可以同步的对象并开始创建对应的同步通道时,我们就需要准备属性同步了。 
首先,我们要有一个同步属性列表来记录当前这个类有哪些属性需要同步(FRepLayout,每个对象有一个,从UClass里面初始化);其次,我们需要针对每个对象保存一个缓存数据,来及时的与发生改变的Actor属性作比较,从而判断与上一次同步前是否发生变化(FRepState,里面有一个Staticbuff来保存);然后,我们要有一个属性变化跟踪器记录所有发生改变同步属性的序号(可能是因为节省内存开销等原因所以不是保存这个属性),便于发送同步数据时处理(FRepChangedPropertyTracker,对各个Connection可见,被各个Connection的Repstate保存一个共享指针)。最后,我们还需要针对每个连接的每个对象有一个控制前面这些数据的执行者(FObjectReplicator)。 
这四个类就是我们属性同步的关键所在,在同步前我们需要对这些数据做好初始化工作,然后在真正同步的时候去判断与处理。


2.重要数据的初始化流程

下面的两个图分别是属性同步的服务器发送堆栈以及客户端的接收堆栈。 
(转载)UE4网络同步(二)——深入同步细节_第13张图片

  • 图5-1服务器发送属性堆栈图

(转载)UE4网络同步(二)——深入同步细节_第14张图片

  • 图5-2客户端接收属性堆栈图

从发送堆栈中我们可以看到属性同步是在执行ReplicatActor的同时进行的,所以我们也可以猜到属性同步的准备工作应该与Actor的同步准备工作是密不可分的。前面Actor同步的讲解中我们已经知道,当Actor同步时如果发现当前的Actor没有对应的通道,就会给其创建一个通道并执行SetChannelActor。这个SetChannelActor所做的工作就是属性同步的关键所在,这个函数里面会对上面四个关键的类构造并做初始化,详细的内容参考下图:

(转载)UE4网络同步(二)——深入同步细节_第15张图片

  • 图5-3 SetChannelActor流程解析图

图中详细的展示了几个关键数据的初始化,不过第一次看可能对这个几个类的关系有点晕,下面给大家简单画了一个类图。 
(转载)UE4网络同步(二)——深入同步细节_第16张图片

  • 图5-4 属性同步相关类图

具体来说,每个ActorChannel在创建的时候会创建一个FObjectReplicator用来处理所有属性同步相关的操作,同时会把当前对应通道Actor的同步的属性记录在FRepLayOut的Parents数组里面(Parents记录了每个属性的UProperty,复制条件,在Object里面的偏移等),同时把这个RepLayOut存储到RepState里面,该RepState指针也会被存储到FObjectReplicator里面,RepState会申请一个缓存空间用来存放当前的Object对象(并不是完整对象,只包含同步属性,但是占用空间大小是一样的)。FRepChangedPropertyTracker在创建RepState的同时也被创建,然后通过FRepLayOut的Parents数量来初始化他的记录表的大小,并记录对应的位置是否是条件复制属性,RepState里面保存一个指向他的指针。 
(关于Parents属性与CMD属性:Replayout里面,数组parents示当前类所有的需要同步的属性,而数组cmd会将同步的复杂类型属性【包括数组、结构体、结构体数组但不包括类类型的指针】进一步展开放到这里面。比如ClassA里面有一个StructB属性,这个属性被标记同步,StructB属性会被放到parents里面。由于StructB里面有一个Int类型C属性以及D属性,那么C和D就会被放到Cmd数组里面。有关结构体的属性同步第5部分还有详细描述)


3.发送同步数据流程分析

前面我们基本上已经做好了同步属性的基本工作,下面开始执行真正的同步流程。
  • 1
  • 2

(转载)UE4网络同步(二)——深入同步细节_第17张图片

  • 图5-5服务器发送属性堆栈图

再次拿出服务器同步属性的流程,我们可以看到属性同步是通过FObjectReplicator:: 
ReplicateProperties函数执行的,进一步执行RepLayout->ReplicateProperties。这里面比较重要的细节就是服务器是如何判断当前属性发生变化的,我们在前面设置通道Actor的时候给FObjectReplicator设置了一个Object指针,这个指针保存的就是当前同步的对象,而在初始化RepState的同时我们还创建了一个Staticbuffer,并且把buffer设置和当前Object的大小相同,对buffer取OffSet把对应的同步属性值添加到buffer里面。所以,我们真正比较的就是这两个对象,一般来说,staticbuffer在创建通道的同时自己就不会改变了,只有当与Object比较发现不同的时候,才会在发送前把属性值置为改变后的。这对于长期同步的Actor没什么问题,但是对于休眠的Actor就会出现问题了,因为每次删除通道并再次同步强制同步的时候这里面的staticbuff都是Object默认的属性值,那比较的时候就可能出现0不同步这样奇怪的现象了。真正比较两个属性是否相同的函数是PropertiesAreIdentical(),他是一个static函数。 
(转载)UE4网络同步(二)——深入同步细节_第18张图片

  • 图5-6 服务器同步属性流程图

4.属性回调函数执行

虽然属性同步是由服务器执行的,但是FObjectReplicator,RepLayOut这些数据可并不是仅仅存在于服务器,客户端也是存在的,客户端也有Channel,也需要执行SetChannelACtor。不过这些数据在客户端上的作用可能就有一些变化,比如Staticbuffer,服务器是用它存储上次同步后的对象,然后与当前的Object比较看是否发生变化。在客户端上,他是用来临时存储当前同步前的对象,然后再把通过过来的属性复制给当前Object,Object再与Staticbuffer对象比较,看看属性是否发生变化,如果发生变化,就在Replicator的RepState里面添加一个函数回调通知RepNotifies。 
在随后的ProcessBunch处理中,会执行RepLayout->CallRepNotifies( RepState, Object );处理所有的函数回调,所以我们也知道了为什么接收到的属性发生变化才会执行函数回调了。 
(转载)UE4网络同步(二)——深入同步细节_第19张图片

  • 图5-7 客户端属性回调堆栈图

5.关于动态数组与结构体的同步

结构体:UE里面UStruct类型的结构体与C++的Struct不一样,在反射系统中对应的是UScriptStruct,他本身可以被标记Replicated并且结构体内的数据默认都会被同步,而且如果里面有还子结构体的话也也会递归的进行同步。如果不想同步的话,需要在对应的属性标记NotReplicated,而且这个标记只对UStruct有效,对UClass无效。这一段的逻辑在FRepLayout::InitFromObjectClass处理,ReplayOut首先会读取Class里面所有的同步属性并逐一的放到FRepLayOut的数组Parents里面,这个Parents里面存放的就是当前类的继承树里面所有的同步属性。随后对Parents里面的属性进一步解析(FRepLayout::InitFromProperty_r),如果发现当前同步属性是数组或者是结构体就会对其进行递归展开,将数组的每一个元素/UStruct里面的每一个属性逐个放到FRepLayOut的Cmds数组里面,这个过程中如果遇到标记了NotReplicate的UStruct内部属性,就跳过。所以Cmds里面存放的就是对数组或者结构体进一步展开的详细属性。 
(下图中:TimeArray是普通数组,StructTest是包含三个元素的结构体,StructTestArray是StructTest类型的数组,当前只有一个元素) 
(转载)UE4网络同步(二)——深入同步细节_第20张图片

  • 图5-8 Parents内部成员截图

(转载)UE4网络同步(二)——深入同步细节_第21张图片

  • 图5-9 Cmds内部成员截图

Struct结构内的数据是不能标记Replicated的。如果你给Struct里面的属性标记Replicated,UHT在编译的时候就会提醒你编译失败”Struct members cannot be replicated”。这个提示多多少少会让人产生误解,实际上这个只是表明UStruct内部属性不能标记Replicated而已。最后,UE里面的UStruct不可以以成员指针的方式在类中声明。 
数组:数组分为两种,静态数组与动态数组。静态数组的每一个元素都相当于一个单独的属性存放在Class的ClassReps里面,同步的时候也是会逐个添加到RepLayOut的Parents里面,参考上面的图5-8。UE里面的动态数组是TArray,他在网络中是可以正常同步的,在初始化RepLayOut的Cmds数组的时候,就会判断当前的属性类型是否是动态数组(UArrayProperty),并会给其cmd.type做上标记REPCMD_DynamicArray。后面在同步的时候,就会通过这个标记来对其做特殊处理。比如服务器上数组长度发生变化,客户端在接收同步过来的数组时,会执行FRepLayout::ReceiveProperties_DynamicArray_r来处理动态数组。这个函数里面会矫正当前对象同步数组的大小。 
(之前发生过休眠动态数组同步不正常的情况,但是现在无法重现,如果出现同步不正常的情况请告知我一下,以便进一步完善这个文档。) 


六. RPC执行细节

RepLayOut参照表不止同步的对象有,函数也同样有,RPC的执行同样也是通过属性同步的这个框架。比如我们在代码里面写了一个Client的RPC函数ClientNotifyRespawned,那UHT会给我们生成一个.genenrate.cpp文件,里面会有这个函数的真正的定义如下:

void APlayerController::ClientNotifyRespawned(class APawn* NewPawn, bool IsFirstSpawn)
{
    PlayerController_eventClientNotifyRespawned_Parms Parms;
    Parms.NewPawn=NewPawn;
    Parms.IsFirstSpawn=IsFirstSpawn ? true : false;
    ProcessEvent(FindFunctionChecked(ENGINE_ClientNotifyRespawned),&Parms);
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

而我们在代码里的函数之所以必须要加上_Implementation,就是因为在调用端里面,实际执行的是.genenrate.cpp文件函数,而不是我们自己写的这个。同时结合下面的RPC执行堆栈,我们可以看到在Uobject这个对象系统里,我们可以通过反射系统查找到函数对应的UFuntion结构,同时利用ProcessEvent函数来处理UFuntion。通过识别UFunction里面的标记,可以知道这个函数是不是一个RPC函数,是否需要发送给其他的端。 
当我们开始调用CallRemoteFunction的时候,RPC相关的初始化就开始了。NetDiver会进行相关的初始化,并试着获取RPC函数的Replayout,那么问题是函数有属性么?正常来说,函数本身就是一个执行过程,函数名是一个起始的执行地址,他本身是没有内存空间,更不用说存储属性了。不过,在UE4的反射系统里面,函数可以被额外的定义为一个UFunction,从而保存自己相关的数据信息。RPC函数的参数就被保存在UFunction的基类Ustruct的属性链表PropertyLink里面,RepLayOut里面的属性信息就是从这里获取到的。 
一旦函数的RepLayOut被创建,也同样会放到NetDiver的RepLayoutMap里面。随后立刻调用FRepLayout::SendPropertiesForRPC将RPC的参数序列化封装与RPC函数一同发送。 
(转载)UE4网络同步(二)——深入同步细节_第22张图片

  • 图6-1 RPC函数的RepLayOut初始化堆栈图

简单概括了RPC的发送,这里再说一下RPC的接收。当客户端收到上面的RPC发来的数据后,他需要一步一步的解析。首先,他会执行ReceivePropertiesForRPC来接收解析RPC函数传来的参数并做一些判断确定是否符合执行条件,如果符合就会通过ProcessEvent去处理传递过来的属性信息,找到对应的函数地址(或者说函数指针)等,最后调用该RPC函数。 
这里的ReplayOut里面的Parents负责记录当前Function的属性信息以及属性位置,在网络同步的过程中,客户端与服务器保存一个相同的ReplayOut,客户端才能在反序列化的时候通过OffSet位置信息正确的解析出服务器传来的RPC函数的N个参数。 
(转载)UE4网络同步(二)——深入同步细节_第23张图片

  • 图6-2 接收RPC函数的传递的参数堆栈图

(转载)UE4网络同步(二)——深入同步细节_第24张图片

  • 图6-3 客户端执行RPC函数堆栈图

最后客户端是怎样调用到带_Implementation的函数呢?这里又需要用到反射的机制。我们看到UHT其实会给函数生成一个.genenrate.h文件,这个文件就有下面这样的宏代码,把宏展开的话其实就是一个标准的C++文件,我们通过函数指针最后找到的就是这个宏里面标记的函数,进而执行我们自己定义的_Implementation函数。

virtual void ClientNotifyRespawned_Implementation(class APawn* NewPawn, bool IsFirstSpawn);\ 
DECLARE_FUNCTION(execClientNotifyRespawned) \
{ \
    P_GET_OBJECT(APawn,NewPawn); \
    P_GET_UBOOL(IsFirstSpawn); \
    P_FINISH; \
    this->ClientNotifyRespawned_Implementation(NewPawn,IsFirstSpawn); \
} \
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

七. 其他网络特性(待更新)

1.Reliable与可靠数据传输 
我们知道RPC函数可以通过标记Reliable来保证远程执行,这个Reliable其实就是通过UE的上层机制来实现的。一般来说,如果使用TCP协议,我们在代码层面不需要做任何处理就可以实现可靠数据传输。但是,考虑到TCP协议的三次握手,保持连接,拥塞控制等等机制会影响传输效率,所以UE4仍然使用UDP作为运输层协议,通过上层机制来保证数据的可靠性。 
对于RPC函数:在执行UNetDriver::InternalProcessRemoteFunction会判断当前函数是否标记了Reliable 
if (Function->FunctionFlags & FUNC_NetReliable) 
{ 
Bunch.bReliable = 1; 
} 
对于Actor及其属性:在执行UActorChannel::ReplicateActor 时只要Actor的bNetTemporary为false,那么Actor的同步就是可靠的。(bNetTemporary表示Actor只在创建时同步一次) 
Bunch.bReliable = !Actor->bNetTemporary; 
关于UDP实现可靠数据传输的思路与TCP相同,主要是解决丢包与包的顺序不对的问题。进一步来讲,就是发送端给数据包标号,接收端按照标号的顺序接收,如果接收到正常顺序的包就发送ACK应答给发送端,发送端如果没有收到ACK就表示丢包,需要重发。这里面的细节并没有去仔细研究,大家如果有兴趣可以参考下面几个函数。网上有篇博客对这部分有简单的讲解 —— UE4网络模块分析。 
UChannel::PreBunch,UNetConnection::ReceivePacket,UNetConnection::ReceiveNak,UChannel::ReceiveNak,UChannel::ReceivedAcks。

最后再推荐几个博客 
http://www.jianshu.com/p/b4f1a5412cc9 
http://www.cnblogs.com/ghl_carmack/ 
https://www.zhihu.com/people/fjz13/posts

 

原文地址:http://blog.csdn.net/u012999985/article/details/78384199

转载于:https://www.cnblogs.com/wodehao0808/p/8298449.html

你可能感兴趣的:((转载)UE4网络同步(二)——深入同步细节)