摘要:中国方言题库的搜索页不是网络搜索,也没有模糊分词引擎,而是基于本地
BANKS与题目集合完成确定性的子串检索。页面同时支持关键词入口和题型分类入口,通过searched明确区分搜索首页、无结果和结果列表三种界面状态,并在用户修改分类关键词时主动退出分类模式。本文面向 HarmonyOS 5.0 及以上版本,逐段复核SearchPage.ets的 ArkUI 状态建模、结果上限、题库与题目双通道检索、安全区适配和导航行为,也会指出"点击题目进入整库随机练习"等真实边界,避免把尚未实现的能力写成产品事实。

一、搜索页真正要解决的是状态语义
一个搜索页通常至少有四个时刻:
- 用户尚未搜索;
- 用户正在输入;
- 搜索完成但没有结果;
- 搜索完成并有结果。
如果只用 results.length === 0 判断界面,就无法区分"尚未搜索"和"确实没有结果"。中国方言题库为此增加:
ts
@State searched: boolean = false
页面的主分支非常清楚:
ts
if (!this.searched) {
this.EmptySearchHome()
} else if (
this.bankResults.length === 0 &&
this.questionResults.length === 0
) {
this.NoResult()
} else {
this.ResultList()
}
这段代码是全文最值得复用的设计。搜索首页的"热门搜索"和搜索失败后的"未找到相关内容"虽然都对应空数组,但用户语义完全不同。
唯一校验标记:空数组不等于无结果,searched 才定义搜索是否发生。
二、真实源码与能力边界
本文依据以下文件复核:
text
entry/src/main/ets/pages/SearchPage.ets
entry/src/main/ets/mock/MockBanks.ets
entry/src/main/ets/common/components/BankCard.ets
librarya/src/main/ets/models/Dialect.ets
librarya/src/main/ets/constants/ThemeConstants.ets
当前搜索能力有明确边界:
- 数据全部来自本地题库;
- 题库按名称检索;
- 题目按题干检索;
- 使用
trim().toLowerCase()做简单归一化; - 最多返回 20 道题;
- 支持从题型分类进入结果;
- 不搜索答案选项、解析文本或拼音;
- 不做同义词、纠错、分词或相关度排序;
- 输入变化不会立即搜索,需点击"搜索"或提交键;
- 点击题目结果会进入该题库的随机练习,不会定位到这一道题。
技术文章不能把本地子串匹配描述成"智能语义检索",也不能宣称支持未实现的词语字段。
三、六组状态如何共同描述搜索会话
页面声明:
ts
@State keyword: string = ''
@State categoryType: string = ''
@State categoryName: string = ''
@State bankResults: Bank[] = []
@State questionResults: Question[] = []
@State searched: boolean = false
这些状态可分成三组:
| 分组 | 字段 | 职责 |
|---|---|---|
| 输入 | keyword |
用户当前看到和编辑的文本 |
| 模式 | categoryType、categoryName |
区分关键词检索与题型筛选 |
| 输出 | bankResults、questionResults、searched |
表达结果和界面阶段 |
categoryType 是机器可比较的题型键,categoryName 是用户可见名称。两者分开,避免业务判断依赖中文显示文案。
四、从题型分类页进入时如何自动搜索
路由参数接口为:
ts
interface SearchParams {
categoryType?: string
categoryName?: string
}
页面出现时读取参数:
ts
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()
}
}
这里把分类名称写入搜索框,是为了让用户知道当前筛选语境。随后立即调用 doSearch(),不要求用户再点一次搜索。
当 categoryName 缺失时,页面回退显示 categoryType。这能保证界面有内容,但机器键可能不够友好,因此调用方最好总是传递可读名称。
五、空关键词为什么要恢复搜索首页
doSearch() 的第一个分支是:
ts
if (
this.keyword.trim().length === 0 &&
this.categoryType.length === 0
) {
this.bankResults = []
this.questionResults = []
this.searched = false
return
}
关键词为空且没有分类时,页面清空两类结果,并把 searched 恢复为 false。这意味着用户清除输入再搜索时,会回到"热门搜索"首页,而不是看到"未找到相关内容"。
若分类仍然存在,即使关键词为空也会继续执行,因为分类本身就是有效查询条件。这个布尔条件准确表达了两种搜索入口。
六、输入归一化只做必要处理
关键词处理是:
ts
const kw = this.keyword.trim().toLowerCase()
trim() 去掉首尾空格,避免 " 四川 " 搜索失败;toLowerCase() 为可能出现的拉丁字母提供大小写不敏感匹配。中文没有大小写,但这一处理不会破坏中文。
当前实现没有移除中间空格、全半角归一化、繁简转换或拼音转换。例如:
text
"四川话" -> 可以匹配题库名或题干
" 四 川 话 " -> 中间空格仍保留,通常无法匹配
"sichuan" -> 除非数据中真的包含英文,否则无结果
这不是缺陷伪装,而是当前产品的真实检索范围。
七、题库与题目为何使用两条结果通道
页面分别保存:
ts
@State bankResults: Bank[] = []
@State questionResults: Question[] = []
题库搜索:
ts
this.bankResults =
this.categoryType.length > 0
? []
: BANKS.filter(
b => b.name.toLowerCase().includes(kw)
)
题目搜索则遍历每个题库的全部题目:
ts
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
分开保存让 UI 能用"题库"和"题目"两个区块展示,用户不会把内容类型混在一起。

