HarmonyOS 服务卡片实战教程:结合 CheckMe 项目拆解完整实现思路
摘要:本文介绍了基于HarmonyOS开发的CheckMe设备监控项目,重点分析了服务卡片功能的实现架构与核心技术。项目通过DeviceInfoService采集设备数据,WidgetDataService处理卡片数据,WidgetFormAbility管理卡片生命周期,结合WorkScheduler实现后台刷新,形成完整的数据采集-处理-展示链路。文章详细阐述了卡片定义、能力注册、生命周期管理等
基于 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 个核心模块来理解:
DeviceInfoService:统一采集系统信息AdvancedDashboard:负责主界面的高质量数据可视化WidgetDataService:统一整理卡片需要的数据结构WidgetFormAbility + WidgetFormPushHelper:负责卡片生命周期和推送WidgetWorkSchedulerAbility:保证后台刷新可靠性
理解这五块,基本就理解了整个项目的核心。
二、为什么我会把“服务卡片”作为项目亮点
如果只是做一个设备信息查看 App,本质上其实不难,无非就是把系统 API 能拿到的数据展示出来。
但 HarmonyOS 项目如果只做到这一步,鸿蒙特性并不突出。
我认为这类项目最适合发挥鸿蒙优势的地方,就是服务卡片。
原因很简单:
- CPU、电量、内存、网络这些信息是高频关注项
- 用户不一定每次都愿意点进 App 查看
- 桌面卡片天然适合做状态前置
所以在 CheckMe 里,我没有把卡片当作附属功能,而是把它当成项目主线之一来设计。最终形成的是:
- App 内:完整数据、详细图表、交互入口
- 桌面上:核心状态、轻量浏览、持续刷新
这也是这个项目最有“鸿蒙味道”的地方。
三、先看整体架构:这个项目的核心链路是怎么串起来的
在真正讲代码之前,先用一句话概括这套架构:
DeviceInfoService负责采集数据,WidgetDataService负责整理卡片数据,WidgetFormAbility负责注册和更新卡片,EntryAbility与WorkScheduler共同保证卡片能在前台和后台都尽可能持续刷新。
如果把这条链路展开,其实就是下面这个顺序:
- 页面或卡片发起刷新需求
DeviceInfoService调用系统能力、文件系统或 Native 能力获取数据WidgetDataService把原始数据整理成卡片结构WidgetFormPushHelper把数据封装成formBindingDataformProvider.updateForm()把数据推送给卡片 UI- 前台时由
EntryAbility的定时刷新负责持续更新 - 后台时由
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. 卡片是什么
通过 name、description、src 指定卡片名称、描述和页面入口。
2. 卡片是不是动态卡片
通过 isDynamic 和 updateEnabled 告诉系统,这不是静态展示卡片,而是允许更新的数据卡片。
3. 卡片支持哪些尺寸
比如 CPU 卡片支持:
2*22*44*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()
});
}
这一段逻辑其实非常值得学,因为它把“卡片添加成功”这件事拆成了几个明确步骤:
- 取出
formId - 识别卡片尺寸
- 识别卡片类型
- 把卡片信息写入本地注册表
- 注册后台调度任务
- 首次采集一次最新数据
- 返回卡片初始绑定数据
换句话说,用户长按桌面添加卡片的那个瞬间,项目已经开始为后续持续更新做准备了,而不是只返回一个静态页面。
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.cppentry/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
它做的事情其实很纯粹:
- 根据卡片类型取对应数据
- 把数据序列化成 JSON
- 构造
formBindingData - 调用
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
会先检测:
- 设备支不支持
- 是否已经授予相机权限
- 没权限时是否请求
对应逻辑如下:
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*2和4*4的信息承载能力完全不同
解决:
- 根据尺寸拆出不同布局
- 让不同尺寸承担不同展示目标
这些内容写出来,读者会更有代入感,也更能体现你是真的做过项目,而不是只看文档拼装功能。
十七、如果你也想照着做,可以按这个顺序实现
如果把整个项目提炼成一个教学步骤,我建议按下面顺序做:
第 1 步:先把设备信息采集层做出来
先完成:
- CPU
- 内存
- 存储
- 电池
- 网络
并统一封装到 DeviceInfoService。
第 2 步:完成主页面展示
先不急着做卡片,把 App 内展示跑通,确认数据结构合理,再逐步升级成图表和仪表盘。
第 3 步:定义卡片数据结构
不要直接把页面数据塞给卡片,而是单独做 WidgetModel.ts 和 WidgetDataService.ts。
第 4 步:实现 FormExtensionAbility
打通:
form_config.jsonmodule.json5WidgetFormAbility
让卡片先能被正常添加和显示。
第 5 步:实现统一推送
用 WidgetFormPushHelper 把 updateForm() 收口。
第 6 步:做 formId 注册表
解决多卡片管理问题。
第 7 步:做前台轮询刷新
用 EntryAbility 管住前台状态下的持续更新。
第 8 步:补上后台 WorkScheduler
解决卡片真正落地时最核心的稳定性问题。
第 9 步:最后再做多尺寸卡片美化
等功能稳定之后,再精细打磨:
- 布局
- 图表
- 文案
- 动画
- 配色
这样整体开发节奏会更稳。
十八、从训练营视角看,这个项目为什么有说服力
如果只是提交一个“能运行”的项目,其实很难突出。
但 CheckMe 的优势在于,它不是单一页面,也不是只展示几个参数,而是完整地打通了:
- 主应用展示
- 系统能力采集
- 服务卡片扩展
- 后台刷新机制
- Native 底层增强
如果用训练营四个方向来总结,可以这样写:
1. 高端精致
- 主页面使用动态看板和可视化图表
- 卡片支持多尺寸与差异化布局
- 趋势图和实时数据让界面更有品质感
2. 创新体验
- 通过服务卡片把设备状态前置到桌面
- 降低用户查看高频信息的操作成本
3. 安全隐私
- 权限按需申请
- 受限能力保守处理
- 数据主要本地采集和本地展示
4. 能力增强
- 组合使用多个 HarmonyOS Kit
- 使用 WorkScheduler 增强后台刷新
- 使用 Native 层增强 CPU 数据采集能力
这个总结方式比较适合发 CSDN,也适合拿去做训练营成果介绍。
十九、我的经验总结:这个项目真正难的不是 UI,而是“链路完整”
做完这个项目之后,我最大的感受是:
HarmonyOS 项目真正有价值的地方,不是你画了多少页面,而是你有没有把能力链路打通。
像 CheckMe 这个项目,真正难的是这些问题:
- 数据从哪里来
- 多处展示怎么复用
- 卡片怎么持续更新
- 后台怎么尽可能可靠
- 多卡片怎么管理
- 权限和能力边界怎么控制
当这些问题都处理完之后,页面反而只是最后一层表现。
所以如果以后我再做类似的 HarmonyOS 项目,我还是会坚持这个思路:
- 先做 Service 层
- 再做状态模型
- 再做页面和卡片
- 最后补性能、适配和视觉质量
这样项目更容易做扎实。
二十、结语
CheckMe 对我来说,不只是一个设备信息查看工具,更像一次比较完整的 HarmonyOS 工程实践。
它让我把这些能力真正串到了一起:
- ArkTS 页面开发
- 系统信息采集
- 服务卡片开发
- 后台任务调度
- 生命周期控制
- Native 数据增强
如果你也在做鸿蒙训练营项目,或者正准备写一篇有质量的 CSDN 博文,我很建议不要只写功能列表,而要像这篇文章这样,尽量从下面几个角度展开:
- 项目为什么这么设计
- 核心模块如何拆分
- 关键代码解决了什么问题
- 踩过哪些坑
- 最后怎样体现鸿蒙特性
这样文章就不只是“项目展示”,而是真正有教学价值。
如果后面我继续迭代 CheckMe,我还会考虑继续增强这些方向:
- 卡片点击后的深度跳转联动
- 更多系统状态的趋势分析
- 更完善的平板与折叠屏适配
- 更多有场景价值的轻量桌面能力
这次的核心收获也可以概括成一句话:
做 HarmonyOS 项目,别只做一个 App 页面,要把系统能力、服务卡片和真实使用场景一起做进去。

更多推荐




所有评论(0)