在这里插入图片描述
在这里插入图片描述

鸿蒙ArkUI棋盘布局实战:从零构建五子棋/围棋/象棋通用棋盘组件

一、项目背景与概述

1.1 缘起

在移动应用开发领域,棋盘类游戏(围棋、五子棋、中国象棋、国际象棋等)一直是经典的应用场景。无论是休闲娱乐还是专业对弈,一个美观、流畅、交互精准的棋盘组件都是此类应用的视觉核心与交互基石。

本文以鸿蒙HarmonyOS生态下的ArkUI框架为技术栈,完整记录了从零构建一个通用棋类棋盘组件的全过程。该组件支持15×15路标准棋盘(可通过配置轻松切换为9路、13路、19路等多种规格),采用Canvas全绘制方案,实现了网格线渲染、棋子落子交互、悔棋历史回溯等完整功能。

1.2 技术选型

  • 开发框架:HarmonyOS ArkUI(API 6.1.1)
  • 开发语言:ArkTS(基于TypeScript的鸿蒙静态类型语言)
  • 绘制方案:CanvasRenderingContext2D 全量绘制
  • 交互方案:Canvas 组件 onClick 事件 + 坐标反算

1.3 功能特性一览

功能 说明
规格可配 BOARD_SIZE 常量控制,适配9/13/15/19路棋盘
网格线绘制 横线 + 竖线 + 外边框加粗,自定义木色纹理背景
星位标记 天元 + 四隅星位,按棋盘规格自动计算位置
黑白落子 纯色棋子 + 高光效果 + 阴影效果,视觉立体感强
最后落子标记 红色圆点标记最后一步位置
触摸交互 点击任意交叉点落子,坐标精准反算
悔棋功能 完整的落子历史栈,支持逐级回退
清空棋盘 一键重置所有状态
回合信息 顶部状态栏实时显示当前回合与步数统计

二、ArkUI 与 Flutter 布局技术对比

本文所实现的核心布局思路对标了Flutter中的三大经典布局技术,并在ArkUI生态中找到了对应的等效实现方案。下表展示了完整的技术映射关系。

2.1 核心技术映射

Flutter 技术 ArkUI 等效方案 本文实现
Table + TableRow 网格布局 Column + Row 嵌套 / Grid 组件 / ForEach 循环 采用 Canvas 全绘制,通过坐标计算处理行列布局
CustomPaint 自定义绘制 Canvas + CanvasRenderingContext2D 使用 Canvas 组件,在 onReady 和事件回调中调用 drawBoard() 方法完成所有图形绘制
GestureDetector 手势检测 onClick / onTouch / 手势组件 Canvas 组件上绑定 onClick 事件,通过 event.x/event.y 反算交叉点坐标

2.2 为什么最终选择Canvas全绘制方案?

在开发过程中,我们先后尝试了三种实现方案,最终选择了Canvas全绘制。下面是各方案的演进过程与选型理由。

方案一:声明式组件叠加方案(Path + Column/Row + Circle)

第一版实现采用ArkUI的声明式组件,在Stack容器中叠加三层:

  • 底层:Path 组件绘制网格线(CustomPaint等效)
  • 中层:Column + Row + ForEach 构建交互网格(Table + TableRow等效)
  • 上层:Circle 组件绘制棋子

此方案的优点是声明式、组件化,代码直观。但实际运行中暴露出两个致命问题:

  1. 对齐偏移:网格线Path从PADDING偏移处开始绘制,而Column/Row从(0,0)处开始布局,两者坐标系统不一致导致交互区域与视觉网格严重错位。
  2. 组件组合复杂度:多层Stack嵌套加上大量条件渲染,ArkUI底层在计算布局时容易出现不可预期的渲染结果。

方案二:Grid组件方案

第二版尝试使用ArkUI的Grid组件(含columnsTemplaterowsTemplate属性)。Grid组件天然具备表格布局能力,与Flutter的Table/TableRow最为接近。