八、分类模式为什么不返回题库
当 categoryType.length > 0 时:
ts
this.bankResults = []
分类搜索的目标是按题型筛选问题,而题库本身没有"语音、词汇、文化、地区特色"等单一题型属性。若仍显示题库,就很难说明题库为何命中。
题目匹配条件变为:
ts
q.type === this.categoryType
此时搜索框中的 categoryName 只是显示标签,不参与题干匹配。这个细节非常重要:分类入口展示"文化典故"时,结果并不是题干中包含"文化典故"的题,而是 q.type 等于对应类型键的题。
九、用户改写分类关键词时自动退出分类模式
输入框的 onChange:
ts
.onChange((value: string) => {
this.keyword = value
if (
this.categoryType.length > 0 &&
value !== this.categoryName
) {
this.categoryType = ''
this.categoryName = ''
}
})
如果用户从分类页进入,搜索框可能显示"地区特色"。只要用户把它改成别的内容,页面就清空分类状态,下一次搜索转为普通关键词检索。
如果没有这段逻辑,用户输入"四川"后仍可能得到所有 region 类型题目,看起来像搜索失效。这里用输入变化维护了"看见的关键词"和"真实查询模式"的一致性。
十、分类标签的清除动作是一次完整状态复位
分类模式下会显示:
ts
Text(`当前分类:${this.categoryName}`)
点击"清除"执行:
ts
this.categoryType = ''
this.categoryName = ''
this.keyword = ''
this.doSearch()
因为分类和关键词都被清空,doSearch() 会把结果数组清空、searched=false,最终回到热门搜索首页。
状态复位必须成组完成。只清 categoryType 而保留 keyword,会立刻把分类名当普通关键词检索,产生完全不同的结果。
十一、搜索触发采用显式提交而非实时联想
页面提供两个触发点:
ts
.onSubmit(() => {
this.doSearch()
})
以及:
ts
Text('搜索')
.onClick(() => {
this.doSearch()
})
输入变化只更新状态,不立即执行搜索。因此没有防抖、异步竞态或旧请求覆盖新请求的问题。对本地题库而言,显式搜索还能减少每次按键都扫描全部题目的重复计算。
如果未来改为实时联想,再考虑 150 到 300 毫秒防抖;当前架构不需要为了"看起来高级"加入未使用的异步层。
十二、20 条上限如何影响结果语义
题目结果达到 20 条后,内外两层循环都会停止:
ts
if (qResults.length >= 20) break
这能控制首屏负担和 ArkUI 节点数量,但也带来三个真实特征:
- 结果最多显示 20 道;
- 顺序由
BANKS顺序和题库内部问题顺序决定; - 没有相关度排序,也没有"共找到多少条"的全量统计。
因此标题 题目 (${this.questionResults.length}) 表示"当前返回数量",不一定是全量命中数。若达到 20,页面也不会提示"仅展示前 20 条"。
更透明的改进是增加:
text
题目(展示前 20 条)
或者先统计总命中数,再截取展示列表。两者的性能和信息完整度不同,应按实际题量选择。
十三、本地遍历的复杂度是否可接受
算法会遍历题库和问题,最坏复杂度约为:
text
O(全部题目数量)
当前数据量有限,且达到 20 条后提前停止,直接扫描简单、可读、无额外索引维护成本。对于几百或几千道本地题目,这种实现通常足够。
如果题量增长到数万,并要求拼音、答案选项和解析文本联合搜索,可以在初始化时建立轻量索引:
ts
interface SearchDocument {
question: Question
normalizedText: string
}
normalizedText 可由题干、选项、题库名等一次性拼接并归一化。当前源码没有该索引,因此本文只把它作为扩展路径。
十四、热门搜索来自真实地区数据
未搜索时,页面遍历 REGIONS:
ts
ForEach(REGIONS, (region: Region) => {
Text(region.name)
.onClick(() => {
this.keyword = region.name
this.doSearch()
})
}, (region: Region) => region.id)
热门词不是手写一组重复字符串,而是复用地区模型。地区数据更新后,热门搜索会同步变化。
点击地区只设置关键词,没有设置 categoryType,所以仍走普通关键词模式。它能匹配名称包含该地区词的题库,也能匹配题干中出现该词的问题。
十五、占位提示与真实搜索字段并不完全一致
输入框占位文案是:
text
搜索地区、题库、题目
从实现看:
- "题库"实际匹配
Bank.name; - "题目"实际匹配
Question.stem; - "地区"没有单独匹配
Region.name或Bank.regionId。
地区词之所以通常有效,是因为题库名称可能包含"四川话""粤语"等名称,或者题干包含地区词。这是间接匹配,不是独立地区索引。
如果数据模型中地区名和题库名不再一致,占位文案可能超出真实能力。稳妥方案是把地区显式映射到相关题库,或者把提示改成"搜索题库名称和题目内容"。
十六、无结果状态为何要同时检查两类数组
无结果条件是:
ts
this.bankResults.length === 0 &&
this.questionResults.length === 0
必须使用逻辑与。关键词可能只命中题库,不命中题目;也可能只命中题目,不命中题库。任何一类有数据都应该进入结果列表。
NoResult() 提供:
text
未找到相关内容
换个关键词试试
并使用空状态图片,而不是保留一块空白区域。这个反馈明确告诉用户搜索已经执行,只是没有命中。
十七、搜索首页和无结果页的视觉职责不同
EmptySearchHome() 包含热门搜索和使用说明,主要帮助用户开始搜索;NoResult() 居中展示错误恢复提示,主要帮助用户修改查询。
从交互上看:
text
未搜索 -> 发现入口
无结果 -> 修正输入
有结果 -> 浏览与行动
这三种页面不应共享同一个"空状态组件"后只替换一句文字,因为它们的下一步操作不同。当前源码使用三个 Builder,结构清晰。
十八、结果列表按内容类型分段
有结果时,页面先显示题库:
ts
if (this.bankResults.length > 0) {
this.ResultTitle(`题库 (${this.bankResults.length})`)
ForEach(this.bankResults, (bank: Bank) => {
Column() {
BankCard({ bank: bank })
}
}, (bank: Bank) => bank.id)
}
再显示题目:
ts
if (this.questionResults.length > 0) {
this.ResultTitle(`题目 (${this.questionResults.length})`)
ForEach(this.questionResults, (q: Question) => {
this.QuestionResultCard(q)
}, (q: Question) => q.id)
}
分段标题提供数量,也帮助用户理解命中类型。ForEach 分别使用 bank.id 和 q.id 作为稳定键,避免用数组下标造成不必要的组件重建。
十九、题目卡片展示了哪些可复核信息
题目卡片包含:
- 题干,最多三行;
- 题型标签;
- 所属题库名称;
- "练习"行动标识。
题干采用:
ts
.maxLines(3)
.textOverflow({ overflow: TextOverflow.Ellipsis })
这能避免长题干把单张卡片撑得过高。题库名通过:
ts
private bankName(bankId: string): string {
const b = BANKS.find(bk => bk.id === bankId)
return b ? b.name : bankId
}
关联真实本地题库;找不到时回退显示 ID,不会返回空字符串。
二十、点击题目结果的真实导航行为
整张题目卡点击后执行:
ts
router.pushUrl({
url: 'pages/PracticePage',
params: {
bankId: q.bankId,
mode: 'random'
}
})
它只传 bankId 和 mode='random',没有传 questionId。因此用户点击某道搜索结果后,进入的是所属题库随机练习,不保证第一题就是点击的那一道。
UI 中的"练习"文字容易让用户理解为"练习这道题"。若产品希望精确定位,需要扩展 PracticeParams:
ts
interface PracticeParams {
bankId: string
mode: string
startQuestionId?: string
}
并在加载题目后把目标题放到首位。当前代码没有实现,不能在文章中声称支持精准跳题。

