摘要

Camera 模块为系统原生相机能力,支持实时预览、拍照保存图片、录像等功能,适用于扫码、头像拍摄、单据上传等业务场景。API Version23 重构相机生命周期、设备释放、帧数据回调、存储写入逻辑,修复低版本相机占用无法释放、多次打开黑屏、拍摄图片保存失败、切换前后摄像头崩溃、后台驻留相机耗电等兼容缺陷。旧项目升级 API23 后常出现:页面退出相机未释放导致下次打开黑屏、拍摄无文件、预览画面拉伸变形、无相机权限直接闪退等问题。本文基于 DevEco Studio 适配 OpenHarmony API23 及以上版本,封装相机通用工具类,实现实时预览、拍照保存至沙盒、前后摄像头切换三大核心功能,搭配拍摄页面完整实战代码,输出权限校验、设备资源释放、图片持久化规范,汇总版本升级兼容故障解决方案,为鸿蒙相机拍摄业务开发提供标准化实操模板。

关键词

OpenHarmony;ArkUI;API Version23;camera;相机预览;拍照;媒体存储;资源释放

一、引言

1.1 相机开发背景

头像拍摄、证件上传、扫码识别、视频录制等场景都需要调用硬件相机。系统 camera 模块直接操作硬件摄像头,相比相册选择能实时取景拍摄,交互更贴合拍照类业务。相机属于独占硬件资源,页面销毁必须主动释放设备,否则会持续占用硬件导致其他应用无法调用相机。 OpenHarmony API Version23 针对 camera 底层硬件调度引擎完成全面升级,核心变更点:

  1. 标准化 CameraDevice 完整生命周期:创建→打开预览→拍摄→关闭释放,缺失步骤直接报错;
  2. 新增硬件占用强回收机制,页面销毁未释放相机则系统强制断开硬件;
  3. 统一拍摄图片沙盒写入逻辑,修复图片丢失、路径无效、无写入权限问题;
  4. 重构 Surface 预览绑定规则,修复预览画面拉伸、黑屏、画面倒置;
  5. 强化 CAMERA 动态权限强校验,未授权调用直接抛出可捕获异常;
  6. 区分前后摄像头设备 ID,切换摄像头自动销毁重建预览流,无画面卡顿。

大量 API9~11 旧项目升级后,二次进入相机页面黑屏、拍摄图片找不到、切换摄像头闪退,根源是未完整释放 CameraDevice 硬件资源,因此掌握 API23 标准相机生命周期与资源释放规范是拍摄类功能必备技能。

1.2 开发环境与测试场景

开发工具:DevEco Studio 5.0 及以上 适配系统:OpenHarmony API Version23、HarmonyOS NEXT 导入模块:@ohos.multimedia.camera、@ohos.file.fs、@ohos.file.common 前置依赖:动态 CAMERA 权限、存储写入权限 测试场景:实时相机预览、拍摄照片保存本地、前后摄像头切换、页面退出释放相机、无权限拦截提示

二、API23+ camera 相机核心 API 与版本变更说明

2.1 核心对象生命周期顺序

  1. CameraManager:相机管理器,获取设备列表
  2. CameraDevice:摄像头硬件实例(核心独占资源,用完必须 close)
  3. PreviewOutput:预览输出流,绑定 XComponent 渲染画面
  4. CaptureOutput:拍照输出流,处理拍摄图片保存

2.2 关键核心方法

ets

import camera from '@ohos.multimedia.camera'
// 获取相机管理器
const camMgr = camera.getCameraManager(getContext(this))
// 获取全部摄像头设备ID列表
const deviceIds = camMgr.getSupportedCameraDeviceIds()
// 打开摄像头硬件
const cameraDev = await camMgr.openCamera(deviceId)
// 创建预览输出
const preview = await camMgr.createPreviewOutput(surfaceId, cameraDev)
// 创建拍照输出
const capture = await camMgr.createCaptureOutput(fileFd, cameraDev)
// 开始预览
preview.start()
// 执行拍照
await capture.capture()
// 停止预览、关闭硬件(必须执行)
preview.stop()
cameraDev.close()

2.3 XComponent 预览容器

相机画面依赖 XComponent 组件承载,通过 onSurfaceCreated 获取 surfaceId 绑定预览流。

2.4 API23 废弃与强制约束

  1. 废弃页面销毁不执行 cameraDev.close () 写法,硬件长期占用造成黑屏;
  2. 禁止多页面同时持有 CameraDevice 实例,同一时间仅允许一个页面使用相机;
  3. 拍摄图片必须传入文件文件描述符 fd,不支持直接传入绝对路径;
  4. 必须前置动态申请 ohos.permission.CAMERA 权限,无权限直接捕获异常;
  5. 切换摄像头必须先关闭当前 CameraDevice,再重新创建新设备实例;
  6. 后台切页必须停止预览并关闭相机,避免持续耗电、硬件占用。

三、API23 标准基础示例代码

