首页
看点啥
插画图片
首页 看点啥 状态枚举正确不等于渲染正确:语音按钮的状态机边界修复实录

状态枚举正确不等于渲染正确:语音按钮的状态机边界修复实录

2026-07-01 0

我在验收「宝贝轻松记」的语音输入按钮时,发现了一个只有半秒的 UI 闪烁。这个闪烁不会触发崩溃,也不影响录音保存——但它出现在主链路入口的按钮上,用户会怀疑自己是不是误提交了语音。

状态枚举正确≠渲染正确:一个语音按钮的状态机边界修复实录

本文记录我从提出「这是不是我的视觉错觉」到最终定位到状态机渲染边界、完成修复的完整过程,以及与 AI 协作中的三层追问方法论。

1. 问题表面:一个半秒的闪烁

语音按钮的正常手势路径之一是:

长按 → 录音中 → 移出 → 松手取消 → 取消区松手 → 默认态

功能上一切正常——取消确实生效了,没有进入整理中,也没有继续录音。但我反复测试后发现一个视觉异常:

松手取消→ 像是先回到录音中→ 再回默认态

文案闪烁的顺序是:

松手取消→ 松手识别,移开取消→ 默认态

如果你写过自定义 View,你应该能感觉到这不是普通的动画瑕疵。状态文案和触摸反馈是用户对系统建立信任的基础——状态闪烁会动摇这种信任。

2. AI 的第一轮解释:为什么我没有接受

AI 第一次检查后给出的解释是:真实状态没有走 RECORDING,只是 applyDefault() 里复用了录音文案,导致视觉上像录音态闪了一下。

这个解释从代码层面是对的——枚举值确实没有错。但在我看来还不够:

于是我继续追问:

这个追问把讨论从「改一句文案」推进到了「状态机边界是否清晰」——这是后续所有修复的起点。

如果当时我接受了第一轮解释,后续只会把 DEFAULT 的文案改掉,问题看似消失,但根因——共享 View 的状态污染——会一直留在代码里,等待下一个更隐蔽的 bug。

3. 先列状态路径,而不是直接修

为了避免 AI 过早给出局部修复,我要求先把所有状态流转路径列出来:

正常提交:DEFAULT → RECORDING → ORGANIZING​移开取消:DEFAULT → RECORDING → CANCEL → DEFAULT​移开再移回:DEFAULT → RECORDING → CANCEL → RECORDING → ORGANIZING​系统取消:RECORDING/CANCEL → DEFAULT​隐私未确认:DEFAULT → controller 返回 falseDEFAULT​权限拒绝:DEFAULT → UNAVAILABLE​不可用态再次长按:UNAVAILABLE → 权限兜底 Dialog → UNAVAILABLE

拆完之后问题立刻清晰了:异常只出现在 CANCEL → DEFAULT 这类「旧进行态退出,新静态态进入」的路径上。

正常路径 CANCEL → RECORDING 没有问题,因为两个状态都使用同一个 stateCopy View,文案过渡是连贯的。但 CANCEL → DEFAULT 不同——DEFAULT 不应该触碰 stateCopy

4. 真正的根因:新状态污染了旧状态的出场内容

当前实现中,RECORDINGCANCELORGANIZING 三个状态共用同一个 stateCopy 文案 View。

取消时的真实状态流转是对的:

CANCEL → DEFAULT

setVoiceState(DEFAULT) 内部的渲染顺序有问题:

  1. voiceState 改成 DEFAULT
  2. applyDefault() 执行
  3. applyDefault() 改写 stateCopy 为「松手识别,移开取消」
  4. updateCopyTransition() 再把 stateCopy 淡出

也就是说,CANCEL 原本要淡出的文案是「松手取消」,但进入 DEFAULT 时,DEFAULT 越权把共享的 stateCopy 改成了「松手识别,移开取消」。

于是用户看到的就变成:

松手取消 → 松手识别,移开取消 → 默认态

而不是正确的:

松手取消 → 默认态

根因总结:不是触摸判断错了,而是新状态在旧状态的出场动画期间,提前污染了共享 View 的内容。

5. previousState 和 nextState:为什么有必要

一开始 AI 建议「让 DEFAULT 不改 stateCopy」。这个建议方向对,但还不完整。我继续追问:

最终结论是:有必要,但只在 setVoiceState() 内部使用。 我们不需要把整个业务状态机升级成双状态模型——对外仍然只有一个当前状态:

var voiceState: VoiceState

但在渲染过渡时,组件必须知道:

from = previousStateto = nextState

原因是 UI 有跨状态动画(旧内容淡出 + 新内容淡入),而且多个状态共用一个 View。只要旧内容还在淡出,新状态就不能提前改写它。

6. 最终修复原则与代码

确定的六条修复原则:

  1. 对外仍然只有一个当前 voiceState
  2. previousState 只作为 setVoiceState() 内部的一次性渲染上下文
  3. DEFAULT 只拥有 defaultCopy
  4. UNAVAILABLE 只拥有 unavailableCopy
  5. RECORDING / CANCEL / ORGANIZING 才拥有 stateCopy
  6. 退出动画期间,旧状态内容不能被新状态覆盖

落到代码上:

fun setVoiceState(state: VoiceState, animate: Boolean = true) {val previousState = voiceStateif (previousState == state && animate) returnvoiceState = stateapplyNextStateVisuals(state)updateCopyTransition(previousState, state, animate)}

同时加了两条关键注释:

7. 修复后验收

修复后,取消路径恢复为:

默认态 → 松手识别,移开取消 → 松手取消 → 默认态

不再出现取消松手后闪回录音文案。

同时验证了保留的正确路径——CANCEL → RECORDING(移出后移回)仍然正常工作。这说明修复没有简单粗暴地禁掉路径,而是只修正了 CANCEL → DEFAULT 的出场边界。

8. 方法论提炼:AI 辅助调试的三层追问

这次问题很小,但沟通模式很典型。如果只听第一轮解释,很容易把它当成「文案设置问题」。但我连续追问了三次:

  1. 「这是不是状态流转问题?」——把讨论从 UI 层面推进到状态机层面
  2. 「先列出状态流转路径。」——强制梳理全貌,避免过早陷入局部修复
  3. 「previousState / nextState 是否有必要?」——追问设计必要性,而不是接受「改一行就行」

这三次追问把 AI 从局部修复拉回到了状态机建模本身。

三条方法论:

9. 后续应用

后续接真实录音和 ASR 时,同样需要沿用这个原则:

尤其是后续会出现的路径:

ORGANIZING → 成功反馈 → DEFAULTORGANIZING → 未识别 Dialog → DEFAULTORGANIZING → 部分识别 BottomSheet → DEFAULT

这些路径同样需要防止「旧状态出场内容被新状态提前覆盖」——问题不在于路径多复杂,而在于每个路径上是否明确划分了内容归属。

你在项目中有没有遇到过状态枚举值正确、但用户看到的东西不对的情况?你是怎么排查到渲染层的?欢迎大家来讨论,给我增加点热度也是好的~~~~~~

喜欢(0)

上一篇

餐饮场景下智能客流统计系统关键技术原理解析

餐饮场景下智能客流统计系统关键技术原理解析

下一篇

AI企业知识库系统开发

AI企业知识库系统开发
猜你喜欢