终于逃离海报的折磨,自己封装一个小程序分享海报组件

3,418 阅读5分钟

前言

事情是这样,最近工作一直都在进行微信小程序的开发,在开发的过程中遇到一个需求,需要生成一张海报,让别的用户可以通过海报进入小程序。OK,网上查阅一下资料很快便解决了问题。

接着事情来了,需求变了,这个小程序不只一个海报,海报不是静态的内容,每一个海报需要根据页面的内容动态展示,且二维码需要携带一定的信息。

比如:在商城小程序中,可以分享小程序的不同主题海报,在不同的商品页面生成不同商品海报。要求海报内携带用户信息用于积分累加和页面跳转。

嗯。。网上搜了一圈之后。。该来的还是要来的,那就自己封装一个吧!

需求整理

基本需求: 用户选择一个一个图片,根据这个图片生成对应的海报内容,可自行添加文字、图片、小程序二维码,最后将生成好的海报内容保存至本地相册。

实现思路: 公司内的小程序都是用 uni 去编译开发的,所以这次也选择用uni。海报生成的部分通过canvas来对海报进行绘制,通过 canvasToTempFilePath 将图片缓存至本地。用 image 标签预览绘制好的图片,用户如果认为 OK 在点击保存按钮,授权相册进行保存。

整体实现

封装的组件功能如下:

1.预览海报底图
2.根据海报配置参数绘制海报
3.可快速绘制图片、文字、矩形、圆、直线、曲线
4.可配置小程序二维码(需要后端支持)
5.可快速保存至本地相册
6.提供 poster.js 功能独立可拆分,可单纯的绘制canvas
7.提供了两个插槽 header 和 save。自定义标题和保存
<template>
	<view class="poster_wrapper">
		<slot name="header"></slot>
		<!-- 要生成海报的图片 -->
		<image :src="imageUrl" mode="aspectFill" :style="{width:imageWidth + 'rpx',height:imageHeight + 'rpx'}" @click="click"></image>
		<!-- 这里把canvas移到了屏幕外面,如果需要查看canvas的话把定位去掉 -->
		<!-- position:'fixed',left:'9999px',top:'0' -->
		<canvas :style="{width:canvasWidth + 'px',height:canvasHeight + 'px',position:'fixed',left:'9999px',top:'0'}"
		 canvas-id="myCanvas" id="myCanvas" class="canvas"></canvas>
		<!-- 遮罩层 -->
		<view class="mask" v-if="showMask" @click="hideMask">
			<!-- 生成的海报图 -->
			<image :style="posterSize" :src="lastPoster" :mode="config.imageMode" @click.stop=""></image>
			<view class="btn_wrapper" @click.stop>
				<slot name="save">
					<button type="primary" @click="saveToAlbum">保存至相册</button>
				</slot>
			</view>
		</view>
	</view>
</template>

