日程页加载慢: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:
@@ -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 '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 标题截短(农历/节日的名字有的很长,格子里放不下) */
|
/** 标题截短(农历/节日的名字有的很长,格子里放不下) */
|
||||||
|
|||||||
Reference in New Issue
Block a user