ARTICLE DETAIL

资讯详情

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

微信小程序手写板实现:基于Canvas 2D的签名、批注与绘图方案

微信小程序手写板实现:基于Canvas 2D的签名、批注与绘图方案 简介微信小程序手写板.zip 是一份面向小程序开发者的手写板源码项目适合想实现手写输入、电子签名或绘图功能的初中级开发者学习复用。项目基于 Canvas 组件完成画布绘制并通过 touchstart、touchmove、touchend 等触摸事件采集笔迹结合路径绘制、颜色与笔触粗细控制、笔迹保存与回放、撤销与重做等模块覆盖了手写板的核心功能链路。压缩包共 21 个文件以 js 逻辑代码、json 配置、png 示例图标和 wxss 样式为主另附 wxml 页面结构、gif 演示及 README 说明整体仅 799KB结构紧凑便于快速查阅。已有 348 人下载学习适合直接借鉴其绘制逻辑并接入自身小程序项目也可作为学习微信小程序 Canvas 交互设计的参考案例。1. 微信小程序手写板.zip解压后不只是一块画布还连着签名、批注与绘图三类场景拿到微信小程序手写板.zip这类交付包第一反应别急着去看代码量。它解决的是小程序里“用手指/触控笔留下笔迹”的通用需求电子合同签名、老师给学生批注作业、直播间弹幕涂鸦、甚至答题纸上手写算草。和网页里随手贴一个插件不同小程序没有 DOM、没有现成的 canvas 组件之外的直接绘图入口大部分人会先找第三方组件但其实原生 canvas 2d 就能把这件事做干净核心绘制代码不超过 150 行。这个 zip 值得琢磨的地方不在“能画”而在画布适配、笔迹回放、导出图片和撤销重绘这些交付层面的细节——这些才是真正卡住新手的地方。2. 手写板选型与原理为什么认准 canvas 2d旧接口要不要留2.1 三种手写板实现方案对比为什么最终落到 canvas 2d小程序里做一个手写板常见做法有三种。最粗暴的是用 view 堆像素点每个触摸点生成一个绝对定位的小圆点点足够密也能看出笔画但性能撑不住一百个点之后页面开始掉帧而且笔迹无法做曲线连接。第二种是用旧版 canvas 接口也就是wx.createCanvasContext这个接口在基础库 2.9.0 之后进入维护状态官方建议新项目直接用 Canvas 2D 接口type2d。第三种就是 Canvas 2D通过 SelectorQuery 拿到 canvas 的 node 节点再getContext(2d)获得和 Web 端几乎一致的绘图上下文。实现方案性能笔迹平滑度真机兼容维护状态view 堆点差差一般无旧版 canvas 接口中中有历史兼容问题维护模式canvas 2d node好好主流推荐第三方组件不是不能用签名宝、painter 这类组件封装了很多业务逻辑但引入体积、自定义线宽和导出格式都要受制于人。手写板本质就是一个自带触摸事件绘图的 canvas自己写的成本很低zip 里的源码也是按这个思路组织的一个 wxml 放 canvas一个 js 文件处理触摸事件剩下的就是参数调优。2.2 一笔笔画是怎么产生的触摸事件到坐标点的映射手写板的物理原理不复杂手指按下去时触摸事件按固定频率上报坐标我们把这一次按下到抬起的坐标点连起来再给线段套上宽度、颜色、端点样式画出来就是笔迹。这里最容易被忽略的是坐标系。canvas 2d 接口在触摸事件里直接给到e.touches[0].x和e.touches[0].y这两个值已经是相对 canvas 左上角的坐标不需要再减getBoundingClientRect的偏移值——这是新接口比旧接口顺手的地方旧接口给的clientX/clientY是屏幕坐标不做偏移换算就会画偏。2.3 先做对画布初始化node 获取、像素比与坐标系缩放动手画之前canvas 节点要先经历一次“实例化”。这一步在旧接口里是wx.createCanvasContext一把梭在 canvas 2d 下要区分物理像素和逻辑像素。小程序 rpx 布局给到 canvas 的是逻辑尺寸比如stylewidth: 600rpx; height: 400rpx但底层绘图缓冲区按物理像素算不做像素比适配的话画出来的线在 iPhone 真机上会发虚。initCanvas() { const query wx.createSelectorQuery().in(this) query.select(#signCanvas) .fields({ node: true, size: true }) .exec((res) { const canvas res[0].node const width res[0].width const height res[0].height // 注意低版本基础库用 wx.getSystemInfoSync()新库可直接用 wx.getWindowInfo() const dpr (wx.getWindowInfo ? wx.getWindowInfo() : wx.getSystemInfoSync()).pixelRatio canvas.width width * dpr canvas.height height * dpr const ctx canvas.getContext(2d) ctx.scale(dpr, dpr) this.canvas canvas this.ctx ctx this.dpr dpr this.width width this.height height this._ready true }) }这段代码里几个参数要留意dpr是设备像素比iPhone 一般是 3Android 从 1.5 到 3 不等canvas.width width * dpr把绘图缓冲区撑到物理分辨率ctx.scale(dpr, dpr)让后续所有绘制指令仍然按逻辑坐标书写。如果不做 scale后面所有坐标都要手动乘 dpr撤销和导出时坐标换算非常容易翻车。画布初始化要在页面onReady之后调用wx.createSelectorQuery().in(this)里的in(this)在自定义组件里必须带否则查不到组件内部节点。2.4 一笔笔迹的数据结构为撤销、重绘、导出设计存储新手写手写板最容易图省事画一笔就ctx.stroke()一笔画完就完事。等要加撤销功能时才发现无从下手。正确做法是每次触摸都记录笔画数据包括笔迹的样式和坐标点数组。后面无论是整板清除、撤销上一步还是导出图片都从这份数据里重放绘制画布本身只是数据的投影。// 一笔的数据结构 const stroke { type: stroke, width: 4, // 笔画线宽 color: #000000, // 笔画颜色 points: [ // 坐标点按时间序 { x: 12, y: 43 }, { x: 14, y: 47 } ] } // 整个画布是 strokes 数组撤销就是 pop 掉最后一笔再整体重绘 this.strokes.push(stroke)为什么要存坐标点而不是存一张图因为撤销到第 n-2 步时如果用 canvas 缓存快照内存和恢复逻辑都会复杂而坐标点重放是确定性操作只需要在每一轮 touchmove 里记录最新坐标成本很低。内存上一笔 100 个点、一个页面几十笔也就几 KB 的数组完全无压力。这个设计决议在 zip 源码里是核心如果你拿到手发现代码里有撤销功能先看看它是不是基于strokes重放实现的——如果是直接在 canvas 上偷偷改像素后续导出多尺寸图像一定会出幺蛾子。3. 把微信小程序手写板跑通触摸画线的三个关键方法与参数调节3.1 落笔touchstart 里先画一个点而不是等 move很多人写手写板第一版是这样touchstart 只记录起点touchmove 才lineTo。结果在真机上快速点一下立刻抬手屏幕上没有留下任何痕迹——因为触摸移动事件可能只触发了一两次甚至没触发而 start 到 end 之间没有绘制指令。落笔必须在touchstart里实际画出一个点哪怕只是画一段极短的线。onTouchStart(e) { if (!this._ready) return const touch e.touches[0] const ctx this.ctx ctx.beginPath() ctx.moveTo(touch.x, touch.y) ctx.lineTo(touch.x 0.01, touch.y 0.01) ctx.stroke() // 记录这一笔的起始点 this._currentStroke { width: this.brush.width, color: this.brush.color, points: [{ x: touch.x, y: touch.y }] } }画了lineTo(x 0.01, y 0.01)是为了让 stroke 指令真正生效加 0.01 而不是 0 是因为某些基础库版本里moveTo和lineTo同一个点会导致路径为空不触发渲染。如果你只调用ctx.rect(touch.x, touch.y, 0.5, 0.5)填充也能达到类似效果但线宽没法统一。这里的this.brush.width和this.brush.color是画笔参数对象界面上的粗细滑块和色板直接改这个对象后续绘制都会读取它。3.2 行笔touchmove 用上一个点连新点避免半透明叠加touchmove 是高频事件核心思路是“从上一点连接到当前点”而不是从路径起点重新画一遍。如果每次都beginPath moveTo(起点) lineTo(当前)前半段笔迹会被重复渲染浅色画笔会出现一截浓一截淡的“叠影”这就是很多人说的笔迹发花。onTouchMove(e) { if (!this._ready) return const touch e.touches[0] const ctx this.ctx const points this._currentStroke.points const lastPoint points[points.length - 1] // 过滤掉距离过近的点减少无效渲染 const minDist 1.5 const dx touch.x - lastPoint.x const dy touch.y - lastPoint.y if (Math.sqrt(dx * dx dy * dy) minDist) return ctx.beginPath() ctx.moveTo(lastPoint.x, lastPoint.y) ctx.lineTo(touch.x, touch.y) ctx.stroke() points.push({ x: touch.x, y: touch.y }) }minDist过滤很有必要。微信小程序的touchmove事件频率在 60Hz 到 120Hz 之间快划时坐标点间距大慢划时点堆在一起。如果不过滤慢速书写时会画大量长度小于 1px 的线段白白浪费性能。过滤阈值 1.5 是经验值放大到 3 时笔迹会显得更“松”但对实时手写感稍有影响。手势事件如果出现漏点可以用简单插值我在第 5 章的避坑部分再展开。3.3 笔锋相关的三个必调参数lineWidth、lineCap、lineJoin绘制状态的设置直接影响笔迹质感。lineWidth 是线宽注意它受ctx.scale(dpr, dpr)影响这里的单位是逻辑像素设置为 4 就对应真机上 4 个 CSS px 的视觉宽度不要把它和物理像素混在一起调否则在 3 倍屏上写出来的字会细成铅笔线。lineCap 设置端点样式手写板必须用round默认的butt会让笔画端点变成平头快速起笔收笔时有明显毛刺感。lineJoin 是折线拐角的连接方式也设置成round否则写到转折处会出现尖角甚至缺口。/** * 设置画笔参数 * param {number} width - 逻辑像素线宽按 1~10 范围做滑块 * param {string} color - 十六进制颜色值 */ setBrush(width, color) { const ctx this.ctx this.brush { width, color } ctx.lineWidth width ctx.strokeStyle color ctx.lineCap round ctx.lineJoin round }3.4 收笔touchend 里落库并刷新可撤销列表抬起手指后当前这笔的 points 已经攒完要做三件事把_currentStroke推入this.strokes数组将_currentStroke置空避免下一笔误用如果页面有“撤销按钮”要同步更新按钮的可用状态。很多实现把这一笔在 move 里就画到渲染层了这没问题但记住 strokes 里存的数据和 canvas 已经画上去的内容要保持一致否则撤销重放时会发现笔迹对不上。onTouchEnd() { if (!this._currentStroke || this._currentStroke.points.length 0) return this.strokes.push(this._currentStroke) this._currentStroke null this.setData({ canUndo: true }) // 如果业务上需要签名完整性在这里标记一版 wx.vibrateShort({ type: light }) }wx.vibrateShort是可选反馈真机上有触感模拟器里无效果。我一般只在“提交签名”按钮上触发震动每笔都震动会显得聒噪产品经理大概率会来提需求去掉这个细节留给 UI 决策。4. 让手写板真正可用撤销栈、导出图片与 zip 交付的边界4.1 撤销的一整条逻辑从 strokes 弹出最后一笔再整体重绘撤销不是把 canvas 存个快照再贴回去。canvas 2d 的位图快照用getImageData存起来一步撤销没问题但连续撤 20 步内存就爆了。常见做法是基于 strokes 数组重放先把画布清空再按顺序把每一笔重画出来撤销动作就等价于strokes.pop()后的一次redraw()。这个逻辑在 zip 源码里通常是一个独立函数也是面试官爱问的点。redraw() { const ctx this.ctx // 清空画布在 scale 后的坐标系里使用逻辑尺寸 ctx.clearRect(0, 0, this.width, this.height) // 如果需要白色底先刷一层白 if (this.withBackground) { ctx.fillStyle #ffffff ctx.fillRect(0, 0, this.width, this.height) } this.strokes.forEach((stroke) { const points stroke.points if (points.length 0) return ctx.beginPath() ctx.lineWidth stroke.width ctx.strokeStyle stroke.color ctx.lineCap round ctx.lineJoin round ctx.moveTo(points[0].x, points[0].y) for (let i 1; i points.length; i) { ctx.lineTo(points[i].x, points[i].y) } ctx.stroke() }) } undo() { if (this.strokes.length 0) return this.strokes.pop() this.redraw() this.setData({ canUndo: this.strokes.length 0 }) }clearRect(0, 0, this.width, this.height)里的width是初始化时拿到的逻辑尺寸。这里有个隐蔽坑如果你在 clearRect 之前调用了ctx.setTransform(1, 0, 0, 1, 0, 0)重置坐标变换那 clearRect 的width/height就要换成物理像素即canvas.width / dpr的结果否则只能清掉左上角一小块。我的习惯是永远不重置 transform统一在缩放坐标系里操作简单不容易错。4.2 带白底的导出canvasToTempFilePath 的入参与时序签名类业务导出 png 时默认导出的是透明背景图。交给后端的签字意见、合同附件如果带着透明底放在白底页面里没问题但打印或嵌入 PDF 时可能出现黑底或花边。正确做法是先刷白底然后调用wx.canvasToTempFilePath。注意 canvas 2d 接口下这个 API 需要显式传canvas实例旧接口时代只传canvasId的方式在 2d 下会报错或者导出空白图。exportImage() { return new Promise((resolve, reject) { const ctx this.ctx // 导出前确保白底先 fill 白再重放笔迹 ctx.fillStyle #ffffff ctx.fillRect(0, 0, this.width, this.height) // 重放笔画以便白底之上显示内容 this.redraw() wx.canvasToTempFilePath({ canvas: this.canvas, // canvas 2d 接口必须传 node x: 0, y: 0, width: this.width, height: this.height, destWidth: this.width * 2, // 导出两倍分辨率避免服务端压缩后发虚 destHeight: this.height * 2, fileType: png, success: (res) resolve(res.tempFilePath), fail: (err) reject(err) }) }) }destWidth是导出图片的目标宽度建议设为画布逻辑尺寸的两倍移动端上放大了也经得起看。真机上如果白底刷完调用导出偶发空白多半是 timing 问题——fillRect是绘制指令它是异步提交到渲染层的立刻canvasToTempFilePath会读到上一帧的缓冲区。我的方案是不在页面里直接同步调用而是用wx.nextTick包一层或者导出前加 100ms 以内的延时兜底。这个坑我第二次写手写板时才碰到第一次交付给后端联调对方拿到一堆透明底黑字图一脸懵。4.3 zip 交付的边界解压、导入与 uniapp 打包差异微信小程序手写板.zip交付给人的是一个工程目录不是运行包。收到 zip 后先解压用微信开发者工具“导入项目”选中解压出的目录注意 AppID 可以选测试号项目名不要带中文和空格路径不要丢层级。有一个很具体的问题开发者工具有时会把 zip 自动识别为“压缩包插件”或者弹窗提示密码——如果交付方加了密码保护收过加密 zip 的都懂先联系交付方要密码用解压工具里的“移除密码”功能处理然后再导入。如果是用 uniapp 写的小程序工程交付结构完全不同微信开发者工具导入的是dist/build/mp-weixin目录而不是 uniapp 源码目录。原生小程序里我写的wx.createSelectorQuery()在 uniapp 中要换成uni.createSelectorQuery()canvas 2d 的 node 获取方法略有差异但绘制 API 完全一致。跨端时绕不开的笔迹性能优化逻辑可以原样复用这是手写板在大前端场景下的最大红利绘图代码写一次各端只是壳差异。5. 手写板避坑与排查触摸偏位、断线、导出黑图这 5 类实坑5.1 触摸偏位画出来的线总是往右下角错了一段现象真机上手指点在屏中间笔迹落在偏右下的位置模拟器上却正常。 原因事件监听用了touches[0].clientX而没有换算成 canvas 相对坐标canvas 2d 接口本身提供touches[0].x/y相对坐标新手容易按 Web 习惯去算偏移量多减了一次canvas.getBoundingClientRect().left偏位就出现了。 解决canvas 2d 下直接用e.touches[0].x和e.touches[0].y。如果你在旧版 canvas 接口上维护才需要clientX - rect.left。判断依据就是 canvas 标签有没有type2d有就不要再手动减偏移。5.2 线条间断快速划一笔中间出现一段“没画上”的空白现象快速划过屏幕笔迹断断续续像是被抽帧。用开发工具在电脑上画没问题手机上复现率高。 原因touchmove 触发频率跟不上手指移动速度相邻两个上报点的间距可能超过十几个像素直接 lineTo 连线会把这一段画成直线看起来没问题真正的问题出在事件被中断——比如手指划出画布边界或者页面本身在 scroll-view 里滚动触摸序列被原生滚动吞掉。 解决两个层面修。一是把画布的bindtouchmove改成catchtouchmove阻止冒泡到页面滚动二是做坐标插值相邻两点间距超过阈值时在中间补一个点代价是笔迹会更圆滑但失去一点速度感。速度敏感型业务比如练字打分不建议插值改成控制点贝塞尔即可。5.3 导出的图片是纯黑或一张空白现象canvasToTempFilePath 成功返回了 tempFilePath但图片内容要么全黑要么全白。 原因全黑的经典原因是destWidth或fileType传了不支持的值全白的经典原因是导出前没有等绘制指令落帧白底 fillRect 还没渲染完就开始截图。另外canvas参数没有传 node 实例时部分安卓机型会导出空白图而不是报错。 解决确保传入canvas: this.canvasnode 实例导出放在wx.nextTick回调里如果 still 黑把fileType换成jpg配合quality: 1排除 png 编码问题同时检查this.width是否为 0——画布没初始化完就导出必然全黑。5.4 撤销后残留叠影、笔迹颜色变深现象撤销一笔后画布上隐约还看得到上一笔的轮廓或者半透明颜色重叠处颜色变深。 原因clearRect没覆盖完整画布残留像素与新重绘内容叠加。最常见的原因是手动setTransform重置坐标系后clearRect 用的还是逻辑尺寸实际只清了左上角四分之一的区域。 解决回到 4.1 节推荐的做法——不要动 transform始终在缩放坐标系里clearRect(0, 0, width, height)。如果组件里别人写了setTransform你在 clearRect 前先ctx.save()/ctx.restore()包住它保证清屏指令和绘制指令在同一坐标系。5.5 真机上画不了模拟器上一切正常现象代码在微信开发者工具模拟器里画得飞起真机上一点触控就报错或者没有反应。 原因这种玄学问题八成是基础库版本差异。canvas 2d 要求基础库2.9.0部分低版本安卓微信下 node 获取返回 null另一种可能是开发者工具自动打开了“动效缩放”或“硬件加速”掩饰了真机上的性能问题。 解决先看项目app.json里有没有指定libVersion没有就默认最新基础库真机调试时打开 vConsole 看exec回调入参是否为 null。组件里初始化 if 判断if (!canvas) return并 toast 提示升级微信比静默失败好排查得多。真机性能掉帧时关掉帧率面板观察如果 draw calls 太高考虑下一章的速度压感优化。6. 进阶给手写板加“速度压感”和空笔校验手写板做到能画、能撤销、能导出已经满足 80% 需求。剩下的 20% 是体验和业务完整性。先看速度压感真实触控笔的线宽是随书写速度变化的越快越细越慢越粗。小程序触摸事件没提供压感字段但可以从两点间距除以时间差估算速度再用速度映射线宽。一般做法是在 touchmove 里拿到当前点和上一个点的时间戳与距离计算出一个speed值再反比到lineWidth上。写字时顿笔、提笔的粗细变化就出来了虽然和 Apple Pencil 的物理压感不能比但至少视觉上不再是一条均匀的圆头线。注意给线宽设置上下限否则快速划过时线宽趋近于 0慢速点时线宽膨胀成一颗痣。空笔校验是另一个容易被忽略的细节。电子合同场景里用户可能只是点到画布上没写就点了提交后端拿到一张白图。我在代码里用一个布尔标志判断是否真正画过东西touchstart时置this._hasInput falsetouchmove里一旦成功画出一个点就置true提交时检查这个标志。更严谨的做法是导出后读像素判断非透明像素数但getImageData在 canvas 2d 下需要传物理像素尺寸逻辑层做一次全画布扫描很耗性能日常业务用布尔标志就够。这个手写板方案做下来我的习惯是永远把“数据可回放”放在第一位——界面随便变撤销、导出、压感都依赖于点数据这份数组结构在 zip 里动都不要动。保存历史笔迹、接入后端签名服务、甚至后续做神经网络笔迹识别都不需要推倒重来。希望帮到你。本文还有配套的精品资源点击获取
返回列表