题库搜索最容易出现一种“逻辑正确、页面不动”的故障:调试器里已经能看到 20 条匹配结果,列表却仍停留在上一次搜索;或者数据变了,但屏外列表项被重复创建,输入框每敲一个字都伴随明显卡顿。

这不是把 ForEach 换成 LazyForEach 就自然解决的问题。LazyForEach 只负责按需创建列表节点,数据什么时候改变、改变后怎样通知框架,仍要由实现 IDataSource 的对象明确回答。本文以灯光模拟应用的 Index.ets 为准,把搜索、结果上限、通知刷新和稳定键串成一条可验证的链路。

题库懒加载与刷新封面

读完后可以直接带走四个判断:

  • 为什么题库结果适合交给 LazyForEach,但科目标签仍可使用普通 ForEach
  • IDataSource 的四个必要方法分别由谁调用;
  • 搜索结果替换后为什么必须触发 onDataReloaded()
  • 如何验证空关键词、切换科目、稳定键和 40 条上限没有互相打架。

先建立边界:搜索算法和列表刷新不是一件事

源码中的数据流可以拆成四个角色:

角色当前源码中的成员只负责什么
输入状态questionBankKeywordquestionBankSubject保存关键词和当前科目
匹配器refreshQuestionBankResults()isQuestionMatched()从内置题库筛选结果
列表数据源QuestionBankLazyDataSource保存本次结果并通知框架
渲染器List + LazyForEach按需创建可见列表项

如果把这四层混在 build() 中,输入一次就会同时做字符串扫描、数组拼装和 UI 构建,后续也很难判断“没搜到”还是“搜到了但没通知”。当前实现把匹配器和列表数据源分开,问题定位会直接很多。

关键词到列表节点的刷新流程

图中采用概念命名:replaceAll 对应本项目的 setQuestions()notifyDataReload 对应 notifyReload(),最终由监听器的 onDataReloaded() 通知框架。图中的“重建可见项”应理解为按键匹配后更新或复用节点,并非每次把所有可见项销毁重建;实际函数名与行为以下文源码为准。

图中的关键不是箭头数量,而是中间那次显式通知:数组替换只是普通 ArkTS 对象内部的变化,LazyForEach 不会凭空知道它需要重新读取数据。

QuestionBankLazyDataSource 如何完成框架约定

下面是项目中的真实实现,位于 entry/src/main/ets/pages/Index.ets。这里没有额外的网络分页,也没有虚构通用仓储;它只服务当前页面的题库搜索结果。

class QuestionBankLazyDataSource implements IDataSource {
  private questions: QuestionItem[] = [];
  private listeners: DataChangeListener[] = [];

  public setQuestions(questions: QuestionItem[]): void {
    this.questions = questions;
    this.notifyReload();
  }

  public totalCount(): number {
    return this.questions.length;
  }

  public getData(index: number): QuestionItem {
    return this.questions[index];
  }

  public registerDataChangeListener(listener: DataChangeListener): void {
    if (this.listeners.indexOf(listener) < 0) {
      this.listeners.push(listener);
    }
  }

  public unregisterDataChangeListener(listener: DataChangeListener): void {
    const nextListeners: DataChangeListener[] = [];
    for (let index = 0; index < this.listeners.length; index++) {
      if (this.listeners[index] !== listener) {
        nextListeners.push(this.listeners[index]);
      }
    }
    this.listeners = nextListeners;
  }

  private notifyReload(): void {
    for (let index = 0; index < this.listeners.length; index++) {
      this.listeners[index].onDataReloaded();
    }
  }
}

这段代码的边界很克制:

  1. totalCount()getData() 是框架读取窗口,页面不直接访问私有数组。
  2. registerDataChangeListener() 防止同一个监听器被重复加入。
  3. unregisterDataChangeListener() 清掉不再使用的监听器,避免旧列表继续收通知。
  4. setQuestions() 采用“整批替换 + 整体重载”,与当前每次搜索都重新计算完整结果的业务语义一致。

项目没有做增量插入或单项修改,所以此处调用 onDataReloaded() 比逐条 onDataAdd() 更清楚。只有当业务真的支持局部增删时,才值得扩展更细粒度的通知方法。

LazyForEach 只创建需要的卡片

数据源准备好之后,QuestionBankView()List 承载搜索结果。项目代码如下:

List({ space: 10 }) {
  LazyForEach(this.questionBankDataSource, (question: QuestionItem) => {
    ListItem() {
      this.QuestionBankResultCard(question)
    }
  }, (question: QuestionItem) => question.id)
}
.layoutWeight(1)
.cachedCount(6)

