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

如果您想在应用中打造锻炼体验,可以使用健康数据共享来执行以下操作:

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

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

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

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

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

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

准备工作

在实现锻炼功能之前:

主要概念

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

锻炼时段

ExerciseSessionRecord 是锻炼数据的中央记录,表示一次锻炼会话。每条记录存储以下信息:

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

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

关联的数据类型

与锻炼会话关联的数据由各个记录类型表示。常见类型包括:

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

锻炼路线

您可以使用 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_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 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,然后查询在同一时间间隔内发生的 HeartRateRecordDistanceRecord

如果您需要将锻炼数据与后端服务器同步,或者让应用的数据存储区与健康数据共享保持同步,请使用 ChangeLogs。这样一来,您就可以检索自特定时间点以来插入、更新或删除的记录列表,这比手动跟踪更改或重复读取所有数据更高效。如需了解详情,请参阅通过“健康数据共享”同步数据

读取会话

如需读取锻炼会话,请使用类型为 ExerciseSessionRecordReadRecordsRequest。您通常会按特定时间范围过滤此数据。

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
        }
    }
}

读取数据类型

如需读取会话期间发生的特定精细数据(例如心率),请使用会话的 startTimeendTime 来过滤对相应数据类型的请求。

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)相匹配,以保持数据的高保真度。

测试

如需验证数据正确性并确保提供优质的用户体验,请遵循以下测试策略,并参阅官方测试热门使用情形文档。

验证工具

质量核对清单

典型架构

锻炼实现通常包括:

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

问题排查

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

常见调试步骤

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