[TCA] Migration to 1.10 from 1.8

정유진·2024년 5월 14일
0

swift

목록 보기
25/25
post-custom-banner

🎢 들어가며

2024년 02월에 첫 commit을 찍은 나의 TCA 프로젝트는 당시 최신 버전이었던 1.8.2 버전에 의존하고 있다. 현 시점 pointfreeco는 1.10.2 를 24년 5월 12일에 릴리즈하였다. 소스 프리징은 몇 주 앞둔 시점이라 무리하게 버전업하기 보다는 1.9, 1.10 버전에 어떤 변화가 생겼는지 Migration Guide를 기반으로 살펴보고 내 프로젝트에 반영하면 좋을 것을 추려보려한다.

https://github.com/pointfreeco/swift-composable-architecture/releases/tag/1.10.4
그들의 표현을 빌리자면,
"The Composable Architecture is under constant development, and we are always looking for ways to
simplify the library, and make it more powerful."
정말 더욱 편리해지고 강력해진 라이브러리이다.

🏄🏻‍♀️ Migrating to 1.9

1. TestStore 사용 구문의 변화

  • the ability to receive test store actions using case key path syntax, massively simplifying how one asserts on actions received in test
  • 1.8.2에서 그 이상 버전의 CaseStudies 예제를 보았을 때 내 이목을 끌었던 것 중의 하나가 @CasePathable이다. 이 macro를 통해 테스트 코드를 작성할 때에 boiler plate가 줄어들게 되었다.
  • 이 @CasePathable은 pointfreeco가 만든 스펙이다. SwiftUI는 @Binding을 구현하는 데에 keypath 특히 WritableKeyPath 에 의존하고 있다. 문제는 struct 타입은 keypath를 기본 지원하지만 enum 타입은 똑같은 value type임에도 저장 프로퍼티를 가지고 있지 않으므로 keypath를 지원하지 않는다는 점이다. TCA에서 @Reducer 자체는 struct이지만 state든 action이든 enum 타입을 사용하고 있어서 부득이하게 이러한 casepathable이 필요했을 것으로 생각된다.
  • 이 CasePathable에 대하여서는 따로 다루도록하고 여기에서는 이를 도입함으로서 테스트 코드가 어떻게 달라지는 지만 기록하겠다.
  • 이 변화에 대해 pointfreeco는 massively simplifying 되었다고 말하고 있다.
-store.send(.path(.element(id: 0, action: .destination(.presented(.record(.startButtonTapped))))))
+store.send(\.path[id: 0].destination.record.startButtonTapped)

2. @Dependency 사용의 변화

  • Unidirectional Data Flow를 구현함에 있어 외부 세계(서버 등)와 통신할 때에 필연 Effect에 의지하게 되는데 이 Effect 쪽을 별도의 객체로 분리하고자 Dependency를 사용하게 된다.
  • 아래는 Reducer에서 사용하는 Dependency의 실체인 DependencyClient이다. 이는 인터페이스(프로토콜)를 선언한 것에 가깝고 실제 구현체는 별도의 extension으로 분리하여서 구현하는 것이 일반적이다.
@DependencyClient
struct DownloadClient {
  var download: @Sendable (_ url: URL) -> AsyncThrowingStream<Event, Error> = { _ in .finished() }

  @CasePathable
  enum Event: Equatable {
    case response(Data)
    case updateProgress(Double)
  }
}
  • 별도 extension으로 분리하여 구현하는 이유는 liveValue, testValue를 분리하여 test를 용이하게 하고 Preview 지원을 하기 위해서이다.
  • 아직 API 서버가 준비되지 않은 경우 testValue를 통해 mockData를 주입할 수 있다.
extension DownloadClient: DependencyKey {
  static let liveValue = Self(
    download: { url in
      .init { continuation in
        Task {
          do {
            let (bytes, response) = try await URLSession.shared.bytes(from: url)
            var data = Data()
            var progress = 0
            for try await byte in bytes {
              data.append(byte)
              let newProgress = Int(
                Double(data.count) / Double(response.expectedContentLength) * 100)
              if newProgress != progress {
                progress = newProgress
                continuation.yield(.updateProgress(Double(progress) / 100))
              }
            }
            continuation.yield(.response(data))
            continuation.finish()
          } catch {
            continuation.finish(throwing: error)
          }
        }
      }
    }
  )

  static let testValue = Self()
}
  • 위와 같이 DependencyClient를 등록했다면 Reducer 안에서 @Dependency로 선언하여 별도의 init 없이 사용할 수 있다. Dependency가 Injection 된다고 이해하면 된다.
  • 아래의 코드에서 self.downloadClient.download 를 사용할 수 있는 이유이다.
@Reducer
struct DownloadComponent {
  struct State: Equatable {
    @PresentationState var alert: AlertState<Action.Alert>?
    let id: AnyHashable
    var mode: Mode
    let url: URL
  }

  // action 생략

  @Dependency(\.downloadClient) var downloadClient

  var body: some Reducer<State, Action> {
   Reduce { state, action in
      switch action {
      case .buttonTapped:
        switch state.mode {
        // ... case 생략

        case .notDownloaded:
          state.mode = .startingToDownload

          return .run { [url = state.url] send in
            for try await event in self.downloadClient.download(url: url) {
              await send(.downloadClient(.success(event)), animation: .default)
            }
          } catch: { error, send in
            await send(.downloadClient(.failure(error)), animation: .default)
          }
          .cancellable(id: state.id)
        }
  • 기존 1.8.2에서는 dependency에서 keypath를 사용하기 위해서 굳이 dependecyValues에 등록을 해주어야 했는데 이 과정이 1.9 이상에서는 생략되었다.
extension DependencyValues {
  var downloadClient: DownloadClient {
    get { self[DownloadClient.self] }
    set { self[DownloadClient.self] = newValue }
  }
}
  • Dependency 선언부의 변화는 아래와 같다.
-@Dependency(\.apiClient) var apiClient
+@Dependency(APIClient.self) var apiClient

🏄🏻‍♀️ Migrating to 1.10

1. Sharing State

  • 이 글을 쓰게 된 이유가 @Shared의 등장 때문이라고 해도 과언이 아니다. 이 Shared를 쓰고싶어서 1.10으로 단번에 올리고 싶다.
  • 기존 State가 Scope를 지원하기 때문에 Super-Sub하게 계층을 둘 수 있지만 Scope가 채가는 것은 action 뿐이다. Super의 State를 Sub에 공유하고 Sub 내에서 해당 state가 변경되었을 때에 Super에게 이를 notify 하는 데에 한계가 있었다. (부모의 state 값을 전달할 수는 있다. 좀 부자연스러울 뿐이지.)
  • 물론 자식의 action이 return된 후 부모 reducer에게 dispatch되기 때문에 콜백 함수를 사용하듯 부모 reducer에서 parameter를 받아 로직을 처리할 수는 있지만 이는 부모의 state를 받은 parameter로 set 해주는 것이지 state 자체가 공유되는 것은 아니었다.
  • data가 unidirectional하게 흐르기 때문에 어쩔 수 없다는 생각은 들지만 다른 프레임워크도 기어코 이벤트를 emit한다던지해서 부모에게 자식의 상태 변경을 알려줄 방법이 있기 때문에 sdk 자체에서 지원해주길 기다리던 찰나에 1.10에 @Shared 가 등장했다. 환영!🤗
@ObservableState
struct State {
  @Shared var signUpData: SignUpData
  // ...
}
  • This will require that SignUpData be passed in from the parent, and any changes made to this state will be instantly observed by all features holding onto it
  • Further, there are persistence strategies one can employ in @Shared - .appStorage, .fileStorage
  • userDefaults나 file에 기록하여 persistent 하게 쓰고 읽을 수 있다는 점이 편리하다.
  • 기존 프로젝트에서는 @AppStorage를 onChange(of:)와 같이 쓰거나 Reducer 의 state에 computed property를 선언하고 get/set 될 때에 별도로 UserDefaults에 쓰거나 읽어오는 방법을 썼는데 이를 하나로 단축할 수 있게 되었다.
class testView: View {

@AppStorage("savedProp") var myName: String = ""
@Bindable var store: StoreOf<PersonalStore>

var body: some View {
	WithPerceptionTracking {
    	SomeView()
        	.onChange(of: myName) { newValue in 
            	store.send(.write(newValue))

// OR

@Reducer
struct PersonalStore {
	@ObservableState
    struct State: Equatable {
    	var name: String {
        	get {
            	if let name = UserDefaults.standard.object(forKey: nameKey) as? String {
                	return name
                }
                return ""
			set {
            	UserDefaults.standard.set(newValue, forKey: nameKey)
  • 위와 같은 번잡한 코드가 아래와 같이 한 줄로 정리된다.
@Reducer
struct PersonalStore {
	@ObservableState
    struct State {
    	@Shared(.appStorage(nameKey)) var name = "defaultName"
  • 원래 Shared는 기본 initValue가 필요없지만 appStorage와 같이 쓸 때에는 해당 key값으로 value가 존재하지 않을 때 반환할 기본값 설정이 필요하다.
  • .fileStorage를 사용하면 UserDefaults에는 기본 원시 타입만 저장할 수 있다는 한계를 벗어나 Codable한 모든 객체를 저장할 수 있다. 정말 멋져!👏👏👏
@ObservableState
struct State: Equatable {
  @Presents var destination: Destination.State?
  @Shared(.fileStorage(.syncUps)) var syncUps: IdentifiedArrayOf<SyncUp> = []
}

🍫참고자료

pointfreeco가 소개하는 Shared
https://www.pointfree.co/blog/posts/134-sharing-state-in-the-composable-architecture
간략한 예제 코드
https://github.com/pointfreeco/swift-composable-architecture/blob/7bd346042b3168894b538f49803d25497885c81c/Examples/SyncUps/SyncUps/SyncUpsList.swift#L18

profile
느려도 한 걸음 씩 끝까지
post-custom-banner

0개의 댓글