C#进阶

1.abstract与虚函数

关键词 它是啥? 潜台词 强制性
Abstract Class
(抽象类)
未完工的模具 “我这个类是不完整的,不能直接用来造东西,只能被继承。” 不能 new,也不能挂在物体上直接用
Abstract Function
(抽象函数)
强制命令 “不管你怎么做,你必须要有这个功能!具体怎么做你自己定,但我这里不写代码。” 子类必须重写 (override)
Virtual Function
(虚函数)
默认建议 “我提供了一个通用的做法,你如果觉得好用就直接用;觉得不好用,也可以改。” 子类可以重写,也可以不重写

2.基类的函数封装

在写 Unity UI 基类时,怎么选?

  • 如果是供 UIManager 调用的函数(如 Open, Close),用 public
  • 如果是组件引用(如 CanvasGroup, Button)或者子类通用的变量,用 protected。(这是基类里最多的)。
  • 如果是基类内部的死逻辑(不想让子类乱改,或者子类根本不需要知道),用 private

3.简单数据结构类

1.Hashtable

1.本质与声明

image-20260309162502414

2.增删改查:

image-20260309162558279

image-20260309162755873

image-20260309162823598

image-20260309162901939

3.遍历

image-20260309163030970

image-20260309163113833

4.泛型

image-20260309171850407

image-20260309171909090

5.泛型约束

image-20260309175616135

举个例子:

1
2
3
4
5
6
7
class Test<T> where T:struct
{
public T value;
}

Test<int> a;
a.value=1;
  • 约束也可以组合使用,where后用逗号隔开就行

  • 如果多个泛型有约束呢,不同泛型约束用空格隔开就行

1
2
3
4
class Test<T,K> where T:struct where K:class
{

}

6.常用泛型数据结构类

1.Dictionary

1.本质与声明

image-20260309185937198

Hashtable:类型其实是确定的,叫 object

在 C# 早期的 System.Collections.Hashtable 中,键(Key)和值(Value)的类型在底层是被写死(确定)为 object 的

因为在 C# 中 object 是所有类型的基类,所以你可以把 int、string、bool 甚至自定义对象全塞进去,这利用的是多态(向上转型),而不是泛型。

这会带来两个致命问题:

  • 性能损耗(装箱与拆箱): 如果你存入值类型(比如 int),C# 必须将其打包成引用类型(装箱,分配堆内存),取出来时又要强转回值类型(拆箱)。这个过程极其消耗性能。
  • 类型不安全: 编译器不知道你存进去的具体是什么,取出来时必须强制转换。如果转错了,运行时直接崩溃。

2.增删改查

和哈希表一样,唯一不同的一小点是通过键查看值时,如果没有该键会报错而不是返回空

3.遍历

image-20260309190147578

注意键值对一起遍历用的类型与哈希表不同(也有通过迭代器的遍历方式,后续讲迭代器再补充)

7.常用委托与事件

第一部分:常用的 5 大委托类型 (Delegate Types)

在现代 C# 开发中,我们极少自己去写 public delegate void MyDelegate(); 这种传统定义了,因为系统已经为你准备好了 5 个“万能委托类型”,直接拿来用即可:

1. Action / Action

—— 最常用的“无返回值”委托

  • 特点:永远没有返回值(void)。

  • 参数:可以没有参数(Action),也可以带 1 到 16 个参数(如 Action<int, string>)。

  • 使用场景:绝大多数的回调函数、普通事件监听。

  • 举例

    1
    Action onComplete = () => Debug.Log("完成!"); Action<int, float> onTakeDamage = (hp, time) => Debug.Log($"扣血{hp}");

2. Func / Func<T1, T2… TResult>

—— 最常用的“有返回值”委托

  • 特点:必须有返回值!尖括号里的最后一个类型,就是它的返回值类型

  • 参数:Func 表示没有输入参数,返回 int;Func<string, bool> 表示输入一个 string,返回 bool。

  • 使用场景:数据计算、LINQ 查询、需要外部告诉你计算结果的回调。

  • 举例

    1
    2
    // 传入一个数字,返回它是否大于
    10 Func<int, bool> isGreaterThanTen = (num) => { return num > 10; };

3. Predicate

