设计模式这个东西一说起来就像大学课堂里的PPTUML图、抽象类、一堆定义。但等你真正用 C 在 UE4 里写玩法系统时你会发现这些概念根本不是考题而是每天在用的工具。甚至可以说UE4 本身就是一个用设计模式堆起来的大型示例工程。这篇博文我把面向对象设计原则和常用设计模式串起来全部结合 UE4 里的真实场景讲分享给那些刚切入 UE4 C 开发、以及写了几年蓝图想回头补 C 底子的朋友。我先说个总观点在 UE4 里谈设计模式核心不是“某个类叫什么”而是“对象的生命周期和依赖关系怎么管理”。Actor、Component、UObject、GameInstance 这些机制背后全是创建、组合、观察、状态切换这些模式的影子。把这一层想通了你写出来的代码会比满屏 if-else 稳得多也更容易做多人协作和后续扩展。1. 先把地基打牢面向对象设计原则到底在管什么1.1 你其实早就在用设计模式只是没意识到很多 UE4 开发者第一次接触面向对象是从继承 AActor 开始的。你定义一个 AMyCharacter继承自 ACharacter然后又定义 AEnemyCharacter也继承自 ACharacter。看上去很自然但多来几个角色类型就会发现重复代码越来越多公共功能塞在基类里子类互相污染。这时候你其实已经踩到了面向对象设计原则想要解决的问题类之间的依赖方向和职责边界混乱。设计模式并不是让你去套一个“XX模式”的名字而是帮你在类之间建立稳定的沟通方式。UE4 里的 UActorComponent 就是最典型的设计模式产物——组合优于继承。你不必把移动、血量、攻击全部塞进一个 Actor 基类而是拆成组件挂上去需要哪个装哪个。1.2 五大原则在 UE4 里的通俗翻译面向对象领域常讲 SOLID 五大原则我在 UE4 项目里会把它们翻译成这几条实操规则单一职责一个类只干一件事。Actor 不该既管移动又管伤害又管 UI。拆成 Component一个组件只负责一块逻辑。开闭原则对扩展开放对修改关闭。新增一种技能效果时不应该去修改原有的伤害计算函数而是通过新增一个效果类来扩展。里氏替换子类对象应该能无障碍替换父类对象。你用 UGameplayAbility 基类指针调用子类接口时调用方不应该感知到差异。接口隔离不该让一个类被迫依赖它用不到的接口。比如“可被伤害”和“可被治疗”是两种接口不要塞进一个大而全的 ICombatInterface 里。依赖倒置高层模块不应该依赖低层模块两者都应该依赖抽象。游戏玩法层依赖一个 IDamageStrategy 接口而不是直接依赖某个具体伤害公式类。这些原则看起来抽象但放到 UE4 里会非常具体。例如你在蓝图里经常做的“接口”功能其实就是接口隔离原则的引擎化实现。而 UClass 工厂、NewObject 动态创建也是创建型模式的引擎化实现。2. 三大类设计模式在 UE4 里的典型身影2.1 创建型模式NewObject、SpawnActor 这些工厂你别乱用创建型模式解决的是“如何创建对象”的问题。在 UE4 里工厂模式几乎无处不在。你调用 NewObjectUMyObject()、CreateDefaultSubobjectUMyComponent()、GetWorld()-SpawnActorAMyActor(...)本质上都是工厂方法由引擎统一决定如何在正确的 Outer 下分配对象、初始化属性、注册进生命周期管理。这里有个新手容易忽略的点UE4 的对象创建不是裸 new而是必须交给 UObject 体系。你如果直接在类里写 new UMyObject()大概率会出问题因为 UObject 的 GC、序列化、反射全依赖 UClass 元数据。正确做法是使用 NewObject并把 Outer 指定成稳定的对象比如 this。这是我见过最多的运行期崩溃来源之一。单例模式也一样UE4 里有大量全局单例例如 UEngine、UGameInstance、USubsystem。你可以写一个静态全局单例类但在 UObject 体系里更稳妥的做法是继承 UGameInstanceSubsystem 或 UWorldSubsystem让引擎管理它的创建和销毁。如果不这么做你会发现关卡切换后单例对象残留或者访问时拿到一个已被 GC 的悬垂引用。单例不是不能用而是“谁拥有它、谁销毁它”必须清晰。对象池模式也是创建型里非常实战的一种。频繁 SpawnActor 再 DestroyActor 会产生性能抖动。你可以在项目里实现一个通用对象池按 UClass 缓存一批 Actor激活时从池里取回收时隐藏而不是销毁。子弹、敌人特效这些高频对象特别适合这种模式。2.2 结构型模式Component 就是最好的组合示例结构型模式关注的是“类和对象如何组装”。UE4 的 Component 体系把组合模式发挥到了极致。一个 AActor 本身是一个容器组件负责具体能力Actor 只是协调者。你在组件里写移动逻辑、写伤害、写交互然后通过 Actor 向外暴露组合能力这比做一个巨大的基类要灵活得多。装饰器模式可以用在动态附加效果上。比如一个角色身上有多个 Buff你可以用一组 UBuffEffect 对象包裹住基础伤害计算每个 Buff 提交一个加成。这样不用为每种 Buff 组合都创建子类而是动态叠加。适配器模式也很常见。当你在 C 里对接第三方库或者把一套旧系统包装进 UE4 的接口时你可以写一个适配器类把外部 API 翻译成 UE4 的调用方式。比如把某个 C HTTP 库封装成 UWebRequestSubsystem引擎层只面向自己的接口底层随便换。2.3 行为型模式委托、状态机和策略对象行为型模式是玩法开发里最常用的。观察者模式在 UE4 里的直接实现就是委托和动态多播委托。你用 DECLARE_DYNAMIC_MULTICAST_DELEGATE 声明一个事件让 UI、音效、任务系统各自订阅当血量变化时广播。这样伤害系统的逻辑不会反向依赖 UI职责边界非常干净。状态模式对应的是角色状态机。很多人习惯用枚举加 switch 写角色状态代码一旦变复杂就会失控。更好的做法是定义状态基类基础状态各自实现 Enter、Exit、Tick由状态机管理当前状态并转发调用。受击、跳跃、攻击都是独立的状态对象加新状态不需要改旧代码。策略模式适合处理“同一种接口多种算法”的情况。例如伤害计算可以有物理、魔法、真伤、百分比扣血把它们做成 IDamageStrategy 接口运行时注入比在角色类里写一大串 if 要清爽得多。模板方法模式在 UE4 里更隐蔽。AActor 的 BeginPlay、Tick、EndPlay 就是一个模板方法框架基类定义了每个生命周期阶段蓝图事件和 C 虚函数都只负责填充内容。你写 Actor 时天然就在用模板方法。3. 把理论落进代码一个用策略加状态加观察者重构的 UE4 C 项目3.1 项目背景与原始痛点我用一个虚构的“某角色技能系统”来演示这几个模式如何落进 C。这个系统原本的做法是一个 ACombatCharacter一个 FSkillData 结构体技能类型用枚举区分在 ExecuteSkill 里用 switch 挨个处理伤害、治疗、Buff。血量变化后直接调 UI 组件去刷新逻辑全耦合在一起。痛感很明显每加一个新技能类型就要改 ExecuteSkill每个状态切换都要加标记位判断UI 动一下就把战斗逻辑扯进来。我决定用状态模式处理角色状态策略模式处理技能效果观察者模式处理血量通知。3.2 策略模式把伤害、治疗、Buff 拆成独立效果对象首先定义一个策略接口。在 UE4 里你既可以写抽象 UCLASS也可以写普通纯虚接口。为了利用 UCLASS 的反射和蓝图继承我通常用抽象 UCLASSUCLASS(Abstract, BlueprintType, Blueprintable) class UEffectStrategy : public UObject { GENERATED_BODY() public: virtual void Execute(AActor* Target, float Magnitude) PURE_VIRTUAL(UEffectStrategy::Execute,); };然后分别实现具体策略UCLASS() class UDamageEffect : public UEffectStrategy { GENERATED_BODY() public: virtual void Execute(AActor* Target, float Magnitude) override; }; UCLASS() class UHealEffect : public UEffectStrategy { GENERATED_BODY() public: virtual void Execute(AActor* Target, float Magnitude) override; }; UCLASS() class UBuffEffect : public UEffectStrategy { GENERATED_BODY() public: virtual void Execute(AActor* Target, float Magnitude) override; };执行技能时角色不再关心技能类型只面向 UEffectStrategy 对象void ACombatCharacter::ExecuteSkill(UEffectStrategy* InStrategy, float Magnitude) { if (InStrategy) { InStrategy-Execute(this, Magnitude); } }新增“反伤”“吸血”时就添加新的效果子类完全不碰角色类。这就是开闭原则在实际项目里的价值。实例化策略时要注意 UObject 的归宿我建议在角色类里维护一个 UPROPERTY() 引用的策略对象并定期清理不再使用的实例。如果你是 UObject 子类且只是临时使用记得把它放进根集或者挂在组件下否则可能被 GC。3.3 状态模式把角色状态机做成可扩展结构接下来是状态模式。以前用枚举加 switch 写角色状态我给你们看下重构后的基础设计UCLASS(Abstract) class UCharacterStateBase : public UObject { GENERATED_BODY() public: virtual void EnterState(ACombatCharacter* Owner) {} virtual void ExitState(ACombatCharacter* Owner) {} virtual void TickState(ACombatCharacter* Owner, float DeltaTime) {} virtual void HandleInput(ACombatCharacter* Owner, EInputType Input) {} };派生状态UCLASS() class UIdleState : public UCharacterStateBase { GENERATED_BODY() public: virtual void EnterState(ACombatCharacter* Owner) override; virtual void HandleInput(ACombatCharacter* Owner, EInputType Input) override; }; UCLASS() class UAirState : public UCharacterStateBase { GENERATED_BODY() public: virtual void EnterState(ACombatCharacter* Owner) override; virtual void TickState(ACombatCharacter* Owner, float DeltaTime) override; }; UCLASS() class UAttackState : public UCharacterStateBase { GENERATED_BODY() public: virtual void EnterState(ACombatCharacter* Owner) override; virtual void TickState(ACombatCharacter* Owner, float DeltaTime) override; };角色类持有当前状态并在输入中转发:void ACombatCharacter::ChangeState(UCharacterStateBase* NewState) { if (CurrentState) { CurrentState-ExitState(this); } CurrentState NewState; if (CurrentState) { CurrentState-EnterState(this); } } void ACombatCharacter::HandleInput(EInputType Input) { if (CurrentState) { CurrentState-HandleInput(this, Input); } }这里的关键点在于状态对象本身要“知进退”。进入攻击状态时播放动画离开攻击状态时清理攻击标志全部封装在状态类里。主类只负责转发不负责状态逻辑。这样你新增“潜行状态”“格挡状态”都不用改角色基类里的任何逻辑只需要创建一个新状态类并注册入口。3.4 观察者模式用动态委托解耦血量变化通知最后是观察者模式。抽一个可复用的通用事件DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnHealthChanged, float, NewHealth); UCLASS() class ACombatCharacter : public ACharacter { GENERATED_BODY() public: UPROPERTY(BlueprintAssignable, Category Combat) FOnHealthChanged OnHealthChanged; void ApplyDamage(float Amount); };ApplyDamage 里扣血后广播void ACombatCharacter::ApplyDamage(float Amount) { CurrentHealth FMath::Max(0.0f, CurrentHealth - Amount); OnHealthChanged.Broadcast(CurrentHealth); }UI 界面在 BeginPlay 里订阅事件void UMyHUDWidget::BindToCharacter(ACombatCharacter* Character) { if (Character) { Character-OnHealthChanged.AddDynamic(this, UMyHUDWidget::HandleHealthChanged); } }这样做完之后战斗模块完全不认识 UIUI 也只需要订阅事件。新增一个“低血量警示音效”只去订阅同一个事件即可不会再改动战斗系统。你检查代码时也会发现依赖关系非常干净从角色到策略、状态、事件都是一条单向的箭头不会出现循环引用。3.5 这套设计的几个经验注解第一不要为了模式而模式。如果状态只有地面和受击两种枚举加 switch 完全够用不必强行抽状态基类。我的经验是当 switch 分支超过三四处、或者不同状态之间的差异导致逻辑开始互相踩脚时再考虑状态模式。第二UObject 的生命周期是绕不开的坎。状态对象和策略对象如果由角色创建就必须用 UPROPERTY() 引用否则会在某个回调里变成失效对象。基类默认 NewObject 创建的 UObject 不会自动被根集持有GC 可能在关卡切换后回收它。常用做法是把这些对象挂在 Owner 组件上或者由角色显式持有引用。第三静态多播委托和动态多播委托使用场景不同。动态委托支持蓝图绑定和序列化但参数类型受限静态委托 C 之间更高效。如果只做 C 内部事件用普通委托即可别什么都往动态多播上塞。4. 实际开发中常见的问题与排查技巧实录4.1 典型坑位速查表问题现象根因解决思路NewObject 创建后访问崩溃Outer 或持有方没设置好对象被 GC指定合理 Outer用 UPROPERTY() 持有引用CreateDefaultSubobject 在非构造函数里调用报错子对象只能在构造函数流程创建不要在初始化函数外调用默认子对象创建改用 NewObject委托绑定后回调不触发委托类型不匹配或动态委托未标记 UPROPERTY检查 DECLARE 宏类型确认 UPROPERTY(BlueprintAssignable)单例对象关卡切换后失效手动管理生命周期没挂接子系统改成 UGameInstanceSubsystem / UWorldSubsystem状态对象切换后旧状态仍然 Tick状态机没有正确处理 ExitState切换时先 Exit 再 Enter并在 Tick 里早期检查状态是否为空子类重写接口后蓝图无法调用接口定义没有正确使用 BlueprintNativeEvent用 UFUNCTION(BlueprintNativeEvent) 并实现 Execute_GetXxx 逻辑4.2 从设计层面排查依赖问题遇到代码越写越乱的时候我推荐先画一张粗糙的依赖图每个类只箭头指向它直接依赖的对象。如果一张图里出现几十个箭头或者出现循环箭头那说明设计层次出了问题。排查顺序可以这样走先看“谁创建了这个对象”。创建者就应该承担初始化责任否则生命周期必然断。再看“谁需要调用这个对象的方法”。如果多个系统同时调用你该考虑把调用收敛到接口或委托里而不是让所有系统都持有具体类。最后看“谁需要知道变化”。变化通知应该通过事件而不是让各系统轮询查询并到处 Set。我个人在项目里做重构时经常先处理依赖方向再补内存安全问题。比如把 A 类直接引用 B 类改成引用 IB 接口把 C 系统主动拉取数据改成订阅 C 事件。这一步做完一半的 bug 自然就消失。4.3 几个值得养成的编码习惯UE4 的 C 写起来和普通 C 很不一样因为它有 UObject 的反射和 GC。这里有几个习惯能有效降低踩坑概率所有类尽量继承 UObject、AActor 或 UActorComponent不要随意裸写非 UObject 管理器。即便写纯数据类只要需要蓝图操作也用 USTRUCT 或 UCLASS。所有资源引用、对象引用都用 UPROPERTY() 标记。不标记的 TObjectPtr 在引擎新版本里很可能出引用丢失。构造函数里不要做耗时操作也不要依赖网络或文件系统。Actor 初始化顺序可能不稳定很多逻辑要挪到 BeginPlay。动态委托的 AddDynamic 绑定到对象时注意对象的生命周期如果被订阅对象先销毁调用广播时就是悬垂指针。必要时在 EndPlay 里 Unbind 或使用弱引用绑定。模块化时优先考虑接口与委托解耦而不是让类之间互相 include。include 一多编译速度慢是一回事隐藏依赖更麻烦。5. 一点经验体会设计模式是为下一个开发者省时间的设计模式在 C 和 UE4 里不是背概念而是为了让你和下一个接手的人都能“不翻车”地改代码。我见过太多项目功能没问题但代码是几百行面条逻辑串起来加一个功能就崩一块。不是程序员不行是没给类之间立好规矩。我通常的做法是起新模块前先花半天时间搭好接口和对象关系哪怕先写伪代码。UE4 的 UCLASS 抽象、UActorComponent 拆分、委托广播这三个工具能把八成以上的业务逻辑梳理得比较干净。把状态机、策略对象这些骨架搭好后面的钢筋水泥往里填就行。最后分享一个小技巧你可以在项目里专门建一个“设计模式样例”目录每实现一种模式就写一个迷你示例并配上简短注释。等团队新成员加入直接让他看这些比翻教材快得多。我自己的项目里就放了不少这样的 Sample后来很多代码风格问题都从这些样例里找到了答案。
阅读完成 · 觉得有帮助?