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
:唯一确定当前处理的是哪个UObject
ContentBlockBody=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
.