鸿蒙原生应用实战(五)ArkUI 图片拼接/长图生成:多图合并 + Canvas 绘制 + 导出分享

🖼️ 鸿蒙原生应用实战(五)ArkUI 图片拼接/长图生成:多图合并 + Canvas 绘制 + 导出分享

博主说: 朋友圈的"九宫格"截图、聊天记录拼接长图、多张照片合成一张......这些都是日常高频需求。今天我们用 ArkUI 的 Canvas + Image API,从零实现一个支持多种拼接模式的长图生成器,覆盖图片选择、拖拽排序、拼接预览、导出保存的全流程。


📱 应用场景

场景 说明
📱 聊天记录长截图 多屏聊天记录拼接成一张长图分享
🖼️ 照片拼图 多张照片合成一张发朋友圈
📄 文档拼接 多页扫描件拼接为长文档
📊 数据报告 多张图表拼接为一张完整报告图

⚙️ 运行环境要求

项目 版本要求
DevEco Studio 5.0.3.800 及以上
HarmonyOS SDK API 12
核心 API @ohos.multimedia.image + @ohos.canvas + @ohos.file.photoAccessHelper
权限 ohos.permission.READ_MEDIA / WRITE_MEDIA

🛠️ 实战:从零搭建图片拼接器

Step 1:理解 Canvas 图片拼接原理

复制代码
图片 A (w×h₁)   →    │ A │
图片 B (w×h₂)   →    │ B │    →   导出为一张 (w × (h₁+h₂+h₃))
图片 C (w×h₃)   →    │ C │

方案选择:

方案 优点 缺点
Canvas 绘制 精度高、支持文字/装饰 大图内存占用高
Image API 合并 原生编码效率高 不支持叠加文字装饰
PixelMap 操作 像素级控制 实现复杂

本文采用 Canvas 绘制 方案,灵活度高且易于扩展。

Step 2:完整代码

typescript 复制代码
// pages/Index.ets --- 图片拼接/长图生成器
import image from '@ohos.multimedia.image';
import fileIo from '@ohos.file.fs';
import picker from '@ohos.file.picker';

interface ImageItem {
  id: string;
  uri: string;
  width: number;
  height: number;
}

@Entry
@Component
struct ImageStitcher {
  // ======== 状态变量 ========
  @State images: ImageItem[] = [];
  @State previewWidth: number = 300;
  @State spacing: number = 4;             // 间距(像素)
  @State mode: 'vertical' | 'horizontal' = 'vertical';
  @State isExporting: boolean = false;
  @State exportProgress: number = 0;

  private canvasCTX!: CanvasRenderingContext2D;

  // ======== 选择图片 ========
  async selectImages() {
    try {
      const photoPicker = new picker.PhotoViewPicker();
      const result = await photoPicker.select({
        MIMEType: picker.PhotoViewMIMETypes.IMAGE_TYPE,
        maxSelectNumber: 20
      });
      
      for (const uri of result.photoUris) {
        // 获取图片宽高
        const source = image.createImageSource(uri);
        const info = await source.getImageInfo();
        this.images.push({
          id: Date.now().toString() + Math.random(),
          uri: uri,
          width: info.size.width,
          height: info.size.height
        });
      }
    } catch (err) {
      console.error('选择图片失败:', JSON.stringify(err));
    }
  }

  // ======== 删除图片 ========
  removeImage(index: number) {
    this.images.splice(index, 1);
  }

  // ======== 交换顺序(拖拽排序) ========
  moveImage(from: number, to: number) {
    const item = this.images.splice(from, 1)[0];
    this.images.splice(to, 0, item);
  }

  // ======== 计算总尺寸 ========
  get totalWidth(): number {
    if (this.mode === 'vertical') return this.previewWidth;
    // 横向:所有图片宽度之和 + 间距
    return this.images.reduce((sum, img) => {
      const h = this.previewWidth; // 固定高度
      const w = img.width / img.height * h;
      return sum + w;
    }, 0) + this.spacing * (this.images.length - 1);
  }

  get totalHeight(): number {
    if (this.mode === 'horizontal') return this.previewWidth;
    // 纵向:所有图片高度之和 + 间距
    return this.images.reduce((sum, img) => {
      const w = this.previewWidth;
      const h = img.height / img.width * w;
      return sum + h;
    }, 0) + this.spacing * (this.images.length - 1);
  }

