👋

【SwiftUI 】HealthKitのデータをFireStoreに保存する方法

2021/09/24に公開約9,900字5件のコメント

HealthKitの導入に関しては以下の記事を参考にさせて頂きました

https://zenn.dev/ueshun/articles/dd700cdbb61f8d

HealthStoreのデータを個人で扱う場合はわざわざデータを他のデータベースに保存する必要はありませんが、
他の人に共有したい場合はその必要が出てきます。
今回はHealthKitのデータをFirestoreに保存する方法に簡単にまとめてみます。
保存するデータの種類は昨日分までの歩数の1日ごとの合計とさせて頂きます。

  1. 権限のリクエスト
  2. Anchorを用いた期間指定
  3. HKCollectionQueryを用いたデータの取得、保存
  4. 実行

権限のリクエスト

https://developer.apple.com/documentation/healthkit/hkhealthstore/1614152-requestauthorization
機能の拡張性を視野に入れ、
  • どのHKSampleTypeを扱うか
  • どのHKSampleTypeに対してどの権限を与えるか
    をカスタマイズしやすくします

扱うデータの種類を整理し、HKSampleTypeを取得する

var allHealthDataTypes: [HKSampleType] {
let typeIdentifiers: [String] = [

    HKQuantityTypeIdentifier.stepCount.rawValue

	]

	return typeIdentifiers.compactMap {
	    getSampleType(for: $0)
	}
}
func getSampleType(for identifier: String) -> HKSampleType? {

	if let quantiryType = HKQuantityType.quantityType(forIdentifier: HKQuantityTypeIdentifier(rawValue: identifier )) {
	    return quantiryType
	}

	if let categoryType = HKCategoryType.categoryType(forIdentifier: HKCategoryTypeIdentifier(rawValue: identifier)) {
	    return categoryType
	}

	return nil

}

今回は歩数を扱うためtypeIdentifiersHKQuantityTypeIdentifier.stepCount.rawValue
を格納します。
typeIdentifiersに基づいてgetSapmleTypeHKSampleTypeを取得します。

扱うデータに応じた書き込み権限、読み取り権限のリクエストをする関数を準備

func requestHealthDataAccessIfNeeded(dataTypes: [String]? = nil, completion: @escaping (_ success: Bool) -> Void) {
	var readDataTypes = Set(allHealthDataTypes)
	var shareDataTypes = Set(allHealthDataTypes)

	if let dataTypeIdentifiers = dataTypes {
	    readDataTypes = Set(dataTypeIdentifiers.compactMap { getSampleType(for: $0) })
	    shareDataTypes = readDataTypes
	}

	requestHealthDataAccessIfNeeded(toShare: shareDataTypes, read: readDataTypes, completion: completion)
}

func requestHealthDataAccessIfNeeded(toShare shareTypes: Set<HKSampleType>?, read readTypes: Set<HKObjectType>?, completion: @escaping (_ success: Bool) -> Void) {
	if !HKHealthStore.isHealthDataAvailable() {
	    fatalError("Health data is not available!")
	}

	print("Requesting HealthKit authorization...")
	healthStore.requestAuthorization(toShare: shareTypes, read: readTypes) { (success, error) in
	    if let error = error {
		print("requestAuthorization error:", error.localizedDescription)
	    }

	    if success {
		print("HealthKit authorization request was successful!")
	    } else {
		print("HealthKit authorization was not successful.")
	    }

	    completion(success)
	}
}

allDataTypesに応じたデータの書き込み権限、読み込み権限をリクエストする関数を準備します。toShareには書き込むデータの種類、readには読み取るデータの種類を代入します。
(今回は読み取りのみですが、今後の拡張性を考え書き込み権限も付与しています)

HKCollectionQueryを用いたデータの取得、保存

いつからいつまでのデータを取得するか

HKCollectionQueryを用いてデータ取得を行う場合、いつからいつまでのデータを取得するかを
predicateを用いて指定し、クエリを実行する必要があります。

例えば2021年9月13日午前0時から2021年9月20日午前0時のデータを取得したい場合、以下のようになります。

let startDate = DateComponents(year: 2021, month: 9, day: 13, hour: 0, minute: 0, second: 0)
let endDate = DateComponents(year: 2021, month: 9, day: 20, hour: 0, minute: 0, second: 0)

let predicate = HKQuery.predicateForSamples(
    withStart: calendar.date(from: startDate),
    end: calendar.date(from: endDate)
)

