鸿蒙Next实战:从零构建每日打卡应用

鸿蒙Next实战:从零构建每日打卡应用(API 24)

一款支持自定义打卡项、连续天数统计和历史记录查询的纯鸿蒙原生应用

一、前言

1.1 为什么选择打卡应用

打卡应用的业务逻辑清晰------用户每天为某个目标做一次标记,系统自动统计连续天数------非常适合作为鸿蒙开发入门实战项目。它覆盖了移动开发中绝大多数基础能力:数据持久化、UI 列表渲染、组件通信、状态管理、手势交互。

如果你正在学习鸿蒙开发,希望找到一个「麻雀虽小五脏俱全」的项目,这篇文章就是为你准备的。

1.2 功能预览

  • ✅ 创建自定义打卡项:选择 Emoji、输入名称、添加备注
  • ✅ 每日打卡 / 取消打卡:一键标记今日完成
  • ✅ 连续天数统计:自动计算连续打卡天数
  • ✅ 总打卡天数:累计所有打卡记录
  • ✅ 本周指示器:近 7 天打卡状态一目了然
  • ✅ 月度日历:按月份查看打卡分布,蓝色高亮标记日
  • ✅ 历史记录:按时间倒序展示全部打卡流水
  • ✅ 编辑与删除:左滑手势删除

应用基于 HarmonyOS Next API 24(SDK 6.1.0),采用 Stage 模型和 ArkTS 语言开发,数据使用 Preferences 键值库持久化。


二、项目初始化

2.1 开发环境要求

工具 版本要求
DevEco Studio 5.0.3.600+
HarmonyOS SDK API 24
Node.js 18.x / 20.x LTS

SDK 版本配置在 build-profile.json5 中:

json5 复制代码
{
  "app": {
    "products": [{
      "name": "default",
      "targetSdkVersion": "6.1.0(24)",
      "compatibleSdkVersion": "6.1.0(24)",
      "runtimeOS": "HarmonyOS"
    }]
  }
}

2.2 创建项目

在 DevEco Studio 中:File → New → Create Project → Empty Ability,Compile SDK 选择 API 24,Model 选择 Stage(ArkTS)。

2.3 依赖管理

本应用只需引用两个 Kit,无需额外安装依赖:

typescript 复制代码
import { preferences } from '@kit.ArkData';    // 数据持久化
import { router } from '@kit.ArkUI';            // 页面路由

HarmonyOS Next 引入了 Kit 化导入机制,推荐使用 @kit.ArkData 而非旧的 @ohos.data.preferences


三、数据模型设计

3.1 接口定义

typescript 复制代码
// 打卡项
export interface CheckInItem {
  id: string;
  name: string;
  emoji: string;
  description?: string;
  createdAt: number;
  sortOrder: number;
}

// 打卡记录
export interface CheckInRecord {
  itemId: string;
  date: string;      // YYYY-MM-DD
}

// 统计数据(实时计算)
export interface CheckInStats {
  consecutiveDays: number;
  totalDays: number;
  todayChecked: boolean;
  weekData: boolean[]; // 近7天
}

日期用 YYYY-MM-DD 字符串而非 Date 对象存储,便于 JSON 序列化、排序和比较。weekData 固定 7 个元素,索引 0 对应 7 天前,索引 6 对应今天。

3.2 ID 生成策略

typescript 复制代码
export function generateId(): string {
  return Date.now().toString(36) + Math.random().toString(36).substring(2, 8);
}

时间戳转 36 进制 + 6 位随机字符,保证唯一性且长度适中。


四、数据持久化

4.1 Preferences 简介

HarmonyOS 的 Preferences 是一个异步 Key-Value 数据库,适合存储小型结构化数据。打卡应用数据量小、结构简单,Preferences 是最合适的选择。

4.2 封装工具函数

typescript 复制代码
const PREFERENCES_NAME = 'daily_checkin_prefs';
let preferencesInstance: preferences.Preferences | null = null;

async function getPreferences(context: Context): Promise<preferences.Preferences> {
  if (preferencesInstance != null) return preferencesInstance;
  preferencesInstance = await preferences.getPreferences(context, PREFERENCES_NAME);
  return preferencesInstance;
}

async function saveData(context: Context, key: string, data: string): Promise<void> {
  const prefs = await getPreferences(context);
  await prefs.put(key, data);
  await prefs.flush();
}

async function getData(context: Context, key: string): Promise<string> {
  const prefs = await getPreferences(context);
  const val = await prefs.get(key, '[]');
  return val as string;
}

关键点:

  • 单例模式 :缓存 preferencesInstance 避免重复打开文件
  • Context 参数 :通过 getContext(this) 获取
  • 类型断言prefs.get() 返回 ValueType,需用 as string 断言

4.3 CRUD 实现

typescript 复制代码
export async function getAllItems(context: Context): Promise<CheckInItem[]> {
  const json = await getData(context, 'checkin_items');
  try { return JSON.parse(json) as CheckInItem[]; }
  catch { return []; }
}

