从零开始构建鸿蒙纪念日提醒 App:ArkTS + API 24 实战
一、前言
手机承担着"数字记忆"的角色。生日、纪念日、节日------这些重要的时间节点,如果只靠大脑记忆,难免有遗漏。正因如此,纪念日提醒类应用几乎成了每台手机的标配。
但在鸿蒙生态中,如何从零搭建一个完整的纪念日提醒应用?API 24 带来了哪些能力?ArkTS 的语法约束在实际开发中又会产生哪些"惊喜"?
本文以一个真实的鸿蒙项目------纪念日提醒 App(记录生日/节日,显示倒计时)为蓝本,从项目架构、数据模型、UI 实现、状态管理、编译踩坑五个维度,详细拆解整个开发过程。适合有一定 ArkTS 基础的开发者,也适合刚接触鸿蒙开发的同学作为入门实战参考。
二、项目概览与技术选型
2.1 项目背景
核心需求:
- 用户可添加生日、节日、自定义纪念日
- 每个纪念日可设日期、年份(可选)、是否每年重复
- 主页以卡片列表展示所有纪念日,显示实时倒计时
- 临近纪念日(7 天内)和今天有醒目标识
- 支持左滑删除
- 数据持久化,退出不丢失
2.2 技术栈
| 技术 | 说明 |
|---|---|
| 开发框架 | HarmonyOS ArkTS(声明式 UI) |
| API 版本 | 9~11(对应 API 24+) |
| 开发工具 | DevEco Studio + hvigor 6.23.5 |
| 数据持久化 | @ohos.data.preferences 轻量键值存储 |
| 路由 | @ohos.router |
| 编译规范 | Stage 模型,strict ArkTS 语法 |
2.3 为什么选择 Preferences 而非数据库?
纪念日数据结构简单------一个扁平的列表,无复杂关联查询。Preferences 提供异步键值存储接口,完全够用,无需额外依赖。用 @ohos.data.relationalStore 反而杀鸡用牛刀。
三、项目架构设计
3.1 目录结构
entry/src/main/ets/
├── model/
│ └── Anniversary.ets # 数据模型 + 工具函数
├── pages/
│ ├── Index.ets # 主页(纪念日列表)
│ └── AddAnniversary.ets # 添加页(表单)
└── util/
└── PreferencesUtil.ets # 持久化工具层
三层架构清晰分离:model 层 纯数据逻辑,不依赖 UI;util 层 数据读写,封装异步 API;pages 层UI 表现,驱动用户交互。
3.2 数据流
用户操作 → Page (UI Event) → PreferencesUtil (async) → Preferences
↑ ↓
└────────── refreshList() ←───────────┘
每次增删后,refreshList() 从 Preferences 重新读取数据,驱动 @State 刷新 UI。这种"拉取式"数据流在小型应用中足够高效。
四、数据模型层详解
4.1 Anniversary 接口
typescript
export interface Anniversary {
id: string; // 唯一标识(时间戳+随机数)
name: string; // 名称(如"妈妈生日")
date: string; // 日期,格式 "MM-DD"
year?: number; // 年份(可选)
type: AnniversaryType; // 类型:生日/节日/自定义
repeatYearly: boolean; // 是否每年重复
createdAt: number; // 创建时间戳
note: string; // 备注
}
设计考量:
日期用 "MM-DD" 字符串而非 Date 对象------纪念日核心是"月-日",年份是可选附属。"12-25" 比 Date 对象更直观,序列化零成本,倒计时计算只需解析两个数字。
为什么区分 repeatYearly 和 year? 这是两个正交概念:
- 生日 = 每年重复 + 有出生年份 → 可算年龄
- 结婚纪念日 = 每年重复 + 有起始年份 → 可算第几年
- 国庆节 = 每年重复 + 无年份 → 只显示倒计时
- 单次事件 = 不重复 + 有年份 → 过期不显示
分开设计让组合更灵活。
4.2 核心算法:倒计时计算
typescript
export function getCountdownDays(anniversary: Anniversary): number {
const now = new Date();
const currentYear = now.getFullYear();
const parts = anniversary.date.split('-');
const month = parseInt(parts[0]);
const day = parseInt(parts[1]);
let targetDate = new Date(currentYear, month - 1, day);
// 已过今年日期且每年重复 → 看明年
if (anniversary.repeatYearly && targetDate.getTime() < now.getTime()) {
targetDate = new Date(currentYear + 1, month - 1, day);
}
// 不重复且有指定年份 → 用指定年份
if (!anniversary.repeatYearly && anniversary.year !== undefined) {
targetDate = new Date(anniversary.year, month - 1, day);
}
const diffTime = targetDate.getTime() - now.getTime();
return Math.ceil(diffTime / (1000 * 60 * 60 * 24));
}
边界情况:
| 场景 | 结果 |
|---|---|
| 生日在今天 | 返回 0 |
| 生日还没到 | 正数 |
| 生日已过 + 每年重复 | 自动跳到明年 |
| 生日已过 + 不重复 | 负数 |
| 自定义纪念日有年份 + 不重复 | 用指定年份计算 |
4.3 年龄计算
年龄计算需要判断"今年生日是否已过"------如果还没过,年龄减 1。这个细节容易被忽略。
typescript
export function getAge(anniversary: Anniversary): number {
if (anniversary.type !== AnniversaryType.BIRTHDAY || !anniversary.year) return 0;
const now = new Date();
const currentYear = now.getFullYear();
const parts = anniversary.date.split('-');
const month = parseInt(parts[0]), day = parseInt(parts[1]);
const thisYearBirthday = new Date(currentYear, month - 1, day);
let age = currentYear - anniversary.year;
if (now.getTime() < thisYearBirthday.getTime()) age--;
return age;
}
五、数据持久化层实现
5.1 Preferences 封装
typescript
import dataPreferences from '@ohos.data.preferences';
const PREFERENCE_NAME = 'anniversary_pref';
const KEY_ANNIVERSARIES = 'anniversaries';
let preferences: dataPreferences.Preferences | null = null;
async function getPreferences(): Promise<dataPreferences.Preferences> {
if (preferences) return preferences;
const context = getContext();
preferences = await dataPreferences.getPreferences(context, PREFERENCE_NAME);
return preferences;
}
要点:
- 单例缓存:Preferences 实例创建是异步的,缓存后避免每次 await
- 全量读写:纪念日列表以 JSON 字符串整体存取,几百条场景下比逐条操作更简单
- 异常兜底:每个 public 方法用 try-catch 包裹,读失败返回空数组,写失败打印日志
5.2 为什么不用数据库?
数据量小、无复杂查询、无事务需求------Preferences 是最轻量方案。如果要做"共享日历"或"多端同步",再考虑换 RDB 甚至上云。
六、UI 层实现
6.1 主页:卡片列表
Column
├── Row (顶部标题栏)
├── List (纪念日列表) / Column (空状态)
│ └── ListItem
│ └── Column (卡片)
│ ├── Row (类型图标 + 名称 + 标签 + 倒计时数字)
│ └── Row (状态文字 + 年龄 + 重复标识)
└── Button (底部添加按钮)
视觉层次:
- 左侧:类型 emoji(🎂🎉💝),圆形底色区分
- 中间:名称(加粗)+ 日期 + 年份
- 右侧:大号倒计时数字,颜色随状态变化
- 底部:状态文字 + 年龄 + 重复标识
颜色编码:
| 状态 | 颜色 |
|---|---|
| 今天 | 红色 |
| 7 天内 | 橙色 |
| 还有多天 | 粉色 #FF6B81 |
| 已过 | 灰色 |
6.2 空状态设计
首次打开不应显示空白。友好引导:
typescript
if (this.isEmpty) {
Column() {
Text('📅').fontSize(64).opacity(0.4)
Text('还没有添加纪念日')
Text('点击下方按钮添加生日、节日或纪念日')
}
}
6.3 排序逻辑
已过天的 → 按已过天数从少到多
将来临的 → 按剩余天数从少到多
即"即将到的排最前,刚过的紧跟其后,很久前过的排最后"------用户最关心的信息始终在最前方。
6.4 添加页:表单实现
表单包含:类型选择(三段式)、名称输入、日期输入(MM-DD)、年份输入(可选)、每年重复开关、备注输入。
校验逻辑:
- 名称非空校验
- 日期格式校验(正则
^\d{2}-\d{2}$+ 月/日范围校验) - 年份范围校验(1900-2100)
错误信息直接显示在输入框下方(内联校验),体验优于 Toast。
6.5 左滑删除
typescript
ListItem() { this.itemCard(item) }
.swipeAction({ end: this.deleteButton(item) })
deleteButton 是一个 @Builder 方法------swipeAction 的 end 属性接收 CustomBuilder,必须通过 @Builder 包装。
七、ArkTS 语法约束与踩坑实录
这是最耗时的部分。ArkTS 是 TS 超集但加了大量编译期限制。以下是实际遇到的"坑"。
7.1 禁止解构赋值
typescript
// ❌ 错误
const [month, day] = date.split('-').map(Number);
// ✅ 正确
const parts = date.split('-');
const month = parseInt(parts[0]);
const day = parseInt(parts[1]);
ArkTS 出于性能考虑禁止运行时解构。数组、对象的解构赋值都不行。
7.2 禁止任意类型 throw
typescript
// ❌ 错误
catch (err) { throw err; }
// ✅ 正确
catch (err) { throw new Error('failed'); }
只允许 throw Error 类型或其子类。
7.3 对象字面量需显式类型
typescript
// ❌ 错误
private typeOptions = [{ value: 'a', label: 'A' }];
// ✅ 正确
class TypeOption { value: string = ''; label: string = ''; }
private typeOptions: TypeOption[] = [...];
ArkTS 要求对象字面量匹配显式声明的 class 或 interface。
7.4 @Builder 中不能有非 UI 语句
typescript
// ❌ 错误:@Builder 里不能声明变量
@Builder itemCard() {
let x = compute(); // Only UI component syntax allowed
Text(x)
}
// ✅ 正确:计算逻辑提取到普通方法
getDateDisplay(item: Anniversary): string {
return item.year ? `${item.date} (${item.year}年)` : item.date;
}
@Builder itemCard() {
Text(this.getDateDisplay(item))
}
7.5 async 异常处理
调用可能抛异常的异步方法时必须有 try-catch:
typescript
.onClick(() => {
this.onSave(); // onSave 内部有 try-catch
})
7.6 泛型数组需显式类型
typescript
// ❌ 错误:无法推断元素类型
const items = [];
// ✅ 正确
const items: AnniversaryItem[] = [];
7.7 Deprecated API 警告
构建中出现的 deprecated 警告:getContext()、promptAction.showToast()、router.pushUrl()。不影响运行,但上架前建议跟进 SDK 更新。
八、API 24 特性利用
8.1 声明式 UI 性能
API 24+ 的 ArkUI 对 @State 装饰器的 diff 更新做了深度优化。anniversaryList 重新赋值时只更新变化的节点,非全量重绘。
8.2 Preferences 异步模型
全面采用 Promise 异步模型,要求 async/await 贯穿数据流。优点是无回调地狱,缺点是对异常处理要求更高。
8.3 SwipeAction API 稳定
ListItem.swipeAction 在 API 24 中已稳定。早期版本该 API 仍是实验性,24 版本中手势响应、动画曲线、Builder 生命周期管理都较成熟。
8.4 安全检查增强
对 BusinessError 的处理更加严格,IDE 会提示捕获特定类型错误,降低崩溃率。
九、实战经验总结
9.1 开发效率
从零到构建通过,编码约 3-4 小时,修复编译器错误约 1 小时。ArkTS 编译期检查双刃剑:好处是大量低级错误在编译阶段捕获;坏处是编译 3-5 秒,不如 JS 的即时反馈流畅。
9.2 推荐开发流程
- 先写 model + util:纯逻辑层,不依赖 UI,可独立测试
- 再写页面骨架:搭组件树,用静态数据验证布局
- 串联数据流:静态数据替换为动态数据
- 细化交互:动画、侧滑、校验
- 编译通过后统一调样式
9.3 值得改进的方向
- Widget 卡片:桌面直接显示最近纪念日
- 通知提醒:前一天/当天发送系统通知
- 农历支持:春节、中秋等农历节日
- 云同步:跨设备数据同步
- 批量导入:从通讯录导入生日
十、完整代码解析
10.1 Anniversary.ets(数据模型)
约 100 行。定义了 AnniversaryType 枚举、Anniversary 接口、倒计时/年龄计算函数、ID 生成器。三个工具函数职责单一,便于测试。
10.2 PreferencesUtil.ets(数据持久化)
约 75 行。四个公开方法覆盖 CRUD:add、delete、load、save。内部缓存实例避免重复创建。所有方法均为 async。
10.3 Index.ets(主页面)
约 290 行。最复杂的文件:列表渲染、空状态、排序、侧滑删除、卡片组件。@Builder 的使用是关键技巧。
10.4 AddAnniversary.ets(添加页面)
约 284 行。采用"前置校验 + 内联错误提示"模式------保存时统一校验,错误显示在对应输入框下方。
十一、构建与部署
bash
hvigorw assembleHap --no-daemon
生成:entry/build/default/outputs/default/entry-default-unsigned.hap
如需真机运行,配置 signingConfigs 或使用 DevEco Studio 自动签名。
常见问题:
| 问题 | 方案 |
|---|---|
| 首次编译过慢 | 增量构建只需 2-3 秒 |
| app_name 冲突 | AppScope 和 entry 模块勿重复声明 |
| 签名缺失 | 配置 signingConfigs |
十二、写在最后
花几个小时完成一个纪念日提醒 App,既是一次技术实践,也是对 ArkTS 开发范式的深度体验。
ArkTS 的感受:它不像 JavaScript 那样随意,也不像 Java 那样沉重。编译期的严格约束最初让人沮丧------"写了十年的 JS 写法到你这儿就不行了?"------适应后会发现类型系统和编译检查确实减少了运行时错误。
如果你正在学习鸿蒙开发,建议从这样一个"小而完整"的项目开始。它涉及数据模型、持久化、路由、列表渲染、表单处理、手势交互------覆盖日常开发 80% 场景。复杂度足够低,让你遇到编译错误时能快速定位。
最后,这个 App 帮我记住了三位好友的生日、两个纪念日和一个"发薪日"------大概是程序员最实用的自我关怀了。😄
本文配套源码位于 D:\hongmeng\app6105,可直接用 DevEco Studio 打开构建。


