HarmonyOS 互动卡片刷新边界

互动卡片很容易被写成“迷你页面”。页面里能做的事,卡片里也想做;页面可以请求接口,卡片也跟着请求;页面可以频繁刷新,卡片也跟着频繁刷新。这样写一开始看着功能丰富,后面问题会集中爆出来。

这篇按 HarmonyOS 5.0.0 及以上版本的卡片入口来拆。重点不是把所有接口都堆上去,而是把卡片刷新、用户触发和缓存兜底三件事分清楚。

我遇到这类问题时,通常先看三个现象:

  • 卡片一刷新,接口就被打很多次;
  • 用户点了卡片按钮,但页面打开后状态对不上;
  • 网络失败时,卡片直接空白,看起来像应用坏了。

这些问题的根源基本一致:卡片没有自己的“轻量状态模型”,而是直接复用了页面的请求链路。

互动卡片先别当页面写

卡片要解决的是快速查看和快速触达。它不适合承载完整页面逻辑,更不适合在每一次显示、每一次点击后都做重请求。

我会把卡片拆成四层:

层级 负责什么 不负责什么
FormExtensionAbility 接收卡片生命周期、提供初始数据、处理卡片更新 不承载页面大流程
卡片数据快照 保存可直接渲染的小数据 不保存大对象和页面状态
刷新调度器 控制刷新间隔、合并重复刷新 不直接渲染 UI
页面入口 接住用户点击后的完整流程 不让卡片自己做复杂跳转判断

这样拆以后,卡片就很清楚:能显示旧数据就先显示旧数据,需要刷新再按规则刷新;用户点击时记录意图,完整业务交给页面。

错误案例:每次触发都直接请求

下面这种写法最容易造成刷新风暴:

async function onFormVisible(formId: string): Promise<FormSnapshot> {
  const data = await requestLatestData()
  return {
    formId,
    title: data.title,
    countText: `${data.count} 条待处理`,
    updatedAt: Date.now()
  }
}

问题在于,它没有判断上次什么时候刷新过,也没有旧快照兜底。卡片如果被系统调度、用户多次触发、页面反复打开,就可能把一个轻量入口变成高频请求源。

更稳的写法是:先读缓存,再判断是否需要刷新,最后决定要不要请求。

案例一:5 分钟内重复刷新,只返回旧快照

先定义一个卡片快照:

interface FormSnapshot {
  formId: string
  title: string
  countText: string
  updatedAt: number
  fromCache: boolean
}

再做一个刷新调度器:

class FormRefreshGuard {
  private lastRefreshAt: Map<string, number> = new Map()

  canRefresh(formId: string, now: number, minIntervalMs: number): boolean {
    const last = this.lastRefreshAt.get(formId) ?? 0
    return now - last >= minIntervalMs
  }

  markRefreshed(formId: string, now: number): void {
    this.lastRefreshAt.set(formId, now)
  }
}

卡片侧使用时,不要一上来就请求:

async function loadFormSnapshot(formId: string, now: number): Promise<FormSnapshot> {
  const cached = await readCachedSnapshot(formId)
  if (cached && !guard.canRefresh(formId, now, 5 * 60 * 1000)) {
    return {
      ...cached,
      fromCache: true
    }
  }

  const remote = await requestLatestData()
  const next = {
    formId,
    title: remote.title,
    countText: `${remote.count} 条待处理`,
    updatedAt: now,
    fromCache: false
  }
  await saveCachedSnapshot(next)
  guard.markRefreshed(formId, now)
  return next
}

这个方案不是为了少写几行代码,而是为了把刷新节奏控制住。卡片是桌面上的常驻入口,不能用页面按钮点击的思路去请求接口。

案例二:网络失败时,先显示旧数据再提示更新时间

卡片最怕的不是数据旧一点,而是直接空白。网络失败、接口超时、账号状态临时异常时,如果卡片没有旧快照,用户看到的就是一个坏入口。

我会把失败处理写成这样:

async function safeRefreshForm(formId: string, now: number): Promise<FormSnapshot> {
  const cached = await readCachedSnapshot(formId)
  try {
    const remote = await requestLatestData()
    const next = {
      formId,
      title: remote.title,
      countText: `${remote.count} 条待处理`,
      updatedAt: now,
      fromCache: false
    }
    await saveCachedSnapshot(next)
    return next
  } catch (err) {
    if (cached) {
      return {
        ...cached,
        fromCache: true
      }
    }
    return {
      formId,
      title: '暂时无法刷新',
      countText: '稍后再试',
      updatedAt: now,
      fromCache: true
    }
  }
}

这里的重点是兜底顺序:

  • 有旧快照,就先展示旧快照;
  • 没有旧快照,才展示轻量错误态;
  • 错误态不要做成长说明,卡片空间有限;
  • 完整错误原因放到打开后的页面里处理。

用户点击不要直接塞复杂逻辑

互动卡片会有按钮,比如“继续处理”“查看详情”“刷新”。这些按钮不应该直接在卡片侧处理完整流程。卡片侧适合传一个明确意图:

interface FormActionIntent {
  formId: string
  action: 'openDetail' | 'refresh' | 'continue'
  payloadId?: string
  createdAt: number
}

页面打开后再接住这个意图:

function handleFormIntent(intent: FormActionIntent): void {
  if (intent.action === 'openDetail' && intent.payloadId) {
    openDetailPage(intent.payloadId)
    return
  }
  if (intent.action === 'refresh') {
    refreshCurrentPage()
    return
  }
  openHomePage()
}

这样卡片和页面都清楚:卡片只表达“用户想做什么”,页面负责“这件事怎么完整执行”。

几种刷新方案怎么选

方案 适合场景 风险
每次触发都请求 数据强实时且频率很低 很容易造成接口压力和耗电
固定间隔刷新 天气、日程、待办数量这类摘要 间隔太短会浪费资源
缓存优先,过期再刷 大多数卡片入口 数据可能略旧,但体验稳定
用户点击后刷新 用户明确需要最新数据 要避免连点重复请求

我更倾向“缓存优先,过期再刷”。如果用户点了刷新,再走用户触发刷新;如果只是系统调度或卡片恢复显示,先看缓存和刷新间隔。

可以封装成一个小工具

如果应用里有多张卡片,不要每张卡片都手写一遍刷新判断。可以封装一个快照仓库:

class FormSnapshotStore {
  private cache: Map<string, FormSnapshot> = new Map()

  read(formId: string): FormSnapshot | undefined {
    return this.cache.get(formId)
  }

  write(snapshot: FormSnapshot): void {
    this.cache.set(snapshot.formId, snapshot)
  }

  isExpired(formId: string, now: number, ttlMs: number): boolean {
    const snapshot = this.cache.get(formId)
    if (!snapshot) {
      return true
    }
    return now - snapshot.updatedAt >= ttlMs
  }
}

真实项目里可以把这个 Map 换成持久化存储,但接口形态尽量保持简单:读快照、写快照、判断过期。这样卡片的刷新策略不会散落在多个生命周期函数里。

本地验证结果

我用脚本跑了两个场景:

{
  "repeatRefresh": {
    "firstFromCache": false,
    "secondFromCache": true,
    "remoteCalls": 1
  },
  "networkFallback": {
    "title": "今日摘要",
    "fromCache": true,
    "remoteCalls": 1
  }
}

第一组证明 5 分钟内重复触发不会重复打接口;第二组证明网络失败后能显示旧快照,不会让卡片空白。

排查时先看这几个点

互动卡片刷新异常时,我会按这个顺序查:

  • 是否每次生命周期触发都直接请求接口;
  • 是否有快照缓存,缓存里有没有更新时间;
  • 是否区分系统调度刷新和用户主动刷新;
  • 网络失败时有没有旧数据兜底;
  • 卡片点击是否只传意图,而不是在卡片侧做完整页面逻辑。

互动卡片写稳以后,它对应用是加分项:用户不用打开完整页面也能看到摘要,点一下又能进入完整流程。反过来,如果刷新边界没拆好,它会变成一个隐藏的高频请求入口。卡片越轻,边界越要清楚。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