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的混合方案市面上给媒体文件加水印无非三种路径服务端处理客户端上传原始文件由服务器调用FFmpeg等工具添加水印。优点是不耗客户端性能但无法实时预览且增加了网络传输和服务器压力。客户端后期处理用uni-app的uni.getImageInfo或uni.createVideoContext拿到文件后再用Canvas绘制水印并合成新文件。这能实现但步骤割裂用户体验是“拍”和“加水印”两步。客户端实时叠加也就是本方案。在相机预览的同时将一个半透明的Canvas层覆盖在上面实时绘制水印。用户按下快门时实际上是将相机画面和Canvas水印一起捕获下来。为什么实时叠加方案更优对于需要“取证”、“责任到人”的场景水印必须是拍摄行为不可分割的一部分。后期添加的水印理论上存在被拦截或篡改的可能虽然uni-app打包后代码不易被改但逻辑上不严谨。实时叠加从体验和逻辑上都更闭环用户看到的就是最终效果。2.2 uni-app相关API与组件剖析实现这个方案主要依赖uni-app的以下几个核心能力camera组件这是基础。我们需要将其设为全屏或自定义大小并设置好设备朝向device-position和闪光灯模式flash。关键属性是frame-size它决定了相机输出的图像尺寸会直接影响后续Canvas绘制的清晰度。canvas组件水印的载体。这里有个巨坑uni-app中的Canvas有新旧两套APIcanvas组件对应的是旧版Canvas 2D API通过canvasId标识和uni.createCanvasContext创建绘图上下文。而功能更强大的新版Canvas APIuni.createOffscreenCanvas或canvas type2d在不同平台的支持度不一尤其在iOS端和小程序端容易出问题。为了最大的兼容性本项目选择稳定但稍显繁琐的旧版Canvas API。uni.createCameraContext()这是控制相机的核心对象。通过它我们可以调用takePhoto方法进行拍照调用startRecord和stopRecord进行录像。这些方法返回的都是临时文件路径是我们处理数据的起点。uni.getSystemInfoSync()获取屏幕信息至关重要。因为我们需要让Canvas层精准覆盖在Camera组件之上这需要知道屏幕的宽度、高度、像素比pixelRatio以及导航栏、状态栏的高度用于计算安全区域。2.3 页面布局与层级结构布局的原理是“层叠”。想象一下我们有一个舞台最底层camera组件充满整个屏幕负责显示实时画面。中间层canvas组件通过绝对定位position: absolute覆盖在camera之上大小和位置必须与camera完全一致否则水印会错位。这个Canvas的背景必须是完全透明的background: transparent。最上层操作UI层。包括拍照按钮、录像按钮、切换摄像头、闪光灯等控件。它们也需要绝对定位悬浮在最上面。template view classcamera-wrapper !-- 第一层相机预览 -- camera classcamera device-positionback flashoff frame-sizemedium erroronCameraError /camera !-- 第二层水印画布 -- canvas classwatermark-canvas canvas-idwatermarkCanvas :style{ width: canvasWidth px, height: canvasHeight px } /canvas !-- 第三层操作控件 -- view classcontrols button tapswitchCamera切换镜头/button button taptakePhoto拍照/button button tapstartRecord v-if!isRecording开始录像/button button tapstopRecord 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); // 每秒更新一次时间 }); } }绘制技巧与避坑指南draw()方法的回调旧版Canvas API的ctx.draw()是异步的。第二个参数是一个回调函数必须在回调里进行下一次绘制否则会出现绘制堆积、性能低下甚至空白的问题。这是新手最容易忽略的地方。性能优化全屏平铺、旋转的水印对性能有影响。如果发现滚动或操作卡顿可以考虑降低水印更新的频率比如时间每5秒更新一次。使用离屏Canvas预先绘制好静态的水印图案但uni-app旧版Canvas对离屏支持有限需谨慎。简化水印样式减少循环绘制的次数。坐标恢复使用了rotate、translate等变换后务必在绘制完当前元素后将变换状态恢复回来否则后续所有绘制都会基于被变换的坐标系导致错乱。上面的代码在绘制平铺水印后立即做了反向旋转。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; } }); } }重要提示takePhoto和stopRecord返回的都是临时文件路径。这些文件在本次小程序/App会话期间有效但一旦应用关闭就可能被清理。务必及时处理要么立即预览后让用户决定是否保存到相册uni.saveImageToPhotosAlbum要么直接上传到你的服务器进行永久存储。4. 多端兼容性处理与深度优化4.1 平台差异与条件编译uni-app虽然统一了API但不同平台底层实现仍有差异必须用条件编译#ifdef、#endif来处理。获取相机权限在App端需要在manifest.json的App模块配置中勾选Camera(相机)权限并在首次使用时用uni.authorize请求。在小程序端则需在app.json的permission字段中声明并调用wx.authorize。Canvas尺寸单位在Vue页面中Canvas的宽高通常用px。但在小程序中Canvas有默认的宽度300px、高度225px且其坐标系与CSS像素有关。有时需要显式设置width和height属性并用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秒一次降低到5秒或10秒一次。对于静态Logo水印只在初始化时绘制一次不需要每帧重绘。使用image代替复杂fillText如果水印包含复杂图形或字体可以预先将水印生成一张透明的PNG图片然后在Canvas中使用ctx.drawImage绘制。这比绘制多行文字和形状性能更好。避免在draw回调中执行重逻辑ctx.draw的回调函数里只安排下一次绘制不要进行数据计算、网络请求等操作。合理设置frame-sizecamera的frame-size属性有low、medium、high可选。不是越高越好high模式在部分老旧设备上可能无法启动或帧率极低。通常medium在清晰度和性能间取得较好平衡。页面生命周期管理在页面onHide或onUnload时务必停止相机预览和录像并清除绘制定时器释放资源。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后逆向难度增大。为了增强防篡改性可以考虑信息隐藏将部分水印信息如用户ID哈希值以极低的透明度例如0.05或微小的像素点变化数字水印方式嵌入画面肉眼难以察觉但可以通过算法提取。后端校验前端上传带水印的文件后后端可以再次对文件进行水印检测验证水印信息是否完整、是否与当前操作者信息匹配。这需要前后端约定好水印的格式和加密方式。关键信息由后端生成例如水印中的时间戳、流水号等可以由后端接口在拍摄前下发一个“拍摄凭证”前端将这个凭证绘制到水印中。这样即使前端被篡改伪造的凭证也无法通过后端验证。5. 常见问题排查与解决方案实录在实际开发中我遇到了不少问题这里列几个典型的问题一水印位置错位在iOS和Android上表现不一致。排查根本原因是Canvas层和Camera层的尺寸或位置计算不准确。没有考虑状态栏、导航栏、tabBar等占用的空间。解决使用uni.getSystemInfoSync()获取windowWidth和windowHeight这是可用窗口区域。使用uni.createSelectorQuery().select(‘.camera’).boundingClientRect()异步获取相机组件在屏幕上的实际位置和尺寸。将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();问题二拍照/录像后水印消失了。排查takePhoto和stopRecord生成的是纯相机画面。我们实时看到的水印是Canvas覆盖层并未真正合成到相机输出流中。这是一个认知错误。实际上我们的方案中水印是“看”得见但“拍”不到的。这回到了方案本质我们依赖的是覆盖层而覆盖层不会被原生相机API捕获。解决必须进行后期合成。当拿到临时文件路径后需要再用一个Canvas将临时文件图片或视频帧绘制出来然后把水印绘制上去最后导出新的文件。对于图片使用uni.getImageInfo获取图片信息然后在新的Canvas中drawImage再绘制水印最后用uni.canvasToTempFilePath导出。对于视频处理起来非常复杂。需要在用户停止录像后使用视频编辑插件如uni.requireNativePlugin调用原生插件或FFmpeg可通过云函数或自己的服务器进行视频与水印的合成。实时视频加水印在纯前端实现高性能合成是不现实的。对于要求高的场景更可行的方案是录制时不带水印录制完成后立即调用后端服务进行合成处理。问题三在部分Android机型上Canvas绘制的水印非常模糊。排查这是Canvas的width、height属性与CSS样式的width、height设置混淆导致的。Canvas有自身的像素画布大小由属性决定和CSS渲染大小。如果画布很小但被CSS拉伸得很大就会模糊。解决在WXML/Vue模板中用style绑定计算出的宽高单位px同时也用width和height属性绑定同样的值旧版Canvas组件支持属性。或者更推荐的方法是使用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可能因为组件未渲染完成而失败。解决将初始绘制的代码放在$nextTick或setTimeout中确保组件渲染完毕。同时检查Canvas的canvas-id是否唯一在自定义组件中是否使用了正确的选择器uni.createCanvasContext的第二个参数。经过这一系列的拆解、实现、优化和排错一个基于uni-app的、支持实时预览水印的拍照录像功能就基本成型了。它虽然无法实现“拍摄即合成”的终极效果但通过“实时预览后期合成”的组合拳在用户体验和功能实现上取得了很好的平衡。最重要的是这套代码经过良好的封装和条件编译可以同时跑在H5、微信小程序和App上极大地提升了开发效率。