HarmonyOS 6.1 水印与安全截图防护 — Canvas叠加实现文档保护

去年做过一个企业内部文档查看器,需求是"防止员工截图外传"。产品经理信心满满地说"加水印不就行了",我泼了盆冷水——水印只能追查泄密源,不能阻止截图。后来我们做了两层: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字符串格式正确。
旋转前必须translate。ctx.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叠加。
坑点汇总
-
Canvas水印不防截屏 — 水印是视觉标记手段,只能追查泄密源,不能阻止截图行为。需要截屏防护请用window.setPrivacyMode。
-
忘记hitTestBehavior会导致下层内容无法点击 — Canvas叠加在上层默认拦截触摸事件,必须设hitTestBehavior(HitTestMode.Transparent)。
-
Canvas每次参数变化需手动重绘 — clearRect + 重画。@State变化不会自动触发Canvas重绘,需配合@Watch或手动调用drawWatermark。
-
透明度太低水印不可见,太高遮挡内容 — 推荐15-20%区间,深色背景文档适当提高。Slider最大值不要超过40%。
-
Canvas绘制必须在onReady回调里 — aboutToAppear时Canvas未就绪,context2D方法无效。
-
rotate前必须translate — 直接rotate会绕原点旋转,文字跑到画布外。先translate到目标位置再rotate。
-
循环绘制范围要超出画布边界 — 旋转导致内容偏移,只在画布内绘制会导致边缘出现无水印空白区域。
写在最后
水印是防君子不防小人的手段,真正安全要靠setPrivacyMode+水印的组合拳,透明度和角度的调校是体验和安全之间的走钢丝。
更多推荐



所有评论(0)