안녕하세요. 이번 시간은 Composable Architecture를 구성하는 방법을 배워볼 예정입니다.
지금까지 만들었던 CounterFeature를 포함하는 부모 feature를 만들어봅시다!
두 개의 탭이 있는 탭뷰 기능을 추가하고 각 탭에는 카운터 피처가 포함되어 있어 함께 기능을 구성하는 방법을 살펴볼 것입니다. 이렇게 하면 Scope Reducer와 Scope Operator를 살펴볼 수 있는 기회가 주어집니다.
struct AppView: View {
var body: some View {
TabView {
CounterView(store: ???)
.tabItem {
Text("Counter 1")
}
CounterView(store: ???)
.tabItem {
Text("Counter 2")
}
}
}
}
루트 탭 뷰를 생성하고 각 뷰에 CounterView를 넣어줍니다. 근데 store에 어떤 값을 넣어줘야 할까요?
먼저 이전과 같이 넣어봅시다
struct AppView: View {
let store1: StoreOf<CounterFeature>
let store2: StoreOf<CounterFeature>
var body: some View {
TabView {
CounterView(store: store1)
.tabItem {
Text("Counter 1")
}
CounterView(store: store2)
.tabItem {
Text("Counter 2")
}
}
}
}
위처럼 store1, store2를 정의하고 이를 각각 store로 넣어줍니다.
하지만 이것은 이상적인 방법이 아닙니다. 우링는 이제 서로 통신할 수 없는 완전히 고립된 두 개의 store가 생겼습니다. 그러나 한 탭에서 다른 탭에 영향을 미치고 싶은 경우 어떻게 해야할까요?
이것이 Composable Architecture에서 여러개의 스토어가 아닌 하나의 스토어로 함께 기능을 구성하고 뷰를 단일로 구동하는 것을 선호하는 이유입니다. 이렇게 하면 기능이 서로 통신하기가 매우 쉬워지고 통신이 제대로 작동하는지 테스트할 수도 있습니다.
그러므로 뷰를 잠시 제쳐두고 먼저 리듀서를 단일 패키지로 구성하는 것에 집중해봅시다. 그런 다음 다시 돌아와서 탭 뷰를 올바르게 생성하는 방법을 배워봅시다.
섹션1에서 우리는 여러 개의 고립된 스토어를 갖는것이 부적절하다는 것을 배웠습니다. 이번 섹션에서는 Reducer에서 단일 store로 구성하는 것을 배워봅시다.
@Reducer
struct AppFeature {
struct State: Equatable {
var tab1 = CounterFeature.State()
var tab2 = CounterFeature.State()
}
enum Action {
case tab1(CounterFeature.Action)
case tab2(CounterFeature.Action)
}
}
Reducer에 State와 Action을 추가해줍니다. 후에 테스트할 것을 고려해 State가 Equtable을 준수하도록 합니다.
@Reducer
struct AppFeature {
struct State: Equatable {
var tab1 = CounterFeature.State()
var tab2 = CounterFeature.State()
}
enum Action {
case tab1(CounterFeature.Action)
case tab2(CounterFeature.Action)
}
var body: some ReducerOf<Self> {
Reduce { state, action in
// Core logic of the app feature
return .none
}
}
}
이전에는 Reduce 유형을 사용해 클로저를 열고 전달된 액션에 따라 State 변형을 수행했습니다.
우리는 그러한 방식으로 로직을 구현할 수 있지만 같은 기능을 tab1, tab2에 중복 없이 구현하고싶습니다.
@Reducer
struct AppFeature {
struct State: Equatable {
var tab1 = CounterFeature.State()
var tab2 = CounterFeature.State()
}
enum Action {
case tab1(CounterFeature.Action)
case tab2(CounterFeature.Action)
}
var body: some ReducerOf<Self> {
Scope(state: \.tab1, action: \.tab1) {
CounterFeature()
}
Scope(state: \.tab2, action: \.tab2) {
CounterFeature()
}
Reduce { state, action in
// Core logic of the app feature
return .none
}
}
}
CounterFeature를 AppFeature에서 사용하려면 Scope Reducer를 사용할 수 있습니다.
이 기능을 사용하면 상위 피처의 하위 도메인에 집중하고 해당 하위 도메인에서 자식 reducer를 실행할 수 있습니다.
NOTE>
body에서는 여러 reducer들을 나열할 수 있습니다. 작업이 시스템에 들어오면 각 reducer는 top-to-bottom으로 처리됩니다.
AppFeature는 이제 3가지의 핵심 피처를 가지고 있습니다.
1. 핵싱 앱 기능
2. 첫번째 탭 카운터 기능
3. 두번째 탭 카운터 기능
위의 3가지 기능은 서로 완전히 독립되어 있습니다.
이를 통해 우리는 테스트도 해볼 수 있습니다.
struct AppFeatureTests {
@Test
func incrementInFirstTab() async {
let store = TestStore(initialState: AppFeature.State()) {
AppFeature()
}
await store.send(\.tab1.incrementButtonTapped) {
$0.tab1.count = 1
}
}
}
AppFeature 도메인을 유지하는 TestStore를 만듭니다. 이 작업은 기능의 초기 상태를 제공하고 기능을 구동하는 Reducer를 지정하여 수행합니다.
NOTE>
TestStore는 action을 전송하고 status 변화에 대해 assertion할 수 있는 테스트 가능한 런타임임을 기억하세요. 또한 effect가 어떻게 data를 시스템으로 다시 방출하는지에 대해 서도 assert 해야 합니다.
Section 3. Deriving child stores
이제 루트 앱과 탭의 모든 로직이 포함되어 있는 단일 구성 AppFeature가 있으므로 애플리케이션의 뷰 계층을 적절하게 구현할 수 있습니다.
기존의 store1, store2를 삭제하고 AppFeature로 store 변수를 생성해줍니다.
import ComposableArchitecture
import SwiftUI
struct AppView: View {
let store: StoreOf<AppFeature>
var body: some View {
TabView {
CounterView(store: store.scope(state: \.tab1, action: \.tab1))
.tabItem {
Text("Counter 1")
}
CounterView(store: store.scope(state: \.tab2, action: \.tab2))
.tabItem {
Text("Counter 2")
}
}
}
}
#Preview {
AppView(
store: Store(initialState: AppFeature.State()) {
AppFeature()
}
)
}
CounterView의 store에 store를 통해 tab1, tab2에 접근할 수 있게 수정합니다.
이처럼 이제 우리는 Composable Architectrue에서 기능을 함께 구성하는 기본 사항을 배웠습니다. 간단한 형태로 부모 리듀서에서 리듀서를 함께 구성하고 scope 리듀서를 사용하여 부모의 하위 도메인에 초점을 맞춰 자식 리듀서를 실행하는 것으로 시작합니다. 그런 다음 뷰에서 (state: action)을 사용하여 부모로부터 하위 store를 도출하고 해당 하위 저장소를 자녀에게 넘깁니다.