从 React 到 ArkTS:手把手教你实现鸿蒙习惯打卡 App
如果你想做一个每日习惯打卡App,需要用到Preferences和通知提醒
想体验一个完整的习惯打卡应用吗?打开鸿蒙应用市场,搜索「日课集」下载体验,看看打卡、挑战转盘、完成量统计这些功能实际用起来是什么感觉。体验完了再回来,咱们一起拆解它是怎么做出来的。
写在前面
做习惯打卡这个方向的应用,说实话不算什么新鲜事。但你要真正动手做一遍,就会发现里面有不少细节值得琢磨。比如打卡数据怎么存?连续打卡天数怎么算?每天到点了怎么提醒用户?这些问题看似简单,但每个都涉及到数据结构设计和系统API的调用。
这篇文章我们就以「日课集」这个App为例,从Web端的React实现讲起,然后一步步搬到鸿蒙的ArkTS上。我不会跳过任何步骤,每一段代码我都会跟你聊聊为什么要这么写。
这篇文章聊什么
这篇文章主要聊三个核心问题:
- 打卡数据的时间序列存储 – 怎么把每天的打卡记录存下来,而且能方便地查询历史
- 连续天数算法 – 怎么判断用户是不是连续打卡了,中间断了一天怎么处理
- 通知提醒 – 怎么用鸿蒙的通知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就是一个可复用的卡片组件。
流程图
下面是打卡功能的完整流程:
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:
- Preferences – 用来持久化存储打卡数据,记住
put之后一定要flush - notificationManager – 用来发送打卡提醒通知,配合WantAgent实现点击跳转
如果你对完整的「日课集」App感兴趣,可以去鸿蒙应用市场搜索下载,实际体验一下打卡、挑战转盘、完成量统计这些功能。代码的细节和完整实现,对照着App用起来会更有感觉。
下一篇文章我们聊抽奖转盘App,看看怎么用@ohos.animator做旋转动画。
更多推荐


所有评论(0)