Files
gold_dolphin/unity/Docs/解耦架构重构方案.md
T
2026-07-07 03:34:56 +08:00

24 KiB
Raw Blame History

解耦架构重构方案(Unity Architecture Refactoring Plan

适用范围:gold_dolphin/unity 项目全局 设计准则:ScriptableObject 优先、单一职责、零 GameObject.Find、无静态单例调用方 配套代码:Assets/Architecture/(已落地,增量、零侵入,未改动任何现有文件) 版本:v1.1 —— 已根据同行评审(2 份意见)修正严重度、行号与遗漏点,见 §1.3


0. 执行摘要

当前项目已经出现典型的耦合腐烂信号7 个跨场景单例、GameObject.Find/FindWithTag/FindObjectsByType 散落在运行时代码中(含 GameHUD.Update_playerHealth == null 时的条件性 FindWithTag 补救)、UI 通过反射读取私有字段来刷新(GameHUDEnemyHealthBar 两处)、God ClassGameManager/HealthSystem)身兼数职、系统间通过 static Action 事件 + 单例方法互相调用。

这套结构在小规模时跑得动,但已直接导致你正在经历的维护困难:

  • 场景重载后引用丢失(GameHUDUpdate_playerHealth == null 时才 FindWithTag 补救——属于条件性回退,并非每帧都查,但本质仍是反模式)
  • 改一个类名/字段名就引发连锁编译或静默运行时失效(反射那几处)
  • 新增系统必须知道"谁是谁"(单例类名、Player 标签),无法并行开发

目标架构:以 ScriptableObject 为"通信总线",所有共享状态放进 Variable 资产、所有跨系统消息走 Event 通道、所有实体集合用 RuntimeSet 跟踪。MonoBehaviour 只做"执行者",且永不直接引用彼此的实现类——它们只认 SO 资产引用。


