鸿蒙实战:从零实现自定义相机(上)——架构设计与核心实现

完整源码:ArtCamera

相机开发涉及大量细节,为避免篇幅过长,本文将专注于应用的架构设计、模块划分和核心功能实现,按照开发顺序逐步讲解:从权限申请、XComponent 创建、相机初始化,到预览、拍照、保存相册。关键代码附详细注释。完整项目包含所有工具类、组件和详细注释,可直接克隆运行。

下一篇将专门讲解开发中遇到的六大典型问题及其解决方案。

一、项目目标

开发一款鸿蒙原生相机应用,具备以下功能:

  • 实时预览,画面按用户选择的比例(1:1、3:4、9:16)显示,无黑边、无拉伸(采用 cover 裁剪模式)
  • 拍摄照片比例与预览一致
  • 支持前后摄像头切换
  • 支持闪光灯(后置)
  • 拍照后底部小圆框立即显示最新缩略图
  • 横竖屏拍照方向自动正确

运行效果

完整的gif质量太大,拍照保存以后相册出现同等比例的照片。

二、整体架构

采用 MVVM 模式,模块划分如下:

复制代码
entry/src/main/ets/
├── pages/Index.ets                 # 主页面,状态管理,权限协调
├── components/
│   ├── CameraPreview.ets           # 预览组件(cover裁剪模式)
│   ├── Toolbar.ets                 # 底部工具栏(比例、拍照、切换摄像头)
│   ├── TopBar.ets                  # 顶部栏(闪光灯)
│   └── ThumbnailView.ets           # 缩略图组件(点击放大查看待实现)
├── utils/
│   ├── CameraController.ets        # 相机核心:初始化、会话、拍照、切换
│   ├── CameraUtils.ets             # Profile匹配、比例转换工具
│   ├── PhotoSaveHelper.ets         # 保存照片、获取最近缩略图
│   ├── PermissionHelper.ets        # 权限申请与二次引导
│   └── SensorHelper.ets            # 重力传感器(仅UI方向,不用于拍照)
├── constants/CameraConstants.ets   # 比例常量、闪光灯模式数组
└── model/                          # 类型定义(Resolution、FlashModeItem等)

核心流程

复制代码
启动 → 请求相机权限 → XComponent创建surface → 初始化相机 → 匹配Profile → 启动会话 → 预览显示
拍照 → capture → 收到photoAsset → 提取缩略图&临时文件 → 更新UI缩略图 → 保存到相册
切换比例/摄像头 → 重新初始化相机 → 重新匹配Profile → 预览自动适配

三、逐步实现过程

3.1 配置权限

src/main/module.json5 中声明相机权限:

json 复制代码
{
  "module": {
    "requestPermissions": [
      {
        "name": "ohos.permission.CAMERA",
        "reason": "$string:permission_camera_reason",
        "usedScene": {
          "abilities": ["EntryAbility"],
          "when": "inuse"
        }
      }
    ]
  }
}

同时在 src/main/resources/base/element/string.json 中添加权限说明文本。

3.2 定义常量与工具函数

文件:constants/CameraConstants.ets

定义支持的比例类型、闪光灯模式等常量,供全局使用。

javascript 复制代码
import { camera } from '@kit.CameraKit';
import { FlashModeItem } from '../model/FlashModeItem';

export type AspectRatio = '1:1' | '3:4' | '9:16';
export const ASPECT_RATIOS: AspectRatio[] = ['1:1', '3:4', '9:16'];

export const FLASH_MODES: FlashModeItem[] = [
  { mode: camera.FlashMode.FLASH_MODE_CLOSE, icon: $r('app.media.flash_off') },
  { mode: camera.FlashMode.FLASH_MODE_OPEN, icon: $r('app.media.flash_on') },
  { mode: camera.FlashMode.FLASH_MODE_AUTO, icon: $r('app.media.flash_auto') }
];

文件:utils/CameraUtils.ets

实现比例转换和 Profile 匹配算法。注意:相机 Profile 是横屏宽高比,UI 比例是竖屏,需要取倒数匹配。

