本文内容:在微信小程序中用Canvas绘制定制的图样并生成图片保存到手机相册的工程方案

概述

在常规的web站点中,用canvas生成图片,有现成的插件,在小程序中没有成熟的插件,本文采用小程序官方提供的api生成图片

注意事项
  • 小程序中插入的静态dom元素image标签的src如果是网络资源,http类型的图片无法使用,必须是https,同时需要在微信公众平台设置request合法域名白名单
  • 对于网络图片的下载有两个方案
  • wx.getImageInfo成功回调函数res.path为网络图片的本地路径,形如:http://tmp/wx57076337a7a4edee.o6zAJs3xDyrzkApA_ZRyzaha8i_o.HfnRwMrkxdNz4f6bea0b9dafcbb1d5d0114cbfe8c9ad.png
  • wx.downloadFile成功回调函数res.tempFilePath为网络图片的临时文件路径 (本地路径),形如:http://tmp/wx57076337a7a4edee.o6zAJs3xDyrzkApA_ZRyzaha8i_o.olS2GgQlZ0Zwa4b4f48107a14c29dc31fcf44b504468.png
  • 网络资源下载后,需要对临时文件进行删除,垃圾回收,释放缓存
let FileSystemManager = wx.getFileSystemManager();//获取全局唯一的文件管理器
FileSystemManager.unlink({
	filePath: tempFilePath,
})
  • canvas中如果插入图片是网络资源,必须是https,需要先使用wx.getImageInfo或 wx.downloadFile下载网络图片到本地,将临时文件路径插入到canvas中,同时需要在微信公众平台设置downloadFile合法域名白名单
  • canvas中如果插入图片是用户头像,形如:https://wx.qlogo.cn/mmopen/vi_32/HjtuGCtLbseYibDBQj01S4RNMDB3ZPMHTI2XeicYfsTibrvoia9T17fGAck55NDbI9f1nn7opBD4yXoQEWbOkm9Sibg/132,即使是微信官方的域名,也需要在微信公众平台设置downloadFile合法域名白名单https://wx.qlogo.cn
在安卓机上的兼容性问题
  • 安卓的部分设备上会出现渲染不全、渲染样式错乱的效果(随机的会产生绘制元素走样的情况)
//draw方法,将之前在绘图上下文中的描述(路径、变形、样式)画到 canvas 中
//如下方所示,即使callback方法this.canvas2image为绘制完成后执行的回调函数,此时调用wx.canvasToTempFilePath,安卓的部分设备上会出现渲染不全的效果(随机的会产生绘制元素走样的情况),因此需要使用延迟加载 setTimeout 函数回避渲染过慢的问题
ctx.draw(false, function () { _this.canvas2image() });
canvas2image(){
	setTimeout(function(){wx.canvasToTempFilePath({})},500)
}
网络图片无法绘制在canvas中的问题

如果是网络资源的图片,必须是https,同时需要在微信公众平台设置request合法域名白名单(使用wx.getImageInfo将网络图片转换为临时文件路径)和downloadFile合法域名白名单(使用wx.downloadFile将网络图片转换为临时文件路径)

wx.canvasToTempFilePath生成图片模糊的问题
  • ctx = wx.createCanvasContext(‘xms-canvas’,this);//如果在自定义组件中使用canvas,需要传入第二个参数this

官方文档的解释:在自定义组件下,当前组件实例的this,表示在这个自定义组件下查找拥有 canvas-id 的 canvas ,如果省略则不在任何自定义组件内查找

  • wx.canvasToTempFilePath的参数destWidth和destHeight不用设置,如果设置只能设置px单位的尺寸

官方文档的解释:destWidth和destHeight的默认值为width*屏幕像素密度height*屏幕像素密度

  • 设计稿为375尺寸,绘制和初始化canvas时采用二倍图750尺寸,可以显示高清图片
  • 在初始化canvas DOM的时候,可以使用二倍或三倍尺寸,但是转图片的时候使用设计稿原始尺寸
