鸿蒙 Next 方言学习 App 开发实战:多方言数据 + 每日一句 + 收藏系统



鸿蒙 Next 方言学习 App 开发实战:多方言数据 + 每日一句 + 收藏系统
作者:duluo
SDK 版本:HarmonyOS API 24 (Next)
开发工具:DevEco Studio
语言框架:ArkTS + ArkUI
字数:约 9800 字
目录
- 引言
- 产品概念与数据模型
- 三 Tab 架构设计
- 首页方言 Grid
- 每日一句种子算法
- 学习 Tab 与方言切换
- 收藏系统
- 详情弹窗
- Helper 方法模式
- 编译错误全记录
- 第三十款 App 全景回顾
- 结语
1. 引言
1.1 为什么做方言学习 App
中国有 129 种方言,分为十大方言区。会说方言的人超过 8 亿。但年轻一代中,能流利使用方言的比例正在快速下降。
这是一个正在发生的"语言失传"现象。很多 90 后、00 后能听懂爷爷奶奶的方言,但自己不会说。等他们当了爷爷奶奶,他们的孩子可能既听不懂也不会说。
方言学习 App 的价值不在于"教会你一门语言"——它没有沉浸式环境、没有语音交互、没有真人陪练。它的价值在于:让你在碎片时间里,学会几句最常用的家乡话。
下次回老家时,对爷爷奶奶说一句"你吃了吗"的方言版——这就是这款 App 想做的事。
1.2 三十款 App 的里程碑
第三十款 App。这个系列从一开始的"试试 ArkUI"走到了现在。
App 数量: 30
代码总行数: ~17,500 行
编译错误数: ~290 个
博客总字数: ~300,000 字
技术博客数: 30 篇
30 款 App、30 篇博客、约 30 万字。这个数字比很多出版物的总字数都要多了。
1.3 本 App 的技术特色
- 32 条方言数据:覆盖 5 种方言(粤语、闽南语、上海话、客家话、四川话),每条包含方言写法、拼音/注音、释义、小贴士
- 每日一句种子算法:基于日期时间戳的伪随机算法,每天展示一条不同的方言短语
- 收藏系统:完整的收藏/取消收藏功能,双向状态同步
- Helper 方法模式:为解决 @Builder 中不能使用 const 而创建的辅助方法体系
- Tab 切换与列表联动:方言切换 Tab 与短语列表的联动
1.4 三十款 App 全景
App 1: 🎵 白噪音 → ArkUI 入门
App 10: 🗡️ 订阅刺客 → 模式复用
App 20: 📸 宠物拍立得 → 效率期
App 24: 🌳 AI 树洞 → 新领域探索
App 27: 📖 长辈说明书 → 适老化
App 29: 🔄 反向导师平台 → 平台类
App 30: 🗣️ 方言学习 → 教育类
2. 产品概念与数据模型
2.1 功能需求
用户故事 1:我想学几句家乡话,回家说给老人听
用户故事 2:我想每天学一句不同的方言
用户故事 3:我想把自己喜欢的短语收藏起来
用户故事 4:我想集中学习某一种方言
功能清单:
├── F1: 5 种方言 Grid 展示
├── F2: 每日一句随机推荐
├── F3: 方言切换 Tab
├── F4: 短语列表(按方言筛选)
├── F5: 收藏/取消收藏
├── F6: 收藏列表
├── F7: 详情弹窗(释义 + 小贴士)
└── F8: 学习进度统计
2.2 两种数据模型
interface Dialect {
id: number;
name: string; // 方言名称
emoji: string; // 区域图标
color: string; // 主题色
desc: string; // 使用区域
}
interface Phrase {
id: number;
dialect: string; // 所属方言
phrase: string; // 方言写法
pron: string; // 发音/拼音
meaning: string; // 普通话释义
note: string; // 使用小贴士
}
Dialect 描述方言本身(5 种),Phrase 描述具体的短语(32 条)。两者通过 Phrase.dialect(字符串)建立关联——不需要外键、不需要关联查询,简单的字符串匹配即可。
为什么用字符串关联而不是 id:因为显示方言名称时直接使用 p.dialect 即可,不需要查表转换。5 种方言 32 条短语的规模下,字符串关联没有性能问题。
2.3 32 条短语的内容设计
每种方言覆盖 6-8 条短语,涵盖四个类别:
打招呼:你好、早晨(粤语)
致谢/道歉:多謝、對唔住
日常寒暄:食咗飯未呀(粤语)、巴適(四川话)
情感表达:我好鍾意你(粤语)、惜你(客家话)
每条短语的 note 字段提供使用场景说明——“最常用的打招呼方式”、“表白专用”、“四川话灵魂词”。这些说明帮助学习者理解什么时候用、怎么用。
短语的发音字段(pron)使用拼音方案标注,对粤语使用了耶鲁拼音(如 nei5 hou2),对闽南语使用了白话字(如 li2 ho2),对其他方言使用了汉语拼音加数字声调。虽然不是标准语言学标注,但足够让初学者"读个大概"。
每条短语的 meaning 字段使用普通话释义,不加多余的解释。例如 食咗飯未呀 直接释义为"吃了吗",而不是"你吃过饭了吗(一种问候方式)"——释义要简短,详细说明放在 note 中。
3. 三 Tab 架构设计
3.1 Tab 配置
build() {
Stack() {
Column().backgroundColor(C.bg)
Column() {
this.buildHeader()
if (this.activeTab === 0) this.buildHomeTab()
else if (this.activeTab === 1) this.buildLearnTab()
else this.buildFavTab()
this.buildTabBar()
}
if (this.showDetail) this.buildDetailOverlay()
}
}
| Tab | 图标 | 功能 | 用户场景 |
|---|---|---|---|
| 0 | 🏠 | 首页 — 方言 Grid + 每日一句 + 进度 | “今天学什么?” |
| 1 | 📚 | 学习 — 方言切换 + 短语列表 | “学几句粤语” |
| 2 | 📖 | 收藏 — 已收藏短语 | “复习一下” |
3.2 首页到学习的跳转
首页的方言 Grid 点击后会将 selectedDialect 设为对应的方言索引,并跳转到学习 Tab:
.onClick(() => {
this.selectedDialect = idx;
this.activeTab = 1; // 跳转到学习 Tab
})
这是一个隐含的"页面跳转"——不通过路由,只是修改两个 @State 变量。这种跳转方式比路由更轻量,适合同一页面内不同 Tab 的联动。
3.3 三 Tab 数据流
首页:读取 DIALECTS + PHRASES 常量 + favorites
↓ 选择方言 → 跳转到学习 Tab
学习 Tab:根据 selectedDialect 筛选 PHRASES
↓ 收藏/取消
favorites 数组更新 → 所有 Tab 自动同步
↓ 点击短语
详情弹窗 → 展示完整信息
数据流是单向的:常量(DIALECTS、PHRASES)→ 筛选 → 展示。收藏是唯一需要 @State 管理的数据。
4. 首页方言 Grid
4.1 2×3 Grid 布局
Grid() {
ForEach(DIALECTS, (d: Dialect, idx: number) => {
GridItem() {
Column() {
Text(d.emoji).fontSize(36)
Text(d.name).fontSize(16).fontWeight(FontWeight.Bold).margin({ top: 4 })
Text(d.desc).fontSize(11).fontColor(C.textMuted).margin({ top: 2 })
Text(d.name + '共' + this.getDialectCount(d.name) + '句').fontSize(10).fontColor(C.primary)
}.padding(14).backgroundColor(C.bgCard).borderRadius(16).height(130)
.borderWidth(this.selectedDialect === idx ? 2 : 0).borderColor(C.primary)
.onClick(() => { this.selectedDialect = idx; this.activeTab = 1; })
}
}, (d: Dialect) => d.name)
}.columnsTemplate('1fr 1fr').rowsGap(10).columnsGap(10)
高度 130px:固定高度确保 2 行 5 个 GridItem 填满可见区域。内容从 top 到 bottom 排列:大 emoji(36sp)→ 方言名(16sp)→ 区域(11sp)→ 短语数(10sp)。
选中态:borderWidth(2) + borderColor(C.primary) 粗边框表示当前选中的方言。这个选中态用于提示用户"你上一次学的是这个"。
4.2 学习进度统计
Row() {
Column() {
Text(this.favorites.length + '').fontSize(24).fontColor(C.primary)
Text('已收藏').fontSize(12).fontColor(C.textMuted)
}
Column() {
Text(PHRASES.length + '').fontSize(24).fontColor(C.text)
Text('总短语').fontSize(12).fontColor(C.textMuted)
}
Column() {
Text(DIALECTS.length + '').fontSize(24).fontColor(C.accent)
Text('种方言').fontSize(12).fontColor(C.textMuted)
}
}
三个指标并排展示:已收藏数、总短语数、方言种类数。数字使用不同颜色(主色/文字色/强调色)来区分三个维度的视觉权重。
5. 每日一句种子算法
5.1 算法实现
getDailyIndex(): number {
return Math.floor(Date.now() / 86400000) % PHRASES.length;
}
与"关怀平替"(App 25)和"断段打卡"(App 26)中的种子算法相同——取当前日期的时间戳,除以一天的毫秒数得到天数序号,对话语数量取模。
32 条短语 ÷ 5 种方言 ≈ 每种方言 6-8 条。32 天的循环周期,足够用户每天看到不同的内容。32 天后虽然会重复,但对于一款轻量级学习 App 来说,重复也是复习——“这句我见过!上次学过了!”
5.2 每日一句的展示
if (PHRASES.length > 0) {
Column() {
Text(this.getDailyDialect() + ' · ' + this.getDailyText()).fontSize(20)
.fontColor(C.primary).fontWeight(FontWeight.Bold)
Text(this.getDailyPron()).fontSize(14).fontColor(C.textLight)
Text('👉 ' + this.getDailyMeaning()).fontSize(15).fontColor(C.text)
Text(this.getDailyNote()).fontSize(12).fontColor(C.textMuted)
}.onClick(() => {
this.selectedPhrase = this.getDailyIndex();
this.showDetail = true;
})
}
卡片展示四行信息:方言名 + 短语(第 1 行)、发音(第 2 行)、释义(第 3 行)、小贴士(第 4 行)。点击卡片打开详情弹窗,可以进一步查看完整内容。
5.3 Helper 方法
因为 @Builder 中不能使用 const daily = this.getDailyPhrase(),所以为每日一句的每个字段都创建了独立的 getter 方法:
getDailyDialect(): string { return PHRASES[this.getDailyIndex()].dialect; }
getDailyText(): string { return PHRASES[this.getDailyIndex()].phrase; }
getDailyPron(): string { return PHRASES[this.getDailyIndex()].pron; }
getDailyMeaning(): string { return PHRASES[this.getDailyIndex()].meaning; }
getDailyNote(): string { return PHRASES[this.getDailyIndex()].note; }
每个方法都调用 getDailyIndex() 获取当天的索引,然后从 PHRASES 数组中读取对应的字段。虽然每次调用都会重新计算 getDailyIndex()(O(1) 复杂度,32 取模),但考虑到一个 @Builder 中最多调用 5 次,性能损耗可以忽略。
6. 学习 Tab 与方言切换
6.1 方言切换器
Row() {
ForEach(DIALECTS, (d: Dialect, idx: number) => {
Text(d.emoji + ' ' + d.name).fontSize(14)
.fontColor(this.selectedDialect === idx ? Color.White : C.text)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.backgroundColor(this.selectedDialect === idx ? C.primary : C.bgLight).borderRadius(14)
.onClick(() => { this.selectedDialect = idx; })
}, (d: Dialect) => 's' + d.id.toString())
}
5 个方言标签水平排列,选中态为白字 + 主色背景,未选中态为深色字 + 浅灰背景。每个标签宽度自适应(padding 12px),使用 borderRadius(14) 形成药丸形状。
6.2 短语列表筛选
getFilteredPhrases(): Phrase[] {
const result: Phrase[] = [];
for (const p of PHRASES) {
if (p.dialect === DIALECTS[this.selectedDialect].name) {
result.push(p);
}
}
return result;
}
每次调用遍历全部 32 条短语,筛选出与当前方言匹配的短语。对于 32 条数据来说,遍历是完全可以接受的。如果扩展到 1000 条以上,可以考虑建立方言→短语的索引。
6.3 短语卡片
每条短语展示为独立卡片:
┌──────────────────────────────────┐
│ 食咗飯未呀 🤍 │
│ sik6 zo2 faan6 mei6 aa3 │
│ ─────────────────────────────── │
│ 📖 吃了吗 👉 │
└──────────────────────────────────┘
卡片分三行:第一行是方言短语(左侧 18sp 粗体)+ 收藏按钮(右侧),第二行是发音(13sp 灰色),第三行是分隔线 + 释义(14sp 灰色)。
点击卡片打开详情弹窗。点击 🤍 切换收藏状态。
7. 收藏系统
7.1 数据结构
@State favorites: number[] = [];
用 number[] 存储已收藏短语的 id(与短语模型中的 id 字段对应)。这是本系列中最常见的一维数组状态模式。
7.2 收藏切换
toggleFav(id: number): void {
const idx = this.favorites.indexOf(id);
if (idx >= 0) {
const f = this.favorites.concat([]);
f.splice(idx, 1);
this.favorites = f;
} else {
this.favorites = [id, ...this.favorites];
}
}
indexOf 查找 id 是否已在收藏中。如果在,用 splice 移除;如果不在,头部插入。concat([]) 创建新数组以触发 @State 更新。
7.3 收藏列表
收藏 Tab 展示所有已收藏的短语。每条卡片显示方言标签、短语、发音、释义。卡片可以点击打开详情弹窗,也可以点击 ❤️ 取消收藏。
空状态引导:“在学习页面点 🤍 收藏你喜欢的方言短语”——这句话使用 App 实际使用的图标(🤍),而不是通用描述,让用户知道去哪里操作。
7.4 收藏状态的同步
收藏状态在三个位置展示,且始终保持同步:
| 位置 | 未收藏 | 已收藏 |
|---|---|---|
| 学习短语卡片 | 🤍 白心 | ❤️ 红心 |
| 详情弹窗底部 | “🤍 收藏” | “❤️ 已收藏” |
| 首页进度统计 | — | 已收藏数 +1 |
| 收藏 Tab | 不显示 | 完整卡片 |
同步机制:所有 Tab 共享同一个 favorites @State 数组。任何 Tab 修改数组后,其他 Tab 自动重新渲染。
8. 详情弹窗
8.1 弹窗布局
Column() {
Text(PHRASES[this.selectedPhrase].dialect).fontSize(14)
.backgroundColor(C.primaryDim).borderRadius(10)
Text(PHRASES[this.selectedPhrase].phrase).fontSize(32)
.fontWeight(FontWeight.Bold)
Text(PHRASES[this.selectedPhrase].pron).fontSize(18)
Divider()
Text('📖 释义')
Text(PHRASES[this.selectedPhrase].meaning).fontSize(16)
Text('💡 小贴士')
Text(PHRASES[this.selectedPhrase].note).fontSize(15)
// 收藏按钮
Text(this.isFav(PHRASES[this.selectedPhrase].id) ? '❤️ 已收藏' : '🤍 收藏')
Text('关闭')
}
弹窗从上到下依次展示:方言标签 → 短语(32sp 超大字号)→ 发音 → 分割线 → 释义 → 小贴士 → 收藏按钮 → 关闭按钮。
32sp 的短语字号:这是全 App 最大的字号,让短语成为弹窗的视觉焦点。学习类 App 的核心信息应该是"这句话怎么说",而不是"这句话什么意思"。释义放在分割线下方,字号 16sp,视觉上低于短语。
8.2 内联访问模式
弹窗中所有数据都通过 PHRASES[this.selectedPhrase] 内联访问,不使用中间变量。这是为了遵守 @Builder 的"无变量声明"约束。
9. Helper 方法模式
9.1 问题的本质
本 App 中,由于 @Builder 中不能使用 const 声明,当需要在一个组件中多次访问同一个对象的属性时,面临两个选择:
选择 A:重复内联
Text(PHRASES[this.getDailyIndex()].dialect)
Text(PHRASES[this.getDailyIndex()].phrase)
Text(PHRASES[this.getDailyIndex()].pron)
// 每次重复调用 getDailyIndex() 和数组访问
选择 B:Helper 方法
getDailyDialect(): string { return PHRASES[this.getDailyIndex()].dialect; }
本 App 选择方案 B。Helper 方法将"获取某个字段"的逻辑封装为独立的方法,在 @Builder 中调用方法而非内联表达式。
9.2 Helper 方法清单
本 App 定义了 getDialectCount、getDailyIndex、getDailyDialect、getDailyText、getDailyPron、getDailyMeaning、getDailyNote、getFavDialect、getFavPhrase、getFavPron、getFavMeaning、getFavIndex、getGlobalIdx、findById、isFav、getFilteredPhrases ——共 16 个帮助方法。
其中 10 个是为解决 @Builder 的 const 约束而创建的"数据访问方法":
| 方法 | 用途 | 在 @Builder 中替代 |
|---|---|---|
getDailyDialect() |
获取每日方言名 | const d = getDailyPhrase().dialect |
getFavPhrase(id) |
获取收藏短语 | const p = findById(id).phrase |
getFavIndex(id) |
获取收藏短语索引 | const p = findById(id); getGlobalIdx(p) |
9.3 Helper 方法的优缺点
优点:
- 在 @Builder 中可安全使用(没有 const)
- 方法名自文档化(
getDailyDialect一眼就知道做什么) - 可以添加空值保护(
return p !== undefined ? p.dialect : '')
缺点:
- 方法数量多(本 App 16 个,其中 10 个是 Helper)
- 每次调用重新计算(但数据量小,无性能问题)
- 命名需要一致(
getDailyXxx前缀,getFavXxx前缀)
Helper 方法模式适合的场景:
- @Builder 中需要频繁访问对象属性
- 数据从常量/状态数组中读取
- 有空值需要处理
Helper 方法模式不适合的场景:
- 属性只有一处使用:直接内联更简单
- 属性是简单 boolean 或 string:内联三元表达式即可
- 方法内部逻辑复杂:拆分为更小的方法
9.4 Helper 方法与普通方法的界限
本 App 中 16 个方法分为两类:纯业务方法和 Helper 方法。
纯业务方法(如 getFilteredPhrases、toggleFav、findById)是完整的、独立的逻辑单元,不管 @Builder 存不存在,这些方法都应该存在。
Helper 方法(如 getDailyDialect、getFavPhrase)纯粹是为了在 @Builder 中替代 const 声明而创建的。如果没有 @Builder 的 const 约束,这些方法根本不需要存在——可以直接 const d = PHRASES[idx]; d.dialect。
这个界限很重要:不要因为 Helper 方法模式好用就滥用它。 如果某个方法的存在仅仅是因为 @Builder 不能写 const,说明这是 ArkTS 语法约束的妥协方案,而不是好的设计。
10. 编译错误全记录
10.1 错误概览
本 App 共出现 10 个编译错误。
| # | 错误代码 | 位置 | 原因 | 修复 |
|---|---|---|---|---|
| 1-3 | 10905209 | L175, 309, 340 | @Builder 中 const 声明 |
提取 Helper 方法 |
| 4-8 | 10605999 | L177-181 | getDailyPhrase() 返回可能为 undefined |
改为独立字段方法 |
| 9 | 10905209 | ForEach 回调 | const phr = this.findById(id) |
提取 Builder 方法 |
| 10 | 10905209 | onClick 回调 | const phr = this.findById(id) |
提取 getFavIndex 方法 |
10.2 Object is possibly ‘undefined’
错误信息:Object is possibly 'undefined'
触发场景:
if (this.getDailyPhrase() !== undefined) {
// 虽然上面有 if 检查,但 ArkTS 认为每次调用 getDailyPhrase()
// 都可能返回不同的值,所以这里的 .dialect 访问仍可能失败
Text(this.getDailyPhrase().dialect) // ❌ 10605999
}
根因:ArkTS 的类型收缩(type narrowing)不会跨函数调用生效。this.getDailyPhrase() 在 if 条件中返回了非 undefined,但在下一行再次调用时,编译器重新评估返回值类型——仍然是 Phrase | undefined。
修复方案:由原来的"一个方法返回整个对象"改为"多个方法分别返回每个字段":
// ❌ 原来:一个方法返回对象
getDailyPhrase(): Phrase | undefined { ... }
// 使用时:Text(this.getDailyPhrase().dialect) ← 10605999
// ✅ 修复:每个字段一个方法
getDailyDialect(): string { return PHRASES[this.getDailyIndex()].dialect; }
// 使用时:Text(this.getDailyDialect()) ← 无错误
10.3 30 款 App 的错误趋势
App 1: 16 ← 入门
App 10: 11 ← 模式形成
App 20: 2 ← 高效期
App 24: 48 ← AI 新领域(波峰)
App 25: 3 ← 回归基线
App 26: 8 ← 全部同一类型
App 27: 5 ← 低级错误
App 28: 8 ← 预览器兼容
App 29: 6 ← Builder const
App 30: 10 ← Helper 方法模式形成
App 30 的 10 个错误中,5 个是"新错误类型"(10605999 对象可能 undefined),5 个是"老朋友"(10905209)。新错误类型的出现意味着新的编码模式带来了新的编译器约束。
10.4 新错误类型的启示
10605999 的出现给了一个重要启示:不要在 @Builder 中多次调用同一个返回值不稳定(可能为 undefined)的方法。 编译器不会跨调用进行类型收缩,每次调用都视为独立结果。
如果需要多次使用同一个方法的返回值,有以下方案:
| 方案 | 适用场景 | 是否可行 |
|---|---|---|
| const 声明 | 所有场景 | ❌ @Builder 中不支持 |
| 内联重复表达式 | 表达式简单 | ✅ 不优雅但可行 |
| 拆分为独立 Helper 方法 | 表达式在多个地方使用 | ✅ 推荐 |
使用非空断言 !. |
确知不会为 null | ⚠️ 编译器可能仍报错 |
11. 第三十款 App 全景回顾
11.1 数据总览
| 指标 | 数值 |
|---|---|
| 代码行数 | 406 行 |
| 编译错误数 | 10 个 |
| @State 变量 | 6 个 |
| @Builder 方法 | 8 个 |
| Helper 方法 | 16 个 |
| 方言种类 | 5 种 |
| 短语总数 | 32 条 |
| 弹窗数 | 1 个 |
| 外部依赖 | 0 个 |
11.2 30 款 App 的代码行数趋势
App 1: 767 ← 白噪音
App 10: 478 ← 订阅刺客
App 20: 582 ← 宠物拍立得
App 24: 907 ← AI 树洞(峰值)
App 25: 488 ← 关怀平替
App 26: 508 ← 断段打卡
App 27: 340 ← 长辈说明书
App 28: 188 ← 记忆时光机(谷值)
App 29: 373 ← 反向导师平台
App 30: 406 ← 方言学习
从 App 28 的 188 行(系列最低)到 App 30 的 406 行,代码量回升了。原因是:方言数据本身占用了大量行数(32 条短语 × 6 个字段 = 近 200 行纯数据)。
排除数据占用的行数后,真正的 UI + 业务逻辑代码约为 200 行 —— 与 App 28 相当。
11.3 30 款 App 的数据膨胀
30 款 App 的代码行数波动,核心原因不是 UI 复杂度,而是预置数据量:
| App | 预置数据 | 数据行数占比 |
|---|---|---|
| 白噪音 | 10 条音效 | ~5% |
| 梦境解析 | 10 条梦境解读 | ~20% |
| AI 树洞 | 164 条回应模板 | ~45% |
| 关怀平替 | 40 条关怀模板 | ~15% |
| 长辈说明书 | 50 条步骤指南 | ~30% |
| 记忆时光机 | 3 条示例记忆 | ~5% |
| 方言学习 | 32 条短语 | ~50% |
结论:代码行数的一半可能来自数据。数据和逻辑分离的设计(数据存在常量中,逻辑从常量读取)让 App 可以轻松扩展——添加 10 条新方言短语,不需要修改任何 @Builder 或业务方法。
11.4 三十款 App 的六个阶段
回顾 30 款 App,可以分为六个清晰的阶段:
第一阶段:语法学习(App 1-5)
这个阶段的 App 行数多、错误多、代码质量低。每一款 App 都在学习新的语法特性,从 @Builder 到 ForEach 到 @State,每一行代码都是"第一次写"。
第二阶段:模式形成(App 6-12)
Tab 架构、弹窗模式、颜色接口——这些通用模式开始形成。新 App 不再从零开始,而是从已验证的模式起步。错误数从 10+ 降到 5-8。
第三阶段:高效输出(App 13-20)
可以在一小时内写出一款完整的 App。模式已经内化为肌肉记忆——写 ForEach 时自动加 key 函数,写颜色时先定义 interface。
第四阶段:新领域探索(App 21-25)
AI 树洞(App 24)的 48 个错误是第四阶段的标志性事件。在熟悉的领域可以做到 1-3 个错误,但进入新领域时,错误数会飙升至数十个。
第五阶段:极简主义(App 26-28)
App 28(记忆时光机)以 188 行创造了系列最低。这不仅是代码量的减少,更是对"什么功能真正必要"的判断力成熟。
第六阶段:模式深化(App 29-30)
Helper 方法模式、双数组状态管理——这些是第五阶段极简主义的基础上,为了应对更复杂需求而发展的新模式。
三十款 App 不是线性的学习曲线,而是一系列"S型曲线"的叠加:每次进入新领域时短暂下降,然后快速上升并超越前一个阶段的水平。
11.5 Helper 方法模式的首秀
本 App 是系列中第一个大规模使用 Helper 方法模式的 App。16 个 Helper 方法中,10 个是为解决 @Builder 的 const 约束而生的"数据访问方法"。
这个模式的诞生标志着:在 ArkTS 开发中,与其在 @Builder 中与编译器较劲,不如创建更多的小方法。方法可以声明 const、可以使用 if-else、可以包含循环——而 @Builder 中这些都不能做。
Helper 方法模式的核心思想:@Builder 只做两件事——调用 Helper 方法和声明 UI 组件。所有数据加工、计算、查找都在 Helper 方法中完成。
12. 结语
12.1 方言的价值
方言不只是"另一种说话方式"。它是身份的标记、是家乡的记忆、是爷爷奶奶教你牙牙学语时的声音。
这款 App 不能替代一个说方言的 grandmother,也不能替代沉浸式的语言环境。但它可以在你回老家前,花 5 分钟学会几句"食咗飯未呀"和"巴適"。
不要小看这几句话。对长辈来说,你用方言说一句"你吃了吗",比用普通话说一百句"我爱你"更有分量。
12.2 30 款 App 之后的感悟
写了 30 款 App 之后,最深的感悟是:
App 的功能可以很简单,但对某个人来说可能很重要。
方言学习 App 的功能极其简单——一个列表加一个收藏。但如果你用它在回老家前学了 3 句家乡话,对爷爷奶奶说了,他们的笑容就是这款 App 存在的全部意义。
同样,AI 树洞的 48 个编译错误不重要——重要的是它能不能在某个人需要倾诉的时候提供一个 safe space。断段打卡的 5 个错误不重要——重要的是它能不能帮一个人建立更好的生活习惯。
写代码的时候,我们太容易陷入"这个功能不够完善"、“这个 UI 不够好看”、"这个架构不够优雅"的自我怀疑中。但用户不会用"代码是否优雅"来评价你的 App——他们用"这个 App 帮我解决了什么问题"来评价。
30 款 App 教会我最重要的一课:完成比完美重要。 188 行的记忆时光机比一个写了一半的 1000 行超级 App 更有价值。上线的不完美的产品,比停留在本地的完美的设计稿更有意义。
12.3 给开发者的建议
- Helper 方法模式:当 @Builder 中需要访问对象属性时,创建 Helper 方法而不是内联表达式——代码更清晰,也避免 “Object is possibly ‘undefined’” 错误
- 编译器的类型收缩有限:TypeScript 的
if (x !== undefined) { x.prop }模式在 ArkTS 中可以工作,但跨函数调用就不行了——每个调用独立检查 - 数据与逻辑分离:方言数据放在常量中,业务逻辑放在方法中,UI 放在 @Builder 中——三层分离,每一层的修改不影响其他层
- 30 款 App 的终点不是终点:如果你正好读到这篇,开始写你的第 1 款 App 吧——不管它多简单,对某个人来说可能很重要
12.4 致谢
30 款 App、30 篇博客、约 300,000 字。
从白噪音到方言学习,从 767 行到 406 行,从 16 个错误到 10 个错误——这个系列记录了 ArkTS 的成长,也记录了一个开发者的成长。
如果你是这个系列的老读者,感谢你从第一篇读到了第三十篇。如果你是新读者,从任何一篇开始读都可以——每一篇都是一个独立的故事。
现在,打开 DevEco Studio,去写属于你自己的第 1 款 App 吧。
附录 A:核心代码速查
每日一句
getDailyIndex(): number {
return Math.floor(Date.now() / 86400000) % PHRASES.length;
}
收藏切换
toggleFav(id: number): void {
const idx = this.favorites.indexOf(id);
if (idx >= 0) {
const f = this.favorites.concat([]);
f.splice(idx, 1);
this.favorites = f;
} else {
this.favorites = [id, ...this.favorites];
}
}
方言筛选
getFilteredPhrases(): Phrase[] {
const result: Phrase[] = [];
for (const p of PHRASES) {
if (p.dialect === DIALECTS[this.selectedDialect].name) {
result.push(p);
}
}
return result;
}
附录 B:色板
| 变量 | 值 | 用途 |
|---|---|---|
C.bg |
#FFF8F0 |
主背景 |
C.bgCard |
#FFFFFF |
卡片背景 |
C.primary |
#C9783E |
主色(陶土橙) |
C.accent |
#5B8C5A |
强调色(竹绿) |
附录 C:Helper 方法 vs @Builder 内联
场景 @Builder 内联 Helper 方法
──────────────────────────────────────────────────────────────
访问对象属性 Text(obj.prop) Text(this.getProp())
需要条件判断 (cond ? a : b) 方法内部 if-else
需要循环 ❌ 不支持 方法内部 for
需要中间变量 ❌ 不支持 方法内部 const
空值保护 ❌ 10605999 return x ?? ''
选择建议:
简单属性读取 → 内联
需要条件/循环/变量 → Helper 方法
可能返回 undefined → Helper 方法
更多推荐




所有评论(0)