微信小程序保存图片组件开发

591 阅读3分钟

前言

许多微信小程序通过保存海报让用户去分享活动让更多的人知道自己的小程序,想必在平时开发小程序的时候应该有遇见过吧。

今天我就来分享下之前在公司做的一个小程序保存海报的功能。首先我先描述下之前在公司做的需求是什么样的。公司上线的小程序会有一个长期的活动目的就是去推广新用户,每个用户都要有一张属于自己的海报,通过个人海报去推广则只是单纯的一种方式。

接到任务后,我也先去万能互联网做了调查但是我的师兄和我说这个做过类似的但是当时只是单纯为了完成任务所以代码很乱,然后他就从其他项目的代码找呀找,然后找到了给我~~~ 而当时给到我的时间紧任务重呀只好先用着调整一些并且交差了。之后呢我就根据网上的文章然后一步一步踩坑,一步一步走实现了一个保存海报的组件。

思路

首先声明下组件采用的是uniapp,具体实现了可以绘制图片、绘制文字以及保存海报至相册的基本功能,在开发中这些也完全够用了。

通过canvas绘制海报。通过uni.canvasToTempFilePath 将绘制好的 canvas转为图片。通过uni.saveImageToPhotosAlbum 将本地临时路径的图片保存至手机相册中。而我的想法是将所有采用的方法全部封装到组件中,只通过父组件去调用需要使用的方法和调整相关的参数即可。 具体使用可以查看示例代码

通过canvas绘制海报内容的顺序先后问题

通过使用promise对象决定绘制海报内容的顺序先后。promise.all()方法进行canvas最后一步的绘画操作 context.draw()

注意uni.getImageInfo()

  1. 在绘制图片 和 头像时,组件通过uni.getImageInfo() 去获取图片的相关信息,调用该方法成功的前提是需要在微信小程序后台配置download域名和request域名当然最好把uploadFile域名也一起配置,防止出差错。但是官方给出的提示是配置download域名白名单即可,但是获取不到图片信息,这算是一个大坑了。

  2. 如果没有进行相关配置,在调试时 或者 体验版 正式版等 打开了vconsole调试工具。uni.getImageInfo() 是可以获取到图片信息的,一旦关闭了vconsole uni.getImageInfo() 将会fail, 也是个坑。

本组件方法,变量介绍

props

  1. canvasInfo Object (必需)

    1. canvasWidth 画布宽度
    2. canvasHeight 画布高度
    3. canvasId 画布标识
  2. isFullScreen Boolean

    1. 为ture时表示画布为手机屏幕全屏,canvasInfo设置的宽高将失效。

    2. 默认为 false

methods

  1. canvasInit(callback) canvas初始化,所有有关画布canvas操作需在其回调函数操作。

  2. drawCanvasImage(context, src, _imageWidth, _imageHeight, dx, dy) 在canvas绘制一张图片

  3. drawCircularAvatar(context, url, _circularX, _circularY, _circularR) 在canvas绘制一张圆形图片

  4. drawText(options) 在canvas绘制单行、多行文本

  5. startDrawToImage(context, promiseArr, callback) 将canvas操作draw()进行绘制

  6. posterToPhotosAlbum(filePath) 保存至手机相册

示例代码

<template>
	<view>
		<view class="savePosterItem">
			<image v-show="tempFilePath" :src="tempFilePath"></image>
			<save-poster-com v-show="!tempFilePath" ref="savePoster" :canvasInfo="canvasInfo"></save-poster-com>
		</view>
		
		
		<button class="savePosterBtn" type="primary" @click="saveBtnFun">保存海报</button>
	</view>
</template>

<script>
	import SavePosterCom from '@/components/SavePosterCom/SavePosterCom.vue'
	export default {
		components: {
			SavePosterCom
		},
		data() {
			return {
				canvasInfo: {
					canvasWidth: 620,
					canvasHeight: 950,
					canvasId: 'save-poster'
				},
				tempFilePath: '',
				canvasBgUrl: 'https://images.pexels.com/photos/4065617/pexels-photo-4065617.jpeg?auto=compress&cs=tinysrgb&dpr=1&w=500',
				avatarUrl: 'https://p9-passport.byteacctimg.com/img/user-avatar/4dbf31fa6dec9c65b78a70d28d843c04~300x300.image'
			}
		},
		onLoad() {
			let {
				drawCanvasImage,
				drawCircularAvatar,
				drawText
			} = this.$refs.savePoster.$options.methods
			this.$refs.savePoster.canvasInit(({
				context,
				comThis
			}) => {
				// 获取画布宽高
				let canvasWH = comThis.canvasWH
				// 绘制海报背景图
				let promise_1 = drawCanvasImage(context, this.canvasBgUrl, canvasWH.canvasWidth, canvasWH.canvasHeight)
				// 必须先绘制玩海报背景图 再去操作其他绘制内容
				promise_1.then(res => {
					let promise_2 = drawCircularAvatar(context, this.avatarUrl, canvasWH.canvasWidth / 2, canvasWH.canvasHeight /
						7, 70)
					
					let promise_3 = drawText({
						context: context,
						text: '皮皮虾仁',
						dx: (canvasWH.canvasWidth / 2) + 60,
						dy: canvasWH.canvasHeight / 4,
						fontSize: 30,
						fontColor: '#5D4037'
					})
					
					let promise_4 = drawCanvasImage(context, this.avatarUrl, 150, 150, (canvasWH.canvasWidth / 2) + 85, (canvasWH.canvasHeight -
						165))
					 
					this.$refs.savePoster.startDrawToImage(context, [promise_1,promise_2,promise_4], (tempFilePath) => {
						this.tempFilePath = tempFilePath
					})
				})
			})
		},
		methods: {
			saveBtnFun() {
				uni.showModal({
					title: '保存海报',
					content: '海报将被保存至相册中',
					confirmText: '保存',
					success: (res) => {
						if(res.confirm) {
							this.$refs.savePoster.posterToPhotosAlbum(this.tempFilePath)
						}
					}
				})
			}
		}
	}
