鸿蒙实战:从零实现自定义相机(下)——填平预览拉伸、比例错乱、缩略图消失的六大坑

完整源码:ArtCamera 从一脸懵逼到流畅运行,我用两天时间踩遍了鸿蒙相机开发的坑,最终逐一攻克了预览拉伸、照片比例错误、斜拍方向乱、切换比例失效、首次安装黑屏、缩略图不显示六大难题。 本文完整记录问题现象、原因分析和解决方案,关键代码附详细注释。完整源码见仓库。

一、背景与需求

开发一款鸿蒙原生相机应用,功能包括:

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

看似简单,实际开发中却接连踩坑。本文将逐一复盘,并给出最终稳定的解决方案。

二、整体架构

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

复制代码
entry/src/main/ets/
├── pages/
│   └── Index.ets                # 主页面,状态管理,权限协调
├── components/
│   ├── CameraPreview.ets        # 封装 XComponent,动态计算 surfaceRect
│   ├── Toolbar.ets              # 底部工具栏(比例、拍照、切换摄像头)
│   ├── TopBar.ets               # 顶部栏(闪光灯)
│   └── ThumbnailView.ets        # 查看缩略图组件(待实现)
├── utils/
│   ├── CameraController.ets     # 相机核心:初始化、会话、拍照、切换
│   ├── CameraUtils.ets          # Profile 匹配、比例转换工具
│   ├── PhotoSaveHelper.ets      # 保存照片、获取缩略图
│   ├── PermissionHelper.ets     # 权限申请与二次引导
│   └── SensorHelper.ets         # 重力传感器(方向感知)
├── constants/
│   └── CameraConstants.ets      # 比例常量、闪光灯模式数组
└── model/
    ├── Resolution.ets
    ├── FlashModeItem.ets
    └── RotationResult.ets

核心流程

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

三、六大技术难点与解决方案

难点 1:预览画面严重拉伸

现象

选择 9:16 比例,预览图像被横向拉宽,人物变形。

定位

打印相机返回的 previewProfile,发现实际选中的是 1440x1440(1:1)。原因是 UI 比例 9:16 对应竖屏宽高比 9/16 = 0.5625,而相机硬件输出的 PreviewProfile 列表全是横屏宽高比(例如 1920x1080 比例 16/9≈1.777)。用 0.5625 去匹配横屏列表,找不到相近项,fallback 选了宽度>=屏幕宽度的最小分辨率,结果选到 1440x1440。

解决方案

将 UI 比例(竖屏)转换为相机需要的横屏比例:targetProfileRatio = 1 / uiRatio。例如 9:16 → 16:9 ≈1.777,这样就能准确匹配到 1920x1080 等正确 Profile。

核心代码(CameraController.ets 中的 setupCamera 方法)

javascript 复制代码
private async setupCamera(): Promise<boolean> {
  // ... 省略:获取 cameraManager、cameraDevice、创建 cameraInput 并打开等 ...

  const previewProfiles = outputCap.previewProfiles;
  const photoProfiles = outputCap.photoProfiles;

  // 关键:将 UI 竖屏比例转换为相机横屏比例
  const uiRatio = getRatioValue(this.currentRatio);   // 9:16 -> 0.5625
  const targetProfileRatio = 1 / uiRatio;             // 16:9 ≈1.777
  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');
  // ... 省略:回调通知上层预览尺寸 ...

  // 拍照 Profile 使用与预览相同的比例
  const targetPhotoRatio = previewProfile.size.width / previewProfile.size.height;  // 16:9
  let photoProfile = findBestProfile(photoProfiles, targetPhotoRatio, screenWidth, screenHeight);
  // ... 省略:创建 previewOutput、photoOutput、session,配置并启动会话等 ...

  return true;
}

同时,为了让预览画面填满 UI 容器且不变形,在 CameraPreview 组件内使用 cover 裁剪模式 (类似 CSS object-fit: cover):

javascript 复制代码
// CameraPreview.ets 中的 updateSurfaceRect 方法
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 模式:surface 完全覆盖容器,超出部分裁剪
  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, offsetY
  });
}

难点 2:拍出的照片是正方形

现象

预览比例 9:16 正常,但拍摄出来的照片却是 1:1 的正方形。

原因

选择 photoProfile 时没有与预览比例保持一致,可能 fallback 选中了 1:1 的拍照分辨率。

解决方案

