HarmonyOS 声笺播放器【播放器系列 04】:后台音频的长时任务与生命周期排查【共创稿事节】
前台播放、切到桌面、声音停止。这条故障描述看起来很短,背后至少可能有三种原因:页面离开时主动释放了播放器,媒体会话没有保持存活,或者应用没有为真实播放业务申请相应后台任务。声笺播放器前三篇已经完成播放、系统播控与进度确认,这一篇按资源所有权、系统声明、申请取消和版本适配逐层处理后台链路。

一、先查是谁停止了声音
第一篇的播放器归页面所有,aboutToDisappear() 里关闭引擎符合当时的产品边界。现在需求变成离开页面后继续播放,就必须修改这个所有权,而不是仅仅增加一项权限。声音属于一次持续的播放业务,页面只是观察窗口;页面可以出现和消失,业务对象由 EntryAbility 持有并在真正销毁时清理。
其次确认第二篇的 MediaBridge 仍然存活。官方 后台音频场景指南 将媒体会话和长时任务列为后台播放的重要环节,两者各自解决不同问题。会话承担统一展示和控制,长时任务声明持续后台业务,任何一个都不能替代另一个。创建了会话却把引用留在局部变量,也不符合稳定持有的设计。
示例仍以 API 12 能力构建基本闭环。关于后台播放模式的显式设置,本文另列 API 24 适配段落,只有使用相应或更高编译 SDK 时才能加入该段。运行时条件判断不会让旧编译器突然认识新接口,因此编译基线和最低运行版本应分别记录。业务采用单 Ability、单音源、单音频长时任务,不讨论多任务合并通知。
有些排查习惯会误导方向,例如把“页面不见了”当成“Ability 已经销毁”,或者把“通知栏还在”当成“声音一定仍然播放”。前者是不同层级的生命周期,后者只是展示现象。需要分别观察播放器状态、会话状态和任务申请结果,才能判断是应用主动清理、系统约束还是音源本身结束。
还应检查实际音源是否仍有可播放内容。本系列的短资源专门用于验证事件顺序,它自然结束后取消后台任务是正确行为。若只观察最后一刻没有声音,却不看完成事件,很容易把业务结束当成系统限制。排查记录需要保留音源长度、切后台时的位置和结束状态,任何单张通知截图都不足以说明这些条件。
另外,后台音频不是保活技巧。应用申请的是用户正在听到的业务,不是用一段静音音频替无关计算争取时间。把资源申请挂到真实播放状态上,有助于在代码层面表达这个边界:播放结束就结束任务,用户主动暂停就释放后台资格,业务没有发生时不预先申请。
二、把声明放到正确层级
在 entry/src/main/module.json5 的模块对象中增加长时任务权限,在 EntryAbility 条目内增加音频后台模式。以下是要合并进已有模板的精确字段,不替换原有名称、入口路径、图标或页面配置,也不把权限放进 Ability 条目内部。
{
"module": {
"requestPermissions": [
{ "name": "ohos.permission.KEEP_BACKGROUND_RUNNING" }
],
"abilities": [
{
"name": "EntryAbility",
"backgroundModes": ["audioPlayback"]
}
]
}
}
权限声明表示应用具备申请入口,业务代码仍需在真实播放时调用长时任务接口。不要在应用启动后永久保持任务,也不要把暂停状态伪装成播放状态维持后台资格。用户暂停、播放结束或音频发生中断时,应当及时取消;恢复播放则重新申请。这样后台资源与用户当前能够感知的业务保持一致。
新增 service/AudioLease.ets。这里用串行链处理申请与取消,防止快速播放暂停时,取消先结束而迟到的申请又留下后台任务。WantAgent 使用当前 Ability 的真实包名与名称,用于用户从通知返回应用,不需要写死开发者机器上的包标识。
import { backgroundTaskManager } from '@kit.BackgroundTasksKit';
import { common, wantAgent } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
export class AudioLease {
private context: common.UIAbilityContext;
private requested: boolean = false;
private queue: Promise<void> = Promise.resolve();
onError: (message: string) => void = () => {};
constructor(context: common.UIAbilityContext) { this.context = context; }
setPlaying(playing: boolean): Promise<void> {
this.queue = this.queue.then(async () => {
if (playing) {
const agent = await wantAgent.getWantAgent({
wants: [{
bundleName: this.context.abilityInfo.bundleName,
abilityName: this.context.abilityInfo.name
}],
actionType: wantAgent.OperationType.START_ABILITY,
requestCode: 0,
wantAgentFlags: [wantAgent.WantAgentFlags.UPDATE_PRESENT_FLAG]
});
await backgroundTaskManager.startBackgroundRunning(
this.context, backgroundTaskManager.BackgroundMode.AUDIO_PLAYBACK, agent);
this.requested = true;
} else if (this.requested) {
try { await backgroundTaskManager.stopBackgroundRunning(this.context); }
finally { this.requested = false; }
}
}).catch((error: BusinessError) => {
this.onError(`后台任务处理失败:${error.code}`);
});
return this.queue;
}
close(): Promise<void> { return this.setPlaying(false); }
}
本地标记只用于避免在从未申请时执行取消,不是系统任务状态的权威缓存。播放从暂停恢复时仍会重新申请,不能因为之前成功过就一直跳过。系统可能在音频中断时冻结或取消任务,因此生命周期处理应围绕新的播放事实重建申请,而不是把第一次成功当成永久通行证。
申请失败采取明确降级:显示原因并暂停播放,不让界面继续承诺后台播放可用。取消失败也记录原因,随后不能继续把本地标记当成有效授权。这个最小实现没有轮询系统任务列表;若产品需要更复杂的恢复,应结合目标 SDK 的任务查询和取消事件接口设计,不要在失败回调里无限重试。
三、把播放业务上移到 Ability
新增 service/AudioRuntime.ets。它复用前三篇的 AudioEngine 与 MediaBridge,只增加统一启动和结束入口。页面通过模块中的当前业务引用访问同一实例,避免每个页面都创建自己的播放器。实例由每次创建的 EntryAbility 持有,销毁后移除引用,下次创建会得到新的引擎;多 Ability 产品需要另行划定共享范围,不能把单 Ability 约束隐藏在变量名里。
import { common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { AudioEngine, AudioSnapshot } from './AudioEngine';
import { MediaBridge } from './MediaBridge';
import { AudioLease } from './AudioLease';
export class AudioRuntime {
engine: AudioEngine = new AudioEngine();
bridge: MediaBridge = new MediaBridge(this.engine);
private lease?: AudioLease;
private unsubscribe?: () => void;
private startup?: Promise<void>;
private closing: boolean = false;
private lastState: string = '';
private bridgeFault: boolean = false;
start(context: common.UIAbilityContext): Promise<void> {
if (!this.startup) { this.startup = this.initialize(context); }
return this.startup;
}
private async initialize(context: common.UIAbilityContext): Promise<void> {
this.lease = new AudioLease(context);
this.lease.onError = (message: string) => {
AppStorage.setOrCreate('audioNotice', message);
this.engine.pause();
};
this.bridge.onError = (message: string) => {
this.bridgeFault = true;
AppStorage.setOrCreate('audioNotice', message);
};
await this.bridge.open(context);
if (this.closing) { return; }
this.unsubscribe = this.engine.subscribe((snapshot: AudioSnapshot) => {
if (snapshot.state === this.lastState) { return; }
this.lastState = snapshot.state;
this.lease?.setPlaying(snapshot.state === 'playing');
});
if (!this.bridgeFault) {
AppStorage.setOrCreate('audioNotice', '后台音频按播放状态申请');
}
await this.engine.open(context);
}
async close(): Promise<void> {
if (this.closing) { return; }
this.closing = true;
try { await this.startup; } catch (error) {
console.error(`startup: ${(error as BusinessError).code}`);
}
this.unsubscribe?.();
await this.engine.pause();
await this.lease?.close();
try { await this.bridge.close(); }
finally { await this.engine.close(); }
}
}
let currentRuntime: AudioRuntime | undefined;
export function installAudioRuntime(runtime: AudioRuntime): void {
currentRuntime = runtime;
}
export function getAudioRuntime(): AudioRuntime {
if (!currentRuntime) { throw new Error('播放业务尚未安装'); }
return currentRuntime;
}
export function removeAudioRuntime(runtime: AudioRuntime): void {
if (currentRuntime === runtime) { currentRuntime = undefined; }
}
这里按播放器状态变化申请和取消,正常时间更新不会重复申请。进入播放时申请,离开播放时取消;因此页面按钮、系统播控、播放完成和后续焦点暂停都经过同一条路径。若只在页面按钮里管理任务,系统暂停命令就可能留下后台任务,自动播放完成也可能无人清理。
在模板 EntryAbility.ets 的已有生命周期中合并下面的调用,保留原来的窗口加载逻辑。初始化错误写到应用状态供页面展示;销毁清理返回 Promise,让资源结束有明确的等待关系。不要在 onBackground() 中再次创建播放器,也不要在那里无条件暂停这个已经声明支持后台音频的业务。
import { UIAbility } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { AudioRuntime, installAudioRuntime, removeAudioRuntime }
from '../service/AudioRuntime';
// 合并到模板的 EntryAbility 类中。
private playback: AudioRuntime = new AudioRuntime();
onCreate(): void {
installAudioRuntime(this.playback);
AppStorage.setOrCreate('audioNotice', '正在准备后台音频');
this.playback.start(this.context).catch((error: BusinessError) => {
AppStorage.setOrCreate('audioNotice', `初始化失败:${error.code}`);
});
}
async onDestroy(): Promise<void> {
try { await this.playback.close(); }
finally { removeAudioRuntime(this.playback); }
}
页面把原来的 new AudioEngine() 和 new MediaBridge() 替换为共享实例,删除第二篇的页面启动方法与清理方法,出现时只订阅,消失时只取消订阅。同名成员直接替换,不重复声明;sessionText 也从 @State 改为下面的 @StorageLink。第三篇的拖动字段仍留在页面,不搬到后台业务;草稿是一次界面交互,不是应当跨页面保持的播放事实。
import { AudioRuntime, getAudioRuntime } from '../service/AudioRuntime';
private runtime: AudioRuntime = getAudioRuntime();
private engine = this.runtime.engine;
private bridge = this.runtime.bridge;
@StorageLink('audioNotice') sessionText: string = '';
aboutToAppear(): void {
this.pageClosing = false;
this.unsubscribe = this.engine.subscribe((value: AudioSnapshot) => {
this.snapshot = value;
});
}
aboutToDisappear(): void {
this.pageClosing = true;
this.unsubscribe?.();
this.dragging = false;
this.seeking = false;
}

重新进入页面时,订阅会立即得到当前引擎快照,因此不需要通过旧页面保存的位置来猜测后台播放进度。页面上的定位等待标记也会随着退出清理,不能把一个已经消失的交互层继续当成后台业务控制者。如果系统仍在完成上一笔定位,引擎自己的在途限制会继续生效,新的页面请求可以得到明确反馈。第三篇超时后的异常标记属于引擎,重新打开页面不会清除它;需要结束播放业务并创建新运行时,才能重新建立定位通道。
业务结束和界面退出还应提供不同入口。返回上一页只取消观察,用户明确选择停止整项播放业务才清理运行时;Ability 销毁作为最后一道清理路径。即使现在只有一个页面,也要在方法职责中保留这个差别,避免以后增加设置页或详情页时,任何一次导航都意外终止声音。
四、新版本后台模式单独适配
官方 AVSession 文档将 setBackgroundPlayMode 标为 API 24 起支持。若项目使用 API 24 或更高编译 SDK,可在第二篇 MediaBridge.create() 激活会话之后加入以下段落,并增加导入。基础版不加入这段也能维持此前的音频会话语义;不能把该方法写进只使用 API 12 编译 SDK 的代码,再声称运行时判断可以解决编译错误。
import { deviceInfo } from '@kit.BasicServicesKit';
// 插入 await session.activate() 之后。
if (deviceInfo.sdkApiVersion >= 24) {
await session.setBackgroundPlayMode(
avSession.BackgroundPlayMode.ENABLE_BACKGROUND_PLAY);
}
这项设置应与产品的后台播放选择一致。当前声笺明确支持后台音频,所以使用启用模式;若增加用户开关,关闭时还需要同步暂停或停止当前后台业务,并取消任务,不能只改界面开关。模式设置、播放器状态和长时任务是互相关联的三个条件,任意一个变化都应能在状态流中找到对应处理。
五、按顺序验证申请、暂停与恢复
先在前台播放,确认引擎已进入播放状态,再检查长时任务申请结果;接着切到桌面或锁屏,确认声音和系统播控仍与预期一致。随后从系统播控暂停,检查播放器进入暂停状态以及后台任务取消路径。再次播放时,应能观察到新一轮申请,而不是依靠之前缓存的成功标记。
再测试播放自然完成、初始化失败、快速播放暂停和 Ability 真正销毁。十秒示例资源会很快到达末尾,因此观察后台持续播放时可以重新生成更长资源,但记录中需要注明输入变化。不能把音源正常结束误判成后台被杀,也不能通过开启循环掩盖任务取消逻辑是否正确。

如果仍然停止,按主动释放、会话存活、任务声明、申请结果、真实播放状态的顺序排查。先确认应用没有自己关闭资源,再分析系统约束,能减少大量无效尝试。后台运行也不是进程永不终止的承诺;播放进度持久化和被终止后的恢复属于下一层产品需求,应以新的启动流程重新建立会话和任务。
完成这一步后,页面、媒体会话与播放器的生命周期已经各就各位。最后一篇会处理来电、其他音频和耳机切换带来的焦点变化,尤其要防止“系统允许恢复”被错误理解成“无条件重新播放”。后台链路继续沿用这里按实际播放状态申请资源的规则。
对取消失败的处理也应留下证据。示例记录错误码并停止继续信任本地申请标记,下一次真实播放仍重新走申请流程。实际产品应进一步检查系统任务状态与用户可见通知,必要时在业务结束入口补充有界重试;不能把错误捕获后没有崩溃解释为后台任务已经成功取消。稳定性来自可追踪的资源状态,而不只是没有异常窗口。
参考资料
更多推荐
所有评论(0)