大家好,我是[晚风依旧似温柔],新人一枚,欢迎大家关注~

一、CameraX 思路:我们要的是“用例驱动”的相机,而不是一坨状态机

先说清楚一点:
CameraX 是 Android 上的一个高级相机库,它的设计思路非常值得借鉴:

  • 以 UseCase 为中心(Preview / ImageCapture / VideoCapture)
  • 自动帮你处理生命周期、线程、Camera 选择等复杂细节
  • 使用方式非常简洁

在鸿蒙上,我们完全可以 模仿 CameraX 的思想

→ “我不想每次都直接跟底层 camera session 互怼,
→ 我只想写:我要预览 / 我要拍照 / 我要录像。”

所以我们可以做一层自己的 “类 CameraX 封装”

  • CameraController

    • 管理 Camera 设备、生命周期、状态
    • 负责打开 / 关闭 / 切换前后摄像头
  • PreviewUseCase

    • 负责预览画面绑定到 UI(XComponent / Surface 等)
  • PhotoUseCase

    • 负责拍照、输出图片数据
  • VideoUseCase

    • 负责开启 / 停止录像,并输出文件路径

你没必要真的叫这些名字,但“按职责拆分”这个思路一定要有,不然代码会拧成一团。

二、相机权限:不把权限搞清楚,你连第一帧预览都看不到

说实话,相机相关问题,有相当一部分是权限没配对
我们先把权限这块讲清楚,再谈相机本身。

2.1 需要哪些权限?

典型拍照 / 录像 + 写入相册场景,基本要这些:

  • ohos.permission.CAMERA —— 相机采集
  • ohos.permission.MICROPHONE —— 录像录音
  • ohos.permission.READ_MEDIA —— 读取媒体(有时用于相册读取)
  • ohos.permission.WRITE_MEDIA —— 写入媒体(保存照片 / 视频)

有的版本会把读写合并为媒体访问相关权限,整体方向一样:
相机 + 麦克风 + 媒体存储 这三件事。

2.2 在 module.json5 中静态声明

{
  "module": {
    // ...
    "requestPermissions": [
      {
        "name": "ohos.permission.CAMERA"
      },
      {
        "name": "ohos.permission.MICROPHONE"
      },
      {
        "name": "ohos.permission.READ_MEDIA"
      },
      {
        "name": "ohos.permission.WRITE_MEDIA"
      }
    ]
  }
}

这一步是“跟系统打招呼”:没有声明,很多相机 / 媒体 API 直接不给用

2.3 动态申请(运行时让用户点“允许”)

相机 & 麦克风一般属于受控权限,需要运行时弹窗申请:

import abilityAccessCtrl from '@ohos.abilityAccessCtrl';

async function ensurePermission(context, permission: string): Promise<boolean> {
  const atManager = abilityAccessCtrl.createAtManager();
  const status = await atManager.checkAccessToken(context.tokenId, permission);

  if (status === abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED) {
    return true;
  }

  const result = await atManager.requestPermissionsFromUser(context, [permission]);
  return result.authResults[0] === abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED;
}

export async function ensureCameraPermissions(context): Promise<boolean> {
  const perms = [
    'ohos.permission.CAMERA',
    'ohos.permission.MICROPHONE',
    'ohos.permission.READ_MEDIA',
    'ohos.permission.WRITE_MEDIA'
  ];

  for (const p of perms) {
    const ok = await ensurePermission(context, p);
    if (!ok) {
      console.error(`【权限被拒绝】${p}`);
      return false;
    }
  }
  return true;
}

📌 建议:

  • 不要一进 App 就弹
  • 在用户点击“拍照/录像”入口的时候再申请,顺便配一个简单说明:“为了拍照 / 录像,需要相机和麦克风权限”。

三、预览 + 拍照 + 录像:把相机能力拆成几条清晰的链路

大部分相机功能,可以抽象成三条链:

  1. 预览:Camera → Surface → UI
  2. 拍照:Camera → Image → 保存文件
  3. 录像:Camera → VideoStream → 封装为媒体文件

