尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

uniapp+Vue实现手机壳DIY小程序:canvas图层合成与手势交互实战

uniapp+Vue实现手机壳DIY小程序:canvas图层合成与手势交互实战 简介这是一套基于UNIAPP与Vue技术栈的手机壳DIY小程序前端源码面向小程序前端开发者及有跨平台需求的个人或团队可应用于抖音、微信等主流平台。压缩包共324个文件容量1.22MB涵盖89个Vue组件、84个JSON配置、51个JavaScript脚本、47个Markdown文档、27个SCSS样式文件以及SVG、PNG、WXS、CSS等辅助资源可满足页面搭建、业务逻辑、视觉美化与文档注释等多方面需求。资源支持在抖音和微信小程序环境运行用户可基于此快速搭建自定义手机壳设计工具缩短从零到一的开发周期。已有597人学习下载适合希望熟悉UNIAPP多端开发流程、快速落地个性化定制类小程序的开发者参考使用。1. 手机壳 DIY 小程序的本质是 canvas 合成uniappVue 这样选型最省事做手机壳 DIY 小程序最容易被低估的是那块画布。用户把照片拖进壳面、打一行字、转个角度前端要在几百毫秒内把所见即所得的画面渲染出来还得保证用户下单后工厂拿到的高清图跟他屏幕上看到的一致。选 uniappVue 做这个前端核心收益是设计器和首页、订单详情这些常规页面共用一套代码同时出微信小程序和 H5 版本canvas 图层引擎写好一次整个 DIY 流程都能复用。这篇按 DIY 小程序前端最常见的落地路径讲先把工程和路由配好再实现图层合成引擎然后把小程序相册、分享这些能力接上最后说三个上线前必调的细节。适合要快速把定制类小程序推上线的前端团队也适合准备用 uniapp 写重交互页面的同学。2. 初始化 uniappVue 项目把页面路由、tabBar 和请求层一次配好DIY 小程序不是单页应用它至少有选款、设计器、订单详情三个核心页面。工程初始化阶段多花十分钟把路由层级和请求层理清楚后面接支付、接分享都不用在业务代码里堆补丁。2.1 用 vite 模板初始化项目npm run dev:mp-weixin 跑通 uni-app 微信小程序新建项目建议直接用官方 vite 模板vue3 语法、vite 构建比老款 vue2 的 webpack 模板启动快后续依赖也好装# 拉取官方模板不保留 git 历史项目名用英文小写 npx degit dcloudio/uni-preset-vue#vite uniapp-diy cd uniapp-diy npm install # 编译到微信小程序并进入 watch 模式 npm run dev:mp-weixindegit的作用是克隆远端仓库但不携带.git历史省掉手动删除旧仓库信息的步骤npm run dev:mp-weixin会把src下的源码编译到dist/dev/mp-weixin用微信开发者工具导入这个目录就能实时预览。首次运行时如果发现开发者工具里页面空白先确认manifest.json里的mp-weixin.appid是否填成了测试号相关配置常见错误是 appid 为空导致部分接口不可用。这个阶段还要顺手改一下manifest.json基础库版本选到 2.30.0 以上否则canvasToTempFilePath和部分新版 canvas API 行为不一致。CLI 项目的manifest.json是源码文件HBuilderX 里看到的可视化项在 CLI 工程里都对应这里的 JSON 字段改完重启 dev 命令才生效。发布安卓市场或 App Store 时对应的是npm run build:app以及应用市场的包名、证书配置这些也在 manifest 里一起维护不要等打包时才回头翻文档。2.2 在 pages.json 里配置 DIY 流程路由与 tabBar设计器页不占 tabDIY 的主流程是「选款 → 设计 → 下单」首页和我的页面做成 tabBar设计器页面用自定义导航全屏展示给画布腾出完整空间{ pages: [ { path: pages/index/index, style: { navigationBarTitleText: 选款 } }, { path: pages/design/index, style: { navigationStyle: custom } }, { path: pages/order/detail, style: { navigationBarTitleText: 订单详情 } } ], tabBar: { color: #999999, selectedColor: #2B85E4, list: [ { pagePath: pages/index/index, text: 选款 }, { pagePath: pages/mine/index, text: 我的 } ] } }pages数组的第一个元素是冷启动首页这个顺序不要乱设计器页面设置navigationStyle: custom后微信小程序原生导航栏被隐藏自己画返回按钮和「保存」按钮布局自由度更大但要注意顶部避开状态栏高度用uni.getSystemInfoSync().statusBarHeight来撑开间距。tabBar 只放低频入口用户从首页进设计器之后停留的沉浸式页面不适合再展示底部 tab。路由跳转用 uniapp 的uni.navigateTo设计器到订单详情之间不能跳 tabBar 页面需要跳转时改uni.redirectTo或uni.reLaunch。这里也提一下版本兼容vue3 模板下pages.json里easycom默认开启组件不需要手动import但如果你在自定义组件里用了script setup确保该组件有独立的name字段否则部分版本下 easycom 自动扫描会警告。2.3 封装 request 与登录态下单前必须做的一层基础设计器最终要提交图片和商品参数不能在每个页面里重复写uni.request。我一般会单独放一个utils/request.js统一处理 baseURL、token 注入和 401// utils/request.js const BASE_URL https://api.example.com export function request(options) { return new Promise((resolve, reject) { uni.request({ url: BASE_URL options.url, method: options.method || GET, data: options.data || {}, header: { Authorization: uni.getStorageSync(token) || , Content-Type: application/json, ...options.header }, success: (res) { // 后端约定 401 表示登录态失效直接踢回登录页 if (res.statusCode 401) { uni.removeStorageSync(token) uni.navigateTo({ url: /pages/login/index }) reject(res) return } if (res.statusCode 200 res.statusCode 300) { resolve(res.data) } else { uni.showToast({ title: res.data?.message || 请求失败, icon: none }) reject(res) } }, fail: (err) { uni.showToast({ title: 网络异常, icon: none }) reject(err) } }) }) }登录态目前的标准做法是uni.login拿到临时code交给后端换 token 并写入 storage。自行登录页里这个 header 的Authorization是每次请求自动带上的不需要业务方重复传。下单接口的 payload 建议只传画布导出的临时文件路径让后端转存 OSS 后再返回最终 URL千万别把 base64 图片直接塞进data小程序请求包体积有限一张 750x1500 的合成图 base64 之后动辄几百 KB请求大概率超时。3. 基于 canvas 的图案合成引擎手机壳贴图、文字与手势变换DIY 的交互核心是图层。用户看到的每一张贴纸、每一行文字、壳底本身在内存里都是图层对象canvas 按固定顺序重绘这些对象。先把数据结构定好功能做起来才不乱。3.1 画布合成顺序与 dpr 换算先讲清楚绘制优先级图层顺序决定视觉遮挡关系我按「壳底 → 图案 → 文字 → 高光水印」来画图层列表存进响应式数组// 图层对象结构 // { id, type: base | image | text, x, y, width, height, rotation, content } const layers ref([]) function drawAllLayers() { // base 壳底 layers.value .filter(l l.type base) .forEach(l drawBaseLayer(l)) // image 图案与用户上传图 layers.value .filter(l l.type image) .forEach(l drawImageLayer(l)) // text 文字 layers.value .filter(l l.type text) .forEach(l drawTextLayer(l)) }画布初始化时的 dpr 换算是最容易出视觉 bug 的地方字体发虚、导出的图被拉变形基本都从这里来import { getCurrentInstance } from vue const instance getCurrentInstance() const DESIGN_WIDTH 750 // 设计稿逻辑宽度 const DESIGN_HEIGHT 1500 // 手机壳通用比例2:1 以下 const sys uni.getSystemInfoSync() const dpr sys.pixelRatio const ctx uni.createCanvasContext(diyCanvas, instance) function initCanvas() { // 关键把物理像素和逻辑像素对齐 ctx.setTransform(1, 0, 0, 1, 0, 0) ctx.scale(dpr, dpr) }uni.createCanvasContext的第二参数必须传instancevue3 组合式 API 里不传会找不到 canvas 节点这是 uni-app 从 vue2 迁移到 vue3 后最典型的坑。ctx.scale(dpr, dpr)之后后续所有绘制都可以用逻辑坐标导出到相册的图片才不会模糊。绘制防抖必须做touchmove事件一触即发每一帧都做全量ctx.draw()在低端机上会产生明显卡顿。常见做法是维护一个dirty标记touchmove里只更新图层坐标用requestAnimationFrame在下一帧统一重绘手势密集触发时保证每秒最多重绘 60 次。3.2 文字编辑组件与中文字体映射设计器和画布要同时改文字图层不能只在 canvas 上画用户编辑时需要看到真实的输入光标。我用的是一个隐藏的input组件双击画布时定位到点击位置显示浮层失焦后把内容写回图层再重绘画布const fontMap { default: sans-serif, xingkai: STXingkai, serif, // 楷体适合个性化签名 round: PingFang SC, sans-serif } function drawTextLayer(layer) { ctx.save() // 以图层中心为坐标原点便于旋转 ctx.translate(layer.x layer.width / 2, layer.y layer.height / 2) ctx.rotate(layer.rotation * Math.PI / 180) ctx.font ${layer.fontSize}px ${fontMap[layer.fontFamily] || fontMap.default} ctx.fillStyle layer.color ctx.textAlign center ctx.textBaseline middle ctx.fillText(layer.content, 0, 0) ctx.restore() }ctx.font里的中文字体名在小程序端的有效性由客户端决定iOS 对PingFang SC支持好Android 大部分机型会回退到系统默认所以字体映射表不要依赖单一字体。fontSize也有上限问题部分安卓 WebView 对ctx.font的px数值有 256px 上限手机壳大标题通常 72~120px 区间内不会触发但测试机型覆盖时要留意。输入组件浮层的位置需要和 canvas 坐标做换算事件回调里拿到的e.touches[0].x是相对页面的坐标要减去 canvas 节点左上角在页面中的偏移量再乘上一个缩放系数才是画布逻辑坐标。设计稿宽度固定 750canvas 实际 CSS 宽度是 375这里的换算系数就是750 / canvas实际的CSS宽度建议封装成toCanvasX(pageX)之类的函数集中处理不要在业务里散落乘除。3.3 手势计算双指缩放、单指拖动的坐标换算图层的移动和缩放都靠touchstart、touchmove事件。单指拖动好处理双指缩放的关键是两指间距的变化率function getDistance(t1, t2) { return Math.hypot(t1.x - t2.x, t1.y - t2.y) } let lastDistance 0 function onTouchStart(e) { if (e.touches.length 2) { lastDistance getDistance(e.touches[0], e.touches[1]) } } function onTouchMove(e) { if (e.touches.length 2) { const currentDistance getDistance(e.touches[0], e.touches[1]) if (lastDistance 0) { const ratio currentDistance / lastDistance // 直接把宽高乘 ratio顺时针旋转等后续再叠加 selectedLayer.width * ratio selectedLayer.height * ratio drawAllLayers() } lastDistance currentDistance } }Math.hypot直接算欧几里得距离比Math.sqrt(Math.pow(dx, 2) Math.pow(dy, 2))更简洁且不易写错低版本安卓 WebView 可能不识别hypot保险写法是用Math.sqrt。缩放基准点是图层左上角实际产品里用户直觉期望的是围绕图片中心缩放更顺手的做法是在translate到中心点后在局部坐标系里放大实现稍复杂但体验完全不同。旋转建议单独用一个两指角度差的算法不跟缩放写在一个函数里方便单独调试。角度计算用Math.atan2求两指连线与水平轴的夹角差值累积到layer.rotation重绘时再转弧度。4. 小程序能力适配照片选择、保存相册与自定义分享canvas 画完只是第一步DIY 流程里至少有三个绕不开的小程序能力选图、存相册、分享。uniapp 把它们都封装成了uni前缀的 API但多端行为有差异适配层值得单独写。4.1 用 uni.chooseImage 兼容 H5 和微信小程序的选择图片参数有讲究选图接口直接用官方的跨端封装不要在业务里判断#ifdef MP-WEIXINfunction choosePhoto() { uni.chooseImage({ count: 1, sizeType: [compressed], sourceType: [album, camera], success: (res) { const tempPath res.tempFilePaths[0] // 把图片地址写入 layerdescription 等后续绘制 addLayer({ type: image, src: tempPath, x: 100, y: 100, width: 200, height: 200 }) }, fail: (err) { // 用户主动取消不算异常只 log 不 toast 更友好 console.log(用户取消选择, err) } }) }微信小程序里sizeType: [compressed]能显著降低内存占用但压缩后图片原尺寸信息会丢失画到 canvas 上用uni.getImageInfo拿实际宽高来维持宽高比接口能力微信小程序H5说明临时文件路径有可直接用于 canvas有Blob 前缀路径可直接当 image srcsizeType 压缩支持不支持忽略H5 端压缩需自行处理多选 count支持部分浏览器一次只能一个业务上 count1返回文件类型pathpath统一选完图后千万别直接ctx.drawImage原图手机相册一张 4000x3000 的照片画进去内存直接翻倍。常见做法是先按 750 设计稿宽度等比缩放成 target 尺寸再画到离屏 canvas 上最后组装进主合成图层这一步同时解决了大图内存占用和导出图体积问题。4.2 canvasToTempFilePath 导出海报与 saveImageToPhotosAlbum 授权处理最终保存到手机相册的流程分两步先canvasToTempFilePath把画布内容导出为临时图片再saveImageToPhotosAlbum写入相册。第二步在 iOS 上必定触发权限弹窗处理不当会直接 failfunction exportAndSave() { uni.canvasToTempFilePath({ canvasId: diyCanvas, fileType: png, quality: 1, success: (res) { const tempFilePath res.tempFilePath uni.saveImageToPhotosAlbum({ filePath: tempFilePath, success: () uni.showToast({ title: 已保存到相册 }), fail: handleAuthError }) }, fail: (err) console.error(导出失败, err) }, instance) } function handleAuthError(err) { if (err.errMsg err.errMsg.includes(auth deny)) { uni.showModal({ title: 需要相册权限, content: 请在设置中开启保存到相册权限后重试, confirmText: 去设置, success: (r) { if (r.confirm) uni.openSetting() } }) } }canvasToTempFilePath的第二个参数同样是instancevue3 组合式下传getCurrentInstance().proxy才稳妥。fileType: png适用于需要透明背景的海报如果背景是纯色壳底图选jpeg体积更小。保存权限被拒后靠errMsg里的auth deny关键词区分是「取消授权」还是「永久拒绝」前者引导重新唤起授权后者跳uni.openSetting让用户手动开。测试时要覆盖「首次拒绝、第二次进入设置打开、返回自动保存」这条链路漏掉这步会让用户体验卡在死循环。4.3 onShareAppMessage 动态标题与 setNavigationBarTitle 双配合分享是 DIY 产品自带裂变属性的一环。用户分享出去的卡片标题如果写死「快来定制手机壳」点击率很低正确做法是根据当前设计内容动态生成标题同时带上订单或设计稿 ID 作为路由参数import { onShareAppMessage } from dcloudio/uni-app onShareAppMessage(() { return { title: 我用「${designName.value}」设计了自己的手机壳你也来一个, path: /pages/order/detail?id${orderId.value}, imageUrl: posterPath.value || undefined } })onShareAppMessage在 vue3 组合式 API 里必须从dcloudio/uni-app显式导入这是 uni-app vue3 的官方写法别再用选项式onShareAppMessage写在export default里两种写法的触发时机有差异。微信小程序分享卡片的imageUrl必须是网络图或本地路径临时路径在部分版本下不生效所以分享封面最好在导出海报后先上传 OSS 拿到正式 URL 再当参数用。订单详情页进入时配合uni.setNavigationBarTitle({ title:订单 ${orderNo}})做动态标题分享落地页和页面标题保持一致用户从卡片点进来不会觉得跳错了地方。「小程序动态设置标题」这个细节很多人只在分享标题上做落地页标题漏掉信息一致性对转化率的影响比想象中大。5. 上线前必查的 3 处细节dpr 换算、内存回收与 rpx 陷阱第一查 dpr 换算是不是克在逻辑坐标上。验收方法很简单设计器页面的 canvas 区域截图和订单详情页海报图对比模糊、发虚或上下偏移都说明换算没对齐。排查时先在initCanvas里打印sys.pixelRatio和 canvas 节点实际 CSS 尺寸确认ctx.scale(dpr, dpr)有没有被执行再检查导出图是canvasToTempFilePath时生成的临时文件尺寸是否等于DESIGN_WIDTH * dpr。很多团队在真机上发现导出图上下多出黑边十有八九是 canvas 物理尺寸设成了 CSS 尺寸scale(dpr)后逻辑坐标系没有被完整覆盖。第二查大图内存回收。DIY 页面的壳底图如果用了高清单反拍摄的商品图配合用户上传的几张高清照片微信小程序在安卓低端机上很容易触发 WebView 内存告警表现是页面白屏或直接闪退。处理方案是「降载重画」用户上传的图先经uni.compressImage或离线 canvas 压缩到单边不超过 1500px再进图层壳底图上传时就要求服务端输出三种尺寸预览用 750 宽、导出用 1500 宽不要只给一张原图。离开设计器页面时onUnload里清掉setInterval、requestAnimationFrame句柄和layer大数组引用vue3 的响应式数组释放后依赖它的 canvas 绘制闭包也要一并置空否则页面实例已经销毁canvas 的异步回调还在跑控制台会刷Cannot read properties of null之类的 noise。第三查 rpx 在 canvas 里的换算。canvas 上下文里不存在 rpx 的概念所有绘制坐标必须用 px。设计稿宽度如果恰好是 750px 和 rpx 数值 1:1如果设计稿是 500 或 375就要给整个绘制流程加一个scaleFactor 750 / designWidth的全局换算系数文字字号和图层坐标统一乘进去否则同一套代码在 iPhone 和 Android 真机上的排版会错位。验证手段是分别用 H5 浏览器 devtools 的 iPhone 尺寸和微信开发者工具的真机预览截图对比两个环境的 CSS 像素宽度不一致错位问题立刻现形。这三处都过一遍后DIY 画布基本就稳了剩下的优化重点是手势跟手度把图层的translate和rotate合成一个变换矩阵来算针对双指旋转和缩放同时触发的场景单独写测试用例那才是这个项目后续最耗性能的环节。本文还有配套的精品资源点击获取
返回列表