포스트

HealthKit (10)

코드 리팩토링

여태까지 만든 코드를 리팩토링을 하는 과정을 해보려한다.

refactoring은 소프트웨어 공학에서 ‘결과의 변경 없이 코드의 구조를 재조정함’을 뜻한다. 주로 가독성을 높이고 유지보수를 편하게 한다. 버그를 없애거나 새로운 기능을 추가하는 행위는 아니다. 사용자가 보는 외부 화면은 그대로 두면서 내부 논리나 구조를 바꾸고 개선하는 유지보수 행위이다. from Wiki

강의에서는 Refactoring을 이해하기 쉽고, 사용하기 쉽고, 읽기 쉽게 하기 위한 과정이라고 한다. 단순히 코드를 줄이는 것이 전부가 아니라는 것.

ChartContainer

현재 앱을 보게되면 Step, weight 이렇게 두개의 Category에 각각 2개의 차트를 가지고 있다.

그래서 현재는 4개의 ChartView에 각각 Chart가 들어가 있는 상태,

그리고 UI도 보면 둘이 상당히 구조가 비슷하다.

simulator_screenshot_4714BDE9-902A-43B6-B35B-3201EE78A28Csimulator_screenshot_F493EDAC-A610-46F5-A30D-7419EEDDA812

둘의 UI는 거의 같다고 해도 무방.

그래서 ChartContainer라는 View를 만들어서 여기서 Chart를 관리 한다면, 4개의 ChartView의 코드도 줄어들 뿐만 아니라, 가독성도 같이 올라가게 된다.

기존의 ChartView에서 Vstack에 해당하는 부분만 가져왔다.

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
let title: String
let symbol: String
let subtitle: String
let context: HealthMetricContext
let isNav: Bool

var body: some View {
    VStack {
        NavigationLink(value: context) {
            HStack {
                VStack(alignment: .leading) {
                    Label(title, systemImage: symbol)
                        .font(.title3.bold())
                        .foregroundStyle(context == .steps ? .pink : .indigo)
                    
                    Text(subtitle)
                        .font(.caption)
                }
                
                Spacer()
                
                Image(systemName: "chevron.right")
            }
        }
        .foregroundStyle(.secondary)
        .padding(.bottom, 12)
    }
    .padding()
    .background(RoundedRectangle(cornerRadius: 12).fill(Color(.secondarySystemBackground)))
}

preview에는 샘플 데이터를 입력.

CleanShot 2024-12-17 at 05 26 58

현재 이렇게 UI가 만들어 졌다.

ViewBuilder

ViewBuilder Docs를 읽어보자.

View Builder에 대해 정리를 하면

ViewBuilder는 SwiftUI에서 도입된 Swift 속성(Attribute) 이다. 이 속성은 클로저 기반 메커니즘으로, 여러 자식 뷰를 조합하여 하나의 부모 뷰로 구성할 수 있게 한다. 이를 통해 동적이고 유연한 UI를 간단히 생성할 수 있다.

  • ViewBuilder를 사용하면 뷰 계층 구조를 유연하게 정의할 수 있다.
  • 동적 뷰 생성이 가능하며, 코드를 더욱 깔끔하고 읽기 쉽게 만든다.

출처

ViewBuilder는 우리가 SwiftUI를 하면서 계속 은연중에 사용을 했는데, 바로 V,HStack에도 ViewBuilder가 있다는 것이다.

CleanShot 2024-12-17 at 05 51 14

해당부분을 보면 VStack은 Generic으로 Content라는 물론 Content는 뒤에 where Content : View를 통해 Content는 반드시 View 프로토콜을 준수해야한다.

1
2
3
4
struct ChartContainer<Content: View>: View { 
}
struct ChartContainer<Content>: View where Content : View {
}

두개는 같고 표현방식의 차이이다.

CleanShot 2024-12-17 at 05 52 43

우리는 이런식으로 Vstack을 만들고 엔터를 쳐서 자연스럽게 Closure의 형태로 바꿔서 계속 ViewBuilder를 사용했던것.

적용하기

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
struct ChartContainer<Content: View>: View { // modified
    
    let title: String
    let symbol: String
    let subtitle: String
    let context: HealthMetricContext
    let isNav: Bool
    
    @ViewBuilder var content: () -> Content // new
    
    var body: some View {
        VStack {
            NavigationLink(value: context) {
                HStack {
                   // 중략
                }
            }
            .foregroundStyle(.secondary)
            .padding(.bottom, 12)
            
            content() // new
        }
        .padding()
        .background(RoundedRectangle(cornerRadius: 12).fill(Color(.secondarySystemBackground)))
    }
}

