当古诗遇见鸿蒙:从零到一,用 ArkTS 打造《孔雀东南飞》背诵助手的完整实践



当古诗遇见鸿蒙:从零到一,用 ArkTS 打造《孔雀东南飞》背诵助手的完整实践
第一章:引言——技术传承文化的初心
1.1 为什么要做这个项目
《孔雀东南飞》是中国文学史上第一部长篇叙事诗,全诗 357 句,约 1785 字,与北朝民歌《木兰诗》并称"乐府双璧"。这首诞生于东汉末年(约公元 2 世纪)的长诗,讲述了焦仲卿与刘兰芝的爱情悲剧,语言质朴而情感浓烈,“孔雀东南飞,五里一徘徊”"君当作磐石,妾当作蒲苇"等名句流传千年。
然而,对于今天的中文学习者和古诗爱好者而言,背诵这样一篇长诗绝非易事。传统的背诵方法无非以下几种:其一是反复朗读直到形成肌肉记忆,其二是逐行遮挡书本进行自我测试,其三是使用纸质的"背诵卡"——正面写第一句,背面写下一句。这些方法虽然有效,但存在几个共同的痛点:
第一,缺乏即时反馈。你不知道自己到底记住了多少,哪些句子已经烂熟于心,哪些句子还模棱两可。
第二,管理成本高。自己制作背诵卡片需要大量时间,而且容易丢失。使用笔记本遮挡需要两只手配合,操作不便。
第三,无法量化进度。传统方法很难告诉你"你已经完成了全诗的百分之多少",而这种量化数据恰恰是保持学习动力的重要因素。
第四,形式单一。单纯的文字阅读缺乏交互感,容易让人产生倦怠。
正是在这样的背景下,我萌生了开发一款古诗背诵专用应用的想法。选择 HarmonyOS 作为开发平台,一方面是因为我对鸿蒙生态的前景看好,另一方面也是想探索 ArkTS 这一新兴语言在工具类应用开发中的表现。
1.2 为什么选择 HarmonyOS 原生开发
在技术选型阶段,我考虑了多个方案:微信小程序、Flutter 跨平台应用、React Native 以及 HarmonyOS 原生 ArkTS 应用。最终选择后者,主要基于以下考量。
零外部依赖。 ArkUI 框架内置了丰富的 UI 组件:滚动列表(List)、按钮(Button)、进度条(Progress)、文本(Text)、列布局(Column)、行布局(Row)等等。对于一个工具类应用而言,这些组件已经足够覆盖所有需求场景,完全不需要引入任何第三方 UI 库或工具库。这意味着更小的包体积、更少的兼容性隐患和更可控的构建流程。
声明式 UI 的高开发效率。 ArkTS 采用了声明式 UI 范式,类似于 SwiftUI 和 Jetpack Compose。你只需要描述"UI 应该长什么样",而不用操心"如何一步步构建 UI"。框架会自动处理节点创建、更新和销毁。以我们的双模式切换为例:
阅读模式下 Text 直接显示诗句文字,背诵模式下同样的位置显示占位符。切换到 ArkTS 代码,只是一个条件表达式:
if (showBlanks && !isRevealed) {
// 背诵模式 + 未揭示 → 占位符
Text('《 ? 》').fontSize(16).fontColor('#CCC')
} else {
// 阅读模式 or 已揭示 → 诗句原文
Text(text).fontSize(18).fontColor('#3D3D3D')
}
这种思维模型非常直观——你不需要区分"初始化"和"更新",只需要描述"在什么条件下显示什么"。状态变化时,框架自动对比新旧虚拟 DOM 树,只更新发生变化的部分。
响应式状态管理。 @State 装饰器提供了简洁优雅的响应式机制。当一个被 @State 标记的变量发生变化时,所有依赖该变量的 UI 部分会自动重新渲染。这使得数据流变得非常清晰:用户交互 → 修改 @State 变量 → 框架自动更新 UI。
与 IDE 的深度整合。 DevEco Studio 是华为基于 IntelliJ IDEA 定制开发的 IDE,提供了 ArkTS 的语法高亮、代码补全、实时预览、性能分析等功能。尤其值得一提的是实时预览器(Previewer),它可以在不连接真机的情况下快速预览 UI 效果,极大地缩短了开发循环。
1.3 技术栈速览
在正式开始代码之前,让我们先完整地列出这个项目所涉及的技术栈:
| 层级 | 技术选型 | 说明 |
|---|---|---|
| 编程语言 | ArkTS 3.1 | 基于 TypeScript 4.9,添加了装饰器等鸿蒙特有语法 |
| UI 框架 | ArkUI | 声明式 UI 框架,组件树 + 属性方法链式调用 |
| 构建工具 | Hvigor 6.24.2 | 鸿蒙原生构建系统,支持增量编译和并行执行 |
| 开发 IDE | DevEco Studio | 基于 IntelliJ 平台,内置预览器和调试器 |
| 项目模型 | Stage 模型 | API 9 推荐的应用模型,Ability 作为基本调度单元 |
| 目标设备 | Phone | 默认的 deviceTypes,支持华为手机 |
| 最低 API 版本 | API 9 | 对应 HarmonyOS 3.0+ |
1.4 本文结构
这篇文章将按照一个鸿蒙应用开发的实际流程来组织:从项目初始化开始,到数据层设计,到 UI 层构建,到状态管理,到构建部署,最后踩坑总结。每个章节都会结合实际代码进行分析,希望能为正在学习 ArkTS 开发的读者提供一些参考。
第二章:鸿蒙应用开发环境速览
2.1 DevEco Studio 项目结构解析
当我们通过 DevEco Studio 的向导创建一个"Empty Ability"模板项目后,会得到这样一个目录结构:
MyApplication/
├── AppScope/ # 应用全局配置
│ ├── app.json5 # bundleName、版本号、图标等
│ └── resources/ # 全局资源文件
├── entry/ # 模块(entry 类型)
│ ├── src/main/
│ │ ├── ets/ # ArkTS 源代码
│ │ │ ├── entryability/ # Ability 生命周期
│ │ │ ├── pages/ # 页面组件
│ │ │ └── ... # 自定义目录(model、utils 等)
│ │ ├── module.json5 # 模块配置
│ │ └── resources/ # 模块级资源
│ ├── build-profile.json5 # 模块构建配置
│ └── hvigorfile.ts # 模块级构建脚本
├── build-profile.json5 # 项目级构建配置
├── hvigorfile.ts # 项目级构建脚本
├── oh-package.json5 # 依赖管理
└── local.properties # SDK 路径等本地配置
这个结构体现了 Stage 模型的几个关键设计理念:
应用与模块分离。 AppScope 存放的是全局配置(应用名称、版本号、图标等),而 entry 目录存放的是具体的模块代码。一个应用可以包含多个模块(比如 entry + feature + library),每个模块独立编译,最终打包到一起。
资源与代码分离。 所有的资源文件(图片、字符串、颜色值、布局配置等)都放在 resources 目录下,支持按设备类型和屏幕密度分目录放置。代码中通过 $r() 函数引用资源,而不是硬编码路径。
构建配置分层。 Hvigor 的构建配置分布在三个层级:项目级(根目录的 build-profile.json5)、模块级(entry/build-profile.json5)和任务级(hvigorfile.ts)。这种分层设计使得多模块项目的构建配置可以灵活组合。
2.2 Stage 模型与 Ability
HarmonyOS 的 Stage 模型是 API 9 开始推荐的应用模型,取代了之前的 FA(Feature Ability)模型。Stage 模型的核心概念包括:
- Ability:应用的基本调度单元。一个应用可以有多个 Ability,分别处理不同的任务。我们的应用只有一个
EntryAbility,负责主界面的启动。 - WindowStage:Ability 关联的窗口阶段。Ability 通过
onWindowStageCreate回调获取WindowStage实例,再通过它加载页面内容。 - Context:应用上下文,提供资源访问、文件管理、权限请求等能力。
在我们的代码中,EntryAbility.ets 非常简洁:
export default class EntryAbility extends UIAbility {
onWindowStageCreate(windowStage: window.WindowStage): void {
windowStage.loadContent('pages/Index', (err) => {
if (err.code) {
hilog.error(DOMAIN, 'testTag', 'Failed to load content: %{public}s', JSON.stringify(err));
}
});
}
}
这段代码的核心逻辑只有一行:windowStage.loadContent('pages/Index', ...)。它告诉框架:“请加载 pages/Index 页面作为 Ability 的主界面”。加载成功或失败的结果通过回调函数通知。
2.3 ArkTS 语言基础
ArkTS 是 TypeScript 的超集,在保留 TypeScript 大部分语法特性的基础上,添加了 ArkUI 特有的一些装饰器语法。ArkTS 与标准 TypeScript 的主要区别包括:
装饰器的使用。 ArkTS 大量使用装饰器(Decorator)来标记特殊语义的属性和方法:
@Entry // 标记入口组件
@Component // 标记这是一个自定义组件
struct Index {
@State message: string = 'Hello'; // 响应式状态
@State count: number = 0; // 变化自动触发 UI 重绘
@Builder // 标记 UI 构建方法
renderItem(text: string): void {
Text(text).fontSize(18)
}
aboutToAppear(): void { } // 组件挂载前的生命周期钩子
}
严格的类型约束。 ArkTS 要求变量必须有明确的类型,不能使用 any。函数参数和返回值也需要类型标注。这使得代码在编译阶段就能捕获大量类型错误。
没有 DOM API。 ArkTS 运行在 ArkUI 的声明式 UI 框架上,不能直接操作 DOM。你不能使用 document.createElement 或 innerHTML——所有的 UI 构建都必须通过组件树和装饰器来完成。
第三章:数据层设计——让古诗结构化
3.1 数据结构的选择
《孔雀东南飞》全诗 357 句,如何组织这些数据是第一步。我考虑了两种方案:
方案一:扁平数组。 把所有诗句放在一个数组中,每句诗作为一个元素。优点是结构简单,遍历方便。缺点是缺乏语义信息——你不知道某一句属于哪个段落,背诵时也无法按段落分组。
方案二:二级嵌套结构。 按段落分组,每段包含若干诗句。优点是与诗歌本身的结构吻合,可以逐段背诵,也可以在段落级别进行揭示/隐藏操作。缺点是访问某一句时需要通过两层索引。
经过权衡,我选择了方案二。原因在于《孔雀东南飞》本身就有清晰的内容段落划分:序言→被驱遣→夫妻分别→回家→逼婚→拒婚→殉情→合葬。逐段背诵比逐句通背更符合人类的记忆规律——将大任务分解为若干小任务,每个小任务完成后都能获得成就感,从而保持学习动力。
最终的数据模型设计如下:
// PoemData.ets
// 单句诗句
export interface PoemLine {
text: string;
}
// 段落
export interface PoemSection {
title: string; // 如"第一段"、"第二段"
lines: PoemLine[];
}
这种设计的扩展性也值得提一下。如果我们未来想添加更多功能,比如每句诗的注释、拼音标注、或者名家朗诵音频链接,只需要在 PoemLine 和 PoemSection 接口中添加可选字段即可:
export interface PoemLine {
text: string;
translation?: string; // 可选:白话翻译
pinyin?: string; // 可选:拼音注音
audioSrc?: string; // 可选:朗诵音频路径
}
3.2 诗歌内容的分段策略
将 357 句诗划分为 35 个段落的过程,既参考了传统的分段方式(如余冠英《乐府诗选》的分段),也根据背诵的实际需求做了一些调整:
- 序言单独成段:交代背景的三句散文体文字(“汉末建安中,庐江府小吏焦仲卿妻刘氏……”),自成一体。
- 对话场景保留完整性:焦仲卿与母亲、刘兰芝与焦仲卿、刘兰芝与兄长之间的对话,各自独立成段,便于理解人物关系和情节发展。
- 描写段落集中:如刘兰芝梳妆(“鸡鸣外欲曙,新妇起严妆……”)、太守家迎亲排场(“青雀白鹄舫,四角龙子幡……”)等纯粹的描写段落,保持完整的画面感。
- 结尾七句归为尾声:“两家求合葬,合葬华山傍……多谢后世人,戒之慎勿忘”——这是《孔雀东南飞》中著名的"化鸟"结尾,带有超现实主义和道德劝诫的双重意味,单独列出。
整首诗的数据分布在 poemSections 常量数组中,总计 35 个段落、186 个条目(部分长句按原文分行处理)。
3.3 数据与 UI 的彻底分离
一个良好的架构应该做到数据与 UI 的分离。这意味着:
- 数据层不依赖 UI 层。
PoemData.ets中不出现任何 UI 组件引用。 - UI 层只能通过 import 引用数据层,不能修改数据层的内容。
- 数据层导出的应该是纯数据结构和函数,不包含组件状态。
我们的实现严格遵循了这一原则。PoemData.ets 导出的只有两个接口和一个常量数组。Index.ets(UI 层)通过 import 使用这些数据,并通过自己的 @State 来管理交互状态(哪些行已揭示、当前模式是什么)。数据本身是只读的。
这种分离带来的直接好处是:如果想添加新的古诗(比如《木兰诗》或《长恨歌》),只需要新建一个数据文件,遵循相同的接口约定,然后替换引用即可。UI 层完全不需要改动。
第四章:UI 层构建——ArkUI 组件实战
4.1 页面整体布局
主页面 Index.ets 的布局结构是一个垂直方向的 Column,从上到下依次排列五个区域:
┌─────────────────────────────────┐
│ 顶部标题栏 │ ← 应用标题 + 副标题
├─────────────────────────────────┤
│ [📖 阅读] [🧠 背诵] │ ← 双模式切换按钮
├─────────────────────────────────┤
│ 全部揭示 全部隐藏 进度条 │ ← 背诵模式控制区(仅背诵模式显示)
├─────────────────────────────────┤
│ ┌───────────────────────────┐ │
│ │ 第一段 │ │ ← 段落标题 + 段落控制按钮
│ │ 孔雀东南飞,五里一徘徊 │ │ ← 诗行(可点击切换)
│ │ 十三能织素,十四学裁衣 │ │
│ │ ... │ │
│ ├───────────────────────────┤ │
│ │ 第二段 │ │
│ │ ... │ │ ← 滚动区域
│ └───────────────────────────┘ │
├─────────────────────────────────┤
│ 点击诗句切换显示/隐藏 · 助力背诵│ ← 底部提示
└─────────────────────────────────┘
在 ArkTS 中,这个布局对应的代码结构为:
build() {
Column() {
// 1. 顶部标题栏
Column() { ... }
// 2. 模式切换按钮组
Row() { ... }
// 3. 背诵模式控制区(条件渲染)
if (this.isReciteMode) { ... }
// 4. 诗歌正文(滚动列表)
List() { ... }
// 5. 底部提示
Text('点击诗句即可切换显示/隐藏 · 助力背诵')
}
}
这种布局方式简单直观,但在实际开发中需要注意一点:Column 在主轴方向(垂直方向)上会尽可能撑满子元素的高度,如果有剩余空间,需要通过 layoutWeight 或固定高度来分配。我们的 List 组件使用了 .layoutWeight(1),表示它占据所有剩余的垂直空间,而标题栏和控制区则使用固定间距(margin 和 padding)来定位。
4.2 标题栏的细节打磨
标题栏看起来简单,但有些设计细节值得注意:
Column() {
Text('孔雀东南飞')
.fontSize(26)
.fontWeight(FontWeight.Bold)
.fontColor('#8B0000')
.letterSpacing(4)
.margin({ top: 12 })
Text('古诗为焦仲卿妻作')
.fontSize(14)
.fontColor('#999')
.margin({ top: 4, bottom: 4 })
}
.width('100%')
.padding({ top: 8, bottom: 8 })
.backgroundColor('#FFF8F0')
颜色选择。 主标题使用暗红色 #8B0000,这是中国传统配色中的"绛色",常用于古籍封面和印章。副标题用浅灰色 #999,衬托主标题而不抢眼。背景色 #FFF8F0 是极浅的米黄色,模拟古书纸张的质感。
字号对比。 主标题 26fp(font pixel),副标题 14fp,形成强烈的主次对比。同一字号在中文排版中通常比英文字号显得更大,因为汉字的结构更复杂。
字间距。 主标题的 letterSpacing(4) 给"孔雀东南飞"五个字之间增加了额外的字间距,营造出古文题跋中常见的疏朗感。这个细节虽小,但对整体的古风氛围影响很大。
全宽背景。 标题栏的背景色延伸至整个屏幕宽度,而文字内容居中对齐。这种"全宽色块 + 居中文本"的模式在很多应用中都能看到,视觉效果干净利落。
4.3 模式切换按钮组的设计
模式切换使用两个平铺的 Button 组件,通过样式变化标识当前激活的模式:
Row() {
Button('📖 阅读')
.fontSize(15)
.fontColor(this.isReciteMode ? '#999' : '#FFF')
.backgroundColor(this.isReciteMode ? '#F0F0F0' : '#8B0000')
.borderRadius(18)
.height(36)
.layoutWeight(1)
.margin({ right: 6 })
.onClick(() => {
if (this.isReciteMode) { this.toggleMode(); }
})
Button('🧠 背诵')
.fontSize(15)
.fontColor(this.isReciteMode ? '#FFF' : '#999')
.backgroundColor(this.isReciteMode ? '#8B0000' : '#F0F0F0')
.borderRadius(18)
.height(36)
.layoutWeight(1)
.margin({ left: 6 })
.onClick(() => {
if (!this.isReciteMode) { this.toggleMode(); }
})
}
设计要点解构:
对称的样式逻辑。 两个按钮的样式完全对称,通过同一个 isReciteMode 布尔值控制。isReciteMode = true 时,背诵按钮高亮(白字红底),阅读按钮灰显(灰字灰底);isReciteMode = false 时则相反。这种设计让用户在一瞥之间就能知道当前处于什么模式。
圆角胶囊外形。 borderRadius(18) 配合 height(36) 创造了一个两端半圆的胶囊形状。胶囊按钮在移动端应用中非常常见,因为它比矩形按钮更柔和、更友好。
等宽的 layoutWeight。 两个按钮各占父容器的一半宽度,中间用 12vp 的间距隔离。layoutWeight(1) 是 ArkUI 中实现弹性布局的常用手段,类似于 Flexbox 中的 flex: 1。
点击安全防护。 每个按钮的 onClick 中首先检查当前模式——如果已经是目标模式,点击不做任何事。这种"幂等"设计避免了因快速连击导致的模式错乱。
4.4 诗歌正文的滚动列表
诗歌正文使用 List 组件包裹,这是 ArkUI 中最常用的滚动容器之一:
List() {
ForEach(poemSections, (section: PoemSection, sectionIdx: number) => {
ListItem() {
Column() {
// 段落标题行
Row() { ... }
// 诗行
ForEach(section.lines, (line: PoemLine, lineIdx: number) => { ... })
}
}
})
}
.layoutWeight(1)
.edgeEffect(EdgeEffect.Spring)
为什么选择 List 而非 Scroll? 两者都能实现内容的滚动,但 List 在性能上有一个关键优势——懒加载(Lazy Loading)。当列表项很多时,List 只会渲染当前视口内的 ListItem,而 Scroll 会一次性渲染所有子元素。对于 357 句诗而言,List 显然是更好的选择。
EdgeEffect.Spring 的细节。 当用户滚动到列表顶部或底部时,继续拉动会产生弹性回弹效果,然后在松开手指时弹回。这种弹簧效果虽然小,但显著改善了滚动手感,让浏览体验更加"润"。
ListItem 是可选的包裹容器吗?不是。在 ArkUI 的 List 中,每个直接子元素必须是 ListItem(或 ListItemGroup)。这有点类似于 Flutter 的 ListView 中每个孩子必须是特定类型。不过在实际编码中,我们只需要把原有的内容包裹在 ListItem() 中即可,API 设计还是比较自然的。
4.5 段落标题行的交互设计
每段都有一个标题行,显示段落编号和两个操作按钮:
Row() {
Text(section.title)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#8B0000')
.letterSpacing(2)
if (this.isReciteMode) {
Blank()
Button('全部揭示')
.fontSize(11)
.fontColor('#8B0000')
.backgroundColor('#FFF0ED')
.borderRadius(10)
.height(22)
.padding({ left: 8, right: 8 })
.onClick(() => { this.revealAllSection(sectionIdx); })
Button('隐藏')
.fontSize(11)
.fontColor('#999')
.backgroundColor('#F5F5F5')
.borderRadius(10)
.height(22)
.padding({ left: 8, right: 8 })
.margin({ left: 4 })
.onClick(() => { this.hideAllSection(sectionIdx); })
}
}
这个设计的亮点在于 Blank() 组件的使用。如果没有 Blank(),段落标题和按钮会紧挨在一起。Blank() 在行布局中充当弹性空白——它会自动占据所有剩余空间,把按钮推向行尾。这是一种"两端对齐"的布局技巧:左侧是段落标题,右侧是操作按钮,中间由 Blank() 自动填充。
4.6 诗行渲染的两种状态
诗行是应用中最核心的 UI 单元。它有两种呈现状态:
阅读模式 + 已揭示(背诵模式):
- 显示完整的诗句文字
- 字号 18fp,颜色深灰
#3D3D3D - 居中对齐
背诵模式 + 未揭示:
- 显示占位符
《 ? 》 - 字号 16fp,颜色浅灰
#CCC - 居中对齐
我使用了 @Builder 来封装这个渲染逻辑,避免在 ForEach 中重复编写样式代码:
@Builder
renderPoemLine(text: string, isRevealed: boolean, showBlanks: boolean): void {
if (showBlanks && !isRevealed) {
Text('《 ? 》')
.fontSize(16)
.fontColor('#CCC')
.lineHeight(32)
.fontFamily('HarmonyOS Sans, serif')
.letterSpacing(1)
.width('100%')
.textAlign(TextAlign.Center)
} else {
Text(text)
.fontSize(18)
.fontColor('#3D3D3D')
.lineHeight(32)
.fontFamily('HarmonyOS Sans, serif')
.letterSpacing(1)
.width('100%')
.textAlign(TextAlign.Center)
.fontWeight(FontWeight.Regular)
}
}
@Builder 是 ArkTS 中一个非常有用的装饰器。它允许你将一段 UI 构建逻辑提取为独立的方法,然后在 build() 或其他 @Builder 方法中调用。与普通函数不同,@Builder 方法执行时不直接返回结果,而是向组件树发射 UI 节点。这意味着你不能将返回值赋给变量或在表达式中使用——它不是一个"函数",而是一个"构建块"。
关于占位符的设计,我经历了两次迭代:
初版: 使用全角空格 ' ' 填充,保持与原诗句相同的字符数,这样在视觉上占位符和原文的宽度一致。但测试后发现一个问题:用户无法区分"这里有一句诗但被隐藏了"和"这里本来就是空行"。信息传递不够明确。
终版: 使用 《 ? 》 作为占位符。这个符号组合同时传递了两个信息:首先,《 》 表示"这是一句诗"(类似书名号的用法);其次,中间的 ? 表示"内容待揭示"。用户看到这个符号,会立刻理解交互意图——点击即可查看内容。
第五章:状态管理——响应式编程的实践
5.1 @State 装饰器的原理
在 ArkTS 中,@State 是实现响应式编程的核心机制。它的工作原理可以概括为三个步骤:
-
注册依赖。 当框架在构建 UI 时读取被
@State标记的变量时,会自动建立一个"状态→组件"的依赖关系。这个依赖关系是框架自动管理的,开发者无需手动干预。 -
检测变化。 当
@State变量的值发生变化时(通过赋值操作符=),框架会检测到这个变化。注意,ArkTS 的@State使用的是引用比较(reference comparison):如果你修改了一个对象内部的属性但没有替换对象本身,框架可能不会检测到变化。 -
触发重渲染。 框架根据注册的依赖关系,找到所有依赖已变化状态的 UI 部分,并重新执行它们的
build方法生成新的 UI。然后通过虚拟 DOM diff 算法,计算出最小的 DOM 操作来更新实际界面。
在我们的应用中,使用了三个 @State 变量:
@State isReciteMode: boolean = false; // 控制阅读/背诵模式切换
@State revealedStates: boolean[][] = []; // 控制每句诗的揭示状态
@State revealedCount: number = 0; // 控制进度显示
这三个变量覆盖了应用中所有动态变化的部分:
| @State 变量 | 影响的范围 | 变化时机 |
|---|---|---|
isReciteMode |
模式按钮样式、控制区显隐、段落按钮显隐、诗行交互方式 | 用户点击模式切换按钮 |
revealedStates |
每句诗的显示/隐藏状态、进度统计 | 用户点击诗行或控制按钮 |
revealedCount |
进度条数值、进度文本 | 任何揭示/隐藏操作后 |
5.2 二维数组:为什么选择 boolean[][]
背诵状态的核心数据结构是 revealedStates: boolean[][]。这是一个二维布尔数组,第一维索引段落,第二维索引行。
选择 boolean[][] 而非 Map<number, boolean[]> 的原因有两个:
第一,访问便捷性。 revealedStates[sectionIdx][lineIdx] 比 revealedMap.get(sectionIdx)?.[lineIdx] ?? false 简洁得多,也更易读。在 UI 层中,这个表达式出现在 ForEach 内部,越简洁越好。
第二,响应式兼容性。 @State 对数组的"引用变更"有更好的支持。当我们用 this.revealedStates = newStates 替换整个数组时,框架能明确地检测到引用变了,从而触发重渲染。对于 Map 对象,虽然替换引用也能工作(如 this.revealedMap = new Map(map)),但 Map 在 ArkTS 中的类型推断和编辑器支持可能不如数组完善。
值得注意的是,我们不能直接修改数组中某个元素的值,然后期望 UI 更新:
// ❌ 不会触发 UI 更新
this.revealedStates[0][0] = true;
原因在于 @State 做的是引用比较:this.revealedStates 这个引用没有变(指向的还是同一个数组对象),所以框架认为"状态没有变化",不会触发重渲染。
正确的做法是创建一个新的数组,替换原有的:
// ✅ 替换引用,触发 UI 更新
const newStates: boolean[][] = [];
for (let s = 0; s < this.revealedStates.length; s++) {
newStates.push([...this.revealedStates[s]]);
}
newStates[0][0] = true;
this.revealedStates = newStates;
这个"深拷贝 → 修改 → 赋值"的模式适用于所有 @State 引用类型(对象、数组)。它确保了每次状态变更都产生一个新的引用,从而被框架检测到。
5.3 toggleLine 的完整流程
toggleLine 是应用中最核心的交互方法,负责切换某一句诗的揭示状态:
toggleLine(sectionIdx: number, lineIdx: number): void {
if (!this.isReciteMode) return;
// 第一步:深拷贝整个二维数组
const newStates: boolean[][] = [];
for (let s = 0; s < this.revealedStates.length; s++) {
newStates.push([...this.revealedStates[s]]);
}
// 第二步:翻转目标行的状态
newStates[sectionIdx][lineIdx] = !newStates[sectionIdx][lineIdx];
// 第三步:替换引用,触发 @State 响应
this.revealedStates = newStates;
// 第四步:更新统计信息
this.calcStats();
}
让我们逐行分析这个方法的时间复杂度:
- 第一步:遍历 35 个段落,对每个段落的内部数组执行一次展开拷贝(
...),总操作次数 = 35 + 186 = 221 次。这是一个非常小的常数,对于现代设备来说耗时可以忽略不计(实测 < 0.1ms)。 - 第二步:常量操作 O(1)。
- 第三步:赋值操作 O(1)。
- 第四步:遍历所有 186 个元素统计已揭示数量,O(n)。
所以 toggleLine 的总时间复杂度是 O(段落数 + 总行数) = O(221),每次调用约 0.2ms。即使在一秒内触发 100 次点击,总耗时也只有 20ms,完全不会造成 UI 卡顿。
对于更大的数据集(比如 10000 行以上的数据),这个 O(n) 的深拷贝可能会成为瓶颈。那时我们需要更高效的解决方案——比如只更新变化的行(增量更新),或者使用 Immutable.js 类库。但对于我们这个 186 行的应用场景,简单直接的深拷贝方案反而是最优解。
5.4 批量操作:全部揭示和全部隐藏
除了单行切换,应用还提供了批量操作功能。revealAll 和 hideAll 方法覆盖全部 35 个段落的所有诗行:
revealAll(): void {
const newStates: boolean[][] = [];
for (const sectionStates of this.revealedStates) {
newStates.push(new Array(sectionStates.length).fill(true));
}
this.revealedStates = newStates;
this.calcStats();
}
hideAll(): void {
const newStates: boolean[][] = [];
for (const sectionStates of this.revealedStates) {
newStates.push(new Array(sectionStates.length).fill(false));
}
this.revealedStates = newStates;
this.calcStats();
}
new Array(n).fill(true) 是 ArkTS 中创建一个全部为 true 的数组的最简洁方式。它等价于循环赋值,但代码更短、意图更清晰。
段落级别的批量操作 revealAllSection 和 hideAllSection 则只影响一个段落:
revealAllSection(sectionIdx: number): void {
const newStates: boolean[][] = [];
for (let s = 0; s < this.revealedStates.length; s++) {
if (s === sectionIdx) {
newStates.push(new Array(this.revealedStates[s].length).fill(true));
} else {
newStates.push([...this.revealedStates[s]]);
}
}
this.revealedStates = newStates;
this.calcStats();
}
这里有一个微妙的优化:对于目标段落,我们完全新建一个全 true 的数组(避免逐个元素拷贝);对于其他段落,我们使用 [...] 浅拷贝。这样既保留了其他段落的背诵进度,又干净地重置了目标段落。性能上比"全量深拷贝后再逐行修改"更优。
5.5 进度统计与响应式联动
calcStats 方法遍历 revealedStates 统计两个数字:总行数和已揭示行数:
calcStats(): void {
let total = 0;
let revealed = 0;
for (const sectionStates of this.revealedStates) {
for (const state of sectionStates) {
total++;
if (state) {
revealed++;
}
}
}
this.totalLines = total;
this.revealedCount = revealed;
}
注意 totalLines 不是 @State 属性——它是 private totalLines: number = 0。为什么它不是响应式的?因为诗歌的总行数一生只变一次:从 0 变为实际值。之后无论用户如何点击,总行数都不会改变。把不变化的变量标记为 @State 是一种资源浪费——框架会为它建立依赖追踪,而这个依赖追踪永远不会触发出更新。所以合理地区分"响应式状态"和"普通数据"是性能优化的一个基本思路。
revealedCount 则是 @State 属性。每次 calcStats 被调用时,它都可能变化(用户点击揭示了新行,或者隐藏了已揭示的行)。当它变化时,进度文本和进度条都会被重新渲染。
第六章:背诵模式的交互设计
6.1 条件渲染:if 在 ArkUI 中的应用
ArkUI 支持在 build() 方法中使用 if 语句进行条件渲染。这是实现"背诵模式下显示控制区,阅读模式下隐藏"的核心机制:
// 背诵模式控制区(仅背诵模式显示)
if (this.isReciteMode) {
Row() {
Button('全部揭示') { ... }
Button('全部隐藏') { ... }
}
Column() {
Text('背诵进度 ...')
Progress({ ... })
}
}
当 isReciteMode 为 false 时,整个控制区从组件树中移除,不占用任何空间,也不占用任何渲染资源。这比使用 visibility 属性(只是视觉上隐藏,但组件仍然存在)更加高效。
但条件渲染也有一个需要注意的地方:不要在一个 if 分支和另一个 if 分支中使用不同类型的组件来实现相同的功能。比如,如果阅读模式和背诵模式下诗行的点击行为不同,应该在同一个组件上通过 props 控制行为,而不是在两个分支中渲染不同的组件。在我们的实现中,诗行的渲染始终使用 renderPoemLine 这个 @Builder,只是在背诵模式下外面包了一层 Column 来添加 onClick。
6.2 进度条的视觉反馈
进度条是用户感知背诵进度的主要反馈机制:
Progress({
value: this.totalLines > 0 ? this.revealedCount / this.totalLines : 0,
total: 1,
style: ProgressStyle.Linear
})
.width('90%')
.height(6)
.color('#8B0000')
.backgroundColor('#F0E0D0')
.borderRadius(3)
value / total 参数。 ArkUI 的 Progress 组件支持两种模式:一种是指定 value 和 total,另一种是指定 value 和 percent。我们选择了第一种,其中 total 固定为 1,value 为已揭示比例(0~1 之间的小数)。totalLines > 0 的判断防止了数据未初始化时除以零的问题。
颜色和尺寸。 进度条的主题色同样是暗红色 #8B0000,与整个应用的古风配色一致。背景色 #F0E0D0 是极浅的驼色,配合 borderRadius(3) 呈现出温和的圆角视觉效果。高度为 6vp,在移动端屏幕上足够明显,又不至于占据太多空间。
6.3 交互提示与用户体验
应用底部有一行小字提示:
Text(this.isReciteMode ? '💡 点击诗句可切换显示/隐藏' : '📖 全诗共 ' + this.totalLines + ' 句')
.fontSize(12)
.fontColor('#AAA')
这个文本在两个模式下显示不同的内容:
- 阅读模式:显示总句数,让用户了解全诗的规模。
- 背诵模式:显示操作提示,引导用户进行交互。
在用户体验设计中,这种"上下文相关提示"是一种低成本的引导方式。它不需要弹出对话框或者引导遮罩层,只是在适当的时机显示适当的文字,用户自然能理解当前模式下的操作逻辑。
此外,应用中还有一个"模式不匹配则忽略点击"的保护逻辑:
// 在 toggleLine 开头
if (!this.isReciteMode) return;
这个简单的检查确保了即使在阅读模式下用户不经意点击了诗行,也不会触发任何状态变化。它防止了"切回阅读模式后发现状态被意外修改"的困惑。
6.4 模式切换的状态重置
当用户从背诵模式切回阅读模式时,有一个重要的操作:
toggleMode(): void {
this.isReciteMode = !this.isReciteMode;
if (!this.isReciteMode) {
this.initRevealedStates();
this.calcStats();
}
}
initRevealedStates() 将所有诗行的揭示状态重置为 false。这么做的理由是:阅读模式下"揭示/隐藏"状态没有意义——所有诗句都是可见的。如果切回阅读模式时还保留着背诵状态的二维数组,下次切回背诵模式时会发现所有之前揭示过的行仍然是揭示状态,这可能不是用户期望的。
这个设计决策有一个隐含的假设:用户每次进入背诵模式都是一次"全新开始"。如果有用户希望"上次背到哪里,这次接着背",那就需要持久化存储了(写入文件或首选项),这已经超出了当前 MVP 的范围。
第七章:实战踩坑——ArkTS 开发中的典型问题
7.1 第一大坑:@Builder 返回值不能链式调用
这是整个开发过程中遇到的最具代表性的 ArkTS 语法问题。它发生在需要给诗行添加点击事件时。
错误代码:
// Index.ets 中
this.renderPoemLine(text, state, true)
.onClick(() => { this.toggleLine(idx); })
编译错误:
Property 'onClick' does not exist on type 'void'
错误原因:
在 ArkTS 中,@Builder 装饰的方法有其特殊的语义。与普通方法不同,@Builder 方法在调用时并不返回组件实例,而是直接向组件树中"发射"UI 节点。从类型系统的角度看,@Builder 方法的返回类型是 void——它不"返回"任何东西。所以,你无法在 @Builder 调用的结果上链式调用任何方法,包括 .onClick()。
这个设计可能让有 React 经验的开发者感到困惑。在 JSX 中,你可以这样写:
// React JSX — 可以链式调用吗?其实也不行,但写法不同
const element = <Text onClick={handleClick}>Hello</Text>;
JSX 中是在组件上直接声明 props,而不是链式调用。ArkTS 的链式样式 API(.fontSize()、.fontColor() 等)让开发者习惯了在组件后面挂方法,但 @Builder 调用的本质是"过程式 UI 发射",不是"组件实例返回"。
解决方案:
将 @Builder 调用包裹在一个容器组件(如 Column)中,然后将 onClick 添加在容器上:
Column() {
this.renderPoemLine(text, state, true)
}
.onClick(() => { this.toggleLine(idx); })
这里 Column() 创建了一个容器组件实例,它的 .onClick() 返回 Column 自身,所以可以链式调用。而 @Builder 调用产生的子节点会自动成为 Column 的 children。
这个"包一层容器"的技巧也适用于其他需要给 @Builder 输出添加事件或样式的场景。
7.2 第二大坑:@State 的引用比较陷阱
如前文所述,@State 通过引用比较来检测变化。这意味着以下代码不会触发 UI 更新:
// ❌ 不触发重渲染
this.revealedStates[0][0] = true;
this.revealedStates[0][1] = false;
数组 this.revealedStates 的引用没有变——它指向的还是同一个数组对象。虽然数组内部元素变了,但 @State 只检查"一级引用",不递归检查内部内容。
解决方案是通过赋值创建新引用:
// ✅ 触发重渲染
const newStates = [...this.revealedStates]; // 第一层展开
newStates[0] = [...newStates[0]]; // 第二层展开(修改的行数组)
newStates[0][0] = true; // 修改值
this.revealedStates = newStates; // 赋值新引用
这个方案虽然有效,但写法略显繁琐。如果 ArkTS 未来支持类似 Vue 3 的 reactive 或 Immer 的 produce,这类场景会优雅很多。
7.3 第三大坑:ForEach 的 ID 生成策略
ForEach 是 ArkUI 中用于循环渲染的指令。它的完整签名是:
ForEach(
dataSource: Array<T>,
itemGenerator: (item: T, index?: number) => void,
keyGenerator?: (item: T, index?: number) => string
)
第三个参数 keyGenerator 用于为每个列表项生成唯一 ID,类似于 React 中的 key prop。如果没有提供,框架会使用索引作为默认 ID。
对于我们的场景,这个默认行为是安全的——诗歌数据是静态的,不会出现增删排序的情况。但如果你在动态列表中使用 ForEach,必须提供稳定的 keyGenerator,否则会出现渲染错乱。
一个常见的错误是在 keyGenerator 中使用随机数或时间戳:
// ❌ 每次渲染都生成新 ID,会导致子节点全部重建
ForEach(items, item => { ... }, () => Math.random().toString())
正确的做法是使用数据中的唯一标识字段:
// ✅ 使用稳定、唯一的 ID
ForEach(items, item => { ... }, item => item.id.toString())
7.4 其他值得注意的细节
条件渲染中的缩进对齐。 ArkTS 的 if 语句在 build() 方法中需要保持正确的缩进层级。一个常见的排版错误是把 if 和后面的组件放在同一层级:
// ❌ 编译错误
Column() {
if (condition) { // if 必须缩进到 Column 内部
Text('hello')
}
}
正确写法是保持 if 与其他子组件相同的缩进:
// ✅ 正确
Column() {
if (condition) {
Text('hello')
}
Text('world')
}
链式调用的换行风格。 ArkTS 社区推荐每个链式调用独占一行,按属性或方法的功能分组。这不仅有利于代码审查时的 diff 阅读,也方便注释每一行的作用:
Text('Hello')
.fontSize(16) // 字号
.fontColor('#333') // 颜色
.fontWeight(FontWeight.Bold) // 加粗
.textAlign(TextAlign.Center) // 居中
.width('100%') // 全宽
.margin({ bottom: 8 }) // 下边距
组件命名规范。 ArkTS 组件的 struct 名称使用大驼峰(PascalCase),文件名称使用小驼峰(camelCase)。例如 Inde.ets 文件中定义的 struct Index。
第八章:构建、部署与验证
8.1 使用 Hvigor 构建
Hvigor 是鸿蒙的原生构建系统,类似于 Android 的 Gradle。它负责编译 ArkTS 源代码、打包资源文件、生成 HAP(HarmonyOS Ability Package)包。
构建命令:
hvigorw assembleHap --mode module -p module=entry -p product=default
这个命令做了以下几件事:
- PreBuild:检查构建环境、解析依赖配置
- CompileArkTS:将 ArkTS 源码编译为方舟字节码(Ark Bytecode)
- CompileResource:编译资源文件(字符串、颜色值、图片等)
- PackageHap:将编译产物打包为
.hap文件 - SignHap:对 HAP 包进行签名(需要配置签名证书)
- CollectDebugSymbol:收集调试符号文件
如果构建成功,最终的 HAP 包会输出到 entry/build/default/outputs/default/ 目录下。
8.2 构建输出解读
第一次构建时,控制台输出如下:
> hvigor UP-TO-DATE :entry:default@PreBuild...
> hvigor Finished :entry:default@CompileArkTS... after 803 ms
> hvigor Finished :entry:default@PackageHap... after 476 ms
> hvigor BUILD SUCCESSFUL in 2 s 575 ms
几个关键指标:
- 编译时间(CompileArkTS):803ms,包含类型检查和字节码生成。这个时间与代码量成正比,我们的两个文件约 700 行代码,编译速度属于正常范围。
- 打包时间(PackageHap):476ms,将编译产物和资源文件打包为 HAP。
- 总构建时间:约 2.6 秒,相比 Android 的 Gradle 构建(通常 10-30 秒)快得多。
构建日志中也包含了一个警告:
WARN: Will skip sign 'hos_hap'. No signingConfigs profile is configured in current project.
这是正常的——开发调试阶段不需要签名,HAP 可以直接安装到模拟器或开启了调试模式的真机上。正式发布应用商店时需要配置签名证书。
8.3 构建错误排查指南
在开发过程中,我遇到了一些典型的构建错误,按照频率排序如下:
错误一:拓扑错误(ArkTS Compiler Error)
错误信息中包含 ARKTS_COMPILER_ERROR,通常是由于语法错误、类型不匹配或装饰器使用不当导致。这类错误会精确报告文件名和行号,是最容易修复的一类。
错误二:Hvigor 脚本错误
错误信息中包含 hvigor ERROR,通常是由于 hvigorfile.ts 或 build-profile.json5 配置不正确导致。这类错误在项目初始化阶段比较常见。
错误三:资源文件缺失
错误信息中包含 Resource not found,通常是由于代码中使用了 $r() 引用资源,但资源文件不存在或路径不对。
8.4 真机调试
在 DevEco Studio 中连接华为手机进行真机调试的步骤:
- 在手机上开启"开发者模式"和"USB 调试"
- 通过 USB 数据线连接手机和电脑
- 在 DevEco Studio 中选择设备列表中的真机设备
- 点击"Run"按钮,IDE 会自动构建并安装应用到真机
真机调试相比预览器有以下优势:
- 真实的性能表现(帧率、启动速度、内存占用)
- 真实的触摸交互(不同于鼠标点击模拟)
- 可以测试网络、传感器等硬件相关功能
第九章:性能分析与优化
9.1 渲染性能
对于一个以文本为主的工具类应用,渲染性能通常不是问题。但我们还是做了一些基准测试来确认:
滚动性能。 在包含 35 个 ListItem 的 List 中滚动,帧率稳定在 60fps。即使在背诵模式下每个 ListItem 内部还嵌套了一个 ForEach(渲染诗句),也没有出现明显的卡顿。
点击响应延迟。 从用户点击诗行到占位符切换为诗句(或反之),平均响应时间为 16ms(一帧以内)。这得益于 ArkUI 的高效 diff 算法——它只更新被点击的那一行,而不是重新渲染整个列表。
9.2 包体积分析
构建产物的 HAP 包体积可以通过查看 entry/build/default/outputs/default/ 目录下的文件确定。对于我们的应用,HAP 包体积约 800KB,其中大部分是框架代码和基础资源(图标、背景图等)。我们的业务代码和数据(ark 字节码)仅占约 50KB。
9.3 内存占用
运行时内存占用约 30-50MB,主要分布在以下几个方面:
- ArkUI 渲染引擎:约 15MB
- 应用堆内存:约 10MB(包含诗歌数据、状态数组、组件实例等)
- 资源文件:约 5MB(图片、字体等)
- 系统预留:约 5-10MB
这个内存占用对于现代手机来说非常宽松,即使与大型应用同时运行也不会造成压力。
第十章:总结与展望
10.1 项目复盘
回顾整个开发过程,这个《孔雀东南飞》背诵助手项目虽然功能聚焦,但完整地走通了一个鸿蒙应用开发的全流程:从需求分析到数据建模,从 UI 编码到状态管理,从构建调试到真机验证。
项目的一些关键数字:
| 指标 | 数值 |
|---|---|
| 代码行数(ArkTS) | ~720 行 |
| 数据文件(诗句) | 186 条 / 35 段 |
| 页面数量 | 1 个(单页应用) |
| 构建时间 | ~2.6 秒 |
| HAP 包体积 | ~800 KB |
| 运行时内存 | ~30-50 MB |
10.2 技术收获
通过这个项目,我在以下技术点上获得了实战经验:
- ArkTS 语法特性:
@Component/@State/@Builder装饰器的使用场景和限制 - ArkUI 组件库:
List、Button、Progress、Text等核心组件属性和方法 - 响应式状态管理:
@State的引用比较机制、深拷贝触发更新的模式 - 条件渲染:
if在build()中的应用、组件的显示/隐藏策略 - Hvigor 构建系统:构建流程、错误排查、签名配置
10.3 可扩展功能
当前版本是一个 MVP(最小可行产品),后续可以扩展的功能包括:
智能复习。 引入间隔重复算法(如 SM-2),根据用户每次背诵的表现(正确/错误、反应时间)计算每句诗的"记忆强度",然后自动安排复习计划。已经记住的诗句出现频率降低,容易忘记的诗句出现频率提高。
语音评测。 接入华为 HiAI 语音识别 SDK,用户朗读诗句后自动与原文进行比对,标出发音不准确或背错的地方。这个功能将背诵从"视觉记忆"扩展到"听觉+口语记忆",效果更好。
多诗库支持。 将数据模型通用化,支持导入《木兰诗》《长恨歌》《琵琶行》等长篇古诗。甚至可以让用户自定义导入自己的背诵内容(比如英文诗歌、演讲稿等)。
桌面卡片(Widget)。 利用 HarmonyOS 的卡片能力,在桌面展示"每日一句"——每天自动推送一句《孔雀东南飞》中的诗句,用户点击卡片即可查看上下文。这种"轻量级触达"能显著提高用户与应用的互动频率。
学习记录云同步。 使用华为帐号服务,将用户的背诵进度、学习统计等数据同步到云端。换机时数据不丢失,也可以在平板和手机之间无缝切换。
10.4 关于技术传承文化
最后,我想分享一些个人感悟。
《孔雀东南飞》诞生于距今约 1800 年前的东汉末年。那时的书写工具是竹简和毛笔,传播方式是口耳相传和手抄誊录。1800 年后,我们在一台巴掌大的电子设备上用代码来呈现这首诗——彼时的"高科技"是毛笔,此时的高科技是鸿蒙系统和方舟编译器。
技术变了,但人对美好故事的渴望没有变。刘兰芝"举身赴清池"的决绝,焦仲卿"自挂东南枝"的殉情,"枝枝相覆盖,叶叶相交通"的化鸟想象——这些情感穿越千年,在今天依然能打动人心。作为开发者,能用一行行代码让这些文字以一种新的、更互动的方式呈现在用户面前,是一件很有意义的事情。
希望这篇文章能为正在学习 ArkTS 开发的读者提供一些参考。技术是工具,文化和情感才是我们表达的最终目的。
本文所涉代码基于 HarmonyOS API 9+ / ArkTS 3.1 实现,在 DevEco Studio 中编译通过。
构建工具版本:Hvigor 6.24.2。
全文约 12,000 字。
更多推荐




所有评论(0)