支付宝小程序之ctx绘制密码卡片并保存

# 前言

本篇记录一下之前在支付宝小程序上实现的6字明文密码生成图片后保存到图库功能。图库内保存的密码图片样式如下所示:

技术点

实现上图功能主要使用了:

其中和绘制相关的是my.createCanvasContext,该方法创建返回了CanvasContext 绘图上下文。CanvasContext如同Android中的Canvas画布,提供了一系列关于绘制的方法,比如:

  • fillText:在画布上绘制被填充的文本
  • setFillStyle:设置 Canvas 填充色
  • setFontSize:设置 Canvas 字体大小
  • lineTo:使用直线连接子路径的终点到某个坐标的方法
  • arc:在 Canvas 上绘制圆弧路径
  • save:保存 canvas 全部状态的方法
  • restore:将 canvas 恢复到最近的保存状态的方法
  • beginPath:在 Canvas 上开始一个新路径
  • closePath:将笔点返回到当前子路径起始点的方法
  • fill:根据当前的填充样式填充当前或已存在的路径的方法
  • translate:对当前网格添加平移变换的方法
  • stroke:用于画出当前路径的边框
  • draw:提交 Canvas 绘制指令

上述列出的CanvasContext的方法为本次绘制卡片效果所调用的绘制接口,CanvasContext提供的其余方法可至官方文档查看:CanvasContext 概览

CanvasContext无法直接将绘制的内容保存到图库,但是官方文档「 canvas组件如何实现生成图片保存 」内提供了实现方式,即:使用CanvasContext.toTempFilePath 把当前画布的内容导出生成图片,获取图片路径(临时路径),再 通过 my.saveImage 保存图片到相册。

所以整个实现的伪代码如下:

1
2
3
4
5
6
7
8
9
10
11
//1.绘制画布内容  
CanvasContext.drawCanvas()
//2.导出生成图片,获取图片路径(临时路径)
CanvasContext.toTempFilePath({
success: (res) => {
//3.保存图片到相册
my.saveImage({
url: res.apFilePath,
});
}
})

细节

如同上述伪代码,主要是canvas内容绘制和保存。

Canvas绘制

通过my.createCanvasContext,创建获取CanvasContext 绘图上下文,其中入参canvasId为<canvas> 组件的 id 属性,如下,为once-key

其中css样式为:

1
2
3
4
5
6
.canvas_pwd {
width: 335px;
height: 235px;
position: absolute;
top:-235px;
}

所以创建为:this._ctx = my.createCanvasContext('once-key')

其中_ctx就是 CanvasContext 对象,通过它来绘制各种所需的内容。

本次功能需求主要分为:

  • 绘制圆角矩形背景
  • 绘制顶部标题
  • 绘制分割线
  • 绘制密码区域
  • 绘制底部提示文本

归纳后其实主要分为两种:绘制圆角矩形、绘制文本。

绘制圆角矩形

小程序的坐标系同Android上的一样,x轴为水平向右➡️,y轴为竖直向下⬇️。

如下roundRect方法所示,分别绘制了左上、右上、右下、左下角上的1/4的圆,以及各边。

其中使用arc在 Canvas 上绘制圆弧路径,其中 startAngle: 起始弧度,单位弧度(在 3 点钟方向)。具体定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* 在 Canvas 上绘制圆弧路径
* @description 圆弧路径的圆心在 (x, y) 位置,半径为 r ,根据anticlockwise (默认为顺时针,anticlockwise:false)指定的方向从 startAngle 开始绘制,到 endAngle 结束。
* @see https://opendocs.alipay.com/mini/api/lut4uo
*/
arc(
x: number,
y: number,
radius: number,
startAngle: number,
endAngle: number,
anticlockwise?: boolean
): void;
1
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

