iOS Safari 的麦克风权限陷阱:为什么 getUserMedia 必须写在点击手势里
给孩子做的听写应用里有一块语音识别功能:孩子说「写好了」就报下一个词。桌面 Chrome、安卓浏览器
都跑得很顺,唯独 iPad 上的表现是——权限弹窗压根不出来,控制台里 AudioContext
一直是 suspended,页面提示「无法获取语音权限」。
一开始以为是 HTTPS 证书、iframe 权限策略这类常见原因,逐一排除之后才发现:问题出在
初始化代码写在了 useEffect 里。
iOS 的规矩:手势调用栈里发生的事才算数
iOS Safari 对两样东西有严格限制:getUserMedia 的权限申请,和
AudioContext 的创建与恢复。它们必须发生在用户点击手势的同步调用栈里。
React 里随手写的 useEffect 虽然紧跟渲染执行,但早已脱离了点击事件的调用栈——
在 iOS 看来,这是一次「页面自己想开麦克风」的行为,于是静默拒绝:弹窗不弹,
context 永远 suspended。
桌面浏览器宽松得多,所以这类 bug 有个讨厌的特性:开发机上永远复现不了。
解法:把初始化往前挪到 onClick 里
把语音相关的初始化从 useEffect 挪进按钮的点击回调,一次解决:
async function onStartClick() {
// 必须直接写在点击回调里,不能放进 useEffect / setTimeout
const ctx = newAudioContext(); // 创建
await ctx.resume(); // 恢复,也要在手势内
const listener = createListener(ctx);
await listener.start(ctx); // getUserMedia 在这之后才可靠
}
两点细节值得记下:
-
创建和
resume()都要在手势内。只创建不恢复, 后续decodeAudioData一样卡住;把 resume 挪到异步链后面, 在 iOS 上也可能被判定脱离手势。 -
把
AudioContext作为参数传下去复用, 而不是在各个模块里各自new。多个 context 在 iOS 上不仅浪费, 恢复时机也更难保证。
一个更省心的封装
后来我把「点击时初始化」封装成了约定:recordOnce 这类一次性录音函数内置
newAudioContext() + resume(),调用方只要保证它在点击事件里被调用;
长时间监听则由页面显式传入 context。之后再没出过权限问题。
// 约定大于配置:凡是开麦的入口,第一行就是它
button.onClick = async () => {
const clip = await recordOnce(); // 内部完成手势内的初始化
sendToAsr(clip.blob);
};
总结
- iOS Safari 要求
getUserMedia、AudioContext的创建与恢复发生在用户手势的同步调用栈内; useEffect、setTimeout、await之后的回调都不算手势,会被静默拒绝;- 遇到「权限弹不出来、context 一直 suspended」,先检查初始化时机,别急着查证书和权限策略。