# 【三国志 App 实战系列 08】HarmonyOS 后台听书实践:AVSession、BackgroundTasks 与真实设备踩坑

系列第 8 篇。本文介绍在 HarmonyOS 中实现后台听书需要的权限、媒体会话、后台任务,以及真机调试中遇到的卡顿和恢复问题。

??????????

一、后台播放不是只加一个定时器

听书退到后台后,如果只靠页面定时器和 TTS 自己播放,很容易出现:

  • 进度不走
  • TTS 被系统打断
  • 锁屏媒体控制不同步
  • 后台一段读完后下一段不播

因此需要三部分配合:

  • KEEP_BACKGROUND_RUNNING 权限
  • backgroundModes: ["audioPlayback"]
  • AVSession + BackgroundTasksKit

如果把这个问题放在真实听书场景里看,会更具体。用户可能正在听人物传记,然后发生下面这些动作:

  • 锁屏后用耳机点暂停
  • 切到别的 App,再从系统媒体卡片恢复
  • 一段朗读结束后,期待自动切到下一段
  • 后台被系统回收后,再次回到前台继续播放

也就是说,后台听书不是“前台逻辑继续跑一会儿”那么简单,而是要让TTS、播放状态、系统媒体控制、后台任务生命周期指向同一份状态。这类工程化解释,正是后续 CSDN 高分文章很依赖的信号。

二、配置权限

{
  "requestPermissions": [
    { "name": "ohos.permission.KEEP_BACKGROUND_RUNNING" }
  ],
  "abilities": [
    {
      "name": "EntryAbility",
      "backgroundModes": ["audioPlayback"]
    }
  ]
}

2.1 权限和后台模式为什么要一起看

很多人在这里容易踩一个误区:只要加了 KEEP_BACKGROUND_RUNNING,就觉得后台播放已经配置完了。实际上后台听书至少有两层约束:

  • 权限层:系统是否允许你的 Ability 继续维持后台运行
  • 媒体层:系统是否把当前会话识别成音频播放场景

如果只做了第一层,没有 audioPlayback 背景模式和后面的 AVSession,系统媒体卡片、锁屏控制和耳机事件都不一定能和页面状态对上。

2.2 入口配置不要和业务代码耦合

这部分推荐放在模块配置和 Ability 初始化附近,不要散落在页面里动态拼装。原因很简单:后台能力属于应用级约束,不应该依赖某个页面刚好先被打开。

三、创建 AVSession

private async ensureMediaSession(): Promise<avSession.AVSession> {
  if (this.mediaSession !== null) {
    return this.mediaSession;
  }
  const context: common.UIAbilityContext = this.getAbilityContext();
  const session: avSession.AVSession = await avSession.createAVSession(
    context,
    'records_audio_session',
    'audio'
  );
  session.on('play', () => this.speakSelectedAudio());
  session.on('pause', () => this.stopTextToSpeech());
  session.on('playNext', () => this.playNextAudio());
  session.on('playPrevious', () => this.playPreviousAudio());
  await session.activate();
  this.mediaSession = session;
  return session;
}

3.1 AVSession 的职责不是“给锁屏展示标题”

很多示例只把 AVSession 当成一个信息展示容器,但在听书项目里,它至少承担三类职责:

  • 接收系统的播放、暂停、下一条、上一条控制事件
  • 把当前媒体状态同步到系统卡片和锁屏页
  • 作为前台页面与后台音频能力之间的统一中枢

如果页面按钮改的是一套状态、锁屏按钮改的是另一套状态,最终就会出现“UI 显示暂停但系统还认为在播放”这种错位。

3.2 会话对象为什么要做幂等创建

后台听书页可能多次进入退出,如果每次进入都新建一个会话,会带来两个问题:

  • 系统里残留重复会话
  • 旧事件监听没有解绑,回调会重复触发

所以这里 ensureMediaSession() 的思路很重要:会话只创建一次,后续复用。

private mediaSessionReady: boolean = false;

private async bindMediaSessionHandlers(session: avSession.AVSession): Promise<void> {
  if (this.mediaSessionReady) {
    return;
  }
  session.on('play', () => this.speakSelectedAudio());
  session.on('pause', () => this.stopTextToSpeech());
  session.on('playNext', () => this.playNextAudio());
  session.on('playPrevious', () => this.playPreviousAudio());
  this.mediaSessionReady = true;
}

这样我们既能避免重复注册,也能把“创建会话”和“绑定事件”拆开,后续更容易排查问题。

四、更新媒体状态

await session.setAVPlaybackState({
  state: avSession.PlaybackState.PLAYBACK_STATE_PLAY,
  speed: 1,
  position: {
    elapsedTime: this.selectedAudio().listenedSeconds * 1000,
    updateTime: Date.now()
  }
});

这一步关系到系统媒体卡片和锁屏控制是否显示正确。

4.1 页面状态和 AVSession 状态必须同源

后台音频最怕的是多个地方各自维护“播放状态”。比较稳的做法是:页面只维护一份业务状态,再把它映射到 AVSession

