第37篇|DualPhotoComposerService:把两张照片合成一张双镜照片

第 37 篇看图像合成服务。前后两路照片交付以后,项目希望生成一张更适合展示和分享的双镜作品:后摄大图作为主画面,前摄图以圆形小窗叠加。这个职责被放进 DualPhotoComposerService,页面层只负责传入 backPath、frontPath 和 compositePath。

本文是 21 天「智能相机开发实战」训练营中的一篇实操记录。所有代码片段都来自当前项目,配图围绕运行页面和源码关键路径展开,读完以后可以直接回到工程里按函数名定位。

本篇目标

  • 理解合成参数为什么要集中在服务层。
  • 读懂 composeDualPhoto 的校验、解码、绘制和写出流程。
  • 知道合成失败时为什么要保留原片。
  • 为第 38 篇双拍闭环准备合成结果。

代码位置

  • entry/src/main/ets/services/DualPhotoComposerService.ets
  • entry/src/main/ets/pages/Index.ets

一、合成服务让页面只关心结果

相册详情里用户看到的是一张双镜作品。页面不需要知道圆窗半径、描边、输出尺寸和像素拷贝方式,这些都属于图像处理服务。服务层越稳定,页面层越能专注交互、状态和错误提示。

图1 DualPhotoComposerService 接收两张照片并输出合成作品

图1 DualPhotoComposerService 接收两张照片并输出合成作品

二、版式参数:输出尺寸和前摄圆窗固定在服务里

服务顶部集中定义输出宽高、前摄圆窗比例、边距、描边和阴影。这样后续调整视觉风格时,只需要改服务参数,不必在页面拍摄流程里寻找魔法数字。

图2 DualPhotoComposerService 集中定义合成版式参数

图2 DualPhotoComposerService 集中定义合成版式参数

export class DualPhotoComposerService {
  private static readonly FALLBACK_OUTPUT_WIDTH: number = 1440;
  private static readonly FALLBACK_OUTPUT_HEIGHT: number = 1920;
  private static readonly DUAL_OUTPUT_WIDTH: number = 2160;
  private static readonly DUAL_OUTPUT_HEIGHT: number = 3840;
  private static readonly MAX_OUTPUT_LONG_SIDE: number = 2560;
  private static readonly FRONT_SCALE: number = 0.22;
  private static readonly FRONT_MARGIN_SCALE: number = 0.055;
  private static readonly FRONT_BORDER_SCALE: number = 0.012;
  private static readonly JPEG_QUALITY: number = 92;

这类参数属于图像生成规则,而不是 UI 状态。放错层会让拍照流程越来越难读。

三、composeDualPhoto:从校验路径到写出 composite 文件

composeDualPhoto 先检查路径是否存在,再读取两张图的尺寸,计算输出规格,解码 PixelMap,创建输出缓冲,然后把后摄作为底图、前摄裁成圆窗叠加。最后写出到 outputPath

图3 composeDualPhoto 负责校验、解码、绘制和写出合成图

图3 composeDualPhoto 负责校验、解码、绘制和写出合成图

