ByteNoteByteNote
用 Pinia 管理 UniApp 多音频实例的播放状态
字

字节笔记本

2026年10月7日 · 约 12 分钟读完

用 Pinia 管理 UniApp 多音频实例的播放状态

API中转
¥120

在一个内容列表里给每个条目配一段音频——朗读、发音、语音讲解——是很多 App 的标配交互。这类场景有个绕不开的特点:播放器不是"一个",而是"一页"。每个条目都有自己的播放按钮和进度条,状态却必须全局一致:同一时刻最好只有一个音频在响,翻页回来还要记得上一个播到哪了。

把这套逻辑写死在单个组件里,很快就会处处碰壁。在一个 UniApp 项目里就遇到过很典型的一环:列表项播放器组件已经能播放、暂停、拖进度条,但音频自然播完之后,按钮还停在"暂停"图标上,进度条也不归零——组件根本不知道"播放结束了"这件事。顺着这个问题,正好可以把多音频实例的状态管理方案完整梳理一遍。

为什么音频状态不该放在组件里

最直觉的写法是在每个列表项组件里直接创建 InnerAudioContext,播放器实例、播放状态、进度全做成组件内部状态。这种写法在小 demo 里没问题,放到列表场景会暴露三个问题。

一是状态孤岛。某个条目正在播放时,其他条目的按钮无从感知,"点开新的、暂停旧的"这种单实例播放策略根本没法实现——组件之间看不见彼此的状态。二是组件复用错乱。列表滚动时组件会被销毁或复用,实例和状态跟着丢失、错位,轻则图标闪一下,重则两个音频同时出声。三是生命周期失控。每个组件各持一个播放器实例,谁负责 destroy?页面卸载后散落在几十个组件里的实例很容易泄漏。

解法是把"音频"这件事整体上移:组件只做展示和交互,播放器实例与全部状态收进一个 Pinia Store,组件通过 audioId 与 Store 通信。Store 是唯一数据源,所有条目看到的都是同一份状态。

UniApp 多音频实例的全局状态管理架构

Store 的骨架:以 audioId 为键的实例表

Store 的核心是一张以 audioId 为键的实例表,条目创建时把自己的 audioId 和音频地址传进来:

typescript
export const useAudioStore = defineStore('audio', {
  state: () => ({
    audioContexts: {} as Record<string, any>,
  }),
  actions: {
    createAudioContext(audioId: string, src: string) {
      if (!this.audioContexts[audioId]) {
        const audioContext = uni.createInnerAudioContext();
        audioContext.src = src;
        audioContext.onEnded(() => {
          this.resetAudioState(audioId);
        });
        this.audioContexts[audioId] = {
          context: audioContext,
          isPlaying: false,
          duration: 0,
          currentTime: 0,
          sliderChanging: false,
        };
      }
    },
    resetAudioState(audioId: string) {
      if (this.audioContexts[audioId]) {
        this.audioContexts[audioId].isPlaying = false;
        this.audioContexts[audioId].currentTime = 0;
      }
    },
  },
});

两个设计点值得展开。

惰性创建、按需复用:createAudioContext 先查表,audioId 已存在就直接复用,只有第一次点击才真正创建实例。快速切换、反复点击不会堆积播放器;如果要做"同时只播一个",只需在 playAudio 里遍历这张表,把其他 isPlaying 的实例先暂停,一行策略全局生效。

audioId 的粒度也值得想清楚:它通常是内容条目的唯一标识,而不是组件实例的 id。好处是同一个音频在列表页、详情页同时出现时,两边共享的是同一个播放器和同一份进度,在哪儿点都能接着播。

表项里的五个字段各司其职:context 是真正的播放器实例,isPlaying 驱动按钮图标与高亮,duration 和 currentTime 驱动进度条,sliderChanging 是一个容易被忽略但很关键的标记,下一节会讲到。

进度条拖拽:changing 与 change 要分开

uni-app 的 slider 组件在拖拽场景下提供两个事件:changing 在拖动过程中持续触发,change 在松手时触发一次。混用或只用 change,体验都会打折。正确分工是:

typescript
// 拖动中:只刷新 UI,不真正 seek
const onSliderChanging = (event: { detail: { value: number } }) => {
  audioStore.setSliderChanging(props.audioId, true);
  const currentTime = (event.detail.value / 100) *
    audioStore.audioContexts[props.audioId]?.duration;
  if (currentTime) {
    audioStore.audioContexts[props.audioId].currentTime = currentTime;
  }
};