以鸿蒙多媒体 camera 能力为例,大致流程如下(伪代码+思路):

3.1 初始化 Camera 管理器 & 选择设备

import camera from '@ohos.multimedia.camera';

class CameraController {
  private cameraManager: camera.CameraManager | null = null;
  private cameraDevice: camera.CameraDevice | null = null;
  private session: camera.Session | null = null;
  private previewOutput: camera.PreviewOutput | null = null;
  private photoOutput: camera.PhotoOutput | null = null;
  private videoOutput: camera.VideoOutput | null = null;

  constructor(private context) {}

  async init() {
    this.cameraManager = camera.getCameraManager(this.context);
    const cameras = this.cameraManager.getSupportedCameras();

    // 简单选一个后置摄像头
    this.cameraDevice = cameras.find(c => c.position === camera.CameraPosition.REAR) ?? cameras[0];
  }
}

实战建议:

  • 把“选择前/后摄像头”封装成单独方法 switchCamera()
  • 对“没有前摄像头”这种情况给出兼容处理

3.2 绑定预览 Surface(XComponent)

在 ArkUI 里面,要把相机画面渲染出来,通常会用一个 XComponent 作为渲染目标(Surface)。

UI 侧:

import camera from '@ohos.multimedia.camera';

@Entry
@Component
struct CameraPage {
  private controller: CameraController = new CameraController(getContext(this));
  private surfaceId: string = '';

  async aboutToAppear() {
    const ok = await ensureCameraPermissions(getContext(this));
    if (!ok) {
      // TODO: 打提示
      return;
    }
    await this.controller.init();
  }

  onPreviewReady(surfaceId: string) {
    this.surfaceId = surfaceId;
    this.controller.startPreview(surfaceId);
  }

  build() {
    Column() {
      XComponent({
        id: 'cameraPreview',
        type: 'surface',
        controller: new XComponentController()
      })
        .onLoad((component) => {
          const surfaceId = component.getSurfaceId();
          this.onPreviewReady(surfaceId);
        })
        .width('100%')
        .height('70%')

      // 下方按钮
      Row() {
        Button('拍照')
          .onClick(() => this.controller.takePhoto());
        Button('开始录像')
          .onClick(() => this.controller.startRecord());
        Button('停止录像')
          .onClick(() => this.controller.stopRecord());
      }.justifyContent(FlexAlign.Center)
    }
  }
}

控制器侧:

async startPreview(surfaceId: string) {
  if (!this.cameraManager || !this.cameraDevice) return;

  const previewProfile = this.cameraManager.getSupportedOutputCapability(this.cameraDevice).previewProfiles[0];

  const surface = camera.createSurfaceFromId(surfaceId); // 示意

  this.previewOutput = this.cameraManager.createPreviewOutput(previewProfile, surface);

  this.session = this.cameraManager.createSession();
  this.session.beginConfig();
  this.session.addOutput(this.previewOutput);
  this.session.commitConfig();
  this.session.start();
}

这块具体 API 根据版本略有差异,但思路都是:
拿到 Camera → 选 profile → 创建 Output(预览)→ 建 session → 绑定 surface → start()


3.3 拍照:触发 PhotoOutput 的 capture

接下来看拍照链路。

  1. 配置 PhotoOutput
  2. 调用 capture()
  3. 拿到图片数据 → 落盘 → 写入相册

简化示例(控制器内部):

async preparePhotoOutput() {
  const cap = this.cameraManager.getSupportedOutputCapability(this.cameraDevice);
  const photoProfile = cap.photoProfiles[0];

  // 创建一个 photoOutput(一般基于某种 image 或 buffer)
  this.photoOutput = this.cameraManager.createPhotoOutput(photoProfile);
  this.photoOutput.on('photoAvailable', async (photo: camera.Photo) => {
    // 拿到图片数据
    const buffer = photo.main; // ArrayBuffer
    await this.savePhotoToGallery(buffer);
  });

  this.session.beginConfig();
  this.session.addOutput(this.photoOutput);
  this.session.commitConfig();
}

async takePhoto() {
  if (!this.photoOutput) {
    await this.preparePhotoOutput();
  }
  this.photoOutput.capture();
}

这里我们先不纠结具体类型,重点放在“拍照 → 回调拿数据 → 保存”这一整条链。


3.4 录像:VideoOutput + 媒体录制

录像比拍照稍微复杂一点,因为要 持续向一个媒体文件写入视频流
一般流程是:

  1. 配置 VideoOutput,指定编码参数
  2. 创建 Recorder 或类似对象
  3. session 中加入 videoOutput
  4. startRecord() / stop()

简化版伪代码(思路):

async prepareVideoOutput(filePath: string) {
  const cap = this.cameraManager.getSupportedOutputCapability(this.cameraDevice);
  const videoProfile = cap.videoProfiles[0];

  this.videoOutput = this.cameraManager.createVideoOutput(videoProfile);
  // 将 videoOutput 与 recorder 关联(不同版本写法不同)

  this.session.beginConfig();
  this.session.addOutput(this.videoOutput);
  this.session.commitConfig();

  // 同时初始化 recorder 写入 filePath
}

async startRecord() {
  // 启动相机 session(若未启动)
  this.session.start();
  // 启动 recorder 或 videoOutput
  // this.videoOutput.start(); 等
}

async stopRecord() {
  // 停止 recorder
  // this.videoOutput.stop();
  // 停止 session 或保持 preview
}

真实项目中这块要对照 SDK 示例,但结构是一样的:相机出视频流 → 媒体录制器按编码配置写成文件


四、相册写入:拍完不入库,用户就永远找不到

很多人拍照写完文件就结束了,结果用户在相册找不到刚刚拍的照片,以为你没保存。
要让媒体真正出现在系统图库里,得走一把 媒体库(MediaLibrary)

4.1 使用 MediaLibrary 写入图片 / 视频

典型写入流程(示意):

import mediaLibrary from '@ohos.multimedia.mediaLibrary';

async savePhotoToGallery(buffer: ArrayBuffer): Promise<string> {
  const context = this.context;
  const ml = mediaLibrary.getMediaLibrary(context);

  const fileName = `IMG_${Date.now()}.jpg`;

  const mediaType = mediaLibrary.MediaType.IMAGE;
  const asset = await ml.createAsset(mediaType, fileName, 'Pictures/Camera');

  const fd = await asset.open('rw');
  // 写数据
  const fileIo = await fs.open(fd); // 伪代码视 fs 模块具体用法
  await fileIo.write(buffer);
  await fileIo.close();

  // 通知媒体库刷新
  await ml.close();

  return asset.uri; // 或者返回 path
}

视频类似,只是媒体类型改成 VIDEO,目录改成 Movies/Camera 之类。

确保:

  • 目录使用系统建议的相册路径(例如 Camera)
  • 写完后关闭文件描述符
  • 必要时触发媒体扫描(一般 MediaLibrary API 会处理)

五、自定义相机 UI 实战:自己做一个“像样的相机页面”

看了这么多流程,如果 UI 还停留在“一个 XComponent + 三个 Button”的 Demo 水平,那就太可惜了。

我们来设计一个更像产品的页面:

  • 上方:相机预览

  • 中间:辅助信息层(比如对焦框、提示文字)

  • 底部:

    • 左:缩略图(最近一张照片)
    • 中:拍照 / 录像按钮
    • 右:切换前后摄像头

5.1 UI 结构

@Entry
@Component
struct CustomCameraPage {
  private cameraCtrl: CameraController = new CameraController(getContext(this));
  private xController: XComponentController = new XComponentController();
  @State isRecording: boolean = false;
  @State usingFront: boolean = false;

  async aboutToAppear() {
    const ok = await ensureCameraPermissions(getContext(this));
    if (!ok) return;
    await this.cameraCtrl.init();
  }

  onPreviewReady() {
    const surfaceId = this.xController.getSurfaceId();
    this.cameraCtrl.startPreview(surfaceId);
  }

