uniapp canvas 生成海报并保存到相册

发布于:2025-02-15 ⋅ 阅读:(14) ⋅ 点赞:(0)

前言:

之前写过一篇canvas小程序画图只要是canvas各种方法的实际应用,有兴趣的小伙伴也可以看看

微信小程序:使用canvas 生成图片 并分享_小程序canvas生成图片-CSDN博客

上一篇文章是小试牛刀,这次是更加全面的记录生成海报的实战应用

一.实现核心原理

1.uni.createCanvasContext----创建canvas画布

uni.createCanvasContext(canvasId, componentInstance) | uni-app官网

2.uni.canvasToTempFilePath ---- 保存临时图片uni.canvasToTempFilePath(object, componentInstance) | uni-app官网

3.uni.saveImageToPhotosAlbum ----下载图片到本地

uni-app官网

利用canvas 先画出二倍图(这样导出的图片不会模糊),canvas容器本身通过visibility: hidden 进行隐藏,实际用image展示画好的海报,并通过uni.saveImageToPhotosAlbum 可进行下载。

二.实现代码

模板部分:

<!-- canvas 绘制海报 -->
	<view class="poster_box" v-if="showPoster">
		<view class="poster_title_box">
			<view class="poster_title">生成海报</view>
			<image @click="() => showPoster = false" class="poster_close"
				src="close.png"
				mode="scaleToFill" />
		</view>
	<image class="canvas_image" :src="lastGeneratedImage" alt="" show-menu-by-longpress/>
		<view class="poster_save_box" @click="savePosterByQR">
			<image class="poster_save"
				src="https://bhk-cms.oss-accelerate.aliyuncs.com/wechat/static/image/poster/poster_save.png"
				mode="scaleToFill" />
			<view class="poster_save_text">保存海报到本地</view>
		</view>
		<canvas canvas-id="positionPoster" class="canvas" :style="{width: canvasW,height:canvasH}">
		</canvas>
	</view>
	<!-- 海报遮罩 -->
<view v-if="showPoster" class="mask" @click="() => showPoster = false"></view>

样式部分:

.mask {
	position: fixed;
	top: 0;
	left: 0;
	width: 100%;
	height: 100%;
	background: #00000066;
	z-index: 120;
}

.poster_box {
	position: absolute;
	top: 0;
	bottom: 0;
	left: 0;
	right: 0;
	margin: auto;
	display: flex;
	flex-direction: column;
	justify-content: center;
	align-items: center;
	text-align: center;
	z-index: 121;
	width: 606rpx;

	.poster_title_box {
		width: 100%;
		text-align: center;
		position: relative;
		font-family: PingFang SC;
		margin-bottom: 48rpx;

		.poster_title {
			font-size: 32rpx;
			font-weight: 500;
			line-height: 48rpx;
			color: #fff;
		}

		.poster_close {
			position: absolute;
			top: 0;
			bottom: 0;
			margin: auto;
			right: 0;
			width: 48rpx;
			height: 48rpx;
		}

	}

	.poster_save_box {
		margin-top: 40rpx;


		.poster_save {
			width: 96rpx;
			height: 96rpx;
			margin-bottom: 8rpx;

		}

		.poster_save_text {
			font-family: PingFang SC;
			font-size: 28rpx;
			font-weight: 500;
			line-height: 44rpx;
			text-align: center;
			color: #fff;

		}
	}

	.canvas_image {
		width: 606rpx;
		height: 924rpx;
	}

	.canvas {
		visibility: hidden;
		z-index: -999;
		position: absolute;
	
	}
}

vue部分:

