Programming/iOS

프로젝트에 Tuist로 TCA 준비하기

게임도우미 2025. 2. 26. 22:35

이전 글에서 Tuist가 준비되었습니다. 다음은 최근 가장 유명한 TCA 입니다.

여러분이 React나 React-Native에서 Redux를 사용해봤다면, 이해하기 쉬우실 겁니다.
TCA도 역시 Reducer를 사용하기 때문이죠.

설치

설치 안내서는 Xcode를 언급합니다만, 저는 Tuist로 프로젝트를 만들었기 때문에 Packages에 추가했습니다.

let project = Project(
    ...,
    packages: [
        ...,
        .remote(
            url: "https://github.com/pointfreeco/swift-composable-architecture",
            requirement: .upToNextMinor(from: "1.9.2"))    
    ],
    targets: [
        .target(
            ...,
            dependencies: [
                ...,
                .package(product: "ComposableArchitecture", type: .runtime)
            ],

이렇게 하면 1.9.2 버전을 설치하거나 패치가 있는 경우 더 상위 버전이 설치될 것 입니다.

설치된 패키지 이름은  ComposableArchitecture가 됩니다.

 

그리고 프로젝트를 생성하면 의존하는 많은 패키지들이 설치됩니다.

신뢰와 활성화

하지만 Swift Macro Policy 때문에 빌드는 할 수 없었습니다. 패키지에 대한 신뢰와 활성화가 필요하기 때문 입니다.

이런 오류들만 나타나서, 프로젝트를 clean하고 다시 열어보니,

경고가 생겼고 선택하면 매크로들을 활성화 할 수 있게 되어 위의 오류들이 사라졌습니다.

사용방법

Import

TCA를 사용하려면, ComposableAchitecture를 import 해야 합니다. 그런데 The는 어디 있는거죠? 🤣

Reducer

@Reducer attribute를 가지고 reducers들을 선언할 수 있습니다.

@Reducer
struct MainReducer {
    
}

State

Reducer는 상태를 가지고 우리는 그 상태 type을 @ObservableState attribute로 지정할 수 있습니다

@Reducer
struct MainReducer {
    @ObservableState
    struct State: Equatable {
        var count = 0
    }
}

Action

Reducer는 또한 eum으로 선언된 Action들을 가집니다. 이것들은 함수가 아닌 Type 입니다.

@Reducer
struct MainReducer {
    ...
    enum Action {
        case countIncreaseButtonTapped
        case countDecreaseButtonTapped
    }
}

Reduce

Reduce는 위에서 선언한 Action에 대한 실제 구현 입니다. 만약 react-redux를 사용해보셨다면 조금 이상하게 느낄 수도 있을 겁니다. redux의 reduce는 새로운 상태를 반환하기 때문이죠.

@Reducer
struct MainReducer {
    ...
    var body: some Reducer<State, Action> {
        Reduce { state, action in
            switch action {
                case .countIncreaseButtonTapped:
                    state.count += 1
                    return .none
                case .countDecreaseButtonTapped:
                    state.count -= 1
                    return .none
            }
        }
    }
}

run

상태 변경이 아닌 다른 작업을 수행하고 싶다면 .none 대신 .run를 사용하세요.

@Reducer
struct MainReducer {
    enum Action {
        ...
        case uploadCount
    }
    
    var body: some Reducer<State, Action> {
        Reduce { state, action in
            switch action {
                ...
                case .uploadCount:
                    return .run { send in
                        // await task...
                    }
                }
        }
    }
}

send

다른 비동기 작업이 완료된 후 상태를 변경하고 싶을 수도 있을 겁니다. 그 때는 .run block에서 send 메소드를 실행해서 상태 변경을 위한 다른 Action을 실행할 수 있습니다.

@Reducer
struct MainReducer {
    @ObservableState
    struct State: Equatable {
        ...
        var uploadingResult = ""
    }
    
    enum Action {
        ...
        case uploadCount
        case updateUploadingResult(result: String)
    }
    
    var body: some Reducer<State, Action> {
        Reduce { state, action in
            switch action {
                ...
                case .uploadCount:
                    return .run { send in
                        // await task...
                        await send(.updateUploadingResult(result: "completed"))
                    }
                case .updateUploadingResult(result: let result):
                    state.uploadingResult = result
                    return .none
            }
        }
    }
}

SwiftUI

State

SwiftUI에서 State를 사용하려면, Store 객체를 생성하세요.

let state = Store(initialState: MainReducer.State()) {
    MainReducer()
}

Store르 주입하려면 StateOf로 State의 Type을 지정합니다.

let state : StoreOf<MainReducer.State>

Action

Action을 호출하려면 어떻게 해야할까요? .run block에서 했던 것 처럼 send에 넣어주면 됩니다. 

state.send(.countIncreaseButtonTapped)

전체 소스

import SwiftUI
import SwiftData
import ComposableArchitecture

@Reducer
struct MainReducer {
    @ObservableState
    struct State: Equatable {
        var count = 0
        var uploadingResult = ""
    }
    
    enum Action {
        case countIncreaseButtonTapped
        case countDecreaseButtonTapped
        case uploadCount
        case updateUploadingResult(result: String)
    }
    
    var body: some Reducer<State, Action> {
        Reduce { state, action in
            switch action {
                case .countIncreaseButtonTapped:
                    state.count += 1
                    return .none
                case .countDecreaseButtonTapped:
                    state.count -= 1
                    return .none
                case .uploadCount:
                    return .run { send in
                        // await task...
                        await send(.updateUploadingResult(result: "completed"))
                    }
                case .updateUploadingResult(result: let result):
                    state.uploadingResult = result
                    return .none
            }
        }
    }
}

struct ContentView: View {
    @Environment(\.modelContext) private var modelContext
    @Query private var items: [Item]
    let state = Store(initialState: MainReducer.State()) {
        MainReducer()
    }

    var body: some View {
        NavigationSplitView {
            Text("\(state.count)")
            List {
                ForEach(items) { item in
                    NavigationLink {
                        Text("Item at \(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))")
                    } label: {
                        Text(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))
                    }
                }
                .onDelete(perform: deleteItems)
            }
            .toolbar {
                ToolbarItem(placement: .navigationBarTrailing) {
                    EditButton()
                }
                ToolbarItem {
                    Button(action: addItem) {
                        Label("Add Item", systemImage: "plus")
                    }
                }
            }
        } detail: {
            Text("Select an item")
        }
    }

    private func addItem() {
        withAnimation {
            let newItem = Item(timestamp: Date())
            modelContext.insert(newItem)
            
            state.send(.countIncreaseButtonTapped)
        }
    }

    private func deleteItems(offsets: IndexSet) {
        withAnimation {
            for index in offsets {
                modelContext.delete(items[index])
                state.send(.countDecreaseButtonTapped)
            }
        }
    }
}

#Preview {
    ContentView()
        .modelContainer(for: Item.self, inMemory: true)
}

 

도움이 되셨다면, 좋아요를.. 더 많은 iOS 관련글을 읽어 보세요.

저와 더 얘기하고 싶다면, LinkedIn으로 오세요. 감사합니다!

문제 해결

target CasePathsMacros must be enabled

Swift Macro들에 대해 신뢰 처리 필요

Clean하고 프로젝트를 다시 불러와서 경고를 클릭

참고자료