用户把一条新配方“桂花燕麦”加入咖啡手记后,回到首页输入“燕麦”,再切换“冷饮”,页面应该出现什么?如果搜索框在修改原始配方数组、分类栏又在修改同一个数组,用户很可能看到搜索结果突然消失,而且关闭筛选仍恢复不了完整数据。更隐蔽的是:列表使用数组下标作为组件标识,删掉第一张卡片后,下面的收藏按钮可能短暂呈现上一张卡片的状态。第五篇要解决的不是再做一个好看的搜索框,而是让查询条件、业务数据和渲染列表拥有清晰边界。

前四篇已经建立 CoffeeNotes 工程,内置 latte / cold / pour 三条配方,可新增 u_N 自定义配方,并用 Preferences 文件 coffee_notes 保存 user_recipes_v1、favorite_ids_v1 等键。上一篇把 CoffeeCard 和 RecipeEditor 调整为只通知父页,由 CoffeeRepository 统一提交。现在不改变这些既有实体,也不额外创建网络接口,而是在 pages/Index.ets 加入轻量的查询视图模型。选中配方、收藏和保存动作仍通过原有提交入口办理。

咖啡手记组合检索的技术封面

一、把搜索看作派生视图,不是数据删除

页面同时拥有四类信息:仓储返回的原始 recipes 数组,持久化的 favoriteIds 集合,页面暂存的 keyword 与 activeFilter,以及基于它们计算出来的可见结果。原始数组代表事实,查询结果只代表当前观察视角。切换“冷饮”时,仓储不会删除热饮;清空关键词时,也不需要再次读取 Preferences。这个区分决定了搜索结果能否稳定回退,更决定了编辑、删除、收藏操作是否会误写缩水后的数组。

我们采用三种分类标签:全部、热饮、冷饮,再单独提供一个“只看收藏”开关。之所以不把“收藏”作为第四个互斥分类,是因为它与冷热类别是两个不同维度。用户完全可能想要“已收藏的冷饮”,如果标签栏将收藏与冷饮做成单选关系,这种需求根本无法表达。关键词、分类和收藏是三个独立谓词,最终结果是它们的交集,而不是把每个按钮对应一份临时数组。

查询的边界要明确。关键词只匹配配方名称和粉液比例,不搜索整篇制作步骤,以免输入“水”就返回几乎所有咖啡。输入前做 trim(),英文字母转小写进行不区分大小写的子串匹配;中文按普通字符串包含规则处理,不宣称支持拼音、同义词或语言学分词。最多接受四十个字符的查询,过长时给出提示而不是偷偷截断用户输入。此处所有配方在设备本地,数据量属于几十条以内的手记,不需要为这点数据引入搜索索引或后台线程。

咖啡手记完整应用竖屏示例:搜索、分类与收藏开关

二、在纯函数里收口三组筛选条件

在 entry/src/main/ets/model/RecipeQuery.ets 新建纯查询模块。输入是旧模型中的 CoffeeRecipe[] 和收藏 ID,输出是新数组;函数不调用 Preferences、不修改原始列表,也不接触 UI 组件。排序时先做数组拷贝,防止 sort() 原地排列对父页状态产生副作用。示例按名称使用 localeCompare() 排列,同名配方再以稳定 ID 排列,避免不同更新顺序导致列表意外跳动。

// model/RecipeQuery.ets
import { CoffeeRecipe } from './CoffeeRecipe';
export type DrinkFilter = '全部' | '热饮' | '冷饮';
export interface RecipeQuery {
  keyword: string;
  category: DrinkFilter;
  favoritesOnly: boolean;
}
export function selectRecipes(source: CoffeeRecipe[], favorites: string[],
  query: RecipeQuery): CoffeeRecipe[] {
  const needle: string = query.keyword.trim().toLocaleLowerCase();
  const result: CoffeeRecipe[] = source.filter((r: CoffeeRecipe) => {
    const matchedText: boolean = needle.length === 0 ||
      r.name.toLocaleLowerCase().includes(needle) ||
      r.ratio.toLocaleLowerCase().includes(needle);
    const matchedCategory: boolean = query.category === '全部' ||
      r.category === query.category;
    const matchedFavorite: boolean = !query.favoritesOnly ||
      favorites.includes(r.id);
    return matchedText && matchedCategory && matchedFavorite;
  });
  return result.sort((a: CoffeeRecipe, b: CoffeeRecipe) => {
    const byName: number = a.name.localeCompare(b.name, 'zh-CN');
    return byName !== 0 ? byName : a.id.localeCompare(b.id);
  });
}

