☰
鸿蒙开发健康App案例:从权限申请到桌面卡片全流程拆解
2026/10/14 8:17:48 网站建设 项目流程

简介:这是基于HarmonyOS开发的健康管理应用完整案例,面向正在学习鸿蒙应用开发的工程师,可快速掌握饮食记录、营养统计与运动消耗追踪类App的落地实现。压缩包共427个文件,以ets页面脚本、js逻辑代码、json配置等工程文件为主,配套png、svg图标资源,整体约23.25MB,目录结构清晰。案例包含登录授权引导、用户协议弹窗、首选项持久化、饮食统计(热量与营养分组展示、按日期加载记录)以及食物列表与详情页等模块,覆盖从欢迎页到核心业务页的完整流程。开发者可从源码中学习页面路由、状态管理、首选项存储、日期处理与列表渲染等关键技术,也可直接参考其UI布局与交互设计,快速迁移到其他鸿蒙项目中。目前已有791人学习下载,适合初中级HarmonyOS开发者作为实战训练与项目模板。

1. 鸿蒙开发健康App案例:先跑通一套能出门的Demo再说

很多人在HarmonyOS开发上卡住,不是看不懂语法,而是缺一个能真正跑起来的完整工程。这份鸿蒙开发健康App案例正好补上这一环节:权限申请、健康数据读取、本地缓存、桌面卡片串成一条完整链路,拿到手就能当基线工程改。我习惯把这类项目叫“能出门的Demo”——不是花架子,是真能上真机跑的那种。

为什么拿健康App做入门?因为功能边界清晰:读数据、存数据、展示数据,三个环节刚好覆盖一个HarmonyOS工程最常见的结构:页面、状态管理、服务卡片。而且数据是真实的,真机上走一遍就有反馈,比纯看代码示例强。

它适合两类人:刚开始写Stage模型的开发者,需要一个模块划分干净的参照工程;以及要接健康类需求、但没时间从零设计数据流的应用开发者。接下来我按从骨架到落地的顺序拆这份案例,中间遇到的坑也会一并讲清楚。

2. 框架与数据流:先看清页面骨架和状态管理再动手

拿到这份资源,第一件事不是去翻每个视频页面,而是先把工程结构和数据流向捋明白。健康App这类工程有个特点:页面不多,但数据跨页面复用频繁。首页要显示今日步数,趋势页要读历史记录,桌面卡片又要把同一份数据搬到外边去。如果每个模块各自读一遍数据源,改一处逻辑就要动三处地方,维护成本直线上升。

所以这个案例给了个不错的示范:页面层只管渲染,数据层通过统一的状态管理和本地缓存对外供应数据。下面我从页面组织、状态管理、文件分层三个角度拆开看。

2.1 页面结构:用Tabs避免一上来就掉进Navigation深坑

健康App的典型结构是“首页 + 趋势 + 设置”三个平级页签,案例里用的是Tabs而不是Navigation。这个选型我认同。Tabs的语义就是固定的平行入口,每个Tab内部自带页面栈,适合一级页面;Navigation更适合“列表进详情”这种二级跳转,比如设置页点进去改隐私选项。

入口代码很简单,核心是把窗口内容指向MainTab:

// entry/src/main/ets/entryability/EntryAbility.ets // 窗口加载入口页面,把首页指向MainTab onWindowStageCreate(windowStage: window.WindowStage): void { // 加载MainTab作为根页面,后续每个Tab内部再各自管理子页面 windowStage.loadContent('pages/MainTab', (err) => { if (err.code) { console.error('加载失败: ' + JSON.stringify(err)); return; } console.info('MainTab loaded'); }); }

这里loadContent的入参是页面路由,MainTab作为根页面被加载。注意回调里的err是BusinessError类型的对象,判断err.code,不能只判断err本身是否存在,这是ArkTS异步回调里常见的判断误区。

再看MainTab组织三个Tab的代码:

