️ 鸿蒙原生应用实战五ArkUI 图片拼接/长图生成多图合并 Canvas 绘制 导出分享博主说朋友圈的九宫格截图、聊天记录拼接长图、多张照片合成一张……这些都是日常高频需求。今天我们用 ArkUI 的 Canvas Image API从零实现一个支持多种拼接模式的长图生成器覆盖图片选择、拖拽排序、拼接预览、导出保存的全流程。 应用场景场景说明 聊天记录长截图多屏聊天记录拼接成一张长图分享️ 照片拼图多张照片合成一张发朋友圈 文档拼接多页扫描件拼接为长文档 数据报告多张图表拼接为一张完整报告图⚙️ 运行环境要求项目版本要求DevEco Studio5.0.3.800 及以上HarmonyOS SDKAPI 12核心 APIohos.multimedia.imageohos.canvasohos.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 — 图片拼接/长图生成器importimagefromohos.multimedia.image;importfileIofromohos.file.fs;importpickerfromohos.file.picker;interfaceImageItem{id:string;uri:string;width:number;height:number;}EntryComponentstruct ImageStitcher{// 状态变量 Stateimages:ImageItem[][];StatepreviewWidth:number300;Statespacing:number4;// 间距像素Statemode:vertical|horizontalvertical;StateisExporting:booleanfalse;StateexportProgress:number0;privatecanvasCTX!:CanvasRenderingContext2D;// 选择图片 asyncselectImages(){try{constphotoPickernewpicker.PhotoViewPicker();constresultawaitphotoPicker.select({MIMEType:picker.PhotoViewMIMETypes.IMAGE_TYPE,maxSelectNumber:20});for(consturiofresult.photoUris){// 获取图片宽高constsourceimage.createImageSource(uri);constinfoawaitsource.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){constitemthis.images.splice(from,1)[0];this.images.splice(to,0,item);}// 计算总尺寸 gettotalWidth():number{if(this.modevertical)returnthis.previewWidth;// 横向所有图片宽度之和 间距returnthis.images.reduce((sum,img){consththis.previewWidth;// 固定高度constwimg.width/img.height*h;returnsumw;},0)this.spacing*(this.images.length-1);}gettotalHeight():number{if(this.modehorizontal)returnthis.previewWidth;// 纵向所有图片高度之和 间距returnthis.images.reduce((sum,img){constwthis.previewWidth;consthimg.height/img.width*w;returnsumh;},0)this.spacing*(this.images.length-1);}// 导出长图 asyncexportImage(){if(this.images.length0)return;this.isExportingtrue;this.exportProgress0;try{// 1. 创建目标 PixelMapconsttotalWthis.totalWidth;consttotalHthis.totalHeight;constpixelMapawaitimage.createPixelMap({width:totalW,height:totalH,pixelFormat:image.PixelMapFormat.RGBA_8888,alphaType:image.AlphaType.PREMUL});// 2. 在 PixelMap 上逐张绘制letoffsetX0,offsetY0;for(leti0;ithis.images.length;i){constimgthis.images[i];// 计算缩放后的尺寸letdrawW:number,drawH:number;if(this.modevertical){drawWtotalW;drawHimg.height/img.width*drawW;}else{drawHtotalH;drawWimg.width/img.height*drawH;}// 读取原图并绘制constsrcimage.createImageSource(img.uri);constsrcPixelMapawaitsrc.createPixelMap();// 使用 Canvas 2D 绘制if(this.canvasCTX){// 这里简化处理实际项目中通过 writeBuffer 逐像素操作}// 更新进度this.exportProgress((i1)/this.images.length)*100;if(this.modevertical){offsetYdrawHthis.spacing;}else{offsetXdrawWthis.spacing;}}// 3. 保存到相册constpackerimage.createImagePacker();constpackedDataawaitpacker.packing(pixelMap,{format:image/jpeg,quality:95});constfilePathgetContext(this).filesDir/stitch_${Date.now()}.jpg;constfilefileIo.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.isExportingfalse;}}});}catch(err){console.error(导出失败:,JSON.stringify(err));AlertDialog.show({message:导出失败: JSON.stringify(err)});this.isExportingfalse;}}// 计算单张图片的预览高度 getItemHeight(index:number):number{constimgthis.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.spacingv;})Button(this.modevertical?↕ 纵向:↔ 横向).backgroundColor(#F0F0F0).fontColor(#333).borderRadius(16).height(36).fontSize(14).onClick((){this.modethis.modevertical?horizontal:vertical;})}.width(94%).justifyContent(FlexAlign.Start).gap(12)// 空状态if(this.images.length0){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.modevertical){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()选择多张图片maxSelectNumberImageSource.getImageInfo()获取原始尺寸返回size.width/heightImagePacker.packing()编码为文件格式quality: 0~100createPixelMap()创建空画布width/height/pixelFormat⚠️ 避坑指南坑原因正确做法大图内存溢出Canvas 处理超大尺寸图限制最大 4096px分块处理图片方向不对EXIF 旋转信息没处理读取 EXIF 方向后旋转导出泛白JPEG quality 太低quality 设为 90~95选图 UI 卡顿加载原图太慢用缩略图 (thumbnail) 预览间距计算错误忘了加最后一个间距间距数 图片数 - 1 最佳实践预览用缩略图预览列表用降采样后的缩略图导出时才加载原图异步处理导出操作放后台避免阻塞 UI内存释放用完的 PixelMap 调用release()释放画布复用不要反复创建 PixelMap复用已有的进度反馈超过 3 张图片必须显示导出进度条官方文档HarmonyOS 应用开发文档开发者社区华为开发者论坛欢迎加入开源鸿蒙跨平台社区https://openharmonycrossplatform.csdn.net/