如果你正在做一款篮球App,或者对Canvas绘图感兴趣,可以去鸿蒙应用市场搜一下**「篮徒笔记」**,下载下来体验体验。点击球场位置记录投篮、查看热区分布图,一套流程走下来对自己的投篮习惯会更清楚。体验完了再回来看这篇文章,你会更清楚这些功能背后的Canvas绘图是怎么实现的。
写在前面
大家好,我是一名写了十多年Web前端的老兵。从jQuery时代一路走到React/Vue,CSS3动画、requestAnimationFrame、Web Animation API这些都算是看家本领。去年开始转战鸿蒙生态,用ArkTS开发App,这一路踩了不少坑,也积累了不少心得。
很多人觉得"前端转鸿蒙"应该很容易------都是写UI嘛,组件化、状态管理、生命周期,概念都差不多。但真正上手之后你会发现,相似的地方让你觉得亲切,不同的地方让你抓狂。
比如:
- Canvas绘图 :Web的Canvas API和鸿蒙的
CanvasRenderingContext2D几乎一模一样------都是那个2D上下文,都是moveTo/lineTo/arc那套。但鸿蒙Canvas在画球场时,坐标系映射、组件尺寸的获取方式跟Web不太一样。 - 状态管理 :React的
useState变成了@State,看起来像,但更新机制完全不同------React是函数式触发重渲染,ArkTS是装饰器驱动的精准更新。 - 触摸事件 :Web的
onClick在鸿蒙里变成了onClick,但坐标系和事件对象的结构有差异。
但别担心,核心思想是一样的:都是用Canvas画线、画圆、填充颜色,都是把坐标转换成视觉元素。
这篇文章聊什么
篮徒笔记的球场绘制,核心要解决的问题是:
- 球场怎么画 --- 用Canvas绘制标准篮球场
- 热区怎么标记 --- 在球场上标记投篮位置
- 数据怎么展示 --- 用颜色表示命中率
第一步:理解Canvas坐标系
Canvas的坐标系和Web一样:左上角是原点(0,0),X轴向右,Y轴向下。
画一个标准篮球场(28米 × 15米),我们需要把实际尺寸映射到Canvas的像素尺寸:
typescript
// ArkTS - Canvas坐标映射
const COURT_WIDTH = 28; // 实际宽度(米)
const COURT_HEIGHT = 15; // 实际高度(米)
// Canvas实际尺寸(像素)
const CANVAS_WIDTH = 300;
const CANVAS_HEIGHT = 300 * (COURT_HEIGHT / COURT_WIDTH); // 保持比例
// 坐标映射函数
function courtToCanvas(x: number, y: number): { x: number; y: number } {
return {
x: (x / COURT_WIDTH) * CANVAS_WIDTH,
y: (y / COURT_HEIGHT) * CANVAS_HEIGHT
};
}
React对应版本:
jsx
// React - Canvas坐标映射
const COURT_WIDTH = 28;
const COURT_HEIGHT = 15;
const CANVAS_WIDTH = 300;
const CANVAS_HEIGHT = 300 * (COURT_HEIGHT / COURT_WIDTH);
const courtToCanvas = (x, y) => ({
x: (x / COURT_WIDTH) * CANVAS_WIDTH,
y: (y / COURT_HEIGHT) * CANVAS_HEIGHT
});
第二步:绘制球场轮廓
用Canvas的直线和弧线绘制篮球场:
typescript
// ArkTS - 绘制球场轮廓
@Component
struct CourtCanvas {
private settings: RenderingContextSettings = new RenderingContextSettings(true);
private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings);
aboutToAppear() {
this.drawCourt();
}
drawCourt() {
const ctx = this.context;
// 清空画布
ctx.clearRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
// 设置样式
ctx.strokeStyle = '#f97316'; // 橙色边线
ctx.lineWidth = 2;
ctx.fillStyle = 'rgba(249, 115, 22, 0.05)'; // 淡橙色填充
// 绘制球场轮廓
const topLeft = courtToCanvas(0, 0);
const bottomRight = courtToCanvas(COURT_WIDTH, COURT_HEIGHT);
ctx.strokeRect(topLeft.x, topLeft.y, bottomRight.x - topLeft.x, bottomRight.y - topLeft.y);
ctx.fillRect(topLeft.x, topLeft.y, bottomRight.x - topLeft.x, bottomRight.y - topLeft.y);
// 绘制中线
ctx.setLineDash([5, 5]); // 虚线
const centerTop = courtToCanvas(COURT_WIDTH / 2, 0);
const centerBottom = courtToCanvas(COURT_WIDTH / 2, COURT_HEIGHT);
ctx.beginPath();
ctx.moveTo(centerTop.x, centerTop.y);
ctx.lineTo(centerBottom.x, centerBottom.y);
ctx.stroke();
ctx.setLineDash([]); // 恢复实线
// 绘制三秒区(油漆区)
ctx.fillStyle = 'rgba(239, 68, 68, 0.1)'; // 淡红色
const paintTop = courtToCanvas(0, 5.8);
const paintBottom = courtToCanvas(5.8, 9.2);
ctx.fillRect(paintTop.x, paintTop.y, paintBottom.x - paintTop.x, paintBottom.y - paintTop.y);
// 绘制罚球圈
const ftCenter = courtToCanvas(5.8, 7.5);
const ftRadius = (1.8 / COURT_WIDTH) * CANVAS_WIDTH;
ctx.beginPath();
ctx.arc(ftCenter.x, ftCenter.y, ftRadius, 0, Math.PI * 2);
ctx.stroke();
// 绘制篮筐
const basketCenter = courtToCanvas(1.575, 7.5);
const basketRadius = (0.2 / COURT_WIDTH) * CANVAS_WIDTH;
ctx.fillStyle = 'rgba(249, 115, 22, 0.3)';
ctx.beginPath();
ctx.arc(basketCenter.x, basketCenter.y, basketRadius, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
// 绘制篮板
const boardLeft = courtToCanvas(1.2, 6.8);
const boardRight = courtToCanvas(1.2, 8.2);
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(boardLeft.x, boardLeft.y);
ctx.lineTo(boardRight.x, boardRight.y);
ctx.stroke();
// 绘制三分线
ctx.lineWidth = 1.5;
ctx.strokeStyle = '#3b82f6'; // 蓝色
const threePointCenter = courtToCanvas(1.575, 7.5);
const threePointRadius = (6.75 / COURT_WIDTH) * CANVAS_WIDTH;
ctx.beginPath();
ctx.arc(threePointCenter.x, threePointCenter.y, threePointRadius, -Math.PI / 3, Math.PI / 3);
ctx.stroke();
}
build() {
Canvas(this.context)
.width(CANVAS_WIDTH)
.height(CANVAS_HEIGHT)
.backgroundColor('#ffffff')
}
}
React对应版本:
jsx
// React - 绘制球场
function CourtCanvas() {
const canvasRef = useRef(null);
useEffect(() => {
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
// 清空画布
ctx.clearRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
// 设置样式
ctx.strokeStyle = '#f97316';
ctx.lineWidth = 2;
ctx.fillStyle = 'rgba(249, 115, 22, 0.05)';
// 绘制球场轮廓
const topLeft = courtToCanvas(0, 0);
const bottomRight = courtToCanvas(COURT_WIDTH, COURT_HEIGHT);
ctx.strokeRect(topLeft.x, topLeft.y, bottomRight.x - topLeft.x, bottomRight.y - topLeft.y);
ctx.fillRect(topLeft.x, topLeft.y, bottomRight.x - topLeft.x, bottomRight.y - topLeft.y);
// 绘制中线
ctx.setLineDash([5, 5]);
const centerTop = courtToCanvas(COURT_WIDTH / 2, 0);
const centerBottom = courtToCanvas(COURT_WIDTH / 2, COURT_HEIGHT);
ctx.beginPath();
ctx.moveTo(centerTop.x, centerTop.y);
ctx.lineTo(centerBottom.x, centerBottom.y);
ctx.stroke();
ctx.setLineDash([]);
// 绘制三秒区
ctx.fillStyle = 'rgba(239, 68, 68, 0.1)';
const paintTop = courtToCanvas(0, 5.8);
const paintBottom = courtToCanvas(5.8, 9.2);
ctx.fillRect(paintTop.x, paintTop.y, paintBottom.x - paintTop.x, paintBottom.y - paintTop.y);
// 绘制罚球圈
const ftCenter = courtToCanvas(5.8, 7.5);
const ftRadius = (1.8 / COURT_WIDTH) * CANVAS_WIDTH;
ctx.beginPath();
ctx.arc(ftCenter.x, ftCenter.y, ftRadius, 0, Math.PI * 2);
ctx.stroke();
// 绘制篮筐
const basketCenter = courtToCanvas(1.575, 7.5);
const basketRadius = (0.2 / COURT_WIDTH) * CANVAS_WIDTH;
ctx.fillStyle = 'rgba(249, 115, 22, 0.3)';
ctx.beginPath();
ctx.arc(basketCenter.x, basketCenter.y, basketRadius, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
}, []);
return <canvas ref={canvasRef} width={CANVAS_WIDTH} height={CANVAS_HEIGHT} />;
}
第三步:定义投篮区域
把球场分成若干个区域,每个区域代表一个投篮位置:
typescript
// ArkTS - 投篮区域定义
interface ShotZone {
id: string;
name: string;
x: number; // 区域中心X坐标(米)
y: number; // 区域中心Y坐标(米)
color: string; // 区域颜色
}
const SHOT_ZONES: ShotZone[] = [
// 左侧底角三分
{ id: 'left_corner_3', name: '左底角三分', x: 1, y: 1.5, color: '#3b82f6' },
{ id: 'left_corner_3b', name: '左底角三分B', x: 1, y: 13.5, color: '#3b82f6' },
// 左侧45度三分
{ id: 'left_wing_3', name: '左翼三分', x: 5, y: 3, color: '#3b82f6' },
{ id: 'left_wing_3b', name: '左翼三分B', x: 5, y: 12, color: '#3b82f6' },
// 弧顶三分
{ id: 'top_3', name: '弧顶三分', x: 7, y: 7.5, color: '#3b82f6' },
// 右侧45度三分
{ id: 'right_wing_3', name: '右翼三分', x: 5, y: 3, color: '#3b82f6' },
{ id: 'right_wing_3b', name: '右翼三分B', x: 5, y: 12, color: '#3b82f6' },
// 右侧底角三分
{ id: 'right_corner_3', name: '右底角三分', x: 1, y: 1.5, color: '#3b82f6' },
{ id: 'right_corner_3b', name: '右底角三分B', x: 1, y: 13.5, color: '#3b82f6' },
// 左侧中距离
{ id: 'left_mid', name: '左翼中距离', x: 4, y: 4, color: '#f59e0b' },
{ id: 'left_midb', name: '左翼中距离B', x: 4, y: 11, color: '#f59e0b' },
// 右侧中距离
{ id: 'right_mid', name: '右翼中距离', x: 4, y: 4, color: '#f59e0b' },
{ id: 'right_midb', name: '右翼中距离B', x: 4, y: 11, color: '#f59e0b' },
// 罚球线
{ id: 'free_throw', name: '罚球线', x: 5.8, y: 7.5, color: '#f59e0b' },
// 三秒区内
{ id: 'paint_left', name: '三秒区左侧', x: 3, y: 6.5, color: '#ef4444' },
{ id: 'paint_right', name: '三秒区右侧', x: 3, y: 8.5, color: '#ef4444' },
// 篮下
{ id: 'restricted', name: '篮下', x: 1.5, y: 7.5, color: '#ef4444' },
];
React对应版本:
jsx
// React - 投篮区域定义
const SHOT_ZONES = [
{ id: 'left_corner_3', name: '左底角三分', x: 1, y: 1.5, color: '#3b82f6' },
{ id: 'left_corner_3b', name: '左底角三分B', x: 1, y: 13.5, color: '#3b82f6' },
{ id: 'left_wing_3', name: '左翼三分', x: 5, y: 3, color: '#3b82f6' },
{ id: 'left_wing_3b', name: '左翼三分B', x: 5, y: 12, color: '#3b82f6' },
{ id: 'top_3', name: '弧顶三分', x: 7, y: 7.5, color: '#3b82f6' },
{ id: 'right_wing_3', name: '右翼三分', x: 5, y: 3, color: '#3b82f6' },
{ id: 'right_wing_3b', name: '右翼三分B', x: 5, y: 12, color: '#3b82f6' },
{ id: 'right_corner_3', name: '右底角三分', x: 1, y: 1.5, color: '#3b82f6' },
{ id: 'right_corner_3b', name: '右底角三分B', x: 1, y: 13.5, color: '#3b82f6' },
{ id: 'left_mid', name: '左翼中距离', x: 4, y: 4, color: '#f59e0b' },
{ id: 'left_midb', name: '左翼中距离B', x: 4, y: 11, color: '#f59e0b' },
{ id: 'right_mid', name: '右翼中距离', x: 4, y: 4, color: '#f59e0b' },
{ id: 'right_midb', name: '右翼中距离B', x: 4, y: 11, color: '#f59e0b' },
{ id: 'free_throw', name: '罚球线', x: 5.8, y: 7.5, color: '#f59e0b' },
{ id: 'paint_left', name: '三秒区左侧', x: 3, y: 6.5, color: '#ef4444' },
{ id: 'paint_right', name: '三秒区右侧', x: 3, y: 8.5, color: '#ef4444' },
{ id: 'restricted', name: '篮下', x: 1.5, y: 7.5, color: '#ef4444' },
];
第四步:在球场上标记投篮位置
在球场上用圆点标记投篮位置,用颜色表示命中率:
typescript
// ArkTS - 标记投篮位置
drawShots(shots: ShotRecord[]) {
const ctx = this.context;
// 按区域统计
const zoneStats = new Map<string, { made: number; total: number }>();
shots.forEach(shot => {
const zone = SHOT_ZONES.find(z => z.id === shot.zone);
if (!zone) return;
if (!zoneStats.has(shot.zone)) {
zoneStats.set(shot.zone, { made: 0, total: 0 });
}
const stats = zoneStats.get(shot.zone)!;
stats.total++;
if (shot.made) stats.made++;
});
// 绘制每个区域的统计
zoneStats.forEach((stats, zoneId) => {
const zone = SHOT_ZONES.find(z => z.id === zoneId);
if (!zone) return;
const pos = courtToCanvas(zone.x, zone.y);
const hitRate = stats.total > 0 ? stats.made / stats.total : 0;
// 根据命中率设置颜色
const color = hitRate >= 0.5 ? '#22c55e' : // 绿色:50%以上
hitRate >= 0.3 ? '#f59e0b' : // 黄色:30-50%
'#ef4444'; // 红色:30%以下
// 绘制圆点
ctx.beginPath();
ctx.arc(pos.x, pos.y, 12, 0, Math.PI * 2);
ctx.fillStyle = color;
ctx.globalAlpha = 0.8;
ctx.fill();
// 绘制命中率文字
ctx.fillStyle = '#ffffff';
ctx.font = '10px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(`${Math.round(hitRate * 100)}%`, pos.x, pos.y);
ctx.globalAlpha = 1;
});
}
React对应版本:
jsx
// React - 标记投篮位置
function drawShots(ctx, shots) {
const zoneStats = {};
shots.forEach(shot => {
const zone = SHOT_ZONES.find(z => z.id === shot.zone);
if (!zone) return;
if (!zoneStats[shot.zone]) {
zoneStats[shot.zone] = { made: 0, total: 0 };
}
zoneStats[shot.zone].total++;
if (shot.made) zoneStats[shot.zone].made++;
});
Object.entries(zoneStats).forEach(([zoneId, stats]) => {
const zone = SHOT_ZONES.find(z => z.id === zoneId);
if (!zone) return;
const pos = courtToCanvas(zone.x, zone.y);
const hitRate = stats.total > 0 ? stats.made / stats.total : 0;
const color = hitRate >= 0.5 ? '#22c55e' :
hitRate >= 0.3 ? '#f59e0b' :
'#ef4444';
ctx.beginPath();
ctx.arc(pos.x, pos.y, 12, 0, Math.PI * 2);
ctx.fillStyle = color;
ctx.globalAlpha = 0.8;
ctx.fill();
ctx.fillStyle = '#ffffff';
ctx.font = '10px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(`${Math.round(hitRate * 100)}%`, pos.x, pos.y);
ctx.globalAlpha = 1;
});
}
第五步:在页面中集成
把球场绘制集成到页面中:
typescript
// ArkTS - 投篮记录页面
@Component
struct ShotTracker {
@State selectedZone: string = '';
@State recentShots: ShotRecord[] = [];
private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(
new RenderingContextSettings(true)
);
aboutToAppear() {
this.loadShots();
}
loadShots() {
const shots: ShotRecord[] = JSON.parse(
preferences.getSync(context, 'shots', '[]') as string
);
this.recentShots = shots.slice(-10); // 最近10次
this.drawCourt();
this.drawShots(shots);
}
recordShot(made: boolean) {
if (!this.selectedZone) return;
const shot: ShotRecord = {
id: Date.now(),
date: new Date().toISOString().slice(0, 10),
zone: this.selectedZone,
made,
timestamp: Date.now()
};
const shots: ShotRecord[] = JSON.parse(
preferences.getSync(context, 'shots', '[]') as string
);
shots.push(shot);
preferences.setSync(context, 'shots', JSON.stringify(shots));
this.recentShots = [shot, ...this.recentShots].slice(0, 10);
this.drawCourt();
this.drawShots(shots);
this.selectedZone = '';
}
build() {
Column() {
// 球场Canvas
Canvas(this.context)
.width(CANVAS_WIDTH)
.height(CANVAS_HEIGHT)
.onClick((event) => {
// 处理点击事件
const x = event.x;
const y = event.y;
// 找到最近的区域
const zone = this.findNearestZone(x, y);
if (zone) this.selectedZone = zone.id;
})
// 投篮按钮
Row() {
Button('命中')
.onClick(() => this.recordShot(true))
Button('不中')
.onClick(() => this.recordShot(false))
}
}
}
private findNearestZone(canvasX: number, canvasY: number): ShotZone | null {
let nearest: ShotZone | null = null;
let minDist = Infinity;
SHOT_ZONES.forEach(zone => {
const pos = courtToCanvas(zone.x, zone.y);
const dist = Math.sqrt(Math.pow(canvasX - pos.x, 2) + Math.pow(canvasY - pos.y, 2));
if (dist < minDist && dist < 30) { // 30像素范围内
minDist = dist;
nearest = zone;
}
});
return nearest;
}
}
React对应版本:
jsx
// React - 投篮记录页面
function ShotTracker() {
const [selectedZone, setSelectedZone] = useState(null);
const [recentShots, setRecentShots] = useState([]);
const canvasRef = useRef(null);
useEffect(() => {
const shots = JSON.parse(localStorage.getItem('app_lanqiu_shots') || '[]');
setRecentShots(shots.slice(-10));
drawCourt();
drawShots(shots);
}, []);
const handleCanvasClick = (e) => {
const canvas = canvasRef.current;
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const zone = findNearestZone(x, y);
if (zone) setSelectedZone(zone.id);
};
const recordShot = (made) => {
if (!selectedZone) return;
const shot = {
id: Date.now(),
date: new Date().toISOString().slice(0, 10),
zone: selectedZone,
made,
timestamp: Date.now()
};
const shots = JSON.parse(localStorage.getItem('app_lanqiu_shots') || '[]');
shots.push(shot);
localStorage.setItem('app_lanqiu_shots', JSON.stringify(shots));
setRecentShots([shot, ...recentShots].slice(0, 10));
drawCourt();
drawShots(shots);
setSelectedZone(null);
};
return (
<div>
<canvas ref={canvasRef} onClick={handleCanvasClick} />
<div>
<button onClick={() => recordShot(true)}>命中</button>
<button onClick={() => recordShot(false)}>不中</button>
</div>
</div>
);
}
踩坑提醒
-
Canvas尺寸 :鸿蒙Canvas的尺寸要在组件初始化时确定,不能动态改变。建议用
onAreaChange监听组件尺寸变化。 -
坐标转换:触摸事件返回的是组件坐标,不是Canvas坐标。需要根据Canvas的实际尺寸做转换。
-
性能优化 :如果投篮数据很多,每次重绘整个Canvas会很慢。建议用增量更新,只重绘变化的部分。
-
高清屏适配 :鸿蒙设备可能是高清屏(2x、3x),Canvas需要设置
pixelRatio才能清晰显示。 -
触摸精度:手机屏幕比较小,投篮区域的点击范围要足够大(至少30像素),否则很难点中。
总结
这篇文章带你走了一遍Canvas绘图的完整流程:
- 坐标映射:把实际尺寸映射到Canvas像素
- 球场绘制:用Canvas API画出标准篮球场
- 区域定义:把球场分成若干个投篮区域
- 数据展示:用颜色表示命中率
- 触摸交互:点击球场选择投篮位置
核心API就几个:ctx.strokeRect()画矩形,ctx.arc()画圆,ctx.fill()填充颜色。其他的都是业务逻辑,跟Web开发没太大区别。
下一篇文章,我会聊聊篮徒笔记的热区交互------怎么让投篮区域的点击更精准、更好用。