8.Unity网络开发实践项目总结

  1. 8.总结
    1. 8.1 知识点
      1. 主要内容
      2. 学习的主要内容
      3. 为什么没有进行实际的网络游戏开发实践
      4. 为什么要进行这个实践优化
      5. 关于同步模式
      6. 关于本套课程的深度
    2. 8.2 核心要点速览
      1. 工程与协议资源放在哪
      2. 旧实现在哪些地方痛
      3. 本系列准备怎么改
      4. 拆包与消费者骨架(便于和后面课对照)
      5. Handler:队列里从消息变成处理者
      6. MessagePool:双字典做表驱动创建
      7. 从字节到业务的一条线
      8. 编辑器里生成 Handler 与整表 MessagePool
      9. 断线:Close(isSelf) 只是钩子
      10. 实现顺序建议
    3. 8.3 面试题精选
      1. 基础题
        1. 1. 为什么说旧写法容易违背开放封闭原则?
          1. 题目
          2. 深入解析
          3. 答题示例
          4. 参考文章
        2. 2. 队列里为什么存 BaseHandler 而不是继续存 BaseMessage?
          1. 题目
          2. 深入解析
          3. 答题示例
          4. 参考文章
        3. 3. 工具生成 *Handler.cs 时,为何文件已存在就跳过、不覆盖?
          1. 题目
          2. 深入解析
          3. 答题示例
          4. 参考文章
        4. 4. Close(bool isSelf) 里的 isSelf 在业务上区分什么?
          1. 题目
          2. 深入解析
          3. 答题示例
          4. 参考文章
      2. 进阶题
        1. 1. 固定 8 字节头加 switch (msgID) 既能对付粘包分包,又会在工程上翻车——具体指什么?
          1. 题目
          2. 深入解析
          3. 答题示例
          4. 参考文章
        2. 2. MessagePool 接到收包循环后,管道里还剩下哪些与「具体协议」强相关的地方?
          1. 题目
          2. 深入解析
          3. 答题示例
          4. 参考文章
        3. 3. 菜单里为何按「先生成消息、再 GenerateMsgPool」?池子代码为何要拦重复 ID 与同名消息?
          1. 题目
          2. 深入解析
          3. 答题示例
          4. 参考文章
        4. 4. 被动断线时,Close 里先发 QuitMessage 再关 socket——可能出什么问题?重连前还有哪些本地状态常被忽略?
          1. 题目
          2. 深入解析
          3. 答题示例
          4. 参考文章
      3. 深度题
        1. 1. 收包在后台、Update 里队列消费这套模型,解决的主矛盾是什么?代价是什么?
          1. 题目
          2. 深入解析
          3. 答题示例
          4. 参考文章
        2. 2. 课里叫「消息池」,实现却是 Activator.CreateInstance——和真正的对象池差在哪?什么时候要升级?
          1. 题目
          2. 深入解析
          3. 答题示例
          4. 参考文章
        3. 3. 手写 MessagePool 过渡到「整文件生成」时,单一事实源应放在哪?如何控制哪些文件能动、哪些不能动?
          1. 题目
          2. 深入解析
          3. 答题示例
          4. 参考文章
        4. 4. 课里断线只留「弹面板」占位——和真正能上线的重连还差哪些维度?
          1. 题目
          2. 深入解析
          3. 答题示例
          4. 参考文章

8.总结


8.1 知识点

主要内容

学习的主要内容

为什么没有进行实际的网络游戏开发实践

为什么要进行这个实践优化

关于同步模式

关于本套课程的深度


8.2 核心要点速览

工程与协议资源放在哪

  • 编辑器专用Editor/ProtocolBuffersEditor/ProtocolTool。前者放 Protobuf 与编辑器侧依赖,后者是「点菜单生成 C#」的自制小工具;它们只在 Unity 编辑器里跑,目录上和 Scripts 下那套进包体的代码分开,避免工具链和运行时缠在一处。
  • 运行时协议脚本:生成出来的协议类型放在 Scripts/Protocol 一类目录,游戏内序列化、反序列化都引用这里。
  • Net 里有什么:在工程里你会看到 BaseDataBaseMessage,TCP/UDP 的同步与异步两套管理器,HttpManagerFtpManager,以及心跳、PlayerMessage 等示例消息类——这是前置章节已实现的通用网络层,本实践是在它之上改消息如何扩展、如何分发,而不是从零写 socket。

