ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

UE C++常用说明宏详解:UENUM、UCLASS、UPROPERTY、UFUNCTION

UE C++常用说明宏详解:UENUM、UCLASS、UPROPERTY、UFUNCTION 1. 先搞明白UEC的“宏”到底是个什么东西很多人一开始接触UE的C都会被一堆UPROPERTY、UFUNCTION、UCLASS这种带U前缀的关键字搞懵。看起来像是关键字但又不像标准C的语法搜一下发现它们其实都是宏于是就有同学天真地以为它们是预处理器替换的普通宏——这就有意思了。如果真是普通宏那UnrealHeaderTool简称UHT就没必要存在了。事实上这些说明宏不是给你做文本替换用的它们更像是贴给UHT看的“标签”。UHT会在编译前对头文件做一次额外的静态解析读取这些说明符然后自动生成对应的反射代码供蓝图、序列化、网络复制、垃圾回收这些系统使用。打个比方你的类就像一张个人信息表UPROPERTY就是“重点标注”的荧光笔UFUNCTION就是“对外服务窗口”的指示牌而UCLASS则决定了这张表本身能不能给别人看、能怎么被别人填写。程序员写的是标记UHT负责把这些标记翻译成引擎听得懂的注册信息。理解这一点之后后面所有的用法就都顺理成章了。说回本篇文章的主题。既然标题是“常用说明宏的使用方法”那就意味着我会聚焦在UENUM、UCLASS、UPROPERTY、UFUNCTION这四个最常见、也最核心的宏上。它们的使用频率在UE项目里几乎可以用“无处不在”来形容。写清楚这几个宏你的UE C就算是真正入了门。本文适合什么人看呢如果你已经会写C但第一次接触UE的反射体系或者你已经写过一段时间蓝图想往C方向转型这篇文章会帮你建立一套比较完整的认知框架。我会结合实际项目里的使用场景来拆解而不是把说明符表格抄一遍给你。2. 四大家族逐个拆解UENUM、UCLASS、UPROPERTY、UFUNCTION2.1 UENUM让枚举类型进入蓝图世界先说UENUM这是四个宏里最简单的一个但也是最容易被忽视的。很多新手头一次写枚举就直接在头文件里写个enum然后用起来发现蓝图里根本搜不到或者网络复制的时候枚举值传不过去。问题就出在没加UENUM。正确的写法是这样UENUM(BlueprintType) enum class EItemType : uint8 { Weapon UMETA(DisplayName 武器), Armor UMETA(DisplayName 护甲), Consumable UMETA(DisplayName 消耗品) };这里有几个关键点需要注意。第一UENUM后面加的BlueprintType说明符表示这个枚举可以被蓝图使用。如果不加蓝图里顶多只能当普通整数用不能在枚举变量下拉框里选。第二使用枚举类enum class时最好指定底层类型为uint8这样在网络复制时不容易出现未知字节数的问题而且UHT要求带类型的枚举必须要用uint8以上。第三UMETA里的DisplayName用于在编辑器里显示一个友好名称比如你想让设计看到的是“武器”而不是“Weapon”就靠它。还有一个容易被忽略的点加了UENUM的枚举其实还隐含了“可以被序列化”的能力。如果你需要把枚举值保存到存档里或者在服务器和客户端之间同步不加UENUM的情况下UHT不会为它生成反射信息复制自然无从谈起。所以凡是需要在蓝图、存档、网络这三个场景中出现的枚举统一都加UENUM这是个零成本的好习惯。2.2 UCLASS类的“户籍登记处”如果说UENUM是给枚举办身份证那UCLASS就是给类落户。一个类要是没加UCLASS那它在C层面自娱自乐没问题但蓝图继承、编辑器面板创建实例、序列化存储这些全套功能都与你无关。最常见的写法是UCLASS(Blueprintable, BlueprintType) class MYGAME_API AMyActor : public AActor { GENERATED_BODY() public: AMyActor(); };注意两个细节。第一个是Blueprintable它表示“这个类可以被蓝图继承”也就是允许在编辑器里创建基于这个类的蓝图子类。第二个是BlueprintType它表示“这个类的对象可以作为变量存在于蓝图中”差别虽然微妙但很重要。如果你只想让别人基于你的类做蓝图子类Blueprintable就够了如果还希望蓝图中能声明这个类型的变量那就再加BlueprintType。还有一个实战中很常用的说明符叫EditInlineNew。打个比方如果你有一个UPROPERTY指向某个自定义类不带EditInlineNew时设计人员只能在蓝图里选一个已经存在的资产实例加了EditInlineNew之后他可以直接在细节面板里凭空创建一个该类型的子对象不需要事先准备任何资产文件。这在配置本地数据、创建行为树节点、设计技能效果这类场景里简直是刚需。有些类还需要考虑是否支持网络复制比如继承自AActor的类通常都会有Replicates相关的配置需求。这个不通过UCLASS控制而是通过Actor的bReplicates属性所以先不过多展开后面讲UPROPERTY时再提。2.3 UPROPERTY属性宏里的“大户人家”UPROPERTY是整个说明宏体系里使用频率最高、说明符组合最丰富的宏。围绕它展开的设计问题几乎能够覆盖整个游戏架构的前半部分。我先从功能角度把它分的几类讲清楚大家按照自己的使用场景对号入座。第一类是编辑与显示相关UPROPERTY(EditAnywhere, Category Config) float MaxHealth; UPROPERTY(VisibleAnywhere, Category Status) float CurrentHealth;EditAnywhere表示这个属性在“类默认值面板”和“实例详情面板”里都能改最灵活VisibleAnywhere表示只能在面板里查看不能修改适合用来看实时状态。这里还有一个容易踩坑的分类EditDefaultsOnly和EditInstanceOnly。前者只能修改“蓝图类默认值”而必须在实例面板中锁定后者只能在关卡放置的实例里改蓝图默认值反而不能动。三者怎么选我的经验是属于这个类固有配置的数据用EditDefaultsOnly比如初始血量、移动速度这些属于每次摆放都要重新配置的数据用EditInstanceOnly比如这个门是开还是关、随机种子是几只有确定“在类默认值改也行、个别实例独享配置也行”时才用EditAnywhere。第二类是蓝图可见与可写UPROPERTY(BlueprintReadWrite, Category Gameplay) float Damage; UPROPERTY(BlueprintReadOnly, Category Gameplay) float FinalDamage;BlueprintReadOnly意味着蓝图里只能读不能写通常配合函数计算出来的结果属性使用防止设计人员直接修改内部计算值。BlueprintReadWrite则会在蓝图里生成一个Get和Set节点允许外部读写。我建议所有需要暴露给蓝图的数据都至少有BlueprintReadOnly这样即使暂时不用后续调整蓝图逻辑时也不会因为访问权限不够而卡住。第三类是网络同步。这个算是UE C里比较进阶的部分但既然讲UPROPERTY结合Replicated使用是绕不开的UPROPERTY(Replicated) float Health; void GetLifetimeReplicatedProps(TArrayFLifetimeProperty OutLifetimeProps) const override;复制意味着服务器上的这个属性会被自动同步到所有客户端不需要自己写RPC。结合RepNotify客户端收到同步更新时能触发一个通知函数非常适合做血量UI刷新、状态变化特效这类逻辑UPROPERTY(ReplicatedUsing OnRep_Health) float Health; UFUNCTION() void OnRep_Health();这里要记住Replicated只保证值能同步过去但同步时机、更新策略、初始值处理都需要自己把控。比如双方都需要RPC并发的交互通常就不是属性复制能覆盖的了。第四类是内存与序列化相关比如Transient、SaveGameUPROPERTY(Transient) float TempValue; UPROPERTY(SaveGame) int32 HighScore;Transient表示这个变量不需要被保存也不会被网络复制适合放运行时临时缓存。SaveGame则允许这个变量进入存档系统配合UGameplayStatics的SaveGameToSlot用起来非常顺手。很多人问为什么存档总是存不下来最常见的解答就是该变量漏了SaveGame说明符——这个坑我踩过不止一次。另外还有一个无数人问过的经典问题为什么在构造函数里NewObject一个UObject然后赋给UPROPERTY但蓝图里总是看不见答案一般出在UPROPERTY对裸指针的处理方式上。你需要在类的析构或BeginDestroy阶段手动释放内存或者更安全的做法是使用TStrongObjectPtr、TObjectPtr这类智能指针。具体到UHT它只关心“这个成员是否被标记”而不负责帮你自动管理UObject生命周期凡是在UPROPERTY中声明为裸UObject*的都要自己考虑对象销毁时的清理逻辑否则一旦对象被GC回收你留下的悬垂指针会在下一次调用时彻底炸掉。2.4 UFUNCTION让C函数对蓝图“开口说话”UFUNCTION在功能上分为两类一类是“蓝图可调用”一类是“蓝图可覆写/实现”。它们解决的是同一个核心问题——跨语言边界通信但适用的交互方向完全相反。先看最基础的一种蓝图主动调C函数UFUNCTION(BlueprintCallable, Category Gameplay) void ApplyDamage(float Amount);加了BlueprintCallable之后蓝图里就能搜索到一个“Apply Damage”节点直接调用C里的逻辑。这里有一个新手最容易犯的错误忘记对函数体做合法性检查比如Amount小于零、对象处于销毁中就贸然修改数据结果就是在客户端与服务器状态不一致这个问题上浪费大量时间。再看一个反方向蓝图覆写C逻辑UFUNCTION(BlueprintImplementableEvent, Category Gameplay) void OnHitSomething(float Damage); UFUNCTION(BlueprintNativeEvent, Category Gameplay) void OnDied();BlueprintImplementableEvent表示C里只有声明没有实现蓝图里必须重写这个函数这在实际项目中用得非常频繁比如各种事件通知、动画回调、技能命中时机点。BlueprintNativeEvent则允许蓝图覆写但同时保留一个C默认实现这个默认实现写在OnDied_Implementation里。要注意的是如果你覆写了BlueprintNativeEvent但又想继续执行C的逻辑就必须在蓝图里手动调用“Parent: OnDied”否则原生实现会被直接跳过。除了这两大方向UFUNCTION还有个非常重要的用途是定义RPC。服务器的权威逻辑、客户端的请求、服务端的广播全靠它来实现。最典型的就是UFUNCTION(Server, Reliable) void ServerFire(FVector HitLocation); UFUNCTION(NetMulticast, Reliable) void MulticastPlayFireEffect();这里Server表示这个函数在服务器上执行客户端调用时会把请求发给服务器Reliable保证一定会送达适合开火、扣血这类关键操作Unreliable则适合特效、音效这种丢了也无所谓的表现层。NetMulticast意味着所有客户端包括服务器都会执行适合做全场景广播。合理组合这两个关键字要思路非常清晰RPC的调用可不像普通函数那样想调就调服务器和客户端执行环境完全不同跨端调用必须设计好协议。3. 从零写一个“可配置拾取物品”类实操演示前面拆了理论现在我们来做一个完整的实践项目。假设我们要做一个非常常见的游戏场景——玩家可以捡起地上的物品物品类型、外观、加血数值都可以在蓝图里配置拾取后要同步到服务器并且所有客户端的表现都要更新。先在头文件里定义枚举和类UENUM(BlueprintType) enum class EPickupType : uint8 { Health UMETA(DisplayName 生命恢复), Ammo UMETA(DisplayName 弹药补给), Score UMETA(DisplayName 得分道具) }; UCLASS(Blueprintable, BlueprintType, EditInlineNew) class MYGAME_API APickupItem : public AActor { GENERATED_BODY() public: APickupItem(); protected: UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category PickupConfig) EPickupType PickupType; UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category PickupConfig) float AmountValue; UPROPERTY(EditInstanceOnly, BlueprintReadOnly, Category PickupRuntime) bool bIsAvailable; UPROPERTY(ReplicatedUsing OnRep_PickupState, BlueprintReadOnly, Category PickupRuntime) bool bWasCollected; UFUNCTION() void OnRep_PickupState(); public: UFUNCTION(BlueprintCallable, Category Pickup) void Pickup(AActor* Collector); UFUNCTION(BlueprintImplementableEvent, Category Pickup) void OnPickupSuccess(AActor* Collector); virtual void GetLifetimeReplicatedProps(TArrayFLifetimeProperty OutLifetimeProps) const override; protected: UFUNCTION(BlueprintNativeEvent, Category Pickup) void ApplyEffect(AActor* Collector); };然后我们逐个分析每个宏为什么这样用。PickupType和AmountValue这两个配置数据用EditDefaultsOnly就意味着它绑定在蓝图类资产上设计人员后续复制出不同种类的拾取物蓝图时每次都只改自己这个蓝图子类的默认值互不干扰。而bIsAvailable这个披萨参数纯粹是运行时状态不能在每个子类蓝图里预先改只能关卡摆Actor后单独设置所以用EditInstanceOnly这样编辑器显示更直观默认值面板里看不到它实例面板里才能改。bWasCollected这个复制属性加ReplicatedUsing意味着服务器一旦修改它所有客户端都会收到并自动执行OnRep_PickupState。这个OnRep函数里一般用来播放“物品已消失”的动画或者禁用碰撞体。这里有个最经典的坑属性复制只在服务器修改的那一刻触发如果在客户端本地直接改这个变量它不会同步给任何人也不会触发OnRep。所以拾取的逻辑只能在服务器上执行。Pickup函数设计成BlueprintCallable是为了让蓝图触发器或者交互系统能直接调它。ApplyEffect设计成BlueprintNativeEvent是因为不同拾取物的效果可能不同——血包回血、弹药补弹但默认实现里也放一个通用的处理比如给Collector增加对应属性。如果设计人员在蓝图里只画了特殊逻辑想完全替代C默认的ApplyEffect就必须注意调用Parent节点否则就会因为不清楚覆写优先级出现默认效果和蓝图效果叠加或失效的问题。写到这里还差一步需要在.cpp文件里补充GetLifetimeReplicatedProps的实现void APickupItem::GetLifetimeReplicatedProps(TArrayFLifetimeProperty OutLifetimeProps) const { Super::GetLifetimeReplicatedProps(OutLifetimeProps); DOREPLIFETIME_CONDITION(APickupItem, bWasCollected, COND_None); }DOREPLIFETIME_CONDITION的第一个参数是类名第二个是属性名第三个是条件宏。COND_None表示无条件复制某些特定情况可以用COND_InitialOnly只在初始同步时复制来节省带宽。实际项目里如果只关心开局刷出的状态、后续状态都通过属性变化驱动用COND_InitialOnly很合适。但要注意条件复制会影响“变化后同步”的行为确定需求再选。搭建完整个类之后我们就能在蓝图里看到三个层面的效果编辑器里可以根据PickupType配置数值按住Alt拖一个副本到关卡里可以单独修改bIsAvailable玩家拾取后服务器同步状态、客户端播放消失表现。整个过程宏选型的核心逻辑就是围绕“谁来配置”“谁能调用”“何时同步”这三个问题展开的。4. 整理一些高频“坑”说明宏使用中的排查手记说明宏看着简单但实际项目里踩坑频率非常高尤其是团队协作的项目各种莫名其妙的编译错误和行为不一致查到最后往往都是某个说明符少写、多写或者写错了位置。我把这些年遇到的典型问题整理成一份速查表大家对照着排查效率会高很多。问题现象可能原因解决建议蓝图里搜不到自定义枚举枚举没加UENUM(BlueprintType)头文件重新编译并让UHT完成刷新确认底层类型是uint8蓝图类继承不到C类类没加UCLASS(Blueprintable)加上Blueprintable并重新编译编辑器会刷新继承菜单蓝图变量修改了但没效果属性可能是EditDefaultsOnly而非EditInstanceOnly理解三类编辑权限按需求改选服务器改了变量客户端无反应属性缺少Replicated/SaveGame或复制注册漏写检查属性的Replicated和GetLifetimeReplicatedProps覆写BlueprintNativeEvent时默认逻辑失效蓝图里没有调用Parent节点在蓝图事件图表中加入“Parent: 函数名”节点报错“Cannot use default parameter”UFUNCTION默认参数在C里编译通过但UHT不支持不要在UFUNCTION里用默认参数改为多函数重载中文注释导致UHT报错编译器文件编码问题保证头文件是UTF-8 BOM编码有全局配置时优先统一格式报错“Incompatible type”宏里声明的类型不是UHT支持的类型自定义类型必须是USTRUCT/UCLASS再尝试放入UPROPERTY编译通过但运行时访问悬垂指针崩溃裸指针属性复用但没考虑GC生命周期考虑用TWeakObjectPtr/TStrongObjectPtr管理关联对象Include顺序导致宏不可见类头文件里自定义类型声明排在引用之前调整include顺序或把相关类型拆到独立头文件其中有不少问题都源于同一个核心误解UHT不等于编译器。Visual Studio的IntelliSense能通过很多代码UHT不一定能理解。最常见的例子就是函数默认参数你在C里写UFUNCTION(BlueprintCallable) void DoSomething(int32 Value 10);编译阶段不会报错但UHT会在生成代码时卡住最后抛出一堆莫名其妙的错误。解决方案也很简单别用默认参数拆成两个函数或者让蓝图侧自己传值。再补充一个我在项目里经常遇到的操作细节修改了头文件里的宏之后有时候即使重新编译编辑器里的属性名称和类别还是一直保持旧的。这种缓存问题大部分情况下需要关掉Live Coding、完全关闭编辑器再重新打开项目等它重新编译。UE的增量编译在UHT这块偶尔会有滞后的情况第一时间就想“我改错了”不一定对先强制全量编译一次再排查代码逻辑效率更高。另一个高频踩坑点围绕着类型的可见性。比如UPROPERTY里放一个FName数组没问题放一个TMapFString, FMyCustomStruct就编译报错原因是UHT对模板容器的泛型支持有限尤其是TMap、TSet这类组件其中的自定义类型如果没有完整的反射信息UHT会直接拒绝生成代码。遇到这种情况要么把自定义类型升级成USTRUCT并确保加了USTRUCT宏要么改成TArray替代。在设计数据结构前就先确认类型的可反射性能省下一大堆无用功。5. 再聊一聊说明宏的设计思路比语法本身更重要很多人会陷入一个误区觉得把四个宏的全部说明符背下来就是学会UE C了。实际上真正值钱的不是背说明符而是能判断出某个属性到底应该暴露到哪一层、以什么方式暴露。举个例子一个血量属性是设计成EditDefaultsOnly还是EditInstanceOnly表面上只是改个词的区别但背后决定的是“这条数据属于蓝图子类还是属于关卡里摆的个体”。项目到了后期配置文件的错误定位、版本迭代时数据迁移的成本往往就取决于这一类决策做得到不到位。我的习惯是在一次新模块的架构阶段把团队成员约到一起明确每条数据的分级标准把EditDefaultsOnly、EditInstanceOnly、BlueprintReadOnly这三个词的适用范围写进项目规范文档。有这一层约束之后后面维护代码的日子会轻松很多。对网络同步的思考也一样。UE提供了属性复制和RPC两种机制但很多系统新手写起来不考虑Flow直接就能把所有东西都复制过去。其实有些数据在服务器上根本不需要独立存在只需要通过RPC广播一个事件就够了。多问自己一句“这究竟是状态还是事件”如果答案是事件那大概率应该用UFUNCTION配合RPC来发送而不是浪费带宽同步一个布尔值。回到标题里那件事——说明宏的使用方法拆到最终大部分内容其实就是判断边界正如布线的工具、配置的权限、同步的方式。把这套思路带着做项目比背多少条语法规则都管用。最后分享一个小技巧在团队项目里如果不想让设计师每次找配置属性都扫一遍超长的细节面板尽量把UPROPERTY里的Category分级命名比如“Config.Pickup”、“Config.Effect”、“Runtime.State”。UE的细节面板会自动按这个结构折叠分组层次一清晰设计师舒心你也少被问问题。加一个良好的习惯这套宏用起来就会顺得非常快。
返回列表