1. 项目概述:从“能用”到“会设计”的关键一步
在Unity开发中,尤其是当项目规模从Demo走向产品,从个人开发转向团队协作时,代码的结构和可维护性就成了决定项目生死的关键。很多开发者,包括我自己在早期,都经历过这样的阶段:功能实现了,但代码像一团乱麻,新增一个特性要改十几个地方,牵一发而动全身。问题的根源,往往在于对面向对象编程(OOP)中几个核心概念——abstract、virtual、override——的理解停留在表面,没有形成一套清晰的“选择策略”。
这三个关键字,是C#赋予我们构建灵活、可扩展代码架构的利器。它们不是语法糖,而是设计思想的具象化。abstract(抽象)定义了“必须做什么”的契约,virtual(虚方法)提供了“默认怎么做”的模板,而override(重写)则是实现“具体怎么做”的自由。理解它们,意味着你从“写代码实现功能”的码农,向“设计系统架构”的工程师迈进了一大步。
这篇文章,我将结合在Unity中开发游戏系统、UI框架和工具链的实战经验,抛开教科书式的定义,直接深入到应用场景和选择逻辑中。我们会探讨:在什么情况下应该定义一个抽象类而不是接口?什么时候用虚方法比用抽象方法更合适?重写时有哪些坑必须避开?最终,你会得到一套清晰的决策流程图和实战检查清单,让你在下次设计新系统时,能自信地做出最合适的选择。
2. 核心概念再认识:超越语法定义
在深入实战前,我们需要统一认知,避免因术语理解偏差导致的误用。这里我不会重复MSDN上的标准定义,而是用Unity开发者更熟悉的方式来解读。
2.1 Abstract:搭建不可动摇的规则框架
你可以把abstract修饰的类或方法,想象成游戏策划案中的核心规则。比如,策划案规定:“游戏里必须有一种叫‘敌人’的东西,每个敌人都必须有一个‘被攻击时会受伤’的行为。” 这个规定是铁律,不容置疑,但策划案本身不具体说“哥布林”是怎么受伤的,“巨龙”又是怎么受伤的。
- 抽象类(Abstract Class): 就是一个“半成品”的蓝图。它定义了这类对象必须有的部分(字段、属性、方法),尤其是那些必须存在但实现方式未知的行为(抽象方法)。你不能直接
new一个抽象类,就像你不能把“敌人策划案”直接扔进游戏里当怪物用。它的存在,是为了被继承,为了给一系列具体的类(如Goblin,Dragon)建立一个统一的、强制性的起点。 - 抽象方法(Abstract Method): 是写在蓝图上的“待办事项”,只有方法签名(名称、参数、返回类型),没有方法体(
{}里的实现代码)。继承了这个蓝图的子类必须用自己的方式完成这个“待办事项”(即重写该方法)。这是一种最强的“契约”。
在Unity中的典型误用:试图将一个普通的MonoBehaviour脚本挂到抽象类上。Unity编辑器会报错,因为MonoBehaviour要求组件可以被实例化。解决方案通常是让抽象类也继承MonoBehaviour,但只为它添加抽象方法,而具体的游戏对象挂载的是继承自该抽象类的具体子类脚本。
2.2 Virtual:提供可修改的默认方案
如果说abstract是“必须做,但你自己想办法”,那么virtual就是“通常这么做,但你可以改”。它提供了默认的、可工作的实现。子类可以完全沿用这个默认方案,也可以根据需要进行部分或全部的修改。
- 虚方法(Virtual Method): 在父类中有一个完整的实现。子类可以选择:
- 直接继承:不重写,完全使用父类的逻辑。
- 重写(Override):用自己全新的逻辑完全替换父类的实现。
- 扩展(通过
base关键字调用):先执行父类的逻辑,再添加自己的额外逻辑。这是非常强大且常用的模式。
一个关键区别:抽象方法强制子类提供实现;虚方法允许子类修改实现。这是选择二者的最根本依据。
2.3 Override:履行契约或进行定制
override是子类对父类中abstract或virtual方法的响应动作。它是实现多态(Polymorphism)的基石。多态允许我们写这样的代码:Enemy currentEnemy = GetCurrentEnemy(); currentEnemy.TakeDamage(10);而不用关心currentEnemy具体是哥布林还是巨龙,它们会各自执行自己被重写的TakeDamage方法。
新手常踩的坑:
- 签名不一致:重写方法时,方法名、参数类型和数量、返回类型必须与父类方法完全一致,否则编译器会认为这是一个新的方法,而非重写。
- 可访问性降低:重写方法不能比父类原方法的访问权限更严格(例如,父类是
protected virtual,子类不能重写为private)。 - 忘记调用
base.Method():当父类的虚方法包含一些重要的初始化或清理逻辑时,在子类重写中忘记通过base关键字调用父类方法,可能导致难以察觉的Bug。例如,一个虚方法OnEnable()里注册了事件,子类重写时若没调用base.OnEnable(),事件就注册不上。
3. 实战应用场景深度解析
理论说再多,不如看实战。下面我们通过几个在Unity开发中高频出现的场景,来具体感受这三个关键字如何塑造我们的代码。
3.1 场景一:构建可扩展的游戏角色状态机
状态机(State Machine)是游戏角色行为控制的灵魂。一个典型的状态包括:进入状态(Enter)、状态更新(Update)、退出状态(Exit)。我们用抽象类和虚方法来设计一个优雅的解决方案。
// 1. 定义抽象状态基类 public abstract class CharacterStateBase { // 抽象方法:进入状态时必须执行的操作,子类必须实现 public abstract void OnStateEnter(); // 虚方法:状态每帧更新,子类可以重写以定制逻辑 public virtual void OnStateUpdate() { // 这里可以放一些所有状态都可能需要的通用更新逻辑 // 例如:检查是否处于无敌时间、更新状态计时器等 // Debug.Log(“State is updating...”); } // 虚方法:退出状态时必须执行的操作,提供默认实现(通常是清理工作) public virtual void OnStateExit() { // 默认可能只是重置一些标志位 // 子类可以选择重写以进行更复杂的清理 } // 普通方法:所有状态共享的辅助方法 protected void PlayAnimation(string animName) { // 调用动画系统播放指定动画 } } // 2. 实现具体状态类 public class IdleState : CharacterStateBase { private float idleTimer; // 必须实现抽象方法 public override void OnStateEnter() { PlayAnimation(“Idle”); idleTimer = 0f; Debug.Log(“进入待机状态”); } // 重写虚方法,添加特定逻辑 public override void OnStateUpdate() { // 首先调用父类的通用更新逻辑(如果有的话) // base.OnStateUpdate(); idleTimer += Time.deltaTime; if (idleTimer > 5f) { // 触发切换到其他状态(如巡逻状态) } } // 可以选择不重写 OnStateExit,使用父类的默认清理 } public class AttackState : CharacterStateBase { public override void OnStateEnter() { PlayAnimation(“Attack”); // 播放攻击音效 // 生成攻击判定框 } public override void OnStateUpdate() { // 检查动画是否播放完毕,完毕则切换回待机或移动状态 } public override void OnStateExit() { // 必须重写,因为需要销毁攻击判定框,这是攻击状态特有的清理工作 // Destroy(attackCollider); // 同时也可以调用父类的清理逻辑(如果父类有) base.OnStateExit(); Debug.Log(“退出攻击状态,清理特效”); } }设计思路解析:
- 为什么用
abstract修饰OnStateEnter?因为每个状态“进入”时的行为差异极大(播放的动画、初始化的变量、触发的特效都不同),且这个行为是必需的,没有合理的默认实现。这强制每个状态设计者都必须思考“进入这个状态要做什么”。 - 为什么用
virtual修饰OnStateUpdate和OnStateExit?因为“更新”和“退出”可能有通用模式。例如,所有状态可能都需要在Update里检查是否满足退出条件;所有状态在Exit时可能都需要重置某个标志。提供虚方法默认实现(哪怕是空的),给了子类一个“安全网”,也给了扩展的入口。AttackState的OnStateExit就展示了先做自己特有的清理,再调用父类通用清理的最佳实践。 - 多态的威力:状态管理器只需要持有
CharacterStateBase currentState;,然后调用currentState.OnStateUpdate()。它完全不用关心当前是哪种具体状态,代码简洁而强大。
3.2 场景二:设计UI控件的通用交互模板
UI系统是另一个重灾区,按钮、滑块、开关等控件都有共同的交互生命周期:指针进入、按下、抬起、退出。我们可以创建一个基础的交互类。
using UnityEngine; using UnityEngine.EventSystems; public abstract class UIInteractableBase : MonoBehaviour, IPointerEnterHandler, IPointerExitHandler, IPointerDownHandler, IPointerUpHandler { [SerializeField] protected bool isInteractive = true; // 是否可交互 protected bool isHovered = false; protected bool isPressed = false; // 虚方法:提供默认的视觉反馈(如颜色变化) protected virtual void OnNormal() { // Debug.Log(“恢复正常状态”); } protected virtual void OnHover() { // Debug.Log(“鼠标悬停”); } protected virtual void OnPressed() { // Debug.Log(“鼠标按下”); } // 抽象方法:子类必须定义具体的交互行为(如点击后打开哪个面板) protected abstract void OnClick(); // 接口实现 - 这些方法通常是固定的模板,适合用虚方法提供默认骨架 public virtual void OnPointerEnter(PointerEventData eventData) { if (!isInteractive) return; isHovered = true; if (!isPressed) { OnHover(); } } public virtual void OnPointerExit(PointerEventData eventData) { if (!isInteractive) return; isHovered = false; if (!isPressed) { OnNormal(); } } public virtual void OnPointerDown(PointerEventData eventData) { if (!isInteractive) return; isPressed = true; OnPressed(); } public virtual void OnPointerUp(PointerEventData eventData) { if (!isInteractive) return; bool wasPressed = isPressed; isPressed = false; if (wasPressed && isHovered) { OnClick(); // 触发抽象方法,执行具体逻辑 OnNormal(); } else if (isHovered) { OnHover(); } else { OnNormal(); } } // 一个可能有用的虚方法:用于外部控制交互性 public virtual void SetInteractive(bool interactive) { if (isInteractive != interactive) { isInteractive = interactive; if (!interactive) { // 强制恢复到普通状态 isHovered = false; isPressed = false; OnNormal(); } } } }设计思路解析:
- 接口与抽象类的结合:这里我们继承了
MonoBehaviour并实现了Unity的UI事件接口(IPointerEnterHandler等)。接口保证了我们必须有这些方法,而抽象类UIInteractableBase则提供了这些接口方法的默认实现逻辑(状态管理、条件判断)。这是一种非常实用的模式。 virtual用于流程控制:OnPointerDown等接口方法是固定的流程(按下->设置状态->调用反馈),所以用virtual。子类(如一个特殊按钮)如果需要在按下时播放特殊音效,可以重写OnPointerDown,并在其中先调用base.OnPointerDown(eventData)确保基础流程执行,再添加自己的音效逻辑。abstract用于核心行为:OnClick是每个UI控件唯一且必须不同的核心行为(普通按钮加载场景,商店按钮打开商店面板),所以定义为抽象方法。这强制每个具体的按钮类都必须明确“点击后到底要干嘛”。protected virtual用于可选扩展:OnNormal,OnHover,OnPressed是视觉/听觉反馈。我们提供了空实现(virtual),子类可以重写它们来改变颜色、缩放、播放动画等。如果某个控件不需要悬停效果,它完全可以不重写OnHover。
3.3 场景三:实现可配置的数据管理器基类
在游戏开发中,我们经常需要管理各种配置表(如物品表、关卡表)。这些管理器的共同点是:都需要加载数据、提供根据ID查询数据的方法。但数据来源可能不同(Resources加载、Addressables、网络下载)。
using System.Collections.Generic; using UnityEngine; public abstract class DataManagerBase<TData, TKey> : MonoBehaviour where TData : class, new() { protected Dictionary<TKey, TData> dataDictionary = new Dictionary<TKey, TData>(); // 抽象方法:子类必须定义如何从原始数据(如TextAsset)解析成TData对象 protected abstract TData ParseDataRow(string rowData); // 抽象方法:子类必须定义如何从TData对象中提取出Key protected abstract TKey GetKeyFromData(TData data); // 虚方法:加载数据的总流程。这是一个“模板方法”,定义了步骤骨架。 public virtual bool LoadData(TextAsset dataFile) { if (dataFile == null) { Debug.LogError(“数据文件为空!”); return false; } dataDictionary.Clear(); string[] lines = dataFile.text.Split(‘\n’); bool success = true; // 步骤1: 遍历每一行(通常跳过表头) for (int i = 1; i < lines.Length; i++) { string line = lines[i].Trim(); if (string.IsNullOrEmpty(line)) continue; // 步骤2: 调用抽象方法解析单行数据 TData data = ParseDataRow(line); if (data == null) { Debug.LogWarning($“解析第{i}行数据失败: {line}”); success = false; continue; } // 步骤3: 调用抽象方法获取Key TKey key = GetKeyFromData(data); if (key == null) { Debug.LogWarning($“从数据中获取Key失败: {line}”); success = false; continue; } // 步骤4: 存入字典 if (!dataDictionary.ContainsKey(key)) { dataDictionary.Add(key, data); } else { Debug.LogWarning($“重复的Key: {key}, 第{i}行数据将被忽略。”); success = false; } } OnDataLoaded(success); // 步骤5: 加载完成后的回调 return success; } // 虚方法:数据加载完成后的回调,子类可重写以进行初始化 protected virtual void OnDataLoaded(bool loadSuccess) { if (loadSuccess) { Debug.Log($“{GetType().Name} 数据加载完成,共加载 {dataDictionary.Count} 条记录。”); } } // 具体方法:提供查询服务。基于抽象方法构建的稳定功能。 public TData GetData(TKey key) { if (dataDictionary.TryGetValue(key, out TData data)) { return data; } Debug.LogWarning($“未找到Key为 {key} 的数据。”); return null; } public bool HasData(TKey key) => dataDictionary.ContainsKey(key); }设计思路解析:
- “模板方法”模式:
LoadData是一个经典的虚方法模板。它定义了加载数据的固定流程(清空字典、按行读取、解析、存储、回调),但将其中会变化的部分——如何解析一行数据(ParseDataRow)和如何获取Key(GetKeyFromData)——推迟到子类,通过抽象方法来实现。这样,所有数据管理器的加载逻辑都是一致且健壮的,我们只需要关心具体的解析规则。 - 泛型的应用:使用泛型
TData和TKey,使得这个基类可以用于管理任何类型的数据(ItemData,LevelData)和任何类型的Key(int,string)。这极大地提高了代码的复用性。 virtual用于提供扩展点:OnDataLoaded是一个虚方法。基类提供了一个简单的日志实现。子类可以重写它,在数据加载完成后进行更复杂的操作,比如初始化其他依赖此数据的系统,或者对数据进行预处理建立索引。如果子类不需要,则什么都不用做。- 稳定与变化的分离:
GetData、HasData这些对外提供服务的具体方法是稳定的,它们建立在抽象的解析方法之上。无论子类如何解析数据,查询接口永远不变。这是面向对象设计“开闭原则”(对扩展开放,对修改关闭)的完美体现。
4. 核心选择策略与决策流程图
经过上面的场景分析,我们可以提炼出一套选择abstract、virtual还是普通方法的标准策略。这不仅仅是语法选择,更是设计意图的传达。
4.1 何时使用 Abstract(抽象方法与抽象类)
使用abstract的核心信号是:“我不知道你会怎么做,但你必须做这个。”这是一种强制性的设计约束。
选择抽象类(而非接口)当:
- 你需要为一系列相关的类提供共同的基类实现(字段、属性、非抽象方法)。例如,所有“敌人”都有血量(字段)和移动速度(属性),都有“播放受伤动画”(具体方法)和“计算掉落物”(抽象方法)的行为。抽象类可以包含这些具体成员,而接口不能。
- 你希望控制继承链的构造函数行为。抽象类可以有构造函数,用于初始化公共字段,确保子类在创建时处于一致的状态。
- 你预计未来可能会在基类中添加新的带有默认实现的方法(虚方法),而不想破坏所有现有的实现类。如果使用接口,添加新方法会强制所有实现类都去实现它,破坏性很大。
选择抽象方法当:
- 该行为在概念上是这个类族不可或缺的核心功能,但每个子类的实现逻辑完全不同,无法给出一个有意义的默认实现。
- 你希望强制每个子类的设计者都认真思考并实现这个行为,避免他们因为忘记实现而导致运行时错误(编译器会报错)。
注意:一个类只要包含至少一个抽象方法,这个类本身就必须声明为
abstract。抽象类中可以同时包含抽象方法和具体方法(包括虚方法)。
4.2 何时使用 Virtual(虚方法)
使用virtual的核心信号是:“我提供了一个不错的默认实现,但如果你有更好的主意,欢迎你来改。”这是一种提供便利和允许扩展的设计。
选择虚方法当:
- 该行为有一个合理的、对大多数子类都适用的默认实现。例如,
MonoBehaviour中的Start()、Update()虽然是空方法,但它们被定义为虚方法,为你提供了在特定生命周期注入代码的入口。 - 你预见到部分子类可能需要定制或扩展该行为。例如,一个
Vehicle类的Move()方法,默认可能是轮式移动,但Airplane子类就需要重写为飞行逻辑。 - 你正在实现**“模板方法”模式**。就像上面数据管理器的例子,在父类的虚方法中定义算法骨架,将某些步骤延迟到子类中实现。
- 你希望子类能够通过
base.Method()调用父类的实现,在其基础上进行增强,而不是完全替换。
一个重要的权衡:过度使用虚方法会带来微小的性能开销(因为涉及虚方法表查找),但在现代游戏开发中,这点开销在绝大多数情况下可以忽略不计。设计的清晰度和灵活性远比这点性能重要。除非你在编写极度性能敏感的代码(如每帧调用数千次的底层循环),否则应优先考虑良好的设计。
4.3 何时使用普通方法(非 virtual)
当一个方法在父类中拥有完整、稳定且不希望被子类改变的实现时,就使用普通方法。这代表了“这就是最终方案,不要动它”。
选择普通方法当:
- 该方法是类的核心辅助功能或工具方法,其逻辑是确定且封闭的。例如,一个数学计算工具类中的方法。
- 该方法的实现涉及到类的内部状态或私有字段的完整性,如果被子类修改可能导致对象状态不一致。将其设为非虚方法可以保护类的内部不变性(Invariant)。
- 出于性能考虑,在极少数需要避免虚方法调用开销的场景下。
- 你明确禁止子类以任何方式改变此行为。
4.4 决策流程图与检查清单
为了更直观地做出选择,你可以遵循下面的决策流程:
开始设计一个类的方法时,问自己: 1. 这个方法是否是此类族(继承体系)中“必须存在”的行为? ├── 是 → 2. └── 否 → 考虑它是否应该是这个类的方法?或许应该放到别处。 2. 我能否为所有子类提供一个有意义的默认实现? ├── 能 → 使用 Virtual 方法。(提供默认实现,允许子类修改) └── 不能 → 使用 Abstract 方法。(强制子类实现) 3. 对于非必须存在的方法,我是否预见到子类可能需要改变或扩展它? ├── 是 → 使用 Virtual 方法。 └── 否 → 使用普通方法。实战检查清单(在代码审查或自己回顾时使用):
- [ ]抽象方法检查:我定义的每个抽象方法,是否真的在所有子类中都有截然不同的实现?有没有可能提取一些公共逻辑到基类,将抽象方法改为虚方法?
- [ ]虚方法检查:我提供的虚方法默认实现是否安全、无副作用?子类在重写时,是否清楚他们应该/可以调用
base.Method()? - [ ]重写检查:我重写的方法签名是否完全正确?访问修饰符是否没有变得更严格?我是否考虑了调用父类实现(如果需要)?
- [ ]多态使用检查:我是否在尽可能使用基类类型(
CharacterStateBase,UIInteractableBase)来引用对象,以利用多态性,而不是到处使用具体类型(IdleState,SpecialButton)?
5. 高级技巧、常见陷阱与性能考量
掌握了基础策略,我们再来看看一些能让你代码更上一层楼的技巧,以及必须绕开的深坑。
5.1new关键字与“隐藏”方法——一个危险的“特性”
有时你会看到这样的代码:
public class ParentClass { public void DoSomething() { Debug.Log(“Parent”); } } public class ChildClass : ParentClass { public new void DoSomething() { Debug.Log(“Child”); } // 使用 new 关键字 }new关键字在这里的作用是“隐藏”从父类继承来的同名方法,而不是重写它。这是一个非常容易导致混淆和Bug的特性。
陷阱演示:
ParentClass obj = new ChildClass(); obj.DoSomething(); // 输出什么?答案是输出“Parent”。因为obj的编译时类型是ParentClass,而DoSomething不是虚方法,没有多态性。编译器直接绑定了ParentClass.DoSomething。
对比override:
public class ParentClass { public virtual void DoSomething() { Debug.Log(“Parent”); } } public class ChildClass : ParentClass { public override void DoSomething() { Debug.Log(“Child”); } } ParentClass obj = new ChildClass(); obj.DoSomething(); // 输出 “Child”结论与建议:尽量避免使用new来隐藏方法。它的存在主要是为了处理版本兼容性问题(比如你引用的一个库更新了,其基类添加了一个和你子类同名的方法)。在你自己可控的代码中,如果子类需要提供与父类同名但行为不同的方法,首先考虑是否应该将父类方法设计为virtual然后进行override。如果父类方法确实不应该被重写(非virtual),那么考虑给子类方法换一个更准确的名字,以避免混淆。
5.2 构造函数、字段初始化与虚方法的调用顺序
这是一个经典的陷阱,在Unity中由于MonoBehaviour的生命周期而变得更加复杂。
public class BaseClass { protected string name = “Base”; public BaseClass() { Debug.Log(“Base Constructor: “ + name); Initialize(); } protected virtual void Initialize() { name += “_Initialized”; } } public class DerivedClass : BaseClass { public DerivedClass() { Debug.Log(“Derived Constructor: “ + name); } protected override void Initialize() { base.Initialize(); name = “Derived”; } } // 调用 new DerivedClass(); 输出顺序是?输出顺序是:
Base Constructor: Base(基类字段初始化)Derived的Initialize()被调用(因为多态,即使是在基类构造函数中),将name改为 “Derived”。Derived Constructor: Derived
陷阱:在基类构造函数中调用虚方法,此时子类的构造函数尚未执行,子类对象可能处于一个“未完全初始化”的状态。如果重写的虚方法依赖于子类构造函数中初始化的字段,就会导致空引用或默认值错误。
Unity 中的特定情况:Awake()和Start()是MonoBehaviour的虚方法。如果一个基类MonoBehaviour在Awake()中调用了一个虚方法进行初始化,而子类重写了这个方法并访问了在Start()或甚至Awake()后半部分才初始化的字段,就会出问题。
最佳实践:
- 避免在构造函数中调用虚方法。
- 在Unity中,如果需要在初始化时调用可定制的逻辑,可以考虑使用一个显式的
Init()方法,并在所有相关字段都确保初始化后(例如在Start()中)手动调用它。 - 或者,使用一个
bool isInitialized标志位,确保初始化逻辑只执行一次。
5.3 性能考量:虚方法调用与内联优化
虚方法调用比非虚方法调用稍慢,因为运行时需要通过虚方法表(vtable)进行间接查找,而不是直接跳转到固定的函数地址。这在绝大多数游戏逻辑中(每秒调用几千几万次)的影响微乎其微,完全不需要担心。
需要关注性能的场景:
- 在
Update()中每帧调用数千次的、非常小的工具方法。如果这些方法是虚方法,且被证明是性能热点(通过Profiler检测),可以考虑将其改为非虚方法,或者使用其他设计模式(如策略模式通过委托注入)。 - 极度关键的渲染循环或物理计算代码。
不要进行不成熟的优化:永远先追求清晰、灵活、可维护的设计。在性能问题被实际测量和证实之前,不要因为担心虚调用开销而放弃良好的面向对象设计。使用virtual和override带来的架构收益,远大于那纳秒级的性能成本。
5.4 抽象类 vs 接口:如何选择?
这是一个永恒的话题。在C#中,一个类只能继承一个基类(单继承),但可以实现多个接口。这是最根本的区别。
选择接口(Interface)当:
- 你定义的是一个能力契约,而不是一个具体的实现。例如,“可以攻击”(
IAttackable)、“可以销毁”(IDestroyable)。任何类,无论它继承自谁,都可以拥有这些能力。 - 你需要让一个类拥有多种不同的、可能不相关的“角色”或“能力”。
- 你正在设计小而专一的契约,不包含任何实现细节。
选择抽象类(Abstract Class)当:
- 你需要在多个紧密相关的类之间共享代码(字段、属性、具体方法实现)。
- 你希望为继承者提供一些公共的状态或行为,并控制其构造过程。
- 你预计基类在未来会添加新的带有默认实现的方法,并且不希望破坏所有现有子类(接口添加方法会破坏所有实现者)。
在Unity中的常见模式:两者结合使用。例如,UIInteractableBase是一个抽象类,它提供了UI交互的通用状态管理和流程,并实现了IPointerClickHandler等接口。具体的按钮类继承这个抽象类,就自动获得了接口能力以及丰富的默认实现。