基于 HarmonyOS 的 CheckMe 项目实战:从核心代码出发,手把手实现设备监控与服务卡片

大家好,这篇文章想系统复盘一下我做的 HarmonyOS 项目 CheckMe,同时也尽量写成一篇“能跟着做”的教学型文章。

这个项目的定位很明确:做一个基于 HarmonyOS 的设备信息与状态监控应用,并把高频信息通过服务卡片直接放到桌面上。应用内负责完整展示,桌面卡片负责高频前置,最终做出一个既有鸿蒙特色、又有实用价值的项目。

和很多只停留在页面展示层面的项目不同,CheckMe 里真正有含金量的部分在于这一整条实现链路:

  • 设备信息采集
  • 实时数据缓存
  • 主页面可视化展示
  • 多种服务卡片定义
  • 卡片数据推送
  • 后台定时刷新
  • 生命周期与权限控制
  • Native 层补充 CPU 底层数据

如果把它放到鸿蒙训练营的视角来看,这个项目其实很适合体现几个关键词:

  • 高端精致:动态看板、趋势图、多尺寸卡片
  • 创新体验:服务卡片前置高频设备状态
  • 安全隐私:权限按需使用、敏感能力谨慎处理
  • 能力增强:FormKit、BackgroundTasksKit、LocationKit、CameraKit、NetworkKit、Native C++ 联动

这篇文章我不打算只讲“做了什么”,而是重点讲:这个项目的核心代码是怎么组织的,功能又是怎么一步一步实现出来的。


一、项目最终做成了什么

先简单说结果,再看实现。

CheckMe 当前已经覆盖以下能力:

  • 设备基础信息展示
  • CPU 型号、核心数、使用率、频率监控
  • 内存、存储、电池、网络信息监控
  • 位置、媒体、蓝牙、工具页等扩展能力
  • 实时仪表盘与趋势图
  • 7 类服务卡片
  • 后台定时刷新桌面卡片

对应的核心目录也很清晰:

entry/src/main/ets/services
entry/src/main/ets/widget
entry/src/main/ets/workscheduler
entry/src/main/ets/components
entry/src/main/cpp
entry/src/main/resources/base/profile

我自己把这个项目拆成 5 个核心模块来理解:

  1. DeviceInfoService:统一采集系统信息
  2. AdvancedDashboard:负责主界面的高质量数据可视化
  3. WidgetDataService:统一整理卡片需要的数据结构
  4. WidgetFormAbility + WidgetFormPushHelper:负责卡片生命周期和推送
  5. WidgetWorkSchedulerAbility:保证后台刷新可靠性

理解这五块,基本就理解了整个项目的核心。


二、为什么我会把“服务卡片”作为项目亮点

如果只是做一个设备信息查看 App,本质上其实不难,无非就是把系统 API 能拿到的数据展示出来。

但 HarmonyOS 项目如果只做到这一步,鸿蒙特性并不突出。

我认为这类项目最适合发挥鸿蒙优势的地方,就是服务卡片

原因很简单:

  • CPU、电量、内存、网络这些信息是高频关注项
  • 用户不一定每次都愿意点进 App 查看
  • 桌面卡片天然适合做状态前置

所以在 CheckMe 里,我没有把卡片当作附属功能,而是把它当成项目主线之一来设计。最终形成的是:

  • App 内:完整数据、详细图表、交互入口
  • 桌面上:核心状态、轻量浏览、持续刷新

这也是这个项目最有“鸿蒙味道”的地方。


三、先看整体架构:这个项目的核心链路是怎么串起来的

在真正讲代码之前,先用一句话概括这套架构:

DeviceInfoService 负责采集数据,WidgetDataService 负责整理卡片数据,WidgetFormAbility 负责注册和更新卡片,EntryAbilityWorkScheduler 共同保证卡片能在前台和后台都尽可能持续刷新。

如果把这条链路展开,其实就是下面这个顺序:

  1. 页面或卡片发起刷新需求
  2. DeviceInfoService 调用系统能力、文件系统或 Native 能力获取数据
  3. WidgetDataService 把原始数据整理成卡片结构
  4. WidgetFormPushHelper 把数据封装成 formBindingData
  5. formProvider.updateForm() 把数据推送给卡片 UI
  6. 前台时由 EntryAbility 的定时刷新负责持续更新
  7. 后台时由 WorkSchedulerExtensionAbility 尝试系统级唤醒刷新

