ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • TCA - ReducerProtocol
    TCA 2023. 1. 31. 09:09

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

    이번 포스팅에서는 TCA의 ReducerProtocol을 간단히 학습해보려합니다🙌

     

    TCA가 날이 갈수록 업데이트도 빨라지고 더 발전하고 있어요!

    현재 버전이 0.50.1까지 나왔으며 그전에 대격변이 0.41.0에서 일어났습니다.

    바로 ReducerProtocol의 등장이죠🕺🏻

     

    기존에 TCA에서 Core를 구성할때는 State, Action, Environment들을 별도 struct, enum으로 정의하고 이를 Reducer타입의 상수로 만들어 정의하는 형태였어요.

    그런데 이와 반대로 Protocol Programming의 장점도 살리면서 더 적합하게 ReducerProtocol의 사용으로 전부 변화되었으며 장점도 많아졌어요🙏🏻

     

    만약 이전 TCA 구성이 대략적으로 어떻게 되어있었는지 궁금하신분들은 아래 사용 예시 포스팅을 먼저 보고 비교해보시는것도 좋습니다!

    https://green1229.tistory.com/156

     

    Composable Architecture로 랜덤 통신 구현하기

    안녕하세요. 그린입니다🟢 이번 포스팅에서는 Composable Architecture으로 랜덤한 통신을 구현해보겠습니다🧑🏻‍💻 뷰는 SwiftUI를 통해 간단히 구현하였습니다. 우선 간략한 기능을 설명드리겠습

    green1229.tistory.com

     

    그럼 본격적으로 ReducerProtocol에 대해 알아보겠습니다🥊

     

    ReducerProtocol?

    우선 Pointfree 공식문서에 따르면 앱에서 액션이 주어질때 현재 상태에서 다음 상태로의 전환되는 방법을 설명하고 후에 Store에서 실행해야하는 Effect를 설명하는 프로토콜입니다.

    protocol ReducerProtocol<State, Action>

    즉 선언은 당연히 State, Action을 갖는 프로토콜입니다.

    그럼 이제는 이 프로토콜을 채택하는 구조체를 하나 만들면서 이 안에 늘 먹던것처럼 State, Action, Env 그리고 reducer 메서드까지 구현해주는것이죠.

    공식문서 기반으로 예시를 한번 보겠습니다!

    struct Feature: ReducerProtocol {
      struct State: Equatable {
        var count = 0
        var numberFactAlert: String?
      }
      
      enum Action: Equatable {
        case factAlertDismissed
        case decrementButtonTapped
        case incrementButtonTapped
        case numberFactButtonTapped
        case numberFactResponse(TaskResult<String>)
      }
    
     func reduce(into state: inout State, action: Action) -> Effect<Action, Never> {
        switch action {
          case .factAlertDismissed:
            state.numberFactAlert = nil
            return .none
    
          case .decrementButtonTapped:
            state.count -= 1
            return .none
    
          case .incrementButtonTapped:
            state.count += 1
            return .none
    
          case .numberFactButtonTapped:
            return .task { [count = state.count] in 
              await .numberFactResponse(
                TaskResult { 
                  String(
                    decoding: try await URLSession.shared
                      .data(from: URL(string: "http://numbersapi.com/\(number)/trivia")!).0,
                    using: UTF8.self
                  )
                }
              )
            }
    
          case let .numberFactResponse(.success(fact)):
            state.numberFactAlert = fact
            return .none
    
          case .numberFactResponse(.failure):
            state.numberFactAlert = "Could not load a number fact :("
            return .none
          } 
        }
      }
    }

    이렇게 프로토콜을 채택하고 State, Action을 정의 해주고 있죠.

    그 다음 reducer 상수를 구현하듯 reduce 메서드를 구현해줘요.

    State는 변화를 입혀야되니 inout 타입이고 반환 타입은 당연히 Effect 타입입니다.

    실제 이제 사용에 있어 .run, .task라던지 SwiftConcurrency에 맞춰 async await을 적용하면서 변경된 부분들은 양이 꽤 되기에 이후 포스팅에서 다뤄보겠습니다🥲

     

     자 그럼 이제 View단 body에서는 어떻게 다뤄줘야할지 보겠습니다.

    struct FeatureView: View {
      let store: StoreOf<Feature>
    
      var body: some View {
        WithViewStore(self.store, observe: { $0 }) { viewStore in
          VStack {
            HStack {
              Button("−") { viewStore.send(.decrementButtonTapped) }
              Text("\(viewStore.count)")
              Button("+") { viewStore.send(.incrementButtonTapped) }
            }
    
            Button("Number fact") { viewStore.send(.numberFactButtonTapped) }
          }
          .alert(
            item: viewStore.binding(
              get: { $0.numberFactAlert.map(FactAlert.init(title:)) },
              send: .factAlertDismissed
            ),
            content: { Alert(title: Text($0.title)) }
          )
        }
      }
    }
    
    struct FactAlert: Identifiable {
      var title: String
      var id: String { self.title }
    }

    실제 View에 올릴때는 이제 store 상수에 StoreOf의 타입으로 생성하는데 해당 Core에서 정의하고 만든 Feature를 제네릭하게 받게 해줍니다.

    그런다음 WithViewStore로 body 내부에서 감싸서 사용하는것은 기존과 거의 동일합니다!

     

    마지막으로 이 View를 올려줄 main App에서는 이렇게 사용하게 되죠.

    @main
    struct MyApp: App {
      var body: some Scene {
        FeatureView(
          store: Store(
            initialState: Feature.State(),
            reducer: Feature()
          )
        )
      }
    }

    보면 state에서는 Feature의 State 인스턴스를 사용하고 reducer는 Feature 인스턴스를 생성해준것을 확인할 수 있죠.

     

    그리고 안짚고 넘어간것이 있는데 기존 서버 통신 및 외부 사이드 이펙트를 처리할때는 Environment 구조체를 정의하고 이를 reducer 상수에서 제네릭하게 받아 사용했는데요.

    이제는 다릅니다.

    struct NumberFactClient {
      var fetch: (Int) async throws -> String
    }

    예를들어 이렇게 fetch 기능을 갖는 NumberFactClient 구조체가 있고 의존성을 주입하여 사용해야 된다고 가정해볼께요.

    extension NumberFactClient: DependencyKey {
      static let liveValue = Self(
        fetch: { number in
          let (data, _) = try await URLSession.shared
            .data(from: .init(string: "http://numbersapi.com/\(number)")!)
          return String(decoding: data, as: UTF8.self)
        }
      )
    }
    
    extension DependencyValues {
      var numberFact: NumberFactClient {
        get { self[NumberFactClient.self] }
        set { self[NumberFactClient.self] = newValue }
      }
    }

    이제는 DependencyKey 프로토콜을 채택해 해당 유형을 의존성 관리 시스템에 등록하게 됩니다.

    그런다음 DependencyValues를 확장해 numberFact라는 get set 프로퍼티를 구현해주죠.

    앞으론 이걸 사용하게 될거에요.

    struct Feature: ReducerProtocol {
      struct State { … }
      enum Action { … }
      @Dependency(\.numberFact) var numberFact
      …
    }

    그럼 이렇게 필요한 Core Feature에서 @Dependency 프로퍼티 래퍼 타입으로 numberFact를 가져옵니다.

    @main
    struct MyApp: App {
      var body: some Scene {
        FeatureView(
          store: Store(
            initialState: Feature.State(),
            reducer: Feature()
          )
        )
      }
    }

    그럼 이렇게 실제로 App단에서부터 의존성을 주입받아 부여하지 않아도 되는것이죠.

    let store = TestStore(
      initialState: Feature.State(),
      reducer: Feature()
    ) {
      $0.numberFact.fetch = { "\($0) is a good number Brent" }
    }

    만약 테스트 환경을 구축한다면 TestStore를 만들때 live하지 않은 fetch 구현을 정의해주면 됩니다.

    즉 ReducerProtocol을 사용하면서 장점으로 느껴졌던 큰 부분이 의존성이 편리해졌다는것 같아요⭐️

    이전에는 App 상위부터 쭈욱 받아오며 의존성 주입을 해주니 까먹을때도 있고 추가되면 많은 뎁스의 코드 수정이 이뤄졌어야하는데 이제는 그런 작업이 불필요하죠👍

     

    마무리

    이렇게 아주아주아주 간단하게 ReducerProtocol의 리드미 정도로만 어떤게 나온건지 알아보긴만 했어요.

    아직 깊이있고 실전 응용으로 하기에는 학습이 미생이라!

    이제 최신버전을 기준으로 조금씩 블로깅 해나가겠습니다🙌

     

    [참고 자료]

    https://pointfreeco.github.io/swift-composable-architecture/0.41.0/documentation/composablearchitecture/reducerprotocol/

     

    Documentation

     

    pointfreeco.github.io

    https://www.pointfree.co/blog/posts/81-announcing-the-reducer-protocol

     

    Announcing the Reducer Protocol

    Today we are releasing the biggest update to the Composable Architecture ever, completely reimagining how features are built with the library.

    www.pointfree.co

    https://github.com/pointfreeco/swift-composable-architecture/tree/0.50.1

     

    GitHub - pointfreeco/swift-composable-architecture: A library for building applications in a consistent and understandable way,

    A library for building applications in a consistent and understandable way, with composition, testing, and ergonomics in mind. - GitHub - pointfreeco/swift-composable-architecture: A library for bu...

    github.com

Designed by Tistory.