如果你想做一个每日习惯打卡App,需要用到Preferences和通知提醒

想体验一个完整的习惯打卡应用吗?打开鸿蒙应用市场,搜索「日课集」下载体验,看看打卡、挑战转盘、完成量统计这些功能实际用起来是什么感觉。体验完了再回来,咱们一起拆解它是怎么做出来的。


写在前面

做习惯打卡这个方向的应用,说实话不算什么新鲜事。但你要真正动手做一遍,就会发现里面有不少细节值得琢磨。比如打卡数据怎么存?连续打卡天数怎么算?每天到点了怎么提醒用户?这些问题看似简单,但每个都涉及到数据结构设计和系统API的调用。

这篇文章我们就以「日课集」这个App为例,从Web端的React实现讲起,然后一步步搬到鸿蒙的ArkTS上。我不会跳过任何步骤,每一段代码我都会跟你聊聊为什么要这么写。

这篇文章聊什么

这篇文章主要聊三个核心问题:

  1. 打卡数据的时间序列存储 – 怎么把每天的打卡记录存下来,而且能方便地查询历史
  2. 连续天数算法 – 怎么判断用户是不是连续打卡了,中间断了一天怎么处理
  3. 通知提醒 – 怎么用鸿蒙的通知API在特定时间提醒用户打卡

我们会先看React怎么写,再看ArkTS怎么写,最后做一个对比。

第一步:设计打卡数据结构

在写任何代码之前,我们得先把数据结构想清楚。打卡数据本质上是一个时间序列 – 某一天打了卡,某一天没打。最直观的存储方式就是用一个对象,key是日期,value是打卡详情。

React版本

// 定义一个习惯的数据结构
const habitSchema = {
  id: 'string',           // 习惯的唯一标识
  name: 'string',         // 习惯名称,比如"早起"
  icon: 'string',         // 图标标识
  createdAt: 'string',    // 创建日期
  checkIns: {             // 打卡记录,key是日期字符串
    '2026-06-20': {
      completed: true,    // 今天是否完成
      note: 'string',     // 可选的备注
      timestamp: number   // 打卡时间戳
    }
  }
};

你看,这个结构其实很直观。checkIns是一个对象,日期作为key,打卡详情作为value。这样做的好处是查询某一天是否打卡非常快,直接checkIns['2026-06-20']就行。

ArkTS版本

在鸿蒙端,我们用interface来定义数据结构,这样类型检查更严格:

// 先定义打卡记录的接口
interface CheckInRecord {
  completed: boolean;     // 是否完成打卡
  note: string;            // 备注
  timestamp: number;       // 打卡时间戳
}

// 再定义习惯的接口
interface HabitItem {
  id: string;              // 唯一标识
  name: string;            // 习惯名称
  icon: string;            // 图标
  createdAt: string;       // 创建日期
  checkIns: Record<string, CheckInRecord>;  // 打卡记录,日期字符串映射到记录
}

这里用Record<string, CheckInRecord>来表示打卡记录的映射关系,和React版本里的对象是一个意思,只是ArkTS有类型约束,写起来更安全。Record<string, CheckInRecord>的意思是:key是string类型(日期字符串),value是CheckInRecord类型。

你可能会问,为什么不直接用Map?因为Preferences API存储数据的时候,对象比Map更容易序列化和反序列化,后面存取数据会更方便。

第二步:用Preferences存储打卡数据

数据结构设计好了,接下来就是存储。在React里,我们一般用localStorage;在鸿蒙里,对应的方案就是Preferences。

React版本 – 用localStorage

// 保存所有习惯数据到localStorage
function saveHabits(habits) {
  localStorage.setItem('habits_data', JSON.stringify(habits));
}

// 从localStorage读取习惯数据
function loadHabits() {
  const data = localStorage.getItem('habits_data');
  return data ? JSON.parse(data) : [];
}

// 打卡操作
function checkIn(habitId, date) {
  const habits = loadHabits();
  const habit = habits.find(h => h.id === habitId);
  if (habit) {
    habit.checkIns[date] = {
      completed: true,
      note: '',
      timestamp: Date.now()
    };
    saveHabits(habits);
  }
}

