본문 바로가기
Architecture

[SwiftUI] MVI Architecture 적용해보기

by yangsubinn 2023. 3. 14.

1️⃣ MVI 등장 배경 / 필요성

SwiftUI에서 MVVM..?

1. MVVM에서의 ViewModel의 역할

  • View의 이벤트와 그에 대한 액션을 바인딩
  • View(VC)에서 어떤 이벤트를 받은 경우, 이벤트에 대한 처리 + 그 결과로 수행할 액션(UI 업데이트 등)을 View에 전달

SwiftUI에서 자체적으로 제공해주는 Binding을 통해 이벤트에 대한 처리를 한다면 비교적 간단하게 처리할 수 있고,

MVVM에서 ViewModel의 역할이 크게 줄어들게 됩니다.

 

2. MVVM의 양방향 데이터 플로우

MVVM은 아래와 같이 양방향 데이터 플로우를 제공합니다.

하지만 SwiftUI는 단방향 데이터 플로우를 기반으로 하기 때문에, 양방향 데이터 플로우인 MVVM은 조금 적합하지 않아보였습니다.

 

2️⃣ MVI

MVI(Model - View - Intent)는 MVC, MVP, MVVM과 같은 계열의 아키텍쳐 패턴으로,

단방향 데이터 흐름을 제공합니다.

  1. Model
    • 앱의 상태(State)를 나타내는 데이터 모델
    • Intent로부터 데이터를 받아 UI로 보여주기 위해 준비하고, 현재 상태(가장 최근 데이터)를 가짐
    • 항상 View의 현 상태(State == Model)을 유지
    • View 참조
  2. View
    • 전달받은 Model(상태)로 UI 제공
    • Intent 참조
  3. Intent
    • View에서 event를 받아 business logic을 실행
    • Model 참조

 

💭 SwiftUI에서 Model이 Struct 타입인 View를 참조할 수 있나..?

Model이 업데이트 될 때 View에 반영하기 위해선 View를 참조해야 하는데,

SwiftUI의 View는 Struct 타입이기 때문에 참조할 수 없습니다.

그래서 Container라는 개념이 등장하게 됩니다.

Container
- Intent와 Model에 대한 참조를 관리하고 접근성을 제공해주는 역할
- View 생성시 container를 포함하여 생성

 

3️⃣ MVI 예시

아래의 예시보다 더 간단한 MVI 예시는 링크에서 확인하실 수 있습니다.

 

예시 앱 내의 기능

‘강아지’ 키워드 검색 결과 이미지 가져오는 기능

  • 최대 노출되는 이미지 10

로딩 중 / 통신 완료 후 / 통신 실패할 경우

 

구현

💡 MVI는 크게 Model → Intent → View(Container) 순서로 구현했습니다.

1. ModelStateProtocol, ModelActionsProtocol, Model
- view에 보여질 state와 intent에서 실행할 model의 action 정의

2. IntentProtocol, Intent
- view에서 event가 발생했을때 실행할 로직 구현

3. MVIContainer, View
- Model과 Intent를 포함하는 container 생성, 연결 </aside>

 

1. ContentState

  • ContentState에 따라 View에서 보일 컨텐츠 분리
enum ContentState {
    case loading
    case content(images: [ImageItem])
    case error
}

 

2. ModelStateProtocol

  • View에 보여질 모든 Model(상태)를 선언
    • 텍스트, 이미지, 뷰 분기처리 조건 등
    • ex) navigationTitle, loadingText, contentState
  • Model로 View를 그릴 때, 내부 로직은 숨기고 properties에 대한 접근만 허용하기 위해 action과 protocol 분리
protocol SearchModelStateProtocol {
    var contentState: ContentState { get }
    var navigationTitle: String { get }
    var loadingText: String { get }
    var errorText: String { get }
}

 

 

3. ModelActionsProtocol

  • Model 내의 값을 바꾸기 위한 로직 함수 정의
    • Model 내의 상태 값 변경, UI 적용을 위해 데이터 가공 등
  • Intent를 통해서만 호출
protocol SearchModelActionsProtocol: AnyObject {
    func displayLoading()
    func updateImage(images: [ImageItem])
    func displayError()
}

 

 

4. Model

  • ModelStateProtocol과 ModelActionsProtocol을 상속받아 구현
    • View에 보여질 State(= Model) 구현 (SearchModelStateProtocol)
    • intent로부터 데이터 또는 이벤트를 받아 처리할 로직 구현 (SearchModelActionsProtocol)
  • ObservableObject + @Published 선언
    • 변경될 경우 바로 View에 전달되어 업데이트 되어야 하는 변수( = model = state)
    • 변경을 감지하여 바로 업데이트 해야되는 변수가 아닌 경우 @Published 선언 불필요
final class SearchModel: ObservableObject, SearchModelStateProtocol {
    @Published var contentState: ContentState = .loading
    @Published var navigationTitle: String = "검색 중.."
    let loadingText: String = "잠시만 기다려주세요💨"
    let errorText: String = "사진 불러오기에 실패했습니다🥺"
}

extension SearchModel: SearchModelActionsProtocol {
    func displayLoading() {
        contentState = .loading
    }
    
