我们正在给 SharkTTY 的浮动键盘加一个按住说话的麦克风:按住即听写,实时预览识别结果, 松手停止,点击候选才插入终端。这个交互和对讲机一样古老,但它的 SwiftUI 实现里藏着一个陷阱—— 它躲过了我们的全部测试,差点让一个「根本不可能工作」的功能带着正常的外表发布出去: 录音每次都会在开始后三分之一秒自己停掉。这篇就是我们当初希望能搜到的那篇文章。
最顺手的 API,恰恰是错的
SwiftUI 看起来提供了按住录音需要的一切:
.onLongPressGesture(minimumDuration: 0.35, maximumDistance: 30) { pressing in
if !pressing { stopRecording() } // ← 在 0.35 秒触发,而不是手指抬起时
} perform: {
startRecording()
}
按大多数人的直觉去读——「pressing
告诉我手指是否按着,perform
在长按开始时触发」——完美。在 perform
里开始录音,pressing 变 false 时停止。
但方法签名没告诉你的是:LongPressGesture
在 minimumDuration 一到就判定成功——
而判定成功的手势,就是已经结束的手势。pressing
回调背后是 gesture state,手势一结束 gesture state 就复位。所以
pressing(false) 会在约 0.35 秒时送达,
此刻手指还牢牢按在屏幕上。你的停止逻辑紧跟着开始逻辑执行;而真正的手指抬起,
无论何时发生,已经没有任何人在听了。
在我们这里,破坏还格外安静:被掐断的录音只有约 0.35 秒,而我们「丢弃误触」的守卫会吞掉 300 毫秒以下的音频,词条静静恢复原状。没有报错、没有崩溃、没有半个结果—— 一个从未工作过的功能,披着一切正常的外衣。
为什么测试抓不到它
所有能测的都是绿的:录音会话的状态机有单测,音频管线正常,构建通过。缺陷完全住在
手势识别器的运行时语义里——哪个回调在哪个物理瞬间触发——而这恰好是单元测试
够不着、模拟器又懒得验证的一层。最后是代码审查抓住的,靠的是对每个回调问同一个问题:
「这段代码到底在哪个物理瞬间运行?」对
pressing 来说,
诚实的答案是:「不是你以为的那个瞬间。」
修法:在长按之后接一个拖动
标准模式——据我们所知也是唯一可靠的模式——是在
LongPressGesture 之后
顺接(sequence)一个零距离的 DragGesture。
长按判定成功后,组合手势交棒给拖动阶段,而拖动会让手势一直活到手指真正抬起:
.onTapGesture { switchToVoiceBoard() } // 快速点按 = 另一个动作
.gesture(
LongPressGesture(minimumDuration: 0.35, maximumDistance: 30)
.sequenced(before: DragGesture(minimumDistance: 0, coordinateSpace: .local))
.onChanged { value in
// 拖动阶段会反复触发;确保只启动一次。
if case .second(true, _) = value, !isRecording { startRecording() }
}
.onEnded { value in
// 只有长按成功后才会到达这里——这才是真正的手指抬起。
if case .second = value { stopRecording() }
}
)
这套组合成立靠三个性质。按住阶段无歧义:
.second(true, _) 的含义是
「长按已成功且手指仍按着」。松手是真的:此时跑的是拖动,所以组合手势的
onEnded 在手指抬起时触发。
点按也干净:快速点按会让长按判定失败,序列永远到不了
.second,由独立的
onTapGesture 接管。
两个实践提醒。其一,按住期间手指的每次移动都会触发
onChanged,启动必须用自己的
标志位守住,保证恰好执行一次。其二,系统可能在不触发
onEnded 的情况下取消手势
(来电、按住时切后台),所以再挂一个 scenePhase
观察者,应用离开前台时直接取消录音。系好安全带,再加一根背带。
给下一个人留句警告
坏版本比对版本更短、更好读——这是最危险的一类 bug:未来某次重构看到这个
sequenced 组合,心想「这不就是一行
onLongPressGesture 的事」,
然后忠实地把缺陷复原回来。所以我们代码上方的注释不描述这段代码做了什么,
而是用一句话写明为什么更简单的写法是错的:LongPressGesture
在 minimumDuration 到达时
成功并结束,此刻手指还按着,所以它的 pressing 回调不可能探测到松手。
如果一段代码的形状是被某个陷阱塑造的,就把陷阱写在代码旁边。
按住说话的键盘将随 SharkTTY 的下一次 iPhone / iPad 更新发布。你也踩过手势的坑, 或者发现这篇里的漏洞?反馈板随时开着。