하루를살자

[iOS] MVVM + Clean Architecture [3] - Presentation Layer 본문

Architecture

[iOS] MVVM + Clean Architecture [3] - Presentation Layer

Kai1996 2022. 8. 22. 00:50

Presentation Layer는 ViewModel, View 들을 포함하고, ViewModel 프로퍼티인 items 들은 View에서 관찰하고 있는 오브젝트들이다 (관찰하는 오브젝트들이 업데이트되면 view에서 변한 값을 적용하여 사용자에게 보여줌).

 

즉 ViewModel 은 View에서 필요한 데이터를 관리하고 있는 것이다. 이 과정에서 ViewModel 은 entity의 raw 한 데이터 타입을 View에 바로 보여줄 수 있는 형식으로 변환하는 작업도 해준다.

  • ex) MovieListViewModel 의 item 은 Observable<[MoviesListItemViewModel]>이고, MoviesListView 인 MoviesListViewController에서 MoviewListItemViewModel의 데이터를 사용자에게 시각화뿐만 아니라 데이터의 변화가 생기면 그대로 적용하여 사용자에게 보여준다.

또한 Viewmodel 의 재사용성과 리팩터링 과정을 쉽게 만들어주기 위해 ViewModel에서 사용되는 framework 들은 foundation 만 사용하도록 해야 한다. 예를 들면, view에서 사용되는 framework가 UIKit에서 SwiftUI로 바뀌어도 viewModel에서 바꿔야 할 로직이 필요 없어지기 때문이다.

 

// Note: We cannot have any UI frameworks(like UIKit or SwiftUI) imports here. 
protocol MoviesListViewModelInput {
    func didSearch(query: String)
    func didSelect(at indexPath: IndexPath)
}

protocol MoviesListViewModelOutput {
    var items: Observable<[MoviesListItemViewModel]> { get }
    var error: Observable<String> { get }
}

protocol MoviesListViewModel: MoviesListViewModelInput, MoviesListViewModelOutput { }

struct MoviesListViewModelActions {
    // Note: if you would need to edit movie inside Details screen and update this 
    // MoviesList screen with Updated movie then you would need this closure:
    //  showMovieDetails: (Movie, @escaping (_ updated: Movie) -> Void) -> Void
    let showMovieDetails: (Movie) -> Void
}

final class DefaultMoviesListViewModel: MoviesListViewModel {
    
    private let searchMoviesUseCase: SearchMoviesUseCase
    private let actions: MoviesListViewModelActions?
    
    private var movies: [Movie] = []
    
    // MARK: - OUTPUT
    let items: Observable<[MoviesListItemViewModel]> = Observable([])
    let error: Observable<String> = Observable("")
    
    init(searchMoviesUseCase: SearchMoviesUseCase,
         actions: MoviesListViewModelActions) {
        self.searchMoviesUseCase = searchMoviesUseCase
        self.actions = actions
    }
    
    private func load(movieQuery: MovieQuery) {
        
        searchMoviesUseCase.execute(movieQuery: movieQuery) { result in
            switch result {
            case .success(let moviesPage):
                // Note: We must map here from Domain Entities into Item View Models. Separation of Domain and View
                self.items.value += moviesPage.movies.map(MoviesListItemViewModel.init)
                self.movies += moviesPage.movies
            case .failure:
                self.error.value = NSLocalizedString("Failed loading movies", comment: "")
            }
        }
    }
}

// MARK: - INPUT. View event methods
extension MoviesListViewModel {
    
    func didSearch(query: String) {
        load(movieQuery: MovieQuery(query: query))
    }
    
    func didSelect(at indexPath: IndexPath) {
        actions?.showMovieDetails(movies[indexPath.row])
    }
}

// Note: This item view model is to display data and does not contain any domain model to prevent views accessing it
struct MoviesListItemViewModel: Equatable {
    let title: String
}

extension MoviesListItemViewModel {
    init(movie: Movie) {
        self.title = movie.title ?? ""
    }
}

 

위 코드를 살펴 보면 두 개의 ViewModel: MovieListViewModel, MoviewListItemViewModel 이 어떻게 구현되어 있는지 나와있다. 

각각의 viewModel 을 분석해보자.

 

 MovieListItemViewModel

 

