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

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

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


目录

  1. 引言
  2. 产品概念与数据模型
  3. 三 Tab 架构设计
  4. 首页方言 Grid
  5. 每日一句种子算法
  6. 学习 Tab 与方言切换
  7. 收藏系统
  8. 详情弹窗
  9. Helper 方法模式
  10. 编译错误全记录
  11. 第三十款 App 全景回顾
  12. 结语

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 的技术特色

  1. 32 条方言数据:覆盖 5 种方言(粤语、闽南语、上海话、客家话、四川话),每条包含方言写法、拼音/注音、释义、小贴士
  2. 每日一句种子算法:基于日期时间戳的伪随机算法,每天展示一条不同的方言短语
  3. 收藏系统:完整的收藏/取消收藏功能,双向状态同步
  4. Helper 方法模式:为解决 @Builder 中不能使用 const 而创建的辅助方法体系
  5. 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 定义了 getDialectCountgetDailyIndexgetDailyDialectgetDailyTextgetDailyProngetDailyMeaninggetDailyNotegetFavDialectgetFavPhrasegetFavProngetFavMeaninggetFavIndexgetGlobalIdxfindByIdisFavgetFilteredPhrases ——共 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 方法。

纯业务方法(如 getFilteredPhrasestoggleFavfindById)是完整的、独立的逻辑单元,不管 @Builder 存不存在,这些方法都应该存在。

Helper 方法(如 getDailyDialectgetFavPhrase)纯粹是为了在 @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 给开发者的建议

  1. Helper 方法模式:当 @Builder 中需要访问对象属性时,创建 Helper 方法而不是内联表达式——代码更清晰,也避免 “Object is possibly ‘undefined’” 错误
  2. 编译器的类型收缩有限:TypeScript 的 if (x !== undefined) { x.prop } 模式在 ArkTS 中可以工作,但跨函数调用就不行了——每个调用独立检查
  3. 数据与逻辑分离:方言数据放在常量中,业务逻辑放在方法中,UI 放在 @Builder 中——三层分离,每一层的修改不影响其他层
  4. 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 方法

Logo

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

更多推荐