简介面向 Unity 游戏开发者的一份轻量示例代码包主要演示 Unity 客户端借助 sproto 协议与 Skynet 服务端完成双向通信的工程实现。资源围绕协议文件编写、.sproto 转 C# 脚本、客户端连接服务端、连接建立与异常处理等关键环节展开并给出客户端发送 sayhello、服务端发送 heartbeat 两类消息的具体代码示例适合有一定 C# 与网络编程基础、想在 Skynet 架构上快速接入 Unity 的开发者直接参考。压缩包以 zip 格式提供共 3 个文件包含 HTML 说明页、代码工程文件Inscode与 gitignore 配置整体仅 7KB体积极小方便快速下载和对比学习。目前已有 207 人学习可配合开发实际项目时查阅。通过这份源码读者可以了解从服务端搭建到 Unity 端消息解析的完整链路减少协议联调中的重复踩坑提升客户端与服务端的交互效率。1. 为什么选Skynet做Unity游戏的服务端先解决Post请求式思维的坑先聊点实际的。很多人第一次接触Unity网络通信都是从UnityWebRequest或者Socket写一个简单的客户端发消息 - 服务端回消息开始。单看这一个来回好像没什么难的。但一旦你的项目是MMORPG、策略SLG或者哪怕只是一个带实时战斗的棋牌游戏问题马上就变了服务端怎么主动把消息推给客户端这才是Unity与Skynet通信真正要解决的核心问题。我用Skynet做游戏服务端前后跟过三个项目从最早的自研C服务器迁移到Skynet再到现在稳定跑着几万日活的棋牌和SLG项目可以负责任地说Skynet这套基于Actor模型的服务端框架跟Unity客户端的契合度比很多人想象中要高得多。原因有几点Skynet的每个服务Service是独立的Lua虚拟机天然隔离崩溃不会拖垮整个服务器。调试的时候哪个服务出问题重启哪个就够了。Skynet内部消息是异步的但通过skynet.call和skynet.send的巧妙设计可以让你同步写代码、异步跑逻辑代码可读性和运行效率两头都占。对Unity客户端非常友好的一点Skynet的消息头设计相当克制它只管分发不绑定任何协议格式。你用Google Protobuf也好、JSON也好、自定义二进制也好都能接。但恰恰是这个协议不绑定的特性坑了不少人。我最初用Skynet时犯过一个典型的错误在服务端用了skynet.call这种等待返回的模式然后客户端也配套地写了一个发消息之后等回调的网络层。结果呢一旦服务端需要主动推送比如玩家A下线了要通知玩家B的好友列表刷新整个客户端逻辑就乱了——因为客户端网络层从头到尾就设计成了一问一答压根没给服务端主动发消息留通道。这就是典型的Post请求式思维用惯了HTTP的人特别容易这样套在了长连接游戏协议上。要解决这个问题首先就得把思维转过来Unity与Skynet之间是一条全双工的长连接Request/Response只是其中一种消息流转方式服务端主动Push才是常态。后文所有代码和设计都围绕这个前提展开。提示如果你是从零开始搭我个人建议先用最简单的TCP 自定义二进制协议跑通不要一上来就上Protobuf也不要一上来就搞网关集群。先把链路打通再谈优化。2. 通信协议设计封包、拆包与消息ID映射2.1 为什么宁可自己封装二进制协议也不直接用WebSocket字符串先给结论如果是移动端游戏跟Skynet自建服通信我强烈建议用TCP 二进制协议如果是页游或者需要穿透某些复杂公司网络的场景再考虑WebSocket。原因很朴素WebSocket的帧头和数据封装有额外开销而且对帧类型、掩码这些处理逻辑在自建服务器上不会帮你省多少事反而多一层理解成本。TCP裸连看着原始实际上你只要处理好粘包、半包后面加字段、加协议都极为自由。一个典型的协议帧格式可以设计成这样字段字节数说明Magic2固定为 0xAB 0xCD用于快速校验非法连接Length4从Cmd开始的整包长度不含包头Cmd4消息ID用于区分业务逻辑Seq4自增序号客户端生成用于回调配对DataN业务数据可以是Protobuf/JSON/自定义字节流Magic其实是很多容易忽略的点。有了它服务端在收数据时可以先判断是不是自己的客户端如果数据完全错乱也能快速丢掉避免对后面的数据流造成污染。Seq这个字段的作用是做请求-响应配对——比如客户端发了一个查询背包的消息报文里带Seq1001服务端处理完回包时原样带回Seq1001客户端一看到这个序号就知道这是我的那个查询请求的返回不会跟别的消息混淆。// C# 端封包示例Unity侧 public static byte[] Encode(uint cmd, uint seq, byte[] data) { int totalLen 4 4 data.Length; // Cmd Seq Data byte[] buffer new byte[2 4 totalLen]; int offset 0; buffer[offset] 0xAB; buffer[offset] 0xCD; // Length: 小端序写入 buffer[offset] (byte)(totalLen 0xFF); buffer[offset] (byte)((totalLen 8) 0xFF); buffer[offset] (byte)((totalLen 16) 0xFF); buffer[offset] (byte)((totalLen 24) 0xFF); // Cmd buffer[offset] (byte)(cmd 0xFF); buffer[offset] (byte)((cmd 8) 0xFF); buffer[offset] (byte)((cmd 16) 0xFF); buffer[offset] (byte)((cmd 24) 0xFF); // Seq buffer[offset] (byte)(seq 0xFF); buffer[offset] (byte)((seq 8) 0xFF); buffer[offset] (byte)((seq 16) 0xFF); buffer[offset] (byte)((seq 24) 0xFF); Buffer.BlockCopy(data, 0, buffer, offset, data.Length); return buffer; }注意上面的Length只从Cmd开始算不包含Magic和Length自己。这个细节最好在客户端和服务端保持一致否则两边对包体长度的理解不同拆包直接错位。2.2 服务端Lua侧如何拆包和分发Skynet侧我一般用一个独立的protocol.lua专门负责拆包、封包和消息分发这样可以避免socket逻辑与业务逻辑混在一起。Skynet的socket模块提供了socket.read这类接口但在真实项目中我不建议直接每个消息都read一次而是自己维护一个接收缓冲区用数据驱动的方式拆包。-- 服务端 protocol.lua 核心拆包逻辑 local protobuf require protobuf local M {} -- 缓冲累积 local recv_buffer {} function M.decode_packet(data) -- data 是 socket 收到的数据块可能是半个包也可能是多个包 table.insert(recv_buffer, data) local buf table.concat(recv_buffer) recv_buffer {} local packets {} local offset 1 while offset 6 #buf do -- 检查 Magic if string.byte(buf, offset) ~ 0xAB or string.byte(buf, offset 1) ~ 0xCD then -- 非法数据断开连接或者重置缓冲 return nil, magic error end local length string.byte(buf, offset 2) | (string.byte(buf, offset 3) 8) | (string.byte(buf, offset 4) 16) | (string.byte(buf, offset 5) 24) if offset 6 length - 1 #buf then -- 半包等待更多数据 break end local cmd string.byte(buf, offset 6) | (string.byte(buf, offset 7) 8) | (string.byte(buf, offset 8) 16) | (string.byte(buf, offset 9) 24) local seq string.byte(buf, offset 10) | (string.byte(buf, offset 11) 8) | (string.byte(buf, offset 12) 16) | (string.byte(buf, offset 13) 24) local body string.sub(buf, offset 14, offset 6 length - 1) table.insert(packets, { cmd cmd, seq seq, body body, }) offset offset 6 length end -- 剩余数据放回缓冲 if offset #buf then table.insert(recv_buffer, string.sub(buf, offset)) end return packets end return M这段代码里有几个点值得单独说Magic校验失败一定要处理不能只做log。攻击者可以随便发垃圾数据如果服务器每次都把非法数据留下来内存溢出都可能发生。while循环里拆包是为了处理一次收到多个包的情况。如果不用循环压测时很容易出问题。拆到半包break时把剩下的数据放回缓冲区等待下一次socket数据到达。这里我并没有使用递归或者协程纯粹靠循环偏移量推进逻辑上最直观。2.3 消息ID如何映射到Lua函数消息IDCmd是整个通信的纽带。两个项目里用过两种方式一是纯数字比如1001代表登录、1002代表心跳二是用module.command的字符串转数字。说实话小项目用纯数字最快但项目一大非常容易秘籍满天飞——不知道哪个ID被谁用掉了。后来我改成了一种半自动方案在Unity端用C#枚举定义Cmd常量在Lua端用一张映射表把数字转成module.func。-- 服务端消息映射表 local handler { [1001] gate.login, [1002] gate.heartbeat, [1003] player.getInfo, [1004] battle.enter, } -- 分发给具体模块 local module_handlers {} function M.dispatch(session, sender, cmd, seq, body) local name handler[cmd] if not name then -- 未知消息可以打日志或者回错误包 return false end local dot name:find(%.) local module_name name:sub(1, dot - 1) local method_name name:sub(dot 1) local module module_handlers[module_name] if not module then module require(logic. .. module_name) module_handlers[module_name] module end local ok, err pcall(module[method_name], session, sender, seq, body) if not ok then -- 业务逻辑出错回错误码给客户端 skynet.error([dispatch error] cmd, cmd, err, err) end return true end用pcall包一层是我后来才想到的。早期没包一个业务函数里小小的空指针错误直接把处理消息的协程弄崩了玩家端表现就是点了没反应。包了之后哪怕逻辑有bug至少整条连接还活着服务端还能打日志。这个习惯建议大家尽早养成。注意module[method_name]这种动态调用方式要求你的业务模块函数签名完全一致。比如登录模块的login函数就固定接收(session, sender, seq, body)千万不要一个模块一个签名除非你愿意在分发层写一堆if-else。3. Unity客户端网络管理器连接、收发与主线程调度3.1 网络层不该写在MonoBehaviour里从Unity项目架构的角度讲我强烈不建议把Socket连接直接写在一个MonoBehaviour里。原因有两点一是Unity生命周期OnDestroy、OnApplicationPause等和网络层强耦合后以后你想把网络层抽成一个单独的DLL或者放到Editor工具里做联调都会很痛苦二是网络层需要常驻如果挂在某个场景的GameObject上切场景时很容易被销毁掉。我的做法是网络层做成一个纯C#类NetworkManager不继承MonoBehaviour然后由一个挂满整个游戏生命周期的单例GameObject引用它并且在场景切换时DontDestroyOnLoad。using System; using System.Net.Sockets; using System.Threading; using System.Collections.Concurrent; public class NetworkManager { private TcpClient _client; private NetworkStream _stream; private Thread _receiveThread; // 收包队列Unity主线程每帧取一次 private ConcurrentQueuePacket _incomingPackets new ConcurrentQueuePacket(); // 待发送队列任意线程塞入 private ConcurrentQueuePacket _outgoingPackets new ConcurrentQueuePacket(); private uint _seq 0; public event ActionPacket OnPacketReceived; public event Action OnDisconnected; public bool IsConnected _client ! null _client.Connected; public void Connect(string ip, int port) { _client new TcpClient(); _client.NoDelay true; // 禁用Nagle算法降低小包延迟 _client.BeginConnect(ip, port, OnConnected, null); } private void OnConnected(IAsyncResult ar) { _client.EndConnect(ar); _stream _client.GetStream(); _receiveThread new Thread(ReceiveLoop); _receiveThread.IsBackground true; _receiveThread.Start(); } private void ReceiveLoop() { byte[] headerBuffer new byte[10]; try { while (true) { // 先读满10字节头Magic 2 Length 4 Cmd 4 ReadFull(headerBuffer, 10); ushort magic BitConverter.ToUInt16(headerBuffer, 0); if (magic ! 0xCDAB) { OnDisconnected?.Invoke(); break; } int bodyLen BitConverter.ToInt32(headerBuffer, 2); uint cmd BitConverter.ToUInt32(headerBuffer, 6); byte[] body new byte[bodyLen]; ReadFull(body, bodyLen); // 注意这里Seq在body里或者你可以单独在头部加Seq字段 // 这里为了演示假设Seq包含在body前4字节 uint seq BitConverter.ToUInt32(body, 0); byte[] payload new byte[bodyLen - 4]; Array.Copy(body, 4, payload, 0, bodyLen - 4); _incomingPackets.Enqueue(new Packet { Cmd cmd, Seq seq, Data payload }); } } catch (Exception e) { UnityEngine.Debug.LogWarning(Network receive error: e.Message); OnDisconnected?.Invoke(); } } private void ReadFull(byte[] buffer, int count) { int offset 0; while (offset count) { int read _stream.Read(buffer, offset, count - offset); if (read 0) throw new SocketException(); offset read; } } // 由Unity主线程在Update中调用 public void PollEvents() { while (_incomingPackets.TryDequeue(out var packet)) { OnPacketReceived?.Invoke(packet); } } public void Send(uint cmd, byte[] data) { if (!IsConnected) return; byte[] packet PacketCodec.Encode(cmd, _seq, data); _stream.Write(packet, 0, packet.Length); _stream.Flush(); } }C#这边的拆包逻辑和服务端异曲同工先固定读头再根据Length读body。唯一的区别是这里直接在ReceiveLoop里按一次读满一个包来拆而不是把半包缓存起来——因为NetworkStream.Read在一定时间内阻塞等数据所以只要循环保证读满指定字节数就不会出现半包问题。ReadFull里的while循环是关键很多人以为_stream.Read一次就能读完所有数据实际上TCP流根本不能保证这点必须循环读直到满。3.2 为什么需要把回调丢到主线程这一节其实是个坑中坑。Unity的Update、OnGUI、StartCoroutine这些都是在主线程执行的凡是操作GameObject、Transform、UGUI的行为都必须发生在主线程。而ReceiveLoop跑在一个后台Thread上如果直接在收包线程里触发OnPacketReceived回调里一写Text.text xxx编辑器下可能不报错真机上轻则卡顿重则崩溃。Unity虽然会打印get_transform can only be called from the main thread但有些第三方库封得深报错信息会被吞掉。所以上面的代码用了一个ConcurrentQueue把收包结果暂存起来主线程每帧PollEvents时再统一派发。这个模式虽然简单但只要你按照这个思路走后面无论接入UniTask还是协程做异步都不会踩线程错乱的雷。实际项目里我还把PollEvents放到了PlayerLoop的Update和FixedUpdate之前确保逻辑层在每帧开始时就能收到最新的网络消息。更讲究一点的做法是用PlayerLoopSystem自定义注入但多数项目没这个必要在某个MonoBehaviour.Update里调用就够了。3.3 封装请求-响应一个带回调的Send有了基础连接接下来就是每个Unity项目都会用到的请求-响应封装。虽然前面我说了不能只做Request/Response但业务里大量操作确实天然是一问一答查背包、购买道具、加载好友列表……这些都需要等服务器确认后再刷新UI。做法是SendWithCallback在发送时把Seq和回调函数放进一个字典里。服务端回包时带了相同的Seq网络层拆出来发现字典里有这个Seq就直接调用对应回调。private Dictionaryuint, ActionPacket _pendingCallbacks new Dictionaryuint, ActionPacket(); public void SendWithCallback(uint cmd, byte[] data, ActionPacket onResponse) { uint seq _seq; _pendingCallbacks[seq] onResponse; byte[] packet PacketCodec.Encode(cmd, seq, data); _stream.Write(packet, 0, packet.Length); _stream.Flush(); } public void OnPacketReceivedInternal(Packet packet) { if (_pendingCallbacks.TryGetValue(packet.Seq, out var cb)) { _pendingCallbacks.Remove(packet.Seq); cb(packet); } else { // 服务端主动推送的消息走全局事件 OnPacketReceived?.Invoke(packet); } }这样一个看起来稍微高级一点的网络层就同时支持了主动请求-回包和服务端推送两种模型。很多Unity商城里的网络框架也是这个套路只是绕了很多层封装过度之后你自己反而看不懂了。我觉得网络层保持这种一张字典 一个回调的朴素的形态遇到问题最好排。4. 完整通信流程实操从登录到实时推送4.1 登录场景握手 Token校验现在把前面的代码串起来写一个完整的登录流程。游戏客户端启动后第一步要做的是连接Skynet网关服务。Skynet里一般有一个gate服务专门负责与客户端建立连接、维护会话业务逻辑会放在后面的agent服务里。一个实用的小建议不要让用户直接输账号密码就连最好先通过HTTP或HTTPS从登录服拿一个一次性Token再拿着Token去连Skynet。Skynet的gate收到Token后向auth服务校验校验通过就接受连接——这能避免账号密码在长连接上反复传输一旦长连接被中间人截获密码也不会直接泄露。虽然很多小项目觉得多此一举但投入产出比真的高因为加这个校验只是多打几十行代码。-- Skynet gate.login function M.login(session, sender, seq, body) local request protobuf.decode(LoginRequest, body) local token request.token local account request.account -- 向 auth 服务校验 token local ok, account_id skynet.call(auth, lua, check_token, account, token) if not ok then -- 校验失败回错误码 local response protobuf.encode(LoginResponse, { code 401, msg token invalid, }) skynet.send(sender, lua, send_by_session, session, 1001, seq, response) return end -- 校验通过先建立 agent 服务 local agent skynet.newservice(agent) skynet.call(agent, lua, bind_account, account_id) -- 把 agent 地址写入 gate 的会话管理表中后续数据都转发给 agent skynet.call(sender, lua, bind_agent, session, agent) -- 返回登录成功 local response protobuf.encode(LoginResponse, { code 0, msg ok, account_id account_id, }) skynet.send(sender, lua, send_by_session, session, 1001, seq, response) end这段代码里有一个Skynet初学者比较容易懵的点session和sender的区别。sender是消息来源服务的地址这里是gate服务自己session是这条客户端连接的会话ID。skynet.send(sender, lua, send_by_session, session, ...)就表示让gate服务通过session找到对应的socket连接把数据发回去。Unity侧的登录调用其实很简单public void Login(string account, string token) { var request new LoginRequest(); request.Account account; request.Token token; byte[] data ProtobufHelper.Serialize(request); _network.SendWithCallback(1001, data, (packet) { var response ProtobufHelper.DeserializeLoginResponse(packet.Data); if (response.Code 0) { // 登录成功进入主场景 SceneManager.LoadScene(Main); } else { UIManager.ShowTip(登录失败: response.Msg); } }); }4.2 服务端Push在线状态变更的实时通知登录成功之后游戏才真正跑起来。这时候最考验通信框架的就是在服务端主动推消息的场景下客户端的表现。举一个我实际做过的功能玩家A和玩家B是好友A上线了B要马上在自己的好友列表里看到A的状态从离线变成在线。这个功能用HTTP接口做近乎不可能除非轮询但对Skynet来说是小菜一碟。关键逻辑agent服务在玩家登录成功后向friend服务发消息通知我上线了。friend服务查到B的agent服务地址把A上线的消息send给B的agent。B的agent把消息推送回B的客户端。-- friend.lua function M.on_player_online(session, sender, account_id) local friend_list get_friend_list(account_id) for _, friend_id in ipairs(friend_list) do local friend_agent get_agent_by_account_id(friend_id) if friend_agent then local payload protobuf.encode(FriendOnlinePush, { account_id account_id, online true, }) skynet.send(friend_agent, lua, push_to_client, 5001, payload) end end end客户端这边收到的Cmd是5001这个数字不在任何请求-回调字典里所以会走OnPacketReceived全局事件。我通常在游戏启动时注册事件NetworkManager.Instance.OnPacketReceived OnNetworkPacket; private void OnNetworkPacket(Packet packet) { switch (packet.Cmd) { case 5001: var push ProtobufHelper.DeserializeFriendOnlinePush(packet.Data); FriendListUI.Instance.SetOnlineStatus(push.AccountId, push.Online); break; } }这个模式一旦跑通你后面做聊天、组队、跨服战都会顺很多。说白了Unity和Skynet通信的本质就是协议 回调分发谁先把这件事理顺后面就是躺赢。4.3 关于聊天消息和广播扇出优化的一点提醒聊天和广播是Skynet推送最常见的场景也是新手最容易写出性能瓶颈的地方。skynet.send一次发一个人的效率没问题但如果一个全服喇叭要发给几千人你还一个个人send那服务器的Lua虚拟机基本就卡死了。我的建议是不要在agent里做循环群发。把全服广播这类的功能放到一个独立的broadcast服务里让业务服务通过skynet.send(broadcast, lua, push, ...)把消息丢给它然后由它统一遍历在线用户列表并分发。这样做的好处是广播压力被隔离在单独服务里不会阻塞其他业务的skynet.call调用。Skynet的skynet.call是阻塞式的如果agent在处理别的请求时卡在广播循环里这个玩家所有后续操作都卡住了非常影响体验。5. 心跳、断线重连与Unity生命周期管理5.1 心跳为什么不能只在Application Quit时处理游戏上线后你一定会遇到玩家挂着游戏去干别的事回来发现连接断了的情况。移动端尤其如此iOS/Android的App在后台切换时Wi-Fi和蜂窝网络切换TCP连接经常被系统或运营商强杀。没有心跳机制客户端无法感知连接已经死亡于是UI还显示你在线实际上服务器那边早就把会话清理了。心跳协议我一般这样设计客户端每隔30秒发一条Heartbeat请求Cmd1002服务端收到后立即回一条HeartbeatResponse。如果服务端超过90秒没收到任何数据就主动断开该连接客户端如果连续3次心跳都没有收到回包就判定断线进入重连流程。private float _heartbeatTimer 0f; private int _missedHeartbeat 0; public void TickHeartbeat(float dt) { _heartbeatTimer dt; if (_heartbeatTimer 30f) { _heartbeatTimer 0f; Send(1002, new byte[0]); _missedHeartbeat; if (_missedHeartbeat 3) { Disconnect(); ReconnectDelay(); } } }服务端Lua里更简单gate服务可以在收到任何数据时更新last_active_time然后起一个定时器定时扫描会话表超时的连接调用socket.close。5.2 断线重连状态机别一股脑往上冲断线重连最容易犯的错是一断线就立刻重新连接。如果服务器因为重启或者网络抖动还没恢复你会看到客户端疯狂重试像热锅上的蚂蚁。更好的做法是引入一个简单的重连状态机状态触发条件行为Connected成功连接正常收发Reconnecting检测到断线首次立即重连之后指数退避WaitRetry重连失败5秒后重试最多N次GiveUp重试次数超限弹窗提示网络连接失败Unity端实现时可以用MonoBehaviour的协程来写退避逻辑private IEnumerator ReconnectRoutine() { int retry 0; while (retry 5) { yield return new WaitForSeconds(Mathf.Min(30f, 5f * Mathf.Pow(2f, retry))); bool connected TryReconnect(); if (connected) { OnReconnected(); yield break; } retry; } UIManager.ShowReconnectFailed(); }这里指数退避的等待时间分别是5秒、10秒、20秒、30秒、30秒封顶。为什么要封顶因为如果服务器正在重启10秒内大概率起不来30秒也未必但无限退避会让玩家觉得游戏卡死了。折中方案是30秒封顶但仍然持续尝试直到玩家手动切后台重进。5.3OnApplicationPause和OnApplicationFocus里的坑Unity移动端的关键坑App切到后台时Socket并不会立刻断开但网络状态可能已经变了。如果你的游戏在后台待久了回来时连接多半已经死了。我推荐的做法是在OnApplicationPause(true)时暂停心跳不主动断连在OnApplicationPause(false)回到前台时立刻发一条心跳。如果立刻收到回包说明连接还在如果没回包就按上面的重连逻辑走。private void OnApplicationPause(bool pauseStatus) { if (pauseStatus) { _heartbeatTimer 0f; } else { // 回到前台立刻检测连接 Send(1002, new byte[0]); } }这个设计的潜台词是不要在前台恢复时盲目Disconnect重连因为有可能连接本来就活着强行断开反而给自己添乱。5.4 断线体验优化客户端状态补拉断线重连成功后很多玩家会抱怨我的背包数据是不是回档了。其实不是回档是断线期间服务器上的数据变了客户端UI还停留在旧状态。这时候客户端应该向服务器做一次状态全量拉取把所有关键模块的数据刷新一遍。协议上可以做一个SyncAll接口服务端直接把玩家当前的货币、背包、任务、好友等数据一次性发给客户端。这个补拉逻辑不做你的重连机制再完善玩家也会觉得游戏有bug。6. Unity和Skynet联调中的日志与排错技巧6.1 先让两端日志格式对齐联调的时候最痛苦的场景客户端报发送超时服务端报没有收到消息两边开始互相甩锅。这种情况十有八九是协议字段理解不一致。我的经验是在客户端和服务端各写一个统一的日志格式输出函数把收发消息的Cmd、Seq、body长度统一打印。比如客户端public static void LogPacket(bool isSend, Packet packet) { Debug.Log(${(isSend ? [C-S] : [S-C])} cmd{packet.Cmd} seq{packet.Seq} len{packet.Data.Length}); }服务端Luafunction M.log_packet(direction, session, cmd, seq, len) skynet.error(string.format([%s] session%d cmd%d seq%d len%d, direction, session, cmd, seq, len)) end就这么简简单单的一个对齐日志已经帮我在项目里抓到了不下十个问题包括客户端把Cmd和Length的字节序搞反了两端大小端不一致服务端拆包后cmd错得离谱。客户端发的是大端序服务端按小端序解析序列号完全乱掉但流量没断看起来像偶发超时。6.2 用回声服务做连通性测试我在新项目开工时一定会先写一个Skynet的echo服务专门把收到的消息原样返回不掺任何业务逻辑。客户端连上来后先发几条echo确认数据往返正常再写业务逻辑。这样做的价值在于当你后面发现业务报文出错时可以先切到echo模式确认通信链路本身是好的把问题隔离在业务逻辑层。-- echo.lua function M.echo(session, sender, seq, body) skynet.send(sender, lua, send_by_session, session, 9999, seq, body) end客户端发Cmd9999内容随便填只要能原样收到说明链路通。6.3 压测阶段必须警惕的死连接压测时最诡异的现象服务端控制台显示会话还是已连接但客户端已经收不到任何消息了。这多半是半开连接——客户端进程被强杀比如kill -9或者Unity编辑器直接停止PlayTCP层只有到超时才会发现连接死了服务端Socket还在傻等数据。解决半开连接的唯一可靠手段就是心跳。Skynet的gate服务端也要做心跳超时主动断开不要把客户端断没断的判断完全交给Socket。对于压测来说还有一个实用技巧在服务器端每隔一段时间打印一次当前存活连接数和最近收到数据的连接数两者差值过大就说明有大量半开连接。7. 重要的一致性经验协议字段的增删改7.1 客户端和服务端谁的协议改动更麻烦先说结论在Skynet Unity这个组合里协议字段改动最麻烦的不是服务端Lua不是Unity C#而是已经打包出去的线上客户端。Lua端发布新协议非常快热更一下服务端配置就行。C#端只要还没打进包里改起来也不难。但一旦玩家手里的包是旧版你的服务端就必须做协议兼容。我踩过一次很惨的坑某版本在登录协议里加了一个channel字段没做可选处理结果老版本客户端不发这个字段服务端protobuf.decode后直接报错导致所有老客户端登录失败。那次事故让我学到一个经验除非你能保证所有客户端同时更新否则服务端解析协议时一定要对字段缺失做防御性处理。local channel request.channel or 就这一行能省去一次线上事故。7.2 给协议号分段预留扩展空间协议号规划上我建议按模块分段不要一口气全用掉范围模块1000-1999登录、会话、心跳2000-2999玩家信息、背包3000-3999战斗4000-4999好友、聊天5000-5999推送、广播这样做的直接好处是看到Cmd3501你立刻知道是战斗模块不用翻文档。另一个隐藏好处是给将来加需求留了连续段不会出现新协议号插在旧协议中间这种逼死强迫症的情况。7.3 让两端共用一份协议定义文件如果你用Protobuf强烈建议把.proto文件维护在同一份仓库里不管是客户端目录还是服务端目录用脚本同步到两边。比如服务端Lua用pbc或lua-protobufUnity端用C#生成的pb.cs只要.proto文件是同一份生成的类就天然一致。手工维护两份协议定义迟早会出现服务端字段叫user_id客户端叫userId序列化结果对不上的悲剧。8. 进阶优化数据压缩、加密与热更协作8.1 什么时候该用压缩手游弱网环境下一个全服广播的ChatMessage可能同时推给几百人消息体里的聊天文本如果是UTF-8明文一个汉字占3字节。一个50字的聊天消息就是150字节几百人广播出去就是几十KB流量开支不小。这时候可以考虑在协议层加一个compress标志如果body长度大于某个阈值比如512字节发送端用zlib压缩后再塞进Data区接收端解压后解析。Unity端用C#自带的System.IO.Compression.DeflateStream即可Lua服务端用zlib库。两边解压速度都不慢开销可以忽略。唯一的坑就是别把压缩和解压写反了这个不用我多说吧。8.2 加密至少要防抓包改包很多人觉得游戏通信加密是给黑客看的小项目就不做了。但我个人的体会是不做加密玩家用抓包工具把你的协议格式摸个底朝天接下来改包刷金币、作弊啥的都来了。你至少要做到登录阶段生成一个session_key用Diffie-Hellman或者其他密钥交换算法协商出会话密钥。后续每次发送消息的body用AES-128-CBC或者XORRC4之类的算法加密。服务端主动推送的消息同样加密。实际项目中我用过最简单的方案登录时服务端下发一个随机game_key之后所有body都用AES-CBC加密IV取序列号。够用也不是很复杂。但要注意AES的Key和IV绝对不能写死在客户端里否则等于没加密。8.3 与C#热更框架配合时网络层的定位很多Unity项目接了HybridCLR或者ILRuntime做热更网络层代码放热更层还是AOT层是一个值得思考的问题。我的建议是底层Socket收发、拆包、TcpClient管理这些放AOT层不热更协议字段定义和业务消息派发放热更层热更。这样协议变了打一个热更包就能解决大部分问题而底层网络代码本身比较稳定没必要塞进热更里增加体积和风险。Skynet服务端有一个天然优势Lua脚本本身就能热更用skynet.reload或者单独的服务重载。所以两端热更能力其实是齐平的——协议变了服务端改Lua客户端改C#热更层双方都不需要发布新包维护成本降了很多。8.4 日志要带时间戳和会话ID最后再提醒一个容易被忽视的点联调日志一定要带时间戳和会话ID。Skynet的skynet.error默认会带时间但客户端Debug.Log默认没有。我给客户端的网络层日志加了一个秒级时间戳实际排查时作用巨大——尤其是服务端和客户端不在同一台机器时你看到客户端说15:03:20发送了登录包服务端15:03:21才打印收到立刻就知道中间有一次网络延迟或者队列等待而不是去猜客户端是不是没发。如果你能接受稍微多花一点时间把客户端日志直接上报到服务端两个系统的日志串在一个时间轴上查看那排错效率会再上一个台阶。不过这个属于进阶玩法了等项目的通信链路稳定后再做也不迟。Unity和Skynet这套组合说穿了就是C#客户端发消息 - 二进制编码 - TCP - Lua服务端收消息 - 拆包 - 业务分发 - 回包链路本身不复杂复杂的是在真实环境中把它做稳。我写这篇的目的一是把整套思路清晰整理出来二是把我在项目中反复踩过的协议位、线程调度、心跳重连这些坑标记出来希望你能少走弯路。如果有条件建议你从第一节里的最小echo开始一步一步把登录和心跳串起来跑通后再加其他业务模块——这个顺序能省掉大量分不清是通信问题还是业务问题的烦恼。本文还有配套的精品资源点击获取