Unity网络开发基础——基础知识(壹)

目录

目录

学习笔记主要来源于唐老狮的Unity课程,经过个人简单整理而成。笔记总共五个部分:

这个课程的大致内容如下:

  • 网络通信中的一些必备理论知识(OSI模型、TCP/IP协议等)。
  • 对自定义类对象进行序列化和反序列化。
  • 使用Socket,进行TCP、UDP的同步异步通信。
  • 处理网络通信中的分包黏包和心跳消息。
  • 使用FTP和HTTP协议进行文件的上传和下载。
  • 掌握Unity的WWW类和UnityWebRequest类。
  • 熟练使用Protobuf,了解自定义协议生成工具的制作原理。
  • 了解大小端模式,消息加密原理。

课程不包含游戏的实际游戏开发的内容,比如后端相关知识、同步模式(帧同步、状态同步)等等,只包含网络通信最基本的内容。

一、网络基础理论

1、网络基本概念

  • 网络:由若干设备和连接这些设备的链路构成,设备间可以相互通信。
  • 局域网:某个小区域内多台设备互联成的计算机组。
  • 以太网:网络连接的一种规则,定义了连接传输规范。
  • 城域网:一个城市范围内所建立的网络,几十到一百公里。
  • 广域网:连接不同城市地区、国家的远程网络,几十到几千公里。
  • 因特网:目前国际上最大的互联网。
  • 万维网:基于因特网的网站和网页的统称。
  • 网络的拓扑结构:用传输媒介将各种设备相互连接而成的物理布局。

2、IP、端口号、Mac地址

  • IP地址:设备在外网的位置。
  • 端口:运行在该设备上的应用程序位置。
  • Mac地址:设备进行网络通信的唯一表示,设备真正进行物理信息传输的定位标识。

3、客户端和服务端

  • 客户端(Client):用户使用的设备(手机、平板等)。
  • 客户端应用程序:运行在客户端上的应用程序(也简称为客户端)。
  • 服务端(Server):为客户端服务的设备,一般是性能较好的计算机。
  • 服务端应用程序:为客户端提供服务的应用程序,运行在服务器上(也简称服务端或服务器)。
  • 游戏开发的客户端与服务端
    • 单机游戏:只有客户端,数据存储在本地。
    • 网络游戏:有客户端和服务端,不变的数据存客户端,动态的数据存服务端,两者通过互联网进行信息交换。

4、数据管理模型

  • 分散式(Decentralized):用户只负责管理自己的计算机系统,每个计算机上都存有数据,各独立系统间没有资源或信息的交换和共享。这种模式存在大量共享数据的重复存储,会导致数据不一致性,成本过高,因此已被淘汰。

  • 集中式(Centralized)一台主计算机保存一个组织的全部数据,用户通过设备李连杰到这台计算机并和它通信。这种模式方便数据共享并且消除了数据冗余和不一致性,但是可靠性不如分散式,主机出现故障则所有系统瘫痪。

  • 分布式(Distributed):结合分散式和集中式的优点,核心数据可能集中管理(如总服务器),而其他数据和处理任务分散在不同地方的服务器上。这种方式保证了数据一致性,同时提高了容错性,不容易出现系统瘫痪的情况,是目前的主流方式。

5、数据通信模型

  • C/S模型:客户端-服务器模型。即客户端和服务端交互的模式,是目前大多数网络通信采用的模型。
  • B/S模型:浏览器-服务器模型,C/S 模型的特例。客户端不用专门开发,而是通用的浏览器,并且单台计算机可以访问任意的Web服务器。
  • P2P模型:点对点模型,也叫对等互联。每个联网的设备同时运行客户端和服务端,每个人既请求服务也提供服务,常见的就比如迅雷下载。

游戏服务器一般采用分布式架构,通信模型中,网络游戏一般用C/S模型,局域网一般采用P2P模型。

二、网络协议

1、概述

  • 协议:经过谈判、协商而定的共同承认、共同遵守的文件。
  • 网络协议:计算机网络中进行数据交换而建立的规则、标准或约定的集合。
  • OSI模型:国际组织定义的一套理论基础,用于定义网络通信规则。
  • TCP/IP协议:基于OSI模型的具体实现。