3.1 基础相机打开与预览流程

ets

import camera from '@ohos.multimedia.camera'
import common from '@ohos.app.ability.common'

let cameraDev: camera.CameraDevice | null = null
let previewOut: camera.PreviewOutput | null = null

async function openCamera(ctx: common.UIAbilityContext, surfaceId: string, deviceId: string) {
  const camMgr = camera.getCameraManager(ctx)
  // 打开硬件摄像头
  cameraDev = await camMgr.openCamera(deviceId)
  // 创建预览输出绑定XComponent画布
  previewOut = await camMgr.createPreviewOutput(surfaceId, cameraDev)
  // 启动预览画面
  await previewOut.start()
}

// 释放全部相机资源(页面退出必调用)
async function releaseCamera() {
  if (previewOut) {
    await previewOut.stop()
    previewOut = null
  }
  if (cameraDev) {
    await cameraDev.close()
    cameraDev = null
  }
}

3.2 基础拍照并保存沙盒文件

ets

import fs from '@ohos.file.fs'
import camera from '@ohos.multimedia.camera'

async function takePhoto(ctx: common.UIAbilityContext) {
  const camMgr = camera.getCameraManager(ctx)
  // 创建沙盒图片文件
  const filePath = ctx.filesDir + "/photo_" + Date.now() + ".jpg"
  const fd = fs.openSync(filePath, fs.OpenMode.CREATE | fs.OpenMode.WRITE_ONLY)
  // 创建拍照输出流
  const captureOut = await camMgr.createCaptureOutput(fd, cameraDev!)
  // 执行拍摄
  await captureOut.capture()
  // 关闭文件描述符
  fs.closeSync(fd)
  return filePath
}

四、完整实战案例(相机工具类 + 拍摄页面)

4.1 实战一:相机工具封装 utils/camera_util.ets

ets

import camera from '@ohos.multimedia.camera'
import fs from '@ohos.file.fs'
import common from '@ohos.app.ability.common'
import promptAction from '@ohos.promptAction'

class CameraUtil {
  private static instance: CameraUtil
  private ctx: common.UIAbilityContext | null = null
  private cameraDev: camera.CameraDevice | null = null
  private previewOut: camera.PreviewOutput | null = null
  private curDeviceId: string = ""

  static getInstance(): CameraUtil {
    if (!CameraUtil.instance) {
      CameraUtil.instance = new CameraUtil()
    }
    return CameraUtil.instance
  }

  setContext(context: common.UIAbilityContext) {
    this.ctx = context
  }

  // 获取前后摄像头ID,0后置,1前置
  getCameraIds(): string[] {
    if (!this.ctx) return []
    const mgr = camera.getCameraManager(this.ctx)
    return mgr.getSupportedCameraDeviceIds()
  }

  // 初始化相机预览
  async initCamera(surfaceId: string, deviceId: string): Promise<boolean> {
    if (!this.ctx) return false
    // 先释放已有相机
    await this.release()
    try {
      const mgr = camera.getCameraManager(this.ctx)
      this.curDeviceId = deviceId
      this.cameraDev = await mgr.openCamera(deviceId)
      this.previewOut = await mgr.createPreviewOutput(surfaceId, this.cameraDev)
      await this.previewOut.start()
      return true
    } catch (err) {
      promptAction.showToast({ message: "相机打开失败,请检查权限" })
      console.error("initCamera error", err)
      return false
    }
  }

  // 执行拍照,返回图片沙盒路径
  async takePicture(): Promise<string> {
    if (!this.ctx || !this.cameraDev) return ""
    try {
      const mgr = camera.getCameraManager(this.ctx)
      const fileName = "cam_" + Date.now() + ".jpg"
      const fullPath = this.ctx.filesDir + "/" + fileName
      const fd = fs.openSync(fullPath, fs.OpenMode.CREATE | fs.OpenMode.WRITE_ONLY)
      const captureOut = await mgr.createCaptureOutput(fd, this.cameraDev)
      await captureOut.capture()
      fs.closeSync(fd)
      promptAction.showToast({ message: "拍摄完成" })
      return fullPath
    } catch (err) {
      promptAction.showToast({ message: "拍摄失败" })
      console.error("takePicture error", err)
      return ""
    }
  }

  // 释放相机全部硬件资源(页面销毁强制调用)
  async release() {
    if (this.previewOut) {
      try {
        await this.previewOut.stop()
      } catch (e) {}
      this.previewOut = null
    }
    if (this.cameraDev) {
      try {
        await this.cameraDev.close()
      } catch (e) {}
      this.cameraDev = null
    }
  }
}

export default CameraUtil.getInstance()

4.2 实战二:相机拍摄页面(XComponent 预览 + 切换摄像头 + 拍照)

ets

import CameraUtil from '../utils/camera_util'
import PermissionUtil, { PERMISSION } from '../utils/permission_util'
import ImagePickerUtil from '../utils/image_picker'