在canvas中绘制圆形图片
ctx.save();//保存绘图上下文ctx ctx.clip()之后绘图都会被限制在被剪切的区域内
ctx.beginPath();
ctx.arc(this.getRpx(0 + 30), this.getRpx(0 + 30), this.getRpx(30), 0, 2 * Math.PI);//圆心x 圆心y 半径 起始弧度(在3点钟方向) 终止弧度
ctx.clip();//从原始画布中剪切任意形状和尺寸 clip使用的注意事项:使用 clip 方法前通过使用 save 方法对当前画布区域进行保存,并在以后的任意时间通过restore方法对其进行恢复
ctx.drawImage('https://abc.cn/image/hehe.png', this.getRpx(0), this.getRpx(0), this.getRpx(60), this.getRpx(60));
ctx.restore();//恢复之前保存的绘图上下文ctx 可以继续绘制其他内容
用户点击保存图片时,当用户拒绝访问相册权限时,下次点击按钮无反应,无法自动调起授权申请
let this.data.hasDenyWritePhotosAlbum = false,//拒绝授权
save() {
	var _this = this;
	//保存图片到系统相册 底部自动弹出授权选项
	wx.saveImageToPhotosAlbum({
		filePath: tempFilePath,//wx.canvasToTempFilePath 的 res.tempFilePath,图片临时路径
		success(res) {
			wx.showToast({
				title: '成功',
				icon: 'success',
				duration: 2000
			})
		},
		fail(res) {
			//用户拒绝相册授权
			if ((res.errMsg).indexOf('saveImageToPhotosAlbum') != -1) {
				_this.data.hasDenyWritePhotosAlbum = true;
			}
		}
	})
	//第一次拒绝授权相册 第二次需要主动调用授权
	if (this.data.hasDenyWritePhotosAlbum){
		//只能是用户手动触发
		wx.openSetting({
			success(res) {
				if (res.authSetting["scope.writePhotosAlbum"]){
					_this.data.hasDenyWritePhotosAlbum = false;
				}
			}
		})
	}
}
在canvas中按照固定宽度文本自动换行绘制
var ctx = wx.createCanvasContext('xms-canvas',this);
ctx.font = 'normal normal 14px sans-serif';
ctx.setFillStyle('#9AA2B3');
ctx.setTextAlign('left');
this.fillTextByLine(data.planName, ctx, 260, 62, 400, 40);
/**
 * 按照固定宽度文本自动换行绘制
 * @param str 文本字符串
 * @param ctx canvas实例
 * @param lineW 单行文本宽度
 * @param left 文本距离canvas左边距
 * @param top 文本距离canvas上边距
 * @param lineH 行高
 * @return null
 */
function fillTextByLine(str, ctx, lineW, left, top, lineH) {
	if (str == '' || (typeof str != 'string'))
		return
	let splitIndex = 1;
	let lineIndex = 0;
	//练习计划名称按行显示
	while (str != '') {
		while ((splitIndex <= str.length) && (ctx.measureText(str.substr(0, splitIndex)).width < lineW)) {
		splitIndex++;
		}
		//最后一行 不用换行
		if (splitIndex - 1 == str.length) {
			ctx.fillText(str, this.getRpx(left), this.getRpx(top + lineIndex * lineH));
			str = ''
		} else {
		//非最后一行
			ctx.fillText(str.substr(0, splitIndex - 1), this.getRpx(left), this.getRpx(top + lineIndex * lineH));
			str = str.slice(splitIndex - 1)
		}
		lineIndex++;
		splitIndex = 1;
	}
}
在主页面直接开发
  • 下面用到的所有尺寸都是750设计稿的绝对尺寸

dom 布局如下

<view class='mask'>
  <view class='imageBox'>
    <image src="{{imagePath}}" class='shengcheng'></image>
  </view>
  <button class='save' bindtap='save'>保存到相册</button>
  <image class='close-btn' src="/images/close.png" bindtap='closeMask'></image>
</view>
<view class="canvas-box">
  <canvas canvas-id="xms-canvas" style="width: 628rpx; height: 1000rpx;"/>
</view>

css 如下

