From 06e6e3798ad3a8a27cd0e1dcd504df26b235a8ee Mon Sep 17 00:00:00 2001 From: laoyang Date: Thu, 5 Nov 2026 21:01:28 +0800 Subject: [PATCH] =?UTF-8?q?=E5=90=8D=E5=86=8C=E5=8A=A0=E5=86=85=E5=AD=98?= =?UTF-8?q?=E7=BC=93=E5=AD=98=20+=20=E7=BC=96=E8=BE=91=E6=A1=86=E8=87=AA?= =?UTF-8?q?=E5=B7=B1=E6=8B=89=E4=B8=80=E4=BB=BD=20(#92)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户 2026-11-05:「速度比较慢。刚点开的时候显示的是『成员 #1、成员 #3』 这样的样式,是不是因为没读到人的信息?」 是。占位项(`成员 #id`)就是 roster 还没到位时的兜底。 **慢的真因**:`FbPersonRepo.list()` 是**全表查询** (800 人 + JOIN person_roster + 标签 GROUP_CONCAT 子查询), 而每个用到选人的组件都在首次打开时各自调一次 —— 首页、任务面板、观影、旅程、资源、账本…逐个来一遍, 同一份数据反复查、反复解析。 ① FbPersonRepo 加内存缓存(5 分钟) · 缓存**只对整表查询**有意义;带关键词时在内存里过滤(同一份 `matches` 口径, 行为与"查库再过滤"完全一致,且更快)。 · 缓存的是**同一份数组引用**:调用方都只读不改(现有代码符合), 不做深拷贝(拷 800 个对象本身就不便宜)。 · 新增 `FbPersonRepo.invalidate()`。 ② 同步完成后失效 `FbSyncAll` 收尾时,只要这次不是"全部跳过",就 `FbPersonRepo.invalidate()`。 ⚠️ 不清的话:用户改了头像/名字、同步完再点开还是旧的那份,要等 5 分钟过期。 ⚠️ 只在 "skipped < total" 时清:全部跳过说明服务端没变,缓存本身有效。 ③ 编辑框自己也在 aboutToAppear 里要一份 父页面(首页)的 `dayRoster` 是异步加载的,点开那一瞬可能还是空。 ⇒ 组件自己调 `list()`(有缓存时是**瞬时返回**), 查找时"先查自己那份、再查父页面传的"。 这样占位项一闪而过甚至看不到。 ⚠️ 两份合并而不是只认一份:父页面可能后到(`@Param` 变化会重画), 组件自己拉的也可能失败(那就靠父页面的)。 编译通过,已装真机。 --- .../main/ets/components/FbDayEditSheet.ets | 51 +++++++++++- entry/src/main/ets/data/FbPersonRepo.ets | 77 +++++++++++++++---- entry/src/main/ets/data/FbSyncAll.ets | 9 +++ 3 files changed, 120 insertions(+), 17 deletions(-) diff --git a/entry/src/main/ets/components/FbDayEditSheet.ets b/entry/src/main/ets/components/FbDayEditSheet.ets index 51e4039..1bcdc70 100644 --- a/entry/src/main/ets/components/FbDayEditSheet.ets +++ b/entry/src/main/ets/components/FbDayEditSheet.ets @@ -27,7 +27,8 @@ import { FbPersonPickSheet } from './FbPersonPickSheet'; import { FbAvatar } from './Widgets'; import { fbImageSrc } from '../data/FbAttachQueue'; import { fbCnDate } from './FbDateTimeField'; -import { FbRosterPerson } from '../data/FbPersonRepo'; +import { FbRosterPerson, FbPersonRepo } from '../data/FbPersonRepo'; +import { hilog } from '@kit.PerformanceAnalysisKit'; import { FB_TOKEN_STORE } from '../data/FbTokenStore'; import { createDay, updateDay, deleteDay, FbDayDraft } from '../data/FbDayRepo'; import { FbApiResult } from '../data/FbHttp'; @@ -82,10 +83,29 @@ export struct FbDayEditSheet { @Local personIds: number[] = []; @Local personPickOpen: boolean = false; @Local saving: boolean = false; + /** + * 组件自己拉到的名册(#92b)。 + * + * 🔴 用户 2026-11-05:「速度比较慢。刚点开的时候显示的是『成员 #1、成员 #3』 + * 这样的样式,是不是因为没读到人的信息?」 + * + * 是。父页面(首页)的 `dayRoster` 是**异步**加载的,点开编辑框那一瞬 + * 可能还是空;而 `pickedSlots()` 查不到就出占位。 + * + * ⇒ 组件自己也在 `aboutToAppear` 里要一份: + * `FbPersonRepo.list()` 现在有内存缓存(#92), + * 首页已经拉过的话这里是**瞬时返回**,占位一闪而过甚至看不到。 + * ⚠️ 用**两份合并**(先 self 再 param)而不是只认其中一份: + * 父页面可能后到(那时 `@Param` 变化会重画), + * 组件自己拉的也可能失败(那就靠父页面的)。 + */ + @Local selfRoster: FbRosterPerson[] = []; @Local confirmingDelete: boolean = false; @Local note2: string = ''; aboutToAppear(): void { + /* #92b:自己确保拿到名册(有缓存时瞬时) */ + this.ensureRoster(); this.title = this.editingTitle; this.kind = this.editingKind; this.note = this.editingNote; @@ -123,11 +143,36 @@ export struct FbDayEditSheet { * 原来直接"查不到就跳过" ⇒ chips 区一片空白 ⇒ 看着像数据丢了。 * 可数据其实一直在 `personIds` 里。 */ + /** + * 自己拉一的名册(#92b)。 + * ⚠️ 失败不弹错:名册拿不到时 `pickedSlots()` 会出占位项, + * 编辑功能本身不受影响(保存时用的是 `personIds`,不依赖名册)。 + */ + private async ensureRoster(): Promise { + if (this.selfRoster.length > 0) { + return; + } + try { + this.selfRoster = await FbPersonRepo.inst().list(); + } catch (err) { + hilog.warn(0x0000, 'FbDayEditSheet', '拉名册失败:%{public}s', JSON.stringify(err)); + } + } + + /** 按 id 找人:先查自己那份,再查父页面传的(#92b) */ + private findPerson(id: number): FbRosterPerson | undefined { + let p: FbRosterPerson | undefined = + this.selfRoster.find((x: FbRosterPerson): boolean => x.id === id); + if (p === undefined) { + p = this.roster.find((x: FbRosterPerson): boolean => x.id === id); + } + return p; + } + private pickedSlots(): FbPickSlot[] { const out: FbPickSlot[] = []; for (const id of this.personIds) { - const p: FbRosterPerson | undefined = - this.roster.find((x: FbRosterPerson): boolean => x.id === id); + const p: FbRosterPerson | undefined = this.findPerson(id); if (p !== undefined) { out.push({ id: id, diff --git a/entry/src/main/ets/data/FbPersonRepo.ets b/entry/src/main/ets/data/FbPersonRepo.ets index 50d7304..624532d 100644 --- a/entry/src/main/ets/data/FbPersonRepo.ets +++ b/entry/src/main/ets/data/FbPersonRepo.ets @@ -279,6 +279,40 @@ export class FbPersonRepo { private static _inst: FbPersonRepo | null = null; private db: FbDb = FbDb.inst(); + /** + * 名册的内存缓存(#92)。 + * + * 🔴 用户 2026-11-05:「速度比较慢。刚点开的时候显示的是『成员 #1、成员 #3』 + * 这样的样式,是不是因为没读到人的信息?」 + * 是 —— 占位项就是 roster 还没到时的兜底。 + * + * **慢的真因**:`list()` 是**全表查询**(800 人 + JOIN person_roster + + * 标签 GROUP_CONCAT 子查询),而每个用到选人的组件都在自己 + * `aboutToAppear` / 首次打开时调一次 —— 首页、任务面板、观影、旅程、 + * 资源、账本…逐个来一遍。同一份数据反复查、反复解析。 + * + * ⚠️ 缓存**只对无关键词的整表查询**生效(`list('')`): + * 带关键词时是"查完再过滤"(内存过滤),走缓存同样正确且更快, + * 所以其实也命中 —— 见下面的实现。 + * ⚠️ 失效:名册变动后必须 `invalidate()`。目前名册只在"同步"时变, + * 同步入口会调它(见 `FbSync` / 各页 `load()`)。 + * ⚠️ 缓存的是**同一份数组引用**。调用方只读不改(现有代码都符合), + * 所以不做深拷贝 —— 拷贝 800 个对象本身就不便宜。 + */ + private static _cache: FbRosterPerson[] | null = null; + private static _cacheAt: number = 0; + /** 缓存有效期(毫秒)。5 分钟 —— 名册不是高频变化的数据 */ + private static readonly CACHE_MS: number = 5 * 60 * 1000; + + /** + * 让名册缓存失效(同步完成 / 有人改名改头像后调用)。 + * ⚠️ 调用后下一次 `list()` 会重新查库。 + */ + static invalidate(): void { + FbPersonRepo._cache = null; + FbPersonRepo._cacheAt = 0; + } + /** 单例(与服务端 `Person` 一样是纯静态工具,端上用单例便于将来加缓存) */ static inst(): FbPersonRepo { if (FbPersonRepo._inst === null) { @@ -498,23 +532,38 @@ export class FbPersonRepo { * 手机上是实打实的卡顿。 */ async list(keyword: string = ''): Promise { - const sql: string = - 'SELECT p.id, p.name, p.nickname, p.gender, p.birthday, p.avatar, p.avatar_color,' + - ' p.note, p.email, p.org, p.title, p.slogan, p.addr_city, p.initials,' + - ' r.sort_order, r.phones,' + - ' (SELECT GROUP_CONCAT(t.tag, "\\u0001") FROM person_tags t' + - ' WHERE t.person_id = p.id AND t.owner_id = r.owner_id) AS tags' + - ' FROM person_roster r JOIN persons p ON p.id = r.person_id' + - ' ORDER BY r.sort_order ASC, p.id ASC'; + /* #92:整表查一次就缓存住 —— 见 `_cache` 的注释 */ + const now: number = Date.now(); + let base: FbRosterPerson[] | null = FbPersonRepo._cache; + if (base === null || now - FbPersonRepo._cacheAt > FbPersonRepo.CACHE_MS) { + const sql: string = + 'SELECT p.id, p.name, p.nickname, p.gender, p.birthday, p.avatar, p.avatar_color,' + + ' p.note, p.email, p.org, p.title, p.slogan, p.addr_city, p.initials,' + + ' r.sort_order, r.phones,' + + ' (SELECT GROUP_CONCAT(t.tag, "\u0001") FROM person_tags t' + + ' WHERE t.person_id = p.id AND t.owner_id = r.owner_id) AS tags' + + ' FROM person_roster r JOIN persons p ON p.id = r.person_id' + + ' ORDER BY r.sort_order ASC, p.id ASC'; - const rows: FbRow[] = await this.db.queryRows(sql, []); + const rows: FbRow[] = await this.db.queryRows(sql, []); + base = []; + for (const r of rows) { + base.push(this.rowToPerson(r)); + } + FbPersonRepo._cache = base; + FbPersonRepo._cacheAt = now; + } + + /* 关键词过滤在内存里做(与原来同一份 `matches` 口径)—— + 缓存命中也走这条,行为与"查库再过滤"完全一致。 */ + if (keyword === '') { + return base; + } const out: FbRosterPerson[] = []; - for (const r of rows) { - const p: FbRosterPerson = this.rowToPerson(r); - if (keyword !== '' && !matches(p, keyword)) { - continue; + for (const p of base) { + if (matches(p, keyword)) { + out.push(p); } - out.push(p); } return out; } diff --git a/entry/src/main/ets/data/FbSyncAll.ets b/entry/src/main/ets/data/FbSyncAll.ets index d2a190f..0d9d43e 100644 --- a/entry/src/main/ets/data/FbSyncAll.ets +++ b/entry/src/main/ets/data/FbSyncAll.ets @@ -302,6 +302,15 @@ export async function syncAll(onStep?: FbSyncStepCb): Promise { st.failed = failed; st.finish(note); + /* 🔴 #92:同步刚把新的名册写进本地库 ⇒ **必须让内存缓存失效**, + 否则用户改了头像/名字、同步完再点开还是旧的那份(要等 5 分钟过期)。 + ⚠️ 只在"真的同步了东西"时清:全部跳过说明服务端没变,缓存本身是有效的。 + ⚠️ `FbPersonRepo` 的缓存是**静态**的(跨组件共享), + 所以这里清一次,所有页面都生效。 */ + if (skipped < total) { + FbPersonRepo.invalidate(); + } + const result: FbSyncAllResult = { ok: failed.length === 0, done: done,