这段代码你应该很熟悉。localStorage.setItem存一个JSON字符串,localStorage.getItem取出来再parse。打卡的时候,先找到对应的习惯,然后在checkIns里加一条记录。

ArkTS版本 – 用Preferences

鸿蒙这边就有点不一样了。Preferences不是同步的API,它全是异步的,所以你得用async/await。

import { preferences } from '@kit.ArkData';

// 先声明一个全局的preferences实例
let preferencesStore: preferences.Preferences | null = null;

// 初始化Preferences,这个要在应用启动时调用
async function initPreferences(context: Context) {
  try {
    // getPreferences的第一个参数是context,第二个是preference的名字
    // 'habit_store'就是我们的数据库名,你可以随便起
    preferencesStore = await preferences.getPreferences(context, 'habit_store');
    console.info('Preferences初始化成功');
  } catch (err) {
    console.error('Preferences初始化失败: ' + JSON.stringify(err));
  }
}

这里有几个点要聊一下。第一,preferences.getPreferences是异步的,返回一个Promise,所以我们用await等待它完成。第二,第一个参数context是应用的上下文,在EntryAbility里可以通过this.context获取。第三,'habit_store'是preference的名字,相当于数据库的表名,你可以在一个应用里创建多个Preferences实例,每个有不同的名字。

接下来是存取数据的代码:

// 保存习惯列表
async function saveHabits(habits: HabitItem[]) {
  if (!preferencesStore) {
    console.error('Preferences未初始化');
    return;
  }
  try {
    // 把习惯数组转成JSON字符串存进去
    await preferencesStore.put('habits_list', JSON.stringify(habits));
    // flush()是必须调用的,它会把内存中的数据写入持久化存储
    // 如果不调flush,数据只存在内存里,应用一关就没了
    await preferencesStore.flush();
    console.info('习惯数据保存成功');
  } catch (err) {
    console.error('保存习惯数据失败: ' + JSON.stringify(err));
  }
}

注意这个flush(),它非常重要。put只是把数据放到了内存缓存里,flush才是真正写入磁盘。如果你忘了调flush,应用正常退出还好,系统会帮你保存;但如果应用崩溃了,没flush的数据就丢了。所以养成习惯,put之后紧跟flush。

// 读取习惯列表
async function loadHabits(): Promise<HabitItem[]> {
  if (!preferencesStore) {
    console.error('Preferences未初始化');
    return [];
  }
  try {
    // get的第二个参数是默认值,如果key不存在就返回这个默认值
    const data = await preferencesStore.get('habits_list', '[]');
    // data拿出来是Any类型,需要转成string再parse
    return JSON.parse(data as string) as HabitItem[];
  } catch (err) {
    console.error('读取习惯数据失败: ' + JSON.stringify(err));
    return [];
  }
}

get方法的第二个参数是默认值。如果'habits_list'这个key从来没被设置过,就会返回'[]',这样parse出来就是一个空数组,不会报错。这个设计很贴心,省得你每次都要判断null。

// 打卡操作
async function checkIn(habitId: string, date: string) {
  const habits = await loadHabits();
  const index = habits.findIndex(h => h.id === habitId);
  if (index >= 0) {
    habits[index].checkIns[date] = {
      completed: true,
      note: '',
      timestamp: Date.now()
    };
    await saveHabits(habits);
  }
}

打卡的逻辑和React版本一模一样:找到对应的习惯,在checkIns里加一条记录,然后保存。唯一的区别就是全都是异步操作,每个函数前面都有async,每个调用前面都有await。

第三步:计算连续打卡天数

连续打卡天数是习惯打卡App里最有意思的功能之一。用户看到"你已连续打卡15天",会有很强的成就感。那这个数字怎么算呢?

React版本

// 计算某个习惯的连续打卡天数
function calculateStreak(habit) {
  const checkIns = habit.checkIns;
  let streak = 0;
  // 从今天开始往前数
  let currentDate = new Date();

  while (true) {
    const dateStr = formatDate(currentDate); // 格式化为 'YYYY-MM-DD'
    if (checkIns[dateStr] && checkIns[dateStr].completed) {
      streak++;
      // 往前一天
      currentDate.setDate(currentDate.getDate() - 1);
    } else {
      // 如果今天还没打卡,但昨天打了,也不算断
      // 只有当streak为0时遇到未打卡才break
      if (streak === 0) {
        currentDate.setDate(currentDate.getDate() - 1);
        continue;
      }
      break;
    }
  }
  return streak;
}

