fix(audio): fix iOS lifecycle at root cause (defer context creation)#3045
Conversation
把 audio 播放生命周期修复改为根因治本,替换本分支原先基于 onstatechange/ 显式 hide-suspend 的方案。所有问题均在真机 iOS Safari 实测复现并验证修复。 根因(本分支原方案未触及):AudioContext 在任何用户手势之前就被创建—— AudioSource 构造时建 gainNode、AudioLoader 用播放 ctx 解码——这种"冷"上下文 在 iOS 来电中断后无法自愈(state 报 running 但 currentTime 冻结的僵尸态)。 改动: - AudioSource: 不在构造期建 gainNode,改为首次 play 时 _ensureGainNode 懒建; ctx 因此推迟到首次 play(用户手势内)才创建,变"热"上下文,来电后 iOS 自愈 - AudioLoader: 用专用 OfflineAudioContext(1,1,44100)解码,不再用播放 ctx 解码(这是第二个过早创建点);要求 iOS >= 14.5(无前缀 OfflineAudioContext) - AudioManager: 切后台/锁屏回前台时 _recoverPlaybackContext 做 suspend→100ms →resume 唤醒僵尸 ctx(对齐 webkit #263627);bfcache 恢复只派发 pageshow (persisted)不派发 visibilitychange,故 _onPageShow 同走恢复;_recovering 重入守卫防 bfcache 双事件(visibilitychange+pageshow)跑两遍;_suspendedByCaller 让显式 suspend() 不被手势/可见性自动唤醒;play() 在 document.hidden 时直接丢弃 (不启动也不挂起,避免回前台错位重放或后台漏音) 移除原方案的 onstatechange:实测"来电之外的外部中断"在 iOS 上无可触发场景 (放音乐/视频是叠加混播、Siri 不打断),唯一独占中断是来电、已由根因覆盖; 而 onstatechange 驱动的恢复正是冷 ctx 僵尸的来源之一。 测试:重写为 AudioSourcePendingPlayback.test.ts,覆盖延迟创建、懒 gainNode、 hidden 丢弃、pending 重放/取消、autoplay-blocked 丢弃、resume 合并、显式 suspend 不自动恢复、僵尸态恢复(suspend→100ms→resume)、_recovering 重入守卫、 bfcache pageshow(persisted)恢复、suspend reject 不阻断 resume 等 23 个用例。
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. 🗂️ Base branches to auto review (1)
Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
回应 review:覆盖版连带删了全部 hide→suspend,导致桌面 Chrome/Firefox、Android Chrome 切 tab/最小化时音频继续在后台出声——这些平台不会自动 suspend 正在运行的 WebAudio ctx(只有 iOS Safari 会)。纯 WebAudio 真机实测确认后台一直响。 “切后台静音”是引擎行业默认(Unity runInBackground=false、Unreal UnfocusedVolumeMultiplier=0.0、Phaser pauseOnBlur=true、Cocos visibilitychange 硬挂起)。 不加 opt-out 开关:Web 上 iOS Safari 系统强制后台 suspend、开发者无法绕过,给“后台 继续放”的开关在 iOS 上必然失效,故硬编码默认 hide→suspend、全平台对齐 iOS 约束。 - visibilitychange 改挂分派器 _onVisibilityChange:hidden → _context?.suspend() (仅在 ctx 已存在时,不为 suspend 去创建 ctx,不污染 _suspendedByCaller,故回前台 仍正常恢复);visible → _recoverPlaybackContext - 恢复定时器 resume 前加 document.hidden / _suspendedByCaller staleness 检查:回前台 触发 suspend→100ms→resume 的窗口内若又切回后台,不再在隐藏页 resume(对齐 play() 对 document.hidden 的 drop) hide→suspend 与延迟创建 ctx(iOS 来电根因)是正交两件事,分开处理。补 4 个测试。
|
感谢 review,[P1] 确认成立,已修复(commit a8eda78)。两个子问题都处理了: 1. hide→suspend(主问题) — 确实是退化。纯 WebAudio 真机实测确认:桌面 Chrome/Firefox、Android Chrome 切 tab/最小化时不会自动 suspend 正在运行的 AudioContext(只有 iOS Safari 会),所以删掉 hide→suspend 后这些平台后台会继续出声。 修复: 2. 恢复定时器 staleness — 也修了。 关于是否做成可配置:考虑过加 正交性:hide→suspend(切后台静音)和延迟创建 ctx(修 iOS 来电根因)是两件正交的事,已分成独立 commit,没有再耦合。 |
AudioManager.suspend() 是公开 API,原先无条件 getContext().suspend()——用户在任何 播放前调一次就会凭空创建一个手势前的“冷”上下文,正是本 PR 根因要规避的 iOS zombie 来源。加 !_context 守卫:无上下文时直接 resolve(没东西在播,无需 suspend)。
|
两个 [P2] 也处理了: [P2] static suspend(): Promise<void> {
AudioManager._suspendedByCaller = true;
const context = AudioManager._context;
return context ? context.suspend() : Promise.resolve();
}无 context 时直接 resolve(没东西在播,无需 suspend),不创建。补了测试。 [P2]
这 4 处都是针对当前不可达场景的防御性校验,加回会为假想场景增加复杂度(且 |
音频生命周期里多处是平台专属处理(bfcache pageshow、手势 resume、手势兜底都只为 iOS Safari),原注释没点明平台,容易被误当通用逻辑误删。补上平台归属:bfcache / pageshow / _resumeAfterInterruption 三处注释明确标注 iOS Safari。 仅注释。
suspend() 在无 context 时本应是 no-op(没有正在播放的音频可暂停),却仍置了 _suspendedByCaller=true,留下一个“幽灵”标志。之后首次 play()(在用户手势内创建 context、开始播放)后,这个标志会让切后台再回前台的 _recoverPlaybackContext 守卫 短路,导致音频无法恢复。 真机复现:序列「先 suspend()(无 ctx)→ play → 切后台 → 回前台」在 Android Chrome 不恢复(无声),iOS 正常。根因平台分歧:iOS 新建 context 默认 suspended → play 走 resume 分支(resume 清标志);Android 在手势内新建 context 立即 running → play 走 _startPlayback 直接播、跳过 resume → 标志残留 true。 修复落在源头:无 context 时直接 resolve 且不置标志(suspend 一个不存在的播放不该 留状态)。补回归测试覆盖该序列。Android 真机验证回前台恢复出声。
|
问题
其余当前 head 暂未发现新的 blocker。已有 review 里的 hidden suspend 和 no-context 验证:已核对当前 head |
|
问题
非 blocker 建议
|
|
问题
|
回应 review 两条 [P1]: 1. stop() 不再重置已暂停音源的播放偏移。`_pausedTime`/`_playTime` 的重置原先放在 `if (this._isPlaying)` 分支内,而 `play() → pause() → stop()` 后 `_isPlaying` 已为 false,导致重置被跳过;再 `play()` 会从旧暂停点续播而非从 0 开始,违反 stop() 的归零契约。把重置移出分支:stop() 无条件归零(拆 source node 仍只在 有 node 时做)。 2. 循环音频恢复用累计播放时长当 offset,丢失循环相位。`_startPlayback` 的 `startTime` 是累计已播时长,循环播放会无限增长越过 buffer 时长;直接传给 `sourceNode.start(0, offset)` 时——按 spec,越界 offset 被 clamp(不 wrap)—— 会丢相位(10s 循环播 35s 应恢复到 5s,却传 35s)。loop 时对 buffer.duration 取模,把累计时长折回 buffer 内的循环相位。non-loop 不取模(暂停点天然 < 时长)。 补回归测试:play→pause→stop→play 从 0 开始;loop 累计 35s 恢复 start(0, 5)。
|
两条 [P1] 都确认是真 bug、已修复(commit 8f96b26),感谢揪出——这两处是覆盖时把 base 的 1. 2. 循环音频恢复丢失循环相位 — 核实 MDN: 补了回归测试: |
|
复核了当前 HEAD 两条 [P1] 已闭环:
关于上一条里提到的 剩余非 blocker:
结论:当前 head 没有我认为需要阻塞合并的 P1。 |
GuoLei1990
left a comment
There was a problem hiding this comment.
总结
复审新 HEAD 8f96b2627。自上轮 b52f998be 以来唯一功能改动就是 8f96b2627——回应 luzhuang 的两条 [P1]:stop() 从暂停态归零失败、loop 恢复丢循环相位。两处都是当初覆盖 base 时把 54ef72e8d 的 stop()/_initSourceNode 回退成更早简化版丢的逻辑,现已修复,回归测试是有效反向见证。本轮无新问题。
已关闭问题清单(全部闭环)
- [P1] hide→suspend 删光 —
a8eda78a1(_onVisibilityChange分派器)。 - [P1-子] 恢复定时器 resume 前不重查 hidden —
a8eda78a1(line 120 staleness 守卫)。 - [P2]
suspend()为 suspend 建冷 ctx —fd15f7c8c+b52f998be收紧(无 ctx 不置幽灵标志)。 - [P2]
_initSourceNode/_clearSourceNode硬化回退(含 non-loopoffset>=duration→return false) — 当前无 seek/time setter,offset>=duration不可达、_isPlaying ⟺ _sourceNode故 NPE 不可达,与 mainline 一致。luzhuang 本轮也独立判为"没有清晰可达路径,最多防御性 P2",与我结论一致,关闭。 - [P1]
stop()不重置已暂停音源(luzhuang) — 已修8f96b2627(见下核对)。 - [P1] loop 恢复丢 modulo(luzhuang) — 已修
8f96b2627(见下核对)。
增量改动核对(8f96b2627)
两处修复落点都在输入端、归零契约层,非输出端打补丁,正确。逐条 trace:
1. stop() 无条件归零(line 197-205) — _pausedTime/_playTime = -1 移出 if (this._isPlaying),拆 source node 仍留在分支内(无 node 不拆)。路径 play→pause→stop→play:pause() 后 _isPlaying=false、_pausedTime 残留暂停点;旧版 stop() 跳过归零 → 再 play() 的 startTime = _pausedTime - _playTime 续播旧点。修后 stop() 无条件 _pausedTime=-1 → _startPlayback 算出 startTime=0 从头播。符合 stop() 的归零语义(vs pause() 保留偏移)。
2. loop 恢复对 buffer 取模(line 293) — startTime 是累计已播时长(_pausedTime - _playTime),循环播放会无限增长越过 buffer.duration。AudioBufferSourceNode.start(when, offset) 的越界 offset 按 spec 是 clamp 到末尾、不 wrap,所以直接传累计值会丢相位(10s 循环播 35s 应恢复到 5s,旧版传 35→clamp 到 10)。修后 this._loop && buffer.duration > 0 ? startTime % buffer.duration : startTime:loop 折回循环相位(35%10=5),non-loop 不取模(暂停点天然 <时长)。buffer.duration > 0 守住除零。边界 startTime 恰为时长整数倍时 % 得 0 = 从循环起点播,正确。
3. 回归测试是有效反向见证 — 两测试我都心算验过 revert 即 fail:
stop() resets the paused offset:play(t=5)→pause(t=8,_pausedTime=8)→stop→play(t=12),断言time===0。若把归零移回if(_isPlaying)分支,stop()跳过归零 → 二次play的startTime=8-5=3→time=3≠0,FAIL。✓wraps the resume offset into the loop:mock clipduration:10,构造_pausedTime=35,_playTime=0,loop=true,断言start调用参数(0, 5)。去掉%duration→start(0,35),FAIL。✓
注释合规:line 203 单行 // 无尾句号、解释 why(stop 归零含暂停态);line 291-292 双行 // 无尾句号、解释 clamp-not-wrap 的 spec 依据。均合规。
问题
无新问题。
luzhuang 在 06:13:35Z/06:17:18Z 列的三条非 blocker(resume() 缺 hidden guard、_recoverPlaybackContext 100ms 窗口内手势 fallback 抢跑、删除 onstatechange fallback)我独立核过,确认与其判断一致,均不阻塞本轮主修复,已在记录上,不重复展开:
resume()hidden guard:仅 public static 直调时可在隐藏页恢复 ctx;引擎自身play()已有document.hiddendrop。属边界收敛,可后续处理。- 手势 fallback 抢跑:100ms 窗口内点击会经
_resumeAfterInterruption(不查_recovering)直接resume(),绕过 suspend→100ms→resume;但_resumePromise去重 + 失败后_needsUserGestureResume留 true 可重试,自愈。最小修法(_resumeAfterInterruption里if (_recovering) return)合理,留作后续。 onstatechange删除:是本 PR"延迟创建 = 热 ctx 自愈"路线相对 #3026 的有意取舍,我首轮即认同优于 onstatechange。剩余风险(visible 下 ctx 非 running 且无 visibility/pageshow 事件)无测试兜底,可补但不阻塞。
简化建议
无。stop() 的"无条件归零 + 仅有 node 时拆 source"与 loop 的 loop && duration>0 ? %duration : raw 三元已是最简正确形态。
|
再从实现形态看了一遍:大方向我认可,延迟创建播放 我觉得当前还有两处值得顺手收掉的小绕路,都是最小改动:
|
回应 review:_recoverPlaybackContext 的 100ms 定时器原先裸调 context.resume(),不走 AudioManager.resume()=不占用 _resumePromise;恢复窗口内手势穿过守卫后再走 AudioManager.resume() 时,promise 仍是 null,于是又一次 context.resume()。真机 iOS 恢复过程中狂点屏幕可复现 2 次 context.resume()(节奏取决于 iOS interrupted→running 落地耗时,经验 300~400ms)。 修复: - 定时器改走 AudioManager.resume(),让 _resumePromise 在恢复期占住合并锁;后续手势 再调 AudioManager.resume() 时 ??= 短路、复用同一 promise,整窗口只一次 context.resume()。 - _resumeAfterInterruption 加 _recovering 守卫:suspend→100ms→resume 的前 100ms 窗口里手势直接 return,不在 suspend 未落地时抢跑(守注释强调的 empirical delay)。 补回归测试: - 恢复中(_recovering=true)手势不触发 resume - 恢复后(_recovering=false)resume 未 settle 的 click 风暴被 _resumePromise 合并为 单次 context.resume()
|
建议 2( 实测发现仅加 真根因:定时器走的是裸 修法(最小、第一性):
补了回归测试:恢复中( 建议 1( |
|
小澄清(以免我上一条表述误导):严格说 B 单独已足以消除 "2 次 resume"( A(
所以分工:B 修"次数"、A 修"时机/too-soon"。两者正交、各管一件,合起来才让"100ms empirical delay"这条注释真正自洽。 (node trace 两个版本都验过、单测 37 全过、iOS 真机狂点验证从 2 次降为 1 次。) |
合并/精简 _recoverPlaybackContext 与 _resumeAfterInterruption 周边的注释:把"100ms 经验值 + _recovering 为何挂 timer 而非 promise"两条合并到一处;把"走 AudioManager. resume() 让 _resumePromise 合并"压成两行;删除单纯描述 WHAT 的 hidden/suspendedByCaller 分支注释(代码自身已直白)。核心 WHY 保留。仅注释。
GuoLei1990
left a comment
There was a problem hiding this comment.
总结
复审 HEAD d5d475a94(自上轮 8f96b2627 起 2 个 commit:597a89f9e 修复"恢复窗口内重复 resume"、d5d475a94 收紧注释)。这是我自己的 PR,故 --comment。
597a89f9e 是对 recovery-window 竞态的正解,逐路径核对无问题:
_recoverPlaybackContext的 100ms timer 从裸context.resume()改为走AudioManager.resume(),借_resumePromise ??=(:37)把"timer 自动恢复"与"用户手势恢复"合流去重——慢速 iOS interrupted→running 期间手势风暴不再触发多次context.resume()。_resumeAfterInterruption(:133)新增_recovering早退守卫:100ms 窗口内手势不再绕过延迟去 resume 一个仍 interrupted 的 context;窗口结束后_recovering=false,手势与 timer 经_resumePromise合一。- 无卡死路径:timer 先置
_recovering=false再判document.hidden || _suspendedByCaller决定是否 resume,后续前台_recoverPlaybackContext可正常重入。
配套两条回归测试精准锁定新行为(ignores a gesture while recovery is in flight... 守 _recovering 门、coalesces a click-storm... 守 _resumePromise 去重),revert 生产改动即红。
问题
[P3] AudioManager.ts:113-114 — d5d475a94 收紧后的注释句子被截断,丢了"为什么"
// 100ms empirical delay (resume too soon after suspend is unreliable on iOS); _recovering is cleared
// on the timer rather than off a promise because iOS may never settle suspend/resume in interrupted第二句结尾 ...in interrupted 是断的——interrupted 后面缺名词(state),且把原注释的后果从句"which would leave _recovering stuck true and block all later recovery"删掉了。原注释的价值正是这后半句(解释 不这么做会怎样),现在读者只看到"因为 iOS 可能 never settle"却不知道 never settle 会导致什么。建议补全为:...because iOS may never settle suspend/resume on an interrupted context, which would leave _recovering stuck true and block all later recovery.
简化建议
无。本轮是收口竞态 + 收紧注释,生产逻辑已干净。
注释合规:suspend() / resume() JSDoc 合规(多行块、@returns、@remarks)。唯一问题是上面 P3 的截断注释(属"注释承诺/解释不完整")。其余 // 注释无句号、解释 为什么、附 WebKit bug 链接,合规。
CI 仅 labeler 跑(本 PR base 是 fix/audio-shaderlab-split 非 dev/2.0,全量 matrix 未触发),逻辑层我已逐路径核对。不阻塞合并。
无前缀 OfflineAudioContext 自 iOS 14.1 才有,老 iOS 上 AudioLoader. _getDecodeContext() 会直接 ReferenceError、所有音频加载即崩。给 Polyfill 加 _registerOfflineAudioContext():在无 OfflineAudioContext 但有 webkitOfflineAudioContext 时做别名,并把其 callback 形式的 decodeAudioData 包成 Promise(对齐既有 _registerAudioContext 的处理)。 按 MDN browser-compat-data:webkitOfflineAudioContext 自 iOS 7。但 PR 链路还依赖 AudioContext.suspend()/resume() 返回 Promise(iOS 9+),故功能可用下限从 iOS 14.1 拓到 iOS 9。
|
顺手拓宽兼容性范围(commit 6119703): 之前要求 iOS ≥ 14.5(其实精确应为 14.1+,已校正),老 iOS 上 数据来源 MDN browser-compat-data:
所以功能可用下限从 iOS 14.1 拓到 iOS 9(suspend/resume Promise 形式是本 PR 链路硬依赖,iOS 9 是合成下限)。iOS 7-8 polyfill 也能让加载不崩,但因 promise 形式 API 缺失,整体链路仍不可用。 PR body 已更新(28 行 / 72 行)。 |
|
采纳(commit 80cc39d)。抽成共享 helper 参数类型用 |
背景
本 PR 提交到 #3026(
fix/audio-shaderlab-split),用根因治本的方案替换本分支当前基于onstatechange/ 显式 hide-suspend 的 audio 生命周期修复。所有问题都在真机 iOS / Android上逐个复现并验证修复——每条修复都对应一个实测能复现的问题,不是推测。
根因(当前分支方案未触及)
iOS audio 各种"中断后起不来"的表现,根子是 AudioContext 在任何用户手势之前就被创建了:
AudioSource构造时就createGain()→ 拉起 AudioContext;AudioLoader用播放用的 AudioContext 解码 → 加载期就拉起 AudioContext。这种在手势前创建的"冷"上下文,在 iOS 中断后无法自愈——表现为
state === "running"但currentTime冻结的僵尸态(参考 WebKit 同区域 bug,详见下文)。而在用户手势内创建的"热"上下文,中断后 iOS 会自己恢复——这一点连前台中断(小窗来电横幅 / Siri / 闹钟,页面仍可见、ctx 走running → interrupted)也成立:中断结束后热 ctx 由 iOS 自行interrupted → running复位,无需任何监听器介入。改动
AudioSource— 延迟创建上下文(根因修复)play()时_ensureGainNode()懒建。play()(在用户手势回调内)才创建 → "热"上下文 → 中断后 iOS 自愈。play()在document.hidden时直接丢弃(不启动、也不挂起 pending):后台启动会漏出声音,pending 到回前台又会错位重放——SFX 宁可丢也不能对不上。AudioLoader— 用 OfflineAudioContext 解码OfflineAudioContext(1, 1, 44100),不再用播放 ctx 解码(这是第二个过早创建点)。OfflineAudioContext自 iOS 14.1+;老 iOS 由 Polyfill 接管:在缺无前缀 API 时为webkitOfflineAudioContext(自 iOS 7)做别名 + 把其 callback 形式的decodeAudioData包成 Promise(对齐既有_registerAudioContext的处理)。AudioManager— 切后台静音、恢复与守卫visibilitychange挂分派器_onVisibilityChange——hidden时_context?.suspend(),visible时_recoverPlaybackContext。_context?.suspend():仅在 ctx 已存在时挂起,绝不为了 suspend 去创建一个手势前的冷 ctx(那正是本 PR 要规避的 zombie 来源);也不污染_suspendedByCaller,故回前台仍正常恢复。runInBackground=false、UnrealUnfocusedVolumeMultiplier=0、PhaserpauseOnBlur=true、Cocosvisibilitychange硬挂起),故硬编码默认 hide→suspend。_recoverPlaybackContext:切后台/锁屏回前台且 ctx 非 running 时,做suspend → 100ms → resume唤醒僵尸 ctx(iOS 的interrupted态不能直接 resume,必须先 suspend 转 suspended)。suspend → 100ms → resume的窗口内,若页面又被切回后台(document.hidden)或期间发生过显式suspend()(_suspendedByCaller),则不在隐藏页/已主动暂停态上 resume(对齐play()对document.hidden的 drop 语义)。pageshow(persisted=true)、不派发visibilitychange,故_onPageShow在 persisted 时同走恢复。(隔离实测:停用 visibilitychange、仅 pageshow 命中仍能恢复循环音。)_recovering重入守卫:bfcache 恢复会同时派发 visibilitychange 和 pageshow,无守卫会让 suspend→resume 跑两遍(浪费 + 第二个 resume 打在未落地的 suspend 上)。守卫在定时器里无条件清除,不依赖 suspend/resume promise settle(iOS 在 interrupted 态下该 promise 可能永不 settle,会卡死恢复)。_suspendedByCaller:用户显式suspend()不被手势/可见性自动唤醒(公开 API 语义:主动暂停不该被自动恢复)。suspend()无 ctx 时是纯 no-op(公开 API 硬化,两处真机 bug 修复):getContext().suspend(),用户在任何播放前调一次suspend()就会凭空创建一个手势前冷 ctx——正是本 PR 要规避的 zombie 来源。加!_context守卫,无 ctx 直接 resolve。_suspendedByCaller = true会留下一个"幽灵"标志——首次play()(手势内建 ctx 并播放)后,它会让回前台的_recoverPlaybackContext守卫短路、阻断恢复。这是 Android 专属症状:iOS 新建 ctx 默认 suspended →play()走 resume 分支(resume 清标志);Android 手势内新建 ctx 立即 running →play()走_startPlayback直播、跳过 resume → 标志残留。修复:suspend()无 ctx 时不触碰_suspendedByCaller(状态标志只应在它镜像的动作真实发生过时为真)。平台归属注释
音频生命周期里多处是平台专属处理(bfcache
pageshow、手势 resume、_resumeAfterInterruption都只为 iOS Safari)。注释明确标注 "iOS Safari",避免后人误当通用逻辑删掉。移除
onstatechange实测确认 onstatechange 驱动的恢复无可挽回的场景,且它本身正是冷 ctx 僵尸态的来源之一:
interrupted → running),无需监听器;故删除不漏任何可复现场景。
测试
重写为
AudioSourcePendingPlayback.test.ts(mock AudioContext,无需真引擎,browser-mode vitest 全绿)覆盖:延迟创建 / 懒 gainNode / pre-play volume 首播生效;play 守卫(无 clip / 已播 / 已 pending no-op,running 立即播);hidden 丢弃(不 pend、不 suspend,丢弃后回前台不重放);pending 重放与取消、autoplay-blocked 丢弃;resume 解锁与重叠合并;hide→suspend 与 staleness(隐藏页/主动暂停不 resume);显式 suspend 不自动唤醒、suspend 无 ctx 不创建/不置标志;僵尸态suspend → 100ms → resume、suspend reject 不阻断 scheduled resume;bfcache pageshow(persisted)恢复与_recovering重入守卫;记账(_playingCount/ 偏移)。真机验证矩阵:
running → suspended,后台静音生效suspend()无 ctx → play → 切后台 → 回前台)running → interrupted,中断结束 iOS 自行interrupted → running,无需 onstatechange另:来电独占中断自愈、bfcache 恢复(隔离验证 pageshow 独立有效)、后台 play 漏音丢弃、
_recovering连续切后台多次每次恢复,均已真机验证(见下方复现/修复对比页面)。兼容性
AudioContext.suspend()/resume()返回 Promise 自 iOS 9 起;OfflineAudioContext自 iOS 14.1+,老 iOS 经 Polyfill 接到webkitOfflineAudioContext(自 iOS 7)。document.hidden/visibilitychange/pageshow均为已广泛支持的标准 API。真机复现 / 修复对比(iOS Safari)
每个问题都提供「复现版」与「修复版」两个可直接在 iOS Safari 打开的页面(均打包真实引擎):
后续修复的真机验证(单页 + 操作,均用本 PR 最终引擎)
以下三处问题(hide→suspend、幽灵标志、前台中断自愈)的验证页用最终引擎打包,靠操作 + 页面实时状态判定(不是修复前/后两版对照):
running → suspended、后台静音suspend()无 ctx → play → 切后台 → 回前台)_suspendedByCaller应为 false → 切后台 → 回前台恢复出声(修复前 Android 残留 true、不恢复)running → interrupted(页面仍 visible)→ 挂断后什么都不点,ctx 自行interrupted → running、BGM 自响(热 ctx 自愈,无需 onstatechange)与原方案 #3026 的逐项对比
作者 #3026 在出口处打补丁:用
onstatechange+ 动态挂/摘手势监听 +_hidden镜像 +_foregroundResumeTimer一整套状态机去追逐已损坏的 AudioContext 状态,但没碰根因——AudioSource构造函数和AudioLoader解码都在任何用户手势之前就创建了播放 ctx,iOS 上这种 pre-gesture 冷 ctx 中断后会永久停在静音 zombie 态,再多恢复逻辑也救不回。本 PR 从输入端修根因,并覆盖作者全部场景:AudioSource构造即getContext().createGain()→ ctx 在手势前冷创建,iOS 中断后永久 zombieplay()时_ensureGainNode()懒建,构造不碰 ctx → ctx 必在手势内热创建AudioLoader复用播放 ctx 解码 → 加载期就把播放 ctx 拉起,同样踩 zombieOfflineAudioContext(1,1,44100)解码,完全不触播放 ctx_onShown→suspend → 100ms → resume,_foregroundResumeTimer守 staleness_recoverPlaybackContext→ 同样suspend → 100ms → resume,_recovering守重入 + 隐藏页/主动暂停 staleness 检查_onVisibilityChangehidden 分支_context?.suspend()(仅在 ctx 已存在时,不创建、不污染_suspendedByCaller);Android 真机验证后台静音_onShown第一行if (!_hidden) return;bfcache 只发 pageshow 不发 visibilitychange,从未置_hidden→ 恢复不了_onPageShow显式判event.persisted走恢复,_recovering防双触发_canStartPlayback在 hidden 时先_onHidden再 return → 走 pending,回前台异步补播play()首行if (document.hidden) return直接丢弃(不漏音、不乱序补播)_suspendedByCaller,散布 5 处判_suspendedByCaller,集中收口;且suspend()无 ctx 时纯 no-op、不置幽灵标志(修 Android 回前台不恢复的真机 bug)context.onstatechange检测翻转挂手势监听interrupted → running自愈,iOS 真机验证;来电之外的纯外部中断在 iOS 无可触发场景_hidden布尔镜像,多处读写易与document.hidden漂移document.hidden(单一事实源)移除的作者机制(已逐一核对作者代码确实存在,本 PR 全部移除且无功能回退):
_onContextStateChange(onstatechange) /_canStartPlayback/_hidden/_foregroundResumeTimer/_addGestureListeners/_removeGestureListeners/_onHidden/_onShown。根因修复后这些追逐损坏状态的机制不再需要,常驻可见性监听 + 直接读document.hidden即等价覆盖。净效果:覆盖作者全部场景(无一遗漏),修了他没触及的 iOS zombie 根因,删掉一整套易漂移的可见性状态机;同时补回全平台 hide→suspend 静音、硬化公开
suspend()、修复 Android 幽灵标志 bug。净 diff+539 / -391(含重写的测试)。