Files
Workspace/NETWORK_PITFALLS.md

18 KiB

ShrinkSDK 联网与事件桥接踩坑记录

最后更新:2026-04-07

本文只记录这轮开发里已经实际踩到、定位过或确认过的问题,以及对应的解决办法,避免后续重复走弯路。

1. Kcp-CSharp.dll 无法加载,Unity 编译链断掉

现象:

  • Unity 提示 ShrinkNetwork.Runtime 依赖错误。
  • Assets/ShrinkNetwork/Plugins/Kcp-CSharp.dll 报错: Unable to resolve reference 'System.Runtime.CompilerServices.Unsafe'

根因:

  • Kcp-CSharp.dll 依赖 System.Runtime.CompilerServices.Unsafe.dll,项目里缺失这个运行库。

解决方法:

  • System.Runtime.CompilerServices.Unsafe.dll 放入 Assets/ShrinkNetwork/Plugins/
  • ShrinkNetwork.Runtime.csproj 正确引用该 DLL。

相关位置:

2. EventBase 网络反序列化时报 IsCanceled 写入异常

现象:

  • 网络收到事件后抛出: Error setting value to 'IsCanceled' on 'DemoPlayerHpDeltaEvent'

根因:

  • EventBase 里有一批运行时字段,例如 IsCanceledResult、回调状态等。
  • Newtonsoft 在反序列化网络事件时尝试给这些运行时字段赋值,但这些字段并不应该进网络载荷。

解决方法:

  • EventBase 中仅运行时使用的属性统一加 [IgnoreDataMember]
  • 让网络序列化只保留真正业务字段。

相关位置:

3. 事件总线桥接需要区分广播事件、请求事件、增量事件

现象:

  • 直接把所有 EventBase 当普通广播消息处理,会导致语义混乱:
    • 有返回值的事件无法正确走请求/响应。
    • 增量事件容易重复应用。

根因:

  • ShrinkEventBus 的事件语义比普通网络消息更丰富。
  • 不能只做“事件转发”,必须保留事件类型自己的语义。

解决方法:

  • 广播事件: 使用 [ShrinkNetworkEvent] + [ShrinkNetworkMessage] + IShrinkNetworkMessage
  • 有返回值事件: 再加 [HasResult] + IShrinkNetworkRequest,统一走 RequestResultAsync(...)
  • 增量事件: 再实现 IShrinkNetworkDeltaEvent,按 SessionId + EventType + DeltaKey 做版本抑制

相关位置:

4. TCP 并发写导致包流交叉,出现 JSON 解析异常

现象:

  • 局域网联机时偶发: JsonReaderException: Unexpected character encountered while parsing value
  • 或者之前出现过响应包和广播包混在一起,反序列化直接失败。

根因:

  • 同一个 TCP 连接上如果并发写入多个包,而底层没有串行化保护,字节流可能交错。
  • 这不是“TCP 粘包”本身的问题,而是应用层没有保证单连接写入顺序。

解决方法:

  • 客户端 TCP 传输层增加 _sendLock,每次发送先串行化再写。
  • 服务端 TCP 传输层按 sessionId 建立独立发送锁。
  • 每个包继续保留“4 字节长度头 + 按长度精确读取”的分帧协议。

相关位置:

5. 仅有“分帧正确”还不够,发送端如果假异步,旧状态会越积越多

现象:

  • 玩家持续移动/跳跃一段时间后,远端看到的动作延迟越来越高。
  • 看起来像网络越来越慢,但并不会直接报解析错。

根因:

  • 之前 ShrinkNetworkService.SendAsync(...) 名字是异步,但实际上只是调用 transport.Send(...) 后立刻返回。
  • 对 TCP 来说,这意味着上层以为自己“等发送完成了”,其实只是把发送继续堆在后台。
  • 高频移动状态一多,旧状态就会排队,形成持续累积延迟。

解决方法:

  • 新增 IShrinkNetworkAsyncTransport,为真正能 await 的传输层提供异步发送接口。
  • ShrinkNetworkService 优先调用 SendAsync(...),等待底层实际写出。
  • TCP、KCP、Loopback 传输都对齐到这个接口。

相关位置:

6. Host 本地玩家如果通过 loopback client 接入自己,容易和本地物理链互相打架

现象:

  • Host 玩家移动、跳跃时会出现回弹、回退、抽搐。
  • 即使已经排除了“收到自己状态包”的问题,Host 本地角色仍然不稳定。

根因:

  • Host 本地玩家既是本地物理控制者,又通过本地 TCP/loopback client 作为“远程玩家”接入自己。
  • 这样会同时存在:
    • 本地刚体状态
    • Host 权威状态
    • 本地 client 入站/出站状态
  • 三条链只要任意一条落后一帧,就可能出现本地角色被旧状态拉回。

解决方法:

  • Host 启动后不再自动连 127.0.0.1 把自己当客户端接入。
  • 改为 Host 直接创建本地玩家状态和本地玩家 actor。
  • 本地玩家输入直接更新 Host 权威状态,再只广播给其他会话。

相关位置:

7. 不要把玩家自己的状态再广播回原发送者

