使用 ExerciseClient 记录运动

健康服务通过 ExerciseClient 为锻炼应用提供一流的支持。借助 ExerciseClient,您的应用可以在用户运动期间发挥控制作用,添加运动目标,并获取有关运动状态、运动事件或其他所需指标的更新信息。如需了解详情,请参阅健康服务支持的运动类型的完整列表。

请参阅 GitHub 上的运动示例

添加依赖项

如需添加健康服务的依赖项,您必须将 Google Maven 制品库添加到项目中。如需了解相关信息,请参阅 Google 的 Maven 制品库

然后在您的模块级 build.gradle 文件中,添加以下依赖项:

Groovy

dependencies {
    implementation "androidx.health:health-services-client:1.1.0-alpha02"
}

Kotlin

dependencies {
    implementation("androidx.health:health-services-client:1.1.0-alpha02")
}

应用结构

通过健康服务构建运动应用时,请使用以下应用结构:

为锻炼做准备时和在锻炼期间,activity 可能会因各种原因而停止。用户可能会切换到其他应用或返回到表盘。系统可能会在 activity 上方显示内容,或者屏幕可能会在闲置一段时间后关闭。请结合使用持续运行的 ForegroundServiceExerciseClient,确保整个锻炼正常进行。

通过使用 ForegroundService,您可以使用 Ongoing Activity API 在表盘上显示一个指示器,让用户能够快速返回锻炼中。

请务必在前台服务中正确请求位置数据。在清单文件中,指定 foregroundServiceType="location" 并指定适当的权限

为锻炼前 activity(包含 prepareExercise() 调用)和锻炼 activity 使用 AmbientLifecycleObserver。但如果锻炼期间使用的是氛围模式,请勿更新显示画面。这是因为设备屏幕处于氛围模式时,健康服务会批处理锻炼以节省电量,因此显示的可能不是最新信息。在锻炼期间,显示对用户有意义的数据,即显示最新信息或空白屏幕。

检查功能

在指标和运动目标方面,每个 ExerciseType 都会支持特定的数据类型。请在启动时检查这些功能,因为具体情况因设备而异。设备可能不支持某些运动类型,也可能不支持特定功能,例如自动暂停。此外,设备的功能也可能会随着时间的推移而发生变化,例如在软件更新后发生变化。

在应用启动时,查询设备功能,并存储和处理以下内容:

  • 平台支持的运动。
  • 每种运动支持的功能。
  • 每种运动支持的数据类型。
  • 每种数据类型对应的必需权限。

结合使用 ExerciseCapabilities.getExerciseTypeCapabilities() 与所需运动类型,了解您可以请求哪些类型的指标、可以配置哪些运动目标,以及该运动类型还可以使用哪些其他功能。具体可见以下示例:

val healthClient = HealthServices.getClient(this /*context*/)
val exerciseClient = healthClient.exerciseClient
lifecycleScope.launch {
    val capabilities = exerciseClient.getCapabilitiesAsync().await()
    if (ExerciseType.RUNNING in capabilities.supportedExerciseTypes) {
        runningCapabilities =
            capabilities.getExerciseTypeCapabilities(ExerciseType.RUNNING)
    }
}

在返回的 ExerciseTypeCapabilities 内,supportedDataTypes 会列出您可以请求哪些数据类型的数据。这一结果因设备而异,因此请注意不要请求不受支持的 DataType,否则您的请求可能会失败。

使用 supportedGoalssupportedMilestones 字段确定运动是否可以支持您想创建的运动目标。

如果您的应用允许用户使用自动暂停功能,您必须使用 supportsAutoPauseAndResume 检查设备是否支持此功能。ExerciseClient 会拒绝设备不支持的请求。

以下示例展示了如何检查对 HEART_RATE_BPM 数据类型、STEPS_TOTAL 目标功能和自动暂停功能的支持情况:

// Whether we can request heart rate metrics.
supportsHeartRate = DataType.HEART_RATE_BPM in runningCapabilities.supportedDataTypes

// Whether we can make a one-time goal for aggregate steps.
val stepGoals = runningCapabilities.supportedGoals[DataType.STEPS_TOTAL]
supportsStepGoals = 
    (stepGoals != null && ComparisonType.GREATER_THAN_OR_EQUAL in stepGoals)

// Whether auto-pause is supported.
val supportsAutoPause = runningCapabilities.supportsAutoPauseAndResume

注册运动状态更新

运动更新会传送给监听器。您的应用一次只能注册一个监听器。请在开始锻炼前设置监听器,如以下示例所示。监听器只会接收应用拥有的运动的更新信息。

val callback = object : ExerciseUpdateCallback {
    override fun onExerciseUpdateReceived(update: ExerciseUpdate) {
        val exerciseStateInfo = update.exerciseStateInfo
        val activeDuration = update.activeDurationCheckpoint
        val latestMetrics = update.latestMetrics
        val latestGoals = update.latestAchievedGoals
    }

    override fun onLapSummaryReceived(lapSummary: ExerciseLapSummary) {
        // For ExerciseTypes that support laps, this is called when a lap is marked.
    }

    override fun onAvailabilityChanged(
        dataType: DataType<*, *>,
        availability: Availability
    ) {
        // Called when the availability of a particular DataType changes.
        when {
            availability is LocationAvailability -> // Relates to Location/GPS.
            availability is DataTypeAvailability -> // Relates to another DataType.
        }
    }
}
exerciseClient.setUpdateCallback(callback)

管理运动生命周期

在设备上的所有应用中,健康服务一次支持最多一项运动。如果正在跟踪某项运动,但此时其他应用开始跟踪新的运动,前一项运动的跟踪便会终止。

在开始运动之前,请执行以下操作:

  • 检查是否已开始跟踪某项运动,并相应地作出反应。例如,在覆盖之前的运动并开始跟踪新运动之前,要求用户进行确认。

以下示例展示了如何使用 getCurrentExerciseInfoAsync 检查现有运动:

lifecycleScope.launch {
    val exerciseInfo = exerciseClient.getCurrentExerciseInfoAsync().await()
    when (exerciseInfo.exerciseTrackedStatus) {
        OTHER_APP_IN_PROGRESS -> // Warn user before continuing, will stop the existing workout.
        OWNED_EXERCISE_IN_PROGRESS -> // This app has an existing workout.
        NO_EXERCISE_IN_PROGRESS -> // Start a fresh workout.
    }
}

权限

使用 ExerciseClient 时,请确保您的应用请求和维护必要的权限。如果您的应用使用 LOCATION 数据,请确保您的应用也请求并维持适当的权限。

对于所有数据类型,在调用 prepareExercise()startExercise() 之前,都应执行以下操作:

  • AndroidManifest.xml 文件中为请求的数据类型指定适当的权限。
  • 验证用户是否已授予必要的权限。如需了解详情,请参阅请求应用权限。如果用户尚未授予必要的权限,健康服务会拒绝相关请求。

对于位置数据,还应额外执行以下步骤:

准备锻炼

某些传感器(例如 GPS 或心率)可能需要短时间的预热,或者用户可能需要在开始锻炼前查看其数据。利用可选的 prepareExerciseAsync() 方法,无需启动锻炼计时器,就能支持接收数据并让这些传感器预热。activeDuration 不受此准备时间的影响。

在调用 prepareExerciseAsync() 之前,请检查以下内容:

  • 务必查看平台级位置信息设置。用户可以在“设置”主菜单中控制此设置;该设置不同于应用级权限检查。

    如果该设置处于关闭状态,请通知用户已拒绝授予位置信息获取权限,并在应用需要位置信息时提示用户启用该权限。

  • 确认您的应用对身体传感器、运动状态识别和精确位置信息具有运行时权限。对于缺少的权限,请提示用户授予运行时权限并提供充分的上下文信息。如果用户未授予特定权限,请从 prepareExerciseAsync() 调用中移除与该权限关联的数据类型。如果身体传感器和位置信息权限均未授予,请勿调用 prepareExerciseAsync(),因为 prepare 调用专门用于在用户开始运动之前获取稳定的心率或 GPS 定位数据。应用仍可获取基于步数的距离、步速、速度以及不需要这些权限的其他指标。

为了确保 prepareExerciseAsync() 调用成功,请执行以下操作:

  • 针对包含 prepare 调用的锻炼前 activity 使用 AmbientLifecycleObserver
  • 从前台服务调用 prepareExerciseAsync()。如果它不在某项服务中并且与 activity 生命周期相关联,那么传感器准备可能会被不必要地终止。
  • 在用户离开锻炼前 activity 时,调用 endExercise() 关闭传感器并降低功耗。

以下示例展示了如何调用 prepareExerciseAsync()

val warmUpConfig = WarmUpConfig(
    ExerciseType.RUNNING,
    setOf(
        DataType.HEART_RATE_BPM,
        DataType.LOCATION
    )
)
// Only necessary to call prepareExerciseAsync if body sensor or location
//permissions are given
exerciseClient.prepareExerciseAsync(warmUpConfig).await()

// Data and availability updates are delivered to the registered listener.

当应用进入 PREPARING 状态后,系统将通过 onAvailabilityChanged()ExerciseUpdateCallback 中传送传感器可用性更新。此信息随后会提供给用户,以便用户决定是否开始锻炼。

开始锻炼

当您想开始某项运动时,可以创建一个 ExerciseConfig 来配置运动类型、您要接收哪些数据类型的指标,以及任何运动目标或里程碑。

运动目标由 DataType 和条件组成。运动目标是一次性的目标,在满足某个条件(例如,用户跑步达到一定距离)时触发。您还可以设置运动里程碑。系统可以多次触发运动里程碑,例如在用户每次跑步超过设定的距离时触发。

以下示例展示了如何为每种类型创建一个目标:

const val CALORIES_THRESHOLD = 250.0
const val DISTANCE_THRESHOLD = 1_000.0 // meters

suspend fun startExercise() {
    // Types for which we want to receive metrics.
    val dataTypes = setOf(
        DataType.HEART_RATE_BPM,
        DataType.CALORIES_TOTAL,
        DataType.DISTANCE
    )

    // Create a one-time goal.
    val calorieGoal = ExerciseGoal.createOneTimeGoal(
        DataTypeCondition(
            dataType = DataType.CALORIES_TOTAL,
            threshold = CALORIES_THRESHOLD,
            comparisonType = ComparisonType.GREATER_THAN_OR_EQUAL
        )
    )

    // Create a milestone goal. To make a milestone for every kilometer, set the initial
    // threshold to 1km and the period to 1km.
    val distanceGoal = ExerciseGoal.createMilestone(
        condition = DataTypeCondition(
            dataType = DataType.DISTANCE_TOTAL,
            threshold = DISTANCE_THRESHOLD,
            comparisonType = ComparisonType.GREATER_THAN_OR_EQUAL
        ),
        period = DISTANCE_THRESHOLD
    )

    val config = ExerciseConfig(
        exerciseType = ExerciseType.RUNNING,
        dataTypes = dataTypes,
        isAutoPauseAndResumeEnabled = false,
        isGpsEnabled = true,
        exerciseGoals = mutableListOf<ExerciseGoal<Double>>(calorieGoal, distanceGoal)
    )
    exerciseClient.startExerciseAsync(config).await()
}

您还可为所有运动标记圈数。健康服务提供了一个 ExerciseLapSummary,其中包含相应圈期间的汇总指标。

上面的示例展示了 isGpsEnabled 的用法,在请求位置数据时,其值必须为 true。不过,还可以使用 GPS 来帮助获取其他指标。如果 ExerciseConfig 将距离指定为 DataType,那么在默认情况下将使用步数来估算距离。而选择启用 GPS 后,就可以改为使用位置信息来估算距离。

暂停、恢复和结束锻炼

您可以使用 pauseExerciseAsync()endExerciseAsync() 等适当的方法暂停、恢复和结束锻炼。

使用 ExerciseUpdate 中的状态作为可信来源。系统不会在 pauseExerciseAsync() 调用返回时认为锻炼已暂停,而是在 ExerciseUpdate 消息中反映出暂停状态时认为锻炼已暂停。尤其是在涉及界面状态时,更是必须考虑这一点。如果用户按暂停按钮,请停用暂停按钮,并对健康服务调用 pauseExerciseAsync()。使用 ExerciseUpdate.exerciseStateInfo.state 等待健康服务达到“已暂停”状态,然后将按钮切换到“恢复”状态。这是因为传送健康服务状态更新所需的时间可能比按下按钮的时间略长,如果您将所有界面更改都与按下按钮相关联,界面可能会与健康服务状态不同步。

在以下情况下请务必注意这一点:

  • 已启用自动暂停功能:锻炼可能会在无用户互动的情况下暂停或开始。
  • 其他应用开始锻炼:系统可能会在无用户互动的情况下终止您的锻炼。

如果您的应用的锻炼被其他应用终止,您的应用必须能够妥善处理这种终止情况:

  • 保存部分完成锻炼状态,以免清除用户的进度。
  • 移除持续性活动图标,并向用户发送通知,告知他们锻炼已被其他应用结束。

此外,请处理运动进行期间权限被撤消的情况。这种情况会通过 isEnded 状态发送,此时的 ExerciseEndReasonAUTO_END_PERMISSION_LOST。处理这种情况的方式与终止情况类似:保存部分状态,移除持续性活动图标,并向用户发送相应通知。

以下示例展示了如何正确检查终止情况:

val callback = object : ExerciseUpdateCallback {
    override fun onExerciseUpdateReceived(update: ExerciseUpdate) {
        if (update.exerciseStateInfo.state.isEnded) {
            // Workout has either been ended by the user, or otherwise terminated
        }
        ...
    }
    ...
}

管理进行时长

在锻炼期间,应用可以显示锻炼的进行时长。应用、健康服务和设备 MCU(负责跟踪运动的低功耗处理器)都需要与同样的当前进行时长保持同步。为了便于对此进行管理,健康服务会发送 ActiveDurationCheckpoint 来提供定位点,应用可从定位点启动自己的计时器。

由于进行时长是从 MCU 发送的,可能需要一些时间才能到达应用,因此 ActiveDurationCheckpoint 包含两个属性:

  • activeDuration:运动已进行的时长
  • time:计算进行时长的时间

因此,在应用中,可以使用以下公式根据 ActiveDurationCheckpoint 计算的运动的进行时长:

(now() - checkpoint.time) + checkpoint.activeDuration

这种做法考虑到了从 MCU 上计算进行时长到计算结果到达应用之间的细微增量。这可用于在应用中植入精密计时器,并有助于确保应用的计时器与健康服务和 MCU 中的时间完全一致。

如果运动暂停,应用会等到计算出的时间超过界面当前显示的时间之后再重启界面中的计时器。其原因在于,暂停信号到达健康服务和 MCU 时会略有延迟。例如,如果应用在 t=10 秒时暂停,健康服务可能直到 t=10.2 秒才将 PAUSED 更新传送给应用。

处理来自 ExerciseClient 的数据

您的应用已注册的数据类型的指标在 ExerciseUpdate 消息中传送。

处理器仅在处于唤醒状态或已达到最大报告期(例如每 150 秒)时传送消息。请勿依赖 ExerciseUpdate 频率让具有 activeDuration 的精密计时器前进。如需查看有关如何实现独立的精密计时器的示例,请参阅 GitHub 上的运动示例

当用户开始锻炼时,ExerciseUpdate 消息可能会频繁传送,例如每秒传送一次。随着用户开始锻炼,屏幕可能会关闭。然后,健康服务可能会传送数据,采样也会以相同的频率进行,避免唤醒主处理器。当用户查看屏幕时,批处理过程中的所有数据就会立即传送到应用。

控制批处理过程中的数据传送频率

在某些情况下,您可能希望控制屏幕关闭时应用接收特定数据类型的频率。借助 BatchingMode 对象,您的应用可以替换默认的批处理行为,以更频繁地传送数据。

如要配置批处理过程中的数据传送频率,请完成以下步骤:

  1. 检查设备是否支持特定的 BatchingMode 定义:

    // Confirm BatchingMode support to control heart rate stream to phone.
    suspend fun supportsHrWorkoutCompanionMode(): Boolean {
        val capabilities = exerciseClient.getCapabilities()
        return BatchingMode.HEART_RATE_5_SECONDS in
                capabilities.supportedBatchingModeOverrides
    }
    
  2. 指定 ExerciseConfig 对象应使用特定的 BatchingMode,如以下代码段所示。

    val config = ExerciseConfig(
        exerciseType = ExerciseType.WORKOUT,
        dataTypes = setOf(
            DataType.HEART_RATE_BPM,
            DataType.TOTAL_CALORIES
        ),
        // ...
        batchingModeOverrides = setOf(BatchingMode.HEART_RATE_5_SECONDS)
    )
    
  3. (可选)您可以在锻炼期间动态配置 BatchingMode,而不是在整个锻炼过程中持续使用特定的批处理行为:

    val desiredModes = setOf(BatchingMode.HEART_RATE_5_SECONDS)
    exerciseClient.overrideBatchingModesForActiveExercise(desiredModes)
    
  4. 如要清除自定义 BatchingMode 并恢复使用默认行为,请将空集传入 exerciseClient.overrideBatchingModesForActiveExercise()

时间戳

每个数据点的时间点表示自设备启动以来的时长。如需将其转换为时间戳,请使用以下代码:

val bootInstant =
    Instant.ofEpochMilli(System.currentTimeMillis() - SystemClock.elapsedRealtime())

然后,可将此值与每个数据点的 getStartInstant()getEndInstant() 一起使用。

数据准确性

某些数据类型可能具有与每个数据点关联的准确性信息。我们使用 accuracy 属性表示此信息。

您可以分别针对 HEART_RATE_BPMLOCATION 数据类型填充 HrAccuracyLocationAccuracy 类。如果存在 accuracy 属性,请使用该属性来确定每个数据点的准确性对于您的应用而言是否足够。

存储和上传数据

请使用 Room 持久保存从健康服务传送的数据。数据上传会在运动结束时使用 Work Manager 等机制来进行。这样可以确保用于上传数据的网络调用被推迟到运动结束后,从而最大限度地减少运动期间的功耗并简化工作。

集成核对清单

在发布使用健康服务 ExerciseClient 的应用之前,请先查阅以下核对清单,确保您的用户体验避免一些常见问题。确认:

  • 每次运行时,您的应用都会检查运动类型的功能和设备的功能。这样,您就可以检测到特定设备或运动何时不支持您的应用所需的某种数据类型。
  • 您请求和维护必要的权限,并在清单文件中指定这些权限。在调用 prepareExerciseAsync() 之前,应用会确认已授予运行时权限。
  • 您的应用使用 getCurrentExerciseInfoAsync() 来处理以下情况
    • 已在跟踪某项运动,且您的应用替换了之前的运动。
    • 其他应用已终止您的锻炼。当用户重新打开应用时,就可能会发生这种情况,此时系统会显示一条消息,指出由于其他应用接管了运动,运动已停止。
  • 如果您使用的是 LOCATION 数据:
    • 在整个运动过程中(包括 prepare 调用),您的应用会维持包含相应 foregroundServiceTypeForegroundService
    • 使用 isProviderEnabled(LocationManager.GPS_PROVIDER) 检查设备是否启用了 GPS,必要时提示用户打开位置信息设置。
    • 对于要求以低延迟接收位置数据非常重要的用例,请考虑集成一体化位置信息提供程序 (FLP) 并将其数据用作初始位置修复。当健康服务有更稳定的位置信息时,请使用该位置信息,而不是 FLP。
  • 如果您的应用需要上传数据,用于上传数据的任何网络调用都会推迟到运动结束。否则,在整个运动过程中,您的应用会谨慎进行必要的网络调用。