羽毛球学习 HarmonyOS 设计续篇(25):搜索列表性能与回到顶部策略
一、从现有搜索页识别可量化的问题当前搜索页已经具备关键字输入、资讯与装备两个标签、远端与本地数据合并、空结果提示和稳定 id。它能完成基础搜索但每次统计、空状态判断和列表构建都会再次调用合并与过滤函数两个列表也没有独立的滚动位置或统一复位规则。数据量小时不明显数据增长后容易出现计算重复、结果切换跳动和返回页面位置不可预测。这是一篇性能治理设计不宣称优化已经运行。方案要解决三个具体问题同一输入只计算一次快照关键字、数据版本或标签变化时明确决定保留还是复位位置“回到顶部”既能被用户触发也能在结果语义变化时自动执行。已有能力设计缺口验收信号关键字过滤、双标签、空状态重复合并过滤每个版本只生成一次快照稳定 id独立滚动锚点切换标签后位置可预测本地与远端合并数据版本与失效规则新数据不会复用旧结果二、先建立查询快照而不是在 build 中反复计算快照应包含标准化关键字、当前数据版本、两个结果集合、耗时和生成序号。页面统计、空状态和列表都读取同一份快照避免三处计算产生不同结果。关键字只在提交或防抖结束时进入快照不把每个输入法组合字符都当作完整查询。export interface SearchSnapshot { query: string dataVersion: number articleIds: string[] equipmentIds: string[] generation: number elapsedMs: number } export interface SearchInput { rawQuery: string selectedTab: article | equipment dataVersion: number } export function normalizeQuery(value: string): string { return value.trim().toLocaleLowerCase() }空关键字也生成合法快照只是结果为空。这样 UI 不需要用undefined区分“尚未搜索”和“搜索无结果”而是通过 query 与结果集合表达。三、合并数据要有版本过滤结果要有缓存远端数据或本地内置数据变化时递增dataVersion。合并层先用 id 去重并生成只读索引搜索层的缓存键由query dataVersion构成。同一版本下切换标签不用重新扫描两份数据版本变化后旧快照自然失效。export interface SearchIndex { version: number articles: ReadonlyArrayArticleSearchRow equipments: ReadonlyArrayEquipmentSearchRow } export class SearchSnapshotCache { private values: Mapstring, SearchSnapshot new Map() get(query: string, version: number): SearchSnapshot | undefined { return this.values.get(${version}:${query}) } put(snapshot: SearchSnapshot): void { this.values.set( ${snapshot.dataVersion}:${snapshot.query}, snapshot ) } invalidateBefore(version: number): void { for (const [key, value] of this.values) { if (value.dataVersion version) this.values.delete(key) } } }触发事件是否重建索引是否重算快照只切换资讯/装备标签否否提交新关键字否是远端数据刷新是是返回详情页但数据未变否否四、生成序号防止慢查询覆盖新查询当数据规模扩大或未来把搜索放到任务池时旧查询可能比新查询晚返回。执行器给每次请求分配 generation结果提交前检查它是否仍是最新值。过期结果直接丢弃但不把它当错误提示给用户。export class SearchExecutor { private generation: number 0 async execute(input: SearchInput, index: SearchIndex): PromiseSearchSnapshot { const current this.generation const startedAt Date.now() const query normalizeQuery(input.rawQuery) const result await this.filterIndex(query, index) if (current ! this.generation) { throw new Error(STALE_SEARCH_RESULT) } return { query, dataVersion: index.version, articleIds: result.articleIds, equipmentIds: result.equipmentIds, generation: current, elapsedMs: Date.now() - startedAt } } cancelPending(): void { this.generation 1 } }五、滚动策略由语义变化决定回到顶部不应在每次 UI 刷新时执行。新关键字改变了结果语义应复位当前标签到顶部数据后台刷新但首个可见 id 仍存在时应尽量保持锚点用户切换标签时保存当前标签位置并恢复另一个标签的位置用户再次点击已选中的标签或点击悬浮按钮时执行带动画的回顶。export interface ScrollAnchor { firstVisibleId: string offset: number } export type ScrollDecision | { kind: keep; anchor?: ScrollAnchor } | { kind: top; animated: boolean } export function decideScroll( reason: new_query | data_refresh | tab_restore | user_request, anchorExists: boolean ): ScrollDecision { if (reason new_query) return { kind: top, animated: false } if (reason user_request) return { kind: top, animated: true } if (reason data_refresh !anchorExists) { return { kind: top, animated: false } } return { kind: keep } }资讯和装备各持有一个 Scroller 与锚点不能共享 offset。锚点优先用业务 id 恢复id 已被删除时再退到顶部避免使用旧索引指向错误条目。六、回顶入口必须满足可发现性和防误触当首个可见索引超过阈值例如 8显示回顶按钮靠近顶部时隐藏。按钮点击后先停止仍在进行的惯性滚动再执行一次动画。连续点击通过 300 ms 节流合并防止重复动画。再次点击当前标签也可作为快捷回顶但不能替代可见按钮因为标签语义对新用户不明确。场景页面行为原因新查询结果从 50 条变 3 条无动画复位避免旧 offset 产生空白后台刷新新增尾部数据保持锚点用户阅读不被打断当前标签再次点击动画回顶明确的用户意图锚点 id 已删除无动画回顶防止恢复到错误行七、失败与降级不应破坏上一次可用结果索引刷新失败时保留上一个有效快照并显示非阻断提示搜索执行超时可退回同步的小数据过滤数据格式异常的单条记录应跳过并计数不能让整个列表消失。恢复锚点失败时退回顶部。任何降级都要记录原因但不在快速输入时连续弹窗。需要区分“没有结果”和“计算失败”前者展示空结果文案后者保留旧结果并提供重试。generation 过期属于正常取消不展示失败。八、实施顺序、性能预算和证据第一阶段把合并数据提取为带版本索引并让统计与两个列表共享快照第二阶段加入 generation 和缓存第三阶段接入双 Scroller、锚点恢复与显式回顶第四阶段增加采样和自动化测试。建议验收预算1000 条资讯加 1000 件装备时已建索引后的本地查询 P95 小于 50 ms同一输入同一版本只计算一次快速连续提交 20 次时只呈现最后一次结果新查询必回顶标签切换恢复各自位置刷新删除锚点后安全回顶。后续需要用性能分析器、滚动录屏、计算次数日志和不同数据规模报告形成运行证据再决定是否将文章升级为实战定位。参考HarmonyOS ArkUI 状态管理概述。九、总结搜索性能的关键不是把一段filter写得更短而是让数据版本、查询快照和滚动语义形成稳定合同。只要统计、空状态和列表共享同一份结果旧查询不会覆盖新查询滚动位置又能按用户意图恢复结果数量变化就不会再让页面行为失去解释。