/**
* 绘制圆角矩形
* @param {Object} ctx - canvas组件的绘图上下文
* @param {Number} x - 矩形的x坐标
* @param {Number} y - 矩形的y坐标
* @param {Number} w - 矩形的宽度
* @param {Number} h - 矩形的高度
* @param {Number} r - 矩形的圆角半径
* @param {String} [c = 'transparent'] - 矩形的填充色
*/
roundRect(ctx, x, y, w, h, r, c = '#fff') {
if (w < 2 * r) { r = w / 2; }
if (h < 2 * r) { r = h / 2; }
ctx.save()//保存 canvas 全部状态
ctx.beginPath();//在 Canvas 上开始一个新路径
ctx.fillStyle = c;//设置填充色

//绘制左上圆角及边线
ctx.arc(x + r, y + r, r, Math.PI, Math.PI * 1.5);
ctx.moveTo(x + r, y);
ctx.lineTo(x + w - r, y);
ctx.lineTo(x + w, y + r);

//绘制右上圆角及边线
ctx.arc(x + w - r, y + r, r, Math.PI * 1.5, Math.PI * 2);
ctx.lineTo(x + w, y + h - r);
ctx.lineTo(x + w - r, y + h);

//绘制右下圆角及边线
ctx.arc(x + w - r, y + h - r, r, 0, Math.PI * 0.5);
ctx.lineTo(x + r, y + h);
ctx.lineTo(x, y + h - r);

//绘制左下圆角及边线
ctx.arc(x + r, y + h - r, r, Math.PI * 0.5, Math.PI);
ctx.lineTo(x, y + r);
ctx.lineTo(x + r, y);

ctx.closePath();//将笔点返回到当前子路径起始点,即闭合路径

ctx.fill();//根据当前的填充样式填充当前或已存在的路径
ctx.restore()//将 canvas 恢复到最近的保存状态
}

绘制文本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
* 绘制文本
*
* @param {*} ctx - canvas组件的绘图上下文
* @param {*} text - 绘制的文本
* @param {*} fontSize - 字体大小
* @param {*} fontColor - 字体颜色
* @param {*} y - 绘制文本起点的 y 轴位置
* @param {*} viewWidth - 卡片宽度
*/
drawText(ctx, text, fontSize, fontColor, y, viewWidth) {
ctx.save()
ctx.setFillStyle(fontColor)
ctx.setFontSize(fontSize)
let { width } = ctx.measureText(text + "")
if (width === undefined) [
width = measureText(text, fontSize, ctx)
]
var x = (viewWidth - width) / 2
// console.log(`drawCanvas-drawText:${text},has measureText:${ctx.measureText !== undefined}, measureText:${width},x:${x}`)
ctx.fillText(text, x, y)
ctx.restore()
}

绘制密码区域

密码区域拆分开来,每个密码显示UI就是圆角矩形的背景和居中的数字文本,并且每个密码UI平分卡片UI总宽度(减去设置的边距)。

根据密码长度、卡片总宽度、margin等值计算出密码块的宽高、数字密码的y轴坐标,再配合translateroundRectdrawText方法遍历绘制各个密码块。

其中便利数组使用of

1
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
/**
* 绘制密码区域
*
* @param {*} pwdDigitList - 需要绘制的数字密码列表
* @param {*} ctx - canvas组件的绘图上下文
* @param {*} viewWidth - 卡片视图总宽度
* @param {*} y - 密码区域绘制的y坐标
* @param {*} margin - 密码区域距卡片左右边距
* @param {*} itemMargin - 密码UI item之间的距离
*/
drawPwdArea(pwdDigitList, ctx, viewWidth, y, margin, itemMargin) {

if (pwdDigitList == null || pwdDigitList.length == 0) {
return
}
var itemCount = pwdDigitList.length,
itemWidth = (viewWidth - margin * 2 - itemMargin * (itemCount - 1)) / itemCount,
itemHeight = 112,
translateX = margin,
digitPointY = y + itemHeight / 2 + 16

console.log(`drawPwdArea,itemCount:${itemCount},itemWidth:${itemWidth},translateX:${translateX},digitPointY:${digitPointY}`)

ctx.save()
ctx.translate(translateX, 0)
for (const digit of pwdDigitList) {
this.roundRect(ctx, 0, y, itemWidth, itemHeight, 16, '#F0F2F7')
this.drawText(ctx, digit, 50, ' #2B3852', digitPointY, itemWidth)
translateX = itemWidth + itemMargin
ctx.translate(translateX, 0)
}
ctx.restore()
}