使用预览 Profile 的实际宽高比来匹配拍照 Profile(已在上面 setupCamera 中体现)。

核心代码(同上 setupCamera 中的关键行)

javascript 复制代码
const targetPhotoRatio = previewProfile.size.width / previewProfile.size.height;  // 16:9
let photoProfile = findBestProfile(photoProfiles, targetPhotoRatio, screenWidth, screenHeight);

确保预览和拍照输出比例完全一致。

难点 3:斜着拍照方向错误

现象

竖拍正常,横拍或倒置手机时,拍出的照片方向旋转了 90° 或 180°。

原因

手动传入传感器计算的 rotation 角度给 capture 方法。但 HarmonyOS 相机 API 会自动根据设备方向将正确的方向信息写入 EXIF,手动传入会导致双重旋转。

解决方案

拍照时固定使用 camera.ImageRotation.ROTATION_0,完全依赖系统自动修正。

核心代码(Index.ets 中的 takePhoto 方法)

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

CameraController.ets 中的 takePhoto 方法

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

难点 4:动态切换比例后预览不更新

现象

点击 1:1、3:4、9:16 按钮,相机虽然重新初始化了,但预览画面仍停留在旧比例。

原因

CameraPreview 组件中绑定的 previewProfileWidth/Height 没有及时清空,组件沿用旧值计算 surfaceRect。

解决方案

在切换比例时主动清空这两个变量,等待相机回调重新赋值。

核心代码(Index.ets 中的 handleChangeRatio 方法)

javascript 复制代码
private async handleChangeRatio(ratio: AspectRatio): Promise<void> {
  if (!this.hasPermission || this.currentRatio === ratio) return;
  this.currentRatio = ratio;
  this.previewAspectRatio = getRatioValue(ratio);
  // 清空预览尺寸,等待相机重新回调
  this.previewProfileWidth = 0;
  this.previewProfileHeight = 0;
  this.isCameraInitialized = false;
  const success = await this.cameraController.changeRatio(ratio);
  if (success) {
    this.isCameraInitialized = true;
    setTimeout(() => this.cameraController.setFlashMode(this.flashMode), 200);
  }
}

同时在 CameraPreview 中使用 @Watch('onProfileChanged') 监听属性变化,一旦更新立即重新计算布局。

难点 5:首次安装同意权限后黑屏

现象

首次打开 App,弹出相机权限,点击同意后预览区域一片黑。

原因

XComponentonAttach 可能在权限弹窗前就执行了,当时 hasPermission 为 false,没有调用 initCamera。用户同意权限后,onAttach 不会再触发,导致相机永远不会初始化。

解决方案

双重保险:在权限回调中主动调用 initCamera(),同时在 onSurfaceReady 中也调用一次。

