Unity网络通信笔记
2026/5/14 11:30:08 网站建设 项目流程

需求

首先要意识到网络通信面对的是一个怎么样的情景:

  1. 服务器会连任意个客户端;
  2. 连接是由客户端发起的,绝大多数情况下由客户端断开(除非服务器要维护),任意时刻可能有客户端连入连出,服务端需要知道客户端连出;
  3. 服务端和客户端可能任意时刻给对方发消息,所以双方都要一直准备好接收。但是两端还有别的事要做,通信不能阻塞主线程;
  4. 发的只能是字节数组,发时要把数据类序列化,接收时反序列化;
  5. 发的只能是字节数组,但是发的数据类有多种,需要一个数据类型头标记这是哪个数据类。(如果是二进制序列化用于保存文件,就可以通过文件路径知道对应的数据类,无需这个标记ID,但网络通信是一条信道传递多种数据类);
  6. 分包黏包。接收端可能接收到多条消息或不完整消息;
  7. 接收消息需要经历分包粘包>解析消息类型>反序列化>消息分发器调用委托。为了这几个工序模块化,每个工序做完会调用一个委托,由主程序或什么把它们缝合起来。
  8. 多线程情景下,非主线程不能调用Unity类,但需要解决这个问题;

网络通信要处理的问题

  1. 定义消息类型,通信会使用很多种消息类型,包含的字段数量、类型、名称不同,接收端要能分辨出消息类型(这里面还有两次要分辨类型的情景,一次是反序列化时根据类型提供对应的模板,一次是业务层根据类型交给相应模块处理)。分辨消息类型的方法主要是2种:消息包含的类型枚举;或用is判断。但是反序列化时消息还是字节流,只能用消息头的数字(或者说枚举)判断。业务层这两种方案都可以用。还要考虑消息类型会不会多到爆炸,哪些消息能复用消息类(比如消息类型枚举可以分得很细,但消息类根据消息需要的数据结构分,比如所有只包含一个字符串的消息共用一个消息类,但是它们的消息类型枚举不同)。
  2. 序列化方案,两端必须使用完全相同的库,即使同用json,不同的库序列化后的具体细节也可能不同。
  3. 通信系统多层之间控制耦合度。系统分为通信层(负责字节流发送接收,以及接收端的分包黏包)、协议层(字节流和消息类之间的翻译)、业务层(翻译出的消息类给不同模块使用)。在发送端顺序是业务层>协议层>通信层,接收端是通信层>协议层>业务层,前一层的输出是后一层的输入。如果用调用,前一层要引用后一层,然后服务端、客户端都要处理发送、接收,这3层就互相全都引用,紧紧耦合成一个整体。

分层

可以把通信部分分成两个模块:协议模块、通信模块。前者负责:

  1. 在数据类和字节数组之间转换;
  2. 发送时在数据类的字节数组前加上标记数据类类型的ID;
  3. 接收时根据头的ID判断数据类类型,然后反序列化成数据类;

通信模块负责:

  1. 接收字节数组,发送给另一端,和接收另一端发来的字节数组。
  2. 维护缓存区,处理分包黏包;

因为处理分包黏包需要读取消息的期望长度,属于协议模块的工作,导致协议模块和通信模块不能完全解耦。

调试用具

首先写一个能打印字节数组的函数,用于直接查看消息内容。

void PrintBytes(byte[] bytes) { string byteString = string.Join(", ", bytes); Debug.Log(byteString); }

C#网络通信的类

IPAddress、IPEndPoint、Socket。Socket构造时输入地址版本(IPv4、IPv6)、协议(TCP、UDP)、IPEndPoint构造时输入IPAddress、端口号。二者通过

socket.Bind(iPEndPoint);

联系起来。为什么需要IPEndPoint、Socket两个类?

通信模块

  1. 服务端会连很多客户端,为了存连接的所有客户端可以用一个字典;
  2. 服务端要知道客户端断开了连接,可以通过判断socket.Receive(buffer)返回的数是否为0.没收到消息时这个函数会阻塞线程,不返回,若返回0则说明客户端断开了连接。需要紧接着执行socket.Shutdown()和socket.Close();

