当前位置:首页>鸿蒙APP>鸿蒙水印相机实战:uni-app + UTS + CameraKit 完整落地与踩坑总结

鸿蒙水印相机实战:uni-app + UTS + CameraKit 完整落地与踩坑总结

  • 2026-10-10 17:50:56
鸿蒙水印相机实战:uni-app + UTS + CameraKit 完整落地与踩坑总结

基于 uni-app + UTS 插件 + HarmonyOS 系统能力 实现的水印相机完整落地记录。
前置背景:项目原本在 Android / iOS 端通过 App 内原生插件实现水印相机(时间/位置/用户名/公司名/Logo),而鸿蒙生态没有对应现成插件,因此通过 uni-app 的 UTS 插件机制,直接用 ArkTS 调用 HarmonyOS 原生能力(CameraKit、ArkUI Overlay、定位、相册等)实现功能对齐。


一、总体架构

整个功能分三层:

层
载体
职责
JS 业务层
pages/camera/water-mark-camera.vue
 + util/native_plug_util.js
组装水印数据、按平台分派调用、接收回传路径
UTS 桥接层
uni_modules/rf-waterMarkCamera/utssdk/interface.uts
、app-harmony/index.uts
定义方法签名与传参类型,把 JS 调用转发给 ArkTS 实现
原生实现层
app-harmony/camera-overlay.ets
、camera-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(面访签到回传路径 / 首页进入展示预览并可保存相册)

二、JS 侧:平台分派与参数组装

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 会话初始化要点:

  1. 1. 权限运行时申请:ohos.permission.CAMERA 属 user_grant,仅 manifest 声明不够,必须 requestPermissionsFromUser。
  2. 2. 创建会话:createCameraInput(device).open() → 创建 preview/photo 输出 → createSession(SceneMode.NORMAL_PHOTO) → beginConfig/addInput/addOutput/commitConfig/start。
  3. 3. 分辨率选择:不能直接用 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 }
}
  1. 4. 连续自动对焦/曝光:部分设备 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 写共享缓存
  }
}

有两个"兜底"层次:

  • • 若定位权限被拒/超时,则把 JS 传入的 经纬度 xx,xx 占位字符串拿出来,用 getAddressesFromLocation 做一次系统逆地理编码。
  • • getAddressesFromLocation 是鸿蒙系统自带离线/在线逆编码,不依赖第三方地图服务,也就没有三方可用的 key 配置问题,保证了外勤场景下地址始终可出。

水印时间则是浮层内每秒刷新的"拍摄瞬间"(setInterval 1s 更新 currentDatecurrentTime),拍照回调里直接取用,保证水印时间与预览一致、精确到秒。


五、原生侧核心三:拍照 → 水印合成 → 保存

1. 拍照与取数据

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)
      }
    })
})

2. 水印合成(camera-utils.ets)

合成流水线刻意做成"一次解码、一次离屏渲染、一次编码":

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)
}

关键取舍(每一条都是踩坑换来的):

  • • 不读 EXIF:image.getImageProperty 解析 EXIF 是 IMAGE 设备上的主线程阻塞点,低端机直接触发 THREAD_BLOCK_6S 崩溃。拍照时已写入旋转方向,但部分设备 photoAvailable 返回的仍是传感器横屏数据,因此用"宽>高即旋转 270°"的宽高比规则兜底判断方向,彻底绕开 EXIF 解析。
  • • 单次离屏渲染:旋转与水印最初是两次整图重采样(开销翻倍),改成先 translate+rotate 后再 drawImage 一次完成,像素搬运量减半。
  • • 解码限长边 1920:成片 1920(竖拍约 276 万像素),比 2560 快约 44%,同时保留足够锐度;水印字号按画布短边等比计算,图片缩放后水印观感比例不变。
  • • 文字投影代替背景条:白色文字 + 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,不影响水印绘制。

3. 保存相册(免权限声明方案)

"首页进入 → 拍照 → 预览 → 保存相册"这条路径没有申请 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() 再初始化
成片发虚
PhotoSession 未合焦
显式 setFocusMode(CONTINUOUS_AUTO) + setExposureMode(CONTINUOUS_AUTO)
预览比例变形
拿到 720p/横屏 profile
按屏幕比例差排序 + 容忍度内选像素最大者
主线程卡死崩溃
EXIF 解析 / 两次整图重采样
宽高比判向 + 单次 OffscreenCanvas 渲染 + 限长边 1920
定位地址漂移
uni.getLocation
 鸿蒙走网络定位
原生 geoLocationManager ACCURACY+NAVIGATION 高精度定位 + 系统逆地理编码
关闭后相机被占用
浮层摘除回调不可靠
removeOverlay
 前主动 releaseCameraAction() 强释放
保存相册要权限弹窗
传统方式需申请 WRITE_IMAGEVIDEO
改用 SaveButton 安全控件,免权限声明

八、小结

这套实现的本质,是把 Android/iOS 端"原生插件封装相机"的思路,在鸿蒙上用 UTS 插件 + CameraKit + OverlayManager 重写了一遍:

  • • JS 侧只面向 uni.waterMarkCamera 一个 API,页面层无需关心平台差异(平台分派收敛在 native_plug_util.js 的 #ifdef APP-HARMONY 里);
  • • 原生侧把"相机 + 水印 + 定位 + 保存"全部收敛在一个插件内,性能瓶颈(主线程合成、EXIF 解析、分辨率选择)都在 ArkTS 层内解决;
  • • 与面访签到等业务共享 last_location 地址缓存,水印地址在任何一个相机里定位过,其他相机打开即可复用,保证水印信息全项目一致。

提示:水印相机页面 pages/camera/water-mark-camera.vue 为业务入口;房勘相机 uni_modules/rf-fk-camera/ 复用了同一套定位缓存与 UTS 思路,可作为多相机场景的参考实现。

最新文章

随机文章