注意,这里面真正难的不是“显示卡片”,而是“让卡片在真实使用场景里持续可用”。


四、第一步:先把卡片定义出来

在 HarmonyOS 里做服务卡片,第一步一定不是写 UI,而是先在配置里把卡片能力定义好。

项目里这个文件是:

  • entry/src/main/resources/base/profile/form_config.json

这里定义了 7 种卡片:

{
  "forms": [
    {
      "name": "CpuWidgetForm",
      "description": "CPU 核心使用率监控小组件",
      "src": "./ets/widget/pages/CpuWidgetPage.ets",
      "uiSyntax": "arkts",
      "isDynamic": true,
      "updateEnabled": true,
      "defaultDimension": "2*2",
      "supportDimensions": ["2*2", "2*4", "4*4"]
    }
  ]
}

这一层主要解决三个问题:

1. 卡片是什么

通过 namedescriptionsrc 指定卡片名称、描述和页面入口。

2. 卡片是不是动态卡片

通过 isDynamicupdateEnabled 告诉系统,这不是静态展示卡片,而是允许更新的数据卡片。

3. 卡片支持哪些尺寸

比如 CPU 卡片支持:

  • 2*2
  • 2*4
  • 4*4

这一步很重要,因为后面 UI 代码里就可以根据尺寸做完全不同的信息布局,而不是简单拉伸。


五、第二步:在 module.json5 中注册 FormExtensionAbility 和 WorkSchedulerAbility

卡片配置文件有了,还需要把扩展能力挂到模块声明里。

项目中对应的是:

  • entry/src/main/module.json5

这里我注册了两个关键扩展:

"extensionAbilities": [
  {
    "name": "WidgetFormAbility",
    "srcEntry": "./ets/widget/WidgetFormAbility.ets",
    "type": "form",
    "exported": true,
    "metadata": [
      {
        "name": "ohos.extension.form",
        "resource": "$profile:form_config"
      }
    ]
  },
  {
    "name": "WidgetWorkSchedulerAbility",
    "srcEntry": "./ets/workscheduler/WidgetWorkSchedulerAbility.ets",
    "type": "workScheduler",
    "exported": false,
    "metadata": [
      {
        "name": "ohos.extension.workscheduler",
        "resource": "$profile:workscheduler_config"
      }
    ]
  }
]

这里的意义分别是:

  • WidgetFormAbility:卡片的生命周期入口
  • WidgetWorkSchedulerAbility:后台刷新任务入口

很多初学者只做到 FormExtensionAbility 这一层,前台看起来没问题,但一到后台刷新就容易失效。这个项目往前多走了一步,把后台任务也接起来了,所以整体体验会更完整。


六、第三步:用 WidgetFormAbility 管住卡片生命周期

这部分是整个卡片功能的“中控层”。

核心文件:

  • entry/src/main/ets/widget/WidgetFormAbility.ets

这个类继承自 FormExtensionAbility,负责处理:

  • 卡片被添加时做什么
  • 卡片更新时做什么
  • 卡片移除时做什么
  • 如何记录卡片类型和尺寸
  • 如何注册后台刷新任务

1. 卡片被添加时做什么

项目里最关键的方法是 onAddForm()

onAddForm(want: Want): formBindingData.FormBindingData {
  const formIdStr: string = (want.parameters?.['ohos.extra.param.key.form_identity'] as number).toString();
  const formDimension: number = (want.parameters?.['ohos.extra.param.key.form_dimension'] as number) ?? 2;
  const dimString: string = this.getDimensionString(formDimension);
  const widgetKind: string = this.extractWidgetKind(want);

  const record: WidgetFormRecord = {
    formId: formIdStr,
    widgetKind: widgetKind,
    widgetType: dimString
  };
  void WidgetFormIdRegistry.register(this.context.getApplicationContext(), record);

  this.registerWorkScheduler();

  void WidgetDataService.getInstance().updateAllWidgetData().then((): void => {
    this.refreshAllForms();
  });

  const initialJson: string = WidgetFormPushHelper.buildInitialJson(widgetKind);

  return formBindingData.createFormBindingData({
    'formId': formIdStr,
    'widgetType': dimString,
    'widgetKind': widgetKind,
    'widgetData': initialJson,
    '_ts': Date.now().toString()
  });
}

