Files
Workspace/NETWORK_PITFALLS.md
T

379 lines
18 KiB
Markdown

# 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。
相关位置:
- [ShrinkNetwork.Runtime.csproj](D:\UnityBuilds\ShrinkSDK\ShrinkNetwork.Runtime.csproj)
- [Assets/ShrinkNetwork/Plugins/System.Runtime.CompilerServices.Unsafe.dll](D:\UnityBuilds\ShrinkSDK\Assets\ShrinkNetwork\Plugins\System.Runtime.CompilerServices.Unsafe.dll)
## 2. `EventBase` 网络反序列化时报 `IsCanceled` 写入异常
现象:
- 网络收到事件后抛出:
`Error setting value to 'IsCanceled' on 'DemoPlayerHpDeltaEvent'`
根因:
- `EventBase` 里有一批运行时字段,例如 `IsCanceled``Result`、回调状态等。
- Newtonsoft 在反序列化网络事件时尝试给这些运行时字段赋值,但这些字段并不应该进网络载荷。
解决方法:
-`EventBase` 中仅运行时使用的属性统一加 `[IgnoreDataMember]`
- 让网络序列化只保留真正业务字段。
相关位置:
- [EventBase.cs](D:\UnityBuilds\ShrinkSDK\Assets\ShrinkEventBus\Runtime\EventBase.cs)
## 3. 事件总线桥接需要区分广播事件、请求事件、增量事件
现象:
- 直接把所有 `EventBase` 当普通广播消息处理,会导致语义混乱:
- 有返回值的事件无法正确走请求/响应。
- 增量事件容易重复应用。
根因:
- `ShrinkEventBus` 的事件语义比普通网络消息更丰富。
- 不能只做“事件转发”,必须保留事件类型自己的语义。
解决方法:
- 广播事件:
使用 `[ShrinkNetworkEvent] + [ShrinkNetworkMessage] + IShrinkNetworkMessage`
- 有返回值事件:
再加 `[HasResult] + IShrinkNetworkRequest`,统一走 `RequestResultAsync(...)`
- 增量事件:
再实现 `IShrinkNetworkDeltaEvent`,按 `SessionId + EventType + DeltaKey` 做版本抑制
相关位置:
- [ShrinkNetworkEventBusBridge.cs](D:\UnityBuilds\ShrinkSDK\Assets\ShrinkNetwork.Integration.EventBus\ShrinkNetworkEventBusBridge.cs)
- [ShrinkNetworkDeltaEventContracts.cs](D:\UnityBuilds\ShrinkSDK\Assets\ShrinkNetwork.Integration.EventBus\ShrinkNetworkDeltaEventContracts.cs)
- [README.md](D:\UnityBuilds\ShrinkSDK\Assets\ShrinkNetwork.Integration.EventBus\README.md)
## 4. TCP 并发写导致包流交叉,出现 JSON 解析异常
现象:
- 局域网联机时偶发:
`JsonReaderException: Unexpected character encountered while parsing value`
- 或者之前出现过响应包和广播包混在一起,反序列化直接失败。
根因:
- 同一个 TCP 连接上如果并发写入多个包,而底层没有串行化保护,字节流可能交错。
- 这不是“TCP 粘包”本身的问题,而是应用层没有保证单连接写入顺序。
解决方法:
- 客户端 TCP 传输层增加 `_sendLock`,每次发送先串行化再写。
- 服务端 TCP 传输层按 `sessionId` 建立独立发送锁。
- 每个包继续保留“4 字节长度头 + 按长度精确读取”的分帧协议。
相关位置:
- [ShrinkTcpClientTransport.cs](D:\UnityBuilds\ShrinkSDK\Assets\ShrinkNetwork\Runtime\ShrinkTcpClientTransport.cs)
- [ShrinkTcpServerTransport.cs](D:\UnityBuilds\ShrinkSDK\Assets\ShrinkNetwork\Runtime\ShrinkTcpServerTransport.cs)
## 5. 仅有“分帧正确”还不够,发送端如果假异步,旧状态会越积越多
现象:
- 玩家持续移动/跳跃一段时间后,远端看到的动作延迟越来越高。
- 看起来像网络越来越慢,但并不会直接报解析错。
根因:
- 之前 `ShrinkNetworkService.SendAsync(...)` 名字是异步,但实际上只是调用 `transport.Send(...)` 后立刻返回。
- 对 TCP 来说,这意味着上层以为自己“等发送完成了”,其实只是把发送继续堆在后台。
- 高频移动状态一多,旧状态就会排队,形成持续累积延迟。
解决方法:
- 新增 `IShrinkNetworkAsyncTransport`,为真正能 await 的传输层提供异步发送接口。
- `ShrinkNetworkService` 优先调用 `SendAsync(...)`,等待底层实际写出。
- TCP、KCP、Loopback 传输都对齐到这个接口。
相关位置:
- [IShrinkNetworkAsyncTransport.cs](D:\UnityBuilds\ShrinkSDK\Assets\ShrinkNetwork\Runtime\IShrinkNetworkAsyncTransport.cs)
- [ShrinkNetworkService.cs](D:\UnityBuilds\ShrinkSDK\Assets\ShrinkNetwork\Runtime\ShrinkNetworkService.cs)
- [ShrinkTcpClientTransport.cs](D:\UnityBuilds\ShrinkSDK\Assets\ShrinkNetwork\Runtime\ShrinkTcpClientTransport.cs)
- [ShrinkTcpServerTransport.cs](D:\UnityBuilds\ShrinkSDK\Assets\ShrinkNetwork\Runtime\ShrinkTcpServerTransport.cs)
- [ShrinkLoopbackTransport.cs](D:\UnityBuilds\ShrinkSDK\Assets\ShrinkNetwork\Runtime\ShrinkLoopbackTransport.cs)
- [ShrinkKcpClientTransport.cs](D:\UnityBuilds\ShrinkSDK\Assets\ShrinkNetwork\Runtime\ShrinkKcpClientTransport.cs)
## 6. Host 本地玩家如果通过 loopback client 接入自己,容易和本地物理链互相打架
现象:
- Host 玩家移动、跳跃时会出现回弹、回退、抽搐。
- 即使已经排除了“收到自己状态包”的问题,Host 本地角色仍然不稳定。
根因:
- Host 本地玩家既是本地物理控制者,又通过本地 TCP/loopback client 作为“远程玩家”接入自己。
- 这样会同时存在:
- 本地刚体状态
- Host 权威状态
- 本地 client 入站/出站状态
- 三条链只要任意一条落后一帧,就可能出现本地角色被旧状态拉回。
解决方法:
- Host 启动后不再自动连 `127.0.0.1` 把自己当客户端接入。
- 改为 Host 直接创建本地玩家状态和本地玩家 actor。
- 本地玩家输入直接更新 Host 权威状态,再只广播给其他会话。
相关位置:
- [ShrinkLanMovementDemoController.cs](D:\UnityBuilds\ShrinkSDK\Assets\Scenes\ShrinkLanMovementDemoController.cs)
## 7. 不要把玩家自己的状态再广播回原发送者
现象:
- Client 跳跃、移动后,自己的日志刷“本地与远端偏差过大”。
- 看起来像自己被自己拽回去。
根因:
- Host 收到某个玩家的状态后,如果又把同一份状态回发给原会话,发送者就会收到一份落后的“自己”。
- 对平台跳跃这种高频状态来说,哪怕只落后几帧,也会表现成回弹。
解决方法:
- `BroadcastStateDeltaAsync(...)` 增加 `excludedSessionId`
- 广播状态时排除原发送会话。
- 客户端侧对 `delta.PlayerId == _localPlayerId` 的状态包直接忽略。
相关位置:
- [ShrinkLanMovementDemoController.cs](D:\UnityBuilds\ShrinkSDK\Assets\Scenes\ShrinkLanMovementDemoController.cs)
## 8. 平台跳跃远端表现层不应该继续挂 `Rigidbody2D`
现象:
- 远端玩家会出现莫名其妙的回弹、抽搐、延迟积累。
- 角色看起来像是先被网络推过去,再被物理系统拉回来。
根因:
- 如果远端玩家也挂 `Rigidbody2D`,再同时由网络状态直接改位置,就相当于让“网络表现层”和“本地物理系统”共同控制一个对象。
- 尤其是 `Kinematic Rigidbody2D + 手动改位置 + 插值/预测` 组合,很容易出现过冲和回拉。
解决方法:
- 只有本地玩家挂 `Dynamic Rigidbody2D`
- 远端玩家只保留纯显示节点,直接显示网络位置,不再参与本地物理模拟。
相关位置:
- [ShrinkLanMovementDemoController.cs](D:\UnityBuilds\ShrinkSDK\Assets\Scenes\ShrinkLanMovementDemoController.cs)
## 9. 本地静止时持续刷同步,通常是“物理微抖 + 直接拿原始状态发包”
现象:
- 两个玩家都站着不动,但版本号仍不断增长。
- 看起来像网络一直在同步“没变的状态”。
根因:
- 落地后刚体仍可能有极小速度抖动。
- 如果直接拿 `body.position/body.velocity` 原始值做同步比较,就会把微小浮动当成新状态。
解决方法:
- 发包前先做速度稳定化:
- 小于阈值的速度直接归零
- 再做位置/速度量化:
- 例如位置按 0.02 网格取整
- 速度按 0.05 网格取整
- 最后只和“上次真正发出的状态”做比较,不和上一帧原始值比
相关位置:
- [ShrinkLanMovementDemoController.cs](D:\UnityBuilds\ShrinkSDK\Assets\Scenes\ShrinkLanMovementDemoController.cs)
## 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 编辑器实际编译结果。
相关位置:
- [ShrinkCommandService.cs](D:\UnityBuilds\ShrinkSDK\Assets\ShrinkCommand\Runtime\Core\ShrinkCommandService.cs)
- [ShrinkCommandTypes.cs](D:\UnityBuilds\ShrinkSDK\Assets\ShrinkCommand\Runtime\Metadata\ShrinkCommandTypes.cs)
- [ShrinkCommandRegHelper.cs](D:\UnityBuilds\ShrinkSDK\Assets\ShrinkCommand\Runtime\Routing\ShrinkCommandRegHelper.cs)
## 18. 生成宿主合同里的数组属性如果不给默认值,会在宿主 nullable 收口时持续回弹 warning
现象:
- `GeneratedServers\ShrinkNetwork.ServerHost\Generated\UnityGeneratedNetworkContracts.g.cs` 中类似
`public LanPlayerStateDto[] Players { get; set; }`
会在宿主构建时触发 `CS8618`
- 当前手工把生成文件改好后,一旦下次重新生成,又会回弹。
根因:
- 生成器的默认值策略之前只处理了 `string``string[]`,没有泛化到任意 `T[]`
- 结果是当前示例里 `LanPlayerStateDto[] Players` 这种合同属性在 `#nullable enable` 下会被视为“构造退出时未初始化”。
解决方法:
- 不只修当前生成文件,更要修生成器源头。
- `GetDefaultInitializer(...)` 统一对任意 `T[]` 输出 `Array.Empty<T>()`
- 这样后续重新点“生成完整独立服务器工程 / 刷新 Generated 合同”时不会把 warning 再带回来。
相关位置:
- [ShrinkDedicatedServerScaffoldGenerator.cs](D:\UnityBuilds\ShrinkSDK\Assets\ShrinkNetwork\Editor\Scaffolding\ShrinkDedicatedServerScaffoldGenerator.cs)
- [UnityGeneratedNetworkContracts.g.cs](D:\UnityBuilds\ShrinkSDK\GeneratedServers\ShrinkNetwork.ServerHost\Generated\UnityGeneratedNetworkContracts.g.cs)
## 19. 当前这轮验证结论
结论:
- 截至 2026-04-07,这轮 `ShrinkCommand` / `ShrinkNetwork` / 独立服务器生成链路已经过两层验证:
- 命令行构建为 `0 warning / 0 error`
- 用户侧实际测试反馈“没问题”
- 因此本文件里凡是“当前还没做 Unity 实测”的旧结论,只能视为历史记录,不能再当作当前状态。
## 13. RPC 关联 id 用裸 `int` 容易把“普通数字”和“请求令牌”混在一起
现象:
- 早期 RPC 链路里,`ShrinkNetworkPacket``ShrinkNetworkContext``pendingRequests` 都直接使用 `int rpcId`
- 代码能跑,但一旦日志、调试、请求覆盖、自定义关联这几件事混到一起,读代码和排问题都很容易把“一个普通 int 参数”误当成业务字段。
根因:
- `rpcId` 语义上其实是“请求令牌”,不是任意整数。
-`int` 会让:
- 包结构
- pending 表
- 调试输出
- 手工覆写调用选项
之间缺少明确边界。
解决方法:
- 把整条链改成强类型 `ShrinkRequestToken`
- `ShrinkRpcCallOptions` 增加:
- `RequestTokenOverride`
- `DebugLabel`
- 超时、取消、pending miss 等日志统一带 `RequestToken`,必要时再带 `DebugLabel`
相关位置:
- [ShrinkNetworkRpc.cs](D:\UnityBuilds\ShrinkSDK\Assets\ShrinkNetwork\Runtime\Metadata\ShrinkNetworkRpc.cs)
- [ShrinkNetworkPacket.cs](D:\UnityBuilds\ShrinkSDK\Assets\ShrinkNetwork\Runtime\Metadata\ShrinkNetworkPacket.cs)
- [ShrinkNetworkContext.cs](D:\UnityBuilds\ShrinkSDK\Assets\ShrinkNetwork\Runtime\Core\ShrinkNetworkContext.cs)
- [ShrinkNetworkRouter.cs](D:\UnityBuilds\ShrinkSDK\Assets\ShrinkNetwork\Runtime\Routing\ShrinkNetworkRouter.cs)
- [ShrinkNetworkService.cs](D:\UnityBuilds\ShrinkSDK\Assets\ShrinkNetwork\Runtime\Core\ShrinkNetworkService.cs)
## 14. TCP 远端正常关闭连接不该当成异常栈刷屏
现象:
- 独立服务器或远端主机关闭后,客户端控制台会刷:
`IOException: Remote closed.`
- 同时还会带一串 `LogException` 堆栈,看起来像框架内部故障。
根因:
- `ReadExactlyAsync(...)` 在读到 0 字节时抛了 `IOException("Remote closed.")`
- 接收循环把这种“远端正常断开”也按异常路径记录,噪音很大。
解决方法:
- 把“远端正常关闭连接”和常见可识别 `SocketError` 视为正常断连分支。
- 不再 `LogException`,而是降级为 warning。
- 真正的协议错误、反序列化错误、未知传输异常才继续走异常日志。
相关位置:
- [ShrinkTcpClientTransport.cs](D:\UnityBuilds\ShrinkSDK\Assets\ShrinkNetwork\Runtime\Transport\Tcp\ShrinkTcpClientTransport.cs)
## 15. 独立服务器生成器如果只“识别到网络事件”而不自动生成处理器,落地仍然会卡住
现象:
- 扫描结果里已经能看到 `[ShrinkNetworkEvent]`,但服务端生成产物只有消息合同,没有对应 handler 和模块注册。
- 结果是:
- 合同编出来了
- 消息也注册了
- 但事件到了服务端后依旧没有地方处理
根因:
- “识别到类型”不等于“把类型纳入可运行脚手架”。
- 对插件用户来说,如果还要手工补一层事件 handler 注册,那这一套特性范式就没有真正闭环。
解决方法:
- 让生成器把网络事件提升为一等公民。
- 扫描阶段明确区分:
- 广播型网络事件
- 远端裁决网络事件
- 增量网络事件
- 对未被状态同步范式接管的网络事件消息:
- 自动生成 `UnityGeneratedServerHandlers.g.cs` 中的 handler 模板
- 自动在 `UnityGeneratedServerModule.g.cs` 中注册 `RegisterHandler(...)`
- 同时在 `UNITY_GENERATED_SERVER_SCAFFOLD.md` 里写清建议动作
相关位置:
- [ShrinkDedicatedServerScaffoldGenerator.cs](D:\UnityBuilds\ShrinkSDK\Assets\ShrinkNetwork\Editor\Scaffolding\ShrinkDedicatedServerScaffoldGenerator.cs)
- [UnityGeneratedServerHandlers.g.cs](D:\UnityBuilds\ShrinkSDK\GeneratedServers\ShrinkNetwork.ServerHost\Generated\UnityGeneratedServerHandlers.g.cs)
- [UnityGeneratedServerModule.g.cs](D:\UnityBuilds\ShrinkSDK\GeneratedServers\ShrinkNetwork.ServerHost\Generated\UnityGeneratedServerModule.g.cs)
- [UNITY_GENERATED_SERVER_SCAFFOLD.md](D:\UnityBuilds\ShrinkSDK\GeneratedServers\ShrinkNetwork.ServerHost\Generated\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 和模板说明同步强调“遵守特性范式,生成结果才最接近开箱即用”。
相关位置:
- [README.md](D:\UnityBuilds\ShrinkSDK\Assets\ShrinkNetwork\README.md)
- [README.md](D:\UnityBuilds\ShrinkSDK\Assets\ShrinkNetwork.Integration.EventBus\README.md)
- [README.md.txt](D:\UnityBuilds\ShrinkSDK\Assets\ShrinkNetwork\Editor\Scaffolding\ServerProjectTemplate\README.md.txt)