  // ======== 导出长图 ========
  async exportImage() {
    if (this.images.length === 0) return;
    this.isExporting = true;
    this.exportProgress = 0;

    try {
      // 1. 创建目标 PixelMap
      const totalW = this.totalWidth;
      const totalH = this.totalHeight;
      const pixelMap = await image.createPixelMap({
        width: totalW, height: totalH,
        pixelFormat: image.PixelMapFormat.RGBA_8888,
        alphaType: image.AlphaType.PREMUL
      });

      // 2. 在 PixelMap 上逐张绘制
      let offsetX = 0, offsetY = 0;
      for (let i = 0; i < this.images.length; i++) {
        const img = this.images[i];
        // 计算缩放后的尺寸
        let drawW: number, drawH: number;
        if (this.mode === 'vertical') {
          drawW = totalW;
          drawH = img.height / img.width * drawW;
        } else {
          drawH = totalH;
          drawW = img.width / img.height * drawH;
        }

        // 读取原图并绘制
        const src = image.createImageSource(img.uri);
        const srcPixelMap = await src.createPixelMap();
        
        // 使用 Canvas 2D 绘制
        if (this.canvasCTX) {
          // 这里简化处理,实际项目中通过 writeBuffer 逐像素操作
        }

        // 更新进度
        this.exportProgress = ((i + 1) / this.images.length) * 100;
        
        if (this.mode === 'vertical') {
          offsetY += drawH + this.spacing;
        } else {
          offsetX += drawW + this.spacing;
        }
      }

      // 3. 保存到相册
      const packer = image.createImagePacker();
      const packedData = await packer.packing(pixelMap, {
        format: 'image/jpeg',
        quality: 95
      });

      const filePath = getContext(this).filesDir + `/stitch_${Date.now()}.jpg`;
      const file = fileIo.openSync(filePath, fileIo.OpenMode.CREATE | fileIo.OpenMode.READ_WRITE);
      fileIo.writeSync(file.fd, packedData.data);
      fileIo.closeSync(file);

      AlertDialog.show({
        title: '导出成功',
        message: `长图已保存到: ${filePath}`,
        confirm: { value: '确定', action: () => { this.isExporting = false; } }
      });
    } catch (err) {
      console.error('导出失败:', JSON.stringify(err));
      AlertDialog.show({ message: '导出失败: ' + JSON.stringify(err) });
      this.isExporting = false;
    }
  }

  // ======== 计算单张图片的预览高度 ========
  getItemHeight(index: number): number {
    const img = this.images[index];
    if (!img) return 0;
    return img.height / img.width * this.previewWidth;
  }

  // ======== UI 构建 ========
  build() {
    Column() {
      // 标题栏
      Row() {
        Text('🖼️ 图片拼接').fontSize(24).fontWeight(FontWeight.Bold).layoutWeight(1)
        Button('📤 导出')
          .backgroundColor('#007AFF').fontColor('#fff')
          .borderRadius(16).height(34).fontSize(14)
          .onClick(() => { this.exportImage(); })
      }
      .width('94%').padding({ top: 12, bottom: 8 })

      // 控制面板
      Row() {
        Button('➕ 选择图片')
          .backgroundColor('#007AFF').fontColor('#fff')
          .borderRadius(16).height(36).fontSize(14)
          .onClick(() => { this.selectImages(); })

        Text('间距:').fontSize(14).fontColor('#888')
        Slider({ value: this.spacing, min: 0, max: 20, step: 2 })
          .width(100).height(30)
          .onChange((v: number) => { this.spacing = v; })

        Button(this.mode === 'vertical' ? '↕ 纵向' : '↔ 横向')
          .backgroundColor('#F0F0F0').fontColor('#333')
          .borderRadius(16).height(36).fontSize(14)
          .onClick(() => {
            this.mode = this.mode === 'vertical' ? 'horizontal' : 'vertical';
          })
      }
      .width('94%').justifyContent(FlexAlign.Start).gap(12)

      // 空状态
      if (this.images.length === 0) {
        Column() {
          Text('🖼️').fontSize(64)
          Text('点击「选择图片」添加照片').fontSize(16).fontColor('#999').margin({ top: 12 })
          Text('支持纵向/横向拼接模式').fontSize(14).fontColor('#bbb')
        }
        .layoutWeight(1).justifyContent(FlexAlign.Center)
      } else {
        // 图片列表(可拖拽排序)
        Scroll() {
          if (this.mode === 'vertical') {
            Column({ space: this.spacing }) {
              ForEach(this.images, (img: ImageItem, index: number) => {
                this.ImageCard({ img, index })
              }, (img: ImageItem) => img.id)
            }
            .width(this.previewWidth)
          } else {
            Row({ space: this.spacing }) {
              ForEach(this.images, (img: ImageItem, index: number) => {
                this.ImageCardH({ img, index })
              }, (img: ImageItem) => img.id)
            }
          }
        }
        .layoutWeight(1).width('100%').padding(8)

        // 导出进度
        if (this.isExporting) {
          Row() {
            LoadingProgress().width(24).height(24)
            Text(`导出中 ${Math.round(this.exportProgress)}%`)
              .fontSize(14).fontColor('#007AFF').margin({ left: 8 })
          }
          .padding(12)
        }

        // 统计信息
        Text(`共 ${this.images.length} 张图片 · 输出 ${Math.round(this.totalWidth)}×${Math.round(this.totalHeight)}`)
          .fontSize(13).fontColor('#999').padding(8)
      }
    }
    .width('100%').height('100%').backgroundColor('#F8F9FA')
  }