function fillBg(ctx, height, color1, color2 = color1) {
	const gradient = ctx.createLinearGradient(0, height, 0, 0); // 从下往上渐变
	gradient.addColorStop(0, color1); // 起点颜色
	gradient.addColorStop(1, color2); // 终点颜色
	ctx.fillStyle = gradient;
}
function useCanvas() {
	const showPoster = ref(false);
	const canvas_finished = ref(true);
	const lastGeneratedImage = ref(null);
	const showPosterContent = ref(false);
	// 获取屏幕宽度和设备像素比
	const dpr = uni.getWindowInfo().pixelRatio; // 设备像素比
	const logicalWidth = toPx(303); // 逻辑宽度
	const logicalHeight = toPx(462); // 逻辑高度
	const canvasWidth = logicalWidth * dpr; // 实际宽度
	const canvasHeight = logicalHeight * dpr; // 实际高度
	// const canvasWidth = logicalWidth; // 实际宽度
	// const canvasHeight = logicalHeight; // 实际高度

	//绘制海报
	async function createPoster(options) {
		canvas_finished.value = true;
		showPosterContent.value = false;
		const { originData, houseData, rmb_price, price } = options;
		const width = toPx(303);
		const height = toPx(462);
		// 获取屏幕宽度(单位为 px)
		const screenWidth = uni.getWindowInfo().screenWidth;
		const screenHeight = uni.getWindowInfo().screenHeight;

		// 检查是否已经生成过图像
		if (lastGeneratedImage.value) {
			// 如果已经生成过图像,直接使用上次的图像数据
			const ctx = uni.createCanvasContext("positionPoster");
			ctx.clearRect(0, 0, width, height); // 清除画布
			ctx.drawImage(lastGeneratedImage.value, 0, 0); // 将上次的图像绘制回canvas
			ctx.draw();
			showPosterContent.value = true;
			uni.hideLoading();
			return; // 结束,不再重新生成
		}

		//初始化画布
		const ctx = uni.createCanvasContext("positionPoster");
		// ctx.scale(dpr, dpr);
		// 提取圆角值
		const bgRadius = [toPx(8), toPx(8), toPx(48), toPx(8)];

		/* 背景 */
		// 创建线性渐变(垂直方向,模拟 360deg)
		fillBg(ctx, height, "#669CFF", "#1F7CFF");

		/* 外层-圆角矩形开始 */
		drawRoundedRect(ctx, 0, 0, width, height, toPx(8));
		/* 外层-圆角矩形结束 */
		//画logo和背景图
		await drawImageWithCache(
			ctx,
			posterLogo,
			toPx(14),
			toPx(16),
			toPx(130),
			toPx(32)
		);
		await drawImageWithCache(
			ctx,
			posterBg,
			toPx(229),
			toPx(7),
			toPx(56),
			toPx(56)
		);

		//主要内容-content

		// /* content-圆角矩形开始 */
		ctx.setFillStyle("#fff");
		drawRoundedRect(ctx, toPx(14), toPx(60), toPx(275), toPx(316), toPx(8));
		/* content-圆角矩形结束 */

		// banner
		/* banner-圆角矩形开始 */
		ctx.fillStyle = "#fff";
		ctx.save(); // 保存当前绘制状态
		drawRoundedRect(ctx, toPx(26), toPx(72), toPx(251), toPx(154), toPx(4));
		ctx.clip(); // 限制绘制区域为圆角矩形
		/* banner-圆角矩形结束 */

		/*  banner图绘制开始*/
		const firstImg =
			originData.thumbnail + "?x-oss-process=image/resize,w_251/quality,q_85";

		await drawImageWithCache(
			ctx,
			firstImg,
			toPx(26),
			toPx(72),
			toPx(251),
			toPx(154)
		);

		// 恢复
		ctx.restore();
		/*  banner图绘制结束*/

		//title
		ctx.fillStyle = "#000";
		ctx.font = "18px PingFang SC";
		ctx.setFontSize(toPx(18));
		//多行显示
		toFormateStr(
			ctx,
			originData.title,
			toPx(26),
			toPx(250),
			toPx(26),
			toPx(250)
		);

		//两室两厅一卫 |90㎡| 芭提雅
		ctx.fillStyle = "#555";
		ctx.font = "10px PingFang SC";
		ctx.setFontSize(toPx(10));
		const room =
			houseData.bedroom === 0
				? "开间"
				: `${houseData.bedroom || "-"}卧${houseData.toilet || "-"}卫`;
		const area = houseData.building || "-";
		const location = houseData.area === 168 ? "芭提雅" : "曼谷";
		const info = `${room} | ${area}㎡ | ${location} `;
		ctx.fillText(info, toPx(26), toPx(302), toPx(250));

		//tag标签
		await drawImagesInRow(ctx, houseData);

		//价格
		drawPriceText(ctx, houseData, rmb_price, price);

		//画图二维码
		/* 二维码-圆角矩形开始 */
		ctx.fillStyle = "#fff";
		ctx.save(); // 保存当前绘制状态
		drawRoundedRect(ctx, toPx(233), toPx(390), toPx(56), toPx(56), toPx(4));
		ctx.clip();

		await drawImageWithCache(
			ctx,
			originData.wx_qr_code,
			toPx(237),
			toPx(394),
			toPx(48),
			toPx(48)
		);
		// // 恢复
		ctx.restore();
		/* 二维码-圆角矩形结束 */

		/* 右侧-文字部分 */
		ctx.fillStyle = "#fff";
		ctx.font = "14px PingFang SC";
		ctx.setFontSize(toPx(14));
		ctx.fillText("找大管家做省心业主", toPx(14), toPx(414), toPx(420));

		ctx.fillStyle = "#e3eeff";
		ctx.font = "8px PingFang SC";
		ctx.setFontSize(toPx(8));
		ctx.fillText("长按扫描二维码,查看详情", toPx(14), toPx(430), toPx(432));

		// 渲染
		// 保存本次生成的海报图为缓存
		ctx.draw(false, () => {
			uni.canvasToTempFilePath({
				canvasId: "positionPoster",
				width: canvasWidth,
				height: canvasHeight,
				destWidth: canvasWidth,
				destHeight: canvasHeight,
				success: function (res) {
					lastGeneratedImage.value = res.tempFilePath; // 缓存图片
					canvas_finished.value = false;
					showPosterContent.value = true;
					uni.showToast({
						title: "绘制成功",
					});
				},
				fail: function (error) {
					uni.showToast({
						title: "绘制失败",
					});
				},
				complete: function complete() {
					uni.hideLoading();
					uni.hideToast();
				},
			});
		});
	}

	//保存海报
	let isSaving = false; // 用于标记当前操作是否正在进行

	function savePosterByQR() {
		// 如果正在保存,直接返回,不执行后续操作
		if (isSaving) {
			return;
		}

		isSaving = true; // 设置标记,表示正在保存操作

		// 如果已缓存海报,直接返回
		if (lastGeneratedImage.value) {
			saveToUser(lastGeneratedImage.value);
		} else {
			isSaving = false; // 结束操作,重置标记
		}

		function saveToUser(tempFilePath) {
			// 画板路径保存成功后,调用方法把图片保存到用户相册
			uni.saveImageToPhotosAlbum({
				filePath: tempFilePath,
				success: (res) => {
					showPoster.value = false;
					uni.showToast({
						title: "保存成功",
						icon: "success",
					});
					isSaving = false; // 操作完成,重置标记
				},
				fail: (err) => {
					uni.showToast({
						title: "保存失败",
						icon: "fail",
					});
					showPoster.value = false;
					isSaving = false; // 操作完成,重置标记
				},
			});
		}
	}
	//隐藏海报
	function cancelPosterContent() {
		showPoster.value = false;
		showPosterContent.value = false;
	}

	return {
		showPoster,
		canvas_finished,
		showPosterContent,
		dpr,
		lastGeneratedImage,
		savePosterByQR,
		createPoster,
		cancelPosterContent,
	};
}
//多行文字绘制
/**
 * 将字符串格式化为适合在画布上显示的格式
 *
 * @param ctx 画布上下文
 * @param str 需要格式化的字符串
 * @param axisX 文本在画布上的起始x坐标
 * @param axisY 文本在画布上的起始y坐标
 * @param titleHeight 每行文本的高度
 * @param maxWidth 每行文本的最大宽度
 */
