포스트

HealthKit (11)

코드 리팩토링

이어서 진행을 하도록 한다.

ChartContainer config 객체 도입

1
2
3
4
5
6
7
8
struct ChartContainer<Content: View>: View {
    let title: String
    let symbol: String
    let subtitle: String
    let context: HealthMetricContext
    let isNav: Bool

    // 후략

ChartContainer의 변수는 이렇게 설정이 되어있었다.

이부분에 있는 변수를 새로운 Structure를 사용해서 옮겨주고 ChartContainer에서는 해당 구조체를 가져와서 사용하는 걸로 바꿔본다.

1
2
3
4
5
6
7
8
9
10
11
struct ChartContainerConfiguration {
    let title: String
    let symbol: String
    let subtitle: String
    let context: HealthMetricContext
    let isNav: Bool
}

struct ChartContainer<Content: View>: View {
    let config: ChartContainerConfiguration
    // 후략

이후 title이런 것들은 config.title로 바꿔주자.

그러면 ChartContainer를 사용하던 View들이 에러가 당연히 발생하고

이런식으로 수정을 하자

1
2
3
4
5
6
7
let config = ChartContainerConfiguration(title: "Weight",
                                            symbol: "figure",
                                            subtitle: "Per Weekday (Last 28 Days)",
                                            context: .weight,
                                            isNav: false)

ChartContainer(config: config) { // 이후 생략

ChartAnnotationView RuleMark 추가

1
2
3
4
5
6
7
8
RuleMark(x: .value("Selected Metric", selectedData.date, unit: .day))
            .foregroundStyle(Color.secondary.opacity(0.3))
            .offset(y: -10)
            .annotation(position: .top,
                        spacing: 0,
                        overflowResolution: .init(x: .fit(to: .chart), y: .disabled)) {
                ChartAnnotationView(data: selectedData, context: .weight)
            }

이부분의 코드도 계속 중복적으로 사용이 되는 부분이기에 여기서 Annotationview를 추가하기에 우리가 만든 ChartAnnotationView에 추가를 하여 내용을 수정한다.

이때 RuleMark의 경우 ChartContent 프로토콜을 따르기에 View를 ChartContent로 반드시 바꿔주어야한다.

이부분은 코드 전체를 가져왔다.

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
34
35
import Charts // new 

struct ChartAnnotationView: ChartContent { // modified View to ChartContent
    let data: DateValueChartData
    let context: HealthMetricContext
    
    var body: some ChartContent {  // modified View to ChartContent
        RuleMark(x: .value("Selected Metric", data.date, unit: .day))
            .foregroundStyle(Color.secondary.opacity(0.3))
            .offset(y: -10)
            .annotation(position: .top,
                        spacing: 0,
                        overflowResolution: .init(x: .fit(to: .chart), y: .disabled)) {
                annotationView
            }
    }
    
    var annotationView: some View { // moved and new
        VStack(alignment: .leading) {
            Text(data.date, format: .dateTime.weekday(.abbreviated).month(.abbreviated).day())
                .font(.footnote.bold())
                .foregroundStyle(.secondary)
            
            Text(data.value, format: .number.precision(.fractionLength(context == .steps ? 0 : 1)))
                .fontWeight(.heavy)
                .foregroundStyle(context == .steps ? .pink : .indigo)
        }
        .padding(12)
        .background {
            RoundedRectangle(cornerRadius: 4)
                .fill(Color(.secondarySystemBackground))
                .shadow(color: .secondary.opacity(0.3), radius: 2, x: 2, y: 2)
        }
    }
}

기존에 ChartAnnotationView의 UI 구성은 이전에 했던 방식으로 다시 var annotationView를 통해 내용을 이관해준다.

그리고 이런식으로 코드를 줄여준다.

1
2
3
4
5
// StepBarChartView
Chart {
    if let selectedData {
        ChartAnnotationView(data: selectedData, context: .steps)
    }

이렇게 리팩토링을 하면 반드시 기능테스트를 하나하나 다시 꼼꼼하게 확인 해보자.

Error 분리

HealthKitManager에 있던 Error도 별도의 STError 파일을 만들어 거기에 이관을 해준다.

Fetch 함수 리팩토링

fetchStepCount

1
2
3
4
5
6
7
8
9
func fetchStepCount() async throws -> [HealthMetric] { // modified
    // 생략
        do {
            let stepsCounts = try await stepsQuery.result(for: store)
            return stepsCounts.statistics().map({ // modified
                .init(date: $0.startDate, value: $0.sumQuantity()?.doubleValue(for: .count()) ?? 0)
            })
        } 
    }

코드는 변경된 부분만 적는다.

fetchWeights

현재 fetchWeightsForDifferentials와 fetchWeights 의 차이는

let startDate = calendar.date(byAdding: .day, value: -29, to: endDate)에서 value가 -28이냐 -29이냐의 차이밖에 없다.

그래서 fetchWeightsForDifferentials를 지워준다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func fetchWeights(daysBack: Int) async throws -> [HealthMetric] {  // modified
    // 생략

    let startDate = calendar.date(byAdding: .day, value: -daysBack, to: endDate)  // modified
    
    // 생략
    
    do {
        let weights = try await weightQuery.result(for: store)
        return weights.statistics().map({  // modified
            .init(date: $0.startDate, value: $0.mostRecentQuantity()?.doubleValue(for: .pound()) ?? 0)
        })
    } 
}

이것도 코드는 변경된 부분만 적는다.

createDateInterval 함수 생성

현재 fetch함수에 중복되는것이

1
2
3
4
let calendar = Calendar.current
let today = calendar.startOfDay(for: .now)
let endDate = calendar.date(byAdding: .day, value: 1, to: today)!
let startDate = calendar.date(byAdding: .day, value: -28, to: endDate)

이런 날짜와 관련된 부분들이다. 이것도 별도로 함수를 통해 관리를 하도록 한다.

1
2
3
4
5
6
7
private func createDateInterval(from date: Date, daysBack: Int) -> DateInterval {
    let calendar = Calendar.current
    let startOfEndDate = calendar.startOfDay(for: date)
    let endDate = calendar.date(byAdding: .day, value: 1, to: startOfEndDate)!
    let startDate = calendar.date(byAdding: .day, value: -daysBack, to: endDate)!
    return .init(start: startDate, end: endDate)
}

위의 코드를 그대로 가져오되 리턴을 DateInterval type으로 하게 한다.

이후 fetch 함수에서 날짜와 관련된 코드를 전부 지우고

1
2
3
4
5
6
7
8
let interval = createDateInterval(from: .now, daysBack: 28) // new
let queryPredicate = HKQuery.predicateForSamples(withStart: interval.start, end: interval.end) // modified
let samplePredicate = HKSamplePredicate.quantitySample(type: HKQuantityType(.stepCount), predicate: queryPredicate)

let stepsQuery = HKStatisticsCollectionQueryDescriptor(predicate: samplePredicate,
                                                        options: .cumulativeSum,
                                                        anchorDate: interval.end, // modified
                                                        intervalComponents: .init(day: 1)

이런식으로 해당부분을 수정을 해주자.

수정한 Fetch 함수 적용.

기존에 잘되던 Fetch함수를 굳이 Return type을 사용하여 리턴을 하는 첫번쨰 이유는,

현재 코드를 보면

1
2
3
try await hkManager.fetchStepCount()
try await hkManager.fetchWeights()
try await hkManager.fetchWeightsForDifferentials()

순서대로 fetch를 진행한다. 데이터가 적을때는 fetch하는데 시간이 얼마 소요가 안될지 몰라도 값이 많아지면 달라지기에 동시에 작업을 하려고 한다.

그리고 두번째 이유는 값을 가져와서 UI에 사용되는 배열에 담는 과정까지 전부 fetch에서 담당하고 있었다.

fetch함수가 데이터 로드, UI구성 두 기능을 담당하고 있기에 이것을 기능 분리를 해주는 것이다.

1
2
3
4
5
6
7
8
9
// fetch를 동시에 실행
async let steps = hkManager.fetchStepCount()
async let weightsForLineChart = hkManager.fetchWeights(daysBack: 28)
async let weightsForDiffBarChart = hkManager.fetchWeights(daysBack: 29)

// UI Update
hkManager.stepData = try await steps
hkManager.weightData = try await weightsForLineChart
hkManager.weightDiffData = try await weightsForDiffBarChart

async let을 사용. 이부분은 이전글에 언급을 한적이 있으니 참고.

이것마저도 함수를 새롭게 만들어 view내부의 코드를 간소화 한다.

Swift Docs: Concurrency도 참고해보자.

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
private func fetchHealthData() {
    Task {
        do {
            async let steps = hkManager.fetchStepCount()
            async let weightsForLineChart = hkManager.fetchWeights(daysBack: 28)
            async let weightsForDiffBarChart = hkManager.fetchWeights(daysBack: 29)
            
            hkManager.stepData = try await steps
            hkManager.weightData = try await weightsForLineChart
            hkManager.weightDiffData = try await weightsForDiffBarChart
        } catch STError.authNotDetermined {
            isShowingPermissionPrimingSheet = true
        } catch STError.noData {
            fetchError = .noData
            isShowingAlert = true
        } catch {
            fetchError = .unableToCompleteRequest
            isShowingAlert = true
        }
    }
}

.task {
    fetchHealthData() // modified
}

이제 초창기에 만들고 구현을 하지 않았던,

1
2
3
4
.sheet(isPresented: $isShowingPermissionPrimingSheet, onDismiss: {
    // fetch health data
    fetchHealthData() // new
}

이부분에 위에서 만든 fetch 함수를 적용하자.

유저의 동의를 받고서 바로 데이터를 가져오는 작업을 실시, 왜냐면 걸음수나 체중이 이전에 Health App에 데이터가 있을 수 있기 때문이다.

ListDataView 리팩토링

현재 Task에서 관련 에러가 발생하고 있지만 코드를 자세히보면

중복되는 부분이 상당히 많다.

이건 관련된 코드 전체를 가져와본다

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
Task {
    if metric == .steps {
        do {
            try await hkManager.addStepData(for: addDataDate, value: value)
            try await hkManager.fetchStepCount()
            isShowingAddData = false
        } catch STError.authNotDetermined {
            isShowingPermissionPriming = true
        } catch STError.sharingDenied(let quantityType) {
            writeError = .sharingDenied(quantityType: quantityType)
            isShowingAlert = true
        } catch {
            writeError = .unableToCompleteRequest
            isShowingAlert = true
        }
        
    } else {
        do {
            try await hkManager.addWeightData(for: addDataDate, value: value)
            try await hkManager.fetchWeights()
            try await hkManager.fetchWeightsForDifferentials()
            isShowingAddData = false
        } catch STError.authNotDetermined {
            isShowingPermissionPriming = true
        } catch STError.sharingDenied(let quantityType) {
            writeError = .sharingDenied(quantityType: quantityType)
            isShowingAlert = true
        } catch {
            writeError = .unableToCompleteRequest
            isShowingAlert = true
        }
    }
}

똑같은 에러 핸들링 거의 같은 fetch 함수… 코드 구성이 상당히 비효율적이다.

에러를 해결하기 앞서 중복코드를 제거하여 조금 더 깔끔하게 다듬어 본다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
do {
    if metric == .steps {
        async let steps = hkManager.fetchStepCount()
        
        hkManager.stepData = try await steps
    } else {
        try await hkManager.addWeightData(for: addDataDate, value: value)
        async let weightsForLineChart = hkManager.fetchWeights(daysBack: 28)
        async let weightsForDiffBarChart = hkManager.fetchWeights(daysBack: 29)
        
        hkManager.weightData = try await weightsForLineChart
        hkManager.weightDiffData = try await weightsForDiffBarChart
    }

    isShowingAddData = false
} catch STError.authNotDetermined {
    isShowingPermissionPriming = true
} catch STError.sharingDenied(let quantityType) {
    writeError = .sharingDenied(quantityType: quantityType)
    isShowingAlert = true
} catch {
    writeError = .unableToCompleteRequest
    isShowingAlert = true
}

이렇게 다듬어 준다.

그리고 이부분역시 button에 너무 내용이 길기에 내용을 복사해서

1
2
3
4
5
6
7
8
9
private func addDataToHealthKit() {
    // 여기에 적용
}

ToolbarItem(placement: .topBarTrailing) {
    Button("Add Data") {
        addDataToHealthKit() // modified
    }
}

이렇게 버튼에 대해서 코드도 간소화 해주자.

추가로 생각해볼 만한 내용.

현재 이렇게 숫자만 하나 더 추가된걸 굳이 함수를 두번 호출하고 있다.

1
2
async let weightsForLineChart = hkManager.fetchWeights(daysBack: 28)
async let weightsForDiffBarChart = hkManager.fetchWeights(daysBack: 29)

이걸 함수 한번만 호출하여 두개의 값에 넣을 수 있는 방법은 없을까? 고민을 해보면 좋을 듯 하다.

이부분은 이후에 서술 하는걸로.

실행을 해보니 잘 돌아간다.


Github: Step-Tracker Repository

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.