1. 写在前面:这一篇要解决的痛点
如果你刚接触UE5的C++,大概率会经历一个阶段:变量类型这种基础知识点,觉得没什么可学的,上课翻两页就会了,可一旦自己动手写项目,不是编译报错,就是蓝图连不上,要不就是保存后数值被重置、对象引用莫名其妙失效……这些问题七拐八弯,最后发现根子都在“变量类型到底声明对没有”上。
这篇“UE5C++Part2”针对的就是这个问题。上一篇大概是聊了创建第一个Actor类、编译和基础流程,这一篇咱们把最常见的几类变量类型彻底聊透。你没看错,就是那些看起来最基础的东西——bool、int、float、double、字符串、向量、旋转量,外加UE特有的反射机制和UPROPERTY说明符。在UE里,变量类型的意义远超普通C++语法层面,它决定了你怎么和蓝图交互、怎么序列化、怎么在编辑器里调试,甚至直接影响打包后的性能表现。
这篇适合什么基础的人看?如果你已经能创建类、大概明白头文件和源文件的关系,对C++的语法有基本概念,但碰到UE的变量声明体系还是发怵,那正好对路。如果你是完全零基础,可能会觉得反射、UPROPERTY这些概念有点绕,不过没关系,我会尽量用白话把原理讲清楚,你跟着抄作业也能跑通。
先说一个最关键的认知:UE5里你用代码写一个变量,它不是一个孤立的“存储数据的盒子”。在引擎眼中,这个变量是否暴露给蓝图、是否能被编辑器编辑、能否被存档保存、能否被垃圾回收追踪,全都由你声明时那行代码决定。明白这一点,后面所有细节就有了逻辑主线。
2. 数值变量:bool、int、float、double的正确打开方式
2.1 用int32而不是int,这不是强迫症
很多从纯C++转过来的开发者,习惯性写int、long、unsigned int,放到UE里也能编译,但问题出在跨平台一致性上。UE官方推荐用int32、int64这种带位宽的类型,因为不同平台对int的默认长度并不完全一致,有些是16位,有些是32位,甚至在同一个项目的不同编译配置下都可能出现差异。
实际写代码时,建议这样声明:
UCLASS() class MYPROJECT_API AMyActor : public AActor { GENERATED_BODY() public: UPROPERTY(EditAnywhere, Category = "Stats") int32 Health = 100; UPROPERTY(EditAnywhere, Category = "Stats") float Speed = 600.0f; UPROPERTY(EditAnywhere, Category = "Stats") double GravityScale = 1.75; };这里有个很实用的小规律:整数型统一用int32,要更大范围才用int64;小数绝大多数场景用float(注意写600.0f,那个f不能少,否则字面量会被当成double,产生隐式转换警告);需要更高精度或做天文数字级别的计算时再用double。
2.2 float和double的取舍,别被精度坑了
小数值用float还是double,很多人觉得顺手就行,但UE里的显示、移动、物理模拟大量基于float,好处是内存占用小、计算快、和引擎底层数据类型一致。坏处是精度有限,尤其做长时间累积计算时误差会越滚越大。
举个例子,如果做一个计时器,每帧累加DeltaTime,用float跑个几十分钟,误差可能达到秒级,UI上显示的时间就会和真实时间偏离。我自己就遇到过这类问题,后来改用了double存储累计时间,只在最终显示层转成float,问题就消失了。再比如做体素地形或大范围坐标计算,float的精度在千万级单位之后根本不够看,这时候double是唯一选择。
所以在项目里可以定个简单规矩:物理、移动、渲染相关的数值用float,省钱省性能;累计时长、坐标长距离运算、高精度配置项用double。
2.3 bool在蓝图里的交互,比你想的更有讲究
bool本身很简单,只有真和假,但在UE里它有一些特殊的编辑器行为。比如你在UPROPERTY里声明了一个bool,默认在细节面板会显示为复选框,这很符合直觉。不过如果声明成“位掩码”形式或者加BlueprintReadOnly,细节面板的呈现方式会不同,BlueprintReadOnly之后蓝图里就只能读不能写。
还有个容易忽略的点:C++里的bool和蓝图里的Boolean在跨语言交互时,序列化格式有专门处理,如果你用SaveGame保存存档,bool的读取顺序和打包格式都已经被引擎封装好了,不用自己折腾,但前提是必须加了UPROPERTY。没加UPROPERTY的变量,本质上就是普通C++成员变量,不参与反射,不参与存档,蓝图也完全看不见。
2.4 数值类型和UPROPERTY的关系,这里必须说透
在UE里,一个C++变量想被蓝图访问,或想出现在编辑器细节面板,必须加UPROPERTY。这不是可选项,而是引擎反射系统的硬性要求。反射的意思是,C++代码编译时会被UnrealHeaderTool解析,生成一份“元数据”,让编辑器、蓝图虚拟机、序列化系统可以在运行时“看见”这个变量。
我习惯把这个过程类比成快递包裹贴标签:C++变量本身是包裹,UPROPERTY就是快递单,快递单上写着能不能编辑、能不能读写、属于什么分类、是否需要存档。没贴快递单的包裹就真的是裸奔,只有你自己的C++代码能碰到它。
所以在写任何成员变量时,先问自己三个问题:这个值要不要在编辑器里手动调?要不要给蓝图读或写?要不要随存档保存?根据回答组合出对应的UPROPERTY参数,这是每写一个变量都该走的流程。
3. 字符串三兄弟:FString、FName、FText,谁该用在哪
3.1 三个类型各管一摊,别混着用
UE里字符串相关有三种类型,新手最容易搞混。我总结成一句话:FString是动态文本,FName是常量名,FText是本地化显示文本。
FString就是一个典型的动态字符串,可以拼接、修改、比较内容,类似标准库的string。FName则是引擎的“名字”类型,内部实现是一个全局哈希表加索引,比较两个FName只需要比较索引,极快,但FName不支持空格、大小写敏感的常规字符串操作,它适合做资源名、Tag、Socket名、骨骼名这类“永不修改”的标识。FText则是面向玩家的文本,最大的价值是支持本地化,可以随语言切换,多语言项目必须用它来包UI文案。
实际项目里我见过的坑,九成是把FName当成FString用,或者反过来把FString塞进需要FName的地方,导致频繁转换和隐性性能损耗。
3.2 三者的声明和转换示例
UPROPERTY(EditAnywhere, Category = "Text") FString PlayerName = TEXT("Kai"); UPROPERTY(EditAnywhere, Category = "Text") FName TagName = TEXT("Enemy"); UPROPERTY(EditAnywhere, Category = "Text") FText DisplayName = NSLOCTEXT("Game", "PlayerDisplay", "Player");FString的字符串字面量包裹,记得要写TEXT(""),这个宏在非英文编码环境下能保证字符串按正确编码存储,不写的话中文很容易变成乱码。
转换方式也很常见:
FString NameStr = TagName.ToString(); // FName -> FString FName NewTag = FName(*PlayerName); // FString -> FName FString DisplayStr = DisplayName.ToString(); // FText -> FString FText NewText = FText::FromString(PlayerName); // FString -> FTextFName构造函数需要传入TCHAR指针,所以要先对FString解引用取底层数据,即FName(*PlayerName)。这个星号是新手频繁遗漏的地方,一旦漏了编译器就开始报错。
3.3 选型建议和“相等”判断的大坑
如果你在做一个名字输入框,用户输入的内容是动态变化的,必须用FString,因为FName在运行时一旦创建,会进全局表且不会释放,用户如果反复改名,每次都会往表里塞一条记录,内存越积越多。如果你在比较一个Tag是不是等于某个常量,用FName,因为FName的比较只是整数比较,速度极快。如果是UI上的显示文字、提示信息、多语言文案,用FText。
比较时有一个经典误区:FString比较用==,FName比较也用==,但它们语义不同,FString比较的是内容,FName比较的是哈希索引,所以两个内容相同但创建方式不同的FName也可能相等,而FString内容相同才相等。反过来,如果你在代码里把一个FString和一个FName用==比较,编译器可能给出报错或隐式转换,导致比较结果不符合预期。稳妥做法是统一类型后再比较,或者先转成FString、FName再比。
4. 引擎高频复合类型:FVector、FRotator、FTransform
4.1 FVector的日常操作,比你想的要简单
FVector是UE里最常碰到的复合类型之一,内部存了X、Y、Z三个float。它是三维向量,代表位置、方向、位移或者速度。定义一个向量:
UPROPERTY(EditAnywhere, Category = "Movement") FVector SpawnLocation = FVector(100.0f, 0.0f, 50.0f);常用操作包括相加、相减、归一化、距离、点积、叉积等:
FVector NewLocation = GetActorLocation() + FVector(0.0f, 100.0f, 0.0f); FVector Dir = (TargetLocation - StartLocation).GetSafeNormal(); float Distance = FVector::Dist(StartLocation, EndLocation); float DotResult = FVector::DotProduct(A, B);这里有一个我在项目里反复遇到的体验点:GetSafeNormal()在零向量时会返回一个安全向量,不会产生NaN,而直接用Normalize()则可能在零向量时出错。凡是处理玩家朝目标点移动的逻辑,建议一律用GetSafeNormal,省得调试时突然出现黑屏和闪电般的相机抖动,排查起来极其痛苦。
4.2 FRotator不只是三个角度,组合起来容易懵
FRotator存储Pitch(俯仰角)、Yaw(偏航角)、Roll(翻滚角),很多人以为它就是三个float封装了一下,但在实际使用中,如果你直接对FRotator做插值或加减,很容易遇到万向锁、角度环绕和方向不一致的问题。
在蓝图里表现最明显的是:两个角色朝向相同的FRotator,直接==比较可能返回false,因为角度比如一个是-170度,一个是190度,数值完全不同但实际朝向一样。所以真正做旋转插值,建议用FQuat或FMath::RInterpTo。FRotator更多是用来给美术、策划在编辑器里直观输入角度值,内部计算时转成FQuat会更稳定。
从FRotator取单轴角度时,注意Pitch、Yaw、Roll的单位是度,而很多数学库默认用弧度,混用就会算出诡异的结果。我建议养成习惯:进入UE数学函数时,先确认参数到底是度还是弧度,UE大部分对外接口是度,但某些引擎内部函数和外部数学库是弧度,这是最容易踩的坑之一。
4.3 FTransform是“位置+旋转+缩放”的组合
FTransform包含Location(位置)、Rotation(旋转)、Scale(缩放),一个对象在场景里的完整变换都能用它表达。它是对外非常友好的类型,你可以直接GetActorTransform()获得Actor当前变换,也可以用SetActorTransform()一下设置位置、旋转、缩放三个属性。
新手比较困惑的是FTransform之间的乘法。FTransform的乘法不算严格意义上的数学矩阵乘法,更多是“变换叠加”。比如把A变换叠加到B上:
FTransform Combined = A * B;在项目里,做挂点、武器槽、摄像机位置时经常会用到。一个实用技巧:如果你要计算一个Actor相对另一个Actor的偏移,先转成FTransform然后求逆,或者直接用相对变换函数,不要手工拆出位置和旋转再拼回去,否则很容易出现偏移旋转后的方向错误。
5. UPROPERTY参数详解:这一行代码决定了变量的一生
5.1 常用说明符的参数组合
前面反复提到UPROPERTY,现在系统说一遍常见参数。最基础的一组:
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Config") float MoveSpeed = 600.0f; UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Config") int32 MaxHealth = 100; UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Status") float CurrentHealth = 100.f;- EditAnywhere:在细节面板可见且可编辑,适合经常手动调整的配置项。
- EditDefaultsOnly:只在类默认值里可编辑,蓝图实例化后不可改,适合“所有实例共享的模板值”。
- VisibleAnywhere:细节面板可见但不可编辑,适合运行时状态、计算的临时结果。
- BlueprintReadWrite:蓝图里可读可写,适合需要蓝图逻辑修改的数据。
- BlueprintReadOnly:蓝图里只能读不能写,适合只允许C++修改的数值。
- Category:在细节面板里把属性分组,方便编辑查找。
组合逻辑很简单:标识符拆成两半,一个是“编辑器可见性”,一个是“蓝图访问权限”。我见过很多初学者把每个属性都写成EditAnywhere + BlueprintReadWrite,图省事,但这样会让设计面变得非常不可控,蓝图里一个误操作就能把数值改乱,细节面板上密密麻麻全是可编辑项,也容易让策划误改逻辑数据。
5.2 默认值应该在构造函数里给
在UE的C++类中,变量的默认值有两种常见给法:一种是在头文件声明处直接赋值,比如float MoveSpeed = 600.0f;另一种是在构造函数里用初始化列表或构造函数体内赋值。
两者大部分情况下等价,但官方更推荐在构造函数中完成初始化,尤其是涉及NativeClass或组件创建时。原因很简单:构造函数是类创建的必经入口,所有初始化逻辑收敛在一个地方,阅读代码时能一眼看到默认值从哪里来。如果某些值需要在子类里被覆盖,使用构造函数+UPROPERTY(Category)组合,蓝图子类可以再覆盖默认值,这种方式最灵活。
5.3 影响存档和复制的高级参数
如果你是做多人联机项目,UPROPERTY还有两个至关重要的参数:
UPROPERTY(Replicated) float Health; UPROPERTY(SaveGame) int32 Level;Replicated表示该变量参与网络复制,服务器修改后,客户端会自动同步。没有这个标记的变量,即使服务器改了,客户端也不知道。SaveGame表示该变量会随存档序列化,配合存档系统使用。这两个参数虽然看起来只是加个单词,但背后涉及整个网络同步和存档框架,一旦漏写,联机时玩家血量不同步、读档后等级丢失,排查方向往往很难第一时间想到是UPROPERTY的问题。
这里分享一个我的排查习惯:每次遇到联机数据不同步、存档数据丢失这类问题,第一反应不是查逻辑,而是查变量上头有没有对应的UPROPERTY标识。因为这类问题十有八九是变量根本就没参与复制或序列化,逻辑再对也没用。
5.4 进阶:Transient、Meta和隐藏属性的实际用途
Transient会把变量标记为“不需要持久化”。典型的例子是运行时缓存数据、每帧计算的指针、临时对象引用。如果不加Transient,引擎会在某些持久化操作中尝试序列化这个变量,轻则浪费性能,重则在你加载存档时恢复一堆过期的临时引用,导致崩溃。这是一个比较隐蔽的坑,我后来养成了习惯:凡是不需要存档、不需要复制的成员变量,统一加Transient。
Meta参数提供了更细粒度的控制,其中UIMin、UIMax、ClampMin、ClampMax经常用来限制编辑器里可输入的数值范围。比如:
UPROPERTY(EditAnywhere, meta = (ClampMin = "0.0", ClampMax = "1.0", UIMin = "0.0", UIMax = "1.0")) float Volume = 0.8f;ClampMin和ClampMax会强制逻辑上不超出范围,UIMin和UIMax则只限制编辑器滑动条的范围。如果只想要UI上的滑动条限制,却仍允许在代码里修改超过范围的值,用UIMin/UIMax更合适。如果希望数值在运行时也永远不可能超出范围,用ClampMin/ClampMax。
6. 编写过程中绕不开的坑:目标与蓝图交互时的常见问题
6.1 变量类型不匹配的跨语言编译错误
做C++和蓝图混合开发时,最常见的报错之一是“Cannot convert type”。比如你在C++声明了一个TArray ,在蓝图里连接了TArray 的引脚,编译就会直接红报。
这类问题往往并不是设计者有意的错误,而是改版时类型调整没同步过来。比如原来SpawnCount是int32,后来想支持小数改成float,结果蓝图里从某个接口拿出来的还是int32,连过去就炸了。我的建议是:所有给蓝图用的数组、函数参数、返回值,头文件写完先编译一遍,再打开蓝图重新连接节点,旧节点类型不匹配会立刻标红,趁早清理。
6.2 编辑器里改了值但不生效的三种原因
遇到过太多次这种情况:细节面板里把移动速度从600改成900,运行后角色还是慢吞吞的600速度。排查下来常见原因有三个:
第一,你改的是CDO默认值,但有些Actor在编辑器中已经躺了很久,CDO更新后已存在场景里的实例并不总是自动刷新默认值,需要手动重新应用。第二,你声明时用的是EditDefaultsOnly,蓝图的实例面板里虽然能看到这个值,但改动不会真正写入,必须去类默认值里改。第三,你的代码里在BeginPlay或某个事件里用代码再次给这个变量赋了值,比如从存档读取覆盖了默认值,那编辑器里怎么改都会被代码盖掉。
这三类原因几乎覆盖了我项目里遇到的九成“改了没生效”问题,排查顺序是先看有没有代码赋值,再看UPROPERTY声明类型,最后看是不是CDO刷新问题。
6.3 FName和FString比较引起的“看似相等却不相等”
如果做一个Tag系统,比如给敌人打上“Boss”标签,然后用Tag == TEXT("Boss")判断,有时候会莫名返回false。最常见的原因是:你把Tag存成了FString,而对比处用了FName,这个FString先被隐式转换成了FName,而转换后的FName和你定义Tag时的FName可能因为大小写、空格或字面量编码不同而不相等。
例如你在Actor里设Tag = TEXT("Boss "),尾部多了一个不可见空格,和TEXT("Boss")比较自然不通过。这类问题排查起来极其费神,因为肉眼难以察觉空格。我现在的做法是:统一用FName存Tag,比较前先打印出来看前后两端有没有空格。团队规范里也明确要求,所有Tag、Socket名、动画通知名一律使用FName,不允许用FString存储。
6.4 常见问题速查表
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 蓝图里看不到C++变量 | 忘记加UPROPERTY | 补上UPROPERTY并设置可见性参数 |
| 蓝图里能读不能写 | 用了BlueprintReadOnly | 改成BlueprintReadWrite |
| 细节面板里改值后运行不生效 | 代码运行时赋值覆盖 | 检查BeginPlay等初始化入口 |
| 联机时血量不同步 | 缺少Replicated标记 | 加上Replicated并配置网络相关设置 |
| 存档后数据丢失 | 缺少SaveGame标记 | 检查存档序列化路径 |
| 中文字符串显示为乱码 | 未用TEXT()包裹字面量 | 所有字符串字面量统一TEXT("") |
| FName比较结果不符预期 | 值里有不可见空格或大小写不一致 | 打印输出,统一规范存储类型 |
| 零向量归一化产生NaN | 直接调用Normalize | 改用GetSafeNormal |
7. 聊点我的个人实操体会
连载到Part2,我特别想强调一个习惯:每声明一个变量前,先默念三问——它需要在编辑器里被改吗?它需要给蓝图读写吗?它需要存档或复制吗?这三个问题的答案,直接决定你UPROPERTY括号里写什么。我在很长一段时间里都是“全都EditAnywhere + BlueprintReadWrite”,结果项目一变大,细节面板里全是参数,蓝图节点里乱糟糟的,后期维护成本高得离谱。后来学乖了,能VisibleAnywhere就绝不EditAnywhere,能BlueprintReadOnly就绝不ReadWrite,反而代码和蓝图都清爽不少。
还有一个体会是:变量类型的选择,很多时候不是语法问题,而是设计问题。FName还是FString、float还是double、FVector还是FTransform,真正决定因素的往往不是“哪个能编译”,而是“哪个能支撑后续功能演进”。你提前想清楚是存Tag还是存动态文本,是算短距离还是算全球坐标,后续重构时就能少掉很多头发。
如果你是跟着这个系列在学UE5 C++,这一篇的变量类型就是地基里的地基,建议你打开编辑器,照着上面的代码亲手敲一遍,把每个类型的UPROPERTY参数换着组合试一遍。光看永远记不牢,只有你自己把EditAnywhere改成EditDefaultsOnly之后,亲眼看到细节面板里某些值变灰不可编辑,才真正明白那个英文单词到底在干什么。
下一篇大概率会聊到函数、事件还是TArray、TMap这类的容器,到时候你会发现在这一篇打下的变量基础有多重要。先把bool、int32、float、FString、FName、FVector、FTransform这些老朋友混个脸熟,后面的路就好走多了。