☰
Unity异步TCP网络开发实战:解决粘包与断线重连
2026/10/10 3:22:47 网站建设 项目流程

如果你做过登录、排行榜、聊天或者联机对战,大概率绕不开Unity网络开发里最基础的TCP通信。我刚入行的第一个项目,服务器一卡,整个客户端直接白屏,原因很简单:我把网络请求写成了同步等待。后来把所有收发逻辑全部改成异步,才意识到异步TCP在Unity场景里不是锦上添花,而是基本功。这篇文章我会从为什么需要异步讲起,一步步把TCP连接、消息收发、粘包处理、断线重连这些常见场景串起来,适合已经会基本C#、正准备写网络功能的Unity开发者。

网上聊Unity TCP的资料很多,但大多是贴一段TcpClient.Connect加上stream.Read的“伪同步”代码,真正把异步模型讲清楚、并且落到Unity生命周期里的很少。所以我打算用一整篇的篇幅,把我实际项目中用过的一整套思路和踩过的坑都写出来。你会看到为什么同步会卡死主线程,为什么async/await在客户端网络层这么好用,以及最关键的:消息边界和线程调度这两个问题不解决,代码再漂亮也白搭。

1. 为什么Unity网络开发首选异步TCP

1.1 同步写法的惨痛教训

很多新手写TCP客户端,第一反应是这样的:

var client = new TcpClient(); client.Connect("127.0.0.1", 7777); var stream = client.GetStream(); var buffer = new byte[1024]; int n = stream.Read(buffer, 0, buffer.Length);

这段代码在控制台Demo里跑没问题,放到Unity里就会出大事。Connect和Read都是阻塞的,意思是:在得到结果之前,调用它们的线程会一直等。而Unity的MonoBehaviour主线程是升级渲染和逻辑的线程,主线程一旦被阻塞,游戏画面就会卡住。最典型的场景是登录界面点完“连接”按钮,服务器没响应,客户端直接白屏几秒甚至十几秒。用户以为是死机,直接退游戏。

这里有个很关键的认知:Unity不是不能用同步阻塞,而是普通游戏客户端根本承担不起“无响应”的成本。数据通道可以慢,但是帧循环不能停。所以网络层必须做到“发起操作不等待结果”的效果,最常见的做法就是用异步API。

同步版本还有一个隐藏问题:如果服务器不返回数据,Read会永远挂住,没有任何办法从外部取消。你说我可以设置ReadTimeout?确实可以,超时一到就抛异常,但这只是“限时自杀”,并没有真正解决逻辑上的问题。更合理的方案是让读操作本身可以被外部控制,比如取消令牌、断线回调,这些只有在异步模型里才容易做干净。

1.2 异步的本质与几个反直觉的真相

async/await并不是让代码“加快”,它只是让线程在等待IO时腾出手来干别的事。操作系统底层仍然有socket缓冲区在搬运数据,ReadAsync被await之后,当前线程不会被霸占。在Unity主线程里发起一个异步连接,客户端主线程可以继续渲染下一帧,等连接完成之后,通过回调或者await之后的代码恢复执行。

这里我要澄清三个反直觉的真相,它们在你排错时非常有用。

第一,异步不代表多线程。await之后的代码可能回到原来的线程,也可能落在线程池线程上,具体取决于上下文。Unity默认没有完整的主线程SynchronizationContext,所以很多人在从MonoBehaviour里直接写await client.ConnectAsync(...)时,会发现await后面的代码不再跑在主线程上。这个问题我后面会详细讲,但你先记住:异步代码后面那一段,不一定还在主线程。

第二,TCP异步读并不解决粘包问题。粘包是字节流协议层的现象,跟你是同步读还是异步读没关系。不管你怎么读,都需要自己定义“一条消息从哪里结束、下一条从哪里开始”。很多新手以为用了ReadAsync填满缓冲区就是一条完整消息,这是大错特错。

第三,异步不等于零开销。每次await都有可能涉及回调对象分配、线程调度、缓冲区拷贝。高频小包场景下,如果每一帧发几百个网络包,甚至要让无锁队列来兜底,否则GC压力会很大。

1.3 什么场景必须用异步TCP

