如果您想在应用中打造锻炼体验,可以使用健康数据共享来执行以下操作:
- 写入锻炼时段
- 写入锻炼路线
- 写入心率、速度和距离等锻炼指标
- 读取其他应用的锻炼数据
本指南介绍了如何构建这些锻炼功能,涵盖了数据类型、后台执行、权限、推荐的工作流程和最佳实践。
概览:构建全面的锻炼追踪器
您可以按照以下核心步骤,使用健康数据共享打造全面的锻炼跟踪体验:
- 根据健康权限正确实现权限。
- 使用
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 清单中声明。
// Create a set of permissions for required data types
val PERMISSIONS =
setOf(
HealthPermission.getReadPermission(ExerciseSessionRecord::class),
HealthPermission.getWritePermission(ExerciseSessionRecord::class),
HealthPermission.getReadPermission(ExerciseRoute::class),
HealthPermission.getWritePermission(ExerciseRoute::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 请求这些权限。系统随即会显示“健康数据共享”权限界面。
// Create the permissions launcher
val requestPermissionActivityContract = PermissionController.createRequestPermissionResultContract()
val requestPermissions = registerForActivityResult(requestPermissionActivityContract) { granted ->
if (granted.containsAll(PERMISSIONS)) {
// Permissions successfully granted
} else {
// Lack of required permissions
}
}
suspend fun checkPermissionsAndRun(healthConnectClient: HealthConnectClient) {
val granted = healthConnectClient.permissionController.getGrantedPermissions()
if (granted.containsAll(PERMISSIONS)) {
// Permissions already granted; proceed with inserting or reading data
} else {
requestPermissions.launch(PERMISSIONS)
}
}
由于用户可以随时授予或撤消权限,因此您的应用需要在每次使用权限之前检查权限,并处理权限丢失的情况。
如需请求权限,请调用 checkPermissionsAndRun 函数:
if (!granted.containsAll(PERMISSIONS)) {
requestPermissions.launch(PERMISSIONS)
// Check if required permissions are not granted, and return
}
// 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 sessionId = UUID.randomUUID().toString()
val sessionClientId = UUID.randomUUID().toString()
val session = ExerciseSessionRecord(
id = sessionId,
exerciseType = ExerciseType.EXERCISE_TYPE_RUNNING,
startTime = Instant.now(),
endTime = null,
metadata = Metadata(clientRecordId = sessionClientId),
)
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进行延迟写入。建议将间隔时间设为 15 分钟,以在实时数据和电池效率之间取得平衡。 - 批处理:不要单独写入每个传感器事件。将请求分块。健康数据共享每次写入请求最多可处理 1,000 条记录。
- 保持会话 ID 的稳定性和唯一性:为会话使用一致的标识符。如果修改或更新了某次锻炼,使用同一 ID 可防止系统将其视为一次新的单独锻炼。
- 对数据类型和路线点使用批处理:为了减少输入/输出开销并节省电池电量,请将数据点分组到单个
insertRecords调用中,而不是单独写入每个点。 避免写入重复数据:使用客户端 ID 创建记录时,请设置
metadata.clientRecordId。健康数据共享使用此属性来标识唯一记录。如果您尝试写入具有已存在的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流程说明您的应用为何需要访问健康数据,例如“用于绘制跑步路线和计算卡路里消耗量”。支持暂停和恢复:验证应用是否能正确处理暂停。 当用户暂停时,停止收集路线点和数据类型,以便平均配速和时长保持准确。
测试长时间运行的会话:监控持续数小时的会话期间的电池消耗情况,以验证您的批处理间隔和传感器使用情况不会耗尽设备电量。
使时间戳与传感器速率保持一致:将记录时间戳与传感器的实际频率(例如,GPS 为 1Hz)相匹配,以保持数据的高保真度。
测试
如需验证数据正确性并确保提供优质的用户体验,请遵循以下测试策略,并参阅官方测试热门使用情形文档。
验证工具
- 健康数据共享工具箱:使用此配套应用可手动检查记录、删除测试数据和模拟数据库更改。这是验证记录是否正确存储的最佳方式。
- 使用
FakeHealthConnectClient进行单元测试:使用测试库验证应用如何处理边缘情况,例如权限撤消或 API 异常,而无需使用实体设备。
质量核对清单
典型架构
锻炼实现通常包括:
| 组件 | 管理 |
|---|---|
| 会话控制器 | 会话状态 计时器 批处理逻辑 数据类型控制器 位置抽样 |
| 代码库层(封装了健康数据共享操作): | 插入会话 插入数据类型 插入路线点 读取会话摘要 |
| 界面层(显示): | 时长 实时数据类型 地图预览 分段计算 实时 GPS 轨迹 |
问题排查
| 问题 | 可能的原因 | 分辨率 |
|---|---|---|
| 路线未与会话相关联 | 会话 ID 或时间范围不匹配。 | 验证 ExerciseRoute 是否写入了完全在 ExerciseSessionRecord 时长范围内的时段。如果您稍后要引用会话,请确保您使用的是一致的 ID。请参阅记录锻炼路线。 |
| 缺少数据类型(例如,心率) | 缺少写入权限或时间过滤条件不正确。 | 检查您是否已请求并获得用户授予的特定数据类型权限。验证您的 ReadRecordsRequest 是否使用了与会话匹配的 TimeRangeFilter。请参阅权限。 |
| 会话写入失败 | 时间戳重叠。 | 健康数据共享可能会拒绝与同一应用中的现有数据重叠的记录。请验证新会话的 startTime 是否在上一会话的 endTime 之后。 |
| 未记录任何 GPS 数据 | 前台服务被终止或处于非活动状态。 | 如需在屏幕关闭时收集数据,您必须使用具有 foregroundServiceType="health" 或位置属性的前台服务。 |
| 显示重复记录 | 缺少 clientRecordId |
在每条记录的 Metadata 中分配唯一的 clientRecordId。这样一来,如果同步重试期间写入了两次相同的数据,健康数据共享便可以执行去重操作。请参阅最佳实践。 |
常见调试步骤
- 检查权限状态:在尝试执行读取或写入操作之前,请务必先调用
getPermissionStatus()。用户可以随时在系统设置中撤消权限。 - 验证执行模式:如果您的应用未在后台收集数据,请验证您是否已在
AndroidManifest.xml中声明正确的权限,以及用户是否未将应用置于“电池用量限制”模式。