文章配图:onClick 回调与闭包、handleColumnClick 处理流程、MVC 分层(UI 调引擎 + 同

页面预览

前言

上一篇我们拆透了定时器泄漏的坑——clearTimers 兜底防泄漏。但定时器只是「时间驱动」,游戏还要「事件驱动」——玩家点击某列时投放猫咪。这需要 ArkUI 的 onClick 事件配合透明点击层(第 17 篇讲过结构)和 handleColumnClick 处理函数(第 17 篇简提,本篇专讲)。

本篇以「猫猫大作战」列投放的事件处理为锚点,把onClick 回调与闭包handleColumnClick 处理流程MVC 分层(UI 调引擎 + 同步 state)投放失败的静默处理四大要点讲透。

提示:本系列不讲 ArkTS 基础语法与环境搭建,假设你已跟完第 1–36 篇。本篇是阶段二第七篇。

一、场景拆解:玩家点击列投放

回顾「猫猫大作战」透明点击层(第 17 篇)和 handleColumnClick(第 17 篇简提):

// 来源:entry/src/main/ets/pages/Index.ets  GameView() 第 3 层
Row() {
  ForEach(this.cols, (col: number) => {
    Column()
      .width(GameConfig.CELL_SIZE)
      .height(GameConfig.BOARD_HEIGHT * GameConfig.CELL_SIZE)
      .backgroundColor('rgba(0,0,0,0)')
      .onClick(() => {
        this.handleColumnClick(col);           // ← 闭包捕获 col
      })
  }, (col: number) => `click_${col}`)
}
// 来源:entry/src/main/ets/pages/Index.ets
handleColumnClick(column: number) {
  if (this.gameState !== GameState.PLAYING) return;          // ← 状态守卫
  if (this.gameEngine.dropCat(column)) {                    // ← 引擎投放
    this.cats = this.gameEngine.getAllCats();               // ← 同步猫数组
    this.nextCatLevel = this.gameEngine.getNextCatLevel();  // ← 更新预告
  }
}

核心问题

  1. onClick 回调里的 col 怎么绑定到对应列?
  2. handleColumnClick 的三步流程(守卫→投放→同步)有什么讲究?
  3. dropCat 返回 false 时(列已满)怎么处理?
  4. UI 层和引擎层的职责怎么分?

二、onClick 回调与闭包

2.1 闭包按值捕获 col

ForEach(this.cols, (col: number) => {
  Column()
    .onClick(() => {
      this.handleColumnClick(col);   // col 被闭包捕获
    })
}, (col: number) => `click_${col}`)

机制ForEach 每次迭代都创建一个新的箭头函数闭包,捕获当时的 col

执行流程

迭代 col 值 创建的闭包
第 1 次 0 () => this.handleColumnClick(0)
第 2 次 1 () => this.handleColumnClick(1)
第 3 次 2 () => this.handleColumnClick(2)
第 4 次 3 () => this.handleColumnClick(3)
第 5 次 4 () => this.handleColumnClick(4)

关键经验ForEach 的迭代变量是「每次新建」的——不是 JS 经典的 var 共享坑,每列绑定到自己的 col

2.2 闭包 vs 普通函数的 this

// ❌ 错误:普通函数 this 不指向组件
.onClick(function () {
  this.handleColumnClick(col);   // this 是触发事件的 Column,不是 Index
})

// ✅ 正确:箭头函数保留外层 this
.onClick(() => {
  this.handleColumnClick(col);   // this 指向 Index struct
})

关键经验ArkUI 事件回统一用箭头函数——保留外层 this,能直接调 this.xxx。本系列第 38 篇会专讲箭头函数 this 绑定。

2.3 onClick 触发时机

ArkUI 的 onClick点击抬起时触发(down → up 算一次 click):

手势阶段 触发回调
手指按下(down) onTouchDown
手指移动(move) onTouchMove
手指抬起(up) onClick 触发

实战经验长按要响应用 onTouch + 计时器,单纯点击用 onClick 就够。本系列第 100 篇会专讲 Menu 长按菜单。

三、handleColumnClick 三步流程

3.1 步骤一:状态守卫

handleColumnClick(column: number) {
  if (this.gameState !== GameState.PLAYING) return;   // 仅 PLAYING 接受点击
  /* ... */
}

守卫的作用

gameState handleColumnClick 行为 原因
IDLE 短路返回 主菜单态不投放
PLAYING 正常投放 游戏中接受点击
PAUSED 短路返回 暂停态不接受操作
GAME_OVER 短路返回 结束态不投放

关键经验UI 层守卫比引擎层守卫更省——if 短路避免无效的 dropCat 调用,省一次引擎遍历。

3.2 步骤二:引擎投放

if (this.gameEngine.dropCat(column)) {
  /* ... 同步 state */
}

dropCat 引擎实现(第 121 篇会专讲重力下落,此处简提):

// 来源:entry/src/main/ets/components/GameEngine.ets
dropCat(column: number): boolean {
  if (column < 0 || column >= GameConfig.BOARD_WIDTH) return false;   // 越界守卫

  // 检查该列顶部是否有空间
  if (this.board[0][column] !== null) return false;                   // 列满守卫

  const cat: Cat = {
    id: `cat_${this.catCounter++}`,
    level: this.nextCatLevel,
    x: column,
    y: 0,                                                              // 从顶部投放
    falling: true                                                     // 标记下落态
  };

  this.cats.set(cat.id, cat);
  // 生成下一只预览
  this.nextCatLevel = this.randomLevel();
  return true;
}

dropCat 双重守卫

守卫 作用
`column < 0
this.board[0][column] !== null 防列满(顶部已有猫)

返回值

返回 含义 UI 层行为
true 投放成功 同步 cats + nextCatLevel
false 投放失败(越界/列满) 静默忽略,不更新 UI

3.3 步骤三:同步 state

if (this.gameEngine.dropCat(column)) {
  this.cats = this.gameEngine.getAllCats();               // 同步猫数组
  this.nextCatLevel = this.gameEngine.getNextCatLevel();  // 更新预告
}

为什么同步两个 state

state 同步原因
this.cats 投放后猫数组增 1,UI 要渲染新猫
this.nextCatLevel dropCat 内部 this.nextCatLevel = this.randomLevel() 生成了新预览,UI 要显示

关键经验投放后必同步两个 state——漏 cats 猫不显示,漏 nextCatLevel 预告区不准。

四、MVC 分层:UI 调引擎 + 同步 state

4.1 职责分层

UI 层(Index.ets)
  ├─ onClick 回调(闭包捕获 col)
  └─ handleColumnClick(守卫 → 调引擎 → 同步 state)
       ↓
引擎层(GameEngine.ets)
  └─ dropCat(越界守卫 → 列满守卫 → 创建 Cat → 加入 Map)
       ↓
数据层
  ├─ this.cats(Map<string, Cat>)
  └─ this.board(二维数组)
``

**分层原则**:

| 层 | 职责 | 不做的事 |
|----|------|----------|
| UI 层 | 事件分发、状态守卫、state 同步 | 直接改棋盘、算物理 |
| 引擎层 | 棋盘操作、物理计算、规则判定 | 渲染、事件处理 |
| 数据层 | 存储猫和棋盘 | 逻辑 |

**关键经验**:**UI 层只调引擎 + 同步 state,不直接改棋盘**——这是 MVC 分层的最小实践。本系列第 111 篇会专讲 GameEngine 引擎类。

### 4.2 UI 层的边界

```ts
// ❌ 错误:UI 层直接改棋盘(绕过引擎)
handleColumnClick(column: number) {
  if (this.gameState !== GameState.PLAYING) return;
  // 直接创建猫,绕过引擎
  const cat: Cat = { id: `cat_${Date.now()}`, level: this.nextCatLevel, x: column, y: 0, falling: true };
  this.cats.push(cat);      // UI 层直接改数组
  // 但引擎的 cats Map 和 board 二维数组没更新!物理循环会错乱
}

// ✅ 正确:UI 层调引擎,引擎改数据,UI 同步快照
handleColumnClick(column: number) {
  if (this.gameState !== GameState.PLAYING) return;
  if (this.gameEngine.dropCat(column)) {           // 引擎改数据
    this.cats = this.gameEngine.getAllCats();      // UI 同步快照
    this.nextCatLevel = this.gameEngine.getNextCatLevel();
  }
}

4.3 引擎是单一数据源

// UI 的 this.cats 只是引擎 Map 的快照
this.cats = this.gameEngine.getAllCats();      // Array.from(this.cats.values())

// 引擎内部数据:
private board: (Cat | null)[][] = [];          // 二维棋盘
private cats: Map<string, Cat> = new Map();   // id → Cat 映射

关键经验引擎是单一数据源——boardcats Map 由引擎统一管,UI 的 this.cats 只是快照。多次同步无竞态(都从引擎读)。

五、投放失败的静默处理

5.1 列已满时 dropCat 返回 false

if (this.gameEngine.dropCat(column)) {
  /* 投放成功,同步 state */
}
// 投放失败(返回 false):if 不通过,啥也不做
``

**静默处理的原因**| 处理方式 | 用户体验 | 适合 |
|----------|----------|------|
| **静默忽略(本项目)** | 点击没反应,自己看棋盘知道列满 | 消除类游戏 |
| Toast 提示 | 「该列已满」 | 新手友好 |
| 振动反馈 | 短振一下 | 触觉反馈型 |

**关键经验****消除类游戏列满是常态,静默处理最不打扰**——玩家看到列顶部有猫就知道满了,不需要每次提示。

### 5.2 进阶:Toast 投放失败提示

```ts
import { promptAction } from '@kit.ArkUI';

handleColumnClick(column: number) {
  if (this.gameState !== GameState.PLAYING) return;
  if (this.gameEngine.dropCat(column)) {
    this.cats = this.gameEngine.getAllCats();
    this.nextCatLevel = this.gameEngine.getNextCatLevel();
  } else {
    // 投放失败提示
    promptAction.showToast({
      message: '该列已满',
      duration: 800,
      bottom: '40%'
    });
  }
}

实战经验新手模式加 Toast,高手模式静默——可用配置切换。本系列第 95 篇会专讲 Toast 提示。

5.3 越界守卫的兜底

// 引擎内越界守卫
dropCat(column: number): boolean {
  if (column < 0 || column >= GameConfig.BOARD_WIDTH) return false;
  /* ... */
}

为什么需要越界守卫

  • handleColumnClickcolumn 来自 ForEach(this.cols, ...),cols = [0,1,2,3,4],理论上不会越界。
  • 防御式编程:万一未来 cols 数组改错,引擎的越界守卫兜底,不会数组越界崩溃。

关键经验系统边界做输入校验——UI 层传入的数据不可信,引擎层兜底校验。

六、完整代码:onClick 列投放

// 来源:entry/src/main/ets/pages/Index.ets
@Entry
@Component
struct Index {
  @State gameState: GameState = GameState.IDLE;
  @State cats: Cat[] = [];
  @State nextCatLevel: CatLevel = CatLevel.SMALL;
  /* ... 其他 state */

  private gameEngine: GameEngine = new GameEngine();
  private readonly cols: number[] = [0, 1, 2, 3, 4];

  // 列投放处理函数(本篇重点)
  handleColumnClick(column: number) {
    // 步骤一:状态守卫
    if (this.gameState !== GameState.PLAYING) return;

    // 步骤二:引擎投放 + 步骤三:同步 state
    if (this.gameEngine.dropCat(column)) {
      this.cats = this.gameEngine.getAllCats();
      this.nextCatLevel = this.gameEngine.getNextCatLevel();
    }
    // 投放失败静默忽略
  }

  @Builder
  GameView() {
    Column() {
      this.GameHUD()

      Column() {
        // 预告区
        Row() { /* ... */ }

        // 游戏棋盘 Stack(3 层叠)
        Stack() {
          // 第 1 层:棋盘背景网格
          Column() { /* ForEach rows × cols */ }

          // 第 2 层:猫咪渲染
          ForEach(this.cats, (cat: Cat) => {
            Column() { /* ... */ }
              .position({ x: ..., y: ... })
              .animation({ duration: 100 })
          }, (cat: Cat) => cat.id)

          // 第 3 层:列点击层(onClick 本篇重点)
          Row() {
            ForEach(this.cols, (col: number) => {
              Column()
                .width(GameConfig.CELL_SIZE)
                .height(GameConfig.BOARD_HEIGHT * GameConfig.CELL_SIZE)
                .backgroundColor('rgba(0,0,0,0)')
                .onClick(() => {
                  this.handleColumnClick(col);     // 闭包捕获 col
                })
            }, (col: number) => `click_${col}`)
          }
        }
        .width(GameConfig.BOARD_WIDTH * GameConfig.CELL_SIZE)
        .height(GameConfig.BOARD_HEIGHT * GameConfig.CELL_SIZE)
        .borderRadius(12)
        .clip(true)
        .backgroundColor('#D6EEF5')
      }
      .alignItems(HorizontalAlign.Center)

      Spacer()

      // 底部控制栏
      Row() { /* 暂停 / 重新开始 */ }
        .width('100%')
        .padding({ left: 24, right: 24, bottom: 24, top: 12 })
    }
    .width('100%').height('100%')
    .linearGradient({
      direction: GradientDirection.Bottom,
      colors: [['#E8F4F8', 0.0], ['#D6EEF5', 0.5], ['#C9E8F2', 1.0]]
    })
    .alignItems(HorizontalAlign.Center)
  }
}

七、踩坑提示

7.1 onClick 用普通函数丢 this

// ❌ 错误:普通函数 this 不指向组件
.onClick(function () {
  this.handleColumnClick(col);   // this 是 Column,不是 Index
})

// ✅ 正确:箭头函数保留外层 this
.onClick(() => {
  this.handleColumnClick(col);
})

7.2 忘记状态守卫,暂停时还能投放

// ❌ 错误:没守卫,暂停时点击还能投放
handleColumnClick(column: number) {
  if (this.gameEngine.dropCat(column)) { /* ... */ }
}

// ✅ 正确:守卫在前
handleColumnClick(column: number) {
  if (this.gameState !== GameState.PLAYING) return;
  if (this.gameEngine.dropCat(column)) { /* ... */ }
}

7.3 忘同步 nextCatLevel,预告区不准

// ❌ 错误:只同步 cats,没同步 nextCatLevel
if (this.gameEngine.dropCat(column)) {
  this.cats = this.gameEngine.getAllCats();
  // 忘了 this.nextCatLevel = this.gameEngine.getNextCatLevel();
}
// 预告区显示的还是上一只的等级,不准

// ✅ 正确:同步两个 state
if (this.gameEngine.dropCat(column)) {
  this.cats = this.gameEngine.getAllCats();
  this.nextCatLevel = this.gameEngine.getNextCatLevel();
}

7.4 UI 层直接改棋盘,绕过引擎

// ❌ 错误:UI 层直接 push 猫到数组
handleColumnClick(column: number) {
  const cat: Cat = { /* ... */ };
  this.cats.push(cat);      // 引擎的 board 和 cats Map 没更新!
}

// ✅ 正确:调引擎,引擎改数据
handleColumnClick(column: number) {
  if (this.gameEngine.dropCat(column)) {
    this.cats = this.gameEngine.getAllCats();
    this.nextCatLevel = this.gameEngine.getNextCatLevel();
  }
}

八、调试技巧

  1. console.info 打 column:onClick 回调里 log 'click col', col,追点击分发。
  2. 点击无响应排查:检查 gameState === PLAYING;检查上层是否有弹窗 Stack 挡住;检查 .hitTestBehavior
  3. 投放后猫不显示:检查 this.cats = this.gameEngine.getAllCats() 是否调用;检查 ForEach 密钥 cat.id 是否冲突。
  4. 预告区不准:检查 this.nextCatLevel = this.gameEngine.getNextCatLevel() 是否调用。

九、性能与最佳实践

  1. onClick 用箭头函数保留 this——普通函数 this 不指向组件。
  2. ForEach 闭包按值捕获 col——每列绑定到自己的 col,不共享。
  3. handleColumnClick 三步流程——守卫 → 引擎投放 → 同步 state。
  4. MVC 分层——UI 只调引擎 + 同步,不直接改棋盘。
  5. 投放失败静默处理——消除类游戏列满是常态,静默最不打扰。
  6. 引擎越界守卫兜底——UI 传入数据不可信,引擎层校验。

总结

本篇我们从 onClick 列投放切入,掌握了onClick 回调与闭包按值捕获 colhandleColumnClick 三步流程(守卫→投放→同步)MVC 分层(UI 调引擎 + 同步 state)投放失败的静默处理四大要点,并给出了完整 onClick 列投放代码。核心要点:闭包按值捕获列号;UI 只调引擎不写逻辑;投放失败静默;引擎越界守卫兜底

下一篇我们将专讲箭头函数 this 绑定——callback 里 this 的核心机制。

如果这篇文章对你有帮助,欢迎点赞👍、收藏⭐、关注🔔,你的支持是我持续创作的动力!


相关资源:

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