本篇文章介绍了游戏开发中一些比较基础的知识,内容涉及到了一些计算机网络、计算机组成原理、设计模式、操作系统的内容,内容覆盖面广,但是其实并不是很深入,明白原理和概念即可。文章作于面试前夕,复习的时候顺便记录一下学习的足迹。

记得第一次面试的时候, 面试的岗位是Unity开发, 面试官第一个问题就是: 你都用过什么设计模式,我一听就觉得,坏了,这个面试官喜欢考八股文,那时候还没看过设计模式,听起来就高大上啊,毫无悬念的不会。即便是会,感觉面试官只喜欢问八股文的话,我也会很抗拒。

后来我去看了一点设计模式,看过之后忍不住感叹一句:就这?我得到了一个观点:设计模式这种东西,既有用也没用。虽然后面写到那我们再说。不过抛开这些来说,一个人真正的能力是基于他的基础知识的,有了基础之后才会知其然知其所以然,只有当你知道这些东西之后,你才有资格评判它们

计算机网络

网络层次划分

在计算机网络中,网络层次划分通常指的是OSI模型(开放式系统互联模型)或TCP/IP模型中的网络层次结构。 现代互联网大多使用更简单的 TCP/IP 模型。但是OSI七层模型为网络的标准层次划分,在介绍TCP/IP模型之前还是先了解一下OSI吧。

OSI模型

名称职责信息格式设备或协议
7应用层人机交互层,应用可以访问网络服务MessageSMTP
6表示层确保数据为可用格式,数据加密等MessageJPEG, MPEG, GIF
5会话层维护连接、控制端口和会话MessageGateway
4运输层根据协议传输数据(TCP&UDP)SegmentFirewall
3网络层决定数据传送的物理路径PacketRouter
2数据链路层定义了网络上数据的格式FrameSwitch, Bridge
1物理层在物理设备间传输生数据流BitsHub, Repeater, Modem, Cables

TCP/IP

OSI 第 5、6、7 层在 TCP/IP 中合并为一个应用层。OSI 第 1 层、第 2 层在 TCP/IP 中合并为一个网络访问层,但 TCP/IP 不负责排序和确认功能,而是将这些功能留给底层传输层。

OSI和TCP/IP的结构

名称职责信息格式
4应用层网络应用程序和网络通信的接口Message
3传输层传输层提供了端到端的数据传输服务Segment
2网络层数据包的路由和转发数据报
1链路层通过物理介质传输数据的规范Frame

TCP/IP协议和UDP协议

重要性不说了。 TCP(Transmission Control Protocol)和UDP(User Datagram Protocol)是计算机网络中两种常用的传输层协议。

TCP是一种面向连接的可靠传输协议。在使用TCP进行通信时,发送方和接收方需要先建立一个连接,然后通过这个连接进行数据的传输。TCP通过序列号和确认机制来保证数据的可靠性,确保数据按照正确的顺序到达目标地点。如果发生数据丢失或损坏,TCP会自动进行重传,直到数据被正确接收。TCP还可以进行拥塞控制,根据网络的情况动态调整数据的发送速率,以避免网络拥塞。

UDP是一种无连接的不可靠传输协议。在使用UDP进行通信时,发送方直接将数据报发送给接收方,而不需要建立连接。UDP不提供数据的可靠性保证和重传机制,因此在传输过程中可能会出现数据丢失或乱序的情况。UDP的优点是传输效率高,适用于对实时性要求较高但对数据可靠性要求不高的应用场景,例如音视频传输和在线游戏中的实时操作

TCP/IP的三次握手

TCP/IP的三次握手

步骤1 (SYN):第一步,客户端要与服务器建立连接,因此它发送一个带有SYN(同步序列号)的报文段,告知服务器客户端可能开始通信以及以什么顺序开始通信分段开始的编号

步骤 2(SYN + ACK):服务器使用设置的 SYN-ACK 信号位来响应客户端请求。确认 (ACK) 表示对收到的数据段的响应,SYN 表示可能以什么序列号开始数据段

步骤 3 (ACK):在最后一部分中,客户端确认服务器的响应,并且双方建立可靠的连接,并通过该连接开始实际的数据传输

其实原理异常简单,我来叙述一遍通俗版: A想和B互换书籍来看,但是它们之间的快递不是很给力,所以提前约法三章,先尝试交换三次的封面以确保后续的交换不出差错。

  1. A:你好啊B,我想和你 交换(SYN) 书籍来看, 我先把《哈利波特》的 第100章节(seq=100) 的封面寄给你了。
  2. B:OK,我可以给你看我的《名侦探柯南》第200章节(seq=200),我已经 收到(ACK) 你的《哈利波特》的 第100章节(seq=100) 的封面了,我期待《哈利波特》主体内容(ack=seq+1=100+1),让我们 交换(SYN)
  3. A: 好的,给你《哈利波特》主体内容(seq=101), 我也 收到(ACK) 你的《名侦探柯南》第200章节(seq=200), 我也期待你的《哈利波特》主体内容(ack=seq+1=200+1)

