最近在做一套对话框展示逻辑时,遇到一个很典型的 UI 需求:一行提示文字要在指定时机淡入,淡入完成后停留一段时间,再根据剧情或玩家操作淡出。听起来很简单,第一反应自然是开个协程,从 0 把 alpha 跑到 1。真正实现后发现完全不是这么回事——多个入口触发、界面复用、还没播完又重新开始,代码会瞬间变成一堆 timer 加 flag,越改越乱。最后我把整个渐显动作拆成了一个轻量状态机,把"文字渐显"从一段动画函数变成一种流程状态,问题立刻清晰了。
这篇文章就围绕 Unity 里用状态机控制原生 Text实现文字渐显方案展开。我会说清楚原生 Text 的 alpha 到底改在哪个渲染层,为什么建议用状态机而不是纯协程,自建状态机怎么写,Animator 路线怎么配合,以及我在实测中踩过的一堆坑。整篇针对 Unity UGUI 项目,适合做剧情对话、新手引导、战斗提示的开发者参考。
1. 先把需求拆明白:文字渐显不是"改透明度",而是"一段可控流程"
1.1 原始需求里的隐藏复杂度
很多刚入门的开发者把"文字渐显"等价于"给 Text 的 alpha 做插值"。这个理解没错,但只覆盖了最简单的情况。实际项目里,渐显往往不是单独存在的,它会和队列、跳过、多段文本、按钮状态绑在一起。
我再举个实际例子:某个剧情界面里,一段文本显示流程是这样的——初始隐藏,玩家点击按钮后才开始淡入;淡入过程中如果再点一次按钮,需要立刻把文本显示完整并进入"展示完成"状态;展示一段时间后自动淡出。如果只用协程,你至少需要两个协程:一个负责淡入、一个负责淡出,还要手动记录"当前正在哪个阶段",点击跳过时要停止当前协程再启动新的。协程之间一旦交错,状态就失控了。
我见过最混乱的写法是:多个协程同时操作同一个 Text 的 color,结果 alpha 像抽风一样忽大忽小。这就是因为"渐显"这个行为天然具备状态属性:它可以从任意状态被中断、被重入、被重新定向。没有状态机管着,任何一条流程线都可能和另一条打架。
1.2 从协程写法到状态机思路的转变
协程的本质是"时间片上的执行流",它擅长的是"按顺序等一段时间"。而状态机的本质是"有限的状态集合 + 状态之间的转移条件",它擅长的是"在任意时刻回答当前系统处于什么阶段、下一步能做什么"。
放在渐显场景里,状态机把整个 UI 文本生命周期拆成四段:
| 状态 | 含义 | 进入条件 | 退出条件 |
|---|---|---|---|
| Hidden | 文本不可见,等待触发 | 初始化完成 / 淡出结束 | 收到显示请求 |
| FadingIn | alpha 从 0 升到 1 | 收到显示请求 | 渐变完成 |
| Showing | 文本完整展示,可停留 | 渐变完成 | 收到隐藏请求 / 自动停留结束 |
| FadingOut | alpha 从 1 降到 0 | 收到隐藏请求 / 自动停留结束 | 渐变完成 |
有了这张状态表,代码里就不再是一堆if (isFading && !_stopFlag),而是switch (_currentState)。每帧只处理当前状态下应该做的事,状态转移由明确的入口函数触发。这样的结构有几个直接好处:调试时可以打印当前状态;打断重入时只需要把状态切回 Hidden;扩展"逐字渐显""悬浮抖动"等效果时,加状态就行。
1.3 状态机到底管了什么:一个很容易被忽略的事实
状态机管的不是"透明度数值本身",而是透明度数值变化的触发时序。
这句话值得多读几遍。如果你只是想把 alpha 从 0 变到 1,用LeanTween、DOTween、协程都行,根本不需要状态机。但当你需要"点击跳过显示完整、文本队列里有下一句、界面可能随时被关闭"时,状态机提供了唯一的确定性保障:任何时候,都只有一个明确的状态在决定 UI 表现。
所以我的建议是:任何超过两段的文本演出,不要先写协程再改成状态机,而是直接按状态机设计。这个思维转换越早,后面返工越少。
2. 原生 Text 的 alpha 到底改在哪一层
2.1 UGUI Text 的顶点颜色机制
用原生 Text(UnityEngine.UI.Text)做渐显,控制 alpha 的方式不止一种,但它们的效果并不等价。要搞清楚这一点,必须理解 UGUI 的渲染链路。
UGUI 的 Text 继承自MaskableGraphic,它的文字不是纹理贴图,而是通过TextGenerator生成一组网格顶点,每个字符对应 4 个顶点。最终显示颜色 = 顶点颜色 × 材质颜色 × CanvasGroup 的倍率。也就是说,原生 Text 的每个字符都带有独立的颜色信息,并且这 4 个顶点的颜色可以被单独修改,这也是后面做"逐字渐显"的底层基础。
但大多数情况下,我们的操作远没到顶点级。常见操作有两种:改Text.color的 alpha,或者改挂在父节点上的CanvasGroup.alpha。它们的原理完全不同。
2.2 三条可用路径的对比
| 控制方式 | 改动层级 | 是否触发网格重建 | 是否影响合批 | 是否影响射线检测 |
|---|---|---|---|---|
Text.color.a | Graphic 顶点颜色 | 是,会触发 SetVerticesDirty | 可能打断合批 | 不影响 |
CanvasGroup.alpha | CanvasRenderer 渲染倍率 | 否 | 基本不影响 | 默认不影响,但需要块屏蔽配合 |
| 直接改 Mesh 顶点颜色 | CanvasRenderer 网格 | 否(手动 SetMesh 后) | 自己控制 | 不影响 |
先说Text.color.a。它的优势是直觉、简单,但代价是每次修改 color 都会让整个 Text 的顶点数据标记为脏,导致网格重建。如果你在一个固定的 Canvas 下每帧改一次 alpha,Text 会每帧重新计算顶点位置和颜色。单条文本还好,但如果一个界面里同时有很多 Text 做各自独立的渐显,每帧重建网格的开销就会明显上升。
再说CanvasGroup.alpha。它并不直接改变 Text 网格,而是在 CanvasRenderer 层面叠加一个渲染透明度。也就是说,Text 的顶点颜色保持不变,Canvas 合批时再整体乘一个 alpha。它的好处是不产生几何重建,性能好,而且天然可以让一组 UI 元素同步渐显。缺点是你需要管理 CanvasGroup 组件,并且要注意它默认不会屏蔽射线检测。
如果标题里明确写了"原生 Text",我个人更推荐把 CanvasGroup 挂在 Text 的父节点上,由状态机控制 CanvasGroup 的 alpha。这样即使一个节点下面挂着 Text 和其他装饰 UI,也可以整体渐显,而状态机本身不关心组件类型,只关心"当前应该显示多少透明度"。
2.3 为什么我不建议每帧改 Text.color.a
这句话需要加个限定条件:如果你只是原地淡入、文本很短、项目对性能不太敏感,那改Text.color.a也能跑。但一旦涉及到整段文本渐显的同时还有滚动文本、多个 UI 同时入场、移动端低端机型,性能问题就会暴露。
我实测过一次:一个界面里 8 段文本同时淡入,用Text.color.a驱动,帧率在旧手机上掉了 5~8 毫秒。排查后发现瓶颈就是顶点重建。改成 CanvasGroup 整体控制后,开销几乎可以忽略,所有文本用同一个 alpha 倍率,视觉上也更统一。
如果你决定用Text.color路线,至少要在初始化时把原始 RGB 存下来,避免渐显完变成别的颜色:
private Text _text; private Color _originColor; void Awake() { _text = GetComponent<Text>(); _originColor = _text.color; } void SetTextAlpha(float alpha) { _text.color = new Color(_originColor.r, _originColor.g, _originColor.b, alpha); }这段代码本身没问题,但请注意:它每次都会触发顶点重建。如果只是少量、低频调用,可以接受;高频调用就建议马上切 CanvasGroup。
3. 自建轻量状态机:结构、代码与踩坑
3.1 最小可用的 FSM 结构
很多项目一提状态机就想到 Animator,其实对于纯代码逻辑来说,一个几十行的 FSM 可能比 Animator 更合适。自建状态机的好处是:完全不受 Animator 的动画系统打扰,可以在任意生命周期函数里控制推进,还能天然配合对象池复用。
我常用的结构是三块:
- 状态枚举,定义所有可能的 UI 文本阶段;
- 一个状态机类,维护当前状态、状态滞留时长、进入/退出事件;
- 一个驱动入口,在
Update里调用,或者在外部事件发生时调用。
下面给出一个可以直接放到项目里的最小实现。这个实现把"文字渐显"和"文字渐出"都纳入管理,并预留了自动停留时间。
3.2 完整核心代码
public enum TextFadePhase { Hidden, FadingIn, Showing, FadingOut } public class TextFadeStateMachine { private TextFadePhase _current = TextFadePhase.Hidden; private float _elapsed; private float _fadeDuration = 0.4f; private float _stayDuration = 1.2f; private float _stayElapsed; private CanvasGroup _target; private System.Action<TextFadePhase> _onEnter; private System.Action<TextFadePhase> _onExit; public TextFadePhase Current => _current; public void Initialize(CanvasGroup target, float fadeDuration, float stayDuration) { _target = target; _fadeDuration = fadeDuration; _stayDuration = stayDuration; _target.alpha = 0f; _current = TextFadePhase.Hidden; _target.blocksRaycasts = false; } public void Update(float deltaTime) { switch (_current) { case TextFadePhase.Hidden: break; case TextFadePhase.FadingIn: _elapsed += deltaTime; _target.alpha = Mathf.Lerp(0f, 1f, _elapsed / _fadeDuration); if (_elapsed >= _fadeDuration) { _target.alpha = 1f; TransitionTo(TextFadePhase.Showing); } break; case TextFadePhase.Showing: _stayElapsed += deltaTime; if (_stayElapsed >= _stayDuration) TransitionTo(TextFadePhase.FadingOut); break; case TextFadePhase.FadingOut: _elapsed += deltaTime; _target.alpha = Mathf.Lerp(1f, 0f, _elapsed / _fadeDuration); if (_elapsed >= _fadeDuration) { _target.alpha = 0f; TransitionTo(TextFadePhase.Hidden); } break; } } public void Show() { if (_current == TextFadePhase.FadingIn) return; TransitionTo(TextFadePhase.FadingIn); } public void Hide() { if (_current == TextFadePhase.FadingOut) return; TransitionTo(TextFadePhase.FadingOut); } public void CompleteImmediately() { if (_current == TextFadePhase.FadingIn) { _target.alpha = 1f; TransitionTo(TextFadePhase.Showing); } } private void TransitionTo(TextFadePhase next) { if (_current == next) return; _onExit?.Invoke(_current); _current = next; _elapsed = 0f; _stayElapsed = 0f; _onEnter?.Invoke(_current); // 进入 Hidden 后彻底关闭射线检测,避免看不见但挡点击 _target.blocksRaycasts = (_current != TextFadePhase.Hidden); } }这段代码做了几件事:初始化时把 alpha 置 0;Show后进入淡入;淡入完成自动进入 Showing;在 Showing 停留指定时间后自动淡出;淡出完成回到 Hidden。最关键的是blocksRaycasts的处理,它解决了"看不见但挡点击"的经典问题。
3.3 状态机与 UI 更新的解耦写法
上面这个状态机是纯逻辑类,它不持有任何 MonoBehaviour 生命周期。使用它的脚本只需要在Update里传入Time.deltaTime即可。
public class DialogueTextController : MonoBehaviour { private TextFadeStateMachine _fsm; void Awake() { var group = GetComponentInChildren<CanvasGroup>(); if (group == null) group = gameObject.AddComponent<CanvasGroup>(); _fsm = new TextFadeStateMachine(); _fsm.Initialize(group, 0.45f, 2.0f); } void Update() { _fsm.Update(Time.deltaTime); // 调试用,正式包可去掉 if (Input.GetKeyDown(KeyCode.Space)) _fsm.Show(); if (Input.GetKeyDown(KeyCode.Escape)) _fsm.Hide(); } }这样写的好处是把"什么时候显示"和"怎么显示"彻底解耦。上游逻辑只需要调用Show()、Hide()、CompleteImmediately(),不用关心当前 alpha 是多少、还剩多少帧。状态机内部自己维护一切。
如果你希望渐显曲线更有弹性,可以把Mathf.Lerp换成Mathf.SmoothStep或自己定义的缓动函数。我建议把缓动函数作为委托注入,而不是写死在状态机里,这样不同界面可以用不同曲线。
4. 如果走 Animator 路线,怎么和原生 Text 配合
4.1 控制器和参数的搭法
自建 FSM 适合逻辑复杂的情况,但有些团队更喜欢用 Animator 来可视化调动画曲线,尤其是想要"淡入 + 轻微上移 + 淡出"这种复合效果时。这时也可以在 Animator 里做文字渐显。
做法是:在 Animator 状态机里定义Init、FadeIn、Stay、FadeOut四个状态,其中FadeIn和FadeOut使用 Animation Clip,Clip 里直接记录CanvasGroup.alpha的曲线。用动画曲线的 0 到 1 关键帧驱动渐显,比代码每帧插值更符合美术习惯。
参数方面,不需要用 Bool,推荐用一个Trigger参数叫ShowTrigger,一个Trigger参数叫HideTrigger。状态转移设置成:
| 起始状态 | 目标状态 | 转移条件 |
|---|---|---|
| Init | FadeIn | ShowTrigger |
| FadeIn | Stay | 动画结束 |
| Stay | FadeOut | HideTrigger |
| FadeOut | Init | 动画结束 |
这样 Animator 本身就是一个可视化的状态机,控制脚本只需要在合适时机执行animator.SetTrigger("ShowTrigger")即可。
4.2 Trigger 过渡的一个常见坑
Animator 的 Trigger 有一个非常容易踩的坑:如果同一个 Trigger 在前一个状态还没消耗完时再次触发,会被后面的状态吞掉或者无效。也就是说快速双击按钮触发 ShowTrigger,可能只有第一次生效,第二次点击没有任何反应。
针对这个问题,我只在状态切换完全由玩家高频操作驱动时才用 Trigger,否则更推荐用bool参数配合OnStateMachineExit重置。或者,干脆在代码侧用一个"目标状态字段",通过animator.Play("FadeIn", 0, 0f)强制切换,跳过 Trigger 的消耗机制。
4.3 两条路线的选择标准
聊到这儿,我总结一下自己团队的选择标准,供你参考:
| 场景 | 推荐路线 |
|---|---|
| 固定展示流程,动画想要可视化编辑 | Animator |
| 存在点击跳过、多段文本、动态队列 | 自建 FSM |
| 需要对象池复用、界面经常销毁重建 | 自建 FSM |
| 需要做时间轴级联动画(淡入 + 位移 + 缩放) | Animator |
| 多 Text 同一组渐显,追求最低开销 | 自建 FSM + CanvasGroup |
两个路线并不互斥。我在某个项目里用的是"混合方案":Animator 管 UI 整体入场和出场,自建 FSM 管文本的阶段逻辑。Animator 结束时触发状态机进入 Showing,Showing 停留自动请求 Animator 播 FadeOut。这样既有可视化编辑,又有明确的状态职责。
5. 实测问题清单:渐显失败、闪白、点击穿透
5.1 首帧全白:初始化时没把 alpha 归零
这是最经典的问题:Text 在场景里默认 alpha 是 1,代码状态机的初始状态是 Hidden,但你没在初始化时把 alpha 设为 0。结果第一帧 UI 直接显示全白内容,从第二帧才开始渐显。视觉表现就是"闪了一下"。
修复很简单——在Awake或OnEnable里,根据初始状态把 Text 或 CanvasGroup 的 alpha 设为 0。但要注意,如果你的状态机不在Awake里初始化,而是延迟到某个事件触发后才初始化,那初始化函数里也必须立刻设置一次 alpha,不能依赖外部。
5.2 CanvasGroup 的 raycast 穿透问题
刚才代码里我特别加了blocksRaycasts的切换,这是实测中非常容易忽略的一环。CanvasGroup.alpha = 0只影响视觉,不影响 UGUI 的射线检测。一段完全透明的文字依然可以挡住后面的按钮点击,用户会以为"点了没反应"。
解决办法三种:第一,在状态机进入 Hidden 时把 CanvasGroup 的blocksRaycasts设为 false;第二,在 Text 组件上关闭raycastTarget,这样 Text 本身不参与射线检测;第三,状态机上挂一个独立的 UI 射线屏蔽节点。我推荐第一和第二种组合使用,因为有时你需要保留 CanvasGroup 上的interactable行为,不能一刀切。
5.3 多个 Text 不同步
一个界面里如果有标题、正文、底部提示三行文本,想同时渐显,最容易犯的错误是给每个 Text 挂一个独立控制脚本,各自启动自己的状态机。问题在于,三个脚本的Update调用顺序、初始化时机可能有细微差异,导致视觉上三行文字总差了半帧到一帧,显得很顿。
正确做法是把三个 Text 挂到同一个 CanvasGroup 下面,由同一个状态机控制这个 CanvasGroup 的 alpha。这样所有子 Text 天然共享同一个透明度倍率,渲染上也利于合批,同步问题直接消失。
5.4 界面复用时的状态复位
现代项目都喜欢做对象池。一个对话框退场时,状态机可能还停在 Showing 状态,alpha 是 1。下次从池里取出来直接调用Show(),如果你在池的Recycle()里没有复位状态机,就会出现"文本一亮一暗"或是"根本没有淡入动画"。
我的做法是给状态机加一个ResetToHidden()方法,内部强制把状态切回 Hidden,alpha 设为 0,清空所有计时字段。对象池回收时调用它。注意不要在OnDisable里复位,因为 OnDisable 在界面销毁和切换时也会触发,容易把正在进行的淡出动画打断。
public void ResetToHidden() { _current = TextFadePhase.Hidden; _elapsed = 0f; _stayElapsed = 0f; if (_target != null) { _target.alpha = 0f; _target.blocksRaycasts = false; } }6. 扩展:逐字渐显怎么做才算"原生"
6.1 用字符串截断实现的简易方案
如果需求不是整段淡入,而是一行对话"像打字机一样逐个字出现,同时带一点渐显",很多项目会用字符串截断来做:每帧向外多显示一个字符,同时整体透明度按进度从 0 到 1。
这个方案本质上是"打字机 + 整体渐显"的杂交体,代码比较简单,但有几个注意点。第一,文本需要设置horizontalOverflow为 Overflow,或者固定 Text 区域和 alignment,避免字符逐个出现时整段文本左右跳动。第二,每次改text.text都会触发网格重建,所以字符数越多,性能越差。第三,如果要显示的话带富文本标签,截断的位置不能随便截,必须先算好去掉标签后的可见长度。
6.2 用顶点颜色做逐字渐显的代价
更高级的做法是真正逐字控制顶点 alpha。原生 Text 的TextGenerator可以拿到每个字符的四个顶点,遍历顶点把颜色 alpha 改成当前进度对应的值,再调用CanvasRenderer.SetMesh把网格交回给渲染管线。
这样做的好处是文字已经出现的部分不会因为截断而重新排版,视觉上每个字像"显影"一样浮现。但代价非常明显:
- 需要处理富文本标签、空格、换行的顶点映射;
- 每次手动改顶点后,任何触发 Graphic dirty 的操作都会覆盖掉你的自定义网格;
- 多行文本的顶点索引不像 TMP 的
textInfo那么直观,调试成本高。
我的结论是:如果团队已经可以用 TextMeshPro,别在原声 Text 上硬实现逐字顶点渐显;如果项目强制用原生 Text,且逐字需求只是固定几句话,用字符串截断方案更划算。这不算技术洁癖,而是投入产出比问题。
6.3 状态机加一个 State 就够
最后,如果用自建状态机做逐字渐显,不需要推翻现有结构,只需要在 FadingIn 里增加一个子状态分流:判断当前是否启用逐字模式,启用时在 FadingIn 的 Update 里把"显示进度"从透明度插值改为"字符数 + 透明度"双插值。
case TextFadePhase.FadingIn: _elapsed += deltaTime; float progress = Mathf.Clamp01(_elapsed / _fadeDuration); if (_revealByChars) { int charCount = Mathf.CeilToInt(progress * _fullContent.Length); _text.text = _fullContent.Substring(0, charCount); } _group.alpha = Mathf.Lerp(0f, 1f, progress); if (progress >= 1f) TransitionTo(TextFadePhase.Showing); break;这段代码可以按照项目需求调整。唯一要记住的是:加特技不会破坏状态机的整体结构,只需要在对应 State 内部丰富行为。这也是用状态机管理 UI 文字渐显最大的好处——表现可以越来越复杂,流程控制始终保持稳定。