
Код приложения доступен на GitHub!
**************
HealthKit — это замечательный фреймворк, который позволяет нам создавать приложения, способные получать доступ к данным, связанным со здоровьем и физической формой, а также делиться ими, сохраняя при этом конфиденциальность и контроль пользователя.
В этой статье я предлагаю вместе создать простое приложение для тренировок, которое позволит вам ознакомиться с основами этого удивительного фреймворка. Мы будем разрабатывать приложение для watchOS, так как именно эта платформа, на мой взгляд, наилучшим образом раскрывает потенциал HealthKit.
Сегодня мы сосредоточимся на следующих аспектах:
настройка фреймворка
запрос разрешений и управление ими
запуск, приостановка, возобновление и завершение тренировки, а также сохранение ее в хранилище HealthKit
мониторинг статистики во время тренировки
получение окончательных показателей по завершению тренировки
И это только начало!
Что же нас ждет в следующей части? Вы узнаете об этом в конце статьи!
Обзор
HealthKit — это мощный инструмент, который служит центральным хранилищем данных о здоровье и физической форме на iPhone и Apple Watch. С разрешения пользователя мы можем получить доступ к этому хранилищу, чтобы читать и делиться этими данными.
Поскольку разные приложения будут иметь доступ к одному и тому же хранилищу, нам необходимо обеспечить:
1. Конфиденциальность. Мы должны делиться только теми данными, которые можно использовать за пределами нашего приложения.
2. Возможность обработки любых изменений в данных хранилища, которые могут произойти вне нашего приложения.
Данные о состоянии здоровья могут быть конфиденциальными, поэтому нам необходимо получить разрешение пользователя на чтение и запись в хранилище HealthKit.
И именно этим мы и займемся дальше!
Подготовка
В этой части мы выполним несколько важных подготовительных шагов:
Добавление функционала HealthKit.
Настройка фонового режима для обработки тренировок.
Добавление описаний использования.
Добавление HealthKit
Прежде чем мы сможем использовать HealthKit, мы должны добавить функционал HealthKit в наше приложение.
Выберите Watch в качестве таргета!

В разделе Signing & Capabilities нажмите на + Capability.

Найдите HealthKit и добавьте его к выбранному таргету.

Мы можем оставить оба флажка пустыми, как показано на скриншоте ниже.

Да, нам НЕ нужна HealthKit Background Delivery.
Эта функция предназначена для выполнения длительных запросов, которые будут обновлять наше приложение всякий раз, когда система обнаружит изменения в хранилище HealthKit.
Настройка фонового режима
Для того же таргета добавьте функционал Background Modes и выберите опцию Workout processing.

Это позволит нашему приложению получать фоновые обновления статистических данных, таких как сожженные калории и частота сердечных сокращений, во время тренировки.
Добавление описаний использования
Поскольку мы будем как считывать данные о состоянии пользователя, так и сохранять их в хранилище HealthKit, нам необходимо добавить два новых ключа в Info.plist
нашего таргета!
NSHealthShareUsageDescription — чтобы объяснить, почему наше приложение запрашивает разрешение на чтение образцов из хранилища HealthKit.
NSHealthUpdateUsageDescription — для объяснения, зачем нам нужно разрешение на запись в хранилище HealthKit

