在这里插入图片描述

1. 开篇

在上一篇《HarmonyOS实战-水印添加 - 第1篇:页面水印的Canvas绘制》中,我们完成了可复用的 WatermarkContainer 组件,通过 Canvas 绘制和 hitTestBehavior 设置实现了页面级文字水印叠加,滑动时水印固定显示且不影响底层交互。

本篇进入更底层的图片水印合成场景。页面水印是“覆盖显示”,图片水印则需要真正修改图片的像素数据,生成一张包含水印的新图片。版权保护、图片分享防篡改等场景依赖此能力。我们将基于 HarmonyOS 的 image 模块和 CanvasRenderingContext2D,实现从图片解析、像素级绘制到最终保存的完整流程。

2. 核心实现

2.1 环境准备与权限配置

图片水印操作涉及从媒体库读取图片和向媒体库写入新图片,需要在 module.json5 中声明读取媒体文件和写入媒体文件的权限。同时,需要导入图像处理与 Canvas 的核心 API。

代码块 1:权限声明与模块导入(module.json5import 声明)
// module.json5 中的权限配置
"requestPermissions": [
  {
    "name": "ohos.permission.READ_MEDIA",
    "reason": "用于读取手机中的图片进行水印处理"
  },
  {
    "name": "ohos.permission.WRITE_MEDIA",
    "reason": "用于保存添加水印后的图片到相册"
  }
]

// ImageWatermarkManager.ets —— 核心模块导入
import image from '@ohos.multimedia.image';          // 图片编解码核心API
import { resourceManager } from '@kit.LocalizationKit'; // 获取应用资源
import { BusinessError } from '@kit.BasicServicesKit';  // 错误类型

// 注意:CanvasRenderingContext2D 在 Page 中使用,此处只需定义类型

关键点说明:

  • ohos.permission.READ_MEDIAohos.permission.WRITE_MEDIA 属于 user_grant 权限,必须在代码中动态弹窗请求。本篇示例假设用户已授权,实际工程需实现 AbilityAccessCtrl.requestPermissions。遗漏动态请求会导致权限判定失败,运行时抛异常。
  • image 模块提供了 ImageSourcePixelMap 等类,是操作图片像素数据的基石。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
 */
export async function drawWatermarkOnPixelMap(
  pixelMap: image.PixelMap,
  watermarkText: string,
  angle: number = -30   // 默认逆时针30度
): Promise<image.PixelMap> {
  // 1. 获取图片宽高,用于Canvas尺寸
  const pixelMapInfo: image.ImageInfo = await pixelMap.getImageInfo();
  const width = pixelMapInfo.size.width;
  const height = pixelMapInfo.size.height;

  // 2. 创建与图片等大的Canvas,用于绘制水印
  const canvas = new OffscreenCanvas(width, height);
  const ctx = canvas.getContext('2d');

  // 3. 将原始图片绘制到Canvas背景
  //    通过pixelMap.readPixelsToBuffer得到RGBA数据,再绘制到canvas
  const readBuffer = new ArrayBuffer(width * height * 4);
  await pixelMap.readPixelsToBuffer(readBuffer);
  const imgData = ctx.createImageData(width, height);
  imgData.data.set(new Uint8ClampedArray(readBuffer));
  ctx.putImageData(imgData, 0, 0);

  // 4. 设置水印样式
  ctx.font = 'bold 36px sans-serif';
  ctx.fillStyle = 'rgba(200, 200, 200, 0.5)';   // 半透明白色
  ctx.textAlign = 'center';
  ctx.textBaseline = 'middle';

  // 5. 计算旋转起点偏移,确保第一个水印完整可见(参考官方公式)
  //    旋转角度 θ 的正切值
  const theta = angle * Math.PI / 180;
  const tanTheta = Math.tan(theta);
  //    根据角度正负决定平移方向
  //    tan(θ) > 0 (角度>0)时,沿x轴平移;tan(θ) < 0时,沿y轴平移
  const positionX = tanTheta > 0 ? tanTheta * 60 : 0;  // 水印高度假设60px
  const positionY = tanTheta < 0 ? -tanTheta * 200 : 0; // 水印宽度假设200px

  // 6. 开始旋转绘制水印,需频繁平移和旋转
  ctx.translate(positionX, positionY);
  ctx.rotate(theta);

  // 7. 在画布上平铺绘制水印文字
  //    采用双重循环,x步长300,y步长150,让水印铺满全图
  for (let y = 0; y < height; y += 150) {
    for (let x = 0; x < width; x += 300) {
      ctx.fillText(watermarkText, x, y);
    }
  }

注意事项

  • 步骤3中readPixelsToBuffer返回的RGBA数据与Canvas的ImageData结构完全匹配,无需额外通道转换,但注意在HarmonyOS中readPixelsToBuffer为异步方法,需await。
  • 步骤5计算平移偏移时,水印高度和宽度使用了固定值(60px、200px)。实际项目中若水印文字长度或字体大小变化,应动态获取ctx.measureText的宽度来调整偏移量,否则可能导致水印被画布边界裁切。
  • 步骤6直接调用translaterotate会累积变换状态。若希望后续绘制不受影响(例如添加多个水印层),可以在旋转前使用ctx.save()保存状态,绘制后使用ctx.restore()恢复。
  • 平铺步长(300、150)根据默认水印大小设置。如需适配不同分辨率图片,可依据图片宽高的比例动态计算步长。

[截图: 旋转水印平铺效果示意,展示多个-30度倾斜的灰色半透明文字“示例水印”均匀覆盖在原始图片上]

你在实际开发中是否考虑过水印旋转后因偏移计算不准导致部分区域空白的情况?欢迎在评论区交流优化方案。


接下来,在水印平铺之前,需要确保Canvas原点已经旋转并平移。旋转起点的位移计算遵循公式:当角度>0时,第一个水印可能超出左边界,需沿x轴正向偏移tan(θ) * 水印高;当角度<0时,需沿y轴正向偏移-tan(θ) * 水印宽

完成旋转变换后,用双重循环平铺绘制水印文字,x步长300,y步长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_IMAGEVIDEOohos.permission.WRITE_IMAGEVIDEOselectUris在API 12及以上版本可用,低版本需使用getPhotoAssetsstartPhotoPicker

代码中drawWatermarkOnPixelMap已在2.2节实现,通过await异步调用,避免界面卡顿。保存时使用ImagePacker将PixelMap编码为JPEG数据后写入文件。

实际项目开发中,水印角度、透明度、位置等参数经常需要动态调整,上述组件预留了扩展空间。你更倾向于将水印配置做成可调节的UI控件还是直接固定参数?

图片水印合成的完整实现:从相册选择到旋转角度控制

在 HarmonyOS 应用中,给图片添加水印时,经常需要处理两个关键环节:一是通过系统相册选择目标图片,二是将文字或图形水印以指定角度旋转后合成到原图。下面直接给出一个可运行的实现方案,包含完整的 @Entry 组件代码,覆盖选择图片、解析为 PixelMap、调用水印绘制函数三个步骤。

1. 组件结构:预览区与操作按钮
private watermarkedPixelMap: image.PixelMap | null = null; // 合成后像素图
@State private isLoading: boolean = false;

// 获取UIAbility上下文,用于媒体库读写
private context = getContext(this) as common.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打开相册选择
          const phAccess = photoAccessHelper.getPhotoAccessHelper(this.context);
          try {
            const photoUri = await phAccess.selectPhotoURI({
              MIME: 'image/jpeg,image/png'
            });
            // 2. 根据URI获取文件,并解析为PixelMap
            const file = await this.context.
              resourceManager.getMediaContent(photoUri);
            const imageSource = image.createImageSource(file.buffer);
            this.originPixelMap = await imageSource.createPixelMap();
            this.isLoading = true;
            // 3. 调用核心水印绘制函数
            this.watermarkedPixelMap = await drawWatermarkOnPixelMap(
              this.originPixelMap,
              '内部资料',
              -30   // 水印旋转角度
            );
            console.info('水印合成完成');
          } catch (err) {
            console.error('选择图片失败: ' + (err as BusinessError).message);
          } finally {
            this.isLoading = false;
          }
        })

注意事项

  • 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('选择图片失败: ' + (err as BusinessError).message);
} finally {
  this.isLoading = false;
}
})

Button('保存图片')
  .enabled(this.watermarkedPixelMap !== null)
  .onClick(async () => {
    // 将PixelMap写入媒体库
    const phAccess = photoAccessHelper.getPhotoAccessHelper(this.context);
    try {
      // 创建临时文件(或直接写入)
      const imagePacker = image.createImagePacker();
      const packOpts: image.PackingOption = {
        format: 'image/jpeg',
        quality: 95
      };
      const packedData = await imagePacker.packing(
        this.watermarkedPixelMap as image.PixelMap,
        packOpts
      );
      // 通过savePhoto创建文件并写入
      await phAccess.savePhoto(packedData, 'IMG_PREFIX');
      promptAction.showToast({ message: '水印图片已保存到相册' });
    } catch (err) {
      console.error('保存失败: ' + (err as BusinessError).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注册该页面。运行应用,跟随以下步骤验证:

  1. 点击“选择图片” → 系统弹出相册选择界面,选择一张任意图片(建议JPEG/PNG)→ 页面预览区立即显示带“内部资料”斜铺水印的新图片。
  2. 检查水印旋转 → 观察水印文字是否逆时针旋转30度,且文字间无重叠、角落完整显示。若水印缺失一角,回到drawWatermarkOnPixelMap调整positionX/Y计算公式。
    注意:positionXpositionY的步进值需要根据水印文字的实际宽度和高度计算,避免缝隙或重叠,建议将文字最大宽度作为步长。
  3. 点击“保存图片” → 系统弹出权限确认框(首次),点击允许 → 弹出“水印图片已保存到相册”的Toast → 打开系统相册,确认生成了后缀为IMG_PREFIX的新图片,且水印正确。
    在这里插入图片描述

4. 小结

本篇完成了图片水印的像素级合成完整实现。核心产出包括:

  • drawWatermarkOnPixelMap辅助函数:基于OffscreenCanvas在PixelMap上绘制旋转水印文字,并返回合成后的PixelMap。
  • ImageWatermarker完整组件:集成了图片选择、水印合成、预览、保存到相册的完整业务流程。

两种常用水印场景的覆盖:

  1. 页面水印 —— 通过Canvas动态绘制 + Stack/overlay实现不影响交互的显示层水印。
  2. 图片水印 —— 通过image模块解析图片为PixelMap,使用OffscreenCanvas合成像素数据,再通过imagePacker保存为新文件。

后续可扩展为PDF文档水印,原理相同:解析内容 → Canvas绘制水印 → 重新编码输出。如果遇到保存后相册中图片不显示,可以检查权限是否被误拒或图片编码格式是否匹配,欢迎在评论中交流具体问题。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