ArkUI + HarmonyOS 多窗口任务工作台:异步刷新与选中态一致性(连载03/06)【共创稿事节】
同一条任务在详情页里还是 T-102,列表刷新之后却跳到了 T-101。只要任务数据来自异步接口,这种看起来像“选中态乱了”的问题就不稀奇。上一阶段我们把业务选择 selectedId、导航历史 NavPathStack 和布局断点分成三种独立状态,已经解决了宽窄窗口切换和返回栈互相干扰的情况。但接口带来的新难题是:旧请求可能比新请求更晚返回,列表顺序可能变化,已选中的任务甚至会被删除。路由依旧正确,数据却不一定是用户最新想看的那份。

这一篇沿用任务工作台的 WorkTask,任务主键继续使用 T-101、T-102、T-103 等稳定标识;不把数组下标当作业务 ID,也不在刷新时重新创建路由栈。解决方案分为三层:异步响应只允许最新请求落地;数据变更要统一经过一次状态归并;UI 使用稳定键渲染列表,并在记录被删除时展示明确的失效状态。这样即使用户把窗口从手机收缩态拖到平板分栏,也不会让数据竞争悄悄变成页面导航错误。
两次刷新,为什么后发请求反而先完成
先看一条常见的调用顺序。任务列表进入页面时发起请求 A,用户又按下刷新按钮发起请求 B;B 很快返回了新任务排序,而 A 因网络抖动在最后才返回。若两处都直接执行 this.tasks = response,屏幕会短暂展示新数据,随后被过期的旧结果覆盖。开发者容易把它误判为 ArkUI 的状态没有刷新,实际上是应用允许过期响应覆盖较新意图。这与 @State 能否触发重绘是两个问题。
通过一个单调递增的请求序号,就可以在不依赖具体 HTTP 实现的情况下明确归属。每次主动刷新都拿到新的 requestVersion,响应回来时必须验证自己仍是当前版本。相同策略也适用于通过 Network Kit、Repository 或业务服务获取数据的场景。取消旧请求可以减少资源浪费,但取消是否成功有时无法严格保证;版本号判断是防止过期数据落地的最后一道门。