—— 专职“判断真假”的委托

  • 特点:固定接受一个参数,并且固定返回 bool 类型

  • 本质:它其实和 Func<T, bool> 一模一样,只是为了语义更清晰。

  • 使用场景:专门用在 List 列表的查找、筛选、删除操作中。

  • 举例

    1
    2
    // 传入一个数字,返回它是否大于10 
    Func<int, bool> isGreaterThanTen = (num) => { return num > 10; };

4. EventHandler / EventHandler

—— 老派严谨的委托

  • 特点:固定带两个参数:(object sender, EventArgs e)。
  • 使用场景:开发正规的 C# 桌面软件(WinForm/WPF)、编写需要符合 .NET 标准规范的类库。在游戏开发中除了特定框架,日常手写用得较少。

5. UnityAction / UnityAction

—— Unity 的亲儿子委托

  • 特点:和 Action 完全一致,无返回值。

  • 使用场景专门用来配合 Unity 自带的 UI 系统(UGUI)。比如给 Button、Slider 动态添加监听器时,人家 API 规定的参数类型就是 UnityAction。

  • 举例

    1
    button.onClick.AddListener(MyUnityAction);

第二部分:常用的事件形式 (Event Implementations)

上面说的是“委托类型”,现在我们来看看把它们加上 event 关键字或封装后,在代码里怎么声明事件。常用的有以下三种流派:

1. 极简实用流:event Action (强烈推荐⭐)

  • 做法:直接拿 C# 内置的 Action 委托,前面加上 event 关键字。

  • 优点:代码极其干净,不用写繁琐的 sender 和 EventArgs。

  • 使用场景:游戏开发中的大部分代码逻辑(主角死亡、游戏通关、金币增加等)。

  • 代码模板

    1
    2
    3
    4
    5
    6
    public class Player {    
    // 1. 声明事件
    public event Action<int> OnCoinChanged;
    public void AddCoin(){
    // 2. 触发事件 (?.Invoke 是一种安全的简写,判断不为空才触发)
    OnCoinChanged?.Invoke(100); } }

2. 标准规范流:event EventHandler

  • 做法:使用微软官方推荐的事件驱动模型。需要自定义一个类继承自 EventArgs 来传递数据。

  • 使用场景:大型框架设计、或者与其他标准 C# 库进行交互时。

  • 代码模板

    1
    2
    3
    public class MyArgs : EventArgs { public string msg; } 
    public event EventHandler<MyArgs> OnMessage;
    // 触发 OnMessage?.Invoke(this, new MyArgs { msg = "Hello" });

3. Unity 检查器流:UnityEvent (重要⭐)

  • 做法:不使用 C# 的 event 关键字,而是直接实例化一个 Unity 提供的类 UnityEvent。

  • 核心优势:**它可以序列化!会显示在 Unity 的 Inspector 面板上!**你可以直接在 Unity 编辑器里拖拽游戏物体来绑定事件(就像给 UGUI Button 绑事件那样)。

  • 注意:UnityEvent 是一个,不是委托类型,也不是 C# 语法的 event。它内部封装了对 UnityAction 委托的管理。

  • 代码模板

    1
    2
    3
    4
    5
    6
    7
    using UnityEngine; using UnityEngine.Events; // 必须引入这个命名空间 
    public class Door : MonoBehaviour {
    // 声明一个 UnityEvent (面板上就能看到了!)
    public UnityEvent OnDoorOpen;
    void Open(){
    // 触发事件
    OnDoorOpen?.Invoke(); } }

💡 终极一句话总结指南:以后写代码该用哪个?

  1. 需要返回值 ➡ 用 Func。
  2. 在 List 里面找东西 ➡ 用 Predicate(或者直接写 Lambda)。
  3. 给 Unity UI 按钮动态绑方法 ➡ 用 UnityAction。
  4. 想在 Unity 面板上拖拽连线(给策划和美术用) ➡ 用 UnityEvent。
  5. 纯粹的代码逻辑交互(自己写代码监听和触发) ➡ 用 event Action(最香、最通用)!

9.lambda表达式

