拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

移动端Canvas手写签名实现:横竖屏适配与底图旋转实践

移动端Canvas手写签名实现:横竖屏适配与底图旋转实践

写这个项目的时候,是因为产品经理给我丢了一个需求:要在一个App内嵌的H5页面里做电子签名,而且用户可能在平板上横着签,也可能在手机上竖着签,签名完之后还要能叠加到一张模板图片上,模板图还得允许用户自己旋转角度对准位置。说实话,当时我脑子里第一个想法是“这不是随手就能搞定的活”,但真正动手做的时候,才发现移动端Canvas手写签名这一套东西,坑比想象中多得多。

这篇文章就把整个项目的实现过程和踩坑记录完整聊一遍,涵盖横竖屏适配、Canvas分辨率处理、签名轨迹采集、图片旋转叠加这些核心环节,代码可以直接拿去改,适配你自己的业务场景。无论你是刚入行的前端新人,还是被“移动端签字”这种需求砸中的老油条,这篇文章应该都能帮你省下不少试错时间。

1. 项目整体设计与需求拆解:为什么选Canvas而不是原生组件

1.1 核心需求解析

先把这个需求拆开看,表面上是“做个手写签名”,但实际拆解开有四个独立的小需求:

  • 支持移动端手写采集,触摸跟手、笔迹顺滑,不能有断线或者延迟感。
  • 同时适配横屏和竖屏,设备方向变化时画布不能变形。
  • 用户能上传一张底图(比如合同扫描件、�ping单据照片),签名需要叠加在底图上面。
  • 底图允许旋转角度,用户手动调整,签名的位置可以自由摆放。

这四个需求叠加在一起,技术选型其实没有太多悬念。移动端手写输入的方案里,原生input或者div模拟跟手效果都很差,Canvas是浏览器天生的绘图画布,配合touch事件天然适合做轨迹采集。Web端也有些签名库比如signature_pad,但那个库在移动端横竖屏切换和自定义底图旋转这块支持得比较弱,自己封装反而更灵活。

1.2 技术选型背后的考量

为什么不用SVG?SVG虽然也有路径绘制能力,但在移动端高频触摸事件下,DOM节点的频繁更新会带来明显的性能损耗。Canvas的优势在于它是一整块像素位图,所有绘制操作都在底层位图上进行,浏览器只需要在每一帧重绘指定区域,不需要频繁操作DOM树,性能开销小得多。

为什么不用WebGL?WebGL的性能确实更强,但做二维手写签名完全是杀鸡用牛刀。Canvas 2D的渲染管线足够应付每秒上百次的触摸采样,而且Canvas 2D导出图片的方式极其简单,直接通过toDataURL就能拿到base64的图片数据,跟后端对接或者转存都非常方便。

竖屏签名和横屏签名的场景有很大不同。竖屏时用户通常签的是简短的姓名或者“同意”两个字,签名区域窄长。横屏时用户可能会签比较完整的意见或者长段文字,签名区域宽扁。同一个Canvas组件要在两种方向下都能正常工作,不能写死宽高,必须基于屏幕的实时尺寸动态计算。

注意:这个需求里最关键的一点是“底图旋转”。很多人第一反应是直接用CSS transform旋转底图,但CSS旋转只改了显示效果,最终导出的图片里底图还是原来的角度。必须用Canvas自身的坐标系变换去旋转绘制,才能保证导出图片时旋转效果被真正“画”进去。

1.3 整体方案架构

我采用的方案是“双Canvas分层绘制”。底层Canvas负责绘制上传的底图和旋转效果,上层Canvas负责采集手写笔迹,两层Canvas在视觉上完全重叠。导出时新建一个临时Canvas,按顺序先把底图(含旋转效果)绘制上去,再把签名笔迹绘制上去,最后导出整张图片。

采用双Canvas而不是单Canvas,核心原因是性能与交互的平衡。签名过程中的触摸事件触发频率极高,上层Canvas在每次touchend之后才把当前笔画合并到底层,中间过程只重绘当前正在绘制的那一笔,避免整张底图每帧都重绘导致卡顿。

