1. 项目概述与核心价值
最近在做一个需要实时交互的Unity项目,比如多人在线游戏或者虚拟展厅,发现传统的HTTP轮询或者短连接方案在延迟和服务器压力上完全没法满足需求。这时候,WebSocket就成了一个绕不开的技术选项。这个“构建Unity WebSocket聊天室”的项目,本质上是一个 跨平台即时通讯的微型案例 ,但它所涵盖的技术栈和设计思想,却远不止一个简单的聊天框那么简单。它解决的核心痛点是:如何在Unity这个游戏引擎主导的环境里,高效、稳定地实现全双工、低延迟的网络通信,并且让这套逻辑能无缝运行在PC、WebGL、移动端(Android/iOS)等多个平台上。
我之所以花时间把这个案例从头到尾捋清楚并分享出来,是因为在实际开发中,我发现很多教程要么只讲Unity客户端,对服务端一笔带过;要么只讲WebSocket协议,和Unity结合得又很生硬。结果就是,开发者照着做完了,客户端和服务端还是对不上号,各种连接失败、消息乱码、跨平台崩溃的问题接踵而至。这个案例的价值在于,它提供了一个 端到端(从服务端到多平台客户端)的、可落地的解决方案 。通过实现一个基础的聊天室,你能掌握Unity中网络模块的封装、异步消息处理、跨平台编译的注意事项,以及如何选择一个轻量且可靠的后端服务框架。无论你是想做一个简单的联机功能,还是为更复杂的实时应用(如实时排行榜、协同编辑、直播弹幕)打基础,这里面的核心思路都是相通的。
2. 技术选型与架构设计思路
2.1 为什么是WebSocket,而不是其他?
在动手之前,我们先得搞清楚为什么选WebSocket。Unity里常见的网络方案有好几种:
- Unity自带的UNet/Netcode :功能强大,但比较重,学习曲线陡,且UNet已进入维护模式,对于轻量级的自定义通讯协议,有点“杀鸡用牛刀”。
- 第三方网络库(如Mirror、Photon) :提供了更高级的抽象和房间管理,非常适合成熟的多人游戏。但如果你想完全控制通信协议,或者项目预算有限,它们可能不是最轻量的选择。
- HTTP短连接/长轮询 :这是最传统的方式。每次通信都要建立、断开连接,开销巨大,实时性差。长轮询虽然能模拟实时,但仍有延迟且服务器压力大。
- WebSocket :HTML5标准协议,提供真正的全双工通信。连接一旦建立,客户端和服务端可以随时主动向对方发送数据,延迟极低,连接开销小。对于需要频繁、双向、小数据量交换的场景(如聊天、实时状态同步),它是目前Web和跨平台应用的首选。
注意 :WebSocket并不是万能的。对于需要严格状态同步、复杂实体管理的硬核竞技游戏,可能需要基于UDP的定制协议(如KCP)并结合WebSocket用于信令传输。但对于我们90%的实时交互需求,WebSocket的TCP特性(保证数据顺序和到达)已经足够可靠和高效。
2.2 服务端技术选型:轻量、高效、易集成
服务端的选择决定了整个系统的稳定性和扩展性。结合热词中提到的
net core
、
springboot
、
go语言
,我们来分析一下:
-
.NET Core / ASP.NET Core
:如果你和团队主要使用C#,那么这是最无缝的选择。可以利用
Microsoft.AspNetCore.WebSockets或第三方库如Fleck、SuperWebSocket快速搭建。与Unity客户端共享C#语言生态,一些工具类和数据结构甚至可以复用,开发体验非常流畅。 -
Spring Boot (Java)
:企业级应用中最常见的选择之一。通过
spring-boot-starter-websocket可以快速集成,生态成熟,性能强劲。适合中大型项目或已有Java技术栈的团队。 -
Node.js
:得益于其事件驱动、非阻塞I/O的特性,非常适合高并发的实时应用。使用
ws或socket.io库,几十行代码就能搭建一个高性能的WebSocket服务器,开发速度极快。 -
Go
:热词中提到了“go语言简易聊天室”和“go语音适合做im即时通讯类app吗”。Go语言以高并发、高性能和简洁的语法著称,标准库
net/http和gorilla/websocket让构建WebSocket服务变得非常简单。它的编译型特性使得服务端程序部署简单、资源占用低,非常适合作为IM(即时通讯)类应用的后台。对于追求极致性能和资源效率的场景,Go是一个非常有力的竞争者。
本项目方案选择
:为了最大限度地展示跨平台和通用性,我将选择
Node.js +
ws
库
作为服务端演示。原因有三:1) 代码极其简洁,便于理解核心逻辑;2) 与语言无关,任何客户端(Unity C#、网页JS、安卓Java等)都能连接;3) 性能足以支撑中小规模的并发测试。在实际项目中,你可以根据团队技术栈无缝替换为上述任意一种。
2.3 客户端架构设计:可维护与可扩展
在Unity客户端,我们不能把WebSocket的连接、发送、接收逻辑到处乱写。一个清晰的分层架构至关重要,这能避免后期变成“蜘蛛网”代码。
- 网络管理层 (NetworkManager) :这是一个单例类,负责WebSocket客户端的生命周期管理(连接、断开、重连)、消息的发送与接收队列管理。它是与网络服务交互的唯一入口。
-
消息分发层 (MessageDispatcher)
:负责解析从服务端收到的原始数据(通常是JSON字符串),将其反序列化为具体的消息对象(如
ChatMessage、SystemMessage),然后通过事件(C#event或Action)或消息总线(如MessageBus)分发给感兴趣的UI模块或游戏逻辑模块。这样做实现了 网络层与表现层的解耦 。 - UI表现层 :包括聊天输入框、消息显示列表、在线用户列表等。它们监听消息分发层发出的事件,然后更新界面,自身不直接处理网络逻辑。
-
数据模型层
:定义客户端与服务端约定好的消息格式(C#类),并使用
JsonUtility(Unity内置)或Newtonsoft.Json(需导入,功能更强大)进行序列化与反序列化。
这样的设计,未来如果要增加新的消息类型(如发送图片、位置共享),只需要在数据模型层新增一个类,在消息分发层添加对应的解析和分发逻辑即可,UI层和网络管理层几乎不需要改动。
3. 服务端实现详解(Node.js + ws)
3.1 环境准备与项目初始化
首先,确保你的开发环境已经安装了Node.js(建议版本14+)和npm。然后创建一个新的项目目录。
mkdir unity-websocket-chat-server
cd unity-websocket-chat-server
npm init -y
接下来,安装我们唯一的核心依赖——
ws
库。它是一个轻量、高效、符合WebSocket标准的Node.js实现。
npm install ws
3.2 核心服务器代码解析
创建一个
server.js
文件,我们将在这里编写所有服务端逻辑。
// server.js
const WebSocket = require('ws');
const http = require('http');
// 1. 创建HTTP服务器(WebSocket协议升级基于HTTP)
const server = http.createServer();
const wss = new WebSocket.Server({ server });
// 用于存储所有连接的客户端和他们的信息
const clients = new Map(); // key: WebSocket对象, value: { userId, username }
// 2. 定义消息类型常量,与客户端保持一致
const MessageType = {
CONNECT: 'connect',
CHAT: 'chat',
USER_LIST: 'user_list',
SYSTEM: 'system'
};
// 3. 广播消息给所有客户端(除了发送者自己)
function broadcast(message, senderClient = null) {
const dataString = JSON.stringify(message);
clients.forEach((clientInfo, client) => {
if (client !== senderClient && client.readyState === WebSocket.OPEN) {
client.send(dataString);
}
});
}
// 4. 处理新客户端连接
wss.on('connection', (ws, request) => {
console.log('新的客户端连接');
let currentUser = { userId: generateUserId(), username: `用户${Math.floor(Math.random() * 1000)}` };
// 将新客户端加入列表
clients.set(ws, currentUser);
console.log(`用户 ${currentUser.username} 加入,当前在线: ${clients.size}`);
// 5. 发送欢迎消息和当前用户列表给新用户
const welcomeMsg = {
type: MessageType.SYSTEM,
content: `欢迎 ${currentUser.username} 加入聊天室!`,
timestamp: Date.now()
};
ws.send(JSON.stringify(welcomeMsg));
// 广播新用户加入的消息给其他所有人
const userJoinedMsg = {
type: MessageType.SYSTEM,
content: `${currentUser.username} 进入了聊天室。`,
timestamp: Date.now()
};
broadcast(userJoinedMsg, ws);
// 更新所有用户的在线列表
updateUserListForAll();
// 6. 监听客户端发来的消息
ws.on('message', (message) => {
try {
const parsedMsg = JSON.parse(message);
handleClientMessage(ws, parsedMsg);
} catch (error) {
console.error('消息解析失败:', error);
ws.send(JSON.stringify({
type: MessageType.SYSTEM,
content: '消息格式错误',
timestamp: Date.now()
}));
}
});
// 7. 处理客户端断开连接
ws.on('close', () => {
const user = clients.get(ws);
if (user) {
console.log(`用户 ${user.username} 断开连接`);
const leaveMsg = {
type: MessageType.SYSTEM,
content: `${user.username} 离开了聊天室。`,
timestamp: Date.now()
};
broadcast(leaveMsg);
clients.delete(ws); // 从Map中移除
updateUserListForAll(); // 更新剩余用户的列表
}
});
// 8. 处理错误
ws.on('error', (error) => {
console.error('WebSocket错误:', error);
});
});
// 9. 消息处理器
function handleClientMessage(ws, msg) {
const userInfo = clients.get(ws);
if (!userInfo) return;
switch (msg.type) {
case MessageType.CHAT:
// 处理聊天消息
const chatMsg = {
type: MessageType.CHAT,
sender: userInfo.username,
content: msg.content,
timestamp: Date.now()
};
console.log(`[聊天] ${userInfo.username}: ${msg.content}`);
broadcast(chatMsg); // 广播给所有人(包括发送者,根据需求可调整)
break;
case MessageType.CONNECT:
// 处理用户设置用户名等连接信息
if (msg.username && msg.username.trim() !== '') {
const oldName = userInfo.username;
userInfo.username = msg.username.trim();
console.log(`用户 ${oldName} 更名为 ${userInfo.username}`);
broadcast({
type: MessageType.SYSTEM,
content: `${oldName} 更名为 ${userInfo.username}`,
timestamp: Date.now()
});
updateUserListForAll();
}
break;
// 可以扩展其他消息类型,如私聊、发送图片等
default:
console.warn('未知的消息类型:', msg.type);
}
}
// 10. 更新所有客户端的在线用户列表
function updateUserListForAll() {
const userList = Array.from(clients.values()).map(user => user.username);
const listMsg = {
type: MessageType.USER_LIST,
users: userList,
timestamp: Date.now()
};
broadcast(listMsg);
}
// 11. 生成一个简单的用户ID
function generateUserId() {
return `user_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
// 12. 启动服务器
const PORT = process.env.PORT || 8080;
server.listen(PORT, () => {
console.log(`WebSocket 聊天服务器已启动,监听端口: ${PORT}`);
console.log(`你可以通过 ws://localhost:${PORT} 连接`);
});
代码关键点解析:
-
clients使用Map存储 :使用Map而不是数组,可以方便地通过WebSocket对象本身作为键来查找对应的用户信息,在断开连接时删除效率更高。 -
消息类型枚举
:定义了清晰的消息类型(
CONNECT,CHAT,USER_LIST,SYSTEM),这是客户端与服务端通信的“协议”基础,双方必须保持一致。 -
broadcast函数 :封装了广播逻辑,并提供了排除发送者的选项,这是聊天室的核心功能。 -
连接与断开处理
:在
connection和close事件中,不仅更新服务器状态,还通过广播系统消息通知所有用户,保持聊天室的“现场感”。 -
错误处理
:在
message事件中使用了try...catch来捕获JSON解析错误,防止畸形消息导致服务器崩溃。同时监听了WebSocket的error事件。 -
用户列表更新
:任何用户连接、断开或改名,都会触发
updateUserListForAll函数,广播最新的在线列表给所有人。
3.3 运行与测试服务端
在终端运行:
node server.js
如果看到“WebSocket 聊天服务器已启动,监听端口: 8080”的提示,说明服务端已经就绪。你可以暂时使用在线的WebSocket测试工具(如“WebSocket King” Chrome插件)连接到
ws://localhost:8080
,发送JSON消息来测试服务器的广播和用户列表功能是否正常。
4. Unity客户端核心实现
4.1 项目设置与第三方库引入
在Unity中创建一个新项目(建议使用较新版本,如2021 LTS或2022 LTS)。Unity本身没有内置的WebSocket客户端库,我们需要引入一个可靠的第三方库。这里我推荐
NativeWebSocket
,它是一个纯C#实现,不依赖特定平台的原生插件,因此在所有Unity支持的平台(包括WebGL)上都能良好运行,且API简洁。
引入NativeWebSocket:
- 打开Unity的包管理器(Window -> Package Manager)。
- 点击左上角的“+”号,选择“Add package from git URL...”。
-
输入:
https://github.com/endel/NativeWebSocket.git#upm - 点击“Add”。等待导入完成。
4.2 定义消息数据模型
在Unity中创建脚本
ChatMessageModels.cs
,定义与服务端对应的消息数据结构。
// ChatMessageModels.cs
using System;
[Serializable]
public class NetworkMessage
{
public string type; // "connect", "chat", "user_list", "system"
}
[Serializable]
public class ChatMessage : NetworkMessage
{
public string sender;
public string content;
public long timestamp;
}
[Serializable]
public class SystemMessage : NetworkMessage
{
public string content;
public long timestamp;
}
[Serializable]
public class UserListMessage : NetworkMessage
{
public string[] users;
public long timestamp;
}
[Serializable]
public class ConnectMessage : NetworkMessage
{
public string username;
}
注意 :这里使用了
[Serializable]特性,是为了配合Unity自带的JsonUtility进行序列化。如果你需要更复杂的JSON处理(如嵌套对象、字典),可以考虑使用Newtonsoft.Json(需通过Unity Package Manager或手动导入DLL)。
4.3 构建网络管理器单例
创建
WebSocketChatManager.cs
,这是客户端的核心。
// WebSocketChatManager.cs
using NativeWebSocket;
using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Events;
public class WebSocketChatManager : MonoBehaviour
{
public static WebSocketChatManager Instance { get; private set; }
// 服务器地址,在Inspector中配置或从配置读取
[SerializeField] private string serverAddress = "ws://localhost:8080";
private WebSocket websocket;
private string _currentUsername = "UnityUser";
// 定义事件,用于UI层订阅
public event Action<ChatMessage> OnChatMessageReceived;
public event Action<SystemMessage> OnSystemMessageReceived;
public event Action<List<string>> OnUserListUpdated;
public event Action<string> OnConnectionStatusChanged; // 连接状态变化
public string CurrentUsername => _currentUsername;
private void Awake()
{
// 简单的单例模式,确保场景中只有一个管理器
if (Instance == null)
{
Instance = this;
DontDestroyOnLoad(gameObject); // 跨场景不销毁
}
else
{
Destroy(gameObject);
}
}
async void Start()
{
// 启动时自动连接,可根据需求调整
// ConnectToServer();
}
public async void ConnectToServer(string username = null)
{
if (!string.IsNullOrEmpty(username))
{
_currentUsername = username;
}
if (websocket != null && websocket.State == WebSocketState.Open)
{
Debug.LogWarning("WebSocket已经连接。");
return;
}
OnConnectionStatusChanged?.Invoke("连接中...");
Debug.Log($"正在连接到服务器: {serverAddress}");
websocket = new WebSocket(serverAddress);
// 订阅WebSocket事件
websocket.OnOpen += () =>
{
Debug.Log("连接成功!");
OnConnectionStatusChanged?.Invoke("已连接");
// 连接成功后,发送连接消息告知服务器用户名
SendConnectMessage(_currentUsername);
};
websocket.OnError += (e) =>
{
Debug.LogError($"WebSocket错误: {e}");
OnConnectionStatusChanged?.Invoke($"连接错误: {e}");
};
websocket.OnClose += (e) =>
{
Debug.Log($"连接关闭: {e}");
OnConnectionStatusChanged?.Invoke("已断开");
};
websocket.OnMessage += (bytes) =>
{
// 在主线程中处理消息,因为Unity UI操作必须在主线程
MainThreadDispatcher.Instance.Enqueue(() => HandleServerMessage(bytes));
};
try
{
await websocket.Connect();
}
catch (Exception ex)
{
Debug.LogError($"连接失败: {ex.Message}");
OnConnectionStatusChanged?.Invoke("连接失败");
}
}
private void HandleServerMessage(byte[] bytes)
{
// 将字节数组转换为字符串(假设服务端发送的是JSON文本)
string messageStr = System.Text.Encoding.UTF8.GetString(bytes);
// Debug.Log($"收到原始消息: {messageStr}");
try
{
// 首先解析出基础消息类型
var baseMsg = JsonUtility.FromJson<NetworkMessage>(messageStr);
if (baseMsg == null) return;
switch (baseMsg.type)
{
case "chat":
var chatMsg = JsonUtility.FromJson<ChatMessage>(messageStr);
OnChatMessageReceived?.Invoke(chatMsg);
break;
case "system":
var sysMsg = JsonUtility.FromJson<SystemMessage>(messageStr);
OnSystemMessageReceived?.Invoke(sysMsg);
break;
case "user_list":
var userListMsg = JsonUtility.FromJson<UserListMessage>(messageStr);
OnUserListUpdated?.Invoke(new List<string>(userListMsg.users));
break;
default:
Debug.LogWarning($"未知消息类型: {baseMsg.type}");
break;
}
}
catch (Exception e)
{
Debug.LogError($"消息处理失败: {e.Message}, 原始数据: {messageStr}");
}
}
// 发送聊天消息
public async void SendChatMessage(string content)
{
if (websocket?.State != WebSocketState.Open)
{
Debug.LogWarning("无法发送消息,WebSocket未连接。");
return;
}
var msg = new ChatMessage
{
type = "chat",
sender = _currentUsername, // 实际上服务端会覆盖,这里发送用于调试或服务端验证
content = content,
timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds()
};
string json = JsonUtility.ToJson(msg);
await websocket.SendText(json);
}
// 发送连接/设置用户名消息
private async void SendConnectMessage(string username)
{
if (websocket?.State != WebSocketState.Open) return;
var msg = new ConnectMessage
{
type = "connect",
username = username
};
string json = JsonUtility.ToJson(msg);
await websocket.SendText(json);
}
// 断开连接
public async void Disconnect()
{
if (websocket != null)
{
await websocket.Close();
}
}
// 每帧检查WebSocket消息,NativeWebSocket需要手动分发
void Update()
{
#if !UNITY_WEBGL || UNITY_EDITOR
if (websocket != null)
{
websocket.DispatchMessageQueue();
}
#endif
}
private async void OnApplicationQuit()
{
await Disconnect();
}
}
关键实现细节与避坑指南:
-
单例与持久化
:
DontDestroyOnLoad确保网络连接在场景切换时不会中断,这对于一个需要保持会话的应用至关重要。 -
主线程操作
:WebSocket的回调(
OnMessage)可能不在Unity的主线程中触发,而UI操作必须在主线程。这里我引入了一个简单的MainThreadDispatcher(下文会给出)来将消息处理任务排队到主线程执行。 这是很多跨线程库在Unity中崩溃的根源,务必注意。 -
连接状态管理
:提供了
OnConnectionStatusChanged事件,让UI可以实时显示“连接中”、“已连接”、“已断开”等状态,提升用户体验。 -
消息序列化
:使用
JsonUtility进行JSON转换。注意它要求类必须是[Serializable]的,并且字段是public的。对于更复杂的嵌套结构,Newtonsoft.Json是更好的选择。 -
NativeWebSocket的
DispatchMessageQueue:在非WebGL平台(如PC、移动端),NativeWebSocket需要你在Update循环中调用DispatchMessageQueue()来触发消息回调。在WebGL平台,它会自动处理。用预处理指令#if !UNITY_WEBGL || UNITY_EDITOR来区分,是个好习惯。 -
异常处理
:在
Connect和消息处理中都加入了try-catch,防止因为网络波动或消息格式错误导致整个客户端崩溃。
4.4 主线程调度器
创建
MainThreadDispatcher.cs
,这是一个简单的工具类,用于在其他线程中将任务委托到主线程执行。
// MainThreadDispatcher.cs
using System;
using System.Collections.Generic;
using UnityEngine;
public class MainThreadDispatcher : MonoBehaviour
{
private static MainThreadDispatcher _instance;
private readonly Queue<Action> _executionQueue = new Queue<Action>();
public static MainThreadDispatcher Instance
{
get
{
if (_instance == null)
{
GameObject go = new GameObject("MainThreadDispatcher");
_instance = go.AddComponent<MainThreadDispatcher>();
DontDestroyOnLoad(go);
}
return _instance;
}
}
public void Enqueue(Action action)
{
lock (_executionQueue)
{
_executionQueue.Enqueue(action);
}
}
void Update()
{
lock (_executionQueue)
{
while (_executionQueue.Count > 0)
{
_executionQueue.Dequeue()?.Invoke();
}
}
}
}
4.5 构建聊天室UI
这部分使用Unity的UGUI系统。创建一个简单的UI Canvas,包含以下元素:
- 连接面板 :输入服务器地址(可选)、用户名、连接按钮。
-
聊天主面板
:
-
一个
ScrollView用于显示聊天记录(里面是垂直布局的Content)。 -
一个
InputField用于输入消息。 - 一个发送按钮。
-
一个
Text或ScrollView用于显示在线用户列表。
-
一个
-
状态显示
:一个
Text用于显示当前连接状态。
创建UI控制脚本
ChatUIController.cs
:
// ChatUIController.cs
using System.Collections.Generic;
using System.Text;
using TMPro; // 如果你使用TextMeshPro
using UnityEngine;
using UnityEngine.UI;
public class ChatUIController : MonoBehaviour
{
[Header("UI References")]
[SerializeField] private TMP_InputField inputFieldUsername;
[SerializeField] private Button buttonConnect;
[SerializeField] private GameObject panelConnect;
[SerializeField] private GameObject panelChat;
[SerializeField] private TMP_Text textConnectionStatus;
[SerializeField] private TMP_InputField inputFieldMessage;
[SerializeField] private Button buttonSend;
[SerializeField] private Transform chatContentParent; // 消息列表的父物体
[SerializeField] private GameObject chatMessagePrefab; // 单条消息的预制体
[SerializeField] private TMP_Text textUserList; // 或用ScrollView+Content
private void Start()
{
// 初始化UI状态
panelChat.SetActive(false);
panelConnect.SetActive(true);
buttonConnect.onClick.AddListener(OnConnectButtonClicked);
buttonSend.onClick.AddListener(OnSendButtonClicked);
inputFieldMessage.onSubmit.AddListener((_) => OnSendButtonClicked()); // 按回车发送
// 订阅网络管理器的事件
WebSocketChatManager.Instance.OnConnectionStatusChanged += UpdateConnectionStatusUI;
WebSocketChatManager.Instance.OnChatMessageReceived += OnChatMessageReceived;
WebSocketChatManager.Instance.OnSystemMessageReceived += OnSystemMessageReceived;
WebSocketChatManager.Instance.OnUserListUpdated += OnUserListUpdated;
}
private void OnDestroy()
{
// 记得取消订阅,防止内存泄漏
var manager = WebSocketChatManager.Instance;
if (manager != null)
{
manager.OnConnectionStatusChanged -= UpdateConnectionStatusUI;
manager.OnChatMessageReceived -= OnChatMessageReceived;
manager.OnSystemMessageReceived -= OnSystemMessageReceived;
manager.OnUserListUpdated -= OnUserListUpdated;
}
}
private void OnConnectButtonClicked()
{
string username = inputFieldUsername.text;
if (string.IsNullOrWhiteSpace(username))
{
username = $"UnityUser{Random.Range(1000, 9999)}";
}
WebSocketChatManager.Instance.ConnectToServer(username);
}
private void UpdateConnectionStatusUI(string status)
{
if (textConnectionStatus != null)
textConnectionStatus.text = $"状态: {status}";
// 根据状态切换面板
if (status.Contains("已连接"))
{
panelConnect.SetActive(false);
panelChat.SetActive(true);
inputFieldMessage.Select(); // 自动聚焦到输入框
}
else if (status.Contains("已断开") || status.Contains("失败"))
{
panelChat.SetActive(false);
panelConnect.SetActive(true);
}
}
private void OnChatMessageReceived(ChatMessage msg)
{
AddMessageToUI($"[{FormatTime(msg.timestamp)}] {msg.sender}: {msg.content}", Color.white);
}
private void OnSystemMessageReceived(SystemMessage msg)
{
AddMessageToUI($"<color=yellow>[系统] {msg.content}</color>", Color.yellow);
}
private void OnUserListUpdated(List<string> users)
{
StringBuilder sb = new StringBuilder("在线用户:\n");
foreach (var user in users)
{
sb.AppendLine($"• {user}");
}
if (textUserList != null)
textUserList.text = sb.ToString();
}
private void AddMessageToUI(string message, Color color)
{
if (chatMessagePrefab == null || chatContentParent == null) return;
GameObject newMsgObj = Instantiate(chatMessagePrefab, chatContentParent);
TMP_Text textComp = newMsgObj.GetComponent<TMP_Text>();
if (textComp != null)
{
textComp.text = message;
textComp.color = color;
}
// 可选:自动滚动到底部
// Canvas.ForceUpdateCanvases();
// chatScrollRect.verticalNormalizedPosition = 0f;
}
private void OnSendButtonClicked()
{
string msg = inputFieldMessage.text;
if (!string.IsNullOrWhiteSpace(msg))
{
WebSocketChatManager.Instance.SendChatMessage(msg);
inputFieldMessage.text = "";
inputFieldMessage.Select();
}
}
private string FormatTime(long timestamp)
{
// 将Unix时间戳转换为本地时间字符串
System.DateTime dateTime = new System.DateTime(1970, 1, 1, 0, 0, 0, 0, System.DateTimeKind.Utc);
dateTime = dateTime.AddSeconds(timestamp).ToLocalTime();
return dateTime.ToString("HH:mm:ss");
}
}
将UI元素拖拽到脚本的对应字段,并创建一个简单的文本预制体作为
chatMessagePrefab
。运行Unity,输入用户名点击连接,然后就可以开始聊天了!
5. 跨平台构建与实战问题排查
5.1 针对不同平台的构建设置
-
PC (Windows/Mac/Linux) :这是最简单的平台,直接Build & Run即可。确保防火墙允许你的Unity应用进行网络连接。
-
WebGL :这是 坑最多 的平台。
-
服务器地址
:不能使用
ws://localhost:8080。因为网页运行在浏览器中,localhost指向的是用户自己的机器。你需要将服务端部署到公网服务器,并将地址改为ws://你的域名或IP:端口。 务必确保服务端支持WebSocket协议,并且配置了正确的CORS(跨域资源共享) 。对于我们的Node.js服务器,可以简单添加:// 在server.js的http.createServer后 const server = http.createServer((req, res) => { // 设置CORS头,允许所有来源(生产环境应限制为你的域名) res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Methods', 'GET, POST'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); // 处理预检请求 if (req.method === 'OPTIONS') { res.writeHead(200); res.end(); return; } // 其他请求可以返回404或一个简单的页面 res.writeHead(404); res.end(); }); - Unity WebGL构建设置 :在Player Settings -> Publishing Settings中,确保“Enable Exceptions”设置为“Full Without Stacktrace”或“Full”,以便在浏览器控制台看到错误信息。**禁用“Data Caching”**有时可以解决一些奇怪的加载问题。
-
浏览器安全策略
:现代浏览器对非安全上下文(
http://)下的WebSocket连接限制越来越严,尤其是混合内容(https页面连接ws)。最好使用wss://(WebSocket Secure)并配置服务端SSL证书。
-
服务器地址
:不能使用
-
Android/iOS (移动端) :
-
权限
:在Player Settings中,确保为Android和iOS添加了
INTERNET权限。 - 后台运行 :移动端应用切到后台时,系统可能会暂停线程或限制网络活动。对于需要保持连接的应用,需要研究平台特定的后台任务或保活机制,但这超出了基础聊天室的范围。我们的案例中,应用回到前台后,需要尝试自动重连。
- 网络状态检测 :移动网络不稳定。建议增加网络状态监听,并在网络从无到有时自动触发重连逻辑。
-
权限
:在Player Settings中,确保为Android和iOS添加了
5.2 常见问题与解决方案实录
在实际开发和测试中,我遇到了不少问题,这里总结一下最典型的几个:
-
连接失败:
WebSocket connection to 'ws://...' failed-
可能原因1:服务端未运行或地址/端口错误
。用
telnet或nc命令测试端口是否开放,或者用网页版WebSocket测试工具先验证服务端。 - 可能原因2:防火墙/安全组阻止 。检查服务器(如果是云服务器)的安全组规则,确保对应端口(如8080)的入站规则已开放。
- 可能原因3:跨域问题(WebGL特有) 。浏览器控制台会明确提示CORS错误。按照上面5.1节的方法在服务端配置CORS头。
-
可能原因4:协议不匹配
。确保客户端连接地址以
ws://或wss://开头。
-
可能原因1:服务端未运行或地址/端口错误
。用
-
连接成功但收不到消息/消息乱码
-
可能原因1:消息格式不一致
。这是最常见的问题。
务必确保客户端和服务端定义的消息类(字段名、类型)完全一致
。一个字段名大小写不同(如
userNamevsusername)都会导致解析失败。使用JsonUtility时,C#字段名必须与JSON键名完全匹配。 -
可能原因2:未在主线程处理UI更新
。如果你在
OnMessage回调中直接操作UI组件,在非WebGL平台会报错。必须通过MainThreadDispatcher或UnityMainThreadDispatcher等机制切换到主线程。 -
可能原因3:NativeWebSocket的
DispatchMessageQueue未调用 。在PC/移动端平台的Update中忘记调用websocket.DispatchMessageQueue(),会导致消息回调永远不会触发。
-
可能原因1:消息格式不一致
。这是最常见的问题。
务必确保客户端和服务端定义的消息类(字段名、类型)完全一致
。一个字段名大小写不同(如
-
WebGL构建后,在浏览器中运行一片空白或报错
-
可能原因1:构建路径或服务器配置问题
。将构建出的WebGL文件(包含
index.html,.js,.data,.wasm等)全部上传到你的Web服务器(如Nginx, Apache)的同一个目录下。通过https://你的域名/路径/index.html来访问,而不是直接双击打开本地HTML文件。 - 可能原因2:Unity版本与浏览器兼容性 。尝试更新Unity版本或更换浏览器(Chrome/Firefox通常兼容性最好)。
- 可能原因3:压缩格式问题 。在Unity的WebGL构建设置中,尝试将“Compression Format”改为“Disabled”进行测试,排除Gzip/Brotli压缩导致加载失败的问题。
-
可能原因1:构建路径或服务器配置问题
。将构建出的WebGL文件(包含
-
移动端(Android)上连接非常慢或经常断开
-
可能原因:IPv6或网络代理问题
。有些移动网络环境对WebSocket支持不佳。尝试在服务端同时监听IPv4地址(
0.0.0.0)。在客户端,确保使用的是稳定的Wi-Fi网络进行测试。对于生产环境,需要考虑实现心跳包机制来保持连接活跃,并实现自动重连逻辑。
-
可能原因:IPv6或网络代理问题
。有些移动网络环境对WebSocket支持不佳。尝试在服务端同时监听IPv4地址(
心跳包与自动重连实现建议:
在
WebSocketChatManager
中增加一个计时器,定期(如每30秒)向服务器发送一个特定的ping消息。如果长时间未收到服务器的pong回应或任何消息,则判断连接可能已失效,触发自动重连。重连逻辑应有退避策略,比如第一次等待2秒,第二次等待4秒,逐渐增加,避免频繁重连轰炸服务器。
6. 性能优化与扩展方向
一个基础的聊天室跑起来后,我们可以从以下几个方面思考如何让它变得更健壮、功能更强大:
-
消息压缩与二进制协议 :目前我们传递的是JSON字符串,对于纯文本聊天足够。但如果未来要传输图片、语音等二进制数据,JSON的Base64编码会带来约33%的体积膨胀。可以考虑定义自己的二进制协议,或者使用更高效的序列化库(如
MessagePack或Protobuf)。 -
房间/频道系统 :现在的服务器是单个全局聊天室。可以扩展为支持多个房间。客户端连接时发送一个“加入房间”的消息,服务端将客户端分配到对应的
Room对象中,广播和用户列表更新都只在房间内进行。 -
用户身份验证 :目前用户是随机生成的。可以增加登录流程,连接时发送token,服务端验证token的有效性后再允许加入。这需要与已有的用户系统(如数据库)集成。
-
消息持久化与历史记录 :将聊天消息存储到数据库(如MongoDB, Redis)。当新用户加入房间时,可以拉取最近N条历史消息。Redis的发布/订阅模式非常适合用来做消息中转和临时存储。
-
负载均衡与分布式 :当单个服务器无法支撑海量用户时,需要引入负载均衡。可以使用Nginx的
ip_hash做会话保持,或者使用一个专门的信令服务器来分配用户到不同的聊天服务器节点。节点间的用户状态同步会变得复杂。 -
前端UI优化 :
-
消息池
:频繁实例化和销毁
chatMessagePrefab会产生GC(垃圾回收)压力。可以使用对象池来复用消息条目。 - 虚拟列表 :当聊天记录成千上万条时,全部渲染出来会卡死。可以只渲染可视区域内的消息,随着滚动动态加载和卸载,这需要自己实现或使用UI插件。
-
消息池
:频繁实例化和销毁
这个“构建Unity WebSocket聊天室”的项目,就像一把钥匙,为你打开了实时网络应用开发的大门。它涉及的客户端架构、服务端处理、协议设计、跨平台适配和问题排查,是几乎所有实时交互功能的通用基础。当你成功让消息在不同设备间实时穿梭时,那种成就感会让你觉得,之前踩过的每一个坑都是值得的。

1080

被折叠的 条评论
为什么被折叠?



