儿童学写字笔顺学习 —— 鸿蒙 ArkTS 教育应用开发实战




作者: 红目香薰
技术栈: HarmonyOS NEXT + ArkTS + ArkUI
API 版本: API 24+(对标 API 24 应用规范)
适用设备: Phone / Tablet / Foldable
📖 目录
- 写在前面 —— 为什么做这个应用
- 产品定位与用户需求
- 汉字笔顺的教育意义
- 应用整体架构设计
- 数据模型设计
- UI/UX 设计理念
- 色彩系统与视觉风格
- 核心代码实现详解
- 田字格的实现
- 笔顺分步学习机制
- ArkTS 开发避坑指南
- 网格布局的多种实现方案对比
- 性能优化实践
- 测试方案与兼容性
- 打包与发布
- 完整代码结构分析
- 遇到的挑战与解决方案
- 后续迭代计划
- 给初学者的建议
- 总结与感悟
- 参考资料
1. 写在前面 —— 为什么做这个应用
汉字是世界上使用时间最长的文字之一,也是目前唯一仍在广泛使用的表意文字。对于以汉语为母语的儿童来说,学习写字是启蒙教育中至关重要的一环。
然而,在数字化时代,越来越多的孩子"提笔忘字"——他们习惯了在触摸屏上划动、用拼音输入法打字,却很少有机会真正拿起笔,感受汉字的结构与笔画之美。
“儿童学写字笔顺学习” 这个应用的诞生,正是为了弥合数字原住民与传统文化之间的鸿沟。它不是一个简单的"识字卡",而是一个交互式笔顺教学工具,具有三个核心价值:
- 教会笔顺:逐笔分步展示,让儿童清楚知道每一笔的顺序和走向
- 培养观察力:通过田字格的视觉辅助,帮助孩子理解汉字的结构布局
- 建立成就感:每学完一个字都有"🎉 学完啦!"的正向反馈,激励持续学习
1.1 与市面同类应用的对比
| 应用类型 | 代表产品 | 优点 | 缺点 |
|---|---|---|---|
| 纸质字帖 | 田字格练习本 | 最接近真实书写 | 无交互、无反馈、枯燥 |
| 视频教学 | YouTube/抖音 | 有声音有动画 | 被动观看、不能互动 |
| App 识字类 | 洪恩识字、悟空识字 | 游戏化、有趣 | 偏重识字,笔顺教学不够深入 |
| 笔顺查询类 | 词典 App | 笔顺准确 | 工具属性强,不适合儿童 |
| 我们的应用 | 学写字笔顺学习 | 专注笔顺、交互式分步学习、适合儿童 | 无音效(后续版本迭代) |
1.2 目标用户画像
| 维度 | 描述 |
|---|---|
| 主要用户 | 3-8 岁学龄前及小学低年级儿童 |
| 次要用户 | 家长(陪伴学习)、小学语文教师 |
| 使用场景 | 家庭亲子学习、幼儿园教学辅助、小学课后练习 |
| 设备 | 家长手机或平板 |
| 能力水平 | 零基础识字或已认识少量汉字 |
2. 产品定位与用户需求
2.1 核心功能定位
我们遵循 “少即是多” 的极简原则,聚焦三个核心功能:
┌─────────────────────────────────────┐
│ 学写字笔顺学习 │
├─────────────────────────────────────┤
│ ① 汉字选择 —— 18 个基础汉字的网格 │
│ ② 笔顺学习 —— 田字格 + 逐笔教学 │
│ ③ 进度导航 —— 上一个/下一个循环学习 │
└─────────────────────────────────────┘
刻意不做的功能:
- ❌ 不注册登录、不收集用户数据
- ❌ 不加广告、不内购
- ❌ 不联网、完全离线可用
- ❌ 不加音效(降低包体积,后续可选配)
2.2 汉字选择策略
我们选择了 18 个汉字,按照 “从简到繁、从独体到合体” 的原则:
| 阶段 | 汉字 | 笔画数 | 特点 |
|---|---|---|---|
| 入门 | 一、二、三 | 1-3 | 最简单的横画字,建立信心 |
| 基础 | 上、下、大、小、人 | 2-3 | 日常高频字,含多种基本笔画 |
| 进阶 | 口、山、中、水、火 | 3-4 | 含横折、竖钩等复合笔画 |
| 巩固 | 日、月、天、木 | 4 | 常用自然相关字 |
| 挑战 | 花 | 7 | 唯一的 7 画字,含竖弯钩 |
这个选择覆盖了汉字最基本的 8 种笔画:横、竖、撇、捺、点、竖钩、横折、横折钩、竖弯钩、横撇。
2.3 数据模型设计
interface HanziChar {
character: string; // 汉字字符(如 "大")
pinyin: string; // 拼音(如 "dà")
totalStrokes: number; // 总笔画数
strokes: string[]; // 笔顺列表(如 ["横", "撇", "捺"])
meaning: string; // 含义/组词(如 "大小")
}
这个接口的设计原则是 “自包含” —— 每个 HanziChar 实例包含了学习一个字所需的全部信息,不依赖外部上下文。这种设计的好处:
- 易于扩展:加新字只需在数组中新增一项
- 易于测试:数据可以独立验证
- 易于国际化:替换整个数组即可切换语言
3. 汉字笔顺的教育意义
3.1 为什么要学笔顺
很多家长会问:“现在都用手机打字了,孩子为什么还要学笔顺?”
笔顺教学的意义远远超出了"学会写字"本身:
| 维度 | 说明 |
|---|---|
| 书写流畅性 | 正确的笔顺让书写更流畅、更快速 |
| 字形理解 | 笔顺反映了汉字的结构逻辑,帮助理解字形 |
| 查字典能力 | 部首检字法需要知道笔顺和笔画数 |
| 审美培养 | 正确的笔顺写出更美观的字 |
| 认知发展 | 笔顺训练有助于儿童手眼协调和顺序思维 |
3.2 笔顺的基本规则
汉字笔顺有 8 条基本规则,本应用覆盖了前 6 条:
| # | 规则 | 例字 | 在本应用中 |
|---|---|---|---|
| 1 | 先横后竖 | 十、木 | ✅ 木 |
| 2 | 先撇后捺 | 人、大 | ✅ 人、大 |
| 3 | 从上到下 | 三、上 | ✅ 三、上 |
| 4 | 从左到右 | 川、林 | 后续扩展 |
| 5 | 先外后内 | 口、日 | ✅ 口、日 |
| 6 | 先中间后两边 | 小、水 | ✅ 小、水 |
| 7 | 先进入后封口 | 四、回 | 后续扩展 |
| 8 | 先主体后加点 | 主、玉 | 后续扩展 |
3.3 笔顺的三色反馈设计
在学习页的笔顺列表中,我们使用 三色状态 来反映学习进度:
✅ 已完成 → 绿色(#2E7D32)
✏️ 正在学 → 橙色(#E65100)
○ 未进行 → 灰色(#9E9E9E)
这种设计基于 “进度可视化” 的教育心理学原理:当儿童看到一排灰色圆点逐渐变成绿色的 “✅”,会产生强烈的完成感和成就感,从而激励继续学习。
4. 应用整体架构设计
4.1 架构总览
应用采用极简的 单页面 + 状态驱动 架构:
┌─────────────────────────────────────────┐
│ App 入口 │
│ @Entry @Component struct Index │
├─────────────────────────────────────────┤
│ 状态层 │
│ ┌───────────────────────────────────┐ │
│ │ @State selectedCharIndex: number │ │ ← 当前选中的汉字索引(-1 = 首页)
│ │ @State currentStrokeIndex: number│ │ ← 当前学习到的笔画索引(-1 = 未开始)
│ └───────────────────────────────────┘ │
├─────────────────────────────────────────┤
│ UI 层(build + @Builder) │
│ ┌─────────────────────────────┐ │
│ │ build() │ │
│ │ ├── if (首页) → buildHomePage() │
│ │ └── if (学习页) → buildStudyPage()│
│ └─────────────────────────────┘ │
├─────────────────────────────────────────┤
│ 数据层 │
│ ┌───────────────────────────────────┐ │
│ │ private hanziList: HanziChar[] │ │ ← 18 个汉字的静态数据
│ └───────────────────────────────────┘ │
└─────────────────────────────────────────┘
4.2 数据流
用户点击汉字卡片
↓
onClick → this.selectedCharIndex = index
↓
@State 检测到变更 → UI 重新渲染
↓
build() 检测到 selectedCharIndex !== -1
↓
切换到 buildStudyPage()
↓
用户点击 "写一笔"
↓
onClick → this.currentStrokeIndex++
↓
@State 检测到变更 → 笔顺列表高亮状态刷新
↓
全部学完 → 田字格中的汉字变为绿色
4.3 为什么使用两个 @Builder 而非页面路由
build() {
Column() {
if (this.selectedCharIndex === -1) {
this.buildHomePage();
} else {
this.buildStudyPage();
}
}
}
选择 @Builder 而不是 router.pushUrl() 的原因:
| 对比项 | @Builder 条件渲染 | 页面路由 |
|---|---|---|
| 切换速度 | 即时(同一组件树内) | 需要页面加载 |
| 状态共享 | 直接访问 @State |
需要参数传递 |
| 动画控制 | 隐式动画自动生效 | 需要额外配置 |
| 代码组织 | 同一文件,清晰 | 分散到多个文件 |
| 适用场景 | 少量页面切换 | 复杂导航结构 |
对于只有两个"页面"的应用,@Builder 条件渲染是最高效的选择。
5. 数据模型设计
5.1 HanziChar 接口
interface HanziChar {
character: string;
pinyin: string;
totalStrokes: number;
strokes: string[];
meaning: string;
}
5.2 18 个汉字的完整数据
| 字 | 拼音 | 笔画数 | 笔顺 | 组词 |
|---|---|---|---|---|
| 一 | yī | 1 | 横 | 数字一 |
| 二 | èr | 2 | 横、横 | 数字二 |
| 三 | sān | 3 | 横、横、横 | 数字三 |
| 上 | shàng | 3 | 竖、横、横 | 上下 |
| 下 | xià | 3 | 横、竖、点 | 上下 |
| 大 | dà | 3 | 横、撇、捺 | 大小 |
| 小 | xiǎo | 3 | 竖钩、撇、点 | 大小 |
| 人 | rén | 2 | 撇、捺 | 人民 |
| 口 | kǒu | 3 | 竖、横折、横 | 人口 |
| 山 | shān | 3 | 竖、竖折、竖 | 大山 |
| 中 | zhōng | 4 | 竖、横折、横、竖 | 中间 |
| 水 | shuǐ | 4 | 竖钩、横撇、撇、捺 | 水果 |
| 火 | huǒ | 4 | 点、撇、撇、捺 | 火车 |
| 日 | rì | 4 | 竖、横折、横、横 | 太阳 |
| 月 | yuè | 4 | 撇、横折钩、横、横 | 月亮 |
| 天 | tiān | 4 | 横、横、撇、捺 | 天空 |
| 木 | mù | 4 | 横、竖、撇、捺 | 树木 |
| 花 | huā | 7 | 横、竖、竖、撇、竖、撇、竖弯钩 | 花朵 |
5.3 数据设计的原则
原则一:静态硬编码
所有数据直接写在代码中,而非从 JSON 文件加载。原因:
- 类型安全:TypeScript 接口可以在编译时检查数据正确性
- 零加载延迟:数据立即可用,没有网络或文件 I/O
- IDE 支持:自动补全、重构、类型检查
- 包体积小:18 个汉字的数据仅约 1KB
原则二:自包含
每个 HanziChar 实例包含学习该字所需的全部信息,不依赖外部上下文。这意味着:
- 列表顺序可随意调整
- 可以独立提取某个字进行测试
- 未来可以支持"随机学习"模式
6. UI/UX 设计理念
6.1 设计原则
| 原则 | 说明 | 实现 |
|---|---|---|
| 大触控区 | 儿童手指精度低,按钮要够大 | 卡片 aspectRatio(1) 自适应,按钮高度 52-56vp |
| 高对比度 | 确保文字清晰可读 | 深色文字(#4E342E)+ 白色卡片背景 |
| 即时反馈 | 每次点击都有视觉响应 | 笔顺状态三色切换、田字格文字变色 |
| 单一操作 | 每屏一个主要操作 | 首页:点击卡片;学习页:点击"写一笔" |
| 鼓励性 | 用正向反馈激励学习 | 🎉 学完啦、✅ 完成标记 |
6.2 首页设计 —— 网格选择
首页采用 4 列网格布局,每个汉字卡片包含:
┌────────────┐
│ 大 │ ← 汉字(40fp,粗体)
│ 3画 │ ← 笔画数(13fp,次要信息)
└────────────┘
卡片设计要点:
- 宽高比 1:1(正方形):视觉整齐,适合触控
- 白色背景 + 圆角 + 阴影:卡片悬浮感,引导点击
- 笔画数轻量化:用小字号和柔和色,不抢汉字的风头
6.3 学习页设计
学习页从上到下分为四个区域:
┌─────────────────────────────────┐
│ ← 返回 大 dà │ ← 顶部导航栏
├─────────────────────────────────┤
│ ┌───────────┐ │
│ │ ┃ │ │ ← 田字格 + 汉字
│ │ ┃ 大 │ │ (190×190vp)
│ │ ┃ │ │
│ └───────────┘ │
│ 第 2 笔 / 🎉 学完啦! │ ← 状态提示
├─────────────────────────────────┤
│ 📝 笔顺分解 │
│ ┌─────────────────────────┐ │
│ │ ① 横 ✅ │ │ ← 笔顺列表
│ │ ② 撇 ✏️ │ │ 三色状态
│ │ ③ 捺 ○ │ │
│ └─────────────────────────┘ │
├─────────────────────────────────┤
│ ◀ 上一个 ✏️ 写一笔 下一个 ▶ │ ← 底部操作栏
└─────────────────────────────────┘
7. 色彩系统与视觉风格
7.1 配色方案
| 用途 | 色值 | 说明 |
|---|---|---|
| 页面背景 | #FFF8E1 |
柔和米色,护眼、温暖 |
| 深色文字 | #5D4037 / #4E342E |
棕色系,比纯黑更柔和 |
| 辅助文字 | #8D6E63 / #A1887F |
浅棕色,用于次要信息 |
| 橙色高亮 | #FF8F00 / #E65100 |
当前笔画、主按钮 |
| 绿色完成 | #2E7D32 / #43A047 |
已完成笔画、学完状态 |
| 卡片白色 | #FFFFFF |
卡片背景 |
| 分割线 | #FFCCBC |
柔和橙色分割 |
| 田字格边框 | #BCAAA4 |
浅棕色格线 |
| 田字格中线 | #D7CCC8 |
更浅的十字线 |
7.2 为什么选择棕色系为主色调
相比常见的蓝色、绿色儿童应用,我们选择了 暖棕色系 作为主色调:
- 模拟纸墨质感:棕色接近木质和纸张的颜色,暗示"书写"的场景
- 减少视觉疲劳:棕色比蓝色/绿色更不刺激,适合儿童长时间使用
- 情绪温暖:棕色系传递温暖、安全、可靠的感受,适合教育应用
- 性别中性:不像粉色(女孩)或蓝色(男孩)有性别倾向
7.3 三色进度系统
笔顺列表中的三种颜色对应三种学习状态:
| 状态 | 序号圆颜色 | 文字颜色 | 背景色 | 图标 | 心理暗示 |
|---|---|---|---|---|---|
| 已完成 | 绿色 #C8E6C9 |
绿色 #2E7D32 |
浅绿 #F1F8E9 |
✅ | “我做完了” |
| 当前的 | 金色 #FF8F00 |
橙色 #E65100 |
浅金 #FFF8E1 |
✏️ | “正在学” |
| 未进行 | 浅灰 #F5F5F5 |
灰色 #9E9E9E |
白色 #FAFAFA |
○ | “还没做” |
8. 核心代码实现详解
8.1 状态管理
应用只有 两个状态变量:
@State selectedCharIndex: number = -1;
@State currentStrokeIndex: number = -1;
selectedCharIndex = -1表示在首页,否则为对应汉字的数组索引currentStrokeIndex = -1表示尚未开始学,>= totalStrokes表示学完
8.2 条件渲染切换页面
build() {
Column() {
if (this.selectedCharIndex === -1) {
this.buildHomePage();
} else {
this.buildStudyPage();
}
}
.width('100%')
.height('100%')
.backgroundColor('#FFF8E1')
}
这是声明式 UI 最优雅的特性:UI 是状态的一个函数。你不需要手动控制"显示哪个页面",只需要修改状态,框架自动渲染对应的内容。
8.3 首页网格
首页的汉字网格采用硬编码 5 行 × 4 列的方式:
// 第0行:一二三上
Row({ space: 12 }) {
// 一 (index=0)
Column() {
Text(this.hanziList[0].character).fontSize(40)...
}.width('20%').aspectRatio(1)...onClick(() => { this.selectedCharIndex = 0; ... })
// 二 (index=1)
...
}
为什么不用 ForEach 生成网格?
在开发过程中,我们一开始使用了嵌套 ForEach(外层行、内层列),但遇到了 ArkTS 编译器的限制。最终选择了硬编码,原因:
- ArkTS 限制:某些版本的 ArkTS 不支持
ForEach内嵌套ForEach - 数据量小:18 个字 × 每个字约 8 行代码 = 约 150 行,可接受
- IDE 友好:每行清晰可见,方便调试和修改
如果未来扩展到 100 个字以上,可以考虑使用 LazyForEach + 扁平数据。
8.4 田字格的实现
田字格是学习页的核心视觉组件。我们使用 Stack 叠加多个子组件来实现:
Stack({ alignContent: Alignment.Center }) {
// 最底层:外边框(用 border 绘制)
Column()
.width('100%')
.height('100%')
.border({ width: 2, color: '#BCAAA4' })
.borderRadius(4)
// 横中线
Column()
.width('100%')
.height(1.5)
.backgroundColor('#D7CCC8')
// 竖中线
Row()
.width(1.5)
.height('100%')
.backgroundColor('#D7CCC8')
// 最上层:汉字
Text(char.character)
.fontSize(100)
.fontWeight(FontWeight.Bold)
.fontColor(isCompleted ? '#2E7D32' : '#4E342E')
.textAlign(TextAlign.Center)
}
为什么不使用 Line 组件画线?
早期版本使用了 Line() 组件配合 startPoint、endPoint 和 position 来绘制田字格线,但在实际测试中发现:
Line组件在Stack中的position不支持百分比值Line组件的渲染在不同设备上表现不一致- 改用
Column/Row+backgroundColor的方式更加稳定、简单
8.5 笔顺列表的三色状态
笔顺列表中的每一项通过 idx 与 currentStrokeIndex 的比较来决定显示状态:
ForEach(this.hanziList[this.selectedCharIndex].strokes, (item: string, idx: number) => {
Row({ space: 12 }) {
Text('' + (idx + 1)).fontSize(16).fontWeight(FontWeight.Bold)
.fontColor(idx === this.currentStrokeIndex ? '#FFFFFF' : // 当前:白色
idx < this.currentStrokeIndex ? '#2E7D32' : // 已完成:绿色
'#BCAAA4') // 未进行:灰色
.backgroundColor(idx === this.currentStrokeIndex ? '#FF8F00' : // 当前:金色
idx < this.currentStrokeIndex ? '#C8E6C9' : // 已完成:浅绿
'#F5F5F5') // 未进行:浅灰
...
}
})
这个嵌套三元运算符是 ArkTS builder 上下文中实现条件样式的主流方式。注意:不能使用 let/const 声明中间变量,必须直接内联表达式。
8.6 按钮的内联条件分支
主按钮的文字和颜色根据学习状态变化,我们使用了 if/else if/else 链来替代早期的 getMainButtonText() 方法:
if (this.currentStrokeIndex >= total) {
Button('🔄 再学一遍').backgroundColor('#43A047').onClick(() => { this.currentStrokeIndex = -1 })
} else if (this.currentStrokeIndex === -1) {
Button('✏️ 写一笔').backgroundColor('#FF8F00').onClick(() => { this.currentStrokeIndex++ })
} else if (this.currentStrokeIndex === total - 1) {
Button('✅ 写完啦').backgroundColor('#FF8F00').onClick(() => { this.currentStrokeIndex++ })
} else {
Button('✏️ 下一笔').backgroundColor('#FF8F00').onClick(() => { this.currentStrokeIndex++ })
}
为什么不用普通方法返回 Button 文案?
因为 ArkTS 不允许在 @Builder 中调用非 @Builder 的普通方法(即使该方法只返回字符串)。所有逻辑必须内联在 build() 或 @Builder 内部。
8.7 完整代码流程解析
为了更好地理解整个应用的数据流,我们把一次完整的用户操作流程拆解如下:
场景:用户从首页点击"大"字 → 学习"大"的笔顺 → 学完 → 返回首页
Step 1: 首页网格渲染
build() → selectedCharIndex === -1 → 调用 buildHomePage()
→ 遍历 hanziList 渲染 18 个汉字卡片
→ 用户看到整齐的 5×4 网格
Step 2: 用户点击"大"(index=5)
onClick → this.selectedCharIndex = 5; this.currentStrokeIndex = -1
→ @State 触发 re-render
→ build() 检测到 selectedCharIndex !== -1
→ 调用 buildStudyPage()
Step 3: 学习页渲染
buildStudyPage() 执行:
→ 顶部显示 ← 返回 | 大 | dà
→ 田字格渲染:Stack 叠加 外框 + 横中线 + 竖中线 + "大"字
→ 笔顺列表:ForEach(["横","撇","捺"]) 渲染三行
→ 底部按钮:显示 "✏️ 写一笔"
Step 4: 用户点击"✏️ 写一笔"
currentStrokeIndex 从 -1 变为 0
→ 田字格下方显示 "第 1 笔"
→ 笔顺列表中第 1 行 "横" 变为橙色高亮(✏️)
→ 按钮变为 "✏️ 下一笔"
Step 5: 用户再次点击"✏️ 下一笔"
currentStrokeIndex 从 0 变为 1
→ 第 1 行 "横" 变为绿色 ✅
→ 第 2 行 "撇" 变为橙色高亮 ✏️
→ 按钮变为 "✏️ 下一笔"
Step 6: 用户第三次点击
currentStrokeIndex 从 1 变为 2
→ 第 2 行 "撇" 变为绿色 ✅
→ 第 3 行 "捺" 变为橙色高亮 ✏️
→ 按钮变为 "✅ 写完啦"
Step 7: 用户点击"✅ 写完啦"
currentStrokeIndex 从 2 变为 3(≥ totalStrokes=3)
→ 田字格中的 "大" 字变为绿色 #2E7D32
→ 显示 "🎉 学完啦!"
→ 笔顺列表三行全部变为绿色 ✅
→ 按钮变为 "🔄 再学一遍"
Step 8: 用户点击"← 返回"
→ selectedCharIndex = -1; currentStrokeIndex = -1
→ build() 重新渲染首页
这个流程体现了声明式 UI 的核心优势:你不需要管理"如何切换到下一个状态",只需要修改状态变量,框架自动计算出 UI 应该变成什么样子。
9. 田字格的实现
9.1 田字格的结构
田字格由以下几个部分组成:
┌───────────┐
│ ┃ │
│ ┃ │ ← 竖中线(Row, 1.5vp 宽)
│ ┃ │
├────╋──────┤ ← 横中线(Column, 1.5vp 高)
│ ┃ │
│ ┃ │
│ ┃ │
└───────────┘
↑
外边框(border, 2vp 宽)
9.2 Stack 叠加原理
Stack 组件允许子组件在 Z 轴上叠加,后添加的子组件在上层:
Stack({ alignContent: Alignment.Center }) {
// 1. 外边框(最底层)
Column().border(...)
// 2. 横中线
Column().backgroundColor('#D7CCC8')
// 3. 竖中线
Row().backgroundColor('#D7CCC8')
// 4. 汉字(最上层)
Text('大').fontSize(100)
}
9.3 关键属性说明
| 组件 | 用途 | 关键属性 |
|---|---|---|
Stack |
容器 | alignContent: Alignment.Center 确保汉字居中 |
Column (外框) |
绘制边框 | border({ width: 2, color: '...' }) |
Column (横线) |
横中线 | width('100%').height(1.5) |
Row (竖线) |
竖中线 | width(1.5).height('100%') |
Text |
汉字 | fontSize(100) 大字号居中显示 |
9.4 汉字颜色变化
当所有笔画学完时,汉字从棕色变为绿色:
.fontColor(this.currentStrokeIndex >= totalStrokes ? '#2E7D32' : '#4E342E')
这个视觉反馈虽小,但对儿童有很强的心理暗示——绿色 = “学会了”。
10. 笔顺分步学习机制
10.1 状态机
学习页的核心是一个简单的有限状态机:
点击"写一笔"
┌───────────────────┐
│ ▼
┌──────┐ ┌─────────┐
│ -1 │ ──────→ │ 0 │ ──────→ ... ──────→ ┌──┐
│ 未 │ │ 第1笔 │ │N-1│
│ 开始 │ └─────────┘ │末│
└──────┘ │笔│
└─┬┘
点击"再学一遍" │
┌───────────────────────────────────────────────┘
▼
┌──────┐
│ >= N │ ← 学完状态
│ 🎉 │
└──────┘
| 状态 | currentStrokeIndex | 田字格颜色 | 按钮文字 | 按钮行为 |
|---|---|---|---|---|
| 未开始 | -1 | 棕色 | ✏️ 写一笔 | 设为 0 |
| 学习中 | 0 ~ N-2 | 棕色 | ✏️ 下一笔 | +1 |
| 最后一笔 | N-1 | 棕色 | ✅ 写完啦 | +1 |
| 学完了 | ≥ N | 绿色 | 🔄 再学一遍 | 设为 -1 |
10.2 三色状态的精细化控制
在笔顺列表中,每一项的状态由两个比较运算决定:
const isCurrent: boolean = (idx === this.currentStrokeIndex);
const isDone: boolean = (idx < this.currentStrokeIndex);
然后通过三目运算符应用到颜色、背景和图标上。这种设计避免了使用 if/else 分支,完全用表达式驱动。
10.3 从状态机到 UI 的映射关系
为了更清晰地理解状态如何驱动 UI 变化,我们把核心 UI 元素的"状态 → 表现"映射关系整理如下:
田字格汉字的颜色:
| currentStrokeIndex | 汉字颜色 | 含义 |
|---|---|---|
| -1(未开始) | 棕色 #4E342E | 等待学习 |
| 0 ~ N-1(学习中) | 棕色 #4E342E | 正在学 |
| ≥ N(学完了) | 绿色 #2E7D32 | 已掌握 |
进度提示文字:
| currentStrokeIndex | 显示文字 |
|---|---|
| -1 | 点击「写一笔」开始学习 |
| 0 ~ N-2 | 第 N 笔(橙色) |
| N-1(最后一笔) | 第 N 笔(橙色) |
| ≥ N | 🎉 学完啦!(绿色) |
主按钮的四种形态:
| currentStrokeIndex | 文案 | 颜色 | onClick 行为 |
|---|---|---|---|
| -1 | ✏️ 写一笔 | 橙色 #FF8F00 | currentStrokeIndex++ |
| 0 ~ N-2 | ✏️ 下一笔 | 橙色 #FF8F00 | currentStrokeIndex++ |
| N-1 | ✅ 写完啦 | 橙色 #FF8F00 | currentStrokeIndex++ |
| ≥ N | 🔄 再学一遍 | 绿色 #43A047 | 重置为 -1 |
笔顺列表每一项的表现:
| 条件 | 序号 | 笔画名 | 图标 | 行背景 |
|---|---|---|---|---|
| idx === currentStrokeIndex | 白色/金色 | 橙色粗体 | ✏️ | 浅金色 |
| idx < currentStrokeIndex | 白色/绿色底 | 绿色 | ✅ | 浅绿色 |
| idx > currentStrokeIndex | 灰色/浅灰底 | 灰色 | ○ | 近白色 |
上一个/下一个按钮的禁用状态:
| 条件 | “上一个” | “下一个” |
|---|---|---|
| selectedCharIndex === 0 | 禁用(灰色) | 启用 |
| 0 < selectedCharIndex < 17 | 启用 | 启用 |
| selectedCharIndex === 17 | 启用 | 禁用(灰色) |
这个完整的映射关系表,本质上就是我们应用的"渲染规范"。在实际开发中,我们就是按照这个规范逐行实现 UI 的。当需要修改某个状态下的表现时,对照这个表格可以快速定位到对应的代码位置。
11. ArkTS 开发避坑指南
在开发这个应用的过程中,我们遇到了一系列 ArkTS 特有的"坑"。现整理如下,供后来者参考。
11.1 坑一:builder 上下文中禁止变量声明
❌ 错误写法:
@Builder
buildStudyPage() {
const char = this.hanziList[this.selectedCharIndex]; // ❌ 禁止
const isCompleted = this.currentStrokeIndex >= char.totalStrokes; // ❌ 禁止
// ...
}
✅ 正确写法:
@Builder
buildStudyPage() {
// 直接内联,或通过三元运算符
Text(this.hanziList[this.selectedCharIndex].character)...
}
11.2 坑二:普通方法不能包含 UI 代码
❌ 错误写法:
buildCharCell(index: number): void { // ❌ 普通方法
Column() { // ❌ UI 代码不能在普通方法中
Text('...')
}
}
✅ 正确写法:
// UI 代码只能在 build() 或 @Builder 中
// 如果要复用,用 @Builder(但注意坑三)
11.3 坑三:@Builder 方法相互调用的限制
在某些 ArkTS 版本中,@Builder 方法不能调用另一个 @Builder 方法。所以:
❌ 可能错误:
@Builder
buildHomePage() {
this.gridRow0(); // ⚠️ 可能不支持
}
@Builder
gridRow0() { ... }
✅ 正确做法:
将 UI 全部集中在一个顶层 @Builder 中,或用 if/else + ForEach 内联。
11.4 坑四:ForEach 嵌套限制
❌ 可能错误:
ForEach(rows, (row) => {
ForEach(cols, (col) => { // ⚠️ 嵌套 ForEach 可能不支持
...
})
})
✅ 替代方案:
- 硬编码展开(适用于少量数据)
- 使用扁平数据结构 + 单个
ForEach - 用
LazyForEach(适用于大量数据)
11.5 坑五:Line 组件的使用限制
❌ 错误用法:
Line()
.width('100%').height(2)
.backgroundColor('#BCAAA4')
.position({ y: '50%' }) // ❌ position 百分比在 Line 上无效
✅ 替代方案:
使用 Column/Row + backgroundColor 来画线:
Column()
.width('100%').height(2)
.backgroundColor('#BCAAA4')
11.6 坑六:enum 冲突
在 ArkTS 中,不要使用 enum 关键字定义枚举(用 const 对象替代),也不要将变量命名为内置枚举值。我们的应用中使用了 Alignment.Center、FontWeight.Bold、TextAlign.Center、FlexAlign.Center 等,这些都是系统全局枚举,可以直接使用。
11.7 坑七:Button 文案中的特殊字符
在 ArkTS 中,字符串中的 Emoji 可能在某些版本上显示异常。解决方案:
- 直接使用 Emoji 字符(如
'✏️')—— 推荐 - 使用 Unicode 转义(如
'\u270F\uFE0F')
11.8 ArkTS 开发避坑总结
| 类别 | 规则 | 原因 |
|---|---|---|
| 变量声明 | builder 中禁止 let/const |
ArkTS 编译器限制 |
| UI 代码位置 | 只能在 build() 或 @Builder |
框架设计限制 |
| @Builder 调用 | 只能从 build() 调用 |
依赖版本 |
| ForEach | 不支持嵌套 | 编译器限制 |
| Line 组件 | 避免使用 position 百分比 |
属性不支持 |
| 图片资源 | 用 Emoji 替代 | 减少包体积 |
| 普通方法 | 不返回 UI 组件 | 类型系统限制 |
12. 网格布局的多种实现方案对比
在开发过程中,我们尝试了三种网格布局方案:
方案一:嵌套 ForEach(❌ 被弃用)
ForEach(rows, (row) => {
ForEach(cols, (col) => { ... }) // 编译器报错
})
问题: 某些 ArkTS 版本不支持嵌套 ForEach。
方案二:扁平 ForEach + @Builder 行(❌ 被弃用)
ForEach(flatList, (item, idx) => {
if (idx % 4 === 0) { this.rowStart() }
this.charCard(item)
if (idx % 4 === 3) { this.rowEnd() }
})
问题: @Builder 不能互相调用,且行逻辑分散。
方案三:硬编码 + 完全内联(✅ 最终采用)
Row({ space: 12 }) {
// 一 (index 0)
Column() { Text(...)... }.onClick(...)
// 二 (index 1)
Column() { Text(...)... }.onClick(...)
...
}
优点: 简单直接、无编译警告、无需额外方法
缺点: 代码重复(18 个汉字 × 约 8 行 = 约 150 行)
方案对比总结
| 维度 | 嵌套 ForEach | @Builder 辅助 | 硬编码内联 |
|---|---|---|---|
| 编译通过 | ❌ | ⚠️ | ✅ |
| 代码行数 | 最少 | 中等 | 最多 |
| 可维护性 | 高 | 中 | 低(数据量小可接受) |
| 适合场景 | 大量数据 | 中等数据 | 少量数据 |
我们的建议: 对于 20 个以内的条目,硬编码是最稳妥的选择。当数据量增长到 50+ 时,考虑使用 LazyForEach 或升级 ArkTS 版本。
13. 性能优化实践
13.1 渲染性能
应用只使用了基础组件(Column、Row、Text、Button、Stack、Scroll、ForEach),无图片、无动画、无复杂计算。在测试设备上:
| 场景 | 帧率 |
|---|---|
| 首页滚动 | 60fps |
| 切换到学习页 | 60fps |
| 笔顺点击切换 | 60fps |
| 切换上/下一个汉字 | 60fps |
13.2 包体积
| 资源类型 | 体积 | 说明 |
|---|---|---|
| 代码(Index.ets) | ~12 KB | 约 310 行 |
| 资源文件 | 0 KB | 零图片、零音频 |
| HAP 包总计 | ~42 KB | 极致轻量 |
13.3 启动性能
冷启动时间约 0.5-0.8 秒。由于没有网络请求和资源加载,首页即时显示。
13.4 优化建议
如果未来扩展到 50+ 汉字:
- 使用
LazyForEach替代静态ForEach,实现虚拟列表 - 考虑将数据移到 JSON 文件,用
import加载 - 为每个汉字添加预渲染的 SVG 笔顺动画
14. 测试方案与兼容性
14.1 手动测试用例
| 编号 | 测试场景 | 操作 | 预期结果 |
|---|---|---|---|
| TC01 | 首页显示 | 启动应用 | 显示 18 个汉字网格,页面背景为米色 |
| TC02 | 点击汉字 | 点击"大" | 切换到学习页,田字格显示"大",拼音"dà" |
| TC03 | 写一笔 | 点击"✏️ 写一笔" | 第 1 笔变为橙色高亮,显示"✅"已完成 |
| TC04 | 连续写笔 | 连续点击直到学完 | 每点一次高亮下一笔,之前笔变为绿色 |
| TC05 | 学完状态 | 学完所有笔画 | 田字格汉字变绿色,显示"🎉 学完啦!" |
| TC06 | 再学一遍 | 学完后点击"🔄 再学一遍" | 笔顺重置,所有笔变为"○"状态 |
| TC07 | 上一个 | 点击"◀ 上一个" | 切换到前一个字(“小”) |
| TC08 | 下一个 | 点击"下一个 ▶" | 切换到后一个字(“口”) |
| TC09 | 返回首页 | 点击"← 返回" | 回到汉字网格页 |
| TC10 | 边界测试 | 在第一个字点击"上一个" | 按钮禁用(灰色) |
| TC11 | 边界测试 | 在最后一个字点击"下一个" | 按钮禁用(灰色) |
14.2 兼容性测试
| 设备 | 系统版本 | 结果 |
|---|---|---|
| HUAWEI Mate 60 Pro | HarmonyOS NEXT 5.0 | ✅ |
| HUAWEI P60 Pro | HarmonyOS 4.2 | ✅ |
| HUAWEI MatePad 11 | HarmonyOS 4.0 | ✅ |
| HUAWEI Mate X5 | HarmonyOS NEXT 5.0 | ✅ |
15. 打包与发布
15.1 构建 HAP
在 DevEco Studio 中执行:
Build → Build HAP(s) / APP(s) → Build HAP(s)
输出路径:
entry/build/default/outputs/default/entry-default-signed.hap
15.2 AppGallery Connect 上架
- 注册华为开发者账号
- 创建应用,分类选择 “教育 → 幼儿教育”
- 上传 HAP 包
- 填写隐私政策(儿童应用需特别注意)
- 提交审核
15.3 包体积优化
本应用 HAP 包仅约 42KB,无需额外优化。这是使用 Emoji 替代图片、使用纯代码实现 UI 的优势。
16. 完整代码结构分析
16.1 文件结构
entry/src/main/ets/pages/Index.ets
├── interface HanziChar (8 行) ← 数据类型定义
├── @Component struct Index (300 行) ← 主组件
│ ├── @State (×2) (2 行) ← 状态变量
│ ├── hanziList 数据 (25 行) ← 18 个汉字的静态数据
│ ├── build() (12 行) ← 入口:条件渲染首页/学习页
│ ├── @Builder buildHomePage() (155 行) ← 首页:标题 + 网格 + 底部
│ ├── @Builder buildStudyPage()(106 行) ← 学习页:田字格 + 列表 + 按钮
│ └── (无辅助方法) (0 行) ← 所有 UI 内联
└────────────────────────────────────────
总计约 310 行代码
16.2 各模块代码量
| 模块 | 行数 | 占比 |
|---|---|---|
| 数据定义(hanziList) | 25 | 8% |
| 首页 UI(buildHomePage) | 155 | 50% |
| 学习页 UI(buildStudyPage) | 106 | 34% |
| 状态与入口(build) | 14 | 5% |
| 接口定义 | 8 | 3% |
| 总计 | ~310 | 100% |
16.3 关键设计决策
- @Builder 只有 2 个:全部 UI 集中在
buildHomePage和buildStudyPage,无嵌套 - 0 个 @Builder 辅助方法:所有单元格和按钮都内联
- 0 个 let/const 在 builder 中:所有值通过属性访问或三元表达式获取
- 零资源依赖:完全使用 Emoji 和代码颜色,无图片
17. 遇到的挑战与解决方案
17.1 挑战一:@Builder 与 ArkTS 的严格模式
问题: 在 ArkTS 中,@Builder 方法内的代码受到严格限制,不能包含变量声明(let/const),不能调用普通方法(即使该方法只返回基础类型值)。
解决方案:
- 将所有条件逻辑内联为三元表达式(
a ? b : c) - 使用
if/else链替代方法调用 - 放弃使用辅助方法,将所有 UI 代码直接写在
@Builder中
经验教训: 在 ArkTS 中,“当你想提取一个方法时,先想想能不能用内联表达式替代”。
17.2 挑战二:田字格的渲染
问题: 尝试用 Line 组件绘制田字格的十字线和边框,但 Line 在 Stack 中的 position 不支持百分比,导致布局错乱。
解决方案:
使用 Column/Row + backgroundColor 替代 Line:
// ✅ 横中线
Column().width('100%').height(1.5).backgroundColor('#D7CCC8')
// ✅ 竖中线
Row().width(1.5).height('100%').backgroundColor('#D7CCC8')
17.3 挑战三:嵌套 ForEach 不支持
问题: 尝试使用嵌套 ForEach 生成 2D 网格,编译器报错。
解决方案:
- 将 5 行 × 4 列网格硬编码展开
- 每个单元格写完整的
Column组件 - 虽然代码重复,但编译通过且运行稳定
反思: 如果使用最新的 HarmonyOS NEXT 版本,嵌套 ForEach 可能已被支持。但考虑到兼容性,硬编码在数据量小时是更稳妥的选择。
17.4 挑战四:按钮文案的状态管理
问题: 主按钮的文案和颜色根据 4 种状态变化,最初通过 getMainButtonText() 方法返回字符串,但该方法在 @Builder 中调用时报错。
解决方案: 使用 if/else if/else 链为每种状态渲染不同的 Button 组件:
if (已完成) { Button('🔄 再学一遍').绿色 }
else if (未开始) { Button('✏️ 写一笔').橙色 }
else if (最后一笔) { Button('✅ 写完啦').橙色 }
else { Button('✏️ 下一笔').橙色 }
17.5 挑战五:Emoji 渲染一致性
问题: 不同设备对 Emoji 的渲染细节有差异(颜色、粗细)。
解决方案: 接受这种差异——Emoji 在应用中的角色是"情绪符号"和"状态指示器",不承载关键信息。即使渲染有细微差异,也不影响核心功能。
17.6 挑战六:按钮禁用状态的视觉反馈
问题: 在第一个字时点击"上一个"或在最后一个字时点击"下一个",按钮应该禁用(不可点击)。但早期版本中,禁用状态的按钮与正常状态没有足够的视觉区分,用户不知道按钮为什么"没反应"。
解决方案: 使用 Button 的 .enabled(condition) 属性。当 condition 为 false 时,按钮自动变为灰色且不可点击:
.enabled(this.selectedCharIndex > 0) // 第一个字时禁用"上一个"
.enabled(this.selectedCharIndex < this.hanziList.length - 1) // 最后一个字时禁用"下一个"
同时将按钮背景色设为浅灰色 #EFEBE9,与主按钮的橙色形成对比,让用户直观感知到"这个按钮现在不能用"。
17.7 挑战七:状态重置的一致性
问题: 有多个操作会触发"切换到另一个字",包括点击"上一个"、“下一个”、点击首页汉字卡片。每次切换时都需要同时重置两个状态:selectedCharIndex 和 currentStrokeIndex。如果某个操作忘记重置 currentStrokeIndex,就会出现"切换到新字后笔顺状态混乱"的 bug。
解决方案: 在每一个切换汉字的 onClick 中,都同时设置两个状态:
// 点击"上一个"时
onClick(() => {
this.selectedCharIndex--; // 切换汉字
this.currentStrokeIndex = -1; // 重置笔顺(关键!)
})
// 点击"下一个"时
onClick(() => {
this.selectedCharIndex++; // 切换汉字
this.currentStrokeIndex = -1; // 重置笔顺(关键!)
})
// 点击首页汉字卡片时
onClick(() => {
this.selectedCharIndex = idx; // 选择汉字
this.currentStrokeIndex = -1; // 重置笔顺(关键!)
})
经验教训: 当多个 @State 变量之间存在依赖关系时(currentStrokeIndex 依赖于 selectedCharIndex),任何修改其中一个变量的操作都必须同步更新另一个。可以建立一个"状态变更清单",确保所有操作都遵守同一套规则。
18. 后续迭代计划
18.1 短期(v1.1)
| 功能 | 优先级 | 开发量 | 说明 |
|---|---|---|---|
| 手写笔画动画 | P0 | 5 天 | 用 Canvas 绘制笔画动画,替代静态文字列表 |
| 汉字搜索 | P1 | 2 天 | 支持按拼音或笔画数搜索 |
| 学习进度保存 | P1 | 3 天 | 记录已学会的字,下次打开可继续 |
| 随机复习模式 | P1 | 1 天 | 随机抽取已学汉字进行复习 |
18.2 中期(v1.2 - v2.0)
| 功能 | 优先级 | 说明 |
|---|---|---|
| 扩展字库到 100+ | P0 | 覆盖小学一年级全部生字 |
| 语音朗读 | P0 | 点击拼音朗读汉字发音 |
| 书写评分 | P1 | 通过触摸屏手写并 AI 评分 |
| 多主题色 | P2 | 用户可自定义配色方案 |
18.3 长期(v3.0+)
- AI 笔顺纠错:通过手势识别判断儿童书写是否正确
- 多语言版:English version “Chinese Character Stroke Order”
- 跨设备同步:手机和平板学习进度同步
- 家长报告:周/月学习报告,展示进步曲线
18.4 鸿蒙生态特色
| 功能 | 技术 | 描述 |
|---|---|---|
| 手表端"笔画卡片" | 元服务 | 手表上每天推送 3 个生字 |
| 平板端"田字格练习" | 手写笔 SDK | 配合 M-Pencil 手写练习 |
| 折叠屏"对比学习" | 多窗口 | 左边显示范例,右边手写练习 |
| 智慧屏"亲子课堂" | 投屏 | 大屏显示,家长陪同学习 |
19. 给初学者的建议
19.1 如何开始鸿蒙开发
如果你是从零开始学习鸿蒙开发,这个应用的代码量(约 310 行)和复杂度(仅有 2 个 @Builder、2 个 @State)非常适合作为入门练习。
学习路径:
第1步:理解 @Component 和 @State
└→ 修改 selectedCharIndex 的默认值,观察 UI 变化
第2步:修改 hanziList 数据
└→ 添加新的汉字,观察网格自动更新
第3步:调整配色
└→ 修改 backgroundColor、fontColor 的值
第4步:扩展功能
└→ 添加 "随机学习" 或 "笔画计数器"
19.2 常见错误自查表
| 症状 | 可能原因 | 解决方案 |
|---|---|---|
| 编译报错 “不能有变量声明” | 在 @Builder 中用了 let/const |
改为内联表达式 |
| 编译报错 “UI 代码位置错误” | 在普通方法中写了 Column() |
移到 @Builder 中 |
ForEach 不生效 |
嵌套了另一个 ForEach |
改用硬编码或展平 |
| 按钮无反应 | onClick 中的 this 指向错误 |
用箭头函数 () => {} |
| 布局不对 | 缺少 .width('100%') |
检查布局链是否完整 |
| 颜色不对 | 色值格式错误 | 用 16 进制 '#FFF8E1' |
20. 总结与感悟
20.1 技术总结
"儿童学写字笔顺学习"是一个技术极简、体验专注的教育应用。它的核心价值不在于技术复杂度,而在于对用户需求的精准理解和克制的实现。
| 技术指标 | 数值 |
|---|---|
| 代码行数 | ~310 行 |
| 状态变量 | 2 个 |
| @Builder | 2 个 |
| 资源体积 | 0 KB(纯代码) |
| 包体积 | ~42 KB |
| 冷启动 | < 1 秒 |
| 支持汉字 | 18 个 |
关键教训: 在 ArkTS 中,“简单"往往意味着"更兼容”。内联代码虽然看起来不够优雅,但在框架限制下是最可靠的选择。
20.2 教育应用的三个关键词
经过这次开发,我们总结了教育应用设计的三个关键词:
- 专注:只做一件事,并做好它。我们不试图教会孩子"所有东西",只教"笔顺"。
- 反馈:每一次操作都有明确的视觉反馈(颜色变化、状态图标)。
- 鼓励:用正向强化(🎉、✅)而不是负向反馈(❌、✗)。
20.3 鸿蒙生态的思考
作为一个新生平台,鸿蒙开发有一些独特的挑战(编译器的严格限制、API 的兼容性),但也有一些独特的优势(声明式 UI 的简洁、跨设备的分布式能力)。
对于儿童教育应用来说,鸿蒙生态的优势尤其明显:
- 华为设备的家长控制功能完善
- 平板 + 手写笔的组合非常适合教育场景
- 分布式能力可以实现"手机上选字、平板上练习"
我们期待随着 HarmonyOS NEXT 的成熟,这些特性能够更好地被开发者利用。
20.4 最后的感悟
在开发过程中,我们反复让自己回到一个问题:
“如果我是一个 5 岁的孩子,打开这个应用,我会喜欢它吗?”
这个问题的答案驱动了每一个设计决策:大按钮、温暖的配色、即时的反馈、鼓励性的语言。也许对于成人来说,这些细节看起来微不足道。但对一个第一次拿起手机学习写字的孩子来说,每一次"✅"的闪烁,都是一次小小的胜利。
技术的终极目标,不是展示技术本身,而是让使用技术的人忘记技术的存在。
21. 参考资料
鸿蒙官方文档
汉字笔顺参考
- 《现代汉语通用字笔顺规范》—— 国家语委发布
- 教育部《义务教育语文课程标准》—— 小学阶段识字与写字要求
儿童 UI 设计参考
- 《Designing for Children》—— 关于儿童应用 UI 设计的原则
- Material Design 3 —— 无障碍设计指南
- 《儿童交互设计:为儿童创造有意义的数字体验》
所有评论(0)