从零开始构建鸿蒙纪念日提醒 App:ArkTS + API 24 实战

从零开始构建鸿蒙纪念日提醒 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;
}

要点:

  1. 单例缓存:Preferences 实例创建是异步的,缓存后避免每次 await
  2. 全量读写:纪念日列表以 JSON 字符串整体存取,几百条场景下比逐条操作更简单
  3. 异常兜底:每个 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)、年份输入(可选)、每年重复开关、备注输入。

校验逻辑:

  1. 名称非空校验
  2. 日期格式校验(正则 ^\d{2}-\d{2}$ + 月/日范围校验)
  3. 年份范围校验(1900-2100)

错误信息直接显示在输入框下方(内联校验),体验优于 Toast。

6.5 左滑删除

typescript 复制代码
ListItem() { this.itemCard(item) }
  .swipeAction({ end: this.deleteButton(item) })

deleteButton 是一个 @Builder 方法------swipeActionend 属性接收 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 推荐开发流程

  1. 先写 model + util:纯逻辑层,不依赖 UI,可独立测试
  2. 再写页面骨架:搭组件树,用静态数据验证布局
  3. 串联数据流:静态数据替换为动态数据
  4. 细化交互:动画、侧滑、校验
  5. 编译通过后统一调样式

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 打开构建。

相关推荐
浮芷.2 小时前
鸿蒙HarmonyOS 6.1新特性之沉浸式光感效果实现过程中的各类问题解决-鸿蒙PC版(一)
华为·harmonyos·鸿蒙·鸿蒙系统
轻口味2 小时前
轻规划鸿蒙开发实战7:接管 Ability Kit 跨设备流转,EntryAbility 全链路 onContinue 数据打包与无缝接
华为·harmonyos·鸿蒙
风满城333 小时前
鸿蒙原生应用实战(五):教程、主题与项目总结 — 从开发到上线的完整回顾
harmonyos
nashane3 小时前
HarmonyOS 6学习:深入解析冷启动中的ArkCompiler
学习·华为·harmonyos
风满城333 小时前
鸿蒙原生应用实战(一):项目创建与首页开发 — 从零搭建数独游戏
harmonyos
风满城334 小时前
【鸿蒙原生应用开发实战】第四篇:相册与提醒——AlbumPage + ReminderPage 完整实现
华为·harmonyos
不羁的木木4 小时前
《HarmonyOS 6.1 新能力实战之智感握姿》第三篇:实战案例——单手操作优化
华为·harmonyos
浮芷.5 小时前
HarmonyOS 6.1 沉浸式光感效果-样式切换效果问题解决方案-鸿蒙PC方向
华为·harmonyos·鸿蒙
木咺吟5 小时前
鸿蒙原生应用实战(三):表单交互与搜索筛选——添加包裹、搜索过滤与公司管理
华为·harmonyos