所讀版本:機械工業出版社
這一篇在做什麼
前幾篇把 Socket、異步、收發與協議拆開講完,這一篇開始把它們組成一個比較完整的 Unity 客戶端網絡模塊。重點是兩部分:
NetManager這個客戶端網絡管理器怎麼分層- 協議格式與編解碼要怎麼定
參考代碼
文中提到的客戶端模塊包在這裡:
Client Module Code unitypackage
先看 NetManager 想解決什麼
如果把客戶端網絡邏輯全部塞在同一個腳本裡,通常很快就會混成一團,因為它同時要負責:
- 建立連接
- 發送數據
- 接收數據
- 解析協議
- 分發網絡事件
- 分發業務消息
- 心跳檢測
所以這裡的 NetManager,本質上是一個把這些責任拆開再收束起來的中控層。
網絡事件分發
除了具體業務消息之外,網絡本身也會產生事件,例如:
- 連接成功
- 連接失敗
- 連接關閉
這些事件適合單獨維護一套監聽器列表。
//網絡事件監聽列表
private static Dictionary<NetEvent, EventListener> eventListeners = new Dictionary<NetEvent, EventListener>();
//添加網絡事件監聽
public static void AddEventListener(NetEvent netEvent, EventListener listener)
{
//...
}
//移除網絡事件監聽
public static void RemoveEventListener(NetEvent netEvent, EventListener listener)
{
//...
}
//分發網絡事件
public static void FireEvent(NetEvent netEvent, string err)
{
//...
}
這樣上層 UI 或場景邏輯就不必直接依賴底層 Socket,而是只關心「連接成功了沒」「是不是斷線了」。
服務器連接模塊
連接部分採用 BeginConnect。這裡額外要處理一個很現實的問題:如果還在連接中,玩家又按了一次連接按鈕怎麼辦。
因此會用一個 isConnecting 標記保護當前狀態。
static bool isConnecting = false;
//連接
public static void Connect(string ip, int port)
{
//...
isConnecting = true;
socket.BeginConnect(ip, port, ConnectCallback, socket);
}
//連接回調
private static void ConnectCallback(IAsyncResult ar)
{
//...
//終止連接
socket.EndConnect(ar);
//分發連接消息
FireEvent(NetEvent.ConnectSucc, "");
//重置連接狀態
isConnecting = false;
//繼續接收
socket.BeginReceive(readBuff.bytes, readBuff.writeIdx, readBuff.remain, 0, ReceiveCallback, socket);
//...
}
這段邏輯的重點是:
- 發起連接時先鎖定狀態
- 連接成功後分發事件
- 再正式進入接收循環
數據發送模塊
這套客戶端模塊不是直接把一個字符串送出去,而是先把消息拆成:
- 協議名
- 協議體
然後分別編碼,再組裝成完整字節流。
//發送數據
public static void Send(MsgBase msg)
{
//...
byte[] nameBytes = MsgBase.EncodeName(msg);
//協議名數據:長度信息+協議名2進制數據
byte[] bodyBytes = MsgBase.Encode(msg);
//協議體數據:長度信息 + 協議體2進制數據
//消息長度計算及組裝
//...
//寫入隊列
ByteArray ba = new ByteArray(sendBytes);
writeQueue.Enqueue(ba);
//...
//發送
socket.BeginSend(sendBytes, 0, sendBytes.Length, 0, SendCallback, socket);
}
//發送數據回調
static void SendCallback(IAsyncResult ar)
{
//...
//獲取成功發送的數據長度
int count = socket.EndSend(ar);
//獲取寫入隊列的第一條數據
//...
writeQueue.Dequeue();
ba = writeQueue.First();
//...
//還有數據就繼續發送
socket.BeginSend(ba.bytes, ba.readIdx, ba.length, 0, SendCallback, socket);
//...
}
和前面章節一樣,這裡用隊列是為了避免多條消息交錯發送時互相覆蓋狀態。
消息事件分發
業務消息和網絡事件是兩條線。網絡事件用枚舉值分發,而業務消息通常是依照協議名分發。
也就是說:
- 網絡事件處理的是連接層狀態
- 消息事件處理的是遊戲協議本身
兩者結構相似,但用途不同。
接收與數據處理模塊
當連接成功後,NetManager 會持續進入異步接收。接收回調要做的事大致有三步:
- 更新接收緩衝區寫入位置
- 嘗試解析完整消息
- 繼續掛起下一次接收
//消息列表
static List<MsgBase> msgList = new List<MsgBase>();
//消息列表長度
static int msgCount = 0;
//每一次Update處理的消息量
readonly static int MAX_MESSAGE_FIRE = 10;
//Receive回調
private static void ReceiveCallback(IAsyncResult ar)
{
Socket socket = (Socket)ar.AsyncState;
//獲取接收數據長度
int count = socket.EndReceive(ar);
//收到FIN信號(count == 0),斷開連接
//...
//更新寫入索引
readBuff.writeIdx += count;
//數據處理
OnReceiveData();
//剩餘空間即將不足,進行數據移位與擴容
//...
//繼續接收
socket.BeginReceive(readBuff.bytes, readBuff.writeIdx, readBuff.remain, 0, ReceiveCallback, socket);
//...
}
OnReceiveData 在做什麼
這一層才是真正的拆包與解析邏輯。它需要處理:
- 粘包半包
- 大小端
- 協議名解析
- 協議體解析
- 消息入隊
//數據處理
private static void OnReceiveData()
{
if (readBuff.length <= 2)
{
return;
}
//消息長度不足解析長度信息
//...
//解析協議名
//...
//解析協議體
//...
//檢查接收緩沖區剩餘空間
readBuff.CheckAndMoveBytes();
//把解析出來的消息加到消息列表中
lock (msgList)
{
msgList.Add(msgBase);
}
msgCount++;
//繼續讀取消息
if(readBuff.length > 2)
{
OnReceiveData();
}
}
這裡遞歸繼續解析的原因,是一次 Receive 可能同時收進來多條完整協議。
為什麼主線程只處理固定數量消息
消息最後不會在接收回調中直接執行,而是由主線程 Update 統一處理。這裡還做了一個小優化:每幀只處理上限數量的消息,避免單幀爆量消息把畫面卡住。
//Updatepublic
static void Update()
{
MsgUpdate();
}
//更新消息
public static void MsgUpdate()
{
if (msgCount == 0)
{
return;
}
//處理多條消息
for (int i = 0; i < MAX_MESSAGE_FIRE; i++)
{
//分發消息
}
}
心跳機制
客戶端這裡同樣要做心跳。基本策略是:
- 距離上次發送
Ping太久,就再發一次 - 距離上次收到
Pong太久,就視為斷線
//心跳間隔時間
public static int pingInterval = 10;
//上一次發送Ping的時間
static float lastPingTime = 0;
//上一次收到Pong的時間
static float lastPongTime = 0;
//發送Ping協議
private static void PingUpdate()
{
//Check Ping Signal Overtime and try ping again
if(Time.time - lastPingTime > pingInterval)
{
//...
}
//Check Pong Signal Overtime
if(Time.time - lastPongTime > pingInterval * 4)
{
//...
}
}
//監聽Pong協議
private static void OnMsgPong(MsgBase msgBase)
{
lastPongTime = Time.time;
}
這樣客戶端就能比 TCP KeepAlive 更快地發現「連著但其實已經失效」的連接。
協議格式
這套客戶端協議的核心思路是:消息名與消息體都顯式寫進字節流裡,而不是只傳一大坨純正文。
Json 協議格式
這裡的 Json 協議可以概括成:
- 消息長度
- 協議名長度
- 協議名
- 協議體
其中消息總長度會包含:
- 協議名長度字段
- 協議名字節
- 協議體字節

