鸿蒙 ArkUI 实战:翻卡游戏——Grid 布局与组件化开发完全指南
本文介绍了一个基于ArkUI的翻卡游戏开发案例,通过168行代码实现完整的3×3/4×4/5×5网格布局。项目采用分层架构设计,包含页面层(状态管理)、组件层(卡片UI)和数据层(数据结构)。核心技术点包括:1)Grid动态布局(columnsTemplate实现弹性列);2)组件化开发(@Prop数据流与回调通信);3)状态管理(@State驱动UI更新);4)Fisher-Yates洗牌算法;



一、引言
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,原因有二:
- 确保所有卡片可见——如果不设高度,Grid 可能被其他组件挤压
- 统一不同网格尺寸的视觉区域——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 内部捕获的 this 和 idx 可能在多次渲染后失效,导致点击事件不触发或数据不更新。
改为独立 @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'
解决方案:使用自定义名称,如 cardClick、onCardClick、tapAction 等。
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。这种方式的优点是:
- 组件实例不变——同一 Column 只改变属性,不销毁重建
- 动画友好——属性变化比组件替换更容易做过渡
- 代码简洁——不需要重复写两套布局
五、状态管理与键值策略
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++
}
执行流程:
- 检查
allOpen——如果全部翻开,不允许单张操作 - 遍历当前卡片数组
- 目标卡片(idx)→
flipped取反 - 其他卡片 →
flipped保持不变 - 新数组赋值 → 更新计数器和版本号
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 风格按钮,选中态紫色高亮与非选中态灰色形成对比。点击后:
this.gridSize = n— Grid 列模板自动更新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 flipped 为 true 时显示 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。
根因分析:
toggleCard确实被调用——否则moves不会变化@State cards也更新了——新数组已赋值- 但 ForEach + GridItem 没有重新渲染
为什么 ForEach 没重新渲染?
当 ForEach 的键值不变时,ArkUI 会复用 GridItem 实例。如果 GridItem 内部的 if/else 条件渲染在首次渲染后没有重新评估,就会出现"数据变了但 UI 不变"。
解决方案:
- 在键值中加入版本号——每次操作递增,强制重建
- 使用独立
@Component+@Prop——确保子组件响应 prop 变化 - 避免在
@Builder中直接使用闭包变量——改用回调 prop
9.2 点击无响应
症状:点击卡片没有任何反应(moves 不增加,卡片不变)。
排查步骤:
| 步骤 | 检查项 | 可能原因 |
|---|---|---|
| 1 | onClick 是否在 GridItem 上? |
onClick 放错了层级 |
| 2 | cardClick 回调是否传递? |
父组件未传递回调 |
| 3 | this 是否正确绑定? |
闭包中的 this 丢失 |
| 4 | allOpen 是否为 true? |
全部翻开模式禁用了单张操作 |
9.3 切换网格尺寸后卡片数量不对
症状:切换到 5×5 后,Grid 显示少于 25 张卡片。
排查:
newGame()是否被调用?——检查控制台日志total = this.gridSize * this.gridSize计算是否正确?- 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 属性,子组件不能重新声明。
修复:改名为 cardClick、onCardClick、tapAction 等。
9.5 UI 闪烁或白屏
症状:切换网格或重置时页面短暂闪烁。
原因:Grid 重建过程中,旧组件销毁与新组件创建之间有短暂间隙。
缓解方法:
- 避免不必要的重建——版本号只在必要时递增
- Grid 设置背景色(
#f0f0f0),即使没有卡片内容也显示灰色区域 - 使用
.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 的痛点:
- 需要手动将卡片数组分行为多个子数组
- 列数变化时需要重新分组
- 代码量与列数成正比
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 行,但遵循了以下软件工程原则:
- 单一职责:每个函数只做一件事(newGame、toggleCard、toggleAll)
- 组件封装:卡片 UI 封装为独立 CardItem,与业务逻辑分离
- 数据驱动:所有 UI 变化由 @State 数据驱动,没有直接操作 DOM
- 引用透明:纯函数不产生副作用,输入决定输出
- 防御性编程:
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 | 版本号 |
更多推荐

所有评论(0)