</script>

<style>
	.savePosterItem {
		text-align: center;
	}
	.savePosterItem > image {
		width: 620rpx;
		height: 950rpx;
	}
	
	.savePosterBtn {
		margin-top: 40rpx;
		width: 80%;
	}
</style>

组件源码

<template>
	<view>
		<canvas :canvas-id="canvasInfo.canvasId" :style="{width: canvasWH.canvasWidth + 'px', height: canvasWH.canvasHeight + 'px'}"></canvas>
	</view>
</template>

<script>
	export default {
		name: 'savePosterCom',
		data() {
			return {
				userPhoneWHInfo: {},
				canvasWH: {
					canvasWidth: 0,
					canvasHeight: 0
				}
			}
		},
		props: {
			// 决定保存下来的图片的宽高
			canvasInfo: {
				type: Object,
				default: () => {
					return {
						canvasWidth: 0,
						canvasHeight: 0,
						canvasId: 'canvasId'
					}
				}
			},
			// canvas画布是不是全屏,默认是false。 false时使用必须传 canvasInfo
			isFullScreen: Boolean
		},
		created() {
			this.userPhoneWHInfo = this.getPhoneSystemInfo()
			if (this.isFullScreen) { // 画布全屏
				this.canvasWH.canvasWidth = this.userPhoneWHInfo.windowWidth
				this.canvasWH.canvasHeight = this.userPhoneWHInfo.windowHeight
			} else { // 指定宽高
				this.canvasWH.canvasWidth = this.canvasInfo.canvasWidth
				this.canvasWH.canvasHeight = this.canvasInfo.canvasHeight
			}
		},
		mounted() {},
		methods: {
			/**
			 * 获取用户手机屏幕信息
			 */
			getPhoneSystemInfo() {
				const res = uni.getSystemInfoSync();
				return {
					windowWidth: res.windowWidth,
					windowHeight: res.windowHeight
				}
			},
			/** 获取 CanvasContext实例
			 * @param {String} canvasId 
			 */
			getCanvasContextInit(canvasId) {
				return uni.createCanvasContext(canvasId, this)
			},
			/** 保存海报组件初始化
			 * @param {Function} callback(context) 回调函数
			 */
			canvasInit(callback) {
				let context = this.getCanvasContextInit(this.canvasInfo.canvasId)
				if (context) {
					callback({
						context: context,
						comThis: this
					})
				}
			},
			/** 将上诉的绘制画到画布中 并且 将画布导出为图片
			 *  @param context 画布
			 *  @param {Promise[]} 存放Promise的数组 
			 *  @param {Function} callback 保存图片后执行的回调函数(本地图片临时路径)
			 */
			startDrawToImage(context, promiseArr, callback) {
				// 将之前在绘图上下文中的描述(路径、变形、样式)画到 canvas 中
				let canvasId = this.canvasInfo.canvasId
				let tempFilePath = ''
				Promise.all(promiseArr).then(res => {
					context.draw(false, async () => {
						callback(await this.canvasToImage(canvasId))
					})
				})
			},
			/**
			 * 在canvas绘制一张图片
			 * @param context 画布
			 * @param src 图片资源
			 * @param _imageWidth 图片宽度
			 * @param _imageHeight 图片高度 
			 */
			drawCanvasImage(context, src, _imageWidth, _imageHeight, dx, dy) {
				return new Promise((resolve, reject) => {
					uni.getImageInfo({
						src: src,
						success: res => {
							context.drawImage(res.path, (dx - _imageWidth), (dy - _imageHeight), _imageWidth, _imageHeight)
							resolve(context)
						},
					})
				})
			},
			/** 绘制一个圆形头像
			 * @param  context 画布 
			 * @param  url     图片地址
			 * @param  _circularX  圆心X坐标
			 * @param  _circularY  圆心Y坐标
			 * @param  _circularR  圆半径
			 */
			drawCircularAvatar(context, url, _circularX, _circularY, _circularR) {
				let dx = _circularX - _circularR;
				let dy = _circularY - _circularR;
				let dwidth = _circularR * 2;
				let dheight = _circularR * 2
				return new Promise((resolve, reject) => {
					uni.downloadFile({
						url: url,
						success: res => {
							context.save()
							context.beginPath()
							// _circularX圆的x坐标  _circularY圆的y坐标  _circularR圆的半径
							context.arc(_circularX, _circularY, _circularR, 0, 2 * Math.PI)
							context.clip()
							// dx: 图像的左上角在目标canvas上 X 轴的位置
							// dy: 图像的左上角在目标canvas上 Y 轴的位置
							// dwidth: 在目标画布上绘制图像的宽度,允许对绘制的图像进行缩放
							// dheight: 在目标画布上绘制图像的高度,允许对绘制的图像进行缩放
							context.drawImage(res.tempFilePath, dx, dy, dwidth, dheight)
							context.restore()
							// context.draw()
							resolve(context)
						}
					})
				})
			},
			/** 绘制多行文本 注:, 和 空格都算一个字
			 * @param context 画布
			 * @param text 需要被绘制的文本
			 * @param dx 左上角x坐标
			 * @param dy 右上角y坐标
			 * @param rowStrnum 每行多少个字 (默认为text字体个数->单行)
			 * @param fontSize 文字大小 (默认16)
			 * @param fontColor 文字颜色 (默认black)
			 * @param lineHeight 单行文本行高 (默认0)
			 */
			drawText(options) {
				let {
					context,
					text,
					dx,
					dy,
					rowStrnum = text.length,
					lineHeight = 0,
					fontSize = 16,
					fontColor = 'black'
				} = options
				return new Promise((resolve, reject) => {
					context.setFontSize(fontSize)
					context.setFillStyle(fontColor)
					context.setTextBaseline('middle')
					// 获取需要绘制的文本宽度
					let textWidth = Number(context.measureText(text).width)
					// console.log('textWidth',textWidth)
					// 获取文本的字数 
					let textNum = text.length
					// 获取行数 向上取整
					let lineNum = Math.ceil(textNum / rowStrnum)
					// console.log('textNum',textNum)
					// console.log('lineNum',lineNum)
					for (let i = 0; i < lineNum; i++) {
						let sliceText = text.slice(i * rowStrnum, (i + 1) * rowStrnum)
						// fillText 的 dx = 文字最左边的距离到屏幕政策的距离
						context.fillText(sliceText, dx - textWidth, dy + i * lineHeight);
					}
					resolve(context)
				})
			},
			/** 将画布导出为图片
			 * @param canvasId 画布标识
			 */
			canvasToImage(canvasId) {
				return new Promise((resolve, reject) => {
					uni.canvasToTempFilePath({
						canvasId: canvasId, // 画布标识
						success: res => {
							// 在H5平台下,tempFilePath 为 base64
							resolve(res.tempFilePath)
						},
						fail: err => {
							console.log('err', err)
							reject(err)
						}
					}, this)
				})
			},
			/** 保存生成的图片到本地相册中
			 *  @param {String} filePath 图片临时路劲
			 */
			posterToPhotosAlbum(filePath) {
				console.log('filePath',filePath)
				uni.showLoading({
					title: '保存中...'
				})
				uni.saveImageToPhotosAlbum({
					filePath: filePath,
					success: (res) => {
						uni.showToast({
							title: '保存成功,请前往手机相册中查看',
							mask: true,
							icon: 'none',
							duration: 2000
						})
					},
					fail: (err) => {
						console.log('err',err)
						if (err.errMsg.includes('deny')||err.errMsg.includes('denied')) { // 用户选择拒绝 
							this.openSetting()
						} else if (err.errMsg.includes('fail cancel')) { // 用户在保存图片时 取消了
							uni.showToast({
								title: '已取消保存,无法保存至相册',
								mask: true,
								icon: 'none',
								duration: 2000
							})
							return
						}
					},
					complete: () => {
						uni.hideLoading()
					}
				})
			},
			/**
			 * 打开摄像头设置权限页面
			 */
			openSetting() {
				uni.showModal({
					title: '温馨提示',
					content: '保存图片至相册中,需要您同意添加访问相册权限',
					cancelText: '拒绝',
					confirmText: '同意',
					success: res => {
						if (res.confirm) {
							uni.openSetting({
								success: settingdata => {
									if (settingdata.authSetting['scope.writePhotosAlbum']) {
										console.log('获取权限成功,给出再次点击图片保存到相册的提示。')
										uni.showToast({
											title: '授权成功,请再次点击保存',
											icon: 'none',
											duration: 2000,
										})
									} else {
										console.log('获取权限失败,给出不给权限就无法正常使用的提示')
										uni.showToast({
											title: '需要访问相册权限',
											icon: 'none',
											duration: 2000,
										})
									}
								},
								fail: (res) => {
									console.log('err', err)
								}
							})
						} else {
							uni.showToast({
								title: '已拒绝授权,无法保存至相册',
								mask: true,
								icon: 'none',
								duration: 2000
							})
							return
						}
					}
				})
			}
		}
	}
</script>

<style>
</style>

效果

jgedx-x12pb.gif