请添加图片描述

去年做过一个企业内部文档查看器,需求是"防止员工截图外传"。产品经理信心满满地说"加水印不就行了",我泼了盆冷水——水印只能追查泄密源,不能阻止截图。后来我们做了两层:Canvas水印做追查,window.setPrivacyMode做截屏防护。WatermarkDemo把这两层都展示了,这篇把水印的实现细节和截屏防护的正确姿势说清楚。


Canvas水印的基本原理:Stack叠加 + 透明穿透

水印的核心思路是在内容之上叠加一层Canvas,绘制半透明旋转文字。但叠加后有个致命问题——Canvas在上层会拦截所有触摸事件,导致底下的内容无法点击、无法滚动。

解决方法是hitTestBehavior(HitTestMode.Transparent)

Stack() {
  Column() {
    Text('这是一份内部文档内容')
      .fontSize(16).fontColor('#333333').fontWeight(FontWeight.Medium).margin({ top: 40, bottom: 16 })
    Text('项目名称: HarmonyOS NEXT 技术方案')
      .fontSize(14).fontColor('#666666').margin({ bottom: 8 })
    Text('负责人: 张三')
      .fontSize(14).fontColor('#666666').margin({ bottom: 8 })
    Text('创建日期: 2026-07-08')
      .fontSize(14).fontColor('#666666').margin({ bottom: 8 })
    Text('文件编号: INT-2026-001')
      .fontSize(14).fontColor('#666666')
  }
  .width('100%').height('100%').padding(20)

  if (this.showWatermark) {
    Canvas(this.context2D)
      .width('100%').height('100%')
      .hitTestBehavior(HitTestMode.Transparent)
      .onReady(() => {
        this.drawWatermark();
      })
  }
}
.width('90%')
.height(250)
.borderRadius(8)
.backgroundColor('#ffffff')
.border({ width: 1, color: '#e0e0e0' })

HitTestMode.Transparent的意思是:这个组件自己不参与命中测试,触摸事件穿透到下层。还有个HitTestMode.None也类似,但None是完全不参与事件分发(子组件也收不到),Transparent是自身不拦截但子组件可以收到。对于纯Canvas水印来说两者效果一样,但如果Canvas上有可交互的子元素,Transparent更合适。

水印Canvas外面包了if (this.showWatermark)条件渲染,开关控制水印显示。不用visibility是因为Canvas不需要时应该从树中移除,避免无意义的绘制开销。


Canvas绘制水印的完整逻辑

drawWatermark是整个水印功能的核心:

private settings: RenderingContextSettings = new RenderingContextSettings(true);
private context2D: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings);

private drawWatermark(): void {
  if (!this.showWatermark) {
    return;
  }
  const ctx: CanvasRenderingContext2D = this.context2D;
  const w: number = 360;
  const h: number = 250;
  ctx.clearRect(0, 0, w, h);
  const opacity: string = 'rgba(0, 0, 0, ' + (this.watermarkOpacity / 100).toFixed(2) + ')';
  ctx.fillStyle = opacity;
  ctx.font = '14px sans-serif';
  const angleRad: number = this.watermarkAngle * Math.PI / 180;
  const gapX: number = 160;
  const gapY: number = 80;
  for (let y: number = -100; y < h + 100; y += gapY) {
    for (let x: number = -100; x < w + 100; x += gapX) {
      ctx.save();
      ctx.translate(x + 60, y + 30);
      ctx.rotate(angleRad);
      ctx.fillText(this.watermarkText, 0, 0);
      ctx.restore();
    }
  }
}

这段代码有几个关键点值得展开说。

clearRect必须先调用。Canvas不会自动清空上一帧的内容,如果参数变了(比如水印文字改了、角度调了),不清空就直接画,新旧内容会叠加在一起,变成一团乱码。所以每次drawWatermark开头必须ctx.clearRect(0, 0, w, h)清空画布。

rgba透明度的计算。用户通过Slider控制watermarkOpacity(5-40的整数),但Canvas的fillStyle需要0-1的浮点数,所以用this.watermarkOpacity / 100转换。5%透明度时文字几乎看不到,40%时文字开始明显遮挡内容,这个范围经过实际测试是比较合理的。代码里用.toFixed(2)保证rgba字符串格式正确。

旋转前必须translatectx.rotate()是围绕坐标原点旋转的。如果你直接rotate再fillText,文字会绕Canvas的(0,0)点旋转,跑到画布外面去。正确做法是先translate到水印文字应该出现的位置,再rotate,这样文字就绕自身中心旋转了。每次save/restore配对使用,保证transform不影响下一个水印。

循环范围超出画布。y从-100开始到h+100,x从-100到w+100,这是因为旋转后的水印可能部分在画布外。如果只画0到w/0到h范围内的,旋转后画布边缘会出现空白区域没有水印覆盖。多画100像素的余量,确保旋转后整个画布都有水印。

onReady回调。Canvas的绘制不能在aboutToAppear里做,因为此时Canvas还没准备好,context2D的方法调了也没效果。必须在onReady回调里绘制,这个回调表示Canvas已经初始化完成,可以安全使用绑定好的context2D。


参数变化时的重绘问题

WatermarkDemo提供了水印文字、透明度、旋转角度三个可调参数。问题来了——当你改了参数,Canvas不会自动重绘。@State变量变化触发的是组件树的更新,但Canvas的内容是命令式绘制的,不是声明式描述的,框架不知道Canvas需要重画。

