ArkUI Refresh:文搜图翻页游标代际隔离与刷新态收口【鸿蒙心迹】
在文搜图页面做“下拉刷新”,很容易只盯着转圈动画:手指往下一拉,重新请求第一页,然后把列表替换掉。这个演示在网络顺畅时大多看不出问题。真正麻烦的是,旧的第二页可能还在路上。它晚于新第一页返回,把已经更新的结果重新拼上去,用户就会看到一组来源不同、排序标准也不同的照片。刷新动画虽然停了,数据却仍然不可信。
我这次把问题缩到一张页面和两个异步入口: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 瀑布流下拉刷新场景、页面与自定义组件生命周期。
更多推荐





所有评论(0)