uni-app实时拍摄水印实现:Camera+Canvas多端兼容方案详解

1. 项目概述:为什么要在uni-app里做带水印的实时拍摄?

最近在做一个社区内容审核的后台工具,运营的同事提了个需求:用户上传的图片和视频,需要能追溯到具体的审核人员和操作时间,防止后续扯皮。最直接的办法,就是在拍摄的源头就把这些信息“烙”上去,也就是实时拍摄并添加水印。

这个需求听起来简单,但真要在多端(尤其是微信小程序和App)上实现流畅的实时拍摄加水印,坑还真不少。用原生开发,iOS和Android得写两套,小程序还得另起炉灶,维护成本太高。所以,我第一时间就想到了uni-app。它“一套代码,多端发行”的特性,正好能覆盖H5、小程序和App这几个主要场景。

但uni-app的<camera>组件和uni.chooseImage/uni.chooseVideo这些API,都是对原生能力的封装,它们本身并不提供实时叠加水印的功能。你拍完了,拿到的是一个临时文件路径,再想加水印,那就是另一个“后期处理”的步骤了,无法做到“所见即所得”。因此,核心思路就变成了:放弃原生相机UI,自己用<camera>组件绘制一个自定义的拍摄界面,然后在相机预览画面上,用<canvas>实时叠加水印层

这个方案的优势很明显:水印在预览时就能看到,与最终成片完全一致,无法被轻易剥离。而且,通过Canvas绘制,水印的样式(文字、图片、位置、透明度、旋转角度)可以做到极其灵活。下面,我就把从零搭建这个功能的核心技术点、实操步骤以及我踩过的那些坑,详细拆解一遍。

2. 核心方案设计与技术选型

2.1 为何选择Camera + Canvas的混合方案?

市面上给媒体文件加水印,无非三种路径:

  1. 服务端处理:客户端上传原始文件,由服务器调用FFmpeg等工具添加水印。优点是不耗客户端性能,但无法实时预览,且增加了网络传输和服务器压力。
  2. 客户端后期处理:用uni-app的uni.getImageInfouni.createVideoContext拿到文件后,再用Canvas绘制水印并合成新文件。这能实现,但步骤割裂,用户体验是“拍”和“加水印”两步。
  3. 客户端实时叠加:也就是本方案。在相机预览的同时,将一个半透明的Canvas层覆盖在上面,实时绘制水印。用户按下快门时,实际上是将相机画面和Canvas水印一起捕获下来。

为什么实时叠加方案更优?对于需要“取证”、“责任到人”的场景,水印必须是拍摄行为不可分割的一部分。后期添加的水印,理论上存在被拦截或篡改的可能(虽然uni-app打包后代码不易被改,但逻辑上不严谨)。实时叠加从体验和逻辑上都更闭环,用户看到的就是最终效果。

2.2 uni-app相关API与组件剖析

实现这个方案,主要依赖uni-app的以下几个核心能力:

  • <camera>组件:这是基础。我们需要将其设为全屏或自定义大小,并设置好设备朝向(device-position)和闪光灯模式(flash)。关键属性是frame-size,它决定了相机输出的图像尺寸,会直接影响后续Canvas绘制的清晰度。
  • <canvas>组件:水印的载体。这里有个巨坑:uni-app中的Canvas有新旧两套API,<canvas>组件对应的是旧版Canvas 2D API(通过canvasId标识和uni.createCanvasContext创建绘图上下文)。而功能更强大的新版Canvas API(uni.createOffscreenCanvas<canvas type="2d">)在不同平台的支持度不一,尤其在iOS端和小程序端容易出问题。为了最大的兼容性,本项目选择稳定但稍显繁琐的旧版Canvas API
  • uni.createCameraContext():这是控制相机的核心对象。通过它,我们可以调用takePhoto方法进行拍照,调用startRecordstopRecord进行录像。这些方法返回的都是临时文件路径,是我们处理数据的起点。
  • uni.getSystemInfoSync():获取屏幕信息至关重要。因为我们需要让Canvas层精准覆盖在Camera组件之上,这需要知道屏幕的宽度、高度、像素比(pixelRatio),以及导航栏、状态栏的高度(用于计算安全区域)。

