基于
uni-app + UTS 插件 + HarmonyOS 系统能力实现的水印相机完整落地记录。
前置背景:项目原本在 Android / iOS 端通过 App 内原生插件实现水印相机(时间/位置/用户名/公司名/Logo),而鸿蒙生态没有对应现成插件,因此通过 uni-app 的 UTS 插件机制,直接用 ArkTS 调用 HarmonyOS 原生能力(CameraKit、ArkUI Overlay、定位、相册等)实现功能对齐。
整个功能分三层:
pages/camera/water-mark-camera.vueutil/native_plug_util.js | ||
uni_modules/rf-waterMarkCamera/utssdk/interface.utsapp-harmony/index.uts | ||
app-harmony/camera-overlay.etscamera-utils.ets |
UTS 插件目录结构:
uni_modules/rf-waterMarkCamera/
├── utssdk/
│ ├── interface.uts # 接口与类型定义(JS 侧可见)
│ └── app-harmony/
│ ├── index.uts # HarmonyOS 实现入口:openWaterMarkCamera 等
│ ├── camera-overlay.ets # ArkTS 原生:窗口浮层 + CameraKit 预览 + 快门/翻转
│ └── camera-utils.ets # ArkTS 原生:水印合成(解码/旋转/绘制/编码)调用链:
页面 JS
→ native_plug_util.js(按平台 #ifdef 分派)
→ uni.waterMarkCamera({ isTakePic, watermark, logoUrl, ..., success, locateSuccess })
→ interface.uts / index.uts
→ camera-overlay.ets openWaterMarkCamera(...) // 挂载窗口级浮层
→ 用户按快门 → photoAvailable 回调 → camera-utils.ets createWatermarkedPhoto()
→ 返回 tempImagePath(面访签到回传路径 / 首页进入展示预览并可保存相册)waterMarkCamera 是 UTS 插件暴露给全局 uni 的方法,所以 JS 侧只需要在鸿蒙分支里组装参数再调用。地址信息并不完全依赖 JS 传入——鸿蒙侧打开浮层后会再做一次高精度定位覆盖位置,见第五章。
// util/native_plug_util.js(节选)
functionwaterMarkCamera(isTakePic, callback) {
// #ifdef APP-HARMONY
const dateTime = formatDateTime() // "2026-07-28 14:30:00"
const location = getLocationText() // 共享缓存 last_location
const userName = getEmpNameText() // "部门.姓名"
uni.waterMarkCamera({
isTakePic: isTakePic,
watermark: {
dateTime: dateTime,
location: location,
userName: userName,
companyName: getCompanyName()
},
logoUrl: getLogoUrl(), // 登录接口返回的 Logo URL
displayName: getDisplayName(),
success: (res) => {
if (res && res.tempImagePath) callback(res.tempImagePath)
},
locateSuccess: (addr) => {
// 鸿蒙定位成功后回传地址,写共享缓存,全项目相机共用
uni.setStorageSync('last_location', addr)
}
})
// #endif
}UTS 接口层用 declare module 声明扩展 uni,同时把参数类型写清楚,JS 侧传参即获得类型提示:
// interface.uts(节选)
exportinterfaceUni {
waterMarkCamera(options: WaterMarkCameraOptions): void;
closeWaterMarkCamera(): void;
}
exporttypeWaterMarkCameraOptions = {
/** 是否需要回调图片路径(面访签到 true;首页进入 false 走存相册) */
isTakePic: boolean;
/** 水印信息 */
watermark: WaterMarkInfo;
/** 品牌 logo URL */
logoUrl?: string;
/** 用户显示名称 */
displayName?: string;
success?: WaterMarkCameraSuccessCallback | null;
fail?: WaterMarkCameraFailCallback | null;
complete?: WaterMarkCameraCompleteCallback | null;
/** 定位成功回调,地址回写共享缓存 last_location */
locateSuccess?: ((addr: string) =>void) | null;
};HarmonyOS 单窗口架构不允许"再开一页"承载相机,因此采用 OverlayManager 窗口级浮层:把相机组件挂到当前页面最上层,覆盖整屏,关闭时摘除。
// camera-overlay.ets
functionopenWaterMarkCamera(dateTime, location, userName, companyName,
logoUrl, displayName, isTakePic, callback, locateCb) {
// 1. 模块级变量承载参数(绕开 @Builder 无参限制)
currentWatermark = { dateTime, location, userName, companyName }
currentLogoUrl = logoUrl
currentIsTakePic = isTakePic
currentResultCallback = callback
// 2. 拿到当前窗口的 UIContext,创建 ComponentContent 挂到 overlay
const ctx = getContext() as common.UIAbilityContext
window.getLastWindow(ctx).then((win) => {
const uiContext = win.getUIContext()
const overlayManager = uiContext.getOverlayManager()
const cc = newComponentContent(uiContext, wrapBuilder(cameraBuilder))
contentRef = cc
overlayManager.addComponentContent(cc) // 覆盖在当前页面之上
})
}相机预览组件内部结构:最底层是 XComponent(SURFACE) 承载 CameraKit 实时画面,上面是水印浮层与操作按钮(同样的 Stack 叠加):
build() {
Stack() {
// 底层:SURFACE 预览
XComponent({ type: XComponentType.SURFACE, controller: this.controller })
.width('100%').height('100%')
.onLoad(() => {
// 关键:surfaceId 由系统动态生成,必须在 onLoad 回调里获取
this.surfaceId = this.controller.getXComponentSurfaceId()
this.initCamera()
})
// 顶层:水印卡片(时间/日期/位置/姓名/Logo),字体与成片保持一致
Column() { /* ... currentTime / currentDate / 📍location / 👤displayName ... */ }
.position({ x: 16, y: 40 })
.hitTestBehavior(HitTestMode.None) // 允许点击穿透,不挡快门
// 底部控制条:关闭 / 快门 / 翻转
// 拍照后的预览层:SaveButton 安全控件 + 返回重拍
// loading 层:capturing 时显示"水印合成中..."
}
}CameraKit 会话初始化要点:
ohos.permission.CAMERA 属 user_grant,仅 manifest 声明不够,必须 requestPermissionsFromUser。createCameraInput(device).open() → 创建 preview/photo 输出 → createSession(SceneMode.NORMAL_PHOTO) → beginConfig/addInput/addOutput/commitConfig/start。cameraProfiles[0],否则可能拿到 720p 或横屏比例导致预览变形。算法是"先按比例差排序,再到容忍度内挑像素面积最大":const screenRatio = this.screenHeight / this.screenWidth// 竖屏
for (const p of profiles) {
const ratio = p.size.width / p.size.height// 传感器横屏方向
candidates.push({ profile: p, diff: Math.abs(ratio - screenRatio) })
}
candidates.sort((a, b) => a.diff - b.diff)
const minDiff = candidates[0].diff
// 在 minDiff + 0.15 容忍范围内选分辨率最大者
for (const c of candidates) {
if (c.diff > minDiff + 0.15) break
if (pixels > bestPixels) { best = c.profile }
}PhotoSession 不显式设置时停留在未合焦状态,成片整体发虚。打开浮层时统一设置并监听对焦状态:session.setFocusMode(camera.FocusMode.FOCUS_MODE_CONTINUOUS_AUTO)
session.setFocusPoint({ x: 0.5, y: 0.5 })
session.setZoomRatio(1.0)
session.setExposureMode(camera.ExposureMode.EXPOSURE_MODE_CONTINUOUS_AUTO)
session.on('focusStateChange', this.focusStateCallback)uni-app 的 uni.getLocation 在鸿蒙端走系统网络定位,同一地点每次返回的坐标漂移大,拿到的地址不可信。因此浮层打开后由原生主动做一次高精度 GPS 定位(ACCURACY + NAVIGATION)+ 系统逆地理编码,用结果覆盖水印地址:
privateasynclocateAccurateAddress() {
const granted = awaitthis.requestLocationPermission() // APPROXIMATELY_LOCATION + LOCATION
if (!granted) { this.resolveSystemAddress(); return }
constrequest: geoLocationManager.CurrentLocationRequest = {
priority: geoLocationManager.LocationRequestPriority.ACCURACY, // GPS 优先
scenario: geoLocationManager.LocationRequestScenario.NAVIGATION,
timeoutMs: 8000
}
const loc = await geoLocationManager.getCurrentLocation(request)
const addresses = await geoLocationManager.getAddressesFromLocation({
latitude: loc.latitude, longitude: loc.longitude, maxItems: 1
})
if (addresses.length > 0) {
const a = addresses[0]
let addr = a.placeName
if (!addr) {
addr = [a.countryName, a.administrativeArea, a.locality,
a.subLocality, a.roadName, a.subRoadName]
.filter(p => p).join('')
}
currentWatermark.location = addr
this.locationText = addr
currentLocateCallback?.(addr) // 回传 JS 写共享缓存
}
}有两个"兜底"层次:
经纬度 xx,xx 占位字符串拿出来,用 getAddressesFromLocation 做一次系统逆地理编码。getAddressesFromLocation 是鸿蒙系统自带离线/在线逆编码,不依赖第三方地图服务,也就没有三方可用的 key 配置问题,保证了外勤场景下地址始终可出。水印时间则是浮层内每秒刷新的"拍摄瞬间"(setInterval 1s 更新 currentDatecurrentTime),拍照回调里直接取用,保证水印时间与预览一致、精确到秒。
takePhoto() 设置 quality: QUALITY_LEVEL_HIGH(MEDIUM 会显著丢细节)与旋转方向(后置 ROTATION_90、前置 ROTATION_270,写入 EXIF),调用 photoOutput.capture();在 photoAvailable 回调里通过 photo.main.getComponent(JPEG) 拿到原始 JPEG ArrayBuffer:
imageObj.getComponent(image.ComponentType.JPEG, (err, component) => {
constjpegData: ArrayBuffer = component.byteBuffer
createWatermarkedPhoto(jpegData, wm, ctx, timestamp, currentLogoUrl)
.then((path) => {
if (currentIsTakePic) {
currentResultCallback(path) // 面访签到:回传路径
removeOverlay()
} else {
pendingGalleryPath = path // 首页进入:展示预览层等用户保存
this.previewPath = path
this.loadPreviewImage(path)
}
})
})合成流水线刻意做成"一次解码、一次离屏渲染、一次编码":
exportasyncfunctioncreateWatermarkedPhoto(jpegData, watermark, context, timestamp, logoUrl) {
// 1. 解码 + 限长边(可编辑 PixelMap)
const srcPixelMap = awaitjpegToPixelMap(jpegData)
// 2. 按宽高比判断旋转(不解析 EXIF)
const rotate = resolveRotateAngleBySize(srcPixelMap)
// 3. 旋转 + 水印合并为单次 OffscreenCanvas 渲染
const wm = awaitrenderWatermarked(srcPixelMap, watermark, rotate, logoUrl)
// 4. JPEG 编码写入缓存目录
returnawaitsavePixelMapToFile(wm, context, timestamp)
}关键取舍(每一条都是踩坑换来的):
image.getImageProperty 解析 EXIF 是 IMAGE 设备上的主线程阻塞点,低端机直接触发 THREAD_BLOCK_6S 崩溃。拍照时已写入旋转方向,但部分设备 photoAvailable 返回的仍是传感器横屏数据,因此用"宽>高即旋转 270°"的宽高比规则兜底判断方向,彻底绕开 EXIF 解析。translate+rotate 后再 drawImage 一次完成,像素搬运量减半。shadowColor/shadowBlur 投影,任何背景上均可读,视觉上也更干净。水印排版与预览浮层完全一致(时间大号粗斜、日期次行、📍位置、👤姓名、右上角 Logo),长地址用 measureText 逐字符累积测量做自动换行:
functionwrapText(ctx, text, maxWidth) {
const lines = []
let line = ''
for (let i = 0; i < text.length; i++) {
const ch = text.charAt(i)
const candidate = line + ch
if (line.length > 0 && ctx.measureText(candidate).width > maxWidth) {
lines.push(line); line = ch
} else { line = candidate }
}
if (line.length > 0) lines.push(line)
return lines
}Logo 支持网络 URL → http 请求下载为 ArrayBuffer 再解码,本地路径直接 createImageSource(path);下载失败返回 null,不影响水印绘制。
"首页进入 → 拍照 → 预览 → 保存相册"这条路径没有申请 WRITE_IMAGEVIDEO 权限,用的是 HarmonyOS 安全控件 SaveButton:点击后获得临时授权,回调 SUCCESS 后再调用特权接口 photoAccessHelper.createAsset 写入相册。注意 createAsset 必须在用户点击 SaveButton 授权后的极短时间窗口内调用,因此"先建资源、后写数据":
// SaveButton 的 onClick 中:
if (result === SaveButtonOnClickResult.SUCCESS) {
this.saveToGallery(pendingGalleryPath) // 立即建资源 + 读缓存文件写入
}
privateasyncsaveToGallery(cachePath: string) {
const helper = photoAccessHelper.getPhotoAccessHelper(ctx)
const uri = await helper.createAsset(photoAccessHelper.PhotoType.IMAGE, 'jpg')
const file = fileIo.openSync(cachePath, fileIo.OpenMode.READ_ONLY)
/* 读缓存文件 buffer → fileIo.openSync(uri).writeSync */
}相机是独占性硬件资源,释放顺序错了会留下监听残留(overlay 移除后仍在打印对焦日志)或相机被占用。释放顺序:
privatereleaseSession() {
// 1. 先移除事件监听
this.photoSession?.off('focusStateChange', this.focusStateCallback)
this.photoOutput?.off('photoAvailable', this.photoAvailableCallback)
// 2. 会话 stop → release,然后输出、输入依次释放
this.photoSession?.stop(); this.photoSession?.release()
this.photoOutput?.release()
this.previewOutput?.release()
this.cameraInput?.close()
}removeOverlay() 摘除 ComponentContent 之前,先调用模块级 releaseCameraAction 强制释放相机会话——因为浮层摘除时 aboutToDisappear 不一定可靠触发,主动释放才能避免"相机被占用"。
另外:
releaseSession → 找目标位置设备 → 重新 startSession,全程 capturing 置位防止快门连点。PixelMap 用完后 release(),避免内存暴涨(Web 端没有这个习惯,ArkTS 必须有)。surfaceId | XComponent.onLoad 里 getXComponentSurfaceId() 再初始化 | |
setFocusMode(CONTINUOUS_AUTO) + setExposureMode(CONTINUOUS_AUTO) | ||
uni.getLocation | geoLocationManager ACCURACY+NAVIGATION 高精度定位 + 系统逆地理编码 | |
removeOverlayreleaseCameraAction() 强释放 | ||
SaveButton 安全控件,免权限声明 |
这套实现的本质,是把 Android/iOS 端"原生插件封装相机"的思路,在鸿蒙上用 UTS 插件 + CameraKit + OverlayManager 重写了一遍:
uni.waterMarkCamera 一个 API,页面层无需关心平台差异(平台分派收敛在 native_plug_util.js 的 #ifdef APP-HARMONY 里);last_location 地址缓存,水印地址在任何一个相机里定位过,其他相机打开即可复用,保证水印信息全项目一致。提示:水印相机页面
pages/camera/water-mark-camera.vue为业务入口;房勘相机uni_modules/rf-fk-camera/复用了同一套定位缓存与 UTS 思路,可作为多相机场景的参考实现。