二十一、搜索页的安全区适配
页面读取:
ts
@StorageLink('topAvoidAreaHeightPx')
topAvoidAreaHeightPx: number = 0
@StorageLink('navigationIndicatorHeightPx')
navigationIndicatorHeightPx: number = 0
顶部换算:
ts
private topSafePadding(): number {
return Math.max(
0,
this.getUIContext().px2vp(this.topAvoidAreaHeightPx)
)
}
底部换算:
ts
private bottomSafePadding(): number {
return Math.max(
Sizes.BOTTOM_NAV_MIN_PADDING,
this.getUIContext().px2vp(
this.navigationIndicatorHeightPx
)
)
}
系统避让值以像素提供,页面转为 vp 后再用于布局。搜索头部避开状态栏,结果列表和热门搜索在底部保留手势导航空间。
二十二、结果列表为何使用 Scroll 和弹性回弹
结果容器是:
ts
Scroll() {
Column({ space: 14 }) {
// 题库结果
// 题目结果
Blank().height(24 + this.bottomSafePadding())
}
}
.layoutWeight(1)
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
layoutWeight(1) 让列表占满搜索头部下方剩余空间;末尾 Blank 负责底部避让;Spring 提供符合移动端习惯的边缘反馈。
在平板或 2in1 上,当前仍是单列卡片。它功能上可用,但宽屏信息密度偏低。后续可以按窗口宽度把题目卡片切为两列,数据和搜索状态无需变化。
二十三、返回按钮使用标准路由返回
搜索头部有可见返回图标,并绑定:
ts
.onClick(() => {
router.back()
})
按钮容器使用统一触控目标尺寸:
ts
.width(Sizes.TOUCH_TARGET)
.height(Sizes.TOUCH_TARGET)
这比只给 24x24 图标绑定点击更容易触达。HarmonyOS 应用还应保留系统返回手势或按键能力,不能只依赖自绘按钮。
二十四、如何把状态逻辑提炼成可测试函数
当前 doSearch() 同时读写页面状态和执行遍历。题量较小时可接受;若需要单元测试,可以提炼纯函数:
ts
interface SearchQuery {
keyword: string
categoryType: string
limit: number
}
interface SearchResult {
banks: Bank[]
questions: Question[]
}
纯函数只接收查询和数据,返回结果:
ts
function searchLocal(
query: SearchQuery,
banks: Bank[]
): SearchResult {
// 归一化、题库过滤、问题遍历和上限控制
return {
banks: [],
questions: []
}
}
页面继续负责 searched、分类清除和渲染。这样可以独立测试空格、分类、20 条上限和顺序,不必启动 ArkUI 页面。
二十五、建议覆盖的搜索测试矩阵
| 用例 | 预期 |
|---|---|
| 初次进入 | 展示热门搜索,不展示无结果 |
| 输入全空格并搜索 | 回到热门搜索 |
| 搜索题库完整名称 | 题库区出现结果 |
| 搜索题干关键词 | 题目区出现结果 |
| 搜索不存在词 | 展示"未找到相关内容" |
| 从题型分类进入 | 自动搜索,只展示题目 |
| 修改分类显示词 | 清除分类模式 |
| 点击分类"清除" | 清空输入并回到首页 |
| 命中超过 20 题 | 只显示前 20 条 |
| 点击题目卡 | 进入所属题库随机练习 |
| 小屏与横屏 | 输入区不溢出,结果可滚动 |
| 手势导航 | 末项不被底部区域遮挡 |
还应测试极长关键词、输入法提交、连续切换分类和多次返回搜索页。
二十六、不要把搜索结果数量当全量统计
因为问题结果达到 20 条后停止扫描,下面的标题:
ts
this.ResultTitle(
`题目 (${this.questionResults.length})`
)
最多只会显示 20。它不是全库命中总数,更不能作为平台运营数据。若技术文章、截图文案或产品说明声称"共找到 20 条",就会误导用户认为只有 20 条。
更准确的显示方式是"题目结果(最多 20 条)",或者增加 totalMatches 单独计数。所有展示数据都应能从实际代码路径复核。
二十七、离线搜索的隐私与上架优势
当前页面没有网络请求、账号、云端索引、搜索日志上传或第三方分析 SDK。关键词只在组件状态中使用,页面没有把搜索词写入 Preferences。
这意味着:
- 搜索可以离线完成;
- 不产生服务端搜索历史;
- 不需要为此新增网络权限;
- 隐私说明可明确"搜索在本地处理"。
前提是应用其他模块也没有与此矛盾的隐藏上传行为。上架材料必须和整个包的真实行为一致,不能只依据单页代码下结论。
二十八、进一步优化时应保持简单
对当前本地题库,优先级较高的改进是:
- 明确提示题目只展示前 20 条;
- 点击题目时支持精确定位;
- 把地区匹配从名称巧合升级为显式模型关联;
- 搜索字段可选地扩展到选项和解析;
- 为输入和无结果增加可访问性描述;
- 用联合类型描述搜索模式;
- 把纯搜索算法提炼为可测试函数。
优先级较低的是引入远程搜索服务、复杂分词库或大型索引框架。没有真实数据规模和业务需求时,这些依赖只会增加包体、权限、故障点和审核说明成本。
二十九、发布前检查清单
搜索功能提交 AppGallery 前,应验证:
- 搜索为空、无结果和有结果时均无白屏;
- 返回按钮和系统返回均可用;
- 输入框、搜索按钮和分类清除按钮触控区域足够;
- 长题干不会遮挡题型与操作;
- 深浅色下输入文字、占位符和分割线对比度合格;
- 手机横屏、小窗、平板和 2in1 下布局不截断;
- 底部结果不会进入系统手势区;
- 不宣称语义搜索、拼音搜索或云搜索;
- 不把最多 20 条描述为全量结果;
- 点击题目后的行为与"练习"文案一致;
- 离线声明、权限和实际代码一致;
- 安装、启动、搜索、进入练习、返回和卸载流程稳定。
三十、结语
SearchPage.ets 的实现没有炫技,却把本地搜索最容易混乱的几个问题处理得很清楚:用 searched 区分首页和无结果,用两组数组区分题库与题目,用分类状态区分题型筛选和普通关键词,用 20 条上限控制渲染规模,并通过顶部、底部安全区保证主要设备形态下的可用性。
同样需要诚实说明边界:当前只匹配题库名和题干,不搜索拼音、选项或解析;"地区"依赖名称间接命中;题目结果没有相关度排序;点击题目会进入整库随机练习。把这些边界讲清楚,才能让后续优化建立在真实系统上,而不是建立在宣传词上。
AI 辅助声明:本文部分内容由 AI 辅助整理,所有功能描述、代码片段和工程结论均依据项目真实源码人工复核;未伪造搜索能力、结果数量或平台数据。