Notice
Recent Posts
Recent Comments
Link
«   2025/03   »
1
2 3 4 5 6 7 8
9 10 11 12 13 14 15
16 17 18 19 20 21 22
23 24 25 26 27 28 29
30 31
Archives
Today
Total
관리 메뉴

J

[SwiftUI] TCA 공식문서로 시작하기_Getting started 본문

SwiftUI

[SwiftUI] TCA 공식문서로 시작하기_Getting started

yujaehui 2024. 8. 20. 00:36

시작하기

Composable Architecture를 프로젝트에 통합하고 첫 번째 애플리케이션을 작성하는 방법을 알아봅시다.


Composable Architecture를 프로젝트에 추가하기

SwiftPM 프로젝트에서 Composable Architecture를 사용하려면 Package.swift의 dependencies 섹션에 추가하고, 해당 라이브러리가 필요한 타겟에서 ComposableArchitecture를 지정해야 합니다.

let package = Package(
  dependencies: [
    .package(
      url: "<https://github.com/pointfreeco/swift-composable-architecture>",
      from: "1.0.0"
    ),
  ],
  targets: [
    .target(
      name: "",
      dependencies: [
        .product(
          name: "ComposableArchitecture",
          package: "swift-composable-architecture"
        )
      ]
    )
  ]
)

첫 번째 기능 구현하기

Composable Architecture를 사용하여 기능을 만들려면 다음과 같은 개념을 정의해야 합니다.

  • State (상태): 기능이 동작하기 위해 필요한 데이터를 표현하는 타입입니다.
  • Action (액션): 사용자 입력, 알림, 이벤트 등 다양한 동작을 표현하는 타입입니다.
  • Reducer (리듀서): 현재 상태를 주어진 액션에 따라 어떻게 변경할지 정의하는 함수입니다. 또한, API 요청과 같은 비동기 작업을 수행하고, 그 결과를 다시 시스템에 전달하는 역할을 합니다.
  • Store (스토어): 기능의 실제 실행 환경입니다. 모든 사용자 액션을 스토어에 전달하면, 스토어는 리듀서를 실행하고 Effect를 처리하며, 상태 변화를 관찰하여 UI를 업데이트합니다.

이 방식의 장점은 기능의 테스트 가능성을 높이고, 복잡한 기능을 작은 도메인 단위로 나누어 유지보수하기 쉽게 만든다는 것입니다.


예제: 숫자 증가/감소 및 랜덤 숫자 정보 가져오기

다음과 같은 UI를 구현해 보겠습니다.

  1. 숫자를 표시하고, +, - 버튼을 눌러 값을 변경할 수 있습니다.
  2. "Number fact" 버튼을 누르면 현재 숫자에 대한 랜덤 정보를 가져와서 알림으로 표시됩니다.

1️⃣ Reducer 정의

Reducer를 사용하여 상태, 액션, 동작을 정의합니다.

struct Feature: Reducer {
}

State 정의

현재 숫자를 저장하는 count와, 랜덤 숫자 정보를 저장하는 numberFactAlert(알림에 표시할 문자열)을 포함합니다.

struct Feature: Reducer {
  struct State: Equatable {
    var count = 0
    var numberFactAlert: String?
  }
}

Action 정의

사용자가 수행할 수 있는 동작을 enum으로 정의합니다.

struct Feature: Reducer {
  struct State: Equatable { /* ... */ }

  enum Action: Equatable {
    case factAlertDismissed
    case decrementButtonTapped
    case incrementButtonTapped
    case numberFactButtonTapped
    case numberFactResponse(String)
  }
}

2️⃣ reduce(into:action:) 메서드 구현

이제 상태를 변경하고 Effect를 반환하는 리듀서를 구현합니다.

struct Feature: Reducer {
  struct State: Equatable { /* ... */ }
  enum Action: Equatable { /* ... */ }

  func reduce(into state: inout State, action: Action) -> Effect {
    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 .run { [count = state.count] send in
        let (data, _) = try await URLSession.shared.data(
          from: URL(string: "<http://numbersapi.com/\\(count)/trivia>")!
        )
        await send(.numberFactResponse(String(decoding: data, as: UTF8.self)))
      }

    case let .numberFactResponse(fact):
      state.numberFactAlert = fact
      return .none
    }
  }
}

3️⃣ SwiftUI View 구현

Composable Architecture에서는 StoreOf<Feature>를 이용하여 상태를 관찰하고 액션을 보낼 수 있습니다.

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

4️⃣ 스토어 생성 및 앱 실행

이제 Store를 생성하여 애플리케이션을 실행할 수 있습니다.

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

5️⃣ 테스트 작성

Composable Architecture의 TestStore를 사용하면 상태 변화와 액션을 쉽게 테스트할 수 있습니다.

@MainActor
func testFeature() async {
  let store = TestStore(initialState: Feature.State()) {
    Feature()
  }

  await store.send(.incrementButtonTapped) {
    $0.count = 1
  }

  await store.send(.decrementButtonTapped) {
    $0.count = 0
  }

  await store.send(.numberFactButtonTapped)
  await store.receive(.numberFactResponse("???")) {
    $0.numberFactAlert = "???"
  }
}

6️⃣ 의존성 주입 및 테스트 개선

API 요청을 테스트하기 위해 Dependency를 주입하면 보다 안정적인 테스트가 가능합니다.

1. 의존성 정의

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

2. 기본 구현 등록

private enum NumberFactClientKey: DependencyKey {
  static let liveValue = NumberFactClient(
    fetch: { number in
      let (data, _) = try await URLSession.shared.data(
        from: URL(string: "<http://numbersapi.com/\\(number)>")!
      )
      return String(decoding: data, as: UTF8.self)
    }
  )
}

extension DependencyValues {
  var numberFact: NumberFactClient {
    get { self[NumberFactClientKey.self] }
    set { self[NumberFactClientKey.self] = newValue }
  }
}

3. Reducer에서 의존성 사용

struct Feature: Reducer {
  @Dependency(\\.numberFact) var numberFact

  func reduce(into state: inout State, action: Action) -> Effect<Action> {
    switch action {
    case .numberFactButtonTapped:
      return .run { [count = state.count] send in
        let fact = try await self.numberFact.fetch(count)
        await send(.numberFactResponse(fact))
      }
    }
  }
}

4. 테스트에서 Mock 데이터 사용

let store = TestStore(initialState: Feature.State()) {
  Feature()
} withDependencies: {
  $0.numberFact.fetch = { "\\($0) is a great number!" }
}

await store.send(.numberFactButtonTapped)
await store.receive(.numberFactResponse("0 is a great number!")) {
  $0.numberFactAlert = "0 is a great number!"
}

참고: https://pointfreeco.github.io/swift-composable-architecture/1.2.0/documentation/composablearchitecture/gettingstarted