HarmonyOS开发音频录制:AVRecorder、权限申请与录音文件管理全攻略
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
参数不兼容
使用推荐配置组合
核心要点回顾:
- 权限先行:麦克风是敏感权限,必须在录音前检查和申请,还要处理用户拒绝和动态收回的场景
- 状态机驱动:AVRecorder 和 AVPlayer 一样是状态驱动的,
prepare → start → pause/stop → release是标准流程 - 配置要匹配:编码格式和容器格式必须兼容,推荐使用表格中的标准组合
- 文件管理:自动命名、及时清理、fd 泄漏防护,是生产级录音功能的基本素养
- 异常兜底:来电中断、权限收回、存储不足,这些边界情况都要有预案
- 点赞
- 收藏
- 关注作者
评论(0)