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

实例:健康数据(Health)|技术:多指标字段、日期维度、HealthDao

一、业务需求分析:健康数据的数据形态

健康数据(体重、步数、睡眠、心率、饮水、运动时长)是数值密集型应用——每条记录是一天的多项指标,数据层的核心是**「按日聚合」与「趋势计算」**(日/周/月汇总)。这也是本实例与前面实例最大的不同:统计查询是主角,增删改查是配角

核心业务需求:

  1. 每日健康记录:一个日期一条记录,含体重、步数、睡眠时长、静息心率、饮水、运动时长六项指标;
  2. 日期维度汇总:本周平均值、本月合计——按日聚合(AVG/SUM + GROUP BY 日期范围);
  3. 趋势计算:体重周变化(本周均值 vs 上周均值)、睡眠趋势——对比查询
  4. 可视化数据源:近 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 都要维护索引树。但健康表一天最多一条记录,写入频率极低,索引维护成本可忽略——「读多写少」正是建索引的最佳场景。

Logo

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

更多推荐