[SUSU] 수수앱에서 Navigation 방식을 정의하기 part 2 (TCA With Navigation)

씨마스터300·2024년 6월 14일
1

NavigationWithTCA

목록 보기
2/3
post-thumbnail

part1: https://velog.io/@seemaster300/SUSU-수수앱에서-Navigation-방식을-정의하기-TCA-With-Navigation

현재 상황: Part1의 문제점

과거 파트에서 다룬 가장 큰 문제는 가장 상위 뷰에서 모든 Navigation로직을 정의해주는 일 이었습니다. 이렇게 가장 상위 뷰에 네비게이션 로직을 정의 하는 것은 개발하는데에 있어서 매우 불편한 상황을 자주 맞딱뜨렸습니다. 불편한 상황은 다음과 같습니다.

읽기 어려운 코드

과거에 어떤 코드를 어떻게 짰는지 읽기 위해서 Navigation Reducer를 키면 개발하고 싶은 욕구가 사라질 정도로, 코드를 읽는 것이 너무너무 어려웠습니다.

TopRedcuer에 넌 너무 많은 것을 알고 있어

TopReducer가 너무 무겁다는 생각을 했습니다. TopReducer가 ChildReducer에 State를 생성해주고 이를 Path에 넣어주었습니다. 안에서 보면 TopReducer는 거의 절대자 처럼 모든 것을 알고 있게 되버립니다. 이것이 과연 옳은 설계일까에 대해서 고민하였습니다.



Part2: SwiftUI는 Coordinator패턴을 적용할 수 없나요?

이번 파트는 다음과 같은 의문으로 시작합니다. "SwiftUI는 Coordinator패턴을 적용할 수 없나요?"(많이 활용되는 Child Parent구조의 Coordinator) UIKit에서는 NavigationController를 통해서 UIViewController만 있다면 NavigationController를 통해서 화면전환이 가능했습니다. 구체타입이 아닌 UIViewController를 상속한 class를 통해서 View를 푸쉬할 수 있었습니다. 하지만 SwiftUI에서는 AnyView를 활용하지 않는 이상 뷰의 구체타입을 모른다면 Push가 안됩니다.


SwiftUI뷰를 NavigationController로 바꿔보기

swiftUI뷰를 UIViewController로 래핑할 수 있습니다. 이는 UIHostingController로 가능합니다. 예를 들어서
FirstView를 만든다고 했을때 다음과 같이 코드를 작성할 수 있습니다.

final class FirstViewController: UIHostingController<FirstView> {
  init() {
    super.init(rootView: FirstView())
  }
  @MainActor required dynamic init?(coder aDecoder: NSCoder) {
    fatalError("init(coder:) has not been implemented")
  }
}

struct FirstView: View {
  var body: some View {
    VStack {
      Text("Im swift UI View")
    }
  }
}

화면



우리는 일단 ViewController를 만들었습니다. 하지만 ViewController가 View를 보여주지 않고 단순히 NavigationController와 연결하기 위한 Wrapper역할만 합니다. 한단계 추상화를 더 거쳐도 되지만, 뷰를 그리는 책임이 SwiftUI View에 전가된 ViewController를 통해서도 Coordinator패턴을 만들 수 있다고 생각했습니다.(Coordinator가 화면전환의 책임을 가진다고 생각했을 때)

그러면 NavigaitonController에서 다음과 같은 코드를 작성할 수 있습니다.



// 첫번째 ViewController
final class FirstViewController: UIHostingController<FirstView> {
  var reducer: FirstReducer
  var destinationSubscriber: AnyCancellable? = nil
  
  // SwiftUI View를 만들기 위해서 필요한 인자들을 init함수를 통해서 받습니다. 
  init(state: FirstReducer.State, reducer: FirstReducer) {
    self.reducer = reducer
    super.init(rootView: FirstView(store: .init(initialState: state, reducer: {
      reducer
    })))
  }
  override func viewDidLoad() {
    super.viewDidLoad()
    
    // 초기 인자로 받은 Reudcer내부의 publisher를 통한 화면 전환에 로직입니다.
    // Combine을 활용하여 가고싶은 화면을 갑니다.
    destinationSubscriber = reducer
      .publisher
      .sink { [weak self] destination in
        let pushViewController: UIViewController
        switch destination {
        case .secondScreen:
          pushViewController = SecondViewController(state: .init(), reducer: .init())
        case .thirdScreen:
          pushViewController = ThirdViewController(state: .init(), reducer: .init())
        }
        self?.navigationController?.pushViewController(pushViewController, animated: true)
      }
  }
  