// 日期格式化工具函数
function formatDate(date) {
  const year = date.getFullYear();
  const month = String(date.getMonth() + 1).padStart(2, '0');
  const day = String(date.getDate()).padStart(2, '0');
  return `${year}-${month}-${day}`;
}

这个算法的思路是:从今天开始,一天一天往前看。如果某天打了卡,streak加1,继续往前;如果某天没打卡,就看当前streak是不是0 – 如果是0,说明今天还没打卡但之前可能有连续记录,所以跳过今天继续往前看;如果不是0,说明连续记录断了,就停止。

ArkTS版本

// 日期格式化工具函数
function formatDate(date: Date): string {
  const year: number = date.getFullYear();
  // getMonth()返回0-11,所以要加1
  const month: string = String(date.getMonth() + 1).padStart(2, '0');
  const day: string = String(date.getDate()).padStart(2, '0');
  return `${year}-${month}-${day}`;
}

// 计算连续打卡天数
function calculateStreak(habit: HabitItem): number {
  const checkIns: Record<string, CheckInRecord> = habit.checkIns;
  let streak: number = 0;
  let currentDate: Date = new Date();

  while (true) {
    const dateStr: string = formatDate(currentDate);
    const record: CheckInRecord | undefined = checkIns[dateStr];

    if (record !== undefined && record.completed) {
      streak++;
      // setDate会修改原对象,getDate - 1就是前一天
      currentDate.setDate(currentDate.getDate() - 1);
    } else {
      if (streak === 0) {
        // 今天还没打卡,跳过今天继续往前看
        currentDate.setDate(currentDate.getDate() - 1);
        continue;
      }
      break;
    }
  }
  return streak;
}

ArkTS版本和React版本逻辑完全一样,只是加了类型注解。checkIns[dateStr]在ArkTS里返回的类型是CheckInRecord | undefined,因为有可能这个日期没有打卡记录。所以我们用record !== undefined来判断,而不是像JS那样直接用truthy/falsy判断。

这里有个小细节:padStart(2, '0')在ArkTS里也是可以用的,因为string类型上有这个方法。月份和日期都补零是为了保证日期字符串格式统一,比如'2026-01-05'而不是'2026-1-5',这样作为key来查询才不会出问题。

第四步:发送打卡提醒通知

每天固定时间提醒用户打卡,这个功能对习惯养成类App来说几乎是标配。鸿蒙提供了notificationManager来发送通知。

React版本

React端没有原生的通知API(在Web环境里),我们一般用Notification构造函数,但它只能在浏览器里用,而且用户必须先允许通知权限。这里就不写React版本了,直接看鸿蒙的实现。

ArkTS版本

import { notificationManager } from '@kit.NotificationKit';
import { wantAgent, WantAgent } from '@kit.AbilityKit';

// 发送打卡提醒通知
async function sendCheckInReminder(title: string, content: string) {
  try {
    // 先创建WantAgent,用户点击通知后可以跳转到App
    // WantAgent本质上是一个"意图代理",它描述了点击通知后要做什么
    const wantAgentInfo: wantAgent.WantAgentInfo = {
      wants: [{
        bundleName: 'com.example.rikeji',   // 你的应用包名
        abilityName: 'EntryAbility'           // 要跳转到的Ability
      }],
      actionType: wantAgent.ActionType.START_ABILITY,
      requestCode: 0,
      wantAgentFlags: [wantAgent.WantAgentFlags.UPDATE_PRESENT_FLAG]
    };

    // 创建WantAgent实例
    const wantAgentObj: WantAgent = await wantAgent.getWantAgent(wantAgentInfo);

    // 构建通知内容
    const notificationRequest: notificationManager.NotificationRequest = {
      id: Math.floor(Math.random() * 10000),  // 随机ID,避免覆盖
      content: {
        contentType: notificationManager.ContentType.NOTIFICATION_CONTENT_BASIC_TEXT,
        normal: {
          title: title,       // 通知标题,比如"该打卡啦"
          text: content        // 通知内容,比如"今天的早起打卡还没完成哦"
        }
      },
      wantAgent: wantAgentObj   // 点击通知后的行为
    };

    // 发布通知
    await notificationManager.publish(notificationRequest);
    console.info('打卡提醒通知发送成功');
  } catch (err) {
    console.error('发送通知失败: ' + JSON.stringify(err));
  }
}

