LOADING

加载过慢请开启缓存 浏览器默认开启

面向对象设计原则系列(3):开闭原则(OCP)


一、OCP 定义与核心思想

“Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification.”

“软件实体(类、模块、函数等)应该对扩展开放,对修改关闭。”
——Bertrand Meyer

  • 对扩展开放:在不修改既有代码的情况下,能够增加新的行为或功能;
  • 对修改关闭:已有的经过验证和测试的代码一旦发布,就不应再被直接改动,以减少回归风险。

二、为什么 需要OCP

  1. 减少回归风险
    每次修改都可能破坏原有功能。遵循 OCP,新增功能只需新增类或资源,不触碰旧代码。
  2. 提升可维护性
    系统随需求增长而平滑演化,核心模块稳定、边缘功能可自由扩展。
  3. 支持团队并行开发
    不同开发者能在不冲突的情况下各自新增扩展,降低代码合并痛苦。
  4. 促进模块化设计
    强制将变化点抽象为接口或脚本化配置(ScriptableObject),驱动架构更清晰。

三、Unity 中常见的 OCP 违反场景

  • 武器伤害类型

    switch (weapon.Type) {
      case WeaponType.Sword:   damage = baseDamage; break;
      case WeaponType.Fireball: damage = baseDamage * 1.5f; break;
      case WeaponType.Ice:     damage = baseDamage * 0.8f; break;
    }
    

每新增一种武器,都要修改这个 switch

  • 技能效果叠加
if (hasBurn) ApplyBurn();
if (hasFreeze) ApplyFreeze();
if (hasPoison) ApplyPoison();

新增效果时需要改动组件。

  • AI 行为分支
if (state == AIState.Patrol) Patrol();
else if (state == AIState.Chase) Chase();
else if (state == AIState.Attack) Attack();

每新增状态,核心 Update() 函数必须变动。

四、OCP 重构策略与常用模式

4.1 策略模式(Strategy Pattern)

将可变行为封装成策略类,通过接口调用而非 switch 分支。

public interface IDamageStrategy {
    float CalculateDamage(Weapon w, float baseDmg);
}

public class SwordDamage : IDamageStrategy {
    public float CalculateDamage(Weapon w, float baseDmg) => baseDmg;
}
public class FireballDamage : IDamageStrategy {
    public float CalculateDamage(Weapon w, float baseDmg) => baseDmg * 1.5f;
}
// … 新增其他策略只需新增类

[RequireComponent(typeof(Weapon))]
public class DamageCalculator : MonoBehaviour {
    IDamageStrategy strategy;
    Weapon weapon;

    void Awake() {
        weapon = GetComponent<Weapon>();
        // 可以通过 DI、Factory 或 ScriptableObject 配置
        strategy = StrategyFactory.Get(weapon.Type);
    }

    public float GetDamage(float baseDmg) {
        return strategy.CalculateDamage(weapon, baseDmg);
    }
}

4.2 装饰器模式(Decorator Pattern)

对已有行为进行动态“打补丁”,而不修改原始类。

public interface IAttack {
    void Execute(GameObject target);
}

public class BasicAttack : IAttack {
    public void Execute(GameObject target) {
        // 普通伤害逻辑
    }
}

public abstract class AttackDecorator : IAttack {
    protected IAttack wrappee;
    public AttackDecorator(IAttack atk) { wrappee = atk; }
    public virtual void Execute(GameObject target) {
        wrappee.Execute(target);
    }
}

public class BurnDecorator : AttackDecorator {
    public BurnDecorator(IAttack atk) : base(atk) {}
    public override void Execute(GameObject target) {
        base.Execute(target);
        // 附加燃烧效果
    }
}

// 运行时组合
IAttack atk = new BasicAttack();
if (hasBurn)   atk = new BurnDecorator(atk);
if (hasFreeze) atk = new FreezeDecorator(atk);
atk.Execute(enemy);

4.3 工厂模式 & 依赖注入(Factory & DI)