.mask{
  position: absolute;
  width: 100%;
  height: 100%;
  top: 0;
  left: 0;
  z-index: 2;
  background: rgba(0,0,0,0.5);
}
.mask .imageBox{
  width: 628rpx;
  height: 1000rpx;
  background: #FFFFFF;
  margin: 60rpx auto 0;
  border-radius: 40px;
  overflow: hidden;
}
.mask .imageBox .shengcheng{
  width: 628rpx;
  height: 1000rpx;
}
.mask .save{
  width: 365rpx;
  height: 78rpx;
  line-height: 78rpx;
  background: #60D0FE;
  text-align: center;
  position: fixed;
  bottom: 20px;
  margin: 0 auto;
  left: 50%;
  transform: translateX(-50%);
  border-radius: 78px;
  font-size: 16px;
  font-weight: bold;
  color: #FFFFFF;
}
.mask .close-btn{
  width: 52rpx;
  height: 52rpx;
  position: absolute;
  top: 48rpx;
  right: 43rpx;
}
/* canvas元素不显示在视口 visibility: hidden; 真机无法隐藏canvas元素 */
.canvas-box{
  position: absolute;
  top: -9999rpx;
  left: 0;
}

js 如下

//px2rpx
getRpx(px) {
	var winWidth = wx.getSystemInfoSync().windowWidth;
	return winWidth / 750 * px
}
function createImage() {
	//显示 loading 提示框
	wx.showLoading({title: '加载中'})
	let _this = this;
    var image = "/images/wsb.png";
    //如果在自定义组件中使用canvas,需要传入第二个参数this
    var ctx = wx.createCanvasContext('xms-canvas',this);
    //设置背景色
    ctx.setFillStyle("#ffffff");
    //设置画布尺寸
    ctx.fillRect(0, 0, this.getRpx(628), this.getRpx(1000))
    //顶部绘制一个彩色矩形
    ctx.setFillStyle("#ff00ff")
    ctx.fillRect(0, 0, this.getRpx(628), this.getRpx(191))
    //插入图片 距离画布左侧20 距离顶部10 图片尺寸100*50
    ctx.drawImage(image, this.getRpx(20), this.getRpx(10), this.getRpx(100), this.getRpx(50));
    //距离画布左侧100 距离顶部300 绘制文本 左对齐
    ctx.font = 'normal bold 18px sans-serif';//font-style:normal-标准的字体样式 italic-斜体 oblique-倾斜 font-weight font-size/line-height font-family
    ctx.setFillStyle('#292C33');
    ctx.setTextAlign('left');
    ctx.fillText('这是一个左对齐的文本', this.getRpx(100), this.getRpx(300));
    //距离画距离顶部400 绘制文本 水平居中显示
    ctx.setFontSize(12);
    ctx.setFillStyle('#292C33');
    ctx.setTextAlign('center');
    ctx.fillText('这是水平居中文本', this.getRpx(314), this.getRpx(400));//314为画布X周中线坐标
    //将之前在绘图上下文中的描述(路径、变形、样式)画到 canvas 中
    ctx.draw(false, function () {canvas2image()});
}
在 draw() 回调里调用该方法才能保证图片导出成功
function canvas2image() {
	var _this = this;
	//截屏canvas的位置
	wx.canvasToTempFilePath({
		x: 0,
		y: 0,
		width: this.getRpx(628),//指定的画布区域的宽度
		height: this.getRpx(1000),
		destWidth: 628,//输出的图片的宽度 rpx-this.getRpx(628)很模糊 px-628不模糊
		destHeight: 1000,
		canvasId: 'xms-canvas',
		quality: 1,
		success: function (res) {
			//设置保存到手机的图片的临时路径
			//显示 图片预览 框
			_this.setData({
				imagePath: res.tempFilePath,
				displayMask: true
			});
			//隐藏 loading 提示框
			wx.hideLoading()
		},
		fail: function (res) {}
	},this);
}
//保存到手机相册
function save() {
	var _this = this
	wx.saveImageToPhotosAlbum({
		filePath: _this.data.imagePath,
		success(res) {
			wx.showToast({
				title: '成功',
				icon: 'success',
				duration: 2000
			})
		}
	})
}
封装为一个组件在主页面中使用

