小程序开发走到画布这一层,基本就告别了"拖拽组件、拼拼页面"的舒适区。画布API是小程序里少数几个需要你真正理解坐标系、渲染时机和异步回调的地方,也是很多开发者从"能写页面"到"能做产品"之间的一道坎。这份画布API汇总的第四篇,我打算把重心放在那些真正会在项目里反复用到、又特别容易踩坑的能力上——绘图上下文的状态管理、路径绘制与裁剪、图片合成、文字排版、渐变填充,以及导出与跨端适配。不管你是刚接触画布、想搞清楚createCanvasContext到底怎么用,还是已经写过几个画布项目、被真机与开发者工具的差异折磨过,这篇内容都能给你一些可以直接抄作业的代码和避坑经验。画布API汇总这个系列写到第四篇,前面几篇铺垫了基础绘图和坐标体系,这一篇更偏向"组合拳",讲的是多个API怎么配合起来完成一个完整功能。
1. 画布能力集成的整体设计与选型思路
1.1 为什么画布API值得单独拿出来做汇总
小程序里的画布能力,本质上是一套"命令式绘图接口"——你调用fillRect、stroke、drawImage这些方法,把绘制指令压进一个队列,最后由框架统一提交给底层渲染。这个模型和浏览器里的Canvas 2D Context非常接近,但又有明显差异:小程序的画布上下文是异步的,很多操作需要配合draw回调或者canvasToTempFilePath的success才能拿到结果。理解这一点,是理解整个画布API体系的前提。
我在实际项目里见过太多这样的场景:开发者按照Web端的习惯写了一段绘图代码,在开发者工具里跑得好好的,一上真机就白屏或者错位。问题往往不在API本身,而在于对"绘制指令何时生效""画布尺寸与样式尺寸的关系""图片加载与绘制的时序"这几个核心机制理解不到位。所以这份汇总不打算做成API文档的搬运工,而是按"能力域"来组织——把状态管理、路径、图片、文字、渐变、导出这几块拆开讲,每块都配上能直接跑的代码和真机验证过的注意事项。
从选型角度看,小程序画布目前主要有两套接口:旧版的CanvasContext(通过wx.createCanvasContext获取)和新版的Canvas 2D(通过canvas节点的getContext('2d')获取)。旧版接口兼容性好、上手快,但性能和部分高级特性受限;新版接口更接近标准Canvas 2D,支持requestAnimationFrame、离屏画布等能力,适合做复杂动画和高频重绘。这份汇总以旧版接口为主线,因为它在存量项目里占比最大,同时在关键处标注新版接口的差异,方便你做迁移判断。
1.2 能力域拆解:一份画布项目到底需要哪些API
把画布API按能力域拆开,能帮你建立一张"知识地图",遇到需求时知道该去查哪一块。我通常把它分成六层:
| 能力域 | 核心API | 典型用途 |
|---|---|---|
| 上下文与状态 | createCanvasContext、save、restore、setFillStyle | 管理绘图状态、隔离样式 |
| 路径绘制 | beginPath、moveTo、lineTo、arc、closePath、clip | 图形、图表、裁剪蒙版 |
| 图片处理 | drawImage、getImageData、putImageData | 图片合成、水印、像素处理 |
| 文字排版 | setFontSize、fillText、measureText、setTextAlign | 海报、证书、动态文案 |
| 渐变与填充 | createLinearGradient、createCircularGradient、addColorStop | 背景、按钮、视觉增强 |
| 导出与适配 | canvasToTempFilePath、draw、canvasId与节点查询 | 保存图片、跨端一致性 |
这张表不是让你背下来,而是让你在动手前先想清楚:这个需求落在哪一层,需要哪几个API组合。比如做一张分享海报,就同时涉及图片处理(底图、头像)、文字排版(昵称、标语)、渐变填充(背景装饰)和导出(生成临时文件),是一个典型的"多能力域协作"场景。
1.3 旧版与新版画布接口的取舍逻辑
选旧版还是新版,不是"新的就一定好"这么简单。我一般按三个维度判断:
- 项目存量:如果项目里已经有大量基于
wx.createCanvasContext的代码,贸然迁移成本高、风险大,除非遇到明确的性能瓶颈,否则维持现状更稳妥。 - 性能要求:需要做高频重绘(比如实时滤镜、粒子动画)时,新版
Canvas 2D配合requestAnimationFrame优势明显,旧版在真机上容易出现掉帧。 - 特性依赖:需要离屏画布、
getImageData做像素级处理、或者更精细的合成模式时,新版接口支持更完整。
注意:新旧两套接口的坐标系原点、尺寸获取方式、
draw的调用时机都有差异,混用极易出错。一个项目里尽量统一用一套,迁移时按页面逐个替换,不要新旧混写。
2. 上下文状态管理与路径绘制实操要点
2.1 save与restore:画布状态隔离的核心机制
画布的绘图状态包括填充色、描边色、线宽、字体、变换矩阵、裁剪区域等。save把当前状态压栈,restore弹出栈顶状态恢复。这个机制的价值在于"局部修改不影响全局"——你可以在一个函数里随意改样式,函数结束前restore,外层的绘图状态完全不受污染。
我见过不少项目从头到尾不写save/restore,全靠手动把每个样式改回去,代码越写越乱,最后没人敢动。正确的做法是:任何一段"会修改状态"的绘制逻辑,进入时save,退出时restore,形成一个封闭的作用域。这跟函数作用域的思路是一样的——局部变量不外泄。
const ctx = wx.createCanvasContext('myCanvas') // 外层状态:红色填充 ctx.setFillStyle('#e74c3c') ctx.fillRect(0, 0, 100, 100) // 内层绘制:独立作用域 ctx.save() ctx.setFillStyle('#3498db') ctx.translate(50, 50) ctx.rotate(Math.PI / 4) ctx.fillRect(-25, -25, 50, 50) ctx.restore() // 回到外层状态,仍是红色 ctx.fillRect(150, 0, 100, 100) ctx.draw()这段代码里,内层的旋转和蓝色填充被save/restore包裹,外层的红色填充不受影响。如果去掉save/restore,第二次fillRect会继承旋转矩阵,画出来的方块就是歪的——这是新手最常遇到的"为什么我的图形位置不对"的根源之一。
2.2 路径绘制的顺序陷阱与beginPath的必要性
路径是一系列子路径的集合,beginPath清空当前路径,moveTo开启新子路径,lineTo、arc等追加线段或弧线,closePath闭合当前子路径,最后fill或stroke渲染。这里最容易踩的坑是忘记beginPath:路径会不断累积,上一次的图形和这一次连在一起,fill出来的形状完全失控。
// 错误示范:没有beginPath,两个三角形连成一片 ctx.moveTo(10, 10) ctx.lineTo(60, 10) ctx.lineTo(35, 50) ctx.closePath() ctx.fill() ctx.moveTo(80, 10) ctx.lineTo(130, 10) ctx.lineTo(105, 50) ctx.closePath() ctx.fill() // 这里会把第一个三角形也重新填充一遍正确写法是每次绘制独立图形前调用beginPath。另外要注意closePath和beginPath的区别:closePath只是把当前子路径首尾相连,不清空路径;beginPath才是真正的"清空重来"。做图表这类需要绘制大量独立图形的场景,beginPath的调用位置直接决定渲染结果对不对。
2.3 裁剪clip:做圆角头像和异形蒙版的利器
clip把当前路径设为裁剪区域,之后的所有绘制都被限制在这个区域内。做圆角头像的经典写法就是:画一个圆或圆角矩形路径,clip,再drawImage头像图,超出部分自动被裁掉。
ctx.save() ctx.beginPath() ctx.arc(60, 60, 50, 0, Math.PI * 2) ctx.clip() ctx.drawImage(avatarPath, 10, 10, 100, 100) ctx.restore()这里save/restore是必须的——clip会永久改变裁剪区域,不恢复的话后续所有绘制都被限制在圆内。我踩过的坑是:裁剪区域一旦设置,连clearRect都受它约束,导致"想清空画布却清不干净"。所以凡是用了clip,务必用save/restore包起来。
提示:
clip的路径必须在clip调用前用beginPath重新构建,否则会继承之前的路径,裁剪区域和预期不符。这是真机上特别容易出问题的地方。
2.4 坐标变换:translate、rotate、scale的组合顺序
坐标变换是画布做复杂布局的基础,但组合顺序会显著影响结果。translate平移原点,rotate绕原点旋转,scale缩放坐标轴。关键规则是:变换按调用顺序累积,后调用的变换作用在前一次变换的基础上。
举个实际例子,要在画布上画一个绕自身中心旋转的矩形:
ctx.save() ctx.translate(cx, cy) // 先把原点移到矩形中心 ctx.rotate(angle) // 绕新原点(即矩形中心)旋转 ctx.fillRect(-w / 2, -h / 2, w, h) // 以中心为基准绘制 ctx.restore()如果顺序反过来,先rotate再translate,平移的方向会被旋转矩阵影响,矩形跑到意想不到的位置。我的经验是:先平移把原点挪到目标位置,再做旋转和缩放,最后以原点为中心绘制,这个顺序最符合直觉,也最容易调试。
3. 图片合成与文字排版的完整实现流程
3.1 drawImage的三种调用形式与图片加载时序
drawImage支持三种参数形式:三参数(图片、x、y)、五参数(图片、x、y、宽、高)、九参数(图片、源图x、y、宽、高、目标x、y、宽、高)。九参数形式是做图片裁剪合成的关键——你可以只取源图的一部分绘制到目标区域。
但比参数更容易出问题的是图片加载时序。drawImage的图片参数可以是临时文件路径、网络图片(需先下载)或本地资源。如果图片还没加载完就调用drawImage,真机上大概率画不出来,开发者工具里却可能"碰巧"成功,造成"工具正常、真机白屏"的经典问题。
稳妥的做法是先把所有图片加载完,再统一绘制:
function loadImage(src) { return new Promise((resolve, reject) => { wx.getImageInfo({ src, success: res => resolve(res.path), fail: reject }) }) } async function composePoster() { const [bg, avatar] = await Promise.all([ loadImage(bgUrl), loadImage(avatarUrl) ]) const ctx = wx.createCanvasContext('posterCanvas') ctx.drawImage(bg, 0, 0, 375, 600) ctx.drawImage(avatar, 20, 20, 80, 80) ctx.draw() }wx.getImageInfo不仅能拿到本地路径,还能拿到图片的原始宽高,这对保持图片比例、计算裁剪区域非常有用。网络图片必须先通过它(或wx.downloadFile)转成本地路径,才能稳定绘制。
3.2 文字排版:measureText与多行文本换行
画布里的文字不会自动换行,长文本必须手动计算断行位置。measureText返回文本的宽度信息,配合画布宽度就能实现自动换行。核心逻辑是:逐字累加宽度,超过最大宽度就换行。
function wrapText(ctx, text, maxWidth, lineHeight, x, y) { let line = '' let currentY = y for (let i = 0; i < text.length; i++) { const testLine = line + text[i] const metrics = ctx.measureText(testLine) if (metrics.width > maxWidth && line !== '') { ctx.fillText(line, x, currentY) line = text[i] currentY += lineHeight } else { line = testLine } } ctx.fillText(line, x, currentY) }这里有几个细节值得注意:measureText在旧版接口里返回的宽度是同步可用的,但字体设置必须在测量前完成,否则测出来的是默认字体的宽度;中英文混排时逐字断行可能在英文单词中间断开,追求效果的话可以按空格和标点做断词优化;setTextAlign和setTextBaseline会影响fillText的定位基准,做居中排版时先设好对齐方式再计算坐标。
3.3 图片与文字的图层叠加顺序
画布是"后画的盖住先画的",图层顺序完全由绘制调用顺序决定。做海报这类合成场景,正确的顺序是:背景图 → 装饰图形 → 头像/图片 → 文字 → 水印。任何一步顺序错了,要么被遮挡,要么遮挡了不该遮挡的内容。
我习惯在代码里用注释明确标注图层,并且把每一层的绘制封装成独立函数,主流程只负责按顺序调用。这样调整图层时只需要挪动函数调用位置,不用在几百行代码里找哪句drawImage该放前面。
注意:
draw是异步提交绘制指令的,多次调用draw之间如果没有等待,可能出现指令交错。合成场景建议所有绘制完成后只调用一次draw,需要分阶段渲染时用draw的回调串起来。
4. 渐变填充、导出与跨端适配的问题排查
4.1 线性渐变与径向渐变的参数计算
createLinearGradient(x0, y0, x1, y1)定义渐变的方向和范围,addColorStop(offset, color)添加色标,offset取值0到1。渐变对象创建后赋给setFillStyle或setStrokeStyle即可生效。
const grd = ctx.createLinearGradient(0, 0, 0, 200) grd.addColorStop(0, '#ff9a9e') grd.addColorStop(1, '#fad0c4') ctx.setFillStyle(grd) ctx.fillRect(0, 0, 375, 200)参数计算的关键是理解渐变线的几何意义:渐变沿着从(x0,y0)到(x1,y1)的直线方向变化,垂直于这条线的方向上颜色相同。做水平渐变就让y坐标相同、x坐标不同;做对角线渐变就让两个坐标都变化。径向渐变createCircularGradient(x, y, r)以(x,y)为圆心、r为半径从中心向外渐变,做光晕、按钮高光很合适。
一个常见误区是渐变坐标用了画布的样式尺寸(比如375),但画布实际绘制尺寸是另一套值,导致渐变范围对不上。渐变坐标必须和绘制坐标使用同一套尺寸体系,这一点在做了scale变换后尤其要注意。
4.2 canvasToTempFilePath导出图片的完整流程
导出是画布项目最常见的收尾动作,canvasToTempFilePath把画布内容转成临时文件,可以预览、保存或上传。它的参数里,canvasId(旧版)或canvas节点(新版)、x/y/width/height(导出区域)、destWidth/destHeight(输出分辨率)是关键。
wx.canvasToTempFilePath({ canvasId: 'posterCanvas', x: 0, y: 0, width: 375, height: 600, destWidth: 750, destHeight: 1200, success: res => { // res.tempFilePath 即生成的图片路径 wx.previewImage({ urls: [res.tempFilePath] }) }, fail: err => console.error('导出失败', err) })destWidth/destHeight设成绘制尺寸的两倍,是为了在高分屏上导出更清晰的图片——画布按逻辑像素绘制,导出时按物理像素输出,相当于做了2倍图。但要注意内存:尺寸越大,导出耗时和内存占用越高,超过一定阈值在低端机上可能失败。
4.3 真机与开发者工具差异的排查清单
画布相关的"工具正常、真机异常"问题,我整理了一张速查表,基本覆盖了八成以上的排查场景:
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 真机白屏 | 图片未加载完就绘制 | 用getImageInfo预加载,Promise.all等待 |
| 图形错位 | 画布样式尺寸与绘制尺寸不一致 | 统一用节点查询拿实际尺寸 |
| 文字不显示 | 字体设置晚于fillText | 先setFontSize再绘制 |
| 导出模糊 | destWidth未按倍率放大 | 按设备像素比设置输出尺寸 |
| 裁剪失效 | clip前未beginPath | 重建路径后再裁剪 |
| 渐变异常 | 渐变坐标与绘制坐标体系不一致 | 检查是否受scale影响 |
真机排查的核心方法是分段验证:把复杂绘制拆成几个简单步骤,逐步draw看哪一步开始出问题。另外,真机的画布尺寸获取建议用wx.createSelectorQuery查询节点实际宽高,而不是硬编码,这样能适配不同屏幕。
提示:旧版画布的
draw在部分机型上有延迟,连续快速调用可能丢帧。需要动画时优先考虑新版Canvas 2D配合requestAnimationFrame,旧版更适合静态合成场景。
5. 我在画布项目里踩过的坑与实操心得
5.1 尺寸体系混乱是万恶之源
画布有两套尺寸:样式尺寸(CSS里写的宽高)和绘制尺寸(canvas标签的width/height属性,或绘制时用的坐标系)。旧版接口里,绘制坐标系默认以样式尺寸为准,但导出时又涉及物理像素,三者不统一就会错位。我的做法是:页面加载时用节点查询拿到画布实际宽高,存成变量,所有绘制坐标都基于这个变量计算,绝不硬编码数字。这样换机型、换布局都不用改绘图代码。
5.2 异步时序要用Promise串起来
画布绘制涉及图片加载、字体加载、导出等多个异步环节,用回调嵌套写出来的代码又长又难维护。我习惯把每个异步操作封装成Promise,用async/await组织主流程,出错时统一catch。这样不仅代码清晰,排查问题时也能快速定位是哪个环节失败。
5.3 复杂画布要拆函数、加注释、留调试开关
一个几百行的绘图函数,改一个颜色都要找半天。我的经验是按图层拆函数,每个函数只负责一层,函数名体现图层含义;关键坐标用常量或变量命名,不写魔法数字;留一个调试开关,打开时给每个图层画边框,方便定位错位问题。这些习惯在项目后期维护时能省下大量时间。
画布API看着零散,但按能力域梳理清楚、把状态管理和异步时序这两个核心机制吃透,大部分需求都能稳稳落地。真正拉开差距的不是记住了多少API,而是对"绘制指令何时生效""坐标体系如何统一"这些底层逻辑的理解深度。