乌啦呀哈呀哈乌啦!

欢迎光临,这里是喵pass的个人博客,希望有能帮到你的地方

0%

进程:一个程序一个进程。
进程是计算机中正在运行的程序实例的抽象,它是资源分配的基本单位。每个进程都有自己独立的地址空间,包括代码段、数据段、堆栈段等,这意味着不同进程之间的内存资源是相互隔离的,不会相互干扰。例如,当你同时打开一个文本编辑器和一个浏览器,它们就分别是两个独立的进程,文本编辑器进程无法直接访问浏览器进程所占用的内存区域。

线程:共享堆资源,利用多核处理器可以实现并行处理。
线程是进程内部的一条执行路径,是 CPU 调度和执行的基本单位。一个进程可以包含多个线程,这些线程共享进程的地址空间(包括代码段、数据段、堆等)以及系统资源(如打开的文件、网络连接等),但每个线程都有自己独立的栈空间用于保存局部变量、函数调用的上下文等信息。比如在一个文字处理软件进程中,可能有一个线程负责接收用户的键盘输入,另一个线程负责实时进行拼写检查,它们都在同一个进程的环境下协同工作。

协程:用户态,单线程中实现并发,程序控制执行时机,I/O操作不用CPU。
协程是一种比线程更加轻量级的存在,它可以看作是用户态的轻量级线程,是在单线程内实现的并发机制。协程不像线程那样由操作系统内核进行调度,而是由程序员自行控制或者由编程语言提供的特定库来进行调度切换。例如在 Python 语言中,通过 asyncio 库就能方便地定义和调度协程。协程在执行过程中可以主动暂停(yield),将执行权交给其他协程,之后又可以在合适的时候恢复执行。


调度方式

  • 进程:由操作系统内核进行调度,切换时需要保存和恢复所有的CPU状态和内存空间。
  • 线程:同样由操作系统进行调度,但由于线程共享进程的内存空间,切换时只需保存和恢复CPU寄存器和栈指针。
  • 协程:由程序员在用户态显式调度,无需操作系统参与,切换时只需保存和恢复少量上下文信息。

资源消耗

  • 进程:创建和销毁进程需要较多的资源,尤其是内存和CPU时间。
  • 线程:创建和销毁线程比进程轻量,但仍然需要一定的资源。
  • 协程:由于在用户态执行,创建和销毁协程非常轻量,对系统资源的消耗最小。

隔离性

  • 进程:完全隔离,进程之间的内存空间独立,安全性高。
  • 线程:共享进程的内存空间,不同线程可以直接访问共享数据,隔离性差。
  • 协程:在同一线程内执行,协程之间共享内存空间。

通信方式

进程间通信(IPC)是指不同进程之间交换数据或信号的机制,常见的 IPC 方法包括:

  • 管道(Pipe):用于单向或双向数据流,常用于父子进程之间的通信。
  • 消息队列(Message Queue):允许进程通过消息传递进行通信,消息按照一定的顺序排队。
  • 共享内存(Shared Memory):多个进程共享同一段内存,速度快,但需要同步机制来避免竞争条件。
  • 信号量(Semaphore):用于进程间的同步,控制多个进程对共享资源的访问。
  • 信号(Signal):用于异步通知进程某个事件的发生。
  • 套接字(Socket):通常用于网络通信,也可以用于同一主机上进程之间的通信。

线程间通信由于共享同一进程的内存空间(堆资源),主要依赖同步机制来管理共享数据的访问:

  • 共享变量:线程可以直接通过共享变量进行通信,但需要同步机制来避免竞争条件。
  • 互斥锁(Mutex):用于保护共享资源,确保同一时刻只有一个线程可以访问。
  • 条件变量(Condition Variable):用于线程之间的等待和通知机制,线程可以等待某个条件的变化。
  • 信号量(Semaphore):用于控制线程对共享资源的访问,特别适用于限制资源数量的场景。
  • 事件(Event):用于线程间的信号传递,线程可以等待事件的发生。

协程之间的通信通常是通过共享数据结构或消息传递机制来实现的,具体方法包括:

  • 共享变量:协程在同一线程内,可以直接访问共享变量,但仍需小心数据一致性问题。
  • 消息传递:许多编程语言提供了内置的消息传递机制,如通道(Channel)或队列(Queue),用于协程之间的通信。
  • 异步回调:协程常用于异步编程,回调机制可以用于协程之间的通信。
  • 未来(Future)和承诺(Promise):用于在协程之间传递异步计算的结果。

适用场景

  • 进程:适用于需要高隔离性和安全性、任务相对独立的场景,如多用户系统、独立的服务模块。进程间通信通常较复杂,需要权衡性能和隔离性。
  • 线程:适用于需要高并发和共享资源的场景,如Web服务器、数据库系统。需要关注线程安全和同步问题,以避免死锁和竞争条件。
  • 协程:适用于大规模并发、IO密集型操作,尤其是在异步编程中,如异步网络请求、实时数据处理。协程的轻量级特性使其在处理大量并发操作时非常高效,但协程的调度和错误处理需要仔细设计。

优缺点对比

进程是资源分配最小单位,独立稳定但开销大;线程是系统调度最小单位(操作系统分割成的时间片),共享资源但安全复杂;协程是用户级的轻量并发编程,切换效率极高,适合高并发I/O,但统一线程下的多个协程不能利用多核并行处理。

