[iOS/SwiftUI] MVVM에 대해 알아보자!
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
- VM은 Model의 변화를 계속해서 감지
- VM이 변화를 감지하면, 앱 전체에 “something changed”를 알린다. something 연관된 View는 이를 알아차린다.
- View는 VM을 통해 Model의 현재 상태를 확인할 수 있다. → VM을 통해 Model과 View가 동기화!
- VM이 publishing 하는 것을 View가 subscribing 하고 있기 때문에 View는 VM의 “something changed” 알림을 감지할 수 있다. publishing 된 데이터를 subscribing하고 있기 때문에 변화된 데이터를 알 수 있고, 이를 바탕으로 view를 redrawing 한다.
View -> VM -> Model
사용자가 View(화면)에 어떠한 행동을 했을 때 그에 반응하여 Model의 데이터가 바뀌어야 하는데 이럴 땐 어떻게 해야 할까?
- VM에 해당 이벤트에 적절한 함수를 만들어놓는다.
- View는 “사용자가 View서 뭔갈 했고 이 이벤트에 대한 기능을 해줘!”라고 VM에게 말하면 VM은 그에 대한 행동을 한다. 예를 들어, 사용자가 View에 있는 + 버튼을 누르면 View는 클래스 VM의 함수(addNumber())를 호출해서 이벤트에 대한 행동을 실행한다.
- 사용자의 행동에 의해 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 디자인 패턴으로 설계해서 만들어보도록 하겠다!! 화이팅이닷~!