HIT해

[iOS/Swift] TCA 본문

Swift/Swift CS

[iOS/Swift] TCA

힛해 2024. 8. 20. 14:02
728x90

TCA란?

TCA(The Composable Architecture)는 Swift와 SwiftUI를 사용하여 애플리케이션의 상태 관리와 비즈니스 로직을 다루기 위한 아키텍처.

TCA는 Point-Free라는 소프트웨어 개발 회사에서 만든 오픈 소스 라이브러리

구성요소

  1. State (상태): 애플리케이션의 현재 상태
  2. Action (액션): 상태를 변경하는 이벤트
  3. Reducer (리듀서): 액션에 따라 상태를 변경하는 함수
  4. Store (스토어): 상태, 리듀서, 및 액션을 관리하는 중앙 저장소
  5. Environment (환경): 외부 시스템과의 상호작용을 추상화한 것

 

구성요소 구체예시

  • State (상태):
    • 애플리케이션의 현재 상태를 나타내는 구조체
    • 상태는 애플리케이션에서 중요한 데이터와 UI 상태를 포함
    • 예시: 사용자 정보, UI의 현재 페이지, 로딩 상태 등
    struct AppState : Equipable {
        var counter: Int
        var isLoading: Bool
        var Tmp : User
    }
    
    
  • Action (액션):
    • 상태를 변경하는 이벤트를 나타내는 열거형
    • 액션은 사용자의 입력, 네트워크 응답 등 상태를 변화시키는 모든 이벤트를 포함
    • 예시: 버튼 클릭, 데이터 로드 완료 등
    enum AppAction {
        case increment
        case decrement
        case setLoading(Bool)
    }
    
    
  • Reducer (리듀서):
    • 액션에 따라 상태를 변경하는 순수 함수
    • 리듀서는 현재 상태와 발생한 액션을 받아 새로운 상태를 반환
    • 예시: 카운터 증가, 감소 등의 로직 처리
    let appReducer = Reducer<AppState, AppAction, AppEnvironment> { state, action, environment in
        switch action {
        case .increment:
            state.counter += 1
            return .none
        case .decrement:
            state.counter -= 1
            return .none
        case .setLoading(let isLoading):
            state.isLoading = isLoading
            return .none
        }
    }
    
    
  • Store (스토어):
    • 상태, 리듀서 및 액션을 관리하는 중앙 저장소
    • 스토어는 상태를 보유하고 있으며, 액션이 발생하면 리듀서를 통해 상태를 업데이트
    • 또한, 스토어는 뷰와 연동되어 상태가 변경될 때 자동으로 UI를 업데이트
    • 예시: ViewModel 역할 수행
    let store = Store(
        initialState: AppState(counter: 0, isLoading: false),
        reducer: appReducer,
        environment: AppEnvironment()
    )
    
    
  • Environment (환경):
    • 외부 시스템과의 상호작용을 추상화한 구조체입니다. 여기에는 API 호출, 데이터베이스 액세스 등의 기능이 포함될 수 있습니다.
    • 예시: 네트워크 서비스, 로컬 데이터베이스 등
    struct AppEnvironment {
        var apiClient: APIClient
    }
    
    

 

MVVM을 대체해서 도입되는 이유

  1. 구조화된 상태 관리: TCA는 상태 관리와 비즈니스 로직을 명확하게 분리하여, 코드의 복잡성을 줄이고 유지 보수 용이
  2. 일관된 데이터 흐름: 단방향 데이터 흐름을 통해 데이터가 어떻게 변경되고 전달되는지 쉽게 추적
  3. 테스트 용이성: 리듀서와 액션은 순수 함수로 작성되므로 단위 테스트가 용이
  4. SwiftUI와의 자연스러운 통합: SwiftUI의 선언적 프로그래밍 패턴과 잘 맞아떨어지며, 뷰 업데이트를 자동으로 처리
  5. 확장성: 애플리케이션이 커질수록 모듈화와 확장이 용이

TCA는 보다 명확하고 구조화된 방법으로 상태 관리를 제공하여 대규모 애플리케이션에서도 일관성을 유지

더보기

TCA는 일관되고 포괄적으로 애플리케이션을 개발할 수 있도록 도와주는 라이브러리 입니다.TCA는 단방향 데이터 흐름 구조를 갖고 있어, 데이터 흐름을 파악하기가 쉽다보니 상태 변화를 추적하기가 쉽습니다.가장 큰 특징이라면 @Published 관련 속성을 사용하지 않아 코드 추적 및 테스트를 더 쉽다는 점입니다.TCA는 단방향 데이터 흐름을 강조하기에 State가 Reducer를 통해 업데이트되고 View에서 직접 변경될 수 없다는 것을 의미합니다.

TCA는 일관되고 포괄적으로 애플리케이션을 개발할 수 있음.

단방향 데이터 흐름 구조를 갖고 있고, 데이터 흐름을 파악하기 쉽다보니 상태변화를 추적하기 쉽다.

가장 큰 특징으로는 @Published 관련 속성을 사용하지 않아 코드 추적 및 테스트가 더 쉽다.

TCA는 단방향 데이터 흐름을 강조하기에 State가 Reducer를 통해 업데이트 되고 View에서 직접 변경될 수 있음

 

실제로 만들어보자

**카운터피처.스위프트
import ComposableArchitecture

@Reducer
struct CounterFeature {
  @ObservableState
  struct State {
    var count = 0
    var fact: String?
    var isLoading = false
  }
  
  enum Action {
    case decrementButtonTapped
    case factButtonTapped
    case factResponse(String)
    case incrementButtonTapped
  }
  
  var body: some ReducerOf {
    Reduce { state, action in
      switch action {
      case .decrementButtonTapped:
        state.count -= 1
        state.fact = nil
        return .none
        
      case .factButtonTapped:
        state.fact = nil
        state.isLoading = true
        return .run { [count = state.count] send in
          let (data, _) = try await URLSession.shared
            .data(from: URL(string: "<http://numbersapi.com/\\(count)>")!)
          let fact = String(decoding: data, as: UTF8.self)
          await send(.factResponse(fact))
          // send(.action명)으로 동작시킨다.
          // run : 비동기 작업을 실행하고, 작업이 완료되면 특정 액션을 보내는 Effect를 생성
          // API 호출 , 타이머, 데이터 처리 등 비동기 작업을 수행하고 그 결과에 따라 상태를 업데이트 헤야할때 사용
          
        }
        
      case let .factResponse(fact):
        state.fact = fact
        state.isLoading = false
        return .none
        // 왜 none 이냐 ObservableState로 감시하고있기때문애 값이 변경되면 자동으로 UI가 업데이트되기 때문이다.
        // 액션에 비동기 작업이 필요 없을때 사용
        
      case .incrementButtonTapped:
        state.count += 1
        state.fact = nil
        return .none
      }
    }
  }
}**

 

다른예시

 if state.isTimerRunning {
          return .run { send in
            while true {
              try await Task.sleep(for: .seconds(1))
              await send(.timerTick)
            }
          }
          .cancellable(id: CancelID.timer)
        } else {
          return .cancel(id: CancelID.timer)
        }

.return .run { send in … } :

  • send : 액션을 보내는 함수, 비동기 작업이 완료되거나 반복적으로 실행될때 이 함수를 사용해 액션을 디스패치함

Task.sleep(for:) : 지정된 시간 동안 비동기적으로 대기

try await : 비동기 작업이 완료될떄까지 기다림

cancellable(id : CancelID.timer)

  • Effect에 취소 가능성을 추가.

return cancel

  • 타이머가 실행중이지 않은 경우 이전에 설정된 타이머를 취소
  • CancelID.timer를 사용해 타이머를 식별하고 취소

여러 store 구성방법

import ComposableArchitecture
import SwiftUI

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")
        }
    }
  }
}

 

리듀서구성

@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
    }
  }
}

 

데이터 사전 입력

#Preview {
  ContactsView(
    store: Store(
      initialState: ContactsFeature.State(
        contacts: [
          Contact(id: UUID(), name: "Blob"),
          Contact(id: UUID(), name: "Blob Jr"),
          Contact(id: UUID(), name: "Blob Sr"),
        ]
      )
    ) {
      ContactsFeature()
    }
  )
}