UPROPERTY 这三个字母,几乎是每个用虚幻引擎(Unreal Engine)写 C++ 的人第一步就会撞到的东西。最典型的一幕就是:你在类里声明了一个int32 Health;,满心期待它出现在细节面板里、能被蓝图读到,结果编辑器里干干净净什么都没有。不是引擎坏了,是漏写了 UPROPERTY 这个宏。它本质上就是 UE 反射系统的入口开关,只有被它标记过的成员变量,编辑器、蓝图虚拟机、序列化系统、垃圾回收器才会“看到”这个变量。这篇文章是系列里的第一篇正篇,我干脆把 UPROPERTY 里能填的参数按功能分类过一次,配合实际工程里踩过的坑一起讲,适合刚入门 UE C++ 的开发者,也适合写过一段时间但一直靠“复制粘贴参数”糊弄过去的人。
1. 在声明成员变量之前,先搞懂 UPROPERTY 到底替你打开了哪些能力
1.1 基础语法与生效位置
UPROPERTY 是一个宏,官方叫法叫“属性说明符”,它写在成员变量声明上方,格式是UPROPERTY(参数1, 参数2, ...),括号里可以填零个或多个参数。最基础的用法是这样:
UCLASS() class MYGAME_API AMyCharacter : public ACharacter { GENERATED_BODY() public: UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Character") float WalkSpeed = 600.0f; };需要注意的是,它只能修饰 UCLASS 或 USTRUCT 内部的成员变量。普通函数里的局部变量、函数参数、静态全局变量,这些都用不上 UPROPERTY。更准确地说,UPROPERTY 标记的必须是“反射系统认识的类”里面的成员属性,比如 AActor、UActorComponent、UObject 派生类,或者带 USTRUCT 的结构体。这一点很多人一开始会搞混,以为任何变量都能挂这个宏,结果编译报错也不知道为什么。
宏的作用范围决定了它能影响的维度:编辑器面板上的可见性、蓝图节点能不能读写、存档时要不要写入、网络复制时要不要同步。这几件事看着不相关,但底层全部依赖同一个东西——UClass 的反射信息。没有 UPROPERTY,虚拟机和编辑器就完全不知道这个成员存在。
1.2 哪些类型值得被标记
理论上,任何能在反射系统里表达的类型都可以标 UPROPERTY,包括基础类型(bool、int32、float、double)、FString、FName、FText、FVector、FRotator、FTransform,以及 TArray、TMap、TSet 和 UObject 指针。但不是所有类型都能被完整支持,比如 TMap 的键值类型本身必须也是反射系统能认识的,嵌套得很深的自定义类型也要小心。
这里有个非常容易踩的坑:C++ 标准库容器(std::vector、std::unordered_map 等)不能直接标 UPROPERTY。如果你写UPROPERTY() std::vector<int32> Data;,编译会直接报错。必须换成 TArray、TMap。原因很简单,UE 的反射系统和 GC 系统不认识标准库容器,也不知道怎么去遍历里面的 UObject 指针。
还有一个更隐蔽的规矩:非 UPROPERTY 的 UObject* 指针成员变量,垃圾回收器不会把它当成一条引用关系来追踪。也就是说,你 new 出来的对象可能在某次 GC 时被判定为“无引用”直接回收掉,然后你下次访问这个指针,就是访问一块已经释放的内存,轻则读到脏数据,重则直接崩溃。这个问题的排查难度很高,因为崩溃点往往不在指针本身,而在之后某个使用它的函数里,给你一种“这里代码明明没写错”的错觉。
1.3 无参 UPROPERTY 的默认行为,以及一个 GC 陷阱
UPROPERTY()空括号不带任何参数,它其实也不是完全没用——它至少让 GC 系统能追踪到这个成员,并且属性本身也会参与 UObject 的默认序列化流程。但注意,它不会出现在编辑器细节面板里,蓝图节点默认也不能访问,网络复制默认关闭。
关于序列化,这里必须说细一点,不然很容易被误导。UE 中普通 UPROPERTY() 属性在保存关卡时,其实会被写入关卡文件,因为这是 Actor 实例序列化的一部分。而 UPROPERTY(SaveGame) 是额外标记给“游戏存档系统”用的,比如 UGameplayStatics::SaveGameToSlot 这类 API 只处理标了 SaveGame 的属性。两者作用域不同,后面第 4 章会详细拆。
GC 陷阱再补一句:如果成员变量不标 UPROPERTY,但它是 UObject 指针,它在 GC 看来就和一块普通内存差不多,不会因为“有人还指向它”而存活。我见过一个项目,某个 Manager 里放了一堆 UDataAsset 指针,没标 UPROPERTY,结果运行时偶发指针失效,每次玩家换关卡时间还不一样,最后排查到是数据资产被 GC 干掉了。加了 UPROPERTY() 之后问题彻底消失。所以哪怕你完全不碰编辑器、不用蓝图,只要类里有 UObject* 成员,请无条件给它挂一个 UPROPERTY,这是 UE C++ 的底线习惯。
2. 编辑器可见性参数:六种写法背后的四种生命周期
2.1 逐对拆解 Edit/Visible 与 DefaultsOnly/InstanceOnly
编辑器可见性参数应该是 UPROPERTY 里被用得最多的一族,但很多人只记住了 EditAnywhere 和 VisibleAnywhere,剩下的四种组合基本靠猜。这一族参数其实是两个维度的组合产物。
第一个维度是“能不能改”:Edit 表示可以编辑,Visible 表示只能看不能改。第二个维度是“在哪个层级上可见/可编辑”:DefaultsOnly 表示只在类默认值(Class Defaults)里可见,InstanceOnly 表示只在放置到关卡或蓝图实例的细节面板里可见,而 Anywhere 表示所有层级都生效。
为什么要分两个层级?因为 UE 的 Actor 有“类默认值”和“实例”两个状态。在 C++ 类里声明的初始值属于类默认值,蓝图里修改的是蓝图默认值,而把 Actor 拖进关卡后,每个放置出来的 Actor 都是一个实例。有些配置是整类通用的,你希望用户只能在类默认值里改;有些配置是每个实例都不同的,比如门应该朝哪个方向开,那就应该暴露在实例面板上。
组合起来就是官方文档里列的六种:
EditAnywhere:类默认值和实例都能编辑EditDefaultsOnly:只能在类默认值/蓝图默认值里编辑EditInstanceOnly:只能在实例上编辑VisibleAnywhere:类默认值和实例都能看到,但不能编辑VisibleDefaultsOnly:只在类默认值里只读可见VisibleInstanceOnly:只在实例里只读可见
2.2 六种组合速查表
| 说明符 | 类默认值/蓝图默认值 | 关卡实例 | 典型用途 |
|---|---|---|---|
| EditAnywhere | 可编辑 | 可编辑 | 通用配置参数,如移动速度、伤害数值 |
| EditDefaultsOnly | 可编辑 | 不可编辑 | 数据资产、角色基类共享能力配置 |
| EditInstanceOnly | 不可编辑 | 可编辑 | 每个放置实例单独定制的门朝向、巡逻点 |
| VisibleAnywhere | 只读 | 只读 | 运行时状态展示,如当前生命值 |
| VisibleDefaultsOnly | 只读 | 不可见 | 组件引用、内部状态调试 |
| VisibleInstanceOnly | 不可见 | 只读 | 实例运行时内部状态,不希望被误改 |
表格里有一块很容易被忽略:VisibleDefaultsOnly在关卡实例上是直接看不见的,VisibleInstanceOnly在类默认值里看不见。这会导致一个很常见的迷惑行为——你在 C++ 里声明了一个组件指针,用 VisibleDefaultsOnly,蓝图类默认值里能看到,但拖到关卡后,实例细节面板里看不到这个组件条目了。很多人会以为自己没写对,其实只是层级没对上。
2.3 从默认值到实例值:选型建议
实际项目里,我的建议很简单:凡是运行时会被代码修改、只想给策划或美术看状态的,用 VisibleAnywhere;凡是需要在蓝图子类里统一配置的,用 EditDefaultsOnly;凡是每个放置实例都需要单独调的东西,用 EditInstanceOnly;如果拿不准,默认用 EditAnywhere 一般不会出大问题,只是面板会有点乱。
这里还要强调一个容易混淆的概念:编辑器的可见性和蓝图的BlueprintReadOnly/Write是两套正交系统。也就是说,VisibleAnywhere只是让编辑器里不能手动改,但如果你同时写了BlueprintReadWrite,蓝图节点照样可以写这个变量。反过来,EditAnywhere但只写BlueprintReadOnly,编辑器里能改,蓝图里却只能读。这类组合是合法的,也经常是故意设计的——比如一个变量希望策划在编辑器里配好,但运行时蓝图逻辑不允许随便覆盖。
3. 蓝图读写与生成暴露:把变量权限设计清楚,而不是一刀切
3.1 BlueprintReadOnly 与 BlueprintReadWrite 的真实边界
这是 UPROPERTY 参数里语义最直白的一对,但实际写代码时经常被误用。BlueprintReadWrite意味着蓝图里既能读又能写,BlueprintReadOnly意味着蓝图里只能读不能写。
我在项目里见过不少同事把状态类的变量全都标成BlueprintReadWrite,理由是“反正蓝图里可能要用”。结果就是蓝图逻辑满天飞,任何一个蓝图节点都能改血量和金币,出 bug 时根本不知道是谁动的。更好的做法是:只把外部需要修改的输入参数标 ReadWrite,把内部运行时状态标 ReadOnly。比如:
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Character|Stats") float MaxHealth = 100.0f; UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Character|State") float CurrentHealth = 100.0f;这样蓝图可以读当前血量并显示到 UI,但不能随手把血量改掉。如果后面真的需要加血减血,提供函数而不是开放变量本身。
3.2 使用 BlueprintGetter/BlueprintSetter 的意义
如果你不只想要“能读能写”,还想在读写的时候执行额外逻辑,那就要用BlueprintGetter和BlueprintSetter指定访问器函数。写法是这样的:
private: UPROPERTY(BlueprintReadWrite, BlueprintGetter = GetHealth, BlueprintSetter = SetHealth, meta = (AllowPrivateAccess = "true")) float Health; public: UFUNCTION(BlueprintPure) float GetHealth() const { return Health; } UFUNCTION(BlueprintCallable) void SetHealth(float NewValue) { Health = FMath::Clamp(NewValue, 0.0f, MaxHealth); }注意,BlueprintGetter和BlueprintSetter参数的值是函数名,不是meta。函数本身要标 UFUNCTION,Getter 通常用BlueprintPure,Setter 通常用BlueprintCallable。加了这两个参数之后,蓝图上生成的节点不再是直接读写变量,而是调用对应的函数。这对需要校验、事件通知、同步 UI 的场景非常有用。
3.3 ExposeOnSpawn:让生成参数变得可配置
ExposeOnSpawn是一个很有意思的参数,它能让变量出现在蓝图里Spawn Actor节点下方的参数列表里。什么意思?就是你从蓝图生成一个 Actor 时,可以直接在生成节点上传入这个值,而不需要生成之后再主动调用一个 Init 函数。
UPROPERTY(EditAnywhere, BlueprintReadWrite, ExposeOnSpawn, Category = "Projectile") float InitialSpeed = 1500.0f;这样以后其他蓝图在生成这个抛射物时,Spawn 节点就会多出一个 InitialSpeed 输入引脚,填多少它就带多少起飞。这个参数很适合做子弹初速度、敌人初始状态这类需要“一出生就决定”的数据。注意,它一般搭配 BlueprintReadWrite 或 BlueprintReadOnly 使用,纯 C++ 内部变量用不上。
3.4 私有变量的 AllowPrivateAccess 细节
还有一个细节我经常在代码里看到,但很多人不清楚为什么加:当你把属性声明在private或protected下,又想让蓝图能访问时,一般要在meta里加一个AllowPrivateAccess = "true"。
private: UPROPERTY(EditAnywhere, BlueprintReadOnly, meta = (AllowPrivateAccess = "true")) float HiddenFromCpp;它的作用是让 UE 自动生成的反射代码可以访问这个私有成员,因为 UHT(Unreal Header Tool)生成的代码本质上还是 C++ 代码,直接访问 private 成员会编译报错。不写AllowPrivateAccess,编译通常也会过,因为 UHT 有默认行为,但官方明确推荐你在私有访问时显式加上,避免头文件工具在版本升级或特定配置下生成出不可编译的代码。这里顺带说一句,虽然加了它蓝图能访问私有变量,但它不会改变 C++ 层面的访问权限,外部 C++ 代码依旧不能直接点这个私有变量,只有蓝图可以。