SwiftUI에 MV 패턴 적용하기

MVVM 문제점

  • MVVM 패턴은 View의 상태와 동작을 분리해, View가 비즈니스 로직을 직접 다루지 않고 데이터만 얻을 수 있게 해준다.
  • 각 화면마다 ViewModel이 생성되고, 각 ViewModel은 ObservableObject 프로토콜을 준수한다.
  • 이는 새로운 SoT(Source of Truth)를 정의하는 것이다.
  • 주로 서버의 데이터가 이 역할도 할 수 있기 때문에 이는 불필요한 복잡도를 만든다.
  • ViewModel의 주요 역할인 바인딩은 SwiftUI의 프로퍼티 래퍼로 View 내에서 가능하다.
  • 그렇기에 화면마다 ObservableObject를 추가하는 것은, 불필요한 SoT를 추가하고 복잡성을 증가시킨다.

요약

swiftui-mv-pattern

MVVM의 문제점으로 MV 패턴이 등장했는데, 각각의 MV 혹은 레이어는 별도의 SPM으로 분리될수 있다.

  • MV layer: 상단의 각 MV 묶음
  • Service layer: View의 상태가 없는 데이터 접근 레이어
  • Core layer: 공통 UI 혹은 코드나 리소스

MV layer

  • MV 패턴의 View는 기존 MVVM 패턴에서의 View + ViewModel의 역할을 한다.
  • View에서 Model은 @EnvironmentObject, @StateObject, @ObservedObject를 통해 사용된다.
  • View에 한정된 프로퍼티는 View 내에 @State@Binding으로 만든다.
  • View에 한정된 로직은 View 내에 함수나 연산 프로퍼티로 만든다.
@main
struct ExampleApp: App {
    private var service = Service(baseURL: URL(string: "https://url.com")!)
    
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(Model(service: service))
        }
    }
}
  • ContentView를 통해 열리게 되는 ExampleView를 보자.
  • UI는 물론, View의 상태 변수와 model 그리고 View의 로직까지 갖고 있다.
  • View가 너무 커지는 것이 걱정된다면, 아래 부분에 나올, ViewState나 Reusable 뷰를 통해 어느 정도 해소할수 있다.
struct ExampleError {
    var name = ""
    var exampleName = ""
}

struct ExampleView: View {
    @EnvironmentObject private var model: Model
    
    @State private var name = ""
    @State private var exampleName = ""
    @State private var errors = ExampleError()
    
    let example: Example?
    
    var body: some View {
        NavigationStack {
            Form {
                TextField("Name", text: $name)
                !errors.name.isEmpty ? Text(errors.name) : nil
                
                TextField("Example name", text: $exampleName)
                !errors.exampleName.isEmpty ? Text(errors.exampleName) : nil
            }
            .onAppear {
                if let example {
                    name = example.name
                    exampleName = example.exampleName
                }
            }
            .toolbar {
                ToolbarItem(placement: .navigationBarTrailing) {
                    Button(example != nil ? "Update": "None") {
                        Task {
                            if isFormValid {
                                await update()
                            }
                        }
                    }
                }
            }
        }
    }
}

extension ExampleView {
    var isFormValid: Bool {
        if name.isEmpty {
            errors.name = "Name cannot be empty!"
        }
        
        if exampleName.isEmpty {
            errors.exampleName = "Coffee name cannot be empty"
        }
        
        return errors.name.isEmpty && errors.exampleName.isEmpty
    }
        
    private func update(_ example: Example) async {
        try? await model.update(order)
    }
}
  • ViewState: View 내의 프로퍼티나 로직이 많이 필요할때 사용할 수 있다.
  • View 관련 내용을 테스트 하기에도 용이하게 해준다.
  • ViewState는 하나의 @State 프로퍼티로 만들어준다.
struct ExampleView: View {
    @State private var state = ExampleState()
    ...
}

struct ExampleState {
    var name = ""
    var exampleName = ""
    var errors = ExampleError()
    
    mutating func isValid() -> Bool {
        if name.isEmpty {
            errors.name = "Name cannot be empty!"
        }
        
        if exampleName.isEmpty {
            errors.exampleName = "Coffee name cannot be empty"
        }
        
        return errors.name.isEmpty && errors.exampleName.isEmpty
    }
}
  • Model은 ObservableObject를 준수하는 class로 만든다. (최신 버전은 Observable 매크로)
  • data 관련된 코드가 들어있고, @Published 프로퍼티로 View와 바로 연결한다.
