文搜图界面有一种很不显眼的错位:用户正在查看某张照片的标签,新的结果插入队尾,或者标签被重新整理,列表看起来刷新了,收藏和选中状态却短暂跑到了隔壁卡片。很多时候问题不在搜索接口,而在列表项究竟以什么身份存在。本文以 PhotoFeed 演示工程为载体,把可复现的状态序列和增量通知单独拎出来讨论。所有数据是预设演示值,并非真实相册扫描或语义检索报告。

一、先把工程问题收窄到一条结果流

假设一项“海边”搜索已经返回三条本地标签匹配结果:P-03 海边日落、P-07 灯塔、P-11 岸边步道。任务号固定为 PF-084,当前选中项是 P-07。随后发生两件事:原有 P-07 的标签改成“海边 / 灯塔”;新发现的 P-18 海边咖啡馆加入末尾。页面最终应显示四条,P-07 仍被选中。结果个数变化与单条内容变化是两种不同的事件,最好不要一概用全量重建处理。

此处的“文搜图”仅指已经建立标签索引的本地演示数据,并不是调用了端侧语义模型。应用若要接入真实文搜图能力,需要另外处理检索服务、权限、排序置信度和素材来源。本文只验证界面侧应该怎样接住结果流。

产品层面还要明确一个判断:用户感知的稳定,不是卡片的位置永远不变,而是在数据位置改变时同一张照片仍能被识别为同一张照片。因此演示里把 P-07 定义为业务主键,禁止用数组下标冒充主键。下标适合定位当前数组的一行,不适合长期标记实体。

二、让 IDataSource 掌握“谁变了”

LazyForEach 不直接管理普通数组的变化,它通过 IDataSource 读取当前长度和指定下标的数据,并接收 DataChangeListener 通知。先把变动约束在数据源内:新增时检查重复 ID,修改时替换目标对象,通知给当前监听器。以下代码解决的就是“数组改变了但界面不知道应当刷新哪一行”的问题。

interface PhotoHit {
  id: string;
  title: string;
  tags: string;
}

class HitSource implements IDataSource {
  private rows: PhotoHit[] = [
    { id: 'P-03', title: '海边日落', tags: '海边 / 日落' },
    { id: 'P-07', title: '灯塔', tags: '灯塔' },
    { id: 'P-11', title: '岸边步道', tags: '海边 / 步道' }
  ];
  private listeners: DataChangeListener[] = [];

  totalCount(): number { return this.rows.length; }
  getData(index: number): PhotoHit { return this.rows[index]; }
  registerDataChangeListener(listener: DataChangeListener): void {
    if (this.listeners.indexOf(listener) < 0) this.listeners.push(listener);
  }
  unregisterDataChangeListener(listener: DataChangeListener): void {
    const i = this.listeners.indexOf(listener);
    if (i >= 0) this.listeners.splice(i, 1);
  }
  updateTag(id: string, tags: string): void {
    const i = this.rows.findIndex((row: PhotoHit) => row.id === id);
    if (i < 0) return;
    const old = this.rows[i];
    this.rows[i] = { id: old.id, title: old.title, tags: tags };
    this.listeners.forEach((l: DataChangeListener) => l.onDataChange(i));
  }
  append(row: PhotoHit): void {
    if (this.rows.some((item: PhotoHit) => item.id === row.id)) return;
    const i = this.rows.length;
    this.rows.push(row);
    this.listeners.forEach((l: DataChangeListener) => l.onDataAdd(i));
  }
}

这里的 getData 只取已有数据,不应在里面发网络请求、写数据库或改变数组。onDataChange(i) 表示已有第 i 项的内容变化;onDataAdd(i) 表示该位置插入新项。两次通知必须在数据完成修改后触发,否则渲染器读到的数量、索引和实际数组可能短暂不一致。删除与批量重排也有对应通知规则,这里没有用到就不虚构实现。

如果数据源未来由多种后台结果共同驱动,还需要让变更统一落在 UI 所在线程的调度入口,避免不同异步回调交错修改集合。这个小例子并未跨线程共享 HitSource,也不声称解决了所有并发冲突。

三、稳定键是逻辑身份,不是列表装饰

接下来处理“卡片选中态错位”。在 LazyForEach 的第三个参数提供基于业务 ID 的键,而不是 index.toString()。选中的状态也保存 ID,不保存第几个格子。这样即使其他记录插入或删除,业务选择仍能找到同一实体。下面这段代码解决的是 UI 组件与业务实体的身份映射问题。

@Entry
@Component
struct PhotoFeedPage {
  private source: HitSource = new HitSource();
  @State selectedId: string = 'P-07';
  @State resultCount: number = 3;

  aboutToAppear(): void {
    this.source.updateTag('P-07', '海边 / 灯塔');
    this.source.append({ id: 'P-18', title: '海边咖啡馆', tags: '海边 / 咖啡' });
    this.resultCount = this.source.totalCount();
  }

  build() {
    Column({ space: 12 }) {
      Text(`PhotoFeed · PF-084 · 海边 · ${this.resultCount} 条`)
      List({ space: 8 }) {
        LazyForEach(this.source, (item: PhotoHit) => {
          ListItem() {
            Column({ space: 6 }) {
              Text(`${item.id}  ${item.title}`)
              Text(item.tags).fontColor('#667085')
              Text(this.selectedId === item.id ? '当前选中' : '查看详情')
            }.padding(12)
          }.onClick(() => { this.selectedId = item.id; })
        }, (item: PhotoHit) => item.id)
      }.layoutWeight(1)
    }.padding(16)
  }
}

