在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

鸿蒙 Next 睡前故事定制机 App 开发实战:10 个内置故事 + 角色替换引擎 + 语音朗读 + 主题筛选 + 第十九款 App

作者:duluo
SDK 版本:HarmonyOS API 24 (Next)
开发工具:DevEco Studio
语言框架:ArkTS + ArkUI
字数:约 10000 字


目录

  1. 引言
  2. 产品概念与数据模型
  3. 三 Tab 架构设计
  4. 故事数据库设计
  5. 角色替换引擎
  6. 语音朗读引擎
  7. 主题筛选与分类浏览
  8. 收藏系统与轻量持久化
  9. 适老化与儿童友好设计
  10. 编译错误全记录
  11. 十九款 App 全景回顾
  12. 结语

1. 引言

1.1 睡前故事的意义

睡前故事是儿童成长过程中不可或缺的一部分。心理学家认为,睡前故事不仅能帮助孩子放松入眠,还能促进语言发展、培养想象力和建立亲子情感连接。每天晚上,全球有数以亿计的父母在孩子的床边讲述着各种故事。

然而,现代父母面临一个普遍困境:故事不够用了。翻来覆去就那几个童话,孩子早就听腻了。买故事书又要额外花钱,网上的故事又不一定适合自己孩子的年龄和兴趣。

"睡前故事定制机"App 的解决方案是:内置一批高质量故事,并允许父母定制故事中的角色和地点。把"小兔子"换成孩子的名字,把"森林"换成孩子熟悉的公园——熟悉的元素让故事更有代入感,孩子也更愿意听。

1.2 本 App 的技术特色

睡前故事定制机在技术上有几个显著特点。

首先,它构建了一个包含 10 个完整故事的故事数据库,每个故事包含标题、主题、年龄建议、角色和地点等元数据。故事按段落存储(而非整段文本),便于分步朗读和定制替换。

其次,它实现了角色替换引擎——使用 replaceAll 方法将故事中的默认角色名和地点名替换为用户自定义的名称。这个引擎虽然算法简单(字符串替换),但 UX 效果显著——孩子听到自己的名字出现在故事中,代入感会大幅提升。

此外,本 App 将上一次语音朗读引擎(来自语音菜谱 App)升级为段落级朗读 + 高亮同步的完整方案,增加了"朗读完毕"后的晚安提示 Toast。

1.3 第十九款 App 的系列数据

App 数量:    19
代码总行数:  ~12,400 行
编译错误数:  ~175 个
博客总字数:  ~200,000 字
技术博客数:  19 篇

2. 产品概念与数据模型

2.1 功能需求

家长故事:我想给孩子讲一个睡前故事
家长故事:我想把故事里的主角换成我家孩子的名字
家长故事:我想按主题找到适合的故事
家长故事:我想收藏喜欢的故事方便再讲
家长故事:我想让 App 朗读故事,我陪着孩子看

功能清单:
├── F1: 故事列表(10 个内置故事,含主题/时长/年龄标签)
├── F2: 主题筛选(6 种主题 Grid 选择)
├── F3: 角色定制(替换主角名字 + 地点名称)
├── F4: 语音朗读(段落推进 + 高亮 + 自动结束)
├── F5: 收藏系统(收藏/取消 + 收藏列表)
├── F6: 详情弹窗(故事元数据 + 定制输入 + 正文 + 朗读)
└── F7: 数据持久化(收藏 ID 列表)

2.2 数据模型

interface Story {
  id: number;              // 唯一标识
  title: string;           // 故事标题
  theme: string;           // 主题分类
  icon: string;            // Emoji 图标
  content: string;         // 一句话简介
  paragraphs: string[];    // 段落数组
  duration: string;        // 朗读时长
  age: string;             // 适龄建议
  characters: string[];    // 可替换角色列表
  places: string[];        // 可替换地点列表
  defaultChar: string;     // 默认主角名(替换目标)
  defaultPlace: string;    // 默认地点(替换目标)
  isFavorite: boolean;     // 是否收藏
}

paragraphsstring[] 而不是整段文本——这个设计决策很关键。它使得:

  • 朗读时可以逐段推进,每段 5 秒
  • 每段可以独立高亮
  • 定制替换按段落执行

