优雅地在 H5 中处理音频:构建一个健壮的 AudioManager
在 H5 开发中,音频播放看似简单(一个 <audio> 标签即可),但在实际项目中,尤其是字典类、语言学习类应用中,我们会面临一系列挑战:内存泄漏、多个音频重叠、页面切后台后的播放行为、以及 UI 状态的实时同步。
今天,我将基于 Howler.js 分享一个生产级别的 AudioManager 封装思路。
核心痛点
- 内存堆积:H5 环境(尤其是移动端)资源有限,频繁点击播放单词发音,如果不及时释放,内存占用会迅速飙升。
- 状态混乱:点击 A 单词还没播完又点 B,如何确保只有一个在响?UI 上的播放动画如何跟物理声音完美同步?
- 用户体验:用户接个电话或者切到微信回个消息,音频是继续在后台放,还是贴心地暂停并在用户回来时恢复?
解决方案:AudioManager 类设计
我们的工具类采用了 单例模式 和 观察者模式。
1. 单例模式:全局唯一控制塔
通过 getInstance() 确保整个应用生命周期内只有一个音频管理器,避免了多个音频实例互相干扰。
2. 彻底的内存清理:unload() 是关键
在 Howler.js 中,stop() 仅仅是停止播放,音频资源依然留在内存中。
public play(url: string): void {
// 如果已有实例,彻底卸载旧音频,释放内存
if (this.sound) {
this.sound.unload(); // 这一步至关重要!
}
// ... 初始化新 Howl 实例
}
每次播放新音频前调用 unload(),能确保旧的解码数据被物理擦除,这在低端安卓机型上是应用不崩溃的保障。
3. 智能可见性处理:意图状态 vs 物理状态
我们引入了 isPlaying 来记录用户的“播放意图”,并配合 Visibility API 处理页面隐藏。
- 页面隐藏时:调用
pause()物理暂停声音,但保持isPlaying = true(意图不变)。 - 页面恢复时:检查
isPlaying,如果为true则自动调用resume()恢复。
private handleVisibilityChange = (): void => {
this.isPageVisible = !document.hidden;
if (this.isPageVisible) {
this.resume();
} else {
if (this.sound && this.sound.playing()) {
this.sound.pause(); // 仅暂停物理声音
}
}
};
如何使用?
在 React 组件中使用(自动同步 UI)
通过订阅模式,我们可以轻松让播放图标与全局音频状态同步。
import React, { useState, useEffect } from 'react';
import audioManager from './utils/audioManager';
const Trumpet = ({ url }) => {
const [isPlaying, setIsPlaying] = useState(false);
useEffect(() => {
const updateState = () => {
// 检查当前管理器播放的是否是本组件的 URL
setIsPlaying(audioManager.isCurrentlyPlaying(url));
};
updateState();
// 订阅状态变化
const removeListener = audioManager.addListener(updateState);
return removeListener; // 卸载时取消订阅
}, [url]);
return (
<div onClick={() => isPlaying ? audioManager.stop() : audioManager.play(url)}>
{isPlaying ? <IconPlaying /> : <IconStatic />}
</div>
);
};
命令式调用
在普通的逻辑脚本中,也可以直接使用:
import audioManager from './utils/audioManager';
// 简单播放
audioManager.play('https://cdn.com/word.mp3');
// 停止并释放资源
audioManager.stop();
4. 完整代码
import { Howl } from 'howler';
class AudioManager {
private static instance: AudioManager;
private sound: Howl | undefined;
private isPlaying: boolean;
private isPageVisible: boolean;
private currentUrl: string | undefined;
private listeners: Set<() => void> = new Set();
/**
* 状态更新辅助方法
*/
private updatePlayingState(isPlaying: boolean, url?: string): void {
this.isPlaying = isPlaying;
if (url !== undefined) {
this.currentUrl = url;
}
this.notifyListeners();
}
private constructor() {
this.isPlaying = false;
this.isPageVisible = !document.hidden;
document.addEventListener('visibilitychange', this.handleVisibilityChange);
}
public static getInstance(): AudioManager {
if (!AudioManager.instance) {
AudioManager.instance = new AudioManager();
}
return AudioManager.instance;
}
/**
* 处理页面可见性变化
*/
private handleVisibilityChange = (): void => {
this.isPageVisible = !document.hidden;
if (this.isPageVisible) {
this.resume();
} else {
// 页面隐藏时暂停物理播放,但不重置 isPlaying 意图状态
if (this.sound && this.sound.playing()) {
this.sound.pause();
}
}
};
/**
* 播放音频
*/
public play(url: string): void {
// 1. 如果已有实例,彻底卸载旧音频,释放内存
if (this.sound) {
this.sound.unload();
}
this.currentUrl = url;
this.sound = new Howl({
src: [url],
html5: true, // 启用HTML5音频,适合流式传输和处理跨域
preload: true,
onplay: () => {
this.updatePlayingState(true, url);
},
onpause: () => {
// 仅在非页面隐藏导致的暂停时更新状态(如果有其他原因导致的暂停)
if (this.isPageVisible) {
this.updatePlayingState(false, url);
}
},
onstop: () => {
this.updatePlayingState(false);
},
onloaderror: (_id, error) => {
// eslint-disable-next-line no-console
console.error('音频加载失败:', error);
this.updatePlayingState(false);
},
onplayerror: (_id, error) => {
// eslint-disable-next-line no-console
console.error('音频播放失败:', error);
this.updatePlayingState(false);
},
onend: () => {
this.updatePlayingState(false);
}
});
this.sound.play();
}
/**
* 停止播放并释放资源
*/
public stop(): void {
if (this.sound) {
this.sound.stop();
this.sound.unload(); // 彻底释放内存
this.sound = undefined;
}
this.updatePlayingState(false);
}
/**
* 暂停播放
*/
public pause(): void {
if (this.sound && this.sound.playing()) {
this.sound.pause();
}
}
/**
* 恢复播放
*/
public resume(): void {
// 只有在用户意图是播放(isPlaying)且页面可见且当前音频未在播放时才恢复
if (this.sound && this.isPlaying && this.isPageVisible && !this.sound.playing()) {
this.sound.play();
}
}
/**
* 销毁实例,解绑事件
*/
public destroy(): void {
this.stop();
this.listeners.clear();
document.removeEventListener('visibilitychange', this.handleVisibilityChange);
}
public getSound(): Howl | undefined {
return this.sound;
}
public getCurrentUrl(): string | undefined {
return this.currentUrl;
}
public isCurrentlyPlaying(url: string): boolean {
return this.isPlaying && this.currentUrl === url;
}
public addListener(callback: () => void): () => void {
this.listeners.add(callback);
return () => {
this.listeners.delete(callback);
};
}
private notifyListeners(): void {
this.listeners.forEach(callback => callback());
}
}
export default AudioManager.getInstance();
完整特性清单
- ✅ HTML5 Audio 模式:通过
html5: true支持大文件流式播放和跨域资源。 - ✅ 健壮的错误处理:捕获加载和播放错误,确保状态机能正确重置。
- ✅ 生命周期自清理:提供
destroy()方法,防止在单页应用中留下残余监听。 - ✅ 防竞态处理:彻底解决了快速连续点击导致的多个音频“叠音”问题。
总结
一个好的 H5 工具类不应仅仅是 API 的搬运工,更应该考虑到 资源分配 和 极端场景。
这个 AudioManager 通过对内存管理的极致要求(unload)和对用户体验的微观考量(Visibility API),将音频播放的可靠性提升到了生产级别。
技术栈: TypeScript, Howler.js
适用场景: H5 字典点读、中长音频播放、移动端 Web App。