然后他们就完成连接,后面开始传送数据了。其实理解上并不复杂,只是数据报的数字和指令看起来比较绕而已。如果你还是记不清这些字母,知道它们的英语之后,就会自然而然地理解(请区别缩写和小写):

SYN (Synchronize sequence numbers) 同步序列号 ACK (Acknowledgment field significant) 确认确认号栏位为有效字段 ack-nowledgment number确认号 seq-uence number 序列号

为什么不两次握手的原因也很简单,是为了防止网络超时或者波动导致有可能在建立连接之后收到了以前最开始发的指令。

举个例子:A发了一个SYN_0请求连接B,超时了,然后发送了一个SYN_1,后面所有的过程都顺利进行了,AB连接开始传送数据了。然后这时候最一开始那个SYN_0在网络波动中幸存了下来,传送到了B,这时候B会认为A又重新发起了一个连接请求。所以需要第三次握手。

无论是所有的一切的核心理念就是:要在不可靠的信道里面,创建一个可靠的连接

TCP/IP的四次挥手

有了三次握手的知识,其实四次挥手的理解也变得简单了:

数据传输完毕后,双方都可释放连接。最开始的时候,客户端和服务器都是处于ESTABLISHED状态,然后客户端主动关闭,服务器被动关闭。

  1. 第一次挥手(FIN):主动关闭方发送一个FIN(Finish)报文段给被动关闭方,表示主动关闭方不会再发送数据。
  2. 第二次挥手(ACK):被动关闭方收到FIN报文段后,发送一个确认报文段(ACK)作为响应,表示已收到关闭请求。
  3. 第三次挥手(FIN):被动关闭方发送一个FIN报文段给主动关闭方,表示被动关闭方也不会再发送数据。
  4. 第四次挥手(ACK):主动关闭方收到FIN报文段后,发送一个确认报文段(ACK)作为响应,表示已收到关闭请求

需要注意的是,四次挥手过程中的ACK确认报文段不一定是立即发送的,可以与其他数据一起发送,也可以延迟发送。这取决于具体的实现和网络条件。

这里也会有序列号(seq)的参与,限于篇幅就不展开写了,网上的讲解又多又全,远超我这个菜鸡。

为什么建立连接是三次握手,关闭连接确是四次挥手呢?

在建立连接时,发起方需要向服务方发送请求,并等待服务方的确认,这两个步骤可以合并为一次握手。 而在关闭连接时,由于双方都可能还有未发送完的数据,因此需要进行两次的关闭操作,分别表示发起方和服务方不再发送数据,最后进行一次确认,可以理解是对称的。

UDP

UDP提供不可靠的服务并且是无连接的。但是可能产生丢包,所以适用于对实时性要求较高但是对少量丢包并没有太大要求的场景。比如:语音通话,视频直播等。udp还有一个非常重要的应用场景就是隧道网络,VPN就源于此。 主要特点:

  • UDP 是无连接的,即发送数据之前不需要建立连接(发送数据结束时也没有连接可释放),减少了开销和发送数据之前的时延
  • UDP 使用尽最大努力交付,即不保证可靠交付,主机不需要维持复杂的连接状态表
  • UDP 是面向报文的,发送方的 UDP 对应用程序交下来的报文,在添加首部后就向下交付 IP 层。UDP 对应用层交下来的报文,既不合并,也不拆分,而是保留这些报文的边界
  • UDP 没有拥塞控制,网络出现的拥塞不会使源主机的发送速率降低。这对某些实时应用是很重要的
  • UDP 的首部开销小,只有8个字节,比 TCP 的20个字节的首部要短

我看网上说有的面试官喜欢问如何改进UDP让它变得可靠。你可以回答:用TCP协议呗。简洁利落的回答证明了自己的专业又为面试官节省了时间,何乐而不为呢?XD

