[iOS] MVI 패턴에 대하여

유인호·2024년 7월 31일
1

iOS

목록 보기
59/73
post-custom-banner

0. 서론

저번 게시물에서 다양한 아키텍쳐의 종류와 나의 생각들, 그리고 회사 프로젝트는 MVI 아키텍쳐로 정했다.

이번 게시물에선 MVI가 무엇인지, iOS 프로젝트에선 MVI를 어떻게 적용시킬 수 있는지에 대해 서술해보도록 하겠다.

1. MVI가 뭔데?

MVI는 단방향 아키텍쳐이다. Model - View - Intent로 이루어져 있다.
Model -> View
View-> Intent
Intent -> Model
이렇게 단방향으로만 이벤트와 데이터들이 흘러간다고 생각하면 좋다.

흔한 MVVM을 기준으로 들자면, Input과 Output을 분리했다고 봐도 좋을 것이다.

사진 출처

사진은 MVVM 관점에서 보았을때 MVI 흐름을 나타낸 그림이다.

2. SwiftUI에서의 MVI

SwiftUI MVI를 검색해보면 크게 MVI가 두가지로 나뉘어져 있는걸 알 수있다.

1. 안드로이드 느낌의 MVI

코드 출처

// MVI Container
final class MVIContainer<Intent, Model>: ObservableObject {

    // 2
    let intent: Intent
    let model: Model

    private var cancellable: Set<AnyCancellable> = []

    init(intent: Intent, model: Model, modelChangePublisher: ObjectWillChangePublisher) {
        self.intent = intent
        self.model = model

        // 3
        modelChangePublisher
            .receive(on: RunLoop.main)
            .sink(receiveValue: objectWillChange.send)
            .store(in: &cancellable)
    }
}

// Model
protocol ListModelStateProtocol {
    var text: String { get }
}

// 2
protocol ListModelActionsProtocol: AnyObject {
    func parse(number: Int)
}

// 1
final class ListModel: ObservableObject, ListModelStatePotocol {
    @Published var text: String = ""
}

// 2
extension ListModel: ListModelActionsProtocol {

    func parse(number: Int) {
        text = "Random number: " + String(number)
    }
}

// View
struct ListView: View {

    // 1
    @StateObject private var container: MVIContainer<ListIntent, ListModelStatePotocol>

    var body: some View {
        // 2
        Text(container.model.text)
            .padding()
            .onAppear(perform: {
                // 3
                self.container.intent.viewOnAppear()
            })
    }
}

// Intent
final class ListIntent {

    // 1
    private weak var model: ListModelActionsProtocol?

    init(model: ListModelActionsProtocol) {
        self.model = model
    }

    func viewOnAppear() {
        let number = Int.random(in: 0 ..< 100)

        // 2
        model?.parse(number: number)
    }
}


// MVI Build
extension ListView {

    static func build() -> some View {
        let model = ListModel()
        let intent = ListIntent(model: model)

        let container = MVIContainer(
            intent: intent,
            model: model as ListModelStatePotocol,
            modelChangePublisher: model.objectWillChange)

        return ListView(container: container)
    }
}

Intent와 Model을 프로토콜로 Interface를 만들어서 사용한다. 처음에 이 방법의 MVI를 선택했던 이유는 Intent와 Model 계층이 확실하게 나뉘어 있기 때문이였다. 또한 Protocol로 Interface를 만들어 분리하기 때문에 각각 분리되어있다는걸 확실히 할 수 있었다.

다만 문제되는 점은 복잡하다. 또한 Protocol로 하게 된다면 function들이 다이나믹 디스패치로 작동하기 때문에 느려지게 된다는 문제가 생긴다.

또한, 이 방법으로 하면 가장 결정적인건 withAnimation이 안먹힌다. SwiftUI에서 애니메이션을 적용하기 위한 가장 좋은 방법인데, 이 방법으로 사용하면 문제가 생긴다.

그래서 나는 흔히 TCA라고 불리는 라이브러리의 MVI 느낌으로 커스텀하여 다시 리팩토링을 하게 되었다.

2. TCA느낌의 MVI

코드출처

이 코드는 상단의 사진을 그대로 코드화 시켰다고 보면 좋을 것 같다.

// Model(State)
struct CounterState {
    var count: Int = 0
}

// View
struct ContentView: View {
    @StateObject private var viewModel = CounterViewModel()

    var body: some View {
        VStack {
            Text("Count: \(viewModel.state.count)")
                .padding()
            HStack {
                Button("Increase") {
                    viewModel.process(intent: .increase)
                }
                Button("Decrease") {
                    viewModel.process(intent: .decrease)
                }
            }
            .padding()
        }
    }
}

// Intent
enum CounterIntent {
    case increase
    case decrease
}

// ViewModel (State + Intent)
final class CounterViewModel: ObservableObject {
    @Published private(set) var state = CounterState()

    /// Intent 에서 Model(State) 의 상태 변화를 일으키는 메서드
    /// - Parameter intent: CounterIntent
    ///
    ///  Ex) intent 가 increase 이면, state 의 count 를 1 증가 시킴
    func process(intent: CounterIntent) {
        switch intent {
        case .increase:
            state.count += 1
        case .decrease:
            state.count -= 1
        }
    }
}

이 코드는 여러 장점이 있다.

  1. 코드가 복잡하지 않다. (러닝커브가 MVI 치고는 적다)
  2. 다이나믹 디스패치가 아니기 때문에 빠르게 동작한다.
  3. state가 private(set)이기 때문에 MVI의 정의에도 충족된다.

다만 단점으로는..

  1. State와 Intent가 코드상으로 분리가 되었다곤 볼 수 없다.
  2. 그래서 조금 복잡하게 가면 ViewModel이 굉장히 뚱뚱해질 수 있을 것 같다.

그러나 이정도는 Extension으로 Function들을 분리하고, private처리 등으로 어느정도 커버가 될 수 있을 것이라고 생각하였고, 이대로 리팩토링을 하게 되었다.

3. 자매품

MVI를 하게되면 Binding을 사용하지 못한다. Binding은 양방향으로 데이터를 주고 받기 때문.

그래서 이러한 방식으로 Bidning을 처리할 수 있다.

 private func binding(for keyPath: WritableKeyPath<AppState, String>) -> Binding<String> {
        Binding(
            get: { viewModel.state[keyPath: keyPath] },
            set: { newValue in
                viewModel.send(.updateText(newValue))
            }
        )
    }
    
    
TextField("Enter text", text: binding(for: \.text))
profile
🍎Apple Developer Academy @ POSTECH 2nd, 🌱SeSAC iOS 4th
post-custom-banner

0개의 댓글