这一段逻辑其实非常值得学,因为它把“卡片添加成功”这件事拆成了几个明确步骤:

  1. 取出 formId
  2. 识别卡片尺寸
  3. 识别卡片类型
  4. 把卡片信息写入本地注册表
  5. 注册后台调度任务
  6. 首次采集一次最新数据
  7. 返回卡片初始绑定数据

换句话说,用户长按桌面添加卡片的那个瞬间,项目已经开始为后续持续更新做准备了,而不是只返回一个静态页面。

2. 为什么要自己维护 formId 注册表

项目里我额外做了一个 WidgetFormIdRegistry

  • entry/src/main/ets/widget/WidgetFormIdRegistry.ts

它会把当前桌面上已经添加的卡片信息持久化到 Preferences 里。

这么做的原因非常现实:

  • 卡片不是只有一张
  • 不同卡片尺寸可能不同
  • 不同卡片类型的数据来源也不同
  • 当应用重启或进程重新拉起时,不能丢掉这些卡片元信息

也就是说,WidgetFormIdRegistry 的本质作用就是:

让系统和应用都知道,“当前桌面上到底有哪些卡片,它们各自是什么类型、什么尺寸”。

这一步是让卡片刷新更稳定的关键基础设施。


七、第四步:把设备信息采集统一收口到 DeviceInfoService

如果卡片层直接写系统调用,项目会很快失控。因为你会发现:

  • 主页面要读 CPU
  • 概览页要读 CPU
  • 卡片也要读 CPU
  • 后台刷新还要再读一次 CPU

所以我把所有系统信息采集统一收口到:

  • entry/src/main/ets/services/DeviceInfoService.ts

这个类是整个项目的数据底座。

1. 为什么要做成统一 Service

统一 Service 的好处至少有三个:

  • 页面层不用关心数据从哪里来
  • 卡片层不用重复写采集逻辑
  • 后续新增能力时只改一处

例如它的 getDeviceInfo() 方法就是一个总入口:

public async getDeviceInfo(): Promise<DeviceInfoData> {
  const cpuInfo: CpuInfo = await this.getCpuInfo();
  const memoryInfo: MemoryInfo = await this.getMemoryInfo();
  const screenInfo: ScreenInfo = await this.getScreenInfo();
  const batteryInfoData: BatteryInfo | null = await this.getBatteryInfo();
  const storageInfo: StorageInfo = await this.getStorageInfo();
  const gpuInfo: GpuInfo = await this.getGpuInfo();
  const cameraInfo: CameraInfo[] = await this.getCameraInfo();
  const sensorInfo: SensorInfo[] = await this.getSensorInfo();
  const networkInfo: NetworkInfo | null = await this.getNetworkInfo();
  const systemInfo: SystemInfo = this.getSystemInfo();
  ...
}

这样页面只需要拿到一个 DeviceInfoData,不需要分别知道 CPU、内存、网络到底是通过哪个 Kit 或哪个系统文件拿到的。

2. 这个 Service 用到了哪些 HarmonyOS 能力

这也是项目体现“能力增强”的地方。

它整合了:

  • @kit.BasicServicesKit
  • @kit.SensorServiceKit
  • @kit.NetworkKit
  • @kit.LocationKit
  • @kit.CameraKit
  • @ohos.display
  • @ohos.file.fs
  • @ohos.hidebug

这类写法比单页直接调 API 更工程化,因为所有底层访问都被封装了起来。

3. 数据真实性和边界控制

这部分我觉得很适合写进 CSDN,因为它体现的是开发思路,不只是功能实现。

比如在 DeviceInfoService.ts 中,我对部分能力做了保守处理:

  • HarmonyOS NEXT 第三方应用受限的能力,不硬造数据
  • 无法稳定获取的字段,不拿设备型号去伪装成运营商信息
  • 某些失败情况返回默认结构,避免页面直接崩

这种处理方式虽然没有“看起来什么都能拿到”那么炫,但更专业。


八、第五步:CPU 信息为什么还要下沉到 Native/C++ 层

如果只依赖上层 API,很多 CPU 信息其实拿得不够细。

所以项目里我专门补了一层:

  • entry/src/main/cpp/cpu_info.cpp
  • entry/src/main/ets/services/CpuInfoReader.ts

这块的设计思路是:

  • ArkTS 层负责调用
  • Native 层负责读取底层系统文件

