
![题库里有大量自动题。源码用 40 条上限控制结果并通过QuestionBankLazyDataSource通知列表刷新。本文只看题库搜索。源码面对大量自动题时没有直接渲染全量结果而是用 DataSource 刷新列表并限制最多 40 条。先看这篇要解决的具体问题题库搜索的核心不是某个 ArkTS 语法点而是边界划分。一个练习功能至少有四个层面入口从哪里来数据在哪里定义用户动作怎样改变状态结果是否保存。只看 UI 会漏掉判题只看题库会漏掉重置只看存储又解释不了按钮为什么这样设计。更稳的做法是把本文的功能拆成下面六步选择搜索科目 - 输入关键词 - 空关键词返回空 - 遍历当前科目 - 最多保留 40 条 - DataSource 刷新列表。后面所有代码片段都服务于这条线不额外展开无关模块。源码边界和文件分工源码位置作用entry/src/main/ets/pages/Index.ets页面状态、Builder、入口分发和业务处理这些节选不是为了贴完整文件而是为了把当前链路看清楚。Index.ets同时拥有QuestionBankLazyDataSource、搜索框、科目 tab、过滤函数和匹配函数这一页的性能边界都在这些代码里。源码路径D:\ProgramData\huawei\lesson\The_kemusan 阅读顺序入口 - 数据 - 状态 - UI - 本地记录 写作边界只解释当前源码能证实的功能不替代真机运行和发布验证DataSource 负责通知列表刷新源码节选entry/src/main/ets/pages/Index.ets:94class 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(); } } }源码节选entry/src/main/ets/pages/Index.ets:400Builder QuestionBankView() { Column({ space: 12 }) { this.QuestionBankSubjectTabs() this.QuestionBankSearchBox() this.QuestionBankStartCard() if (this.questionBankKeyword.trim().length 0) { this.EmptyState(搜索题目, 输入关键词后显示当前科目的匹配题目。) } else if (this.questionBankDataSource.totalCount() 0) { this.EmptyState(未找到题目, 换一个关键词或切换科目再试。) } else { Text(搜索结果) .fontSize(13) .fontColor($r(app.color.text_secondary)) .width(100%) List({ space: 10 }) { LazyForEach(this.questionBankDataSource, (question: QuestionItem) { ListItem() { this.QuestionBankResultCard(question) } }, (question: QuestionItem) question.id) } .layoutWeight(1) .cachedCount(6) } } .layoutWeight(1) .width(100%) .padding({ left: 20, right: 20, bottom: 24 }) } Builder QuestionBankSubjectTabs() { Row({ space: 8 }) { ForEach(QUESTION_BANK_TABS, (item: QuestionBankSubjectTab) { this.QuestionBankSubjectChip(item) }, (item: QuestionBankSubjectTab) item.subject) } .width(100%) } Builder QuestionBankSubjectChip(item: QuestionBankSubjectTab) { Text(item.title) .fontSize(13) .fontWeight(FontWeight.Medium) .fontColor(this.questionBankSubject item.subject ? Color.White : $r(app.color.text_secondary)) .textAlign(TextAlign.Center) .height(38) .layoutWeight(1) .backgroundColor(this.questionBankSubject item.subject ? $r(app.color.accent) : $r(app.color.panel_bg)) .borderRadius(19) .border({ width: 1, color: $r(app.color.border_subtle) }) .onClick(() { this.questionBankSubject item.subject; this.refreshQuestionBankResults(); }) } Builder QuestionBankSearchBox() { TextInput({ placeholder: 搜索题目、选项或解析, text: this.questionBankKeyword }) .fontSize(15) .fontColor($r(app.color.text_primary)) .placeholderColor($r(app.color.text_secondary)) .height(46) .padding({ left: 14, right: 14 }) .backgroundColor($r(app.color.button_bg)) .borderRadius(16) .border({ width: 1, color: $r(app.color.border_subtle) }) .onChange((value: string) { this.questionBankKeyword value; this.refreshQuestionBankResults(); }) } Builder QuestionBankStartCard() { Column({ space: 10 }) { Row() { Column() { Text(this.getQuestionBankSubjectTitle()) .fontSize(19) .fontWeight(FontWeight.Bold) .fontColor($r(app.color.text_primary)) Text(this.getQuestionBankSubjectSubtitle()) .fontSize(13) .fontColor($r(app.color.text_secondary)) .margin({ top: 4 }) } .layoutWeight(1) .constraintSize({ minWidth: 0 }) Text(20题测试) .fontSize(13) .fontWeight(FontWeight.Bold) .fontColor($r(app.color.app_bg)) .padding({ left: 12, right: 12, top: 7, bottom: 7 }) .backgroundColor($r(app.color.warning)) .borderRadius(15) } .width(100%) Text(开始练习) .fontSize(16) .fontWeight(FontWeight.Bold) .fontColor(Color.White) .textAlign(TextAlign.Center) .height(44) .width(100%) .backgroundColor($r(app.color.accent)) .borderRadius(22) .onClick(() { this.openQuestionBankTest(this.questionBankSubject, this.getQuestionBankSubjectTitle(), this.getQuestionBankSubjectMode(), ); }) } .width(100%) .padding(16) .backgroundColor($r(app.color.surface_bg)) .borderRadius(18) .border({ width: 1, color: $r(app.color.border_subtle) }) }搜索列表首先要有刷新机制。输入框只更新关键词如果 DataSource 不通知列表页面不会知道结果已经变化。QuestionBankLazyDataSource保存当前结果并维护监听者setQuestions后调用notifyReload这是 LazyForEach 能刷新列表的关键。搜索函数先过滤科目再限制数量源码节选entry/src/main/ets/pages/Index.ets:1132private resetQuestionBank(): void { this.questionBankSubject SUBJECT_ONE; this.questionBankKeyword ; this.questionBankDataSource.setQuestions([]); } 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); }源码节选entry/src/main/ets/pages/Index.ets:1161private 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; }搜索函数先处理空关键词避免一进入题库就渲染大量题目。非空时再按当前科目遍历。结果上限是 40 条目的不是限制题库规模而是限制当前屏幕的渲染压力和阅读负担。关键词匹配覆盖题干、选项和解析源码节选entry/src/main/ets/pages/Index.ets:431Builder QuestionBankSubjectTabs() { Row({ space: 8 }) { ForEach(QUESTION_BANK_TABS, (item: QuestionBankSubjectTab) { this.QuestionBankSubjectChip(item) }, (item: QuestionBankSubjectTab) item.subject) } .width(100%) } Builder QuestionBankSubjectChip(item: QuestionBankSubjectTab) { Text(item.title) .fontSize(13) .fontWeight(FontWeight.Medium) .fontColor(this.questionBankSubject item.subject ? Color.White : $r(app.color.text_secondary)) .textAlign(TextAlign.Center) .height(38) .layoutWeight(1) .backgroundColor(this.questionBankSubject item.subject ? $r(app.color.accent) : $r(app.color.panel_bg)) .borderRadius(19) .border({ width: 1, color: $r(app.color.border_subtle) }) .onClick(() { this.questionBankSubject item.subject; this.refreshQuestionBankResults(); }) }关键词匹配覆盖题干、解析、分类和选项。这样用户搜“雾灯”“会车”“远光”时不只命中题干也能命中解析里的知识点。迁移时不要把搜索写成全量 List。先限定科目再限定数量最后通过 DataSource 刷新 UI。迁移到自己的 HarmonyOS 项目迁移顺序建议保持保守先抽模型再接入口然后接页面最后接本地记录。不要一上来就把所有 Builder、题库和存储混在一个文件里。当前主题题库搜索可以按下面的最小切片实现1. 定义模型Subject、QuestionItem、PracticeRecord、action 常量。 2. 定义数据模块入口、题库、灯光命令或历史记录结构。 3. 定义状态当前页面、当前题、当前灯光、反馈文案和记录列表。 4. 定义 UI状态行、题面、按钮区、历史卡片或搜索结果。 5. 定义验证正确流、错误流、重置流和边界流都要能复现。这个顺序能让搜索页在题量变大后仍然可控题库可以扩容结果列表保持轻量。验证路线空关键词结果为空切科目后结果按新科目刷新高频词最多显示 40 条除了上面的业务核对还建议记录一个最小验证表主题题库搜索 输入入口点击、按钮点击、题目选择或关键词输入 状态currentPage、lightState、timeLeft、selectedAnswer、records 输出页面提示、状态高亮、历史记录、搜索结果 边界返回首页、重置、重复点击、空记录、超时这里的验证是面向源码链路的核对不等同于真机兼容性、性能或上架流程。如果要证明设备行为还需要单独构建安装并操作页面。常见问题与处理方式现象常见原因处理方式页面能点但结果不对按钮 action 与题库 action 没有对齐先看模型常量再看题库命令和处理函数返回后状态残留计时器或闪烁器没有释放核对 stopTimer、stopAltFlash 和 reset 方法历史记录不完整写记录时只保存了展示文案补齐 subject、mode、correctOption、selectedOption搜索问题先看三个点关键词是否 trim、subject 是否过滤、setQuestions 是否触发 reload。小结题库搜索这条线把灯光模拟从“能点按钮”推进到“可练习、可判题、可复盘”。The_kemusan 的代码没有把所有能力做成复杂框架而是用清晰的模型、页面状态和本地记录完成闭环。写 CSDN 文章时把这条闭环讲清楚比堆更多 API 名词更有价值。](https://i-blog.csdnimg.cn/direct/9dbc2d249f91470db0a71cccdb8d453c.png#pic_center)