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