HarmonyOS 6 音视频实战:用 AVPlayer 做一个本地音乐播放器

零、 前言

前阵子整理旧手机,翻出一个装满了本地音乐的文件夹------都是上学时候一首一首下进去的,有些歌连平台都已经下架了。我一边听一边想,这些歌我可能不会再主动点开,但删掉又舍不得。干脆,给它们做一个自己的播放器?

不接流媒体、不碰云盘,就在 HarmonyOS 6 上做一个把本地音频老老实实放出来的播放器。需求极简,但做完之后我对 AVPlayer 这个老熟人的理解完全不一样了------以前我只是会调它的接口,这次我把它的状态机、它的脾气摸了个底朝天。

这篇就完整记录这个过程:AVPlayer 到底是个什么角色、它那套绕不开的状态机是怎么回事、本地音频该怎么喂给它,最后给出一份完整可跑的工程代码。你在模拟器上跑起来,点一下播放,就能听到我预先塞进去的一段和弦。

壹、 AVPlayer 是谁:一个什么都能播的多面手

先给不熟悉的朋友介绍一下。在 HarmonyOS 的多媒体体系里,AVPlayer 是负责播放的那一位:音频、视频都归它管,本地文件、网络流、甚至裸的媒体数据流,它都能接。你可以把它理解成一台什么格式都愿意读的收音机,MP3、WAV、AAC、HLS 流媒体,塞给它就能出声。

既然是官方统一播放能力,它自然也是性能与兼容性的最优解------比自己写解码器靠谱得多,也比拼一堆第三方库省心得多。这篇只用到它的一小部分能力:把一段本地 wav 放出来,顺便控制播放暂停、进度和音量。

但别小看这个"最简单"的用法。AVPlayer 有一个和大多数组件都不一样的规矩:它不能拿来就播,必须先走完一套严格的状态流程。理解这套流程,是这篇文章真正的主题。

贰、 播放器的脾气:先摸清它的状态机

AVPlayer 用状态机管理自己的生命周期,官方文档里有一张经典的图,核心状态就这几个:

  • idle:创建出来之后的初始状态,什么资源都还没绑定。
  • initialized:数据源已经设置好了(比如把 fdSrc 赋给播放器),但还没准备就绪。
  • prepared:媒体信息解析完成,随时可以 play,也可以 seek、设音量。
  • playing:正在播放。
  • paused:暂停,随时可以回到 playing。
  • completed:播完了。

几个关键认知,都是踩了坑才深刻体会到的:

  • 创建播放器之后,不能直接 play(),必须先设置数据源(触发 initialized),再 prepare()(变成 prepared),之后才有资格 play。
  • 很多操作是有状态限制的:比如在 idle 状态下调用 play(),就是违规操作,会走 error 回调。
  • 每个状态变化都会触发 stateChange 事件,整个流程就是靠监听这个事件来推进的。

翻译成人话:AVPlayer 是一台需要"热车"的机器。点火之后不能立刻轰油门,得先让它把油路、电路都确认一遍,仪表盘亮起"准备就绪",你才敢踩油门。这个"热车"过程,就是我们代码里要处理的 prepare()。

叁、 音频从哪来:rawfile 和文件描述符

数据源这一步,就是给播放器"喂料"。AVPlayer 支持几种喂法:网络 URL、应用沙箱里的文件路径、还有文件描述符 fdSrc。这篇用第三种,配合工程里的 rawfile 资源目录。

rawfile 是 HarmonyOS 应用打包时的一个特殊目录:里面的文件原样打进安装包里,不经过编译,运行时可以直接读。把音频文件放进去,应用一安装,音频就跟着到了设备上------不需要任何网络,也不涉及用户目录权限。

读取 rawfile 里的文件,官方推荐走 resourceManager。它能拿到这个文件的文件描述符(fd),我们把 fd、偏移量、长度一起交给 AVPlayer,播放器就能精确地从安装包里读出这段音频:

复制代码
// 从 rawfile 里拿文件描述符
const desc = await getContext(this).resourceManager.getRawFileDescriptor('music_demo.wav');
const fdSrc: media.AVFileDescriptor = { fd: desc.fd, offset: desc.offset, length: desc.length };

这里有个版本细节值得记一笔:早期 SDK 提供的是 getRawFileDescriptorSync 同步版,在 API 24 的 HarmonyOS 6.1.1 SDK 里它已经不在 ResourceManager 上了,要用异步的 getRawFileDescriptor。我第一次写的时候照旧习惯用了 Sync 版,编译器直接报"方法不存在",改成 await 版才过。版本在迭代,API 也在收敛,写的时候留个心眼。

