老板甩过来一个需求:要在微信小程序里加一个手动签字功能。听起来不就是画布上手写吗?真正落地的时候才发现,坑是一个接一个——真机画线发虚、手指拖动页面跟着滚、签名区域上下留白一大片、生成的图片上传后端还报错。这篇文章我把整个实现过程从头到尾拆开,包含完整的WXML/WXSS/JS代码、canvas 2D接口的踩坑记录、签名图片裁剪与上传方案,以及我在实际项目中遇到的高频问题和排查思路,直接照着抄就行。
1. 功能设计思路与方案选型
1.1 为什么要用canvas而不是view加背景图
手动签字的核心是捕获用户手指在屏幕上的轨迹,并且把轨迹渲染出来。很多第一次做这个功能的人会上来就写一堆view,每个touchmove事件里往页面里塞一个小圆点,用绝对定位控制位置。这个方案在小规模测试时看着没问题,但一旦签名时间稍长、轨迹点变多,页面节点会膨胀得非常厉害,iPhone 6这类老设备直接卡成PPT。而且view节点没法直接导出成图片,最后还是要借助canvas把整个区域重新画一遍,等于做了两遍工作。
正确做法是直接用canvas承载整个签名过程,手指滑动时实时绘制笔迹,松手后把canvas内容导出成图片。canvas是位图渲染,绘制性能远高于操作DOM节点,而且导出图片是天生的能力,一步到位。微信小程序从基础库2.9.0开始推荐使用Canvas 2D接口(也就是type="2d"),相比旧版的wx.createCanvasContext,新接口更贴近Web标准,性能更好,而且官方的维护重心已经完全转移到新接口上。我的建议是直接上新接口,虽然初期要多写几行初始化代码,但后面真机适配时能少踩很多坑。
1.2 整体功能拆解与流程规划
一个完整的签名功能,拆开来看其实就四个环节:签名画布展示、手写轨迹捕获与绘制、签名图片生成、图片上传与回显。业务上还要考虑用户签错了怎么重写、签名区域怎么校验是否为空、以及后续是否需要把签名叠加到某个合同模板上。
以下是我在实际项目中采用的功能清单:
| 功能点 | 实现方式 | 说明 |
|---|---|---|
| 手写轨迹捕获 | canvas触摸事件 | touchstart记录起点,touchmove持续绘制,touchend结束 |
| 笔迹渲染 | canvas 2D的stroke接口 | 设置线宽、线帽、颜色,实时绘制 |
| 清空重签 | 清空画布方法 | 保存初始状态,调用clearRect重置 |
| 签名图片导出 | canvasToTempFilePath | 生成临时文件路径,可指定导出尺寸 |
| 空白区域裁剪 | 像素扫描getImageData | 扫描非空白像素边界,裁剪四周留白 |
| 图片上传 | wx.uploadFile | 把临时文件提交到后端,兼容本地调试 |
| 空签名校验 | 记录hasDrawFlag | 通过是否真正绘制过笔迹来判断,不用扫描像素 |
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心细节解析与实操要点
2.1 WXML结构和WXSS布局的注意点
先看WXML结构。canvas组件本身是原生组件,层级最高,普通view盖不住它,所以在设计弹窗、按钮布局时要注意z-index没用,要么让按钮和canvas错开位置,要么用cover-view覆盖。我这里把底部按钮区放在canvas区域外面,既避开了原生组件层级问题,布局上也更自然。
html复制<!-- 签名弹窗区域 -->
<view class="sign-modal" wx:if="{{showSignModal}}">
<view class="sign-content">
<view class="sign-header">
<text class="sign-title">请签名</text>
<text class="sign-subtitle">请使用正楷书写</text>
</view>
<view class="sign-canvas-wrap">
<canvas
type="2d"
id="signCanvas"
class="sign-canvas"
disable-scroll="{{true}}"
bindtouchstart="onTouchStart"
bindtouchmove="onTouchMove"
bindtouchend="onTouchEnd"
></canvas>
</view>
<view class="sign-footer">
<button class="sign-btn reset-btn" bindtap="onResetSign">重签</button>
<button class="sign-btn confirm-btn" bindtap="onConfirmSign">确认</button>
</view>
</view>
</view>
注意canvas的id必须要给,后面初始化Canvas 2D实例时要靠这个id去查节点。disable-scroll这个属性很关键,告诉小程序在canvas区域内触摸时不要滚动页面,防止用户签个字页面跟着上下晃。
WXSS方面重点说尺寸问题。canvas的width和height样式要显式设置,不设置的话默认是300x150,而且开发者工具和真机的默认值还不一致。我习惯把签名区域做成固定高度,宽度铺满容器,配合rpx做响应式。
css复制.sign-canvas-wrap {
width: 100%;
height: 400rpx;
background: #fff;
border: 2rpx dashed #dcdfe6;
border-radius: 12rpx;
box-sizing: border-box;
}
.sign-canvas {
width: 100%;
height: 400rpx;
display: block;
}
有个很容易被忽视的坑:canvas样式上要加display: block。因为canvas默认是inline元素,底部会多出几像素的间隙,虽然不影响绘制本身,但会让canvas和容器边框之间出现一条细缝,在视觉上显得不严谨。
容器加个background: #fff也是有讲究的。如果容器背景是透明的,导出图片时透明区域在部分安卓机型上会变成黑色。白底一了百了,导出什么就是什么。
2.2 Canvas 2D初始化与坐标换算原理
Canvas 2D的初始化和旧版接口有本质区别。旧版直接wx.createCanvasContext('canvasId')就能用,新版必须先获取canvas节点,再调用canvas.getContext('2d')拿到绘图上下文。而且新版canvas的坐标系默认是CSS像素,但内部绘图精度是物理像素,所以需要在初始化时设置canvas的width和height为显示尺寸乘以设备像素比(dpr),否则画出来的线条会发虚。
javascript复制initCanvas() {
const query = this.createSelectorQuery();
query.select('#signCanvas')
.fields({ node: true, size: true })
.exec((res) => {
if (!res || !res[0] || !res[0].node) {
console.error('canvas节点获取失败');
return;
}
const canvas = res[0].node;
const ctx = canvas.getContext('2d');
const dpr = wx.getSystemInfoSync().pixelRatio;
// 关键:canvas内部尺寸乘以dpr,保证清晰度
canvas.width = res[0].width * dpr;
canvas.height = res[0].height * dpr;
ctx.scale(dpr, dpr);
// 设置笔迹样式
ctx.lineWidth = 3;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.strokeStyle = '#1f2329';
this.canvas = canvas;
this.ctx = ctx;
this.canvasWidth = res[0].width;
this.canvasHeight = res[0].height;
this.hasDrawFlag = false;
// 保存空白状态,用于清空重置
this.blankCanvasData = ctx.getImageData(0, 0, canvas.width, canvas.height);
});
}
这里解释一下为什么要canvas.width = res[0].width * dpr,然后ctx.scale(dpr, dpr)。如果不做这一步,canvas内部像素尺寸就等于CSS尺寸。在dpr为3的iPhone上,一个CSS像素对应3x3个物理像素,canvas只用了1x1个物理像素做绘制,等于把3个像素当一个用,线条自然发虚。设置内部尺寸为CSS尺寸乘以dpr,再通过scale把坐标系缩放回来,这样后续所有绘制代码都按CSS像素来思考,不用在每个坐标上都手动乘dpr。这个设计是Canvas 2D绘图的标准做法,也是线条清晰的关键。
2.3 触摸事件中的坐标获取与绘制逻辑
触摸事件的核心难点在于坐标。触摸事件的e.touches[0].x和e.touches[0].y是相对于页面的,不是相对于canvas的,直接拿来做绘制会导致笔画偏移。正确做法是用e.touches[0].clientX和clientY,再减去canvas左上角在页面中的位置。在Canvas 2D接口下,还有一个更省事的方案:使用canvas.createTouch的对应事件对象,或者从e.touches[0]里直接读取相对于canvas的坐标。
实际上,经过我多次验证,Canvas 2D接口的触摸事件中,e.touches[0].x和e.touches[0].y就是相对于canvas左上角的坐标(注意不是clientX)。这是因为基础库对canvas内的触摸事件做了坐标转换。但这里有一个兼容性隐患:部分基础库版本下这个转换并不生效。为了稳妥,我采取的做法是用clientX减去canvas的偏移量,通过wx.createSelectorQuery().select('#signCanvas').boundingClientRect()拿到canvas相对视口的位置。
javascript复制getTouchPosition(e) {
const touch = e.touches[0];
// 方案一:直接读x/y(Canvas 2D下大部分版本已转换为相对坐标)
if (typeof touch.x === 'number' && typeof touch.y === 'number') {
// 但仍需确认这个坐标是否真的相对canvas
return { x: touch.x, y: touch.y };
}
// 方案二:用clientX减canvas偏移量
if (this.canvasRect) {
return {
x: touch.clientX - this.canvasRect.left,
y: touch.clientY - this.canvasRect.top
};
}
return null;
}
实际开发中我更推荐直接使用方案二,也就是拿到canvasRect后统一用clientX/clientY减偏移。这个方案对所有版本都适用,不会出现坐标偏移问题。注意要在初始化时通过boundingClientRect拿到canvasRect,同时在页面的onReady生命周期里确保canvas已经渲染完成后再获取。
绘制逻辑本身不复杂,核心三步:touchstart时用moveTo把画笔移动到起点,touchmove时用lineTo连接上一个点到当前点并stroke,touchend时重置路径。但这里有一个细节许多人会忽略:如果每次都重新beginPath再moveTo再lineTo再stroke,画出来的线条在拐角处会出现不连续的小点,视觉上断断续续。正确做法是维护一个isDrawing状态,touchstart时beginPath并moveTo,touchmove时lineTo并stroke,直到touchend才结束。由于canvas的stroke是累积式的——每次stroke会在上一次的路径上继续描边——所以连续move产生的线条是平滑衔接的。
javascript复制onTouchStart(e) {
const pos = this.getTouchPosition(e);
if (!pos) return;
this.isDrawing = true;
this.hasDrawFlag = true;
this.ctx.beginPath();
this.ctx.moveTo(pos.x, pos.y);
// 记录上一个点,用于touchmove时的线段连接
this.lastPoint = pos;
}
onTouchMove(e) {
if (!this.isDrawing) return;
const pos = this.getTouchPosition(e);
if (!pos) return;
this.ctx.lineTo(pos.x, pos.y);
this.ctx.stroke();
this.lastPoint = pos;
}
onTouchEnd() {
if (this.isDrawing) {
this.isDrawing = false;
}
}
这里有一个性能优化点:touchmove触发的频率非常高,如果每次move都做多余的计算(比如getImageData之类的操作),手写流畅度会明显下降。我在实际测试中发现,getImageData这个API在部分安卓机型上非常耗时,一帧绘制里调用它会让帧率直接掉到30fps以下。所以后文提到的留白裁剪功能,我会放在导出时集中处理,而不是在每帧绘制时计算。
3. 实操过程与核心环节实现
3.1 清空重签功能的两种实现方式
清空画布是最容易踩坑的模块。我见过有人用ctx.clearRect(0, 0, canvas.width, canvas.height)来清空,结果发现画布上原来的笔迹是没了,但canvas背景变成黑色了——这是因为clearRect把整个画布清成了透明,而透明在视觉上取决于容器底色。如果容器是白色,看起来就是白的;如果容器是黑色的,那清空后签名区域就是黑的,用户在黑底上签名,体验很糟糕。
我常用的清空方案是先把空白状态存下来。在初始化完成后立刻执行ctx.getImageData(0, 0, canvas.width, canvas.height),把当前空白画布的像素数据存到this.blankCanvasData。清空时只要ctx.putImageData(this.blankCanvasData, 0, 0)就能恢复到初始化时的状态,保证背景色完全一致。这个方案相比clearRect+重新填充颜色的方式更可靠,因为它跟初始化时的状态是一模一样的,不管容器是什么颜色都不会出错。
javascript复制onResetSign() {
if (!this.ctx) return;
// 恢复到初始化时的空白状态
this.ctx.putImageData(this.blankCanvasData, 0, 0);
this.hasDrawFlag = false;
}
另外,wasm(canvas保存的空白数据)在部分低端机上会比较大,如果签名区域是750x400,乘以dpr后实际像素可能是2250x1200,getImageData返回的数据量大约是2250x1200x4个字节,大概10MB左右。这个数据放在内存里是没问题的,但不建议缓存多份。应用内存吃紧时,也可以换成用一个标志位加clearRect后手动填充白底来清空,不过要自己维护fillStyle。
3.2 签名图片导出与留白裁剪的完整实现
签名完成后导出图片,用的是wx.canvasToTempFilePath。这个API有一个坑:在Canvas 2D接口下,必须把canvas实例作为参数传进去(canvas: this.canvas),否则在部分安卓机型上会导出失败或者导出空白图片。
javascript复制onConfirmSign() {
if (!this.hasDrawFlag) {
wx.showToast({ title: '请先签名', icon: 'none' });
return;
}
wx.canvasToTempFilePath({
canvas: this.canvas,
x: 0,
y: 0,
width: this.canvasWidth,
height: this.canvasHeight,
destWidth: this.canvasWidth * 2,
destHeight: this.canvasHeight * 2,
fileType: 'png',
quality: 1,
success: (res) => {
const tempFilePath = res.tempFilePath;
// 这里可以继续做裁剪或直接上传
this.processSignatureImage(tempFilePath);
},
fail: (err) => {
console.error('导出签名图片失败', err);
}
});
}
导出参数说明:x/y/width/height决定从canvas的哪个区域截取,destWidth/destHeight决定导出图片的最终尺寸。这里我设置的dest是实际CSS尺寸的两倍,这样得到的图片清晰度更高,因为canvas内部本身已经有dpr倍率的像素了,再放大一倍是为了在合同打印或者详情页放大查看时依然清晰。不过要注意,destWidth放大不会让画面无限清晰,因为canvas内部的物理像素是固定的,放大只是拉伸。
留白裁剪是我在生产项目中特别加的一个功能,因为签约场景下签名图片经常要贴到合同的指定位置,如果签名区域四周留白很大,会显得非常不专业。裁剪思路是:拿到canvas的ImageData,遍历所有像素,找到非空白像素的最小包围盒,然后按这个包围盒去裁剪。
javascript复制trimSignatureImage() {
const canvas = this.canvas;
const ctx = this.ctx;
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
const width = canvas.width;
const height = canvas.height;
let minX = width, minY = height, maxX = 0, maxY = 0;
// 判定非空白像素:RGB任一通道大于阈值,或alpha大于阈值
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const idx = (y * width + x) * 4;
const r = data[idx];
const g = data[idx + 1];
const b = data[idx + 2];
const a = data[idx + 3];
// 这里用alpha大于0作为有内容的判断,但要注意抗锯齿边缘
if (a > 0) {
// 排除纯白像素的干扰(即使alpha是255,RGB为255,255,255也不当内容)
if (r < 250 || g < 250 || b < 250) {
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
}
}
}
// 没有任何内容
if (maxX < minX || maxY < minY) {
return null;
}
// 加一点内边距,避免签名紧贴边缘
const padding = 10;
minX = Math.max(0, minX - padding);
minY = Math.max(0, minY - padding);
maxX = Math.min(width, maxX + padding);
maxY = Math.min(height, maxY + padding);
return {
x: minX / this.canvasWidth * 1, // 因为ctx已scale,下面的canvasToTempFilePath用的是CSS尺寸
y: minY / this.canvasHeight * 1,
width: (maxX - minX),
height: (maxY - minY)
};
}
注意这里有个尺寸匹配问题:getImageData拿到的像素坐标是基于canvas物理尺寸的(即已乘以dpr),但wx.canvasToTempFilePath的x/y/width/height参数是基于CSS尺寸的。所以在传入裁剪参数时,需要把像素坐标除以dpr再传给canvasToTempFilePath。上面代码里我偷了个懒直接写1,实际正确写法是除以dpr。这个坑我踩过一次:在iPhone X上导出来的图片明显偏右上偏移,就是因为像素坐标没有换算回CSS坐标。
正确写法是:
javascript复制const dpr = wx.getSystemInfoSync().pixelRatio;
wx.canvasToTempFilePath({
canvas: this.canvas,
x: minX / dpr,
y: minY / dpr,
width: (maxX - minX) / dpr,
height: (maxY - minY) / dpr,
destWidth: (maxX - minX) * 2,
destHeight: (maxY - minY) * 2,
fileType: 'png',
quality: 1,
success: (res) => {
// 得到裁剪后的签名图片
}
});
从性能角度看,全量遍历一张2250x1200的像素图大约要处理270万个像素,在iPhone上大概耗时30ms左右,可以接受。但如果在很低端的安卓机上,可能需要50ms以上,建议放在loading弹窗里做,让用户感觉是正常的处理过程。
3.3 图片上传与后端对接经验
签名图片生成后,下一步就是上传。这里我用的是wx.uploadFile,需要注意两点:一是filePath是临时文件路径,可以直接上传;二是如果后端要求的是base64字符串而非multipart文件流,需要先用wx.getFileSystemManager().readFile把临时文件读成base64。
javascript复制uploadSignature(tempFilePath) {
wx.showLoading({ title: '上传中...' });
wx.uploadFile({
url: 'https://your-api.example.com/api/signature/upload',
filePath: tempFilePath,
name: 'file',
formData: {
bizId: this.data.bizId,
userId: this.data.userId,
signType: 'contract'
},
success: (res) => {
// 注意:res.statusCode是HTTP状态码,res.data才是后端返回的数据
const data = JSON.parse(res.data);
if (data.code === 0) {
wx.showToast({ title: '上传成功', icon: 'success' });
} else {
wx.showToast({ title: data.message || '上传失败', icon: 'none' });
}
},
fail: (err) => {
console.error('上传失败', err);
wx.showToast({ title: '网络异常,请重试', icon: 'none' });
},
complete: () => {
wx.hideLoading();
}
});
}
上传时的注意点:小程序上传默认超时时间是60秒,如果网络较慢且图片较大,建议在wx.request的timeout设置之外,单独给uploadFile做超时处理——但wx.uploadFile没有timeout参数,只能在success/fail里处理。如果后端接收到了图片但后续处理超时,前端拿到的可能是超时错误。我在实际项目中遇到过一次,后端处理图片压缩转存耗时10秒以上,前端等不到响应直接fail了。解决办法是后端改成先返回“已接收”,图片异步处理完再通过消息推送通知前端结果。
如果后端要求base64,可以用如下方式读取:
javascript复制const fs = wx.getFileSystemManager();
fs.readFile({
filePath: tempFilePath,
encoding: 'base64',
success: (res) => {
const base64 = res.data;
// 把base64交给后端
}
});
这里有个细节:base64数据里可能包含+、/、=等特殊字符,如果通过wx.request放在JSON里传输没问题,但如果放在URL参数里传输,必须做encodeURIComponent处理,否则后端解析时数据会截断。
4. 常见问题与排查技巧实录
4.1 真机画线发虚或模糊
这个问题的出现频率最高。绝大多数情况是初始化时没有做dpr适配,canvas内部像素尺寸等于CSS尺寸,导致图形在物理像素上被放大渲染,边缘出现锯齿和模糊。解决办法是严格按2.2节的初始化方式,设置canvas.width = cssWidth * dpr并ctx.scale(dpr, dpr)。如果设置完后还是发虚,检查一下是否在ctx.scale之前调用了其他绘制操作,如果有,缩放会影响到之前的状态,最好先scale再设置样式和绘制。
还有一种情况是导出图片模糊,但画布上看着是清晰的。这时检查canvasToTempFilePath的destWidth/destHeight是否小于canvas实际像素尺寸。如果你在css里把canvas显示为375px宽,内部实际是1125px(dpr=3),但导出时destWidth设为375,那图片分辨率就只有375px,放大看当然模糊。导出时destWidth建议不小于canvas内部物理像素的一半。
4.2 签名时页面跟着滚动或弹窗穿透
canvas的触摸事件如果不禁用页面滚动,手指在签名区域上下滑动时,整个页面会跟着滚。这个在弹窗场景下尤其难受:弹窗背景是半透明遮罩,用户字体写到一半,遮罩下面的页面动了,视觉上整个屏幕都在晃。解决方案:
- 给canvas加
disable-scroll="{{true}}"属性,这是小程序专门为滚动容器内的原生组件提供的属性,只对canvas生效。 - 给页面根节点加
catchtouchmove="noop",也就是捕获所有touchmove事件但不处理,阻止冒泡导致的页面滚动。 - 如果canvas在scroll-view里,需要给scroll-view设置
scroll-y="{{false}}"。
其中方案1和方案3是互补的,方案2全局禁用页面滚动,适合签名弹窗这种全屏模态的场景。我在项目中是弹窗打开时给page加overflow: hidden,弹窗关闭时移除,同时canvas上保留disable-scroll,双保险。
catchtouchmove="noop"的实现:
javascript复制noop() {
// 阻止默认的滚动行为
return false;
}
4.3 真机导出图片为空白
这个问题在Canvas 2D接口下比较常见。导出空白的原因通常是canvasToTempFilePath没有传canvas参数。旧版接口不需要这个参数,因为全局只有一个canvas上下文,新版接口一个页面可能多个canvas,不传参数时API不知道要导出哪个。如果代码是从旧版迁移过来的,非常容易漏掉这一步。
另外还有一个隐蔽原因:canvas初始化是异步的(需要查节点),如果用户快速点击“确认签名”按钮,而这个时候canvas还没初始化完成,this.canvas是null,调用canvasToTempFilePath会直接报错或导出空白。解决方式是初始化完成前禁用确认按钮,或者初始化时做Promise封装,等初始化完成后才允许后续操作。
javascript复制initCanvasPromise() {
return new Promise((resolve, reject) => {
const query = this.createSelectorQuery();
query.select('#signCanvas')
.fields({ node: true, size: true })
.exec((res) => {
if (!res || !res[0] || !res[0].node) {
reject(new Error('canvas节点获取失败'));
return;
}
// ...初始化逻辑
resolve();
});
});
}
async onModalOpen() {
this.setData({ showSignModal: true });
await this.initCanvasPromise();
this.setData({ signReady: true });
}
4.4 部分安卓机型手写断线
断线的表象是笔画中间有一段空白,像是画笔的墨水断流了。原因主要有两个:一是touchmove触发的坐标点间距过大,两点之间直接lineTo连接时,如果系统touch采样率低,相邻两个点之间距离很大,stroke时直线拉过去会显得生硬,但不是断线;真正的断线通常是因为canvas的宽高在初始化后又被样式修改过,导致绘制坐标系和显示坐标系不一致。比如通过media query或者其他样式覆盖了canvas的宽高,画布内部坐标系还是旧的尺寸,手指画到某个区域后,canvas的显示区域和绘制区域发生偏移,笔迹就“跑偏”了。
排查方法:在真机上打开调试面板,确认canvas的实际宽高和初始化时获取到的res[0].width/height是否一致。如果不一致,把canvas宽高固定在WXSS里,不要用flex布局动态撑开,确保初始化时拿到的尺寸就是最终显示尺寸。
另一个可能导致断线的坑是:在touchmove过程中异步执行了其他操作(比如setData更新页面某个状态),小程序主线程和渲染线程有通信开销,导致touchmove回调被延迟,出现掉帧和笔迹不连续。签名绘制过程中避免做任何setData操作,所有绘制状态都挂在this上,不进data。
4.5 问题排查方法总结
| 现象 | 可能原因 | 排查方案 | 解决方法 |
|---|---|---|---|
| 画线发虚 | 未适配dpr | 检查canvas内部尺寸是否等于CSS尺寸乘以dpr | 设置canvas.width/height乘以dpr并scale |
| 导出空白图片 | canvasToTempFilePath未传canvas参数 | 检查API参数 | 传入当前canvas实例 |
| 签名跟着页面滚动 | 未禁用canvas滚动 | 检查disable-scroll属性 | 开启disable-scroll,页面禁止滚动 |
| 笔迹偏移 | 触摸坐标未换算 | 打印touch对象查看坐标 | 用clientX减canvasRect偏移 |
| 断线掉帧 | 绘制期间执行setData | 检查touchmove里是否操作data | 状态挂this,绘制期间不setData |
| 清空后黑底 | clearRect后背景透明 | 观察清空后的颜色 | 使用putImageData恢复空白状态 |
| 裁剪偏移 | 像素坐标与CSS坐标混淆 | 检查裁剪参数是否除以dpr | 裁剪参数统一除以dpr |
| 上传失败超时 | 后端处理耗时过长 | 看后端日志 | 后端改成异步处理先返回 |
4.6 微信开发者工具与真机表现不一致的问题
程序包在开发者工具上一切正常,一到真机就问题百出,这是小程序开发的常态。我在签名功能上遇到的典型差异有:
- 字体渲染差异。开发者工具用的是PC的字体渲染引擎,画出来的线条平滑度和真机差距很大,但这不影响功能,只是视觉观感不同,不用纠结。
getImageData在开发者工具上性能很好,真机上部分安卓机型明显卡顿,所以别在touchmove里调用。- 触摸坐标在开发者工具里鼠标模拟触屏,坐标系和真机手指触摸有一些区别,真机上
touch.x和touch.y在Canvas 2D下是相对canvas的坐标,开发者工具里可能变成相对页面的坐标,这就导致同一个代码在工具里正常,真机上偏移。解决方法是统一用clientX减偏移的方式,工具和真机行为一致。 wx.canvasToTempFilePath在开发者工具上的输出目录是本地临时目录,可以直接打开查看;真机上输出的是沙箱目录,需要通过wx.saveImageToPhotosAlbum保存到相册才能肉眼确认结果。调试时可以临时加一个保存到相册的按钮,方便真机验证。
5. 签名功能的扩展思路与性能优化建议
5.1 签名笔迹的按压感应增强
如果业务要求更高,希望笔迹能体现书写力度(类似Apple Pencil的压感效果),受限于触摸屏硬件和浏览器API,小程序里的方案一般是把线宽和速度关联起来:速度快的地方线细,速度慢的地方线粗。思路是维护一个lastTime和lastPoint,计算当前点与上一个点的距离除以时间差得到速度,然后根据速度范围映射线宽。
javascript复制onTouchMove(e) {
const pos = this.getTouchPosition(e);
if (!pos) return;
const now = Date.now();
const dt = now - this.lastTime;
const dx = pos.x - this.lastPoint.x;
const dy = pos.y - this.lastPoint.y;
const dist = Math.sqrt(dx * dx + dy * dy);
const speed = dist / dt; // 像素/毫秒
// 速度越快线越细
const baseWidth = 4;
const minWidth = 1.5;
let lineWidth = baseWidth - speed * 0.05;
if (lineWidth < minWidth) lineWidth = minWidth;
this.ctx.lineWidth = lineWidth;
this.ctx.lineTo(pos.x, pos.y);
this.ctx.stroke();
this.lastPoint = pos;
this.lastTime = now;
}
这个方法做出来的笔迹比固定线宽有质感得多,但不要太激进——如果线宽变化太剧烈,字体看起来会忽粗忽细,反而不如统一线宽干净利落。建议把线宽范围限制在1.5到5之间。
5.2 多签名场景与签名模板的结合
实际业务中,用户往往不只在一个地方签名。比如一份合同里可能有“客户签字”、“业务员签字”两个位置。此时不要为每个位置单独建一个canvas,而是建一个canvas,用户签完一个位置后把图片导出来存到对应位置,然后清空画布继续签下一个。签名完成后,把多个签名图片通过canvas合成到合同模板图片上:
- 用
wx.getImageInfo加载合同模板图片 - 创建离屏canvas(页面上不可见的canvas),把模板画上去
- 再把不同位置的签名图片按坐标画到模板上
- 最后导出整张合同
这个方案的优势是最终的合同图片是单张,方便后端归档和用户下载。实际实现时,离屏canvas建议用wx.createOffscreenCanvas,它不占用页面布局,性能也比页面上的canvas好。
当然,这不代表所有场景都要在端上合成。如果合同模板经常变化,端上维护模板坐标的成本很高,可以改为端上只上传裁剪好的单个签名图片,由后端在服务端合成。两种方案各有优劣:端上合成节省上传流量,但模板更新要发版;后端合成灵活,但需要后端具备图片处理能力。我这里给出一个判断标准:签名位置少于5个且模板不常改动,用端上合成;反之用后端合成。
5.3 性能优化:降低画布像素量级
前面提到过的性能问题这里再总结一下。签名画布的CSS尺寸一般是350x200左右,dpr=3时物理像素是1050x600,总像素63万,这个量级对getImageData和putImageData来说还可以接受。但如果你把画布尺寸搞到750rpx宽(约375px),dpr=3时还是1050x600,影响不大。真正影响性能的是在drawImage合成时用了大图,或者在一帧内重复调用getImageData。
优化建议:
- 初始化时锁定画布尺寸,不在运行时修改。
getImageData只在导出裁剪时调用一次,不在绘制过程中调用。putImageData清空时虽然是恢复了10MB左右的像素数据,但这是同步操作,一次性执行,不会卡顿。- 连续stroke时注意不要每次move都beginPath再stroke,这样会造成大量细碎路径,性能反而不如一次连续路径多次stroke。
5.4 签名数据的存储与后续使用
签名图片上传后,后端会返回一个图片URL。前端在展示历史合同时,用<image>标签直接加载URL即可。如果签名图片涉及隐私(比如客户在营业厅的pad上签字),建议后端对图片做访问鉴权,避免URL泄露后被随意查看。
对于签名数据本身,如果需要审计追溯(签字人、签字时间、IP等),前端上传时一定要把业务ID、用户ID、时间戳都通过formData传到后端,由后端一并落库。不要只传图片不传业务上下文,否则后期核对时根本不知道这张签名属于哪笔订单。
6. 写在最后的实操心得
这个签名功能从开发到落地,最耗时间的不是画线逻辑本身,而是各种真机适配和导出裁剪的细节。真机测试永远比开发者工具早一步发现问题,每调整一次canvas相关的代码,都要在至少一台iOS和一台Android上跑一遍,两个平台的表现差异比想象中大得多。
我个人的经验是:canvas相关功能一定要尽早做真机预览,不要等整个页面写完再统一测。因为canvas的尺寸、坐标、导出这些问题,和页面其他业务的耦合度很高,等到最后再排查,定位问题的时间成本会成倍增加。另外,调试时建议在页面上临时加一个“保存到相册”的按钮,虽然审核时要去掉,但在开发阶段能肉眼确认导出图片的效果,比光看base64字符串高效太多了。
有一个细节我到现在还在坚持:签名区域下方一定要有一行提示文案,比如“请使用正楷签名”,或者“签名后将视为您本人确认”。这个小细节在业务侧价值很大,既提升了合同签署的严肃性,也在后续产生纠纷时作为证据链的一部分。功能实现是一回事,把功能放进业务场景里考虑,才是工程价值的真正体现。
如果后续有时间,我计划把手写签名升级成支持毛笔效果——通过贝塞尔曲线平滑拟合触摸轨迹,再叠加纹理笔刷,出来的笔迹会接近真实毛笔的提按顿挫。这个方向技术上完全可行,关键还是性能,等canvas相关的性能瓶颈进一步解决之后再动手。
