第11章 字符串与名称系统——FName、FString、FTextUE提供了三种截然不同性格的字符串类型。FName是一个不可变标识符整个进程只存一份字符串正文实例仅占8字节FString是一条普通的堆字符串和std::string定位类似但缺少短字符串优化FText则为用户可见的本地化文本而生内部维护着一套从源文本到译文的重建链。三者的内存成本、转换代价和适用场景各不相同本章逐一展开。11.1 三种字符串的定位FName的角色是标识符和键。它将字符串正文存储在全局名称池中实例只保留一个4字节索引加一个4字节数字后缀。因此10 000个Actor即使都叫同一个名字也只占80 KB左右的实例空间加上池中一份约30字节的正文。FName最常见的用途包括资产名、属性名、行名和Tag。FString是通用可变字符串内部就是一个TArrayTCHAR。它支持拼接、替换、格式化但每次操作都可能引发堆分配和复制。日志输出、路径拼接和各类运行时格式化通常使用FString。FText面向用户可见的UI文本。它内部持有一个线程安全的共享引用TSharedRefITextData指向一组带有本地化元数据的文本数据。同一段用LOCTEXT定义的文本在内存中只存一份被多处UI控件共享切换语言时FText能根据自身的重建历史自动查找新语言的译文。三者之间有一条清晰的分工线如果这段文字要显示给玩家用FText如果它是一个在代码内部流转的标识符或字典键用FName如果需要对字符串做拼接、截取或格式化操作用FString。11.2 FName——高性能标识符11.2.1 为什么需要FName游戏引擎中充斥着名字——资产名、骨骼名、属性名、标签。这些名字有三个共同特征字典量有限通常在几千到几万个不同名字的范围内频繁参与比较和查找创建后极少修改。如果用FString来存储大量重复的字符串会带来冗余堆分配而逐字符比较的O(N)成本在热路径上也难以接受。FName的方案是全局去重存储加整数ID比较字符串正文只在全局池中保留一份每个FName实例只是一个8字节的门牌号比较两个FName等同于比较两个int32。这一设计使得FName在TMap查找、属性匹配、标签过滤等场景中成为最优的键类型。11.2.2 FName的内存布局——仅8字节classFName{private:FNameEntryId ComparisonIndex;// 4字节在全局名称表中的索引uint32 Number;// 4字节数字后缀如 Bone_3 中的3// 总计8字节};全局名称表查找FName(Bone_3) — 8字节ComparisonIndex 42Number 3FNamePool[42]存储的字符串: Bone实际名字 Bone _ 3 Bone_3Number字段的巧妙之处在于引擎中大量名字带有数字后缀Bone_0、Bone_1、MeshComponent_3它们共享池中同一个Bone或MeshComponent条目仅靠Number字段区分。这避免了为每个后缀变体在池中多存一份字符串。当Number为0时表示没有后缀FName(“Bone”)的Number就是0。一个骨骼模型可能有200根骨骼名为Bone_0到Bone_199——如果每个后缀都单独占一个池条目就是200条约4 KB的池空间而用Number字段表示后缀只需一条约20字节的Bone条目。11.2.3 FName全局池——FNamePoolclassFNamePool{staticconstexprint32 ShardBits8;staticconstexprint32 NumShards1ShardBits;// 256个分片structFShard{mutableFRWLock Lock;FNameEntryAllocator Allocator;TMapuint64,FNameEntryIdLookupMap;};FShard Shards[NumShards];FShardGetShard(uint32 Hash){returnShards[Hash(NumShards-1)];}};名称池将条目分散到256个Shard中每个Shard有自己的读写锁。创建FName时先计算字符串哈希据此选中目标Shard在该Shard的LookupMap中查找。若已存在则直接返回索引若不存在则在该Shard的Allocator中分配一条新的FNameEntry并插入映射表。分片设计使得多线程同时创建不同名字时绝大多数操作落入不同Shard锁竞争极低。读操作查找已存在的名字只需获取读锁多个线程可以并发读同一个Shard而互不阻塞。只有当需要插入新名字时才升级为写锁。在引擎启动完成、大部分名字已注册之后运行时的FName构造几乎全是读操作锁开销可以忽略不计。11.2.4 FNameEntry——条目结构structFNameEntry{FNameEntryId ComparisonId;FNameEntryHeader Header;// 之后紧跟字符串数据窄字符或宽字符};structFNameEntryHeader{uint16 Len:10;// 字符串长度最大1023uint16 bIsWide:1;// 是否宽字符};FNameEntry将字符串内容直接追加在Header后面不额外分配内存。对于纯ASCII的名字变量名、路径中的字母数字部分bIsWide为0每个字符仅占1字节比TCHAR的2字节节省一半空间。包含CJK或其他非ASCII字符的名字则以宽字符形式存储。条目的Len字段只有10bit意味着单个FName的字符串长度上限为1023个字符——对于标识符来说绰绰有余。11.2.5 内存统计一个名称字符串在全局池中只存储一次假设有10,000个Actor都叫 Blueprint_Actor FString方案10,000 × ~40字节 ~400 KB FName方案 全局池1 × ~30字节 30字节 实例10,000 × 8字节 80 KB 总计~80 KB 节省400KB → 80KB (80%节省)在一个中等规模的UE项目中全局名称池通常包含数万到十几万个不同名字。假设平均每个名字8个ASCII字符加上FNameEntry的头部和对齐每条目约20字节。10万条目的池本身约2 MB而这10万个名字被引擎中成千上万处引用时每处引用只花8字节与用FString的方案相比节省的内存往往达到数百MB级别。11.2.6 FName的比较与大小写// FName比较 两个int32比较booloperator(constFNameOther)const{returnComparisonIndexOther.ComparisonIndexNumberOther.Number;// 相当于比较两个int64O(1)}FName的比较默认忽略大小写。在创建时引擎会将字符串转为一种规范化的大小写无关形式来计算ComparisonIndex因此FName(Weapon)和FName(weapon)得到相同的ComparisonIndex比较返回true。这与文件系统的行为一致——Windows上文件名不区分大小写UE选择在FName层面保持同样的语义。如果确实需要区分大小写进行比较可以使用FName::IsEqual(Other, ENameCase::CaseSensitive)。但要注意这条路径需要从池中取出原始字符串逐字符对比退化为O(N)操作失去了FName的速度优势。11.2.7 FName的常见操作与代价操作时间复杂度说明创建新名字O(N) 锁计算哈希、查表、可能插入创建已存在O(N) 读锁计算哈希、查表确认存在比较O(1)整数比较拷贝O(1)拷贝8字节哈希O(1)直接用ComparisonIndex→ FStringO(N)从池中复制字符串值得注意的是创建操作即使名字已存在也需要O(N)时间来计算字符串哈希。在循环中反复用同一个字面量构造FName不是零成本操作——应在循环外构造一次在循环内复用。引擎代码中大量使用static const FName局部静态变量正是这种优化模式voidSomeFunction(){// 只在第一次调用时构造后续直接复用staticconstFNameHealthName(TEXT(Health));if(PropertyNameHealthName){// ...}}11.2.8 FName池的生命周期与碎片化FNamePool的条目一旦创建便永远不会被释放。这是刻意的设计决策FName实例遍布引擎各处——UObject的名字、UPROPERTY的属性名、资产注册表的键——如果池中的条目被回收所有持有该索引的FName都会变成悬挂引用追踪这些持有者在实践中不可行。代价是名称池只增不减。FNameEntryAllocator在每个Shard内部以Block为单位分配内存。新条目从当前Block的尾部顺序追加满了就申请一个新Block。由于条目长度不等且从不释放Block中可能存在短条目和长条目交错的局面但因为条目不会被删除不存在传统意义上的碎片空洞——只是空间无法回收。在编辑器中长期工作、反复导入删除资产的场景下名称池会持续增长。每次导入一个新资产其中包含的所有FName材质名、纹理名、骨骼名等都会注册到池中即使之后删除该资产这些名字仍然留在池里。一个大型项目在编辑器中运行数小时后池可能增长到20-50 MB。发布构建中由于内容经过Cook、名称集合相对确定池的大小通常稳定在项目实际需要的规模内。可以通过FName::GetNameTableMemorySize()在运行时查询当前池的总字节数用于内存报告或预算监控。如果发现池异常庞大往往说明存在程序化生成大量唯一名称的代码——这是下面要讨论的问题。11.2.9 程序化名称生成的陷阱对于需要在运行时动态创建大量对象并赋予不同名字的场景例如弹幕游戏中生成上万个投射物需要意识到每个唯一字符串都会在池中永久占据一个条目。如果用FString::Printf(TEXT(Projectile_%d), Index)来为每个投射物命名一万个不同的Index就是一万条不可回收的池条目。更经济的做法是利用FName自带的Number字段。FName在解析字符串时会自动识别尾部的_N模式所有Projectile_0到Projectile_9999共享池中同一个Projectile条目Number字段存储各自的数字后缀池里只增加一条记录而非一万条。代码上只需直接传入带后缀的字符串FName的构造逻辑会自动拆分// 这些FName共享同一个池条目 Projectile, Number分别为 0, 1, 2FNameName0(TEXT(Projectile_0));FNameName1(TEXT(Projectile_1));FNameName2(TEXT(Projectile_2));// 池中只有一个 Projectile 条目如果投射物不需要有名字纯粹的数据对象最干净的方案是使用NAME_None或者直接用FStruct而非UObject——从根本上避开FName池的增长。11.3 FString——通用堆字符串FString是UE中最平凡也最常用的字符串类型。它本质上就是一个TArrayTCHAR的薄封装继承了TArray的增长策略、内存量化和移动语义。理解FString的内存行为很大程度上等同于理解一个元素为2字节的TArray的行为。11.3.1 内存布局classFString{private:TArrayTCHARData;// TCHAR在多数平台上为wchar_t(Windows)或char16_t// UE5在多数平台使用UTF-16, sizeof(TCHAR) 2};堆 (12字节 6 × 2B)FString 本体 (16字节 TArrayTCHAR)Data*Num 6Max 6Hello\\0FString的本体是16字节的TArray头指向堆上的TCHAR数组。空FString不做堆分配——Data指针为nullptrNum和Max均为0。一旦赋值即使只有一个字符也要分配堆内存因为UE的FString没有SSO。11.3.2 关键特性FString没有实现短字符串优化SSO。即使只存一个字符也要进行一次堆分配。Data数组始终以\0结尾Num字段包含这个终结符。FString可以自由拼接、替换和格式化这使它成为日志输出和字符串处理的默认选择但每次修改都可能触发堆重分配。移动语义在FString上表现良好MoveTemp(SomeString)将源字符串的Data指针、Num和Max三个字段直接搬到目标源变为空字符串不发生堆操作。在函数返回FString时编译器的RVO通常能直接在调用者的栈帧上构造字符串连移动都不需要。11.3.3 常见操作的内存影响// 拼接——每次可能触发堆重分配FString Result;for(int32 i0;i1000;i){ResultFString::FromInt(i);// O(N²)的内存操作}// 优化预估大小FString Result;Result.Reserve(5000);for(int32 i0;i1000;i){ResultFString::FromInt(i);// 不触发重分配}// 使用FString::Printf一次性格式化FString ResultFString::Printf(TEXT(Name%s, Value%d),*Name,Value);TStringBuilderN是另一种避免频繁堆分配的方式。它在栈上预留N个TCHAR的固定缓冲区拼接过程不触发堆分配只有超出N时才溢出到堆。对于在函数内临时拼接短字符串的场景TStringBuilder256往往能彻底消除堆分配TStringBuilder256Builder;Builder.Append(TEXT(Position: ));Builder.Appendf(TEXT((%.1f, %.1f, %.1f)),Pos.X,Pos.Y,Pos.Z);FString ResultBuilder.ToString();// Builder的256 TCHAR缓冲在栈上不走堆11.3.4 FString vs std::stringFStringstd::string字符类型TCHAR (2-4字节)char (1字节)SSO无有通常22字节以内内联分配器FMemory (Binned2)std::allocator (系统)空串开销16字节(TArray头)032字节(SSO缓冲)FString空串时的16字节开销全部在对象本身内TArray的三个字段堆上不分配任何东西。而std::string即使为空也通常占32字节的本体空间来容纳SSO缓冲区。当需要存储大量可能为空的字符串字段时如结构体中的可选描述字段FString的空串成本反而更低。不过对于大量1-22字符的短字符串std::string的SSO避免堆分配的优势明显。11.3.5 TCHAR编码与UTF-8转换的内存开销UE在内部统一使用TCHAR多数平台为UTF-16来表示字符串。这意味着一段纯ASCII文本——变量名、文件路径、日志标签——每个字符占2字节而非1字节是UTF-8编码的两倍。FName池对此有专门优化FNameEntry通过Header.bIsWide标志区分窄字符和宽字符纯ASCII名字以ANSICHAR存储只有包含非ASCII字符时才切换到宽字符格式。FString则始终使用TCHAR无论内容是否为纯ASCII。当引擎需要处理来自外部的UTF-8数据解析JSON、接收HTTP响应、读取文本文件时FUTF8ToTCHAR转换器会分配一块临时缓冲区来存放转换结果。对于CJK字符UTF-8编码3字节/字符与UTF-16编码2字节/字符的差异不算悬殊但对于以Latin字母为主的文本从UTF-8单字节转为UTF-16双字节确实会使内存翻倍。如果处理大块文本数据如完整的对话剧本或本地化资源文件这个膨胀值得留意。// UTF-8 → TCHAR 转换示例FString ConvertedStringUTF8_TO_TCHAR(Utf8Source);// Utf8Source是100KB的纯ASCII JSON// ConvertedString的堆分配约200KB每字符从1B→2BUE5逐步引入了FUtf8String和FAnsiStringView等窄字符类型允许在明确知道编码的场景下避免不必要的宽化。在网络序列化等对带宽敏感的路径上直接使用UTF-8字节流而不经TCHAR中转可以同时节省CPU时间和内存带宽。11.3.6 TArray的内存放大效应当FString作为TArray或TMap的值类型使用时内存放大效应值得警惕。一个TArrayFString的每个元素是16字节的TArray头加上各自的堆分配。如果存储10 000个平均20字符的FStringTArray头 16字节 × 10,000 160 KB 堆上字符串 ~42字节 × 10,000 420 KB20个TCHAR \0 对齐 总计 ~580 KB作为对比如果这些字符串足以用FName表示不需要修改、且作为标识符使用TArrayFName只需80 KB8字节 × 10 000外加名称池中的去重存储。差距接近一个数量级。在函数参数传递中const TArrayFString避免复制整个容器但如果函数内部需要逐个处理字符串每个FString的堆数据已经在各自的位置上——这一步不可避免。TArrayViewconst FString提供零拷贝的只读视图在不需要转移所有权的场景下是传递字符串集合的首选方式。11.4 FText——本地化文本11.4.1 设计理念FText不只是一个字符串而是一个可本地化的文本单元。它可能指向不同语言的翻译表支持格式化参数有复杂的共享和重建机制。对UI文本而言FText是唯一正确的类型——直接用FString显示给玩家的文本无法被本地化系统管理也无法在语言切换时自动更新。11.4.2 内存结构classFText{TSharedRefITextData,ESPMode::ThreadSafeTextData;// 取决于TSharedRef实现8或16字节uint32 Flags;};TextData指向ITextData的某个子类实例该实例持有当前语言的显示字符串一个FString以及可选的重建历史。多个FText变量可以共享同一个TextData——赋值操作只增加引用计数而不复制字符串。当引用计数降为零时TextData才被销毁。11.4.3 FText的共享机制FText Text1LOCTEXT(MyKey,Hello World);FText Text2Text1;// 共享同一个TextData引用计数1// 格式化时创建新的TextDataFText Text3FText::Format(LOCTEXT(Fmt,{0} points),Score);赋值和拷贝的成本极低——仅仅是增加一个原子引用计数。只有通过FText::Format、FText::AsNumber等方式创建的新文本才会分配新的TextData实例。在一个典型的UI面板中大多数静态标签都指向同一个TextData动态数值文本则各自持有独立的TextData。在内存层面一个少量动态文本加大量静态标签的UI页面其FText总开销主要取决于动态文本的数量和它们的DisplayString长度静态标签的共享开销可以忽略不计。11.4.4 LOCTEXT/NSLOCTEXT的内存#defineLOCTEXT_NAMESPACEMyModuleFText MyTextLOCTEXT(Key,Source Text);#undefLOCTEXT_NAMESPACELOCTEXT展开后Namespace、Key和SourceString三个字面量存储在可执行文件的只读数据段.rdata/.rodata运行时不产生堆分配。FText对象持有指向这些字面量的指针以及一个由本地化管理器创建或查找到的TextData。如果本地化表中没有该Key对应的译文TextData的DisplayString直接指向SourceString——连一次堆拷贝都不需要。11.4.5 FTextHistory与本地化重建FText的一个独特设计是它不仅记住当前显示什么还记住这段文本是怎么来的。这个来源信息存储在FTextHistory的子类层级中classFTextHistory_Base;// 基础文字直接字面值classFTextHistory_NamedFormat;// 格式化保存模式和命名参数classFTextHistory_OrderedFormat;// 格式化保存模式和位置参数classFTextHistory_FormatNumber;// 数字/日期/时间格式化classFTextHistory_StringTableEntry;// 引用字符串表条目当玩家在设置中切换语言时FTextLocalizationManager向所有活跃的FText发出重建请求。对于持有FTextHistory_StringTableEntry历史的文本管理器用新语言的键查翻译表替换DisplayString。对于持有FTextHistory_NamedFormat的文本管理器先获取格式模式的译文再用之前保存的参数重新执行一遍格式化——因为不同语言的语序可能不同参数的嵌入位置也不同。这套机制的内存代价在于每个带有格式化历史的FText除了DisplayString本身还需要持有格式模式字符串和所有参数的副本。在UI密集的应用中——比如一个有数百个动态数值的HUD面板——格式化FText的累积内存不可忽视。缓解方式有两种。对于不需要本地化重建能力的文本如纯调试用的显示使用FText::AsCultureInvariant()创建不保留重建历史的FText。对于需要占位符但暂时没有内容的场合FText::GetEmpty()返回一个全局共享的空文本实例避免重复分配。11.4.6 FText在Slate中的内存表现Slate控件中的文本属性如STextBlock的Text通常存储为TAttribute。如果绑定的是一个静态FText通过LOCTEXT定义多个控件共享同一个TextData内存开销很低。但如果绑定的是一个每帧重新构造的LambdaSNew(STextBlock).Text_Lambda([this](){returnFText::Format(LOCTEXT(HP,HP: {0}/{1}),CurrentHP,MaxHP);})每次求值都会创建一个新的FText实例和对应的TextData。Slate的文本失效检测通过比较FText的内部标识符会检测到文本变化并触发重新布局和渲染。如果数值实际没有变化这些分配和布局计算就是浪费。更好的做法是在值真正变化时才更新FText将其缓存为成员变量// 类成员FText CachedHPText;// 仅在HP变化时更新voidOnHPChanged(){CachedHPTextFText::Format(LOCTEXT(HP,HP: {0}/{1}),CurrentHP,MaxHP);}11.5 三者之间的转换代价转换操作代价FName → FString从名称池复制字符串O(N) 堆分配FString → FName哈希 查表可能插入O(N) 可能获取锁FString → FText封装为FText堆分配TextDataFText → FString获取显示字符串O(1)或O(N)FName → FText中转FString两次转换开销FText → FName中转FString两次转换开销在热路径每帧执行的代码中应尽量避免字符串类型转换。初始化阶段完成所需的转换运行时直接使用目标类型是最稳妥的策略。如果不得不在Tick中做FName到FString的转换例如用于日志至少应加上频率控制或条件检查避免每帧都执行。一个隐蔽的转换场景是日志宏中的FNameUE_LOG(LogTemp, Log, TEXT(%s), *SomeFName.ToString())。ToString()创建了一个临时FString做堆分配如果这条日志在关闭Shipping构建中被编译掉则无碍但在Development构建的热循环里这些临时字符串的分配和释放足以在分配器统计中留下痕迹。11.6 内存监控可以在运行时对字符串相关的内存做基本的度量。FName池的总字节数通过FName::GetNameTableMemorySize()获取配合引擎的MemReport或LLMLow Level Memory Tracker标签可以追踪其增长趋势。FString没有全局统计接口因为它们分散在各处的堆上但Binned2分配器的Size Class统计能间接反映短字符串分配的密度——如果32字节和64字节的Size Class占用异常高往往意味着大量短FString或临时字符串的创建回收。在Unreal Insights的Memory Insights视图中可以按分配标签过滤与字符串相关的内存观察一段时间内的分配/释放模式。如果看到某个函数反复分配和释放长度相似的小块内存多半是循环中的临时FString——正是Reserve或TStringBuilder能解决的问题。11.7 选择指南需要显示给玩家的文本用FText因为只有它支持本地化和格式化重建。需要频繁比较或用作TMap键的标识符用FNameO(1)比较和8字节实例是它的核心优势。需要拼接、修改或格式化的临时字符串用FString。资产路径可以用FName或FSoftObjectPath。配置文件的键名用FName。日志输出用FString。在实践中三种类型往往共存于同一个系统中。一个典型的例子UDataTable的行名是FName某个需要显示给玩家的字段是FText而序列化到JSON时又需要把它们都转为FString。理解各类型的内存成本和转换代价后就能有意识地控制转换发生的位置和频率避免在循环内部做不必要的类型来回。另一个常见的模式是网络复制中的字符串序列化。FName和FString通过UPROPERTY参与复制时序列化引擎会把它们转换为字节流发送到远端远端再从字节流重建。FName的序列化会把字符串正文发送出去而非索引因为对端的名称池索引不同接收端再调用FName构造器从字符串重建。频繁复制大量FName属性的Actor每次接收都会触发O(N)的哈希计算和池查找这在高频复制场景下可能成为瓶颈。避免将变化不频繁的FName标记为每帧复制是减轻这一开销的常见做法。11.8 小结FName以8字节实例加全局去重池的设计为标识符和键提供了最经济的存储和最快的比较速度。池中的条目永不释放长时间运行后名称池只增不减因此程序化生成大量唯一名字时需要谨慎——利用Number后缀或避免不必要的命名是有效的应对策略。FString是一条朴素的堆字符串没有SSO每次操作都可能触发堆分配预分配、TStringBuilder和减少不必要的拷贝是关键优化手段TCHAR的UTF-16编码使得纯ASCII文本的内存开销是UTF-8的两倍UE5正在逐步补充窄字符支持以缓解这一问题。FText为本地化而生内部通过共享引用减少重复存储通过TextHistory支持语言切换时的自动重建代价是格式化文本要额外保存参数和模式的副本——在UI密集场景下需要注意缓存策略。三种类型之间的转换都有成本在热路径中应将转换前置到初始化阶段。理解这三种字符串的内存模型实质上是理解三种不同的设计权衡FName用池的不可回收换取比较的极速FString用缺失SSO换取空串的紧凑FText用历史链的内存开销换取本地化的透明重建。没有“最好”的字符串类型只有最匹配当前场景的字符串类型。