更新记录
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。
目录
- 它解决什么问题
- 功能总览
- 安装与接入
- 五分钟上手
- API 参考
- 模板 DSL 完整指南
- 内置模板清单
- 字段清单与业务接入
- 图标库
- 二维码
- 常见场景配方
- 性能、包体与内存
- 跨端差异与已知限制
- 排错手册
- 版本策略与升级
一、它解决什么问题
水印相机看起来很简单:拍张照,把时间和地点印上去。真正做起来有四个反复被低估的成本:
| 成本 | 具体表现 | 本插件的处理方式 |
|---|---|---|
| 版式改一次要发一次版 | 客户要换配色、加一行工单号、把二维码挪到右上角,都需要改代码、打包、过审 | 版式是 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%。若你对"各端像素级一致"有硬要求,可以:
- 在设备上用真实字体跑一遍
measureText - 算出系数表
- 在各端注入同一份数据
⚠️ 只在一端注入、另一端不注入,反而会造成更大的不一致。不注入时各端都用内置表,这个组合是自洽的。
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%; }
两条纪律:
- 只放大叠加层,不改模板。模板本身不动,成片按照片真实尺寸排版,引擎自己算缩放。
- 成片路径绝不能再乘一次
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' })
}
}
})
三条约定:
- 先采后合。顺序反了会得到"图上坐标是上一次的"这种很难复现的问题。
- 切模板时字段表会整体重建,定位只写进当前表就会丢 ——
症状是"明明定位过,却报
9100004缺geo.lat、geo.lng,再切回去又好"。所以定位单独存一份。 - 叠加层报
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 是参考像素,实际按缩放系数放大。绘制端会:
- 取
min(w,h)作为码面边长 - 预留 1 个模块宽的静区
- 算出整数模块尺寸(
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(...)即可四端一致。
其他已知限制
collect未实现:传了会得到告警,不会静默忽略。keepExif未实现完整透传:只做方向归正,拍摄参数(光圈/ISO/厂商)会丢。map/signature元素能排版、不绘制:绘制端跳过并告警(见 2.1)。qrcode只支持 Byte 模式:纯数字/纯大写字母的短内容会比最优实现多用一个版本(可扫,只是码面略大)。- 掩码选择与部分参考实现可能不同:罚分规则实现细节有差异,但同内容在同设备上恒定位相同(这是验真所需的关键性质)。
- iOS 无
sha256:CryptoKit 桥接层取不到裸字节,该字段返回空串(Android 与微信小程序正常)。 iOS 还有三个绘制降级:文字描边、圆角渐变面板、圆角图片元素 —— 都写进warnings,不静默。 - iOS 文本字体:CATextLayer 方案使用 Helvetica + 系统中文回落,与 Android 系统默认字体存在轻微度量差异;
要求像素级一致时用
setGlyphCalibration注入同一份实测表。 - 鸿蒙出图缺
sha256(返回空串);keepOriginal/deleteSource/collect中尚未实现的项会写warnings,不静默。 - 鸿蒙相机是独占资源:不用了就
close(),否则本应用下一页、以及其他应用都打不开相机。 释放顺序是stop → release(outputs) → input.close,顺序错了下次打不开。页面onHide建议再关一次。 - 微信小程序三条边界(0.4.3):① 单边像素上限 4096,超出等比缩小并写
warnings; ② 可写目录只有wx.env.USER_DATA_PATH,outputDir传绝对路径会被映射到用户目录下同名子目录; ③ EXIF 的*-mirrored方向不自动纠正(镜像会连水印文字一起翻,比不纠正更糟)。 - 小程序
type-shims.js需要跟着类型走:core里{...} as 类型经uts2js转译会变成new 类型(...), 而这些类型只是interface.uts的export type,运行时不存在。utssdk/mp-weixin/type-shims.js负责补构造器; 往interface.uts新增导出类型时要同步加进它的名字清单,否则小程序端会在冷门分支上抛ReferenceError(症状:出图正常,一开模板库就白屏)。 - 验证状态: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 排查顺序(省时间)
- 先看启动日志:版本号不对 → 重打基座(小程序重新编译到开发者工具),后面都别查
- 再跑
previewLayout:不碰像素,能立刻区分"排版错了"还是"绘制错了" - 看
warnings:插件不会静默跳过任何东西,跳过必有告警 - 看
usedFields:字段没绑上是键名写错,这比猜快得多 - 最后才怀疑平台差异:先查
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 升级步骤
- 替换
uni_modules/uts-watermark-camera目录 - 重新制作自定义基座
- 冷启动,确认控制台版本号已更新
- 跑一遍你们自己的模板(建议保留一份回归用例:用
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

收藏人数:
购买源码授权版(
试用
使用 HBuilderX 导入示例项目
赞赏(0)
下载 3
赞赏 0
下载 12739081
赞赏 1959
赞赏
京公网安备:11010802035340号