위와같이 코드를 수정해 주었다.

preview에 적용을 해보았는데, ChartContainer를 호출하는 쪽에서는

CleanShot 2024-12-17 at 05 54 49

이렇게 해서 사용하고 content는 Closure로 전환하여 하위 View를 추가한다.

1
2
3
4
ChartContainer(title: "Test title", symbol: "figure.walk", subtitle: "Test Subtitle", context: .steps, isNav: true) {
    Text("Chart goes here")
        .frame(minHeight: 150)
}

PreView용 코드를 위와같이 작성하였고 그 결과는 아래와 같은 화면이 출력된다.

CleanShot 2024-12-17 at 05 29 15

동적 동작 분리와 View 세분화

ChartContainer에서 isNav 값을 기준으로 두 가지 동작(네비게이션 링크 또는 일반 타이틀 뷰)을 분리하여 UI를 세분화하였다. 이를 통해 코드 재사용성을 높이고, 가독성을 향상시킬 수 있다.

isNav를 활용한 동적 동작 관리

isNav는 ChartContainer의 동작을 결정하는 플래그 값이다. true일 경우 네비게이션 링크를 사용해 화면 전환 기능을 제공하고, false일 경우 단순한 타이틀 뷰를 표시한다.

CleanShot 2024-12-17 at 06 12 24

이해를 돕기위해 사진 첨부. 물론 우리는 UI를 디자인할떄 chevron.right라는 이미지를 사용해서 해당 뷰 를 클릭하면 뭐가있을지 사용자로 하여금 클릭을하게 유도를 하고있다.

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
36
var body: some View {
        VStack(alignment: .leading) {
            if isNav {
                navigationLinkView
            } else {
                titleView
            }
            content()
        }
        .padding()
        .background(RoundedRectangle(cornerRadius: 12).fill(Color(.secondarySystemBackground)))
        
    }
    
    var navigationLinkView: some View {
        NavigationLink(value: context) {
            HStack {
                titleView
                Spacer()
                Image(systemName: "chevron.right")
            }
        }
        .foregroundStyle(.secondary)
        .padding(.bottom, 12)
    }

    var titleView: some View {
        VStack(alignment: .leading) {
            Label(title, systemImage: symbol)
                .font(.title3.bold())
                .foregroundStyle(context == .steps ? .pink : .indigo)
            
            Text(subtitle)
                .font(.caption)
        }
    }
  • navigationLinkView: 사용자가 클릭하면 화면 전환이 가능한 UI를 제공.
  • titleView: 단순히 타이틀과 서브타이틀을 보여주는 UI 구성.
View 세분화의 이점

View 세분화를 통해 다음과 같은 개선 효과를 얻었다.

  1. 가독성 향상 navigationLinkView와 titleView를 명확히 분리하여, 각 뷰가 어떤 역할을 하는지 한눈에 파악할 수 있다.
  2. 코드 재사용성 증가 뷰를 세분화함으로써, 동일한 타이틀 구조를 다른 UI에서도 쉽게 재사용 가능하다.
  3. 유지보수 용이성 특정 뷰의 디자인 변경이 필요할 경우, 해당 뷰만 수정하면 전체에 반영된다.

ChartContainer 사용해보기

