最近在做笔记类应用时,遇到一个头疼的需求:防止用户内容被非法截图传播。思来想去,加水印是个直接有效的方案。研究了 HarmonyOS 的开发文档后,发现用 Canvas 配合布局组件能轻松实现动态水印效果。今天就来聊聊如何给笔记页面加上「会呼吸」的用户专属水印,顺便分享几个开发时踩过的坑。
一、需求拆解:什么样的水印防截图最有效?
我们的目标很明确:在笔记浏览页面覆盖一层半透明水印,内容包含用户 ID+实时时间戳,且满足以下条件:
- 斜向排列:防止截图后通过简单裁剪去除
- 动态更新:每分钟刷新时间戳,增加追踪难度
- 性能无感:不影响页面滑动和交互
- 全局覆盖:适配不同屏幕尺寸和旋转方向
二、核心实现:用canvas实现动态水印
1. 搭建水印组件骨架
首先封装一个 UserWatermark
组件,基于 HarmonyOS 的 Canvas 实现自绘。这里有个关键细节:通过 hitTestBehavior
设置水印层透明,避免阻挡用户点击笔记内容。
// components/Watermark.ets
@Component
export struct UserWatermark {
@State userId = 'user001' // 从账号服务获取的动态用户ID
private context = new CanvasRenderingContext2D(new RenderingContextSettings(true))
private timestamp = new Date().toLocaleString() // 实时时间戳
build() {
Canvas(this.context)
.width('100%')
.height('100%')
.hitTestBehavior(HitTestMode.Transparent) // 重点!不影响触摸事件
.onReady(() => this.drawWatermark())
}
// 初始化绘制
private drawWatermark() {
this.updateTimestamp() // 先更新时间
this.context.clearRect(0, 0, this.context.width, this.context.height)
this.setWatermarkStyle()
this.drawWatermarkGrid()
}
}
2. 动态时间戳实现:每分钟刷新一次
为了避免高频重绘影响性能,选择每分钟更新一次时间戳。这里用 setInterval
配合状态变量触发重绘:
// 组件生命周期钩子
aboutToAppear() {
this.updateTimestamp() // 初始化时间
setInterval(() => {
this.timestamp = new Date().toLocaleString() // 更新时间
this.drawWatermark() // 触发画布重绘
}, 60 * 1000) // 每分钟执行一次
}
// 时间格式化方法(可根据需求调整)
private updateTimestamp() {
const now = new Date()
this.timestamp = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')} ${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`
}
3. 斜向水印的坐标计算「玄学」
实现斜向排列的关键是坐标系旋转和平移。这里踩过最大的坑是旋转后原点位置的变化,调试了半小时才发现需要将原点移到左下角:
private drawWatermarkGrid() {
const text = `用户ID:${this.userId} ${this.timestamp}`
const font = '14vp sans-serif'
const angle = -25 * Math.PI / 180 // 倾斜25度
// 计算文本尺寸
this.context.font = font
const { width: textWidth, height: textHeight } = this.context.measureText(text)
// 坐标系变换:先平移到左下角,再旋转
this.context.save()
this.context.translate(0, this.context.height) // 原点移至左下角
this.context.rotate(angle) // 逆时针旋转25度
// 计算行列间隔(1.5倍文本尺寸避免重叠)
const colGap = textWidth * 1.5
const rowGap = textHeight * 1.5
const cols = Math.ceil(this.context.width / colGap)
const rows = Math.ceil(this.context.height / rowGap)
// 绘制网格
for (let i = 0; i < cols; i++) {
for (let j = 0; j < rows; j++) {
const x = i * colGap
const y = j * rowGap
this.context.fillText(text, x, -y) // y轴反转(因为原点在左下角)
}
}
this.context.restore() // 恢复原始坐标系
}
4. 页面集成:用 Stack 实现水印层覆盖
将水印组件与笔记内容层叠加,推荐使用 Stack
布局,清晰且性能稳定:
// pages/NoteDetail.ets
@Entry
@Component
struct NoteDetail {
private noteContent = '这里是用户的笔记正文...'
build() {
Stack() {
// 笔记内容层
Column()
.padding(24)
.spacing(16)
.text(this.noteContent)
.fontSize(16)
.lineHeight(24)
// 水印层(覆盖在内容上方)
UserWatermark()
}
.backgroundColor(Color.White)
.width('100%')
.height('100%')
}
}
三、性能优化
-
离屏渲染优化
如果遇到页面卡顿,可以尝试将Canvas
替换为OffscreenCanvas
进行离屏绘制,减少主线程压力:// 离屏画布版本(适用于复杂场景) private offscreenCanvas = new OffscreenCanvas() private offscreenContext = this.offscreenCanvas.getContext('2d')! // 在draw方法中使用offscreenContext绘制,最后同步到主画布 this.context.drawImage(this.offscreenCanvas, 0, 0)
-
文本测量缓存
重复计算文本宽度会影响性能,因此将measureText
的结果缓存:private textMetrics: TextMetrics | null = null private getTextSize(text: string) { if (!this.textMetrics || this.textMetrics.text !== text) { this.textMetrics = this.context.measureText(text) } return this.textMetrics }
-
触摸事件优化
通过hitTestBehavior: HitTestMode.Transparent
让水印层完全透明,触摸事件直接穿透到下层内容,不影响用户操作。
四、从页面到全场景的水印方案
如果你的应用需要支持更多场景,还可以参考官方文档中的其他能力:
- 图片水印:用
OffscreenCanvas
实现本地图片加水印(适合用户保存笔记截图时自动加水印) - PDF 水印:通过
pdfService
模块给导出的 PDF 文档添加水印(企业需求必备) - 动态变色:根据页面主题切换水印颜色(浅色/深色模式适配)
五、那些让我半夜睡不着的细节
-
旋转方向的坑
坐标系默认顺时针旋转,想实现「向左倾斜」需要用负数角度(如-25度
),刚开始用正数导致水印方向搞反。 -
设备适配问题
不同设备的vp
单位换算有差异,建议统一处理尺寸问题。 -
性能监控
用 DevEco Studio 的「性能调优」工具监控onReady
和draw
方法的执行时间,确保单次绘制不超过 16ms(60fps标准)。
六、总结:水印背后的安全哲学
加水印本质是一种「威慑性防护」,它不能完全阻止截图,但能大大增加内容泄露后的追溯成本。在实际开发中,建议结合以下策略:
- 前端水印:防止非授权截图传播
- 后端日志:记录用户操作时间线
- 数据加密:敏感内容本地加密存储
技术之外,更重要的是平衡用户体验与安全需求——毕竟,没有人喜欢被密密麻麻的水印「包围」。通过透明度调整(建议 opacity: 0.15-0.2
)和合理的排列间隔,完全可以做到「水印可见但不干扰阅读」。
如果你在开发中遇到其他有趣的场景,欢迎在评论区交流~ 一起用技术让内容安全更优雅一点~ 💻✨