UE4 FastArray

提问
普通TArray和FastArray的增删改查有什么区别? (文中有FastArray详细讲解)
普通TArray有哪些缺点, FastArray优化了哪些点? (文末指出)
类似于HistoryChangedList, FastArray怎么实现的? (Retirement+HistoryList)
ReceivingRepState, SendingRepState, FastArray又是怎么实现的? (存放在自身结构体
FFastArraySerializer中)UnmapedGUID, FastArray怎么实现的? (存放在
FFastArraySerializer.GuidReferencesMap中)FastArray第一次同步和谁对比, 是全量同步吗?(和Archetype对比, 根据Changelist同步)
关键变量
FFastArraySerializer

ItemMap
存储Item.ReplicationID到FastArray数组中的Index的映射关系.
1 | /** Maps Element ReplicationID to Array Index.*/ |
IDCounter
IDCounter初始化为0, 每次有Item初始化(新Item)时候自增, 该变量用于Item.ReplicationID初始化.
1 | /** Counter used to assign IDs to new elements. */ |

FFastArraySerializerItem

ReplicationID
唯一标志Item的ID, 一旦赋值, 终生不变. 一般用做Key, 查询该Item.
1 | UPROPERTY(NotReplicated) |
ReplicationKey
每次Item修改都会+1. 用于对比Item是否发生变化.
1 | UPROPERTY(NotReplicated) |
MostRecentArrayReplicationKey
只在客户端使用, 用于记录最近更新时候的FastArrayReplicationKey. 用于Replay时候, 剔除不正确的数据.
1 | UPROPERTY(NotReplicated) |

FNetFastTArrayBaseState
存放在FSendingRepState.RecentCustomDeltaState中, 用于存储历史数据.
IDToCLMap
一个Map,
存储element's ReplicationID到ReplicationKey的映射.
该数据时历史数据, 用于和当前数据进行对比, 进而只发送dirty数据.
1 | /** Maps an element's Replication ID to Index. */ |
ArrayReplicationKey
历史的ArrayReplicationKey数据. 如果ArrayReplicationKey没有发生变化, 则不需要重建.
1 | int32 ArrayReplicationKey; |

不需要重建, 则直接返回.

如果需要重建, 则直接创建新的, 并重新赋值.
相关函数:FFastArraySerializer.FastArrayDeltaSerialize_DeltaSerializeStructs

FFastArraySerializerHeader.BaseReplicationKey
赋值. 在序列化FastArray数据的时候,
将OldState的ArrayReplicationKey写入DeltaHeader的BaseReplicationKey中.

在序列化DeltaHeader时,
将BaseReplicationKey序列化到Bunch中.

客户端在解析DeltaHeader时,
将BaseReplicationKey解析出来.

总结:
BaseReplicationKey是OldState的ArrayReplicationKey,
和DeltaHeader.ArrayReplicationKey联合起来表示当前比较的范围:\([BaseReplicationKey,
ArrayReplicationKey]\).
BaseReplicationKey是用来处理Replay的.
目前只有处理Replay时候用到了BaseReplicationKey.

DS处理FastArray

FastArray Layout
ParentCmd存储顶层数据结构,
在此处为FExampleFastArray成员变量,
FRepLayoutCmd存储ParentCmd展开后的属性.
而Handle存储了数组对象展开后成员的FRepLayoutCmd索引值.
序列化时只序列化RelativeHandle,
客户端根据RelativeHandle就可以知道当前消息处理的是哪个成员.
下图中,
Cmd可以根据RelativeHandle定位到FHandleToCmdIndex数组中的Item,
FHandleToCmdIndex数组可以通过Item中的CmdIndex定位到对应的Cmd.
FRepLayoutCmd.RelativeHandle和FHandleToCmdIndex.CmdIndex是他们二者相互查找的方式.
Parent结构:

Cmd结构:

RelativeHandle:

FastArray Layout的构建
在初始化Replayout(FRepLayout.InitFromClass),会创建属性的描述类型:
FRepParentCmd和FRepLayoutCmd.
针对IsCustomDelta类型,在遍历UClass.ClassReps时,
将其FRepParentCmd.Flags设置为IsStructProperty|IsCustomDelta|IsFastArray.


此时, FastArray的FRepParentCmd.Flags为

针对LifetimeProps,
还要附加ERepParentFlags::IsLifetime属性,
但是要排除ERepParentFlags::IsCustomDelta类型. 所以, FastArray的FRepParentCmd.Flags并不包含ERepParentFlags::IsLifetime,
这样后续对于IsCustomDelta类型, 不进行Compare.