例如在 cpu_info.cpp 里,项目会去读:

  • /proc/cpuinfo
  • /sys/devices/system/cpu/cpuX/cpufreq/scaling_cur_freq
  • /sys/devices/system/cpu/cpuX/cpufreq/cpuinfo_max_freq

像下面这段逻辑,就是典型的底层频率读取:

std::string path = "/sys/devices/system/cpu/cpu" + std::to_string(coreIndex) +
                  "/cpufreq/scaling_cur_freq";

std::string content = readFile(path);
if (content.empty()) {
    path = "/sys/devices/system/cpu/cpu" + std::to_string(coreIndex) +
           "/cpufreq/cpuinfo_cur_freq";
    content = readFile(path);
}

这样做的好处是什么?

1. 可以拿到更细粒度的 CPU 信息

比如:

  • 每个核心当前频率
  • 最大频率
  • 核心数量
  • 芯片相关特征

2. 能支撑更有价值的卡片展示

如果没有每核频率数据,就很难做 CPU 频率卡片、频比图、趋势图。

3. 项目更像“完整工程”,而不只是调用现成 API

这也是我认为这个项目比较有训练营价值的原因之一,因为它不是止步于页面层,而是往底层延伸了一步。


九、第六步:用 WidgetDataService 统一整理卡片数据

前面 DeviceInfoService 解决的是“怎么采集”,但卡片还需要“适合展示的数据结构”。

所以项目里我又做了一层:

  • entry/src/main/ets/widget/WidgetDataService.ts

它的职责非常明确:

DeviceInfoService 获取原始数据,再整理成适合卡片 UI 直接消费的数据模型。

1. 先定义卡片数据模型

文件:

  • entry/src/main/ets/widget/WidgetModel.ts

比如 CPU 卡片数据结构:

export interface CpuWidgetData extends WidgetBaseData {
  type: AppWidgetType.CPU;
  usagePercent: number;
  usageHistory: number[];
  coreCount: number;
  avgFreq: number;
  maxFreq: number;
}

CPU 频率卡片数据结构:

export interface CpuFreqWidgetData extends WidgetBaseData {
  type: AppWidgetType.CPU_FREQ;
  coreCount: number;
  coreFrequencies: number[];
  coreMaxFreqs: number[];
  coreTypes: string[];
  avgFreq: number;
  maxFreq: number;
  freqHistory: number[][];
  systemUsage: number;
}

这一步特别重要,因为它把“业务数据”和“展示数据”分离开了。

2. 卡片数据为什么不直接临时算

因为卡片有持续更新需求,而且有多个卡片共用同一套采集结果。如果每张卡片都单独去采集系统信息,不仅浪费资源,还可能导致数据节奏不一致。

所以 WidgetDataService 做了几件很关键的事情:

  • 缓存最新采集结果
  • 维护历史数组
  • 做统一轮询
  • 控制采集并发
  • 设置超时保护

例如这里有一个非常实用的保护逻辑:

public updateAllWidgetData(): Promise<void> {
  if (this.collectBusy) {
    return Promise.resolve();
  }
  this.collectBusy = true;
  const work: Promise<void> = this.runCollectAllOnce();
  return Promise.race([
    work,
    WidgetDataService.sleepRejectTimeout(WidgetDataService.COLLECT_TIMEOUT_MS)
  ])
    .finally((): void => {
      this.collectBusy = false;
    });
}

这段代码解决了两个典型问题:

1. 防止重叠采集

如果上一次采集还没结束,下一次刷新又来了,就容易出现竞争和资源浪费。

2. 防止某次 await 永久挂起

如果某次系统调用卡住,busy 状态不释放,后面的卡片刷新就全停了。这里通过超时兜底,把整个刷新链路做得更健壮。

这类细节特别适合教学文去讲,因为它体现的是“工程质量”。


十、第七步:把卡片推送逻辑收口到 WidgetFormPushHelper

很多人做卡片时,容易在多个地方重复写 updateForm() 逻辑,最后很难维护。

所以项目里我单独封装了:

  • entry/src/main/ets/widget/WidgetFormPushHelper.ts

它做的事情其实很纯粹:

  1. 根据卡片类型取对应数据
  2. 把数据序列化成 JSON
  3. 构造 formBindingData
  4. 调用 formProvider.updateForm()

核心逻辑如下:

