健康数据的数值仓库:ArkTS 为鸿蒙健康应用设计多指标字段


实例:健康数据(Health)|技术:多指标字段、日期维度、HealthDao
一、业务需求分析:健康数据的数据形态
健康数据(体重、步数、睡眠、心率、饮水、运动时长)是数值密集型应用——每条记录是一天的多项指标,数据层的核心是**「按日聚合」与「趋势计算」**(日/周/月汇总)。这也是本实例与前面实例最大的不同:统计查询是主角,增删改查是配角。
核心业务需求:
- 每日健康记录:一个日期一条记录,含体重、步数、睡眠时长、静息心率、饮水、运动时长六项指标;
- 日期维度汇总:本周平均值、本月合计——按日聚合(AVG/SUM + GROUP BY 日期范围);
- 趋势计算:体重周变化(本周均值 vs 上周均值)、睡眠趋势——对比查询;
- 可视化数据源:近 7 天/30 天的指标序列——喂给趋势图表(页面用 Text 柱状图模拟)。
二、字段设计:六项指标 + 日期维度
健康表 health
| 字段名 | 类型 | 约束 | 说明 |
|---|---|---|---|
| id | INTEGER | PRIMARY KEY AUTOINCREMENT | 自增主键 |
| record_date | TEXT | NOT NULL UNIQUE | 日期(yyyy-MM-dd,天然去重) |
| weight | REAL | NOT NULL DEFAULT 0 | 体重 kg |
| steps | INTEGER | NOT NULL DEFAULT 0 | 步数 |
| sleep_hours | REAL | NOT NULL DEFAULT 0 | 睡眠时长 h |
| heart_rate | INTEGER | NOT NULL DEFAULT 0 | 静息心率 bpm |
| water | INTEGER | NOT NULL DEFAULT 0 | 饮水 ml |
| exercise_minutes | INTEGER | NOT NULL DEFAULT 0 | 运动时长 min |
| remark | TEXT | DEFAULT ‘’ | 备注 |
设计要点拆解:
1. record_date 用 TEXT 存 ‘yyyy-MM-dd’ + UNIQUE。健康记录「一天一条」——UNIQUE 约束保证同一日期只有一条记录,避免重复。为什么不用时间戳?因为「按天聚合」需要按日期分组,‘yyyy-MM-dd’ 字符串天然同组(字符串比较即日期比较);时间戳还要转换日期格式。日期维度用规范字符串(ISO 日期格式)是时序数据的最佳实践——排序、比较、分组都正确。
2. UNIQUE 约束的价值:重复插入同一天 → 违反约束报错。数据层用「先查后插」或「INSERT OR REPLACE」处理(落地版:新增时先检查该日期是否已存在,存在则提示)。UNIQUE 是数据完整性的第一道防线——比应用层判断可靠。
3. 六项指标的字段类型:
- REAL:weight(73.5)、sleep_hours(7.5)——小数指标;
- INTEGER:steps(8000)、heart_rate(65)、water(1500)、exercise_minutes(30)——整数指标。
数值类型的正确性:REAL 存小数(体重、睡眠),INTEGER 存整数(步数、心率)——类型与数据的自然单位匹配,聚合计算(AVG/SUM)才准确。
4. remark 备注:每日备注(「加班了」「跑了个半马」)——给数值加一句人话,让健康记录有温度。
三、建表 SQL 与索引
CREATE TABLE IF NOT EXISTS health (
id INTEGER PRIMARY KEY AUTOINCREMENT,
record_date TEXT NOT NULL UNIQUE,
weight REAL NOT NULL DEFAULT 0,
steps INTEGER NOT NULL DEFAULT 0,
sleep_hours REAL NOT NULL DEFAULT 0,
heart_rate INTEGER NOT NULL DEFAULT 0,
water INTEGER NOT NULL DEFAULT 0,
exercise_minutes INTEGER NOT NULL DEFAULT 0,
remark TEXT DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_health_date ON health (record_date);
idx_health_date 日期索引:所有按日期范围查询(本周/本月/近 7 天)都走 record_date 范围条件——WHERE record_date >= ? AND record_date <= ? 走索引加速。日期范围查询的索引是时序数据的标配。
四、HealthDao 封装:聚合查询为主
数据层核心 HealthDao,查询方法以「聚合 + 日期范围」为主:
按日期查询单日:
static async queryByDate(context: common.Context, date: string): Promise<HealthRecord | null> {
const store = await HealthDao.getStore(context);
const predicates = new relationalStore.RdbPredicates(HealthDao.TABLE);
predicates.equalTo('record_date', date);
const result = await store.query(predicates);
if (result.goToNextRow()) {
const r = HealthDao.rowToRecord(result);
result.close();
return r;
}
result.close();
return null;
}
返回 null 表示该日无记录——页面新增前先查(存在则提示更新而非新增)。
日期范围查询(近 N 天)——趋势图的数据源:
static async queryRange(context: common.Context, startDate: string, endDate: string): Promise<HealthRecord[]> {
const store = await HealthDao.getStore(context);
const predicates = new relationalStore.RdbPredicates(HealthDao.TABLE);
predicates.greaterThanOrEqualTo('record_date', startDate)
.lessThanOrEqualTo('record_date', endDate)
.orderByAsc('record_date');
const result = await store.query(predicates);
const list: HealthRecord[] = [];
while (result.goToNextRow()) {
list.push(HealthDao.rowToRecord(result));
}
result.close();
return list;
}
日期字符串的范围比较:'2024-06-01' <= record_date <= '2024-06-30'——TEXT 日期按字典序比较,恰好等于日期顺序(ISO 格式保证)。这是 record_date 用规范字符串的核心红利:范围查询不需要任何日期函数转换。
日期范围计算(页面辅助,放 DAO 更聚合——落地版放 DAO):
/** 计算近 N 天的起止日期(含今天) */
static today(): string {
const d = new Date();
return HealthDao.fmtDate(d);
}
static daysAgo(n: number): string {
const d = new Date(Date.now() - n * 86400000);
return HealthDao.fmtDate(d);
}
static fmtDate(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
日期格式化工具:padStart(2, '0') 补零——2024-6-1 必须补成 2024-06-01 才能字典序正确比较('2024-6-1' 与 '2024-06-01' 排序会乱)。日期字符串的零填充是字典序正确的前提。
五、技术要点对照表
| 技术点 | 实现方式 | 生产价值 |
|---|---|---|
| 日维度 | record_date TEXT UNIQUE | 一天一条 |
| 日期范围 | 字符串范围比较 | 无需日期函数 |
| 日期索引 | idx_health_date | 范围查询加速 |
| 单日查询 | equalTo(record_date) + null | 先查后插 |
| 趋势数据 | queryRange(start, end) | 图表数据源 |
| 类型匹配 | REAL 小数 / INTEGER 整数 | 聚合准确 |
六、文章小结
健康数据的数据层核心是**「日期维度 + 数值指标 + 聚合查询」**:record_date 用规范字符串(yyyy-MM-dd + UNIQUE)承载「一天一条」的时序语义,日期范围查询吃「字符串比较 = 日期比较」的红利(无需日期函数转换),六项指标按自然单位选型(REAL/INTEGER)。数据层以「范围查询喂趋势图」为设计中心——这是数值统计类应用的标准骨架。
下一篇(14-2)展示数据仪表盘 UI——本周概览卡片 + 近 7 天趋势柱状图 + 记录表单。
七、record_date 字段详解:UNIQUE 撑起「一天一条」
record_date TEXT NOT NULL UNIQUE 是健康表的语义核心——一条记录就是「某一天的健康状态」,UNIQUE 约束把「一天一条」从应用层约定变成了数据库层面的硬规则。
UNIQUE 约束的三种行为
| 插入场景 | 数据库行为 | 落地处理 |
|---|---|---|
| 插入全新日期 | 正常写入 | 新增记录 |
| 重复插入已有日期 | 违反 UNIQUE 约束报错 | 先查后插,提示「该日已有记录,是否更新」 |
| INSERT OR REPLACE 重插 | 覆盖旧行 | 编辑保存 |
数据层「先查后插」的完整流程:
static async save(context: common.Context, record: HealthRecord): Promise<number> {
const store = await HealthDao.getStore(context);
const exist = await HealthDao.queryByDate(context, record.recordDate);
if (exist) {
// 更新:以 record_date 为条件,不依赖 id
const predicates = new relationalStore.RdbPredicates(HealthDao.TABLE);
predicates.equalTo('record_date', record.recordDate);
return store.update(record.toValues(), predicates);
}
// 新增:UNIQUE 兜底,即便并发重复插入也会被数据库拦截
return store.insert(HealthDao.TABLE, record.toValues());
}
「先查后插 + UNIQUE 兜底」是双保险——正常路径由应用层判断,异常路径(并发、重复提交)由数据库约束拦截,数据完整性不依赖程序员的细心。
六项指标的选型逻辑
| 指标 | 类型 | 典型值 | 选型理由 |
|---|---|---|---|
| weight | REAL | 73.5 | 小数体重,AVG 需要精度 |
| sleep_hours | REAL | 7.5 | 半小时粒度的小数 |
| steps | INTEGER | 8000 | 步数天然整数 |
| heart_rate | INTEGER | 65 | 心率取整即可 |
| water | INTEGER | 1500 | 毫升整数 |
| exercise_minutes | INTEGER | 30 | 分钟整数 |
选型口诀:「小数用 REAL,整数用 INTEGER」。REAL 存整数浪费精度表达(7.0 存成 7.0),INTEGER 存小数会截断(7.5 存成 7)——类型与自然单位错配,聚合结果必然失真。
八、日期字符串 vs 时间戳:为什么选 TEXT
健康数据按「天」聚合,日期存储有两条路:TEXT 存 ‘yyyy-MM-dd’ 或 INTEGER 存时间戳(毫秒)。落地版选 TEXT,对比如下:
| 维度 | TEXT ‘yyyy-MM-dd’ | INTEGER 时间戳 |
|---|---|---|
| 存储示例 | ‘2024-06-01’ | 1717171200000 |
| 按天分组 | 直接 GROUP BY,天然同组 | 需 strftime(‘%Y-%m-%d’, …) 转换 |
| 范围比较 | 字典序 = 日期序,直接比较 | 数值比较,但日期函数转换繁琐 |
| 可读性 | 一眼可读 | 需格式化 |
| 一天多条 | UNIQUE 直接约束 | 需应用层去重 |
时间戳的粒度是「秒/毫秒」,健康数据的粒度是「天」——用时间戳存日期,等于用天平称西瓜:精度过剩,转换成本高。每天只存一个 ‘yyyy-MM-dd’ 字符串,排序、比较、分组、UNIQUE 全部开箱即用。
唯一的取舍:同日多次测量(如早/晚各称一次体重)无法存多条——健康实例按「一天一条」设计,多次测量由用户在表单中直接覆盖当日数值。
九、日期格式化:padStart 补零是字典序的前提
fmtDate 里最不起眼的一行,决定了整个日期体系是否正确:
static fmtDate(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0'); // 6 -> '06'
const day = String(d.getDate()).padStart(2, '0'); // 1 -> '01'
return `${y}-${m}-${day}`;
}
为什么不补零会乱?字典序比较逐字符进行:
'2024-6-1' vs '2024-06-01'
^ ^
'6' > '0' → '2024-6-1' 排在 '2024-06-01' 之后 ✗
而不补零的 ‘2024-6-10’ 与 ‘2024-6-2’ 比较:'1' < '2',6 月 10 日反而排在 6 月 2 日前面——排序彻底错乱,范围查询(>= start AND <= end)也会漏数据。零填充让 ‘yyyy-MM-dd’ 成为「排序即日期序」的规范形态,这是整个日期维度设计的地基。
十、范围查询为什么需要索引
idx_health_date 是一个普通 B-Tree 索引,服务于所有日期范围查询:
-- 近 7 天:走 idx_health_date 索引
SELECT * FROM health
WHERE record_date >= '2024-06-24' AND record_date <= '2024-06-30'
ORDER BY record_date ASC;
| 有无索引 | 执行方式 | 数据量 365 行(一年) | 数据量 3650 行(十年) |
|---|---|---|---|
| 无索引 | 全表扫描,逐行比对 | 慢(仍可接受) | 明显变慢 |
| 有索引 | B-Tree 定位 + 区间扫描 | 快 | 几乎不变 |
索引的本质是「以空间换时间」:B-Tree 把日期排好序,范围查询只需定位起点、沿链表顺序扫到终点——耗时与「区间内行数」成正比,与全表行数无关。健康数据虽小,但「范围查询 + ORDER BY」是仪表盘每个动作都在走的路径,索引是这类时序表的标配。注意索引不改变查询结果,只改变查询速度——queryRange 的 SQL 不需要为索引做任何修改。
十一、FAQ
Q1:为什么不用自增 id 判断「是否已有当日记录」?
A:自增 id 是插入顺序,不代表日期。判断当日是否存在必须按业务键 record_date 查——这也正是 UNIQUE 约束的字段。
Q2:UNIQUE 和 PRIMARY KEY 有什么区别?
A:两者都保证唯一,但一张表只有一个主键,且主键常用于关联(外键引用)。record_date 是业务唯一键、id 是关联主键,两者职责不同,可以并存。
Q3:时间戳查询更快吗?
A:数值比较理论上略快,但健康表每天一条、数据量极小,差异可忽略;而时间戳带来的 strftime 转换成本和可读性损失是实打实的。正确性 > 微优化。
Q4:record_date 能存 ‘2024/06/01’ 这种格式吗?
A:不建议。‘/’ 分隔的日期字典序与日期序不一致(‘2024/6/2’ 会排在 ‘2024/6/10’ 之后),且混用格式会让范围查询失效。格式必须统一为 ‘yyyy-MM-dd’。
Q5:索引会拖慢写入吗?
A:会,每次 insert/update 都要维护索引树。但健康表一天最多一条记录,写入频率极低,索引维护成本可忽略——「读多写少」正是建索引的最佳场景。
更多推荐




所有评论(0)