ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Unity 2022+中基于DoTween与TextMeshPro的高性能打字效果实现方案

Unity 2022+中基于DoTween与TextMeshPro的高性能打字效果实现方案 1. 项目概述为什么TextMeshPro需要新的打字效果方案如果你是从Unity 2018甚至更早版本一路用过来的老开发者对Legacy Text组件也就是UI Text一定不陌生。那时候要实现一个打字机效果网上随便一搜十有八九是那种用string.Substring配合Coroutine和WaitForSeconds的经典套路。代码简单直接效果也还凑合所以这套方案生命力极强成了很多项目的标配。但自从TextMeshPro简称TMP凭借其矢量字体、超强渲染效果和丰富样式支持成为Unity官方推荐的UI文本解决方案后问题就来了。很多开发者包括我自己在把老项目从Legacy UI升级到UGUI或者在新项目里直接使用TMP时会习惯性地把旧的打字机脚本直接挂到TextMeshProUGUI组件上。结果呢要么报错要么效果诡异。核心原因在于TMP的底层文本处理逻辑和渲染管线与老Text组件完全不同。它不再是简单的字符串而是一个包含富文本标签、字体图集、顶点信息等复杂数据的对象。粗暴地使用text text.Substring(0, i)这种方式不仅会破坏TMP的富文本格式比如颜色、加粗标签在性能上也是一种浪费因为它会触发完整的文本重建和网格重算。这就是我们面临的核心痛点在Unity 2022及更高版本这些版本对TMP的支持和性能优化已非常成熟中我们急需一个既能保留TMP全部强大功能尤其是富文本又能实现高性能、可定制化动画的打字效果方案。而DoTween作为Unity生态中最强大、最易用的动画插件之一自然成为了解决这个问题的绝佳选择。它提供的不仅仅是“移动A点到B点”的能力其强大的序列Sequence控制、回调机制和性能优化能让我们以声明式、高可读性的代码构建出丝滑且复杂的打字动画。简单来说这个方案的目标用户很明确所有在Unity 2022中使用TextMeshPro并希望为其文本内容添加动态、流畅的逐字显示效果的开发者。无论是用于游戏内的对话系统、任务提示、UI引导还是用于制作炫酷的宣传片头这套方案都能让你告别老旧、笨拙的协程写法拥抱更现代、更强大的动画驱动模式。2. 核心思路与DoTween方案选型在动手写代码之前我们先要理清思路。用DoTween实现TMP打字效果核心思想是**“数值驱动文本”**。我们不再直接操作字符串的截取而是通过一个浮点数比如从0到1的进度值来驱动文本的显示范围。DoTween则负责以我们指定的动画曲线Ease和时长平滑地改变这个进度值。2.1 为什么是DoTween而不是协程或Update很多新手可能会问用协程yield return new WaitForSeconds不是更简单吗这里我必须分享一个踩过的坑可控性和性能。协程方案在简单场景下没问题但一旦你需要中途暂停、加速、减速、跳过或者同时管理多个打字动画时代码会迅速变得混乱不堪。你需要维护一堆Coroutine引用和状态标志。而DoTween的Tween对象本身就是一个完美的状态控制器你可以随时Pause()、Restart()、改变timeScale甚至通过OnUpdate回调获取每一帧的进度值来做更复杂的事情比如配合音效。在性能上DoTween经过高度优化其内部更新机制比大量活跃的协程开销要小。另一个备选方案是在Update里自己写插值。这当然可以但意味着你要重复造轮子处理动画曲线、循环模式、回调管理等一大堆繁琐细节。DoTween把这些都封装好了我们只需要关注业务逻辑。2.2 方案设计两种主流的实现路径基于“数值驱动”的思想我们可以衍生出两种具体实现路径基于maxVisibleCharacters属性这是TMP组件自带的一个属性用于控制最多显示多少个字符。这是最直观、性能最好的方式。我们只需要用DoTween将一个整型变量从0动画到文本总长度然后在OnUpdate回调中将这个值赋给tmpText.maxVisibleCharacters即可。它的优点是实现简单对富文本支持良好因为TMP内部会处理可见字符的渲染。缺点是动画的“颗粒度”是单个字符无法实现更平滑的字符内动画比如字符的淡入。基于文本裁剪或遮罩通过Shader或RectMask2D等方式实现文本的逐区域显示。这种方式更灵活可以实现诸如从左到右、从右到左、从中心扩散等非线性的擦除式效果。但实现复杂度高需要一定的图形学知识且可能对富文本的支持不完美。对于绝大多数需求——也就是实现一个流畅的逐字显示效果——方案一基于maxVisibleCharacters是绝对的首选。它完美地平衡了效果、性能、实现难度和对TMP原生功能的支持。我们接下来的核心实现也将围绕此展开。2.3 DoTween能力匹配我们需要用到它的哪些功能DOFloat/DOTween.To用于创建浮点数或整数的补间动画这是我们驱动进度的核心。SetEase设置动画曲线这是“丝滑”效果的关键。比如Ease.OutCubic能提供一种平滑减速的结束感非常适合打字效果。SetDelay设置动画开始前的延迟。OnUpdate动画每帧更新时的回调在这里我们将进度值同步给TMP组件。OnComplete动画完成时的回调用于触发后续事件如显示光标、播放音效。Sequence序列。如果我们的打字效果需要组合多个动作比如先显示文字再让光标闪烁Sequence是必不可少的工具。SetLoops设置循环。对于循环播放的提示性文字很有用。Pause/Play/Restart/Kill对动画生命周期的完全控制。明确了这些我们的工具箱就准备好了。接下来我们进入最核心的实操环节。3. 核心实现基于maxVisibleCharacters的丝滑打字机让我们从一个最基础、最实用的类开始。我将它命名为TMPTypewriter目标是将其做成一个可复用的组件直接挂载在带有TextMeshProUGUI的GameObject上即可工作。3.1 基础组件结构与参数设计首先我们定义这个组件需要暴露给Inspector面板调整的参数。一个好的参数设计能让美术和策划同学也能轻松使用。using TMPro; using UnityEngine; using DG.Tweening; // 确保已导入DoTween命名空间 public class TMPTypewriter : MonoBehaviour { [Header(核心参数)] public TextMeshProUGUI targetText; // 目标TMP文本组件 [SerializeField, TextArea(3, 10)] private string fullText 请输入你想要显示的文本内容...; // 完整的文本内容 public float charsPerSecond 30f; // 打字速度字符/秒 public Ease easeType Ease.Linear; // 动画曲线 [Header(高级效果)] public bool playOnAwake true; // 是否唤醒时自动播放 public float startDelay 0f; // 开始前的延迟 public AudioClip typeSound; // 每个字符的打字音效可选 public GameObject cursorObject; // 打字时跟随的光标对象可选 public float cursorBlinkSpeed 0.5f; // 光标闪烁速度 [Header(回调事件)] public UnityEngine.Events.UnityEvent onCharacterPrinted; // 每打印一个字符时触发 public UnityEngine.Events.UnityEvent onPrintCompleted; // 打印完成时触发 // 运行时变量 private int _totalCharacters; private Tween _typewriterTween; private Sequence _cursorSequence; private AudioSource _audioSource; // 用于播放音效 private void Awake() { // 自动获取组件 if (targetText null) targetText GetComponentTextMeshProUGUI(); if (targetText null) { Debug.LogError(TMPTypewriter: 未找到TextMeshProUGUI组件, this); return; } // 存储完整文本并清空显示 if (string.IsNullOrEmpty(fullText)) fullText targetText.text; _totalCharacters fullText.Length; targetText.text fullText; // 先设置完整文本 targetText.maxVisibleCharacters 0; // 初始隐藏所有字符 targetText.ForceMeshUpdate(); // 强制更新网格确保字符数计算正确 // 尝试获取AudioSource _audioSource GetComponentAudioSource(); if (_audioSource null typeSound ! null) { _audioSource gameObject.AddComponentAudioSource(); _audioSource.playOnAwake false; } if (playOnAwake) { // 稍晚一帧开始确保所有组件初始化完成 Invoke(nameof(StartTypewriter), startDelay 0.05f); } } }参数解析与设计理由charsPerSecond用“字符/秒”而非“总时长”来控制速度更符合直觉。策划可以明确知道“我想要一秒打20个字”的效果。Ease easeType这是实现“丝滑”而非“机械”效果的灵魂。默认Linear是匀速但你可以尝试Ease.OutSine或Ease.OutCubic它们会在结尾轻微减速模拟人手打字的自然节奏。playOnAwake与startDelay提供灵活的触发控制。比如一段对话你可以让第一句playOnAwake为true第二句则通过事件触发并设置一个startDelay作为间隔。音效与光标这是提升体验的关键细节。通过回调事件和DoTween序列我们可以轻松地将这些效果与打字进度同步。3.2 核心动画驱动逻辑接下来是核心方法StartTypewriter和动画创建逻辑。public void StartTypewriter(string newText null) { // 如果传入新文本则替换 if (!string.IsNullOrEmpty(newText)) { fullText newText; targetText.text fullText; _totalCharacters fullText.Length; targetText.maxVisibleCharacters 0; targetText.ForceMeshUpdate(); } // 清理之前的动画 if (_typewriterTween ! null _typewriterTween.IsActive()) { _typewriterTween.Kill(); } if (_cursorSequence ! null _cursorSequence.IsActive()) { _cursorSequence.Kill(); } // 计算总时长 float duration _totalCharacters / charsPerSecond; // 创建核心打字动画 int visibleChars 0; _typewriterTween DOTween.To( () visibleChars, // 动画的起始值Getter x { visibleChars x; targetText.maxVisibleCharacters Mathf.FloorToInt(x); // 每帧更新可见字符数 // 触发每字符回调注意由于是浮点数动画这里需要判断整数变化 if (Mathf.FloorToInt(x) Mathf.FloorToInt(visibleChars) || x _totalCharacters) { OnCharacterPrinted(); } }, _totalCharacters, // 动画的结束值 duration // 动画时长 ) .SetEase(easeType) .SetDelay(startDelay) .OnStart(() { // 动画开始时显示光标如果存在 if (cursorObject ! null) cursorObject.SetActive(true); }) .OnComplete(() { // 动画完成时触发完成事件并开始光标闪烁 onPrintCompleted?.Invoke(); StartCursorBlink(); }); // 立即开始光标闪烁打字过程中光标常亮 if (cursorObject ! null) { cursorObject.SetActive(true); } } private void OnCharacterPrinted() { // 播放音效 if (typeSound ! null _audioSource ! null) { _audioSource.PlayOneShot(typeSound); } // 触发每字符事件 onCharacterPrinted?.Invoke(); } private void StartCursorBlink() { if (cursorObject null) return; _cursorSequence DOTween.Sequence(); _cursorSequence.Append(cursorObject.GetComponentCanvasGroup()?.DOFade(0f, cursorBlinkSpeed) ?? cursorObject.transform.DOScale(Vector3.zero, cursorBlinkSpeed).SetEase(Ease.InOutSine)); _cursorSequence.Append(cursorObject.GetComponentCanvasGroup()?.DOFade(1f, cursorBlinkSpeed) ?? cursorObject.transform.DOScale(Vector3.one, cursorBlinkSpeed).SetEase(Ease.InOutSine)); _cursorSequence.SetLoops(-1, LoopType.Yoyo); // 无限循环 }关键点解析DOTween.To的使用这是DoTween中非常强大的一个方法它允许你动画任何可以通过getter/setter访问的属性。我们这里动画的是一个临时的整型变量visibleChars在OnUpdate即setter委托中将其赋值给targetText.maxVisibleCharacters。浮点数与整数转换注意我们动画的是float值但maxVisibleCharacters需要int。使用Mathf.FloorToInt(x)进行转换。同时为了准确地在“每个新字符出现时”触发音效和事件我们比较了当前帧的整数部分和上一帧的整数部分。性能考量在OnUpdate中直接赋值是高效的因为TMP内部会检查值是否变化无变化则不会触发重绘。相比不断修改text属性会引发字符串分配和完整解析性能有数量级提升。光标闪烁的实现使用Sequence来组合“淡出”和“淡入”动画并设置为Yoyo循环就能实现平滑的闪烁效果。这里做了兼容性处理优先使用CanvasGroup控制透明度如果没有则使用缩放模拟。3.3 控制方法与生命周期管理一个健壮的组件必须提供完善的控制接口。public void Pause() { _typewriterTween?.Pause(); _cursorSequence?.Pause(); } public void Resume() { _typewriterTween?.Play(); _cursorSequence?.Play(); } public void Skip() { if (_typewriterTween ! null _typewriterTween.IsActive()) { _typewriterTween.Complete(); // 立即完成打字动画 } // 跳过时直接显示全部文字并停止光标闪烁 targetText.maxVisibleCharacters _totalCharacters; if (_cursorSequence ! null _cursorSequence.IsActive()) { _cursorSequence.Kill(); if (cursorObject ! null) cursorObject.SetActive(false); } } public void Restart() { targetText.maxVisibleCharacters 0; StartTypewriter(); } private void OnDestroy() { // 组件销毁时务必清理DoTween动画防止内存泄漏和空引用错误 _typewriterTween?.Kill(); _cursorSequence?.Kill(); }重要提示务必在OnDestroy中Kill掉你创建的Tween和Sequence这是使用DoTween时最常见的疏忽之一。如果不清理这些动画对象会继续存在并尝试更新当它们引用的GameObject或组件被销毁时就会抛出MissingReferenceException。Kill()方法会安全地停止并清理动画。4. 高级技巧与效果扩展基础功能实现后我们可以利用DoTween和TMP的特性玩出更多花样。4.1 富文本Rich Text的完美支持这是本方案相比老旧字符串截取方案最大的优势之一。TMP的富文本标签如color#FF0000红色/color、b粗体/b、i斜体/i甚至是自定义的sprite在我们的方案中都能被完美保留。因为maxVisibleCharacters是TMP渲染管线的内置属性它在渲染时会自动处理标签的打开和关闭状态。即使一个颜色标签在不可见区域开始在可见区域结束它也能正确渲染。实操心得你甚至可以在文本中嵌入动画标签比如animwave晃动文字/anim需要搭配TMP的顶点动画Shader。我们的打字效果会逐字揭示这些动画创造出非常炫酷的叠加效果。4.2 非线性打字与随机化效果匀速打字有时显得单调。我们可以通过修改DoTween的Ease曲线或者更高级地自定义OnUpdate中的映射关系来实现非线性效果。示例模拟老式电报机的不均匀节奏// 在StartTypewriter方法中替换DOTween.To的调用部分 _typewriterTween DOTween.To( () visibleChars, x { // 添加一些随机扰动 float jitter Random.Range(-0.3f, 0.3f); int displayChars Mathf.FloorToInt(Mathf.Clamp(x jitter, 0, _totalCharacters)); if (displayChars ! targetText.maxVisibleCharacters) { targetText.maxVisibleCharacters displayChars; OnCharacterPrinted(); // 只在真正变化时触发 } visibleChars x; }, _totalCharacters, duration ) .SetEase(Ease.InOutSine); // 使用一个两头慢中间快的曲线示例实现“逐词”打印而非“逐字”打印这需要先对文本进行分词。我们可以用一个简单的空格分词逻辑private int[] _wordEndIndices; // 存储每个单词结束的字符索引 private void ParseWords() { Listint wordEnds new Listint(); for (int i 0; i fullText.Length; i) { if (fullText[i] || i fullText.Length - 1) { wordEnds.Add(i 1); // 加1是因为maxVisibleCharacters是独占上界 } } _wordEndIndices wordEnds.ToArray(); } // 然后在OnUpdate中将进度映射到最近的单词边界 int displayChars _wordEndIndices[Mathf.FloorToInt((float)_wordEndIndices.Length * (x / _totalCharacters))]; targetText.maxVisibleCharacters displayChars;4.3 与UI系统的深度集成按钮交互与滚动视图打字效果常常用于可交互的叙事中。例如当一段对话打印完毕后才激活“继续”按钮。// 在Inspector中将按钮拖入onPrintCompleted事件 // 或者通过代码 public Button continueButton; private void Start() { onPrintCompleted.AddListener(() continueButton.interactable true); continueButton.interactable false; // 初始不可交互 }在滚动视图Scroll View中如果打字内容很长你可能希望视图自动滚动到底部。可以在OnUpdate中调用ScrollRect.verticalNormalizedPosition 0f;0代表底部。4.4 性能优化与对象池如果你的游戏中有大量、频繁出现的打字效果比如一个聊天密集的模拟游戏频繁创建和销毁TMPTypewriter组件和DoTween动画可能带来GC垃圾回收压力。优化建议对象池将TMPTypewriter组件及其所在的GameObject进行池化管理。当一段文字显示完毕不是销毁它而是将其Reset隐藏文字重置状态并放回池中。复用Tween对于固定速度、固定效果的打字可以考虑预创建一个Tween模板然后通过ChangeEndValue和Restart来复用避免每次重新创建Tween的开销。避免每帧字符串操作这是最重要的。我们的方案已经避免了在动画循环中修改text属性这是最大的性能胜利。确保你的OnCharacterPrinted回调里不要做太重的操作。5. 常见问题、调试技巧与避坑指南在实际项目中使用这套方案你可能会遇到以下问题。这里是我踩过坑后总结的排查清单。5.1 打字效果不出现或瞬间完成检查1maxVisibleCharacters是否生效在动画开始前手动在Inspector里调整TextMeshProUGUI组件的Max Visible Characters属性看文字是否会隐藏。如果不生效检查字体材质或Shader是否支持此属性标准TMP Shader都支持。检查2动画时长计算是否正确打印duration变量看是否因为charsPerSecond值过大如1000导致时长近乎为0。确保_totalCharacters计算正确在设置fullText并调用ForceMeshUpdate之后获取。检查3DoTween初始化了吗确保在场景中有一个DOTween.Init的调用。通常可以在游戏启动的某个脚本中执行DOTween.Init(recycleAllByDefault: true, useSafeMode: true, logBehaviour: LogBehaviour.Default);。5.2 富文本标签显示错乱现象颜色标签提前结束或者样式跑到不该有的地方。原因与解决这通常是因为在动画过程中直接修改了targetText.text。绝对不要在动画播放期间更改text属性所有文本内容都应在动画开始前设置好。如果必须动态更新文本请先调用_typewriterTween.Kill()停止旧动画然后重新设置文本并启动新动画。5.3 音效播放卡顿或重叠原因OnUpdate回调每帧调用如果直接用PlayOneShot且速度很快会导致音效堆叠听起来像爆破音。解决两种方案。一是像我们示例中那样只在整数部分增加时播放一次。二是使用一个简单的计时器限制音效播放的最小间隔例如每0.05秒最多播放一次。5.4 在UI Canvas重建时动画异常现象当Canvas的渲染模式改变、或UI布局剧烈变动时打字动画可能暂停或跳帧。解决DoTween默认使用UpdateType.Normal即每帧更新。在UI剧烈变动时可以尝试将动画的更新模式设置为UpdateType.LateUpdate或UpdateType.FixedUpdate看是否能更稳定。使用SetUpdate(UpdateType.LateUpdate)。_typewriterTween.SetUpdate(UpdateType.LateUpdate);5.5 与Time.timeScale的交互如果你的游戏有暂停功能Time.timeScale 0默认的DoTween动画也会暂停。如果你希望打字效果在游戏暂停时依然继续比如用于重要的剧情对话可以使用SetUpdate(true)来使其忽略Unity的时间缩放。_typewriterTween.SetUpdate(true); // 独立于Time.timeScale5.6 内存泄漏排查如果你发现游戏运行一段时间后变卡记得用Unity Profiler的Memory窗口检查Tween和Sequence对象是否在场景切换后依然存在。确保所有TMPTypewriter组件在OnDestroy中都正确调用了Kill()。DoTween的useSafeMode默认开启能在一定程度上防止因空引用导致的崩溃但主动管理生命周期是最佳实践。最后这套基于DoTween和TextMeshPromaxVisibleCharacters的方案我已经在多个Unity 2022 LTS版本的实际项目中验证过从简单的对话气泡到复杂的任务日志界面它都表现得非常稳定和高效。它成功地将老旧的、命令式的“字符串截取”思维转换为了现代的、声明式的“动画驱动”思维不仅代码更清晰也为实现更复杂的文本动画效果打开了大门。希望这份详细的拆解能帮助你彻底告别Legacy Text时代的打字机在Unity 2022中创造出真正丝滑的动态文本体验。
返回列表