适用人群:Android 开发者、对状态机设计感兴趣的前端/客户端工程师
关键词:状态机渲染边界、previousState/nextState、共享 View 状态污染、AI 辅助调试
我在验收「宝贝轻松记」的语音输入按钮时,发现了一个只有半秒的 UI 闪烁。这个闪烁不会触发崩溃,也不影响录音保存------但它出现在主链路入口的按钮上,用户会怀疑自己是不是误提交了语音。
本文记录我从提出「这是不是我的视觉错觉」到最终定位到状态机渲染边界、完成修复的完整过程,以及与 AI 协作中的三层追问方法论。
1. 问题表面:一个半秒的闪烁
语音按钮的正常手势路径之一是:
长按 → 录音中 → 移出 → 松手取消 → 取消区松手 → 默认态
功能上一切正常------取消确实生效了,没有进入整理中,也没有继续录音。但我反复测试后发现一个视觉异常:
松手取消
→ 像是先回到录音中
→ 再回默认态
文案闪烁的顺序是:
松手取消
→ 松手识别,移开取消
→ 默认态
如果你写过自定义 View,你应该能感觉到这不是普通的动画瑕疵。状态文案和触摸反馈是用户对系统建立信任的基础------状态闪烁会动摇这种信任。
2. AI 的第一轮解释:为什么我没有接受
AI 第一次检查后给出的解释是:真实状态没有走 RECORDING,只是 applyDefault() 里复用了录音文案,导致视觉上像录音态闪了一下。
这个解释从代码层面是对的------枚举值确实没有错。但在我看来还不够:
对用户来说,「真实枚举状态没错」并不能说明问题不存在。用户看到的是可见状态流转,而不是代码里的枚举值。
于是我继续追问:
这是状态流转的问题吧?看起来不像是简单的 UI 切换问题。
这个追问把讨论从「改一句文案」推进到了「状态机边界是否清晰」------这是后续所有修复的起点。
如果当时我接受了第一轮解释,后续只会把 DEFAULT 的文案改掉,问题看似消失,但根因------共享 View 的状态污染------会一直留在代码里,等待下一个更隐蔽的 bug。
3. 先列状态路径,而不是直接修
为了避免 AI 过早给出局部修复,我要求先把所有状态流转路径列出来:
arduino
正常提交:
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。
4. 真正的根因:新状态污染了旧状态的出场内容
当前实现中,RECORDING、CANCEL、ORGANIZING 三个状态共用同一个 stateCopy 文案 View。
取消时的真实状态流转是对的:
arduino
CANCEL → DEFAULT
但 setVoiceState(DEFAULT) 内部的渲染顺序有问题:
voiceState改成DEFAULTapplyDefault()执行applyDefault()改写stateCopy为「松手识别,移开取消」updateCopyTransition()再把stateCopy淡出
也就是说,CANCEL 原本要淡出的文案是「松手取消」,但进入 DEFAULT 时,DEFAULT 越权把共享的 stateCopy 改成了「松手识别,移开取消」。
于是用户看到的就变成:
松手取消 → 松手识别,移开取消 → 默认态
而不是正确的:
松手取消 → 默认态
根因总结:不是触摸判断错了,而是新状态在旧状态的出场动画期间,提前污染了共享 View 的内容。
5. previousState 和 nextState:为什么有必要
一开始 AI 建议「让 DEFAULT 不改 stateCopy」。这个建议方向对,但还不完整。我继续追问:
previousState 和 nextState 是否有设计的必要?
最终结论是:有必要,但只在 setVoiceState() 内部使用。 我们不需要把整个业务状态机升级成双状态模型------对外仍然只有一个当前状态:
csharp
var voiceState: VoiceState
但在渲染过渡时,组件必须知道:
ini
from = previousState
to = nextState
原因是 UI 有跨状态动画(旧内容淡出 + 新内容淡入),而且多个状态共用一个 View。只要旧内容还在淡出,新状态就不能提前改写它。
6. 最终修复原则与代码
确定的六条修复原则:
- 对外仍然只有一个当前
voiceState previousState只作为setVoiceState()内部的一次性渲染上下文DEFAULT只拥有defaultCopyUNAVAILABLE只拥有unavailableCopyRECORDING/CANCEL/ORGANIZING才拥有stateCopy- 退出动画期间,旧状态内容不能被新状态覆盖
落到代码上:
kotlin
fun setVoiceState(state: VoiceState, animate: Boolean = true) {
val previousState = voiceState
if (previousState == state && animate) return
voiceState = state
applyNextStateVisuals(state)
updateCopyTransition(previousState, state, animate)
}
同时加了两条关键注释:
previousState 只服务本次视觉过渡,组件对外仍只有一个当前语音状态。
DEFAULT 只拥有 defaultCopy;不能改写 stateCopy,否则会污染 CANCEL 等出场态文案。
7. 修复后验收
修复后,取消路径恢复为:
默认态 → 松手识别,移开取消 → 松手取消 → 默认态
不再出现取消松手后闪回录音文案。
同时验证了保留的正确路径------CANCEL → RECORDING(移出后移回)仍然正常工作。这说明修复没有简单粗暴地禁掉路径,而是只修正了 CANCEL → DEFAULT 的出场边界。
8. 方法论提炼:AI 辅助调试的三层追问
这次问题很小,但沟通模式很典型。如果只听第一轮解释,很容易把它当成「文案设置问题」。但我连续追问了三次:
- 「这是不是状态流转问题?」------把讨论从 UI 层面推进到状态机层面
- 「先列出状态流转路径。」------强制梳理全貌,避免过早陷入局部修复
- 「previousState / nextState 是否有必要?」------追问设计必要性,而不是接受「改一行就行」
这三次追问把 AI 从局部修复拉回到了状态机建模本身。
三条方法论:
- 用户看到的可见状态,也是状态机的一部分------不只需要枚举正确,还需要渲染正确
- 共享 View + 跨状态动画,一定要明确旧状态和新状态的边界
- AI 给出自洽解释时,追问「这是表面原因还是根因」比追问「怎么修」更有价值
9. 后续应用
后续接真实录音和 ASR 时,同样需要沿用这个原则:
VoiceInputButtonView只负责触摸和可见状态VoiceInputController负责权限、录音、失败消化和成功结果- 任何跨状态动画都要明确内容归属,避免新状态越权改写旧状态
尤其是后续会出现的路径:
arduino
ORGANIZING → 成功反馈 → DEFAULT
ORGANIZING → 未识别 Dialog → DEFAULT
ORGANIZING → 部分识别 BottomSheet → DEFAULT
这些路径同样需要防止「旧状态出场内容被新状态提前覆盖」------问题不在于路径多复杂,而在于每个路径上是否明确划分了内容归属。
你在项目中有没有遇到过状态枚举值正确、但用户看到的东西不对的情况?你是怎么排查到渲染层的? 欢迎大家来讨论,给我增加点热度也是好的~~~~~~