特殊处理IsFastArray类型
特殊的, 针对IsFastArray类型,
要将其放入FRepLayout.LifetimeCustomPropertyState中.
一定是IsCustomDelta中的IsCustomDelta类型.

不进行Compare
在FNetSerializeCB::UpdateChangelistMgr和FRepLayout.ReplicateProperties执行时时会过滤掉IsCustomDelta类型,
因为
IsCustomDelta类型的FRepParentCmd.Flags不包含ERepParentFlags::IsLifetime,
不会进入比较函数,
进而比较结果ChangedList中不会包含CustomDeltaProperty,
所以CustomDeltaProperty不会在FRepLayout.ReplicateProperties中被序列化.
预先处理CustomDeltaProperty ChangeList
保证提取一个可用的HistoryItem, 供这次修改使用.
从下图可以看出FRepLayout::PreSendCustomDeltaProperties主要是处理FastArray中的HistoryItemList,
如果没有可用的HistoryItem,
则将EndItem强制Reset, 用于填充此次比较的结果.
注意, 该行为是每帧最多执行一次, 即所有Connection共用一个HistoryList.

处理所有CustomDeltaProperty
遍历所有CustomDeltaProperty, 依次进行处理.

RecentCustomDeltaState:使用RecentCustomDeltaState作为OldState,
与新创建的NewState进行比较.


清理RetirementList
根据LastAckPacketID, 删除已经无用的RetirementItem.

自定义实现比较并获得ChangedElements
关键数据IDToCLMap
IDToCLMap: ReplicationID到数组中ReplicationKey的映射. 在数组发生变化时候, 进行重建, 并且根据Old和New的IDToCLMap比较, 得出删除的数据. 如果数组中Item只是位置发生了变化无任何影响.
每个需要Replication的UObject都有一个FObjectReplicator与之相对应.
每个Connection都有一份RetirementList.
RetirementItem中就包括IDToCLMap.

NewState是每次比较时候, 新创建的:

等到数据比较完成之后, 将旧数据放入了FSendingRepState.Retirement中, , 新数据变成旧数据, 放入ReplicateCustomDeltaProperties.UsingCustomDeltaStates中.

填充NewState的IDToCLMap, 对比ReplicationKey, 填充changedElementList
在处理FastArray的属性同步时, 如果数组内容发生变化, 会重建IDToCLMap. 并对比ReplicationKey, 填充ChangedList

计算ChangedItems和DeletedItems
根据ReplicationID将新旧IDToCLMap进行对比,
计算已经删除Item. 注意, 这里只是根据ReplicationID进行比较,
并没有根据数组的位置比较.
而且是旧的IDToCLMap中存在某个ReplicationID,但是新的中没有,
就代表删除.

核心:计算ChangedList

构建Shadow
背景知识: C++可以通过对象地址+Offset唯一确定某个对象的成员变量, 取址后, 可以根据该地址直接对该成员进行操作. UE使用该特性, 对Property进行各种直接操作.
初始化FastArray对应的FLifetimeCustomDeltaProperty,
并存放在FRepLayout.LifetimeCustomPropertyState中.
其中关键变量为:FastArrayArrayReplicationKeyName和FastArrayItemReplicationIDName的Offset,
为后续查询做准备.

通过对象指针+成员偏移, 找到对应的成员地址, 在通过成员对象提取出其存储数据的起始地址. 这块地址为存放着连续Item的数组.

可以通过TScriptArray.Insert探究出其内存分布.
即所有Item存储在一块连续内存中.

Shadow变更流程: 首先将Shadow中数据调整成和当前ItemArray数组一致, 即shadow中Index位置存储的ReplicationID要和当前Item数组中Index位置的ReplicationID一致.

然后, 如果Shadow数组大小小于ObjectArrayNumber, 要扩充Shadow数组大小. 并且如果需要扩充shadow, 则需要将Shadow中扩充的部分填充好ReplicationID, 并全部设置为New.
经过上述调整, 最终ShadowArray和ObjectArray大小一致, 并且Index位置所对应Item的ReplicationID都是一样的.
计算出这一帧和上一帧的ChangeList
构建完成Shadow, 就可以用它和ObjectArray进行比较了, 并且在比较的同时, 会将Shadow值更到最新. 而且会得出ChangeList.

合并历史需要Resend数据