这个函数对每条记录最多检查三组条件,时间复杂度主要由排序决定,为 O(n log n);favorites.includes 每次需要线性扫描收藏列表,若配方达到几千条,可先把 ID 转成 Set 再查。不要在本地只有五六条数据时追求所谓“极限优化”,应先保证条件的意义和结果的确定性。特别是自建配方删除后,旧的收藏 ID 要在仓储提交中一起清理;查询函数可以安全地忽略已不存在的 ID,却不能代替真正的数据清理。

同一个函数可以进行不依赖设备的逻辑验证:关键词“燕麦”在默认三条内置配方里应返回 latte,分类为“冷饮”且关键词为“柑橘”应返回 cold;如果收藏只有 pour,同时启用“冷饮 + 收藏”,应得到空数组。故意将源列表倒序再调用函数,结果仍按名称与 ID 的排序规则出现。断言不需要截图,也不应拿输入数据的排序结果冒充实际滚动性能。

独立查询流水线与确定性结果排序关系图

三、ArkUI 页面只保存输入与真实状态

既有 Index.ets 页面已经有 @State recipes、@State favoriteIds、@State activeFilter 与提交中状态 busy,这里只增加关键词和收藏视图开关。查询条件可以保持在内存里,用户再次打开应用时继续由前一篇保存的 active_filter_v1 决定默认类别;关键词不必持久化,避免每次重新进入页面都被一个过期搜索词困住。页面数据从 Repository 的加载结果整批替换,而不是将查询后的数组写回仓储。

// pages/Index.ets 的核心新增部分;沿用前篇的 CoffeeCard、仓储与提交方法
import { CoffeeRecipe } from '../model/CoffeeRecipe';
import { DrinkFilter, RecipeQuery, selectRecipes } from '../model/RecipeQuery';
@Entry
@Component
struct Index {
  @State recipes: CoffeeRecipe[] = [];
  @State favoriteIds: string[] = [];
  @State activeFilter: DrinkFilter = '全部';
  @State keyword: string = '';
  @State favoritesOnly: boolean = false;
  @State busy: boolean = false;
  @State notice: string = '';
  private visible(): CoffeeRecipe[] {
    const q: RecipeQuery = { keyword: this.keyword,
      category: this.activeFilter, favoritesOnly: this.favoritesOnly };
    return selectRecipes(this.recipes, this.favoriteIds, q);
  }
  build() {
    Column({ space: 12 }) {
      Text('咖啡手记').fontSize(26).fontWeight(FontWeight.Bold);
      TextInput({ text: this.keyword, placeholder: '搜索配方名称或比例' })
        .onChange((value: string) => {
          if (value.length > 40) {
            this.notice = '搜索词不得超过40字';
            return;
          }
          this.notice = '';
          this.keyword = value;
        });
      Row({ space: 8 }) {
        Button('全部').onClick(() => { this.activeFilter = '全部'; });
        Button('热饮').onClick(() => { this.activeFilter = '热饮'; });
        Button('冷饮').onClick(() => { this.activeFilter = '冷饮'; });
      }
      Button(this.favoritesOnly ? '仅看收藏:开' : '仅看收藏:关')
        .onClick(() => { this.favoritesOnly = !this.favoritesOnly; });
      if (this.notice.length > 0) {
        Text(this.notice).fontSize(13).fontColor('#AA5B44');
      }
      Text(`找到 ${this.visible().length} 条配方`).fontSize(14);
      List({ space: 10 }) {
        ForEach(this.visible(), (item: CoffeeRecipe) => {
          ListItem() {
            // 使用第04篇的 CoffeeCard,动作仍回传父页仓储提交
            Text(`${item.name} · ${item.category} · ${item.ratio}`)
              .fontSize(17).padding(16).width('100%');
          }
        }, (item: CoffeeRecipe) => item.id)
      }.layoutWeight(1)
    }.padding(16).width('100%').height('100%')
  }
}

