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 口碑的是数据安全:分批导入 + 回查校验 + 批次号回滚,迁移前备份 + 失败恢复 + 脚本幂等。这两套流程做扎实了,用户的坚持记录才不会因为一次版本升级而消失。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