Administrator
发布于 2026-09-01 / 80 阅读
0
0

鸿蒙开发系列(第14篇):音视频开发实战——AVPlayer、录音与媒体会话管理

鸿蒙开发系列 · 高级篇

前言

做完天气服务和推送系统,你可能觉得鸿蒙开发已经"够用"了。但当你接到一个真正的产品需求——“做个语音备忘录App,能录音、能播放、还要在锁屏界面显示播放控制”——你会发现事情远没有想象中简单。

音视频开发是移动端最复杂的领域之一。它涉及硬件采集、编解码、缓冲区管理、音频焦点竞争、后台播放保活、锁屏媒体控制等一系列问题。Android的MediaPlayer/MediaRecorder被诟病多年,iOS的AVFoundation相对完善但学习曲线陡峭。HarmonyOS的方案是分层设计:底层提供高性能的C++引擎,上层用ArkTS封装出简洁的API。

本文将带你构建一个完整的语音备忘录应用,覆盖音频播放、录音、媒体会话(Media Session)三大核心能力。


一、HarmonyOS音视频架构概览

1.1 分层架构

┌─────────────────────────────────────────────┐
│  应用层(你的代码)                            │
│  AVPlayer / AVRecorder / AudioRecorder       │
├─────────────────────────────────────────────┤
│  框架层(ArkTS API)                          │
│  @kit.MediaKit / @kit.AudioKit               │
├─────────────────────────────────────────────┤
│  引擎层(C++)                                │
│  播放器引擎 / 录音引擎 / 编解码器              │
├─────────────────────────────────────────────┤
│  硬件抽象层(HDI)                            │
│  音频HDI / 视频HDI / 编解码HDI               │
├─────────────────────────────────────────────┤
│  硬件层                                      │
│  扬声器 / 麦克风 / 摄像头 / GPU               │
└─────────────────────────────────────────────┘

开发者主要与框架层交互,但理解分层有助于排查问题。比如播放卡顿,可能是网络层(上)的问题,也可能是音频缓冲区(下)的问题。

1.2 核心Kit对照表

能力

Kit名称

主要类/接口

音频播放

@kit.MediaKit

AVPlayer

视频播放

@kit.MediaKit

AVPlayer

音频录制

@kit.AudioKit

AudioRecorder

视频录制

@kit.MediaKit

AVRecorder

相机预览

@kit.CameraKit

CameraManager + PreviewOutput

媒体会话

@kit.MediaKit

AVSession

音效处理

@kit.AudioKit

AudioEffect


二、音频播放:AVPlayer 深度实战

2.1 AVPlayer 状态机

AVPlayer采用状态机模型,理解状态流转是避免Bug的关键:

         initialize()
    [Idle] ──────────→ [Initialized]
                            │
                            │ prepare()
                            ▼
                       [Prepared] ←───────
                            │              │
            ┌───────────────┼──────────────┤
            │               │              │
            ▼               ▼              │
       [Playing] ←─► [Paused]            │
            │               │              │
            │    stop()     │              │
            └───────┬───────┘              │
                    ▼                      │
               [Stopped] ──────────────────┘
                    │
                    │ release()
                    ▼
                [Released]

关键规则:

  • prepare()是异步的,必须在状态回调中确认准备完成才能调用play()

  • stop()后不能直接从Stopped回到Playing,需要重新prepare()

  • release()后播放器对象不可再用,需要重新创建

2.2 完整播放器封装

// entry/src/main/ets/utils/AudioPlayer.ets
import { media } from '@kit.MediaKit';
import { BusinessError } from '@kit.BasicServicesKit';

export enum PlayerState {
  IDLE = 'idle',
  INITIALIZED = 'initialized',
  PREPARED = 'prepared',
  PLAYING = 'playing',
  PAUSED = 'paused',
  STOPPED = 'stopped',
  RELEASED = 'released',
  ERROR = 'error'
}

export class AudioPlayer {
  private avPlayer: media.AVPlayer | null = null;
  private currentState: PlayerState = PlayerState.IDLE;
  private stateCallbacks: Set<(state: PlayerState) => void> = new Set();
  private progressCallbacks: Set<(current: number, duration: number) => void> = new Set();
  private progressTimer: number = -1;

