文搜图页面最难解释的异常,有时不是搜索不准,而是列表已经滑到最后一张,底部仍留着一块灰白卡片。用户继续上划,结果数偶尔又增加两张;返回页面重进,数量反而正常。这类现象不应该直接归咎于模型服务:图片异步解码、滚动容器的触底回调、游标推进以及页面复用,是四个不同的时间轴。本篇用一个可复现的构造数据场景,单独检查“搜索已经完成,列表尾部为什么还不收口”。

示例工程叫 PierLens,页面 SearchGallery.ets,搜索词固定为“傍晚的码头”,会话号 SEA-071。初始已有十二张图片,尾页请求游标为 PAGE-02;构造返回八条记录,其中 P-010、P-011 与首屏重复,另外六条是新结果。预期总数 18,剔除重复 2,尾页游标为空,页面显示 END_REACHED。这些是文章为了讲清状态机而编排的示例输入与预期结果,不是某个线上文搜图模型的实测输出。

一、触底回调为什么不能代表“下一页只请求一次”

WaterFlow 是 ArkUI 的瀑布流容器,FlowItem 负责承载不同高度的内容;官方资料也介绍了 onReachEnd 与 layoutDirection 等使用方式。滚动到边缘产生回调,只说明视区位置满足条件,并不承担接口幂等、网络去重或页码提交。尾部布局发生变化,或一次数据更新重新触发边界计算,都可能让业务层再次遇到“已经到底”的信号。如果直接在回调中 cursor++,同一个游标就可能出现并发请求。

这里故意不使用数组长度推断下一页。文搜图的数据列表经常遇到重复素材、过滤不合格缩略图、权限不可见项目,接口返回八条不意味着可以追加八条;相反,接口返回两条也不意味着已经到终点。决定是否结束的是服务端明确给出的下一游标,而不是客户端眼前卡片数量。另一个常见误解是“请求被取消了,就无需检查返回值”:并非所有底层请求都能及时中止,旧响应仍可能返回,所以提交入口必须有清晰的单向状态。

为了解决这一类重入问题,先建立一个与界面完全分离的闸门。以下代码只使用 ArkTS 对象、集合与 Promise;PageReply 是本文定义的业务响应合同,绝不是华为系统 API。fetchPage 是项目实际服务层需要提供的函数,示例测试可换成构造数据源。

interface PhotoCard {
  id: string;
  cover: string;
  cardHeight: number;
}
interface PageReply {
  items: PhotoCard[];
  nextCursor: string | null;
}
type TailPhase = 'READY' | 'LOADING' | 'RETRYABLE' | 'END_REACHED';

class TailGate {
  photos: PhotoCard[] = [];
  phase: TailPhase = 'READY';
  nextCursor: string | null = 'PAGE-02';
  duplicateCount: number = 0;
  private ids: Set<string> = new Set<string>();

  seed(items: PhotoCard[]): void {
    this.photos = items.slice();
    this.ids = new Set(items.map((item: PhotoCard) => item.id));
  }

  async append(fetchPage: (cursor: string) => Promise<PageReply>): Promise<void> {
    if (this.phase !== 'READY' || this.nextCursor === null) return;
    const cursor: string = this.nextCursor;
    this.phase = 'LOADING';
    try {
      const reply: PageReply = await fetchPage(cursor);
      for (const item of reply.items) {
        if (this.ids.has(item.id)) { this.duplicateCount++; continue; }
        this.ids.add(item.id);
        this.photos.push(item);
      }
      this.nextCursor = reply.nextCursor;
      this.phase = reply.nextCursor === null ? 'END_REACHED' : 'READY';
    } catch (_) {
      this.phase = 'RETRYABLE';
    }
  }
  allowRetry(): void {
    if (this.phase === 'RETRYABLE') this.phase = 'READY';
  }
}

phase 同时承担一次性并发闸门和用户可见的反馈语义。它先被置为 LOADING,于是重入回调会在方法开头退出;只有返回并完成去重、游标提交后才重新开放触底请求。发生异常时保留原游标,让用户主动重试而不是自动狂刷接口。Set 记录的是业务图片 ID,而不是序号或资源 URL:URL 可能带签名参数,数组序号会随分页变化。当前闸门作用域是一次页面会话,如果产品允许切换关键词,需要为不同搜索会话新建对象,并防止旧会话把结果写进新页面。

二、把加载状态放在列表之外

瀑布流尾部的“空白卡”经常是加载占位节点留下的。开发中容易把“正在加载”“重试按钮”“没有更多了”统统作为普通 FlowItem 塞进去。这样做在列数变化、卡片高度不一时会给布局引入一个伪结果,甚至让 onReachEnd 反复被触发。本文把状态栏放到 WaterFlow 下方,并规定只有真实 PhotoCard 才能成为 FlowItem。这里的图片地址来自业务数据,生成的 UI 图用彩色占位缩略图演示,不声称已经连接远端图库。

下面只展示页面的关键部分;initialTwelve 是项目本地预置的十二条 PhotoCard 数据,fetchPage 是已绑定的业务服务函数。两者由调用端注入,避免为了演示捏造不存在的 HarmonyOS 检索 API。重点是回调如何调用闸门、如何复制数组触发可观察刷新,以及末页怎样不再留下“加载中”卡片。

@Entry
@Component
struct SearchGallery {
  private gate: TailGate = new TailGate();
  @State cards: PhotoCard[] = [];
  @State phaseText: string = 'READY';

