
1. 为什么继承是Unity开发绕不开的第一道坎刚接触Unity那会儿我写敌人脚本基本靠复制粘贴。一个近战怪一个脚本一个远程怪再复制一份改改数值就完事。结果项目里二十多个敌人脚本每个都带几百行重复代码改一个受击逻辑要翻二十个文件改完还漏了三个测试的时候直接被策划追着骂。后来被逼着把公共逻辑抽出来才真正理解C#里继承这个东西到底在解决什么问题。这篇笔记面向的是已经写过几个Unity脚本、能看懂MonoBehaviour生命周期、但对C#面向对象还停留在“知道有继承这回事”阶段的开发者。我会把继承在Unity实际项目里的用法、坑点、以及为什么这么设计讲透不是照本宣科念语法而是告诉你什么时候该用、什么时候千万别用。核心关键词就一个类的继承但我会把它放在Unity组件化开发的大背景下讲因为脱离引擎谈继承在Unity里是要出事的。继承的本质是“is-a”关系。近战怪是一种敌人飞行怪也是一种敌人它们共享“敌人”这个身份下的所有公共属性和行为。C#用冒号语法表达这层关系子类自动获得父类的字段、属性、方法还能按需重写。听起来简单但Unity的组件模型和C#的继承机制之间存在天然的张力这个张力是很多新手踩坑的根源后面会展开讲。2. 继承的核心机制拆解与Unity适配2.1 从语法到语义冒号背后发生了什么C#里声明继承的语法就一个冒号public class Enemy : MonoBehaviour { // 所有敌人都有的东西 } public class MeleeEnemy : Enemy { // 近战怪特有的东西 }MeleeEnemy继承自EnemyEnemy又继承自MonoBehaviour。这条链意味着MeleeEnemy的实例同时是MeleeEnemy、是Enemy、也是MonoBehaviour。你可以把MeleeEnemy对象赋给Enemy类型的变量也可以赋给MonoBehaviour类型的变量反过来不行。这个“向上转型安全、向下转型需要显式转换”的规则是后面讲多态的基础。字段和方法的继承规则要分清楚。public和protected成员子类能直接访问private成员子类看不见但确实存在于内存中。方法默认也是继承的子类可以直接调用父类的public方法。构造函数是个例外子类不会继承父类的构造函数但子类构造时会自动调用父类的无参构造函数如果父类没有无参构造函数子类必须用base(...)显式调用某一个。在Unity里MonoBehaviour的构造函数你基本不会自己写因为Unity不允许你用new来创建组件实例组件的生命周期由引擎管理。所以构造函数那套规则在Unity日常开发里用得少但面试会问而且写纯C#逻辑类的时候用得上。2.2 virtual、override、new三个关键词决定行为走向父类方法默认是“密封”的子类只能隐藏不能改写。要让子类能改写父类方法必须标virtual子类用overridepublic class Enemy : MonoBehaviour { public virtual void TakeDamage(int amount) { health - amount; if (health 0) Die(); } } public class MeleeEnemy : Enemy { public override void TakeDamage(int amount) { // 先执行父类逻辑 base.TakeDamage(amount); // 再加自己的反应 PlayHitEffect(); } }base.TakeDamage(amount)这行很关键。很多新手重写方法时把父类逻辑整个复制一遍再改这是最糟糕的做法因为父类逻辑一改所有子类的复制品全部失效。正确姿势是能调base就调base只在需要完全替换行为时才不调。new关键词是另一回事它做的是“隐藏”而非“重写”。用new标记的方法通过父类引用调用时执行父类版本通过子类引用调用时执行子类版本。这个行为差异在多态场景下会造成非常隐蔽的bug我个人的建议是除非你非常清楚自己在做什么否则永远不要用new来“重写”方法老老实实用override。2.3 抽象类与接口什么时候该用哪个抽象类用abstract修饰不能实例化可以包含抽象方法只有声明没有实现和普通方法。接口用interface定义只包含成员签名C# 8之后可以有默认实现但Unity常用的.NET版本里基本用不到。选择标准其实很清晰如果多个类共享的是“是什么”的关系并且有公共的字段和实现用抽象类如果共享的是“能做什么”的能力且这些类可能属于完全不同的继承体系用接口。Unity里有个经典例子玩家和敌人都能受伤但玩家不是敌人敌人也不是玩家它们没有共同的“生物”父类就算有也不该为了受伤这个能力硬造一个。这时候定义IDamageable接口玩家和敌人各自实现伤害系统只依赖接口不依赖具体类型。这就是接口的价值解耦。但接口不能有字段不能有构造函数所有实现类都要自己写一遍相同逻辑。所以如果多个类不仅有共同能力还有共同数据抽象类更省事。实际项目里两者经常混用抽象类提供基础实现接口提供能力契约。3. Unity中继承的实操落地与典型场景3.1 搭建一个可扩展的敌人系统假设我们要做一个有近战、远程、飞行三种敌人的系统。最朴素的做法是三个独立脚本但我们要用继承来组织。先定义基类EnemyBase继承自MonoBehaviourpublic abstract class EnemyBase : MonoBehaviour { [SerializeField] protected int maxHealth 100; [SerializeField] protected float moveSpeed 3f; protected int currentHealth; protected Transform playerTransform; protected virtual void Awake() { currentHealth maxHealth; playerTransform GameObject.FindGameObjectWithTag(Player).transform; } protected virtual void Update() { if (currentHealth 0) return; MoveTowardsPlayer(); } protected abstract void MoveTowardsPlayer(); public virtual void TakeDamage(int amount) { currentHealth - amount; if (currentHealth 0) { OnDeath(); } } protected virtual void OnDeath() { Destroy(gameObject); } }这里有几个设计决策值得说。Awake和Update都标了virtual因为子类可能需要在这两个生命周期里插入自己的逻辑。MoveTowardsPlayer是abstract因为不同敌人的移动方式完全不同基类没法给默认实现。TakeDamage和OnDeath是virtual因为大部分敌人受伤和死亡逻辑一样但个别敌人可能要特殊处理。然后近战怪public class MeleeEnemy : EnemyBase { [SerializeField] private int attackDamage 10; [SerializeField] private float attackRange 1.5f; protected override void MoveTowardsPlayer() { if (Vector3.Distance(transform.position, playerTransform.position) attackRange) { transform.position Vector3.MoveTowards( transform.position, playerTransform.position, moveSpeed * Time.deltaTime); } else { Attack(); } } private void Attack() { // 攻击逻辑 } }飞行怪public class FlyingEnemy : EnemyBase { [SerializeField] private float hoverHeight 3f; [SerializeField] private float hoverAmplitude 0.5f; private float hoverTimer; protected override void MoveTowardsPlayer() { hoverTimer Time.deltaTime; Vector3 targetPos playerTransform.position Vector3.up * hoverHeight; targetPos.y Mathf.Sin(hoverTimer) * hoverAmplitude; transform.position Vector3.MoveTowards( transform.position, targetPos, moveSpeed * Time.deltaTime); } }这套结构的好处是公共逻辑只写一遍新增敌人类型只需要继承EnemyBase并实现MoveTowardsPlayer。策划要改所有敌人的移动速度改基类的moveSpeed默认值就行。要加一个“所有敌人死亡时播放音效”的功能在基类OnDeath里加一行所有子类自动生效。3.2 多态在伤害系统里的实际应用有了继承体系伤害系统可以写得非常干净public class DamageDealer : MonoBehaviour { [SerializeField] private int damage 20; private void OnTriggerEnter(Collider other) { EnemyBase enemy other.GetComponentEnemyBase(); if (enemy ! null) { enemy.TakeDamage(damage); } } }注意这里GetComponentEnemyBase()拿的是基类类型但实际拿到的可能是MeleeEnemy或FlyingEnemy。调用TakeDamage时如果子类重写了这个方法执行的是子类版本。这就是多态同一行代码根据运行时对象的实际类型执行不同的行为。如果不用继承伤害系统就得写成MeleeEnemy melee other.GetComponentMeleeEnemy(); if (melee ! null) melee.TakeDamage(damage); FlyingEnemy flying other.GetComponentFlyingEnemy(); if (flying ! null) flying.TakeDamage(damage); // 每加一种敌人就要加一段这种写法每新增一种敌人就要改伤害系统违反开闭原则。继承加多态让伤害系统对扩展开放、对修改关闭。3.3 用接口处理跨继承体系的能力前面提到玩家和敌人都能受伤但玩家不该继承EnemyBase。这时候接口登场public interface IDamageable { void TakeDamage(int amount); bool IsDead { get; } }EnemyBase实现这个接口public abstract class EnemyBase : MonoBehaviour, IDamageable { public bool IsDead currentHealth 0; // TakeDamage已经是public virtual自动满足接口 }玩家也实现public class PlayerController : MonoBehaviour, IDamageable { [SerializeField] private int maxHealth 100; private int currentHealth; public bool IsDead currentHealth 0; public void TakeDamage(int amount) { currentHealth - amount; // 玩家特有的受伤表现 } }伤害系统改成依赖接口private void OnTriggerEnter(Collider other) { IDamageable target other.GetComponentIDamageable(); if (target ! null !target.IsDead) { target.TakeDamage(damage); } }这样伤害系统完全不关心对方是玩家还是敌人只关心“能不能受伤”。新增任何可受伤对象只要实现接口就能被伤害系统识别一行代码都不用改。4. 继承的坑点排查与设计经验4.1 深继承链是维护噩梦我见过最夸张的项目敌人继承链有七层Entity→Character→Enemy→GroundEnemy→MeleeEnemy→BossEnemy→FinalBoss。改最底层一个方法要顺着链往上翻七层看有没有被重写、有没有调base。这种代码谁接手谁崩溃。经验法则继承层级不要超过三层。MonoBehaviour算一层你的基类算一层具体实现算一层到顶了。如果发现需要第四层大概率是设计出了问题应该用组合代替继承。组合的思路是把“能力”做成独立组件挂在GameObject上而不是通过继承获得。比如移动能力做成Mover组件攻击能力做成Attacker组件敌人对象按需组合这些组件。Unity本身就是组件化引擎用组合比用继承更符合引擎设计哲学。4.2 生命周期函数的继承陷阱Unity的Awake、Start、Update这些生命周期函数如果父类和子类都定义了两个都会执行不需要virtual/override也不需要调base。这是Unity特有的行为和普通C#方法继承规则不一样。public class Parent : MonoBehaviour { private void Awake() { Debug.Log(Parent Awake); } } public class Child : Parent { private void Awake() { Debug.Log(Child Awake); } }运行结果会打印两行父类的先执行。这个特性容易让人困惑明明子类“覆盖”了Awake为什么父类的还执行因为Unity是通过反射查找所有Awake方法并逐个调用的不走C#的虚方法表。我的建议是生命周期函数统一用protected virtual声明子类用override并调base。这样执行顺序明确可控也符合C#惯例团队协作时不容易出乱子。4.3 常见问题速查表问题现象可能原因排查方向子类方法没被调用父类方法没标virtual子类用了new而非override检查方法签名和关键词父类字段值为默认值子类重写了Awake但没调base.Awake()确认base调用链完整转型报InvalidCastException向下转型时对象实际类型不匹配用as或is做安全转换接口方法找不到接口方法没声明为public接口实现必须public抽象类无法挂载抽象类不能作为组件添加到GameObject只挂具体子类4.4 几个我踩过的坑第一个坑在基类Awake里访问子类字段。C#对象构造顺序是父类字段初始化 → 父类构造函数 → 子类字段初始化 → 子类构造函数。Unity的Awake调用时机在对象构造之后所以基类Awake里访问子类字段是安全的。但如果你在基类构造函数里访问子类字段拿到的会是默认值。Unity里很少写构造函数但这个规则在写纯C#类时要注意。第二个坑GetComponent父类()能拿到子类组件但GetComponent子类()拿不到父类组件。这个方向性要记牢。场景里挂的是MeleeEnemyGetComponentEnemyBase()返回该组件GetComponentMeleeEnemy()也返回该组件但GetComponentFlyingEnemy()返回null。第三个坑序列化字段的继承。父类的[SerializeField] private int health在子类Inspector里是可见的但子类代码无法直接访问因为是private。要么改成protected要么提供protected属性。我倾向于用protected字段加[SerializeField]简单直接。第四个坑重写Equals和GetHashCode时忘了调base。Unity的Object重写过这两个方法子类如果再重写不调base可能导致对象比较行为异常。不过日常开发中很少重写这两个方法知道有这回事就行。5. 从继承到组合的演进思路写到这里必须说清楚继承是工具不是信仰。Unity项目里继承最适合的场景是“同一类对象的公共逻辑抽取”比如所有敌人共享的受伤、死亡、寻路逻辑。但跨类别的能力共享接口和组合是更好的选择。我现在的习惯是先想清楚对象之间是不是真正的“is-a”关系。如果是用继承但层级控制在三层以内。如果不是优先考虑接口定义契约用组合拼装能力。Unity的组件系统本身就是组合模式的实现顺应引擎设计比对抗它要省力得多。举个实际例子一个Boss既有近战攻击又有远程攻击还能召唤小怪。用继承怎么做Boss继承EnemyBase然后近战逻辑复制一份、远程逻辑复制一份、召唤逻辑再写一份。用组合怎么做MeleeAttack组件、RangedAttack组件、SummonAbility组件分别挂在Boss上每个组件独立负责一块能力。Boss的代码只需要协调这些组件不涉及任何继承。后者明显更清晰也更容易复用——小怪需要远程攻击时直接挂RangedAttack组件就行不用改继承体系。继承和组合不是对立的而是互补的。基类负责定义“是什么”组件负责提供“能做什么”。把这两者用对地方代码的可维护性会有质的提升。我在实际项目里见过太多因为滥用继承导致的“上帝基类”——一个基类几千行什么功能都往里塞最后没人敢改。这种代码的根源就是把组合该做的事硬塞给了继承。最后分享一个判断标准如果你发现子类里有大量方法直接return base.XXX()或者干脆把父类方法整个复制过来改两行说明继承关系可能不成立应该考虑拆成组合。继承应该让子类代码更少而不是更多。子类代码比父类还长的继承基本都是有问题的。