在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

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

作者:duluo
SDK 版本:HarmonyOS API 24 (Next)
开发工具:DevEco Studio
语言框架:ArkTS + ArkUI
字数:约 9500 字


目录

  1. 引言
  2. 产品概念与数据模型
  3. 三 Tab 架构设计
  4. 健康指标卡片设计
  5. 数据记录与模拟生成
  6. 用药管理系统
  7. 健康贴士系统
  8. Grid 组件复用模式
  9. 零错误构建实践
  10. 第三十一款 App 全景回顾
  11. 结语

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 体重值

Logo

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

更多推荐