canvas导出-小程序地图自定义marker通用解决方案
约 2877 字大约 10 分钟
2026-05-03
一、背景:小程序地图自定义 Marker 的痛点与解决方案
抖音小程序的地图 Marker 不支持直接通过组件属性实现复杂样式自定义,仅能配置简单的 Icon 图片;微信小程序虽支持 customCallout 实现部分自定义,但配置项繁琐且与其他平台不兼容;支付宝小程序的自定义逻辑则采用了完全不同的 API 设计。这一现状直接导致:若要实现跨端兼容的自定义 Marker,需为三个平台分别编写 3 套独立代码,不仅开发效率低下,后续迭代维护的成本也会成倍增加。
针对这一痛点,一套更优的通用解决方案应运而生:通过 Canvas 绘制自定义 Marker 的完整视觉样式,导出为高清 PNG 图片,将该图片直接赋值给三大小程序地图 Marker 的 Icon 属性。此方案的核心优势在于 「一次开发,三端通用」
二、自定义 Marker 的核心组成与整体设计
举个例子,比如这种格式,一个白色盒子包裹,有两行文字,第二行文字中部分文字颜色不一致
0. 配置
config: {
pixelRatio: 3,
textGap: 4, // 两个文本的间距
shadow: {
color: 'rgba(0, 0, 0, 0.1)', // 阴影颜色(半透明)
blur: 6, // 阴影模糊度
offsetX: 2, // 阴影水平偏移
offsetY: 2, // 阴影垂直偏移
},
}
boxConfig: {
topText: {
text: this.start.name,
fontSize: 12,
color: '#222222',
isBold: true,
},
bottomTextArr: [
{ text: '预计', color: '#222222', isBold: false },
{ text: '3分钟', color: '#ff0000', isBold: false },
{ text: '上车', color: '#222222', isBold: false },
],
bottomTextFontSize: 10,
padding: 12,
borderRadius: 8,
bgColor: '#ffffff',
}1. 计算盒子宽高
const { textGap, pixelRatio, shadow } = this.config;
const { topText, bottomTextArr, bottomTextFontSize, padding, borderRadius, bgColor } = boxConfig;
const ctx = this.ctx;
// 1. 测量文本宽度(取上下文本最大宽,确保盒子能包裹)
// 测量上行文本
ctx.font = `bold ${topText.fontSize}px sans-serif`;
const topTextWidth = ctx.measureText(topText.text).width;
// 测量下行文本总宽(多个片段累加)
ctx.font = `${bottomTextFontSize}px sans-serif`;
const bottomTextTotalWidth = bottomTextArr.reduce((total, item) => {
return total + ctx.measureText(item.text).width;
}, 0);
const maxTextWidth = Math.max(topTextWidth, bottomTextTotalWidth);
// 2. 计算盒子视觉尺寸(未乘像素比)
const boxWidth = maxTextWidth + 2 * padding;
const boxHeight = topText.fontSize + textGap + bottomTextFontSize + 2 * padding;- 第一步:先测量文本宽度(确定盒子的核心宽度边界)
- 上行文本:通过
ctx.measureText()获取单行加粗文本的实际渲染宽度(topTextWidth),字体配置与后续绘制保持一致(避免宽度偏差)。 - 下行文本:因为是分段变色文本,通过
reduce()累加分段文本的宽度,得到下行文本总宽度(bottomTextTotalWidth)。 - 取最大值
maxTextWidth:确保盒子宽度能同时包裹上行文本和下行文本,避免某一行文本超出盒子边界(比如上行文本短、下行文本长时,以行为本为准)。
- 上行文本:通过
- 第二步:计算盒子视觉宽度(肉眼感知的宽度,未做高清适配)
- 公式:
boxWidth = maxTextWidth + 2 * padding - 解释:
maxTextWidth是文本的实际宽度,2 * padding是左右两侧的内边距(左侧padding+右侧padding),保证文本与盒子边缘有一定间距,提升视觉美观度。
- 公式:
- 第三步:计算盒子视觉高度(肉眼感知的高度,未做高清适配)
- 公式:
boxHeight = topText.fontSize + textGap + bottomTextFontSize + 2 * padding - 拆解解释:
topText.fontSize:上行文本的字号(Canvas 中文本高度与字号基本一致,作为上行文本的高度占用)。textGap:上行文本与下行文本之间的间距,避免两行文本重叠。bottomTextFontSize:下行文本的字号,作为下行文本的高度占用。2 * padding:上下两侧的内边距(顶部padding+底部padding),保证文本与盒子上下边缘有间距。
- 公式:
2. 计算尺寸绘制盒子
// 3. 计算高清适配后的尺寸(调用公共方法)
let { canvasWidth, canvasHeight, boxX, boxY, realBoxWidth, realBoxHeight } = this.calcBaseSize(
boxWidth,
boxHeight,
shadow // 盒子2显示阴影
);
// Canvas 高度额外增加 30px(视觉尺寸),适配高清需乘像素比
const transparentGap = 36; // 下方透明空间(视觉px)
canvasHeight += transparentGap * pixelRatio; // 只加高度,宽度不变
this.canvasWidth = canvasWidth;
this.canvasHeight = canvasHeight;
await commonUtils.sleep(10);
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
ctx.scale(pixelRatio, pixelRatio); // 高清缩放
// 5. 绘制盒子(调用公共圆角方法)
const drawX = boxX / pixelRatio;
const drawY = boxY / pixelRatio;
const drawW = boxWidth;
const drawH = boxHeight;
// 画阴影+圆角盒子
ctx.shadowColor = shadow.color;
ctx.shadowBlur = shadow.blur;
ctx.shadowOffsetX = shadow.offsetX;
ctx.shadowOffsetY = shadow.offsetY;
ctx.fillStyle = bgColor;
this.drawRoundedRect(ctx, drawX, drawY, drawW, drawH, borderRadius);
ctx.fill();/**
* 3. 计算基础尺寸(适配高清,公共)
* @param {number} boxWidth - 盒子视觉宽度
* @param {number} boxHeight - 盒子视觉高度
* @param {Object} shadowConfig - 阴影配置
* @returns {Object} canvasWidth, canvasHeight, boxX, boxY(实际像素尺寸)
*/
calcBaseSize(boxWidth, boxHeight, shadowConfig) {
const { pixelRatio, shadow } = this.config;
// Canvas 尺寸:带阴影则预留阴影空间,无阴影则与盒子等大
let canvasWidth = boxWidth;
let canvasHeight = boxHeight;
let boxX = 0;
let boxY = 0;
if (shadowConfig) {
canvasWidth = boxWidth + shadowConfig.blur + shadowConfig.offsetX;
canvasHeight = boxHeight + shadowConfig.blur + shadowConfig.offsetY;
boxX = shadowConfig.blur * pixelRatio; // 盒子位置偏移(预留阴影空间)
boxY = shadowConfig.blur * pixelRatio;
}
// 乘以像素比,适配高清
return {
canvasWidth: canvasWidth * pixelRatio,
canvasHeight: canvasHeight * pixelRatio,
boxX,
boxY,
realBoxWidth: boxWidth * pixelRatio,
realBoxHeight: boxHeight * pixelRatio,
};
},// —————— 公共工具函数(两个盒子共用,抽离出来)——————
/**
* 1. 绘制圆角矩形(公共)
* @param {Object} ctx - Canvas 2d上下文
* @param {number} x - 起始x坐标
* @param {number} y - 起始y坐标
* @param {number} w - 宽度
* @param {number} h - 高度
* @param {number} radius - 圆角半径
*/
drawRoundedRect(ctx, x, y, w, h, radius) {
ctx.beginPath();
ctx.arc(x + radius, y + radius, radius, Math.PI, Math.PI * 1.5);
ctx.arc(x + w - radius, y + radius, radius, Math.PI * 1.5, Math.PI * 2);
ctx.arc(x + w - radius, y + h - radius, radius, 0, Math.PI * 0.5);
ctx.arc(x + radius, y + h - radius, radius, Math.PI * 0.5, Math.PI);
ctx.closePath();
},pixelRatio
pixelRatio 即设备像素比,是核心的高清适配参数,具体作用和原理如下:
- 核心定义:设备物理像素(屏幕实际的像素点)与逻辑像素(视觉像素/代码中定义的像素,如
12px字号、36px空白)的比值。- 举例:高清屏(Retina 屏)的
pixelRatio通常为 2 或 3,意味着 1 个逻辑像素(代码中写的1px),对应屏幕上 2×2 或 3×3 个物理像素点。
- 举例:高清屏(Retina 屏)的
- 解决的核心问题:避免 Canvas 导出图片模糊。
- 如果不考虑
pixelRatio,直接按逻辑像素绘制 Canvas,高清屏会用多个物理像素点渲染一个逻辑像素,导致图片边缘模糊、细节丢失。
- 如果不考虑
- 在本代码中的具体作用:
- 放大 Canvas 物理尺寸:
canvasWidth = 视觉宽度 × pixelRatio,让 Canvas 拥有更多的像素点(填充高清屏的物理像素)。 - 上下文缩放:
ctx.scale(pixelRatio, pixelRatio),让所有绘制元素(盒子、文本、阴影)自动按比例放大,无需手动修改绘制尺寸。 - 修正绘制坐标:
drawX = boxX / pixelRatio,因为 Canvas 物理尺寸被放大,需要将偏移坐标还原为逻辑像素,保证元素绘制位置准确。 - 阴影适配:
boxX = shadowConfig.blur * pixelRatio,让阴影也能高清渲染,避免阴影模糊。
- 放大 Canvas 物理尺寸:
transparentGap
transparentGap 即透明空白区域高度(视觉尺寸),是专门为地图 Marker 适配设计的辅助参数,具体作用和原理如下:
- 核心定义:在 Marker 核心内容(白色盒子+文本)的下方(或上方)预留的纯透明区域高度,仅占用 Canvas 尺寸,不绘制任何可见内容。
- 解决的核心问题:适配地图 Marker 的偏移逻辑,提升视觉体验。
- 地图 Marker 的锚点通常在图片的底部中心,若直接导出核心内容图片,Marker 会紧贴地图道路、其他标注,导致视觉重叠、信息遮挡。
- 预留透明空白后,地图 Marker 的锚点会落在透明区域的底部,核心内容(白色盒子)会悬浮在地图上方,避免与地图元素重叠。
- 在本代码中的具体作用:
- 扩容 Canvas 高度:
canvasHeight += transparentGap * pixelRatio,仅增加高度(宽度不变,避免比例失衡),且乘以pixelRatio保证高清适配。 - 实现透明效果:Canvas 中未绘制任何内容的区域天然透明,无需额外填充颜色,导出后的图片会包含「核心内容+透明空白区域」。
- 不影响核心内容:仅扩容 Canvas,不修改核心盒子(白色背景)和文本的绘制尺寸、坐标,保证原有样式不变。
- 扩容 Canvas 高度:
3. 绘制文字
// 6. 绘制文本
ctx.shadowColor = 'transparent'; // 文本取消阴影
// 上行文本(加粗)
ctx.font = `bold ${topText.fontSize}px sans-serif`;
ctx.fillStyle = topText.color;
const topTextY = drawY + padding + topText.fontSize;
ctx.fillText(topText.text, drawX + padding, topTextY);
// 下行文本(部分红色、不加粗,调用公共工具函数)
const bottomTextY = topTextY + textGap + bottomTextFontSize;
this.drawPartColoredText(ctx, drawX + padding, bottomTextY, bottomTextArr, bottomTextFontSize);/**
* 2. 绘制部分变色文本(box2 专用,但作为公共工具函数)
* @param {Object} ctx - 上下文
* @param {number} x - 起始x
* @param {number} y - 基线y
* @param {Array} textArr - 文本片段数组 [{text, color, isBold}]
* @param {number} fontSize - 字号
*/
drawPartColoredText(ctx, x, y, textArr, fontSize) {
let currentX = x;
textArr.forEach((item) => {
ctx.font = `${item.isBold ? 'bold' : ''} ${fontSize}px sans-serif`;
ctx.fillStyle = item.color;
ctx.fillText(item.text, currentX, y);
currentX += ctx.measureText(item.text).width;
});
}部分变色文本怎么绘制的
部分变色文本的核心是分段遍历、逐段绘制、无缝拼接,依托drawPartColoredText工具函数实现,具体步骤如下:
- 步骤 1:初始化绘制起始坐标
- 定义
currentX = x,保存当前文本片段的绘制起始 X 坐标(初始值为传入的文本左侧起始坐标drawX + padding),用于实现多个文本片段的无缝拼接。
- 定义
- 步骤 2:遍历分段文本数组(
bottomTextArr)- 数组中的每个元素对应一个独立样式的文本片段(包含
text内容、color颜色、isBold是否加粗),遍历每个片段进行单独绘制。
- 数组中的每个元素对应一个独立样式的文本片段(包含
- 步骤 3:设置当前片段的文本样式
- 字体配置:
ctx.font =${item.isBold ? 'bold' : ''} ${fontSize}px sans-serif``,根据片段的isBold属性决定是否加粗,保证样式与配置一致。 - 颜色配置:
ctx.fillStyle = item.color,设置当前片段的文本颜色(如「3 分钟」设为#ff0000红色)。
- 字体配置:
- 步骤 4:绘制当前文本片段
- 调用
ctx.fillText(item.text, currentX, y),在当前currentX坐标处绘制文本片段,Y 坐标保持不变(保证单行对齐)。
- 调用
- 步骤 5:更新下一个片段的起始坐标
- 调用
ctx.measureText(item.text).width获取当前文本片段的宽度,累加到currentX上(currentX += 文本宽度)。 - 这样下一个文本片段会从当前片段的右侧无缝衔接绘制,不会出现重叠或间隙,最终形成完整的一行分段变色文本。
- 调用
- 额外补充:文本 Y 坐标对齐逻辑
- 上行文本 Y 坐标:
topTextY = drawY + padding + topText.fontSize,利用 Canvas 文本基线(默认是alphabetic字母基线),保证文本垂直居中于盒子内边距上方。 - 下行文本 Y 坐标:
bottomTextY = topTextY + textGap + bottomTextFontSize,基于上行文本 Y 坐标,加上行间距和下行字号,保证两行文本垂直间距均匀。
- 上行文本 Y 坐标:
4. 绘制导出
ctx.draw(false, () => {
mpx.canvasToTempFilePath({
x: 0,
y: 0,
width: this.canvasWidth,
height: this.canvasHeight,
destWidth: this.canvasWidth,
destHeight: this.canvasHeight,
canvasId: 'myCanvas',
success: res => {
resolve({
src: res.tempFilePath,
width: this.canvasWidth / 3,
height: this.canvasHeight / 3
});
}
});
});ctx.draw(false, callback):Canvas 2d 上下文绘制完成后的回调,false表示不清除原有绘制内容,确保所有元素(盒子、文本)都已绘制完成后再导出。canvasToTempFilePath:小程序将 Canvas 转为临时图片文件的 API,导出的tempFilePath可直接赋值给地图 Marker 的icon属性。- 尺寸还原:
width: this.canvasWidth / 3,因为 Canvas 物理尺寸乘以了pixelRatio=3,还原为视觉尺寸后,地图 Marker 加载图片不会拉伸。