  build() {
    Column() {
      // 预览区域
      XComponent({
        id: 'cameraPreview',
        type: 'surface',
        controller: this.xController
      })
        .onLoad(() => this.onPreviewReady())
        .width('100%')
        .height('70%')

      // 可叠加一些 overlay,比如对焦框、提示等,这里先略

      // 底部控制条
      Row() {
        // 左侧缩略图位占位
        Circle()
          .width(40)
          .height(40)
          .backgroundColor(0x333333)

        // 中间大按钮
        Circle()
          .width(70)
          .height(70)
          .backgroundColor(this.isRecording ? 0xff0000 : 0xffffff)
          .margin({ left: 50, right: 50 })
          .onClick(async () => {
            if (this.isRecording) {
              await this.cameraCtrl.stopRecord();
              this.isRecording = false;
            } else {
              // 拍照模式 / 录像模式可根据需求切换
              await this.cameraCtrl.takePhoto();
            }
          })

        // 右侧切换摄像头
        Button(this.usingFront ? '前置' : '后置')
          .onClick(async () => {
            this.usingFront = !this.usingFront;
            await this.cameraCtrl.switchCamera(this.usingFront);
            const surfaceId = this.xController.getSurfaceId();
            this.cameraCtrl.startPreview(surfaceId);
          })
      }
      .height('30%')
      .justifyContent(FlexAlign.Center)
    }
    .backgroundColor(0x000000)
    .width('100%')
    .height('100%')
  }
}

这个 UI 示例的重点,是告诉你:

  • 预览与控制可以完全按产品要求排版
  • 相机只是一块“画面源”,UI 完全由你决定
  • 拍照、录像、切换摄像头、显示缩略图都可以组合

5.2 可扩展的交互细节

你可以继续往里加很多“产品级体验”:

  • 点击预览区域对焦 / 显示对焦动画
  • 滑动调整缩放(Pinch 手势 → 调整 Zoom)
  • 长按拍照按钮 → 切换成连拍模式
  • 左滑打开相册
  • 录像时显示录制时长 / 剩余空间提示

在代码层面,都可以围绕 CameraController 增加对应接口,比如:

setZoom(level: number) {}
setFlashMode(mode: 'auto' | 'on' | 'off') {}
lockFocusAt(x: number, y: number) {}

UI 这块完全可以做得很“产品化”,不要满足于 SDK Demo 级别


六、常见坑 & 心理安慰小合集 😂

最后简单列几个你几乎一定会遇到的坑(至少我都挨过):

  1. 预览是横的,UI 是竖的

    • 记得处理旋转 / 映射,或者使用 SDK 提供的 orientation 控制
  2. 照片方向颠倒 / 旋转 90°

    • EXIF 信息 / orientation 没处理
  3. 录像没声音

    • MIC 权限 / 音频编码配置缺失
  4. 拍完照片在相册里找不到

    • 媒体库没正确写入 / 目录不对 / 没触发扫描
  5. 后台切换回来相机崩 / 黑屏

    • 生命周期没处理:onBackground 里该停的停、该释放的释放
    • 回到前台时重新创建 session & preview

写相机真的很少“一把过”,但你把本文这些流程和结构吃透之后,再遇到 bug,至少心里有根线:
——问题一定在 权限 / 会话 / 输出配置 / 媒体库 这几块。


最后一点小结 💬

如果你看完这篇,还能在脑子里清楚画出这条线:

相机权限 ✅ → 初始化 CameraManager & Device ✅ → 建 Session + PreviewOutput ✅
→ 自定义 UI 绑定 Surface ✅ → 拍照链路:PhotoOutput + 保存到相册 ✅
→ 录像链路:VideoOutput + 录制文件 ✅ → 媒体库写入 & 相册可见 ✅

那你已经告别“只能跑 Demo”的阶段了,开始摸到 “可上线产品级相机” 的门槛了。

如果觉得有帮助,别忘了点个赞+关注支持一下~
喜欢记得关注,别让好内容被埋没~

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