안녕하세요. 후르륵짭짭입니다.
이전에 Composable Architecture에 대한 글을 처음으로 작성 했는데,
이번에는 해당 글의 연장선으로 글을 작성해보도록 하겠습니다.
** Reducer **
이전에 AppState와 AppAction에 대해서 알아봤습니다.
Reducer는 행위에 대한 구체성을 의미합니다.
즉, AppState로 받은 정보와 AppAction으로 통해 어떤 행위를 할 것인지
구체적인 행위에 대한 정의를 내리는 함수라 생각하면 되겠습니다.
public struct FavoritePrimesState {
public var favoritePrimes: [Int]
public var activityFeed : [Activity]
public init(favoritePrimes: [Int], activityFeed: [Activity]) {
self.favoritePrimes = favoritePrimes
self.activityFeed = activityFeed
}
}
public enum FavoritePrimeAction {
case deleteFavoritePrime(IndexSet)
}
public func favoritePrimesReducer(state : inout FavoritePrimesState , action : FavoritePrimeAction) {
switch action {
case .deleteFavoritePrime(let indexSet):
for index in indexSet {
state.favoritePrimes.remove(at: index)
}
}
}
이러한 코드가 있다고 할 때, favoritePrimesReducer에서 State와 Action 두개의 값을 받습니다.
FavoritePrimeAction을 통해서 입력 받은 값이 deleteFavoritePrime이라면
FavoritePrimesState에서 받은 favoritePrimes 배열에서 해당 인덱스의 값을 제거합니다.
이렇게 Reducer라는 것은 AppState와 Action을 통해서 받은 값을 토대로 구체성을 정의하는 것 입니다.
** PullBack **
Reducer가 많을 때 어떻게 Store에 reducer를 전달할 수 있을까요?
public final class Store<Value, Action>: ObservableObject {
private let reducer : (inout Value, Action) -> Void
@Published public private(set) var value : Value
private var cancellable: Cancellable?
public init(value: Value, reducer : @escaping (inout Value, Action) -> Void ) {
self.value = value
self.reducer = reducer
}
public func send(_ action : Action) {
self.reducer(&self.value, action)
}
,,, 생략 ,,,
}
위에서 볼 때 Init 시점에 reducer 함수를 받고 있습니다.
그런데 reducer가 여러개일 때는 굉장히 난해하죠.
그래서 pullBack이라는 메소드가 존재합니다.
public func pullBack<LocalValue, GlobalValue, GlobalAction, LocalAction> (
_ reducer: @escaping (inout LocalValue, LocalAction) -> Void,
value: WritableKeyPath<GlobalValue, LocalValue>,
action: WritableKeyPath<GlobalAction, LocalAction?>) -> (inout GlobalValue , GlobalAction) -> Void {
return { globalValue , globalAction in
guard let localAction = globalAction[keyPath: action] else {return}
reducer(&globalValue[keyPath: value], localAction)
}
}
PullBack 메소드는 결국 LocalValue와 LocalAction을 받아서 GlobalValue와 GlobalAction을 반환하는 함수 입니다.
즉, 타입이 다른 값을 공통된 타입으로 전달 한다는 겁니다.
예를들어 Int로 받았지만 해당 값이 GlobalValue에 존재한다면 타입이 달라도 사용할 수 있게하는거죠.
// MARK: -Combine
public func combine<Value, Action>(_ reducers : (inout Value, Action) -> Void...) -> (inout Value, Action) -> Void {
return { value , action in
for reducer in reducers {
reducer(&value,action)
}
}
}
일단 Combine이라는 메소드를 만들어 줍니다.
이것은 GlobalValue와 GlobalAction을 받아서 동일한 값을 리턴하는 함수인데,
모든 reducer를 수행해주는 함수입니다.
let appReducer: (inout AppState, AppAction) -> Void = combine(
pullBack(counterViewReducer, value: \.counterView, action: \.counterView),
pullBack(favoritePrimesReducer, value: \.favoritePrimesState, action: \.favoritePrimes)
)
combine에 대해서 알았으니, appReducer에서 리턴값을 AppState와 AppAction으로 정해줬으니,
GlobalValue와 GlobalAction이 AppState와 AppAction으로 고정이 됐습니다.
이제 Value와 Action에는 LocalValue와 LocalAction이 들어가게 이에 대한 정의는 reducer 메소드에서 해주게 됩니다.
여기서는 favoritePrimesReducer와 counterViewReducer가 그 역할을 해주겠네요.
그리고 마지막으로 value는 writablekeypath를 통해서 KeyPath를 통해 해당 변수 값을 가져올 수 있도록 합니다.
위에 이미지 처럼 LocalValue인 Int를 가져올 수 있게 됩니다.
** Higher Reducer **
Higher reducer는 Reducer의 상위 메소드 호출을 의미합니다.
그러니깐 공통적으로 수행할 함수에 대해서 해당 메소드를 호출해줄 수 있겠네요.
예를들어 Logging 시스템일 경우에 Reducer가 사용될 때 공통적으로 적용해주고 싶다면 일반적인 Reducer 위에
덮어주는 겁니다.
// MARK: -HeighOrder
public func logging<Value, Action>(
_ reducer: @escaping (inout Value, Action) -> Void
) -> (inout Value, Action) -> Void {
return { value, action in
reducer(&value, action)
print("Logging - Action: \(action)")
print("State:")
dump(value)
print("---")
}
}
이렇게 Redcuer를 수행하고 나서 로그를 찍어주는 겁니다.
public func compose<A>(
_ highOrders: (A) -> A...
)
-> (A) -> A {
return { (function: A) -> A in
return highOrders.reversed().reduce(function) { partialResult, orderfunc in
orderfunc(partialResult)
}
// return highOrders[0](highOrders[1](highOrders[2](function)))
}
}
public func with<A, B>(_ a: A, _ f: (A) throws -> B) rethrows -> B {
return try f(a)
}
이렇게 higher reducer를 도와주는 compose와 with를 통해서 아래와 같이 Reducer에 공통적으로 적용해줄 수 있는
HighReducer를 적용해줄 수 있습니다.
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView(store: Store(value: AppState(), reducer: with(appReducer, compose(logging, activityFeed) ) ))
}
}
** View **
View는 상위 View에서 다른 View로 이동할 때 State값과 Action을 해당 하위 View의 맞는 상태로 변경해주는 메소드 입니다.
// MARK: -STORE
public final class Store<Value, Action>: ObservableObject {
private let reducer : (inout Value, Action) -> Void
@Published public private(set) var value : Value
private var cancellable: Cancellable?
public init(value: Value, reducer : @escaping (inout Value, Action) -> Void ) {
self.value = value
self.reducer = reducer
}
public func send(_ action : Action) {
self.reducer(&self.value, action)
}
public func view<LocalValue, LocalAction>(
value toLocalValue: @escaping (Value) -> LocalValue,
action toGlobalAction: @escaping (LocalAction) -> Action
) -> Store<LocalValue, LocalAction> {
let localStore = Store<LocalValue, LocalAction>.init(value: toLocalValue(self.value), reducer: { [weak self] inoutLocalValue, localAction in
self?.send(toGlobalAction(localAction)) // 부모에게 변경 된 사항을 알려줌
inoutLocalValue = toLocalValue(self!.value)
})
localStore.cancellable = self.$value.sink(receiveValue: { [weak localStore] newValue in
localStore?.value = toLocalValue(newValue) //자녀에게 부모가 변경 됬을 때 알려줌
})
return localStore
}
}
View 메소드는 이렇게 Store에 포함되어 있으며 상위 View에서 하위 View로 이동할 때 State와 Action을
하위 view에 맞도록 변경해주고 상태 변경 값을 부모와 하위 에게 모두 알려주는 기능을 합니다.
그냥 보면 이해하기 어렵지만 Layer로 보면 이해하기 쉬울 겁니다.
- App View -
@main
struct StateManagerApp: App {
var body: some Scene {
WindowGroup {
ContentView(store: Store(value: AppState(), reducer: logging(activityFeed(appReducer)) ))
}
}
}
가장 상위 AppView에서
ContentView에게 가장 상위인 AppState와 Reducer를 정의해줍니다.
- Content View -
struct ContentView: View {
@ObservedObject var store : Store<AppState,AppAction>
var body: some View {
NavigationView(content: {
List(content: {
NavigationLink(destination: {
CounterView(store: store.view(value: {$0.counterView}, action: { .counterView($0)
}))
}, label: {
Text("Counter demo")
})
NavigationLink(destination: {
FavoritePrimesView(store: self.store.view(value: {$0.favoritePrimes}, action: {.favoritePrimes($0)}))
}, label: {
Text("Favorite Primes")
})
})
.navigationTitle("State management")
})
}
}
extension AppState {
var counterView: CounterViewState {
get {
CounterViewState(count: self.count, favoritePrimes: self.favoritePrimes)
}
set{
self.count = newValue.count
self.favoritePrimes = newValue.favoritePrimes
}
}
var favoritePrimesState: FavoritePrimesState {
get {
FavoritePrimesState(favoritePrimes: self.favoritePrimes, activityFeed: self.activityFeed)
}
set {
self.favoritePrimes = newValue.favoritePrimes
self.activityFeed = newValue.activityFeed
}
}
}
enum AppAction {
case counterView(CounterViewAction)
case favoritePrimes(FavoritePrimeAction)
var counterView : CounterViewAction? {
get{
guard case let .counterView(counterViewAction) = self else {
return nil
}
return counterViewAction
}
set{
guard case .counterView = self, let newValue = newValue else {return}
self = .counterView(newValue)
}
}
var favoritePrimes: FavoritePrimeAction? {
get {
guard case let .favoritePrimes(value) = self else { return nil }
return value
}
set {
guard case .favoritePrimes = self, let newValue = newValue else { return }
self = .favoritePrimes(newValue)
}
}
}
그리고 ContentView에서 자신의 Store 객체에 있는 view 메소드를 호출해줍니다.
이때 Store의
Value : AppState
Action : AppAction
LocalValue : CounterViewState
LocalAction : CounterViewAction
이렇게 될 겁니다.
이때 View의 파라미터로 value가 있는데 이는 AppState를 CounterViewState로 변경해주는 겁니다.
value toLocalValue: @escaping (Value) -> LocalValue
따라서 AppState에 있는 counterView Compute property를 전달하게 됩니다.
var counterView: CounterViewState {
get {
CounterViewState(count: self.count, favoritePrimes: self.favoritePrimes)
}
set{
self.count = newValue.count
self.favoritePrimes = newValue.favoritePrimes
}
}
반대로 Action은 CounterViewAction을 받아서 AppAction으로 바꿔주도록 되어 있습니다.
action toGlobalAction: @escaping (LocalAction) -> Action
따라서 아래 부분을 넣어주게 되는 겁니다.
case counterView(CounterViewAction)
case favoritePrimes(FavoritePrimeAction)
var counterView : CounterViewAction? {
get{
guard case let .counterView(counterViewAction) = self else {
return nil
}
return counterViewAction
}
set{
guard case .counterView = self, let newValue = newValue else {return}
self = .counterView(newValue)
}
}
,,, 생략 ,,,
}
이제 View에 대한 코드를 좀더 분석하자면
let localStore = Store<LocalValue, LocalAction>.init(value: toLocalValue(self.value), reducer: { [weak self] inoutLocalValue, localAction in
self?.send(toGlobalAction(localAction)) // 부모에게 변경 된 사항을 알려줌
inoutLocalValue = toLocalValue(self!.value)
})
localStore.cancellable = self.$value.sink(receiveValue: { [weak localStore] newValue in
localStore?.value = toLocalValue(newValue) //자녀에게 부모가 변경 됬을 때 알려줌
})
이렇게 되어 있습니다.
CounterView는 아래의 형태로 Init을 store를 받기 때문에 그에 맞게 Store를 반환해줘야하죠.
public struct CounterView : View {
@ObservedObject var store : Store<CounterViewState,CounterViewAction>
//Local State
@State var isPrimeModelShown : Bool = false
@State var alertNthPrime : Bool = false
@State var alertNthPrimeDisable : Bool = false
public init(store : Store<CounterViewState,CounterViewAction>) {
self.store = store
}
,,, 생략 ,,,
}
따라서 이 부분을 자세하게 본다면,
let localStore = Store<LocalValue, LocalAction>.init(value: toLocalValue(self.value), reducer: { [weak self] inoutLocalValue, localAction in
self?.send(toGlobalAction(localAction)) // 부모에게 변경 된 사항을 알려줌
inoutLocalValue = toLocalValue(self!.value)
})
현재 부모View의 State 정보를 받아서 자녀View의 State로 변경해주는 겁니다.
즉, AppState를 받아서 CounterViewState로 변경하고
reducer는 Send 메소드에서 호출 되기 때문에 CounterViewAction을 받게 되고
이를 AppAction으로 변경해준뒤에 부모의 send 메소드를 전달하게 됩니다.
이러면 결국 최초 reducer에게 전달이 되는거죠.
그리고 변경된 내용을 inout 이기 때문에 자녀View가 변경 된 것을 인지할 수 있게 해줍니다.
그리고 만약 자녀의 State가 변경이 됐다면,
localStore.cancellable = self.$value.sink(receiveValue: { [weak localStore] newValue in
localStore?.value = toLocalValue(newValue) //자녀에게 부모가 변경 됬을 때 알려줌
})
위 부분의 호출되어 자녀에게 부모가 변경 되었다는 것을 알려주게 해줍니다.
댓글