第一次完整地看完虚幻C项目的代码是在我入行做游戏客户端大概半年的时候。当时我已经在蓝图的舒适区里呆习惯了变量类型基本就是 Integer、Float、Boolean 这几个轮着用。结果领导甩给我一个用 C 写好的模块让我改成蓝图接口。我打开 Visual Studio满屏的 uint32、BYTE、FString、TArray前缀字母五花八门当时整个人是懵的。尤其是看到一行UE_LOG(LogTemp, Warning, TEXT(PlayerName: %s), *PlayerName);我盯着那个*PlayerName看了很久——这不是个字符串变量吗前面加个星号是要干嘛解引用FString 内部还有指针后来我才慢慢明白这其实是虚幻 C 里最基础但也最绕人的一套类型体系。这篇文章就把我踩过的坑、后来彻底搞懂的东西一次性说清楚int32 和 uint32 为什么是 UE 的主角BYTE 到底是不是就是无符号字节FString 前面那一堆 F 开头的东西靠什么区分以及那个让无数新手抓狂的*运算符到底是什么原理。1. 为什么 UE 不让你直接用 int非要搞出 int32 和 uint321.1 教科书里的 int 是薛定谔的宽度很多从纯 C 转到 UE 的人第一个不适应的点就是为什么到处是 int32、uint8、float但就是很少看到 int、unsigned int原因不复杂——C 标准从来没规定过 int 到底占多少字节。它只给了一个下限至少 16 位。也就是说在一台老式的嵌入式设备上int 可能是 16 位的在你的 Windows 台式机上int 是 32 位的换到某些 64 位平台int 依然是 32 位的。但谁也不能保证 50 年后新平台把它搞成 64 位。问题出在游戏是要跨平台的。你的存档数据、网络协议包、玩家属性数值如果在 PC 上用 32 位 int 存了一个数值打包同步到手机上手机平台却把 int 解释成 16 位那数据的二进制表示直接就错位了轻则数值不对重则内存越界崩溃。所以虚幻引擎做了一个非常硬核的决定所有基础数据类型强制使用固定宽度别名。int32全称叫 32-bit signed integer不管在哪个平台、哪个编译器下它都必须不多不少占 32 位4 字节。这就保证了你在 Windows 上写的一个 uint32 变量的内存布局和在 Android、iOS、主机平台上完全一致。到头来你只需要记住一个结论在 UE C 里int32 就是教科书里的 int但宽度被焊死了int 在 UE 里虽然也能编译但官方推荐你养成写 int32 和 uint32 的习惯。1.2 int32默认的数值主力但它有个边界int32 的取值范围是-2,147,483,648 ~ 2,147,483,647大约是正负 21 亿。游戏里绝大多数整数用它都够了玩家等级、金币数量、道具 ID、数组下标、敌人数量。我在新手期最常用到它的场景就是遍历数组for (int32 i 0; i Items.Num(); i) { // 处理第 i 个物品 FString ItemName Items[i].Name; UE_LOG(LogTemp, Warning, TEXT(Item[%d]: %s), i, *ItemName); }看到TArray::Num()返回的类型吗就是 int32。你遍历数组的循环变量也理应跟它对上。这就牵扯出一个很实际的编码经验UE 容器相关的索引、计数基本都以 int32 为准。如果你写一个函数返回元素数量最好也返回 int32而不是 int省得后面跟容器打交道时出现类型不匹配的警告。1.3 uint32不要轻易碰负数的人uint32 是无符号 32 位取值范围0 ~ 4,294,967,295刚好是 int32 正数上限的两倍还多。适合用在永远不可能是负数的场景比如网络包的标志位、二进制位掩码、或一个物品的绝对唯一 ID如果用 int3221 亿的累加在极端游戏里是有可能溢出的uint32 能撑到 42 亿。但请注意uint32 和 int32 混用是新手最容易踩的坑。看这样一个例子int32 Health -10; uint32 MaxHealth 100; if (Health MaxHealth) // 这里会发生什么 { // 不会进到这里吗未必 }C 的隐式类型转换规则中有符号数和无符号数比较时结果取决于具体实现和编译器警告策略。但别指望它给你想要的结果——真实的情况往往是-10被当成一个巨大的无符号数来比较逻辑直接错乱。所以我的建议是要么全用 int32要么全用 uint32不要在同一个表达式中混合两种有符号性。UE 的源码里这种混用也偶尔会出现但你踩到一次就知道有多痛了。提示UE_LOG 打印 uint32 时建议用%u而不是%d。虽然很多场景下%d也能打出正确的数但严格来说格式说明符和参数类型不匹配是未定义行为。打印 int32 用%d打印 uint32 用%u记住这个小事能帮你少查很多诡异的日志问题。2. BYTE 的前世今生unsiged char 在 UE 里的特殊地位2.1 BYTE 就是无符号字节但它不是 char标题里写得很直接BYTE 是无符号字节。这句话是对的但在 UE 里要稍微展开一点。在 C 原生的类型里最小的整数类型是 char但麻烦在于标准没有规定 char 是 signed 还是 unsigned这取决于编译器和平台。因此UE 干脆定义了自己的别名体系其中就包括类型字节数取值范围本质int81-128 ~ 127signed charuint810 ~ 255unsigned charBYTE10 ~ 255同 uint8int162-32768 ~ 32767shortuint1620 ~ 65535unsigned shortint324-21亿 ~ 21亿intuint3240 ~ 42亿unsigned intint648很大long longuint648非负的很大unsigned long long你可能会疑惑既然已经有了 uint8为什么还要一个 BYTE这其实是从早期 Unreal Engine 时代传下来的习惯可以理解为对字节byte这个概念的直接命名。在源码层面BYTE 和 uint8 是同一个东西在大部分平台上的 typedef 都一样但语义上用 BYTE 的地方通常强调这里存的是一段原始字节数据的一部分而用 uint8 的地方更强调这是一个 0~255 范围内的数值。2.2 实际中 BYTE/uint8 的典型使用场景以我做过的实际项目为例BYTE 和 uint8 主要用在几个地方第一内存紧凑的数值存储。比如一个玩家同时拥有的状态效果列表状态类型一共只有 10 种用 int32 存 10 个状态会占 40 字节用 uint8 存只要 10 字节。如果一个 GameObject 有几百个这样的字段差距就出来了。第二位运算和位掩码。权限标记、动画状态位、开关选项非常典型地用uint8 Flags 0x01 | 0x04;这类方式组合。按位操作天然适合 8 位、16 位这样的定宽类型。第三图像和序列化数据。一张 8bit 的贴图数据里每个颜色通道的原始值就是一个字节。你在 C 里写网络包的包头、WriteInt 到存档时底层处理的也经常是 BYTE 数组。用一张表来说明白哪种场景选哪个场景推荐类型理由玩家金币、等级、数量int32符合直觉不易溢出日志好打索引、数组下标int32跟 TArray 的 Num() 对齐永不为负的 ID、位标志uint32避免符号问题上限更高小范围数值0~255或位运算uint8 / BYTE省内存语义清晰存档标记字节、RGB 颜色分量BYTE强调原始字节性质2.3 为什么别直接用 char在 UE 的代码规范里直接写char是比较不受待见的原因就是前面说的符号性不确定问题。UE 提供了两个明确的替代ANSICHAR单字节字符等价于我们的 char。WIDECHAR宽字符等价于 wchar_t在 Windows 通常是 16 位在 Linux 是 32 位。TCHAR这是一个智能类型它根据项目设置自动决定是 ANSICHAR 还是 WIDECHAR。UE5 的项目默认都是 Unicode 宽字符。在任何跟文字、文件名打交道的地方你基本不会直接操作 char而是用 TCHAR 或 FString。这一点在 FString 的*运算符部分会再次体现。所以请养成一个习惯看到char就警惕一下在 UE 里它几乎总该被 TCHAR 或 uint8 替换。3. 类型前缀 U / A / F / I / T / E / S一眼分辨 UE 家族的暗号3.1 前缀不是装饰是类型家谱标题提到的FString 与别的类型前缀 U I T E S A实际上是在问 UE 里那些五花八门的字母前缀到底代表什么。我刚学的时候觉得这只是命名风格后来看源码才意识到这些前缀是 UE 反射系统、内存管理和代码规范的共同产物。你看到一个类名前缀基本就能判断它继承自谁、怎么创建、能不能被垃圾回收管着。最核心的几个U 前缀继承自UObject的类。比如UStaticMeshComponent、UDataAsset、UWidget。它们由引擎的垃圾回收系统GC管理通常通过NewObject创建不需要你手动 delete。A 前缀继承自AActor的类。比如ACharacter、APawn、AGameModeBase。Actor 是能放在游戏世界里的物体有 Transform可以被 SpawnActor 生成。F 前缀普通结构体或普通类。比如FString、FVector、FRotator、FTransform。它们通常是纯 C 数据结构由栈或普通的 new/delete 管理不参与 GC。I 前缀接口类。比如IInterface、IMotionController、IDamageable。通常配合 UINTERFACE 宏使用让不同家族的类型可以共享某些行为。T 前缀模板类。比如TArray、TMap、TSet、TSubclassOf。模板容器是 UE 的数据结构主力。E 前缀枚举类型。比如EMovementMode、EAbilityInput。加上前缀之后你搜索代码时看到一个 E 开头的变量名立刻知道它是个枚举方便判断取值范围。S 前缀Slate UI 层的类底层 UI 框架比如SButton、SWidget。一般游戏逻辑代码碰得少但编辑器扩展、自定义 UI 控件时会见。这还没完全局变量或全局访问器前面还会习惯性加G比如GEngine、GWorld、GEditor。蓝图里你看到的变量本质上也会被编译成带前缀的 C 类型。3.2 从看懂到会起名知道这些前缀之后一个立刻能用的技巧是自己写类时也能一眼被同行看懂。比如我要写一个玩家角色正确的做法是命名为APlayerCharacter因为需要挂到游戏世界中继承自 ACharacter。我要写一个处理背包存档的数据类不参与 GC、只是个普通结构体那就可以用FInventorySaveData。我要写一个定义 AI 状态的枚举那就是EAIState。如果你看到有人把 UI 的控件类命名成UMyWidget说明他继承自 UWidget 或 UUserWidget是 UObject 家族的由 GC 管理如果有人写成SMyWidget说明这是 Slate 纯 UI 层不走 GC。这套前缀规则真正的价值是解决代码可读性的核心痛点——看到一个名字就瞬间知道它在整个引擎生态里的位置。这在团队协作里的帮助是巨大的别人 review 你的代码时不需要点进去看声明就能推断出大部分行为。3.3 前缀和成员变量、局部变量的配合还有一个与之配套的命名习惯成员变量通常用_开头比如_Health局部变量通常不加。这在 UE C 里是从 Epic 官方代码风格保留下来的_PlayerName、_MaxHealth。但说实话不同团队风格差异很大有的用m_有的用后缀下划线我觉得最关键的是团队统一。我自己现在写的项目统一用下划线前缀因为和 Epic 源码保持一致插件、子系统的代码也能无缝衔接。4. FString、FName、FText 三兄弟到底该用哪个4.1 FString字符串里的全能选手FString是 UE 里最像标准 Cstd::string的类型。它是一个动态长度的字符数组可以任意拼接、修改、比较、格式化。我在游戏逻辑里用到最多的几个操作FString PlayerName TEXT(Unreal); PlayerName TEXT(Player); // 拼接 PlayerName.Append(TEXT(_001)); FString FullName FString::Printf(TEXT(%s_%d), *PlayerName, Level); FString Path FPaths::ProjectContentDir() TEXT(Data/SaveGame.dat);注意那几个TEXT()宏。它保证了字符串字面量在宽字符/窄字符项目设置下都是正确的 TCHAR 数组。在 UE C 里写字符串字面量永远要包上一层TEXT()这是一个几乎所有新手都会踩的坑。不写 TEXT字符串字面量会被当成 ANSI 字符数组在某些平台会乱码或编译报错。FString 适合的场景动态拼接、文件路径、序列化数据、日志输出。4.2 FName给查找而生的哈希字符串FName 和 FString 完全是两种设计思路。FName 是不可变的、被哈希处理的字符串。什么意思当你在 UE 里创建一个 FName引擎会把它放进一张全局的字符串表里并分配一个唯一的 ID。后续对这个 FName 的比较本质上是在比较整数 ID而不是逐字符对比。所以 FName 的比较速度极快但代价是创建速度慢需要查找/插入全局表而且一旦创建就不能修改。FName 适合的场景非常明确标签Tag、Socket 名字、资产引用、组件名字、动画 Curve 的名字。你蓝图里给 Actor 加的 Tag在 C 里基本都是 FName。用两个例子// 查询一个组件的挂点 FName SocketName TEXT(Hand_R_Socket); // 判断一个 Actor 是否带某个 Tag if (MyActor-ActorHasTag(TEXT(Enemy))) { // 是敌人 }注意TEXT(Enemy)本身是一个字面量但传给ActorHasTag时它会隐式构造一个 FName这个构造就是一次哈希查找。好在名字查找在游戏运行时也是高频操作引擎本身做了很多优化。4.3 FText专为给玩家看的文字而生FText 是三个字符串类型里最特殊的。它存储的字符串可以包含本地化信息——也就是同一个字符串在不同语言下的不同翻译版本。用 FText 作为 UI 显示文本是官方强烈推荐的规范。它的底层不仅保存当前语言的文本还能保存这个文本的源语言版本方便本地化工具收集和翻译。为什么不能用 FString 直接显示在 UI 上因为 FString 就是一串字符它不具备翻译能力。如果项目只在中文环境跑你可能觉得无所谓。但一旦游戏要出海UI 上一百多个 FString 全要改成 FText那就是灾难级的重构。UE 提供了 LOCTEXT 宏来定义 FText 字面量或者用FText::FromString从 FString 转换// 推荐方式使用 FText 字面量 FText DisplayName LOCTEXT(PlayerAttributeName, 攻击力); // 也可以用 FromString 从 FString 转 FText HintText FText::FromString(SomeFString);4.4 三兄弟的转换关系实际开发中三者的转换几乎天天见我直接给个转换速查表从哪到哪语句FString → FNameFName(*MyFString)FString → FTextFText::FromString(MyFString)FName → FStringMyFName.ToString()FName → FTextFText::FromName(MyFName)FText → FStringMyFText.ToString()提示FText 转 FString 通常会丢失本地化信息所以这个转换要格外谨慎尽量只用于日志输出、临时调试不要用转换后的字符串去做本地化相关的逻辑判断。5. FString 的 * 运算符解引用到底发生了什么5.1 一个星号把 FString 变成裸字符串指针这是标题里最核心的疑点*FString到底在干什么先看结论对于一个 FString 类型的对象MyString*MyString返回的是一个const TCHAR*指针指向该字符串内部缓冲区的第一个字符。你可以把它理解为把引擎的字符串对象变成传统 C/C 的字符指针。为什么要这样因为 FString 是一个类对象它不能直接作为 C 风格字符串参数传给那些要求TCHAR*或const char*的函数。在 C 这些老接口里字符串就是一个以\0结尾的字符数组首地址。*运算符就是为这个场景专门重载的。举个最常见的使用场景UE_LOG 日志FString PlayerName TEXT(Hero); UE_LOG(LogTemp, Warning, TEXT(玩家名字%s), *PlayerName);这里的%s期望一个 TCHAR 指针也就是const TCHAR*。你不能直接写PlayerName因为那是 FString 类对象不是一个指针传进去编译会报错。必须用*PlayerName取出它内部的 TCHAR 指针才能被 printf 风格的%s正确匹配。我见过很多人初学时问*PlayerName是不是解引用了一个指针严格来说这里的*不是 C 内置的解引用运算符而是 FString 类重载的operator*()。它内部做的是返回成员数组的首地址和解引用拿到指针所指对象是相反的语义但在形式上你确实是在获得一个指针。5.2 为什么不能把 * 当成万能解药理解了它返回const TCHAR*之后有几个常见陷阱你必须心里有数。第一不要把*FString存下来长期持有。看这个例子const TCHAR* RawPtr *SomeFString; // 警惕FString 对象如果之后被修改、重新分配、或者销毁它内部的缓冲区可能会被释放或重新分配你手里这个 RawPtr 就成了悬空指针访问它轻则数据错误重则崩溃。正确的姿势是只在当前调用语句里使用*FString用完即丢。第二注意这跟你自己写函数时的接收参数有关。如果你的函数接收一个 FString 参数调用者直接传PlayerName如果你的函数接收const FString也直接传。什么时候用*只有当参数类型是const TCHAR*、const char*或在纯 Windows API 里的LPCWSTR时才需要*FString。最常见的是文件操作、底层平台 API、UE_LOG。举个例子用 IFileManager 写文件FString FilePath FPaths::ProjectSavedDir() TEXT(Logs/MyLog.txt); FFileHelper::SaveStringToFile(MyContent, *FilePath);SaveStringToFile接收的参数是const TCHAR*所以必须*FilePath。第三*FString是TCHAR*不是FString*。有些人会混淆想要指向 FString 对象的指针那应该是FString* Ptr MyString。这两个是完全不同的东西要注意区分。5.3 一个完整的对比示例为了让你彻底看明白我写了一段对照代码展示什么时候用 *什么时候不用void LogPlayerInfo(const FString Name, int32 Health) { // 正确UE_LOG 的 %s 需要 TCHAR* UE_LOG(LogTemp, Warning, TEXT(Name: %s, Health: %d), *Name, Health); // 错误直接把 FString 传给 %s编译报警/输出乱码 // UE_LOG(LogTemp, Warning, TEXT(Name: %s), Name); // 正确普通 C 字符串函数需要 TCHAR* // 比如 FString::Printf 内部也接受 Format 变量本身但 %s 参数要 *FString FString Formatted FString::Printf(TEXT(Player(%s) Lv.%d), *Name, Health); }顺便说一句*FString的姊妹运算符是FStringMyString得到的是 FString* 指针。这两个运算符虽然都出现在表达式里但一个是返回底层字符数组指针一个是取对象地址代表两个完全不同的意图。5.4 经验总结*FString 不是玄学是接口适配最后我用自己的经验给这个运算符定个位。在虚幻 C 的世界里凡是跟第三方 C 库、平台 SDK、文件系统、底层 API 打交道字符串参数几乎都是const TCHAR*或const char*。FString 内部虽然装着同样的字符数据但 C 风格的接口不认识你这个类。*运算符就像是一个适配器把 FString 的封装剥开一层露出底层那个最朴素、最通用的字符指针让两个世界能对话。新手阶段最容易犯的错误不是忘写*而是到处写*。比如在蓝图里习惯了节点连线到了 C 里看到*就觉得字符串加个星星是标配。实际上你只有明确知道某个接口的参数是const TCHAR*时才需要写*。判断方法是看函数签名参数是const FString就不写是const TCHAR*就写。就这么简单不要去背什么花活。我个人在实际项目里的习惯是写日志、写文件、调平台 API 时条件反射地在 FString 前面加*除此之外尽量让字符串在 FString 的封装层里流动。这样代码既干净又不会踩到悬空指针的坑。等你在源码里看到operator*的定义再配合这次对*的理解你基本就算把 UE C 的字符串和变量类型这一关彻底打通了。