开发 Web App 一直是个蛮尴尬的事情。一方面,Google 不断在推;PWA 等技术也越来越好;另一方面,当我们真的像用 Web 开发 App 的时候,总会在不同地方踩到各种坑,猝不及防。尤其是 iOS Safari,各种 bug 层出不穷,坑死个人。今天分享一下在 iOS Safari 播放音频的一些技巧,希望节省大家将来的时间。
基础:使用 new Audio()
创建播放器
播放声音看起来并不难,在桌面上实现也比较简单,直接 const audio = new Audio(音频地址); audio.play()
就好了。只要你的音频文件可以正常访问,那你应该很快就能听到声音。
这个过程实际上创建了一个 HTMLAudioElement
的实例。所以,你也可以在页面上直接插入一个 <audio>
,然后通过某种方式拿到它的引用,再使用它播放音频。
除了初始化的时候传入音频地址,在任何时候改变音频地址,然后再 .play()
都可以,很简单。
在用户点击时提前创建播放器
但是在 iOS Safari 上,就有问题。因为手机端的限制更严格,只有用户主动操作(点击、敲击)时,新建的 <audio>
才能正常播放。如果你在其它时间创建 <audio>
并播放,一切看起来都是正常的,没有任何报错,网络也会正常产生请求,但就是不会发出声音。
解决方案就是在用户产生点击、敲击的时候,就把 <audio>
创建好,然后在需要播放的时候让它播放声音。
双播放器切换实现连续播放
有时候我们需要循环连续播放一首歌,比如游戏背景音乐,或者打坐的时候播一些背景音。这个时候我们也有两个选择:
- 使用
<audio>
。- 好处:流式播放,速度会更快;有
loop
属性,更方便。 - 坏处:如果是长音频,由于流式播放的原因,用过的数据可能被随时丢弃,所以新的一遍开始时可能会卡 0.N 秒,无法避免
- 好处:流式播放,速度会更快;有
- 使用 Web Audio API 等更高级的 API
- 好处:音频在内存里,想怎么播怎么播,还可以加入各种效果
- 坏处:第一次播需要完成加载,或者手动实现音频流,很麻烦
我建议用第二个方法,使用起来比较简单。只要在开始的时候创建两个 <aduio>
对象,然后在第一个播放结束前 250ms 启动第二个即可。至于为什么是 250ms,因为按照规范,onTimeUpdate
的触发周期是最长不超过 250ms,所以这样可以比较好的切换播放器。
以下是我在 Nuxt3 里使用的 composable,供大家参考:
import type { AudioOptions } from '~/types';
export function useAudio({
src,
loop,
loopOverlap,
autoplay,
}: AudioOptions = {}) {
const audio = shallowRef<HTMLAudioElement>();
const backup = shallowRef<HTMLAudioElement>();
const isStarting = ref<boolean>(false);
const isPlaying = ref<boolean>(false);
const playhead = ref<number>(0);
const duration = ref<number>(0);
async function playAudio(src: string, overlap: number = 0.25): Promise<void> {
if (!audio.value) {
initAudio(src);
}
if (audio.value) {
audio.value.src = src;
if (loop && backup.value) {
backup.value.src = src;
}
}
if (overlap !== undefined) {
loopOverlap = overlap;
}
isStarting.value = true;
await audio.value?.play();
}
function pauseAudio(): void {
audio.value?.pause();
backup.value?.pause();
}
function initAudio(src: string): void {
audio.value = new Audio(src);
if (src) {
audio.value.preload = 'auto';
}
audio.value.autoplay = autoplay || false;
if (loop) {
backup.value = new Audio(src);
backup.value.preload = 'auto';
backup.value.autoplay = false;
backup.value.addEventListener('ended', onEnded);
backup.value.addEventListener('timeupdate', onTimeUpdate);
backup.value.addEventListener('play', onPlay);
backup.value.addEventListener('playing', onPlaying);
backup.value.addEventListener('pause', onPause);
backup.value.addEventListener('loadedmetadata', onMetadata);
}
audio.value.addEventListener('timeupdate', onTimeUpdate);
audio.value.addEventListener('loadedmetadata', onMetadata);
audio.value.addEventListener('ended', onEnded);
audio.value.addEventListener('pause', onPause);
audio.value.addEventListener('play', onPlay);
audio.value.addEventListener('playing', onPlaying);
}
function destroy(): void {
if (audio.value) {
audio.value.removeEventListener('timeupdate', onTimeUpdate);
audio.value.removeEventListener('loadedmetadata', onMetadata);
audio.value.removeEventListener('ended', onEnded);
audio.value.removeEventListener('pause', onPause);
audio.value.removeEventListener('play', onPlay);
audio.value.removeEventListener('playing', onPlaying);
audio.value = undefined;
}
if (!backup.value) return;
backup.value.removeEventListener('ended', onEnded);
backup.value.removeEventListener('loadedmetadata', onMetadata);
backup.value.removeEventListener('timeupdate', onTimeUpdate);
backup.value.removeEventListener('play', onPlay);
backup.value.removeEventListener('playing', onPlaying);
backup.value.removeEventListener('pause', onPause);
backup.value = undefined;
}
function onPlay(): void {
isPlaying.value = true;
}
function onPlaying(): void {
isStarting.value = false;
}
function onPause(): void {
isPlaying.value = false;
}
function onEnded(event: Event): void {
if (loop) {
(event.target as HTMLAudioElement).currentTime = 0;
} else {
isPlaying.value = false;
}
}
function onTimeUpdate(): void {
playhead.value = audio.value?.currentTime || 0;
if (!loop) return;
if (!playhead.value || !duration.value || playhead.value < duration.value - loopOverlap) return;
backup.value?.play();
// swap audio and backup
const temp = audio.value;
audio.value = backup.value;
backup.value = temp;
}
function onMetadata(event: Event): void {
duration.value = (event.target as HTMLAudioElement).duration || 0;
}
if (src) {
initAudio(src);
}
return {
audio,
isStarting,
isPlaying,
playhead,
playAudio,
pauseAudio,
destroy,
};
}
使用 macOS Safari 调试 iOS Safari
Safari 烂归烂,但是有一点:iOS Safari 的 bug 大多可以在 macOS 上复现。所以大多数时候,我们可以直接在 macOS 完成调试。但是有时候,我们必须在 iOS 上进行调试,比如涉及到输入框影响屏幕高度、或者上面提到的播放问题。
于是,使用 macOS Safari 调试 iOS Safari 也是必备技能,需要的同学请看我的这个视频:
总结
我有时候会怀疑,苹果是不是故意劣化 Safari,以避免被 Web App 抢走原生 App 的市场份额,不然 iOS Safari 的 bug 不应该这么多。
如果大家对 Web App 开发感兴趣,有相关的问题,欢迎留言讨论。
欢迎吐槽,共同进步