HarmonyOS开发音频录制:AVRecorder、权限申请与录音文件管理全攻略

举报
Jack20 发表于 2026/06/20 20:34:18 2026/06/20
【摘要】 HarmonyOS开发中的音频录制:AVRecorder、权限申请与录音文件管理全攻略📌 核心要点:掌握 HarmonyOS 音频录制的完整流程——从权限申请到 AVRecorder 状态机管理,从录音参数配置到文件存储策略,实现生产级录音功能 一、背景与动机你有没有用过手机上的录音 App?按下录音键,对着手机说话,松手停止,一段清晰的语音备忘录就保存好了。或者在语音聊天里,按住说话、...

HarmonyOS开发中的音频录制:AVRecorder、权限申请与录音文件管理全攻略

📌 核心要点:掌握 HarmonyOS 音频录制的完整流程——从权限申请到 AVRecorder 状态机管理,从录音参数配置到文件存储策略,实现生产级录音功能


一、背景与动机

你有没有用过手机上的录音 App?按下录音键,对着手机说话,松手停止,一段清晰的语音备忘录就保存好了。或者在语音聊天里,按住说话、松开发送,对面就能听到你的声音。这些看似简单的操作,背后涉及权限申请、音频采集、编码压缩、文件存储等一系列技术环节。

为什么音频录制比播放更复杂?

  • 权限门槛:麦克风是敏感权限,需要用户主动授权,还得处理拒绝场景
  • 配置参数多:采样率、位深、声道数、编码格式、容器格式……组合起来眼花缭乱
  • 状态管理严格:和 AVPlayer 一样,AVRecorder 也有严格的状态机,乱来就崩溃
  • 文件管理:录音文件存哪里?怎么命名?怎么清理?都是实际问题
  • 异常处理:来电中断、权限被收回、存储空间不足……各种边界情况

今天这篇,咱们就把音频录制的每个环节都拆解清楚。


二、核心原理

2.1 AVRecorder 状态机

AVRecorder 是 HarmonyOS 提供的音频/视频录制核心类。和 AVPlayer 类似,它也是状态驱动的,但状态流转方向不同——从"准备"到"录制"。

打个比方:AVRecorder 就像一台录像机。你得先装好录像带(配置输出文件)、设置好参数(分辨率、帧率),然后才能按录制键。录制过程中可以暂停、恢复,最后停止保存。

stateDiagram-v2
    classDef primary fill:#4CAF50,stroke:#388E3C,color:#fff
    classDef warning fill:#FF9800,stroke:#F57C00,color:#fff
    classDef error fill:#F44336,stroke:#D32F2F,color:#fff
    classDef info fill:#2196F3,stroke:#1976D2,color:#fff
    classDef purple fill:#9C27B0,stroke:#7B1FA2,color:#fff

    [*] --> idle : 创建AVRecorder
    idle --> prepared : prepare(config)
    prepared --> recording : start()
    recording --> paused : pause()
    paused --> recording : resume()
    recording --> stopped : stop()
    paused --> stopped : stop()
    stopped --> prepared : prepare(config)
    stopped --> idle : reset()
    prepared --> idle : reset()
    any --> released : release()
    any --> error : 异常

    class idle primary
    class prepared info
    class recording primary
    class paused warning
    class stopped error
    class released error
    class error error

状态机关键规则:

当前状态 允许的操作 目标状态
idle prepare(config) prepared
prepared start recording
recording pause / stop paused / stopped
paused resume / stop recording / stopped
stopped prepare / reset prepared / idle
任何状态 release released

2.2 录音配置参数详解

录音配置是 AVRecorder 的核心,参数选错了要么录不了,要么文件巨大。来看关键参数:

参数 说明 推荐值
audioSourceType 音频源类型 VOICE_COMMUNICATION(通话)/ MIC(麦克风)
profile.audioBitrate 音频比特率 48000 ~ 128000 bps
profile.audioSampleRate 采样率 44100 / 48000 Hz
profile.audioChannels 声道数 1(单声道)/ 2(立体声)
profile.audioCodec 编码格式 AAC_LC / AMR_NB / AMR_WB
profile.fileFormat 容器格式 FORMAT_MPEG_4 / FORMAT_AMR / FORMAT_THREE_GPP
url 输出文件路径 fd:// 或 file://

