HarmonyOS APP开发---“习惯猫“习惯打卡App,需要用到这个库
HarmonyOS APP开发—"习惯猫"习惯打卡App,需要用到这个库
打卡类 App 有个特殊性:数据是用户几个月甚至几年的坚持记录,丢一次用户就再也不回来了。
@ohos/dataorm让你少写 SQL 的同时,本文重点讲两个数据安全实践——批量导入怎么不出错、版本升级怎么可回滚。
📦 仓库地址 |
ohpm install @ohos/dataorm
场景
"习惯猫"的数据模型:
- 习惯表:名称、图标、颜色、每周目标次数、是否归档
- 打卡记录表:哪个习惯、哪天打的、备注
- 统计:连续天数、总次数、本月排行(由打卡记录聚合)
关系是一对多。查询很典型:“今天哪些习惯没打卡”、“「早起」连续多少天了”、“本月打卡最多的是哪个”。
手写 SQL 要拼字符串、写 JOIN、手动映射结果集,每次改动都担心字段名拼错。@ohos/dataorm 用注解定义实体,链式 API 拼查询,@ToMany 自动带出关联数据。
为什么是 dataorm
| 需求 | 原生 relationalStore | @ohos/dataorm |
|---|---|---|
| 建表 | 手写 CREATE TABLE | ✅ 注解自动生成 |
| 查询 | 拼 SQL 字符串 | ✅ 链式 API |
| 一对多关联 | 手写 JOIN + 映射 | ✅ @ToMany 自动带出 |
| 类型安全 | ❌ 字符串无约束 | ✅ 编译期检查 |
| 迁移 | 手写 ALTER,无回滚 | ✅ Migration API |
| 代码量 | 100% | ✅ ~30% |
关键实现
注解定义实体
import { Entity, Id, NotNull, Column, ToMany, Index, List } from '@ohos/dataorm'
@Entity({ tableName: 'CHECKIN' })
export class CheckIn {
@Id() @Column({ columnName: 'ID' }) id: number = 0
@Index() @Column({ columnName: 'HABIT_ID' }) habitId: number = 0 // ✅ 高频查询字段加索引
@NotNull() @Column({ columnName: 'DATE' }) date: number = 0
@Column({ columnName: 'NOTE' }) note: string = ''
@Column({ columnName: 'IMPORT_BATCH' }) importBatch: number = 0 // 导入批次号,用于校验和回滚
}
@Entity({ tableName: 'HABIT' })
export class Habit {
@Id() @Column({ columnName: 'ID' }) id: number = 0
@NotNull() @Column({ columnName: 'NAME' }) name: string = ''
@Column({ columnName: 'ICON' }) icon: string = '🎯'
@Column({ columnName: 'COLOR' }) color: string = '#4ECDC4'
@Column({ columnName: 'TARGET_FREQ' }) targetFreq: number = 7
@Column({ columnName: 'ARCHIVED' }) archived: boolean = false
@ToMany({ joinEntity: CheckIn.class }) // ✅ 一对多,查询时自动带出
checkIns: List<CheckIn> = new List()
}
@Index() 很重要:打卡记录会积累到几万条,getStreakDays(habitId) 这类按 habitId 过滤的查询,没索引会明显变慢。
链式查询统计
const habitDao = db.dao(Habit)
const checkInDao = db.dao(CheckIn)
// 今日待打卡
async function getTodayPending(): Promise<Habit[]> {
const todayStart = getDayStartTimestamp(new Date())
const allHabits = await habitDao.queryBuilder().where(Habit.ARCHIVED.eq(false)).list()
const checked = await checkInDao.queryBuilder().where(CheckIn.DATE.ge(todayStart)).list()
const checkedIds = new Set(checked.map((c) => c.habitId))
return allHabits.filter((h) => !checkedIds.has(h.id))
}
// 连续打卡天数
async function getStreakDays(habitId: number): Promise<number> {
const records = await checkInDao.queryBuilder()
.where(CheckIn.HABIT_ID.eq(habitId))
.orderDesc(CheckIn.DATE)
.list()
if (records.length === 0) return 0
const days = new Set(records.map((r) => getDayStartTimestamp(new Date(r.date))))
const ONE_DAY = 24 * 60 * 60 * 1000
let checkDate = getDayStartTimestamp(new Date())
if (!days.has(checkDate)) checkDate -= ONE_DAY // 今天还没打卡不算断签
let streak = 0
while (days.has(checkDate)) { streak++; checkDate -= ONE_DAY }
return streak
}
// 打卡(业务层去重)
async function checkIn(habitId: number, note = ''): Promise<boolean> {
const todayStart = getDayStartTimestamp(new Date())
const exists = await checkInDao.queryBuilder()
.where(CheckIn.HABIT_ID.eq(habitId))
.and(CheckIn.DATE.ge(todayStart))
.count()
if (exists > 0) {
promptAction.showToast({ message: '今天已经打过卡啦 🎉' })
return false
}
const record = new CheckIn()
record.habitId = habitId
record.date = Date.now()
record.note = note
await checkInDao.insert(record)
return true
}
链式查询的好处是字段名写错编译期就报错,不像 SQL 字符串要运行时才发现。
数据安全实践一:批量导入
"习惯猫"支持从其他 App 导入历史记录,可能一次几千条。两个坑要避开:一次性插入几千条时异步接口的等待行为可能不符合预期;插入中途失败不知道进了多少条。
async function importCheckIns(
records: CheckIn[],
batchSize = 200,
onProgress?: (percent: number) => void,
): Promise<{ success: boolean; imported: number }> {
const total = records.length
if (total === 0) return { success: true, imported: 0 }
const batchId = Date.now() // 本次导入批次号
records.forEach((r) => { r.importBatch = batchId })
let imported = 0
for (let i = 0; i < total; i += batchSize) {
const batch = records.slice(i, i + batchSize)
try {
await checkInDao.saveInTxAsync(batch)
imported += batch.length
} catch (err) {
console.error(`第 ${i / batchSize + 1} 批失败: ${err}`)
continue // 单批失败不中断整体
}
onProgress?.(Math.floor(imported / total * 100)) // ✅ 进度条,否则用户以为卡死
}
// ✅ 回查校验:确认数据真的写进去了
const actual = await checkInDao.queryBuilder()
.where(CheckIn.IMPORT_BATCH.eq(batchId)).count()
return { success: actual === imported, imported: actual }
}
// 失败时按批次号精确回滚
async function rollbackImport(batchId: number) {
const deleted = await checkInDao.queryBuilder()
.where(CheckIn.IMPORT_BATCH.eq(batchId)).remove()
console.info(`已回滚 ${deleted} 条`)
}
分批的四个好处:能显示进度、单批失败影响小、内存占用可控、配合批次号能精确回滚。
数据安全实践二:版本升级迁移
v2 要给习惯表加"提醒时间"字段。数据库迁移是最容易丢数据的操作:
async function upgradeDatabase(from: number, to: number): Promise<boolean> {
const backupName = `habitcat_backup_v${from}_${Date.now()}.db`
// 第一步:备份(备份失败就中止升级)
try {
await backupDatabase('habitcat.db', backupName)
} catch (err) {
promptAction.showToast({ message: '备份失败,为保护数据已中止升级' })
return false
}
// 第二步:迁移
try {
await db.migrate(from, to)
// ✅ 迁移后校验数据完整性
const habitCount = await habitDao.queryBuilder().count()
const checkInCount = await checkInDao.queryBuilder().count()
if (habitCount === 0 && checkInCount > 0) {
throw new Error('数据校验异常:习惯表为空但打卡表有数据')
}
await deleteBackup(backupName)
return true
} catch (err) {
// 第三步:失败则恢复
try {
await restoreDatabase(backupName, 'habitcat.db')
promptAction.showToast({ message: '升级失败,数据已恢复' })
} catch (restoreErr) {
// 恢复也失败 → 保留备份文件,提示联系客服
console.error('恢复失败!备份保留在: ' + backupName)
promptAction.showToast({ message: '升级异常,数据已保留,请联系客服' })
}
return false
}
}
// 迁移脚本:幂等 + 不做不可逆操作
const migrationV1toV2: Migration = {
fromVersion: 1,
toVersion: 2,
async migrate(database) {
// ✅ 新增字段(安全,不影响已有数据)
await database.executeSql('ALTER TABLE HABIT ADD COLUMN REMIND_TIME INTEGER DEFAULT NULL')
await database.executeSql('ALTER TABLE HABIT ADD COLUMN REMIND_ENABLED INTEGER DEFAULT 0')
// ❌ 不要写这些:
// DROP TABLE / DELETE FROM / DROP COLUMN —— 不可逆,出问题无法挽回
},
}
迁移脚本三条铁律:幂等(可重复执行不出错)、只增不减(删字段删表极度谨慎)、可回滚(迁移前必备份)。
数据变化监听
const listener = { onDataChanged: () => this.refreshStatistics() }
checkInDao.addListener(listener)
aboutToDisappear() {
checkInDao.removeListener(listener) // ✅ 别忘了移除,否则内存泄漏
}
小结
"习惯猫"这个场景里,@ohos/dataorm 解决了三件事:@ToMany 让习惯自动带出打卡记录、链式 API 让统计查询几十行搞定、@Index 保证数据量增长后查询依然快。
但真正决定这个 App 口碑的是数据安全:分批导入 + 回查校验 + 批次号回滚,迁移前备份 + 失败恢复 + 脚本幂等。这两套流程做扎实了,用户的坚持记录才不会因为一次版本升级而消失。
更多推荐


所有评论(0)