在文搜图页面做“下拉刷新”,很容易只盯着转圈动画:手指往下一拉,重新请求第一页,然后把列表替换掉。这个演示在网络顺畅时大多看不出问题。真正麻烦的是,旧的第二页可能还在路上。它晚于新第一页返回,把已经更新的结果重新拼上去,用户就会看到一组来源不同、排序标准也不同的照片。刷新动画虽然停了,数据却仍然不可信。

我这次把问题缩到一张页面和两个异步入口:Refresh 负责发起刷新,普通“加载下一页”按钮负责消费游标。演示工程叫 SearchPulse,业务任务为 SP-1040,关键词固定为“雨夜街角”。用可控的本地数据模拟请求,不连接相册、不调用真实文搜图检索接口。初次列表可有十二项,模拟第二次刷新后第一页只有十项;一笔上一代的翻页结果晚到,应该被丢弃。这里讨论的是应用自己的请求协议,不是系统会替开发者自动取消网络连接。

一、先定义“列表属于哪一次搜索”

我们经常把游标当成无状态字符串,看到后端给了 nextCursor 就直接继续拉取。但游标只有放在某次查询上下文里才有意义。关键词没变不代表结果快照没变:索引可能增删、搜索排序可能刷新、用户也可能切换筛选条件。旧游标不能无条件交给新列表接着用。

SearchPulse 的规则很简单:任何主动刷新都会产生新的 generation。发出的请求捕获当时的代数;返回时先对比当前代数,再决定是否允许更新 rows。第一页成功之后才能设置新游标;刷新失败时,应保留旧内容,并给出可以重试的状态。既不能提前清空全部可见内容制造白屏,也不能把失败解释成“找不到图片”。

演示把每页的数据源定义成严格类型。下面的 queryDemo 是业务模拟适配器,不是华为提供的检索 API。为了让界面数据固定,C1 表示下一页游标,null 表示第一页;正式项目只需要把适配器替换为真实的检索服务,同时保留代际校验。

export interface PhotoHit {
  id: string;
  title: string;
}
export interface PhotoPage {
  hits: PhotoHit[];
  nextCursor: string | null;
}
// 本地固定样例,真实搜索服务请另行接入。
export async function queryDemo(cursor: string | null): Promise<PhotoPage> {
  if (cursor === 'C1') {
    return { hits: [{ id: 'P11', title: '街灯' },
                    { id: 'P12', title: '雨伞' }], nextCursor: null };
  }
  let hits: PhotoHit[] = [];
  for (let i = 1; i <= 10; i++) {
    hits.push({ id: `P${i}`, title: `雨夜街角 ${i}` });
  }
  return { hits: hits, nextCursor: 'C1' };
}

这里故意不写 page++ 作为提交条件。分页序号只能说明向后翻了几次,不能说明属于哪次结果快照。id 是图片的业务标识,不能拿数组下标替代;即使下次刷新调整了展示顺序,也不应该把第三区块的选择态误贴给另一张图片。示例里 P11、P12 是模拟旧页的两个项目,和真实照片没有关联。

二、Refresh 是入口,刷新代数才是数据门禁

组件的 refreshing 控制下拉反馈,它是一个可见的 UI 状态,不等同于请求已成功。页面至少还要区分 loadingMore、errorText、generation 和 nextCursor。尤其要注意 finally:如果直接在任何请求完成时把 refreshing 改成 false,旧请求的清理回调就可能让新一轮刷新动画提前停止。

我把 generation 设为普通私有字段,不把它当展示数据;UI 只渲染结果和状态。每次刷新先把旧代数加一,再记录本轮 mine,等待数据返回。如果 mine !== generation,直接返回,连错误消息都不更新。另一条路径“加载更多”也必须带着相同的代数校验,并阻止同一游标重复请求。

// SearchPulsePage.ets 中 @Entry @Component struct SearchPulsePage 的成员
@State rows: PhotoHit[] = [];
@State refreshing: boolean = false;
@State loadingMore: boolean = false;
@State errorText: string = '';
private generation: number = 1;
private nextCursor: string | null = 'C1';

private async refreshFirst(): Promise<void> {
  const mine = ++this.generation;
  this.refreshing = true;
  this.loadingMore = false; // 新代际主动回收旧翻页按钮状态
  this.errorText = '';
  try {
    const page = await queryDemo(null);
    if (mine !== this.generation) return;
    this.rows = page.hits;
    this.nextCursor = page.nextCursor;
  } catch (error) {
    if (mine === this.generation) this.errorText = '刷新失败,保留旧列表';
  } finally {
    if (mine === this.generation) this.refreshing = false;
  }
}

private async loadMore(): Promise<void> {
  if (this.refreshing || this.loadingMore || !this.nextCursor) return;
  const mine = this.generation;
  const cursor = this.nextCursor;
  this.loadingMore = true;
  try {
    const page = await queryDemo(cursor);
    if (mine !== this.generation) return;
    this.rows = [...this.rows, ...page.hits];
    this.nextCursor = page.nextCursor;
  } catch (error) {
    if (mine === this.generation) this.errorText = '下一页获取失败';
  } finally {
    if (mine === this.generation) this.loadingMore = false;
  }
}

