ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

UniApp小程序DIY设计器:Canvas绘制与手势交互实践

UniApp小程序DIY设计器:Canvas绘制与手势交互实践 简介这是基于UNIAPP框架与Vue技术开发的手机壳DIY小程序前端源码面向小程序开发者与前端学习者用于快速搭建支持抖音、微信等多平台的自助定制应用核心围绕手机壳图案在线DIY场景设计。资源包共324个文件约1.22MB涵盖89个Vue组件、84个JSON配置、51个JavaScript脚本、27个SCSS样式以及Markdown文档、SVG图标、PNG图片等覆盖页面组件封装、全局配置、业务逻辑与界面美化。其中Markdown文档辅助理解源码WXS组件用于补充交互SVG与PNG提供手机壳模板和视觉素材目录结构清晰适合按模块复用。源码按uni-app标准使用pages.json与manifest.json管理页面路由和全局设置目前已有597人学习查看适合需要参考完整前端工程结构的开发者。借助源码可掌握跨平台编译、DIY交互与组件化开发思路直接二次开发用于个人项目或商业demo有效缩短从零搭建小程序的周期。1. 拿 UNIAPP 做手机壳 DIY前端到底要解决什么手机壳 DIY 不是“一个页面加个画布”那么简单。用户要选底模、传照片、拖贴纸、改文字、缩放旋转最后还要把设计稿原样导出成高清图。这些动作落到小程序里全部要在一个有内存上限、没有 DOM 概念的 Canvas 环境里完成。UNIAPP 的价值在于一套 Vue 代码编译到微信小程序、支付宝小程序和 H5设计器核心逻辑可以完全复用不需要为每个端重写一遍绘图代码。但 UNIAPP 不是银弹。小程序端的 Canvas 和 H5 的 Canvas 在 API 上有差异图片加载、触摸事件、字体渲染的行为都不完全一致。这篇文章会从工程搭建、页面拆分、Canvas 绘制、手势交互、导出图片到最后的打包上架把一条可复现的路径走完。适合已经写过 Vue、想快速上手 uniapp 小程序的前端也适合正在被“DIY 类小程序怎么做交互和渲染”困扰的开发者。2. 基于 Vue3 的 UNIAPP 工程初始化与 DIY 设计器页面结构2.1 用 Vite 创建 UNIAPP 项目并配置 manifest常见做法是用npx degit dcloudio/uni-preset-vue#vite拉取 Vue3 Vite 的模板而不是从 HBuilderX 里新建。命令行创建的项目更容易纳入 Git 管理和 CI 流程。npx degit dcloudio/uni-preset-vue#vite uniapp-diy-phonecase cd uniapp-diy-phonecase npm install npm run dev:mp-weixin项目跑起来后src/manifest.json是小程序端的核心配置文件。要改三个地方mp-weixin的appid、name改成实际的小程序名称、permission里声明相册权限。{ name: 手机壳DIY, appid: 你的微信小程序appid, mp-weixin: { appid: 你的微信小程序appid, setting: { urlCheck: false }, permission: { scope.writePhotosAlbum: { desc: 用于保存你设计的手机壳图片 } } } }urlCheck在开发阶段建议关掉否则请求本地接口会报域名校验失败。scope.writePhotosAlbum是保存图片到相册的权限声明不写的话运行时授权弹窗会异常。2.2 DIY 设计器的页面路由与组件拆分DIY 流程通常拆成三个页面商品列表页、设计器页、订单确认页。设计器页是核心组件结构直接影响后续 Canvas 绘制的复杂度。我一般这样拆pages/index/index商品列表展示底模和模板pages/designer/index设计器承载 Canvas 和操作面板pages/designer/components/toolbar.vue左侧工具条切换贴纸/文字/背景pages/designer/components/layer-panel.vue图层列表和层级调整pages/designer/components/canvas-board.vue封装 Canvas 相关逻辑关键是canvas-board.vue要独立出来。这样将来如果要支持 PC 端 H5或者换用uni-canvas之类的封装库只动这一个组件不影响工具条和图层面板。2.3 设计器页面的核心状态管理设计器里的状态比普通页面复杂得多当前选中元素、元素列表、画布缩放比例、撤销栈、底模图片路径。我习惯用 Pinia 管理而不是每个组件各自ref。原因很简单图层面板要监听elements的变化工具条要修改selectedIdCanvas 组件要同时读取这两个状态跨组件通信用事件总线维护起来太痛苦。// src/stores/designer.js import { defineStore } from pinia export const useDesignerStore defineStore(designer, { state: () ({ baseImage: , // 手机壳底模图片 elements: [], // 贴纸/文字元素数组 selectedId: null, scale: 1, // 画布缩放比例 undoStack: [], redoStack: [] }), actions: { addElement(el) { this.undoStack.push(JSON.parse(JSON.stringify(this.elements))) el.id Date.now() Math.random().toString(16).slice(2) this.elements.push(el) this.selectedId el.id }, updateElement(id, patch) { const idx this.elements.findIndex(e e.id id) if (idx -1) { this.elements[idx] { ...this.elements[idx], ...patch } } } } })这里addElement入栈时用JSON.parse(JSON.stringify())做深拷贝是因为elements里的元素对象会被 Canvas 直接引用浅拷贝会导致撤销时旧状态被意外修改。对于手机壳 DIY 这种元素数量不超过几十个的场景深拷贝的性能开销可以忽略。3. Canvas 2D 实现手机壳 DIY 的核心绘制与手势交互3.1 底模、贴纸、文字的分层绘制顺序手机壳 DIY 的绘制层级是底模图手机壳轮廓 → 背景色 → 用户上传的图片元素 → 文字元素 → 选中框。这个顺序不能乱。底模如果盖在用户素材上面用户传的照片会被壳的外框裁掉看起来像印在壳上如果底模在素材下面素材会溢出壳的边界。Canvas 绘制核心代码// src/components/canvas-board.vue 中的 draw 方法 render() { const ctx this.ctx ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight) // 1. 绘制底模图 if (this.baseImage) { ctx.drawImage(this.baseImage, 0, 0, this.canvasWidth, this.canvasHeight) } // 2. 绘制用户添加的元素 this.elements.forEach(el { ctx.save() ctx.translate(el.x, el.y) ctx.rotate(el.rotation * Math.PI / 180) ctx.scale(el.scaleX, el.scaleY) if (el.type image) { ctx.drawImage(el.image, -el.width / 2, -el.height / 2, el.width, el.height) } else if (el.type text) { ctx.font ${el.fontSize}px sans-serif ctx.fillStyle el.color ctx.textAlign center ctx.textBaseline middle ctx.fillText(el.content, 0, 0) } ctx.restore() }) // 3. 绘制选中框 if (this.selectedElement) { this.drawSelectionBox() } }ctx.save()和ctx.restore()是必须的。每个元素的translate、rotate、scale都是相对画布原点做的变换如果不包在 save/restore 里前一个元素的变换会累积到最后一次绘制的元素上贴纸位置会越来越偏。drawImage的坐标参数用的是-el.width / 2这是把元素的中心点作为锚点。这样旋转和缩放都围绕中心做手势交互时计算位置更直观。如果锚点在左上角旋转时元素会剧烈抖动。3.2 触摸事件单指拖拽、双指缩放旋转小程序 Canvas 的触摸事件在touchstart、touchmove、touchend上监听。核心逻辑是单指计算位移增量更新元素 x/y双指根据两指间距离变化算缩放根据两指连线角度变化算旋转onTouchMove(e) { const touches e.touches if (touches.length 1) { // 单指拖拽 const dx touches[0].clientX - this.lastTouch.clientX const dy touches[0].clientY - this.lastTouch.clientY this.updateElement(this.selectedId, { x: this.selectedElement.x dx / this.scale, y: this.selectedElement.y dy / this.scale }) } else if (touches.length 2) { // 双指缩放与旋转 const t0 touches[0] const t1 touches[1] const distance this.getDistance(t0, t1) const angle this.getAngle(t0, t1) if (this.lastDistance 0) { const scaleDelta distance / this.lastDistance this.updateElement(this.selectedId, { scaleX: this.selectedElement.scaleX * scaleDelta, scaleY: this.selectedElement.scaleY * scaleDelta }) } if (this.lastAngle ! null) { const angleDelta angle - this.lastAngle this.updateElement(this.selectedId, { rotation: this.selectedElement.rotation angleDelta }) } this.lastDistance distance this.lastAngle angle } }注意e.touches里的clientX/clientY是相对屏幕的坐标而元素在 Canvas 里的位置是逻辑坐标。当 Canvas 被 CSS 缩放时dx / this.scale这个换算不能省。否则在 iPhone 的 3x 屏上拖拽速度会是实际速度的 3 倍。this.getDistance和this.getAngle是简单的数学计算getDistance(t0, t1) { const dx t1.clientX - t0.clientX const dy t1.clientY - t0.clientY return Math.sqrt(dx * dx dy * dy) } getAngle(t0, t1) { return Math.atan2(t1.clientY - t0.clientY, t1.clientX - t0.clientX) * 180 / Math.PI }旋转用atan2而不是acos是因为atan2直接返回带符号的角度能区分顺时针和逆时针不会出现旋转方向反了的问题。3.3 命中测试如何判断点到了哪个元素画布上有多个元素时点击要能选中最上层的那个。Canvas 没有 DOM 的elementFromPoint只能手动做命中测试。hitTest(x, y) { // 从上层往下层遍历第一个命中的就是目标 for (let i this.elements.length - 1; i 0; i--) { const el this.elements[i] // 将点击坐标逆变换回元素局部坐标系 const dx x - el.x const dy y - el.y const cos Math.cos(-el.rotation * Math.PI / 180) const sin Math.sin(-el.rotation * Math.PI / 180) const localX dx * cos - dy * sin const localY dx * sin dy * cos const halfW el.width * el.scaleX / 2 const halfH el.height * el.scaleY / 2 if (Math.abs(localX) halfW Math.abs(localY) halfH) { return el } } return null }这里把点击坐标先做一个逆旋转再判断是否在元素的 AABB 范围内。如果元素没有旋转直接比较Math.abs(x - el.x) el.width/2就行有旋转后必须做逆变换否则点击斜着的贴纸时四个角附近会点不中。3.4 手机壳模板素材的本地化与远程加载素材图片可以放本地static目录也可以放 CDN。区别在于微信小程序里 Canvas 的drawImage不能直接绘制网络图片必须先通过uni.getImageInfo把网络图片下载到本地临时文件拿到本地路径后才能绘制。async loadImage(url) { return new Promise((resolve, reject) { if (url.startsWith(http)) { uni.getImageInfo({ src: url, success: (res) resolve(res.path), fail: reject }) } else { resolve(url) } }) }拿到res.path之后还要先new Image()加载一次确保图片解码完成再传入 Canvasconst tempPath await this.loadImage(url) const img new Image() img.onload () { ctx.drawImage(img, ...) } img.src tempPath如果跳过这步直接drawImageCanvas 可能会绘制一张空白图尤其是图片体积较大时。这个坑在真机上的出现概率远高于开发者工具因为开发者工具内存充足图片解码速度快不容易暴露时序问题。4. UNIAPP 小程序端 Canvas 减少白屏卡顿的渲染性能方案4.1 离屏 Canvas 预渲染底模手机壳 DIY 最卡的地方不是绘制本身而是每次触摸移动都触发全量重绘。底模图如果是一张 750x1500 的大图每帧都drawImage一次真机上帧率会明显下降。常见做法是加一层离屏 Canvas把底模和固定背景先绘制到一个隐藏 Canvas 上只绘制一次之后每一帧用drawImage把离屏 Canvas 整体贴到主 Canvas 上。// 创建离屏 Canvas createOffscreenCanvas() { const offscreen uni.createOffscreenCanvas({ type: 2d, width: this.canvasWidth, height: this.canvasHeight }) const octx offscreen.getContext(2d) // 底模和背景只画一次 octx.drawImage(this.baseImage, 0, 0, this.canvasWidth, this.canvasHeight) octx.fillStyle this.bgColor octx.fillRect(0, 0, this.canvasWidth, this.canvasHeight) this.offscreenCanvas offscreen }微信小程序基础库 2.16.1 之后支持uni.createOffscreenCanvas但 H5 端不支持这个方法。H5 端可以用document.createElement(canvas)创建离屏画布。跨端写法需要条件编译// #ifdef MP-WEIXIN const offscreen uni.createOffscreenCanvas({ type: 2d, width: w, height: h }) // #endif // #ifdef H5 const offscreen document.createElement(canvas) offscreen.width w offscreen.height h // #endif4.2 触摸事件节流与脏矩形判断触摸事件的触发频率是 60Hz 以上但 Canvas 重绘不需要每个事件都执行。常见做法是用requestAnimationFrame做节流touchmove 里只记录最新状态真正绘制放到下一帧。scheduleRender() { if (this.renderScheduled) return this.renderScheduled true requestAnimationFrame(() { this.render() this.renderScheduled false }) }这样即使 touchmove 一帧触发 5 次实际只重绘 1 次。对于手机壳 DIY 这种元素数量不多的场景全量重绘就够用不需要做脏矩形。4.3 图片内存管理与素材尺寸约束DIY 小程序最常见的内存溢出场景是用户传了一张 4000x3000 的照片Canvas 绘制时按原始尺寸解码一张图占 48MB 内存。小程序 iOS 端内存上限约 500MB叠加底模、离屏 Canvas 和多个贴纸很容易白屏。上传前压缩是必须的。设计器显示用的图片最长边压到 1080px 足够导出商品图时再单独传原图给后端合成。前端压缩代码compressImage(src, maxSize 1080) { return new Promise((resolve) { uni.getImageInfo({ src, success: (info) { const { width, height } info let ratio 1 if (width maxSize || height maxSize) { ratio Math.min(maxSize / width, maxSize / height) } uni.compressImage({ src, quality: 80, compressedWidth: Math.floor(width * ratio), compressedHeight: Math.floor(height * ratio), success: (res) resolve(res.tempFilePath), fail: () resolve(src) }) }, fail: () resolve(src) }) }) }uni.compressImage是小程序原生 API压缩后图片会存到临时目录。注意compressedWidth和compressedHeight必须同时传只传一个会导致压缩失败。5. Canvas 导出高清商品图与 UNIAPP 打包安卓/iOS 的验证清单5.1 用 canvasToTempFilePath 导出 2x 高清图用户设计的画布在屏幕上是逻辑分辨率导出到商品图时要用 2 倍尺寸否则打印出来的手机壳边缘会有锯齿。做法是单独建一个导出用 Canvas尺寸设为逻辑尺寸的 2 倍重新按缩放比例绘制所有元素再导出。async exportImage() { const exportCanvas uni.createCanvasContext(exportCanvas, this) const scale 2 exportCanvas.scale(scale, scale) // 绘制底模 exportCanvas.drawImage(this.baseImage, 0, 0, this.canvasWidth, this.canvasHeight) // 绘制所有元素 this.elements.forEach(el { exportCanvas.translate(el.x, el.y) exportCanvas.rotate(el.rotation * Math.PI / 180) exportCanvas.scale(el.scaleX, el.scaleY) if (el.type image) { exportCanvas.drawImage(el.image, -el.width / 2, -el.height / 2, el.width, el.height) } else if (el.type text) { exportCanvas.font ${el.fontSize}px sans-serif exportCanvas.fillStyle el.color exportCanvas.textAlign center exportCanvas.setTextAlign(center) exportCanvas.fillText(el.content, 0, 0) } exportCanvas.translate(-el.x, -el.y) }) exportCanvas.draw(false, () { setTimeout(() { uni.canvasToTempFilePath({ canvasId: exportCanvas, width: this.canvasWidth * scale, height: this.canvasHeight * scale, destWidth: this.canvasWidth * scale, destHeight: this.canvasHeight * scale, success: (res) { uni.saveImageToPhotosAlbum({ filePath: res.tempFilePath, success: () uni.showToast({ title: 已保存 }) }) } }, this) }, 300) }) }exportCanvas.draw(false, callback)的false表示不保留上一次绘制内容。setTimeout 300ms是等 Canvas 绘制完成再导出时间太短会导出空白图这个延迟在低端安卓机上要调到 500ms 以上。5.2 UNIAPP 打包安卓应用市场与 iOS 上架前检查项目源码本身跨端打包上架时要额外处理几件事。安卓端用uni build:app生成离线打包资源iOS 端需要证书签名。src/manifest.json里要配好app-plus模块权限Android打包app-plus节点下配置permissions列表声明存储读写权限iOS 打包相册保存权限要在 Xcode 工程的Info.plist里加NSPhotoLibraryAddUsageDescription各安卓应用市场要求软件著作权证书、隐私政策页面、ICP 备案号app-plus: { distribute: { android: { permissions: [ uses-permission android:name\android.permission.WRITE_EXTERNAL_STORAGE\/, uses-permission android:name\android.permission.READ_EXTERNAL_STORAGE\/ ] }, ios: { privacyDescription: { NSPhotoLibraryAddUsageDescription: 需要保存您设计的手机壳图片到相册 } } } }隐私政策页面必须放在小程序首页可跳转的位置否则审核会被拒常见理由是「用户协议和隐私政策未有明示」。5.3 真机调试时的 Canvas 常见坑开发者工具里正常、真机上一片白这是 Canvas 最典型的故障。逐项排查现象原因处理真机白屏图片未加载完成就 drawImage用img.onload或uni.getImageInfo回调后再绘制字体变成默认体自定义字体文件过大字体文件控制在 200KB 以内用wx.loadFontFace加载导出图片空白导出时机过早draw()回调后加 300-500ms 延迟双指缩放卡顿每次 touchmove 都全量重绘用requestAnimationFrame节流图片模糊绘制尺寸小于显示尺寸用getImageInfo的原始宽高计算绘制尺寸5.4 设计稿还原度检查用截图对比替代肉眼上线前我会做一轮自动化截图对比把设计器的 Canvas 导出图与设计稿放到同一画布上叠加用像素级 diff 检查间距和色彩偏差。这一步能发现底模图被拉伸、文字换行位置漂移、贴纸主色偏色等肉眼难以察觉的问题。截图的对比脚本挂在 CI 里每次改完代码自动跑一遍比人工点页面高效得多。# 对比设计稿和导出图输出差异比例 node scripts/compare-screenshot.js design-mock.png export-result.png差异超过 1% 就阻断合并请求避免把回归带进主分支。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进