ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

UE4 C++设计模式实战:用Actor、Component和状态机重构玩法代码

UE4 C++设计模式实战:用Actor、Component和状态机重构玩法代码

设计模式这个东西,一说起来就像大学课堂里的PPT:UML图、抽象类、一堆定义。但等你真正用 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 里,工厂模式几乎无处不在。你调用 NewObject<UMyObject>()、CreateDefaultSubobject<UMyComponent>()、GetWorld()->SpawnActor<AMyActor>(...),本质上都是工厂方法:由引擎统一决定如何在正确的 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 的反射和蓝图继承,我通常用抽象 UCLASS:

UCLASS(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); } }

这样做完之后,战斗模块完全不认识 UI,UI 也只需要订阅事件。新增一个“低血量警示音效”,只去订阅同一个事件即可,不会再改动战斗系统。你检查代码时也会发现依赖关系非常干净:从角色到策略、状态、事件,都是一条单向的箭头,不会出现循环引用。

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,后来很多代码风格问题都从这些样例里找到了答案。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进