@Entry
@Component
struct CameraPage {
  @State surfaceId: string = ""
  @State photoPath: ResourceStr = ""
  deviceList: string[] = []
  @State currentIndex: number = 0

  async aboutToAppear() {
    // 绑定上下文并校验相机权限
    PermissionUtil.setContext(getContext(this))
    const hasCamPerm = await PermissionUtil.requestSingle(PERMISSION.CAMERA, "相机")
    if (!hasCamPerm) return
    CameraUtil.setContext(getContext(this))
    this.deviceList = CameraUtil.getCameraIds()
  }

  // XComponent画布创建完成,初始化相机预览
  onSurfaceReady(surface: string) {
    this.surfaceId = surface
    if (this.deviceList.length > 0) {
      CameraUtil.initCamera(this.surfaceId, this.deviceList[this.currentIndex])
    }
  }

  // 切换前后摄像头
  async switchCamera() {
    this.currentIndex = this.currentIndex === 0 ? 1 : 0
    await CameraUtil.initCamera(this.surfaceId, this.deviceList[this.currentIndex])
  }

  // 执行拍照
  async shotPhoto() {
    const path = await CameraUtil.takePicture()
    if (path) this.photoPath = path
  }

  build() {
    Column() {
      // 相机预览画布
      XComponent()
        .width("100%")
        .layoutWeight(1)
        .onSurfaceCreated((surface) => this.onSurfaceReady(surface))

      // 底部操作按钮栏
      Row({ space: 30 }) {
        Button("切换摄像头").width(140).height(48).onClick(() => this.switchCamera())
        Button("拍照").width(140).height(48).backgroundColor("#f56c6c").onClick(() => this.shotPhoto())
      }
      .width("100%")
      .height(80)
      .justifyContent(FlexAlign.Center)

      // 拍摄预览缩略图
      if (this.photoPath) {
        Image(this.photoPath)
          .width(80)
          .height(80)
          .position({ x: 10, y: -90 })
          .borderRadius(8)
      }
    }
    .width("100%")
    .height("100%")
  }

  async aboutToDisappear() {
    // 页面退出强制释放相机硬件资源,API23硬性要求
    await CameraUtil.release()
  }
}

五、API23+ 相机适配与硬件性能优化规范

5.1 硬件资源释放强制规范

  1. 页面aboutToDisappear必须调用工具 release () 停止预览、关闭 CameraDevice;
  2. 切换摄像头先关闭当前设备,再创建新预览流,避免硬件冲突;
  3. 应用切后台 onBackground 主动释放相机,前台返回再重新初始化。

5.2 权限与存储规范

  1. 打开相机前动态申请 CAMERA 权限,无权限阻断相机初始化流程;
  2. 拍摄图片保存至应用沙盒 filesDir 目录,无需额外存储权限;
  3. 拍照文件必须使用 fs 文件描述符 fd 传入 CaptureOutput,禁止直接传路径。

5.3 预览画面适配规范

  1. XComponent 铺满页面宽高,避免画面拉伸、黑边;
  2. 设备 ID 0 默认后置主摄,1 前置自拍,自动区分设备列表;
  3. 初始化失败捕获异常弹窗提示,不直接闪退应用。

5.4 性能耗电规范

  1. 长时间停留相机页面不频繁重复创建 / 销毁预览;
  2. 页面退出、切后台立刻关闭相机硬件,停止图像解码节省电量;
  3. 不循环连续调用拍照,增加按钮防抖锁防止重复拍摄。

六、API23 升级高频兼容问题与解决方案

问题 1:第一次打开相机正常,返回再次进入页面黑屏无画面 解决:页面销毁执行 release 完整关闭 CameraDevice 硬件,下次进入重新初始化。

问题 2:点击拍照无图片,沙盒目录找不到照片文件 解决:使用 fs.openSync 获取文件描述符 fd 传入拍摄输出,不能直接传文件路径字符串。

问题 3:切换前后摄像头画面卡顿、直接闪退 解决:切换前先释放当前预览与摄像头设备,再新建预览流。

问题 4:无相机权限进入页面直接崩溃 解决:页面加载前置动态权限校验,未授予权限直接阻断相机初始化逻辑。

问题 5:切后台后再切回,相机画面冻结不动 解决:onBackground 释放相机,onForeground 重新调用 initCamera 重建预览。

问题 6:多次打开相机后系统提示相机被其他应用占用 解决:保证所有退出分支都执行 release,无遗漏硬件关闭逻辑。

七、总结

camera 原生相机模块是鸿蒙硬件拍摄标准实现方案,API Version23 严格标准化摄像头硬件生命周期,强制要求页面销毁释放 CameraDevice 硬件资源,解决旧版硬件长期占用、黑屏、拍摄图片丢失、切换摄像头崩溃等核心问题。项目封装独立相机工具类,统一管理预览、拍照、设备释放逻辑,搭配 XComponent 预览画布、动态权限校验,完整实现前后摄像头切换、拍照保存本地核心业务。

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