需要再组件的index.json文件中配置{“component”: true}

//index.json
{
  "usingComponents": {
    "canvasToImage": "/componts/canvas2image/index"
  },
  "navigationBarBackgroundColor":"#4C9AFF",
  "navigationBarTextStyle":"white",
  "navigationBarTitleText": "首页"
}
//index.wxml 在任意位置插入
<view class="container">
	<canvasToImage id='c2i' dataFromParent='{{canvasData}}'></canvasToImage>
</view>
//index.js
onReady: function() {
	this.canvas = this.selectComponent('#c2i');
}
//点击事件触发canvas绘制
function click(){
	this.canvas.createImage()
}
canvas生产从上到下渐变矩形
let grd = ctx.createLinearGradient(0, 0, 0, this.getRpx(144));//从上到下渐变
grd.addColorStop(0, '#BAE8DE')
grd.addColorStop(1, '#FFFFFF')
ctx.setFillStyle(grd)
ctx.fillRect(0, 0, this.getRpx(622), this.getRpx(144))
canvas生产从左上角到右下角渐变矩形
let grd = ctx.createLinearGradient(this.getRpx(32), this.getRpx(36), this.getRpx(590), this.getRpx(354));//从左上角到右下角渐变-径向-对角线
grd.addColorStop(0, '#53D484');
grd.addColorStop(1, '#28CFB9');
ctx.setFillStyle(grd)
ctx.fillRect(this.getRpx(32), this.getRpx(36), this.getRpx(558), this.getRpx(318));
canvas绘制圆角矩形
function drawRoundRect(ctx, x, y, width, height, r, fill) {
      ctx.save(); ctx.beginPath(); // draw top and top right corner 
      ctx.moveTo(x + r, y);
      ctx.arcTo(x + width, y, x + width, y + r, r); // draw right side and bottom right corner 
      ctx.arcTo(x + width, y + height, x + width - r, y + height, r); // draw bottom and bottom left corner 
      ctx.arcTo(x, y + height, x, y + height - r, r); // draw left and top left corner 
      ctx.arcTo(x, y, x + r, y, r);
      if (fill) { ctx.fill(); }
      ctx.restore();
}

this.drawRoundRect(ctx, this.getRpx(32), this.getRpx(36), this.getRpx(558), this.getRpx(318), this.getRpx(16),true);

//绘制border
ctx.strokeStyle = "#0f0";
ctx.stroke();
//上面的 drawRoundRect 方法会出现矩形漂移,产生梯形
function drawRoundRect(ctx, x, y, width, height, r, fill) {
	ctx.beginPath(0);
	//从右下角顺时针绘制,弧度从0到1/2PI  
	ctx.arc(x + width - r, y + height - r, r, 0, Math.PI / 2);
	//矩形下边线  
	ctx.lineTo(r, y+height);
	//左下角圆弧,弧度从1/2PI到PI  
	ctx.arc(x + r, y + height - r, r, Math.PI / 2, Math.PI);
	//矩形左边线  
	ctx.lineTo(x, r);
	//左上角圆弧,弧度从PI到3/2PI  
	ctx.arc(x + r, y + r, r, Math.PI, Math.PI * 3 / 2);
	//上边线  
	ctx.lineTo(x + width - r, y);
	//右上角圆弧  
	ctx.arc(x + width - r, y + r, r, Math.PI * 3 / 2, Math.PI * 2);
	//右边线  
	ctx.lineTo(x + width,y + height - r);
	ctx.closePath();
	//是否需要填充矩形内部颜色
	if(fill) {
		ctx.setFillStyle('#000000');//矩形内部填充色
		ctx.fill();
	}
	ctx.restore();
}
文本中心对齐
ctx.setFontSize(12);
ctx.setFillStyle('#292C33');
ctx.setTextAlign('center');
ctx.fillText('x轴水平居中文本', this.getRpx(314), this.getRpx(400));//314为画布X轴中线坐标
ctx.fillText('基于x轴某个坐标中心对齐', this.getRpx(x), this.getRpx(400));// x为画布X轴某个点