Unity TextMeshPro富文本打字机效果:原理、实现与性能优化 📅 2026/7/19 8:30:30 1. 项目概述为什么我们需要“打字机”式的富文本打印在Unity UI开发中TextMeshProTMP因其卓越的渲染质量和丰富的功能早已成为替代传统UI Text的首选。我们经常需要展示对话、任务描述、系统公告等大段文字。如果只是简单地将一整段文字瞬间显示出来体验会非常生硬缺乏沉浸感。而“富文本打印”效果就是模拟老式打字机或逐字显示的效果让文字一个接一个地“打”出来同时还能完整支持TMP内置的粗体、斜体、颜色、大小等富文本标签。这不仅仅是视觉上的小花招它能极大地增强叙事的表现力控制信息释放的节奏是游戏开发中提升用户体验的关键细节之一。你可能在网上搜到过一些简单的Coroutine配合string.Substring的方案但它们往往一遇到colorred红色/color这样的标签就束手无策了要么会破坏标签结构导致渲染错误要么会把标签字符也打印出来。我们真正要实现的是一个能“理解”TMP富文本标签结构的、健壮的打印系统。它需要解析标签在打印过程中动态构建合法的富文本字符串确保最终显示效果与直接设置完整文本时一模一样。接下来我将拆解这个功能的完整实现思路、核心算法以及实际开发中积累的避坑经验。2. 核心思路与方案选型解析与重建的艺术实现TMP富文本打印的核心挑战在于如何在逐字增加可见字符的同时保持富文本标签的完整性和正确性直接按字符索引切割字符串会切断标签导致TMP无法解析。因此我们必须采取“解析-重建”的策略。2.1 方案对比为什么不能简单切割首先我们分析几种朴素方案的缺陷简单子字符串截取textMeshPro.text fullText.Substring(0, currentIndex);。这是最直接的错误做法。如果fullText包含i斜体/i当currentIndex位于标签中间时会输出类似i斜这样的非法片段TMP会渲染出“斜”这个字符串本身而不是“斜”字的斜体效果。正则表达式过滤标签后打印先移除所有标签打印纯文本打印完成后再一次性加上标签。这失去了打印过程中样式动态变化的效果比如一句话中关键词逐渐变红的效果就无法实现。使用TMP自带的maxVisibleCharacters属性这个属性确实可以控制显示多少个字符并且能正确处理富文本。但它有一个致命缺点它严格按字符数包括标签字符来隐藏。这意味着colorredABC/color如果maxVisibleCharacters设为5你可能会看到col这样被截断的标签片段显示在屏幕上而不是“A”字带红色效果。结论是我们需要一个能够**按“可见字符”而非“源字符串字符”**来推进并能动态构建合法富文本字符串的方案。2.2 选定方案基于TMP富文本解析器的逐字推进TMP内部有一个强大的TMP_TextInfo类和文本解析引擎。我们的思路是获取完整的字符信息通过TMP组件我们可以拿到最终渲染所需的字符数组、链接信息等。区分“源文本”与“富文本标签”我们需要一个解析器将输入字符串如“这是color#FF0000红色/color文字”分解为两部分纯文本字符序列和作用于这些字符的样式标签栈。模拟打印过程在每一帧我们不是从源字符串截取而是根据当前打印到的“可见字符索引”动态地重新生成一个符合TMP语法的富文本字符串。这个生成过程需要考虑到当前所有“激活”的标签例如一个color标签在打开后对其后所有字符生效直到遇到/color。这个方案的优势是精准、可靠与TMP内部逻辑一致能实现所有复杂的富文本效果。接下来我们就进入具体的实现环节。3. 核心模块设计与实现我们将构建一个名为TMP_Typewriter的组件。它需要挂在含有TextMeshProUGUI或TextMeshPro的GameObject上。3.1 数据结构定义如何表示富文本状态首先定义几个关键的数据结构来帮助我们跟踪状态。using System.Collections.Generic; using TMPro; using UnityEngine; using UnityEngine.Events; public class TMP_Typewriter : MonoBehaviour { [SerializeField] private TextMeshProUGUI targetText; [SerializeField] private float charactersPerSecond 50f; // 打印速度 [SerializeField] private bool autoStartOnEnable false; [SerializeField] private bool skipOnClick true; // 点击快速完成 public UnityEvent onPrintStarted; public UnityEvent onPrintCompleted; // 核心数据结构用于存储标签信息 private struct RichTextTag { public string fullTag; // 完整的标签字符串如 “color#FF0000” public int startIndex; // 该标签在“纯文本序列”中开始生效的索引位置 } private string _fullRichText; // 用户设置的原始富文本 private Listchar _plainCharList; // 解析出的纯文本字符列表 private ListRichTextTag _activeTags; // 当前活跃的标签栈按开启顺序 private ListRichTextTag _allTags; // 记录所有解析到的标签及其位置 private int _currentPrintIndex 0; // 当前已打印到的纯文本字符索引 private float _timer 0f; private bool _isPrinting false; }这里的关键是RichTextTag结构体和两个列表。_plainCharList存储剥离所有标签后的纯字符比如解析“AbB/bC”会得到[‘A’ ‘B’ ‘C’]。_allTags列表会记录b标签在位置1对字符‘B’生效/b标签在位置2结束这样的信息。_activeTags则是一个运行时栈用来跟踪在当前位置哪些标签是打开的。3.2 富文本解析器剥离标签记录位置这是最核心、最复杂的一步。我们不能用简单的正则表达式因为标签可以嵌套如bi文本/i/b需要维护一个栈来匹配开闭标签。private void ParseRichText(string richText) { _plainCharList new Listchar(); _allTags new ListRichTextTag(); _activeTags new ListRichTextTag(); int len richText.Length; bool insideTag false; System.Text.StringBuilder tagSb new System.Text.StringBuilder(); for (int i 0; i len; i) { char c richText[i]; if (c ‘’) { // 遇到标签开始 if (!insideTag) { insideTag true; tagSb.Clear(); tagSb.Append(c); } else { // 理论上在标签内不应该遇到‘’按普通字符处理虽然非法 _plainCharList.Add(c); } } else if (c ‘’) { // 遇到标签结束 if (insideTag) { tagSb.Append(c); string fullTag tagSb.ToString(); insideTag false; // 判断是开标签还是闭标签 bool isClosingTag fullTag.Length 1 fullTag[1] ‘/’; // 记录标签信息。标签的“位置”是当前纯文本列表的长度。 // 这意味着标签在它后面的字符之前生效。 RichTextTag tagInfo new RichTextTag { fullTag fullTag, startIndex _plainCharList.Count // 关键标签作用于后续字符 }; _allTags.Add(tagInfo); // 处理标签栈 if (!isClosingTag) { // 开标签入栈 _activeTags.Add(tagInfo); } else { // 闭标签需要找到匹配的开标签并出栈 string openTagName GetTagName(fullTag.Substring(2)); // 去掉“/” for (int j _activeTags.Count - 1; j 0; j--) { if (GetTagName(_activeTags[j].fullTag) openTagName) { _activeTags.RemoveAt(j); break; } } } } else { _plainCharList.Add(c); } } else { if (insideTag) { // 标签内的字符如属性值 tagSb.Append(c); } else { // 纯文本字符 _plainCharList.Add(c); } } } // 解析完成后_allTags记录了所有标签及其位置。 // 我们需要按startIndex对_allTags进行排序以便后续按顺序处理。 _allTags.Sort((a, b) a.startIndex.CompareTo(b.startIndex)); } // 辅助方法从类似“color#FF0000”或“b”中提取标签名 private string GetTagName(string tagContent) { // 找到第一个非字母数字的字符如‘’ ‘ ’ ‘’ for (int i 0; i tagContent.Length; i) { if (!char.IsLetterOrDigit(tagContent[i])) { return tagContent.Substring(0, i); } } return tagContent; }注意这个解析器是一个简化版它处理了常见的嵌套和自闭合标签如br/逻辑可能不完整。对于生产环境更推荐利用TMP内部未公开的解析逻辑或者使用经过充分测试的第三方解析库。但上述代码清晰地阐述了原理对于大多数b,i,color,size等标准标签是有效的。3.3 动态文本构建根据当前进度生成合法富文本有了解析好的数据我们就可以在每一帧根据_currentPrintIndex当前打印到的纯文本字符位置来构建应该显示的文字。private string BuildCurrentText() { if (_plainCharList null || _plainCharList.Count 0) return “”; System.Text.StringBuilder sb new System.Text.StringBuilder(); // 1. 添加所有在当前位置之前开始、且尚未关闭的标签即_activeTags中startIndex _currentPrintIndex的标签 // 但在我们当前的设计中_activeTags是动态维护的。我们需要从_allTags中筛选。 // 更优的方法是在打印过程中维护一个“当前激活标签列表”。 // 这里我们采用另一种思路遍历_allTags根据其位置和类型决定是否添加。 Liststring openTagsToApply new Liststring(); int tagPointer 0; // 遍历所有标签找出所有在_currentPrintIndex位置之前或正好开始的开标签 // 并且其对应的闭标签在_currentPrintIndex位置之后。 // 这需要更复杂的匹配计算。为了简化演示我们采用一个运行时维护的栈 // 在每次打印一个字符时检查是否有标签在这个字符索引“开始”或“结束”。 // 简化方案我们维护一个“当前生效标签列表” currentActiveTags。 // 在Parse完成后我们根据_allTags来初始化一个“标签事件”列表每个事件包含在哪个字符索引发生是打开还是关闭。 // 打印时每推进一个字符索引就处理发生在这个索引上的所有标签事件更新currentActiveTags。 // 然后用currentActiveTags里的所有标签 已打印的纯文本构建字符串。 // 以下是简化版的Build逻辑假设我们已经有一个按字符索引管理的事件系统 // StringBuilder sb new StringBuilder(); // foreach (var tag in currentActiveTags) { sb.Append(tag.fullTag); } // for (int i 0; i _currentPrintIndex; i) { sb.Append(_plainCharList[i]); } // foreach (var tag in currentActiveTags.Reverse()) { sb.Append(GetClosingTag(tag.fullTag)); } // return sb.ToString(); // 由于篇幅这里给出一个概念性代码实际实现需要更精细的事件处理。 // 一个更直接但性能稍差的方法是在每次Build时从头模拟一遍到_currentPrintIndex的解析过程。 return SimulateParsingToIndex(_currentPrintIndex); } private string SimulateParsingToIndex(int targetIndex) { System.Text.StringBuilder sb new System.Text.StringBuilder(); Liststring activeTagStack new Liststring(); // 用List模拟栈方便最后生成闭标签 int processedCharCount 0; int tagListIndex 0; int allTagsCount _allTags.Count; // 同时遍历标签列表和字符打印进度 while (processedCharCount targetIndex tagListIndex allTagsCount) { // 检查下一个要处理的是标签还是字符 int nextTagIndex (tagListIndex allTagsCount) ? _allTags[tagListIndex].startIndex : int.MaxValue; if (nextTagIndex processedCharCount) { // 处理这个标签 RichTextTag tag _allTags[tagListIndex]; string tagName GetTagName(tag.fullTag.Trim(‘‘ ‘‘ ‘/‘)); if (tag.fullTag.StartsWith(“/”)) { // 闭标签从栈中移除对应的开标签 for (int i activeTagStack.Count - 1; i 0; i--) { if (GetTagName(activeTagStack[i].Trim(‘‘ ‘‘)) tagName) { activeTagStack.RemoveAt(i); break; } } } else { // 开标签入栈 activeTagStack.Add(tag.fullTag); } tagListIndex; } else { // 添加下一个字符 sb.Append(_plainCharList[processedCharCount]); processedCharCount; } } // 现在sb中是从头到targetIndex的纯文本但我们需要在开头加上所有“活跃”的标签在结尾关闭它们。 System.Text.StringBuilder finalSb new System.Text.StringBuilder(); // 添加所有当前活跃的开标签 foreach (var tag in activeTagStack) { finalSb.Append(tag); } // 添加已构建的纯文本 finalSb.Append(sb.ToString()); // 以相反顺序添加对应的闭标签 for (int i activeTagStack.Count - 1; i 0; i--) { finalSb.Append(“/”).Append(GetTagName(activeTagStack[i].Trim(‘‘ ‘‘))).Append(“”); } return finalSb.ToString(); }这个SimulateParsingToIndex函数是核心它模拟了从文本开始解析到第targetIndex个纯文本字符的过程并记录了此时哪些标签是打开的。最后它生成一个格式正确的富文本字符串[所有打开标签][纯文本][所有对应的关闭标签]。3.4 驱动打印的协程与外部控制最后我们需要一个协程来驱动整个打印过程并处理用户交互。private void Start() { if (targetText null) targetText GetComponentTextMeshProUGUI(); } private void OnEnable() { if (autoStartOnEnable targetText ! null !string.IsNullOrEmpty(targetText.text)) { StartPrinting(targetText.text); } } public void StartPrinting(string richTextContent) { if (_isPrinting) { StopAllCoroutines(); } _fullRichText richTextContent; ParseRichText(_fullRichText); _currentPrintIndex 0; _isPrinting true; _timer 0f; onPrintStarted?.Invoke(); StartCoroutine(PrintingCoroutine()); } private System.Collections.IEnumerator PrintingCoroutine() { if (_plainCharList null || _plainCharList.Count 0) { _isPrinting false; onPrintCompleted?.Invoke(); yield break; } float delay 1f / charactersPerSecond; while (_currentPrintIndex _plainCharList.Count) { _timer Time.deltaTime; int targetIndex Mathf.FloorToInt(_timer / delay); targetIndex Mathf.Clamp(targetIndex 0 _plainCharList.Count); if (targetIndex _currentPrintIndex) { _currentPrintIndex targetIndex; targetText.text BuildCurrentText(); // 强制TMP立即重建网格避免显示延迟 targetText.ForceMeshUpdate(); } // 处理点击跳过 if (skipOnClick Input.GetMouseButtonDown(0)) { _currentPrintIndex _plainCharList.Count; targetText.text _fullRichText; // 直接显示最终文本 targetText.ForceMeshUpdate(); break; } yield return null; } _isPrinting false; onPrintCompleted?.Invoke(); } public void SkipToEnd() { if (_isPrinting) { _currentPrintIndex _plainCharList.Count; targetText.text _fullRichText; StopAllCoroutines(); _isPrinting false; onPrintCompleted?.Invoke(); } }4. 性能优化与高级功能扩展基础功能完成后我们需要考虑性能和更多实用功能。4.1 性能优化避免每帧重建字符串上面的SimulateParsingToIndex函数在每帧都会从头模拟解析当文本很长时可能成为性能瓶颈。优化方法是预计算。我们可以在ParseRichText阶段就生成一个“快照”列表。列表的每个元素对应“打印到第N个纯文本字符时应该显示的完整字符串”。这样在打印时只需要根据_currentPrintIndex做一次数组查找即可。private Liststring _prebuiltTextSnapshots; // 预构建的文本快照 private void PrebuildSnapshots() { _prebuiltTextSnapshots new Liststring(_plainCharList.Count 1); for (int i 0; i _plainCharList.Count; i) { _prebuiltTextSnapshots.Add(SimulateParsingToIndex(i)); } }然后在PrintingCoroutine中将targetText.text BuildCurrentText();替换为targetText.text _prebuiltTextSnapshots[_currentPrintIndex];。这用内存换取了CPU时间对于固定且需要反复播放的对话文本非常有效。4.2 支持声音与回调让打印效果更生动单纯的视觉打印还不够我们通常需要为每个字符或单词播放一个打字音效或者在特定字符位置触发事件比如显示表情符号。[System.Serializable] public struct PrintEvent { public int characterIndex; // 在纯文本列表中的索引 public UnityEvent onReached; } public PrintEvent[] printEvents; private void CheckAndInvokeEvents(int newIndex) { if (printEvents null) return; foreach (var evt in printEvents) { // 确保事件只触发一次 if (evt.characterIndex _lastEventIndex evt.characterIndex newIndex) { evt.onReached?.Invoke(); } } _lastEventIndex newIndex; }在协程中更新文本后调用CheckAndInvokeEvents(_currentPrintIndex)。你可以用这个机制来播放AudioSource音效或者激活某个动画。4.3 处理TMP特效标签波浪、抖动等TMP支持一些内置的顶点动画标签如wigglewave。我们的解析器需要识别这些标签。好消息是这些标签的解析方式与普通富文本标签相同。只要它们被正确记录在_allTags列表中我们的SimulateParsingToIndex函数就能在构建字符串时包含它们从而在打印过程中正确显示动画效果。关键在于这些特效标签在打印“中途”被添加到文本中时TMP需要一次网格重建ForceMeshUpdate才能开始播放动画。我们的代码中已经在每次更新文本后调用了ForceMeshUpdate()这确保了特效能够立即生效。5. 实战避坑与常见问题排查即使逻辑正确在实际集成到项目时你仍可能会遇到一些棘手的问题。5.1 问题一打印速度不稳定在低帧率下卡顿现象使用Time.deltaTime累计时间计算字符索引在帧率波动时打印速度看起来不均匀有时甚至会跳字。根因Time.deltaTime每一帧都在变化。我们的逻辑是“累计时间达到一个间隔就增加一个字符”。在帧率突然降低比如从60fps降到30fps时这一帧的deltaTime很大可能导致累计时间一下子越过了好几个字符的间隔造成“跳字”。解决方案使用固定时间步长进行更新而非依赖可变帧时间。// 在协程中修改循环逻辑 private System.Collections.IEnumerator PrintingCoroutine() { float delay 1f / charactersPerSecond; float accumulatedTime 0f; int lastPrintedIndex 0; while (_currentPrintIndex _plainCharList.Count) { accumulatedTime Time.deltaTime; int targetIndex Mathf.FloorToInt(accumulatedTime / delay); targetIndex Mathf.Clamp(targetIndex 0 _plainCharList.Count); if (targetIndex _currentPrintIndex) { _currentPrintIndex targetIndex; targetText.text _prebuiltTextSnapshots[_currentPrintIndex]; // 使用预构建快照 targetText.ForceMeshUpdate(); // 可以在这里按字符或按帧播放音效 // if (typewriterSound ! null targetIndex lastPrintedIndex) { /* 播放音效逻辑 */ } lastPrintedIndex targetIndex; } yield return null; // 仍然每帧检查但计算基于固定间隔 } }5.2 问题二文本组件在滚动视图ScrollRect中时打印导致布局跳动现象打印的文本在一个ScrollRect/Content Size Fitter布局下每打印一个字符文本的总体高度和布局都会重新计算导致视图频繁跳动体验很差。根因TMP在文本变更时会标记布局为脏等待Canvas更新。ForceMeshUpdate()会强制立即更新网格和布局。解决方案延迟布局重建。我们可以在打印过程中暂时禁用文本的“影响布局”属性打印完成后再恢复。private bool _originalRaycastTarget; private bool _originalEnableAutoSizing; private void StartPrinting(string richTextContent) { // ... 其他初始化代码 ... // 保存原始状态 if (targetText ! null) { _originalRaycastTarget targetText.raycastTarget; _originalEnableAutoSizing targetText.enableAutoSizing; // 临时禁用减少布局计算 targetText.raycastTarget false; targetText.enableAutoSizing false; } // ... 开始协程 ... } private void OnPrintCompleted() { if (targetText ! null) { targetText.raycastTarget _originalRaycastTarget; targetText.enableAutoSizing _originalEnableAutoSizing; // 最终确保布局正确 LayoutRebuilder.ForceRebuildLayoutImmediate(targetText.rectTransform); } onPrintCompleted?.Invoke(); }5.3 问题三包含超链接link的文本在打印过程中点击无效现象文本中有link”id”点击这里/link在逐字打印时即使链接文本已经显示出来点击也无法触发TMP_TextEventHandler的事件。根因TMP的超链接检测是基于完整的text字符串和其textInfo.linkInfo数组。当我们动态修改text属性时TMP需要重新解析文本并生成新的linkInfo。如果修改非常频繁链接信息的更新可能滞后或者点击检测的区域Hitbox计算不准确。解决方案降低更新频率不要每帧更新文本可以每打印2-3个字符更新一次或者使用固定时间间隔如0.05秒更新减少重建开销。确保更新后强制刷新在设置text后依次调用targetText.ForceMeshUpdate()和Canvas.ForceUpdateCanvases()如果必要确保布局和链接信息立即生效。考虑替代方案如果超链接交互至关重要可以考虑在打印期间暂时禁用链接交互打印完成后再启用。或者将超链接部分作为一个单独的、提前显示完全的TMP组件来处理。5.4 问题四内存与GC垃圾回收压力现象在打印很长的文本如一整章小说时感觉游戏有卡顿尤其是在移动设备上。根因频繁的字符串拼接StringBuilder.ToString()和TMP的网格重建会产生大量的临时内存分配触发GC。优化策略使用预构建快照如前所述这是减少运行时计算和字符串操作最有效的方法。虽然增加了内存占用但换来了O(1)的文本获取复杂度。对象池化StringBuilder如果无法预构建避免在每一帧都new StringBuilder()。可以使用一个静态的或复用的StringBuilder实例。分页打印对于极长的文本不要一次性全部解析和打印。实现一个分页系统每次只加载和打印一屏或一段内容打印完当前段再加载下一段。控制ForceMeshUpdate的调用如果打印速度很快比如每秒100字符以上可以考虑每完成一个单词或一句话再调用一次ForceMeshUpdate()而不是每个字符都调用。实现一个健壮、高效、功能丰富的TMP富文本打印系统需要仔细权衡功能、性能和易用性。从最基础的解析重建到预计算优化再到处理UI布局和交互的边界情况每一步都充满了细节。我希望这份详细的拆解和代码示例能为你实现自己的打字机效果提供一个坚实的起点。记住关键永远是理解TMP如何处理富文本并在此基础上设计你的状态管理和重建逻辑。