现象:

  • Client 跳跃、移动后,自己的日志刷“本地与远端偏差过大”。
  • 看起来像自己被自己拽回去。

根因:

  • Host 收到某个玩家的状态后,如果又把同一份状态回发给原会话,发送者就会收到一份落后的“自己”。
  • 对平台跳跃这种高频状态来说,哪怕只落后几帧,也会表现成回弹。

解决方法:

  • BroadcastStateDeltaAsync(...) 增加 excludedSessionId
  • 广播状态时排除原发送会话。
  • 客户端侧对 delta.PlayerId == _localPlayerId 的状态包直接忽略。

相关位置:

8. 平台跳跃远端表现层不应该继续挂 Rigidbody2D

现象:

  • 远端玩家会出现莫名其妙的回弹、抽搐、延迟积累。
  • 角色看起来像是先被网络推过去,再被物理系统拉回来。

根因:

  • 如果远端玩家也挂 Rigidbody2D,再同时由网络状态直接改位置,就相当于让“网络表现层”和“本地物理系统”共同控制一个对象。
  • 尤其是 Kinematic Rigidbody2D + 手动改位置 + 插值/预测 组合,很容易出现过冲和回拉。

解决方法:

  • 只有本地玩家挂 Dynamic Rigidbody2D
  • 远端玩家只保留纯显示节点,直接显示网络位置,不再参与本地物理模拟。

相关位置:

9. 本地静止时持续刷同步,通常是“物理微抖 + 直接拿原始状态发包”

现象:

  • 两个玩家都站着不动,但版本号仍不断增长。
  • 看起来像网络一直在同步“没变的状态”。

根因:

  • 落地后刚体仍可能有极小速度抖动。
  • 如果直接拿 body.position/body.velocity 原始值做同步比较,就会把微小浮动当成新状态。

解决方法:

  • 发包前先做速度稳定化:
    • 小于阈值的速度直接归零
  • 再做位置/速度量化:
    • 例如位置按 0.02 网格取整
    • 速度按 0.05 网格取整
  • 最后只和“上次真正发出的状态”做比较,不和上一帧原始值比

相关位置:

10. 当前阶段对“TCP 粘包”的结论

结论:

  • 之前的 JSON 解析错误,根因更接近“同一连接并发写导致包流交叉”,不只是泛泛意义上的 TCP 粘包。
  • 当前这轮“回弹、回退、延迟越来越高”的主因,更偏向:
    • 移动状态发送堆积
    • Host 本地 loopback 链
    • 本地物理与远端显示链互相打架
  • 不能再把现阶段所有问题都归因到 TCP 粘包。

11. 后续继续排查时的优先顺序

建议顺序:

  1. 先确认 Host 本地玩家是否已经完全脱离 loopback client 链。
  2. 再确认移动发送是否真的只保留最新状态,没有旧包排队。
  3. 然后确认远端玩家是否已经完全不参与本地物理。
  4. 最后才去调同步阈值、插值参数、速度量化参数。

12. 当前仍需持续观察的点

这些项已经缩小范围,但仍建议继续验证:

  • Host 本地玩家跳跃是否还存在回弹。
  • 远端玩家长时间连续移动后,Host 视角是否仍会持续积压延迟。
  • 平台跳跃同步是否需要进一步拆成:
    • 水平移动状态同步
    • 跳跃事件同步
    • 低频位置校正

如果后续确认“整包状态同步”仍然不够稳,下一步应考虑把平台跳跃改成“输入同步 + 权威校正”或“事件同步 + 状态兜底”方案,而不是继续硬调位置插值参数。

17. init / #nullable 在 Unity 编译链与宿主编译链之间容易出现“只在某一侧炸掉”的兼容断点

现象:

  • ShrinkCommand.Runtime.csproj 或 Unity 编译日志里突然出现: System.Runtime.CompilerServices.IsExternalInit is not defined or imported
  • 同时还会夹杂一批 CS8632,提示 nullable 注解只允许出现在 #nullable 上下文内。

根因:

  • 当前这套 Unity 2022 工程链路里,不同工程对 init / nullable 的支持并不完全一致。
  • 某些源码在 net8.0 宿主侧能过,不代表在 Unity 侧生成的 *.csproj 和编辑器编译链里也一定能过。
  • 如果公开运行时类型直接依赖 init,而工程里又没有稳定提供 IsExternalInit,就会在 Unity 侧先炸。

解决方法:

  • 对需要同时给 Unity 运行时、生成宿主、烟测工程共用的公共类型,优先用普通 set,不要默认上 init
  • nullable 语义不要赌项目级默认值,直接在文件头显式写 #nullable enable
  • 这类问题优先先验证:
    • dotnet build .\ShrinkSDK.sln /m:1
    • dotnet build .\GeneratedServers\ShrinkNetwork.ServerHost\ShrinkNetwork.ServerHost.csproj /nr:false
    • 必要时再看 Unity 编辑器实际编译结果。

相关位置:

18. 生成宿主合同里的数组属性如果不给默认值,会在宿主 nullable 收口时持续回弹 warning

