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个月内不可修改。