前言
最近开发了一款【苏超排名助手】,里面有一个功能页:足球战术板,主要就是让用户能在球场上画箭头、线条、曲线啥的,方便教练布置战术。本来以为挺简单的,结果各种坑,尤其是要兼容鸿蒙,差点没给我整崩溃。不过最后还是搞定了,记录一下,给后面的兄弟们少踩点坑。
|
|---|
需求是啥
说白了就是这几个功能:
- 在球场上画箭头(传球路线)
- 画直线和曲线(跑位路线)
- 橡皮擦,画错了能擦掉
- 能保存成图片分享
- 还得支持鸿蒙(这个最坑)
Canvas初始化 - 第一个大坑
鸿蒙和其他平台的API不一样
uni-app有两种Canvas API:
- 旧的:
uni.createCanvasContext('canvasId') - 新的:Canvas 2D(type="2d")
鸿蒙现在只支持旧API,所以得这样写:
<!-- #ifdef APP-HARMONY -->
<canvas
canvas-id="tacticsCanvas"
id="tacticsCanvas"
class="canvas-board"
@touchstart="handleCanvasTouchStart"
@touchmove="handleCanvasTouchMove"
@touchend="handleCanvasTouchEnd"
disable-scroll="true"
></canvas>
<!-- #endif -->
<!-- #ifndef APP-HARMONY -->
<canvas
type="2d"
id="tacticsCanvas"
canvas-id="tacticsCanvas"
class="canvas-board"
@touchstart="handleCanvasTouchStart"
@touchmove.prevent="handleCanvasTouchMove"
@touchend="handleCanvasTouchEnd"
></canvas>
<!-- #endif -->
注意几个细节:
- 鸿蒙不用写
type="2d" - 鸿蒙用
disable-scroll="true",其他平台用@touchmove.prevent - 两个平台都得有
canvas-id和id
初始化时机很关键
不能在onMounted里直接初始化,得等页面真正渲染完了再搞。我试了好几次,最后发现延迟个200ms比较靠谱:
onMounted(() => {
// #ifdef APP-HARMONY
setTimeout(() => {
const systemInfo = uni.getSystemInfoSync();
canvasWidth.value = systemInfo.windowWidth;
canvasHeight.value = systemInfo.windowWidth * 1.25;
ctx.value = uni.createCanvasContext("tacticsCanvas");
ctx.value.isCanvas2d = false; // 标记是旧API
// 再延迟获取真实边界
setTimeout(() => {
const query = uni.createSelectorQuery();
query.select(".canvas-board").boundingClientRect((rect) => {
if (rect) {
boardRect.value = rect;
canvasReady.value = true; // 就绪标记
}
}).exec();
}, 100);
}, 200);
// #endif
});
为啥要搞个isCanvas2d标记?因为后面很多API调用方式不一样,得区分开。
触摸绘制 - 坐标转换是重点
坐标系问题
这个坑我踩了好久。触摸事件返回的坐标,在不同平台上格式不一样:
- 鸿蒙:
touch.x/touch.y(可能是相对坐标) - 其他平台:
touch.clientX/touch.clientY(需要减去canvas的偏移)
我写了个通用函数处理:
const getCanvasCoords = (touch) => {
let x, y;
// #ifdef APP-HARMONY
if (touch.x !== undefined && touch.y !== undefined) {
const rawX = touch.x;
const rawY = touch.y;
// 如果在合理范围内,直接用
if (rawX >= 0 && rawX <= canvasWidth.value &&
rawY >= 0 && rawY <= canvasHeight.value) {
x = rawX;
y = rawY;
}
// 超出范围就减去偏移
else if (boardRect.value && rawX > canvasWidth.value) {
x = rawX - boardRect.value.left;
y = rawY - boardRect.value.top;
}
else {
x = rawX;
y = rawY;
}
}
// #endif
// #ifndef APP-HARMONY
if (touch.clientX !== undefined && touch.clientY !== undefined && boardRect.value) {
x = touch.clientX - boardRect.value.left;
y = touch.clientY - boardRect.value.top;
}
// #endif
// 限制在画布范围内
x = Math.max(0, Math.min(x, canvasWidth.value));
y = Math.max(0, Math.min(y, canvasHeight.value));
return { x, y };
};
绘制流程
整个绘制流程是这样的:
-
touchstart:记录起点,初始化当前绘制对象
const handleCanvasTouchStart = (e) => { if (!canvasReady.value) { uni.showToast({ title: "画布初始化中,请稍候", icon: "none" }); return; } const touch = e.touches[0]; const coords = getCanvasCoords(touch); isDrawing.value = true; currentDrawing.value = { type: currentTool.value, // arrow/line/curve startX: coords.x, startY: coords.y, endX: coords.x, endY: coords.y, points: [{ x: coords.x, y: coords.y }], // 曲线用 color: currentTool.value === 'arrow' ? '#ff6b35' : '#1a3b6e' }; }; -
touchmove:更新终点或添加曲线点
const handleCanvasTouchMove = (e) => { if (!isDrawing.value || !currentDrawing.value) return; const touch = e.touches[0]; const coords = getCanvasCoords(touch); if (currentTool.value === 'curve') { // 曲线:不断添加点 currentDrawing.value.points.push({ x: coords.x, y: coords.y }); } else { // 箭头/直线:更新终点 currentDrawing.value.endX = coords.x; currentDrawing.value.endY = coords.y; } redrawCanvas(); // 实时重绘 }; -
touchend:保存到数组
const handleCanvasTouchEnd = () => { if (isDrawing.value && currentDrawing.value) { drawings.value.push({ ...currentDrawing.value }); isDrawing.value = false; currentDrawing.value = null; redrawCanvas(); } };
绘制各种图形
兼容两种API的方法
旧API和新API的方法名不一样,得封装一下:
const drawShape = (shape, context = null) => {
const drawCtx = context || ctx.value;
if (!drawCtx) return;
// 兼容函数
const setStrokeStyle = (color) => {
if (drawCtx.isCanvas2d) {
drawCtx.strokeStyle = color;
} else {
drawCtx.setStrokeStyle(color); // 旧API
}
};
const setLineWidth = (width) => {
if (drawCtx.isCanvas2d) {
drawCtx.lineWidth = width;
} else {
drawCtx.setLineWidth(width);
}
};
const setLineCap = (cap) => {
if (drawCtx.isCanvas2d) {
drawCtx.lineCap = cap;
} else {
drawCtx.setLineCap(cap);
}
};
// ... 其他方法类似
};
绘制箭头
箭头是最常用的,分两部分:线段 + 箭头头部
if (shape.type === 'arrow') {
const endX = shape.endX || shape.startX;
const endY = shape.endY || shape.startY;
// 1. 画线
drawCtx.beginPath();
drawCtx.moveTo(shape.startX, shape.startY);
drawCtx.lineTo(endX, endY);
drawCtx.stroke();
// 2. 画箭头头部(两条边)
const angle = Math.atan2(endY - shape.startY, endX - shape.startX);
const arrowLength = 25;
setLineWidth(5);
drawCtx.beginPath();
drawCtx.moveTo(endX, endY);
// 左边
drawCtx.lineTo(
endX - arrowLength * Math.cos(angle - Math.PI / 6),
endY - arrowLength * Math.sin(angle - Math.PI / 6)
);
drawCtx.moveTo(endX, endY);
// 右边
drawCtx.lineTo(
endX - arrowLength * Math.cos(angle + Math.PI / 6),
endY - arrowLength * Math.sin(angle + Math.PI / 6)
);
drawCtx.stroke();
}
箭头的原理:
- 先算出线的角度:
Math.atan2(dy, dx) - 箭头两边各偏离30度(Math.PI / 6)
- 用三角函数算出两条边的终点坐标
绘制曲线(虚线)
曲线用来表示跑位,所以用虚线:
if (shape.type === 'curve' && shape.points) {
setLineDash([10, 5]); // 虚线:10px实线,5px空白
drawCtx.beginPath();
drawCtx.moveTo(shape.points[0].x, shape.points[0].y);
for (let i = 1; i < shape.points.length; i++) {
drawCtx.lineTo(shape.points[i].x, shape.points[i].y);
}
drawCtx.stroke();
setLineDash([]); // 恢复实线
}
橡皮擦功能 - 点到线段距离算法
橡皮擦要判断点击的位置是不是靠近某条线,这个用到点到线段距离公式:
const pointToLineDistance = (px, py, x1, y1, x2, y2) => {
const A = px - x1;
const B = py - y1;
const C = x2 - x1;
const D = y2 - y1;
const dot = A * C + B * D;
const lenSq = C * C + D * D;
let param = -1;
if (lenSq !== 0) param = dot / lenSq;
let xx, yy;
if (param < 0) {
// 点在线段外侧,靠近起点
xx = x1;
yy = y1;
} else if (param > 1) {
// 点在线段外侧,靠近终点
xx = x2;
yy = y2;
} else {
// 点在线段范围内
xx = x1 + param * C;
yy = y1 + param * D;
}
const dx = px - xx;
const dy = py - yy;
return Math.sqrt(dx * dx + dy * dy);
};
这个算法的核心思路:
- 把点投影到直线上
- 判断投影点是不是在线段范围内
- 算出点到投影点的距离
然后在touchstart判断:
if (currentTool.value === 'eraser') {
const clickX = coords.x;
const clickY = coords.y;
const eraseRadius = 30; // 橡皮擦范围
for (let i = drawings.value.length - 1; i >= 0; i--) {
const drawing = drawings.value[i];
let isNear = false;
if (drawing.type === 'line' || drawing.type === 'arrow') {
const dist = pointToLineDistance(
clickX, clickY,
drawing.startX, drawing.startY,
drawing.endX, drawing.endY
);
isNear = dist < eraseRadius;
} else if (drawing.type === 'curve') {
// 曲线:检查是否靠近任意点
for (let point of drawing.points) {
const dist = Math.sqrt(
Math.pow(clickX - point.x, 2) + Math.pow(clickY - point.y, 2)
);
if (dist < eraseRadius) {
isNear = true;
break;
}
}
}
if (isNear) {
drawings.value.splice(i, 1);
redrawCanvas();
break;
}
}
}
重绘画布 - 性能优化
每次操作都要重绘整个画布,顺序很重要:
const redrawCanvas = () => {
if (!ctx.value) return;
// 1. 清空画布(但不渲染)
ctx.value.clearRect(0, 0, canvasWidth.value, canvasHeight.value);
// 2. 绘制所有已保存的图形
drawings.value.forEach((drawing) => {
drawShape(drawing);
});
// 3. 绘制当前正在画的(实时反馈)
if (currentDrawing.value) {
drawShape(currentDrawing.value);
}
// 4. 旧API必须调用draw()才能渲染到屏幕
if (!ctx.value.isCanvas2d) {
ctx.value.draw(false); // false表示不清空之前的内容
}
};
注意:
- 新API(Canvas 2D):每次绘制都是实时的
- 旧API:必须调用
draw()才会显示 - 鸿蒙用的是旧API,别忘了
draw() draw(false)的false很关键,true会清空画布
导出图片 - 生成分享海报
这个功能挺实用的,步骤是这样的:
- 先截取战术板:把球场、球员、战术线都画到临时canvas上
- 再画海报:在另一个canvas上画标题、战术板图片、底部信息
- 导出为图片:用
uni.canvasToTempFilePath
绘制战术板完整内容
const captureBoard = () => {
return new Promise((resolve, reject) => {
const tempCtx = uni.createCanvasContext("tacticsCanvas");
tempCtx.isCanvas2d = false;
// 1. 画背景(球场)
tempCtx.setFillStyle("#2d8659");
tempCtx.fillRect(0, 0, boardWidth, boardHeight);
// 2. 画场地线(中线、禁区等)
tempCtx.setStrokeStyle("rgba(255, 255, 255, 0.6)");
tempCtx.setLineWidth(2);
tempCtx.beginPath();
tempCtx.moveTo(0, boardHeight * 0.5);
tempCtx.lineTo(boardWidth, boardHeight * 0.5);
tempCtx.stroke();
// 中圈
const centerCircleRadius = (100 / 750) * boardWidth;
tempCtx.beginPath();
tempCtx.arc(boardWidth * 0.5, boardHeight * 0.5, centerCircleRadius, 0, 2 * Math.PI);
tempCtx.stroke();
// ... 更多场地线
// 3. 画战术线路
drawings.value.forEach((drawing) => {
drawShape(drawing, tempCtx);
});
// 4. 画球员
playersOnField.value.forEach((player) => {
const markerRadius = (60 / 2) * (screenWidth / 750);
const centerX = player.x + markerRadius;
const centerY = player.y + markerRadius;
// 圆圈
tempCtx.setFillStyle(player.team === 'home' ? '#1a3b6e' : '#ff6b35');
tempCtx.beginPath();
tempCtx.arc(centerX, centerY, markerRadius, 0, 2 * Math.PI);
tempCtx.fill();
// 号码
tempCtx.setFillStyle('#ffffff');
tempCtx.setFontSize(markerRadius * 0.8);
tempCtx.setTextAlign('center');
tempCtx.setTextBaseline('middle');
tempCtx.fillText(player.number, centerX, centerY);
});
// 5. 导出
tempCtx.draw(false, () => {
setTimeout(() => {
uni.canvasToTempFilePath({
canvasId: "tacticsCanvas",
success: (res) => {
resolve(res.tempFilePath);
},
fail: reject
});
}, 500); // 等渲染完成
});
});
};
绘制海报
const drawSharePoster = (boardImagePath) => {
return new Promise((resolve, reject) => {
const pWidth = 375;
const pHeight = 550;
const pCtx = uni.createCanvasContext("sharePosterCanvas");
// 1. 背景渐变
const gradient = pCtx.createLinearGradient(0, 0, 0, pHeight);
gradient.addColorStop(0, "#1a3b6e");
gradient.addColorStop(1, "#2c5282");
pCtx.setFillStyle(gradient);
pCtx.fillRect(0, 0, pWidth, pHeight);
// 2. 标题
pCtx.setFillStyle("#ffffff");
pCtx.setFontSize(24);
pCtx.setTextAlign("center");
pCtx.fillText("⚽ 足球战术板", pWidth / 2, 40);
// 3. 副标题
pCtx.setFillStyle("rgba(255, 255, 255, 0.8)");
pCtx.setFontSize(14);
pCtx.fillText("Football Tactics Board", pWidth / 2, 65);
// 4. 战术板图片(重点!)
const margin = 20;
const maxBoardWidth = pWidth - margin * 2;
const boardAspect = boardRect.value.width / boardRect.value.height;
let drawWidth = maxBoardWidth;
let drawHeight = drawWidth / boardAspect;
const boardX = (pWidth - drawWidth) / 2;
const boardY = 90;
// 白色卡片背景
pCtx.setFillStyle("#ffffff");
pCtx.fillRect(boardX - 8, boardY - 8, drawWidth + 16, drawHeight + 16);
// 画战术板图片
pCtx.drawImage(boardImagePath, boardX, boardY, drawWidth, drawHeight);
// 5. 底部信息
const footerY = pHeight - 50;
pCtx.setFillStyle("rgba(255, 255, 255, 0.9)");
pCtx.setFontSize(16);
pCtx.fillText("苏超排名助手", pWidth / 2, footerY);
const now = new Date();
const timeStr = `${now.getFullYear()}-${now.getMonth()+1}-${now.getDate()}`;
pCtx.setFillStyle("rgba(255, 255, 255, 0.5)");
pCtx.setFontSize(10);
pCtx.fillText(`生成时间: ${timeStr}`, pWidth / 2, footerY + 25);
// 6. 导出高清图(2倍分辨率)
pCtx.draw(false, () => {
setTimeout(() => {
uni.canvasToTempFilePath({
canvasId: "sharePosterCanvas",
width: pWidth,
height: pHeight,
destWidth: pWidth * 2, // 2倍分辨率
destHeight: pHeight * 2,
fileType: "png",
quality: 1,
success: (res) => resolve(res.tempFilePath),
fail: reject
});
}, 800);
});
});
};
注意destWidth和destHeight,设置成2倍能提高导出图片的清晰度。
踩过的坑总结
1. 初始化时机问题
症状:获取不到canvas尺寸,或者坐标转换不准
原因:页面还没渲染完就初始化了
解决:延迟200ms,再用createSelectorQuery获取真实位置
setTimeout(() => {
const query = uni.createSelectorQuery();
query.select(".canvas-board").boundingClientRect((rect) => {
boardRect.value = rect;
canvasReady.value = true;
}).exec();
}, 200);
2. 坐标转换不准确
症状:画的位置跟手指点的位置对不上
原因:没考虑canvas的偏移量(padding、margin等)
解决:必须用boundingClientRect获取真实位置,然后减去偏移
x = touch.clientX - boardRect.value.left;
y = touch.clientY - boardRect.value.top;
3. 鸿蒙draw()必须调用
症状:画了但是屏幕上看不到
原因:旧API必须调用draw()才会渲染
解决:每次绘制完都调用
if (!ctx.value.isCanvas2d) {
ctx.value.draw(false);
}
4. 导出图片时机问题
症状:导出的图片是空白的或者不完整
原因:draw()是异步的,还没渲染完就导出了
解决:在draw()的回调里,再延迟500ms
ctx.draw(false, () => {
setTimeout(() => {
uni.canvasToTempFilePath({...});
}, 500);
});
5. 橡皮擦范围不好控制
症状:点了删不掉,或者不小心删了别的线
原因:距离阈值设置不合理
解决:30-40px比较合适,太小不好点,太大容易误删
const eraseRadius = 30; // 橡皮擦范围
if (dist < eraseRadius) {
// 删除
}
6. touchmove和页面滚动冲突
症状:画线的时候页面跟着滚动
原因:touchmove事件冒泡到了页面
解决:加.stop.prevent修饰符
<canvas @touchmove.stop.prevent="handleCanvasTouchMove"></canvas>
但是鸿蒙不支持.prevent,要用disable-scroll="true":
<!-- #ifdef APP-HARMONY -->
<canvas disable-scroll="true"></canvas>
<!-- #endif -->
7. 重绘性能问题
症状:线条多了之后很卡
原因:每次touchmove都要重绘所有线条
解决:
- 限制绘制对象数量(最多50个)
- touchmove节流(每10ms更新一次)
- 复杂背景用离屏canvas缓存
let lastTime = 0;
const handleCanvasTouchMove = (e) => {
const now = Date.now();
if (now - lastTime < 10) return; // 节流
lastTime = now;
// ...
};
8. 图片清晰度问题
症状:导出的图片很模糊
原因:没设置destWidth和destHeight
解决:设置成实际尺寸的2倍
uni.canvasToTempFilePath({
canvasId: "myCanvas",
destWidth: width * 2,
destHeight: height * 2,
fileType: "png",
quality: 1
});
性能优化建议
1. 节流touchmove
绘制曲线时,不用每次touchmove都添加点:
let lastTime = 0;
const handleCanvasTouchMove = (e) => {
const now = Date.now();
if (now - lastTime < 16) return; // 约60fps
lastTime = now;
const coords = getCanvasCoords(e.touches[0]);
currentDrawing.value.points.push(coords);
redrawCanvas();
};
2. 限制绘制数量
太多绘制对象会导致重绘变慢:
const MAX_DRAWINGS = 50;
const handleCanvasTouchEnd = () => {
if (isDrawing.value && currentDrawing.value) {
drawings.value.push({ ...currentDrawing.value });
// 限制数量
if (drawings.value.length > MAX_DRAWINGS) {
drawings.value.shift(); // 删除最早的
}
redrawCanvas();
}
};
3. 离屏canvas缓存背景
球场背景每次都画很费性能,可以缓存起来:
let backgroundCache = null;
const cacheBackground = () => {
const offscreenCanvas = uni.createOffscreenCanvas({
type: '2d',
width: canvasWidth.value,
height: canvasHeight.value
});
const offCtx = offscreenCanvas.getContext('2d');
// 画球场背景
offCtx.fillStyle = '#2d8659';
offCtx.fillRect(0, 0, canvasWidth.value, canvasHeight.value);
// ... 画其他场地线
backgroundCache = offscreenCanvas;
};
const redrawCanvas = () => {
if (!ctx.value) return;
ctx.value.clearRect(0, 0, canvasWidth.value, canvasHeight.value);
// 用缓存的背景
if (backgroundCache) {
ctx.value.drawImage(backgroundCache, 0, 0);
}
// 画战术线
drawings.value.forEach(drawing => drawShape(drawing));
if (!ctx.value.isCanvas2d) {
ctx.value.draw(false);
}
};
4. 按需重绘
如果只是改变某个元素,可以不重绘整个画布(但Canvas API不支持局部重绘,这个比较难实现)。
替代方案:把不同层分开
- 背景层:静态的球场(很少变化)
- 战术层:箭头、线条(经常变化)
- 球员层:用普通DOM而不是Canvas
<!-- 背景canvas -->
<canvas id="bgCanvas" class="bg-layer"></canvas>
<!-- 战术canvas -->
<canvas id="tacticsCanvas" class="tactics-layer"></canvas>
<!-- 球员用普通DOM -->
<view class="players-layer">
<view v-for="player in players" :key="player.id"
:style="{left: player.x, top: player.y}">
{{ player.number }}
</view>
</view>
这样球员拖动就不需要重绘canvas了。
实用技巧
1. 调试坐标
在开发时可以实时显示坐标,方便调试:
const handleCanvasTouchMove = (e) => {
const coords = getCanvasCoords(e.touches[0]);
console.log(`x: ${coords.x}, y: ${coords.y}`);
// 或者显示在页面上
debugInfo.value = `x: ${coords.x}, y: ${coords.y}`;
};
2. 撤销功能
保存历史记录数组:
const history = ref([]);
const historyIndex = ref(-1);
const saveHistory = () => {
// 删除当前索引之后的历史
history.value = history.value.slice(0, historyIndex.value + 1);
// 添加新状态
history.value.push(JSON.parse(JSON.stringify(drawings.value)));
historyIndex.value++;
// 限制历史记录数量
if (history.value.length > 20) {
history.value.shift();
historyIndex.value--;
}
};
const undo = () => {
if (historyIndex.value > 0) {
historyIndex.value--;
drawings.value = JSON.parse(JSON.stringify(history.value[historyIndex.value]));
redrawCanvas();
}
};
const redo = () => {
if (historyIndex.value < history.value.length - 1) {
historyIndex.value++;
drawings.value = JSON.parse(JSON.stringify(history.value[historyIndex.value]));
redrawCanvas();
}
};
3. 颜色选择器
让用户自定义线条颜色:
const colors = ['#ff6b35', '#1a3b6e', '#4caf50', '#f44336', '#9e9e9e'];
const currentColor = ref('#ff6b35');
const handleCanvasTouchStart = (e) => {
// ...
currentDrawing.value = {
type: currentTool.value,
startX: coords.x,
startY: coords.y,
color: currentColor.value // 使用选中的颜色
};
};
4. 线条粗细调整
const lineWidths = [2, 4, 6, 8];
const currentWidth = ref(4);
const drawShape = (shape) => {
// ...
const lineWidth = shape.width || 4;
setLineWidth(lineWidth);
// ...
};
最后
整个功能做下来,最大的感受就是:Canvas在uni-app编译到鸿蒙还是有不少坑的。不过搞定之后还挺有成就感的。
开发建议:
- 先在H5上调试,等逻辑都对了再适配鸿蒙
- 鸿蒙的真机调试很慢,多用
console.log和uni.showToast - 坐标问题一定要用实际设备测试,模拟器不准
- 多保存代码,Canvas相关的代码很容易改崩
适用场景:
- 战术板(足球、篮球等)
- 签名功能
- 涂鸦板
- 路径规划
- 图片标注
代码在附件,有问题可以留言交流。希望能帮到要做类似功能的兄弟们!