进程的优点

  • 隔离性和稳定性:每个进程拥有独立的地址空间,这意味着它们之间的内存是隔离的。这种隔离性提高了系统的稳定性,因为一个进程的崩溃不会直接影响其他进程。
  • 安全性:由于进程之间的资源是隔离的,这为应用程序提供了更高的安全性,防止一个进程无意中修改另一个进程的数据。
  • 容错性:如果某个进程失败,不会影响其他进程的运行。操作系统可以通过重启进程来恢复服务。

进程的缺点

  • 资源消耗大:进程的创建和销毁需要分配和回收大量的资源,包括内存和文件句柄。进程的上下文切换也比线程开销更大,因为需要切换独立的地址空间。
  • 通信复杂:由于进程之间的内存是隔离的,进程间通信(IPC)需要使用复杂的机制,如管道、消息队列、共享内存等,这增加了编程的复杂性。
  • 启动速度慢:启动一个新进程比启动一个新线程需要更多的时间,因为需要为进程分配独立的资源。

线程的优点

  • 轻量级:线程是比进程更轻量级的执行单位,创建和销毁线程的开销相对较小。线程的上下文切换比进程更快,因为线程共享进程的内存空间。
  • 共享资源:线程可以共享进程的内存和资源,这使得线程之间的数据交换更加直接和高效。
  • 并发性:线程可以在多核处理器上实现真正的并行执行(多对多模型),充分利用多核系统的优势,提高程序的执行效率。

线程的缺点

  • 安全性和稳定性:由于线程共享进程的地址空间,一个线程的错误(如非法内存访问)可能会影响整个进程的稳定性。
  • 同步复杂性:线程之间共享数据,需要使用同步机制(如互斥锁、条件变量)来避免竞争条件和死锁,这增加了编程的复杂性。
  • 调试困难:多线程程序的调试比单线程程序复杂得多,因为线程的调度和切换往往是不确定的,可能导致难以重现的错误。

协程的优点

  • 极低的切换开销:协程在用户态执行,切换时只需保存和恢复少量上下文信息,比线程和进程切换都要快得多。
  • 简单的并发模型:协程通过显式调用进行调度,程序员可以精确控制协程的执行顺序,避免了线程调度带来的不确定性。
  • 适合IO密集型任务:协程非常适合用于处理大量IO操作,因为它们可以在等待IO操作时主动让出控制权,从而提高系统的整体吞吐量。
  • 资源消耗小:协程是非常轻量级的,创建和销毁协程的开销极低。

协程的缺点

  • 不支持多核并行:大多数协程实现是在单线程上运行的,因此无法利用多核处理器进行并行计算。
  • 调度责任在程序员:协程的调度由程序员显式控制,这虽然提供了灵活性,但也意味着程序员需要负责协程的正确调度和资源管理。
  • 错误传播:在协程中,错误的传播和处理需要仔细设计,否则可能导致系统的不稳定。

超线程

  • 超线程技术通过硬件指令将单个物理核心模拟为多个逻辑核心
  • 硬件层面的功能,需要CPU支持
  • 操作系统无法直接区分物理核心与逻辑核心的差异,只能通过统计逻辑核心数来管理任务分配

MVC

  • Model:纯数据容器(如:PlayerState),不包含 UI 或游戏逻辑。
  • View:负责渲染数据(如:UI 文本、生命值条),通常直接监听(观察) Model 的变化。
  • Controller:处理用户输入(如:点击按钮)并修改 Model。

特点:View 和 Model 之间仍有直接关联(View 需要知道 Model 的存在以进行监听),这使得 View 难以独立测试。

MVP

  • Model:纯数据,不包含业务逻辑,不依赖其他层。
  • View:被动视图,不直接访问 Model。它只负责将用户操作传给 Presenter,并提供更新 UI 的公共接口。
  • Presenter:核心中间层。它监听 Model 的变化,格式化数据后手动调用 View 的接口更新 UI;同时监听 View 的事件来修改 Model。

特点:View 和 Model 完全解耦。由于 View 非常“薄”,你可以轻松编写 Presenter 的单元测试。

MVC 和 MVP 的区别

MVC(模型-视图-控制器)和MVP(模型-视图-表示器)的核心区别在于View与Model的解耦程度和通信方式。
MVC中View可直接访问Model,耦合度较高;而MVP通过接口将View与Model完全隔绝,所有交互由Presenter中转,使代码更易于测试和维护。

View 与 Model 的关系

  • MVC: View 可以直接读取 Model 数据(View 依赖 Model),View 中可能包含部分业务逻辑。
  • MVP: View 与 Model 彻底解耦。View 只能通过接口与 Presenter 通信,无法感知 Model。

交互逻辑

  • MVC: Controller 接收用户请求,更新 Model,View 监听 Model 变化更新界面(有时 C 直接更新 V)。
  • MVP: Presenter 作为中介,不仅处理 UI 事件,还负责从 Model 获取数据并通过接口更新 View。View 是被动接收渲染。

测试性

  • MVC: 较难进行单元测试,因为 UI 逻辑与数据逻辑存在耦合。
  • MVP: 极易测试,Presenter 逻辑与具体 UI 实现分离,可通过 Mock 接口进行纯逻辑测试。

MVVM

