鸿蒙 Next 慢性病管理 App 开发实战:健康数据记录 + 指标卡片 + 用药管理



鸿蒙 Next 慢性病管理 App 开发实战:健康数据记录 + 指标卡片 + 用药管理
作者:duluo
SDK 版本:HarmonyOS API 24 (Next)
开发工具:DevEco Studio
语言框架:ArkTS + ArkUI
字数:约 9500 字
目录
1. 引言
1.1 慢性病的数字管理
中国有超过 3 亿慢性病患者。高血压、糖尿病、慢性阻塞性肺疾病——这些疾病不需要住院,但需要长期管理:每天吃药、定期测量、按时复查。慢性病占中国疾病负担的 70% 以上,是最大的健康挑战之一。
慢性病管理的核心不是"治疗",而是记录。血压今天多少?血糖控制得怎么样?药有没有按时吃?这些数据看起来简单,但如果连续记录三个月,就是一份对医生极有价值的健康档案。
然而现实是:绝大多数慢性病患者没有系统的记录习惯。原因不是"不想记",而是"太麻烦"——找纸、找笔、记下来、整理。一周后纸条丢了,一个月后忘记开始记了。App 要做的,就是让记录变得足够简单——点一下按钮记录血压、看一眼卡片了解趋势、翻一下列表回顾历史。记录的门槛越低,坚持记录的可能性越大。
1.2 与上一款 App 的对比
方言学习(App 30)和慢性病管理(App 31)看起来是完全不同的产品——一个教育类、一个健康类。但它们的架构高度相似:首页(概览)→ 第二页(操作)→ 第三页(浏览)。差异在于数据内容和操作方式——方言学习操作的是"收藏",慢性病管理操作的是"记录"。
1.3 三十一款 App 全景
App 数量: 31
代码总行数: ~17,800 行
编译错误数: ~290 个
博客总字数: ~310,000 字
技术博客数: 31 篇
2. 产品概念与数据模型
2.1 功能需求
用户故事 1:我想每天记录血压和血糖
用户故事 2:我想看到最近的指标趋势
用户故事 3:我想知道今天有没有漏吃药
用户故事 4:我想看到一些健康生活的建议
功能清单:
├── F1: 首页 4 项健康指标展示(最新值)
├── F2: 一键添加血压/血糖/体重记录
├── F3: 历史记录列表
├── F4: 用药提醒
├── F5: 每日健康贴士
└── F6: 贴士完整列表
2.2 数据模型
interface HealthTip {
id: number;
title: string;
content: string;
emoji: string;
}
HealthTip 是健康贴士的数据模型。6 条贴士覆盖饮水量、合理膳食、适度运动、规律作息、定期监测、情绪管理六个维度。
核心健康数据没有使用 interface 定义,而是直接使用 number[] 数组:
// [timestamp, systolicBP, diastolicBP, bloodSugar, weight]
// 每个记录是一个 5 元组
@State records: number[][] = [];
为什么不用 interface:因为每条记录的长度固定为 5,通过索引访问(r[1] 为收缩压、r[2] 为舒张压等)足够清晰,而且数组字面量比对象字面量更简洁。对于只有 5 个字段的数据结构,数组的可读性损失可以接受。
2.3 数据格式
| 索引 | 字段 | 类型 | 范围 |
|---|---|---|---|
| 0 | 时间戳 | number | Date.now() |
| 1 | 收缩压 | number | 110-140 |
| 2 | 舒张压 | number | 70-90 |
| 3 | 血糖 | number | 5.0-8.0 |
| 4 | 体重 | number | 60-70 |
数据以一维数组 number[] 存储,每条记录 5 个数字。records 是一个二维数组 number[][],最新的记录在头部。
3. 三 Tab 架构设计
3.1 Tab 配置
build() {
Stack() {
Column().backgroundColor(C.bg)
Column() {
this.buildHeader()
if (this.activeTab === 0) this.buildHomeTab()
else if (this.activeTab === 1) this.buildRecordTab()
else this.buildTipsTab()
this.buildTabBar()
}
}
}
| Tab | 图标 | 功能 | 使用频次 |
|---|---|---|---|
| 0 | 🏠 | 首页 — 指标 + 用药 + 贴士 | ⭐⭐⭐ 每天多次 |
| 1 | 📋 | 记录 — 添加 + 历史 | ⭐⭐⭐ 每天使用 |
| 2 | 💡 | 贴士 — 完整列表 | ⭐ 偶尔查看 |
3.2 首页布局
┌──────────────────────────────┐
│ 🏥 慢病管理 │
│ 健康每一天 │
├──────────────────────────────┤
│ 下午好 ☀️ 今天也要好好照顾自己 │
├──────────────────────────────┤
│ 🩸 收缩压 🩸 舒张压 │ ← 2×2 Grid
│ 125 mmHg 82 mmHg │
│ 🍬 空腹血糖 ⚖️ 体重 │
│ 6.0 mmol/L 65 kg │
├──────────────────────────────┤
│ 💊 今日用药 2 次 │
│ 💊 降压药 每日1次 早餐后 │
│ 💊 降糖药 每日2次 早晚 │
├──────────────────────────────┤
│ 💧 每日饮水 │ ← 每日贴士
│ 每天饮用 1500-2000ml 水... │
└──────────────────────────────┘
3.3 数据流
添加记录 → records 头部插入 → @State 自动更新
↓
首页指标卡片 ← 读取 records[0](最新值)
记录列表 ← 遍历 records
用药提醒 ← 预置常量(不依赖状态)
每日贴士 ← 种子算法 + TIPS 常量
所有健康数据通过 records 数组管理。首页指标卡片读取最新一条记录(records[0]),记录列表遍历全部记录。
4. 健康指标卡片设计
4.1 可复用卡片 Builder
@Builder
buildMetricCard(emoji: string, label: string, value: string, unit: string, color: string) {
GridItem() {
Column() {
Text(emoji).fontSize(28)
Text(label).fontSize(12).fontColor(C.textMuted).margin({ top: 2 })
Text(value).fontSize(20).fontColor(color).fontWeight(FontWeight.Bold).margin({ top: 2 })
Text(unit).fontSize(10).fontColor(C.textMuted)
}.padding(12).backgroundColor(C.bgCard).borderRadius(14).height(110)
}
}
5 个参数控制卡片的所有内容:emoji、标签、数值、单位、颜色。不同的指标通过不同的参数组合展示:
this.buildMetricCard('🩸', '收缩压', this.getLatest('sbp'), 'mmHg', C.warm)
this.buildMetricCard('🩸', '舒张压', this.getLatest('dbp'), 'mmHg', C.warm)
this.buildMetricCard('🍬', '空腹血糖', this.getLatest('glu'), 'mmol/L', C.primary)
this.buildMetricCard('⚖️', '体重', this.getLatest('wgt'), 'kg', C.accent)
这个 Builder 的复用价值:后续如果增加新指标(如心率 ❤️、血氧 🫁、尿酸等),不需要创建新的卡片组件,只需要增加一行 buildMetricCard 调用和一个 getLatest 分支。
4.2 最新值查询
getLatest(type: string): string {
for (let i = this.records.length - 1; i >= 0; i--) {
const r = this.records[i];
if (type === 'sbp' && r[1] > 0) return r[1] + '';
if (type === 'dbp' && r[2] > 0) return r[2] + '';
if (type === 'glu' && r[3] > 0) return r[3].toFixed(1);
if (type === 'wgt' && r[4] > 0) return r[4] + '';
}
return '--';
}
从最新的记录开始向前遍历,找到第一条包含该类型数据的记录。如果没有任何记录包含该类型数据,返回 '--'(占位符)。
为什么从后往前:records 数组头部是最新记录,所以 records[0] 如果包含血压数据,直接返回。如果 records[0] 只录入了血糖(glu),则向前查找 records[1]、records[2],直到找到有血压数据的记录。
5. 数据记录与模拟生成
5.1 一键添加
@Builder
buildAddBtn(label: string, action: () => void) {
Text(label).fontSize(14).fontColor(Color.White).fontWeight(FontWeight.Bold)
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
.backgroundColor(C.primary).borderRadius(12).margin({ right: 6 })
.layoutWeight(1).textAlign(TextAlign.Center)
.onClick(() => { action(); })
}
三个按钮在 Row 中等宽分布(layoutWeight(1))。每个按钮点击后调用 addRecord 方法,传入不同的类型参数。
5.2 数据生成
addRecord(type: string): void {
const now = Date.now();
const vals: number[] = [now, 0, 0, 0, 0];
if (type === 'bp') {
vals[1] = Math.floor(110 + Math.random() * 30); // 收缩压 110-140
vals[2] = Math.floor(70 + Math.random() * 20); // 舒张压 70-90
} else if (type === 'glu') {
vals[3] = parseFloat((5.0 + Math.random() * 3).toFixed(1)); // 血糖 5.0-8.0
} else if (type === 'wgt') {
vals[4] = Math.floor(60 + Math.random() * 10); // 体重 60-70
}
this.records = [vals, ...this.records];
}
模拟数据在合理范围内随机生成。后续版本接入真实蓝牙设备后,只需要将 Math.floor(110 + Math.random() * 30) 替换为蓝牙读数即可——接口不变(vals[1] = bluetoothData.systolicBP),消费者(getLatest('sbp') 和指标卡片)不需要修改。
5.3 历史记录列表
ForEach(this.records, (r: number[]) => {
Row() {
Text(this.formatRecDate(r[0])).fontSize(13).width(80)
Text('🩸 ' + r[1] + '/' + r[2]).layoutWeight(1)
Text('🍬 ' + (r[3] > 0 ? r[3].toFixed(1) : '--')).layoutWeight(1)
Text('⚖️ ' + (r[4] > 0 ? r[4] : '--')).layoutWeight(1)
}
})
每条历史记录在同一行展示四个数据:时间、血压、血糖、体重。使用 layoutWeight(1) 等分宽度,确保每列对齐。
空值显示 '--':如果某条记录只有血压数据(血糖和体重为 0),对应的列显示 --,不会显示 0 误导用户。
6. 用药管理系统
6.1 用药数据
getMeds(): string[][] {
return [['降压药', '每日1次 早餐后'], ['降糖药', '每日2次 早晚']];
}
getMedsToday(): number {
return this.getMeds().length;
}
使用固定数据模拟用药信息。降压药(每日 1 次)和降糖药(每日 2 次)是慢性病患者最常见的药物组合。
6.2 用药卡片
💊 今日用药 2 次
💊 降压药 每日1次 早餐后
💊 降糖药 每日2次 早晚
顶部显示总用药次数(2 次),下方逐条列出药物名称和服用说明。这个卡片在首页位于指标下方、贴士上方,属于"每日必看"信息。
7. 健康贴士系统
7.1 贴士数据
const TIPS: HealthTip[] = [
{ id: 1, title: '每日饮水', content: '每天饮用 1500-2000ml 水...', emoji: '💧' },
{ id: 2, title: '合理膳食', content: '少盐少油少糖...', emoji: '🥗' },
{ id: 3, title: '适度运动', content: '每周至少 150 分钟...', emoji: '🚶' },
{ id: 4, title: '规律作息', content: '每晚睡足 7-8 小时...', emoji: '😴' },
{ id: 5, title: '定期监测', content: '按时测量血压、血糖...', emoji: '📊' },
{ id: 6, title: '情绪管理', content: '保持乐观心态...', emoji: '🧘' },
];
6 条贴士覆盖慢性病管理的核心维度。每条贴士 20-40 字,简洁有力。
7.2 每日推荐
getTodayTip(): number {
return Math.floor(Date.now() / 86400000) % TIPS.length;
}
与"关怀平替"、“断段打卡”、"方言学习"等 App 完全相同的种子算法。6 条贴士,6 天一个循环。
7.3 贴士完整列表
Tab 2 展示全部 6 条贴士,每条贴士用卡片展示:左侧大 emoji(28sp)+ 右侧标题 + 正文。点击无操作——贴士是只读内容,不需要交互。
8. Grid 组件复用模式
8.1 Grid 的两种使用方式
本 App 使用了两种 Grid 模式:
模式一:ForEach + 内联 GridItem
Grid() {
ForEach(items, (item) => {
GridItem() { /* 卡片内容 */ }
})
}.columnsTemplate('1fr 1fr')
适用于数据来自数组的动态 Grid。ForEach 的 key 函数使用唯一标识。
模式二:硬编码 GridItem
Grid() {
this.buildMetricCard('🩸', '收缩压', ...)
this.buildMetricCard('🩸', '舒张压', ...)
this.buildMetricCard('🍬', '血糖', ...)
this.buildMetricCard('⚖️', '体重', ...)
}.columnsTemplate('1fr 1fr')
适用于卡片数量固定(4 张)且每张调用不同参数的场景。硬编码避免了 ForEach 的参数传递复杂性。
8.2 何时用 ForEach,何时硬编码
| 条件 | ForEach | 硬编码 |
|---|---|---|
| 卡片数量可变 | ✅ | ❌ |
| 每张卡片参数相同 | ✅ | ✅ |
| 每张卡片参数不同 | ⚠️ 需要参数数组 | ✅ |
| 卡片数量少(<6) | ⚠️ 过度抽象 | ✅ |
| 卡片数量多(>10) | ✅ | ❌ |
本 App 的 4 张指标卡片选择了硬编码,因为参数各不相同(emoji、label、color 都不同),且数量固定。后续如果要支持"用户自定义指标",可以改为 ForEach 方式。
9. 零错误构建实践
9.1 首个零错误的 App
本 App 实现了系列首次零编译错误构建——从第一次编译到最终构建成功,零个编译错误。
这不是因为本 App 没有代码错误,而是因为所有潜在错误已经在之前的 30 款 App 中遇到并解决了。 写代码时就已经知道哪些语法是 ArkTS 不支持的,哪些模式会触发 10905209。
以下是本 App 在编码阶段就主动避开的 8 个潜在错误:
| 潜在错误 | 避免方式 |
|---|---|
@Builder 中写 const |
所有数据通过方法参数传入 |
| 颜色缺 interface | 第一时间定义 ColorScheme |
| ForEach 缺 key 函数 | 每个 ForEach 都加了第三个参数 |
wrap(true) |
不用 Row wrap,用 Grid 替代 |
| 解构赋值 | 全部使用显式属性访问 |
| 索引签名 | 全部使用数组 |
Set 不支持 |
全部使用数组 + indexOf |
| 对象可能 undefined | 从不返回 undefined 类型 |
9.2 零错误的真正意义
零错误不是"代码没有 bug",而是"编译器没有发现语法违规"。对 ArkTS 来说,编译错误的唯一来源就是语法违规——没有类型推导错误、没有隐式转换错误、没有模板错误。只要遵循 ArkTS 的语法规则,编译就能通过。
零错误不代表代码质量高,但代表对 ArkTS 语法规则的完全掌握。 从 App 1 的 16 个错误到 App 31 的 0 个错误,这中间的差距就是 30 款 App、约 290 个错误的经验积累。
9.3 零错误的三个前提
本 App 实现零错误不是偶然的,有三个前提条件:
前提一:没有引入新的代码模式
本 App 的所有代码模式——三 Tab 架构、Grid 卡片、ForEach 列表、Builder 参数传递——都在前 30 款 App 中至少使用过 5 次以上。没有使用任何第一次写的新模式。
前提二:没有使用实验性 API
本 App 只使用了最基础的 ArkUI 组件:Stack、Column、Row、Text、Scroll、Grid、ForEach。没有使用 animateTo、Canvas、XComponent 等可能有兼容问题的 API。
前提三:数据模型简化
没有使用 Map、Set、Date() 等可能触发预览器问题的功能。所有数据要么是常量(TIPS、Meds),要么是 number[][] 数组。
9.4 零错误之后
9.5 三十一款 App 的错误数趋势
App 1: 16 ← 第一课
App 5: 12 ← 模式学习
App 10: 11 ← 模式形成
App 15: 1 ← 稳定期
App 20: 2 ← 高效期
App 24: 48 ← 新领域(波峰)
App 25: 3 ← 回归基线
App 28: 8 ← 预览器问题
App 30: 10 ← 新 Helper 模式
App 31: 0 ← 零错误!🏆
App 31 的零错误不是"意外",而是 30 款 App 积累的结果。每一个可能触发的错误都已经在前 30 款中被修复过至少一次。
10. 第三十一款 App 全景回顾
10.1 数据总览
| 指标 | 数值 |
|---|---|
| 代码行数 | 268 行 |
| 编译错误数 | 0 个 🏆 |
| @State 变量 | 2 个 |
| @Builder 方法 | 5 个 |
| 健康指标 | 4 项 |
| 用药种类 | 2 种 |
| 健康贴士 | 6 条 |
| 弹窗数 | 0 个 |
| 外部依赖 | 0 个 |
零弹窗、零错误、零外部依赖——本 App 在三个维度上都达到了系列最优。
10.2 31 款 App 的代码行数对比
App 1: 767 ← 白噪音
App 10: 478 ← 订阅刺客
App 20: 582 ← 宠物拍立得
App 24: 907 ← AI 树洞(峰值)
App 28: 188 ← 记忆时光机(谷值)
App 29: 373 ← 反向导师平台
App 30: 406 ← 方言学习
App 31: 268 ← 慢病管理
268 行,系列第三少(仅次于 App 28 的 188 行和 App 27 的 340 行)。
10.3 首次实现的技术指标
| 指标 | 说明 | 系列首次 |
|---|---|---|
| 零编译错误 | 首次编译即通过,0 个错误 | ✅ |
| 健康数据类型 | 血压/血糖/体重数值记录 | ✅ |
| 5 元组数组模型 | number[] 替代 interface |
✅ |
| 随机数据生成 | Math.random() 生成模拟数据 |
✅ |
10.4 经验积累的速度
从 App 1 到 App 31,经验的积累不是线性的:
App 1-5: 平均 12 个错误/款
App 6-15: 平均 5 个错误/款
App 16-25: 平均 7 个错误/款(含 AI 树洞的 48 个)
App 26-30: 平均 6 个错误/款
App 31: 0 个错误
说明:即使经验丰富,进入新领域(AI 树洞)时错误数仍会反弹。但反弹后的基线比之前更低——从 5 降到了 0。
### 10.5 三十一款 App 的六个"首次"
回顾 31 款 App,每一款都有一个"系列首次"的贡献。App 8 首次使用 ForEach,App 16 首次使用 Grid,App 24 首次使用 AI 回应系统,App 28 创造了代码量最低纪录(188 行),App 30 首次使用 Helper 方法模式。App 31 的贡献是"首次零编译错误"——这不是一个激动人心的视觉里程碑,但它是最重要的基础能力:当你能零错误构建时,说明你已经完全掌握了当前技术栈的语法约束。
---
## 11. 结语
### 11.1 零错误是终点吗
不,零错误不是终点。
零错误只说明"编译器没有发现语法问题",但语法正确不代表产品正确。一个零编译错误但没有人使用的 App,不如一个有 10 个编译错误但有 1000 个用户的 App。
零错误只说明"编译器没有发现语法问题",但语法正确不代表产品正确。一个零编译错误但没有人使用的 App,不如一个有 10 个编译错误但有 1000 个用户的 App。零错误的真正价值在于:当编译器不报错时,你就可以把全部精力集中在产品逻辑和用户体验上。
### 11.2 从零错误到好产品
零错误是起点,不是终点。一个零错误但功能残缺的 App,不如一个有 3 个错误但解决用户痛点的 App。编译错误是技术问题,可以修复;产品价值是设计问题,无法自动生成。
在 31 款 App 的开发过程中,编译错误从最初的 16 个降到了 0 个。但"这个功能是否有用"这个问题从来没有被编译器回答过——它需要开发者自己判断。
所以给所有开发者的建议是:追求零错误是对的,但不要为了零错误而牺牲产品价值。如果必须引入一个新的代码模式(可能带来编译错误)来为用户创造价值,那就引入它。修复编译错误的成本是固定的(几分钟到几小时),但错失用户价值的成本是无法估量的。
**指标卡片**
```typescript
@Builder
buildMetricCard(emoji: string, label: string, value: string, unit: string, color: string) {
GridItem() {
Column() {
Text(emoji).fontSize(28)
Text(label).fontSize(12).fontColor(C.textMuted)
Text(value).fontSize(20).fontColor(color).fontWeight(FontWeight.Bold)
Text(unit).fontSize(10).fontColor(C.textMuted)
}.padding(12).backgroundColor(C.bgCard).borderRadius(14).height(110)
}
}
添加记录
addRecord(type: string): void {
const vals: number[] = [Date.now(), 0, 0, 0, 0];
if (type === 'bp') { vals[1] = Math.floor(110 + Math.random() * 30); vals[2] = Math.floor(70 + Math.random() * 20); }
else if (type === 'glu') { vals[3] = parseFloat((5.0 + Math.random() * 3).toFixed(1)); }
else if (type === 'wgt') { vals[4] = Math.floor(60 + Math.random() * 10); }
this.records = [vals, ...this.records];
}
最新值查询
getLatest(type: string): string {
for (const r of this.records) {
if (type === 'sbp' && r[1] > 0) return r[1] + '';
if (type === 'dbp' && r[2] > 0) return r[2] + '';
if (type === 'glu' && r[3] > 0) return r[3].toFixed(1);
if (type === 'wgt' && r[4] > 0) return r[4] + '';
}
return '--';
}
附录 B:色板
| 变量 | 值 | 用途 |
|---|---|---|
C.bg |
#F0F7F0 |
主背景(淡绿) |
C.bgCard |
#FFFFFF |
卡片背景 |
C.primary |
#4A9B5A |
健康绿 |
C.warm |
#D4A857 |
血压值 |
C.accent |
#5B8C5A |
体重值 |
更多推荐




所有评论(0)