
【中国方言题库09】HarmonyOS ArkTS 方言搜索实战实现词语检索和无结果反馈证据边界本轮静态复核SearchPage.ets、MockBanks.ets、方言数据模型与路由配置未执行构建、模拟器、真机或 AppGallery 验证。当前实现只匹配题库名和题干地区词为间接命中不支持拼音、选项、解析或语义搜索。摘要中国方言题库的搜索页不是网络搜索也没有模糊分词引擎而是基于本地BANKS与题目集合完成确定性的子串检索。页面同时支持关键词入口和题型分类入口通过searched明确区分搜索首页、无结果和结果列表三种界面状态并在用户修改分类关键词时主动退出分类模式。本文面向 HarmonyOS 5.0 及以上版本逐段复核SearchPage.ets的 ArkUI 状态建模、结果上限、题库与题目双通道检索、安全区适配和导航行为也会指出“点击题目进入整库随机练习”等真实边界避免把尚未实现的能力写成产品事实。搜索执行流输入与分类参数先决定查询模式再分别筛选题库名和题干最后由两组结果共同决定空态或列表。搜索页职责结构SearchPage负责查询状态和页面切换BANKS/getQuestions提供本地数据BankCard与题目卡负责分类展示router只把题目结果导向所属题库的随机练习。一、搜索页真正要解决的是状态语义一个搜索页通常至少有四个时刻用户尚未搜索用户正在输入搜索完成但没有结果搜索完成并有结果。如果只用results.length 0判断界面就无法区分“尚未搜索”和“确实没有结果”。中国方言题库为此增加State searched: boolean false页面的主分支非常清楚if (!this.searched) { this.EmptySearchHome() } else if ( this.bankResults.length 0 this.questionResults.length 0 ) { this.NoResult() } else { this.ResultList() }这段代码是全文最值得复用的设计。搜索首页的“热门搜索”和搜索失败后的“未找到相关内容”虽然都对应空数组但用户语义完全不同。唯一校验标记空数组不等于无结果searched 才定义搜索是否发生。二、真实源码与能力边界本文依据以下文件复核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 道题支持从题型分类进入结果不搜索答案选项、解析文本或拼音不做同义词、纠错、分词或相关度排序输入变化不会立即搜索需点击“搜索”或提交键点击题目结果会进入该题库的随机练习不会定位到这一道题。技术文章不能把本地子串匹配描述成“智能语义检索”也不能宣称支持未实现的词语字段。三、六组状态如何共同描述搜索会话页面声明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是用户可见名称。两者分开避免业务判断依赖中文显示文案。四、从题型分类页进入时如何自动搜索路由参数接口为interface SearchParams { categoryType?: string categoryName?: string }页面出现时读取参数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()的第一个分支是if ( this.keyword.trim().length 0 this.categoryType.length 0 ) { this.bankResults [] this.questionResults [] this.searched false return }关键词为空且没有分类时页面清空两类结果并把searched恢复为false。这意味着用户清除输入再搜索时会回到“热门搜索”首页而不是看到“未找到相关内容”。若分类仍然存在即使关键词为空也会继续执行因为分类本身就是有效查询条件。这个布尔条件准确表达了两种搜索入口。六、输入归一化只做必要处理关键词处理是const kw this.keyword.trim().toLowerCase()trim()去掉首尾空格避免 四川 搜索失败toLowerCase()为可能出现的拉丁字母提供大小写不敏感匹配。中文没有大小写但这一处理不会破坏中文。当前实现没有移除中间空格、全半角归一化、繁简转换或拼音转换。例如四川话 - 可以匹配题库名或题干 四 川 话 - 中间空格仍保留通常无法匹配 sichuan - 除非数据中真的包含英文否则无结果这不是缺陷伪装而是当前产品的真实检索范围。七、题库与题目为何使用两条结果通道页面分别保存State bankResults: Bank[] [] State questionResults: Question[] []题库搜索this.bankResults this.categoryType.length 0 ? [] : BANKS.filter( b b.name.toLowerCase().includes(kw) )题目搜索则遍历每个题库的全部题目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时this.bankResults []分类搜索的目标是按题型筛选问题而题库本身没有“语音、词汇、文化、地区特色”等单一题型属性。若仍显示题库就很难说明题库为何命中。题目匹配条件变为q.type this.categoryType此时搜索框中的categoryName只是显示标签不参与题干匹配。这个细节非常重要分类入口展示“文化典故”时结果并不是题干中包含“文化典故”的题而是q.type等于对应类型键的题。九、用户改写分类关键词时自动退出分类模式输入框的onChange.onChange((value: string) { this.keyword value if ( this.categoryType.length 0 value ! this.categoryName ) { this.categoryType this.categoryName } })如果用户从分类页进入搜索框可能显示“地区特色”。只要用户把它改成别的内容页面就清空分类状态下一次搜索转为普通关键词检索。如果没有这段逻辑用户输入“四川”后仍可能得到所有region类型题目看起来像搜索失效。这里用输入变化维护了“看见的关键词”和“真实查询模式”的一致性。十、分类标签的清除动作是一次完整状态复位分类模式下会显示Text(当前分类${this.categoryName})点击“清除”执行this.categoryType this.categoryName this.keyword this.doSearch()因为分类和关键词都被清空doSearch()会把结果数组清空、searchedfalse最终回到热门搜索首页。状态复位必须成组完成。只清categoryType而保留keyword会立刻把分类名当普通关键词检索产生完全不同的结果。十一、搜索触发采用显式提交而非实时联想页面提供两个触发点.onSubmit(() { this.doSearch() })以及Text(搜索) .onClick(() { this.doSearch() })输入变化只更新状态不立即执行搜索。因此没有防抖、异步竞态或旧请求覆盖新请求的问题。对本地题库而言显式搜索还能减少每次按键都扫描全部题目的重复计算。如果未来改为实时联想再考虑 150 到 300 毫秒防抖当前架构不需要为了“看起来高级”加入未使用的异步层。十二、20 条上限如何影响结果语义题目结果达到 20 条后内外两层循环都会停止if (qResults.length 20) break这能控制首屏负担和 ArkUI 节点数量但也带来三个真实特征结果最多显示 20 道顺序由BANKS顺序和题库内部问题顺序决定没有相关度排序也没有“共找到多少条”的全量统计。因此标题题目 (${this.questionResults.length})表示“当前返回数量”不一定是全量命中数。若达到 20页面也不会提示“仅展示前 20 条”。更透明的改进是增加题目展示前 20 条或者先统计总命中数再截取展示列表。两者的性能和信息完整度不同应按实际题量选择。十三、本地遍历的复杂度是否可接受算法会遍历题库和问题最坏复杂度约为O(全部题目数量)当前数据量有限且达到 20 条后提前停止直接扫描简单、可读、无额外索引维护成本。对于几百或几千道本地题目这种实现通常足够。如果题量增长到数万并要求拼音、答案选项和解析文本联合搜索可以在初始化时建立轻量索引interface SearchDocument { question: Question normalizedText: string }normalizedText可由题干、选项、题库名等一次性拼接并归一化。当前源码没有该索引因此本文只把它作为扩展路径。十四、热门搜索来自真实地区数据未搜索时页面遍历REGIONSForEach(REGIONS, (region: Region) { Text(region.name) .onClick(() { this.keyword region.name this.doSearch() }) }, (region: Region) region.id)热门词不是手写一组重复字符串而是复用地区模型。地区数据更新后热门搜索会同步变化。点击地区只设置关键词没有设置categoryType所以仍走普通关键词模式。它能匹配名称包含该地区词的题库也能匹配题干中出现该词的问题。十五、占位提示与真实搜索字段并不完全一致输入框占位文案是搜索地区、题库、题目从实现看“题库”实际匹配Bank.name“题目”实际匹配Question.stem“地区”没有单独匹配Region.name或Bank.regionId。地区词之所以通常有效是因为题库名称可能包含“四川话”“粤语”等名称或者题干包含地区词。这是间接匹配不是独立地区索引。如果数据模型中地区名和题库名不再一致占位文案可能超出真实能力。稳妥方案是把地区显式映射到相关题库或者把提示改成“搜索题库名称和题目内容”。十六、无结果状态为何要同时检查两类数组无结果条件是this.bankResults.length 0 this.questionResults.length 0必须使用逻辑与。关键词可能只命中题库不命中题目也可能只命中题目不命中题库。任何一类有数据都应该进入结果列表。NoResult()提供未找到相关内容 换个关键词试试并使用空状态图片而不是保留一块空白区域。这个反馈明确告诉用户搜索已经执行只是没有命中。十七、搜索首页和无结果页的视觉职责不同EmptySearchHome()包含热门搜索和使用说明主要帮助用户开始搜索NoResult()居中展示错误恢复提示主要帮助用户修改查询。从交互上看未搜索 - 发现入口 无结果 - 修正输入 有结果 - 浏览与行动这三种页面不应共享同一个“空状态组件”后只替换一句文字因为它们的下一步操作不同。当前源码使用三个 Builder结构清晰。十八、结果列表按内容类型分段有结果时页面先显示题库if (this.bankResults.length 0) { this.ResultTitle(题库 (${this.bankResults.length})) ForEach(this.bankResults, (bank: Bank) { Column() { BankCard({ bank: bank }) } }, (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) }分段标题提供数量也帮助用户理解命中类型。ForEach分别使用bank.id和q.id作为稳定键避免用数组下标造成不必要的组件重建。十九、题目卡片展示了哪些可复核信息题目卡片包含题干最多三行题型标签所属题库名称“练习”行动标识。题干采用.maxLines(3) .textOverflow({ overflow: TextOverflow.Ellipsis })这能避免长题干把单张卡片撑得过高。题库名通过private bankName(bankId: string): string { const b BANKS.find(bk bk.id bankId) return b ? b.name : bankId }关联真实本地题库找不到时回退显示 ID不会返回空字符串。二十、点击题目结果的真实导航行为整张题目卡点击后执行router.pushUrl({ url: pages/PracticePage, params: { bankId: q.bankId, mode: random } })它只传bankId和moderandom没有传questionId。因此用户点击某道搜索结果后进入的是所属题库随机练习不保证第一题就是点击的那一道。UI 中的“练习”文字容易让用户理解为“练习这道题”。若产品希望精确定位需要扩展PracticeParamsinterface PracticeParams { bankId: string mode: string startQuestionId?: string }并在加载题目后把目标题放到首位。当前代码没有实现不能在文章中声称支持精准跳题。二十一、搜索页的安全区适配页面读取StorageLink(topAvoidAreaHeightPx) topAvoidAreaHeightPx: number 0StorageLink(navigationIndicatorHeightPx) navigationIndicatorHeightPx: number 0顶部换算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 ) ) }系统避让值以像素提供页面转为 vp 后再用于布局。搜索头部避开状态栏结果列表和热门搜索在底部保留手势导航空间。二十二、结果列表为何使用 Scroll 和弹性回弹结果容器是Scroll() { Column({ space: 14 }) { // 题库结果 // 题目结果 Blank().height(24 this.bottomSafePadding()) } } .layoutWeight(1) .scrollBar(BarState.Off) .edgeEffect(EdgeEffect.Spring)layoutWeight(1)让列表占满搜索头部下方剩余空间末尾Blank负责底部避让Spring提供符合移动端习惯的边缘反馈。在平板或 2in1 上当前仍是单列卡片。它功能上可用但宽屏信息密度偏低。后续可以按窗口宽度把题目卡片切为两列数据和搜索状态无需变化。二十三、返回按钮使用标准路由返回搜索头部有可见返回图标并绑定.onClick(() { router.back() })按钮容器使用统一触控目标尺寸.width(Sizes.TOUCH_TARGET) .height(Sizes.TOUCH_TARGET)这比只给 24x24 图标绑定点击更容易触达。HarmonyOS 应用还应保留系统返回手势或按键能力不能只依赖自绘按钮。二十四、如何把状态逻辑提炼成可测试函数当前doSearch()同时读写页面状态和执行遍历。题量较小时可接受若需要单元测试可以提炼纯函数interface SearchQuery { keyword: string categoryType: string limit: number }interface SearchResult { banks: Bank[] questions: Question[] }纯函数只接收查询和数据返回结果function searchLocal( query: SearchQuery, banks: Bank[] ): SearchResult { // 归一化、题库过滤、问题遍历和上限控制 return { banks: [], questions: [] } }页面继续负责searched、分类清除和渲染。这样可以独立测试空格、分类、20 条上限和顺序不必启动 ArkUI 页面。二十五、建议覆盖的搜索测试矩阵用例预期初次进入展示热门搜索不展示无结果输入全空格并搜索回到热门搜索搜索题库完整名称题库区出现结果搜索题干关键词题目区出现结果搜索不存在词展示“未找到相关内容”从题型分类进入自动搜索只展示题目修改分类显示词清除分类模式点击分类“清除”清空输入并回到首页命中超过 20 题只显示前 20 条点击题目卡进入所属题库随机练习小屏与横屏输入区不溢出结果可滚动手势导航末项不被底部区域遮挡还应测试极长关键词、输入法提交、连续切换分类和多次返回搜索页。二十六、不要把搜索结果数量当全量统计因为问题结果达到 20 条后停止扫描下面的标题this.ResultTitle( 题目 (${this.questionResults.length}) )最多只会显示 20。它不是全库命中总数更不能作为平台运营数据。若技术文章、截图文案或产品说明声称“共找到 20 条”就会误导用户认为只有 20 条。更准确的显示方式是“题目结果最多 20 条”或者增加totalMatches单独计数。所有展示数据都应能从实际代码路径复核。二十七、离线搜索的隐私与上架优势当前页面没有网络请求、账号、云端索引、搜索日志上传或第三方分析 SDK。关键词只在组件状态中使用页面没有把搜索词写入 Preferences。这意味着搜索可以离线完成不产生服务端搜索历史不需要为此新增网络权限隐私说明可明确“搜索在本地处理”。前提是应用其他模块也没有与此矛盾的隐藏上传行为。上架材料必须和整个包的真实行为一致不能只依据单页代码下结论。二十八、进一步优化时应保持简单对当前本地题库优先级较高的改进是明确提示题目只展示前 20 条点击题目时支持精确定位把地区匹配从名称巧合升级为显式模型关联搜索字段可选地扩展到选项和解析为输入和无结果增加可访问性描述用联合类型描述搜索模式把纯搜索算法提炼为可测试函数。优先级较低的是引入远程搜索服务、复杂分词库或大型索引框架。没有真实数据规模和业务需求时这些依赖只会增加包体、权限、故障点和审核说明成本。二十九、发布前检查清单搜索功能提交 AppGallery 前应验证搜索为空、无结果和有结果时均无白屏返回按钮和系统返回均可用输入框、搜索按钮和分类清除按钮触控区域足够长题干不会遮挡题型与操作深浅色下输入文字、占位符和分割线对比度合格手机横屏、小窗、平板和 2in1 下布局不截断底部结果不会进入系统手势区不宣称语义搜索、拼音搜索或云搜索不把最多 20 条描述为全量结果点击题目后的行为与“练习”文案一致离线声明、权限和实际代码一致安装、启动、搜索、进入练习、返回和卸载流程稳定。三十、结语SearchPage.ets的实现没有炫技却把本地搜索最容易混乱的几个问题处理得很清楚用searched区分首页和无结果用两组数组区分题库与题目用分类状态区分题型筛选和普通关键词用 20 条上限控制渲染规模并通过顶部、底部安全区保证主要设备形态下的可用性。同样需要诚实说明边界当前只匹配题库名和题干不搜索拼音、选项或解析“地区”依赖名称间接命中题目结果没有相关度排序点击题目会进入整库随机练习。把这些边界讲清楚才能让后续优化建立在真实系统上而不是建立在宣传词上。三类状态需要成组验证搜索页的验收不能只停在“输入四川能看到结果”。首先要验证未搜索态初次进入只显示热门地区和使用说明不能把还未提交查询误判成“无结果”。其次是无结果态只有题库和题目两组数组同时为空才能显示恢复提示。最后是有结果态题库和题目应分区显示数量文案只表示当前返回条数不能误写为全库总数。输入测试至少覆盖空字符串、全空格、完整题库名、题干中间词、不存在词和极长关键词。全空格经过 trim 后应恢复首页不存在词应进入无结果页而题库名和题干词可能分别只命中一个区块。分类入口还要检查预填文案、自动搜索、手动改词退出分类模式、清除后成组复位。列表验收要关注稳定键、长题干截断、底部安全区和连续返回。点击题目卡后只传bankId与moderandom因此应验证进入所属题库的随机练习而不是假设直接定位到被点击的题目。这个区别必须保持在产品文案、技术文章和回归用例中一致。