HealthKit (Fin)
iOS 18 적용
Swift6 Concurrency
지금 Xcode에서 사용하는 Swift Version은 5이지만, Swift6가 최근 발표가 되었기에 사용을 해본다.
Swift 버전설정은 위의 사진을 참고!
문제
버전을 바꾸자 에러가 발생
해당 코드만 보면
1
2
3
4
5
6
7
8
9
// Background thread
async let steps = hkManager.fetchStepCount()
async let weightsForLineChart = hkManager.fetchWeights(daysBack: 28)
async let weightsForDiffBarChart = hkManager.fetchWeights(daysBack: 29)
// Main thread
hkManager.stepData = try await steps
hkManager.weightData = try await weightsForLineChart
hkManager.weightDiffData = try await weightsForDiffBarChart
주석을 달았는데, 해당 코드들이 실행되는 thread 이다.
print를 통해 출력 해보면
1
2
<NSThread: 0x60000049e000>{number = 4, name = (null)}
<_NSMainThread: 0x6000004c80c0>{number = 1, name = main}
이렇게 된다.
fetch함수내부에
print(Thread.current)
를 통해 함수가 어떤 Thread에서 작업이 되는지 확인이 가능.
해결방법
HealthKitManager가 Sendable Protocol을 따라야 한다.
1
2
final class HealthKitManager: Sendable { // modified
}
이떄 Docs에 보면 Sendable 프로토콜을 따르는 class는 final class이어야 한다고 명시되어있다.
그리고 Sendable 클래스 안의 변수들은 immutable이어야 한다.
별도로 내부 변수를 관리할 클래스를 하나 더 만든다.
1
2
3
4
5
6
7
@Observable
@MainActor
final class HealthKitData {
var stepData: [HealthMetric] = []
var weightData: [HealthMetric] = []
var weightDiffData: [HealthMetric] = []
}
Sendable 프로토콜의 정책 따라 내부 변수는 immutable이어야 했지만, @MainActor를 통해 mutable 상태를 관리할 수 있도록 HealthKitData 클래스를 분리한다.
Classes marked with @MainActor are implicitly sendable, because the main actor coordinates all access to its state. These classes can have stored properties that are mutable and nonsendable.
@MainActor가 있는 class는 암묵적으로 Sendable이다. 왜냐하면 메인 액터가 해당 클래스의 모든 상태 접근을 조정하기 때문. 이러한 클래스는 mutable 및 nonsendable 저장 프로퍼티를 가질 수 있다.
그래서 HealthKitData의 변수가 mutable임에도 불구하고 사용이 가능했던것.
이제 적용을 해본다. (Dashboard와 DataListView에서 hkData로 바꾸는것은 동일하기에 listview 코드는 생략)
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
// App
let hkData = HealthKitData() // new
let hkManager = HealthKitManager()
var body: some Scene {
WindowGroup {
DashboardView()
.environment(hkData) // new
.environment(hkManager)
}
}
// Dashboard
@Environment(HealthKitData.self) private var hkData
switch selectedStat {
case .steps:
StepBarChartView(chartData: ChartHelper.convert(data: hkData.stepData)) // modified
StepPieChartView(chartData: ChartHelper.averageWeekdayCount(for: hkData.stepData)) // modified
case .weight:
WeightLineChartView(chartData: ChartHelper.convert(data: hkData.weightData)) // modified
WeightDiffBarChartView(chartData: ChartHelper.averageDailyWeightDiffs(for: hkData.weightDiffData)) // modified
}
hkData.stepData = try await steps
hkData.weightData = try await weightsForLineChart
hkData.weightDiffData = try await weightsForDiffBarChart
primingView에서는 아래와 같은 에러가 발생
이부분을 해결하기위해 dismiss를 Main Thread에서 작업하도록 해야한다.
1
2
3
4
5
6
7
8
9
10
11
12
switch result {
case .success(_):
Task {
await MainActor.run {
dismiss()
}
}
case .failure(_):
Task { @MainActor in
dismiss()
}
}
두가지 방법이 있으니 본인 취향껏 사용하면 될것같다.
Swift Testing
Swift Testing에 대한 내용은 여기서
WWDC2024에 설명도있으니 시청을 반드시 하자.
Xcode16에서 사용 가능하다.
test를 생성할때는 target으로 생성하고
test를 검색하면 나오는데 우리는 Unit Testing Bundle
을 사용한다.
그리고 만들때 반드시 확인해야 할 것은
Testing System에서 Swift Testing
이 체크 되어있어야 한다.
XCTest는 Xcode16 이전에 Unit Test를 할때 사용했었다.
이전글에서 Combine을 사용할때 XCTest를 사용해본 경험을 글로 적었으니 다시 확인해보면 좋을듯.
생성하면 디렉토리와 파일이 새로 생성되는데 기본 코드 구성은 다음과 같다.
1
2
3
4
5
6
7
8
9
import Testing
struct Step_Tracker_Tests {
@Test func example() async throws {
// Write your test here and use APIs like `#expect(...)` to check expected conditions.
}
}
Extension Testing
1
2
3
4
5
6
7
extension Array where Element == Double {
var average: Double {
guard !self.isEmpty else { return 0 }
let total = self.reduce(0, +)
return total/Double(self.count)
}
}
우리가 필요해서 만든 extension이 잘 작동하는지 테스트를 해본다.
1
2
3
4
5
6
7
8
9
10
11
import Testing
@testable import Step_Tracker
struct Step_Tracker_Tests {
@Test func arrayAverage() {
let array: [Double] = [2.0, 3.1, 0.45, 1.84]
#expect(array.average == 1.8475)
}
}
이떄 우리가 앱 코드에서 만든 extension을 사용하기 위해선
@testable import Step_Tracker
를 반드시 해줘야한다.
그리고 테스트용 함수를 만들때는 @Test
를 반드시 명시해줘야한다.
#expect(array.average == 1.8475)
1.8475는 우리가 계산을 직접 한 값을 입력해줘야한다.
즉 우리가 위에서 만든 배열에 대해 array extension의 average를 사용하면 값은 1.8465가 나올것을 우리는 기대하고 있다. 라고 생각하면 될듯.
저부분을 클릭해서 실행을 해보자.
처음에는 테스트하는데 시간이 걸린다.
테스트가 성공하면
이렇게 ✅ 표시가 뜬다.
혹시 누가 extension을 잘못 건드려서
1
2
3
4
5
6
7
extension Array where Element == Double {
var average: Double {
guard !self.isEmpty else { return 0 }
let total = self.reduce(0, +)
return total/Double(self.count - 1) // modified
}
}
코드가 변경이 되었다면?
이렇게 ❌ 표시가 뜨고 값이 잘못되었다고 뜬다.
test로 계산한 값은 2.463333 인데 우리가 기대하는 결과값은 1.8475라 둘이 일치하지 않기에 위와같은 에러가 발생한 것.
이걸 통해 extension의 average가 잘못되었다는걸 우리는 알 수 있고, 해당 코드를 수정함으로써 잘못된 부분을 바로 잡을 수 있다.
이건 아주 간단한 테스트이다.
ChartHelper Testing
averageWeekdayCount
함수에 대해서 테스트를 진행해본다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import Foundation // new
@Suite("Chart Helper Tests") struct ChartHelperTests {
var metrics: [HealthMetric] = [
.init(date: Calendar.current.date(from: .init(year: 2024, month: 12, day: 2))!, value: 1000), // Mon
.init(date: Calendar.current.date(from: .init(year: 2024, month: 12, day: 3))!, value: 500), // Tue
.init(date: Calendar.current.date(from: .init(year: 2024, month: 12, day: 4))!, value: 250), // Wed
.init(date: Calendar.current.date(from: .init(year: 2024, month: 12, day: 9))!, value: 750), // Mon
]
@Test func averageWeekdayCount() {
let averageWeekdayCount = ChartHelper.averageWeekdayCount(for: metrics)
#expect(averageWeekdayCount.count == 3)
#expect(averageWeekdayCount[0].value == 875)
#expect(averageWeekdayCount[1].value == 500)
#expect(averageWeekdayCount[2].date.weekdayTitle == "Wednesday")
}
}
Suite Docs는 여기
WWDC2024에서는 Suite를
group related test functions and suites 관련된 테스트 함수와 스위트(테스트 그룹)를 그룹화한다.
더미데이터가 필요하니 배열을 만들고 init을 통해 샘플 데이터를 생성한다.
그리고 이번에도 역시 #expect
를 통해 우리가 예상하는 값을 적어둔다.
테스트를 해보면 ✅
1.1.0 로 버전업하기
버전업을 어디서 하는지는 이전글에서 언급했으니 패스
버전업을 하고 다시 git push를 한다.
이번엔 GitHub DeskTop을 사용하여 해본다.
터미널로 하는게 익숙한데, 다양하게 해보는게 좋기에 데스크탑 앱을 사용.
현재상태는 다음과 같다.
마지막으로 커밋을 한게 최종적으로 머지를 한 상태이기에 해당부분을 우클릭하여
create tag를 클릭하자
이렇게 초기 생성한 버전처럼 맞춰주자. (1.1로도 생성이 가능하지만 가급적이면 그렇게는 하지말자)
생성을 하면
이렇게 태그가 달리고 push를 하라고 바뀐다.
push를 하자.
그러면 fetch로 바뀌고 태그가 있던곳에 ↑ 가 사라진걸 알 수 있다.
레포지토리로 가서 Releases를 클릭하면,
Draft a new release
를 클릭해서 새로 작성을 하자.
전과 다르게 새롭게 추가한 버전이 생성된걸 알 수 있다.
지금 발행하는게 최신이기에 반드시 체크를 확인.
위와같이 확인이 가능하다.
이렇게해서 HealthKit에 대한 내용이 모두 끝이났다.
이번에도 상당히 배움이 컸던 주제였다.
Github: Step-Tracker Repository