MVVM 是从 WPF 等前端技术引入的,核心在于 数据绑定 (Data Binding)。

  • Model(模型):纯数据,不包含业务逻辑,不依赖其他层。
  • View(视图):UI 组件,通过数据绑定 (Data Binding)直接映射 ViewModel 的属性。
  • ViewModel(视图模型):它是 View 的抽象。它将 Model 的数据转换为 View 可以直接使用的格式(如将 float 转换为 string),并通过属性变更通知(如 OnPropertyChanged)自动驱动 View 更新。

特点:双向/单向数据绑定,View 不知道 ViewModel 的存在
优点:自动化程度最高。Model 改变时,View 会通过绑定自动更新,无需像 MVP 那样手动写代码控制。只需“订阅”数据变化,实现了极高的解耦。

Unity 中的 UI-Model 设计架构

UGUI:以 MVP 为核心的实现

UGUI 本身不提供自动化的数据绑定,因此最契合的模式是 MVP (Model-View-Presenter)。通过 Presenter 手动协调数据与界面的同步。

  • Model: 纯 C# 类或 ScriptableObject。定义 Action 或 UnityEvent,在数据变化时发出通知。
  • View: 继承 MonoBehaviour 的 UI 脚本。包含对 Text、Slider 等组件的引用,并提供简单的公共方法(如 UpdateHealthBar(int value))供外部调用。
  • Presenter: 用于连接/同步 Model 和 View 的中间层。
    • 监听 Model: 订阅 Model 的事件,当数据改变时,调用 View 的更新方法。
    • 监听 UI: 接收来自 View 的 UI 事件(如 Button.onClick),处理逻辑后修改 Model。

UI Toolkit:以 MVVM 为核心的实现

UI Toolkit(尤其是 Unity 6 以后)原生支持 数据绑定 (Data Binding),这使得实现 MVVM 变得非常自然且高效。

  • Model: 基础数据结构,C#原生类型变量。
  • View: 使用 UXML(结构)和 USS(样式)文件定义。在 UI Builder 中,你可以直接通过 Attributes 检查器 将 UI 元素的属性(如 Label 的 Text)绑定到数据源路径。
  • ViewModel: 负责“翻译”数据。
    • 实现通知: 属性通常需要实现 INotifyPropertyChanged 接口或使用特定属性标签(如 [ObservableProperty]),以便在值变化时自动触发 UI 更新。
    • 命令绑定: 使用 ICommand 或 RelayCommand 绑定按钮点击等行为,无需在脚本中手动查找 VisualElement。
  • 代码解耦: View 和 ViewModel 之间不需要显式的引用关系,完全通过绑定路径 (Binding Path) 链接。


AVL树

  1. 空二叉树是一个 AVL 树
  2. 如果 T 是一棵 AVL 树,那么其左右子树也是 AVL 树,并且 |ℎ(𝑙𝑠) −ℎ(𝑟𝑠)| ≤1,h 是其左右子树的高度
  3. 树高为 𝑂(log ⁡𝑛)

平衡因子:右子树高度 - 左子树高度


红黑树

首先,红黑树是一个二叉搜索树,它在每个节点增加了一个存储位记录节点的颜色,可以是RED,也可以是BLACK
通过任意一条从根到叶子简单路径上颜色的约束,红黑树保证最长路径不超过最短路径的二倍,因而近似平衡(最短路径就是全黑节点,最长路径就是一个红节点一个黑节点,当从根节点到叶子节点的路径上黑色节点相同时,最长路径刚好是最短路径的两倍)

红黑树满足以下特性:

  1. 节点是红色或黑色
  2. 根是黑色
  3. 叶子节点(外部节点,空节点)都是黑色,这里的叶子节点指的是最底层的空节点(外部节点),即null节点才是叶子节点,null节点的父节点在红黑树里不将其看作叶子节点
  4. 红色节点的子节点都是黑色,红色节点的父节点都是黑色
  5. 从根节点到叶子节点的所有路径上不能有 2 个连续的红色节点
  6. 从任一节点到叶子节点的所有路径都包含相同数目的黑色节点

并查集

并查集是一种用于管理元素所属集合的数据结构,实现为一个森林,其中每棵树表示一个集合,树中的节点表示对应集合中的元素

并查集支持以下两种操作:

  • 合并(Unite):合并两个元素所属集合(合并对应的树)。
  • 查询(Find):查询某个元素所属集合(查询对应的树的根节点),这可以用于判断两个元素是否属于同一集合。

路经压缩

如果查询过程中经过的每个元素都属于该集合,我们可以将其直接连到根节点以加快后续查询。

1
2
3
// assignment expression 
// size_t a; size_t b = (a = 10);
size_t dsu::find(size_t x) { return pa[x] == x ? x : pa[x] = find(pa[x]); }

优点

  1. 全局唯一实例:单例模式确保在整个应用程序中只有一个实例存在,可以提供一个全局的访问点,方便对实例的管理和调用。
  2. 节省资源:由于只有一个实例存在,可以避免重复创建实例,节省了系统资源。
  3. 避免竞态条件:在多线程环境下,使用单例模式可以避免由于竞态条件而导致的问题,如资源争夺、数据不一致等。
  4. 实现了懒加载:某些单例模式的实现方式(如懒汉式)在需要时才创建实例,实现了延迟加载,节省了内存空间。
  5. 继承monobehaviour的单例有unity生命周期函数和也可以调用协程,可以用来控制没有继承monobehaivour的脚本组件

