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

一、引言

1.1 为什么用游戏学开发?

在移动开发的学习路径中,纯工具类应用(列表、表单、设置页)虽然实用,但往往缺乏"趣味感"。而小游戏类应用恰好处于"有趣"和"有料"的交叉点——它们看起来轻松好玩,但背后涉及的开发技术一点也不简单。

翻卡(Flip Card)正是这样一个入门友好、深度可观的项目:

  • 入门门槛低:只需一个页面、一组卡片、两种状态
  • 技术覆盖面广:布局、组件化、状态管理、算法、动画
  • 容易扩展:从"随意翻"到"记忆配对"只需要增加少量逻辑

1.2 本文目标

本文通过构建一个完整的翻卡应用(168 行代码),系统性地讲解以下技术点:

技术领域 核心知识点 难度
Grid 布局 columnsTemplate、rowsGap、GridItem、动态列数 ⭐⭐
组件化 @Component、@Prop 单向数据流、回调通信 ⭐⭐⭐
状态管理 @State、不可变数据更新、版本号驱动键值 ⭐⭐⭐
条件渲染 if/else、三目运算符切换属性 ⭐⭐
数据算法 Fisher-Yates 无偏洗牌 ⭐⭐
组件通信 父传子(@Prop)、子传父(回调) ⭐⭐⭐

1.3 应用效果

┌─────────────────────────────────────┐
│  翻卡                                 │
│  点击卡片翻转 · Grid 布局             │
│                                      │
│  [3×3] [4×4] [5×5]        [🔄] [👁️] │
│  4×4 = 16 张              翻牌 0 次   │
│                                      │
│  ┌──┐ ┌──┐ ┌──┐ ┌──┐                │
│  │? │ │? │ │? │ │? │                │
│  └──┘ └──┘ └──┘ └──┘                │
│  ┌──┐ ┌──┐ ┌──┐ ┌──┐                │
│  │? │ │? │ │? │ │? │                │
│  └──┘ └──┘ └──┘ └──┘                │
│  ┌──┐ ┌──┐ ┌──┐ ┌──┐                │
│  │? │ │? │ │? │ │? │                │
│  └──┘ └──┘ └──┘ └──┘                │
│  ┌──┐ ┌──┐ ┌──┐ ┌──┐                │
│  │? │ │? │ │? │ │? │                │
│  └──┘ └──┘ └──┘ └──┘                │
│                                      │
│  点击卡片翻转 · 再点击翻回             │
└─────────────────────────────────────┘

点击任意紫色问号卡片 → 翻转显示彩色动物 emoji。控制栏支持切换网格尺寸、重置洗牌、全部翻阅。


二、项目架构设计

2.1 整体结构

翻卡应用的代码分为三个逻辑层次:

┌──────────────────────────────────────┐
│  页面层:FlipCardGame (@Entry)         │
│  ├── 状态管理(5 个 @State 变量)      │
│  ├── 业务逻辑(newGame/toggleCard)    │
│  └── 主布局 build()                   │
├──────────────────────────────────────┤
│  组件层:CardItem (@Component)         │
│  ├── @Prop 接收数据                   │
│  ├── 卡片 UI(正面/背面)              │
│  └── cardClick 回调                   │
├──────────────────────────────────────┤
│  数据层:CardData (interface)          │
│  ├── id:唯一标识                     │
│  ├── emoji:显示图标                  │
│  └── flipped:翻转状态                │
└──────────────────────────────────────┘

这种分层让每个模块的职责清晰:

  • 页面层:管理全局状态、处理交互逻辑
  • 组件层:纯 UI 展示、通过 Prop 接收数据
  • 数据层:定义数据结构、不包含业务逻辑

2.2 数据流

用户点击卡片
    ↓
CardItem.cardClick()  →  FlipCardGame.toggleCard(idx)
    ↓
创建新 cards 数组         version++
    ↓
this.cards = newArr     this.version++
    ↓                    ↓
@State 更新触发 UI 重新渲染
    ↓
ForEach 检测到键值变化(含版本号)
    ↓
创建新 CardItem 实例,传入最新 @Prop
    ↓
卡片显示 emoji(正面)或 ?(背面)

2.3 组件树

