页面加载中
博客快捷键
按住 Shift 键查看可用快捷键
ShiftK
开启/关闭快捷键功能
ShiftA
打开/关闭中控台
ShiftD
深色/浅色显示模式
ShiftS
站内搜索
ShiftR
随机访问
ShiftH
返回首页
ShiftL
友链页面
ShiftP
关于本站
ShiftI
原版/本站右键菜单
松开 Shift 键或点击外部区域关闭
互动
最近评论
暂无评论
标签
寻找感兴趣的领域
暂无标签
    0
    文章
    0
    标签
    8
    分类
    10
    评论
    128
    功能
    深色模式
    标签
    JavaScript12TypeScript8React15Next.js6Vue10Node.js7CSS5前端20
    互动
    最近评论
    暂无评论
    标签
    寻找感兴趣的领域
    暂无标签
      0
      文章
      0
      标签
      8
      分类
      10
      评论
      128
      功能
      深色模式
      标签
      JavaScript12TypeScript8React15Next.js6Vue10Node.js7CSS5前端20
      随便逛逛
      博客分类
      文章标签
      复制地址
      深色模式
      AnHeYuAnHeYu
      Search⌘K
      博客
        暂无其他文档

        Iris 网络复制系统技术分析 - 第九部分:NetBlob 系统

        December 22, 202556 分钟 阅读461 次阅读

        📦 Iris 网络复制系统技术分析 - 第九部分:NetBlob 系统


        🎯 读完你应该能做到什么?

        • 🧠 知道什么时候该用 NetBlob(什么时候属性复制 / RPC 更合适)。

        • 🧵 看懂 Iris 里 NetBlob 的"发送→排队→分片→可靠→接收→分发"链路,遇到问题知道该从哪里查。

        • 🛠️ 能写出一个最小可用的自定义 UNetBlobHandler(并知道它为什么"必须配套")。

        9.1 🚚 一句话理解 NetBlob:大件快递 + 急件插队

        如果把网络同步想成"快递系统"📦:

        • 属性复制(Replicated Property):像"每天送报纸"🗞️,量小、频繁、系统自动。

        • RPC:像"打一通电话"☎️,强调"事件发生了",参数通常不大。

        • NetBlob:像"寄一件大件家具"🛋️ —— 数据体积大、结构特殊、或者你想要更可控的传输方式(分片、可靠、有序、复用序列化)。

        再加一个生活类比:购物排队🛒

        • 普通物品:按队列慢慢结账(普通 Attachment 队列)。

        • 急救药 / 生鲜要化了:可以走"绿色通道"插队(Iris 里 ScheduleAsOOB:Out-Of-Band/立即处理队列)。

        这一章你会反复看到一个核心思想:NetBlob 是"你自己能控制的包裹",Iris 提供"分拣中心/分片/可靠队列/类型分发"这些基础设施。


        9.2 🧭 NetBlob 在 Iris 里的位置:从 Gameplay 到 Packet

        先给你一张"从逻辑到网络包"的总流程图(把抽象概念落到路径上):

        PLAINTEXT
          Gameplay 代码
              |
              | 1) 生成 Blob(附件/RPC/自定义)
              v
          FNetBlobManager::QueueNetObjectAttachment / SendRPC
              |
              | 2) 入队(普通队列 or OOB 急件队列)
              v
          FNetObjectAttachmentSendQueue::ProcessQueue
              |
              | 3) 预序列化
              |    - 能装下:ShrinkWrap(复用序列化结果)
              |    - 装不下:Split(拆成 PartialNetBlob)
              v
          FReplicationWriter::QueueNetObjectAttachments
              |
              | 4) 写入 Packet(可靠/有序由更下层队列保证)
              v
          网络发送 → 网络接收
              |
              v
          反序列化:CreateNetBlob → Deserialize
              |
              v
          FNetBlobHandlerManager::OnNetBlobReceived
              |
              v
          你的 UNetBlobHandler::OnNetBlobReceived(业务逻辑)

        🤔 读这章时你可以带着两个问题:

        1. 📤 谁负责"把我的 Blob 写进包里"?(发送侧)

        2. 📥 谁负责"收到后把 Blob 交给对的人处理"?(接收侧)


        9.3 🧱 FNetBlob:最小抽象与关键标志位

        9.3.1 你至少要认识的三个东西:CreationInfo、Flags、引用计数

        引擎源码摘录(NetBlob.h):

        CPP
        // Engine/Source/Runtime/Experimental/Iris/Core/Public/Iris/ReplicationSystem/NetBlob/NetBlob.h
        
        /** Flags that can be set at blob creation time. */enum class ENetBlobFlags : uint32
        {
        	None = 0,
        
        	/** The blob should be delivered reliably in order with respect to other reliable blobs. Implies Ordered. */
        	Reliable = 1U << 0U,
        
        	/** Used for FRawDataNetBlob derived classes to avoid duplicate serialization when splitting large blob. */
        	RawDataNetBlob = Reliable << 1U,
        
        	/** Used to indicate that this blob have ObjectReferences that might have to be exported. */
        	HasExports = RawDataNetBlob << 1U,
        
        	/** The blob should respect delivery order with respect to other Ordered blobs, including Reliable ones. Unreliable ordered blobs will only be sent once. */
        	Ordered = HasExports << 1U,
        };
        
        struct FNetBlobCreationInfo
        {
        	FNetBlobType Type = InvalidNetBlobType;
        	ENetBlobFlags Flags = ENetBlobFlags::None;
        };
        
        class FNetBlob
        {
        public:
        	// ...
        	bool IsReliable() const { return EnumHasAnyFlags(GetCreationInfo().Flags, ENetBlobFlags::Reliable); }
        	void AddRef() const { ++RefCount; }
        	IRISCORE_API void Release() const;
        	int32 GetRefCount() const { return RefCount; }
        	// ...
        };

        把它翻译成人话🗣️:

        • CreationInfo.Type:像"包裹品类编号"🏷️(由 Handler 系统分配,运行时可能变)。

        • CreationInfo.Flags:像"快递服务选项"📮(是否挂号 Reliable、是否按顺序 Ordered、是否带导出 HasExports)。

        • AddRef/Release:这是 Iris 里 Blob 的生命周期控制方式之一(常见 TRefCountPtr)。

        9.3.2 自定义 Blob 必须实现的核心虚函数

        引擎源码摘录(NetBlob.h):

        CPP
        class FNetBlob
        {
        public:
        	/** Serialize the blob to a bitstream. */
        	virtual void Serialize(FNetSerializationContext& Context) const PURE_VIRTUAL(Serialize,);
        
        	/** Deserialize the blob from a bitstream. */
        	virtual void Deserialize(FNetSerializationContext& Context) PURE_VIRTUAL(Deserialize,);
        
        	/** 
        	 * Serialize with object reference optimization.
        	 * When the blob is associated with an object, this avoids redundantly writing the RefHandle.
        	 */
        	virtual void SerializeWithObject(FNetSerializationContext& Context, FNetRefHandle RefHandle) const;
        	virtual void DeserializeWithObject(FNetSerializationContext& Context, FNetRefHandle RefHandle);
        };

        关键点🔑:

        • Serialize/Deserialize:纯虚函数,自定义 Blob 必须实现,负责把业务数据写入/读出 bitstream。

        • SerializeWithObject/DeserializeWithObject:当 Blob 作为某个对象的附件(FNetObjectAttachment)发送时,系统可能调用这对函数。默认实现会调用普通的 Serialize/Deserialize,但你可以重写它来避免重复写入已知的 RefHandle(因为附件的目标对象引用已经在上层写过了)。

        什么时候用 SerializeWithObject?
        当 bSerializeWithObject = true 时(由 PreSerializeAndSplitNetBlob 根据附件是否绑定对象决定),系统会调用带 Object 的版本。这是一个优化点:如果你的 Blob 内部需要引用它所属的对象,可以直接用传入的 RefHandle,而不是再序列化一遍。

        9.3.3 FNetObjectAttachment:很多 NetBlob 其实是"挂在某个对象上的附件"

        引擎源码摘录(同文件):

        CPP
        class FNetObjectAttachment : public FNetBlob
        {
        public:
        	const FNetObjectReference& GetNetObjectReference() const { return NetObjectReference; }
        	const FNetObjectReference& GetTargetObjectReference() const { return TargetObjectReference; }
        
        protected:
        	friend class Private::FNetBlobManager;
        	FNetObjectReference NetObjectReference;
        	FNetObjectReference TargetObjectReference;
        };

        理解方式:

        • 很多时候你并不是"广播一个纯数据块",而是"给某个对象寄一个附件"(例如 RPC、对象初始化块)。

        • Attachment 的意义在于:发送端/接收端都知道它"属于哪个对象",从而可以走更高效的路径(比如 SerializeWithObject,避免重复写同一个 NetRefHandle)。


        9.4 🧰 Handler 体系:谁负责"开箱验货"

        NetBlob 本体只管"装数据/序列化",业务处理靠 Handler。

        9.4.1 UNetBlobHandler:单类型处理器

        引擎源码摘录(NetBlobHandler.h):

        CPP
        UCLASS(transient, MinimalApi, Abstract)
        class UNetBlobHandler : public UObject, public INetBlobReceiver
        {
        	GENERATED_BODY()
        
        public:
        	TRefCountPtr<FNetBlob> CreateNetBlob(UE::Net::ENetBlobFlags Flags) const;
        	UE::Net::FNetBlobType GetNetBlobType() const { return NetBlobType; }
        
        protected:
        	IRISCORE_API virtual TRefCountPtr<FNetBlob> CreateNetBlob(const FNetBlobCreationInfo&) const override PURE_VIRTUAL(CreateNetBlob, return nullptr;);
        	IRISCORE_API virtual void OnNetBlobReceived(UE::Net::FNetSerializationContext& Context, const TRefCountPtr<FNetBlob>&) override PURE_VIRTUAL(OnNetBlobReceived,);
        
        private:
        	UE::Net::FNetBlobType NetBlobType;
        };

        这段代码告诉你两件很"硬核但很重要"的事实:

        1. 每个 Handler 只负责一种 Blob 类型(NetBlobType)。

        2. 接收端想处理 Blob,必须先 CreateNetBlob(创建正确的派生类实例),然后 Deserialize,最后才能 OnNetBlobReceived。

        9.4.2 FNetBlobHandlerManager:类型分发器(把包裹交给对应柜台)

        引擎源码摘录(NetBlobHandlerManager.cpp):

        CPP
        bool FNetBlobHandlerManager::RegisterHandler(UNetBlobHandler* Handler){
        	const FString& ClassName = Handler->GetClass()->GetName();
        	const UNetBlobHandlerDefinitions* BlobHandlerDefinitions = GetDefault<UNetBlobHandlerDefinitions>();
        	for (const FNetBlobHandlerDefinition& Definition : MakeArrayView(BlobHandlerDefinitions->NetBlobHandlerDefinitions))
        	{
        		if (Definition.ClassName.ToString() == ClassName)
        		{
        			const uint32 Index = static_cast<uint32>(&Definition - BlobHandlerDefinitions->NetBlobHandlerDefinitions.GetData());
        			Handler->NetBlobType = Index;
        			Handlers[Index] = Handler;
        			return true;
        		}
        	}
        	return false;
        }

        用"快递柜"来解释🗄️:

        • UNetBlobHandlerDefinitions 是"快递柜格口列表"(允许有哪些 Handler)。

        • RegisterHandler 的过程是:

          • 你拿着一个快递(Handler)来报到

          • 系统去"格口清单"里找同名柜台

          • 找到了就给你发一个"格口编号"作为 NetBlobType

        也就是说:你自定义 Handler,光写 C++ 还不够,还得确保它能被 Definitions 识别,否则 RegisterHandler 会失败(日志里也会提醒)。

        9.4.3 NetBlobType 在包里占几位?(以及为什么最多 128 个 Handler)

        这部分是写技术博客时非常"加分"的细节,因为它解释了一个看似离谱、但其实很工程的约束:NetBlob 的 Type 在默认实现里只有 7 bit。

        9.4.3.1 🔬 FNetBlob::SerializeCreationInfo:只序列化 Type(7 bit)+ Reliable(1 bit)

        引擎源码摘录(NetBlob.cpp):

        CPP
        void FNetBlob::SerializeCreationInfo(FNetSerializationContext& Context, const FNetBlobCreationInfo& CreationInfo){
        	FNetBitStreamWriter* Writer = Context.GetBitStreamWriter();
        
        	Writer->WriteBits((CreationInfo.Type == 0 ? 0U : 1U), 1U);
        	if (CreationInfo.Type != 0)
        	{
        		Writer->WriteBits(CreationInfo.Type, 7U);
        	}
        
        	// Retain Reliable flag
        	Writer->WriteBits(EnumHasAnyFlags(CreationInfo.Flags, ENetBlobFlags::Reliable) ? 1U : 0U, 1U);
        }

        这意味着✅:

        • Type ∈ 0..127(最多 128 个类型)

        • CreationInfo.Flags 默认只保留 Reliable(其它 Flag 是否序列化,取决于具体派生类/协议设计)

        9.4.3.2 🔬 FNetBlobHandlerManager::Init:源码直接 checkf(< 128)

        引擎源码摘录(NetBlobHandlerManager.cpp):

        CPP
        // Check if FNetBlob::SerializeCreationInfo needs to use more bits for blob type.checkf(BlobHandlerDefinitions->NetBlobHandlerDefinitions.Num() < 128,
        	TEXT("Excessive amount of NetBlobHandlers: %d. This breaks net serialization."),
        	BlobHandlerDefinitions->NetBlobHandlerDefinitions.Num());

        9.4.3.3 📌 "兼容性规则"⚠️

        把上面两段连起来,你就能得出一个很关键的结论:

        • UNetBlobHandlerDefinitions 里的顺序就是 Type 的分配顺序(Index = Type)。

        • 因此 客户端/服务端必须保持同一份 Definitions,且顺序一致,否则就会出现"我以为你寄的是 A,你其实寄的是 B"的灾难级错包。

        可以用一个生活类比来解释:

        • 你们两个人约定"快递柜 12 号格放药品、13 号格放生鲜"。

        • 如果有一天一方把格子顺序调了,另一方不知情,那就是开箱即事故💥。

        9.4.4 示例:一个"聊天消息 Blob"(示例代码,不是引擎源码)💬

        下面是"写博客用的示例实现",核心目的是展示:你需要一个 Blob + 一个 Handler,并保证序列化对称。

        CPP
        // 示例:聊天消息 Blob(建议 Reliable)class FChatMessageBlob : public UE::Net::FNetBlob
        {
        public:
        	FString Sender;
        	FString Msg;
        
        	// 构造函数:必须正确初始化基类的 CreationInfo
        	FChatMessageBlob(const UE::Net::FNetBlobCreationInfo& InCreationInfo)
        		: FNetBlob(InCreationInfo)
        	{
        	}
        
        	virtual void Serialize(UE::Net::FNetSerializationContext& Context) const override
        	{
        		auto* Writer = Context.GetBitStreamWriter();
        		// 注意:实际 API 可能是 WriteString 或需要自己写长度+内容
        		UE::Net::WriteString(Writer, Sender);
        		UE::Net::WriteString(Writer, Msg);
        	}
        
        	virtual void Deserialize(UE::Net::FNetSerializationContext& Context) override
        	{
        		auto* Reader = Context.GetBitStreamReader();
        		UE::Net::ReadString(Reader, Sender);
        		UE::Net::ReadString(Reader, Msg);
        	}
        };
        
        UCLASS()
        class UChatMessageBlobHandler : public UNetBlobHandler
        {
        	GENERATED_BODY()
        
        	virtual TRefCountPtr<UE::Net::FNetBlob> CreateNetBlob(const UE::Net::FNetBlobCreationInfo& Info) const override
        	{
        		// 使用传入的 CreationInfo 构造 Blobreturn new FChatMessageBlob(Info);
        	}
        
        	virtual void OnNetBlobReceived(UE::Net::FNetSerializationContext& Context, const TRefCountPtr<UE::Net::FNetBlob>& Blob) override
        	{
        		const FChatMessageBlob* Chat = static_cast<const FChatMessageBlob*>(Blob.GetReference());
        		UE_LOG(LogTemp, Log, TEXT("Chat from %s: %s"), *Chat->Sender, *Chat->Msg);
        		// TODO:交给 UI/聊天系统
        	}
        };

        关键点🔑:

        1. Blob 构造函数必须接受 FNetBlobCreationInfo 并传给基类,否则 Type 和 Flags 会丢失。

        2. Serialize/Deserialize 必须对称:写什么顺序,读就什么顺序,类型和位数都要一致。

        3. Handler 的 CreateNetBlob 要用传入的 Info,不要自己硬编码 Type(Type 是运行时由 FNetBlobHandlerManager 分配的)。


        9.5 🏭 FNetBlobManager:调度中心与两条队列

        如果说 Handler 是"收货柜台",那 FNetBlobManager 更像"快递分拣中心"🏭。

        9.5.1 初始化时会注册默认 Handler(RPC/分片/大对象)

        引擎源码摘录(NetBlobManager.cpp):

        CPP
        void FNetBlobManager::Init(FNetBlobManagerInitParams& InitParams){
        	BlobHandlerManager.Init();
        	// ... 绑定 ReplicationSystem / Connections / NetRefHandleManager / ObjectReferenceCache
        	RegisterDefaultHandlers();
        	AttachmentSendQueue.Init(this);
        }
        
        void FNetBlobManager::RegisterDefaultHandlers(){
        	RPCHandler = TStrongObjectPtr<UNetRPCHandler>(NewObject<UNetRPCHandler>());
        	RPCHandler->Init(*ReplicationSystem);
        	RegisterNetBlobHandler(RPCHandler.Get());
        
        	PartialNetObjectAttachmentHandlerConfig = GetDefault<UPartialNetObjectAttachmentHandlerConfig>();
        	PartialNetObjectAttachmentHandler = TStrongObjectPtr<UPartialNetObjectAttachmentHandler>(NewObject<UPartialNetObjectAttachmentHandler>());
        	PartialNetObjectAttachmentHandler->Init(InitParams);
        	RegisterNetBlobHandler(PartialNetObjectAttachmentHandler.Get());
        
        	NetObjectBlobHandler = TStrongObjectPtr<UNetObjectBlobHandler>(NewObject<UNetObjectBlobHandler>());
        	RegisterNetBlobHandler(NetObjectBlobHandler.Get());
        }

        要点📌:

        • Iris 自己就会确保"常用的那几类"能工作(RPC、分片、NetObjectBlob)。

        • 你写博客时可以强调:很多功能你不需要手动注册,但自定义 Handler 必须双方都注册。

        9.5.2 入队:QueueNetObjectAttachment 会帮你处理"目标不是可复制对象"的边界

        引擎源码摘录(NetBlobManager.cpp,删减):

        CPP
        bool FNetBlobManager::QueueNetObjectAttachment(uint32 ConnectionId, const FNetObjectReference& TargetRef, const TRefCountPtr<FNetObjectAttachment>& Attachment, ENetObjectAttachmentSendPolicyFlags SendFlags){
        	FOwnerInfo OwnerInfo;
        	OwnerInfo.TargetRef = TargetRef;
        
        	// 解析 Root/SubObject 的 internal index
        	bool bCanSendRpc = GetRootObjectAndSubObjectIndicesFromAnyHandle(TargetRef.GetRefHandle(), OwnerInfo.RootObjectIndex, OwnerInfo.SubObjectIndex);
        	if (!bCanSendRpc)
        	{
        		// 如果 TargetRef 有效但没被复制,尝试用 ReplicatedOuter
        		OwnerInfo.CallerRef = ObjectReferenceCache->GetReplicatedOuter(TargetRef);
        		bCanSendRpc = GetRootObjectAndSubObjectIndicesFromAnyHandle(OwnerInfo.CallerRef.GetRefHandle(), OwnerInfo.RootObjectIndex, OwnerInfo.SubObjectIndex);
        	}
        
        	if (!bCanSendRpc)
        	{
        		// 仍然失败:目标对象不可复制,无法发送return false;
        	}
        
        	Attachment->SetNetObjectReference(OwnerInfo.CallerRef, OwnerInfo.TargetRef);
        	AttachmentSendQueue.Enqueue(ConnectionId, OwnerInfo.RootObjectIndex, OwnerInfo.SubObjectIndex, Attachment, SendFlags);
        	return true;
        }

        FOwnerInfo 结构说明:

        CPP
        struct FOwnerInfo
        {
        	FNetObjectReference CallerRef;      // 实际用于路由的"可复制外层对象"引用
        	FNetObjectReference TargetRef;      // 原始目标对象引用
        	FInternalNetRefIndex RootObjectIndex;   // 根对象的内部索引
        	FInternalNetRefIndex SubObjectIndex;    // 子对象的内部索引(如果有)
        };
        • 比如你想把一个 RPC/Attachment 发给不可复制的组件/子对象(TargetRef 解析 RootObjectIndex/SubObjectIndex 失败),Iris 就会用 ObjectReferenceCache->GetReplicatedOuter(TargetRef) 找到它挂靠的可复制外层对象(例如角色 ACharacter)作为 CallerRef,再用这个"主门牌"解析出 RootObjectIndex/SubObjectIndex,从而把包裹正确投递到"楼栋 + 房间号"。


        9.6 📤 发送侧主流程:入队→预序列化→(ShrinkWrap 或 Split)→写包

        发送侧最关键的"分岔路口"其实就一句话:

        先尝试把 Blob 序列化进一个"阈值大小"的缓冲区,

        • 能写进去:用 ShrinkWrap(后续复用、写包快)

        • 写爆了:Split(变成 FPartialNetBlob 序列)

        这条路径由 UPartialNetObjectAttachmentHandler 负责(默认 Handler 之一)。

        9.6.1 OOB(急件)到底是什么?ScheduleAsOOB 的真实含义 🚑

        在 Iris 里,"急件"不是玄学,它是一条单独的队列。

        引擎源码摘录(NetBlobManager.cpp,删减):

        CPP
        const bool bScheduleUsingOOBAttachmentQueue = EnumHasAnyFlags(SendFlags, ENetObjectAttachmentSendPolicyFlags::ScheduleAsOOB);
        FQueue& TargetQueue = bScheduleUsingOOBAttachmentQueue ? ScheduleAsOOBAttachmentQueue : AttachmentQueue;

        可以用"急诊分诊"来类比:

        • 普通队列:按帧处理(慢一点但稳定)

        • OOB 队列:优先处理、并且会标记连接需要"立即发送"

        9.6.1.1 🚨 "立即发送"是怎么触发的?

        当附件被标记为 OOB 时,系统会把对应的连接 ID 加入 OutConnectionsPendingImmediateSend 集合:

        CPP
        // NetBlobManager.cpp (ProcessQueue 内部)if (bScheduleUsingOOBAttachmentQueue)
        {
        	OutConnectionsPendingImmediateSend.Add(ConnectionId);
        }

        这个集合会在 FReplicationSystem::SendAndFlushPackets 时被检查:

        • 普通附件:等到下一次 Tick 或 SendUpdate 时才会被写入包。

        • OOB 附件:会触发当前帧内的额外发包(FlushNet),尽可能快地把数据送出去。

        注意:"立即发送"不是"绕过网络延迟",而是"不等下一帧,现在就尝试发包"。如果当前帧已经发过包,OOB 会触发额外的发包调用。

        写博客时建议配一个小对比表:

        队列

        生活类比

        触发方式

        发包时机

        适合场景

        普通 Attachment 队列

        普通排队结账 🛒

        默认

        下一次 Tick/SendUpdate

        常规 RPC/附件

        OOB 队列

        急诊绿色通道 🚑

        ScheduleAsOOB

        当前帧立即尝试

        需要尽快送达的附件

        9.6.2 多播 + 连接特化序列化:为什么"有时不能共享 ShrinkWrap"?

        你在 ProcessQueue 会看到一个非常关键的条件分支:

        • 多播(Multicast)时,理想情况是"预序列化一次,发送给所有连接"。

        • 但如果这个 Blob 的 ReplicationStateDescriptor 标记了 HasConnectionSpecificSerialization,那就意味着:

          • 同一份 Blob,对不同连接序列化出来的结果可能不同

          • 这时就不能共享同一个 ShrinkWrap/同一组 Partial 结果

        引擎源码摘录(NetBlobManager.cpp,删减):

        CPP
        const bool bMulticast = Entry.ConnectionId == 0;
        const bool bHasConnectionSpecificSerialization = ReplicationStateDescriptor && EnumHasAnyFlags(ReplicationStateDescriptor->Traits, EReplicationStateTraits::HasConnectionSpecificSerialization);
        
        if (!(bMulticast && bHasConnectionSpecificSerialization) && !PreSerializeAndSplitNetBlob(Entry.ConnectionId, Attachment, PartialNetBlobs, bShouldSendAttachmentsWithObject))
        {
        	// ...
        }
        
        if (bMulticast)
        {
        	for (uint32 ConnectionId : MakeArrayView(ProcessContext.ConnectionIds))
        	{
        		if (bHasConnectionSpecificSerialization)
        		{
        			PartialNetBlobs.Reset();
        			PreSerializeAndSplitNetBlob(ConnectionId, Attachment, PartialNetBlobs, bShouldSendAttachmentsWithObject);
        			AttachmentsView = MakeArrayView(PartialNetBlobs);
        		}
        		Connection->ReplicationWriter->QueueNetObjectAttachments(..., AttachmentsView, ...);
        	}
        }

        一句话总结🧾:

        • 能共享时就共享(ShrikWrap 的价值最大化)。

        • 不能共享时就按连接重做(保证正确性)。


        9.7 🎁 预序列化与 ShrinkWrap:一次打包,多人复用

        9.7.1 UPartialNetObjectAttachmentHandlerConfig:阈值不是"拍脑袋"

        引擎源码摘录(PartialNetObjectAttachmentHandler.h):

        CPP
        class UPartialNetObjectAttachmentHandlerConfig : public USequentialPartialNetBlobHandlerConfig
        {
        public:
        	uint32 GetBitCountSplitThreshold() const { return BitCountSplitThreshold; }
        	uint32 GetClientUnreliableBitCountSplitThreshold() const { return ClientUnreliableBitCountSplitThreshold; }
        	uint32 GetServerUnreliableBitCountSplitThreshold() const { return ServerUnreliableBitCountSplitThreshold; }
        
        private:
        	UPROPERTY(Config)
        	uint32 BitCountSplitThreshold = (128 + 64)*8;
        
        	UPROPERTY(Config)
        	uint32 ClientUnreliableBitCountSplitThreshold = (850)*8;
        
        	UPROPERTY(Config)
        	uint32 ServerUnreliableBitCountSplitThreshold = (256)*8;
        };

        这三行默认值背后的直觉:

        • 分片有额外开销(序列号、首包信息、更多包头),所以"并不是越容易分片越好"。

        • 不可靠数据在不同端的策略不同:客户端/服务端的阈值分别配置。

        9.7.2 关键实现:溢出才 Split,否则 ShrinkWrap

        引擎源码摘录(PartialNetObjectAttachmentHandler.cpp,删减):

        CPP
        bool UPartialNetObjectAttachmentHandler::PreSerializeAndSplitNetBlob(uint32 ConnectionId, const TRefCountPtr<UE::Net::FNetObjectAttachment>& Blob, TArray<TRefCountPtr<FNetBlob>>& OutPartialBlobs, bool bSerializeWithObject){
        	uint32 BitCountSplitThreshold = GetConfig()->GetBitCountSplitThreshold() & ~31U;
        	const bool bIsReliable = EnumHasAnyFlags(Blob->GetCreationInfo().Flags, ENetBlobFlags::Reliable);
        	if (!bIsReliable)
        	{
        		BitCountSplitThreshold = (ReplicationSystem->IsServer() ? GetConfig()->GetServerUnreliableBitCountSplitThreshold() : GetConfig()->GetClientUnreliableBitCountSplitThreshold()) & ~31U;
        	}
        
        	TArray<uint32> Payload;
        	Payload.AddUninitialized(BitCountSplitThreshold/32U);
        	FNetBitStreamWriter Writer;
        	Writer.InitBytes(Payload.GetData(), Payload.Num()*4U);
        
        	// ... 设置 SerializationContext(含 LocalConnectionId)并序列化 Blob
        	Writer.CommitWrites();
        
        	if (Writer.IsOverflown())
        	{
        		return Super::SplitNetBlob(SerializationContext, /*...*/, OutPartialBlobs);
        	}
        	else
        	{
        		FShrinkWrapNetObjectAttachment* ShrinkWrap = new FShrinkWrapNetObjectAttachment(SerializationContext, Blob, MoveTemp(Payload), Writer.GetPosBits());
        		OutPartialBlobs.AddDefaulted_GetRef() = ShrinkWrap;
        		return true;
        	}
        }

        用"打包发货"来解释:

        • 先准备一个"预计能装下的箱子"📦(阈值大小)。

        • 装得下:直接把这个箱子封好贴条(ShrinkWrap),后面发给多少人都是"搬同一个箱子"。

        • 装不下:再去做"拆箱分件"(Split)。

        这也是为什么 ShrinkWrapNetBlob.h 的注释会强调:它是为了"多目的地复用序列化结果"。

        9.7.3 ShrinkWrap 的"接收端视角":它根本不会被 Deserialize ✅

        很多同学第一次看到 FShrinkWrapNetBlob 会以为"接收端也要解开包装"。其实 Iris 的设计更像现实世界的快递:

        • ShrinkWrap 的本质是:发送端把原始 Blob 的序列化结果缓存下来,后续直接把"缓存的 bitstream"写进包里。

        • 对接收端来说:它收到的依然是"原始 Blob 类型的 CreationInfo + 原始 Blob 的 payload"。

        所以你在源码里会看到非常"强硬"的断言:

        引擎源码摘录(ShrinkWrapNetBlob.cpp):

        CPP
        void FShrinkWrapNetBlob::Deserialize(FNetSerializationContext& Context){
        	checkf(false, TEXT("%s"), TEXT("This function should not be called. Contact the networking team."));
        }

        9.7.3.1 ❓ 为什么它还能正确处理 Exports?

        ShrinkWrap 还顺手解决了一个常被忽略的问题:导出(Exports)不能丢。

        引擎源码摘录(ShrinkWrapNetBlob.cpp,构造函数):

        CPP
        NetTokenExportsArray = Context.GetExportContext()->GetBatchExports().NetTokensPendingExportInCurrentBatch;
        CreationInfo.Flags |= NetTokenExportsArray.Num() ? ENetBlobFlags::HasExports : ENetBlobFlags::None;

        对于 FShrinkWrapNetObjectAttachment,它还会把目标对象的引用加入导出(避免目标引用在接收端解析不到):

        CPP
        if (Private::FNetExportContext* ExportContext = Context.GetExportContext())
        {
        	const Private::FObjectReferenceCache* ObjectReferenceCache = Context.GetInternalContext()->ObjectReferenceCache;
        	ObjectReferenceCache->AddPendingExport(*ExportContext, OriginalBlob->GetNetObjectReference());
        	ObjectReferenceCache->AddPendingExport(*ExportContext, OriginalBlob->GetTargetObjectReference());
        }

        ShrinkWrap 不只是"缓存序列化",它还会把"这份缓存里依赖的外部引用/Token"一起打包好,避免接收端出现"数据到了,但引用没到"的半残状态。


        9.8 🧩 真正的分片:FPartialNetBlob 的序列号与首包信息

        9.8.1 为什么需要全局序列号?(避免串包)

        引擎源码摘录(PartialNetBlob.cpp 注释):

        CPP
        // We need a unique sequence number per split blob to detect out of order sequences.// ... sharing a global sequence number avoids bloating the splitting API ...static std::atomic<uint32> PartialNetBlobGlobalSequenceNumber;

        人话🗣️:

        • 分片是"一个大包裹拆成很多小包裹"。

        • 网络上可能乱序/丢包/重传,接收端需要能判断"这些碎片是不是同一趟拆分出来的"。

        • 所以 Iris 用一个全局原子序列号给每次拆分分配一段连续序列号。

        9.8.2 首包携带"原始 CreationInfo"

        引擎源码摘录(FPartialNetBlob::InternalSerialize,删减):

        CPP
        WritePackedUint32(Writer, SequenceNumber);
        if (Writer->WriteBool(IsFirstPart()))
        {
        	WritePackedUint16(Writer, PartCount - 1U);
        	SerializeCreationInfo(Context, OriginalCreationInfo);
        }
        
        WritePackedUint16(Writer, PayloadBitCount);
        Writer->WriteBitStream(Payload.GetData(), 0U, PayloadBitCount);

        关键点🔑:

        • 只有第一片携带:总片数 + OriginalCreationInfo。

        • 其它片只带:序列号 + 自己的 payload。

        9.8.3 Split 算法:先序列化到临时缓冲,再切块

        引擎源码摘录(FPartialNetBlob::SplitNetBlob,删减):

        CPP
        // We have no idea what the internals of the FNetBlob look like. We must serialize it to a temporary buffer.
        TArray<uint32> Payload;
        // Trial and error: grow buffer until serialization fits.// ...
        Blob->Serialize(SubContext);
        // ...
        Writer.CommitWrites();
        CurrentPayloadBitCount = Writer.GetPosBits();
        
        // Reserve sequence numbers for all parts.
        uint32 SequenceNumber = PartialNetBlobGlobalSequenceNumber.fetch_add(PartialBlobCount, std::memory_order_relaxed);
        
        // Copy relevant data into each partial blob.
        FPlatformMemory::Memcpy(PartialBlob->Payload.GetData(), SplitParams.Payload + PayloadWordOffset, PartialBlobWordCount*4U);

        9.8.3.1 📏 扩容重试策略详解

        由于 Blob 的 Serialize 是黑盒(系统不知道它会写多少数据),Split 采用试探性扩容:

        CPP
        // PartialNetBlob.cpp 中的扩容逻辑(简化)
        uint32 PayloadWordCount = InitialPayloadWordCount;  // 初始大小for (;;)
        {
        	Payload.SetNumUninitialized(PayloadWordCount);
        	Writer.InitBytes(Payload.GetData(), PayloadWordCount * 4U);
        	
        	Blob->Serialize(SubContext);
        	Writer.CommitWrites();
        	
        	if (!Writer.IsOverflown())
        	{
        		break;  // 成功,缓冲区够大
        	}
        	
        	// 溢出:扩容重试
        	PayloadWordCount *= 2;  // 翻倍策略
        	
        	if (PayloadWordCount > MaxPayloadWordCount)
        	{
        		// 超过最大限制,报错
        		Context.SetError(GNetError_BitStreamOverflow);
        		return false;
        	}
        }

        关键参数:

        • 初始大小:通常是 BitCountSplitThreshold / 32(即阈值对应的字数)

        • 扩容策略:翻倍(PayloadWordCount *= 2)

        • 最大限制:由 USequentialPartialNetBlobHandlerConfig::MaxPartPayloadByteCount 控制,默认约 64KB

        把它画成流程图就更好懂了:

        PLAINTEXT
          原始 Blob
             |
             | Serialize → 临时 Payload(可能需要扩容重试)
             v
          Payload BitCount 已知
             |
             | 计算 PartCount,申请一段连续 SequenceNumber
             v
          循环切块:
             - Part0:写 OriginalCreationInfo + PartCount
             - PartN:只写 Sequence + Payload

        9.8.4 FNetBlobAssembler:把碎片拼回"原始 Blob"🧩

        上面我们讲的是"怎么拆"。但真正能把功能跑通的关键是"怎么拼"。Iris 的答案是 FNetBlobAssembler:

        • 它不关心你原始 Blob 的具体类型(因为碎片里首包携带了 OriginalCreationInfo)。

        • 它要求:碎片必须按顺序来,否则直接判定序列损坏(尤其是可靠序列)。

        9.8.4.1 🔬 AddPartialNetBlob:顺序校验 + 缓冲拼接

        引擎源码摘录(NetBlobAssembler.cpp,删减):

        CPP
        const uint32 SequenceNumber = PartialNetBlob->GetSequenceNumber();
        const bool bIsFirstPart = PartialNetBlob->IsFirstPart();
        const bool bIsReliable = PartialNetBlob->IsReliable();
        
        if (SequenceNumber != NextSequenceSumber || bIsReliable != bIsProcessingReliable)
        {
        	if (bIsProcessingReliable)
        	{
        		bIsBrokenSequence = true;
        		Context.SetError(NetError_PartialNetBlobSequenceError);
        		return;
        	}
        
        	if (!bIsFirstPart && SequenceNumber != NextSequenceSumber)
        	{
        		bIsBrokenSequence = true;
        		if (bIsReliable)
        		{
        			Context.SetError(NetError_PartialNetBlobSequenceError);
        		}
        		return;
        	}
        }
        
        if (bIsFirstPart)
        {
        	NetBlobCreationInfo = PartialNetBlob->GetOriginalCreationInfo();
        	// 预分配足够大的 Payload 缓冲
        	Payload.SetNumUninitialized((MaxByteCountPerPart*PartCount + 3U)/4U);
        	BitWriter.InitBytes(Payload.GetData(), Payload.Num()*4U);
        }
        
        BitWriter.WriteBitStream(PartialNetBlob->GetPayload(), 0, PayloadBitCount);

        这段代码非常适合用"拼乐高"来讲:

        • 每片都有"编号"(SequenceNumber),你必须按编号拼。

        • 可靠拼装一旦发现中间断了:直接报错(因为可靠语义要求最终必须完整)。

        9.8.4.2 🔬 Assemble:Create 原始 Blob → 用拼好的 bitstream 做一次真正的 Deserialize

        引擎源码摘录(NetBlobAssembler.cpp,删减):

        CPP
        const TRefCountPtr<FNetBlob>& NetBlob = BlobHandler->CreateNetBlob(NetBlobCreationInfo);
        
        // RawDataNetBlob 快路径:直接把 Payload Move 进去if (EnumHasAnyFlags(NetBlob->GetCreationInfo().Flags, ENetBlobFlags::RawDataNetBlob))
        {
        	FRawDataNetBlob* RawDataNetBlob = static_cast<FRawDataNetBlob*>(NetBlob.GetReference());
        	const uint32 PayloadBitCount = BitWriter.GetPosBits();
        	BitWriter = FNetBitStreamWriter();
        	RawDataNetBlob->SetRawData(MoveTemp(Payload), PayloadBitCount);
        }
        else
        {
        	FNetBitStreamReader BitReader;
        	BitReader.InitBits(Payload.GetData(), BitWriter.GetPosBits());
        	FNetSerializationContext ReadContext = Context.MakeSubContext(&BitReader);
        	ReadContext.SetTraceCollector(nullptr);
        
        	NetBlob->Deserialize(ReadContext);
        
        	// Bitstream mismatch?
        	if (BitWriter.GetPosBits() != BitReader.GetPosBits())
        	{
        		Context.SetError(GNetError_BitStreamError);
        		return nullptr;
        	}
        }

        这段代码能讲出两个"非常工程"的点:

        • 接收端并不是把 FPartialNetBlob 交给业务;它会先"拼回原始 Blob",然后再像正常 Blob 一样走 Deserialize。

        • FRawDataNetBlob 有快路径:不需要再走一遍读 bitstream 的反序列化逻辑,直接把 raw bits 搬进对象(省 CPU)。


        9.9 📮 可靠与有序:FReliableNetBlobQueue 的滑动窗口

        如果 FPartialNetBlob 解决的是"包裹太大",那 FReliableNetBlobQueue 解决的是"包裹必须送达且按顺序"。📮

        9.9.1 关键数据结构:窗口大小 1024 + 复制记录(最多 4 段不连续序列)

        引擎源码摘录(ReliableNetBlobQueue.h):

        CPP
        static constexpr uint32 MaxUnackedBlobCount = 1024U;
        
        // A single ReplicationRecord supports up to four disjoint sequences...struct FReplicationRecord
        {
        	FSequence Sequences[4];
        };
        
        inline bool FReliableNetBlobQueue::IsSendWindowFull() const{
        	return (LastSeq - FirstSeq) >= MaxUnackedBlobCount;
        }

        生活类比:

        • 这就是"挂号信窗口":一次最多挂 1024 封待确认的信。

        • FReplicationRecord 支持"最多 4 段不连续"——很好理解:你可能在重传丢失片段的同时,还想塞一点新数据进去。

        9.9.2 发送侧:SerializeInternal 会尽量写满包、支持不连续序列、并在 overflow 时回滚

        引擎源码摘录(ReliableNetBlobQueue.cpp,删减):

        CPP
        for (uint32 Seq = FirstSeq, EndSeq = LastSeq; Seq < EndSeq; ++Seq)
        {
        	const uint32 Index = Seq & IndexMask;  // 环形缓冲区索引
        	
        	// IsIndexSent:检查这个序列号是否已经在当前包里写过了
        	// 用于避免重复写入(比如重传场景下,同一个包可能包含多段序列)
        	if (IsIndexSent(Index)) { continue; }
        
        	FNetBitStreamRollbackScope RollbackScope(*Writer);
        	FNetExportRollbackScope ExportRollbackScope(Context);
        
        	// 不连续序列编码:如果当前 Seq 不是上一个写入的 Seq + 1,需要显式写入 Index
        	// PrevWrittenSeq:上一次成功写入的序列号,用于判断是否连续
        	if (Writer->WriteBool(Seq != PrevWrittenSeq + 1U))
        	{
        		Writer->WriteBits(Index, IndexBitCount);  // 写入实际的环形索引
        	}
        
        	const TRefCountPtr<FNetBlob>& Attachment = Entries[Index].Attachment;
        	const bool bHasData = Attachment.GetRefCount() > 0;  // Unreliable 可能已被释放
        	if (Writer->WriteBool(bHasData))
        	{
        		Attachment->SerializeCreationInfo(Context, Attachment->GetCreationInfo());
        		Attachment->Serialize(Context);
        	}
        
        	// HasMore 标志:告诉接收端是否还有更多数据
        	// 这里先写 false,如果后续还有数据会被覆盖
        	Writer->WriteBool(false); // HasMore
        	
        	if (Writer->IsOverflown()) 
        	{ 
        		ExportRollbackScope.Rollback();  // 回滚导出break;  // RollbackScope 析构时会自动回滚 bitstream
        	}
        	
        	PrevWrittenSeq = Seq;
        	MarkIndexSent(Index);
        }

        关键概念解释:

        • IsIndexSent(Index):位图标记,表示这个环形索引在当前包里是否已经写过。避免重传时重复写入同一数据。

        • PrevWrittenSeq:上一次成功写入的序列号。用于不连续序列编码优化:如果序列号是连续的(Seq == PrevWrittenSeq + 1),只需要 1 bit 表示"连续";否则需要额外写入完整的 Index。

        • HasMore 标志:接收端用它来判断"这个包里是否还有更多 Blob"。发送端在循环中先写 false,如果后续还有数据,会在下一次迭代开始时被新的 WriteBool 覆盖。

        • 回滚作用域(RollbackScope):写到一半包满了就撤回,避免半包脏数据。

        • Unreliable blob 可能被释放(GetRefCount() 检查):不可靠数据只发一次,丢了就算了。

        9.9.3 接收侧:先校验序列号,再 Create,再 Deserialize

        (这段我们在 9.10 会把"交给 Handler"串起来讲。)


        9.10 📥 接收侧主流程:CreateNetBlob→Deserialize→OnNetBlobReceived

        接收侧最容易让新手迷糊的点是:

        谁来 Create?谁来 Deserialize?谁来最终处理?

        答案是:

        • Create:INetBlobReceiver::CreateNetBlob(通常是 FNetBlobHandlerManager)

        • Deserialize:Blob 自己(Deserialize/DeserializeWithObject)

        • 最终处理:FNetBlobHandlerManager::OnNetBlobReceived → 具体 UNetBlobHandler::OnNetBlobReceived

        9.10.1 可靠队列里实际怎么做 Create/Deserialize

        引擎源码摘录(ReliableNetBlobQueue.cpp,删减):

        CPP
        FNetBlobCreationInfo CreationInfo;
        FNetBlob::DeserializeCreationInfo(Context, CreationInfo);
        Blob = BlobReceiver->CreateNetBlob(CreationInfo);
        if (!Blob.IsValid())
        {
        	Context.SetError(GNetError_UnsupportedNetBlob);
        	return DeserializedCount;
        }
        
        Blob->Deserialize(Context);

        一句话总结🧾:

        • 接收端先读 CreationInfo,靠 Type 找到对应 Handler,创建正确的 Blob 类型。

        • 然后 Blob 才能按自己的规则把 payload 解析出来。

        9.10.2 两层队列的关系:FReliableNetBlobQueue vs FNetObjectAttachmentReceiveQueue

        在深入分片处理之前,先理清这两个队列的关系:

        PLAINTEXT
          网络包到达
              |
              v
          FReliableNetBlobQueue::Deserialize*
              |
              | 底层可靠队列:负责可靠/有序语义、序列号校验、重传检测
              | 输出:反序列化后的 FNetBlob(可能是 FPartialNetBlob)
              v
          FNetObjectAttachmentReceiveQueue
              |
              | 上层接收队列:负责分片识别、组装、最终分发
              | - 如果是 FPartialNetBlob → 交给 FNetBlobAssembler 组装
              | - 如果是普通 Blob → 直接入队等待处理
              v
          FNetBlobHandlerManager::OnNetBlobReceived

        为什么要分两层?

        • FReliableNetBlobQueue:专注于"可靠有序传输",不关心 Blob 的具体类型。它只保证:按序列号顺序、可靠地把 Blob 反序列化出来。

        • FNetObjectAttachmentReceiveQueue:专注于"附件的业务处理",包括分片组装、对象关联、最终分发。它知道"分片是一种特殊的 Blob",需要特殊处理。

        9.10.3 接收端如何吃下"分片":延迟处理队列

        如果你只看 FReliableNetBlobQueue,会以为"它 Deserialize 出来的就是最终 Blob"。但一旦启用了分片,接收端实际会多一层:

        • 先把 FPartialNetBlob 收齐

        • 用 FNetBlobAssembler 拼回原始 Blob

        • 再把原始 Blob 放进待处理队列

        这条链路在 AttachmentReplication.cpp 里写得非常清楚。

        9.10.3.1 🔍 如何识别"这是一个 PartialNetBlob"?

        引擎源码摘录(AttachmentReplication.cpp,删减):

        CPP
        PartialNetBlobType = (InitParams.PartialNetObjectAttachmentHandler ? InitParams.PartialNetObjectAttachmentHandler->GetNetBlobType() : InvalidNetBlobType);
        
        bool FNetObjectAttachmentReceiveQueue::IsPartialNetBlob(const TRefCountPtr<FNetBlob>& Blob) const{
        	return Blob.IsValid() && Blob->GetCreationInfo().Type == PartialNetBlobType;
        }

        翻译成人话🗣️:

        • UPartialNetObjectAttachmentHandler 自己也有一个 NetBlobType。

        • 所有的 FPartialNetBlob 都会用这个 Type 发送(也就是说:包裹外箱上写的是"分片件")。

        9.10.3.2 🕒 延迟队列:收到分片就喂给 Assembler,凑齐就组装

        引擎源码摘录(AttachmentReplication.cpp,删减):

        CPP
        if (bIsPartialNetBlob)
        {
        	TUniquePtr<FNetBlobAssembler>& NetBlobAssembler = bIsBlobReliable ? ReliableNetBlobAssembler : UnreliableNetBlobAssembler;
        	if (!NetBlobAssembler.IsValid())
        	{
        		FNetBlobAssemblerInitParams InitParams;
        		InitParams.PartialNetBlobHandlerConfig = PartialNetObjectAttachmentHandler ? PartialNetObjectAttachmentHandler->GetConfig() : nullptr;
        		NetBlobAssembler = MakeUnique<FNetBlobAssembler>();
        		NetBlobAssembler->Init(InitParams);
        	}
        
        	NetBlobAssembler->AddPartialNetBlob(Context, RefHandle, reinterpret_cast<const TRefCountPtr<FPartialNetBlob>&>(NetBlob));
        	if (NetBlobAssembler->IsReadyToAssemble() || NetBlobAssembler->IsSequenceBroken())
        	{
        		if (NetBlobAssembler->IsReadyToAssemble())
        		{
        			const TRefCountPtr<FNetBlob>& AssembledBlob = NetBlobAssembler->Assemble(Context);
        			if (AssembledBlob.IsValid())
        			{
        				Queue.Enqueue(AssembledBlob);
        			}
        		}
        		NetBlobAssembler.Reset();
        	}
        }
        else
        {
        	Queue.Enqueue(NetBlob);
        }

        把它画成"接收侧流水线"会更直观:

        PLAINTEXT
          反序列化得到 NetBlob
                |
                v
          IsPartialNetBlob ?
             |是                 |否
             v                   v
          Assembler.AddPart     直接入队
             |
             +--> ReadyToAssemble ? --是--> Assemble() 得到原始 Blob --> 入队
             |
             +--> SequenceBroken ? --是--> 报错/丢弃并 Reset

        9.10.3.3 🧷 一个细节:可靠/不可靠的分片组装是两套 Assembler

        你会注意到代码里用了两个成员:ReliableNetBlobAssembler 和 UnreliableNetBlobAssembler。

        • 可靠分片:要求严格按序,断了就错。

        • 不可靠分片:更像"尽力而为",并且在某些只处理不可靠的场景,会为了尽快释放队列而主动 Reset()。

        9.10.4 FNetBlobHandlerManager::OnNetBlobReceived:最后一步"分发到柜台"

        引擎源码摘录(NetBlobHandlerManager.cpp,删减):

        CPP
        const FNetBlobCreationInfo& CreationInfo = Blob->GetCreationInfo();
        UNetBlobHandler* Handler = Handlers[CreationInfo.Type].Get();
        return Handler->OnNetBlobReceived(Context, Blob);

        到这里你就能把"端到端链路"闭环了:

        • Type 决定柜台

        • 柜台决定 Create + 处理逻辑

        9.10.5 发送/接收队列全景图(含"窗口满/丢不可靠/重传")🗺️

        上面 9.6~9.10 你已经理解了"分片/可靠/Handler 分发"。但在真实项目里,问题通常出在队列层级:你以为"发了",其实卡在某个队列里;你以为"可靠=必达",其实卡在可靠窗口,导致后续一直 stalled。

        下面这张图把 NetBlob 从"入队"到"被业务处理"的关键队列层级串起来(你排查问题可以按图从上往下走):

        PLAINTEXT
        发送侧(Gameplay → Packet)
        
          Gameplay / RPC / Attachment
                |
                v
          FNetBlobManager::QueueNetObjectAttachment / SendRPC
                |
                | ① 进入调度队列(普通 AttachmentQueue 或 OOB ScheduleAsOOBAttachmentQueue)
                v
          FNetBlobManager::FNetObjectAttachmentSendQueue::ProcessQueue
                |
                | ② 预序列化 & 决策:ShrinkWrap 或 Split(FPartialNetBlob...)
                v
          FReplicationWriter::QueueNetObjectAttachments
                |
                | ③ 进入每连接/每对象的发送队列(AttachmentReplication.cpp)
                v
          FNetObjectAttachmentSendQueue
             |                             |
             | Reliable / Ordered          | Unreliable
             |                             |
             v                             v
          FReliableSendQueue               RingQueue(容量受限,满了丢旧的)
           - PreQueue(可很大)              - 只发一次,丢了就算了
           - FReliableNetBlobQueue(窗口=1024)
                |
                | ④ 写入包(Serialize*)并生成 ReplicationRecord
                v
          Packet
                |
                | ⑤ 通过 PacketNotification 回调交付状态
                v
          ProcessPacketDeliveryStatus(Delivered/Lost)
           - Delivered: 释放已确认/推进窗口
           - Lost: 标记未发送 → 触发重传接收侧(Packet → Gameplay)
        
          Packet
            |
            v
          FReliableNetBlobQueue::Deserialize*
            |
            | ⑥ 先校验序列号,再 Create,再 Deserialize
            v
          得到 NetBlob(可能是 FPartialNetBlob)
            |
            v
          FNetObjectAttachmentReceiveQueue
             | 是 Partial?                     | 否
             v                                vFNetBlobAssembler::AddPartialNetBlob 直接入队
             |
             +--> ReadyToAssemble → Assemble() 得到"原始 Blob"
            |
            v
          FNetBlobHandlerManager::OnNetBlobReceived
            |
            v
          UNetBlobHandler::OnNetBlobReceived(业务)

        9.10.5.1 🧭 排查顺序(你写博客时可以直接给读者一套"定位路径")

        • 发不出去:先看是不是卡在 FNetBlobManager 的两条队列(普通 / OOB)。

        • 发了但不动:看 FNetObjectAttachmentSendQueue 的返回状态是不是 ReliableWindowFull(窗口满会导致可靠附件持续排队)。

        • 收了但业务没触发:看是不是分片没拼齐(FNetBlobAssembler 没有 ReadyToAssemble),或 Type 对不上导致 CreateNetBlob 失败。

        • 偶发"错包/乱序":优先怀疑 UNetBlobHandlerDefinitions 的顺序不一致(Type 分配变化)。

        9.10.5.2 🗺️ 读源码路线图(按"你想回答的问题"索引)

        • 我在 Gameplay 调用后,Blob 到底进了哪个队列?

          • NetBlobManager.cpp:QueueNetObjectAttachment / SendRPC → FNetObjectAttachmentSendQueue::Enqueue。

        • 什么时候 ShrinkWrap,什么时候 Split?

          • PartialNetObjectAttachmentHandler.cpp:PreSerializeAndSplitNetBlob(阈值写爆才 Split)。

        • Split 的序列号、首包携带什么?

          • PartialNetBlob.cpp:InternalSerialize / SplitNetBlob。

        • 可靠窗口/重传/交付状态怎么推进?

          • ReliableNetBlobQueue.h/.cpp:SerializeInternal / ProcessPacketDeliveryStatus / OnPacketDropped / OnPacketDelivered。

        • 为什么我看到 ReliableWindowFull?

          • AttachmentReplication.cpp:FNetObjectAttachmentSendQueue::Serialize(窗口满直接返回 ReliableWindowFull)。

        • 接收侧分片在哪拼?拼完去哪?

          • AttachmentReplication.cpp:FNetObjectAttachmentReceiveQueue(识别 PartialNetBlobType,Assembler 拼完才入队)。

        • 最后怎么分发到我的 Handler?

          • NetBlobHandlerManager.cpp:OnNetBlobReceived / RegisterHandler。


        9.11 🎮 游戏场景:敌人 AI 决策、区域初始化、回放/排行榜

        9.11.1 先做选型:属性复制 vs RPC vs NetBlob(从约束出发)

        你可以把常见需求按三个维度切分:

        • 频率:高频(每帧/每几帧) vs 低频(偶发/每秒级)

        • 体积:小(几十字节) vs 大(上 KB~MB)

        • 一致性要求:必须一致(状态机/结算) vs 允许丢(纯表现)

        快速决策表(博客可直接用):

        需求

        更合适的方式

        原因

        高频、可增量(位置/速度/Anim 状态)

        属性复制

        系统天然做增量/压缩/频控

        低频、小事件(播放音效/表情)

        RPC / Unreliable Blob

        事件语义清晰,丢了影响小

        低频、必须一致(状态机跳转/关键技能)

        Reliable RPC / Reliable Blob

        必达且按序,避免"先爆炸后抬手"

        低频、体积大(区域初始化/快照/回放块)

        NetBlob(可 Split)

        一次性大件,系统提供分片与组装

        9.11.2 敌人 AI 决策:从"需求"到"Flags/SendPolicy"的完整落地

        假设你要同步一个敌人 AI 的"决策结果",并且希望:

        • 客户端表现尽量一致(关键状态不能错)

        • 非关键表现尽量省带宽(丢了也不心疼)

        把数据拆成三层,会非常好讲:

        1. 持续状态(State):例如 MoveMode/TargetId/bIsEnraged → 属性复制。

        2. 关键事件(Event, must happen):例如"进入狂暴""技能释放(参数)" → Reliable + Ordered。

        3. 纯表现事件(Cosmetic, best effort):例如"转头""嘲讽表情""脚步粒子" → Unreliable(可选 Ordered)。

        对比表(升级版:把 SendPolicy 也写出来):

        事件/数据

        推荐方式

        ENetBlobFlags

        ENetObjectAttachmentSendPolicyFlags(直觉)

        解释

        敌人位置/速度

        属性复制

        -

        -

        高频小数据,增量最划算

        转头/表情

        Unreliable RPC / Blob

        Ordered 可选

        一般默认

        丢了最多少一次表现

        进入关键状态(狂暴/无敌)

        Reliable RPC / Blob

        Reliable(隐含 Ordered)

        必要时 ScheduleAsOOB

        必须一致,且最好尽快到

        技能释放(多段连招)

        Reliable + Ordered

        Reliable +(必要时)Ordered

        一般默认

        防止乱序导致表现穿帮

        9.11.2.1 📝 什么时候该用 ScheduleAsOOB?

        • 用在"晚一点就失去意义"的数据(例如"立刻打断/立刻进入无敌帧"的指令)。

        • 但不要滥用:OOB 会改变调度优先级,也会影响"附件随对象一起发"的策略(见 9.6)。

        9.11.2.2 🎯 端到端示例:敌人进入狂暴状态

        下面是一个完整的"敌人进入狂暴"NetBlob 实现,从 Blob 定义到发送到接收处理:

        CPP
        // ========== 1. 定义 Blob ==========// EnragedStateBlob.hclass FEnragedStateBlob : public UE::Net::FNetObjectAttachment
        {
        public:
        	// 狂暴状态数据
        	float DamageMultiplier = 1.0f;
        	float SpeedMultiplier = 1.0f;
        	float Duration = 0.0f;
        	uint8 EnrageLevel = 0;
        
        	FEnragedStateBlob(const UE::Net::FNetBlobCreationInfo& InCreationInfo)
        		: FNetObjectAttachment(InCreationInfo)
        	{
        	}
        
        	virtual void Serialize(UE::Net::FNetSerializationContext& Context) const override
        	{
        		auto* Writer = Context.GetBitStreamWriter();
        		Writer->WriteBits(EnrageLevel, 4);  // 0-15 级狂暴// 使用量化减少带宽
        		UE::Net::WriteQuantizedFloat(Writer, DamageMultiplier, 1.0f, 5.0f, 8);  // 1x-5x, 8bit
        		UE::Net::WriteQuantizedFloat(Writer, SpeedMultiplier, 1.0f, 3.0f, 8);   // 1x-3x, 8bit
        		UE::Net::WriteQuantizedFloat(Writer, Duration, 0.0f, 60.0f, 10);        // 0-60s, 10bit
        	}
        
        	virtual void Deserialize(UE::Net::FNetSerializationContext& Context) override
        	{
        		auto* Reader = Context.GetBitStreamReader();
        		EnrageLevel = Reader->ReadBits(4);
        		UE::Net::ReadQuantizedFloat(Reader, DamageMultiplier, 1.0f, 5.0f, 8);
        		UE::Net::ReadQuantizedFloat(Reader, SpeedMultiplier, 1.0f, 3.0f, 8);
        		UE::Net::ReadQuantizedFloat(Reader, Duration, 0.0f, 60.0f, 10);
        	}
        };
        
        // ========== 2. 定义 Handler ==========// EnragedStateBlobHandler.hUCLASS()
        class UEnragedStateBlobHandler : public UNetBlobHandler
        {
        	GENERATED_BODY()
        
        public:
        	// 用于外部获取 Handler 实例
        	static UEnragedStateBlobHandler* Get(UReplicationSystem* RepSystem);
        
        protected:
        	virtual TRefCountPtr<UE::Net::FNetBlob> CreateNetBlob(
        		const UE::Net::FNetBlobCreationInfo& Info) const override
        	{
        		return new FEnragedStateBlob(Info);
        	}
        
        	virtual void OnNetBlobReceived(
        		UE::Net::FNetSerializationContext& Context,
        		const TRefCountPtr<UE::Net::FNetBlob>& Blob) override
        	{
        		const FEnragedStateBlob* EnragedBlob = 
        			static_cast<const FEnragedStateBlob*>(Blob.GetReference());
        		
        		// 获取目标对象const FNetObjectAttachment* Attachment = 
        			static_cast<const FNetObjectAttachment*>(Blob.GetReference());
        		AActor* TargetActor = ResolveNetObjectReference(Attachment->GetTargetObjectReference());
        		
        		if (AEnemyCharacter* Enemy = Cast<AEnemyCharacter>(TargetActor))
        		{
        			// 应用狂暴状态
        			Enemy->ApplyEnragedState(
        				EnragedBlob->EnrageLevel,
        				EnragedBlob->DamageMultiplier,
        				EnragedBlob->SpeedMultiplier,
        				EnragedBlob->Duration
        			);
        			
        			// 播放客户端表现
        			Enemy->PlayEnrageVFX();
        		}
        	}
        };
        
        // ========== 3. 服务端发送 ==========// EnemyCharacter.cpp (Server)void AEnemyCharacter::Server_EnterEnragedState(uint8 Level, float Duration){
        	// 计算狂暴参数
        	float DamageMult = 1.0f + Level * 0.5f;
        	float SpeedMult = 1.0f + Level * 0.2f;
        
        	// 创建 Blob
        	UE::Net::FNetBlobCreationInfo CreationInfo;
        	CreationInfo.Flags = UE::Net::ENetBlobFlags::Reliable;  // 关键状态,必须可靠
        	
        	TRefCountPtr<FEnragedStateBlob> Blob = new FEnragedStateBlob(CreationInfo);
        	Blob->EnrageLevel = Level;
        	Blob->DamageMultiplier = DamageMult;
        	Blob->SpeedMultiplier = SpeedMult;
        	Blob->Duration = Duration;
        
        	// 获取 NetBlobManager 并发送
        	if (UReplicationSystem* RepSystem = GetReplicationSystem())
        	{
        		FNetBlobManager* BlobManager = RepSystem->GetNetBlobManager();
        		FNetObjectReference TargetRef = RepSystem->GetNetObjectReference(this);
        		
        		// 多播给所有连接(ConnectionId = 0)// 使用 ScheduleAsOOB 让状态尽快送达
        		BlobManager->QueueNetObjectAttachment(
        			0,  // 0 = 多播
        			TargetRef,
        			Blob,
        			ENetObjectAttachmentSendPolicyFlags::ScheduleAsOOB
        		);
        	}
        }

        配置步骤(别忘了!):

        INI
        ; DefaultEngine.ini[/Script/IrisCore.NetBlobHandlerDefinitions]
        +NetBlobHandlerDefinitions=(ClassName="EnragedStateBlobHandler")

        9.11.3 区域初始化:大快照如何避免把可靠窗口打满(并让读者知道该怎么调)

        区域初始化常见的工程约束是:

        • 数据大(上 KB~MB)

        • 必须一致(丢了会导致"这一片地形/掉落/怪物"完全不同步)

        • 通常发生在"进区域/Join In Progress/切换关卡"这种关键时刻

        推荐讲法:把区域初始化当成"一个可分片的 Reliable NetBlob"。

        • Flags:优先 Reliable。

        • 为什么不用堆 RPC:RPC 多条会带来更高的包头/调度开销,并且你要自己处理乱序/去重。

        9.11.3.1 ⚠️ 关键提醒:可靠窗口(1024)不是无限的

        区域快照很大时,Split 会产生很多 FPartialNetBlob。

        • 如果短时间内你给同一连接塞了太多可靠附件,就可能出现 ReliableWindowFull。

        • 这时"看起来像卡死":因为可靠窗口不推进,你后续可靠数据也发不出去。

        • 宁可分批发多个快照 chunk(每个都是一个 Blob),也不要一次性把一个连接的可靠窗口灌爆。

        • 如果你必须一次性很大:至少让读者知道要看 ReliableWindowFull/以及相关队列大小 CVar(见 9.12.4)。

        9.11.4 回放/排行榜:FRawDataNetBlob 适合"我已经有 bitstream 了"

        引擎源码摘录(RawDataNetBlob.h):

        CPP
        // Helper class for stateless data...// Things like splitting and assembling have optimized code paths for this type of blob.class FRawDataNetBlob : public FNetBlob
        {
        	IRISCORE_API void SetRawData(TArray<uint32>&& RawData, uint32 RawDataBitCount);
        	TArrayView<const uint32> GetRawData() const;
        	uint32 GetRawDataBitCount() const;
        };

        一句话:

        • 如果你已经把回放/排行榜压成一段 bitstream,FRawDataNetBlob 能让你少走很多"二次序列化"的弯路(Split/Assemble 还有快路径)。

        9.11.4.1 ⚠️ 这类数据最容易踩的坑:Exports

        回放/排行榜经常包含对象引用、字符串 token、NetToken 等外部依赖。

        • 只要涉及引用,一定要关注 HasExports(见 9.7.3)。

        • 否则会出现"数据到了,但引用没到"的半残状态:读者会以为自己 Serialize/Deserialize 写错,其实是导出没带全。


        9.12 ⚠️ 实用技巧与踩坑清单

        9.12.1 最常见的 6 个坑(写博客很适合做成 Checklist)✅

        • 坑 1:自定义 Handler 注册失败

          • 症状:RegisterHandler 找不到 class name,日志 warning

          • 原因:不在 UNetBlobHandlerDefinitions 的定义列表里

          • 建议:写博客时强调"Definitions 是白名单"

        • 坑 2:序列化/反序列化不对称

          • 症状:接收端读错位、Context 报错、后续包都被污染

          • 建议:对每个字段都成对写,必要时写版本号

        • 坑 3:把大数据做成不可靠

          • 症状:分片丢一片就永远组不回来

          • 建议:大块初始化/快照通常用 Reliable

        • 坑 4:过度分片

          • 症状:吞吐下降、延迟上升、包头开销爆炸

          • 建议:别盲目调大 MaxPartCount,先理解阈值(BitCountSplitThreshold)

        • 坑 5:多播场景忽略复用序列化

          • 症状:CPU 上升,反复 Serialize 同一份数据

          • 建议:理解 ShrinkWrap 的价值:一次序列化,多人发送

        • 坑 6:忽视 HasExports

          • 症状:对象引用/Token 导出不完整,接收端解析失败或引用为空

          • 建议:关注 HasExports 的语义,以及拆分首包携带 exports 的策略

        9.12.2 "调参直觉"📌

        • 分片不是越早越好:先 ShrinkWrap,只有写爆了才 Split。

        • 可靠窗口不是无限大:MaxUnackedBlobCount = 1024,窗口满了就入队失败。

        • 不可靠数据的哲学:它就是"发一次,丢了算了",别指望它像可靠那样重传。

        9.12.3 自定义 Handler 落地步骤:让它进入 Definitions 白名单(并保持双方一致)🧾

        专门解决 坑 1:注册失败。

        • 第 1 步:实现 UNetBlobHandler 子类

          • 你需要实现 CreateNetBlob(...) 和 OnNetBlobReceived(...)。

          • 注意:Handler->GetClass()->GetName() 用的是 UClass 名字(通常不带 U 前缀)。例如 C++ 类 UChatMessageBlobHandler 的 GetName() 常见是 ChatMessageBlobHandler。

        • 第 2 步:确保两端都 RegisterNetBlobHandler

          • 自定义类型要能收,就要"客户端/服务端都注册"(否则会出现接收端 CreateNetBlob 返回空,报 GNetError_UnsupportedNetBlob)。

        • 第 3 步:把 Handler 加入 UNetBlobHandlerDefinitions(白名单)

          • UNetBlobHandlerDefinitions 的 NetBlobHandlerDefinitions 数组是 UPROPERTY(Config),因此你可以在 INI 里配置。

        ⚠️ 重要说明:模块名取决于你的引擎版本和构建配置。在 UE 5.5 中,Iris 核心模块名为 IrisCore。如果配置不生效,请检查:

        1. 确认模块名是否正确(可以在 UNetBlobHandlerDefinitions 的 UCLASS() 宏里查看 config= 参数)

        2. 确认 INI 文件是否被正确加载(DefaultEngine.ini vs 项目特定配置)

        3. 某些版本可能需要在 C++ 中手动添加到数组

        INI
        ; DefaultEngine.ini; 让 NetBlobHandlerManager 能找到你的 Handler(注意:顺序会影响 Type 分配)[/Script/IrisCore.NetBlobHandlerDefinitions]
        +NetBlobHandlerDefinitions=(ClassName="ChatMessageBlobHandler")
        +NetBlobHandlerDefinitions=(ClassName="EnragedStateBlobHandler")

        如果 INI 配置不生效,可以在 C++ 中手动注册:

        CPP
        // 在游戏模块初始化时void FMyGameModule::StartupModule(){
        	// 获取 Definitions 并添加
        	UNetBlobHandlerDefinitions* Definitions = GetMutableDefault<UNetBlobHandlerDefinitions>();
        	
        	FNetBlobHandlerDefinition ChatDef;
        	ChatDef.ClassName = FName(TEXT("ChatMessageBlobHandler"));
        	Definitions->NetBlobHandlerDefinitions.AddUnique(ChatDef);
        }
        • 第 4 步:保证 Definitions 的"内容与顺序"在所有端一致

          • 因为 Type 就是数组下标,而且默认只有 7 bit(最多 128 个)。

          • 写博客时建议加一句醒目的提醒:

            • 不要在已上线版本中随意插入/调整顺序(这会改变 Type,导致跨版本/跨端不兼容)。

        9.12.4 队列/窗口/调试速查:一眼看懂"卡哪了"🧰

        这一小节的目标是:遇到问题时,不用翻半天源码,先用"直觉"定位。

        9.12.4.1 🚧 可靠窗口满(ReliableWindowFull)意味着什么?

        • 本质:FReliableNetBlobQueue 的发送窗口上限是 MaxUnackedBlobCount = 1024。

        • 现象:可靠附件(含 Split 产生的 FPartialNetBlob)无法继续写入包,表现为"可靠数据不再前进"。

        • 处理思路:减少短时间内的可靠附件数量(分批/节流),或者让先前的可靠包更快被 ACK(不要把链路堵死)。

        9.12.4.2 🗑️ 不可靠队列丢弃:为什么你"发了"但看不到?

        • 不可靠附件(Unreliable)如果积压,会触发"丢旧的"策略:为了避免队列无限膨胀。

        • 写博客时建议强调:Unreliable 的哲学是 best-effort,丢了是设计预期。

        9.12.4.3 🧰 常用 CVar 速查(与本文各段落对应)

        这些名字都出自引擎源码(主要在 NetBlobManager.cpp / AttachmentReplication.cpp):

        • RPC/附件开关与行为

          • net.Iris.EnableRPCs

          • net.Iris.RPC.AllowOnDormantObjects

          • net.Iris.RPC.AutoNetFlushOnDormantObjects

          • net.Iris.ThrottleRPCWarnings

        • 发送队列容量(影响"丢弃/积压/窗口前的缓冲")

          • net.UnreliableRPCQueueSize:每对象不可靠队列最大条数(超出会丢旧的)

          • net.ReliableRPCQueueSize:每对象可靠"预队列"容量(用于支持很大的可靠 RPC/附件)

          • net.ClientToServerUnreliableRPCQueueSize:客户端→服务端的不可靠队列大小

          • net.MaxSimultaneousObjectsWithRPCs:同时允许多少对象"有待发送 RPC/附件"

        • Split 阈值(影响"何时拆/拆多少")

          • UPartialNetObjectAttachmentHandlerConfig(见 9.7.1):BitCountSplitThreshold / ClientUnreliableBitCountSplitThreshold / ServerUnreliableBitCountSplitThreshold

        9.12.4.4 🧾 你可以教读者看的 3 条日志/错误(最值回票价)

        • GNetError_UnsupportedNetBlob:通常是 Type 对不上(Definitions 顺序/缺注册)或接收端没注册 Handler。

        • NetError_PartialNetBlobSequenceError:分片序列断了(可靠语义下通常是硬错误)。

        • GNetError_BitStreamError:高概率是 Serialize/Deserialize 不对称,或者读写位置不一致。


        9.13 🧾 本章小结 + 关键源文件索引

        9.13.1 本章一句话总结

        NetBlob 是 Iris 的"可控大包裹"📦:FNetBlobManager 负责排队与调度,UPartialNetObjectAttachmentHandler 负责"先试装再决定拆不拆",FPartialNetBlob 解决"太大要拆",FReliableNetBlobQueue 解决"必须送达且按序",最终由 FNetBlobHandlerManager 按 Type 把包裹交给正确的 UNetBlobHandler 处理。

        9.13.2 关键源文件(写博客时建议放到文末)

        文件

        作用

        Public/Iris/ReplicationSystem/NetBlob/NetBlob.h

        FNetBlob / FNetObjectAttachment / ENetBlobFlags

        Public/Iris/ReplicationSystem/NetBlob/NetBlobHandler.h

        UNetBlobHandler / INetBlobReceiver

        Private/Iris/ReplicationSystem/NetBlob/NetBlobHandlerManager.h/.cpp

        类型分发与注册(UNetBlobHandlerDefinitions 白名单)

        Private/Iris/ReplicationSystem/NetBlob/NetBlobManager.h/.cpp

        调度中心:入队、处理队列、默认 Handler 注册、OOB 队列

        Private/Iris/ReplicationSystem/NetBlob/PartialNetObjectAttachmentHandler.h/.cpp

        预序列化阈值、ShrinkWrap vs Split 的关键分岔

        Public/Private/Iris/ReplicationSystem/NetBlob/PartialNetBlob.h/.cpp

        分片结构、序列号、首包携带原始 CreationInfo

        Public/Private/Iris/ReplicationSystem/NetBlob/ReliableNetBlobQueue.h/.cpp

        可靠有序队列:滑动窗口、重传、记录

        Public/Iris/ReplicationSystem/NetBlob/ShrinkWrapNetBlob.h

        ShrinkWrap 思想与两个包装类型

        Public/Iris/ReplicationSystem/NetBlob/RawDataNetBlob.h

        原始 bitstream Blob(优化分片/组装路径)


        本文档基于 Unreal Engine 5.5.0 Iris 源代码分析(源码目录:Engine/Source/Runtime/Experimental/Iris/)

        最后更新于 April 13, 2026
        On this page
        暂无目录