缺点

  1. 可能引入全局状态:由于单例模式提供了全局访问点,可能会导致多个部分之间共享了同一个状态,增加了系统的耦合性。
  2. 可能造成性能瓶颈:在高并发环境下,单例模式的实现需要考虑线程安全性,可能会引入锁机制,导致性能下降。
  3. 隐藏了依赖关系:单例模式的使用会隐藏类的依赖关系,增加了代码的复杂性和理解难度。
  4. 不利于扩展和测试:单例模式一般是通过静态方法获取实例,难以进行扩展和替换,也不利于单元测试。
  5. 可能造成内存泄漏:如果实例被长时间持有而不释放,可能会造成内存泄漏,特别是在移动端或长时间运行的服务中。

实例化方法

懒汉式(Lazy Initialization):在第一次使用时创建实例。

1
2
3
4
5
6
7
8
9
10
public class Singleton {
private static Singleton instance;
private Singleton() {}
public static synchronized Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}

饿汉式(Eager Initialization):在类加载时就创建实例。

1
2
3
4
5
6
7
public class Singleton {
private static final Singleton instance = new Singleton();
private Singleton() {}
public static Singleton getInstance() {
return instance;
}
}

观察者模式

通过事件实现的观察者模式
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
using UnityEngine;
using System;

public class Publisher : MonoBehaviour
{
// 声明一个事件
public static event Action OnEventPublished;

void Update()
{
if (Input.GetKeyDown(KeyCode.Space))
{
// 发布事件
OnEventPublished?.Invoke();
}
}
}

public class Subscriber : MonoBehaviour
{
private void OnEnable()
{
// 订阅事件
Publisher.OnEventPublished += HandleEvent;
}

private void OnDisable()
{
// 取消订阅事件
Publisher.OnEventPublished -= HandleEvent;
}

// 事件处理方法
private void HandleEvent()
{
Debug.Log("Event received!");
}
}

委托

委托是个类,分为Delegate自定义委托类型,Func有返回值的委托类型,Action无返回值的委托类型

Func和Action的底层原理便是用Delegate声明一个委托类型(有返回值和无返回值),并且通过泛型参数(最多十六个)来实现自定义参数类型和参数
其中,Func委托类型的最后一个参数为返回值

委托需要先定义后使用

1
delegate void IntMethodInvoker(int x);

如上定义了一个委托InMethodInvoker,这个委托可以指向一个 int类型参数,返回值为void 的方法

Action委托 和 Func委托

Action委托引用了一个void返回类型的方法,T表示方法参数

1
2
3
4
Action
Action<in T>
Action<in t1,in t2>
Action<in t1,in t2,···,t16>

Func引用了一个带有一个返回值的方法,它可以传递0或者多到16个参数类型,和一个返回值类型

1
2
3
Func<out TResult>
Func<in t,out TResult>
Function<in t1,in t2,···,in t16,out TResult>

多播委托

前面是用的委托都只包含一个方法调用,但是委托也可以包含多个方法,这种委托叫做多播委托。使用多播委托可以按照顺序调用多个方法,多播委托只能得到调用的最后一个方法结果,一般我们把多播委托的返回值类型声明为void。
多播委托包含一个逐个调用的委托集合,如果通过委托调用的其中一个方法抛出异常,整个迭代就会停止。

使用匿名方法给委托赋值

前面使用委托都是先定义一个方法,然后把方法给委托的实例。但还有另外一种使用委托的方式,不用去定义一个方法,直接使用匿名方法(lambda expression)

1
2
3
4
5
6
7
8
9
10
Func<int,int,int> plus = delegate(int a, int b)
{
int temp = a+b;
return temp;
}
int res = plus(34,34)
Console.WriteLine(res);

//上述代码可以换成下面一行
Func<int, int, int> plus = (a, b) => { return a + b; };

事件

用event关键词修饰的字段,由观察者拥有,一种类型成员,有能力使一个类或者对象去通知其他类、对象们

事件是基于委托的,委托是事件的“底层基础”,事件是委托的“上层建筑”
委托类型定义了事件的有无返回值和参数类型,事件处理器必须和事件的有无返回值和参数类型一致,即双方都要遵守同一个约定(有无返回值和参数类型),我们把这叫做事件和事件处理器必须是匹配的

自定义事件

先声明该事件的委托类型,再声明事件

  • 在声明委托类型的时候,如果这个委托,是为了声明某个事件而准备的委托,那么这个委托的名字,就要去使用:事件名+EventHandler的格式,由于委托是一种引用类型,所以事件名首字母要大写
  • 在定义事件参数的时候,即在定义该事件的委托类型的参数的类型的时候,要遵循:类型名+EventArgs这个格式
事件声明示例
1
2
3
4
5
// 声明该事件的委托类型:事件名 + EventHandler
public delegate void OrderEventHandler(Customer customer, OrderEventArgs e);//声明一个委托类型

// 声明事件:访问修饰符 + event + 事件处理器(委托类型的实例、字段)+ 事件名称(一定要注意命名规范:On+事件名)
public event OrderEventHandler OnOrder;

微软提供了一个EventHandler委托类型