常见配置组合:

场景 编码 容器 采样率 比特率 声道
高质量录音 AAC_LC MPEG_4 48000 128000 2
语音备忘 AAC_LC MPEG_4 44100 64000 1
电话录音 AMR_WB THREE_GPP 16000 23850 1
语音消息 AMR_NB AMR 8000 12200 1

2.3 录音权限与安全流程

flowchart TB
    classDef primary fill:#4CAF50,stroke:#388E3C,color:#fff
    classDef warning fill:#FF9800,stroke:#F57C00,color:#fff
    classDef error fill:#F44336,stroke:#D32F2F,color:#fff
    classDef info fill:#2196F3,stroke:#1976D2,color:#fff
    classDef purple fill:#9C27B0,stroke:#7B1FA2,color:#fff

    A[用户点击录音] --> B{检查麦克风权限}
    B -->|已授权| D[创建AVRecorder]
    B -->|未授权| C[请求权限]
    C -->|用户同意| D
    C -->|用户拒绝| E[提示用户去设置授权]
    D --> F[配置录音参数]
    F --> G[prepare]
    G --> H[start录音]
    H --> I{录音中}
    I -->|用户停止| J[stop]
    I -->|来电中断| K[暂停录音]
    K -->|通话结束| I
    J --> L[保存文件]
    L --> M[release释放]

    class A primary
    class B warning
    class C info
    class D primary
    class E error
    class F info
    class G primary
    class H primary
    class I primary
    class J warning
    class K error
    class L primary
    class M error

三、代码实战

3.1 录音权限申请与检查

录音的第一步,永远是权限。没有权限,一切都是白搭:

import { abilityAccessCtrl, bundleManager, Permissions } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';

@Entry
@Component
struct PermissionManager {
  @State hasPermission: boolean = false;
  @State permissionStatus: string = '未检查';

  // 麦克风权限
  private readonly MICROPHONE_PERMISSION: Permissions = 'ohos.permission.MICROPHONE';

  aboutToAppear(): void {
    this.checkPermission();
  }

  /**
   * 检查是否已授权麦克风权限
   */
  async checkPermission(): Promise<void> {
    try {
      const atManager = abilityAccessCtrl.createAtManager();
      const bundleInfo = await bundleManager.getBundleInfoForSelf(bundleManager.BundleFlag.GET_BUNDLE_INFO_DEFAULT);
      const bundleName = bundleInfo.name;

      const grantStatus = await atManager.checkAccessToken(
        bundleInfo.appInfo.accessTokenId,
        this.MICROPHONE_PERMISSION
      );

      if (grantStatus === abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED) {
        this.hasPermission = true;
        this.permissionStatus = '已授权 ✅';
      } else {
        this.hasPermission = false;
        this.permissionStatus = '未授权 ❌';
      }
    } catch (err) {
      const error = err as BusinessError;
      console.error(`[权限] 检查失败: ${error.message}`);
      this.permissionStatus = '检查失败';
    }
  }

  /**
   * 请求麦克风权限
   */
  async requestPermission(): Promise<boolean> {
    try {
      const atManager = abilityAccessCtrl.createAtManager();
      const result = await atManager.requestPermissionsFromUser(
        getContext(this),
        [this.MICROPHONE_PERMISSION]
      );

      // authResult: 0=已授权, -1=未授权
      if (result.authResults[0] === 0) {
        this.hasPermission = true;
        this.permissionStatus = '已授权 ✅';
        console.info('[权限] 麦克风权限已获取');
        return true;
      } else {
        this.hasPermission = false;
        this.permissionStatus = '用户拒绝 ❌';
        console.warn('[权限] 用户拒绝了麦克风权限');
        return false;
      }
    } catch (err) {
      const error = err as BusinessError;
      console.error(`[权限] 请求失败: ${error.message}`);
      return false;
    }
  }

  /**
   * 引导用户去系统设置中手动授权
   */
  async openSystemSettings(): Promise<void> {
    try {
      const context = getContext(this) as common.UIAbilityContext;
      // 打开应用设置页面
      context.openLink('settings://application_settings');
    } catch (err) {
      console.error('[权限] 无法打开系统设置');
    }
  }

