小程序canvas绘制海报中遇到的一些坑
# 前言
最近接到个需求在小程序要实现一个拍照打卡的功能,拍下的照片需要合成上我们小程序的logo还有专属边框。稍微思考了一下,这不就是获取用户拍的照片然后合成几张图嘛简简单单!直接开干!
# 实现思路
说到合成图片,那就不得不提到canvas
了。利用canvas
绘制图片然后调整好图片位置后导出图片临时路径就ok啦,但是微信小程序果然从不让人失望,文档写的跟屎一样坑一大堆官方不填,下面就从0到1实现一下来细数其中的坑。
# 开始
这里为了方便逻辑清晰 所以将合成图片这一步抽成了一个组件 问题开始了! 首先看一下基本代码
<ImageToPoster ref="ImageToPoster" :width="473" :height="652" />
// ImageToPoster
<template>
<view class="imgtop">
<canvas ref="Canvas" :style="{'width':width+'px','height':height+'px'}" canvas-id="Canvas" id="Canvas"></canvas>
</view>
</template>
<script>
export default {
props: {
width: {
type: Number,
default: 0
},
height: {
type: Number,
default: 0
},
step: {
type: Number,
default: 1
}
},
data() {
return {
imagePath: '',
bgImageTempPath: '', // 背景图的临时路径
logoDetail:{},// logo图的信息
logoImageTempPath1: '', // logo的临时路径
logoImageTempPath2: '', // logo的临时路径
logoImageTempPath3: '', // logo的临时路径
}
},
mounted() {
this.handleLoadImage()
},
methods:{
// 提前加载素材图片
handleLoadImage() {
let that = this
uni.getImageInfo({
src: loadImage('hckdt.png'),
success: function(image) {
that.bgImageTempPath = image.path
}
});
uni.getImageInfo({
src: loadImage('logo1.png'),
success: function(image) {
that.logoDetail[1] = {
path:image.path,
width:image.width,
height:image.height
}
}
});
uni.getImageInfo({
src: loadImage('logo2.png'),
success: function(image) {
that.logoDetail[2] = {
path:image.path,
width:image.width,
height:image.height
}
}
});
uni.getImageInfo({
src: loadImage('logo3.png'),
success: function(image) {
that.logoDetail[3] = {
path:image.path,
width:image.width,
height:image.height
}
}
});
},
/**
* 通过canvas合成图片
* @param path 拍照的图片的临时路径
* @param width1 拍照的图的宽度
* @param height1 拍照的图的高度
*/
handleCanvasToImage(path, width1, height1){
const ctx = uni.createCanvasContext('Canvas', this)
ctx.beginPath()
}
}
}
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
上文代码中是一个简易过的组件,里面一些重要代码保留了。这个组件主要就是提供一个canvas
然后将传入的图片与素材图合成到一起最后导出临时路径。
- 问题一 为什么要这么设计?
首先小程序不支持canvas
被隐藏,所以我们的canvas
画板一定得出现在页面上,那怎么办只能离屏(将canvas
定位到超出屏幕之外的地方去)。
- 问题二 获取不到
canvas
实例?
小程序中canvas
如果放在自定义子组件中,那么获取实例的时候必须传入子组件的this
。否则会找不到canvas
解决两个基本的问题下面又有一个新的问题,那就是如何在canvas
中对图片进行截取呢?
因为用户手机拍摄的照片大小都是不一样的,所以我们必须指定一个尺寸,然后在用户拍摄的照片上截取一个目标尺寸的图下来。但是要保证以中心区域为主。也就是说我们需要手动去实现一个img
标签的cover
功能了。
具体截取逻辑如下
handleCanvasToImage(path, width1, height1){
const ctx = uni.createCanvasContext('Canvas', this)
ctx.beginPath()
// 获取目标图片的原始尺寸
const originalWidth = width1;
const originalHeight = height1;
// 计算目标图片的宽高比和canvas的宽高比
const imageRatio = originalWidth / originalHeight;
const canvasRatio = 406 / 432;
// 定义绘制区域的起点坐标和宽高
let x = 0;
let y = 0;
let width = 0;
let height = 0;
// 判断以宽度为基准进行裁切还是以高度为基准进行裁切
if (imageRatio > canvasRatio) {
// 以宽度为基准进行裁切
width = originalHeight * canvasRatio;
height = originalHeight;
x = (originalWidth - width) / 2;
} else {
// 以高度为基准进行裁切
width = originalWidth;
height = originalWidth / canvasRatio;
y = (originalHeight - height) / 2;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
获取到两个图片的宽高比后根据宽高比大小的不同来选择是以宽度为基准
裁切还是以高度为基准
裁切,最终得出裁切的起始坐标和裁切的图的宽度以及高度
ok,有了这个之后我们就可以开始着手绘制啦
handleCanvasToImage(path, width1, height1){
const ctx = uni.createCanvasContext('Canvas', this)
ctx.beginPath()
// 获取目标图片的原始尺寸
const originalWidth = width1;
const originalHeight = height1;
// 计算目标图片的宽高比和canvas的宽高比
const imageRatio = originalWidth / originalHeight;
const canvasRatio = 406 / 432;
// 定义绘制区域的起点坐标和宽高
let x = 0;
let y = 0;
let width = 0;
let height = 0;
// 判断以宽度为基准进行裁切还是以高度为基准进行裁切
if (imageRatio > canvasRatio) {
// 以宽度为基准进行裁切
width = originalHeight * canvasRatio;
height = originalHeight;
x = (originalWidth - width) / 2;
} else {
// 以高度为基准进行裁切
width = originalWidth;
height = originalWidth / canvasRatio;
y = (originalHeight - height) / 2;
}
// 绘制边框背景
ctx.drawImage(that.bgImageTempPath, 0, 0, posterWidth, posterHeight)
ctx.draw(true)
// 绘制拍摄的照片
ctx.drawImage(path, 0, 0, width, height, 33.5, 60, 406, 432);
ctx.draw(true)
// 绘制logo
ctx.drawImage(that.logoDetail[that.step].path, 10, 462, that.logoDetail[that.step].width/3*2, that.logoDetail[that.step].height/3*2)
ctx.draw(true)
ctx.save()
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
这里需要注意要调用ctx.draw
才会绘制canvas
,并且需要传入第一个参数true
,画布才能保留上一次绘制的内容。
绘制完了之后再次调用小程序提供的Api
来导出合成图的临时路径
uni.canvasToTempFilePath({
x: 0,
y: 0,
width: posterWidth,
height: posterHeight,
destWidth: posterWidth,
destHeight: posterHeight,
canvasId: 'Canvas',
success: function(res) {
// 在H5平台下,tempFilePath 为 base64
that.imagePath = res.tempFilePath
resolve(that.imagePath)
},
fail(err) {
reject('海报生成失败...')
console.log(err)
}
}, this)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
同样的这个api
也需要传入this
,不然也会报错。代码写到这里,掏出我的手机一测,嗯!不错但是图片好像有点糊。别急,一番搜索后发现导出图的宽高*手机的缩放比
可以解决这个问题。
// 获取当前设备的图片缩放比
const pixelRatio = uni.getSystemInfo().pixelRatio || 2
uni.canvasToTempFilePath({
x: 0,
y: 0,
width: posterWidth,
height: posterHeight,
destWidth: posterWidth * pixelRatio,
destHeight: posterHeight * pixelRatio,
canvasId: 'Canvas',
success: function(res) {
// 在H5平台下,tempFilePath 为 base64
that.imagePath = res.tempFilePath
resolve(that.imagePath)
},
fail(err) {
reject('海报生成失败...')
console.log(err)
}
}, this)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
ok啦这下是真的ok啦!提交测试。测试一通操作后传来噩耗,有些机型导出了空白的图。
我开始慌了,这种兼容性问题是最让人头疼的。但是没办法 该调试还是得调试。于是我连上了第一个手机返现确实是这样偶尔会导出空白的图片,
于是我立马联想到会不会是因为canvas
还没有画完就执行了导出方法。于是乎我给导出函数加了个定时器,延迟300ms
执行。
联调过后发现没问题,提交给测试。几分钟后测试又说偶尔还是会出现那个问题,这个时候我确定了,就是因为canvas
没有画完就执行了导出函数。确定了问题之后就比较好解决了,直接打开wx
的文档
文档上有提到draw
函数可以接收第二个参数,是一个回调函数,会在绘制完毕后调用。于是我又快马加鞭的加上
ctx.drawImage(that.bgImageTempPath, 0, 0, posterWidth, posterHeight)
ctx.fillText(" ", 0, 0);
ctx.draw(true,()=>{
// 绘制拍摄的照片
ctx.drawImage(path, 0, 0, width, height, 33.5, 60, 406, 432);
ctx.fillText(" ", 0, 0);
ctx.draw(true,()=>{
// 绘制logo
ctx.drawImage(that.logoDetail[that.step].path, 10, 462, that.logoDetail[that.step].width/3*2, that.logoDetail[that.step].height/3*2)
ctx.fillText(" ", 0, 0);
ctx.draw(true,()=>{
const pixelRatio = uni.getSystemInfo().pixelRatio || 2
setTimeout(()=>{
uni.canvasToTempFilePath({
x: 0,
y: 0,
width: posterWidth,
height: posterHeight,
destWidth: posterWidth * pixelRatio,
destHeight: posterHeight * pixelRatio,
canvasId: 'Canvas',
success: function(res) {
// 在H5平台下,tempFilePath 为 base64
that.imagePath = res.tempFilePath
resolve(that.imagePath)
},
fail(err) {
reject('海报生成失败...')
console.log(err)
}
}, that)
},300)
})
ctx.save()
}) // 需要传入true 才会保留上次绘制的内容
})
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
这下总没问题了吧。蛮喜欢喜的我准备下班了,结果测试又又又传来噩耗。这下更严重了,连空白的图都导不出来了。我立马掏出我的手机测试了一番,发现没问题。
依旧是丝滑流畅。那这可就令人头大了,api
的兼容性问题,按照文档上写还没有用。文档也没说解决办法(这里真的忍不住吐槽微信的文档,真真真依托答辩)!
没办法,只能在社区找找找。翻了个底朝天终于看到一位大哥说的,遇到这种不执行的情况,第二个参数需要传入一个立即执行函数
。我仿佛抓住了救民稻草一般。
加上之后发现果然没问题了。
ctx.drawImage(that.bgImageTempPath, 0, 0, posterWidth, posterHeight)
ctx.fillText(" ", 0, 0);
ctx.draw(true,(()=>{
// 绘制拍摄的照片
ctx.drawImage(path, 0, 0, width, height, 33.5, 60, 406, 432);
ctx.fillText(" ", 0, 0);
ctx.draw(true,(()=>{
// 绘制logo
ctx.drawImage(that.logoDetail[that.step].path, 10, 462, that.logoDetail[that.step].width/3*2, that.logoDetail[that.step].height/3*2)
ctx.fillText(" ", 0, 0);
ctx.draw(true,(()=>{
const pixelRatio = uni.getSystemInfo().pixelRatio || 2
setTimeout(()=>{
uni.canvasToTempFilePath({
x: 0,
y: 0,
width: posterWidth,
height: posterHeight,
destWidth: posterWidth * pixelRatio,
destHeight: posterHeight * pixelRatio,
canvasId: 'Canvas',
success: function(res) {
// 在H5平台下,tempFilePath 为 base64
that.imagePath = res.tempFilePath
resolve(that.imagePath)
},
fail(err) {
reject('海报生成失败...')
console.log(err)
}
}, that)
},300)
})())
ctx.save()
})()) // 需要传入true 才会保留上次绘制的内容
})())
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
事情到这里就结束了,在过程中还有看到关于canvasToTempFilePath
这个Api
的问题,如果canvas
采用的是2d
模式,那在调用这个api
的时候还需要传入canvas
实例。不然会报错!
# 优化
由于合成的图片有点太大了,渲染的时候会出现一行一行渲染的情况。测试那边不满意,没办法只能改。一开始我以为是加载太慢,于是采取了先加载图然后再渲染的方式。后面发现无济于事 问题可能还是出在渲染上,之前有一次做web项目的时候也出现过图片太大导致页面非常卡顿,经过性能调试后发现是渲染过程导致的卡顿,图片太大导致解码耗费了很多时间。当时采取了渲染缩略图的方式来规避这个问题, 但是这里很显然没办法用缩略图,因为需求是用户要长按合成出来的图片保存。所以渲染的必须是最终的高清图。
那怎么办!就在我一筹莫展的时候,我看到了离屏渲染的canvas
,诶!对了 图片我是不是也可以离屏渲染,等他渲染过一次之后第二次渲染就不会再出现卡顿的情况了。
具体的实现步骤这里就不贴代码了,很简单。就是用一个img
标签再屏幕外先渲染一次图片然后再把图片渲染到用户可视区域中。
# 总结
一次简单的需求,炸出来这么多坑,关于
wx
小程序的canvas
我只能说真的坑多,同一个api
在不同手机上的运行结果不一致也就算了,也不报错啥也没有。他就在那不起作用,真的很让人无语。 下面总结一下几个点
canvas
必须在页面中,不可以display:none
也不可以透明度为0
canvas
在自定义组件中使用的话 获取实例一定要传this
canvas
的绘制函数需要调用draw
才能绘制到画布中draw
需要传入true
才能保留上一次绘制的结果,不然画布中就只有最后一次的结果draw
是支持回调的,有些需要等待绘制的场景可以用到,但是这个回调在某些机型上不执行,需要改为立即执行函数
的方式才能解决canvasToTempFilePath
Api
导出的图片糊的话,可以将实际图片宽高*手机的缩放比
,能让图片更清晰。不要再盲目的设置2
了,每个手机都不一样canvasToTempFilePath
Api
在自定义组件中使用也需要传入this
,否则会报错
# 后续
原本以为问题已经得到解决,后面有些机型依然存在这个问题。经过一番排查我发现一开始我的思路就错了,我把所有精力都放在canvas的绘制上,以为是这里出了问题。但后面发现是因为有些机型在拉起相机之后再回到小程序会导致小程序页面重载, 重载的过程中调用绘制函数导致了问题的发生(调用函数的时候canvas还没有加载完,这个时候绘制就会出现很多问题。)经过一番讨论后决定还是由服务端去合成图片,对于客户端来说这种兼容性问题太多了。如果是日活非常大的程序上这种需求都 应该由服务端去完成,最大程度的去降低兼容性带来的毁灭性bug。