javascript 复制代码
import { camera } from '@kit.CameraKit';
import { AspectRatio } from '../constants/CameraConstants';

export function getRatioValue(ratio: AspectRatio): number {
  if (ratio === '1:1') return 1;
  if (ratio === '3:4') return 3 / 4;
  return 9 / 16;
}

export function findBestProfile(
  profiles: camera.Profile[],
  targetRatio: number,
  minWidth: number,
  minHeight?: number
): camera.Profile | undefined {
  if (!profiles.length) return undefined;

  // 1. 筛选比例完全相同(误差<0.01)
  const exactMatch = profiles.filter(p => Math.abs(p.size.width / p.size.height - targetRatio) < 0.01);
  if (exactMatch.length === 0) {
    // 降级:选比例最接近的
    let best: camera.Profile | undefined;
    let minDiff = Infinity;
    for (const p of profiles) {
      const diff = Math.abs(p.size.width / p.size.height - targetRatio);
      if (diff < minDiff) {
        minDiff = diff;
        best = p;
      }
      if (diff < 0.001) break;
    }
    return best;
  }

  // 2. 比例匹配中,优先选宽高都满足的最小分辨率(性能最优)
  if (minHeight !== undefined) {
    const sufficient = exactMatch.filter(p => p.size.width >= minWidth && p.size.height >= minHeight);
    if (sufficient.length) {
      sufficient.sort((a, b) => a.size.width - b.size.width);
      return sufficient[0];
    }
  }
  // 3. 只满足宽度(高度可能小于屏幕高度,但预览时通过surfaceRect裁剪)
  const widthEnough = exactMatch.filter(p => p.size.width >= minWidth);
  if (widthEnough.length) {
    widthEnough.sort((a, b) => a.size.width - b.size.width);
    return widthEnough[0];
  }
  // 4. 选分辨率最大的兜底
  exactMatch.sort((a, b) => b.size.width - a.size.width);
  return exactMatch[0];
}

3.3 创建预览组件 CameraPreview

预览依赖 XComponent,我们需要在组件中动态设置 surfaceRect 以实现无拉伸显示。采用 cover 裁剪模式:使画面填满容器,超出部分裁剪。

javascript 复制代码
@Component
export struct CameraPreview {
  @Prop @Watch('onProfileChanged') previewProfileWidth: number = 0;
  @Prop @Watch('onProfileChanged') previewProfileHeight: number = 0;
  onSurfaceReady?: (surfaceId: string) => void;
  private xComponentController: XComponentController = new XComponentController();
  private containerWidth: number = 0;
  private containerHeight: number = 0;

  private onProfileChanged() {
    if (this.containerWidth > 0 && this.containerHeight > 0) {
      this.updateSurfaceRect(this.containerWidth, this.containerHeight);
    }
  }

  private updateSurfaceRect(containerWidthPx: number, containerHeightPx: number) {
    if (!this.previewProfileWidth || !this.previewProfileHeight) {
      this.xComponentController.setXComponentSurfaceRect({
        surfaceWidth: containerWidthPx,
        surfaceHeight: containerHeightPx
      });
      return;
    }
    const profileRatio = this.previewProfileWidth / this.previewProfileHeight;
    const containerRatio = containerWidthPx / containerHeightPx;
    let drawWidth: number, drawHeight: number;
    let offsetX = 0, offsetY = 0;

    // cover算法:宽度或高度填满,超出的部分通过offset居中裁剪
    if (profileRatio >= containerRatio) {
      drawWidth = containerWidthPx;
      drawHeight = drawWidth / profileRatio;
      offsetY = (containerHeightPx - drawHeight) / 2;
    } else {
      drawHeight = containerHeightPx;
      drawWidth = drawHeight * profileRatio;
      offsetX = (containerWidthPx - drawWidth) / 2;
    }

    this.xComponentController.setXComponentSurfaceRect({
      surfaceWidth: drawWidth,
      surfaceHeight: drawHeight,
      offsetX: offsetX,
      offsetY: offsetY
    });
  }