  @MainActor required dynamic init?(coder aDecoder: NSCoder) {
    fatalError("init(coder:) has not been implemented")
  }
}

// 첫번째 뷰가 갈 수 있는 경로
enum FirstViewPushDestinations {
  case secondScreen
  case thirdScreen
}

그리고 ViewController에 대응되는 Reducer와 SwiftUIView는 다음과 같이 작성할 수 있습니다.


// 첫번째 뷰 리듀서
@Reducer
struct FirstReducer {
  struct State {
    var onAppear: Bool = false
  }
  
  enum Action {
    case navigationSecondScreen
    case navigationThirdScreen
    case push(FirstViewPushDestinations)
  }
  
  // 화면전환을 위해 사용되는 Publisher 
  var publisher: PassthroughSubject<FirstViewPushDestinations, Never> = .init()
  
  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case .navigationSecondScreen:
        return .send(.push(.secondScreen))
      case .navigationThirdScreen:
        return .send(.push(.thirdScreen))
      case let .push(destination):
        publisher.send(destination)
        return .none
      }
    }
  }
}

이를 구조도로 나타내면 다음과 같습니다.

구조도

  1. AViewController 생성
  2. AViewReducer의 Publisher 구독
  3. AViewReducer에서 Publisher Event발생
  4. AViewController에서 Event를 확인하고 적절한 View를 생성
  5. AViewController의 NavigationController를 통해 적절한 View로 푸쉬

Pros

  • TopReducer에서 모든 객체를 만들지 않음
    • Part1에서 보았던 Top Reducer가 짊어졌던 하위 리듀서들의 Action을 관찰하는 것이 줄어들었습니다.
    • 이를 통해서 Reducer가 상위 객체에 종속적이지 않게 되었습니다.
  • 객체지향적 설계
    • part1의 가장 큰 단점은 역시 변경사항 마다 이전 객체를 "수정" 해야한다는 것 입니다.
    • 객체를 수정한다는 것 자체가 side effect가 발생할 수 있다는 위험에 놓이게 되는데 이를 완전하게 회피할 수 있습니다.
    • Reducer는 View와 관련된 로직과 도메인 로직, ViewController는 Navigation 로직, View는 View의 로직으로 매우 객체 지향적 설계를 확인할 수 있습니다.
  • 가독성 향상
    • 이전의 문제였던 가독성이 매우 향상되었습니다. 코드를 일렬로 느려뜨리지 않게 되었습니다.

cons

  • 이전보다 작성해야하는 코드의 증가
    • 객체지향적으로 설계하게 되면 역시 피할 수 없는 코드의 증가입니다.(이는 실력이 부족해서 그런 것 같습니다.)
    • UIHostingController, Reducer, View, NavigationPath 만 해도 벌써 4개의 파일을 추가해야 합니다
    • 만약 Protocol을 통해 대부분의 객체를 추상화 한다면 코드양은 훨씬 더 늘어납니다.
  • TCA의 Navigation 함수 사용 불가
    • TCA는 SwiftUINavigation이라는 하위 Library가 있습니다. 이는 매우 편한 방식으로 네비게이션 할 수 있는데 이를 활용하지 못합니다.
    • 가령 View에서 Custom action을 통해서 dismiss하기 위해서는 TCA의 dismiss()가 아닌 NavigationPublisher를 통해서 dismiss해야 합니다.

화면

전체 코드

누를 시 깃허브로 이동됩니다.

profile
승리하면 작은 것을 배울 수 있다. 그러나 패배하면 모든 것을 배울 수 있다.

0개의 댓글

관련 채용 정보