前言

做完「拼词小达人」后,我没有急着换一套技术方案,而是先问了自己一个问题:那套“本地内容 + 卡片学习 + TTS + Preferences + 服务卡片”的结构,能不能迁移到古诗词学习?
详细调研之后得出结论如果想实现的话不是简单地把单词字段替换成诗词字段。
单词卡的正反面通常是单词和释义,内容短,切换节奏快;一首诗包含题目、作者、朝代、正文和译文,展示密度更高。单词的“看过”和“记住”相对容易定义,古诗的“读过”“收藏”和“学会”却是三种不同状态。再加上中文朗读、年级与册次筛选、连续打卡和桌面卡片,真正需要复用的是开发方法,而不是页面代码。
于是有了「诗境」:一款面向小学 1~6 年级学生的 HarmonyOS 古诗词学习元服务,当前已完成上架可在元服务中搜索体验哦!

image

它内置 111 首课本古诗词,不依赖后端,打开后可以选年级、按上下册刷诗、听系统朗读、翻卡看诗意、收藏内容并记录学习进度。
这篇文章不按页面逐个介绍,而是复盘三个更值得记录的问题:

  1. 怎样让 HarmonyOS 开发助手基于规划书分阶段完成项目,而不是一次生成一堆难以维护的代码。
  2. 怎样把本地诗词、学习状态、TTS 和服务卡片串成一条稳定的数据链路。
  3. 哪些内容来自豆包模型的开发辅助,哪些才是应用真实具备的运行能力。

项目做到了什么

「诗境」采用 HarmonyOS 元服务形态,当前版本的主流程很短:

首次打开
  → 选择年级
  → 进入“学诗”首页
  → 选择上册、下册或整年级学习
  → 左右滑动诗词卡片
  → 听朗读、翻卡看诗意
  → 收藏或手动标记“已学会”
  → 在“收藏”和“我的”中查看结果

7599547b4d65c34307231bc643a3596a

应用包含三个主 Tab:

页面主要内容
学诗每日一诗、年级教材、上下册入口和学习统计
收藏本地收藏的诗词,可进入详情继续学习
我的当前年级、已学会数量、连续打卡、等级和数据清理

元服务还提供两张桌面服务卡片:2*2 学习进度卡和 4*4 每日一诗卡。前者展示已学会数量、连续打卡和当前等级,后者展示当天诗词及学习入口。

image

image

有几条边界需要先说清楚:

  • “每日一诗”是本地按日期轮换,不是 AI 推荐。
  • “已学会”由用户手动标记,不是背诵检测结果。
  • 朗读使用 HarmonyOS 系统 TTS,不是豆包语音服务。
  • 豆包模型参与的是开发过程,应用运行时没有接入大模型接口。

这些边界看起来像产品说明,实际上会直接影响数据结构和统计口径。边界不清楚,后面生成得再快也只是更快地返工。

开发环境

项目与「拼词小达人」使用同一套开发环境,工程采用 Stage 模型和 ArkTS,运行设备为 Phone。

项目配置
开发工具DevEco Studio
开发语言ArkTS
UI 框架ArkUI
工程模型Stage 模型
系统环境HarmonyOS 7
HarmonyOS SDKAPI 26
targetSdkVersion26.0.0
compatibleSdkVersion26.0.0
应用形态HarmonyOS 元服务
运行方式单机运行,无后端依赖

module.json5 中开启了 installationFree,同时注册 EntryAbility 和用于桌面卡片的 FormAbility。DevEco Studio 与开发助手的具体版本会随本机环境变化,工程配置无法证明的版本号没有写进本文。

HarmonyOS 开发助手负责什么

HarmonyOS 开发助手集成在 DevEco Studio 中,能结合工程上下文完成需求拆分、多文件修改、编译问题定位和局部重构。它和普通代码补全最大的区别,是可以围绕一个任务同时检查页面、服务和配置文件。

例如“收藏后首页数字没有刷新”并不是一个按钮问题。排查时至少要同时看:

  • 收藏按钮是否写入 StorageService;
  • 收藏 ID 是否持久化到 Preferences;
  • 响应式 UserState 是否重新计算统计值;
  • 收藏页是否重新读取有效诗词;
  • 服务卡片是否需要主动刷新。

这类跨文件链路很适合交给开发助手检索和修改,但统计规则不能让模型猜。什么叫“已学”、哪些操作会刷新卡片、TTS 失败后页面怎样反馈,仍然需要开发者先定规则,再检查生成结果。

HarmonyOS 开发助手的安装与配置可参考官方文档:

image

image

豆包模型只参与开发