旧实现在哪些地方痛

  • 网络管理类改动面集中:收字节、切包、按 ID 决定类型、入队……如果都码在一个管理器里,每来一种新消息就要打开同一个文件改多处,和「对扩展开放、对修改关闭」是反着的。
  • **拆包处按 msgID 写长 switch:典型流程是先看缓冲里够不够 8 字节头——前 4 字节是消息 ID,后 4 字节是正文长度——再判断剩余字节够不够一整个消息体;够则 new 对应的 BaseMessage 子类并反序列化。能正确处理粘包、分包,但每个 ID 一个 case**,协议一多,文件就长成一堵墙。
  • 主线程消费再来一遍分支Update 里对 receiveQueueDequeue,对取出的 BaseMessageswitch 模式匹配(例如 case PlayerMessage msg:),逐字段写业务。写法比一直 as 再判空干净,但本质仍是一种消息一个分支,和拆包那边的 switch 是同一类维护负担。

本系列准备怎么改

  • 字典记「消息 ID → 具体类型」:用映射表代替无限增长的 switch 去找「这个 ID 对应哪种消息类」,后面配合反射或工厂,可以做到加协议时少改管道代码
  • 一类消息一个 Handler:把「这条消息到了以后要做什么」从网络管理器挪到独立小类里;管道只负责收包、建对象、找到 Handler 调一下,业务迭代尽量不动主干。

拆包与消费者骨架(便于和后面课对照)

收包循环里(示意,与课中截图一致):只要 cacheNum - nowIndex >= 8 就读 ID 与长度;只有长度合法且剩余字节够一整条,才 switch (msgID) 决定具体类型并 Reading。主线程侧则是 if (receiveQueue.Count > 0) 出队,再按对象运行时类型分支。

// 包头:msgID 4 字节 + bodyLength 4 字节
msgID = BitConverter.ToInt32(cacheBytes, nowIndex); nowIndex += 4;
msgLength = BitConverter.ToInt32(cacheBytes, nowIndex); nowIndex += 4;
// nowIndex 起 msgLength 字节为正文,满足条件时再 unread/Reading

Handler:队列里从消息变成处理者

  • receiveQueueQueue<BaseMessage> 换成 Queue<BaseHandler>:在切出一条完整消息之后,就创建好对应的 PlayerMessageHandler(示例),把反序列化得到的 BaseMessage 赋给 baseHandler.message整颗 Handler 入队
  • Update 里不再 switch (baseMessage),只做 **DequeueMessageHandle()**;每种消息的业务写在自己 Handler 的 MessageHandle 里,用 message as PlayerMessage 取强类型字段。
  • 渐进重构:可以先只改队列与 Update(第 3 课),收包处仍用手写 switch + new 具体 Handler;再到第 4 课用池化注册把前面的 switch 也干掉

MessagePool:双字典做表驱动创建

说明
数据结构 两个 Dictionary<int, Type>:一个记 消息 ID → BaseMessage 子类,一个记 同一 ID → BaseHandler 子类;构造函数或初始化阶段 Register 成对登记。
取出 GetMessage(id) / GetHandler(id) 内部 Activator.CreateInstance 各 new 一份;**ID 未登记则返回 null**,管道里要自己判空,避免对空引用 Reading
接到收包循环 BaseMessage msg = messagePool.GetMessage(msgID)msg != nullReading(cacheBytes, nowIndex)BaseHandler handler = messagePool.GetHandler(msgID)handler.message = msgEnqueue(handler)