// pages/MainTab.ets import { HomePage } from './HomePage'; import { TrendPage } from './TrendPage'; import { SettingPage } from './SettingPage'; @Entry @Component struct MainTab { @State currentIndex: number = 0; // 当前选中的Tab下标 build() { Tabs({ barPosition: BarPosition.End, index: this.currentIndex }) { TabContent() { HomePage() }.tabBar('首页') TabContent() { TrendPage() }.tabBar('趋势') TabContent() { SettingPage() }.tabBar('设置') } .scrollable(false) // 健康数据页固定Tab,避免左右滑动误触 .barHeight(56) } }

barPosition设成End表示Tab栏在底部,符合健康类App的操作习惯;index是受控下标,配合@State可以实现外部跳转,比如趋势页从首页某个入口点击后切过来。scrollable(false)是我比较建议的取舍:左右滑动切Tab在普通应用里很顺手,但健康数据页里有横向滚动的趋势图,手势冲突概率很大,固定Tab切换少一次翻车。

2.2 状态管理:@State、@Observed与AppStorage的分工

这个案例的状态管理分层值得多说一句,因为它解决的是健康数据最常见的“跨页面同步”问题。

首页要显示今日步数,设置页可能要显示“最近同步时间”,桌面卡片还要读同一份数据。如果每个页面各自读一遍健康服务接口,页面切换回来数据就变了,很难保持视觉一致。案例里的做法是:全局只存一份,页面共享。

关键代码是这样的,先定义可观察的数据模型:

// model/HealthData.ets // 可被观察的健康数据模型,字段变化时通知所有引用方 @Observed export class HealthData { todaySteps: number = 0; // 今日步数 lastUpdateTime: number = 0; // 最后刷新时间戳 heartRate: number = 0; // 最近一次心率值 updateSteps(steps: number): void { this.todaySteps = steps; this.lastUpdateTime = Date.now(); } }

@Observed让类的实例具备被观察能力,但光有模型还不够,初始化时要把全局值放进AppStorage:

// 在EntryAbility的onWindowStageCreate里初始化一次 AppStorage.setOrCreate('todaySteps', 0);

页面侧读取用@StorageLink:

@StorageLink('todaySteps') todaySteps: number = 0;

StorageLink是双向绑定,页面里改了这个值,AppStorage里同步改,其他所有用StorageLink或StorageProp接收的页面也能感知。这里有一个经常被忽略的差异:StorageProp是单向只读,StorageLink是双向读写。健康数据在首页有主动刷新逻辑,所以用Link没问题;如果只是展示性页面,用Prop更保险,避免子页面误写全局数据。

2.3 工程目录:一个健康App最简的文件组织

案例工程的文件结构很克制,没有堆一大堆分层架构,而是按“页面 / 模型 / 服务 / 卡片”四块组织,基本对应了功能边界。拆过几次这类工程后,我认为这个划分值得直接抄。

目录或文件职责改动频率
pages/首页、趋势页、设置页等页面组件中
model/健康数据模型、可观察对象定义低
service/权限申请、数据读取、本地缓存等能力封装高
form/桌面卡片相关Ability与卡片数据绑定中
resources/base/profile/form_config.json等配置文件低

这个分层的关键价值在于:后续换图表库、接新设备、加提醒功能时,改动都被限制在单目录内。比如把步数从单日改成一周总和,只需要动service层的聚合函数,页面不用跟着改。很多Home做的工程把所有业务逻辑堆在页面里,每次需求变更都要重读一遍几百行的页面代码,时间全花在找逻辑上了。

3. 核心功能落地:步数读取、本地缓存与桌面卡片

框架搭好之后,这个案例真正核心的部分是三块:怎么拿到健康数据、怎么把数据留下来、怎么把数据展示到桌面。这三块是健康类App的标配,也是最容易因为配置不当导致整个工程跑不起来的地方。

3.1 权限申请:在module.json5里把健康数据权限写对