至于这段音频本身:我写了个小脚本合成了一段 12 秒的和弦 wav(C、E、G 三个音叠起来,44.1kHz 采样率),放进 rawfile 里。这样做的好处是工程完全自包含------不依赖任何外部素材,clone 下来就能跑。等你看完想换歌,把自己喜欢的 mp3 丢进 rawfile,改一行文件名就行。

肆、 让声音响起来:播放核心

核心逻辑其实就三步:创建播放器、监听状态、设置数据源并 prepare。我用一个 async 方法把它们串起来:

复制代码
async initPlayer() {
  // 1. 创建播放器实例(异步)
  const p: media.AVPlayer = await media.createAVPlayer();
  this.player = p;

  // 2. 监听状态机:idle -> initialized -> prepared -> playing/paused/completed
  p.on('stateChange', (state: string) => {
    this.stateText = state;
    if (state === 'prepared') {
      this.ready = true;
    }
  });
  // 播放位置(秒)
  p.on('timeUpdate', (time: number) => {
    this.currentTime = time;
  });
  // 总时长(毫秒,需要换算成秒)
  p.on('durationUpdate', (dur: number) => {
    this.duration = dur / 1000;
  });
  p.on('error', (err: BusinessError) => {
    console.error(`AVPlayer error: ${err.message}`);
  });

  // 3. 用 rawfile 里的音频初始化:拿文件描述符喂给 fdSrc
  const desc = await getContext(this).resourceManager.getRawFileDescriptor('music_demo.wav');
  const fdSrc: media.AVFileDescriptor = { fd: desc.fd, offset: desc.offset, length: desc.length };
  p.fdSrc = fdSrc;
  p.prepare();
}

注意一个坑:createAVPlayer() 是异步的,返回的是 Promise,必须 await 拿到真正的播放器实例再往下走。我一开始把它当同步用,编译报"类型不匹配"------Promise 上哪来的 play 方法。

设置 fdSrc 会触发播放器从 idle 走到 initialized,紧接着的 prepare() 让它解析媒体信息、走到 prepared。走到 prepared 那一刻,stateChange 回调里我们把 ready 置为 true,界面的播放按钮才亮起来------在这之前乱点播放,只会得到一声报错。

伍、 把控制权交给用户:暂停、进度与音量

播放器能响了,接下来就是交互。三个最基础的控制,AVPlayer 都给了直接对应的接口:

  • 播放 / 暂停:play() 和 pause(),各自对应一个状态切换,按钮文案跟着 isPlaying 变。

  • 拖进度:seek(毫秒),把 Slider 的秒值换算成毫秒传进去。

  • 音量:setVolume(0~1),配一个音量 Slider 实时调。

    togglePlay() {
    const p = this.player;
    if (!p || !this.ready) {
    return;
    }
    if (this.isPlaying) {
    p.pause();
    this.isPlaying = false;
    } else {
    p.play();
    this.isPlaying = true;
    }
    }

    seekTo(sec: number) {
    const p = this.player;
    if (!p || !this.ready) {
    return;
    }
    p.seek(sec * 1000);
    }

    setVol(v: number) {
    this.volume = v;
    const p = this.player;
    if (p && this.ready) {
    p.setVolume(v);
    }
    }

界面上:顶部一个圆形大按钮负责播放暂停,下面两张卡片分别是进度条(带当前时间/总时长)和音量条。进度条的时间显示靠 timeUpdate 事件驱动------它每隔一小段时间回报一次当前播放位置(单位秒),我们只管往界面上刷。总时长来自 durationUpdate 事件,单位是毫秒,记得除以 1000 再显示。

陆、 完整代码------直接运行

下面是整个工程的关键代码,和我在模拟器上跑的是同一份。Empty Ability 工程里,把 Index.ets 替换成下面的内容,再把那段合成好的 wav 放到 entry/src/main/resources/rawfile/music_demo.wav,编译运行即可。

全程不需要申请任何权限------rawfile 是应用自带的,不走用户文件,也不碰网络。

复制代码
import { media } from '@kit.MediaKit';
import { BusinessError } from '@kit.BasicServicesKit';

@Entry
@Component
struct Index {
  @State stateText: string = '未就绪';
  @State isPlaying: boolean = false;
  @State currentTime: number = 0;
  @State duration: number = 0;
  @State volume: number = 0.8;
  private player: media.AVPlayer | null = null;
  private ready: boolean = false;

  aboutToAppear() {
    this.initPlayer();
  }

  aboutToDisappear() {
    this.releasePlayer();
  }

