当前位置: 首页 > news >正文

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

🖼️ 鸿蒙原生应用实战(五)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/
http://www.jsqmd.com/news/1013921/

相关文章:

  • 5分钟掌握猫抓Cat-Catch:浏览器资源嗅探工具的完整使用指南
  • BiliRaffle:让B站UP主告别手动抽奖的终极解决方案
  • 告别拍脑袋估算:用RUSLE模型+QGIS,5步搞定土壤侵蚀强度计算(附数据获取渠道)
  • 终极BT下载加速指南:如何用trackerslist项目彻底告别龟速下载
  • 2026年6月最新版莱芜正规房屋漏水防水补漏维修口碑名单:创维修缮机构等5家深度测评 - 一修哥咨询
  • 2026 广州合同诈骗罪专业律师推荐:合同纠纷变刑事?怎么选对辩护律师 - 互联网科技品牌测评
  • 存算一体芯片软件双模式:单字符驱动网络(普通CPU也能跑)
  • 17-slots为什么有时反而更慢-属性查找的底层路径与描述符协议
  • AIOps 智能容量预测与弹性伸缩联动:从经验估算到数据驱动,云资源的成本与性能平衡
  • PyTorch训练避坑实录:在AMD平台(DirectML)上跑代码,为什么我的优化器不工作了?
  • 5步创新方案彻底解决CAD字体同步难题
  • Neura获14亿美元C轮融资,人形机器人赛道从实验室迈向工厂!
  • 3种高效方法在macOS上完美安装IINA专业播放器
  • ChatGPT API实战入门:从401报错到生产级对话服务
  • 核心必背!【中药学】必背100题及解析(卷号:06121219_04)
  • 深入解析MPC8309 eSDHC中断机制:SDIO通信稳定性的关键
  • 5分钟快速上手:免费获取海量小说资源的完整书源配置方案
  • LLM 验证代码题解:从输出校验到逻辑等价判定的工程实践
  • 2026年6月最新版酒泉正规房屋漏水防水补漏维修口碑名单:创维修缮机构等5家深度测评 - 一修哥咨询
  • 2026年云端保姆级流程:如何部署OpenClaw?Token Plan配置及大模型API Key接入
  • 消费级柔性机器人公司SoulX获融资,首款产品MoYa将带来家庭智能关护新体验!
  • 18-生成器不只是省内存(上)-yield的状态机模型与帧暂停
  • 合肥市庐江县 家电维修清洗|维小达|空调、冰箱、洗衣机、热水器、油烟机一站式维保清洗服务 - 维小达科技
  • 广州擅长合同诈骗刑事辩护律师排名参考:2026 年经济犯罪辩护实务观察 - 互联网科技品牌测评
  • 跨平台BongoCat交互式桌宠:从事件捕获到视觉反馈的实时响应机制
  • Claudesidian:打造AI驱动的第二大脑,让知识管理从未如此简单高效
  • Java Web WEB旅游推荐系统系统源码-SpringBoot2+Vue3+MyBatis-Plus+MySQL8.0【含文档】
  • 面试官最爱挖的“数学陷阱”:有序转数组(Sort Transformed Array)为什么很多人第一眼就做错了?
  • Yuzu模拟器企业级部署方案:3种架构设计与性能优化50%技术指南
  • 2026年6月最新版晋城正规房屋漏水防水补漏维修口碑名单:创维修缮机构等5家深度测评 - 一修哥咨询