一、SRP 定义与核心思想
“A class should have only one reason to change.”
“一个类应该只有一个引起它变化的原因。”
——Robert C. Martin
- 职责(Responsibility):面向业务或技术关注点的高层抽象,如“处理玩家输入”、“控制角色移动”、“更新 UI”。
- 原因(Reason to change):如果需求变更会导致类修改的动机,比如“UI 样式调整”或“移动算法优化”应属于不同职责。
二、为什么需要SRP
- 降低耦合
- 当一个类只负责一件事,内部变更不会牵连其它模块;
- 提升内聚
- 高内聚的组件更易理解、测试、复用;
- 支持开闭
- 新需求时,只需新增或替换某个职责组件,不改动已有代码;
- 便于分工
- 团队协作时,每人可聚焦在自己负责的职责组件上;
- 增强可测试性
- 面向接口/单一职责设计,单元测试无需 Mock 整个“巨型”组件。
三、在 Unity 中识别与拆分职责
功能卡片法
- 列出 MonoBehaviour 中的所有方法、字段:输入、移动、动画、物理、血量、UI、音效……
- 逐项问:“如果要改这里,改动原因是什么?”
- 修改移动逻辑 → Movement
- 修改动画逻辑 → Animation
- 修改 UI 样式 → UIController
职责映射
职责类别 典型组件名 说明 输入采集 PlayerInputHandler只处理按键/触摸/手柄输入 运动控制 PlayerMovement只处理位置/速度/跳跃等逻辑 视觉表现 PlayerAnimator只处理 Animator 参数映射 状态管理 PlayerHealth只处理血量、死亡判断 界面交互 PlayerUI只处理 UI 更新 粒度把控
- 粗粒度:按大模块拆分,组件不超过 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 警告。
七、常见误区
- SRP ≠ 方法最少:只要方法围绕同一职责,多方法也符合 SRP;
- 职责不能过细:不要把“日志写文件”“日志写网络”再拆成两个组件;
- 拆分与性能:运行时
GetComponent过多可能影响启动性能,可在Awake缓存引用; - SRP 与微服务:在微服务架构中,SRP 可扩展为“一个服务只提供一种业务能力”。
八、小结
- 自上而下识别职责:先按业务流程绘制时序图,再映射到组件;
- 按接口编程:必要时为职责组件定义接口,配合依赖注入;
- 定期重构:借助静态分析工具监测内聚度与耦合度;
- 平衡拆分:避免过度拆分带来的管理成本。