不是所有项目都非要异步不可。如果你的需求是编辑器工具、离线模拟器,或者数据量小到可以忍受瞬间卡顿,那同步代码能跑就行。但下面这几类场景,我强烈建议直接上异步:

  • 登录、注册、支付这类需要等待网络结果的流程,用户可不想看菊花转。
  • 实时对战、帧同步、位置同步,底层几乎全是UDP或者TCP长连接,必须常驻接收循环。
  • 匹配服、网关服、聊天服多条连接并存,同步代码根本管不过来。
  • 需要断线重连和心跳检测的逻辑,异步模型天然适合循环任务和定时任务组合。

说白了,做上线游戏,网络通信就是异步的舞台,TCP客户端只是其中一个基础形态。把异步这个底座打牢,后面写WebSocket、KCP、Unity Transport都顺很多。

2. 先把异步TCP的几个核心概念理清楚

2.1 TcpClient与NetworkStream的关系

你平时用.NET写TCP,最常用的类就是TcpClient,它是基于Socket的一层封装。TcpClient.Connect完成连接之后,通过GetStream()拿到NetworkStream,后续的收发几乎都在这个流上操作。理解这个关系很重要,因为很多网上资料把TcpClient和Socket混着用,概念一乱代码就乱。

在异步世界里,操作分三块:

  • 连接异步化:TcpClient.ConnectAsync(host, port, cancellationToken)。
  • 发送异步化:NetworkStream.WriteAsync(buffer, offset, count, token)。
  • 接收异步化:NetworkStream.ReadAsync(buffer, offset, count, token)。

这些API大多返回Task或ValueTask,用await等结果即可。注意ValueTask可以直接await,但如果要多次等待同一个实例,需要先.AsTask(),否则会抛异常。实际项目里为了简单,我一般直接调用返回Task的重载。

还有一块非常容易被忽略:TcpClient和NetworkStream都需要在合适的时机释放。客户端主动断开时,stream.Close()要同时让另一端的ReadAsync返回0,操作系统的TCP层会发送FIN包通知对端。如果你忘记关闭底层资源,就会看到一堆连接处于TIME_WAIT状态,开发时经常把端口耗光。

2.2 async/await在.NET和Unity中的落点

async/await是C# 5引入的语言特性,Unity从2018.2开始支持.NET 4.x脚本运行时,所以原生使用现代异步语法没有障碍。但注意:Unity主线程默认没有安装SynchronizationContext,这跟WPF、WinForms不一样。在桌面应用里,await UI线程相关的操作很自然就回到UI线程;在Unity里,如果你不做任何设置,await后续代码大概率会在线程池线程上继续执行。

所以“在Unity里用async/await”有两个流派:

  • 流派A:所有网络层代码全部不使用Unity API,await后面随便跑哪个线程都行,回调结果再手动丢到主线程。
  • 流派B:引入UniTask或者自己写一个主线程调度器,让网络层也能在await之后回到主线程,做到“Unity API随便碰”。

我个人的经验是:网络核心类尽量不要直接依赖Unity API,保持纯净的C#逻辑,方便单独测试;真正需要更新UI、驱动状态机的地方,用简单的调度队列把回调切回主线程。这样项目结构清晰,也不会被第三方库绑死。

2.3 CancellationToken怎么用才合理

异步TCP最容易被忽视的是取消机制。CancellationToken可以让“等待连接”“等待数据”“循环心跳”这些操作可以被外部打断。举一个实际例子:玩家点击“连接”之后又点击“取消”,或者游戏切后台需要断开网络,如果没有令牌,异步操作可能卡在某个ReadAsync上很久不返回。

我在封装网络层时,每个公开方法都留一个CancellationToken token = default参数,内部统一用CancellationTokenSource.CreateLinkedTokenSource组合外部令牌与超时令牌:

async Task<bool> ConnectWithTimeout(string host, int port, int timeoutMs, CancellationToken externalToken) { using var cts = CancellationTokenSource.CreateLinkedTokenSource(externalToken); cts.CancelAfter(timeoutMs); try { var client = new TcpClient(); await client.ConnectAsync(host, port, cts.Token); return true; } catch (OperationCanceledException) { return false; } }

这样超时和外部取消都能正确处理,不会出现“UI都关了,后台还在尝试连服务器”的诡异情况。还有一个细节:ConnectAsync如果被取消,不同平台抛的异常类型不一定相同,可能是OperationCanceledException,也可能是SocketException,所以除了捕获前者,最好再兜底检查token.IsCancellationRequested。

3. 动手实现:连接、发送、接收循环

3.1 事先约定好消息协议

