HealthKit (8)
HealthData 추가하기
지금까지는 처음에 addSimulator
라는 함수를 사용해서 임의의 데이터를 추가해서 그 데이터를 기반으로 View나 기능을 구현했다면
이제는 유져가 직접 값을 추가하게 만들어 본다.
이전에 만들어둔 HealthDataListView를 조금 더 보완한다.
@Environment(HealthKitManager.self) private var hkManager
환경 변수를 가져온다.
Preview Error Handling
이때 Preview에러가 발생하는데,
1
2
3
4
5
6
#Preview {
NavigationStack {
HealthDataListView(metric: .steps)
.environment(HealthKitManager()) // new
}
}
이렇게 View에 환경변수가 있다는것을 Preview에도 적용을 해줘야한다.
Data Binding
기존에 하드코딩으로 10000으로만 찍히던 값들을 이젠 저장된 데이터를 연동시켜본다.
1
2
3
var listData: [HealthMetric] {
metric == .steps ? hkManager.stepData : hkManager.weightData
}
Computed Property로 값을 만들어주고,
1
2
3
4
5
6
7
List(listData) { data in // modified
HStack {
Text(data.date, format: .dateTime.month(.wide).day().year()) // modified
Spacer()
Text(data.value, format: .number.precision(.fractionLength(metric == .steps ? 0 : 1))) // modified
}
}
이렇게 데이터 바인딩을 해준다.
실행하면
이렇게 값을 가져오게 된다.
이때 지금은 과거순으로 정렬이 되는데
listData.reversed()를 적용하면 배열이 역순이 되면서 최신순으로 정렬이 된다.
사진은 패스.
Health Data 추가 함수 구현
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func addStepData(for date: Date, value: Double) async {
let stepQuantity = HKQuantity(unit: .count(), doubleValue: value)
let stepSample = HKQuantitySample(
type: HKQuantityType(.stepCount),
quantity: stepQuantity,
start: date,
end: date
)
try! await store.save(stepSample)
}
func addWeightData(for date: Date, value: Double) async {
let weightQuantity = HKQuantity(unit: .pound(), doubleValue: value)
let weightSample = HKQuantitySample(
type: HKQuantityType(.bodyMass),
quantity: weightQuantity,
start: date,
end: date
)
try! await store.save(weightSample)
}
딱히 이젠 설명할 부분은 없어보인다.
ListView에 적용
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
ToolbarItem(placement: .topBarTrailing) {
Button("Add Data") {
Task { // new
if metric == .steps {
await hkManager.addStepData(for: addDataDate, value: Double(valueToAdd)!)
await hkManager.fetchStepCount()
isShowingAddData = false
} else {
await hkManager.addWeightData(for: addDataDate, value: Double(valueToAdd)!)
await hkManager.fetchWeights()
await hkManager.fetchWeightsForDifferentials()
isShowingAddData = false
}
}
}
}
이렇게 추가를 해준다.
값을 추가한 뒤 바로 fetch하지 않으면 유저가 데이터가 반영되었는지 확인할 수 없다. 따라서 값을 저장한 뒤 반드시 fetch를 호출해야 한다.
실행하면 데이터 추가 및 Chart에도 반영이 잘 되는걸 확인할 수 있다.
Error Handling
이전까지 코드를 보면 에러 핸들링에 대해선 잠시 후순위로 미루고 기능구현을 위주로 작성했다.
예를 들면 fetch 함수에서도
1
2
3
4
5
6
7
8
do {
let weights = try await weightQuery.result(for: store)
weightDiffData = weights.statistics().map({
.init(date: $0.startDate, value: $0.mostRecentQuantity()?.doubleValue(for: .pound()) ?? 0)
})
} catch {
}
catch 블럭에 대해선 내용을 적지 않았기에 에러가 발생했을때 아무런 대처가 안되는 상황이다.
이제 어느정도 틀이 갖춰졌으니 에러 핸들링을 해보려고 한다.
HKManager Error Handling
1
2
3
4
5
6
7
8
9
10
11
12
13
// example
do {
let stepsCounts = try await stepsQuery.result(for: store)
stepData = stepsCounts.statistics().map({
.init(date: $0.startDate, value: $0.sumQuantity()?.doubleValue(for: .count()) ?? 0)
})
} catch HKError.errorDataSizeExceeded { // new
} catch HKError.errorDatabaseInaccessible { // new
} catch {
}
HealthKit은 HKError를 통해 에러 케이스를 분류 할 수 있다.
HKError Docs 참고.
1. Authorization Error Handling
유져가 앱을 설치하자 마자 실행했을때 Health 관련 연동을 하지 않고, 앱을 재실행했을때 fetch를 하려할떄 발생하는 에러에 대한 핸들링을 진행한다.
let status = store.authorizationStatus(for: HKQuantityType(.stepCount))
이렇게 Auth에 관한 status 변수를 하나 만들어 준다.
지금은 switch case를 통해 status의 3개의 케이스를 구현하지는 않고
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func fetchStepCount() async throws { // modfied → throws added
guard store.authorizationStatus(for: HKQuantityType(.stepCount)) != .notDetermined else {
throw STError.authNotDetermined
}
// 중략
do {
let stepsCounts = try await stepsQuery.result(for: store)
stepData = stepsCounts.statistics().map({
.init(date: $0.startDate, value: $0.sumQuantity()?.doubleValue(for: .count()) ?? 0)
})
} catch HKError.errorNoData { // new
throw STError.noData
} catch {
throw STError.unableToCompleteRequest
}
}
이렇게 구현한다.
throw를 사용한 이유는 여기서 에러를 처리 하지않고 호출하는 쪽에서 처리를 하기위해 에러를 던진다. 라고 생각하면 더 직관적으로 이해가 간다.
그리고 해당 함수를 수정하면서
1
2
3
4
5
enum STError: Error {
case authNotDetermined
case noData
case unableToCompleteRequest
}
enum을 통해 Error를 각 케이스별로 분류를 한다. ST는 Step-tracker의 약자로 사용.
위의 함수를 바탕으로 나머지 fetch 함수도 수정을 한다.
이때 주의할 점이라면
1
2
3
guard store.authorizationStatus(for: HKQuantityType(.bodyMass)) != .notDetermined else {
throw STError.authNotDetermined
}
steps를 제외한 나머지 두 함수는 몸무게에 관한 내용이므로 HKQuantity을 반드시 HKQuantityType(.bodyMass)
로 해줘야한다.
2. add 함수 Error Handling
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
enum STError: Error {
case authNotDetermined
case sharingDenied(quantityType: String) // new
case noData
case unableToCompleteRequest
}
func addStepData(for date: Date, value: Double) async throws {
let status = store.authorizationStatus(for: HKQuantityType(.stepCount))
switch status {
case .notDetermined:
throw STError.authNotDetermined
case .sharingDenied:
throw STError.sharingDenied(quantityType: "step count")
case .sharingAuthorized:
break
@unknown default:
break
}
// 중략
do { // new
try await store.save(stepSample) // modified → ! deleted
} catch {
throw STError.unableToCompleteRequest
}
}
이번엔 status에 대해 각 케이스 별로 핸들링을 한다.
- case .notDetermined:
- HealthKit 권한 상태가 “미결정”인 경우, STError.authNotDetermined를 던짐.
- case .sharingDenied:
- 권한 요청이 “거부됨”인 경우, STError.sharingDenied를 던짐.
- case .sharingAuthorized:
- 권한이 “허용됨” 상태인 경우, break로 switch 블록을 빠져나가고 이후 코드가 계속 실행
addWeightData 함수도 거의 같기에 패스.
Call Site Error Handling
이제 이렇게 함수를 수정 했다면, 해당 함수를 호출하는 쪽에서 에러를 핸들링을 해야한다.
- 에러를 throws를 통해 호출하는 쪽에서 처리하도록 던졌기 때문.
DashBoardView
함수를 바꾸니 발생하는 에러들
1
2
3
4
5
6
7
8
9
10
11
do {
try await hkManager.fetchStepCount()
try await hkManager.fetchWeights()
try await hkManager.fetchWeightsForDifferentials()
} catch STError.authNotDetermined {
} catch STError.noData {
} catch {
}
이렇게 3가지 케이스에 대해 핸들링을 한다.
1. authNotDetermined
해당 에러는 유저의 동의를 얻지 못한 에러이므로 다시 primingView를 띄우면 된다.
DashBoardView
기존에는 AppStorage를 사용했는데, 이제 해당 부분을 지우고 내용을 수정한다.
1
2
3
4
5
6
7
8
9
catch STError.authNotDetermined {
isShowingPermissionPrimingSheet = true
}
.sheet(isPresented: $isShowingPermissionPrimingSheet, onDismiss: {
// fetch health data
}, content: {
HealthKitPermissionPrimingView() // modified
})
PrimingView
hasSeen 변수를 삭제,
onAppear도 삭제.
2, 3 case 같이 해결
alert
를 사용하여 에러를 유져에게 직접 보여주는 방식으로 한다.
이떄 Error가 Error프로토콜이 아닌 LocalizedError 프로토콜을 따르므로, LocalizedError로 바꿔준다.
해당 프로토콜은 4가지 변수에 대해서 이미 디폴트값이 제공되어있다고 한다.
우리는 errorDescription, failureReason 이 두개만 커스터 마이징을 한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
enum STError: LocalizedError {
// 중략
var errorDescription: String? {
switch self {
case .authNotDetermined:
"Need Access to Health Data"
case .sharingDenied(_):
"No Write Access"
case .noData:
"No Data"
case .unableToCompleteRequest:
"Unable to Complete Request"
}
}
var failureReason: String {
switch self {
case .authNotDetermined:
"You have not given access to your Health data. Please go to Settings > Health > Data Access & Devices."
case .sharingDenied(let quantityType):
"You have denied access to upload your \(quantityType) data.\n\nYou can change this in Settings > Health > Data Access & Devices."
case .noData:
"There is no data for this Health statistic."
case .unableToCompleteRequest:
"We are unable to complete your request at this time.\n\nPlease try again later or contact support."
}
}
}
이렇게 설정한걸 alert
Modifier를 통해 적용을 해본다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
@State private var isShowingAlert = false
@State private var fetchError: STError = .noData
.task {
do {
try await hkManager.fetchStepCount()
try await hkManager.fetchWeights()
try await hkManager.fetchWeightsForDifferentials()
} catch STError.authNotDetermined {
isShowingPermissionPrimingSheet = true
} catch STError.noData { // modified
fetchError = .noData
isShowingAlert = true
} catch { // modified
fetchError = .unableToCompleteRequest
isShowingAlert = true
}
}
.navigationTitle("Dashboard")
.navigationDestination(for: HealthMetricContext.self) { metric in
HealthDataListView(isShowingPermissionPriming: $isShowingPermissionPrimingSheet, metric: metric)
}
.sheet(isPresented: $isShowingPermissionPrimingSheet, onDismiss: {
// fetch health data
}, content: {
HealthKitPermissionPrimingView()
})
.alert(isPresented: $isShowingAlert, error: fetchError) { fetchError in // new
// action
} message: { fetchError in
Text(fetchError.failureReason)
}
예전에 여러 Alert를 처리할때 사용한 방식이다.
이전글의 10.보완에서 해당 부분에 대해 언급을 한다.
action 주석쪽에 아무것도 적지 않으면 Default로 Ok버튼 하나만 생긴다.
테스트를 해보기 위해
1
2
3
4
func fetchStepCount() async throws {
throw STError.noData
// 후략
}
함수가 시작되자마자 에러를 던지게 해본다.
이렇게 에러가 뜨고 title, message는 우리가 설정해둔그대로 적용되는걸 알 수 있다.
DataListView
여기도 똑같이 에러가 발생하니 핸들링을 해보도록 하자.
1
2
3
4
5
6
7
8
9
10
11
do {
try await hkManager.addStepData(for: addDataDate, value: Double(valueToAdd)!)
try await hkManager.fetchStepCount()
isShowingAddData = false
} catch STError.authNotDetermined {
} catch STError.sharingDenied(let quantity) {
} catch {
}
이것도 3가지 케이스에 대해 핸들링을 한다.
1. authNotDetermined
@Binding var isShowingPermissionPriming: Bool
바인딩 변수를 하나 만들어 주고,
1
2
3
catch STError.authNotDetermined {
isShowingPermissionPriming = true
}
이렇게 true로 바꾼다.
그러면 DashBoardView에서 Missing 에러가 발생하고
1
2
3
.navigationDestination(for: HealthMetricContext.self) { metric in
HealthDataListView(isShowingPermissionPriming: $isShowingPermissionPrimingSheet, metric: metric) // modified
}
이렇게 해주자.
앱을 삭제하고 재실행하게 되면 out of range 에러가 발생하는데 averageDailyWeightDiffs
여기서 발생한다.
왜냐하면
1
2
3
4
5
for i in 1 ..< weights.count { // here
let date = weights[i].date
let diff = weights[i].value - weights[i-1].value
diffValues.append((date: date, value: diff))
}
처음에 데이터가 아무것도 없을때는 배열에 아무것도 없는데 index를 1부터 하려고하니 에러가 발생하는 것이다.
이부분도 예외처리를 한다.
그냥 for문 앞에 guard weights.count > 1 else { return [] }
이걸 추가해준다.
2, 3 case 같이 해결
이부분 역시 위의 DashBoardView에서 했던 방식과 크게 차이가 없다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@State private var isShowingAlert = false
@State private var writeError: STError = .noData
.navigationTitle(metric.title)
.alert(isPresented: $isShowingAlert, error: writeError, actions: { writeError in // new
// action
}, message: { writeError in
Text(writeError.failureReason)
})
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Add Data") {
Task {
if metric == .steps {
do {
try await hkManager.addStepData(for: addDataDate, value: Double(valueToAdd)!)
try await hkManager.fetchStepCount()
isShowingAddData = false
} catch STError.authNotDetermined {
isShowingPermissionPriming = true
} catch STError.sharingDenied(let quantityType) { // modified
writeError = .sharingDenied(quantityType: quantityType)
isShowingAlert = true
} catch { // modified
writeError = .unableToCompleteRequest
isShowingAlert = true
}
이렇게 동일하게 해준다.
단지 하나 차이점이 있다면, sharingDenied일때 Device의 Setting으로 화면 전환을 하여, Setting에서 권한설정을 다시 하도록 유도를 해본다.
1
2
3
4
5
6
7
8
9
10
11
.alert(isPresented: $isShowingAlert, error: writeError, actions: { writeError in
switch writeError { // new
case .authNotDetermined, .noData, .unableToCompleteRequest:
EmptyView()
case .sharingDenied:
Button("Settings") {
UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!)
}
Button("Cancel", role: .cancel) { }
}
}
위의 3가지 경우에 대해선 EmptyView라 어떠한 화면도 나오지 않는다. 한번 에러를 또 던져서 확인을 해보자.
이번엔 값을 추가하는 ListView에서 한 핸들링이므로 에러를 던지는것도 addStepData
여기에 해야한다.
이렇게 차이가 있는걸 알 수 있다.
여담으로 권한을 바꾸는 방법은
여기에 있다.
Github: Step-Tracker Repository