HarmonyOS实战-水印添加 - 第2篇:图片水印的像素级合成

📅 2026/6/28 2:46:50
HarmonyOS实战-水印添加 - 第2篇:图片水印的像素级合成
1. 开篇在上一篇《HarmonyOS实战-水印添加 - 第1篇页面水印的Canvas绘制》中我们完成了可复用的WatermarkContainer组件通过 Canvas 绘制和hitTestBehavior设置实现了页面级文字水印叠加滑动时水印固定显示且不影响底层交互。本篇进入更底层的图片水印合成场景。页面水印是“覆盖显示”图片水印则需要真正修改图片的像素数据生成一张包含水印的新图片。版权保护、图片分享防篡改等场景依赖此能力。我们将基于 HarmonyOS 的image模块和CanvasRenderingContext2D实现从图片解析、像素级绘制到最终保存的完整流程。2. 核心实现2.1 环境准备与权限配置图片水印操作涉及从媒体库读取图片和向媒体库写入新图片需要在module.json5中声明读取媒体文件和写入媒体文件的权限。同时需要导入图像处理与 Canvas 的核心 API。代码块 1权限声明与模块导入module.json5及import声明// module.json5 中的权限配置requestPermissions:[{name:ohos.permission.READ_MEDIA,reason:用于读取手机中的图片进行水印处理},{name:ohos.permission.WRITE_MEDIA,reason:用于保存添加水印后的图片到相册}]// ImageWatermarkManager.ets —— 核心模块导入importimagefromohos.multimedia.image;// 图片编解码核心APIimport{resourceManager}fromkit.LocalizationKit;// 获取应用资源import{BusinessError}fromkit.BasicServicesKit;// 错误类型// 注意CanvasRenderingContext2D 在 Page 中使用此处只需定义类型关键点说明ohos.permission.READ_MEDIA和ohos.permission.WRITE_MEDIA属于 user_grant 权限必须在代码中动态弹窗请求。本篇示例假设用户已授权实际工程需实现AbilityAccessCtrl.requestPermissions。遗漏动态请求会导致权限判定失败运行时抛异常。image模块提供了ImageSource、PixelMap等类是操作图片像素数据的基石。PixelMap 是操作位图数据的核心对象后续所有绘制都在其上完成。在 HarmonyOS 中ohos.multimedia.image替代了传统的 Base64 或直接文件读写方式性能更优且支持主流图片格式。2.2 核心逻辑在 PixelMap 上绘制水印图片水印的核心思路先将原始图片解析为PixelMap像素图然后利用CanvasRenderingContext2D在 PixelMap 的画布上绘制水印文字最后将合成后的 PixelMap 保存回文件或展示给用户。代码块 2绘制水印到 PixelMap——drawWatermarkOnPixelMap函数由于篇幅限制该函数的具体实现包括创建 OffscreenCanvas、设置画笔样式、计算水印位置、绘制透明文字等请参考工程完整源码。实际开发中需注意调用PixelMap.getContext(2d)将 PixelMap 作为 Canvas 的 target后续渲染直接修改该 PixelMap 的像素数据。文字水印建议使用半透明、旋转角度避免完全遮挡原图内容。绘制完成后调用packing()或编码接口生成新图片注意释放 PixelMap 和 ImageSource 资源防止内存泄漏。欢迎留言交流具体实现时的细节问题。2.2 核心逻辑在PixelMap上绘制水印图片水印的核心思路解析原始图片为PixelMap利用CanvasRenderingContext2D在PixelMap画布上绘制水印文字再将合成后的PixelMap保存或展示给用户。以下函数实现了从PixelMap到带水印PixelMap的完整转换包含旋转与平铺逻辑。代码块 2绘制水印到PixelMap——drawWatermarkOnPixelMap函数/** * 在给定的PixelMap上绘制旋转水印文字 * param pixelMap 原始图片的像素图对象 * param watermarkText 水印文字内容 * param angle 水印旋转角度度数 * returns 合成水印后的新PixelMap */exportasyncfunctiondrawWatermarkOnPixelMap(pixelMap:image.PixelMap,watermarkText:string,angle:number-30// 默认逆时针30度):Promiseimage.PixelMap{// 1. 获取图片宽高用于Canvas尺寸constpixelMapInfo:image.ImageInfoawaitpixelMap.getImageInfo();constwidthpixelMapInfo.size.width;constheightpixelMapInfo.size.height;// 2. 创建与图片等大的Canvas用于绘制水印constcanvasnewOffscreenCanvas(width,height);constctxcanvas.getContext(2d);// 3. 将原始图片绘制到Canvas背景// 通过pixelMap.readPixelsToBuffer得到RGBA数据再绘制到canvasconstreadBuffernewArrayBuffer(width*height*4);awaitpixelMap.readPixelsToBuffer(readBuffer);constimgDatactx.createImageData(width,height);imgData.data.set(newUint8ClampedArray(readBuffer));ctx.putImageData(imgData,0,0);// 4. 设置水印样式ctx.fontbold 36px sans-serif;ctx.fillStylergba(200, 200, 200, 0.5);// 半透明白色ctx.textAligncenter;ctx.textBaselinemiddle;// 5. 计算旋转起点偏移确保第一个水印完整可见参考官方公式// 旋转角度 θ 的正切值constthetaangle*Math.PI/180;consttanThetaMath.tan(theta);// 根据角度正负决定平移方向// tan(θ) 0 角度0时沿x轴平移tan(θ) 0时沿y轴平移constpositionXtanTheta0?tanTheta*60:0;// 水印高度假设60pxconstpositionYtanTheta0?-tanTheta*200:0;// 水印宽度假设200px// 6. 开始旋转绘制水印需频繁平移和旋转ctx.translate(positionX,positionY);ctx.rotate(theta);// 7. 在画布上平铺绘制水印文字// 采用双重循环x步长300y步长150让水印铺满全图for(lety0;yheight;y150){for(letx0;xwidth;x300){ctx.fillText(watermarkText,x,y);}}注意事项步骤3中readPixelsToBuffer返回的RGBA数据与Canvas的ImageData结构完全匹配无需额外通道转换但注意在HarmonyOS中readPixelsToBuffer为异步方法需await。步骤5计算平移偏移时水印高度和宽度使用了固定值60px、200px。实际项目中若水印文字长度或字体大小变化应动态获取ctx.measureText的宽度来调整偏移量否则可能导致水印被画布边界裁切。步骤6直接调用translate和rotate会累积变换状态。若希望后续绘制不受影响例如添加多个水印层可以在旋转前使用ctx.save()保存状态绘制后使用ctx.restore()恢复。平铺步长300、150根据默认水印大小设置。如需适配不同分辨率图片可依据图片宽高的比例动态计算步长。[截图: 旋转水印平铺效果示意展示多个-30度倾斜的灰色半透明文字“示例水印”均匀覆盖在原始图片上]你在实际开发中是否考虑过水印旋转后因偏移计算不准导致部分区域空白的情况欢迎在评论区交流优化方案。接下来在水印平铺之前需要确保Canvas原点已经旋转并平移。旋转起点的位移计算遵循公式当角度0时第一个水印可能超出左边界需沿x轴正向偏移tan(θ) * 水印高当角度0时需沿y轴正向偏移-tan(θ) * 水印宽。完成旋转变换后用双重循环平铺绘制水印文字x步长300y步长150使水印铺满整张图片。for (let y 0; y height; y 150) { for (let x 0; x width; x 300) { ctx.fillText(watermarkText, x, y); } }从Canvas获取像素数据时注意使用getImageData获取RGBA缓冲区再通过image.createPixelMap构建新的PixelMap并指定pixelFormat: image.PixelMapFormat.RGBA_8888以保证格式一致。const outputBuffer canvas.getImageData(0, 0, width, height).data.buffer; const outputPixelMap: image.PixelMap await image.createPixelMap(outputBuffer, { width: width, height: height, pixelFormat: image.PixelMapFormat.RGBA_8888, editable: false });提示OffscreenCanvas适用于计算密集型的图片处理避免主线程阻塞。若水印角度不为0°务必按公式调整平移偏移量否则水印会显示不完整。2.3 完整页面组件从选择图片到保存新图集成水印功能到页面时需要处理图片选择、异步绘制、结果保存。下面给出一个完整的ImageWatermarker组件用户通过photoAccessHelper调用系统相册选择图片自动添加水印后预览点击“保存”将合成图写回相册。import { photoAccessHelper } from kit.MediaLibraryKit; import { common } from kit.AbilityKit; import { drawWatermarkOnPixelMap } from ./ImageWatermarkManager; Component export struct ImageWatermarker { State private originPixelMap: image.PixelMap | null null; State private watermarkedPixelMap: image.PixelMap | null null; State private isLoading: boolean false; private context getContext(this) as common.Context; // 选择图片 async selectImage() { const helper photoAccessHelper.getPhotoAccessHelper(this.context); const uris await helper.selectUris?.([image/*]); // API 12 推荐写法 if (!uris || uris.length 0) return; const file await fs.open(uris[0], fs.OpenMode.READ_ONLY); const imageSource image.createImageSource(file.fd); const pixelMap await imageSource.createPixelMap(); this.originPixelMap pixelMap; // 添加水印 this.isLoading true; const watermarked await drawWatermarkOnPixelMap(pixelMap, 水印, 30, 45); this.watermarkedPixelMap watermarked; this.isLoading false; } // 保存图片 async saveImage() { if (!this.watermarkedPixelMap) return; const helper photoAccessHelper.getPhotoAccessHelper(this.context); const uri await helper.createAsset?.(photoAccessHelper.PhotoType.IMAGE, jpg); if (!uri) return; const file await fs.open(uri, fs.OpenMode.WRITE_ONLY); const imagePacker image.createImagePacker(); const packOpts: image.PackingOption { format: image/jpeg, quality: 95 }; const packedData await imagePacker.packing(this.watermarkedPixelMap, packOpts); await fs.write(file.fd, packedData); await fs.close(file); } build() { Column() { if (this.watermarkedPixelMap) { Image(this.watermarkedPixelMap).width(100%).aspectRatio(1); } else if (this.originPixelMap) { Text(水印生成中...).fontSize(18).fontColor(#999); } Button(选择图片).onClick(() this.selectImage()); Button(保存).enabled(!!this.watermarkedPixelMap).onClick(() this.saveImage()); } .padding(20) .justifyContent(FlexAlign.Center) .width(100%) .height(100%) } }注意使用photoAccessHelper需要申请权限ohos.permission.READ_IMAGEVIDEO和ohos.permission.WRITE_IMAGEVIDEO。selectUris在API 12及以上版本可用低版本需使用getPhotoAssets或startPhotoPicker。代码中drawWatermarkOnPixelMap已在2.2节实现通过await异步调用避免界面卡顿。保存时使用ImagePacker将PixelMap编码为JPEG数据后写入文件。实际项目开发中水印角度、透明度、位置等参数经常需要动态调整上述组件预留了扩展空间。你更倾向于将水印配置做成可调节的UI控件还是直接固定参数图片水印合成的完整实现从相册选择到旋转角度控制在 HarmonyOS 应用中给图片添加水印时经常需要处理两个关键环节一是通过系统相册选择目标图片二是将文字或图形水印以指定角度旋转后合成到原图。下面直接给出一个可运行的实现方案包含完整的Entry组件代码覆盖选择图片、解析为 PixelMap、调用水印绘制函数三个步骤。1. 组件结构预览区与操作按钮privatewatermarkedPixelMap:image.PixelMap|nullnull;// 合成后像素图StateprivateisLoading:booleanfalse;// 获取UIAbility上下文用于媒体库读写privatecontextgetContext(this)ascommon.Context;build(){Column({space:16}){// 预览区域if(this.watermarkedPixelMap!null){Image(this.watermarkedPixelMap).width(90%).aspectRatio(1).objectFit(ImageFit.Contain)}else{Text(请选择一张图片).width(90%).aspectRatio(1).backgroundColor(#F5F5F5).textAlign(TextAlign.Center)}// 按钮组Row({space:12}){Button(选择图片).onClick(async(){// 1. 通过photoAccessHelper打开相册选择constphAccessphotoAccessHelper.getPhotoAccessHelper(this.context);try{constphotoUriawaitphAccess.selectPhotoURI({MIME:image/jpeg,image/png});// 2. 根据URI获取文件并解析为PixelMapconstfileawaitthis.context.resourceManager.getMediaContent(photoUri);constimageSourceimage.createImageSource(file.buffer);this.originPixelMapawaitimageSource.createPixelMap();this.isLoadingtrue;// 3. 调用核心水印绘制函数this.watermarkedPixelMapawaitdrawWatermarkOnPixelMap(this.originPixelMap,内部资料,-30// 水印旋转角度);console.info(水印合成完成);}catch(err){console.error(选择图片失败: (errasBusinessError).message);}finally{this.isLoadingfalse;}})注意事项selectPhotoURI返回的是photoAccessHelper.PhotoURI类型不能直接作为文件路径使用。必须通过resourceManager.getMediaContent()读取其二进制数据。MIME参数需根据应用支持的图片格式填写本例只接受 JPEG 和 PNG可扩展为image/*但会提高解码失败风险。[截图: 选择图片前的空白预览区域与“请选择一张图片”提示]2. 核心水印函数旋转角度的选择drawWatermarkOnPixelMap函数内部通常会在原 PixelMap 上使用Canvas绘制文字或图片并设置旋转角度。角度参数为负数时表示逆时针旋转正数顺时针。本例使用-30度常见于斜向水印防止裁剪。实现要点水印绘制前需先获取原图的宽高确保绘制区域不越界。旋转中心应设为水印文字的中点否则旋转后位置偏移。3. 加载状态的反馈isLoading状态用于控制按钮的禁用或显示 loading 动画避免用户重复点击。在 finally 块中重置为 false确保无论成功或失败都能恢复按钮交互。[截图: 水印合成完成后的预览图水印 “内部资料” 逆时针倾斜30度]关于水印旋转角度与其他参数的配合如字体大小、透明度你是否遇到过预览与导出不一致的情况欢迎在评论区分享你的调试经验。在HarmonyOS图片水印功能开发中选择图片并完成水印合成后需要将结果保存到相册。下面的代码片段展示了选择图片失败后的异常处理以及保存按钮的完整实现。// 选择图片成功后的处理水印合成已完成// 前面的选择图片逻辑省略...promptAction.showToast({message:水印合成完成});}catch(err){console.error(选择图片失败: (errasBusinessError).message);}finally{this.isLoadingfalse;}})Button(保存图片).enabled(this.watermarkedPixelMap!null).onClick(async(){// 将PixelMap写入媒体库constphAccessphotoAccessHelper.getPhotoAccessHelper(this.context);try{// 创建临时文件或直接写入constimagePackerimage.createImagePacker();constpackOpts:image.PackingOption{format:image/jpeg,quality:95};constpackedDataawaitimagePacker.packing(this.watermarkedPixelMapasimage.PixelMap,packOpts);// 通过savePhoto创建文件并写入awaitphAccess.savePhoto(packedData,IMG_PREFIX);promptAction.showToast({message:水印图片已保存到相册});}catch(err){console.error(保存失败: (errasBusinessError).message);}})关键点说明photoAccessHelper.selectPhotoURI是HarmonyOS提供的相册选择API会弹出系统界面让用户选择一张图片返回图片的URI。image.createImagePacker用于将PixelMap编码为JPEG或PNG格式的ArrayBuffer。打包前this.watermarkedPixelMap必须保证为RGBA格式且可读否则会抛出编码异常。savePhoto需要传入打包后的ArrayBuffer和文件名前缀系统会自动创建文件并写入媒体库。该操作会触发用户授权确认因此需要在module.json5中声明ohos.permission.WRITE_IMAGEVIDEO权限。[截图: ImageWatermarker组件运行界面]3. 运行验证将上述ImageWatermarker.ets添加到工程的pages中并在entry/src/main/resources/base/profile/main_pages.json注册该页面。运行应用即可验证水印合成和保存功能。如果你在实现中遇到相册写入失败的问题检查是否已申请媒体库读写权限并确认PixelMap在打包前处于可读状态。图片水印保存触发媒体库写入需要用户授权确认点击“保存图片”后系统会调用媒体库写入接口此时会弹出授权确认框首次。授权通过后水印图片才真正存入相册。3. 运行验证将上述ImageWatermarker.ets添加到工程的pages中并在entry/src/main/resources/base/profile/main_pages.json注册该页面。运行应用跟随以下步骤验证点击“选择图片”→ 系统弹出相册选择界面选择一张任意图片建议JPEG/PNG→ 页面预览区立即显示带“内部资料”斜铺水印的新图片。检查水印旋转→ 观察水印文字是否逆时针旋转30度且文字间无重叠、角落完整显示。若水印缺失一角回到drawWatermarkOnPixelMap调整positionX/Y计算公式。注意positionX和positionY的步进值需要根据水印文字的实际宽度和高度计算避免缝隙或重叠建议将文字最大宽度作为步长。点击“保存图片”→ 系统弹出权限确认框首次点击允许 → 弹出“水印图片已保存到相册”的Toast → 打开系统相册确认生成了后缀为IMG_PREFIX的新图片且水印正确。4. 小结本篇完成了图片水印的像素级合成完整实现。核心产出包括drawWatermarkOnPixelMap辅助函数基于OffscreenCanvas在PixelMap上绘制旋转水印文字并返回合成后的PixelMap。ImageWatermarker完整组件集成了图片选择、水印合成、预览、保存到相册的完整业务流程。两种常用水印场景的覆盖页面水印—— 通过Canvas动态绘制 Stack/overlay实现不影响交互的显示层水印。图片水印—— 通过image模块解析图片为PixelMap使用OffscreenCanvas合成像素数据再通过imagePacker保存为新文件。后续可扩展为PDF文档水印原理相同解析内容 → Canvas绘制水印 → 重新编码输出。如果遇到保存后相册中图片不显示可以检查权限是否被误拒或图片编码格式是否匹配欢迎在评论中交流具体问题。