本指南与 健康数据共享 1.1.0 或更高版本兼容。
如果您想在应用中打造锻炼体验,可以使用 Health Connect 执行以下操作:
- 写入锻炼时段
- 写入锻炼路线
- 写入锻炼指标,例如心率、速度和距离
- 从其他应用读取锻炼数据
本指南介绍了如何构建这些锻炼功能,涵盖数据类型、后台执行、权限、推荐的工作流程和最佳实践。
概览:构建全面的锻炼跟踪器
您可以按照以下核心步骤,使用健康数据共享构建全面的锻炼跟踪体验:
- 根据健康权限正确实现权限。
- 使用
ExerciseSessionRecord录制会话。 - 在练习期间持续写入锻炼数据。
- 妥善管理后台执行,以验证持续数据捕获。
- 读取锻炼后总结和分析所需的练习数据。
此工作流可实现与其他“健康数据共享”应用的互操作性,并验证用户控制的数据访问权限。
准备工作
在实现锻炼功能之前,请先完成以下准备工作:
- 使用适当的依赖项集成健康数据共享。
- 创建
HealthConnectClient实例。 - 验证您的应用是否实现了基于健康权限的运行时权限流程。
- 如果您的工作流使用 GPS,请设置位置信息权限和前台服务。
主要概念
“健康数据共享”使用一些核心组件来表示锻炼数据。ExerciseSessionRecord 充当锻炼的中心记录,其中包含开始或结束时间以及锻炼类型等详细信息。在会话期间,可以记录各种数据类型,例如 HeartRateRecord 或 SpeedRecord。对于户外活动,ExerciseRoute 会存储 GPS 数据,这些数据会与相应的练习相关联。
锻炼时段
ExerciseSessionRecord 是锻炼数据的中心记录,代表单次锻炼。每条记录存储:
startTimeendTimeexerciseType- 可选会话元数据(标题、备注)
ExerciseSessionRecord 还可以包含锻炼路线、圈数和分段作为其数据的一部分。此外,其他数据类型(例如 HeartRateRecord 或 SpeedRecord)可以在会话期间记录并与之关联。
关联的数据类型
与锻炼时段相关的数据由各个记录类型表示。主要有以下几类:
HeartRateRecord:表示一系列心率测量值。SpeedRecord:表示一系列速度测量值。DistanceRecord:表示两次读数之间经过的距离。TotalCaloriesBurnedRecord:表示两次读数之间消耗的总卡路里数。ElevationGainedRecord:表示两次读数之间的爬升高度。StepsCadenceRecord:表示两次读取之间的步频。PowerRecord:表示两次读数之间的功率输出,常见于骑车等活动。
如需查看数据类型的完整列表,请参阅健康数据共享数据类型。
锻炼路线
您可以使用 ExerciseRoute 将路线与户外锻炼相关联。路线由依序排列的 ExerciseRoute.Location 对象组成,每个对象都包含:
- 纬度和经度
- 可选海拔高度
- 可选方位
- 准确性信息
- 时间戳
关联会话路由
ExerciseRoute 包含锻炼时段的连续位置数据。在健康数据共享中,它不会被视为独立记录。而是在插入或更新 ExerciseSessionRecord 时提供 ExerciseRoute 数据。
开发注意事项
锻炼跟踪应用通常需要长时间运行,并且经常在屏幕关闭时在后台运行。在构建锻炼功能时,请务必考虑如何管理后台执行,并请求锻炼数据所需的权限。
后台执行
锻炼应用通常在屏幕关闭的情况下运行。在这种状态下,您应使用:
- 用于位置信息和传感器采样的前台服务
WorkManager,用于延迟写入或同步- 常规记录写入的批处理策略
在所有写入操作中保持会话 ID 一致,以确保连续性。
权限
您的应用必须先请求相关健康数据共享权限,然后才能读取或写入锻炼数据。锻炼的常见权限包括锻炼课程、锻炼路线以及心率或速度等指标。您可以执行以下操作:
- 锻炼时段:
ExerciseSessionRecord的读取和写入权限。 - 锻炼路线:
ExerciseRoute的读取和写入权限。 - 心率:
HeartRateRecord的读取和写入权限。 - 速度:对
SpeedRecord的读取和写入权限。 - 距离:对
DistanceRecord的读写权限。 - 卡路里:
TotalCaloriesBurnedRecord的读取和写入权限。 - 爬升高度:
ElevationGainedRecord的读取和写入权限。 - 步频:
StepsCadenceRecord的读取和写入权限。 - 电源:对
PowerRecord的读取和写入权限。 - 步数:
StepsRecord的读写权限。
以下示例展示了如何为包含路线、心率、距离、卡路里、速度和步数数据的锻炼时段请求多项权限:
创建客户端实例后,您的应用需要向用户请求权限。必须允许用户随时授予或拒绝权限。 为此,请为所需的数据类型创建一个权限集。 先确保此集中的权限已在 Android 清单中声明。
val permissions = setOf( HealthPermission.getReadPermission(ExerciseSessionRecord::class), HealthPermission.getWritePermission(ExerciseSessionRecord::class), HealthPermission.getReadPermission(HeartRateRecord::class), HealthPermission.getWritePermission(HeartRateRecord::class), HealthPermission.getReadPermission(SpeedRecord::class), HealthPermission.getWritePermission(SpeedRecord::class), HealthPermission.getReadPermission(DistanceRecord::class), HealthPermission.getWritePermission(DistanceRecord::class), HealthPermission.getReadPermission(TotalCaloriesBurnedRecord::class), HealthPermission.getWritePermission(TotalCaloriesBurnedRecord::class), HealthPermission.getReadPermission(StepsRecord::class), HealthPermission.getWritePermission(StepsRecord::class) )
getGrantedPermissions 查看您的应用是否已获得所需权限。如不具备,请使用 createRequestPermissionResultContract 请求这些权限。系统会显示“健康数据共享”权限界面。
val permissions = setOf( HealthPermission.getReadPermission(StepsRecord::class), HealthPermission.getWritePermission(StepsRecord::class), HealthPermission.getReadPermission(HeartRateRecord::class), HealthPermission.getWritePermission(HeartRateRecord::class) ) val requestPermissionsLauncher = rememberLauncherForActivityResult( contract = PermissionController.createRequestPermissionResultContract() ) { grantedPermissions -> if (grantedPermissions.containsAll(permissions)) { coroutineScope.launch { snackbarHostState.showSnackbar("Permissions granted!") } } else { coroutineScope.launch { snackbarHostState.showSnackbar("Permissions denied.") } } }
如需请求权限,请调用 checkPermissionsAndRun 函数:
if (!granted.containsAll(permissions)) { // Check if required permissions are not granted, and return return emptySet() } // Permissions already granted; proceed with inserting or reading data
如果您只需要请求对单一数据类型(例如心率)的权限,请仅在权限集中添加该数据类型:
对心率的访问受以下权限保护:
android.permission.health.READ_HEART_RATEandroid.permission.health.WRITE_HEART_RATE
如要向应用添加心率功能,请先请求 HeartRateRecord 数据类型的权限。
您需要声明以下权限才能写入心率:
<application>
<uses-permission
android:name="android.permission.health.WRITE_HEART_RATE" />
...
</application>
如需读取心率,您需要请求以下权限:
<application>
<uses-permission
android:name="android.permission.health.READ_HEART_RATE" />
...
</application>
实现锻炼时段
本部分介绍了记录锻炼数据的推荐工作流程。
启动会话
如需创建新的锻炼项目,请执行以下操作:
- 生成唯一的会话 ID:验证此 ID 是否稳定。如果应用进程被终止并重启,您必须能够继续使用同一 ID,以防止会话碎片化。
- 设置
metadata.clientRecordId,以防止在同步重试期间出现重复。 - 撰写
ExerciseSessionRecord:添加开始时间。 - 开始收集数据类型和 GPS 数据:仅在会话记录成功初始化后才开始收集。
示例:
val sessionClientId = UUID.randomUUID().toString() val zoneOffset = ZoneOffset.systemDefault().rules.getOffset(startTime) val session = ExerciseSessionRecord( startTime = startTime, startZoneOffset = zoneOffset, endTime = startTime.plusSeconds(3600), endZoneOffset = zoneOffset, exerciseType = ExerciseSessionRecord.EXERCISE_TYPE_RUNNING, metadata = Metadata.activelyRecorded(clientRecordId = sessionClientId, device = Device(type = Device.TYPE_PHONE)), ) healthConnectClient.insertRecords(listOf(session))
记录锻炼路线
如需详细了解读取指南,请参阅读取原始数据。
记录锻炼路线时,您应批量处理数据。这意味着,您无需在每个 GPS 点发生时单独保存,而是收集一组点,并在一次调用中同时保存所有点。
这一点很重要,因为您的应用每次在健康数据共享中读取或写入数据时,都会消耗少量电池电量和处理能力。
以下代码展示了如何批量记录:
// 1. Create a list to hold your route locations
val routeLocations = mutableListOf<ExerciseRoute.Location>()
// 2. Add points to your list as the exercise happens
routeLocations.add(
ExerciseRoute.Location(
time = Instant.now(),
latitude = 37.7749,
longitude = -122.4194
)
)
// ... keep adding points over a period of time ...
// 3. Save the whole list at once (Batching)
val session = ExerciseSessionRecord(
startTime = startTime,
endTime = endTime,
exerciseType = ExerciseSessionRecord.EXERCISE_TYPE_RUNNING,
// We pass the whole list here
exerciseRoute = ExerciseRoute(routeLocations)
)
healthConnectClient.insertRecords(listOf(session))
结束会话
停止数据收集后:
- 更新记录:您的应用使用
endTime更新ExerciseSessionRecord。 - 最终确定数据:可选择计算摘要值(例如总距离或平均配速),并将其写入为额外记录。
val finishedSession = session.copy(endTime = Instant.now())
healthConnectClient.updateRecords(listOf(finishedSession))
读取锻炼数据
应用可以读取锻炼时段及其关联的数据,以汇总活动、提供健康数据分析或与外部服务器同步数据。例如,您可以读取 ExerciseSessionRecord,然后查询在同一时间间隔内发生的 HeartRateRecord 或 DistanceRecord。
如果您需要将锻炼数据与后端服务器同步,或使应用的数据存储区与健康数据共享保持同步,请使用 ChangeLogs。这样,您就可以检索自特定时间点以来插入、更新或删除的记录列表,这比手动跟踪更改或重复读取所有数据更高效。如需了解详情,请参阅与“健康数据共享”同步数据。
读取会话
如需读取锻炼时段,请使用类型为 ExerciseSessionRecord 的 ReadRecordsRequest。您通常会按特定时间范围过滤这些数据。
suspend fun readExerciseSessions(
healthConnectClient: HealthConnectClient,
startTime: Instant,
endTime: Instant
) {
val response = healthConnectClient.readRecords(
ReadRecordsRequest(
recordType = ExerciseSessionRecord::class,
timeRangeFilter = TimeRangeFilter.between(startTime, endTime)
)
)
for (exerciseRecord in response.records) {
// Process each session
val exerciseType = exerciseRecord.exerciseType
val notes = exerciseRecord.notes
}
}
读取路线数据
虽然 ExerciseRoute 数据是作为锻炼时段的一部分写入的,但必须单独读取。使用 getExerciseRoute() 方法和会话 ID 读取其路线数据:
suspend fun readExerciseRoute(
healthConnectClient: HealthConnectClient,
exerciseSessionRecord: ExerciseSessionRecord
) {
// Check if the session has a route
val route = healthConnectClient.getExerciseRoute(
exerciseSessionRecordId = exerciseSessionRecord.metadata.id
)
when (route) {
is ExerciseRouteResponse.Success -> {
val locations = route.exerciseRoute.locations
for (location in locations) {
// Use latitude, longitude, and altitude
}
}
is ExerciseRouteResponse.NoData -> {
// Handle case where no route exists
}
is ExerciseRouteResponse.ConsentRequired -> {
// Handle case where permissions are missing
}
}
}
读取数据类型
如需读取在某个时段内发生的特定精细数据(例如心率),请使用该时段的 startTime 和 endTime 来过滤针对该数据类型的请求。
suspend fun readHeartRateData(
healthConnectClient: HealthConnectClient,
exerciseSession: ExerciseSessionRecord
) {
val response = healthConnectClient.readRecords(
ReadRecordsRequest(
recordType = HeartRateRecord::class,
timeRangeFilter = TimeRangeFilter.between(
exerciseSession.startTime,
exerciseSession.endTime
)
)
)
for (heartRateRecord in response.records) {
for (sample in heartRateRecord.samples) {
val bpm = sample.beatsPerMinute
}
}
}
最佳做法
请遵循以下准则,以提高数据可靠性和用户体验:
- 在主动追踪期间频繁写入:对于主动追踪,请在数据可用时或以最长 15 分钟的间隔写入数据。
- 使用 WorkManager 进行后台同步:使用
WorkManager进行延迟写入。建议将间隔时间设为 15 分钟,以便在实时数据和电池效率之间取得平衡。 - 批量写入请求:不要单独写入每个传感器事件。分块处理请求。健康数据共享每个写入请求最多可处理 1,000 条记录。
- 确保会话 ID 稳定且唯一:为会话使用一致的标识符。如果某个练习被修改或更新,使用相同的 ID 可防止其被视为新的独立练习。
- 对数据类型和路线点使用批处理:为了减少输入/输出开销并延长电池续航时间,请将数据点分组到单个
insertRecords调用中,而不是单独写入每个点。 - 避免写入重复数据:使用客户端 ID:创建记录时,请设置
metadata.clientRecordId。“健康数据共享”会使用此 ID 来识别唯一记录。如果您尝试写入一条clientRecordId已存在的记录,健康数据共享 会忽略重复记录或更新现有记录,而不是创建新记录。设置metadata.clientRecordId是防止在同步重试或应用重新安装期间出现重复项的最有效方法。val record = StepsRecord( count = 100, startTime = startTime, endTime = endTime, startZoneOffset = ZoneOffset.UTC, endZoneOffset = ZoneOffset.UTC, metadata = Metadata( // Use a unique ID from your own database clientRecordId = "daily_steps_2023_10_27_user_123" ) )
- 检查现有数据:在同步之前,查询相应时间范围,查看是否已存在来自您应用的记录。
- 验证 GPS 准确性:在写入
ExerciseRoute之前,滤除低准确性的 GPS 样本(例如,水平准确性半径较大的点),以确保地图看起来干净、专业。 - 确保时间戳不重叠:验证新会话是否在上一个会话结束之前开始。重叠的锻炼时段可能会导致健身信息中心和摘要计算出现冲突。
- 提供明确的权限请求理由:使用
Permission.createIntent流程说明应用为何需要访问健康数据,例如:“为了监测您的血压趋势并提供数据洞见。” - 支持暂停和恢复:验证您的应用是否能正确处理暂停。当用户暂停时,停止收集路线点和数据类型,以确保平均配速和时长保持准确。
- 测试长时间运行的会话:监控持续数小时的会话期间的电池消耗情况,以验证批处理间隔和传感器使用情况不会耗尽设备电量。
- 使时间戳与传感器速率保持一致:使记录时间戳与传感器的实际频率保持一致,以保持数据的高保真度。
测试
为验证数据正确性并确保优质的用户体验,请遵循以下测试策略,并参阅官方测试主要用例文档。
验证工具
- 健康数据共享 Toolbox:使用此配套应用可手动检查记录、删除测试数据以及模拟对数据库的更改。这是验证记录是否正确存储的最佳方式。
- 使用
FakeHealthConnectClient进行单元测试:使用测试库验证应用如何处理边缘情况,例如权限撤消或 API 异常,而无需实体设备。
质量核对清单
典型架构
锻炼实现通常包括:
| 组件 | 管理 |
|---|---|
| 会话控制器 | 会话状态 计时器 批处理逻辑 数据类型控制器 位置采样 |
| 代码库层(封装健康数据共享操作): | 插入锻炼 插入数据类型 插入路线点 读取锻炼摘要 |
| 界面层(显示): | 时长 实时数据类型 地图预览 分段计算 实时 GPS 轨迹 |
问题排查
| 问题 | 可能的原因 | 分辨率 |
|---|---|---|
| 路线未与会话关联 | 会话 ID 或时间范围不匹配。 | 验证 ExerciseRoute 的写入时间范围是否完全在 ExerciseSessionRecord 持续时间内。如果您稍后要引用该会话,请验证您使用的是否是一致的 ID。请参阅记录锻炼路线。 |
| 缺少数据类型(例如心率) | 缺少写入权限或时间过滤条件不正确。 | 检查您是否已请求特定数据类型的权限,以及用户是否已授予该权限。验证您的 ReadRecordsRequest 是否使用了与会话匹配的 TimeRangeFilter。请参阅权限。 |
| 会话写入失败 | 时间戳重叠。 | 健康数据共享 可能会拒绝与同一应用中的现有数据重叠的记录。请验证新练习的 startTime 是否在上一练习的 endTime 之后。 |
| 未记录 GPS 数据 | 前台服务已被终止或处于非活跃状态。 | 如需在屏幕关闭时收集数据,您必须使用具有 foregroundServiceType="health" 或位置属性的前台服务。 |
| 出现重复记录 | 缺少 clientRecordId。 |
在每条记录的 Metadata 中分配一个唯一的 clientRecordId。这样一来,如果在同步重试期间同一数据被写入两次,健康数据共享 就可以执行去重操作。查看最佳实践。 |
常见调试步骤
| 检查权限状态。 | 在尝试执行读取或写入操作之前,请务必先调用 getPermissionStatus()。用户可以随时在系统设置中撤消权限。 |
| 验证执行模式。 | 如果您的应用未在后台收集数据,请验证您是否已在 AndroidManifest.xml 文件中声明正确的权限,以及用户是否未将应用置于“电池受限”模式。 |