8.总结
8.1 知识点

主要内容

学习的主要内容

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

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

关于同步模式

关于本套课程的深度

8.2 核心要点速览
工程与协议资源放在哪
- 编辑器专用:
Editor/ProtocolBuffers、Editor/ProtocolTool。前者放 Protobuf 与编辑器侧依赖,后者是「点菜单生成 C#」的自制小工具;它们只在 Unity 编辑器里跑,目录上和Scripts下那套进包体的代码分开,避免工具链和运行时缠在一处。 - 运行时协议脚本:生成出来的协议类型放在
Scripts/Protocol一类目录,游戏内序列化、反序列化都引用这里。 Net里有什么:在工程里你会看到BaseData、BaseMessage,TCP/UDP 的同步与异步两套管理器,HttpManager、FtpManager,以及心跳、PlayerMessage等示例消息类——这是前置章节已实现的通用网络层,本实践是在它之上改消息如何扩展、如何分发,而不是从零写 socket。
旧实现在哪些地方痛
- 网络管理类改动面集中:收字节、切包、按 ID 决定类型、入队……如果都码在一个管理器里,每来一种新消息就要打开同一个文件改多处,和「对扩展开放、对修改关闭」是反着的。
- **拆包处按
msgID写长switch:典型流程是先看缓冲里够不够 8 字节头——前 4 字节是消息 ID,后 4 字节是正文长度——再判断剩余字节够不够一整个消息体;够则new对应的BaseMessage子类并反序列化。能正确处理粘包、分包,但每个 ID 一个case**,协议一多,文件就长成一堵墙。 - 主线程消费再来一遍分支:
Update里对receiveQueue做Dequeue,对取出的BaseMessage做switch模式匹配(例如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:队列里从消息变成处理者
receiveQueue从Queue<BaseMessage>换成Queue<BaseHandler>:在切出一条完整消息之后,就创建好对应的PlayerMessageHandler(示例),把反序列化得到的BaseMessage赋给baseHandler.message,整颗 Handler 入队。Update里不再switch (baseMessage),只做 **Dequeue→MessageHandle()**;每种消息的业务写在自己 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 != null 时 Reading(cacheBytes, nowIndex) → BaseHandler handler = messagePool.GetHandler(msgID) → handler.message = msg → Enqueue(handler)。 |
命名提醒:这里的 MessagePool 在课里是 「映射表 + 反射创建」,解决的是扩展时少改管道;和游戏中常说的 Object Pool(实例复用、压 GC)不是同一层概念——后者要另做租借与归还,正文尚未实现。
从字节到业务的一条线
HandleReceiveMsg在缓冲区上按 8 字节头 + 长度兜住粘包、分包。- 合法消息体:
MessagePool按 ID new 消息、反序列化,再 new Handler、绑message,入队。 - 主线程
Update:出队只调MessageHandle,具体打印或改场景都进子类。
编辑器里生成 Handler 与整表 MessagePool
GenerateMsg写完消息类后顺手写 Handler:与消息同目录.../Msg/{类名}.cs落盘后,再拼{类名}Handler : BaseHandler,MessageHandle里默认只有msg = message as {类名}的空壳。若File.Exists(...Handler.cs)已为真则跳过——防止重复生成把你在 Handler 里写好的逻辑整体覆盖;要重出骨架就删掉该 Handler 文件再点一次生成。GenerateMsgPool专职刷注册表:读 同一套 XML 的message节点,把 id、name、namespace 收进列表;重复 ID、重复消息类名会LogError(课里也提示:即使不同命名空间也尽量别同名,否则拼出来的typeof(xxxHandler)与引用容易乱)。生成的类放在namespace Pool,路径一般为Scripts/Protocol/Pool/MessagePool.cs,构造函数里多行Register(id, typeof(消息类), typeof(消息类名+Handler)),并在文件头using各消息所在命名空间,这样typeof才能解析。- 菜单侧 pipeline:
ProtocolTool/生成C#脚本里按GenerateEnum→GenerateData→GenerateMsg→GenerateMsgPool调用,最后AssetDatabase.Refresh,Project 窗口立刻能看到新文件。顺序上先有消息与 Handler 桩,再扫一遍消息节点拼池子,Register行与 XML 一致。
断线:Close(isSelf) 只是钩子
Close(bool isSelf = false)仍沿用前几课的收尾:先发QuitMessage、再Shutdown/Disconnect/Close、置空socket;isSelf表示这一次是不是玩家/本地逻辑主动发起的断线。!isSelf末尾:留给对端关连接、收发失败、心跳超时等场景——典型是弹网络提示、展示重连按钮、做指数退避再Connect;课里只留了注释,和具体玩法、是否要回到登录、是否要补快照无关。- **谁传
true/false**:用户点「断开」「退出对局」调Close(true);Send/Receive失败、socket 已断等路径宜Close(false),这样才会走进「被动断线」分支。销毁单例时通常Close(true),避免关进程还弹重连层。 - 落地还应自洽的点(正文未细写,复习时心里有数):被动断线时
socket可能已不可用,阻塞Send可能抛异常;cacheBytes/cacheNum与receiveQueue是否在重连前清空,要避免「旧包当新连接第一条吃进去」。
实现顺序建议
- 按课中目录把
Editor工具链与Scripts/Protocol、Net拷入工程,先能生成协议、再能跑通原有收发。 - 用表格列出现有消息 ID / 消息类型 / 拆包处是否分支 / Update 是否分支,标出「每加一条协议要改几个地方」。
- 再进入字典、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 时,为何文件已存在就跳过、不覆盖?
题目
结合 GenerateMsg 里 File.Exists 与 continue 的写法,说明设计意图与想避免的坑。
深入解析
- 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。 - 未知 ID:
GetMessage返回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 上线必炸,同名类在
typeof和using上容易掐架,所以在生成阶段就报错最省钱。
参考文章
- 6.工具生成消息池类
- 5.工具生成Handler类
4. 被动断线时,Close 里先发 QuitMessage 再关 socket——可能出什么问题?重连前还有哪些本地状态常被忽略?
题目
联系异常路径下 Send 的行为,以及半包缓冲、消息队列与旧 SocketAsyncEventArgs 复用。
深入解析
- Quit 包:socket 已半断或底层错时,同步
Send可能抛异常或阻塞;生产里常加 try/catch 或直接 teardown,视协议是否必须优雅通知对端。 - 半包缓存:
cacheNum、nowIndex若不归零,下一次连上可能把残余字节当成新流,解析错乱。 - 队列:
receiveQueue里未处理的Handler可能是上一局的包,重连后应清空或整体切换会话上下文。 - 异步收包:是否复用同一个
SocketAsyncEventArgs、是否在断线时取消挂起 IO,都和具体封装的健壮性有关——课里是示例级做法。
答题示例
断透了再 Send 可能直接异常,要有 try 或干脆跳过 Quit。
重连前要清缓冲和队列,否则上一连接剩的半条消息会污染下一条连接;生产环境还要注意异步接收句柄别和旧 socket 绑着。
参考文章
- 7.断线重连
深度题
1. 收包在后台、Update 里队列消费这套模型,解决的主矛盾是什么?代价是什么?
题目
联系 receiveQueue 与在 Update 里 switch 消息对象的做法,说明线程与 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