或许你会感到困惑:当语音按钮在松手取消时,其文案竟会短暂跳回“录音中”,随后才恢复正常——这究竟算不算Bug?
严格而言,该现象并未引发闪退、崩溃,录音功能也能正常保存。然而,问题在于它出现在主链路的入口按钮上。用户只要目睹这一闪,心中便会产生疑虑:“刚才是不是误触了什么?录音到底取消了没有?”

本文记录的并非一次普通的Bug修复,而是从“是不是我眼花了”到最终精准定位状态机渲染边界、并完成修复的完整历程。同时,也将分享在此过程中与AI协作时所使用的三层追问方法。
1. 问题表象:持续半秒的视觉闪烁
语音按钮的正常手势路径并不复杂:
长按 → 录音中 → 移出 → 松手取消 → 取消区松手 → 默认态
从功能上看,一切正常——取消确实生效了,没有进入整理中,也没有继续录音。但反复测试后,总有一个视觉异常挥之不去:
松手取消→ 像是先回到录音中→ 再回默认态
文案闪烁的顺序,具体来说是:
松手取消→ 松手识别,移开取消→ 默认态
写过自定义View的同行应该都知道,这绝非普通的动画瑕疵。状态文案和触摸反馈,是用户对系统建立信任的基石——这种闪烁会直接动摇用户的信任感。
2. AI的第一轮解释:为何未被采纳
AI第一次检查后给出的解释是:真实状态并没有走到 RECORDING,只是在 applyDefault() 里复用了录音文案,导致视觉上看起来像录音态闪了一下。
这个解释从代码层面看是对的——枚举值确实没错。但这里其实还有个更关键的问题:
如果只从UI层面看,把DEFAULT的文案改掉就完事了。但问题真的仅限于“文案设置”吗?
这个追问,直接把讨论从“改一句文案”推进到了“状态机边界是否清晰”的层面——而后者,才是后续所有修复的起点。
试想一下,如果当时接受了第一轮解释,后续只会把 DEFAULT 的文案改掉,表面上看问题消失了,但根因——共享View的状态污染——会一直留在代码里,等着挖掘出下一个更隐蔽的Bug。
3. 先列状态路径,而不是直接修
为了避免AI过早给出局部修复,第一步应该是先把所有状态流转路径完整梳理清楚:
正常提交:DEFAULT → RECORDING → ORGANIZING移开取消:DEFAULT → RECORDING → CANCEL → DEFAULT移开再移回:DEFAULT → RECORDING → CANCEL → RECORDING → ORGANIZING系统取消:RECORDING/CANCEL → DEFAULT隐私未确认:DEFAULT → controller 返回 false → DEFAULT权限拒绝:DEFAULT → UNA VAILABLE不可用态再次长按:UNA VAILABLE → 权限兜底 Dialog → UNA VAILABLE
拆完之后,问题立刻变得清晰起来:异常只出现在 CANCEL → DEFAULT 这类“旧进行态退出,新静态态进入”的路径上。
正常路径 CANCEL → RECORDING 没有问题,因为两个状态都使用同一个 stateCopy View,文案过渡是连贯的。但 CANCEL → DEFAULT 不同——DEFAULT 不应该触碰 stateCopy。
4. 真正的根因:新状态污染了旧状态的出场内容
在当前实现中,RECORDING、CANCEL、ORGANIZING 三个状态共用同一个 stateCopy 文案 View。
取消时的真实状态流转是对的:
CANCEL → DEFAULT
但 setVoiceState(DEFAULT) 内部的渲染顺序,才是问题的症结所在:
voiceState改成DEFAULTapplyDefault()执行applyDefault()改写stateCopy为“松手识别,移开取消”updateCopyTransition()再把stateCopy淡出
换句话说,CANCEL 原本要淡出的文案是“松手取消”,但进入 DEFAULT 时,DEFAULT 越权把共享的 stateCopy 改成了“松手识别,移开取消”。
于是用户看到的就变成:
松手取消 → 松手识别,移开取消 → 默认态
而不是正确的:
松手取消 → 默认态
根因总结起来就一句话:不是触摸判断错了,而是新状态在旧状态的出场动画期间,提前污染了共享View的内容。
5. previousState 和 nextState:为什么有必要
AI一开始建议“让 DEFAULT 不改 stateCopy”。这个方向是对的,但还不够完整。再追问一步:
在渲染过渡时,组件到底需不需要知道从哪里来、到哪里去?
最终结论是:有必要,但只在 setVoiceState() 内部使用。不需要把整个业务状态机升级成双状态模型——对外仍然只有一个当前状态:
var voiceState: VoiceState
但在渲染过渡时,组件必须知道:
from = previousStateto = nextState
原因在于,UI有跨状态动画(旧内容淡出 + 新内容淡入),而且多个状态共用一个View。只要旧内容还在淡出,新状态就不能提前改写它。
6. 最终修复原则与代码
最终确立了六条修复原则:
- 对外仍然只有一个当前
voiceState previousState只作为setVoiceState()内部的一次性渲染上下文DEFAULT只拥有defaultCopyUNA VAILABLE只拥有una vailableCopyRECORDING/CANCEL/ORGANIZING才拥有stateCopy- 退出动画期间,旧状态内容不能被新状态覆盖
落到代码上:
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辅助调试的三层追问
这个问题本身很小,但这次沟通模式其实很典型。如果只听第一轮解释,很容易把它当成一个“文案设置问题”。但连续追问了三次之后,局面完全不同:
- “这是不是状态流转问题?”——把讨论从UI层面推进到状态机层面
- “先列出状态流转路径。”——强制梳理全貌,避免过早陷入局部修复
- “previousState / nextState 是否有必要?”——追问设计必要性,而不是接受“改一行就行”
这三次追问,硬生生把AI从局部修复拉回到了状态机建模本身。
从这里可以提炼出三条非常实用的方法论:
- 用户看到的可见状态,也是状态机的一部分——不只需要枚举正确,还需要渲染正确
- 共享View + 跨状态动画,一定要明确旧状态和新状态的边界
- AI给出自洽解释时,追问“这是表面原因还是根因”,比追问“怎么修”更有价值
9. 后续应用
后续接入真实录音和ASR时,同样需要沿用这个原则:
VoiceInputButtonView只负责触摸和可见状态VoiceInputController负责权限、录音、失败消化和成功结果- 任何跨状态动画都要明确内容归属,避免新状态越权改写旧状态
尤其是后续可能会出现的路径:
ORGANIZING → 成功反馈 → DEFAULTORGANIZING → 未识别 Dialog → DEFAULTORGANIZING → 部分识别 BottomSheet → DEFAULT
这些路径同样需要防止“旧状态出场内容被新状态提前覆盖”。问题不在于路径多复杂,而在于每个路径上,是否明确划分了内容归属。
你在项目中有没有遇到过状态枚举值正确、但用户看到的东西不对的情况?后来是怎么排查到渲染层的?不妨来聊聊,说不定能碰撞出更多有意思的思路。