ViewModel의 역할은 View에 보여줄 수 있는 데이터를 관리하고 View 가 관찰하고 있는 데이터를 viewModel 이 items 란 프로퍼티로 가지고 있다고 말했었다. 하지만 모든 ViewModel 이 반드시 observable 한 프로퍼티를 가지고 있어야 한다는 것은 아니다. 만약 하나의 view 안에 또 다른 뷰(subView)가 포함되어있고 subView의 데이터 변화가  superView에서 발생되는 것이라면 ViewModel 또한 View에 맞추어서 layer를 가지는 형태로 구현할 수 있다. 

 

현재 MovieListViewController의 ViewModel은 MoviewListViewModel로, MovieListItemCell 은 MoviewListItemViewModel에 사용되는 것을 볼 수 있다. 이런 구조는 ViewModel의 책임 두 가지: "사용자 Input 에 의한 데이터의 변화 적용", "데이터의 변환" 을 MoviewListViewModel, MoviewListItemViewModel에 각각 나누어 준 것을 확인할 수 있다. 

 

 

MoiveListViewModel

 

UI(View) 들은 비즈니스 로직/애플리케이션 로직 (비즈니스 모델, Usecase) 들을 가지고 있어선 안된다. View는 seperation of concerns (관심사 분리)에 따라 이러한 비즈니스 로직을 ViewModel에 구현시켜놓는다. 따라서 사용자의 input에 따라 구현되어 있는 viewmodel의 메서드만 호출하면 된다. 

 

MoivewListViewModel의 특이점을 꼽자면 Input, Output이라는 프로토콜 사용과, Actions 라는 structure를 선언했다는 것이다. 

 

Input 인터페이스: 사용자가 어떤 행동을 했는지에 대한 메서드를 가지고 있고, 이 메서드의 비즈니스 로직은 ViewModel에서 구현된다.

Output 인터페이스: 사용자의 Input 에 의한 변화를 Observable 한 프로퍼티를 통해 View에게 알려주는 역할을 한다.

 

이런 Input, Output 인터페이스를 만든 것은 ViewController의 test를 보다 쉽게 하기 위해서이다. Interface를 명시해놓으면  Mock 버전의 ViewModel을 쉽게 만들어 줄 수 있다. 이는 Dependency Injection 계념을 ViewController의 생성 시에 적용할 수 있게 되고, ViewController의 의존성을 분리시켜준다. 그 후 가짜 데이터, Mock ViewModel을 이용하여 ViewController 가 예상한 behavior를 보여주는지 확인할 수 있게 된다. 

 

  • ViewController unitTesting 예시
class MoviesListViewTests: FBSnapshotTestCase {
	let movies: [Movie] = [
            Movie.stub(id: "1", title: "title1", posterPath: "/1", overview: "overview1"),
            Movie.stub(id: "2", title: "title2", posterPath: "/2", overview: "overview2"),
            Movie.stub(id: "3", title: "title3", posterPath: "/3", overview: "overview3")
    		]
    
    func test_whenHasItems_thenShowItemsOnScreen() {
        // given
        let items = movies.map(MoviesListItemViewModel.init)

        //예제 (사용되는 매개변수가 실제예제 와 다릅니다)
        let vc = MoviesListViewController.create(
            with: MoviesListViewModelMock.stub(items: Observable(items)),
            posterImagesRepository: PosterImagesRepositoryMock())

        // then
        FBSnapshotVerifyView(vc.view)
    }
}


struct MoviesListViewModelMock: MoviesListViewModel {
    // MARK: - Input
    func didSearch(query: String) {}
    func didSelect(at indexPath: IndexPath) {}

    // MARK: - Output
    var items: Observable<[MoviesListItemViewModel]>
    var error: String

    static func stub(items: Observable<[MoviesListItemViewModel]> = Observable([]),
                     error: Observable<String> = Observable(""),
                     errorTitle: String = NSLocalizedString("Error", comment: "")) -> Self {
           
        .init(items: items,
              error: error,
              errorTitle: errorTitle)
    }
}

그럼 Actions 은 어떤 역할을 하는걸까?  

