easy-word 的核心设计原则是离线优先:所有学习数据(单词、学习记录、复习计划)以本地 RDB 为事实源,云端同步是可选的增强。断网时功能零降级。

为什么离线优先?

学习类应用的特殊性:

  • 学生可能在学校(无网络)使用
  • 学习数据不能丢失(一次丢数据 = 失去信任)
  • 高频读写(每次翻卡、拼写、答题都产生记录)
  • 云端同步延迟不能阻塞 UI

RDB 表设计

三张核心表:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
-- 单词表
CREATE TABLE IF NOT EXISTS words (
id INTEGER PRIMARY KEY AUTOINCREMENT,
word TEXT NOT NULL,
meaning TEXT NOT NULL,
phonetic TEXT,
grade INTEGER NOT NULL, -- 年级 1-6
unit INTEGER NOT NULL, -- 单元
created_at INTEGER NOT NULL
)

-- 学习记录表
CREATE TABLE IF NOT EXISTS learn_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
word_id INTEGER NOT NULL,
user_id TEXT NOT NULL, -- 儿童档案 ID
status INTEGER NOT NULL, -- 0=未学 1=学习中 2=已掌握
review_count INTEGER DEFAULT 0, -- 复习次数
correct_count INTEGER DEFAULT 0,
next_review_at INTEGER NOT NULL, -- 下次复习时间戳
last_review_at INTEGER,
FOREIGN KEY (word_id) REFERENCES words(id)
)

-- 学习日志表(热力图数据源)
CREATE TABLE IF NOT EXISTS daily_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
date TEXT NOT NULL, -- YYYY-MM-DD
words_learned INTEGER DEFAULT 0,
words_reviewed INTEGER DEFAULT 0,
accuracy REAL DEFAULT 0.0,
UNIQUE(user_id, date)
)

BaseDao 懒加载模式

鸿蒙的 relationalStore.getRdbStore() 是异步的,但组件渲染是同步的。BaseDao 的懒加载解决了这个时序问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
abstract class BaseDao<T> {
private store: relationalStore.RdbStore | null = null
protected abstract tableName: string

protected async getStore(): Promise<relationalStore.RdbStore> {
if (this.store) return this.store
this.store = await relationalStore.getRdbStore(getContext(), {
name: 'easy_word.db',
securityLevel: relationalStore.SecurityLevel.S1
})
await this.ensureTable(this.store)
return this.store
}

protected abstract ensureTable(store: relationalStore.RdbStore): Promise<void>

async insert(data: Partial<T>): Promise<number> {
const store = await this.getStore()
const bucket = new relationalStore.RdbPredicates(this.tableName)
const rowId = await store.insert(this.tableName, data)
return rowId
}

async query(predicates: relationalStore.RdbPredicates): Promise<T[]> {
const store = await this.getStore()
const resultSet = await store.query(predicates, [])
return this.mapResultSet(resultSet)
}
}

第一次调用 getStore() 时初始化数据库并确保表存在,后续调用直接返回缓存。组件的 aboutToAppear() 中调用 DAO 方法,第一次会 await,第二次起就是毫秒级。

分布式同步:同账号设备间同步

鸿蒙的 relationalStore 支持分布式表,同账号的不同设备可以自动同步数据:

1
2
3
4
5
6
7
8
9
10
async enableDistributed() {
const store = await this.getStore()
await store.setDistributedTables(['learn_records', 'daily_logs'])
}

async syncToDevice(deviceId?: string) {
const store = await this.getStore()
const predicate = new relationalStore.RdbPredicates('learn_records')
await store.sync(relationalStore.SyncMode.SYNC_MODE_PUSH, predicate, deviceId)
}

setDistributedTables 标记哪些表参与分布式同步。sync 触发一次推送同步,deviceId 为空时推送到所有设备。

多用户:儿童档案隔离

一个家庭可能有多个孩子使用同一台设备。通过 user_id 字段隔离数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class LearnRecordDao extends BaseDao<LearnRecord> {
protected tableName = 'learn_records'

async getByUser(userId: string): Promise<LearnRecord[]> {
const predicates = new relationalStore.RdbPredicates(this.tableName)
predicates.equalTo('user_id', userId)
return this.query(predicates)
}

async getNextReviewWords(userId: string, limit: number): Promise<LearnRecord[]> {
const predicates = new relationalStore.RdbPredicates(this.tableName)
predicates.equalTo('user_id', userId)
predicates.lessThanOrEqualTo('next_review_at', Date.now())
predicates.equalTo('status', 1) // 学习中
predicates.limit(limit)
predicates.orderByAsc('next_review_at')
return this.query(predicates)
}
}

所有查询都带 user_id 过滤,切换档案时只需要切换 currentUserId。

数据安全

securityLevel: relationalStore.SecurityLevel.S1 设置了安全级别。S1 级别表示:

  • 数据加密存储
  • 设备锁定后数据不可访问
  • 适合存储用户学习数据

对于更敏感的数据(如登录凭证),使用 preferences KV 存储,安全级别可以设到 S3。

离线优先不是”网络不重要”,是”网络不可靠”。把本地数据做扎实,网络是锦上添花。