  // 状态监听
  onStateChange(callback: (state: PlayerState) => void): () => void {
    this.stateCallbacks.add(callback);
    return () => this.stateCallbacks.delete(callback);
  }

  // 进度监听
  onProgress(callback: (current: number, duration: number) => void): () => void {
    this.progressCallbacks.add(callback);
    return () => this.progressCallbacks.delete(callback);
  }

  private setState(state: PlayerState) {
    this.currentState = state;
    this.stateCallbacks.forEach(cb => cb(state));
  }

  // 初始化播放器
  async initialize(): Promise<void> {
    if (this.avPlayer) {
      await this.release();
    }

    this.avPlayer = await media.createAVPlayer();
    this.setupCallbacks();
    this.setState(PlayerState.IDLE);
  }

  private setupCallbacks() {
    if (!this.avPlayer) return;

    // 状态变更回调
    this.avPlayer.on('stateChange', (state) => {
      const stateMap: Record<string, PlayerState> = {
        'idle': PlayerState.IDLE,
        'initialized': PlayerState.INITIALIZED,
        'prepared': PlayerState.PREPARED,
        'playing': PlayerState.PLAYING,
        'paused': PlayerState.PAUSED,
        'stopped': PlayerState.STOPPED,
        'released': PlayerState.RELEASED,
        'error': PlayerState.ERROR
      };
      this.setState(stateMap[state] || PlayerState.ERROR);

      if (state === 'playing') {
        this.startProgressTimer();
      } else if (state === 'paused' || state === 'stopped') {
        this.stopProgressTimer();
      }
    });

    // 错误回调
    this.avPlayer.on('error', (err: BusinessError) => {
      console.error('播放器错误:', err.code, err.message);
      this.setState(PlayerState.ERROR);
    });

    // 播放完成
    this.avPlayer.on('endOfStream', () => {
      console.info('播放完成');
      this.stopProgressTimer();
      // 可以自动播放下一首,或回到起始位置
      this.seek(0);
    });
  }

  // 设置数据源
  async setSource(uri: string): Promise<void> {
    if (!this.avPlayer) throw new Error('播放器未初始化');

    // 支持网络URL、本地文件路径、FD(文件描述符)
    this.avPlayer.url = uri;
    this.setState(PlayerState.INITIALIZED);
  }

  // 准备播放(异步,缓冲数据)
  async prepare(): Promise<void> {
    if (!this.avPlayer) throw new Error('播放器未初始化');
    await this.avPlayer.prepare();
  }

  // 播放
  async play(): Promise<void> {
    if (!this.avPlayer) throw new Error('播放器未初始化');

    if (this.currentState === PlayerState.PAUSED) {
      await this.avPlayer.play();
    } else if (this.currentState === PlayerState.PREPARED ||
               this.currentState === PlayerState.STOPPED) {
      await this.avPlayer.play();
    } else {
      throw new Error(`当前状态 ${this.currentState} 不支持播放`);
    }
  }

  // 暂停
  async pause(): Promise<void> {
    if (!this.avPlayer || this.currentState !== PlayerState.PLAYING) return;
    await this.avPlayer.pause();
  }

  // 停止
  async stop(): Promise<void> {
    if (!this.avPlayer) return;
    await this.avPlayer.stop();
    this.stopProgressTimer();
  }

  // 跳转
  async seek(timeMs: number): Promise<void> {
    if (!this.avPlayer) return;
    await this.avPlayer.seek(timeMs);
  }

  // 释放资源
  async release(): Promise<void> {
    this.stopProgressTimer();
    if (this.avPlayer) {
      await this.avPlayer.release();
      this.avPlayer = null;
    }
    this.setState(PlayerState.RELEASED);
  }

  // 获取总时长
  getDuration(): number {
    return this.avPlayer?.duration ?? 0;
  }

  // 获取当前进度
  getCurrentTime(): number {
    return this.avPlayer?.currentTime ?? 0;
  }