  async initPlayer() {
    // 1. 创建播放器实例(异步)
    const p: media.AVPlayer = await media.createAVPlayer();
    this.player = p;

    // 2. 监听状态机:idle -> initialized -> prepared -> playing/paused/completed
    p.on('stateChange', (state: string) => {
      this.stateText = state;
      if (state === 'prepared') {
        this.ready = true;
      }
    });
    // 播放位置(秒)
    p.on('timeUpdate', (time: number) => {
      this.currentTime = time;
    });
    // 总时长(毫秒,需要换算成秒)
    p.on('durationUpdate', (dur: number) => {
      this.duration = dur / 1000;
    });
    p.on('error', (err: BusinessError) => {
      console.error(`AVPlayer error: ${err.message}`);
    });

    // 3. 用 rawfile 里的音频初始化:拿文件描述符喂给 fdSrc
    const desc = await getContext(this).resourceManager.getRawFileDescriptor('music_demo.wav');
    const fdSrc: media.AVFileDescriptor = { fd: desc.fd, offset: desc.offset, length: desc.length };
    p.fdSrc = fdSrc;
    p.prepare();
  }

  togglePlay() {
    const p = this.player;
    if (!p || !this.ready) {
      return;
    }
    if (this.isPlaying) {
      p.pause();
      this.isPlaying = false;
    } else {
      p.play();
      this.isPlaying = true;
    }
  }

  seekTo(sec: number) {
    const p = this.player;
    if (!p || !this.ready) {
      return;
    }
    p.seek(sec * 1000);
  }

  setVol(v: number) {
    this.volume = v;
    const p = this.player;
    if (p && this.ready) {
      p.setVolume(v);
    }
  }

  releasePlayer() {
    const p = this.player;
    if (p) {
      p.release();
      this.player = null;
    }
  }

  fmt(sec: number): string {
    const m = Math.floor(sec / 60);
    const s = Math.floor(sec % 60);
    return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
  }

  build() {
    Column() {
      // 顶栏
      Row() {
        Text('本地音乐播放器')
          .fontSize(18)
          .fontWeight(FontWeight.Bold)
          .fontColor('#1A1A1A')
        Blank()
        Text('AVPlayer')
          .fontSize(13)
          .fontColor(Color.White)
          .backgroundColor('#2B5CE6')
          .borderRadius(10)
          .padding({ left: 10, right: 10, top: 3, bottom: 3 })
      }
      .width('100%')
      .padding({ left: 16, right: 16, top: 12, bottom: 12 })

      Scroll() {
        Column({ space: 14 }) {
          // 封面卡片:内置音乐
          Column({ space: 6 }) {
            Text('🎵')
              .fontSize(56)
            Text('内置示例音乐(12 秒和弦)')
              .fontSize(14)
              .fontWeight(FontWeight.Medium)
              .fontColor('#1A1A1A')
            Text(`状态:${this.stateText}`)
              .fontSize(12)
              .fontColor(this.ready ? '#2B5CE6' : '#FA8C16')
          }
          .width('100%')
          .padding({ top: 28, bottom: 28 })
          .backgroundColor(Color.White)
          .borderRadius(16)

          // 播放 / 暂停
          Row() {
            Button(this.isPlaying ? '⏸ 暂停' : '▶ 播放')
              .width(120)
              .height(48)
              .fontSize(16)
              .fontWeight(FontWeight.Bold)
              .backgroundColor(this.isPlaying ? '#FA8C16' : '#2B5CE6')
              .enabled(this.ready)
              .onClick(() => {
                this.togglePlay();
              })
          }
          .width('100%')
          .justifyContent(FlexAlign.Center)

          // 进度区
          Column({ space: 6 }) {
            Row() {
              Text('进度')
                .fontSize(13)
                .fontColor('#666666')
              Blank()
              Text(`${this.fmt(this.currentTime)} / ${this.fmt(this.duration)}`)
                .fontSize(12)
                .fontColor('#888888')
            }
            .width('100%')

            if (this.duration > 0) {
              Slider({
                value: this.currentTime,
                min: 0,
                max: this.duration,
                style: SliderStyle.OutSet
              })
                .width('100%')
                .onChange((value: number) => {
                  this.seekTo(value);
                })
            }
          }
          .width('100%')
          .padding(16)
          .backgroundColor(Color.White)
          .borderRadius(16)
          .alignItems(HorizontalAlign.Start)

          // 音量区
          Column({ space: 6 }) {
            Row() {
              Text('音量')
                .fontSize(13)
                .fontColor('#666666')
              Blank()
              Text(`${Math.round(this.volume * 100)}%`)
                .fontSize(12)
                .fontColor('#888888')
            }
            .width('100%')

            Slider({
              value: this.volume * 100,
              min: 0,
              max: 100,
              style: SliderStyle.OutSet
            })
              .width('100%')
              .onChange((value: number) => {
                this.setVol(value / 100);
              })
          }
          .width('100%')
          .padding(16)
          .backgroundColor(Color.White)
          .borderRadius(16)
          .alignItems(HorizontalAlign.Start)

          // 说明
          Column() {
            Text('这个 Demo 做了什么')
              .fontSize(15)
              .fontWeight(FontWeight.Medium)
              .fontColor('#1A1A1A')
            Text('用 media.createAVPlayer() 创建播放器,把 rawfile 里的 wav 文件描述符喂给 fdSrc,走完 initialized -> prepared 状态机后 play。播放、暂停、拖进度、调音量全部通过 AVPlayer 的官方接口完成,全程不需要申请任何权限,也不需要联网。')
              .fontSize(13)
              .fontColor('#666666')
              .lineHeight(20)
              .margin({ top: 8 })
          }
          .width('100%')
          .padding(16)
          .backgroundColor(Color.White)
          .borderRadius(16)
          .alignItems(HorizontalAlign.Start)
        }
        .width('100%')
        .padding({ left: 16, right: 16, bottom: 16 })
      }
      .layoutWeight(1)
      .width('100%')
      .align(Alignment.Top)
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F5F7FA')
  }
}

柒、 见证奇迹:运行展示

把工程灌进模拟器(我这套是 MateBook Pro 2in1,HarmonyOS 6.1.1),点开应用,你会看到:

