LOADING

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

面向对象设计原则系列(2):单一职责原则(SRP)


一、SRP 定义与核心思想

“A class should have only one reason to change.”
“一个类应该只有一个引起它变化的原因。”
——Robert C. Martin

  • 职责(Responsibility):面向业务或技术关注点的高层抽象,如“处理玩家输入”、“控制角色移动”、“更新 UI”。
  • 原因(Reason to change):如果需求变更会导致类修改的动机,比如“UI 样式调整”或“移动算法优化”应属于不同职责。

二、为什么需要SRP

  1. 降低耦合
    • 当一个类只负责一件事,内部变更不会牵连其它模块;
  2. 提升内聚
    • 高内聚的组件更易理解、测试、复用;
  3. 支持开闭
    • 新需求时,只需新增或替换某个职责组件,不改动已有代码;
  4. 便于分工
    • 团队协作时,每人可聚焦在自己负责的职责组件上;
  5. 增强可测试性
    • 面向接口/单一职责设计,单元测试无需 Mock 整个“巨型”组件。

三、在 Unity 中识别与拆分职责

  1. 功能卡片法

    • 列出 MonoBehaviour 中的所有方法、字段:输入、移动、动画、物理、血量、UI、音效……
    • 逐项问:“如果要改这里,改动原因是什么?”
      • 修改移动逻辑 → Movement
      • 修改动画逻辑 → Animation
      • 修改 UI 样式 → UIController
  2. 职责映射

    职责类别 典型组件名 说明
    输入采集 PlayerInputHandler 只处理按键/触摸/手柄输入
    运动控制 PlayerMovement 只处理位置/速度/跳跃等逻辑
    视觉表现 PlayerAnimator 只处理 Animator 参数映射
    状态管理 PlayerHealth 只处理血量、死亡判断
    界面交互 PlayerUI 只处理 UI 更新
  3. 粒度把控

    • 粗粒度:按大模块拆分,组件不超过 5 个职责;
    • 细粒度:进一步拆出子职责,如地面/空中运动;
    • 平衡:过度拆分会导致 GameObject 上组件过多,维护成本上升。

四、反模式示例:违背 SRP 的 PlayerController

public class PlayerController : MonoBehaviour {
    public float speed = 5f;
    public Animator animator;
    public Image healthBar;

    private float health = 100f;

    void Update() {
        // ① 输入
        float h = Input.GetAxis("Horizontal");
        float v = Input.GetAxis("Vertical");

        // ② 移动
        Vector3 dir = new Vector3(h, 0, v).normalized;
        transform.Translate(dir * speed * Time.deltaTime, Space.World);

        // ③ 动画
        animator.SetFloat("Speed", dir.magnitude);

        // ④ 生命值检测
        if (health <= 0) Die();

        // ⑤ UI 更新
        healthBar.fillAmount = health / 100f;
    }

    public void TakeDamage(float d) {
        health = Mathf.Max(0, health - d);
    }

    void Die() {
        Debug.Log("Player died");
        // … 播放死亡效果、重载场景
    }
}

问题

  • 输入、移动、动画、血量、UI 更新混杂在同一个组件;
  • 任意一项需求改动都可能影响其他功能;
  • 单元测试时必须 Mock 整个组件的所有依赖。

五、SRP 重构实战

PlayerController 拆分为五个职责单一的组件:

5.1 PlayerInputHandler

[RequireComponent(typeof(PlayerMovement))]
public class PlayerInputHandler : MonoBehaviour {
    public Vector3 MoveDirection { get; private set; }

    void Update() {
        float h = Input.GetAxis("Horizontal");
        float v = Input.GetAxis("Vertical");
        MoveDirection = new Vector3(h, 0, v).normalized;
    }
}

5.2 PlayerMovement

[RequireComponent(typeof(CharacterController))]
public class PlayerMovement : MonoBehaviour {
    public float speed = 5f;
    PlayerInputHandler input;
    CharacterController cc;

    void Awake() {
        input = GetComponent<PlayerInputHandler>();
        cc    = GetComponent<CharacterController>();
    }

    void Update() {
        Vector3 dir = input.MoveDirection;
        cc.Move(dir * speed * Time.deltaTime);
    }
}

5.3 PlayerAnimator

[RequireComponent(typeof(Animator))]
public class PlayerAnimator : MonoBehaviour {
    public PlayerMovement movement;
    Animator animator;

    void Awake() {
        animator = GetComponent<Animator>();
    }

    void Update() {
        float speed = movement.GetComponent<PlayerInputHandler>()
                              .MoveDirection.magnitude;
        animator.SetFloat("Speed", speed);
    }
}

5.4 PlayerHealth

public class PlayerHealth : MonoBehaviour {
    public float maxHealth = 100f;
    public UnityEvent onDeath;
    float current;

    void Awake() {
        current = maxHealth;
    }

    public void TakeDamage(float d) {
        current = Mathf.Max(0, current - d);
        if (current <= 0) onDeath.Invoke();
    }

    public float GetHealth01() => current / maxHealth;
}

5.5 PlayerUI

public class PlayerUI : MonoBehaviour {
    public PlayerHealth healthModel;
    public Image healthBarImage;

    void Update() {
        healthBarImage.fillAmount = healthModel.GetHealth01();
    }
}

六、工具

6.1 内聚度量:LCOM (Lack of Cohesion of Methods)

  • LCOM1:不共享字段的方法对数;

  • LCOM5(常用):
    $$
    \mathrm{LCOM} = 1 - \frac{\sum_i|M_i|}{M \times F}
    $$

    • $M$:方法数,$F$:字段数,$M_i$:访问字段 $i$ 的方法数。

6.2 静态分析工具

  • Rider / ReSharper:显示类内方法与字段的依赖,提示“提炼类”重构;
  • SonarQube:监控 LCOM、循环复杂度、代码重复度;
  • Unity Code Analysis:VSCode/Visual Studio 插件检测大型 MonoBehaviour 警告。

七、常见误区

  1. SRP ≠ 方法最少:只要方法围绕同一职责,多方法也符合 SRP;
  2. 职责不能过细:不要把“日志写文件”“日志写网络”再拆成两个组件;
  3. 拆分与性能:运行时 GetComponent 过多可能影响启动性能,可在 Awake 缓存引用;
  4. SRP 与微服务:在微服务架构中,SRP 可扩展为“一个服务只提供一种业务能力”。

八、小结

  • 自上而下识别职责:先按业务流程绘制时序图,再映射到组件;
  • 按接口编程:必要时为职责组件定义接口,配合依赖注入;
  • 定期重构:借助静态分析工具监测内聚度与耦合度;
  • 平衡拆分:避免过度拆分带来的管理成本。