UE 里所有对象名、类名、属性名都是 FName。如果把它当字符串理解,很多设计会显得莫名其妙:为什么比较用 == 而不用 Equals?为什么 FName("Foo") 每次调用都有开销?为什么 NAME_None 是个特殊存在?答案在于 FName 根本不是字符串——它是一个指向全局字符串池的索引,池子里的每个字符串只存一份。下面基于 UE5.3(Engine/Source/Runtime/Core/Private/UObject/UnrealNames.cpp)把这套机制讲清楚。
FName 的真实结构
UE5 里 FName 只有 8 字节:
struct FName
{
FNameEntryId ComparisonIndex; // 4 字节,指向池中的字符串条目
uint32 Number; // 4 字节,数字后缀(如 _123 的 123)
};
FNameEntryId 本质上是个 uint32,编码了"在池子哪个分片的哪个位置"。比较两个 FName 是否相等,等价于比较 (ComparisonIndex, Number) 这个 64 位整数——没有内存访问、没有字符串遍历,是纯粹的寄存器操作。这就是为什么 UE 的代码里到处直接用 NameA == NameB,而不用费心做字符串比较。
数字后缀的机制也值得注意。FName("Foo_123") 并不是把 "Foo_123" 整个存进池子,而是拆成 ComparisonIndex → "Foo" + Number = 123。这样 "Foo_1" 到 "Foo_999" 共享同一个字符串条目,Number 字段独立编码。这个设计对大量带序号命名的场景(自动生成的 Actor 名、复制槽名)省了大量内存。
NameTable:分片的字符串池
UE4 时代 NameTable 是单个 TNameEntryArray,所有名字串在一个数组里。问题有两个:一是多线程创建名字要抢全局锁;二是数组无限增长,内存碎片化。UE5 改成了 FNamePool,核心变化是分片(sharding):
- 池子分成 256 个分片(shard),每个分片独立加锁。
- 字符串按哈希值落到对应分片,不同分片的创建可以并行。
FNameEntryId编码包含分片号和分片内偏移,解码是两次数组下标。
源码在 Runtime/Core/Private/UObject/UnrealNames.cpp 的 FNamePool 类。分片数量 256 是个经验值:足够分散多线程竞争,又不会让单个分片的负载太轻。
哈希:忽略大小写,保留显示形式
FName 的比较是大小写不敏感的——FName("Foo") == FName("foo") 为 true。实现方式是哈希时统一转小写再算,但条目里同时存着首次注册时的原始大小写形式(display string)。ComparisonIndex 指向的条目保存小写哈希用于比较,ToString() 输出的是 display string。
这个设计的后果:
FName("MyObject")和FName("myobject")是同一个 FName,比较相等。- 但
ToString()的输出取决于谁先注册了这个名字。如果"myobject"先入池,之后FName("MyObject").ToString()返回"myobject"。这是编辑器里名字大小写"莫名其妙变了"的根因。 - 需要区分大小写的场景(比如配置文件 key)不能用 FName,得用 FString。
EName:硬编码的常用名
引擎内置了几百个高频使用的名字(NAME_None、NAME_GameThread、NAME_Transient 等),通过 EName 枚举在编译期确定。这些名字在启动时批量注册到池子里,之后通过 EName 值直接构造 FName,跳过哈希查找。NAME_None 的 ComparisonIndex 固定为 0,是个合法的"空值"——判断名字是否为空的标准写法是 Name.IsNone(),等价于比较 ComparisonIndex 是否为 0。
序列化:FName 存索引,不存字符串
FName 序列化到资产文件(.uasset)时,写的是包内 Name Map 的索引,不是字符串本身。每个包的头部(Package Summary)维护一张 Name Map,记录这个包用到的所有 FName 的字符串。加载时先把 Name Map 读进来注册到全局池,之后的 FName 反序列化就是查表 + 构造 8 字节结构。
这意味着同名字符串在包文件里只存一份,包内所有引用共享索引。跨包引用时,每个包各自维护自己的 Name Map——同一个名字在 A 包是索引 5,在 B 包可能是索引 12,全局池负责把它们映射到同一个 ComparisonIndex。
性能账
FName 的核心收益:
| 操作 | FString | FName |
|---|---|---|
| 比较 | O(n) 逐字符 | O(1) 整数比较 |
| 哈希 | O(n) 计算 | O(1) 取索引做哈希 |
| 内存 | 每实例一份 | 全局共享一份 |
| 序列化 | 变长字符串 | 定长 4 字节索引 |
代价是创建开销:FName("SomeString") 要从字符串构造时需要哈希 + 查池(可能触发注册),比 FString 的构造贵。所以热路径上应该缓存 FName 而不是反复从字符串构造。
和 FString、FText 的分工
UE 里三套字符串类型各司其职,选错了性能和功能都会受损:
FName:内部标识符。对象名、类名、属性名、Socket 名、Tag 名。比较和哈希是 O(1),但不可本地化、大小写不敏感。FString:通用字符串。动态分配、可修改,适合日志拼接、临时计算。比较是 O(n)。FText:面向用户的文本。带本地化支持(通过NSLOCTEXT/LOCTEXT挂到 Localization Dashboard),比较和显示都走文化感知的规则。
转换的开销要注意方向:FString → FName 要哈希查池(可能触发注册和锁),FName → FString 要查条目拷字符串(分配内存)。两个方向都不是免费的,热路径上应该缓存转换结果。FText → FName 没有直接路径,要先 ToString() 再转,基本只出现在编辑器工具代码里。
多线程下的注册竞争
FNamePool 的分片锁让不同分片的注册可以并行,但同一分片内的注册仍然互斥。实测在异步加载高峰期(同时加载几十个包,每个包的 Name Map 几百个名字),分片锁的竞争能在 stat thread 里看到 AsyncLoadingThread 的等待尖峰。缓解办法是 cook 时开启名字表的预计算(PackageStore 会把常用名字打包进 preload 数据),运行时直接映射而不是逐个注册——这是 5.x IoStore 加载比 4.x 快的原因之一。
调试器里看 FName
FName 在调试器里显示为两个数字(ComparisonIndex + Number),不直观。两个实用手段:
- Visual Studio / Xcode 的 Natvis 文件(UE 源码
Engine/Extras/VisualStudioDebugging/UE5.natvis)会自动把 ComparisonIndex 解析成字符串显示,装了 Natvis 之后调试体验完全不同。 - 代码里临时打印用
*Name.ToString()——星号把 FString 转成TCHAR*给UE_LOG。漏了星号是新手编译错误的高频来源。
另一个坑是 FName 作为 TMap 的 key:哈希直接取 ComparisonIndex,速度极快,但遍历顺序不稳定——同一个名字在不同进程里 ComparisonIndex 可能不同(取决于注册顺序),依赖 TMap 遍历顺序做确定性逻辑(比如回放系统)会出跨进程不一致。需要确定性顺序时先按 ToString() 排序。
实战结论
- 热路径(Tick、渲染循环)里的名字比较直接用
==,不要ToString()再比较字符串——后者是 O(n) 还要分配内存。 - 配置文件、用户输入这类需要精确大小写的场景不要用 FName。
NAME_None是合法空值,判断用IsNone();FName()默认构造就是NAME_None。- 包加载时 Name Map 的注册是全局池的写入操作,多线程加载不同包时在分片锁上会有竞争——这是异步加载线程偶发等待的来源之一。