  build() {
    XComponent({ type: XComponentType.SURFACE, controller: this.xComponentController })
      .onAttach(() => {
        console.info('CameraPreview onAttach');
        const surfaceId = this.xComponentController.getXComponentSurfaceId();
        if (surfaceId && this.onSurfaceReady) {
          this.onSurfaceReady(surfaceId);
        }
      })
      .onAreaChange((oldValue: Area, newValue: Area) => {
        const widthVp = newValue.width as number;
        const heightVp = newValue.height as number;
        if (widthVp > 0 && heightVp > 0) {
          const widthPx = this.getUIContext().vp2px(widthVp);
          const heightPx = this.getUIContext().vp2px(heightVp);
          this.updateSurfaceRect(widthPx, heightPx);
        }
      })
      .width('100%')
      .height('100%')
  }
}

3.4 实现相机控制器 CameraController

控制器负责所有相机底层操作:初始化、拍照、切换摄像头/比例、释放资源。核心是 setupCamera 方法,步骤包括:

  1. 获取 CameraManager
  2. 选择相机设备(后置/前置)
  3. 创建 CameraInput 并打开
  4. 获取输出能力 CameraOutputCapability,得到预览和拍照的 Profile 列表
  5. 转换 UI 比例为横屏比例 targetProfileRatio = 1 / getRatioValue(currentRatio)
  6. 匹配最佳预览 Profile
  7. 使用预览 Profile 的宽高比匹配拍照 Profile
  8. 创建 PreviewOutputPhotoOutput
  9. 创建 PhotoSession,配置输入输出,提交并启动会话
  10. 注册 photoAssetAvailable 回调,生成缩略图和临时文件

关键代码片段:

javascript 复制代码
private async setupCamera(): Promise<boolean> {
  if (!this.context || !this.surfaceId) return false;
  await this.release();

  try {
    this.cameraManager = camera.getCameraManager(this.context);
    const cameras = this.cameraManager.getSupportedCameras();
    const cameraDevice = cameras[this.currentCameraPos];
    this.cameraInput = this.cameraManager.createCameraInput(cameraDevice);
    await this.cameraInput.open();

    const outputCap = this.cameraManager.getSupportedOutputCapability(cameraDevice, camera.SceneMode.NORMAL_PHOTO);
    const previewProfiles = outputCap.previewProfiles;
    const photoProfiles = outputCap.photoProfiles;

    const uiRatio = getRatioValue(this.currentRatio);
    const targetProfileRatio = 1 / uiRatio;
    const screenWidth = display.getDefaultDisplaySync().width;
    const screenHeight = display.getDefaultDisplaySync().height;

    const previewProfile = findBestProfile(previewProfiles, targetProfileRatio, screenWidth, screenHeight);
    if (!previewProfile) throw new Error('no preview profile');
    if (this.onPreviewProfileSelected) {
      this.onPreviewProfileSelected(previewProfile.size.width, previewProfile.size.height);
    }

    const targetPhotoRatio = previewProfile.size.width / previewProfile.size.height;
    let photoProfile = findBestProfile(photoProfiles, targetPhotoRatio, screenWidth, screenHeight);
    if (!photoProfile && photoProfiles.length) photoProfile = photoProfiles[0];
    if (!photoProfile) throw new Error('no photo profile');

    this.previewOutput = this.cameraManager.createPreviewOutput(previewProfile, this.surfaceId);
    this.photoOutput = this.cameraManager.createPhotoOutput(photoProfile);
    this.setupPhotoOutputCallback();

    this.session = this.cameraManager.createSession(camera.SceneMode.NORMAL_PHOTO) as camera.PhotoSession;
    this.session.beginConfig();
    this.session.addInput(this.cameraInput);
    this.session.addOutput(this.previewOutput);
    this.session.addOutput(this.photoOutput);
    await this.session.commitConfig();
    await this.session.start();

    return true;
  } catch (err) {
    await this.release();
    return false;
  }
}

拍照回调中生成缩略图和临时文件:

