题库类 HarmonyOS 应用做搜索页时,最容易把事情做散:入口页只负责跳转,搜索页又同时关心输入框、分类筛选、题库列表、题目列表、空结果提示和练习入口。等页面越来越复杂后,一个常见问题会出现:用户从“热门地区”点进来能搜到内容,从“题型分类”点进来却没有状态;清空关键词后列表还残留;点击题目进入练习时,题型和起始题目又丢了。
笔下生辉的SearchPage.ets没有把搜索包装成一个很重的服务层,而是把页面边界收得比较明确:它从router.getParams()接收可选分类参数,用keyword、categoryType、categoryName、searched管住页面状态,再基于本地BANKS和getQuestions()做题库与题目的过滤。搜索结果分成三种渲染:未搜索态、空结果态、结果列表态;题目卡片点击后通过router.pushUrl()把bankId、mode、questionType和startQuestionId交给PracticePage。
本文基于本地工程D:\huawei\one17-11的真实源码编写,包名标记为com.jiaweikang.one17。这里先说明边界:当前源码没有服务端全文检索、拼音检索、模糊排序、AI 推荐或倒排索引;它是一个面向本地题库数据的 ArkTS 页面级搜索实现。文章只讨论源码可以复核的关键词过滤、分类入口、空结果、结果卡片和练习跳转。
本文会解决五个工程问题:
- 如何让搜索页同时支持普通关键词入口和题型分类入口。
- 如何把“未搜索”和“搜索后无结果”区分开,避免空白页。
- 如何在本地
BANKS和题目集合中完成题库/题目两类过滤。 - 如何限制题目结果数量,避免长列表影响移动端体验。
- 如何从搜索结果稳定跳到练习页,并携带起始题目。
一、搜索页先定义状态边界
SearchPage的状态并不多,但每个字段都有明确职责。它没有把输入框内容、分类来源、结果列表和页面是否搜索过混在一起,而是拆成几个独立状态。
@State keyword: string = '' @State categoryType: string = '' @State categoryName: string = '' @State bankResults: Bank[] = [] @State questionResults: Question[] = [] @State searched: boolean = false @StorageLink('topAvoidAreaHeightPx') topAvoidAreaHeightPx: number = 0 @StorageLink('navigationIndicatorHeightPx') navigationIndicatorHeightPx: number = 0这组状态可以分成三类:
| 状态 | 职责 | 页面影响 |
|---|---|---|
keyword | 用户输入或入口预填关键词 | 驱动题库名和题干搜索 |
categoryType/categoryName | 从分类页带来的题型约束 | 决定是否按题型过滤 |
bankResults/questionResults/searched | 搜索结果和渲染分支 | 决定首页、空结果、结果列表 |
searched是这个页面里很关键的布尔值。很多搜索页只看results.length,这样会把“用户还没有搜索”和“用户搜索后没有结果”都渲染成同一种空页面。源码里用searched区分这两种状态,未搜索时展示热门搜索和说明,搜索后无结果才展示空结果图和提示。
二、路由参数让分类入口直接进入搜索结果
搜索页不只从搜索框进入,也会从首页分类、题型分类页进入。源码定义了一个很窄的参数接口,只接收题型分类所需的两个字段。
interface SearchParams { categoryType?: string categoryName?: string }页面出现时读取路由参数。如果携带categoryType,说明这是分类入口,不需要用户再点一次搜索按钮,页面会把分类名称写入keyword,并立即执行doSearch()。
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() } }这段代码解决的是入口一致性问题。比如CategoryPage.ets里进入搜索页时传入:
router.pushUrl({ url: 'pages/SearchPage', params: { categoryType: cat.type, categoryName: cat.name } })搜索页收到参数后不会把它当成普通关键词,而是进入“题型过滤”模式。这样分类页负责选择题型,搜索页负责展示题型下的题目,两边职责不会重复。
三、空输入要回到未搜索态
doSearch()的第一段处理空输入。这里不能简单把结果设为空,因为空输入有两种语义:一种是用户还没开始搜索,另一种是用户清除了已有条件。源码选择把它们都收敛到未搜索态。
private doSearch(): void { if (this.keyword.trim().length === 0 && this.categoryType.length === 0) { this.bankResults = [] this.questionResults = [] this.searched = false return } this.searched = true const kw = this.keyword.trim().toLowerCase() // 后续过滤逻辑 }这段逻辑有两个防御点:
| 场景 | 如果不处理 | 当前源码行为 |
|---|---|---|
| 用户打开搜索页未输入 | 页面可能显示“未找到内容” | searched=false,展示热门搜索 |
| 用户清除分类和关键词 | 旧结果可能残留 | 清空两类结果并回到首页 |
这个处理对移动端体验很重要。搜索页打开后如果直接展示“未找到相关内容”,用户会误以为题库为空;清除条件后如果保留旧结果,用户又会误以为清除没有生效。
四、关键词搜索先过滤题库名
当前源码的普通关键词搜索分成两类结果:题库结果和题目结果。题库结果只在非分类模式下出现,因为分类入口已经限定了题型,不再需要展示题库名匹配。
const kw = this.keyword.trim().toLowerCase() this.bankResults = this.categoryType.length > 0 ? [] : BANKS.filter(b => b.name.toLowerCase().includes(kw))这个判断让页面避免出现混乱的结果组合:
| 模式 | categoryType | 题库结果 |
|---|---|---|
| 普通关键词搜索 | 空 | 按bank.name匹配 |
| 题型分类入口 | 非空 | 不展示题库结果 |
题库匹配使用的是toLowerCase().includes(kw)。这说明当前实现是基础字符串包含匹配,不是分词检索。写文章和发布材料时不能把它描述为“智能搜索”或“语义搜索”,否则就和源码不一致。
五、题目搜索遍历本地题库并限制数量
题目结果来自getQuestions(bank.id)。源码会遍历所有题库,再遍历每个题库的题目。普通搜索按题干包含关键词匹配,分类搜索按题目类型匹配。
const qResults: Question[] = [] for (const bank of BANKS) { const qs = getQuestions(bank.id) for (const q of qs) { if ((this.categoryType.length > 0 && q.type === this.categoryType) || (this.categoryType.length === 0 && q.stem.toLowerCase().includes(kw))) { qResults.push(q) if (qResults.length >= 20) break } } if (qResults.length >= 20) break } this.questionResults = qResults这段逻辑里有一个实际工程取舍:题目结果最多保留 20 条。对于本地数据量不大的题库应用,这种线性遍历足够直接;同时限制数量可以避免搜索页一次性渲染过长列表。
如果后续题库规模变大,优化方向也很清晰:
| 规模变化 | 当前实现可能的问题 | 可演进方案 |
|---|---|---|
| 题库几十条 | 线性遍历可接受 | 保持页面级过滤 |
| 题库几千条 | 搜索触发可能卡顿 | 抽出 SearchService 并做缓存 |
| 支持远程题库 | 本地getQuestions不够 | Repository 层封装接口 |
| 支持多字段排序 | 只有包含匹配 | 增加权重和排序字段 |
当前文章只按第一种边界展开,因为源码就是本地题库搜索。
六、输入框变化要清理分类状态
搜索页允许分类入口预填关键词,但用户一旦手动修改输入框,就不能继续保留旧分类约束。源码在TextInput.onChange里处理了这个边界。
TextInput({ placeholder: '搜索题型、题库、题目', text: this.keyword }) .layoutWeight(1) .height(42) .fontSize(Sizes.BODY_FONT) .fontColor(Colors.INPUT_TEXT) .placeholderColor(Colors.INPUT_PLACEHOLDER) .caretColor(Colors.PRIMARY) .backgroundColor(Colors.INPUT_BG) .padding(0) .onChange((value: string) => { this.keyword = value if (this.categoryType.length > 0 && value !== this.categoryName) { this.categoryType = '' this.categoryName = '' } }) .onSubmit(() => { this.doSearch() })这里的判断很具体:如果当前是分类模式,并且用户输入已经不等于原来的分类名称,就清除分类条件。否则用户以为自己在搜“作文”,实际页面还在按旧题型过滤,结果会非常难解释。
搜索按钮和键盘提交都调用同一个doSearch():
Text('搜索') .fontSize(Sizes.BODY_FONT) .fontColor(Colors.PRIMARY) .fontWeight(FontWeight.Bold) .height(Sizes.TOUCH_TARGET) .padding({ left: 4, right: 4 }) .onClick(() => { this.doSearch() })这能保证触屏点击和键盘提交得到一致结果。对于鸿蒙电脑、平板外接键盘、2in1 设备,这个细节比只做按钮点击更稳。
七、分类标签提供可撤销的过滤上下文
当categoryType存在时,搜索头部会展示当前分类,并提供“清除”动作。这个小区域让用户知道自己不是在做普通关键词搜索。
if (this.categoryType.length > 0) { Row() { Text(`当前分类:${this.categoryName}`) .fontSize(Sizes.CAPTION_FONT) .fontColor(Colors.PRIMARY) Blank() Text('清除') .fontSize(Sizes.CAPTION_FONT) .fontColor(Colors.TEXT_HINT) .onClick(() => { this.categoryType = '' this.categoryName = '' this.keyword = '' this.doSearch() }) } .width('100%') .padding({ left: 12, right: 12, top: 8, bottom: 8 }) .backgroundColor(Colors.PRIMARY_LIGHT) .borderRadius(14) }这里的“清除”不是只清空 UI 文案,而是同时清空三处状态:categoryType、categoryName、keyword。随后调用doSearch(),因为空输入且无分类,页面会回到未搜索态。这个动作闭环比单纯隐藏标签更可靠。
八、三种页面状态分别渲染
页面主体的渲染分支很清楚。它不把空首页和空结果页合并,也不在结果列表内部再塞复杂判断。
build() { Column() { this.SearchHeader() if (!this.searched) { this.EmptySearchHome() } else if (this.bankResults.length === 0 && this.questionResults.length === 0) { this.NoResult() } else { this.ResultList() } } .width('100%') .height('100%') .justifyContent(FlexAlign.Start) .alignItems(HorizontalAlign.Start) .backgroundColor(Colors.BACKGROUND) }三个状态对应不同用户心理:
| 渲染分支 | 用户状态 | 页面目标 |
|---|---|---|
EmptySearchHome() | 还没搜索 | 给热门地区和搜索说明 |
NoResult() | 搜过但没有结果 | 提示换关键词 |
ResultList() | 有结果 | 展示题库和题目 |
这类页面状态设计可以直接复用到其他本地资料检索场景,比如诗词素材、写作模板、错题记录、学习历史等。关键不是状态数量多,而是每个状态都能解释用户下一步该做什么。
九、热门搜索从地区数据进入普通关键词模式
未搜索态展示REGIONS,用户点击地区名称后,把地区名写入keyword并执行搜索。
ForEach(REGIONS, (region: Region) => { Text(region.name) .fontSize(Sizes.BODY_FONT) .fontColor(Colors.TEXT_SECONDARY) .height(38) .padding({ left: 16, right: 16 }) .backgroundColor(Colors.SURFACE) .borderRadius(19) .margin({ right: 10, bottom: 10 }) .onClick(() => { this.keyword = region.name this.doSearch() }) }, (region: Region) => region.id)这不是分类搜索,因为它没有写入categoryType。地区名进入的是普通关键词过滤链路,会匹配题库名和题干。这样热门搜索和输入框搜索复用同一套doSearch(),避免热门入口单独维护一份结果生成逻辑。
十、结果列表把题库和题目分区展示
结果列表用Scroll包裹,先展示题库,再展示题目。底部留出安全区,避免最后一条结果被系统手势区域遮挡。
@Builder ResultList() { Scroll() { Column({ space: 14 }) { if (this.bankResults.length > 0) { this.ResultTitle(`题库 (${this.bankResults.length})`) ForEach(this.bankResults, (bank: Bank) => { Column() { BankCard({ bank: bank }) } .padding({ left: Sizes.PADDING_LARGE, right: Sizes.PADDING_LARGE }) }, (bank: Bank) => bank.id) } if (this.questionResults.length > 0) { this.ResultTitle(`题目 (${this.questionResults.length})`) ForEach(this.questionResults, (q: Question) => { this.QuestionResultCard(q) }, (q: Question) => q.id) } Blank().height(24 + this.bottomSafePadding()) } .padding({ top: 8 }) } .layoutWeight(1) .scrollBar(BarState.Off) .edgeEffect(EdgeEffect.Spring) }这段代码有三个适配点:
| 适配点 | 源码做法 | 作用 |
|---|---|---|
| 长结果 | Scroll() | 小屏仍可浏览全部结果 |
| 系统底部区域 | bottomSafePadding() | 防止最后一项不可点 |
| 结果区分 | ResultTitle() | 题库和题目不混在一起 |
BankCard自己负责题库卡片样式和跳转,搜索页只负责把匹配到的Bank传进去。这样组件边界是干净的。
十一、题目卡片点击进入指定练习
题目搜索的最终目标不是只展示文字,而是让用户从某道题开始练习。QuestionResultCard点击后把题目上下文传给PracticePage。
.onClick(() => { router.pushUrl({ url: 'pages/PracticePage', params: { bankId: q.bankId, mode: 'random', questionType: q.type, startQuestionId: q.id } }) })这四个参数各自有作用:
| 参数 | 来源 | 作用 |
|---|---|---|
bankId | 当前题目所属题库 | 让练习页读取同一题库 |
mode | 固定为random | 进入普通练习模式 |
questionType | q.type | 让练习页可按题型约束 |
startQuestionId | q.id | 从用户点击的题开始 |
如果只传bankId,用户点了搜索结果中的某题,进入练习页后可能看不到这道题;如果不传questionType,分类搜索入口的上下文会丢失。当前源码把这两个信息都传过去,搜索页和练习页的链路就闭合了。
十二、空结果页不应该只是空白
搜索后没有题库和题目结果时,源码展示空状态图片和两行提示。
@Builder NoResult() { Column({ space: 12 }) { Image($r('app.media.img_empty_default')) .width(120) .height(120) .objectFit(ImageFit.Contain) .opacity(0.6) Text('未找到相关内容') .fontSize(Sizes.BODY_FONT) .fontColor(Colors.TEXT_HINT) Text('换个关键词试试') .fontSize(Sizes.CAPTION_FONT) .fontColor(Colors.TEXT_HINT) } .layoutWeight(1) .width('100%') .justifyContent(FlexAlign.Center) .alignItems(HorizontalAlign.Center) }空状态的价值不是装饰,而是给用户反馈:搜索已经执行,只是没有匹配。这里结合前面的searched状态,避免了“打开页面就显示无结果”的误判。
十三、安全区处理保证搜索页在不同设备上可用
SearchPage通过@StorageLink读取顶部和底部避让高度,并转换成 vp。
private topSafePadding(): number { return Math.max(0, this.getUIContext().px2vp(this.topAvoidAreaHeightPx)) } private bottomSafePadding(): number { return Math.max(Sizes.BOTTOM_NAV_MIN_PADDING, this.getUIContext().px2vp(this.navigationIndicatorHeightPx)) }搜索头部用顶部安全区:
.padding({ left: Sizes.PADDING_LARGE, right: Sizes.PADDING_LARGE, top: 10 + this.topSafePadding(), bottom: 10 })结果列表底部用底部安全区:
Blank().height(24 + this.bottomSafePadding())这说明搜索页不是只按固定手机窗口写死。对于 HarmonyOS 5.0+ 的手机、折叠屏、平板或鸿蒙电脑窗口,顶部状态栏和底部导航/手势区域都可能影响可点击区域。源码用统一尺寸常量和安全区高度处理,能降低结果项被遮挡的风险。
十四、本地复核清单
复核这个搜索页时,不需要先看文章描述,直接从源码路径检查即可。
| 复核点 | 源码位置 | 预期 |
|---|---|---|
| 分类入口参数 | aboutToAppear() | categoryType存在时预填并搜索 |
| 空输入处理 | doSearch()开头 | 清空结果并searched=false |
| 题库过滤 | BANKS.filter | 非分类模式按题库名匹配 |
| 题目过滤 | getQuestions(bank.id)循环 | 分类按q.type,关键词按q.stem |
| 数量限制 | qResults.length >= 20 | 最多展示 20 条题目 |
| 空结果 | NoResult() | 搜索后无结果才显示 |
| 练习跳转 | QuestionResultCard | 传bankId/mode/questionType/startQuestionId |
建议手工验证四条路径:
- 从首页搜索入口进入,未输入时应看到热门搜索。
- 输入一个能匹配题库名的关键词,结果区应出现题库分区。
- 从分类页进入搜索页,应自动按题型展示题目,并显示当前分类条。
- 搜索一个不存在的关键词,应进入空结果页,而不是保留旧结果。
十五、常见问题与修复方向
| 问题 | 可能原因 | 修复方向 |
|---|---|---|
| 清空输入后仍显示旧结果 | 没有在空输入分支清空结果 | 保留bankResults=[]和questionResults=[] |
| 分类搜索后修改关键词仍按旧题型过滤 | onChange没清理categoryType | 输入变化时判断是否偏离categoryName |
| 点击搜索结果进入练习不是目标题 | 未传startQuestionId | 跳转参数带上q.id |
| 空结果和未搜索态混淆 | 只用结果长度判断页面 | 增加searched状态 |
| 底部结果不可点击 | 列表没有底部安全区 | 添加bottomSafePadding() |
总结
SearchPage.ets的重点不是算法复杂度,而是搜索页的状态闭环。它用keyword接收普通输入,用categoryType承接题型入口,用searched区分未搜索和无结果,用BANKS/getQuestions()完成本地过滤,再用router.pushUrl()把用户点击的题目带到PracticePage。
对 HarmonyOS 题库应用来说,这种实现适合第一版本地资料搜索:页面状态少、入口清楚、结果可解释、空状态明确、跳转参数完整。后续如果要扩展到更大题库或远程检索,再把过滤逻辑抽到SearchService或 Repository 层会更稳;但在当前源码边界内,SearchPage已经完成了关键词过滤、空结果与练习跳转的核心链路。