鸿蒙原生应用实战(五)ArkUI 图片拼接/长图生成:多图合并 + Canvas 绘制 + 导出分享
2026/6/14 20:22:57 网站建设 项目流程

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

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


📱 应用场景

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

⚙️ 运行环境要求

项目版本要求
DevEco Studio5.0.3.800 及以上
HarmonyOS SDKAPI 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:完整代码

// pages/Index.ets — 图片拼接/长图生成器importimagefrom'@ohos.multimedia.image';importfileIofrom'@ohos.file.fs';importpickerfrom'@ohos.file.picker';interfaceImageItem{id:string;uri:string;width:number;height:number;}@Entry@Componentstruct ImageStitcher{// ======== 状态变量 ========@Stateimages:ImageItem[]=[];@StatepreviewWidth:number=300;@Statespacing:number=4;// 间距(像素)@Statemode:'vertical'|'horizontal'='vertical';@StateisExporting:boolean=false;@StateexportProgress:number=0;privatecanvasCTX!:CanvasRenderingContext2D;// ======== 选择图片 ========asyncselectImages(){try{constphotoPicker=newpicker.PhotoViewPicker();constresult=awaitphotoPicker.select({MIMEType:picker.PhotoViewMIMETypes.IMAGE_TYPE,maxSelectNumber:20});for(consturiofresult.photoUris){// 获取图片宽高constsource=image.createImageSource(uri);constinfo=awaitsource.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){constitem=this.images.splice(from,1)[0];this.images.splice(to,0,item);}// ======== 计算总尺寸 ========gettotalWidth():number{if(this.mode==='vertical')returnthis.previewWidth;// 横向:所有图片宽度之和 + 间距returnthis.images.reduce((sum,img)=>{consth=this.previewWidth;// 固定高度constw=img.width/img.height*h;returnsum+w;},0)+this.spacing*(this.images.length-1);}gettotalHeight():number{if(this.mode==='horizontal')returnthis.previewWidth;// 纵向:所有图片高度之和 + 间距returnthis.images.reduce((sum,img)=>{constw=this.previewWidth;consth=img.height/img.width*w;returnsum+h;},0)+this.spacing*(this.images.length-1);}// ======== 导出长图 ========asyncexportImage(){if(this.images.length===0)return;this.isExporting=true;this.exportProgress=0;try{// 1. 创建目标 PixelMapconsttotalW=this.totalWidth;consttotalH=this.totalHeight;constpixelMap=awaitimage.createPixelMap({width:totalW,height:totalH,pixelFormat:image.PixelMapFormat.RGBA_8888,alphaType:image.AlphaType.PREMUL});// 2. 在 PixelMap 上逐张绘制letoffsetX=0,offsetY=0;for(leti=0;i<this.images.length;i++){constimg=this.images[i];// 计算缩放后的尺寸letdrawW:number,drawH:number;if(this.mode==='vertical'){drawW=totalW;drawH=img.height/img.width*drawW;}else{drawH=totalH;drawW=img.width/img.height*drawH;}// 读取原图并绘制constsrc=image.createImageSource(img.uri);constsrcPixelMap=awaitsrc.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. 保存到相册constpacker=image.createImagePacker();constpackedData=awaitpacker.packing(pixelMap,{format:'image/jpeg',quality:95});constfilePath=getContext(this).filesDir+`/stitch_${Date.now()}.jpg`;constfile=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{constimg=this.images[index];if(!img)return0;returnimg.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')}@BuilderImageCard({img,index}:{img:ImageItem;index:number}){Stack(){Image(img.uri).width('100%').height(this.getItemHeight(indexasnumber)).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(indexasnumber);})}.width('100%')}@BuilderImageCardH({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(indexasnumber);})}}}

📚 核心知识点深度解析

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 应用开发文档

  • 开发者社区:华为开发者论坛
  • 欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net/

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询