打卡类 App 有个特殊性: 数据是用户几个月甚至几年的坚持记录,丢一次用户就再也不回来了 。 @ohos/dataorm 让你少写 SQL 的同时,本文重点讲两个数据安全实践——批量导入怎么不出错、版本升级怎么可回滚。
📦 仓库地址 | ohpm install @ohos/dataorm
习惯表 :名称、图标、颜色、每周目标次数、是否归档
打卡记录表 :哪个习惯、哪天打的、备注
统计 :连续天数、总次数、本月排行(由打卡记录聚合)
关系是一对多。查询很典型:"今天哪些习惯没打卡"、"「早起」连续多少天了"、"本月打卡最多的是哪个"。
手写 SQL 要拼字符串、写 JOIN、手动映射结果集,每次改动都担心字段名拼错。 @ohos/dataorm 用注解定义实体,链式 API 拼查询, @ToMany 自动带出关联数据。
| 需求 | 原生 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
{
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'
)
},
}迁移脚本三条铁律 :幂等(可重复执行不出错)、只增不减(删字段删表极度谨慎)、可回滚(迁移前必备份)。
const
listener = {
onDataChanged
:
() =>
this
.
refreshStatistics
() }
checkInDao.
addListener
(listener)
aboutToDisappear
(
) {
checkInDao.
removeListener
(listener)
}"习惯猫"这个场景里, @ohos/dataorm 解决了三件事: @ToMany 让习惯自动带出打卡记录、链式 API 让统计查询几十行搞定、 @Index 保证数据量增长后查询依然快。
但真正决定这个 App 口碑的是 数据安全 :分批导入 + 回查校验 + 批次号回滚,迁移前备份 + 失败恢复 + 脚本幂等。这两套流程做扎实了,用户的坚持记录才不会因为一次版本升级而消失。
转载声明:本文为转载整理,原文版权归原作者及原站点所有。
原文来源:Juejin HarmonyOS
原文作者:OH_TPC;如有侵权,请联系管理员删除。