有一些开源的方案提供了改进的思路(RUDP、RTP、UDT等)

  • 应用层协议设计:可以在应用层上设计一种自定义的协议,通过添加确认、重传和序列号等机制来实现可靠性传输。这样的协议可以根据具体的应用需求进行定制,但需要在应用程序中实现相应的逻辑。
  • 超时与重传:在发送端引入超时和重传机制。发送端在发送数据后启动一个定时器,如果在一定时间内没有收到对应的确认,就认为数据丢失,触发重传机制重新发送数据。接收端可以通过确认消息来指示发送端需要重传的数据。
  • 确认机制:接收端可以向发送端发送确认消息,表示已成功收到数据。发送端在收到确认消息后才认为数据传输成功,否则会触发重传机制。可以使用累计确认(cumulative acknowledgment)来减少确认消息的数量。
  • 序列号:在数据包中添加序列号,接收端可以根据序列号对数据进行排序和重组。序列号还可以用于检测重复数据包和丢失数据包。 流量控制:通过控制发送速率和接收端缓冲区大小,可以避免发送过多的数据导致接收端无法及时处理而丢失数据。

尽管这些改进方法可以提高UDP的可靠性,但它们也会增加传输的延迟和开销。对于对可靠性要求较高的应用场景,考虑使用TCP等已经提供可靠性传输保证的协议可能更为合适。
md,说到底还是TCP那一套。不要妄想自己有实力颠覆网络通信协议,当然如果你能提出并实现KCP那样的网络协议,那你没必要拘泥于这些Useless的八股文,别花时间做一些没用的事情。

设计模式与吐槽

单例模式

用于确保一个类只有一个实例,并提供一个全局访问点来访问该实例。在单例模式中,类的实例化过程受到严格控制,使得系统中只有一个实例存在,并且该实例可以被其他对象共享和访问。

应用场景: 当一个类只需要一个实例来协调操作时,例如线程池、缓存、日志记录器等。
当对资源访问需要控制时,例如数据库连接池。
当希望通过统一的接口管理全局状态时。

实现方式: 懒汉式单例:在首次访问时创建实例。如果没有线程安全的控制,可能会导致多个实例的创建。
饿汉式单例:在类加载时就创建实例,确保了线程安全。但在某些情况下可能会导致资源浪费,因为实例在整个程序周期内都存在,即使没有被使用。
双重检查锁定(Double-Checked Locking)单例:结合了懒汉式和饿汉式的优势,在首次访问时创建实例,并通过加锁保证线程安全。
静态内部类单例:利用类的初始化锁机制实现延迟加载和线程安全。

工厂模式

它提供了一种将对象的实例化过程封装起来的方式。工厂模式通过定义一个共同的接口或基类,然后由具体的工厂类负责创建符合接口或基类定义的对象实例。

工厂模式通常在以下情况下使用:

当需要创建复杂对象时,对象的创建过程涉及到多个步骤或依赖关系复杂时,可以使用工厂模式将创建过程封装起来,降低对象的创建复杂度。
当希望通过统一的接口来创建对象,而不暴露具体实现类时,可以使用工厂模式隐藏对象的具体实现。

工厂模式的主要角色包括:

抽象工厂(Abstract Factory):定义创建对象的接口,它可以是一个接口或抽象类。
具体工厂(Concrete Factory):实现抽象工厂中定义的创建对象的方法,返回具体的对象实例。
抽象产品(Abstract Product):定义工厂创建的对象的接口,可以是一个接口或抽象类。
具体产品(Concrete Product):实现抽象产品接口的具体对象。

工厂模式可以根据具体的需求进行扩展和变化,常见的工厂模式包括简单工厂模式、工厂方法模式和抽象工厂模式。
简单工厂模式通过一个工厂类来创建所有的产品对象,根据传入的参数或条件来确定创建哪种具体产品。
工厂方法模式将对象的创建延迟到具体的工厂类中,每个具体工厂类负责创建一个具体产品。
抽象工厂模式提供了一种创建一系列相关或相互依赖对象的接口,每个具体工厂类负责创建一族产品

观察者模式

用于在对象之间建立一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都会得到通知并自动更新。

观察者模式通常在以下情况下使用:

当一个对象的改变需要通知其他对象,并且不需要知道具体通知哪些对象时,可以使用观察者模式。
当一个对象的改变需要同时改变其他对象的状态,但又希望对象之间解耦,以避免紧密耦合的代码。

观察者模式的主要角色包括:

  • 主题(Subject):也称为被观察者或可观察对象,它维护一组观察者对象,并在状态改变时通知观察者。
  • 观察者(Observer):定义一个更新接口,用于接收主题的通知并进行相应的更新操作。
  • 具体主题(Concrete Subject):实现主题接口,维护观察者对象的集合,并在状态改变时通知观察者。
  • 具体观察者(Concrete Observer):实现观察者接口,存储与主题相关的状态,并在接收到通知时进行更新操作。

察者模式特别有效。通过这种方式,观察者模式使得主题对象不需要关心它有哪些观察者,只需通知观察者它的状态变化,观察者对象再根据需要做出响应。这样不仅减少了系统间的耦合度,还使得系统更容易扩展和维护。