HarmonyOS的健康数据读取属于敏感能力,必须在module.json5里声明权限,运行时再向用户申请。案例里的做法是在requestPermissions节点下显式声明。这里要注意一件事:权限名拼错或者漏声明,编译期不会报错,只会在运行时表现为弹窗不出现、数据读取失败。这种“不报错的错”最容易让人排查到怀疑人生,等到了避坑章我会再展开。

配置文件片段如下:

// entry/src/main/module.json5 片段 { "module": { "requestPermissions": [ { "name": "ohos.permission.HEALTH_DATA", "reason": "用于读取步数与心率数据,在首页展示健康趋势", "usedScene": { "abilities": ["EntryAbility"] } } ] } }

name是权限标识,reason是系统弹窗给用户看的说明,用于提高授权通过率;usedScene限定权限绑定的Ability,避免权限被扩大使用。reason文字不要写“方便开发调试”这类话,用户不关心开发者,只关心自己的数据被拿去做什么,写得具体一点,授权率会明显高一些。

3.2 步数数据读取:从回调里把数据落到页面

权限就绪后,读取步数就走健康数据服务。这个案例把读取逻辑封装在service层,页面侧只关心拿到的结果。我比较欣赏这个做法,因为健康数据接口的回调嵌套通常很深,如果在页面里直接写,代码很快会变成一团乱麻。

封装代码看这里:

// service/HealthService.ets import { BusinessError } from '@kit.BasicServicesKit'; import { HealthData } from '../model/HealthData'; // 读取当天步数,成功走resolve,失败走reject export function fetchTodaySteps(): Promise<number> { return new Promise((resolve, reject) => { // 这里调用系统健康数据读取接口,返回当天0点到现在的累计步数 getTodayStepCount() .then((count: number) => { console.info('[HealthDemo] step count: ' + count); resolve(count); }) .catch((err: BusinessError) => { console.error('[HealthDemo] read step failed, code=' + err.code + ', msg=' + err.message); reject(err); }); }); }

代码里的getTodayStepCount是占位写法,真实工程里对应系统健康服务提供的步数查询接口,返回的是从当天零点到当前时刻的累计值。这里用Promise包装一下,页面侧就可以用async/await串起来,不用在回调里再嵌回调。

页面侧调用如下:

// pages/HomePage.ets 内 async loadTodaySteps(): Promise<void> { try { const steps = await fetchTodaySteps(); this.todaySteps = steps; console.info('[HealthDemo] UI updated, steps=' + steps); } catch (err) { // 读取失败时保留上一次缓存值,避免界面闪0 console.error('[HealthDemo] load today steps failed'); } }

这里有个值得养成的习惯:失败时保留旧值,而不是把界面清成0。健康类页面最忌讳“闪0”,用户早上打开看到步数归零,会直接怀疑数据没同步。与其闪0,不如把旧的缓存值留在界面,同时弹一个不起眼的提示条。

3.3 本地缓存:用Preferences把每日步数存下来

趋势页要展示近一周的步数变化,这就要求我们不能只读当天值,还得把历史数据存起来。案例用的方案是Preferences,这是HarmonyOS提供的轻量级键值对存储,适合存这种结构简单的业务数据,不需要上数据库。

存储代码分两个函数,一个写、一个读:

// service/StepStore.ets import { preferences } from '@kit.ArkData'; const STORE_NAME = 'step_history'; // key按日期命名,例如 step_20250101 function buildKey(dateStr: string): string { return 'step_' + dateStr; } export async function saveDailySteps(dateStr: string, steps: number): Promise<void> { const store = await preferences.getPreferences(getContext(), STORE_NAME); await store.put(buildKey(dateStr), steps); // put只是写进内存 await store.flush(); // flush才会落盘,忘写等于白存 } export async function getDailySteps(dateStr: string): Promise<number> { const store = await preferences.getPreferences(getContext(), STORE_NAME); return (await store.get(buildKey(dateStr), 0)) as number; }

