iOS

[iOS/SwiftUI] MVVM에 대해 알아보자!

녕이 2023. 2. 9. 22:50
728x90

 

 

Stanford Lecture를 보고 이해한 내용을 나름대로 정리해 보았습니다.

 

MVVM 구성

  • Model 
    • 앱의 데이터와 로직 관리
    • VM에게 데이터/상태를 알려준다
    • Model은 View와 연결되어있지 않다. (직접 소통 불가)
    • UI로부터 독립적 (View와 소통불가한 이유, View == UI)
    • single source of truth (오직 Model에만 저장)
    • What the app is and does?
  • View
    • UI 로직이 VM에 있기 때문에 간결
    • 사용자와의 상호작용을 통해 이벤트가 일어나면 VM에 알림
    • Model의 데이터, 상태를 반영해서 사용자에게 보여줌
    • Model의 데이터를 저장하지 않고, 일시적인 상태만 가지고 있음
    • 업데이트된 데이터 값을 바탕으로 해당 데이터를 사용하는 View를 redraw
    • VM이 publishing(@Published)한 것을 subscribing/observing
    • -> @StateObject, @EnvironmentObject, @ObservedObject
  • ViewModel
    • View와 Model을 연결
    • Model 혹은 View의 변화에 View 혹은 Model이 반응하도록 함
    • View의 코드를 간결하게 해 준다. (UI 로직이 VM에 있기 때문)
    • Model 데이터를 View가 원하는 데이터 형태로 변환해서 제공한다. (번역가)
    • 사용자의 상호작용을 View가 보내주면 그에 맞는 이벤트를 처리하고 Model의 Read/Update/Delete를 담당한다.
    • View가 무엇인지, 무엇을 하는지 절대 알지 못한다.
    • -> 이 아키텍처를 더욱 testable로 만들고 complexity를 제거한다.

 

MVVM 규칙

  • View는 VM을 거쳐 Model의 데이터를 가져온다
  • VM은 Model에 대한 데이터를 저장하면 안 된다
  • Model은 single source of truth (오직 Model에만 데이터 저장)
  • VM은 Model에서 View로 데이터 전달

 

Model -> VM -> View

  1. VM은 Model의 변화를 계속해서 감지
  2. VM이 변화를 감지하면, 앱 전체에 “something changed”를 알린다. something 연관된 View는 이를 알아차린다.
  3. View는 VM을 통해 Model의 현재 상태를 확인할 수 있다. → VM을 통해 Model과 View가 동기화!
  4. VM이 publishing 하는 것을 View가 subscribing 하고 있기 때문에 View는 VM의 “something changed” 알림을 감지할 수 있다. publishing 된 데이터를 subscribing하고 있기 때문에 변화된 데이터를 알 수 있고, 이를 바탕으로 view를 redrawing 한다.

 

View -> VM -> Model

 

사용자가 View(화면)에 어떠한 행동을 했을 때 그에 반응하여 Model의 데이터가 바뀌어야 하는데 이럴 땐 어떻게 해야 할까?

  1. VM에 해당 이벤트에 적절한 함수를 만들어놓는다.
  2. View는 “사용자가 View서 뭔갈 했고 이 이벤트에 대한 기능을 해줘!”라고 VM에게 말하면 VM은 그에 대한 행동을 한다. 예를 들어, 사용자가 View에 있는 + 버튼을 누르면 View는 클래스 VM의 함수(addNumber())를 호출해서 이벤트에 대한 행동을 실행한다.
  3. 사용자의 행동에 의해 Model 데이터가 변화해야 한다면, VM이 Model에 접근해 수정하도록 한다.

이렇게 되면 또, VM은 Model의 변화를 감지하고 앱 전체에 변경된 사실을 publishing → 그 데이터를 subscribing 하고 있는 View는 Model의 변화를 눈치채고, body에 변화된 데이터를 가지고 와서 화면을 redrawing

 

 

SwiftUI MVVM 예제

 

