灯光模拟HarmonyOS应用实战-21-题库列表为何要用 LazyForEach 与 IDataSource 控制刷新
题库搜索最容易出现一种“逻辑正确、页面不动”的故障:调试器里已经能看到 20 条匹配结果,列表却仍停留在上一次搜索;或者数据变了,但屏外列表项被重复创建,输入框每敲一个字都伴随明显卡顿。
这不是把 ForEach 换成 LazyForEach 就自然解决的问题。LazyForEach 只负责按需创建列表节点,数据什么时候改变、改变后怎样通知框架,仍要由实现 IDataSource 的对象明确回答。本文以灯光模拟应用的 Index.ets 为准,把搜索、结果上限、通知刷新和稳定键串成一条可验证的链路。

读完后可以直接带走四个判断:
- 为什么题库结果适合交给
LazyForEach,但科目标签仍可使用普通ForEach; IDataSource的四个必要方法分别由谁调用;- 搜索结果替换后为什么必须触发
onDataReloaded(); - 如何验证空关键词、切换科目、稳定键和 40 条上限没有互相打架。
先建立边界:搜索算法和列表刷新不是一件事
源码中的数据流可以拆成四个角色:
| 角色 | 当前源码中的成员 | 只负责什么 |
|---|---|---|
| 输入状态 | questionBankKeyword、questionBankSubject | 保存关键词和当前科目 |
| 匹配器 | 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();
}
}
}
这段代码的边界很克制:
totalCount()和getData()是框架读取窗口,页面不直接访问私有数组。registerDataChangeListener()防止同一个监听器被重复加入。unregisterDataChangeListener()清掉不再使用的监听器,避免旧列表继续收通知。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 没必要引入数据源协议,可能增长、可滚动的搜索结果才有按需创建的价值。

搜索函数先缩小范围,再交给数据源
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() |
| 清空输入仍显示旧结果 | 空关键词是否只 return | 先 setQuestions([]) 再返回 |
| 切科目后列表没变 | Chip 点击是否触发刷新 | 两个输入入口汇入同一函数 |
| 滑动后卡片内容串位 | 题目 ID 是否重复或变化 | 使用稳定、唯一的业务键 |
| 每输入一字都明显卡顿 | 是否在 Builder 内全量过滤 | 移出构建函数,先限量;大数据再评估防抖/索引 |
| 节点越来越多或重复通知 | 是否正确反注册监听器 | 对称实现注册与反注册 |
华为 ArkUI 文档中的 IDataSource 示例同样通过监听器向 LazyForEach 发送数据变化通知,并建议为列表项提供稳定键;可结合 LazyForEach 参数与 IDataSource 示例 交叉核对接口形状。文档示例用于确认框架约定,本文的业务结论仍以当前项目源码为准。
收束:让数据变化拥有唯一出口
题库搜索的稳定性不来自某一个组件名,而来自明确的数据契约:输入改变后只在一个地方筛选,筛选结果只经 IDataSource 替换,替换后显式通知,列表再按稳定键按需创建节点。
当这条链路完整时,“搜索是否正确”和“页面是否刷新”可以分别验证;当它们混在 Builder 里时,任何一个空态、科目切换或节点复用问题都会变得难以定位。这正是当前题库页选择 LazyForEach + IDataSource 的工程价值。
更多推荐

所有评论(0)