今回の場合、初回以降にFirestoreへ保存するデータは、重複が起こらないよう前回保存したデータとの差分を保存していく必要があります。
つまり、いつまでのデータを保存したかをFirestoreにデータとして記録しなければなりません。
そのため、anchorを用意します。

anchorを保存、取得する関数を用意する

anchorは最後にいつまでのデータを取得したかを示すDate型のObjectです。
これをFirestoreに保存することで、次回queryを実行する際のstartDateanchorとして取得することができます。

class AnchorEntity: Codable, Identifiable {
    var id: String
    var createdAt: Date

    init(id: String, createdAt: Date) {
        self.id = id
        self.createdAt = createdAt
    }
}
func saveAnchor(from date: Date) -> Future<Void, Error> {

return Future<Void, Error> { promise in

	    let anchorId = IdFactory.create()

	    let anchorParams: [String: Any] = [
		"id": anchorId,
		"createdAt": Timestamp(date: date)
	    ]

	    docRef.collection("anchor")
		  .document(anchorId)
		  .setData(anchorParams)

	    promise(.success(Void()))

	}
}

anchorコレクションの中に次々にドキュメントとして追加していく形になります。

func getAnchor(from date: Date) -> Future<Date, Error> {
	return Future<Date, Error> { promise in

	    let docsRef = 
		docRef
		.collection("anchor")
		.order(by: "createdAt", descending: true).limit(to: 1)

	    docsRef.getDocuments { snapshot, error in
		if let error = error {
		    print(error)
		    return
		}

		guard let snapshot = snapshot else {
		    print("Error fetching snapshot: \(error!)")

		    return
		}

		let anchor = try? snapshot.documents.first?.data(as: AnchorEntity.self)

		if let anchor = anchor {
		    promise(.success(anchor.createdAt))

		} else {
		    promise(.success(Calendar.current.date(byAdding: .day, value: -7, to: date)!))

		}
	    }
	}
}

anchorコレクションの中から最新のものを取り出します。anchorとして取得できるデータがない場合(初回保存時)は7日前の日付をanchorとして返すようにします。

HKCollectionQueryを実行するための関数を準備

func fetchStatistics(with identifier: HKQuantityTypeIdentifier,
		 predicate: NSPredicate? = nil,
		 options: HKStatisticsOptions,
		 startDate: Date,
		 endDate: Date = Date(),
		 interval: DateComponents,
		 completion: @escaping (HKStatisticsCollection?, Error?) -> Void) {
guard let quantityType = HKObjectType.quantityType(forIdentifier: identifier) else {
    fatalError("*** Unable to create a step count type ***")
}

let anchorDate = createAnchorDate()

let query = HKStatisticsCollectionQuery(quantityType: quantityType,
					quantitySamplePredicate: predicate,
					options: options,
					anchorDate: anchorDate,
					intervalComponents: interval)

query.initialResultsHandler = { query, results, error in
    completion(results, error)
}

healthStore.execute(query)
}
func createAnchorDate() -> Date {
let calender: Calendar = .current
var anchorComponents = calender.dateComponents([.day, .month, .year, .weekday], from: Date())
let offset = (7 + (anchorComponents.weekday ?? 0) - 2) % 7

anchorComponents.day! -= offset
anchorComponents.hour = 0

let anchorDate = calender.date(from: anchorComponents)!

return anchorDate
}

あらかじめfetchStatisticsを用意しておくことでHKStatisticsCollectionQueryを実行しやすくします。この場合のcreateAnchorDateで作成しているanchorDateはHKStatisticsCollectionQueryを実行するためのものであり先ほどのanchorとは関係がありません。

https://developer.apple.com/documentation/healthkit/hkstatisticscollectionquery

データを取得、保存する関数の準備

データはHealthQuantityEntityとしてFireStoreに保存します

class HealthQuantityEntity: Codable, Identifiable {

    enum HealthQuantityType: String, Codable {
        case step
    }

    var id: String

    var startDate: Date

    var endDate: Date

    var data: Double

    var dataType: HealthQuantityType

    var unit: String