命名提醒:这里的 MessagePool 在课里是 「映射表 + 反射创建」,解决的是扩展时少改管道;和游戏中常说的 Object Pool(实例复用、压 GC)不是同一层概念——后者要另做租借与归还,正文尚未实现。

从字节到业务的一条线

  1. HandleReceiveMsg 在缓冲区上按 8 字节头 + 长度兜住粘包、分包。
  2. 合法消息体:MessagePool 按 ID new 消息反序列化,再 new Handler、绑 message,入队。
  3. 主线程 Update:出队只调 MessageHandle,具体打印或改场景都进子类。

编辑器里生成 Handler 与整表 MessagePool

  • GenerateMsg 写完消息类后顺手写 Handler:与消息同目录 .../Msg/{类名}.cs 落盘后,再拼 {类名}Handler : BaseHandlerMessageHandle 里默认只有 msg = message as {类名} 的空壳。若 File.Exists(...Handler.cs) 已为真则跳过——防止重复生成把你在 Handler 里写好的逻辑整体覆盖;要重出骨架就删掉该 Handler 文件再点一次生成。
  • GenerateMsgPool 专职刷注册表:读 同一套 XMLmessage 节点,把 id、name、namespace 收进列表;重复 ID重复消息类名LogError(课里也提示:即使不同命名空间也尽量别同名,否则拼出来的 typeof(xxxHandler) 与引用容易乱)。生成的类放在 namespace Pool,路径一般为 Scripts/Protocol/Pool/MessagePool.cs,构造函数里多行 Register(id, typeof(消息类), typeof(消息类名+Handler)),并在文件头 using 各消息所在命名空间,这样 typeof 才能解析。
  • 菜单侧 pipelineProtocolTool/生成C#脚本 里按 GenerateEnumGenerateDataGenerateMsgGenerateMsgPool 调用,最后 AssetDatabase.Refresh,Project 窗口立刻能看到新文件。顺序上先有消息与 Handler 桩,再扫一遍消息节点拼池子,Register 行与 XML 一致。

断线:Close(isSelf) 只是钩子

  • Close(bool isSelf = false) 仍沿用前几课的收尾:先发 QuitMessage、再 Shutdown / Disconnect / Close、置空 socketisSelf 表示这一次是不是玩家/本地逻辑主动发起的断线
  • !isSelf 末尾:留给对端关连接、收发失败、心跳超时等场景——典型是弹网络提示、展示重连按钮、做指数退避再 Connect;课里只留了注释,和具体玩法、是否要回到登录、是否要补快照无关。
  • **谁传 true / false**:用户点「断开」「退出对局」调 Close(true)Send/Receive 失败、socket 已断等路径宜 Close(false),这样才会走进「被动断线」分支。销毁单例时通常 Close(true),避免关进程还弹重连层。
  • 落地还应自洽的点(正文未细写,复习时心里有数):被动断线时 socket 可能已不可用,阻塞 Send 可能抛异常cacheBytes / cacheNumreceiveQueue 是否在重连前清空,要避免「旧包当新连接第一条吃进去」。

实现顺序建议

  1. 按课中目录把 Editor 工具链与 Scripts/ProtocolNet 拷入工程,先能生成协议、再能跑通原有收发。
  2. 用表格列出现有消息 ID / 消息类型 / 拆包处是否分支 / Update 是否分支,标出「每加一条协议要改几个地方」。
  3. 再进入字典、Handler、代码生成工具与断线重连——先把找类型执行业务两条链路分开,再谈自动化。

8.3 面试题精选

基础题

1. 为什么说旧写法容易违背开放封闭原则?

题目

结合「网络管理器经常要改」的语境,说明拆包与主线程分发各自在哪里引入了集中修改。

深入解析
  • 开放封闭:加功能应多靠新增类型与注册,少靠改旧类的内部细节
  • 本系列语境:每加一种网络消息,往往要在收包解析里为新 ID 补 case,在 Update 队列消费里再为新子类补分支;改动都落在少数几个「中枢」文件上,消息越多,改动越频繁,回归面越大。
答题示例

