← 返回文章列表

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);
};

总结

  1. iOS Safari 要求 getUserMediaAudioContext 的创建与恢复发生在用户手势的同步调用栈内;
  2. useEffectsetTimeoutawait 之后的回调都不算手势,会被静默拒绝;
  3. 遇到「权限弹不出来、context 一直 suspended」,先检查初始化时机,别急着查证书和权限策略。