【私房菜集 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.ets


export 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): Promise<SearchResultData> {
  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'): Promise<ExploreData> {
  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(): Promise<void> {
  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(): Promise<void> {
  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(): Promise<void> {
  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 })
}

列表项不展示过多信息,是为了保证搜索页能快速扫描:图、菜名、描述、时间、难度就够了。完整用料和步骤留给详情页。

十、分类页:另一条确定性检索入口

探索页不只有搜索框,还有分类宫格。分类点击后进入 CategoryRecipeListPage


private 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.ets


interface 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();
}

搜索执行同样复用 SearchService


private 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,看详情页如何把内容展示和用户操作承接起来。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