<script>
	import {
		loadImage,
		createPoster,
		canvasToTempFilePath,
		saveImageToPhotosAlbum
	} from '@u/poster.js';
	import {
		getWechatCode
	} from "@u/appletCode.js";
	export default {
		props: {
			// 展示图片的宽 单位 rpx
			imageWidth: {
				type: [String, Number],
				default: 550
			},
			// 展示图片的高 单位 rpx
			imageHeight: {
				type: [String, Number],
				default: 980
			},
			// 展示图片的url
			imageUrl: {
				type: String,
				default: '',
				required: true
			},
			// 绘制海报的数据参数
			drawData: {
				type: Array,
				default: () => ([]),
				required: true
			},
			// 海报的配置参数
			config: {
				type: Object,
				default: () => ({
					imageMode: 'aspectFit',
					posterHeight: '80%',
				}),
			},
			// 是否需要小程序二维码
			wechatCode: {
				type: Boolean,
				default: false
			},
			// 小程序二维码的配置参数
			wechatCodeConfig: {
				type: Object,
				default: () => ({
					serverUrl: '',
					scene: '',
					config: {
						x: 0,
						y: 0,
						w: 100,
						h: 100
					}
				}),
			}
		},
		data() {
			return {
				// 资源是否加载成功的标志
				readyed: false,
				// 将网络图片转成静态图片后的绘制参数
				imageMap: [],
				// 最后生成的海报的本地缓存地址
				lastPoster: '',
				// 是否展示遮罩
				showMask: false,
				// 是否加载资源的标志
				loadingShow: false,
				// 是否可以创建海报
				disableCreatePoster:false,
			}
		},
		computed: {
			// 所生成海报图的大小
			posterSize() {
				let str = '';
				this.config.posterWidth && (str += `width:${this.config.posterWidth};`);
				this.config.posterHeight && (str += `height:${this.config.posterHeight};`);
				return str
			},
			// 画布的宽,优先使用配置的宽,如果没用配置默认使用图片的宽
			// 需要主要的是这里canvas和image的单位不同,不过并不影响
			// 我们在绘制时(配置drawData)以px为基准进行绘制就行,用px的原因时防止不同设备Dpr不同导致无法确定画布的具体宽高,使得最后的图片可能会留白边
			canvasWidth(){
				return this.config.canvasWidth ? this.config.canvasWidth : this.imageWidth
			},
			// 画布的高,优先使用配置的高,如果没用配置默认使用图片的高
			canvasHeight(){
				return this.config.convasHeight ? this.config.convasHeight : this.imageHeight
			}
		},
		watch: {
			// 监听外部绘制参数的变化,重新加载资源
			drawData(newVlaue) {
				this.loadingResources(newVlaue)
			},
			// 监听readyed变化
			readyed(newVlaue) {
				// 用户点击了生成海报且资源还没有加载好,待资源加载好后触发海报生成
				if (newVlaue == true && this.loadingShow == true) {
					uni.hideLoading()
					this.loadingShow = false;
					this.disableCreatePoster = false;
					this.createImage();
				}
			}
			// 会存在异步问题,还没解决。
			// 目前的解决方法 1.在绘制之前先改变 scene 2.改变scene后手动调用this.loadingResources 函数,但是资源会重新加载
			// "wechatCodeConfig.scene":function (newVlaue){
			// 	console.log('wechatCodeConfig.scene',this.imageMap)
			// 	this.loadingWechatCode(this.imageMap)
			// }
		},
		created() {
			this.loadingResources(this.drawData)
		},
		methods: {
			
			// 加载静态资源,创建或更新组件内所下载本地图片集合
			async loadingResources(drawData) {
				this.readyed = false;
				if (!drawData.length || drawData.length <= 0) return;
				// 加载静态图片,将所以图片的网络地址替换成本地缓存地址
				const tempMap = [];
				for (let i = 0; i < drawData.length; i++) {
					let temp
					if (drawData[i].type === "image") {
						temp = await loadImage(drawData[i].config.url);
						drawData[i].config.url = temp;
					}
					tempMap.push({ 
						...drawData[i],
						url: temp
					})
				}
				// 加载小程序二维码
				await this.loadingWechatCode(tempMap);
				// 赋值给imageMap保存
				this.imageMap = tempMap;
				setTimeout(() => {
					this.readyed = true;
				}, 100)
			},
			// 绘制海报图
			async createImage() {
				// 禁用生成海报,直接返回
				if(this.disableCreatePoster) return
				this.disableCreatePoster = true;
				try {
					if (!this.readyed) {
						uni.showLoading({
							title: '静态资源加载中...'
						});
						this.loadingShow = true;
						this.$emit('loading')
						return
					}
					// 获取上下文对象,组件内一定要传this
					const ctx = uni.createCanvasContext('myCanvas', this);
					await createPoster(ctx, this.imageMap);
					this.lastPoster = await canvasToTempFilePath('myCanvas', this);
					this.showMask = true;
					this.disableCreatePoster = false;
					// 创建成功函数
					this.$emit('success')
				} catch (e) {
					// 创建失败函数
					this.disableCreatePoster = false;
					this.$emit('fail', e)
				}
			},
			// 加载或更新小程序二维码
			async loadingWechatCode(tempMap) {
				if (this.wechatCode) {
					if (this.wechatCodeConfig.serverUrl) {
						const code = await getWechatCode(this.wechatCodeConfig.serverUrl, this.wechatCodeConfig.scene || '');
						// 记录替换的索引,没有就替换length位,即最后加一个
						let targetIndex = tempMap.length;
						for (let i = 0; i < tempMap.length; i++) {
							if (tempMap[i].wechatCode) targetIndex = i;
						}
						tempMap.splice(targetIndex, 1, {
							type: 'image',
							url: code.path,
							// 标记是小程序二维码
							wechatCode: true,
							config: this.wechatCodeConfig.config,
						})
					} else {
						throw new Error('serverUrl请求二维码服务器地址不能为空')
					}
				}
				return tempMap
			},
			// 保存到相册
			saveToAlbum() {
				saveImageToPhotosAlbum(this.lastPoster).then(res => {
					this.showMask = false;
					uni.showToast({
						icon: 'none',
						title: '保存成功'
					})
				}).catch(err => {
					
				})
			},
			click() {
				this.$emit('click')
			},
			hideMask(){
				this.showMask = false;
				this.$emit('hidemask')
			}
		},
	}