참고 코드: https://github.com/SwiftfulThinking/SwiftUI-Todo-List-MVVM-UserDefaults

 

GitHub - SwiftfulThinking/SwiftUI-Todo-List-MVVM-UserDefaults: Full source code for the SwiftUI Todo List. The complete tutorial

Full source code for the SwiftUI Todo List. The complete tutorial is available for free on my YouTube channel @SwiftfulThinking. The project uses MVVM architecture and UserDefaults to persist data....

github.com

 

Model

앱을 구성하는 데이터를 Model에 구성하기

import Foundation

//앱 전체를 관통하는 데이터를 여기에 보관.

struct ItemModel: Identifiable, Codable {
    let id: String
    let title: String
    let isCompleted: Bool
    
    init(id: String = UUID().uuidString, title: String, isCompleted: Bool) {
        self.id = id
        self.title = title
        self.isCompleted = isCompleted
    }
    
    func updateCompletion() -> ItemModel {
        return ItemModel(id: id, title: title, isCompleted: !isCompleted)
    }   
}

 

ViewModel

View에 맞게 Model 데이터 변경, 데이터 불러오기, 데이터 변경 가능

import Foundation

//Model 데이터를 가지고 하는 모든 기능을 여기서 하도록 한다.
//네트워킹 또한 VM을 거쳐서 진행
//View가 VM에 있는 메소드를 호출해서 Model의 데이터를 가져오기 or 형식 변경할 수 있음

//ObservableObject protocol 
//-> 객체의 값이 바뀌기 전에 알려주는 Publisher를 의미하며, SwiftUI가 화면을 다시 그리는 것을 가능하도록
class ListViewModel: ObservableObject {
    //View에 Publishing할 ItemModel 배열 데이터
    //이 데이터를 VM에서 Publishing하고 View에서는 이를 Subscribing해서 데이터 변화를 감지한다.
    @Published var items: [ItemModel] = [] {
        didSet { //값이 변하면 saveItems() 메소드 호출해서 Model 데이터 저장
            saveItems()
        }
    }
    
    let itemsKey: String = "items_list"
    
    init() {
        getItems()
    }
    
    func getItems() {
        guard let data = UserDefaults.standard.data(forKey: itemsKey),
              let savedItems = try? JSONDecoder().decode([ItemModel].self, from: data)
        else { return }

        self.items = savedItems
    }
    
    func deleteItem(indexSet: IndexSet) {
        items.remove(atOffsets: indexSet)
    }
    
    func moveItem(from: IndexSet, to: Int) {
        items.move(fromOffsets: from, toOffset: to)
    }
    
    func addItem(title: String) {
        let newItem = ItemModel(title: title, isCompleted: false)
        items.append(newItem)
    }
    
    func updateItem(item: ItemModel) {
        if let index = items.firstIndex(where: { $0.id == item.id }) {
            items[index] = item.updateCompletion() //item에 대한 직접적인 데이터 변경은 Model에서 이루어지도록 함
        }
    }
    
    func saveItems() {
        if let encodedData = try? JSONEncoder().encode(items) {
            UserDefaults.standard.set(encodedData, forKey: itemsKey)
        }
    }
}

 

View

VM 인스턴스를 생성해서 VM이 Published 한 파라미터들을 관찰할 수 있고, 메서드를 호출해서 기능을 수행할 수 있다.

VM을 통해, 데이터를 수정, 저장, 읽기를 할 수 있다.

import SwiftUI

//Top Level View
@main
struct TodoListApp: App {
    @StateObject var listViewModel: ListViewModel = ListViewModel() 
    //ViewModel 인스턴스 생성 -> StateObject 프로퍼티 랩퍼를 사용해서 ViewModel이 published한 프로퍼티를 관찰하고 데이터가 변경되면 그에 맞게 화면 redrawing