enum ModelError: Error {
    case custom(String)
}

@MainActor
class Model: ObservableObject {
    @Published var examples: [Example] = []
    
    let service: ExampleService
    
    init(service: ExampleService) {
        self.service = service
    }
    
    func sort() { ... }
    
    func filter() { ... }
    
    func get() async throws {
        self.examples = try await service.get()
    }
    
    func update(_ example: Example) async throws {
        guard let updated = try await service.update(example) else {
            throw ModelError.custom("Unable to update.")
        }
        
        guard let index = examples.firstIndex(where: { $0.id == updated.id }) else {
            return
        }
        
        examples[index] = updated
    }
}
  • Codable, Identifiable, Equatable 등을 준수할 Entity도 Model 레이어에 포함된다.
struct Example: Codable, Identifiable {
    let id: String
    let name: String
    let exampleName: String
}

Service layer

  • Service 레이어는 데이터 접근 레이어로 API 서비스 등이 있을 수 있다.
  • Serive에 포함될 네트워크 레이어에 대한 자세한 내용은 Swift의 Network Layer를 참고하자.
enum ServiceError: Error {
    case badURL
}

struct Service {
    let baseURL: URL
    
    func get() async throws -> [Example] { ... }
    
    func update(_ example: Example) async throws -> Example? { ... }
}

Core layer

  • SharedUI: 디자인 시스템
  • Shared: 공유 가능한 코드
  • Resources: assets, localizables, fonts
  • Preview Content: SwiftUI와 Tests mocks

Reusable 뷰

Screen vs View:

  • 풀 스크린 vs 재사용 가능한 컴포넌트 뷰

Container 패턴 사용:

  • View는 Container 혹은 Presenter 중 하나다.
  • Container는 데이터를 fetch, filter 하는 등의 로직을 갖고 있다.
  • Presenter는 어디서든 재사용 가능하게 model이 아닌 부모 뷰로부터 데이터를 전달 받아야 한다.
enum Events {
    case onChecked(Int)
    case onDelete(Int)
}

struct ContainerScreen: View {
    var body: some View {
        List(1...20, id: \.self) { index in
            PresenterView(index: index) { event in
                switch event {
                    case .onChecked(let index):
                        print(index)
                    case .onDelete(let index):
                        print(index)
                }
            }
        }
    }
}

struct PresenterView: View {
    let index: Int
    let onEvent: (Events) -> Void
    
    var body: some View {
        HStack {
            Image(systemName: "square")
                .onTapGesture {
                    onEvent(.onChecked(index))
                }
            Text("\(index)")
            Spacer()
            Image(systemName: "trash")
                .onTapGesture {
                    onEvent(.onDelete(index))
                }
        }
    }
}

Test

Xcode previews:

  • View를 작은 단위로 바로 바로 테스트 할 수 있다.
#Preview {
    private let service = Service(baseURL: URL(string: "https://url.com")!)
    
    ExampleView(example: nil)
        .environmentObject(Model(service: service))
}

Extract as ViewState:

  • 위에서 ViewState를 따로 빼놓은 것은, 코드 분리도 되고 testable 하게 하기 위함도 있다.
  • 아래처럼 Unit Test를 작성할수 있을 것이다.
import XCTest
@testable import ExampleApp

class ExampleAppTests: XCTestCase {
    func testExampleStateValidationSuccess() {
        var state = ExampleState(name: "John Doe", exampleName: "SwiftUI")
        XCTAssertTrue(state.validate(), "Validation should pass when all fields are provided.")
    }
    
    func testExampleStateValidationFailure() {
        var state = ExampleState()
        XCTAssertFalse(state.validate(), "Validation should fail when fields are empty.")
        XCTAssertEqual(state.errors.name, "Name cannot be empty!")
        XCTAssertEqual(state.errors.exampleName, "Example name cannot be empty!")
    }
}

E2E test:

  • End-to-end 테스트는 UI 테스트다.
  • 아래처럼 만들어볼 수 있다.
import XCTest

class ExampleAppUITests: XCTestCase {
    func testExampleViewInteraction() {
        let app = XCUIApplication()
        app.launch()
        
        let nameField = app.textFields["Name"]
        let exampleNameField = app.textFields["Example name"]
        
        nameField.tap()
        nameField.typeText("John Doe")
        
        exampleNameField.tap()
        exampleNameField.typeText("SwiftUI")
        
        app.buttons["Update"].tap()
        
        XCTAssertTrue(app.staticTexts["Update Successful"].exists)
    }
}

참고