还在为多音频播放管理烦恼吗?---单例音频处理

48 阅读4分钟

优雅地在 H5 中处理音频:构建一个健壮的 AudioManager

在 H5 开发中,音频播放看似简单(一个 <audio> 标签即可),但在实际项目中,尤其是字典类、语言学习类应用中,我们会面临一系列挑战:内存泄漏、多个音频重叠、页面切后台后的播放行为、以及 UI 状态的实时同步

今天,我将基于 Howler.js 分享一个生产级别的 AudioManager 封装思路。


生成音频管理发愁图片.png

核心痛点

  1. 内存堆积:H5 环境(尤其是移动端)资源有限,频繁点击播放单词发音,如果不及时释放,内存占用会迅速飙升。
  2. 状态混乱:点击 A 单词还没播完又点 B,如何确保只有一个在响?UI 上的播放动画如何跟物理声音完美同步?
  3. 用户体验:用户接个电话或者切到微信回个消息,音频是继续在后台放,还是贴心地暂停并在用户回来时恢复?

解决方案: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。