现象:

  • GeneratedServers\ShrinkNetwork.ServerHost\Generated\UnityGeneratedNetworkContracts.g.cs 中类似 public LanPlayerStateDto[] Players { get; set; } 会在宿主构建时触发 CS8618
  • 当前手工把生成文件改好后,一旦下次重新生成,又会回弹。

根因:

  • 生成器的默认值策略之前只处理了 stringstring[],没有泛化到任意 T[]
  • 结果是当前示例里 LanPlayerStateDto[] Players 这种合同属性在 #nullable enable 下会被视为“构造退出时未初始化”。

解决方法:

  • 不只修当前生成文件,更要修生成器源头。
  • GetDefaultInitializer(...) 统一对任意 T[] 输出 Array.Empty<T>()
  • 这样后续重新点“生成完整独立服务器工程 / 刷新 Generated 合同”时不会把 warning 再带回来。

相关位置:

19. 当前这轮验证结论

结论:

  • 截至 2026-04-07,这轮 ShrinkCommand / ShrinkNetwork / 独立服务器生成链路已经过两层验证:
    • 命令行构建为 0 warning / 0 error
    • 用户侧实际测试反馈“没问题”
  • 因此本文件里凡是“当前还没做 Unity 实测”的旧结论,只能视为历史记录,不能再当作当前状态。

13. RPC 关联 id 用裸 int 容易把“普通数字”和“请求令牌”混在一起

现象:

  • 早期 RPC 链路里,ShrinkNetworkPacketShrinkNetworkContextpendingRequests 都直接使用 int rpcId
  • 代码能跑,但一旦日志、调试、请求覆盖、自定义关联这几件事混到一起,读代码和排问题都很容易把“一个普通 int 参数”误当成业务字段。

根因:

  • rpcId 语义上其实是“请求令牌”,不是任意整数。
  • int 会让:
    • 包结构
    • pending 表
    • 调试输出
    • 手工覆写调用选项 之间缺少明确边界。

解决方法:

  • 把整条链改成强类型 ShrinkRequestToken
  • ShrinkRpcCallOptions 增加:
    • RequestTokenOverride
    • DebugLabel
  • 超时、取消、pending miss 等日志统一带 RequestToken,必要时再带 DebugLabel

相关位置:

14. TCP 远端正常关闭连接不该当成异常栈刷屏

现象:

  • 独立服务器或远端主机关闭后,客户端控制台会刷: IOException: Remote closed.
  • 同时还会带一串 LogException 堆栈,看起来像框架内部故障。

根因:

  • ReadExactlyAsync(...) 在读到 0 字节时抛了 IOException("Remote closed.")
  • 接收循环把这种“远端正常断开”也按异常路径记录,噪音很大。

解决方法:

  • 把“远端正常关闭连接”和常见可识别 SocketError 视为正常断连分支。
  • 不再 LogException,而是降级为 warning。
  • 真正的协议错误、反序列化错误、未知传输异常才继续走异常日志。

相关位置:

15. 独立服务器生成器如果只“识别到网络事件”而不自动生成处理器,落地仍然会卡住

现象:

  • 扫描结果里已经能看到 [ShrinkNetworkEvent],但服务端生成产物只有消息合同,没有对应 handler 和模块注册。
  • 结果是:
    • 合同编出来了
    • 消息也注册了
    • 但事件到了服务端后依旧没有地方处理

根因:

  • “识别到类型”不等于“把类型纳入可运行脚手架”。
  • 对插件用户来说,如果还要手工补一层事件 handler 注册,那这一套特性范式就没有真正闭环。

解决方法:

  • 让生成器把网络事件提升为一等公民。
  • 扫描阶段明确区分:
    • 广播型网络事件
    • 远端裁决网络事件
    • 增量网络事件
  • 对未被状态同步范式接管的网络事件消息:
    • 自动生成 UnityGeneratedServerHandlers.g.cs 中的 handler 模板
    • 自动在 UnityGeneratedServerModule.g.cs 中注册 RegisterHandler(...)
    • 同时在 UNITY_GENERATED_SERVER_SCAFFOLD.md 里写清建议动作

相关位置:

16. 生成器要想更泛用,必须依赖“特性范式”,不能继续依赖演示代码命名碰运气

现象:

  • 如果完全靠演示路由名或约定俗成命名去猜服务器模块,生成结果会高度绑定某个 demo。
  • 一旦用户自己的项目换了命名方式,就会退化成“识别不到”或“生成不完整”。

根因:

  • 生成器面向的是插件用户,不是当前仓库里的某一个 LAN 演示。
  • 只靠:
    • join_room
    • move_command
    • player_state_delta 这种固定 route 约定,泛化能力不够。

解决方法:

  • 正式引入并推荐:
    • [ShrinkNetworkMessage]
    • [ShrinkNetworkSubscriber] / [ShrinkNetworkSubscribe]
    • [ShrinkNetworkEvent]
    • [HasResult]
    • IShrinkNetworkDeltaEvent
    • [ShrinkNetworkStateSync("group", role)]
  • 旧 route 约定只保留为回退识别,不再作为主要范式。
  • README 和模板说明同步强调“遵守特性范式,生成结果才最接近开箱即用”。

相关位置: