훈련 계획

이 가이드는 Health Connect 버전 1.1.0-alpha11과 호환됩니다.

헬스 커넥트는 훈련 앱이 훈련 계획을 작성하고 운동 앱이 훈련 계획을 읽을 수 있도록 하는 계획된 운동 데이터 유형을 제공합니다. 녹음된 운동 (운동)은 사용자가 훈련 목표를 달성하는 데 도움이 되도록 맞춤형 실적 분석을 위해 다시 읽을 수 있습니다.

기능 사용 가능 여부

사용자의 기기가 헬스 커넥트에서 운동 계획을 지원하는지 확인하려면 클라이언트에서 FEATURE_PLANNED_EXERCISE의 사용 가능 여부를 확인합니다.

if (healthConnectClient
     .features
     .getFeatureStatus(
       HealthConnectFeatures.FEATURE_PLANNED_EXERCISE
     ) == HealthConnectFeatures.FEATURE_STATUS_AVAILABLE) {

  // Feature is available
} else {
  // Feature isn't available
}

자세한 내용은 기능 사용 가능 여부 확인하기를 참고하세요.

필수 권한

교육 계획에 대한 액세스는 다음 권한으로 보호됩니다.

  • android.permission.health.READ_PLANNED_EXERCISE
  • android.permission.health.WRITE_PLANNED_EXERCISE

앱의 Play Console과 앱 매니페스트에서 다음 권한을 선언합니다.

<application>
  <uses-permission
android:name="android.permission.health.READ_PLANNED_EXERCISE" />
  <uses-permission
android:name="android.permission.health.WRITE_PLANNED_EXERCISE" />
...
</application>

기기와 앱에서 사용할 모든 적절한 권한을 선언해야 할 책임은 개발자에게 있습니다. 또한 사용하기 전에 각 권한이 사용자에 의해 부여되었는지 확인해야 합니다.

트레이닝 계획은 운동 세션에 연결됩니다. 따라서 헬스 커넥트의 이 기능을 최대한 활용하려면 사용자가 트레이닝 계획과 관련된 각 레코드 유형을 사용할 수 있는 권한을 부여해야 합니다.

예를 들어 트레이닝 계획이 일련의 달리기 중에 사용자의 심박수를 측정하는 경우 운동 세션을 작성하고 나중에 평가할 결과를 읽으려면 개발자가 다음 권한을 선언하고 사용자가 권한을 부여해야 할 수 있습니다.

  • android.permission.health.READ_EXERCISE
  • android.permission.health.READ_EXERCISE_ROUTE
  • android.permission.health.READ_HEART_RATE
  • android.permission.health.WRITE_EXERCISE
  • android.permission.health.WRITE_EXERCISE_ROUTE
  • android.permission.health.WRITE_HEART_RATE

그러나 종종 운동 계획을 만들고 계획에 대한 성능을 평가하는 앱이 운동 계획을 사용하고 실제 운동 데이터를 쓰는 앱과 다릅니다. 앱 유형에 따라 일부 읽기 및 쓰기 권한만 필요할 수 있습니다. 예를 들어 각 앱 유형에 다음 권한만 필요할 수 있습니다.

훈련 계획 앱 운동 앱
WRITE_PLANNED_EXERCISE READ_PLANNED_EXERCISE
READ_EXERCISE WRITE_EXERCISE
READ_EXERCISE_ROUTE WRITE_EXERCISE_ROUTE
READ_HEART_RATE WRITE_HEART_RATE

계획된 운동 세션 레코드에 포함된 정보

  • 세션의 제목입니다.
  • 계획된 운동 블록 목록입니다.
  • 세션의 시작 및 종료 시간입니다.
  • 운동 유형입니다.
  • 활동에 대한 메모입니다.
  • 메타데이터
  • 완료된 운동 세션 ID: 이 계획된 운동 세션과 관련된 운동 세션이 완료된 후 자동으로 작성됩니다.

계획된 운동 블록 레코드에 포함된 정보

계획된 운동 블록에는 다양한 단계 그룹의 반복을 지원하기 위한 운동 단계 목록이 포함됩니다 (예: 팔 컬, 버피, 크런치의 시퀀스를 연속으로 5번 수행).

계획된 운동 걸음 수 기록에 포함된 정보

지원되는 집계

이 데이터 유형에는 지원되는 집계가 없습니다.

사용 예