新消息一来就要打开管理器:拆包要认新 ID,主线程又要处理新类型,全是改同一块中心代码。

这和开放封闭相反——扩展点应该稳定,通过挂接新 Handler、登记映射完成,而不是反复改管道。

参考文章
  • 2.需求分析

2. 队列里为什么存 BaseHandler 而不是继续存 BaseMessage

题目

结合 Update 里从 switch (baseMessage) 到只调用 MessageHandle 的改动,说明这一换类型的直接收益。

深入解析
  • BaseMessage 时,主线程必须按运行时类型分支才知道干什么;存 Handler 时,分支被提前封装进多态Update 只认识抽象 MessageHandle,具体逻辑在子类。
  • 拆包阶段已经知道 msgID,顺手配好「消息 + 处理者」再入队,主线程消费路径短且稳定,后续加消息主要加 Handler 类并在注册表里挂 ID。
答题示例

继续存消息就要在 Update 里对每种类型写 case;改成 Handler 后,出队只调一次虚方法,业务分散在各个 Handler 里。

本质上用多态换掉主线程的大 switch,扩展时少动网络循环代码。

参考文章
  • 3.Handler类

3. 工具生成 *Handler.cs 时,为何文件已存在就跳过、不覆盖?

题目

结合 GenerateMsgFile.Existscontinue 的写法,说明设计意图与想避免的坑。

深入解析
  • Handler 生成的是可编辑业务代码,不像纯数据结构那样每次全量覆盖无所谓;开发者会在 MessageHandle 里写日志、改模型、驱动 UI。
  • 跳过已存在文件:重复点菜单时只更新消息类(当次循环里消息仍重写),不动已有 Handler,避免把手工逻辑冲掉。需要新骨架时按课中做法删掉旧 Handler 再生成。
答题示例

MessageHandle 里会手写业务,覆盖就等于把改动全没了,所以 Exist 就 continue。

消息类可以反复生成对齐协议,Handler 更像「脚手架 + 人工填充」,要重来就删文件再跑工具。

参考文章
  • 5.工具生成Handler类

4. Close(bool isSelf) 里的 isSelf 在业务上区分什么?

题目

结合被动断线要走「弹面板 / 重连」、OnDestroy 要关连接两种场景,说明参数该何时为 true、何时为 false

深入解析
  • true:玩家或本地系统主动结束会话——点断开、切场景销毁网络管理器前等;一般不触发「意外断线」类 UI。
  • false对端关闭、收发错误、网络闪断等;Close 末尾 !isSelf 分支里接重连/提示。课中实现里收发失败路径应显式传 false,与默认参数一致亦可。
答题示例

true 表示自己打算断,false 表示被断或出错;只有 false 才去弹重连那种东西。

玩家点退出用 true,socket 报错用 false,销毁单例若不想弹窗也用 true

参考文章
  • 7.断线重连

进阶题

1. 固定 8 字节头加 switch (msgID) 既能对付粘包分包,又会在工程上翻车——具体指什么?

题目

说明包头如何支撑循环切包;再说明当消息种类变多时,switch 会带来什么维护成本。

深入解析
  • 切包:缓冲区里是字节流;先保证有足够字节读出 ID 与长度,再按长度截取一条完整消息,半条留待下次、多条则循环处理,这是粘包、分包共用的套路。
  • 工程成本switch 与具体协议枚举强耦合;协议迭代时易漏改、易冲突;测试要覆盖所有分支;多人协作时合并冲突集中。主线程若再 mirror 一套类型分支,两处都要维护
答题示例

8 字节头让解析循环知道「这一条有多长」,不怕粘在一块或半截先到。

麻烦在于每个 msgID 都要写 case,协议上百条时文件不可读,改一处要牵动测试矩阵——所以要换成表驱动和 Handler。

参考文章
  • 2.需求分析

2. MessagePool 接到收包循环后,管道里还剩下哪些与「具体协议」强相关的地方?

题目