export async function addItem(context: Context, item: CheckInItem): Promise<void> {
  const items = await getAllItems(context);
  items.push(item);
  await saveData(context, 'checkin_items', JSON.stringify(items));
}

export async function updateItem(context: Context, updated: CheckInItem): Promise<void> {
  const items = await getAllItems(context);
  const idx = items.findIndex(i => i.id === updated.id);
  if (idx >= 0) items[idx] = updated;
  await saveData(context, 'checkin_items', JSON.stringify(items));
}

export async function deleteItem(context: Context, itemId: string): Promise<void> {
  const items = await getAllItems(context);
  await saveData(context, 'checkin_items', JSON.stringify(items.filter(i => i.id !== itemId)));
  const records = await getAllRecords(context);
  await saveData(context, 'checkin_records', JSON.stringify(records.filter(r => r.itemId !== itemId)));
}

打卡/取消打卡是同一个操作------切换今天的状态:

typescript 复制代码
export async function toggleCheckIn(context: Context, itemId: string): Promise<boolean> {
  const today = getTodayDate();
  const records = await getAllRecords(context);
  const idx = records.findIndex(r => r.itemId === itemId && r.date === today);
  if (idx >= 0) {
    records.splice(idx, 1);
    await saveData(context, 'checkin_records', JSON.stringify(records));
    return false; // 取消打卡
  } else {
    records.push({ itemId, date: today });
    await saveData(context, 'checkin_records', JSON.stringify(records));
    return true;  // 打卡成功
  }
}

五、连续天数计算算法

5.1 算法思路

连续打卡天数(Streak)从今天(或昨天)开始向前遍历,直到遇到没有打卡的日期为止。

如果今天已打卡,从今天开始逆推;如果今天未打卡,从昨天开始逆推(因为用户可能还没来得及打卡)。

复制代码
示例:
日期:   3/1  3/2  3/3  3/4  3/5  3/6  3/7
打卡:   ✅   ✅   ❌   ✅   ✅   ✅   ✅
                         ↑ 从3/7逆推 → 连续4天

5.2 代码实现

typescript 复制代码
export async function getItemStats(context: Context, itemId: string): Promise<CheckInStats> {
  const records = await getAllRecords(context);
  const dates = records.filter(r => r.itemId === itemId).map(r => r.date).sort();
  const today = getTodayDate();
  const todayChecked = dates.indexOf(today) >= 0;

  let consecutive = 0;
  const startDate = new Date();
  if (!todayChecked) startDate.setDate(startDate.getDate() - 1);

  while (true) {
    const ds = getDateString(startDate);
    if (dates.indexOf(ds) >= 0) {
      consecutive++;
      startDate.setDate(startDate.getDate() - 1);
    } else break;
  }

  // 近7天数据
  const weekData: boolean[] = [];
  const weekDate = new Date();
  for (let i = 6; i >= 0; i--) {
    const d = new Date(weekDate);
    d.setDate(d.getDate() - i);
    weekData.push(dates.indexOf(getDateString(d)) >= 0);
  }

  return { consecutiveDays: consecutive, totalDays: dates.length, todayChecked, weekData };
}

getDateString 基于 Date 对象计算,自动处理月份和年份的边界,无需额外判断。


六、页面开发

6.1 @Builder 的关键限制

ArkTS 编译器对 @Builder 有严格限制:方法体内不允许声明局部变量

typescript 复制代码
// ❌ 编译错误
@Builder
MyCard(item: ItemType) {
  let title = item.title;
  Text(title)
}

解决方案:在组件方法中预计算数据,通过参数传递给 @Builder

定义承载数据的接口:

typescript 复制代码
interface CardData {
  item: CheckInItem;
  stats: CheckInStats;
}

loadData() 中构建列表:

typescript 复制代码
const list: CardData[] = [];
for (const item of this.items) {
  const stats = await getItemStats(ctx, item.id);
  list.push({ item, stats });
}
this.cardDataList = list;

@Builder 中直接引用 card 属性:

typescript 复制代码
@Builder
CheckInCard(card: CardData) {
  Stack() {
    Row() {
      Text(card.item.emoji)
      Text(card.item.name)
      Text(String(card.stats.consecutiveDays))
    }
  }
}

6.2 主页打卡列表

主页结构:标题栏 → 打卡项列表 → 底部提示。使用 List + ForEach 渲染:

typescript 复制代码
List({ space: 12 }) {
  ForEach(this.cardDataList, (card: CardData) => {
    ListItem() { this.CheckInCard(card) }
  }, (card: CardData) => card.item.id)
}