这段演示在页面出现时直接施加预设变动,目的在于让读者看到最终态,不代表真实业务应该在每次 aboutToAppear 都写入相同数据。正式页面应把结果订阅、首次初始化以及视图重入分开,避免返回页面时重复触发变更。由于 append 带有 ID 去重,本例重复调用不会生成第二条 P-18,但标签更新仍会触发一次变更通知。

注意 LazyForEach 的 key 回调只负责身份,不应该把查询词或收藏状态拼进 key。若 P-07 的标签从“灯塔”变成“海边 / 灯塔”,key 仍是 P-07,局部刷新和选中关系才有稳定的前提。真正需要切换用户、工作空间或完全不同相册时,应该明确建立新的命名空间,而不是让两个实体共用同一个 ID。

四、如何给增量刷新增加可验收的轨迹

日志有一个经常被忽略的作用:它不是记录“刷新成功”四个字,而是留下足够的信息来重建一次结果流。对 PF-084 可以预设以下事件顺序:21:40:11 loaded=3,21:40:12 change index=1 id=P-07,21:40:13 append index=3 id=P-18 total=4,21:40:14 selected=P-07。这四条是本文演示脚本的期望输出,不能当作真实设备 HiLog 测试截图。

有些应用把所有刷新都交给 onDataReloaded()。功能上未必立刻出错,但它会抹去“只是某一行标签变化”的业务事实,可能让复杂卡片重建并影响用户操作连续性。局部通知并非越细越好:当筛选条件变化导致整批数据的顺序和集合都换了,明确全量重新加载往往更好理解。工程取舍的关键是数据变更语义,而不是追求最少的通知次数。

为了让“新搜索覆盖旧搜索”的问题也有边界,可以给服务回调加一次性代次。下面展示一个纯本地异步演示片段,不引入虚构的后端 API。它阻止页面退出后对旧结果继续提交;对真实网络调用,应由专门服务层负责取消或忽略过期响应。

// PhotoFeedPage 的逻辑片段:不会自动中断实际网络请求
private requestEpoch: number = 0;
private alive: boolean = true;

async applyMockResult(items: PhotoHit[]): Promise<void> {
  const epoch = ++this.requestEpoch;
  const result: PhotoHit[] = await Promise.resolve(items);
  if (!this.alive || epoch !== this.requestEpoch) return;
  result.forEach((hit: PhotoHit) => this.source.append(hit));
  this.resultCount = this.source.totalCount();
}

aboutToDisappear(): void {
  this.alive = false;
  ++this.requestEpoch;
}

epoch 代表本页面最新接受的请求代次,alive 只决定 UI 能否消费结果,并不保证底层请求被取消。实际项目还要在再次进入页面时重设活动状态,检查复用组件的生命周期,以及避免旧 Promise 持有不该长期存活的页面引用。短小的屏障解决的是过期结果不得覆盖当前 UI,不是并发任务自动释放。

五、验收时看稳定性,不只看列表长度

下图将四条演示记录放在同一视图:P-03、P-07、P-11、P-18,其中 P-07 的标签已经修改但身份未变,页面仍显示“当前选中 P-07”;结果总数是 4。截图上的时间统一为 21:40,只作为排版和字段核对基准。它不是一张真实手机运行证据。

如果准备在工程里重现问题,我会把验收拆成三轮:先保持数据不变连续点选,确认点击回传的 ID 与视觉高亮一致;再只更新 P-07 标签,看有没有无关卡片被替换;最后追加 P-18,检查它只出现一次、旧选中项没漂移。随后把流程扩展到删除首项、排序、窗口重布局和快速返回页面。此时若发现错误,先检查 key 的唯一性、通知索引是否对应修改后的集合,再怀疑布局框架。

还有一个实际限制:稳定 key 只能降低身份错配风险,不能代替图片缓存策略。相册缩略图解码、预加载半径、可复用节点内部状态都可能决定滑动是否流畅;本文没有设备内存和 FPS 实测,也不能因为卡片看上去没跳就宣称性能提升。

六、从示例迁移到实际文搜图工程

最值得留下的不是一个 HitSource 类,而是三条工程约束:结果实体有稳定 ID;变更先写数据再发对应通知;选中态由业务 ID 驱动。这些规则既适用于按标签检索,也适用于接入模型后的候选照片列表。真正对接检索引擎时,还要处理候选去重规则、检索词切换、隐私授权、文件被删除、相册权限回收等情况。

本文刻意没有把“文搜图”包装成已经实现的多模态理解,也没有声称示意图是 DevEco 真正运行产生的记录。官方 LazyForEach 文档和 DevEco 参数检查说明给出了 IDataSource、监听器与通知接口的依据;代码应当放入对应工程,在目标 SDK 下自行编译和补充测试。对工程质量而言,能说清楚什么没有验证,比给出一张看似顺利的截图更重要。

资料核对: 华为 LazyForEach 数据源参数检查说明、华为 ArkUI 官方资料入口。

Logo

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

更多推荐