  • 页面顶部一行小字写着"状态:未就绪",紧接着播放器开始热身------几毫秒内状态依次变成 initialized、prepared,然后按钮亮起来,状态变绿。
  • 点**▶** 播放,一段轻快的和弦从扬声器里响起来,12 秒,C-E-G 三音叠加,淡入淡出,不刺耳。
  • 进度条开始自己往前走,时间从 00:00 一点点跳到 00:12;把进度条往后一拖,声音立刻跳到对应位置继续播------seek 的效果是即时的。
  • 把音量滑到 0%,世界瞬间安静;滑回来,音乐继续。播放中点暂停,进度条停住,再点播放,从停住的地方接着走。

没有权限弹窗,没有网络等待,整个流程干净利落。这就是本地播放器的好处:音频就在安装包里,播放器要做的只是把它放出来。

终章:写在最后的话

做完这个播放器,最深的感受是:AVPlayer 的接口看起来就三五个,真正的门槛不在接口,而在那套状态机。很多文档把这个当成"背景知识"一笔带过,但实际开发中,绝大多数播放异常都发生在状态不对的时候------该等 prepared 的时候去 play,该监听 error 的时候假装没事。

这也解释了为什么我总是劝身边的人别急着抄代码:抄会了三个方法,遇到下一个场景照样抓瞎;把状态机想明白了,AVPlayer 能玩的花样可就多了。

这套代码送给你。如果你想继续折腾,我留了几个课后作业:往 rawfile 里多塞几首歌,做一个歌单切换;给播放器加一个上一首/下一首;或者更进一步,把播放进度同步到锁屏通知上,让这个本地播放器真正"日常化"。

鸿蒙的媒体能力比想象中完整,AVPlayer 只是入口。别让它只躺在你的代码库里吃灰,去放一首你舍不得删的歌吧。

参考资料

  1. AVPlayer 开发指导(官方)
  2. @ohos.multimedia.media 媒体服务(官方 API 参考)
  3. 应用资源访问(rawfile,官方)
  4. ResourceManager API 参考(官方)
相关推荐
威哥爱编程1 小时前
HarmonyOS 7 多设备 UX 自动检测实战:AppAnalyzer 多设备体检 + 一多工程底线,提前拦截截断重叠大图大字
harmonyos
威哥爱编程2 小时前
HarmonyOS 7 安全相机 2.0 实战:数字内容溯源,为拍摄内容提供来源保护
harmonyos
威哥爱编程2 小时前
HarmonyOS 7 弱网优化实战:Network Boost Kit 场景加速 + QUIC 长连接 + 弱网直播优化
harmonyos
威哥爱编程2 小时前
HarmonyOS 7 应用故障智能诊断实战:APMS 故障总览 + Profiler 跨语言泄漏 + Operation Analyzer 冻屏分析
harmonyos
暖风熏人醉2 小时前
Expo 鸿蒙双 SDK 时代:我把适配鸿蒙做成了工程
harmonyos
张星河zen3 小时前
华为超节点Peerium架构全拆解:Atlas 950、昇腾960、灵衢UnifiedBus
华为·npo·peerium·昇腾960·atlas950·全光互联·灵衢
程序猿追3 小时前
HarmonyOS 6 毛玻璃卡片:背景模糊 + 半透明
华为·harmonyos
CV工程师丁Sir3 小时前
ArkWeb 手记 05|请求拦截:劫持 H5 接口与图片资源
华为·harmonyos
weixin_690654744 小时前
龙迅#LT9611EX 现MIPI转HDMI1.4功能主推型号,分辨率高达4K30HZ,IC内置I2C无需外部12C控制。
嵌入式硬件·音视频·信号处理