    var body: some Scene {
        WindowGroup {
            NavigationView {
                ListView()
            }
            .navigationViewStyle(StackNavigationViewStyle())
            .environmentObject(listViewModel) 
            //environmentObject는 listViewModel을 View 사이를 통과할 필요없이 모든 View에서 이 인스턴스를 사용할 수 있게 한다. 
            //대신, 사용하는 View에서는 해당 인스턴스를 @EnvironmentObject로 선언해줘야 한다.
        }
    }
}

 

ListView에서 사용자 이벤트를 받는다. (onTapGesture)

VM에 작성해 놓은 updateItem 호출

import SwiftUI

struct ListView: View {
    
    @EnvironmentObject var listViewModel: ListViewModel //TodoListApp View에서 생성한 ListViewModel을 사용. 본체는 TodoListApp에 있음
    
    var body: some View {
        ZStack {
            if listViewModel.items.isEmpty {
                NoItemsView()
                    .transition(AnyTransition.opacity.animation(.easeIn))
            } else {
                List {
                    ForEach(listViewModel.items) { item in
                        ListRowView(item: item)
                            .onTapGesture { //사용자가 List를 탭하면 (사용자 이벤트)
                                withAnimation(.linear) {
                                    listViewModel.updateItem(item: item) //사용자가 탭한 item을 View가 VM에 넘기면서 해당 기능 수행하도록 지시
                                }
                            }
                    }
                    .onDelete(perform: listViewModel.deleteItem)
                    .onMove(perform: listViewModel.moveItem)
                }
                .listStyle(PlainListStyle())
            }
        }
        .navigationTitle("Todo List 📝")
        .navigationBarItems(
            leading: EditButton(),
            trailing:
                NavigationLink("Add", destination: AddView())
            )
    }
}

 

여기의 Item은 상위 View이 ListView에서 VM을 통해 Model의 데이터 ItemModel

import SwiftUI

struct ListRowView: View {
    let item: ItemModel //ListView에서 넘겨준 ItemModel 객체 (Model에 있는 데이터 사용)
    
    var body: some View {
        HStack {
            Image(systemName: item.isCompleted ? "checkmark.circle" : "circle")
                .foregroundColor(item.isCompleted ? .green : .red)
            Text(item.title)
            Spacer()
        }
        .font(.title2)
        .padding(.vertical, 8)
    }
}

 

여기서도 VM을 통해 메서드 호출하며 데이터 저장(addItem())

import SwiftUI

struct AddView: View {
    @Environment(\.presentationMode) var presentationMode
    @EnvironmentObject var listViewModel: ListViewModel
    @State var textFieldText: String = ""
    
    @State var alertTitle: String = ""
    @State var showAlert: Bool = false
        
    var body: some View {
        ScrollView {
            VStack {
                TextField("Type something here...", text: $textFieldText)
                    .padding(.horizontal)
                    .frame(height: 55)
                    .background(Color(UIColor.secondarySystemBackground))
                    .cornerRadius(10)
                
                Button(action: saveButtonPressed, label: {
                    Text("Save".uppercased())
                        .foregroundColor(.white)
                        .font(.headline)
                        .frame(height: 55)
                        .frame(maxWidth: .infinity)
                        .background(Color.accentColor)
                        .cornerRadius(10)
                })
            }
            .padding(14)
        }
        .navigationTitle("Add an Item 🖊")
        .alert(isPresented: $showAlert, content: getAlert)
    }
    
    // MARK: FUNCTIONS
    
    func saveButtonPressed() {
        if textIsAppropriate() {
            listViewModel.addItem(title: textFieldText)
            presentationMode.wrappedValue.dismiss()
        }
    }
    
    func textIsAppropriate() -> Bool {
        if textFieldText.count < 3 {
            alertTitle = "Your new todo item must be at least 3 characters long!!! 😨😰😱"
            showAlert.toggle()
            return false
        }
        return true
    }
    
    func getAlert() -> Alert {
        return Alert(title: Text(alertTitle))
    }   
}

 

 

 

다음에는 UIKit으로 위의 내용을 MVVM 디자인 패턴으로 설계해서 만들어보도록 하겠다!! 화이팅이닷~!

 

 

 

 

728x90