interface AudioUiState {
  title: string;
  subtitle: string;
  listenedSeconds: number;
  durationSeconds: number;
  isPlaying: boolean;
}

private async syncSessionState(state: AudioUiState) {
  const session = await this.ensureMediaSession();
  await session.setAVMetadata({
    assetId: this.selectedAudio().id,
    title: state.title,
    artist: state.subtitle
  });
  await session.setAVPlaybackState({
    state: state.isPlaying
      ? avSession.PlaybackState.PLAYBACK_STATE_PLAY
      : avSession.PlaybackState.PLAYBACK_STATE_PAUSE,
    speed: state.isPlaying ? 1 : 0,
    position: {
      elapsedTime: state.listenedSeconds * 1000,
      updateTime: Date.now()
    }
  });
}

这里的核心思路是:AVSession 不自己发明状态,而是消费页面已经确认好的状态。

五、启动后台任务

await backgroundTaskManager.startBackgroundRunning(
  context,
  backgroundTaskManager.BackgroundMode.AUDIO_PLAYBACK,
  agent
);
this.backgroundTaskRunning = true;

停止时要成对调用:

await backgroundTaskManager.stopBackgroundRunning(this.getAbilityContext());
this.backgroundTaskRunning = false;

5.1 后台任务的开启和关闭要成对

后台任务最容易被写成“只开不关”。短期看可能没问题,但后面会出现两个隐患:

  • 页面退出后后台任务还挂着,状态泄漏
  • 下一次再进入听书页时,无法判断当前到底有没有有效后台任务

更稳的做法是把它抽成成对方法:

private async ensureBackgroundRunning(agent: wantAgent.WantAgent) {
  if (this.backgroundTaskRunning) {
    return;
  }
  await backgroundTaskManager.startBackgroundRunning(
    this.getAbilityContext(),
    backgroundTaskManager.BackgroundMode.AUDIO_PLAYBACK,
    agent
  );
  this.backgroundTaskRunning = true;
}

private async releaseBackgroundRunning() {
  if (!this.backgroundTaskRunning) {
    return;
  }
  await backgroundTaskManager.stopBackgroundRunning(this.getAbilityContext());
  this.backgroundTaskRunning = false;
}

这类“幂等开启/关闭”结构在技术文章里也很重要,因为它直接体现你不是只把 API 调通,而是考虑了完整生命周期。

六、真机踩坑:TTS 后台 stop

真实设备上,退后台后 TTS 可能异步触发 onStop。如果直接认为是用户暂停,就会停止播放。项目中增加恢复判断:

private shouldRecoverBackgroundTtsStop(): boolean {
  return this.appLifecycleState === 'background' || Date.now() - this.lastBackgroundKeepAt < 5000;
}

后台恢复时不要立刻 stop()speak(),需要给引擎一点时间:

const retryLevel: number = Math.min(this.backgroundTtsRecoveryCount, 6);
const delayMs: number = 800 + retryLevel * 240;
setTimeout(() => {
  this.speakSelectedAudio();
}, delayMs);

6.1 为什么后台 onStop 不能直接等于“用户暂停”

这里是后台听书最隐蔽的坑之一。前台时 onStop 很多时候确实意味着用户主动停止,但到后台后情况会复杂得多:

  • 系统切换音频焦点
  • 后台调度短暂中断
  • 锁屏或切应用时 TTS 引擎短暂停止

如果一收到 onStop 就把全局状态切成“用户已暂停”,页面回到前台后就很难恢复自动续播。因此这里必须先结合生命周期状态判断:这次 stop 到底是用户行为,还是后台恢复过程的一部分。

6.2 后台恢复不要无脑立即重播

不少实现会在 onStop 后立即重新 speak()。这在真机上很容易放大问题:

  • 旧请求还没完全释放,新请求又进来
  • speak/stop 重入,导致重复读或跳段
  • 设备负载高时,连续重试触发更强的不稳定

更合理的办法是:记录恢复次数,给一个递增延迟,并且只恢复当前活动队列。

private activeAudioId: string = '';
private backgroundTtsRecoveryCount: number = 0;

private tryRecoverBackgroundSpeak(targetId: string) {
  if (targetId !== this.activeAudioId) {
    return;
  }
  const retryLevel = Math.min(this.backgroundTtsRecoveryCount, 6);
  const delayMs = 800 + retryLevel * 240;
  this.backgroundTtsRecoveryCount++;
  setTimeout(() => {
    if (targetId !== this.activeAudioId) {
      return;
    }
    this.speakSelectedAudio();
  }, delayMs);
}

七、前后台状态机要怎么拆

后台听书项目里,最推荐显式建模的一件事就是播放器状态。不要只靠 isPlaying 一个布尔值撑完整个页面。

type AudioLifecycleState =
  | 'idle'
  | 'preparing'
  | 'playing'
  | 'paused'
  | 'background_recovering'
  | 'error';

interface AudioRuntimeState {
  targetId: string;
  lifecycle: AudioLifecycleState;
  listenedSeconds: number;
  backgroundTaskRunning: boolean;
  sessionActive: boolean;
}

这样做的直接收益是:页面、AVSession、后台恢复逻辑都可以围绕同一份状态来更新,而不是各自猜测当前到底在什么阶段。