这次在开发助手中配置的是豆包 Doubao-Seed-2.1-pro。配置方式与「拼词小达人」一致:通过 URL 添加模型,填写火山方舟提供的兼容接口地址、模型名称和 API Key。
API Key 只保存在开发工具的模型配置中,不应写进 ArkTS 源码或提交到仓库。开始开发前先执行模型连通性测试,也能避免把鉴权失败误判成工程问题。
我主要用豆包完成三类任务:

  1. 阅读规划书,将需求拆成数据层、服务层、页面和卡片任务。
  2. 根据现有目录连续修改多个 ArkTS 文件。
  3. 结合编译日志定位类型、状态同步和系统 API 使用问题。

先写规划书,再让模型写代码

一句“帮我做一个古诗词应用”没有可执行性。模型不知道内容面向谁、诗词从哪里来、怎样算学过,也不知道运行时是否允许联网。
我沿用了「拼词小达人」的做法,先在项目的 物料 目录准备《项目规划说明书》,明确以下约束:

  • 用户是小学 1~6 年级学生,辅助使用者是家长和老师。
  • 内容固定为本地 poems.json 中的 111 首课本古诗词。
  • 首次启动必须选年级,后续可在“我的”页面切换。
  • 核心交互是刷卡、朗读、翻卡看诗意、收藏和标记已学会。
  • 学习状态保存在 Preferences,不做账号、云同步和后端服务。
  • 页面、数据、存储、TTS 与服务卡片各自分层。

第一轮提示词没有要求模型直接交付完整应用,而是先让它输出计划:

image

需求如下 
根据 /Users/chengruo/Desktop/mycode/harmonyOSfor26/ContextualMeaning/物料/项目规划说明书.md 项目规划书 在  /Users/chengruo/Desktop/mycode/harmonyOSfor26/ContextualMeaning/entry 项目中进行开发实现 ,  相关诗词的数据在  /Users/chengruo/Desktop/mycode/harmonyOSfor26/ContextualMeaning/物料/poems.json  
注意使用技能 /Users/chengruo/Desktop/Qclaw/harmonyos-skill-2.1.1 
来规范语法 使用 技能 /Users/chengruo/Desktop/Qclaw/ui-design-guide-2.24.2-beta.1 
来优化 UI

这里的关键不是提示词写得长,而是把验收条件写成模型无法回避的约束。后续每完成一阶段,都可以回到规划书核对,而不是凭界面“看起来差不多”就继续往下做。

六个阶段逐步验收

阶段开发内容验收重点
工程初始化元服务配置、启动页、年级选择、主页面首次进入不能绕过年级选择
数据层解析 poems.json,建立 ID 索引111 首诗能按年级和册次查询
学习页刷卡、翻卡、TTS、收藏、已学会切换诗词后状态和阅读次数正确
统计体系已学、已学会、收藏、打卡、等级多页面口径一致,重启后数据仍在
服务卡片学习进度卡、每日一诗卡卡片数据与应用内一致
真机验证TTS、动效、刷新、失败提示系统能力不可用时有明确反馈

分阶段还有一个实际好处:问题容易归层。数量不对先查 Repository,重启后状态丢失先查 Preferences,页面数字不刷新再查响应式状态。一次生成全部页面,报错和业务问题会混在一起,模型也很难判断应该改哪一层。

工程结构:页面不直接碰原始数据

项目的核心目录如下:

entry/src/main/
├── ets/
│   ├── common/
│   │   ├── components/       # 诗词卡片、册次入口、升级弹窗
│   │   ├── constants/        # 颜色、卡片主题、等级规则
│   │   ├── models/           # 诗词模型与响应式用户状态
│   │   └── services/
│   │       ├── PoemRepository.ets
│   │       ├── StorageService.ets
│   │       ├── TtsService.ets
│   │       └── WidgetRefreshService.ets
│   ├── entryability/EntryAbility.ets
│   ├── formability/FormAbility.ets
│   ├── features/             # 首页、收藏、我的三个 Tab
│   ├── pages/                # 启动、选年级、详情、刷卡页面
│   └── widget/pages/         # 两张桌面服务卡片
└── resources/
    ├── base/profile/form_config.json
    └── rawfile/poems.json

页面只消费服务层结果:PoemRepository 管内容,StorageService 管用户状态,TtsService 管朗读,WidgetRefreshService 管卡片刷新。这套分层与「拼词小达人」相似,但诗词的数据结构、状态口径和卡片布局都重新设计过。

第一条链路:从本地 JSON 到每日一诗

PoemRepository 在启动时读取 rawfile/poems.json,使用 UTF-8 解码并解析,然后把“年级 → 册次 → 诗词”的嵌套数据拉平成数组,同时建立 Map<number, Poem> 索引。

const buffer: Uint8Array =
  await context.resourceManager.getRawFileContent('poems.json') as Uint8Array;
const decoder: util.TextDecoder = util.TextDecoder.create('utf-8');
const content: string = decoder.decodeToString(buffer);
const root: PoemsJsonRoot = JSON.parse(content) as PoemsJsonRoot;