javascript 复制代码
private setupPhotoOutputCallback(): void {
  if (!this.photoOutput) return;
  const context = this.context;

  this.photoOutput.on('photoAssetAvailable', async (err: BusinessError, photoAsset: photoAccessHelper.PhotoAsset) => {
    if (err) {
      console.error(`photoAssetAvailable error: ${JSON.stringify(err)}`);
      return;
    }
    try {
      const srcUri = photoAsset.uri;
      if (!srcUri) throw new Error('photoAsset.uri is empty');
      if (!context) throw new Error('context is null');
      const tempDir = context.tempDir;
      const fileName = `temp_${Date.now()}.jpg`;
      const tempFilePath = `${tempDir}/${fileName}`;
      // 复制到临时文件中,用完关闭
      const srcFile = fs.openSync(srcUri, fs.OpenMode.READ_ONLY);
      const destFile = fs.openSync(tempFilePath, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE);
      fs.copyFileSync(srcFile.fd, destFile.fd);
      fs.closeSync(srcFile);
      fs.closeSync(destFile);

      try {
        fs.accessSync(tempFilePath);
      } catch (e) {
        console.error(`temp file not accessible after copy: ${JSON.stringify(e)}`);
      }

      if (this.onPhotoCaptured) {
        // 缩略图两种方式都可以
        // const fileImageSource = image.createImageSource(tempFilePath);
        // const thumbPixelMap = await fileImageSource.createPixelMap({
        //   desiredSize: { width: 200, height: 200 },
        //   desiredPixelFormat: image.PixelMapFormat.RGBA_8888
        // });
        // this.onPhotoCaptured(thumbPixelMap, tempFilePath);

        const thumbnail = await photoAsset.getThumbnail();
        this.onPhotoCaptured(thumbnail, tempFilePath);
      }
    } catch (e) {
      console.error(`process photo asset error: ${JSON.stringify(e)}`);
    }
  });
}

拍照时固定使用 ROTATION_0,让系统自动处理方向:

javascript 复制代码
public async takePhoto(rotation = camera.ImageRotation.ROTATION_0): Promise<void> {
  const settings = {
    quality: camera.QualityLevel.QUALITY_LEVEL_HIGH,
    rotation: rotation,
    mirror: this.currentCameraPos === 1
  };
  await this.photoOutput.capture(settings);
}

3.5 实现权限申请与页面生命周期

主页面 Index.ets 负责权限申请、状态管理、协调子组件。

权限申请 :使用自定义的 PermissionHelper,它封装了首次请求和二次引导(弹窗提示用户去设置中开启)。

javascript 复制代码
aboutToAppear() {
  PermissionHelper.requestWithFallback(
    this.context,
    REQUIRED_PERMISSIONS,
    () => {
      this.hasPermission = true;
      this.loadLatestThumbnail(); // 异步加载相册最新缩略图(仅展示,不影响相机)
      this.initCamera();
    },
    () => {
      this.hasPermission = false;
      this.showToast('未获得相机权限,部分功能无法使用');
    }
  );
}

XComponent 的 surface 就绪回调

javascript 复制代码
private onSurfaceReady = (surfaceId: string) => {
  this.surfaceId = surfaceId;
  this.initCamera();  // 如果权限已授予且未初始化,会触发相机初始化
}

相机初始化方法(双重保险,避免因执行顺序导致黑屏):

javascript 复制代码
private async initCamera() {
  if (!this.hasPermission || !this.surfaceId || this.isCameraInitialized) return;
  const pos = this.isFrontCamera ? 1 : 0;
  const success = await this.cameraController.init(this.surfaceId, this.context, pos, this.currentRatio);
  if (success) {
    this.isCameraInitialized = true;
    this.cameraController.setFlashMode(this.flashMode);
  }
  this.cameraController.setPhotoCapturedCallback(async (thumbnail, tempFilePath) => {
    this.thumbnailPixelMap = thumbnail;
    await PhotoSaveHelper.saveWithDialog(tempFilePath, this.context);
  });
  this.cameraController.setPreviewProfileCallback((w, h) => {
    this.previewProfileWidth = w;
    this.previewProfileHeight = h;
  });
}

拍照与缩略图更新

javascript 复制代码
private async takePhoto() {
  if (!this.hasPermission) return;
  await this.cameraController.takePhoto(camera.ImageRotation.ROTATION_0);
}

