所讀版本:機械工業出版社

這一篇在做什麼

前面已經有了連接、收發與異步回調,接下來要面對的是「資料收到了之後要怎麼組織」。

這一篇主要處理兩件事:

  • 怎樣定義一個簡單可解析的通信協議
  • 怎樣在 Unity 裡用消息隊列,把異步線程收到的資料安全地交回主線程處理

為什麼需要通信協議

Socket 只負責傳送字節流,不會幫你理解這串字節到底代表:

  • 聊天消息
  • 登錄請求
  • 移動同步
  • 系統通知

因此,客戶端和服務端必須事先約定資料格式,也就是通信協議。

最簡單的做法之一,是把消息整理成固定格式的字符串,例如:

消息名|客戶端IP與端口,參數1,參數2,參數n

這樣收包後就能先拆出:

  1. 消息類型
  2. 參數內容

再交給對應的處理函數去執行。

為什麼還要消息隊列

在 Unity 裡,大部分 UI 與遊戲對象操作都要求在主線程執行。

但 Socket 的異步回調通常不保證回到 Unity 主線程。實際上,BeginReceive 等異步 API 多半會由線程池處理,所以:

  • 第一次接收回調不一定在主線程
  • 下一次回調也不一定和上一次在同一條線程
  • 如果直接在回調裡動 UI,很容易出現各種錯亂或例外

因此,一個很實用的辦法是:

  1. 異步線程只負責把收到的消息放進隊列
  2. 主線程每幀從隊列取消息
  3. 真正的業務邏輯與 UI 更新在主線程完成

這個管理器想解決什麼

這一篇的 NetManager 可以看成一個很小的中控:

  • 管理 Socket 連接
  • 記錄不同消息名對應的處理函數
  • 保存待處理消息隊列
  • 在主線程中逐條消費消息

NetManager 範例

public static class NetManager {
    static Socket socket;
    //接收緩沖區
    static byte[] readBuff = new byte[1024];
    //委托類型
    public delegate void MsgListener(string str);
    //監聽列表(各個消息名對應的處理方法)
    private static Dictionary<string, MsgListener> listeners = new Dictionary<string, MsgListener>();
    //消息隊列
    static List<string> msgList = new List<string>();

    //記錄消息對應的處理方法
    public static void AddListener(string msgName, MsgListener listener)
    {
        listeners[msgName] = listener;
    }

    public static void Connect(string ip, int port)
    {
        socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
        socket.Connect(ip, port); //同步連接
        socket.BeginReceive(readBuff, 0, 1024, 0, ReceiveCallback, socket); //異步接收信息
    }

    static void ReceiveCallback(IAsyncResult ar)
    {
        try
        {
            Socket socket = (Socket)ar.AsyncState;
            int count = socket.EndReceive(ar);
            string recvStr = System.Text.Encoding.UTF8.GetString(readBuff, 0, count);

            //消息隊列更新
            msgList.Add(recvStr);

            socket.BeginReceive(readBuff, 0, 1024, 0, ReceiveCallback, socket);
        }
        catch(SocketException ex)
        {
            Debug.Log("Socket Receive Failed: " + ex.ToString());
        }
    }

    public static void Send(string sendStr)
    {
        if (socket == null) { return; }
        if (!socket.Connected) { return; }

        //同步發送
        byte[] sendBytes = System.Text.Encoding.UTF8.GetBytes(sendStr);
        socket.Send(sendBytes);
    }

    //每幀更新,持續處理消息隊列中的消息
    public static void Update()
    {
        if (msgList.Count <= 0) { return; }
        string msgStr = msgList[0];
        msgList.RemoveAt(0);

        string[] split = msgStr.Split('|');
        string msgName = split[0];
        string msgArgs = split[1];

        if (listeners.ContainsKey(msgName))
        {
            listeners[msgName](msgArgs);
        }
    }
}

逐段拆開看

監聽器字典

public delegate void MsgListener(string str);
private static Dictionary<string, MsgListener> listeners = new Dictionary<string, MsgListener>();

