更新记录

0.4.4(2026-11-03)

初始版本


平台兼容性

水印相机插件 · 功能说明与使用文档

插件 id:uts-watermark-camera 当前版本:0.4.4 支持平台:Android/iOS/HarmonyOS/微信小程序(四端均可出图;后两者只有异步接口) 文档对应代码目录:uni_modules/uts-watermark-camera 文档与代码的对应关系:所有参数、默认值、错误码以 utssdk/interface.uts 与各端 index 为准; 能力是否存在以 getCapabilities() 为准。两者不一致时,代码是对的,本文档是 bug。


目录

  1. 它解决什么问题
  2. 功能总览
  3. 安装与接入
  4. 五分钟上手
  5. API 参考
  6. 模板 DSL 完整指南
  7. 内置模板清单
  8. 字段清单与业务接入
  9. 图标库
  10. 二维码
  11. 常见场景配方
  12. 性能、包体与内存
  13. 跨端差异与已知限制
  14. 排错手册
  15. 版本策略与升级

一、它解决什么问题

水印相机看起来很简单:拍张照,把时间和地点印上去。真正做起来有四个反复被低估的成本:

成本 具体表现 本插件的处理方式
版式改一次要发一次版 客户要换配色、加一行工单号、把二维码挪到右上角,都需要改代码、打包、过审 版式是 JSON 模板,云端下发即生效,改模板不需要发版
多端版式对不上 Android 与 iOS 各写一套绘制,字体度量不同,同一模板两端差几个像素,客户一眼看出 排版在 平台无关的布局引擎里算完(各端同一份代码),原生只"按指令落笔"
水印能被绕过 预览层叠加的水印,成片里没有;或者截图能裁掉 拍出无水印原片 → 合成层烧录 → 输出,像素级嵌入,不可剥离
接入方要自己造轮子 定位、逆地理、天气、海拔、参数校验、错误码,每个 App 都重写一遍 内置字段约定 + 模板校验 + 明确错误码,接入方只负责填业务字段

一句话定位:给你一个"版式可配、多端一致、水印烧进像素"的水印相机内核,业务字段由你填。


二、功能总览

2.1 渲染能力

元素类型 说明 Android iOS HarmonyOS 微信小程序
text 单行/多行文本,支持字段绑定、格式化、自动折行、省略号、自适应字号 ✅ ✅¹ ✅ ✅
textBlock 多行文本块(同上,语义区分) ✅ ✅¹ ✅ ✅
panel 半透明/渐变面板,圆角、描边 ✅ ✅¹ ✅ ✅
column / row 纵向/横向布局容器,支持 fill 主轴分配 ✅ ✅ ✅ ✅
divider 分割线 ✅ ✅ ✅ ✅
spacer 弹性占位(与 fill 配合做左右分栏) ✅ ✅ ✅ ✅
image 图片(Logo、签名、静态地图瓦片),支持 cover/contain 与圆角 ✅ ✅¹ ✅ ✅
qrcode 内置二维码编码器(Byte 模式,版本 1–10,纠错 L/M/Q/H) ✅ ✅ ✅ ✅
icon 33 个内置矢量图标 + 自定义图元串 ✅ ✅ ✅ ✅
compass 罗盘/朝向指示,两种样式 ✅ ✅ ✅ ✅
map 静态地图缩略图 ⏳ ⏳ ⏳ ⏳
signature 手写签名图层 ⏳ ⏳ ⏳ ⏳