this.gradeGroups = root.grades;
this.allPoems = flat;
this.poemMap.set(poem.id, poem);

建立 ID 索引不是为了“优化得更高级”,而是因为收藏和已学会状态只保存诗词 ID。收藏页、详情页和服务卡片都需要通过 ID 取回完整诗词;如果 ID 不稳定,更新词库后旧记录就会指向错误内容。

页面不解析原始 JSON。首页按年级查询册次,刷卡页按 grade 和 volumeKey 过滤,详情页按 poemId 查询。词库结构变化时,修改范围因此能收敛在 Repository。

“每日一诗”也来自同一份本地数据:

const now: Date = new Date();
const start: Date = new Date(now.getFullYear(), 0, 0);
const diff: number = now.getTime() - start.getTime();
const dayOfYear: number = Math.floor(diff / (1000 * 60 * 60 * 24));
const idx: number = dayOfYear % this.allPoems.length;
return this.allPoems[idx];

按一年中的第几天取模,有两个符合当前项目的特性:同一天结果稳定,断网也能正常轮换。代价是它不理解用户偏好,也不会根据学习情况推荐。对于只有 111 首固定内容的离线元服务,这个取舍比引入后端推荐更合适。

第二条链路:刷卡交互不能混淆学习状态

PoemCardLearnPage 使用 Stack 叠放卡片,根据卡片与当前索引的距离计算偏移、透明度和缩放。用户既可以点上一首、下一首,也可以通过 PanGesture 左右滑动;横向位移超过阈值后才切换,避免轻微触碰导致误翻页。

c8e6f53639389c52b290aede62c5ebb4

路由参数决定本次学习范围:

  • 传入 volumeKey,只学习指定上册或下册;
  • 只传 grade,加载整个年级;
  • 传入 poemId,从指定诗词开始;
  • 没有参数,使用当前已选年级。

从单词卡迁移到诗词卡后,最容易出错的不是动画,而是统计定义。当前项目把三个状态明确分开:

状态触发方式含义
已学阅读、收藏、已学会三者的并集用户已经接触过这首诗
已学会用户主动点击标记主观确认掌握,不代表系统检测通过
收藏用户主动收藏希望之后重点回看

切换到一首诗只增加阅读次数,不会自动标记“已学会”。否则用户只是滑过卡片,首页掌握数量就会上升,等级体系也失去意义。

项目设置了 8 个成长等级,从“小书童”逐步到“小诗仙”。等级只根据已学会数量计算,达到新阈值后弹出升级反馈。它是儿童学习中的正向反馈,不是能力测评结果。

image

第三条链路:Preferences 写入只是开始

学习状态统一由 StorageService 管理,底层使用 @kit.ArkData 的 Preferences。

数据用途
selectedGrade当前年级
collectedIds收藏的诗词 ID
masteredIds已学会的诗词 ID
readCounts每首诗的阅读次数
firstUseDate首次使用日期
lastActiveDate最近活跃日期
streakDays连续打卡天数
lastLevelIndex最近确认的等级

真正麻烦的是写入之后:收藏按钮变了,首页统计、“我的”页面和桌面卡片也要看到同一份结果。

项目把派生统计集中到 syncStats(),每次状态变更后统一重算,再递增 statsVersion:

private syncStats(): void {
  const u: UserState = this.userState;
  u.learnedCount = this.getLearnedPoemIds().length;
  u.masteredCount = this.getMasteredPoemIds().length;
  u.collectedCount = this.getCollectedPoemIds().length;
  u.statsVersion = u.statsVersion + 1;
}

页面通过响应式 UserState、@Monitor 和变更监听消费结果,返回主页面时再用 refreshTick 兜底。数组更新时创建新数组并重新赋值,而不是只在原数组上修改,这样 @Trace 和 @Monitor 更容易观察到引用变化。

这里的经验比代码本身重要:跨页面共享的统计口径必须只有一个出口。让首页、收藏页和“我的”各算一遍,短期少写一个服务函数,后面却很容易出现三个数字。

822f999cbefe055eee5b57617f49c649

连续打卡也要先定义日期规则

连续打卡根据 lastActiveDate 与当天的日期差处理:同一天不重复增加,相邻一天加一,中断后从一天重新开始。它记录的是“当天是否发生学习行为”,不是应用被打开了多少次。

这类规则应先写进规划书再生成代码。否则模型很可能把每次启动都算成一次打卡,功能看似能跑,数据却没有可信度。

第四条链路:系统 TTS 要按失败场景设计

朗读能力封装在 TtsService 中,使用 @kit.CoreSpeechKit 的 textToSpeech,语言为 zh-CN,并选择系统预置声音:

const chineseParams: textToSpeech.CreateEngineParams = {
  language: 'zh-CN',
  person: 13,
  online: 1,
  extraParams: {
    'style': 'interaction-broadcast'
  }
};

this.engine = await textToSpeech.createEngine(chineseParams);

播放新内容前先停止上一次请求,避免两首诗同时朗读。监听器在开始、完成、停止和错误时更新 AppStorage 中的 ttsSpeaking,页面据此切换播放状态。

项目本身没有网络权限,朗读依赖设备上的 HarmonyOS 语音引擎和可用资源。这里不能只验证“编译通过”:不同设备的语音资源和引擎状态可能不同,必须在目标设备上试听。引擎未准备好时,页面会提示“语音引擎暂不可用”,基础阅读和收藏功能仍可继续使用。

这也是我没有让页面直接调用 TTS API 的原因。初始化、停止旧请求、请求 ID、回调和错误状态都收进服务后,多个页面不会各自维护一套容易冲突的播放状态。

第五条链路:服务卡片与应用不是同一个状态现场

两张卡片在 form_config.json 中配置每天 08:00 定时更新。应用内发生收藏、标记已学会、切换年级和升级等可见变化时,还会通过 WidgetRefreshService 主动刷新。

并非所有操作都值得触发 updateForm。普通翻卡会增加阅读次数,但卡片不展示单首阅读次数,因此不会每翻一张就更新。只有真正影响卡片内容的数据变化才触发刷新,可以减少高频更新触碰系统配额的风险。

另一个容易忽略的问题是进程缓存。应用进程和卡片进程可能在不同时间持有 Preferences 缓存。卡片读取数据时会移除旧缓存,再以只读方式从磁盘加载最新值,并禁止回写。如果卡片拿着旧状态写盘,可能覆盖用户刚刚在应用内产生的新记录。

这部分是「诗境」相比普通单页 Demo 更值得保留的实现:桌面卡片不只是另一套 UI,它还引入了跨进程数据一致性问题。

3f5c082dd08476ed55e4f00cb752c27b

启动顺序决定后续数据是否可信

EntryAbility 首先加载 SplashPage。启动页依次初始化诗词数据、TTS、Preferences 和卡片数据,最后根据当前年级分流:

await PoemRepository.getInstance().load(ctx);
await TtsService.getInstance().init(ctx);
await StorageService.getInstance().init(ctx);

const grade: number = StorageService.getInstance().userState.selectedGrade;
if (grade <= 0) {
  this.getUIContext().getRouter().replaceUrl({ url: 'pages/GradeSelectPage' });
} else {
  this.getUIContext().getRouter().replaceUrl({ url: 'pages/MainPage' });
}

词库必须先加载,后续才能校验收藏 ID、计算统计和生成每日一诗;Preferences 必须初始化完成,才能决定进入年级选择页还是主页面。把时序收口到启动页,比让每个业务页面各自补一次初始化更容易排查问题。

规划书不是最终实现

项目规划书最初写过“译文、注释、作者”三个详情标签,也使用过 PoemListPage、FavoritePage 等早期页面命名。实际开发后,详情页只保留译文与作者,主要学习入口则改成 PoemCardLearnPage;三个主页面也落在 features 下,分别是 HomeTabPage、FavoriteTabPage 和 MineTabPage。

这不是规划失败。规划书解决的是范围和职责问题,交互方案仍要根据运行效果调整。真正需要避免的是文章继续照抄旧规划,把没有进入源码的功能写成已经完成。

开发助手生成代码后,我会做三轮核对:

  1. 对照规划书,检查核心链路是否缺失。
  2. 对照源码,确认页面名称、统计规则和能力边界。
  3. 在真机上验证 TTS、滑动手势、升级反馈和服务卡片刷新。

模型适合加速实现和排错,但不能替代这三轮验收。

这次迁移真正复用了什么

从「拼词小达人」到「诗境」,最终复用下来的不是某段 ArkTS,而是四条方法:

  • 先准备稳定的本地内容和 ID,再设计收藏、历史等状态。
  • 先用规划书固定产品边界,再让开发助手拆阶段实现。
  • 把 Repository、Storage、TTS、Widget 等能力从页面中抽离。
  • 每个阶段都以源码和真机结果为准,及时修正规划中的旧设计。
    「诗境」当前仍是一款边界明确的离线学习元服务。它没有语音识别、背诵评分、云同步,也没有应用内大模型问答。但“选年级、读诗、听读、理解、收藏、记录进度”已经形成闭环,而且断网时仍能使用。
    豆包模型提高了规划、多文件修改和问题定位的效率,HarmonyOS 开发助手则把这些能力带进了现有工程上下文。项目能否稳定运行,最后仍取决于更朴素的事情:词库 ID 是否可靠、状态口径是否统一、进程间数据会不会互相覆盖,以及系统能力有没有经过真机验证。
Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