如果ChangeList为空, 重发所有数据
这种情况发生在超高丢包率情况下, 64个ChangeList已经不够用了, 导致该帧比较结果+历史比较结果已经不能正确表示真是的修改值.

发送数据
根据收集到的ChangeList发数据, 并且结尾添加结束符:0.

DS填充消息
消息结构图
Bunch=BunchHeader+BunchBody,BunchHeader唯一确定该Bunch处理的对象是哪个Actor.BunchBody=ContentBlockHeader+ContentBlockBody,ContentBlockHeader:唯一确定当前处理的是哪个UObjectContentBlockBody=FieldHeader+FieldBody,FieldHeader唯一标识当前要处理的是哪个Property.FieldBody=FastArrayHeader+FastArrayBody,FastArrayHeader有当前FastArray的信息, 比如修改的梳理, FastArrayReplicationID, 删除元素的ReplicationID列表等.FastArrayBody是由一个list组成,ListItem=ReplicationID+bDirty+Item详细内容.


填充DeltaHeader
遍历当前所有Item,
填充NewState.IDToCLMap(ReplicationID2ReplicationKey),
然后将NewState和OldState中的IDToCLMap进行比较,
统计DeletedIndices(已删除Item的ReplicationID),
NumChanged(变化的Item),
记录BaseReplicationKey(OldState.ArrayReplicationKey)和ArrayReplicationKey(NewState.ArrayReplicationKey).

序列化Header信息

序列化需要发送的数据
数据格式:


添加和修改
默认流程是支持增加和修改的, 上述流程已经说的很详细了, 这里不再赘述.
删除
DS上构建删除元素(ReplicationIDList),
序列化到FastArrayHeader中, 发送到客户端.
逻辑上DS删除元素是直接删除的,
在TickFlush-SerializeDeltaCustomData期间,
会根据OldState(RecentCustomDelta)和NewState
ReplicationID-ReplicationKey的比较结果,
得出DS上删除的元素的ReplicationID. 将ID序列化到Header中,
然后发送的客户端.

客户端解析收到的FastArrayHeader,
找到ReplicationID对应的Index, 然后删除之.
在客户端,
反序列化FastArrayHeader得到删除的ReplicationID,
根据ReplicationID找到对应的Index,

删除前回调:

客户端真正触发删除操作:

客户端处理FastArray

|
// 客户端在RecvBunch时,调用NetDeltaSerialize调用堆栈 |
解析DeltaHeader

解析ChangedItem
先解析出ReplicationID:

解析是否有数据修改:bDirty, 如果有数据修改, 则开始递归解析该Item的属性.

PostReceiveCleanup回调处理

删除之前, 回调PreRemove函数:

添加之后, 回调PostAdd函数:

修改之后, 调用PostChanged函数:

执行删除操作:

客户端更新GUID
DS不需要更新GUID, 因为DS都赋值者, 所有UObject对应的GUID都是在DS上赋值(Replication/RPC的时候注册到FNetGUIDCache中). 这种情况只可能发生在客户端. 客户端需要更新的原因: 客户端处理消息是有先后顺序的, 有的时候某些对象在序列化GUID的时候, 对应的UObject(Actor)还没有创建出来, 就无法找到对应UObject(Actor), 所以需要对于UmmapedGUID进行延迟绑定.
这里需要关注两个变量:
FReceivePropertiesSharedParams.bOutHasUnmapped和FReceivePropertiesSharedParams.bOutGuidsChanged.
在处理收到的属性消息时, 如果返回值为true, 则表示具有Unmaped的GUID.

进一步深究发现,
在ReceivePropertyHelper序列化属性时会传入PackageMap,
如果发现有Umapped的GUID就会将其放入UPackageMap.TrackedUnmappedNetGuids中,
然后根据TrackedUnmappedGuids数量来确定是否有UmappedGUID(TrackedUnmappedGuids数量大约0).

并且在处理结果中, 我们还看到, 在这份代码中还有比较,
如果发现与之前的有不同, 则将bOutGuidsChanged设置为true.

在FastArray反序列化(FRepLayout.DeltaSerializeFastArrayProperty)完成之后,
会将bOutGuidsChanged和bOutHasUnmapped合并到FNetDeltaSerializeInfo.bGuidListsChanged和FNetDeltaSerializeInfo.bOutHasMoreUnmapped中.
因为有其他模块也有可能含有Unmapped和Changed数据, 所以需要合并.

