HarmonyOS开发中的视频录制:AVRecorder从入门到多路录制的全流程实战
HarmonyOS开发中的视频录制:AVRecorder从入门到多路录制的全流程实战
核心要点:掌握HarmonyOS视频录制核心API——AVRecorder,理解录制配置参数、权限申请、录制状态机,学会实现多路录制和录制文件管理。
一、背景与动机
想象一下这个场景:你正在开发一款短视频App,用户打开摄像头,对准自己,点击"录制"按钮,开始拍摄15秒的短视频。看起来很简单对吧?但背后涉及的事情可不少——摄像头画面怎么采集?麦克风声音怎么同步?录出来的视频保存在哪里?录制的分辨率和码率怎么配置?如果用户录到一半切到后台怎么办?
视频录制,是音视频开发中"牵一发动全身"的模块。它需要协调摄像头(视频源)、麦克风(音频源)、编码器、文件写入等多个环节,任何一个环节配合不好,就会出现画面卡顿、音画不同步、文件损坏等问题。
HarmonyOS提供了AVRecorder作为视频录制的核心API,配合CameraKit实现摄像头画面采集,形成完整的录制链路。今天我们就来把这个链路彻底打通。
二、核心原理
2.1 视频录制的完整链路
视频录制本质上是一个"采集→编码→封装"的过程:
flowchart LR
classDef primary fill:#4FC3F7,stroke:#0288D1,color:#000
classDef warning fill:#FFB74D,stroke:#F57C00,color:#000
classDef error fill:#EF5350,stroke:#C62828,color:#fff
classDef info fill:#81C784,stroke:#388E3C,color:#000
classDef purple fill:#CE93D8,stroke:#7B1FA2,color:#000
A[摄像头 Camera]:::primary --> C[视频编码器]:::warning
B[麦克风 Mic]:::purple --> D[音频编码器]:::warning
C --> E[封装器 Muxer]:::info
D --> E
E --> F[输出文件 MP4/M4A]:::primary
更详细地说:
- 采集:通过Camera获取视频帧,通过AudioCapturer/Mic获取音频PCM数据
- 编码:视频帧编码为H.264/H.265,音频PCM编码为AAC
- 封装:将编码后的音视频流打包成MP4容器格式
- 写入:将封装后的数据写入文件
2.2 AVRecorder状态机
和AVPlayer一样,AVRecorder也有严格的状态机:
stateDiagram-v2
[*] --> 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 --> error : 出错
error --> idle : reset()
关键状态说明:
| 状态 | 含义 | 可执行操作 |
|---|---|---|
| idle | 空闲 | prepare() |
| prepared | 配置完成,准备录制 | start() |
| recording | 录制中 | pause()、stop() |
| paused | 暂停录制 | resume()、stop() |
| stopped | 停止录制 | prepare()(重新配置)、reset() |
| error | 错误 | reset() |
2.3 录制配置参数详解
AVRecorder的配置是录制的核心,决定了输出视频的质量和大小:
// AVRecorder配置结构
interface AVRecorderConfig {
audioSourceType?: AudioSourceType // 音频源类型
videoSourceType?: VideoSourceType // 视频源类型
profile: AVRecorderProfile // 录制配置档案
url: string // 输出文件路径
rotation?: number // 旋转角度
location?: Location // 地理位置
}
// 录制配置档案
interface AVRecorderProfile {
audioBitrate?: number // 音频码率(bps)
audioChannels?: number // 音频声道数
audioCodec?: CodecMimeType // 音频编码格式
audioSampleRate?: number // 音频采样率
fileFormat?: ContainerFormatType // 容器格式
videoBitrate?: number // 视频码率(bps)
videoCodec?: CodecMimeType // 视频编码格式
videoFrameWidth?: number // 视频宽度
videoFrameHeight?: number // 视频高度
videoFrameRate?: number // 视频帧率
}
三、代码实战
3.1 基础实战:纯音频录制
先从最简单的纯音频录制开始,理解AVRecorder的基本用法:
// AudioRecordDemo.ets
// 纯音频录制示例
import { media } from '@kit.MediaKit'
import { fileIo as fs } from '@kit.CoreFileKit'
import { common } from '@kit.AbilityKit'
@Entry
@Component
struct AudioRecordDemo {
private avRecorder: media.AVRecorder | null = null
private recordFilePath: string = ''
@State recordState: string = 'idle'
@State recordDuration: number = 0
@State isRecording: boolean = false
@State isPaused: boolean = false
@State statusMessage: string = '准备就绪'
aboutToAppear() {
this.initRecorder()
}
aboutToDisappear() {
this.releaseRecorder()
}
// 初始化录制器
private async initRecorder() {
try {
// 创建AVRecorder实例
this.avRecorder = await media.createAVRecorder()
// 监听状态变化
this.avRecorder.on('stateChange', (state: string) => {
this.recordState = state
console.info(`[AVRecorder] 状态: ${state}`)
})
// 监听错误
this.avRecorder.on('error', (err) => {
this.statusMessage = `录制错误: ${err.message}`
console.error(`[AVRecorder] 错误: ${JSON.stringify(err)}`)
})
// 准备输出文件路径
const context = getContext(this) as common.UIAbilityContext
const cacheDir = context.cacheDir
this.recordFilePath = `${cacheDir}/audio_record_${Date.now()}.m4a`
this.statusMessage = '录制器已初始化'
} catch (err) {
this.statusMessage = `初始化失败: ${err}`
}
}
// 配置并准备录制
private async prepareRecording() {
if (!this.avRecorder) return
try {
// 配置录制参数
const config: media.AVRecorderConfig = {
audioSourceType: media.AudioSourceType.AUDIO_SOURCE_TYPE_MIC, // 麦克风
profile: {
audioBitrate: 128000, // 音频码率 128kbps
audioChannels: 2, // 双声道
audioCodec: media.CodecMimeType.AUDIO_AAC, // AAC编码
audioSampleRate: 44100, // 44.1kHz采样率
fileFormat: media.ContainerFormatType.CFT_MPEG_4A // M4A容器
},
url: `fd://${this.getFdPath(this.recordFilePath)}` // 文件描述符路径
}
await this.avRecorder.prepare(config)
this.statusMessage = '录制准备完成'
} catch (err) {
this.statusMessage = `准备失败: ${err}`
}
}
// 获取文件的fd路径
private getFdPath(filePath: string): number {
// 打开文件获取文件描述符
const file = fs.openSync(filePath, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE)
return file.fd
}
// 开始录制
private async startRecording() {
if (!this.avRecorder) return
try {
await this.avRecorder.start()
this.isRecording = true
this.isPaused = false
this.statusMessage = '正在录制...'
} catch (err) {
this.statusMessage = `开始录制失败: ${err}`
}
}
// 暂停录制
private async pauseRecording() {
if (!this.avRecorder) return
try {
await this.avRecorder.pause()
this.isPaused = true
this.statusMessage = '录制已暂停'
} catch (err) {
this.statusMessage = `暂停失败: ${err}`
}
}
// 恢复录制
private async resumeRecording() {
if (!this.avRecorder) return
try {
await this.avRecorder.resume()
this.isPaused = false
this.statusMessage = '继续录制...'
} catch (err) {
this.statusMessage = `恢复失败: ${err}`
}
}
// 停止录制
private async stopRecording() {
if (!this.avRecorder) return
try {
await this.avRecorder.stop()
this.isRecording = false
this.isPaused = false
this.statusMessage = `录制完成,文件: ${this.recordFilePath}`
} catch (err) {
this.statusMessage = `停止失败: ${err}`
}
}
// 释放录制器
private async releaseRecorder() {
if (!this.avRecorder) return
try {
if (this.isRecording) {
await this.avRecorder.stop()
}
await this.avRecorder.release()
this.avRecorder = null
} catch (err) {
console.error(`[AVRecorder] 释放失败: ${err}`)
}
}
build() {
Column() {
// 状态显示
Text(this.statusMessage)
.fontSize(18)
.fontColor('#ffffff')
.margin({ bottom: 24 })
// 录制时长
Text(this.formatDuration(this.recordDuration))
.fontSize(48)
.fontWeight(FontWeight.Bold)
.fontColor(this.isRecording ? '#EF5350' : '#ffffff')
.margin({ bottom: 32 })
// 录制指示器
if (this.isRecording && !this.isPaused) {
Row() {
Circle()
.width(12)
.height(12)
.fill('#EF5350')
Text('REC')
.fontSize(14)
.fontColor('#EF5350')
.margin({ left: 8 })
}
.margin({ bottom: 16 })
}
// 控制按钮
Row() {
if (!this.isRecording) {
Button('开始录制')
.backgroundColor('#4CAF50')
.onClick(async () => {
await this.prepareRecording()
await this.startRecording()
})
} else {
if (this.isPaused) {
Button('继续')
.backgroundColor('#4CAF50')
.onClick(() => this.resumeRecording())
} else {
Button('暂停')
.backgroundColor('#FF9800')
.onClick(() => this.pauseRecording())
}
Button('停止')
.backgroundColor('#F44336')
.onClick(() => this.stopRecording())
}
}
.justifyContent(FlexAlign.SpaceEvenly)
.width('100%')
.padding(16)
}
.width('100%')
.height('100%')
.backgroundColor('#1a1a2e')
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
}
private formatDuration(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')}`
}
}
3.2 进阶实战:视频录制(摄像头+麦克风)
视频录制需要协调Camera和AVRecorder,这是最常见的使用场景:
// VideoRecordDemo.ets
// 视频录制示例:摄像头画面 + 麦克风音频
import { media } from '@kit.MediaKit'
import { camera } from '@kit.CameraKit'
import { fileIo as fs } from '@kit.CoreFileKit'
import { common } from '@kit.AbilityKit'
@Entry
@Component
struct VideoRecordDemo {
// 摄像头相关
private cameraManager: camera.CameraManager | null = null
private cameras: camera.CameraDevice[] = []
private currentCamera: camera.CameraDevice | null = null
private captureSession: camera.PhotoSession | null = null
private videoOutput: camera.VideoOutput | null = null
// 录制器
private avRecorder: media.AVRecorder | null = null
private recordFilePath: string = ''
private surfaceId: string = ''
// 状态
@State isRecording: boolean = false
@State isPaused: boolean = false
@State recordDuration: number = 0
@State statusMessage: string = '正在初始化摄像头...'
@State cameraSwitched: boolean = false // 是否切换了前后摄像头
aboutToAppear() {
this.initCamera()
}
aboutToDisappear() {
this.releaseAll()
}
// 初始化摄像头
private async initCamera() {
try {
// 获取摄像头管理器
const context = getContext(this) as common.UIAbilityContext
this.cameraManager = camera.getCameraManager(context)
// 获取可用摄像头列表
this.cameras = this.cameraManager.getSupportedCameras()
if (this.cameras.length === 0) {
this.statusMessage = '未找到可用摄像头'
return
}
// 默认使用后置摄像头
this.currentCamera = this.cameras[0]
this.statusMessage = '摄像头就绪'
} catch (err) {
this.statusMessage = `摄像头初始化失败: ${err}`
}
}
// 配置并开始视频录制
private async startVideoRecord(xComponentSurfaceId: string) {
if (!this.cameraManager || !this.currentCamera) {
this.statusMessage = '摄像头未就绪'
return
}
try {
// 第一步:创建AVRecorder
this.avRecorder = await media.createAVRecorder()
// 第二步:准备输出文件
const context = getContext(this) as common.UIAbilityContext
this.recordFilePath = `${context.cacheDir}/video_${Date.now()}.mp4`
const file = fs.openSync(this.recordFilePath, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE)
// 第三步:配置AVRecorder
const recorderConfig: media.AVRecorderConfig = {
audioSourceType: media.AudioSourceType.AUDIO_SOURCE_TYPE_MIC,
videoSourceType: media.VideoSourceType.VIDEO_SOURCE_TYPE_SURFACE_YUV,
profile: {
audioBitrate: 128000,
audioChannels: 2,
audioCodec: media.CodecMimeType.AUDIO_AAC,
audioSampleRate: 44100,
fileFormat: media.ContainerFormatType.CFT_MPEG_4,
videoBitrate: 2000000, // 视频码率 2Mbps
videoCodec: media.CodecMimeType.VIDEO_AVC, // H.264编码
videoFrameWidth: 1280, // 视频宽度
videoFrameHeight: 720, // 视频高度
videoFrameRate: 30 // 帧率30fps
},
url: `fd://${file.fd}`,
rotation: 0 // 旋转角度,竖屏录制可设为90
}
await this.avRecorder.prepare(recorderConfig)
// 第四步:获取录制用的Surface ID
const recorderSurfaceId = await this.avRecorder.getInputSurface()
// 第五步:配置摄像头会话
this.setupCameraSession(recorderSurfaceId, xComponentSurfaceId)
// 第六步:开始录制
await this.avRecorder.start()
this.isRecording = true
this.statusMessage = '正在录制视频...'
} catch (err) {
this.statusMessage = `录制启动失败: ${err}`
console.error(`[VideoRecord] 启动失败: ${err}`)
}
}
// 配置摄像头会话
private setupCameraSession(recorderSurfaceId: string, previewSurfaceId: string) {
if (!this.cameraManager || !this.currentCamera) return
try {
// 创建视频输出
const videoProfile = this.getVideoProfile()
this.videoOutput = this.cameraManager.createVideoOutput(videoProfile, recorderSurfaceId)
// 创建预览输出(用于显示摄像头画面)
const previewOutput = this.cameraManager.createPreviewOutput(
this.getPreviewProfile(),
previewSurfaceId
)
// 创建会话并绑定输入输出
const session = this.cameraManager.createVideoSession(
this.currentCamera,
previewOutput,
this.videoOutput
)
// 开始会话
session.start()
} catch (err) {
console.error(`[Camera] 会话配置失败: ${err}`)
}
}
// 获取视频配置档案
private getVideoProfile(): camera.VideoProfile {
const profiles = this.cameraManager?.getSupportedOutputCapability(this.currentCamera!)
const videoProfile = profiles?.videoProfiles?.[0]
return videoProfile!
}
// 获取预览配置档案
private getPreviewProfile(): camera.Profile {
const profiles = this.cameraManager?.getSupportedOutputCapability(this.currentCamera!)
const previewProfile = profiles?.previewProfiles?.[0]
return previewProfile!
}
// 停止录制
private async stopRecording() {
try {
if (this.avRecorder && this.isRecording) {
await this.avRecorder.stop()
await this.avRecorder.release()
this.avRecorder = null
}
if (this.videoOutput) {
this.videoOutput.stop()
}
this.isRecording = false
this.isPaused = false
this.statusMessage = `录制完成: ${this.recordFilePath}`
} catch (err) {
this.statusMessage = `停止录制失败: ${err}`
}
}
// 切换前后摄像头
private switchCamera() {
if (this.cameras.length < 2) {
this.statusMessage = '只有一个摄像头,无法切换'
return
}
// 切换到另一个摄像头
const currentIndex = this.cameras.indexOf(this.currentCamera!)
const nextIndex = (currentIndex + 1) % this.cameras.length
this.currentCamera = this.cameras[nextIndex]
this.cameraSwitched = !this.cameraSwitched
this.statusMessage = `已切换到${this.cameraSwitched ? '前置' : '后置'}摄像头`
}
// 释放所有资源
private async releaseAll() {
try {
if (this.avRecorder) {
if (this.isRecording) {
await this.avRecorder.stop()
}
await this.avRecorder.release()
}
} catch (err) {
console.error(`[VideoRecord] 释放失败: ${err}`)
}
}
build() {
Column() {
// 摄像头预览区域
XComponent({
id: 'cameraPreview',
type: XComponentType.SURFACE,
controller: new XComponentController()
})
.width('100%')
.height(400)
.onLoad(() => {
const surfaceId = 'cameraPreview' // 实际需获取XComponent的SurfaceId
// 此处可以开始预览
})
// 状态信息
Text(this.statusMessage)
.fontSize(16)
.fontColor('#aaaaaa')
.margin({ top: 16 })
// 录制时长
if (this.isRecording) {
Text(this.formatDuration(this.recordDuration))
.fontSize(36)
.fontWeight(FontWeight.Bold)
.fontColor('#EF5350')
.margin({ top: 8 })
}
// 控制按钮
Row() {
// 切换摄像头
Button('🔄 切换')
.onClick(() => this.switchCamera())
// 录制按钮
if (!this.isRecording) {
Button('⏺ 开始录制')
.backgroundColor('#EF5350')
.width(80)
.height(80)
.borderRadius(40)
.onClick(() => {
// 开始录制(需要传入Surface ID)
this.startVideoRecord(this.surfaceId)
})
} else {
Button('⏹ 停止')
.backgroundColor('#F44336')
.onClick(() => this.stopRecording())
}
}
.width('100%')
.justifyContent(FlexAlign.SpaceEvenly)
.padding(24)
}
.width('100%')
.height('100%')
.backgroundColor('#1a1a2e')
}
private formatDuration(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')}`
}
}
3.3 高级实战:录制文件管理器
在实际项目中,录制文件需要统一管理——自动命名、分类存储、清理过期文件、导出分享等:
// RecordFileManager.ets
// 录制文件管理器
import { fileIo as fs, fileUri } from '@kit.CoreFileKit'
import { common } from '@kit.AbilityKit'
import { buffer } from '@kit.ArkTS'
// 录制文件信息
export interface RecordFileInfo {
fileName: string // 文件名
filePath: string // 完整路径
fileSize: number // 文件大小(字节)
createTime: number // 创建时间戳
duration: number // 录制时长(毫秒)
type: 'video' | 'audio' // 类型
width?: number // 视频宽度
height?: number // 视频高度
}
// 录制文件管理器
export class RecordFileManager {
private context: common.UIAbilityContext
private recordDir: string
private maxStorageMB: number = 500 // 最大存储空间,默认500MB
constructor(context: common.UIAbilityContext) {
this.context = context
// 使用filesDir下的record子目录
this.recordDir = `${context.filesDir}/record`
this.ensureDirExists(this.recordDir)
}
// 确保目录存在
private ensureDirExists(dirPath: string) {
try {
if (!fs.accessSync(dirPath)) {
fs.mkdirSync(dirPath, true)
}
} catch (err) {
console.error(`[FileManager] 创建目录失败: ${err}`)
}
}
// 生成录制文件路径
generateRecordPath(type: 'video' | 'audio'): string {
const now = new Date()
const dateStr = `${now.getFullYear()}${(now.getMonth() + 1).toString().padStart(2, '0')}${now.getDate().toString().padStart(2, '0')}`
const timeStr = `${now.getHours().toString().padStart(2, '0')}${now.getMinutes().toString().padStart(2, '0')}${now.getSeconds().toString().padStart(2, '0')}`
const ext = type === 'video' ? 'mp4' : 'm4a'
const fileName = `${type}_${dateStr}_${timeStr}.${ext}`
return `${this.recordDir}/${fileName}`
}
// 获取所有录制文件列表
getRecordFiles(): RecordFileInfo[] {
const files: RecordFileInfo[] = []
try {
const entries = fs.listFileSync(this.recordDir)
for (const entry of entries) {
const filePath = `${this.recordDir}/${entry}`
const stat = fs.statSync(filePath)
// 只处理mp4和m4a文件
if (entry.endsWith('.mp4') || entry.endsWith('.m4a')) {
const type = entry.endsWith('.mp4') ? 'video' : 'audio'
files.push({
fileName: entry,
filePath: filePath,
fileSize: stat.size,
createTime: stat.mtime !== undefined ? Number(stat.mtime) * 1000 : Date.now(),
duration: 0, // 需要通过媒体API获取,这里暂设0
type: type
})
}
}
// 按创建时间倒序排列(最新的在前)
files.sort((a, b) => b.createTime - a.createTime)
} catch (err) {
console.error(`[FileManager] 获取文件列表失败: ${err}`)
}
return files
}
// 删除指定录制文件
deleteRecordFile(filePath: string): boolean {
try {
if (fs.accessSync(filePath)) {
fs.unlinkSync(filePath)
console.info(`[FileManager] 已删除: ${filePath}`)
return true
}
} catch (err) {
console.error(`[FileManager] 删除失败: ${err}`)
}
return false
}
// 清理过期文件(超过指定天数的文件)
cleanExpiredFiles(expireDays: number): number {
const now = Date.now()
const expireMs = expireDays * 24 * 60 * 60 * 1000
let deletedCount = 0
const files = this.getRecordFiles()
for (const file of files) {
if (now - file.createTime > expireMs) {
if (this.deleteRecordFile(file.filePath)) {
deletedCount++
}
}
}
console.info(`[FileManager] 清理了${deletedCount}个过期文件`)
return deletedCount
}
// 检查并管理存储空间
checkStorageAndClean(): boolean {
const files = this.getRecordFiles()
let totalSize = 0
for (const file of files) {
totalSize += file.fileSize
}
const totalSizeMB = totalSize / (1024 * 1024)
if (totalSizeMB > this.maxStorageMB) {
// 超出限制,删除最旧的文件
const sortedFiles = [...files].sort((a, b) => a.createTime - b.createTime)
let freedSize = 0
const targetFreeMB = totalSizeMB - this.maxStorageMB * 0.8 // 清理到80%
for (const file of sortedFiles) {
if (freedSize >= targetFreeMB * 1024 * 1024) break
if (this.deleteRecordFile(file.filePath)) {
freedSize += file.fileSize
}
}
return true // 进行了清理
}
return false // 无需清理
}
// 获取存储使用情况
getStorageInfo(): { usedMB: number; maxMB: number; fileCount: number } {
const files = this.getRecordFiles()
let totalSize = 0
for (const file of files) {
totalSize += file.fileSize
}
return {
usedMB: Math.round(totalSize / (1024 * 1024) * 100) / 100,
maxMB: this.maxStorageMB,
fileCount: files.length
}
}
// 格式化文件大小
static 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`
}
}
// ====== 录制文件管理UI ======
@Entry
@Component
struct RecordFileManagerDemo {
private fileManager: RecordFileManager | null = null
@State recordFiles: RecordFileInfo[] = []
@State storageInfo: { usedMB: number; maxMB: number; fileCount: number } = {
usedMB: 0, maxMB: 500, fileCount: 0
}
aboutToAppear() {
const context = getContext(this) as common.UIAbilityContext
this.fileManager = new RecordFileManager(context)
this.refreshFileList()
}
// 刷新文件列表
private refreshFileList() {
if (!this.fileManager) return
this.recordFiles = this.fileManager.getRecordFiles()
this.storageInfo = this.fileManager.getStorageInfo()
}
build() {
Column() {
// 存储信息
Row() {
Text(`存储: ${this.storageInfo.usedMB}MB / ${this.storageInfo.maxMB}MB`)
.fontSize(14)
.fontColor('#aaaaaa')
Text(`共 ${this.storageInfo.fileCount} 个文件`)
.fontSize(14)
.fontColor('#aaaaaa')
.margin({ left: 16 })
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.padding(16)
// 存储进度条
Progress({
value: this.storageInfo.usedMB,
total: this.storageInfo.maxMB,
type: ProgressType.Linear
})
.width('90%')
.color('#4FC3F7')
.backgroundColor('#333333')
// 操作按钮
Row() {
Button('刷新')
.onClick(() => this.refreshFileList())
Button('清理过期(7天)')
.backgroundColor('#FF9800')
.onClick(() => {
this.fileManager?.cleanExpiredFiles(7)
this.refreshFileList()
})
Button('空间管理')
.backgroundColor('#9C27B0')
.onClick(() => {
this.fileManager?.checkStorageAndClean()
this.refreshFileList()
})
}
.width('100%')
.justifyContent(FlexAlign.SpaceEvenly)
.padding(16)
// 文件列表
List() {
ForEach(this.recordFiles, (file: RecordFileInfo) => {
ListItem() {
Row() {
// 类型图标
Text(file.type === 'video' ? '🎬' : '🎵')
.fontSize(24)
.margin({ right: 12 })
// 文件信息
Column() {
Text(file.fileName)
.fontSize(14)
.fontColor('#ffffff')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(`${RecordFileManager.formatFileSize(file.fileSize)} · ${this.formatTimestamp(file.createTime)}`)
.fontSize(12)
.fontColor('#888888')
.margin({ top: 4 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
// 删除按钮
Button('删除')
.fontSize(12)
.height(28)
.backgroundColor('#EF5350')
.onClick(() => {
if (this.fileManager?.deleteRecordFile(file.filePath)) {
this.refreshFileList()
}
})
}
.width('100%')
.padding(12)
}
})
}
.layoutWeight(1)
.width('100%')
}
.width('100%')
.height('100%')
.backgroundColor('#1a1a2e')
}
private formatTimestamp(ts: number): string {
const date = new Date(ts)
return `${date.getMonth() + 1}/${date.getDate()} ${date.getHours()}:${date.getMinutes().toString().padStart(2, '0')}`
}
}
四、踩坑与注意事项
4.1 权限申请
坑:视频录制需要多个权限,忘记任何一个都会导致录制失败。
解:在module.json5中声明权限,并在代码中动态申请:
// module.json5
{
"module": {
"requestPermissions": [
{ "name": "ohos.permission.CAMERA" },
{ "name": "ohos.permission.MICROPHONE" },
{ "name": "ohos.permission.MEDIA_LOCATION" }
]
}
}
// 动态申请权限
import { abilityAccessCtrl, bundleManager } from '@kit.AbilityKit'
async function requestPermissions(context: common.UIAbilityContext): Promise<boolean> {
const atManager = abilityAccessCtrl.createAtManager()
const permissions: string[] = [
'ohos.permission.CAMERA',
'ohos.permission.MICROPHONE'
]
try {
const result = await atManager.requestPermissionsFromUser(context, permissions)
// 检查是否所有权限都已授予
return result.authResults.every(r => r === 0)
} catch (err) {
console.error(`权限申请失败: ${err}`)
return false
}
}
4.2 文件路径问题
坑:AVRecorder的url参数支持fd://格式,但不支持直接使用文件路径字符串。
解:必须先用fs.openSync()打开文件获取fd,然后使用fd://${fd}格式:
// ❌ 错误做法
const config = {
url: '/data/storage/el2/base/files/video.mp4' // 不支持直接路径
}
// ✅ 正确做法
const file = fs.openSync(filePath, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE)
const config = {
url: `fd://${file.fd}` // 使用fd路径
}
4.3 Surface获取时机
坑:在AVRecorder prepare之后才能调用getInputSurface()获取录制用的Surface,过早调用会返回空值。
解:严格按照状态机顺序操作——先prepare,再getInputSurface,再start:
await avRecorder.prepare(config) // 先准备
const surfaceId = await avRecorder.getInputSurface() // 再获取Surface
// 将surfaceId传给Camera的VideoOutput
setupCameraWithSurface(surfaceId)
await avRecorder.start() // 最后开始录制
4.4 录制过程中的异常处理
坑:录制过程中如果摄像头被占用、存储空间不足或系统杀后台,可能导致录制文件损坏。
解:
- 监听AVRecorder的error事件,出错时及时stop和release
- 在应用进入后台时主动暂停录制
- 录制完成后验证文件完整性
4.5 多路录制的限制
坑:部分设备不支持同时录制多路视频(如前后摄像头同时录制),尝试创建多个AVRecorder实例可能失败。
解:在创建多个录制器前,先检查设备能力。如果设备不支持多路录制,可以使用画面拼接的方式实现类似效果。
五、HarmonyOS 6适配
5.1 API变更
| 变更项 | HarmonyOS 5 | HarmonyOS 6 |
|---|---|---|
| 4K录制 | 部分设备支持 | 标准化4K录制配置 |
| HDR录制 | 不支持 | 新增HDR10录制支持 |
| 多路录制 | 有限支持 | 优化多实例并发能力 |
| 录制回调 | 基础回调 | 新增码率动态调整回调 |
| 文件格式 | MP4/M4A | 新增WebM格式支持 |
5.2 迁移指南
// HarmonyOS 6 新增的HDR录制配置
const hdrConfig: media.AVRecorderConfig = {
audioSourceType: media.AudioSourceType.AUDIO_SOURCE_TYPE_MIC,
videoSourceType: media.VideoSourceType.VIDEO_SOURCE_TYPE_SURFACE_YUV,
profile: {
audioBitrate: 256000,
audioChannels: 2,
audioCodec: media.CodecMimeType.AUDIO_AAC,
audioSampleRate: 48000,
fileFormat: media.ContainerFormatType.CFT_MPEG_4,
videoBitrate: 8000000, // HDR需要更高码率
videoCodec: media.CodecMimeType.VIDEO_HEVC, // H.265更适合HDR
videoFrameWidth: 3840,
videoFrameHeight: 2160,
videoFrameRate: 30
},
url: `fd://${file.fd}`,
// HarmonyOS 6 新增:HDR标志
// hdr: true
}
5.3 性能优化
HarmonyOS 6对录制性能做了以下优化:
- 内存管理:优化了录制缓冲区,内存峰值降低约25%
- 编码效率:H.265编码速度提升约15%
- 文件写入:异步写入优化,减少录制卡顿
六、总结
mindmap
root((视频录制))
核心API
AVRecorder
状态机: idle→prepared→recording
配置: profile参数
Surface绑定
录制配置
音频参数
码率/采样率/声道
AAC编码
视频参数
分辨率/帧率/码率
H.264/H.265编码
容器格式
MP4/M4A
权限管理
CAMERA
MICROPHONE
MEDIA_LOCATION
文件管理
自动命名
存储空间控制
过期清理
完整性校验
关键要点
文件路径用fd://
Surface获取时机
异常处理与资源释放
多路录制设备限制
一句话总结:视频录制是"采集→编码→封装"的链路,核心是AVRecorder的状态机管理——先prepare获取Surface、再配置Camera绑定、最后start开始录制。权限、文件路径、资源释放是三个最容易踩坑的地方。
记住三个关键:
- 权限先行——CAMERA和MICROPHONE缺一不可,且需动态申请
- fd路径——AVRecorder的url必须用
fd://格式,不能直接用文件路径 - 及时释放——录制结束或出错时,务必stop+release,否则摄像头和麦克风会被持续占用
- 点赞
- 收藏
- 关注作者
评论(0)