之前学习了网络的基本概念,如网络如何找到设备(IP、端口)、网络设备基本布局等。

接下来将学习这些二进制数据如何加工、如何传递到目标设备中,传递的规则标准是什么。而这些规则标准都由网络协议制定。

2、OSI模型

  • OSI模型:由国际标准化组织提出,是一个试图使各种设备在世界范围内互联为网络的标准框架。
  • OSI模型规则:互联网的协议很庞大且复杂,因此需要将OSI模型分层,每层都有自己的功能,采用分而治之的方法。OSI分了7个层级。
  • OSI模型每层职能
    • 应用层:为应用程序提供服务。
    • 表示层:格式化数据、加密解密。
    • 会话层:建立、管理和维护会话。
    • 传输层:建立、管理和维护端到端的连接。
    • 网络层:IP选址及路由选择。
    • 数据链路层:分帧(数据包),确定Mac地址。
    • 物理层:真正的物理设备传输数据。

image-20260520170618180

3、TCP/IP协议

  • TCP/IP协议:传输控制/网络协议,指能够在多个不同网络间实现信息传输的协议族它是一个工业标准。TCP/IP不止包含TCP和IP,还包含FTP、SMTP、UDP等协议,只不过是这两个协议最具代表性,因此称为TCP/IP协议。
  • TCP/IP协议的规则:TCP/IP将数据通信的过程抽象成了4个层级,即常说的四层模型
  • TCP/IP协议每层职能
    • 应用层:为应用程序提供服务,如选择传输协议;格式化数据、加密解密;建立、管理和维护会话。
    • 传输层:建立、管理和维护端到端的连接。
    • 网络层:IP选址及路由选择。
    • 网络接口层:提供一条准确无误的传输线路,确定传输数据的物理媒介。
  • 重要的协议
    • 应用层:HTTP、HTTPS、FTP、DNS
    • 传输层:TCP、UDP
    • 网络层:IP

image-20260520171237476

之后我们要学习的网络通信API都是基于 TCP/IP 协议的封装,各种语言都有对应的网络通信类对 TCP/IP 协议进行了封装,我们只需要使用对应的类和方法进行网络连接、网络通信就可以完成对应的功能。

4、TCP和UDP

(1)TCP协议

TCP(Transmission Control Protocol,传输控制协议)是面向连接的协议,收发数据前必须先建立可靠连接,传输过程中数据有序、不丢包,失败会自动重传,直到成功。

  • 特点

    • 面向连接:通信双方必须先建立可靠连接。
    • 一对一通信:一条 TCP 连接只能在两个节点之间建立。
    • 高可靠性:数据无差错、不丢失、不重复,按序到达;传输失败会自动重传。
    • 有序传输:数据按发送顺序到达接收方。
  • 三次握手(建立连接)

    • 第一次握手(客户端 → 服务器):发送 TCP 连接请求,告知服务器 “我要和你建立连接”。
    • 第二次握手(服务器 → 客户端):服务器回复确认,告知客户端 “我已准备好,可以建立连接”。
    • 第三次握手(客户端 → 服务器):客户端再次确认,告知服务器 “我收到了确认,连接正式建立”。

image-20260520173226459

  • 四次挥手(断开连接)
    • 第一次挥手(客户端 → 服务器):告知服务器 “我的数据已经发完,你如果还有数据请尽快发送”。
    • 第二次挥手(服务器 → 客户端):回复确认,告知客户端 “我已收到断开请求,请等待我发送剩余数据”。
    • 第三次挥手(服务器 → 客户端):告知客户端 “我的数据也发完了,你可以断开连接了”。
    • 第四次挥手(客户端 → 服务器):回复确认,告知服务器 “我将等待一段时间,若未收到回复就正式断开连接”。

image-20260520173255477

(2)UDP协议

