做虚幻引擎C++开发,绕不开一堆以U开头的宏。新手第一次看到 UCLASS()、UPROPERTY(VisibleAnywhere)、GENERATED_BODY() 挤在一起时,多半会愣一下:这些到底是语法的一部分,还是某种编译器魔法?“说明宏”这个名字听起来像注释,实际作用却远不止此。这篇Part3就专门把UE C++里最常用的说明宏讲透——它们分别负责什么、参数该怎么选、写错了会出什么幺蛾子。内容适合已经能写基础C++类、但一碰到UE反射就发懵的开发者,看完之后你至少能看懂别人代码里那几行宏在做什么,也能自己设计出一套合理的属性权限。
1. 那些看似多余的宏,其实是UE反射系统的“唯一入口”
1.1 宏不是给编译器看的装饰品,而是给UHT看的简历
很多新手问过我:UCLASS() 括号里什么都没写,能不能直接删掉?答案是不行。哪怕括号里是空的,这个宏也在扮演“登记”的角色。虚幻引擎的反射系统依赖一个叫UnrealHeaderTool(简称UHT)的编译期工具,它在编译之前扫描你的头文件,看到UCLASS、UPROPERTY、UFUNCTION、GENERATED_BODY这些宏,就知道哪些类型需要被反射登记,然后生成对应的 generated.h 和 .gen.cpp 文件。这些生成文件再通过GENERATED_BODY等宏“拼接”回你的类里,UClass、UObject、蓝图系统才能动态地发现和操作这些类型。
可以拿招聘报名做个类比:宏就是报名表,UHT就是负责收表和录入系统的HR。你的C++类只有填了报名表、进了人才库(反射系统),之后蓝图才能看到它、垃圾回收才能管理它、编辑器才能序列化它。没填表的类哪怕写得再漂亮,也只是漂在引擎世界之外的普通C++类,蓝图看不见也够不着。更关键的是,UE的反射系统不认“约定”,只认“标记”。你以为是常识的命名规则、注释说明,UHT一概不看,它只认这些宏。
1.2 宏家族的组成与分类
在动手写代码之前,先建立一张认知地图会省很多事。我把UE里常用的宏分成六类:
| 类别 | 代表人物 | 作用 |
|---|---|---|
| 模块导出宏 | 模块名_API | 控制DLL符号导入导出,跨模块引用必须加 |
| 类型声明宏 | UCLASS / USTRUCT / UENUM / UINTERFACE | 声明一个可以被反射识别的类型 |
| 成员声明宏 | UPROPERTY / UFUNCTION | 把成员变量、成员函数暴露给蓝图和反射系统 |
| 生成代码宏 | GENERATED_BODY | 把UHT生成的反射代码拼进当前类 |
| 元数据宏 | UMETA / meta=(...) | 给类型、属性、函数补充编辑器和蓝图显示用的信息 |
| 条件编译宏 | WITH_EDITOR / UE_BUILD_SHIPPING 等 | 控制代码只在特定构建状态下参与编译 |
这六类不是并列关系,而是层层嵌套的:一个类先要用UCLASS登记类型,再用GENERATED_BODY引入生成代码,接着成员们各自用UPROPERTY/UFUNCTION登记,最后在参数上补充meta信息做细节说明。理解了这套逻辑,再看一长行宏就不会发怵,因为它无非是在说“在哪个环节补充什么信息”。所以下文我就按这个顺序,从类型声明到成员声明再到编译宏,逐个拆开讲。
2. 类声明宏逐个拆解:UCLASS、USTRUCT、UENUM、UINTERFACE
2.1 UCLASS:决定你的类能不能进蓝图
UCLASS最常出现在继承自UObject、AActor、UActorComponent的类前面。它回答几个问题:这个类能不能被蓝图继承?能不能作为蓝图变量的类型?在编辑器内容菜单里归哪个组?下面是我实际项目里用过的参数表:
| 参数 | 作用 | 建议场景 |
|---|---|---|
| Blueprintable | 允许这个类作为蓝图类的父类 | UI控件、可扩展的Actor、任务目标 |
| BlueprintType | 允许蓝图变量使用这个类类型 | 需要声明蓝图变量指向该类型 |
| NotBlueprintable | 禁止蓝图继承 | 纯C++内部工具类 |
| BlueprintSpawnableComponent | 允许蓝图动态挂载该组件 | 组件类的常见参数 |
| Abstract | 标记为抽象类,禁止蓝图直接创建 | 基类设计,例如所有武器共享基类 |
| ClassGroup=名称 | 编辑器内容菜单里的分组 | 自定义内容归类 |
| EditInlineNew | 允许在细节面板直接创建内嵌对象 | 暴露TArray<T*>这类容器成员时 |
这里有个反直觉的点经常让人踩坑:BlueprintType只管“变量能不能引用这个类型”,而Blueprintable才管“蓝图能不能继承”。两者经常一起出现,但语义完全不同。举个例子,一个纯C++的子弹基类如果只写了Blueprintable,蓝图里可以继承它,但你没法在蓝图里声明一个“子弹基类类型”的变量去引用它;反过来只写BlueprintType,变量能用,但不能被蓝图继承扩展。
挑选参数时我会先问自己三个问题:这个类型是否需要被蓝图继承?是否需要作为蓝图变量类型出现?是否要出现在编辑器的创建菜单里?把答案组合起来,UCLASS的参数自然就出来了。至于CustomConstructor、Config=Engine这类冷门参数,等遇到具体场景再查也不迟,不必一开始就背完。
2.2 USTRUCT与UENUM:让数据也具备反射能力
不是只有Actor才需要反射,散落的数据块同样需要。USTRUCT就是给结构体开反射入口的宏。最常见的写法是:
USTRUCT(BlueprintType) struct FMyLaserParams { GENERATED_BODY() UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Laser") float Damage = 10.f; UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Laser") FLinearColor BeamColor = FLinearColor::Red; };注意,USTRUCT本身只是说明“这个结构体要进反射系统”,真正让数据被蓝图操作的是内部成员的UPROPERTY。如果只写了USTRUCT却没有任何UPROPERTY,这个结构体对蓝图来说仍然是个黑盒子。USTRUCT还有个冷门参数NoExport,表示只生成声明、不生成C++导出代码,一般用在需要和第三方库共享内存布局的结构上,日常开发很少碰。
UENUM则用来标记枚举类型。比如:
UENUM(BlueprintType) enum class EMyWeaponState : uint8 { Idle, Firing, Reloading };在UE里我强烈建议用 enum class,并且显式指定底层类型为uint8。否则编译器可能把枚举底层类型撑大到int甚至更大,影响网络同步和存档序列化的稳定性以及内存占用。UENUM配合 meta=(Bitflags) 可以做成位标志型枚举,蓝图中以多选框形式操作,适合实现状态叠加、权限组合这类需求。不过位标志枚举用到时要做枚举值的位移操作,新手容易忘记。
2.3 UINTERFACE:让接口被蓝图看见
C++本身有纯虚类做接口,为什么UE还要单独搞一个UINTERFACE?核心原因在于蓝图编辑器只认被反射系统登记过的类型,不认普通C++类。UINTERFACE背后实际上定义了UInterface这个UObject派生类型,它作为“影子”,让蓝图能理解你的接口并支持在蓝图中实现接口方法。
在头文件里,接口通常的写法是两层结构:
UINTERFACE(Blueprintable) class UMyInteractable : public UInterface { GENERATED_BODY() }; class IMyInteractable { GENERATED_BODY() public: UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "Interaction") void OnInteract(AActor* Instigator); };第一个类继承UInterface,命名必须以U开头但又只作为标记存在;第二个类继承IInterface,才是你真正写接口逻辑的地方,命名必须以I开头。蓝图里实现OnInteract的时候,如果你看到节点名带_Implementation后缀,别慌,那是BlueprintNativeEvent的约定——C++里默认实现要写成OnInteract_Implementation。接口设计非常适合“可交互物”这类需求:箱子、门、机关全都实现同一个I可交互接口,玩家面向一个物体时只做一次Cast,就能调用所有这一类物体的交互逻辑,避免一堆冗长的Actor类型分支判断。
3. UPROPERTY与UFUNCTION:每天写得最多的两个宏
3.1 UPROPERTY:属性反射的最全参数盘点
UPROPERTY是很多人接触UE C++后最先发现“原来一个宏能有这么多种写法”的地方。括号里的参数可以分成四类:访问控制、蓝图权限、生命周期控制、meta细节。
访问控制参数决定编辑器、蓝图、实例之间谁能改这个值:
| 参数 | 谁能改 | 使用场景 |
|---|---|---|
| EditAnywhere | 编辑器、蓝图、运行时都能改 | 大多数配置型变量 |
| EditDefaultsOnly | 只能在类默认值(CDO)里改,实例上不能改 | 影响全局的数值配置 |
| VisibleAnywhere | 编辑器里可见但不可改 | 运行时状态、统计信息 |
| VisibleDefaultsOnly | 只在类默认值面板里可见不可改 | 只读默认配置 |
| EditInstanceOnly / VisibleInstanceOnly | 只对实例生效 | 场景里独立调参的关卡Actor |
初学阶段最容易犯的错误是拿EditDefaultsOnly当万能选项,结果把运行时会被频繁修改的变量标成了DefaultsOnly,导致实例面板里找不到修改入口,还得回头改代码。我的经验是:需要在资源或蓝图里统一配置的选EditDefaultsOnly,需要每个实例单独调整的选EditAnywhere,只是想调试时看一眼的选VisibleAnywhere。
蓝图访问控制就是BlueprintReadOnly和BlueprintReadWrite这对组合。注意它们和访问控制参数是互相独立的,可以自由组合,比如EditDefaultsOnly + BlueprintReadWrite意味着蓝图能读能写,但只能在默认值阶段调整。同一属性绝不能同时标BlueprintReadOnly和BlueprintReadWrite,UHT会直接报错。
生命周期控制参数容易被忽略,但最容易引发隐蔽Bug。Transient表示不需要保存;Config表示从INI文件读取;Replicated表示参与网络同步;SaveGame表示参与存档。不少人网络同步变量忘了标Replicated,客户端看到的值全是本地初值,排查老半天才发现是宏参数漏了。meta细节里常用的有DisplayName(编辑器中显示友好名)、Tooltip(悬停提示)、ClampMin/ClampMax(数值范围限制)、AllowPrivateAccess(允许蓝图访问私有成员)、ExposeOnSpawn(生成Actor时作为参数暴露)。写大项目时我习惯给每个UPROPERTY都写Category和Tooltip,这会让蓝图侧的界面整洁很多,调参的人也少问“这个变量到底是什么意思”。
实际写起来,一行UPROPERTY会长这样:
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Weapon|Damage", meta = (ClampMin = "1.0", ClampMax = "999.0", Tooltip = "单发子弹的基础伤害")) float BaseDamage = 50.f;Category支持斜杠分级,写Weapon|Damage就会在细节面板里生成Weapon下的Damage折叠组。这是提升项目可维护性的免费午餐,强烈建议每个属性都认真规划分类。
3.2 UFUNCTION:暴露给蓝图的三兄弟
UPROPERTY管数据,UFUNCTION管逻辑。三个最常见的修饰词不搞清楚,蓝图侧就会出现两个极端:要么节点太多冗余,要么什么节点都搜索不到。
| 修饰词 | 含义 | 蓝图表现 |
|---|---|---|
| BlueprintCallable | C++实现的函数,蓝图可以主动调用 | 可直接拖出成节点 |
| BlueprintImplementableEvent | 蓝图负责实现,C++只声明不实现 | 蓝图重写事件 |
| BlueprintNativeEvent | C++提供默认实现,蓝图可重写 | 蓝图重写事件,仍可调用C++默认逻辑 |
三者的取舍可以这样理解:如果这个逻辑只能由C++写,用BlueprintCallable;如果这个逻辑应当完全交给策划在蓝图里填写,用BlueprintImplementableEvent;如果需要一个默认实现但允许被蓝图覆盖,用BlueprintNativeEvent。比如“开火”可以做成BlueprintNativeEvent,C++里先写好子弹发射、后坐力的默认流程,蓝图端在特殊武器上重写表现层。
写BlueprintNativeEvent时有个细节:C++实现要写成函数名加_Implementation后缀,比如OnFire_Implementation(),声明里不能写实现体。BlueprintImplementableEvent则只能是一个纯声明,连实现都不许写,写了也会被UHT吞掉。这个机制容易让人困惑,但只要记住“有实现体就是_Implementation版本”就基本不会错。
网络相关修饰符(Server、Client、NetMulticast)也属于UFUNCTION体系。Server表示在服务器上执行,Client在客户端执行,NetMulticast在所有人端执行。配合Reliable/Unreliable标记,可以实现不同场景的网络调用。这部分展开又是另一篇文章,这里只提醒一点:网络函数目前只支持Server/Client/NetMulticast这三类;如果标了Server函数,调用方要注意执行上下文,否则会出现服务器逻辑跑到客户端上的怪问题。
3.3 从零搭一个属性的“配方”
如果把几个高频参数组合起来,可以总结出几个能直接套用的稳定配方。我自己的常用组合是:
- 数据配置类属性:EditDefaultsOnly + BlueprintReadOnly + Category=Configuration
- 运行时状态类属性:VisibleAnywhere + BlueprintReadOnly + Transient
- 交互玩家可调属性:EditAnywhere + BlueprintReadWrite + Category=Interaction
- 需要存档的属性:BlueprintReadWrite + SaveGame + VisibleAnywhere
这套配方不是定律,但如果项目还在早期、团队成员之间没有统一规范,按这套写,至少不会出现“蓝图侧改不了”或“代码侧看不到”的极端情况。等团队经验丰富了,再针对不同模块定制专属规范。
4. 模块导出宏与GENERATED_BODY:编译环节的两个隐藏主角
4.1 模块名_API:跨模块引用的通行证
在UE项目里,每个模块本质上是一个动态链接库(开发编辑器模式下)或静态库(打包版)。默认情况下,符号只在模块内部可见,跨模块使用类时,连接器根本不知道这个类的导出符号在哪。模块名_API宏就是解决这个问题的:在编辑器开发模式下,它会被展开成__declspec(dllexport)或__declspec(dllimport),具体是导出还是导入,由当前是否正在编译该模块自动切换。
举个具体场景,假设你的模块叫WeaponSystem,在模块里定义:
class WEAPONSYSTEM_API AMyRifle : public AActor { GENERATED_BODY() };如果把WEAPONSYSTEM_API这一节删掉,项目在单模块开发时通常还能编译过;一旦你在另一个模块(比如角色模块)里include了枪械头文件并调用枪械类型,连接器立刻报LNK2019无法解析的外部符号。我的建议是:凡是会被其他模块include的类,无论有没有被别的模块真正引用,都先把模块名_API写上。这几乎不费成本,却能省掉一半的跨模块编译困扰。
对于纯C++类(不继承UObject的类型),一般不需要模块名_API。此外,模块名必须是Build.cs文件里定义的模块名,大小写保持一致。填错模块名编译时不会立刻报错,但链接时会非常难查。还有一个常见误区:一个类只在当前模块内部使用,就不写_API,这当然可以;但团队协作时你不知道哪天会把类挪到别的模块,所以我倾向于统一都写上。
4.2 GENERATED_BODY:UHT生成代码的“缝合线”
GENERATED_BODY是UHT生成代码和你的源码之间的缝合线。展开后的内容会把UHT生成的构造函数、静态结构、反射访问函数全部嵌入当前类型。它几乎是强制性的:所有带反射标记的类型都得写,忘记的话UHT直接报错,根本不给你继续编译的机会。
UE早期版本里还存在GENERATED_UCLASS_BODY这个变体。两者区别在于GENERATED_UCLASS_BODY会生成一个默认构造函数,并且自动声明一些友元结构,而GENERATED_BODY则不会自动生成构造函数的声明。社区和老项目的代码里还能看到前者,但我建议新代码一律使用GENERATED_BODY,理由很简单:它让你显式声明构造函数,行为更可控,也不会被UHT悄悄修改类布局,还能避免老宏与新版UHT的兼容性问题。
想要理解这层“缝合”,可以翻开Intermediate目录下源文件对应的 generated.h 文件。里面能看到属性索引赋值、RPC函数映射表、类型转换访问器等真实结构。我第一次翻这些生成文件时最大的感慨是:“原来我写的UPROPERTY都被翻译成了带索引的元数据表。”遇到宏参数不生效之类的问题,去生成的代码里比对一下,往往一眼就能看出是不是没解析进去。
5. 高频雷区与排查实录
5.1 那些不会让你报错、但会让你怀疑人生的坑
有些宏错误会在编译阶段立刻报出来,反而好解决。真正的麻烦是那些能编译、能运行,但行为不符合预期的问题。我列几个反复出现的高频雷区。
第一,UPROPERTY并不支持所有C++类型。原生指针只支持UObject派生类指针和少数特殊情况;TMap的键类型如果比较复杂也会出问题;容器里装的值类型最好都是被反射系统支持的类型。没标UPROPERTY的成员如果被蓝图读取,系统不会报错,但运行时读到的是稳定不变的初值——很多人以为是自己逻辑错了,实际上是反射根本没登记这个字段。
第二,UPROPERTY不能用在静态变量、全局变量和普通函数内部。UHT只扫描类成员字段,放在函数体里会被直接忽略。另外在Lambda里捕获UPROPERTY字段也要当心,按值捕获容易复制出不正确的对象生命周期,尤其涉及UObject*指针时,建议改用WeakPtr或显式捕获。
第三,对TArray、TMap、TSet这类容器成员,如果不写UPROPERTY,蓝图侧完全无法操作容器里的元素。很多人在C++内部用TArray只是为了逻辑便利,就没写UPROPERTY,结果调试时无法在编辑器里检查容器内容,非常憋屈。哪怕是内部容器,我也建议标上VisibleAnywhere,调试体验会好很多。
第四,构造函数里初始化UPROPERTY时,不要依赖构造函数的参数初始化列表。UE推荐在构造函数体内初始化,或者在声明处给初值,因为UObject的构造是两阶段的,先构造原生部分再构造反射部分,某些路径下初始化列表会绕过反射初始化,导致编辑器里看到的值和实际值不一致。
第五,BlueprintImplementableEvent声明成const或静态的,UHT会直接编译报错。蓝图事件本质上不能是静态的,这一点在不同UHT版本里从未放松过。所以遇到这类报错,优先检查修饰符而不是检查语法。
5.2 高频报错速查表
我整理了一张高频报错表,这些是社区里答疑时反复出现的类型:
| 报错信息 / 现象 | 最常见原因 | 排查思路 |
|---|---|---|
| C4003 宏参数不足 | UCLASS() 缺右括号,或宏名写错 | 检查头文件括号配对,再查自定义宏有没有污染UE宏 |
| C2447 缺少函数标题 | 某成员函数被错误地写在extern C块里 | 检查头文件是否缺失类结束花括号 |
| 类型不是声明/类模板的友元 | 用错friend或GENERATED_BODY位置不当 | 确保GENERATED_BODY放在类体内的合适位置 |
| 无法解析的外部符号 | 跨模块引用时没写模块名_API | 补齐API宏,确认模块名大小写 |
| 生成代码没有更新 | 增删UPROPERTY后编译未重跑UHT | 关闭编辑器、清理Intermediate、重新构建 |
| 蓝图看不到函数 | 忘了加UFUNCTION,或函数只定义在cpp里没在头文件声明 | 在头文件加UFUNCTION和声明 |
挑两个典型的展开说。C4003宏参数不足在UE里经常是因为写UCLASS时手滑写成了UCLASS或者UCLASS()后面括号没闭合,导致后边参数被当成宏展开。这类报错的诡异之处在于报错位置往往不在你的文件,而在某个引擎头文件里,新手容易误判。解决办法就是别追报错位置,回自己的头文件看宏名和括号。
另一个高频坑是把GENERATED_BODY和UPROPERTY的排列顺序弄乱。虽然UE头文件顺序没有硬性规定,但GENERATED_BODY最好放在类声明靠前的位置,并且保证include顺序正确:先include引擎头文件,再include自己的头文件。很多编译错误追根究底都是头文件互相包含导致的循环引用,规范顺序可以规避一大半。
5.3 查看UHT到底做了什么
当你不确定一个宏会不会生效时,最快的验证办法是编译一次项目,然后去Intermediate目录里找对应源文件的 generated.h。你不需要读完整个文件,但搜索你的类型名附近的结构,能看到属性索引赋值、函数映射表这些真实证据。用多了之后,你会形成一种直觉:宏参数写错了,多半在生成代码里能看出端倪。比如设了BlueprintReadOnly但生成代码里对应的Metadata标记不是BlueprintReadOnly,说明参数确实没被解析进去,这时再回头检查参数的拼写和逗号分隔。
6. 一个可以直接抄的完整样例:带调试面板的武器组件
前面讲的都是碎片知识点,容易知道每个宏是什么,却不知道放一起长什么样。我用一个模拟项目X的武器组件做示例,把前面提到的宏全部串起来。这个类要做的事:一个可配置伤害值、一个运行时状态值、一个控制入口函数、一个蓝图可覆盖的表现函数,再配上跨模块导出。
6.1 完整代码与逐行解读
假设模块名是WeaponSystem,头文件如下:
#pragma once #include "CoreMinimal.h" #include "Components/ActorComponent.h" #include "BasicWeaponComponent.generated.h" UCLASS(ClassGroup = (Weapon), Blueprintable, meta = (BlueprintSpawnableComponent)) class WEAPONSYSTEM_API UBasicWeaponComponent : public UActorComponent { GENERATED_BODY() public: UBasicWeaponComponent(); UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Weapon|Config") float BaseDamage = 30.f; UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Weapon|State", Transient) float CurrentHeat = 0.f; UFUNCTION(BlueprintCallable, Category = "Weapon") void Fire(); UFUNCTION(BlueprintNativeEvent, Category = "Weapon") void OnFireEvent(); virtual void OnFireEvent_Implementation(); };对应实现文件:
#include "BasicWeaponComponent.h" UBasicWeaponComponent::UBasicWeaponComponent() { PrimaryComponentTick.bCanEverTick = false; } void UBasicWeaponComponent::Fire() { if (CurrentHeat > 100.f) { return; } OnFireEvent(); } void UBasicWeaponComponent::OnFireEvent_Implementation() { CurrentHeat += 10.f; // 默认实现:播放音效、生成弹道之类的逻辑都写在这里 }6.2 关键参数的设计意图解读
我来逐行解释设计意图。WEAPONSYSTEM_API保证其他模块能include这个组件并创建它。ClassGroup加meta=(BlueprintSpawnableComponent)让它能出现在“添加组件”菜单里。BaseDamage用EditDefaultsOnly + BlueprintReadOnly,表示它是全局配置型数据,蓝图只能读,默认值里改,运行时通过逻辑间接调整。CurrentHeat用VisibleAnywhere + Transient,这是纯运行期状态,不需要存档、不需要编辑,只给调试面板看一眼当前过热值。
Fire用BlueprintCallable,是C++提供的逻辑控制入口,蓝图拿它来驱动。OnFireEvent用BlueprintNativeEvent,让C++先提供默认表现,特定蓝图武器组件里可以覆写。如果组件被挂到枪械蓝图里,策划需要改伤害时,只需打开类默认值面板,在Weapon/Config分组里改BaseDamage;需要自定义特殊开火特效时,直接在蓝图里覆写OnFireEvent,连C++都不用动。这就是一套合理宏参数设计带来的日常体验。
有人可能会问,Fire和OnFireEvent看上去有点重复,为什么不让蓝图直接调用Fire?因为Fire里面还可能包含冷却校验、协冷、网络同步这些逻辑,属于“控制入口”;OnFireEvent是“表现出口”。把两类逻辑拆开是典型的架构分工,也让BlueprintNativeEvent的“默认实现+可覆写”优势最大化。这个样例里没有写网络复制相关参数,实际项目如果要做联机射击,CurrentHeat还得考虑加Replicated,不然客户端看到的热量值永远是本地节奏。
关于说明宏,我个人最后再分享几个长期积累的习惯。第一,写新类时先花十分钟把参数检查表过一遍:要不要跨模块导出、要不要被蓝图继承、哪些字段要被蓝图读写、哪些字段要参与存档和网络。这十分钟的思考比后期改宏参数省力得多。第二,不要试图背参数表,UE的宏参数在安装的引擎源码里都能搜到,IDE里把光标放在宏名上按F12,可以直接跳到引擎头文件看注释说明,那才是最权威的文档。第三,团队协作时最值得强调的规则是“不要轻易去掉别人写好的UPROPERTY”——你删掉一个没看懂的宏可能让整条反射链断裂,报错却要很久才能定位。这些教训我都是实打实踩过来的,希望你看完这篇能少走几步弯路。