lambda表达式本质是匿名函数,配合委托一起使用,就是为了省事用的,用委托不想让他一个一个函数地加,就可以用lambda表达式直接等于你想让此委托执行的内容,也可以+=一个lambda表达式,弊端就是无法-=(因为是匿名函数)(事件不能直接=一个lambda表达式,因为封装过了)

image-20260310141739570

image-20260310141813346

10.单例模式

这两种单例模式是游戏开发中最经典的两种写法,它们代表了 Unity 开发中的两大流派:“纯 C# 派”“MonoBehaviour 派”

它们不仅代码长得不一样,底层原理、生命周期和使用场景更是完全不同

我为你详细拆解这两者的区别,并告诉你该怎么选。


第一种:纯 C# 单例(饿汉模式)

1
2
3
private static JsonMgr instance = new JsonMgr();
public static JsonMgr Instance => instance;
private JsonMgr(){}

1. 这是什么?

这是标准的纯 C# 面向对象单例模式。因为它在声明 instance 的时候直接就 new 出来了,不管你用不用它,程序一启动它就在内存里了,所以这叫**“饿汉模式”**。

2. 代码细节解析

  • new JsonMgr():因为它没有继承 MonoBehaviour,所以可以通过 new 关键字直接在内存中创建实例。

  • private JsonMgr(){}:这是一个极其精妙且必要的设计!私有化构造函数,意味着在类的外部,别的脚本绝对无法写出 new JsonMgr() 这种代码,从而从根本上保证了单例的唯一性

  • 那么 => 是什么意思?
    => 在这里叫做**“表达式主体定义(Expression-bodied members)”,它是 C# 6.0 引入的一个超级好用的“语法糖”。
    在这里,它其实是
    只读属性(只包含 get 操作)**的简写形式。

    我们来看一下它的**“前世今生”**,你就秒懂了:

    在以前的老 C# 版本中,你必须这样写(非常啰嗦):

    1
    2
    3
    4
    5
    6
    7
    8
    public static JsonMgr Instance
    {
    get
    {
    return instance; // 当外部访问时,把私有的金库对象交出去
    }
    // 注意:这里故意不写 set,这就意味着外部只能“读取”,绝对不能“修改(赋值)”!
    }

3. 优缺点

  • 优点
    • 极其轻量、干净:不依赖 Unity 的场景,不挂载在任何 GameObject 上。
    • 跨场景绝对存活:切场景对它没有任何影响,它一直安安静静地躺在内存里。
    • 无需配置:不用去 Hierarchy 面板拖拽,代码直接调用即可生效。
  • 缺点
    • 不能使用 Unity 的 AwakeStartUpdate 等生命周期函数。
    • 不能开启协程(StartCoroutine)。
    • 不能在 Inspector 面板中暴露参数给策划配置。

第二种:MonoBehaviour 场景单例

1
2
3
4
5
6
7
8
public static UIManager Instance { get; private set; }
private void Awake()
{
if (Instance == null)
Instance = this;
else
Destroy(this.gameObject);
}

1. 这是什么?

这是 Unity 特有的组件式单例。这个脚本必须继承自 MonoBehaviour,并且必须挂载到场景里的某个 GameObject 身上

2. 代码细节解析

  • 依赖场景:只有当场景加载,并且挂载着这个脚本的 GameObject 被激活时,Awake 函数才会被触发,Instance 才会被赋值。
  • Destroy(this.gameObject):由于 Unity 中可以复制无数个 GameObject,如果你不小心在场景里建了两个 UIManager,或者切场景时带过来了一个新的,这句代码会把多余的那个连同它所在的物体直接销毁,保证场景里只有一个大管家。

3. 优缺点

  • 优点
    • 全能:可以使用 Unity 的所有功能(生命周期、协程、物理检测等)。
    • 可视化:可以直接在 Unity 面板上给它拖拽各种预制体、配置音频文件、设置参数。
  • 缺点
    • 依赖场景:如果场景里没有建这个物体,别的脚本一调用 UIManager.Instance 就会报空指针错误。
    • 切场景会被销毁:默认情况下,切换 Scene 会把这个单例销毁。必须额外加一句 DontDestroyOnLoad(this.gameObject) 才能保住它的命。

💡 我该用哪个?

结论是:两者都极度推荐,但必须“专款专用”!

【第一种:纯 C# 单例】的场景:

