写Unity网络这块,尤其是服务端,常有一种尴尬:客户端教程一抓一大把,什么RPC、SyncVar、NetworkTransform讲得头头是道,可真到了要自己搭一个服务端、跑通一条完整消息链路的时候,很多人直接卡住。不是不会写C#,而是不知道服务端该管哪些事、消息怎么设计、连接怎么维护。这篇东西就是冲着这个问题来的,我直接把一个能跑通的基础服务端代码从零拆给你看,用C#写、走TCP、配长度前缀协议,顺便把心跳、粘包、半包、掉线重连这些坑都填上。适合刚接触Unity网络、想自己动手写服务端而不是只会调库的开发者。
1. 先想清楚:Unity服务端到底要管哪些事?
很多Unity开发者第一次接触网络,是照着教程在客户端里拖一个NetworkManager,点一下Play就能看到两个玩家动起来。这时候服务端是框架帮你藏起来的,你根本不知道它做了什么。一旦你想自己做联机,或者想接入自己的服务器,立刻就会撞上一堵墙:服务器代码到底从哪下手?
我给你的答案是:先别急着写代码,把服务端的职责拆清楚。
一个最基础的服务端,日常就干四件事:管连接、收消息、发消息、维护状态。管连接是接受客户端进来、处理客户端断开;收消息是把客户端发过来的字节流还原成一条条有意义的数据;发消息是给指定客户端或所有客户端广播数据;维护状态则是服务端自己留下一份"权威数据",比如玩家坐标、血量、房间信息。大部分所谓"网络卡了"的问题,本质上都是这四件事里某一件没做好。
Unity客户端和服务端最大的思维差异就在这里:客户端的所有操作都是围绕本地表现来的,一个人、一台机器、一个画面;服务端则要同时面对几十上百个连接,每条消息都要判断"这条该转给谁"。你可以把服务端想象成一个前台,所有人都找它说话,它得记清每个房间住着谁,还得保证传话不传错。
技术选型上,我建议服务端直接用C#写控制台程序,用.NET 6以上版本。不要引入Mirror、Photon这些现成框架,原因很简单:你要学的是原理,不是API用法。用原始Socket写一遍,你才能理解为什么框架要帮你做那些封装。而且C#写服务端有一个天然优势:协议类、消息定义可以直接和Unity客户端共用一套C#代码,不用像Node或Java那样维护两套定义。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 一个最小可运行的服务端骨架:监听、接入与消息循环
服务端最基本的骨架,就是监听端口、接受连接、然后给每个连接开一条消息处理通道。下面这套代码是我实际项目里最常用的基底,删掉了业务逻辑,只保留网络骨架。
csharp复制// Server.cs
using System.Net;
using System.Net.Sockets;
using System.Text;
public class GameServer
{
private TcpListener _listener;
private readonly Dictionary<int, ClientConnection> _clients = new();
private int _nextClientId = 1;
private bool _isRunning;
public async Task StartAsync(int port)
{
_listener = new TcpListener(IPAddress.Any, port);
_listener.Start();
_isRunning = true;
Console.WriteLine($"[Server] listening on port {port}");
while (_isRunning)
{
try
{
TcpClient tcpClient = await _listener.AcceptTcpClientAsync();
ClientConnection conn = new ClientConnection(tcpClient, _nextClientId, OnClientMessage, OnClientDisconnected);
_clients[_nextClientId] = conn;
Console.WriteLine($"[Server] client #{_nextClientId} connected, total {_clients.Count}");
_ = conn.StartAsync();
_nextClientId++;
}
catch (Exception ex)
{
Console.WriteLine($"[Server] accept error: {ex.Message}");
}
}
}
private void OnClientMessage(int clientId, string message)
{
Console.WriteLine($"[Server] from #{clientId}: {message}");
// 在这里做消息分发和业务处理
}
private void OnClientDisconnected(int clientId)
{
if (_clients.Remove(clientId))
{
Console.WriteLine($"[Server] client #{clientId} disconnected, total {_clients.Count}");
}
}
}
核心逻辑就三行:AcceptTcpClientAsync 等连接进来;进来之后包一个 ClientConnection 存到字典里;然后开一个不阻塞主循环的任务去处理这个连接。
这里有个初学者特别容易犯的错误:在 while 循环里同步调用 AcceptTcpClient()。这样一旦有客户端连上,服务端就会卡在消息处理上,别的客户端就进不来了。用 AcceptTcpClientAsync 配合 await,是让监听循环保持"随时响应新连接"状态的最低成本方案。
再看每个连接的消息处理类:
csharp复制// ClientConnection.cs
using System.Net.Sockets;
using System.Text;
public class ClientConnection
{
private readonly TcpClient _tcpClient;
private readonly NetworkStream _stream;
private readonly int _clientId;
private readonly Action<int, string> _onMessage;
private readonly Action<int> _onDisconnected;
private readonly byte[] _buffer = new byte[4096];
private readonly byte[] _lengthBuffer = new byte[4];
private bool _isClosed;
public ClientConnection(TcpClient tcpClient, int clientId, Action<int, string> onMessage, Action<int> onDisconnected)
{
_tcpClient = tcpClient;
_clientId = clientId;
_onMessage = onMessage;
_onDisconnected = onDisconnected;
_stream = tcpClient.GetStream();
}
public async Task StartAsync()
{
try
{
while (!_isClosed)
{
int length = await ReadExactlyAsync(_lengthBuffer, 4);
if (length == 0) break;
int messageLength = IPAddress.NetworkToHostOrder(BitConverter.ToInt32(_lengthBuffer, 0));
byte[] messageBuf = new byte[messageLength];
await ReadExactlyAsync(messageBuf, messageLength);
string message = Encoding.UTF8.GetString(messageBuf);
_onMessage(_clientId, message);
}
}
catch
{
// 连接异常断开,统一按断开处理
}
finally
{
Close();
}
}
private async Task<int> ReadExactlyAsync(byte[] buffer, int count)
{
int offset = 0;
while (offset < count)
{
int read = await _stream.ReadAsync(buffer.AsMemory(offset, count - offset));
if (read == 0) return offset;
offset += read;
}
return count;
}
public void Close()
{
if (_isClosed) return;
_isClosed = true;
_onDisconnected(_clientId);
_stream.Close();
_tcpClient.Close();
}
}
这里最关键的是 ReadExactlyAsync 这个方法。TCP是流式协议,你永远不知道一次 ReadAsync 会读到多少字节,可能只有半个消息,也可能是好几条消息粘在一起。ReadExactlyAsync 做的事就是"必须读满指定长度才返回",保证读一个消息长度就读满4个字节,读消息体就读满整个消息体,这样后面解析才不会错位。
注意:我在
ReadExactlyAsync里对返回0的处理是直接返回当前已读字节数,外层调用方收到小于期望值的数字就break掉。这是TCP连接正常关闭时会出现的"EOF"情况,代表对方主动断开,不需要当异常处理。
3. 消息协议设计:为什么我推荐长度前缀+JSON
协议设计是网络编程里最容易忽略、后期改起来又最痛苦的部分。我第一次写联机demo的时候,直接在客户端发一个字符串、服务端收到就打印,完全不管消息边界。本地测试没问题,等稍微一改,消息一多,立刻开始出现"消息串了""掉字"的情况。
问题就出在TCP流式传输上。TCP没有"消息"的概念,它只保证字节流的顺序,不保证你的消息是一条一条完整到达的。举个例子,客户端连续发三条消息:"Hello"、"World"、"Test",服务端可能一次 ReadAsync 就全收到了:"HelloWorldTest",也可能先收到"Hell",再收到"oWorldTest"。这就是经典的粘包和半包问题。
解决方案很简单:给每条消息加一个固定长度的头部,里面存消息长度。这就是我前面代码里那句 NetworkToHostOrder(BitConverter.ToInt32(...)) 做的事。
我推荐的协议格式是这样:
| 字段 | 长度 | 说明 |
|---|---|---|
| 消息长度 | 4字节 | 整个消息体的字节数,网络字节序 |
| 消息ID | 4字节 | 表示消息类型,比如1=登录,2=移动,3=心跳 |
| 消息内容 | 可变 | 实际数据,我建议用JSON字符串 |
用C#封装一下发送和解析:
csharp复制// MessageProtocol.cs
using System.Text;
public static class MessageProtocol
{
public static byte[] Encode(int msgId, string jsonContent)
{
byte[] contentBytes = Encoding.UTF8.GetBytes(jsonContent);
int totalLength = 4 + contentBytes.Length;
byte[] result = new byte[4 + totalLength];
// 消息总长度(包含msgId和content,不含长度字段本身)
byte[] lengthBytes = BitConverter.GetBytes(IPAddress.HostToNetworkOrder(totalLength));
Array.Copy(lengthBytes, 0, result, 0, 4);
// 消息ID
byte[] idBytes = BitConverter.GetBytes(IPAddress.HostToNetworkOrder(msgId));
Array.Copy(idBytes, 0, result, 4, 4);
// 内容
Array.Copy(contentBytes, 0, result, 8, contentBytes.Length);
return result;
}
public static (int msgId, string content) Decode(byte[] data, int offset, int length)
{
int msgId = IPAddress.NetworkToHostOrder(BitConverter.ToInt32(data, offset));
string content = Encoding.UTF8.GetString(data, offset + 4, length - 4);
return (msgId, content);
}
}
客户端也要用同一套规则。以Unity里最基础的 TcpClient 为例,发送一条消息就是先 Encode 再 WriteAsync:
csharp复制byte[] packet = MessageProtocol.Encode(1, "{\"username\":\"player01\"}");
await _stream.WriteAsync(packet);
接收端逻辑和服务端完全一致,先把4字节长度读满,再读消息体。正因为C#两边可以共用这个 MessageProtocol 类,我才推荐服务端用C#写,少维护一套协议解析代码。
消息ID这块,我建议用 enum 管理,不要裸写数字:
csharp复制public enum MsgId
{
Login = 1,
Move = 2,
Heartbeat = 3,
Logout = 4,
}
这样写的好处是后面加消息类型时,一眼能看到所有消息的编号,不会出现消息ID冲突的问题。
4. 心跳机制与掉线检测:为什么不能只看 Connected 属性
服务端写完了,消息也能收发,很多人觉得就结束了。结果一上线就出问题:玩家退出游戏,服务端那边连接半天不释放;玩家网络断了几秒,服务端不知道,还在傻傻地发消息。
原因很简单:TcpClient.Connected 属性不是实时的。它只在最近一次IO操作时才会知道连接是否真的断了。如果客户端突然断电、断网,操作系统可能要好几分钟甚至更久才反馈一个TCP超时,服务端就会一直认为这个连接是活的。
解决这个问题的最基础手段就是心跳机制。设计很简单:
- 客户端每隔一段时间(比如5秒)发一个心跳包给服务端。
- 服务端记录每个客户端最后一次收到消息的时间。
- 如果超过一定时间(比如15秒)没收到某个客户端的任何消息,就判定它掉线,主动清理连接。
服务端代码加一个定时检查循环:
csharp复制// HeartbeatMonitor.cs
public class HeartbeatMonitor
{
private readonly Dictionary<int, DateTime> _lastSeen = new();
private readonly int _timeoutSeconds;
public HeartbeatMonitor(int timeoutSeconds)
{
_timeoutSeconds = timeoutSeconds;
}
public void Update(int clientId)
{
_lastSeen[clientId] = DateTime.UtcNow;
}
public List<int> GetTimedOutClients()
{
List<int> result = new();
DateTime now = DateTime.UtcNow;
foreach (var pair in _lastSeen)
{
if ((now - pair.Value).TotalSeconds > _timeoutSeconds)
{
result.Add(pair.Key);
}
}
return result;
}
}
然后服务端单独起一个 Task,每5秒跑一次检查:
csharp复制private async Task HeartbeatLoopAsync()
{
while (_isRunning)
{
await Task.Delay(5000);
foreach (int id in _heartbeatMonitor.GetTimedOutClients())
{
Console.WriteLine($"[Server] client #{id} timed out, closing");
_clients[id]?.Close();
}
}
}
还有一个配套操作:服务端主动向客户端发送消息时,如果抛出 IOException 或 ObjectDisposedException,马上标记这个连接失效。不要等到心跳超时。这两个异常是网络断开在服务端最常见的表现,异常信息里通常带着"无法连接到已停止的主机"或者"远程主机强迫关闭了一个现有的连接"。
心跳包本身不要承载业务逻辑,就是一个空消息或者带个时间戳的简单JSON。把它当成"我还活着"的信号就够了。
注意:心跳超时参数要根据网络环境调,局域网联机15秒没问题,公网环境建议放宽到30秒甚至45秒,不然走移动网络的玩家容易被误判掉线。
5. 消息分发与业务逻辑:从裸Socket到简易消息路由
如果你的服务端只是接收消息然后打印,那还谈不上"基础服务端"。一个能用的服务端至少要能根据消息ID把消息路由到对应的处理方法上。这步做到了,后面接登录、接移动同步、接房间管理都顺理成章。
最直接的做法是写一个消息分发器,用 switch 或者字典存处理函数:
csharp复制// MessageDispatcher.cs
public class MessageDispatcher
{
private readonly Dictionary<int, Func<ClientConnection, string, Task>> _handlers = new();
public void Register(int msgId, Func<ClientConnection, string, Task> handler)
{
_handlers[msgId] = handler;
}
public async Task DispatchAsync(ClientConnection conn, int msgId, string content)
{
if (_handlers.TryGetValue(msgId, out var handler))
{
await handler(conn, content);
}
else
{
Console.WriteLine($"[Server] unhandled msgId: {msgId}");
}
}
}
然后在 ClientConnection 里解析完消息后调用 DispatchAsync,而不是直接调 OnClientMessage:
csharp复制var (msgId, content) = MessageProtocol.Decode(messageBuf, 0, messageLength);
await _dispatcher.DispatchAsync(this, msgId, content);
这样做的好处是,业务逻辑彻底和网络收发解耦。你注册一个处理登录的handler,就只关心"收到登录请求后怎么验证、怎么回包",不用管这条消息是TCP还是UDP传过来的。后面你要加协议、加消息类型,都是在 Register 里多写一行的事。
我实际项目里会再套一层"会话"概念。ClientConnection 只管网络收发的通透性,会话层管"这个连接是谁、登录了没有、在哪个房间里"。会话层维护一个 Player 对象,里面有玩家ID、昵称、坐标、所在房间ID。这样处理登录消息时,验证通过后就给这个连接绑定一个 Player,后续的消息直接查会话就知道是谁发的,不用每次都在消息里传玩家ID。
Unity客户端那边对应也要有一个"网络管理器"脚本挂在场景里,负责建立连接、收发消息、把服务器回包转成Unity主线程可用的数据。这里有个坑必须提醒:Unity的API不能直接在socket回调线程里操作,比如你不能在收到消息的线程里直接改 Transform.position。你得用 UnityMainThreadDispatcher 或者把消息放进队列,在 Update 里处理。
6. 联调实测:从本机到局域网的完整验证过程
代码都写完了,接下来是验证。很多人在这一步翻车。我建议按下面这个顺序来,能最快定位问题是出在客户端还是服务端。
第一步,先跑服务端。确保控制台输出 listening on port 7777 之类的启动日志。
第二步,在同一台机器上跑Unity客户端,连接 127.0.0.1:7777。这一步是验证协议和服务端逻辑是否正确的关键,因为本机回环不受防火墙干扰,出问题一定是代码问题。
第三步,用Unity的 Editor 模式直接Play,看控制台有没有连接日志。这一步通过后,把客户端Build出来,再跑一遍,确认打包后行为一致。
第四步,局域网联调。把服务端跑在一台机器上,客户端跑在另一台机器上,连接IP改成服务端所在机器的局域网IP。比如服务端IP是 192.168.1.100,客户端就连 192.168.1.100:7777。
如果第四步连不上,优先级最高的排查方向是:
-
服务端防火墙有没有放行端口。Windows上入站规则默认会拦截外部连接。你需要在"高级安全Windows Defender防火墙"里新建一条入站规则,允许TCP 7777端口。这个是我遇到过最多的坑,本机怎么都通,换成局域网立刻失败。
-
服务端监听地址是不是
IPAddress.Any。如果你Listen在127.0.0.1上,局域网其他机器是连不进来的,只能本机连。监听0.0.0.0才能接受所有网卡的连接。 -
客户端填的IP对不对。在服务端机器上打开命令行,输入
ipconfig,找IPv4地址,别用127.0.0.1。
这里还有个细节:Unity打包后的程序连接服务器,需要在Player Settings里给 SocketException 留出权限。如果你在编辑器里能连上、打包后连不上,基本就是打包权限问题。到 Player Settings -> Other Settings -> Configuration 下面,勾选 Internet Client 权限。
压力测试方面,基础阶段不用搞复杂。用并发的TcpClient写个小脚本,模拟10个客户端同时连接、同时发几条消息,看看服务端是不是都能正确处理、消息有没有串号。我推荐直接在Visual Studio里写个简单的并发客户端程序跑一下,比手工开10个Unity实例高效得多。
csharp复制// StressTestClient.cs
using System.Net.Sockets;
for (int i = 0; i < 10; i++)
{
int clientId = i;
Task.Run(async () =>
{
using TcpClient client = new();
await client.ConnectAsync("127.0.0.1", 7777);
NetworkStream stream = client.GetStream();
for (int j = 0; j < 10; j++)
{
byte[] packet = MessageProtocol.Encode(2, $"{{\"client\":{clientId},\"seq\":{j}}}");
await stream.WriteAsync(packet);
await Task.Delay(100);
}
});
}
跑起来后看服务端日志,应该能看到每个客户端发来的10条消息,且客户端ID和序列号都对应得上。如果出现某条消息内容串到另一个客户端头上,那就是消息解析或者缓冲区管理出了问题,赶紧回头查 ReadExactlyAsync 和 MessageProtocol.Decode。
我在实际项目里靠这个并发脚本抓到过一个典型的半包bug,原因是消息长度字段大小端写反了,服务端把长度判断成一个极大的数字,一直等永远等不到的字节数,整个连接就卡死了。NetworkToHostOrder 和 HostToNetworkOrder 这两个转换,发送、接收两端必须一致,多写几个不同的端口测试环境就能暴露出来。
7. 踩坑记录:粘包、主线程调度和缓冲区边界
最后把我在这个项目里踩过最深的几个坑列一下,都是真实教训,网上教程一般不写这些。
粘包和半包的细节处理。我在第3节说过用 ReadExactlyAsync 解决半包。但粘包还有个更隐蔽的情况:一次 ReadAsync 读到了两条完整消息,比如4字节长度+10字节消息体+4字节长度+5字节消息体,一共23字节。如果你只读一次长度+一次消息体,就会遗漏后面的那条消息。我这里的处理方式是在外层再套一层循环:读长度、读消息体、然后立刻回到循环开头读下一个长度,所以多出来的数据会留在 NetworkStream 缓冲区里被下一次循环读到,不会丢。关键是要保证 ReadExactlyAsync 不会多读——它绝不读超过count的字节,多的留在流里。
Unity侧不能在Socket线程里改UI。Unity的大部分API不是线程安全的,尤其是Transform、UI Text、GameObject这类。而 NetworkStream.ReadAsync 的回调线程是线程池线程,不是主线程。我在跑通第一个demo时,直接在收到消息的线程里调 Debug.Log 没问题,但一改成改Text就随机报 MissingReferenceException,最后才知道是线程问题。解法是做一个线程安全的队列:
csharp复制private ConcurrentQueue<Action> _mainThreadActions = new();
void Update()
{
while (_mainThreadActions.TryDequeue(out Action action))
{
action?.Invoke();
}
}
void OnMessageReceived(string message)
{
_mainThreadActions.Enqueue(() => {
_statusText.text = message;
});
}
缓冲区设多久合适。我一开始把 _buffer 设成1024字节,觉得够用,结果消息一大就溢出。后来改成4096,其实还是不够保险。更好的做法是不要用固定缓冲区装消息体,而是根据读到的长度字段动态 new byte[messageLength]。这也是我在 ClientConnection 里采用的做法——_lengthBuffer 固定4字节,消息体按需分配,万无一失。
还有一个容易忽略的边界条件:消息长度字段如果被恶意客户端设置成一个超大值(比如 int.MaxValue),服务端就会一直等待读取。正式环境要给消息体长度设上限,比如超过512KB直接拒绝。基础demo可以暂时不处理,但你心里得清楚这里有个隐患。
小端大端别混用。TCP/IP协议规定网络字节序是大端(Big-Endian),而x86机器默认是小端(Little-Endian)。BitConverter.GetBytes 得到的是本地字节序,所以在发送前要 HostToNetworkOrder,接收后要 NetworkToHostOrder。如果你只在本机联调可能碰巧没问题,但跨平台就很容易踩中——Unity在手机上跑的时候就是ARM处理器,字节序表现和PC不同,这种bug特别难查。
做一个能在项目里长期用的服务端基础框架,我的经验是把注意力多放在"边界情况"上:消息边界、线程边界、生命周期边界。边界处理好了,核心收发逻辑反而简单。这套骨架代码跑通之后,你可以往上面继续加房间系统、帧同步、状态同步,但地基就是这篇文章里讲到的这么多——监听连接、流式读写、协议封包、心跳检测、消息路由。你把这五件事吃透,Unity网络的客户端再花哨,对服务端来说也只是多解析一种消息而已。
