1. 面试官问TCP/UDP,其实是想听你说“选型逻辑”
初级网络面经里,TCP和UDP的区别是最高频的开场题,几乎每轮技术面都会出现在前三问。很多同学背了一堆“TCP面向连接、可靠传输、慢;UDP无连接、不可靠、快”,但面试官真正想听的不是背诵,而是你能不能从游戏开发的实际场景出发,说清楚“什么情况用哪个”以及“为什么”。
1.1 三次握手为什么是三次,不是两次
先讲个最基础的:TCP的三次握手到底在干什么。我习惯用“打电话确认”来理解——第一次握手是客户端说“喂,我要打电话”;第二次是服务端回“听到了,你现在能听到我吗”;第三次是客户端再回“能听到,咱俩链路通了”。它本质上解决的是“双方都确认对方能收能发”的问题。
为什么不是两次?假设只有两次握手:客户端发了一个连接请求,但因为网络延迟,这个请求被卡了很久,客户端超时重发了一次,第二次成功建连并传输数据后关闭了。这时候第一次的迟到请求才到达服务端,服务端以为客户端要重新建连,就回了一个确认并进入等待状态,白白占着一个连接资源。三次握手可以做到“客户端收到服务端的确认后,再回一次”,这样迟到的旧请求即使到了服务端,会因为客户端不再回应而让服务端主动放弃这次连接。
面经里如果问到这块,建议大家把“防止历史重复连接请求干扰”这个点答出来,这比只说“确保可靠传输”要深入一档。
1.2 TCP和UDP的核心差异表
我整理过一张对比表,面试前背熟,但要能展开:
| 维度 | TCP | UDP |
|---|---|---|
| 连接状态 | 面向连接,需要三次握手 | 无连接,直接发包 |
| 可靠性 | 确认重传、序号保证有序 | 不保证到达,不保证有序 |
| 传输效率 | 有ACK确认和滑动窗口,慢 | 无确认机制,快 |
| 数据边界 | 面向字节流,会出现粘包 | 面向报文,自带边界 |
| 应用场景 | 资源下载、登录验证、聊天消息 | 帧同步战斗、语音、视频流 |
在Unity游戏里,登录、存档、商城这些需要“必须到达且不能出错”的请求走TCP;实时对战里的位置/朝向/操作指令,对延迟敏感而能容忍少量丢失,走UDP或基于UDP的自定义协议。
1.3 游戏里“可靠UDP”是怎么回事
这里有个容易被初级候选人忽视的知识点:像帧同步对战里,很多团队并不是直接用裸UDP,而是在UDP之上自己封装一套可靠传输层,实现“UDP的速度 + TCP的可靠性补偿”,业界管这叫可靠UDP。
思路其实不复杂:给每个数据包加一个自增序列号,接收端维护一个滑动窗口,发现序号不连续就发NACK(Negative ACK)请求重传丢掉的包,而不是像TCP那样把后面的包全部缓存等待。这样网络抖动时,只补传真正丢失的包,后续更新包照常处理,延迟上比TCP更可控。
面试里能把这一层说出来,面试官基本就能确认你不只是背过“TCP/UDP区别”,而是真的理解实时游戏对延迟的要求。
2. Unity客户端的网络API家族,你必须能画出这张选型表
初级岗位考察网络,不会停在纯理论,第二波问题会集中在“你用Unity做过什么网络功能”“用的什么API”。其实Unity从入门到现在,网络相关的API已经更新了好几代,每个API适合的场景不同,踩坑的方式也不同。
2.1 UnityWebRequest:日常大头,HTTP请求首选
UnityWebRequest是Unity官方主推的HTTP通信API,用来替代老旧的WWW类。它封装了POST/GET/PUT/DELETE等方法,支持文本、二进制、音频、视频等多种数据类型,也支持分块下载。
一个核心注意点是要搞清楚它和协程怎么配合。很多新手写下载会直接写:
using UnityEngine; using UnityEngine.Networking; using System.Collections; public class DownloadExample : MonoBehaviour { IEnumerator DownloadText(string url) { using (UnityWebRequest request = UnityWebRequest.Get(url)) { yield return request.SendWebRequest(); if (request.result == UnityWebRequest.Result.Success) { string content = request.downloadHandler.text; Debug.Log(content); } else { Debug.LogError("请求失败: " + request.error); } } } }这段代码看着对,实际项目里有几个坑:
一是SendWebRequest()在校验result前,务必判断request.result而不是request.isError。老版本的isError在部分情况下会返回错误判断,而result == UnityWebRequest.Result.Success才是新版官方推荐的判定方式。
二是using必须写,UnityWebRequest实现了IDisposable接口,不释放会在真机上积累内存垃圾。别问我怎么知道的,线上包内存峰值就是这么搞上去的。
三是超时设置,默认的timeout是0,表示永不超时。真机弱网下,一个不超时的HTTP请求会直接把用户卡死在加载页。我建议所有请求都显式设置超时时间:
request.timeout = 10; // 10秒超时如果是下载大文件,比如几十MB的AB包或视频,还需要监听downloadProgress做进度条,同时注意断点续传时要用UnityWebRequest的SetRequestHeader("Range", "bytes=...")。
2.2 Socket:基于TCP/UDP的灵活控制
当游戏需要长连接或自定义协议时,UnityWebRequest就不够用了。举个例子:MMO游戏里,玩家的移动、聊天、战斗结算都是持续高频的消息流,HTTP那种“请求-响应”模式完全不适合。这时候直接用System.Net.Sockets下的TcpClient和UdpClient写Socket层。
千万不要一上来就异步回调满天飞,初级候选人最容易在这块写崩。我的建议是先封装一个简单的NetworkManager,用异步接收循环加线程安全队列:
using System; using System.Net.Sockets; using System.Threading; using System.Collections.Concurrent; public class TcpClientWrapper { private TcpClient client; private NetworkStream stream; private ConcurrentQueue<byte[]> receiveQueue = new ConcurrentQueue<byte[]>(); private Thread receiveThread; public void Connect(string host, int port) { client = new TcpClient(); client.Connect(host, port); stream = client.GetStream(); receiveThread = new Thread(ReceiveLoop); receiveThread.IsBackground = true; receiveThread.Start(); } private void ReceiveLoop() { byte[] buffer = new byte[4096]; while (client.Connected) { int len = stream.Read(buffer, 0, buffer.Length); if (len > 0) { byte[] data = new byte[len]; Buffer.BlockCopy(buffer, 0, data, 0, len); receiveQueue.Enqueue(data); } } } public bool TryDequeue(out byte[] data) { return receiveQueue.TryDequeue(out data); } }这里用ConcurrentQueue是为了避免主线程和接收线程同时操作List导致索引越界,算是一个基础但典型的跨线程数据交互解法。
面试时,面试官常会追问“客户端怎么把收到的字节流转成消息对象”,这就引出下一节要聊的粘包问题。
2.3 WebSocket:小游戏和社交功能的黄金选择
如果你做的是Unity微信小游戏、WebGL版本,或者游戏内有聊天室、邮件、活动公告这类需要服务端主动推送的功能,WebSocket就非常合适。它是基于TCP的全双工通信协议,浏览器原生支持,Unity侧可以用NativeWebSocket插件或官方没有内置的第三方库。
和原生Socket最大的区别是:WebSocket是“先HTTP握手升级,再二进制/文本帧通信”。在Unity里,要注意的是消息格式——服务端通常发的是字节数组,客户端要处理好大端字节序和JSON序列化的组合问题。
using System; using System.Text; using NativeWebSocket; public class WebSocketClient : MonoBehaviour { WebSocket websocket; async void Start() { websocket = new WebSocket("ws://example.com:8080/ws"); websocket.OnMessage += (bytes) => { string message = Encoding.UTF8.GetString(bytes); Debug.Log("收到服务端消息: " + message); }; await websocket.Connect(); } public async void SendJson(string json) { if (websocket.State == WebSocketState.Open) { await websocket.SendText(json); } } }这段代码里有个我踩过的坑:OnMessage回调是在Unity主线程里触发的,所以可以直接操作UI对象,但如果你用了某些第三方原生WebSocket库,回调可能是异步线程,这时候必须用UnityMainThreadDispatcher之类的组件丢回主线程。面试时提一句“UI操作有线程要求”,会显得你考虑得很周全。
2.4 一张表理清API选型
| 需求场景 | 推荐API | 理由 |
|---|---|---|
| 登录/注册/拉取配置 | UnityWebRequest | 短连接、HTTP请求、天然REST接口 |
| 下载AB包/热更资源 | UnityWebRequest + 断点续传 | 支持进度回调和Range分块 |
| MMO游戏实时移动/战斗 | Socket (TcpClient/UdpClient) | 需要自定协议、长连接保活 |
| 小游戏/WEBGL/聊天室 | WebSocket | 服务端主动推送、浏览器兼容好 |
| 帧同步对战 | 可靠UDP封装 | 低延迟 + 丢包重传补偿 |
这四类覆盖了90%以上的客户端网络需求,能把这套选型逻辑讲明白,面经的网络基础部分已经过了一大半。
3. 帧同步 vs 状态同步,初级岗位也要能说清差异
网络面经的第二块重头戏是同步方案。你不需要讲得很深,但基本的逻辑链必须通:什么是状态同步、什么是帧同步、两者优劣势、各自适合什么类型游戏。
3.1 状态同步:权威服务器,逻辑更重
状态同步的特点是“服务器拥有最终权威的游戏状态”。客户端把操作发给服务器,服务器模拟运算后,把修正后的位置、血量、Buff状态等广播给所有客户端。典型的例子是各类MMO:你点了一下移动,客户端发出移动请求,服务器校验后下发新坐标,你再看到自己的人物继续往前走。
好处是逻辑全在服务器上,挂机、封号检测(反作弊)容易做,客户端逻辑简单,就是“发请求、收状态、表现”。麻烦在于它需要频繁同步状态数据,带宽消耗大,而且操作到状态回来之间的延迟感明显,必须要靠客户端预测和延迟补偿来磨平手感。
3.2 帧同步:客户端只传操作,逻辑全靠自己算
帧同步刚好反过来:服务器只负责收集所有客户端的操作指令,然后按固定帧率把指令打包广播出去,每个客户端用自己的逻辑代码计算整个游戏世界。这样同一个输入序列,只要逻辑一致,结果就一致。它最大的优点是同步数据量小、网络消耗低,极适合MOBA、格斗这种高频操作对战。
但帧同步有几个让初级候选人直接懵的问题:
- 逻辑必须确定性:不能用
Time.deltaTime、Random这类每次运行结果可能不同的东西,否则不同设备算出来的位置会漂移。 - 浮点数精度问题:不同手机CPU对浮点运算结果存在微小差异,量变引起质变。很多帧同步项目干脆把逻辑层改成定点数运算,用整数模拟小数。
- 断线重连难做:因为客户端本地就是一台“世界模拟器”,如果中途掉线重连,需要服务器把掉线期间所有操作帧补给你,再快速回放到当前帧,这个镜像和回放逻辑的实现复杂度比状态同步高不少。
初级岗位面试不用全答,但至少要说清楚“状态同步同步的是结果,帧同步同步的是操作”,然后再补一两个各自优缺点,就够用了。
3.3 一个真实项目里的踩坑案例
我之前做过一个小规模多人对战的小游戏,早期图省事直接选了帧同步,结果刚开始联调就崩了:两个手机在同一个操作序列下,走个几十帧就开始偏移,后来排查发现是有个随机音效的播放器在逻辑层用了Unity自带的Random.Range,导致客户端逻辑分支出现了不确定性。
这个坑的教训是:帧同步的逻辑层一定要和表现层严格分离,哪怕一个毫不起眼的随机数调用都可能让整局战斗分叉。后来我们直接在编译阶段加了一个静态代码检查,凡是逻辑层代码文件里出现Random、Time、DateTime这几个关键字就直接报编译错误,从根上杜绝。
面经里把这个案例讲出来,面试官会认为你有实际项目经验,而不只是知道概念。这个案例在面试效果上比背十条定义都好。
4. 移动端弱网下的四道送命题:超时、重连、粘包、缓存
最后这部分,是初级面试里最容易被“降维打击”的地方。很多候选人能答上TCP和UDP的区别,却答不出“用户在电梯里、地铁上、Wi-Fi和4G切换时,游戏该怎么办”。移动网络环境远比PC复杂,弱网处理能力和经验,往往是区分“会写代码”和“能做上线项目”的分水岭。
4.1 超时和重试:别让用户卡死在转圈
HTTP请求超时的问题上面提过,这里说重试策略。很多初级开发者写的重试逻辑是“失败后马上再发一次”,这在移动网络上几乎等于自杀——如果第一次失败是因为信号差,马上重试大概率还会失败,还白白消耗电量。
正确的做法是带退避的指数重试:第一次失败等1秒再试,第二次失败等2秒,第三次等4秒,最多重试N次。我给一个常用模板:
public IEnumerator RequestWithRetry(Func<UnityWebRequest> createRequest, int maxRetry = 3) { int retry = 0; while (retry <= maxRetry) { using (var request = createRequest()) { yield return request.SendWebRequest(); if (request.result == UnityWebRequest.Result.Success) { // 成功处理 yield break; } retry++; Debug.LogWarning($"第{retry}次请求失败: {request.error}"); yield return new WaitForSeconds(Mathf.Pow(2f, retry)); } } Debug.LogError("多次重试仍然失败"); // 走失败UI流程 }指数退避的本质是“给网络一个恢复的时间窗口”。面试时把这个点答出来,再顺带提一句“要考虑业务幂等性,比如支付回调类的请求不能盲目重试”,基本就是一道加分回答了。
4.2 长连接断线重连:心跳机制是保命符
TCP长连接在移动网络下,最常见的问题是假死:看起来连接还在,实际上链路早就断了,用户点击“发送”却迟迟没反应。避免假死要靠心跳包——客户端每隔一段固定时间(比如10秒)发一个极小的心跳消息,服务端收到后回一个心跳ACK;如果连续多次(比如3次)没收到ACK,就判定连接已死,走重连流程。
心跳包还有个细节:它会占用一点点带宽,在超低流量套餐用户那里不致命,但要采用“心跳间期可调、空闲才发”的策略,避免在频繁交互时重复发包。
比如这样:
public class HeartbeatManager : MonoBehaviour { float heartbeatInterval = 10f; float missTimeout = 30f; float lastSendTime; float lastReceiveTime; void Update() { if (Time.time - lastSendTime >= heartbeatInterval) { SendHeartbeatPacket(); lastSendTime = Time.time; } if (Time.time - lastReceiveTime >= missTimeout) { Debug.Log("心跳超时,触发重连"); Reconnect(); } } }重连要跟“弱网判定”联动起来:不是所有失败都立刻重连,而是先做一次网络状态检测(比如Ping一下域名或者发一个空请求),如果当前网络本来就不可用,就先提示用户“网络异常”,等网络恢复后再尝试重连。这样不会在网络恢复前无限发起垃圾请求。
4.3 TCP粘包:一定要讲的字节流陷阱
粘包问题在Socket类的网络编程里几乎是必问的。TCP是字节流协议,底层只保证字节按顺序到达,不保证“发送方每次Write的数据”和“接收方每次Read到的数据”是一一对应的。客户端连续发了两条消息,服务端可能一次Read就把两条都读到了,也可能一条都没读全。这就是粘包和半包。
解决办法业界很成熟:应用层自己定义消息边界。最常用的是“包头 + 包体”,包头固定几个字节,包含消息长度字段。比如定义一个结构:
| 字段 | 占用字节 | 说明 |
|---|---|---|
| 消息ID | 2字节 | 标识消息类型 |
| 消息长度 | 4字节 | 包体字节数 |
| 包体 | N字节 | 业务序列化数据 |
接收端的处理逻辑是:先读取固定长度的包头,解析出消息长度字段,再继续读取该长度的包体。如果收到的数据不够一个包头,就等下一批;如果数据超出包体长度,把剩余数据留给下一次消息解析。
在Unity里,我比较推荐写一个PacketParser的类,维护一个List<byte>缓冲区,每次从Socket读取入队后,立刻尝试解析完整消息:
public class PacketParser { private List<byte> buffer = new List<byte>(); public void Append(byte[] data) { buffer.AddRange(data); } public byte[][] ParseMessages() { var result = new List<byte[]>(); int offset = 0; while (buffer.Count - offset >= 6) // 包头固定6字节 { int msgId = BitConverter.ToUInt16(buffer.GetRange(offset, 2).ToArray(), 0); int length = BitConverter.ToInt32(buffer.GetRange(offset + 2, 4).ToArray(), 0); if (buffer.Count - offset - 6 < length) { break; // 包体还没完整到达,跳出等待更多数据 } byte[] body = buffer.GetRange(offset + 6, length).ToArray(); result.Add(body); offset += 6 + length; } if (offset > 0) { buffer.RemoveRange(0, offset); } return result.ToArray(); } }注意这里用的List<byte>.GetRange在频繁收包时会产生不少GC,进阶优化可以用环形缓冲区或MemoryStream来避免。面试时先给出能跑通的版本,然后主动说“如果包量大,我会用对象池和环形缓冲来降低GC”,这个从“能跑”到“上线”的思考转变非常加分。
4.4 断线缓存:玩家体验的最后一道防线
移动端网络切换是常态:从Wi-Fi出门切到4G,或者进了电梯直接无信号。玩家在这个瞬间正在打副本、正在看直播、正在下载资源包,如果直接报错“网络连接失败”,体验会非常差。这也是初级面经里比较容易忽略的考点。
断线缓存可以从几个层面做:小数据(玩家操作、日志上报)先入本地队列,等网络恢复后按序补发;下载类的资源包,每块下载完成后写入本地文件,恢复后只补下载缺失的部分;UI界面给出统一的“连接中断,正在重连”提示,而不是每次请求失败都弹一遍错误框。
本地缓存方案在Unity里最简单的是用PlayerPrefs,但只能存少量字符串和数值,不能当队列用。要存积压指令,建议用SQLite或者简单的文件系统,在每次成功收到服务器ACK后才删除对应缓存项。
这里有一个特别容易踩的坑:缓存必须有“最大长度上限”。如果玩家在断网状态下连续操作了几百次,每次操作都往本地缓存里塞,又不清理,内存和磁盘都会涨得很难看。所以我会设置一个上限,比如最多缓存50条操作,超过上限就强制丢弃最旧的,同时给玩家一个“操作过于频繁,请稍候再试”的提示。这既保护了客户端,也简化了服务端处理的复杂度。
5. 面经不是背答案,而是把经验穿成线
写到这里,想说说我对初级网络面经的看法。很多同学会把面经当成“题库”来背,今天背三次握手,明天背粘包,后天背帧同步。这样面试的时候确实能答出几个名词,但面试官一旦追问“具体什么时候会遇到”或者“你怎么排查”,立马露馅。
我的建议是把面经当线索,用项目把知识串起来。比如你做一个登录功能,就顺着想一遍:HTTP请求怎么发?超时怎么设?失败重试怎么做?弱网怎么提示?这一个小功能就能带出UnityWebRequest、超时重试、UI状态管理、错误日志上报一整条链路。你做一个队伍聊天,就自然涉及Socket长连接、心跳保活、消息粘包、断线重连。这些功能做完,你再回头背三次握手、背TCP与UDP的区别,会发现它们不再是死记硬背,而是一个个你真实处理过的问题。
最后再分享一个面试技巧:当被问到“TCP为什么可靠”这类基础题时,不要只回答“有确认和重传”,而是尽量补充“我在项目里发现,如果不设超时重传,有些请求会一直挂在系统的发送队列里,导致后续请求全被阻塞,所以我一般会设置超时并配合重试”。这种回答方式把知识点落回到你做过的事情上,面试官会明显更愿意往下聊。
面经的作用是帮你梳理知识体系,但真正的底气,还是来自项目里一行行写出来的代码和一个个排查过的线上问题。基础打扎实,多动手,初级岗位的网络关没那么难过。