工程记录说明:本文讨论的是 ShiftRefresh 演示工程中一个可复现的业务竞态。屏幕图、DevEco 布局和 HiLog 均为独立制作的演示示意,并非真实 IDE 截图、设备日志或目标 SDK 编译结论。示例中的 RF-3210、版本号与请求序号均为约定的测试数据。

一、列表刷新完成了,为什么内容又变旧

巡检人员看待办列表,最怕的不是刷新转圈,而是转完之后明明出现过的新任务又消失。这个现象通常很难在开发机器上稳定复现:网络较快时,用户的下拉请求先返回;自动同步稍后返回,看起来一切正常。等到了弱网现场,两个请求完成的顺序一倒转,界面就可能被较早的快照覆盖。

我给这个问题单独做了个小工程 ShiftRefresh,页面是 ShiftFeedPage,追踪任务号固定为 RF-3210。列表一开始采用修订号 rev=12 的五条待办;后台自动同步发出请求 seq=41,准备得到 rev=13 的六条;用户紧接着下拉,发出请求 seq=42,拿到 rev=14 的七条。故意让 42 在前、41 在后完成,才能看出状态有没有被误写。

如果仅在每次 Promise 完成时执行 this.rows = response.rows,最终画面就会退回六条。Refresh 的动画结束与数据是否新鲜是两回事;List 根据传入的数据展示内容,也不会替业务判断哪个响应具有写权限。因此这里要解决的不是怎样快速停止动画,而是谁能提交一次列表快照。

二、先把三种时间顺序分开

我原先把“请求编号”和“服务端修订号”混成一个字段,用 Date.now() 判断是不是最新结果。这看起来省事,实际有两个坑。首先,同一毫秒内连续发出的动作可能得到相同时间戳;其次,发起顺序与数据修订顺序不是一件事:一个请求后发出,不代表服务端就一定返回更高版本。

当前实现把它们拆成三项:seq 只表示本页面最近发出的请求;revision 只表示业务快照的单调版本;rows 是与该版本绑定的一整份列表。前台只接受“请求仍为最近一次,并且修订号不比现有修订低”的结果。这是以最新用户意图为优先的页面策略,不是普适的网络一致性协议。

特别注意,服务端必须真的提供可信的修订字段。如果接口只返回一堆数据而没有版本,那么可以用本地发起序号防止旧请求反写,但无法证明新请求携带的数据一定比上一版更新。文章中的修订号由模拟服务提供,不能冒充真实服务器协议。

先写门禁而不是先写 UI,能够把最容易出错的竞态收敛成普通的纯逻辑判断。它只保存已签发的最大序号与已接纳的最高版本,不管理网络连接,也不替页面自动取消旧任务。

interface FeedRow { id: string; label: string }
interface FeedSnapshot { revision: number; rows: FeedRow[] }

class FeedGate {
  private issued: number = 40
  private acceptedRevision: number = 12

  issue(): number { return ++this.issued }
  isCurrent(seq: number): boolean { return seq === this.issued }

  accept(seq: number, snapshot: FeedSnapshot): boolean {
    if (!this.isCurrent(seq)) return false
    if (snapshot.revision < this.acceptedRevision) return false
    this.acceptedRevision = snapshot.revision
    return true
  }
}

这里并不借助不确定的“请求自然结束顺序”。issued 初始为四十,因此本例两个动作自然是 41 与 42。在真实产品里,这个初值不重要,只需要页面实例内递增。注意等版本响应的策略:只有当前序号才能写入,若服务端要求“严格递增”而不是“允许重取同版本”,还需把小于号改成小于等于号,并区分首次加载。

三、让 Refresh 管动画,门禁管写入

我会把页面里的刷新状态分成两个:一是 refreshing,仅控制下拉动效;二是 status,说明最近一次被接纳的页面快照。失败、过期、被忽略的响应都不能直接把后一项改成成功。rows 只在验收通过时整体替换,避免标题、条数和列表来自不同回包。

下面保留关键交互与释放逻辑。fetchFeed 是应用自己注入的 Promise 数据接口,不是系统 API;真实项目需替换为业务客户端,错误处理也要按服务端返回码细化。$$this.refreshing 是刷新容器的双向状态绑定写法,适合把手势进度与页面状态关联起来。

@Entry
@Component
struct ShiftFeedPage {
  @State refreshing: boolean = false
  @State revision: number = 12
  @State status: string = 'BASE_REV12'
  @State rows: FeedRow[] = [
    { id: 'RF-01', label: '巡检点位 1' },
    { id: 'RF-02', label: '巡检点位 2' },
    { id: 'RF-03', label: '巡检点位 3' },
    { id: 'RF-04', label: '巡检点位 4' },
    { id: 'RF-05', label: '巡检点位 5' }
  ]
  @State ignored: number = 0
  private gate: FeedGate = new FeedGate()
  private alive: boolean = true

  async load(source: string,
    fetchFeed: (seq: number) => Promise<FeedSnapshot>): Promise<void> {
    const seq = this.gate.issue()
    if (source === 'USER') this.refreshing = true
    try {
      const snapshot = await fetchFeed(seq)
      if (!this.alive) return
      if (this.gate.accept(seq, snapshot)) {
        this.rows = snapshot.rows.slice()
        this.revision = snapshot.revision
        this.status = 'APPLIED_' + seq
      } else {
        this.ignored += 1  // 只记审计计数,不覆盖已接纳的视图
      }
    } catch (err) {
      if (this.alive && this.gate.isCurrent(seq)) {
        this.status = 'REFRESH_FAILED'
      }
    } finally {
      if (this.alive && this.gate.isCurrent(seq)) this.refreshing = false
    }
  }

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

