안녕하세요. 후르륵짭짭입니다.
거의 한달만에 글을 작성하게 됐네요.
이제 Composable Architecture의 핵심 로직의 마무리 단계에 온 것 같습니다.
이전에 PullBack과 Store 에 대해서 알게 됐는데요.
이번에는 Effect와 Composable Architecture의 Testing을 통해 알게 된 점에 대해 작성해보려고 합니다.
** Effect **
Composable Architecture에서 Effect라는 것은 Input에 대한 결과를 Ouput으로 전달하는 것을 의미합니다.
즉, Effect의 사전적인 의미가 효과 인것 처럼 입력이 주어졌을 때 해당 함수를 수행하고 다음 함수는 어떤 것인지 알려주는 작업을 합니다.
Combine으로 결합하여 만들어 줄 수 있는데,
public struct Effect<Output> : Publisher {
public typealias Failure = Never
let publisher: AnyPublisher<Output, Failure>
public func receive<S>(subscriber: S) where S : Subscriber, Never == S.Failure, Output == S.Input {
self.publisher.receive(subscriber: subscriber)
}
}
Effect는 다양한 타입을 받을 수 있게 Generic으로 만들고 비동기를 위해서 Combine의 Publisher 프로토콜을 채택해준다.
Publisher가 있다는 것은 Subscriber도 있어야하는 것인데, 이 말은 Combine의 Sink가 존재할 것이고
public typealias Reducer<Value,Action> = (inout Value, Action) -> [Effect<Action>]
public final class Store<Value, Action>: ObservableObject {
private let reducer : Reducer<Value,Action>
@Published public private(set) var value : Value
private var viewCancellable: Cancellable?
private var effectCancellables: Set<AnyCancellable> = []
public init(value: Value, reducer : @escaping Reducer<Value,Action> ) {
self.value = value
self.reducer = reducer
}
public func send(_ action : Action) {
let effects = self.reducer(&self.value, action)
effects.forEach({[weak self] effect in
guard let self = self else {return}
var effectCancellable: AnyCancellable?
var didComplete = false
effectCancellable = effect.sink(receiveCompletion: { _ in
didComplete = true
guard let effectCancellable = effectCancellable else {return}
self.effectCancellables.remove(effectCancellable)
}, receiveValue: { newAction in
self.send(newAction)
})
if !didComplete, let effectCancellable = effectCancellable {
self.effectCancellables.insert(effectCancellable)
}
})
}
이 Effect를 Send에서 sink subscribe로 받고 있다.
public typealias Reducer<Value,Action> = (inout Value, Action) -> [Effect<Action>]
Reducer는 (State, Enum Action) 을 입력으로 받아서 Effect<Enum Action>을 배열로 반환하게 type을 지정한다.
effect.sink(receiveCompletion: { _ in
didComplete = true
guard let effectCancellable = effectCancellable else {return}
self.effectCancellables.remove(effectCancellable)
}, receiveValue: { newAction in
self.send(newAction)
})
그리고 이렇게 배열의 Effect를 하나씩 돌면서 각각 sink를 하게 되고 receviceValue에 값이 존재한다면 다시 send 메소드 호출을 통해 다음 Effect를 수행해준다.
이렇게만 보면 이해하기 어렵습니다.
struct FavoritePrimeTestView: View {
var body: some View {
NavigationView(content: {
FavoritePrimesView(store: .init(value: .init(favoritePrimes: [2,3,5,7,11], activityFeed: []), reducer: FavoritePrimeReducer.shared.favoritePrimesReducer))
})
}
}
public struct FavoritePrimesView: View {
@ObservedObject var store : Store<FavoritePrimesState,FavoritePrimeAction>
public init(store: Store<FavoritePrimesState, FavoritePrimeAction>) {
self.store = store
}
public var body: some View {
List(content: {
ForEach(self.store.value.favoritePrimes , id: \.self, content: { prime in
Text("\(prime)")
})
.onDelete(perform: { indexSet in
self.store.send(.deleteFavoritePrime(indexSet))
})
})
.navigationTitle("Favorite Primes")
.toolbar(content: {
ToolbarItem(placement: .navigationBarTrailing, content: {
HStack(content: {
Button("Save",action: {
self.store.send(.saveButtonTapped)
})
Button("Load", action: {
self.store.send(.loadButtonTapped)
})
})
})
})
}
}
이렇게 FavoritePrimeView라는 SwifUI가 있다고 합시다.
Reducer에 favoritePrimesReducer가 들어가게 됩니다. 그렇다면 View에서 "Load"를 누르게 됐을 때,
Store의 send를 수행하게 되고 "loadButtonTapped" Enum Action을 수행하게 됩니다.
public func favoritePrimesReducer(state : inout FavoritePrimesState , action : FavoritePrimeAction) -> [Effect<FavoritePrimeAction>] {
switch action {
case .deleteFavoritePrime(let indexSet):
for index in indexSet {
state.favoritePrimes.remove(at: index)
}
return []
case .loadFavoritePrime(let favoritePrimes):
state.favoritePrimes = favoritePrimes
return []
case .saveButtonTapped:
// return [saveEffect(favoritePrimes: state.favoritePrimes)]
return [
Current.fileClient.save("favorite-primes.json", try! JSONEncoder().encode(state.favoritePrimes))
.fireAndForget()
]
case .loadButtonTapped:
// return [loadEffect.compactMap({$0}).eraseToEffect()]
return [
Current.fileClient.load("favorite-primes.json")
.compactMap({$0})
.decode(type: [Int].self, decoder: JSONDecoder())
.catch({ error in Empty(completeImmediately: true)})
.map({FavoritePrimeAction.loadFavoritePrime($0)})
.eraseToEffect()]
}
}
그렇다면 load 메소드를 수행하게 되는데, 마지막에 FavoritePrimeAction.loadFavoritePrime Action을 반환하고 있습니다.
*** 참고로 eraseToEffect() 는 Combine에서 eraseToAnypubliser랑 같은 겁니다. ***
extension Publisher where Failure == Never {
public func eraseToEffect() -> Effect<Output> {
return Effect(publisher: self.eraseToAnyPublisher())
}
}
//Publiser를 받아서 Effect Publiser로 변경해주는 겁니다.
또한 Void(Empty)와 같이 결과 값을 받지 않는 경우에도
Effect의 특정 Action 타입으로 변환이 필요할 때는 아래와 같은 메소드를 만들어 사용했습니다.
extension Publisher where Output == Never, Failure == Never {
public func fireAndForget<A>() -> Effect<A> {
return self.map(absurd).eraseToEffect()
}
func absurd<A>(_ never: Never) -> A {}
}
이렇게 배열 형태의 Effect를 받게 된다면 Store의 Send에서 Effect의 Sink를 받고 있으니, 결과 값으로 loadFavoritePrime Action을 받게 될 겁니다.
effect.sink(receiveCompletion: { _ in
didComplete = true
guard let effectCancellable = effectCancellable else {return}
self.effectCancellables.remove(effectCancellable)
}, receiveValue: { newAction in
self.send(newAction) //여기서 loadFavoritePrime를 수행하게 됩니다.
})
그럼 Send는 계속 반복 되면서 Effect 들을 수행하게 되는 형식이 되는 겁니다.
지금 까지 순차적인 Effect를 알아 봤습니다.
그렇다면 비동기적인 Effect는 어떻게 처리하는지 보도록 하겠습니다.
이번 예시로는 CounterView를 알아보도록 하겠습니다.
struct CounterTestView: View {
var body: some View {
CounterView(store: .init(value: CounterViewState.init(alertNthPrime: nil, count: 0, favoritePrimes: [], isNthPrimeButtonDisabled: false), reducer: logging(counterViewReducer)))
}
}
이렇게 CounterView가 존재하고 Reducer로 두개의 Reducer가 Pullback으로 합쳐져있습니다.
그 중에서 counterReducer를 알아보도록 하겠습니다.
public var counterViewReducer = combine(
pullBack(counterReducer, value: \CounterViewState.counter, action: \CounterViewAction.counter),
pullBack(primeModelReducer, value: \.primeModalState, action: \.primeModel))
public func counterReducer(state : inout CounterState, action: CounterAction) -> [Effect<CounterAction>] {
switch action {
case .decrTapped:
state.count -= 1
return []
case .incrTapped:
state.count += 1
return []
case .nthPrimeButtonTapped:
state.isNthPrimeButtonDisabled = true
let effect = Current.nthPrime(state.count) // nthPrime(state.count)
.map({ count in
CounterAction.nthPrimeResponse(count)
})
.receive(on: DispatchQueue.main)
.eraseToEffect()
return [
effect
]
case .nthPrimeResponse(let prime):
state.alertNthPrime = prime.map({PrimeAlert(prime: $0)})
state.isNthPrimeButtonDisabled = false
return []
case .alertDismissButtonTapped:
state.alertNthPrime = nil
return []
}
}
만약에 CounterReducer의 nthPrimeButtonTapped의 Action이 수행 되었다고 합시다.
그렇다면 nthPrime이라는 메소드가 수행이 될 것인데, 아래와 같이 모든 것이 Combine으로 처리되고 있습니다.
func nthPrime(_ n : Int) -> Effect<Int?> {
return wolframAlpha(query: "prime \(n)")
.map({ result in
result.flatMap({
$0.queryresult.pods.first(where: {$0.primary == .some(true)})?.subpods.first?.plaintext
})
.flatMap(Int.init)
}).eraseToEffect()
}
func wolframAlpha(query: String) -> Effect<WolframeAlphaResult?> {
var components = URLComponents(string: "https://api.wolframalpha.com/v2/query")!
components.queryItems = [
URLQueryItem(name: "input", value: query),
URLQueryItem(name: "format", value: "plaintext"),
URLQueryItem(name: "output", value: "JSON"),
URLQueryItem(name: "appid", value: wolframAlphaApiKey),
]
return URLSession.shared.dataTaskPublisher(for: components.url(relativeTo: nil)!)
.map({ data, _ in data})
.decode(type: WolframeAlphaResult?.self, decoder: JSONDecoder())
.replaceError(with: nil)
.eraseToEffect()
}
사실상 Combine을 사용하면 순차적인 것과 비동기적인 차이가 없습니다.
결국 Reducer에 Action이 들어오면 해당 Action에 맞는 Effect를 반환하고
이 Effect를 Store의 Send 메소드에서 처리하게 되는 겁니다.
그러니깐 nthPrime 메소드를 수행하면 서버 Api로 받은 결과 값을 담아야하는 Effect를 만들어지게 되고 Send 메소드에서
서버로 부터 결과 값인 nthPrimeResponse Action을 기다립니다.
Send에서는 Action을 받게 되면 해당 Action을 다시 수행하게 합니다.
이렇게 Effect에 대해서 알아봤는데, 이해가 잘 됐을지 모르겠습니다.
이 외에도 이렇게 Effect에 다양한 기능들을 만들어 줄 수 있습니다.
extension Effect {
public static func fireAndForget(work: @escaping () -> Void) -> Effect {
return Deferred.init { () -> Empty<Output, Never> in
work()
return Empty(completeImmediately: true)
}.eraseToEffect()
}
}// 특정 작업을 하고 빈 Effect를 반환하는 Effect의 Factory 메소드
// 예시
Effect.fireAndForget(work: {
let data = try! JSONEncoder().encode(data)
let documentPath = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true)[0]
let documentsUrl = URL(fileURLWithPath: documentPath)
let favoritePrimeUrl = documentsUrl.appendingPathComponent(fileName)
try! data.write(to: favoritePrimeUrl)
})
뿐만 아니라
public static func sync(work: @escaping () -> Output) -> Effect {
return Deferred(createPublisher: {
Just(work())
}).eraseToEffect()
}// 특정 작업을 하고 결과값을 반환이 필요한 경우
// 예시
Effect<Data?>.sync(work: {
let documentPath = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true)[0]
let documentsUrl = URL(fileURLWithPath: documentPath)
let favoritePrimeUrl = documentsUrl.appendingPathComponent(fileName)
return try? Data(contentsOf: favoritePrimeUrl)
})
이렇게 다양한 Effect Factory 메소드를 만들 수도 있습니다.
지금까지 Effect에 대해 알아봤는데, 이해하기 어려울거라 생각이 됩니다.
저도 이해하기 쉽지 않았지만 천천히 생각해보니 더 공부가 되었습니다.
나중에는 TCA를 활용하여 어떻게 Testing을 하면 좋은지 작성하도록 하겠습니다.
'Design Pattern' 카테고리의 다른 글
SwiftUI) The Composable Architecture - 1 (feat: State Management, Store, Action, Reducer) (0) | 2023.08.26 |
---|
댓글