在写任何收发代码之前,第一件事是定义消息协议。我建议简单场景直接用“长度前缀”方案:每条消息由4字节大端或小端整型的长度头 + 对应长度的消息体组成。别嫌土,这套协议简单可靠,几乎不会出岔子,后期换Protobuf、JSON,只需要替换消息体反序列化逻辑就行。

我习惯用大端序,因为网络协议里大端很常见,C#里的BitConverter默认是小端,需要手动处理:

static void WriteInt32BE(byte[] buffer, int value) { buffer[0] = (byte)(value >> 24); buffer[1] = (byte)(value >> 16); buffer[2] = (byte)(value >> 8); buffer[3] = (byte)value; }

如果整个项目都是C#写,用IPAddress.HostToNetworkOrder也行。反正要保持读和写两端一致,否则解析出来的长度会变成几百万,直接把你缓冲区打爆。

3.2 异步连接与超时控制

用一个干净的C#类做连接器,不继承MonoBehaviour,这样方便单元测试。连接逻辑一般长这样:

public class TcpConnection { private TcpClient client; private NetworkStream stream; private readonly object sendLock = new object(); public async Task<bool> ConnectAsync(string host, int port, int timeoutMs = 5000, CancellationToken token = default) { client = new TcpClient(); client.NoDelay = true; using var cts = CancellationTokenSource.CreateLinkedTokenSource(token); cts.CancelAfter(timeoutMs); try { await client.ConnectAsync(host, port, cts.Token); stream = client.GetStream(); return true; } catch (OperationCanceledException) { return false; } catch (SocketException ex) { Debug.LogError($"[TcpConnection] connect failed: {ex.SocketErrorCode}"); return false; } catch (Exception ex) { Debug.LogError($"[TcpConnection] connect error: {ex}"); return false; } } }

注意NoDelay = true,也就是禁用Nagle算法。Nagle算法会把多个小包合并发送,降低网络拥塞,但对于游戏、实时交互这种场景,延迟比省几个字节重要得多。你说不定会发现“指令总是慢半拍”,罪魁祸首就是这个设置没开。如果做成开发工具不需要低延迟,倒是可以不开。

3.3 发送数据

发送要做两件事:打包长度头和实际写入网络流。我见过很多同学直接stream.WriteAsync(payload, 0, payload.Length, token),完全不发长度头,结果另一端根本不知道你这包数据多长。正确做法:

