从零构建「孔雀东南飞」背诵 App:HarmonyOS ArkTS 实践全记录




目录
- 项目缘起
- 技术选型与架构设计
- 开发环境搭建
- 项目结构规划
- 数据层:诗文的数字化建模
- 页面层:三Tab框架的实现
- 全文浏览模式
- 提示背诵模式
- 逐句默写模式
- 编译构建与签名
- 踩坑记录与最佳实践
- 总结与展望
一、项目缘起
《孔雀东南飞》是中国文学史上第一部长篇叙事诗,被誉为"乐府双璧"之一。全诗三百五十七句,一千七百余字,讲述了刘兰芝与焦仲卿的爱情悲剧。如此经典的文学作品,至今没有一个专门为背诵设计的移动端工具。
这个项目就是为了填补这个空白——用 HarmonyOS ArkTS 构建一个轻量级的古诗背诵助手。目标很简单:让用户能在手机上反复诵读、练习默写,直至全文成诵。
项目从立项到完成,核心代码只有两个文件、约 830 行代码,充分体现了 ArkTS 在快速原型开发上的效率。
二、技术选型与架构设计
2.1 为什么选择 HarmonyOS ArkTS
选择 ArkTS 有几个务实的理由:
| 因素 | 说明 |
|---|---|
| 声明式UI | ArkTS 的 @Component + @Builder 模型与 SwiftUI / Jetpack Compose 类似,学习曲线平缓 |
| 单语言覆盖 | 逻辑层和视图层都用 TypeScript 语法,无需在 Kotlin/Swift/Java 之间切换 |
| Stage 模型 | API 24 强制 Stage 模型,生命周期管理更清晰 |
| 原生性能 | ArkTS 编译为方舟字节码,不依赖 WebView,滚动大量文本也没有卡顿 |
2.2 整体架构
应用采用单页面多视图架构,而非多页面路由。原因有二:一是功能简单,三个Tab之间的切换无需复杂的路由栈管理;二是保持状态共享的方便性——三个Tab共享同一份诗词数据。
┌──────────────────────────────────┐
│ App (Index.ets) │
│ ┌────────────────────────────┐ │
│ │ 标题栏 │ │
│ ├────────────────────────────┤ │
│ │ Tab 导航 (全文/背诵/默写) │ │
│ ├────────────────────────────┤ │
│ │ │ │
│ │ ┌─── Tab 0: 全文浏览 ──┐ │ │
│ │ │ Scroll + ForEach │ │ │
│ │ │ POEM_SECTIONS │ │ │
│ │ └──────────────────────┘ │ │
│ │ │ │
│ │ ┌─── Tab 1: 提示背诵 ──┐ │ │
│ │ │ 按段背诵 / 逐句背诵 │ │ │
│ │ │ showLineText toggle │ │ │
│ │ └──────────────────────┘ │ │
│ │ │ │
│ │ ┌─── Tab 2: 逐句默写 ──┐ │ │
│ │ │ TextArea + 自动判对 │ │ │
│ │ └──────────────────────┘ │ │
│ └────────────────────────────┘ │
└──────────────────────────────────┘
↑ 数据层 ↓
┌────────────────────────────┐
│ PoemData.ets (数据模型) │
│ POEM_SECTIONS[] │
│ getAllLines() │
└────────────────────────────┘
2.3 核心依赖
项目的 oh-package.json5 十分干净,没有任何第三方依赖:
{
"dependencies": {}
}
所有 UI 组件全部使用 ArkUI 框架内置组件,零外部引入。这对小型应用来说是一个显著的优势——不必操心版本兼容和包体积膨胀。
三、开发环境搭建
3.1 工具链版本
| 组件 | 版本 |
|---|---|
| DevEco Studio | 6.1.x(推荐最新) |
| HarmonyOS SDK | API 24 (6.1.1) |
| ArkTS 版本 | 基于 TypeScript 5.0+ |
| 构建工具 | hvigor 6.1.1 |
3.2 项目初始化要点
创建项目时选择 Empty Ability 模板,API 选择 24,模型选择 Stage。生成骨架后需要做两件事:
- 确认
build-profile.json5中targetSdkVersion和compatibleSdkVersion为"6.1.1(24)" - 确认
buildModeSet包含debug和release
{
"products": [{
"name": "default",
"targetSdkVersion": "6.1.1(24)",
"compatibleSdkVersion": "6.1.1(24)",
"runtimeOS": "HarmonyOS"
}]
}
这里有一个容易忽略的细节:runtimeOS 字段必须明确声明为 "HarmonyOS",否则在打包时可能失败。
3.3 目录结构
项目初始化后,需要手动创建 models/ 目录来存放数据文件:
entry/src/main/ets/
├── entryability/
│ └── EntryAbility.ets # Ability生命周期入口(自动生成)
├── models/
│ └── PoemData.ets # 诗词数据和类型定义(手动创建)
└── pages/
└── Index.ets # 主页面(手动重写)
需要注册页面路由:在 resources/base/profile/main_pages.json 中:
{
"src": ["pages/Index"]
}
四、项目结构规划
4.1 文件职责划分
这是一个非常简洁的结构,只有两个核心文件:
PoemData.ets ← 纯数据层,零UI依赖
Index.ets ← 纯UI层,消费数据层
这种分离的好处显而易见:
- 数据层可测试:
PoemData.ets可以独立运行,其导出的函数可以在单元测试中直接调用 - UI层可替换:如果需要换用不同的UI框架(比如从 ArkTS 换到 Java UI),数据层完全不需要改动
- 模块化边界清晰:
Index.ets通过import消费数据,不直接修改数据,符合单向数据流原则
4.2 为什么不使用多页面路由
这个应用只有三个功能Tab,且它们共享同一份诗词数据。如果拆成三个独立的 @Page:
- 要么把数据放在全局(污染命名空间)
- 要么在页面间通过
router.push传参(数据量太大,URL 参数有长度限制) - 要么使用 AppStorage / LocalStorage(增加了复杂度)
最简单的方案就是单页面 + @State 控制视图切换。ArkTS 的 @State 装饰器天然支持这种模式——当 currentTabIndex 变化时,框架自动重新渲染对应的视图分支。
@State currentTabIndex: number = 0;
build() {
Column() {
// 标题栏
// Tab导航
// 内容区域(条件渲染)
if (this.currentTabIndex === 0) {
this.buildFullTextView();
} else if (this.currentTabIndex === 1) {
this.buildReciteView();
} else {
this.buildQuizView();
}
}
}
五、数据层:诗文的数字化建模
5.1 数据结构设计
这是整个应用的基础。我需要把一篇线性文本转化为计算机可以处理的结构化数据。
核心需求:
- 支持分段显示(按原诗的叙事段落分组)
- 支持逐句背诵(需要访问每一行诗句)
- 支持提示模式(显示诗句的开头和结尾几个字)
基于这些需求,设计了三个数据结构:
// 一行诗句
export interface PoemLine {
text: string; // 完整诗句
startHint: string; // 提示开头几个字
endHint: string; // 提示结尾几个字
}
// 一个段落
export interface PoemSection {
title: string;
lines: PoemLine[];
}
5.2 提示生成算法
提示模式的关键在于 startHint 和 endHint。生成逻辑基于诗句长度动态调整:
function makeLine(text: string): PoemLine {
const len = text.length;
if (len <= 6) {
// 短句(如"阿母谓府吏"):只显示首尾各1个字
return {
text,
startHint: text.substring(0, 1),
endHint: text.substring(len - 1),
};
} else if (len <= 10) {
// 中等长度:首尾各2个字
return {
text,
startHint: text.substring(0, 2),
endHint: text.substring(len - 2),
};
} else {
// 长句(含对话):首尾各3个字
return {
text,
startHint: text.substring(0, 3),
endHint: text.substring(len - 3),
};
}
}
这个算法的设计逻辑是:
- 太短的句子不需要太多提示,1个字已经足够唤起记忆
- 长句提供的提示多一个字,帮助用户定位
- 所有长度规则在编译期决定,不在运行时计算,对性能零影响
5.3 全诗数据组织
全诗被组织为 15 个段落(含序言),每个段落按叙事节奏划分:
| 段落 | 名称 | 内容 | 行数 |
|---|---|---|---|
| 序 | 序 | 小序背景 | 3 |
| 一 | 自述 | 兰芝的身世陈述 | 11 |
| 二 | 求母 | 仲卿向母亲求情 | 5 |
| 三 | 母怒 | 焦母拒绝 | 11 |
| 四 | 话别 | 夫妻告别 | 17 |
| 五 | 严妆 | 兰芝梳妆辞别 | 18 |
| 六 | 盟誓 | 路口盟誓 | 13 |
| 七 | 还家 | 兰芝回家见母 | 8 |
| 八 | 求亲 | 县令求婚 | 15 |
| 九 | 太守 | 太守求婚,兄长逼迫 | 17 |
| 十 | 备婚 | 太守家备办婚礼 | 14 |
| 十一 | 悲泣 | 兰芝缝衣哭泣 | 8 |
| 十二 | 死别 | 焦仲卿赶来,生死诀别 | 19 |
| 十三 | 别母 | 仲卿拜别母亲 | 15 |
| 十四 | 殉情 | 两人殉情 | 7 |
| 十五 | 化鸟 | 合葬化鸟 | 7 |
段落的划分不是随意的,它遵循故事的内在叙事节奏。例如 “死别” 段是高潮,对话密集,行数最多。
5.4 按段 vs 逐句的映射
数据层同时支持两种访问模式:
// 按段访问:直接在UI层通过 POEM_SECTIONS[index] 访问
POEM_SECTIONS[this.reciteSectionIndex]
// 逐句访问:通过 getAllLines() 获取平铺数组
export function getAllLines(): PoemLine[] {
const lines: PoemLine[] = [];
for (const section of POEM_SECTIONS) {
for (const line of section.lines) {
if (line.text.trim() !== '') {
lines.push(line);
}
}
}
return lines;
}
getAllLines() 过滤了空行(段落中的分隔留白),只返回实际有文本的诗句。这在逐句背诵和默写模式中非常重要——用户不应该在空行上浪费翻页操作。
六、页面层:三Tab框架的实现
6.1 @Component 结构
整个 Index.ets 是一个 @Component,通过 @State 装饰的变量来管理所有交互状态:
@Entry
@Component
struct Index {
@State currentTabIndex: number = 0; // Tab切换
@State reciteSectionIndex: number = 0; // 背诵-当前段
@State reciteLineIndex: number = 0; // 背诵-当前行
@State showLineText: boolean = false; // 背诵-显示/隐藏
@State reciteMode: number = 0; // 背诵-模式(按段/逐句)
@State userInput: string = ''; // 默写-用户输入
@State showAnswer: boolean = false; // 默写-显示答案
@State currentQuizLineIndex: number = 0; // 默写-当前行
@State showLineResult: boolean = false; // 默写-对错结果
}
每个 @State 变量都可以独立触发UI更新。比如当用户点击"下一句"时,只需要更新 reciteLineIndex 和 showLineText 两个状态,框架自动重绘对应区域。
6.2 @Builder 拆分视图
ArkTS 的 @Builder 装饰器允许将视图拆分为独立的方法,然后在 build() 中组合。这比将所有代码写在 build() 中可读性强得多:
build() {
Column() {
this.buildTitleBar();
this.buildTabBar();
if (this.currentTabIndex === 0) {
this.buildFullTextView();
} else if (this.currentTabIndex === 1) {
this.buildReciteView();
} else {
this.buildQuizView();
}
}
}
6.3 自定义Tab导航
没有使用 Tab 容器组件(Tabs + TabContent),因为 ArkUI 的 Tabs 组件在早期版本中切换动画不够流畅,且自定义样式受限。我选择自己实现:
@Builder
buildTabItem(title: string, index: number) {
Column() {
Text(title)
.fontSize(15)
.fontColor(this.currentTabIndex === index ? '#E8D5B7' : '#7A7A9A')
.fontWeight(this.currentTabIndex === index ? FontWeight.Medium : FontWeight.Regular)
if (this.currentTabIndex === index) {
Divider()
.width('60%')
.height(2)
.color('#E8D5B7')
.margin({ top: 4 })
}
}
.height('100%')
.justifyContent(FlexAlign.Center)
.layoutWeight(1)
.onClick(() => {
this.currentTabIndex = index;
})
}
这里的技巧:
.layoutWeight(1)让三个Tab等宽平分- 选中项显示金色下划线,未选中不显示
- 点击时直接修改
currentTabIndex,下方视图自动切换
七、全文浏览模式
7.1 长文本滚动的实现
ArkUI 的 Scroll + Column 组合可以处理任意长度的文本:
@Builder
buildFullTextView() {
Scroll() {
Column() {
Text('孔雀东南飞')
.fontSize(24)
.fontWeight(FontWeight.Bold)
.fontColor('#E8D5B7')
Text('(汉乐府)')
.fontSize(14)
.fontColor('#A0896E')
ForEach(POEM_SECTIONS, (section: PoemSection) => {
Column() {
Text(section.title)
.fontSize(16)
.fontColor('#C4A97D')
ForEach(section.lines, (line: PoemLine) => {
if (line.text === '') {
Blank().height(12) // 空行用 Blank 占位
} else {
Text(line.text)
.fontSize(17)
.fontColor('#D4C5A9')
.lineHeight(30)
.textAlign(TextAlign.Center)
.width('100%')
}
})
}
.padding({ left: 20, right: 20 })
})
}
.width('100%')
}
}
7.2 性能考量
对于全诗 15 个段落约 170 行文本(含空行分隔),Scroll 的渲染性能完全不是问题。但如果诗句数量再大一个数量级(数千行),就需要考虑 LazyForEach 来实现虚拟列表。ArkTS 的 LazyForEach 在 API 24 中已经支持,用法与 ForEach 类似,只是需要提供一个数据源来告诉框架哪些 item 在可视区内。
7.3 样式设计
浏览模式采用了深色古风主题:
| 元素 | 颜色 |
|---|---|
| 背景 | #1A1A2E(深蓝黑) |
| 标题 | #E8D5B7(金色) |
| 诗句 | #D4C5A9(米白) |
| 段落标题 | #C4A97D(暗金) |
| 副标题 | #A0896E(浅棕) |
这套配色模仿了古籍的视觉感受——不是白底黑字的现代印刷风格,而是更接近古籍泛黄的纸张和墨色。
八、提示背诵模式
8.1 两种背诵子模式
提示背诵是应用的核心功能,支持两种模式:
- 按段背诵:一次显示整段内容(但被隐藏),适合把握段落结构和整体逻辑
- 逐句背诵:一句一句过,适合精细记忆
用户可以通过顶部的按钮自由切换:
@Builder
buildModeButton(title: string, mode: number) {
Button(title)
.fontColor(this.reciteMode === mode ? '#1A1A2E' : '#E8D5B7')
.backgroundColor(this.reciteMode === mode ? '#E8D5B7' : '#3A3A5C')
// ...
.onClick(() => {
this.reciteMode = mode;
// 重置所有背诵状态
this.reciteSectionIndex = 0;
this.reciteLineIndex = 0;
this.showLineText = false;
})
}
8.2 显示/隐藏切换的逻辑
核心交互是"点击按钮切换诗句的显示状态":
Text(this.showLineText ? line.text : line.startHint + '……' + line.endHint)
.fontColor(this.showLineText ? '#D4C5A9' : '#6A6A8A')
当 showLineText 为 false 时:
- 显示
"十三……归"(三段式提示:开头 + …… + 结尾) - 颜色为暗灰色(
#6A6A8A),视觉上不突出
当 showLineText 为 true 时:
- 显示完整诗句
- 颜色为亮色(
#D4C5A9)
用户可以通过同一个按钮反复切换,这与传统的"卡片翻转"式记忆法原理相同——先尝试回忆,再核对原文。
8.3 按键处理中的边界条件
背诵模式中有几个容易忽视的边界条件:
// 上一段:检查上界
.enabled(this.reciteSectionIndex > 0)
.onClick(() => {
if (this.reciteSectionIndex > 0) {
this.reciteSectionIndex--;
this.showLineText = false; // 切换段落时自动隐藏
}
})
// 下一段:检查下界
.enabled(this.reciteSectionIndex < POEM_SECTIONS.length - 1)
- 上界检查:第一段时"上一段"按钮禁用
- 下界检查:最后一段时"下一段"按钮禁用
- 自动隐藏:切换段落/句子时,
showLineText重置为false,强迫用户在新内容上再次尝试回忆
8.4 当前进度展示
逐句背诵时显示进度条信息:
Text('第 ' + (this.reciteLineIndex + 1) + ' / ' + this.allLines.length + ' 句')
.fontSize(14)
.fontColor('#A0896E')
让用户随时知道自己处于背诵的哪个阶段。这对长诗的背诵至关重要——知道已经完成了多少、还剩多少,可以减轻背诵过程中的焦虑感。
九、逐句默写模式
9.1 交互流程
逐句默写是应用中最复杂的功能,交互流程如下:
- 系统显示提示(开头 + …… + 结尾)
- 用户在
TextArea中输入诗句 - 点击"检查"按钮,系统自动判断对错
- 如果不会,可以点击"显示答案"
- 点击"下一句"继续
9.2 TextArea 的使用
ArkUI 的 TextArea 组件提供了多行文本输入能力:
TextArea({ text: this.userInput, placeholder: '在此输入诗句……' })
.height(60)
.fontSize(17)
.fontColor('#D4C5A9')
.placeholderFont({ size: 15, weight: FontWeight.Regular })
.placeholderColor('#5A5A7A')
.backgroundColor('#2A2A44')
.borderRadius(12)
.onChange((val: string) => {
this.userInput = val;
this.showLineResult = false; // 用户修改输入时清除上次的检查结果
})
使用要点:
placeholder属性在输入框为空时显示占位提示onChange回调在每次输入时触发,用于同步状态- 用户修改输入时清除之前的检查结果,防止新旧结果混淆
9.3 自动判对逻辑
判断逻辑简单直接:
if (this.userInput.trim() === this.allLines[this.currentQuizLineIndex].text) {
Text('✓ 正确!').fontColor('#7BCF8C')
} else {
Text('✗ 再想想').fontColor('#E87A7A')
}
使用的是严格相等匹配(===)。这里有一个权衡:
- 严格匹配:用户必须一字不差地输入,要求高,但适合精确背诵
- 模糊匹配:如果允许部分匹配或忽略标点,可能更适合初学阶段
当前版本选择了严格模式,后续可以增加"宽松模式"选项,允许忽略中英文标点和空格的差异。
9.4 随机抽背功能
默写模式实现了"随机跳转"功能,用于测试用户对整个诗篇的掌握程度:
Button('随机跳转')
.onClick(() => {
const randomIdx = Math.floor(Math.random() * this.allLines.length);
this.currentQuizLineIndex = randomIdx;
this.userInput = '';
this.showAnswer = false;
this.showLineResult = false;
})
这个功能看似简单,但实际上解决了背诵中的一个核心问题:顺序背诵容易,随机考核困难。很多人可以按顺序背出整首诗,但被打断后无法接上。随机跳转正是针对这个问题设计的。
十、编译构建与签名
10.1 构建流程
使用 hvigor 构建工具,一次完整的构建包括:
hvigor UP-TO-DATE :entry:default@PreBuild
hvigor Finished :entry:default@CompileArkTS ← 编译 ArkTS → 方舟字节码
hvigor Finished :entry:default@CompileResource ← 编译资源
hvigor Finished :entry:default@PackageHap ← 打包 HAP
hvigor Finished :entry:default@SignHap ← 签名
hvigor BUILD SUCCESSFUL
整个构建流程在 8 秒左右完成(含增量编译),对于小型应用来说非常快。
10.2 签名注意事项
构建输出中有一个关键警告:
Will skip sign 'hos_hap'. No signingConfigs profile is configured in current project.
这意味着 HAP 包是未签名的。在真机上运行需要配置签名证书。调试阶段有两个选择:
- 自动签名(推荐):在 DevEco Studio 中登录华为开发者账号,让 IDE 自动管理签名
- 手动配置:在
build-profile.json5的signingConfigs中配置 keystore 信息
{
"app": {
"signingConfigs": [{
"name": "default",
"material": {
"certpath": "xxx.cer",
"profile": "xxx.p7b",
"keystore": {
"keyalias": "xxx",
"storePassword": "xxx",
"keypass": "xxx"
}
}
}]
}
}
10.3 部署测试
签名后的 HAP 包可以通过两种方式部署到真机或模拟器:
- DevEco Studio 中直接点击 Run
- 使用命令行:
hdc install entry/build/default/outputs/default/entry-default-unsigned.hap
十一、踩坑记录与最佳实践
11.1 ForEach 的 key 生成
在 ArkTS 中,ForEach 默认使用数组索引作为 key。在遍历 POEM_SECTIONS 和 section.lines 时没有问题,因为数据是静态的。但如果数据是动态的(增删改),必须提供一个 keyGenerator 函数:
ForEach(arr, (item) => { /* ... */ }, (item) => item.id)
11.2 @Builder 中访问 @State
在 @Builder 方法中可以直接访问 @State 变量,因为 @Builder 是 @Component 的方法,共享同一个 this 上下文。但如果在 @Builder 内部嵌套了 @Builder(或使用了 @LocalBuilder),则需要注意作用域问题。
11.3 颜色值的大小写
ArkUI 的颜色值必须使用十六进制格式,大小写敏感度较低,但建议统一使用大写:
.backgroundColor('#1A1A2E') // ✔ 正确
.backgroundColor('#1a1a2e') // ✔ 也正确,但建议统一
11.4 字符串模板
ArkTS 不支持 JavaScript 的模板字符串(反引号):
// ❌ 不支持
const msg = `第 ${index + 1} / ${total} 句`;
// ✔ 使用字符串拼接
const msg = '第 ' + (index + 1) + ' / ' + total + ' 句';
这是一个容易踩的坑——跟 TypeScript 语法非常相似但不完全相同。
11.5 Scroll 嵌套 Column 的布局
Scroll 必须直接包裹 Column 或 Row,不能直接包裹多个子元素:
// ❌ 错误
Scroll() {
Text('a')
Text('b')
}
// ✔ 正确
Scroll() {
Column() {
Text('a')
Text('b')
}
}
11.6 数组类型的泛型写法
ArkTS 的泛型语法要求在变量名后标注类型,而不是在构造器后:
// ✔ ArkTS 支持的写法
private allLines: PoemLine[] = getAllLines();
// ArkTS 也支持
// private allLines: Array<PoemLine> = getAllLines();
十二、总结与展望
12.1 项目数据
| 指标 | 数据 |
|---|---|
| 源代码文件 | 2 个 |
| 总代码行数 | ~830 行 |
| 构建时间 | ~8 秒 |
| 构建产物 | HAP 约 2 MB |
12.2 可以扩展的方向
这个应用目前是 MVP(最小可行产品),还有很多可以优化的方向:
- 语音识别背诵:利用 HarmonyOS 的语音识别能力,用户可以直接朗读诗句,系统自动判断是否正确
- 云端同步:接入华为帐号服务,将学习进度同步到云端
- AI 辅助:利用盘古大模型,对用户的背诵进行智能评分,给出针对性建议
- 暗黑模式:适配系统深色/浅色主题切换
- 更多古诗:扩展为一个通用的古诗词背诵平台
- 离线包:支持用户下载诗词语音包
12.3 个人感悟
用 ArkTS 构建这个项目,最大的感受是:声明式UI的威力不在于花哨的动画,而在于让状态管理变得可预测。
在传统的命令式UI中,当用户点击"下一句"时,你需要手动找到对应的 TextView,调用 setText(),再更新另一个 View 的可见性。而在 ArkTS 中,你只需要更新一个 @State 变量,剩下的交给框架去做。这种模式大幅减少了 UI 相关的 bug。
一个有趣的细节是:整个应用只用了一个 .ets 文件作为主页面。对于 830 行的代码量来说,这完全能接受。每个 @Builder 之间的边界清晰,读代码的顺序就是阅读 UI 从上到下的顺序。但如果应用继续扩展(超过 2000 行),就应该拆分为多个 Component 文件了。
12.4 写在最后
技术博客的结尾,终归要回到产品本身。
《孔雀东南飞》用它千年不衰的生命力告诉我们:真正好的作品,无论历经多少时代,总能找到与当下对话的方式。而作为开发者,能用一行行代码为经典的传播做一点微小的贡献,是一种幸运。
感谢 HarmonyOS 生态提供的技术平台,也感谢所有愿意花时间读完这篇博客的读者。如果你也在学习 ArkTS,希望这篇文章能给你一些启发。
十三、深入 State 管理与响应式编程
13.1 @State 的响应式原理
ArkTS 的 @State 装饰器基于观察者模式实现。当一个被 @State 修饰的变量发生变化时,框架会自动标记依赖该变量的组件为"脏"(dirty),并在下一帧进行重绘。开发者不需要手动调用 setState() 或 notifyDataSetChanged()——这些都是框架自动完成的。
在我们的应用中,每个 @State 变量的变化都会触发不同范围的UI更新:
| @State 变量 | 变化时影响的范围 | 更新复杂度 |
|---|---|---|
currentTabIndex |
整个内容区域 | 全量重建 |
reciteLineIndex |
仅当前行Text | 局部更新 |
showLineText |
当前段/行的Text节点 | 局部更新 |
userInput |
TextArea + 结果区域 | 局部更新 |
showLineResult |
仅对错提示 | 局部更新 |
框架的渲染引擎足够智能,只会重新渲染依赖于变化状态的 if 分支或 Text 节点,而不是重建整个组件树。这意味着在 Tab 切换时(currentTabIndex 变化),只有对应分支的 @Builder 会被执行,其他两个 Tab 的内容会被跳过。
13.2 @Prop 与 @Link 的使用时机
虽然这个应用没有使用子组件(所有视图都集中在 Index.ets 中),但如果需要将视图拆分为独立组件,就需要理解 @Prop 和 @Link 的区别:
- @Prop:单向数据流。父组件传递给子组件的数据,子组件可以读取但不能修改(修改会触发编译错误或运行时警告)
- @Link:双向绑定。父组件和子组件共享同一个状态,任一方修改都会同步到另一方
// 父组件
@Component
struct Parent {
@State count: number = 0;
build() {
Child({ count: this.count }) // @Prop: 只读传递
LinkedChild({ count: this.count }) // @Link: 双向绑定
}
}
// 子组件 - 只读
@Component
struct Child {
@Prop count: number;
build() { Text('' + this.count) }
}
// 子组件 - 双向
@Component
struct LinkedChild {
@Link count: number;
build() { Button('+').onClick(() => { this.count++ }) }
}
在这个应用中,如果未来将三个 Tab 拆分为独立的 @Component,背诵 Tab 的 showLineText 应该用 @Link 传递(因为背诵组件内部会修改它),而全诗数据 POEM_SECTIONS 则适合用 @Prop 传递(只读)。
13.3 @Watch 的使用场景
@Watch 装饰器可以在 @State 变量变化时执行一个回调函数。这在需要"监听变化后执行副作用"的场景中非常有用:
@State @Watch('onLineChange') reciteLineIndex: number = 0;
onLineChange(): void {
// 每次切换诗句时自动重置状态
this.showLineText = false;
this.showAnswer = false;
// 可以在这里添加日志或统计分析
console.info(`用户浏览到第 ${this.reciteLineIndex + 1} 句`);
}
在这个应用中,我没有使用 @Watch,因为所有状态重置逻辑都放在按钮的 onClick 回调中手动处理。但如果状态管理变得更复杂,@Watch 可以确保某个状态变化时的一定执行某个逻辑,比手动调用更可靠。
十四、Stage 模型的生命周期管理
14.1 UIAbility 的生命周期
我们的应用逻辑全部在 Index.ets 的 @Component 中,但 Ability 层的生命周期管理仍然值得了解。EntryAbility.ets 中定义了应用级别的生命周期:
export default class EntryAbility extends UIAbility {
onCreate(want, launchParam) { /* 应用启动时调用 */ }
onDestroy() { /* 应用销毁时调用 */ }
onWindowStageCreate(windowStage) {
windowStage.loadContent('pages/Index', (err) => {
// 在这里加载主页面
});
}
onForeground() { /* 应用进入前台 */ }
onBackground() { /* 应用进入后台 */ }
}
对于我们的背诗应用,onBackground 和 onForeground 可以用于实现"进度持久化":
import { localStorage } from '@kit.ArkUI';
onBackground(): void {
// 保存当前背诵进度
AppStorage.set<number>('lastSection', this.reciteSectionIndex);
AppStorage.set<number>('lastLine', this.reciteLineIndex);
}
onForeground(): void {
// 恢复上次的背诵进度
const savedSection = AppStorage.get<number>('lastSection') ?? 0;
const savedLine = AppStorage.get<number>('lastLine') ?? 0;
this.reciteSectionIndex = savedSection;
this.reciteLineIndex = savedLine;
}
14.2 AppStorage 与 LocalStorage
ArkTS 提供了两个用于跨组件共享状态的存储方案:
- AppStorage:应用级别的全局单例,所有组件都可以访问。适合保存用户设置、学习进度等跨页面数据
- LocalStorage:页面级别的共享存储,在 Ability 和 Page 之间共享
// AppStorage 使用示例
AppStorage.setOrCreate('fontSize', 17); // 设置默认值
// 在组件中绑定
@StorageLink('fontSize') fontSize: number = 17;
// 在另一个组件中读取
@StorageProp('fontSize') fontSize: number = 17;
14.3 配置持久化方案
为了给用户更好的体验,我设计了以下持久化方案(作为未来迭代方向):
AppStorage 键值规划:
├── 'fontSize' → 用户偏好的字体大小 (默认 17)
├── 'lastSection' → 上次背诵的段落索引
├── 'lastLine' → 上次背诵的诗句索引
├── 'reciteMode' → 上次使用的背诵模式 (0/1)
├── 'favoriteLines' → 用户收藏的诗句索引数组
└── 'completedLines' → 已完成的诗句索引 Set
这些数据通过 AppStorage 实现跨会话的持久化,即使用户关闭应用再打开,进度也不会丢失。
十五、格式化与视觉设计细节
15.1 字体选择与排版
ArkUI 中的 Text 组件支持丰富的排版属性。在古诗应用中,以下几个属性特别重要:
Text('孔雀东南飞')
.fontSize(24) // 字体大小
.fontWeight(FontWeight.Bold) // 字重
.fontColor('#E8D5B7') // 字体颜色
.lineHeight(30) // 行高(影响阅读体验的关键)
.textAlign(TextAlign.Center) // 居中对齐(古诗的美学需求)
.letterSpacing(2) // 字符间距(古风效果)
行高(lineHeight)对古诗阅读体验的影响最大。古诗每行字数少,如果行高太小,阅读时容易跳行;如果太大,又浪费空间。经过测试,对于 17px 的字体大小,30px 的行高是最舒适的。
15.2 颜色体系设计
应用的颜色系统基于深色古风主题,采用了色彩映射表来保证一致性:
| CSS 变量名 | 色值 | 用途 |
|---|---|---|
| bg-primary | #1A1A2E |
主背景 |
| bg-secondary | #16213E |
标题栏背景 |
| bg-card | #2A2A44 |
输入框/卡片背景 |
| tab-inactive | #3A3A5C |
Tab/按钮背景(未选中) |
| text-gold | #E8D5B7 |
标题/选中Tab文字 |
| text-body | #D4C5A9 |
诗句正文 |
| text-section | #C4A97D |
段落标题 |
| text-muted | #A0896E |
副标题/提示文字 |
| text-hint | #6A6A8A |
隐藏状态的诗句 |
| text-placeholder | #5A5A7A |
输入框占位符 |
| success | #7BCF8C |
正确提示 |
| error | #E87A7A |
错误提示 |
所有的颜色值都直接硬编码在 Index.ets 中。对于更大型的应用,建议将这些颜色值提取到 resources/base/element/color.json 中,通过 $r('app.color.xxx') 引用,这样可以支持系统级的暗黑/浅色主题适配。
15.3 响应式布局考量
虽然目前应用只适配了竖屏手机,但在设计时已经考虑了几个响应式的关键点:
.layoutWeight(1) // 等分空间
.width('100%') // 宽度自适应
.height('100%') // 高度自适应
.padding({ left: 20, right: 20 }) // 安全的边距
.layoutWeight(1) 是 ArkUI 中最强大的布局工具之一。它类似于 Flexbox 中的 flex: 1,可以让多个子元素按权重等分父容器空间。如下所示:
Row() {
Button('检查').layoutWeight(1)
Button('显示答案').layoutWeight(1)
Button('下一句').layoutWeight(1)
}
三个按钮自动等宽,无论屏幕宽度如何变化,始终保持三等分。
十六、测试与质量保障
16.1 ArkTS 单元测试
HarmonyOS 提供了 @ohos/hypium 测试框架,支持标准的三段式测试(Arrange-Act-Assert):
// 在 ohosTest 模块中
import { describe, it, expect } from '@ohos/hypium';
import { getAllLines, POEM_SECTIONS } from '@/models/PoemData';
export default function poemDataTest() {
describe('PoemDataTest', () => {
it('should have 15 sections', 0, () => {
expect(POEM_SECTIONS.length).assertEqual(15);
});
it('should have non-empty lines', 0, () => {
const lines = getAllLines();
expect(lines.length).assertGreaterThan(0);
lines.forEach(line => {
expect(line.text.length).assertGreaterThan(0);
expect(line.startHint.length).assertGreaterThan(0);
expect(line.endHint.length).assertGreaterThan(0);
});
});
it('startHint should be prefix of text', 0, () => {
const lines = getAllLines();
lines.forEach(line => {
expect(line.text.startsWith(line.startHint)).assertTrue();
expect(line.text.endsWith(line.endHint)).assertTrue();
});
});
it('should contain first line', 0, () => {
const lines = getAllLines();
expect(lines[0].text).assertEqual('孔雀东南飞,五里一徘徊。');
});
});
}
这些测试覆盖了数据层最核心的契约:
- 段落数量固定为 15
- 所有诗句非空
- 提示内容必须是完整诗句的前缀/后缀
- 第一行诗句必须正确
16.2 本地测试执行
在 DevEco Studio 中执行本地测试(非模拟器):
hvigorw run ohosTest --module entry --product default --build-mode debug
// entry/oh-package.json5 中的测试配置
{
"devDependencies": {
"@ohos/hypium": "1.0.25",
"@ohos/hamock": "1.0.0"
}
}
16.3 UI 交互测试
对于 UI 层,可以利用 @ohos/hamock 进行组件级别的交互测试。例如测试"下一句"按钮是否正常工作:
import { renderComponent } from '@ohos/hamock';
// 模拟点击下一句按钮
const component = renderComponent(Index);
const nextButton = component.findComponent('nextLineButton');
nextButton.triggerClick();
// 验证状态变化
expect(component.getState('reciteLineIndex')).assertEqual(1);
expect(component.getState('showLineText')).assertFalse();
16.4 性能测试指标
对于古诗背诵这类文本密集型应用,需要关注以下性能指标:
| 指标 | 目标值 | 测试方法 |
|---|---|---|
| 首屏渲染时间 | < 500ms | performance.now() 打点 |
| Scroll 帧率 | > 55fps | DevEco Profiler |
| 内存占用 | < 80MB | DevEco Profiler |
| HAP 包体积 | < 3MB | 查看构建产物 |
| 状态更新延迟 | < 16ms | Profiler 帧分析 |
对于当前应用,这些指标都很容易达成,因为:
- 数据量小(不到 200 条文本节点)
- 无网络请求
- 无图片加载
- 无动画循环
十七、API 24 (SDK 6.1.1) 新特性利用
17.1 API 24 的关键变化
HarmonyOS SDK 6.1.1 (API 24) 引入了多项对开发者友好的改进:
- 方舟编译器性能优化:@Component 的编译产物更小,运行时内存占用更低
- @Builder 参数传递增强:支持在 @Builder 方法中传递回调函数
- Scroll 组件改进:支持
edgeEffect属性,可以控制滚动到边界时的回弹效果 - TextArea 增强:placeholder 属性支持单独设置字体颜色和大小
17.2 项目中实际使用的 API 24 特性
在我们的应用中,以下特性依赖 API 24:
// 1. TextArea 的 placeholderFont 单独设置
TextArea({ placeholder: '在此输入诗句……' })
.placeholderFont({ size: 15, weight: FontWeight.Regular })
.placeholderColor('#5A5A7A')
// 这在 API 23 及之前版本中是不支持的
// 2. Scroll 的 edgeEffect(如果使用的话)
Scroll() { /* ... */ }
.edgeEffect(EdgeEffect.None) // 不显示滚动到边界的效果
// API 24 新增
// 3. Divider 的颜色和宽度自定义
Divider()
.width('60%')
.height(2)
.color('#E8D5B7')
17.3 Stage 模型的强制使用
API 24 强制使用 Stage 模型,这意味着:
- 不再支持 FA(Feature Ability)模型的
@Entry+@Page组合 - 必须在
module.json5中显式声明"srcEntry": "./ets/entryability/EntryAbility.ets" - Ability 的生命周期方法与 Stage 模型的规范严格绑定
{
"abilities": [{
"name": "EntryAbility",
"srcEntry": "./ets/entryability/EntryAbility.ets",
"launchType": "singleton" // 默认单实例模式
}]
}
十八、项目复盘与经验总结
18.1 开发时间线
| 阶段 | 耗时 | 主要工作 |
|---|---|---|
| 数据建模 | 30分钟 | 定义 PoemLine/PoemSection 接口,录入全诗 |
| 主框架搭建 | 20分钟 | 三Tab导航 + 标题栏 |
| 全文浏览 | 10分钟 | Scroll + ForEach 遍历 |
| 提示背诵 | 25分钟 | 两种模式 + 显示/隐藏逻辑 |
| 逐句默写 | 20分钟 | TextArea + 输入检查 + 随机跳转 |
| 调试优化 | 15分钟 | 边界条件修复 + 视觉调整 |
| 总计 | ~2小时 |
18.2 代码量统计
PoemData.ets: 332 行(含数据与类型定义)
Index.ets: 502 行(含 UI 与逻辑)
Total: 834 行
其中数据(诗文本身)占据了将近一半的篇幅,纯逻辑代码(UI + 交互)约 500 行。
18.3 值得保持的好习惯
- 数据与视图分离:
PoemData.ets零 UI 依赖,可以在控制台直接测试 - 边界条件前置:按钮的
.enabled()条件在绑定数据时就确定,不会出现越界 - 状态重置的一致性:每次导航(上一句/下一句/随机跳转)都重置相关的 UI 状态
- 颜色编码系统化:使用统一的色板,避免"五彩斑斓的黑"
18.4 可以改进的地方
如果重新做这个项目,我会在以下方面做得更好:
- 使用 LazyForEach 替代 ForEach,为扩展到支持更多古诗做准备
- 拆分子组件,将每个 Tab 的
@Builder提取为独立的@Component,以便单独测试 - 增加 Accessibility 支持:为 Text 组件添加
accessibilityText,方便视障用户使用屏幕阅读器 - 边距使用
$r资源引用替代硬编码数字,方便适配不同屏幕密度
附录A:完整项目文件清单
MyApplication/
├── AppScope/
│ └── app.json5 # 应用元信息
├── entry/
│ ├── src/main/
│ │ ├── ets/
│ │ │ ├── entryability/
│ │ │ │ └── EntryAbility.ets # Ability 生命周期
│ │ │ ├── models/
│ │ │ │ └── PoemData.ets # 诗词数据模型(手动创建)
│ │ │ └── pages/
│ │ │ └── Index.ets # 主页面(手动创建)
│ │ ├── module.json5 # 模块配置
│ │ └── resources/ # 资源文件
│ └── build-profile.json5 # 模块构建配置
├── build-profile.json5 # 项目构建配置
├── hvigor/hvigor-config.json5 # hvigor 构建工具配置
└── oh-package.json5 # 项目依赖声明
附录B:全诗结构速查表
| 段落 | 名称 | 起止诗句 | 行数 | 故事节点 |
|---|---|---|---|---|
| 序 | 序 | 小序 | 3 | 背景交代 |
| 一 | 自述 | 孔雀东南飞…及时相遣归 | 11 | 矛盾爆发 |
| 二 | 求母 | 府吏得闻之…何意致不厚 | 5 | 仲卿求情 |
| 三 | 母怒 | 阿母谓府吏…会不相从许 | 11 | 焦母拒绝 |
| 四 | 话别 | 府吏默无声…久久莫相忘 | 17 | 夫妻告别 |
| 五 | 严妆 | 鸡鸣外欲曙…涕落百余行 | 18 | 辞别上路 |
| 六 | 盟誓 | 府吏马在前…二情同依依 | 13 | 路口盟誓 |
| 七 | 还家 | 入门上家堂…阿母大悲摧 | 8 | 回家见母 |
| 八 | 求亲 | 还家十余日…不得便相许 | 15 | 县令求亲 |
| 九 | 太守 | 媒人去数日…便可作婚姻 | 17 | 太守逼婚 |
| 十 | 备婚 | 媒人下床去…郁郁登郡门 | 14 | 太守备婚 |
| 十一 | 悲泣 | 阿母谓阿女…愁思出门啼 | 8 | 兰芝悲泣 |
| 十二 | 死别 | 府吏闻此变…千万不复全 | 19 | 生死诀别 |
| 十三 | 别母 | 府吏还家去…渐见愁煎迫 | 15 | 仲卿别母 |
| 十四 | 殉情 | 其日牛马嘶…自挂东南枝 | 7 | 双双殉情 |
| 十五 | 化鸟 | 两家求合葬…戒之慎勿忘 | 7 | 化鸟升华 |
本文涉及的完整源码可在 DevEco Studio 项目中查看。构建环境:HarmonyOS API 24 (SDK 6.1.1),DevEco Studio 6.1.x。
更多推荐


所有评论(0)