【私房菜集 HarmonyOS ArkTS 实战系列 05】探索与搜索:分类、热词、关键词匹配的本地检索方案

📅 2026/7/6 14:08:01
【私房菜集 HarmonyOS ArkTS 实战系列 05】探索与搜索:分类、热词、关键词匹配的本地检索方案
【私房菜集 HarmonyOS ArkTS 实战系列 05】探索与搜索分类、热词、关键词匹配的本地检索方案第 04 篇拆解了首页推荐流首页负责回答“今天看什么”。当用户已经有明确目标时入口就要从推荐切到检索。本篇进入探索与搜索链路重点看SearchService如何在没有后端搜索服务的单机应用里完成关键词匹配、搜索历史、热词入口、分类列表和搜索结果展示。一、搜索解决的是确定性找菜首页推荐适合“随便看看”搜索适合“明确要找”。菜谱应用常见的确定性需求有几类按菜名找比如输入“鸡”找到番茄炒蛋、鸡蛋小炒、鸡腿肉小炒。按食材找比如输入“番茄”“鸡蛋”“青椒”。按分类找比如家常菜、小吃夜宵、汤羹炖品。按做法或描述找比如“蒜香”“凉拌”“下饭”。“私房菜集”是单机应用当前没有后端搜索服务也没有全文索引引擎。它的搜索方案并不追求复杂算法而是先把检索边界做清楚输入归一化、空关键词保护、搜索历史持久化、字段匹配、结果降维、列表渲染和详情跳转。二、源码对象总览源码对象作用entry/src/main/ets/services/SearchService.ets本地搜索入口负责关键词归一化、热词、历史记录和字段匹配。entry/src/main/ets/pages/Index.ets探索 Tab承载搜索框、搜索记录、分类宫格和搜索结果列表。entry/src/main/ets/pages/SearchResultPage.ets独立搜索结果页接收路由关键词并展示列表结果。entry/src/main/ets/pages/CategoryRecipeListPage.ets分类菜谱页按categoryId展示某一类菜谱。entry/src/main/ets/components/recipe/RecipeListItem.ets搜索结果和分类列表复用的菜谱列表项组件。这条链路的核心思路是服务层只返回RecipeSummary[]页面负责搜索状态和结果展示列表组件只负责单条菜谱的视觉表达。三、结果模型搜索页不拿完整 Recipe搜索结果使用的模型定义在RecipeModels.etsexport type SearchResultType dish | ingredient | recipe; export interface SearchResultData { keyword: string; type: SearchResultType; results: RecipeSummary[]; }SearchResultType给搜索留出了三种入口dish综合搜索匹配菜名、描述、分类、标签和食材。ingredient偏食材搜索只看用料名称。recipe偏做法搜索看描述和步骤。当前探索页默认使用dish独立搜索结果页也使用dish。虽然页面暂时没有把三种类型做成 Tab但模型已经把边界留好。后续如果要扩展成“菜品 / 食材 / 做法”三类结果不需要改搜索服务的核心结构。四、SearchService.search先归一化再查本地菜谱搜索入口如下async search(keyword: string, type: SearchResultType): PromiseSearchResultData { const normalized keyword.trim(); if (!normalized) { return { keyword, type, results: [] }; } const results recipeService.getAllRecipes() .filter((item: Recipe) this.matchRecipe(item, normalized, type)) .map((item: Recipe): RecipeSummary recipeService.toSummary(item)) .slice(0, 100); return { keyword: normalized, type, results }; }这段代码有几个值得注意的点。第一关键词先trim()。用户输入前后空格时不应该触发无效搜索也不应该把带空格的内容写进搜索历史。第二空关键词直接返回空结果。搜索页可以根据空结果展示“输入关键词开始搜索”或清空状态而不是用空字符串扫完整个菜谱库。第三搜索结果先从完整Recipe映射成RecipeSummary。列表展示只需要封面、标题、描述、分类、时长、难度和热度不需要携带完整用料、步骤、技巧。第四结果最多取 100 条。当前内置菜谱规模是 514 道直接遍历没有性能压力但列表展示仍然需要上限避免一个过宽泛关键词让页面一次性渲染太多节点。截图中使用关键词“鸡”页面返回 63 道菜品能看到番茄炒蛋、蒜香鸡蛋番茄小炒、蒜香鸡腿肉番茄小炒等结果。这说明搜索不是只匹配标题也能命中食材字段。五、matchRecipe字段匹配要有明确边界真正的匹配逻辑集中在matchRecipe()private matchRecipe(recipe: Recipe, keyword: string, type: SearchResultType): boolean { if (type ingredient) { return recipe.ingredients.some(item item.name.includes(keyword)); } if (type recipe) { return recipe.description.includes(keyword) || recipe.steps.some(item item.content.includes(keyword)); } return recipe.title.includes(keyword) || recipe.description.includes(keyword) || recipe.categoryName.includes(keyword) || recipe.tags.some(item item.includes(keyword)) || recipe.ingredients.some(item item.name.includes(keyword)); }这段代码把不同搜索意图分开了。ingredient只查用料名称适合“家里有某种食材想找能做什么”的场景。recipe查描述和步骤适合找做法特征比如“凉拌”“蒜香”“炖”“煎”。dish是综合搜索覆盖菜名、描述、分类、标签和食材。探索页用它作为默认搜索类型因为用户在主搜索框里输入关键词时通常不希望先选择搜索类型。当前实现使用String.includes()优点是简单、可解释、调试成本低。它的边界也很明确不做拼音、不做同义词、不做分词权重。后续如果要增强搜索可以在matchRecipe()这一层替换策略而不用重写页面。六、热词和历史让搜索不只靠输入框搜索服务提供固定热词getHotKeywords(): string[] { return [红烧肉, 番茄炒蛋, 麻婆豆腐, 鸡翅, 土豆丝, 排骨汤]; }探索页实际展示的热词来自RecipeService.getExploreData()async getExploreData(sortKey: RecipeSortKey popular): PromiseExploreData { const recipes this.getAllRecipes().map((item: Recipe): RecipeSummary recipeDataSource.toSummary(item)); return { categories: recipeDataSource.loadCategories().slice(0, 9), hotKeywords: [红烧肉, 番茄炒蛋, 鸡翅, 汤羹, 夜宵, 低脂], recipes: sortKey popular ? this.pickRandomSummaries(recipes, 16) : this.sortSummaries(recipes, sortKey).slice(0, 16) }; }这两处可以继续统一但当前职责已经清楚搜索服务知道通用搜索能力探索数据知道探索页要展示哪些入口。搜索历史写在本地 Preferences 中const SEARCH_HISTORY_KEY: string search_history; getSearchHistory(): string[] { const raw localStoreAdapter.loadString(SEARCH_HISTORY_KEY); if (!raw) { return []; } try { return JSON.parse(raw) as string[]; } catch (_) { return []; } }解析失败时返回空数组这是本地状态常见的防御策略。Preferences 里存的是字符串一旦 JSON 被异常内容污染页面不应该因此崩溃。新增历史时会去重并限制长度addSearchHistory(keyword: string): string[] { const normalized keyword.trim(); if (!normalized) { return this.getSearchHistory(); } const next [normalized].concat(this.getSearchHistory().filter(item item ! normalized)).slice(0, 12); localStoreAdapter.saveString(SEARCH_HISTORY_KEY, JSON.stringify(next)); return next; }新的关键词放在最前面旧的同名关键词会被移除最多保留 12 条。这样搜索记录既能保留最近行为又不会无限增长。七、探索页状态搜索结果和默认探索分开Index.ets中探索页的状态如下State exploreData: ExploreData { categories: [], hotKeywords: [], recipes: [] }; State exploreKeyword: string ; State exploreSearchResults: RecipeSummary[] []; State searchRecords: string[] []; State hasExploreSearch: boolean false;这里把默认探索数据和搜索结果分开维护。exploreData默认探索页包含分类、热词和推荐菜品。exploreKeyword当前输入框内容。exploreSearchResults搜索后的结果列表。searchRecords本地搜索历史。hasExploreSearch是否处于搜索结果模式。刷新探索数据时会同时读取搜索历史并执行一次搜索状态刷新private async refreshExploreData(): Promisevoid { this.exploreData await recipeService.getExploreData(popular); this.searchRecords searchService.getSearchHistory(); await this.runExploreSearch(); }这个设计避免了一个常见问题返回探索页后分类和推荐列表刷新了但搜索历史还是旧的。探索页作为主 Tab必须把默认探索和搜索状态一起管理。八、提交搜索空关键词保护和历史写入探索页的搜索输入由TextInput和按钮组成TextInput({ text: this.exploreKeyword, placeholder: 搜索菜品 }) .layoutWeight(1) .height(46) .fontSize(14) .backgroundColor($r(app.color.card_bg)) .borderRadius(23) .onChange((value: string) { this.exploreKeyword value; if (value.trim().length 0) { this.clearExploreSearch(); } else { this.exploreKeyword value; } }) Button(搜索) .width(58) .height(42) .fontSize(13) .backgroundColor($r(app.color.primary_orange)) .onClick(() this.submitExploreSearch()) Button(清除) .width(58) .height(42) .fontSize(13) .fontColor($r(app.color.primary_orange)) .backgroundColor($r(app.color.primary_orange_light)) .onClick(() this.clearExploreSearch())提交逻辑如下private async submitExploreSearch(): Promisevoid { const keyword this.exploreKeyword.trim(); if (keyword.length 0) { this.clearExploreSearch(); return; } this.searchRecords searchService.addSearchHistory(keyword); this.hasExploreSearch true; await this.runExploreSearch(); }空关键词不会进入搜索历史也不会触发无意义结果。有效关键词会先写历史再切换到搜索结果模式最后执行搜索。搜索执行函数也有空关键词保护private async runExploreSearch(): Promisevoid { if (this.exploreKeyword.trim().length 0) { this.exploreSearchResults []; this.hasExploreSearch false; return; } const data await searchService.search(this.exploreKeyword, dish); this.exploreSearchResults data.results; this.hasExploreSearch true; }这让输入框清空、点击清除、页面刷新三种路径都能回到默认探索状态。九、搜索结果渲染复用 RecipeListItem当hasExploreSearch为 true 时探索页渲染搜索结果if (this.hasExploreSearch) { Row() { Text(搜索结果) .fontSize(18) .fontWeight(FontWeight.Bold) .fontColor($r(app.color.text_primary)) Blank() Text(${this.exploreSearchResults.length}道菜品) .fontSize(13) .fontColor($r(app.color.text_secondary)) } .width(100%) if (this.exploreSearchResults.length 0) { EmptyStateView({ text: 没找到相关菜品, actionText: 清除搜索, onAction: () { this.clearExploreSearch(); } }) } else { ForEach(this.exploreSearchResults, (recipe: RecipeSummary) { RecipeListItem({ recipe, onRecipeClick: (id: string) this.openDetail(id) }) }, (recipe: RecipeSummary) recipe.id) } }截图中的“鸡 / 搜索结果 / 63 道菜品”就来自这段逻辑。每条结果使用RecipeListItem因此搜索结果、分类列表和其他列表入口的视觉结构保持一致。RecipeListItem展示的字段非常克制Row({ space: 12 }) { Column() { RecipeImage({ src: this.recipe.coverImage, title: this.recipe.title, heightValue: 84, radius: 12 }) } .width(96) Column({ space: 7 }) { Text(this.recipe.title) .fontSize(16) .fontWeight(FontWeight.Medium) .fontColor($r(app.color.text_primary)) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) .constraintSize({ minWidth: 0 }) Text(this.recipe.description) .fontSize(12) .fontColor($r(app.color.text_secondary)) .maxLines(2) .textOverflow({ overflow: TextOverflow.Ellipsis }) .constraintSize({ minWidth: 0 }) Row() { Text(${this.recipe.durationMinutes}分钟 · ${this.recipe.difficultyText}) .fontSize(12) .fontColor($r(app.color.text_secondary)) .layoutWeight(1) } .width(100%) } .layoutWeight(1) .constraintSize({ minWidth: 0 }) }列表项不展示过多信息是为了保证搜索页能快速扫描图、菜名、描述、时间、难度就够了。完整用料和步骤留给详情页。十、分类页另一条确定性检索入口探索页不只有搜索框还有分类宫格。分类点击后进入CategoryRecipeListPageprivate openCategory(categoryId: string): void { router.pushUrl({ url: AppRoutes.CATEGORY, params: { categoryId } }); }全部分类入口会进入侧边栏模式private openAllCategories(): void { router.pushUrl({ url: AppRoutes.CATEGORY, params: { mode: all, categoryId: this.exploreData.categories[0]?.id ?? } }); }分类页初始化时读取路由参数async aboutToAppear() { recipeService.init(getContext(this) as common.UIAbilityContext); const params router.getParams() as CategoryParams; this.isAllMode params.mode all; this.categories await recipeService.getCategories(); const firstCategoryId this.categories[0]?.id ?? ; this.selectedCategoryId params.categoryId ?? firstCategoryId; if (this.selectedCategoryId.length 0) { this.selectedCategoryId firstCategoryId; } await this.loadCategory(this.selectedCategoryId); if (this.isAllMode) { this.title 全部分类; } }分类列表本质上也是本地检索只是关键词变成了稳定的categoryId。它不需要匹配多个字段直接调用recipeService.getCategoryRecipes(categoryId)即可。十一、独立搜索结果页路由参数驱动除了探索 Tab 内联搜索项目还保留了独立搜索结果页SearchResultPage.etsinterface SearchParams { keyword?: string; } async aboutToAppear() { recipeService.init(getContext(this) as common.UIAbilityContext); const params router.getParams() as SearchParams; this.keyword params.keyword ?? ; await this.runSearch(); }搜索执行同样复用SearchServiceprivate async runSearch() { const data await searchService.search(this.keyword, dish); this.results data.results; }页面内输入框变更后立即搜索TextInput({ text: this.keyword, placeholder: 搜索菜品 }) .layoutWeight(1) .height(42) .backgroundColor($r(app.color.card_bg)) .borderRadius(21) .onChange((value: string) { this.keyword value; this.runSearch(); })这和探索页的“点击搜索按钮后切换结果模式”略有不同。独立搜索结果页更像一个持续编辑页面输入变化直接刷新列表探索页则更强调默认探索内容和搜索结果之间的模式切换。十二、运行与验收本篇截图来自本机 HarmonyOS 模拟器真实运行页面操作路径如下hdc shell aa start -a EntryAbility -b com.lesson.myapplicationsfcj hdc shell uitest uiInput click 502 2635 hdc shell uitest uiInput click 200 280 hdc shell uitest uiInput text 鸡 hdc shell uitest uiInput click 930 280 hdc shell uitest uiInput keyEvent Back hdc shell snapshot_display -f /data/local/tmp/sfcj_05_search_result.jpeg hdc file recv /data/local/tmp/sfcj_05_search_result.jpeg .\SFCJ\screenshots\05_search_result_raw.jpeg验收重点可以按下面清单检查探索页默认展示搜索记录、分类宫格和全部菜品列表。输入空关键词不会写入搜索历史也不会触发无效搜索。输入“鸡”后能进入搜索结果模式并显示结果数量。搜索结果每项包含封面、标题、描述、时长和难度。点击搜索结果能进入菜谱详情页。点击分类宫格能进入对应分类列表页。点击“全部”能进入全部分类侧边栏模式。搜索历史最多保留 12 条并且重复关键词会提到最前面。十三、问题复盘先可解释再复杂化本地搜索最容易走向两个极端要么只做菜名匹配导致食材和做法都搜不到要么一开始就设计复杂分词、权重、拼音和同义词结果页面还没稳定搜索服务先变成黑盒。当前方案选择了中间路线搜索字段覆盖菜名、描述、分类、标签和食材。搜索类型保留dish / ingredient / recipe三类边界。结果统一降维为RecipeSummary。页面只处理搜索状态、空状态和列表展示。历史记录用 Preferences 持久化解析失败时安全回退。这种方案的优点是可解释、可调试、可逐步增强。后续如果需要更强体验可以继续在SearchService内增加拼音首字母、同义词表、分词权重或高亮字段而不用推翻探索页、分类页和列表组件。十四、下一篇衔接探索和搜索解决了“要找哪道菜”。找到菜之后用户真正进入的是详情页大图、用料、步骤、收藏、想做、计时器和分享入口都要在同一个页面里组织清楚。下一篇继续拆RecipeDetailPage看详情页如何把内容展示和用户操作承接起来。