  build() {
    Column({ space: 20 }) {
      Text('🎤 麦克风权限管理')
        .fontSize(24)
        .fontWeight(FontWeight.Bold)

      Text(`权限状态: ${this.permissionStatus}`)
        .fontSize(16)
        .fontColor(this.hasPermission ? '#4CAF50' : '#F44336')

      if (!this.hasPermission) {
        Column({ space: 12 }) {
          Text('录音功能需要麦克风权限')
            .fontSize(14)
            .fontColor('#888')

          Button('请求权限')
            .width(200)
            .backgroundColor('#2196F3')
            .onClick(() => this.requestPermission())

          Button('去设置中授权')
            .width(200)
            .backgroundColor('#FF9800')
            .onClick(() => this.openSystemSettings())
        }
      } else {
        Text('权限已就绪,可以开始录音 🎉')
          .fontSize(16)
          .fontColor('#4CAF50')
      }
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .padding(20)
  }
}

⚠️ 重要:别忘了在 module.json5 中声明权限:

"requestPermissions": [
  {
    "name": "ohos.permission.MICROPHONE",
    "reason": "$string:microphone_reason",
    "usedScene": {
      "abilities": ["EntryAbility"],
      "when": "inuse"
    }
  }
]

3.2 AVRecorder 完整录音器

有了权限,就可以正式录音了。下面是一个功能完整的录音器,支持暂停/恢复、计时、文件管理:

import { media } from '@kit.MediaKit';
import { fileIo as fs } from '@kit.CoreFileKit';
import { common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';

// 录音配置预设
interface RecordPreset {
  name: string;
  audioCodec: media.CodecMimeType;
  fileFormat: media.ContainerFormatType;
  sampleRate: number;
  bitrate: number;
  channels: number;
}

@Entry
@Component
struct AudioRecorder {
  // AVRecorder 实例
  private avRecorder: media.AVRecorder | null = null;
  // 录音文件路径
  private currentFilePath: string = '';
  // 录音计时器
  private timer: number = -1;

  // 状态
  @State isRecording: boolean = false;
  @State isPaused: boolean = false;
  @State recordDuration: number = 0;   // 录音时长(秒)
  @State recorderState: string = 'idle';
  @State recordList: string[] = [];     // 录音文件列表

  // 录音预设
  private presets: Record<string, RecordPreset> = {
    high: {
      name: '高质量',
      audioCodec: media.CodecMimeType.AUDIO_AAC,
      fileFormat: media.ContainerFormatType.CFT_MPEG_4,
      sampleRate: 48000,
      bitrate: 128000,
      channels: 2
    },
    normal: {
      name: '标准',
      audioCodec: media.CodecMimeType.AUDIO_AAC,
      fileFormat: media.ContainerFormatType.CFT_MPEG_4,
      sampleRate: 44100,
      bitrate: 64000,
      channels: 1
    },
    voice: {
      name: '语音',
      audioCodec: media.CodecMimeType.AUDIO_AMR_WB,
      fileFormat: media.ContainerFormatType.CFT_THREE_GPP,
      sampleRate: 16000,
      bitrate: 23850,
      channels: 1
    }
  };

  // 当前使用的预设
  private currentPreset: string = 'normal';

  aboutToDisappear(): void {
    this.releaseRecorder();
  }

  /**
   * 生成录音文件路径
   */
  generateFilePath(): string {
    const context = getContext(this) as common.Context;
    const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
    const ext = this.currentPreset === 'voice' ? '.3gp' : '.m4a';
    return `${context.filesDir}/recording_${timestamp}${ext}`;
  }

  /**
   * 开始录音
   */
  async startRecording(): Promise<void> {
    try {
      // 创建 AVRecorder
      this.avRecorder = await media.createAVRecorder();
      this.recorderState = 'idle';

      // 注册状态监听
      this.avRecorder.on('stateChange', (state: string) => {
        this.recorderState = state;
        console.info(`[录音] 状态变化: ${state}`);
      });

      // 错误监听
      this.avRecorder.on('error', (err: BusinessError) => {
        console.error(`[录音] 错误: ${err.message}`);
        this.isRecording = false;
        this.isPaused = false;
        this.stopTimer();
      });

      // 生成文件路径
      this.currentFilePath = this.generateFilePath();

      // 创建文件并获取 fd
      const file = fs.openSync(this.currentFilePath, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE);

      // 获取当前预设
      const preset = this.presets[this.currentPreset];

      // 配置录音参数
      const config: media.AVRecorderConfig = {
        audioSourceType: media.AudioSourceType.AUDIO_SOURCE_TYPE_MIC,
        profile: {
          audioBitrate: preset.bitrate,
          audioChannels: preset.channels,
          audioCodec: preset.audioCodec,
          audioSampleRate: preset.sampleRate,
          fileFormat: preset.fileFormat
        },
        url: `fd://${file.fd}`,
      };

      // prepare → prepared
      await this.avRecorder.prepare(config);

      // start → recording
      await this.avRecorder.start();

      // 更新状态
      this.isRecording = true;
      this.isPaused = false;
      this.recordDuration = 0;

      // 启动计时器
      this.startTimer();

      console.info(`[录音] 开始录制: ${this.currentFilePath}`);

    } catch (err) {
      const error = err as BusinessError;
      console.error(`[录音] 启动失败: ${error.message}`);
      this.isRecording = false;
    }
  }

  /**
   * 暂停录音
   */
  async pauseRecording(): Promise<void> {
    if (!this.avRecorder || this.recorderState !== 'recording') return;

    try {
      await this.avRecorder.pause();
      this.isPaused = true;
      this.stopTimer();
      console.info('[录音] 已暂停');
    } catch (err) {
      const error = err as BusinessError;
      console.error(`[录音] 暂停失败: ${error.message}`);
    }
  }

  /**
   * 恢复录音
   */
  async resumeRecording(): Promise<void> {
    if (!this.avRecorder || this.recorderState !== 'paused') return;

    try {
      await this.avRecorder.resume();
      this.isPaused = false;
      this.startTimer();
      console.info('[录音] 已恢复');
    } catch (err) {
      const error = err as BusinessError;
      console.error(`[录音] 恢复失败: ${error.message}`);
    }
  }

  /**
   * 停止录音
   */
  async stopRecording(): Promise<void> {
    if (!this.avRecorder) return;

    try {
      // 停止录音
      await this.avRecorder.stop();
      // 释放资源
      await this.avRecorder.release();
      this.avRecorder = null;

      // 更新状态
      this.isRecording = false;
      this.isPaused = false;
      this.stopTimer();

      // 将录音文件加入列表
      this.recordList.unshift(this.currentFilePath);
      console.info(`[录音] 已停止,文件保存至: ${this.currentFilePath}`);

    } catch (err) {
      const error = err as BusinessError;
      console.error(`[录音] 停止失败: ${error.message}`);
    }
  }

  /**
   * 释放录音器资源
   */
  async releaseRecorder(): Promise<void> {
    if (this.avRecorder) {
      try {
        if (this.recorderState === 'recording' || this.recorderState === 'paused') {
          await this.avRecorder.stop();
        }
        await this.avRecorder.release();
        this.avRecorder = null;
      } catch (err) {
        console.error('[录音] 释放失败');
      }
    }
    this.stopTimer();
    this.isRecording = false;
    this.isPaused = false;
  }

  /**
   * 启动计时器
   */
  private startTimer(): void {
    this.stopTimer();
    this.timer = setInterval(() => {
      this.recordDuration++;
    }, 1000) as unknown as number;
  }

  /**
   * 停止计时器
   */
  private stopTimer(): void {
    if (this.timer !== -1) {
      clearInterval(this.timer);
      this.timer = -1;
    }
  }

  /**
   * 格式化时长
   */
  formatDuration(seconds: number): string {
    const mins = Math.floor(seconds / 60);
    const secs = seconds % 60;
    return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
  }

  /**
   * 从路径中提取文件名
   */
  getFileName(path: string): string {
    return path.split('/').pop() || path;
  }

  build() {
    Column({ space: 20 }) {
      // 标题
      Text('🎙️ 录音器')
        .fontSize(24)
        .fontWeight(FontWeight.Bold)

      // 录音时长显示
      Text(this.formatDuration(this.recordDuration))
        .fontSize(64)
        .fontWeight(FontWeight.Bold)
        .fontColor(this.isRecording ? '#F44336' : '#fff')
        .animation({ duration: 500 })

      // 状态提示
      Text(
        this.isRecording
          ? (this.isPaused ? '已暂停' : '录音中...')
          : '点击开始录音'
      )
        .fontSize(16)
        .fontColor('#888')

      // 录音预设选择
      Row({ space: 12 }) {
        ForEach(Object.entries(this.presets), ([key, preset]: [string, RecordPreset]) => {
          Text(preset.name)
            .fontSize(14)
            .padding({ left: 16, right: 16, top: 8, bottom: 8 })
            .borderRadius(20)
            .backgroundColor(this.currentPreset === key ? '#4CAF50' : '#333')
            .fontColor(this.currentPreset === key ? '#fff' : '#aaa')
            .onClick(() => {
              if (!this.isRecording) {
                this.currentPreset = key;
              }
            })
        })
      }

      // 控制按钮
      Row({ space: 20 }) {
        if (!this.isRecording) {
          // 开始录音按钮
          Button('🎙 开始')
            .width(80)
            .height(80)
            .borderRadius(40)
            .backgroundColor('#F44336')
            .onClick(() => this.startRecording())
        } else {
          // 暂停/恢复按钮
          Button(this.isPaused ? '▶ 恢复' : '⏸ 暂停')
            .width(80)
            .height(80)
            .borderRadius(40)
            .backgroundColor(this.isPaused ? '#4CAF50' : '#FF9800')
            .onClick(() => {
              if (this.isPaused) {
                this.resumeRecording();
              } else {
                this.pauseRecording();
              }
            })

          // 停止按钮
          Button('⏹ 停止')
            .width(80)
            .height(80)
            .borderRadius(40)
            .backgroundColor('#666')
            .onClick(() => this.stopRecording())
        }
      }

      // 录音文件列表
      if (this.recordList.length > 0) {
        Text('录音文件')
          .fontSize(16)
          .fontWeight(FontWeight.Medium)
          .width('100%')
          .padding({ left: 16 })

        List({ space: 4 }) {
          ForEach(this.recordList, (filePath: string, index: number) => {
            ListItem() {
              Row({ space: 12 }) {
                Text('🎵')
                  .fontSize(20)
                Text(this.getFileName(filePath))
                  .fontSize(14)
                  .layoutWeight(1)
                  .maxLines(1)
                  .textOverflow({ overflow: TextOverflow.Ellipsis })
              }
              .width('100%')
              .padding(12)
              .borderRadius(8)
              .backgroundColor('#252540')
            }
          }, (filePath: string, index: number) => `${index}`)
        }
        .width('90%')
        .height(200)
      }
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .padding(20)
    .backgroundColor('#1a1a2e')
  }
}

3.3 录音文件管理器

录音文件会越来越多,需要一套完整的管理方案——自动命名、文件信息读取、清理过期文件:

import { fileIo as fs } from '@kit.CoreFileKit';
import { common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';

// 录音文件信息模型
interface RecordFileInfo {
  fileName: string;
  filePath: string;
  fileSize: number;       // 字节
  createTime: number;     // 时间戳
  duration: number;       // 时长(毫秒),可能无法获取
}

@Entry
@Component
struct RecordFileManager {
  @State recordFiles: RecordFileInfo[] = [];
  @State totalSize: string = '0 B';
  @State isRefreshing: boolean = false;

  aboutToAppear(): void {
    this.loadRecordFiles();
  }

  /**
   * 加载录音目录下所有文件
   */
  async loadRecordFiles(): Promise<void> {
    this.isRefreshing = true;
    const context = getContext(this) as common.Context;
    const recordDir = context.filesDir;

    try {
      // 列出目录下所有文件
      const files = fs.listFileSync(recordDir);
      const recordFileList: RecordFileInfo[] = [];

      for (const fileName of files) {
        // 只处理录音文件
        if (!fileName.startsWith('recording_')) continue;

        const filePath = `${recordDir}/${fileName}`;
        try {
          const stat = fs.statSync(filePath);
          recordFileList.push({
            fileName: fileName,
            filePath: filePath,
            fileSize: stat.size,
            createTime: stat.mtime?.valueOf() || 0,
            duration: 0  // 时长需要通过 AVPlayer 获取,此处省略
          });
        } catch (err) {
          console.warn(`[文件管理] 无法读取: ${fileName}`);
        }
      }

      // 按创建时间降序排列
      recordFileList.sort((a, b) => b.createTime - a.createTime);
      this.recordFiles = recordFileList;

      // 计算总大小
      const totalBytes = recordFileList.reduce((sum, f) => sum + f.fileSize, 0);
      this.totalSize = this.formatFileSize(totalBytes);

    } catch (err) {
      const error = err as BusinessError;
      console.error(`[文件管理] 加载失败: ${error.message}`);
    } finally {
      this.isRefreshing = false;
    }
  }

  /**
   * 删除指定录音文件
   */
  async deleteFile(filePath: string): Promise<void> {
    try {
      fs.unlinkSync(filePath);
      console.info(`[文件管理] 已删除: ${filePath}`);
      // 刷新列表
      await this.loadRecordFiles();
    } catch (err) {
      const error = err as BusinessError;
      console.error(`[文件管理] 删除失败: ${error.message}`);
    }
  }

  /**
   * 清理超过指定天数的录音文件
   */
  async cleanOldFiles(daysToKeep: number = 30): Promise<number> {
    const now = Date.now();
    const threshold = now - daysToKeep * 24 * 60 * 60 * 1000;
    let deletedCount = 0;

    for (const file of this.recordFiles) {
      if (file.createTime < threshold) {
        try {
          fs.unlinkSync(file.filePath);
          deletedCount++;
        } catch (err) {
          console.warn(`[文件管理] 清理失败: ${file.fileName}`);
        }
      }
    }

    console.info(`[文件管理] 清理完成,删除 ${deletedCount} 个文件`);
    await this.loadRecordFiles();
    return deletedCount;
  }

  /**
   * 清理所有录音文件
   */
  async cleanAllFiles(): Promise<void> {
    for (const file of this.recordFiles) {
      try {
        fs.unlinkSync(file.filePath);
      } catch (err) {
        console.warn(`[文件管理] 清理失败: ${file.fileName}`);
      }
    }
    await this.loadRecordFiles();
  }

  /**
   * 格式化文件大小
   */
  formatFileSize(bytes: number): string {
    if (bytes < 1024) return `${bytes} B`;
    if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
    if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
    return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
  }

  /**
   * 格式化时间
   */
  formatTime(timestamp: number): string {
    if (timestamp === 0) return '未知';
    const date = new Date(timestamp);
    return `${date.getMonth() + 1}/${date.getDate()} ${date.getHours()}:${date.getMinutes().toString().padStart(2, '0')}`;
  }

  build() {
    Column({ space: 16 }) {
      // 标题栏
      Row() {
        Text('📁 录音文件管理')
          .fontSize(22)
          .fontWeight(FontWeight.Bold)
          .layoutWeight(1)

        Text(`${this.recordFiles.length} 个文件 | ${this.totalSize}`)
          .fontSize(12)
          .fontColor('#888')
      }
      .width('100%')
      .padding({ left: 16, right: 16 })

      // 操作按钮
      Row({ space: 12 }) {
        Button('刷新')
          .fontSize(12)
          .height(32)
          .onClick(() => this.loadRecordFiles())

        Button('清理30天前')
          .fontSize(12)
          .height(32)
          .backgroundColor('#FF9800')
          .onClick(() => this.cleanOldFiles(30))

        Button('全部清理')
          .fontSize(12)
          .height(32)
          .backgroundColor('#F44336')
          .onClick(() => this.cleanAllFiles())
      }
      .width('100%')
      .padding({ left: 16, right: 16 })

      // 文件列表
      if (this.recordFiles.length === 0) {
        Column() {
          Text('📭')
            .fontSize(48)
          Text('暂无录音文件')
            .fontSize(14)
            .fontColor('#888')
            .margin({ top: 8 })
        }
        .layoutWeight(1)
        .justifyContent(FlexAlign.Center)
      } else {
        List({ space: 8 }) {
          ForEach(this.recordFiles, (file: RecordFileInfo, index: number) => {
            ListItem() {
              Row({ space: 12 }) {
                // 文件图标
                Text('🎵')
                  .fontSize(28)

                // 文件信息
                Column({ space: 4 }) {
                  Text(file.fileName)
                    .fontSize(14)
                    .fontWeight(FontWeight.Medium)
                    .maxLines(1)
                    .textOverflow({ overflow: TextOverflow.Ellipsis })

                  Row({ space: 12 }) {
                    Text(this.formatFileSize(file.fileSize))
                      .fontSize(12)
                      .fontColor('#888')

                    Text(this.formatTime(file.createTime))
                      .fontSize(12)
                      .fontColor('#888')
                  }
                }
                .alignItems(HorizontalAlign.Start)
                .layoutWeight(1)

                // 删除按钮
                Text('🗑️')
                  .fontSize(20)
                  .onClick(() => this.deleteFile(file.filePath))
              }
              .width('100%')
              .padding(12)
              .borderRadius(8)
              .backgroundColor('#252540')
            }
            .swipeAction({ end: this.deleteSwipeAction(file) })
          }, (file: RecordFileInfo) => file.fileName)
        }
        .width('100%')
        .layoutWeight(1)
        .padding({ left: 12, right: 12 })
      }
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#1a1a2e')
  }

  /**
   * 滑动删除组件
   */
  @Builder
  deleteSwipeAction(file: RecordFileInfo) {
    Button('删除')
      .backgroundColor('#F44336')
      .height('100%')
      .onClick(() => this.deleteFile(file.filePath))
  }
}

四、踩坑与注意事项

4.1 权限被动态收回

问题:用户在录音过程中去系统设置关闭了麦克风权限,导致录音失败但应用没有感知。

解决方案:监听权限变化,在录音前再次检查权限。

// 在 Ability 的 onPageShow 中检查权限
onPageShow(): void {
  this.checkPermission();
}

// 或使用定时检查(录音过程中)
startPermissionMonitor(): void {
  this.permissionTimer = setInterval(async () => {
    if (this.isRecording) {
      const granted = await this.checkPermission();
      if (!granted) {
        // 权限被收回,立即停止录音
        await this.stopRecording();
        promptAction.showToast({ message: '麦克风权限已被收回,录音已停止' });
      }
    }
  }, 3000);
}

4.2 fd 文件描述符泄漏

问题:使用 fd:// 方式录音时,AVRecorder 内部持有 fd,如果异常退出没有释放,fd 就泄漏了。

解决方案:在 release() 之前确保 fd 被正确关闭。

// ❌ 错误写法:fd 未关闭
const file = fs.openSync(filePath, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE);
await this.avRecorder.prepare({ url: `fd://${file.fd}`, ... });
// 如果这里异常了,file.fd 就泄漏了

// ✅ 正确写法:记录 fd,在释放时关闭
private recordFd: number = -1;

async startRecording(): Promise<void> {
  const file = fs.openSync(filePath, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE);
  this.recordFd = file.fd;
  // ...
}

async releaseRecorder(): Promise<void> {
  if (this.avRecorder) {
    await this.avRecorder.release();
  }
  if (this.recordFd !== -1) {
    fs.closeSync(this.recordFd);
    this.recordFd = -1;
  }
}

4.3 录音参数不兼容

问题:某些编码格式和容器格式不兼容,导致 prepare() 失败。

常见不兼容组合:

  • AAC 编码 + AMR 容器 → ❌ 不兼容
  • AMR_NB 编码 + MPEG_4 容器 → ❌ 不兼容
  • AAC 编码 + MPEG_4 容器 → ✅ 兼容
  • AMR_WB 编码 + THREE_GPP 容器 → ✅ 兼容

解决方案:使用上面"常见配置组合"表格中的推荐搭配,不要随意混搭。

4.4 存储空间不足

问题:录音过程中磁盘空间满了,导致写入失败。

解决方案:录音前检查可用空间,录音中监控文件大小。

import { statvfs } from '@kit.CoreFileKit';

async checkAvailableSpace(): Promise<boolean> {
  const context = getContext(this) as common.Context;
  const stat = await statvfs.statfs(context.filesDir);
  const freeSize = stat.bfree * stat.bsize; // 可用字节数
  const minRequired = 10 * 1024 * 1024; // 至少需要 10MB

  if (freeSize < minRequired) {
    promptAction.showToast({ message: '存储空间不足,请清理后重试' });
    return false;
  }
  return true;
}

4.5 来电中断录音

问题:录音过程中来电,系统可能强制停止录音。

解决方案:监听来电事件,主动暂停录音;通话结束后恢复。

import { call } from '@kit.TelephonyKit';

// 监听通话状态
call.on('callStateChange', (data) => {
  if (data.state === call.CallState.CALL_STATE_OFFHOOK) {
    // 来电接通,暂停录音
    if (this.isRecording && !this.isPaused) {
      this.pauseRecording();
    }
  } else if (data.state === call.CallState.CALL_STATE_IDLE) {
    // 通话结束,恢复录音
    if (this.isRecording && this.isPaused) {
      this.resumeRecording();
    }
  }
});

五、HarmonyOS 6 适配

5.1 API 变更

变更项 HarmonyOS 5 HarmonyOS 6
AVRecorder 创建 media.createAVRecorder() 保持一致,新增配置选项
音频源类型 AUDIO_SOURCE_TYPE_MIC 新增 AUDIO_SOURCE_TYPE_VOICE_RECOGNITION(语音识别优化)
编码格式 AAC / AMR 新增 OPUS 编码支持
文件格式 MPEG_4 / AMR / THREE_GPP 新增 WEBM 容器格式
降噪处理 无内置支持 新增 noiseSuppression 配置项

5.2 迁移指南

// HarmonyOS 5 写法
const config: media.AVRecorderConfig = {
  audioSourceType: media.AudioSourceType.AUDIO_SOURCE_TYPE_MIC,
  profile: {
    audioBitrate: 64000,
    audioChannels: 1,
    audioCodec: media.CodecMimeType.AUDIO_AAC,
    audioSampleRate: 44100,
    fileFormat: media.ContainerFormatType.CFT_MPEG_4
  },
  url: `fd://${fd}`
};

// HarmonyOS 6 写法(新增降噪和自动增益)
const config: media.AVRecorderConfig = {
  audioSourceType: media.AudioSourceType.AUDIO_SOURCE_TYPE_VOICE_RECOGNITION,
  profile: {
    audioBitrate: 64000,
    audioChannels: 1,
    audioCodec: media.CodecMimeType.AUDIO_AAC,
    audioSampleRate: 44100,
    fileFormat: media.ContainerFormatType.CFT_MPEG_4
  },
  url: `fd://${fd}`,
  // HarmonyOS 6 新增
  noiseSuppression: true,   // 降噪
  autoGainControl: true     // 自动增益
};

5.3 新特性

  • OPUS 编码:低延迟、高质量,适合实时通信场景
  • WEBM 容器:与 Web 平台更好的兼容性
  • 语音识别优化源:专门为语音识别场景优化的音频采集,自动降噪和增益
  • 录音元数据:支持在录音文件中写入元数据(标题、作者等)

六、总结

mindmap
  root((音频录制))
    权限管理
      麦克风权限
        checkAccessToken
        requestPermissionsFromUser
        引导去系统设置
      权限监听
        动态收回检测
        录音前二次检查
    AVRecorder
      状态机
        idle → prepared → recording
        recording ↔ paused
        recording/paused → stopped
        任何状态 → released
      核心API
        createAVRecorder
        prepare(config)
        start / pause / resume / stop
        release
      配置参数
        audioSourceType
        audioCodec / fileFormat
        sampleRate / bitrate / channels
    录音配置
      高质量录音
        AAC + MPEG_4
        48kHz / 128kbps / 立体声
      语音备忘
        AAC + MPEG_4
        44.1kHz / 64kbps / 单声道
      电话录音
        AMR_WB + 3GP
        16kHz / 23.85kbps / 单声道
    文件管理
      自动命名
        recording_时间戳.ext
      文件信息
        大小 / 创建时间
      清理策略
        按天数清理
        空间不足检测
    异常处理
      来电中断
        暂停/恢复
      fd泄漏
        release时关闭fd
      参数不兼容
        使用推荐配置组合

核心要点回顾:

  1. 权限先行:麦克风是敏感权限,必须在录音前检查和申请,还要处理用户拒绝和动态收回的场景
  2. 状态机驱动:AVRecorder 和 AVPlayer 一样是状态驱动的,prepare → start → pause/stop → release 是标准流程
  3. 配置要匹配:编码格式和容器格式必须兼容,推荐使用表格中的标准组合
  4. 文件管理:自动命名、及时清理、fd 泄漏防护,是生产级录音功能的基本素养
  5. 异常兜底:来电中断、权限收回、存储不足,这些边界情况都要有预案
【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。