这里有三个值得保留的细节。

  • ListItem 是每条结果的列表容器,QuestionBankResultCard 只关心呈现。
  • cachedCount(6) 在可见区域外预创建少量节点,降低快速滑动时临时创建组件的抖动;它不是“一次渲染六条”的结果限制。
  • 键生成器使用 question.id,同一道题在刷新前后仍有稳定身份。不能把数组下标当长期身份,否则筛选结果前插、删除或换序后,旧节点状态可能被错误复用。

稳定身份不等于内容一定会刷新。当前内置题库只改变筛选集合,题目内容本身不变,所以 question.id 适合作为键。如果以后增加收藏或在线编辑,同 ID 对象中的字段变化还需要响应式绑定;普通对象仅修改字段再通知,不能保证同键节点更新。另一种选择是把内容版本纳入渲染键,在发出变化通知后创建新节点,但要同时评估节点局部状态重置的影响。

科目标签只有三个固定项,源码仍使用普通 ForEach。这说明选择不是“LazyForEach 永远更高级”,而是看数据规模与节点生命周期:固定的三个 Chip 没必要引入数据源协议,可能增长、可滚动的搜索结果才有按需创建的价值。

输入状态、匹配器、IDataSource 与 List 的职责结构

搜索函数先缩小范围,再交给数据源

refreshQuestionBankResults() 不是在 Builder 中临时过滤,而是在输入或科目变化时主动执行。以下仍是当前源码的真实逻辑:

private refreshQuestionBankResults(): void {
  const keyword = this.questionBankKeyword.trim().toLowerCase();
  const result: QuestionItem[] = [];
  if (keyword.length === 0) {
    this.questionBankDataSource.setQuestions(result);
    return;
  }
  for (let index = 0; index < BUILTIN_QUESTIONS.length; index++) {
    const rawQuestion = BUILTIN_QUESTIONS[index];
    if (rawQuestion.subject !== this.questionBankSubject) {
      continue;
    }
    const question = normalizeQuestion(rawQuestion);
    if (this.isQuestionMatched(question, keyword)) {
      result.push(question);
      if (result.length >= QUESTION_BANK_RESULT_LIMIT) {
        break;
      }
    }
  }
  this.questionBankDataSource.setQuestions(result);
}

顺序很重要:先处理空关键词,再按科目排除无关题目,然后才规范化并匹配,达到 QUESTION_BANK_RESULT_LIMIT 后立即停止。项目把上限设为 40,这既控制结果密度,也避免用户输入常见词时把整套题库全部塞进本轮渲染。

空关键词分支同样调用 setQuestions([]),而不是只退出函数。否则输入框清空了,旧的搜索结果还会留在页面上,形成典型的“输入状态和结果状态不一致”。

isQuestionMatched 统一决定哪些字段可被搜到

匹配规则覆盖题干、解析、分类和所有选项。题干先去掉“科一灯光专项”等展示前缀,避免前缀反复干扰业务关键词。

private isQuestionMatched(question: QuestionItem, keyword: string): boolean {
  if (this.stripQuestionTitlePrefix(question.title).toLowerCase().indexOf(keyword) >= 0) {
    return true;
  }
  if (question.explanation.toLowerCase().indexOf(keyword) >= 0) {
    return true;
  }
  if (question.category.toLowerCase().indexOf(keyword) >= 0) {
    return true;
  }
  for (let index = 0; index < question.options.length; index++) {
    if (question.options[index].text.toLowerCase().indexOf(keyword) >= 0) {
      return true;
    }
  }
  return false;
}

这段实现选择了可预测的“包含匹配”,而不是模糊分词。迁移时不要一边在输入框里转小写,一边又在不同卡片里写另一套匹配条件。搜索口径只有一个,空态、数量和结果内容才容易验证。

要特别注明一个证据边界:上述实现已从 The_kemusan 当前源码核对;它没有拼音搜索、分词索引、网络题库或搜索防抖。若题库扩展到数万条,再评估索引、任务池或防抖,而不是把这些能力描述成现有项目已经具备。

两个事件入口为何都调用同一刷新函数

当前页面在两个地方触发刷新:关键词变化,以及科目标签切换。

TextInput({
  placeholder: '搜索题目、选项或解析',
  text: this.questionBankKeyword
})
  .onChange((value: string) => {
    this.questionBankKeyword = value;
    this.refreshQuestionBankResults();
  })

// QuestionBankSubjectChip 的点击逻辑
.onClick(() => {
  this.questionBankSubject = item.subject;
  this.refreshQuestionBankResults();
})

如果科目切换只改高亮,不重新计算结果,就会出现“标签显示科四,列表仍是科一”的错位。反过来,如果每个事件各写一套过滤代码,字段范围或结果上限早晚会分叉。两个入口汇入同一方法,是比增加更多状态变量更有效的稳定措施。

什么时候用整体重载,什么时候改成增量通知

