小程序05 canvas绘图并保存到相册

小程序现在没有提供直接分享到朋友圈的接口,所以只能采取折中的策略,即先将要分享的内容先保存为图片,保存到用户相册,然后再由用户将该图片分享到朋友圈

生成小程序码需要access token,后端生成比较方便

1 Canvas标签

小程序的绘图使用的是<canvas>标签

<canvas canvas-id="myCanvas" style="border: 1px solid;"></canvas?

onLoad或者mounted(mpvue)中绘图:

const ctx = wx.createCanvasContext('myCanvas')
ctx.setFillStyle('red')
// 从左上角(0, 0)开始,画一个150 x 75px 的矩形。
ctx.fillRect(10, 10, 150, 75)
ctx.draw()

canvas左上角的坐标是(0,0),默认宽度300px,高度225px,canvas-id不可重复

<!-- canvas.wxml -->
<canvas style="width: 300px; height: 200px;" canvas-id="firstCanvas"></canvas>
<!-- 当使用绝对定位时,文档流后边的 canvas 的显示层级高于前边的 canvas -->
<canvas style="width: 400px; height: 500px;" canvas-id="secondCanvas"></canvas>

更多小程序中canvas组件介绍看这里

2 绘图技巧

小程序提供的API原生的API非常相似。

2.1 创建图片drawImage()

参数 描述
img 规定要使用的图像、画布或视频。
sx 可选。开始剪切的 x 坐标位置。
sy 可选。开始剪切的 y 坐标位置。
swidth 可选。被剪切图像的宽度。
sheight 可选。被剪切图像的高度。
x 在画布上放置图像的 x 坐标位置。
y 在画布上放置图像的 y 坐标位置。
width 可选。要使用的图像的宽度。(伸展或缩小图像)
height 要使用的图像的高度。(伸展或缩小图像)

(1)在画布上定位图像

context.drawImage(img,x,y,width,height);

var c=document.getElementById("myCanvas");
var ctx=c.getContext("2d");
var img=document.getElementById("tulip");
ctx.drawImage(img,10,10);

(2)在画布上定位图像,并规定图像的宽度和高度:

context.drawImage(img,x,y);

var c=document.getElementById("myCanvas");
var ctx=c.getContext("2d");
var img=document.getElementById("tulip");
ctx.drawImage(img,10,10,240,160);

(3)剪切图像,并在画布上定位被剪切的部分

context.drawImage(img,sx,sy,swidth,sheight,x,y,width,height);

var c=document.getElementById("myCanvas");
var ctx=c.getContext("2d");
var img=document.getElementById("scream");
ctx.drawImage(img,90,130,90,80,20,20,90,80);

2.2 绘制网络图片

小程序中如果绘制的图片是网络图片,在开发者工具中可以预览,但是在手机端不会显示,需要先试用getImageInfo方法将网络图片下载到本地,然后用图片的本地路径绘制

// 绘制网络图片
const imgPath2 = 'https://m.360buyimg.com/babel/jfs/t17068/121/2715217240/96992/93902dda/5b07a381N85db5073.png';
wx.getImageInfo({
  src: imgPath2,
  success(res) {
    ctx.drawImage(res.path, left, 320, 200, 150);
  }
});

2.3 创建空心矩形rect()

context.rect(x,y,width,height);
参数 描述
x 矩形左上角的 x 坐标
y 矩形左上角的 y 坐标
width 矩形的宽度,以像素计
height 矩形的高度,以像素计
// 红色矩形
ctx.beginPath();
ctx.setLineWidth(1);
ctx.setStrokeStyle("red");
ctx.rect(left, 200, 200, 20);
ctx.stroke();

2.4 创建填充矩形fillRect()

context.fillRect(x,y,width,height);
参数 描述
x 矩形左上角的 x 坐标
y 矩形左上角的 y 坐标
width 矩形的宽度,以像素计
height 矩形的高度,以像素计

默认的填充颜色是黑色。使用setFillStyle方法填充颜色

ctx.setFillStyle('green');
ctx.fillRect(52, 55, 150, 75);