观察者模式的优缺点:

  • 优点:

    • 松耦合:主题和观察者之间没有直接的依赖关系,它们只通过抽象的接口进行交互,使得系统更加灵活。
    • 动态添加观察者:可以在运行时向主题中动态添加或移除观察者,无需修改主题类的代码。
    • 支持广播通信:主题可以将变化通知给所有已注册的观察者。
  • 缺点:

    • 通知的顺序问题:如果观察者之间有顺序要求(例如,某些观察者必须先于其他观察者更新),则可能需要额外的机制来控制通知顺序。
    • 可能造成性能问题:如果有大量的观察者对象,每次主题状态变化时需要通知所有观察者,可能会引起性能问题,尤其是在观察者数量较多的情况下。
    • 可能导致过多的依赖:如果观察者的数量过多,且它们之间的更新过于频繁,可能导致系统的复杂性增加,难以维护。

观察者模式的使用场景:

  • UI界面:例如,图形用户界面(GUI)中的控件,比如按钮、文本框等,可能作为主题(Subject)对象,而显示这些控件的窗口或面板则是观察者对象。当控件的状态变化时(如按钮被点击、文本框内容更改),通知所有相关的观察者更新UI。
  • 事件驱动的应用程序:例如,监听用户输入、传感器数据变化等,状态变化通知所有相关处理模块。
  • MVC架构中的模型:在MVC(Model-View-Controller)模式中,模型通常作为主题,而视图是观察者,模型的数据变化会通知视图进行更新。
  • 消息系统:例如,推送通知系统,主题可以是消息源,而多个接收者可以是观察者。
    通过使用观察者模式,系统可以更好地支持扩展、维护和解耦,让对象之间的交互更加清晰和灵活。

吐槽

其实如果你写代码时能注意一些解耦和合理代码结构的事情,那么设计模式其实对你来说用处不大。有时候你的代码会无意间遵循设计模式的金科玉律。接下来我举例证明设计模式是没啥用处的。

我使用Unity3D比较多,拿单例模式来说吧,一般是不是单例模式会用在Unity的一些管理类或者是某些唯一的东西上面,因为我们希望它们是唯一的,并且可以全局的管理一些事情。

单例模式有两个特性:1. 单例必须仅限于单个实例 2. 实例需要是全局可访问的。

很合理,而且在简单项目中非常好用。用AudioManager举例,它用于参与全局控制整个游戏的音频逻辑。单例模式允许你不需要在Unity的Inspector里面指定引用,或者是用代码去寻找这个类,你直接访问这种全局实例即可,你可以在任何地方随意访问它们。

当我们的代码量很大时,单例模式省去了很多类的依赖关系,很不巧的是你需要到处使用它们来传递数据,一开始这种mess并不明显,但是当你有一些团队成员,或是你自己需要处理一段时间之前自己写的代码时,你会发现这些代码慢慢滚雪球变成了屎山。

并且,在Unity里面,你想使用单例模式,遇到的问题会更多,下面是最普通的一个单例模式的实现:

public class GameManager : MonoBehaviour
{
    private static GameManager instance;
    private void Awake()
    {
        if (instance != null)
        {
            Destroy(gameObject);
            return;
        }
        instance = this;
        DontDestroyOnLoad(gameObject);
    }
    public static GameManager GetInstance()
    {
        return instance;
    }
    // 其他游戏逻辑代码...
}

在Unity中MonoBehaviour隐藏了构造函数,并使得Instantiate可以实例化它们。
大多时候我们都会继承MonoBehaviour,那么我们就需要在Awake中检查实例是不是已经存在了,如果基于MonoBehaviour在创建时存在另一个实例,它必须要Destroy自己。

当新场景加载时,DontDestroyOnLoad使得原来的实例保持存在,新场景中如果存在一个实例的话,根据它们在Hierarchy,有可能一个错误的实例被Destroy了。其实局限性有很多。

这么来看,设计模式好像是在多此一举。要是真的想用类似的东西,static静态类与静态成员反而是最简洁的答案。

而类似于工厂模式之类的概念,我只能说,在没有学设计模式之前,我也是那么做的。比如Unity的Pooling就是一个最典型的例子。 又如观察者模式,Unity或者是C#的事件系统不就是这种思路吗,解耦的常规操作而已。往往我们在优化代码结构时,稀里糊涂就遵循了某些设计模式。

而更多的情况,设计模式只是提供了指导思路,有时候并不能纯粹地使用这些模式,而是需要一些变形或是结合,那么问题来了:经过变化的设计模式,它还是之前的那个设计模式了吗?有时候不能太教条,设计模式是指导思路,不是一个规范

文章正在加载中… …
Loading Posts… …

Ref

作者博客:YMX’s Site
作者B站视频:CyberStreamer