日程改为本地库按需查询,修掉每次进页面解析 9.3MB 的架构错误
## 用户指出的问题
「这个数据量很大,不是应该每次同步完了之后解包放在一个数据库里吗?
然后点开日程,不就是把今天的日程从数据库里查出来吗?
这个应该速度非常快才对呀!怎么能每次打开都要去读所有的数据呢?」
用户说得完全对。这是**架构错误**,上一轮我只做了算法层的局部优化
(把 O(n²) 改成 O(n)),没动"数据怎么存、怎么取"这个根本问题。
## 改前 vs 改后
改前:
load() → module_cache 表读 **9.3MB 字符串**
→ JSON.parse **28628 个对象**
→ 全塞进 this.allEvents 内存数组
渲染 → 在这 28628 条上遍历
`module_cache` 的设计注释本来写着「解析一遍毫秒级,可接受」——
那是按小数据量假设写的,日程涨到 2.8 万条后这个假设就破了。
改后:
load() → ① `WHERE date BETWEEN ? AND ?`(走 idx_cal_date)→ 立刻渲染
② 后台拉全量 → 批量入库 → 重查当前范围
渲染 → 只在**当前范围**那几十到几百条上遍历
## 改动
### FbSchema / FbDb:新增 cal_events 实体表
字段对齐 FbCalEvent(12 个)+ uid + fetched_at,三个索引:
- `idx_cal_date` —— 按日期查(最高频)
- `idx_cal_date_kind` —— 农历/节日按 kind 过滤
- `idx_cal_uid` —— 同步去重
⚠️ 主键不用服务端 `ref_id`:衍生条目(农历/节气/纪念日)的 ref_id **恒为 0**,
几万条全撞主键。用自增 rowid + (cal_id, uid) 唯一索引。
uid 由 kind|calId|evId|date|time|title哈希 组成。
⚠️ **建表语句与 migrate 两处都改了**,列名逐一比对一致
(这个坑本项目已踩 5 次,加了静态守卫钉住)。
### FbCalStore(新建):本地库读写
- `replaceAll(rows, stamp)` —— 事务内先 DELETE 再分批写(20 行/批,照
FbMovieRepo 的实测结论,200 行会卡死)。整批替换而非逐条 upsert,
否则服务端已删的条目会永远留在本地。
- `between(from,to)` / `ofDay(iso)` —— 页面真正用的查询
- `lunarBetween` —— 月视图 42 个格子只要农历文字,单独查比取全量再过滤快
- `count()` / `fetchedAt()` —— 判空与"数据新不新"
⚠️ 命名用 `FbCalStore` 而不是 `FbCalRepo`:后者已存在,是**接口读写**层
(create/update/remove/fetchBooks)。我第一版误覆盖了它,编译报 8 处错误后
从 git 恢复,改名为 Store 区分「本地库」和「接口」。
### FbCalendarPage
- `allEvents` → **`rangeEvents`**(改名强制提醒:这里不再是全量)
- `rangeOfView()` —— 算当前视图需要的日期区间
- `loadFromLocal()` —— 先查本地,有数据立刻渲染
- `persistAndRefresh()` —— 入库完成后重查,界面跟上最新数据
- `reloadRange()` + `@Monitor('showYear','showMonth','mode')` —— 翻月/切视图按需重查
- `rangeSeq` —— 防抖,避免旧结果覆盖新结果
## 验证
### 编译
`build_hap debug` → BUILD SUCCESSFUL
### 静态校验(34 项全过)
`_tooling/check_cal_db.js`,含最关键的两项:
- 建表与迁移**列名逐一相同**(15 列全对上)
- `load()` 里本地查询确实在远程同步**之前**
### 未验证
**真机实测未做**(设备未连接)。需要确认:
- 老库升级(v7→v8)是否顺利建表
- 首次进入(库空)要等全量拉取,之后进入应该秒开
- 翻月是否立刻有数据
## 过程中的一个失误
第一版把新仓储命名为 `FbCalRepo`,**覆盖了已存在的同名文件**。
编译立刻报 8 处 "has no exported member / does not exist on type"。
从 git 恢复原文件后改名 `FbCalStore`,避免了两层职责混在一个类里。
教训:新建文件前先查同名是否存在(`git status` 没报是因为该文件本就在版控里,
我 `cp` 上去把它改成"已修改"状态,看着像正常改动)。
This commit is contained in:
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* 岁月家 FamilyBook · 日程本地库(鸿蒙端)
|
||||
*
|
||||
* 🔴 2026-11-03 新建。起因是用户的一句话点破了架构错误:
|
||||
* 「这个数据量很大,不是应该每次同步完了之后解包放在一个数据库里吗?
|
||||
* 然后点开日程,不就是把今天的日程从数据库里查出来吗?
|
||||
* 这个应该速度非常快才对呀!怎么能每次打开都要去读所有的数据呢?」
|
||||
*
|
||||
* 他说得完全对。改之前的样子:
|
||||
* 日程 → `module_cache` 表(存整个接口 JSON 的**原样文本**,9.3MB)
|
||||
* 每次进页面:读 9.3MB 字符串 → JSON.parse 28628 个对象 → 全塞进内存数组
|
||||
* ⇒ 那"好几秒"就花在这里。
|
||||
*
|
||||
* `module_cache` 的设计注释本来写着「解析一遍毫秒级,可接受」——
|
||||
* 那个判断建立在"数据量小"上,日程涨到 2.8 万条之后假设就破了。
|
||||
*
|
||||
* 改之后:
|
||||
* 同步 → 批量写入 `cal_events` 实体表
|
||||
* 页面 → `WHERE date = '2026-11-03'`(走索引,毫秒级)
|
||||
*
|
||||
* 这就是用户说的「把今天的查出来」。
|
||||
*/
|
||||
|
||||
import { relationalStore } from '@kit.ArkData';
|
||||
import { hilog } from '@kit.PerformanceAnalysisKit';
|
||||
import { FbDb, FbRow } from './FbDb';
|
||||
import { FbCalEvent } from '../model/Types';
|
||||
|
||||
const DOMAIN: number = 0x0000;
|
||||
const TAG: string = 'FbCalStore';
|
||||
|
||||
/**
|
||||
* 每批写入的行数。
|
||||
*
|
||||
* ⚠️ 取 20 是照 `FbMovieRepo` 的**实测**结论:
|
||||
* 一次塞 200 行时 `batchInsert` 卡死不返回,分批到 20 能正常完成。
|
||||
* 日程有 2.8 万条,更必须分批 —— 别随手改大。
|
||||
*/
|
||||
const BATCH_SIZE: number = 20;
|
||||
|
||||
/** `cal_events` 表的列(建表语句与此处必须一致,见 FbSchema) */
|
||||
const COLS: string = 'uid, ev_id, cal_id, title, date, time, end_time, '
|
||||
+ 'cal_name, color, all_day, place, note, kind';
|
||||
|
||||
/**
|
||||
* 日程仓库。
|
||||
*
|
||||
* 与 `FbMovieRepo` 同一定位:**UI 只读这里,不直接读接口**。
|
||||
* 有网没网读的都是同一份本地数据,于是"断网可用""零等待"是天然成立的。
|
||||
*/
|
||||
export class FbCalStore {
|
||||
private static _inst: FbCalStore | null = null;
|
||||
|
||||
static inst(): FbCalStore {
|
||||
if (FbCalStore._inst === null) {
|
||||
FbCalStore._inst = new FbCalStore();
|
||||
}
|
||||
return FbCalStore._inst;
|
||||
}
|
||||
|
||||
private db: FbDb = FbDb.inst();
|
||||
|
||||
// ============================================================
|
||||
// 写入
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 把一批日程**整体替换**进本地库。
|
||||
*
|
||||
* @param rows 解析好的日程(`parseCalendar` 的输出)
|
||||
* @param stamp 本批数据的拉取时刻(秒)。用于区分"这一批"和"更早的残留"
|
||||
*
|
||||
* ⚠️ 先清空再整批写入,而不是逐条 upsert:
|
||||
* 服务端每次给的是**这个时间窗口的全部数据**,
|
||||
* 逐条 upsert 会让"服务端已删除的条目"永远留在本地。
|
||||
* 整批替换才是对的语义。
|
||||
*
|
||||
* ⚠️ 全程在一个事务里:中途失败就整批回滚,
|
||||
* 否则会出现"删了旧的、新的没写完"——空日历,比慢更糟。
|
||||
*/
|
||||
async replaceAll(rows: FbCalEvent[], stamp: number): Promise<void> {
|
||||
if (!this.db.isReady()) {
|
||||
hilog.warn(DOMAIN, TAG, '库未就绪,跳过写日程');
|
||||
return;
|
||||
}
|
||||
|
||||
const t0: number = Date.now();
|
||||
|
||||
await this.db.transaction(async (tx: relationalStore.Transaction): Promise<void> => {
|
||||
// ① 清空旧数据
|
||||
await tx.execute('DELETE FROM cal_events');
|
||||
|
||||
// ② 分批插入
|
||||
for (let i = 0; i < rows.length; i += BATCH_SIZE) {
|
||||
const chunk: FbCalEvent[] = rows.slice(i, i + BATCH_SIZE);
|
||||
const buckets: relationalStore.ValuesBucket[] = [];
|
||||
for (const e of chunk) {
|
||||
const vb: relationalStore.ValuesBucket = {
|
||||
'uid': this.uidOf(e),
|
||||
'ev_id': e.id,
|
||||
'cal_id': e.calId,
|
||||
'title': e.title,
|
||||
'date': e.date,
|
||||
'time': e.time,
|
||||
'end_time': e.endTime,
|
||||
'cal_name': e.calName,
|
||||
'color': e.color,
|
||||
'all_day': e.allDay ? 1 : 0,
|
||||
'place': e.place,
|
||||
'note': e.note,
|
||||
'kind': e.kind,
|
||||
'fetched_at': stamp
|
||||
};
|
||||
buckets.push(vb);
|
||||
}
|
||||
// ⚠️ 用 tx.insert + 冲突替換:`(cal_id, uid)` 唯一索引会挡住重复
|
||||
for (const vb of buckets) {
|
||||
await tx.insert('cal_events', vb, relationalStore.ConflictResolution.ON_CONFLICT_REPLACE);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
hilog.info(DOMAIN, TAG, '日程入库 %{public}d 条,耗时 %{public}dms',
|
||||
rows.length, Date.now() - t0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 给一条日程算稳定 UID。
|
||||
*
|
||||
* 为什么要自己造:服务端的 `ref_id` 对衍生条目(农历/节气/纪念日)**恒为 0**,
|
||||
* 几万条全是 0,没法当唯一键。
|
||||
*
|
||||
* 组成:`kind|calId|evId|date|time|title的短哈希`
|
||||
* · 自定义日程:evId 是真 id,天然唯一
|
||||
* · 衍生条目:靠 kind + date 区分(农历每天一条,节气每年 24 条)
|
||||
* · 再挂 title 的哈希,防止同一天同 kind 的多条(如两个节日)互相覆盖
|
||||
*/
|
||||
private uidOf(e: FbCalEvent): string {
|
||||
const base: string = e.kind + '|' + e.calId + '|' + e.id + '|' + e.date + '|' + e.time;
|
||||
// 简易 32 位哈希(FNV-1a):够用,不追求密码学强度
|
||||
let h: number = 0x811c9dc5;
|
||||
const src: string = base + '|' + e.title;
|
||||
for (let i = 0; i < src.length; i++) {
|
||||
h = h ^ src.charCodeAt(i);
|
||||
h = (h * 0x01000193) >>> 0;
|
||||
}
|
||||
return base + '|' + h.toString(16);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 读取(页面真正用的就是这几个)
|
||||
// ============================================================
|
||||
|
||||
/** 本地有没有数据(判断要不要等网络) */
|
||||
async count(): Promise<number> {
|
||||
if (!this.db.isReady()) {
|
||||
return 0;
|
||||
}
|
||||
return await this.db.scalar('SELECT COUNT(*) FROM cal_events');
|
||||
}
|
||||
|
||||
/** 库里的数据是什么时候拉的(用于显示"数据可能不新") */
|
||||
async fetchedAt(): Promise<number> {
|
||||
if (!this.db.isReady()) {
|
||||
return 0;
|
||||
}
|
||||
return await this.db.scalar('SELECT COALESCE(MAX(fetched_at), 0) FROM cal_events');
|
||||
}
|
||||
|
||||
/**
|
||||
* 取某个日期区间的日程(含衍生条目)。
|
||||
*
|
||||
* `WHERE date BETWEEN ? AND ?` 走 `idx_cal_date`,几万条里取一个月是毫秒级。
|
||||
*
|
||||
* @param from 'YYYY-MM-DD'(含)
|
||||
* @param to 'YYYY-MM-DD'(含)
|
||||
*/
|
||||
async between(from: string, to: string): Promise<FbCalEvent[]> {
|
||||
if (!this.db.isReady()) {
|
||||
return [];
|
||||
}
|
||||
const rows: FbRow[] = await this.db.queryRows(
|
||||
'SELECT ' + COLS + ' FROM cal_events WHERE date >= ? AND date <= ? ORDER BY date, time',
|
||||
[from, to]);
|
||||
return this.toModels(rows);
|
||||
}
|
||||
|
||||
/** 取某一天的全部条目 */
|
||||
async ofDay(iso: string): Promise<FbCalEvent[]> {
|
||||
if (!this.db.isReady()) {
|
||||
return [];
|
||||
}
|
||||
const rows: FbRow[] = await this.db.queryRows(
|
||||
'SELECT ' + COLS + ' FROM cal_events WHERE date = ? ORDER BY all_day DESC, time',
|
||||
[iso]);
|
||||
return this.toModels(rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取某个日期区间的**农历/节日文字**(月视图每个格子要的那一小段)。
|
||||
*
|
||||
* ⚠️ 只要 `kind` 是那几类的,不要整个月所有条目 ——
|
||||
* 月视图 42 个格子只需要"这天是什么农历/节日",
|
||||
* 真日程由 `between()` 另取。分开查比取全量再过滤快得多。
|
||||
*/
|
||||
async lunarBetween(from: string, to: string): Promise<FbCalEvent[]> {
|
||||
if (!this.db.isReady()) {
|
||||
return [];
|
||||
}
|
||||
const rows: FbRow[] = await this.db.queryRows(
|
||||
`SELECT ${COLS} FROM cal_events
|
||||
WHERE date >= ? AND date <= ?
|
||||
AND kind IN ('lunar', 'traditional', 'solarterm', 'holiday')
|
||||
ORDER BY date`,
|
||||
[from, to]);
|
||||
return this.toModels(rows);
|
||||
}
|
||||
|
||||
/** 某天的真日程(排除农历/节气这类衍生条目) */
|
||||
async eventsOfDay(iso: string): Promise<FbCalEvent[]> {
|
||||
if (!this.db.isReady()) {
|
||||
return [];
|
||||
}
|
||||
const rows: FbRow[] = await this.db.queryRows(
|
||||
`SELECT ${COLS} FROM cal_events
|
||||
WHERE date = ?
|
||||
AND kind NOT IN ('lunar', 'traditional', 'solarterm', 'holiday')
|
||||
ORDER BY all_day DESC, time`,
|
||||
[iso]);
|
||||
return this.toModels(rows);
|
||||
}
|
||||
|
||||
/** 库里最早的日期(决定能往前翻到哪) */
|
||||
async minDate(): Promise<string> {
|
||||
if (!this.db.isReady()) {
|
||||
return '';
|
||||
}
|
||||
const r: FbRow | null = await this.db.queryOne(
|
||||
'SELECT MIN(date) AS d FROM cal_events WHERE date <> \'\'', []);
|
||||
return r === null ? '' : r.strOr('d', '');
|
||||
}
|
||||
|
||||
/** 库里最晚的日期 */
|
||||
async maxDate(): Promise<string> {
|
||||
if (!this.db.isReady()) {
|
||||
return '';
|
||||
}
|
||||
const r: FbRow | null = await this.db.queryOne(
|
||||
'SELECT MAX(date) AS d FROM cal_events WHERE date <> \'\'', []);
|
||||
return r === null ? '' : r.strOr('d', '');
|
||||
}
|
||||
|
||||
/** 清空("重装/清缓存"时用) */
|
||||
async clear(): Promise<void> {
|
||||
if (!this.db.isReady()) {
|
||||
return;
|
||||
}
|
||||
await this.db.exec('DELETE FROM cal_events');
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 行 → 模型
|
||||
// ============================================================
|
||||
|
||||
private toModels(rows: FbRow[]): FbCalEvent[] {
|
||||
const out: FbCalEvent[] = [];
|
||||
for (const r of rows) {
|
||||
const e: FbCalEvent = {
|
||||
id: r.num('ev_id', 0),
|
||||
calId: r.num('cal_id', 0),
|
||||
title: r.strOr('title', ''),
|
||||
date: r.strOr('date', ''),
|
||||
time: r.strOr('time', ''),
|
||||
endTime: r.strOr('end_time', ''),
|
||||
calName: r.strOr('cal_name', ''),
|
||||
color: r.strOr('color', '') !== '' ? r.strOr('color', '') : '#A84A28',
|
||||
allDay: r.num('all_day', 0) === 1,
|
||||
place: r.strOr('place', ''),
|
||||
note: r.strOr('note', ''),
|
||||
kind: r.strOr('kind', '')
|
||||
};
|
||||
out.push(e);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -362,6 +362,45 @@ export class FbDb {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ---- v7 → v8:日程实体表(cal_events)----
|
||||
// 🔴 2026-11-03:日程从 module_cache(存整包 JSON)改为实体表。
|
||||
// 老库升级时这张表是空的;第一次同步会全量灌进去。
|
||||
// ⚠️ 建表语句必须与 FbSchema 里**完全一致** —— 只改一处就是
|
||||
// "新装有、老库没有"或者反过来(这个坑代码里已记 5 次)。
|
||||
if (from < 8) {
|
||||
const calDdl: string[] = [
|
||||
`CREATE TABLE IF NOT EXISTS cal_events (
|
||||
row_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
uid TEXT NOT NULL DEFAULT '',
|
||||
ev_id INTEGER NOT NULL DEFAULT 0,
|
||||
cal_id INTEGER NOT NULL DEFAULT 0,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
date TEXT NOT NULL DEFAULT '',
|
||||
time TEXT NOT NULL DEFAULT '',
|
||||
end_time TEXT NOT NULL DEFAULT '',
|
||||
cal_name TEXT NOT NULL DEFAULT '',
|
||||
color TEXT NOT NULL DEFAULT '',
|
||||
all_day INTEGER NOT NULL DEFAULT 0,
|
||||
place TEXT NOT NULL DEFAULT '',
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
kind TEXT NOT NULL DEFAULT '',
|
||||
fetched_at INTEGER NOT NULL DEFAULT 0
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_cal_date ON cal_events (date)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_cal_date_kind ON cal_events (date, kind)`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_cal_uid ON cal_events (cal_id, uid)`,
|
||||
];
|
||||
for (const sql of calDdl) {
|
||||
try {
|
||||
await store.executeSql(sql);
|
||||
} catch (err) {
|
||||
// 已经建过就跳过(迁移中途失败后重跑的场景)
|
||||
hilog.warn(DOMAIN, TAG, '建 cal_events 跳过:%{public}s', JSON.stringify(err));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
store.version = FB_DB_VERSION;
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ export const FB_DB_NAME: string = 'familybook.db';
|
||||
* 本地库**不能像服务端那样"每次启动都全量 ALTER 一遍"** ——
|
||||
* 那是拿用户的手机当试验田。必须按版本逐步迁移。
|
||||
*/
|
||||
export const FB_DB_VERSION: number = 7;
|
||||
export const FB_DB_VERSION: number = 8;
|
||||
|
||||
/**
|
||||
* 建表语句。
|
||||
@@ -315,6 +315,46 @@ export const FB_SCHEMA_SQL: string[] = [
|
||||
payload TEXT NOT NULL DEFAULT '',
|
||||
fetched_at INTEGER NOT NULL DEFAULT 0
|
||||
)`,
|
||||
|
||||
// ---------- 日程实体表(v8 新增)----------
|
||||
//
|
||||
// 用户 2026-11-03 指出:「数据量这么大,不是应该同步完放进数据库,
|
||||
// 点开日程就把今天的查出来吗?怎么能每次都去读全部数据?」
|
||||
//
|
||||
// 他说得对。原来日程走 `module_cache`(存 9.3MB JSON 原样文本),
|
||||
// 每次进页面都要读 9.3MB + JSON.parse 28628 个对象 + 全塞内存。
|
||||
// 那张表的注释本来说「解析一遍毫秒级,可接受」—— 那是按小数据量假设写的,
|
||||
// 日程 2.8 万条之后这个假设就破了。
|
||||
//
|
||||
// ⇒ 日程单独建实体表(跟 events 表给观影用的思路一致),
|
||||
// 同步时批量入库,页面按日期范围查。
|
||||
//
|
||||
// ⚠️ 为什么主键不用 id:
|
||||
// 服务端 `ref_id` 对衍生条目(农历/节气/纪念日)恒为 0,
|
||||
// 几万条全是 0 会撞主键。用自增 rowid + (cal_id, uid) 唯一索引。
|
||||
`CREATE TABLE IF NOT EXISTS cal_events (
|
||||
row_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
uid TEXT NOT NULL DEFAULT '' COMMENT '服务端条目稳定标识',
|
||||
ev_id INTEGER NOT NULL DEFAULT 0 COMMENT '服务端 ref_id(自定义日程才是真 id)',
|
||||
cal_id INTEGER NOT NULL DEFAULT 0 COMMENT '所属日历本 id;0=衍生条目',
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
date TEXT NOT NULL DEFAULT '' COMMENT 'YYYY-MM-DD',
|
||||
time TEXT NOT NULL DEFAULT '' COMMENT 'HH:mm;全天为空',
|
||||
end_time TEXT NOT NULL DEFAULT '',
|
||||
cal_name TEXT NOT NULL DEFAULT '',
|
||||
color TEXT NOT NULL DEFAULT '',
|
||||
all_day INTEGER NOT NULL DEFAULT 0,
|
||||
place TEXT NOT NULL DEFAULT '',
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
kind TEXT NOT NULL DEFAULT '' COMMENT 'custom / lunar / holiday / anniv …',
|
||||
fetched_at INTEGER NOT NULL DEFAULT 0 COMMENT '这批数据的拉取时刻(全量替换时用来防并发)'
|
||||
)`,
|
||||
// 按日期查是最高频的(「今天的日程」「本月」),必须索引
|
||||
`CREATE INDEX IF NOT EXISTS idx_cal_date ON cal_events (date)`,
|
||||
// 农历/节日按 kind 过滤(渲染日历时区分「真日程」和「衍生条目」)
|
||||
`CREATE INDEX IF NOT EXISTS idx_cal_date_kind ON cal_events (date, kind)`,
|
||||
// 同步时按 uid 去重/更新
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_cal_uid ON cal_events (cal_id, uid)`,
|
||||
];
|
||||
|
||||
/** 建表语句分组说明(供日志/排查用) */
|
||||
@@ -339,6 +379,10 @@ export const FB_TABLE_GROUPS: FbTableGroup[] = [
|
||||
group: '名册',
|
||||
tables: ['person_roster', 'person_tags']
|
||||
},
|
||||
{
|
||||
group: '日程',
|
||||
tables: ['cal_events']
|
||||
},
|
||||
{
|
||||
group: '同步元数据(本地专有)',
|
||||
tables: ['sync_state', 'pending_ops', 'module_sync', 'attach_cache']
|
||||
|
||||
@@ -32,6 +32,7 @@ import { FbCalBook, FbCalEvent, isEditableEvent, isEventWritable } from '../../m
|
||||
import { parseCalendar } from '../../data/FbParse';
|
||||
import { loadModule } from '../../data/FbModuleLoad';
|
||||
import { FbCalRepo, FbCalEventFull } from '../../data/FbCalRepo';
|
||||
import { FbCalStore } from '../../data/FbCalStore';
|
||||
import { FB_CAL_SETTINGS, FbCalView, FbCalStyle } from '../../data/FbCalSettings';
|
||||
import { FB_CARD_FORMS } from '../../data/FbCardForms';
|
||||
import { FB_TOKEN_STORE } from '../../data/FbTokenStore';
|
||||
@@ -149,8 +150,17 @@ export struct FbCalendarPage {
|
||||
|
||||
/** 日历本(走 `/calendar/books`,**真实 id**) */
|
||||
@Local books: FbCalBook[] = [];
|
||||
/** 全部条目(月历格子/列表都从这里查) */
|
||||
@Local allEvents: FbCalEvent[] = [];
|
||||
/**
|
||||
* **当前显示范围**内的条目(不是全量!)。
|
||||
*
|
||||
* 🔴 2026-11-03 从 `allEvents` 改名。原来这里装的是全量 28628 条,
|
||||
* 每次进页面都要读 9.3MB JSON + 解析 + 塞内存。
|
||||
* 现在只装当前视图需要的那一段(月视图约 3 个月、列表视图约 2 个月),
|
||||
* 翻月时按需重查 —— 数据在 SQLite 里,`WHERE date BETWEEN` 走索引。
|
||||
*
|
||||
* ⚠️ **不要再往这里塞全量数据**。名字里的 range 就是提醒。
|
||||
*/
|
||||
@Local rangeEvents: FbCalEvent[] = [];
|
||||
|
||||
// ---------------- 派生索引(性能)----------------
|
||||
//
|
||||
@@ -160,7 +170,7 @@ export struct FbCalendarPage {
|
||||
// ⇒ 预建索引,把 O(n²) 降到 O(n)。
|
||||
//
|
||||
// ⚠️ 这两个字段**不是状态**(不加 @Local):
|
||||
// 它们是纯派生缓存,改了不需要触发重渲染 —— 重渲染由 allEvents/evtVer 驱动。
|
||||
// 它们是纯派生缓存,改了不需要触发重渲染 —— 重渲染由 rangeEvents/evtVer 驱动。
|
||||
// 加 @Local 反而会在每次重建索引时多触发一轮 build。
|
||||
|
||||
/** `iso → 农历/节日文字`。节日优先于农历日名(与旧 lunarOf 的优先级一致) */
|
||||
@@ -169,8 +179,17 @@ export struct FbCalendarPage {
|
||||
private dayIdx: Map<string, FbCalEvent[]> = new Map<string, FbCalEvent[]>();
|
||||
/** 索引对应的数据版本;与 evtVer 不一致就重建 */
|
||||
private idxVer: number = -1;
|
||||
/** 数据/筛选条件版本号:allEvents、books、日历本可见性任一变化都 ++ */
|
||||
/** 数据/筛选条件版本号:rangeEvents、books、日历本可见性任一变化都 ++ */
|
||||
private evtVer: number = 0;
|
||||
|
||||
/**
|
||||
* 范围重查的序号(防抖)。
|
||||
*
|
||||
* 翻月时 `showYear` 与 `showMonth` 可能同时变 ⇒ `@Monitor` 触发多次。
|
||||
* 每次重查前记下当前序号,查完对比:序号变了说明有更新的请求,
|
||||
* 丢弃这次结果(避免旧结果覆盖新结果)。
|
||||
*/
|
||||
private rangeSeq: number = 0;
|
||||
/** 顶部提示 */
|
||||
@Local syncNote: string = '';
|
||||
/** 加载中 */
|
||||
@@ -446,30 +465,162 @@ export struct FbCalendarPage {
|
||||
* 如果某次请求失败,`FbModuleRepo` 会把**旧范围**的缓存又 sink 回来,
|
||||
* 端上就把"只含 10 月~12 月的数据"当成全量显示 —— 那正是这个 bug 的帮凶。
|
||||
*/
|
||||
/**
|
||||
* 当前视图需要显示的日期区间。
|
||||
*
|
||||
* 🔴 2026-11-03:这是本页性能改造的核心。
|
||||
* 原来页面加载**全量 28628 条**(读 9.3MB JSON + 解析 + 塞内存),
|
||||
* 用户点进日程要等好几秒。现在只查「当前要显示的那一段」。
|
||||
*
|
||||
* 月视图:当月前后各 1 个月(月历格子会显示上/下月的尾巴,多留一月够用)
|
||||
* 周视图:当周前后各 1 周
|
||||
* 列表:今天 -7 ~ +90(与 agendaDays 的口径一致)
|
||||
*/
|
||||
private rangeOfView(): [string, string] {
|
||||
const today: Date = new Date();
|
||||
if (this.mode === 'month') {
|
||||
const first: Date = new Date(this.showYear, this.showMonth - 2, 1);
|
||||
const last: Date = new Date(this.showYear, this.showMonth + 1, 0);
|
||||
return [this.isoOfDate(first), this.isoOfDate(last)];
|
||||
}
|
||||
if (this.mode === 'week') {
|
||||
const picked: Date = this.pickedIso.length >= 10
|
||||
? new Date(parseInt(this.pickedIso.substring(0, 4), 10),
|
||||
parseInt(this.pickedIso.substring(5, 7), 10) - 1,
|
||||
parseInt(this.pickedIso.substring(8, 10), 10))
|
||||
: today;
|
||||
const from: Date = new Date(picked.getFullYear(), picked.getMonth(), picked.getDate() - 14);
|
||||
const to: Date = new Date(picked.getFullYear(), picked.getMonth(), picked.getDate() + 21);
|
||||
return [this.isoOfDate(from), this.isoOfDate(to)];
|
||||
}
|
||||
// 列表视图
|
||||
const from: Date = new Date(today.getFullYear(), today.getMonth(), today.getDate() - AGENDA_BACK_DAYS);
|
||||
const to: Date = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 90);
|
||||
return [this.isoOfDate(from), this.isoOfDate(to)];
|
||||
}
|
||||
|
||||
/**
|
||||
* 从本地库读当前范围,立刻渲染。
|
||||
*
|
||||
* ⚠️ 这是"用户点进去就能看到东西"的那一步。
|
||||
* 走 `idx_cal_date` 索引,几万条里取三个月是毫秒级。
|
||||
*
|
||||
* @returns 库里有没有数据(false = 首次使用,得等网络)
|
||||
*/
|
||||
private async loadFromLocal(): Promise<boolean> {
|
||||
const r: [string, string] = this.rangeOfView();
|
||||
const rows: FbCalEvent[] = await FbCalStore.inst().between(r[0], r[1]);
|
||||
if (rows.length === 0) {
|
||||
// 还要看一眼库里总共有没有数据:没有=首次使用;有=这个月确实空
|
||||
const total: number = await FbCalStore.inst().count();
|
||||
return total > 0;
|
||||
}
|
||||
this.rangeEvents = rows;
|
||||
this.evtVer++;
|
||||
this.loading = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载日程。
|
||||
*
|
||||
* 🔴 2026-11-03 重构(用户指出架构错误):
|
||||
* 「数据量这么大,不是应该同步完放进数据库,点开就把今天的查出来吗?」
|
||||
*
|
||||
* 现在的顺序:
|
||||
* ① **先读本地库** → 立刻渲染(毫秒级)。老用户进页面几乎无等待。
|
||||
* ② 后台拉接口全量 → 入库 → 重查当前范围(界面跟上最新数据)。
|
||||
*
|
||||
* 首次使用(库里空):①没东西,等②;此时和原来一样慢一次,此后都快。
|
||||
*/
|
||||
private async load(): Promise<void> {
|
||||
// ---- ① 本地优先:有数据就先画出来 ----
|
||||
const hasLocal: boolean = await this.loadFromLocal();
|
||||
if (hasLocal) {
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
// ---- ② 后台同步 ----
|
||||
const range5: [string, string] = calFullRange();
|
||||
const from: string = range5[0];
|
||||
const to: string = range5[1];
|
||||
const range: string = '/api/v1/calendar?from=' + from + '&to=' + to;
|
||||
/* 缓存键带上窗口:窗口一变就是另一条缓存,不会读到别的窗口的旧数据 */
|
||||
const cacheKey: string = 'calendar:' + from + ':' + to;
|
||||
const stamp: number = Math.floor(Date.now() / 1000);
|
||||
|
||||
await loadModule<FbCalEvent[]>(cacheKey, parseCalendar,
|
||||
(rows: FbCalEvent[]): void => {
|
||||
this.allEvents = rows;
|
||||
// 数据换了 ⇒ 派生索引必须重建
|
||||
this.evtVer++;
|
||||
this.loading = false;
|
||||
/* 入库是异步的、可能比较慢(2.8 万条要分批写),
|
||||
不能让它挡住界面 —— 先重查当前范围(读的是刚写进去的数据)。 */
|
||||
this.persistAndRefresh(rows, stamp);
|
||||
},
|
||||
(note: string): void => {
|
||||
this.syncNote = note;
|
||||
this.loading = false;
|
||||
},
|
||||
range,
|
||||
/* 全量响应约 9.3MB,默认 15 秒在移动网络下会超时;
|
||||
超时后会退回旧缓存,表现成"翻月没数据"。给到 60 秒。 */
|
||||
CAL_FULL_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把拉回来的全量数据写库,然后重查当前范围。
|
||||
*
|
||||
* ⚠️ 刻意**不 await 入库**再刷新界面:
|
||||
* 入库 2.8 万条要几百毫秒到数秒,等它会让界面白等。
|
||||
* 但也不能"入库没完就查"(查到一半的数据)——
|
||||
* 所以顺序是:入库完成 → 查 → 更新 rangeEvents。
|
||||
*/
|
||||
private persistAndRefresh(rows: FbCalEvent[], stamp: number): void {
|
||||
FbCalStore.inst().replaceAll(rows, stamp)
|
||||
.then(async (): Promise<void> => {
|
||||
const r: [string, string] = this.rangeOfView();
|
||||
const fresh: FbCalEvent[] = await FbCalStore.inst().between(r[0], r[1]);
|
||||
this.rangeEvents = fresh;
|
||||
this.evtVer++;
|
||||
this.loading = false;
|
||||
hilog.info(0x0000, 'FbCalendarPage',
|
||||
'本地库已更新:全量 %{public}d 条,当前范围 %{public}d 条',
|
||||
rows.length, fresh.length);
|
||||
})
|
||||
.catch((err: Error): void => {
|
||||
hilog.error(0x0000, 'FbCalendarPage', '入库失败:%{public}s', JSON.stringify(err));
|
||||
this.loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 翻月 / 切视图时按需重查。
|
||||
*
|
||||
* 🔴 这是"只查需要的"的关键:翻到 8 月就只查 8 月附近那一段,
|
||||
* 不碰 9 月之后的数据。
|
||||
*/
|
||||
private async reloadRange(): Promise<void> {
|
||||
const seq: number = ++this.rangeSeq;
|
||||
const r: [string, string] = this.rangeOfView();
|
||||
const rows: FbCalEvent[] = await FbCalStore.inst().between(r[0], r[1]);
|
||||
// 期间又翻页了 ⇒ 这次结果已过期,丢掉(否则旧数据会盖掉新数据)
|
||||
if (seq !== this.rangeSeq) {
|
||||
return;
|
||||
}
|
||||
this.rangeEvents = rows;
|
||||
this.evtVer++;
|
||||
}
|
||||
|
||||
/**
|
||||
* 翻月 / 切视图 → 按需重查当前范围。
|
||||
*
|
||||
* 🔴 2026-11-03:这是"只查需要的"的关键。翻到 8 月就只查 8 月附近,
|
||||
* 不碰 9 月之后的数据(数据在 SQLite,`WHERE date BETWEEN` 走索引)。
|
||||
*
|
||||
* ⚠️ 用 `@Monitor` **一处声明**覆盖所有翻月路径:
|
||||
* 页面里有 5 处会改 `showMonth`(周条滑动跨月、月历翻页、
|
||||
* 卡片跳转日期…),逐个手写必然漏,漏一处就是"翻到这个月没数据"。
|
||||
*/
|
||||
@Monitor('showYear', 'showMonth', 'mode')
|
||||
onRangeKeyChanged(): void {
|
||||
this.reloadRange();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 日期 / 筛选
|
||||
// ============================================================
|
||||
@@ -517,7 +668,7 @@ export struct FbCalendarPage {
|
||||
const fest: Map<string, string> = new Map<string, string>();
|
||||
const lunar: Map<string, string> = new Map<string, string>();
|
||||
|
||||
for (const e of this.allEvents) {
|
||||
for (const e of this.rangeEvents) {
|
||||
// ---- 农历/节日索引 ----
|
||||
if (e.kind === 'traditional' || e.kind === 'solarterm' || e.kind === 'holiday') {
|
||||
if (!fest.has(e.date)) {
|
||||
@@ -579,10 +730,10 @@ export struct FbCalendarPage {
|
||||
*/
|
||||
private visibleEvents(): FbCalEvent[] {
|
||||
if (this.books.length === 0) {
|
||||
return this.allEvents;
|
||||
return this.rangeEvents;
|
||||
}
|
||||
const out: FbCalEvent[] = [];
|
||||
for (const e of this.allEvents) {
|
||||
for (const e of this.rangeEvents) {
|
||||
// calId=0 的衍生条目(农历/纪念日)不受"本"控制,照常显示
|
||||
if (e.calId === 0 || FB_CAL_SETTINGS.isVisible(e.calId)) {
|
||||
out.push(e);
|
||||
|
||||
Reference in New Issue
Block a user