FastArray反序列化,
会将计算得到的bOutHasUnmapped和bOutGuidsChanged传递出来.
如果bOutGuidsChanged为true,
则会调用FObjectReplicator.UpdateGuidToReplicatorMap,
即调用INetSerializeCB.GatherGuidReferencesForFastArray函数.
当bOutHasUnmapped为true, 则最终会将

将bHasUnmapped信息传递出来,
如果bHasUnmapped为true,
则会将其放入UNetDriver.UnmappedReplicators中,
等待TickFlush时候调用.

反序列化+GatherGuidReferences+UpdateUnmappedObjects流程

客户端会先执行反序列化, 然后会得出bHasUnmappedGUID和bChangedGUID. 如果bChangedGUID为true, 则进行Gather流程, 即重新收集GUID. 如果bHasUnmappedGUID为true, 则进行UpdateUnmappedObject流程, 即尝试重新将GUID映射到对应的Object上.
|
// FastArray GatherGuidReferences+反序列化+UpdateUnmappedObjects流程
|
MoveGuidToUnmapped流程
当ActorChannel销毁时候, 触发MoveGuidToUnmapped.
1 | // FastArray MoveGuidToUnmapped流程 |
从上述流程可以看出, 当一个Actor销毁时候, 会将其Actor内部所OwnedGUID所关联的对象对应的GUID, 全部从MappedGUID移动到UnmappedGUID, 而不是删除. 那什么时候删除呢? 对于DS来讲, 当发生变化时候, 会将设置成全新的, 然后将新的GUID同步到客户端. 客户端发起新的流程, 会走一遍序列化+UpdateUnmappedObjects流程.

那么客户端呢? 如果一个客户端主动将一个Actor销毁, UnmappedGUID还会清理吗? 不会了, 从堆栈中可以看出, MoveMappedObjectToUnmapped都是通过ActorChannel调用的, 客户端销毁一个Actor, 并不会将其ActorChannel销毁, 所以, 不会触发MoveMappedObjectToUnmapped.

问
FastArray相互套用怎么传输
被嵌套的FastArray会被当做普通结构图进行传输,
失去了其本身FastArray对比等功能. 所以嵌套能用,
但是会被当做普通数组一样对待.
这里注意以下几点, 证明FastArray是不能被嵌套的.
- 在遍历所有ParentCmd时候,
针对每个
IsCustomDelta的ParentCmd会遍历其Cmd, 在这些cmd中如果发现一个是FastArray,就会break, 导致Replayout中没有被嵌套FastArray的信息, 即其属性成员的各种Offset无法被找到. 而且, 可以注意到, 如果FastArray被其他结构嵌套, 由于其ParentCmd不是IsCustomDelta, 则其也不会存在FLifetimeCustomDeltaProperty结构. 即FastArray不能被嵌套.

FLifetimeCustomDeltaProperty记录FastArray的各种属性成员的Offset,
如果没有FLifetimeCustomDeltaProperty, 很多操作无法执行.
比如说在FRepLayout::DeltaSerializeFastArrayProperty中用到的函数FLifetimeCustomDeltaState.GetCustomDeltaProperty.

- FastArray的比较是自定义实现的, 如果FastArray嵌套FastArray, 那么其内容的递归比较也不支持
但是通用比较函数没有针对FastArray进行特殊处理:

如果数组中两个Item位置发生变化, 会传输数据吗?
如果调用的时Memcopy, 则不会传输数据:
示例:


可以清晰的看到, 在传输数据的时候, 发现其没有任何变化, 不会序列化数据.

如果调用=, 则会
由于FFastArraySerializerItem重写了操作符=,
在复制的时候会将ReplicationID和ReplicationKey重置, 所以相当于全新数据,
会全部同步.


总结
如果仅仅是数组位置发生变化
而Item内容没有发生任何变化, 则会不进行任何数据传输.
在使用FastArray时候, 交换两个Item位置的时候, 不要使用=,
否则会发生无意义的数据传输. UE原生的TArray.Swap,
TArray.RemoveAt,
TArray.RemoveAtSwap等都不会调用=,
直接进行内存级别的赋值, 推荐使用.
FastArray有使用ShadedSerialized吗?
首先,
看一下什么是ShadedSerialized:
ShadedSerialized是在多个Connection之间共用一份数据,
省去其中重复执行序列化操作的时间. 同一帧之内起效果, 隔帧失效. 其次,
看一下ShadedSerialized是怎么使用的:
在属性比较之后, Gather出ChangedList(里面存储着RelativeHandle),
针对每个RelativeHandle, 如果该属性开启ShadedSerialized,
并且该帧已经序列化了, 则取出其序列化结果直接使用.
好了, 有了以上基础, 可以看看FastArray了. 针对FastArray,
其调用序列化并发送数据的接口为:FRepLayout::SendProperties_r,
观察期传入参数SharedInfo为空,
则一定不会有ShadedSerialized.


