[SwiftUI/TCA] CMPedometer, AsyncThrowingStream, TCA dependencies 라이브러리로 만보기 기능 구현하기
현재 진행중인 프로젝트에서 실시간으로 걸음수를 업데이트하는 기능을 구현해야 했습니다. 프로젝트 스펙은 tuist 모듈화 및 TCA 아키텍쳐를 사용하고 있었기 때문에, TCA에 내장된 dependencies 라이브러리로 의존성을 관리하고자 했습니다.
걸음 수 데이터 가져오기
걸음 수 데이터는 HealthKit 또는 CoreMotion의 CMPedometer를 이용하여 가져올 수 있습니다.
HealthKit을 이용하기 위해서는 사용자의 건강 정보에 접근 권한을 가지고 있어야하며, CoreMotion의 경우 Motion 정보 접근 권한을 허가받아야 합니다.
CMPedometer를 이용할 경우 HealthKit보다 더욱 빠르게 sync되는 걸음수 데이터를 얻을 수 있기 때문에, 실시간 만보기를 구현해야 하는 입장에서 CMPedometer를 사용하기로 결정했습니다. 대신 7일 간의 데이터에만 접근 가능하기 때문에, 더 넓은 범위의 데이터가 필요하다면 HealthKit을 사용하면 됩니다.
CMPedometer로 만보기 기능 구현하기
An object for fetching the system-generated live walking data.
CMPedometer의 정의는 위와 같습니다. 시스템에서 생성된 실시간 걸음 데이터를 가져오는 객체라고 합니다.
To use a pedometer object, create an instance of this class and call the appropriate methods. Use the queryPedometerData(from:to:withHandler:) method to retrieve data that has already been gathered. To get live updates, use the startUpdates(from:withHandler:) method to start the delivery of events to the handler you provide.
공식 문서에서는, queryPedometerData 메서드를 이용하여 이미 생성된 데이터를 얻을 수 있고, 걸음 정보를 실시간으로 업데이트하고 싶으면 startUpdates 메서드를 사용하라고 합니다.
실시간 걸음 수 데이터를 얻는 방법으로 두 가지 메서드 모두 사용할 수 있습니다.
1. queryPedometerData 메서드를 Timer를 통해 지속적으로 호출하여 시스템에서 생성된 걸음 정보 얻기
2. startUpdates를 이용하여 특정 시점에서부터 지속적으로 걸음 수 데이터를 갱신하고 handler에서 데이터 사용하기
개인적으로 Timer를 이용하기보다는 시스템에서 제공하는 기능을 사용하는 것이 애플의 의도에 맞다고 생각했기에, startUpdates를 사용하여 데이터를 갱신해 보겠습니다.
func startUpdates(
from start: Date,
withHandler handler: @escaping CMPedometerHandler
)
이 startUpdates 메서드는 최신 값을 받아올 때마다 handler에서 코드블록을 지속적으로 실행시킵니다. 공식 문서에서는 앱이 suspended 되면 해당 흐름은 일시 중단되고, foreground 상태에서 다시 시작한다고 나와 있습니다. 흐름에 대한 제어를 자동으로 실행해주기에 관리가 편한 추상화 메서드라고 생각합니다.
TCA는 기본적으로 비동기 처리에서 Swift Concurrency를 사용할 것을 권장하기에, Swift Concurrency의 AsyncStream을 이용하여 비동기 흐름을 만들어야 합니다. 먼저 AsyncStream에 대해 잠깐 알아보겠습니다.
AsyncStream
An asynchronous sequence generated from a closure that calls a continuation to produce new elements.
새로운 elements를 생성하기 위한 continuation을 호출하는 클로저로부터 생성된 비동기 sequence라고 합니다. 정의만으로는 감을 잡기 어렵습니다.
AsyncStream conforms to AsyncSequence, providing a convenient way to create an asynchronous sequence without manually implementing an asynchronous iterator. In particular, an asynchronous stream is well-suited to adapt callback- or delegation-based APIs to participate with async-await.
요약하면, callback 또는 delegate 기반 API를 asnyc-await와 호환되는 비동기 흐름, 즉 AsyncSequence로 만들기 위해 편하게 사용할 수 있는 구조체라고 합니다. 내부가 @Sendable하게 구현되기 때문에, 내부 자원에 대한 안정성을 보장합니다.
extension QuakeMonitor {
static var quakes: AsyncStream<Quake> {
AsyncStream { continuation in
let monitor = QuakeMonitor()
monitor.quakeHandler = { quake in
continuation.yield(quake)
}
continuation.onTermination = { @Sendable _ in
monitor.stopMonitoring()
}
monitor.startMonitoring()
}
}
}
생성자에서 continuation 파라미터를 제공하는데, 반복적인 행위에서 생성되는 element를 continuation.yield()를 통해 전달할 수 있습니다. 흐름을 종료하고 싶은 경우 파라미터로 제공되는 continuation에 finish를 호출하는 것으로 stream을 종료시킬 수 있습니다.
여기에 더하여 AsyncThrowingStream을 사용하면, 시퀀스를 실행하는 과정에서 error 또한 throw 할 수 있습니다. Pedometer의 권한이 없는 경우 실패 가능하기 때문에, AsyncThrowingStream을 사용하겠습니다.
마지막으로, TCA의 Dependencies 라이브러리에 대해 알아보겠습니다.
Dependencies 라이브러리
A dependency management library inspired by SwiftUI’s “environment.”
TCA의 Dependencies 라이브러리는 SwiftUI의 environment에 착안하여 만든 의존성 관리 라이브러리입니다.
의존성 관리 방법이 아주 직관적이고 편리합니다.
1. 원하는 의존성 정의하기. 걸음수를 async한 stream으로 받기 위해서 AsyncThrowingStream으로 정의합니다.
import ComposableArchitecture
// MARK: - PedometerClient
public struct PedometerClient {
public let startFetchingSteps: () -> AsyncThrowingStream<Int, Error>
}
2. 의존성을 Key 및 value로 정의하기. SwiftUI의 EnvironmentValue와 비슷한 방식입니다.
// MARK: DependencyKey
extension PedometerClient: DependencyKey {}
public extension DependencyValues {
var pedometerClient: PedometerClient {
get { self[PedometerClient.self] }
set { self[PedometerClient.self] = newValue }
}
}
3. LiveValue 구현하기. 실제 의존성으로 사용할 구현된 객체입니다.
import Foundation
import CoreMotion
#if os(iOS)
import Shared_ios
#elseif os(watchOS)
import Shared_watchOS
#endif
public extension PedometerClient {
static let liveValue: Self = {
var pedometer = CMPedometer()
return .init(
startFetchingSteps: {
return AsyncThrowingStream<Int, Error> { continuation in
guard CMPedometer.isStepCountingAvailable() else {
continuation.finish(throwing: NSError(domain: "pedometer", code: 1))
return
}
pedometer.startUpdates(from: .todayStart) { data, error in
guard let steps = data?.numberOfSteps else {
continuation.finish(throwing: NSError(domain: "pedometer", code: 1))
return
}
continuation.yield(Int(truncating: steps))
}
}
}
)
}()
}
위의 코드에서 확인할 수 있듯, AsyncThrowingStream을 생성하고, Pedometer가 사용 가능한 경우에만 걸음 수 업데이트를 시작합니다.
Feature에서 사용하기
이제 걸음 수를 받아오기 위한 구현은 완료했으니, 실제 화면에서 사용해 보겠습니다.
먼저 Reducer의 구현 부분입니다.
import ComposableArchitecture
import PedometerClient_ios
public struct MainFeature: ReducerProtocol {
public init() {}
public struct State: Equatable {
var steps: Int?
public init() {}
}
public enum Action: Equatable {
// View Actions
case onAppear
// Internal Actions
case _fetchSteps(Int)
case _showFetchingStepError
// Delegate
case delegate(Delegate)
public enum Delegate: Equatable {
case checkTodayPopup
}
}
@Dependency(\.pedometerClient)
var pedometerClient
public var body: some ReducerProtocol<State, Action> {
Reduce { state, action in
switch action {
case .onAppear:
return .run { send in
do {
for try await steps in pedometerClient.startFetchingSteps() {
await send(._fetchSteps(steps))
}
} catch {
await send(._showFetchingStepError)
}
}
case ._fetchSteps(let steps):
state.steps = steps
return .none
case ._showFetchingStepError:
state.steps = nil
return .none
case .delegate:
return .none
}
}
}
}
1. 아래와 같은 프로퍼티 래퍼로 특별한 의존성 주입 과정이 필요 없이 바로 client를 가져올 수 있습니다.
@Dependency(\.pedometerClient)
var pedometerClient
2. onAppear 액션에서 .run을 통해 EffectTask를 return합니다. 해당 task의 내부에서 pedometerClient를 통한 걸음 수 update의 AsyncThrowingStream을 생성하고, 걸음 수가 update될 때마다 internal action인 _fetchSteps를 실행합니다. 만약 error가 발생한다면 _showFetchingStepError를 실행하여 error 상태를 반영합니다.
3. fetchSteps는 state의 steps를 바꿔주는 역할을 합니다. state가 변화하면 view는 store에서 자동으로 해당 값을 구독하여 반영합니다.
View의 구현
import SwiftUI
import ComposableArchitecture
import DesignSystem_ios
public struct MainView: View {
let store: StoreOf<MainFeature>
@ObservedObject var viewStore: ViewStoreOf<MainFeature>
public init(store: StoreOf<MainFeature>) {
self.store = store
self.viewStore = ViewStore(store, observe: { $0 })
}
public var body: some View {
VStack {
Text("Main View")
IfLet(viewStore.steps) { steps in
Text("\(steps) 만큼 걸음")
} elseContent: {
Text("걸음 수 접근 권한이 없습니다.")
}
}
.frame(
maxWidth: .infinity,
maxHeight: .infinity
)
.background(
Color(.brown)
)
.onAppear {
viewStore.send(.onAppear)
}
}
}
IfLet은 Custom VeiwBuilder입니다. viewStore의 steps의 상태에 따라 서로 다른 뷰를 만들어 줍니다.
public extension View {
@ViewBuilder
func IfLet<V>(
_ value: V?,
@ViewBuilder ifLetContent: @escaping (V) -> some View,
@ViewBuilder elseContent: @escaping () -> some View
) -> some View {
if let value = value {
ifLetContent(value)
} else {
elseContent()
}
}
}
기능 구현 완료
데이터를 성공적으로 받아오면 아래와 같은 화면에서, 걸음 수가 지속적으로 갱신됩니다. CMPedometer 자체적인 특성으로 인해 걸음수가 즉시 반영되지는 않지만, 수 초 내에 갱신되는 모습을 확인할 수 있습니다.