完整代码

save-key-card.js

1
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
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
import { measureText } from "./utils";

class KeyCrad {

_ctx
_type

constructor(canvasId, type) {
this._ctx = my.createCanvasContext(canvasId)
this._type = type
}

saveToGalary(deviceName = '可视门锁', pwdDigitList, callback = {
success: () => { },
fail: () => { }
}) {
this.drawCanvas(deviceName, pwdDigitList)
this._ctx.toTempFilePath({
success: (res) => {
my.saveImage({
url: res.apFilePath,
showActionSheet: false,
success: () => {
callback.success()
},
fail: () => {
callback.fail()
},
complete: () => {

}
});
},
fail: (error) => {
console.log('toTempFilePath, fail ', error)
}
})
}

/* ----------------------------- 画布相关 START ----------------------------- */

drawCanvas(deviceName, pwdDigitList) {
let ctx = this._ctx,
type = this._type,
typeName = type === 0 ? '随机' : '一次性',
viewWidth = 670,
viewHeight = 470
this.roundRect(ctx, 0, 0, viewWidth, viewHeight, 24)
this.drawText(ctx, `${deviceName}${typeName}密码`, 34, '#333333', 60, viewWidth)
this.drawDivider(ctx, viewWidth, 100)

this.drawPwdArea(pwdDigitList, ctx, viewWidth, 180, 54, 32)

this.drawText(ctx, `${typeName}密码只能使用一次,用完作废,`, 24, 'silver', viewHeight / 5 * 4, viewWidth)
this.drawText(ctx, '不要泄露给他人。', 24, 'silver', viewHeight / 5 * 4 + 40, viewWidth)
ctx.draw()
console.log(`drawCanvas-finish`)
}

/**
* 绘制文本
*
* @param {*} ctx - canvas组件的绘图上下文
* @param {*} text - 绘制的文本
* @param {*} fontSize - 字体大小
* @param {*} fontColor - 字体颜色
* @param {*} y - 绘制文本起点的 y 轴位置
* @param {*} viewWidth - 卡片宽度
*/
drawText(ctx, text, fontSize, fontColor, y, viewWidth) {
ctx.save()
ctx.setFillStyle(fontColor)
ctx.setFontSize(fontSize)
let { width } = ctx.measureText(text + "")
if (width === undefined) [
width = measureText(text, fontSize, ctx)
]
var x = (viewWidth - width) / 2
// console.log(`drawCanvas-drawText:${text},has measureText:${ctx.measureText !== undefined}, measureText:${width},x:${x}`)
ctx.fillText(text, x, y)
ctx.restore()
}

drawDivider(ctx, x, y) {
ctx.save()
ctx.beginPath()
ctx.setStrokeStyle('#F5F5F5')
ctx.setLineWidth(2)
ctx.moveTo(0, y)
ctx.lineTo(x, y)
ctx.stroke()
ctx.restore()
}

/**
* 绘制密码区域
*
* @param {*} pwdDigitList - 需要绘制的数字密码列表
* @param {*} ctx - canvas组件的绘图上下文
* @param {*} viewWidth - 卡片视图总宽度
* @param {*} y - 密码区域绘制的y坐标
* @param {*} margin - 密码区域距卡片左右边距
* @param {*} itemMargin - 密码UI item之间的距离
*/
drawPwdArea(pwdDigitList, ctx, viewWidth, y, margin, itemMargin) {

if (pwdDigitList == null || pwdDigitList.length == 0) {
return
}
var itemCount = pwdDigitList.length,
itemWidth = (viewWidth - margin * 2 - itemMargin * (itemCount - 1)) / itemCount,
itemHeight = 112,
translateX = margin,
digitPointY = y + itemHeight / 2 + 16

console.log(`drawPwdArea,itemCount:${itemCount},itemWidth:${itemWidth},translateX:${translateX},digitPointY:${digitPointY}`)

ctx.save()
ctx.translate(translateX, 0)
for (const digit of pwdDigitList) {
this.roundRect(ctx, 0, y, itemWidth, itemHeight, 16, '#F0F2F7')
this.drawText(ctx, digit, 50, ' #2B3852', digitPointY, itemWidth)
translateX = itemWidth + itemMargin
ctx.translate(translateX, 0)
}
ctx.restore()
}

/**
* 绘制圆角矩形
* @param {Object} ctx - canvas组件的绘图上下文
* @param {Number} x - 矩形的x坐标
* @param {Number} y - 矩形的y坐标
* @param {Number} w - 矩形的宽度
* @param {Number} h - 矩形的高度
* @param {Number} r - 矩形的圆角半径
* @param {String} [c = 'transparent'] - 矩形的填充色
*/
roundRect(ctx, x, y, w, h, r, c = '#fff') {
if (w < 2 * r) { r = w / 2; }
if (h < 2 * r) { r = h / 2; }
ctx.save()
ctx.beginPath();
ctx.fillStyle = c;

ctx.arc(x + r, y + r, r, Math.PI, Math.PI * 1.5);
ctx.moveTo(x + r, y);
ctx.lineTo(x + w - r, y);
ctx.lineTo(x + w, y + r);

ctx.arc(x + w - r, y + r, r, Math.PI * 1.5, Math.PI * 2);
ctx.lineTo(x + w, y + h - r);
ctx.lineTo(x + w - r, y + h);

ctx.arc(x + w - r, y + h - r, r, 0, Math.PI * 0.5);
ctx.lineTo(x + r, y + h);
ctx.lineTo(x, y + h - r);

ctx.arc(x + r, y + h - r, r, Math.PI * 0.5, Math.PI);
ctx.lineTo(x, y + r);
ctx.lineTo(x + r, y);

ctx.closePath();

ctx.fill();
ctx.restore()
}


}