1
2
3
4
5
6
7
8
9
var body: some View {
        ChartContainer(title: "Steps",
                       symbol: "figure.walk",
                       subtitle: "Avg: \(Int(averageStepCount)) steps",
                       context: .steps,
                       isNav: true
        ) {
            if chartData.isEmpty {
                // 생략

기존에 VStack 있던 부분 대신 ChartContainer로 대신한다.

1
2
.padding()
.background(RoundedRectangle(cornerRadius: 12).fill(Color(.secondarySystemBackground)))

background에 대한 설정이 이제 필요없으니 전부 지워준다.

왜냐 이미 ChartContainer에서 해당 부분이 포함이 되어있기 때문이다.

이렇게 다른 ChartView들도 똑같이 적용 해보도록 하자.

실행해도 결과는 우리가 아는 앱실행 화면 그대로니 패스.

Structure 단일화

헤더를 위와같이 적었는데, 무슨말이냐면

StepBarChart, WeightLineChart에서는 chartData의 타입이 HealthMetric StepPieChart, WeightDiffBarChart에서는 chartData 타입이 WeekdayChartData 이렇게 되어있다.

간단히 표로 정리하면 아래와 같다.

차트 이름chartData 타입
StepBarChartHealthMetric
WeightLineChartHealthMetric
StepPieChartWeekdayChartData
WeightDiffBarChartWeekdayChartData

그런데 아이러니하게도 두 sturcture의 내부구조는 같다.

불필요하기도하고, 헷갈릴 수 있으니 하나로 단일화를 진행한다.

기존에 WeekdayChartData 로 했던 것을

1
2
3
struct DateValueChartData: Identifiable, Equatable { // Rename
    // 생략
}

이렇게 변경을 해준다. (Refactor → Rename 사용을 권고)

그리고 ChartView에서 HealthMetric을 사용하던 것을 DateValueChartData로 타입을 바꿔준다.

바꾸면서 selectedHealthMetric으로 변수를 사용하던 것도

1
2
3
var selectedData: DateValueChartData? {
    // 생략
    }

selectedData로 변수명을 통일해준다.

CleanShot 2024-12-17 at 07 19 15

이렇게 에러가 발생.

ChartHelper

현재 hkManager에서 적용하는 변수들은

1
2
3
var stepData: [HealthMetric] = []
var weightData: [HealthMetric] = []
var weightDiffData: [HealthMetric] = []

HealthMetric의 타입을 가진다. DateValueChartData은 Chart를 위한 타입, HealthMetric은 fetch를 하여 가져온 값을 저장하는 목적으로, 두개가 구조체 이름만다르고 내부는 같다고 하여도, 생성 목적이 다르기에 이부분은 바꾸지 않고 그대로 두되, 형변환을 해주는 Helper를 하나 만들어 본다.

1
2
3
4
5
struct ChartHelper {
    static func convert(data: [HealthMetric]) -> [DateValueChartData] {
        data.map { .init(date: $0.date, value: $0.value) }
    }
}

내용은 간단하다. 이전에도 했던 방식으로, map을 사용하여 형변환을 해준다. 링크를 걸었으니 혹시나 기억이 안나면, 다시 읽어보자.

1
2
case .steps:
    StepBarChartView(selectedStat: selectedStat, chartData: ChartHelper.convert(data: hkManager.stepData))

에러가 발생한 부분을 이렇게 Helper를 통해 convert해주면 된다.

AnnotationView

지금 각 ChartView에 AnnotationView가 있다. 이부분도 View File을 새로 만들어 별도로 관리를 하게하자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
struct ChartAnnotationView: View {
    let data: DateValueChartData
    let context: HealthMetricContext
    
    var body: some View {
        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)
        }
    }
}

이렇게 새로 만들어 준다.

그리고 이제 annotationview 변수를 제거하고 해당 변수를 호출하던 자리에 ChartAnnotationview를 사용하자.

적용 예시:

1
2
3
4
5
.annotation(position: .top,
            spacing: 0,
            overflowResolution: .init(x: .fit(to: .chart), y: .disabled)) {
    ChartAnnotationView(data: selectedData, context: .steps)
}

ChartHelper 보완

ChartView들이 Computed Property를 가지고 있던 부분을 Helper에서 처리하도록 한다.

1
2
3
4
5
6
7
8
9
10
11
12
static func averageValue(for data: [DateValueChartData]) -> Double {
    guard !data.isEmpty else { return 0 }
    let totalSteps = data.reduce(0) { $0 + $1.value }
    return totalSteps/Double(data.count)
}

static func parseSelectedData(from data: [DateValueChartData], in selectedDate: Date?) -> DateValueChartData? {
    guard let selectedDate else { return nil }
    return data.first {
        Calendar.current.isDate(selectedDate, inSameDayAs: $0.date)
    }
}

그리고 다음과 같이 적용한다.

1
2
3
4
5
6
7
8
9
10
11
// StepBarChartView
var selectedData: DateValueChartData? {
        ChartHelper.parseSelectedData(from: chartData, in: rawSelectedDate) // modified
    }

var body: some View {
        ChartContainer(title: "Steps",
                       symbol: "figure.walk",
                       subtitle: "Avg: \(Int(ChartHelper.averageValue(for: chartData))) steps", // modified
                       context: .steps,
                       isNav: true

이런식으로 관련부분을 수정해준다.

그리고 selectedStat도 이제 필요가 없다. 왜냐면 ChartContainer에서 이미 각각에 대해 적용을 하기 때문.(해당내용은 StepBarChart, WeigtLineChart에만 해당)

실행해서 작동이 잘 되는지 확인을 하자.

내용이 길어질듯 해서 다음글에서 계속…


Github: Step-Tracker Repository

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