HarmonyOS 媒体能力深度实战:Camera/Audio/Video 开发全解析
封面信息图一文掌握 HarmonyOS NEXT 媒体三件套,避开 90% 开发者踩过的坑,从采集到播放全流程跑通。
适用版本:HarmonyOS NEXT / API 12+ 阅读时长:约 18 分钟
场景切入:为什么媒体开发这么难?
你写了一个拍照功能,真机跑起来黑屏;录音权限申请通过了但 AudioCapturer 始终报 -1;视频播放器在部分设备上卡顿、首帧慢——这些问题不是你代码写错了,而是 HarmonyOS 媒体 API 有严格的状态机约束,顺序错一步就全盘崩。
本文从实际开发场景出发,拆解 Camera Kit、Audio Kit 和 AVPlayer 的核心机制,给出可直接运行的 ArkTS 代码示例,并系统整理高频坑点。
一、Camera Kit:拍照与录像
1.1 架构总览
CameraManager ├── getSupportedCameras() → Array<CameraDevice> ├── createCameraInput(device) → CameraInput └── createSession(sessionType) → PhotoSession / VideoSessionPhotoSession ├── addInput(cameraInput) ├── addOutput(photoOutput) ├── commitConfig() ← 必须调用,否则配置不生效 └── start()PhotoOutput └── capture(settings) → 触发拍照
整体流程遵循严格状态机:IDLE → CONFIGURING → COMMITTED → STARTED → STOPPED,任何跨状态调用都会抛出 CameraError。
1.2 最简拍照实现
import { camera } from'@kit.CameraKit';import { fileIo } from'@kit.CoreFileKit';asyncfunctiontakeSinglePhoto(context: Context): Promise<string> {const cameraManager = camera.getCameraManager(context);const cameras = cameraManager.getSupportedCameras();// 选择后置摄像头const backCamera = cameras.find(c => c.cameraPosition === camera.CameraPosition.CAMERA_POSITION_BACK ) ?? cameras[0];// ❌ 错误写法:直接 start() 而不 commitConfig()// session.start(); // 报错:状态机不在 COMMITTED 状态// ✅ 正确写法:严格按顺序执行const cameraInput = cameraManager.createCameraInput(backCamera);await cameraInput.open();const photoProfile = cameraManager.getSupportedOutputCapability( backCamera, camera.SceneMode.NORMAL_PHOTO ).photoProfiles[0];const photoOutput = cameraManager.createPhotoOutput(photoProfile);const session = cameraManager.createSession<camera.PhotoSession>( camera.SceneMode.NORMAL_PHOTO ); session.beginConfig(); session.addInput(cameraInput); session.addOutput(photoOutput);await session.commitConfig(); // 必须 await,提交配置await session.start(); // 启动预览// 拍照并保存const savePath = context.filesDir + `/photo_${Date.now()}.jpg`;const file = fileIo.openSync(savePath, fileIo.OpenMode.CREATE | fileIo.OpenMode.WRITE_ONLY); photoOutput.on('photoAvailable', (err, photo) => {const buffer = photo.main.getComponent(camera.Component.JPEG)!.byteBuffer; fileIo.writeSync(file.fd, buffer); fileIo.closeSync(file.fd); });await photoOutput.capture(); // 触发拍摄return savePath;}
1.3 录像会话差异
录像使用 VideoSession,需要额外传入 VideoOutput 和 AVRecorder:
import { media } from'@kit.MediaKit';asyncfunctionstartVideoRecording(context: Context, savePath: string) {const avRecorder = await media.createAVRecorder();// AVRecorder 必须先 prepare,再绑定到 VideoOutputawait avRecorder.prepare({ videoSourceType: media.VideoSourceType.VIDEO_SOURCE_TYPE_SURFACE_YUV, profile: { fileFormat: media.ContainerFormatType.CFT_MPEG_4, videoBitrate: 2000000, videoCodec: media.CodecMimeType.VIDEO_AVC, videoFrameWidth: 1280, videoFrameHeight: 720, videoFrameRate: 30 }, url: `fd://${fileIo.openSync(savePath, fileIo.OpenMode.CREATE | fileIo.OpenMode.WRITE_ONLY).fd}` });const videoSurface = await avRecorder.getInputSurface();// 后续 addOutput(videoOutput) 时需传入 videoSurface 创建的 VideoOutput}
关键差异对比:
| | |
|---|
| | |
| | |
| | avRecorder.stop() → avRecorder.reset() |
| | 必须在 addOutput 前获取 Surface |
二、Audio Kit:录音与播放
2.1 核心类职责划分
AudioCapturer → 麦克风采集 PCM 原始数据(实时处理场景)AudioRenderer → 播放 PCM/AAC 原始数据(自定义播放场景)AVRecorder → 高层封装,采集+编码+写文件(推荐录音场景)AVPlayer → 高层封装,解码+渲染(推荐播放场景)
选型原则:
- 需要对原始 PCM 做实时处理(如波形图、变声)→
AudioCapturer / AudioRenderer
2.2 AudioCapturer 采集实践
import { audio } from'@kit.AudioKit';asyncfunctionstartAudioCapture(): Promise<audio.AudioCapturer> {const streamInfo: audio.AudioStreamInfo = { samplingRate: audio.AudioSamplingRate.SAMPLE_RATE_44100, channels: audio.AudioChannel.CHANNEL_2, sampleFormat: audio.AudioSampleFormat.SAMPLE_FORMAT_S16LE, encodingType: audio.AudioEncodingType.ENCODING_TYPE_RAW };const capturerInfo: audio.AudioCapturerInfo = { source: audio.SourceType.SOURCE_TYPE_MIC, capturerFlags: 0 };const capturer = await audio.createAudioCapturer({ streamInfo, capturerInfo });// ❌ 错误写法:不等待 start() 完成就读取数据// capturer.read(bufferSize, true, callback); // 状态未就绪,返回空数据// ✅ 正确写法:等待 start 完成后再注册 readData 回调await capturer.start(); capturer.on('readData', (buffer: ArrayBuffer) => {// buffer 即为 PCM 原始数据,可直接写文件或实时处理console.log(`采集到 ${buffer.byteLength} 字节 PCM 数据`); });return capturer;}asyncfunctionstopAudioCapture(capturer: audio.AudioCapturer) {await capturer.stop();await capturer.release(); // 必须 release,否则持续占用麦克风}
2.3 权限申请(常见漏项)
媒体开发必须在 module.json5 中声明权限,**API 12+ 还需填写 usedScene**,否则动态申请时权限无法弹窗:
// module.json5 中的 requestPermissions{ "name": "ohos.permission.MICROPHONE", "reason": "$string:reason_microphone", "usedScene": { "abilities": ["EntryAbility"], "when": "inuse" // ← API 12 起必填,填 always 或 inuse }}
import { abilityAccessCtrl, Permissions } from'@kit.AbilityKit';asyncfunctionrequestMediaPermissions(context: Context): Promise<boolean> {const permissions: Permissions[] = ['ohos.permission.MICROPHONE','ohos.permission.CAMERA' ];const atManager = abilityAccessCtrl.createAtManager();const result = await atManager.requestPermissionsFromUser(context, permissions);return result.authResults.every(r => r === abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED);}
三、AVPlayer:视频播放深度解析
3.1 状态机全景
idle ↓ url = '...'(赋值触发)initialized ↓ prepare()prepared ←─────────────────────┐ ↓ play() │ reset()playing │ ↓ pause() │paused ──→ play() ──→ playing │ ↓ stop() │stopped ─────────────────────────┘ ↓ reset()idle
跨状态调用是最高频的崩溃来源。 在 initialized 状态调用 play() 直接报状态机错误。
3.2 完整播放器实现
import { media } from'@kit.MediaKit';@Componentstruct VideoPlayer {private avPlayer: media.AVPlayer | null = null;@State isPlaying: boolean = false;private surfaceId: string = '';async initPlayer(url: string) {this.avPlayer = await media.createAVPlayer();// 注册 stateChange 回调(必须在 url 赋值前注册)this.avPlayer.on('stateChange', async (state: string) => {switch (state) {case'initialized':// ✅ initialized 后才能调用 prepare,不能直接 playawaitthis.avPlayer!.prepare();break;case'prepared':// prepared 后设置 surfaceId,再 playthis.avPlayer!.surfaceId = this.surfaceId;awaitthis.avPlayer!.play();this.isPlaying = true;break;case'completed':awaitthis.avPlayer!.seek(0, media.SeekMode.SEEK_PREV_SYNC);break;case'error':awaitthis.avPlayer!.reset();break; } });this.avPlayer.on('error', (err) => {console.error(`AVPlayer error: ${err.code} - ${err.message}`); });// url 赋值触发状态 idle → initializedthis.avPlayer.url = url; }async releasePlayer() {if (this.avPlayer) {awaitthis.avPlayer.stop();awaitthis.avPlayer.reset();awaitthis.avPlayer.release(); // 释放解码器等系统资源this.avPlayer = null; } } build() { Column() { XComponent({ id: 'videoSurface',type: XComponentType.SURFACE, // ← 必须是 SURFACE,不能是 COMPONENT controller: new XComponentController() }) .onLoad((controller) => {// surfaceId 在 onLoad 回调后才可用this.surfaceId = controller.getXComponentSurfaceId(); }) .width('100%') .aspectRatio(16 / 9) Button(this.isPlaying ? '暂停' : '播放') .onClick(async () => {if (!this.avPlayer) return;if (this.isPlaying) {awaitthis.avPlayer.pause();this.isPlaying = false; } else {awaitthis.avPlayer.play();this.isPlaying = true; } }) } }}
3.3 seek 精度控制
// ❌ 错误写法:单参数 seek,默认 SEEK_PREV_SYNC,可能与期望位置偏差数秒await avPlayer.seek(30000);// ✅ 正确写法:明确传入精度模式await avPlayer.seek(30000, media.SeekMode.SEEK_CLOSEST); // 帧精确,适合编辑场景await avPlayer.seek(30000, media.SeekMode.SEEK_NEXT_SYNC); // 关键帧对齐,适合快进场景
四、最佳实践
4.1 及时释放媒体资源
做法: 在 aboutToDisappear 或 onPageHide 中主动调用 release()。
原因: 摄像头、麦克风、硬件解码器是独占型系统资源,页面切走后若不释放,其他应用无法获取,同时自身下次打开也会冲突。
不这样做: 退出页面后摄像头指示灯常亮,再次进入拍照页 cameraInput.open() 报 CONFLICT_CAMERA(错误码 7400101)。
4.2 在 stateChange 回调中驱动 AVPlayer
做法: 所有状态迁移操作(prepare()、play()、seek())放在对应的 stateChange 回调里,而非在赋值 url 后直接链式调用。
原因: AVPlayer 是异步状态机,url 赋值返回不代表状态已切换到 initialized,直接调用下一步会触发非法状态错误。
不这样做: 随机出现 state machine error,仅在低端设备(异步切换慢)上复现,难以稳定复现和排查。
4.3 录音前申请焦点并监听焦点变化
做法: 使用 AudioSessionManager 申请音频焦点,并注册 audioInterrupt 事件监听,失去焦点时暂停采集。
原因: 来电、系统通知等会抢占音频焦点,继续采集会导致录音内容混入系统声音,或采集到静音数据。
不这样做: 用户接打电话期间录音文件包含通话内容,存在隐私合规风险。
五、常见坑点
坑 1:预览画面黑屏
- 现象: Camera 所有 API 返回成功,XComponent 上无画面显示。
- 原因:
session.start() 在 XComponent.onLoad 之前调用,surfaceId 尚未绑定到 PreviewOutput。 - 复现: 在
aboutToAppear 中初始化 Camera,而不是在 XComponent.onLoad 回调中。 - 解决: 将整个 Camera 初始化链路(createCameraInput → addOutput → commitConfig → start)移入
XComponent.onLoad 回调,确保 surfaceId 已就绪。
坑 2:AudioCapturer.start() 报 -1(SYSTEM_ERROR)
- 现象: 动态权限弹窗已授权,但
capturer.start() 抛出错误码 -1。 - 原因:
module.json5 中 requestPermissions 缺少 usedScene.when 字段,API 12 起该字段必填,缺失时权限实际未生效。 - 复现: 旧工程迁移到 API 12 后直接运行,未更新权限配置。
- 解决: 在
module.json5 中补全 "usedScene": {"abilities": ["EntryAbility"], "when": "inuse"}。
坑 3:AVPlayer 有声音无画面
- 原因: XComponent 的
type 设置为 COMPONENT 而非 SURFACE,或 surfaceId 在 prepared 状态前未赋值给 avPlayer.surfaceId。 - 复现: XComponent 类型写错,或在 Button 点击后才赋值 surfaceId。
- 解决: 确认
type: XComponentType.SURFACE,并在 stateChange === 'prepared' 回调中、play() 调用前赋值 avPlayer.surfaceId。
六、总结
- Camera、Audio、AVPlayer 均为严格状态机,调用顺序错一步即报错。
CameraSession.commitConfig() 是 Camera 配置生效的关键节点,不可省略。- AVPlayer 所有状态迁移操作必须在
stateChange 回调中触发,禁止赋值后直接链式调用。 - 媒体资源独占,页面退出必须显式调用
release(),否则导致资源冲突。 - API 12+ 权限申请必须填写
usedScene.when,旧工程迁移需重点检查。
核心结论:HarmonyOS 媒体开发的本质是状态机驱动的资源管理,调用顺序和及时释放比业务逻辑本身更重要。
参考资料
- OpenHarmony 源码路径:
foundation/multimedia/camera_framework/ 、foundation/multimedia/av_session/