配图和 HiLog 均为教学示意,未声称在 DevEco Studio 或真机实际运行。本文从可复现的本地模拟结果讨论列表层的正确性,真实文搜图模型、检索 SDK、设备耗时不在演示覆盖范围内。

一、问题不是“能不能搜出来”,而是候选进来以后怎样不乱

文搜图产品通常先把自然语言送入检索层,再将候选照片交给界面展示。检索层与界面层如果被写成同一个回调,第一次返回十几条数据时看不出问题;到了分页、重复触底或者更换搜索词,列表却可能出现重复卡片、错位图片和点击进入另一张照片的情况。这篇只拆解后一半:当候选结果分批送到 ArkUI 时,怎样保证内容身份稳定、重复项不占位、通知次数与实际新增数一致。不虚构 Core Vision Kit 的返回结构,也不把本地模拟叫作真实检索性能。

SearchSlice 的输入词固定为“山间露营”,业务会收到两个预置批次。第一批包含 P-207「山谷帐篷」0.92、P-215「湖边营地」0.89、P-220「松林小屋」0.85;第二批为 P-215「湖边营地」0.89、P-236「星空草坡」0.81。五条输入中 P-215 出现两次,最终界面应当只有四张不同的结果卡片。任务编号 SS-203 只用于串联示例记录,不是 SDK 会返回的 ID,也不是已经完成测试的证明。

这里有三个不同的身份。搜索词表示用户意图,任务编号用于排查一轮演示,照片业务 ID 决定卡片是不是同一张图。不能用数组下标充当卡片 key:追加一批、删去某条或调整排序都会让下标指向不同照片。也不能把文件路径简单用作唯一标识,因为图片搬家或生成缩略图后路径可能改变。Demo 规定上游为每张图片分配不随列表位置变化的 ID,分页去重也以此为准。如果真实后端没有提供稳定 ID,应先明确身份策略,再谈懒加载优化。

二、先构造最小输入,拒绝在 UI 回调里临时猜字段

本文选择 LazyForEach 与 IDataSource,原因不是声称它比新版本 Repeat 更快,而是这个练习需要观察显式数据变更通知。华为官方新版资料指出,LazyForEach 需要开发者实现数据源接口并管理增量更新;Repeat 的 virtualScroll 模式能直接响应状态变量变化,在新工程中应按组件适用条件比较。示例把候选对象视为业务适配层交付的结果,不调用未确认的文字检索函数名。下面的类型与样本刻意收敛,保证去重行为可以用肉眼核对。

interface SearchItem {
  id: string;
  title: string;
  score: number;
}

const pageA: SearchItem[] = [
  { id: 'P-207', title: '山谷帐篷', score: 0.92 },
  { id: 'P-215', title: '湖边营地', score: 0.89 },
  { id: 'P-220', title: '松林小屋', score: 0.85 }
];
const pageB: SearchItem[] = [
  { id: 'P-215', title: '湖边营地', score: 0.89 },
  { id: 'P-236', title: '星空草坡', score: 0.81 }
];

把相似度分数当作展示值即可,本例不对 0.92 解释为“92% 准确率”。真实检索的分数分布受索引、模型、数据集和归一化方法影响,不适合在界面层拍脑袋设统一阈值。如果业务还需要相似图片聚类、低分过滤或安全策略,应该作为单独且可审计的规则模块运行,不能悄悄塞进去重循环。演示里的四个标题、分数仅是手工输入数据,因此后文“结果”指预期状态推演,不指模型评测记录。

三、数据源维护唯一 ID,并把新增通知对应到真实行号

IDataSource 关键方法包括 totalCount、getData 和数据变化监听的注册、注销。每当一张全新卡片加入底层数组,向监听者发送对应位置的 onDataAdd;遇到重复 ID 则不改变数组,也不发新增通知。这里不提供重排序功能,分数变化也不覆盖老数据,刻意避免把“新增”“修改”“移动”混在一个方法里。后端确有版本更新时应另设 update 路径,并根据所用 API 的数据通知语义对接。一个批次内若重复,也会在本次遍历中被 Set 排除。

class ResultSource implements IDataSource {
  private rows: SearchItem[] = [];
  private listeners: DataChangeListener[] = [];
  private ids: Set<string> = new Set<string>();

  totalCount(): number { return this.rows.length; }
  getData(index: number): SearchItem { return this.rows[index]; }
  registerDataChangeListener(l: DataChangeListener): void {
    if (!this.listeners.includes(l)) { this.listeners.push(l); }
  }
  unregisterDataChangeListener(l: DataChangeListener): void {
    const i = this.listeners.indexOf(l);
    if (i >= 0) { this.listeners.splice(i, 1); }
  }
  appendPage(batch: SearchItem[]): number[] {
    const inserted: number[] = [];
    batch.forEach((x: SearchItem) => {
      if (this.ids.has(x.id)) { return; }
      this.ids.add(x.id);
      const index: number = this.rows.length;
      this.rows.push(x);
      inserted.push(index);
      this.listeners.forEach((l: DataChangeListener) => l.onDataAdd(index));
    });
    return inserted;
  }
  clear(): void {
    this.rows = [];
    this.ids.clear();
    this.listeners.forEach((l: DataChangeListener) => l.onDataReloaded());
  }
}