  // 设置音量(0-1)
  setVolume(volume: number): void {
    if (this.avPlayer) {
      this.avPlayer.setVolume(volume);
    }
  }

  // 设置倍速
  setSpeed(speed: number): void {
    if (this.avPlayer) {
      this.avPlayer.setSpeed(speed);
    }
  }

  private startProgressTimer() {
    this.stopProgressTimer();
    this.progressTimer = setInterval(() => {
      const current = this.getCurrentTime();
      const duration = this.getDuration();
      this.progressCallbacks.forEach(cb => cb(current, duration));
    }, 500);
  }

  private stopProgressTimer() {
    if (this.progressTimer !== -1) {
      clearInterval(this.progressTimer);
      this.progressTimer = -1;
    }
  }

  getState(): PlayerState {
    return this.currentState;
  }
}

2.3 播放页面UI

// entry/src/main/ets/pages/PlayerPage.ets
import { AudioPlayer, PlayerState } from '../utils/AudioPlayer';

@Entry
@Component
struct PlayerPage {
  private player: AudioPlayer = new AudioPlayer();
  @State currentState: PlayerState = PlayerState.IDLE;
  @State currentTime: number = 0;
  @State duration: number = 0;
  @State playUrl: string = 'https://example.com/audio/sample.mp3';

  aboutToAppear() {
    this.player.onStateChange((state) => {
      this.currentState = state;
    });
    this.player.onProgress((current, duration) => {
      this.currentTime = current;
      this.duration = duration;
    });
    this.initPlayer();
  }

  async initPlayer() {
    await this.player.initialize();
    await this.player.setSource(this.playUrl);
    await this.player.prepare();
  }

  aboutToDisappear() {
    this.player.release();
  }

  private formatTime(ms: number): string {
    const totalSeconds = Math.floor(ms / 1000);
    const minutes = Math.floor(totalSeconds / 60);
    const seconds = totalSeconds % 60;
    return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
  }

  build() {
    Column({ space: 24 }) {
      // 封面区域
      Column({ space: 12 }) {
        Image($r('app.media.ic_audio_cover'))
          .width(200)
          .height(200)
          .borderRadius(12)
          .shadow({ radius: 16, color: 'rgba(0,0,0,0.15)' })

        Text('语音备忘录 #1')
          .fontSize(20)
          .fontWeight(FontWeight.Bold)

        Text('2026-09-01 录制')
          .fontSize(14)
          .fontColor('#888')
      }
      .padding(24)

      // 进度条
      Column({ space: 8 }) {
        Slider({
          value: this.duration > 0 ? this.currentTime / this.duration * 100 : 0,
          min: 0,
          max: 100
        })
          .onChange((value) => {
            const targetTime = (value / 100) * this.duration;
            this.player.seek(targetTime);
          })

        Row() {
          Text(this.formatTime(this.currentTime))
            .fontSize(12)
            .fontColor('#666')
          Text(this.formatTime(this.duration))
            .fontSize(12)
            .fontColor('#666')
        }
        .width('100%')
        .justifyContent(FlexAlign.SpaceBetween)
      }
      .padding({ left: 24, right: 24 })

      // 控制按钮
      Row({ space: 32 }) {
        Button() {
          Image($r('app.media.ic_skip_back'))
            .width(24)
            .height(24)
        }
        .width(48)
        .height(48)
        .backgroundColor('#F0F0F0')
        .type(ButtonType.Circle)

        Button() {
          Image(this.currentState === PlayerState.PLAYING
            ? $r('app.media.ic_pause')
            : $r('app.media.ic_play'))
            .width(32)
            .height(32)
        }
        .width(64)
        .height(64)
        .backgroundColor('#2196F3')
        .type(ButtonType.Circle)
        .onClick(() => {
          if (this.currentState === PlayerState.PLAYING) {
            this.player.pause();
          } else {
            this.player.play();
          }
        })

        Button() {
          Image($r('app.media.ic_skip_forward'))
            .width(24)
            .height(24)
        }
        .width(48)
        .height(48)
        .backgroundColor('#F0F0F0')
        .type(ButtonType.Circle)
      }
      .padding(24)
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#FAFAFA')
  }
}

三、音频录制:AudioRecorder

3.1 录音实现

// entry/src/main/ets/utils/AudioRecorder.ets
import { audio } from '@kit.AudioKit';
import { fileIo } from '@kit.CoreFileKit';

export enum RecorderState {
  IDLE = 'idle',
  PREPARING = 'preparing',
  RECORDING = 'recording',
  PAUSED = 'paused', // 部分格式支持暂停
  STOPPED = 'stopped',
  ERROR = 'error'
}

export class VoiceRecorder {
  private audioRecorder: audio.AudioRecorder | null = null;
  private currentState: RecorderState = RecorderState.IDLE;
  private outputFile: string = '';
  private stateCallbacks: Set<(state: RecorderState) => void> = new Set();
  private amplitudeCallbacks: Set<(amp: number) => void> = new Set();
  private amplitudeTimer: number = -1;

