桌面卡片如何持续刷新?CheckMe 的 HarmonyOS WorkScheduler 后台更新方案详解

摘要

很多 HarmonyOS 服务卡片在前台看起来都能正常刷新,但一旦应用退到后台,数据就容易卡住。CheckMe 在实现桌面监控卡片时,也遇到了同样的问题。本文围绕 WidgetWorkSchedulerAbilityWidgetFormAbilityEntryAbility,系统讲清楚为什么后台刷新难、只靠 setInterval 为什么不够,以及如何设计一套更稳定的前后台协同刷新方案。

四个主题适配说明

这篇文章的主方向是 能力增强,核心亮点是通过 BackgroundTasksKit 的 WorkScheduler 解决服务卡片后台刷新问题。它同时符合 创新体验,因为用户可以在不进入 App 的情况下持续看到较新的设备状态;符合 高端精致,因为稳定刷新本身就是高级体验的一部分,避免桌面卡片变成静态摆设;也能关联 安全隐私,因为项目在后台只做本地状态采集和按需推送,并在无卡片时停止刷新任务,减少无意义后台活动。

一、为什么卡片刷新真正的难点在后台

先说一个很现实的问题:服务卡片的难点从来不是“把 UI 显示出来”,而是“让它持续有用”。

以设备监控类卡片为例,如果用户拖了一个 CPU 卡片到桌面,他真正期待的是:

  • 数据尽量是新的
  • 不需要每次点进 App 才刷新
  • 应用在后台时卡片也不要长期卡死

而实际开发里,很多人第一反应是直接在应用里写一个 setInterval,每隔几秒更新一次数据。前台运行时,这样做看起来没什么问题;但一旦应用切到后台,事情就复杂了:

  • JS 定时器可能暂停
  • 进程可能被冻结
  • 系统调度优先级会变化
  • 卡片和主应用不一定一直共存

这也是为什么很多卡片 Demo 看起来能跑,但实际体验并不稳定。

二、CheckMe 的刷新目标是什么

CheckMe 中,我对卡片刷新的目标比较克制,不追求“绝对实时”,而追求“合理可靠”:

  1. 应用前台时可以主动高频刷新。
  2. 卡片显示时可以触发局部刷新。
  3. 后台时尽量由系统级任务补充刷新。
  4. 多张卡片统一走一套数据和推送链路。

这四点决定了我不能只依赖某一种刷新策略,而必须做前台和后台协同设计。

三、先看前台刷新:EntryAbility 怎么接管轮询

前台刷新主入口可以放在 EntryAbility 中。这里有一个很关键的方法:

private startWidgetPushTimer(): void {
  const appCtx = this.context.getApplicationContext();
  WidgetDataService.getInstance().startUpdating(WIDGET_PUSH_INTERVAL_MS, (): Promise<void> => {
    return WidgetFormIdRegistry.pushAllFromPreferences(appCtx);
  });
}

这个方法的意义是:

  • 前台时由主应用统一驱动采集
  • 每次采集完成后,再统一推送所有卡片

这里有两个设计点很重要。

1. 先采集,再统一推送

如果每张卡片单独去拉一遍 CPU、内存、网络数据,不仅浪费资源,还会导致各卡片刷新节奏不一致。

2. 推送目标来自注册表

通过 WidgetFormIdRegistry.pushAllFromPreferences() 统一拿到当前所有卡片实例,避免卡片管理分散在多个地方。

四、为什么前台和后台刷新不能混为一谈

EntryAbility 中,项目对前后台状态做了明确区分。

前台:

onForeground(): void {
  void WidgetFormIdRegistry.getFormCount(this.context.getApplicationContext()).then((n: number): void => {
    if (n > 0) {
      this.startWidgetPushTimer();
      void WidgetDataService.getInstance().updateAllWidgetData().then((): void => {
        void WidgetFormIdRegistry.pushAllFromPreferences(this.context.getApplicationContext());
      });
    } else {
      WidgetDataService.getInstance().stopUpdating();
    }
  });
}

后台:

onBackground(): void {
  WidgetDataService.getInstance().stopUpdating();
}

这里的逻辑其实非常清晰:

  • 前台时允许应用主动频繁更新
  • 后台时不再依赖应用自身定时器

这一步不是“少写代码”,而是主动承认后台刷新和前台刷新是两类问题,应该用不同方案处理。

五、后台刷新为什么要引入 WorkScheduler

项目中真正解决后台刷新问题的核心,是:

它继承自 WorkSchedulerExtensionAbility,也就是说,这不是普通页面逻辑,而是系统调度入口。

核心代码如下:

export default class WidgetWorkSchedulerAbility extends WorkSchedulerExtensionAbility {
  onWorkStart(work: workScheduler.WorkInfo): void {
    const workId: number = work.workId;
    if (workId === WIDGET_WORKER_WORK_ID) {
      WidgetDataService.getInstance().updateAllWidgetData()
        .then((): void => {
          void WidgetFormIdRegistry.pushAllFromPreferences(this.context.getApplicationContext());
        })
        .catch((): void => {
          // ignore
        });
    }
  }
}