其中用来传递事件数据的类EventArgs,凡是用来传递事件数据的类,都是从这个类派生出来的
让自定义的传递事件数据的类继承EventArgs,就可以作为参数传入EventHandler委托类型了
将Object类型的变量转换为Customer类型的变量,我们可以用as操作符

1
2
3
public delegate void EventHandler(object? sender, EventArgs e);

Customer customer1 = _sender as Customer;

事件和委托的区别

  • 事件其实是委托类型字段的包装器、限制器,限制外界对委托类型字段的访问。
  • 外界只能通过“+=”和“-=”两个操作符对事件进行添加事件处理器和移除事件处理器的操作,并不能去赋值和触发事件。
  • 事件是用来阻挡非法操作的“蒙版”,它绝对不是委托字段的本身
  • 类似的情况有字段和属性,属性是字段的包装器。字段能做的,属性都能做;属性能做的,字段不一定都能做

总结:事件是用来“阉割”委托实例的,事件只能添加、删除事件处理器,不能赋值。外界只能用“+=”和“-=”去访问它,不能=,不能从外部触发事件,也就是说,事件包含了委托类型字段的所有功能,但只是对外部暴露了“+=”和“-=”操作符。

事件和委托的关系

委托类型规定了事件拥有者和事件响应者通知和接收的消息必须是同一类型的消息
约束了添加和移除事件时必须要使用与之匹配(同样类型)的事件处理器,即使用与之匹配(同样类型)的方法来处理响应这个事件


完整示例(顾客点单)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
/// <summary>
/// 事件的拥有者:顾客
/// 事件:点单
/// 事件的响应者:服务员
/// 事件处理器:计算最后金额
/// 事件订阅(+=操作符)
/// </summary>
namespace EventDemo
{
//声明一个委托类型
public delegate void OrderEventHandler(Customer customer, OrderEventArgs e);

class EventExample
{
public static Customer customer = new Customer();
public static Customer customer2 = new Customer();
public static Waiter waiter = new Waiter();
static void Main()
{
customer.OnOrder += waiter.CalculateBill;
customer2.OnOrder += waiter.CalculateBill;

//使用事件,事件只能由事件拥有者触发,不能在外部去触发
//顾客1点了超大杯摩卡15+6 = 21
customer.Order("摩卡", 15, OrderEventArgs.CoffeeSizeEnum.Venti);
//顾客1点了中杯拿铁20
customer.Order("拿铁", 20, OrderEventArgs.CoffeeSizeEnum.Tall);
//顾客2点了中杯卡布奇诺
customer2.Order("卡布奇诺", 25, OrderEventArgs.CoffeeSizeEnum.Tall);
customer.PayTheBill();
customer2.PayTheBill();

/*//如果使用委托,不使用事件,委托类型的字段可以在外部进行调用,意味着顾客2可以把自己点的东西记在顾客1的账单上
//顾客1点了超大杯摩卡15+6 = 21
OrderEventArgs e1 = new OrderEventArgs();
e1.CoffeeName = "摩卡";
e1.CoffeePrice = 15;
e1.CoffeeSize = OrderEventArgs.CoffeeSizeEnum.Venti;
customer.OnOrder(customer, e1);
//顾客1点了超大杯拿铁20+6 = 26,并记在了倒霉蛋顾客1的账单上
OrderEventArgs e2 = new OrderEventArgs();
e2.CoffeeName = "拿铁";
e2.CoffeePrice = 20;
e2.CoffeeSize = OrderEventArgs.CoffeeSizeEnum.Venti;
customer2.OnOrder(customer, e2);
customer.PayTheBill();
customer2.PayTheBill();*/


Console.Read();
}
}

public class Customer
{
//声明一个点单事件
public event OrderEventHandler OnOrder;

public float Bill { get; set; }
public void PayTheBill()
{
Console.WriteLine("I have to pay : " + Bill);
}

/// <summary>
/// 点餐(咖啡名称、价格、大小)
/// </summary>
/// <param name="coffeeName"></param>
/// <param name="coffeePrice"></param>
/// <param name="coffeeSize"></param>
public void Order(string coffeeName,float coffeePrice, OrderEventArgs.CoffeeSizeEnum coffeeSize)
{
//语法糖:如果事件不为空(因为简略声明事件时委托类型的字段被隐藏了)
if (OnOrder != null)
{
OrderEventArgs e = new OrderEventArgs();
e.CoffeeName = coffeeName;
e.CoffeePrice = coffeePrice;
e.CoffeeSize = coffeeSize;
//事件只能由事件拥有者触发:限制只能自己给自己点单
OnOrder(this, e);
}
}
}

public class Waiter
{
//计算账单金额
public void CalculateBill(Customer customer, OrderEventArgs e)
{
float finalPrice = 0;
switch (e.CoffeeSize)
{
case OrderEventArgs.CoffeeSizeEnum.Tall:
finalPrice = e.CoffeePrice; //中杯,原价
break;
case OrderEventArgs.CoffeeSizeEnum.Grand:
finalPrice = e.CoffeePrice + 3; //大杯:原价+3元
break;
case OrderEventArgs.CoffeeSizeEnum.Venti:
finalPrice = e.CoffeePrice + 6; //超大杯:原价+6元
break;
}
customer.Bill += finalPrice;
}
}

public class OrderEventArgs
{
// 咖啡是大杯、中杯还是小杯
public enum CoffeeSizeEnum { Tall,Grand,Venti}//默认为静态
public CoffeeSizeEnum CoffeeSize { get; set; }
// 咖啡价格
public float CoffeePrice { get; set; }
// 咖啡名称
public string CoffeeName { get; set; }
}
}