    init(id: String,
         startDate: Date,
         endDate: Date,
         data: Double,
         dataType: HealthQuantityType,
         unit: String) {

        self.id = id
        self.startDate = startDate
        self.endDate = endDate
        self.data = data
        self.dataType = dataType
        self.unit = unit
    }
    
}
func saveSteps(startDate: Date, endDate: Date) -> Future<Void, Error> {

	return Future<Void, Error> { promise in

	    let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate)
	    let dateInterval = DateComponents(day: 1)
	    let statisticsOptions = HKStatisticsOptions.cumulativeSum

	    let initialResultsHundler: (HKStatisticsCollection?, Error?) -> Void = { (statisticsCollection, error) in

		if let error = error {
		    promise(.failure(error))
		    return
		}

		statisticsCollection?.enumerateStatistics(from: startDate, to: endDate) { (statistics, stop) in

		    let stepsId = IdFactory.create()

		    var stepsParams: [String: Any] = [

			"id": stepsId,
			"startDate": Timestamp(date: statistics.startDate),
			"endDate": Timestamp(date: statistics.endDate),
			"data": 0,
			"dataType": HealthQuantityEntity.HealthQuantityType.step.rawValue,
			"unit": "歩"
		    ]

		    let statisticsQuantity = statistics.sumQuantity()

		       let value = statisticsQuantity?.doubleValue(for: .count) {
			stepsParams["data"] = value

			docRef
			    .collection("HealthQuantity")
			    .document(stepsId)
			    .setData(stepsParams)

		    } else {
			promise(.failure(Errors.invalid))
			return
		    }
		}

	    }

	    healthKitSerivice.fetchStatistics(with: HKQuantityTypeIdentifier.stepCount,
					      predicate: predicate,
					      options: statisticsOptions,
					      startDate: startDate,
					      interval: dateInterval,
					      completion: initialResultsHundler)

	    promise(.success(Void()))
	}

}

fetchStatisticsの実行によって返ってきたデータをinitialResultsHundler内のenumerateStatisticsにより1日ごとに合計して結果を返しています。

実行

以上で作成した関数を以下のように呼び出せば、昨日までの歩数の1日ごとの合計を重複なくFireStoreに保存することができます。

requestHealthDataAccessIfNeeded(dataTypes: [HKQuantityTypeIdentifier.stepCount.rawValue]) { (success) in
    if success {

	let now = Date()
	let calender: Calendar = .current

	var endDateComponents = calender.dateComponents([.day, .month, .year, .weekday], from: now)
	endDateComponents.hour = 0
	let endDate = calender.date(from: endDateComponents)!

	self.getAnchor(from: endDate)

    } 
}
func getAnchor(from date: Date) {

	getAnchor(from: date).sink { err in

	    print(err)

	} receiveValue: { startDate in

	    if startDate != date {

		self.saveSteps(startDate: startDate, endDate: date)
		self.saveAnchor(from: date)

	    }

	}.store(in: &cancellables)

}

anchor取得後にsaveStepsにより歩数を保存し、saveAnchorにより最新のanchorを保存します。

func saveSteps(startDate: Date, endDate: Date) {

	saveSteps(startDate: startDate, endDate: endDate).sink { err in
	    print(err)
	} receiveValue: { _ in

	}.store(in: &cancellables)

}
func saveAnchor(from date: Date) {

	saveAnchor(from: date).sink { err in
	    print(err)
	} receiveValue: { _ in

	}.store(in: &cancellables)

}

Discussion

HealthKitを使用して歩数計アプリを作成しようとしているものです。
記事とても参考になりました。ありがとうございます。

1つお聞きしたいことがあります。
HealthKitがどの程度、過去のデータを取得できるのか?がわからないのでデータベースに保存するという方法が良いのかなと思っているのですが、もしご存知であれば過去のデータをどの程度取得できるのかをご教授頂けると助かります。
よろしくお願いいたします。

コメント頂きありがとうございます。
predicateのstartDateを指定すればいつからでもデータがとって来られると思います。
機種変更をした際そのデバイスがバックアップをとっているかどうかわからないため、
いつからデータを記録しているかわからないということですかね?

コメントに返信ありがとうございます。
startDateに日付を指定すれば過去のデータを期限なく取って来られるということですね。

機種変更をした際そのデバイスがバックアップをとっているかどうかわからないため、
いつからデータを記録しているかわからないということですかね?

そうですね。
バックアップを取っていなかった場合過去のデータが消えてしまい、過去のデータを取得出来なくなってしまうのでは?と思い質問させていただきました。

HealthKitからデータをとって来る場合デバイスにある限りのデータを指定期間次第でいつからでも取って来ることができそうです。
FireStoreに保存することでバックアップに依存することなくデータを残すことができますが、
いつ以降のデータを保存するかの指定だけ必要になりますね。
あとはユーザーごとのアカウント作成も必要になってきそうです。

なるほどですね。
大変参考になりました。
丁寧な対応ありがとうございます。

ログインするとコメントできます