Flow Coordinator 패턴을 사용해서 Scene 들 간의 이동에서 필요한 정보들을 전달하는 Closure 들로 구성되어 있다. 또한 Structure로 action을 감싸주어서 간편하게 action을 추가할 수 있게 만들어 주었다. 

 

  • Flow Coordinator
import UIKit

protocol MoviesSearchFlowCoordinatorDependencies  {
    func makeMoviesListViewController(actions: MoviesListViewModelActions) -> MoviesListViewController
    func makeMoviesDetailsViewController(movie: Movie) -> UIViewController
}

final class MoviesSearchFlowCoordinator {
    
    private weak var navigationController: UINavigationController?
    private let dependencies: MoviesSearchFlowCoordinatorDependencies

    private weak var moviesListVC: MoviesListViewController?

    init(navigationController: UINavigationController,
         dependencies: MoviesSearchFlowCoordinatorDependencies) {
        self.navigationController = navigationController
        self.dependencies = dependencies
    }
    
    func start() {
        // Note: here we keep strong reference with actions, this way this flow do not need to be strong referenced
        let actions = MoviesListViewModelActions(showMovieDetails: showMovieDetails)
        let vc = dependencies.makeMoviesListViewController(actions: actions)

        navigationController?.pushViewController(vc, animated: false)
        moviesListVC = vc
    }

    private func showMovieDetails(movie: Movie) {
        let vc = dependencies.makeMoviesDetailsViewController(movie: movie)
        navigationController?.pushViewController(vc, animated: true)
    }

}

 

0. Coordinator 란? 

화면전환하는 로직을 ViewController에 사용자의 Input에 따라 전환시키는 로직을 작성해본 경험이 있을 것이다. Coordinator 패턴은 ViewController에 있던 일련의 화면 전환 Navigation 로직을 따로 관리한다. Coordinator 패턴을 사용하면 기존 ViewController에 강하게 결합되어 있어 있던 Navigation 로직을 느슨하게 만든다. 이는 ViewController 간들의 분리를 해주기 때문에 내비게이션 로직의 재사용성을 높여준다. 또한  ViewController 가 비대해지는 것을 조금이나마 방지해줄 수 있다. 

 

1. MoviesSearchFlowCoordinatorDependencies

CoordinatorDpendencies의 인터페이스들은 이후 DIcontainer에서 구현될 예정이다. 위 코드에선 FlowCoordinator의 엔진 역할을 해주는 dependency 로서 사용되고 있다. Dependencies의 인터페이스는 Actions에 구현되어 있는 메서드의 함수 타입에 맞추어서 정의해 Actions 생성을 생성할 때 사용될 수 있도록 구현한다. 

 

2. MoviesSearchFlowCoordinator 

매개변수로 전해오는 값을 이용해서 새로운 ViewController 를 생성, 화면 전환 역할을 해준다. 

  • start() 
    • Actions 를 인스턴스화 시키고 메인으로 사용되는 ViewController, (현재 예시에선 MoviesListViewController)를 NavigationViewController에 stack 시킨다. 
  • showMovieDetails(movie: Movie)
    • Actions 가 인스턴스화 될때 (Start() 시점)에 initializer로 사용된다. (함수 타입을 사용하는 것을 처음 봐서 어색했다) 
    • Actions에 등록된 클로저가 ViewModel에서 사용될 때, 매개변수로 전해져 오는 movie를 사용해 MoviewDetailViewController를 생성, navigation stack에 추가한다.

 

*Picteresting 리팩터링 작업은 다음 포스트에서 계속..


참조 

https://tech.olx.com/clean-architecture-and-mvvm-on-ios-c9d167d9f5b3

 

Clean Architecture and MVVM on iOS

When we develop software it is important to not only use design patterns, but also architectural patterns. There are many different…

tech.olx.com

https://www.vadimbulavin.com/unit-testing-view-controller-uiviewcontroller-and-uiview-in-swi ft/

 

Unit Testing View Controllers and Views in Swift

Learn how to unit test UIViewController and UIView in Swift 5 with Xcode and XCTest. We'll cover: benefits of unit testing view controllers; what we should be unit testing; how to design a view controller for testability; and sharpen our knowledge by writi

www.vadimbulavin.com

 

 

 

 

Comments