1. 项目缘起:为什么我们需要一个横屏签名板?
最近在做一个政务类的微信小程序项目,里面有个环节需要用户在线签署一份电子告知书。产品经理的原型图里,签名区域是一个横着的长条,要求用户用手指或者触控笔在上面签名。一开始,我们团队里有个前端小哥图省事,直接用了微信小程序原生的input组件,让用户打字输入名字。结果被业务方直接打回来了,理由很简单:这完全没有“签署”的感觉,法律效力和用户体验都大打折扣。用户需要的是“手写”这个动作,来代表确认和授权。
于是,这个需求就落到了我头上:在微信小程序里,实现一个手写签名功能,并且必须是横屏的签名板。听起来好像就是画个画布(Canvas)让人涂鸦,但真做起来,坑是一个接一个。比如,怎么让签名笔迹流畅跟手?横屏下如何适配不同尺寸的手机?签完名怎么把那一坨Canvas数据变成一张清晰的图片保存下来?还有最头疼的,在iOS和安卓不同机型上,Canvas的绘制表现和性能差异巨大。
网上搜了一圈,虽然有不少Canvas画板的demo,但要么是竖屏的,要么代码写得比较“玩具”,真放到生产环境,问题百出。所以,我决定把这次从零搭建一个高可用、横屏手写签名组件的完整过程,包括核心原理、关键代码、避坑指南和性能优化点,都详细记录下来。如果你也在为类似的需求头疼,希望这篇近万字的实战总结能让你少走弯路。
2. 核心战场:微信小程序Canvas API的深度剖析
要实现手写,Canvas(画布)是我们唯一且必须依赖的核心。微信小程序的Canvas API虽然源自Web标准,但为了适应小程序环境,做了不少封装和限制,理解这些是成功的第一步。
2.1 Canvas上下文:2D与WebGL的抉择
微信小程序提供了两种Canvas上下文:CanvasContext(2D) 和WebGLContext(WebGL)。对于签名板这种2D矢量绘图场景,毫无疑问选择2D API。它更轻量,API也更直观。我们通过wx.createCanvasContext或SelectorQuery来获取上下文对象。
这里有个早期容易踩的坑:Canvas的初始化时机。你不能在onLoad生命周期里直接去获取Canvas节点,因为此时视图层可能还未渲染完成。正确做法是在onReady生命周期里,或者使用wx.nextTick来确保Canvas已经挂载。
// 推荐在 onReady 或 使用 nextTick onReady() { setTimeout(() => { // 使用setTimeout模拟 nextTick,实际项目可用wx.nextTick this.initCanvas(); }, 100); } initCanvas() { const query = wx.createSelectorQuery(); query.select('#signatureCanvas') .fields({ node: true, size: true }) .exec((res) => { if (res[0]) { const canvas = res[0].node; const ctx = canvas.getContext('2d'); const dpr = wx.getSystemInfoSync().pixelRatio; // 获取设备像素比 // 设置Canvas实际宽高(物理像素),避免模糊 canvas.width = res[0].width * dpr; canvas.height = res[0].height * dpr; ctx.scale(dpr, dpr); // 缩放上下文,使绘制坐标与CSS像素对齐 this.canvas = canvas; this.ctx = ctx; this.setDefaultStyle(); // 设置默认画笔样式 } }); }注意:上面代码中关键的一步是处理设备像素比(DPR)。如果不设置,Canvas会以CSS像素(逻辑像素)为单位进行绘制,在高清屏(如Retina屏)上会显得模糊。通过将Canvas的
width和height属性设置为CSS宽高 * DPR,并缩放上下文,我们是在用更多的物理像素来绘制同样的图形,从而获得清晰锐利的线条。
2.2 触摸事件:流畅签名的生命线
签名的本质是捕获用户手指的移动轨迹。微信小程序提供了touchstart,touchmove,touchend三个触摸事件。我们的绘制逻辑就绑定在这三个事件上。
- touchstart:手指按下。记录起始点坐标,并开始一条新的路径 (
ctx.beginPath())。这是每一笔的起点。 - touchmove:手指移动。这是最频繁触发的事件。我们需要将移动过程中的多个点连接起来,形成平滑的线条。这里不能简单地用
lineTo连接两个点,那样会有棱角。业内通用的做法是使用贝塞尔曲线进行插值,让线条圆滑。 - touchend:手指抬起。结束当前路径的绘制。
然而,touchmove事件的触发频率是有限的,如果用户滑动很快,采集到的点就会很稀疏,画出来的线会是折线。因此,我们需要在两点之间进行插值计算。一个经典且效果不错的算法是模拟二次贝塞尔曲线,用上一个点、当前点和它们的中点来构建平滑曲线。
// 简化版的平滑绘制逻辑 let lastPoint = { x: 0, y: 0 }; // 上一个点 let currentPoint = { x: 0, y: 0 }; // 当前点 onTouchMove(e) { const point = { x: e.touches[0].x, y: e.touches[0].y }; currentPoint = point; if (!lastPoint.x) { // 如果是touchmove的第一个点,直接移动到该点 this.ctx.moveTo(point.x, point.y); } else { const midPoint = { x: (lastPoint.x + currentPoint.x) / 2, y: (lastPoint.y + currentPoint.y) / 2 }; // 使用二次贝塞尔曲线绘制 (控制点为上一个点,终点为中点) this.ctx.quadraticCurveTo(lastPoint.x, lastPoint.y, midPoint.x, midPoint.y); this.ctx.stroke(); // 描边 } lastPoint = currentPoint; } onTouchEnd() { // 最后一段线,从上一个点到当前点用直线连接,确保线条闭合 this.ctx.lineTo(currentPoint.x, currentPoint.y); this.ctx.stroke(); // 重置记录点,为下一笔做准备 lastPoint = { x: 0, y: 0 }; }实操心得:
touchmove事件在iOS和安卓上的触发频率和精度有差异。实测发现,在部分安卓机型上,即使使用了贝塞尔曲线,快速书写时仍可能出现线段不连贯的情况。一个补救措施是,在touchmove事件处理函数中,可以尝试用requestAnimationFrame对绘制进行节流,而不是每次事件触发都立即绘制,这能在一定程度上平衡性能和流畅度。
2.3 性能黑洞:离屏Canvas与绘制状态管理
签名板如果长时间使用,或者用户反复涂改,直接在主Canvas上操作可能会引发性能问题,因为每一次stroke()都是直接向视图层提交绘制命令。一个高级的优化技巧是使用离屏Canvas。
思路是:我们创建两个Canvas。一个是在屏幕外(Offscreen)的、内存中的Canvas,用户所有的绘制操作都先在这个离屏Canvas上进行。当一次完整的笔画(从touchstart到touchend)结束时,再将离屏Canvas上这一整笔的图形,一次性drawImage到屏幕上的主Canvas中。这样,主Canvas的重绘次数大大降低,滚动或进行其他操作时会更加流畅。
微信小程序从基础库2.7.0开始支持wx.createOffscreenCanvas,但为了更好的兼容性,我们也可以用一个隐藏的、同尺寸的普通Canvas来模拟离屏画布。
此外,绘制状态管理也很重要。频繁调用ctx.setLineWidth,ctx.setStrokeStyle等设置方法是有开销的。最好在初始化时或画笔属性改变时一次性设置好,在绘制过程中避免重复设置。
3. 横屏适配:不仅仅是旋转90度那么简单
“横屏签名板”这个需求,听起来好像就是把Canvas的宽高设置反一下。但实际开发中,横屏适配涉及视图层布局、Canvas坐标转换、以及不同机型的安全区域(如刘海屏、下巴)处理,是个系统工程。
3.1 CSS布局与横屏锁定
首先,我们需要让整个页面或签名区域横过来。有几种方案:
- CSS旋转:给签名板的容器元素设置
transform: rotate(90deg)。这是最简单的方案,但要注意,旋转后元素的宽高逻辑会互换,触摸事件的坐标也需要相应进行逆转换,计算起来有点绕。 - 页面配置:在小程序页面的
.json文件中设置"pageOrientation": "landscape"。这个属性可以强制页面横屏显示。但是,它依赖于微信客户端的支持,并且可能影响页面内其他组件的布局。 - 自定义组件内部横屏:最推荐的做法。我们不改变页面方向,只让签名板组件内部“认为”自己是横屏。通过CSS,将签名板容器的宽度设置为100vh(视口高度),高度设置为100vw(视口宽度),并利用
flex布局或定位使其在视觉上横过来。这样对页面其他部分影响最小。
/* 签名板组件样式示例 */ .signature-container { width: 100vh; /* 宽度等于竖屏时的视口高度 */ height: 70vw; /* 高度设为视口宽度的一部分,留出按钮空间 */ display: flex; flex-direction: column; margin: 20px auto; } .canvas-wrapper { flex: 1; background: #f9f9f9; border: 1px solid #ddd; border-radius: 4px; overflow: hidden; /* 防止画布溢出 */ } #signatureCanvas { width: 100%; height: 100%; display: block; }这种方案的优点是纯CSS实现,兼容性好,且触摸事件的坐标无需复杂转换(因为Canvas本身没有旋转,只是容器尺寸变了)。
3.2 安全区域(Safe Area)适配
在全面屏手机,特别是iPhone上,横屏时刘海和底部的Home Indicator(横条)会遮挡部分内容。我们需要确保签名区域显示在安全区域内。
微信小程序提供了wx.getSystemInfoSync()来获取设备信息,但更直接的是使用CSS的constant()和env()函数(注意兼容性)来处理安全区域插入。对于横屏,我们主要关心左右的安全距离。
.canvas-wrapper { flex: 1; background: #f9f9f9; border: 1px solid #ddd; border-radius: 4px; overflow: hidden; /* 适配安全区域 */ padding-left: constant(safe-area-inset-left); padding-left: env(safe-area-inset-left); padding-right: constant(safe-area-inset-right); padding-right: env(safe-area-inset-right); }同时,在JS初始化Canvas时,我们也需要通过wx.getSystemInfoSync().safeArea来获取安全区域的尺寸,并以此为依据来设置Canvas的实际绘制区域,避免签名内容被绘制到屏幕不可见或被遮挡的区域。
3.3 坐标转换:触摸点与Canvas绘制的映射
无论采用哪种横屏方案,触摸事件返回的坐标(e.touches[0].x, e.touches[0].y)都是相对于整个页面或组件原始坐标系的。如果我们的Canvas在视觉上旋转或通过复杂布局定位了,这个坐标就不能直接用于Canvas绘图。
我们必须进行坐标转换。核心方法是使用wx.createSelectorQuery().select()获取Canvas节点的位置信息(boundingClientRect),然后计算触摸点相对于Canvas左上角的偏移量。
onTouchStart(e) { const query = wx.createSelectorQuery(); query.select('#signatureCanvas').boundingClientRect(); query.exec((rects) => { const canvasRect = rects[0]; if (canvasRect) { // 计算相对坐标 const x = e.touches[0].clientX - canvasRect.left; const y = e.touches[0].clientY - canvasRect.top; // 注意:如果Canvas用DPR放大过,这里的x,y可能还需要除以DPR,取决于你的绘制逻辑 const dpr = this.data.dpr; const pointForDraw = { x: x / dpr, y: y / dpr }; this.startDrawing(pointForDraw); } }); }踩坑记录:坐标转换这个步骤极其关键,也极易出错。特别是在横屏布局下,
boundingClientRect返回的left,top值可能会因为父容器的变换(如transform)而变得诡异。务必在真机上多测试,确保手指触摸位置和笔迹出现位置完全吻合。一个调试技巧是,在Canvas上先绘制一个网格背景,能直观地看到坐标系统。
4. 从数据到图片:签名导出与清晰度保障
用户签完名,我们的工作才完成一半。如何将Canvas上的矢量笔迹,变成一张可供上传、预览或打印的图片,并且保证清晰度,这里面门道不少。
4.1 Canvas导出图片的API选择
微信小程序提供了wx.canvasToTempFilePath这个API,它可以将指定Canvas的内容导出生成临时图片文件路径。这是最标准的做法。
exportSignature() { return new Promise((resolve, reject) => { wx.canvasToTempFilePath({ canvas: this.canvas, // 传入Canvas实例 fileType: 'png', // 推荐PNG,支持透明背景 quality: 1, // PNG格式下quality无效,JPEG格式下有效 success: (res) => { const tempFilePath = res.tempFilePath; // 此时可以预览、保存或上传 tempFilePath console.log('签名图片临时路径:', tempFilePath); resolve(tempFilePath); }, fail: (err) => { console.error('导出图片失败', err); reject(err); } }, this); }); }关键参数解析:
canvas: 必须传入Canvas节点对象,而不是CanvasId。这就是为什么我们之前要用SelectorQuery获取node。fileType: 可选'jpg'或'png'。'png'格式支持透明通道,如果签名板背景不是白色,或者你想叠加到其他背景上,用PNG是最佳选择。'jpg'文件体积更小,但不支持透明。quality: 仅对'jpg'有效,范围 0~1,表示图片压缩质量。
4.2 解决导出图片模糊问题
这是高频投诉点:“我在手机上签得挺清楚,为什么导出的图片发糊?” 根源通常就是我们之前在2.1节提到的设备像素比(DPR)。
错误做法:Canvas的CSS样式设置为宽500px、高300px,然后就直接在这个“逻辑尺寸”上绘制和导出。在高DPI设备上,这个Canvas实际只用了500*300个物理像素,却被拉伸到更多物理像素的屏幕上显示,导出时自然就模糊。
正确做法:我们已经在初始化时,将Canvas的width和height属性(绘图表面)设置为CSS宽高的DPR倍。这样,Canvas内部是一个高分辨率的绘图缓冲区。当我们用wx.canvasToTempFilePath导出时,默认会导出这个高分辨率的缓冲区内容。但是,生成的临时图片的尺寸,也是这个缓冲区的尺寸(即 宽DPR, 高DPR)。
如果你直接把这张大图显示在一个CSS尺寸的<image>标签里,图片会被压缩,可能看起来更锐利,但也可能因为尺寸不匹配而出现别的问题。通常,我们需要根据使用场景来决定是否对导出的图片进行缩放。
- 场景一:用于在屏幕上预览。可以将图片的CSS宽高设置为原始Canvas的CSS宽高,并设置
image标签的mode为'widthFix'或'heightFix',让图片自适应。<image src="{{signatureImagePath}}" mode="widthFix" style="width: 500px;" /> - 场景二:用于上传服务器或生成PDF。高分辨率的图片可能体积过大。我们可以在导出后,使用
wx.getImageInfo获取图片信息,再用wx.canvasToTempFilePath的另一个变体(指定destWidth和destHeight)进行二次压缩和缩放,生成一个指定目标尺寸的图片。// 假设我们需要一张宽度为750逻辑像素的图片 const targetWidth = 750; const scale = targetWidth / (this.data.canvasWidth * this.data.dpr); // 计算缩放比 const targetHeight = Math.round(this.data.canvasHeight * this.data.dpr * scale); wx.canvasToTempFilePath({ canvas: this.canvas, destWidth: targetWidth, // 指定目标宽度(物理像素) destHeight: targetHeight, // 指定目标高度(物理像素) fileType: 'png', success: (res) => { // 得到一张尺寸为 targetWidth * targetHeight 的清晰图片 } }, this);
4.3 背景处理与图片格式优化
签名往往需要纯色背景(通常是白色)或者透明背景。我们可以在绘制签名前,用ctx.fillRect清除或填充整个Canvas。
// 清除画布(透明背景) clearCanvas() { this.ctx.clearRect(0, 0, this.canvas.width / this.data.dpr, this.canvas.height / this.data.dpr); } // 填充白色背景 fillWhiteBackground() { this.ctx.fillStyle = '#FFFFFF'; this.ctx.fillRect(0, 0, this.canvas.width / this.data.dpr, this.canvas.height / this.data.dpr); // 注意:填充后,需要重新设置画笔样式,因为fillStyle覆盖了strokeStyle this.setDefaultStyle(); }如果导出jpg格式,白色背景是必须的,因为JPEG不支持透明。导出png格式则可以选择透明背景,方便后期合成。
关于图片体积,PNG对于线条、色块简单的签名图片压缩率很好,通常不会太大。如果确实需要极致压缩,可以考虑在服务端收到图片后再进行进一步的PNG优化(如使用pngquant)或转换为有损的WebP格式。
5. 工程化与体验打磨:封装组件与高级功能
一个基础的签名板完成后,我们需要把它变得健壮、易用,能够复用到不同项目中。
5.1 封装为自定义组件
将签名板功能封装成微信小程序自定义组件是必然选择。这样可以将Canvas初始化、事件监听、绘制逻辑、导出方法全部内聚,对外暴露简洁的属性和事件。
组件属性(properties):
width: 画布宽度(逻辑像素)height: 画布高度(逻辑像素)lineColor: 画笔颜色lineWidth: 画笔粗细backgroundColor: 画布背景色
组件方法(methods):
init(): 初始化画布clear(): 清空签名undo(): 撤销上一步(需要实现历史记录栈)export(): 导出图片,返回Promise
组件事件(events):
bind:signatureChange: 当签名内容发生变化时触发(可用于控制“确认”按钮状态)bind:exportSuccess: 导出图片成功时触发
组件的WXML结构相对简单,主要就是一个<canvas>元素,绑定触摸事件。组件的JS部分则包含了前面几章讨论的所有核心逻辑。
5.2 实现撤销(Undo)与重做(Redo)
这是一个能极大提升用户体验的功能。实现原理是绘制历史记录。我们不需要记录每一个像素点,那太占内存。Canvas的getImageData和putImageData方法可以帮我们。
思路是:用一个数组作为历史记录栈。每次用户开始一次新的笔画(touchstart)前,或者完成一次笔画(touchend)后,将当前整个Canvas的图像数据保存下来。
// 保存当前状态到历史栈 saveHistory() { const imageData = this.ctx.getImageData(0, 0, this.canvas.width, this.canvas.height); this.historyStack.push(imageData); // 可以限制历史栈长度,比如最多20步 if (this.historyStack.length > 20) { this.historyStack.shift(); } this.redoStack = []; // 清空重做栈 } // 撤销 undo() { if (this.historyStack.length > 1) { // 保留至少一个初始状态 const lastState = this.historyStack.pop(); this.redoStack.push(lastState); // 当前状态放入重做栈 const prevState = this.historyStack[this.historyStack.length - 1]; this.ctx.putImageData(prevState, 0, 0); // 恢复到上一个状态 } else if (this.historyStack.length === 1) { // 清空画布 this.clearCanvas(); this.redoStack.push(this.historyStack.pop()); this.historyStack.push(this.ctx.getImageData(0, 0, this.canvas.width, this.canvas.height)); // 存入空状态 } } // 重做 redo() { if (this.redoStack.length > 0) { const nextState = this.redoStack.pop(); this.historyStack.push(nextState); this.ctx.putImageData(nextState, 0, 0); } }性能注意:
getImageData获取的是整个Canvas缓冲区的数据,数据量很大(宽高像素 * 4)。频繁调用会影响性能。因此,保存历史记录的时机要把握好,通常是在一笔画完或清空时保存,而不是每次touchmove都保存。
5.3 笔锋效果模拟与压感适配
为了让电子签名更接近真实笔迹,模拟笔锋(即线条粗细根据运笔速度变化)是一个加分项。在移动端,我们无法获取真实的笔压,但可以通过计算触摸点移动的速度来模拟。
基本原理:在touchmove事件中,记录每个点的时间戳。两点之间的距离除以时间间隔,得到速度。速度越快,线条越细;速度越慢,线条越粗。
onTouchMove(e) { const now = Date.now(); const point = { x: e.touches[0].x, y: e.touches[0].y, time: now }; if (this.lastPointForVelocity) { const distance = Math.sqrt(Math.pow(point.x - this.lastPointForVelocity.x, 2) + Math.pow(point.y - this.lastPointForVelocity.y, 2)); const timeDelta = now - this.lastPointForVelocity.time; const velocity = distance / Math.max(timeDelta, 1); // 防止除零,计算速度 // 根据速度动态设置线宽(速度越快,线宽越小,可设置一个范围) const baseWidth = 3; const maxWidth = 8; const minWidth = 1; const newWidth = Math.max(minWidth, Math.min(maxWidth, baseWidth - velocity * 0.5)); this.ctx.lineWidth = newWidth; } // ... 原有的绘制逻辑(贝塞尔曲线) this.lastPointForVelocity = point; }这个效果需要精细调参,并且在不同DPI和性能的设备上表现可能不稳定,属于锦上添花的功能。如果对签名真实性要求极高,可以考虑连接外接的主动式电容笔,部分高端笔支持压感,但需要特定的SDK和兼容性测试。
5.4 真机调试与多端兼容性清单
微信小程序开发,真机调试是绕不开的坎。以下是我在完成这个组件后,总结的必须检查的清单:
- iOS vs Android 绘制性能:在低端安卓机上,复杂的贝塞尔曲线绘制可能导致卡顿。可以考虑降级方案,比如在检测到帧率过低时,改用
lineTo绘制直线段。 - Canvas层级问题:微信小程序中,原生组件(如
<canvas>、<video>)具有最高层级,会覆盖在普通视图组件之上。这意味着你的“确认”、“清除”按钮如果用的是普通<view>,可能会被Canvas挡住。解决方案是将按钮放在一个独立的、层级更高的<cover-view>中。 - 触摸事件冲突:如果签名板区域可以滚动,要防止触摸事件被父容器的滚动事件吞掉。可以在
touchstart事件中调用e.preventDefault(),或使用catchtouchmove来阻止事件冒泡。 - 内存管理:历史记录栈里的
ImageData对象很占内存。当组件卸载(detached)时,务必手动清空这些数组,并将Canvas上下文置为null,促进垃圾回收。 - 导出图片的权限:
wx.canvasToTempFilePath在iOS和安卓上都需要用户授权才能写入临时文件。虽然通常会自动触发,但最好在fail回调里做好错误处理,提示用户。 - 横屏下的键盘弹出:如果页面内有输入框,横屏时键盘弹出可能会挤压Canvas区域,导致布局错乱。需要测试并考虑使用
adjust-position等属性。
做完这个横屏签名板组件,最大的感触就是:细节决定成败。从清晰的线条到准确的坐标,从流畅的书写到完美的导出,每一个环节都需要仔细考量。它不是一个炫技的功能,而是一个需要扎实的基础知识和耐心的调试才能做好的基础工具。现在,这个组件已经稳定运行在我们的生产环境中,处理了成千上万份电子签名。希望这些踩坑经验和实现思路,能帮你更快地构建出属于自己的、体验优秀的签名功能。