uniapp+Vue实现手机壳DIY小程序:canvas图层合成与手势交互实战
2026/9/20 20:52:36 网站建设 项目流程

简介:这是一套基于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 合成,uniapp+Vue 这样选型最省事

做手机壳 DIY 小程序,最容易被低估的是那块画布。用户把照片拖进壳面、打一行字、转个角度,前端要在几百毫秒内把所见即所得的画面渲染出来,还得保证用户下单后工厂拿到的高清图跟他屏幕上看到的一致。选 uniapp+Vue 做这个前端,核心收益是设计器和首页、订单详情这些常规页面共用一套代码,同时出微信小程序和 H5 版本;canvas 图层引擎写好一次,整个 DIY 流程都能复用。这篇按 DIY 小程序前端最常见的落地路径讲:先把工程和路由配好,再实现图层合成引擎,然后把小程序相册、分享这些能力接上,最后说三个上线前必调的细节。适合要快速把定制类小程序推上线的前端团队,也适合准备用 uniapp 写重交互页面的同学。

2. 初始化 uniapp+Vue 项目,把页面路由、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-weixin

degit的作用是克隆远端仓库但不携带.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,设计器页不占 tab

DIY 的主流程是「选款 → 设计 → 下单」,首页和我的页面做成 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.redirectTouni.reLaunch。这里也提一下版本兼容:vue3 模板下pages.jsoneasycom默认开启,组件不需要手动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的第二参数必须传instance,vue3 组合式 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.fontpx数值有 256px 上限,手机壳大标题通常 72~120px 区间内不会触发,但测试机型覆盖时要留意。

输入组件浮层的位置需要和 canvas 坐标做换算:事件回调里拿到的e.touches[0].x是相对页面的坐标,要减去 canvas 节点左上角在页面中的偏移量,再乘上一个缩放系数才是画布逻辑坐标。设计稿宽度固定 750,canvas 实际 CSS 宽度是 375,这里的换算系数就是750 / canvas实际的CSS宽度,建议封装成toCanvasX(pageX)之类的函数集中处理,不要在业务里散落乘除。

3.3 手势计算:双指缩放、单指拖动的坐标换算

图层的移动和缩放都靠touchstarttouchmove事件。单指拖动好处理,双指缩放的关键是两指间距的变化率:

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-WEIXIN

function choosePhoto() { uni.chooseImage({ count: 1, sizeType: ['compressed'], sourceType: ['album', 'camera'], success: (res) => { const tempPath = res.tempFilePaths[0] // 把图片地址写入 layer,description 等后续绘制 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 src
sizeType 压缩支持不支持,忽略H5 端压缩需自行处理
多选 count支持部分浏览器一次只能一个业务上 count=1
返回文件类型pathpath统一

选完图后千万别直接ctx.drawImage原图,手机相册一张 4000x3000 的照片画进去,内存直接翻倍。常见做法是先按 750 设计稿宽度等比缩放成 target 尺寸,再画到离屏 canvas 上,最后组装进主合成图层;这一步同时解决了大图内存占用和导出图体积问题。

4.2 canvasToTempFilePath 导出海报与 saveImageToPhotosAlbum 授权处理

最终保存到手机相册的流程分两步:先canvasToTempFilePath把画布内容导出为临时图片,再saveImageToPhotosAlbum写入相册。第二步在 iOS 上必定触发权限弹窗,处理不当会直接 fail:

function 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的第二个参数同样是instance,vue3 组合式下传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里清掉setIntervalrequestAnimationFrame句柄和layer大数组引用,vue3 的响应式数组释放后依赖它的 canvas 绘制闭包也要一并置空,否则页面实例已经销毁,canvas 的异步回调还在跑,控制台会刷Cannot read properties of null之类的 noise。

第三查 rpx 在 canvas 里的换算。canvas 上下文里不存在 rpx 的概念,所有绘制坐标必须用 px。设计稿宽度如果恰好是 750,px 和 rpx 数值 1:1;如果设计稿是 500 或 375,就要给整个绘制流程加一个scaleFactor = 750 / designWidth的全局换算系数,文字字号和图层坐标统一乘进去,否则同一套代码在 iPhone 和 Android 真机上的排版会错位。验证手段是分别用 H5 浏览器 devtools 的 iPhone 尺寸和微信开发者工具的真机预览截图对比,两个环境的 CSS 像素宽度不一致,错位问题立刻现形。这三处都过一遍后,DIY 画布基本就稳了;剩下的优化重点是手势跟手度,把图层的translaterotate合成一个变换矩阵来算,针对双指旋转和缩放同时触发的场景单独写测试用例,那才是这个项目后续最耗性能的环节。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询