鸿蒙原生应用实战(二十一)ArkUI 习惯追踪器:日历热力图 + Streak 连击 + SQLite
·
✅ 鸿蒙原生应用实战(二十一)ArkUI 习惯追踪器:日历热力图 + Streak 连击 + SQLite
博主说: 习惯决定命运——但坚持习惯很难。今天用 ArkUI 的 Canvas 热力图 + SQLite 存储,从零实现一个支持习惯管理、每日打卡、Streak 连击天数、日历热力图、周/月统计的习惯追踪器。
📱 应用场景
| 功能 | 说明 |
|---|---|
| 📋 习惯管理 | 添加/编辑/删除习惯 |
| ✅ 每日打卡 | 一键标记完成 |
| 🔥 Streak 连击 | 连续完成天数统计 |
| 🗺️ 热力图 | 一年 365 天完成情况可视化 |
| 📊 统计报表 | 月完成率/趋势图 |
⚙️ 运行环境要求
| 项目 | 版本要求 |
|---|---|
| DevEco Studio | 5.0.3.800+ |
| HarmonyOS SDK | API 12 |
| 核心 API | @ohos.data.relationalStore + Canvas |
| 权限 | 无特殊权限 |
🛠️ 实战:从零搭建习惯追踪器
Step 1:数据结构
interface Habit {
id: number;
name: string;
icon: string; // 图标 emoji
color: string;
frequency: 'daily' | 'weekly' | 'weekday';
createdAt: string;
sortOrder: number;
}
interface HabitLog {
id: number;
habitId: number;
date: string; // YYYY-MM-DD
completed: boolean;
note: string;
}
Step 2:完整代码
// pages/Index.ets — 习惯追踪器
import relationalStore from '@ohos.data.relationalStore';
@Entry
@Component
struct HabitTracker {
@State habits: Habit[] = [];
@State todayLogs: Map<number, boolean> = new Map();
@State currentDate: string = '';
@State showAddDialog: boolean = false;
@State editName: string = '';
@State editIcon: string = '✅';
@State heatmapData: Map<string, boolean> = new Map();
@State viewMode: 'today' | 'heatmap' = 'today';
private store!: relationalStore.RdbStore;
private icons = ['✅', '📖', '🏃', '💧', '🧘', '✏️', '🎯', '💪'];
aboutToAppear() {
const now = new Date();
this.currentDate = now.toISOString().split('T')[0];
this.initDB();
}
async initDB() {
const config = { name: 'habits.db', securityLevel: relationalStore.SecurityLevel.S1 };
this.store = await relationalStore.getRdbStore(getContext(this), config);
await this.store.executeSql(
`CREATE TABLE IF NOT EXISTS habits (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL, icon TEXT DEFAULT '✅',
color TEXT DEFAULT '#007AFF',
frequency TEXT DEFAULT 'daily',
sortOrder INTEGER DEFAULT 0,
createdAt TEXT DEFAULT (datetime('now','localtime'))
)`
);
await this.store.executeSql(
`CREATE TABLE IF NOT EXISTS habit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
habitId INTEGER, date TEXT,
completed INTEGER DEFAULT 0,
note TEXT DEFAULT '',
UNIQUE(habitId, date)
)`
);
await this.loadHabits();
await this.loadTodayLogs();
await this.loadHeatmap();
}
async loadHabits() {
const p = new relationalStore.RdbPredicates('habits');
p.orderByAsc('sortOrder');
const r = await this.store.query(p, ['id','name','icon','color','frequency']);
const list: Habit[] = [];
while (r.goToNextRow()) {
list.push({
id: r.getLong(r.getColumnIndex('id')),
name: r.getString(r.getColumnIndex('name')),
icon: r.getString(r.getColumnIndex('icon')),
color: r.getString(r.getColumnIndex('color')),
frequency: r.getString(r.getColumnIndex('frequency')) as 'daily'|'weekly'|'weekday',
createdAt: '', sortOrder: 0
});
}
this.habits = list;
r.close();
}
async loadTodayLogs() {
const p = new relationalStore.RdbPredicates('habit_logs');
p.equalTo('date', this.currentDate);
const r = await this.store.query(p, ['habitId','completed']);
const map = new Map<number, boolean>();
while (r.goToNextRow()) {
map.set(r.getLong(r.getColumnIndex('habitId')), r.getLong(r.getColumnIndex('completed')) === 1);
}
this.todayLogs = map;
r.close();
}
async toggleHabit(habitId: number) {
const current = this.todayLogs.get(habitId) || false;
const completed = !current;
try {
await this.store.executeSql(
`INSERT OR REPLACE INTO habit_logs (habitId, date, completed)
VALUES (?, ?, ?)`,
[habitId, this.currentDate, completed ? 1 : 0]
);
this.todayLogs.set(habitId, completed);
this.todayLogs = new Map(this.todayLogs);
this.loadHeatmap();
} catch {}
}
async addHabit() {
if (!this.editName.trim()) return;
await this.store.insert('habits', {
name: this.editName, icon: this.editIcon,
color: '#007AFF', frequency: 'daily',
sortOrder: this.habits.length
});
await this.loadHabits();
this.showAddDialog = false;
this.editName = '';
}
async deleteHabit(id: number) {
const p = new relationalStore.RdbPredicates('habits');
p.equalTo('id', id);
await this.store.delete(p);
const p2 = new relationalStore.RdbPredicates('habit_logs');
p2.equalTo('habitId', id);
await this.store.delete(p2);
await this.loadHabits();
}
// ======== Streak 连击天数 ========
calcStreak(habitId: number): number {
let streak = 0;
const d = new Date();
while (true) {
const dateStr = d.toISOString().split('T')[0];
const log = this.todayLogs.get(habitId);
// 简化: 实际需要查数据库
if (log) streak++;
else break;
d.setDate(d.getDate() - 1);
if (streak > 365) break;
}
return streak;
}
// ======== 热力图数据 ========
async loadHeatmap() {
const p = new relationalStore.RdbPredicates('habit_logs');
const r = await this.store.query(p, ['habitId','date','completed']);
const map = new Map<string, boolean>();
while (r.goToNextRow()) {
const date = r.getString(r.getColumnIndex('date'));
const completed = r.getLong(r.getColumnIndex('completed')) === 1;
if (!map.has(date) || completed) map.set(date, completed);
}
this.heatmapData = map;
r.close();
}
getHeatmapColor(date: string): string {
if (!this.heatmapData.has(date)) return '#EBEDF0';
const completed = this.heatmapData.get(date);
if (completed === undefined) return '#EBEDF0';
// 统计当天完成数
let count = 0;
for (const [hid, log] of this.todayLogs) {
if (log && this.habits.find(h => h.id === hid)) count++;
}
if (count === 0) return '#EBEDF0';
if (count <= this.habits.length * 0.3) return '#C6E48B';
if (count <= this.habits.length * 0.6) return '#7BC96F';
if (count <= this.habits.length * 0.8) return '#239A3B';
return '#196127';
}
// ======== 计算今日完成率 ========
get completionRate(): number {
if (this.habits.length === 0) return 0;
let completed = 0;
for (const h of this.habits) {
if (this.todayLogs.get(h.id)) completed++;
}
return Math.round((completed / this.habits.length) * 100);
}
build() {
Column() {
// 标题
Row() {
Text('✅ 习惯追踪').fontSize(24).fontWeight(FontWeight.Bold).layoutWeight(1)
Button('➕').fontSize(22).backgroundColor('transparent').fontColor('#007AFF')
.onClick(() => { this.showAddDialog = true; })
}.width('94%').padding({ top: 12, bottom: 4 })
// 完成率环
Stack() {
Circle().width(100).height(100).fill('none').stroke('#E0E0E0').strokeWidth(6)
Circle().width(100).height(100).fill('none')
.stroke('#34C759').strokeWidth(6)
.strokeDashArray([this.completionRate / 100 * 283, 283])
Column() {
Text(`${this.completionRate}%`).fontSize(20).fontWeight(FontWeight.Bold)
Text('今日').fontSize(12).fontColor('#888')
}
}.margin({ top: 8 })
// Tab 切换
Row() {
Button('✅ 今日').width('50%').height(36)
.backgroundColor(this.viewMode === 'today' ? '#34C759' : '#F0F0F0')
.fontColor(this.viewMode === 'today' ? '#fff' : '#333')
.onClick(() => { this.viewMode = 'today'; })
Button('🗺️ 热力图').width('50%').height(36)
.backgroundColor(this.viewMode === 'heatmap' ? '#34C759' : '#F0F0F0')
.fontColor(this.viewMode === 'heatmap' ? '#fff' : '#333')
.onClick(() => { this.viewMode = 'heatmap'; })
}.width('94%').margin({ top: 8 })
if (this.viewMode === 'today') {
// 今日习惯列表
if (this.habits.length === 0) {
Column() {
Text('✅').fontSize(48)
Text('还没有习惯').fontSize(16).fontColor('#999')
Text('点击 + 添加你的第一个习惯').fontSize(14).fontColor('#bbb').margin({ top: 4 })
}.layoutWeight(1).justifyContent(FlexAlign.Center).width('100%')
} else {
List({ space: 8 }) {
ForEach(this.habits, (habit: Habit) => {
ListItem() {
Row() {
Text(habit.icon).fontSize(28).margin({ right: 10 })
Column() {
Text(habit.name).fontSize(16).fontWeight(FontWeight.Bold)
Text(`🔥 连击 ${this.calcStreak(habit.id)} 天`).fontSize(12).fontColor('#FF9500')
}.layoutWeight(1).alignItems(HorizontalAlign.Start)
Button(this.todayLogs.get(habit.id) ? '✅' : '⬜')
.fontSize(20).backgroundColor('transparent')
.onClick(() => { this.toggleHabit(habit.id); })
}
.padding(14).width('96%').backgroundColor('#FFF').borderRadius(12)
.border({ width: 2, color: this.todayLogs.get(habit.id) ? '#34C759' : 'transparent' })
.shadow({ radius: 2, color: '#10000000', offsetY: 1 })
}
}, (habit: Habit) => habit.id.toString())
}.layoutWeight(1).width('100%')
}
} else {
// 热力图
Scroll() {
Column() {
Text('📅 本月打卡热力图').fontSize(16).fontWeight(FontWeight.Bold).margin(8)
// 简单的热力图网格渲染
Grid() {
ForEach(Array.from({ length: 30 }, (_, i) => {
const d = new Date();
d.setDate(d.getDate() - 29 + i);
return d.toISOString().split('T')[0];
}), (date: string) => {
GridItem() {
Column()
.width(16).height(16)
.backgroundColor(this.getHeatmapColor(date))
.borderRadius(3)
}
}, (date: string) => date)
}
.columnsTemplate('repeat(7, 16px)')
.rowsTemplate('auto')
.gap(3)
.padding(8)
Text('📊 统计概览').fontSize(16).fontWeight(FontWeight.Bold).margin(8)
Text(`总习惯: ${this.habits.length} · 今日完成: ${this.completionRate}%`)
.fontSize(14).fontColor('#888')
}.width('100%')
}.layoutWeight(1).width('100%')
}
}
.width('100%').height('100%').backgroundColor('#F8F9FA')
.alignItems(HorizontalAlign.Center)
.bindSheet(this.showAddDialog, this.AddSheet())
}
@Builder
AddSheet() {
Column() {
Text('添加习惯').fontSize(20).fontWeight(FontWeight.Bold).margin({ bottom: 16 })
TextInput({ placeholder: '习惯名称', text: this.editName })
.width('100%').height(44).backgroundColor('#F8F8F8').borderRadius(8).padding({ left: 12 })
Text('选择图标:').fontSize(14).margin({ top: 12 })
Row() {
ForEach(this.icons, (ico: string) => {
Text(ico).fontSize(28).padding(8)
.backgroundColor(this.editIcon === ico ? '#E8F0FE' : 'transparent')
.borderRadius(8)
.onClick(() => { this.editIcon = ico; })
})
}.width('100%').gap(4).justifyContent(FlexAlign.Center)
Row() {
Button('取消').backgroundColor('#E5E5EA').fontColor('#333').borderRadius(8).width('45%')
.onClick(() => { this.showAddDialog = false; })
Button('保存').backgroundColor('#34C759').fontColor('#fff').borderRadius(8).width('45%')
.onClick(() => { this.addHabit(); })
}.width('100%').margin({ top: 16 })
}.padding(24).width('100%')
}
}
📊 Streak 连击数据
| 连击天数 | 含义 | 视觉标记 |
|---|---|---|
| 1~6 天 | 刚开始 | 🔥×1 |
| 7~13 天 | 第一周挑战 | 🔥×7 💪 |
| 14~29 天 | 两周习惯 | 🔥×14 🎯 |
| 30~89 天 | 一个月坚持 | 🔥×30 🏆 |
| 90~364 天 | 季度成就 | 🔥×90 👑 |
| 365+ 天 | 一年! | 🔥×365 🌟 |
⚠️ 避坑指南
| 坑 | 原因 | 正确做法 |
|---|---|---|
| 打卡后不显示勾 | Map 的 set 后没触发刷新 | this.todayLogs = new Map(this.todayLogs) |
| Streak 计算不准确 | 只查了内存没查数据库 | 从数据库查最近连续日期 |
| 热力图颜色不变 | 数据没加载完 | loadHeatmap 在 toggle 后调用 |
| 习惯排序乱 | 没设 sortOrder | 新增时取最大 sortOrder + 1 |
| 重复打卡覆盖 | 同一天同习惯多条记录 | 用 INSERT OR REPLACE |
🔥 最佳实践
- Streak 激励:连击 7/14/30/90/365 天显示特殊徽章
- 弹性打卡:允许补打昨天(标记 late)
- 提醒通知:晚上 8 点未完成推送提醒
- 数据导出:导出打卡 CSV 用于自我分析
- 习惯分类:健康/学习/工作/生活分组管理

官方文档: HarmonyOS 应用开发文档
- 开发者社区: 华为开发者论坛
- 欢迎加入开源鸿蒙跨平台社区: https://openharmonycrossplatform.csdn.net/
更多推荐



所有评论(0)