
1. 项目概述与核心价值最近在做一个Unity项目需要实现一个实时性要求比较高的文件传输功能。传统的HTTP协议在频繁的小文件传输或者需要服务端主动推送进度时显得有些笨重每次请求都要建立连接、传输、断开开销不小。而WebSocket协议作为一种全双工通信协议正好能解决这个问题——连接一旦建立客户端和服务器就可以在任意时刻互发数据非常适合用来做文件上传下载这种需要实时交互的场景。这个项目就是基于Unity结合NativeWebSocket客户端和.NET服务端的Fleck库搭建一个完整的WebSocket文件传输系统。我会把整个实现过程从环境搭建、协议设计、核心代码实现到各种“坑”的规避都详细拆解一遍。无论你是想在自己的游戏里加入实时更新资源包的功能还是需要做一个内网工具进行快速文件分发这套方案都能给你提供一个扎实的起点。代码我会尽量保持清晰、模块化方便你直接拿去集成或修改。2. 技术选型与架构设计思路为什么选NativeWebSocket和Fleck这背后有几个很实际的考量。首先看客户端。Unity里处理WebSocket的插件不少比如WebSocketSharp、Best HTTP/2的WebSocket模块等。我选择NativeWebSocket主要是因为它足够轻量、纯粹并且对移动平台iOS/Android的支持很好。它是一个纯C#的实现不依赖任何原生插件Native Plugin这意味着在WebGL平台也能用虽然WebGL平台对二进制数据的处理有些特殊但至少是可行的。它的API设计也很简洁事件驱动用起来非常顺手。服务端选择Fleck原因类似。它是一个轻量级的.NET WebSocket服务器库基于async/await性能不错而且使用起来非常简单几行代码就能跑起来一个WebSocket服务器。对于中小型项目或者内部工具来说它比SignalR更轻比直接用System.Net.WebSockets封装要省事得多。我们的文件传输服务端通常不需要特别复杂的路由和鉴权逻辑Fleck的简洁性正合适。整个架构的设计思路是“命令-数据”分离。WebSocket连接本身只负责传输数据流我们需要自己定义一套简单的应用层协议来告诉对方“接下来要干什么”。比如客户端要上传文件不能直接把文件二进制流发过去服务器会懵。所以我们会先发一个小的JSON数据包命令帧告诉服务器“嘿我要开始上传了文件名是level1_assets.zip大小是1024000字节”。服务器收到这个命令后做好准备比如在指定目录创建空文件然后回复一个“可以开始发送数据”的指令。之后客户端再将文件切分成一个个数据块数据帧发送过去。下载流程则相反。这种设计的好处是逻辑清晰易于处理中断、续传和错误。如果传输中途连接断了重连后我们可以根据已传输的大小发送一个“续传”命令从断点继续而不是从头开始。3. 环境准备与核心库引入3.1 Unity客户端环境搭建在Unity中我们首先需要导入NativeWebSocket。最方便的方式是通过Unity的Package Manager使用Git URL添加。打开Unity项目进入Window - Package Manager。点击左上角的号选择Add package from git URL...。在弹出的输入框中填入NativeWebSocket的Git仓库地址https://github.com/endel/NativeWebSocket.git#upm点击Add等待Unity下载并导入包。导入成功后你可以在Packages目录下看到NativeWebSocket。现在在任何脚本中你都可以通过using NativeWebSocket;来使用它了。注意如果你遇到Git相关错误可能是因为没安装Git或者Unity找不到Git路径。确保系统已安装Git并在Unity的Edit - Preferences - External Tools中正确配置了Git的安装路径。3.2 .NET 服务端环境搭建服务端我们创建一个简单的.NET Console App。使用Visual Studio或者命令行工具dotnet new console创建一个新项目。然后通过NuGet包管理器添加Fleck库。在项目根目录的.csproj文件中添加包引用或者使用NuGet管理器界面搜索Fleck安装。!-- 在你的 .csproj 文件中添加 -- ItemGroup PackageReference IncludeFleck Version1.2.0 / /ItemGroupFleck依赖于System.Net.WebSockets但后者是.NET Framework/.NET Core/.NET 5内置的所以通常不需要额外安装。4. 核心协议设计与消息格式定义在WebSocket的二进制流之上我们需要定义自己的消息格式。一个完整的消息由“消息头”和“消息体”组成。消息头 (Message Header):我们用一个简短的JSON字符串作为消息头它至少包含两个字段type: 消息类型用于区分是命令还是数据。例如command,data。action: 具体动作。例如upload_start,upload_data,download_request,progress等。消息体 (Message Body):对于command类型的消息消息体可以是JSON字符串包含更多参数如fileName,fileSize,offset用于断点续传等。对于data类型的消息消息体就是纯粹的二进制数据块。如何组合发送我们采用“头体分离顺序发送”的方式。在同一WebSocket连接中先发送一个完整的、包含消息头的文本帧WebSocketOpcode.Text紧接着发送一个或多个包含消息体的二进制帧WebSocketOpcode.Binary。接收方按顺序解析即可。例如上传文件的流程客户端 - 服务器: 发送文本帧{type:command, action:upload_start, fileName:test.zip, fileSize: 2048000}服务器 - 客户端: 发送文本帧{type:command, action:upload_ack}(确认可以开始)客户端 - 服务器: 发送二进制帧[字节数据块1]客户端 - 服务器: 发送二进制帧[字节数据块2]... 直到发送完毕客户端 - 服务器: 发送文本帧{type:command, action:upload_end}服务器 - 客户端: 发送文本帧{type:command, action:upload_complete}这种设计避免了将元数据和二进制数据混合编码如Base64导致的开销增大也保持了协议的灵活性和可读性。5. Unity客户端核心实现详解5.1 WebSocket连接管理与事件处理首先我们创建一个FileWebSocketClient类来封装所有WebSocket操作。using NativeWebSocket; using System; using System.Collections.Generic; using System.IO; using System.Text; using System.Threading.Tasks; using UnityEngine; public class FileWebSocketClient : MonoBehaviour { private WebSocket websocket; private string serverAddress ws://127.0.0.1:8181; // 服务器地址 // 定义一些事件方便UI或其他脚本监听 public event Action OnConnected; public event Actionstring OnDisconnected; public event Actionstring OnError; public event Actionlong, long OnUploadProgress; // 已传总量 public event Actionlong, long OnDownloadProgress; // 已收总量 private Dictionarystring, FileTransferTask activeTasks new Dictionarystring, FileTransferTask(); async void Start() { // 启动时自动连接实际项目可根据需要调整 await Connect(); } public async Task Connect() { if (websocket ! null websocket.State WebSocketState.Open) { Debug.LogWarning(WebSocket is already connected.); return; } websocket new WebSocket(serverAddress); websocket.OnOpen () { Debug.Log(Connection opened!); OnConnected?.Invoke(); }; websocket.OnError (errorMsg) { Debug.LogError(WebSocket Error: errorMsg); OnError?.Invoke(errorMsg); }; websocket.OnClose (closeCode) { Debug.Log(Connection closed with code: closeCode); OnDisconnected?.Invoke(closeCode.ToString()); }; websocket.OnMessage HandleIncomingMessage; try { await websocket.Connect(); } catch (Exception ex) { Debug.LogError(Connect failed: ex.Message); OnError?.Invoke(ex.Message); } } void Update() { // NativeWebSocket 需要在主线程分发事件 #if !UNITY_WEBGL || UNITY_EDITOR if (websocket ! null) { websocket.DispatchMessageQueue(); } #endif } private async void OnDestroy() { if (websocket ! null websocket.State WebSocketState.Open) { await websocket.Close(); } } // ... 后续处理消息和文件传输的方法 }实操心得NativeWebSocket在WebGL平台下消息回调是自动在主线程执行的。但在其他平台如PC、移动端OnMessage等事件可能在后台线程触发。因此我们需要在Update()中调用DispatchMessageQueue()将消息队列里的回调函数拉到主线程执行这样才能安全地操作Unity的API如更新UI、打印Log。这是一个非常关键的细节忘记调用会导致回调不执行或者报错。5.2 文件上传功能实现上传的核心是将文件分块读取并按照我们的协议发送。我们定义一个FileTransferTask类来管理单个传输任务的状态。public class FileTransferTask { public string FilePath { get; set; } public string FileName { get; set; } public long FileSize { get; set; } public long TransferredSize { get; set; } public bool IsUpload { get; set; } public string TaskId { get; set; } // 用于标识任务可以用GUID } // 在 FileWebSocketClient 类中添加方法 public async Task UploadFile(string localFilePath, string remoteFileName null) { if (websocket null || websocket.State ! WebSocketState.Open) { Debug.LogError(WebSocket is not connected.); return; } if (!File.Exists(localFilePath)) { Debug.LogError($File not found: {localFilePath}); return; } FileInfo fileInfo new FileInfo(localFilePath); string fileName string.IsNullOrEmpty(remoteFileName) ? fileInfo.Name : remoteFileName; long fileSize fileInfo.Length; // 创建传输任务 string taskId Guid.NewGuid().ToString(); var task new FileTransferTask { TaskId taskId, FilePath localFilePath, FileName fileName, FileSize fileSize, TransferredSize 0, IsUpload true }; activeTasks[taskId] task; // 1. 发送上传开始命令 var startCommand new { type command, action upload_start, taskId taskId, fileName fileName, fileSize fileSize }; string startMsg JsonUtility.ToJson(startCommand); await websocket.SendText(startMsg); Debug.Log($Sent upload_start command for {fileName}); }发送开始命令后我们需要在HandleIncomingMessage中等待服务器的确认(upload_ack)。收到确认后才开始发送数据块。private async void HandleIncomingMessage(byte[] bytes) { // 首先尝试解码为文本消息命令 try { string message Encoding.UTF8.GetString(bytes); var command JsonUtility.FromJsonWebSocketCommand(message); if (command ! null command.type command) { await ProcessCommand(command); return; // 命令处理完毕不再尝试作为数据处理 } } catch { // 如果不是有效的JSON文本则可能是二进制数据帧 } // 处理二进制数据帧如下载的文件块 // 这部分逻辑在下载功能中实现 } private async Task ProcessCommand(WebSocketCommand cmd) { switch (cmd.action) { case upload_ack: // 服务器允许上传开始发送数据 if (activeTasks.TryGetValue(cmd.taskId, out var uploadTask)) { _ SendFileDataChunks(uploadTask); // 使用 discard 操作符不等待 } break; case upload_complete: Debug.Log($Upload complete for task: {cmd.taskId}); if (activeTasks.ContainsKey(cmd.taskId)) { activeTasks.Remove(cmd.taskId); } break; case progress: // 处理进度更新可能是上传或下载进度 if (activeTasks.TryGetValue(cmd.taskId, out var task)) { long transferred cmd.transferredSize; if (task.IsUpload) { OnUploadProgress?.Invoke(transferred, task.FileSize); } else { OnDownloadProgress?.Invoke(transferred, task.FileSize); } } break; // ... 处理其他命令如 download_response, error 等 } } // 一个简单的命令类 [System.Serializable] public class WebSocketCommand { public string type; public string action; public string taskId; public string fileName; public long fileSize; public long transferredSize; public string error; }现在实现发送数据块的核心方法SendFileDataChunksprivate async Task SendFileDataChunks(FileTransferTask task) { const int chunkSize 64 * 1024; // 64KB 为一个数据块可根据网络调整 byte[] buffer new byte[chunkSize]; using (FileStream fileStream new FileStream(task.FilePath, FileMode.Open, FileAccess.Read, FileShare.Read)) { fileStream.Seek(task.TransferredSize, SeekOrigin.Begin); // 支持断点续传 int bytesRead; while ((bytesRead await fileStream.ReadAsync(buffer, 0, buffer.Length)) 0) { if (websocket.State ! WebSocketState.Open) { Debug.LogError(WebSocket disconnected during transfer.); break; } // 发送数据块 await websocket.Send(buffer.Take(bytesRead).ToArray()); // 注意只发送实际读取的字节 task.TransferredSize bytesRead; // 可以定期发送进度更新或者由服务器计算并推送 // 这里我们选择由客户端每隔几个块或固定时间发送一次进度命令 if (task.TransferredSize % (chunkSize * 10) 0) // 每10个块约640KB报告一次进度 { var progressCmd new { type command, action progress, taskId task.TaskId, transferredSize task.TransferredSize }; await websocket.SendText(JsonUtility.ToJson(progressCmd)); } // 短暂延迟避免发送过快淹没接收方非必须取决于网络和服务器处理能力 // await Task.Delay(1); } // 所有数据发送完毕发送结束命令 if (websocket.State WebSocketState.Open) { var endCommand new { type command, action upload_end, taskId task.TaskId }; await websocket.SendText(JsonUtility.ToJson(endCommand)); Debug.Log($All data sent for {task.FileName}. Waiting for server confirmation...); } } }注意事项分块大小chunkSize的选择是个权衡。太小如1KB会导致协议头开销比例变大频繁的IO和网络发送调用太大如1MB则可能导致单次发送延迟高内存占用大且不利于实时进度更新。通常选择16KB到256KB之间。对于移动网络可以适当调小对于稳定高速的内网可以调大。我这里选择64KB是一个比较折中的值。5.3 文件下载功能实现下载流程是上传的逆过程。客户端发送下载请求命令服务器回复一个开始命令后持续发送二进制数据块。客户端需要将接收到的二进制数据块按顺序写入文件。首先在FileWebSocketClient中添加下载请求方法public async Task DownloadFile(string remoteFileName, string localSavePath) { if (websocket null || websocket.State ! WebSocketState.Open) { Debug.LogError(WebSocket is not connected.); return; } string taskId Guid.NewGuid().ToString(); var task new FileTransferTask { TaskId taskId, FilePath localSavePath, // 这次是保存路径 FileName remoteFileName, FileSize 0, // 初始未知由服务器返回 TransferredSize 0, IsUpload false }; activeTasks[taskId] task; // 发送下载请求命令 var requestCommand new { type command, action download_request, taskId taskId, fileName remoteFileName }; await websocket.SendText(JsonUtility.ToJson(requestCommand)); Debug.Log($Sent download_request for {remoteFileName}); }然后我们需要扩展HandleIncomingMessage和ProcessCommand来处理下载相关的命令和数据。在ProcessCommand中添加对download_response的处理case download_response: // 服务器响应下载请求告知文件大小或错误 if (activeTasks.TryGetValue(cmd.taskId, out var downloadTask)) { if (!string.IsNullOrEmpty(cmd.error)) { Debug.LogError($Download error: {cmd.error}); OnError?.Invoke(cmd.error); activeTasks.Remove(cmd.taskId); } else { downloadTask.FileSize cmd.fileSize; Debug.Log($Ready to download {downloadTask.FileName}, size: {cmd.fileSize}); // 服务器接下来会开始发送二进制数据流 // 我们需要准备文件流来写入数据 PrepareFileStreamForDownload(downloadTask); } } break;PrepareFileStreamForDownload方法负责创建或打开本地文件并关联一个写入流。同时我们需要修改HandleIncomingMessage将非命令的二进制数据帧识别为下载的数据块并写入文件。这里有一个关键点我们如何知道当前接收到的二进制帧属于哪个下载任务由于WebSocket是全双工的上传和下载的数据帧可能交错到达。我们需要一个状态机或者上下文来关联。一个简单有效的方法是在服务器发送二进制数据流之前先发送一个download_start命令其中包含taskId。客户端收到后将当前taskId设置为“正在接收数据”的状态。之后接收到的二进制帧都写入这个任务对应的文件直到收到download_end命令。我们在FileTransferTask中增加一个IsReceivingData的布尔状态并在ProcessCommand中处理download_start和download_end。public class FileTransferTask { // ... 原有属性 public bool IsReceivingData { get; set; } false; public FileStream FileStream { get; set; } // 持有文件流引用 } // 在 FileWebSocketClient 中 private FileTransferTask currentDownloadTask null; private async Task ProcessCommand(WebSocketCommand cmd) { switch (cmd.action) { // ... 其他case case download_start: if (activeTasks.TryGetValue(cmd.taskId, out var startTask) !startTask.IsUpload) { currentDownloadTask startTask; startTask.IsReceivingData true; // 创建文件流准备写入 startTask.FileStream new FileStream(startTask.FilePath, FileMode.Create, FileAccess.Write, FileShare.None); Debug.Log($Start receiving data for task: {cmd.taskId}); } break; case download_end: if (currentDownloadTask ! null currentDownloadTask.TaskId cmd.taskId) { currentDownloadTask.FileStream?.Close(); currentDownloadTask.FileStream?.Dispose(); currentDownloadTask.FileStream null; currentDownloadTask.IsReceivingData false; Debug.Log($Download finished for task: {cmd.taskId}); OnDownloadProgress?.Invoke(currentDownloadTask.FileSize, currentDownloadTask.FileSize); // 触发100%进度 activeTasks.Remove(cmd.taskId); currentDownloadTask null; } break; } } private async void HandleIncomingMessage(byte[] bytes) { // 1. 尝试解析为命令 try { string message Encoding.UTF8.GetString(bytes); var command JsonUtility.FromJsonWebSocketCommand(message); if (command ! null command.type command) { await ProcessCommand(command); return; } } catch { // 不是文本命令继续 } // 2. 处理二进制数据帧下载的数据块 if (currentDownloadTask ! null currentDownloadTask.IsReceivingData) { // 将数据写入文件流 await currentDownloadTask.FileStream.WriteAsync(bytes, 0, bytes.Length); currentDownloadTask.TransferredSize bytes.Length; // 更新进度可以节流避免每帧都触发事件 OnDownloadProgress?.Invoke(currentDownloadTask.TransferredSize, currentDownloadTask.FileSize); } else { Debug.LogWarning($Received binary data but no active download task is receiving.); } }最后别忘了在OnDestroy或断开连接时清理所有打开的文件流。6. .NET Fleck 服务端核心实现服务端的职责是监听连接、解析命令、协调文件的上传存储和下载发送。6.1 WebSocket服务器启动与连接管理using Fleck; using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text; using System.Text.Json; using System.Threading.Tasks; namespace FileWebSocketServer { class Program { static ConcurrentDictionaryGuid, IWebSocketConnection sockets new ConcurrentDictionaryGuid, IWebSocketConnection(); static ConcurrentDictionarystring, FileUploadSession uploadSessions new ConcurrentDictionarystring, FileUploadSession(); static string baseStoragePath ./UploadedFiles; // 文件存储根目录 static void Main(string[] args) { FleckLog.Level LogLevel.Debug; // 设置日志级别 var server new WebSocketServer(ws://0.0.0.0:8181); server.Start(socket { socket.OnOpen () { Console.WriteLine($Connection opened: {socket.ConnectionInfo.Id}); sockets.TryAdd(socket.ConnectionInfo.Id, socket); }; socket.OnClose () { Console.WriteLine($Connection closed: {socket.ConnectionInfo.Id}); sockets.TryRemove(socket.ConnectionInfo.Id, out _); // 清理该连接可能存在的未完成上传会话 var sessionsToRemove uploadSessions.Where(s s.Value.ConnectionId socket.ConnectionInfo.Id).Select(s s.Key).ToList(); foreach (var key in sessionsToRemove) { uploadSessions.TryRemove(key, out _); } }; socket.OnMessage async message { await ProcessMessage(socket, message); }; socket.OnBinary async data { await ProcessBinaryData(socket, data); }; }); Console.WriteLine(WebSocket Server started on ws://0.0.0.0:8181); Console.ReadLine(); // 阻塞主线程 } // ... 后续的消息处理函数 } public class FileUploadSession { public string TaskId { get; set; } public string FileName { get; set; } public long FileSize { get; set; } public long ReceivedSize { get; set; } public FileStream FileStream { get; set; } public Guid ConnectionId { get; set; } } }6.2 命令解析与上传逻辑处理ProcessMessage方法处理所有文本命令。private static async Task ProcessMessage(IWebSocketConnection socket, string message) { try { using JsonDocument doc JsonDocument.Parse(message); var root doc.RootElement; if (!root.TryGetProperty(type, out var typeProp) || typeProp.GetString() ! command) { await SendError(socket, Invalid message type.); return; } string action root.GetProperty(action).GetString(); string taskId root.GetProperty(taskId).GetString(); switch (action) { case upload_start: await HandleUploadStart(socket, root, taskId); break; case upload_end: await HandleUploadEnd(socket, taskId); break; case progress: // 客户端上报进度服务端可以记录这里简单忽略或记录到日志 Console.WriteLine($Progress for {taskId}: {root.GetProperty(transferredSize).GetInt64()}); break; case download_request: await HandleDownloadRequest(socket, root, taskId); break; default: await SendError(socket, $Unknown action: {action}); break; } } catch (Exception ex) { Console.WriteLine($Error processing message: {ex.Message}); await SendError(socket, $Server error: {ex.Message}); } } private static async Task HandleUploadStart(IWebSocketConnection socket, JsonElement root, string taskId) { string fileName root.GetProperty(fileName).GetString(); long fileSize root.GetProperty(fileSize).GetInt64(); // 安全检查文件名、路径等 if (string.IsNullOrEmpty(fileName) || fileName.Contains(..) || Path.GetInvalidFileNameChars().Any(fileName.Contains)) { await SendError(socket, Invalid file name., taskId); return; } // 限制文件大小例如100MB const long maxFileSize 100 * 1024 * 1024; if (fileSize maxFileSize) { await SendError(socket, $File size exceeds limit: {maxFileSize / (1024*1024)}MB, taskId); return; } // 确保存储目录存在 if (!Directory.Exists(baseStoragePath)) { Directory.CreateDirectory(baseStoragePath); } string filePath Path.Combine(baseStoragePath, fileName); // 处理重名文件例如添加时间戳 if (File.Exists(filePath)) { string timestamp DateTime.Now.ToString(yyyyMMdd_HHmmss); string nameWithoutExt Path.GetFileNameWithoutExtension(fileName); string extension Path.GetExtension(fileName); fileName ${nameWithoutExt}_{timestamp}{extension}; filePath Path.Combine(baseStoragePath, fileName); } // 创建上传会话 var session new FileUploadSession { TaskId taskId, FileName fileName, FileSize fileSize, ReceivedSize 0, ConnectionId socket.ConnectionInfo.Id }; try { session.FileStream new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.None); uploadSessions[taskId] session; // 发送确认命令 var ack new { type command, action upload_ack, taskId taskId }; await socket.Send(JsonSerializer.Serialize(ack)); Console.WriteLine($Upload session started for {fileName} (TaskId: {taskId})); } catch (Exception ex) { Console.WriteLine($Failed to create file {filePath}: {ex.Message}); await SendError(socket, $Server failed to prepare storage: {ex.Message}, taskId); session.FileStream?.Dispose(); uploadSessions.TryRemove(taskId, out _); } }6.3 二进制数据接收与文件写入当客户端开始发送二进制数据块时服务端的OnBinary事件触发。private static async Task ProcessBinaryData(IWebSocketConnection socket, byte[] data) { // 关键我们需要知道这个二进制数据块属于哪个上传任务。 // 一个简单但不够严谨的假设是客户端在上传过程中不会穿插其他二进制数据。 // 更严谨的做法是让客户端在每个数据块前也发送一个包含taskId的小头但这会增加协议复杂度。 // 这里我们采用会话关联一个连接在同一时间只处理一个文件上传。 // 我们可以通过连接ID找到最近的上传会话。 var session uploadSessions.Values.FirstOrDefault(s s.ConnectionId socket.ConnectionInfo.Id s.FileStream ! null); if (session null) { Console.WriteLine($Received binary data but no active upload session for connection {socket.ConnectionInfo.Id}); // 可以发送错误命令给客户端 await SendError(socket, No active upload session., null); return; } try { await session.FileStream.WriteAsync(data, 0, data.Length); session.ReceivedSize data.Length; // 定期向客户端发送进度可选 if (session.ReceivedSize % (64 * 1024 * 10) 0) // 每640KB发送一次进度 { var progress new { type command, action progress, taskId session.TaskId, transferredSize session.ReceivedSize }; await socket.Send(JsonSerializer.Serialize(progress)); } // 检查是否接收完毕 if (session.ReceivedSize session.FileSize) { await FinalizeUpload(socket, session); } } catch (Exception ex) { Console.WriteLine($Error writing data for session {session.TaskId}: {ex.Message}); await SendError(socket, $File write error: {ex.Message}, session.TaskId); CleanupSession(session); } } private static async Task FinalizeUpload(IWebSocketConnection socket, FileUploadSession session) { session.FileStream?.Close(); session.FileStream?.Dispose(); uploadSessions.TryRemove(session.TaskId, out _); var complete new { type command, action upload_complete, taskId session.TaskId }; await socket.Send(JsonSerializer.Serialize(complete)); Console.WriteLine($Upload completed for {session.FileName}); } private static void CleanupSession(FileUploadSession session) { session.FileStream?.Close(); session.FileStream?.Dispose(); if (session.FileStream ! null session.ReceivedSize session.FileSize) { // 上传未完成删除可能已损坏的部分文件 try { File.Delete(Path.Combine(baseStoragePath, session.FileName)); } catch { } } uploadSessions.TryRemove(session.TaskId, out _); }6.4 文件下载逻辑处理当客户端请求下载时服务器需要读取文件并分块发送。private static async Task HandleDownloadRequest(IWebSocketConnection socket, JsonElement root, string taskId) { string fileName root.GetProperty(fileName).GetString(); string filePath Path.Combine(baseStoragePath, fileName); if (!File.Exists(filePath)) { await SendError(socket, $File not found: {fileName}, taskId); return; } FileInfo fileInfo new FileInfo(filePath); long fileSize fileInfo.Length; // 发送下载响应告知文件大小 var response new { type command, action download_response, taskId taskId, fileSize fileSize }; await socket.Send(JsonSerializer.Serialize(response)); // 发送开始命令 var start new { type command, action download_start, taskId taskId }; await socket.Send(JsonSerializer.Serialize(start)); // 开始发送文件数据 const int bufferSize 64 * 1024; // 与服务端发送块大小一致 byte[] buffer new byte[bufferSize]; try { using (FileStream fileStream new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read)) { int bytesRead; long totalSent 0; while ((bytesRead await fileStream.ReadAsync(buffer, 0, buffer.Length)) 0) { // 注意Fleck的Send方法需要完整的数组如果bytesRead小于buffer.Length需要截取 byte[] chunk bytesRead buffer.Length ? buffer : buffer.Take(bytesRead).ToArray(); await socket.Send(chunk); totalSent bytesRead; // 发送进度可选 if (totalSent % (bufferSize * 10) 0) { var progress new { type command, action progress, taskId taskId, transferredSize totalSent }; await socket.Send(JsonSerializer.Serialize(progress)); } } } // 发送结束命令 var end new { type command, action download_end, taskId taskId }; await socket.Send(JsonSerializer.Serialize(end)); Console.WriteLine($File {fileName} sent successfully.); } catch (Exception ex) { Console.WriteLine($Error sending file {fileName}: {ex.Message}); await SendError(socket, $Failed to send file: {ex.Message}, taskId); } } private static async Task SendError(IWebSocketConnection socket, string error, string taskId null) { var errorMsg new { type command, action error, taskId taskId, error error }; await socket.Send(JsonSerializer.Serialize(errorMsg)); }7. 关键问题排查与性能优化技巧在实际部署和测试中你肯定会遇到各种问题。这里记录几个我踩过的坑和解决方案。1. 连接不稳定与断线重传WebSocket连接并非100%可靠尤其是在移动网络下。我们的协议必须支持断点续传。上传续传客户端在发送upload_start命令时可以附加一个offset参数表示已传输的字节数。服务器收到后检查文件是否存在以及大小是否匹配offset如果匹配则打开文件流并定位到offset位置回复upload_ack并告知offset。客户端从offset处开始发送剩余数据。下载续传类似客户端在download_request中携带offset。服务器从文件指定位置开始读取并发送。这需要服务器记录每个任务已发送的字节数或者客户端在本地记录已接收的字节数。2. 大文件传输与内存压力一次性将大文件读入内存File.ReadAllBytes会导致内存飙升甚至溢出。始终使用流Stream如示例所示使用FileStream并分块读取/写入保持内存占用恒定约等于块大小。调整块大小根据目标平台调整chunkSize。PC端可以大一些256KB移动端或WebGL建议小一些16KB-32KB并在发送间隔中加入微小延迟Task.Delay(1)避免阻塞主线程或导致GC频繁。3. 并发传输与任务管理一个客户端同时上传/下载多个文件或者多个客户端连接服务器。任务ID是关键每个传输任务必须有唯一的taskId如GUID并在所有命令和数据关联中使用它。服务端的uploadSessions字典就是基于taskId来管理的。连接与任务解耦一个WebSocket连接可以承载多个任务。在服务端我们通过ConnectionId和taskId共同管理会话。清理资源时如连接断开需要遍历并清理该连接下的所有任务。4. 进度更新的频率与性能频繁地发送进度更新命令比如每收到一个64KB块就发一次会产生大量小网络包增加开销。节流Throttle如代码所示每传输一定量数据如10个块或每隔固定时间如100毫秒才发送一次进度更新。在Unity客户端更新UI进度条时也要注意不要每帧都更新可以使用一个累加器或者使用Observable的Sample操作符进行采样。5. 文件安全性路径穿越防护服务器在接收文件名时必须检查是否包含..或绝对路径防止客户端上传文件到系统任意目录。示例中使用了简单的检查。文件类型过滤根据业务需求可以在服务端检查文件扩展名或MIME类型。病毒扫描对于用户上传的文件在生产环境中应考虑集成病毒扫描服务。6. NativeWebSocket在WebGL平台的限制WebGL平台下的WebSocket是浏览器原生实现对二进制数据的支持可能因浏览器而异。NativeWebSocket会尝试使用ArrayBuffer。如果遇到问题可以尝试将二进制数据转换为Base64字符串发送但这会增加约33%的数据量。对于非关键小文件这不失为一种兼容性方案。8. 扩展思路与应用场景这个基础的WebSocket文件传输框架可以扩展到很多实际场景1. 游戏资源热更新这是最直接的应用。游戏启动时连接资源服务器检查本地资源版本号通过WebSocket下载差异资源包。利用其全双工特性可以实时显示精确的下载进度和速度。相比HTTP在需要同时处理多个小文件更新时WebSocket的单连接复用优势明显。2. 实时日志与数据上报游戏运行时可以将性能数据、异常日志、用户行为事件通过同一个WebSocket连接实时上报到服务器服务器甚至可以即时下发配置调整指令。3. 多人游戏中的自定义内容分享例如玩家在游戏中创作了一张地图或一个角色皮肤可以将其打包成文件通过WebSocket快速分享给房间内的其他玩家。4. 内网开发工具在团队内部可以开发一个简单的工具用于在开发机、测试手机、模拟器之间快速同步构建好的AssetBundle或配置文件提升开发测试效率。要实现这些扩展核心是丰富我们的应用层协议。例如为“资源更新”场景增加check_version、file_list、patch_download等命令为“日志上报”增加log_data、config_update等命令。协议的设计要保持向后兼容和可扩展性。最后关于性能监控建议在关键节点如连接、命令处理、数据块收发添加简单的日志或计数器以便在出现问题时能快速定位瓶颈是网络延迟、IO速度还是代码逻辑问题。这套系统虽然简单但涵盖了实时网络通信的核心模式理解了它你就能应对更多复杂的网络交互需求。