鸿蒙 ArkUI 实战:组句子小课堂——面向儿童的交互式语言学习应用
本文介绍了一款面向儿童的组句子学习应用开发过程。该应用采用ArkUI框架,通过Flex布局实现自适应词语按钮网格,使用Scroll组件制作横向分类标签栏。核心功能包括:点击词语按钮添加至句子区,点击已选词语标签可移除,支持清空、退格和句子检查操作。应用设计遵循儿童教育软件的三大原则:趣味性(彩色按钮+emoji反馈)、直观性(简洁交互)和即时反馈(鼓励性弹窗)。技术实现上采用@State管理句子数



一、引言
1.1 当编程遇上教育
移动应用最有价值的用途之一就是教育。而面向儿童的教育应用有其独特的设计哲学——它必须同时满足三个要求:
- 有趣:儿童的注意力持续时间短,应用必须有足够的吸引力
- 直观:儿童不具备复杂的操作能力,交互必须简单明了
- 有反馈:儿童需要即时的正面反馈来维持学习动力
「组句子小课堂」正是基于这些原则设计的。它让小朋友通过点击按钮组合词语来构建句子,在游戏中学习语言组织能力。
1.2 本文目标
读完本文,你将掌握:
- 如何用
Flex+FlexWrap构建适应不同屏幕的词语按钮网格 - 如何用
Scroll实现横向滚动的分类标签栏 - 如何管理一个"句子"数组——添加词语、移除词语、清空、退格
- 如何用
AlertDialog提供鼓励性反馈 - 儿童应用 UI 设计的基本原则和配色方案
- ArkUI 中的数据不可变更新模式
1.3 应用效果预览
┌──────────────────────────────────────────┐
│ 🧒 组句子 小课堂 │
│ 点击词语按钮,组合成完整的句子吧! │
│ │
│ ┌──────────────────────────────────────┐ │
│ │ 我 的 小猫 很 可爱。 │ │
│ │ [我 ✕] [的 ✕] [小猫 ✕] [很 ✕] [可爱 ✕]│ │
│ └──────────────────────────────────────┘ │
│ │
│ [🗑️ 清空] [↩️ 退格] [⭐ 检查] │
│ │
│ [人物] [动作] [物品] [地点] [形容词] [助词] [疑问] │
│ │
│ [我] [你] [他] [她] [它] [我们] │
│ [你们] [他们] [妈妈] [爸爸] [老师] [同学] │
│ [小猫] [小狗] [太阳] [月亮] │
│ │
│ 点击词语加入句子 · 点击句子中的词语可移除 │
└──────────────────────────────────────────┘
二、项目架构
2.1 页面分层
Column(根容器)
├── 标题区(Row)
├── 提示文字
├── 句子展示区(Column + Flex)
│ ├── 空状态提示 / 已组句子 + 词语标签
├── 操作按钮区(Row)
│ ├── 清空、退格、检查
├── 分类标签栏(Scroll + Row)
│ ├── 7 个分类胶囊按钮
├── 词语按钮区(Flex + FlexWrap)
│ ├── 16 个词语按钮(当前分类)
└── 底部提示
2.2 数据模型
@State sentence: string[] = [] // 当前已选的词语列表
@State catIndex: number = 0 // 当前选中的分类索引
@State score: number = 0 // 鼓励计数器
private categories: string[] = ['人物', '动作', '物品', '地点', '形容词', '助词', '疑问']
private words: string[][] = [ /* 7 个分类 × 16 个词语 */ ]
整个应用的核心就是一个词语数组 sentence。用户的操作无非是:
- 向数组尾部添加一个词(点击词语按钮)
- 从数组中移除一个词(点击词语标签)
- 清空整个数组(清空按钮)
- 移除最后一个词(退格按钮)
2.3 数据流
用户点击词语按钮 "小猫"
→ addWord("小猫")
→ sentence = ["我", "的", "小猫"]
→ @State 更新 → UI 重新渲染
→ 句子显示区: "我 的 小猫。"
→ 词语标签区:显示 [我 ✕] [的 ✕] [小猫 ✕]
用户点击词语标签 "小猫 ✕"
→ removeWord(2)
→ sentence = ["我", "的"]
→ @State 更新 → UI 重新渲染
三、Flex 布局与词语按钮
3.1 Flex + FlexWrap 实现词云
词语按钮区域使用 Flex 布局并开启自动换行:
Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.Start }) {
ForEach(this.words[this.catIndex], (word: string, idx: number) => {
Text(word).fontSize(17)
.fontColor('#555').fontWeight(FontWeight.Medium)
.padding({ left: 18, right: 18, top: 10, bottom: 10 })
.backgroundColor('#fff').borderRadius(12)
.shadow({ radius: 4, color: '#08000000', offsetY: 2 })
.margin(5)
.onClick(() => { this.addWord(word) })
}, (word: string) => word)
}
.width('100%').padding(10).layoutWeight(1)
为什么用 Flex 而不是 Grid?
| 对比 | Flex + FlexWrap | Grid |
|---|---|---|
| 换行行为 | 自动换行,按内容宽度排列 | 严格按列数排列 |
| 按钮宽度 | 由内容决定(不同词语宽度不同) | 等宽 |
| 适合场景 | 标签云、词云、按钮组 | 宫格、相册 |
词语按钮的文字长度不同(如"为什么"比"的"长很多),Flex 让每个按钮按内容宽度排列,视觉上更自然。
3.2 按钮视觉设计
.fontSize(17).fontColor('#555').fontWeight(FontWeight.Medium)
.backgroundColor('#fff').borderRadius(12)
.shadow({ radius: 4, color: '#08000000', offsetY: 2 })
.margin(5)
- 白色背景 + 浅阴影:模拟"卡片"质感,暗示可点击
- 圆角 12vp:柔和友好,适合儿童
- margin(5):按钮之间的间距,让每个按钮独立可辨
3.3 layoutWeight 填满空间
.layoutWeight(1)
这个属性让 Flex 区域填满操作按钮和分类标签栏之后的所有剩余空间。无论屏幕大小,词语按钮都会占满可用区域。
四、句子展示区
4.1 空状态 vs 有内容
if (this.sentence.length === 0) {
Text('👆 点击下面的词语开始组句').fontSize(14).fontColor('#ccc')
} else {
// 显示已组句子 + 词语标签
}
空状态提示用灰色文字和 emoji 引导用户操作。有内容后,展示已组句子和可选中的词语标签。
4.2 句子展示
Text(this.sentence.join(' ') + '。')
.fontSize(22).fontWeight(FontWeight.Bold).fontColor('#333')
.lineHeight(32).width('100%')
join(' ')在词语之间加空格,让句子易读- 自动添加句号,让句子完整
- 大字号 + 加粗,适合儿童阅读
4.3 词语标签(可移除)
Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.Start }) {
ForEach(this.sentence, (word: string, idx: number) => {
Row() {
Text(word).fontSize(15).fontColor('#FF6B6B')
Text(' ✕').fontSize(12).fontColor('#ccc')
}
.padding({ left: 10, right: 6, top: 4, bottom: 4 })
.backgroundColor('#FFF0F0').borderRadius(16)
.margin(3)
.onClick(() => { this.removeWord(idx) })
}, (word: string, idx: number) => word + idx)
}
每个已选词语显示为一个带 ✕ 的标签:
- 粉色文字 + 浅粉背景,与整体配色一致
- ✕ 符号暗示"可删除"
- 点击标签触发
removeWord(idx)从句子中移除该词
4.4 容器样式
.constraintSize({ minHeight: 80 })
.padding(14).backgroundColor('#fff').borderRadius(14)
.shadow({ radius: 6, color: '#10000000', offsetY: 3 })
白色卡片容器,最小高度 80vp,保证空状态也有足够的展示区域。
五、操作按钮
5.1 三个功能按钮
Row({ space: 10 }) {
Button('🗑️ 清空').fontSize(13).fontColor('#fff')
.backgroundColor('#FF6B6B').borderRadius(10).height(34)
.onClick(() => { this.sentence = [] })
Button('↩️ 退格').fontSize(13).fontColor('#666')
.backgroundColor('#f5f5f5').borderRadius(10).height(34)
.onClick(() => {
if (this.sentence.length > 0) {
let arr: string[] = []
for (let i = 0; i < this.sentence.length - 1; i++) {
arr.push(this.sentence[i])
}
this.sentence = arr
}
})
Button('⭐ 检查').fontSize(13).fontColor('#fff')
.backgroundColor('#4ECDC4').borderRadius(10).height(34)
.onClick(() => { this.checkSentence() })
}
| 按钮 | 颜色 | 功能 |
|---|---|---|
| 🗑️ 清空 | 红色 #FF6B6B |
清空所有已选词语 |
| ↩️ 退格 | 灰色 #f5f5f5 |
移除最后一个词语 |
| ⭐ 检查 | 青色 #4ECDC4 |
弹出鼓励弹窗 |
5.2 退格的实现
退格不能用 slice(0, -1) 或 pop(),因为 ArkUI 要求不可变更新。需要显式创建新数组:
let arr: string[] = []
for (let i = 0; i < this.sentence.length - 1; i++) {
arr.push(this.sentence[i])
}
this.sentence = arr
六、分类标签栏
6.1 横向滚动标签
Scroll() {
Row({ space: 8 }) {
ForEach(this.categories, (cat: string, ci: number) => {
Text(cat).fontSize(14)
.fontColor(ci === this.catIndex ? '#fff' : '#FF6B6B')
.padding({ left: 18, right: 18, top: 6, bottom: 6 })
.backgroundColor(ci === this.catIndex ? '#FF6B6B' : '#FFF0F0')
.borderRadius(18)
.onClick(() => { this.catIndex = ci })
}, (cat: string) => cat)
}
.width('100%').padding({ left: 14, right: 14, top: 6, bottom: 4 })
}
.scrollable(ScrollDirection.Horizontal)
7 个分类标签在一行内显示,当超出屏幕宽度时自动启用横向滚动。
选中态 vs 未选中态:
| 状态 | 背景色 | 文字色 | 含义 |
|---|---|---|---|
| 选中 | 红色实心 #FF6B6B |
白色 | 当前浏览的分类 |
| 未选中 | 红色浅底 #FFF0F0 |
红色 | 可切换的分类 |
点击标签切换 catIndex,下方的词语按钮区立即更新为对应分类的 16 个词语。
6.2 分类与词语的对应
private categories: string[] = ['人物', '动作', '物品', '地点', '形容词', '助词', '疑问']
private words: string[][] = [
// index 0 → 人物
['我', '你', '他', '她', '它', '我们', '你们', '他们', '妈妈', '爸爸', '老师', '同学', '小猫', '小狗', '太阳', '月亮'],
// index 1 → 动作
['吃', '喝', '看', '听', '说', '跑', '跳', '走', '飞', '游', '画', '唱', '读', '写', '玩', '笑'],
// ... 以此类推
]
categories[catIndex] 与 words[catIndex] 通过索引一一对应。这种设计比对象键值对更简洁,也避免了 ArkUI 不支持索引签名的限制。
七、核心逻辑实现
7.1 addWord:添加词语
addWord(w: string): void {
let arr: string[] = []
for (let i = 0; i < this.sentence.length; i++) {
arr.push(this.sentence[i])
}
arr.push(w)
this.sentence = arr
}
每次点击词语按钮,调用 addWord 将该词追加到句子末尾。
为什么不用 [...this.sentence, w]?
ArkUI 的 ArkTS 编译器不支持数组展开运算符(spread operator),所以需要手动遍历复制。
7.2 removeWord:移除词语
removeWord(idx: number): void {
let arr: string[] = []
for (let i = 0; i < this.sentence.length; i++) {
if (i !== idx) { arr.push(this.sentence[i]) }
}
this.sentence = arr
}
遍历当前句子数组,除了指定索引的词之外,其他所有词保留。新数组赋值后触发 UI 更新。
7.3 checkSentence:鼓励检查
checkSentence(): void {
let text: string = ''
for (let i = 0; i < this.sentence.length; i++) {
text = text + this.sentence[i]
}
if (text.length < 2) { return }
this.score++
let msg: string = ''
if (this.score % 3 === 0) {
msg = '🌟 太棒了!你已经连对了 ' + this.score + ' 次!'
} else if (this.score % 2 === 0) {
msg = '👍 真厉害!继续加油!'
} else {
msg = '👏 很好!你组了 ' + this.score + ' 个句子了!'
}
AlertDialog.show({ message: msg, confirm: { value: '继续', action: () => {} } })
}
鼓励系统设计:
| 条件 | 消息 | 目的 |
|---|---|---|
| 每 3 次 | 🌟 太棒了 + 计数 | 里程碑奖励 |
| 每 2 次(非 3 倍数) | 👍 真厉害 | 常规鼓励 |
| 其他 | 👏 很好 + 计数 | 基础鼓励 |
AlertDialog.show() 是 ArkUI 的弹窗 API。点击「继续」关闭弹窗,孩子可以继续组句。
八、儿童应用 UI 设计原则
8.1 配色方案
应用使用红 + 白 + 灰的主题色系:
| 颜色 | 色值 | 用途 |
|---|---|---|
| 红色 | #FF6B6B |
主色、标题、选中标签、按钮 |
| 红色浅底 | #FFF0F0 |
未选中标签、词语标签背景 |
| 青色 | #4ECDC4 |
"检查"按钮(正面反馈色) |
| 白色 | #fff |
卡片背景、词语按钮背景 |
| 浅灰背景 | #F8F9FA |
页面背景 |
儿童配色的关键要点:
- 高对比度:文字与背景对比明显,易于阅读
- 暖色主调:红色为主色,传递温暖和活力
- 颜色数量适中:不超过 5 种主要颜色,避免视觉混乱
- 圆角大量使用:减少尖锐边缘,视觉友好
8.2 字号与可读性
| 元素 | 字号 | 说明 |
|---|---|---|
| 标题 | 24fp | 醒目但不过大 |
| 已组句子 | 22fp | 大字号,儿童易读 |
| 词语按钮 | 17fp | 适中,点击精准 |
| 分类标签 | 14fp | 标准标签字号 |
| 辅助文字 | 11~12fp | 淡化处理 |
8.3 交互反馈
- 即时响应:点击按钮立即更新句子,没有延迟
- 视觉反馈:词语标签带 ✕ 符号,暗示可删除
- 鼓励弹窗:每次检查获得正面鼓励
- 空状态引导:初始时显示 👆 提示
九、核心编程技巧
9.1 不可变数组更新
ArkUI 通过引用比较检测 @State 变化。必须创建新数组,不能修改原数组:
// ❌ 错误:引用不变,UI 不更新
this.sentence.push('小猫')
// ❌ 错误:引用不变,UI 不更新
this.sentence.splice(idx, 1)
// ✅ 正确:创建新数组,引用变化
let arr: string[] = []
for (let i = 0; i < this.sentence.length; i++) {
if (i !== idx) { arr.push(this.sentence[i]) }
}
this.sentence = arr
9.2 ForEach 键值
ForEach(this.sentence, (word: string, idx: number) => {
// 词语标签 UI
}, (word: string, idx: number) => word + idx)
键值使用 word + idx 组合。因为句子中可能出现重复词语(如"我"出现两次),仅用 word 做键值会导致重复键冲突。加入索引 idx 确保唯一。
9.3 分类切换的数据映射
// 用户点击第 3 个分类 → catIndex = 2
// 词语按钮区渲染 words[2] → 物品类词语
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(this.words[this.catIndex], (word: string) => {
Text(word).onClick(() => { this.addWord(word) })
})
}
9.4 条件渲染空状态
if (this.sentence.length === 0) {
Text('👆 点击下面的词语开始组句')
} else {
// 显示句子和标签
}
通过 @State sentence 的长度控制两种 UI 状态的切换。
十、应用扩展方向
10.1 语音朗读
加入 TTS(Text To Speech)功能,朗读孩子造的句子:
import { textToSpeech } from '@kit.AccessibilityKit';
// 朗读句子
speakSentence(): void {
let text = this.sentence.join('') + '。'
textToSpeech.speak(text)
}
10.2 句子收藏
保存孩子造的好句子:
@State favorites: string[][] = []
saveSentence(): void {
let arr: string[][] = []
for (let i = 0; i < this.favorites.length; i++) {
arr.push(this.favorites[i])
}
arr.push(this.sentence)
this.favorites = arr
}
10.3 更多词库
扩展词库支持不同主题:
private themes: WordTheme[] = [
{ name: '动物', words: ['小猫', '小狗', '兔子', '大象', ...] },
{ name: '食物', words: ['苹果', '西瓜', '蛋糕', '冰淇淋', ...] },
{ name: '自然', words: ['太阳', '月亮', '星星', '花朵', ...] },
]
10.4 难度分级
- 初级:只用人物 + 动作 + 物品,造简单句子
- 中级:加入形容词和助词
- 高级:加入疑问词,造问句
十一、常见问题与调试
11.1 点击按钮后句子不更新
排查:
addWord是否创建了新数组?检查this.sentence = arr@State sentence是否正确声明?- ForEach 键值是否唯一?
11.2 退格按钮无效
排查:
this.sentence.length > 0条件是否满足?- 新数组的元素数量是否为
length - 1?
11.3 切换分类后词语不变
排查:
catIndex是否正确更新?this.words[this.catIndex]是否能正确访问二维数组?
11.4 弹窗不显示
排查:
checkSentence是否被调用?text.length < 2的条件是否满足(不足 2 个字不弹窗)?AlertDialog.show()的参数格式是否正确?
11.5 分类标签遮挡
Scroll 容器设置了固定高度 .height(38),确保标签不会压缩其他区域。
十二、小结
12.1 核心知识点回顾
| 技术点 | 实现方式 | 用途 |
|---|---|---|
| Flex 布局 | Flex + FlexWrap + layoutWeight |
词语按钮的流式排列 |
| Scroll 标签 | Scroll + Row + borderRadius |
分类标签横向滚动 |
| 数组状态管理 | @State string[] + 不可变更新 |
句子构建和修改 |
| 条件渲染 | if/else |
空状态 vs 已组句子 |
| 弹窗交互 | AlertDialog.show() |
鼓励反馈 |
| 分类切换 | 索引映射 words[catIndex] |
切换词语类别 |
| ForEach 键值 | word + idx 组合 |
避免重复键冲突 |
| 约束尺寸 | constraintSize({ minHeight }) |
句子区最小高度 |
12.2 代码统计
SentenceBuilder.ets (159 行)
├── 数据定义 (18 行)
├── build() 主布局 (105 行)
│ ├── 标题区 (8 行)
│ ├── 句子展示区 (23 行)
│ ├── 操作按钮区 (20 行)
│ ├── 分类标签栏 (15 行)
│ ├── 词语按钮区 (13 行)
│ └── 底部提示 (2 行)
├── addWord (8 行)
├── removeWord (7 行)
└── checkSentence (17 行)
12.3 设计原则总结
- 儿童友好:暖色、圆角、大字号、即时反馈
- 交互直观:点击添加、点击移除、滑动切换分类
- 正面激励:鼓励弹窗 + 计数,每次操作都有正向反馈
- 数据驱动:所有 UI 变化由
@State数据驱动 - 不可变更新:始终创建新数组,不修改原数组
附录
A:词库详情
| 分类 | 词数 | 示例词语 |
|---|---|---|
| 人物 | 16 | 我、你、他、她、我们、妈妈、爸爸、老师、同学、小猫、小狗、太阳、月亮 |
| 动作 | 16 | 吃、喝、看、听、说、跑、跳、走、飞、游、画、唱、读、写、玩、笑 |
| 物品 | 16 | 苹果、西瓜、牛奶、面包、书包、铅笔、玩具、水杯、帽子、雨伞 |
| 地点 | 16 | 家里、学校、公园、河边、超市、图书馆、动物园、花园、海边、教室 |
| 形容词 | 16 | 开心、漂亮、聪明、勇敢、可爱、认真、努力、快乐、温暖、明亮 |
| 助词 | 16 | 的、地、得、了、着、过、在、把、被、和、跟、对、从、比、向、为 |
| 疑问 | 16 | 吗、呢、吧、什么、怎么、为什么、哪儿、谁、几、多少、怎样 |
B:API 使用速查
| API | 用途 |
|---|---|
Flex({ wrap, justifyContent }) |
流式布局容器 |
Scroll().scrollable() |
可滚动容器 |
AlertDialog.show() |
弹窗提示 |
constraintSize({ minHeight }) |
最小尺寸约束 |
.layoutWeight(n) |
权重填充剩余空间 |
ForEach(arr, builder, key) |
列表渲染 |
C:推荐阅读
- HarmonyOS 开发者文档 - Flex 布局
- HarmonyOS 开发者文档 - AlertDialog
- 儿童 UI 设计最佳实践
更多推荐

所有评论(0)