上面用 Text 展示必要绑定,是为了突出可见集合和稳定键的位置;完整工程里应把 ListItem 内的 Text 换回第四篇的 CoffeeCard,并连接 onFavorite/onSelect/onEdit/onDelete 回调。尤其不要为了在搜索页收藏,把 favoriteIds 复制进每张卡片的本地 @State。收藏按钮读取父页传入的 isFavorite,用户点击后父页用 ID 查最新记录、提交仓储并更新父状态,随后 visible() 自动重新计算。

ForEach 最后的键提取器必须是 item.id,而不是数组索引。当关键词变化时,位置会重排;当一条自定义配方被删除时,后续项会整体向前移动。索引既不是业务标识,也不能表示同一张卡片的持续身份。ID 稳定并不意味着所有复杂列表都自动流畅:如果每次输入都在几千项里筛选和排序,需要再评估 LazyForEach、数据源通知、分页或索引;本示例选择普通 ForEach 是因为业务规模小,易于核查。

搜索无结果、数据空白与提交中三种状态的分支图解

四、把无结果和存储失败区别开

没有搜索命中不代表数据库空了。界面至少要区分三种情况:仓储尚未加载完成,显示简洁的加载提示;配方数据有效但条件交集为零,显示“没有符合条件的配方”和清除筛选动作;仓储读取失败则显示错误与重试入口。若读取失败后也显示“没有记录”,用户很可能误以为自己的配方被删除,再重复创建造成冲突。此处“重试”应该重新调用 Repository 的读方法,不应在页面直接篡改 user_recipes_v1。

空态恢复应明确优先级:点击“清除筛选”时,将关键词置空、分类改为全部、取消只看收藏;不能顺手清空 favoriteIds。删除自建配方时,如果 selectedId 正好指向被删记录,应由此前的仓储事务把选择置空,然后页面回到列表。切换分类和关键词只是视图行为,不需要触发一次闪烁的保存中;收藏改变才需要执行仓储持久化。将这两类动作混淆,是许多看似简单的搜索页出现重复读盘和乱序刷新问题的原因。

工程上还有一道不可忽视的校验:旧版本存储的 active_filter_v1 可能是无效字符串,启动恢复时要回退到“全部”,不能把它直接断言成新的联合类型。用户输入过长、配方名称有前后空格、某项比例为空,也应按一套一致的规则处理。建议用数个特意构造的用例手工走查,而不是只在默认“全部”页面点一遍按钮就宣布功能完整。

另外要观察搜索中的导航关系:在列表里选中一张卡片进入详情,回来后最好保留刚刚使用的筛选条件,因为用户正在比较同类配方;但如果详情页删除了该卡片,则返回时必须依据新的仓储快照重新计算结果。不要把详情页传回来的整份旧数组无条件覆盖首页。筛选的恢复规则和业务实体的生存规则不同,应通过 ID 查找来重新建立关系。对含有空格的英文配方名,输入“latte”和“ LATTE ”应取得一致结果;对纯空格查询,则应与空关键词具有相同含义。页面测试覆盖这些边界,比单纯调整搜索框的阴影更能降低交付风险。

五、调试验证顺序与这一篇的取舍

从原有三个内置配方启动,确认“全部”能显示三项;输入“燕麦”仅保留燕麦拿铁;切换“冷饮”出现空状态;执行“清除筛选”恢复完整列表。再收藏柑橘冷萃,打开“仅看收藏”,此时只出现它;同时选择“热饮”得到无结果,解除开关后恢复热饮。最后创建 u_N 配方,先搜索它,再删除它,核对列表和收藏不会留下悬挂项。对每一步最好同时观察 Repository 的返回值、父页数组长度和 UI 可见数量,但这些信息应清楚区分,不需制造所谓的实测耗时截图。

此方案适用于单设备、本地几十条手记的静态筛选。查询是同步派生计算,并不会自动解决海量数据滚动、远端分页或者多窗口实时一致性;名字排序也不等于用户手动排列顺序。更完整的解决方案需要显式定义分页数据源与查询生命周期。这里优先完成三组查询条件的数学交集、稳定 ID、空态语义以及存储提交边界,以便连载最后一篇做功能收束和可重复验证,不再让界面操作意外修改业务事实。

相关官方参考:ArkUI 状态管理概览、ForEach 使用指南、User Preferences 持久化指南。

Logo

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

更多推荐