每个卡片支持:

  • 点击 → 跳转历史详情页
  • 左滑 → 弹出删除确认(PanGesture

6.3 添加打卡项页

三个输入区:

  1. Emoji 选择器:24 个常用 Emoji,点击展开网格
  2. 名称输入TextInput
  3. 备注输入TextArea,可选

保存时校验名称非空,调用 addItem 写入存储。

6.4 历史详情页

三块内容:

统计卡片:Emoji + 名称 + 连续天数(橙色大号数字)+ 总天数(蓝色大号数字)。

月度日历 :最复杂的 UI 组件。预计算 DayInfo[]

typescript 复制代码
interface DayInfo {
  dayNum: number;    // 0=占位
  checked: boolean;
  key: string;
}

Grid 网格布局,7 列等宽。已打卡日期蓝色圆形高亮。每个月用 按钮切换。

历史记录列表:时间倒序,最多 90 条,每条显示 ✅ + 日期 + 星期。


七、页面路由与传参

7.1 路由配置

json 复制代码
{
  "src": ["pages/Index", "pages/AddItem", "pages/History"]
}

7.2 跳转与传参

typescript 复制代码
// 跳转
router.pushUrl({
  url: 'pages/History',
  params: { itemId: item.id, itemName: item.name, itemEmoji: item.emoji }
});

// 接收
aboutToAppear(): void {
  const params = router.getParams() as Record<string, Object>;
  this.itemId = params['itemId'] as string || '';
  this.itemName = params['itemName'] as string || '';
}

八、ArkTS 编译避坑

8.1 import 必须在文件顶部

typescript 复制代码
// ❌ 错误
export interface MyData {}
import { something } from '@kit.SomeKit';

// ✅ 正确
import { something } from '@kit.SomeKit';
export interface MyData {}

8.2 禁止 throw 任意类型

typescript 复制代码
// ❌ 错误
throw 'error message';
// ✅ 正确
throw new Error('error message');

8.3 禁止 any / unknown

typescript 复制代码
// ❌ 错误
let data: any = getData();
// ✅ 正确
let data = getData() as string;

8.4 Loading 组件不可用

API 24 中 <Loading> 已被移除,改用 LoadingProgress

typescript 复制代码
LoadingProgress().width(40).height(40)

8.5 for...of 兼容性

某些版本不支持 for...of,推荐使用传统 for 循环:

typescript 复制代码
for (let i = 0; i < items.length; i++) {
  const item = items[i];
}

九、构建与运行

9.1 构建命令

bash 复制代码
hvigorw assembleHap --mode module -p product=default --no-daemon

9.2 常见错误

app_name 冲突AppScopeentry 中重复定义,删除 entry 中的定义即可。

未配置签名:调试阶段用 DevEco Studio 自动签名。

9.3 完整项目结构

复制代码
entry/src/main/ets/
├── model/
│   └── CheckInModel.ets       # 数据模型 + 持久化
├── pages/
│   ├── Index.ets              # 主页
│   ├── AddItem.ets            # 添加页
│   └── History.ets            # 历史页
└── entryability/
    └── EntryAbility.ets       # 入口

十、总结

10.1 功能回顾

本文基于 HarmonyOS Next API 24 构建了完整的每日打卡应用:Preferences 持久化存储、打卡/取消打卡、连续天数计算、周指示器、月度日历、历史记录。

10.2 扩展方向

  • 数据备份与恢复(JSON 导入导出)
  • 分布式多端同步
  • 每日提醒通知
  • 桌面 Widget 小组件
  • 深色模式

10.3 开发体会

ArkTS + ArkUI 的声明式 UI 语法与 SwiftUI、Jetpack Compose 类似,上手快。ArkTS 对 JS 灵活语法的限制(禁止 any、限制 @Builder)在适应后能有效避免运行时错误。Kit 化生态使模块划分更清晰。

理论与代码结合是学习鸿蒙开发的最佳路径。当你亲手解决一个个编译错误、看到应用在模拟器上成功运行时,对 ArkTS 和 ArkUI 的理解会更深。


相关推荐
yuegu7771 小时前
HarmonyOS应用<节气通>开发第20篇:ArticleCard组件封装
华为·harmonyos
金启攻1 小时前
鸿蒙原生应用实战(二):首页开发 —— 周历导航与@Builder组件化实践
华为·harmonyos
hahjee2 小时前
【鸿蒙PC】kcp 移植:AtomCode Skills 4 步速通单文件 C 库适配
c语言·华为·harmonyos
风满城332 小时前
鸿蒙原生应用实战(四):歌单管理 —— 创建歌单与歌曲编排
华为·harmonyos
木咺吟3 小时前
鸿蒙原生应用开发实战(三):电影列表与搜索筛选 — 电影清单App
harmonyos
金启攻3 小时前
鸿蒙原生应用实战(一):项目初始化与Stage模型架构设计
华为·harmonyos
seal_jing3 小时前
44岁被裁后用AI写鸿蒙App(5):一个页面的App,真的能搞定一切吗
harmonyos
坚果派·白晓明3 小时前
鸿蒙PC】libuv适配:AtomCode Skills一站式指南
c语言·c++·华为·ai编程·harmonyos·atomcode
FrameNotWork3 小时前
HarmonyOS 6.1 Canvas粒子效果系统从零实现
华为·harmonyos