Column(根容器)
├── Text(标题)
├── Text(副标题)
├── Row(控制栏)
│   ├── Row(尺寸切换)
│   │   ├── Text('3×3')
│   │   ├── Text('4×4')
│   │   └── Text('5×5')
│   └── Row(操作按钮)
│       ├── Text('🔄')
│       └── Text('👁️')
├── Row(统计栏)
├── Grid(卡片网格)
│   ├── GridItem → CardItem
│   ├── GridItem → CardItem
│   ├── ...
│   └── GridItem → CardItem
└── Text(底部提示)

三、Grid 布局详解

3.1 Grid 的核心概念

Grid 是 ArkUI 提供的二维网格布局容器。它与 Column(一维纵向)和 Row(一维横向)不同,同时在行和列两个维度上排列子组件。

翻卡游戏的卡片排列是最典型的 Grid 应用场景——卡片按行列均匀分布,每张卡片的大小一致。

3.2 columnsTemplate:列模板

columnsTemplate 是 Grid 最核心的属性。它通过一个字符串来定义列的宽度:

// 4 列等宽
.columnsTemplate('1fr 1fr 1fr 1fr')

// 3 列,中间列是两侧的 2 倍
.columnsTemplate('1fr 2fr 1fr')

// 混合:第一列 80vp,剩余弹性分配
.columnsTemplate('80vp 1fr 1fr')

fr 单位的含义

fr(fraction,份)是 Grid 的弹性单位,表示"剩余空间的一份"。当 Grid 宽度减去固定宽度列后,剩余空间按 fr 值的比例分配:

  • '1fr 1fr 1fr 1fr' → 4 列,每列占总宽度的 1/4
  • '1fr 2fr 1fr' → 3 列,中间列是两侧的 2 倍宽
  • '1fr 1fr' → 2 列,各占 1/2

3.3 动态列模板

翻卡游戏需要支持 3×3、4×4、5×5 三种网格尺寸,对应的列模板也不同:

.columnsTemplate('1fr '.repeat(this.gridSize).trim())

'1fr '.repeat(n) 生成 n 个 '1fr ',然后用 .trim() 去掉末尾空格:

  • gridSize = 3'1fr 1fr 1fr'
  • gridSize = 4'1fr 1fr 1fr 1fr'
  • gridSize = 5'1fr 1fr 1fr 1fr 1fr'

当用户点击 [5×5] 按钮时,this.gridSize = 5,Grid 自动重新布局为 5 列。

3.4 间距控制

.rowsGap(8)      // 行间距 8vp
.columnsGap(8)   // 列间距 8vp

两个属性分别控制行和列之间的间距。8vp 是经过视觉测试的舒适值:

  • 间距过小(<4vp)→ 卡片黏在一起,缺乏呼吸感
  • 间距过大(>16vp)→ 浪费屏幕空间,整体松散

对比不使用间距的情况:

间距 视觉效果 适用场景
0vp 卡片紧贴,像一整块 图片墙、无缝拼图
4~8vp 有分隔感,清晰整齐 翻卡、功能宫格
12~16vp 卡片独立感强 卡片式信息流

3.5 GridItem:网格项

每个子项必须用 GridItem 包裹:

Grid() {
  ForEach(this.cards, (card: CardData, idx: number) => {
    GridItem() {
      CardItem({ ... })
    }
  }, /* key */)
}

GridItem 自动按 columnsTemplate 指定的列数排列。当 GridItem 数量超过列数时自动折行,并扩展 Grid 的高度。

3.6 Grid 容器的尺寸

.width('100%').height(380)

Grid 设置了固定高度 380vp,原因有二:

  1. 确保所有卡片可见——如果不设高度,Grid 可能被其他组件挤压
  2. 统一不同网格尺寸的视觉区域——3×3、4×4、5×5 都在同一区域

Grid 容器本身也做了视觉包装:

.padding(12)
.backgroundColor('#f0f0f0').borderRadius(16)
.margin({ left: 12, right: 12 })
  • 内边距让卡片不贴边
  • 浅灰背景 + 圆角,与白色卡片形成层次
  • 左右外边距与页面其他元素对齐

3.7 aspectRatio:保持正方形

CardItem 中使用 .aspectRatio(1) 确保卡片始终是正方形:

.width('100%').aspectRatio(1)

aspectRatio(1) 表示宽高比 1:1。宽度由 Grid 列宽决定,高度自动等于宽度。这个设置保证了:

  • 无论 Grid 多宽,卡片始终是正方形
  • 3×3 时卡片较大,5×5 时卡片较小——自动适配
  • 不需要手动计算卡片高度