这段代码背后的思路非常关键:

不再指望主应用进程始终活着,而是交给系统在合适时机唤醒任务,再执行一次数据采集和卡片更新。

对桌面卡片来说,这一步是从“Demo 刷新”走向“真实可用刷新”的分水岭。

六、WorkScheduler 任务是在哪里注册的

很多人只写了 WorkSchedulerExtensionAbility,但没有注册任务,结果任务根本不跑。示例注册方式如下:

private registerWorkScheduler(): void {
  const workInfo: workScheduler.WorkInfo = {
    workId: WIDGET_WORKER_WORK_ID,
    bundleName: 'com.checkme.app',
    abilityName: 'WidgetWorkSchedulerAbility',
    isRepeat: true,
    repeatCycleTime: WORK_SCHEDULER_INTERVAL_MS
  };
  workScheduler.startWork(workInfo);
}

这段逻辑通常会在 onAddForm() 中触发。也就是说:

  • 当用户第一次添加卡片时
  • 系统级后台刷新机制就开始准备了

这点很符合实际业务需求,因为如果一个应用压根没有任何卡片,就没必要持续为后台刷新付出成本。

七、为什么注册和注销都要管

项目里不仅有注册,也有注销逻辑:

onRemoveForm(formId: string): void {
  this.formMeta.delete(formId);
  void WidgetFormIdRegistry.unregister(this.context.getApplicationContext(), formId);
  if (this.formMeta.size === 0) {
    this.unregisterWorkScheduler();
    WidgetDataService.getInstance().stopUpdating();
  }
}

这一点很容易被忽略,但非常重要。因为如果最后一张卡片都被删掉了:

  • 就没有继续刷新的必要
  • 如果还让后台任务一直跑,会造成无意义资源消耗

所以这套设计并不是“只会启动”,而是完整考虑了资源回收。

八、WidgetDataService 如何保证刷新过程不容易卡死

即便有了 WorkScheduler,如果数据采集层本身设计得不好,后台刷新依然可能不稳定。

项目中这一层由:

负责。

它里边做了两个非常关键的保护:

1. 防重入

if (this.collectBusy) {
  return Promise.resolve();
}

如果上一次采集没结束,下一次请求就直接跳过,防止叠加。

2. 超时兜底

return Promise.race([
  work,
  WidgetDataService.sleepRejectTimeout(WidgetDataService.COLLECT_TIMEOUT_MS)
]).finally((): void => {
  this.collectBusy = false;
});

这样即使某个系统调用卡住,也不会把整个刷新链永久拖死。

这一步对后台刷新尤其重要,因为后台任务一旦长时间卡死,用户能感知到的结果就是卡片“再也不更新了”。

九、卡片页为什么还要主动刷新一次

除了前台轮询和后台调度之外,CheckMe 的卡片页本身也做了一个实用设计。

CpuWidgetPage.ets 为例,在 aboutToAppear() 时会主动调用:

postCardAction(this, {
  action: 'call',
  abilityName: 'EntryAbility',
  params: {
    method: 'refreshWidgets',
    formId: this.formId
  }
});

这样做的意义是:

  • 用户刚把卡片加到桌面
  • 或者卡片刚刚被系统重新展示
  • 页面能主动请求一次刷新

这种“页面主动拉一次”的机制,能减少冷启动时看到旧数据的概率。

十、CheckMe 这套刷新方案的完整总结

如果把整个刷新方案归纳一下,就是:

前台

  • EntryAbility 启动轮询
  • WidgetDataService 统一采集
  • WidgetFormIdRegistry 找到所有卡片
  • WidgetFormPushHelper 统一推送

后台

  • WidgetFormAbility 注册 WorkScheduler
  • 系统在合适时机拉起 WidgetWorkSchedulerAbility
  • 再次触发统一采集和统一推送

页面显示时

  • 卡片页通过 postCardAction 请求局部刷新

这三层叠加后,卡片刷新体验会比单一策略更稳。

十一、这篇文章最值得带走的经验

1. 不要把后台刷新理解成“继续跑一个定时器”

真正可靠的思路是:前台靠应用主动刷新,后台尽量交给系统调度。

2. 刷新体系一定要统一

不要让每张卡片各自刷新、各自采集。统一模型、统一缓存、统一推送,系统才稳。

3. 资源回收也要设计

最后一张卡片删掉后,后台任务也应该停掉。

十二、结语

如果服务卡片只是静态展示,那它的技术价值并不高。但一旦你开始认真处理前台刷新、后台刷新、数据防卡死和多卡片实例管理,它就会变成一个很有工程深度的模块。

CheckMe 在这块的实践也让我更明确了一点:HarmonyOS 的服务卡片真正的挑战不在“做出来”,而在“做稳定”。如果你也在做项目,这绝对是一个很值得展开的专题。

在这里插入图片描述

Logo

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

更多推荐