這部分的作用是把「消息名」映射到「對應處理函數」。

例如:

  • Move 對應角色移動更新
  • Chat 對應聊天顯示
  • Login 對應登錄結果處理

只要調用 AddListener,就能把某個消息名註冊到對應的處理邏輯上。

消息隊列

static List<string> msgList = new List<string>();

這個列表充當最基本的消息隊列。異步接收線程只往尾部追加新消息,而主線程則從頭部取出處理。

雖然這裡用的是 List<string>,但思路本質上就是 FIFO 隊列。

連接與首次接收

public static void Connect(string ip, int port)
{
    socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
    socket.Connect(ip, port); //同步連接
    socket.BeginReceive(readBuff, 0, 1024, 0, ReceiveCallback, socket); //異步接收信息
}

這裡是同步 Connect 加異步 BeginReceive 的組合。也就是說:

  • 連接建立本身仍然是同步的
  • 收包則交給異步回調處理

ReceiveCallback 在做什麼

static void ReceiveCallback(IAsyncResult ar)
{
    try
    {
        Socket socket = (Socket)ar.AsyncState;
        int count = socket.EndReceive(ar);
        string recvStr = System.Text.Encoding.UTF8.GetString(readBuff, 0, count);

        //消息隊列更新
        msgList.Add(recvStr);

        socket.BeginReceive(readBuff, 0, 1024, 0, ReceiveCallback, socket);
    }
    catch(SocketException ex)
    {
        Debug.Log("Socket Receive Failed: " + ex.ToString());
    }
}

這裡的工作非常克制,只做三件事:

  1. 把接收到的字節轉成字符串
  2. 放進消息列表
  3. 重新掛起下一次接收

刻意不在這裡直接處理消息內容,正是為了避免在線程池回調裡亂動 Unity 主線程資源。

Send 做了哪些保護

public static void Send(string sendStr)
{
    if (socket == null) { return; }
    if (!socket.Connected) { return; }

    //同步發送
    byte[] sendBytes = System.Text.Encoding.UTF8.GetBytes(sendStr);
    socket.Send(sendBytes);
}

這裡先檢查:

  • socket 是否存在
  • 連接是否仍然有效

然後才把字符串編碼成字節並送出。這仍然是同步 Send,後面還會繼續處理更完整的收發問題。

主線程 Update 的角色

真正的消息解析與分發,是在主線程的 Update 裡做的。

public static void Update()
{
    if (msgList.Count <= 0) { return; }
    string msgStr = msgList[0];
    msgList.RemoveAt(0);

    string[] split = msgStr.Split('|');
    string msgName = split[0];
    string msgArgs = split[1];

    if (listeners.ContainsKey(msgName))
    {
        listeners[msgName](msgArgs);
    }
}

流程可以概括成:

  1. 如果隊列沒消息,就直接返回
  2. 取出最前面的一條消息
  3. 用分隔符 | 拆成消息名與消息參數
  4. 查找是否有對應監聽器
  5. 如果有,就把參數交給它處理

這樣就把「網絡接收」和「遊戲邏輯處理」分開了。

這個方案的價值

這種設計雖然簡單,但很實用,因為它同時解決了兩個問題:

第一,消息有了明確的語義入口

透過 消息名 -> 處理函數 的映射,程序不再只是收一堆原始字節,而是能把不同類型的消息導到不同業務邏輯。

第二,線程邊界被隔開

異步接收線程只負責收包入隊,不碰 Unity UI 或場景物件;主線程再統一做解析與處理,風險會小很多。

這一篇先記住的重點

如果只收斂成最重要的幾點,可以記成:

  • Socket 傳的是字節流,不是語義化消息
  • 通信協議的作用,是讓雙方知道怎麼解析資料
  • Unity 的網絡回調不適合直接操作 UI
  • 消息隊列是一個非常常見也很實用的主線程橋接方案
  • 異步接收線程負責入隊,主線程負責消費與分發

下一步就會進一步碰到更底層但更麻煩的問題:TCP 是流,不是天然分包的消息,這會引出粘包、半包與發送不完整等問題。