  static async composeDualPhoto(backPath: string, frontPath: string, outputPath: string): Promise<void> {
    if (backPath.trim().length === 0 || frontPath.trim().length === 0 || outputPath.trim().length === 0) {
      throw new Error('双摄素材合成路径不完整。');
    }

    let backImage: DecodedBgraImage | undefined = undefined;
    let frontImage: DecodedBgraImage | undefined = undefined;
    let outputPixelMap: image.PixelMap | undefined = undefined;
    let packer: image.ImagePacker | undefined = undefined;

    try {
      const backSourceSize = await DualPhotoComposerService.getImageSize(backPath);
      const frontSourceSize = await DualPhotoComposerService.getImageSize(frontPath);
      const outputSize = DualPhotoComposerService.normalizeOutputSize(
        backSourceSize
      );
      const backSize = DualPhotoComposerService.normalizeCoverDecodeSize(
        outputSize,
        backSourceSize
      );
      const circleLayout = DualPhotoComposerService.createCircleOverlayLayout(outputSize);
      const frontSize = DualPhotoComposerService.normalizeCircleDecodeSize(circleLayout.diameter, frontSourceSize);

      backImage = await DualPhotoComposerService.decodeToBgra(backPath, backSize);
      frontImage = await DualPhotoComposerService.decodeToBgra(frontPath, frontSize);

      const outputBuffer = new ArrayBuffer(outputSize.width * outputSize.height * 4);
      const outputBytes = new Uint8Array(outputBuffer);
      DualPhotoComposerService.copyCoverImage(
        outputBytes,
        outputSize.width,
        outputSize.height,
        backImage.bytes,
        backSize.width,
        backSize.height
      );

      const shadowOffsetX = Math.max(8, Math.round(circleLayout.border * 1.7));
      const shadowOffsetY = Math.max(12, Math.round(circleLayout.border * 2.3));
      DualPhotoComposerService.fillCircle(
        outputBytes,
        outputSize.width,
        outputSize.height,
        circleLayout.shellX + Math.round(circleLayout.shellDiameter / 2) + shadowOffsetX,
        circleLayout.shellY + Math.round(circleLayout.shellDiameter / 2) + shadowOffsetY,
        Math.round(circleLayout.shellDiameter / 2),
        0,
        0,
        0,
        64
      );
      DualPhotoComposerService.fillCircle(
        outputBytes,
        outputSize.width,
        outputSize.height,
        circleLayout.shellX + Math.round(circleLayout.shellDiameter / 2),
        circleLayout.shellY + Math.round(circleLayout.shellDiameter / 2),
        Math.round(circleLayout.shellDiameter / 2),
        246,
        248,

这段流程里最值得学的是职责收口:页面层不操作像素,服务层不决定相册选中项。

四、文件读写:服务层独立处理本地文件

合成服务内部封装了读取和写入本地文件的方法。页面只传路径,不接触底层 byte array。这样合成失败可以被统一捕获,上层再决定保留原片或展示提示。

图4 DualPhotoComposerService 独立处理本地文件读取和写入

图4 DualPhotoComposerService 独立处理本地文件读取和写入

    let file: fs.File | undefined = undefined;
    try {
      file = fs.openSync(path, fs.OpenMode.READ_ONLY);
      const stat = fs.statSync(file.fd);
      const buffer = new ArrayBuffer(stat.size);
      fs.readSync(file.fd, buffer);
      return buffer;
    } catch (error) {
      throw new Error(`读取素材失败:${DualPhotoComposerService.getErrorMessage(error)}`);
    } finally {
      DualPhotoComposerService.closeFileQuietly(file);
    }
  }

  private static writeFile(path: string, buffer: ArrayBuffer): void {
    let file: fs.File | undefined = undefined;
    try {
      file = fs.openSync(
        path,
        fs.OpenMode.CREATE | fs.OpenMode.WRITE_ONLY | fs.OpenMode.TRUNC
      );
      fs.writeSync(file.fd, buffer);
    } catch (error) {
      throw new Error(`写入合成素材失败:${DualPhotoComposerService.getErrorMessage(error)}`);
    } finally {
      DualPhotoComposerService.closeFileQuietly(file);

当合成失败时,不应该删除原始后摄和前摄照片。第 38 篇会看到上层如何在失败时返回原路径,保证用户作品不丢。

五、合成服务为什么先计算尺寸再解码

composeDualPhoto 的第一步不是直接读完整大图,而是先获取后摄、前摄原始尺寸,再计算输出图、后摄 cover 尺寸和前摄圆窗解码尺寸。这样做可以控制内存峰值,也让前摄小窗在不同分辨率照片上保持稳定比例。对于移动端相机项目来说,这比“先解码原图再缩放”更稳,因为原图可能来自不同镜头、不同方向和不同分辨率。

      const backSourceSize = await DualPhotoComposerService.getImageSize(backPath);
      const frontSourceSize = await DualPhotoComposerService.getImageSize(frontPath);
      const outputSize = DualPhotoComposerService.normalizeOutputSize(
        backSourceSize
      );
      const backSize = DualPhotoComposerService.normalizeCoverDecodeSize(
        outputSize,
        backSourceSize
      );
      const circleLayout = DualPhotoComposerService.createCircleOverlayLayout(outputSize);
      const frontSize = DualPhotoComposerService.normalizeCircleDecodeSize(circleLayout.diameter, frontSourceSize);

      backImage = await DualPhotoComposerService.decodeToBgra(backPath, backSize);
      frontImage = await DualPhotoComposerService.decodeToBgra(frontPath, frontSize);

这段代码把“作品尺寸”“后摄铺底尺寸”“前摄头像尺寸”拆成三个独立问题。后摄负责覆盖整个画布,前摄只负责圆形区域,输出图负责被相册、分享和后续 AI 视频链路读取。页面层不参与这些计算,页面只关心最终文件路径是否生成成功。

六、圆形小窗不是装饰,而是作品信息结构

双镜照片的主语是后摄场景,前摄小窗是当时的人像或表情补充。服务层用阴影、外壳和圆形裁剪把两者区分开,避免前摄图直接压在后摄内容上造成混乱。代码中先复制后摄 cover 图,再绘制圆窗阴影和浅色外壳,最后把前摄像素按圆形 mask 写入输出缓冲区。

      const shadowOffsetX = Math.max(8, Math.round(circleLayout.border * 1.7));
      const shadowOffsetY = Math.max(12, Math.round(circleLayout.border * 2.3));
      DualPhotoComposerService.fillCircle(
        outputBytes,
        outputSize.width,
        outputSize.height,
        circleLayout.shellX + Math.round(circleLayout.shellDiameter / 2) + shadowOffsetX,
        circleLayout.shellY + Math.round(circleLayout.shellDiameter / 2) + shadowOffsetY,
        Math.round(circleLayout.shellDiameter / 2),
        0,
        0,
        0,
        64
      );
      DualPhotoComposerService.fillCircle(
        outputBytes,
        outputSize.width,
        outputSize.height,
        circleLayout.shellX + Math.round(circleLayout.shellDiameter / 2),
        circleLayout.shellY + Math.round(circleLayout.shellDiameter / 2),
        Math.round(circleLayout.shellDiameter / 2),
        246,
        248,
        250,

从验收角度看,圆窗位置、直径、边框和阴影都要在服务层集中控制。以后如果要扩展成方形小窗、双人小窗或带水印的成片,只需要调整合成服务的布局函数,而不是回到页面里重排一堆预览组件。

工程检查清单

  • 图像合成参数集中管理,避免页面层散落魔法数字。
  • 合成前检查输入路径,失败时抛出明确错误。
  • PixelMap 使用后要释放,避免内存占用累积。
  • 输出文件写入成功后再让上层替换成 compositePath。
  • 合成失败不等于拍摄失败,原片要保留。

今日练习

  1. 调整前摄圆窗比例的参数,预估相册展示会发生什么变化。
  2. 阅读 composeDualPhoto 中的资源释放逻辑,确认异常路径是否也能清理。
  3. 思考如果以后要加入水印,应该放在合成服务还是页面层。

下一篇会继续沿着同一条工程链路往下拆:先看用户能看到的效果,再回到源码确认状态、文件和服务边界是否闭合。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