关于两个函数的逻辑说明:saveDailySteps先拿到Preferences实例,然后put键值对,最后flush落盘。getDailySteps读取时带了默认值0,这样即使某天没有数据,也能拿一个合理的数字而不是null。

这里有一个必须记住的参数细节:Preferences的put之后必须调用flush,数据才会真正写入磁盘。只调put不调flush,进程重启后数据就丢了,这在真机调试时非常难发现,因为逻辑上没有任何报错,只是重启后数据默默消失。

3.4 桌面卡片:把今日步数变成一张2×4小卡片

卡片是这个案例最能体现鸿蒙特色的功能。健康数据搬到桌面,用户不用打开App就能看到步数,这是实打实的使用场景。

卡片要先在配置里声明,文件位于resources/base/profile/form_config.json:

{ "forms": [ { "name": "TodayStepCard", "displayName": "今日步数", "description": "桌面展示今日步数", "scheduledUpdateTime": "06:30", "updateEnabled": true, "supportDimensions": ["2*4"] } ] }

scheduledUpdateTime表示定时刷新时间点,我一般设为早上起床时间附近,用户第一眼看手机时数据是新的;updateEnabled必须为true,否则卡片拿不到任何更新回调;supportDimensions限定卡片尺寸,2×4足够显示步数数字和一句鼓励文案。

卡片侧的控制逻辑在FormExtensionAbility里:

// entry/src/main/ets/form/StepCardFormAbility.ets import formBindingData from '@ohos.app.form.formBindingData'; import formInfo from '@ohos.app.form.formInfo'; export default class StepCardFormAbility extends FormExtensionAbility { // 卡片被添加到桌面时触发,把最新步数填进卡片 onAddForm(want) { const steps = 8562; // 实际工程里从缓存读取,这里做演示 const formData = { steps: steps, unit: '步' }; return formBindingData.createFormBindingData(formData); } }

onAddForm的返回值会直接绑定到卡片布局,卡片里的文本组件通过变量名引用steps和unit。这里我刻意把steps写成了死值,实际开发时建议从Preferences读昨天或当前缓存值,这样用户把卡片加到桌面的那一刻就能看到数据,而不是先看到0再慢慢刷新。

4. 避坑指南:真机签名、权限和卡片刷新五个高频坑

这个案例整体跑通不难,但真机调试时遇到的坑比写业务逻辑更折磨人。我把这个工程里最常踩的五个问题按“现象→原因→解决”列出来,全部是我自己或周围同事实际翻过车的场景。

4.1 坑一:卡片添加到桌面一直提示安装失败

现象:工程编译通过,HAP安装到手机成功,但把卡片拖到桌面时提示“添加失败”或“安装失败”,没有任何其他有效信息。

原因:调试签名证书过期或与当前设备不匹配。HarmonyOS的自动签名证书有有效期,过期后旧签名生成的HAP无法通过卡片服务校验。签名问题在普通页面运行时不明显,但卡片涉及独立Ability,校验更严格。

解决:在工程设置里删除旧调试证书,重新生成并勾选自动签名,然后卸载手机上旧的HAP,重新安装再试。注意顺序是先卸载再装,直接覆盖安装有时仍沿用旧签名。

4.2 坑二:权限弹窗出不来,步数一直读0

现象:代码里调用了健康数据读取接口,但没有任何授权弹窗,读取结果永远是0或者直接报错。

原因:module.json5里权限名拼错、权限没声明,或者申请权限的时机太早,页面还没起来就去调接口。后一种情况最常见,权限申请应该在onPageShow之后再做,而不是在Ability的onCreate里做。

解决:先检查requestPermissions配置,确认权限名和系统文档一致;再把权限申请和读取逻辑移到页面onPageShow之后调用。真机上还要多做一步:进系统设置的应用权限管理,看健康数据权限是否被手动关掉或用默认拒绝。

4.3 坑三:卡片数据一直显示旧值,改代码也不更新

