Как стать автором
Обновить
484.5
OTUS
Цифровые навыки от ведущих экспертов

SwiftUI: Пишем простое фитнес-приложение с использованием HealthKit

Уровень сложностиСредний
Время на прочтение26 мин
Количество просмотров1.4K
Автор оригинала: Itsuki

Код приложения доступен на GitHub!

**************

HealthKit — это замечательный фреймворк, который позволяет нам создавать приложения, способные получать доступ к данным, связанным со здоровьем и физической формой, а также делиться ими, сохраняя при этом конфиденциальность и контроль пользователя.

В этой статье я предлагаю вместе создать простое приложение для тренировок, которое позволит вам ознакомиться с основами этого удивительного фреймворка. Мы будем разрабатывать приложение для watchOS, так как именно эта платформа, на мой взгляд, наилучшим образом раскрывает потенциал HealthKit.

Сегодня мы сосредоточимся на следующих аспектах:

  • настройка фреймворка

  • запрос разрешений и управление ими

  • запуск, приостановка, возобновление и завершение тренировки, а также сохранение ее в хранилище HealthKit

  • мониторинг статистики во время тренировки

  • получение окончательных показателей по завершению тренировки

И это только начало!

Что же нас ждет в следующей части? Вы узнаете об этом в конце статьи!

Обзор

HealthKit — это мощный инструмент, который служит центральным хранилищем данных о здоровье и физической форме на iPhone и Apple Watch. С разрешения пользователя мы можем получить доступ к этому хранилищу, чтобы читать и делиться этими данными.

Поскольку разные приложения будут иметь доступ к одному и тому же хранилищу, нам необходимо обеспечить:

  • 1. Конфиденциальность. Мы должны делиться только теми данными, которые можно использовать за пределами нашего приложения.

  • 2. Возможность обработки любых изменений в данных хранилища, которые могут произойти вне нашего приложения.

Данные о состоянии здоровья могут быть конфиденциальными, поэтому нам необходимо получить разрешение пользователя на чтение и запись в хранилище HealthKit.

И именно этим мы и займемся дальше!

Подготовка

В этой части мы выполним несколько важных подготовительных шагов:

  1. Добавление функционала HealthKit.

  2. Настройка фонового режима для обработки тренировок.

  3. Добавление описаний использования.

Добавление HealthKit

Прежде чем мы сможем использовать HealthKit, мы должны добавить функционал HealthKit в наше приложение.

Выберите Watch в качестве таргета!

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

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

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

Да, нам НЕ нужна HealthKit Background Delivery.

Эта функция предназначена для выполнения длительных запросов, которые будут обновлять наше приложение всякий раз, когда система обнаружит изменения в хранилище HealthKit.

Настройка фонового режима

Для того же таргета добавьте функционал Background Modes и выберите опцию Workout processing.

Это позволит нашему приложению получать фоновые обновления статистических данных, таких как сожженные калории и частота сердечных сокращений, во время тренировки.

Добавление описаний использования

Поскольку мы будем как считывать данные о состоянии пользователя, так и сохранять их в хранилище HealthKit, нам необходимо добавить два новых ключа в Info.plist нашего таргета!

  1. NSHealthShareUsageDescription — чтобы объяснить, почему наше приложение запрашивает разрешение на чтение образцов из хранилища HealthKit.

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

Начало тренировки

Теперь, когда у нас есть необходимые разрешения, мы можем начать тренировку, выполнив следующие шаги:

  1. Создание HKWorkoutSession

  2. Извлечение HKLiveWorkoutBuilder из сеанса.

  3. Назначение HKLiveWorkoutDataSource билдеру

  4. Назначение HKWorkoutSessionDelegate для мониторинга сеанса тренировки

  5. Назначение HKLiveWorkoutBuilderDelegate для мониторинга билдера

  6. Запуск сеанса и начало сбора данных

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, из которых первые две являются обязательными:

В этой статье мы главным образом сосредоточимся на первом из них. В этом методе мы:

  1. Устанавливаем переменную sessionRunning, которая будет указывать, выполняется ли сеанс в данный момент.

  2. Если состояние 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:) и выясним, как мы можем обрабатывать 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.

Чтобы подготовить эту статистику для использования, нам необходимо выполнить несколько шагов:

  1. Определить quantityType выборок, используемых для расчета статистических данных.

  2. Получить соответствующее HKQuantity в зависимости от выбранного типа.

  3. Извлечь 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.

Чтобы получить продолжительность тренировки, у нас есть два варианта:

  1. Свойство elapsedTime, доступное в HKLiveWorkoutBuilde.

  2. Функция 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-разработке и не только смотрите в календаре мероприятий.

Теги:
Хабы:
Всего голосов 8: ↑4 и ↓4+3
Комментарии0

Публикации

Информация

Сайт
otus.ru
Дата регистрации
Дата основания
Численность
101–200 человек
Местоположение
Россия
Представитель
OTUS