下面先把与系统 API 无关、易于验证的归并规则放在 entry/src/main/ets/viewmodel/TaskReducer.ets。它只处理普通 ArkTS 数据,不触摸 UI,也不会因为某个页面被销毁而改变计算逻辑。
export interface WorkTask {
id: string;
title: string;
owner: string;
description: string;
}
export interface TaskSnapshot {
items: WorkTask[];
selectedId: string;
selectedMissing: boolean;
}
export function mergeTasks(
latest: WorkTask[], selectedId: string
): TaskSnapshot {
const exists: boolean = latest.some((item: WorkTask) =>
item.id === selectedId);
return {
items: latest.slice(),
selectedId: selectedId, // 不因排序或缺项偷偷选择另一条
selectedMissing: selectedId.length > 0 && !exists
};
}
export function findSelected(
items: WorkTask[], selectedId: string
): WorkTask | undefined {
return items.find((item: WorkTask) => item.id === selectedId);
}
这段规则看似保守,却解决了一个很隐蔽的体验问题。假设 T-102 被删除,若为了“保持页面有内容”自动把选中项设成 latest[0].id,用户可能以为还在阅读 T-102,却看到另一条记录。正确做法是保留已知的 selectedId,标记为缺失,再由界面提示“任务已经不可用”。清除选中或改选另一条是用户的下一次操作,而不是数据刷新流程擅自决定。
把数据落地收束在一个入口
前文 Index 中已经有 @Provide('selectedTaskId') selectedId、@Provide('workStack') stack 和按断点切换的 Navigation。现在只替换列表来源与刷新方法:把静态 tasks 改为 @State,新增 loading 和 selectedMissing,并保存一个不参与 UI 的请求计数器。这里把读取任务列表的工作抽象成一个 TaskSource;网络层负责解析和抛出异常,页面只负责“接受还是丢弃”响应,避免把 URL、鉴权与刷新竞态混成一个难以排查的函数。
// entry/src/main/ets/viewmodel/TaskSource.ets
import { WorkTask } from './TaskReducer';
export interface TaskSource {
list(): Promise<WorkTask[]>;
}
// 本地样例数据源:用于演示刷新的状态边界。
export class SampleTaskSource implements TaskSource {
async list(): Promise<WorkTask[]> {
return [
{ id: 'T-103', title: '状态管理重构', owner: '测试组',
description: '检查任务主键与刷新一致性。' },
{ id: 'T-102', title: '多窗口布局优化', owner: '客户端组',
description: '验证列表重排后的详情指向。' },
{ id: 'T-101', title: '巡检表单校验', owner: '设计组',
description: '检查输入校验与错误反馈。' }
];
}
}
下面代码直接放入上一阶段的 Index 组件中,对已有字段进行替换或补充。组件仍然由 Navigation(this.stack) 构建页面,不在本篇重复整份路由代码;详页继续消费同一个 selectedTaskId。刷新函数使用 try/catch/finally,不让失败响应清空现有内容。注意,@State 数组替换为新的数组引用后,绑定到 UI 的列表会根据新数据重建相关项;ForEach 的键仍是 task.id,而不是数组索引。
// Index.ets 中新增的成员与方法
@Provide('taskItems') tasks: WorkTask[] = []; // 与详情共享最新列表
@State private loading: boolean = false;
@State private refreshError: string = '';
@State private selectedMissing: boolean = false;
private refreshVersion: number = 0;
private source: TaskSource = new SampleTaskSource();
aboutToAppear(): void {
this.refreshTasks();
}
private async refreshTasks(): Promise<void> {
const version: number = ++this.refreshVersion;
this.loading = true;
this.refreshError = '';
try {
const latest: WorkTask[] = await this.source.list();
if (version !== this.refreshVersion) {
return; // 较旧的返回值没有资格覆盖页面
}
const snapshot: TaskSnapshot = mergeTasks(latest, this.selectedId);
this.tasks = snapshot.items;
this.selectedMissing = snapshot.selectedMissing;
} catch (_error) {
if (version === this.refreshVersion) {
this.refreshError = '刷新失败,仍显示上次数据';
}
} finally {
if (version === this.refreshVersion) {
this.loading = false;
}
}
}
在示例工程中需要从 TaskReducer.ets 导入 WorkTask、TaskSnapshot 和 mergeTasks,并从 TaskSource.ets 导入 TaskSource、SampleTaskSource。如果要接真实接口,只替换 source.list() 的具体实现;不要把某个请求对象强行塞进 UI 生命周期里。一个需要注意的边界是:这里的 refreshVersion 是单页面实例中的最新请求仲裁,而不是跨进程的版本号或数据库事务标识。如果项目有多个独立窗口,必须进一步定义共享仓库中的版本与事件广播策略,不能指望一个组件字段让所有窗口自动一致。
列表排序变了,用户的焦点不能被“借走”
列表渲染的最小原则是稳定身份。ForEach 用 task.id 作为键,在服务器改变排序时,框架可以识别相同任务身份;但 UI 是否把某条内容画成蓝色,仍由 selectedId === task.id 决定。如果写成 selectedIndex === index,只要列表前面插进一个任务,就会把高亮交给另一个人。对于任务工作台,这种错配比短暂白屏更危险,因为用户可能在错误详情上继续操作。
@Builder
TaskList() {
Column({ space: 10 }) {
Row() {
Text('任务工作台').fontSize(22).fontWeight(FontWeight.Bold)
Blank()
Button(this.loading ? '刷新中' : '刷新')
.enabled(!this.loading)
.onClick(() => { this.refreshTasks(); })
}.width('100%')
if (this.refreshError.length > 0) {
Text(this.refreshError).fontColor('#B54708')
}
List({ space: 8 }) {
ForEach(this.tasks, (task: WorkTask) => {
ListItem() {
Text(task.title)
.width('100%').padding(16)
.backgroundColor(this.selectedId === task.id ? '#DAE8FF' : '#F3F6FA')
.onClick(() => { this.open(task.id); })
}
}, (task: WorkTask) => task.id)
}.layoutWeight(1)
}.padding(16).width('100%').height('100%')
}
这份 UI 片段中的 open(task.id) 沿用第二篇的“选中 ID + 单详情目的地”导航方法,不过要给它补一条缺项状态更新,否则删除后的失效提示会残留在下一次正常选择上。把旧 open 替换为下面的方法,保持栈不被重复追加;详情组件同时通过 @Consume('taskItems') tasks: WorkTask[] 消费上面 @Provide('taskItems') 提供的列表,把旧版的 TASKS.find(...) 改成 this.tasks.find(...)。这样列表和详情不会一个读新数组、另一个读旧常量。
private open(taskId: string): void {
this.selectedId = taskId;
this.selectedMissing = !this.tasks.some((task: WorkTask) =>
task.id === taskId);
if (!this.stack.getAllPathName().includes('TaskDetail')) {
this.stack.pushPathByName('TaskDetail', taskId);
}
}
// TaskDetail 组件新增:@Consume('taskItems') tasks: WorkTask[];
// 查找改为 this.tasks.find((item: WorkTask) => item.id === this.selectedId)
实际合并时,把 Navigation 内容里的原 this.ListHome() 换成 this.TaskList(),保留 PageMap、TaskDetail 和断点容器。状态数组替换只影响列表与详情的数据呈现;导航栈不应该在刷新回调中 clear()、pop(),更不该重新赋值为 new NavPathStack()。若项目拆分到跨 Ability 的多个窗口,@Provide/@Consume 并不能代替进程外状态同步,需要另建共享仓库。

