🖼️ 鸿蒙原生应用实战(五)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:完整代码
// 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 |
🔥 最佳实践
- 预览用缩略图:预览列表用降采样后的缩略图,导出时才加载原图
- 异步处理:导出操作放后台,避免阻塞 UI
- 内存释放:用完的 PixelMap 调用
release()释放 - 画布复用:不要反复创建 PixelMap,复用已有的
- 进度反馈:超过 3 张图片必须显示导出进度条
![]()
官方文档:HarmonyOS 应用开发文档
- 开发者社区:华为开发者论坛
- 欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net/