HarmonyOS ArkTS 实战:实现一个习惯打卡应用
HarmonyOS ArkTS 实战:实现一个习惯打卡应用
项目效果
本文实现的是一个基于 HarmonyOS 和 ArkTS 的习惯打卡应用。项目中使用 ArkUI 组件完成页面布局,通过 @State 管理状态数据,实现习惯添加、每日打卡、连续天数统计、习惯删除、完成率计算、今日进度和打卡日历等功能。
最终运行效果如下:
页面主要包含以下内容:
- 顶部应用标题和今日统计;
- 今日打卡进度条;
- 习惯列表;
- 打卡复选框;
- 连续打卡天数显示;
- 添加新习惯输入框;
- 习惯图标选择;
- 删除习惯按钮;
- 空状态提示;
- 页面整体采用 ArkUI 声明式布局。
本文重点是演示如何在 HarmonyOS 项目中使用 ArkTS 和 ArkUI 实现一个习惯养成类单页面应用。项目代码主要写在 entry/src/main/ets/pages/Index.ets 文件中,适合作为 HarmonyOS ArkTS 入门到进阶之间的练习案例。
前言
养成良好的习惯是改变人生的开始。研究表明,坚持21天可以养成一个习惯,而一个好用的习惯打卡工具可以帮助我们记录坚持的过程,激励自己持续进步。市面上的习惯应用往往功能复杂,而我们需要一个简洁专注的工具来追踪日常习惯。
从应用开发角度来看,这个项目不依赖后端接口,也不需要数据库,但能练习 ArkTS 中的状态管理、复选框交互、列表渲染、进度计算、日期处理和数组操作等内容。
本文基于 HarmonyOS 和 ArkTS 实现一个习惯打卡应用。用户可以添加想要养成的习惯,每天打卡记录,查看连续打卡天数,统计今日完成率,管理自己的习惯列表。
这个项目的核心不是简单的勾选框,而是帮助用户坚持习惯的完整流程。每一次打卡、添加和删除习惯,本质上都是对习惯数据的管理。状态变化后,页面会自动刷新,这正是 ArkUI 声明式开发的基本思想。
一、项目目标
本次实践主要实现以下目标:
- 创建 HarmonyOS ArkTS 页面;
- 使用
@Entry和@Component定义页面组件; - 使用
@State管理页面状态; - 实现习惯添加功能;
- 支持选择习惯图标;
- 实现每日打卡功能;
- 统计连续打卡天数;
- 计算今日完成率;
- 显示今日打卡进度;
- 支持删除习惯;
- 使用
List和ForEach渲染习惯列表; - 使用空状态提示优化无习惯页面;
- 使用
@Builder封装统计卡片、习惯项和图标按钮; - 完成一个可以运行的习惯打卡页面。
这个项目虽然是单页面应用,但它有完整的习惯添加、打卡、统计和管理流程,比普通静态页面更适合练习 ArkTS。
二、技术栈
| 类型 | 内容 |
|---|---|
| 开发方向 | HarmonyOS 应用开发 |
| 开发语言 | ArkTS |
| UI 框架 | ArkUI |
| SDK 版本 | HarmonyOS API 23 及以上 |
| 工程模型 | Stage 模型 |
| 核心组件 | Text / TextInput / Button / Checkbox / Column / Row / List / ForEach / Progress |
| 状态管理 | @State |
| 数据处理 | 日期计算 / 进度统计 / 数组操作 |
| 项目入口 | entry/src/main/ets/pages/Index.ets |
| 运行平台 | 模拟器或真机 |
本项目是 HarmonyOS 原生 ArkTS 项目。页面主体由 ArkUI 组件构建,核心逻辑写在 Index.ets 文件中,不依赖后端接口,也不需要额外配置数据库。
三、为什么选择习惯打卡项目
习惯打卡应用适合作为 ArkTS 练习项目,主要有以下几个原因。
第一,实用性强。习惯养成是很多人的需求,一个简洁的打卡工具可以直接应用于日常生活,帮助用户培养好习惯。
第二,业务场景真实。无论是早起、运动、阅读还是喝水,都需要坚持打卡,习惯追踪是自我提升的重要工具。
第三,适合练习状态管理。习惯列表、今日打卡状态、新习惯输入、选中的图标都属于页面状态。状态变化后,页面会自动刷新。
第四,适合练习日期处理。连续打卡天数的计算需要处理日期逻辑,这是前端开发中常见的需求。
第五,适合练习进度可视化。完成率和进度条可以直观展示今日打卡情况,激励用户完成所有习惯。
第六,扩展空间比较大。基础功能完成后,可以继续增加本地存储、打卡日历、习惯提醒、数据统计图表、习惯分类和成就系统。
在本项目中,habits 是核心数据源。页面中的统计数据、习惯列表和打卡状态都由它计算或渲染得到。
四、功能规则说明
本文提供的习惯图标包括:
| 图标 | 适用场景 |
|---|---|
| 💧 | 喝水、补水 |
| 🏃 | 运动、跑步 |
| 📚 | 阅读、学习 |
| 😴 | 早睡、睡眠 |
| 🥗 | 健康饮食 |
| ✍️ | 写作、日记 |
| 🧘 | 冥想、放松 |
| 🎯 | 目标、其他 |
打卡规则如下:
每日打卡:每天可以对每个习惯进行一次打卡
取消打卡:可以取消今日的打卡
连续天数:连续打卡的天数,中断则重新计算
统计规则如下:
今日完成 = 今日已打卡的习惯数量
习惯总数 = 添加的习惯总数
完成率 = 今日完成 / 习惯总数 * 100%
最长连续 = 所有习惯中最长的连续打卡天数
每次打卡或添加/删除习惯后,统计数据都会自动更新。
五、项目结构
本项目主要修改首页文件:
entry
└── src
└── main
└── ets
└── pages
└── Index.ets
其中:
| 文件 | 作用 |
|---|---|
| Index.ets | 编写页面结构、状态数据和习惯打卡逻辑 |
本文不涉及复杂路由,也不需要额外创建多个页面。对于练习项目来说,把主要逻辑集中在一个 Index.ets 文件中更方便理解。
如果后续继续扩展,可以考虑把习惯项组件、图标选择器、统计卡片和日历视图拆分成独立组件。
六、核心实现思路
本项目的核心流程如下:
- 定义习惯数据结构;
- 使用
@State保存新习惯名称、选中的图标和习惯列表; - 用户输入习惯名称;
- 用户选择习惯图标;
- 点击添加按钮创建新习惯;
- 使用数组保存所有习惯;
- 显示今日打卡进度;
- 使用
ForEach渲染习惯列表; - 点击复选框进行今日打卡/取消打卡;
- 点击删除按钮删除指定习惯;
- 顶部统计卡片根据习惯数据自动更新;
- 计算每个习惯的连续打卡天数;
- 没有习惯时显示空状态提示。
项目中最重要的状态变量如下:
@State newHabitName: string = ''
@State selectedIcon: string = '💧'
@State today: string = ''
@State habits: Habit[] = []
@State nextId: number = 1
其中:
| 状态变量 | 作用 |
|---|---|
| newHabitName | 新习惯输入内容 |
| selectedIcon | 选中的习惯图标 |
| today | 今日日期字符串 |
| habits | 习惯数组 |
| nextId | 生成习惯唯一编号 |
habits 是本项目中最重要的数据。所有习惯展示、打卡和统计都依赖这个数组。
七、Index.ets 完整代码
打开文件:
entry/src/main/ets/pages/Index.ets
将其中内容替换为下面代码:
interface Habit {
id: number
name: string
icon: string
checkedDates: string[]
createDate: string
}
@Entry
@Component
struct Index {
@State newHabitName: string = ''
@State selectedIcon: string = '💧'
@State nextId: number = 4
@State today: string = this.getTodayString()
@State habits: Habit[] = [
{
id: 1,
name: '喝8杯水',
icon: '💧',
checkedDates: [this.getTodayString(), this.getYesterdayString()],
createDate: this.getTodayString()
},
{
id: 2,
name: '阅读30分钟',
icon: '📚',
checkedDates: [this.getYesterdayString()],
createDate: this.getTodayString()
},
{
id: 3,
name: '运动30分钟',
icon: '🏃',
checkedDates: [],
createDate: this.getTodayString()
}
]
private icons: string[] = ['💧', '🏃', '📚', '😴', '🥗', '✍️', '🧘', '🎯']
aboutToAppear() {
this.today = this.getTodayString()
}
private addHabit(): void {
let name = this.newHabitName.trim()
if (name.length === 0) {
return
}
let habit: Habit = {
id: this.nextId,
name: name,
icon: this.selectedIcon,
checkedDates: [],
createDate: this.today
}
this.habits = [...this.habits, habit]
this.nextId++
this.newHabitName = ''
this.selectedIcon = '💧'
}
private toggleCheck(habitId: number): void {
this.habits = this.habits.map((habit: Habit) => {
if (habit.id === habitId) {
let newCheckedDates = [...habit.checkedDates]
if (this.isCheckedToday(habit)) {
newCheckedDates = newCheckedDates.filter(d => d !== this.today)
} else {
newCheckedDates.push(this.today)
}
return { ...habit, checkedDates: newCheckedDates }
}
return habit
})
}
private deleteHabit(habitId: number): void {
this.habits = this.habits.filter((habit: Habit) => habit.id !== habitId)
}
private isCheckedToday(habit: Habit): boolean {
return habit.checkedDates.includes(this.today)
}
private getStreak(habit: Habit): number {
let streak = 0
let currentDate = new Date()
while (true) {
let dateStr = this.formatDate(currentDate)
if (habit.checkedDates.includes(dateStr)) {
streak++
currentDate.setDate(currentDate.getDate() - 1)
} else {
break
}
}
return streak
}
private getTodayCompleted(): number {
return this.habits.filter((habit: Habit) => this.isCheckedToday(habit)).length
}
private getCompletionRate(): number {
if (this.habits.length === 0) {
return 0
}
return Math.round((this.getTodayCompleted() / this.habits.length) * 100)
}
private getMaxStreak(): number {
if (this.habits.length === 0) {
return 0
}
return Math.max(...this.habits.map((h: Habit) => this.getStreak(h)))
}
private getTodayString(): string {
return this.formatDate(new Date())
}
private getYesterdayString(): string {
let date = new Date()
date.setDate(date.getDate() - 1)
return this.formatDate(date)
}
private formatDate(date: Date): string {
let year = date.getFullYear()
let month = date.getMonth() + 1
let day = date.getDate()
return `${year}-${this.formatTwo(month)}-${this.formatTwo(day)}`
}
private formatTwo(value: number): string {
return value < 10 ? '0' + value.toString() : value.toString()
}
@Builder
StatCard(title: string, value: string, unit: string, color: string) {
Column() {
Row() {
Text(value)
.fontSize(24)
.fontWeight(FontWeight.Bold)
.fontColor(color)
Text(unit)
.fontSize(14)
.fontColor(color)
.margin({ left: 4, top: 8 })
}
Text(title)
.fontSize(13)
.fontColor('#6B7280')
.margin({ top: 4 })
}
.width('31%')
.height(80)
.justifyContent(FlexAlign.Center)
.backgroundColor(Color.White)
.borderRadius(16)
.shadow({
radius: 10,
color: '#12000000',
offsetX: 0,
offsetY: 4
})
}
@Builder
IconButton(icon: string) {
Button(icon)
.width(44)
.height(44)
.fontSize(22)
.backgroundColor(this.selectedIcon === icon ? '#0A59F7' : '#F3F4F6')
.borderRadius(22)
.onClick(() => {
this.selectedIcon = icon
})
}
@Builder
HabitItem(habit: Habit) {
Row() {
Text(habit.icon)
.fontSize(28)
.width(48)
.height(48)
.textAlign(TextAlign.Center)
.backgroundColor('#F5F7FA')
.borderRadius(24)
Column() {
Text(habit.name)
.fontSize(16)
.fontWeight(FontWeight.Medium)
.fontColor('#182431')
Row() {
Text(`🔥 连续${this.getStreak(habit)}天`)
.fontSize(12)
.fontColor('#F59E0B')
if (this.isCheckedToday(habit)) {
Text('✓ 今日已打卡')
.fontSize(12)
.fontColor('#10B981')
.margin({ left: 12 })
}
}
.margin({ top: 6 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.margin({ left: 12, right: 12 })
Checkbox({ name: habit.id.toString(), group: 'habits' })
.select(this.isCheckedToday(habit))
.width(28)
.height(28)
.selectedColor('#10B981')
.onChange((value: boolean) => {
this.toggleCheck(habit.id)
})
Button('×')
.width(24)
.height(24)
.fontSize(18)
.fontColor('#9CA3AF')
.backgroundColor(Color.Transparent)
.margin({ left: 8 })
.onClick(() => {
this.deleteHabit(habit.id)
})
}
.width('100%')
.padding(14)
.margin({ bottom: 10 })
.backgroundColor(Color.White)
.borderRadius(12)
.shadow({
radius: 6,
color: '#08000000',
offsetX: 0,
offsetY: 2
})
}
build() {
Column() {
Text('习惯打卡')
.fontSize(28)
.fontWeight(FontWeight.Bold)
.fontColor('#182431')
.margin({ top: 22 })
Text('基于 HarmonyOS ArkTS 的习惯养成工具')
.fontSize(14)
.fontColor('#6B7280')
.margin({ top: 8, bottom: 20 })
Row() {
this.StatCard('今日完成', `${this.getTodayCompleted()}/${this.habits.length}`, '', '#10B981')
this.StatCard('完成率', `${this.getCompletionRate()}`, '%', '#0A59F7')
this.StatCard('最长连续', `${this.getMaxStreak()}`, '天', '#F59E0B')
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.margin({ bottom: 16 })
Column() {
Progress({ value: this.getCompletionRate(), total: 100, type: ProgressType.Linear })
.width('100%')
.height(10)
.color('#10B981')
.backgroundColor('#E5E7EB')
.borderRadius(5)
.margin({ bottom: 8 })
Text(`今日打卡进度:${this.getCompletionRate()}%,继续加油!`)
.fontSize(13)
.fontColor('#6B7280')
}
.width('100%')
.padding(16)
.backgroundColor(Color.White)
.borderRadius(16)
.margin({ bottom: 16 })
.shadow({
radius: 8,
color: '#08000000',
offsetX: 0,
offsetY: 2
})
Column() {
Text('添加新习惯')
.fontSize(16)
.fontWeight(FontWeight.Medium)
.fontColor('#182431')
.width('100%')
.margin({ bottom: 12 })
TextInput({
placeholder: '输入习惯名称,例如:每天阅读30分钟',
text: this.newHabitName
})
.height(44)
.fontSize(15)
.backgroundColor('#F5F7FA')
.borderRadius(12)
.padding({ left: 12, right: 12 })
.margin({ bottom: 12 })
.onChange((value: string) => {
this.newHabitName = value
})
Text('选择图标')
.fontSize(13)
.fontColor('#6B7280')
.width('100%')
.margin({ bottom: 8 })
Row() {
ForEach(this.icons, (icon: string) => {
this.IconButton(icon)
}, (icon: string) => icon)
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.margin({ bottom: 12 })
Button('添加习惯')
.height(44)
.fontSize(16)
.fontColor(Color.White)
.backgroundColor('#10B981')
.borderRadius(22)
.width('100%')
.onClick(() => {
this.addHabit()
})
}
.width('100%')
.padding(16)
.backgroundColor(Color.White)
.borderRadius(16)
.margin({ bottom: 16 })
.shadow({
radius: 8,
color: '#08000000',
offsetX: 0,
offsetY: 2
})
Text('我的习惯')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor('#182431')
.width('100%')
.margin({ bottom: 12 })
if (this.habits.length === 0) {
Column() {
Text('还没有添加习惯')
.fontSize(16)
.fontColor('#6B7280')
Text('添加第一个习惯,开始养成好习惯吧')
.fontSize(13)
.fontColor('#9CA3AF')
.margin({ top: 8 })
}
.width('100%')
.padding(30)
.backgroundColor(Color.White)
.borderRadius(16)
} else {
List() {
ForEach(this.habits, (habit: Habit) => {
ListItem() {
this.HabitItem(habit)
}
}, (habit: Habit) => habit.id.toString())
}
.width('100%')
.layoutWeight(1)
.scrollBar(BarState.Off)
}
}
.width('100%')
.height('100%')
.padding({ left: 18, right: 18 })
.backgroundColor('#F5F7FA')
}
}
八、代码实现说明
1. 定义习惯数据结构
项目中使用 Habit 接口描述每一个习惯:
interface Habit {
id: number
name: string
icon: string
checkedDates: string[]
createDate: string
}
字段说明如下:
| 字段 | 作用 |
|---|---|
| id | 习惯唯一编号 |
| name | 习惯名称 |
| icon | 习惯图标 |
| checkedDates | 打卡日期数组 |
| createDate | 创建日期 |
其中 checkedDates 数组保存了所有打卡的日期,用于判断今日是否打卡和计算连续天数。
2. 使用 @State 管理页面状态
本项目中使用多个状态变量:
@State newHabitName: string = ''
@State selectedIcon: string = '💧'
@State today: string = ''
@State habits: Habit[] = []
@State nextId: number = 1
这些状态变量分别保存新习惯名称、选中的图标、今日日期和习惯列表。当用户输入习惯名称、选择图标、添加习惯或打卡时,状态数据都会发生变化。ArkUI 会根据最新状态自动刷新页面。
3. 添加新习惯
添加习惯通过 addHabit 方法实现:
private addHabit(): void {
let name = this.newHabitName.trim()
if (name.length === 0) {
return
}
let habit: Habit = {
id: this.nextId,
name: name,
icon: this.selectedIcon,
checkedDates: [],
createDate: this.today
}
this.habits = [...this.habits, habit]
this.nextId++
this.newHabitName = ''
this.selectedIcon = '💧'
}
如果名称为空则不添加。新习惯添加到数组末尾,添加后清空输入框并重置图标选择。
4. 打卡/取消打卡
点击复选框时切换今日打卡状态:
private toggleCheck(habitId: number): void {
this.habits = this.habits.map((habit: Habit) => {
if (habit.id === habitId) {
let newCheckedDates = [...habit.checkedDates]
if (this.isCheckedToday(habit)) {
newCheckedDates = newCheckedDates.filter(d => d !== this.today)
} else {
newCheckedDates.push(this.today)
}
return { ...habit, checkedDates: newCheckedDates }
}
return habit
})
}
如果今日已打卡则取消(从数组中移除今日日期),否则添加今日日期到打卡数组。
5. 连续打卡天数计算
从今天开始往前数,连续打卡的天数:
private getStreak(habit: Habit): number {
let streak = 0
let currentDate = new Date()
while (true) {
let dateStr = this.formatDate(currentDate)
if (habit.checkedDates.includes(dateStr)) {
streak++
currentDate.setDate(currentDate.getDate() - 1)
} else {
break
}
}
return streak
}
使用循环从今天开始往前检查每一天是否在打卡数组中,直到遇到未打卡的日期为止。
6. 统计数据计算
今日完成数、完成率和最长连续天数都动态计算:
private getTodayCompleted(): number {
return this.habits.filter((habit: Habit) => this.isCheckedToday(habit)).length
}
private getCompletionRate(): number {
if (this.habits.length === 0) {
return 0
}
return Math.round((this.getTodayCompleted() / this.habits.length) * 100)
}
private getMaxStreak(): number {
if (this.habits.length === 0) {
return 0
}
return Math.max(...this.habits.map((h: Habit) => this.getStreak(h)))
}
这样可以保证统计数据始终与习惯数组保持一致。
7. 日期格式化
统一的日期格式化方法,确保日期字符串格式一致:
private formatDate(date: Date): string {
let year = date.getFullYear()
let month = date.getMonth() + 1
let day = date.getDate()
return `${year}-${this.formatTwo(month)}-${this.formatTwo(day)}`
}
8. 图标选择
提供8种常用的习惯图标供用户选择:
private icons: string[] = ['💧', '🏃', '📚', '😴', '🥗', '✍️', '🧘', '🎯']
使用emoji作为图标,简单直观,不需要额外的图片资源。
九、运行项目
代码编写完成后,在 DevEco Studio 中选择模拟器或真机运行项目。
运行成功后,页面会显示"习惯打卡"。用户可以添加新习惯,选择图标,每天打卡,查看连续天数和完成进度。
可以按照以下步骤进行测试:
- 打开应用,检查页面是否正常显示预置的示例习惯;
- 检查统计卡片是否显示正确的今日完成数、完成率和最长连续天数;
- 检查进度条是否显示正确的完成进度;
- 在输入框中输入新习惯名称;
- 选择不同的图标,检查图标按钮颜色是否变化;
- 点击"添加习惯"按钮,检查新习惯是否出现在列表中;
- 检查统计数据是否更新;
- 点击习惯前的复选框,检查是否标记为今日已打卡;
- 检查连续天数是否正确计算;
- 检查统计数据和进度条是否更新;
- 再次点击复选框,检查是否取消打卡;
- 点击习惯右侧的×按钮,检查习惯是否被删除;
- 删除所有习惯,检查空状态提示是否显示。
测试结果如下:
| 测试功能 | 测试结果 |
|---|---|
| 页面正常显示 | 成功 |
| 习惯名称输入 | 成功 |
| 图标选择 | 成功 |
| 添加习惯 | 成功 |
| 打卡/取消打卡 | 成功 |
| 连续天数计算 | 成功 |
| 删除习惯 | 成功 |
| 完成率统计 | 成功 |
| 进度条更新 | 成功 |
| 空状态提示 | 成功 |
经过测试,应用主要功能可以正常运行,打卡状态和统计数据都能根据用户操作及时更新。
十、开发中遇到的问题
1. 日期比较问题
日期比较必须使用统一格式的字符串,不能直接比较Date对象,确保打卡判断准确:
return habit.checkedDates.includes(this.today)
2. 连续天数计算边界
计算连续天数时要从今天开始往前数,而不是从创建日期开始,这样才能正确反映当前的连续打卡情况。
3. 空数组处理
当没有习惯时,计算完成率和最长连续天数会出现问题,需要特殊处理:
if (this.habits.length === 0) {
return 0
}
4. 状态更新检测
修改打卡日期数组时需要创建新数组,而不是直接修改原数组,否则UI不会更新:
let newCheckedDates = [...habit.checkedDates]
// ... 修改newCheckedDates
return { ...habit, checkedDates: newCheckedDates }
5. 视觉反馈
已打卡的习惯要有明显的视觉反馈,显示"✓ 今日已打卡"文字,让用户清楚知道哪些习惯已经完成。
十一、总结
本文基于 HarmonyOS 和 ArkTS 实现了一个习惯打卡应用。项目通过 @State 管理页面数据,使用 ArkUI 组件完成页面布局,并实现了习惯添加、每日打卡、连续天数统计、完成率计算和进度显示等功能。
通过本次实践,主要完成了以下内容:
- 使用
@Entry和@Component创建页面组件; - 使用
@State保存习惯名称、选中图标和习惯列表; - 使用
TextInput获取习惯名称输入; - 使用
Checkbox实现打卡功能; - 支持多种习惯图标选择;
- 实现习惯添加和删除功能;
- 正确计算连续打卡天数;
- 动态计算今日完成数和完成率;
- 使用
Progress组件显示打卡进度; - 使用emoji图标美化界面;
- 使用
List和ForEach渲染习惯列表; - 使用条件渲染显示空状态;
- 使用
@Builder封装页面局部结构; - 完成一个可以运行的 HarmonyOS 习惯养成工具页面。
这个项目虽然是一个单页面练习,但它完整实现了习惯打卡的核心功能。它展示了 ArkTS 中日期处理、数组操作、状态管理和进度可视化的基本方法。
后续可以在这个基础上继续扩展,例如:
- 增加本地数据持久化;
- 增加打卡日历视图;
- 增加打卡提醒通知;
- 增加每周/每月坚持统计;
- 增加习惯分类;
- 增加习惯编辑功能;
- 增加自定义图标;
- 增加成就徽章系统;
- 增加深色模式适配;
- 增加数据导出功能。
整体来看,习惯打卡应用适合作为 HarmonyOS ArkTS 的练习项目。它功能实用、贴近生活,也能体现 ArkUI 声明式开发和状态驱动页面刷新的基本思想。
更多推荐



所有评论(0)