UDP(User Datagram Protocol,用户数据报协议)是无连接的协议,无需提前建立连接即可直接发送数据包,提供简单的不可靠传输服务。

  • 特点
    • 无连接:通信双方无需提前建立连接,直接发送数据即可。
    • 低可靠性:数据可能丢失、乱序,且丢失后不会重传。
    • 传输效率高:无需建立连接,也没有复杂的确认和重传机制,资源消耗小、处理速度快。
    • 支持多对多通信:可实现一对一、一对多、多对一、多对多的通信场景。

(3)两者对比

  • TCP更可靠,保证数据的正确性和有序性,适合对信息准确性要求高、效率要求较低的场景,如游戏开发、文件传输、远程登录等。
  • UDP更高效,传输更快,资源消耗更少,适合对实时性要求高的场景,如直播、即时通讯、游戏开发等。
TCP UDP
连接方面 面向连接 无连接
安全方面 无差错、不丢失,不重复、按序到达 只会尽力交付,不保证可靠性
传输效率 相对较低 相对较高
连接对象 一对一 N对N

三、网络通信前置知识

(一)网络通信方案概述

1、弱联网与强联网游戏

  • 弱联网游戏:游戏不会频繁进行数据通信,客户端与服务端间每次连接只处理一次请求,处理完后就断开连接。
    • 代表:休闲三消游戏,卡牌游戏等。核心玩法由客户端完成,客户端处理完后只需告诉服务端一个结果,服务器验证即可。
  • 强联网游戏:游戏会频繁与服务器进行通信,会一直和服务器保持连接状态,不停和服务器间交换数据。
    • 代表:MMORPG、MOBA游戏等。这些游戏核心逻辑由服务端处理,客户端和服务端间不停的在同步信息。

2、长连接与短连接游戏

  • 短连接游戏:需要传输数据时建立连接,传输数据,获得响应,断开连接。
    • 特点:需要通信时再连接,通信完断开连接。
    • 通信方式:HTTP超文本协议、HTTPS安全超文本协议。
  • 长连接游戏:不管是否需要传输数据,客户端与服务器一直保持连接,除非一方主动断开连接,或者意外情况。
    • 特点:连接一直建立,可以实时传输数据
    • 通信方式:TCP传输控制协议、UDP用户数据报协议。

3、Socket

网络套接字是对网络中不同主机上应用程序间双向通信的端点的抽象。Socket屏蔽了各个协议的通信细节,提供了tcp/ip协议的抽象,对外提供了一套网络通信接口

通信过程中,客户端和服务端通过套接字发送和接收数据,通信完毕后就会断开连接,套接字也会关闭。

  • 主要用于长连接游戏(强联网游戏)

4、HTTP/HTTPS

(安全的)超文本传输协议,是一个简单的请求-响应协议,运行在TCP协议之上,指定了客户端发送给服务端什么样的信息以及得到什么样的响应。

  • 主要用于短连接游戏(弱联网游戏),也可以用于资源下载

5、FTP

文件传输协议是用于在网络上进行文件传输的标准协议。它是基于TCP的传输,是面向连接的,为文件传输提供可靠保证。

  • 主要用于进行网络资源的上传和下载

(二)IP地址和端口类

1、IPAddress类

该类提供了对IP地址的封装和操作相关功能。

  • 127.0.0.1代表本机地址。
// 初始化
byte[] ipAddress = new byte[] { 192, 168, 79, 1 }; // IP地址
IPAddress ip = new IPAddress(ipAddress); // 方式一:byte数组
IPAddress ip2 = new IPAddress(0xC0A84F01); // 方式二:long长整型,即byte数组对应的long数值,不推荐使用
IPAddress ip3 = IPAddress.Parse("192.168.79.1"); // 方式三:字符串,推荐使用

// 一些属性
IPAddress.IPv6Any; // 表示IPv6的通配地址,值为 ::

2、IPEndPoint

该类表示网络通信中的端点(IP地址和端口号的组合)。

// 初始化
IPEndPoint ipPoint = new IPEndPoint(0xC0A84F01, 8080); // 方式一:long长整型,不推荐使用
IPEndPoint ipPoint2 = new IPEndPoint(IPAddress.Parse("192.168.79.1"), 8080); // 方式二:IPAddress对象,推荐使用

(三)域名解析

