本文内容:在微信小程序中用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轴某个点