将创建逻辑从使用逻辑中分离,新增类型时只修改 Factory。

public static class StrategyFactory {
    public static IDamageStrategy Get(WeaponType type) {
        switch (type) {
            case WeaponType.Sword:    return new SwordDamage();
            case WeaponType.Fireball: return new FireballDamage();
            // 新增类型时只改这里一次
            default: return new SwordDamage();
        }
    }
}

或使用容器自动注册,消除 switch 完全依赖反射与配置。

4.4 ScriptableObject 方案

利用 Unity 的资源驱动特性,将扩展点放在可编辑资产中。

// 定义 ScriptableObject
public abstract class DamageConfig : ScriptableObject {
    public abstract float Calc(float baseDmg);
}

[CreateAssetMenu("Damage/Sword")]
public class SwordConfig : DamageConfig {
    public override float Calc(float baseDmg) => baseDmg;
}

[CreateAssetMenu("Damage/Fireball")]
public class FireballConfig : DamageConfig {
    public float multiplier = 1.5f;
    public override float Calc(float baseDmg) => baseDmg * multiplier;
}

// 使用方式
public class Weapon : MonoBehaviour {
    public DamageConfig damageConfig;
    public float baseDamage = 10f;
    public float Damage => damageConfig.Calc(baseDamage);
}
  • 新增类型:只需创建新的 DamageConfig 资产,无需触碰代码;
  • 配置化:可在 Inspector 调整参数,极大提升迭代效率。

五、Unity 具体示例:武器系统重构

  1. 反例:在 Weapon 内部用 switch 分支计算伤害

    public enum WeaponType {
        Sword,
        Fireball,
        Ice
    }
    
    public class Weapon : MonoBehaviour {
        // 1. 用一个枚举记录武器类型
        public WeaponType type;
    
        // 2. 基础伤害值
        public float baseDamage = 10f;
    
        // 3. 根据 type 分支计算不同的伤害
        public float GetDamage() {
            switch (type) {
                case WeaponType.Sword:
                    // 近战武器,直接返回基础伤害
                    return baseDamage;
    
                case WeaponType.Fireball:
                    // 火球法术,附加 50% 伤害
                    return baseDamage * 1.5f;
    
                case WeaponType.Ice:
                    // 冰霜法术,降低 20% 伤害
                    return baseDamage * 0.8f;
    
                default:
                    // 如果忘记处理某个类型,保证有一个默认值
                    return baseDamage;
            }
        }
    }
    
  2. 策略模式

    • 定义 IDamageStrategy 与若干实现;
    • Weapon 上通过依赖注入或 ScriptableObject 引用策略/配置;
  3. ScriptableObject 驱动

    • 直接将 ScriptableObject 作为“扩展插件”挂载到 Weapon
  4. 工厂注册

    • 利用反射扫描所有 IDamageStrategy 实现,自动注册到容器;
    • 运行时按 type 或配置动态解析,无需 switch

六、工具

  • 静态分析:Rider/Resharper 可检测大型 switch 语句,建议使用策略模式;
  • 架构可视化:NDepend 提供依赖图谱,帮助识别扩展点与变化点;
  • 测试覆盖:为每种策略编写单元测试,无需 Mock 其他分支。

七、注意事项

  1. 过度抽象:每个变化点都抽象策略,易导致类爆炸;
  2. 性能开销:动态策略或装饰器过多时,可能产生额外分配,注意缓存实例;
  3. 配置管理:ScriptableObject 滥用会让项目资产管理混乱,需按命名空间和文件夹组织;
  4. 接口演进:谨慎修改策略接口签名,避免所有实现都需同步改动。

八、小结

  • 识别变化点:先找到项目中频繁改动的代码块或 switch 分支;
  • 抽象行为接口:将变化行为封装为接口或基类;
  • 使用策略/装饰器:在运行时组合或切换行为;
  • ScriptableObject 配置化:让策划/美术也能增删扩展;
  • 自动化注册:结合反射与 DI 容器,彻底消除工厂中的 switch