ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • TCA 1.0 - MultiStore (ch.07)
    TCA 2024. 2. 22. 19:10

    안녕하세요. 그린입니다 🍏

    이번 포스팅에서는  TCA의 MultiStore에 대해 알아보겠습니다🙋🏻

     

    항상 포스팅에서도 소개했지만, TCA 1.0 시리즈 학습은 아래 학습자료를 기반으로 하고 있습니다.

    해당 레퍼를 기반으로 학습하면서 제 나름대로 정리해보는 포스팅이기에, 주관적인 사견이 추가됩니다 🙋🏻

     

     

    Chapter 7. MultiStore | Notion

    여기까지 오셨다면 이제 여러분들은 Reducer를 통해 Store를 생성해 View에 출력하는 방법을 모두 익히신겁니다. 하지만 이전보다 앱이 복잡해지고 규모가 커진다면 하나의 Reducer에서 상태관리를 하

    axiomatic-fuschia-666.notion.site

     

    그럼 시작하겠습니다 😃


    MultiStore

    • 지금까지의 학습을 통해 Reducer로 Store를 생성하고 View에 연결하는 방법에 대해 모두 익혔지만, 이는 다소 간단한 규모의 프로젝트 예시로만 사용될 수 있음
    • 더 나아가 큰 규모의 앱이라면 하나의 Reducer로만 관리하기에 벅차, 잘게 요구에 맞게 쪼개 사용하는것이 이번 학습의 핵심
    • 즉, 이번 챕터에서 배울 MultiStore를 통해 그것을 가능케함
    • MultiStore에선 대표적으로 아래 장점 및 특징들을 가짐
      • 자식 State가 옵셔널로 없을 시에도 UI에 적절히 처리하는 등 특정 케이스에서 자식 뷰의 렌더링을 할 수 있음
      • Reducer를 잘게 나눠 각각 독립적으로 동작 및 관리 가능
      • 작게 나눈 Store를 통해 특정 부분만의 업데이트를 가져와 성능 최적화

    ifLet

    • 부모 State의 옵셔널 프로퍼티에서 동작하는 자식 리듀서를 부모의 도메인으로 넣을 수 있게함
    struct Parent: Reducer {
      struct State {
        var child: Child.State?
      }
      
      enum Action {
        case child(Child.Action)
      }
      
      var body: some Reducer<State, Action> {
        Reduce { state, action in 
          ...
        }
        .ifLet(\.child, action: /Action.child) {
          Child()
        }
      }
    }
    • 위와 같이 자식 State가 옵셔널일 시 부모 리듀서에 ifLet을 붙여주면서 키패스와 케이스패스로 지정하여 자식 리듀서를 가져올 수 있음
    • 즉, 자식 State가 옵셔널이 아니게 되는 값이 부여될 때 자식 Reducer를 실행해줌

    ifLet 생성자

    @warn_unqualified_access func ifLet<WrappedState, WrappedAction, Wrapped>(
      _ toWrappedState: WritableKeyPath<Self.State, WrappedState?>,
      action toWrappedAction: CasePath<Self.Action, WrappedAction>, 
      @ReducerBuilder<WrappedState, WrappedAction> 
      then wrapped: () -> Wrapped,
      fileID: StaticString = #fileID,
      line: UInt = #line
    ) -> _IfLetReducer<Self, Wrapped> 
      where WrappedState == Wrapped.State, 
            WrappedAction == Wrapped.Action, 
    				Wrapped: Reducer
    • toWrappedState는 부모 State에서 옵셔널한 자식 State를 포함하게 하는 키패스
    • toWrappedAction은 부모 Action에서 자식 Action을 포함하게 하는 케이스패스
    • Wrapped는 자식 State가 옵셔널이 아닐 시 자식 Action과 함께 호출될 리듀서
    • 반환 타입에서 where절을 해석해보면 WrappedState가 자식 State이고, WrappedAction도 자식의 액션이고 자식 리듀서 자체는 리듀서 프로토콜을 따르고 있다면 부모 리듀서와 자식 리듀서를 조합해 새로운 리듀서를 반환한다는 뜻!
    • ifLet은 아래와 같은 작업을 수행
      • 자식과 부모 기능에서 특정 작업 순서를 강제화 (자식 > 부모 실행 순)
      • 자식 State가 nil이 되면 모든 자식의 이펙트를 자동으로 취소
      • 얼럿이나 컨펌과 같은 창에서 액션이 전송되면 자동으로 자식 nil을 무효화 처리함

    ifLetStore

    • ifLet을 통해 옵셔널한 자식 리듀서를 부모 리듀서에 끌어왔으니 이제는 뷰에 보여줄 차례
    • 자식 State가 있을때 자식 뷰 혹은 특정한 뷰를 보여주고 없다면 대체 뷰를 보여줄때 사용
    IfLetStore(store.scope(state: \.child, action: Child.Action.blabla)) {
      ChildView(store.$0)
    } else: {
      Text("자식 State nil")
    }
    • 이런식으로 자식 State 값을 스코프로 가져와 상태에 넣고 수행할 특정 액션도 넣고 else 구문에선 자식 State가 nil일 시 보여줄 뷰를 제공

    ifLetStore 생성자

    public struct IfLetStore<State, Action, Content: View>: View {
      private let content: (ViewStore<State?, Action>) -> Content
      private let store: Store<State?, Action>
      ...
    }
    • State는 옵셔널한 자식 State
    • Action은 자식 액션
    • Content는 뷰
    public init<IfContent, ElseContent>(
      _ store: Store<State?, Action>,
      @ViewBuilder 
    	then ifContent: @escaping (_ store: Store<State, Action>) -> IfContent,
      @ViewBuilder 
    	else elseContent: () -> ElseContent
    ) where Content == _ConditionalContent<IfContent, ElseContent> {
    • store는 스코프한 자식 State와 Action을 넣어 사용
    • then은 자식 State가 존재할 시 실행되는 클로저로 뷰빌더를 채택하기에 뷰를 구성
    • else에서는 자식 State가 없을 시 보여줄 뷰를 구성
    let store = store.invalidate { $0 == nil }
    self.store = store
    • 해당 invalidate 함수를 통해서 nil일 때 상태를 무시하는 새 store를 지정할 수 있음
    self.content = { viewStore in
        if var state = viewStore.state {
          return ViewBuilder.buildEither(
            first: ifContent(
              store
                .invalidate { $0 == nil }
                .scope(
                  state: {
                    state = $0 ?? state
                    return state
                  },
                  action: { $0 }
                )
            )
          )
        } else {
          return ViewBuilder.buildEither(second: elseContent)
        }
    }
    • 위와 같이 사용됨!
    • content를 만들때 자식 State가 nil이 아닌지 바인딩을 통해 구성하면서 first로 첫번째 보여줄 뷰에 대한 스토어는 ifContent를 통해서 만들며 nil을 스토어가 무시해야하기에 invalidate를 쓰는것 같음!
    public var body: some View {
      WithViewStore(
        self.store,
        observe: { $0 },
        removeDuplicates: { ($0 != nil) == ($1 != nil) },
        content: self.content
      )
    }
    • 결국 이렇게 body 프로퍼티를 구성할 때 WithViewStore로 스토어를 지정해줘야하는데 위에서 이미 자식 State의 nil 여부에 따라 store를 구성했기에 넣어주면 가능한듯
    • 이렇게 WithViewStore를 지정해준다면 옵셔널 여부에 따라 중복 제거도 되고 그러는듯~
    • 근데 이제 1.7에서 Deprecated 되었기에 아래처럼 사용할 수 있음
    if let childStore = store.scope(state: \.child, action: \.child) {
      ChildView(store: childStore)
    } else {
      Text("Nothing to show")
    }

     


    forEach

    • 부모 도메인에 부모 State의 컬렉션 요소에서 작동하는 자식 리듀서를 포함하는 기능
    • 즉, 자식 State가 어레이 같은 타입일 때 forEach를 통해서 부모 로직과 자식 로직을 수행할 수 있도록함 
    struct Parent: Reducer {
      struct State {
        var rows: IdentifiedArrayOf<Child.State>
      }
      
      enum Action {
        case row(id: Child.State.ID, action: Child.Action)
      }
      
      var body: some Reducer<State, Action> {
        Reduce { state, action in 
          ...
        }
        .forEach(\.rows, action: /Action.row) {
          Row()
        }
      }
    }
    • ID로 안전하게 접근하도록하며 컬렉션 라이브러리의 IdentifiedArray를 사용함
    • forEach도 자식 및 부모 기능에 대해서 작업 순서를 강제화함 (자식 > 부모 순)

    forEach 생성자

    @warn_unqualified_access func forEach<ElementState, ElementAction, ID, Element>(
      _ toElementsState: 
    		WritableKeyPath<Self.State, IdentifiedArray<ID, ElementState>>,
      action toElementAction: CasePath<Self.Action, (ID, ElementAction)>, 
    	@ReducerBuilder<ElementState, ElementAction> element: () -> Element,
      fileID: StaticString = #fileID,
      line: UInt = #line
    ) -> _ForEachReducer<Self, ID, Element> 
    	where ElementState == Element.State, 
    	ElementAction == Element.Action, 
    	ID : Hashable, Element : Reducer
    • toElementState는 부모 State로부터 자식 State의 식별된 배열에 대한 키패스
    • toElementAction은 자식 액션의 케이스패스
    • element는 자식 리듀서
    • 반환값으로는 where절을 만족시키면 자식 리듀서와 부모 리듀서를 결합한 리듀서를 반환하게됨

    ForEachStore

    • 배열된 State를 순회하면서 각 항목에 대한 뷰를 생성하는데 사용됨
    • 동적으로 UI 생성하며 배열 요소를 기반으로 뷰를 표시함
    ForEachStore(
      self.store.scope(state: \.rows, action: { .row(id: $0, action: $1) })
    ) { childState in 
      ChildView(store: childStore)
    }

     


    ForEachStore 생성자

    public struct ForEachStore<
    EachState, EachAction, Data: Collection, ID: Hashable, Content: View>: DynamicViewContent {
    public let data: Data
    let content: Content
    • EachState와 EachAction은 각 요소의 State, Action을 나타냄
    • Data는 순회하는 데이터 컬렉션이며 ID는 데이터 요소의 고유 식별자
    • Content는 각 요소에 대한 뷰를 생성하는 클로저 형식
    public init<EachContent>(
      _ store: Store<IdentifiedArray<ID, EachState>, (ID, EachAction)>,
      @ViewBuilder 
    	content: @escaping (_ store: Store<EachState, EachAction>) -> EachContent
    )
    • 초기화 메서드에서 store는 위 사용부처럼 자식 상태와 액션으로 스코핑된 store로 넣음
    • content는 각 요소에 대한 뷰를 반환하도록 함
    • 사실 이후 내부 구현들을 더 자세히 봐도 좋지만 1.7에서 마찬가지로 Deprecated되어서 스킵ㅎㅎ..
    • 이제 이처럼 사용해야함!
    ForEach(
      store.scope(state: \.rows, action: \.rows),
      id: \.state.id
    ) { childStore in
      ChildView(store: childStore)
    }
    • 사용 시 각 행의 상태 ID에 의존하지 않는 경우에는 ID 매개 변수도 생략 가능

    ifCaseLet

    • 부모 State가 enum 타입이고 각 여러 자식 State들을 가질 때, 특정 케이스에 대해 자식 리듀서를 실행하도록 함
    • 즉, 기존에는 State는 struct였지만, 부모 State를 enum으로 가져가면서 여러 자식에 대해 포함할 수 있음
    struct Parent: Reducer {
      enum State {
        case firstChild(FirstChild.State)
        case secondChild(SecondChild.State)
      }
    
      enum Action {
        case firstChild(FirstChild.Action)
        case secondChild(SecondChild.Action)
      }
    
      var body: some Reducer<State, Action> {
        Reduce { state, action in 
          ...
        }
        .ifCaseLet(/State.firstChild, action: /Action.firstChild) {
          FirstChild()
        }
        .ifCaseLet(/State.secondChild, action: /Action.secondChild) {
          SecondChild()
        }
      }
    }
    • 이렇게 부모 상태에서 선택된 자식 상태 및 액션에 따라 해당 리듀서가 결합되어 사용될 수 있음
    • 마찬가지로 자식 및 부모 기능에 대해 특정 작업 순서를 강제화함 (자식 > 부모)
    • 다른 자식 열거 값으로 변경이 되면 모든 기존 이펙트가 취소됨

    ifCaseLet 생성자

    @warn_unqualified_access func ifCaseLet<CaseState, CaseAction, Case>(
      _ toCaseState: CasePath<Self.State, CaseState>,
      action toCaseAction: CasePath<Self.Action, CaseAction>, @ReducerBuilder<CaseState, CaseAction> then case: () -> Case,
      fileID: StaticString = #fileID,
      line: UInt = #line
    ) -> _IfCaseLetReducer<Self, Case> 
    	where CaseState == Case.State, CaseAction == Case.Action, Case : Reducer
    • toCaseState는 자식 State를 지정하는 케이스패스
    • toCaseAction은 자식 Action을 지정하는 케이스패스
    • Case는 자식 리듀서
    • 반환 탕비으로 부모와 자식 리듀서가 결합된 리듀서를 반환

    SwitchStore

    • ifCaseLet하고 짝꿍이라고 보면됨
    • 리듀서를 조합한걸 이제 뷰에서 스토어를 통해 갈아끼워줄 수 있음
    • enum 타입의 State를 관찰하면서 해당 각 케이스에 대해 CaseLet으로 전환해줌
    struct ParentView: View {
    let store: StoreOf<Parent>
    
    init(store: StoreOf<Parent>) {
      self.store = store
    }
    
    var body: some View {
      SwitchStore(self.store) { state in 
        switch State {
        case .firstChild:
          CaseLet(/Parent.State.firstChild, action: Parent.Action.firstChild) { store in 
            FirstChildState(store: store)
          }
        case .secondChild:
          CaseLet(/Parent.State.secondChild, action: Parent.Action.secondChild) { store in
            SecondChildState(store: store)
          }
        }
      }
    }
    • 이처럼 사용될 수 있음
    • CaseLet은 enum의 특정 case 상태를 처리하고 그에 맞는 뷰를 생성하는데 사용됨
    • 즉, SwitchStore의 상태가 특정 케이스에 일치할때만 해당 State에 대한 처리를 수행
    • 이제 SwitchStore도 사실 1.7에서 Deprecated되어서 생성자나 그런것을 알아보기보다 이제 어떻게 쓰이는지 보자!
    switch store.state {
    case .activity:
      if let store = store.scope(state: \.activity, action: \.activity) {
        ActivityView(store: store)
      }
    case .settings:
      if let store = store.scope(state: \.settings, action: \.settings) {
        SettingsView(store: store)
      }
    }
    • 좀 더 바닐라스럽게 변하면서 간단해짐!

    소감

    이제 정말 뭔가 Store가 붙은 기존의 기능들을 제거하면서 점차 더 바닐라스럽게 변화하면서 간소화되는 느낌이 많이 들었다 

     


    레퍼런스

     

    Chapter 7. MultiStore | Notion

    여기까지 오셨다면 이제 여러분들은 Reducer를 통해 Store를 생성해 View에 출력하는 방법을 모두 익히신겁니다. 하지만 이전보다 앱이 복잡해지고 규모가 커진다면 하나의 Reducer에서 상태관리를 하

    axiomatic-fuschia-666.notion.site

    'TCA' 카테고리의 다른 글

    TCA 1.0 - Testable Code (ch.09)  (85) 2024.03.02
    TCA 1.0 - Navigation (ch.08)  (84) 2024.02.27
    TCA 1.0 - Swift의 비동기 처리와 TCA에서의 응용 (ch.06)  (81) 2024.02.20
    TCA 1.0 - Dependency (ch.05)  (67) 2024.02.15
    TCA 1.0 - TCA Binding (ch.04)  (73) 2024.02.12
Designed by Tistory.