但该方案在实际测试中遇到两个问题:

  1. Grid组件按「格子」布局而非按「交叉点」布局(围棋/五子棋的落子位置是网格线的交叉点,而不是格子中心,两者的坐标系统有半格偏移)。
  2. 网格线需要在GridItem之间绘制,需要额外计算偏移量,复杂度反而增加。

方案三:Canvas全绘制方案(最终选择)

最终方案采用单一Canvas组件完成所有视觉元素的绘制。其优势是:

  • 坐标系统一:所有元素(网格线、星位、棋子、标记)使用同一套坐标计算公式,完全消除对齐问题。
  • 交互精准:onClick事件返回的坐标可以直接用于交叉点反算,无需考虑组件偏移修正。
  • 性能优异:避免大量组件树的构建/销毁,一次绘制调用完成所有渲染工作。
  • 状态可控:棋盘数据与UI解耦,不依赖@State触发重建,绘图逻辑完全由开发者掌控。

三、系统架构与数据流设计

3.1 整体架构

本应用遵循单向数据流架构模式,数据流向清晰可追溯。

用户触摸事件
     │
     ▼
┌─────────────────────────────────┐
│   handleTap(event: ClickEvent)  │
│   ┌───────────────────────────┐  │
│   │ 1. 坐标反算 (event→row,col) │  │
│   │ 2. 边界校验                │  │
│   │ 3. 落子逻辑 (写入boardData) │  │
│   │ 4. 历史记录 (push到栈)      │  │
│   │ 5. 切换玩家                │  │
│   │ 6. 更新@State UI文字        │  │
│   │ 7. 调用drawBoard()重绘Canvas│  │
│   └───────────────────────────┘  │
└─────────────────────────────────┘
     │
     ▼
┌─────────────────────────────────┐
│   drawBoard() Canvas重绘        │
│   ┌───────────────────────────┐  │
│   │ 1. clearRect 清空         │  │
│   │ 2. 绘制背景色              │  │
│   │ 3. 绘制网格线 (横+竖)      │  │
│   │ 4. 绘制外边框              │  │
│   │ 5. 绘制星位               │  │
│   │ 6. 遍历boardData绘制棋子   │  │
│   │ 7. 绘制最后落子标记        │  │
│   └───────────────────────────┘  │
└─────────────────────────────────┘
     │
     ▼
┌─────────────────────────────────┐
│   ArkUI渲染管线                 │
│   @State变化 → build()重建     │
│   Canvas复用 → 绘图内容保留     │
└─────────────────────────────────┘

3.2 数据模型设计

棋盘的核心数据模型极为简洁,体现了数据与视图分离的设计思想。

// 棋盘二维数组(普通私有字段,不触发UI重建)
private boardData: number[][] = [];
// 0 = EMPTY(空)| 1 = BLACK(黑子)| 2 = WHITE(白子)

// 落子历史栈(支持悔棋)
private moveHistory: Array<[number, number, number]> = [];
// [row, col, playerColor]

// 最后落子坐标(用于绘制标记)
private lastRow: number = -1;
private lastCol: number = -1;

// UI状态(@State 装饰器自动驱动UI更新)
@State currentPlayer: number = 1;   // 当前回合玩家
@State statusText: string = '黑棋回合'; // 状态文字
@State moveCount: number = 0;       // 落子步数

这里有一个关键的设计决策:棋盘数据层(boardData、moveHistory)使用普通私有字段而非@State装饰器。这样做的原因是:

  1. 棋盘数据的变更不应触发build()方法的重新执行(避免Canvas组件重建导致的绘制丢失)。
  2. 只有与UI显示直接相关的文字信息(当前玩家、状态文字、步数)才使用@State,确保这些文字能自动更新。
  3. Canvas的重绘由开发者手动控制(调用drawBoard()),而不是依赖框架的自动渲染机制。

四、Canvas绘制引擎详解

4.1 初始化与上下文

Canvas绘制的入口是组件的onReady生命周期回调。在ArkUI中,CanvasRenderingContext2D需要在组件的属性初始化阶段创建,然后在onReady中确认Canvas已就绪。