这段代码有一个刻意保留的产品决定:刷新只在第一页成功后替换旧列表,失败则保留旧数据。“旧数据仍然可见”和“后台刷新失败”要同时告诉用户,不能让屏幕静默展示旧数据,好像数据刚同步过。正式项目还需处理返回重复 ID 的去重,以及数据源服务端对游标有效期的声明,不能仅凭客户端递增数字认为一切安全。

旧 loadMore 的 finally 因代数不匹配不会清除 loadingMore,因此刷新开始时还应该把这个可视状态归零。更稳妥的实现是在开始新代际时同时复位上一代的按钮状态;旧异步函数即使随后执行,也无权把状态写回来。这是示例中值得读者亲手补充的边界,也是前后端接口协定的一部分。

三、把真实交互接进列表,检查异常顺序

页面层尽量薄:用户下拉只触发 refreshFirst,按钮只触发 loadMore,它们不负责拼接或改写游标。用 ForEach 的 hit.id 做稳定键,让前后两次刷新不会因为数组位置变化错误复用卡片。为了演示系统下拉和业务主动刷新共存,Refresh 里放一个 List,按钮作为列表末项。具体组件事件签名仍应以项目所选 API 版本的 SDK 类型声明为准。

// 接在上述 SearchPulsePage 的组件内
onPageHide(): void {
  this.generation++;             // 页面隐藏后旧返回失效
  this.refreshing = false;
  this.loadingMore = false;
}

build() {
  Column() {
    Text('SearchPulse · 雨夜街角').fontSize(24).fontWeight(FontWeight.Bold)
    Text(`任务 SP-1040 · 结果 ${this.rows.length} 条`)
    if (this.errorText.length > 0) {
      Text(this.errorText).fontColor(Color.Red)
    }
    Refresh({ refreshing: $$this.refreshing }) {
      List() {
        ForEach(this.rows, (hit: PhotoHit) => {
          ListItem() { Text(`${hit.id}  ${hit.title}`).padding(14) }
        }, (hit: PhotoHit) => hit.id)
        ListItem() {
          Button(this.loadingMore ? '加载中' : '加载下一页')
            .enabled(!this.loadingMore && !this.refreshing)
            .onClick(() => { this.loadMore(); })
        }
      }
    }.onRefreshing(() => { this.refreshFirst(); })
  }.width('100%').height('100%')
}

页面上展示的“已同步 · 10条”“代际2”“已丢弃旧页1次”都是预设业务场景的演示文案。它们不是系统自动统计值,更不是对真实搜索服务做过压测的结果。图里的 HiLog 也是为了说明团队应该观察哪些字段而排版的示意内容。要在实际项目中获得同样日志,开发者需要在请求创建、入库前校验、丢弃和结束四处统一打点。

一轮值得执行的验证顺序是:先发第一页,紧接着触发加载下一页;在旧页返回之前进行下拉刷新;让新第一页先完成,最后放行旧页返回。预期 UI 保留新十条,不出现 P11、P12;loadingMore 不持续占位;如果页签离开页面,后续的完成回调不会再修改结果。另外要观察刷新动画是否准确结束,而不是看见第一页就随手把旧翻页按钮恢复成可点击。检查同一游标的并发点击是否被拦截;当结果数为零时是否仍显示“空结果”,而不是遗留上一代卡片。这里的成功标准应从界面可见性、游标归属和操作可恢复性三个维度独立记录。这样的测试重点不是毫秒速度,而是交错顺序。使用可控 Promise、模拟延迟或接口代理都能验证,不需要依赖弱网“碰运气”。

还有个容易遗漏的边界:页面隐藏与组件销毁不是一回事。onPageHide 可以让旧响应失去 UI 写入资格,但并不能保证取消已经发送的网络请求。真实网络层若支持取消,需要在适配器里持有取消句柄;若不支持,也必须保持最终结果无副作用。请求可能耗费资源,业务层丢弃结果只解决状态一致性,不解决带宽浪费。

实际接入服务端以后,还需要明确游标对关键词、排序版本以及用户身份的绑定协议。如果服务端在刷新前后允许同一字符串游标代表不同结果,客户端单凭字符串相等根本无法判定安全;这时应该由接口附带快照标识,或明确让旧游标过期。分页失败不应触发整页清空,刷新失败也不能暗中继续使用已无效的下一页游标。这里的代际号解决的是客户端并发返回的顺序问题,不是替代服务端分页一致性的银弹。

最后回到这张小页面。Refresh 只负责给用户一个明确的“我在刷新”的交互入口;能够保证列表不混代的是请求所有权。一个游标属于一代快照,一个错误只能解释它自己的请求,一个 finally 也只能收尾它自己的可视状态。在文搜图、相册推荐乃至搜索建议中,这条界线都比旋转动画是否流畅更重要。

技术与素材边界:本文的 queryDemo 是纯 ArkTS 本地模拟;没有调用真实文搜图引擎、没有接入系统相册,也未在 DevEco 中编译运行。三张配图均为独立制作的演示画面,日志、结果数和状态不是实测证据。

官方参考:ArkUI 瀑布流下拉刷新场景、页面与自定义组件生命周期。

Logo

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

更多推荐