  onStateChange(callback: (state: RecorderState) => void): () => void {
    this.stateCallbacks.add(callback);
    return () => this.stateCallbacks.delete(callback);
  }

  onAmplitude(callback: (amp: number) => void): () => void {
    this.amplitudeCallbacks.add(callback);
    return () => this.amplitudeCallbacks.delete(callback);
  }

  private setState(state: RecorderState) {
    this.currentState = state;
    this.stateCallbacks.forEach(cb => cb(state));
  }

  // 初始化并准备录音
  async prepare(outputPath: string): Promise<void> {
    this.outputFile = outputPath;

    // 确保目录存在
    const dir = outputPath.substring(0, outputPath.lastIndexOf('/'));
    try {
      fileIo.accessSync(dir);
    } catch {
      fileIo.mkdirSync(dir, true);
    }

    // 创建录音器
    const audioSourceInfo: audio.AudioSourceInfo = {
      source: audio.SourceType.SOURCE_TYPE_MIC
    };

    const audioEncoderInfo: audio.AudioEncoderInfo = {
      mimeType: 'audio/mp4a-latm', // AAC编码
      sampleRate: 44100,
      channels: 1,
      bitRate: 128000,
      bitWidth: 16
    };

    this.audioRecorder = await audio.createAudioRecorder();

    // 配置录音参数
    await this.audioRecorder.prepare({
      audioSourceInfo,
      audioEncoderInfo,
      fileInfo: {
        filePath: outputPath
      }
    });

    this.setState(RecorderState.PREPARING);
  }

  // 开始录音
  async start(): Promise<void> {
    if (!this.audioRecorder) throw new Error('录音器未初始化');
    await this.audioRecorder.start();
    this.setState(RecorderState.RECORDING);
    this.startAmplitudeMonitor();
  }

  // 停止录音
  async stop(): Promise<void> {
    if (!this.audioRecorder) return;
    await this.audioRecorder.stop();
    this.stopAmplitudeMonitor();
    this.setState(RecorderState.STOPPED);
  }

  // 释放资源
  async release(): Promise<void> {
    this.stopAmplitudeMonitor();
    if (this.audioRecorder) {
      await this.audioRecorder.release();
      this.audioRecorder = null;
    }
    this.setState(RecorderState.IDLE);
  }

  // 获取录音文件路径
  getOutputFile(): string {
    return this.outputFile;
  }

  private startAmplitudeMonitor() {
    this.stopAmplitudeMonitor();
    this.amplitudeTimer = setInterval(() => {
      // 实际项目中可通过AudioKit获取实时音量
      // 此处模拟波形数据
      const amplitude = Math.random() * 100;
      this.amplitudeCallbacks.forEach(cb => cb(amplitude));
    }, 100);
  }

  private stopAmplitudeMonitor() {
    if (this.amplitudeTimer !== -1) {
      clearInterval(this.amplitudeTimer);
      this.amplitudeTimer = -1;
    }
  }
}

3.2 录音页面

// entry/src/main/ets/pages/RecorderPage.ets
import { VoiceRecorder, RecorderState } from '../utils/AudioRecorder';
import { fileUri } from '@kit.CoreFileKit';

@Entry
@Component
struct RecorderPage {
  private recorder: VoiceRecorder = new VoiceRecorder();
  @State state: RecorderState = RecorderState.IDLE;
  @State amplitude: number = 0;
  @State recordTime: number = 0;
  @State recordFile: string = '';
  private timer: number = -1;