// Canvas上下文 - 组件属性初始化时创建
private readonly settings: RenderingContextSettings = new RenderingContextSettings(true);
private readonly ctx: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings);

// Canvas就绪标志 - 防止drawBoard在Canvas未就绪时被调用
private canvasReady: boolean = false;

// build方法中的Canvas组件
Canvas(this.ctx)
  .width(this.boardPixelSize)
  .height(this.boardPixelSize)
  .onReady(() => {
    this.canvasReady = true;
    this.drawBoard(); // 首次绘制
  })
  .onClick((event: ClickEvent) => {
    this.handleTap(event); // 交互事件
  })

drawBoard()方法内部首先检查canvasReady标志,确保Canvas已经完成初始化布局,避免在无效的上下文上执行绘制操作导致运行时错误。

4.2 棋盘背景与网格线绘制

背景渲染:使用CSS颜色字符串直接设置填充色,模拟传统木质棋盘效果。

ctx.fillStyle = '#D4A853'; // 传统木质棋盘色(黄褐色)
ctx.fillRect(0, 0, size, size);

网格线绘制:这是整个棋盘最基础的视觉元素。网格线由横线和竖线两部分组成,每条线从棋盘一端延伸到另一端。

坐标计算的核心公式为:

交叉点(i)的像素坐标 = PADDING + i × CELL_SIZE

其中PADDING(边距)确保棋盘边缘留出空白,CELL_SIZE(格子大小)控制网格疏密程度。

for (let i = 0; i < this.BOARD_SIZE; i++) {
  const p: number = this.pos(i); // 计算第i条线的坐标
  
  // 横线:从最左到最右
  ctx.beginPath();
  ctx.moveTo(p0, p);   // 起点 (左, 当前行)
  ctx.lineTo(pN, p);   // 终点 (右, 当前行)
  ctx.stroke();
  
  // 竖线:从最上到最下
  ctx.beginPath();
  ctx.moveTo(p, p0);   // 起点 (当前列, 上)
  ctx.lineTo(p, pN);   // 终点 (当前列, 下)
  ctx.stroke();
}

外边框加粗:棋盘的四条外边界线使用更粗的线宽绘制,增强视觉边界感。

ctx.strokeStyle = '#3a2718'; // 深棕色边框
ctx.lineWidth = 2;            // 2px 粗线(内部网格线为1px)
ctx.strokeRect(p0, p0, pN - p0, pN - p0);

4.3 星位(星标/天元)绘制

围棋和五子棋棋盘都有标记星位的传统,用于辅助定位和帮助记忆。不同规格的棋盘星位数量不同:

  • 9路棋盘:5个星位(四隅+天元)
  • 13路棋盘:9个星位(参照15路布局)
  • 15路棋盘:9个星位(标准布局)
  • 19路棋盘:9个星位(标准布局,隅星位于第3/17路)

星位坐标的计算逻辑:

if (this.BOARD_SIZE >= 9) {
  const mid: number = Math.floor(this.BOARD_SIZE / 2); // 天元位置
  const s: number = this.BOARD_SIZE >= 15 ? 3 : 2;     // 隅星偏移量
  const e: number = this.BOARD_SIZE - 1 - s;           // 对侧隅星位置
  
  // 9个星位的行列坐标
  const stars: number[][] = [
    [s, s], [s, mid], [s, e],
    [mid, s], [mid, mid], [mid, e],
    [e, s], [e, mid], [e, e],
  ];
  
  ctx.fillStyle = '#4a3728';
  // 每个星位绘制为直径7px的实心圆点
  for (let si = 0; si < stars.length; si++) {
    ctx.beginPath();
    ctx.arc(this.pos(stars[si][1]), this.pos(stars[si][0]), 3.5, 0, 2 * Math.PI);
    ctx.fill();
  }
}

4.4 棋子渲染技术

棋子的渲染是视觉效果的核心,本文采用了三层渲染技术来营造立体感。

第一层:阴影。在棋子实际位置的右下偏移1.5像素处绘制一个半透明灰色圆形,模拟光线投射产生的阴影效果。