사용자가 90분 달리기를 계획했다고 가정해 보겠습니다. 이 달리기는 호수를 3바퀴 돌며 심박수를 90~110bpm으로 유지하는 것이 목표입니다.

  1. 다음과 같은 계획된 운동 세션은 사용자가 트레이닝 계획 앱에서 정의합니다.
    1. 계획된 실행 시작 및 종료
    2. 운동 유형 (달리기)
    3. 랩 수 (반복)
    4. 심박수의 성능 타겟 (90~110bpm)
  2. 이 정보는 운동 블록과 단계로 그룹화되며 트레이닝 계획 앱에서 헬스 커넥트에 PlannedExerciseSessionRecord로 기록됩니다.
  3. 사용자가 계획된 세션을 실행합니다.
  4. 세션과 관련된 운동 데이터는 다음 중 하나로 기록됩니다.
    1. 세션 중에 웨어러블에서 예를 들어 심박수입니다. 이 데이터는 활동의 레코드 유형으로 헬스 커넥트에 기록됩니다. 이 경우 HeartRateRecord입니다.
    2. 세션 종료 후 사용자가 수동으로 예를 들어 실제 실행의 시작과 종료를 표시합니다. 이 데이터는 헬스 커넥트에 ExerciseSessionRecord로 작성됩니다.
  5. 나중에 트레이닝 계획 앱은 헬스 커넥트에서 데이터를 읽어 계획된 운동 세션에서 사용자가 설정한 타겟과 비교하여 실제 실적을 평가합니다.

운동 계획 및 목표 설정

사용자는 향후 운동을 계획하고 목표를 설정할 수 있습니다. 이를 헬스 커넥트에 계획된 운동 세션으로 작성합니다.

사용 예에 설명된 예시에서 사용자는 2일 후 90분 달리기를 계획합니다. 이 달리기는 호수를 3바퀴 돌며 심박수를 90~110bpm으로 유지하는 것이 목표입니다.

이와 같은 스니펫은 계획된 운동 세션을 헬스 커넥트에 기록하는 앱의 양식 핸들러에서 찾을 수 있습니다. 교육을 제공하는 서비스와 같은 통합의 처리 지점에서도 찾을 수 있습니다.

// Ensure the user has granted all necessary permissions for this task
val grantedPermissions =
    healthConnectClient.permissionController.getGrantedPermissions()
if (!grantedPermissions.contains(
      HealthPermission.getWritePermission(PlannedExerciseSessionRecord::class))) {
    // The user hasn't granted the app permission to write planned exercise session data.
    return
}

val plannedDuration = Duration.ofMinutes(90)
val plannedStartDate = LocalDate.now().plusDays(2)

val plannedExerciseSessionRecord = PlannedExerciseSessionRecord(
    startDate = plannedStartDate,
    duration = plannedDuration,
    exerciseType = ExerciseSessionRecord.EXERCISE_TYPE_RUNNING,
    blocks = listOf(
        PlannedExerciseBlock(
            repetitions = 1, steps = listOf(
                PlannedExerciseStep(
                    exerciseType = ExerciseSessionRecord.EXERCISE_TYPE_RUNNING,
                    exercisePhase = PlannedExerciseStep.EXERCISE_PHASE_ACTIVE,
                    completionGoal = ExerciseCompletionGoal.RepetitionsGoal(repetitions = 3),
                    performanceTargets = listOf(
                        ExercisePerformanceTarget.HeartRateTarget(
                            minHeartRate = 90.0, maxHeartRate = 110.0
                        )
                    )
                ),
            ), description = "Three laps around the lake"
        )
    ),
    title = "Run at lake",
    notes = null
)
val insertedPlannedExerciseSessions =
    healthConnectClient.insertRecords(listOf(plannedExerciseSessionRecord)).recordIdsList
val insertedPlannedExerciseSessionId = insertedPlannedExerciseSessions.first()

운동 및 활동 데이터 기록

2일 후 사용자가 실제 운동 세션을 기록합니다. 이를 헬스 커넥트에 운동 세션으로 작성합니다.

이 예에서 사용자의 세션 시간이 계획된 시간과 정확하게 일치했습니다.

다음 스니펫은 헬스 커넥트에 운동 세션을 기록하는 앱의 양식 핸들러에서 찾을 수 있습니다. 운동 세션을 감지하고 기록할 수 있는 웨어러블의 데이터 처리 및 내보내기 핸들러에서도 찾을 수 있습니다.

여기서 insertedPlannedExerciseSessionId는 이전 예에서 재사용됩니다. 실제 앱에서는 사용자가 기존 세션 목록에서 계획된 운동 세션을 선택하여 ID가 결정됩니다.

// Ensure the user has granted all necessary permissions for this task
val grantedPermissions =
    healthConnectClient.permissionController.getGrantedPermissions()
if (!grantedPermissions.contains(
      HealthPermission.getWritePermission(ExerciseSessionRecord::class))) {
    // The user doesn't granted the app permission to write exercise session data.
    return
}

val sessionDuration = Duration.ofMinutes(90)
val sessionEndTime = Instant.now()
val sessionStartTime = sessionEndTime.minus(sessionDuration)