7.1 哪些事件会驱动状态切换

后台听书最常见的状态切换事件包括:

  • 用户点击播放
  • 用户点击暂停
  • App 进入后台
  • TTS onStart
  • TTS onStop
  • TTS onComplete
  • 系统媒体卡片触发 play/pause

当这些事件都显式列出来以后,文章的“工程感”会明显更强,也更容易指导读者落地。

????

八、模拟器限制

DevEco 模拟器不支持 Core Speech Kit 与 AVSession 的完整行为。TTS 和后台媒体控制必须以真机回归为准。

8.1 哪些能力必须真机验证

以下几类行为不要只看模拟器:

  • 锁屏页媒体控制是否可见
  • 耳机播放/暂停事件是否能回传
  • TTS 在切后台后的 stop/recover 行为
  • 后台任务在系统资源紧张时是否还能续住

也就是说,模拟器更适合验证页面联动和基本状态,真机才是后台音频最终结论。

九、调试命令与日志观察

后台音频问题如果只靠界面观察,会非常低效。建议把下面几条命令直接作为文章里的排查入口:

hdc list targets
hdc shell hilog | Select-String -Pattern "Audio|AVSession|Background|TTS"
hdc shell aa force-stop com.example.recordofthreekingdoms
hdc shell aa start -a EntryAbility -b com.example.recordofthreekingdoms

如果要确认后台任务和媒体状态有没有真的同步,可以在关键位置补日志:

hilog.info(0x0000, 'AudioPage', 'state=%{public}s progress=%{public}d bg=%{public}s',
  this.audioState.lifecycle,
  this.audioState.listenedSeconds,
  String(this.backgroundTaskRunning));

建议至少观察三类日志:

  • 开始播放时是否创建/激活了 AVSession
  • 切后台后是否真正开启了 BackgroundMode.AUDIO_PLAYBACK
  • onStop 发生时,当前生命周期是不是 background_recovering

十、常见问题复盘

后台听书的难点不是“API 多”,而是多个子系统之间很容易不同步。项目里最常见的坑可以直接整理成下面这张表:

问题 表现 处理方式
只开权限,不同步媒体会话 锁屏页没有控制卡片 补齐 AVSession 元数据与播放状态更新
后台任务只开不关 页面退出后状态泄漏 抽成成对的 ensure/release 方法
后台 onStop 被误判 回到前台后无法继续播放 结合生命周期判断是否需要恢复
立即 stop + speak 重入 出现重复读、跳段 使用递增延迟恢复
页面状态和系统状态分裂 UI 暂停但锁屏还显示播放中 所有入口都写回同一份播放器状态

这类复盘表对读者非常有价值,因为它说明文章覆盖的是“真机问题”,而不是只把 API 调通。

十一、小结

后台听书不是“让定时器继续跑”这么简单。它涉及播放状态、通知控制、系统媒体会话和后台任务续期,必须先把前台播放队列设计清楚。

interface PlayerState {
  title: string;
  artist: string;
  duration: number;
  position: number;
  playing: boolean;
}

function toPlaybackState(state: PlayerState) {
  return {
    state: state.playing ? avSession.PlaybackState.PLAYBACK_STATE_PLAY
      : avSession.PlaybackState.PLAYBACK_STATE_PAUSE,
    position: {
      elapsedTime: state.position,
      updateTime: Date.now()
    },
    speed: state.playing ? 1.0 : 0
  };
}

页面按钮、通知按钮和耳机控制都应该修改同一个播放状态,再由状态驱动 UI 和 AVSession 更新。

这里真正决定体验的,不是有没有后台权限,而是后台状态是否和前台状态共用一套来源。只有这样,锁屏控制、耳机控制、页面按钮和 TTS 回调才不会互相打架。

十二、工程实现与验收清单

场景 期望结果
退到后台 听书不中断
锁屏 媒体控制可见
点击暂停 UI 和通知状态一致
耳机控制 能触发播放/暂停
后台 stop 前台回到可恢复状态
低电量模式 不出现无限重试

12.1 发布前我会额外检查什么

  • 锁屏后从系统卡片点暂停,前台返回时按钮状态是否一致
  • 连续切换两篇听书内容,旧队列是否完全停止
  • 真机后台超过数分钟后,回到前台是否还能恢复
  • 低电量或资源紧张场景下,是否只有限次恢复而不是死循环重试
  • 正文截图、代码块、复盘表、调试命令是否足以支撑一篇完整工程文章

十三、小结

后台听书的关键是把 TTS、媒体会话、后台任务和生命周期统一管理。本文可以压缩成四个核心点:权限和后台模式只是前提,AVSession 负责系统控制同步,BackgroundTasksKit 负责后台续期,状态机和恢复策略决定最终体验是否稳定。

如果你接下来要做真正可用的后台听书,不要把它看成“给前台播放器加个后台开关”,而要把它当成“前后台共用一套状态模型”的问题。下一篇会继续讲最隐蔽的重入 Bug:TTS speak/stop 交错导致的卡带、跳段和重复朗读。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