那怎么在参数变化时触发重绘?WatermarkDemo用了一个取巧的方法——Canvas包在if (this.showWatermark)里,但参数变化不会触发这个if重求值。实际项目中,你需要手动调用drawWatermark。

一种可靠的方式是监听参数变化后调重绘:

TextInput({ text: this.watermarkText, placeholder: '输入水印文字' })
  .onChange((v: string) => {
    this.watermarkText = v;
    this.drawWatermark();
  })

Slider({ value: this.watermarkOpacity, min: 5, max: 40, step: 1 })
  .onChange((v: number) => {
    this.watermarkOpacity = v;
    this.drawWatermark();
  })

但这里有个时序问题——onChange触发时@State还没更新,drawWatermark里读到的还是旧值。更稳妥的做法是用@Watch:

@Watch('onWatermarkChange')
@State watermarkText: string = '内部文件 禁止外传';

@Watch('onWatermarkChange')
@State watermarkOpacity: number = 15;

@Watch('onWatermarkChange')
@State watermarkAngle: number = -30;

private onWatermarkChange(): void {
  this.drawWatermark();
}

@Watch在@State更新后回调,此时drawWatermark能读到最新值。但@Watch不支持同一个回调函数给多个属性同时使用(会重复注册),实际写法需要每个属性单独指定@Watch回调,然后在回调里都调drawWatermark。

WatermarkDemo为了简化演示,没有加@Watch,但实际项目必须处理这个重绘问题,否则用户改了参数水印不更新,直接就提bug了。


透明度的平衡:太低看不见,太高挡内容

水印透明度是最难调的参数。5%透明度,正常看文档时几乎察觉不到水印,截图后放大仔细看能发现——这是理想的追查场景。但5%的水印在深色背景上基本消失,如果你的文档内容有深色表格或图片,水印形同虚设。

30-40%透明度,水印非常明显,截图后一眼就能看到。但这时候水印已经开始干扰正常阅读了——尤其是大量文字的文档,每行字都被半透明的"内部文件 禁止外传"叠加,阅读体验极差。

我的经验值是15-20%,配合-30度旋转角度。这个区间水印在日常使用中不会太打扰阅读,但截图后用图像处理工具调高对比度就能清晰看到。具体值要看文档内容类型——表格多的文档可以稍高,纯文字文档要偏低。

Demo里默认15%就是这个考量。Slider的max设成40而不是100,也是因为超过40%就没法看了。


请添加图片描述

真正的截屏防护:window.setPrivacyMode

Canvas水印是视觉手段,不能阻止截图。用户截图时Canvas内容会被正常捕获到截图中(所以水印才有追查价值),但截图这个动作本身不会被阻止。

如果需要真正的截屏防护,要用window.setPrivacyMode(true)。这个API告诉系统:这个窗口的内容属于隐私内容,截屏时显示黑屏。Android有类似机制(FLAG_SECURE),iOS没有。

import { window } from '@kit.ArkUI';

// 在Ability的onWindowStageCreate里
windowStage.getMainWindow().then((win: window.Window) => {
  win.setPrivacyMode(true);
});

setPrivacyMode(true)后,系统截屏、录屏时这个窗口的内容全部显示为黑色。用户截了图也看不到任何内容,更不用说外传了。

但setPrivacyMode有副作用——整个窗口都变黑,包括标题栏、导航栏。如果你只想保护文档内容区域而不影响其他UI,就需要把文档放在独立的窗口(子窗口)里,只给子窗口开隐私模式。这会增加复杂度。

另外,setPrivacyMode不是万能的——硬件级别的截屏(比如HDMI采集卡、另一个手机对着拍)无法防护。所以实际方案通常是两层叠加:setPrivacyMode防软件截屏,Canvas水印防物理拍摄后的追查。


方案对比总结

Demo的对比指南列了三种方案:

方案 性能 防截屏 适用场景
Canvas叠加 视觉标记,追查泄密源
Stack+Text 简单场景,少量水印
窗口防截屏 - 安全场景,配合水印

Stack+Text方案的"低性能"是因为大量Text组件的布局计算比Canvas命令式绘制重。Canvas是一次drawText搞定,Text组件每个都是独立的布局节点,几百个水印Text组件会显著影响渲染性能。所以超过10个水印就选Canvas,别用Text叠加。


坑点汇总

  1. Canvas水印不防截屏 — 水印是视觉标记手段,只能追查泄密源,不能阻止截图行为。需要截屏防护请用window.setPrivacyMode。

  2. 忘记hitTestBehavior会导致下层内容无法点击 — Canvas叠加在上层默认拦截触摸事件,必须设hitTestBehavior(HitTestMode.Transparent)。

  3. Canvas每次参数变化需手动重绘 — clearRect + 重画。@State变化不会自动触发Canvas重绘,需配合@Watch或手动调用drawWatermark。

  4. 透明度太低水印不可见,太高遮挡内容 — 推荐15-20%区间,深色背景文档适当提高。Slider最大值不要超过40%。

  5. Canvas绘制必须在onReady回调里 — aboutToAppear时Canvas未就绪,context2D方法无效。

  6. rotate前必须translate — 直接rotate会绕原点旋转,文字跑到画布外。先translate到目标位置再rotate。

  7. 循环绘制范围要超出画布边界 — 旋转导致内容偏移,只在画布内绘制会导致边缘出现无水印空白区域。


写在最后

水印是防君子不防小人的手段,真正安全要靠setPrivacyMode+水印的组合拳,透明度和角度的调校是体验和安全之间的走钢丝。

Logo

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

更多推荐