static pushForm(formId: string, widgetKind: string, dimString: string): void {
  const widgetTypeEnum: AppWidgetType = WidgetFormPushHelper.kindToType(widgetKind);
  const widgetData: WidgetData | null = WidgetFormPushHelper.getWidgetData(widgetTypeEnum);
  const jsonStr: string = widgetData !== null ? JSON.stringify(widgetData) : '{}';
  const updateData: Record<string, string> = {
    'formId': formId,
    'widgetKind': widgetKind,
    'widgetType': dimString,
    'widgetData': jsonStr,
    '_ts': Date.now().toString()
  };
  const bindingData = formBindingData.createFormBindingData(updateData);
  formProvider.updateForm(formId, bindingData).catch((_err: Error) => {
    // ignore
  });
}

这种封装的价值非常大:

  • EntryAbility 可以用
  • FormExtensionAbility 可以用
  • WorkSchedulerAbility 也可以间接用

也就是说,不同刷新入口最终都走同一条推送链路,减少了大量重复代码。


十一、第八步:卡片页面怎么实现多尺寸和主动刷新

前面讲的都是数据层和能力层,下面看卡片页面本身。

以 CPU 卡片为例,文件在:

  • entry/src/main/ets/widget/pages/CpuWidgetPage.ets

这个页面的实现很值得拆,因为它不是“一个页面 + 一个数字”那么简单。

1. 先通过 LocalStorageProp 接收系统注入的数据

@LocalStorageProp('formId') formId: string = '';
@LocalStorageProp('widgetType') widgetType: string = '2*2';
@LocalStorageProp('widgetData') @Watch('onWidgetDataChange') widgetData: string = DEFAULT_CPU_DATA;

这说明卡片 UI 和推送层之间是通过绑定数据同步的。

2. 卡片出现时主动触发刷新

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

这里有个非常实战的设计:

  • 卡片不是傻等后台刷新
  • 当卡片真正显示出来时,会主动请求应用侧刷新一次

这就能减少“用户刚把卡片拖到桌面上看到的是旧数据”的问题。

3. 根据尺寸切换布局

if (this.widgetType === '4*4') {
  this.buildLargeLayout()
} else if (this.widgetType === '2*4') {
  this.buildMediumLayout()
} else {
  this.buildSmallLayout()
}

这也是我比较喜欢这个项目的一点:不是简单缩放,而是真正做了多态布局。

4. 通过 Canvas 绘制趋势图

CPU 卡片里不是只显示一个百分比,还会把历史使用率数组画成图。这就让卡片从“状态标签”升级成了“轻量监控面板”。

这种设计非常适合设备工具类应用。


十二、第九步:前台刷新由 EntryAbility 接管

卡片只靠 FormExtensionAbility 还不够,因为用户在前台使用应用时,卡片也需要更平滑地持续更新。

所以项目里在:

  • entry/src/main/ets/entryability/EntryAbility.ets

又做了一层前台刷新控制。

1. 应用回到前台时启动卡片轮询

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();
    }
  });
}

2. 应用进入后台时停止前台定时器

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

这套设计很有必要,因为前台和后台的刷新策略应该不同:

  • 前台:可以更灵活、更高频地更新
  • 后台:交给系统调度,避免纯 JS 定时器不可靠

如果两者混在一起,项目很容易要么耗电、要么刷新不稳定。


十三、第十步:后台刷新为什么必须引入 WorkScheduler

这是整个项目里我认为最值得讲的一个技术点。

很多同学第一次做卡片,会用 setInterval 定时采集数据,看起来前台没问题。但只要应用被挂后台、冻结或者系统回收,这种刷新基本就不可靠了。

所以项目里我专门做了:

  • entry/src/main/ets/workscheduler/WidgetWorkSchedulerAbility.ets

核心逻辑非常明确:

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
        });
    }
  }
}

WidgetFormAbility 里则会负责注册这个调度任务:

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);
}

这套方案解决了什么问题

一句话总结:

解决了“应用进入后台后,卡片定时器失效导致数据卡死”的问题。

这也是我在做设备监控类卡片时最看重的一点。因为卡片最核心的价值就是“用户不打开应用也能看到较新的数据”,如果后台完全不刷新,卡片就会失去意义。


十四、第十一步:主页面为什么还要单独做 AdvancedDashboard

卡片是轻量入口,但项目不能只有卡片。

