《猫猫钓游记》可爱+收集+钓鱼游戏试玩
2026-06-30
2026-07-01 0
我在验收「宝贝轻松记」的语音输入按钮时,发现了一个只有半秒的 UI 闪烁。这个闪烁不会触发崩溃,也不影响录音保存——但它出现在主链路入口的按钮上,用户会怀疑自己是不是误提交了语音。

本文记录我从提出「这是不是我的视觉错觉」到最终定位到状态机渲染边界、完成修复的完整过程,以及与 AI 协作中的三层追问方法论。
语音按钮的正常手势路径之一是:
长按 → 录音中 → 移出 → 松手取消 → 取消区松手 → 默认态
功能上一切正常——取消确实生效了,没有进入整理中,也没有继续录音。但我反复测试后发现一个视觉异常:
松手取消→ 像是先回到录音中→ 再回默认态
文案闪烁的顺序是:
松手取消→ 松手识别,移开取消→ 默认态
如果你写过自定义 View,你应该能感觉到这不是普通的动画瑕疵。状态文案和触摸反馈是用户对系统建立信任的基础——状态闪烁会动摇这种信任。
AI 第一次检查后给出的解释是:真实状态没有走 RECORDING,只是 applyDefault() 里复用了录音文案,导致视觉上像录音态闪了一下。
这个解释从代码层面是对的——枚举值确实没有错。但在我看来还不够:
于是我继续追问:
这个追问把讨论从「改一句文案」推进到了「状态机边界是否清晰」——这是后续所有修复的起点。
如果当时我接受了第一轮解释,后续只会把 DEFAULT 的文案改掉,问题看似消失,但根因——共享 View 的状态污染——会一直留在代码里,等待下一个更隐蔽的 bug。
为了避免 AI 过早给出局部修复,我要求先把所有状态流转路径列出来:
正常提交:DEFAULT → RECORDING → ORGANIZING移开取消:DEFAULT → RECORDING → CANCEL → DEFAULT移开再移回:DEFAULT → RECORDING → CANCEL → RECORDING → ORGANIZING系统取消:RECORDING/CANCEL → DEFAULT隐私未确认:DEFAULT → controller 返回 false → DEFAULT权限拒绝:DEFAULT → UNAVAILABLE不可用态再次长按:UNAVAILABLE → 权限兜底 Dialog → UNAVAILABLE
拆完之后问题立刻清晰了:异常只出现在 CANCEL → DEFAULT 这类「旧进行态退出,新静态态进入」的路径上。
正常路径 CANCEL → RECORDING 没有问题,因为两个状态都使用同一个 stateCopy View,文案过渡是连贯的。但 CANCEL → DEFAULT 不同——DEFAULT 不应该触碰 stateCopy。
当前实现中,RECORDING、CANCEL、ORGANIZING 三个状态共用同一个 stateCopy 文案 View。
取消时的真实状态流转是对的:
CANCEL → DEFAULT
但 setVoiceState(DEFAULT) 内部的渲染顺序有问题:
voiceState 改成 DEFAULTapplyDefault() 执行applyDefault() 改写 stateCopy 为「松手识别,移开取消」updateCopyTransition() 再把 stateCopy 淡出也就是说,CANCEL 原本要淡出的文案是「松手取消」,但进入 DEFAULT 时,DEFAULT 越权把共享的 stateCopy 改成了「松手识别,移开取消」。
于是用户看到的就变成:
松手取消 → 松手识别,移开取消 → 默认态
而不是正确的:
松手取消 → 默认态
根因总结:不是触摸判断错了,而是新状态在旧状态的出场动画期间,提前污染了共享 View 的内容。
一开始 AI 建议「让 DEFAULT 不改 stateCopy」。这个建议方向对,但还不完整。我继续追问:
最终结论是:有必要,但只在 setVoiceState() 内部使用。 我们不需要把整个业务状态机升级成双状态模型——对外仍然只有一个当前状态:
var voiceState: VoiceState
但在渲染过渡时,组件必须知道:
from = previousStateto = nextState
原因是 UI 有跨状态动画(旧内容淡出 + 新内容淡入),而且多个状态共用一个 View。只要旧内容还在淡出,新状态就不能提前改写它。
确定的六条修复原则:
voiceStatepreviousState 只作为 setVoiceState() 内部的一次性渲染上下文DEFAULT 只拥有 defaultCopyUNAVAILABLE 只拥有 unavailableCopyRECORDING / CANCEL / ORGANIZING 才拥有 stateCopy落到代码上:
fun setVoiceState(state: VoiceState, animate: Boolean = true) {val previousState = voiceStateif (previousState == state && animate) returnvoiceState = stateapplyNextStateVisuals(state)updateCopyTransition(previousState, state, animate)}
同时加了两条关键注释:
修复后,取消路径恢复为:
默认态 → 松手识别,移开取消 → 松手取消 → 默认态
不再出现取消松手后闪回录音文案。
同时验证了保留的正确路径——CANCEL → RECORDING(移出后移回)仍然正常工作。这说明修复没有简单粗暴地禁掉路径,而是只修正了 CANCEL → DEFAULT 的出场边界。
这次问题很小,但沟通模式很典型。如果只听第一轮解释,很容易把它当成「文案设置问题」。但我连续追问了三次:
这三次追问把 AI 从局部修复拉回到了状态机建模本身。
三条方法论:
后续接真实录音和 ASR 时,同样需要沿用这个原则:
VoiceInputButtonView 只负责触摸和可见状态VoiceInputController 负责权限、录音、失败消化和成功结果尤其是后续会出现的路径:
ORGANIZING → 成功反馈 → DEFAULTORGANIZING → 未识别 Dialog → DEFAULTORGANIZING → 部分识别 BottomSheet → DEFAULT
这些路径同样需要防止「旧状态出场内容被新状态提前覆盖」——问题不在于路径多复杂,而在于每个路径上是否明确划分了内容归属。
你在项目中有没有遇到过状态枚举值正确、但用户看到的东西不对的情况?你是怎么排查到渲染层的?欢迎大家来讨论,给我增加点热度也是好的~~~~~~