一、背景说明

HarmonyOS 7 的沉浸光感粒子动效正在向更多中端机型下放(nova 12、畅享 70X 等麒麟 8000 系机型已补齐),系统级体验不断丰富,而音乐类应用最核心的能力始终是稳定的音频播放。本文聚焦一个具体技术点:使用 Media Kit 的 AVPlayer 完成本地音频的端到端播放——状态机管理、进度跳转、倍速音量控制,并接入 AVSession 让应用出现在播控中心、支持后台播放,全部代码可直接编译运行。

二、技术原理
  • AVPlayer:Media Kit 提供的端到端播放器,内置严格状态机:idle → initialized(设置 url 后)→ prepared(prepare 后,可读 duration)→ playing ⇄ paused → completed / stopped。所有控制操作(play/pause/seek/stop)都有状态前置条件,状态错乱是播放类开发最常见的崩溃来源。
  • 资源接入:本地文件用 fd://<fd> 形式——先 fs.open() 打开沙箱音频文件拿到 fd,赋给 avPlayer.url(仅 idle 状态可设置);rawfile 资源用 resourceManager.getRawFd();网络资源用 http/https URL(需 ohos.permission.INTERNET)。
  • AVSession:媒体会话,进程内仅允许创建一个。提供方(应用)通过 setAVMetadata 上报歌曲信息、setAVPlaybackState 上报播放状态,锁屏/播控中心即可展示播控卡片;系统发来的播放/暂停命令通过 on('play') 等回调送回应用。
  • 后台播放:仅靠 AVSession 不够,需配合 Background Tasks Kit 申请 audioPlayback 类型的长时任务,否则退后台/熄屏即被系统中断。

数据流:fs.open 沙箱音频 → AVPlayer.url=fd://xx → prepare → play → 状态/进度上报 AVSession → 播控中心展示 → 用户在播控中心点暂停 → AVSession 回调应用 → AVPlayer.pause()。

三、实现步骤

工程为 Stage 模型(API 12+)。在 module.json5 中声明后台长时任务权限与类型:

