更新记录

1.0.0(2026-03-01)

  • feat: 提供全局页面水印 API(showPageWatermark / hidePageWatermark
  • feat: 支持 grid / stagger / jitter / dual-angle 模式与 seed 固定分布
  • feat: Android(Kotlin)与 iOS(Swift)原生渲染实现
  • feat: 统一错误码与日志开关(setLogEnabled / isLogEnabled
  • docs: 补充完整使用文档(平台支持、参数、错误码、排查)

平台兼容性

uni-app(4.87)

Vue2 Vue3 Chrome Safari app-vue app-nvue Android iOS 鸿蒙
- - - -
微信小程序 支付宝小程序 抖音小程序 百度小程序 快手小程序 京东小程序 鸿蒙元服务 QQ小程序 飞书小程序 小红书小程序 快应用-华为 快应用-联盟
- - - - - - - - - - - -

uni-app x(4.87)

Chrome Safari Android iOS 鸿蒙 微信小程序
- - -

hans-watermark

适用于 uni-app 与 uni-app x 的 UTS 全局页面水印插件(Android Kotlin + iOS Swift + Harmony)。

功能概览

  • 提供页面级全局覆盖水印,不依赖业务页面结构。
  • 支持 grid / stagger / jitter / dual-angle 四种平铺模式。
  • 支持通过 seed 固定 jitter 模式分布,便于回归测试和视觉一致性。
  • 提供统一错误码和回调风格(success / fail / complete)。

接入方式

从官方插件市场安装:

  1. 在 HBuilderX 中打开 插件市场
  2. 搜索 hans-watermark 并安装到当前项目。
  3. 安装后会自动生成 uni_modules/hans-watermark

业务侧直接引入:

import {
    showPageWatermark,
    hidePageWatermark,
    setLogEnabled,
    isLogEnabled,
    WatermarkFail
} from '@/uni_modules/hans-watermark'

快速开始

import {
    showPageWatermark,
    hidePageWatermark,
    setLogEnabled
} from '@/uni_modules/hans-watermark'

setLogEnabled(true)

showPageWatermark({
    text: 'CONFIDENTIAL',
    mode: 'jitter',
    seed: 20260228,
    success: (res) => {
        console.log('watermark visible =', res.visible)
    },
    fail: (err) => {
        console.error(err.errCode, err.errMsg)
    }
})

hidePageWatermark({
    success: (res) => {
        console.log('watermark visible =', res.visible)
    }
})

Harmony 接入(必须)

Harmony 方案使用 WindowStage 子窗口承载 overlay。除安装 uni_modules/hans-watermark 外,还需要在项目根目录提供 harmony-configs 中的原生页面配置。

最小目录结构:

<project-root>/
  harmony-configs/
    entry/src/main/resources/base/profile/main_pages.json
    entry/src/main/ets/pages/HansWatermarkOverlay.ets

main_pages.json 至少包含:

{
  "src": [
    "pages/Index",
    "pages/HansWatermarkOverlay"
  ]
}

entry/src/main/ets/pages/HansWatermarkOverlay.ets 可直接使用下面模板:

import display from '@ohos.display'
import { __getOverlayConfigJSONForHarmony } from '@uni_modules/hans-watermark'

interface OverlaySnapshot {
  version: number
  visible: boolean
  text: string
  opacity: number
  rotation: number
  fontSize: number
  color: string
  gapX: number
  gapY: number
  mode: string
  jitterPx: number
  angleVariance: number
  seed: number
}

interface OverlayCell {
  id: string
  left: number
  top: number
  rotation: number
}

function clamp(value: number, min: number, max: number): number {
  if (value < min) {
    return min
  }
  if (value > max) {
    return max
  }
  return value
}

function resolveMode(mode: string): string {
  if (mode === 'stagger') {
    return 'stagger'
  }
  if (mode === 'jitter') {
    return 'jitter'
  }
  if (mode === 'dual-angle') {
    return 'dual-angle'
  }
  return 'grid'
}

function u32(value: number): number {
  return value >>> 0
}

function cellNoise(seed: number, row: number, col: number, channel: number): number {
  let value = u32(u32(seed) * 73856093)
  value = u32(value ^ u32(row * 19349663))
  value = u32(value ^ u32(col * 83492791))
  value = u32(value ^ u32(channel * 265443576))
  value = u32(value ^ (value >>> 13))
  value = u32(value * 1274126177)
  value = u32(value ^ (value >>> 16))
  const positive = value & 0x7fffffff
  const normalized = positive / 1073741824.0
  return normalized - 1.0
}

@Entry
@Component
struct HansWatermarkOverlay {
  @State private cells: Array<OverlayCell> = []

  private timerId: number = 0
  private lastVersion: number = -1
  private wmVisible: boolean = false
  private wmText: string = ''
  private wmOpacity: number = 0.15
  private wmRotation: number = -25
  private wmFontSize: number = 18
  private wmColor: string = '#000000'
  private wmGapX: number = 120
  private wmGapY: number = 88
  private wmMode: string = 'grid'
  private wmJitterPx: number = 10
  private wmAngleVariance: number = 12
  private wmSeed: number = 0
  private viewportWidth: number = 390
  private viewportHeight: number = 844

  aboutToAppear(): void {
    this.pullConfig(true)
    this.timerId = setInterval(() => {
      this.pullConfig(false)
    }, 240)
  }

  aboutToDisappear(): void {
    if (this.timerId > 0) {
      clearInterval(this.timerId)
      this.timerId = 0
    }
  }

  private pullConfig(force: boolean): void {
    let payload = ''
    try {
      payload = __getOverlayConfigJSONForHarmony()
    } catch (_) {
      return
    }
    if (payload.length == 0) {
      return
    }

    let snapshot: OverlaySnapshot
    try {
      snapshot = JSON.parse(payload) as OverlaySnapshot
    } catch (_) {
      return
    }

    if (!force && snapshot.version == this.lastVersion) {
      return
    }
    this.lastVersion = snapshot.version

    this.wmVisible = snapshot.visible
    this.wmText = snapshot.text.trim()
    this.wmOpacity = clamp(snapshot.opacity, 0, 1)
    this.wmRotation = snapshot.rotation
    this.wmFontSize = clamp(snapshot.fontSize, 8, 120)
    this.wmColor = snapshot.color
    this.wmGapX = clamp(snapshot.gapX, 0, 400)
    this.wmGapY = clamp(snapshot.gapY, 0, 400)
    this.wmMode = resolveMode(snapshot.mode)
    this.wmJitterPx = clamp(snapshot.jitterPx, 0, 80)
    this.wmAngleVariance = clamp(snapshot.angleVariance, 0, 45)
    this.wmSeed = Math.round(snapshot.seed)

    this.rebuildCells()
  }

  private refreshDisplaySize(): void {
    try {
      const info = display.getDefaultDisplaySync()
      if (info.width > 0) {
        this.viewportWidth = info.width
      }
      if (info.height > 0) {
        this.viewportHeight = info.height
      }
    } catch (_) {
    }
  }

  private estimateTextWidth(): number {
    let wideCount = 0
    const size = this.wmText.length
    for (let i = 0; i < size; i++) {
      const code = this.wmText.charCodeAt(i)
      if (code > 255) {
        wideCount++
      }
    }
    const asciiCount = size - wideCount
    return Math.max(wideCount * this.wmFontSize + asciiCount * this.wmFontSize * 0.58, this.wmFontSize * 2)
  }

  private rebuildCells(): void {
    if (!this.wmVisible || this.wmText.length == 0) {
      this.cells = []
      return
    }

    this.refreshDisplaySize()
    if (this.viewportWidth <= 0 || this.viewportHeight <= 0) {
      this.cells = []
      return
    }

    const textWidth = this.estimateTextWidth()
    const textHeight = Math.max(this.wmFontSize * 1.2, 10)
    const stepX = Math.max(textWidth + this.wmGapX, textWidth + 24)
    const stepY = Math.max(textHeight + this.wmGapY, textHeight + 18)

    const result: Array<OverlayCell> = []
    let row = 0
    let y = -textHeight - stepY

    while (y < this.viewportHeight + stepY * 2) {
      const rowOffsetX = (this.wmMode == 'stagger' && row % 2 != 0) ? (stepX * 0.5) : 0
      let col = 0
      let x = -textWidth - stepX + rowOffsetX

      while (x < this.viewportWidth + stepX * 2) {
        let drawX = x
        let drawY = y
        let drawRotation = this.wmRotation

        if (this.wmMode == 'dual-angle') {
          const sign = ((row + col) % 2 == 0) ? -1 : 1
          drawRotation = this.wmRotation + sign * this.wmAngleVariance
        } else if (this.wmMode == 'jitter') {
          const noiseX = cellNoise(this.wmSeed, row, col, 11)
          const noiseY = cellNoise(this.wmSeed, row, col, 29)
          const noiseR = cellNoise(this.wmSeed, row, col, 53)
          drawX += noiseX * this.wmJitterPx
          drawY += noiseY * this.wmJitterPx
          drawRotation = this.wmRotation + noiseR * this.wmAngleVariance
        }

        result.push({
          id: `${row}-${col}`,
          left: drawX,
          top: drawY,
          rotation: drawRotation
        })

        col++
        x += stepX
      }

      row++
      y += stepY
    }

    this.cells = result
  }

  build() {
    Stack({ alignContent: Alignment.TopStart }) {
      ForEach(this.cells, (cell: OverlayCell) => {
        Text(this.wmText)
          .position({ x: cell.left, y: cell.top })
          .fontSize(this.wmFontSize)
          .fontColor(this.wmColor)
          .opacity(this.wmOpacity)
          .rotate({ x: 0, y: 0, z: 1, angle: cell.rotation })
      }, (cell: OverlayCell) => cell.id)
    }
    .width('100%')
    .height('100%')
    .backgroundColor(Color.Transparent)
  }
}

说明:

  • pages/HansWatermarkOverlay 是子窗口 setUIContent 的加载目标。
  • 若缺少该页面或未注册到 main_pages.json,会出现 setUIContent failed ... code: 401
  • 本仓库 playground 已包含可用的 harmony-configs 参考实现。

API

setLogEnabled(enabled: boolean): void

开启/关闭插件日志。

isLogEnabled(): boolean

读取当前日志开关状态。

showPageWatermark(options): void

显示或更新页面水印。重复调用会更新同一层覆盖视图。

参数 类型 必填 默认值 说明
text string - 水印文本,不能为空
opacity number 0.15 透明度,范围会被钳制到 0~1
rotation number -25 旋转角度(度)
fontSize number 18 字号,范围会被钳制到 8~120
color string #000000 文本颜色(推荐 #RRGGBB
gapX number 120 水平间距,范围会被钳制到 0~400
gapY number 88 垂直间距,范围会被钳制到 0~400
mode 'grid' \| 'stagger' \| 'jitter' \| 'dual-angle' grid 水印排布模式
jitterPx number 10 抖动位移,范围会被钳制到 0~80
angleVariance number 12 角度扰动,范围会被钳制到 0~45
seed number 0 随机种子,jitter 模式在同平台可稳定复现分布
success (res) => void - 成功回调
fail (err) => void - 失败回调,errWatermarkFail
complete (res) => void - 完成回调

成功回调 res

{
    visible: boolean
}

hidePageWatermark(options?): void

关闭页面水印层,支持可选回调参数:

  • success(res)
  • fail(err)
  • complete(res)

模式说明

  • grid: 常规均匀网格。
  • stagger: 奇偶行错位半个步长。
  • jitter: 在网格基础上加入随机位移和随机角度(受 seed 控制)。
  • dual-angle: 邻近文本正负角度交替,增强视觉破坏性。

错误码

错误码 含义 常见触发
9010001 参数错误 text 为空
9010005 平台异常 原生层执行失败、超时等
9010006 当前平台不支持 预留错误码(非支持平台调用)
9010008 页面水印上下文不可用 获取当前页面窗口失败

调试与排查

  • 打开日志:setLogEnabled(true)
  • Android 日志关键字:[hans-watermark][android]
  • iOS 日志关键字:[hans-watermark][ios]
  • Harmony 日志关键字:[hans-watermark][harmony]
  • 如果出现 9010008,优先确认调用时机(建议页面 onReady/onShow 后)以及当前窗口是否可用。

隐私、权限声明

1. 本插件需要申请的系统权限列表:

无需额外系统权限

2. 本插件采集的数据、发送的服务器地址、以及数据用途说明:

仅在应用页面渲染水印,不采集用户个人信息

3. 本插件是否包含广告,如包含需详细说明广告表达方式、展示频率:

暂无用户评论。