日程页加载慢:O(n²) 改 O(n) + 全量范围收窄(A+B)

## 问题

用户:「首页点悬浮按钮进日程,加载得好几秒」。
实测数据:`calendar_events` 2026 条 + 328 条 rrule,按范围展开后 **28628 条**。

## 根因:渲染路径上有 O(n²)

| 函数 | 每次成本 | 调用次数 |
|---|---|---|
| `lunarOf(iso)` | 遍历 28628 条 **×2 遍**(先找节日、再找农历) | 月视图每格 + 周视图每天 + 列表每天 |
| `visibleEvents()` | **新建数组** + 遍历 28628 条 | `eventsOf()` 每次调用,列表按天调 |

月视图 42 格 × 2 遍 × 28628 ≈ **240 万次比较**,全部同步跑在 build 路径上。

## A:索引化(客户端)

- 新增 `ensureIndex()`:**一次遍历**同时建 `lunarIdx`(iso→农历文字)
  和 `dayIdx`(iso→当天条目,已排序)
- `lunarOf()` 改为 O(1) 查 Map
- `eventsOf()` 改为 O(1) 查桶,返回副本(防止调用方 sort/push 改到索引)
- 缓存失效:`evtVer` 计数,`allEvents` / `books` 变化时 `++`;
  `idxVer` 记录索引对应版本,不一致才重建

⚠️ **语义必须一致**:旧 `lunarOf` 是「先找 traditional/solarterm/holiday,
找不到才用 kind=lunar」。建索引时用两个 Map 分级收集、最后按"节日优先"合并,
否则会出现「初一」盖掉「中秋」。

保留了这些特例:
- `calId === 0` 的衍生条目不受"本可见性"控制
- `books` 为空时全部通过
- 桶内按 `byTime` 排序

## B:范围收窄 + 服务端上限

### 客户端
`CAL_FULL_FROM/TO` 写死的 `1970-01-01 ~ 2100-12-31`(47846 天)
改为 `calFullRange()` 动态算「今年 ±5 年」(`CAL_SPAN_YEARS = 5`)。

理由:
- 服务端要为**每一天**生成农历条目 ⇒ 130 年范围要传上万条(约 6MB)
- 实际使用只看眼前几年
- 动态计算而非写死年份,避免随时间推移失效
- 缓存键仍带 from/to ⇒ 跨年后范围平移、旧缓存自然失效

### 服务端 `app/Holiday.php`(三处都修)

| 函数 | 原问题 | 修法 |
|---|---|---|
| `lunarRange()` | `$i < 400` ⇒ 只算前 400 天,**今天的农历空** | 去上限,改年数上限 30 年 + 夹 Lunar 支持范围 |
| `traditionalRange()` | 同样 400 天 ⇒ **今年春节/中秋全丢** | 同上 |
| `solarTermRange()` | 只取首末两年 ⇒ 130 年仅 2 年节气 | 改逐年遍历 + 年数上限 |

`MAX_LUNAR_YEARS = 30` 是防御性的:客户端只要 ±5 年,
留余量足够;同时防止有人手拼 1900~2100 把服务器拖死(算 7 万天农历需数十秒)。

## 验证

### 服务端实测(部署后)
```
今天 2026-11-02 → 「廿四」        ← 修复前为空
本月农历覆盖 30/30 天
今年 11 个传统节日、24 个节气     ← 齐全
2021~2031 农历 4017 条(应 4017) ← 修复前只有 400 条
2021~2031 节气 264 条(应 264)   ← 修复前只有 48 条
1970~2100 农历 10958 条,0.47 秒(按 30 年上限截断,符合设计)
```
> ⚠️ 中途发现改动**没上传到服务器**就跑了验证,一度得出"已修好"的错误结论。
> 比对 md5 才定位到。现在 `_tooling/check_cal_perf.js` 里有这项检查(7.1)。

### 静态校验
`_tooling/check_cal_perf.js`:**36 项全过**,含
- 渲染路径无全表遍历
- 索引语义与原实现一致(8 项)
- 缓存失效正确
- 范围收窄且动态
- 服务端三处上限已修
- 服务器文件 md5 与本地一致

### 编译
`build_hap debug` → `BUILD SUCCESSFUL`

### 未验证
**真机加载耗时未测**(设备当时未连接)。理论提速约 84 倍
(240 万次比较 → 28628 次遍历 + 42 次查表),但实际感知需真机确认。

## 顺带记录