Credits

https://blog.csdn.net/Hotgun2222/article/details/139901041

线性:一头一尾,每个元素只有一个前驱和一个后驱,比如栈/队列
非线性:分支、分层关系,比如树/图


物理结构和逻辑结构

数组和链表可以看做物理存储的概念。
数组是用一段连续的内存存储,可以随机访问,支持随机访问( O(1) ),但增删需移动元素( O(n) )。
链表不要求连续的内存,元素分散存储,通过指针连接,增删无需移动元素( O(1) ),但不支持随机访问( O(n) )。

而数据结构都是逻辑层的概念,线性表,非线性表,栈,队列,树,图等等。
线性表这些逻辑层的概念,底层既可以用数组实现,也可以用链表实现。线性表用数组实现就叫做顺序表。

“顺序表是在计算机内存中以数组的形式保存的线中以数组的形式保存的线性表。” 简而言之,是线性表的一种实现方式。显然,这里的“数组”指物理结构,“线性表”指逻辑结构。这个解释应该还是合理的。

面向对象三大特性

  • 封装 Encapsulation
    将客观事物抽象为类,隐藏对象的内部属性和实现细节,只通过对外提供的公开接口(如 getter/setter)进行访问。

  • 继承 Inheritance
    一个类(子类/派生类)从另一个类(父类/基类)中继承属性和方法,同时可以扩展新的功能,实现代码复用减少冗余。

  • 多态 Polymorphism
    同一接口在不同情形下有不同的表现形式。简单来说,即父类指针或引用指向子类对象,并调用子类重写后的方法。
    如果多个派生类同时实现了一个接口,通过实例化此接口也可以展现出多态的特性。

虚函数

当继承链中虚函数 >= 1,则在compile time编译时生成虚函数表vtable映射到虚函数
继承链中的每个包含虚函数的类都有一个属于自己的 vtable (虚函数表)
在runtime运行时,子类的构造函数中为vptr赋值指向vtable

父类指针调用虚函数时,子类复写的虚函数被调用
查询vtable找到虚函数的入口地址并执行
父类指针指向子类对象,或父类引用绑定子类对象,父类对象vptr变成子类对象vptr,查询子类vtable

virtual vs. new


Note: virtual + override are resolved at runtime (so override really overrides virtual methods), while new just create new method with the same name, and hides the old, it is resolved at compile time -> your compiler will call the method it ‘sees’.

HybridCLR扩充了IL2CPP的代码,使它由纯AOT Runtime变成“AOT+Interpreter“混合Runtime,进而原生支持动态加载Assembly,使得基于IL2CPP Backend打包的游戏不仅能在Android平台,也能在iOS、Consoles等限制了JIT的平台上高效地以AOT+interpreter混合模式执行。

通过 “Differential Hybrid dll” 技术,可以对 AOT dll 实现任意增删改,会智能地让 被修改或者新增的类和方法 以 Interpreter 模式运行,但 未被修改的类 以AOT方式运行,从而使 热更新的游戏逻辑 的运行性能基本达到原生AOT的水平。


基础原理

CLR,即Common Language Runtime,中文叫公共语言运行时,是让.NET程序执行所需的外部服务的集合,是.NET平台的核心和最重要的组件,类似于Java的JVM。