  aboutToAppear(): void {
    this.gate.seed(initialTwelve);
    this.cards = this.gate.photos.slice();
  }
  async loadTail(): Promise<void> {
    await this.gate.append(fetchPage);
    this.cards = this.gate.photos.slice();
    this.phaseText = this.gate.phase;
  }
  build() {
    Column({ space: 8 }) {
      Text('傍晚的码头').fontSize(23).fontWeight(FontWeight.Bold)
      Text(`SEA-071 · ${this.cards.length} 张`)
      WaterFlow() {
        ForEach(this.cards, (item: PhotoCard) => {
          FlowItem() {
            Image(item.cover).objectFit(ImageFit.Cover)
              .width('100%').height(item.cardHeight)
          }
        }, (item: PhotoCard) => item.id)
      }
      .columnsTemplate('1fr 1fr')
      .onReachEnd(() => { void this.loadTail(); })
      .layoutWeight(1)
      Text(this.phaseText).fontSize(13)
    }.width('100%').height('100%')
  }
}

这段 UI 是聚焦逻辑的节选,正式工程中要补齐本地数据文件与服务注入,而且应使用完整的失败重试按钮与空结果呈现。@State 要通过重新赋值来发布当前照片列表;ForEach 的 keyGenerator 使用图片 id,让已经显示过的结果在增量追加时仍有稳定身份。cardHeight 代表演示使用的缩略图排版高度,不能把原始图片像素尺寸直接无约束地当成组件高度,否则大图可能让整列出现突兀的超长留白。

还需要留意 Image 的资源加载方式。官方 Code Linter 在大图场景建议采用异步加载,不推荐为了抹掉占位闪动就滥用 .syncLoad(true)。对真实的文搜图结果,服务端应优先提供缩略图资源与尺寸信息,先让首屏可读,再按需加载原图。本文没有给出未经测试的滑动帧率数字,也没有声称切换某个属性就能消除所有卡顿;图片数量、码率、解码线程和容器复用策略都会共同决定体验。

三、用一组有重复的尾页验证边界

有效的验证不是看“列表能不能滑动”,而是看状态日志能否解释为什么这一次只追加了六张。初始十二张,拿 PAGE-02 请求尾页;让测试替身连续触发两次 loadTail(),两次请求中只有第一次应越过 READY 闸门。构造返回 P-010、P-011 两个重复 ID 及六条新 ID;最后返回 nextCursor: null。以下第三段代码仅构造本地场景,不访问网络,也没有调用模型推理服务。

const tailFixture: PageReply = {
  items: [
    { id: 'P-010', cover: 'mock://10', cardHeight: 140 },
    { id: 'P-011', cover: 'mock://11', cardHeight: 170 },
    ...[13, 14, 15, 16, 17, 18].map((n: number): PhotoCard => ({
      id: `P-${String(n).padStart(3, '0')}`,
      cover: `mock://${n}`,
      cardHeight: n % 2 === 0 ? 160 : 200
    }))
  ],
  nextCursor: null
};
async function fetchPage(cursor: string): Promise<PageReply> {
  if (cursor !== 'PAGE-02') throw new Error('unexpected cursor');
  return tailFixture;
}

mock:// 只是测试替身识别的字符串,不能直接交给真实 Image 组件作为生产资源 URI;接入时必须由数据适配层转换为合法资源。这个细节很重要:很多示例代码看上去“UI 已经写完”,实则只是数据门控可以单测,真正的图片加载仍需在工程中验证。上面的示例展示的是预期状态流:READY → LOADING → END_REACHED,结果总数 12 → 18,重复计数 0 → 2,游标 PAGE-02 → null。这里列出的日志数据用于设计核对,不能冒充 DevEco Studio 实机输出。

调试时应记录会话号、请求游标、进入与退出状态、收到条数、新增条数、重复条数、最终游标;不要打印包含用户搜索历史或临时签名的完整图片 URL。建议把这个记录分成两行:一行解释闸门是否拒绝重入,另一行解释本次分页到底新增多少,能让同事在没有网络抓包的情况下也看出哪里发生了反复追加。开发配图只放与 TailGate.append 有关的代码,日志配上 SEA-071 PAGE-02 +6 duplicate=2,不把长代码硬挤进模拟器区域。

结果页的目标效果如下图:词条“傍晚的码头”,总计 18 张,底部为 END_REACHED,旁边解释“过滤重复 2 条”。它是人为构建的页面示意,不代表真实模型输出了这些图片,也不代表已在设备上量过性能。

四、什么时候要换方案

这套轻量门控适合结果数量不大、分页请求由同一页面管理的图库。它不能替代服务端幂等设计,也不能防止用户跨设备收到重复数据;要从根上保证索引稳定,服务端仍需给出可重放、可校验的游标语义。如果返回结果会随索引更新而漂移,还应规定同一次搜索的快照版本,至少把“重新搜索”和“继续翻页”区分开。若客户端有筛选条件变更,务必让旧 Gate 失效,不能把新关键词的数据继续追加到旧列表。

另一个边界是内存。一次性 ForEach 只适合小型样例,结果达到数千条时需要考虑 LazyForEach、组件复用、缓存和图片预取,不能仅凭这个 Demo 推断大数据量下的帧率。华为官方还给出了基于 ScrollComponents 的 WaterFlow 优化方案,它的复用池与预创建机制属于下一层问题;本文有意先隔离业务分页问题,避免“换了一个瀑布流库”掩盖尾页重入。真正值得验收的不是画面多绚丽,而是 PAGE-02 只被有效消费一次、末页不再留下假的结果卡。

资料与验证边界: 华为官方 WaterFlow/ScrollComponents 最佳实践:https://developer.huawei.com/consumer/cn/doc/doccenter-ui-dev/bpta-waterflow-based-on-scrollcomponents ;官方触底回调说明:https://developer.huawei.com/consumer/cn/doc/doccenter-dev-faq/faqs-arkui-519 ;官方图片异步加载检查规则:https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/ide-hmos-hp-arkui-image-async-load 。

Logo

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

更多推荐