charactersplaces 存储了故事中所有可替换的元素。defaultChardefaultPlace 标记了故事中的"主角名"和"主要场景"——这两个是最常被替换的元素。

2.3 故事结构设计

每个故事包含 8-10 个段落,总朗读时长约 5-6 分钟。以下是一个典型故事的结构:

段落 1:引入主角和环境
段落 2-3:冲突出现
段落 4-6:解决问题(核心情节)
段落 7-8:圆满结局
段落 9:晚安祝福(可选)

这种"引入-冲突-解决-结局"的四段式结构符合儿童故事的经典叙事模式。


3. 三 Tab 架构设计

3.1 Tab 配置

buildTabContent() {
  if (this.activeTab === 0) this.buildStoryList()       // 故事
  else if (this.activeTab === 1) this.buildCustomizeTab() // 定制
  else this.buildFavoriteTab()                            // 收藏
}
Tab 图标 功能 用户场景
故事 📖 浏览 + 筛选全部故事 “今晚讲哪个?”
定制 选择故事 + 替换角色 “把主角换成宝宝名字”
收藏 已收藏的故事列表 “上次那个还要再讲一遍”

3.2 Tab 之间的协作

与之前的 App 不同,本 App 的"定制"Tab 不直接进行定制操作——它只是一个入口列表。用户点击后跳转到故事详情弹窗,在弹窗中完成定制输入和朗读:

定制 Tab → 选择故事 → 详情弹窗(定制输入 + 朗读)→ 回到故事列表

这种设计把定制功能集成在详情弹窗中,而不是单独开一个定制页面,减少了交互层级。


4. 故事数据库设计

4.1 10 个故事的选取

10 个故事覆盖了 6 种主题:

主题 图标 故事数 名称
童话 🌙 3 月亮上的兔子、会说话的小星星、云朵棉花糖
动物 🦊 2 小熊的蜂蜜梦、想飞的小企鹅
冒险 🚀 1 勇敢的小火车
魔法 🧚 1 魔法画笔
自然 🌊 1 小乌龟的慢旅行
晚安 🤗 2 晚安小怪兽、枕头王国历险记

每篇故事的主角名(如"小月"“团团”)和地点(如"森林小屋"“蜜糖森林”)都是可定制的。故事结尾通常包含"晚安"或"好梦"的祝福语。

4.2 故事中可替换元素的标记

故事段落中使用 【】 标记可替换元素:

从前,在很远很远的月亮上,住着一只毛茸茸的小兔子,名字叫【小月】。

defaultChar 字段值为 "小月"replaceAll 引擎在运行时将 "小月" 替换为用户输入的名称(如"宝宝")。

标记使用 【】 而非 {{}}<>,因为 【】 在中文文本中更自然,即使在未替换时也不会让故事显得突兀。

4.3 年龄建议

每个故事标注了适龄建议:

年龄 故事数 特点
2-5 岁 3 情节简单,角色少,句子短
2-6 岁 3 中等复杂度
3-6 岁 1
3-7 岁 2 情节更丰富
3-8 岁 1 包含抽象概念

年龄建议帮助家长快速筛选适合自己孩子年龄的故事。


5. 角色替换引擎

5.1 替换逻辑

getCustomizedParas(): string[] {
  if (this.selected === null) return [];
  return this.selected.paragraphs.map(p =>
    p.replaceAll(this.selected!.defaultChar, this.customizeChar)
     .replaceAll(this.selected!.defaultPlace, this.customizePlace)
  );
}

引擎的核心是 String.prototype.replaceAll 方法。它将故事每个段落中的 defaultChar 替换为 customizeChar,将 defaultPlace 替换为 customizePlace

5.2 替换范围

替换不是全局全文替换,而是基于段落级别逐段进行:

paragraphs.map(p => p.replaceAll(...))

每个段落独立执行替换,这意味着:

  • 性能好:大文本分割成小段落后,每次替换的计算量很小
  • 可扩展:未来可以针对不同段落做不同替换规则

5.3 实时预览

详情弹窗中提供了两个 TextInput 用于输入自定义名称:

👤 主角 [____________]  🏠 地点 [____________]

用户输入时,正文区的文字实时更新(因为 getCurrentParagraphs() 每次渲染时重新计算):