Если ваше приложение будет получать доступ к медицинским данным пользователей, потребуется несколько больше настроек, поскольку они более чувствительны, чем другие данные. Более подробную информацию об этом вы можете найти в разделе Accessing a User»s Clinical Records.
WorkoutManager
Как всегда, мы начнем с класса менеджера. Я также поделюсь с вами примером того, как мы можем использовать этот менеджер.
Код
//
// WorkoutManager.swift
// ItsukiWorkoutApp
//
// Created by Itsuki on 2025/03/02.
//
import SwiftUI
import HealthKit
@MainActor
@Observable
class WorkoutManager: NSObject {
let supportedWorkoutTypes: Set<HKWorkoutActivityType> = [
.walking,
.running,
.cycling,
.swimming,
.swimBikeRun,
.highIntensityIntervalTraining,
]
private let typesToShare: Set<HKSampleType> = [HKQuantityType.workoutType()]
private let typesToRead: Set<HKObjectType> = [
HKQuantityType(.heartRate),
HKQuantityType(.activeEnergyBurned),
HKQuantityType(.stepCount),
HKQuantityType(.swimmingStrokeCount),
HKQuantityType(.walkingSpeed),
HKQuantityType(.cyclingSpeed),
HKQuantityType(.runningSpeed),
HKQuantityType(.distanceWalkingRunning),
HKQuantityType(.distanceCycling),
HKQuantityType(.distanceSwimming),
HKObjectType.activitySummaryType()
]
var error: WorkoutError? {
didSet {
if let error {
print("error: \(error.message)")
}
}
}
// после окончания сеанса
var showResult: Bool = false {
didSet {
if !showResult {
reset()
return
}
}
}
var workoutResult: HKWorkout? {
didSet {
if workoutResult != nil {
self.showResult = true
}
}
}
// во время сеанса
var sessionRunning: Bool = false
var workoutMetrics: WorkoutMetrics?
func getElapseTime(at date: Date) -> TimeInterval {
return self.builder?.elapsedTime(at: date) ?? 0
}
private var healthStore: HKHealthStore?
private var session: HKWorkoutSession?
private var builder: HKLiveWorkoutBuilder?
// для iOS: вместо этого используйте HKWorkoutBuilder
// var builder: HKWorkoutBuilder?
override init() {
super.init()
if HKHealthStore.isHealthDataAvailable() {
self.healthStore = HKHealthStore()
Task {
await self.requestAuthorization()
}
} else {
self.error = .unavailable
}
}
}
// ПОМЕТКА: - Код, связанный с разрешениями
extension WorkoutManager {
private func checkAvailability() async -> Bool {
if !HKHealthStore.isHealthDataAvailable() {
self.error = .unavailable
return false
}
do {
let status = try await healthStore?.statusForAuthorizationRequest(toShare: typesToShare, read: typesToRead)
if status == .unnecessary {
return true
}
if status == .shouldRequest {
await self.requestAuthorization()
return true
}
self.error = .permissionDenied
return false
} catch(let error) {
self.error = .requestPermissionError(error)
return false
}
}
private func requestAuthorization() async {
do {
try await healthStore?.requestAuthorization(toShare: typesToShare, read: typesToRead)
} catch (let error) {
self.error = .requestPermissionError(error)
}
}
}
// ПОМЕТКА: - Управление сеансом тренировки / билдером
extension WorkoutManager {
func startWorkout(with configuration: HKWorkoutConfiguration) async {
let result = await self.checkAvailability()
if !result {
return
}
guard let healthStore else {
self.error = .unavailable
return
}
if !self.supportedWorkoutTypes.contains(configuration.activityType) {
self.error = .workoutTypeNotSupported
return
}
do {
session = try HKWorkoutSession(healthStore: healthStore, configuration: configuration)
builder = session?.associatedWorkoutBuilder()
} catch(let error) {
self.error = .startWorkoutFailed(error)
return
}
builder?.dataSource = HKLiveWorkoutDataSource(healthStore: healthStore, workoutConfiguration: configuration)
// print("types to collect: ", builder?.dataSource?.typesToCollect as Any)
// чтобы можно было отслеживать дополнительные типы данных, например: respiratoryRate
// builder?.dataSource?.enableCollection(for: HKQuantityType(.respiratoryRate), predicate: nil)
// Для отслеживания обновлений сеанса и билдера
session?.delegate = self
builder?.delegate = self
// запуск активити
let date = Date()
session?.startActivity(with: date)
do {
try await builder?.beginCollection(at: date)
self.workoutMetrics = .init(workoutConfiguration: configuration)
} catch(let error) {
self.error = .startWorkoutFailed(error)
reset()
}
}
func resumeSession() {
session?.resume()
}
func pauseSession() {
session?.pause()
}
func endSession() {
session?.end()
}
private func reset() {
self.session = nil
self.builder = nil
self.workoutResult = nil
self.workoutMetrics = nil
}
}
// ПОМЕТКА: - остальные вспомогательные функции
extension WorkoutManager {
nonisolated private func setError(_ error: WorkoutError) {
DispatchQueue.main.async {
self.error = error
}
}
nonisolated private func updateMetrics(_ statistics: HKStatistics?) {
guard let statistics else { return }
// print("update metrics for \(statistics.quantityType.identifier)")
DispatchQueue.main.async {
switch statistics.quantityType {
case HKQuantityType(.heartRate):
self.workoutMetrics?.heartRate = statistics.heartRate
self.workoutMetrics?.averageHeartRate = statistics.averageHeartRate
case HKQuantityType(.activeEnergyBurned):
self.workoutMetrics?.energyBurned = statistics.activeEnergyBurned
case HKQuantityType(.distanceWalkingRunning),
HKQuantityType(.distanceCycling),
HKQuantityType(.distanceSwimming):
self.workoutMetrics?.distance = statistics.totalDistance
case HKQuantityType(.walkingSpeed),
HKQuantityType(.cyclingSpeed),
HKQuantityType(.runningSpeed):
self.workoutMetrics?.speed = statistics.speed
self.workoutMetrics?.averageSpeed = statistics.averageSpeed
case HKQuantityType(.swimmingStrokeCount):
self.workoutMetrics?.stepStrokeCount = statistics.strokeCount
case HKQuantityType(.stepCount):
self.workoutMetrics?.stepStrokeCount = statistics.stepCount
default:
return
}
}
}
}
// ПОМЕТКА: - HKWorkoutSessionDelegate
extension WorkoutManager: HKWorkoutSessionDelegate {
nonisolated func workoutSession(_ workoutSession: HKWorkoutSession, didChangeTo toState: HKWorkoutSessionState, from fromState: HKWorkoutSessionState, date: Date) {
DispatchQueue.main.async {
self.sessionRunning = (toState == .running)
}
// Дождитесь перехода сеанса в другое состояние, прежде чем завершать работу билдера.
if toState == .ended {
Task {
do {
try await builder?.endCollection(at: date)
let workout = try await builder?.finishWorkout()
DispatchQueue.main.async {
self.workoutResult = workout
}
} catch(let error) {
self.setError(.endWorkFailed(error))
return
}
}
}
}
// вызывается при возникновении ошибки, которая останавливает сеанс тренировки.
// когда состояние сеанса тренировки изменяется из-за возникновения ошибки, этот метод всегда вызывается перед workoutSession:didChangeToState:fromState:date:.
nonisolated func workoutSession(_ workoutSession: HKWorkoutSession, didFailWithError error: any Error) {
setError(.sessionStoppedWithError(error))
DispatchQueue.main.async(execute: {
self.endSession()
self.reset()
})
}
// используется при разделении сеанса на несколько активти, например, для триатлона или многоборья
nonisolated func workoutSession(_ workoutSession: HKWorkoutSession, didBeginActivityWith workoutConfiguration: HKWorkoutConfiguration, date: Date) {
print("did begin activity: \(workoutConfiguration)")
}
nonisolated func workoutSession(_ workoutSession: HKWorkoutSession, didEndActivityWith workoutConfiguration: HKWorkoutConfiguration, date: Date) {
print("did end activity: \(workoutConfiguration)")
}
}
// ПОМЕТКА: - HKLiveWorkoutBuilderDelegate
extension WorkoutManager: HKLiveWorkoutBuilderDelegate {
nonisolated func workoutBuilder(_ workoutBuilder: HKLiveWorkoutBuilder, didCollectDataOf collectedTypes: Set<HKSampleType>) {
for type in collectedTypes {
guard let quantityType = type as? HKQuantityType else {
return
}
let statistics = workoutBuilder.statistics(for: quantityType)
updateMetrics(statistics)
}
}
nonisolated func workoutBuilderDidCollectEvent(_ workoutBuilder: HKLiveWorkoutBuilder) {
print("did collect event: last new event: \(String(describing: workoutBuilder.workoutEvents.last))")
if let event = workoutBuilder.workoutEvents.last {
DispatchQueue.main.async(execute: {
switch event.type {
case .pauseOrResumeRequest:
print("pauseOrResumeRequest received.")
self.sessionRunning ? self.pauseSession() : self.resumeSession()
break
default:
break
}
})
}
}
nonisolated func workoutBuilder(_ workoutBuilder: HKLiveWorkoutBuilder, didBegin workoutActivity: HKWorkoutActivity) {
print("did begin activity: \(workoutActivity)")
}
nonisolated func workoutBuilder(_ workoutBuilder: HKLiveWorkoutBuilder, didEnd workoutActivity: HKWorkoutActivity) {
print("did end activity: \(workoutActivity)")
}
}
// ПОМЕТКА: - Типы
extension WorkoutManager {
enum WorkoutError: Error {
case unavailable
case permissionDenied
case requestPermissionError(Error)
case workoutTypeNotSupported
case startWorkoutFailed(Error)
case endWorkFailed(Error)
case sessionStoppedWithError(Error)
case queryError(Error?)
var message: String {
switch self {
case .unavailable:
return "Workout unavailable."
case .permissionDenied:
return "Permission denied."
case .requestPermissionError(let error):
return "Request permission: \(error.localizedDescription)"
case .workoutTypeNotSupported:
return "Workout type not supported."
case .startWorkoutFailed(let error):
return "startWorkoutFailed: \(error.localizedDescription)"
case .endWorkFailed(let error):
return "endWorkFailed: \(error.localizedDescription)"
case .sessionStoppedWithError(let error):
return "sessionStoppedWithError: \(error.localizedDescription)"
case .queryError(let error):
return "queryError: \(error?.localizedDescription ?? "unknown")"
}
}
}
struct WorkoutMetrics: Hashable, Identifiable, Equatable {
var id: UUID = UUID()
var workoutConfiguration: HKWorkoutConfiguration
var averageHeartRate: Double = 0
var heartRate: Double = 0
var energyBurned: Double = 0
var distance: Double = 0
var speed: Double = 0
var averageSpeed: Double = 0
var stepStrokeCount: Double = 0
static func == (lhs: Self, rhs: Self) -> Bool {
return lhs.workoutConfiguration == rhs.workoutConfiguration
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}
}
extension HKStatistics {
static var heartRateUnit: HKUnit {
HKUnit.count().unitDivided(by: HKUnit.minute())
}
static var energyUnit: HKUnit {
HKUnit.kilocalorie()
}
static var distanceUnit: HKUnit {
HKUnit.meter()
}
static var speedUnit: HKUnit {
HKUnit.meter().unitDivided(by: HKUnit.second())
}
static var countUnit: HKUnit {
HKUnit.count()
}
var heartRate: Double {
if self.quantityType == HKQuantityType(.heartRate) {
return self.mostRecentQuantity()?.doubleValue(for: Self.heartRateUnit) ?? 0
}
return 0
}
var averageHeartRate: Double {
if self.quantityType == HKQuantityType(.heartRate) {
return self.averageQuantity()?.doubleValue(for: Self.heartRateUnit) ?? 0
}
return 0
}
var activeEnergyBurned: Double {
if self.quantityType == HKQuantityType(.activeEnergyBurned) {
return self.sumQuantity()?.doubleValue(for: Self.energyUnit) ?? 0
}
return 0
}
var totalDistance: Double {
if self.quantityType == HKQuantityType(.distanceWalkingRunning) ||
self.quantityType == HKQuantityType(.distanceCycling) ||
self.quantityType == HKQuantityType(.distanceSwimming) {
return self.sumQuantity()?.doubleValue(for: Self.distanceUnit) ?? 0
}
return 0
}
var speed: Double {
if self.quantityType == HKQuantityType(.walkingSpeed) ||
self.quantityType == HKQuantityType(.runningSpeed) ||
self.quantityType == HKQuantityType(.cyclingSpeed) {
return self.mostRecentQuantity()?.doubleValue(for: Self.speedUnit) ?? 0
}
return 0
}
var averageSpeed: Double {
if self.quantityType == HKQuantityType(.walkingSpeed) ||
self.quantityType == HKQuantityType(.runningSpeed) ||
self.quantityType == HKQuantityType(.cyclingSpeed) {
return self.averageQuantity()?.doubleValue(for: Self.speedUnit) ?? 0
}
return 0
}
var stepCount: Double {
if self.quantityType == HKQuantityType(.stepCount) {
return self.sumQuantity()?.doubleValue(for: Self.countUnit) ?? 0
}
return 0
}
var strokeCount: Double {
if self.quantityType == HKQuantityType(.swimmingStrokeCount) {
return self.sumQuantity()?.doubleValue(for: Self.countUnit) ?? 0
}
return 0
}
}
Проверка доступности и запрос разрешений
Прежде чем мы сможем создать HKHealthStore — точку доступа ко всем данным, управляемым HealthKit, — нам необходимо убедиться, что HealthKit доступен на устройстве пользователя. Для этого мы можем вызвать метод isHealthDataAvailable().
Если HealthKit действительно доступен, мы можем запросить разрешение на сохранение и чтение интересующих нас типов данных, вызвав requestAuthorization(toShare:read:).
override init() {
super.init()
if HKHealthStore.isHealthDataAvailable() {
self.healthStore = HKHealthStore()
Task {
await self.requestAuthorization()
}
} else {
self.error = .unavailable
}
}
private func requestAuthorization() async {
do {
try await healthStore?.requestAuthorization(toShare: typesToShare, read: typesToRead)
} catch (let error) {
self.error = .requestPermissionError(error)
}
}
Параметр typesToShare
здесь содержит типы данных, которыми мы хотим поделиться. Это может быть любой конкретный подкласс класса HKSampleType, например, HKQuantityType, HKCategoryType, HKWorkoutType или HKCorrelationType.
В нашем случае мы будем делиться только HKWorkoutType. Этот тип будет содержать всю необходимую информацию и статистику, собранную во время тренировки, включая продолжительность, дистанцию, сожженные калории и другие важные детали. Таким образом, нам не нужны другие HKQuantityTypes по отдельности.
typesToRead
— это множество, в которое мы включаем типы данных, которые хотим прочитать. Это может быть любой конкретный подкласс класса HKObjectType, такой как HKCharacteristicType, HKQuantityType, HKCategoryType, HKWorkoutType или HKCorrelationType.
Мы добавили сюда несколько HKQuantityType`ов и HKActivitySummaryType. Не стесняйтесь добавлять больше типов в зависимости от того, какие виды тренировок вы собираетесь поддержать.
При первом запуске приложения вы должны увидеть следующее приглашение с запросом доступа:

В дополнение к проверке доступности и запросу разрешения при инициализации, мы также будем проверять статус запроса перед началом тренировки:
private func checkAvailability() async -> Bool {
if !HKHealthStore.isHealthDataAvailable() {
self.error = .unavailable
return false
}
do {
let status = try await healthStore?.statusForAuthorizationRequest(toShare: typesToShare, read: typesToRead)
if status == .unnecessary {
return true
}
if status == .shouldRequest {
await self.requestAuthorization()
return true
}
self.error = .permissionDenied
return false
} catch(let error) {
self.error = .requestPermissionError(error)
return false
}
}
Начало тренировки
Теперь, когда у нас есть необходимые разрешения, мы можем начать тренировку, выполнив следующие шаги:
Создание HKWorkoutSession
Извлечение HKLiveWorkoutBuilder из сеанса.
Назначение HKLiveWorkoutDataSource билдеру
Назначение HKWorkoutSessionDelegate для мониторинга сеанса тренировки
Назначение HKLiveWorkoutBuilderDelegate для мониторинга билдера
Запуск сеанса и начало сбора данных
func startWorkout(with configuration: HKWorkoutConfiguration) async {
let result = await self.checkAvailability()
if !result {
return
}
guard let healthStore else {
self.error = .unavailable
return
}
if !self.supportedWorkoutTypes.contains(configuration.activityType) {
self.error = .workoutTypeNotSupported
return
}
do {
session = try HKWorkoutSession(healthStore: healthStore, configuration: configuration)
builder = session?.associatedWorkoutBuilder()
} catch(let error) {
self.error = .startWorkoutFailed(error)
return
}
builder?.dataSource = HKLiveWorkoutDataSource(healthStore: healthStore, workoutConfiguration: configuration)
// print("types to collect: ", builder?.dataSource?.typesToCollect as Any)
// чтобы можно было собирать дополнительные типы данных, например: respiratoryRate
// builder?.dataSource?.enableCollection(for: HKQuantityType(.respiratoryRate), predicate: nil)
// для отслеживания обновлений сеанса и билдера
session?.delegate = self
builder?.delegate = self
// запуск активити
let date = Date()
session?.startActivity(with: date)
do {
try await builder?.beginCollection(at: date)
self.workoutMetrics = .init(workoutConfiguration: configuration)
} catch(let error) {
self.error = .startWorkoutFailed(error)
reset()
}
}
Как много всего в одной функции!
Через несколько секунд мы подробнее рассмотрим функции‑делегаты, но сначала давайте подробнее остановимся на других аспектах.
Прежде всего, чтобы запустить HKWorkoutSession, нам нужны две вещи: наш HKHealthStore и HKWorkoutConfiguration, которую мы получаем из параметра функции, представляющая собой объект конфигурации для тренировки. Мы используем его для настройки типа активити и места для тренировки, чтобы Apple Watch могли оптимизировать работу датчиков и расчет калорий в соответствии с нашей конфигурацией.
let configuration = HKWorkoutConfiguration()
configuration.activityType = .running
configuration.locationType = .outdoor
activityType задает HKWorkoutActivityType сеанса.
Остальные параметры, которые нам нужно установить, будут зависеть от типа тренировки. Например, для плавания (swimming) вместо LocationType нам нужен swimmingLocationType. И если для swimmingLocationType установлено значение pool, нам также нужно будет указать lapLength.
Затем мы можем приступить к созданию нашей HKWorkoutSession, получить HKLiveWorkoutBuilder и назначить dataSource.
Важно отметить, что Apple Watch автоматически собирает различные типы данных в зависимости от конфигурации activityType. Например, во время сеанса бега на свежем воздухе устройство фиксирует и сохраняет activeEnergyBurned, basalEnergyBurned, heartRate, и distanceWalkingRunning.
Чтобы узнать, какие типы данных и в каком количестве источник данных автоматически отправляет в билдер, следует обратить внимание на свойство builder.dataSource.typesToCollect.
Кроме того, мы можем настроить источник данных для начала сбора определенного типа информации, используя метод enableCollection(for:predicate:)
и прекратить сбор с помощью метода disableCollection(for:)
. Например, если мы хотим дополнительно включить информацию о частоте дыхания respiratoryRate
, нам следует добавить следующий код после назначения источника данных:
builder?.dataSource?.enableCollection(for: HKQuantityType(.respiratoryRate), predicate: nil)
Вскоре мы рассмотрим делегатов, но пока что по умолчанию сеанс тренировки автоматически передает все события в билдер. Это означает, что оба делегата должны получать один и тот же набор событий. Однако мы можем установить свойство shouldCollectWorkoutEvents билдера в false, чтобы контролировать поступающие в него события.
После завершения всех этих настроек мы наконец‑то вызываем startActivity(with:) и beginCollection(at:), чтобы запустить активити сеанса тренировки и начать процесс сбора данных.
Наша workoutMetrics
— это просто пользовательская структура, которая будет хранить соответствующую статистику на протяжении всего сеанса тренировки.
Приостановка и возобновление сеанса
Для этого достаточно всего одной строчки кода!
Просто вызовите методы pause() и resume() HKWorkoutSession.
func resumeSession() {
session?.resume()
}
func pauseSession() {
session?.pause()
}
Однако область применения этих функций не ограничивается только нашим собственным view. Давайте посмотрим, где еще их нужно использовать.
Завершение тренировки (Шаг 1)
Завершение тренировки также представляет собой многоэтапный процесс.
Однако, в отличие от начала сеанса, эти шаги не могут быть выполнены последовательно, то есть в рамках одной функции. Первый шаг — вызов end() для завершения HKWorkoutSession.
func endSession() {
session?.end()
}
После этого мы завершим билдер и тренировку, убедившись, что сессия действительно завершена в рамках наших функций‑делегатов.
HKWorkoutSessionDelegate
HKWorkoutSessionDelegate позволяет нам получать обновления в следующих случаях:
При изменении состояния сеанса.
При возникновении события.
В случае завершения сеанса с ошибкой.
Он предоставляет следующие функции для отслеживания HKWorkoutSession, из которых первые две являются обязательными:
workoutSession(_:didChangeTo:from:date:) запускается при изменении состояния сеанса
workoutSession(_:didFailWithError:) уведомляет нас, когда наш сеанс был остановлен с ошибкой
workoutSession(_:didGenerate:) когда система сгенерировала событие тренировки
workoutSession(_:didBeginActivityWith:date:) при начале новой тренировки
workoutSession(_:didEndActivityWith:date:) при завершении текущей тренировки
В этой статье мы главным образом сосредоточимся на первом из них. В этом методе мы:
Устанавливаем переменную
sessionRunning
, которая будет указывать, выполняется ли сеанс в данный момент.Если состояние HKWorkoutSessionState имеет значение ended, что свидетельствует о завершении сеанса тренировки, мы вызываем методы билдера endCollection(withEnd:) и finishWorkout() для завершения тренировки.
nonisolated func workoutSession(_ workoutSession: HKWorkoutSession, didChangeTo toState: HKWorkoutSessionState, from fromState: HKWorkoutSessionState, date: Date) {
DispatchQueue.main.async {
self.sessionRunning = (toState == .running)
}
// Дождитесь перехода сеанса в другое состояние, прежде чем завершать работу билдера.
if toState == .ended {
Task {
do {
try await builder?.endCollection(at: date)
let workout = try await builder?.finishWorkout()
DispatchQueue.main.async {
self.workoutResult = workout
}
} catch(let error) {
self.setError(.endWorkFailed(error))
return
}
}
}
}
Метод finishWorkout() возвращает объект HKWorkout, который мы можем использовать для получения дополнительной информации о тренировке. Более подробно мы рассмотрим этот вопрос, когда приступим к созданию нашего view.
HKLiveWorkoutBuilderDelegate
HKLiveWorkoutBuilderDelegate используется для мониторинга запущенных билдеров, например, для получения обновлений статистики (выборок) или для получения уведомлений о новом HKWorkoutEvent.
В нашем распоряжении имеется несколько методов делегирования, из которых обязательными являются первые два:
workoutBuilder(_:didCollectDataOf:) для получения новых статистических данных
workoutBuilderDidCollectEvent(_:) когда в конструктор добавлено новое событие
workoutBuilder(_:didBegin:) запуск новой тренировки
workoutBuilder(_:didEnd:) по завершении текущей тренировки
Давайте подробнее рассмотрим метод workoutBuilder(_:didCollectDataOf:) и выясним, как мы можем обрабатывать HKSampleType.
Я полагаю, что этот тип данных уникален для HealthKit, и в дальнейшем мы также будем обрабатывать наши HKWorkout‑данные (приведенные выше) аналогичным образом.
nonisolated func workoutBuilder(_ workoutBuilder: HKLiveWorkoutBuilder, didCollectDataOf collectedTypes: Set<HKSampleType>) {
for type in collectedTypes {
guard let quantityType = type as? HKQuantityType else {
return
}
let statistics = workoutBuilder.statistics(for: quantityType)
updateMetrics(statistics)
}
}
В нашей функции‑делегате мы сначала проверяем, относится ли собираемый тип к HKQuantityType
. Если это так, мы получаем статистику для данного типа, используя метод билдера statistics(for:).
Статистика, представленная в формате HKStatistics, представляет собой объект, содержащий результаты вычислений минимального, максимального, среднего значения или суммы по набору выборок из хранилища HealthKit.
Чтобы подготовить эту статистику для использования, нам необходимо выполнить несколько шагов:
Определить quantityType выборок, используемых для расчета статистических данных.
Получить соответствующее HKQuantity в зависимости от выбранного типа.
Извлечь
Double
‑значение из полученногоHKQuantity
, указав определенный HKUnit.
extension HKStatistics {
static var heartRateUnit: HKUnit {
HKUnit.count().unitDivided(by: HKUnit.minute())
}
static var energyUnit: HKUnit {
HKUnit.kilocalorie()
}
static var distanceUnit: HKUnit {
HKUnit.meter()
}
static var speedUnit: HKUnit {
HKUnit.meter().unitDivided(by: HKUnit.second())
}
static var countUnit: HKUnit {
HKUnit.count()
}
var heartRate: Double {
if self.quantityType == HKQuantityType(.heartRate) {
return self.mostRecentQuantity()?.doubleValue(for: Self.heartRateUnit) ?? 0
}
return 0
}
var averageHeartRate: Double {
if self.quantityType == HKQuantityType(.heartRate) {
return self.averageQuantity()?.doubleValue(for: Self.heartRateUnit) ?? 0
}
return 0
}
var activeEnergyBurned: Double {
if self.quantityType == HKQuantityType(.activeEnergyBurned) {
return self.sumQuantity()?.doubleValue(for: Self.energyUnit) ?? 0
}
return 0
}
var totalDistance: Double {
if self.quantityType == HKQuantityType(.distanceWalkingRunning) ||
self.quantityType == HKQuantityType(.distanceCycling) ||
self.quantityType == HKQuantityType(.distanceSwimming) {
return self.sumQuantity()?.doubleValue(for: Self.distanceUnit) ?? 0
}
return 0
}
var speed: Double {
if self.quantityType == HKQuantityType(.walkingSpeed) ||
self.quantityType == HKQuantityType(.runningSpeed) ||
self.quantityType == HKQuantityType(.cyclingSpeed) {
return self.mostRecentQuantity()?.doubleValue(for: Self.speedUnit) ?? 0
}
return 0
}
var averageSpeed: Double {
if self.quantityType == HKQuantityType(.walkingSpeed) ||
self.quantityType == HKQuantityType(.runningSpeed) ||
self.quantityType == HKQuantityType(.cyclingSpeed) {
return self.averageQuantity()?.doubleValue(for: Self.speedUnit) ?? 0
}
return 0
}
var stepCount: Double {
if self.quantityType == HKQuantityType(.stepCount) {
return self.sumQuantity()?.doubleValue(for: Self.countUnit) ?? 0
}
return 0
}
var strokeCount: Double {
if self.quantityType == HKQuantityType(.swimmingStrokeCount) {
return self.sumQuantity()?.doubleValue(for: Self.countUnit) ?? 0
}
return 0
}
}
После этого мы можем использовать этот extension
для обновления наших workoutMetrics
во вспомогательной функции updateMetrics
.
Движемся далее!
И наконец, последнее, но не менее важное: наш метод workoutBuilderDidCollectEvent(_:)!
Благодаря потрясающим возможностям watchOS, во время текущей тренировки для нас автоматически создается виджет, который отображает прошедшее время (рассчитанное с использованием внутренней логики системы) и предоставляет кнопку для приостановки и возобновления тренировки.

Однако эта кнопка управления фактически НЕ запускает и не приостанавливает сеанс. (Очевидно… потому что система понятия не имеет о том, как мы реализовали эти функции в нашем приложении!)
Вместо этого она отправляет HKWorkoutEvent в наше приложение с типом события (HKWorkoutEventType) pauseOrResumeRequest.
Чтобы связать элемент управления с нашим приложением и фактически приостановить или возобновить сеанс, нам необходимо обработать это событие в нашем методе workoutBuilderDidCollectEvent(_:).
nonisolated func workoutBuilderDidCollectEvent(_ workoutBuilder: HKLiveWorkoutBuilder) {
if let event = workoutBuilder.workoutEvents.last {
DispatchQueue.main.async(execute: {
switch event.type {
case .pauseOrResumeRequest:
self.sessionRunning ? self.pauseSession() : self.resumeSession()
break
default:
break
}
})
}
}
Затраченное время
Выше мы рассмотрели основные функции, необходимые для обработки сеанса тренировки и связанных с ним обновлений данных!
Однако есть ещё один важный момент, на который я хотел бы обратить ваше внимание! Наша функция getElapseTime
.
Чтобы получить продолжительность тренировки, у нас есть два варианта:
Свойство elapsedTime, доступное в HKLiveWorkoutBuilde.
Функция elapsedTime(at:) из HKLiveWorkoutBuilde.
Ключевое различие заключается в том, что первое свойство возвращает общую продолжительность тренировки с учетом времени, потраченного на паузы. Тогда как вторая функция даст нам продолжительность без учета этих периодов, которые представляют собой интервалы между событиями паузы и возобновления.
View
Настало время взяться за view!
import SwiftUI
import HealthKit
struct NewWorkoutView: View {
@Environment(WorkoutManager.self) private var workoutManager
@State private var selectedWorkoutType: HKWorkoutActivityType? = nil
@State private var showConfigurationSheet: Bool = false
@State private var swimLocation: HKWorkoutSwimmingLocationType = .pool
@State private var lapLength: Int = 400
@State private var activityLocation: HKWorkoutSessionLocationType = .outdoor
var body: some View {
@Bindable var workoutManager = workoutManager
let supportedWorkoutTypes = Array(workoutManager.supportedWorkoutTypes)
List {
if let error = workoutManager.error {
Text(error.message)
.foregroundStyle(.red)
}
ForEach(0..<supportedWorkoutTypes.count, id: \.self) { index in
let workoutType = supportedWorkoutTypes[index]
Button(action: {
selectedWorkoutType = workoutType
showConfigurationSheet = true
}, label: {
Text(workoutType.string)
})
}
}
.scrollIndicators(.hidden)
.sheet(isPresented: $showConfigurationSheet, content: {
VStack(alignment: .leading, spacing: 8) {
if let selectedWorkoutType {
if selectedWorkoutType == .swimming {
List {
Picker(selection: $swimLocation, content: {
Text("Open water")
.tag(HKWorkoutSwimmingLocationType.openWater)
Text("Pool")
.tag(HKWorkoutSwimmingLocationType.pool)
}, label: {
Text("Swim Location")
})
}
.frame(height: 56)
.scrollIndicators(.hidden)
.scrollDisabled(true)
HStack {
Text("Lap Length(m)")
.padding(.leading, 4)
.lineLimit(1)
.minimumScaleFactor(0.7)
TextField("", value: $lapLength, format: .number)
}
} else {
Form {
Picker(selection: $activityLocation, content: {
Text("Indoor")
.tag(HKWorkoutSessionLocationType.indoor)
Text("Outdoor")
.tag(HKWorkoutSessionLocationType.outdoor)
}, label: {
Text("Activity Location")
})
}
}
if let error = workoutManager.error {
Text(error.message)
.foregroundStyle(.red)
.padding(.leading, 4)
.lineLimit(1)
.minimumScaleFactor(0.8)
}
HStack(spacing: 8) {
Button(action: {
showConfigurationSheet = false
}, label: {
Text("Cancel")
.foregroundStyle(.red)
.padding(.all, 4)
.contentShape(Rectangle())
})
Button(action: {
Task {
let configuration = HKWorkoutConfiguration()
configuration.activityType = selectedWorkoutType
if selectedWorkoutType == .swimming {
configuration.swimmingLocationType = swimLocation
configuration.lapLength = HKQuantity(unit: HKUnit.meter(), doubleValue: Double(lapLength))
} else {
configuration.locationType = activityLocation
}
await workoutManager.startWorkout(with: configuration)
showConfigurationSheet = false
}
}, label: {
Text("Start")
.foregroundStyle(.blue)
.padding(.all, 4)
.contentShape(Rectangle())
})
}
.padding(.trailing, 4)
.fontWeight(.semibold)
.buttonStyle(.plain)
.frame(maxWidth: .infinity, alignment: .trailing)
}
}
.font(.system(size: 12))
.frame(maxHeight: .infinity, alignment: .top)
.toolbarVisibility(.hidden, for: .navigationBar)
.interactiveDismissDisabled()
})
.onChange(of: showConfigurationSheet, {
if !showConfigurationSheet {
selectedWorkoutType = nil
}
})
.navigationTitle("New Workout")
.navigationDestination(item: $workoutManager.workoutMetrics, destination: { _ in
WorkoutProgressView()
.environment(workoutManager)
})
.sheet(isPresented: $workoutManager.showResult) {
WorkoutResultView()
.environment(workoutManager)
.toolbarVisibility(.hidden, for: .navigationBar)
}
}
}
struct WorkoutProgressView: View {
@Environment(WorkoutManager.self) private var workoutManager
@State private var currentDate: Date = Date()
private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
@State private var showControl: Bool = false
@State private var showProgressView: Bool = false
var body: some View {
ZStack {
if showProgressView {
ProgressView()
.controlSize(.extraLarge)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.contentShape(Rectangle())
}
if let metrics = workoutManager.workoutMetrics {
VStack(alignment: .leading) {
HStack {
Text(workoutManager.getElapseTime(at: currentDate).hourMinuteSecond)
.font(.title2)
.fontWeight(.semibold)
Spacer()
Button(action: {
showControl.toggle()
}, label: {
Image(systemName: "ellipsis")
.rotationEffect(.degrees(showControl ? 90 : 0))
.frame(width: 32, height: 32)
.background(Circle().fill(.gray))
})
.overlay(alignment: .top, content: {
if showControl {
let sessionRunning = workoutManager.sessionRunning
VStack(spacing: 8) {
Button(action: {
sessionRunning ? workoutManager.pauseSession() : workoutManager.resumeSession()
}, label: {
Image(systemName: sessionRunning ? "pause.fill" : "arrow.clockwise")
.frame(width: 32, height: 32)
.background(Circle().fill(.gray))
})
Button(action: {
workoutManager.endSession()
self.showProgressView = true
}, label: {
Image(systemName: "stop.fill")
.frame(width: 32, height: 32)
.background(Circle().fill(.gray))
})
}
.padding(.top, 40)
}
})
.buttonStyle(.plain)
}
Text(metrics.distance.formatted(.number.precision(.fractionLength(0))) + " \(HKStatistics.distanceUnit.unitString)")
Text(metrics.energyBurned.formatted(.number.precision(.fractionLength(0))) + " \(HKStatistics.energyUnit.unitString)")
Text(metrics.heartRate.formatted(.number.precision(.fractionLength(0))) + " bpm")
if metrics.workoutConfiguration.activityType == .swimming {
Text(metrics.stepStrokeCount.formatted(.number.precision(.fractionLength(0))) + " strokes")
}
if metrics.workoutConfiguration.activityType == .walking || metrics.workoutConfiguration.activityType == .running || metrics.workoutConfiguration.activityType == .highIntensityIntervalTraining {
Text(metrics.stepStrokeCount.formatted(.number.precision(.fractionLength(0))) + " steps")
}
}
.onReceive(timer) { input in
currentDate = input
}
.disabled(showProgressView)
.navigationTitle("\(metrics.workoutConfiguration.activityType.string)")
.navigationBarTitleDisplayMode(.inline)
}
}
.navigationBarBackButtonHidden(true)
.onTapGesture {
self.showControl = false
}
}
}
struct WorkoutResultView: View {
@Environment(WorkoutManager.self) private var workoutManager
var body: some View {
if let result = workoutManager.workoutResult {
ScrollView {
HKWorkoutView(workout: result)
Spacer()
.frame(height: 8)
Button(action: {
workoutManager.showResult = false
}, label: {
Text("Done")
})
}
}
}
}
struct HKWorkoutView: View {
var workout: HKWorkout
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text("\(workout.workoutActivityType.string) Summary")
.font(.title3)
.fontWeight(.bold)
Text("**Duration:** \(workout.duration.hourMinuteSecond)" )
let distanceStatistics = switch workout.workoutActivityType {
case .cycling:
workout.statistics(for: HKQuantityType(.distanceCycling))
case .swimming:
workout.statistics(for: HKQuantityType(.distanceSwimming))
default:
workout.statistics(for: HKQuantityType(.distanceWalkingRunning))
}
if let distanceStatistics {
Text("**Total Distance:** \(distanceStatistics.totalDistance.formatted(.number.precision(.fractionLength(0)))) \(HKStatistics.distanceUnit.unitString)" )
}
if let energyStatistics = workout.statistics(for: HKQuantityType(.activeEnergyBurned)) {
Text("**Energy Burnt:** \(energyStatistics.activeEnergyBurned.formatted(.number.precision(.fractionLength(0)))) \(HKStatistics.energyUnit.unitString)" )
}
if let heartRateStatistics = workout.statistics(for: HKQuantityType(.heartRate)) {
Text("**Avg. Heart Rate:** \(heartRateStatistics.averageHeartRate.formatted(.number.precision(.fractionLength(0)))) bpm" )
}
if let stepCountStatistics = workout.statistics(for: HKQuantityType(.stepCount)) {
Text("**Steps:** \(stepCountStatistics.stepCount.formatted(.number.precision(.fractionLength(0))))" )
}
if let strokeCountStatistics = workout.statistics(for: HKQuantityType(.swimmingStrokeCount)) {
Text("**Strokes:** \(strokeCountStatistics.strokeCount.formatted(.number.precision(.fractionLength(0))))" )
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
extension TimeInterval {
var hourMinuteSecond: String {
let interval = Int(self)
let seconds = interval % 60
let minutes = (interval / 60) % 60
let hours = (interval / (60*60)) % 60
return String(format: "%02d:%02d:%02d", hours, minutes, seconds)
}
}
extension HKWorkoutActivityType {
var string: String {
switch self {
case .walking:
return "Walking"
case .running:
return "Running"
case .cycling:
return "Cycling"
case .swimming:
return "Swimming"
case .swimBikeRun:
return "Triathlon"
case .highIntensityIntervalTraining:
return "Interval Training"
default:
return "(not supported)"
}
}
}
Единственное замечание (и наше последнее замечание на сегодня)! Хочу обратить ваше внимание на то, как мы извлекаем HKStatistics
из HKWorkout
!
Подобно тому, как мы использовали метод statistics(for:) в билдере, в HKWorkout
также есть метод statistics(for:). Мы просто указываем интересующий нас HKQuantityType и получаем нашу HKStatistics
!
С этого момента обработка статистики будет происходить точно так же, как описано ранее.
Вот и всё!
Теперь вы можете надеть часы и отправиться на пробежку!

Вуаля! Я проехал 7 секунд (технически говоря, может быть, всего 6?) на велосипеде!
Это было все, что я хотел рассказать вам в первой части.
Опять же, не стесняйтесь пользоваться демонстрационным приложением, которое доступно на моем GitHub!
Все вышесказанное — это замечательно, но разве не интересно знать, как мы провели день? Сколько мы работали? Сколько энергии потратили?
Именно об этом мы поговорим во второй части, которая скоро выйдет. Следите за новостями, если вам было интересно.
Хорошей тренировки!
Впереди еще много интересного! После того, как мы изучили работу с HealthKit, пора расширить наши знания. На следующем открытом уроке вы узнаете, как написать сетевой клиент под SwiftUI, какие технологии выбрать, и как сделать это эффективно. Подключайтесь 22 апреля к уроку «SwiftUI + Network: Выбираем сетевой клиент» Записаться
Больше уроков по Swift-разработке и не только смотрите в календаре мероприятий.