</script>

<style scoped>
	.poster_wrapper {
		width: 100%;
		display: flex;
		flex-direction: column;
		align-items: center;
	}

	.canvas {
		border: 1px solid #333333;
	}

	.mask {
		width: 100vw;
		height: 100vh;
		position: fixed;
		background-color: rgba(0,0,0,.4);
		left: 0;
		top: 0;
		display: flex;
		flex-direction: column;
		justify-content: space-around;
		align-items: center;
	}
</style>

绘制功能

poster.js 提供了与海报相关的功能函数,包括解析配置参数,根据配置参数绘制canvas,canvas缓存为本地图片,本地图片保存至手机相册等。在绘制的时候为了方便查看哪的配置参数有问题,使用了一个检查函数进行快速定位。整体实现如下:

// 错误提示集合
const errMsgMap = {
	'arc':{
		'x':'请指定圆的起始位置 x',
		'y':'请指定圆的起始位置 y',
		'r':'请指定圆的半径 r',
		'sAngle':'请指定圆的起始弧度 sAngle',
		'eAngle':'请指定圆的终止弧度 eAngle',
	},
	'rect':{
		'x':'请指定矩形的起始位置 x',
		'y':'请指定矩形的起始位置 y',
		'w':'请指定矩形的宽度 w',
		'h':'请指定矩形的高度 h',
	},
	'stroke_rect':{
		'x':'请指定矩形边框的起始位置 x',
		'y':'请指定矩形边框的起始位置 y',
		'w':'请指定矩形边框的宽度 w',
		'h':'请指定矩形边框的高度 h',
	},
	'text':{
		'x':'请指定文本的起始位置 x',
		'y':'请指定文本的起始位置 y',
		'text':'请指定文本的内容 text'
	},
	'image':{
		'x':'请指定图片的起始位置 x',
		'y':'请指定图片的起始位置 y',
		'w':'请指定图片的宽度 w',
		'h':'请指定图片的高度 h',
		'url':'请指定图片的路径 url'
	},
	'line':{
		'path':'请指定线的路径 path'
	},
	'points':{
		'points':'请指定点集合 points'
	}
};
// 绘制方法集合
const DrawFuncMap = {
	drawLine(ctx,config,i){
		// 检验必传参数
		checkNecessaryParam(config,'line',i,'path');
		// 每一个path就描述了一组线的开始到结束,这一组线段不一定是连续的,根据配置属性来具体描述这个线
		// 他们的形态是一样的(线的粗细,颜色),形状不一样且不一定是连续的
		for(let j = 0; j < config.path.length; j++){
			const path = config.path[j];
			checkNecessaryParam(path,'points',`${i}-${j}`,'points');
			const lineWidth = path.lineWidth || 1;
			const lineJoin = path.lineJoin || 'round';
			const lineCap = path.lineCap || 'round';
			ctx.beginPath();
			// 设置颜色
			ctx.setStrokeStyle(path.strokeStyle || '#333333');
			// 设置粗细
			ctx.setLineWidth(lineWidth);
			// 设置线条交点样式
			ctx.setLineJoin(lineJoin);
			// 设置线条的断点样式
			ctx.setLineCap(lineCap);
			// 遍历线的点集合,根据每个点的不同属性来绘制成线
			for(let k = 0; k < path.points.length; k++){
				// 拿到每一个点
				const pointSet = path.points[k];
				// 如果该点是一个数组集合,则点的类型直接当 lineTo 处理
				if(Object.prototype.toString.call(pointSet) === "[object Array]"){
					if(k === 0) ctx.moveTo(...pointSet);
					else ctx.lineTo(...pointSet);
				}else{
					// 默认的第一个点一定是起始点,且点类型为 moveTo 则执行 ctx.moveTo 移动画笔
					if(k === 0 || pointSet.type === 'moveTo'){
						ctx.moveTo(...pointSet.point);
					// 点的类型为 lineTo 或 没有 type 属性也默认为 lineTo 至执行 ctx.lineTo 连线
					}else if(pointSet.type === 'lineTo' || pointSet.type === undefined){
						ctx.lineTo(...pointSet.point);
					}else if(pointSet.type === 'bezierCurveTo'){
						const P2 = pointSet.P2 ? pointSet.P2 : pointSet.P1;
						ctx.bezierCurveTo(...pointSet.P1,...P2,...pointSet.point);
					}
				}
			}
			// 每一组点集合(path)结束 stroke
			ctx.stroke();
		}
	},
	// 绘制图片
	drawImage(ctx,config,i){
		checkNecessaryParam(config,'image',i,'x','y','w','h','url');
		ctx.drawImage(config.url, config.x, config.y, config.w, config.h);
	},
	// 绘制圆
	drawArc(ctx,config,i){
		checkNecessaryParam(config,'arc',i,'x','y','r','sAngle','eAngle');
		ctx.beginPath();
		ctx.arc(config.x, config.y, config.r, config.sAngle, config.eAngle);
		ctx.setFillStyle(config.fillStyle || '#333333');
		ctx.fill();
		ctx.setLineWidth(config.lineWidth || 1);
		ctx.setStrokeStyle(config.strokeStyle || '#333333');
		ctx.stroke();
	},
	// 绘制文字
	drawText(ctx,config,i){
		checkNecessaryParam(config,'text',i,'x','y','text');
		ctx.font = config.font || '10px sans-serif';
		ctx.setFillStyle(config.color || '#333333');
		ctx.setTextAlign(config.textAlign || 'center');
		ctx.fillText(config.text, config.x, config.y);
		ctx.stroke();
	},
	// 绘制矩形
	drawRect(ctx,config,i){
		checkNecessaryParam(config,'rect',i,'x','y','w','h');
		ctx.beginPath();
		ctx.rect(config.x, config.y, config.w, config.h);
		ctx.setFillStyle(config.fillStyle || '#333333');
		ctx.fill();
		ctx.setLineWidth(config.lineWidth || 1);
		ctx.setStrokeStyle(config.strokeStyle || '#333333');
		ctx.stroke();
	},
	// 绘制非填充矩形
	drawStrokeRect(ctx,config,i){
		checkNecessaryParam(config,'stroke_rect',i,'x','y','w','h');
		ctx.beginPath();
		ctx.setStrokeStyle(config.strokeStyle || '#333333');
		ctx.setLineWidth(config.lineWidth || 1);
		ctx.strokeRect(config.x, config.y, config.w, config.h);
		ctx.stroke();
	},
}

