凌峰创科服务平台

Unity如何搭建Socket服务器?

目录

  1. 核心概念:Socket 是什么?
  2. 服务器端实现 (C#)
    • 创建 TCP 服务器
    • 处理多客户端连接
    • 广播消息给所有客户端
  3. Unity 客户端实现 (C#)
    • 连接到服务器
    • 发送和接收消息
  4. 完整项目示例
    • 服务器端代码 (SocketServer.cs)
    • 客户端代码 (SocketClient.cs)
    • Unity 场景设置
  5. 重要注意事项与最佳实践
    • 异步编程 (Async/Await)
    • 协议设计
    • 线程安全
    • 性能优化

核心概念:Socket 是什么?

可以把 Socket 想象成一个电话插座,服务器就像一个总机,客户端就像一部部电话。

Unity如何搭建Socket服务器?-图1
(图片来源网络,侵删)
  • 服务器: 监听一个特定的“电话号码”(IP 地址和端口号),等待“电话”(客户端连接)打进来,一旦有电话接入,它就拿起听筒,开始通话。
  • 客户端: 知道服务器的“电话号码”,主动拨打电话,建立连接,连接成功后,就可以通过这条线路(Socket)发送和接收数据。
  • IP 地址: 服务器的网络地址,本地开发时通常是 0.0.1 (代表本机) 或 localhost
  • 端口号: 服务器上用来区分不同服务的数字,每个服务器程序都需要一个唯一的端口号(8080),避免与其他程序冲突。

服务器端实现 (C#)

我们将创建一个简单的 TCP 服务器,它能够:

  1. 启动并监听客户端连接。
  2. 当有新客户端连接时,将其加入一个列表。
  3. 当一个客户端发送消息时,将该消息广播给所有已连接的客户端。

在 Unity 中,你可以创建一个 C# 脚本(SocketServer.cs)来管理服务器逻辑。

// SocketServer.cs
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
public class SocketServer
{
    private TcpListener _server;
    private bool _isRunning;
    private List<TcpClient> _clients = new List<TcpClient>();
    private object _lock = new object(); // 用于线程安全
    public void Start(int port)
    {
        _server = new TcpListener(IPAddress.Any, port);
        _server.Start();
        _isRunning = true;
        // 在一个单独的线程中开始接受连接
        Thread acceptThread = new Thread(new ThreadStart(AcceptClients));
        acceptThread.IsBackground = true;
        acceptThread.Start();
        UnityEngine.Debug.Log("服务器已启动,等待连接...");
    }
    private void AcceptClients()
    {
        while (_isRunning)
        {
            try
            {
                TcpClient client = _server.AcceptTcpClient();
                lock (_lock)
                {
                    _clients.Add(client);
                }
                UnityEngine.Debug.Log($"客户端 {client.Client.RemoteEndPoint} 已连接。");
                // 为每个客户端创建一个处理线程
                Thread clientThread = new Thread(new ParameterizedThreadStart(HandleClient));
                clientThread.IsBackground = true;
                clientThread.Start(client);
            }
            catch (SocketException ex)
            {
                if (_isRunning)
                {
                    UnityEngine.Debug.LogError($"接受连接时出错: {ex.Message}");
                }
            }
        }
    }
    private void HandleClient(object clientObj)
    {
        TcpClient client = (TcpClient)clientObj;
        NetworkStream stream = client.GetStream();
        byte[] buffer = new byte[1024];
        int bytesRead;
        try
        {
            while (_isRunning)
            {
                bytesRead = stream.Read(buffer, 0, buffer.Length);
                if (bytesRead == 0)
                {
                    // 客户端断开连接
                    break;
                }
                string message = Encoding.UTF8.GetString(buffer, 0, bytesRead);
                UnityEngine.Debug.Log($"收到来自 {client.Client.RemoteEndPoint} 的消息: {message}");
                // 广播消息给所有客户端
                Broadcast(message);
            }
        }
        catch (Exception ex)
        {
            UnityEngine.Debug.LogError($"处理客户端 {client.Client.RemoteEndPoint} 时出错: {ex.Message}");
        }
        finally
        {
            // 清理资源
            lock (_lock)
            {
                _clients.Remove(client);
            }
            client.Close();
            UnityEngine.Debug.Log($"客户端 {client.Client.RemoteEndPoint} 已断开连接。");
        }
    }
    private void Broadcast(string message)
    {
        byte[] data = Encoding.UTF8.GetBytes(message);
        lock (_lock) // 锁定列表,防止在广播时列表被修改
        {
            foreach (TcpClient client in _clients)
            {
                try
                {
                    NetworkStream stream = client.GetStream();
                    stream.Write(data, 0, data.Length);
                }
                catch (Exception ex)
                {
                    UnityEngine.Debug.LogError($"广播消息给客户端 {client.Client.RemoteEndPoint} 失败: {ex.Message}");
                    // 如果发送失败,说明客户端可能已断开,可以将其移除
                    _clients.Remove(client);
                }
            }
        }
    }
    public void Stop()
    {
        _isRunning = false;
        _server.Stop();
        lock (_lock)
        {
            foreach (TcpClient client in _clients)
            {
                client.Close();
            }
            _clients.Clear();
        }
        UnityEngine.Debug.Log("服务器已停止。");
    }
}

代码解释:

  • TcpListener: 负责监听传入的 TCP 连接请求。
  • AcceptTcpClient(): 阻塞方法,直到有客户端连接,然后返回一个 TcpClient 对象。
  • Thread: 由于 AcceptTcpClient()stream.Read() 都是阻塞操作,我们必须在单独的线程中运行它们,否则 Unity 的主线程会卡住。
  • NetworkStream: 用于通过网络发送和接收数据。
  • lock: 这是一个非常重要的关键字,当多个线程(如一个新客户端连接线程和一个广播线程)同时访问 _clients 列表时,可能会导致数据竞争。lock 确保一次只有一个线程能修改列表。

Unity 客户端实现 (C#)

客户端的逻辑相对简单,主要任务是连接、发送和接收。

Unity如何搭建Socket服务器?-图2
(图片来源网络,侵删)
// SocketClient.cs
using System;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using UnityEngine;
public class SocketClient : MonoBehaviour
{
    private TcpClient _client;
    private NetworkStream _stream;
    private Thread _receiveThread;
    private bool _isConnected;
    public string serverIP = "127.0.0.1";
    public int serverPort = 8080;
    public void ConnectToServer()
    {
        try
        {
            _client = new TcpClient(serverIP, serverPort);
            _stream = _client.GetStream();
            _isConnected = true;
            UnityEngine.Debug.Log("成功连接到服务器!");
            // 启动一个线程来持续接收服务器消息
            _receiveThread = new Thread(new ThreadStart(ReceiveData));
            _receiveThread.IsBackground = true;
            _receiveThread.Start();
        }
        catch (Exception ex)
        {
            UnityEngine.Debug.LogError($"连接服务器失败: {ex.Message}");
        }
    }
    private void ReceiveData()
    {
        byte[] buffer = new byte[1024];
        int bytesRead;
        while (_isConnected)
        {
            try
            {
                bytesRead = _stream.Read(buffer, 0, buffer.Length);
                if (bytesRead == 0)
                {
                    // 服务器断开连接
                    Disconnect();
                    break;
                }
                string message = Encoding.UTF8.GetString(buffer, 0, bytesRead);
                // 在Unity主线程中更新UI,避免跨线程操作UI
                UnityMainThreadDispatcher.Instance().Enqueue(() => {
                    UnityEngine.Debug.Log($"收到服务器消息: {message}");
                });
            }
            catch (Exception ex)
            {
                if (_isConnected)
                {
                    UnityEngine.Debug.LogError($"接收数据时出错: {ex.Message}");
                }
                Disconnect();
            }
        }
    }
    public void SendMessage(string message)
    {
        if (!_isConnected) return;
        try
        {
            byte[] data = Encoding.UTF8.GetBytes(message);
            _stream.Write(data, 0, data.Length);
            UnityEngine.Debug.Log($"已发送消息: {message}");
        }
        catch (Exception ex)
        {
            UnityEngine.Debug.LogError($"发送消息失败: {ex.Message}");
            Disconnect();
        }
    }
    private void Disconnect()
    {
        _isConnected = false;
        if (_receiveThread != null && _receiveThread.IsAlive)
        {
            _receiveThread.Abort(); // 注意:Thread.Abort() 已被标记为过时,但为了简单示例使用
        }
        _stream?.Close();
        _client?.Close();
        UnityEngine.Debug.Log("与服务器断开连接。");
    }
    void OnApplicationQuit()
    {
        Disconnect();
    }
}

重要补充:跨线程更新 UI

ReceiveData 方法中,我们直接在接收线程里调用了 UnityEngine.Debug.Log,这通常可以工作,但更规范的做法是,如果要在 UI 上显示信息(Text 组件),必须确保在 Unity 的主线程中执行,我们可以使用一个简单的辅助类 UnityMainThreadDispatcher 来实现。

UnityMainThreadDispatcher.cs (创建这个脚本并放在你的项目中)

using System.Collections.Generic;
using UnityEngine;
public class UnityMainThreadDispatcher : MonoBehaviour
{
    private static readonly Queue<System.Action> _executionQueue = new Queue<System.Action>();
    private static UnityMainThreadDispatcher _instance = null;
    public static UnityMainThreadDispatcher Instance()
    {
        if (_instance == null)
        {
            _instance = FindObjectOfType<UnityMainThreadDispatcher>();
            if (_instance == null)
            {
                var go = new GameObject("UnityMainThreadDispatcher");
                _instance = go.AddComponent<UnityMainThreadDispatcher>();
                DontDestroyOnLoad(go);
            }
        }
        return _instance;
    }
    void Update()
    {
        lock (_executionQueue)
        {
            while (_executionQueue.Count > 0)
            {
                _executionQueue.Dequeue().Invoke();
            }
        }
    }
    public void Enqueue(System.Action action)
    {
        lock (_executionQueue)
        {
            _executionQueue.Enqueue(action);
        }
    }
}

然后在你的 SocketClient 中,接收消息后通过它来分发到主线程:

Unity如何搭建Socket服务器?-图3
(图片来源网络,侵删)
// UnityMainThreadDispatcher.Instance().Enqueue(() => {
//     // 在这里执行所有需要主线程操作的代码
//     Debug.Log("主线程收到消息");
//     //  uiText.text = message;
// });

完整项目示例

创建 Unity 项目

  • 创建一个新的 3D 或 2D Unity 项目。

创建服务器

  • 在 Unity 中创建一个空 GameObject,命名为 ServerManager
  • 创建一个 C# 脚本 SocketServer.cs,将代码复制进去。
  • SocketServer.cs 挂载到 ServerManager 上。
  • ServerManager 的 Inspector 中,你可以添加一个按钮来启动和停止服务器。

ServerManager.cs (用于控制服务器启停)

using UnityEngine;
using UnityEngine.UI; // 如果需要按钮
public class ServerManager : MonoBehaviour
{
    public SocketServer server;
    public Button startButton;
    public Button stopButton;
    void Start()
    {
        if (startButton != null) startButton.onClick.AddListener(StartServer);
        if (stopButton != null) stopButton.onClick.AddListener(StopServer);
    }
    public void StartServer()
    {
        if (server == null)
        {
            server = new SocketServer();
        }
        server.Start(8080);
    }
    public void StopServer()
    {
        if (server != null)
        {
            server.Stop();
        }
    }
    void OnApplicationQuit()
    {
        StopServer();
    }
}

创建客户端

  • 创建另一个空 GameObject,命名为 ClientManager
  • 创建一个 C# 脚本 SocketClient.cs,将代码复制进去。
  • SocketClient.cs 挂载到 ClientManager 上。
  • 在 Inspector 中设置好 Server IP (默认 0.0.1) 和 Server Port (默认 8080)。
  • 添加一个 InputField 用于输入消息,一个 Button 用于发送消息。

ClientUI.cs (用于处理客户端输入和发送)

using UnityEngine;
using UnityEngine.UI;
public class ClientUI : MonoBehaviour
{
    public SocketClient client;
    public InputField messageInputField;
    public Button sendButton;
    public Text outputText; // 用于显示聊天记录的UI文本
    void Start()
    {
        if (sendButton != null) sendButton.onClick.AddListener(SendMessage);
        if (messageInputField != null) messageInputField.onEndEdit.AddListener((value) => SendMessage());
    }
    public void SendMessage()
    {
        if (client != null && !string.IsNullOrEmpty(messageInputField.text))
        {
            client.SendMessage(messageInputField.text);
            messageInputField.text = ""; // 清空输入框
        }
    }
}

创建 UnityMainThreadDispatcher

  • 按照前面的说明创建 UnityMainThreadDispatcher.cs 脚本,并确保场景中有一个 GameObject 挂载了它(或者它会自动创建)。

场景布局

  • 一个 ServerManager GameObject,挂载 ServerManager.csSocketServer.cs
  • 一个 ClientManager GameObject,挂载 SocketClient.cs
  • 一个 UI GameObject,包含:
    • 一个 Text 用于显示服务器状态和聊天记录(挂载 UnityMainThreadDispatcher)。
    • 一个 InputField 用于输入消息。
    • 一个 Button 用于发送消息。
    • InputFieldButton 拖到 ClientUI 脚本的相应字段中。

重要注意事项与最佳实践

异步编程 (Async/Await)

上面的示例使用了 Thread,这是一种比较传统的多线程方式,现代 C# 推荐使用 async/await 模式,它能让代码更简洁,并且更好地与 Unity 的主线程集成。

服务器端的 AcceptClientsHandleClient 可以改写成 async 方法,使用 AcceptTcpClientAsync()ReadAsync()/WriteAsync(),这样可以避免手动管理线程池,代码也更易于维护。

协议设计

直接发送 string (UTF8 字符串) 在简单场景下可行,但在复杂应用中会遇到问题:

  • 消息粘包: TCP 是流式协议,连续发送的两条消息可能会被合并成一条接收。
  • 消息边界: 接收方如何知道一条消息在哪里结束,下一条在哪里开始?

解决方案:自定义协议 最简单的方式是在消息前加上消息的长度(头部)。 要发送消息 "Hello"

  1. 计算 "Hello" 的字节长度,假设为 5。
  2. 将长度 5 转换成 4 字节的头 (00000005)。
  3. 将头部 00000005 和消息体 "Hello" 一起发送。
  4. 接收方先读取 4 个字节,得到消息长度 5,然后再读取 5 个字节,得到完整的消息体。

线程安全

我们已经通过 lock 保证了列表操作的线程安全,在任何共享资源(如列表、变量)被多个线程访问时,都要考虑使用 lock 或其他并发控制机制(如 Mutex, Semaphore)。

性能优化

  • 对象池: 对于频繁创建和销毁的 TcpClientNetworkStream,可以考虑使用对象池来减少 GC 压力。
  • 缓冲区管理: 对于大文件传输,需要设计更高效的缓冲区管理策略。
  • 使用更高级的库: 对于商业级项目,可以考虑使用成熟的网络库,如 LiteNetLib (轻量级,高性能) 或 Mirror (基于 UNET 的现代网络框架),它们已经处理了底层的复杂性和性能优化。

希望这份详细的指南能帮助你成功地在 Unity 中实现 Socket 通信!

分享:
扫描分享到社交APP
上一篇
下一篇