2. 核心细节解析:分辨率和坐标计算,手写签名不糊的关键

2.1 Canvas尺寸与移动端分辨率的适配原理

直接设置Canvas的CSS宽高,然后按CSS尺寸绘制,在移动端几乎一定会出问题——画出来的字是糊的。原因在于移动端设备的物理分辨率(设备像素)和CSS逻辑分辨率(CSS像素)之间存在一个比例关系,这个比例就是window.devicePixelRatio,在iPhone上通常是2或者3,在安卓机型上从1.5到3.5都很常见。

Canvas元素本身有两套尺寸概念。一套是CSS尺寸,决定它在页面上占多大显示区域;另一套是绘图缓冲区尺寸,也就是Canvas的width和height属性,决定实际有多少个像素点可以绘制。如果两者一致,那么在Retina屏幕上,一个CSS像素对应多个物理像素,但Canvas只能使用其中一个物理像素来绘制色彩信息,其余像素只能用相邻像素的色彩信息插值填充,结果就是文字和线条边缘发虚。

解决办法也简单,把Canvas的绘图缓冲区尺寸乘以devicePixelRatio,然后把整个绘图上下文按同样比例缩放。代码写法是:

const dpr = window.devicePixelRatio || 1; canvas.width = cssWidth * dpr; canvas.height = cssHeight * dpr; ctx.scale(dpr, dpr);

这样一来,绘图缓冲区就有足够的物理像素去对应屏幕上的显示区域了。后续所有绘制代码都按CSS像素坐标系来写,不需要额外处理坐标换算。

2.2 触摸坐标的正确换算

移动端触摸事件的坐标信息有几种不同的来源。touch.clientX和touch.clientY是相对于浏览器可视区域左上角的坐标,touch.pageX和touch.pageY是相对于整个页面的坐标,touch.screenX和touch.screenY是相对于屏幕左上角的坐标。

在常规移动端H5场景下,页面没有滚动或者只有一个方向的滚动,使用clientX和clientY最合适。但是Canvas元素在页面上的位置不一定从(0,0)开始,还需要减去Canvas自身在页面中的偏移量。通过getBoundingClientRect()可以拿到Canvas相对于视口的精确位置。

const rect = canvas.getBoundingClientRect(); const x = touch.clientX - rect.left; const y = touch.clientY - rect.top;

这里面有个隐藏的坑:如果在CSS中设置了touch-action: none,触摸事件不会触发浏览器的默认滚动行为;如果没有设置,手指在Canvas上滑动时会带动整个页面滚动,导致触摸坐标和Canvas实际位置错位,字迹就花了。

2.3 笔画平滑处理的最优解

手写签名最忌讳的就是笔迹生硬、棱角分明。连续触摸采样得到的一系列坐标点,如果用直线段直接连接,快速书写时会出现明显的折线感。

解决思路是使用二次贝塞尔曲线替代直线段。具体做法是取三个相邻的点,以前一个点和后一个点的中点作为贝塞尔曲线的起点和终点,以中间点作为控制点。这样每两个采样点之间绘制的都是一段平滑的曲线,整体轨迹非常圆润。

ctx.beginPath(); ctx.moveTo(prevMidX, prevMidY); ctx.quadraticCurveTo(currentX, currentY, midX, midY); ctx.stroke();

实测下来,使用贝塞尔曲线之后,笔迹的平滑度和书写舒适感提升非常明显。不过要注意,如果采样点间隔太大(说明手指移动速度很快),可以适当对坐标做插值,在两点之间生成中间点,避免曲线变形。

2.4 底图旋转的中心坐标计算

底图旋转是整个项目里最容易出错的部分。Canvas的rotate()方法是围绕坐标系原点旋转的,如果直接调用,图片会绕着Canvas左上角转,效果完全不对。

正确做法是先通过translate()把坐标系原点移动到画布的中心,然后旋转,再把坐标系原点移回来。这样图片就是围绕画布中心旋转了。代码如下:

