ARTICLE DETAIL

资讯详情

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

微信小程序九宫格拼图开发:Canvas切割与坐标换算实战踩坑

微信小程序九宫格拼图开发:Canvas切割与坐标换算实战踩坑 做小程序拼图功能那阵子我踩了不少坑。早先以为只是把图片切碎再随机排列真正上手才发现拖拽手感怎么调、切割后坐标怎么换算、真机上为什么总差几个像素每个环节都能让人磨上半天。这篇就把我完整实现九宫格拼图的过程和踩坑记录整理出来给打算做同类型功能的开发者一份可以直接抄的作业。无论你是刚接触微信小程序还是已经在做图片类应用这篇文章都会比官方文档更实用一些。1. 拼图功能不只是“把图切碎”需求与方案选型动手之前先想清楚一件事你要做的拼图到底是哪一种很多需求方说“做个拼图功能”但不同形态的实现方式差别非常大选错方案后面要返工很痛苦。1.1 三种最常见的拼图形态按交互方式分市面上常见的拼图功能大致有三类形态交互方式实现难度典型场景宫格点击/拖拽交换点击相邻碎片交换位置或长按拖拽到目标格子中等九宫格拼图小游戏、活动营销页滑动验证式拼图按住滑块移动一块凸起碎片到指定缺口较低登录验证、风控校验自由拖拽拼图每个碎片可以任意拖动旋转拼成完整图片较高儿童教育类、创意DIY这三种里滑动验证式本质上是“单块碎片沿固定轴移动”只要算好目标位置和容差值就行自由拖拽拼图虽然最像真实拼图但要处理旋转角度、层级遮挡、碰撞吸附复杂度高出一大截宫格交换则是性价比最高的方案——交互直观、判定简单又保留了“拼图”的核心体验。1.2 为什么我选择宫格点击交换方案我当时的需求是做一个“上传照片生成拼图转发给好友挑战”的小程序目标用户是普通微信用户交互必须零学习成本。滑动验证太单调自由拖拽在手机上误触率高最终定了“九宫格点击/拖拽交换”方案。这个方案有几个实际好处状态模型简单清晰核心就是一个order数组记录每个格子里放的是第几号碎片。完成判定容易每次交换后检查order数组是否等于正确序列。交互出错率低用户要么点击两个格子交换要么长按拖拽手指操作范围可控。打乱后可还原从正确状态反向洗牌保证一定有解。如果你要做的也是类似场景我建议从宫格方案起步。但要注意这个方案里最容易翻车的地方不是切割图片而是交互坐标换算——后面第三章详细说。1.3 技术选型原生小程序与框架的选择做微信小程序拼图技术栈选择上可以有三种原生小程序、uni-app、Taro。我自己最终用了原生小程序加 Canvas 2D原因很朴素拼图功能涉及大量 touch 事件和 canvas 绘制原生接口文档最全、调试链路最短。用跨端框架虽然能同时输出多端但遇到 canvas 兼容性问题时排查成本会成倍增加。如果你所在团队已经是 uni-app 技术栈那继续用 uni-app 也没问题但核心绘图逻辑建议用uni.createCanvasContext或 Canvas 2D 封装好方便后续迁移原生端。另外基础库版本建议设在 2.30.0 以上。早期 canvas 接口wx.createCanvasContext和新的 Canvas 2D 接口type2d差异很大新接口更接近 Web API后续维护体验好很多。2. 从选择图片到 Canvas 切割图像处理的完整链路定好方案后第一件实际要做的事是把用户选的图片裁剪成正方形切成 N 块碎片。这一步看起来简单但“把图片画到 canvas 上”这个动作在微信小程序里有不少隐藏限制。2.1 图片选择wx.chooseMedia 是当前的最佳选择微信小程序选图片旧接口是wx.chooseImage新接口是wx.chooseMedia。后者支持从相册选择或直接拍摄并且返回的临时文件路径可以直接用于wx.getImageInfo。我建议直接用wx.chooseMedia代码更简洁返回结构也更统一。wx.chooseMedia({ count: 1, mediaType: [image], sourceType: [album, camera], sizeType: [compressed], success: (res) { const tempFilePath res.tempFiles[0].tempFilePath; this.loadImageAndCut(tempFilePath); } });这里有个细节sizeType我选了compressed。拼图功能本质是娱乐向的对画质要求不高压缩图能显著减少 canvas 绘制和临时文件读取的时间尤其对中低端安卓机很友好。2.2 wx.getImageInfo 与图片跨域问题拿到临时路径后下一步是获取图片的宽高信息用于计算裁剪区域。用wx.getImageInfo即可async loadImageAndCut(filePath) { const info await new Promise((resolve, reject) { wx.getImageInfo({ src: filePath, success: resolve, fail: reject }); }); this.imageWidth info.width; this.imageHeight info.height; this.imagePath info.path; this.cutIntoTiles(); }注意wx.getImageInfo返回的path可能和传入的临时路径不一样后续画图时要用返回的path。网络图片必须配置 downloadFile 域名白名单否则在真机上会直接绘制失败。本地临时路径则没有这个问题。2.3 裁剪策略以短边为基准裁出正方形用户上传的照片几乎不可能是正方形我的做法是以短边为基准从图片中心裁出最大正方形区域避免主体内容被切掉。calculateCropRect(imgW, imgH) { const side Math.min(imgW, imgH); const x (imgW - side) / 2; const y (imgH - side) / 2; return { sx: x, sy: y, sWidth: side, sHeight: side }; }从中心裁切而不是从左上角裁切原因是普通用户拍照时主体通常在画面中央中心裁切能最大程度保住人脸或关键物体。如果你做的场景是证件照那裁切策略就要改成识别主体位置那个复杂度另说。2.4 Canvas 2D 切割注意接口差异微信小程序的 canvas 有两种用法旧版的wx.createCanvasContext和新版的 Canvas 2Dtype2d。新版接口通过wx.createSelectorQuery获取节点再调用node.getContext(2d)绘制 API 和浏览器端几乎一致。canvas type2d idtileCanvas stylewidth: 300px; height: 300px;/canvasasync cutIntoTiles() { const query this.createSelectorQuery(); const canvasNode await new Promise((resolve) { query.select(#tileCanvas).fields({ node: true, size: true }).exec((res) { resolve(res[0]); }); }); const canvas canvasNode.node; const ctx canvas.getContext(2d); const dpr wx.getWindowInfo().pixelRatio; canvas.width 300 * dpr; canvas.height 300 * dpr; ctx.scale(dpr, dpr); }这里有个特别重要的坑canvas 的实际像素尺寸不等于 CSS 尺寸。如果 canvas 在页面展示是 300px内部绘制也要按 300px 逻辑尺寸来但因为高分屏的关系canvas.width要设置成300 * dpr再用ctx.scale(dpr, dpr)保证绘制时坐标不用乘 dpr。不这么做的话切出来的碎片会模糊或者坐标对不上。切割核心逻辑如下const tileCount 3; // 九宫格 const tileSize 300 / tileCount; for (let row 0; row tileCount; row) { for (let col 0; col tileCount; col) { // 每次绘制都清空画布单独输出一张碎片图 ctx.clearRect(0, 0, 300, 300); ctx.drawImage( this.imageObj, cropX col * tileSize, cropY row * tileSize, tileSize, tileSize, 0, 0, tileSize, tileSize ); const tempFilePath await new Promise((resolve) { wx.canvasToTempFilePath({ canvas, x: 0, y: 0, width: tileSize * dpr, height: tileSize * dpr, destWidth: tileSize, destHeight: tileSize, success: (res) resolve(res.tempFilePath), fail: () resolve() }, this); }); tiles.push(tempFilePath); } }这里要注意wx.canvasToTempFilePath的参数。在 Canvas 2D 模式下canvas参数要传节点对象本身不是节点 id。width和height是画布上的像素尺寸因为已经scale(dpr, dpr)实际像素是 100 的 dpr 倍所以要用tileSize * dpr否则导出会空白或者被裁剪。这些细节我在第一次做的时候调了很久希望你不要再走弯路。2.5 碎片列表管理与内存释放切割完成后得到一个包含 9 个临时文件路径的数组tiles这就是拼图碎片的“素材库”。接下来渲染到页面时通过order数组建立索引映射order[i]表示第 i 个格子显示的是tiles[order[i]]。临时文件在退出页面时应该清理可以调用wx.removeSavedFile来删除避免占用 Storage 空间。不过实际上canvasToTempFilePath生成的临时文件由小程序自动管理App 退出或者空间不足时会被回收不手动清理问题也不大。我保留这个步骤主要是为了养成好习惯。3. 拖拽交互的底层逻辑Touch 事件与坐标换算拼图功能的核心体验就在交互。最初我天真地给每个碎片 view 绑定bindtouchstart、bindtouchmove结果真机上碎片疯狂乱跳根本没法用。下面讲讲正确做法。3.1 为什么直接给碎片绑定 bindtouchmove 会“跑飞”如果你把 touch 事件直接绑到每个碎片上在bindtouchmove里直接用e.touches[0].clientX去设置碎片的left、top你会发现碎片要么跟不上手指要么一下子跑到屏幕边缘。原因有两个坐标基准不一致clientX是相对视口的坐标而碎片的位置是用相对容器的坐标。这两者之间隔着一个容器的偏移量。事件频繁触发导致 setData 卡顿bindtouchmove在手指滑动时高频触发每次setData更新left/top渲染层根本来不及响应累积下来就会看到碎片“跳帧”。3.2 正确做法统一维护拖拽状态我的解法是不直接把 touch 事件绑到每个碎片上而是把整个拼图容器作为一个可触摸区域在页面层统一处理手势。核心思路是在touchstart时根据手指位置反推出点击的是哪个格子记录这个格子的索引。在touchmove时计算手指相对容器的坐标更新拖拽层的位置。在touchend时判断目标格子执行交换逻辑。这样做的好处是事件逻辑集中在一个地方不会出现多碎片手势冲突。3.3 坐标换算从像素到宫格坐标换算是整个功能最容易出错的地方。以九宫格为例我把裁好的图片放在一个 300px 宽的容器里实际宽度可以通过wx.createSelectorQuery动态获取不要写死每个格子是 100px。手指点击时需要把clientX/clientY换算成“第几行第几列”。getGridIndex(clientX, clientY) { return new Promise((resolve) { this.createSelectorQuery() .select(#puzzle-board) .boundingClientRect((rect) { if (!rect) { resolve(-1); return; } const relativeX clientX - rect.left; const relativeY clientY - rect.top; const col Math.floor(relativeX / this.tileSize); const row Math.floor(relativeY / this.tileSize); const index row * this.colCount col; // 边界校验 if (row 0 || row this.colCount || col 0 || col this.colCount) { resolve(-1); return; } resolve(index); }) .exec(); }); }很多教程会忽略boundingClientRect这一步直接拿clientX除以格子大小结果在真机上总有偏差。因为页面可能有导航栏、胶囊按钮、安全区导致clientX和容器左上角的相对位置差出一截。经验教训无论什么机型都先boundingClientRect获取容器实际坐标再换算格子。这是拼图坐标稳定不飘的基础。对于普通点击交换touchstart和touchend各取一次网格索引两个索引有效且不同就执行交换。对于长按拖拽还要记录touchstart的时间戳和初始坐标判断用户是点击还是拖拽。3.4 拖拽跟随与松手吸附拖拽跟随我用的是一个“拖拽层”方案在容器顶部覆盖一个绝对定位的 view平时隐藏拖拽时显示并通过transform: translate3d(x, y, 0)移动它。这样在touchmove阶段只需要setData更新拖拽位置不动碎片列表数据。onTouchMove(e) { if (!this.data.dragging) return; const touch e.touches[0]; const x touch.clientX - this.boardLeft; const y touch.clientY - this.boardTop; this.setData({ dragX: x - this.tileSize / 2, dragY: y - this.tileSize / 2 }); }dragX和dragY让拖拽的碎片中心跟随手指体验更自然。松手时再根据当前位置计算目标格子如果目标格子和起始格子不同就交换order里的两个值如果相同就原样回弹。回弹动画我用 CSStransition实现先把碎片的位置改成目标格子的坐标再触发一个transition等动画结束后更新order数组。这里有个小技巧动画过程中不要立即更新order否则碎片会先跳回原来位置再动画视觉上很突兀。3.5 长按与点击的区分策略如果你需要同时支持“点击交换”和“长按拖拽”需要在touchstart时记录时间戳在touchend时判断onTouchStart(e) { const touch e.touches[0]; this.startTime Date.now(); this.startX touch.clientX; this.startY touch.clientY; this.startIndex this.getGridIndex(touch.clientX, touch.clientY); } onTouchEnd(e) { const duration Date.now() - this.startTime; const touch e.changedTouches[0]; const endIndex this.getGridIndex(touch.clientX, touch.clientY); if (duration 300 !this.data.dragging) { // 短按走点击交换逻辑 if (this.data.selectedIndex -1) { this.setData({ selectedIndex: this.startIndex }); } else { this.swapTiles(this.data.selectedIndex, this.startIndex); this.setData({ selectedIndex: -1 }); } } else { // 长按拖拽执行吸附交换 this.dragEnd(endIndex); } }点击交换的交互是“先选一个再点另一个”选中时给格子加上高亮边框。拖拽交互则是长按某块碎片直接拖动。这两个交互并存时要小心长按触发后touchend就不要走点击交换逻辑了我用this.data.dragging做状态标记来区分。4. 打乱、判定与步数管理让拼图真正“可玩”交互做好后拼图游戏才完成了六成。剩下的是游戏逻辑怎么打乱、怎么判定完成、怎么给用户步数和时间反馈。这些逻辑看着不复杂但没处理好会影响体验。4.1 保证可还原的洗牌算法很多人写打乱直接用Math.random()对数组sort这会导致生成的拼图有概率无解。正确做法是从正确序列出发进行一定次数的随机交换这样一定能还原。shuffle() { const correct [0, 1, 2, 3, 4, 5, 6, 7, 8]; let shuffled [...correct]; const steps 200; for (let i 0; i steps; i) { const a Math.floor(Math.random() * 9); const b Math.floor(Math.random() * 9); [shuffled[a], shuffled[b]] [shuffled[b], shuffled[a]]; } this.setData({ order: shuffled, stepCount: 0 }); }随机交换 200 次足以让拼图看起来完全打乱但不会出现无解情况。虽然随机交换 200 次也可能结束后回到几乎正确位置概率极低如果担心可以加一步校验如果洗牌后和正确序列完全一致就重新洗一次。4.2 完成判定的两种判断方式完成判定其实很简单在每次交换后比较order.every((v, i) v i)即可。但要注意setData是异步的直接比较this.data.order可能拿到旧值。我的做法是维护一份独立的this.currentOrder数组交换时同步更新判定时用它。checkWin() { const isWin this.currentOrder.every((v, i) v i); if (isWin) { this.setData({ gameStatus: win }); wx.vibrateShort({ type: medium }); if (this.timer) { clearInterval(this.timer); this.timer null; } } return isWin; }判定后我用wx.vibrateShort做触觉反馈这个小细节能让完成瞬间更有“成就感”。视觉上加一个弹窗或整图淡入效果比纯文字提示好得多。4.3 步数统计与计时器步数统计很简单每次成功交换就stepCount 1。点击交换和拖拽交换都要计入但“选中再取消”不要计步。计时器用setInterval每 10 秒更新一次elapsedTime即可。注意两点在onHide、onUnload时清理计时器防止后台运行白白计时。按“暂停”时也要清理计时器继续时重新开启。4.4 游戏进度缓存与恢复用户拼到一半退出下次进来想继续我把order数组、步数、图片路径和已用时间存到wx.setStorageSync加载页面时从缓存恢复。saveProgress() { wx.setStorageSync(puzzle_progress, { order: this.currentOrder, stepCount: this.data.stepCount, tileImages: this.tiles, elapsedTime: this.data.elapsedTime }); }缓存图片路径有个隐患canvasToTempFilePath生成的临时文件下次启动可能失效。所以我在恢复进度时会先检查图片路径是否还能显示如果失效就用原始图片重新切割。这里也顺带回答一个频繁被问的问题wx.env.user_data_path适合存需要长期保存的文件比如用户拼好的成品图可以把 canvas 导出的文件复制到这个目录再存对应路径这样就不会被临时文件清理机制删掉。5. 开发中踩过的坑与真机适配细节这一部分是我最想分享的。有些问题安卓上好好的一到 iOS 就出错有些真机正常开发者工具预览却不对。如果你也在做类似功能建议直接翻到这一节对照看看。5.1 setData 性能优化为什么拖动还是会卡九宫格拼图的setData开销比想象中大。如果你在touchmove里更新order数组或者拖拽位置时把整个数组都带上会引起视图层全量 diff中低端机秒卡。我的优化策略有三个拖动中只更新位置不更新数组拖拽层的位置用transform控制且位置变更数据尽量精简。用 WXS 响应式处理高频位置更新如果非要实时更新拖拽层坐标可以在 WXML 绑定内联样式配合 WXS 减少通信开销。不过实测下来小程序对setData({ dragX, dragY })这种简单数据更新也能做到流畅。拖拽结束后再更新order松手后才 setData 数组避免拖动过程反复触发列表渲染。view classdrag-layer wx:if{{dragging}} styletransform: translate3d({{dragX}}px, {{dragY}}px, 0); image src{{tileImages[currentDragIndex]}} / /view5.2 iOS 真机坐标系偏差问题我遇到过一个经典问题开发者工具里拼图一切正常安卓真机也正常唯独 iPhone 上点击位置差了二十多像素。排查了很久最后发现原因有两个一是boundingClientRect获取的坐标和clientX的基准在 iOS 上存在细微差别尤其是页面有滚动或安全区避让时。解决方法是不要缓存boardLeft和boardTop每次touchstart都重新获取一次。二是 iOS canvas 2D 的 dpr 缩放问题。iPhone 的pixelRatio是 3如果 canvas 的逻辑尺寸和物理尺寸换算不对切出来的图会偏。我因为在ctx.scale(dpr, dpr)上处理得早规避了大部分问题但如果你用的是旧版 canvas 接口这里大概率会踩一跤。5.3 顶部导航栏高度和 iPhone 刘海屏适配拼图页如果是自定义导航栏顶部距离的适配很关键。小程序里wx.getMenuButtonBoundingClientRect()能拿到胶囊按钮的位置安全区上边距可以用wx.getWindowInfo().safeArea获取。const windowInfo wx.getWindowInfo(); const menuRect wx.getMenuButtonBoundingClientRect(); const navHeight (menuRect.top - windowInfo.safeArea.top) * 2 menuRect.height;这个navHeight可以作为自定义导航栏的高度。拼图区域放在导航栏下方同时用padding-bottom避开底部安全区。否则在 iPhone 上拼图底部按钮会被 home indicator 挡住。5.4 保存拼图结果到相册的授权坑用户拼完图常见需求是“保存成品图片到相册”。这里要从 Canvas 导出整张图片用wx.canvasToTempFilePath然后调用wx.saveImageToPhotosAlbum。这个接口需要授权scope.writePhotosAlbum如果用户拒绝过再次调用会直接 fail。我踩过的坑是在 iOS 上wx.saveImageToPhotosAlbum失败时fail回调里的errMsg是 “auth deny” 而不是 “authorize denied”我一开始没匹配对导致不会弹出二次授权引导。后来统一用err.errMsg.includes(auth)判断授权相关错误再弹窗引导用户去设置页打开权限。saveToAlbum() { wx.saveImageToPhotosAlbum({ filePath: this.finalImagePath, success: () { wx.showToast({ title: 已保存到相册 }); }, fail: (err) { if (err.errMsg err.errMsg.includes(auth)) { wx.showModal({ title: 需要授权, content: 请在设置中开启保存到相册的权限, confirmText: 去设置, success: (res) { if (res.confirm) { wx.openSetting(); } } }); } } }); }5.5 小图的处理与边界条件如果用户选了一张 200px 的小图切割成九宫格后每块只有 60 多像素放大到屏幕上就会模糊。我加了一个下限判断图片短边小于 300px 时提示用户更换图片。如果你的拼图是 4x4 或 5x5这个阈值还要更高。另外用户选择的图片如果是 Live Photo 或者 HEIC 格式临时路径可能是空或无法绘制需要在loadImageAndCut的 fail 回调里做兜底提示不要让页面卡在空白状态。5.6 扩展思路从九宫格到滑块验证拼图如果你后续想把九宫格拼图扩展成滑块验证式拼图核心变化在于多了一个“缺口”概念需要把底板图和滑块图分两层绘制。拖动时只沿 X 轴移动Y 轴固定。判定条件是滑块与缺口的距离小于容差比如 5px而不是数组相等。我自己实现时发现滑块拼图反而比九宫格简单因为它不需要维护二维数组状态只要维护一个滑块 X 坐标和缺口 X 坐标。但缺口裁剪时需要同时生成带缺口的底图和凸起的滑块图这又回到了 canvas 绘制的坑里——建议把切割封装成公共函数复用。最后再分享两个小细节。一个是我在做拼图时发现给碎片加上轻微的阴影视觉上会比纯图片边界清晰很多用户拖拽时也能更明确地判断“哪块被选中了”。这个效果只需要一个box-shadow样式成本极低但对体验提升很明显。另一个是如果图片里有比较丰富的大面积纯色区域比如天空、墙壁用户拼起来会非常难因为几块碎片看起来几乎一样。我后来在后台加了一个“图片复杂度检测”用简单的颜色直方图计算一下去重色占比过于单一就提示用户换图。当然这个功能要考虑服务端成本如果只是小工具类小程序不做也完全能跑。拼图这个功能做好之后其实是那种“看起来简单但每一步都能写一篇博客”的活儿。至少对我来说坐标换算、真机适配、canvas 性能这三个坎过去之后再做别的图片类交互就顺手多了。如果你正在做类似功能希望这篇能帮你少踩几个坑。
返回列表