为什么 clear 要连同 ids 一起清?因为用户切换查询词后,历史搜索与新搜索可以包含同一张照片;若只清空数组而留着旧的 ID 集合,新搜索里那张图会被误判为重复。又为什么不每次加入一条都用 onDataReloaded?全量刷新虽容易写,但会让列表难以区分“末尾追加”和“内容重建”。本例展示细粒度通知,而不是保证任何设备都获得某个固定帧率。监听器在组件解绑时由框架按接口约定注销,业务侧不能把它当作永久全局监听表。

四、最薄 UI:只认唯一键,显示可解释的批次结果

页面只负责输入词、点击模拟按钮与渲染,不负责识别重复。LazyForEach 的第三个参数返回 item.id,把可见节点身份绑定到业务对象。下列代码展示结构和关键状态,真实项目应将上面的两个批次从数据层注入;这里按钮使用预置数据,用来观察通知是否生效。List 负责滚动而不是大图原图解码,展示缩略图时还需叠加占位图和内存策略。文搜图与 ArkUI 列表管理是两层能力,不应该让 UI 决定后端是否已成功建索引。

@Entry
@Component
struct SearchHome {
  private source: ResultSource = new ResultSource();
  @State summary: string = 'SS-203 · 等待载入';

  private loadFixture(): void {
    this.source.clear();
    const a: number[] = this.source.appendPage(pageA);
    const b: number[] = this.source.appendPage(pageB);
    this.summary = `SS-203 · MERGED 4 / DUP ${pageB.length - b.length}`;
    console.info(`SS-203 batch=1 added=${a.length}`);
    console.info(`SS-203 batch=2 added=${b.length} dup=1 total=4`);
  }
  build() {
    Column({ space: 12 }) {
      Text('SearchSlice · 山间露营').fontSize(22)
      Button('加载两个模拟批次').onClick(() => this.loadFixture())
      Text(this.summary)
      List() {
        LazyForEach(this.source, (item: SearchItem) => {
          ListItem() {
            Row({ space: 16 }) {
              Text(item.id).fontWeight(FontWeight.Bold)
              Text(item.title)
              Text(item.score.toFixed(2))
            }.padding(12)
          }
        }, (item: SearchItem) => item.id)
      }.layoutWeight(1)
    }.padding(16).height('100%')
  }
}

五、调试不要只盯页面,先在数据源层核对不变量

推荐先记录每次输入的任务 ID、页序号、原始长度、新增长度、重复长度和最终长度,不需要先接入网络就能识别业务错误。本例的推演日志为 SS-203 batch=1 added=3 和 SS-203 batch=2 added=1 dup=1 total=4。看到四张卡片还不够,应进一步核对唯一键恰好为 P-207/P-215/P-220/P-236,列表中 P-215 只出现一次。第二次按下加载按钮后,本示例明确清空并重建,因此页面仍为四张而不是八张。若项目需要在同一查询内继续翻页,则不应该在每次翻页前调用 clear。

更容易被遗漏的测试是乱序分页:假设第二批先回来,第一批后回来,本实现仍能去掉重复,但排列顺序会随到达时间变化。产品如果要求按相似度或分页游标维持固定顺序,必须引入请求序列、游标一致性和排序规则;那已经超出本练习的“原地追加”模型。再把搜索词从“山间露营”改成“雪山湖泊”时,应创建新的查询代次并拒绝旧请求回写,避免上个词的候选混入。网络重试也要区分同一页重放与真正的新页,不能只用页号假定内容不变。

六、边界和验收口径

验收可以采用一张很短的人工核对单:输入五条、唯一四条、重复一条、稳定键四个、展示顺序先 A 后 B。若重复项分数比旧项高,本例仍保留首次出现的记录,这是选择而非普遍最优解;要“取最新”或“取最高分”必须显式定义替换通知。大量图片还会涉及缩略图缓存、列表复用状态、长图占位与页面销毁,不能凭四条样本推断海量图库性能。LazyForEach 的适配也需要针对目标 SDK 编译和设备滑动验证,不能拿示意图当性能曲线。对于全新代码,新版官方资料同时提供 Repeat 虚拟滚动能力,选型应服从实际版本、组件结构及测试结果。

本例真正要留下的工程习惯,是把数据身份、接口通知、显示顺序和任务边界拆成四张独立的纸。它们分别有自己的验收方法:ID 用集合检查,通知用监听测试,顺序用固定样本比对,旧任务用代次测试。等这些底层约束成立后,再接入真实文字检索能力,才不会让检索模型为界面层的数据错误背锅。

官方资料与复核说明

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