ctx.save(); ctx.translate(canvasWidth / 2, canvasHeight / 2); ctx.rotate(angle * Math.PI / 180); ctx.drawImage(img, -imgDrawWidth / 2, -imgDrawHeight / 2, imgDrawWidth, imgDrawHeight); ctx.restore();

这里有一个经验要点:在实际项目中,底图往往需要等比缩放适配画布,而旋转后的底图可能出现暴露出画布底色的情况(比如底图是竖长的,旋转90度后就横向超出了画布范围)。所以旋转前要计算底图的最大边,确保旋转后能完整覆盖整个Canvas区域。

计算方式是:底图相对于画布中心旋转θ角后,其四个顶点可能超出画布边界,安全尺寸应当取长 * cosθ + 宽 * sinθ和长 * sinθ + 宽 * cosθ中的较大值。为了简化,代码里直接按45度角时的最大尺寸来缩放底图,保证任何角度都不会露白。

3. 核心代码实现:签名板完整开发过程

3.1 页面结构与基础样式

移动端页面首先需要设置viewport,禁止用户缩放,同时保证页面占满整个屏幕。HTML结构很简单,一个包裹容器加两个Canvas。

<div id="signWrap" class="sign-wrap"> <canvas id="bgCanvas"></canvas> <canvas id="signCanvas"></canvas> <div id="signTip">请在此区域手写签名</div> </div>
.sign-wrap { position: relative; width: 100%; height: 100vh; overflow: hidden; touch-action: none; background: #fff; } .sign-wrap canvas { position: absolute; top: 0; left: 0; width: 100%; height: 100%; }

touch-action: none很关键。如果不加,移动端浏览器会在触摸滑动时触发页面滚动或者双指缩放,签名过程中页面会“跟着跑”。

3.2 Canvas初始化与分辨率适配

初始化时读取容器的宽高,按devicePixelRatio设置Canvas的绘图缓冲区尺寸。

function initCanvas() { const wrap = document.getElementById('signWrap'); const bgCanvas = document.getElementById('bgCanvas'); const signCanvas = document.getElementById('signCanvas'); const dpr = window.devicePixelRatio || 1; const cssW = wrap.clientWidth; const cssH = wrap.clientHeight; [bgCanvas, signCanvas].forEach(canvas => { canvas.width = cssW * dpr; canvas.height = cssH * dpr; canvas.style.width = cssW + 'px'; canvas.style.height = cssH + 'px'; const ctx = canvas.getContext('2d'); ctx.scale(dpr, dpr); }); // 保存全局变量供后续绘制使用 window.__signDpr = dpr; window.__signCssW = cssW; window.__signCssH = cssH; }

有一个细节值得说明:两层的Canvas都需要执行ctx.scale(dpr, dpr)。底图Canvas如果不缩放,代码里绘制图片时的尺寸单位会与预期不符,导出时会出现图片只画出了一部分的问题。

3.3 触摸事件采集与笔迹绘制

签名Canvas绑定触摸事件,维护一个isDrawing标记和一条当前路径的点集数组。

const signCtx = signCanvas.getContext('2d'); let isDrawing = false; let points = []; signCanvas.addEventListener('touchstart', (e) => { e.preventDefault(); const point = getCanvasPos(e.touches[0]); points = [point]; isDrawing = true; signCtx.beginPath(); signCtx.moveTo(point.x, point.y); }); signCanvas.addEventListener('touchmove', (e) => { e.preventDefault(); if (!isDrawing) return; const point = getCanvasPos(e.touches[0]); points.push(point); if (points.length < 3) { // 点数不够时直接画线段,保证响应速度 signCtx.lineTo(point.x, point.y); signCtx.stroke(); return; } // 使用贝塞尔曲线平滑 const midPoint = getMidPoint(points[points.length - 2], points[points.length - 1]); signCtx.quadraticCurveTo(points[points.length - 2].x, points[points.length - 2].y, midPoint.x, midPoint.y); signCtx.stroke(); }); signCanvas.addEventListener('touchend', (e) => { e.preventDefault(); if (!isDrawing) return; isDrawing = false; // 将当前画布内容固化 }); function getCanvasPos(touch) { const rect = signCanvas.getBoundingClientRect(); return { x: touch.clientX - rect.left, y: touch.clientY - rect.top }; } function getMidPoint(p1, p2) { return { x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2 }; }

绘制样式的设置里有两个常用的参数:线条宽度(lineWidth)和线条样式(lineCap、lineJoin)。签名笔迹建议lineWidth控制在3到4像素,lineCap和lineJoin都用round,这样笔画起笔和转折处都是圆润的,比较接近真实笔触。

3.4 横竖屏切换的动态重绘

移动端横竖屏切换时,页面的宽高会互换。Canvas不能直接用原来的宽高,必须重新初始化,而且之前画在Canvas上的内容会丢失,需要重绘。

用orientationchange事件监听屏幕方向变化,配合resize事件做兼容兜底。方向变化后,需要延迟一段时间再重新初始化,因为移动端浏览器在方向变化事件触发时窗口尺寸还没更新完毕,直接读取会出现宽高不对的问题。

window.addEventListener('orientationchange', () => { setTimeout(() => { // 保存当前签名内容为图片数据 saveSignSnapshot(); // 重新初始化画布 initCanvas(); // 重绘底图 drawBackgroundImage(); // 恢复签名内容 restoreSignSnapshot(); }, 300); });

这里saveSignSnapshot用getImageData保存签名Canvas的所有像素数据,重绘时再通过putImageData恢复。这样用户横屏写完,转成竖屏之后签名内容不会丢失。

getImageData保存的是Canvas坐标系下的像素数据,Canvas尺寸变化后直接putImageData可能出现错位。解决方法是把保存的数据按照新的Canvas尺寸重新绘制到临时Canvas上进行自适应居中。实际操作中我们按“签名内容在原画布中的相对位置比例”进行恢复,保证签名能完整显示。

3.5 底图上传与旋转实现

底图通过input[type=file]上传,用FileReader读取并转换成Image对象。

function loadBackgroundImage(file) { const reader = new FileReader(); reader.onload = (e) => { const img = new Image(); img.onload = () => { window.__bgImg = img; drawBackgroundImage(); }; img.src = e.target.result; }; reader.readAsDataURL(file); }

底图绘制时按比例缩放到画布区域。不直接拉伸填满的原因是为了避免图片变形。

function drawBackgroundImage() { const img = window.__bgImg; if (!img) return; const ctx = bgCanvas.getContext('2d'); ctx.clearRect(0, 0, window.__signCssW, window.__signCssH); ctx.save(); const centerX = window.__signCssW / 2; const centerY = window.__signCssH / 2; ctx.translate(centerX, centerY); ctx.rotate((window.__bgAngle || 0) * Math.PI / 180); // 计算缩放比例,保证图片旋转任意角度能完全覆盖画布 const rawRatio = img.width / img.height; const canvasRatio = window.__signCssW / window.__signCssH; let drawW, drawH; if (canvasRatio > rawRatio) { drawW = window.__signCssW * 1.5; drawH = drawW / rawRatio; } else { drawH = window.__signCssH * 1.5; drawW = drawH * rawRatio; } ctx.drawImage(img, -drawW / 2, -drawH / 2, drawW, drawH); ctx.restore(); }

缩放系数取1.5而不是1.2的原因是,旋转角度为45度时图片对角线长度会显著增加,如果缩放系数太小,图片旋转后四角会露出空白。

旋转操作使用slider控件实时调整角度。用户拖动slider时,监听input事件更新角度值并重新绘制底图。

document.getElementById('angleSlider').addEventListener('input', (e) => { window.__bgAngle = parseFloat(e.target.value); drawBackgroundImage(); });

3.6 签名图片导出

导出是签名场景的收尾动作。新建一个临时Canvas,将底图和签名按顺序绘制上去,然后toDataURL转成base64文件流。

function exportSignature() { const tempCanvas = document.createElement('canvas'); tempCanvas.width = window.__signCssW; tempCanvas.height = window.__signCssH; const tempCtx = tempCanvas.getContext('2d'); // 绘制白色背景 tempCtx.fillStyle = '#ffffff'; tempCtx.fillRect(0, 0, tempCanvas.width, tempCanvas.height); // 将底图Canvas内容绘制到临时画布 tempCtx.drawImage(bgCanvas, 0, 0, window.__signCssW, window.__signCssH); // 将签名Canvas内容绘制到临时画布 tempCtx.drawImage(signCanvas, 0, 0, window.__signCssW, window.__signCssH); return tempCanvas.toDataURL('image/png'); }

如果要导出非白色背景,或者透明背景的签名图层,只需要去掉填充白色背景那一步,导出的图片就是透明底。这个在电商签收回执、电子合同盖章场景很常用。

3.7 操作界面与用户交互

完整的签名板需要提供这三个核心操作:清除重写、撤销上一步和确认提交。清除就是clearRect清空签名Canvas,撤销则需要维护一份历史快照栈。

let historyStack = []; function saveHistory() { historyStack.push(signCanvas.toDataURL('image/png')); if (historyStack.length > 10) { historyStack.shift(); } } function undo() { if (historyStack.length === 0) return; const img = new Image(); img.onload = () => { signCtx.clearRect(0, 0, window.__signCssW, window.__signCssH); signCtx.drawImage(img, 0, 0, window.__signCssW, window.__signCssH); historyStack.pop(); }; img.src = historyStack[historyStack.length - 1]; }

在touchend时保存历史快照,只在每次笔画结束时调用,避免把同一笔中间过程存进历史。

4. 常见问题与排查技巧实录

4.1 签名识别率低的问题

实际项目里如果要接OCR识别签名,经常发现识别率很低。原因通常是签名笔迹线宽太小,或者线宽不均,边缘模糊。提高识别率有三个实用策略:

  • 导出给OCR引擎时,将图片做二值化处理,把浅色像素全部转为白色,深色像素转为黑色。
  • 在绘制时增加像素级描边处理,保证笔迹的最细处不少于2个物理像素。
  • 导出前对图片做一次锐化处理,提升边缘对比度。

有个取巧的办法就是在签名结束后叠加一层整页的二值化滤镜。先用ctx.filter = 'contrast(2) grayscale(1)',把签名Canvas重新绘制到临时Canvas上,再做导出,识别率能提升一个档次。

4.2 横竖屏切换丢内容

这是签名组件最容易暴露的问题。大多数实现只是在orientationchange后重新初始化Canvas,但是忽略了保存和恢复签名内容。

推荐做法是在beforeunload和页面隐藏时都做一次快照保存,防患于未然。操作上可以用visibilitychange事件,当页面进入后台时保存签名快照和底图数据,回前台后自动恢复。

还要注意,iOS的Safari在横竖屏切换时会出现约300ms左右的延迟才更新window.innerWidth和innerHeight,所以定时器只用300ms还不够保险,建议在初始化时用requestAnimationFrame轮询检测宽度变化,等宽度值稳定后再执行重绘。

4.3 安卓低端机掉帧断线

安卓低端机上的Canvas签名经常出现笔画断线、跟手性差的问题。排查方向有几个:

  • 检查触摸事件类型。部分安卓WebView对touchmove连续触发频率有限制,如果有明显掉帧,可以尝试PointerEvent(pointermove)替代。
  • 降低Canvas的dpr系数。很多低端安卓机的devicePixelRatio是3,但GPU性能跟不上,此时可以设置const dpr = Math.min(window.devicePixelRatio || 1, 2),牺牲一点清晰度换流畅度。
  • 绘制时减少不必要的状态切换。每次stroke()前不要重复设置strokeStyle、lineWidth,可以只在初始化时设置一次。
  • 移除touchmove过程中的console.log、DOM操作等额外开销。

实测下来,把dpr钳制到2之后,千元安卓机上的流畅度改善非常明显,清晰度损失肉眼几乎不可见。

4.4 Canvas内容被App内嵌WebView缩放

App内嵌H5页面时,如果原生App端开启了页面缩放,或者WebView的viewport设置有误,会导致Canvas绘制内容在视觉上被缩放,签名大小与手写轨迹不一致。

排查步骤:

  1. 检查index.html里<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">是否配置正确。
  2. 在App的WebView设置中关闭手势缩放(如Android的setSupportZoom(false))。
  3. 页面CSS中禁用用户选择:-webkit-user-select: none; user-select: none;。

4.5 常见问题速查表

问题现象可能原因解决方案
签名模糊发虚Canvas分辨率未按dpr调整按devicePixelRatio设置宽度和高度并scale
手写过程中页面滚动缺少touch-action禁止设置touch-action: none
横竖屏切换后签名丢失未保存快照或恢复时机太早用orientationchange延时300ms+快照恢复
图片旋转后四角露白底图缩放比例不足扩大缩放到画布尺寸的1.5倍
笔迹出现折线感使用lineTo直连改用quadraticCurveTo贝塞尔平滑
导出图片被截断Canvas尺寸与CSS尺寸不一致统一乘以dpr再绘制并导出

4.6 性能优化的几个实测技巧

签名场景的性能优化,原则是“减少每一帧的工作量”。我实际用下来最有效的手段:

  1. 按需重绘。签名移动过程中,只绘制从上一采样点到当前位置的线段,不清空画布、不触发整帧重绘。

  2. 避免频繁保存和恢复上下文。ctx.save()和ctx.restore()是有开销的,在touchmove这种高频回调里尽量少调用,可以只在底图绘制时使用。

  3. 减少Canvas尺寸突变。横竖屏切换时Canvas会整体重建,这种场景无法完全避免,但可以先把旧的Canvas位图保存为ImageBitmap,快速恢复。

  4. 合并多个笔画。签名过程中如果用户连续书写,不要每一笔都单独走一次保存和绘制流程,而是把所有笔画统一在touchend时绘制到同一个Canvas层。

另外提一句,如果签名板要嵌到比较复杂的H5页面里,建议把签名区做成独立的全屏浮层。这样既能拿到最大的书写面积,又能避免外层页面滚动、导航栏遮挡等问题。全屏浮层方案配合position: fixed,触控坐标的计算也简单很多。

5. 真实项目中的总结与补充建议

这个签名组件开发完大致花了三个工作日,第一天搭框架、第二天调样式和交互、第三天主要在处理各种机型适配和WebView兼容性问题。

我个人经验里最重要的一点是:**移动端Canvas签名,花在“设备适配”上的时间一定超过“绘制逻辑”本身。**代码逻辑上的手写轨迹采集其实很成熟,真正麻烦的是Retina屏的清晰度、横竖屏切换的尺寸变化、安卓机型性能差异,还有各种WebView环境下的行为不一致。建议在开发初期就准备好一张常见机型的测试清单,覆盖iOS、主流安卓、个别低端安卓机,每改一轮都回归一遍。

底图旋转这个功能加得比较划算。产品设计上只花了很小的成本,但用户体验提升却非常明显。之前在别的项目里,用户遇到图片方向不对只能重新上传,现在旋转一下就能对准了。如果后续要迭代,可以考虑再加一个双指缩放的功能,让用户既能旋转又能缩放底图,这样签名位置调整的自由度会更大。

签名数据的落地方式也要提前想清楚。toDataURL输出的base64字符串体积比较大,一张A4尺寸的签名图动辄几百KB。如果后端接口对数据大小有限制,可以在前端先用Canvas把图片压一压,一般质量系数设为0.7到0.8就足够清晰了。如果是高频签收场景,建议走文件上传而不是base64字符串传输,减少内存压力。

最后再分享一个小技巧:在Canvas下方叠一层慢速度的text-shadow提示文字(比如“请在此处签名”)能让用户的交互引导感强很多,而且完全不影响导出图片,用CSS加在Canvas容器上就行。

.sign-wrap::after { content: '请在此区域手写签名'; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); color: #ccc; font-size: 18px; pointer-events: none; }

等用户第一笔落下之后,把这层提示文字隐藏,体验细节上会显得比大部分签名组件都用心。

返回列表