现象:App里步数已经是新的了,桌面卡片纹丝不动,点卡片甚至没有反应。

原因:保存数据后没有主动通知卡片更新。卡片的刷新机制不是“数据改了自动变”,而是必须调用updateForm触发。另外,卡片更新有频率限制,如果更新请求过于频繁,系统会直接丢弃请求。

解决:在数据刷新处主动调用updateForm,并传目标卡片ID和最新数据。调试时如果连续触发了多次更新,日志里出现类似“update form rejected”的提示,就是频率超限,等一会儿再试就好。日常开发想验证卡片格式,可以用卡片预览功能或桌面上的手动“更新”按钮,不依赖定时策略。

4.4 坑四:模拟器上步数永远是0,趋势图画不出来

现象:在模拟器里跑工程,步数读数始终为0,趋势页没有任何数据,图表只画了个空坐标轴。

原因:模拟器没有运动传感器,健康服务读不到真实步数;同时图表组件在数据为空时不会自动渲染内容。

解决:开发阶段用mock数据源。我一般会在service层加一个布尔开关,比如useMockData,true时返回写死的一组步数序列,false时走真实健康服务接口。这样UI开发和数据联调可以并行,真机复测时再切回false。模拟器只看UI效果,真机才看数据链路。

4.5 坑五:列表push了新数据,趋势图不刷新

现象:从服务端拿到新数据,数组里也能看到新元素,但页面没有任何变化,图表停留在一分钟前的状态。

原因:直接对数组调用push方法,UI不会感知到数组内容的变化。HarmonyOS的状态管理机制里,数组的某个下标被修改或元素被追加,并不会触发刷新,需要整体替换数组引用。

解决:赋值给一个新的数组,而不是在原数组上做修改。对比写法如下:

// 错误写法:直接push不会触发UI刷新 this.trendPoints.push(newPoint); // 正确写法:拷贝后整体赋值,触发UI重新渲染 const tmp = this.trendPoints.slice(); tmp.push(newPoint); this.trendPoints = tmp;

这里slice先拷贝一份原数组,在拷贝上push,再把新数组整体赋给状态变量,这样才能触发UI刷新。理解这个机制后,后续遇到列表类数据更新都知道要“换引用,不要改内容”。

5. 进阶验证:把调试效率拉满的三种手段

工程能跑通只是开始,日常改代码最耗时间的环节其实是“改一行→装一次机→点亮屏幕→翻到对应页面”。这一套动作做多了,人会产生一种疲劳感。所以我在拆完这份健康App案例后,会额外做三件提升效率的事,这里也推荐给你。

第一件:用Previewer预览卡片,别每次都装真机。DevEco Studio的卡片预览功能可以直接在不安装的情况下查看卡片布局效果。每次改完卡片布局文件,先点预览确认UI,再上真机验证数据和刷新行为。UI相关的验证至少能省掉70%的装机次数。

第二件:日志过滤统一tag。我在这个工程的工具类里把日志tag统一成HealthDemo,所有console.info和console.error都带这个前缀。调试时在日志面板加一个过滤器,只看HealthDemo相关输出,一步到位,不用在满屏系统日志里捞自己那几行打印。这个习惯在做多模块工程时价值更大,不同模块用不同tag,日志一开就能定位是哪个模块在报错。

第三件:数据源加开关。前面避坑章提过useMockData这个开关,正式开发时我会把它做成一个编译期常量,mock阶段置为true,真机联调前置为false。这套做法让我在前期可以把全部精力放在UI和交互上,不被传感器数据的随机波动干扰,等到功能稳定后再切真实数据做全链路验证。

从那以后,我每次把健康类改动合并进工程前,都强制走一遍“清数据→重装→过滤日志→切真实数据源”四步,翻车概率明显降了下来。这份鸿蒙开发健康App案例也建议你按这个顺序跑一遍,先mock后真实,先UI后数据,整体节奏会顺畅很多,希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询