  aboutToAppear() {
    this.recorder.onStateChange((s) => this.state = s);
    this.recorder.onAmplitude((amp) => this.amplitude = amp);
  }

  aboutToDisappear() {
    this.recorder.release();
    if (this.timer !== -1) clearInterval(this.timer);
  }

  async startRecording() {
    const context = getContext();
    const filesDir = context.filesDir;
    const fileName = `record_${Date.now()}.m4a`;
    const outputPath = `${filesDir}/${fileName}`;

    await this.recorder.prepare(outputPath);
    await this.recorder.start();

    this.recordTime = 0;
    this.timer = setInterval(() => {
      this.recordTime++;
    }, 1000);
  }

  async stopRecording() {
    await this.recorder.stop();
    if (this.timer !== -1) {
      clearInterval(this.timer);
      this.timer = -1;
    }
    this.recordFile = this.recorder.getOutputFile();
  }

  private formatTime(seconds: number): string {
    const mins = Math.floor(seconds / 60);
    const secs = seconds % 60;
    return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
  }

  build() {
    Column({ space: 32 }) {
      // 波形可视化
      Row() {
        ForEach(Array.from({ length: 20 }, (_, i) => i), (index) => {
          Column()
            .width(6)
            .height(this.state === RecorderState.RECORDING
              ? Math.max(10, this.amplitude * (0.5 + Math.random() * 0.5))
              : 10)
            .backgroundColor(this.state === RecorderState.RECORDING ? '#2196F3' : '#DDD')
            .borderRadius(3)
            .animation({
              duration: 100,
              curve: Curve.EaseInOut
            })
        })
      }
      .width('100%')
      .height(120)
      .padding(24)
      .justifyContent(FlexAlign.SpaceAround)
      .alignItems(VerticalAlign.Center)

      // 计时
      Text(this.formatTime(this.recordTime))
        .fontSize(48)
        .fontWeight(FontWeight.Light)
        .fontColor(this.state === RecorderState.RECORDING ? '#2196F3' : '#333')

      // 录音按钮
      Button(this.state === RecorderState.RECORDING ? '停止录音' : '开始录音')
        .width(160)
        .height(56)
        .fontSize(18)
        .backgroundColor(this.state === RecorderState.RECORDING ? '#F44336' : '#2196F3')
        .onClick(() => {
          if (this.state === RecorderState.RECORDING) {
            this.stopRecording();
          } else {
            this.startRecording();
          }
        })

      // 已录制文件
      if (this.recordFile) {
        Text(`已保存: ${this.recordFile.split('/').pop()}`)
          .fontSize(14)
          .fontColor('#666')
          .maxLines(1)
          .textOverflow({ overflow: TextOverflow.Ellipsis })
      }
    }
    .width('100%')
    .height('100%')
    .padding(24)
    .backgroundColor('#FAFAFA')
  }
}

四、媒体会话:AVSession

4.1 为什么需要媒体会话?

当你播放音频时,用户期望:

  • 锁屏界面显示当前播放信息和控制按钮

  • 控制中心能看到播放卡片

  • 耳机线控可以暂停/播放

  • 与其他音频应用"抢焦点"时行为正确

这些功能不是播放器自带的,而是通过**媒体会话(AVSession)**与系统协作实现的。

4.2 AVSession 接入

// entry/src/main/ets/utils/PlaybackSession.ets
import { avSession } from '@kit.AVSessionKit';

export class PlaybackSession {
  private session: avSession.AVSession | null = null;

  // 创建媒体会话
  async create(context: Context, tag: string): Promise<void> {
    this.session = await avSession.createAVSession(context, tag, 'audio');
    this.setupCallbacks();
  }