ctx.beginPath();
ctx.arc(cx + 1.5, cy + 1.5, this.PIECE_RADIUS, 0, 2 * Math.PI);
ctx.fillStyle = 'rgba(0,0,0,0.15)'; // 15%透明度黑色阴影
ctx.fill();

第二层:棋子本体。使用纯色填充加描边绘制棋子主体。黑子使用深灰(#222222),白子使用纯白(#FFFFFF)。

ctx.beginPath();
ctx.arc(cx, cy, this.PIECE_RADIUS, 0, 2 * Math.PI);
ctx.fillStyle = isBlack ? '#222222' : '#FFFFFF';
ctx.fill();
ctx.strokeStyle = isBlack ? '#000000' : '#999999';
ctx.lineWidth = 1;
ctx.stroke();

第三层:高光。在棋子的左上方绘制一个小半透明圆形,模拟光线在光滑棋子表面产生的反射高光效果。

ctx.beginPath();
ctx.arc(cx - 4, cy - 4, 4, 0, 2 * Math.PI);
ctx.fillStyle = isBlack ? '#444444' : '#FFFFFF';
ctx.globalAlpha = 0.6; // 60%透明度,产生柔和高光
ctx.fill();
ctx.globalAlpha = 1.0;  // 恢复不透明度

最后落子标记。在当前回合最后落下的棋子上方绘制一个红色小圆点,帮助对弈双方快速定位最新的落子位置。

if (r === this.lastRow && c === this.lastCol) {
  ctx.beginPath();
  ctx.arc(cx, cy, 4, 0, 2 * Math.PI);
  ctx.fillStyle = '#FF3333'; // 醒目红色
  ctx.fill();
}

4.5 完整的drawBoard方法

下面是drawBoard方法的完整代码,展示了上述所有绘制步骤的有序组合。

private drawBoard(): void {
  if (!this.canvasReady) {
    return; // 安全检测:Canvas未就绪时不执行绘制
  }

  const ctx: CanvasRenderingContext2D = this.ctx;
  const size: number = this.boardPixelSize;

  // 步骤1:清空画布(防止前一帧残留)
  ctx.clearRect(0, 0, size, size);

  // 步骤2:棋盘背景
  ctx.fillStyle = '#D4A853';
  ctx.fillRect(0, 0, size, size);

  // 步骤3-5:网格线、外边框、星位(详见上文)
  // ...
  
  // 步骤6:遍历棋盘数据绘制棋子
  for (let r = 0; r < this.BOARD_SIZE; r++) {
    for (let c = 0; c < this.BOARD_SIZE; c++) {
      if (this.boardData[r][c] === 0) continue; // 跳过空位
      // 绘制阴影、棋子本体、高光、标记
    }
  }
}

五、交互系统实现

5.1 触摸事件与坐标反算

交互系统的核心是坐标反算:将用户的触摸点像素坐标转换为棋盘逻辑坐标(行列号)。这是连接用户操作与游戏逻辑的桥梁。

private handleTap(event: ClickEvent): void {
  // 坐标反算:将像素坐标转换为棋盘行列号
  const col: number = Math.round((event.x - this.PADDING) / this.CELL_SIZE);
  const row: number = Math.round((event.y - this.PADDING) / this.CELL_SIZE);

  // 边界校验:确保点击位置在有效范围内
  if (row < 0 || row >= this.BOARD_SIZE || col < 0 || col >= this.BOARD_SIZE) {
    return;
  }
  
  // 冲突检测:已有棋子则忽略本次点击
  if (this.boardData[row][col] !== 0) {
    return;
  }

  // 执行落子逻辑...
}

坐标反算的数学原理非常直观:

行号 = round((触摸点Y坐标 - 边距) / 格子大小)
列号 = round((触摸点X坐标 - 边距) / 格子大小)

使用Math.round()进行四舍五入,确保当用户点击在两个交叉点之间时,自动吸附到最近的交叉点,大大提升了交互的容错性和用户体验。

5.2 落子逻辑

落子操作是一个复合操作,涉及状态更新、历史记录和UI刷新三个层面。

private handleTap(event: ClickEvent): void {
  // ...坐标反算和校验(如上所述)

  // 1. 写入棋盘数据
  this.boardData[row][col] = this.currentPlayer;
  
  // 2. 压入历史栈(悔棋用)
  this.moveHistory.push([row, col, this.currentPlayer]);
  
  // 3. 记录最后落子坐标(用于标记)
  this.lastRow = row;
  this.lastCol = col;

  // 4. 切换玩家
  this.currentPlayer = this.currentPlayer === 1 ? 2 : 1;
  
  // 5. 更新UI文字(@State变量自动触发UI更新)
  this.statusText = this.currentPlayer === 1 ? '黑棋回合' : '白棋回合';
  this.moveCount = this.moveHistory.length;

  // 6. 手动触发Canvas重绘
  this.drawBoard();
}

5.3 悔棋机制

悔棋功能的实现依赖于历史栈数据结构。每次落子时都将操作的完整信息(行、列、玩家颜色)压入栈中;悔棋时从栈顶弹出最近的一次操作并回退。

private undoLastMove(): void {
  // 空栈检测
  if (this.moveHistory.length === 0) {
    return;
  }

  // 弹出最近一步记录
  const last = this.moveHistory.pop()!;
  const row: number = last[0];
  const col: number = last[1];
  const player: number = last[2];

  // 回退棋盘状态
  this.boardData[row][col] = 0; // 置为空
  this.currentPlayer = player;  // 恢复当前玩家

  // 更新最后落子标记
  if (this.moveHistory.length > 0) {
    const prev = this.moveHistory[this.moveHistory.length - 1];
    this.lastRow = prev[0];
    this.lastCol = prev[1];
  } else {
    this.lastRow = -1;
    this.lastCol = -1; // 无历史记录时清除标记
  }

  // 更新UI
  this.statusText = this.currentPlayer === 1 ? '黑棋回合' : '白棋回合';
  this.moveCount = this.moveHistory.length;

  // 重绘Canvas
  this.drawBoard();
}

这里有一个值得注意的设计细节:悔棋后需要检查历史栈是否还有剩余记录,如果有则将最后落子标记更新为栈中最后一步的位置;如果没有则清除标记。这个看似微小的设计保证了用户体验的连贯性。

5.4 清空棋盘

清空棋盘本质上就是重新执行初始化操作,将所有状态恢复为初始值。

private initBoard(): void {
  // 重新生成空白棋盘
  const b: number[][] = [];
  for (let r = 0; r < this.BOARD_SIZE; r++) {
    const row: number[] = [];
    for (let c = 0; c < this.BOARD_SIZE; c++) {
      row.push(0); // 全部置空
    }
    b.push(row);
  }
  
  // 重置所有状态
  this.boardData = b;
  this.moveHistory = [];
  this.lastRow = -1;
  this.lastCol = -1;
  this.currentPlayer = 1;
  this.statusText = '黑棋回合';
  this.moveCount = 0;
  
  // 重绘空白棋盘
  this.drawBoard();
}

六、布局与常量配置

6.1 关键常量

棋盘的视觉呈现完全由一组精心调优的常量控制。通过调整这些常量,可以适配不同规格和样式的棋盘。

// 棋盘路数(15=标准五子棋/小围棋,19=大围棋,9=小棋盘)
private readonly BOARD_SIZE: number = 15;

// 格子像素大小(越大网格越疏,建议范围30-50px)
private readonly CELL_SIZE: number = 38;

// 棋盘边距(留白区域,防止棋子溢出)
private readonly PADDING: number = 28;

// 棋子半径(决定棋子视觉大小)
private readonly PIECE_RADIUS: number = 15;

常量之间的数学关系:

棋盘总像素宽度 = (BOARD_SIZE - 1) × CELL_SIZE + 2 × PADDING
                  = 14 × 38 + 2 × 28
                  = 588 px

这个公式的含义是:- (BOARD_SIZE - 1) × CELL_SIZE:棋盘内部网格的总跨度(15路棋盘有14个间距) - 2 × PADDING:棋盘上下/左右各留出的边距

6.2 组件布局结构

页面采用纵向弹性布局(Column),从上到下依次排列三个区域。

build() {
  Column() {
    // ─── 区域1:顶部状态栏 ───
    Row() {
      // 当前玩家指示器(圆形图标)
      // 回合文字说明
      // 步数统计
    }

    // ─── 区域2:Canvas棋盘 ───
    Canvas(this.ctx)
      .width(this.boardPixelSize)
      .height(this.boardPixelSize)
      .onReady(() => { this.canvasReady = true; this.drawBoard(); })
      .onClick((event) => { this.handleTap(event); })

    // ─── 区域3:底部操作栏 ───
    Row() {
      // 棋盘规格标签
      // 悔棋按钮
      // 清空棋盘按钮
    }
  }
  .width('100%')
  .height('100%')
  .justifyContent(FlexAlign.Center)
}

ColumnjustifyContent(FlexAlign.Center)属性将三个区域整体居中显示,适配不同屏幕尺寸。

6.3 样式设计

整体的配色方案参考了传统木质棋盘的美学风格:

元素 色值 用途
棋盘背景 #D4A853 模拟木质棋盘黄褐色调
网格线 #4a3728 深棕线条,清晰不刺眼
外边框 #3a2718 略深于内部网格线,突出边界
页面背景 #F5E6D3 米白色背景,突出棋盘主体
黑子 #222222 深灰近黑,不采用纯黑更柔和
白子 #FFFFFF 纯白,与黑子形成鲜明对比
落子标记 #FF3333 醒目红色,快速定位最后一步
阴影 rgba(0,0,0,0.15) 半透明黑色,模拟立体感

七、性能优化与注意事项

7.1 Canvas全量绘制的性能特征

本方案采用的是全量重绘策略——每次状态变化时,清空Canvas并重新绘制全部内容。对于15×15(225个交叉点)的棋盘,每次绘制需要执行约300-500条Canvas绘图指令。

从性能角度看,全量重绘在以下场景中完全可接受:

  • 棋盘规格在19路以内(361个交叉点以内)
  • 每秒钟交互次数不超过10次(人类操作频率远低于此)
  • 不涉及动画或连续帧更新

如果未来需要支持更大规格的棋盘或需要动画效果,可以考虑引入脏矩形重绘优化:仅重绘发生变化的最小矩形区域,而非整个画布。

7.2 @State 与 Canvas 的交互注意事项

这是本实现中最容易踩坑的地方,值得特别强调:

不要将Canvas的绘制数据声明为@State。因为@State变量的每一次变化都会触发build()方法的重新执行,导致Canvas组件在ArkUI的渲染树中被重建。重建后的Canvas虽然使用了同一个CanvasRenderingContext2D上下文对象,但原生层的绘图缓冲区可能已经被重置,导致之前绘制的所有内容丢失。

正确的做法是:

  1. 棋盘数据(boardData)、历史记录(moveHistory)等逻辑数据使用普通私有字段
  2. UI文字(statusText、moveCount、currentPlayer)使用**@State装饰器**,确保文字自动更新。
  3. Canvas的绘制通过**手动调用drawBoard()**完成,而不是依赖框架的自动渲染。

7.3 canvasReady 安全标志

在ArkUI中,Canvas组件有一个onReady生命周期回调,该回调在Canvas完成首次布局后触发。在此之前,Canvas上下文可能还未与原生层完成绑定,调用绘图方法可能产生不可预期的结果。

private canvasReady: boolean = false;

// 在onReady中设置标志
Canvas(this.ctx)
  .onReady(() => {
    this.canvasReady = true;
    this.drawBoard();
  })

// drawBoard方法中检查标志
private drawBoard(): void {
  if (!this.canvasReady) {
    return; // 安全退出
  }
  // ...执行绘制
}

这个看似简单的安全标志,在实际运行中能有效避免因事件触发时机早于Canvas初始化而导致的运行时错误。

7.4 坐标系统的双校验

触摸事件的坐标反算涉及两个关键的校验步骤,缺一不可。

边界校验:防止用户点击位置超出棋盘范围时产生数组越界错误。

if (row < 0 || row >= this.BOARD_SIZE || col < 0 || col >= this.BOARD_SIZE) {
  return; // 超出棋盘范围,忽略本次点击
}

冲突校验:防止在已有棋子的位置重复落子。

if (this.boardData[row][col] !== 0) {
  return; // 该位置已有棋子,忽略本次点击
}

八、扩展性与自定义

8.1 适配不同棋盘规格

本组件的棋盘规格由BOARD_SIZE一个常量决定,修改即可适配不同的棋类游戏:

BOARD_SIZE 适用场景
8 国际象棋、中国象棋
9 小围棋(9路)、简易五子棋
13 中等围棋(13路)
15 标准五子棋、小围棋
19 标准围棋

星位位置的计算逻辑会根据BOARD_SIZE自动调整:

  • BOARD_SIZE >= 15时,隅星偏离边距3路
  • BOARD_SIZE >= 9时,隅星偏离边距2路
  • BOARD_SIZE < 9时,仅在天元位置绘制一个星位

8.2 可自定义的视觉主题

通过替换drawBoard()方法中的颜色常量,可以实现多样化的视觉主题:

// 深色主题示例
ctx.fillStyle = '#2c2c2c'; // 深色背景
ctx.strokeStyle = '#666666'; // 灰色网格线

// 古风主题示例
ctx.fillStyle = '#f0d9b5'; // 米黄色背景
ctx.strokeStyle = '#8b4513'; // 棕色网格线

// 现代简约主题示例
ctx.fillStyle = '#ffffff'; // 纯白背景
ctx.strokeStyle = '#cccccc'; // 浅灰网格线

8.3 可扩展的游戏逻辑

当前的棋盘组件提供了完整的交互框架和数据模型,可以在此基础上方便地扩展各种棋类游戏的逻辑:

// 五子棋胜负判断(扩展示例)
private checkWin(row: number, col: number, player: number): boolean {
  // 四个方向的连续棋子计数
  const directions = [[0,1], [1,0], [1,1], [1,-1]];
  for (const [dr, dc] of directions) {
    let count = 1;
    // 正方向计数
    for (let i = 1; i < 5; i++) {
      const r = row + dr * i, c = col + dc * i;
      if (r < 0 || r >= BOARD_SIZE || c < 0 || c >= BOARD_SIZE) break;
      if (boardData[r][c] !== player) break;
      count++;
    }
    // 反方向计数
    for (let i = 1; i < 5; i++) {
      const r = row - dr * i, c = col - dc * i;
      if (r < 0 || r >= BOARD_SIZE || c < 0 || c >= BOARD_SIZE) break;
      if (boardData[r][c] !== player) break;
      count++;
    }
    if (count >= 5) return true;
  }
  return false;
}

九、开发经验总结

9.1 踩坑记录

在开发过程中,我们遇到了以下几个值得记录的问题:

问题1:Canvas重绘后内容丢失

  • 现象:点击落子后,Canvas上的所有内容消失,棋盘变为空白。
  • 原因:@State变量变化导致build()重新执行,Canvas组件被重建,之前的绘制内容丢失。
  • 解决方案:将棋盘数据从@State中移除,改为普通字段 + 手动drawBoard()调用的模式。

问题2:交互区域与视觉网格错位

  • 现象:用户点击网格交叉点周围时,落子位置与该交叉点明显不符。
  • 原因:使用Stack叠加Path(网格线)+ Column/Row(交互层)时,两层坐标系统不一致。
  • 解决方案:改用Canvas全绘制方案,视觉与交互使用同一套坐标系统。

问题3:ArkTS语法限制

  • 现象:编译报错"Destructuring variable declarations are not supported"和"Type inference in case of generic function calls is limited"。
  • 原因:ArkTS对JavaScript/TypeScript的部分语法有严格限制。
  • 解决方案:使用显式索引替代解构赋值,使用for循环替代Array.from泛型调用。

问题4:Canvas绘制接口兼容性

  • 现象:部分设备上棋子渐变效果不显示。
  • 原因:createRadialGradient在某些API版本中可能不完全支持。
  • 解决方案:放弃渐变效果,使用纯色填充加高光/阴影模拟立体感。

9.2 ArkUI Canvas vs. Flutter CustomPaint

对于开发者而言,理解ArkUI Canvas与Flutter CustomPaint之间的异同有助于更高效地进行跨平台开发:

维度 ArkUI Canvas Flutter CustomPaint
引入方式 声明式 Canvas(this.ctx) 声明式 CustomPaint(painter: myPainter)
上下文 CanvasRenderingContext2D Canvas 对象(canvas.drawXXX()
绘制方法 命令式 ctx.beginPath(); ctx.arc(); ctx.fill() 命令式 canvas.drawCircle(Offset, radius, paint)
重绘触发 手动调用绘制方法 shouldRepaint() 返回true时触发
API风格 接近Web Canvas标准 Flutter自有Canvas API
学习成本 较低(Web开发者熟悉) 中等(需要学习Paint/PaintingStyle等概念)

9.3 最佳实践建议

基于本项目的开发经验,以下是一些值得分享的最佳实践:

  1. 优先选择Canvas全绘制:对于需要精确坐标控制的自定义UI组件,Canvas方案的调试成本和维护成本远低于组件组合方案。
  2. 数据与视图分离:逻辑数据(棋盘状态)与UI数据(@State)分开管理,避免不必要地触发UI重建。
  3. 防御性编程:在Canvas绘图前检查上下文是否就绪,在事件处理中校验坐标和数据合法性。
  4. 常量集中管理:将所有的样式常量和配置参数集中定义,便于后续修改和主题定制。
  5. 分层绘制顺序:Canvas绘制时严格遵循「背景→底层→中间层→顶层」的顺序,确保视觉层次正确。

十、总结与展望

10.1 实现成果

本文完整实现了一个基于HarmonyOS ArkUI框架的通用棋类棋盘组件,它:

  • 功能完整:包含棋盘渲染、落子交互、悔棋、清空等核心功能
  • 视觉精细:木质背景、立体棋子、高光效果、最后落子标记
  • 交互精准:坐标反算+双校验确保交互的准确性和鲁棒性
  • 架构清晰:单向数据流、数据与视图分离、职责分明的模块划分
  • 扩展性强:通过常量配置可适配多种棋类游戏规格

10.2 未来展望

当前实现已经奠定了良好的基础,未来可以从以下方向进一步扩展:

  1. AI对弈集成:接入AI引擎(如基于MiniMax算法的五子棋AI),实现人机对战。
  2. 联机对弈:基于鸿蒙分布式能力或WebSocket实现远程对弈。
  3. 动画增强:引入落子动画效果(棋子从上方掉落、涟漪扩散等)。
  4. 棋谱导出:支持SGF等标准围棋棋谱格式的导入与导出。
  5. 计时系统:为对弈双方分别计时,实现标准比赛计时器。
  6. 多主题切换:提供多套预设配色方案,支持用户自定义主题。
  7. 无障碍适配:为视障用户提供语音提示和读屏适配。

10.3 结语

从最初的多层组件叠加方案,到最终的Canvas全绘制方案,这个棋盘组件的演进过程本身就是一个关于「如何选择技术方案」的典型案例。在开发过程中,我们深刻体会到:

在UI开发中,简单直接的方案往往是最可靠的。Canvas虽然需要手动管理重绘,但它避开了框架组件树的复杂交互,提供了完全可控的渲染管线。当你的UI元素需要在精确的坐标位置呈现时,Canvas几乎总是最优解。

希望本文对正在进行鸿蒙应用开发的同行们有所帮助。棋盘虽小,五脏俱全——它涵盖了自定义绘制、触摸交互、状态管理、数据结构设计等移动开发的核心议题,是一个值得深入研究的经典UI场景。


Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