只要你的 Manager 不需要在面板上拖拽东西,不需要用 Update 每帧执行逻辑,不需要开协程,统统用第一种!

  • JsonMgr (数据解析):纯计算逻辑,不涉及场景物体,完美适用。
  • DataManager (玩家存档数据):管理金币、等级、背包数据。
  • EventManager (全局事件/消息广播中心)
  • NetworkManager (网络通信)

【第二种:MonoBehaviour 单例】的场景:

只要你的 Manager 需要控制场景里的物体、加载预制体、或者需要持续的生命周期刷新,就用第二种!

  • UIManager (UI 管理):需要找 Canvas、需要生成 UI 预制体,必须用 MonoBehaviour。
  • AudioManager (声音管理):身上需要挂载 AudioSource 组件来播放声音,必须依附于 GameObject。
  • GameManager (游戏主流程/时间控制):需要用到 Update 和协程。

最佳实战法则
你的 JsonMgr 写得非常标准且完美,继续保持第一种写法;
你的 UIManager 也是标准的 Unity 做法。这两个代码在同一个游戏项目中并存,才是真正的高手架构!

monobehaviour脚本是否可以用第一种?

绝对不行!这是一个极其危险的“天坑”!

如果你让一个继承了 MonoBehaviour 的脚本(比如你的 UIManager)使用这 3 行代码,Unity 会直接向你疯狂报错,游戏根本跑不起来!

这正是为什么在 Unity 里,单例模式分成了“纯 C# 派”和“MonoBehaviour 派”的最根本原因。

我们来看看为什么带 MonoBehaviour 的脚本绝对不能这么写:

一:MonoBehaviour 绝对不能用 new

在那 3 行代码里,最核心的是第一句:
private static JsonMgr instance = new JsonMgr();

  • 在纯 C# 里:用 new 凭空创造一个对象是天经地义的。
  • 在 Unity 里:只要你的类继承了 MonoBehaviour,它就变成了一个**“组件(挂件)”**。Unity 底层有着铁一般的纪律——组件绝对不能凭空产生,它必须依附在一个实实在在的 GameObject(游戏物体)身上!

如果你敢写 new UIManager(),Unity 的控制台会立刻弹出一长串红色的警告:

Warning: MonoBehaviour instances must be instantiated with GameObject.AddComponent() instead of new!
(警告:MonoBehaviour 实例必须通过 AddComponent 来添加,绝对不能用 new!)

二:MonoBehaviour 不能写构造函数!

在那 3 行代码里,第三句是防克隆的保安:
private JsonMgr(){}

  • 在纯 C# 里:构造函数是对象诞生的第一步。
  • 在 Unity 里:Unity 的底层引擎(C++ 写的)有一套自己的“造物法则”。当 Unity 实例化一个物体时,它会强行绕过你写的构造函数。
  • 对于 MonoBehaviour 来说,它的“出生证明”不是构造函数,而是 Awake()
  • 如果你强行给一个 MonoBehaviour 写了构造函数(不管是 public 还是 private),不但起不到保护作用,还会严重干扰 Unity 的序列化系统,导致各种莫名其妙的 Bug 甚至编辑器崩溃。

💡 总结

现在你终于知道为什么我要给你列出两种单例写法了吧?

  1. 对于不带 MonoBehaviour 的纯 C# 类(自由人):
    它们不受 Unity 场景的管辖,所以你必须自己当上帝:自己用 new 创造它,自己用 private 构造函数 保护它。这就是那 3 行极简代码的由来。

  2. 对于带 MonoBehaviour 的类(受 Unity 管控的打工人):
    你不能用 new,也不能写构造函数。你只能乖乖遵守 Unity 的规矩:

    • 你必须把它挂载到一个 GameObject 上。
    • 你必须在 Unity 赐予你的生命周期 Awake() 里,通过判断 Instance == null 来抢占单例的宝座。
    • 必须用 Destroy(this.gameObject) 来干掉后来的冒充者。

这就是为什么:
你的 JsonMgr(纯 C#)用的是最优雅的 3 行代码。
你的 UIManager(MonoBehaviour)必须在 Awake 里写 if (Instance == null)

这两种写法,就是针对这两种不同身份的“完美解法”!