技术博客

RPC 实现:UFUNCTION(Server/Client) 的调用打包与丢失语义

第 71 / 100 篇 · 2025-04-27 · ue5 · 网络复制RPC源码分析

属性同步解决"状态怎么追平",RPC 解决"事件怎么通知"。两者在底层共用 actor channel 和 bunch 机制,但打包路径和可靠性语义差别很大。基于 UE5.3 源码(NetDriver.cpp、DataChannel.cpp、RepLayout.cpp 的函数复制部分)走一遍。

从 ProcessEvent 到 bunch

客户端调一个 UFUNCTION(Client, Reliable) 函数,表面上和本地函数调用无异:

UFUNCTION(Client, Reliable)
void ClientReceiveHit(const FHitResult& Hit);

本地调用落在 UObject::ProcessEvent,由 UFunction 的 RPC 标志(FUNC_NetClient/FUNC_NetServer)分派到 AActor::CallRemoteFunction(NetDriver.cpp)。之后的路径:

  1. 找到该 actor 在本连接上的 UActorChannel(没有就开一个);
  2. 通过 FObjectReplicator 的函数序列化路径:写函数 ID(不是名字——和属性 handle 同理,是 UFunction 在类里的索引,位打包后几个 bit)+ 每个参数的序列化;
  3. 产出 bunch,SendBunch 进 channel 的可靠队列或直接发。

参数序列化按 FProperty 逐个走,可以自定义:结构体实现 NetSerialize 或标 NetSerializeParams 就能做自己的压缩(FHitResult 的位压缩就是教科书)。注意默认走原生内存拷贝,float 不压缩,Vector 全精度——RPC 的默认带宽开销比属性同步大得多,因为属性有 NetQuantize 的存量优化,RPC 参数没有默认优化。

Reliable 队列:什么时候重传

reliable RPC 的本质是排队进 FOutBunch 的可靠流。每个 channel 有两条 bunch 流:可靠和不可靠,序号独立(OutReliableBunch 计数)。可靠 bunch 发出后挂进 UNetConnection::OutAckBits 等 ack:收到 ack 序号之前的全部确认,没确认的下个 tick 原样重传,直到 ack 或 channel 关闭。

这套机制的直接推论,每条都在项目里踩过:

  • reliable RPC 有序但不保证时效。前面一个 bunch 丢了,后面所有 reliable bunch 全在等它重传——队头阻塞。高频 RPC(比如每帧一次的状态上报)做成 reliable,一次丢包积累出的延迟肉眼可见。
  • 饱和时 reliable 也会丢——不是丢包,是根本没发出去。IsNetReady 为 false 时 reliable bunch 继续排队,缓冲无限增长(5.x 有上限,超了直接断开连接,UNetConnection::Closing)。大面积丢包时你看到掉线,多半是这个缓冲炸了,不是网络物理断了。
  • 不可靠 RPC 的语义是"尽力发一次"。bunch 塞进 packet,packet 没凑满等下个 tick;等的过程中如果连接饱和或 channel 被关闭,这个 bunch 可能就没了。所以"玩家扣血"必须 reliable,"播放一个脚步音效"必须 unreliable。

reliable RPC:发送 → 等 ack → 重传,与不可靠的对比 channel 可靠流(bunch 按序,R 序号递增) R3 ✓ ack R4 ✗ 丢 R5 排队 R6 排队 下个 tick 原样重传 R4 队头阻塞:R4 未 ack 期间,R5/R6 已发送但客户端按序递送,全部等 R4 reliable 保序不保时效 —— 高频 reliable RPC 是延迟的隐形来源 reliable:队列 + ack 重传 必须到达且有序:扣血、开门 unreliable:发一次,可能丢 丢了无所谓的:音效、短暂表现

WithValidation 与安全

UFUNCTION(Server, Reliable, WithValidation) 会在打包时额外生成一个 _Validate 函数,服务端反序列化参数后先跑 Validate 再跑实现。这个机制防的是作弊客户端:反序列化层只保证字节流格式合法,不保证语义合法(比如客户端声称"我瞬移了 1 万公里")。Validate 里做范围检查,不通过就丢弃并记日志:

bool AMyCharacter::ServerMove_Validate(FVector_NetQuantize Loc, float Time)
{
    const float Dist = FVector::Dist(Loc, GetActorLocation());
    return Dist < 2000.f; // 单帧最大位移上界
}

实测里 Validate 漏写等于把信任边界让给了客户端。引擎自带的 ServerMove 校验就是这么做的(CharacterMovement 里),值得抄它的上界思路。

和属性同步的取舍

同一个信息"玩家的血量变了",用 RPC 通知还是属性同步?判断依据是信息的性质:血量是一个持续状态,会再被读(UI、AI 决策、存档),走属性同步(状态以服务端为准,客户端随时可查);受击瞬间的屏幕震动是一次性事件,没有"当前值"概念,走 RPC。混用的典型 bug 是拿 RPC 同步状态——客户端漏收一次就永久不一致,且没有 dormancy 和 relevancy 那套省带宽机制。

实操侧两个命令:net.DebugRPC 日志看 RPC 调用频率(高频 reliable RPC 在带宽统计里很显眼),LogNetTraffic 配合抓包工具分析具体 bunch 内容。