核心代码(Index.ets 中的 aboutToAppearonSurfaceReady

javascript 复制代码
aboutToAppear() {
  this.sensorHelper.startListening((rotation: camera.ImageRotation) => {
    this.currentRotation = rotation;
  });

  PermissionHelper.requestWithFallback(
    this.context,
    REQUIRED_PERMISSIONS,
    () => {
      this.hasPermission = true;
      this.loadLatestThumbnail();
      this.initCamera();
    },
    () => {
      this.hasPermission = false;
      this.showToast('未获得相机权限,部分功能无法使用');
    }
  );
}

private onSurfaceReady = (surfaceId: string): void => {
  this.surfaceId = surfaceId;
  this.initCamera();
};

initCamera 内部会检查所有条件(权限、surfaceId、已初始化标志),多次调用是安全的。

难点 6:拍照后缩略图无法显示(核心原因:@Prop 深拷贝导致 Native 句柄丢失)

现象

拍照回调中拿到的 thumbnail (PixelMap) 非空,能打印出宽高。但通过 @Prop 传递给子组件 Toolbar 后,Image 始终不显示。

尝试了 clone()、从文件重新生成、直接用 @State 在父组件显示------父组件正常,子组件不行。

关键实验

  • 从本地资源加载的 PixelMap 通过 @Prop 传给子组件 → ❌ 不显示
  • 相机回调中得到的 thumbnail 通过 @Prop 传给子组件 → ❌ 不显示
  • @Prop 改为 @Link 传递同一个 thumbnail → ✅ 正常显示

根本原因

通过断点发现 PixelMap 是 C++ 原生对象句柄,其内部结构包含一个 _napiwrapperSymbol(Symbol.nativeBinding) = [External: 0x...]

  • @Prop 采用深拷贝/序列化方式传递数据,对于普通 JS 对象没问题,但对于这种原生句柄,深拷贝无法复制底层像素缓冲区,强制序列化拷贝后原生句柄丢失,PixelMap 失效。
  • @Link 采用引用传递(浅拷贝) ,父子组件共享同一个对象实例,因此原生句柄有效。或者你也可以采用 AppStorage 全局应用共享,但不如 @Link 便捷。

结论 PixelMap 对象不能使用 @Prop 深拷贝,必须使用 @LinkAppStorage

核心代码(父组件 Index.ets

javascript 复制代码
@State thumbnailPixelMap: string | Resource | PixelMap | undefined = undefined;

// 拍照回调中直接赋值
this.cameraController.setPhotoCapturedCallback(async (thumbnail: image.PixelMap, tempFilePath: string) => {
  const saved = await PhotoSaveHelper.saveWithDialog(tempFilePath, this.context);
  this.thumbnailPixelMap = thumbnail;
  this.showToast(saved ? '已保存到相册' : '保存失败');
});

子组件 Toolbar.ets

javascript 复制代码
@Link thumbnailPixelMap: string | Resource | PixelMap;   // 注意是 @Link 不是 @Prop

build() {
  Row() {
    Image(this.thumbnailPixelMap)
      .width(56).height(56).borderRadius(28).border({ width: 1, color: Color.White })
      .onClick(() => this.onThumbnailPress?.())
    // ... 其他按钮
  }
}

父组件传值 (在 Index.etsbuild 中):

javascript 复制代码
Toolbar({
  currentRatio: this.currentRatio,
  thumbnailPixelMap: this.thumbnailPixelMap,   // 注意:此处直接传递,未使用 $ 符号
  // ... 其他参数
})

核心规则总结

  • @Prop 适合:string/number/boolean/简单可序列化对象
  • @Link 适合:引用类型、原生句柄、需要实时共享的数据
  • 相机回调中的 PixelMap 属于生命周期敏感的原生句柄 ,必须用 @Link

四、核心技术选型与工具函数

4.1 Profile 匹配算法(CameraUtils.ets

完整实现

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. 只满足宽度
  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];
}

4.2 照片保存到相册(PhotoSaveHelper

使用 showAssetsCreationDialog 弹窗授权保存临时文件:

javascript 复制代码
public static async saveWithDialog(tempFilePath: string, context: common.UIAbilityContext): Promise<boolean> {
  try {
    fs.accessSync(tempFilePath);
    const srcFileUris: string[] = [fileUri.getUriFromPath(tempFilePath)];
    const photoCreationConfigs: photoAccessHelper.PhotoCreationConfig[] = [{
      fileNameExtension: 'jpg',
      photoType: photoAccessHelper.PhotoType.IMAGE,
      subtype: photoAccessHelper.PhotoSubtype.DEFAULT
    }];
    const phAccessHelper = photoAccessHelper.getPhotoAccessHelper(context);
    const desFileUris = await phAccessHelper.showAssetsCreationDialog(srcFileUris, photoCreationConfigs);
    if (desFileUris.length === 0) return false;
    // 分块复制文件到目标URI,然后删除临时文件
    // ... 实现细节见仓库
    return true;
  } catch (err) {
    console.error(`saveWithDialog error: ${JSON.stringify(err)}`);
    return false;
  }
}

4.3 传感器方向监听(用于预览画面跟随设备旋转)

完整 calculateRotation 函数实现:

javascript 复制代码
// SensorHelper.ets
private calculateRotation(x: number, y: number, z: number): RotationResult {
  let degree: number = -1;
  // 如果设备平放(z轴分量过大),不改变方向
  if ((x * x + y * y) * 3 < z * z) {
    return { rotation: this.currentRotation, orientation: this.currentOrientation };
  }
  // 计算角度(0~360)
  degree = 90 - Math.round(Math.atan2(y, -x) * 180 / Math.PI);
  degree = degree >= 0 ? degree % 360 : degree % 360 + 360;

  let rotation: camera.ImageRotation;
  let orientation: DeviceOrientation;
  if (degree >= 0 && (degree <= 45 || degree >= 315)) {
    rotation = camera.ImageRotation.ROTATION_0;
    orientation = 'portrait';
  } else if (degree >= 45 && degree <= 135) {
    rotation = camera.ImageRotation.ROTATION_270;
    orientation = 'landscapeLeft';
  } else if (degree >= 135 && degree <= 225) {
    rotation = camera.ImageRotation.ROTATION_180;
    orientation = 'portraitUpsideDown';
  } else {
    rotation = camera.ImageRotation.ROTATION_90;
    orientation = 'landscapeRight';
  }
  return { rotation, orientation };
}

public startListening(onChange: (rotation: camera.ImageRotation, orientation: DeviceOrientation) => void): void {
  if (this.isListening) return;
  this.callback = onChange;
  try {
    sensor.on(sensor.SensorId.GRAVITY, (data: sensor.GravityResponse) => {
      const result = this.calculateRotation(data.x, data.y, data.z);
      if (this.currentRotation !== result.rotation || this.currentOrientation !== result.orientation) {
        this.currentRotation = result.rotation;
        this.currentOrientation = result.orientation;
        if (this.callback) this.callback(result.rotation, result.orientation);
      }
    });
    this.isListening = true;
  } catch (err) {
    console.error(`SensorHelper startListening error: ${JSON.stringify(err)}`);
  }
}

注意 :预览画面的旋转仅影响显示效果,不影响最终照片的方向 。拍照时仍使用固定的 ROTATION_0,照片的方向由系统自动写入 EXIF 修正。

五、运行效果

经过上述修改,应用达到以下效果:

  • 预览:9:16 时完全填满屏幕,比例准确,人物正常。
  • 拍照:输出照片与预览比例一致,横竖拍方向自动正确。
  • 切换比例:1:1、3:4、9:16 平滑切换,无残留画面。
  • 缩略图 :拍照后底部圆框立即显示最新照片(@Link 双向同步,无延迟)。
  • 首次安装:同意权限后正常预览,无黑屏。

六、踩坑总结与建议

  1. 比例转换:UI比例(竖)与相机Profile(横)互为倒数。
  2. 预览与拍照同比例:用预览的实际宽高比选拍照Profile。
  3. 拍照方向 :固定 ROTATION_0,让系统EXIF自动修正。
  4. Native对象跨组件传递PixelMap 必须使用 @Link(引用传递),不能用 @Prop(深拷贝会丢失句柄)。
  5. XComponent生命周期 :权限回调和 onSurfaceReady 双保险调用 initCamera
  6. 日志要充足:打印选中的Profile、初始化结果、回调触发等,快速定位问题。

七、结语

两天的时间,从一脸迷茫到跑通所有功能,收获的不只是代码,更是对鸿蒙相机 API、状态管理、Native 对象传递机制的深刻理解。希望这篇总结能帮助更多开发者绕过这些坑,快速实现自己的相机应用。

如果你在开发中也遇到了奇怪的问题,欢迎在评论区交流。觉得有用的话,别忘了点赞、收藏、转发~

相关推荐
风满城3321 小时前
鸿蒙原生应用实战(三):设置与统计页面开发 — 数据驱动的功能模块
harmonyos
xcLeigh21 小时前
鸿蒙平台 KeePass 密码管理器适配实战:从 Windows 到 鸿蒙PC 的 Electron 迁移指南
windows·electron·web·harmonyos·加密算法·keepass
金启攻21 小时前
鸿蒙原生应用开发实战(一):从零搭建“钓点日记“——项目初始化与环境配置全指南
harmonyos
风华圆舞1 天前
鸿蒙语音识别为什么要区分 startListening 和 stopListening
华为·语音识别·harmonyos
YM52e1 天前
鸿蒙PC ArkTS 声明合并问题深度解析与最佳实践
学习·华为·harmonyos·鸿蒙·鸿蒙系统
互联网散修1 天前
鸿蒙实战:网络状态监听与诊断工具
网络·华为·harmonyos·网络状态监听
祭曦念1 天前
从零开始构建鸿蒙纪念日提醒 App:ArkTS + API 24 实战
华为·harmonyos
浮芷.1 天前
鸿蒙HarmonyOS 6.1新特性之沉浸式光感效果实现过程中的各类问题解决-鸿蒙PC版(一)
华为·harmonyos·鸿蒙·鸿蒙系统
轻口味1 天前
轻规划鸿蒙开发实战7:接管 Ability Kit 跨设备流转,EntryAbility 全链路 onContinue 数据打包与无缝接
华为·harmonyos·鸿蒙
风满城331 天前
鸿蒙原生应用实战(五):教程、主题与项目总结 — 从开发到上线的完整回顾
harmonyos