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



鸿蒙 Next 声音明信片 App 开发实战:模拟录音 + 卡片画廊
作者:DULUO
SDK 版本:HarmonyOS API 24 (Next)
开发工具:DevEco Studio
语言框架:ArkTS + ArkUI
字数:约 10500 字
目录
- 引言
- 产品概念与数据模型
- 三 Tab 架构
- 明信片画廊
- 模拟录音机制
- 播放模拟与进度条
- 心情选择器
- 关于页面与随机语录
- 编译错误全记录
- 十一款 App 全景回顾
- ArkUI 开发的十一个关键认知
- 结语
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 两个交互入口
- 点击卡片主体 → 打开详情弹窗
- 点击 🔊 按钮 → 直接播放模拟录音
// 卡片主体点击 → 详情
.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 包含完整的表单输入:
- 标题输入 — TextInput,必填
- 心情选择 — 点击弹出 Grid 选择器
- 地点输入 — TextInput,选填,默认"未知地点"
- 文字内容 — TextArea,选填,为空时随机生成
- 录音按钮 — 圆形按钮,点击开始/停止
- 提交按钮 — “🎑 制作明信片”
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 绑定 playProgress,max 绑定录音时长。随着定时器更新 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 不支持方向边框
borderBottomWidth、borderTopWidth 等属性在 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 给开发者的十一条建议
- Builder 不放逻辑
- 颜色声明接口
- 数组用 concat
- 弹窗用 if 包裹
- ForEach key 独立作用域
- 数据模型先行
- 模式复用
- 紧凑风格
- 检查残留代码
- setInterval 要清理
- 持续实践
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 轮 |
更多推荐



所有评论(0)