写在前面
功能需求完成一段时间了。特此记录一下。
需求
就是生成一张海报
想法
通过ScrollView作为外壳,将所需的节点信息写在Scroll内。通过小程序的API来获取滚动容器内部滚动高度。以达到生成长图的目的。
简单的说,就是将ScrollView作为一个容器,Canvas根据屏幕的DPI来确定保证生成图片清晰
坑
- 初始时使用Canvas 2D的方式生成图片。发现生成图片的最大高度不能超过4096不满足产品生成长图的需求故放弃
- 图片模糊问题。由于用户上传的图片大小不一致。所以动态计算canvas的大小
- 尽可能的优化图片的大小。画图所需要的路径必须调用getImageInfo 来获取。本地临时路径会占用大量的内存空间
- 画布画完需要调用save 函数,虽然文档未说明。但是不调用save 函数会导致背景图变为透明。画图存在异常
- 小程序读取本地文件会将png格式的图片转变给jp透明图片会添加白色背景,解决方案:使用webView,H5原生读取文件。绕过小程序读取文件。
- Android系统画图片不能删除本地图片缓存。不然画不上,可以做数组收集缓存路径,在生成图片保存后遍历删除。ios 系统可以先画画布上,删除缓存也没有问题。
- 小程序部分Android系统中如果画图存在小数点,会导致画图异常,具体表现为奇形怪状的不规则图形。
方法与思路
第一步 确定ScrollView容器的具体大小
/**
* params :
* el : 元素节点选择器使用
* $scope: this指向,元素所在的上下文,
* */
// 获取滚动视图的节点信息
export const getScrollViewInfo = ({ el, $scope }) => {
return new Promise(resolve => {
if (el) {
const query = Taro.createSelectorQuery().in($scope)
query
.select(el)
.fields({ scrollOffset: true }, res => {
resolve(res)
})
.exec()
} else {
resolve({ scrollHeight: 0, scrollLeft: 0, scrollTop: 0, scrollWidth: 0 })
}
})
}
第二步 确定真实Canvas 画图的尺寸
// CANVAS_SCALE_MULTIPLE 为常量。根据屏幕的DPI来确定
/**
* params :
* width : 宽度 需要画的 容器宽度 即第一步获取的ScrollView的宽度
* height : 高度 需要画的 容器高度 即第一步获取的ScrollView的高度
* */
// 计算画布尺寸
export const computeCanvasSize = ({ width, height }) => {
return new Promise(resolve => {
const _width = width * CANVAS_SCALE_MULTIPLE
const _height = height * CANVAS_SCALE_MULTIPLE
resolve({
canvasWidth: _width,
canvasHeight: _height
})
})
}
第三步 将ScrollView内的元素画在Canvas上
// 获取选择器的元素信息
// 由于Canvas 和真实的ScrollView 的比例为CANVAS_SCALE_MULTIPLE 所以画图的时间要将所有的元素位置放大 CANVAS_SCALE_MULTIPLE倍 画在Canvas 上
//具体所有方法代码如下:
// 工具方法
// 获取选择器的元素信息
export const getElementInfo = ({ el, $scope }) => {
return new Promise(resolve => {
if (el) {
const query = Taro.createSelectorQuery().in($scope)
query
.selectAll(el)
.boundingClientRect(rect => {
resolve(rect)
})
.exec()
} else {
resolve([{ left: 0, top: 0, width: 0, height: 0 }])
方法 截取Canvas内部的一段区域进行画图
// 方法 截取Canvas内部的一段区域进行画图
export function drawClipRect(imageWidth, imageHeight, wrapWidth, wrapHeight) {
let resultLeft, resultTop, resultWidth, resultHeight
resultHeight = Math.floor(imageWidth * (wrapHeight / wrapWidth))
if (resultHeight > imageHeight) {
resultHeight = imageHeight
resultWidth = Math.floor(resultHeight * (wrapWidth / wrapHeight))
resultLeft = (imageWidth - resultWidth) / 2
resultTop = 0
} else {
resultLeft = 0
resultTop = (imageHeight - resultHeight) / 2
resultWidth = imageWidth
}
const result = {
left: resultLeft,
top: resultTop,
width: resultWidth,
height: resultHeight
}
return result
}
画ScrollView的背景即画布的背景
/**
* params :
* ctx : 画布,
* canvasWidth : 画布的 宽度,
* canvasHeight: 画布的 高度,
* bgColor: 背景颜色
* */
export const drawPosterBg = ({ ctx, canvasWidth, canvasHeight, bgColor = '#ffffff' }) => {
return new Promise(resolve => {
ctx.fillStyle = bgColor
ctx.fillRect(0, 0, canvasWidth, canvasHeight) // 填充整个画布
ctx.restore()
ctx.save()
resolve()
})
}
小坑——ctx.arcTo
ctx.arcTo在安卓手机上画圆角会不兼容。因此改用ctx.arc方法
画单张图片
/**
* params :
* ctx : 画布,
* photoInfo : 图片信息
* $scope: this指向,元素所在的上下文,
* rectInfo:{ } 对象。需要画的 元素的位置
* {bottom: 317,dataset: {},height: 218,id: "",left: 24,right: 351,top: 99,width: 327}
* offsetLeft: 左边修正偏移量 0
* offsetTop: 顶部修正偏移量 0
* scrollTop: 滚动容器顶部距离 0
* circle: 画圆 false
* borderRadius: 画圆角 一个数字 或者 一个数组 [left_top,right_top,right_bottom,left_bottom] 从左上角开始的逆时针4个角度 和css 圆角一致
* borderWidth: 边框宽度 1
* borderColor : 边框颜色 transparent,
* cropType: 裁剪类型 居中裁剪center
* */
// 画海报图片
export const drawPosterImage = async (config, $scope) => {
const {
ctx,
offsetTop = 0,
offsetLeft = 0,
scrollTop = 0,
rectInfo,
photoInfo,
circle = false,
borderRadius = 0,
borderWidth = 1,
borderColor = 'transparent',
cropType = ''
} = config
return new Promise(async resolve => {
if (photoInfo.path) {
// 容器的节点信息
const { left, top, width, height } = rectInfo
// 左边修正
let _left = left - offsetLeft
// 顶部修正
let _top = top + scrollTop - offsetTop
// 获取图片信息
const img = await Taro.getImageInfo({
src: photoInfo.path
})
if (borderColor) {
ctx.setStrokeStyle(borderColor)
} else {
ctx.setStrokeStyle('transparent')
}
if (borderWidth) {
ctx.setLineWidth(borderWidth)
}
// 画圆
if (circle) {
const radius = width / 2 //圆的半径
ctx.save()
ctx.beginPath()
ctx.arc(
(_left + radius) * CANVAS_SCALE_MULTIPLE,
(_top + radius) * CANVAS_SCALE_MULTIPLE,
radius * CANVAS_SCALE_MULTIPLE,
0,
Math.PI * 2,
false
)
ctx.clip()
}
//画圆角
if (borderRadius) {
let _copyRadiusArr = null
// 判断传过来是否是数组。数组的话 自定义圆角
if (isArray(borderRadius)) {
_copyRadiusArr = borderRadius
} else {
_copyRadiusArr = new Array(4).fill(borderRadius)
}
const [left_top, right_top, right_bottom, left_bottom] = _copyRadiusArr
ctx.save()
ctx.translate(_left * CANVAS_SCALE_MULTIPLE, _top * CANVAS_SCALE_MULTIPLE)
ctx.beginPath()
const resultWidth = width * CANVAS_SCALE_MULTIPLE
const resultHeight = height * CANVAS_SCALE_MULTIPLE
const _right_bottom_radius = right_bottom * CANVAS_SCALE_MULTIPLE
const _left_bottom_radius = left_bottom * CANVAS_SCALE_MULTIPLE
const _left_top_radius = left_top * CANVAS_SCALE_MULTIPLE
const _right_top_radius = right_top * CANVAS_SCALE_MULTIPLE
//从右下角顺时针绘制,弧度从0到1/2PI
ctx.arc(
resultWidth - _right_bottom_radius,
resultHeight - _right_bottom_radius,
_right_bottom_radius,
0,
Math.PI / 2
)
//矩形下边线
ctx.lineTo(_right_bottom_radius, resultHeight)
//左下角圆弧,弧度从1/2PI到PI
ctx.arc(
_left_bottom_radius,
resultHeight - _left_bottom_radius,
_left_bottom_radius,
Math.PI / 2,
Math.PI
)
//矩形左边线
ctx.lineTo(0, _left_bottom_radius)
//左上角圆弧,弧度从PI到3/2PI
ctx.arc(_left_top_radius, _left_top_radius, _left_top_radius, Math.PI, (Math.PI * 3) / 2)
//上边线
ctx.lineTo(resultWidth - _left_top_radius, 0)
//右上角圆弧
ctx.arc(
resultWidth - _right_top_radius,
_right_top_radius,
_right_top_radius,
(Math.PI * 3) / 2,
Math.PI * 2
)
//右边线
ctx.lineTo(resultWidth, resultHeight - _right_top_radius)
ctx.closePath()
ctx.clip()
_left = 0
_top = 0
}
let cropLeft = 0
let cropTop = 0
let cropWidth = img.width
let cropHeight = img.height
if (cropType == 'center') {
const result = drawClipRect(img.width, img.height, width, height)
cropLeft = result.left
cropTop = result.top
cropWidth = result.width
cropHeight = result.height
}
await ctx.drawImage(
img.path,
cropLeft,
cropTop,
Math.floor(cropWidth),
Math.floor(cropHeight),
Math.floor(_left * CANVAS_SCALE_MULTIPLE),
Math.floor(_top * CANVAS_SCALE_MULTIPLE),
Math.floor(width * CANVAS_SCALE_MULTIPLE),
Math.floor(height * CANVAS_SCALE_MULTIPLE)
)
if (borderWidth) {
ctx.stroke()
}
ctx.restore()
// 延时画下个图片
cacheFilePath.push(img.path)
setTimeout(() => {
resolve()
}, 30)
}
})
}}
})
}
小坑——emoji
由于emoji表情是4个字节储存。ctx.measureText在计算宽度时可能会把emoji表情拆开导致绘emoji表情失败,所以遍历字符串方法上 用了for of 循环
获取需要画的文字数组
/**
* @params
* ctx 指定的画布,
* text 要处理的文字
* limitWidth 限制宽度
* fontSize 文字大小
* fontFamily 字体
* maxRows 最大几行
*
* */
// 获取需要画的文字数组
export const getCanvasWrapTextList = async config => {
return new Promise(resolve => {
const { ctx, text, limitWidth, maxRows } = config
if (!text) return []
let textList = []
let computedText = ''
for (let str of text) {
if (ctx.measureText(computedText).width < limitWidth * CANVAS_SCALE_MULTIPLE) {
computedText += str
} else {
textList.push(computedText)
computedText = str
}
}
textList.push(computedText)
if (maxRows < textList.length && maxRows >= 1) {
const resultText = textList[maxRows - 1]
if (resultText.length > 1) {
textList[maxRows - 1] = resultText.slice(0, resultText.length - 1) + '...'
}
}
resolve(textList.slice(0, maxRows))
})
}
小坑——ctx.setTextAlign
ctx.setTextAlign设置居中方法不成功所以可以自己手动设置
画文字
export const drawCanvasPosterText = async config => {
const {
ctx,
text,
offsetTop = 0,
offsetLeft = 0,
scrollTop = 0,
rectInfo,
color = '#000',
fontFamily,
fontSize = 12,
textAlign = 'left',
textBaseLine = 'middle',
lineHeight,
maxRows = 1
} = config
if (text) {
// 容器的节点信息
const { left, top, width } = rectInfo
// 左边修正
const _left = left - offsetLeft
// 顶部修正
const _top = top + scrollTop - offsetTop
// 画图水平方向修正
let _drawLeft = 0
// 画图垂直方向修正
let _drawTop = 0
// 设置颜色
ctx.setFillStyle(color)
// 设置文字字号
ctx.setFontSize(fontSize * CANVAS_SCALE_MULTIPLE)
// 设置文字水平方式
ctx.setTextAlign(textAlign)
if (textAlign === 'center') {
_drawLeft = _left + width / 2
} else if (textAlign === 'right') {
_drawLeft = _left + width
} else {
_drawLeft = _left
}
_drawLeft = Math.floor(_drawLeft)
// 设置文字垂直方式
ctx.setTextBaseline(textBaseLine)
// const { ctx, text, limitWidth, maxRows } = config
const textList = await getCanvasWrapTextList({
ctx,
text,
limitWidth: width,
maxRows
})
let _lineHeight = 0
if (lineHeight) {
_lineHeight = lineHeight
} else {
_lineHeight = fontSize * 1.5
}
for (let j = 0; j < textList.length; j++) {
_drawTop = _top + (j + 0.5) * _lineHeight
ctx.fillText(textList[j], _drawLeft * CANVAS_SCALE_MULTIPLE, _drawTop * CANVAS_SCALE_MULTIPLE)
}
}
}
整合函数。为了方便画图片,相同class类的图片直接调用该方法
/**
* params :
* ctx : 画布,
* canvas:
* scrollTop,
* photo, 接收一个数组或一个图片路径 [{
* path:"",
* width:"",
* height,
* }] 或者 "path"
* return:
* 整合函数。为了方便画图片
*
* */
export const exportDrawImage = async config => {
const { el, $scope, photo, ...rest } = config || {}
const elementArr = await getElementInfo({
el,
$scope
})
let _photo = null
if (isArray(photo)) {
_photo = photo
} else {
_photo = new Array(elementArr.length).fill({
path: photo
})
}
try {
for (let i = 0; i < elementArr.length; i++) {
await drawPosterImage(
{
rectInfo: elementArr[i],
photoInfo: _photo[i],
...rest
},
$scope
)
}
} catch (e) {
console.log(e)
}
}
整合函数。为了方便画文字
/**
* params :
* ctx : 画布,
* canvas:
* scrollTop,
* texts,
* return:
* 整合函数。为了方便画文字
*
* */
export const exportDrawText = async config => {
const { el, $scope, text, ...rest } = config || {}
const elementArr = await getElementInfo({
el,
$scope
})
let _texts = null
if (isArray(text)) {
_texts = text
} else {
_texts = [text]
}
try {
for (let i = 0; i < elementArr.length; i++) {
await drawCanvasPosterText(
{
rectInfo: elementArr[i],
text: _texts[i],
...rest
},
$scope
)
}
} catch (e) {
console.log(e)
}
}
整合 方法 在图片上开辟出来新的空间
export const exportDrawView = async config => {
const { el, $scope, ...rest } = config || {}
const elementArr = await getElementInfo({
el,
$scope
})
try {
for (let i = 0; i < elementArr.length; i++) {
await drawCanvasPosterView(
{
rectInfo: elementArr[i],
...rest
},
$scope
)
}
} catch (e) {
console.log(e)
}
}
画圆角矩形
export async function drawCanvasView({ ctx, width, height, radius }) {
ctx.beginPath()
//从右下角顺时针绘制,弧度从0到1/2PI
ctx.arc(width - radius, height - radius, radius, 0, Math.PI / 2)
//矩形下边线
ctx.lineTo(radius, height)
//左下角圆弧,弧度从1/2PI到PI
ctx.arc(radius, height - radius, radius, Math.PI / 2, Math.PI)
//矩形左边线
ctx.lineTo(0, radius)
//左上角圆弧,弧度从PI到3/2PI
ctx.arc(radius, radius, radius, Math.PI, (Math.PI * 3) / 2)
//上边线
ctx.lineTo(width - radius, 0)
//右上角圆弧
ctx.arc(width - radius, radius, radius, (Math.PI * 3) / 2, Math.PI * 2)
//右边线
ctx.lineTo(width, height - radius)
ctx.closePath()
}
绘制圆角矩形的各个边
export const drawCanvasPosterView = async config => {
//圆的直径必然要小于矩形的宽高
const {
rectInfo,
radius = 0,
scrollTop,
offsetTop,
offsetLeft,
ctx,
fillColor,
borderColor,
borderWidth
} = config
// 容器的节点信息
const { left, top, width, height } = rectInfo
if (2 * radius > width || 2 * radius > height) {
return false
}
// 左边修正
const _left = left - offsetLeft
// 顶部修正
const _top = top + scrollTop - offsetTop
ctx.save()
if (borderColor) {
ctx.setStrokeStyle(borderColor)
} else {
ctx.setStrokeStyle('transparent')
}
if (borderWidth) {
ctx.setLineWidth(borderWidth)
}
ctx.translate(_left * CANVAS_SCALE_MULTIPLE, _top * CANVAS_SCALE_MULTIPLE)
//绘制圆角矩形的各个边
await drawCanvasView({
ctx,
width: width * CANVAS_SCALE_MULTIPLE,
height: height * CANVAS_SCALE_MULTIPLE,
radius: radius * CANVAS_SCALE_MULTIPLE
})
if (fillColor) {
ctx.fillStyle = fillColor
ctx.fill()
}
if (borderWidth) {
ctx.stroke()
}
ctx.restore()
}
第四步 将画好的Canvas保存成临时路径
/**
* @params:
* x: 指定的画布区域的左上角横坐标
* y : 指定的画布区域的左上角纵坐标,
* canvasWidth : 指定的画布区域的宽度
* canvasHeight: 指定的画布区域的高度
* destWidth: 输出的图片的宽度
* destHeight:输出的图片的高度
* customOutput // Boolean 自定义输出 指定自定义输出时 务必指定输出高度
* 默认输出为canvas 的整个画布,如需自定义 需设置
*
* */
// 将canvas生成本地临时图片
export const canvasToTempImageAndSave = async params => {
const {
$scope,
width,
height,
destWidth = 750,
destHeight = 0,
customOutput,
completeCb,
isSaveAlbum = true,
completeBlock,
...rest
} = params
let _destHeight = (destWidth * height) / width
//如果自定义输出
if (customOutput) {
_destHeight = destHeight
}
Taro.canvasToTempFilePath(
{
x: 0,
y: 0,
width,
height,
destWidth,
destHeight: _destHeight,
canvasId: 'posterCanvas',
...rest,
success: async res => {
const filePath = res.tempFilePath
console.log(filePath)
if (!isSaveAlbum) {
if (completeBlock) {
console.log(filePath)
completeBlock({
tempFilePath: filePath
})
}
return
}
console.log(filePath)
const fileSize = await getTempImageSize({
filePath
})
if (fileSize < 100 * 1024) {
Taro.showToast({
title: '绘制失败请重新绘制',
icon: 'none'
})
return false
}
saveTempImageInAlbum({
filePath: res.tempFilePath,
completeCb,
...rest
})
},
fail: res => {
Taro.showToast({
title: '生成照片失败',
icon: 'none'
})
console.log(res)
if (completeBlock) {
completeBlock({
tempFilePath: ''
})
}
completeCb && completeCb()
// todo 保存到相册失败函数
},
complete: res => {
console.log(res)
}
},
$scope
)
}
第五步 将本地临时路径相片保存到相册
// 将本地临时路径相片保存到相册
export const saveTempImageInAlbum = params => {
const { filePath, success, fail, completeCb } = params
cacheFilePath.push(filePath)
Taro.saveImageToPhotosAlbum({
filePath,
success: () => {
// 保存成功提示
Taro.showToast({
title: '保存图片成功,请到相册查看',
icon: 'none'
})
success && success()
},
fail: err => {
fail && fail()
if (err.errMsg === 'saveImageToPhotosAlbum:fail auth deny') {
Taro.showModal({
title: '提示',
content: '需要获取访问相册权限',
showCancel: false,
success: resp => {
if (resp.confirm) {
Taro.openSetting({
success: setting => {
if (setting.authSetting['scope.writePhotosAlbum']) {
Taro.showToast({
title: '授权成功,再次点击可保存图片',
icon: 'none'
})
} else {
Taro.showToast({
title: '授权失败,请重新操作',
icon: 'none'
})
}
}
})
}
}
})
}
},
complete() {
// 延时50ms 画下个图片。预防图片画失败
setTimeout(() => {
completeCb && completeCb()
}, 50)
delTempImageArr({
filePathArr: cacheFilePath
})
}
})
}
优化项
由于生成Canvas画图片必须要生成临时路径。对性能较差和内存较小的手机不友好故做了临时文件删除策略
小坑:对于安卓系统不能将图片画到Canvas上就删除本地的缓存路径的,不然会保存到本地会失败。是一张空白图。故要在第五步后删除。
/**
* @params
* filePath: 本地图片临时路径
* */
// 删除本地临时路径
export const delTempImageArr = ({ filePathArr }) => {
const fileMgr = Taro.getFileSystemManager()
for (let i = 0; i < filePathArr.length; i++) {
console.log('删除的临时路径', filePathArr[i])
fileMgr.unlink({
filePath: filePathArr[i]
})
cacheFilePath = []
}
}
常见问题FAQ
- 免费下载或者VIP会员专享资源能否直接商用?
- 本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
- 提示下载完但解压或打开不了?
- 找不到素材资源介绍文章里的示例图片?
- 模板不会安装或需要功能定制以及二次开发?
发表评论
还没有评论,快来抢沙发吧!