因为IP地址记忆相对困难,因此会采用域名来代替IP地址来标识站点地址。而域名解析就是将域名解析成IP的过程。域名解析工作由DNS服务器完成,**域名系统(DNS)**是一个将域名和IP地址相互映射的一个分布式数据库。

1、IpHostEntry

该类表示 DNS 主机相关的信息,可以通过该对象获取IP地址、主机名等等信息。一般作为返回值获取,不会自己声明。

ipHostEntry.AddressList; // 获取关联IP
ipHostEntry.Aliases; // 获取主机别名列表
ipHostEntry.HostName; // 获取DNS名称

2、Dns

一个静态类,可以使用它来根据域名获取IP地址。

  • 获取主机名
Dns.GetHostName();
  • 同步获取指定域名的IP信息(IpHostEntry)
IPHostEntry entry = Dns.GetHostEntry("www.baidu.com");
  • 异步获取指定域名的IP信息
async void GetHostEntry()
{
    Task<IPHostEntry> task = Dns.GetHostEntryAsync("www.baidu.com");
    await task; // 等待网路通信获取远程主机信息完成
    IPHostEntry entry = task.Result; // 获取
}

(四)字符编码

字符编码(Character Encoding)也称字集码,是把字符集中的字符,编码为指定集合中某一对象,以便文本在计算机中存储或通过网络进行传递。

1、编码规则

  • 每个国家针对自己国家语言制定的编码规则:**ASCII码(美国)、GB2312编码(中国)、Shift_JIS编码(日本)、Euc-kr(韩国)**等等。

  • 世界通用的编码规则Unicode编码,比如UTF-8、UTF-16、UTF-32等等。

2、乱码

由于编码格式的存读不统一就会造成乱码的出现。因为不同的编码规则,字符和数值的映射关系是不同的。

3、一些重要编码

  • ASCII编码:只包含128个字符和二进制数值的对应关系
  • Unicode编码:包含世界上所有符号和二进制数值的对应关系,但只是一个符号集,并没有规定这个二进制数值应该如何存储。
  • UTF-8编码:是Unicode的实现方式之一,它是基于Unicode符号集的变长编码规则,根据实际情况使用1、2、3、4个字节来存储字符。

(五)序列化与反序列化

1、概述

网络通信中,我们需要把传递的类对象信息序列化为二进制数据,远端设备获取后,再反序列化二进制数据为类对象。

  • 序列化:将类对象信息转换为可保存或可传输格式的过程。
  • 反序列化:将保存或传输过来的数据转换为类对象的过程。

这里需要掌握数据持久化二进制的知识点,这在我 Unity数据持久化C#之文件操作 文章中有提及。这里简单回顾一下:

  • BitConverter类:处理各类型和字节数组间的相互转换。
  • Encoding类:处理字符串和字节数组间的相互转换。
  • File类:用于文件操作。
  • FileStream类:以流的形式进行文件读取操作。
  • MemoryStream类:内存流对象。
  • BinaryFormatter类:二进制格式化对象。

需要注意的是,网络开发中并不会使用BinaryFormatter来序列化和反序列化,因为客户端和服务端开发的语言大多数情况下是不同的,而BinaryFormatter是按照C#的规则来的,无法兼容其他语言。

2、序列化

(1)各类型转字节数组

  • BitConverter:处理各类型和字节数组间的相互转换。
  • Encoding:处理字符串和字节数组间的相互转换。

(2)类对象转字节数组

之前我们提到过不会使用BinaryFormatter来序列化和反序列化,因此,这里需要使用其他的方法。在数据持久化那篇文章也有提到。分为如下几步:

① 明确字节数组容量:如果是其他基础类型,可以使用sizeof()获取大小;如果是字符串类型(不定长),需要先记录字符串的字节数组长度(用int表示),之后再记录真正的字符串的字节数组。

  • 举个例子,比如我们要保存如下的Player对象:
public class Player
{
    public int lev;
    public string name;
    public int atk;
    public bool gender;
}

// 类对象
Player player = new Player();
player.lev = 1;
player.name = "PMM";
player.atk = 10;
player.gender = true;
  • 那么总的字节数组容量可以这么计算:
int total = sizeof(int) + // lev的字节数组大小
            sizeof(int) + // 代表name的字节数组长度的int值
            Encoding.UTF8.GetBytes(player.name).Length + // 真正的name字符串的字节数组大小
            sizeof(int) + // atk的字节数组大小
            sizeof(bool); // gender的字节数组大小

② 声明数组容器:前面通过计算得到了字节数组容量,这里就可以进行声明。

byte[] bytes = new byte[total];

③ 填入对象的字节数组信息:可以使用CopyTo方法转存到数组容器中,其中第二个参数表示从数组容器的第几个位置开始存储,因此需要一个index变量进行记录。

int index = 0;

// lev
BitConverter.GetBytes(player.lev).CopyTo(bytes, index);
index += sizeof(int);

// name
byte[] nameBytes = Encoding.UTF8.GetBytes(player.name);
BitConverter.GetBytes(nameBytes.Length).CopyTo(bytes, index); // 先存储name字符串的字节数组长度
index += sizeof(int);
nameBytes.CopyTo(bytes, index); // 再存储真正的name字符串的字节数组
index += nameBytes.Length;

// atk
BitConverter.GetBytes(player.atk).CopyTo(bytes, index);
index += sizeof(int);

// gender
BitConverter.GetBytes(player.gender).CopyTo(bytes, index);
index += sizeof(bool);

3、反序列化

(1)字节数组转各类型

  • BitConverter:处理各类型和字节数组间的相互转换。
  • Encoding:处理字符串和字节数组间的相互转换。

(2)字节数组转类对象

和序列化相对应,步骤如下:

① 获取对应的字节数组:举例的话,这里我们直接拿序列化的bytes数组即可。

② 将字节数组按照序列化的顺序进行反序列化

Player player = new Player();
int index = 0;

// lev
player.lev = BitConverter.ToInt32(bytes, index);
index += sizeof(int);

// name
int length = BitConverter.ToInt32(bytes, index); // name的字节数组长度
index += sizeof(int);
player.name = Encoding.UTF8.GetString(bytes, index, length); // 获取真正的name
index += length;

// atk
player.atk = BitConverter.ToInt32(bytes, index);
index += sizeof(int);

// gender
player.gender = BitConverter.ToBoolean(bytes, index);
index += sizeof(bool);

(六)序列化和反序列化实践

之前我们学习了如何序列化和反序列化,我们很自然的想到用面向对象的思想对其进行封装。

这里提出封装的一个思路,后面也会用到,就是将需要网络传输的数据都继承BaseData这个抽象类,BaseData负责封装一系列序列化和反序列化的方法,子类只需简单实现即可。

1、BaseData代码

  • 抽象方法GetBytesLength获取该类对象字节数组长度、Writing序列化方法、Reading反序列化方法。
  • Write方法:负责将数据写入字节数组,有基础类型的WriteXXX、字符串类型的WriteString、以及自定义类的WriteData
  • Read方法:负责从字节数组解析数据,有基础类型的ReadXXX、字符串类型的ReadString、以及自定义类的ReadData
public abstract class BaseData
{
    /// <summary>
    /// 获取字节数组容量
    /// </summary>
    /// <returns>字节数组容量</returns>
    public abstract int GetBytesLength();

    /// <summary>
    /// 序列化方法
    /// </summary>
    /// <returns>序列化后的字节数组</returns>
    public abstract byte[] Writing();

    /// <summary>
    /// 反序列化方法
    /// </summary>
    /// <param name="bytes">字节数组</param>
    /// <param name="beginIndex">字节数组开始读取的索引</param>
    /// <returns>读取的字节数</returns>
    public abstract int Reading(byte[] bytes, int beginIndex = 0);

    public void WriteShort(byte[] bytes, short value, ref int index)
    {
        BitConverter.GetBytes(value).CopyTo(bytes, index);
        index += sizeof(short);
    }

    public void WriteInt(byte[] bytes, int value, ref int index)
    {
        BitConverter.GetBytes(value).CopyTo(bytes, index);
        index += sizeof(int);
    }

    public void WriteBool(byte[] bytes, bool value, ref int index)
    {
        BitConverter.GetBytes(value).CopyTo(bytes, index);
        index += sizeof(bool);
    }

    public void WriteLong(byte[] bytes, long value, ref int index)
    {
        BitConverter.GetBytes(value).CopyTo(bytes, index);
        index += sizeof(long);
    }

    public void WriteFloat(byte[] bytes, float value, ref int index)
    {
        BitConverter.GetBytes(value).CopyTo(bytes, index);
        index += sizeof(float);
    }

    public void WriteByte(byte[] bytes, byte value, ref int index)
    {
        bytes[index] = value;
        index += sizeof(byte);
    }

    public void WriteString(byte[] bytes, string value, ref int index)
    {
        byte[] stringBytes = Encoding.UTF8.GetBytes(value);
        WriteInt(bytes, stringBytes.Length, ref index); // 先写入字符串的字节数组长度
        stringBytes.CopyTo(bytes, index); // 再写入真正的字符串的字节数组
        index += stringBytes.Length;
    }

    public void WriteData(byte[] bytes, BaseData data, ref int index)
    {
        data.Writing().CopyTo(bytes, index);
        index += data.GetBytesLength();
    }

    public short ReadShort(byte[] bytes, ref int index)
    {
        short value = BitConverter.ToInt16(bytes, index);
        index += sizeof(short);
        return value;
    }

    public int ReadInt(byte[] bytes, ref int index)
    {
        int value = BitConverter.ToInt32(bytes, index);
        index += sizeof(int);
        return value;
    }

    public bool ReadBool(byte[] bytes, ref int index)
    {
        bool value = BitConverter.ToBoolean(bytes, index);
        index += sizeof(bool);
        return value;
    }

    public long ReadLong(byte[] bytes, ref int index)
    {
        long value = BitConverter.ToInt64(bytes, index);
        index += sizeof(long);
        return value;
    }

    public float ReadFloat(byte[] bytes, ref int index)
    {
        float value = BitConverter.ToSingle(bytes, index);
        index += sizeof(float);
        return value;
    }

    public byte ReadByte(byte[] bytes, ref int index)
    {
        byte value = bytes[index];
        index += sizeof(byte);
        return value;
    }

    public string ReadString(byte[] bytes, ref int index)
    {
        int length = ReadInt(bytes, ref index); // 先读取字符串的字节数组长度
        string value = Encoding.UTF8.GetString(bytes, index, length);
        index += length;
        return value;
    }

    public T ReadData<T>(byte[] bytes, ref int index) where T : BaseData, new()
    {
        T value = new T();
        index += value.Reading(bytes, index);
        return value;
    }

    //TODO:列表、字典等复杂数据类型的读写方法
    //TODO:也可以使用泛型反射等知识进行封装
}

2、使用

TestData为例,继承BaseData并实现相关的方法。其中这里的PlayerData也是继承BaseData的类,这样才可以通过调用它的ReadingWriting方法来序列化和反序列化。

public class TestData : BaseData
{
    public short lev;
    public PlayerData p;
    public int hp;
    public string name;
    public bool sex;

    public override int GetBytesLength()
    {
        return sizeof(short) + 
            p.GetBytesLength() + 
            sizeof(int) + 
            sizeof(int) + Encoding.UTF8.GetBytes(name).Length +
            sizeof(bool);
    }

    public override int Reading(byte[] bytes, int beginIndex = 0)
    {
        int index = beginIndex;
        lev = ReadShort(bytes, ref index);
        p = ReadData<PlayerData>(bytes, ref index);
        hp = ReadInt(bytes, ref index);
        name = ReadString(bytes, ref index);
        sex = ReadBool(bytes, ref index);
        return index - beginIndex;
    }

    public override byte[] Writing()
    {
        int index = 0;
        byte[] bytes = new byte[GetBytesLength()];
        WriteShort(bytes, lev, ref index);
        WriteData(bytes, p, ref index);
        WriteInt(bytes, hp, ref index);
        WriteString(bytes, name, ref index);
        WriteBool(bytes, sex, ref index);
        return bytes;
    }
}