val exerciseSessionRecord = ExerciseSessionRecord(
    startTime = sessionStartTime,
    startZoneOffset = ZoneOffset.UTC,
    endTime = sessionEndTime,
    endZoneOffset = ZoneOffset.UTC,
    exerciseType = ExerciseSessionRecord.EXERCISE_TYPE_RUNNING,
    segments = listOf(
        ExerciseSegment(
            startTime = sessionStartTime,
            endTime = sessionEndTime,
            repetitions = 3,
            segmentType = ExerciseSegment.EXERCISE_SEGMENT_TYPE_RUNNING
        )
    ),
    title = "Run at lake",
    plannedExerciseSessionId = insertedPlannedExerciseSessionId,
)
val insertedExerciseSessions =
    healthConnectClient.insertRecords(listOf(exerciseSessionRecord))

또한 웨어러블은 달리기 중 심박수를 기록합니다. 다음 스니펫은 타겟 범위 내에서 레코드를 생성하는 데 사용할 수 있습니다.

실제 앱에서는 이 스니펫의 기본 부분이 수집 시 헬스 커넥트에 측정값을 작성하는 웨어러블의 메시지 핸들러에 있을 수 있습니다.

// Ensure the user has granted all necessary permissions for this task
val grantedPermissions =
    healthConnectClient.permissionController.getGrantedPermissions()
if (!grantedPermissions.contains(
      HealthPermission.getWritePermission(HeartRateRecord::class))) {
    // The user doesn't granted the app permission to write heart rate record data.
    return
}

val samples = mutableListOf<HeartRateRecord.Sample>()
var currentTime = sessionStartTime
while (currentTime.isBefore(sessionEndTime)) {
    val bpm = Random.nextInt(21) + 90
    val heartRateRecord = HeartRateRecord.Sample(
        time = currentTime,
        beatsPerMinute = bpm.toLong(),
    )
    samples.add(heartRateRecord)
    currentTime = currentTime.plusSeconds(180)
}

val heartRateRecord = HeartRateRecord(
    startTime = sessionStartTime,
    startZoneOffset = ZoneOffset.UTC,
    endTime = sessionEndTime,
    endZoneOffset = ZoneOffset.UTC,
    samples = samples,
)
val insertedHeartRateRecords = healthConnectClient.insertRecords(listOf(heartRateRecord))

실적 타겟 평가

사용자가 운동한 다음 날에는 기록된 운동을 검색하고, 계획된 운동 타겟을 확인하고, 추가 데이터 유형을 평가하여 설정된 타겟이 달성되었는지 확인할 수 있습니다.

이와 같은 스니펫은 실적 타겟을 평가하는 주기적 작업이나 연습 목록을 로드하고 앱에서 실적 타겟에 관한 알림을 표시할 때 발견될 수 있습니다.

// Ensure the user has granted all necessary permissions for this task
val grantedPermissions =
     healthConnectClient.permissionController.getGrantedPermissions()
if (!grantedPermissions.containsAll(
        listOf(
            HealthPermission.getReadPermission(ExerciseSessionRecord::class),
            HealthPermission.getReadPermission(PlannedExerciseSessionRecord::class),
            HealthPermission.getReadPermission(HeartRateRecord::class)
        )
    )
) {
    // The user doesn't granted the app permission to read exercise session record data.
    return
}

val searchDuration = Duration.ofDays(1)
val searchEndTime = Instant.now()
val searchStartTime = searchEndTime.minus(searchDuration)

val response = healthConnectClient.readRecords(
    ReadRecordsRequest<ExerciseSessionRecord>(
        timeRangeFilter = TimeRangeFilter.between(searchStartTime, searchEndTime)
    )
)
for (exerciseRecord in response.records) {
    val plannedExerciseRecordId = exerciseRecord.plannedExerciseSessionId
    val plannedExerciseRecord =
        if (plannedExerciseRecordId == null) null else healthConnectClient.readRecord(
            PlannedExerciseSessionRecord::class, plannedExerciseRecordId
        ).record
    if (plannedExerciseRecord != null) {
        val aggregateRequest = AggregateRequest(
            metrics = setOf(HeartRateRecord.BPM_AVG),
            timeRangeFilter = TimeRangeFilter.between(
                exerciseRecord.startTime, exerciseRecord.endTime
            ),
        )
        val aggregationResult = healthConnectClient.aggregate(aggregateRequest)

        val maxBpm = aggregationResult[HeartRateRecord.BPM_MAX]
        val minBpm = aggregationResult[HeartRateRecord.BPM_MIN]
        if (maxBpm != null && minBpm != null) {
            plannedExerciseRecord.blocks.forEach { block ->
                block.steps.forEach { step ->
                    step.performanceTargets.forEach { target ->
                        when (target) {
                            is ExercisePerformanceTarget.HeartRateTarget -> {
                                val minTarget = target.minHeartRate
                                val maxTarget = target.maxHeartRate
                                if(
                                    minBpm >= minTarget && maxBpm <= maxTarget
                                ) {
                                  // Success!
                                }
                            }
                            // Handle more target types
                            }
                        }
                    }
                }
            }
        }
    }
}