Unity TMP文本获取与赋值全指南:从GetComponent到性能优化
做 Unity 的 UI几乎天天跟 Text 打交道。Unity 2018 之后 TextMeshPro 成了官方推荐的文本组件项目里新建 UI 默认就是 TMP很多老项目也从 UnityEngine.UI.Text 往 TextMeshPro 迁移。但迁过来之后大家会发现TMP 的组件名、API 和曾经的 Text 不完全一样获取组件和给文本赋值这两件“小事”经常能折腾出一堆问号。这篇就围绕一个很具体的需求展开Unity 中 Text-TextMeshPro 的获取与赋值。不管你是刚转 TMP、接手老项目还是想把手头的 UI 写法统一到一个更稳的方式下面这些内容都值得过一遍。我会把拖拽引用、代码获取、动态创建、富文本、性能缓存这些点全部串起来顺便把那些文档里不会写的坑也摆出来。1. 先搞清楚你要操作的是哪个组件很多人第一坑就栽在“不知道挂的到底是谁”。Unity 的 UGUI 体系里文本组件有几个看起来很接近但实际不同的类型搞混了后面的代码全白写。1.1 TextMeshPro 和 UGUI Text 的边界Unity 内置的老文本组件是UnityEngine.UI.Text在 Canvas 下渲染属于 UGUI 里最基础的一层。TextMeshPro 则分两种运行时形态TMPro.TextMeshProUGUI挂在 Canvas 下的 UI 文本替代UnityEngine.UI.Text。TMPro.TextMeshPro挂在 3D 场景里的文本类似原来的 3D Text但支持 TMP 的 SDF 渲染。很多同学在代码里GetComponentText()拿不到组件就是因为场景里挂的是TextMeshProUGUI而不是UnityEngine.UI.Text。反过来你用GetComponentTextMeshProUGUI()去拿一个挂在普通 GameObject 上、没用 Canvas 的TextMeshPro同样拿不到。两者虽然接口长得非常像但继承链不一样TextMeshProUGUI继承自TMP_Text并严格绑定 Canvas 渲染TextMeshPro是另一个分支走的是 MeshRenderer 渲染。所以第一步不是急着写代码而是先确认目标物体上的组件类型。1.2 为什么推荐用 TMP 的 API从工程维护角度我强烈建议新代码里直接用 TMP 的接口而不是继续迁就老的UnityEngine.UI.Text。原因有三个TMP 的文本渲染质量更好采用 SDFSigned Distance Field有向距离场技术放大缩小不糊。TMP_Text是所有 TMP 文本类型的基类声明成它之后同一套代码既可以用在 UI 上也能用在 3D 文本上灵活性高得多。老Text在 Unity 新版本里已经是“兼容旧项目”的存在新 UI 默认生成的就是 TMP继续写老 API 反而增加转换成本。所以后面所有示例我优先用TMPro命名空间下的接口老 Text 的差异点会在对应位置单独标出来。2. 拿到组件几种获取路径“获取”在 Unity 里无非三条路编辑器拖拽、代码查找、缓存引用。具体选哪条取决于你的场景是静态预制体还是运行时动态生成。2.1 Inspector 拖拽引用最稳妥、性能也最高的方式是在 Inspector 里直接把组件拖到脚本字段上。比如using TMPro; using UnityEngine; public class MyUIText : MonoBehaviour { [SerializeField] private TMP_Text titleText; private void Start() { titleText.text 这是标题; } }这里我用的是TMP_Text而不是TextMeshProUGUI原因前面说了两者都继承它拖拽时能自动兼容后续想换 UI 还是 3D 文本都不需要改字段类型。加[SerializeField]是为了让私有字段也能在 Inspector 显示比 public 字段更符合封装习惯。拖拽方式最大的好处是“不可能拿错对象”你眼睛看到的是哪个组件运行时就调用哪个组件。但缺点也明显如果预制体被复制很多份每次都得手动拖一遍如果脚本是运行时动态挂载的Inspector 根本没法提前拖。这时候就得走代码获取。2.2 代码动态获取GetComponent 与 Find代码获取最常见的写法是GetComponentTMP_Text()。注意这个接口只能拿到“挂在这个 GameObject 自身上的组件”拿不到子物体或父物体上的组件。using TMPro; using UnityEngine; public class DynamicFetch : MonoBehaviour { private void Awake() { TMP_Text text GetComponentTMP_Text(); if (text ! null) { text.text 自己身上的文本; } } }必须判空。组件没挂或者类型不匹配时GetComponentTMP_Text()会返回 null直接访问.text会抛NullReferenceException新手十有八九遇到过这种错误。如果文本在子物体上有一个常用写法TMP_Text text GetComponentInChildrenTMP_Text(true);第二个参数true表示包含隐藏的子物体默认值是false所以如果你在 Inspector 里把子物体隐藏了不加这个参数就会返回 null。同理从父物体找用GetComponentInParentTMP_Text()。还有一条万能但尽量少用的路按名字查找。Transform tf transform.Find(Panel/TitleText); if (tf ! null) { TMP_Text text tf.GetComponentTMP_Text(); text.text 查找到的文本; }Find传的是物体的路径中间用斜杠分隔。这个方法很直观但对字符串敏感一旦物体重命名或者层级调整代码就断了。我的习惯是静态结构做成预制体直接在 Inspector 拖动态生成的物体用GetComponentInChildren或在生成时直接把引用存下来Find只用来兜底绝不让它成为主路径。2.3 从子物体/父物体中找组件GetComponentInChildren和GetComponentInParent看起来方便实际上有个隐藏细节它们的查找顺序是按层级深度优先遍历如果物体下面有多个TMP_Text你拿到的“第一个”未必是你要的。比如一个卡片 UI 上背景里有一个版本号文本标题栏又有一个标题文本两个都挂在卡片 GameObject 的子层级里。GetComponentInChildrenTMP_Text()拿到的是哪个取决于层级遍历顺序通常是最先遍历到的那个也就是最上层、最靠前的子树。如果你以为“子物体里只有一个文本”结果赋值赋到版本号上就会出现文本没变的假象。解决方式有两种给目标物体固定命名用transform.Find(TitleText)精确拿。在预制体上把文本引用通过序列化字段直接拖好运行时不用再找。还有一个容易忽略的GetComponentInChildren包括自身。也就是说如果脚本挂在文本组件同一个物体上它会连自身一起找出一个TMP_Text。这本身没毛病但如果你认为它一定从子物体找就会对返回值来源产生误解。2.4 缓存引用避免重复 GetComponentGetComponent本身不是特别重但如果在Update里每帧调用再叠加 UI 的重建积少成多也会产生可以感知的 CPU 开销。更麻烦的是每次GetComponent都可能拿到空引用每一帧判空也会把代码搞得很啰嗦。我习惯在Awake或OnEnable里缓存引用using TMPro; using UnityEngine; public class CachedText : MonoBehaviour { private TMP_Text _text; private void Awake() { _text GetComponentTMP_Text(); } public void SetTitle(string value) { _text.text value; } }这里有个生命周期问题如果文本组件是动态添加的执行顺序上有讲究。Awake发生在OnEnable之前且在 Start 前。多数情况Awake里拿引用没问题但如果组件是其他地方在Start里AddComponent的Awake可能拿不到。遇到这种极端情况可以在Start里缓存或者在SetTitle里做延迟判空if (_text null) _text GetComponentTMP_Text();这种懒加载模式对动态组件很友好第一次调用时拿到引用之后一直复用。缓存引用的最大好处是代码可读性和性能同时提升在高频刷新文本的场景里尤其重要。3. 给 TMP 赋值文本基础写法拿到组件之后就是赋值。赋值不是简单的text xxx就完事里面还有字符串拼接效率、富文本标签、本地化适配等一堆要考虑的问题。3.1 最简单的赋值text 属性和 SetTextTMP_Text提供了两个最常用的赋值入口写法适用场景备注text.text 内容简单字符串字符串已经拼好内部会处理并标记 UI 重建text.SetText(string)普通字符串调用语法别名行为上和直接赋 text 一样text.SetText(StringBuilder)高频拼接场景避免多次分配 string性能更好直接赋值是最直观的比如TMP_Text text GetComponentTMP_Text(); text.text 当前分数100;这里有一个很多人不知道的点SetText还有一个带占位符的重载类似string.Format但性能更好不会产生临时字符串text.SetText(当前分数{0}, score);TMP 的SetText重载会直接把参数格式化到底层的StringBuilder或者内部缓冲区里比string.Format省掉一层中间字符串分配。不过要注意{0}这种占位符只适用于SetText(string, float)、SetText(string, int)这类重载实际参数个数和类型受限于 TMP 内置的重载列表。3.2 组合字符串的正确姿势UI 开发里最常见的是“等级15”“金币12345”这类文本新手容易写成text.text 等级 level;level是 int 时这个写法会做一次装箱生成临时字符串。少量调用无所谓但如果每个 UI 每帧都在刷新大量临时字符串会给 GC 带来压力。更好的做法分两种低频刷新直接用SetText(等级{0}, level)。高频刷新用StringBuilder维护一个变量只在该变的时候才赋值。using System.Text; using TMPro; using UnityEngine; public class ScoreText : MonoBehaviour { private TMP_Text _text; private readonly StringBuilder _builder new StringBuilder(32); private void Awake() { _text GetComponentTMP_Text(); } public void UpdateScore(int score) { _builder.Clear(); _builder.Append(当前分数); _builder.Append(score); _text.SetText(_builder); } }StringBuilder在初始化时给容量避免频繁扩容SetText(StringBuilder)是 TMP 专门为这种场景提供的接口内部直接复制内容不再产生额外字符串。高频刷新时这种做法能明显减少 GC Alloc。3.3 富文本与样式标签TMP 自带一套类似 HTML 的富文本标签渲染时自动解析。比较常用的几个b加粗/bi斜体/isize120%更大字号/sizecolor#FF0000红色/coloraligncenter居中/alignsprite0插入图集里的 Sprite比如一行文本里要强调分数text.text 当前分数color#FFD700b100/b/color;富文本标签默认是开启的由 TMP 文本组件的richText属性控制。关闭后这些标签会原样显示出来看起来像乱码。实际操作中我建议在编辑器里保持richText开启但要注意开启富文本会带来一点解析开销特别长的文本要评估一下性能。还有一个容易踩的坑如果你用富文本给部分文字改颜色然后代码里又频繁整段重设.text那些标签会跟着你组装的字符串一起被拼接进去。所以最好把标签常量抽出来或者统一走封装方法避免满屏color字符串到处出现。3.4 处理多语言和特殊符号多语言项目里赋值通常不是直接写死字符串而是查表text.text LocalizationManager.Get(level_score, level);这种做法的关键不在 TMP而在你的文案表。TMP 真正要关心的是字体。中文、日文、韩文、俄文、阿拉伯文的字符集差异很大如果当前 TMP 字体资产里没有对应字符显示出来就是一个个“豆腐块”方框。所以多语言项目里通常要按语言切换字体资产而不是靠一套字体通吃。text.font chineseFontAsset;TMP_Text的font属性可以直接换成TMP_FontAsset。切换字体的时机要小心如果这帧刚切完语言下一帧又改了文本可能出现短暂闪一下旧字体的情况。实践中我会在改语言后强制刷新text.ForceMeshUpdate()确保文本网格立刻重建。特殊符号里还有一个很实用的是插入精灵图Sprite。格式为sprite name图标名需要先在 TMP Settings 里配置 Sprite Asset。表情、图标、货币符号都能用这种方式混排比单独放一个 Image 省布局成本。4. 动态创建 TextMeshProUGUI 并赋值预制体里的文本组件可以通过拖拽搞定但运行时动态生成的 UI 就得靠代码创建、挂载、赋值一条龙。这个环节最容易出问题的不是赋值而是组件没挂上或者 Canvas 相关属性不对。4.1 运行时创建物体并挂 TMP动态创建一个挂在 Canvas 下的 TMP 文本核心步骤如下using TMPro; using UnityEngine; public class CanvasTextFactory : MonoBehaviour { public Transform canvasParent; public TMP_Text CreateText(string content) { GameObject go new GameObject(DynamicText); go.transform.SetParent(canvasParent, false); TMP_Text text go.AddComponentTextMeshProUGUI(); text.text content; text.fontSize 24; text.color Color.white; text.alignment TextAlignmentOptions.Center; RectTransform rect go.GetComponentRectTransform(); rect.sizeDelta new Vector2(200, 50); return text; } }SetParent(canvasParent, false)里的第二个参数worldPositionStays很关键。对 UI 物体来说应该传false让 Unity 保持局部坐标不变并自动把它放到 Canvas 坐标体系里。如果传true物体容易跑到奇怪的世界坐标位置尤其是 Canvas 是屏幕空间时很难直觉判断它在哪。AddComponentTextMeshProUGUI()之后Unity 会自动补上一个RectTransform这点和普通GameObject不一样因为TextMeshProUGUI是 UI 组件依赖RectTransform。如果你先创建空物体再 AddComponent不需要手动再挂 RectTransform但如果你想设置位置尺寸可以直接GetComponentRectTransform()。有一个细节动态创建的 TMP 文本默认没有 CanvasRendererAddComponent 时会自动带上。如果你在非 Canvas 父物体下创建文本可能渲染不出来或者出现在奇怪的位置。所以动态创建 UI 文本父物体一定要是 Canvas 或其子物体。4.2 从 Prefab 实例化后获取和赋值更常见的做法不是从零建物体而是实例化一个预设好的 Prefab然后从 Prefab 里拿子物体上的 TMP 组件来赋值。using TMPro; using UnityEngine; public class CardSpawner : MonoBehaviour { public GameObject cardPrefab; public Transform parent; public void SpawnCard(string title, string desc) { GameObject card Instantiate(cardPrefab, parent); TMP_Text titleText card.transform.Find(Title).GetComponentTMP_Text(); TMP_Text descText card.transform.Find(Desc).GetComponentTMP_Text(); titleText.text title; descText.text desc; } }这里我用Find精确查找子物体名称。如果 Prefab 里层级较深、文本较多维护成本会上升。更工程化的做法是给 Prefab 根节点挂一个“视图脚本”把内部所有引用提前拖好暴露属性给外部设置public class CardView : MonoBehaviour { [SerializeField] private TMP_Text titleText; [SerializeField] private TMP_Text descText; public void SetData(string title, string desc) { titleText.text title; descText.text desc; } }然后调用方只关心CardView不需要知道内部结构CardView view card.GetComponentCardView(); view.SetData(标题, 描述);这个模式不仅是“好习惯”更是团队协作的刚需。否则一旦策划要调整 UI 层级把“Title”改名成“TitleText”你所有Find(Title)的代码全部要同步改很容易漏。4.3 初始化时机与隐藏显示动态创建的文本常常会配合“列表加载时先隐藏”的需求。比如玩家打开背包先隐藏列表数据加载完再显示。这时如果直接把整块 UISetActive(false)再SetActive(true)TMP 内部会发生重建赋值时机不对可能闪一下白屏或者出现上一帧文本没来得及刷新。推荐顺序是实例化 Prefab。给 TMP 组件赋值。全部就绪后再SetActive(true)。而不是先显示再赋值。因为 TMP 的text赋值会标记 UI 重建但 Canvas 也会在批量渲染前统一处理如果你先SetActive(true)再逐条赋值玩家在极短时间里可能看到未赋值的空文本。如果 Prefab 本身默认是隐藏的实例化后先SetActive(false)赋完值再打开也是一个安全思路。还有一种情况是列表复用文本已经显示过需要先隐藏再改内容。此时可以直接赋值但要注意对象池里的 TMP 如果有Material残留或Sprite残留要把富文本标签也一起清干净。5. 常见坑与排查记录这部分整理我实际踩过、也帮别人排查过的几类高频问题不按教科书来按场景来。5.1 赋值不生效rebuild 与 dirty最常见的一种情况是代码里text.text确实赋值了但游戏画面上不变化。排查思路按优先级排序是不是拿错组件了场景里挂的是TextMeshProUGUI你GetComponentTMP_Text()按理能拿到前提是命名空间TMPro正确引用。如果代码文件没有using TMPro;你写的TextMeshProUGUI可能被编译器解析成了其他同名类型或者直接报错。是不是赋值的对象不是显示的那一个多 Canvas、多 Camera、UI 分层复杂时很容易出现“赋给 A 的文本但屏幕上是 B 在显示”。是不是被 Mask、RectMask2D 裁剪了文本本身赋值正常但被父级 Mask 截掉了看起来像没变化。这不算赋值问题是显示问题。有没有可能text.SetText里传的字符串本身是空比如你用SetText({0}, someNullString)TMP 会把空字符串渲染成空。还有一个被忽略的点TMP 文本组件在 Play Mode 下如果启用了Enable Auto Sizing字体大小会自动缩放赋值后长度变短/变长时视觉上可能和预期不一样。自动缩放本身没问题但当父节点 width 改变时文本可能被压缩成一行看起来像“没赋值”。如果怀疑是重建时机问题可以强制手动更新text.ForceMeshUpdate();这行代码会立即重建文本网格不依赖 Canvas 的自动重建。排查阶段用它确认“不是渲染更新问题”很有效。5.2 拿到的是 TextMeshPro 而不是 TextMeshProUGUI场景里有一个 3D TextTextMeshPro你在代码里写GetComponentTextMeshProUGUI()拿到的肯定是 null。这两个类型前面提过属于不同分支。那有没有统一获取的办法有用TMP_TextTMP_Text tmp GetComponentTMP_Text();这个基类在两者之间架了一座桥。UI 的TextMeshProUGUI和 3D 的TextMeshPro都继承TMP_Text所以GetComponentTMP_Text()都能拿到。在不需要区分 UI 还是 3D 的场景里用TMP_Text声明引用是最省心的。但要留意如果场景里同一个 GameObject 上同时挂了TextMeshProUGUI和TextMeshPro虽然极少见但确实存在GetComponentTMP_Text()返回的是先添加的那个你赋值可能赋到不希望的那一个上。遇到这种诡异情况直接按具体类型获取更明确。5.3 字体与 Material 问题TMP 的字体和普通 Font 不一样需要.asset字体资产。如果一个 TMP 文本 Sprite 里的图标显示成方框十有八九是 Sprite Asset 没配置或者spriteAsset属性没有指向正确的资产。字体材质也容易出现“文本是黑的”这类问题。TMP 使用 UI 的 CanvasRenderer材质设置不对文本可能没有正确显示 SDF 效果而是渲染成黑块。检查顺序text.font是否为 null如果为 null默认字体也没有。项目里有没有导入 TMP Essential Resources首次创建 TMP 文本时如果没有导入Unity 会弹提示这个资源包没导会导致默认字体缺失。字体的 Material 是否被某个 UI 的 Canvas 材质覆盖。多 Canvas 项目尤其要注意Canvas的material属性如果被覆盖TMP 的文本可能显示异常。字体切换的坑在多语言项目里最明显中英混排如果只有一套字体英文字体没有中文中文会变成框中文字体没有数字数字会变成框。解决方案是给 TMP 配置fallbackFontAssets列表让主字体缺失字符时自动降级到备选字体。5.4 编码与文本丢失动态加载本地文本比如从服务器下拉的公告、读取本地配置时经常出现中文乱码。TMP 本身对字符的渲染没问题问题通常出在读取环节文件编码不对或者 JSON 解析时用了错误的解码方式。检查两点文件保存的编码用 UTF-8带 BOM 或不带均可但 C# 读取时最好明确Encoding.UTF8。网络字符串解析时如果服务端返回的不是 UTF-8需要分段解码转换直接text.text response会显示乱码。如果文本里包含\n换行赋给 TMP 后没有生效多看看是不是双引号里写成了\\n。\\n在 C# 里会被转义成反斜杠加字母 n渲染出来就是字面意义的\n而不是换行。这种错误一眼看不出来排查时建议打印原始字符串。6. 工程化建议与性能注意获取和赋值本身是小事但放到一个完整的项目里代码组织方式和性能习惯比 API 本身更重要。6.1 缓存、对象池与刷新频率频繁创建、销毁 UI 是性能杀手。尤其列表、聊天、公告这类场景每一条消息都实例化一个 PrefabGC 压力很大。对象池是标准的解法TMP 文本组件在对象池里回收时要注意把text.text重置为空color重置为默认隐藏时SetActive(false)否则会出现“上一帧内容残留”。另外注意刷新频率。有些文本比如玩家名字、任务目标根本不需要每帧更新在数值没变时直接跳过赋值能省掉一次 UI 重建。很多时候 UI 卡顿不是 TextMeshPro 本身慢而是你每帧都在给它喂新字符串。private string _lastValue; public void SetLevel(int level) { string current $等级{level}; if (_lastValue current) { return; } _lastValue current; _text.text current; }这个简单的“值变化才更新”技巧在高频数据流的 UI 上非常有效肉眼无差别但 CPU 占用会明显下降。6.2 大量文本的 UI 性能每个 TMP 文本都是一个独立网格Canvas 会把所有 TMP 文本合并到一张 Mesh 里提交渲染。文本数量多、内容频繁变化时Canvas 的重建开销会显著上升。解压过的 TMP 文档里提到过一项测试同一个 Canvas 下 300 个动态文本每帧都改成不同内容CPU 压力很大。如果改成只有几个文本动态变化、其他文本不变Canvas 的dirty面积就小很多。实际操作上的建议尽量把动态文本和静态文本放不同 Canvas。静态文本所在的 Canvas 不产生 rebuild动态文本的 Canvas 独立重建减少整个 UI 的重建范围。需要频繁更新的文本用Canvas.ForceUpdateCanvases()的频率要控制不是每帧都调。对于数值类文本可以考虑用多个TextMeshProUGUI叠加的“数字字表”但配置成本高一般项目用不上这里不展开。6.3 可维护性封装一个文本赋值工具业务代码里到处写GetComponentTMP_Text().text xxx不是不行但时间久了命名不统一、组件类型混乱、判空不完整等问题全冒出来。我自己习惯做一个轻量封装using TMPro; using UnityEngine; public static class UITextExtensions { public static void SafeSetText(this GameObject go, string content) { if (go null) { return; } TMP_Text text go.GetComponentInChildrenTMP_Text(true); if (text ! null) { text.text content; } } }这个扩展方法的价值在于任何地方只要gameObject.SafeSetText(你好)就能把子物体里的文本改掉拿不到组件时不会报错。但要注意这种写法条件反射式的隐藏了很多问题只能作为快速开发的兜底真正面向性能的 UI 还是应该用缓存引用加值比较更新的模式。如果要做得更完善还可以再包一层“文本样式”接口根据数据生成文本多语言、数字格式化判断是否需要更新统一处理富文本标签延迟刷新等 Canvas 空闲再一次更新这类设计听起来绕但在有大量 UI 的中型项目中能少很多重复代码和隐性 bug。7. 最后补几个我记在笔记里的使用细节聊到这里再补充几个不一定算坑、但能省时间的技巧编辑器里双击 TMP 文本组件可以直接打开 TMP 字体资产编辑器方便查看字符表缺失情况。TMP_Text的isRightToLeftText属性在切换阿拉伯语、希伯来语时会用到但开启后需要手动处理文本顺序别把它当成自动排版开关。如果你的文本内容会带%号用SetText(进度{0}%, value)时注意 TMP 的重载解析某些版本会把第二个参数识别成float显示时精度会变化必要时用ToString()显式转字符串。赋值时如果遇到“文本编辑器里看到是对的运行后显示不对”检查一下 TMP Settings 里的Missing Character Unicode这个值决定了找不到字符时的默认占位符一般设置成空格或者一个可见框方便排查。使用text.text null不会报错会显示空字符串但text.text 123.ToString()和text.text 123是不一样的后者编译直接报错这里没有隐式转换。我个人在实际项目里最深的体会是TMP 的 API 本身很简单难的是时刻记得“我拿到的是谁、什么时候拿、怎么避免重复拿”。把这三个问题想清楚获取和赋值的代码基本上就踩不了大坑。先按被拖拽引用来设计预制体需要动态生成时再走对象池加缓存引用最后把高频更新的文本单独拆 Canvas这套组合拳足够覆盖绝大多数项目需求了。上面这些内容如果你对照着自己项目里的 TMP 组件走一遍应该能把前面那些模棱两可的写法一次性理顺。后面写 UI 的时候碰到“为什么赋值不显示”“为什么拿到 null”这类问题心里也能有个明确的排查顺序。