/**
 * 检测绘制的必要属性
 * @param {Object} configObj 配置对象
 * @param {String} type 对应校验的类型
 * @param {String|Number} index 当前的错误位置 从0开始对应绘画(drawData)配置中的索引,
 * 当为 String 类型时会以'-'间隔出第几层的第几个,如1-2 表示是绘画(drawData)配置中第一个配置里的第二个子配置对象有问题,依次类推
 * @param {Array} keyArr 搜集到的所以需要进行检验的键名
 **/
function checkNecessaryParam (configObj,type,index,...keyArr){
	// 这里要注意由于,绘画配置有些参数可能会漏写,所以 errMsgMap[type] 作为遍历对象进行比较
	for(let prop in errMsgMap[type]){
		if(configObj[prop] === undefined){
			throw new Error(`第${index}顺位:${errMsgMap[type][prop]}` )
		}
	}
}
// 获取图片信息,这里主要要获取图片缓存地址
export function loadImage(url) {
	return new Promise((resolve, reject) => {
		wx.getImageInfo({
			src: url,
			success(res) {
				resolve(res.path)
			},
			fail(err) {
				reject('海报图资源加载失败')
			}
		})
	})
}
// 解析海报对象,绘制canvas海报
export function createPoster(ctx, posterItemList) {
	return new Promise((resolve,reject)=>{
		try{
			for (let i = 0; i < posterItemList.length; i++) {
				const temp = posterItemList[i];
				if (temp.type === 'image') {
					DrawFuncMap.drawImage(ctx,temp.config,i);
				} else if (temp.type === 'text') {
					DrawFuncMap.drawText(ctx,temp.config,i);
				} else if ( temp.type === 'arc' ){
					DrawFuncMap.drawArc(ctx,temp.config,i);
				} else if (temp.type === 'rect'){
					DrawFuncMap.drawRect(ctx,temp.config,i);
				} else if (temp.type === 'stroke_rect'){
					DrawFuncMap.drawStrokeRect(ctx,temp.config,i);
				} else if (temp.type === 'line'){
					DrawFuncMap.drawLine(ctx,temp.config,i)
				}
			}
			ctx.draw();
			resolve({result:'ok',msg:'绘制成功'})
		}catch(e){
			console.error(e)
			reject({result:'fail',msg:e})
		}
	})
}
// canvas转image图片
export function canvasToTempFilePath(canvasId, vm,delay=50) {
	return new Promise((resolve, reject) => {
		// 这里canvas绘制完成之后想要存缓存需要一定时间,这里设置了50毫秒
		setTimeout(()=>{
			uni.canvasToTempFilePath({
				canvasId: canvasId,
				success(res) {
					if (res.errMsg && res.errMsg.indexOf('ok') != -1) resolve(res.tempFilePath);
					else reject(res)
				},
				fail(err) {
					reject(err)
				}
			}, vm);
		},delay)
	})
}
// 保存图片到相册
export function saveImageToPhotosAlbum(imagePath) {
	return new Promise((resolve, reject) => {
		uni.saveImageToPhotosAlbum({
			filePath: imagePath,
			success(res) {
				resolve(res)
			},
			fail(err){
				reject(err)
			}
		})
	})
}

项目开源

整体功能大致介绍完了,具体的使用示例可以看我的开源仓库,或者直接在uni插件市场引入示例。

这是我自己第二个开源,如果有写的不足之处还望见谅。如果您发现了任何问题或者发现有哪些地方需要改进,欢迎在下面留言。

后面的计划:

1. 用户可以在多张图片中选择一个喜欢的生成海报
2. 在现有的基础上扩展canvas绘制功能
3. 优化模板样式
4. 解决小程序二维码携带参数变更时海报上二维码图片未能及时更新的问题

uni插件市场

gitee仓库

具体效果图