说明 GetMessage / GetHandler / Reading 的职责分工;再说明未知消息 ID 时该怎么表现、注册表要在哪里维护。

深入解析
  • 分工:池(注册表)只负责 ID→Type→实例Reading 仍由具体 BaseMessage 子类实现,把字节填进字段;Handler 只读已经填好的 message
  • 未知 IDGetMessage 返回 null 时不应继续 Reading;可打日志、丢弃本条或走统一错误处理——课中示例用 if (baseMessage != null) 拦住。
  • 仍强相关处Register 列表仍要随协议增长而更新;第 5、6 课用工具生成池与 Handler 声明,就是为了少手改这一段。
答题示例

网络管理器里不再写满 case 1001: 这种具体类名,改成按 ID 查表 new

但 ID 和类型的对应关系还在注册表里,未知 ID 要判空;注册表本身可以用代码生成维护。

参考文章
  • 4.消息池类
  • 3.Handler类

3. 菜单里为何按「先生成消息、再 GenerateMsgPool」?池子代码为何要拦重复 ID 与同名消息?

题目

联系 ProtocolTool 中的调用顺序,以及 GenerateMsgPool 里对 ids / names 的去重校验。

深入解析
  • 顺序GenerateMsg 负责落地各 Msg 与(若不存在)Handler 桩GenerateMsgPool 再按 XML 汇总 Register 全表。池依赖「消息类 + Handler 类名约定」已存在或可生成;先消息后池,避免池引用尚未生成的类型(实际若同批一次跑完也可用,但逻辑上池是依赖消息定义与命名约定的一步)。
  • 重复 ID:运行时无法区分路由,必错;工具层直接 LogError 把配置错误暴露在编辑器里。
  • 同名消息:生成池时用 typeof({name}) / typeof({name}Handler)跨命名空间同名会让 using 与类型解析模糊;课中建议从协议配置层规避。
答题示例

消息和 Handler 桩先落盘,池子再根据 XML 把 ID 和类型对上,一次菜单全刷完。

重复 ID 上线必炸,同名类在 typeofusing 上容易掐架,所以在生成阶段就报错最省钱。

参考文章
  • 6.工具生成消息池类
  • 5.工具生成Handler类

4. 被动断线时,Close 里先发 QuitMessage 再关 socket——可能出什么问题?重连前还有哪些本地状态常被忽略?

题目

联系异常路径下 Send 的行为,以及半包缓冲、消息队列与旧 SocketAsyncEventArgs 复用。

深入解析
  • Quit 包:socket 已半断或底层错时,同步 Send 可能抛异常或阻塞;生产里常加 try/catch 或直接 teardown,视协议是否必须优雅通知对端。
  • 半包缓存cacheNumnowIndex 若不归零,下一次连上可能把残余字节当成新流,解析错乱。
  • 队列receiveQueue 里未处理的 Handler 可能是上一局的包,重连后应清空或整体切换会话上下文。
  • 异步收包:是否复用同一个 SocketAsyncEventArgs、是否在断线时取消挂起 IO,都和具体封装的健壮性有关——课里是示例级做法。
答题示例

断透了再 Send 可能直接异常,要有 try 或干脆跳过 Quit。

重连前要清缓冲和队列,否则上一连接剩的半条消息会污染下一条连接;生产环境还要注意异步接收句柄别和旧 socket 绑着。

参考文章
  • 7.断线重连

深度题

1. 收包在后台、Update 里队列消费这套模型,解决的主矛盾是什么?代价是什么?

题目

联系 receiveQueue 与在 Updateswitch 消息对象的做法,说明线程与 Unity API 约束;并说明若不重构分发结构会有什么问题。

深入解析
  • 主矛盾:Socket 回调往往不在主线程,而 Unity 引擎 API 要求在主线程调用;所以常见做法是解析与对象构造可靠近 IO 线程,入队;Update 出队后再碰场景、UI、组件。
  • 代价:安全换来了消费侧仍可能是一坨分支;本系列后续用 ID→类型→Handler 把分支从管理器里撕开,线程模型不用动,复杂度却能降下来。
