利用“健康数据共享”开发锻炼体验

本指南与 健康数据共享 1.1.0 或更高版本兼容。

如果您想在应用中打造锻炼体验,可以使用 Health Connect 执行以下操作:

  • 写入锻炼时段
  • 写入锻炼路线
  • 写入锻炼指标,例如心率、速度和距离
  • 从其他应用读取锻炼数据

本指南介绍了如何构建这些锻炼功能,涵盖数据类型、后台执行、权限、推荐的工作流程和最佳实践。

概览:构建全面的锻炼跟踪器

您可以按照以下核心步骤,使用健康数据共享构建全面的锻炼跟踪体验:

  • 根据健康权限正确实现权限。
  • 使用 ExerciseSessionRecord 录制会话。
  • 在练习期间持续写入锻炼数据。
  • 妥善管理后台执行,以验证持续数据捕获。
  • 读取锻炼后总结和分析所需的练习数据。

此工作流可实现与其他“健康数据共享”应用的互操作性,并验证用户控制的数据访问权限。

准备工作

在实现锻炼功能之前,请先完成以下准备工作:

主要概念

“健康数据共享”使用一些核心组件来表示锻炼数据。ExerciseSessionRecord 充当锻炼的中心记录,其中包含开始或结束时间以及锻炼类型等详细信息。在会话期间,可以记录各种数据类型,例如 HeartRateRecord 或 SpeedRecord。对于户外活动,ExerciseRoute 会存储 GPS 数据,这些数据会与相应的练习相关联。

锻炼时段

ExerciseSessionRecord 是锻炼数据的中心记录,代表单次锻炼。每条记录存储:

  • startTime
  • endTime
  • exerciseType
  • 可选会话元数据(标题、备注)

ExerciseSessionRecord 还可以包含锻炼路线、圈数和分段作为其数据的一部分。此外,其他数据类型(例如 HeartRateRecord 或 SpeedRecord)可以在会话期间记录并与之关联。

关联的数据类型

与锻炼时段相关的数据由各个记录类型表示。主要有以下几类:

如需查看数据类型的完整列表,请参阅健康数据共享数据类型。

锻炼路线

您可以使用 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_RATE
  • android.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>

实现锻炼时段

本部分介绍了记录锻炼数据的推荐工作流程。

启动会话

如需创建新的锻炼项目,请执行以下操作:

  1. 生成唯一的会话 ID:验证此 ID 是否稳定。如果应用进程被终止并重启,您必须能够继续使用同一 ID,以防止会话碎片化。
  2. 设置 metadata.clientRecordId,以防止在同步重试期间出现重复。
  3. 撰写 ExerciseSessionRecord:添加开始时间。
  4. 开始收集数据类型和 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 流程说明应用为何需要访问健康数据,例如:“为了监测您的血压趋势并提供数据洞见。”
  • 支持暂停和恢复:验证您的应用是否能正确处理暂停。当用户暂停时,停止收集路线点和数据类型,以确保平均配速和时长保持准确。
  • 测试长时间运行的会话:监控持续数小时的会话期间的电池消耗情况,以验证批处理间隔和传感器使用情况不会耗尽设备电量。
  • 使时间戳与传感器速率保持一致:使记录时间戳与传感器的实际频率保持一致,以保持数据的高保真度。

测试

为验证数据正确性并确保优质的用户体验,请遵循以下测试策略,并参阅官方测试主要用例文档。

验证工具

质量核对清单

典型架构

锻炼实现通常包括:

组件 管理
会话控制器 会话状态
计时器
批处理逻辑
数据类型控制器
位置采样
代码库层(封装健康数据共享操作): 插入锻炼
插入数据类型
插入路线点
读取锻炼摘要
界面层(显示): 时长
实时数据类型
地图预览
分段计算
实时 GPS 轨迹

问题排查

问题 可能的原因 分辨率
路线未与会话关联 会话 ID 或时间范围不匹配。 验证 ExerciseRoute 的写入时间范围是否完全在 ExerciseSessionRecord 持续时间内。如果您稍后要引用该会话,请验证您使用的是否是一致的 ID。请参阅记录锻炼路线。
缺少数据类型(例如心率) 缺少写入权限或时间过滤条件不正确。 检查您是否已请求特定数据类型的权限,以及用户是否已授予该权限。验证您的 ReadRecordsRequest 是否使用了与会话匹配的 TimeRangeFilter。请参阅权限。
会话写入失败 时间戳重叠。 健康数据共享 可能会拒绝与同一应用中的现有数据重叠的记录。请验证新练习的 startTime 是否在上一练习的 endTime 之后。
未记录 GPS 数据 前台服务已被终止或处于非活跃状态。 如需在屏幕关闭时收集数据,您必须使用具有 foregroundServiceType="health" 或位置属性的前台服务。
出现重复记录 缺少 clientRecordId。 在每条记录的 Metadata 中分配一个唯一的 clientRecordId。这样一来,如果在同步重试期间同一数据被写入两次,健康数据共享 就可以执行去重操作。查看最佳实践。

常见调试步骤

检查权限状态。 在尝试执行读取或写入操作之前,请务必先调用 getPermissionStatus()。用户可以随时在系统设置中撤消权限。
验证执行模式。 如果您的应用未在后台收集数据,请验证您是否已在 AndroidManifest.xml 文件中声明正确的权限,以及用户是否未将应用置于“电池受限”模式。