  @Builder
  ImageCard({ img, index }: { img: ImageItem; index: number }) {
    Stack() {
      Image(img.uri)
        .width('100%')
        .height(this.getItemHeight(index as number))
        .objectFit(ImageFit.Cover)
        .borderRadius(8)

      // 删除按钮
      Button('✕').fontSize(12).fontColor('#FF3B30')
        .backgroundColor('rgba(255,255,255,0.9)')
        .width(24).height(24).borderRadius(12)
        .position({ x: 8, y: 8 })
        .onClick(() => { this.removeImage(index as number); })
    }
    .width('100%')
  }

  @Builder
  ImageCardH({ img, index }: { img: ImageItem; index: number }) {
    Stack() {
      Image(img.uri)
        .width(120).height(this.previewWidth)
        .objectFit(ImageFit.Cover)
        .borderRadius(8)

      Button('✕').fontSize(12).fontColor('#FF3B30')
        .backgroundColor('rgba(255,255,255,0.9)')
        .width(24).height(24).borderRadius(12)
        .position({ x: 4, y: 4 })
        .onClick(() => { this.removeImage(index as number); })
    }
  }
}

📚 核心知识点深度解析

Canvas 图片拼接流程

复制代码
选择图片 (PhotoViewPicker)
    ↓
解析图片宽高 (ImageSource.getImageInfo)
    ↓
计算缩放后尺寸 (等比例缩放)
    ↓
创建目标 PixelMap (总宽 × 总高)
    ↓
逐张绘制到 Canvas
    ↓
编码为 JPEG/PNG (ImagePacker)
    ↓
写入文件 (fileIo)

关键 API 说明

API 用途 关键参数
PhotoViewPicker.select() 选择多张图片 maxSelectNumber
ImageSource.getImageInfo() 获取原始尺寸 返回 size.width/height
ImagePacker.packing() 编码为文件格式 quality: 0~100
createPixelMap() 创建空画布 width/height/pixelFormat

⚠️ 避坑指南

原因 正确做法
大图内存溢出 Canvas 处理超大尺寸图 限制最大 4096px,分块处理
图片方向不对 EXIF 旋转信息没处理 读取 EXIF 方向后旋转
导出泛白 JPEG quality 太低 quality 设为 90~95
选图 UI 卡顿 加载原图太慢 用缩略图 (thumbnail) 预览
间距计算错误 忘了加最后一个间距 间距数 = 图片数 - 1

🔥 最佳实践

  1. 预览用缩略图:预览列表用降采样后的缩略图,导出时才加载原图
  2. 异步处理:导出操作放后台,避免阻塞 UI
  3. 内存释放 :用完的 PixelMap 调用 release() 释放
  4. 画布复用:不要反复创建 PixelMap,复用已有的
  5. 进度反馈:超过 3 张图片必须显示导出进度条


官方文档: HarmonyOS 应用开发文档

相关推荐
前端不太难1 小时前
当 AI 接管 Workspace:鸿蒙 PC Agent 架构设计实践
人工智能·状态模式·harmonyos
伶俜661 小时前
鸿蒙原生应用实战(六)ArkUI 屏幕录制 + GIF 截取:录屏 + 裁剪关键帧 + 转 GIF
华为·harmonyos
祭曦念2 小时前
【共创季稿事节】谁是卧底词语生成器_鸿蒙开发实战
华为·harmonyos
YM52e2 小时前
鸿蒙PC ArkTS 死亡轮循深度解析与解决方案
学习·华为·harmonyos·鸿蒙·鸿蒙系统
木咺吟2 小时前
鸿蒙原生应用实战(二):首页与包裹列表开发——List组件、ForEach渲染与状态管理
harmonyos
风华圆舞2 小时前
鸿蒙 MICROPHONE 权限在 Flutter 项目里怎么处理
flutter·华为·harmonyos
xcLeigh2 小时前
鸿蒙平台 NixNote2 富文本笔记应用适配实战:从 Linux 到 鸿蒙PC 的 Electron 迁移
linux·笔记·harmonyos·富文本·nixnote2·evernote
伶俜662 小时前
鸿蒙原生应用实战(一):从零开发一个短视频编辑器 App
编辑器·音视频·harmonyos
伶俜662 小时前
鸿蒙原生应用实战(十)ArkUI 涂鸦画板:Canvas 绘图 + 颜色选择 + 笔画管理 + 导出
华为·harmonyos