逻辑分层¶
UE4网络模块大致可分为如下四层:
- 系统接口:Socket 特性
- 跨平台封装:FSocket 提供统一抽象接口
- 连接:握手、拆包合包、可靠传输等
- 应用:序列化、对象复制、RPC、同步策略
本文主要集中在第三和第四层,弄清楚实现原理以便指导设计和开发
TCP、UDP 特性¶
TCP
- 可靠
- 流式传输、无边界
- 监听 Socket、通信 Socket
- send、recv 接口不带地址
- I/O 复用(epoll、kqueue、IOCP)
UDP
- 不可靠
- 数据报传输、保留边界
- 无连接,客户端和服务端都是单个 Socket
- sendto、recvfrom 带地址
- 单个报文上限 65507 字节
特性说明:
- TCP 流式无边界意味着多个 send 调用可能会合并后 recv 或 单个 send 调用可能会拆分后 recv,需要接收端自行做边界判断(保证数据的 FIFO,与 Socket 接口调用次数无关)
- TCP 的监听 socket 仅进行三次握手,握手完毕由对应的通信 socket 进行 IO 操作
- 在服务端,TCP 协议下的多个客户端会对应多个 socket,因而需要 IO 复用的系统接口对 socket 进行高效管理(通常所说的 Reactor 模式)
- UDP 保留边界意味着一个 sendto 调用不丢包的话必定对应一个 recvfrom 调用(仅有一个,如果此次接收缓冲无法容纳整个报文,多余部分会被丢弃)
- UDP 单个报文上限 65507 字节其实是一个 IP 报的上限(SO_MAX_MSG_SIZE)(一个 IP 数据报总长记录是 16 位,所以最长是 2^16-1,减去 IP 报头 20 字节,减去 UDP 报头 8 字节,得到 65507 字节)
- UDP 单个报文上限实际测试,默认情况下 Mac:9216, Win、Linux:65507, Mac 通过 setsockopt 设置 SNDBUF 也能提高到 65507,三个平台都无法发送更大的报文
- UDP Socket 有个 MSG_PEEK 的 flag 可以在保留数据缓冲的情况下做一次读取(UE4 没用到)
TCP 和 UDP 测试图:
由上图可以看出,确实是 TCP 可能产生合并或拆分,送达的 UDP 数据报带有正确的边界,测试代码请看这里。
UE4 的设计
- UE 状态同步的实现是基于 UDP,并且以 Actor 为同步单位,与 UE 整体结构强耦合,从而分多个层次在保证可靠性的同时提高整体效率
- IpNetDriver 中 RecvFrom 代码硬编码了 MAX_PACKET_SIZE 的接收缓冲大小,为 1024 字节
- UNetConnection::InitSendBuffer 函数中设置了 SendBuffer 为 1024 字节,可在初始化 Connection 的时候传入
- 通过准确计算将 UDP 数据报严格限制在 1024 字节之内
跨平台¶
- 跨平台通过 UE4 的 Module 机制来实现,底层多平台的 socket 通过 FSocket 封装进行统一,通过 FSocketSubsystemModule 进行加载
- Module 的建立与 UE4 编译过程强绑定
基本概念¶
关键类¶
整个网络通信涉及的关键类:
其中:
- NetDriver 管理 Socket 和 NetConnection
- NetConnection 管理 Channel
- ControlChannel 会将消息转发给 FNetworkNotify(实际接收者是 UWorld 或 UPendingNetGame)
- PacketHandler 是充当一个网络通信中间件的概念,负责握手、数据包过滤等
- PacketHandler 也是个管理者的角色,实际干活的是 HandlerComponent
- 值得注意的是,不同 Connection 可以有不同的 Packethandler 实例
Actor 与 Channel¶
通信组件逻辑关系图:
其中,针对服务端:
- NetDriver 中管理着多个 Connection,每个 Connection 对应一个客户端的 IP+Port
- Connection 中管理着多个 ActorChannel,每个 ActorChannel 对应着一个 Actor
- ActorChannel 中管理着多个 FObjectReplicator,每个 FObjectReplicator 负责一个 UObject 的序列化和反序列化操作
- 显然,对于服务端中的一个 Actor,面对不同的 Connection 会有不同的 ActorChannel,FObjectReplicator 有类似的情况
针对客户端:
- NetDriver 中只有一个 Connection,就是 ServerConnection
- 一个 Actor 只对应一个 ActorChannel,因为只有一个 Connection
Actor 承载的同步内容¶
针对一个Actor:
- Actor 自己(ActorChannel 同步的第⼀个 Bunch 附带)
- Properties、DeltaProperty、RPC
- SubObjects(必须是 UObject)
针对一个SubObject:
- SubObject 自己(SubObject 同步的第一个 ContentBlock 附带)
- Properties、DeltaProperty、RPC
- Sub SubObjects(由此可构成一棵 UObject 树)
其中:
- 网络通信建立在 Actor 的基础之上
- Actor 中的 Component 和 SubObject 同步(包括属性)也是通过该 ActorChannel 来实现的
- 我们在设计 Client 与 DS 通信内容的时候,需要想好通过哪个 Actor 来承担通信任务
- Property 必须是值类型或者 UObject 的引用类型(如果是 F 类的指针,无法同步)
- DeltaProperty 是 UE 设计的用来实现数组的差异传输
报文结构¶
报文结构图:
图中:
- ⼀个 UDP 数据报是⼀个 RawPacket(直接是⼀个 Packet 或者加密处理过的 Packet)
- Connection 处理的数据是 Packet,⼀个 Packet 包含 PacketHeader + n 个 RawBunch(可以对应多个 Channel,另外,这⾥的 n 个 Bunch 肯定属于同⼀个 Connection)
- ActorChanne 处理的数据是 RawBunch,⼀个 RawBunch 可以直接就是⼀个独⽴的 Bunch,或者是 Bunch 的⼀部分
- ⼀个 Bunch 包含 BunchHeader + m 个 ContentBlock
- FObjectReplicator 处理的数据是 ContentBlock,一个 ContentBlock 包含 CBHeader + 序列化数据
另外,图中 NetGUID 代表 FNetworkGUID,用来同步引用关系的,当 DS 同步过来一个引用关系,能找到的直接引用上,找不到的放到 Unmaped 表中
数据包路由过程¶
数据路由图:
其中:
- NetDriver 通过 Socket 的 recvfrom 接口收到 RawPacket 和对应的 IP+Port
- NetDriver 此时对 RawPacket 做第一次分流,根据是否有对应的 Connection 和是否握手包,分为三种情况
- 第一种情况:找到 Connection,则走流程解包,从而反序列化出属性或 RPC
- 第二种情况:是握手包,走握手流程,握手成功会创建该 Client 对应的 Connection,用 IP+Port 来标识
- 第三种情况:异常包,走重新握手流程,重新握手成功会更新客户端的 IP+Port
数据解包图:
其中:
- 数据解包指的是 RawPacket 路由给 Connection 这个分支的整体变化过程
- 从 socket 拿到 RawPacket 之后,UE 拆分了多阶段多层次去解析该数据报
- RawPacket 经由 NetDriver 转发给 Connection
- RawPacket 在 Connection 中经过中间件 PacketHandler 的过滤(比如解压缩等),得到 Packet
- Packet 在 Connection 中被拆解,其中的 PacketHeader 用于 ACK 逻辑,RawBunch 被发往各个 ActorChannel
- RawBunch 经过 ActorChannel 的排序合并,得到 Bunch
- Bunch 在 ActorChannel 中被拆解,得到 ContentBlock
- 通过 ContentBlock 的头部数据找到对应的 UObject 和对应的 FObjectReplicator,进而执行反序列化
连接流程及其实体¶
握手流程¶
握手流程图:
其中:
- 握手阶段也会走数据包路由过程
- Server 和 Client 都是在 PacketHandler 中间件中完成握手逻辑
- 客户端实际是 Component 中的 InComing 函数进行响应,服务端实际是 Component 中的 InComingConnectionless 函数进行响应
针对 Server 端:
- DS 加载配置的地图场景
- 创建 GameMode 和 GameState
- 创建 NetDriver 和 Socket,并开始监听客户端的连接
- 创建中间件:PacketHandler 和对应的 Component
- 等待客户端的连接
- 收到客户端发来的 Handshake 包,创建 Cookie 并响应 Challenge 包
- 收到客户端发来的 ChallengeResponse 包,验证 Cookie,并发出 ChallengeAck 包对 Cookie 进行确认
- 握手成功,创建对应的 ClientConnection 并绑定到该客户端的 IP+Port
针对 Client 端:
- 创建 UPendingNetGame
- 创建 NetDriver 和 Socket
- 创建 ServerConnection,就是一个 UNetConnection,客户端只有一个 Connection 对应着服务端的 IP+Port
- 创建中间件:PacketHandler 和对应的 Component
- 创建三个基本的 Channel,目前只有 ControlChannel 是有实际作用的
- 调用 BeginHandshaking 给 DS 发 Handshake 包
- 收到 DS 发来的 Challenge 包,获取其中的 Cookie,组成 ChallengeResponse 包,发给 DS
- 收到 DS 发来的 ChallengeAck 包,验证当前的 Cookie
- 握手结束,触发 HandshakeComplete 委托
连接流程¶
连接流程图:
其中:
- 连接流程建立在 ControlChannel 上(代码实现在 DataChannel.h 中,报文第一个字段就是 MessageType)
- 客户端实际是 UNetPendingGame::NotifyControlMessage 函数进行响应,服务端是 UWorld::NotifyControlMessage 进行响应
针对 Server 端:
- 收到客户端 ControlChannel 发来的 Hello 消息,用 Challenge 消息进行响应
- 收到客户端的 Login 消息,走 GameMode 的 PreLogin 接口(此时 GameMode 可以拒绝该 Client 的连接)
- GameMode 接收连接后,给客户端发送 Welcome 消息表示接受加入,并附带地图信息
- 收到客户端发来的 NetSpeed 消息,记录起来
- 收到客户端发来的 Join 消息,说明客户端加载完地图了
- 紧接着走 GameMode 的 Login 流程,会创建该 Client 对应的 PlayerController
- 调用 GameMode 的 PostLogin 接口,手动同步 GameState 给客户端,同时触发 APlayerController、APawn 的同步逻辑
- 最后,将该 PlayerController 与该 Connection 进行绑定
针对 Client 端:
- HandshakeComplete 委托函数中触发 Join 操作,向 DS 的 ControlChannel 发送 Hello 消息
- 收到 DS 发来的 Challenge 消息,响应一个 Login 消息
- 收到 DS 发来的 Welcome 消息,解析出其中的地图信息,并开始加载地图
- 给 DS 回应一个 NetSpeed 消息
- 地图加载完成则给 DS 发一个 Join 消息,并开始接收后续的同步操作
- 地图加载完之后,对应的 UWorld 会替代 UNetPendingGame 成为 ControlChannel 的实际处理者(NetDriver->Notify)
连接结束之后一系列 AActor 和 UObject 会被同步到客户端,大致可分为以下 4 类:
- 仅 DS:AGameMode
- DS 和 AllClient:AGameState、APlayerState、APawn(AGameState 中保存着所有 APlayerState 的引用)
- DS 和 OwnerClient:APlayerController(Controller 去 Possess 一个 Pawn 是发生在 DS)
- 仅 OwnerClient:AHUD、UMGWidget
另外:
- AGameState 虽然会同步给客户端,但客户端无法调用其 ServerRPC,因为此时客户端的 AGameState 的最外层 Owner 并不是客户端的 PlayerController 或 Pawn,无法找到对应的 Connection
- APlayerState 的创建时机是在 APlayerController 的 FinishSpawning,并由 TickFlush/ReplicationGraph 推动同步给客户端
- APlayerState 中记录的 ExactPing 是 Client 和 DS 通信数据一个来回耗时,Ping * 4 为耗时毫秒数
- 掉线或流程结束的时候,Connection 会被销毁,同时 APlayerController 和 APlayerState 会被一起销毁
重连流程¶
重连流程图:
其中:
- 典型情况:用户从 4G 网络切换到 Wifi,切换网络会导致外网 DS 收到的数据报 IP 和端口发生变化,从而触发重连
- 重连操作的目的是更新 DS 中对应 ClientConnection 所绑定的 IP+Port
针对 Server 端:
- 收到一个来自新 SockAddr 的非握手包,说明有可能切换了网络
- 给该新 SockAddr 发一个 RestartHandshake 包,触发重新握手
- 收到该新 SockAddr 发来的带有重新握手标志位的 Handshake 包,则创建一个新 Cookie,组成 Challenge 包响应给它
- 收到该新 SockAddr 发来的 ChallengeResponse 包,并带有新 Cookie 和原本的旧 Cookie
- 到这里说明这个客户端真的切换了网络,那么,根据 Cookie 更新对应 Connection 的 IP+Port
- 给客户端发送一个 ChallengeAck,并带上旧 Cookie,用来表示对旧 Cookie 的确认
针对 Client 端:
- 某种情况下网络被切换,逻辑层并无感知
- 按照往常一样向 DS 发送数据包(可能是同步数据,也可能是心跳包)
- 突然收到 DS 发来的 RestartHandshake 包,赶紧响应一个 Handshake 包并附带重新握手标志
- 收到 DS 发来的带新 Cookie 的 Challenge 包,回应一个 ChallengeResponse 包,并附带新 Cookie 和旧 Cookie
- 收到 DS 发来的确认旧 Cookie 的 ChallengeAck 包,说明已经重连成功
数据报处理流程¶
Tick 顺序图:
其中:
- TickDispatch 是收包
- 对象 Tick 是更新世界状态
- TickFlush 是发包
- 收包阶段,DS 会同步 Actor、属性、UObject、RPC 等给客户端,其中会包含 Uobject 的引用关系
- 在 DS 上 ActorA 引用了 ActorB,这两个 Actor 都 Replicate 到客户端之后,是如何保持这个引用关系的?答案是同步引用的时候会带上 FNetworkGUID,找到了直接引用上,找不到的则加入 Unmaped 表中,另外,对象和对象引用关系是分开同步的
- Client 收到引用关系的时候,可能对应的 UObject 还没同步过来,此时会触发 Unmapped 的属性记录
- 当 Unmapped 属性在 TickFlush 阶段被更新引用之后,会触发对应的 OnRep 回调
数据包处理步骤¶
其中:
- Connection 收到 RawPacket,第一步丢给中间件 PacketHandler 过滤一下,得到 Packet
- FNetPacketNotify 从 Packet 中读出 Header,并进行 Ack 数据的处理(执行一系列可靠 UDP 的逻辑,记录 ID、更新历史等)
- 循环从 Packet 中读出数据
- 根据 Bunch 结构 从 Packet 读出状态位(包括 ChIndex)以及此 RawBunch 的数据大小
- 从 Packet 读出 RawBunch 的数据,此时得到一个 RawBunch
- 根据 ChIndex 去找对应 Channel,找不到就用 Bunch 结构 的信息创建一个(仅限 ActorChannel)
- 将 RawBunch 丢给该 Channel 去处理(以下针对 ActorChannel)
- ActorChannel 中判断,Reliable 包顺序不对需要排队
- PartialBunch 则需要等待合并,或者,本次 Bunch 就是最后一个部分,则触发合并
- ActorChannel 首次收包会创建对应的 Actor,此时这一个 Bunch 必定包含复制 Actor 需要的数据(静态对象同步 NetGUID、OuterNetGUID 和 Path,动态对象同步 NetGUID、Archetype 以及 Location、Rotation 等)
- 循环从 Bunch 中读出 ContentBlock
- 根据 ContentBlockHeader 的状态位或 FNetworkGUID,找到对应的 Object(也许是创建 SubObject,那么就是 Actor 的子对象进行了复制操作)
- 根据找到的 Object 在 ActorChannel 中获取对应的 FObjectReplicator
- 调用 Replicator->ReceivedBunch 进行数据的反序列化
- 反序列化过程依赖反射信息
- 属性同步则先将旧的属性复制出来,执行反序列化去更新属性,再比较是否有变化,有变化则将该 Property 加入 RepNotifies 数组
- 是 RPC 则走 Replicator::ReceivedRPC,最终通过 Object->ProcessEvent(Function, Parms);执行 RPC
- 统一调用 RepNotifies 数组中的属性 OnRep_XXX 函数
- Actor 是新创建就调用 BeginPlay
- 结束
另外:
- 属性回调执行的前提是当前值与同步过来的值有差异
- 如果 Client 先修改一个值,然后服务器同步过来的与当前值相同,那么是不会触发回调的
- 可以采用如下宏来强制客户端执行 OnRep 回调: DOREPLIFETIME_CONDITION_NOTIFY( AActor, XXX, COND_Custom, REPNOTIFY_Always );
- 对于一个配置了 OnRep 函数的结构体,只要结构体中有一个字段被更新,该 OnRep 就会被调用,因此,可能在一次同步操作中触发多次 OnRep 调用,具体取决于结构体字段的更新时机(比如遇到 Unmapped 的对象,则会延迟调用 OnRep 回调)
- 多个 OnRep 回调的顺序为对应属性的 Offset,其实就是类定义中属性字段的顺序
创建 Actor 的 3 种情况¶
总的来说,Spawn 一个 Actor 可以划分为如下 3 种情况:
- GetWorld()->SpawnActor(Class, FActorSpawnParameters);
- GetWorld()->SpawnActorDeferred(...); + Actor->FinishSpawning(SpawnTransform);
- ⽹络同步过来的 Actor: World->SpawnActorAbsolute(...);
这 3 种情况对应的 BeginPlay 接口执行顺序如下:
- 第一种情况:SpawnActor 调⽤返回的时候 BeginPlay 已经执行过了
- 第二种情况:BeginPlay 在 FinishSpawning 中调⽤,我们可以在 SpawnActor 和 FinishSpawning 之间修改属性值
- 第三种情况:Spawn ⼀个 Actor,也走 FinishSpawning,但不调⽤ BeginPlay,等待后续接收完所有属性之后再调⽤ BeginPlay。UE 在调用 BeginPlay 之前会判断是否为网络同步过来的 Actor,如果是,则不走 BeginPlay
同步 SapwnActor 不立刻走 BeginPlay 图:
3 个同步参数¶
开发过程中用到的 3 个同步控制参数如下:
- 属性变量 UProperty 标记为 Replicated
- Actor ⾃⼰标记 bReplicates
- ⼿动在 ReplicatedSubOjbect 函数中将 UObject 写⼊ Bunch
对应的功能:
- UProperty 配置 Replicated 和 GetLifetimeReplicatedProps 是搭配使⽤的,共同实现值复制和引⽤关系的复制(只复制引用关系,不复制引用的 UObject)
- Actor 设置 bReplicates 之后在 TickFlush 阶段会被考虑进同步列表(Channel->ReplicateActor),此时会复制 Actor 自己以及 SubObject
- ReplicateSubobjects 接口⽤于⽀持同步⾮ Actor 的 UObject
针对 UProperty 配置,只配置 Replicated 没效果,只实现 GetLifetimeReplicatedProps 会编译报错。另外,被 Actor 管理同步的 UObject 也可以有自己的 RPC。
Replicate 一个 Actor¶
ReplicateActor 图:
其中:
- 第一步创建一个全新的 Bunch
- 首次同步则需要写入重建 Actor 需要的信息(静态 Actor 写入路径,动态 Actor 写入 Spawn 参数)
- 序列化该 Actor 需要同步的属性
- 用当前属性值与 staticbuff 记录的缓存值进行比对,得到发生变化的属性列表
- 讲属性变化列表记录到属性变化记录管理器中,并 Merge 属性历史中需要 Resend 的属性
- 序列化这些属性
- 序列化 UnreliableMulticastRPC
- 调用 Actor 的 ReplicateSubobjects 接口
- 针对每一个 UObject 进行序列化
- 首先找到 UObject 对应的 FObjectReplicator
- 序列化 UObject 需要同步的属性(这一步与序列化 Actor 需要同步的属性类似,也有属性变化历史记录)
- 序列化 UnreliableMulticastRPC
- 讲 ContentBlock 写入 Bunch 中,这样多个 SubObject 会有多个 ContentBlock
- 记录需要删除的 SubObject 到 Bunch
- SendBunch
另外:
- 创建 Actor 默认使用 CDO 作为 Template
- 一个对象的 Archetype 就是创建该对象时的 Template
- 第一次创建 FObjectReplicator 的时候固定用该 Object 的 Archetype 对象的属性值来填充 staticbuff
- 查找变化的属性时,是直接用备份的 staticbuff 跟该属性对比,有差异则说明有变化,同时更新 staticbuff
- 综上,首次同步 Actor 不会序列化所有参数,仅被标记了 UPROPERTY 并且与 CDO 值不一致的情况下才会序列化
- 属性不变的情况下,DS 还是会消耗 CPU 去算当前属性是否发生了变化(遍历 Actor 中标有 Replicated 的属性)
- 需要同步的属性采用了 Class 中对应属性的顺序(属性在内存中的 Offset)
触发一个 RPC¶
RPC 发包图:
其中:
- 首先,是 MultiCast 的 RPC 则需要遍历所有相关的 Connection 进行发包
- 是该 ActorChannel 的首次发包,则先触发 Actor 的同步(Actor 的同步是独立的 Bunch)
- 创建一个全新的 Bunch
- 获取该 RPC 的参数类型信息
- 讲 RPC 的实际参数序列化到缓冲中
- 如果是 UnreliableMulticastRPC,则将相关数据缓存起来,等待后续 Actor 同步时一块发出去
- 其他情况则构造全新的 ContentBlock,并立刻发包
另外:
- Reliable 的 Bunch 是一定会保证按顺序送达,发生丢包会使得后续的包处于一个等待的状态,此时便会有明显的延迟现象
- 使用 ReliableBunch 有两种情况,一是首次同步一个 Actor 或 UObject,二是使用 ReliableRPC
- 因此,建议少用 ReliableRPC,特别是 ReliableMulticastRPC
可靠的先后顺序:
- 服务端:新建 Actor 立刻设置属性,然后触发 RPC,对应客户端顺序固定为:OnRep_XXX => Actor 的 BeginPlay => RPC
- 服务端调用多个 ReliableRPC,对应客户端上会按顺序执行,反之亦然
- 同一个 UObject,同时赋值多个 Replicated 属性,在某个值属性 OnRep 被调用时,其他值属性也已经获得最新值(引用属性则不一定)
- 同一个 UObject,多个 OnRep 回调同时有数值改变的情况下,被调用的顺序是按照对应字段在内存中的 offset 来排序的,offset 小的先调用(不是 GetLifetimeReplicatedProps 中的顺序)
还有,Actor 已同步到客户端的情况下,同时设置属性和调用非 UM 的 RPC 以及 UMRPC,一般顺序为: 非 UnreliableMulticast 的 RPC => OnRep_XXX => UnreliableMulticast 的 RPC(此执行顺序受网络环境影响,写代码不可依赖此顺序)
可靠 UDP 原理¶
ReliableBunch¶
可靠 UDP 的整体实现可大致分为两层:Packet 层通过 Ack 进行确认、Packet 层之上的具体功能层则根据 Ack 结果决定响应。
数据包 PacketID 图:
Packet 层:
- 每个发出去的 Packet 都带唯一的 PacketID,此 ID 依序只增不减
- 收到新包后,依序报告每一个 Packet 是 Ack 或 Nak
- 心跳包保证客户端和服务端一定会发包(即使是只带有 Ack 的空 Packet)
- 通信对方在一段时间内没响应就判断为掉线
- 没有超时重传
Bunch 层:
- 每个 Bunch 都有唯一的 BunchID
- 发送 ReliableBunch 时记录该 Bunch 对应的 PacketID 并缓存起来
- 收到 PacketID 的 Ack 则可解除对应 Bunch 的缓存(可能有多个 Bunch)
- 收到 PacketID 的 Nak 则重发对应的 Bunch(BunchID 不变,会构成新的 Packet 用新的 PacketID)
举例说明 Bunch 可靠性保证:
- 客户端发送 1 号到 5 号包给 DS
- DS 只收到 1 号、3 号、5 号,并根据需求给客户端回复 100 号和 101 号包
- 客户端收到 100 号包,通过包头的 Ack 知道了服务端的收包情况,向上层按顺序报告 1 号 Ack,2 号 Nak,3 号 Ack,4 号 Nak,5 号 Ack
- 客户端 Bunch 层收到 Ack 报告,将原本 2 号包的 Bunch 封装成 6 号包,4 号包的 bunch 封装成 7 号包,发出去(6 号包和 7 号包包含了 100 号包的 Ack)
- DS 收到 6 号包和 7 号包,再向客户端发新的 102 号包
- 在这个过程中,客户端发给 DS 的 PacketID 序列与 DS 发给客户端的 PacketID 序列完全独立,互不影响
心跳包的超时判断逻辑:
- Client 和 DS 都有心跳包,在 Connection 的 Tick 中每帧执行,超过 KeepAlive 时间就发心跳包,KeepAlive 数值为 3,记录的是 Tick 的 DeltaTime 积分差
- Client 和 DS 都有超时掉线判断,在 Connection 的 Tick 中每帧执行,超过 Timeout 时间就认为掉线,直接 Close 该 Connection(Timeout 默认为真实的 3.4 秒)
- 简单的说:如果服务端一直没有收到包,那么 3.4 秒之后就关闭该 Connection
- 超时后 Connection 的 State 会被设置为 CLOSE
- 在 NetDriver 的 TickDispatch 中会检查 Connection 的 State,发现 CLOSE 则进行 Cleanup
- Connection 的 Cleanup 会触发 APlayerController 的 OnNetCleanup
- APlayerController 的 OnNetCleanup 中就直接 Destroy 自己,Destroy 一个 AController 就触发 GameMode 的 Logout 以及销毁 APlayerState
- 整体结论就是:Connection 超时关闭导致 GameMode->Logout 和 APlayerController、APlayerState 的销毁
属性同步可靠传输¶
属性同步:
- 每次检测到属性发生变化,就进行属性同步并将该属性的索引和 PacketID 记录到 FRepChangedHistory 中
- FObjectReplicator 收到 PacketID 的 Nak 则遍历对应 FRepChangedHistory 将相关属性的 Resend 标志位置 true
- 等待下一次同步该 Object 的时候一并同步
Ack 可靠原理¶
Ack 采用冗余传输:
- 每次收到 Packet 都会更新 InAckSeq(收到 Packet 并响应 Ack 的最大 PacketID)
- 每次收到 Ack 都会更新 InAckSeqAck(确认对方收到我响应 Ack 的最大 PacketID)
- 每个 Packet 发送前,都会在头部写入 InAckSeqAck 到 InAckSeq 之间的 Packet 的 Ack
- 相邻发出的 Packet 很可能头部的 Ack 数据是一样的
- Packet 层必须收到 Ack 才算确认,否则都认为丢包了
其他¶
易混淆点:
- \<\<操作符可能是读也可能是写,具体看数据类型(对于 FBitReader \<\<操作符和 Serializa 函数都是读的意思,FBitWriter 反之)
- 以太网 byte 序为大端序,bit 序为小端序
可用委托:
- UNetConnection->ReceivedRawPacketDel // 收到的是 RawPacket
- UNetConnection->LowLevelSendDel // 发送的是 RawPacket
- UNetDriver->SendRPCDel // 发送 RPC 前回调
- Actor->OnSubobjectCreatedFromReplication
- Actor->OnSubobjectDestroyFromReplication
参考资料¶
- UE4 源码
- UE4 文档: https://docs.unrealengine.com/4.26/en-US/InteractiveExperiences/Networking/Actors/
- 《网络多人游戏架构与编程》
- UE4 UDP 是如何进行可靠传输的: https://zhuanlan.zhihu.com/p/372375535
- 《Exploring in UE4》网络同步原理深入(上): https://zhuanlan.zhihu.com/p/34723199
- 《Exploring in UE4》网络同步原理深入(下): https://zhuanlan.zhihu.com/p/55596030
- 理解同步规则: https://blog.csdn.net/u012999985/article/details/78244492
- 深入同步细节: https://blog.csdn.net/u012999985/article/details/78384199
- 使用虚幻引擎 4 年,再谈 UE 的网络架构: http://news.16p.com/869660.html