被删除的任务要有明确的视觉状态。若 selectedMissing 为真,详情仍显示原任务编号,并增加“任务已从当前列表中移除,请返回重新选择”的提示,而不是把详情无声替换成列表第一条。用户主动点击新任务以后,selectedId 和 selectedMissing 必须在同一次 open 操作中更新,不能等到下一次刷新。对于企业业务系统,如果删除记录仍需保留审计快照,可以将失效对象的最后已知信息另存于详情草稿,不与刷新列表混放。
还应检查的三条交错路径
第一条是先发 A 后发 B,B 提前返回。必须保证最后屏幕以 B 的结果为准,A 返回时不再写入状态。第二条是刷新期间窗口不断从宽屏切换到窄屏,breakpoint 只负责视图结构;即使详情正在栈顶,selectedId 也应保持相同。第三条是刷新结果删除了当前任务,此时宽屏右侧和紧凑屏详情都应显示同一条“记录失效”的解释,而不是各自挑一条替代任务。
为了提高复现效率,可以给 TaskSource 准备两个由外部控制完成顺序的 Promise,人工制造 A 慢于 B 的情况;也可以把样例任务数组反转、插入新的 ID、删掉 T-102,逐项核对 UI。以上是工程检查建议,不是声称已经完成的真机性能测试。若线上接口会返回服务端修订号,优先结合修订号判断数据新旧;页面请求序号只反映本地请求先后,并不能证明网络返回的数据本身就是服务器最新版本。

本篇真正重要的结果不是多加了一个 loading 图标,而是把刷新先后、任务身份、页面路由分别放回它们应该负责的层次。接下来会围绕多窗口表单编辑继续推进:当前任务进入编辑态后,窗口结构改变也不能丢掉输入草稿;这时状态持有位置与提交边界就变得更加关键。
官方参考:ArkUI @Watch 状态变量更改通知、ArkUI @ObservedV2 与 @Trace、HarmonyOS 开发者文档中心。
更多推荐

所有评论(0)