public async Task SendAsync(byte[] payload, CancellationToken token = default) { if (stream == null || client == null || !client.Connected) throw new InvalidOperationException("connection is not established"); var packetLength = payload.Length; var header = new byte[4]; WriteInt32BE(header, packetLength); var packet = new byte[4 + payload.Length]; Buffer.BlockCopy(header, 0, packet, 0, 4); Buffer.BlockCopy(payload, 0, packet, 4, payload.Length); // 并发发送时,多线程写入同一个NetworkStream会有问题,先加锁 lock (sendLock) { await stream.WriteAsync(packet, 0, packet.Length, token); await stream.FlushAsync(token); } }

关于FlushAsync:NetworkStream本身默认不缓存,大多数情况下不调用Flush也能发出去。但如果你在上面套了缓冲流或者其他过滤层,显式Flush更保险。还有一点,锁保护要加,因为网络层可能同时被UI回调、心跳循环、游戏逻辑多个地方调用,多线程写到同一条流会踩坏管道。

3.4 接收循环

接收不能等一帧才读一次,TCP连接是常驻的,服务器随时可能发数据。所以要在连接成功后开启一个“一直读”的后台任务:

private CancellationTokenSource receiveCts; public event Action<byte[]> OnPacketReceived; public event Action<string> OnDisconnected; public void StartReceiveLoop(CancellationToken token = default) { receiveCts = CancellationTokenSource.CreateLinkedTokenSource(token); _ = ReceiveLoopAsync(receiveCts.Token); } private async Task ReceiveLoopAsync(CancellationToken token) { try { while (!token.IsCancellationRequested) { var packet = await ReadPacketAsync(token); if (packet == null) break; OnPacketReceived?.Invoke(packet); } } catch (OperationCanceledException) { // 正常取消 } catch (Exception ex) { OnDisconnected?.Invoke(ex.Message); } }

这个循环是异步TCP客户端的“心脏”。只要循环在,消息就能进来;循环一断,说明连接基本没救了。很多项目的卡顿、漏消息、界面不同步,都是因为接收循环没做好。

4. 粘包和半包:TCP通信里躲不开的槛

4.1 TCP是字节流,不是消息流

这是TCP协议最劝退新人的地方:send了三次100字节的数据,对方read可能一口气读到300字节;send一次1000字节,对方也可能两次各读到500字节。TCP只保证字节顺序和最终一致性,不保证每次读写都跟“消息”一一对应。

生活化类比:你往水管里扔乒乓球,水管里的球是连续排队的。对方在水管出口接球,一次可能接住一个,也可能把十几个球一起漏进桶里。你无法从“一次接了三个球”判断发送方到底扔了几次。

所以网络库必须做“封帧”:把连续的字节流切割成一帧帧有边界的应用层消息。

4.2 三种封帧方案如何选

常见的封帧方案有三种:

方案实现思路优点缺点
固定长度每条消息都定长,比如1024字节实现极简,解析快短消息浪费空间,长消息无法表达
分隔符消息末尾加\n或特殊字符适合文本协议、日志流正文里不能出现分隔符,需要转义
长度前缀头部4字节记录消息体长度通用、可靠、二进制友好需要处理半包,代码稍多

我的建议很明确:通用项目直接用长度前缀。JSON文本协议用分隔符也够,但游戏项目早晚会碰到二进制数据,比如战斗属性、位置快照、资源哈希,长度前缀一劳永逸。固定长度只适合你完全控制协议、并且消息大小变化不大的玩具项目。

4.3 用ReadFullAsync解决半包

长度前缀方案的核心难点在“可能只读到半截数据”。比如服务器发送了1000字节的封包,但ReadAsync这次只返回了300字节。如果你直接把这300字节当成一包消息,就切错了边界,后面全部错位。

正确做法是封装一个“读满指定字节数”的函数:

private async Task<bool> ReadFullAsync(byte[] buffer, int count, CancellationToken token) { int offset = 0; while (offset < count) { int n = await stream.ReadAsync(buffer, offset, count - offset, token); if (n == 0) return false; // 连接被对端关闭 offset += n; } return true; } private async Task<byte[]> ReadPacketAsync(CancellationToken token) { var header = new byte[4]; if (!await ReadFullAsync(header, 4, token)) return null; int length = ReadInt32BE(header); if (length <= 0 || length > MAX_PACKET_SIZE) throw new InvalidDataException($"invalid packet length: {length}"); var body = new byte[length]; if (!await ReadFullAsync(body, length, token)) return null; return body; }

这个while循环就是半包处理的核心思想:先读完4字节长度头,让它告诉我们这一帧有多大,然后继续循环读,直到凑满整个消息体为止。注意读取可能分很多次,所以ReadAsync的结果不直接当作一帧消息。

同理,长度头本身也可能半包,所以连4字节都必须用循环读完。很多线上事故的根因就是:长度头读了一半就开始解析,得到了一个错误长度。

MAX_PACKET_SIZE一定要有上限。比如限制不超过64KB或1MB,否则服务端异常发了一个长度为2GB的包头,客户端就会不断试图分配内存,直到内存爆炸。

5. 异步遇到Unity:主线程、生命周期和断线重连

5.1 回调不在主线程,如何安全更新UI

这是Unity异步网络里最容易被忽略的问题。我说过,await之后不保证回到主线程。如果你的OnPacketReceived回调里直接改Text.text或者transform.position,Unity会直接报“finding object in another thread”之类的错,甚至崩溃。

最稳妥的做法,是自己做一个极简主线程调度队列。在MonoBehaviour的Update里取队列中的任务来执行:

public class MainThreadDispatcher : MonoBehaviour { public static MainThreadDispatcher Instance { get; private set; } private readonly ConcurrentQueue<Action> actions = new ConcurrentQueue<Action>(); void Awake() { Instance = this; } public void Post(Action action) { actions.Enqueue(action); } void Update() { while (actions.TryDequeue(out var action)) { action(); } } }

然后在网络回调里这样用:

connection.OnPacketReceived += bytes => { MainThreadDispatcher.Instance.Post(() => { // 这里才能安全访问Unity对象 var msg = ParseMessage(bytes); loginStatusText.text = msg; }); };

如果你不想手写调度器,用UniTask的UniTask.SwitchToMainThread()也行,它能自动处理上下文切换,很多商业项目就是这么用的。但无论如何,“网络线程不能用Unity API”这条红线要刻在脑子里。

5.2 async void是容易爆炸的写法

在Unity里,网络库经常会提供“回调式”API给人调用,比如按钮点击:

async void OnLoginButtonClicked() { var ok = await connection.ConnectAsync(host, port); if (ok) ... }

async void在事件里能用,但异常一旦抛出来,整个进程直接崩,不像Task会走观察机制。我见过一个项目,断线时网络层抛了异常,游戏连个错误日志都没有,“啪”一下退到手机桌面。排查了很久,最后发现就是某个async void没包try-catch。

一个安全习惯:网络层的公开连接、发送方法内部消化所有可预知异常;事件处理器里用async void时,方法体开头就包一层try/catch,至少保证异常会打到日志而不是悄悄逃逸。更规范的做法是让事件处理器只调用普通async Task方法,尽量不要用async void给外部做入口。

5.3 断线重连与心跳

TCP有个经典问题:连接假死。玩家手机从WiFi切到4G,或者中间路由器静默丢包,TCP连接既没发FIN也没发RST,客户端以为还连着,其实早已断掉。光靠ReadAsync是等不到通知的,因为你没数据读。

所以需要心跳机制。最简单的方案:每隔一段时间发一个轻量ping包,服务端回pong;如果连续几次没收到pong,就判定断线,走重连逻辑。

private async Task HeartbeatLoopAsync(CancellationToken token) { var missCount = 0; while (!token.IsCancellationRequested) { await Task.Delay(5000, token); try { await SendAsync(pingPayload, token); missCount++; if (missCount >= 3) { OnDisconnected?.Invoke("heartbeat timeout"); break; } } catch { break; } } }

收到任何数据包都可以把missCount清零,不一定要单独等pong,因为只要有数据回来,连接就是活的。

断线重连要做退避,否则服务器一旦抖动,所有客户端同时重连,形成“重连风暴”。我一般这么写:第一次重连延迟1秒,失败翻倍到2秒、4秒,封顶30秒,并且加随机抖动。

int delaySec = 1; while (!stop && !connected) { await Task.Delay(Random.Range(delaySec, delaySec + 2) * 1000, token); var ok = await ConnectAsync(host, port, timeoutMs, token); if (ok) { StartReceiveLoop(); delaySec = 1; } else { delaySec = Math.Min(delaySec * 2, 30); } }

5.4 NoDelay、GC与单例封装

性能层面的几个细节,我直接列成清单,每一条都是踩过的坑。

  • 发送频繁时,反复new byte[]会产生大量GC。可以给接收缓冲区用池,比如用ArrayPool<byte>.Shared.Rent(len),用完归还。简单项目至少做到:不要为每个包都new一个几千字节的数组,能复用就复用。
  • NetworkStream.WriteAsync有小包合并问题,NoDelay = true只是把Nagle禁掉,不代表发送延迟一定低。如果你要批量发几千个小消息,建议自己合并成一个大的byte[]一次性写入,效果立竿见影。
  • 不要每帧都去检查client.Connected。这个属性只能反映上一次IO的状态,不能真正判断连接存活,用它做连接判断会给你虚假的安全感。
  • 连接、收发、心跳这些Task要统一记录引用,在OnDestroy或应用切换后台时取消。否则MonoBehaviour销毁了,后台任务还在跑,回调一堆幽灵事件,严重的话内存泄漏。

6. 一个可以直接抄的极简Echo客户端

6.1 EchoClient完整代码

把上面的思路合并成一个最小可用类。这个类只负责连接、发送、接收循环和通知,不包含业务协议解析,方便你直接扩展。

public class EchoClient : IDisposable { private TcpClient client; private NetworkStream stream; private readonly object sendLock = new object(); private CancellationTokenSource loopCts; private const int MaxPacketSize = 1 << 20; // 1MB public event Action<byte[]> OnPacket; public event Action<string> OnDisconnected; public bool IsConnected => client?.Connected == true; public async Task<bool> ConnectAsync(string host, int port, int timeoutMs = 5000, CancellationToken token = default) { DisposeConnection(); client = new TcpClient(); client.NoDelay = true; using var cts = CancellationTokenSource.CreateLinkedTokenSource(token); cts.CancelAfter(timeoutMs); try { await client.ConnectAsync(host, port, cts.Token); stream = client.GetStream(); return true; } catch (OperationCanceledException) { return false; } catch (SocketException ex) { UnityEngine.Debug.LogError($"[EchoClient] connect socket error: {ex.SocketErrorCode}"); return false; } catch (Exception ex) { UnityEngine.Debug.LogError($"[EchoClient] connect error: {ex}"); return false; } } public void StartReceiveLoop(CancellationToken token = default) { loopCts?.Cancel(); loopCts = CancellationTokenSource.CreateLinkedTokenSource(token); _ = ReceiveLoopAsync(loopCts.Token); } private async Task ReceiveLoopAsync(CancellationToken token) { try { while (!token.IsCancellationRequested) { var packet = await ReadPacketAsync(token); if (packet == null) { OnDisconnected?.Invoke("remote closed"); break; } OnPacket?.Invoke(packet); } } catch (OperationCanceledException) { } catch (Exception ex) { OnDisconnected?.Invoke(ex.Message); } } public async Task SendAsync(byte[] payload, CancellationToken token = default) { if (stream == null || !client.Connected) throw new InvalidOperationException("connection is not established"); var header = new byte[4]; WriteInt32BE(header, payload.Length); var packet = new byte[4 + payload.Length]; Buffer.BlockCopy(header, 0, packet, 0, 4); Buffer.BlockCopy(payload, 0, packet, 4, payload.Length); lock (sendLock) { await stream.WriteAsync(packet, 0, packet.Length, token); await stream.FlushAsync(token); } } private async Task<byte[]> ReadPacketAsync(CancellationToken token) { var header = new byte[4]; if (!await ReadFullAsync(header, 4, token)) return null; int length = ReadInt32BE(header); if (length <= 0 || length > MaxPacketSize) throw new InvalidDataException($"invalid packet length: {length}"); var body = new byte[length]; if (!await ReadFullAsync(body, length, token)) return null; return body; } private async Task<bool> ReadFullAsync(byte[] buffer, int count, CancellationToken token) { int offset = 0; while (offset < count) { int n = await stream.ReadAsync(buffer, offset, count - offset, token); if (n == 0) return false; offset += n; } return true; } private static void WriteInt32BE(byte[] buffer, int value) { buffer[0] = (byte)(value >> 24); buffer[1] = (byte)(value >> 16); buffer[2] = (byte)(value >> 8); buffer[3] = (byte)value; } private static int ReadInt32BE(byte[] buffer) { return (buffer[0] << 24) | (buffer[1] << 16) | (buffer[2] << 8) | buffer[3]; } public void SendHeartbeatLoop(int intervalMillis = 5000, CancellationToken token = default) { _ = HeartbeatLoopAsync(intervalMillis, token); } private async Task HeartbeatLoopAsync(int intervalMillis, CancellationToken token) { var pingPayload = new byte[] { 0x00, 0x00, 0x00, 0x04, 0x01 }; while (!token.IsCancellationRequested) { await Task.Delay(intervalMillis, token); try { await SendAsync(pingPayload, token); } catch { break; } } } public void Dispose() { loopCts?.Cancel(); DisposeConnection(); } private void DisposeConnection() { try { stream?.Close(); } catch { } try { client?.Close(); } catch { } stream = null; client = null; } }

6.2 在MonoBehaviour里调用

使用方式非常简单,你可以在一个玩家控制类里托管它:

public class GameNetwork : MonoBehaviour { private EchoClient client = new EchoClient(); void Start() { client.OnPacket += bytes => { MainThreadDispatcher.Instance.Post(() => { // 解析消息,更新UI Debug.Log($"receive {bytes.Length} bytes"); }); }; client.OnDisconnected += reason => { MainThreadDispatcher.Instance.Post(() => { Debug.Log($"disconnected: {reason}"); }); }; _ = ConnectAndRunAsync(); } private async Task ConnectAndRunAsync() { var ok = await client.ConnectAsync("127.0.0.1", 7777); if (ok) { client.StartReceiveLoop(); await client.SendAsync(Encoding.UTF8.GetBytes("hello server"), CancellationToken.None); } } void OnDestroy() { client.Dispose(); } }

这套代码没有任何花哨设计,但它是异步TCP客户端最扎实的骨架。你可以在它上面加消息队列、加Protobuf序列化、加自动重连、加多条服务器连接切换,都不会动摇底层的正确性。

我自己后来在好几个项目里复用这个结构,只改协议解析和业务分发部分。真正让我觉得值钱的不是那几十行代码,而是对“消息边界”“主线程调度”“可取消操作”这三个问题的理解。你把这三点吃透,再去学Unity Transport或者Mirror,会发现它们本质上也在解决同一批问题,只是换了一层更上层的封装。写网络代码,慢不怕,怕的是“看起来能跑、一上线就炸”的假稳定。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询