2.3 页面布局与层级结构

布局的原理是“层叠”。想象一下,我们有一个舞台:

  1. 最底层:<camera>组件,充满整个屏幕,负责显示实时画面。
  2. 中间层:<canvas>组件,通过绝对定位(position: absolute)覆盖在<camera>之上,大小和位置必须与<camera>完全一致,否则水印会错位。这个Canvas的背景必须是完全透明的(background: transparent)。
  3. 最上层:操作UI层。包括拍照按钮、录像按钮、切换摄像头、闪光灯等控件。它们也需要绝对定位,悬浮在最上面。
<template> <view class="camera-wrapper"> <!-- 第一层:相机预览 --> <camera class="camera" device-position="back" flash="off" frame-size="medium" @error="onCameraError" ></camera> <!-- 第二层:水印画布 --> <canvas class="watermark-canvas" canvas-id="watermarkCanvas" :style="{ width: canvasWidth + 'px', height: canvasHeight + 'px' }" ></canvas> <!-- 第三层:操作控件 --> <view class="controls"> <button @tap="switchCamera">切换镜头</button> <button @tap="takePhoto">拍照</button> <button @tap="startRecord" v-if="!isRecording">开始录像</button> <button @tap="stopRecord" v-else>停止录像</button> </view> </view> </template>

这里的难点在于如何让Canvas和Camera严丝合缝。你不能简单地把宽高都设为100%,因为相机可能有固定的宽高比(如4:3),而屏幕可能是更长的比例(如19.5:9)。直接100%会导致Canvas被拉伸,绘制的水印变形。正确的做法是:通过uni.getSystemInfoSync()获取屏幕宽高,并参考相机frame-size的设置,动态计算出一个与相机预览区域匹配的Canvas尺寸。

3. 实时水印绘制的核心实现

3.1 初始化Canvas与计算布局

在页面的onReady生命周期里,我们需要进行初始化。因为此时视图层已渲染,可以获取到元素的准确信息。

