HarmonyOS开发中的视频录制:AVRecorder从入门到多路录制的全流程实战

举报
Jack20 发表于 2026/06/20 21:12:16 2026/06/20
【摘要】 HarmonyOS开发中的视频录制:AVRecorder从入门到多路录制的全流程实战核心要点:掌握HarmonyOS视频录制核心API——AVRecorder,理解录制配置参数、权限申请、录制状态机,学会实现多路录制和录制文件管理。 一、背景与动机想象一下这个场景:你正在开发一款短视频App,用户打开摄像头,对准自己,点击"录制"按钮,开始拍摄15秒的短视频。看起来很简单对吧?但背后涉及的...

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

更详细地说:

  1. 采集:通过Camera获取视频帧,通过AudioCapturer/Mic获取音频PCM数据
  2. 编码:视频帧编码为H.264/H.265,音频PCM编码为AAC
  3. 封装:将编码后的音视频流打包成MP4容器格式
  4. 写入:将封装后的数据写入文件

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 录制过程中的异常处理

:录制过程中如果摄像头被占用、存储空间不足或系统杀后台,可能导致录制文件损坏。

  1. 监听AVRecorder的error事件,出错时及时stop和release
  2. 在应用进入后台时主动暂停录制
  3. 录制完成后验证文件完整性

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开始录制。权限、文件路径、资源释放是三个最容易踩坑的地方。

记住三个关键

  1. 权限先行——CAMERA和MICROPHONE缺一不可,且需动态申请
  2. fd路径——AVRecorder的url必须用fd://格式,不能直接用文件路径
  3. 及时释放——录制结束或出错时,务必stop+release,否则摄像头和麦克风会被持续占用
【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

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

全部回复

上滑加载中

设置昵称

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

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

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