用户点进应用后,应该看到的是更完整、更高级的设备监控面板。所以我又做了:

  • entry/src/main/ets/components/AdvancedDashboard.ets

这个组件承担的是主页面“高端精致”的表达。

它里面有几个我觉得很值得借鉴的点:

1. 使用多组 Canvas 绘制不同维度图表

包括:

  • CPU 趋势图
  • 内存环图
  • 存储图
  • 电池图
  • 网络图
  • 流量趋势图

2. 不是简单直线,而是做平滑线和面积填充

比如它会通过 buildSmoothSegments() 生成更自然的趋势曲线,这样视觉上会比折线图更高级。

3. 按容器宽度自适应图表尺寸

private onDashboardAreaChange(_oldValue: Area, newValue: Area): void {
  const nextWidth = this.parseAreaWidth(newValue.width);
  if (nextWidth < 280) {
    return;
  }
  ...
}

这意味着它不是死宽度写死,而是会根据设备空间调整图表结构,这对平板、大屏或不同窗口尺寸都更友好。

4. 生命周期里控制轮询

这个组件不会在不可见时还一直刷新,而是配合 AppLifecycleManager 管控轮询行为,这一点很符合真实项目的性能考虑。

所以如果从训练营“高端精致”的角度来看,AdvancedDashboard 其实就是项目视觉质量的重要支撑。


十五、第十二步:权限与能力控制怎么做得更合理

设备信息类应用很容易踩一个坑,就是“为了功能全,把权限全加上”,但这其实并不专业。

项目中在:

  • entry/src/main/module.json5

声明了与实际能力相关的权限,比如:

  • 网络信息
  • WiFi 信息
  • 定位
  • 相机

但在代码实现层面,我并没有一上来就默认都能用,而是做了能力检测和权限判断。

例如工具页中的手电筒服务:

  • entry/src/main/ets/services/QuickToolsService.ets

会先检测:

  1. 设备支不支持
  2. 是否已经授予相机权限
  3. 没权限时是否请求

对应逻辑如下:

public async getTorchCapabilityHint(context: common.UIAbilityContext): Promise<string> {
  try {
    const camManager: camera.CameraManager = camera.getCameraManager(context);
    if (!camManager.isTorchSupported()) {
      return '设备不支持';
    }
    const granted: boolean = await this.isCameraPermissionGranted(context);
    if (!granted) {
      return '需相机权限';
    }
    return '可用';
  } catch (_e) {
    return '检测失败';
  }
}

这一类写法虽然不算炫技,但很符合真实上架项目的思路:

  • 先检测能力
  • 再考虑权限
  • 最后执行动作

同样的思路也适用于定位、网络、设备信息等模块。


十六、这个项目里最值得写进教学文的几个“踩坑点”

如果只写“怎么实现”,文章会偏流水账。真正有价值的是把实现过程中遇到的问题也讲出来。

我总结下来,这个项目最值得分享的几个坑是:

1. 卡片前台能刷新,后台却卡死

原因:

  • 只依赖 setInterval
  • 应用后台后 JS 定时器不可靠

解决:

  • 引入 WorkSchedulerExtensionAbility
  • 前台与后台使用不同刷新策略

2. 卡片不止一种,不做注册表很难管理

原因:

  • 多卡片、多尺寸、多类型并存
  • 应用重启后容易丢上下文

解决:

  • WidgetFormIdRegistry 持久化 formId + widgetKind + widgetType

3. 数据采集过慢会拖死整个刷新链

原因:

  • 某个异步采集可能卡住
  • 采集重叠可能导致刷新阻塞

解决:

  • collectBusy 防重入
  • Promise.race + timeout 防卡死

4. CPU 信息不够细,卡片做不出层次感

原因:

  • 单靠上层 API 很难拿到每核频率

解决:

  • Native C++ 补充读取 /proc/sys 数据

5. 卡片 UI 不能只做一个模板硬缩放

原因:

  • 2*24*4 的信息承载能力完全不同

解决:

  • 根据尺寸拆出不同布局
  • 让不同尺寸承担不同展示目标

这些内容写出来,读者会更有代入感,也更能体现你是真的做过项目,而不是只看文档拼装功能。


十七、如果你也想照着做,可以按这个顺序实现

如果把整个项目提炼成一个教学步骤,我建议按下面顺序做:

第 1 步:先把设备信息采集层做出来