3.8 Grid 属性汇总

属性 类型 作用
columnsTemplate string '1fr 1fr 1fr 1fr' 定义 4 列等宽
rowsGap number 8 行间距 8vp
columnsGap number 8 列间距 8vp
padding Padding 12 容器内边距
backgroundColor Color ‘#f0f0f0’ 灰色背景
borderRadius number 16 容器圆角
width Length ‘100%’ 铺满父容器
height Length 380 固定高度
margin Margin left:12, right:12 水平外边距

四、组件化设计:CardItem

4.1 为什么选择 @Component?

最初的版本使用 @Builder 封装卡片 UI:

@Builder
CardView(card: CardData, idx: number) {
  Stack() {
    if (!card.flipped) { /* 背面 */ }
    if (card.flipped) { /* 正面 */ }
  }
  .onClick(() => { this.flipCard(idx) })
}

@Builder 在 Grid 的 ForEach 中存在闭包上下文绑定问题——@Builder 内部捕获的 thisidx 可能在多次渲染后失效,导致点击事件不触发或数据不更新。

改为独立 @Component 后:

@Component
struct CardItem {
  @Prop emoji: string = '';
  @Prop flipped: boolean = false;
  cardClick?: () => void;

  build() { /* 卡片 UI */ }
}

@Component vs @Builder 的关键区别:

对比维度 @Builder @Component
数据绑定 闭包捕获 @Prop 装饰器
生命周期 有完整的组件生命周期
复用性 中等 高(可在多处使用)
性能 轻量 略重但有优化空间
调试 困难(无独立标识) 容易(独立组件)

对于翻卡这种每个卡片独立交互的场景,@Component 的 @Prop 数据绑定比 @Builder 的闭包捕获更可靠。

4.2 @Prop 单向数据流

@Prop 是 ArkUI 的单向数据流装饰器

@Component
struct CardItem {
  @Prop emoji: string = '';     // 从父组件传入
  @Prop flipped: boolean = false; // 从父组件传入
  cardClick?: () => void;        // 回调
}

数据流向:

父组件 @State cards 变化
  → ForEach 生成新的 CardItem({ emoji, flipped })
  → ArkUI 检测到 @Prop 值变化
  → CardItem 重新渲染
  → if/else 根据 flipped 显示对应 UI

关键特性:

特性 说明
单向 数据从父流向子,子不能直接修改 prop
响应式 prop 变化时子组件自动更新
类型安全 编译时检查类型匹配
默认值 可以设置默认值(如 emoji: string = ''

4.3 回调通信

ArkUI 的组件间通信模式是**“父传子用 Prop,子传父用回调”**:

// 父组件传递回调
CardItem({
  emoji: card.emoji,
  flipped: card.flipped,
  cardClick: () => { this.toggleCard(idx) }
})

// 子组件执行回调
.onClick(() => {
  if (this.cardClick) { this.cardClick() }
})

为什么不能直接用 onClick

ArkUI 的 Component 基类已经定义了 onClick 事件属性。如果在子组件中声明同名的属性,会导致编译冲突:

// ❌ 编译错误
struct CardItem {
  onClick?: () => void;  // Property 'onClick' in type 'CardItem'
}                        // is not assignable to the same property
                         // in base type 'CustomComponent'

解决方案:使用自定义名称,如 cardClickonCardClicktapAction 等。

4.4 CardItem 完整实现

@Component
struct CardItem {
  @Prop emoji: string = '';
  @Prop flipped: boolean = false;
  cardClick?: () => void;

  build() {
    Column() {
      if (this.flipped) {
        // 正面:显示 emoji
        Text(this.emoji).fontSize(36)
      } else {
        // 背面:显示问号
        Text('?').fontSize(28)
          .fontWeight(FontWeight.Bold).fontColor('#fff')
      }
    }
    .width('100%').aspectRatio(1)
    .backgroundColor(this.flipped ? '#fff' : '#6C5CE7')
    .borderRadius(14)
    .border(this.flipped ? { width: 2, color: '#6C5CE7' } : { width: 0 })
    .shadow({
      radius: this.flipped ? 4 : 8,
      color: this.flipped ? '#15000000' : '#306C5CE7',
      offsetY: this.flipped ? 2 : 4
    })
    .onClick(() => {
      if (this.cardClick) { this.cardClick() }
    })
  }
}

整个组件没有复杂的逻辑——只是根据 @Prop 的值渲染对应 UI。这就是"纯 UI 组件"的最佳实践:组件只做展示,不做决策

4.5 属性切换的三目运算技巧

注意代码中的样式切换:

.backgroundColor(this.flipped ? '#fff' : '#6C5CE7')
.border(this.flipped ? { width: 2, color: '#6C5CE7' } : { width: 0 })
.shadow({
  radius: this.flipped ? 4 : 8,
  color: this.flipped ? '#15000000' : '#306C5CE7',
  offsetY: this.flipped ? 2 : 4
})

所有样式属性都通过三目运算在同一组件上切换,而不是用 if/else 渲染两套不同的 Column。这种方式的优点是:

  1. 组件实例不变——同一 Column 只改变属性,不销毁重建
  2. 动画友好——属性变化比组件替换更容易做过渡
  3. 代码简洁——不需要重复写两套布局

五、状态管理与键值策略

5.1 @State 变量设计

翻卡应用有 5 个 @State 变量:

@State gridSize: number = 4;     // 网格尺寸
@State cards: CardData[] = [];   // 卡片数据
@State allOpen: boolean = false; // 是否全部翻开
@State moves: number = 0;        // 翻牌计数
@State version: number = 0;      // 版本号

每个变量的职责单一,互不重叠:

变量 类型 初始值 影响范围
gridSize number 4 Grid 列数、卡片总数
cards CardData[] [] 所有卡片的 emoji 和 flipped
allOpen boolean false toggleAll 的模式状态
moves number 0 UI 显示的翻牌次数
version number 0 ForEach 键值

5.2 不可变数据更新

ArkUI 通过引用比较来检测 @State 数据是否变化。直接修改数组元素不会触发更新:

// ❌ 错误的做法
this.cards[idx].flipped = true  // 引用没变,UI 不更新

// ✅ 正确的做法
let newList: CardData[] = []
for (let i = 0; i < this.cards.length; i++) {
  newList.push({ /* 新对象 */ })
}
this.cards = newList  // 引用变了,UI 更新

这是 ArkTS 不可变数据更新的核心原则——永远创建新对象/新数组来替换旧数据,而不是修改旧数据

toggleCard 的实现:

toggleCard(idx: number): void {
  if (this.allOpen) { return }
  let list: CardData[] = []
  for (let i = 0; i < this.cards.length; i++) {
    let c: CardData = this.cards[i]
    if (i === idx) {
      // 目标卡片:flipped 取反
      list.push({ id: c.id, emoji: c.emoji, flipped: !c.flipped })
    } else {
      // 其他卡片:保持原样
      list.push({ id: c.id, emoji: c.emoji, flipped: c.flipped })
    }
  }
  this.cards = list     // 新数组触发 UI 更新
  this.moves++          // 计数
  this.version++        // 版本号递增
}

5.3 版本号驱动的键值策略

这是翻卡应用最关键的技术保障

背景问题:当 @State cards 更新时,ForEach 通过键值来追踪哪些 GridItem 需要重建。如果键值不变,ArkUI 会复用 GridItem 实例。但在某些场景下(特别是 Grid + 条件渲染),复用导致 UI 不更新。

解决方案:在键值中加入版本号,每次数据变化都递增:

ForEach(
  this.cards,
  (card, idx) => {
    GridItem() {
      CardItem({ emoji: card.emoji, flipped: card.flipped, ... })
    }
  },
  // 键值 = 卡片 ID + 版本号
  (card: CardData) => 'c' + card.id + '_v' + this.version
)

工作原理:

初始状态:this.version = 0
键值示例:'c0_v0', 'c1_v0', 'c2_v0', ...

用户点击卡片 0 → toggleCard(0) → this.version = 1
新键值:'c0_v1', 'c1_v1', 'c2_v1', ...(全部变化)
ForEach 检测到所有键值变化 → 销毁旧 GridItem → 创建新 GridItem
新 GridItem 拿到 card.flipped = true → 显示 emoji

版本号强制 ForEach 认为"所有数据都变了",从而彻底重建所有 GridItem。

性能考量:翻卡游戏最多 25 张卡片(5×5),完全重建的开销完全可以接受。

5.4 对比其他方案

方案 原理 优点 缺点
版本号键值 每次重建所有 GridItem 100% 可靠 轻微性能开销
仅用 ID 键值 复用 GridItem,更新 @Prop 性能好 某些场景 UI 不更新
强制刷新 用额外 @State 触发全量刷新 可靠 代码冗余
不使用 ForEach 手写 GridItem 完全控制 无法动态扩展

翻卡应用选择版本号方案,是因为在可靠性 > 微性能优化的场景下,确保功能正确是第一优先级。


六、业务逻辑实现

6.1 newGame:初始化游戏

newGame(): void {
  let total: number = this.gridSize * this.gridSize
  let pool: string[] = []

  // 1. 从 emoji 池中取对应数量的图标
  for (let i = 0; i < total; i++) {
    pool.push(this.emojis[i % this.emojis.length])
  }

  // 2. Fisher-Yates 洗牌
  for (let i = pool.length - 1; i > 0; i--) {
    let j: number = Math.floor(Math.random() * (i + 1))
    let tmp: string = pool[i]
    pool[i] = pool[j]
    pool[j] = tmp
  }

  // 3. 创建卡片数组
  let list: CardData[] = []
  for (let i = 0; i < total; i++) {
    list.push({ id: i, emoji: pool[i], flipped: false })
  }

  // 4. 更新状态
  this.cards = list
  this.allOpen = false
  this.moves = 0
  this.version++
}

流程四个步骤:

步骤 1:取图标

  • 计算卡片总数(gridSize × gridSize)
  • 从 emoji 数组按序循环取对应数量的图标
  • 如果 4×4=16 张,取前 16 个 emoji

步骤 2:洗牌

  • 使用 Fisher-Yates 算法打乱图标顺序
  • 确保每次游戏排列不同

步骤 3:创建卡片

  • 按打乱后的顺序创建 CardData 对象
  • 所有卡片初始为 flipped: false(背面朝上)

步骤 4:更新状态

  • 赋值新数组触发 UI 更新
  • 重置计数器和版本号

6.2 Fisher-Yates 洗牌算法

for (let i = pool.length - 1; i > 0; i--) {
  let j: number = Math.floor(Math.random() * (i + 1))
  let tmp: string = pool[i]
  pool[i] = pool[j]
  pool[j] = tmp
}

算法可视化(4 个元素的洗牌过程):

初始:[A, B, C, D]
i=3: 从 [0,3] 随机选 j,假设 j=1 → 交换 pool[3]↔pool[1] → [A, D, C, B]
i=2: 从 [0,2] 随机选 j,假设 j=0 → 交换 pool[2]↔pool[0] → [C, D, A, B]
i=1: 从 [0,1] 随机选 j,假设 j=1 → 交换 pool[1]↔pool[1] → [C, D, A, B](不变)
结果:[C, D, A, B]

为什么不用 sort(() => Math.random() - 0.5)

Array.sort + Math.random 的洗牌方式存在分布不均匀的问题——某些排列出现的概率显著高于其他排列。Fisher-Yates 是业界公认的无偏(unbiased)洗牌算法,每个排列的概率相等。

6.3 toggleCard:翻转单张

toggleCard(idx: number): void {
  if (this.allOpen) { return }        // 全部翻开模式禁止单张操作
  let list: CardData[] = []
  for (let i = 0; i < this.cards.length; i++) {
    let c: CardData = this.cards[i]
    if (i === idx) {
      list.push({ id: c.id, emoji: c.emoji, flipped: !c.flipped })
    } else {
      list.push({ id: c.id, emoji: c.emoji, flipped: c.flipped })
    }
  }
  this.cards = list
  this.moves++
  this.version++
}

执行流程:

  1. 检查 allOpen——如果全部翻开,不允许单张操作
  2. 遍历当前卡片数组
  3. 目标卡片(idx)→ flipped 取反
  4. 其他卡片 → flipped 保持不变
  5. 新数组赋值 → 更新计数器和版本号

6.4 toggleAll:全部翻开/翻回

toggleAll(): void {
  this.allOpen = !this.allOpen
  let list: CardData[] = []
  for (let i = 0; i < this.cards.length; i++) {
    let c: CardData = this.cards[i]
    list.push({ id: c.id, emoji: c.emoji, flipped: this.allOpen })
  }
  this.cards = list
  this.version++
}

逻辑简单直接:

  • allOpen 取反
  • 所有卡片的 flipped 设为同一个值(allOpen 的新值)
  • 版本号递增

由于 allOpen 是交替的 true/false,第一次点击是"全部翻开",第二次点击是"全部翻回"。


七、控制面板与布局

7.1 标题区

Text('翻卡').fontSize(28).fontWeight(FontWeight.Bold).fontColor('#1a1a2e')
  .width('100%').textAlign(TextAlign.Center).padding({ top: 44 })

Text('点击卡片翻转 · Grid 布局')
  .fontSize(12).fontColor('#aaa').width('100%').textAlign(TextAlign.Center)

标题居中、粗体、深色,副标题灰色小字。padding({ top: 44 }) 为系统状态栏留出空间。

7.2 网格尺寸切换

Row({ space: 6 }) {
  ForEach([3, 4, 5], (n: number) => {
    Text(n + '×' + n).fontSize(13)
      .fontColor(n === this.gridSize ? '#fff' : '#666')
      .padding({ left: 14, right: 14, top: 5, bottom: 5 })
      .backgroundColor(n === this.gridSize ? '#6C5CE7' : '#eee')
      .borderRadius(8)
      .onClick(() => { this.gridSize = n; this.newGame() })
  }, (n: number) => n.toString())
}

三个 Chip 风格按钮,选中态紫色高亮与非选中态灰色形成对比。点击后:

  1. this.gridSize = n — Grid 列模板自动更新
  2. this.newGame() — 重新生成卡片数据

为什么切换网格尺寸时需要重新生成?
因为卡片总数变了——3×3=9 张、4×4=16 张、5×5=25 张。需要重新取图标、重新洗牌。

7.3 操作按钮

Row({ space: 6 }) {
  Text('🔄').fontSize(16).padding(6)
    .backgroundColor('#f0f0f0').borderRadius(8)
    .onClick(() => { this.newGame() })
  Text('👁️').fontSize(16).padding(6)
    .backgroundColor('#f0f0f0').borderRadius(8)
    .onClick(() => { this.toggleAll() })
}

两个按钮使用 emoji 作为图标,功能一目了然:

  • 🔄(刷新)→ 重置游戏:重新洗牌、翻牌计数归零
  • 👁️(眼睛)→ 全部翻转:翻开或翻回所有卡片

7.4 统计栏

Row() {
  Text(this.gridSize + '×' + this.gridSize + ' = ' + (this.gridSize * this.gridSize) + ' 张')
    .fontSize(12).fontColor('#999')
  Text('翻牌 ' + this.moves + ' 次 | v' + this.version)
    .fontSize(12).fontColor('#999')
}

左侧显示网格信息(如 “4×4 = 16 张”),右侧显示翻牌计数和版本号。版本号对用户无意义,但在开发调试时可以直接看到每次操作后版本号是否递增。

7.5 底部提示

Text('点击卡片翻转 · 再点击翻回').fontSize(11).fontColor('#ddd').padding(8)

通过灰度极低的文字提供操作引导,不抢夺视觉焦点。


八、条件渲染与样式设计

8.1 双面切换

卡片的正反面通过 if/else 条件渲染实现:

if (this.flipped) {
  Text(this.emoji).fontSize(36)       // 正面
} else {
  Text('?').fontSize(28)
    .fontWeight(FontWeight.Bold).fontColor('#fff')  // 背面
}

@Prop flippedtrue 时显示 emoji,为 false 时显示问号。

8.2 样式三目运算

与文字内容的 if/else 不同,样式属性使用三目运算在同一组件上切换:

.backgroundColor(this.flipped ? '#fff' : '#6C5CE7')
.border(this.flipped ? { width: 2, color: '#6C5CE7' } : { width: 0 })
.shadow({
  radius: this.flipped ? 4 : 8,
  color: this.flipped ? '#15000000' : '#306C5CE7',
  offsetY: this.flipped ? 2 : 4
})

为什么样式用三目、内容用 if/else?

  • 样式是同一组件的不同属性值——适合三目
  • 内容是不同的 Text 节点——Text 的特性不同(emoji vs 文字,字号不同),用 if/else 更清晰

8.3 卡片正背面对比

设计元素 背面(未翻转) 正面(翻转后)
背景色 紫色 #6C5CE7 白色 #fff
显示内容 问号 ? 随机 emoji
内容字号 28fp 36fp
内容颜色 白色 #fff emoji 自带
边框 2vp 紫色边框
阴影半径 8vp(较大) 4vp(较小)
阴影颜色 紫透 #306C5CE7 灰透 #15000000
阴影偏移 4vp 2vp

背面的紫色 + 白色问号营造"神秘感",正面的 emoji + 白色背景 + 紫色边框形成"揭示感"。阴影的变化也增加了交互反馈——背面阴影更明显,暗示"可以点"。


九、常见问题与调试

9.1 翻牌计数增加但卡片不变

症状moves 数值变化,但卡片始终显示问号,不显示 emoji。

根因分析

  1. toggleCard 确实被调用——否则 moves 不会变化
  2. @State cards 也更新了——新数组已赋值
  3. 但 ForEach + GridItem 没有重新渲染

为什么 ForEach 没重新渲染?
当 ForEach 的键值不变时,ArkUI 会复用 GridItem 实例。如果 GridItem 内部的 if/else 条件渲染在首次渲染后没有重新评估,就会出现"数据变了但 UI 不变"。

解决方案

  1. 在键值中加入版本号——每次操作递增,强制重建
  2. 使用独立 @Component + @Prop——确保子组件响应 prop 变化
  3. 避免在 @Builder 中直接使用闭包变量——改用回调 prop

9.2 点击无响应

症状:点击卡片没有任何反应(moves 不增加,卡片不变)。

排查步骤

步骤 检查项 可能原因
1 onClick 是否在 GridItem 上? onClick 放错了层级
2 cardClick 回调是否传递? 父组件未传递回调
3 this 是否正确绑定? 闭包中的 this 丢失
4 allOpen 是否为 true? 全部翻开模式禁用了单张操作

9.3 切换网格尺寸后卡片数量不对

症状:切换到 5×5 后,Grid 显示少于 25 张卡片。

排查

  1. newGame() 是否被调用?——检查控制台日志
  2. total = this.gridSize * this.gridSize 计算是否正确?
  3. Grid 高度 380vp 是否足够容纳 5×5 卡片?

5×5 卡片单张高度约 (380 - 24 - 32) / 5 ≈ 65vp,加上 8vp gap,足够显示。

9.4 回调命名冲突

症状:编译报错 Property 'onClick' in type 'X' is not assignable to the same property in base type 'CustomComponent'

原因:ArkUI 的 Component 基类已经内置了 onClick 属性,子组件不能重新声明。

修复:改名为 cardClickonCardClicktapAction 等。

9.5 UI 闪烁或白屏

症状:切换网格或重置时页面短暂闪烁。

原因:Grid 重建过程中,旧组件销毁与新组件创建之间有短暂间隙。

缓解方法

  1. 避免不必要的重建——版本号只在必要时递增
  2. Grid 设置背景色(#f0f0f0),即使没有卡片内容也显示灰色区域
  3. 使用 .clip(true) 裁剪超出内容

十、Grid vs 其他布局方案

10.1 Grid vs Flex + FlexWrap

// Flex + FlexWrap 方式
Flex({ wrap: FlexWrap.Wrap }) {
  ForEach(cards, card => {
    CardItem(card)
  })
}

// Grid 方式
Grid() {
  ForEach(cards, card => {
    GridItem() { CardItem(card) }
  })
}
.columnsTemplate('1fr 1fr 1fr 1fr')
对比 Flex + Wrap Grid
列对齐 松散对齐 严格对齐
列宽控制 由内容决定 精确模板
空白区域 可能有 无(平分空间)
行列间距 rowGap rowsGap + columnsGap
适合场景 标签、自适应流 宫格、相册

结论:对于翻卡这种需要严格行列对齐的场景,Grid 是正确答案。

10.2 Grid vs Column + Row 嵌套

// Column + Row 方式
Column({ space: 8 }) {
  Row({ space: 8 }) {
    ForEach(row1, item => CardItem(item))
  }
  Row({ space: 8 }) {
    ForEach(row2, item => CardItem(item))
  }
}

Column+Row 的痛点

  1. 需要手动将卡片数组分行为多个子数组
  2. 列数变化时需要重新分组
  3. 代码量与列数成正比

Grid 用一行 columnsTemplate 就解决了以上所有问题。


十一、扩展方向

11.1 记忆配对游戏

最简单的功能扩展——从"随意翻"变为"成对匹配":

数据准备修改:

// 成对出现
let pairs = total / 2
for (let i = 0; i < pairs; i++) {
  pool.push(this.emojis[i])
  pool.push(this.emojis[i])  // 每张 emoji 出现两次
}
// 然后洗牌...

逻辑修改:

@State firstPick: number = -1
@State secondPick: number = -1

matchFlip(idx: number): void {
  if (this.firstPick === -1) {
    this.firstPick = idx
    this.flipCard(idx)
  } else if (this.secondPick === -1 && idx !== this.firstPick) {
    this.secondPick = idx
    this.flipCard(idx)
    // 延迟检查
    setTimeout(() => this.checkMatch(), 800)
  }
}

checkMatch(): void {
  let a = this.cards[this.firstPick]
  let b = this.cards[this.secondPick]
  if (a.emoji === b.emoji) {
    // 匹配成功,保持翻开
  } else {
    // 不匹配,翻回去
    this.flipCard(this.firstPick)
    this.flipCard(this.secondPick)
  }
  this.firstPick = -1
  this.secondPick = -1
}

11.2 计时与计分

@State seconds: number = 0
@State score: number = 0
private timer: number = -1

startGame(): void {
  this.newGame()
  this.seconds = 0
  this.score = 0
  this.timer = setInterval(() => { this.seconds++ }, 1000)
}

endGame(): void {
  clearInterval(this.timer)
  this.score = Math.max(1000 - this.seconds * 10, 0)
}

11.3 主题切换

private themes: string[][] = [
  ['🐶', '🐱', '🐼', '🦊', '🐸', '🐵', '🦁', '🐯'],  // 动物
  ['🍎', '🍕', '🍔', '🍩', '🍪', '🧁', '🍦', '🍫'],  // 食物
  ['⚽', '🏀', '🎾', '🏈', '⚾', '🎱', '🏓', '🥊'],  // 运动
  ['🇨🇳', '🇺🇸', '🇬🇧', '🇯🇵', '🇰🇷', '🇫🇷', '🇩🇪', '🇮🇹'],  // 国旗
]

@State themeIndex: number = 0

// 在 newGame 中使用当前主题
pool.push(this.themes[this.themeIndex][i % this.themes[this.themeIndex].length])

十二、小结

12.1 核心知识点回顾

技术点 实现方式 用途
Grid 布局 columnsTemplate + GridItem 卡片网格排列
组件化 @Component + @Prop CardItem 独立封装
单向数据流 Prop 传入、回调传出 父→子数据、子→父事件
状态管理 @State + 不可变更新 数据驱动 UI
键值策略 版本号 + ID 组合键 强制 ForEach 重建
条件渲染 if/else + 三目运算 正面/背面切换
洗牌算法 Fisher-Yates 无偏打乱顺序
网格尺寸 '1fr '.repeat(n).trim() 动态列模板

12.2 架构设计原则

翻卡应用的代码虽然只有 168 行,但遵循了以下软件工程原则:

  1. 单一职责:每个函数只做一件事(newGame、toggleCard、toggleAll)
  2. 组件封装:卡片 UI 封装为独立 CardItem,与业务逻辑分离
  3. 数据驱动:所有 UI 变化由 @State 数据驱动,没有直接操作 DOM
  4. 引用透明:纯函数不产生副作用,输入决定输出
  5. 防御性编程if (this.allOpen) return 防止非法操作

12.3 从 168 行到生产应用

这个翻卡应用虽然小巧,但它包含的架构思维可以迁移到更大的项目:

  • 组件化思路 → 将 UI 拆分为独立 Component
  • 状态管理模式 → 用 @State + @Prop 管理数据流
  • 不可变更新 → 避免直接修改状态数据
  • 键值策略 → 确保 ForEach 正确追踪列表项

如果你能完全理解这 168 行代码的每一处设计决策,你就已经掌握了 ArkUI 组件化开发的核心思维模式


附录

A:完整接口定义

interface CardData {
  id: number;        // 唯一标识(0 ~ N-1)
  emoji: string;     // 显示图标(如 '🐶')
  flipped: boolean;  // 是否翻转(true=正面, false=背面)
}

B:Grid 属性速查表

属性 类型 示例 说明
columnsTemplate string '1fr 1fr 1fr' 列宽度模板
rowsGap number 8 行间距
columnsGap number 8 列间距
padding Padding 12 内边距
height Length 380 高度

C:状态变量速查表

变量 类型 初始值 说明
gridSize number 4 网格尺寸
cards CardData[] [] 卡片数据
allOpen boolean false 全部翻开模式
moves number 0 翻牌计数
version number 0 版本号
Logo

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

更多推荐