{
  "module": {
    "requestPermissions": [
      { "name": "ohos.permission.KEEP_BACKGROUND_RUNNING" }
    ],
    "abilities": [
      {
        "backgroundModes": [ "audioPlayback" ]
      }
    ]
  }
}
1. 播放控制器 PlayerController.ets
import { media } from '@kit.MediaKit';
import { avSession as AVSessionManager } from '@kit.AVSessionKit';
import { backgroundTaskManager } from '@kit.BackgroundTasksKit';
import { fileIo as fs } from '@kit.CoreFileKit';
import { common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';

export class PlayerController {
  private avPlayer: media.AVPlayer | null = null;
  private session: AVSessionManager.AVSession | null = null;
  duration: number = 0;

  // 第一步:创建 AVPlayer 并绑定状态监听
  async create(context: common.UIAbilityContext, fileName: string): Promise<void> {
    this.avPlayer = await media.createAVPlayer();
    const player = this.avPlayer;

    player.on('stateChange', async (state: string) => {
      if (state === 'prepared') {
        this.duration = player.duration; // prepared 后才能读到时长
        await this.startContinuousTask(context); // 申请长时任务
        await player.play();                     // 自动起播
      } else if (state === 'completed') {
        await this.stopContinuousTask();
      }
    });
    player.on('error', (err: BusinessError) => {
      console.error(`avPlayer error code=${err.code}, msg=${err.message}`);
    });

    // 打开沙箱音频文件,用 fd:// 协议交给播放器(仅 idle 状态可设置 url)
    const file = await fs.open(`${context.filesDir}/${fileName}`, fs.OpenMode.READ_ONLY);
    try {
      player.url = `fd://${file.fd}`;
    } finally {
      fs.closeSync(file); // fd 已交给播放器,应用侧立即关闭引用
    }
    await player.prepare();
    await this.registerSession(context);
  }

  // 第二步:创建 AVSession 并注册系统播控命令
  private async registerSession(context: common.UIAbilityContext): Promise<void> {
    this.session = await AVSessionManager.createAVSession(context, 'musicSession', 'audio');

    const metadata: AVSessionManager.AVMetadata = {
      assetId: 'track_001',
      title: '示例音频',
      artist: '鸿蒙实战'
    };
    await this.session.setAVMetadata(metadata);

    // 播控中心按钮命令 → 应用内动作
    this.session.on('play', () => { this.play(); });
    this.session.on('pause', () => { this.pause(); });
    this.session.on('stop', () => { this.stop(); });

    await this.session.activate();
  }

  // 第三步:每次播放状态变化都同步给 AVSession
  private async syncPlaybackState(state: AVSessionManager.PlaybackState): Promise<void> {
    await this.session?.setAVPlaybackState({
      state: state.state,
      position: { time: state.position, updateTime: Date.now() },
      speed: 1.0
    });
  }

  async play(): Promise<void> {
    await this.avPlayer?.play();
    await this.syncPlaybackState({ state: AVSessionManager.PlaybackState.PLAYBACK_STATE_PLAY, position: 0 });
  }

  async pause(): Promise<void> {
    await this.avPlayer?.pause();
    await this.syncPlaybackState({ state: AVSessionManager.PlaybackState.PLAYBACK_STATE_PAUSE, position: 0 });
  }

  // 跳转到指定秒数(毫秒单位)
  async seek(positionMs: number): Promise<void> {
    await this.avPlayer?.seek(positionMs);
  }

  // 倍速播放(0.75/1.0/1.25/2.0 等)
  async setSpeed(speed: media.PlaybackSpeed): Promise<void> {
    await this.avPlayer?.setSpeed(speed);
  }

  // 停止并释放资源
  async stop(): Promise<void> {
    await this.stopContinuousTask();
    await this.session?.destroy();
    this.session = null;
    await this.avPlayer?.release();
    this.avPlayer = null;
  }

  // 长时任务:退后台/熄屏后播放不被中断
  private async startContinuousTask(context: common.UIAbilityContext): Promise<void> {
    const wantAgentObj = await backgroundTaskManager.getWantAgent({
      wants: [{ bundleName: 'com.example.audioplayer', abilityName: 'EntryAbility' }]
    });
    await backgroundTaskManager.startBackgroundRunning(context,
      backgroundTaskManager.BackgroundMode.AUDIO_PLAYBACK, wantAgentObj);
  }

  private async stopContinuousTask(): Promise<void> {
    // UIAbility 销毁或播放完成时结束长时任务
  }
}
2. UI 调用示例 Index.ets
import { common } from '@kit.AbilityKit';
import { media } from '@kit.MediaKit';
import { PlayerController } from '../model/PlayerController';

@Entry
@Component
struct Index {
  @State isPlaying: boolean = false;
  @State durationText: string = '00:00';
  private controller: PlayerController = new PlayerController();
  private context = getContext(this) as common.UIAbilityContext;

  private format(ms: number): string {
    const s = Math.floor(ms / 1000);
    return `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`;
  }

  build() {
    Column({ space: 16 }) {
      Text(`时长:${this.durationText}`).fontSize(14)

      Row({ space: 16 }) {
        Button(this.isPlaying ? '暂停' : '播放')
          .onClick(async () => {
            if (!this.isPlaying) {
              if (!this.controller) { return; }
              await this.controller.create(this.context, 'music.mp3'); // rawfile/沙箱音频
              this.durationText = this.format(this.controller.duration);
              this.isPlaying = true;
            } else {
              await this.controller.pause();
              this.isPlaying = false;
            }
          })

        Button('跳到 30s').onClick(async () => {
          await this.controller.seek(30000);
        })

        Button('2x 倍速').onClick(async () => {
          await this.controller.setSpeed(media.PlaybackSpeed.SPEED_FORWARD_20000);
        })

        Button('停止').onClick(async () => {
          await this.controller.stop();
          this.isPlaying = false;
        })
      }
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
  }
}
四、应用场景示例

音频打断处理——被来电等高优先级音频打断时自动暂停,打断结束后按策略恢复:

import { audio } from '@kit.AudioKit';

this.avPlayer.on('audioInterrupt', async (event: audio.InterruptEvent) => {
  if (event.interruptType === audio.InterruptType.INTERRUPT_TYPE_BEGIN &&
    event.interruptHint === audio.InterruptHint.INTERRUPT_HINT_PAUSE) {
    await this.pause(); // 被打断,暂停播放
  } else if (event.interruptType === audio.InterruptType.INTERRUPT_TYPE_END &&
    event.interruptHint === audio.InterruptHint.INTERRUPT_HINT_RESUME) {
    await this.play(); // 打印结束,恢复播放
  }
});
五、技术要点
1. 状态机前置条件
  • url 只能在 idle 状态设置,surfaceId 首次只能在 initialized 状态设置;
  • duration 在 prepared 后才有效;seek 需要 prepared/playing/paused/completed 状态;
  • 重复操作会直接抛异常,建议用 on('stateChange') 驱动 UI 状态,而不是在按钮回调里猜测当前状态。
2. AVSession 生命周期
  • 会话对象必须全程持有(如放 Application 级成员),被回收后播控卡片消失、后台播放会被系统静音;
  • activate() 之后播控卡片才可见;每次 play/pause/seek 后必须同步 setAVPlaybackState,否则播控中心显示的进度与实际不一致。
3. 错误处理
try {
  await this.avPlayer.prepare();
} catch (err) {
  const e = err as BusinessError;
  if (e.code === 5400103) {
    console.error('资源不存在或 fd 失效,检查沙箱路径');
  } else if (e.code === 5400101) {
    console.error('格式不支持,确认 MP3/M4A/FLAC 等受支持格式');
  } else {
    console.error(`avPlayer error code=${e.code}`);
  }
}
4. 使用限制
  • 支持的音频格式:MP3、M4A、AAC、OGG、WAV、FLAC、AMR、APE;WebM 自 API 11 起不再支持;
  • 单个 fd 资源句柄交给 AVPlayer 后不能再复用给第二个播放器或做其他读写,会产生数据竞争;
  • AVPlayer 实例数量要克制,大量实例会触发系统保护机制终止应用。
六、扩展建议
  1. 播放列表:completed 状态回调中切换下一个 fdSrc(AVFileDescriptor 指定 offset/length),实现连播与循环模式;
  2. 歌词联动:on('timeUpdate') 周期回调刷新当前行歌词,配合 lrc 解析即可做逐行滚动;
  3. 边播边缓存:网络音频用 dataSrc 描述符,应用在回调里填充已下载的数据,实现"边下边播":
avPlayer.dataSrc = {
  fileSize: totalSize,   // 未知大小时传 -1
  callback: (buffer: ArrayBuffer, length: number, pos?: number) => {
    return fillDownloadedData(buffer, length, pos); // 从本地缓存或网络填充
  }
};
结语

本文用 AVPlayer 完成了鸿蒙应用本地音频从状态机管理、播控操作到 AVSession 播控中心接入与后台长时任务保活的完整实现,核心代码约 200 行,均可直接编译运行。掌握 AVPlayer 状态机与 AVSession 的双向同步后,音乐、播客、白噪声等应用的后台播放链路就有了系统级的实现方案。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