先完成:

  • CPU
  • 内存
  • 存储
  • 电池
  • 网络

并统一封装到 DeviceInfoService

第 2 步:完成主页面展示

先不急着做卡片,把 App 内展示跑通,确认数据结构合理,再逐步升级成图表和仪表盘。

第 3 步:定义卡片数据结构

不要直接把页面数据塞给卡片,而是单独做 WidgetModel.tsWidgetDataService.ts

第 4 步:实现 FormExtensionAbility

打通:

  • form_config.json
  • module.json5
  • WidgetFormAbility

让卡片先能被正常添加和显示。

第 5 步:实现统一推送

WidgetFormPushHelperupdateForm() 收口。

第 6 步:做 formId 注册表

解决多卡片管理问题。

第 7 步:做前台轮询刷新

EntryAbility 管住前台状态下的持续更新。

第 8 步:补上后台 WorkScheduler

解决卡片真正落地时最核心的稳定性问题。

第 9 步:最后再做多尺寸卡片美化

等功能稳定之后,再精细打磨:

  • 布局
  • 图表
  • 文案
  • 动画
  • 配色

这样整体开发节奏会更稳。


十八、从训练营视角看,这个项目为什么有说服力

如果只是提交一个“能运行”的项目,其实很难突出。

CheckMe 的优势在于,它不是单一页面,也不是只展示几个参数,而是完整地打通了:

  • 主应用展示
  • 系统能力采集
  • 服务卡片扩展
  • 后台刷新机制
  • Native 底层增强

如果用训练营四个方向来总结,可以这样写:

1. 高端精致

  • 主页面使用动态看板和可视化图表
  • 卡片支持多尺寸与差异化布局
  • 趋势图和实时数据让界面更有品质感

2. 创新体验

  • 通过服务卡片把设备状态前置到桌面
  • 降低用户查看高频信息的操作成本

3. 安全隐私

  • 权限按需申请
  • 受限能力保守处理
  • 数据主要本地采集和本地展示

4. 能力增强

  • 组合使用多个 HarmonyOS Kit
  • 使用 WorkScheduler 增强后台刷新
  • 使用 Native 层增强 CPU 数据采集能力

这个总结方式比较适合发 CSDN,也适合拿去做训练营成果介绍。


十九、我的经验总结:这个项目真正难的不是 UI,而是“链路完整”

做完这个项目之后,我最大的感受是:

HarmonyOS 项目真正有价值的地方,不是你画了多少页面,而是你有没有把能力链路打通。

CheckMe 这个项目,真正难的是这些问题:

  • 数据从哪里来
  • 多处展示怎么复用
  • 卡片怎么持续更新
  • 后台怎么尽可能可靠
  • 多卡片怎么管理
  • 权限和能力边界怎么控制

当这些问题都处理完之后,页面反而只是最后一层表现。

所以如果以后我再做类似的 HarmonyOS 项目,我还是会坚持这个思路:

  1. 先做 Service 层
  2. 再做状态模型
  3. 再做页面和卡片
  4. 最后补性能、适配和视觉质量

这样项目更容易做扎实。


二十、结语

CheckMe 对我来说,不只是一个设备信息查看工具,更像一次比较完整的 HarmonyOS 工程实践。

它让我把这些能力真正串到了一起:

  • ArkTS 页面开发
  • 系统信息采集
  • 服务卡片开发
  • 后台任务调度
  • 生命周期控制
  • Native 数据增强

如果你也在做鸿蒙训练营项目,或者正准备写一篇有质量的 CSDN 博文,我很建议不要只写功能列表,而要像这篇文章这样,尽量从下面几个角度展开:

  • 项目为什么这么设计
  • 核心模块如何拆分
  • 关键代码解决了什么问题
  • 踩过哪些坑
  • 最后怎样体现鸿蒙特性

这样文章就不只是“项目展示”,而是真正有教学价值。

如果后面我继续迭代 CheckMe,我还会考虑继续增强这些方向:

  • 卡片点击后的深度跳转联动
  • 更多系统状态的趋势分析
  • 更完善的平板与折叠屏适配
  • 更多有场景价值的轻量桌面能力

这次的核心收获也可以概括成一句话:

做 HarmonyOS 项目,别只做一个 App 页面,要把系统能力、服务卡片和真实使用场景一起做进去。


在这里插入图片描述

Logo

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

更多推荐