IL2CPP是Unity开发的跨平台CLR解决方案,诞生它的一个关键原因是Unity需要跨平台运行。但一些平台如iOS,这种禁止JIT并导致依赖JIT的官方CLR虚拟机无法运行,而是必须使用AOT技术将Mananged程序提前转化为目标平台的静态原生程序后再运行。而Mono虽然也支持AOT,但性能较差以及跨平台支持不佳。The IL2CPP backend converts MSIL (Microsoft Intermediate Language) code (for example, C# code in scripts) into C++ code, then uses the C++ code to create a native binary file (for example, .exe, .apk, or .xap) for your chosen platform.

IL2CPP方案包含一套AOT运行时以及一套DLL到C++代码及元数据的转换工具,使得原始的C#开发的代码最终能在iOS这样的平台运行起来。因为 IL2CPP 生成的 C++ 代码不是普通的 C++,它本质上还是在模拟 C# 的行为。很多 C# 特性在 C++ 里根本不存在,必须有额外代码来支撑。IL2CPP 只是把计算逻辑翻译成了 C++,但 C# 作为托管语言的那些”托管服务”,必须由运行时来提供。生成的 C++ 代码到处都在调用 il2cpp_xxx() 这类运行时 API,离开运行时根本跑不起来。

IL2Cpp运行时

Unity 在打包时就把 IL 代码转换成 C++ 源码,然后在开发者的机器上(或 CI 上)用目标平台的 C++ 编译器编译成 machinecode。最终交付给用户的已经是编译好的原生二进制,不是在用户设备上编译的。
所以用户设备上不需要安装 C++ 编译器,也不需要单独安装什么 IL2CPP runtime —— 它是随游戏一起打包分发的。

虽然代码本身已经变成了原生机器码,但 C#/.NET 的很多语言特性不只是代码逻辑,还依赖一个运行时环境。IL2CPP VM(更准确地说是 libil2cpp 运行时库)负责提供这些能力:

  1. 垃圾回收(GC):C++ 没有自动内存管理,需要运行时提供 GC(IL2CPP 用的是 BoehmGC)
  2. 类型系统和反射:typeof、GetType()、Activator.CreateInstance() 这些反射操作需要运行时维护类型元数据
  3. 泛型的运行时支持:某些泛型实例化需要运行时处理
  4. 线程管理:对应 .NET 的线程模型
  5. 异常处理:C# 的异常机制需要映射到原生代码的异常处理
  6. 平台抽象层:提供统一的 API 来屏蔽不同操作系统的差异

可以把它理解为:IL2CPP 把”程序逻辑”编译成了原生代码,但 C# 程序运行时还需要一个”服务层”来提供 GC、反射、类型系统等基础设施。这个服务层就是 IL2CPP runtime(所谓的”VM”)。它不是传统意义上解释执行字节码的虚拟机,更像是一个原生运行时库,和你的游戏代码一起编译、一起打包、一起运行。
它的角色类似于 .NET 程序依赖的 CoreCLR runtime,只不过 IL2CPP 的版本更轻量,且不包含 JIT 编译器。

  • 这个il2cpp runtime是由用户在目标设备上自行安装吗?还是随unity游戏项目一起打包?
  • 如果是这样,当一个用户设备上有多个unity引擎制作的游戏,会有多个重复的il2cpp runtime吗?

IL2CPP runtime 是随每个游戏一起打包的,不需要用户自行安装。如果用户设备上装了 3 个 Unity 游戏,就会有 3 份 IL2CPP runtime,各自独立存在于各自的应用包内。

  • 为什么不做成共享的系统级运行时?

这和 .NET Framework on Windows 的模式(系统全局安装一份,所有 .NET 程序共用)是完全不同的设计选择,原因包括:

  1. 版本耦合:每个游戏可能用不同版本的 Unity 构建,对应不同版本的 IL2CPP runtime。共享的话版本兼容性会是噩梦。
  2. 跨平台一致性:Unity 要支持 iOS、Android、主机等众多平台,不可能在每个平台上都推动安装一个系统级运行时。
  3. 自包含 = 可靠:游戏自带运行时,不依赖用户设备上装了什么,部署更可控。
  4. 体积可接受:libil2cpp 运行时库本身并不大(通常几 MB 级别),相对于游戏的贴图、音频等资源来说微不足道。

不只是 Unity,很多引擎和框架都这么做:

  1. Unreal Engine 的游戏也是各自打包完整的引擎运行时
  2. Electron 应用每个都自带一份 Chromium + Node.js
  3. Go / Rust 编译的程序都是静态链接、自包含的

本质上就是用少量磁盘空间换取部署的简单性和可靠性,在现代存储容量下这是非常合理的取舍。

HybridCLR核心构成

IL2CPP是一个纯静态的AOT运行时,不支持运行时加载DLL,因此不支持热更新;不像Mono有Hybrid mode execution,可支持动态加载DLL。

目前Unity平台的主流热更新方案xLua、ILRuntime之类都是引入一个第三方VM(Virtual Machine),在VM中解释执行代码,来实现热更新。这里我们只分析使用C#为开发语言的热更新方案。这些热更新方案的VM与IL2CPP是独立的,意味着它们的元数据系统是不相通的,在热更新里新增一个类型是无法被IL2CPP所识别的(例如,通过System.Activator.CreateInstance是不可能创建出这个热更新类型的实例),这种看起来像,但实际上又不是的伪CLR虚拟机,在与IL2CPP这种复杂的CLR运行时交互时,会产生极大量的兼容性问题,另外还有严重的性能问题。

HybridCLR 对 IL2CPP运行时进行扩充,添加Interpreter模块,将它由AOT运行时改造为“AOT + interpreter”双引擎的混合运行时,进而实现Mono hybrid mode execution这样的机制。这样一来就能彻底支持热更新,并且兼容性极佳。对开发者来说,除了解释模式运行的部分执行得比较慢,其他方面跟标准的运行时没有区别,完美支持在iOS这种禁止JIT的平台上以解释模式无缝地运行动态加载的DLL。

与其他热更新方案对比

HybridCLR是原生的C#热更新方案。通俗地说,IL2CPP相当于Mono的AOT模块,HybridCLR相当于Mono的Interpreter模块,两者合一成为完整Mono。HybridCLR使得IL2CPP变成一个全功能的Runtime,原生(即通过System.Reflection.Assembly.Load)支持动态加载DLL,从而支持iOS平台的热更新。

正因为HybridCLR是原生Runtime级别实现,热更新部分的类型与主工程AOT部分类型是完全等价并且无缝统一的。可以随意调用、继承、反射或多线程,不需要生成代码或者写适配器。

其他热更新方案则是独立VM,与IL2CPP的关系本质上相当于Mono中嵌入Lua的关系。因此类型系统不统一,为了让热更新类型能够继承AOT部分类型,需要写适配器,并且解释器中的类型不能为主工程的类型系统所识别。特性不完整、开发麻烦、运行效率低下。

关节坐标的定义和计算方法

  1. 绑定姿势:这是网格绑定到骨骼之前的姿势,通常是设计师在绑定模型时预设的。绑定姿势通常在一个T型姿势(T-Pose)下进行记录‌。
  2. 局部姿势:这是关节相对于父关节来指定的常见姿势。局部姿势存储为TQS格式,表示相对于父关节的位置、朝向和缩放。根关节的父节点可以认为是世界坐标系的原点。在数学上,关节姿势是一个仿射变换,由平移向量、旋转矩阵和对角缩放矩阵组成‌。

骨架由一系列具有层次关系的关节(骨骼)和关节链组成,是一种树结构,选择其中一个是根关节,其它关节是根关节的子孙,可以通过平移和旋转根关节移动并确定整个骨架在世界空间中的位置和方向。父关节运动能影响子关节运动,但子关节运动对父关节不产生影响,因此,平移或旋转父关节时,也会同时平移或旋转其所有子关节。


Unity动画系统

分为旧版动画系统和新版动画系统,即AnimationClip 与 Animator(Mecanim)

  • AnimationClip
    Plays specific Animation Clips directly via script (e.g., Animation.Play(“Jump”)). It has no built-in state machine or visual graph.

  • Animator/Mecanim (New)
    Attach a animator component to your GameObject. It requires an Animator Controller asset to function.
    It uses a visual graph to manage states. You rarely tell it to “Play” a clip; instead, you change a parameter (e.g., animator.SetFloat(“Speed”, 5.0f)), and the state machine decides which animation to play.

3D模型

  1. 绑定 (Rigging)
    指为3D模型创建内部骨骼、关节及控制器系统,定义运动逻辑,给角色搭建“骨架”和“控制系统”的过程,类似于给木偶安装操纵线。
  • 目的: 赋予模型生命,使动画师能方便地操纵复杂的几何体。
  • 组成: 包含骨骼(Joints)、控制器(Controllers)、IK/FK(逆向/正向动力学)链。
    工作内容: 放置关节、设置骨骼层级、添加IK把手、创建控制器。
  1. 蒙皮 (Skinning)
    蒙皮是绑定完成后的步骤,将模型的顶点(Vertex)“绑定”到骨骼上。
    目的: 让骨骼驱动模型网格变形。当骨骼移动时,皮肤上的顶点会随之移动。
    方法:
  • 平滑蒙皮(Smooth Skinning): 顶点受多个骨骼影响,变形平滑自然(最常用)。
  • 刚性蒙皮(Rigid Skinning): 一个顶点只受一个骨骼影响,变形较生硬。
    关键概念——刷权重(Weighting): 定义顶点受特定骨骼影响的程度(0-1之间)。将Mesh网格的顶点与骨骼节点对应,从而决定模型的不同部位要跟着关节位移的程度
  1. 制作关键帧动画
    IK骨(Inverse Kinematics,反向动力学)通过设置骨骼末端(如手、脚)的目标位置,计算系统会自动推算出其他关节(如手腕、肘部)的移动
  • IK骨不是被反推的骨头的子节点,而是以外的骨头(例如大腿->小腿->脚,此时IK骨则不为这三个骨头的子节点)
  • Chain Length:有多少根节点骨头要反算
  • Pole Target:被影响关节的朝向方向

顶点着色器 vs 片元着色器

One main difference is that a vertex shader can manipulate the attributes of vertices. which are the corner points of your polygons.
The fragment shader on the other hand takes care of how the pixels between the vertices look. They are interpolated between the defined vertices following specific rules.

白话:顶点着色器负责定位三角形位置!片段着色器负责修改像素颜色!!

顶点着色器(Vertex Shader)

顶点着色器是图形渲染管线中的第一个可编程阶段。它的主要任务是处理从CPU发送到GPU的顶点数据。每个顶点都会通过顶点着色器进行一次,通常用于执行以下操作:

  • 变换:将顶点从模型空间转换到世界空间,然后进一步转换到视图空间和投影空间。这通常涉及到矩阵乘法运算,用于实现平移、旋转和缩放等变换。
  • 光照计算(可选):在某些情况下,顶点着色器也用于执行初步的光照计算,但这通常是在更简单的渲染场景中,或者作为更复杂的片元级光照计算的一个初步步骤。
  • 传递数据:顶点着色器可以计算并传递额外的数据到后续的渲染阶段,如片元着色器。这些数据可以是颜色、纹理坐标或其他自定义属性。

片元着色器

片元着色器是图形渲染管线中处理像素级渲染的阶段。它接收由顶点着色器插值得到的片元(即屏幕上的像素或像素的候选者),并生成最终的颜色和其他与像素相关的数据。以下是片元着色器的一些主要用途:

  • 纹理映射:从纹理中读取颜色信息,并应用到相应的像素上。这可以用于实现贴图、细节增强等效果。
  • 光照计算:执行更详细的光照计算,如计算每个像素上的光照强度和颜色。这可以包括漫反射、镜面反射、环境光等多种光照模型。
  • 颜色混合和特殊效果:实现各种颜色混合模式,以及应用如模糊、发光、深度测试等后处理效果。
  • 输出最终颜色:基于上述计算,确定每个像素的最终颜色,并将其发送到渲染管线的下一个阶段(通常是帧缓冲区)。

Credits:

https://zhuanlan.zhihu.com/p/718015588