那么能不能改造成支持ShadedSerialized呢?
答案肯定是能的,
参考普通属性的ShadedSerialized,
在FDeltaArrayHistoryState中添加一个成员,
用于存储FastArray的数据, 从目前来看, 其要存放成一个Map成员,
ReplicationID-ShadedInfo,
即针对每个Item,都要建立一个ShadedInfo数据.
FastArray每帧最多只进行一次比较吗? 怎么去重的?

针对FastArray, 这一帧和上一帧的内容比较次数, 在一帧内, 最多只执行一次.
在FRepLayout::PreSendCustomDeltaProperties阶段,
会记录每次执行比较操作的帧数CustomDeltaChangelistState.CompareIndex,
如果该帧执行过比较, 则直接略过. 这里特殊说一下, 如果是第一次执行,
会取出一个新的HistoryItem, 重置, 然后用作这次比较.
如果HistoryList环形buffer超标了, HistoryEnd和HistoryStart重合了,
强制重置第一个HistoryStartItem, 然后将HistoryStart后延,
即强制提供一个可用的HistoryItem.

在比较过程中, 取出History数据, 如果HistoryItem执行过比较, 就直接略过.
如果是第一次, 则FDeltaArrayHistoryItem.bWasUpdated为false,
则进行比较并更新historyList, 否则直接跳过了.

FastArray第一次和谁比较?
和Archetype对比, 根据Changelist同步.
1 | // FastArray RecentDeltaCustom初始化, RecentDeltaCustom每个Connection独一份 |
这样, 在创建Replicator时候, 使用CDO, 对FSendingRepState.RecentCustomDeltaState进行初始化. 所以后续对比的其实是CDO内的FastArray数据.

使用CDO初始化FSendingRepState.RecentCustomDeltaState数据.

总结
FastArray快在哪里
快在Array中间一个元素发生变化, 不会影响其他元素. 而TArray会重传变动后面的所有数据. 因为TArray是按照位置进行比较的, 而FastArray是按照ReplicationID进行比较的.
FastArray本身有哪些问题
- 如果大量连续丢包, 并超过64(HistoryList环形buffer大小), 则传输的数据只能保证最终数据一致, 而不能保证过程中, 某些数据的顺序.例如FastArray中只有Item1和Item2两个元素, 当遇到连续丢包, 并且已经超过了64(HistoryList环形buffer大小), 那么在比较过程中, 就不会从HistoryList中获取数据, 只发送本帧修改的数据, 这样会导致一段时间内只能收到当帧修改的数据. 只有当帧没有数据变化, 才会触发ChangedItem全部发送的逻辑, 即保证最后数据的正确性.
- 没有使用
ShadedSerialized, 当Connection较多时候, 大量使用FastArray会造成性能问题. - 不能保证数组顺序, 例如DS数组中内容为
0,1,2, 同步到客户端就可能是1,2,0. - FastArray内部不支持嵌套FastArray, 如果嵌套了, 会被当做普通结构体进行同步
- 任何数据中嵌套了FastArray, 都会被当做普通结构体进行传输(可以认为是普通数据). 只有FastArray在ParentCmd层级, 才会被真正当做FastArray使用.
- 如果Item过多, 每个RelativeHandle都使用uint32进行数据传输, 那么会非常耗费. 方法1: 可以考虑使用bit替换, 每个成员变量都会用一个bit表示其是否发生了变化, 如果有变化则进行序列化(参考RPC参数的发送).方法2: 或者限制范围, 比如该结构体参数个数为5, 那么可以将RelativeHandle限制在3个bit内. 总结:还要结合项目具体情况使用, 比如某些结构体频繁改动, 并且结构体内部成员很多, 可以使用方法1. 如果某些结构体成员很多, 但内部只有少量数据变动, 但是变动很频繁, 可以使用方法2. 还可以二者结合使用, 动态方式, 比如统计之前的使用状态, 预计下次最优的使用方式.
注意
FastArray没有HistoryChangedList,
只有RetirementList,
并且它只存储ReplicationID-Replicationkey.