当前实现采用整体重载是合理的,因为每次关键词变化都可能改变大部分结果。迁移到别的业务时,可按下面的决策表选择通知粒度:

变化场景推荐通知原因
关键词或筛选条件改变onDataReloaded()新旧结果可能整体不同
在固定位置新增一条onDataAdd(index)框架只需处理新增位置
同一题的内容或收藏状态改变按数据模型采用响应式字段更新,或更新内容版本键后发出 onDataChange(index)通知本身不能保证稳定键对应的普通对象内容刷新
删除一条历史记录onDataDelete(index)明确告诉框架删除位置
后端返回全新分页快照视合并策略决定先明确是追加还是替换

列表结构或数据源项被替换时,应发出对应通知,不要把任何变化都伪装成 onDataAdd()。同键节点的内部字段则要按其响应式模型更新,不能把“通知数据变化”和“更新组件内容”混成一个条件。OpenHarmony 官方 LazyForEach API 说明了键值不变时的节点复用规则。

迁移到自己的页面,按四步落地

第一步,先定义列表项的稳定 ID,并保证同一批结果中不重复。第二步,实现最小 IDataSource,只加入业务真实需要的通知。第三步,把过滤逻辑放在普通方法或服务层,不放进 @Builder。第四步,让所有筛选入口调用同一个刷新函数。

一个最小的迁移骨架如下;这是从当前模式提炼出的示例,不是 The_kemusan 原文件的逐字代码:

class SearchResultSource implements IDataSource {
  private items: QuestionItem[] = [];
  private listeners: DataChangeListener[] = [];

  replace(items: QuestionItem[]): void {
    this.items = items;
    for (let index = 0; index < this.listeners.length; index++) {
      this.listeners[index].onDataReloaded();
    }
  }

  totalCount(): number { return this.items.length; }
  getData(index: number): QuestionItem { return this.items[index]; }

  registerDataChangeListener(listener: DataChangeListener): void {
    if (this.listeners.indexOf(listener) < 0) {
      this.listeners.push(listener);
    }
  }

  unregisterDataChangeListener(listener: DataChangeListener): void {
    const index = this.listeners.indexOf(listener);
    if (index >= 0) {
      this.listeners.splice(index, 1);
    }
  }
}

示例仍坚持“替换就是重载”,没有为了通用而提前加入分页、缓存或仓储抽象。等真实需求出现,再扩展通知类型会更稳。

可执行验证矩阵

操作预期数据预期界面重点观察
初次进入题库页数据源为空显示“搜索题目”空态不残留上次页面节点
输入只含空格结果立即清空回到输入提示空态trim() 分支生效
输入一个命中题干的词返回当前科目的匹配项卡片题干正确题干前缀已剥离
输入只命中选项的词仍能找到对应题目正确答案与解析可见选项参与搜索
保持关键词切换科目重新计算新科目结果标签与列表一致点击事件触发刷新
输入高频词最多 40 条列表可顺畅滚动结果上限与懒加载分工明确
连续输入、删除、再输入每次显示最新结果无旧卡片闪回每次替换都发重载通知
检查题库 ID同批结果 ID 唯一节点不串位keyGenerator 稳定

在 DevEco Studio 中还可以给 setQuestions()notifyReload() 临时加计数日志。一次输入应形成一次结果替换和一次整体通知;验证完成后删除调试日志,避免高频输入产生噪声。

常见失败模式与定位顺序

现象优先检查修复方向
数组有数据,页面不刷新setQuestions() 是否调用通知替换后执行 onDataReloaded()
清空输入仍显示旧结果空关键词是否只 returnsetQuestions([]) 再返回
切科目后列表没变Chip 点击是否触发刷新两个输入入口汇入同一函数
滑动后卡片内容串位题目 ID 是否重复或变化使用稳定、唯一的业务键
每输入一字都明显卡顿是否在 Builder 内全量过滤移出构建函数,先限量;大数据再评估防抖/索引
节点越来越多或重复通知是否正确反注册监听器对称实现注册与反注册

华为 ArkUI 文档中的 IDataSource 示例同样通过监听器向 LazyForEach 发送数据变化通知,并建议为列表项提供稳定键;可结合 LazyForEach 参数与 IDataSource 示例 交叉核对接口形状。文档示例用于确认框架约定,本文的业务结论仍以当前项目源码为准。

收束:让数据变化拥有唯一出口

题库搜索的稳定性不来自某一个组件名,而来自明确的数据契约:输入改变后只在一个地方筛选,筛选结果只经 IDataSource 替换,替换后显式通知,列表再按稳定键按需创建节点。

当这条链路完整时,“搜索是否正确”和“页面是否刷新”可以分别验证;当它们混在 Builder 里时,任何一个空态、科目切换或节点复用问题都会变得难以定位。这正是当前题库页选择 LazyForEach + IDataSource 的工程价值。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