【SwiftUI 】HealthKitのデータをFireStoreに保存する方法
HealthKitの導入に関しては以下の記事を参考にさせて頂きました
HealthStoreのデータを個人で扱う場合はわざわざデータを他のデータベースに保存する必要はありませんが、
他の人に共有したい場合はその必要が出てきます。
今回はHealthKitのデータをFirestoreに保存する方法に簡単にまとめてみます。
保存するデータの種類は昨日分までの歩数の1日ごとの合計とさせて頂きます。
- 権限のリクエスト
- Anchorを用いた期間指定
- HKCollectionQueryを用いたデータの取得、保存
- 実行
権限のリクエスト
機能の拡張性を視野に入れ、
- どの
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
}
今回は歩数
を扱うためtypeIdentifiers
にHKQuantityTypeIdentifier.stepCount.rawValue
を格納します。
typeIdentifiers
に基づいてgetSapmleType
でHKSampleType
を取得します。
扱うデータに応じた書き込み権限、読み取り権限のリクエストをする関数を準備
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
を実行する際のstartDate
をanchor
として取得することができます。
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
とは関係がありません。
データを取得、保存する関数の準備
データは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に保存することでバックアップに依存することなくデータを残すことができますが、
いつ以降のデータを保存するかの指定だけ必要になりますね。
あとはユーザーごとのアカウント作成も必要になってきそうです。
なるほどですね。
大変参考になりました。
丁寧な対応ありがとうございます。