客户端:

void HandleBuffer(int len) { bufferEnd += len; while (bufferEnd - bufferHead >= MyProtocol.headOffset) { int lenE = buffer[bufferHead + MyProtocol.typeOffset]; int lenR = bufferEnd - bufferHead - MyProtocol.headOffset; if (lenE > lenR)//分包 { if (bufferHead != 0)//搬运到缓存区头 { Array.Copy(buffer, bufferHead, buffer, 0, bufferEnd - bufferHead); bufferEnd = bufferEnd - bufferHead; bufferHead = 0; } break; } else if (lenE == lenR) { object data = MyProtocol.Instance.Decode(buffer, bufferHead); PrintData(data); bufferHead = 0; bufferEnd = 0; break; } else if (lenE < lenR)//黏包 { object data = MyProtocol.Instance.Decode(buffer, bufferHead); PrintData(data); bufferHead+=MyProtocol.headOffset+lenE; } } }

序列化反序列化模块

用于网络通信的二进制序列化和一般二进制序列化多了消息头,消息头包括:

  1. 标记是哪种数据类的ID;
  2. 用于处理分包黏包而加的消息体长度;

如果暂时没有实战项目,纯为了学习,该怎么写数据类序列化反序列化的部分?

是否要写一个能序列化任意数据类的程序?更一般地说,序列化反序列化是写在各数据类里还是由一个类统一完成?

序列化之后就是一个字节数组,已经无法知道是什么数据类了,所以加数据类ID必须在序列化的函数里。那么这个《能序列化任意数据类的程序》也就不再能序列化任意数据类了。然后意识到“能序列化任意数据类”对网络传输意义不大,任何数据类都要通过头ID才知道怎么反序列化。

不过《能序列化任意数据类的程序》还是能解决对大型数据类一个个字段序列化太麻烦的问题。

使用GetFields()的“万能”序列化程序还有一个问题,就是不能序列化基本数据类型,只能序列化class。

综上,这个《能序列化任意数据类的程序》对输入的object需要先用is判断具体类型,加ID头,然后用GetFields()、循环序列化,如果要传输的数据类种类很多、数据类字段很多,才有优势。

消息体长度需要把数据类序列化后才能知道,却要放在消息体前面。这么看序列化函数里用byte[]处理太不灵活了,不如用List<byte>。

TCP

分包粘包处理

一句话概括分包粘包:接收端接收到的消息不一定是一条,可能小于1,可能是多条,可能有“小数”。

发送端在消息头再加上消息体的字节数。接收端有一个byte[]缓存区,缓存头cacheHead、缓存尾int cacheEnd。收到新消息放入缓存区尾部,更新cacheEnd。消息解析变成一个循环,每次循环:

  1. cacheEnd-cacheHead看是否不小于消息头,小于则循环结束,继续等;
  2. 不小于则读取消息头记录的消息体长度Le,和缓存区的消息体长度Lr比较;
  3. 若Le>Lr,则消息没收完,把残留消息搬运到缓存区头,循环结束,继续等;
  4. 若Le==Lr,则刚好是一条消息,解析,cacheHead、cacheEnd设0,循环结束,继续等;
  5. 若Le<Lr,则有多条消息,解析,更新cacheHead,回到1;

前面的消息解析完后,最后不完整的消息是搬运到缓冲区头还是原地不动?搬到缓存区头造成额外劳动。

如果原地不动,那么

  1. 把完整消息解析后如果尾部有残留消息需要把cacheHead移到残留消息头,如果没有残留消息则cacheHead、cacheEnd都设0;
  2. 存在一种可能,收到的消息总是有残留,cacheHead、cacheEnd总是不设0,有可能溢出缓存区。所以我们发现完整消息解析完后残留消息必须搬运到缓存区头。

处理分包黏包的逻辑略复杂,应该尽量封装,避免反复写。但是这个函数内部调用了解码函数,还修改了缓存区头、缓存区尾两个字段,和其他部分高度耦合。 即使解耦也不得不把解码函数通过委托,bufferHead、bufferEnd通过out传入,解码函数是一个byte[]转object的函数,委托类型还要自定义。

void HandleBuffer(int len) { bufferEnd += len; while (bufferEnd - bufferHead >= MyProtocol.headOffset) { int lenE = buffer[bufferHead + MyProtocol.typeOffset]; int lenR = bufferEnd - bufferHead - MyProtocol.headOffset; if (lenE > lenR)//分包 { if (bufferHead != 0)//搬运到缓存区头 { Array.Copy(buffer, bufferHead, buffer, 0, bufferEnd - bufferHead); bufferEnd = bufferEnd - bufferHead; bufferHead = 0; } break; } else if (lenE == lenR) { object data = MyProtocol.Instance.Decode(buffer, bufferHead); PrintData(data); bufferHead = 0; bufferEnd = 0; break; } else if (lenE < lenR)//黏包 { object data = MyProtocol.Instance.Decode(buffer, bufferHead); PrintData(data); bufferHead+=MyProtocol.headOffset+lenE; } } }

心跳消息

心跳消息里有几种周期?首先是客户端发消息周期Tsend,然后服务端心跳超时Tout如果也用这个周期那么传输中有点延迟就超时了,所以心跳超时应该比Tsend大一点。收心跳消息直接用接收函数。还有一个检测的周期Tcheck,能不能让Tcheck=Tout?

心跳消息和socket.Receive()判断客户端断开的矛盾

socket.Receive()会阻塞线程,使用它返回的len判断客户端断开就不能利用接收线程接收心跳消息。要让socket.Receive()不阻塞接收线程,需要用socket.Availabe>0判断,然后接收。

使用异步方法容易遇到的问题

  1. 客户端连接后就开始发心跳消息,但是连接异步方法可能还没执行完;
  2. 服务端心跳消息超时后关闭连接,但是会把异步接收用到的某些对象设空;
  3. socketAsyncEventArgs.BytesTransferred或EndReceive返回0时,其实是断开了连接;

UDP通信

有这几个特点:

  1. 无连接,就是说收到消息才知道是谁发的。根据之前如果维护一个记录客户的字典,TCP是接受连接时加入字典,UDP是收到消息,客户不在字典时加入字典,之前是客户类有一个接收缓存区,现在收到消息才确定加不加入字典,无法用客户的缓存区收消息了。

HTTP

InvalidOperationException: Insecure connection not allowed

要么把url的http改成https,有时候能成功,有时候会打印unityWebRequest.error:

关于protobuf

protobuf通过命令行生成cs脚本,先输入软件protoc.exe,但是要求:

  1. 不能用双引号括起来;
  2. 路径里不能有空格,否则就被认为是分隔参数的;

要么

  1. cd跳转到protoc.exe的路径,直接输入软件名,然后发现这样也不行,还需要前面加.\
  2. 使用没有空格的路径(扯淡呢,这不可能);
  3. 把protoc.exe加入环境变量PATH(多了这一步手动操作);

脚本,需要注意:

  1. WorkingDirectory写入protoc.exe的文件夹;
  2. exe名字前面加.\\
  3. 路径两边加\"
using System.Diagnostics; using System.IO; using UnityEditor; using UnityEngine; public class ProtobufAuto{ static string PATH = $"{Application.dataPath}/protobuf"; static string EXE_PATH = $".\\protoc.exe"; [MenuItem("Protobuf/生成cs脚本")] static void GenCS() { DirectoryInfo directory = Directory.CreateDirectory(PATH); FileInfo[] files = directory.GetFiles(); UnityEngine.Debug.Log(PATH); foreach (FileInfo file in files) { if (file.Extension == ".proto") { Process process = new Process(); process.StartInfo.FileName = EXE_PATH; process.StartInfo.Arguments = $"-I=\"{PATH}\" --csharp_out=\"{PATH}\" {file.Name}"; process.StartInfo.WorkingDirectory = $"{Application.dataPath}"; process.Start(); UnityEngine.Debug.Log(file.Name); } } } }

找不到metadata文件

重开项目,好了,草你妈。

UnityWebRequest

获取文本、二进制

IEnumerator Load(){ UnityWebRequest reqest=UnityWebRequest.Get("资源地址带后缀名"); yield return request.SendWebRequest(); if(request.result==UnityWebRequest.Result.Success){ request.downloadHander.text; request.downloadHandler.data; }

获取纹理

IEnumerator LoadTex(){ UnityWebRequest reqest=UnityWebRequestTexture.GetTexture("资源地址带后缀名"); yield return request.SendWebRequest(); if(request.result==UnityWebRequest.Result.Success){ (request.downloadHander as DownloadHandlerTexture).texture;//或者下面的方法 DownloadHandlerTexture.GetContent(request); }

获取AB包

IEnumerator LoadAB(){ UnityWebRequest reqest=UnityWebRequestAssetBundle.GetAssetBundle("资源地址带后缀名"); yield return request.SendWebRequest(); if(request.result==UnityWebRequest.Result.Success){ (request.downloadHander as DownloadHandlerAssetBundle).assetBundle;//或者下面的方法 DownloadHandlerAssetBundle.GetContent(request); }

获取进度

IEnumerator Load(){ UnityWebRequest reqest=UnityWebRequest.Get("资源地址带后缀名"); request.SendWebRequest(); while(!request.isDone){ Debug.Log(request.downloadProgress); Debug.Log(request.downloadBytes); yield return 0; } if(request.result==UnityWebRequest.Result.Success){ request.downloadHander.text; request.downloadHandler.data; }

FTP和Serv U

管理用户时出现:当前页面的脚本发生错误

上传失败后会出现,重启Serv U可暂时解决

上传失败:550 Permission denied.

deepseek:

FTP上传文件时出现"550 Permission denied"错误通常与权限配置问题相关,以下是常见原因和解决方案:

  1. 目录权限问题
  • 确保目标目录具有可写权限(chmod 755或777)3
  • 检查目录所有者是否为FTP用户(chown ftpuser)34
  1. FTP服务配置
  • 在vsftpd.conf中启用写入权限:write_enable=YES4
  • 检查匿名用户权限配置(如需匿名上传)8
  • 确认用户未被列入/etc/ftpusers黑名单4
  1. SELinux/防火墙限制
  • 临时禁用SELinux测试:setenforce 04
  • 检查防火墙是否开放FTP端口(21及被动模式端口)45
  1. 被动模式问题
  • 尝试切换主动/被动模式5
  • 确保防火墙允许被动模式端口范围通信5
  1. 其他可能原因
  • 磁盘空间不足6
  • 网络连接不稳定7
  • 路径错误(检查文件路径是否存在)2

建议按以下步骤排查:

  1. 先检查目录权限和所有者34
  2. 验证FTP服务配置48
  3. 检查SELinux/防火墙设置45
  4. 尝试切换传输模式5
  5. 查看服务器日志获取具体错误信息
原因:没有设置request.Credentials,输入用户名和密码!

上传失败:Cannot send a content-body with this verb-type

原因:没有写request.Method = WebRequestMethods.Ftp.UploadFile; !!!!

在SocketAsyncEventArgs.Completed里执行一个回调,里面把一个面板隐藏,显示另一个面板,没有执行。在回调里面Debug.Log(字符串)可以执行,Debug.Log(面板)没有执行。以为是非主线程不能访问Unity类,但是打印Thread.CurrentThread.ManagedThreadId显示是一样的。

解决方法:副线程把委托推到主线程执行!通过synchronizationContext.Post()

_mainThread = SynchronizationContext.Current; void PostToMainThread<T>(Action<T> action,T t) { var d = new SendOrPostCallback((o) => { action?.Invoke(t); }); _mainThread.Post(d,t); }

消息基类用类还是接口?

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询