服务端 `Holiday.php` 需要**单独上传**(它不在客户端仓库里)。
以后改服务端文件,验证前必须先比对服务器 md5,否则会拿旧代码验出假结果。
This commit is contained in:
2026-11-02 08:51:28 +08:00
parent 274b363852
commit 57a0ddd5ad
@@ -62,19 +62,30 @@ const AGENDA_BACK_DAYS: number = 7;
const AGENDA_FWD_DAYS: number = 60; const AGENDA_FWD_DAYS: number = 60;
/** /**
* 全量同步的请求范围(`load()` 用)。 * 全量同步的请求范围:**今年 ±5 年**。
* *
* 🔴 2026-10-30 用户:「全部拉取,全量同步」。 * 🔴 2026-11-02 修:原为写死的 `1970-01-01 ~ 2100-12-31`(47846 天)。
* 起因:切到下个月再点某一天,端上没数据(即使服务端有)。 * 服务端要为每一天生成农历条目 ⇒ 每次进页面要传上万条(约 6MB),
* 根因是范围写死成「今天-7 ~ +60」且切月不重拉 ⇒ 窗口外的日子永远空。 * 而用户实际只看眼前几年的日历。
* ⇒ 改成一次性拉全量,端上按日期筛,翻到哪个月都有。
* *
* ⚠️ 这两个值取得很宽(1970~2100),是因为服务端会按范围**展开重复日程**: * 为什么是 ±5 年:
* 本账号实测 28628 条 / 9.3MB / 1.3s。宁可一次多拉,也不要再出现"翻月没数据"。 * · 过去的日程不会早于用户开始用这个 App 的时间
* 流量成本由 `module_cache` 兜住(缓存键带窗口标识,见 `load()`)。 * · 未来的日程不会排到 5 年以后(长期事项是"提醒",不占日程)
* · 农历算法本身只支持 1900~2100,再宽的区间有一部分是白算
*
* ⚠️ **动态计算,不写死年份**:写死会随时间失效(2030 年时 2025 就成了远古)。
* 跨年后范围整体平移,缓存键(含 from/to)随之变化 ⇒ 旧缓存自然失效,
* 不会出现"拿去年的数据当今年"。
*/ */
const CAL_FULL_FROM: string = '1970-01-01'; const CAL_SPAN_YEARS: number = 5;
const CAL_FULL_TO: string = '2100-12-31';
/** 今天所在年份往前/后各 5 年的 1 月 1 日 / 12 月 31 日 */
function calFullRange(): [string, string] {
const y: number = new Date().getFullYear();
const from: string = (y - CAL_SPAN_YEARS).toString() + '-01-01';
const to: string = (y + CAL_SPAN_YEARS).toString() + '-12-31';
return [from, to];
}
/** /**
* 全量请求的超时(毫秒)。 * 全量请求的超时(毫秒)。
@@ -140,6 +151,26 @@ export struct FbCalendarPage {
@Local books: FbCalBook[] = []; @Local books: FbCalBook[] = [];
/** 全部条目(月历格子/列表都从这里查) */ /** 全部条目(月历格子/列表都从这里查) */
@Local allEvents: FbCalEvent[] = []; @Local allEvents: FbCalEvent[] = [];
// ---------------- 派生索引(性能)----------------
//
// 🔴 2026-11-02 用户:「点悬浮按钮进日程,加载得好几秒」。
// 根因:`lunarOf()` 与 `visibleEvents()` 每次调用都全表遍历 28628 条,
// 而它们都在渲染路径上(月视图 42 格 × 2 遍 ⇒ 约 240 万次比较)。
// ⇒ 预建索引,把 O(n²) 降到 O(n)。
//
// ⚠️ 这两个字段**不是状态**(不加 @Local):
// 它们是纯派生缓存,改了不需要触发重渲染 —— 重渲染由 allEvents/evtVer 驱动。
// 加 @Local 反而会在每次重建索引时多触发一轮 build。
/** `iso → 农历/节日文字`。节日优先于农历日名(与旧 lunarOf 的优先级一致) */
private lunarIdx: Map<string, string> = new Map<string, string>();
/** `iso → 当天条目`,供 eventsOf 直接取用 */
private dayIdx: Map<string, FbCalEvent[]> = new Map<string, FbCalEvent[]>();
/** 索引对应的数据版本;与 evtVer 不一致就重建 */
private idxVer: number = -1;
/** 数据/筛选条件版本号:allEvents、books、日历本可见性任一变化都 ++ */
private evtVer: number = 0;
/** 顶部提示 */ /** 顶部提示 */
@Local syncNote: string = ''; @Local syncNote: string = '';
/** 加载中 */ /** 加载中 */
@@ -384,6 +415,8 @@ export struct FbCalendarPage {
const list: FbCalBook[] = await FbCalRepo.inst().fetchBooks(token); const list: FbCalBook[] = await FbCalRepo.inst().fetchBooks(token);
if (list.length > 0) { if (list.length > 0) {
this.books = list; this.books = list;
// books 变了会影响 visibleEvents(books 为空时直接返回 allEvents)
this.evtVer++;
} }
} }
@@ -414,14 +447,17 @@ export struct FbCalendarPage {
* 端上就把"只含 10 月~12 月的数据"当成全量显示 —— 那正是这个 bug 的帮凶。 * 端上就把"只含 10 月~12 月的数据"当成全量显示 —— 那正是这个 bug 的帮凶。
*/ */
private async load(): Promise<void> { private async load(): Promise<void> {
const from: string = CAL_FULL_FROM; const range5: [string, string] = calFullRange();
const to: string = CAL_FULL_TO; const from: string = range5[0];
const to: string = range5[1];
const range: string = '/api/v1/calendar?from=' + from + '&to=' + to; const range: string = '/api/v1/calendar?from=' + from + '&to=' + to;
/* 缓存键带上窗口:窗口一变就是另一条缓存,不会读到别的窗口的旧数据 */ /* 缓存键带上窗口:窗口一变就是另一条缓存,不会读到别的窗口的旧数据 */
const cacheKey: string = 'calendar:' + from + ':' + to; const cacheKey: string = 'calendar:' + from + ':' + to;
await loadModule<FbCalEvent[]>(cacheKey, parseCalendar, await loadModule<FbCalEvent[]>(cacheKey, parseCalendar,
(rows: FbCalEvent[]): void => { (rows: FbCalEvent[]): void => {
this.allEvents = rows; this.allEvents = rows;
// 数据换了 ⇒ 派生索引必须重建
this.evtVer++;
this.loading = false; this.loading = false;
}, },
(note: string): void => { (note: string): void => {
@@ -457,7 +493,90 @@ export struct FbCalendarPage {
return this.isoOf(dt.getFullYear(), dt.getMonth() + 1, dt.getDate()); return this.isoOf(dt.getFullYear(), dt.getMonth() + 1, dt.getDate());
} }
/** 只看"可见的日历本"里的条目(设置里能逐本关掉) */ /**
* 重建派生索引(`lunarIdx` + `dayIdx`)。**一次遍历搞定**。
*
* ⚠️ 只在数据/筛选条件变化后调用(由 `evtVer` 判定),不要在渲染里直接调。
*
* 农历文字的优先级必须与旧 `lunarOf()` 完全一致,否则会出现
* 「初一」盖掉「中秋」这类回退:
* 1. `traditional` / `solarterm` / `holiday` → 用它的标题
* 2. 都没有时,才用 `kind === 'lunar'` 的标题
*/
private ensureIndex(): void {
if (this.idxVer === this.evtVer) {
return;
}
const lunarIdx: Map<string, string> = new Map<string, string>();
const dayIdx: Map<string, FbCalEvent[]> = new Map<string, FbCalEvent[]>();
// 两级缓冲:先收"节日类",再收"农历日名"。
// 用两个 Map 分开存,最后按"节日优先"合并 —— 等价于旧实现的两次遍历,
// 但只扫一遍源数据。
const fest: Map<string, string> = new Map<string, string>();
const lunar: Map<string, string> = new Map<string, string>();
for (const e of this.allEvents) {
// ---- 农历/节日索引 ----
if (e.kind === 'traditional' || e.kind === 'solarterm' || e.kind === 'holiday') {
if (!fest.has(e.date)) {
const t: string = this.shortTitle(e.title);
if (t !== '') {
fest.set(e.date, t);
}
}
} else if (e.kind === 'lunar') {
if (!lunar.has(e.date)) {
const t: string = this.shortTitle(e.title);
if (t !== '') {
lunar.set(e.date, t);
}
}
}
// ---- 当天条目索引(含可见性过滤)----
// calId=0 的衍生条目(农历/纪念日)不受"本"控制,照常显示
const vis: boolean = this.books.length === 0
|| e.calId === 0
|| FB_CAL_SETTINGS.isVisible(e.calId);
if (vis) {
const bucket: FbCalEvent[] | undefined = dayIdx.get(e.date);
if (bucket === undefined) {
dayIdx.set(e.date, [e]);
} else {
bucket.push(e);
}
}
}
// 节日优先,其次农历日名
fest.forEach((v: string, k: string): void => {
lunarIdx.set(k, v);
});
lunar.forEach((v: string, k: string): void => {
if (!lunarIdx.has(k)) {
lunarIdx.set(k, v);
}
});
// 每个桶内按时间排序(原本 eventsOf 每次都排,这里一次排好)
dayIdx.forEach((arr: FbCalEvent[]): void => {
arr.sort((a: FbCalEvent, c: FbCalEvent): number => this.byTime(a, c));
});
this.lunarIdx = lunarIdx;
this.dayIdx = dayIdx;
this.idxVer = this.evtVer;
}
/**
* 只看"可见的日历本"里的条目(设置里能逐本关掉)。
*
* ⚠️ 保留这个函数是为了兼容既有调用方(`monthEventCount` 等)。
* 新的渲染路径请用 `eventsOf()` —— 它直接查 `dayIdx`,不再全表扫。
* 这里仍然会遍历,但**只在下游确实需要"全量可见列表"时才走**。
*/
private visibleEvents(): FbCalEvent[] { private visibleEvents(): FbCalEvent[] {
if (this.books.length === 0) { if (this.books.length === 0) {
return this.allEvents; return this.allEvents;
@@ -472,16 +591,12 @@ export struct FbCalendarPage {
return out; return out;
} }
/** 某一天的条目(已排序) */ /** 某一天的条目(已排序,O(1) 命中索引) */
private eventsOf(iso: string): FbCalEvent[] { private eventsOf(iso: string): FbCalEvent[] {
const out: FbCalEvent[] = []; this.ensureIndex();
for (const e of this.visibleEvents()) { const bucket: FbCalEvent[] | undefined = this.dayIdx.get(iso);
if (e.date === iso) { // ⚠️ 返回副本:调用方可能会 sort/push,别让他们改到索引里的数组
out.push(e); return bucket === undefined ? [] : bucket.slice();
}
}
out.sort((a: FbCalEvent, c: FbCalEvent): number => this.byTime(a, c));
return out;
} }
private byTime(a: FbCalEvent, c: FbCalEvent): number { private byTime(a: FbCalEvent, c: FbCalEvent): number {
@@ -512,36 +627,17 @@ export struct FbCalendarPage {
* ⇒ 这里**不自己算农历**(那是 `LunarUtil` 的活,且容易算错), * ⇒ 这里**不自己算农历**(那是 `LunarUtil` 的活,且容易算错),
* 直接从条目里取:当天有节日就显示节日名,否则若是初一就显示"农历"当月的标记。 * 直接从条目里取:当天有节日就显示节日名,否则若是初一就显示"农历"当月的标记。
*/ */
/**
* 某天的农历/节日文字(O(1))。
*
* 🔴 2026-11-02:原来是每次调用都全表遍历 28628 条、而且扫两遍
* (先找节日、再找农历)。月视图 42 格 ⇒ 约 240 万次比较,全部在渲染路径上。
* 现改为查预建索引(见 `ensureIndex`),并保持**节日优先于农历日名**的语义。
*/
private lunarOf(iso: string): string { private lunarOf(iso: string): string {
// 优先:传统节日 / 节气 / 节假日(这类名字短、信息量大) this.ensureIndex();
for (const e of this.allEvents) { const v: string | undefined = this.lunarIdx.get(iso);
if (e.date !== iso) { return v === undefined ? '' : v;
continue;
}
if (e.kind === 'traditional' || e.kind === 'solarterm' || e.kind === 'holiday') {
// 标题可能带 emoji 前缀(如「🎂 婚礼纪念日」不属这类;这里只取纯节日名)
return this.shortTitle(e.title);
}
}
// 其次:农历日名
//
// 🔴🔴 2026-10-18 修的真 bug(用户:「农历全部都是初一,说明是错误的数据」):
// 这里原来**硬编码返回 `'初一'`**,注释还写着"`kind=lunar` 的条目只在初一有" ——
// 而事实相反:服务端 `Calendar::month()` 里 `Holiday::lunarRange()` 是
// **每一天都出一条**(`title` = `Lunar::monthName()`(初一显示月名)
// 或 `Lunar::dayName()`),端上把这个真值丢掉了,于是**整月都显示"初一"**。
// ⇒ 直接取服务端给的 title(它本来就是"这一天的农历文字")。
// ⚠️ title 可能带节日后缀(`八月十五 · 中秋`)—— `shortTitle` 截前 3 字,
// 而这张格子里只有 8sp 的位置,本来就该显示短的。
for (const e of this.allEvents) {
if (e.date === iso && e.kind === 'lunar') {
const t: string = this.shortTitle(e.title);
if (t !== '') {
return t;
}
}
}
return '';
} }
/** 标题截短(农历/节日的名字有的很长,格子里放不下) */ /** 标题截短(农历/节日的名字有的很长,格子里放不下) */