<script> export default { data() { return { canvasWidth: 0, canvasHeight: 0, ctx: null, // Canvas绘图上下文 systemInfo: null, cameraContext: null, isRecording: false }; }, onReady() { this.initCameraAndCanvas(); }, methods: { async initCameraAndCanvas() { // 1. 获取系统信息 this.systemInfo = uni.getSystemInfoSync(); const windowWidth = this.systemInfo.windowWidth; const screenHeight = this.systemInfo.screenHeight; // 2. 计算相机预览区域尺寸(这里是个关键假设) // 通常,相机预览会保持自身宽高比,并在屏幕上居中显示。 // 为简化,我们假设相机预览充满安全区域。更精确的做法可能需要通过<camera>的bindstop事件获取实际宽高。 const cameraAspectRatio = 4 / 3; // 假设相机输出为4:3 let previewWidth = windowWidth; let previewHeight = windowWidth / cameraAspectRatio; // 如果计算出的高度超过屏幕可用高度,则按高度适配 if (previewHeight > screenHeight) { previewHeight = screenHeight; previewWidth = screenHeight * cameraAspectRatio; } this.canvasWidth = previewWidth; this.canvasHeight = previewHeight; // 3. 创建Canvas上下文 this.ctx = uni.createCanvasContext('watermarkCanvas', this); // 第二个参数传入当前组件实例,在自定义组件中必须 // 4. 创建相机上下文 this.cameraContext = uni.createCameraContext(); // 5. 开始绘制水印(可以是一个持续绘制的循环) this.drawWatermark(); }, } } </script>

注意:上述计算是一个简化模型。在实际项目中,尤其是Android设备上,相机预览的宽高比和屏幕比例可能非常复杂。更稳健的做法是,在<camera>组件的@stop事件中,通过uni.createSelectorQuery()获取相机节点的实际宽高,以此作为Canvas的尺寸基准。这是保证水印位置精准的关键一步,很多开发者在这里栽了跟头。

3.2 动态水印内容的设计与绘制

水印内容通常包括:用户名、时间、地点(需获取定位权限)、公司Logo等。绘制水印不是画一次就完事了,因为时间是动态变化的,我们需要一个循环来不断重绘。

methods: { drawWatermark() { if (!this.ctx) return; // 1. 清空上一帧画布 this.ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight); // 2. 设置绘制样式 this.ctx.setFontSize(14); this.ctx.setFillStyle('rgba(255, 255, 255, 0.7)'); // 半透明白色文字 this.ctx.setTextAlign('center'); // 3. 组织水印文本 const now = new Date(); const timeStr = `${now.getFullYear()}-${now.getMonth()+1}-${now.getDate()} ${now.getHours()}:${now.getMinutes()}:${now.getSeconds()}`; const userInfo = uni.getStorageSync('userInfo') || {}; const watermarkText = `审核员:${userInfo.name || '未知'} | 时间:${timeStr}`; // 4. 绘制单条水印(例如在画面底部居中) this.ctx.fillText(watermarkText, this.canvasWidth / 2, this.canvasHeight - 20); // 5. 绘制平铺水印(作为背景防伪) this.ctx.setFontSize(20); this.ctx.setFillStyle('rgba(255, 255, 255, 0.15)'); this.ctx.rotate(-30 * Math.PI / 180); // 旋转-30度 for (let i = -2; i < 5; i++) { for (let j = -2; j < 5; j++) { this.ctx.fillText('内部资料 严禁外传', i * 150, j * 80); } } this.ctx.rotate(30 * Math.PI / 180); // 旋转回来,避免影响后续绘制 // 6. 执行绘制 this.ctx.draw(true, () => { // 绘制完成后,递归调用,实现动态更新(时间) setTimeout(() => { this.drawWatermark(); }, 1000); // 每秒更新一次时间 }); } }

绘制技巧与避坑指南:

  1. draw()方法的回调:旧版Canvas API的ctx.draw()是异步的。第二个参数是一个回调函数,必须在回调里进行下一次绘制,否则会出现绘制堆积、性能低下甚至空白的问题。这是新手最容易忽略的地方。
  2. 性能优化:全屏平铺、旋转的水印对性能有影响。如果发现滚动或操作卡顿,可以考虑:
    • 降低水印更新的频率(比如时间每5秒更新一次)。
    • 使用离屏Canvas预先绘制好静态的水印图案(但uni-app旧版Canvas对离屏支持有限,需谨慎)。
    • 简化水印样式,减少循环绘制的次数。
  3. 坐标恢复:使用了rotatetranslate等变换后,务必在绘制完当前元素后,将变换状态恢复回来,否则后续所有绘制都会基于被变换的坐标系,导致错乱。上面的代码在绘制平铺水印后,立即做了反向旋转。

3.3 拍照与录像的集成

当用户点击拍照或录像时,我们调用相机上下文的方法。关键点在于,相机输出的画面,已经包含了我们Canvas绘制的水印层,因为Canvas是覆盖在上面的。所以不需要额外的合成步骤。

methods: { takePhoto() { if (!this.cameraContext) return; this.cameraContext.takePhoto({ quality: 'high', success: (res) => { // res.tempImagePath 就是带水印的图片临时路径 console.log('拍照成功,临时路径:', res.tempImagePath); uni.previewImage({ urls: [res.tempImagePath] }); // 这里可以上传到服务器:uni.uploadFile(...) }, fail: (err) => { console.error('拍照失败:', err); uni.showToast({ title: '拍照失败', icon: 'none' }); } }); }, startRecord() { if (!this.cameraContext) return; this.isRecording = true; this.cameraContext.startRecord({ success: () => { console.log('开始录像'); uni.showToast({ title: '录像中...', icon: 'none' }); }, fail: (err) => { console.error('开始录像失败:', err); this.isRecording = false; } }); }, stopRecord() { if (!this.cameraContext) return; this.cameraContext.stopRecord({ success: (res) => { this.isRecording = false; // res.tempVideoPath 就是带水印的视频临时路径 console.log('录像结束,临时路径:', res.tempVideoPath); // 可以预览或上传 const videoContext = uni.createVideoContext('myVideo'); // 需要一个<video>组件 videoContext.src = res.tempVideoPath; videoContext.play(); }, fail: (err) => { console.error('停止录像失败:', err); this.isRecording = false; } }); } }

重要提示takePhotostopRecord返回的都是临时文件路径。这些文件在本次小程序/App会话期间有效,但一旦应用关闭就可能被清理。务必及时处理,要么立即预览后让用户决定是否保存到相册(uni.saveImageToPhotosAlbum),要么直接上传到你的服务器进行永久存储。

4. 多端兼容性处理与深度优化

4.1 平台差异与条件编译

uni-app虽然统一了API,但不同平台底层实现仍有差异,必须用条件编译(#ifdef#endif)来处理。

  • 获取相机权限:在App端,需要在manifest.jsonApp模块配置中勾选Camera(相机)权限,并在首次使用时用uni.authorize请求。在小程序端,则需在app.jsonpermission字段中声明,并调用wx.authorize
  • Canvas尺寸单位:在Vue页面中,Canvas的宽高通常用px。但在小程序中,Canvas有默认的宽度300px、高度225px,且其坐标系与CSS像素有关。有时需要显式设置widthheight属性,并用px单位。
  • 录像参数startRecord的参数在不同平台支持度不同。例如,指定录像最大时长、码率等,需要查阅各平台文档。
// 示例:请求相机权限 async requestCameraAuth() { try { // #ifdef APP-PLUS const status = await uni.authorize({ scope: 'scope.camera' }); // #endif // #ifdef MP-WEIXIN const status = await wx.authorize({ scope: 'scope.camera' }); // #endif if (status.authSetting['scope.camera']) { console.log('已授权相机'); } } catch (err) { console.error('授权失败:', err); uni.showModal({ title: '提示', content: '需要相机权限才能使用拍照功能', showCancel: false }); } }

4.2 性能优化实战

在低端机上,同时运行相机预览和Canvas动画可能导致发热、卡顿。以下是我总结的优化点:

  1. 降低绘制频率:如之前所述,将时间水印的更新频率从1秒一次降低到5秒或10秒一次。对于静态Logo水印,只在初始化时绘制一次,不需要每帧重绘。
  2. 使用image代替复杂fillText:如果水印包含复杂图形或字体,可以预先将水印生成一张透明的PNG图片,然后在Canvas中使用ctx.drawImage绘制。这比绘制多行文字和形状性能更好。
  3. 避免在draw回调中执行重逻辑ctx.draw的回调函数里只安排下一次绘制,不要进行数据计算、网络请求等操作。
  4. 合理设置frame-size<camera>frame-size属性有lowmediumhigh可选。不是越高越好,high模式在部分老旧设备上可能无法启动或帧率极低。通常medium在清晰度和性能间取得较好平衡。
  5. 页面生命周期管理:在页面onHideonUnload时,务必停止相机预览和录像,并清除绘制定时器,释放资源。
onUnload() { // 停止录像(如果正在录) if (this.isRecording && this.cameraContext) { this.cameraContext.stopRecord(); } // 清除绘制循环 if (this.drawTimer) { clearTimeout(this.drawTimer); this.drawTimer = null; } // 释放相机上下文(虽然不是必须,但是个好习惯) this.cameraContext = null; this.ctx = null; }

4.3 水印安全性与防篡改思考

Canvas绘制的前端水印并非绝对安全,有一定技术能力的用户可以通过浏览器开发者工具或反编译手段移除Canvas层。但在uni-app打包成App后,逆向难度增大。为了增强防篡改性,可以考虑:

  1. 信息隐藏:将部分水印信息(如用户ID哈希值)以极低的透明度(例如0.05)或微小的像素点变化(数字水印)方式嵌入画面,肉眼难以察觉,但可以通过算法提取。
  2. 后端校验:前端上传带水印的文件后,后端可以再次对文件进行水印检测,验证水印信息是否完整、是否与当前操作者信息匹配。这需要前后端约定好水印的格式和加密方式。
  3. 关键信息由后端生成:例如,水印中的时间戳、流水号等,可以由后端接口在拍摄前下发一个“拍摄凭证”,前端将这个凭证绘制到水印中。这样即使前端被篡改,伪造的凭证也无法通过后端验证。

5. 常见问题排查与解决方案实录

在实际开发中,我遇到了不少问题,这里列几个典型的:

问题一:水印位置错位,在iOS和Android上表现不一致。

  • 排查:根本原因是Canvas层和Camera层的尺寸或位置计算不准确。没有考虑状态栏、导航栏、tabBar等占用的空间。
  • 解决
    1. 使用uni.getSystemInfoSync()获取windowWidthwindowHeight,这是可用窗口区域
    2. 使用uni.createSelectorQuery().select(‘.camera’).boundingClientRect()异步获取相机组件在屏幕上的实际位置和尺寸。
    3. 将Canvas的样式设置为与查询结果完全一致。
    // 在onReady或相机@stop事件中 const query = uni.createSelectorQuery().in(this); query.select('.camera').boundingClientRect(data => { if (data) { this.canvasWidth = data.width; this.canvasHeight = data.height; // 同时设置Canvas的left和top,确保对齐 this.canvasStyle = `width:${data.width}px;height:${data.height}px;left:${data.left}px;top:${data.top}px;`; this.$nextTick(() => { this.drawWatermark(); // 尺寸确定后再绘制 }); } }).exec();

问题二:拍照/录像后,水印消失了。

  • 排查takePhotostopRecord生成的是纯相机画面。我们实时看到的水印是Canvas覆盖层,并未真正合成到相机输出流中。这是一个认知错误。实际上,我们的方案中,水印是“看”得见但“拍”不到的。这回到了方案本质:我们依赖的是覆盖层,而覆盖层不会被原生相机API捕获。
  • 解决必须进行后期合成。当拿到临时文件路径后,需要再用一个Canvas,将临时文件(图片或视频帧)绘制出来,然后把水印绘制上去,最后导出新的文件。
    • 对于图片:使用uni.getImageInfo获取图片信息,然后在新的Canvas中drawImage,再绘制水印,最后用uni.canvasToTempFilePath导出。
    • 对于视频:处理起来非常复杂。需要在用户停止录像后,使用视频编辑插件(如uni.requireNativePlugin调用原生插件)或FFmpeg(可通过云函数或自己的服务器)进行视频与水印的合成。实时视频加水印,在纯前端实现高性能合成是不现实的。对于要求高的场景,更可行的方案是:录制时不带水印,录制完成后立即调用后端服务进行合成处理。

问题三:在部分Android机型上,Canvas绘制的水印非常模糊。

  • 排查:这是Canvas的widthheight属性与CSS样式的widthheight设置混淆导致的。Canvas有自身的像素画布大小(由属性决定)和CSS渲染大小。如果画布很小但被CSS拉伸得很大,就会模糊。
  • 解决
    1. 在WXML/Vue模板中,用style绑定计算出的宽高(单位px),同时也用widthheight属性绑定同样的值(旧版Canvas组件支持属性)。
    2. 或者,更推荐的方法是,使用uni.createCanvasContext时,传入的canvasId对应的Canvas组件,其样式宽高和属性宽高设置为相同的像素值。并且,在绘制文字时,根据设备的pixelRatio进行缩放。
    const dpr = this.systemInfo.pixelRatio; this.ctx.scale(dpr, dpr); // 缩放坐标系 this.ctx.setFontSize(14 * dpr); // 字体大小也要相应放大 // 此时,Canvas的画布大小是 CSS大小 * dpr,绘制更清晰。

问题四:小程序真机调试时,Canvas绘制的内容偶尔不显示。

  • 排查:小程序中Canvas的绘制是异步的,且与视图层通信有延迟。在组件初始化或数据变化后立即调用draw,可能因为组件未渲染完成而失败。
  • 解决:将初始绘制的代码放在$nextTicksetTimeout中,确保组件渲染完毕。同时,检查Canvas的canvas-id是否唯一,在自定义组件中是否使用了正确的选择器(uni.createCanvasContext的第二个参数)。

经过这一系列的拆解、实现、优化和排错,一个基于uni-app的、支持实时预览水印的拍照录像功能就基本成型了。它虽然无法实现“拍摄即合成”的终极效果,但通过“实时预览+后期合成”的组合拳,在用户体验和功能实现上取得了很好的平衡。最重要的是,这套代码经过良好的封装和条件编译,可以同时跑在H5、微信小程序和App上,极大地提升了开发效率。