这段代码有点长,我们拆开来一段一段聊。

首先是WantAgent。你可能会问,发个通知为什么要搞这么复杂?因为鸿蒙的通知不只是显示一行文字,它还可以和App交互。用户点击通知后,系统需要知道该打开哪个App的哪个页面,WantAgent就是用来描述这个行为的。bundleName是你的应用包名,在module.json5里配置的那个;abilityName是要启动的Ability名字。

然后是通知内容。NOTIFICATION_CONTENT_BASIC_TEXT是最简单的通知类型,就一个标题加一段文字。如果你需要更丰富的样式,比如带图片、带进度条,可以用其他ContentType。

最后是notificationManager.publish,这个方法把通知发出去。id是通知的唯一标识,如果两条通知id相同,后发的会覆盖先发的。所以我们用随机数来避免冲突。

第五步:完整的UI界面

数据存储和业务逻辑都搞定了,接下来就是UI。我们用鸿蒙的声明式UI来写打卡界面。

ArkTS UI代码

import { preferences } from '@kit.ArkData';

@Entry
@Component
struct HabitCheckInPage {
  // 习惯列表的状态变量
  @State habits: HabitItem[] = [];
  @State isLoading: boolean = true;
  // 当前日期
  private currentDate: string = '';

  async aboutToAppear() {
    // 获取今天的日期字符串
    this.currentDate = this.formatDate(new Date());
    // 加载习惯数据
    await this.loadHabits();
    this.isLoading = false;
  }

  // 格式化日期
  formatDate(date: Date): string {
    const year: number = date.getFullYear();
    const month: string = String(date.getMonth() + 1).padStart(2, '0');
    const day: string = String(date.getDate()).padStart(2, '0');
    return `${year}-${month}-${day}`;
  }

  // 加载习惯数据
  async loadHabits() {
    try {
      const store = await preferences.getPreferences(getContext(this), 'habit_store');
      const data = await store.get('habits_list', '[]');
      this.habits = JSON.parse(data as string) as HabitItem[];
    } catch (err) {
      console.error('加载失败: ' + JSON.stringify(err));
    }
  }

  // 打卡
  async onCheckIn(habit: HabitItem) {
    const store = await preferences.getPreferences(getContext(this), 'habit_store');
    const index = this.habits.findIndex(h => h.id === habit.id);
    if (index >= 0) {
      this.habits[index].checkIns[this.currentDate] = {
        completed: true,
        note: '',
        timestamp: Date.now()
      };
      // 更新UI状态
      this.habits = [...this.habits];
      // 持久化
      await store.put('habits_list', JSON.stringify(this.habits));
      await store.flush();
    }
  }

  build() {
    Scroll() {
      Column({ space: 16 }) {
        // 顶部标题区域
        Text('今日日课')
          .fontSize(24)
          .fontWeight(FontWeight.Bold)
          .margin({ top: 20, bottom: 8 })

        Text(this.currentDate)
          .fontSize(14)
          .fontColor('#999999')

        if (this.isLoading) {
          // 加载中状态
          LoadingProgress()
            .width(40)
            .height(40)
        } else if (this.habits.length === 0) {
          // 空状态
          Text('还没有添加习惯,快去添加吧')
            .fontSize(16)
            .fontColor('#999999')
        } else {
          // 习惯卡片列表
          ForEach(this.habits, (habit: HabitItem) => {
            this.HabitCard(habit)
          }, (habit: HabitItem) => habit.id)
        }
      }
      .padding(16)
      .width('100%')
    }
  }