1. 现状诊断(Architecture Audit

1.1 反模式清单(含证据)

反模式 位置 具体表现
跨场景单例 GameManager : PersistentSingletonAudioManagerScoreManagerSceneLoaderTimeControllerEnemyManager/LightMaskSystemstatic Instance 调用方写死 XxxManager.Instance,系统间强耦合,且 DontDestroyOnLoad 引发跨场景引用生命周期问题
GameObject.Find / FindWithTag / FindObjectsByType GameManager.cs:81,145,150,165GameHUD.cs:49,76,104-115EnemyAI.cs:112MainMenuUIController.cs:55,61,66,143GameLostOverlay.cs:85,273BoundaryWallGenerator.cs:171SceneInteraction.cs:49SpawnPointGenerator.cs:263,293 依赖场景层级、对象名、标签。GameHUD.UpdateGameHUD.cs:104)仅在 _playerHealth == null 时才 FindWithTag("Player") 补救——条件性回退,非每帧GameHUD.cs:76StartFindObjectOfType<DamageFlashOverlay>() 未发现则动态 new GameObject 创建,增加 Start 复杂度;EnemyAI.cs:112FindWithTag 仅在 Start 执行一次并缓存 _playerHealth,与 GameHUD 的高频补救需区分;MainMenuUIController.cs:55FindObjectsByType<StoryPVPlayer>:61/66GameObject.Find("SettingsPanel"/"CreditsPanel")FindButton()GameObject.Find(name) 属主菜单场景的对象名耦合
反射读取私有字段 GameHUD.cs:150-152health)、170-176rollCooldown/lastRollTime)、183-189cooldown/_lastEchoTime)、197-199lantern cooldown);EnemyHealthBar.cs:50-53(读 maxHealth)、73-75(读 health typeof(X).GetField("xxx", NonPublic|Instance) 读私有字段;字段一改名即静默失效GameHUDEnemyHealthBar 均在每帧更新(EnemyHealthBarLateUpdate)里读 health/maxHealth,既有性能开销,又对字段改名极度脆弱(初稿仅列 GameHUDEnemyHealthBar 已补)
God Class GameManager.cs(约 295 行,6 项职责:状态/过场/敌人控制/分数/场景/输入分发)、HealthSystem.cs(约 216 行,健康+受伤+无敌+死亡+溶解动画+粒子) 一处改动牵动全局,难以测试
轻度耦合(非 God Class PlayerControllerPlayer.cs,约 112 行,仅移动+翻滚;RequireComponent3 个 服务系统:HealthSystem/SwordAttack、ProjectileShooter、SpiritLanternSystem 体量可控,主要问题是 RequireComponent 把子系统的实现类硬绑到玩家预制体上;可在 P4 轻量剥离,不必列为重度重构对象(初稿误归为 God Class,已降级)
static 事件伪总线 GameManager.onGameOver/onGameWinGameManager.cs:20 声明、:61 触发)、HealthSystem.onPlayerDamagedEchoSystem.OnEchoReleasedScoreManager.onScoreChanged/onScoreSettled 全局无类型资产、不可在 Inspector 配置、跨场景订阅清理脆弱(GameHUD 在 Update 里二次订阅补洞);onGameWinGameResultScreen.cs:63,69 监听(初稿漏列)
直接跨实体耦合 EnemyAI.cs:116 缓存 _playerHealth = player.GetComponent<HealthSystem>():232 直接 _playerHealth.Damage() 敌人紧耦合玩家实现类;玩家死亡时 HealthSystem.cs:82 又反向调 GameManager.GameOver() —— 双向硬依赖(注:初稿误写为 :230,实际调用在 :232
直接单例调用(覆盖面比初稿更广) ScorePickup.cs:39SoulDrop.cs:110 直接 ScoreManager.Instance.AddScore(...)ScoringUIController.cs 同时依赖 ScoreManager.Instance/AudioManager.Instance/SceneLoader.InstanceMainMenuUIController.cs:206,216SceneLoader.Instance.LoadGameplayScene()GameLostOverlay.cs:246SceneLoader.Instance.LoadMainMenuScene() 调用方写死单例类名,跨系统强耦合;SoulDrop.cs 甚至在无 ScoreManagernew GameObject 自建——单例缺失的运行时补洞,比显式依赖更危险
Resources.Load AudioManager.cs:121StoryPVPlayer.cs:141 资源路径硬编码,无法做分包/热更,构建裁剪风险
Shader.Find(同类硬编码) GameLostOverlay.cs:358 Shader.Find("GameFramework/UI/WaterRippleFade") Resources.Load 同属"字符串硬编码资源路径",构建裁剪 / 资源改名即失效,归入 P5 一并治理;GameLostOverlay.cs:85 还用 FindObjectOfType<GameLostOverlay>() 做单体自检查

1.2 耦合地图(谁通过什么找谁)

[EnemyAI] --FindWithTag("Player")【仅 Start 一次 :112】--> [Player]
[EnemyAI] --GetComponent<HealthSystem>().Damage()【:232】--> [Player.HealthSystem]
[GameHUD] --FindWithTag【_playerHealth==null 时 :104】+ 反射--> [Player] 各子系统
[GameHUD] --FindObjectOfType<DamageFlashOverlay> :76 + 动态 new GameObject--> [DamageFlashOverlay]
[GameHUD] --ScoreManager.Instance + onScoreChanged--> [ScoreManager]
[EnemyHealthBar] --反射读 HealthSystem 私有字段 maxHealth/health :50/73--> [HealthSystem]
[GameManager] --FindObjectsOfType<EnemyAI>()--> 所有敌人
[GameManager] --FindWithTag + GetComponent 逐个禁用--> [Player] 子组件
[HealthSystem] --GameManager.GameOver()--> [GameManager]
[HealthSystem/Echo/Player/Lantern] --AudioManager.Instance.PlayX()--> [AudioManager]
[ScorePickup]/[SoulDrop] --ScoreManager.Instance.AddScore()--> [ScoreManager]
[ScoringUIController] --ScoreManager.Instance / AudioManager.Instance / SceneLoader.Instance--> [三服务]
[MainMenuUIController] --SceneLoader.Instance.LoadGameplayScene() :206/216--> [SceneLoader]
[MainMenuUIController] --GameObject.Find / FindObjectsByType【对象名耦合】--> [主菜单场景物体]
[GameLostOverlay] --SceneLoader.Instance.LoadMainMenuScene() :246--> [SceneLoader]
[GameLostOverlay] --Shader.Find :358 + FindObjectOfType 自检查 :85--> [Shader / 自身]
[GameResultScreen] --onGameWin / onGameOver 静态事件--> [GameManager]
[各系统] --static event--> 订阅方(无资产、无可见性)

根因:所有系统都把"另一个具体类/具体场景物体"当成了通信媒介,而不是把"消息"和"状态"抽象成独立资产。

1.3 评审补充(peer-review 修正记录)

本方案经 2 份同行评审,以下为已采纳的修正,供后续读者对照:

  • 严重度下调:初稿将 GameHUDFindWithTag 描述为"每帧"。经核实为 _playerHealth == null 时的条件性回退GameHUD.cs:104-115),已下调严重度;但本质仍是反模式。
  • 降级 PlayerController:从 God Class 降级为"轻度耦合"(约 112 行、仅移动+翻滚、RequireComponent 为 3 个服务系统,非初稿所述的 4 个)。
  • 行号修正EnemyAI.Damage() 调用在 :232(非初稿写的 :230);其 FindWithTag 仅在 Start 一次(:112),与 GameHUD 需区分。
  • 补充遗漏点EnemyHealthBar 反射读私有字段;GameHUD StartFindObjectOfType<DamageFlashOverlay>onGameWin 静态事件被 GameResultScreen 监听;GameLostOverlayShader.FindMainMenuUIController 的对象名耦合;ScorePickup/SoulDrop/ScoringUIController/MainMenuUIController/GameLostOverlayScoreManager/SceneLoader 的直接单例调用线。
  • 配套代码据评审补强TypedGameEvents 已增加 StringEventListener(初稿声称有但未实现);RuntimeSet 已增加泛型 RuntimeSetRegistrar<T>(强类型集合用),详见 §2.3。

2. 目标架构(Target Architecture

2.1 分层

┌─────────────────────────────────────────────────────────────┐
│  L0  SO 通信总线(项目级资产,跨场景天然存活,无 MonoBehaviour │
│   · Event 通道:GameEvent / IntEvent / Vector3Event ...       │
│   · Variable 资产:FloatVariable / IntVariable / BoolVariable  │
│   · RuntimeSet<T>:玩家集 / 敌人集 / 灵灯集                    │
└───────────────┬───────────────────────────┬─────────────────┘
                │ 监听/触发(Inspector 引用) │ 注册/读取
        ┌───────▼────────┐           ┌───────▼────────┐
        │ L1 系统服务      │           │ L2 实体         │
        │ (MonoBehaviour)  │           │ Player/Enemy/   │
        │ Audio/Scene/Time │           │ Lantern         │
        │ Spawn/Transition │           │                 │
        └───────┬────────┘           └───────┬────────┘
                │ 触发事件                    │ 触发事件
                └─────────────┬──────────────┘
                      ┌───────▼────────┐
                      │ L3 表现层        │
                      │ HUD / Overlay   │
                      │ 只读 Variable +  │
                      │ 监听 Event       │
                      └─────────────────┘

关键变化L1/L2/L3 之间没有任何直接的类引用。它们只通过 L0 的 SO 资产通信。谁触发了什么、谁在监听,全部在 Inspector 里可见、可配、可审计。

2.2 四条铁律

  1. 调用方不碰单例:需要某个服务时,不是 AudioManager.Instance.PlayX(),而是 audioEvent.Raise(clip)。服务类自己订阅该 Event 资产。
  2. 生产代码零 GameObject.Find:找实体走 RuntimeSet(遍历集合);找玩家走 PlayerRuntimeSet(玩家生成时注册)。
  3. 共享状态进 Variable:血量/得分/魂灵数/冷却剩余 全是 SO 资产,UI 订阅 OnValueChanged,不再反射、不再逐帧 Find。
  4. 一个 MonoBehaviour 一件事GameManager 拆成状态机 + 过场 + 敌人控制;HealthSystem 拆成健康逻辑 + 死亡表现;PlayerController 仅剥离 RequireComponent 对子系统实现类的硬绑。

2.3 已落地的基础原语(Assets/Architecture/

文件 作用
Core/GameEvent.cs 无参事件通道 + GameEventListenerInspector 配置响应)
Core/TypedGameEvents.cs 泛型 GameEvent<T> + Int/Float/Vector3/String/GameObject 五类通道;监听器组件 Int/Float/Vector3/GameObject/StringEventListener(初稿遗漏的 StringEventListener 已补)
Variables/FloatVariable.cs IntVariable.cs BoolVariable.cs Vector3Variable.cs 共享变量资产,带 OnValueChanged 事件与 ContextMenu 重置
RuntimeSets/RuntimeSet.cs 泛型 RuntimeSet<T> + TransformRuntimeSet + RuntimeSetRegistrarTransform 集合自动注册)+ 泛型 RuntimeSetRegistrar<T>(强类型集合如 EnemyRuntimeSet 用,领域文件夹写一行具体子类即可)
Editor/VariableDrawer.cs 变量资产在 Inspector 实时显示当前值(含 Play 模式)

配套资产(在 Unity 里右键 Create)示例: Assets/ScriptableObjects/Events/OnPlayerDamaged.asset.../Variables/PlayerHealth.asset.../RuntimeSets/Enemies.asset

关于 RuntimeSetRegistrar 的限制说明(评审点):初版仅 RuntimeSetRegistrar 支持 TransformRuntimeSet。若 P2 建立强类型集合(如 EnemyRuntimeSet : RuntimeSet<EnemyAI>),应使用泛型 RuntimeSetRegistrar<EnemyAI> 的闭包子类:

// 放在 enemy 文件夹,领域专用
public class EnemySetRegistrar : RuntimeSetRegistrar<EnemyAI> { }

这样敌人生成时自动 GetComponent<EnemyAI>() 并加入集合,无需退回 Transform 集合再手动转型。


3. 逐条改造映射(Before → After

3.1 玩家引用:FindWithTag("Player")PlayerRuntimeSet

// ❌ 现有
var player = GameObject.FindWithTag("Player");
_playerHealth = player.GetComponent<HealthSystem>();

// ✅ 新增 PlayerRuntimeSet : RuntimeSet<Transform>(放在 enemy/或 player 文件夹)
// 玩家预制体挂 RuntimeSetRegistrar 并指向该 Set
// 任意系统:
public class EnemyAI : MonoBehaviour
{
    [SerializeField] private TransformRuntimeSet _players; // 拖入 Player Set 资产
    private Transform _player;
    private void Awake() => _player = _players.Items.Count > 0 ? _players.Items[0] : null;
}

敌人不再假设"场景里一定有个带 Player 标签的物体",也无需每帧 Find。若用强类型集合(如 EnemyRuntimeSet : RuntimeSet<EnemyAI>),挂 RuntimeSetRegistrar<EnemyAI> 的闭包子类即可,无需退回 Transform 集合再转型。

3.2 敌人受伤:紧耦合 HealthSystem.Damage() → 事件 / 引用集

EnemyAI 仍可直接持有玩家 HealthSystem 引用(在注册时通过 Set 取得),但不建议跨实体直接调用。更干净的做法:伤害走"玩家健康"由玩家自己管理,敌人只负责"发起攻击意图"——例如提升一个 FloatVariable 或触发 DamageEvent。本方案第一步先解决查找问题,第二步再抽伤害意图。

3.3 玩家血量显示:反射 → IntVariable + 订阅

// ❌ 现有:GameHUD.Update 里 typeof(HealthSystem).GetField("health", NonPublic|Instance)
// ✅ HealthSystem 写入 SO 变量;GameHUD 订阅
[SerializeField] private IntVariable _playerHealth;   // 拖入 PlayerHealth.asset
private void OnEnable() { _playerHealth.OnValueChanged += UpdateLifeIcons; UpdateLifeIcons(_playerHealth.Value); }
private void OnDisable() => _playerHealth.OnValueChanged -= UpdateLifeIcons;
// UpdateLifeIcons 只读 _playerHealth.Value,零反射、零 Find

EnemyHealthBarEnemyHealthBar.cs:50-53,73-75)同理:让 HealthSystem 暴露 maxHealth/healthIntVariable,血条订阅刷新,彻底消灭两处反射。

3.4 游戏结束:static onGameOverGameOverEvent 资产

// ❌ HealthSystem.cs:82  →  GameManager.GameOver()  (反向硬依赖 + 单例)
// ✅ HealthSystem 只管健康;血量归零时触发事件资产
[SerializeField] private GameEvent _onPlayerDied;
// 在 health<=0 且 isPlayer 时:_onPlayerDied.Raise();
// GameOverTransition 组件监听该 Event → 播放过场 → 再 Raise LoadSceneEvent

GameManager 拆为:GameStateController(持有 GameState 枚举变量)、GameOverTransitionVictoryTransitionEnemyPauseOnGameOver(监听事件,遍历 EnemyRuntimeSet 禁用,替代 FindObjectsOfType<EnemyAI>)。onGameWin 同样改为 VictoryEvent 资产,GameResultScreen 改为监听该资产事件。

3.5 音频:AudioManager.Instance.PlayX()AudioEvent 通道

// ❌ 各系统:AudioManager.Instance.PlaySFXFromResources("SFX/Bell", 0.9f);
// ✅ 定义 AudioEvent : GameEvent<AudioClip> 资产;系统 RaiseAudioManager 订阅并播放
[SerializeField] private AudioEvent _bellSfx;
// 播放处:_bellSfx.Raise(bellClip);   // clip 用 SO 引用或 Addressables,不再 Resources.Load

AudioManager 的 MonoBehaviour 保留(它需要持有 AudioSource、跑播放逻辑),但外部不再通过单例调它——它只是某个 Event 资产的订阅者。

3.6 分数:ScoreManager.onScoreChangedScore IntVariable

分数本质是"共享值",用 IntVariable 比事件更自然。HUD 订阅 OnValueChanged 刷新;排行榜逻辑留在 ScoreManager(改为写该 Variable)。ScorePickup/SoulDrop 不再 ScoreManager.Instance.AddScore(...),改为对 Score IntVariable ApplyChange(...);缺失时也不会运行时 new GameObject 自建。

3.7 场景切换 / 暂停:SceneLoader.Load() / TimeController.Pause() → Event 资产

UI 按钮只 loadSceneEvent.Raise("Gameplay") / pauseEvent.Raise()SceneLoaderTimeController 退化为事件订阅者(仍保留 MonoBehaviour 负责协程与 Time.timeScale)。场景名放进 SO 配置(SceneList 资产),消除 "Gameplay"/"Scoring" 魔法字符串。MainMenuUIController/GameLostOverlay/ScoringUIControllerSceneLoader.Instance 调用全部改走事件通道。

3.8 回声:EchoSystem.OnEchoReleasedVector3Event 资产

EnemyAI.OnBell 改为订阅 EchoEvent(带位置载荷)的 Register(Action<Vector3>),彻底解耦对 EchoSystem 类的依赖。

3.9 着色器引用:Shader.Find → 资产引用(P5

Shader.FindResources.Load 同类,归入 P5。实际项目中共 5 处,应于 Inspector 中将 shader 作为 Shader 字段拖入(构建管线可静态识别,避免 shader stripping 将其剔除导致运行时 fallback 成粉色错误着色器):

  • GameLostOverlay.cs:358Shader.Find("GameFramework/UI/WaterRippleFade")
  • EchoSystem.cs:140Shader.Find("IndianOcean/EchoRing")已落地 ringShader 序列化字段
  • GroundBuilder.cs:180Shader.Find("IndianOcean/AbyssEdgeGlow")
  • CliffWallBuilder.cs:108Shader.Find("IndianOcean/AbyssEdgeGlow")
  • GroundClipTool.cs:19Shader.Find("Custom/SpriteWithGroundClip")Editor 脚本,运行时不进包,可保留,但建议同样改字段引用

3.10 变量跨局重置(VariableRegistryP0/P1 必做)

SO 变量(IntVariable/FloatVariable 等)在 Editor 下是跨 Play Mode 域重载持久化的。若 Play Mode 退出时 PlayerHealth.Value 残留 0,下次启动会直接触发死亡——这是 SO 架构常见坑。 已在 Assets/Architecture/Variables/VariableRegistry.cs 解决:

  • 变量实现 IVariable 接口,含 ResetToDefault()
  • VariableRegistry[RuntimeInitializeOnLoadMethod(BeforeSceneLoad)] 中于每次进入 Play Mode 时统一 ResetToDefault()
  • AssetBootstrap 生成变量时同时写入 _value_defaultValue,保证重置目标正确。 (注:该回调在跨场景加载时只触发一次,故分数等跨场景共享状态可保留。)

4. 分阶段迁移路线图(Phased Roadmap

每个阶段独立、可回滚、可编译,不要求一次性大改。

阶段 目标 关键动作 风险 验证
P0 地基 引入通信总线 已落地 Assets/Architecture/,在 Unity 创建首批 Event/Variable/RuntimeSet 资产 极低(增量) 编译通过,无现有代码改动
P1 事件去静态化 消灭 static Action 事件 onGameOver/onGameWin/onPlayerDamaged/onScoreChanged/onScoreSettled/OnEchoReleased 改为对应 SO 事件资产,1:1 替换订阅 各触发点行为不变
P2 去掉 Find 消灭 GameObject.Find/FindWithTag/FindObjectsOfType PlayerRuntimeSet/EnemyRuntimeSet/LanternRuntimeSet,预制体挂 RuntimeSetRegistrar(或泛型闭包子类);改写 GameHUD/EnemyAI/GameManager/MainMenuUIController/GameLostOverlay/EnemyHealthBar 的查找与反射 Play 模式跑通;GameHUD 不再在 Update 里 FindEnemyHealthBar 反射消除
P3 去单例调用方 调用方不再 XxxManager.Instance AudioManager/SceneLoader/TimeController/ScoreManager 改为事件订阅者;调用方改 RaiseScorePickup/SoulDrop/ScoringUIController 改走 Variable/Event 全功能回归测试
P4 拆分 God Class 单一职责 GameManager→状态机+过场+敌人控制;HealthSystem→健康+死亡表现;PlayerController 仅轻度重构(剥离 RequireComponent 对子系统实现类的硬绑,改由 SO 引用/事件驱动,非重度重写) 中高 单元/手动测试每个拆分组件
P5 资源与配置 Resources.Load/Shader.Find 与魔法串 音频/视频→SerializeField 直接引用(AudioClip/VideoClip 拖到 SO/组件)不引入 Addressables(项目体量下工程复杂度过高);Shader.Find 共 5 处→序列化 Shader 字段引用(GameLostOverlay:358 / EchoSystem:140 已有 ringShader / GroundBuilder:180 / CliffWallBuilder:108 / GroundClipTool:19 为 Editor 脚本);场景名/标签进 SO 配置 构建后资源不缺失
P6 工具与守门 防回归 VariableDrawer(已落地);加构建期校验脚本(扫描生产代码 GameObject.Find 报错);设计师 SO 配置文档 CI / 构建时报错拦截

建议节奏:P0→P1→P2 可在一个迭代内完成(收益最大、风险最低);P3/P4 按系统逐个推进;P5/P6 与功能开发并行。


5. 设计师赋能(Designer Empowerment

  • 所有 SO 均已 [CreateAssetMenu],策划/美术右键即可创建事件、变量、集合,无需写代码
  • VariableDrawer 让 Inspector 实时显示变量当前值(含 Play 模式),数值调试不再开脚本。
  • 事件连线在 Inspector 可见:谁监听 OnPlayerDamaged、谁触发 LoadSceneEvent 一目了然,便于排查"为什么没反应"。
  • 建议建立 Assets/ScriptableObjects/{Events,Variables,RuntimeSets} 目录规约,按领域分子文件夹。

6. 风险与回滚

  • 所有新增代码位于 Assets/Architecture/未触碰任何现有文件,可整体删除回滚。
  • 迁移过程保持"旧接口可用、新接口并行",每个阶段独立验证,避免大爆炸式重写。
  • 事件通道为引用语义:误删资产会在 Inspector 显示缺失引用(编译期可查),不会静默失效——比反射/单例安全得多。

7. 下一步

  1. 在 Unity 中创建首批资产:PlayerHealth(Int)、Score(Int)、OnPlayerDied(GameEvent)、Victory(GameEvent)、Echo(Vector3Event)、Enemies(EnemyRuntimeSet)、Players(TransformRuntimeSet)。
  2. 选一个最小系统(建议从 GameHUDPlayerHealth 变量 + 消灭 EnemyHealthBar 反射开始)验证 P1+P2 链路。
  3. 跑通后按路线图逐阶段推进;需要我直接改造某个具体系统时,指认文件即可。