鸿蒙APP开发:篮球App怎么画球场?鸿蒙Canvas绘图实战

如果你正在做一款篮球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画线、画圆、填充颜色,都是把坐标转换成视觉元素。


这篇文章聊什么

篮徒笔记的球场绘制,核心要解决的问题是:

  1. 球场怎么画 --- 用Canvas绘制标准篮球场
  2. 热区怎么标记 --- 在球场上标记投篮位置
  3. 数据怎么展示 --- 用颜色表示命中率

第一步:理解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>
  );
}

踩坑提醒

  1. Canvas尺寸 :鸿蒙Canvas的尺寸要在组件初始化时确定,不能动态改变。建议用onAreaChange监听组件尺寸变化。

  2. 坐标转换:触摸事件返回的是组件坐标,不是Canvas坐标。需要根据Canvas的实际尺寸做转换。

  3. 性能优化 :如果投篮数据很多,每次重绘整个Canvas会很慢。建议用增量更新,只重绘变化的部分。

  4. 高清屏适配 :鸿蒙设备可能是高清屏(2x、3x),Canvas需要设置pixelRatio才能清晰显示。

  5. 触摸精度:手机屏幕比较小,投篮区域的点击范围要足够大(至少30像素),否则很难点中。


总结

这篇文章带你走了一遍Canvas绘图的完整流程:

  1. 坐标映射:把实际尺寸映射到Canvas像素
  2. 球场绘制:用Canvas API画出标准篮球场
  3. 区域定义:把球场分成若干个投篮区域
  4. 数据展示:用颜色表示命中率
  5. 触摸交互:点击球场选择投篮位置

核心API就几个:ctx.strokeRect()画矩形,ctx.arc()画圆,ctx.fill()填充颜色。其他的都是业务逻辑,跟Web开发没太大区别。

下一篇文章,我会聊聊篮徒笔记的热区交互------怎么让投篮区域的点击更精准、更好用。

相关推荐
colofullove1 小时前
前端工程搭建与用户访问流程设计
前端
广州华水科技2 小时前
如何利用单北斗GNSS系统实现大坝的变形监测?
前端
代码小库2 小时前
【2026前端最新面试题——day10】JavaScript 高频面试题
开发语言·前端·javascript
zzz_23682 小时前
【Spring】面试突击系列(三):Spring Web MVC 深度解析
前端·spring·面试
colofullove2 小时前
小说上传中心与异步处理进度展示设计
前端
Marst Code3 小时前
⚙️ 2026 年推荐技术方案
前端
qq_366086223 小时前
测试接口传参数时,放在Header和Body中后台接收参数的区别
java·开发语言·前端
whatever who cares3 小时前
Vue3中vue文件和composables的分工
前端·javascript·vue.js
袋鼠云数栈UED团队3 小时前
基于 superpowers 实现复杂前端改造
前端