TextInput({ placeholder: '输入主角名字', text: this.customizeChar })
  .onChange((v: string) => { this.customizeChar = v; })

由于正文的展示依赖 this.customizeCharthis.customizePlace 这两个 @State 变量,输入框内容变化时,正文会自动重新渲染并展示替换后的文本。

5.4 替换引擎的局限性

当前替换引擎使用简单的字符串匹配,有两个潜在问题:

  1. 部分匹配:如果用户输入的角色名恰好是故事中其他词的一部分,可能会产生意外替换。例如,将"小月"改为"小亮"后,故事中"月亮"会变成"月亮"(保持不变),但"小月"会被替换。

  2. 多角色替换:当前只支持主角名和地点两个替换维度。对于有多个角色的故事(如"小乌龟的慢旅行"中只有主角),这个限制不是问题;但对于需要替换更多角色的场景,需要扩展。


6. 语音朗读引擎

6.1 朗读流程

朗读引擎从语音菜谱 App 升级而来,核心逻辑一致:

startReading(): void {
  this.isReading = true;
  this.currentPara = 0;
  let paras = this.getCurrentParagraphs();
  this.readTimer = setInterval(() => {
    if (this.currentPara < paras.length - 1) {
      this.currentPara++;
    } else {
      this.stopReading();
      promptAction.showToast({ message: '🌙 故事讲完了,晚安', duration: 2000 });
    }
  }, 5000);
}

6.2 与语音菜谱的对比

两个 App 的朗读引擎在核心上一致,但有三个关键差异:

特性 语音菜谱 睡前故事
步进间隔 4 秒 5 秒
高亮对象 步骤(数字编号圆形) 段落(背景色)
结束提示 “🔊 朗读完毕” “🌙 故事讲完了,晚安”

5 秒/段的步进步长比菜谱的 4 秒更长,因为故事段落通常比菜谱步骤长。

6.3 段落高亮

当前朗读的段落使用淡紫色背景高亮:

.backgroundColor(
  this.isReading && idx === this.currentPara ? C.primary + '10' : 'transparent')

10% 透明度的紫色背景足够明显但不刺眼,适合睡前昏暗的光线环境。

6.4 朗读的停止条件

// 1. 用户手动点击"停止"
// 2. 所有段落朗读完毕
// 3. 用户关闭详情弹窗
// 4. App 进入后台/销毁(aboutToDisappear)

四重停止条件与语音菜谱一致,确保定时器在任何情况下都不会泄漏。


7. 主题筛选与分类浏览

7.1 6 种主题

const THEMES: string[] = ['🌙 童话', '🦊 动物', '🚀 冒险', '🧚 魔法', '🌊 自然', '🤗 晚安'];

每个主题前有一个 Emoji 图标,在 Grid 选择器中展示时一目了然。

7.2 主题选择器

主题选择器使用 3 列 Grid 展示 6 种主题:

Grid() {
  ForEach(THEMES, (t: string) => {
    GridItem() {
      Text(t)
        .fontColor(this.filterTheme === t ? C.primary : C.text)
        .backgroundColor(this.filterTheme === t ? C.primary + '15' : C.cardBg)
        .borderWidth(this.filterTheme === t ? 1 : 0)
        .onClick(() => {
          this.filterTheme = this.filterTheme === t ? '' : t;
        })
    }
  }, (t: string) => t)
}.columnsTemplate('1fr 1fr 1fr')

点击已选中的主题会取消筛选(回到全部),体现了"即点即切换,再点取消"的交互模式。这个模式在系列中被多次使用(如收藏筛选、情绪选择器)。

7.3 列表中的主题标签

每个故事卡片在列表中展示主题标签:

🐰 月亮上的兔子
🌙 童话 · 5 分钟 · 2-6 岁
从前,在很远很远的月亮上...

主题标签使用主题色(C.primary: #5C6BC0),时长和年龄使用辅助色(C.textLight),层次分明。


8. 收藏系统与轻量持久化

8.1 收藏存储

与语音菜谱 App 一致,收藏数据只存储故事的 ID 列表:

async saveData(): Promise<void> {
  if (this.pref) {
    let ids = this.stories.filter(s => s.isFavorite).map(s => s.id);
    await this.pref.put(STORAGE_KEY, JSON.stringify(ids));
    await this.pref.flush();
  }
}

8.2 收藏恢复

async loadData(): Promise<void> {
  let savedIds = JSON.parse(v as string) as number[];
  for (let i = 0; i < this.stories.length; i++) {
    if (savedIds.indexOf(this.stories[i].id) >= 0) {
      this.stories[i].isFavorite = true;
    }
  }
  this.stories = this.stories.concat([]);
}

这个模式在系列中已经使用了三次(语音菜谱、睡前故事),证明其可靠性和简洁性。

8.3 收藏列表

收藏 Tab 在空状态时显示引导提示:

⭐
还没有收藏的故事
在故事详情中点击 ⭐ 收藏

有收藏时,列表展示与故事列表一致,但收藏状态以金色星星(C.star: #FFD54F)突出显示。


9. 适老化与儿童友好设计

9.1 星空夜晚主题

本 App 首次使用深色主题——深蓝星空渐变背景:

const C: ColorScheme = {
  primary: '#5C6BC0',    // 紫色
  bgStart: '#1A1A2E',    // 深蓝
  bgEnd: '#16213E',      // 深蓝
  cardBg: '#FFFFFF',     // 白色(弹窗/卡片)
  text: '#1A1A2E',       // 深色正文
  star: '#FFD54F',       // 金色星星
  moon: '#E8EAF6',       // 月光白
};

深色背景 + 白色卡片的设计既减少了屏幕在暗光环境下的刺眼感,又保证了卡片内容的可读性。

9.2 字体与触摸目标

虽然是儿童/家长使用而非老年人,但设计上仍然保持了较大的字体和触摸目标:

元素 字号 触摸目标
故事标题 17px 全宽卡片
故事正文 15px -
标签信息 12-13px -
Tab 图标 20-24px ~50px
定制输入框 14px 36px 高

9.3 安全设计

本 App 在儿童使用场景下做了几个安全设计:

  1. 没有广告:所有内容都是本地内置,不联网
  2. 没有敏感内容:所有故事都是积极向上的主题
  3. 退出确认:弹窗都有明确的关闭按钮
  4. 朗读自动停止:读完所有段落后自动停止,不会继续占用设备

10. 编译错误全记录

10.1 错误概览

本 App 出现 1 个编译错误

# 错误类型 位置 根因
1 方法重复定义 第 587 行 getFilteredStories() 被定义两次

10.2 唯一的错误:方法重复

现象:编译报错 “Duplicate function implementation” 或类似错误。

根因:在重构代码时,删除了 getFilteredStoriesList() 方法但错误地在原位置留下了一个空的 getFilteredStories() 声明头。同时原始的 getFilteredStories() 方法(第 342 行)仍然存在。编译器检测到两个同名方法。

// 第 342 行(正确)
getFilteredStories(): Story[] {
  if (this.filterTheme === '') return this.stories;
  return this.stories.filter(s => s.theme === this.filterTheme);
}

// 第 587 行(重复,空的声明头)
getFilteredStories(): Story[] {  // 重复定义

修复:删除第 587 行的空方法声明。

教训:在手动编辑代码时,注意检查是否在错误的位置插入了代码。本次错误是在删除 getFilteredStoriesList() 并将其替换为上一行的方法声明头时,没有意识到该位置已经有一个同名方法了。

10.3 十九款 App 错误数趋势

22 → 17 → 16 → 1 → 12 → 12 → 10 → 4 → 11 → 11 → 3 → 8 → 7 → 12 → 1 → 4 → 3 → 2 → 1

第十九款 App 的错误数为 1 个,追平了系列最低纪录(尴尬粉碎机、绿植领养)。

10.4 关于"轮次归零"的思考

经过十九款 App 的实践,从第 10 款开始,错误数就已经稳定在个位数。第 18 款 2 个错误,第 19 款 1 个错误——这不是偶然的下降,而是模式复用的必然结果

每一款新 App 的开发模式:

  1. 复用前作的 ColorScheme + 三 Tab + 弹窗模式(0 错误)
  2. 根据 App 特性新增数据模型和业务逻辑(少数错误)
  3. 修复少量错误(1-2 轮)
  4. 验证通过

当模式足够成熟,新增代码足够少时,错误数自然趋向于 0。


11. 十九款 App 全景回顾

11.1 数据总览

# App 行数 错误数 Type
1 🎵 白噪音 767 16 工具
2 ⏳ 时间胶囊 955 17 工具
3 🧊 冰箱剩菜 1320 22 工具
4 😅 尴尬粉碎机 953 1 工具
5 🛡️ 防骗训练 1038 12 教育
6 💡 碎片学习 851 12 教育
7 🐶 宠物日记 450 10 工具
8 🗑️ 情绪垃圾桶 390 4 工具
9 🧭 线下寻宝 447 11 社交
10 🗡️ 订阅刺客 478 11 工具
11 🎑 声音明信片 458 3 工具
12 🎲 家庭大富翁 537 8 游戏
13 📚 二手书漂流瓶 452 7 社交
14 🧹 废话过滤器 542 12 工具
15 🌱 绿植领养 530 1 社交
16 🌙 梦境解析 614 4 工具
17 🏕️ 断网挑战营 418 3 工具
18 👨‍🍳 语音菜谱 498 2 工具
19 🌙 睡前故事 668 1 教育

11.2 "讲给孩子听"的三款 App

系列中有三款 App 可以归类为"适合与孩子一起使用":

App 交互方式 核心价值
🐶 宠物日记 记录 + 统计 培养责任感
🦊 睡前故事 朗读 + 定制 亲子阅读时间
👨‍🍳 语音菜谱 朗读 + 步骤 亲子烹饪时间

这三款 App 的共同点是:App 不是被孩子独自使用的,而是家长和孩子一起使用。App 提供了内容(故事步骤/菜谱步骤),家长操作,孩子参与体验。

11.3 替换引擎类的三款 App

系列中有三款 App 实现了"替换"功能:

App 替换对象 引擎
😅 尴尬粉碎机 场景随机生成 随机选择 + 拼接
🌙 梦境解析 关键词匹配 indexOf + 遍历
🌙 睡前故事 角色/地点替换 replaceAll

三种替换引擎使用的技术不同,但核心思想一致:将用户输入与结构化数据结合,生成个性化的输出

11.4 十九款 App 的关键教训

# App 最大教训
1 白噪音 颜色对象需要 interface
2 时间胶囊 @Builder 不能用 let
3 冰箱剩菜 闭包不能传给 @Builder
4 尴尬粉碎机 模式复用可大幅降错
5 防骗训练 大段 Builder 分批重构
6 碎片学习 ForEach key 函数作用域
7 宠物日记 紧凑风格减少 50% 代码
8 情绪垃圾桶 ForEach key 用值本身
9 线下寻宝 残留代码导致级联错误
10 订阅刺客 暗色主题设计
11 声音明信片 setInterval 要清理
12 家庭大富翁 展开运算符替代
13 二手书漂流瓶 @Builder 注解不能缺
14 废话过滤器 Text 组件不支持变量声明
15 绿植领养 重构也可能引入错误
16 梦境解析 内联对象不能作类型
17 断网挑战营 已知错误也会重复犯
18 语音菜谱 肌肉记忆比语法更难改
19 睡前故事 删除代码要检查残留

11.5 第 19 条教训:删除代码要检查残留

第 19 条教训是一条关于代码清理的教训。

在重构过程中删除了 getFilteredStoriesList() 方法,但在原位置留下了一个空的 getFilteredStories() 声明头。原因是使用了"编辑文件"工具时,替换文本没有完全匹配旧内容。

这个错误虽然是使用 AI 编辑工具时产生的特定问题,但反映了一个通用的编程原则:删除代码后,要检查是否还有残留。常见的残留包括:

  1. 空方法声明(如本次)
  2. 无引用的 import
  3. 孤立的注释
  4. 未使用的变量

养成"删除后执行一次完整构建"的习惯,可以快速发现这类残留。


12. 结语

12.1 十九款 App 的开发历程

App1  🎵  白噪音          → 初识 ArkUI
App2  ⏳  时间胶囊        → 数据持久化
App3  🧊  冰箱剩菜        → Tab 架构
App4  😅  尴尬粉碎机      → 模式复用
App5  🛡️  防骗训练        → 适老化
App6  💡  碎片学习        → 学习激励
App7  🐶  宠物日记        → 紧凑风格
App8  🗑️  情绪垃圾桶      → 情感交互
App9  🧭  线下寻宝        → 社交互动
App10 🗡️  订阅刺客        → 暗色主题
App11 🎑  声音明信片      → 模拟录音
App12 🎲  家庭大富翁      → 回合制游戏
App13 📚  二手书漂流瓶    → 随机匹配
App14 🧹  废话过滤器      → 自然语言检测
App15 🌱  绿植领养        → 缘分匹配
App16 🌙  梦境解析        → 潜意识探索
App17 🏕️  断网挑战营      → 行为养成
App18 👨‍🍳  语音菜谱        → 适老化设计
App19 🌙  睡前故事        → 故事定制

12.2 替换引擎:从第三款到第十九款

替换引擎这个概念在系列中经历了三次迭代:

  • 第三款(冰箱剩菜):写死在 Builder 中的条件判断,不可配置
  • 第十四款(废话过滤器):基于规则库的 indexOf 匹配,可配置规则
  • 第十九款(睡前故事):基于 replaceAll 的字符串替换,用户可输入替换值

这种演进体现了从"硬编码"到"规则驱动"再到"用户驱动"的三级跃迁。

12.3 ArkUI 的最终评价(第六次修订)

经过十九款 App 的实践,ArkUI 的评价已经非常成熟。所有重要的"不足"都在第 14 篇博客中完整记录,后续 App 没有遇到任何新的错误类型——只有重复犯的旧错误和代码清理相关的错误。

核心优势(与前作一致):

  • 声明式 DSL + @State 响应式
  • 编译检查有效
  • Preferences API 简洁

核心不足(与前作一致,但没有新发现):

  • @Builder 语法约束严格
  • 展开运算符限制
  • 部分 API 差异

12.4 给开发者的建议

  1. 段落存储比整段文本更灵活:如果内容需要分步展示或逐段处理,用数组存储比用大段字符串好 10 倍。

  2. 替换引擎虽然简单但有效replaceAll + customizeChar = 孩子听到自己名字时的惊喜。有时候最简单的技术方案就是最好的方案。

  3. 深色主题关注对比度:深色背景上用浅色文字是基本的,但卡片内部的文字仍然需要足够的对比度。白色卡片 + 深色文字是安全的选择。

  4. 删除代码后要检查:养成"删除三查"的习惯——查 import、查方法名、查引用。

12.5 感谢与展望

十九款 App、十九篇博客、约 200,000 字。从第一款白噪音 22 个错误到第十九款睡前故事 1 个错误——这条曲线本身就是这本书最好的总结。

现在,打开 DevEco Studio,去创造属于你自己的 App 吧。


附录 A:第十九款 App 核心代码

角色替换引擎

getCustomizedParas(): string[] {
  if (this.selected === null) return [];
  return this.selected.paragraphs.map(p =>
    p.replaceAll(this.selected!.defaultChar, this.customizeChar)
     .replaceAll(this.selected!.defaultPlace, this.customizePlace)
  );
}

语音朗读引擎

startReading(): void {
  this.isReading = true; this.currentPara = 0;
  let paras = this.getCurrentParagraphs();
  this.readTimer = setInterval(() => {
    if (this.currentPara < paras.length - 1) this.currentPara++;
    else { this.stopReading(); promptAction.showToast({ message: '🌙 故事讲完了,晚安', duration: 2000 }); }
  }, 5000);
}

stopReading(): void {
  this.isReading = false; this.currentPara = 0;
  if (this.readTimer >= 0) { clearInterval(this.readTimer); this.readTimer = -1; }
}

收藏持久化(ID 列表)

async saveData(): Promise<void> {
  let ids = this.stories.filter(s => s.isFavorite).map(s => s.id);
  await this.pref.put(STORAGE_KEY, JSON.stringify(ids));
  await this.pref.flush();
}

附录 B:系列速查

指标 数值
App 数量 19
博客总字数 ~200,000 字
代码总行数 ~12,400 行
编译错误 ~175 个
@Builder 方法 ~260 个
修复轮次 37 轮

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