  private setupCallbacks() {
    if (!this.session) return;

    // 播放控制
    this.session.on('play', () => {
      // 收到系统播放指令(如点击锁屏播放按钮)
      this.emit('play');
    });

    this.session.on('pause', () => {
      this.emit('pause');
    });

    this.session.on('stop', () => {
      this.emit('stop');
    });

    this.session.on('seek', (time) => {
      this.emit('seek', time);
    });

    // 上一首/下一首
    this.session.on('playPrevious', () => {
      this.emit('previous');
    });

    this.session.on('playNext', () => {
      this.emit('next');
    });
  }

  // 更新播放元数据(封面、标题等)
  async setMetadata(metadata: {
    title: string;
    artist: string;
    album: string;
    duration: number;
    mediaImage?: string;
  }): Promise<void> {
    if (!this.session) return;

    await this.session.setAVMetadata({
      assetId: metadata.title,
      title: metadata.title,
      artist: metadata.artist,
      album: metadata.album,
      duration: metadata.duration,
      mediaImage: metadata.mediaImage || '',
      mediaUri: ''
    });
  }

  // 更新播放状态
  async setPlaybackState(state: 'playing' | 'paused' | 'stopped', position: number): Promise<void> {
    if (!this.session) return;

    const stateMap: Record<string, avSession.PlaybackState> = {
      'playing': avSession.PlaybackState.PLAYBACK_STATE_PLAY,
      'paused': avSession.PlaybackState.PLAYBACK_STATE_PAUSE,
      'stopped': avSession.PlaybackState.PLAYBACK_STATE_STOP
    };

    await this.session.setAVPlaybackState({
      state: stateMap[state],
      position: { elapsedTime: position, updateTime: Date.now() }
    });
  }

  // 激活会话(开始接收系统控制指令)
  async activate(): Promise<void> {
    await this.session?.activate();
  }

  // 释放会话
  async destroy(): Promise<void> {
    await this.session?.destroy();
    this.session = null;
  }

  private eventHandlers: Map<string, Set<Function>> = new Map();

  on(event: string, handler: Function): void {
    if (!this.eventHandlers.has(event)) {
      this.eventHandlers.set(event, new Set());
    }
    this.eventHandlers.get(event)!.add(handler);
  }

  private emit(event: string, ...args: unknown[]): void {
    this.eventHandlers.get(event)?.forEach(h => h(...args));
  }
}

4.3 播放器与AVSession整合

// 在AudioPlayer中集成AVSession
async initialize(context: Context): Promise<void> {
  // 原有初始化...
  await this.player.initialize();

  // 创建媒体会话
  this.avSession = new PlaybackSession();
  await this.avSession.create(context, 'VoiceMemoPlayer');

  // 将系统控制转发给播放器
  this.avSession.on('play', () => this.play());
  this.avSession.on('pause', () => this.pause());
  this.avSession.on('seek', (time: number) => this.seek(time));

  await this.avSession.activate();
}

// 播放时更新会话状态
async play(): Promise<void> {
  await this.player.play();
  await this.avSession?.setPlaybackState('playing', this.player.getCurrentTime());
}

async pause(): Promise<void> {
  await this.player.pause();
  await this.avSession?.setPlaybackState('paused', this.player.getCurrentTime());
}

五、小结与下篇预告

本文构建了一个完整的语音备忘录应用,覆盖三大核心能力:

  1. AVPlayer音频播放:状态机管理、完整封装、进度监听、UI联动

  2. AudioRecorder录音:AAC编码、振幅可视化、文件管理

  3. AVSession媒体会话:锁屏控制、系统焦点、播放元数据同步

音视频开发的核心难点不在于API调用,而在于状态管理的复杂性和资源生命周期控制。AVPlayer的状态机、录音器的权限申请、媒体会话的系统协作,任何一环处理不当都会导致体验问题。

地图和位置服务是另一个高频领域能力。从定位获取到地图渲染,从POI搜索到轨迹绘制,涉及的Kit和API同样复杂。第15篇将深入鸿蒙地图与位置服务,讲解Map Kit的集成、定位精度控制、地理编码与逆编码,以及如何在元服务中优雅地使用地图能力。


参考阅读


评论