  // 习惯卡片组件
  @Builder
  HabitCard(habit: HabitItem) {
    Row() {
      Column({ space: 4 }) {
        Text(habit.name)
          .fontSize(18)
          .fontWeight(FontWeight.Medium)
        // 显示连续打卡天数
        Text('已连续 ' + this.calculateStreak(habit) + ' 天')
          .fontSize(12)
          .fontColor('#FF6B6B')
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1)

      // 打卡按钮
      Button(habit.checkIns[this.currentDate]?.completed ? '已完成' : '打卡')
        .fontSize(14)
        .type(habit.checkIns[this.currentDate]?.completed
          ? ButtonType.Normal
          : ButtonType.Capsule)
        .backgroundColor(habit.checkIns[this.currentDate]?.completed
          ? '#E0E0E0'
          : '#FF6B6B')
        .fontColor(habit.checkIns[this.currentDate]?.completed
          ? '#999999'
          : '#FFFFFF')
        .enabled(!habit.checkIns[this.currentDate]?.completed)
        .onClick(() => {
          this.onCheckIn(habit);
        })
    }
    .width('100%')
    .padding(16)
    .borderRadius(12)
    .backgroundColor('#FFFFFF')
    .shadow({ radius: 4, color: '#00000010', offsetY: 2 })
  }

  // 计算连续天数
  calculateStreak(habit: HabitItem): number {
    const checkIns: Record<string, CheckInRecord> = habit.checkIns;
    let streak: number = 0;
    let currentDate: Date = new Date();

    while (true) {
      const dateStr: string = this.formatDate(currentDate);
      const record: CheckInRecord | undefined = checkIns[dateStr];
      if (record !== undefined && record.completed) {
        streak++;
        currentDate.setDate(currentDate.getDate() - 1);
      } else {
        if (streak === 0) {
          currentDate.setDate(currentDate.getDate() - 1);
          continue;
        }
        break;
      }
    }
    return streak;
  }
}

这段UI代码有几个地方值得聊聊。

@State装饰器是鸿蒙响应式系统的核心。被@State修饰的变量,一旦值发生变化,UI会自动刷新。所以当我们在onCheckIn里修改了this.habits,卡片上的按钮文字会自动从"打卡"变成"已完成"。

注意this.habits = [...this.habits]这行。在ArkTS里,直接修改数组元素的属性不会触发UI刷新(因为数组引用没变)。所以我们用展开运算符创建一个新数组,让@State检测到引用变化,从而触发重新渲染。

ForEach是鸿蒙的列表渲染组件,类似React的map。第三个参数是key生成函数,用来标识每个列表项的唯一性,帮助系统高效地更新列表。

@Builder装饰器用来定义可复用的UI构建函数,类似React的函数组件。HabitCard就是一个可复用的卡片组件。

流程图

下面是打卡功能的完整流程:

没有

用户打开App

加载Preferences数据

是否有习惯数据?

渲染习惯卡片列表

显示空状态引导

用户点击打卡

更新checkIns数据

调用put写入Preferences

调用flush持久化

UI自动刷新显示已完成

计算连续打卡天数

在卡片上显示连续天数

定时触发通知提醒

创建WantAgent

构建通知内容

publish发布通知

用户点击通知跳转App

React vs ArkTS 对比表

功能点 React (Web) ArkTS (鸿蒙)
数据存储 localStorage (同步) Preferences (异步)
存储写入 localStorage.setItem(key, value) preferencesStore.put(key, value) + flush()
数据读取 localStorage.getItem(key) preferencesStore.get(key, defaultValue)
状态管理 useState @State 装饰器
列表渲染 array.map() ForEach()
组件复用 函数组件 @Builder 装饰器
通知推送 Notification API (浏览器) notificationManager.publish()
通知跳转 window.open() WantAgent
类型系统 可选 (TypeScript) 强制 (ArkTS)
日期处理 Date对象 Date对象 (用法相同)

总结

这篇文章我们从数据结构设计开始,聊了打卡数据的存储、连续天数的计算、以及通知提醒的实现。核心用到的就是两个API:

  1. Preferences – 用来持久化存储打卡数据,记住put之后一定要flush
  2. notificationManager – 用来发送打卡提醒通知,配合WantAgent实现点击跳转

如果你对完整的「日课集」App感兴趣,可以去鸿蒙应用市场搜索下载,实际体验一下打卡、挑战转盘、完成量统计这些功能。代码的细节和完整实现,对照着App用起来会更有感觉。

下一篇文章我们聊抽奖转盘App,看看怎么用@ohos.animator做旋转动画。

更多推荐