ARTICLE DETAIL

资讯详情

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

微信小程序绘画学习平台开发实战:从Canvas绘制到调试交付全流程

微信小程序绘画学习平台开发实战:从Canvas绘制到调试交付全流程 我以前做完一个“基于微信小程序的绘画学习平台”的项目后最大的感受是这类带源码、文档和调试说明的交付物真正值钱的部分其实不在代码本身而在“为什么这么设计”和“踩过哪些坑”。尤其是如果你打算把它用作毕设、课设或者作品集项目评审老师大概率不会一行行读你的代码而是看你讲不讲得清楚架构、能不能现场跑起来、遇到问题能不能自己排查。这篇文章我就以这个绘画学习平台为例从项目定位、核心功能拆解、关键技术实现到调试经验完整讲讲我是怎么把一个空壳小程序做成一个能跑、能演示、能交付的完整项目。内容包括微信小程序登录获取手机号、顶部导航栏高度适配、canvas绘图、保存作品到相册等高频踩坑点也都一并整理出来。如果你正准备做一个类似的小程序项目这篇可以直接当参考。1. 平台定位与选型为什么是微信小程序而不是App或H51.1 绘画学习场景与小程序形态的匹配度先聊聊选题逻辑。绘画学习这个场景有几个特点第一用户需要看教学视频或图文步骤第二用户要跟着画也就是练习第三作品要能保存、展示、分享。这三点落在一个应用形态上微信小程序其实是很合适的。原因不复杂。小程序不需要安装用户扫一下码或搜一下就能打开学习类工具最怕的就是获客成本高小程序天然解决了这个问题。再者绘画学习的核心操作是手指或触控笔在屏幕上画小程序基于微信生态在iOS和Android上的触控事件支持都比较成熟Canvas 2D接口也足够完成画板功能。加上微信登录、手机号获取、分享到群聊这些能力都是现成的社交裂变属性也贴合“学习展示”的闭环。如果是做个App开发和上架成本都高学习类产品本身又没有太强的低频工具属性不划算。H5虽然开发快但保存图片到相册、获取用户手机号这类原生能力受限体验会打折扣。所以微信小程序是绘画学习平台的最优解。1.2 原生小程序 vs uniapp怎么做技术选型我考虑过用uniapp做跨端方案毕竟Vue语法写起来顺手而且能打包成H5和App。但最终我选择了原生小程序开发主要基于三个理由Canvas绘制性能绘画类项目对绘制流畅度要求高原生小程序的Canvas接口虽然也有坑但生命周期和事件模型是跟微信客户端直接打通的uniapp打包后Canvas相关API在端上会有一层桥接损耗遇到绘制异常不好定位是框架问题还是平台问题。调试成本原生小程序的调试器模拟器、真机调试、性能面板是最贴近真实环境的。如果你的项目最终要交付“源码文档调试”三件套那原生项目的调试过程本身就是文档的一部分评审方更容易理解。依赖可控uniapp插件市场虽然丰富但绘画这类功能往往要动底层事件处理用原生写WXML、WXSS、JS、JSON四件套逻辑清晰出问题排查链路短。如果你的项目定位是快速出一套跨端Demo那uniapp没问题但如果核心是“演示一个功能完整、能讲解、能调试的绘画学习平台”原生小程序更稳。1.3 整体功能模块划分我把这个平台按用户角色拆成两个端C端小程序和后台管理端这里后台我用了一个轻量的管理页面挂在管理员身份下没有单独做管理端小程序。C端核心功能有六个模块模块核心功能对应页面用户体系微信登录、获取手机号、昵称头像维护登录页、个人中心课程学习视频课程列表、图文教程详情、分步骤临摹模式首页、课程详情页、临摹页自由绘画画板、画笔粗细/颜色切换、撤销/重做、橡皮擦创作页作品管理保存作品到本地相册、作品预览、删除作品页激励系统学习打卡、练习积分、等级头像框个人中心教师后台课程上传、课程上下架、查看学习统计管理页这个功能列表看着多但每个功能之间耦合度控制得比较低。比如绘画功能单独一个模块登录和作品管理都是通过一个全局的userInfo对象串联课程学习不依赖创作模块这样调试的时候可以分模块单独验证。1.4 工程目录结构与代码组织原生小程序的目录结构比较固定但好的组织方式能让你后期调试省很多事。我是这么分的miniprogram/ ├── pages/ │ ├── index/ # 首页 │ ├── course/ # 课程列表与详情 │ ├── practice/ # 分步骤临摹页 │ ├── draw/ # 自由绘画画板 │ ├── works/ # 作品展示 │ ├── profile/ # 个人中心 │ └── admin/ # 教师后台 ├── components/ # 自定义组件 │ ├── draw-board/ # 画板组件封装Canvas │ ├── course-card/ # 课程卡片 │ └── level-frame/ # 等级头像框 ├── utils/ │ ├── auth.js # 登录与手机号获取 │ ├── request.js # 网络请求封装 │ ├── storage.js # 本地缓存管理 │ └── canvas-helper.js # Canvas绘制辅助函数 ├── images/ # 静态资源 ├── app.js ├── app.json └── app.wxss项目结构这个东西看着简单实际会影响你整个开发周期。我一开始懒得分层所有页面都直接拿Canvas操作后来加撤销功能时发现逻辑散落在三个页面里被迫重构了一版。建议一开始就把画板抽成自定义组件这样你修复一个绘制bug所有页面同步生效不用到处改。2. 用户体系与登录链路微信登录、手机号获取和权限设计2.1 静默登录优先授权按钮兜底绘画学习平台的第一步是让用户进入小程序。很多新手会在这块做错——用户一打开就弹窗要求授权手机号和头像结果用户还没看到产品长什么样就先被授权流程劝退了。我的做法是静默登录优先显式授权兜底。进入小程序时先调用wx.login获取code然后通过后端接口换成openid和session_key。这个动作是无感的用户不需要点任何按钮。拿到openid后我先生成一个临时用户记录让用户可以正常浏览课程、试绘画板。只有当用户要保存作品、参与打卡、同步学习进度时才弹出“获取手机号”的按钮引导。// utils/auth.js 核心逻辑 const silentLogin () { return new Promise((resolve, reject) { wx.login({ success: async (res) { if (res.code) { try { const { openid, sessionKey } await request.post(/auth/login, { code: res.code }); wx.setStorageSync(openid, openid); wx.setStorageSync(sessionKey, sessionKey); resolve({ openid, sessionKey }); } catch (e) { reject(e); } } else { reject(new Error(wx.login failed)); } } }); }); };这一步注意wx.login拿到的code五分钟有效且只能用一次。你在后端换openid时的接口地址必须是HTTPS并在小程序后台配置为request合法域名否则真机上一调就报“url not in domain list”。开发调试阶段可以先在开发者工具里勾选“不校验合法域名”但交付前一定要配上真实域名不然换台手机演示直接瘫痪。2.2 获取手机号新版button组件与老版API的区别获取手机号这块我踩过一个比较大的坑。微信官方从基础库2.21.2开始把手机号快速验证组件升级成了button open-typegetPhoneNumber配合wx.getPhoneNumber的云端调用方式。老式的直接返回加密手机号的接口逐渐被限制。也就是说你不能在代码里随便调一个API就拿到明文手机号必须满足这几个条件使用button open-typegetPhoneNumber bindgetphonenumberonGetPhoneNumber让用户主动点击。拿到cloudID或加密数据后在微信云开发环境里解密或者通过后端调用phonenumber.getPhoneNumber接口换取明文。如果你用的不是云开发而是自建后端流程会绕一些前端把code和encryptedData传给后端后端调用微信接口把手机号解出来。示例代码如下!-- 页面模板 -- button open-typegetPhoneNumber bindgetphonenumberhandlePhoneNumber 获取手机号 /button// 事件处理 async handlePhoneNumber(e) { if (e.detail.errMsg ! getPhoneNumber:ok) { wx.showToast({ title: 您拒绝了授权, icon: none }); return; } const { code } e.detail; const res await request.post(/auth/bind-phone, { code }); if (res.data.phone) { wx.setStorageSync(phone, res.data.phone); } }注意e.detail.code是动态令牌有效期很短而且每一个code只能绑定一次。如果你在开发工具里测试需要手动在详情设置里填入测试手机号否则会报错。这里有个很重要的经验手机号绑定接口一定要做幂等设计。用户可能点了两次按钮发了两个code后端必须能处理“同一个用户重复绑定手机号”的情况否则就会出现用户绑定了A手机号第二次误点后又绑定成B手机号。2.3 顶部导航栏高度的兼容适配这个和用户体系沾边但更偏全局我单独拿出来讲。因为小程序顶部导航栏在不同机型上的高度是不一样的尤其对于绘画学习平台我们做了一个自定义导航栏为了嵌入课程分类tab结果在适配时出了不少问题。标准做法是在app.js的onLaunch里读取胶囊按钮位置和状态栏高度全局存储所有页面通过wx.getMenuButtonBoundingClientRect()动态计算。// app.js 全局适配 const systemInfo wx.getSystemInfoSync(); const menuRect wx.getMenuButtonBoundingClientRect(); const navHeight (() { const menuTop menuRect.top; // 胶囊上边界到屏幕顶的距离 const statusBarHeight systemInfo.statusBarHeight || 20; return (menuTop - statusBarHeight) * 2 menuRect.height; })(); globalData { statusBarHeight: systemInfo.statusBarHeight, navHeight: navHeight, menuRect };算出来的navHeight就是导航栏实际高度。然后页面里用padding-top: {{statusBarHeight}}px保底导航栏内容区高度用navHeight撑开。很多人的bug出在哪里呢他们把导航栏高度写死成44px或64px结果iPhone 12和iPhone SE的显示效果完全不一样。正确的姿势是永远不要写死导航栏高度永远动态计算。另外如果你用了navigationStyle: custom自定义导航栏记得给页面底部也留出iPhone X安全区距离否则底部“保存作品”按钮会被Home条手势区域盖住。这个用env(safe-area-inset-bottom)处理。3. 核心绘画功能实现Canvas画板、撤销重做与作品保存3.1 Canvas接口选型旧版canvas vs Canvas 2D绘画平台的重头戏当然是画板。我在初始化阶段纠结过用旧的wx.createCanvasContext还是新版Canvas 2D接口。最终选型是用Canvas 2Dtype2d。原因很直接旧版canvas虽然写起来简单ctx.moveTo和ctx.lineTo一把梭就行但它存在两个硬伤。第一在高频绘制时容易出现线条断裂或模糊尤其用手指快速画折角的时候。第二旧版canvas导出图片时容易出现尺寸不匹配明明画板是375宽导出却变成了一小张。Canvas 2D的写法差异不大canvas type2d idmyCanvas classcanvas-board/canvas// 获取Canvas 2D上下文 const query wx.createSelectorQuery(); query.select(#myCanvas) .fields({ node: true, size: true }) .exec((res) { const canvas res[0].node; const ctx canvas.getContext(2d); const dpr wx.getSystemInfoSync().pixelRatio; canvas.width res[0].width * dpr; canvas.height res[0].height * dpr; ctx.scale(dpr, dpr); // 之后的操作都基于ctx });这里有个关键点Canvas 2D拿到的ctx它的坐标系默认跟CSS像素一致不会自动乘设备像素比。你要是不乘dpr画出来的线条在Retina屏上是模糊的。上面代码里的canvas.width res[0].width * dpr是必须的否则真机上保存的图片会发虚。还有Canvas 2D的canvas节点必须通过query.select获取this.createSelectorQuery()要在小程序页面实例上调用不能在组件的attached生命周期里直接用wx.createSelectorQuery()否则可能查不到节点。如果你封装了画板组件要在ready生命周期后执行查询。3.2 画笔、橡皮擦和线条平滑处理画笔实现的核心是touch事件。touchstart记录起点touchmove持续连线touchend结束这一段笔触。canvas.addEventListener(touchstart, (e) { // 获取当前相对canvas的坐标 const { x, y } getCanvasPos(e); ctx.beginPath(); ctx.moveTo(x, y); ctx.strokeStyle currentColor; ctx.lineWidth currentSize; currentSegment []; }); canvas.addEventListener(touchmove, (e) { const { x, y } getCanvasPos(e); ctx.lineTo(x, y); ctx.stroke(); });这种写法能跑但画出来的线会有些毛糙。更好的方式是记录点集用二次贝塞尔曲线做平滑// 用中点法画平滑曲线 const midPoint (p1, p2) ({ x: (p1.x p2.x) / 2, y: (p1.y p2.y) / 2 }); // 连续画贝塞尔曲线 for (let i 1; i points.length - 1; i) { const mid midPoint(points[i], points[i 1]); ctx.quadraticCurveTo(points[i].x, points[i].y, mid.x, mid.y); }这个技巧叫“中点法平滑”本质是用两个点之间的中点作为贝塞尔终点原采样点作为控制点这样线条不会出现突兀的折角。实测下来手指慢画的时候效果提升非常明显。橡皮擦不需要额外发明轮子直接ctx.globalCompositeOperation destination-out然后用白色画线就能实现“擦除”效果。注意用完后切换回画笔要把globalCompositeOperation重置为source-over不然会出现画上去的线条是透明的诡异情况。3.3 撤销和重做基于快照栈的实现撤销功能是所有画板项目的分水岭。很多纯前端方案是存一个ImageData栈每画一笔就ctx.getImageData存快照撤销时putImageData恢复。这个方案在小程序Canvas 2D里要谨慎因为画板尺寸大、存快照多的话内存峰值很恐怖。我用的方案是合并线段栈每次touchend后把这条线段的点序列和画笔配置颜色、粗细存入数组撤销时重绘整幅画面重绘过程中跳过被撤销的线段。// 线段数据结构 const line { points: [{ x, y }, ...], color: #000000, width: 4, type: pen // 或 eraser }; // 撤销弹出最后一条线段重绘 const history []; undo() { if (history.length 0) return; history.pop(); redrawAll(); } redrawAll() { ctx.clearRect(0, 0, canvasWidth, canvasHeight); // 重置背景为白色 ctx.fillStyle #ffffff; ctx.fillRect(0, 0, canvasWidth, canvasHeight); history.forEach((line) { drawLine(ctx, line); }); }这个方案在绘画学习平台场景下足够用了。为什么不用逐像素快照因为Canvas 2D里导出ImageData的耗时和内存都很大用户画个几十笔就卡顿演示的时候很尴尬。线段重绘虽然每次撤销都要全量重画但用户画板的线段量一般不会上千条重绘一次耗时在几十毫秒内体验完全可接受。重做逻辑就是维护一个redoStack撤销时把弹出来的线段推进redoStack重做时弹回来再重绘。如果用户撤销后画了新线段要把redoStack清空这是标准的编辑器交互逻辑。3.4 保存作品到相册与分享画完画用户最自然的动作就是保存或分享。小程序里Canvas 2D导出图片用wx.canvasToTempFilePath。有两个常见坑第一Canvas 2D的canvas对象导出时要传入canvas字段为canvas节点实例。wx.canvasToTempFilePath({ canvas: canvas, // Canvas 2D的节点对象 success(res) { const tempFilePath res.tempFilePath; wx.saveImageToPhotosAlbum({ filePath: tempFilePath, success() { wx.showToast({ title: 已保存到相册 }); }, fail() { // 用户拒绝相册权限时要引导打开设置页 wx.showModal({ title: 提示, content: 需要您同意保存图片到相册, confirmText: 去设置, success(res) { if (res.confirm) { wx.openSetting(); } } }); } }); } });第二保存的图片背景必须是画板尺寸一致。有些同学画板用了CSS的百分比宽度但Canvas的width和height没设置实际像素值导致导出图片要么全黑要么只有一小块。调试时优先检查canvas.width、canvas.height是不是正常的整数。分享到朋友圈或者转发群聊用的是onShareAppMessage这块比较常规不多说。但我建议绘画平台在这里加一个细节分享的时候带上当前作品的缩略图路径接收方打开小程序直接跳到作品预览页转化率会高不少。3.5 canvas-lazy画布初始化常见的白屏问题画布白屏是高频问题。我排查过好几次最终定位到两个原因canvas节点被隐藏或display:none小程序里canvas节点如果在页面不可见区域初始化时高度计算可能为0导致canvas.width0自然画不出来。解决方式是在onReady后用wx.createSelectorQuery()查询节点尺寸并打印确认。异步数据未到就初始化比如你要在画板上绘制模板底图临摹模式的参考线但图片还没load完就开始绘制。必须用canvas.createImage()创建图片对象在onload回调里再画const img canvas.createImage(); img.onload () { ctx.drawImage(img, 0, 0, canvasWidth, canvasHeight); }; img.src templateUrl;注意Canvas 2D里不能用wx.getImageInfo返回的图片路径直接drawImage必须用canvas.createImage()。这个细节官方文档没写太明白很多人卡在这里。4. 课程学习与临摹模式把教学和绘画结合起来4.1 分步骤临摹模式的设计逻辑绘画学习平台不能只有自由画板否则就和普通画板工具没区别了。我给平台加了一个“分步骤临摹”模块思路是把一幅画拆成若干个临摹阶段。比如画一个苹果步骤分为轮廓、明暗分界、底色填充、高光点缀。每个阶段给用户提供一张半透明的参考图用户在当前画板上照着画。画完一个阶段点击“下一阶段”参考图切换为下一步的叠加线条。这个功能的实现有两个核心点参考图的透明度控制绘制参考图时先ctx.globalAlpha 0.5画完再恢复为1。阶段切换时不清空用户的绘画内容只切换参考底图。// 阶段切换 switchStage(stageIndex) { if (stageIndex 0 || stageIndex stages.length) return; this.setData({ currentStage: stageIndex }); const bgImage stages[stageIndex].imageUrl; const img canvas.createImage(); img.onload () { // 重绘当前用户内容 redrawAll(); // 再绘制半透明参考图 ctx.save(); ctx.globalAlpha 0.5; ctx.drawImage(img, 0, 0, canvasWidth, canvasHeight); ctx.restore(); }; img.src bgImage; }这里注意一个问题canvas.createImage()每次加载图片都有网络开销。如果课程图片多可以用wx.setStorageSync做结果缓存或者预加载下一张图片。演示的时候如果网络慢阶段切换会有空白等待体验不好。4.2 视频课程与图文教程的播放控制课程内容我分成视频课程和图文教程两种。视频课程直接用video组件但要注意video组件是原生组件层级最高会盖住自定义导航栏和弹窗。如果你在课程详情页有“打卡”按钮需要把这个按钮做成cover-view或者用同层渲染能力来解决。视频播放进度的回调频率很高不要每帧都setData可以每5秒同步一次进度条由video自己显示我们只存“上次播放位置”。图文教程用ScrollView配合富文本渲染。这里有个安全提示富文本不能简单用rich-text组件直接渲染后端返回的HTML因为rich-text不支持img标签的懒加载且对HTML标签有白名单限制。建议后端返回标准化JSON结构字段包含段落、图片、标题等前端自定义渲染。4.3 单选框组的坑课程筛选为什么不直接使用radio首页课程分类我用到了筛选功能。很自然的做法是使用radio-group和radio。实际开发中我发现默认的radio样式在视觉效果上特别像“考试选择题”跟课程平台的气质不搭。更麻烦的是在小程序自定义组件里使用radio它的value绑定经常因为组件间数据传递不及时导致选中状态异常。我最后的做法是不用radio改用自定义tab列表纯view加class切换。view classcategory-tabs view wx:for{{tabs}} wx:keyid classtab-item {{activeTab item.id ? active : }} bindtapswitchTab >// utils/request.js const request (path, method GET, data {}) { const baseUrl https://your-api.example.com; return new Promise((resolve, reject) { wx.request({ url: ${baseUrl}${path}, method, data, header: { Content-Type: application/json, // 避免一块拿不到登录态 Authorization: wx.getStorageSync(token) || }, timeout: 10000, success(res) { if (res.statusCode 200) { resolve(res.data); } else { reject(res); } }, fail(err) { reject(err); } }); }); };关于超时默认timeout是60秒对这个项目而言太长了。用户画完一幅画要保存如果接口请求10秒没返回早就急死了。我把超时设为10秒并在页面层加loading提示实测体验好很多。后端接口的联调建议如果后端还没开发完可以先Mock数据把接口返回结构定下来。我在项目早期就用了一个简单的本地Mock方案把课程列表、用户信息的返回值写成JSON文件前端先跑通页面后面再换真接口。这个习惯能帮你节省大量等后端的时间。5.3 文档交付好的文档不是给机器看的是给下一个开发者看的既然这个项目是“源码文档调试”的交付物那文档质量直接决定项目评分。我见过太多人写完代码后随便写几行“环境配置微信开发者工具”然后让下一个接手的人自己探索。我的文档结构基本是这样docs/ ├── 00-项目概述.md # 项目定位、技术栈、功能清单 ├── 01-环境配置.md # 微信开发者工具版本、后端运行方式、数据库初始化SQL ├── 02-目录结构说明.md # 每个目录和核心文件的作用 ├── 03-接口文档.md # 所有后端接口的请求方式、参数、返回示例 ├── 04-数据库设计.md # ER图、表结构、字段说明 ├── 05-调试指南.md # 常见问题排查步骤、不同机型的坑 └── 06-交付检查清单.md # 上线前要检查的事项写接口文档时不要只贴一个URL要把每个字段什么意思、边界情况是什么写清楚。比如“获取手机号”接口文档里要注明“该code仅一次有效使用后过期重复请求需要用户重新点击按钮”。这些细节才是调试时真正救命的。5.4 部署上线前必须检查的清单我在交付前整理过一个检查清单这里直接分享检查项说明request合法域名确保所有HTTPS域名已在微信后台配置用户隐私协议小程序后台要更新《用户隐私保护指引》尤其涉及手机号手机号快速验证组件资质个人主体小程序不能直接使用该能力需企业主体Canvas白屏回归换3台不同机型测试相册权限被拒的引导必须有openSetting引导否则审核会被拒分享卡片缩略图尺寸分享图必须使用网络图片或本地路径尺寸建议5:4真机调试的console无报错不能有红色error体验版二维码交付时可让评审方扫体验版而不是让评审方自己配域名上面前三行是最容易踩的红线。尤其个人主体的小程序获取手机号是受限的如果你在演示时需要手机号绑定最好准备一个小程序后台账号给评审用。5.5 GDB调试类比小程序的调试架构思维如果你之前写过C/C用过GDB调试工具你会发现小程序调试的思维方式是相通的。GDB是设置断点、查看变量、观察堆栈小程序开发者工具的debugger也是同一套思路。调试小程序的网络面板和Console面板是我的主力。network里看每个请求的耗时和返回console里看真机上报的日志。我习惯在关键流程加console.log比如登录态变化、Canvas初始化完成、图片保存成功这样远程调试时能快速定位问题。画板类项目遇到“真机上按钮点击没反应”时先看是不是有原生组件覆盖再看是不是catchtap和bindtap用混了。catchtap会阻止事件冒泡bindtap不会在自定义组件里误用catchtap经常会导致父组件的点击事件失效。6. 这个项目的后续优化空间项目做到可交付、可演示的程度之后我通常还会想一想后续优化方向。这个绘画学习平台如果要继续演进我会优先做三件事第一把画板升级成支持多层图层。绘画教学里老师画底层草稿、学生在上层临摹是很常见的单层画板限制了这种教学体验。多层画板再配合透明度调节教学感会强很多。第二加入AI辅助点评。比如通过简单的色彩占比分析、线条流畅度评分给用户的练习作品一个基础反馈。这个不需要很强的能力用Canvas解析像素就能做一个入门版的“配色分析”。第三把打卡学习和社交关系链更深地绑定。比如用户完成每日一画自动生成一张带二维码的“今日画作卡片”分享到群聊后别人扫进来可以看到原作品和教学步骤形成学习闭环。当然这些优化要结合项目周期来排优先级。如果这是一个毕设项目把核心流程打磨好、文档写规范、调试链路讲清楚就已经比大多数同类交付物出色了。最后说点实在的我在做这个项目的过程中最深的体会是调试能力比编码能力更能决定一个项目的完成度。很多代码写出来是“看起来能用”但只有经历过真机测试、不同机型适配、网络异常、权限被拒这一系列问题之后你才真正理解“可运行”和“可交付”之间的差距。希望这篇分享能帮你少踩几个坑。
返回列表