fillRect()strokeRect()在调用后会立即在画布上画面效果,而rect()不会立即将图形画出,只有在调用了`stroke()方法之后,才会实际作用于画布。

2.5 绘制文本fillText()

context.fillText(text,x,y,maxWidth);
参数 描述
text 规定在画布上输出的文本。
x 开始绘制文本的 x 坐标位置(相对于画布)。
y 开始绘制文本的 y 坐标位置(相对于画布)。
maxWidth 可选。允许的最大文本宽度,以像素计。
ctx.setFontSize(20);
ctx.setFillStyle('#6F6F6F');
ctx.setTextAlign('center');
ctx.fillText('妖妖灵', deciveWidth/2 , 220, 200);

要注意的是,x/y坐标位置一个点,setTextAlign是相对于这个点定位:

image

2.6 文本换行

Canvas的文字排版不支持自动换行,如果不设定maxWitdh,文字会一直横排下去,如果设定了maxWidth,会令文字变窄而不会换行。解决这个问题的方法有两个思路:
1. 使用measureText这个API,实现根据尺寸调整文字排版
2. 借助SVG 直接把CSS效果绘制上去

具体参考张鑫旭的博客,但是貌似第二种方法在小程序是不OK的

下面来看第一种方法的具体实现:
measureText()方法返回包含一个对象,该对象包含以像素计的指定字体宽度。

context.measureText(text).width;

思路就是将字符串转换为数组后在逐项累加,判断累加的字符串的长度与maxWidth,当超过maxWidth时将之前的额字符串进行一次fillText输出,然后继续循环,并累加行高,向下移动再次进行除数。

// 文字换行
fillTextWrap(ctx, text, x, y, maxWidth, lineHeight) {
  // 设定默认最大宽度
  const systemInfo = wx.getSystemInfoSync();
  const deciveWidth = systemInfo.screenWidth;
  // 默认参数
  maxWidth = maxWidth || deciveWidth;
  lineHeight = lineHeight || 20;
  // 校验参数
  if (typeof text !== 'string' || typeof x !== 'number' || typeof y !== 'number') {
    return;
  }
  // 字符串分割为数组
  const arrText = text.split('');
  // 当前字符串及宽度
  let currentText = '';
  let currentWidth;
  for (let letter of arrText) {
    currentText += letter;
    currentWidth = ctx.measureText(currentText).width;
    if (currentWidth > maxWidth) {
      ctx.fillText(currentText, x, y);
      currentText = '';
      y += lineHeight;
    }
  }
  if (currentText) {
    ctx.fillText(currentText, x, y);
  }
}

使用:

const textHeight = this.fillTextWrap(ctx, text, left, 220, 190, 30);

2.7 绘制头像

绘制头像需要用的API有一下几个:

(1)save()/restor()

保存/恢复当前绘图的上下文(否则的话真个画布就只剩下头像了)

(2)beginPath()

开始一条路径,或重置当前的路径。

(3)arc()

创建圆或部分圆

image

context.arc(x,y,r,sAngle,eAngle,counterclockwise);

ctx.arc(100,75,50,0,2*Math.PI);
参数 描述
x 圆的中心的 x 坐标。
y 圆的中心的 y 坐标。
r 圆的半径。
sAngle 起始角,以弧度计。(弧的圆形的三点钟位置是 0 度)。
eAngle 结束角,以弧度计。
counterclockwise 可选。规定应该逆时针还是顺时针绘图。False = 顺时针,true = 逆时针。

(4)clip()

从原始画布中剪切任意形状和尺寸。

一旦剪切了某个区域,则所有之后的绘图都会被限制在被剪切的区域内(不能访问画布上的其他区域)。您也可以在使用clip()方法前通过使用save()方法对当前画布区域进行保存,并在以后的任意时间对其进行恢复(通过restore()方法)。

(5) drawImage

绘制图片,需要注意的是,绘制圆形的时候坐标是圆心的坐标,而图片的坐标是图片左上角的坐标,两个坐标系之间差了一个半径长度

// 绘制头像
ctx.save();
// 保存画布
ctx.beginPath();
// 圆心中点
const circleX = deciveWidth / 2;
const circleY = 80;
// 圆半径
const circleRadius = 50;
ctx.arc(circleX, circleY, circleRadius, 0, 2 * Math.PI);
// 剪切
ctx.clip();
// 绘制图片
const imgPath = '/static/1.jpg';
ctx.drawImage(imgPath, circleX - circleRadius, circleY - circleRadius, circleRadius * 2, circleRadius * 2);
// 恢复画布
ctx.restore();

3 将canvas画布转成图片

使用的API是[wx.canvasToTempFilePath()]https://developers.weixin.qq.com/miniprogram/dev/api/canvas/temp-file.html),把当前画布指定区域的内容导出生成指定大小的图片,并返回文件路径。在自定义组件下,第二个参数传入组件实例this,以操作组件内<canvas/> 组件

wx.canvasToTempFilePath(OBJECT, this)

OBJECT参数:

参数 类型 说明
x Number 画布x轴起点(默认0)
y Number 画布y轴起点(默认0)
width Number 画布宽度(默认为canvas宽度-x)
height Number 画布高度(默认为canvas高度-y)
destWidth Number 输出图片宽度(默认为 width * 屏幕像素密度)
destHeight Number 输出图片高度(默认为 height * 屏幕像素密度)
canvasId String 画布标识,传入<canvas/>的canvas-id(必须)
fileType String 目标文件的类型,只支持 ‘jpg’ 或 ‘png’。默认为 ‘png’
quality Number 图片的质量,取值范围为 (0, 1],不在范围内时当作1.0处理
success Function 接口调用成功的回调函数
fail Function 接口调用失败的回调函数
complete Function 接口调用结束的回调函数(调用成功、失败都会执行)

draw回调里调用该方法才能保证图片导出成功。

ctx.draw(false, function() {
  wx.canvasToTempFilePath({
    canvasId: 'myCanvas',
    success: function(res) {
      // 获得图片临时路径
      this.imageTempPath = res.tempFilePath;
    }
  })
});

4 用户点击分享到朋友圈时,将图片保存到相册

保存图片到系统相册。需要用户授权scope.writePhotosAlbum

(1)获取授权wx.authorize(OBJECT)

提前向用户发起授权请求。调用后会立刻弹窗询问用户是否同意授权小程序使用某项功能或获取用户的某些数据,但不会实际调用对应接口。如果用户之前已经同意授权,则不会出现弹窗,直接返回成功。

可以通过wx.getSetting先查询一下用户是否授权

// 可以通过 wx.getSetting 先查询一下用户是否授权了 "scope.record" 这个 scope
wx.getSetting({
  success(res) {
    if (!res.authSetting['scope.record']) {
      wx.authorize({
        scope: 'scope.record',
        success() {
          // 用户已经同意小程序使用录音功能,后续调用 wx.startRecord 接口不会弹窗询问
          wx.startRecord()
        }
      })
    }
  }
})

在生成朋友圈图片的前提下,没有必要提前向用户发起授权请求,所以直接使用saveImageToPhotosAlbum时获取授权即可。

(2)保存图片wx.saveImageToPhotosAlbum()

wx.saveImageToPhotosAlbum(OBJECT)

OBJECT参数:

参数 类型 说明
filePath String 图片文件路径,可以是临时文件路径也可以是永久文件路径,不支持网络图片路径
success Function 接口调用成功的回调函数
fail Function 接口调用失败的回调函数
complete Function 接口调用结束的回调函数(调用成功、失败都会执行)
wx.saveImageToPhotosAlbum({
  filePath: this.filePath,
  success(res) {
    console.log(res.errMsg)
  },
  fail(res) {
    console.log(res.errMsg)
  }
})

5 整体思路

  1. 如果不想看到Canvas,可以将其父容器的position设定在屏幕很远之外,或者设定visibityhidden,但不能通过v-if控制父容器的显示(即设定displaynone),否则无法正常生产图片
  2. 要保证保存图片时(saveImageToPhotosAlbum)临时图片路径已经生成成功(canvasToTempFilePath),而临时图片路径的成功生成又依赖于Canvas绘制完毕(draw),所以最好将有依赖关系的调用都放到其success回调函数中,集体来看分两种情况
  3. 有两种策略:
    1. 进入页面加载完数据后就绘制Canvas,点击分享时直接上传图片,这是需要在点击分享时首先确认Canvas是否绘制完成,如果用户过快点击分享,只能提示用户稍后重试(或者在Canvas绘制完成之前禁用按钮/显示全局loading遮罩),
    2. 点击分享时再绘制Canvas,随后的操作都放在sucess回调中,这样保证用户点击分享(正常情况下)不会有额外提示,流程顺畅,但是分享时间会相对较长

demo在这里

注意,有一个问题,使用诸如wx.getImageInfo等借口异步获取图片信息的时候,如果最后的draw方法不在回调函数中,会导致回调中对应的绘画不被绘制到canvas

6 坑坑坑

本以为掌握了以上技巧,写了个小demo就能飞黄腾达直达人生巅峰了实现业务需求了,结果实践证明我还是too naive too young了,还有多个坑在等着我呢

这些坑有的是canvas的坑,有的是小程序的坑,更多的是自己脑子进水的坑。

6.1 绘制网络图片调试工具中成功,真机失败

绘制网络图片时需要首先通过wx.getImageInfo获取图片的临时路径,但是如果图片地址的域名没有被设置到小程序管理后台的downloadFile的白名单中,就无法获取图片的临时路径。

在调试时我们一般会打开“不校验域名”的选项,这就导致了图片在调试工具中处理成功,而在真机环境失败。

image

解决这个问题就是登陆小程序管理后台的设置开发设置服务器域名设置中,将图片域名添加到downloadFileh合法域名的白名单中,对于网络请求也一样,需要添加到request合法域名

这里写图片描述

6.2 canvas无法通过z-index覆盖

canvas是原生组件,总是处于顶级的,是没有办法通过CSS的z-index来控制其上下层的显示的,解决方法就是通过小程序提供的cover-view组件

cover-view会覆盖在原生组件之上,可以覆盖的原生组件包括mapvideocanvascameralive-playerlive-pusher,其内部只能嵌套cover-viewcove-image

cover-image是覆盖在原生组件的图片视图,通过src属性设置图片的临时路径、网络地址,暂不支持base64格式

6.3 canvas绘制图片时无法绘制base64格式的图片

呃,截止到2018.06.14,canvas不支持base64格式的图片

这里写图片描述

6.4 测试环境下真机环境网络请求失败

因为测试环境下,网络请求都是走的内网地址,首先PC能够访问成功是因为配置了host,但是手机没有配置,导致网络请求失败

解决方法就是通过fiddler为手机设置代理,让手机的网络请求也走内网,前提是手机也连入了PC相同的局域网,具体的设置方法参考这篇文章

6.5 小程序开发工具无法打断点

在小程序开发工具更新后可以像在chrome中一样进行断点调试,但是在开发过程中遇到了无法打断点的情况,找到对应的.vue.js文件,打了断点执行时不会在期待的地方停止程序的运行

原因就是开启了开发工具的ES6转ES5的选项,关闭这个选项再打断点就OK了。

这里写图片描述

6.6 通过左上角返回按钮返回后页面实例没有被销毁

原有的流程是这样的:首先进入列表A,点击其中某个连接进入页面B1,B1中生成canvas并绘制到页面上,在B1页面可以点击保存按钮将canvas绘制的图片保存到相册,也可以点击canvas进行图片预览

本来想进行一个优化,因为保存相册和图片预览都需要通过wx.canvasToTempFilePath获取canvas转换为图片的临时路径,所以将临时图片保存到了组件的data中的this.filePath,然后保存图片或者游览时判断是否已经有了临时路径,如果有了就省略canvasToTempFilePath这一步

但是带来了问题:在进入B1生成了图片P1并预览后,退出到列表A,进入B2生成图片P2,但是预览时并不是P2,还是P1

image

问题原因其实不难查找,出现在小程序路由的触发与生命周期函数的对应上:

这里写图片描述

当页面返回时,并没有触发mpvue的destroy生命周期,也就是说实例没有被销毁,其属性都保存了,所以以后再次进入B1/B2/…,图片预览或者保存都是P1

这和vue-router跳转不同组件时的行为表现是不同的,与使用了动态参数的vue-router跳转的行为表现相同:

当使用路由参数时,例如从/user/ligang导航到user/lg,原来的组件实例会被复用。因为两个路由都渲染同个组件,比起销毁再创建,复用则显得更加高效。不过,这也意味着组件的生命周期钩子不会再被调用。

解决方法有两个:
1. 不优化了,将canvasToTempFilePath放在canvas绘制图片draw方法的回调中,每次绘制图片都生成新的临时路径
2. 每次返回时当前页面触发的是小程序自己的声明周期onUnload,所以在这个钩子中将this.filePath清空

P.S. 在图片预览时触发的当前页面的钩子是onHide

6.7 图片预览时图片过大,上方的信息会被挡住

如果canvas生成的图片过大,在进行图片预览时会被上方小程序自带的页面信息遮挡:

这里写图片描述

解决方法就是别在图片的这里写东西canvasToTempFilePath控制生成图片的尺寸,可以将画布宽度高度全部输入,但是通过输出图片的宽度、高度控制来避免图片过大,当然这会带来图片的压缩和变形,这就要进行取舍了。

参考

原文链接:加载失败,请重新获取