四端跑的是同一份布局引擎(utssdk/core/*.uts),元素能不能画出来只取决于各端绘制层。 鸿蒙与微信小程序的出图只有异步接口(composeImageAsync / renderOverlayAsync),同步版返回 9100017。

map / signature 目前能排版、不绘制:指令会正常产出(previewLayout 里看得到), 绘制端跳过并写一条 warnings —— 这样模板作者能先调版式,不会因为"某端没实现"就整张图出不来。

¹ iOS 出图有三个已知降级:文字描边、圆角渐变面板、圆角图片元素按直角/无描边绘制 (都会写进 warnings,不静默)。其余效果与 Android 逐点对齐。

2.2 模板能力

  • 主题继承:theme 定义一次,全模板复用;节点可逐字段覆盖
  • 变体(variants):按横/竖画幅与宽高比自动切换版式,一份模板兼顾 4:3 / 16:9 / 1:1
  • 模板继承(extends):extends 基础模板,按 id 逐项合并(数组不是整段替换)
  • 版本协商(minEngine):模板声明所需引擎版本,老客户端拿不到新版式时明确报错而不是白屏
  • 必填字段校验(blockIfMissing):排版前先查字段,缺了直接返回 9100004 并列出缺哪个
  • 字段缺失三策略:hide(隐藏元素)/ placeholder(画占位符)/ block(阻断出图)

2.3 工程能力

  • 输出内容随调用方配置(0.3.0):文件(默认,path 可直接给 <image> 的 src)/ base64 / 两者都要;格式 jpeg / png,质量可配(默认 0.92)
  • base64 输入(imageBase64,不带 data: 前缀)与等比尺寸压缩(maxWidth / maxHeight)
  • 四边分区自由排版(0.3.0):top/bottom/left/right/center 各传一组内容即可成版式,不必学整套 DSL(见 5.8)
  • 异步接口四端同名(0.4.0 / 0.4.3):composeImageAsync / renderOverlayAsync。鸿蒙与小程序只有异步版本,Android / iOS 的异步版是同步实现外包一层 Promise
  • 大图合成不崩(0.4.4):解码 / EXIF 转正 / 缩放合并为一次装载(最多两张位图),超预算自动等比缩并写 warnings;失败时 reason 带尺寸与堆快照
  • 自动按 EXIF 方向做像素级归正(避免"拍出来躺着")
  • 返回成片 SHA-256(Android、微信小程序可用;iOS 与鸿蒙当前返回空串并在 warnings 说明),可用于落库校验"上传后未被替换"
  • 可选输出无水印原片副本(keepOriginal)
  • 可选合成后删除输入图(deleteSource)
  • 自定义输出目录与文件名,避免缓存堆积(微信小程序只能写 wx.env.USER_DATA_PATH,见 13 节)
  • 排版告警不阻断出图(warnings 数组),例如"某个图标名写错已跳过"

三、安装与接入

3.1 放置插件

把 uni_modules/uts-watermark-camera 整个目录放进项目的 uni_modules/ 下。目录结构:

uni_modules/uts-watermark-camera/
├── package.json              # 插件市场元数据(platforms 含 app-android / app-ios / app-harmony / mp-weixin)
├── readme.md                 # 市场详情页文案
├── changelog.md
├── static/                   # 8 套模板 + 2 个分区示例的渲染预览图(上架展示用)
├── utssdk/
│   ├── interface.uts         # 对外类型(只放 export type),参数与错误码的唯一声明处
│   ├── core/                 # ★ 平台无关引擎(四端共用同一份)
│   │   ├── util.uts          # 跨端安全的字符串/数字工具
│   │   ├── json.uts          # 自研 JSON 解析器(报错带行列)
│   │   ├── types.uts         # 节点 / 绘制指令模型
│   │   ├── text.uts          # 字形度量、断行、时间格式化
│   │   ├── textblock.uts     # 多行文本块排版
│   │   ├── field.uts         # 字段绑定与条件表达式
│   │   ├── parser.uts        # 模板解析与校验
│   │   ├── style.uts         # text / textLines / theme / canvas 覆盖的统一编译
│   │   ├── zone.uts          # 四边分区编译器(zone → 整份模板)
│   │   ├── layout.uts        # 两趟布局,输出 DrawCommand[]
│   │   ├── presets.uts       # 8 套内置模板 + 注册表
│   │   ├── qrcode.uts        # 二维码编码器
│   │   ├── icons.uts         # 33 个内置图标 + 图元 DSL
│   │   └── engine.uts        # 门面
│   ├── app-android/index.uts # Android:Bitmap 绘制 + 合成(同步 + Async 包装)
│   ├── app-ios/index.uts     # iOS:CATextLayer / CoreGraphics 绘制 + 合成
│   ├── app-harmony/          # 鸿蒙:出图(仅 Async)+ 原生相机
│   │   ├── index.uts         #   导出面(含 WmCamera 类)
│   │   ├── wm-draw.ets       #   OffscreenCanvas 2D 绘制层
│   │   └── wm-camera.ets     #   XComponent + CameraKit 相机实现
│   └── mp-weixin/            # 微信小程序(平台层用 js,引擎仍复用 core)
│       ├── index.js          #   离屏 Canvas 2D 绘制 + 出图(仅 Async)
│       └── type-shims.js     #   uts2js「类型断言 → new 类型()」的运行时兜底
└── components/
    └── wm-camera/wm-camera.uvue   # 鸿蒙相机组件(仅 APP-HARMONY 下使用)

为什么小程序端是 .js 而不是 .uts:非 App 平台目录本来就允许写 js(官方 uts-getbatteryinfo 的 mp-weixin/index.js 就是范本)。小程序层直连 wx.createOffscreenCanvas / canvasToTempFilePath, 而 .uts 转译会把对象字面量包成 UTSJSONObject,小程序 API 对入参形态敏感 —— 在 JS 里写才"零摩擦"。 *排版仍然复用 `core/.uts`**,不存在第二份布局实现。

3.2 重打自定义基座:分三种情况

平台 要不要重打 原因
Android / iOS 必须 含原生代码(.kt / .swift);uni_modules/ 下插件的 .uts 改动同样要重打
HarmonyOS 不用 每次运行都会重新 CompileArkTS
微信小程序 不用 重新编译到开发者工具即可

⚠️ 只"重新运行"而不重打基座,症状是毫无变化 —— 模板改了、插件改了、版本号改了,跑起来全是旧的。 这是最常见的无效排查起点。判据很简单:改了 uni_modules/ 下的东西,除鸿蒙与小程序外一律重打;拿不准就重打。

⚠️ 另一个坑:不要在 HBuilderX 编译过程中编辑插件源码,否则会吃到半新半旧的副本,报错位置对不上行号。

3.3 自证改动是否真的进包

四端入口各有一行启动日志:

[uts-watermark-camera] v0.4.4 android 实现已加载
[uts-watermark-camera] v0.4.4 ios 实现已加载
[uts-watermark-camera] v0.4.4 harmony 实现已加载
[uts-watermark-camera] v0.4.4 mp-weixin 实现已加载     # 小程序:开发者工具控制台

冷启动后看控制台,版本号对不上就是没进包,先重打(小程序先重新编译),别查别的。

3.4 权限

插件自身不申请任何权限:不联网、不自动定位、不自动读相册。权限只来自"宿主用相机"这一件事:

平台 需要什么 写在哪
Android android.permission.CAMERA(录像另需 RECORD_AUDIO) manifest.json 的 App 权限配置
iOS NSCameraUsageDescription(录像另需 NSMicrophoneUsageDescription) iOS 隐私描述
HarmonyOS ohos.permission.CAMERA harmony-configs/entry/src/main/module.json5 的 requestPermissions;插件代劳不了,缺了只会得到 9100008
微信小程序 <camera> 不需要在 app.json 声明 小程序后台《用户隐私保护指引》勾选「摄像头」;存相册首次调用弹 scope.writePhotosAlbum

定位权限由你自己的定位代码申请(uni.getLocation),不是插件申请的 —— 插件只接收你填进 fields 的坐标。


四、五分钟上手

4.1 最小可用示例

★ 字段表是 UTSJSONObject,不是 Map。 uvue 调用插件导出方法时参数要过桥接, 桥接对 Map 字段做的是硬转 it as Map,而 uvue 传过去的是 UTSJSONObject, 结果就是一点就崩:ClassCastException: UTSJSONObject cannot be cast to io.dcloud.uts.Map (真机实测,堆栈落在 createUTSXXXOptions)。 uts 插件的公开签名里不能出现 Map —— 这是跨语言边界的硬限制,不是写法问题。 插件内部仍然是 Map<string, string>,在方法入口转一次。

<script setup lang="uts">
import { composeImageAsync, ComposeOptions } from '@/uni_modules/uts-watermark-camera'

// 业务字段:键名可以自定义,模板里用 {键名} 绑定
const fields : UTSJSONObject = {}
fields.set('timestamp', `${Date.now()}`)
fields.set('geo.address', '四川省成都市武侯区天府五街 200 号')
fields.set('geo.lat', '30.542103')
fields.set('geo.lng', '104.063219')
fields.set('biz.operator', '梁工')

async function shoot(imagePath : string) : Promise<void> {
    const r = await composeImageAsync({
        imagePath: imagePath,
        templateId: 'tpl-general',   // 用内置模板
        fields: fields,
        maxWidth: 3000,              // ★ 相机直出 12MP 给水印用是浪费,见 12.3
        maxHeight: 3000,
        quality: 0.92
    } as ComposeOptions)
    if (r.ok) {
        console.log(`成片:${r.path} ${r.width}×${r.height} ${r.elapsed}ms`)
        console.log(`SHA-256:${r.sha256}`)
        if (r.warnings.length > 0) {
            console.log(`告警:${r.warnings.join(' | ')}`)
        }
    } else {
        console.error(`合成失败 ${r.code}:${r.reason}`)
    }
}
</script>

为什么一上手就用 Async:鸿蒙的图片编解码与微信小程序的 Canvas 2D 只有异步 API, 同步的 composeImage() 在这两端固定返回 9100017;而 Android / iOS 的 composeImageAsync 只是同步实现外包一层 Promise。统一写 Async 不付额外代价,还能让四端一份代码。

4.2 不开相机也能调版式

previewLayout() 只跑排版、不碰像素,可以在没有相机权限、没有真机拍照的情况下把模板调到满意为止:

import { previewLayout } from '@/uni_modules/uts-watermark-camera'

const r = previewLayout({
    imagePath: '',
    templateJson: myTemplateJson,
    fields: fields,
    previewWidth: 1080,
    previewHeight: 1440
})
if (r.ok) {
    // r.commandsJson 是数组,每条指令形如
    // {"op":"text","x":48,"y":1310,"text":"2026-10-29 16:20:31","size":34,"color":"#FFFFFFFF"}
    console.log(r.commandsJson)
    // r.usedFields 是本次真正用到的字段快照 —— 排查"为什么这个字段没显示"的第一现场
    console.log(JSON.stringify(r.usedFields))
} else {
    console.error(`${r.code} ${r.reason}`)
}

commandsJson 的结构是稳定的,你可以拿它在自建的调试页里直接画一遍(Canvas 或 SVG 都行),得到一个"像素级忠实"的预览。

仓库里的 example/preview.uvue 是一个完整的相机调试台:取景 → 选模板(一行三张、可滚动)→ 实时叠加层 → 拍照 / 相册导入 → 合成 → 存相册,还能改字号、底色、贴底距离,并查看指令与告警。 它的调用与本文档一致(统一 await composeImageAsync / renderOverlayAsync), 条件编译只用在相机组件那一处(鸿蒙用 wm-camera,其余用 <camera>)。


五、API 参考

四端导出名与签名完全一致,差异只在 getCapabilities() 的返回值,以及"同步版是否可用"。 所以业务代码不需要按平台写分支 —— 只要统一用 Async,四端可以直接共用。

5.1 getCapabilities() : CameraCapabilities

唯一事实来源。 UI 显不显示某个按钮,必须以它为准,不要按平台名硬编码。

const caps = getCapabilities()
// Android / iOS:
// {
//   platform: 'android',                     // ios / harmony / mp-weixin
//   supportedPositions: ['back', 'front'],   // 鸿蒙由 wm-camera 承载,小程序由内置 <camera>
//   cameraCapture: true,
//   liveOverlay: true,                       // renderOverlay(Async) 可用
//   video: false, videoWatermark: false,
//   cropByPreview: false, draggable: false,
//   batchImageWatermark: false,              // 鸿蒙为 true
//   qrcode: true,
//   mapThumbnail: false,
//   deviceSignature: false,
//   mockLocationDetection: false,
//   maxPhotoSize: 0,                         // 小程序为 4096(单边上限)
//   engineVersion: '0.4.4'
// }
字段 四端取值 用途
liveOverlay 均为 true 决定"预览上要不要叠水印"
batchImageWatermark 鸿蒙 true,其余 false 鸿蒙绘制层可批量循环;其它端请自己串行循环
maxPhotoSize 小程序 4096,其余 0(未知) 上传/裁剪前判断是否要缩
qrcode / mapThumbnail true / false 模板编辑器里是否允许插入该元素
engineVersion 0.4.4 与模板 minEngine 比对

supportPositions 非空 ≠ 相机已就绪。它描述的是"本插件能提供哪些镜头", 真正能否打开还取决于宿主权限(鸿蒙缺权限只会给你 9100008)。

5.2 listTemplates() : BuiltinTemplateInfo[]

返回内置模板清单,供宿主做模板选择器。

const list = listTemplates()   // [{ id, name, industry, preview }]

preview 字段当前恒为空串(接口预留,未来可能由云端模板返回 URL)。 内置模板的渲染预览图在插件 static/ 目录里,命名规则 preview-<id>.png, 模板选择器可以直接拼路径当缩略图:

const thumb = `/uni_modules/uts-watermark-camera/static/preview-${tpl.id}.png`

5.3 registerTemplate(id, json) : boolean

注册(或覆盖)一个自定义模板。注册后可以:

  • 直接用 templateId: id 调用 composeImage
  • 被其他模板通过 extends: id 继承
registerTemplate('tpl-my-fleet', myJsonString)

返回 false 表示 JSON 语法或结构不合法。

5.4 listIcons() : string[]

返回 33 个内置图标名,供宿主做图标选择器。

5.5 engineVersion() : string

返回当前引擎版本,与 getCapabilities().engineVersion 同值。

5.6 setGlyphCalibration(table) : void

注入字宽校准表,键为 Unicode 码点,值为 字宽 / 字号(em 比例)。

内置字宽表是按常见字体比例估算的,中英混排误差约 ±3%。若你对"各端像素级一致"有硬要求,可以:

  1. 在设备上用真实字体跑一遍 measureText
  2. 算出系数表
  3. 在各端注入同一份数据

⚠️ 只在一端注入、另一端不注入,反而会造成更大的不一致。不注入时各端都用内置表,这个组合是自洽的。

5.7 previewLayout(options) : LayoutPreviewResult

只排版,不读写图片。参数见 ComposeOptions,只用到 templateJson / templateId / fields / previewWidth / previewHeight。

返回:

字段 说明
ok / code / reason 结果与失败原因
canvasWidth / canvasHeight 实际画布尺寸
commandsJson 绘制指令数组的 JSON 字符串
usedFields 本次真正参与排版的字段快照
warnings 告警(字段缺失被跳过、图标名未知等)

5.8 composeImage / composeImageAsync:出图

两个函数的参数与返回值完全一致,只差同步/异步。鸿蒙与小程序只有 Async 版本(见 5.9), 所以推荐一律写 await composeImageAsync(...)。完整参数:

参数 类型 默认 说明
imagePath string 必填¹ 输入图片绝对路径;与 imageBase64 二选一
imageBase64 string — base64 图片内容(0.3.0,不带 data: 前缀)
templateJson string — 模板 JSON
templateId string — 内置或已注册的模板 id
layout ZoneLayout — 四边分区(0.3.0):top/bottom/left/right/center 各自一组内容;优先于模板
layoutJson string — 同 layout 的 JSON 字符串形式
output string 'file' 输出内容:file / base64 / both(0.3.0)
format string 'jpeg' 输出格式:jpeg / png(0.3.0)
maxWidth / maxHeight number — 等比尺寸压缩上限(0.3.0,排版前先缩)
fields UTSJSONObject — 字段表(键值对;不能用 Map,跨语言边界会 ClassCastException)
text string — 自定义内容模式(0.3.0):整段文案,\n 分行;传了就不看模板与字段
textLines string[] — 同上,逐行数组形式(优先于 text)
theme WatermarkTheme — 外观覆盖(0.3.0):背景色 / 字号 / 整体缩放等,对两种内容模式都生效
canvas WatermarkCanvas — 位置覆盖(0.3.0):贴边方向、离边距离
collect CollectOptions — 当前未实现,传了会得到告警
outputDir string 源图同级 watermark/ 输出目录绝对路径;base64 输入且 output 含 file 时必填
fileName string 自动生成 输出文件名(不含路径分隔符)
quality number 0.92 JPEG 质量 0–1
keepExif boolean false 当前只做方向归正,传 true 会得到告警
keepOriginal boolean false 额外输出无水印原片副本
deleteSource boolean false 出图后删除输入图
previewWidth/Height number 1080/1440 仅 previewLayout 使用

返回:

字段 说明
ok / code / reason 结果与失败原因
path 成片绝对路径(output 含 file 时非空)
base64 / dataUri base64 内容与完整 data URI(0.3.0,dataUri 可直接喂 <image> 的 src)
mimeType image/jpeg / image/png
originalPath 原片副本路径(仅 keepOriginal)
width / height / size 成片尺寸与字节数
sha256 成片 SHA-256(Android 可用;iOS 0.3.0 返回空串并在 warnings 说明。证明文件完整性,不证明内容真实)
templateId / templateVersion 本次使用的模板,便于事后追溯
elapsed 合成耗时(毫秒)
warnings 不阻断出图的告警

5.8.1 两种内容模式(0.3.0)

插件不强制"字段驱动"一种玩法,两种模式并存、由调用方按场景选:

模式 A:插件字段(默认) —— 模板定版式,fields 给内容。适合考勤、巡检、物流这类"内容就是业务数据"的场景。字段名与模板 {xxx} 绑定一一对应。

模式 B:自定义文本 —— 显示什么完全由调用方传,插件不看模板、不看字段、不做必填校验:

const r = composeImage({
    imagePath: photoPath,
    text: '旅游实拍打卡\n日期:2026.10.09\n拍摄人:梁工\n地点:示例地点',
    theme: { panelColor: '#CCB5121B', fontSize: 36 } as WatermarkTheme,
    canvas: { safeBottom: 0, margin: 20 } as WatermarkCanvas
} as ComposeOptions)
  • text 用 \n 分行,也可以传 textLines: string[](一行一项,优先级更高)
  • 想改行序、增删行,改文本就行,不需要凑任何字段名
  • 内部实现是把文本编译成标准模板 JSON(panel + 若干 text 节点)走同一套引擎,因此与模式 A 的排版结果完全一致

外观覆盖 theme(两种模式都生效):

字段 说明
panelColor 背景色,#AARRGGBB;#00000000 = 不要底板。对所有模板生效,见下方说明
textColor / accentColor 正文与强调色
fontSize 正文字号(参考坐标,1080 宽下)
scale 整体缩放 0.8–1.5(字号、间距、内边距一起缩)
panelRadius / panelPadding / lineSpacing 圆角 / 内边距 / 行距
strokeColor / strokeWidth 文字描边(浅底上提高可读性)

panelColor 是"一键换底色"(0.4.1 起语义为「水印区域底色」),不是"只改没写 background 的面板"。它按模板形态走三条路径,保证传了就一定看得出: ① 有 panel 节点 → 强制覆盖(模板里显式写了 background 的 panel 也会被它覆盖); ② mode: band 的模板 → canvas.bandColor(色带底色)同步成它; ③ 没有 panel 节点的模板(极简、四边分区自由排版)→ 自动补一块带内边距与圆角的底板 (铺在顶层容器上,不是按包围盒并集,否则分区排版会得到一条几乎覆盖整屏的色带); ④ 未同时传 textColor 时按亮度自动选深/浅文字(浅底配深字并去掉描边), 避免出现"换成白底后水印看不见"。 想让色带与面板不同色,再显式传 canvas.bandColor(它优先于自动同步值)。

位置覆盖 canvas:anchor(bottomLeft 默认 / bottomCenter / topRight…)、margin(离边留白)、safeBottom / safeTop(安全区;想更贴底就传更小值)、bandColor / bandHeight(底部色带,仅 mode: band 模板有)。

5.8.2 成片存进相册(0.4.1)

composeImage 只负责把文件写到应用目录,要进系统相册得再调一次系统 API:

uni.saveImageToPhotosAlbum({
  filePath: result.path,   // output=base64 时 path 为空 —— 没有文件就存不进相册
  success: () => {},
  fail: (e) => {
    // 1101005 = 没有相册写入权限,引导用户去系统设置授权,别静默吞掉
  }
})

兼容性:Android 3.9+ / iOS 4.11+ / HarmonyOS 4.61+(uni-app x 官方 API)。

5.9 composeImageAsync / renderOverlayAsync 与实时叠加层

同步 vs 异步:四端矩阵

接口 Android iOS HarmonyOS 微信小程序
composeImage(同步) ✅ ✅ ❌ 9100017 ❌ 9100017
composeImageAsync ✅ ✅ ✅ ✅
renderOverlay(同步) ✅ ✅ ❌ 9100017 ❌ 9100017
renderOverlayAsync ✅ ✅ ✅ ✅
previewLayout(纯排版) ✅ ✅ ✅ ✅(同步可用)

同步版在鸿蒙 / 小程序返回 9100017,不返回"看起来成功"的空结果 —— 空结果比报错更难查。业务代码统一 await 就好,不必写平台分支。

renderOverlay / renderOverlayAsync 参数

字段 类型 说明
width / height number 画布尺寸(像素),通常等于预览容器尺寸
templateJson / templateId / layout / layoutJson — 版式来源,优先级同 ComposeOptions
fields UTSJSONObject 字段表
liveTime boolean 用"渲染时刻"覆盖 timestamp。预览每秒刷新,不置 true 时间会停在进页面那一刻
text / textLines string / string[] 自定义内容模式
theme / canvas — 外观与位置覆盖

返回 { ok, code, reason, base64, dataUri, width, height, warnings },dataUri 可直接给 <image :src>。

关键:预览叠加层要按「容器高 / 1440」缩放

布局引擎的参考坐标系是 1080 × 1440,而预览容器是任意尺寸。 叠加层必须按容器尺寸放大,否则水印的视觉占比会随取景框比例变化 —— 典型症状是"水印跑到画面中间"或"取景框里字特别大,拍出来特别小"。

const overlayUri = ref<string>('')
const overlayH = ref<number>(0)      // 预览容器实测高度(px)

async function refreshOverlay() : Promise<void> {
    const scale = overlayH.value / 1440.0     // ★ 唯一的缩放系数
    const r = await renderOverlayAsync({
        width: 1080,
        height: overlayH.value,
        templateId: 'tpl-general',
        fields: buildFields(),
        theme: { panelColor: bgColor.value, scale: scale } as WatermarkTheme,
        canvas: { safeBottom: 0, margin: 14 } as WatermarkCanvas,
        liveTime: true
    } as OverlayOptions)

    if (r.ok) {
        overlayUri.value = r.dataUri
    } else if (r.code == 9100004) {
        // 缺字段:缺 geo.* 就补一次定位(只补一次),缺业务字段提示用户填
    }
}
<view class="preview">
    <!-- #ifdef APP-HARMONY -->
    <wm-camera style="width:100%;height:100%" @take="onTake" />
    <!-- #endif -->
    <!-- #ifndef APP-HARMONY -->
    <camera style="width:100%;height:100%" :device-position="'back'" />
    <!-- #endif -->
    <image v-if="overlayUri.length > 0" :src="overlayUri" class="overlay" />
</view>
.preview { position: relative; }   /* ★ 绝对定位的父容器必须 relative */
.overlay { position: absolute; left: 0; top: 0; width: 100%; height: 100%; }

两条纪律:

  1. 只放大叠加层,不改模板。模板本身不动,成片按照片真实尺寸排版,引擎自己算缩放。
  2. 成片路径绝不能再乘一次 scale。合成时照片已经是 4000×3000,再乘 0.27 会让水印小到看不见。

重渲染要防抖:输入法连续敲字时每字符编一张 PNG 会明显卡。 示例页的做法是把 60ms 内的多次请求合并成一次,且只在 ok 时替换 dataUri(失败时保留上一帧,避免闪白)。

5.10 相机接入:三条链路

平台 用什么 拍照怎么拿图
Android / iOS / 微信小程序 内置 <camera> 组件 uni.createCameraContext().takePhoto(...) → res.tempImagePath
HarmonyOS 插件自带的 wm-camera 组件 ref.$callMethod('takePhoto') → @take 事件回传 JSON 字符串

为什么鸿蒙要多一个组件:官方 camera 组件的兼容性表里 HarmonyOS 是 ×。 wm-camera 是 native-view + XComponent(SURFACE) + CameraKit 的实现,插件已经封好, 页面里按条件编译分流即可 —— 两条链路的输出都是本地图片路径,后续合成完全一致。

<!-- #ifdef APP-HARMONY -->
<wm-camera ref="camRef" style="width:100%;height:100%" :device-position="'back'"
  @ready="onCameraReady" @take="onTake" @error="onCameraError" />
<!-- #endif -->
<!-- #ifndef APP-HARMONY -->
<camera style="width:100%;height:100%" :device-position="'back'" flash="auto" />
<!-- #endif -->
const camRef = ref<ComponentPublicInstance | null>(null)

function shoot() : void {
    // #ifdef APP-HARMONY
    camRef.value?.$callMethod('takePhoto')        // 结果走 @take 事件
    // #endif
    // #ifndef APP-HARMONY
    uni.createCameraContext().takePhoto({
        quality: 'high',
        success: (res) => {
            const p = res.tempImagePath
            if (p != null) { compose(p!) }
        }
    } as CameraContextTakePhotoOptions)
    // #endif
}

/** 鸿蒙:detail 是 JSON 字符串,形如 {"path":"...","width":4000,"height":3000} */
function onTake(detail : string) : void {
    const obj = JSON.parse(detail) as UTSJSONObject
    const p = obj.getString('path')
    if (p != null) { compose(p!) }
}

wm-camera 的方法(ref.$callMethod('xxx')):open() / takePhoto() / switchCamera() / setFlash(mode) / close();事件 ready / error / take / switched,detail 一律是 JSON 字符串。

两条纪律:① 相机是独占资源,不用了就 close(),否则本应用下一页和其他应用都打不开; ② 鸿蒙必须先声明 ohos.permission.CAMERA(见 3.4),否则只会收到 9100008 —— 这是鸿蒙相机"黑屏"的头号原因,不是插件 bug。

5.11 错误码

码 含义 常见触发
0 成功 —
9100001 参数无效 缺 imagePath、templateJson 与 templateId 都没给
9100002 模板 JSON 非法 少逗号、多逗号、根节点不是对象
9100003 模板版本高于引擎 模板写了 "minEngine": "0.3.0" 但当前是 0.2.0
9100004 必填字段缺失 模板 blockIfMissing / missing: "block" 里的字段没提供;reason 会列出具体键名(切模板后丢 geo.lat/geo.lng 见 8.3)
9100005 图片不可读 路径错、文件损坏、不是图片
9100006 合成失败 绘制异常或内存不足。0.4.4 起 reason 带「原图尺寸 → 目标尺寸、采样倍数、堆快照」,并会先按 100%→70%→45%→30% 重试
9100007 该平台不支持此能力 调用了当前平台没有的能力
9100008 相机权限被拒 鸿蒙 wm-camera 最常见:宿主没在 harmony-configs/entry/src/main/module.json5 声明 ohos.permission.CAMERA
9100017 该平台此能力只有异步接口 鸿蒙或微信小程序调了同步的 composeImage / renderOverlay,改用 composeImageAsync / renderOverlayAsync
9100009 定位不可用 插件不自动定位;此码由宿主自行采集失败时复用
9100010 字体加载失败 自定义字体文件缺失
9100011 输出路径不可写 目录不存在且创建失败;小程序传了用户目录以外的绝对路径
9100012 extends 指向的模板不存在 基础模板 id 拼错
9100013 字段绑定语法错误 {geo.lat\|6} 写成了 {geo.lat\|6
9100014 相机被占用 / 初始化失败 鸿蒙相机未 close() 就重开、设备无可用相机
9100015 录像中断 存储满 / 被来电打断(当前版本尚无录像链路,码位预留)
9100016 模板不存在 templateId 拼错

版式来源优先级:layout > layoutJson > templateJson > templateId。分区里每个条目支持 text(含 {a.b} 绑定与格式化)、qrcode、icon、divider;区级属性 direction / gap / align / anchor / width / background / opacity。

设计原则:能力缺失也走错误码,不静默降级。 静默降级比报错更伤集成方——他会以为是自己参数写错,排查一轮才发现是平台差异。


六、模板 DSL 完整指南

6.1 顶层结构

{
  "schema": "wm.template/1",
  "id": "tpl-my",
  "name": "我的模板",
  "version": "1.0.0",
  "minEngine": "0.2.0",
  "industry": "general",
  "author": "me",
  "extends": "tpl-general",
  "canvas": { "...": "画布配置" },
  "theme": { "...": "主题" },
  "fields": { "blockIfMissing": ["timestamp"] },
  "variants": [ { "orientation": "landscape", "root": { } } ],
  "root": { "...": "根节点" }
}
字段 必填 说明
schema ✅ 固定 "wm.template/1",不匹配直接拒绝
id ✅ 模板 id,也是 extends 引用的键
name — 展示名
version — 模板自身版本,会随成片结果返回
minEngine — 所需最低引擎版本;高于当前引擎返回 9100003
extends — 继承的基础模板 id
variants — 按画幅切换版式(见 6.6)

6.2 画布(canvas)

"canvas": {
  "refWidth": 1080,
  "refHeight": 1440,
  "anchor": "bottomLeft",
  "margin": 26,
  "safeArea": { "bottom": 24 },
  "mode": "overlay"
}
字段 说明
refWidth / refHeight 参考坐标系尺寸。所有节点里写的数字都是"参考像素"。实际成片按宽度等比缩放,因此同一模板能适配 1080p / 4K / 各种传感器比例
anchor 水印整体贴靠哪个角:topLeft / topRight / bottomLeft / bottomRight
margin 距画面边缘的留白(参考像素)
safeArea 单边额外留白,如 {"bottom": 24} 让开系统手势条
mode overlay(叠加)或 band(底部色带,配 bandHeight / bandColor)

为什么用"参考坐标系 + 等比缩放"而不是百分比:百分比在长图上会把字号"拉高",同一模板在 4:3 和 9:16 上看起来是两种设计。参考坐标系保证了字号相对画面的视觉重量恒定。

6.3 主题(theme)

"theme": {
  "textColor": "#FFFFFFFF",
  "accentColor": "#FF34C759",
  "panelColor": "#D9101418",
  "panelRadius": 10,
  "panelPadding": 22,
  "strokeColor": "#33000000",
  "strokeWidth": 2,
  "fontSize": 26,
  "lineSpacing": 1.32
}
字段 默认 说明
textColor #FFFFFFFF 正文色
accentColor #FFF0B429 强调色(标题、分割线取色时用)
panelColor #B3000000 面板底色
panelRadius 10 面板圆角
panelPadding 18 面板内边距
strokeColor / strokeWidth 空 / 0 文本描边(深色/复杂背景上保可读)
shadowColor / shadowBlur 空 / 0 文本阴影
opacity 1 整体不透明度
scale 1 整体缩放系数
fontSize 34 默认正文字号
lineSpacing 1.35 行高倍数

颜色一律 #AARRGGBB,也接受 #RRGGBB、#RGB、不带 #。写错颜色不会让整张图出不来——会回落到默认色,并在 warnings 里留痕。

6.4 节点通用属性

{
  "type": "text",
  "id": "t1",
  "width": "fill",
  "height": "auto",
  "padding": 8,
  "marginTop": 4,
  "gap": 6,
  "align": "center",
  "justify": "start",
  "position": "flow",
  "visibleIf": "geo.altitude > 0",
  "background": "#66000000",
  "radius": 8,
  "borderColor": "#33FFFFFF",
  "borderWidth": 1,
  "opacity": 0.9
}

尺寸值三种写法:

写法 含义
"120" 或 120 固定 120 参考像素
"auto" 由内容撑开(文本按文字宽度、容器按子节点)
"fill" 占满父容器剩余空间

fill 的分配规则(0.2.0 修正):先量所有非 fill 的兄弟节点,扣掉它们的尺寸与间距,剩余空间在 fill 节点之间平分。这与 CSS flex 的 flex:1 一致。row 里放 spacer + 右侧二维码,就靠这条规则才不会被挤出画布。

position: "absolute" 让节点脱离布局流,用 anchor / x / y 直接定位:

{ "type": "image", "src": "/static/logo.png", "width": "80", "height": "80",
  "position": "absolute", "anchor": "topRight", "x": 20, "y": 20 }

6.5 字段绑定语法

文本类节点用 bind 绑定字段:

{ "type": "text", "bind": "{geo.address}" }
{ "type": "text", "bind": "{geo.lat|6}°N {geo.lng|6}°E" }
{ "type": "text", "bind": "{timestamp}", "format": "yyyy-MM-dd HH:mm:ss" }
{ "type": "text", "bind": "海拔 {geo.altitude|1} 米" }

语法:{路径|格式}

部分 说明
路径 点号分隔的字段名,如 geo.lat、biz.orderNo。没有层级限制,你在 fields 里塞什么键就能绑定什么键
格式 数字 → 保留几位小数(|6 = 6 位,用于经纬度);时间 → 日期格式串(yyyy-MM-dd HH:mm:ss.SSS)
format 属性 单独指定时间格式,等价于 {timestamp\|yyyy-MM-dd HH:mm:ss}

缺失处理(节点级):

missing 行为
hide(默认) 字段没值 → 整个节点不画
placeholder 画 fallback 内容(默认 --)
block 阻断出图,返回 9100004 并列出缺哪个字段

block 是给"取证/考勤"这类场景用的:宁可拍不出,也不要拍出一张地址为空的现场照。

条件显示 visibleIf:

{ "type": "text", "bind": "{weather.temp|1}℃", "visibleIf": "weather.temp != ''" }

6.6 变体(variants)

一份模板兼顾横竖画幅:

"variants": [
  {
    "orientation": "landscape",
    "minAspect": 1.0,
    "root": { "type": "row", "...": "横版把信息放在底部一行" }
  }
],
"root": { "type": "column", "...": "默认竖版" }
字段 说明
orientation portrait / landscape,留空表示不限
minAspect / maxAspect 宽高比区间(宽 / 高)

匹配按数组顺序,第一个命中的生效;都不命中则用顶层 root。

6.7 模板继承(extends)

{
  "id": "tpl-acme-inspect",
  "extends": "tpl-inspect",
  "theme": { "accentColor": "#FF0A84FF" },
  "root": {
    "children": [
      { "id": "title", "text": "中建三局 · 现场巡检" }
    ]
  }
}

合并规则:

  • 对象:逐键递归合并
  • 数组:按元素 id 逐项合并,不是整段替换

    所以上面只写 {"id":"title","text":"..."} 就能改掉继承来的标题文字,其余子节点原样保留。

  • 基础模板不存在 → 9100012

6.8 元素参考

text / textBlock

{
  "type": "text",
  "bind": "{geo.address}",
  "size": 26,
  "weight": "bold",
  "color": "#CCFFFFFF",
  "textAlign": "left",
  "maxLines": 2,
  "ellipsis": "tail",
  "autoShrink": true,
  "minSize": 18,
  "letterSpacing": 0.5,
  "strokeColor": "#80000000",
  "strokeWidth": 2,
  "prefix": "地址:",
  "suffix": ""
}
属性 说明
maxLines 最多几行
ellipsis none / tail(末尾省略号)
autoShrink 单行放不下时自动缩字号,minSize 为下限
textAlign left / center / right

断行规则:中文按单字断,英文按词断,超长单词(URL)按字符硬切。这是水印里最常出问题的地方——不做硬切的话,一条长链接会直接撑破版式。

qrcode

{
  "type": "qrcode",
  "content": "{biz.verifyUrl}",
  "width": "104",
  "height": "104",
  "fg": "#FF000000",
  "bg": "#FFFFFFFF",
  "errorLevel": "Q",
  "missing": "hide"
}

见 第十节。

icon

{ "type": "icon", "name": "pin", "width": "28", "height": "28", "color": "#FF34C759" }
{ "type": "icon", "path": "C,0.5,0.5,0.4;L,0.5,0.2,0.5,0.8", "width": "28", "height": "28" }

见 第九节。

compass

{ "type": "compass", "bind": "{geo.heading}", "style": "dial", "width": "54", "height": "54", "color": "#CCFFFFFF" }
属性 说明
bind 方位角字段(0–360,0 = 正北)
style dial(带刻度环)/ arrow(只有箭头,适合小尺寸)

divider / spacer / image

{ "type": "divider", "color": "#4D34C759", "thickness": 1 }
{ "type": "spacer" }
{ "type": "image", "src": "/static/logo.png", "width": "72", "height": "72", "fit": "contain", "radius": 8 }

image.src 支持绝对路径与 file:// 前缀(会自动剥离)。非 :// 的封面/图片在某些场景原生接不了,建议用本地绝对路径。


七、内置模板清单

8 套开箱即用,全部可直接复制出来改。

id 名称 适用场景 必填字段
tpl-general 通用 · 时间地点 万金油,时间+地址+经纬度 timestamp、geo.lat、geo.lng
tpl-contract 工程 · 施工巡检 项目名、标段、施工方、监理 timestamp、geo.lat、geo.lng
tpl-attend 考勤 · 上班打卡 姓名、部门、打卡时间地点 timestamp、biz.name、biz.dept
tpl-sanitation 环卫 · 作业留痕 班组、路段、车辆 timestamp、geo.lat、geo.lng
tpl-logistics 物流 · 签收凭证 运单号、收件人、签收状态 timestamp、biz.orderNo
tpl-forensic 取证 · 现场记录 毫秒级时间、六位小数坐标、验真二维码 timestamp、geo.lat、geo.lng
tpl-inspect 巡检 · 图标卡片 图标行 + 罗盘 + 二维码(0.2.0 新增,演示新元素) timestamp、geo.lat、geo.lng
tpl-minimal 极简 · 右下角 只印时间与地址,不遮挡画面 timestamp

取模板 JSON:

// 通过 previewLayout 拿不到原始 JSON,建议自己维护一份;
// 或直接用 registry:
import { previewLayout } from '@/uni_modules/uts-watermark-camera'
// 调试期最省事的办法:把 tpl-inspect 的 JSON 从 presets.uts 里拷出来改

八、字段清单与业务接入

字段完全由你提供,插件不预设结构。但建议沿用下面这套命名——内置模板都按它写的,沿用即开箱可用。

8.1 内置模板使用的字段

字段 示例 说明
timestamp 1761720000000 毫秒时间戳(数字字符串)
geo.address 四川省成都市武侯区天府五街 200 号 逆地理地址
geo.lat / geo.lng 30.542103 / 104.063219 经纬度(配 \|6 精度)
geo.altitude 502.4 海拔(米)
geo.accuracy 8.4 定位精度(米)
geo.heading 128 方位角(度)
weather.desc 多云 天气现象
weather.temp 18.6 温度(℃)
weather.humidity 62 湿度(%)
weather.wind 东北风 2 级 风力
biz.projectName 天府五街管网改造二期 项目名
biz.part K1+200 至 K1+480 段 标段/部位
biz.operator 梁工 作业人
biz.team 三班组 班组
biz.vehicle 川A·12345 车牌
biz.orderNo SF1234567890123 运单号/工单号
biz.receiver 张先生 收件人
biz.verifyUrl https://v.example.com/p/8f3a… 验真链接(二维码用)
device.model / device.os PGT-AN00 / Android 15 设备指纹
file.sha256Short 9c1f4a7b2d8e0f31 文件指纹前 16 位

8.2 接入自己的业务字段

字段名随便起,模板里能绑定就行。推荐 业务域.字段 的形式,避免不同模块的键撞车:

const fields : UTSJSONObject = {}
fields.set('timestamp', `${Date.now()}`)
fields.set('crm.customerName', '成都某某科技有限公司')
fields.set('crm.contractNo', 'HT-2026-1029-017')
fields.set('crm.amount', '128000.00')
fields.set('staff.id', 'E10293')

模板里:

{ "type": "text", "bind": "客户:{crm.customerName}", "size": 26 }
{ "type": "text", "bind": "合同号:{crm.contractNo} 金额:¥{crm.amount}", "size": 24 }

8.3 定位 / 天气自己采(0.4.4 写法)

插件不采集、也不申请定位权限。采到之后塞进 fields 即可 —— 但要放持久层,不能只写当前字段表:

// 定位持久层:成功一次就存住,与"当前模板的字段表"彻底解耦
const locLat = ref<string>('')
const locLng = ref<string>('')
const locAcc = ref<string>('')

function applyLocation(lat : number, lng : number, acc : string) : void {
    locLat.value = `${lat}`
    locLng.value = `${lng}`
    locAcc.value = acc
    writePanelFields()      // 面板展示用
    refreshOverlay()        // 叠加层重渲染
}

function buildFields() : UTSJSONObject {
    const json : UTSJSONObject = {}
    json.set('timestamp', `${Date.now()}`)
    // ...模板默认值、业务字段、用户编辑值、天气...
    if (locLat.value.length > 0) { json.set('geo.lat', locLat.value) }   // ★ 最后覆盖
    if (locLng.value.length > 0) { json.set('geo.lng', locLng.value) }
    if (locAcc.value.length > 0) { json.set('geo.accuracy', locAcc.value) }
    return json
}

uni.getLocation({
    // ★ 不要传 type: 'gcj02':系统定位只保证 wgs84,传了在部分平台直接失败
    success: (res) => {
        const acc = res.accuracy == null ? '' : `${res.accuracy}`
        applyLocation(res.latitude, res.longitude, acc)
    },
    fail: (e) => {
        // 权限被拒与定位超时是两回事,不要都糊成一句"定位失败"
        const msg = e.errMsg ?? ''
        if (msg.indexOf('auth') >= 0 || msg.indexOf('deny') >= 0) {
            uni.showToast({ title: '定位权限未授权,请到系统设置开启', icon: 'none' })
        } else {
            uni.showToast({ title: '定位超时,到空旷处再试', icon: 'none' })
        }
    }
})

三条约定:

  1. 先采后合。顺序反了会得到"图上坐标是上一次的"这种很难复现的问题。
  2. 切模板时字段表会整体重建,定位只写进当前表就会丢 —— 症状是"明明定位过,却报 9100004 缺 geo.lat、geo.lng,再切回去又好"。所以定位单独存一份。
  3. 叠加层报 9100004 且 reason 含 geo. 时自愈一次(补一次定位再重渲染)。 只试一次 —— 权限被拒的用户不该每切一次模板就被弹一次框。

geo.address 需要逆地理编码(地图 key + 联网),插件不做。只给经纬度而模板绑定 {geo.address}, 水印上依旧是空/占位 —— 这就是"坐标有了、地址是 --"的唯一原因。


九、图标库

9.1 内置图标(33 个)

pin  location  navigate  compass  altitude  accuracy          # 位置与地理
clock  calendar                                               # 时间
sun  cloud  rain  snow  wind  thermometer  droplet  aqi        # 天气
user  camera  phone  signal  battery  wifi                     # 人员与设备
truck  doc  check  check-circle  cross  warn  flash  search  shield  star   # 业务与状态

用法:

{ "type": "icon", "name": "clock", "width": "24", "height": "24", "color": "#AAFFFFFF" }

程序里取清单:

const names = listIcons()   // string[]

9.2 自定义图标(图元串)

内置图标不够用时,不用改插件,直接在模板里写图元:

{
  "type": "icon",
  "path": "C,0.5,0.5,0.42;O,0.12,0.54,0.4,0.82,0.88,0.2",
  "width": "32", "height": "32", "color": "#FFFFFFFF"
}

坐标一律 0–1 归一化,绘制端按目标尺寸等比缩放并居中。七种图元:

图元 语法 含义
C C,cx,cy,r 描边圆
F F,cx,cy,r 实心圆
L L,x1,y1,x2,y2 直线
R R,x,y,w,h 描边矩形
B B,x,y,w,h 实心矩形
O O,x1,y1,x2,y2,... 折线(不闭合)
P P,x1,y1,x2,y2,... 多边形(自动闭合、实心)

多个图元用 ; 分隔。描边宽度由绘制端按边长 7.5% 自动计算,保证 24px 的小图标与 200px 的大图标看起来是同一套设计语言。

换成 SVG path 会怎样?Android 没有原生 SVG 解析、iOS 要自己实现 CoreGraphics path 解析、鸿蒙 Drawing 又不认 SVG 语法,同一份 path 三端会画出三种粗细。七种图元任何一端 60 行画完,且结果严格一致。


十、二维码

10.1 为什么内置编码器

  • 引第三方库:三端各带一份原生依赖,包体积 +2~4MB,而水印二维码永远是短内容
  • 编码必须确定:同样输入必须得到逐位相同的矩阵,否则两台设备出的图哈希不同,验真时会互相矛盾

10.2 实现范围

项 范围
模式 Byte(UTF-8),不实现数字/字母数字/汉字模式
版本 1–10(21×21 ~ 57×57 模块)
纠错 L / M / Q / H,全支持
掩码 8 种全试,按标准 4 条罚分规则取最优
ECI 不写。绝大多数扫码器按 UTF-8 解析,写了反而有兼容风险

10.3 容量上限(Byte 模式,UTF-8 字节数)

纠错等级 最大字节数 建议场景
L 271 内容尽量短、码面尽量小
M 213 均衡
Q(默认) 151 打印/翻拍/脏污场景,推荐
H 119 极小尺寸、强反光、局部遮挡

中文一个字在 UTF-8 里是 3 字节。所以 Q 级下大约 50 个汉字封顶。二维码里只放短链(https://v.example.com/p/8f3a2c1d),不要放长参数串。

10.4 尺寸与可扫性

模板里给 qrcode 的 width/height 是参考像素,实际按缩放系数放大。绘制端会:

  1. 取 min(w,h) 作为码面边长
  2. 预留 1 个模块宽的静区
  3. 算出整数模块尺寸(floor),整体居中

为什么要整数模块:模块落在半像素上,相邻模块会被抗锯齿连成一片。手机端能扫出去,但一压缩、一放大立刻失效。宁可周边多留一点白,也不要半像素。

最小尺寸经验值(实际像素):

模块数 建议最小边 说明
21(v1) 84px 100px 更稳
29(v3) 116px 常见的短链就是这个量级
37(v5) 148px 含中文内容
57(v10) 228px 长内容,不建议

若把码贴在复杂背景(照片主体)上,请在模板里给 qrcode 外层加一个浅色 panel,或用 padding 撑出静区。

10.5 验真链路实践

// 1. 先上传原片,拿到服务端返回的照片 id
const photoId = await upload(originalPath)

// 2. 用 photoId + 签名拼出验真链接
const verifyUrl = `https://v.example.com/p/${photoId}?sig=${sign(photoId)}`

// 3. 把链接塞进字段,再合成(模板里 qrcode 绑定 biz.verifyUrl)
fields.set('biz.verifyUrl', verifyUrl)
composeImage({ imagePath: originalPath, templateId: 'tpl-forensic', fields })

// 4. 把 sha256 一并落库,用于后续"文件是否被替换"的核对

注意边界:sha256 证明的是文件完整性(上传后没被改),不证明图像内容真实(拍的时候是真的)。后者需要设备密钥签名 + 可信时间戳,属 0.5.0 计划。


十一、常见场景配方

11.1 拍照即打水印(当前最完整链路)

// 1. 用 uni.chooseImage / 相机组件拿到原片路径
// 2. 采集定位与业务字段(见 8.3)
// 3. composeImage 出图
// 4. 上传成片 + 落库 sha256 / 坐标 / 时间

11.2 历史照片批量加水印

function batch(paths : string[]) : void {
    let i = 0
    const step = () => {                        // ⚠️ 必须写成 function 声明,
        if (i >= paths.length) {                 //    const 箭头函数自引用在 UTS 里编译不过
            return
        }
        const p = paths[i]
        i++
        const r = composeImage({
            imagePath: p,
            templateId: 'tpl-general',
            fields: fields,
            outputDir: outDir,
            fileName: `wm_${i}.jpg`,
            quality: 0.9
        })
        if (!r.ok) {
            console.warn(`第 ${i} 张失败:${r.code} ${r.reason}`)
        }
        step()
    }
    step()
}

批量时建议串行。并行合成多张大图会同时持有多个 Bitmap,很容易在中低端机上 OOM。

11.3 一图两用(带水印展示 + 原片留档)

composeImage({
    imagePath: p,
    templateId: 'tpl-sanitation',
    fields: fields,
    keepOriginal: true      // 额外输出无水印副本
})
// r.path 用于展示/上传,r.originalPath 用于内部留档

11.4 同一套业务,多个客户不同版式

// 客户 A
registerTemplate('tpl-a', jsonA)
composeImage({ imagePath: p, templateId: 'tpl-a', fields })

// 客户 B:继承 A,只改配色与标题
registerTemplate('tpl-b', jsonB)   // { "extends": "tpl-a", "theme": {...}, "root": {...} }
composeImage({ imagePath: p, templateId: 'tpl-b', fields })

模板可以从服务端下发,换版式不需要发版。

11.5 防"回头补拍"

在拍照前先校验必填字段齐不齐:

const missing = previewLayout({ imagePath: '', templateId: 'tpl-forensic', fields })
if (!missing.ok && missing.code == 9100004) {
    uni.showToast({ title: `定位未就绪:${missing.reason}`, icon: 'none' })
    return   // 不让他拍
}

十二、性能、包体与内存

12.1 包体积

  • 纯 UTS 实现(微信小程序平台层为 JS),无第三方原生依赖、不联网
  • 无内置字体文件(用比例字宽表,见 5.6)
  • 源码规模:core 约 8400 行,四端绘制层合计约 6700 行(含鸿蒙 .ets 与小程序 index.js)
  • 编译后增量以百 KB 计;四个平台目录里只有 mp-weixin 会被打进小程序包,App 端不会带上它

12.2 合成耗时

一次 composeImage 的开销分布:

阶段 说明
解码原图 主要开销,与像素数线性相关(一张 4000×3000 约 12MP)
排版 几十个节点,通常 1–3ms,可忽略
二维码编码 短内容 < 2ms
绘制 二维码合并成单个 Path 一次 drawPath(v10 有 1500+ 深色模块,逐个 drawRect 在低端机会掉到 20fps 以下)
编码 JPEG 与像素数和质量相关

尚未在真机上做量化基准,上表是各环节的相对权重,不是实测毫秒数。接入后请用返回的 elapsed 字段自行采样。

12.3 内存

  • 一次合成最多同时持有两张全尺寸位图(解码源 + 目标),按 ARGB_8888 的 4 字节/像素算就是 8 字节/像素
  • 4000×3000(12MP)就是约 96MB —— 中低端机堆上限 192~256MB,这就是"必现 OOM"的来源
  • 请显式传 maxWidth / maxHeight(示例页取长边 3000):不传也能跑,但成片尺寸由插件内部预算决定,不可预期

0.4.4 的位图链路:一次装载,最多两张

步骤 做法
1. 量尺寸 inJustDecodeBounds 只读宽高,不解码像素
2. 算最终尺寸 EXIF 宽高互换、maxWidth/maxHeight、内存预算三重夹取,只缩不放
3. 一次解码 inSampleSize + inMutable,源图进来就已经是"够用且安全"的尺寸
4. 一次落笔 「转正 + 缩放」合成一个复合矩阵,画进 createBitmap 新建的目标位图
5. 立即回收 目标画完立刻 recycle() 源图
  • 目标位图由 createBitmap 新建,天生可编辑,因此彻底删掉了"再建一张可编辑副本"的兜底 —— 那条分支正是 OOM 与 recycled bitmap 的共同源头
  • 快速路径:不用转正、不用缩放、源图本来就可编辑 → 一张都不多建
  • 预算 = 堆字节数 / 32 像素(两张正好吃掉 1/4 堆),夹在 150 万~1200 万像素;超预算会写 warnings,不再静默缩图
  • 仍然失败时按 100%→70%→45%→30% 重试,reason 里带尺寸、采样倍数与堆快照, 例如 绘制原图失败(4000×3000 → 2250×3000):java.lang.OutOfMemoryError(堆上限256MB·已用198MB·可用58MB)

批量处理务必串行(见 11.2):并行合成 N 张就是 N 组位图,多高的预算都救不回来。


十三、跨端差异与已知限制

能力 Android iOS HarmonyOS 微信小程序
模板解析 / 布局 / 中文断行 ✅ ✅ ✅ ✅
previewLayout 指令输出 ✅ ✅ ✅ ✅
二维码编码 ✅ ✅ ✅ ✅
图标图元 / 罗盘排版 ✅ ✅ ✅ ✅
composeImage 出图(同步) ✅ ✅(描边/圆角渐变/圆角图片降级,warnings 会说明) ❌ 返回 9100017(见下) ❌ 返回 9100017(见下)
composeImageAsync 出图(异步) ✅ ✅ ✅(0.4.0) ✅(0.4.3)
renderOverlay 实时叠加(同步) ✅ ✅ ❌ 返回 9100017 ❌ 返回 9100017
renderOverlayAsync 实时叠加(异步) ✅ ✅ ✅(0.4.0) ✅(0.4.3)
相机拍照成片 ✅(内置 <camera>) ✅(内置 <camera>) ✅(插件自带 wm-camera 组件,0.4.0) ✅(内置 <camera>)
录像 / 逐帧水印 ⏳ ⏳ 计划不支持 计划不支持
按预览区域裁剪 ⏳ ⏳ 计划不支持 计划不支持
模拟定位检测 ⏳ — — —
设备签名 ⏳ ⏳ ⏳ ⏳

小程序端为什么只有异步:Canvas 2D 从 createImage 到 canvasToTempFilePath 全是回调, 拿不到同步位图。同步接口返回 9100017 而不是"假装成功返回空结果"。 调用方统一 await composeImageAsync(...) 即可四端一致。

其他已知限制

  1. collect 未实现:传了会得到告警,不会静默忽略。
  2. keepExif 未实现完整透传:只做方向归正,拍摄参数(光圈/ISO/厂商)会丢。
  3. map / signature 元素能排版、不绘制:绘制端跳过并告警(见 2.1)。
  4. qrcode 只支持 Byte 模式:纯数字/纯大写字母的短内容会比最优实现多用一个版本(可扫,只是码面略大)。
  5. 掩码选择与部分参考实现可能不同:罚分规则实现细节有差异,但同内容在同设备上恒定位相同(这是验真所需的关键性质)。
  6. iOS 无 sha256:CryptoKit 桥接层取不到裸字节,该字段返回空串(Android 与微信小程序正常)。 iOS 还有三个绘制降级:文字描边、圆角渐变面板、圆角图片元素 —— 都写进 warnings,不静默。
  7. iOS 文本字体:CATextLayer 方案使用 Helvetica + 系统中文回落,与 Android 系统默认字体存在轻微度量差异; 要求像素级一致时用 setGlyphCalibration 注入同一份实测表。
  8. 鸿蒙出图缺 sha256(返回空串);keepOriginal / deleteSource / collect 中尚未实现的项会写 warnings,不静默。
  9. 鸿蒙相机是独占资源:不用了就 close(),否则本应用下一页、以及其他应用都打不开相机。 释放顺序是 stop → release(outputs) → input.close,顺序错了下次打不开。页面 onHide 建议再关一次。
  10. 微信小程序三条边界(0.4.3):① 单边像素上限 4096,超出等比缩小并写 warnings; ② 可写目录只有 wx.env.USER_DATA_PATH,outputDir 传绝对路径会被映射到用户目录下同名子目录; ③ EXIF 的 *-mirrored 方向不自动纠正(镜像会连水印文字一起翻,比不纠正更糟)。
  11. 小程序 type-shims.js 需要跟着类型走:core 里 {...} as 类型 经 uts2js 转译会变成 new 类型(...), 而这些类型只是 interface.uts 的 export type,运行时不存在。utssdk/mp-weixin/type-shims.js 负责补构造器; 往 interface.uts 新增导出类型时要同步加进它的名字清单,否则小程序端会在冷门分支上抛 ReferenceError (症状:出图正常,一开模板库就白屏)。
  12. 验证状态:Android 真机主链路可用;iOS 的 Swift 编译与真机、微信小程序真机待验证; 鸿蒙的 ArkTS 类型检查与真机需在 DevEco / 设备上做(本机无 DevEco)。

十四、排错手册

14.1 症状 → 原因

症状 最可能的原因 怎么确认
改了代码/模板毫无变化 没重打自定义基座 看启动日志版本号,对不上就是没进包
控制台没有 [uts-watermark-camera] 那行 插件没被编译进去 检查 uni_modules 目录名与 package.json 的 id 是否一致
9100016 templateId 拼错 listTemplates() 看合法 id
9100002 模板 JSON 语法错 错误信息里带行列号,直接定位
9100003 模板 minEngine 高于引擎 降 minEngine,或升级插件
9100004 必填字段缺失 reason 里列了缺哪些键
9100012 extends 的 id 找不到 先 registerTemplate 基础模板,或用内置 id
二维码没出现 内容为空 / 内容超长 看 warnings,会明确写"生成失败:内容过长…"
二维码在画面外 版式问题(fill 挤占) 用 previewLayout 看指令坐标,0.2.0 已修 fill 分配
图标没出现 name 写错且没给 path 看 warnings,"未知图标「xxx」"
文字版式与设计稿差几个像素 字宽表是比例近似 见 5.6,可注入校准表
图片拍出来是躺的 原图 EXIF 方向未归正 本插件会自动归正;若仍躺,检查输入是否已被别处处理过
合成报 9100006,reason 带尺寸与堆快照 机型内存不足,或没传 maxWidth 先传 maxWidth/maxHeight: 3000 重试;若"可用"很小而"已用"接近上限,往别处查内存泄漏
切模板后报 9100004 缺 geo.lat/geo.lng 定位只写进当前字段表,换模板时被整体重建 见 8.3:定位单独存,拼 fields 时最后统一覆盖
取景框里水印位置/大小与成片对不上 叠加层漏了 scale = 容器高 / 1440,或成片又乘了一次 见 5.9;只放大叠加层,成片不乘
小程序:出图正常,一开模板库就白屏 type-shims.js 缺构造器 控制台会写 Xxx is not defined,把类型名加进清单(见 13 节第 11 条)
鸿蒙:相机黑屏 / 9100008 宿主没声明 ohos.permission.CAMERA 查 harmony-configs/entry/src/main/module.json5
鸿蒙:第二次打不开相机 上次没 close(),或释放顺序错 stop → release(outputs) → input.close(见 13 节第 9 条)

14.2 排查顺序(省时间)

  1. 先看启动日志:版本号不对 → 重打基座(小程序重新编译到开发者工具),后面都别查
  2. 再跑 previewLayout:不碰像素,能立刻区分"排版错了"还是"绘制错了"
  3. 看 warnings:插件不会静默跳过任何东西,跳过必有告警
  4. 看 usedFields:字段没绑上是键名写错,这比猜快得多
  5. 最后才怀疑平台差异:先查 getCapabilities()

十五、版本策略与升级

15.1 版本与能力对应

版本 内容 状态
0.1.0 引擎内核、7 套模板、Android 出图(文字/面板/分割线/图片) 已发布
0.1.1 合规修正(能力矩阵如实、导出面修正、启动日志) 已发布
0.2.0 二维码编码器、33 个内置图标、罗盘、fill 主轴分配修复、模板 8 套、setGlyphCalibration / listIcons 导出 已发布
0.3.0 四边分区自由排版;输出配置化(file/base64/both、jpeg/png、尺寸压缩);base64 输入;renderOverlay 实时叠加层;自定义内容模式(text/textLines);外观与位置覆盖;iOS 出图 已发布
0.4.0 鸿蒙补齐:wm-camera 原生相机(XComponent + CameraKit)、鸿蒙出图(composeImageAsync / renderOverlayAsync) 已发布
0.4.1 theme.panelColor 语义升级为「水印区域底色」;大图合成不再直接失败;示例页保存到相册 已发布
0.4.2 修复 recycled bitmap 崩溃与横画幅变体不吃背景色;背景色自检日志 已发布
0.4.3 微信小程序端:离屏 Canvas 2D 绘制、异步契约、type-shims.js 已发布
0.4.4 Android 合成 OOM 根治:位图一次装载(最多两张)、预算改 堆/32 像素、错误文案带堆快照;定位字段持久化 当前
0.5.0 视频录制实时叠加、完整 EXIF 透传、静态地图元素、可信能力(授时 / 模拟定位检测 / 设备签名 / 验真) 计划

15.2 模板的向前/向后兼容

  • 模板加 minEngine,老客户端会明确拿到 9100003,而不是画出一张缺元素的图
  • 新版本引擎保证能渲染旧模板(schema 不变的前提下)
  • 需要"老客户端降级版式"时,用 variants + 较低的 minEngine 提供一份兜底 root

15.3 升级步骤

  1. 替换 uni_modules/uts-watermark-camera 目录
  2. 重新制作自定义基座
  3. 冷启动,确认控制台版本号已更新
  4. 跑一遍你们自己的模板(建议保留一份回归用例:用 previewLayout 断言指令数量与关键坐标)

附:完整模板示例(带二维码 + 图标 + 罗盘)

{
  "schema": "wm.template/1",
  "id": "tpl-demo",
  "name": "演示 · 全要素",
  "version": "1.0.0",
  "minEngine": "0.2.0",
  "canvas": {
    "refWidth": 1080,
    "refHeight": 1440,
    "anchor": "bottomLeft",
    "margin": 26,
    "safeArea": { "bottom": 24 }
  },
  "theme": {
    "textColor": "#FFFFFFFF",
    "accentColor": "#FF34C759",
    "panelColor": "#D9101418",
    "panelRadius": 10,
    "panelPadding": 22,
    "fontSize": 26,
    "lineSpacing": 1.32
  },
  "fields": { "blockIfMissing": ["timestamp"] },
  "root": {
    "type": "panel",
    "id": "card",
    "width": "fill",
    "gap": 10,
    "children": [
      {
        "type": "row",
        "width": "fill",
        "align": "center",
        "gap": 10,
        "children": [
          { "type": "icon", "name": "pin", "width": "30", "height": "30", "color": "#FF34C759" },
          { "type": "text", "text": "现场记录", "size": 30, "weight": "bold" },
          { "type": "spacer" },
          { "type": "compass", "bind": "{geo.heading}", "style": "dial", "width": "54", "height": "54", "color": "#CCFFFFFF" }
        ]
      },
      { "type": "divider", "color": "#4D34C759", "thickness": 1 },
      {
        "type": "row",
        "width": "fill",
        "gap": 12,
        "children": [
          {
            "type": "column",
            "width": "fill",
            "gap": 6,
            "children": [
              { "type": "text", "bind": "{timestamp}", "format": "yyyy-MM-dd HH:mm:ss", "size": 26, "missing": "block" },
              { "type": "text", "bind": "{geo.address}", "size": 24, "color": "#AAFFFFFF", "maxLines": 2, "ellipsis": "tail" },
              { "type": "text", "bind": "海拔 {geo.altitude|1} 米 ±{geo.accuracy|0} 米", "size": 22, "color": "#BBFFFFFF" }
            ]
          },
          { "type": "qrcode", "content": "{biz.verifyUrl}", "width": "104", "height": "104", "errorLevel": "Q" }
        ]
      }
    ]
  }
}

文档版本:0.2.0 / 最后更新:2026-10-29

隐私、权限声明

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

无

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

无

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

无

暂无用户评论。