    func updateImage(images: [ImageItem]) {
        navigationTitle = "검색 결과🔍"
        contentState = .content(images: images)
    }
    
    func displayError() {
        navigationTitle = "에러 발생"
        contentState = .error
    }
}

 

 

5. IntentProtocol

  • View에서 event 받아오기 위한 Protocol
  • View에서 발생할 이벤트에 대한 함수 정의 ex) tappedButton
protocol SearchIntentProtocol {
    func viewOnAppear()
}

 

 

6. Intent

  • View에서 어떤 이벤트가 들어올 경우 실행할 비즈니스 로직 구현
  • Intent는 ModelActions에만 접근 (ModelStateProtocol X)
    • 서버 통신이 있는 경우, Intent에서 해당 로직 실행 후 그 결과에 model에 어떤 actions을 처리하라고 전달
import SwiftUI

class SearchIntent {
    private weak var model: SearchModelActionsProtocol?
    
    init(model: SearchModelActionsProtocol) {
        self.model = model
    }
}

extension SearchIntent: SearchIntentProtocol {
    func viewOnAppear() {
        model?.displayLoading()
        
        ImageService.shared.fetchImageSearchData { [weak self] response in
            guard let self = self else { return }
            switch response {
            case .success(let data):
                guard let data = data as? ImageEntity else { return }
                let images = data.items.map { $0.toModel(id: 0) } // entity를 UI에 적용하기 좋은 model로 변환
                self.model?.updateImage(images: images)
            default:
                self.model?.displayError()
            }
        }
    }
}

 

 

7. Container

  • Intent와 Model을 받는 클래스
    • Intent와 Model에 대한 참조 구현
  • init의 modelChangePublisher
    • Model의 변화 감지
      • modelChangePublisher: model.objectWillChange
    • Model에서의 변화가 감지되면 Container가 아닌 View로 연결
import SwiftUI
import Combine

final class MVIContainer<Intent, Model>: ObservableObject {
		
    let intent: Intent
    let model: Model

    private var cancellable: Set<AnyCancellable> = []

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

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

 

 

8. View.build()

  • model과 intent를 묶은 container를 포함하는 View 생성하여 리턴해주도록 하는 함수
    • ContentView()가 아닌, .build()를 통해 containter까지 포함한 view return
  • @StateObject 프로퍼티로 container 선언
    • View가 다시 생성될 때 Intent와 Model도 다시 생성하지 않도록 하기 위해
import SwiftUI

// MARK: - ContentView

struct ContentView: View {

    @StateObject var container: MVIContainer<SearchIntentProtocol, SearchModelStateProtocol>
		...
}

// MARK: - ContentView+Build

extension ContentView {
	/// Container와 View 생성
    static func build() -> some View {
        let model = SearchModel()
        let intent = SearchIntent(model: model)
        let container = MVIContainer(intent: intent as SearchIntentProtocol,
                                     model: model as SearchModelStateProtocol,
                                     modelChangePublisher: model.objectWillChange)
        let view = ContentView(container: container)
        return view
    }
}

// MARK: - Pinterest_MVIApp

@main
struct Pinterest_MVIApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView.build()
        }
    }
}

 

 

9. View event 전달 + model로 view 구성

  • view에 user event가 들어오면 container.intent에 intent에 전달
  • container.model을 통해 model( = state = data)를 가져와 보여줌
import SwiftUI

struct ContentView: View {
    
    @StateObject var container: MVIContainer<SearchIntentProtocol, SearchModelStateProtocol>
    private var state: SearchModelStateProtocol { container.model }
    private var intent: SearchIntentProtocol { container.intent }
    
    var body: some View {
        NavigationView {
            ZStack {
                switch state.contentState {
                case .loading:
                    Text(state.loadingText)
                case .content(let images):
                    ScrollView {
                        LayoutView(imageData: images)
                            .padding()
                    }
                default:
                    Text(state.errorText)
                }
            }
            .onAppear {
                intent.viewOnAppear()
            }
            .navigationTitle(state.navigationTitle)
        }
    }
}

 

4️⃣ MVI 장·단점

데이터가 어디서 오고 - 어디로 가는지 파악이 쉽기 때문에 코드가 더 간단해집니다.

한가지 요소의 규모가 과도하게 커질 가능성이 적어집니다.

그리고 Model 자체가 State 역할을 하여 새로운 Model을 전달하면 그에 따라 새로운 View를 그리게 되는데,

이 흐름에 따라 State와 View는 항상 동일한 상태 즉 최신화 상태를 유지하게 됩니다.

😀 장점
- 데이터의 흐름이 한 방향으로 정해져 있어 흐름을 이해하고 관리하기 쉬움
- 상태 문제(부수 효과) 발생 가능성이 적음
😩 단점
- 다른 MV*에 비해 러닝 커브가 높음
- 작은 변경이 생겨도 intent를 통해야 하기 때문에, 작은 앱에서도 최소한의 intent와 model을 가져야 함

 

📁 참고자료

 

https://broken-bytes.medium.com/using-the-mvi-pattern-in-swift-ios-app-development-72d7881d0dc2

https://betterprogramming.pub/mvi-architecture-for-swiftui-apps-cff44428394

https://hryang.tistory.com/17