本地数据持久化
HealthAide 应用基于 Android Room 框架构建的本地 SQLite 持久化层,负责健康指标、运动记录、位置轨迹、用户信息与 AI 云端消息等数据的落盘存储、查询与迁移。
Purpose and Scope
本页讲解 HealthAide(HealthAide_V1.1.0_SDK_V1.14.0 模块)中 com.jieli.healthaide.data 包下的本地持久化机制,涵盖:
- 数据库层(
data/db):Room 数据库HealthDatabase、数据库门面HealthDataDbHelper、演示数据辅助类VirtualDataHelper; - 数据访问层(
data/dao):UserDao、SportRecordDao、HealthDao、LocationDao、AICloudMessageDao; - 实体层(
data/entity):User、SportRecord、HealthEntity、LocationEntity、AICloudMessageEntity; - 数据库版本与迁移策略:版本 3、
MIGRATION_2_3增量建表; - 线程模型与生命周期:单例初始化、单线程写入线程池、
destroy()清理。
本页不覆盖数据来源侧的蓝牙协议解析(VO 解析层)、云端 AI 接口调用或 UI 层展示逻辑,这些内容属于 4-health-sports 目录下的其他页面。
Overview
为什么需要本地持久化
健康类应用的核心场景是持续采集、离线可查、历史回溯:心率、血氧、步数、运动记录、GPS 轨迹等数据由手环/手表通过蓝牙持续同步到手机,若仅保存在内存中,应用进程被杀后数据即丢失;若全部依赖云端,则网络不可用时功能不可用。因此 HealthAide 将采集数据先落入本地 SQLite 数据库(jl_health.db),再按需上传或展示。
为什么选择 Room
- 编译期校验:
@Database/@Entity注解与 DAO 接口在编译期生成 SQL 访问实现,表结构或 SQL 错误在编译期即可暴露; - 迁移机制:通过
Migration类控制 schema 演进,避免升级时数据丢失; - 与 LiveData/协程集成友好:DAO 返回值可直接支撑 UI 层响应式刷新(
data/vo包中另有 VO 解析体系配合使用)。
关键概念
| 概念 | 说明 |
|---|---|
HealthDatabase | Room 数据库抽象类,唯一数据源,管理 5 张表 |
| DAO(Data Access Object) | 每张表对应的数据访问接口,封装 SQL 增删改查 |
| Entity | 与表一一对应的 POJO 类 |
| Migration | 数据库版本升级时的增量 schema 变更 |
HealthDataDbHelper | 面向业务层的单例门面,统一暴露 DAO 与数据库实例 |
Architecture
flowchart TD
subgraph sg_App["应用上层"]
VM["ViewModel / 业务层"]
App["HealthApplication"]
end
subgraph sg_Facade["访问门面"]
Helper["HealthDataDbHelper<br/>(单例门面)"]
end
subgraph sg_Db["数据库层 data/db"]
DB["HealthDatabase<br/>(Room 单例, jl_health.db, version=3)"]
VP["VirtualDataHelper<br/>(演示数据)"]
end
subgraph sg_Dao["数据访问层 data/dao"]
UDao["UserDao"]
SDao["SportRecordDao"]
HDao["HealthDao"]
LDao["LocationDao"]
ADao["AICloudMessageDao"]
end
subgraph sg_Entity["实体层 data/entity"]
U["User"]
S["SportRecord"]
H["HealthEntity"]
L["LocationEntity"]
A["AICloudMessageEntity"]
end
SQLite[("SQLite 文件<br/>jl_health.db")]
VM -->|"getInstance()"| Helper
App -->|"提供 Application Context"| Helper
Helper -->|"getHealthDatabase()"| DB
Helper -->|"getXxxDao()"| UDao
Helper -->|"getXxxDao()"| SDao
Helper -->|"getXxxDao()"| HDao
Helper -->|"getXxxDao()"| LDao
Helper -->|"getXxxDao()"| ADao
DB --> UDao
DB --> SDao
DB --> HDao
DB --> LDao
DB --> ADao
UDao --> U
SDao --> S
HDao --> H
LDao --> L
ADao --> A
U --> SQLite
S --> SQLite
H --> SQLite
L --> SQLite
A --> SQLite
VP -.->|"预置/演示数据"| DB
架构说明:上层业务(ViewModel 等)从不直接接触 Room,而是通过 HealthDataDbHelper 单例获取 DAO 或数据库实例。这一分层的目的:
- 隔离实现细节:数据库构建、迁移、线程池管理都被封装在
data/db内部,业务层只面对类型安全的 DAO 接口; - 单一入口:
HealthDataDbHelper是唯一对外门面,DAO 实例由HealthDatabase统一生成,避免多处重复构建数据库; - 便于替换:若未来切换 DataStore 或自研存储,只需改动门面与数据库层,DAO 签名不变,上层无感知。
核心实现
1. Room 数据库:HealthDatabase
HealthDatabase 是持久化层的核心抽象类,通过 @Database 注解声明全部实体与当前版本号:
@Database(entities = {User.class, SportRecord.class,HealthEntity.class, LocationEntity.class, AICloudMessageEntity.class}, version = 3)
public abstract class HealthDatabase extends RoomDatabase {
private final static String DB_NAME = "jl_health.db";
private volatile static HealthDatabase instance;
private final ExecutorService mThreadPool = Executors.newSingleThreadExecutor();
public abstract UserDao UserDao();
public abstract SportRecordDao SportRecordDao();
public abstract HealthDao HealthDao();
public abstract LocationDao LocationDao();
public abstract AICloudMessageDao AICloudMessageDao();
...
}
Source: HealthDatabase.java
设计要点:
- 一张数据库文件承载五张表:健康指标(
HealthEntity)、运动记录(SportRecord)、位置(LocationEntity)、用户(User)、AI 云端消息(AICloudMessageEntity),统一版本号3,所有迁移在同一数据库上按版本递增执行; - DAO 以抽象方法暴露:Room 在编译期根据方法返回类型(
UserDao等)自动生成实现,接口即契约; - 内置单线程 Executor:
mThreadPool = Executors.newSingleThreadExecutor()为写入操作提供串行化执行通道,避免多线程并发写同一 SQLite 文件时的锁竞争与SQLiteDatabaseLockedException。
单例构建(双重检查锁)
public static HealthDatabase buildHealthDb(Context context) {
if (instance == null) {
synchronized (HealthDatabase.class) {
if (instance == null) {
instance = Room.databaseBuilder(context, HealthDatabase.class, DB_NAME)
.allowMainThreadQueries()
// .createFromAsset("databases/jl_health.db")//todo 测试使用的预存健康数据
.addMigrations(MIGRATION_2_3)
.build();
}
}
}
return instance;
}
Source: HealthDatabase.java
设计意图分析:
- 双重检查锁(DCL):
volatile修饰的instance配合synchronized块,保证多线程环境下数据库只被构建一次,同时避免每次访问都加锁的开销; allowMainThreadQueries():显式放开主线程查询限制。这是为兼容历史代码中直接在主线程读取健康数据的调用(如部分 UI 初始化路径),代价是可能产生主线程 I/O 卡顿——这是权衡而非最佳实践,新代码应尽量使用线程池(见下);createFromAsset被注释:保留了一份"预存健康数据"的测试路径,正式构建时不使用;addMigrations(MIGRATION_2_3):注册版本 2→3 的迁移,Room 在打开数据库时若发现磁盘版本与期望版本不一致,会自动按注册的迁移链升级。
线程池与销毁
public ExecutorService getThreadPool() {
return mThreadPool;
}
public void destroy() {
if (!mThreadPool.isShutdown()) {
mThreadPool.shutdownNow();
}
instance.close();
instance = null;
}
Source: HealthDatabase.java
destroy() 是完整的生命周期收尾:先 shutdownNow() 中断排队/执行中的写任务,再 close() 关闭数据库连接,最后置空单例以便下次 buildHealthDb 重建。该设计支持应用在账号切换或登出时安全重置本地数据源。
2. 数据库迁移:MIGRATION_2_3
/**
* 数据库版本 2->3 增加了 AICloudMessageEntity 表
*/
static final Migration MIGRATION_2_3 = new Migration(2, 3) {
@Override
public void migrate(@NonNull SupportSQLiteDatabase database) {
database.execSQL("CREATE TABLE IF NOT EXISTS `AICloudMessageEntity` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `uid` TEXT NOT NULL, `devMac` TEXT NOT NULL, `role` INTEGER NOT NULL, `time` INTEGER NOT NULL, `revId` INTEGER NOT NULL, `aiCloudState` INTEGER NOT NULL, `text` TEXT)");
}
};
Source: HealthDatabase.java
该迁移展示了本地持久化的演进策略:只增不删。新增 AI 云端消息表时使用 CREATE TABLE IF NOT EXISTS 保证幂等(迁移失败或重复执行不会报错),且不触碰已有四张表的任何列,避免老用户升级时数据损坏。从建表 SQL 可以还原 AICloudMessageEntity 的完整结构:
| 列名 | 类型 | 约束/含义 |
|---|---|---|
id | INTEGER | 主键,自增 |
uid | TEXT | 用户 ID(非空) |
devMac | TEXT | 设备 MAC(非空) |
role | INTEGER | 消息角色(非空) |
time | INTEGER | 时间戳(非空) |
revId | INTEGER | 云端回复 ID(非空) |
aiCloudState | INTEGER | AI 云端状态(非空) |
text | TEXT | 消息文本(可空) |
3. 访问门面:HealthDataDbHelper
HealthDataDbHelper 是业务层访问数据库的统一入口,采用饿汉式单例 + 静态持有模式:
@SuppressLint("StaticFieldLeak")
private volatile static HealthDataDbHelper instance;
private final HealthDatabase mHealthDatabase;
private HealthDataDbHelper() {
Context context = HealthApplication.getAppViewModel().getApplication();
if (null == context) {
throw new NullPointerException("Application is null.");
}
mHealthDatabase = HealthDatabase.buildHealthDb(context);
}
public static HealthDataDbHelper getInstance() {
if (null == instance) {
synchronized (HealthDataDbHelper.class) {
if (null == instance) {
instance = new HealthDataDbHelper();
}
}
}
return instance;
}
Source: HealthDataDbHelper.java
要点:
- Context 来源:从
HealthApplication.getAppViewModel().getApplication()获取全局 Application Context(而非 Activity),避免内存泄漏;若拿不到则抛出NullPointerException快速失败——这保证了数据库实例一定在合法环境下构建; @SuppressLint("StaticFieldLeak"):承认静态持有 Context 的固有风险,但 Application 级 Context 生命周期与应用进程一致,实际不会泄漏;- 对外方法:
getHealthDao()、getSportRecordDao()、getLocationDao()、getAICloudMessageDao()分别返回对应 DAO,getHealthDatabase()返回数据库本体(供需要直接操作线程池/事务的场景使用):
public HealthDao getHealthDao() {
return mHealthDatabase.HealthDao();
}
public SportRecordDao getSportRecordDao() {
return mHealthDatabase.SportRecordDao();
}
public LocationDao getLocationDao() {
return mHealthDatabase.LocationDao();
}
public AICloudMessageDao getAICloudMessageDao() {
return mHealthDatabase.AICloudMessageDao();
}
public HealthDatabase getHealthDatabase() {
return mHealthDatabase;
}
Source: HealthDataDbHelper.java
4. DAO 与实体全景
data/dao 与 data/entity 两个包构成了"每实体一张表、每表一个 DAO"的规整映射:
| DAO | 对应实体 | 存储内容 |
|---|---|---|
UserDao | User | 用户档案/账号信息 |
SportRecordDao | SportRecord | 运动记录(类型、时长、消耗等) |
HealthDao | HealthEntity | 健康指标(心率、血氧等按时间序列存储) |
LocationDao | LocationEntity | 运动轨迹 GPS 定位点 |
AICloudMessageDao | AICloudMessageEntity | AI 云端对话消息(含 role/revId/aiCloudState 字段) |
每个 DAO 均通过 @Dao 注解定义插入、查询、更新、删除方法,由 Room 编译期生成实现;实体类通过 @Entity 注解声明表名与主键。查询结果通常直接支撑 UI:健康数据与运动记录按时间区间(日/月)聚合展示,位置数据按轨迹回放,AI 消息按会话渲染——本地持久化是整个"健康-运动"业务闭环的数据底座。
5. 演示数据:VirtualDataHelper
data/db 包中还存在 VirtualDataHelper,用于生成/注入演示数据(与 HealthDatabase 中被注释的 createFromAsset("databases/jl_health.db") 测试路径呼应),方便在无真实设备时验证列表、图表与轨迹展示。具体实现细节建议直接查阅该文件。
核心流程
写入流程(数据落盘)
sequenceDiagram
participant Biz as 业务层 ViewModel
participant Helper as HealthDataDbHelper
participant DB as HealthDatabase
participant DAO as XxxDao
participant SQLite as jl_health.db
Biz->>Helper: getInstance()
Helper->>DB: 内部构造时 buildHealthDb(context)
Note over DB: Room 构建实例<br/>校验版本、执行 MIGRATION_2_3
Biz->>Helper: getXxxDao()
Helper-->>Biz: DAO 实例
Biz->>DB: getThreadPool().execute(...)
DB-->>Biz: 单线程 Executor
Biz->>DAO: insert(entity) / update(entity)
DAO->>SQLite: 生成 SQL 写入
SQLite-->>DAO: 行 id / 影响行数
DAO-->>Biz: 结果(id / 行数)
读取流程(数据展示)
sequenceDiagram
participant UI as UI/图表
participant Biz as 业务层
participant Helper as HealthDataDbHelper
participant DAO as HealthDao/SportRecordDao
participant SQLite as jl_health.db
UI->>Biz: 请求某时间段健康数据
Biz->>Helper: getHealthDao()
Helper-->>Biz: HealthDao
Biz->>DAO: 按时间区间查询(日/月聚合)
DAO->>SQLite: SELECT ... WHERE time BETWEEN ...
SQLite-->>DAO: 结果集
DAO-->>Biz: Entity / VO 列表
Biz-->>UI: 数据驱动图表刷新
流程说明:所有写入都通过 HealthDatabase.getThreadPool() 单线程执行器串行提交,保证 SQLite 写入的顺序一致性;读取路径则经门面取 DAO 后直接查询。主线程查询因 allowMainThreadQueries() 被允许,但建议新代码遵循"写走线程池、读异步化"的约定,避免阻塞 UI。
使用示例
示例一:初始化数据库并写入健康数据
// 业务层获取数据库与线程池,在后台线程写入健康指标
HealthDatabase db = HealthDataDbHelper.getInstance().getHealthDatabase();
db.getThreadPool().execute(() -> {
HealthDao dao = HealthDataDbHelper.getInstance().getHealthDao();
dao.insert(healthEntity); // 插入一条健康记录
dao.update(healthEntity); // 或更新已有记录
});
Source: HealthDataDbHelper.java 与 HealthDatabase.java
示例二:数据库版本迁移(2 → 3 新增 AI 消息表)
static final Migration MIGRATION_2_3 = new Migration(2, 3) {
@Override
public void migrate(@NonNull SupportSQLiteDatabase database) {
database.execSQL("CREATE TABLE IF NOT EXISTS `AICloudMessageEntity` "
+ "(`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `uid` TEXT NOT NULL, "
+ "`devMac` TEXT NOT NULL, `role` INTEGER NOT NULL, `time` INTEGER NOT NULL, "
+ "`revId` INTEGER NOT NULL, `aiCloudState` INTEGER NOT NULL, `text` TEXT)");
}
};
// 注册方式:Room.databaseBuilder(context, HealthDatabase.class, "jl_health.db")
// .addMigrations(MIGRATION_2_3).build();
Source: HealthDatabase.java
配置选项
| 配置项 | 类型/取值 | 默认值 | 说明 |
|---|---|---|---|
数据库文件名 DB_NAME | String | "jl_health.db" | SQLite 数据库文件名,存储在应用私有目录 |
数据库版本 version | int | 3 | 通过 @Database(version = 3) 声明,变更 schema 时必须递增并注册 Migration |
实体列表 entities | Class[] | {User, SportRecord, HealthEntity, LocationEntity, AICloudMessageEntity} | @Database 注解声明,决定建表 |
| 主线程查询 | boolean | true(allowMainThreadQueries()) | 允许在主线程执行查询;历史兼容性开关 |
预置数据库 createFromAsset | String path | 未启用(已注释) | 从 assets 拷贝预存数据库,测试专用 |
迁移 addMigrations | Migration[] | MIGRATION_2_3 | 版本 2→3 增量建表 |
| 写线程池 | ExecutorService | 单线程 newSingleThreadExecutor() | 串行化所有写入任务,避免并发锁冲突 |
API 参考
HealthDatabase
| 方法 | 签名 | 说明 |
|---|---|---|
| 构建 | static HealthDatabase buildHealthDb(Context context) | 双重检查锁单例构建;重复调用返回同一实例 |
| 用户 DAO | abstract UserDao UserDao() | Room 生成的 DAO 访问器 |
| 运动 DAO | abstract SportRecordDao SportRecordDao() | 运动记录访问器 |
| 健康 DAO | abstract HealthDao HealthDao() | 健康指标访问器 |
| 位置 DAO | abstract LocationDao LocationDao() | GPS 轨迹访问器 |
| AI 消息 DAO | abstract AICloudMessageDao AICloudMessageDao() | AI 云端消息访问器 |
| 线程池 | ExecutorService getThreadPool() | 返回单线程写入执行器 |
| 销毁 | void destroy() | 关闭线程池与数据库并置空单例 |
HealthDataDbHelper
| 方法 | 签名 | 说明 |
|---|---|---|
| 单例 | static HealthDataDbHelper getInstance() | 双重检查锁;构造时校验 Application Context 非空 |
| 健康 DAO | HealthDao getHealthDao() | 委托 mHealthDatabase.HealthDao() |
| 运动 DAO | SportRecordDao getSportRecordDao() | 委托 mHealthDatabase.SportRecordDao() |
| 位置 DAO | LocationDao getLocationDao() | 委托 mHealthDatabase.LocationDao() |
| AI 消息 DAO | AICloudMessageDao getAICloudMessageDao() | 委托 mHealthDatabase.AICloudMessageDao() |
| 数据库 | HealthDatabase getHealthDatabase() | 返回数据库本体(线程池/事务等高级场景) |
构造异常:HealthDataDbHelper() 在 HealthApplication.getAppViewModel().getApplication() 返回 null 时抛出 NullPointerException("Application is null."),用于在应用初始化顺序错误时快速暴露问题。
失败模式、边界情况与并发
并发与一致性
- 写入串行化:
HealthDatabase内置newSingleThreadExecutor(),所有写操作(insert/update/delete)应通过getThreadPool()提交,从根本上避免 SQLite 多写并发导致的SQLiteDatabaseLockedException; - 单例双重检查锁:
instance使用volatile修饰,buildHealthDb与getInstance均采用 DCL,保证多线程下只构建一次;Room 自身对同一数据库文件也做了进程内单例约束; - 读多写少:查询不经过线程池(
allowMainThreadQueries放开主线程读),UI 直接驱动读取,写入走后台,读写互不阻塞。
生命周期与销毁边界
destroy()语义:先shutdownNow()中断线程池再close()数据库。若在销毁后仍有代码调用getInstance(),会重新构建一个全新数据库实例——上层必须保证"销毁后不再持有旧 DAO 引用",否则会得到已关闭的数据库(IllegalStateException: attempt to re-open an already-closed object);- 进程级单例生命周期:
HealthDataDbHelper与HealthDatabase均为静态单例,生命周期等同应用进程;HealthApplication是 Context 的唯一合法来源,任何使用 Activity/Service Context 构建数据库的调用都是隐患。
数据完整性风险
- 迁移只增不删:
MIGRATION_2_3使用CREATE TABLE IF NOT EXISTS,幂等但不处理已有数据清洗;若后续版本修改列约束(如将可空列改为非空),必须新增迁移做UPDATE ... WHERE数据回填,否则升级后可能出现约束冲突; - 版本号与迁移不匹配:
@Database(version = 3)只注册了MIGRATION_2_3。若磁盘上存在版本 1 的旧库,Room 将抛出IllegalStateException(缺少 1→2 迁移链)——这意味着版本 1 的库已被放弃支持,属于有意的边界; - 外键/级联缺失:从迁移 SQL 可见表间无外键约束,实体间关联(如
SportRecord与LocationEntity的轨迹归属)由业务层维护,删除运动记录时需手动级联清理位置数据。
边界情况
text可空:AICloudMessageEntity.text无 NOT NULL 约束,渲染 AI 消息时需判空;- 时间戳为 INTEGER:
time以毫秒/秒级整数存储,查询"日/月"聚合依赖WHERE time BETWEEN的区间过滤,时区换算由上层 VO/UI 处理; - 空数据:首次安装无任何数据,DAO 查询返回空列表,UI 需有空态设计(
VirtualDataHelper提供演示数据填充路径)。
性能与运维注意事项
- 主线程 I/O:
allowMainThreadQueries()允许主线程查询,简单点查(单条记录)可接受,但日/月聚合、轨迹回放等大数据量查询不应在主线程执行,应使用线程池或异步查询,否则列表滚动/图表绘制会卡顿; - 写入批量性:健康数据为高频时间序列(心率、血氧可能每分钟多条),建议批量插入(DAO 的
insertAll风格方法)并复用同一事务,减少 SQLite 文件写放大; - 数据库体积:长时间佩戴会积累大量
HealthEntity与LocationEntity记录,需在业务层制定保留策略(如按月清理),当前源码未体现自动清理任务; - 调试与测试:被注释的
createFromAsset("databases/jl_health.db")与VirtualDataHelper表明团队使用"预置数据库 + 演示数据"两条路径做无设备联调;接入 CI 时可利用这两条路径做 UI 冒烟测试。
扩展点
- 新增实体表:按现有模式操作——新增
@Entity类 → 新建@Dao接口 → 在HealthDatabase的entities数组与抽象方法中各加一项 → 版本号+1→ 注册Migration(old, new)增量建表 → 在HealthDataDbHelper暴露对应 DAO 门面方法; - 切换存储方案:业务层只依赖
HealthDataDbHelper门面与 DAO 签名,若未来迁移至 DataStore/MMKV 或自研缓存,可替换data/db与data/dao两包实现而不影响上层; - 预置数据:取消
createFromAsset注释并放置app/src/main/assets/databases/jl_health.db即可发布带初始数据的版本(适合演示包); - 会话级数据重置:账号切换时可调用
HealthDatabase.destroy()后重建,或按uid/devMac字段做分区清理。
测试现状
仓库中存在 app/src/androidTest 下的 ExampleInstrumentedTest.java(仪器化测试模板),未发现针对数据库层(DAO/迁移/实体)的专门单元测试。建议补充以下用例以固化行为:
MIGRATION_2_3:用版本 2 的预置库升级,断言AICloudMessageEntity表存在且旧四表数据完好;- DAO CRUD:插入/查询/更新/删除的往返一致性(含
time区间过滤); - 单例语义:多次
getInstance()返回同一实例,destroy()后可重建。
相关链接
- 数据解析层(VO 体系):
com.jieli.healthaide.data.vo(蓝牙原始数据 → 业务对象解析,与实体持久化配套) - 应用入口:
HealthApplication(提供getAppViewModel().getApplication()作为数据库 Context 来源) - 演示数据:
data/db/VirtualDataHelper.java - 数据库门面:
data/db/HealthDataDbHelper.java - 数据库定义:
data/db/HealthDatabase.java