協議名編碼與解碼
EncodeName
//編碼協議名
public static byte[] EncodeName(MsgBase msgBase)
{
//協議名和長度
byte[] nameBytes = System.Text.Encoding.UTF8.GetBytes(msgBase.protoName);
Int16 len = (Int16)nameBytes.Length;
byte[] bytes = new byte[2 + len]; //2字節用於描述協議名長度
//小端組裝長度信息
bytes[0] = (byte)(len % 256);
bytes[1] = (byte)(len / 256);
Array.Copy(nameBytes, 0, bytes, 2, len);
return bytes;
}
DecodeName
//解碼協議名
public static string DecodeName(byte[] bytes, int offset, out int count)
{
count = 0;
if(offset + 2 > bytes.Length) //字節數組長度<2,無法解析長度信息
{
return "";
}
//小端模式讀取長度信息
Int16 len = (Int16)((bytes[offset + 1] << 8) | bytes[offset]);
if (len <= 0) //長度<=0
{
return "";
}
//長度不足
if(offset + 2 + len > bytes.Length)
{
return "";
}
count = 2 + len;
string name = System.Text.Encoding.UTF8.GetString(bytes, offset + 2, len);
return name;
}
協議體編碼與解碼
Json
最簡單的方案是直接用 Json 把協議對象序列化成字符串,再轉成字節。
//編碼協議體
public static byte[] Encode(MsgBase msgBase)
{
string s = JsonUtility.ToJson(msgBase);
return System.Text.Encoding.UTF8.GetBytes(s);
}
//解碼協議體
public static MsgBase Decode(string protoName, byte[] bytes, int offset, int count)
{
string s = System.Text.Encoding.UTF8.GetString(bytes, offset, count);
MsgBase msgBase = (MsgBase)JsonUtility.FromJson(s, Type.GetType(protoName));
return msgBase;
}
Json 的優點是簡單直觀,缺點是字節量通常比較大。
ProtoBuf
如果更在意協議體積與傳輸效率,可以改用 ProtoBuf。整體流程是:
- 定義
.proto文件 - 用工具生成協議類
- 用
protobuf-net做編解碼
例如:
message MsgMove{ optional int32 x = 1; optional int32 y = 2; optional int32 z = 3;}
編碼:
//使用pb編碼
public static byte[] ProtobufEncode(ProtoBuf.IExtensible msgBase)
{
using(MemoryStream ms = new MemoryStream())
{
ProtoBuf.Serializer.Serialize(ms, msgBase);
return ms.ToArray();
}
}
解碼:
//使用pb解碼
public static ProtoBuf.IExtensible ProtobufDecode(string protoName, byte[] bytes, int offset, int count)
{
using (MemoryStream ms = new MemoryStream(bytes, offset, count))
{
System.Type t = System.Type.GetType(protoName);
return (ProtoBuf.IExtensible)ProtoBuf.Serializer.NonGeneric.Deserialize(t, ms);
}
}
這一篇先記住的重點
如果把整篇再壓縮成幾條核心原則,大概是:
- 客戶端網絡模塊最好把連接、收發、事件、消息與心跳分層
- 發送與接收都應建立在明確協議格式之上
- 接收回調只負責拆包和入隊,真正的消息處理留在主線程
- 心跳機制是客戶端檢測斷線的重要補充
- Json 好上手,ProtoBuf 更省帶寬,取捨看項目需求