Toolbar 组件中必须使用 @Link 接收缩略图 PixelMap,父组件可以直接传递 this.thumbnailPixelMap 或使用 $thumbnailPixelMap(两者均可)。注意:不能使用 @Prop ,因为 PixelMap 是 C++ 原生句柄,@Prop 的深拷贝会导致数据丢失。

javascript 复制代码
// 父组件 Index.ets
@State thumbnailPixelMap: PixelMap | undefined;
Toolbar({ thumbnailPixelMap: this.thumbnailPixelMap })  // 或使用 $thumbnailPixelMap

// 子组件 Toolbar.ets
@Link thumbnailPixelMap: PixelMap | undefined;
Image(this.thumbnailPixelMap)
  .width(56).height(56).borderRadius(28).objectFit(ImageFit.Cover)

实测结论 :无论是相机生成的 PixelMap 还是从本地资源加载的 PixelMap,都无法通过 @Prop 传递,必须使用 @LinkAppStorage

3.7 保存照片到相册

使用 photoAccessHelper.showAssetsCreationDialog 弹出系统保存窗口,用户确认后自动复制文件到相册,无需额外申请读写权限。

javascript 复制代码
public static async saveWithDialog(tempFilePath: string, context: common.UIAbilityContext): Promise<boolean> {
  const srcUris = [fileUri.getUriFromPath(tempFilePath)];
  const phAccessHelper = photoAccessHelper.getPhotoAccessHelper(context);
  const photoCreationConfigs = [{
    fileNameExtension: 'jpg',
    photoType: photoAccessHelper.PhotoType.IMAGE,
    subtype: photoAccessHelper.PhotoSubtype.DEFAULT
  }];
  const desUris = await phAccessHelper.showAssetsCreationDialog(srcUris, photoCreationConfigs);
  if (desUris.length === 0) return false;
  // 分块复制文件到目标URI,然后删除临时文件
  // ...(具体实现见仓库)
  return true;
}

四、总结

通过以上步骤完成了相机应用的基础功能:权限申请、预览显示、拍照、保存相册、缩略图更新。

在开发过程中,我遇到了不少问题,例如预览拉伸、照片比例错误、方向错乱、黑屏、缩略图不显示等。这些问题将在下一篇中详细分析原因并提供解决方案。

自定义相机一定会遇到的问题总结:

  1. 预览画面严重拉伸(比例转换 + cover 裁剪)
  2. 拍出的照片是正方形(拍照 Profile 与预览同比例)
  3. 斜着拍照方向错误(固定 ROTATION_0
  4. 动态切换比例后预览不更新(清空预览尺寸 + @Watch
  5. 首次安装同意权限后黑屏(权限回调和 onSurfaceReady 双保险)
  6. 拍照后缩略图无法显示(@Link 引用传递代替 @Prop 深拷贝)

如果你觉得本文有帮助,请点赞、收藏、转发支持!

相关推荐
不羁的木木2 小时前
《HarmonyOS 6.1 新能力实战之智感握姿》第一篇:初识智感握姿——能力与场景全解析
华为·harmonyos
狼哥16862 小时前
《新闻资讯》八、产品定制层实现指南
ui·华为·harmonyos
浮芷.2 小时前
六星光芒阵:HarmonyOS API 24 Canvas 高级绘图实战
科技·华为·开源·harmonyos·鸿蒙
2601_957418802 小时前
Android手机与相机USB有线连接技术
数码相机
极客范儿2 小时前
华为HCIP网络工程师认证—交换基础
网络·华为
狼哥16862 小时前
《新闻资讯》一、应用分层模块化整体实现指南
ui·harmonyos
木咺吟2 小时前
鸿蒙原生应用实战(一):项目搭建与首页开发 — 游戏收藏夹
华为·harmonyos
风华圆舞3 小时前
鸿蒙 + Flutter 如何把 AI 助手嵌进应用页面里——以食界探味为
人工智能·flutter·harmonyos
金启攻3 小时前
【鸿蒙原生应用实战】第五篇:活动记录页——数据筛选、统计与成就系统
harmonyos