export default KeyCrad

utils.js

1
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

/**
* 由于在iOS上CanvasContext.measureText返回API不存在,所以得自己计算文本text宽度
*
* @param {string} text
* @param {number} fontSize
*/
export const measureText = function (text, fontSize) {
text = String(text);
var text = text.split('');
var width = 0;
for (let i = 0; i < text.length; i++) {
let item = text[i];
if (/[a-zA-Z]/.test(item)) {
width += 7;
} else if (/[0-9]/.test(item)) {
width += 5.5;
} else if (/\./.test(item)) {
width += 2.7;
} else if (/-/.test(item)) {
width += 3.25;
} else if (/[\u4e00-\u9fa5]/.test(item)) {
width += 10;
} else if (/\(|\)/.test(item)) {
width += 3.73;
} else if (/\s/.test(item)) {
width += 2.5;
} else if (/%/.test(item)) {
width += 8;
} else {
width += 10;
}
}
return width * fontSize / 10;
}

使用示例

js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import KeyCard from './save-key-card';

Page({
onLoad(params) {
this.keyCard = new KeyCard('once-key', 1)
},

saveCanvasToGalary(deviceName) {
this.keyCard.saveToGalary(deviceName, this.data.pwdDigitList, {
success: () => {
showToast('已保存到手机相册');
},
fail: () => {
showToast('保存失败');
},
})
},
})

axml

1
<canvas id="once-key" class="canvas_pwd" width="670" height="470"></canvas>
坚持原创技术分享,您的支持是对我最大的鼓励!