// 松手:真正跳转,解除拖拽标记
const onSliderChange = (event: { detail: { value: number } }) => {
  audioStore.seekAudio(props.audioId, event.detail.value);
  audioStore.setSliderChanging(props.audioId, false);
};

拖动过程中如果每次 changing 都调 seek,播放器会被高频跳转打断,产生卡顿甚至杂音。所以拖动中只更新 currentTime 让进度条跟手,并把 sliderChanging 置真,让播放进度的时间同步逻辑在此期间让路;松手后才执行一次 seekAudio,再把标记复位。

这段代码里还埋着两个典型的坑。其一是除零得 NaN:进度条的值按 currentTime 除以 duration 再乘一百计算,而 duration 初始值是 0,音频元数据加载完成之前进度条会拿到 NaN,稳妥做法是在计算属性里兜底,duration 为 0 时进度恒为 0。其二是 if (currentTime) 的 falsy 陷阱:这句本意是"算出有效时间才赋值",但 currentTime 为 0 时同样是 falsy——用户把进度条拖回开头,赋值被跳过,UI 停在旧位置。判断应写成 currentTime != null,或者干脆不做条件判断。

与此配套的还有反方向的同步:播放进行中,InnerAudioContext 会通过 onTimeUpdate 持续上报当前时间,要把它写回 Store,进度条才能自动前进;写入前先看 sliderChanging 标记,拖拽期间让路,松手后恢复。两个方向各走各的事件,进度条才又跟手又不断流。

播放完成:onEnded 收口在 Store

回到最初的问题:音频自然播完时,谁来把状态复位?

InnerAudioContext 提供了 onEnded 回调,但关键不在"调用它",而在"在哪里调用它"。如果在组件里注册,状态重置逻辑就又被关回单个组件;一旦播完的瞬间组件恰好被滚动回收,重置就丢了。正确的位置是 Store 层:在 createAudioContext 创建实例的那一刻就注册好 onEnded,回调里调用 Store 自己的 resetAudioState,把 isPlaying 置 false、currentTime 归零,也就是前面示例代码里的写法。

UniApp 音频播放事件流与状态重置

这样处理后,组件层只剩一件事:把 Store 的状态同步到本地 UI。Vue 3 的做法是 watch:

typescript
watch(
  () => audioStore.audioContexts[props.audioId]?.isPlaying,
  (newValue) => { isPlaying.value = newValue; }
);

按钮图标、背景高亮都跟着这个本地 ref 走,播放结束的瞬间整个条目自动回到初始态,组件不参与任何播放逻辑。

有一个容易误解的点值得强调:onEnded 只在音频自然播放结束时触发,手动调用 pause、stop,或组件销毁时执行 destroy,都不会走到这个回调。如果产品要求"手动停止也要复位",需要在对应 action 里显式调用 resetAudioState。把两者混为一谈,是音频播放器最常见的 bug 来源之一。

平台差异与资源回收

uni.createInnerAudioContext 是跨平台 API,同一份代码跑在 H5、各家小程序和 App 上,行为并不完全一致,几条经验值得提前记下。

duration 的时机:多数平台要等元数据加载完成(onCanplay 之后)才能拿到时长,播放开始前读 duration 很可能是 0。自动播放限制:H5 端浏览器普遍要求用户手势之后才允许出声,不要指望页面加载完就自动播放;小程序端对同时存在的音频实例数量也有约束,不该留的实例要及时销毁。iOS 静音键:InnerAudioContext 默认遵循 iOS 静音键开关(obeyMuteSwitch 属性),调试时手机静音导致"没有声音"的问题多半源于此。后台播放与锁屏控制:InnerAudioContext 做不到,需要换用 uni.getBackgroundAudioManager,那是另一套 API,且只在部分端可用。

资源回收方面,页面卸载或组件卸载时应遍历 Store,把属于当前页面的实例 destroy 掉并从表里移除,避免长时间使用后实例越积越多。

写在最后

回看这套方案,它解决的核心问题只有一个:多个组件共享同一批播放器实例时,状态一致性由谁保证。答案是收口——实例创建、播放、暂停、seek、完成重置全部收进 Store,组件退化为纯视图,状态单向同步。这个模式不止适用于音频,任何"列表里大量条目共享全局资源"的场景,比如视频封面帧预览、动效资源加载,都可以套用同样的思路:资源上移、以 id 索引、事件在 Store 层订阅。

相关文章

分享: