[iOS/SwiftUI] TCA 프로젝트 뜯어보기
https://github.com/pointfreeco/swift-composable-architecture?tab=readme-ov-file#examples
GitHub - pointfreeco/swift-composable-architecture: A library for building applications in a consistent and understandable way,
A library for building applications in a consistent and understandable way, with composition, testing, and ergonomics in mind. - pointfreeco/swift-composable-architecture
github.com
해당 프로젝트를 실행했을때의 앱 화면이다.
TCA GitHub의 Examples 중에서 SearchApp코드를 하나하나 뜯어보자.
Search Reducer
@Reducer
struct Search {
@ObservableState
struct State: Equatable {
var results: [GeocodingSearch.Result] = []
var resultForecastRequestInFlight: GeocodingSearch.Result?
var searchQuery = ""
var weather: Weather?
struct Weather: Equatable {
var id: GeocodingSearch.Result.ID
var days: [Day]
struct Day: Equatable {
var date: Date
var temperatureMax: Double
var temperatureMaxUnit: String
var temperatureMin: Double
var temperatureMinUnit: String
}
}
}
enum Action {
case forecastResponse(GeocodingSearch.Result.ID, Result<Forecast, Error>)
case searchQueryChanged(String)
case searchQueryChangeDebounced
case searchResponse(Result<GeocodingSearch, Error>)
case searchResultTapped(GeocodingSearch.Result)
}
@Dependency(\.weatherClient) var weatherClient
private enum CancelID { case location, weather }
var body: some Reducer<State, Action> {
Reduce { state, action in
switch action {
case .forecastResponse(_, .failure):
state.weather = nil
state.resultForecastRequestInFlight = nil
return .none
case let .forecastResponse(id, .success(forecast)):
state.weather = State.Weather(
id: id,
days: forecast.daily.time.indices.map {
State.Weather.Day(
date: forecast.daily.time[$0],
temperatureMax: forecast.daily.temperatureMax[$0],
temperatureMaxUnit: forecast.dailyUnits.temperatureMax,
temperatureMin: forecast.daily.temperatureMin[$0],
temperatureMinUnit: forecast.dailyUnits.temperatureMin
)
}
)
state.resultForecastRequestInFlight = nil
return .none
case let .searchQueryChanged(query):
state.searchQuery = query
// When the query is cleared we can clear the search results, but we have to make sure to
// cancel any in-flight search requests too, otherwise we may get data coming in later.
guard !state.searchQuery.isEmpty else {
state.results = []
state.weather = nil
return .cancel(id: CancelID.location)
}
return .none
case .searchQueryChangeDebounced:
guard !state.searchQuery.isEmpty else {
return .none
}
return .run { [query = state.searchQuery] send in
await send(.searchResponse(Result { try await self.weatherClient.search(query: query) }))
}
.cancellable(id: CancelID.location)
case .searchResponse(.failure):
state.results = []
return .none
case let .searchResponse(.success(response)):
state.results = response.results
return .none
case let .searchResultTapped(location):
state.resultForecastRequestInFlight = location
return .run { send in
await send(
.forecastResponse(
location.id,
Result { try await self.weatherClient.forecast(location: location) }
)
)
}
.cancellable(id: CancelID.weather, cancelInFlight: true)
}
}
}
}
너무 길다...! 분해해서 알아보자
ObservableState : 감시하는 값들
@ObservableState
// 관리하려는 상태들을 State 구조체 안에 묶어서 접근
struct State: Equatable {
var results: [GeocodingSearch.Result] = []
var resultForecastRequestInFlight: GeocodingSearch.Result?
var searchQuery = ""
var weather: Weather?
struct Weather: Equatable {
var id: GeocodingSearch.Result.ID
var days: [Day]
struct Day: Equatable {
var date: Date
var temperatureMax: Double
var temperatureMaxUnit: String
var temperatureMin: Double
var temperatureMinUnit: String
}
}
}
State구조체 안에 관리하는 상태들을 묶은 모습이다.
하나의 Reducer는 하나의 뷰에서 사용하는 경우가 많기에 어짜피 변수 하나만 바뀌어도 뷰가 업데이트된다.
현재 토이 프로젝트에서는 구조체로 묶지않았는데 이런식으로 묶어서 관리하면 가독성 측면에서 좋을 것 같다.
Action
enum Action {
case forecastResponse(GeocodingSearch.Result.ID, Result<Forecast, Error>)
case searchQueryChanged(String)
case searchQueryChangeDebounced
// 성공 액션과 실패 액션 모두 정의
case searchResponse(Result<GeocodingSearch, Error>)
case searchResultTapped(GeocodingSearch.Result)
}
forecastResponse는 GeocodingSearch.Result.ID (리스트의 번호를 사용하기위해) , Result 제네릭을 매개변수로 사용하는데.
body Reducer 정의 부분에서 성공했을때와 실패했을때를 아래와 같이 모두 정의해 주어야한다.
case .forecastResponse(_, .failure):
state.weather = nil
state.resultForecastRequestInFlight = nil
return .none
// 통신이 성공하면 state.weather에 응답 값 할당
case let .forecastResponse(id, .success(forecast)):
state.weather = State.Weather(
id: id,
days: forecast.daily.time.indices.map {
State.Weather.Day(
date: forecast.daily.time[$0],
temperatureMax: forecast.daily.temperatureMax[$0],
temperatureMaxUnit: forecast.dailyUnits.temperatureMax,
temperatureMin: forecast.daily.temperatureMin[$0],
temperatureMinUnit: forecast.dailyUnits.temperatureMin
)
}
)
state.resultForecastRequestInFlight = nil
return .none
이때 실패했을때는 case 성공했을때는 case let으로 로직을 정의한 것을 볼 수 있는데.
case let으로 정의하면 변수를 캡쳐해서 정의된 로직에 변수를 할당해서 사용할 수 있다.
간단 정리
case만 사용하는 경우
값이 필요하지 않고, 단순히 패턴이 일치하는지 확인할 때 사용된다.
값을 바인딩하지 않기 때문에, 이후 코드에서 해당 값을 사용할 수 없다.
case let을 사용하는 경우
패턴이 일치할 뿐 아니라, 그 값을 바인딩하여 이후 코드에서 사용하고자 할 때 사용된다.
즉, let 키워드를 사용하여 특정 값을 캡처하고 이후 로직에서 그 값을 참조해 사용할 수 있다.
@Dependency
@Dependency(\.weatherClient) var weatherClient
외부 API 통신 설정 전역적으로 불러와서 사용한다.
이 부분은 뒷부분에서 자세하게 알아보자.
Reduce body : state, action 로직 정의
Reduce { state, action in
switch action {
// 통신이 실패 했을때
case .forecastResponse(_, .failure):
state.weather = nil
state.resultForecastRequestInFlight = nil
return .none
// 통신이 성공하면 state.weather에 응답 값 할당
case let .forecastResponse(id, .success(forecast)):
state.weather = State.Weather(
id: id,
days: forecast.daily.time.indices.map {
State.Weather.Day(
date: forecast.daily.time[$0],
temperatureMax: forecast.daily.temperatureMax[$0],
temperatureMaxUnit: forecast.dailyUnits.temperatureMax,
temperatureMin: forecast.daily.temperatureMin[$0],
temperatureMinUnit: forecast.dailyUnits.temperatureMin
)
}
)
state.resultForecastRequestInFlight = nil
return .none
// 검색문장을 바로 state.searchQuery에 패턴매칭
case let .searchQueryChanged(query):
state.searchQuery = query
// When the query is cleared we can clear the search results, but we have to make sure to
// cancel any in-flight search requests too, otherwise we may get data coming in later.
guard !state.searchQuery.isEmpty else {
state.results = []
state.weather = nil
return .cancel(id: CancelID.location)
}
return .none
case .searchQueryChangeDebounced:
// 검색쿼리가 비어있다면
guard !state.searchQuery.isEmpty else {
return .none
}
// 검색쿼리가 비어있지 않다면
return .run { [query = state.searchQuery] send in
await send(.searchResponse(Result { try await self.weatherClient.search(query: query) }))
}
.cancellable(id: CancelID.location)
// 통신에 실패했을때
case .searchResponse(.failure):
// results에 빈 배열을 반환한다.
state.results = []
return .none
// 통신에 성공했을때
case let .searchResponse(.success(response)):
// results에 통신 결과값들을 반환한다.
state.results = response.results
return .none
// 검색 결과를 눌렀을때
case let .searchResultTapped(location):
// 통신중이다 버퍼링 표시
state.resultForecastRequestInFlight = location
return .run { send in
await send(
.forecastResponse(
location.id,
Result { try await self.weatherClient.forecast(location: location) }
)
)
}
// 다른 버튼을 누르면 이전 통신 취소
.cancellable(id: CancelID.weather, cancelInFlight: true)
}
}
실행되는 순서를 살펴보자
- searchQueryChangeDebounced : 검색창 입력값을 감시하고 searchResponse를 실행한다.
- searchResponse : 입력값으로 통신하여 결과값을 리스트에 반환한다.
- searchResultTapped : 리스트를 클릭했을때 해당 리스트 이름을 forecastResponse로직에 담아 실행시킨다.
- forecastResponse : 해당 지역의 날씨정보를 API통신으로 반환한다.
그럼 동작 하나하나 뜯어보자.
searchQueryChangeDebounced
case .searchQueryChangeDebounced:
// 검색쿼리가 비어있다면
guard !state.searchQuery.isEmpty else {
return .none
}
// 검색쿼리가 비어있지 않다면
return .run { [query = state.searchQuery] send in
await send(.searchResponse(Result { try await self.weatherClient.search(query: query) }))
}
.cancellable(id: CancelID.location)
검색쿼리가 비어있으면 return .none으로 아무런 Effect를 실행시키지 않는다.
[query = state.searchQuery]
- 이 부분은 클로저의 캡처 목록인데, 캡처 목록을 사용하여 클로저가 외부 상태를 캡처할 때, 해당 상태의 복사본을 사용할 수 있게한다.
- query = state.searchQuery는 state.searchQuery의 현재 값을 query라는 이름으로 클로저 내부에서 사용할 수 있도록 캡처한다.
send in
- await send(...) 비동기 작업이 완료된 후 호출되어 상태를 업데이트한다.
cancellable(id: CancelID.location)
- 같은 API 통신이 일어나면 기존의 것을 취소한다.
searchResponse
// 통신에 실패했을때
case .searchResponse(.failure):
// results에 빈 배열을 반환한다.
state.results = []
return .none
// 통신에 성공했을때
case let .searchResponse(.success(response)):
// results에 통신 결과값들을 반환한다.
state.results = response.results
return .none
성공했을떄는 state.results에 통신결과값을 담아 화면에 리스트를 표시한다
searchResultTapped
// 검색 결과를 눌렀을때
case let .searchResultTapped(location):
// 통신중이다 버퍼링 표시
state.resultForecastRequestInFlight = location
return .run { send in
await send(
.forecastResponse(
location.id,
Result { try await self.weatherClient.forecast(location: location) }
)
)
}
// 다른 버튼을 누르면 이전 통신 취소
.cancellable(id: CancelID.weather, cancelInFlight: true)
리스트를 클릭했을때 state.resultForecasetRequestInFlight 에 location(GeocodingSearch.results) 값을 담는다
추후 버퍼링 표시의 id와 비교하기 위해서.
.run
- 비동기 작업을 정의하고, 작업이 완료되면 send 클로저를 호출하여 액션을 디스패치하는 역할을 한다.
매개변수로 location.id와 통신결과값을 forecasetResponse에 담아 액션을 실행시킨다.
forecastResponse
case .forecastResponse(_, .failure):
state.weather = nil
state.resultForecastRequestInFlight = nil
return .none
// 통신이 성공하면 state.weather에 응답 값 할당
case let .forecastResponse(id, .success(forecast)):
state.weather = State.Weather(
id: id,
days: forecast.daily.time.indices.map {
State.Weather.Day(
date: forecast.daily.time[$0],
temperatureMax: forecast.daily.temperatureMax[$0],
temperatureMaxUnit: forecast.dailyUnits.temperatureMax,
temperatureMin: forecast.daily.temperatureMin[$0],
temperatureMinUnit: forecast.dailyUnits.temperatureMin
)
}
)
state.resultForecastRequestInFlight = nil
return .none
통신이 끝나면 버퍼링 표시는 필요없으니 성공과 실패의 경우 resultForecastRequestInFlight의 값을 nil로 바꿔준다.
id는 location.id 즉 리스트의 번호다.
통신에서 성공한 값 forecaset를 State.Weather구조체에 담아 state.weather에 할당해준다.
Reducer는 전부 알아보았다.
이제 외부 API 통신 코드를 뜯어보자
전체 코드
import ComposableArchitecture
import Foundation
// MARK: - API models
struct GeocodingSearch: Decodable, Equatable, Sendable {
var results: [Result]
struct Result: Decodable, Equatable, Identifiable, Sendable {
var country: String
var latitude: Double
var longitude: Double
var id: Int
var name: String
var admin1: String?
}
}
struct Forecast: Decodable, Equatable, Sendable {
var daily: Daily
var dailyUnits: DailyUnits
struct Daily: Decodable, Equatable, Sendable {
var temperatureMax: [Double]
var temperatureMin: [Double]
var time: [Date]
}
struct DailyUnits: Decodable, Equatable, Sendable {
var temperatureMax: String
var temperatureMin: String
}
}
// MARK: - API client interface
// Typically this interface would live in its own module, separate from the live implementation.
// This allows the search feature to compile faster since it only depends on the interface.
@DependencyClient
struct WeatherClient {
var forecast: @Sendable (_ location: GeocodingSearch.Result) async throws -> Forecast
var search: @Sendable (_ query: String) async throws -> GeocodingSearch
}
// 실제 통신 전 테스트
extension WeatherClient: TestDependencyKey {
// 여기서의 Self는 WeatherClient
static let previewValue = Self(
forecast: { _ in .mock },
search: { _ in .mock }
)
static let testValue = Self()
}
extension DependencyValues {
var weatherClient: WeatherClient {
get { self[WeatherClient.self] }
set { self[WeatherClient.self] = newValue }
}
}
// MARK: - Live API implementation
extension WeatherClient: DependencyKey {
static let liveValue = WeatherClient(
forecast: { result in
var components = URLComponents(string: "https://api.open-meteo.com/v1/forecast")!
components.queryItems = [
URLQueryItem(name: "latitude", value: "\(result.latitude)"),
URLQueryItem(name: "longitude", value: "\(result.longitude)"),
URLQueryItem(name: "daily", value: "temperature_2m_max,temperature_2m_min"),
URLQueryItem(name: "timezone", value: TimeZone.autoupdatingCurrent.identifier),
]
let (data, _) = try await URLSession.shared.data(from: components.url!)
return try jsonDecoder.decode(Forecast.self, from: data)
},
search: { query in
var components = URLComponents(string: "https://geocoding-api.open-meteo.com/v1/search")!
components.queryItems = [URLQueryItem(name: "name", value: query)]
let (data, _) = try await URLSession.shared.data(from: components.url!)
return try jsonDecoder.decode(GeocodingSearch.self, from: data)
}
)
}
// MARK: - Mock data
extension Forecast {
static let mock = Self(
daily: Daily(
temperatureMax: [90, 70, 100],
temperatureMin: [70, 50, 80],
time: [0, 86_400, 172_800].map(Date.init(timeIntervalSince1970:))
),
dailyUnits: DailyUnits(temperatureMax: "°F", temperatureMin: "°F")
)
}
extension GeocodingSearch {
static let mock = Self(
results: [
GeocodingSearch.Result(
country: "United States",
latitude: 40.6782,
longitude: -73.9442,
id: 1,
name: "Brooklyn",
admin1: nil
),
GeocodingSearch.Result(
country: "United States",
latitude: 34.0522,
longitude: -118.2437,
id: 2,
name: "Los Angeles",
admin1: nil
),
GeocodingSearch.Result(
country: "United States",
latitude: 37.7749,
longitude: -122.4194,
id: 3,
name: "San Francisco",
admin1: nil
),
]
)
}
// MARK: - Private helpers
private let jsonDecoder: JSONDecoder = {
let decoder = JSONDecoder()
let formatter = DateFormatter()
formatter.calendar = Calendar(identifier: .iso8601)
formatter.dateFormat = "yyyy-MM-dd"
formatter.timeZone = TimeZone(secondsFromGMT: 0)
formatter.locale = Locale(identifier: "en_US_POSIX")
decoder.dateDecodingStrategy = .formatted(formatter)
return decoder
}()
extension Forecast {
private enum CodingKeys: String, CodingKey {
case daily
case dailyUnits = "daily_units"
}
}
extension Forecast.Daily {
private enum CodingKeys: String, CodingKey {
case temperatureMax = "temperature_2m_max"
case temperatureMin = "temperature_2m_min"
case time
}
}
extension Forecast.DailyUnits {
private enum CodingKeys: String, CodingKey {
case temperatureMax = "temperature_2m_max"
case temperatureMin = "temperature_2m_min"
}
}
크게 세 부분으로 나뉘어져있다.
1. 통신을 위한 모델링 및 JSONDecoding extension
2. ClientDependency 설정 및 API 통신 정의
3. 테스트를 위한 목 데이터
하나하나 알아보자
@DependencyClient
struct WeatherClient {
var forecast: @Sendable (_ location: GeocodingSearch.Result) async throws -> Forecast
var search: @Sendable (_ query: String) async throws -> GeocodingSearch
}
@DependencyClient
@DependencyClient는 Composable Architecture에서 제공하는 속성 래퍼다.
이 래퍼를 사용하여 의존성을 정의하고 주입할 수 있고, 클라이언트 구조체에 이 속성 래퍼를 사용하면, TCA의 의존성 주입 시스템을 통해 의존성을 쉽게 관리하고, 테스트와 모킹을 지원하는 데 유용하다.
@Sendable
Swift의 새로운 비동기 및 동시성 기능과 관련된 속성. Swift 5.5에서 도입된 비동기 프로그래밍 모델과 관련하여 사용되는데 이 속성은 함수가 스레드와 비동기 컨텍스트에서 안전하게 호출될 수 있음을 나타내는 역할을 한다고 한다.
@Sendable의 역할
- 스레드-안전성 보장
- @Sendable 속성은 해당 클로저나 함수가 스레드 간에 안전하게 전달될 수 있음을 보장한다.
- 이 클로저는 여러 스레드에서 동시에 호출되어도 문제가 없도록 설계되어야 한다.
- 비동기 작업과의 호환성
- Swift의 비동기 프로그래밍 모델에서는 비동기 함수나 클로저를 스레드 간에 안전하게 전달할 수 있어야 한다.
- @Sendable은 이러한 비동기 작업에서 클로저가 안전하게 사용할 수 있음을 보장합니다.
- 클로저의 안전성 검증
- Swift 컴파일러는 @Sendable을 사용하여 클로저가 스레드-안전성을 유지하는지 검사한다.
- 클로저가 공유 상태를 변경하거나 비동기 작업 중에 상태를 접근할 때 스레드-안전성을 보장하기 위해 컴파일 타임 검사를 수행한다.
오류가 발생하면 예외처리를 할 수 있고 반환타입은 각각 모델링에 따라 반환한다.
extension WeatherClient : TestDependencyKey
// 실제 통신 전 테스트
extension WeatherClient: TestDependencyKey {
// 여기서의 Self는 WeatherClient
static let previewValue = Self(
forecast: { _ in .mock },
search: { _ in .mock }
)
static let testValue = Self()
}
목데이터를 집어넣어 테스트를 해볼 수 있게 만들어준다.
주석처리를 해도 사용에는 문제가 없고 @DependencyClient 에서 테스트할 수 있는 기능이다.
extension DependencyValues
extension DependencyValues {
var weatherClient: WeatherClient {
get { self[WeatherClient.self] }
set { self[WeatherClient.self] = newValue }
}
}
DependencyValues 객체에 WeatherClient 인스턴스를 저장하고 접근할 수 있게 한다.
self[WeatherClient.self]
- 이 구문은 DependencyValues에 저장된 WeatherClient의 값을 접근하거나 설정하는데 사용된다.
- self는 DependencyValues 객체를 나타내며, [WeatherClient.self]는 WeatherClient 타입에 대한 키를 나타낸다.
- self[WeatherClient.self]는 DependencyValues에서 WeatherClient 타입의 값을 가져오거나 저장하는데 사용되는 내부 메커니즘이다.
extension WeatherClient: DependencyKey
extension WeatherClient: DependencyKey {
static let liveValue = WeatherClient(
forecast: { result in
var components = URLComponents(string: "https://api.open-meteo.com/v1/forecast")!
components.queryItems = [
URLQueryItem(name: "latitude", value: "\(result.latitude)"),
URLQueryItem(name: "longitude", value: "\(result.longitude)"),
URLQueryItem(name: "daily", value: "temperature_2m_max,temperature_2m_min"),
URLQueryItem(name: "timezone", value: TimeZone.autoupdatingCurrent.identifier),
]
let (data, _) = try await URLSession.shared.data(from: components.url!)
return try jsonDecoder.decode(Forecast.self, from: data)
},
search: { query in
var components = URLComponents(string: "https://geocoding-api.open-meteo.com/v1/search")!
components.queryItems = [URLQueryItem(name: "name", value: query)]
let (data, _) = try await URLSession.shared.data(from: components.url!)
return try jsonDecoder.decode(GeocodingSearch.self, from: data)
}
)
}
DependencyKey 프로토콜
- DependencyKey 프로토콜은 ComposableArchitecture에서 의존성을 정의하는 데 사용된다.
- 이를 통해 애플리케이션의 다양한 부분에서 의존성을 설정하고 가져올 수 있다.
liveValue 프로퍼티
- liveValue는 WeatherClient의 실제 구현을 정의하는 정적 프로퍼티
- 실제 API 호출을 통해 데이터를 가져오는 로직을 포함한다.
- liveValue는 WeatherClient를 실제 환경에서 사용하는 경우의 기본값으로 설정되고 가상 환경이라면 extension WeatherClient : TestDependencyKey 의 목 데이터가 사용된다.
이런 설정을 해두어 Reducer에서 아래와 같이 통신이 가능한 것이었다.
@Dependency(\.weatherClient) var weatherClient
case let .searchResultTapped(location):
// 통신중이다 버퍼링 표시
state.resultForecastRequestInFlight = location
return .run { send in
await send(
.forecastResponse(
location.id,
Result { try await self.weatherClient.forecast(location: location) }
)
)
}
이제 마지막으로 실제 뷰에서 어떻게 사용되는지 알아보면 끝이다...!
SearchView 전체 코드
struct SearchView: View {
@Bindable var store: StoreOf<Search>
var body: some View {
NavigationStack {
VStack(alignment: .leading) {
Text(readMe)
.padding()
HStack {
Image(systemName: "magnifyingglass")
TextField(
"New York, San Francisco, ...", text: $store.searchQuery.sending(\.searchQueryChanged)
)
.textFieldStyle(.roundedBorder)
.autocapitalization(.none)
.disableAutocorrection(true)
}
.padding(.horizontal, 16)
List {
// seoul이라고 검색했을때 seoul과 그와 이름이 유사한 도시들이 List로 나옴
// 이때 location은 Result 구조체
// struct Result: Decodable, Equatable, Identifiable, Sendable {
// var country: String
// var latitude: Double
// var longitude: Double
// var id: Int
// var name: String
// var admin1: String?
// }
ForEach(store.results) { location in
VStack(alignment: .leading) {
Button {
store.send(.searchResultTapped(location))
} label: {
HStack {
Text(location.name)
// List로 나열했을때 id로 구분해서 버퍼링 표시를 하기위해 identifiable처리
if store.resultForecastRequestInFlight?.id == location.id {
ProgressView()
}
}
}
// id 값이 서로 일치하면 해당 리스트 id위치에 검색결과 뷰 표시
if location.id == store.weather?.id {
weatherView(locationWeather: store.weather)
}
}
}
}
Button("Weather API provided by Open-Meteo") {
UIApplication.shared.open(URL(string: "https://open-meteo.com/en")!)
}
.foregroundColor(.gray)
.padding(.all, 16)
}
.navigationTitle("Search")
}
.task(id: store.searchQuery) {
do {
try await Task.sleep(for: .milliseconds(300))
await store.send(.searchQueryChangeDebounced).finish()
} catch {}
}
}
@ViewBuilder
func weatherView(locationWeather: Search.State.Weather?) -> some View {
if let locationWeather {
let days = locationWeather.days
.enumerated()
.map { idx, weather in formattedWeather(day: weather, isToday: idx == 0) }
VStack(alignment: .leading) {
ForEach(days, id: \.self) { day in
Text(day)
}
}
.padding(.leading, 16)
}
}
}
@Bindable로 store를 불러와서 사용한다.
현재 진행중인 토이 프로젝트에서 나는 이렇게 사용중이었다.
@State var store: StoreOf<HomeFeature>
...
CustomTextField(text: $store.message)
// Reducer
switch action {
case .binding(\.message):
// 여기서 사용자 이름 관찰
print ( "toDolMessage" , state.message)
return .none
Bindable은 iOS17 이상에서 지원하여 최대한 많은 사용자가 사용할 수 있도록 @State로 불러와 사용하고
모듈에서 바인딩 값을 사용하려면 Reducer에서 case .binding(\.바인딩값) 으로 binding case를 만들었어야 했다.
가독성 측면에서 우수하지만 내 프로젝트에서 적용하지 못해 아쉬울 따름이다.
.tast(id: store.searchQuery)
- 검색 입력값이 바뀌면 3초의 시간을 가지고 비동기 통신을 실행한다.
전체 동작 시연
예제 코드를 뜯어보니 기존 코드를 어떻게 리팩토링하면 좋아질지 보이고, TCA 구조의 정석을 맛볼 수 있었다.
이제 토이 프로젝트에 적용하러 가보자.