答题示例

网络线程里直接改 GameObject 会踩线程安全雷区,所以把消息送进队列,主线程 Update 里再处理。

但若处理端还是巨型 switch,只是换了个线程排队而已——扩展性照样差,所以要引入 Handler 和映射表。

参考文章
  • 2.需求分析

2. 课里叫「消息池」,实现却是 Activator.CreateInstance——和真正的对象池差在哪?什么时候要升级?

题目

对比当前每消息一次 new 消息与 Handler 的做法,与「池化复用实例」要解决的问题。

深入解析
  • 当前实现:名称沿用了「池」的叫法,实际是 工厂 + 字典映射;每条消息到来都可能分配新对象,简单、行为直观,短协议列表足够用。
  • 真·对象池:预分配或按需增长一层 可用实例队列Rent / Return 重置状态后复用,主要扛 高 QPS、大包体、GC 抖动;Handler 无状态或只挂当前 message 时,也有人选择单例 Handler + 每次换绑 message,那是另一类优化。
  • 升级时机:Profiler 里网络消息路径 GC 占比明显、或移动端卡顿与收包频率相关时,再考虑池化或 Handler 复用;同时要处理 实例归还时机与线程安全,比课中示例复杂一档。
答题示例

现在的「消息池」是 ID 映射到类型,每次来包就 new,方便教学。

真对象池是反复用同一块内存与同一些实例,省分配;帧率高、消息密的时候再考虑,并要自己处理清空状态和归还。

参考文章
  • 4.消息池类

3. 手写 MessagePool 过渡到「整文件生成」时,单一事实源应放在哪?如何控制哪些文件能动、哪些不能动?

题目

结合 XML 配置、GenerateMsg 覆盖消息但跳过已有 Handler、以及 GenerateMsgPool 整文件写入 MessagePool.cs,谈协作与 Git 冲突风险。

深入解析
  • 事实源:协议意图应以 XML(或等价表) 为准;生成器是**从配置投影到 C#**,避免人在两处手改对不上。
  • 能动性划分:课中策略是 消息类可反复覆写、Handler 已存在则不覆盖、池文件通常整文件重建——团队规范要明确「Handler 与业务改动不入生成覆盖范围」,池与消息是否提交、是否进 CI 要在项目里定规矩。
  • Git:生成文件大批量变动易冲突,常见做法是配置合并、生成物由 CI 产出或约定一人收口;若池文件手改一行就会被下次生成冲掉,就要么禁止手改池、要么改生成模板而不是改产物。
答题示例

以 XML 为准,生成 C#;消息可以每次都覆盖对齐协议,Handler 用 Exist 跳过保护手写逻辑。

池子一般是整文件输出,别在生成物里打补丁,要改就改工具或模板;否则下一次生成全没,合并也痛苦。

参考文章
  • 6.工具生成消息池类
  • 5.工具生成Handler类

4. 课里断线只留「弹面板」占位——和真正能上线的重连还差哪些维度?

题目

从会话恢复、服务器配合、客户端状态机三方面,各挑至少一条与「只关 socket + 再 Connect」不同的要点。

深入解析
  • 会话层:重连后往往要 重新鉴权(Token)、房间/战局是否保留、服务端是否要求断线续玩命令或踢旧连接;否则新 TCP 只是空管道。
  • 状态同步:客户端是否在断线期间丢了属性快照;重连后需要 全量下发追帧/关键帧,不能假设内存模型仍正确。
  • 客户端 UX 与防滥用退避重连、连续失败回登录、禁止双 Connect、销毁与重入边界,都比课中注释复杂一档。
答题示例

课里只管本地关连接和再连一条 TCP;上线还要Token、房间还在不在、服务端认不认这条新连接。

客户端可能要在重连后拉快照或补帧,再加上重试间隔和并发连接控制,否则体验和安全都不稳。

参考文章
  • 7.断线重连


转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 785293209@qq.com

×

喜欢就点赞,疼爱就打赏