//多行文本
export function toFormateStr(ctx, str, axisX, axisY, titleHeight, maxWidth) {
	// 文本处理
	let strArr = str.split("");
	let row = [];
	let temp = "";
	for (let i = 0; i < strArr.length; i++) {
		if (ctx.measureText(temp).width < maxWidth) {
			temp += strArr[i];
		} else {
			i--; //这里添加了i-- 是为了防止字符丢失,效果图中有对比
			row.push(temp);
			temp = "";
		}
	}
	row.push(temp); // row有多少项则就有多少行

	//如果数组长度大于2,现在只需要显示两行则只截取前两项,把第二行结尾设置成'...'
	if (row.length > 2) {
		let rowCut = row.slice(0, 2);
		let rowPart = rowCut[1];
		let test = "";
		let empty = [];
		for (let i = 0; i < rowPart.length; i++) {
			if (ctx.measureText(test).width < maxWidth) {
				test += rowPart[i];
			} else {
				break;
			}
		}
		empty.push(test);
		let group = empty[0] + "..."; //这里只显示两行,超出的用...表示
		rowCut.splice(1, 1, group);
		row = rowCut;
	}
	// 把文本绘制到画布中
	for (let i = 0; i < row.length; i++) {
		// 一次渲染一行
		ctx.fillText(row[i], axisX, axisY + i * titleHeight, maxWidth);
	}
	// // 保存当前画布状态
	// ctx.save();
	// // 将之前在绘图上下文中的描述(路径、变形、样式)画到 canvas 中。
	// ctx.draw();
}
//绘制图片
export function drawImageWithCache(context, imgUrl, x, y, width, height) {
	return new Promise((resolve, reject) => {
		uni.getImageInfo({
			src: imgUrl,
			success: function (res) {
				context.drawImage(res.path, x, y, width, height);
				resolve();
			},
			fail: function (err) {
				console.error("图片加载失败:", err);
				reject(err);
			},
		});
	});
}
//绘制圆角矩形
export function drawRoundedRect(
	ctx,
	x,
	y,
	width,
	height,
	r1,
	r2 = r1,
	r3 = r1,
	r4 = r1
) {
	ctx.beginPath();
	// 左上角
	ctx.arc(x + r1, y + r1, r1, Math.PI, Math.PI * 1.5);
	// 上边线
	ctx.lineTo(x + width - r2, y);
	ctx.arc(x + width - r2, y + r2, r2, Math.PI * 1.5, Math.PI * 2);
	// 右边线
	ctx.lineTo(x + width, y + height - r3);
	ctx.arc(x + width - r3, y + height - r3, r3, 0, Math.PI * 0.5);
	// 下边线
	ctx.lineTo(x + r4, y + height);
	ctx.arc(x + r4, y + height - r4, r4, Math.PI * 0.5, Math.PI);
	// 左边线
	ctx.lineTo(x, y + r1);
	ctx.arc(x + r1, y + r1, r1, Math.PI, Math.PI * 1.5);
	ctx.closePath(); // 闭合路径
	ctx.fill(); // 填充路径
}
//多行tag
export async function drawImagesInRow(ctx, houseData) {
	
	// 设置起始位置和间距
	let currentX = toPx(26); // 起始位置,x 坐标
	const yPosition = toPx(312); // 图片绘制的 y 坐标
	const spacing = toPx(4); // 图片之间的间距

	// 遍历每个图像 URL,依次绘制图片
	for (let i = 0; i < imageUrls.length; i++) {
		const image = imageUrls[i];

		// 异步加载并绘制图片
		await drawImageWithCache(
			ctx,
			image.src,
			currentX,
			yPosition,
			image.width,
			image.height
		);

		// 更新当前的 x 坐标,图像绘制完成后将 x 坐标向右移动,考虑到间距
		currentX += image.width + spacing;
	}
}
//单位转换
export function toPx(px) {
	const devicePi = uni.getWindowInfo().pixelRatio;
	const screenWidth = uni.getWindowInfo().screenWidth;
	return (px / 375) * screenWidth;
}

海报展示: