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

鸿蒙 Next 声音明信片 App 开发实战:模拟录音 + 卡片画廊

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


目录

  1. 引言
  2. 产品概念与数据模型
  3. 三 Tab 架构
  4. 明信片画廊
  5. 模拟录音机制
  6. 播放模拟与进度条
  7. 心情选择器
  8. 关于页面与随机语录
  9. 编译错误全记录
  10. 十一款 App 全景回顾
  11. ArkUI 开发的十一个关键认知
  12. 结语

1. 引言

1.1 声音明信片:用声音记录瞬间

明信片是旅行中寄给亲友的传统礼物——一张图片、一段文字、一个邮戳。数字时代,明信片的形式可以更加丰富:加入声音,让收件人不仅能"看到"风景,还能"听到"现场的声音——海浪拍岸、风吹树叶、街头艺人的歌声。与图片相比,声音承载了更丰富的情感信息——海浪的节奏、风声的强弱、人群的嘈杂声,这些都是静态图片无法传达的。

“声音明信片"App 将这一概念数字化:用户录制一段声音,配上文字和心情,标记地点,制作成一张"声音明信片”。它可以自己收藏,也可以分享给朋友。每张明信片都包含五个要素:声音、文字、心情、地点和时间,五者共同构成一个完整的记忆切片。

1.2 本 App 的技术特色

本 App 引入了几个此前未涉及的技术点:

技术点 说明
模拟录音 使用 setInterval 模拟录音计时和状态切换
播放进度条 Slider 组件与定时器配合模拟播放进度
录音按钮动画 圆形按钮的录制态/停止态视觉切换
心情 Grid 8 种心情 Emoji 的 Grid 选择器
随机语录 从列表中随机抽取展示

1.3 十一款 App 的系列数据

这是本系列的第十一款 App。

App 数量:    11
代码总行数:  ~8,200 行
编译错误数:  ~121 个
博客总字数:  ~116,000 字
技术博客数:  11 篇

2. 产品概念与数据模型

2.1 功能需求

用户故事 1:我想录制一段声音,记录下来
用户故事 2:我想配上文字和心情,制作成明信片
用户故事 3:我想浏览所有声音明信片
用户故事 4:我想回放之前录制的声音

功能清单:
├── F1: 模拟录音(60 秒上限,实时计时)
├── F2: 制作明信片(标题 + 文字 + 心情 + 地点)
├── F3: 明信片画廊(卡片列表)
├── F4: 模拟播放(进度条 + 时长显示)
├── F5: 心情选择器(8 种 Emoji)
├── F6: 关于页面(使用说明 + 随机语录)
└── F7: 数据持久化

2.2 数据模型

interface Postcard {
  id: number;          // 唯一标识
  title: string;       // 标题
  message: string;     // 文字内容
  location: string;    // 地点
  mood: string;        // 心情 Emoji
  date: number;        // 创建时间戳
  duration: number;    // 录音时长(秒)
}

duration 字段的用途:在画廊中显示录音时长(🔊 30"),在详情页中作为 Slider 的最大值。

2.3 预设文案

const SAMPLE_MESSAGES: string[] = [
  '今天在海边听到了海浪的声音,想记录下来分享给你。',
  '走在熟悉的小路上,阳光透过树叶洒下来。',
  '这家咖啡馆的角落很安静,只有翻书的声音。',
  // ... 共 6 条
];

当用户填写的文字为空时,自动从预设文案中随机选取一条。


3. 三 Tab 架构

3.1 Tab 配置

buildTabContent() {
  if (this.activeTab === 0) { this.buildGallery() }      // 明信片
  else if (this.activeTab === 1) { this.buildCreatePage() } // 录制
  else { this.buildAboutPage() }                         // 关于
}
Tab 名称 图标 核心功能
0 明信片 🎑 卡片列表 + 播放 + 详情
1 录制 🎤 录音 + 表单 + 制作
2 关于 ℹ️ 使用说明 + 统计 + 语录

本 App 的 Tab 架构与系列前作一致,Tab 栏代码直接复用。


4. 明信片画廊

4.1 卡片设计

ForEach(this.list, (p: Postcard) => {
  Column() {
    Row() {
      Text(p.mood).fontSize(32)           // 心情 Emoji
      Column() {
        Text(p.title).fontSize(16).fontWeight(FontWeight.Bold)  // 标题
        Text(p.location + ' · ' + date)   // 地点 · 日期
        Text(p.message).fontSize(13)      // 文字预览(单行截断)
      }
      Column() {
        Text('🔊').fontSize(20)            // 播放按钮
        Text(p.duration + '"').fontSize(10) // 时长
      }
    }
  }
  .borderRadius(16)
  .shadow({ radius: 6, color: 'rgba(0,0,0,0.04)', offsetY: 2 })
})

4.2 卡片布局

每张卡片包含三个区域:

😊  海边的午后               🔊
    厦门鼓浪屿 · 06/15       30"
    今天在海边听到了海浪...
  • 左侧:心情 Emoji(32sp)
  • 中间:标题 + 地点日期 + 文字预览
  • 右侧:播放按钮 + 时长

4.3 两个交互入口

  1. 点击卡片主体 → 打开详情弹窗
  2. 点击 🔊 按钮 → 直接播放模拟录音
// 卡片主体点击 → 详情
.onClick(() => { this.selected = p; this.showDetail = true; })

// 🔊 按钮点击 → 播放
Text('🔊').fontSize(20).onClick(() => { this.startPlayback(p); })

这是"双重交互"设计——用户可以快速播放,也可以进入详情查看更多内容。


5. 模拟录音机制

5.1 录音状态管理

@State isRecording: boolean = false;
@State recordTime: number = 0;
private recordTimer: number = -1;

三个状态变量控制录音功能:

  • isRecording — 是否正在录制
  • recordTime — 已录制秒数
  • recordTimer — 定时器 ID

5.2 录音切换逻辑

toggleRecord(): void {
  if (this.isRecording) {
    // 停止录制
    this.isRecording = false;
    if (this.recordTimer > 0) { clearInterval(this.recordTimer); this.recordTimer = -1; }
  } else {
    // 开始录制
    this.isRecording = true;
    this.recordTime = 0;
    this.recordTimer = setInterval(() => {
      this.recordTime++;
      if (this.recordTime >= 60) { this.toggleRecord(); }  // 60秒自动停止
    }, 1000);
  }
}

5.3 录音按钮的视觉反馈

Column() {
  Text(this.isRecording ? '⏹️' : '🎤').fontSize(40)
  if (this.isRecording) {
    Text(this.recordTime + '"').fontSize(32).fontColor(C.record)
  }
}.width(120).height(120)
  .backgroundColor(this.isRecording ? C.record + '15' : C.cardBg)
  .borderRadius(60)
  .borderWidth(2)
  .borderColor(this.isRecording ? C.record : C.border)

录制态与停止态的视觉对比

属性 停止态 录制态
图标 🎤 ⏹️
计时 不显示 32sp 红色数字
背景 白色 红色 8% 透明度
边框 灰色 #D7CCC8 红色 #E53935

5.4 录制页面的完整表单

录制 Tab 包含完整的表单输入:

  1. 标题输入 — TextInput,必填
  2. 心情选择 — 点击弹出 Grid 选择器
  3. 地点输入 — TextInput,选填,默认"未知地点"
  4. 文字内容 — TextArea,选填,为空时随机生成
  5. 录音按钮 — 圆形按钮,点击开始/停止
  6. 提交按钮 — “🎑 制作明信片”

5.5 创建明信片逻辑

doCreate(): void {
  if (this.newTitle.trim() === '' || this.recordTime === 0) return;

  let msg = this.newMessage.trim() ||
    SAMPLE_MESSAGES[Math.floor(Math.random() * SAMPLE_MESSAGES.length)];

  let p: Postcard = {
    id: Date.now(), title: this.newTitle.trim(), message: msg,
    location: this.newLocation.trim() || '未知地点',
    mood: MOODS[this.newMood], date: Date.now(), duration: this.recordTime
  };
  this.list = [p].concat(this.list);
  this.showCreate = false;
  this.saveData();
}

校验规则:标题和录音时长必填,其他字段可选。


6. 播放模拟与进度条

6.1 播放状态管理

@State isPlaying: boolean = false;
@State playProgress: number = 0;
private playTimer: number = -1;

6.2 播放逻辑

startPlayback(p: Postcard): void {
  if (this.isPlaying) {
    // 停止播放
    this.isPlaying = false; this.playProgress = 0;
    if (this.playTimer > 0) { clearInterval(this.playTimer); this.playTimer = -1; }
  } else {
    // 开始播放
    this.isPlaying = true; this.playProgress = 0;
    let target = p.duration;
    this.playTimer = setInterval(() => {
      this.playProgress++;
      if (this.playProgress >= target) {
        // 播放完成
        this.isPlaying = false; this.playProgress = 0;
        clearInterval(this.playTimer); this.playTimer = -1;
      }
    }, 100);  // 每 100ms 前进 1 步
  }
}

播放速度:每 100ms 前进 1 个单位,对于 30 秒的录音,整个播放过程持续 30 × 10 = 300 步 × 100ms = 30 秒。

6.3 进度条组件

Slider({ value: this.playProgress, min: 0, max: p.duration, step: 1 })
  .width('85%')
  .trackThickness(4)
  .blockColor(C.play)         // 绿色滑块
  .trackColor(C.border + '66') // 灰色轨道
  .selectedColor(C.play)      // 绿色已播放区域

Slider 的 value 绑定 playProgressmax 绑定录音时长。随着定时器更新 playProgress,Slider 自动前进。

6.4 播放区域

Column() {
  Row() {
    Text('🔊').fontSize(24)
    Text(this.isPlaying ? '播放中...' : '点击播放').fontSize(14)
    Text(p.duration + '"').fontSize(14)
  }
  Slider({ ... })
}
.width('85%').padding(14)
.backgroundColor(C.play + '10')        // 绿色 6% 透明背景
.borderRadius(14)
.borderWidth(1).borderColor(C.play + '33')  // 绿色 20% 透明边框

绿色半透明背景区分于卡片的白色背景,告诉用户"这是可交互的播放区域"。


7. 心情选择器

7.1 八种心情

const MOODS: string[] = ['😊', '🥰', '😌', '🎉', '😢', '🤔', '😤', '☀️'];
const MOOD_LABELS: string[] = ['开心', '心动', '平静', '兴奋', '伤感', '思考', '发泄', '晴朗'];

八种心情覆盖了日常的主要情绪状态,既包括正面情绪(开心、心动、平静、兴奋、晴朗),也包括负面情绪(伤感、发泄)和中立情绪(思考)。

7.2 Grid 布局

Grid() {
  ForEach(MOODS, (m: string, idx: number) => {
    GridItem() {
      Column() {
        Text(m).fontSize(28)
        Text(MOOD_LABELS[idx]).fontSize(11)
      }
      .padding(8)
      .backgroundColor(this.newMood === idx ? C.primary + '18' : 'transparent')
      .borderRadius(12)
      .borderWidth(this.newMood === idx ? 1 : 0)
      .borderColor(C.primary + '44')
      .onClick(() => { this.newMood = idx; this.showMoodPicker = false; })
    }
  }, (m: string) => m)
}
.columnsTemplate('1fr 1fr 1fr 1fr')  // 4列

选中态与未选中态的视觉对比

属性 未选中 选中
背景 透明 橙色 9% 透明度
边框 1px 橙色 27% 透明度
文字颜色 灰色 橙色(通过判断)

7.3 心情在卡片中的展示

选中的心情 Emoji 会展示在明信片卡片的左上角,作为最重要的视觉元素——因为它表达了制作者当时的情感状态。


8. 关于页面与随机语录

8.1 四步使用说明

this.buildAboutCard('🎤', '录制声音', '录下此刻的声音——海浪、雨声、笑声、歌声。')
this.buildAboutCard('📝', '配上文字', '写下想说的话,选择此刻的心情,记录当下的地点。')
this.buildAboutCard('🎑', '制作明信片', '声音 + 文字 + 心情 + 地点 = 一张完整的声音明信片。')
this.buildAboutCard('🔊', '回放聆听', '随时翻出收藏的声音明信片,让回忆再次响起。')

8.2 统计信息

Text('共 ' + this.list.length + ' 张明信片').fontSize(14).fontColor(C.textLight)

8.3 随机语录展示

if (this.list.length > 0) {
  Text('"' + this.list[0].message + '"')
    .fontSize(15).fontColor(C.textLight)
    .fontStyle(FontStyle.Italic)
    .lineHeight(22).textAlign(TextAlign.Center)
}

展示第一条明信片的消息内容作为"今日语录"。当用户制作更多明信片后,这个展示可以改为随机抽取。


9. 编译错误全记录

9.1 错误概览

本 App 出现 3 个编译错误——系列最低之一,仅次于尴尬粉碎机的 1 个。

# 错误类型 位置 原因
1 对象字面量无类型 L31 C 缺 ColorScheme 接口
2-3 @Builder 中 let/return L308-309 buildDetailDialog 用 let p + return

9.2 修复过程

L31 修复:添加 ColorScheme 接口。

L308-309 修复

// 修复前
@Builder buildDetailDialog() {
  if (this.selected === null) return;
  let p = this.selected as Postcard;
  // 使用 p.title, p.mood 等...
}

// 修复后
@Builder buildDetailDialog() {
  if (this.selected !== null) {
    // 使用 this.selected!.title, this.selected!.mood 等...
  }
}

9.3 十一款 App 错误数趋势

22 │              🧊
   │
17 │      ⏳
16 │  🎵
   │
12 │                  🛡️ 💡
11 │                         🧭 🗡️
10 │                            🐶
   │
 4 │                                🗑️
 3 │                                    🎑
 1 │          😅
   └──────────────────────────────────────────────▶
   A1  A2  A3  A4  A5  A6  A7  A8  A9  A10 A11

错误数稳定在 3-12 之间,趋势平稳。


10. 十一款 App 全景回顾

10.1 数据总览

# App 行数 错误数 @Builder 持久化 Tab 主题
1 🎵 白噪音 767 16 8 1 深色
2 ⏳ 时间胶囊 955 17 12 1 浅色
3 🧊 冰箱剩菜 1320 22 17 3 浅色
4 😅 尴尬粉碎机 953 1 15 3 浅色
5 🛡️ 防骗训练 1038 12 14 3 浅色
6 💡 碎片学习 851 12 14 3 浅色
7 🐶 宠物日记 450 10 12 3 浅色
8 🗑️ 情绪垃圾桶 390 4 12 3 浅色
9 🧭 线下寻宝 447 11 12 3 浅色
10 🗡️ 订阅刺客 478 11 14 3 暗色
11 🎑 声音明信片 458 3 14 3 浅色

10.2 核心技术覆盖

技术点 覆盖 App 首次出现
@State + @Builder 全部 11 款 App1
数据持久化 10 款 App2
Tab 架构 9 款 App3
弹窗系统 全部 11 款 App1
Grid 选择器 4 款 App3
颜色接口 10 款 App2
紧凑风格 5 款 App7
暗色主题 1 款 App10
模拟录音 1 款 App11

10.3 代码行数趋势

1320 │ 🧊
 955 │ ⏳
 953 │     😅
 851 │          💡
 767 │ 🎵
 478 │                  🗡️
 458 │                      🎑
 450 │               🐶
 447 │             🧭
 390 │                 🗑️

10.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 实现

11. ArkUI 开发的十一个关键认知

经过十一款 App 的开发实践,以下是 ArkUI 开发的十一条关键认知:

11.1 @Builder 的语法边界

@Builder 方法中不要写 let、return、函数调用。这是出现频率最高的错误,占总错误的近四成。遵守这条规则可以减少大部分编译错误。

11.2 @State 的引用比较

@State 使用 === 检测变化。数组和对象修改后必须创建新引用。数组用 .concat([]),对象用自定义的 copyXxx() 方法。

11.3 ForEach 的三个参数

ForEach(arr, (item, index) => { /* UI */ }, (item) => item.id.toString())

第三个参数是 key 函数,不能访问第二个参数的 index 变量

11.4 颜色常量必须声明接口

interface ColorScheme { primary: string; }
const C: ColorScheme = { primary: '#000' };

11.5 弹窗用 if 包裹

// ✅
if (this.show) { Column() { /* 蒙层 + 浮层 */ } }
// ❌
if (!this.show) return; Column() { /* ... */ }

11.6 Row 不支持方向边框

borderBottomWidthborderTopWidth 等属性在 Row 上不存在。使用 Divider 组件替代。

11.7 检查残留代码

编辑多个 Builder 方法时,注意各方法的起止位置。一个多余的 } 会导致后续所有方法解析错误。

11.8 数据模型先行

先定义 interface,再写 UI。能减少至少一半的返工。

11.9 紧凑风格降低错误

Builder 越短,出错概率越低。链式调用合并到单行。

11.10 模式复用提高效率

新 App 优先使用已验证的模式。Tab 架构、弹窗、持久化等模式一旦验证,可以直接复制使用。

11.11 setInterval 的资源管理

使用 setInterval 的 App 必须实现 clearTimers() 方法,在 aboutToDisappear() 中调用,防止组件销毁后定时器仍在运行。

clearTimers(): void {
  if (this.recordTimer > 0) { clearInterval(this.recordTimer); this.recordTimer = -1; }
  if (this.playTimer > 0) { clearInterval(this.playTimer); this.playTimer = -1; }
}

12. 结语

12.1 十一款 App 的开发历程

App1  🎵  白噪音         → 多媒体 + 动画
App2  ⏳  时间胶囊       → 数据持久化
App3  🧊  冰箱剩菜       → Tab 架构 + 游戏化
App4  😅  尴尬粉碎机     → 模式复用
App5  🛡️  防骗训练       → 适老化设计
App6  💡  碎片学习       → 学习激励
App7  🐶  宠物日记       → 紧凑风格
App8  🗑️  情绪垃圾桶     → 情感交互
App9  🧭  线下寻宝       → 社交互动
App10 🗡️  订阅刺客       → 暗色主题 + 财务计算
App11 🎑  声音明信片     → 模拟录音 + 卡片画廊

12.2 关于本系列的最终说明

十一款 App、十一篇博客、约 116,000 字——这是从零开始学习 HarmonyOS Next 应用开发的完整记录。

这条学习路径的设计思路是循序渐进:从最简单的单页 App 开始,逐步引入持久化、Tab 架构、游戏化、适老化、紧凑风格、暗色主题等技术方向。每个 App 只引入一到两个新的技术点,避免一次性学习过多内容。

如果你正在学习 HarmonyOS 开发,推荐按照这个顺序逐步实践。每个 App 的代码量控制在 400-1300 行之间,可以在 1-2 天内完成从阅读到理解再到修改的全过程。

12.3 给开发者的十一条建议

  1. Builder 不放逻辑
  2. 颜色声明接口
  3. 数组用 concat
  4. 弹窗用 if 包裹
  5. ForEach key 独立作用域
  6. 数据模型先行
  7. 模式复用
  8. 紧凑风格
  9. 检查残留代码
  10. setInterval 要清理
  11. 持续实践

12.4 感谢

十一篇博客、约 116,000 字——感谢每一位读到这里的朋友。

技术学习的路上,最珍贵的不是最终的成果,而是过程中每一次编译错误的排查、每一次重构的思考、每一次运行成功的喜悦。

现在,打开 DevEco Studio,开始你的第一个 ArkUI 项目吧。


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

模拟录音

toggleRecord(): void {
  if (this.isRecording) {
    this.isRecording = false;
    clearInterval(this.recordTimer);
  } else {
    this.isRecording = true;
    this.recordTime = 0;
    this.recordTimer = setInterval(() => {
      this.recordTime++;
      if (this.recordTime >= 60) this.toggleRecord();
    }, 1000);
  }
}

模拟播放

startPlayback(p: Postcard): void {
  if (this.isPlaying) {
    this.isPlaying = false; this.playProgress = 0;
    clearInterval(this.playTimer);
  } else {
    this.isPlaying = true; this.playProgress = 0;
    this.playTimer = setInterval(() => {
      this.playProgress++;
      if (this.playProgress >= p.duration) {
        this.isPlaying = false; this.playProgress = 0;
        clearInterval(this.playTimer);
      }
    }, 100);
  }
}

创建明信片

doCreate(): void {
  if (this.newTitle.trim() === '' || this.recordTime === 0) return;
  let msg = this.newMessage.trim() || SAMPLE_MESSAGES[Math.floor(Math.random() * SAMPLE_MESSAGES.length)];
  let p: Postcard = {
    id: Date.now(), title: this.newTitle.trim(), message: msg,
    location: this.newLocation.trim() || '未知地点',
    mood: MOODS[this.newMood], date: Date.now(), duration: this.recordTime
  };
  this.list = [p].concat(this.list);
  this.showCreate = false;
  this.saveData();
}

附录 B:十一款 App 色彩主题全景

App 主色 主题 背景
白噪音 #26A69A 深空沉浸 渐变深色
时间胶囊 #8B6B4A 复古纸 渐变暖色
冰箱剩菜 #26A69A 清爽 渐变绿色
尴尬粉碎机 #FF6B6B 活力 渐变粉紫
防骗训练 #1565C0 冷静 渐变蓝黄
碎片学习 #5C6BC0 知性 渐变紫粉
宠物日记 #FF7043 温暖 渐变橙黄
情绪垃圾桶 #7986CB 治愈 渐变紫粉
线下寻宝 #E65100 探险 渐变橙黄
订阅刺客 #E53935 暗黑 纯色深紫
声音明信片 #FF8A65 温暖旅行 渐变暖橙

附录 C:系列数据终览

指标 数值
App 数量 11
博客总字数 ~116,000 字
代码总行数 ~8,200 行
编译错误总数 ~121 个
@Builder 方法总数 ~150 个
@State 变量总数 ~115 个
接口定义总数 ~30 个
持久化键数 15 个
修复轮次 22 轮

Logo

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

更多推荐