  build() {
    Column() {
      Text('RF-3210 · rev=' + this.revision)
      Text('状态 ' + this.status + '  已忽略 ' + this.ignored)
      Refresh({ refreshing: $$this.refreshing }) {
        List() {
          ForEach(this.rows, (row: FeedRow) => {
            ListItem() { Text(row.id + '  ' + row.label) }
          }, (row: FeedRow) => row.id)
        }.layoutWeight(1)
      }.onRefreshing(() => {
        this.load('USER', (_: number) => fakeFeed(180, 14, 7))
      }).layoutWeight(1)
    }.width('100%').height('100%')
  }
}

这个示例刻意没有在 onRefreshing 内直接给 rows 赋值,也没有把数据错误解释成刷新组件的能力缺陷。绑定状态可能由组件和页面共同变化,应用仍要决定异步任务的生命期。aboutToDisappear 的布尔值防止离开页面后的回调继续更新状态;它不等于取消了 HTTP 请求,产品代码应再配合实际网络客户端支持的取消、超时或释放手段。

有一个容易忽视的例外:自动同步可能在用户手动刷新前启动,之后即使它失败,也不应该把后发且成功的手动刷新标成失败。上面 catch 与 finally 同样检查序号,就是为此设的。错误态要由当前请求写,动效也要由当前请求收尾,否则会出现“新请求还在加载,旧请求先把动效关掉”的错觉。

四、用故意反序的回包替代运气

只在高速网络下滑动十次,很难说明门禁真的有效。我更倾向于先做确定性回放,再到设备上核对真实 UI。模拟服务提供固定列表,不发起任何网络流量;慢任务稍后返回六条,快任务先返回七条。两条数据都合法,差别只是完成顺序。

function fakeFeed(delayMs: number, revision: number,
  count: number): Promise<FeedSnapshot> {
  return new Promise<FeedSnapshot>((resolve) => {
    setTimeout(() => {
      const rows: FeedRow[] = []
      for (let i = 1; i <= count; i++) {
        const id = 'RF-' + String(i).padStart(2, '0')
        rows.push({ id: id, label: '巡检点位 ' + i })
      }
      resolve({ revision: revision, rows: rows })
    }, delayMs)
  })
}

// 页面调试操作:先触发自动同步,再触发用户刷新
// this.load('AUTO', () => fakeFeed(900, 13, 6))  // seq=41
// this.load('USER', () => fakeFeed(180, 14, 7))  // seq=42

这段回放依赖一个前提:页面实例中的计数器此前没有额外签发请求。如果有其他自动动作,应按实际返回的序号记录,而不要硬编码 41/42 去比较。预期顺序是:初始 rev=12 / 5条;先提交 seq=42 / rev=14 / 7条;最后到来的 seq=41 / rev=13 / 6条 被拒绝,ignored=1。最终页面应保持 APPLIED_42,而不是把可见数据退回旧版本。

从示意画面中可以核对三个位置:代码里的 accept() 拒绝过期序号;右侧页面一直显示七条;底部 HiLog 只记录“已接纳、已忽略”这些模拟结果。日志不应该把用户身份、原始业务响应体和接口密钥整包打出来。工程排查关注的是边界是否被守住,而不是把更多数据放进日志。

五、列表稳定还不等于刷新体验稳定

ForEach 使用 row.id 作为稳定键,是另一个独立问题。假设后端调整巡检点位排序,如果用数组下标作为键,组件复用可能导致行内部状态串位;把稳定业务 ID 作为键,更适合这个待办场景。它不能代替请求门禁:一份旧数据即使有完全正确的 ID,也仍然是旧的。

页面还要留意空集、断网与服务端回滚。当前产品策略是“失败时保留最后一次成功快照”,不能把失败处理成七条变零条;真正没有待办则要通过成功响应的空数组显式进入空态。若发现服务端响应携带比现有版本更低的 revision,应该拒绝并记录异常,而不是默默替业务决定这是一次合法回滚。

刷新来源的多样性也会影响策略。这里选的是“最近一次请求优先”,适合强调用户主动操作的待办页;如果后台推送具有不可丢失的新事件,那么只按请求序号拦截会丢数据,需要事件去重、补拉或基于服务端游标的合并协议。这篇没有把两类一致性模型混为一谈。

纯手机屏幕示意图保留了 RF-3210、rev=14、七条可见任务、APPLIED_42 和“忽略旧回包 1 次”。时间与网络图标都是演示 UI,不代表连接了真实服务或获得了真实设备状态。让页面、代码与日志共用一组数据,是为了下一轮排查时不再出现文章写七条而图上只有六条的低级错位。

六、离开页面时,收口的是写入资格

实现里最关键的一步不是 finally 关掉动画,而是离开页面时取消写入资格。alive=false 只能让当前实例忽略完成回调,不能消灭服务器请求、计时器或底层连接;真正有资源句柄的实现仍需在相应生命周期进行取消和释放。重进页面时应创建新门禁或明确重置计数,不应复用上一实例的 issued 判断当前页面的请求。

我给验收留四组输入:旧请求晚于新请求、新请求失败旧请求成功、相同修订但不同列表、服务端意外返回较低修订。前两组验证写入资格,第三组倒逼服务端解释修订语义,最后一组检查回滚策略。只有数据、动效、错误提示和资源收尾都符合预期,才算这个版本门禁适合接进业务。

目前这里完成的是静态设计与模拟数据推导,没有宣称目标 SDK 编译成功、真机滚动手势验证通过或者线上接口具备修订号。对后续工程来说,明确这些尚未验证的边界,比给一张看起来漂亮的“刷新成功”图更有价值。

七、参考与核验边界

Logo

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

更多推荐