【口算王|07】HarmonyOS ArkTS 题型搜索实战:实现关键词筛选和空结果状态
证据边界:本文的“当前实现”来自本轮对本地口算王工程中
SearchPage.ets、CategoryPage.ets、MockBanks.ets、ThemeConstants.ets与PracticePage.ets的静态复核。能够确认的是本地同步搜索、题库名与题干匹配、题型精确筛选、自由结果 50 条上限、三态页面以及结果卡跳转参数;类型白名单、惰性列表、答案折叠和独立搜索服务属于建议。本轮未执行构建、安装、真机交互、性能测量或用户数据验证。
搜索页最容易出现的错觉是:输入框能打字、点“搜索”能出列表,就算完成了。真正影响体验的是搜索口径是否与占位文案一致、题型筛选和自由关键词是否互相冲突、空输入与无结果是否被区分,以及用户从结果进入练习时能否从选中的题开始。
本文基于口算王项目本地口算王工程(脱敏路径)的真实源码,复核SearchPage.ets、CategoryPage.ets、MockBanks.ets、ThemeConstants.ets与PracticePage.ets。项目包名com.jiaweikang.one16是本文用于草稿核验的唯一标记。
当前实现支持两条真实搜索路径:用户在首页输入关键词后搜索题库名称和题干;用户从分类页进入时按Question.type获取某一题型。页面已经区分搜索初始态、无结果态与结果列表,自由文本结果最多 50 道,题型结果不设该上限。需要明确的是,自由关键词并不会匹配题型中文标签、解析文本或年级别名;结果卡还会直接展示正确答案和解析,这对“找题”方便,但对“先测后看答案”的场景并不合适。
一、搜索页有两种入口
首页搜索入口不带参数:
router.pushUrl({ url: 'pages/SearchPage' })分类卡入口携带题型:
router.pushUrl({ url: 'pages/SearchPage', params: { categoryType: cat.type, categoryName: cat.name } })因此SearchPage不是只有“关键词搜索”一种模式。它同时承担:
- 自由文本检索;
- 题型分类结果页。
这两个入口共用结果卡和练习跳转,但筛选规则不同。先把模式区分清楚,后面的状态设计才不会混乱。
二、页面状态不是只有一个 results 数组
源码声明了六个核心状态:
@State keyword: string = '' @State categoryType: string = '' @State categoryName: string = '' @State bankResults: Bank[] = [] @State questionResults: Question[] = [] @State searched: boolean = false其中searched很关键。空数组可能表示两种完全不同的页面:
- 用户尚未执行搜索,应展示热门训练;
- 用户已经搜索,但没有命中,应展示“未找到相关内容”。
如果只判断results.length === 0,这两个场景就会被合并,用户一打开页面就看到失败提示。
当前 build 分支准确区分了三态:
if (!this.searched) { this.EmptySearchHome() } else if ( this.bankResults.length === 0 && this.questionResults.length === 0 ) { this.NoResult() } else { this.ResultList() }这是一种简单有效的状态机:idle、empty、content。本地同步搜索没有loading和error,所以当前三态足够;如果以后接入网络题库,再增加加载与失败态。
三、分类参数如何触发自动搜索
页面出现时读取参数:
aboutToAppear(): void { const params = router.getParams() as SearchParams | undefined if (params && params.categoryType) { this.categoryType = params.categoryType this.categoryName = params.categoryName || params.categoryType this.keyword = this.categoryName this.doSearch() } }分类页传入add与“加法练习”后,搜索框显示中文名称,真正筛选条件仍是categoryType === 'add'。显示值与查询值分开,避免依赖中文文案。
但路由参数依旧是运行时输入。若传入未知类型,页面会完成一次搜索并进入无结果态。可以在边界增加白名单:
const SUPPORTED_TYPES: string[] = [ 'add', 'sub', 'mul', 'div', 'mixed', 'word', 'speed' ] function normalizeCategoryType(value?: string): string { if (!value) return '' return SUPPORTED_TYPES.includes(value) ? value : '' }未知类型降级为普通搜索或初始态,不让无效路由制造假分类。
四、空输入为什么要回到初始态
doSearch()开头处理空输入:
if ( this.keyword.trim().length === 0 && this.categoryType.length === 0 ) { this.bankResults = [] this.questionResults = [] this.searched = false return }这里同时清空旧结果并把searched恢复为false。用户删除关键词再点击搜索,不会看到“没有找到空字符串”,而是回到热门年级入口。
这一行为比把所有题目视为“空关键词匹配”更合适,否则本地约 1500 道题会瞬间进入结果列表,既没有检索意义,也增加渲染压力。
建议再做一次关键词归一化:
private normalizedKeyword(): string { return this.keyword .trim() .replace(/\s+/g, ' ') .toLowerCase() }当前题干以算式和中文为主,大小写影响很小,但统一处理能为未来英文符号或拼音别名留出稳定口径。
五、自由关键词实际匹配哪些字段
题库结果只匹配题库名称:
this.bankResults = this.categoryType.length > 0 ? [] : BANKS.filter((bank: Bank) => bank.name.toLowerCase().includes(kw) )题目结果只匹配题干:
const matchesText = this.categoryType.length === 0 && question.stem.toLowerCase().includes(kw)所以占位文案“搜索年级、题型、题目”并不完全等于实现:
- 输入“一年级”可以匹配“一年级口算”题库;
- 输入具体数字或题干片段可以匹配题目;
- 输入“加法练习”不会通过题型中文标签命中题目;
- 输入“进位”不会匹配题目解析;
- 输入“加法”也未必匹配形如
8 + 7 = ?的题干。
如果要兑现“搜索题型”,应显式加入questionTypeLabel():
const typeText = questionTypeLabel(question.type).toLowerCase() const matchesText = question.stem.toLowerCase().includes(kw) || typeText.includes(kw)还可以增加受控别名,如“乘法”“口诀”“乘”。别名表应集中维护,不要散落在页面判断里。
六、分类筛选按稳定 type 执行
分类模式的判断为:
const matchesType = this.categoryType.length > 0 && question.type === this.categoryType当前七种真实类型是:
| type | 中文标签 |
|---|---|
add | 加法练习 |
sub | 减法练习 |
mul | 乘法口诀 |
div | 除法练习 |
mixed | 混合运算 |
word | 应用题 |
speed | 限时挑战 |
CategoryPage、ThemeConstants、题目生成器和搜索页都依赖这些字符串。它们已经构成跨模块契约,适合收敛到共享常量或字符串联合类型。
export type QuestionType = 'add' | 'sub' | 'mul' | 'div' | 'mixed' | 'word' | 'speed'使用同一类型定义后,新增题型时编译器能帮助定位遗漏的标签、颜色、图标和筛选分支。
七、结果数量限制的真实规则
源码对自由搜索和分类搜索采用不同上限:
const limit = this.categoryType.length > 0 ? Number.MAX_SAFE_INTEGER : 50自由文本最多返回 50 道题,避免短关键词命中过多;分类模式则遍历全部六个题库并返回该类型的所有题目。
循环在达到上限后会同时退出内外层:
for (const bank of BANKS) { const questions = getQuestions(bank.id) for (const question of questions) { if (matchesType || matchesText) { results.push(question) if (results.length >= limit) break } } if (results.length >= limit) break }自由搜索的 50 条按BANKS顺序和题目生成顺序截断,不是相关度排序。若命中很多题,一年级结果可能占满上限,后续年级没有机会展示。
更公平的做法可以按题库轮询取样,或先生成匹配结果后按“题库名称命中、题型标签命中、题干命中”分级排序。
八、分类模式为什么可能返回很多结果
当前每个题库生成 250 道题,题型按七槽轮转,六个题库合计约 1500 道。一个题型通常能命中两百余条。分类模式不设上限,ForEach会一次构建所有结果卡。
在当前设备和数据量下可能仍能运行,但这不是可扩展的列表策略。HarmonyOS 长列表应优先使用:
List+LazyForEach;- 分批加载;
- 每题型先展示前 N 条;
- 题库或年级筛选;
- 明确“已展示 N / 总计 M”。
如果继续使用Scroll + Column + ForEach,要用真机测量首次渲染、滚动帧率和内存,而不是只看模拟器截图。
九、输入变化如何退出分类模式
分类模式下,搜索框展示categoryName。用户开始修改文字时:
.onChange((value: string) => { this.keyword = value if ( this.categoryType.length > 0 && value !== this.categoryName ) { this.categoryType = '' this.categoryName = '' } })只要输入值不再等于分类名称,页面就清除结构化分类条件。这样用户编辑“加法练习”为“加法”后,下一次搜索会走自由关键词,而不会继续偷偷按add返回全部加法题。
当前搜索不是实时执行,onChange只更新状态;用户需要按键盘提交或点击“搜索”。这避免每输入一个字符就遍历全部题库,也无需防抖。
十、清除分类回到什么状态
分类提示条提供“清除”:
this.categoryType = '' this.categoryName = '' this.keyword = '' this.doSearch()由于关键词和分类都为空,doSearch()会清空结果,并将searched=false。页面回到热门训练,而不是停留在无结果态。
这条状态转换完整闭环:
分类结果 -> 清除分类 -> 初始热门页搜索状态机最好显式记录:
type SearchStatus = 'idle' | 'content' | 'empty'当前用searched + 两个数组已能表达,但状态枚举会让未来加入加载、错误时更清晰。
十一、初始页提供了可点击热门年级
没有搜索时,页面遍历REGIONS:
ForEach(REGIONS, (region: Region) => { Text(region.name) .onClick(() => { this.keyword = region.name this.doSearch() }) }, (region: Region) => region.id)点击“一年级”后,关键词匹配题库名称,因此返回“一年级口算”训练卡。它不是“最近搜索”或网络热词,而是六个固定年级快捷入口。
如果文案写“热门训练”,读者可能以为存在热度统计。更准确的名称是“按年级查找”或“常用入口”,除非应用确实根据hot或本地行为排序。
十二、无结果状态已经独立实现
搜索执行后两个结果数组都为空,页面显示:
Text('未找到相关内容') Text('换个关键词试试')这比空白列表更能解释状态。还可以增加两个可操作恢复入口:
- 清除关键词;
- 返回全部分类。
空结果页不应自动展示所有题目,因为那会否定用户刚刚输入的筛选条件。恢复动作应由用户明确触发。
十三、结果卡为什么会直接显示答案
结果卡通过选项 key 找答案文本:
private answerText(question: Question): string { const answer = question.options.find( option => option.key === question.answer ) return answer ? answer.text : question.answer }随后在题干右侧显示:
Text(`= ${this.answerText(question)}`)卡片底部还展示question.analysis。这适合“题目检索与查阅”,但如果搜索页定位为“挑题练习”,答案预览会削弱作答价值。
可按产品目标选择:
- 查阅模式:保留答案和解析;
- 练习模式:只显示题干、题型和题库;
- 折叠模式:用户点击“查看答案”后再展示;
- 设置项:允许用户控制搜索结果是否显示答案。
当前源码真实行为是直接显示,文章与上架截图都应忠实呈现。
十四、点击结果如何从指定题开始
结果卡跳转参数为:
router.pushUrl({ url: 'pages/PracticePage', params: { bankId: question.bankId, mode: 'random', questionType: question.type, startQuestionId: question.id } })PracticePage会先按questionType过滤,再由随机题集逻辑把startQuestionId放在第一题。用户选择的搜索结果不会在随机洗牌中丢失。
这条契约需要四项同时有效:
bankId找得到题库;questionType与题目类型一致;startQuestionId属于过滤后的集合;mode='random'能识别起始题。
如果题库升级删除了该题,页面应回退到同题型随机练习,而不是进入空白页。
十五、搜索逻辑应移出页面吗
当前约 1500 道本地题,页面内同步过滤简单直接,没有必要引入数据库。但可以提取纯函数,提升测试性:
export interface SearchRequest { keyword: string categoryType: string questionLimit: number } export interface SearchResult { banks: Bank[] questions: Question[] truncated: boolean } export class LocalSearchService { static search(request: SearchRequest): SearchResult { // 统一归一化、匹配、限制和排序 return { banks: [], questions: [], truncated: false } } }页面只负责读取输入、调用服务和显示状态。测试可以直接覆盖空输入、中文题型、数字算式、50 条截断与未知分类。
十六、性能优化不等于立即上索引
搜索一次最多扫描六个题库的全部题目,时间复杂度约为 O(N)。N 约 1500 时,线性扫描通常足够。
优先优化:
- 保留
getQuestions()现有缓存; - 搜索只在提交时执行;
- 分类结果使用惰性列表;
- 把
questionTypeLabel和标准化文本预计算; - 显示截断提示。
只有题量增长到数万、中文分词和多字段相关度成为真实需求时,再考虑倒排索引或本地数据库全文检索。
十七、多设备与输入法边界
搜索头部由返回按钮、输入框和“搜索”文字组成。输入框使用layoutWeight(1),能够在不同宽度压缩;页面还处理底部安全区。
需要在 HarmonyOS 5.0 以上设备验证:
- 键盘弹出后结果列表仍能滚动;
- 2in1 键盘按 Enter 能触发
onSubmit; - 小窗中“搜索”文字不挤压输入框;
- 大字体时分类提示条不溢出;
- 结果卡长题干两行截断后仍可识别;
- 深浅色下输入框边框、占位文字和类型标签有足够对比度;
- 返回操作既支持页面按钮,也支持系统返回。
十八、测试矩阵
| 输入 | 预期 |
|---|---|
| 空字符串 | 回到初始热门页 |
| 前后空格 | trim 后搜索 |
| “一年级” | 命中一年级题库 |
| 完整题干片段 | 命中对应题目 |
| 数字或运算符 | 最多返回 50 条自由结果 |
| “加法练习”自由输入 | 当前不保证命中题目 |
分类参数add | 返回全部加法类型题 |
| 未知分类参数 | 应降级或显示明确空态 |
| 编辑分类显示名称 | 清除 categoryType |
| 点击清除分类 | 回到初始态 |
| 完全无命中 | 显示独立无结果页 |
| 点击某道结果 | 从该题开始同题型随机练习 |
| 结果超过 50 | 显示截断说明更合理 |
| 分类结果超过 200 | 验证滚动性能 |
十九、常见问题与排查
| 现象 | 原因 | 检查点 |
|---|---|---|
| 输入“加法”没有题目 | 自由搜索只匹配题干 | 增加题型标签字段 |
| 分类页结果正常,手输分类名为空 | 两条路径口径不同 | 区分 categoryType 与 keyword |
| 搜索后仍显示热门页 | searched没有置 true | doSearch() |
| 清空输入显示无结果 | 没恢复 searched=false | 空输入分支 |
| 结果总来自低年级 | 50 条按 BANKS 顺序截断 | 相关度或分组策略 |
| 分类结果卡顿 | 无上限且 eager ForEach | LazyForEach 或分页 |
| 点击结果不是选中题 | startQuestionId 未被优先 | PracticePage 随机组卷 |
| 答案提前泄露 | 结果卡直接渲染答案 | 改为折叠或练习模式 |
二十、发布前检查清单
- 首页与分类页入口参数一致;
- 七种题型使用同一共享定义;
- 占位文案与实际可搜索字段一致;
- 空输入、初始态和无结果态明确区分;
- 分类名称编辑后正确退出分类模式;
- 自由结果 50 条限制有提示;
- 分类大结果集完成滚动性能验证;
- 搜索结果 key 使用稳定 question.id;
- 答案与解析是否预览符合产品目标;
- 结果跳转携带 bankId、questionType、startQuestionId;
- 未知分类和失效题目有回退;
- 键盘提交、点击搜索和系统返回均可用;
- 大字体、小窗、平板和 2in1 无重叠;
- 深浅色下输入、标签与空态可读;
- 不把固定年级入口描述为真实热度榜。
二十一、先把“搜索”拆成两个不同契约
自由关键词与题型分类虽然共用一个页面,却不是同一种查询。自由搜索的输入是用户文案,允许为空格、中文、数字或符号,匹配规则是题库名称和题干包含关系;分类搜索的输入是内部稳定标识,只接受add、sub等类型,并且不应被搜索框中的中文名称改变。当前页面通过categoryType是否为空区分两条路径,这个边界应继续保留。
一个常见错误是把分类名称直接塞进普通关键词搜索。例如“加法练习”不会命中形如8 + 7 = ?的题干,于是页面错误地显示无结果。另一个错误是用户从分类页进入后修改输入框,却仍保留旧categoryType,导致看似搜索新文本,实际仍按旧类型过滤。当前onChange在输入值离开原分类名称时清空分类状态,正是为了解除这一隐含条件。
若后续引入搜索请求模型,建议使用可辨识的联合结构,而不是同时允许任意字段组合。这样“关键词为空但分类存在”“关键词存在且分类为空”是合法状态,“两者都为空却要求执行搜索”则直接回到初始页。该模型是建议扩展,当前源码仍使用页面字段表达契约。
二十二、50 条限制必须让用户知道是否被截断
当前自由题目搜索在收集到 50 条后停止,而题库结果不受这个数字影响;分类模式则使用Number.MAX_SAFE_INTEGER,会继续收集所有匹配题目。这个实现避免了普通关键词把大量题目一次塞进页面,但结果页没有告诉用户“还有更多匹配项”。如果关键字很宽泛,用户可能把前 50 条误认为完整结果。
更可解释的做法是在搜索服务中同时返回truncated或hasMore,并在列表底部显示“仅展示前 50 条,请缩小关键词”。如果要分页,还要保证顺序稳定,例如先按题库在BANKS中的顺序,再按题目原始顺序,不能每次点击“更多”都重新随机。分类结果数量更大时,则应优先改成惰性列表或分段加载,再依据真机数据决定阈值。
这里不能虚构性能结论。线性扫描约 1500 道本地题在代码层面规模有限,但是否流畅取决于设备、渲染卡片复杂度和结果数量。本轮没有测量首次搜索耗时、滚动帧率或内存,因此文章只给出测试方法,不把“通常足够”写成已经通过的性能指标。
二十三、空结果要保留用户刚才的查询上下文
无结果页的职责不是宣布“应用没有题目”,而是说明当前条件没有匹配。页面至少要保留搜索框内容,让用户能看见自己输入了什么;分类模式还要保留“当前分类”标签,除非用户主动清除。若直接清空输入并展示热门训练,用户无法判断是没有结果、查询未执行,还是应用偷偷修改了条件。
测试空状态时,应分别覆盖:不存在的普通关键词、合法但当前无题的分类、非法categoryType、只输入空格、从有结果改成无结果、清除分类、返回页面以及长中文输入。断言不仅检查图片和提示文案,还要检查searched、两个结果数组、关键词、分类标识和返回按钮是否一致。对于结果卡直接显示答案与解析的现状,上架描述也要忠实;若产品希望先练后看,则把答案折叠设计成独立改动,不能在文章里说成当前已经实现。
重复执行也需要纳入验收。同一个关键词连续点击两次,结果数量与顺序应保持一致;先搜出内容,再输入无结果关键词,旧数组必须被新结果覆盖;从分类模式切换到自由模式时,题库结果应重新参与匹配,不能沿用分类模式下的空bankResults。如果未来把同步搜索改成异步,还要给每次请求分配序号,只接受最后一次请求的返回值,防止旧查询晚到后覆盖新页面。不过当前源码是本地同步循环,不存在网络竞态,不能为了讲异步而虚构加载状态。
这些断言应使用固定题库夹具,避免测试结果随随机题序变化。
二十四、总结
口算王当前搜索页已经形成完整本地链路:空页面展示年级快捷入口,自由关键词匹配题库名称与题干,分类入口按稳定题型标识过滤,三态 UI 区分初始、无结果和内容,点击题目还能把目标题放到同题型随机练习的第一位。
继续优化时,最重要的不是堆搜索算法,而是对齐用户预期:占位文案必须与真实字段一致,题型中文词要有显式别名,自由搜索截断要公平且可见,分类大列表要惰性渲染,答案预览要符合页面定位。对约 1500 道本地题而言,清晰的字段契约、状态机和结果跳转,比引入复杂全文检索更有价值。
CSDN-SERIES:ALL-163187841